Unified · Split
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { spawn } from "node:child_process"; |
| 2 | 2 | import { existsSync } from "node:fs"; |
| 3 | −import { chmod, cp, mkdir, rm, writeFile } from "node:fs/promises"; | |
| 3 | +import { chmod, cp, mkdir, rename, rm, writeFile } from "node:fs/promises"; | |
| 4 | 4 | import { createRequire } from "node:module"; |
| 5 | 5 | import { dirname, join, resolve } from "node:path"; |
| 6 | 6 | |
| @@ -136,10 +136,77 @@ const createPackageJson = (input: { · 129 unmodified lines | ||
| 136 | 136 | |
| 137 | 137 | const createLauncherSource = () => [ |
| 138 | 138 | "#!/usr/bin/env node", |
| 139 | − 'import "./executor.mjs";', | |
| 139 | + 'import { readFileSync, readdirSync } from "node:fs";', | |
| 140 | + 'import { dirname, join } from "node:path";', | |
| 141 | + 'import { fileURLToPath } from "node:url";', | |
| 142 | + "", | |
| 143 | + "const isMusl = () => {", | |
| 144 | + ' try {', | |
| 145 | + ' return readFileSync("/usr/bin/ldd", "utf8").includes("musl");', | |
| 146 | + " } catch {}", | |
| 147 | + "", | |
| 148 | + ' if (typeof process.report?.getReport === "function") {', | |
| 149 | + " const report = process.report.getReport();", | |
| 150 | + " if (report?.header?.glibcVersionRuntime) {", | |
| 151 | + " return false;", | |
| 152 | + " }", | |
| 153 | + " if (Array.isArray(report?.sharedObjects)) {", | |
| 154 | + ' return report.sharedObjects.some((file) => file.includes("libc.musl-") || file.includes("ld-musl-"));', | |
| 155 | + " }", | |
| 156 | + " }", | |
| 157 | + "", | |
| 158 | + " return false;", | |
| 159 | + "};", | |
| 160 | + "", | |
| 161 | + "const resolveBundledKeyringNativeLibraryPath = () => {", | |
| 162 | + ' if (process.platform !== "linux" || process.arch !== "x64") {', | |
| 163 | + " return null;", | |
| 164 | + " }", | |
| 165 | + "", | |
| 166 | + " const binDir = dirname(fileURLToPath(import.meta.url));", | |
| 167 | + ' const pattern = isMusl()', | |
| 168 | + ' ? /^keyring\\.linux-x64-musl-.*\\.node$/', | |
| 169 | + ' : /^keyring\\.linux-x64-gnu-.*\\.node$/;', | |
| 170 | + ' const match = readdirSync(binDir).find((entry) => pattern.test(entry));', | |
| 171 | + " return match ? join(binDir, match) : null;", | |
| 172 | + "};", | |
| 173 | + "", | |
| 174 | + "const bundledKeyringNativeLibraryPath = resolveBundledKeyringNativeLibraryPath();", | |
| 175 | + 'if (bundledKeyringNativeLibraryPath && !process.env.NAPI_RS_NATIVE_LIBRARY_PATH) {', | |
| 176 | + " process.env.NAPI_RS_NATIVE_LIBRARY_PATH = bundledKeyringNativeLibraryPath;", | |
| 177 | + "}", | |
| 178 | + "", | |
| 179 | + 'await import("./executor.mjs");', | |
| 140 | 180 | "", |
| 141 | 181 | ].join("\n"); |
| 142 | 182 | |
| 183 | +const buildCliBundle = async (input: { | |
| 184 | + binDir: string; | |
| 185 | + bundlePath: string; | |
| 186 | +}): Promise<void> => { | |
| 187 | + const builtEntrypointPath = join(input.binDir, "main.js"); | |
| 188 | + | |
| 189 | + await runCommand({ | |
| 190 | + command: "bun", | |
| 191 | + args: [ | |
| 192 | + "build", | |
| 193 | + "./apps/executor/src/cli/main.ts", | |
| 194 | + "--target", | |
| 195 | + "node", | |
| 196 | + "--outdir", | |
| 197 | + input.binDir, | |
| 198 | + ], | |
| 199 | + cwd: repoRoot, | |
| 200 | + }); | |
| 201 | + | |
| 202 | + if (!existsSync(builtEntrypointPath)) { | |
| 203 | + throw new Error(`Missing bundled CLI entrypoint at ${builtEntrypointPath}`); | |
| 204 | + } | |
| 205 | + | |
| 206 | + await rm(input.bundlePath, { force: true }); | |
| 207 | + await rename(builtEntrypointPath, input.bundlePath); | |
| 208 | +}; | |
| 209 | + | |
| 143 | 210 | export const buildDistributionPackage = async ( |
| 144 | 211 | options: BuildDistributionPackageOptions = {}, |
| 145 | 212 | ): Promise<DistributionPackageArtifact> => { |
| @@ -172,17 +239,9 @@ export const buildDistributionPackage = async ( · 26 unmodified lines | ||
| 172 | 239 | throw new Error(`Missing built web assets at ${webDistDir}`); |
| 173 | 240 | } |
| 174 | 241 | |
| 175 | − await runCommand({ | |
| 176 | − command: "bun", | |
| 177 | − args: [ | |
| 178 | − "build", | |
| 179 | − "./apps/executor/src/cli/main.ts", | |
| 180 | − "--target", | |
| 181 | − "node", | |
| 182 | − "--outfile", | |
| 183 | − bundlePath, | |
| 184 | − ], | |
| 185 | − cwd: repoRoot, | |
| 242 | + await buildCliBundle({ | |
| 243 | + binDir, | |
| 244 | + bundlePath, | |
| 186 | 245 | }); |
| 187 | 246 | |
| 188 | 247 | await cp(webDistDir, webDir, { recursive: true }); |
| @@ -25,37 +25,17 @@ describe("distribution flow", () => { · 24 unmodified lines | ||
| 25 | 25 | } |
| 26 | 26 | `); |
| 27 | 27 | |
| 28 | − const initialDoctor = yield* runCommand([ | |
| 29 | − "doctor", | |
| 30 | − "--json", | |
| 31 | − "--base-url", | |
| 32 | − harness.baseUrl, | |
| 33 | − ]); | |
| 34 | − const initialDoctorJson = JSON.parse(initialDoctor.stdout) as { | |
| 35 | − ok: boolean; | |
| 36 | − checks: Record<string, { ok: boolean }>; | |
| 37 | − }; | |
| 38 | − expect(initialDoctorJson.ok).toBe(false); | |
| 39 | − expect(initialDoctorJson.checks.webAssets?.ok).toBe(true); | |
| 40 | − expect(initialDoctorJson.checks.database?.ok).toBe(true); | |
| 41 | − | |
| 42 | − yield* runCommand(["up", "--base-url", harness.baseUrl]); | |
| 28 | + expect(yield* harness.isReachable()).toBe(false); | |
| 43 | 29 | |
| 44 | − const statusResult = yield* runCommand([ | |
| 45 | − "status", | |
| 46 | − "--json", | |
| 30 | + const initialCall = yield* runCommand([ | |
| 31 | + "call", | |
| 32 | + "return 1 + 1;", | |
| 47 | 33 | "--base-url", |
| 48 | 34 | harness.baseUrl, |
| 35 | + "--no-open", | |
| 49 | 36 | ]); |
| 50 | − const status = JSON.parse(statusResult.stdout) as { | |
| 51 | − reachable: boolean; | |
| 52 | − pidRunning: boolean; | |
| 53 | − installation: { workspaceId: string; accountId: string } | null; | |
| 54 | − }; | |
| 55 | − | |
| 56 | − expect(status.reachable).toBe(true); | |
| 57 | − expect(status.pidRunning).toBe(true); | |
| 58 | − expect(status.installation).not.toBeNull(); | |
| 37 | + expect(initialCall.stdout).toBe("2"); | |
| 38 | + expect(yield* harness.isReachable()).toBe(true); | |
| 59 | 39 | |
| 60 | 40 | const html = yield* harness.fetchText("/"); |
| 61 | 41 | expect(html.status).toBe(200); |
| @@ -80,8 +60,17 @@ describe("distribution flow", () => { · 18 unmodified lines | ||
| 80 | 60 | ); |
| 81 | 61 | expect(sesCall.stderr).toContain("fetch is disabled in SES executor"); |
| 82 | 62 | |
| 83 | − yield* runCommand(["down", "--base-url", harness.baseUrl]); | |
| 84 | − yield* runCommand(["up", "--base-url", harness.baseUrl]); | |
| 63 | + yield* harness.stopServer(); | |
| 64 | + expect(yield* harness.isReachable()).toBe(false); | |
| 65 | + | |
| 66 | + const restartedCall = yield* runCommand([ | |
| 67 | + "call", | |
| 68 | + "return 3;", | |
| 69 | + "--base-url", | |
| 70 | + harness.baseUrl, | |
| 71 | + "--no-open", | |
| 72 | + ]); | |
| 73 | + expect(restartedCall.stdout).toBe("3"); | |
| 85 | 74 | |
| 86 | 75 | const installationAfterRestartResponse = yield* harness.fetchText("/v1/local/installation"); |
| 87 | 76 | expect(installationAfterRestartResponse.status).toBe(200); |
| @@ -95,7 +84,8 @@ describe("distribution flow", () => { · 7 unmodified lines | ||
| 95 | 84 | expect(installationAfterRestart.scopeId).toBe(installation.scopeId); |
| 96 | 85 | expect(installationAfterRestart.actorScopeId).toBe(installation.actorScopeId); |
| 97 | 86 | |
| 98 | − yield* runCommand(["down", "--base-url", harness.baseUrl]); | |
| 87 | + yield* harness.stopServer(); | |
| 88 | + expect(yield* harness.isReachable()).toBe(false); | |
| 99 | 89 | }); |
| 100 | 90 | |
| 101 | 91 | it.live("boots a staged package artifact in a fresh home", () => |
| @@ -53,9 +53,14 @@ export class DistributionHarness extends Context.Tag( · 52 unmodified lines | ||
| 53 | 53 | readonly body: string; |
| 54 | 54 | readonly contentType: string | null; |
| 55 | 55 | }, Error, never>; |
| 56 | + readonly isReachable: () => Effect.Effect<boolean, Error, never>; | |
| 57 | + readonly stopServer: () => Effect.Effect<void, Error, never>; | |
| 56 | 58 | } |
| 57 | 59 | >() {} |
| 58 | 60 | |
| 61 | +const SERVER_WAIT_TIMEOUT_MS = 5_000; | |
| 62 | +const SERVER_POLL_INTERVAL_MS = 100; | |
| 63 | + | |
| 59 | 64 | const runCommand = (input: { |
| 60 | 65 | readonly command: string; |
| 61 | 66 | readonly args: ReadonlyArray<string>; |
| @@ -174,6 +179,36 @@ const packPackage = (packageDir: string, outputDir: string) => · 112 unmodified lines | ||
| 174 | 179 | }), |
| 175 | 180 | ); |
| 176 | 181 | |
| 182 | +const waitForReachability = (input: { | |
| 183 | + baseUrl: string; | |
| 184 | + expected: boolean; | |
| 185 | +}): Effect.Effect<void, Error, never> => | |
| 186 | + Effect.gen(function* () { | |
| 187 | + const startedAt = Date.now(); | |
| 188 | + | |
| 189 | + while (Date.now() - startedAt < SERVER_WAIT_TIMEOUT_MS) { | |
| 190 | + const reachable = yield* Effect.tryPromise({ | |
| 191 | + try: async () => { | |
| 192 | + const response = await fetch(new URL("/", input.baseUrl)); | |
| 193 | + return response.ok; | |
| 194 | + }, | |
| 195 | + catch: toError, | |
| 196 | + }).pipe(Effect.catchAll(() => Effect.succeed(false))); | |
| 197 | + | |
| 198 | + if (reachable === input.expected) { | |
| 199 | + return; | |
| 200 | + } | |
| 201 | + | |
| 202 | + yield* Effect.sleep(`${SERVER_POLL_INTERVAL_MS} millis`); | |
| 203 | + } | |
| 204 | + | |
| 205 | + return yield* Effect.fail( | |
| 206 | + new Error( | |
| 207 | + `Timed out waiting for executor server to become ${input.expected ? "reachable" : "unreachable"} at ${input.baseUrl}`, | |
| 208 | + ), | |
| 209 | + ); | |
| 210 | + }); | |
| 211 | + | |
| 177 | 212 | export const LocalDistributionHarnessLive = Layer.scoped( |
| 178 | 213 | DistributionHarness, |
| 179 | 214 | Effect.gen(function* () { |
| @@ -264,6 +299,58 @@ export const LocalDistributionHarnessLive = Layer.scoped( · 84 unmodified lines | ||
| 264 | 299 | catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)), |
| 265 | 300 | }); |
| 266 | 301 | |
| 302 | + const isReachable = () => | |
| 303 | + Effect.tryPromise({ | |
| 304 | + try: async () => { | |
| 305 | + const response = await fetch(new URL("/", baseUrl)); | |
| 306 | + return response.ok; | |
| 307 | + }, | |
| 308 | + catch: toError, | |
| 309 | + }).pipe(Effect.catchAll(() => Effect.succeed(false))); | |
| 310 | + | |
| 311 | + const stopServer = () => | |
| 312 | + Effect.gen(function* () { | |
| 313 | + const pidFile = join(executorHome, "run", "server.pid"); | |
| 314 | + const exists = yield* fs.exists(pidFile).pipe(Effect.mapError(toError)); | |
| 315 | + if (!exists) { | |
| 316 | + return; | |
| 317 | + } | |
| 318 | + | |
| 319 | + const contents = yield* fs.readFileString(pidFile).pipe( | |
| 320 | + Effect.mapError(toError), | |
| 321 | + ); | |
| 322 | + const parsed = JSON.parse(contents) as { pid?: number }; | |
| 323 | + const pid = parsed.pid; | |
| 324 | + | |
| 325 | + if (typeof pid === "number" && Number.isInteger(pid) && pid > 0) { | |
| 326 | + yield* Effect.try({ | |
| 327 | + try: () => process.kill(pid, "SIGTERM"), | |
| 328 | + catch: toError, | |
| 329 | + }).pipe(Effect.catchAll(() => Effect.void)); | |
| 330 | + | |
| 331 | + const stopped = yield* waitForReachability({ | |
| 332 | + baseUrl, | |
| 333 | + expected: false, | |
| 334 | + }).pipe( | |
| 335 | + Effect.as(true), | |
| 336 | + Effect.catchAll(() => Effect.succeed(false)), | |
| 337 | + ); | |
| 338 | + if (stopped) { | |
| 339 | + return; | |
| 340 | + } | |
| 341 | + | |
| 342 | + yield* Effect.try({ | |
| 343 | + try: () => process.kill(pid, "SIGKILL"), | |
| 344 | + catch: toError, | |
| 345 | + }).pipe(Effect.catchAll(() => Effect.void)); | |
| 346 | + } | |
| 347 | + | |
| 348 | + yield* waitForReachability({ | |
| 349 | + baseUrl, | |
| 350 | + expected: false, | |
| 351 | + }); | |
| 352 | + }); | |
| 353 | + | |
| 267 | 354 | const writeProjectConfig = (contents: string) => |
| 268 | 355 | Effect.forEach( |
| 269 | 356 | [stagedWorkspaceRoot, installedWorkspaceRoot], |
| @@ -291,6 +378,8 @@ export const LocalDistributionHarnessLive = Layer.scoped( · 21 unmodified lines | ||
| 291 | 378 | run, |
| 292 | 379 | runInstalled, |
| 293 | 380 | fetchText, |
| 381 | + isReachable, | |
| 382 | + stopServer, | |
| 294 | 383 | }); |
| 295 | 384 | }).pipe(Effect.provide(NodeFileSystem.layer)), |
| 296 | 385 | ); |
| @@ -1,7 +1,4 @@ | ||
| 1 | 1 | import { randomUUID } from "node:crypto"; |
| 2 | −import { | |
| 3 | − Entry, | |
| 4 | −} from "@napi-rs/keyring"; | |
| 5 | 2 | |
| 6 | 3 | import * as Effect from "effect/Effect"; |
| 7 | 4 | |
| @@ -51,12 +48,13 @@ const keychainDisplayName = () => · 43 unmodified lines | ||
| 51 | 48 | ? "Windows Credential Manager" |
| 52 | 49 | : "Desktop Keyring"; |
| 53 | 50 | |
| 54 | −const createKeyringEntry = (input: { | |
| 55 | − providerHandle: string; | |
| 56 | − keychainServiceName: string; | |
| 57 | −}) => | |
| 58 | − Effect.try({ | |
| 59 | − try: () => { | |
| 51 | +let keyringEntryConstructorPromise: Promise< | |
| 52 | + (typeof import("@napi-rs/keyring"))["Entry"] | |
| 53 | +> | null = null; | |
| 54 | + | |
| 55 | +const loadKeyringEntryConstructor = () => | |
| 56 | + Effect.tryPromise({ | |
| 57 | + try: async () => { | |
| 60 | 58 | if (!isSupportedPlatform()) { |
| 61 | 59 | throw runtimeEffectError( |
| 62 | 60 | "plugin-keychain-secret-store", |
| @@ -64,11 +62,30 @@ const createKeyringEntry = (input: { · 1 unmodified line | ||
| 64 | 62 | ); |
| 65 | 63 | } |
| 66 | 64 | |
| 67 | − return new Entry(input.keychainServiceName, input.providerHandle); | |
| 65 | + keyringEntryConstructorPromise ??= import("@napi-rs/keyring").then( | |
| 66 | + ({ Entry }) => Entry, | |
| 67 | + ); | |
| 68 | + | |
| 69 | + return await keyringEntryConstructorPromise; | |
| 68 | 70 | }, |
| 69 | − catch: toError, | |
| 71 | + catch: (cause) => | |
| 72 | + runtimeEffectError( | |
| 73 | + "plugin-keychain-secret-store", | |
| 74 | + `system-keyring: failed loading native keyring support: ${toError(cause).message}`, | |
| 75 | + ), | |
| 70 | 76 | }); |
| 71 | 77 | |
| 78 | +const createKeyringEntry = (input: { | |
| 79 | + providerHandle: string; | |
| 80 | + keychainServiceName: string; | |
| 81 | +}) => | |
| 82 | + Effect.flatMap(loadKeyringEntryConstructor(), (Entry) => | |
| 83 | + Effect.try({ | |
| 84 | + try: () => new Entry(input.keychainServiceName, input.providerHandle), | |
| 85 | + catch: toError, | |
| 86 | + }) | |
| 87 | + ); | |
| 88 | + | |
| 72 | 89 | const readKeychainSecretValue = (input: { |
| 73 | 90 | providerHandle: string; |
| 74 | 91 | keychainServiceName: string; |