diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index 02343bb2..c56c732b 100644 --- a/.codex/skills/hack-cli/SKILL.md +++ b/.codex/skills/hack-cli/SKILL.md @@ -12,11 +12,11 @@ Use `hack` as the primary interface for local-first development. ## Integration freshness -- These instructions were generated by hack CLI v3.3.5; treat cached rules from another version as potentially stale. +- These instructions were generated by hack CLI v3.3.6; treat cached rules from another version as potentially stale. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `b8663fad3ef4` (version alone is not a freshness guarantee). +- Content revision: `eef2b0142b95` (version alone is not a freshness guarantee). ## Product boundary @@ -125,7 +125,7 @@ Use `hack` as the primary interface for local-first development. - Inspect lifecycle status via `hack projects --details` and stream via `hack logs `. - Lifecycle session recovery is ownership-proven: Hack adopts healthy token-, definition-, and environment-matched sessions, replaces owned stale sessions, and refuses to kill same-name sessions without deterministic ownership proof. - `hack doctor --fix` reaps an orphan lifecycle session only when mux ownership is proven and its Compose instance is absent; unverified same-name sessions are never modified. -- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`. +- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful. Hack recognizes dependency installers, `hack.service.one-shot=true`, and services referenced by Compose `condition: service_completed_successfully`; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`. - Detached startup is bounded; a hung Compose operation returns `E_STARTUP_TIMEOUT`, terminates its process group, and `hack doctor --fix` can start exact containers left in `Created`. - Target only affected services with `hack up --detach`, `hack restart `, or `hack env apply --service `; scoped operations skip project lifecycle hooks and implicit dependency startup. - Use `hack env explain --env --service --target ` for redacted source, precedence, availability, and delivery diagnostics. diff --git a/.cursor/rules/hack.mdc b/.cursor/rules/hack.mdc index c466d9cc..2b3c0e13 100644 --- a/.cursor/rules/hack.mdc +++ b/.cursor/rules/hack.mdc @@ -7,11 +7,11 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is ## Integration freshness -- These instructions were generated by hack CLI v3.3.5; treat cached rules from another version as potentially stale. +- These instructions were generated by hack CLI v3.3.6; treat cached rules from another version as potentially stale. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `b8663fad3ef4` (version alone is not a freshness guarantee). +- Content revision: `eef2b0142b95` (version alone is not a freshness guarantee). ## Product boundary @@ -73,7 +73,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - Inspect lifecycle status via `hack projects --details` and stream via `hack logs `. - Lifecycle session recovery is ownership-proven: Hack adopts healthy token-, definition-, and environment-matched sessions, replaces owned stale sessions, and refuses to kill same-name sessions without deterministic ownership proof. - `hack doctor --fix` reaps an orphan lifecycle session only when mux ownership is proven and its Compose instance is absent; unverified same-name sessions are never modified. -- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`. +- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful. Hack recognizes dependency installers, `hack.service.one-shot=true`, and services referenced by Compose `condition: service_completed_successfully`; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`. - Detached startup is bounded; a hung Compose operation returns `E_STARTUP_TIMEOUT`, terminates its process group, and `hack doctor --fix` can start exact containers left in `Created`. - Target only affected services with `hack up --detach`, `hack restart `, or `hack env apply --service `; scoped operations skip project lifecycle hooks and implicit dependency startup. - Use `hack env explain --env --service --target ` for redacted source, precedence, availability, and delivery diagnostics. diff --git a/AGENTS.md b/AGENTS.md index 12619922..7d5863d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -210,11 +210,11 @@ Most formatting and common issues are automatically fixed by Biome. Run `bun x u Use `hack` as the single interface for local-first runtime orchestration (compose, DNS/TLS, logs, env, and persistent project workspaces). Integration freshness: -- These instructions were generated by hack CLI v3.3.5; treat cached rules from another version as potentially stale. +- These instructions were generated by hack CLI v3.3.6; treat cached rules from another version as potentially stale. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `b8663fad3ef4` (version alone is not a freshness guarantee). +- Content revision: `eef2b0142b95` (version alone is not a freshness guarantee). Product boundary: - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -310,7 +310,7 @@ Lifecycle + startup: - Inspect lifecycle status via `hack projects --details` and stream via `hack logs `. - Lifecycle session recovery is ownership-proven: Hack adopts healthy token-, definition-, and environment-matched sessions, replaces owned stale sessions, and refuses to kill same-name sessions without deterministic ownership proof. - `hack doctor --fix` reaps an orphan lifecycle session only when mux ownership is proven and its Compose instance is absent; unverified same-name sessions are never modified. -- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`. +- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful. Hack recognizes dependency installers, `hack.service.one-shot=true`, and services referenced by Compose `condition: service_completed_successfully`; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`. - Detached startup is bounded; a hung Compose operation returns `E_STARTUP_TIMEOUT`, terminates its process group, and `hack doctor --fix` can start exact containers left in `Created`. - Target only affected services with `hack up --detach`, `hack restart `, or `hack env apply --service `; scoped operations skip project lifecycle hooks and implicit dependency startup. - Use `hack env explain --env --service --target ` for redacted source, precedence, availability, and delivery diagnostics. diff --git a/CLAUDE.md b/CLAUDE.md index 9ba38e9f..01e34ba1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,11 +80,11 @@ This project uses Obsidian for project context, specs, research, and progress tr Use `hack` as the single interface for local-first runtime orchestration (compose, DNS/TLS, logs, env, and persistent project workspaces). Integration freshness: -- These instructions were generated by hack CLI v3.3.5; treat cached rules from another version as potentially stale. +- These instructions were generated by hack CLI v3.3.6; treat cached rules from another version as potentially stale. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `b8663fad3ef4` (version alone is not a freshness guarantee). +- Content revision: `eef2b0142b95` (version alone is not a freshness guarantee). Product boundary: - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -180,7 +180,7 @@ Lifecycle + startup: - Inspect lifecycle status via `hack projects --details` and stream via `hack logs `. - Lifecycle session recovery is ownership-proven: Hack adopts healthy token-, definition-, and environment-matched sessions, replaces owned stale sessions, and refuses to kill same-name sessions without deterministic ownership proof. - `hack doctor --fix` reaps an orphan lifecycle session only when mux ownership is proven and its Compose instance is absent; unverified same-name sessions are never modified. -- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`. +- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful. Hack recognizes dependency installers, `hack.service.one-shot=true`, and services referenced by Compose `condition: service_completed_successfully`; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`. - Detached startup is bounded; a hung Compose operation returns `E_STARTUP_TIMEOUT`, terminates its process group, and `hack doctor --fix` can start exact containers left in `Created`. - Target only affected services with `hack up --detach`, `hack restart `, or `hack env apply --service `; scoped operations skip project lifecycle hooks and implicit dependency startup. - Use `hack env explain --env --service --target ` for redacted source, precedence, availability, and delivery diagnostics. diff --git a/docs/lifecycle.md b/docs/lifecycle.md index a01b9d4a..fa6c6c3f 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -220,6 +220,9 @@ its isolated derived branch instance, and `--branch ` targets only that ex `E_COMPOSE_FAILED`. If Compose exits successfully but one or more containers remain `created`, exit non-zero, or enter another failed runtime state, Hack returns `E_STARTUP_INCOMPLETE`. Successful one-shot services (`exited` with code 0) are reported under `services.completed` and do not fail startup. +Hack recognizes dependency installers, services labeled `hack.service.one-shot=true`, and services +referenced by another service with Compose `condition: service_completed_successfully`. An unrelated +service that merely exits zero is still treated as incomplete startup. Lifecycle failures are one of the primary consumers of the `--json` error envelope, since hook and process startup are common failure points around `hack up`. diff --git a/src/agents/instruction-source.ts b/src/agents/instruction-source.ts index bf126aa0..e9069b59 100644 --- a/src/agents/instruction-source.ts +++ b/src/agents/instruction-source.ts @@ -206,7 +206,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ "Inspect lifecycle status via `hack projects --details` and stream via `hack logs `.", "Lifecycle session recovery is ownership-proven: Hack adopts healthy token-, definition-, and environment-matched sessions, replaces owned stale sessions, and refuses to kill same-name sessions without deterministic ownership proof.", "`hack doctor --fix` reaps an orphan lifecycle session only when mux ownership is proven and its Compose instance is absent; unverified same-name sessions are never modified.", - "After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`.", + "After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful. Hack recognizes dependency installers, `hack.service.one-shot=true`, and services referenced by Compose `condition: service_completed_successfully`; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`.", "Detached startup is bounded; a hung Compose operation returns `E_STARTUP_TIMEOUT`, terminates its process group, and `hack doctor --fix` can start exact containers left in `Created`.", "Target only affected services with `hack up --detach`, `hack restart `, or `hack env apply --service `; scoped operations skip project lifecycle hooks and implicit dependency startup.", "Use `hack env explain --env --service --target ` for redacted source, precedence, availability, and delivery diagnostics.", diff --git a/src/agents/integration-revision.ts b/src/agents/integration-revision.ts index a5b6d364..e0a5e4f7 100644 --- a/src/agents/integration-revision.ts +++ b/src/agents/integration-revision.ts @@ -3,4 +3,4 @@ * source test recomputes this value and fails whenever guidance changes * without a revision update. */ -export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "b8663fad3ef4"; +export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "eef2b0142b95"; diff --git a/src/commands/project.ts b/src/commands/project.ts index dcb7c779..8c6079cd 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -755,6 +755,7 @@ async function readComposeServiceStates(opts: { readonly composeProjectName: string | null; readonly profiles: readonly string[]; readonly services?: readonly string[]; + readonly env?: Readonly>; }): Promise { const res = await composeRuntimeBackend.psJson({ composeFiles: [opts.project.composeFile], @@ -762,6 +763,7 @@ async function readComposeServiceStates(opts: { profiles: opts.profiles, cwd: dirname(opts.project.composeFile), all: true, + env: opts.env, }); if (res.exitCode !== 0) { return []; @@ -6114,10 +6116,17 @@ async function runUpCommand({ composeProjectName, profiles, services: targetServices, + env: envOverrides.env, + }); + const effectiveProfiles = resolveEffectiveComposeProfiles({ + cliProfiles: profiles, + composeEnv: envOverrides.env, }); const successfulCompletionServices = new Set( await discoverSuccessfulCompletionServices({ composeFile: project.composeFile, + activeProfiles: effectiveProfiles, + selectedServices: serviceScoped ? targetServices : undefined, }) ); const startup = classifyComposeStartupState(states, { @@ -6786,10 +6795,17 @@ async function runTargetedServiceRestart(opts: { composeProjectName: opts.composeProjectName, profiles: opts.profiles, services: opts.services, + env: envOverrides.env, + }); + const effectiveProfiles = resolveEffectiveComposeProfiles({ + cliProfiles: opts.profiles, + composeEnv: envOverrides.env, }); const successfulCompletionServices = new Set( await discoverSuccessfulCompletionServices({ composeFile: opts.project.composeFile, + activeProfiles: effectiveProfiles, + selectedServices: opts.services, }) ); const startup = classifyComposeStartupState(states, { @@ -7049,10 +7065,16 @@ async function runRestartCommand({ project, composeProjectName, profiles, + env: preflightEnv.env, + }); + const effectiveProfiles = resolveEffectiveComposeProfiles({ + cliProfiles: profiles, + composeEnv: preflightEnv.env, }); const successfulCompletionServices = new Set( await discoverSuccessfulCompletionServices({ composeFile: project.composeFile, + activeProfiles: effectiveProfiles, }) ); const startup = classifyComposeStartupState(states, { @@ -8064,6 +8086,20 @@ function parseCsvList(value: string | undefined): string[] { return out; } +/** Resolve the profiles Compose activates after CLI-over-environment precedence. */ +function resolveEffectiveComposeProfiles(opts: { + readonly cliProfiles: readonly string[]; + readonly composeEnv: Readonly>; +}): string[] { + if (opts.cliProfiles.length > 0) { + return [...opts.cliProfiles]; + } + const composeProfiles = Object.hasOwn(opts.composeEnv, "COMPOSE_PROFILES") + ? opts.composeEnv.COMPOSE_PROFILES + : process.env.COMPOSE_PROFILES; + return parseCsvList(composeProfiles); +} + function parseLogTimeRange(opts: { readonly since: string | undefined; readonly until: string | undefined; diff --git a/src/lib/registry-credential-preflight.ts b/src/lib/registry-credential-preflight.ts index fa554709..36ade7b9 100644 --- a/src/lib/registry-credential-preflight.ts +++ b/src/lib/registry-credential-preflight.ts @@ -121,6 +121,8 @@ export async function discoverDependencyBootstrapServices(opts: { /** Services that may legitimately exit zero after startup instead of staying running. */ export async function discoverSuccessfulCompletionServices(opts: { readonly composeFile: string; + readonly activeProfiles?: readonly string[]; + readonly selectedServices?: readonly string[]; }): Promise { const bootstrapServices = await discoverDependencyBootstrapServices(opts); const text = await readTextFile(opts.composeFile); @@ -144,9 +146,41 @@ export async function discoverSuccessfulCompletionServices(opts: { }) ) .map(([service]) => service); - return [...new Set([...bootstrapServices, ...explicitOneShots])].sort( - (left, right) => left.localeCompare(right) + const activeProfiles = new Set(opts.activeProfiles ?? []); + const allProfilesActive = activeProfiles.has("*"); + const selectedServices = opts.selectedServices + ? new Set(opts.selectedServices) + : null; + const completionDependencies = Object.entries(services).flatMap( + ([service, value]) => { + if (!(isRecord(value) && isRecord(value.depends_on))) { + return []; + } + const dependentIsActive = selectedServices + ? selectedServices.has(service) + : !Array.isArray(value.profiles) || + allProfilesActive || + value.profiles.some( + (profile) => + typeof profile === "string" && activeProfiles.has(profile) + ); + return Object.entries(value.depends_on) + .filter( + ([dependencyService, dependency]) => + isRecord(dependency) && + dependency.condition === "service_completed_successfully" && + (dependentIsActive || selectedServices?.has(dependencyService)) + ) + .map(([service]) => service); + } ); + return [ + ...new Set([ + ...bootstrapServices, + ...explicitOneShots, + ...completionDependencies, + ]), + ].sort((left, right) => left.localeCompare(right)); } catch { return bootstrapServices; } diff --git a/tests/project-up-startup-state.test.ts b/tests/project-up-startup-state.test.ts index be7e42e2..35ff583c 100644 --- a/tests/project-up-startup-state.test.ts +++ b/tests/project-up-startup-state.test.ts @@ -14,9 +14,11 @@ const psRows: string[] = []; const errorMessages: string[] = []; const warnMessages: string[] = []; const upEnvs: Array> | undefined> = []; +const psEnvs: Array> | undefined> = []; const upServiceSelections: Array = []; const tempDirs = new Set(); const originalHackHome = process.env.HACK_HOME; +const originalComposeProfiles = process.env.COMPOSE_PROFILES; let autoBranch: string | null = null; let runtimeProjects: readonly Record[] = []; @@ -59,11 +61,16 @@ const runtimeBackendMock = await registerScopedModuleMock({ return 0; }, down: async () => 0, - psJson: async () => ({ - exitCode: 0, - stdout: psRows.join("\n"), - stderr: "", - }), + psJson: async (opts: { + readonly env?: Readonly>; + }) => { + psEnvs.push(opts.env); + return { + exitCode: 0, + stdout: psRows.join("\n"), + stderr: "", + }; + }, ps: async () => 0, run: async () => 0, exec: async () => 0, @@ -116,6 +123,7 @@ afterEach(async () => { errorMessages.length = 0; warnMessages.length = 0; upEnvs.length = 0; + psEnvs.length = 0; upServiceSelections.length = 0; autoBranch = null; runtimeProjects = []; @@ -124,6 +132,11 @@ afterEach(async () => { } tempDirs.clear(); process.env.HACK_HOME = originalHackHome; + if (originalComposeProfiles === undefined) { + Reflect.deleteProperty(process.env, "COMPOSE_PROFILES"); + } else { + process.env.COMPOSE_PROFILES = originalComposeProfiles; + } }); afterAll(() => { @@ -159,7 +172,7 @@ test("up returns failure when compose reports no services after exit zero", asyn ); }); -test("up accepts running and successfully completed one-shot services", async () => { +test("up accepts running services and successful Compose completion gates", async () => { const projectRoot = await createProject(); psRows.push( JSON.stringify({ Service: "api", State: "running", ExitCode: 0 }), @@ -173,6 +186,99 @@ test("up accepts running and successfully completed one-shot services", async () expect(upServiceSelections).toEqual([undefined]); }); +test("up rejects an exited service referenced only by an inactive profile", async () => { + const projectRoot = await createProject(); + psRows.push(JSON.stringify({ Service: "api", State: "exited", ExitCode: 0 })); + + const exitCode = await runDetachedUp({ projectRoot }); + + expect(exitCode).toBe(1); + expect(errorMessages).toContain( + "Startup incomplete for startup-state-test: api did not reach running or successful completion" + ); +}); + +test("up accepts completion gates from wildcard-enabled profiles", async () => { + const projectRoot = await createProject(); + psRows.push(JSON.stringify({ Service: "api", State: "exited", ExitCode: 0 })); + + const exitCode = await runDetachedUp({ projectRoot, profiles: ["*"] }); + + expect(exitCode).toBe(0); + expect(errorMessages).toEqual([]); +}); + +test("up accepts profiles delivered through COMPOSE_PROFILES", async () => { + process.env.COMPOSE_PROFILES = "other"; + const projectRoot = await createProject({ + composeProfiles: "benchmark,diagnostics", + }); + psRows.push(JSON.stringify({ Service: "api", State: "exited", ExitCode: 0 })); + + const exitCode = await runDetachedUp({ projectRoot }); + + expect(exitCode).toBe(0); + expect(errorMessages).toEqual([]); + expect(upEnvs).toEqual([ + { COMPOSE_PROFILES: "benchmark,diagnostics", SHARED_MODE: "compose" }, + ]); + expect(psEnvs).toEqual(upEnvs); +}); + +test("up accepts wildcard COMPOSE_PROFILES", async () => { + const projectRoot = await createProject({ composeProfiles: "*" }); + psRows.push(JSON.stringify({ Service: "api", State: "exited", ExitCode: 0 })); + + const exitCode = await runDetachedUp({ projectRoot }); + + expect(exitCode).toBe(0); + expect(errorMessages).toEqual([]); +}); + +test("up gives CLI profiles precedence over COMPOSE_PROFILES", async () => { + const projectRoot = await createProject({ composeProfiles: "benchmark" }); + psRows.push(JSON.stringify({ Service: "api", State: "exited", ExitCode: 0 })); + + const exitCode = await runDetachedUp({ + projectRoot, + profiles: ["diagnostics"], + }); + + expect(exitCode).toBe(1); + expect(errorMessages).toContain( + "Startup incomplete for startup-state-test: api did not reach running or successful completion" + ); +}); + +test("up combines multiple CLI profiles", async () => { + const projectRoot = await createProject({ composeProfiles: "diagnostics" }); + psRows.push(JSON.stringify({ Service: "api", State: "exited", ExitCode: 0 })); + + const exitCode = await runDetachedUp({ + projectRoot, + profiles: ["diagnostics", "benchmark"], + }); + + expect(exitCode).toBe(0); + expect(errorMessages).toEqual([]); +}); + +test("up accepts a directly targeted completion dependency", async () => { + const projectRoot = await createProject(); + psRows.push( + JSON.stringify({ Service: "migrate", State: "exited", ExitCode: 0 }) + ); + + const exitCode = await runDetachedUp({ + projectRoot, + services: ["migrate"], + }); + + expect(exitCode).toBe(0); + expect(errorMessages).toEqual([]); + expect(upServiceSelections).toEqual([["migrate"]]); +}); + test("registry credentials must exist in the bootstrap service scope", async () => { const projectRoot = await createProject({ registryTokenScope: "api" }); @@ -196,6 +302,36 @@ test("restart returns failure when compose reports a created service after exit ); }); +test("restart accepts completion gates from inherited COMPOSE_PROFILES", async () => { + process.env.COMPOSE_PROFILES = "benchmark"; + const projectRoot = await createProject(); + psRows.push(JSON.stringify({ Service: "api", State: "exited", ExitCode: 0 })); + + const exitCode = await runRestart({ projectRoot }); + + expect(exitCode).toBe(0); + expect(errorMessages).toEqual([]); +}); + +test("targeted restart preserves delivered Compose environment", async () => { + const projectRoot = await createProject({ composeProfiles: "benchmark" }); + psRows.push( + JSON.stringify({ Service: "migrate", State: "exited", ExitCode: 0 }) + ); + + const exitCode = await runRestart({ + projectRoot, + services: ["migrate"], + }); + + expect(exitCode).toBe(0); + expect(errorMessages).toEqual([]); + expect(upServiceSelections).toEqual([["migrate"]]); + expect(psEnvs).toEqual([ + { COMPOSE_PROFILES: "benchmark", SHARED_MODE: "compose" }, + ]); +}); + test("up --json emits E_STARTUP_INCOMPLETE for a created service", async () => { const projectRoot = await createProject(); psRows.push( @@ -262,7 +398,10 @@ test("up warns before an auto-derived branch retargets the same worktree", async async function runDetachedUp(opts: { readonly projectRoot: string; + readonly profiles?: readonly string[]; + readonly services?: readonly string[]; }): Promise { + const profile = opts.profiles?.join(","); const input = { ctx: { cwd: opts.projectRoot, cli: CLI_SPEC }, args: { @@ -272,14 +411,22 @@ async function runDetachedUp(opts: { env: "base", branch: undefined, detach: true, - profile: undefined, + profile, target: undefined, json: false, }, - positionals: {}, + positionals: { services: opts.services ?? [] }, raw: { - argv: ["--path", opts.projectRoot, "--env", "base", "--detach"], - positionals: [], + argv: [ + "--path", + opts.projectRoot, + "--env", + "base", + "--detach", + ...(profile ? ["--profile", profile] : []), + ...(opts.services ?? []), + ], + positionals: opts.services ?? [], }, }, } as unknown as Parameters[0]; @@ -289,7 +436,10 @@ async function runDetachedUp(opts: { async function runRestart(opts: { readonly projectRoot: string; + readonly profiles?: readonly string[]; + readonly services?: readonly string[]; }): Promise { + const profile = opts.profiles?.join(","); const input = { ctx: { cwd: opts.projectRoot, cli: CLI_SPEC }, args: { @@ -298,14 +448,21 @@ async function runRestart(opts: { project: undefined, env: "base", branch: undefined, - profile: undefined, + profile, target: undefined, json: false, }, - positionals: {}, + positionals: { services: opts.services ?? [] }, raw: { - argv: ["--path", opts.projectRoot, "--env", "base"], - positionals: [], + argv: [ + "--path", + opts.projectRoot, + "--env", + "base", + ...(profile ? ["--profile", profile] : []), + ...(opts.services ?? []), + ], + positionals: opts.services ?? [], }, }, } as unknown as Parameters[0]; @@ -353,6 +510,7 @@ async function runJsonUp(opts: { } async function createProject(opts?: { + readonly composeProfiles?: string; readonly lifecycleMarkerFile?: string; readonly registryTokenScope?: "api" | "deps"; }): Promise { @@ -370,10 +528,17 @@ async function createProject(opts?: { "services:", " api:", " image: alpine:3.20", + " depends_on:", + " migrate:", + " condition: service_completed_successfully", " migrate:", " image: alpine:3.20", - " labels:", - ' hack.service.one-shot: "true"', + " profiled-worker:", + " image: alpine:3.20", + " profiles: [benchmark]", + " depends_on:", + " api:", + " condition: service_completed_successfully", ...(opts?.registryTokenScope ? [ " deps:", @@ -421,6 +586,9 @@ async function createProject(opts?: { "values:", " global:", ' SHARED_MODE: "compose"', + ...(opts?.composeProfiles !== undefined + ? [` COMPOSE_PROFILES: ${JSON.stringify(opts.composeProfiles)}`] + : []), " host:", ' SHARED_MODE: "host"', ' HOST_ONLY: "host-only"', diff --git a/tests/registry-credential-preflight.test.ts b/tests/registry-credential-preflight.test.ts index 26fba7dc..820b6881 100644 --- a/tests/registry-credential-preflight.test.ts +++ b/tests/registry-credential-preflight.test.ts @@ -66,7 +66,7 @@ test("dependency bootstrap detection is name agnostic", async () => { ]); }); -test("successful completion requires installer semantics or an explicit one-shot label", async () => { +test("successful completion recognizes installers, labels, and Compose completion gates", async () => { const projectRoot = await createTempProject(); const composeFile = resolve(projectRoot, "compose.yml"); await writeFile( @@ -80,14 +80,47 @@ test("successful completion requires installer semantics or an explicit one-shot " image: app", " labels:", ' hack.service.one-shot: "true"', + " database-init:", + " image: app", " api:", " image: app", + " depends_on:", + " database-init:", + " condition: service_completed_successfully", + " exited-api:", + " image: app", + " profiled-worker:", + " image: app", + " profiles: [benchmark]", + " depends_on:", + " exited-api:", + " condition: service_completed_successfully", "", ].join("\n") ); expect(await discoverSuccessfulCompletionServices({ composeFile })).toEqual([ + "database-init", "installer", "migrate", ]); + expect( + await discoverSuccessfulCompletionServices({ + composeFile, + activeProfiles: ["benchmark"], + }) + ).toEqual(["database-init", "exited-api", "installer", "migrate"]); + expect( + await discoverSuccessfulCompletionServices({ + composeFile, + activeProfiles: ["benchmark"], + selectedServices: ["api"], + }) + ).toEqual(["database-init", "installer", "migrate"]); + expect( + await discoverSuccessfulCompletionServices({ + composeFile, + selectedServices: ["database-init"], + }) + ).toEqual(["database-init", "installer", "migrate"]); });