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 @@
1{1{
2 "name": "executor",2 "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",
3 "private": true,22 "private": true,
4 "type": "module",23 "type": "module",
5 "bin": {24 "bin": {
@@ -9,7 +28,12 @@ · 3 unmodified lines
9 "typecheck": "bunx tsc --noEmit -p tsconfig.json",28 "typecheck": "bunx tsc --noEmit -p tsconfig.json",
10 "test": "vitest run",29 "test": "vitest run",
11 "test:watch": "vitest",30 "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"
13 },37 },
14 "dependencies": {38 "dependencies": {
15 "@effect/cli": "catalog:",39 "@effect/cli": "catalog:",
apps/executor/src/distribution/artifact.ts+215−0
@@ -0,0 +1,215 @@
1import { spawn } from "node:child_process";
2import { existsSync, readdirSync } from "node:fs";
3import { chmod, cp, mkdir, rm, writeFile } from "node:fs/promises";
4import { join, resolve } from "node:path";
5
6import { readDistributionPackageMetadata, repoRoot } from "./metadata";
7
8const defaultOutputDir = resolve(repoRoot, "apps/executor/dist/npm");
9
10export type BuildDistributionPackageOptions = {
11 outputDir?: string;
12 packageName?: string;
13 packageVersion?: string;
14 buildWeb?: boolean;
15};
16
17export type DistributionPackageArtifact = {
18 packageDir: string;
19 launcherPath: string;
20 bundlePath: string;
21 resourcesDir: string;
22};
23
24type CommandInput = {
25 command: string;
26 args: ReadonlyArray<string>;
27 cwd: string;
28};
29
30const 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
72const 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
96const 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
138const createLauncherSource = () => [
139 "#!/usr/bin/env node",
140 'import "./executor.mjs";',
141 "",
142].join("\n");
143
144export 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 @@
1import { buildDistributionPackage } from "./artifact";
2
3type BuildPackageCliOptions = {
4 outputDir?: string;
5 packageName?: string;
6 packageVersion?: string;
7 buildWeb: boolean;
8};
9
10const parseArgs = (argv: ReadonlyArray<string>): BuildPackageCliOptions => {
11 const options: BuildPackageCliOptions = {
12 buildWeb: true,
13 };
14
15 for (let index = 0; index < argv.length; index += 1) {
16 const arg = argv[index];
17
18 if (arg === "--skip-web-build") {
19 options.buildWeb = false;
20 continue;
21 }
22
23 const next = argv[index + 1];
24 if (!next) {
25 throw new Error(`Missing value for ${arg}`);
26 }
27
28 if (arg === "--output") {
29 options.outputDir = next;
30 index += 1;
31 continue;
32 }
33
34 if (arg === "--package-name") {
35 options.packageName = next;
36 index += 1;
37 continue;
38 }
39
40 if (arg === "--package-version") {
41 options.packageVersion = next;
42 index += 1;
43 continue;
44 }
45
46 throw new Error(`Unknown argument: ${arg}`);
47 }
48
49 return options;
50};
51
52const main = async () => {
53 const options = parseArgs(process.argv.slice(2));
54 const artifact = await buildDistributionPackage(options);
55 process.stdout.write(`${artifact.packageDir}\n`);
56};
57
58await main();
apps/executor/src/distribution/build-portable.ts+78−0
@@ -0,0 +1,78 @@
1import {
2 buildPortableDistribution,
3 type BuildPortableDistributionOptions,
4} from "./portable";
5
6type BuildPortableCliOptions = BuildPortableDistributionOptions;
7
8const parseArgs = (argv: ReadonlyArray<string>): BuildPortableCliOptions => {
9 const options: BuildPortableCliOptions = {
10 buildWeb: true,
11 createArchives: true,
12 };
13
14 for (let index = 0; index < argv.length; index += 1) {
15 const arg = argv[index];
16
17 if (arg === "--skip-web-build") {
18 options.buildWeb = false;
19 continue;
20 }
21
22 if (arg === "--no-archive") {
23 options.createArchives = false;
24 continue;
25 }
26
27 const next = argv[index + 1];
28 if (!next) {
29 throw new Error(`Missing value for ${arg}`);
30 }
31
32 if (arg === "--output") {
33 options.outputDir = next;
34 index += 1;
35 continue;
36 }
37
38 if (arg === "--package-name") {
39 options.packageName = next;
40 index += 1;
41 continue;
42 }
43
44 if (arg === "--package-version") {
45 options.packageVersion = next;
46 index += 1;
47 continue;
48 }
49
50 if (arg === "--target") {
51 options.targets = [...(options.targets ?? []), next];
52 index += 1;
53 continue;
54 }
55
56
57 if (arg === "--node-version") {
58 options.nodeVersion = next;
59 index += 1;
60 continue;
61 }
62
63 throw new Error(`Unknown argument: ${arg}`);
64 }
65
66 return options;
67};
68
69const main = async () => {
70 const options = parseArgs(process.argv.slice(2));
71 const artifact = await buildPortableDistribution(options);
72
73 for (const bundle of artifact.artifacts) {
74 process.stdout.write(`${bundle.archivePath ?? bundle.bundleDir}\n`);
75 }
76};
77
78await main();
apps/executor/src/distribution/distribution.test.ts+88−0
@@ -0,0 +1,88 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3
4import {
5 DistributionHarness,
6 LocalDistributionHarnessLive,
7} from "./harness";
8
9describe("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 @@
1import { spawn } from "node:child_process";
2import { mkdir, mkdtemp, rm } from "node:fs/promises";
3import { createServer } from "node:net";
4import { tmpdir } from "node:os";
5import { dirname, join } from "node:path";
6
7import * as Context from "effect/Context";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10
11import { buildDistributionPackage } from "./artifact";
12
13type CommandResult = {
14 readonly stdout: string;
15 readonly stderr: string;
16 readonly exitCode: number;
17};
18
19export 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
51const 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
110const 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
137const 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
146const 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
169export 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 @@
1import { readFile } from "node:fs/promises";
2import { dirname, resolve } from "node:path";
3import { fileURLToPath } from "node:url";
4
5export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../..");
6const packageMetadataPath = resolve(repoRoot, "apps/executor/package.json");
7
8export 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
24export 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 @@
1import { spawn } from "node:child_process";
2import { existsSync } from "node:fs";
3import { mkdir, mkdtemp, rm } from "node:fs/promises";
4import { createServer } from "node:net";
5import { tmpdir } from "node:os";
6import { join } from "node:path";
7
8import { describe, expect, it } from "vitest";
9
10import {
11 buildPortableDistribution,
12 resolveDefaultPortableTarget,
13} from "./portable";
14
15type CommandResult = {
16 stdout: string;
17 stderr: string;
18 exitCode: number;
19};
20
21const runCommand = async (input: {
22 command: string;
23 args: ReadonlyArray<string>;
24 cwd: string;
25 env: NodeJS.ProcessEnv;
26 okExitCodes?: ReadonlyArray<number>;
27}): Promise<CommandResult> => {
28 const child = spawn(input.command, [...input.args], {
29 cwd: input.cwd,
30 env: input.env,
31 stdio: ["ignore", "pipe", "pipe"],
32 });
33
34 let stdout = "";
35 let stderr = "";
36
37 child.stdout?.setEncoding("utf8");
38 child.stdout?.on("data", (chunk) => {
39 stdout += chunk;
40 });
41
42 child.stderr?.setEncoding("utf8");
43 child.stderr?.on("data", (chunk) => {
44 stderr += chunk;
45 });
46
47 const exitCode = await new Promise<number>((resolveExitCode, reject) => {
48 child.once("error", reject);
49 child.once("close", (code) => {
50 resolveExitCode(code ?? -1);
51 });
52 });
53
54 const result = {
55 stdout: stdout.trim(),
56 stderr: stderr.trim(),
57 exitCode,
58 } satisfies CommandResult;
59
60 if ((input.okExitCodes ?? [0]).includes(exitCode)) {
61 return result;
62 }
63
64 throw new Error(
65 [
66 `${input.command} ${input.args.join(" ")} exited with code ${exitCode}`,
67 result.stdout.length > 0 ? `stdout:\n${result.stdout}` : null,
68 result.stderr.length > 0 ? `stderr:\n${result.stderr}` : null,
69 ].filter((part) => part !== null).join("\n\n"),
70 );
71};
72
73const allocatePort = async (): Promise<number> =>
74 await new Promise<number>((resolvePort, reject) => {
75 const server = createServer();
76 server.once("error", reject);
77 server.listen(0, "127.0.0.1", () => {
78 const address = server.address();
79 if (!address || typeof address === "string") {
80 server.close(() => reject(new Error("Failed to allocate test port")));
81 return;
82 }
83
84 server.close((error) => {
85 if (error) {
86 reject(error);
87 return;
88 }
89
90 resolvePort(address.port);
91 });
92 });
93 });
94
95describe("portable distribution flow", () => {
96 it("installs and boots a portable bundle in a fresh home", async () => {
97 const tempRoot = await mkdtemp(join(tmpdir(), "executor-portable-"));
98
99 try {
100 const distDir = join(tempRoot, "dist");
101 const homeDir = join(tempRoot, "home");
102 const binDir = join(homeDir, ".local", "bin");
103 const installHome = 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 @@
1import { createHash } from "node:crypto";
2import { spawn } from "node:child_process";
3import { existsSync } from "node:fs";
4import {
5 chmod,
6 copyFile,
7 cp,
8 mkdir,
9 mkdtemp,
10 readFile,
11 rm,
12 writeFile,
13} from "node:fs/promises";
14import { tmpdir } from "node:os";
15import { basename, join, resolve } from "node:path";
16
17import { buildDistributionPackage } from "./artifact";
18import { readDistributionPackageMetadata, repoRoot } from "./metadata";
19
20const defaultOutputDir = resolve(repoRoot, "apps/executor/dist/portable");
21const defaultNodeCacheDir = resolve(repoRoot, "apps/executor/.cache/portable-node");
22const DEFAULT_OFFICIAL_NODE_VERSION = "v24.14.0";
23
24type PortableTarget = {
25 id: string;
26 archiveExtension: "tar.gz" | "zip";
27 archiveFileName: (nodeVersion: string) => string;
28 extractedExecutablePath: (nodeVersion: string) => string;
29 bundledExecutableName: string;
30};
31
32const portableTargets: Record<string, PortableTarget> = {
33 "linux-x64": {
34 id: "linux-x64",
35 archiveExtension: "tar.gz",
36 archiveFileName: (nodeVersion: string) => `node-${nodeVersion}-linux-x64.tar.gz`,
37 extractedExecutablePath: (nodeVersion: string) => join(`node-${nodeVersion}-linux-x64`, "bin", "node"),
38 bundledExecutableName: "node",
39 },
40 "linux-arm64": {
41 id: "linux-arm64",
42 archiveExtension: "tar.gz",
43 archiveFileName: (nodeVersion: string) => `node-${nodeVersion}-linux-arm64.tar.gz`,
44 extractedExecutablePath: (nodeVersion: string) => join(`node-${nodeVersion}-linux-arm64`, "bin", "node"),
45 bundledExecutableName: "node",
46 },
47 "darwin-x64": {
48 id: "darwin-x64",
49 archiveExtension: "tar.gz",
50 archiveFileName: (nodeVersion: string) => `node-${nodeVersion}-darwin-x64.tar.gz`,
51 extractedExecutablePath: (nodeVersion: string) => join(`node-${nodeVersion}-darwin-x64`, "bin", "node"),
52 bundledExecutableName: "node",
53 },
54 "darwin-arm64": {
55 id: "darwin-arm64",
56 archiveExtension: "tar.gz",
57 archiveFileName: (nodeVersion: string) => `node-${nodeVersion}-darwin-arm64.tar.gz`,
58 extractedExecutablePath: (nodeVersion: string) => join(`node-${nodeVersion}-darwin-arm64`, "bin", "node"),
59 bundledExecutableName: "node",
60 },
61 "win-x64": {
62 id: "win-x64",
63 archiveExtension: "zip",
64 archiveFileName: (nodeVersion: string) => `node-${nodeVersion}-win-x64.zip`,
65 extractedExecutablePath: (nodeVersion: string) => join(`node-${nodeVersion}-win-x64`, "node.exe"),
66 bundledExecutableName: "node.exe",
67 },
68 "win-arm64": {
69 id: "win-arm64",
70 archiveExtension: "zip",
71 archiveFileName: (nodeVersion: string) => `node-${nodeVersion}-win-arm64.zip`,
72 extractedExecutablePath: (nodeVersion: string) => join(`node-${nodeVersion}-win-arm64`, "node.exe"),
73 bundledExecutableName: "node.exe",
74 },
75};
76
77export 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
87export type PortableBundleArtifact = {
88 target: string;
89 bundleDir: string;
90 launcherPath: string;
91 installScriptPath: string;
92 archivePath: string | null;
93 nodeVersion: string;
94};
95
96export type PortableDistributionArtifact = {
97 outputDir: string;
98 artifacts: ReadonlyArray<PortableBundleArtifact>;
99};
100
101
102type NodeShasums = Map<string, string>;
103
104type CommandInput = {
105 command: string;
106 args: ReadonlyArray<string>;
107 cwd: string;
108};
109
110type CommandOutput = {
111 stdout: string;
112 stderr: string;
113};
114
115const 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
160const normalizeNodeVersion = (value: string): string =>
161 value.startsWith("v") ? value : `v${value}`;
162
163const computeSha256 = (contents: Uint8Array): string =>
164 createHash("sha256").update(contents).digest("hex");
165
166const 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
182const 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
210export const resolveDefaultPortableTarget = (): string => getHostTargetId();
211
212const 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
225const 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
234const 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
331const 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
339const 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
346const 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
423const 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
462const 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
501const ensureRuntimeExecutable = async (input: {
502 target: PortableTarget;
503 nodeVersion: string;
504 cacheDir: string;
505 destinationPath: string;
506}): Promise<void> => {
507 await ensureOfficialNodeRuntime(input);
508};
509
510export 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};