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.
Unified · Split
| @@ -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:", |
| @@ -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 | }; | ||
| @@ -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(); | ||
| @@ -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(); | ||
| @@ -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 | }); | ||
| @@ -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 | ); | ||
| @@ -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 | }; | ||
| @@ -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 | }); | ||
| @@ -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 | }; | ||