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