Unified · Split
| @@ -1,6 +1,6 @@ | |||
| 1 | import { spawn } from "node:child_process"; | 1 | import { spawn } from "node:child_process"; |
| 2 | import { existsSync } from "node:fs"; | 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 | import { createRequire } from "node:module"; | 4 | import { createRequire } from "node:module"; |
| 5 | import { dirname, join, resolve } from "node:path"; | 5 | import { dirname, join, resolve } from "node:path"; |
| 6 | 6 | ||
| @@ -136,10 +136,77 @@ const createPackageJson = (input: { · 129 unmodified lines | |||
| 136 | 136 | ||
| 137 | const createLauncherSource = () => [ | 137 | const createLauncherSource = () => [ |
| 138 | "#!/usr/bin/env node", | 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 | ].join("\n"); | 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 | export const buildDistributionPackage = async ( | 210 | export const buildDistributionPackage = async ( |
| 144 | options: BuildDistributionPackageOptions = {}, | 211 | options: BuildDistributionPackageOptions = {}, |
| 145 | ): Promise<DistributionPackageArtifact> => { | 212 | ): Promise<DistributionPackageArtifact> => { |
| @@ -172,17 +239,9 @@ export const buildDistributionPackage = async ( · 26 unmodified lines | |||
| 172 | throw new Error(`Missing built web assets at ${webDistDir}`); | 239 | throw new Error(`Missing built web assets at ${webDistDir}`); |
| 173 | } | 240 | } |
| 174 | 241 | ||
| 175 | await runCommand({ | 242 | await buildCliBundle({ |
| 176 | command: "bun", | 243 | binDir, |
| 177 | args: [ | 244 | bundlePath, |
| 178 | "build", | ||
| 179 | "./apps/executor/src/cli/main.ts", | ||
| 180 | "--target", | ||
| 181 | "node", | ||
| 182 | "--outfile", | ||
| 183 | bundlePath, | ||
| 184 | ], | ||
| 185 | cwd: repoRoot, | ||
| 186 | }); | 245 | }); |
| 187 | 246 | ||
| 188 | await cp(webDistDir, webDir, { recursive: true }); | 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([ | 28 | expect(yield* harness.isReachable()).toBe(false); |
| 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]); | ||
| 43 | 29 | ||
| 44 | const statusResult = yield* runCommand([ | 30 | const initialCall = yield* runCommand([ |
| 45 | "status", | 31 | "call", |
| 46 | "--json", | 32 | "return 1 + 1;", |
| 47 | "--base-url", | 33 | "--base-url", |
| 48 | harness.baseUrl, | 34 | harness.baseUrl, |
| 35 | "--no-open", | ||
| 49 | ]); | 36 | ]); |
| 50 | const status = JSON.parse(statusResult.stdout) as { | 37 | expect(initialCall.stdout).toBe("2"); |
| 51 | reachable: boolean; | 38 | expect(yield* harness.isReachable()).toBe(true); |
| 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(); | ||
| 59 | 39 | ||
| 60 | const html = yield* harness.fetchText("/"); | 40 | const html = yield* harness.fetchText("/"); |
| 61 | expect(html.status).toBe(200); | 41 | expect(html.status).toBe(200); |
| @@ -80,8 +60,17 @@ describe("distribution flow", () => { · 18 unmodified lines | |||
| 80 | ); | 60 | ); |
| 81 | expect(sesCall.stderr).toContain("fetch is disabled in SES executor"); | 61 | expect(sesCall.stderr).toContain("fetch is disabled in SES executor"); |
| 82 | 62 | ||
| 83 | yield* runCommand(["down", "--base-url", harness.baseUrl]); | 63 | yield* harness.stopServer(); |
| 84 | yield* runCommand(["up", "--base-url", harness.baseUrl]); | 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 | const installationAfterRestartResponse = yield* harness.fetchText("/v1/local/installation"); | 75 | const installationAfterRestartResponse = yield* harness.fetchText("/v1/local/installation"); |
| 87 | expect(installationAfterRestartResponse.status).toBe(200); | 76 | expect(installationAfterRestartResponse.status).toBe(200); |
| @@ -95,7 +84,8 @@ describe("distribution flow", () => { · 7 unmodified lines | |||
| 95 | expect(installationAfterRestart.scopeId).toBe(installation.scopeId); | 84 | expect(installationAfterRestart.scopeId).toBe(installation.scopeId); |
| 96 | expect(installationAfterRestart.actorScopeId).toBe(installation.actorScopeId); | 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 | it.live("boots a staged package artifact in a fresh home", () => | 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 | readonly body: string; | 53 | readonly body: string; |
| 54 | readonly contentType: string | null; | 54 | readonly contentType: string | null; |
| 55 | }, Error, never>; | 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 | const runCommand = (input: { | 64 | const runCommand = (input: { |
| 60 | readonly command: string; | 65 | readonly command: string; |
| 61 | readonly args: ReadonlyArray<string>; | 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 | export const LocalDistributionHarnessLive = Layer.scoped( | 212 | export const LocalDistributionHarnessLive = Layer.scoped( |
| 178 | DistributionHarness, | 213 | DistributionHarness, |
| 179 | Effect.gen(function* () { | 214 | Effect.gen(function* () { |
| @@ -264,6 +299,58 @@ export const LocalDistributionHarnessLive = Layer.scoped( · 84 unmodified lines | |||
| 264 | catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)), | 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 | const writeProjectConfig = (contents: string) => | 354 | const writeProjectConfig = (contents: string) => |
| 268 | Effect.forEach( | 355 | Effect.forEach( |
| 269 | [stagedWorkspaceRoot, installedWorkspaceRoot], | 356 | [stagedWorkspaceRoot, installedWorkspaceRoot], |
| @@ -291,6 +378,8 @@ export const LocalDistributionHarnessLive = Layer.scoped( · 21 unmodified lines | |||
| 291 | run, | 378 | run, |
| 292 | runInstalled, | 379 | runInstalled, |
| 293 | fetchText, | 380 | fetchText, |
| 381 | isReachable, | ||
| 382 | stopServer, | ||
| 294 | }); | 383 | }); |
| 295 | }).pipe(Effect.provide(NodeFileSystem.layer)), | 384 | }).pipe(Effect.provide(NodeFileSystem.layer)), |
| 296 | ); | 385 | ); |
| @@ -1,7 +1,4 @@ | |||
| 1 | import { randomUUID } from "node:crypto"; | 1 | import { randomUUID } from "node:crypto"; |
| 2 | import { | ||
| 3 | Entry, | ||
| 4 | } from "@napi-rs/keyring"; | ||
| 5 | 2 | ||
| 6 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 7 | 4 | ||
| @@ -51,12 +48,13 @@ const keychainDisplayName = () => · 43 unmodified lines | |||
| 51 | ? "Windows Credential Manager" | 48 | ? "Windows Credential Manager" |
| 52 | : "Desktop Keyring"; | 49 | : "Desktop Keyring"; |
| 53 | 50 | ||
| 54 | const createKeyringEntry = (input: { | 51 | let keyringEntryConstructorPromise: Promise< |
| 55 | providerHandle: string; | 52 | (typeof import("@napi-rs/keyring"))["Entry"] |
| 56 | keychainServiceName: string; | 53 | > | null = null; |
| 57 | }) => | 54 | |
| 58 | Effect.try({ | 55 | const loadKeyringEntryConstructor = () => |
| 59 | try: () => { | 56 | Effect.tryPromise({ |
| 57 | try: async () => { | ||
| 60 | if (!isSupportedPlatform()) { | 58 | if (!isSupportedPlatform()) { |
| 61 | throw runtimeEffectError( | 59 | throw runtimeEffectError( |
| 62 | "plugin-keychain-secret-store", | 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 | const readKeychainSecretValue = (input: { | 89 | const readKeychainSecretValue = (input: { |
| 73 | providerHandle: string; | 90 | providerHandle: string; |
| 74 | keychainServiceName: string; | 91 | keychainServiceName: string; |