History

misc stuff

Rhys Sullivan committed 8 months ago

Commit f045f3c1 parent 20f99de16 files · +744 −1741Browse files

Showing the changes to apps/executor (8 of 16 files). Show all

Unified · Split

8 files
  1. apps/executor/package.json +2 −6
  2. apps/executor/src/distribution/artifact.ts +6 −74
  3. apps/executor/src/distribution/build-package.ts +0 −58
  4. apps/executor/src/distribution/build-portable.ts +0 −78
  5. apps/executor/src/distribution/build-release.ts +0 −137
  6. apps/executor/src/distribution/portable.test.ts +0 −232
  7. apps/executor/src/distribution/portable.ts +0 −607
  8. apps/executor/src/distribution/publish.ts +224 −0
apps/executor/package.json+2−6
@@ -29,12 +29,8 @@ · 28 unmodified lines
29 "test": "vitest run",29 "test": "vitest run",
30 "test:watch": "vitest",30 "test:watch": "vitest",
31 "start": "bun src/cli/main.ts",31 "start": "bun src/cli/main.ts",
32 "build:dist": "bun src/distribution/build-package.ts",32 "release:publish:dry-run": "bun src/distribution/publish.ts --dry-run",
33 "pack:dist": "bun src/distribution/build-package.ts && npm pack ./dist/npm",33 "release:publish": "bun src/distribution/publish.ts"
34 "publish:dist:dry-run": "bun src/distribution/build-package.ts && npm publish ./dist/npm --dry-run",
35 "publish:dist": "bun src/distribution/build-package.ts && npm publish ./dist/npm",
36 "build:portable": "bun src/distribution/build-portable.ts",
37 "build:release": "bun src/distribution/build-release.ts"
38 },34 },
39 "dependencies": {35 "dependencies": {
40 "@effect/cli": "catalog:",36 "@effect/cli": "catalog:",
apps/executor/src/distribution/artifact.ts+6−74
@@ -7,7 +7,6 @@ import { dirname, join, resolve } from "node:path"; · 6 unmodified lines
7import { readDistributionPackageMetadata, repoRoot } from "./metadata";7import { readDistributionPackageMetadata, repoRoot } from "./metadata";
8 8
9const defaultOutputDir = resolve(repoRoot, "apps/executor/dist/npm");9const defaultOutputDir = resolve(repoRoot, "apps/executor/dist/npm");
10const DENO_INSTALL_URL = "https://docs.deno.com/runtime/getting_started/installation/";
11 10
12 11
13export type BuildDistributionPackageOptions = {12export type BuildDistributionPackageOptions = {
@@ -72,26 +71,16 @@ const runCommand = async (input: CommandInput): Promise<void> => { · 58 unmodified lines
72 );71 );
73};72};
74 73
75const resolvePGliteDistDir = (): string => {
76 const requireFromControlPlane = createRequire(
77 join(repoRoot, "packages/control-plane/package.json"),
78 );
79 const entryPath = requireFromControlPlane.resolve("@electric-sql/pglite");
80 const distDir = dirname(entryPath);
81
82 if (!existsSync(distDir)) {
83 throw new Error(`Unable to locate PGlite dist directory at ${distDir}`);
84 }
85
86 return distDir;
87};
88
89const resolveQuickJsWasmPath = (): string => {74const resolveQuickJsWasmPath = (): string => {
90 const requireFromQuickJsRuntime = createRequire(75 const requireFromQuickJsRuntime = createRequire(
91 join(repoRoot, "packages/runtime-quickjs/package.json"),76 join(repoRoot, "packages/runtime-quickjs/package.json"),
92 );77 );
93 const wasmPath = requireFromQuickJsRuntime.resolve(78 const quickJsPackagePath = requireFromQuickJsRuntime.resolve(
94 "@jitl/quickjs-wasmfile-release-sync/wasm",79 "quickjs-emscripten/package.json",
80 );
81 const wasmPath = resolve(
82 dirname(quickJsPackagePath),
83 "../@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm",
95 );84 );
96 85
97 if (!existsSync(wasmPath)) {86 if (!existsSync(wasmPath)) {
@@ -131,9 +120,6 @@ const createPackageJson = (input: { · 33 unmodified lines
131 bin: {120 bin: {
132 executor: "bin/executor.js",121 executor: "bin/executor.js",
133 },122 },
134 scripts: {
135 postinstall: "node ./bin/postinstall.js",
136 },
137 files: [123 files: [
138 "bin",124 "bin",
139 "resources",125 "resources",
@@ -154,48 +140,6 @@ const createLauncherSource = () => [ · 14 unmodified lines
154 "",140 "",
155].join("\n");141].join("\n");
156 142
157const createPostinstallSource = () => [
158 "#!/usr/bin/env node",
159 'import { spawnSync } from "node:child_process";',
160 'import { existsSync } from "node:fs";',
161 'import { join } from "node:path";',
162 "",
163 `const denoInstallUrl = ${JSON.stringify(DENO_INSTALL_URL)};`,
164 "",
165 "const resolveDenoExecutable = () => {",
166 ' const configured = process.env.DENO_BIN?.trim();',
167 ' if (configured) {',
168 ' return configured;',
169 ' }',
170 "",
171 ' const home = process.env.HOME?.trim();',
172 ' if (home) {',
173 ' const installedPath = join(home, ".deno", "bin", "deno");',
174 ' if (existsSync(installedPath)) {',
175 ' return installedPath;',
176 ' }',
177 ' }',
178 "",
179 ' return "deno";',
180 "};",
181 "",
182 "const isDenoAvailable = (executable) => {",
183 ' const result = spawnSync(executable, ["--version"], {',
184 ' stdio: "ignore",',
185 ' timeout: 5000,',
186 ' });',
187 "",
188 ' return result.error === undefined && result.status === 0;',
189 "};",
190 "",
191 "const denoExecutable = resolveDenoExecutable();",
192 "",
193 "if (!isDenoAvailable(denoExecutable)) {",
194 ' console.warn(`Deno not found, if you want to use deno for sandboxing install it from ${denoInstallUrl} otherwise JS will execute in QuickJS`);',
195 "}",
196 "",
197].join("\n");
198
199export const buildDistributionPackage = async (143export const buildDistributionPackage = async (
200 options: BuildDistributionPackageOptions = {},144 options: BuildDistributionPackageOptions = {},
201): Promise<DistributionPackageArtifact> => {145): Promise<DistributionPackageArtifact> => {
@@ -204,13 +148,8 @@ export const buildDistributionPackage = async ( · 2 unmodified lines
204 const binDir = join(packageDir, "bin");148 const binDir = join(packageDir, "bin");
205 const resourcesDir = join(packageDir, "resources");149 const resourcesDir = join(packageDir, "resources");
206 const webDir = join(resourcesDir, "web");150 const webDir = join(resourcesDir, "web");
207 const migrationsDir = join(resourcesDir, "migrations");
208 const bundlePath = join(binDir, "executor.mjs");151 const bundlePath = join(binDir, "executor.mjs");
209 const launcherPath = join(binDir, "executor.js");152 const launcherPath = join(binDir, "executor.js");
210 const postinstallPath = join(binDir, "postinstall.js");
211 const pgliteDistDir = resolvePGliteDistDir();
212 const pgliteDataPath = join(pgliteDistDir, "pglite.data");
213 const pgliteWasmPath = join(pgliteDistDir, "pglite.wasm");
214 const quickJsWasmPath = resolveQuickJsWasmPath();153 const quickJsWasmPath = resolveQuickJsWasmPath();
215 const webDistDir = join(repoRoot, "apps/web/dist");154 const webDistDir = join(repoRoot, "apps/web/dist");
216 const readmePath = join(repoRoot, "README.md");155 const readmePath = join(repoRoot, "README.md");
@@ -246,11 +185,6 @@ export const buildDistributionPackage = async ( · 29 unmodified lines
246 });185 });
247 186
248 await cp(webDistDir, webDir, { recursive: true });187 await cp(webDistDir, webDir, { recursive: true });
249 await cp(join(repoRoot, "packages/control-plane/drizzle"), migrationsDir, {
250 recursive: true,
251 });
252 await cp(pgliteDataPath, join(binDir, "pglite.data"));
253 await cp(pgliteWasmPath, join(binDir, "pglite.wasm"));
254 await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));188 await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));
255 await mkdir(join(binDir, "openapi-extractor-wasm"), { recursive: true });189 await mkdir(join(binDir, "openapi-extractor-wasm"), { recursive: true });
256 await cp(190 await cp(
@@ -273,9 +207,7 @@ export const buildDistributionPackage = async ( · 16 unmodified lines
273 }));207 }));
274 await cp(readmePath, join(packageDir, "README.md"));208 await cp(readmePath, join(packageDir, "README.md"));
275 await writeFile(launcherPath, createLauncherSource());209 await writeFile(launcherPath, createLauncherSource());
276 await writeFile(postinstallPath, createPostinstallSource());
277 await chmod(launcherPath, 0o755);210 await chmod(launcherPath, 0o755);
278 await chmod(postinstallPath, 0o755);
279 211
280 return {212 return {
281 packageDir,213 packageDir,
apps/executor/src/distribution/build-package.ts+0−58
@@ -1,58 +0,0 @@
1import { buildDistributionPackage } from "./artifact";
2
3type BuildPackageCliOptions = {
4 outputDir?: string;
5 packageName?: string;
6 packageVersion?: string;
7 buildWeb: boolean;
8};
9
10const parseArgs = (argv: ReadonlyArray<string>): BuildPackageCliOptions => {
11 const options: BuildPackageCliOptions = {
12 buildWeb: true,
13 };
14
15 for (let index = 0; index < argv.length; index += 1) {
16 const arg = argv[index];
17
18 if (arg === "--skip-web-build") {
19 options.buildWeb = false;
20 continue;
21 }
22
23 const next = argv[index + 1];
24 if (!next) {
25 throw new Error(`Missing value for ${arg}`);
26 }
27
28 if (arg === "--output") {
29 options.outputDir = next;
30 index += 1;
31 continue;
32 }
33
34 if (arg === "--package-name") {
35 options.packageName = next;
36 index += 1;
37 continue;
38 }
39
40 if (arg === "--package-version") {
41 options.packageVersion = next;
42 index += 1;
43 continue;
44 }
45
46 throw new Error(`Unknown argument: ${arg}`);
47 }
48
49 return options;
50};
51
52const main = async () => {
53 const options = parseArgs(process.argv.slice(2));
54 const artifact = await buildDistributionPackage(options);
55 process.stdout.write(`${artifact.packageDir}\n`);
56};
57
58await main();
apps/executor/src/distribution/build-portable.ts+0−78
@@ -1,78 +0,0 @@
1import {
2 buildPortableDistribution,
3 type BuildPortableDistributionOptions,
4} from "./portable";
5
6type BuildPortableCliOptions = BuildPortableDistributionOptions;
7
8const parseArgs = (argv: ReadonlyArray<string>): BuildPortableCliOptions => {
9 const options: BuildPortableCliOptions = {
10 buildWeb: true,
11 createArchives: true,
12 };
13
14 for (let index = 0; index < argv.length; index += 1) {
15 const arg = argv[index];
16
17 if (arg === "--skip-web-build") {
18 options.buildWeb = false;
19 continue;
20 }
21
22 if (arg === "--no-archive") {
23 options.createArchives = false;
24 continue;
25 }
26
27 const next = argv[index + 1];
28 if (!next) {
29 throw new Error(`Missing value for ${arg}`);
30 }
31
32 if (arg === "--output") {
33 options.outputDir = next;
34 index += 1;
35 continue;
36 }
37
38 if (arg === "--package-name") {
39 options.packageName = next;
40 index += 1;
41 continue;
42 }
43
44 if (arg === "--package-version") {
45 options.packageVersion = next;
46 index += 1;
47 continue;
48 }
49
50 if (arg === "--target") {
51 options.targets = [...(options.targets ?? []), next];
52 index += 1;
53 continue;
54 }
55
56
57 if (arg === "--node-version") {
58 options.nodeVersion = next;
59 index += 1;
60 continue;
61 }
62
63 throw new Error(`Unknown argument: ${arg}`);
64 }
65
66 return options;
67};
68
69const main = async () => {
70 const options = parseArgs(process.argv.slice(2));
71 const artifact = await buildPortableDistribution(options);
72
73 for (const bundle of artifact.artifacts) {
74 process.stdout.write(`${bundle.archivePath ?? bundle.bundleDir}\n`);
75 }
76};
77
78await main();
apps/executor/src/distribution/build-release.ts+0−137
@@ -1,137 +0,0 @@
1import { createHash } from "node:crypto";
2import { spawn } from "node:child_process";
3import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4import { join, relative, resolve } from "node:path";
5
6import { buildDistributionPackage } from "./artifact";
7import { repoRoot } from "./metadata";
8import { buildPortableDistribution, portableTargetIds } from "./portable";
9
10const defaultReleaseDir = resolve(repoRoot, "apps/executor/dist/release");
11
12type CommandInput = {
13 command: string;
14 args: ReadonlyArray<string>;
15 cwd: string;
16};
17
18type CommandOutput = {
19 stdout: string;
20 stderr: string;
21};
22
23type PackResult = {
24 filename?: string;
25};
26
27const runCommand = async (input: CommandInput): Promise<CommandOutput> => {
28 const child = spawn(input.command, [...input.args], {
29 cwd: input.cwd,
30 env: process.env,
31 stdio: ["ignore", "pipe", "pipe"],
32 });
33
34 let stdout = "";
35 let stderr = "";
36
37 child.stdout?.setEncoding("utf8");
38 child.stdout?.on("data", (chunk) => {
39 stdout += chunk;
40 });
41
42 child.stderr?.setEncoding("utf8");
43 child.stderr?.on("data", (chunk) => {
44 stderr += chunk;
45 });
46
47 const exitCode = await new Promise<number>((resolveExitCode, reject) => {
48 child.once("error", reject);
49 child.once("close", (code) => {
50 resolveExitCode(code ?? -1);
51 });
52 });
53
54 if (exitCode !== 0) {
55 throw new Error(
56 [
57 `${input.command} ${input.args.join(" ")} exited with code ${exitCode}`,
58 stdout.trim().length > 0 ? `stdout:\n${stdout.trim()}` : null,
59 stderr.trim().length > 0 ? `stderr:\n${stderr.trim()}` : null,
60 ]
61 .filter((part) => part !== null)
62 .join("\n\n"),
63 );
64 }
65
66 return {
67 stdout: stdout.trim(),
68 stderr: stderr.trim(),
69 };
70};
71
72const computeSha256 = (contents: Uint8Array): string =>
73 createHash("sha256").update(contents).digest("hex");
74
75const createChecksumsFile = async (
76 releaseDir: string,
77 assetPaths: ReadonlyArray<string>,
78): Promise<string> => {
79 const entries = await Promise.all(
80 [...assetPaths]
81 .sort((left, right) => left.localeCompare(right))
82 .map(async (assetPath) => {
83 const contents = await readFile(assetPath);
84 const relativePath = relative(releaseDir, assetPath).replaceAll("\\", "/");
85 return `${computeSha256(contents)} ${relativePath}`;
86 }),
87 );
88
89 const checksumsPath = join(releaseDir, "checksums.txt");
90 await writeFile(checksumsPath, `${entries.join("\n")}\n`);
91 return checksumsPath;
92};
93
94const packDistributionPackage = async (releaseDir: string): Promise<string> => {
95 const output = await runCommand({
96 command: "npm",
97 args: ["pack", "./apps/executor/dist/npm", "--pack-destination", releaseDir, "--json"],
98 cwd: repoRoot,
99 });
100 const [result] = JSON.parse(output.stdout) as ReadonlyArray<PackResult>;
101 const filename = result?.filename;
102
103 if (!filename) {
104 throw new Error(`npm pack did not report an output filename. stdout:\n${output.stdout}`);
105 }
106
107 return join(releaseDir, filename);
108};
109
110const main = async () => {
111 const releaseDir = defaultReleaseDir;
112 const portableDir = join(releaseDir, "portable");
113
114 await rm(releaseDir, { recursive: true, force: true });
115 await mkdir(releaseDir, { recursive: true });
116
117 await buildDistributionPackage();
118 const packageArchivePath = await packDistributionPackage(releaseDir);
119 const portableArtifact = await buildPortableDistribution({
120 outputDir: portableDir,
121 buildWeb: false,
122 targets: portableTargetIds,
123 });
124 const portableArchivePaths = portableArtifact.artifacts
125 .map((artifact) => artifact.archivePath)
126 .filter((archivePath): archivePath is string => archivePath !== null);
127 const checksumsPath = await createChecksumsFile(releaseDir, [
128 packageArchivePath,
129 ...portableArchivePaths,
130 ]);
131
132 for (const assetPath of [packageArchivePath, ...portableArchivePaths, checksumsPath]) {
133 process.stdout.write(`${assetPath}\n`);
134 }
135};
136
137await main();
apps/executor/src/distribution/portable.test.ts+0−232
@@ -1,232 +0,0 @@
1import { spawn } from "node:child_process";
2import { existsSync } from "node:fs";
3import { mkdir, mkdtemp, rm } from "node:fs/promises";
4import { createServer } from "node:net";
5import { tmpdir } from "node:os";
6import { join } from "node:path";
7
8import { describe, expect, it } from "vitest";
9
10import {
11 buildPortableDistribution,
12 resolveDefaultPortableTarget,
13} from "./portable";
14
15type CommandResult = {
16 stdout: string;
17 stderr: string;
18 exitCode: number;
19};
20
21const runCommand = async (input: {
22 command: string;
23 args: ReadonlyArray<string>;
24 cwd: string;
25 env: NodeJS.ProcessEnv;
26 okExitCodes?: ReadonlyArray<number>;
27}): Promise<CommandResult> => {
28 const child = spawn(input.command, [...input.args], {
29 cwd: input.cwd,
30 env: input.env,
31 stdio: ["ignore", "pipe", "pipe"],
32 });
33
34 let stdout = "";
35 let stderr = "";
36
37 child.stdout?.setEncoding("utf8");
38 child.stdout?.on("data", (chunk) => {
39 stdout += chunk;
40 });
41
42 child.stderr?.setEncoding("utf8");
43 child.stderr?.on("data", (chunk) => {
44 stderr += chunk;
45 });
46
47 const exitCode = await new Promise<number>((resolveExitCode, reject) => {
48 child.once("error", reject);
49 child.once("close", (code) => {
50 resolveExitCode(code ?? -1);
51 });
52 });
53
54 const result = {
55 stdout: stdout.trim(),
56 stderr: stderr.trim(),
57 exitCode,
58 } satisfies CommandResult;
59
60 if ((input.okExitCodes ?? [0]).includes(exitCode)) {
61 return result;
62 }
63
64 throw new Error(
65 [
66 `${input.command} ${input.args.join(" ")} exited with code ${exitCode}`,
67 result.stdout.length > 0 ? `stdout:\n${result.stdout}` : null,
68 result.stderr.length > 0 ? `stderr:\n${result.stderr}` : null,
69 ].filter((part) => part !== null).join("\n\n"),
70 );
71};
72
73const allocatePort = async (): Promise<number> =>
74 await new Promise<number>((resolvePort, reject) => {
75 const server = createServer();
76 server.once("error", reject);
77 server.listen(0, "127.0.0.1", () => {
78 const address = server.address();
79 if (!address || typeof address === "string") {
80 server.close(() => reject(new Error("Failed to allocate test port")));
81 return;
82 }
83
84 server.close((error) => {
85 if (error) {
86 reject(error);
87 return;
88 }
89
90 resolvePort(address.port);
91 });
92 });
93 });
94
95describe("portable distribution flow", () => {
96 it("installs and boots a portable bundle in a fresh home", async () => {
97 const tempRoot = await mkdtemp(join(tmpdir(), "executor-portable-"));
98
99 try {
100 const distDir = join(tempRoot, "dist");
101 const homeDir = join(tempRoot, "home");
102 const binDir = join(homeDir, ".local", "bin");
103 const installHome = process.platform === "darwin"
104 ? join(homeDir, "Library", "Application Support", "Executor")
105 : join(homeDir, ".local", "share", "executor");
106 const runHome = process.platform === "darwin"
107 ? join(installHome, "run")
108 : join(homeDir, ".local", "state", "executor", "run");
109 const legacyExecutorBin = join(homeDir, ".executor", "bin", "executor");
110 const baseUrl = `http://127.0.0.1:${await allocatePort()}`;
111
112 await mkdir(homeDir, { recursive: true });
113 await mkdir(binDir, { recursive: true });
114
115 const artifact = await buildPortableDistribution({
116 outputDir: distDir,
117 buildWeb: false,
118 targets: [resolveDefaultPortableTarget()],
119 });
120 const bundle = artifact.artifacts[0]!;
121
122 const env = {
123 ...process.env,
124 HOME: homeDir,
125 PATH: `${binDir}:/usr/bin:/bin`,
126 } satisfies NodeJS.ProcessEnv;
127
128 await runCommand({
129 command: "sh",
130 args: [bundle.installScriptPath],
131 cwd: bundle.bundleDir,
132 env,
133 });
134
135 await runCommand({
136 command: "sh",
137 args: [bundle.installScriptPath],
138 cwd: bundle.bundleDir,
139 env,
140 });
141
142 expect(existsSync(join(binDir, "executor"))).toBe(true);
143 expect(existsSync(join(installHome, "portable"))).toBe(true);
144 expect(existsSync(legacyExecutorBin)).toBe(false);
145
146 const initialDoctor = await runCommand({
147 command: "executor",
148 args: ["doctor", "--json", "--base-url", baseUrl],
149 cwd: tempRoot,
150 env,
151 });
152 const initialDoctorJson = JSON.parse(initialDoctor.stdout) as {
153 ok: boolean;
154 checks: Record<string, { ok: boolean }>;
155 };
156 expect(initialDoctorJson.ok).toBe(false);
157 expect(initialDoctorJson.checks.webAssets?.ok).toBe(true);
158 expect(initialDoctorJson.checks.migrations?.ok).toBe(true);
159
160 await runCommand({
161 command: "executor",
162 args: ["up", "--base-url", baseUrl],
163 cwd: tempRoot,
164 env,
165 });
166
167 const statusResult = await runCommand({
168 command: "executor",
169 args: ["status", "--json", "--base-url", baseUrl],
170 cwd: tempRoot,
171 env,
172 });
173 const status = JSON.parse(statusResult.stdout) as {
174 reachable: boolean;
175 pidRunning: boolean;
176 logFile: string;
177 localDataDir: string;
178 pidFile: string;
179 installation: { workspaceId: string; accountId: string } | null;
180 };
181 expect(status.reachable).toBe(true);
182 expect(status.pidRunning).toBe(true);
183 expect(status.installation).not.toBeNull();
184 expect(status.logFile.startsWith(runHome)).toBe(true);
185 expect(status.localDataDir.startsWith(join(installHome, "data"))).toBe(true);
186 expect(status.pidFile.startsWith(runHome)).toBe(true);
187
188 const html = await fetch(new URL("/", baseUrl));
189 expect(html.status).toBe(200);
190 expect(html.headers.get("content-type")).toContain("text/html");
191 expect(await html.text()).toContain('<div id="root"></div>');
192
193 const installationResponse = await fetch(new URL("/v1/local/installation", baseUrl));
194 expect(installationResponse.status).toBe(200);
195 const installation = await installationResponse.json() as {
196 workspaceId: string;
197 accountId: string;
198 };
199
200 await runCommand({
201 command: "executor",
202 args: ["down", "--base-url", baseUrl],
203 cwd: tempRoot,
204 env,
205 });
206 await runCommand({
207 command: "executor",
208 args: ["up", "--base-url", baseUrl],
209 cwd: tempRoot,
210 env,
211 });
212
213 const installationAfterRestartResponse = await fetch(new URL("/v1/local/installation", baseUrl));
214 expect(installationAfterRestartResponse.status).toBe(200);
215 const installationAfterRestart = await installationAfterRestartResponse.json() as {
216 workspaceId: string;
217 accountId: string;
218 };
219 expect(installationAfterRestart.workspaceId).toBe(installation.workspaceId);
220 expect(installationAfterRestart.accountId).toBe(installation.accountId);
221
222 await runCommand({
223 command: "executor",
224 args: ["down", "--base-url", baseUrl],
225 cwd: tempRoot,
226 env,
227 });
228 } finally {
229 await rm(tempRoot, { recursive: true, force: true });
230 }
231 }, 240_000);
232});
apps/executor/src/distribution/portable.ts+0−607
@@ -1,607 +0,0 @@
1import { createHash } from "node:crypto";
2import { spawn } from "node:child_process";
3import { existsSync } from "node:fs";
4import {
5 chmod,
6 copyFile,
7 cp,
8 mkdir,
9 mkdtemp,
10 readFile,
11 rm,
12 writeFile,
13} from "node:fs/promises";
14import { tmpdir } from "node:os";
15import { basename, join, resolve } from "node:path";
16
17import { buildDistributionPackage } from "./artifact";
18import { readDistributionPackageMetadata, repoRoot } from "./metadata";
19
20const defaultOutputDir = resolve(repoRoot, "apps/executor/dist/portable");
21const defaultNodeCacheDir = resolve(repoRoot, "apps/executor/.cache/portable-node");
22const DEFAULT_OFFICIAL_NODE_VERSION = "v24.14.0";
23
24type PortableTarget = {
25 id: string;
26 archiveExtension: "tar.gz" | "zip";
27 archiveFileName: (nodeVersion: string) => string;
28 extractedExecutablePath: (nodeVersion: string) => string;
29 bundledExecutableName: string;
30};
31
32const portableTargets: Record<string, PortableTarget> = {
33 "linux-x64": {
34 id: "linux-x64",
35 archiveExtension: "tar.gz",
36 archiveFileName: (nodeVersion: string) => `node-${nodeVersion}-linux-x64.tar.gz`,
37 extractedExecutablePath: (nodeVersion: string) => join(`node-${nodeVersion}-linux-x64`, "bin", "node"),
38 bundledExecutableName: "node",
39 },
40 "linux-arm64": {
41 id: "linux-arm64",
42 archiveExtension: "tar.gz",
43 archiveFileName: (nodeVersion: string) => `node-${nodeVersion}-linux-arm64.tar.gz`,
44 extractedExecutablePath: (nodeVersion: string) => join(`node-${nodeVersion}-linux-arm64`, "bin", "node"),
45 bundledExecutableName: "node",
46 },
47 "darwin-x64": {
48 id: "darwin-x64",
49 archiveExtension: "tar.gz",
50 archiveFileName: (nodeVersion: string) => `node-${nodeVersion}-darwin-x64.tar.gz`,
51 extractedExecutablePath: (nodeVersion: string) => join(`node-${nodeVersion}-darwin-x64`, "bin", "node"),
52 bundledExecutableName: "node",
53 },
54 "darwin-arm64": {
55 id: "darwin-arm64",
56 archiveExtension: "tar.gz",
57 archiveFileName: (nodeVersion: string) => `node-${nodeVersion}-darwin-arm64.tar.gz`,
58 extractedExecutablePath: (nodeVersion: string) => join(`node-${nodeVersion}-darwin-arm64`, "bin", "node"),
59 bundledExecutableName: "node",
60 },
61 "win-x64": {
62 id: "win-x64",
63 archiveExtension: "zip",
64 archiveFileName: (nodeVersion: string) => `node-${nodeVersion}-win-x64.zip`,
65 extractedExecutablePath: (nodeVersion: string) => join(`node-${nodeVersion}-win-x64`, "node.exe"),
66 bundledExecutableName: "node.exe",
67 },
68 "win-arm64": {
69 id: "win-arm64",
70 archiveExtension: "zip",
71 archiveFileName: (nodeVersion: string) => `node-${nodeVersion}-win-arm64.zip`,
72 extractedExecutablePath: (nodeVersion: string) => join(`node-${nodeVersion}-win-arm64`, "node.exe"),
73 bundledExecutableName: "node.exe",
74 },
75};
76
77export const portableTargetIds = Object.freeze(Object.keys(portableTargets));
78
79export type BuildPortableDistributionOptions = {
80 outputDir?: string;
81 packageName?: string;
82 packageVersion?: string;
83 buildWeb?: boolean;
84 targets?: ReadonlyArray<string>;
85 nodeVersion?: string;
86 createArchives?: boolean;
87};
88
89export type PortableBundleArtifact = {
90 target: string;
91 bundleDir: string;
92 launcherPath: string;
93 installScriptPath: string;
94 archivePath: string | null;
95 nodeVersion: string;
96};
97
98export type PortableDistributionArtifact = {
99 outputDir: string;
100 artifacts: ReadonlyArray<PortableBundleArtifact>;
101};
102
103
104type NodeShasums = Map<string, string>;
105
106type CommandInput = {
107 command: string;
108 args: ReadonlyArray<string>;
109 cwd: string;
110};
111
112type CommandOutput = {
113 stdout: string;
114 stderr: string;
115};
116
117const runCommand = async (input: CommandInput): Promise<CommandOutput> => {
118 const child = spawn(input.command, [...input.args], {
119 cwd: input.cwd,
120 env: process.env,
121 stdio: ["ignore", "pipe", "pipe"],
122 });
123
124 let stdout = "";
125 let stderr = "";
126
127 child.stdout?.setEncoding("utf8");
128 child.stdout?.on("data", (chunk) => {
129 stdout += chunk;
130 });
131
132 child.stderr?.setEncoding("utf8");
133 child.stderr?.on("data", (chunk) => {
134 stderr += chunk;
135 });
136
137 const exitCode = await new Promise<number>((resolveExitCode, reject) => {
138 child.once("error", reject);
139 child.once("close", (code) => {
140 resolveExitCode(code ?? -1);
141 });
142 });
143
144 if (exitCode !== 0) {
145 throw new Error(
146 [
147 `${input.command} ${input.args.join(" ")} exited with code ${exitCode}`,
148 stdout.trim().length > 0 ? `stdout:\n${stdout.trim()}` : null,
149 stderr.trim().length > 0 ? `stderr:\n${stderr.trim()}` : null,
150 ]
151 .filter((part) => part !== null)
152 .join("\n\n"),
153 );
154 }
155
156 return {
157 stdout: stdout.trim(),
158 stderr: stderr.trim(),
159 };
160};
161
162const normalizeNodeVersion = (value: string): string =>
163 value.startsWith("v") ? value : `v${value}`;
164
165const computeSha256 = (contents: Uint8Array): string =>
166 createHash("sha256").update(contents).digest("hex");
167
168const parseNodeShasums = (contents: string): NodeShasums =>
169 new Map(
170 contents
171 .split(/\r?\n/u)
172 .map((line) => line.trim())
173 .filter((line) => line.length > 0)
174 .flatMap((line) => {
175 const match = /^(?<sha>[a-f0-9]{64})\s+(?<filename>.+)$/iu.exec(line);
176 if (!match?.groups) {
177 return [];
178 }
179
180 return [[match.groups.filename, match.groups.sha.toLowerCase()] as const];
181 }),
182 );
183
184const getHostTargetId = (): string => {
185 if (process.platform === "linux" && process.arch === "x64") {
186 return "linux-x64";
187 }
188
189 if (process.platform === "linux" && process.arch === "arm64") {
190 return "linux-arm64";
191 }
192
193 if (process.platform === "darwin" && process.arch === "x64") {
194 return "darwin-x64";
195 }
196
197 if (process.platform === "darwin" && process.arch === "arm64") {
198 return "darwin-arm64";
199 }
200
201 if (process.platform === "win32" && process.arch === "x64") {
202 return "win-x64";
203 }
204
205 if (process.platform === "win32" && process.arch === "arm64") {
206 return "win-arm64";
207 }
208
209 throw new Error(`Unsupported host platform for portable bundle: ${process.platform}-${process.arch}`);
210};
211
212export const resolveDefaultPortableTarget = (): string => getHostTargetId();
213
214const resolveTargets = (requested: ReadonlyArray<string> | undefined): ReadonlyArray<PortableTarget> => {
215 const targetIds = requested && requested.length > 0 ? requested : [getHostTargetId()];
216
217 return targetIds.map((targetId) => {
218 const target = portableTargets[targetId];
219 if (!target) {
220 throw new Error(`Unsupported portable target: ${targetId}`);
221 }
222 return target;
223 });
224};
225
226
227const resolveNodeVersion = async (requestedVersion: string | undefined): Promise<string> => {
228 if (!requestedVersion) {
229 return DEFAULT_OFFICIAL_NODE_VERSION;
230 }
231
232 return normalizeNodeVersion(requestedVersion);
233};
234
235
236const ensureOfficialNodeRuntime = async (input: {
237 target: PortableTarget;
238 nodeVersion: string;
239 cacheDir: string;
240 destinationPath: string;
241}): Promise<void> => {
242 const archiveName = input.target.archiveFileName(input.nodeVersion);
243 const archivePath = join(input.cacheDir, archiveName);
244 const shasumsPath = join(input.cacheDir, "SHASUMS256.txt");
245 const url = `https://nodejs.org/dist/${input.nodeVersion}/${archiveName}`;
246 const shasumsUrl = `https://nodejs.org/dist/${input.nodeVersion}/SHASUMS256.txt`;
247
248 await mkdir(input.cacheDir, { recursive: true });
249
250 let shasumsContents = existsSync(shasumsPath)
251 ? await readFile(shasumsPath, "utf8")
252 : null;
253
254 if (shasumsContents === null) {
255 const response = await fetch(shasumsUrl);
256 if (!response.ok) {
257 throw new Error(`Unable to download ${shasumsUrl}: ${response.status} ${response.statusText}`);
258 }
259
260 shasumsContents = await response.text();
261 await writeFile(shasumsPath, shasumsContents);
262 }
263
264 const shasums = parseNodeShasums(shasumsContents);
265 const expectedChecksum = shasums.get(archiveName);
266 if (!expectedChecksum) {
267 throw new Error(`Missing checksum for ${archiveName} in ${shasumsUrl}`);
268 }
269
270 const ensureArchiveMatchesChecksum = async (): Promise<void> => {
271 if (!existsSync(archivePath)) {
272 return;
273 }
274
275 const existingArchive = await readFile(archivePath);
276 if (computeSha256(existingArchive) === expectedChecksum) {
277 return;
278 }
279
280 await rm(archivePath, { force: true });
281 };
282
283 await ensureArchiveMatchesChecksum();
284
285 if (!existsSync(archivePath)) {
286 const response = await fetch(url);
287 if (!response.ok) {
288 throw new Error(`Unable to download ${url}: ${response.status} ${response.statusText}`);
289 }
290
291 const bytes = Buffer.from(await response.arrayBuffer());
292 const actualChecksum = computeSha256(bytes);
293 if (actualChecksum !== expectedChecksum) {
294 throw new Error(
295 `Checksum mismatch for ${archiveName}: expected ${expectedChecksum}, received ${actualChecksum}`,
296 );
297 }
298
299 await writeFile(archivePath, bytes);
300 }
301
302 const extractDir = await mkdtemp(join(tmpdir(), "executor-portable-node-"));
303 try {
304 if (input.target.archiveExtension === "tar.gz") {
305 await runCommand({
306 command: "tar",
307 args: ["-xzf", archivePath, "-C", extractDir],
308 cwd: repoRoot,
309 });
310 } else {
311 await runCommand({
312 command: "unzip",
313 args: ["-q", archivePath, "-d", extractDir],
314 cwd: repoRoot,
315 });
316 }
317
318 const extractedExecutable = join(
319 extractDir,
320 input.target.extractedExecutablePath(input.nodeVersion),
321 );
322
323 if (!existsSync(extractedExecutable)) {
324 throw new Error(`Missing extracted Node.js executable at ${extractedExecutable}`);
325 }
326
327 await copyFile(extractedExecutable, input.destinationPath);
328 } finally {
329 await rm(extractDir, { recursive: true, force: true });
330 }
331};
332
333const createUnixLauncher = (): string => [
334 "#!/bin/sh",
335 "set -eu",
336 'SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
337 'exec "$SCRIPT_DIR/runtime/node" "$SCRIPT_DIR/bin/executor.mjs" "$@"',
338 "",
339].join("\n");
340
341const createWindowsLauncher = (): string => [
342 "@echo off",
343 'set "SCRIPT_DIR=%~dp0"',
344 '"%SCRIPT_DIR%runtime\\node.exe" "%SCRIPT_DIR%bin\\executor.mjs" %*',
345 "",
346].join("\r\n");
347
348const createInstallScript = (input: {
349 packageName: string;
350 packageVersion: string;
351 target: string;
352}) => [
353 "#!/bin/sh",
354 "set -eu",
355 'SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
356 'DEFAULT_INSTALL_HOME=${EXECUTOR_INSTALL_HOME:-}',
357 'if [ -z "$DEFAULT_INSTALL_HOME" ]; then',
358 ' if [ "$(uname -s)" = "Darwin" ]; then',
359 ' DEFAULT_INSTALL_HOME="$HOME/Library/Application Support/Executor"',
360 ' else',
361 ' DEFAULT_INSTALL_HOME="${XDG_DATA_HOME:-$HOME/.local/share}/executor"',
362 ' fi',
363 'fi',
364 'DEFAULT_BIN_DIR=${EXECUTOR_BIN_DIR:-${XDG_BIN_HOME:-$HOME/.local/bin}}',
365 `PACKAGE_NAME=${JSON.stringify(input.packageName)}`,
366 `PACKAGE_VERSION=${JSON.stringify(input.packageVersion)}`,
367 `TARGET_ID=${JSON.stringify(input.target)}`,
368 'INSTALL_DIR="$DEFAULT_INSTALL_HOME/portable/$PACKAGE_VERSION/$TARGET_ID"',
369 'INSTALL_PARENT=$(dirname "$INSTALL_DIR")',
370 'STAGING_DIR="$INSTALL_PARENT/.install-$PACKAGE_VERSION-$TARGET_ID-$$"',
371 'PREVIOUS_DIR="$INSTALL_PARENT/.previous-$PACKAGE_VERSION-$TARGET_ID-$$"',
372 'BIN_DIR="$DEFAULT_BIN_DIR"',
373 "",
374 'while [ "$#" -gt 0 ]; do',
375 ' case "$1" in',
376 ' --install-dir)',
377 ' INSTALL_DIR="$2"',
378 ' INSTALL_PARENT=$(dirname "$INSTALL_DIR")',
379 ' STAGING_DIR="$INSTALL_PARENT/.install-$PACKAGE_VERSION-$TARGET_ID-$$"',
380 ' PREVIOUS_DIR="$INSTALL_PARENT/.previous-$PACKAGE_VERSION-$TARGET_ID-$$"',
381 ' shift 2',
382 ' ;;',
383 ' --bin-dir)',
384 ' BIN_DIR="$2"',
385 ' shift 2',
386 ' ;;',
387 ' *)',
388 ' printf "Unknown argument: %s\\n" "$1" >&2',
389 ' exit 1',
390 ' ;;',
391 ' esac',
392 'done',
393 "",
394 'rm -rf "$STAGING_DIR" "$PREVIOUS_DIR"',
395 'mkdir -p "$INSTALL_PARENT" "$STAGING_DIR" "$BIN_DIR"',
396 'cp -R "$SCRIPT_DIR/bin" "$STAGING_DIR/bin"',
397 'cp -R "$SCRIPT_DIR/runtime" "$STAGING_DIR/runtime"',
398 'cp -R "$SCRIPT_DIR/resources" "$STAGING_DIR/resources"',
399 'cp "$SCRIPT_DIR/package.json" "$STAGING_DIR/package.json"',
400 'cp "$SCRIPT_DIR/executor" "$STAGING_DIR/executor"',
401 'cp "$SCRIPT_DIR/executor.cmd" "$STAGING_DIR/executor.cmd"',
402 'cp "$SCRIPT_DIR/install.sh" "$STAGING_DIR/install.sh"',
403 'cp "$SCRIPT_DIR/install.ps1" "$STAGING_DIR/install.ps1"',
404 'chmod 755 "$STAGING_DIR/executor" "$STAGING_DIR/runtime/node"',
405 'if [ -e "$INSTALL_DIR" ]; then mv "$INSTALL_DIR" "$PREVIOUS_DIR"; fi',
406 'mv "$STAGING_DIR" "$INSTALL_DIR"',
407 'rm -rf "$PREVIOUS_DIR"',
408 'WRAPPER_PATH="$BIN_DIR/executor"',
409 'WRAPPER_TEMP="$WRAPPER_PATH.tmp.$$"',
410 'cat > "$WRAPPER_TEMP" <<EOF',
411 '#!/bin/sh',
412 'exec "$INSTALL_DIR/executor" "\\$@"',
413 'EOF',
414 'chmod 755 "$WRAPPER_TEMP"',
415 'mv "$WRAPPER_TEMP" "$WRAPPER_PATH"',
416 'printf "Installed %s %s to %s\\n" "$PACKAGE_NAME" "$PACKAGE_VERSION" "$INSTALL_DIR"',
417 'printf "Launcher: %s\\n" "$BIN_DIR/executor"',
418 'case ":${PATH}:" in',
419 ' *":${BIN_DIR}:"*) ;;',
420 ' *) printf "Add %s to PATH to run `executor` directly.\\n" "$BIN_DIR" ;;',
421 'esac',
422 "",
423].join("\n");
424
425const createInstallPowerShellScript = (input: {
426 packageName: string;
427 packageVersion: string;
428 target: string;
429}) => [
430 'param(',
431 ' [string]$InstallHome = $(if ($env:EXECUTOR_INSTALL_HOME) { $env:EXECUTOR_INSTALL_HOME } elseif ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA "Executor" } else { Join-Path $env:USERPROFILE "AppData\\Local\\Executor" }),',
432 ' [string]$BinDir = $(if ($env:EXECUTOR_BIN_DIR) { $env:EXECUTOR_BIN_DIR } else { Join-Path $env:USERPROFILE ".local\\bin" })',
433 ')',
434 '$ErrorActionPreference = "Stop"',
435 '$InstallDir = Join-Path $InstallHome "portable\\' + input.packageVersion + '\\' + input.target + '"',
436 '$InstallParent = Split-Path -Parent $InstallDir',
437 '$StageDir = Join-Path $InstallParent (".install-' + input.packageVersion + '-' + input.target + '-" + [Guid]::NewGuid().ToString("N"))',
438 '$PreviousDir = Join-Path $InstallParent (".previous-' + input.packageVersion + '-' + input.target + '-" + [Guid]::NewGuid().ToString("N"))',
439 '$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path',
440 'New-Item -ItemType Directory -Force -Path $InstallParent | Out-Null',
441 'if (Test-Path $StageDir) { Remove-Item -Path $StageDir -Recurse -Force }',
442 'if (Test-Path $PreviousDir) { Remove-Item -Path $PreviousDir -Recurse -Force }',
443 'New-Item -ItemType Directory -Force -Path $StageDir | Out-Null',
444 'New-Item -ItemType Directory -Force -Path $BinDir | Out-Null',
445 'Copy-Item -Path (Join-Path $ScriptDir "bin") -Destination (Join-Path $StageDir "bin") -Recurse -Force',
446 'Copy-Item -Path (Join-Path $ScriptDir "runtime") -Destination (Join-Path $StageDir "runtime") -Recurse -Force',
447 'Copy-Item -Path (Join-Path $ScriptDir "resources") -Destination (Join-Path $StageDir "resources") -Recurse -Force',
448 'Copy-Item -Path (Join-Path $ScriptDir "package.json") -Destination (Join-Path $StageDir "package.json") -Force',
449 'Copy-Item -Path (Join-Path $ScriptDir "executor") -Destination (Join-Path $StageDir "executor") -Force',
450 'Copy-Item -Path (Join-Path $ScriptDir "executor.cmd") -Destination (Join-Path $StageDir "executor.cmd") -Force',
451 'Copy-Item -Path (Join-Path $ScriptDir "install.sh") -Destination (Join-Path $StageDir "install.sh") -Force',
452 'Copy-Item -Path (Join-Path $ScriptDir "install.ps1") -Destination (Join-Path $StageDir "install.ps1") -Force',
453 'if (Test-Path $InstallDir) { Move-Item -Path $InstallDir -Destination $PreviousDir -Force }',
454 'Move-Item -Path $StageDir -Destination $InstallDir -Force',
455 'if (Test-Path $PreviousDir) { Remove-Item -Path $PreviousDir -Recurse -Force }',
456 '$WrapperPath = Join-Path $BinDir "executor.cmd"',
457 '$WrapperTemp = "$WrapperPath.tmp"',
458 'Set-Content -Path $WrapperTemp -Value ("@echo off`r`n\"" + (Join-Path $InstallDir "executor.cmd") + "\" %*`r`n") -NoNewline',
459 'Move-Item -Path $WrapperTemp -Destination $WrapperPath -Force',
460 'Write-Host "Installed ' + input.packageName + ' ' + input.packageVersion + ' to $InstallDir"',
461 'Write-Host "Launcher: $WrapperPath"',
462].join("\r\n");
463
464const createArchive = async (input: {
465 bundleDir: string;
466 archivePath: string;
467 target: PortableTarget;
468 outputDir: string;
469}): Promise<void> => {
470 const bundleName = basename(input.bundleDir);
471
472 if (input.target.archiveExtension === "tar.gz") {
473 await runCommand({
474 command: "tar",
475 args: ["-czf", input.archivePath, "-C", input.outputDir, bundleName],
476 cwd: repoRoot,
477 });
478 return;
479 }
480
481 await runCommand({
482 command: "python3",
483 args: [
484 "-c",
485 [
486 "import os, sys, zipfile",
487 "bundle_dir = os.path.abspath(sys.argv[1])",
488 "archive_path = os.path.abspath(sys.argv[2])",
489 "parent = os.path.dirname(bundle_dir)",
490 "with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as archive:",
491 " for root, _, files in os.walk(bundle_dir):",
492 " for name in files:",
493 " path = os.path.join(root, name)",
494 " archive.write(path, os.path.relpath(path, parent))",
495 ].join("\n"),
496 input.bundleDir,
497 input.archivePath,
498 ],
499 cwd: repoRoot,
500 });
501};
502
503const ensureRuntimeExecutable = async (input: {
504 target: PortableTarget;
505 nodeVersion: string;
506 cacheDir: string;
507 destinationPath: string;
508}): Promise<void> => {
509 await ensureOfficialNodeRuntime(input);
510};
511
512export const buildPortableDistribution = async (
513 options: BuildPortableDistributionOptions = {},
514): Promise<PortableDistributionArtifact> => {
515 const defaults = await readDistributionPackageMetadata();
516 const packageName = options.packageName ?? defaults.name;
517 const packageVersion = options.packageVersion ?? defaults.version;
518 const outputDir = resolve(options.outputDir ?? defaultOutputDir);
519 const targets = resolveTargets(options.targets);
520 const nodeVersion = await resolveNodeVersion(options.nodeVersion);
521 const createArchives = options.createArchives ?? true;
522 const cacheDir = join(defaultNodeCacheDir, nodeVersion);
523 const stageRoot = await mkdtemp(join(tmpdir(), "executor-portable-stage-"));
524
525 await rm(outputDir, { recursive: true, force: true });
526 await mkdir(outputDir, { recursive: true });
527
528 try {
529 const stagedPackage = await buildDistributionPackage({
530 outputDir: join(stageRoot, "package"),
531 packageName,
532 packageVersion,
533 buildWeb: options.buildWeb,
534 });
535
536 const artifacts: PortableBundleArtifact[] = [];
537
538 for (const target of targets) {
539 const bundleName = `${packageName}-${packageVersion}-${target.id}`;
540 const bundleDir = join(outputDir, bundleName);
541 const runtimeDir = join(bundleDir, "runtime");
542 const runtimeExecutablePath = join(runtimeDir, target.bundledExecutableName);
543 const launcherPath = join(bundleDir, "executor");
544 const installScriptPath = join(bundleDir, "install.sh");
545 const archivePath = createArchives
546 ? join(outputDir, `${bundleName}.${target.archiveExtension}`)
547 : null;
548
549 await cp(stagedPackage.packageDir, bundleDir, { recursive: true });
550 await mkdir(runtimeDir, { recursive: true });
551 await ensureRuntimeExecutable({
552 target,
553 nodeVersion,
554 cacheDir,
555 destinationPath: runtimeExecutablePath,
556 });
557 await writeFile(launcherPath, createUnixLauncher());
558 await writeFile(join(bundleDir, "executor.cmd"), createWindowsLauncher());
559 await writeFile(
560 installScriptPath,
561 createInstallScript({
562 packageName,
563 packageVersion,
564 target: target.id,
565 }),
566 );
567 await writeFile(
568 join(bundleDir, "install.ps1"),
569 createInstallPowerShellScript({
570 packageName,
571 packageVersion,
572 target: target.id,
573 }),
574 );
575 await chmod(launcherPath, 0o755);
576 await chmod(installScriptPath, 0o755);
577 if (target.bundledExecutableName === "node") {
578 await chmod(runtimeExecutablePath, 0o755);
579 }
580
581 if (archivePath !== null) {
582 await createArchive({
583 bundleDir,
584 archivePath,
585 target,
586 outputDir,
587 });
588 }
589
590 artifacts.push({
591 target: target.id,
592 bundleDir,
593 launcherPath,
594 installScriptPath,
595 archivePath,
596 nodeVersion,
597 });
598 }
599
600 return {
601 outputDir,
602 artifacts,
603 };
604 } finally {
605 await rm(stageRoot, { recursive: true, force: true });
606 }
607};
apps/executor/src/distribution/publish.ts+224−0
@@ -0,0 +1,224 @@
1import { spawn } from "node:child_process";
2import { mkdir, rm } from "node:fs/promises";
3import { join, resolve } from "node:path";
4
5import { buildDistributionPackage } from "./artifact";
6import { readDistributionPackageMetadata, repoRoot } from "./metadata";
7
8const defaultReleaseDir = resolve(repoRoot, "apps/executor/dist/release");
9const semverPattern =
10 /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
11
12type PublishCliOptions = {
13 dryRun: boolean;
14};
15
16type CommandInput = {
17 command: string;
18 args: ReadonlyArray<string>;
19 cwd: string;
20};
21
22type CommandOutput = {
23 stdout: string;
24 stderr: string;
25};
26
27type PackResult = {
28 filename?: string;
29};
30
31const parseArgs = (argv: ReadonlyArray<string>): PublishCliOptions => {
32 const options: PublishCliOptions = {
33 dryRun: false,
34 };
35
36 for (const arg of argv) {
37 if (arg === "--dry-run") {
38 options.dryRun = true;
39 continue;
40 }
41
42 throw new Error(`Unknown argument: ${arg}`);
43 }
44
45 return options;
46};
47
48const runCommand = async (input: CommandInput): Promise<CommandOutput> => {
49 const child = spawn(input.command, [...input.args], {
50 cwd: input.cwd,
51 env: process.env,
52 stdio: ["ignore", "pipe", "pipe"],
53 });
54
55 let stdout = "";
56 let stderr = "";
57
58 child.stdout?.setEncoding("utf8");
59 child.stdout?.on("data", (chunk) => {
60 stdout += chunk;
61 });
62
63 child.stderr?.setEncoding("utf8");
64 child.stderr?.on("data", (chunk) => {
65 stderr += chunk;
66 });
67
68 const exitCode = await new Promise<number>((resolveExitCode, reject) => {
69 child.once("error", reject);
70 child.once("close", (code) => {
71 resolveExitCode(code ?? -1);
72 });
73 });
74
75 if (exitCode !== 0) {
76 throw new Error(
77 [
78 `${input.command} ${input.args.join(" ")} exited with code ${exitCode}`,
79 stdout.trim().length > 0 ? `stdout:\n${stdout.trim()}` : null,
80 stderr.trim().length > 0 ? `stderr:\n${stderr.trim()}` : null,
81 ]
82 .filter((part) => part !== null)
83 .join("\n\n"),
84 );
85 }
86
87 return {
88 stdout: stdout.trim(),
89 stderr: stderr.trim(),
90 };
91};
92
93const resolveTagFromEnvironment = (): string | undefined => {
94 const refName = process.env.GITHUB_REF_NAME?.trim();
95 if (process.env.GITHUB_REF_TYPE === "tag" && refName) {
96 return refName;
97 }
98
99 const ref = process.env.GITHUB_REF?.trim();
100 if (ref?.startsWith("refs/tags/")) {
101 return ref.slice("refs/tags/".length);
102 }
103
104 return undefined;
105};
106
107const resolveGitHubRepository = (): string => {
108 const repository = process.env.GH_REPO?.trim() || process.env.GITHUB_REPOSITORY?.trim();
109 if (!repository) {
110 throw new Error("Set GH_REPO or GITHUB_REPOSITORY before creating a GitHub release.");
111 }
112
113 return repository;
114};
115
116const validateVersion = (version: string): void => {
117 if (!semverPattern.test(version)) {
118 throw new Error(`apps/executor/package.json version is not valid semver: ${version}`);
119 }
120};
121
122const resolveChannel = (version: string): "latest" | "beta" =>
123 version.includes("-") ? "beta" : "latest";
124
125const packDistributionPackage = async (releaseDir: string): Promise<string> => {
126 const output = await runCommand({
127 command: "npm",
128 args: ["pack", "./apps/executor/dist/npm", "--pack-destination", releaseDir, "--json"],
129 cwd: repoRoot,
130 });
131 const [result] = JSON.parse(output.stdout) as ReadonlyArray<PackResult>;
132 const filename = result?.filename;
133
134 if (!filename) {
135 throw new Error(`npm pack did not report an output filename. stdout:\n${output.stdout}`);
136 }
137
138 return join(releaseDir, filename);
139};
140
141const publishDistributionPackage = async (channel: "latest" | "beta"): Promise<void> => {
142 const args = ["publish", "./dist/npm", "--access", "public", "--tag", channel];
143 if (process.env.GITHUB_ACTIONS === "true") {
144 args.push("--provenance");
145 }
146
147 await runCommand({
148 command: "npm",
149 args,
150 cwd: resolve(repoRoot, "apps/executor"),
151 });
152};
153
154const createGitHubRelease = async (input: {
155 tag: string;
156 channel: "latest" | "beta";
157 assetPath: string;
158}): Promise<void> => {
159 if (!process.env.GH_TOKEN?.trim()) {
160 throw new Error("GH_TOKEN is required to create a GitHub release.");
161 }
162
163 const args = [
164 "release",
165 "create",
166 input.tag,
167 input.assetPath,
168 "--repo",
169 resolveGitHubRepository(),
170 "--title",
171 input.tag,
172 "--generate-notes",
173 "--verify-tag",
174 ];
175
176 if (input.channel === "beta") {
177 args.push("--prerelease");
178 } else {
179 args.push("--latest");
180 }
181
182 await runCommand({
183 command: "gh",
184 args,
185 cwd: repoRoot,
186 });
187};
188
189const main = async () => {
190 const options = parseArgs(process.argv.slice(2));
191 const metadata = await readDistributionPackageMetadata();
192 const version = metadata.version;
193 const tag = `v${version}`;
194 const refTag = resolveTagFromEnvironment();
195
196 validateVersion(version);
197
198 if (refTag && refTag !== tag) {
199 throw new Error(`GitHub tag ${refTag} does not match apps/executor/package.json version ${version}`);
200 }
201
202 const channel = resolveChannel(version);
203 await rm(defaultReleaseDir, { recursive: true, force: true });
204 await mkdir(defaultReleaseDir, { recursive: true });
205
206 await buildDistributionPackage();
207 const archivePath = await packDistributionPackage(defaultReleaseDir);
208
209 process.stdout.write(`Prepared executor@${version} for ${channel}\n`);
210 process.stdout.write(`${archivePath}\n`);
211
212 if (options.dryRun) {
213 return;
214 }
215
216 await publishDistributionPackage(channel);
217 await createGitHubRelease({
218 tag,
219 channel,
220 assetPath: archivePath,
221 });
222};
223
224await main();