History

add distribution build system for npm packages and portable bundles

Implement build-package (bundles JS, web assets, migrations, and PGlite into an npm-publishable package) and build-portable (downloads official Node.js binaries and creates self-contained installers for macOS, Linux, and Windows). Includes integration tests and metadata utilities.

Rhys Sullivan committed 8 months ago

Commit f41e5bd1 parent 9d549f79 files · +1603 −1Browse files

Unified · Split

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