Repository navigation
Expand file tree
/
Copy pathcreate.ts
More file actions
145 lines (139 loc) · 10.2 KB
/
Copy pathcreate.ts
File metadata and controls
145 lines (139 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
import { enrichedHtmlPins, installWithEnrichedHtmlPins } from "./enriched-html-pins.ts";
import { writePackageConfig } from "./package-manager-config.ts";
import { initializeTemplate } from "./init-template.cjs";
import { installedSparkVersion, migrateProject, refreshPointers } from "./migrations.cjs";
import { creationManifest } from "./local.ts";
import { installedRelease, materializeReleasePackages, packageSources, verifyMaterializedReleasePackages } from "./release.ts";
import { packageManager, applyOverrides, localArchive, managerCommand, type PackageManager } from "./package-manager.ts";
import { run } from "./commands.ts";
import { packArchive } from "./pack-archive.ts";
import { nodeCommand, spawnProcess } from "./process.ts";
import { checkExpoDesktopNode } from "./expo-node.ts";
import { configureExample, type Example } from "./examples.ts";
import { hostPlatform, type AppPlatform } from "./platform.ts";
import { appendFileSync, cpSync, existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
import path from "node:path";
import os from "node:os";
import { createInterface } from "node:readline";
import { Readable } from "node:stream";
import { readJson } from "./json.ts";
// Resolve relative to the installed CLI so packed consumers use the same starter.
const template = path.resolve(import.meta.dirname, "../templates/blank-typescript");
// Yarn PnP's fs patch rejects the async iterables Expo Desktop extracts templates with.
export const pnpWriteFile = `--import=data:text/javascript,${encodeURIComponent("import fs from 'node:fs'; const write = fs.promises.writeFile; fs.promises.writeFile = async (file, data, options) => write(file, data?.[Symbol.asyncIterator] ? Buffer.concat(await Array.fromAsync(data)) : data, options);")}`;
export async function refreshLocalPackages(root: string, manifest: string, selectedManager?: PackageManager) {
const pkg = readJson(path.join(root, "package.json"));
// The template owns the tested compatibility matrix, not module inclusion.
const defaults: { overrides: Record<string, string>; dependencies: Record<string, string> } = readJson(path.join(template, "package.json"));
const pins = { ...Object.fromEntries(Object.entries(defaults.overrides).filter(([name]) => !(name in enrichedHtmlPins) && name !== "react-dom")), ...Object.fromEntries(Object.entries(defaults.dependencies)
.filter(([name]) => name.startsWith("expo-desktop"))) };
for (const [name, version] of Object.entries(pins)) {
if (pkg.dependencies?.[name]) pkg.dependencies[name] = version;
}
const manager = packageManager(root, selectedManager);
const overrides = { ...pins };
for (const [name, file] of Object.entries(readJson(manifest))) {
const archive = localArchive(path.resolve(path.dirname(manifest), file as string));
overrides[name] = archive;
if (pkg.dependencies?.[name]) pkg.dependencies[name] = archive;
if (pkg.allowScripts?.[name]) pkg.allowScripts[archive] = true;
}
applyOverrides(pkg, overrides, manager);
writePackageConfig(root, pkg, manager, root, [...Object.keys(enrichedHtmlPins), "react-dom"]);
await installWithEnrichedHtmlPins(root, manager);
upgradeManagedEntry(root);
}
/** A placeholder ID for new apps: lowercase letters, digits and hyphens, starting with a letter. */
export const exampleBundleId = (name: string) => `com.example.${name.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/^[^a-z]+/, "") || "app"}`;
export async function create(root: string, archiveManifest: string | undefined, platform: AppPlatform = hostPlatform(), universal = false, example?: Example, selectedManager?: PackageManager, bundleId?: string) {
if (!(universal || example) && !["macos", "windows"].includes(platform)) throw new Error("Use create --universal for mobile/web targets");
archiveManifest ??= creationManifest();
const manager = packageManager(process.cwd(), selectedManager);
await checkExpoDesktopNode(path.resolve(import.meta.dirname, ".."));
const variant = universal || example ? "universal" : platform === "macos" ? "blank-typescript" : "windows";
const source = path.resolve(import.meta.dirname, "../templates", variant);
const temporary = mkdtempSync(path.join(os.tmpdir(), "spark-create-"));
const templateFile = path.join(temporary, "template.tgz");
// Resolve archives on the recipient machine, immediately before Expo extracts
// and installs the template. No producer-machine paths enter the SDK bundle.
try {
cpSync(source, temporary, { recursive: true });
refreshPointers(temporary);
const pkg = readJson(path.join(temporary, "package.json"));
const release = archiveManifest ? undefined : installedRelease();
const archives = packageSources(archiveManifest, release);
if (pkg.dependencies["@react-native-runtimes/core"] && !archives["@react-native-runtimes/core"]) throw new Error("This SDK lacks the patched Runtimes archive. Repack or install the complete SDK.");
if (release) await materializeReleasePackages(release, temporary);
const overrides: Record<string, string> = {};
for (const [name, file] of Object.entries(archives)) {
overrides[name] = file;
if (pkg.dependencies[name]) pkg.dependencies[name] = file;
// Spark itself comes from the registry as a bare version, which npm would read as a package name.
if (pkg.allowScripts?.[name] && file.startsWith("file:")) pkg.allowScripts[file] = true;
}
applyOverrides(pkg, overrides, manager);
writePackageConfig(temporary, pkg, manager, root);
if (manager === "yarn") writeFileSync(path.join(temporary, ".yarnrc.yml"), "nodeLinker: node-modules\n");
await packArchive(temporary, templateFile);
if (release) verifyMaterializedReleasePackages(release, temporary);
// The upstream CLI owns validation, extraction, app IDs, install, and Git setup.
// Also initialize explicitly below: pnpm/Bun can disable lifecycle scripts.
const name = path.basename(root);
// Expo Desktop misreads npm 12's record-shaped pack metadata for a local tarball.
// Use the compatible npm executable for upstream extraction; the chosen manager installs.
const npmBin = path.join(import.meta.dirname, "npm-bin");
// Yarn PnP (`yarn dlx`) loads Expo Desktop from zip archives.
const pnp = !!process.versions.pnp;
// Expo needs file: to distinguish Windows drive paths from npm package names.
const child = spawnProcess(nodeCommand(path.resolve(import.meta.dirname, ".."), "expo-desktop", "expo-desktop", [
"create-app", root, "--template", `file:${templateFile}`, "--yes", "--no-agents-md",
"--display-name", name, "--rdns", bundleId ?? exampleBundleId(name), ...(pnp ? ["--no-install"] : []),
]), { cwd: process.cwd(), env: { ...process.env, PATH: `${npmBin}${path.delimiter}${process.env.PATH ?? ""}`, npm_config_user_agent: `${manager}/spark`, CI: "1", ...(pnp ? { NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} ${pnpWriteFile}` } : {}), ...(manager === "yarn" ? { YARN_ENABLE_IMMUTABLE_INSTALLS: "false" } : {}) }, stdout: "pipe", stderr: "inherit" });
// Spark owns the next steps; Expo's final block requires an unnecessary prebuild.
let projectReady = false;
for await (const line of createInterface({ input: Readable.from(child.stdout!), crlfDelay: Infinity })) {
if (line.includes("✅ Your project is created!")) projectReady = true;
if (!projectReady) process.stdout.write(line + "\n");
}
if (await child.exited) throw new Error("Expo Desktop could not create the app. See its output above.");
if (pnp) {
// Expo Desktop's install would inherit the PnP runtime of `yarn dlx`; install without it.
process.env.NODE_OPTIONS = process.env.NODE_OPTIONS?.replace(/--(?:require|experimental-loader)[ =]("[^"]*\.pnp\.[^"]*"|\S*\.pnp\.\S*)/g, "") ?? "";
if (manager === "yarn") appendFileSync(path.join(root, "yarn.lock"), "");
await run(root, managerCommand(manager, ["install"]));
}
} finally { rmSync(temporary, { recursive: true, force: true }); }
if (!archiveManifest) {
const release = installedRelease();
if (release) verifyMaterializedReleasePackages(release, root);
const ignoreFile = path.join(root, ".gitignore");
const ignore = existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "";
if (!ignore.includes("!/spark-packages/**")) writeFileSync(ignoreFile, `${ignore}${ignore.endsWith("\n") || !ignore ? "" : "\n"}\n# Spark verified release package inputs\n!/spark-packages/\n!/spark-packages/**\n`);
}
// Upstream can report success after an install failure. Require a usable CLI.
if (!existsSync(path.join(root, "node_modules/@legendapp/spark/package.json"))) throw new Error(`Dependency installation failed. Run ${manager} install in ${root} and retry.`);
initializeTemplate(root);
const installed = installedSparkVersion(root);
// A new starter already has the current release's configuration and source.
migrateProject(root, installed, true, { fromVersion: installed });
if (example) {
if (!archiveManifest) { const release = installedRelease(); if (release) verifyMaterializedReleasePackages(release, root); }
await configureExample(root, example);
}
const rename = bundleId || variant === "windows" ? "" : `\n\nBefore you ship, change bundleIdentifier in desktop.config.json from ${exampleBundleId(path.basename(root))} to an ID you own.`;
console.log(`Created ${root}.\n\n cd ${JSON.stringify(root)}\n ${manager} run ${platform}${rename}`);
}
export function upgradeManagedEntry(root: string) {
const oldMetro = `const { makeMetroConfig } = require("expo-desktop-metro-config");
const { gate } = require("@legendapp/spark-cli/src/metro-gate.cjs");
const config = makeMetroConfig(__dirname);
config.server = { ...config.server, enhanceMiddleware: (middleware) => gate(__dirname, middleware) };
module.exports = config;
`;
const oldEntry = 'import { registerRootComponent } from "expo";\nimport App from "./App";\nregisterRootComponent(App);\n';
const metro = path.join(root, "metro.config.js"), entry = path.join(root, "index.ts");
// Upgrade only the exact generated pair; customized entries remain user-owned.
if (existsSync(metro) && existsSync(entry) && readFileSync(metro, "utf8") === oldMetro && readFileSync(entry, "utf8") === oldEntry) {
for (const file of ["metro.config.js", "index.ts"]) cpSync(path.join(template, file), path.join(root, file));
}
}