diff --git a/src/nativeServices/servicePlan.test.ts b/src/nativeServices/servicePlan.test.ts new file mode 100644 index 0000000..f09f097 --- /dev/null +++ b/src/nativeServices/servicePlan.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createDevelopmentNativeServicePlan, + planValidationProbeRequests, + resolveProductionNativeServicePlan, + type NativeServiceAuthoritativeProbe, + type NativeServiceProbeRequest, + type NativeServiceProbeResult, + type ProductionNativeServicePlanInput, +} from "./servicePlan.js"; + +const backend = { kind: "systemd", label: "systemd user services" } as const; +const shell = { + name: "zsh", + executable: "/bin/zsh", + source: "detected", + detectedExecutable: "/bin/zsh", +} as const; + +function productionInput(): ProductionNativeServicePlanInput { + return { + backend, + shell, + environment: { PI_WEB_CONFIG: "/home/user/.config/pi-web/config.json" }, + executables: { + sessiond: { + configuredCommand: undefined, + namedCommand: "pi-web-sessiond", + bundledEntrypointPath: "/package/dist/server/sessiond.js", + }, + web: { + configuredCommand: undefined, + namedCommand: "pi-web-server", + bundledEntrypointPath: "/package/dist/server/index.js", + }, + }, + }; +} + +function completedProbe(status: "satisfied" | "unsatisfied", detail: string | null = null): NativeServiceAuthoritativeProbe { + return { + run: (request) => Promise.resolve({ + kind: "completed", + outcomes: request.prerequisites.map((prerequisite) => ({ prerequisiteId: prerequisite.id, status, detail })), + }), + }; +} + +describe("production native service planning", () => { + it("selects named commands from one authoritative backend probe and carries their exact requirements", async () => { + const requests: NativeServiceProbeRequest[] = []; + const probe: NativeServiceAuthoritativeProbe = { + run: (request) => { + requests.push(request); + return Promise.resolve({ + kind: "completed", + outcomes: request.prerequisites.map((prerequisite) => ({ + prerequisiteId: prerequisite.id, + status: "satisfied", + detail: "/usr/local/bin/example", + })), + }); + }, + }; + + const resolution = await resolveProductionNativeServicePlan(productionInput(), { + probe, + fileExists: () => false, + }); + + expect(requests).toEqual([ + { + purpose: "executable-selection", + backend, + shell, + environment: { PI_WEB_CONFIG: "/home/user/.config/pi-web/config.json" }, + workingDirectory: null, + prerequisites: [ + expect.objectContaining({ id: "sessiond.command.pi-web-sessiond", kind: "command-available", command: "pi-web-sessiond" }), + expect.objectContaining({ id: "web.command.pi-web-server", kind: "command-available", command: "pi-web-server" }), + ], + }, + ]); + expect(resolution.ok).toBe(true); + if (!resolution.ok) throw new Error(JSON.stringify(resolution.failures)); + + expect(resolution.plan).toMatchObject({ + mode: "production", + backend, + shell, + services: [ + { + id: "sessiond", + manager: { systemdName: "pi-web-sessiond.service", launchdLabel: "com.pi-web.sessiond" }, + shellCommand: "exec pi-web-sessiond", + strategy: { kind: "named-command", command: "pi-web-sessiond", selectedBy: "authoritative-backend-probe" }, + environment: { PI_WEB_CONFIG: "/home/user/.config/pi-web/config.json" }, + workingDirectory: null, + after: [], + wants: [], + prerequisites: [ + { id: "sessiond.command.pi-web-sessiond", kind: "command-available", command: "pi-web-sessiond" }, + { id: "sessiond.node", kind: "node-version", command: "node", minimumMajor: 22 }, + ], + }, + { + id: "web", + shellCommand: "exec pi-web-server", + strategy: { kind: "named-command", command: "pi-web-server" }, + after: ["sessiond"], + wants: ["sessiond"], + prerequisites: [ + { id: "web.command.pi-web-server", kind: "command-available", command: "pi-web-server" }, + { id: "web.node", kind: "node-version", command: "node", minimumMajor: 22 }, + ], + }, + ], + }); + + expect(planValidationProbeRequests(resolution.plan)).toMatchObject([ + { + purpose: "plan-validation", + backend, + shell, + workingDirectory: null, + prerequisites: [{ id: "sessiond.command.pi-web-sessiond" }, { id: "sessiond.node" }], + }, + { + purpose: "plan-validation", + backend, + shell, + workingDirectory: null, + prerequisites: [{ id: "web.command.pi-web-server" }, { id: "web.node" }], + }, + ]); + }); + + it("preserves configured overrides verbatim and never probes or executes them", async () => { + const input = productionInput(); + input.executables.sessiond.configuredCommand = " /opt/pi web/run-sessiond --flag "; + input.executables.web.configuredCommand = "custom-web --serve"; + const run = vi.fn<(request: NativeServiceProbeRequest) => Promise>(); + const fileExists = vi.fn<(path: string) => boolean>(); + + const resolution = await resolveProductionNativeServicePlan(input, { probe: { run }, fileExists }); + + expect(run).not.toHaveBeenCalled(); + expect(fileExists).not.toHaveBeenCalled(); + expect(resolution.ok).toBe(true); + if (!resolution.ok) throw new Error(JSON.stringify(resolution.failures)); + expect(resolution.plan.services).toMatchObject([ + { + id: "sessiond", + shellCommand: "exec /opt/pi web/run-sessiond --flag ", + strategy: { + kind: "configured-override", + command: " /opt/pi web/run-sessiond --flag ", + verification: "unverified", + }, + prerequisites: [], + }, + { + id: "web", + shellCommand: "exec custom-web --serve", + strategy: { kind: "configured-override", command: "custom-web --serve", verification: "unverified" }, + prerequisites: [], + }, + ]); + expect(planValidationProbeRequests(resolution.plan)).toEqual([]); + }); + + it("falls back per service to bundled entrypoints when named commands are unavailable", async () => { + const input = productionInput(); + input.executables.sessiond.bundledEntrypointPath = "/package with space/sessiond's entry.js"; + const fileExists = vi.fn<(path: string) => boolean>(() => true); + + const resolution = await resolveProductionNativeServicePlan(input, { + probe: completedProbe("unsatisfied", "command not found"), + fileExists, + }); + + expect(fileExists).toHaveBeenCalledTimes(2); + expect(resolution.ok).toBe(true); + if (!resolution.ok) throw new Error(JSON.stringify(resolution.failures)); + expect(resolution.plan.services[0]).toMatchObject({ + shellCommand: "exec node '/package with space/sessiond'\\''s entry.js'", + strategy: { + kind: "bundled-entrypoint", + command: "node", + namedCommand: "pi-web-sessiond", + namedCommandFailure: "command not found", + }, + prerequisites: [ + { id: "sessiond.node", kind: "node-version", command: "node", minimumMajor: 22 }, + { id: "sessiond.entrypoint", kind: "readable-file", path: "/package with space/sessiond's entry.js" }, + ], + }); + }); + + it("mixes configured, named, and bundled decisions without unrelated checks", async () => { + const input = productionInput(); + input.executables.sessiond.configuredCommand = "custom-sessiond"; + const requests: NativeServiceProbeRequest[] = []; + + const resolution = await resolveProductionNativeServicePlan(input, { + probe: { + run: (request) => { + requests.push(request); + return Promise.resolve({ + kind: "completed", + outcomes: [{ prerequisiteId: "web.command.pi-web-server", status: "unsatisfied", detail: null }], + }); + }, + }, + fileExists: () => true, + }); + + expect(requests[0]?.prerequisites).toMatchObject([{ id: "web.command.pi-web-server", command: "pi-web-server" }]); + expect(resolution.ok).toBe(true); + if (resolution.ok) { + expect(resolution.plan.services.map((service) => service.strategy.kind)).toEqual(["configured-override", "bundled-entrypoint"]); + } + }); + + it("returns structured failures when neither production executable strategy is viable", async () => { + const resolution = await resolveProductionNativeServicePlan(productionInput(), { + probe: completedProbe("unsatisfied", "not found in service PATH"), + fileExists: () => false, + }); + + expect(resolution).toEqual({ + ok: false, + failures: [ + { + kind: "executable-unavailable", + serviceId: "sessiond", + namedCommand: "pi-web-sessiond", + namedCommandFailure: "not found in service PATH", + bundledEntrypointPath: "/package/dist/server/sessiond.js", + }, + { + kind: "executable-unavailable", + serviceId: "web", + namedCommand: "pi-web-server", + namedCommandFailure: "not found in service PATH", + bundledEntrypointPath: "/package/dist/server/index.js", + }, + ], + }); + }); + + it("does not reinterpret probe infrastructure failures as missing commands", async () => { + const fileExists = vi.fn<(path: string) => boolean>(() => true); + const resolution = await resolveProductionNativeServicePlan(productionInput(), { + probe: { + run: () => Promise.resolve({ kind: "infrastructure-failure", message: "launchd probe cleanup failed" }), + }, + fileExists, + }); + + expect(fileExists).not.toHaveBeenCalled(); + expect(resolution).toEqual({ + ok: false, + failures: [{ + kind: "probe-infrastructure", + serviceIds: ["sessiond", "web"], + message: "launchd probe cleanup failed", + }], + }); + }); + + it("treats thrown and malformed probe results as infrastructure failures", async () => { + const thrown = await resolveProductionNativeServicePlan(productionInput(), { + probe: { run: () => Promise.reject(new Error("systemd-run failed")) }, + fileExists: () => true, + }); + expect(thrown).toMatchObject({ + ok: false, + failures: [{ kind: "probe-infrastructure", message: "systemd-run failed" }], + }); + + const malformed = await resolveProductionNativeServicePlan(productionInput(), { + probe: { + run: () => Promise.resolve({ + kind: "completed", + outcomes: [{ prerequisiteId: "sessiond.command.pi-web-sessiond", status: "satisfied", detail: null }], + }), + }, + fileExists: () => true, + }); + expect(malformed).toMatchObject({ + ok: false, + failures: [{ kind: "probe-infrastructure", message: "Authoritative probe returned no outcome for web.command.pi-web-server." }], + }); + }); +}); + +describe("development native service planning", () => { + it("plans only the exact checkout commands and prerequisites", () => { + const plan = createDevelopmentNativeServicePlan({ + backend: { kind: "launchd", label: "LaunchAgents" }, + shell: { name: "fish", executable: "/opt/homebrew/bin/fish", source: "detected", detectedExecutable: "/opt/homebrew/bin/fish" }, + environment: { PI_WEB_CONFIG: "/tmp/config.json" }, + workingDirectory: "/checkout with space", + packageJsonPath: "/checkout with space/package.json", + }); + + expect(plan).toMatchObject({ + mode: "development", + backend: { kind: "launchd" }, + shell: { name: "fish", executable: "/opt/homebrew/bin/fish" }, + services: [ + { + id: "sessiond", + shellCommand: "exec npm run start:sessiond", + strategy: { kind: "development-npm-script", script: "start:sessiond" }, + restart: "never", + environment: { PI_WEB_CONFIG: "/tmp/config.json" }, + workingDirectory: "/checkout with space", + prerequisites: [ + { id: "sessiond.node", kind: "node-version", minimumMajor: 22 }, + { id: "sessiond.command.npm", kind: "command-available", command: "npm" }, + { id: "sessiond.package-scripts", kind: "package-scripts", scripts: ["start:sessiond"] }, + ], + }, + { + id: "uiDev", + shellCommand: "exec /usr/bin/env bash -c 'trap \"kill 0\" EXIT; npm run dev:web & npm run dev:client & wait'", + strategy: { kind: "development-npm-script-group", scripts: ["dev:web", "dev:client"], interpreter: "bash" }, + restart: "never", + workingDirectory: "/checkout with space", + after: ["sessiond"], + wants: ["sessiond"], + prerequisites: [ + { id: "uiDev.node", kind: "node-version", minimumMajor: 22 }, + { id: "uiDev.command.npm", kind: "command-available", command: "npm" }, + { id: "uiDev.command.bash", kind: "command-available", command: "bash" }, + { id: "uiDev.package-scripts", kind: "package-scripts", scripts: ["dev:web", "dev:client"] }, + ], + }, + ], + }); + + const serviceCommandRequirements = plan.services.flatMap((service) => service.prerequisites) + .filter((prerequisite) => prerequisite.kind === "command-available") + .map((prerequisite) => prerequisite.command); + expect(serviceCommandRequirements).toEqual(["npm", "npm", "bash"]); + expect(serviceCommandRequirements).not.toContain("pi-web-server"); + expect(serviceCommandRequirements).not.toContain("pi-web-sessiond"); + + expect(planValidationProbeRequests(plan)).toMatchObject([ + { backend: { kind: "launchd" }, workingDirectory: "/checkout with space" }, + { backend: { kind: "launchd" }, workingDirectory: "/checkout with space" }, + ]); + }); +}); diff --git a/src/nativeServices/servicePlan.ts b/src/nativeServices/servicePlan.ts new file mode 100644 index 0000000..f0d3404 --- /dev/null +++ b/src/nativeServices/servicePlan.ts @@ -0,0 +1,540 @@ +export type NativeServiceBackendKind = "systemd" | "launchd"; +export type NativeServiceMode = "production" | "development"; +export type NativeServiceId = "sessiond" | "web" | "uiDev"; +export type ProductionNativeServiceId = Extract; +export type NativeServiceShellName = "bash" | "zsh" | "fish"; +export type NativeServiceRestartPolicy = "on-failure" | "never"; + +export interface NativeServiceBackend { + kind: NativeServiceBackendKind; + label: string; +} + +export interface NativeServiceShell { + name: NativeServiceShellName; + executable: string; + source: "detected" | "fallback"; + detectedExecutable: string | null; +} + +export interface NativeServiceManagerRef { + systemdName: string; + launchdLabel: string; + launchdPlistName: string; + logName: string; +} + +export type NativeServiceCommandStrategy = + | { + kind: "configured-override"; + command: string; + verification: "unverified"; + } + | { + kind: "named-command"; + command: string; + selectedBy: "authoritative-backend-probe"; + } + | { + kind: "bundled-entrypoint"; + command: "node"; + entrypointPath: string; + namedCommand: string; + namedCommandFailure: string | null; + } + | { + kind: "development-npm-script"; + script: string; + } + | { + kind: "development-npm-script-group"; + scripts: readonly string[]; + interpreter: "bash"; + }; + +export type NativeServicePrerequisite = + | { + id: string; + kind: "command-available"; + command: string; + description: string; + } + | { + id: string; + kind: "node-version"; + command: "node"; + minimumMajor: number; + description: string; + } + | { + id: string; + kind: "readable-file"; + path: string; + description: string; + } + | { + id: string; + kind: "package-scripts"; + packageJsonPath: string; + scripts: readonly string[]; + description: string; + }; + +export interface NativeServicePlanService { + id: NativeServiceId; + manager: NativeServiceManagerRef; + description: string; + shellCommand: string; + strategy: NativeServiceCommandStrategy; + restart: NativeServiceRestartPolicy; + environment: Readonly>; + workingDirectory: string | null; + after: readonly NativeServiceId[]; + wants: readonly NativeServiceId[]; + prerequisites: readonly NativeServicePrerequisite[]; +} + +export interface NativeServicePlan { + mode: NativeServiceMode; + backend: NativeServiceBackend; + shell: NativeServiceShell; + services: readonly NativeServicePlanService[]; +} + +export interface NativeServiceProbeRequest { + purpose: "executable-selection" | "plan-validation"; + backend: NativeServiceBackend; + shell: NativeServiceShell; + environment: Readonly>; + workingDirectory: string | null; + prerequisites: readonly NativeServicePrerequisite[]; +} + +export interface NativeServicePrerequisiteOutcome { + prerequisiteId: string; + status: "satisfied" | "unsatisfied"; + detail: string | null; +} + +export type NativeServiceProbeResult = + | { + kind: "completed"; + outcomes: readonly NativeServicePrerequisiteOutcome[]; + } + | { + kind: "infrastructure-failure"; + message: string; + }; + +/** + * Runs requirements in the real native service-manager context represented by + * the request. Implementations must not treat the caller shell or a simulated + * `env -i` environment as authoritative. Timeouts, manager failures, malformed + * output, and cleanup failures are infrastructure failures; a missing command + * is a completed probe with an unsatisfied outcome. + */ +export interface NativeServiceAuthoritativeProbe { + run(request: NativeServiceProbeRequest): Promise; +} + +export interface ProductionNativeServiceExecutableInput { + configuredCommand: string | undefined; + namedCommand: string; + bundledEntrypointPath: string; +} + +export interface ProductionNativeServicePlanInput { + backend: NativeServiceBackend; + shell: NativeServiceShell; + environment: Readonly>; + executables: Readonly>; +} + +export interface DevelopmentNativeServicePlanInput { + backend: NativeServiceBackend; + shell: NativeServiceShell; + environment: Readonly>; + workingDirectory: string; + packageJsonPath: string; +} + +export interface NativeServicePlanDependencies { + probe: NativeServiceAuthoritativeProbe; + fileExists(path: string): boolean; +} + +export type NativeServicePlanFailure = + | { + kind: "probe-infrastructure"; + serviceIds: readonly ProductionNativeServiceId[]; + message: string; + } + | { + kind: "entrypoint-inspection-failure"; + serviceId: ProductionNativeServiceId; + entrypointPath: string; + message: string; + } + | { + kind: "executable-unavailable"; + serviceId: ProductionNativeServiceId; + namedCommand: string; + namedCommandFailure: string | null; + bundledEntrypointPath: string; + }; + +export type NativeServicePlanResolution = + | { ok: true; plan: NativeServicePlan } + | { ok: false; failures: readonly NativeServicePlanFailure[] }; + +const nativeServiceRefs: Readonly> = { + sessiond: { + systemdName: "pi-web-sessiond.service", + launchdLabel: "com.pi-web.sessiond", + launchdPlistName: "com.pi-web.sessiond.plist", + logName: "sessiond.log", + }, + web: { + systemdName: "pi-web.service", + launchdLabel: "com.pi-web.web", + launchdPlistName: "com.pi-web.web.plist", + logName: "web.log", + }, + uiDev: { + systemdName: "pi-web-ui-dev.service", + launchdLabel: "com.pi-web.ui-dev", + launchdPlistName: "com.pi-web.ui-dev.plist", + logName: "ui-dev.log", + }, +}; + +const productionServiceIds = ["sessiond", "web"] as const satisfies readonly ProductionNativeServiceId[]; + +export async function resolveProductionNativeServicePlan( + input: ProductionNativeServicePlanInput, + dependencies: NativeServicePlanDependencies, +): Promise { + const configuredStrategies = new Map(); + const selectionRequirements: NativeServicePrerequisite[] = []; + const serviceIdsToProbe: ProductionNativeServiceId[] = []; + + for (const serviceId of productionServiceIds) { + const executable = input.executables[serviceId]; + if (hasConfiguredCommand(executable.configuredCommand)) { + configuredStrategies.set(serviceId, { + kind: "configured-override", + command: executable.configuredCommand, + verification: "unverified", + }); + continue; + } + + serviceIdsToProbe.push(serviceId); + selectionRequirements.push(commandRequirement(serviceId, executable.namedCommand)); + } + + let outcomes = new Map(); + if (selectionRequirements.length > 0) { + const probeResult = await runSelectionProbe(input, selectionRequirements, dependencies.probe); + if (probeResult.kind === "infrastructure-failure") { + return { + ok: false, + failures: [{ kind: "probe-infrastructure", serviceIds: serviceIdsToProbe, message: probeResult.message }], + }; + } + + const parsedOutcomes = probeOutcomes(selectionRequirements, probeResult.outcomes); + if (parsedOutcomes.kind === "infrastructure-failure") { + return { + ok: false, + failures: [{ kind: "probe-infrastructure", serviceIds: serviceIdsToProbe, message: parsedOutcomes.message }], + }; + } + outcomes = parsedOutcomes.outcomes; + } + + const strategies = new Map(configuredStrategies); + const failures: NativeServicePlanFailure[] = []; + + for (const serviceId of serviceIdsToProbe) { + const executable = input.executables[serviceId]; + const outcome = outcomes.get(commandRequirementId(serviceId, executable.namedCommand)); + if (outcome?.status === "satisfied") { + strategies.set(serviceId, { + kind: "named-command", + command: executable.namedCommand, + selectedBy: "authoritative-backend-probe", + }); + continue; + } + + let entrypointExists: boolean; + try { + entrypointExists = dependencies.fileExists(executable.bundledEntrypointPath); + } catch (error: unknown) { + failures.push({ + kind: "entrypoint-inspection-failure", + serviceId, + entrypointPath: executable.bundledEntrypointPath, + message: errorMessage(error), + }); + continue; + } + + if (entrypointExists) { + strategies.set(serviceId, { + kind: "bundled-entrypoint", + command: "node", + entrypointPath: executable.bundledEntrypointPath, + namedCommand: executable.namedCommand, + namedCommandFailure: outcome?.detail ?? null, + }); + continue; + } + + failures.push({ + kind: "executable-unavailable", + serviceId, + namedCommand: executable.namedCommand, + namedCommandFailure: outcome?.detail ?? null, + bundledEntrypointPath: executable.bundledEntrypointPath, + }); + } + + if (failures.length > 0) return { ok: false, failures }; + + return { + ok: true, + plan: { + mode: "production", + backend: input.backend, + shell: input.shell, + services: productionServiceIds.map((serviceId) => productionService(input, serviceId, requiredStrategy(strategies, serviceId))), + }, + }; +} + +export function createDevelopmentNativeServicePlan(input: DevelopmentNativeServicePlanInput): NativeServicePlan { + const environment = copyEnvironment(input.environment); + const sessiondScripts = ["start:sessiond"] as const; + const uiDevScripts = ["dev:web", "dev:client"] as const; + const uiDevCommand = 'trap "kill 0" EXIT; npm run dev:web & npm run dev:client & wait'; + + return { + mode: "development", + backend: input.backend, + shell: input.shell, + services: [ + { + id: "sessiond", + manager: nativeServiceRefs.sessiond, + description: "PI WEB session daemon (dev)", + shellCommand: "exec npm run start:sessiond", + strategy: { kind: "development-npm-script", script: "start:sessiond" }, + restart: "never", + environment, + workingDirectory: input.workingDirectory, + after: [], + wants: [], + prerequisites: [ + nodeRequirement("sessiond"), + commandRequirement("sessiond", "npm"), + packageScriptsRequirement("sessiond", input.packageJsonPath, sessiondScripts), + ], + }, + { + id: "uiDev", + manager: nativeServiceRefs.uiDev, + description: "PI WEB UI dev server", + shellCommand: `exec /usr/bin/env bash -c ${shellSingleQuote(input.shell.name, uiDevCommand)}`, + strategy: { kind: "development-npm-script-group", scripts: uiDevScripts, interpreter: "bash" }, + restart: "never", + environment, + workingDirectory: input.workingDirectory, + after: ["sessiond"], + wants: ["sessiond"], + prerequisites: [ + nodeRequirement("uiDev"), + commandRequirement("uiDev", "npm"), + commandRequirement("uiDev", "bash"), + packageScriptsRequirement("uiDev", input.packageJsonPath, uiDevScripts), + ], + }, + ], + }; +} + +export function planValidationProbeRequests(plan: NativeServicePlan): readonly NativeServiceProbeRequest[] { + return plan.services.flatMap((service) => service.prerequisites.length === 0 ? [] : [{ + purpose: "plan-validation" as const, + backend: plan.backend, + shell: plan.shell, + environment: service.environment, + workingDirectory: service.workingDirectory, + prerequisites: service.prerequisites, + }]); +} + +function productionService( + input: ProductionNativeServicePlanInput, + serviceId: ProductionNativeServiceId, + strategy: NativeServiceCommandStrategy, +): NativeServicePlanService { + const isWeb = serviceId === "web"; + return { + id: serviceId, + manager: nativeServiceRefs[serviceId], + description: isWeb ? "PI WEB server" : "PI WEB session daemon", + shellCommand: `exec ${strategyCommand(input.shell, strategy)}`, + strategy, + restart: "on-failure", + environment: copyEnvironment(input.environment), + workingDirectory: null, + after: isWeb ? ["sessiond"] : [], + wants: isWeb ? ["sessiond"] : [], + prerequisites: strategyPrerequisites(serviceId, strategy), + }; +} + +function strategyCommand(shell: NativeServiceShell, strategy: NativeServiceCommandStrategy): string { + switch (strategy.kind) { + case "configured-override": + case "named-command": + return strategy.command; + case "bundled-entrypoint": + return `${strategy.command} ${shellSingleQuote(shell.name, strategy.entrypointPath)}`; + case "development-npm-script": + return `npm run ${strategy.script}`; + case "development-npm-script-group": + throw new Error("Development script groups define their complete service shell command"); + } +} + +function strategyPrerequisites(serviceId: ProductionNativeServiceId, strategy: NativeServiceCommandStrategy): readonly NativeServicePrerequisite[] { + switch (strategy.kind) { + case "configured-override": + return []; + case "named-command": + return [commandRequirement(serviceId, strategy.command), nodeRequirement(serviceId)]; + case "bundled-entrypoint": + return [nodeRequirement(serviceId), readableFileRequirement(serviceId, strategy.entrypointPath)]; + case "development-npm-script": + case "development-npm-script-group": + throw new Error(`Unexpected ${strategy.kind} strategy in a production plan`); + } +} + +async function runSelectionProbe( + input: ProductionNativeServicePlanInput, + prerequisites: readonly NativeServicePrerequisite[], + probe: NativeServiceAuthoritativeProbe, +): Promise { + try { + return await probe.run({ + purpose: "executable-selection", + backend: input.backend, + shell: input.shell, + environment: copyEnvironment(input.environment), + workingDirectory: null, + prerequisites, + }); + } catch (error: unknown) { + return { kind: "infrastructure-failure", message: errorMessage(error) }; + } +} + +function probeOutcomes( + prerequisites: readonly NativeServicePrerequisite[], + outcomes: readonly NativeServicePrerequisiteOutcome[], +): { kind: "completed"; outcomes: Map } | { kind: "infrastructure-failure"; message: string } { + const expectedIds = new Set(prerequisites.map((prerequisite) => prerequisite.id)); + const byId = new Map(); + + for (const outcome of outcomes) { + if (!expectedIds.has(outcome.prerequisiteId)) { + return { kind: "infrastructure-failure", message: `Authoritative probe returned unexpected outcome ${outcome.prerequisiteId}.` }; + } + if (byId.has(outcome.prerequisiteId)) { + return { kind: "infrastructure-failure", message: `Authoritative probe returned duplicate outcome ${outcome.prerequisiteId}.` }; + } + byId.set(outcome.prerequisiteId, outcome); + } + + const missing = prerequisites.find((prerequisite) => !byId.has(prerequisite.id)); + if (missing !== undefined) { + return { kind: "infrastructure-failure", message: `Authoritative probe returned no outcome for ${missing.id}.` }; + } + return { kind: "completed", outcomes: byId }; +} + +function requiredStrategy( + strategies: ReadonlyMap, + serviceId: ProductionNativeServiceId, +): NativeServiceCommandStrategy { + const strategy = strategies.get(serviceId); + if (strategy === undefined) throw new Error(`Missing executable strategy for ${serviceId}`); + return strategy; +} + +function hasConfiguredCommand(command: string | undefined): command is string { + return command !== undefined && command.trim() !== ""; +} + +function commandRequirementId(serviceId: NativeServiceId, command: string): string { + return `${serviceId}.command.${command}`; +} + +function commandRequirement(serviceId: NativeServiceId, command: string): NativeServicePrerequisite { + return { + id: commandRequirementId(serviceId, command), + kind: "command-available", + command, + description: `${command} is available to the service shell`, + }; +} + +function nodeRequirement(serviceId: NativeServiceId): NativeServicePrerequisite { + return { + id: `${serviceId}.node`, + kind: "node-version", + command: "node", + minimumMajor: 22, + description: "node >= 22 is available to the service shell", + }; +} + +function readableFileRequirement(serviceId: NativeServiceId, path: string): NativeServicePrerequisite { + return { + id: `${serviceId}.entrypoint`, + kind: "readable-file", + path, + description: `bundled entrypoint is readable: ${path}`, + }; +} + +function packageScriptsRequirement( + serviceId: NativeServiceId, + packageJsonPath: string, + scripts: readonly string[], +): NativeServicePrerequisite { + return { + id: `${serviceId}.package-scripts`, + kind: "package-scripts", + packageJsonPath, + scripts, + description: `package.json defines scripts: ${scripts.join(", ")}`, + }; +} + +function shellSingleQuote(shell: NativeServiceShellName, value: string): string { + if (shell === "fish") return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function copyEnvironment(environment: Readonly>): Readonly> { + return { ...environment }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +}