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
2929 "test": "vitest run",
3030 "test:watch": "vitest",
3131 "start": "bun src/cli/main.ts",
32− "build:dist": "bun src/distribution/build-package.ts",
33− "pack:dist": "bun src/distribution/build-package.ts && npm pack ./dist/npm",
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"
32+ "release:publish:dry-run": "bun src/distribution/publish.ts --dry-run",
33+ "release:publish": "bun src/distribution/publish.ts"
3834 },
3935 "dependencies": {
4036 "@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
77 import { readDistributionPackageMetadata, repoRoot } from "./metadata";
88
99 const defaultOutputDir = resolve(repoRoot, "apps/executor/dist/npm");
10−const DENO_INSTALL_URL = "https://docs.deno.com/runtime/getting_started/installation/";
1110
1211
1312 export type BuildDistributionPackageOptions = {
@@ -72,26 +71,16 @@ const runCommand = async (input: CommandInput): Promise<void> => { · 58 unmodified lines
7271 );
7372 };
7473
75−const 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−
8974 const resolveQuickJsWasmPath = (): string => {
9075 const requireFromQuickJsRuntime = createRequire(
9176 join(repoRoot, "packages/runtime-quickjs/package.json"),
9277 );
93− const wasmPath = requireFromQuickJsRuntime.resolve(
94− "@jitl/quickjs-wasmfile-release-sync/wasm",
78+ const quickJsPackagePath = requireFromQuickJsRuntime.resolve(
79+ "quickjs-emscripten/package.json",
80+ );
81+ const wasmPath = resolve(
82+ dirname(quickJsPackagePath),
83+ "../@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm",
9584 );
9685
9786 if (!existsSync(wasmPath)) {
@@ -131,9 +120,6 @@ const createPackageJson = (input: { · 33 unmodified lines
131120 bin: {
132121 executor: "bin/executor.js",
133122 },
134− scripts: {
135− postinstall: "node ./bin/postinstall.js",
136− },
137123 files: [
138124 "bin",
139125 "resources",
@@ -154,48 +140,6 @@ const createLauncherSource = () => [ · 14 unmodified lines
154140 "",
155141 ].join("\n");
156142
157−const 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−
199143 export const buildDistributionPackage = async (
200144 options: BuildDistributionPackageOptions = {},
201145 ): Promise<DistributionPackageArtifact> => {
@@ -204,13 +148,8 @@ export const buildDistributionPackage = async ( · 2 unmodified lines
204148 const binDir = join(packageDir, "bin");
205149 const resourcesDir = join(packageDir, "resources");
206150 const webDir = join(resourcesDir, "web");
207− const migrationsDir = join(resourcesDir, "migrations");
208151 const bundlePath = join(binDir, "executor.mjs");
209152 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");
214153 const quickJsWasmPath = resolveQuickJsWasmPath();
215154 const webDistDir = join(repoRoot, "apps/web/dist");
216155 const readmePath = join(repoRoot, "README.md");
@@ -246,11 +185,6 @@ export const buildDistributionPackage = async ( · 29 unmodified lines
246185 });
247186
248187 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"));
254188 await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));
255189 await mkdir(join(binDir, "openapi-extractor-wasm"), { recursive: true });
256190 await cp(
@@ -273,9 +207,7 @@ export const buildDistributionPackage = async ( · 16 unmodified lines
273207 }));
274208 await cp(readmePath, join(packageDir, "README.md"));
275209 await writeFile(launcherPath, createLauncherSource());
276− await writeFile(postinstallPath, createPostinstallSource());
277210 await chmod(launcherPath, 0o755);
278− await chmod(postinstallPath, 0o755);
279211
280212 return {
281213 packageDir,
apps/executor/src/distribution/build-package.ts+0−58
@@ -1,58 +0,0 @@
1−import { buildDistributionPackage } from "./artifact";
2−
3−type BuildPackageCliOptions = {
4− outputDir?: string;
5− packageName?: string;
6− packageVersion?: string;
7− buildWeb: boolean;
8−};
9−
10−const 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−
52−const 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−
58−await main();
apps/executor/src/distribution/build-portable.ts+0−78
@@ -1,78 +0,0 @@
1−import {
2− buildPortableDistribution,
3− type BuildPortableDistributionOptions,
4−} from "./portable";
5−
6−type BuildPortableCliOptions = BuildPortableDistributionOptions;
7−
8−const 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−
69−const 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−
78−await main();
apps/executor/src/distribution/build-release.ts+0−137
@@ -1,137 +0,0 @@
1−import { createHash } from "node:crypto";
2−import { spawn } from "node:child_process";
3−import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4−import { join, relative, resolve } from "node:path";
5−
6−import { buildDistributionPackage } from "./artifact";
7−import { repoRoot } from "./metadata";
8−import { buildPortableDistribution, portableTargetIds } from "./portable";
9−
10−const defaultReleaseDir = resolve(repoRoot, "apps/executor/dist/release");
11−
12−type CommandInput = {
13− command: string;
14− args: ReadonlyArray<string>;
15− cwd: string;
16−};
17−
18−type CommandOutput = {
19− stdout: string;
20− stderr: string;
21−};
22−
23−type PackResult = {
24− filename?: string;
25−};
26−
27−const 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−
72−const computeSha256 = (contents: Uint8Array): string =>
73− createHash("sha256").update(contents).digest("hex");
74−
75−const 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−
94−const 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−
110−const 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−
137−await main();
apps/executor/src/distribution/portable.test.ts+0−232
@@ -1,232 +0,0 @@
1−import { spawn } from "node:child_process";
2−import { existsSync } from "node:fs";
3−import { mkdir, mkdtemp, rm } from "node:fs/promises";
4−import { createServer } from "node:net";
5−import { tmpdir } from "node:os";
6−import { join } from "node:path";
7−
8−import { describe, expect, it } from "vitest";
9−
10−import {
11− buildPortableDistribution,
12− resolveDefaultPortableTarget,
13−} from "./portable";
14−
15−type CommandResult = {
16− stdout: string;
17− stderr: string;
18− exitCode: number;
19−};
20−
21−const 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−
73−const 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−
95−describe("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 @@
1−import { createHash } from "node:crypto";
2−import { spawn } from "node:child_process";
3−import { existsSync } from "node:fs";
4−import {
5− chmod,
6− copyFile,
7− cp,
8− mkdir,
9− mkdtemp,
10− readFile,
11− rm,
12− writeFile,
13−} from "node:fs/promises";
14−import { tmpdir } from "node:os";
15−import { basename, join, resolve } from "node:path";
16−
17−import { buildDistributionPackage } from "./artifact";
18−import { readDistributionPackageMetadata, repoRoot } from "./metadata";
19−
20−const defaultOutputDir = resolve(repoRoot, "apps/executor/dist/portable");
21−const defaultNodeCacheDir = resolve(repoRoot, "apps/executor/.cache/portable-node");
22−const DEFAULT_OFFICIAL_NODE_VERSION = "v24.14.0";
23−
24−type PortableTarget = {
25− id: string;
26− archiveExtension: "tar.gz" | "zip";
27− archiveFileName: (nodeVersion: string) => string;
28− extractedExecutablePath: (nodeVersion: string) => string;
29− bundledExecutableName: string;
30−};
31−
32−const 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−
77−export const portableTargetIds = Object.freeze(Object.keys(portableTargets));
78−
79−export 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−
89−export type PortableBundleArtifact = {
90− target: string;
91− bundleDir: string;
92− launcherPath: string;
93− installScriptPath: string;
94− archivePath: string | null;
95− nodeVersion: string;
96−};
97−
98−export type PortableDistributionArtifact = {
99− outputDir: string;
100− artifacts: ReadonlyArray<PortableBundleArtifact>;
101−};
102−
103−
104−type NodeShasums = Map<string, string>;
105−
106−type CommandInput = {
107− command: string;
108− args: ReadonlyArray<string>;
109− cwd: string;
110−};
111−
112−type CommandOutput = {
113− stdout: string;
114− stderr: string;
115−};
116−
117−const 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−
162−const normalizeNodeVersion = (value: string): string =>
163− value.startsWith("v") ? value : `v${value}`;
164−
165−const computeSha256 = (contents: Uint8Array): string =>
166− createHash("sha256").update(contents).digest("hex");
167−
168−const 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−
184−const 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−
212−export const resolveDefaultPortableTarget = (): string => getHostTargetId();
213−
214−const 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−
227−const 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−
236−const 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−
333−const 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−
341−const 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−
348−const 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−
425−const 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−
464−const 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−
503−const ensureRuntimeExecutable = async (input: {
504− target: PortableTarget;
505− nodeVersion: string;
506− cacheDir: string;
507− destinationPath: string;
508−}): Promise<void> => {
509− await ensureOfficialNodeRuntime(input);
510−};
511−
512−export 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 @@
1+import { spawn } from "node:child_process";
2+import { mkdir, rm } from "node:fs/promises";
3+import { join, resolve } from "node:path";
4+
5+import { buildDistributionPackage } from "./artifact";
6+import { readDistributionPackageMetadata, repoRoot } from "./metadata";
7+
8+const defaultReleaseDir = resolve(repoRoot, "apps/executor/dist/release");
9+const 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+
12+type PublishCliOptions = {
13+ dryRun: boolean;
14+};
15+
16+type CommandInput = {
17+ command: string;
18+ args: ReadonlyArray<string>;
19+ cwd: string;
20+};
21+
22+type CommandOutput = {
23+ stdout: string;
24+ stderr: string;
25+};
26+
27+type PackResult = {
28+ filename?: string;
29+};
30+
31+const 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+
48+const 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+
93+const 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+
107+const 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+
116+const 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+
122+const resolveChannel = (version: string): "latest" | "beta" =>
123+ version.includes("-") ? "beta" : "latest";
124+
125+const 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+
141+const 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+
154+const 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+
189+const 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+
224+await main();