From f326acd95fe9aa475ff2d78a4a9b9da1b52ce3d8 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 9 Jul 2026 22:05:59 -0400 Subject: [PATCH 1/5] Harden runtime integrity and diagnostics --- .codex/skills/hack-cli/SKILL.md | 24 +- .cursor/rules/hack.mdc | 24 +- AGENTS.md | 24 +- CLAUDE.md | 22 +- docs/cli.md | 69 +- docs/docs-ia.md | 7 +- docs/env.md | 20 +- docs/extensions.md | 10 +- docs/guides/codex-managed-environments.md | 2 - docs/guides/create-extension.md | 19 +- docs/guides/tickets.md | 34 +- docs/integrations.md | 19 +- docs/lifecycle.md | 18 +- docs/reference.md | 4 +- docs/reference/cli.md | 89 ++- examples/basic/AGENTS.md | 77 +- examples/basic/CLAUDE.md | 77 +- src/agents/init-patterns.ts | 1 - src/agents/instruction-source.ts | 47 +- src/agents/integration-revision.ts | 6 + src/agents/shared-skill.ts | 179 +++++ src/backends/runtime-backend.ts | 29 +- src/cli/integration-sync.ts | 163 ++-- src/cli/spec.ts | 2 +- src/commands/agent.ts | 28 +- src/commands/config.ts | 11 +- src/commands/doctor.ts | 478 ++++++----- src/commands/env.ts | 250 ++++++ src/commands/project.ts | 750 +++++++++++++++--- src/commands/projects.ts | 51 +- src/commands/recovery-guidance.ts | 26 +- src/commands/setup.ts | 158 ++-- src/commands/tickets.ts | 7 +- src/commands/update.ts | 43 +- .../extensions/tickets/agent-docs.ts | 54 +- .../extensions/tickets/commands.ts | 161 +--- .../extensions/tickets/extension.ts | 2 +- .../extensions/tickets/tickets-skill.ts | 21 + src/lib/cli-result.ts | 2 + src/lib/compose-startup-state.ts | 44 + src/lib/dependency-cache.ts | 225 ++++++ src/lib/project-env-config.ts | 95 ++- src/lib/project-runtime-hygiene.ts | 50 ++ src/lib/registry-credential-preflight.ts | 137 ++++ src/lib/shell.ts | 55 +- src/lib/worktree-runtime-target.ts | 86 ++ tests/agent-instruction-source.test.ts | 49 +- tests/cli-help.test.ts | 5 +- tests/config-command.test.ts | 48 +- tests/crash-capture.test.ts | 2 +- tests/dependency-cache.test.ts | 72 ++ tests/doctor-command.test.ts | 111 ++- tests/doctor-tickets-dir-gating.test.ts | 20 +- tests/e2e/fixture.ts | 15 +- tests/e2e/harness.ts | 4 +- tests/e2e/scenarios/agent-docs-sync.ts | 159 +++- tests/e2e/scenarios/lifecycle-host-process.ts | 14 + .../scenarios/lifecycle-session-recovery.ts | 49 +- tests/env-command-modern.test.ts | 29 + tests/lifecycle-json.test.ts | 9 +- tests/project-env-config.test.ts | 51 ++ tests/project-lifecycle-env.test.ts | 131 +++ tests/project-runtime-hygiene.test.ts | 48 ++ tests/project-startup-state.test.ts | 54 ++ tests/project-up-startup-state.test.ts | 394 +++++++++ tests/projects-prune.test.ts | 41 +- tests/registry-credential-preflight.test.ts | 66 ++ tests/runtime-backend.test.ts | 53 ++ tests/shared-agent-skill.test.ts | 97 +++ tests/shell-timeout.test.ts | 14 + tests/worktree-runtime-target.test.ts | 121 +++ 71 files changed, 4492 insertions(+), 864 deletions(-) create mode 100644 src/agents/integration-revision.ts create mode 100644 src/agents/shared-skill.ts create mode 100644 src/lib/compose-startup-state.ts create mode 100644 src/lib/dependency-cache.ts create mode 100644 src/lib/registry-credential-preflight.ts create mode 100644 src/lib/worktree-runtime-target.ts create mode 100644 tests/dependency-cache.test.ts create mode 100644 tests/project-lifecycle-env.test.ts create mode 100644 tests/project-startup-state.test.ts create mode 100644 tests/project-up-startup-state.test.ts create mode 100644 tests/registry-credential-preflight.test.ts create mode 100644 tests/shared-agent-skill.test.ts create mode 100644 tests/shell-timeout.test.ts create mode 100644 tests/worktree-runtime-target.test.ts diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index 28d98aa5..02343bb2 100644 --- a/.codex/skills/hack-cli/SKILL.md +++ b/.codex/skills/hack-cli/SKILL.md @@ -10,6 +10,14 @@ description: > 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. +- 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). + ## Product boundary - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -65,12 +73,13 @@ Use `hack` as the primary interface for local-first development. - Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose..override.yml`. - Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands). - Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`. -- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). +- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). ## Linked git worktrees - Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments. - `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance. +- Before `hack up` or `hack restart` auto-targets a new branch instance, Hack warns when the same worktree already owns a non-terminal instance; pass `--branch ` to make the target explicit. - `hack doctor` flags divergent secret keys and dev_host collisions across checkouts. ## Advanced networking (extra_hosts + local proxies/tunnels) @@ -110,11 +119,17 @@ Use `hack` as the primary interface for local-first development. - Put host setup in `.hack/hack.config.json` under `startup`/`lifecycle` (not ad-hoc terminal tabs). - Use `lifecycle.up.before` for pre-start hooks and `lifecycle.processes` for long-running host tasks. +- Lifecycle hooks and processes receive the selected overlay's `global` values plus `host` overrides; service-scoped values remain container-specific unless a host command explicitly selects that scope. - For fixed-port host helpers such as SSM tunnels or local proxies, set `singleton.ports` and usually `onConflict: "adopt"` so Hack reuses a healthy existing listener instead of starting duplicate tunnel stacks. - `singleton` is a listener guard, not process ownership transfer; adopted external processes are left running on `hack down`. - 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`. +- 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. +- Dependency installer services are detected generically by command or `hack.dependencies.bootstrap=true`; registry env references are preflighted before container mutation. Optional `hack.dependencies.cache-volume`, `hack.dependencies.lockfiles`, and `hack.dependencies.runtime-files` labels enable lockfile/runtime-keyed volumes shared across compatible worktrees. ## Workspaces (mux-managed, tmux-first by default) @@ -171,7 +186,8 @@ Use `hack` as the primary interface for local-first development. ## Agent integration maintenance -- Project-level hack commands auto-check integration drift and attempt auto-sync (docs/skills/MCP). +- Project-level hack commands auto-check integration drift and attempt auto-sync (project docs, client skills/rules, shared global skills, and MCP). +- When drift is detected, Hack reports it before repair and tells the agent to reload after repair; it never silently leaves the session using cached rules. - Set `HACK_SETUP_SYNC_MODE=warn` to only warn, or `HACK_SETUP_SYNC_MODE=off` to disable. - Refresh project + user integrations: `hack setup sync --all-scopes` - Audit integration state only: `hack setup sync --all-scopes --check` @@ -190,7 +206,3 @@ Use `hack` as the primary interface for local-first development. - Init patterns: `hack agent patterns` - MCP (no-shell only): `hack setup mcp` - MCP install (explicit): `hack mcp install --all --scope project` - -## Optional extensions - -- A local git-backed tickets extension exists (`hack tickets`) — only use it when the project explicitly uses it. diff --git a/.cursor/rules/hack.mdc b/.cursor/rules/hack.mdc index abf8425e..c466d9cc 100644 --- a/.cursor/rules/hack.mdc +++ b/.cursor/rules/hack.mdc @@ -5,6 +5,14 @@ Hack v3 is local-first. This project uses `hack` for local runtime orchestration (compose + DNS/TLS + logs + sessions). Prefer `hack` when shell access is available. Use MCP only when shell access is unavailable. +## Integration freshness + +- These instructions were generated by hack CLI v3.3.5; 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). + ## Product boundary - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -29,12 +37,13 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose..override.yml`. - Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands). - Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`. -- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). +- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). ## Linked git worktrees - Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments. - `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance. +- Before `hack up` or `hack restart` auto-targets a new branch instance, Hack warns when the same worktree already owns a non-terminal instance; pass `--branch ` to make the target explicit. - `hack doctor` flags divergent secret keys and dev_host collisions across checkouts. ## Standard workflow @@ -58,11 +67,17 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - Put host setup in `.hack/hack.config.json` under `startup`/`lifecycle` (not ad-hoc terminal tabs). - Use `lifecycle.up.before` for pre-start hooks and `lifecycle.processes` for long-running host tasks. +- Lifecycle hooks and processes receive the selected overlay's `global` values plus `host` overrides; service-scoped values remain container-specific unless a host command explicitly selects that scope. - For fixed-port host helpers such as SSM tunnels or local proxies, set `singleton.ports` and usually `onConflict: "adopt"` so Hack reuses a healthy existing listener instead of starting duplicate tunnel stacks. - `singleton` is a listener guard, not process ownership transfer; adopted external processes are left running on `hack down`. - 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`. +- 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. +- Dependency installer services are detected generically by command or `hack.dependencies.bootstrap=true`; registry env references are preflighted before container mutation. Optional `hack.dependencies.cache-volume`, `hack.dependencies.lockfiles`, and `hack.dependencies.runtime-files` labels enable lockfile/runtime-keyed volumes shared across compatible worktrees. ## Host-side env helpers @@ -74,7 +89,8 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is ## Agent integration maintenance -- Project-level hack commands auto-check integration drift and attempt auto-sync (docs/skills/MCP). +- Project-level hack commands auto-check integration drift and attempt auto-sync (project docs, client skills/rules, shared global skills, and MCP). +- When drift is detected, Hack reports it before repair and tells the agent to reload after repair; it never silently leaves the session using cached rules. - Set `HACK_SETUP_SYNC_MODE=warn` to only warn, or `HACK_SETUP_SYNC_MODE=off` to disable. - Refresh project + user integrations: `hack setup sync --all-scopes` - Audit integration state only: `hack setup sync --all-scopes --check` @@ -82,8 +98,4 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - After upgrading CLI: `hack update` then `hack setup sync --all-scopes` - When changing hack itself: interface or behavior changes must update docs/ in the same change (regenerate the CLI reference with `bun run docs:cli-reference`). -## Optional extensions - -- A local git-backed tickets extension exists (`hack tickets`) — only use it when the project explicitly uses it. - # END HACK INTEGRATION diff --git a/AGENTS.md b/AGENTS.md index d63ef80f..12619922 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,6 +209,13 @@ 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. +- 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). + Product boundary: - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. - Removed surfaces: hosted auth/account/org/team flows, web dashboard, built-in GitHub workflows, and built-in Linear sync. @@ -257,11 +264,12 @@ Project files (managed vs generated): - Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose..override.yml`. - Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands). - Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`. -- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). +- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). Linked git worktrees: - Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments. - `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance. +- Before `hack up` or `hack restart` auto-targets a new branch instance, Hack warns when the same worktree already owns a non-terminal instance; pass `--branch ` to make the target explicit. - `hack doctor` flags divergent secret keys and dev_host collisions across checkouts. Advanced networking (extra_hosts + local proxies/tunnels): @@ -296,11 +304,17 @@ Logs (default is compose): Lifecycle + startup: - Put host setup in `.hack/hack.config.json` under `startup`/`lifecycle` (not ad-hoc terminal tabs). - Use `lifecycle.up.before` for pre-start hooks and `lifecycle.processes` for long-running host tasks. +- Lifecycle hooks and processes receive the selected overlay's `global` values plus `host` overrides; service-scoped values remain container-specific unless a host command explicitly selects that scope. - For fixed-port host helpers such as SSM tunnels or local proxies, set `singleton.ports` and usually `onConflict: "adopt"` so Hack reuses a healthy existing listener instead of starting duplicate tunnel stacks. - `singleton` is a listener guard, not process ownership transfer; adopted external processes are left running on `hack down`. - 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`. +- 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. +- Dependency installer services are detected generically by command or `hack.dependencies.bootstrap=true`; registry env references are preflighted before container mutation. Optional `hack.dependencies.cache-volume`, `hack.dependencies.lockfiles`, and `hack.dependencies.runtime-files` labels enable lockfile/runtime-keyed volumes shared across compatible worktrees. Workspaces (mux-managed, tmux-first by default): - Picker: `hack session` for persistent project workspaces. @@ -348,7 +362,8 @@ Docker compose notes: - Use `docker compose -f .hack/docker-compose.yml exec ` only if you need exec into a running container. Agent integration maintenance: -- Project-level hack commands auto-check integration drift and attempt auto-sync (docs/skills/MCP). +- Project-level hack commands auto-check integration drift and attempt auto-sync (project docs, client skills/rules, shared global skills, and MCP). +- When drift is detected, Hack reports it before repair and tells the agent to reload after repair; it never silently leaves the session using cached rules. - Set `HACK_SETUP_SYNC_MODE=warn` to only warn, or `HACK_SETUP_SYNC_MODE=off` to disable. - Refresh project + user integrations: `hack setup sync --all-scopes` - Audit integration state only: `hack setup sync --all-scopes --check` @@ -366,13 +381,10 @@ Agent setup (CLI-first): - Init patterns: `hack agent patterns` - MCP (no-shell only): `hack setup mcp` - MCP install (explicit): `hack mcp install --all --scope project` - -Optional extensions: -- A local git-backed tickets extension exists (`hack tickets`) — only use it when the project explicitly uses it. ## Learned Workspace Facts - Hack v3 intentionally removed the web dashboard, auth broker, built-in GitHub workflows, and built-in Linear sync from the supported product. -- Future repo work should default to the local-first CLI/runtime, optional local tickets, and the slim macOS companion unless the product boundary is explicitly reopened. +- Future repo work should default to the local-first CLI/runtime and the slim macOS companion unless the product boundary is explicitly reopened. - Remote/gateway/node/dispatch should stay unsupported experimental and outside core release gates unless they break local core behavior. diff --git a/CLAUDE.md b/CLAUDE.md index 12d4c70f..9ba38e9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,6 +79,13 @@ 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. +- 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). + Product boundary: - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. - Removed surfaces: hosted auth/account/org/team flows, web dashboard, built-in GitHub workflows, and built-in Linear sync. @@ -127,11 +134,12 @@ Project files (managed vs generated): - Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose..override.yml`. - Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands). - Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`. -- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). +- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). Linked git worktrees: - Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments. - `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance. +- Before `hack up` or `hack restart` auto-targets a new branch instance, Hack warns when the same worktree already owns a non-terminal instance; pass `--branch ` to make the target explicit. - `hack doctor` flags divergent secret keys and dev_host collisions across checkouts. Advanced networking (extra_hosts + local proxies/tunnels): @@ -166,11 +174,17 @@ Logs (default is compose): Lifecycle + startup: - Put host setup in `.hack/hack.config.json` under `startup`/`lifecycle` (not ad-hoc terminal tabs). - Use `lifecycle.up.before` for pre-start hooks and `lifecycle.processes` for long-running host tasks. +- Lifecycle hooks and processes receive the selected overlay's `global` values plus `host` overrides; service-scoped values remain container-specific unless a host command explicitly selects that scope. - For fixed-port host helpers such as SSM tunnels or local proxies, set `singleton.ports` and usually `onConflict: "adopt"` so Hack reuses a healthy existing listener instead of starting duplicate tunnel stacks. - `singleton` is a listener guard, not process ownership transfer; adopted external processes are left running on `hack down`. - 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`. +- 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. +- Dependency installer services are detected generically by command or `hack.dependencies.bootstrap=true`; registry env references are preflighted before container mutation. Optional `hack.dependencies.cache-volume`, `hack.dependencies.lockfiles`, and `hack.dependencies.runtime-files` labels enable lockfile/runtime-keyed volumes shared across compatible worktrees. Workspaces (mux-managed, tmux-first by default): - Picker: `hack session` for persistent project workspaces. @@ -218,7 +232,8 @@ Docker compose notes: - Use `docker compose -f .hack/docker-compose.yml exec ` only if you need exec into a running container. Agent integration maintenance: -- Project-level hack commands auto-check integration drift and attempt auto-sync (docs/skills/MCP). +- Project-level hack commands auto-check integration drift and attempt auto-sync (project docs, client skills/rules, shared global skills, and MCP). +- When drift is detected, Hack reports it before repair and tells the agent to reload after repair; it never silently leaves the session using cached rules. - Set `HACK_SETUP_SYNC_MODE=warn` to only warn, or `HACK_SETUP_SYNC_MODE=off` to disable. - Refresh project + user integrations: `hack setup sync --all-scopes` - Audit integration state only: `hack setup sync --all-scopes --check` @@ -236,7 +251,4 @@ Agent setup (CLI-first): - Init patterns: `hack agent patterns` - MCP (no-shell only): `hack setup mcp` - MCP install (explicit): `hack mcp install --all --scope project` - -Optional extensions: -- A local git-backed tickets extension exists (`hack tickets`) — only use it when the project explicitly uses it. diff --git a/docs/cli.md b/docs/cli.md index 0ec25067..a0e71e1d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -13,7 +13,7 @@ terminal). - `hack open` — open/print the project URL - `hack logs` — tail logs (compose by default; Loki via `--loki`/`--query`) - `hack ps` / `hack status` — project status -- `hack projects` — registry + running instances; `hack projects prune` removes stale registry +- `hack projects` — registry + running instances; `hack projects prune --project ` safely scopes stale registry/container cleanup to one project family (omit `--project` only for an intentional machine-wide prune) entries and stops orphaned containers - `hack env` — env values and local secrets - `hack host exec` / `hack host shell` — host commands/shells with Hack-resolved env injected @@ -24,7 +24,7 @@ terminal). - `hack daemon` — optional local daemon for faster JSON status/ps - `hack agent onboard` — agent-assisted onboarding for existing projects - `hack setup` — install/refresh agent integrations (Cursor rules, Claude hooks, Codex skill, MCP) -- `hack tickets` — optional, opt-in local tickets extension (see [Tickets](#tickets) below) +- `hack tickets` — deprecated compatibility surface for existing Tickets data Run `hack help` for the full command list, or `hack help --all` to include hidden unsupported experimental commands. Every command and flag on this page is also in the generated @@ -61,6 +61,12 @@ See [Beta workflows](beta.md) for guides on this surface. apply documented defaults or fail fast with `E_INTERACTIVE_REQUIRED`. - `NO_COLOR` (or `HACK_NO_COLOR`) disables colored/decorated output. +Generated agent docs, Cursor rules, Codex skills, and the shared `~/.ai/skills/hack-cli` skill carry +the Hack CLI version that generated them. Audit both project and global surfaces with +`hack setup sync --all-scopes --check`; repair them with `hack setup sync --all-scopes`, then reload +the agent session so it stops using cached guidance. Interactive project commands also report drift +before auto-repair instead of repairing silently. + ## First-run path ```bash @@ -84,6 +90,48 @@ project without `.hack/`, use `hack agent onboard`. See - Call a service over HTTP (from the host or between containers): use its Caddy hostname `https://.`; discover routable URLs with `hack open --json`. +Service-scoped runtime changes do not run project-wide lifecycle hooks and do not start Compose +dependencies implicitly: + +```bash +hack up api worker --env qa --detach +hack restart api --env qa +hack env apply --service api --env qa +``` + +Full detached startup and inspection are bounded. A timeout returns `E_STARTUP_TIMEOUT`, terminates +the Compose process group, and leaves an explicit repair path instead of hanging indefinitely. +`hack doctor --fix` starts exact project containers left in `Created`; it never removes those +containers as part of this repair. + +## Dependency bootstrap integrity + +Hack detects package-manager install services by their command or by the explicit +`hack.dependencies.bootstrap=true` Compose label. Before such a service can mutate the runtime, +Hack scans `.npmrc`, `.yarnrc.yml`, and `bunfig.toml` for `${VAR}` credential references and fails +with `E_ENV_KEY_MISSING` when the selected Hack overlay cannot supply them. Values are never +printed. + +To share dependency data only across worktrees with the same lockfile/runtime fingerprint, label +the bootstrap service with a logical top-level Compose volume: + +```yaml +services: + install-workspace: + command: bun install --frozen-lockfile + labels: + hack.dependencies.bootstrap: "true" + hack.dependencies.cache-volume: workspace-dependencies + hack.dependencies.lockfiles: bun.lock,package.json + hack.dependencies.runtime-files: .mise.toml + volumes: + - workspace-dependencies:/app/node_modules +``` + +Hack generates a content-addressed volume name from the declared inputs. Branch instances adopt an +existing compatible volume automatically; a lockfile or runtime change selects a new volume. No +service name such as `deps` is special. + ## Branch instances and linked worktrees `--branch ` on `hack up/down/restart/ps/logs/open/run/exec` targets a separate branch @@ -94,6 +142,11 @@ branch when no `--branch` is passed (`worktree.auto_branch`), so two checkouts n same hostnames. A one-line notice is printed to stderr when the default kicks in, so captured stdout stays clean. +Before `up` or `restart`, Hack also checks for a non-terminal instance previously started from the +same worktree. If the worktree's current branch would auto-target a different Compose project, Hack +prints a warning naming both the existing and new targets. Pass `--branch ` to make the target +explicit. + A detached linked worktree has no branch name to derive, so these commands fail instead of silently targeting the base instance. Pass `--branch ` to select an isolated instance, or set `worktree.auto_branch` to `false` only when intentionally opting into the base instance. @@ -141,19 +194,19 @@ The global config root defaults to `~/.hack`; override it with `HACK_HOME`. ## Tickets -Tickets is an **optional, opt-in** extension — disabled by default and not part of default agent -instructions. Enable it before using the commands below: +Hack Tickets is deprecated. It is no longer installed into agent instructions or skills, and +`hack setup sync --all-scopes` removes legacy Tickets agent artifacts. Existing commands remain +available only for compatibility and migration when the extension is explicitly enabled. ```bash -hack tickets setup # auto-enables the extension and installs the skill hack tickets create --title "Investigate flaky lifecycle cleanup" hack tickets list hack tickets show T-00001 -hack tickets status T-00001 in_progress +hack tickets sync ``` -`hack tickets ` is an alias for `hack x tickets `; every subcommand except -`setup` requires the extension to already be enabled. See the full guide: +`hack tickets setup` now removes deprecated agent skills/instruction blocks and performs compatible +storage hygiene; it does not enable Tickets or reinstall guidance. See the migration reference: [Tickets](guides/tickets.md). ## Lifecycle diff --git a/docs/docs-ia.md b/docs/docs-ia.md index f96fedd7..eed86c9f 100644 --- a/docs/docs-ia.md +++ b/docs/docs-ia.md @@ -21,7 +21,6 @@ remote/control-plane features. - `docs/guides/global-settings.md` — global config - `docs/guides/agent-first-setup.md` — agent-assisted onboarding (`hack init --with`, `hack agent onboard`) - `docs/guides/prerequisite-detection-matrix.md` — environment prerequisite checks -- `docs/guides/tickets.md` — optional, opt-in local ticket workflow (see note below) - Generated agent-facing guidance: `AGENTS.md`/`CLAUDE.md` snippets, `.codex/skills/hack-cli/SKILL.md`, and `hack-init` skill/MCP prompt content, all rendered from `src/agents/instruction-source.ts` (canonical source) via `src/agents/*` and `src/mcp/agent-docs.ts`. @@ -30,10 +29,6 @@ New docs belong here when they describe supported v3 CLI surface a user hits wit remote/gateway/node/dispatch: project lifecycle, env, sessions, worktrees, diagnostics (`hack doctor`), JSON/`--no-interactive` agent ergonomics, and onboarding. -Tickets note: the tickets extension is optional and opt-in (disabled by default; enable -`dance.hack.tickets` in config). It stays in the core bucket because it is a supported local-first -workflow, not because it ships enabled by default. - ### 2. Beta (`docs/beta.md` index) Unsupported experimental remote/control-plane workflows: gateway exposure, remote nodes, remote @@ -68,7 +63,7 @@ control-plane SDK, and API-level detail for the beta surface. - `docs/extensions.md` — extension model, dispatch (`hack x `), built-in extensions - `docs/integrations.md` — what shipped, what was removed, optional agent-setup helpers - `docs/guides/create-extension.md` — how to author a new extension -- `docs/guides/tickets.md` — also cross-linked here for the full tickets command surface +- `docs/guides/tickets.md` — deprecated compatibility and migration reference - `docs/gateway-api.md` — gateway HTTP/WS API surface (unsupported experimental) - `docs/sdk.md` — control-plane SDK diff --git a/docs/env.md b/docs/env.md index 5dc4fad7..51cf5c96 100644 --- a/docs/env.md +++ b/docs/env.md @@ -51,7 +51,7 @@ worktrees inherit the rules with zero setup. It covers (patterns relative to - `.env.state.json` - `hack.env.local.yaml` - `hack.env.*.local.yaml` -- `tickets/` (local tickets-extension cache) +- `tickets/` (deprecated Tickets compatibility cache) How it is maintained: @@ -75,7 +75,7 @@ Leak detection and repair: secret; rotate it after untracking - `hack doctor --fix` untracks the offenders with `git rm --cached` (files stay on disk) and re-ensures `.hack/.gitignore`; commit the removal -- `--fix` and `--migrate-env-config` are ignored when `--json` is passed; +- `--fix` and `--migrate-env-config` cannot be combined with `--json`; run the repair and JSON audit separately so a mutating request is never silently ignored. run them separately from a JSON-mode invocation ## File format @@ -91,7 +91,7 @@ Each env file is YAML with: - `global`: values applied everywhere - ``: values applied only for that compose service -- `host`: optional overrides applied only to host-command injection (`hack host exec` / `hack host shell`) +- `host`: optional overrides applied to host execution (`hack host exec`, `hack host shell`, and lifecycle hooks/processes) Example: @@ -123,7 +123,8 @@ Hack resolves env in this order: 6. if a service scope is requested, merge `values.` on top Worktree-local overrides win over shared repo overlays. Service values override global values. -Host-command injection applies `host` values last when the execution target is `host`. +Host execution applies `host` values last. This includes `hack host exec`, `hack host shell`, +and lifecycle hooks/processes, which all run on the host rather than in Compose. Projects can set a default overlay in `.hack/hack.config.json`: @@ -156,7 +157,7 @@ That means `.hack/.env` is no longer the primary runtime source of truth. `hack env materialize` is manual by design. Use it only when you need a compatibility file for an external tool that expects `.env` on disk. -For host commands, `hack host exec` and `hack host shell` default to a host-local view. +For host execution, `hack host exec`, `hack host shell`, and lifecycle hooks/processes use a host-local view. That means Hack prefers host-usable values when it can: - explicit `host` scope values override the normal `global` + `` merge @@ -183,6 +184,15 @@ hack env list --env qa hack env list --env qa --service api hack env list --json hack env list --show-secrets +hack env explain GITHUB_TOKEN --env runner --service installer --target compose +``` + +`hack env explain` never prints the value. It reports availability, secret classification, +selected/effective scope, source file and precedence, and whether the value is delivered to host +lifecycle code or Compose. Recreate just one service after an env change with: + +```bash +hack env apply --service api --env qa ``` `--show-secrets` prints secret values in plaintext instead of the masked default; use it deliberately. diff --git a/docs/extensions.md b/docs/extensions.md index ead669ce..0f0b8f7c 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -11,8 +11,8 @@ built-in GitHub integration, or built-in Linear integration. ## Scope -- Supported local helpers: - - Tickets: `hack x tickets ...` (opt-in — see [Built-in Extensions](#built-in-extensions)) +- Deprecated compatibility helpers: + - Tickets: `hack x tickets ...` (existing data migration only) - Unsupported experimental workflows: - Gateway: `hack x gateway ...` - Supervisor: `hack x supervisor ...` @@ -37,11 +37,13 @@ built-in GitHub integration, or built-in Linear integration. ## Built-in Extensions -- Tickets (opt-in; disabled unless `controlPlane.extensions["dance.hack.tickets"].enabled` is - `true` — running `hack x tickets setup` auto-enables it in the project config): +- Tickets (deprecated; disabled unless `controlPlane.extensions["dance.hack.tickets"].enabled` is + explicitly set to `true`; no agent skills or instructions are installed): - `hack x tickets setup|create|update|comment|review-note|document|list|show|status|resolve-conflict|sync|tui` - Also available as the top-level alias `hack tickets ` (see [docs/guides/tickets.md](guides/tickets.md)). + - `setup` removes legacy Tickets guidance and repairs storage hygiene; it no longer enables the + extension or installs agent integrations. - Unsupported experimental: - `hack x gateway token-create|token-list|token-revoke` - `hack x supervisor job-create|job-list|job-show|job-tail|job-attach|job-cancel|shell` diff --git a/docs/guides/codex-managed-environments.md b/docs/guides/codex-managed-environments.md index 2d4a9d25..b5bf0267 100644 --- a/docs/guides/codex-managed-environments.md +++ b/docs/guides/codex-managed-environments.md @@ -186,8 +186,6 @@ bash scripts/maintain-codex-slim.sh - `hack host shell` - repo-local docs, specs, and agent setup - command and config surfaces that do not require the machine-wide runtime stack -- `hack tickets`, if the project opts in — the extension is disabled by default; run - `hack tickets setup` once (it works even while the extension is disabled and enables it) If the repo uses the modern env overlay model, provide secret decryption material with `HACK_ENV_SECRET_KEY` instead of relying on a local `.hack.secret.key` file. (On a developer diff --git a/docs/guides/create-extension.md b/docs/guides/create-extension.md index 46cf50da..cc0549ca 100644 --- a/docs/guides/create-extension.md +++ b/docs/guides/create-extension.md @@ -48,21 +48,6 @@ export const extension: ExtensionDefinition = { } ``` -This mirrors the real tickets extension (`src/control-plane/extensions/tickets/extension.ts`): - -```ts -export const TICKETS_EXTENSION: ExtensionDefinition = { - manifest: { - id: "dance.hack.tickets", - version: "0.1.0", - scopes: ["project"], - cliNamespace: "tickets", - summary: "Git-backed tickets and runs", - }, - commands: TICKETS_COMMANDS, -}; -``` - `scopes` is a list of `"global" | "project"` and drives what the CLI suggests when the extension is disabled: a `global`-only extension gets a `hack config set --global ...` enable hint, otherwise the hint targets the project config (`hack config set ...`). @@ -98,8 +83,8 @@ hack x myext help ### Advanced: commands that must run before the extension is enabled Set `allowWhenDisabled: true` on a command (for example a `setup` command) to let it run even while -the extension itself is disabled — this is how tickets' `hack x tickets setup` can enable the -extension as its first step. See `src/commands/tickets.ts` for the pattern. +the extension itself is disabled. Use this only for explicit enablement or migration commands; do +not make normal operations bypass extension enablement. ## Planned improvements diff --git a/docs/guides/tickets.md b/docs/guides/tickets.md index e3ab3485..11aa8867 100644 --- a/docs/guides/tickets.md +++ b/docs/guides/tickets.md @@ -3,10 +3,9 @@ This page is part of [Extensions & reference](../reference.md). If you are learning the core local product flow, start with [Core docs](../core.md). -> Tickets is an optional, opt-in extension. Enable `dance.hack.tickets` in config (or run -> `hack x tickets setup`, which enables it for you) before it does anything, and any -> agent/integration syncing only happens once it's enabled. Tickets is no longer part of default -> agent instructions — only use it when the project explicitly opts into it. +> **Deprecated.** Hack Tickets remains available only for compatibility and migration of existing +> data. It is no longer installed into agent instructions or skills. New projects should use the +> tracker selected by the project instead. The tickets extension is a lightweight, git-backed ticket log intended for small teams and solo dev. It stores events in a dedicated git ref (`refs/hack/tickets` by default, hidden from branch lists) so @@ -16,30 +15,29 @@ ticket history is versioned and syncable without requiring an external service. - Extension id: `dance.hack.tickets` - Storage: `.hack/tickets/` (local working state) + a git ref for syncing -## Enable +## Compatibility enablement -The primary path is `hack x tickets setup` (or the top-level alias `hack tickets setup`) — it -enables the extension in the project's `.hack/hack.config.json` as its first step, then installs -the skill and agent-doc snippets. `setup` is special-cased to run even when the extension is -disabled, so this is the only command that works before tickets is turned on. +Existing repositories must explicitly enable the extension in `.hack/hack.config.json` or global +config. `hack x tickets setup` no longer enables it and no longer installs skills or agent-doc +snippets. The setup command now removes those deprecated artifacts and keeps storage hygiene usable +for migration. -From inside the repo you want to enable tickets for: +From inside an existing Tickets repo, clean up deprecated agent integrations: ```bash hack x tickets setup ``` Options: -- `--global` installs the Codex skill into `~/.codex/skills/hack-tickets/` instead of the repo. The - default (project) scope installs to `/.codex/skills/hack-tickets/SKILL.md`. -- `--agents` / `--claude` / `--all` control which agent-doc files get a tickets snippet. -- `--check` and `--remove` work as expected. +- `--global` audits or removes the deprecated user-scoped Tickets skill. +- `--agents` / `--claude` / `--all` select legacy agent-doc blocks to audit or remove. +- `--check` exits non-zero when deprecated Tickets guidance is still installed. +- `--remove` is accepted explicitly; the default setup action also removes agent guidance. - `--json` prints a machine-readable result shaped like `{ skill, docs, repo: { gitignore, tracking } }` instead of the human-readable summary. Notes: -- Most tickets commands prompt to run setup if `.hack/tickets/` is tracked, missing from `.gitignore`, - or if agent docs/skills are missing (TTY + gum only). +- Tickets commands may prompt for repository storage hygiene, but never install agent docs or skills. - Setup also prompts to repair legacy tickets branches or stray files in the tickets ref. - In `--json` mode this setup-health check is skipped entirely and silently — no warning is printed. In non-interactive terminals (no TTY, or `gum` unavailable) the CLI instead prints a @@ -83,8 +81,8 @@ default for both is on. ## Basic usage Every `hack x tickets ` below also works as the top-level alias `hack tickets ` -(for example `hack tickets list`). The alias requires the extension to already be enabled, except -for `setup`, which can enable it for you. +(for example `hack tickets list`). The alias requires the extension to already be enabled. `setup` +can run while disabled, but it does not enable the extension. Create a ticket: diff --git a/docs/integrations.md b/docs/integrations.md index 7b74a0ce..f354f086 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -4,10 +4,6 @@ Hack v3 no longer ships hosted or broker-backed integrations. What remains: -- local tickets stored with the repo — optional and opt-in: the `dance.hack.tickets` extension is - disabled by default and must be enabled via `controlPlane.extensions["dance.hack.tickets"].enabled` - (or auto-enabled by running `hack x tickets setup`); it is no longer part of default agent - instructions - local env management and host/container injection - local sessions and runtime orchestration - optional coding-agent setup helpers: `hack init --with claude|codex|both`, `hack agent onboard` / @@ -15,6 +11,7 @@ What remains: What was removed: +- Hack Tickets agent integration; legacy commands remain compatibility-only and are deprecated - built-in GitHub integration - built-in Linear integration - hosted auth/account/org/team surfaces @@ -27,4 +24,16 @@ instead of failing hard. Recommended replacements: - GitHub: native `git` and `gh` -- planning systems: keep them outside Hack, or use repo-local tickets when you want the workflow to stay self-contained +- planning systems: keep them outside Hack and use the tracker selected by the project + +## Agent integration freshness + +Hack maintains project instructions plus global Cursor, Claude, Codex, and shared `~/.ai/skills` +surfaces. Generated guidance identifies the CLI version that rendered it. + +- Audit without writing: `hack setup sync --all-scopes --check` +- Repair project and global integrations: `hack setup sync --all-scopes` +- After repair: reload the agent session so cached rules are discarded + +Interactive project commands announce detected drift before auto-repair. `hack agent prime` performs +the same read-only audit at session start and prints a warning before any Hack operating guidance. diff --git a/docs/lifecycle.md b/docs/lifecycle.md index dcc347cf..a01b9d4a 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -205,7 +205,11 @@ stopped. `hack restart` preserves the same guard semantics during its down phase ### `hack restart` -`hack restart` performs the same lifecycle steps as `hack down` followed by `hack up`. +`hack restart` runs the down lifecycle hooks and stops owned host processes, but preserves the +current Compose runtime until preflight succeeds. It then force-recreates services and attempts a +repair start if recreation fails. This avoids destroying a healthy stack before env and registry +checks have passed. `hack restart ` is service-scoped: it skips project-wide lifecycle +hooks, uses `--no-deps`, and verifies only the selected services. From the primary checkout, it targets only the base Compose/lifecycle instance. A linked worktree uses its isolated derived branch instance, and `--branch ` targets only that explicit branch. @@ -213,7 +217,10 @@ its isolated derived branch instance, and `--branch ` targets only that ex `hack up`, `hack down`, and `hack restart` accept `--json`. A lifecycle hook failure surfaces as `{ok: false, error: {code: 'E_LIFECYCLE_FAILED', message}}`; a `docker compose` failure surfaces as -`E_COMPOSE_FAILED`. Lifecycle failures are one of the primary consumers of the `--json` error envelope, +`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. +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`. ## Sessions backend @@ -228,6 +235,10 @@ Lifecycle session name: - No branch: `--lifecycle` - With `--branch `: `--lifecycle-` +Lifecycle hooks and processes run on the host. They receive the selected overlay's `global` values +plus `host` overrides, with `host` taking precedence. Service-scoped values remain container-specific +unless a host command explicitly selects that scope with `hack host exec --scope `. + Notes: - If no mux backend is available, lifecycle process startup fails with an actionable error. - Teardown is implemented by killing the lifecycle session; anything running inside that session will be stopped. @@ -244,6 +255,9 @@ Notes: as possible in-flight startups for five minutes and stay untouched. `hack doctor --fix` reaps only established orphans after rechecking runtime liveness and mux ownership; unverified same-name sessions stay untouched. +- Runtime hygiene also warns when regular Compose services are stuck in `Created`, including stacks + left behind by an interrupted startup. This check is diagnostic only; inspect the explicit target + with `hack ps --json` before deciding whether to retry or tear it down. ## Tips diff --git a/docs/reference.md b/docs/reference.md index 2c90d4ae..ac99302f 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -15,12 +15,12 @@ - [Sessions](./sessions.md) - [Agent-first setup](./guides/agent-first-setup.md) -## Extensions & tickets +## Extensions & legacy compatibility - [Extensions](./extensions.md) - [Creating an extension](./guides/create-extension.md) - [Integrations](./integrations.md) -- [Tickets guide (optional extension)](./guides/tickets.md) +- [Tickets migration reference (deprecated compatibility surface)](./guides/tickets.md) - [SDK](./sdk.md) ## Experimental / beta diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 06fd8228..305f6915 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -85,16 +85,22 @@ hack init [options] | `--version, -v` | Show version | -## `hack up` +## `hack up [services...]` Start project services (docker compose up) ### Usage ```bash -hack up [options] +hack up [services...] [options] ``` +### Arguments + +| Arg | Description | +| --- | --- | +| `services` | | + ### Options | Option | Description | @@ -138,16 +144,22 @@ hack down [options] | `--version, -v` | Show version | -## `hack restart` +## `hack restart [services...]` -Restart project services (down then up) +Restart the project or selected services ### Usage ```bash -hack restart [options] +hack restart [services...] [options] ``` +### Arguments + +| Arg | Description | +| --- | --- | +| `services` | | + ### Options | Option | Description | @@ -1160,6 +1172,7 @@ hack projects prune [options] | Option | Description | | --- | --- | +| `--project ` | Target a registered project by name (from ~/.hack/projects.json) | | `--include-global` | Include global infra projects under ~/.hack (e.g. logging stack) | | `--json` | Output JSON (machine-readable) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | @@ -1628,9 +1641,9 @@ hack setup [options] | `hack setup cursor` | Install Cursor rules for hack CLI usage | | `hack setup claude` | Install Claude Code hooks for hack CLI usage | | `hack setup codex` | Install Codex skill for hack CLI usage | -| `hack setup tickets` | Install Codex skill for hack tickets usage | +| `hack setup tickets` | Remove or audit the deprecated Hack Tickets skill | | `hack setup agents` | Install AGENTS.md / CLAUDE.md snippets for hack CLI usage | -| `hack setup sync` | Refresh agent docs, skills, and MCP configs | +| `hack setup sync` | Refresh project/global agent guidance and remove deprecated artifacts | | `hack setup mcp` | Install MCP configs for hack CLI usage (no-shell only) | ### Options @@ -1729,7 +1742,7 @@ hack setup codex [options] ## `hack setup tickets` -Install Codex skill for hack tickets usage +Remove or audit the deprecated Hack Tickets skill ### Usage @@ -1775,7 +1788,7 @@ hack setup agents [options] ## `hack setup sync` -Refresh agent docs, skills, and MCP configs +Refresh project/global agent guidance and remove deprecated artifacts ### Usage @@ -2164,6 +2177,8 @@ hack env [options] | Command | Summary | | --- | --- | | `hack env list` | List resolved env values for the selected overlay | +| `hack env explain ` | Explain a resolved env value without revealing it | +| `hack env apply` | Recreate one service with its resolved env | | `hack env add [key] [value]` | Add or update an env value | | `hack env set [key] [value]` | Alias for env add | | `hack env materialize` | Write a compatibility .env file from the selected overlay | @@ -2204,6 +2219,59 @@ hack env list [options] | `--help, -h` | Show help | | `--version, -v` | Show version | +## `hack env explain ` + +Explain a resolved env value without revealing it + +### Usage + +```bash +hack env explain [options] +``` + +### Arguments + +| Arg | Description | +| --- | --- | +| `key` | | + +### Options + +| Option | Description | +| --- | --- | +| `--path, -p ` | Run a project command against a repo path (overrides cwd search) | +| `--project ` | Target a registered project by name (from ~/.hack/projects.json) | +| `--env ` | Apply an optional env overlay by name (use 'base' to bypass overlays) | +| `--json` | Output JSON (machine-readable) | +| `--service ` | Target scope (global or a discovered service name) | +| `--target ` | Env view for host commands (default: host rewrites container-oriented addresses for local host execution) | +| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | +| `--help, -h` | Show help | +| `--version, -v` | Show version | + +## `hack env apply` + +Recreate one service with its resolved env + +### Usage + +```bash +hack env apply [options] +``` + +### Options + +| Option | Description | +| --- | --- | +| `--path, -p ` | Run a project command against a repo path (overrides cwd search) | +| `--project ` | Target a registered project by name (from ~/.hack/projects.json) | +| `--env ` | Apply an optional env overlay by name (use 'base' to bypass overlays) | +| `--json` | Output JSON (machine-readable) | +| `--service ` | Target scope (global or a discovered service name) | +| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | +| `--help, -h` | Show help | +| `--version, -v` | Show version | + ## `hack env add [key] [value]` Add or update an env value @@ -2487,7 +2555,7 @@ Start an interactive host shell with the selected Hack env overlay injected. Use ## `hack tickets [args...]` -Track repo-local work without leaving git +Deprecated: legacy repo-local Tickets compatibility commands ### Usage @@ -2505,6 +2573,7 @@ Usage: hack tickets setup hack tickets tui +Deprecated compatibility surface. It is no longer installed into agent instructions or skills. Alias for `hack x tickets `. Requires extension enabled. ### Arguments diff --git a/examples/basic/AGENTS.md b/examples/basic/AGENTS.md index 4802097d..29a8fe2c 100644 --- a/examples/basic/AGENTS.md +++ b/examples/basic/AGENTS.md @@ -1,17 +1,26 @@ ## hack CLI (local dev + MCP) -Use `hack` as the single interface for local-first runtime orchestration (compose, DNS/TLS, logs, env, persistent project workspaces, and optional local tickets). +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. +- 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). Product boundary: -- Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, daemon, and optional local tickets. +- Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. - Removed surfaces: hosted auth/account/org/team flows, web dashboard, built-in GitHub workflows, and built-in Linear sync. -- Unsupported experimental: remote/gateway/node/dispatch. Do not use these as the default path unless explicitly requested. +- Experimental and unsupported: remote/gateway/node/dispatch commands. They are hidden from default help (list with `hack help --all`) and warn on use; do not use them unless explicitly requested. Operating rules: - Prefer `hack` over raw `docker` / `docker compose` for project workflows. - Do not start/stop services from Docker Desktop UI for `hack`-managed projects. - Treat `.hack/.internal` and `.hack/.branch` as hack-managed artifacts; do not hand-edit generated files there. +- Use `--json` for machine-readable output when available; `hack up/down/restart/doctor --json` emit an `{ok, data | error: {code, message}}` envelope with stable E_* error codes. +- Scripted/agent runs: pass `--no-interactive` (or set `HACK_NO_INTERACTIVE=1`) so commands never block on prompts — they apply documented defaults or fail fast with E_INTERACTIVE_REQUIRED. - Use MCP only when shell access is unavailable. - If runtime state looks wrong, run `hack doctor`, then `hack doctor --fix` before manual repair. @@ -36,6 +45,8 @@ Hostname routing + Caddy labels: TLS + valid-hostname constraints: - `hack` uses Caddy internal PKI for HTTPS on routed hosts; trust CA with `hack global trust`. +- Containers get a combined public+local trust bundle (SSL_CERT_FILE etc.) once `hack global trust` has run; public TLS (package registries, external APIs) keeps working alongside `*.hack` trust. +- If the combined bundle is missing, only Node gets `*.hack` trust (NODE_EXTRA_CA_CERTS); OpenSSL-based tools keep public roots — run `hack global trust` to enable both. - `.hack` is local-first and great for dev, but it is not a public suffix. - Use OAuth alias hosts (for example `*.hack.gy`) when providers require public-suffix-style callback domains. - Alias hosts are still local-dev routes unless you add an external tunnel/remote ingress path. @@ -44,10 +55,16 @@ Project files (managed vs generated): - Source-of-truth files: `.hack/docker-compose.yml`, `.hack/hack.config.json`, `.hack/hack.env.default.yaml`, and optional `.hack/hack.env..yaml`. - Worktree-local env override files: `.hack/hack.env.local.yaml` and `.hack/hack.env..local.yaml`. - Local-only files: `.hack.secret.key`, optional `.hack/.env` compatibility output, `.hack/.env.state.json`, and `.hack/.internal/` (runtime/local machine state; keep gitignored). -- Linked worktrees can inherit secret decryption through the git common dir; use `HACK_ENV_SECRET_KEY` in CI/managed containers. - Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose..override.yml`. - Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands). - Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`. +- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). + +Linked git worktrees: +- Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments. +- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance. +- Before `hack up` or `hack restart` auto-targets a new branch instance, Hack warns when the same worktree already owns a non-terminal instance; pass `--branch ` to make the target explicit. +- `hack doctor` flags divergent secret keys and dev_host collisions across checkouts. Advanced networking (extra_hosts + local proxies/tunnels): - Static host mappings: set `internal.extra_hosts` in `.hack/hack.config.json`. @@ -63,6 +80,13 @@ Standard workflow: - Restart: `hack restart` - Stop services: `hack down` +Running things (decision guide): +- One-off command in a fresh service container (deps started as needed): `hack run `. +- Command inside an already-running service container: `hack exec -- `. +- Host script that needs hack-stored env: `hack host exec --env --scope -- ` — this is THE way to run repo scripts; never read .env files directly. +- Interactive host shell with injected env: `hack host shell --env --scope `. +- Call a service over HTTP (from the host or between containers): use its Caddy hostname `https://.`; discover routable URLs with `hack open --json`. + Logs (default is compose): - Fast tail: `hack logs --pretty` - Per-service tail: `hack logs ` @@ -74,9 +98,17 @@ Logs (default is compose): Lifecycle + startup: - Put host setup in `.hack/hack.config.json` under `startup`/`lifecycle` (not ad-hoc terminal tabs). - Use `lifecycle.up.before` for pre-start hooks and `lifecycle.processes` for long-running host tasks. +- Lifecycle hooks and processes receive the selected overlay's `global` values plus `host` overrides; service-scoped values remain container-specific unless a host command explicitly selects that scope. - For fixed-port host helpers such as SSM tunnels or local proxies, set `singleton.ports` and usually `onConflict: "adopt"` so Hack reuses a healthy existing listener instead of starting duplicate tunnel stacks. - `singleton` is a listener guard, not process ownership transfer; adopted external processes are left running on `hack down`. - 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`. +- 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. +- Dependency installer services are detected generically by command or `hack.dependencies.bootstrap=true`; registry env references are preflighted before container mutation. Optional `hack.dependencies.cache-volume`, `hack.dependencies.lockfiles`, and `hack.dependencies.runtime-files` labels enable lockfile/runtime-keyed volumes shared across compatible worktrees. Workspaces (mux-managed, tmux-first by default): - Picker: `hack session` for persistent project workspaces. @@ -94,28 +126,10 @@ Host-side env helpers: - Interactive host shell with injected env: `hack host shell --env qa --scope api` - Run inside an already-running service container: `hack exec api -- bun test` -Tickets (git-backed): -- Create: `hack tickets create --title "..." --body-stdin` -- List/show: `hack tickets list`, `hack tickets show T-AB12CD34EF` -- Status/sync: `hack tickets status T-AB12CD34EF in_progress`, `hack tickets sync` - -Global infra: -- Bootstrap once: `hack global install` -- Start/stop/status: `hack global up`, `hack global down`, `hack global status` -- Use `hack global up` before Loki/Grafana queries if global logging is offline. - -Unsupported experimental remote nodes + dispatch: -- These commands are source-available but outside the supported v3 product contract. -- Pair/register nodes: `hack node pair ...`, then verify with `hack node list` and `hack node status --watch`. -- Repair SSH for remote Git/mutagen: `hack node ssh setup --node `. -- On node host, inspect workspace map via `hack node workspace list|resolve|attach|remove`. -- Inspect/repair controller-side route bridge with `hack node routes status` and `hack node routes repair`. -- Dispatch remote commands: `hack dispatch run --project --node default --branch --runner generic -- ""`. - -When to use a branch instance: -- You need two versions running at once (PR review, experiments, migrations). -- You want to keep a stable environment while testing another branch. -- Use `--branch ` on `hack up/open/logs/down` to target it. +Branch instances (parallel envs): +- Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. +- Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). +- Linked worktrees pick a branch instance automatically (see Linked git worktrees). Run commands inside services: - One-off: `hack run ` (uses `docker compose run --rm`) @@ -128,6 +142,11 @@ Project targeting: - Else use `--project ` (registry) or `--path `. - List projects: `hack projects --json` +Global infra: +- Bootstrap once: `hack global install` +- Start/stop/status: `hack global up`, `hack global down`, `hack global status` +- Use `hack global up` before Loki/Grafana queries if global logging is offline. + Daemon (optional): - Start for faster JSON status/ps: `hack daemon start` - Check status: `hack daemon status` @@ -137,19 +156,21 @@ Docker compose notes: - Use `docker compose -f .hack/docker-compose.yml exec ` only if you need exec into a running container. Agent integration maintenance: -- Project-level hack commands auto-check integration drift and attempt auto-sync (docs/skills/MCP). +- Project-level hack commands auto-check integration drift and attempt auto-sync (project docs, client skills/rules, shared global skills, and MCP). +- When drift is detected, Hack reports it before repair and tells the agent to reload after repair; it never silently leaves the session using cached rules. - Set `HACK_SETUP_SYNC_MODE=warn` to only warn, or `HACK_SETUP_SYNC_MODE=off` to disable. - Refresh project + user integrations: `hack setup sync --all-scopes` - Audit integration state only: `hack setup sync --all-scopes --check` - Remove generated integration artifacts: `hack setup sync --all-scopes --remove` - After upgrading CLI: `hack update` then `hack setup sync --all-scopes` +- When changing hack itself: interface or behavior changes must update docs/ in the same change (regenerate the CLI reference with `bun run docs:cli-reference`). Agent setup (CLI-first): - Cursor rules: `hack setup cursor` - Claude hooks: `hack setup claude` - Codex skill: `hack setup codex` -- Tickets skill: `hack setup tickets` - Refresh all local agent integrations: `hack setup sync --all-scopes` +- Agent-assisted onboarding: `hack init --with claude|codex|both` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. - Init prompt: `hack agent init` (use --client cursor|claude|codex to open) - Init patterns: `hack agent patterns` - MCP (no-shell only): `hack setup mcp` diff --git a/examples/basic/CLAUDE.md b/examples/basic/CLAUDE.md index 4802097d..29a8fe2c 100644 --- a/examples/basic/CLAUDE.md +++ b/examples/basic/CLAUDE.md @@ -1,17 +1,26 @@ ## hack CLI (local dev + MCP) -Use `hack` as the single interface for local-first runtime orchestration (compose, DNS/TLS, logs, env, persistent project workspaces, and optional local tickets). +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. +- 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). Product boundary: -- Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, daemon, and optional local tickets. +- Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. - Removed surfaces: hosted auth/account/org/team flows, web dashboard, built-in GitHub workflows, and built-in Linear sync. -- Unsupported experimental: remote/gateway/node/dispatch. Do not use these as the default path unless explicitly requested. +- Experimental and unsupported: remote/gateway/node/dispatch commands. They are hidden from default help (list with `hack help --all`) and warn on use; do not use them unless explicitly requested. Operating rules: - Prefer `hack` over raw `docker` / `docker compose` for project workflows. - Do not start/stop services from Docker Desktop UI for `hack`-managed projects. - Treat `.hack/.internal` and `.hack/.branch` as hack-managed artifacts; do not hand-edit generated files there. +- Use `--json` for machine-readable output when available; `hack up/down/restart/doctor --json` emit an `{ok, data | error: {code, message}}` envelope with stable E_* error codes. +- Scripted/agent runs: pass `--no-interactive` (or set `HACK_NO_INTERACTIVE=1`) so commands never block on prompts — they apply documented defaults or fail fast with E_INTERACTIVE_REQUIRED. - Use MCP only when shell access is unavailable. - If runtime state looks wrong, run `hack doctor`, then `hack doctor --fix` before manual repair. @@ -36,6 +45,8 @@ Hostname routing + Caddy labels: TLS + valid-hostname constraints: - `hack` uses Caddy internal PKI for HTTPS on routed hosts; trust CA with `hack global trust`. +- Containers get a combined public+local trust bundle (SSL_CERT_FILE etc.) once `hack global trust` has run; public TLS (package registries, external APIs) keeps working alongside `*.hack` trust. +- If the combined bundle is missing, only Node gets `*.hack` trust (NODE_EXTRA_CA_CERTS); OpenSSL-based tools keep public roots — run `hack global trust` to enable both. - `.hack` is local-first and great for dev, but it is not a public suffix. - Use OAuth alias hosts (for example `*.hack.gy`) when providers require public-suffix-style callback domains. - Alias hosts are still local-dev routes unless you add an external tunnel/remote ingress path. @@ -44,10 +55,16 @@ Project files (managed vs generated): - Source-of-truth files: `.hack/docker-compose.yml`, `.hack/hack.config.json`, `.hack/hack.env.default.yaml`, and optional `.hack/hack.env..yaml`. - Worktree-local env override files: `.hack/hack.env.local.yaml` and `.hack/hack.env..local.yaml`. - Local-only files: `.hack.secret.key`, optional `.hack/.env` compatibility output, `.hack/.env.state.json`, and `.hack/.internal/` (runtime/local machine state; keep gitignored). -- Linked worktrees can inherit secret decryption through the git common dir; use `HACK_ENV_SECRET_KEY` in CI/managed containers. - Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose..override.yml`. - Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands). - Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`. +- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). + +Linked git worktrees: +- Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments. +- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance. +- Before `hack up` or `hack restart` auto-targets a new branch instance, Hack warns when the same worktree already owns a non-terminal instance; pass `--branch ` to make the target explicit. +- `hack doctor` flags divergent secret keys and dev_host collisions across checkouts. Advanced networking (extra_hosts + local proxies/tunnels): - Static host mappings: set `internal.extra_hosts` in `.hack/hack.config.json`. @@ -63,6 +80,13 @@ Standard workflow: - Restart: `hack restart` - Stop services: `hack down` +Running things (decision guide): +- One-off command in a fresh service container (deps started as needed): `hack run `. +- Command inside an already-running service container: `hack exec -- `. +- Host script that needs hack-stored env: `hack host exec --env --scope -- ` — this is THE way to run repo scripts; never read .env files directly. +- Interactive host shell with injected env: `hack host shell --env --scope `. +- Call a service over HTTP (from the host or between containers): use its Caddy hostname `https://.`; discover routable URLs with `hack open --json`. + Logs (default is compose): - Fast tail: `hack logs --pretty` - Per-service tail: `hack logs ` @@ -74,9 +98,17 @@ Logs (default is compose): Lifecycle + startup: - Put host setup in `.hack/hack.config.json` under `startup`/`lifecycle` (not ad-hoc terminal tabs). - Use `lifecycle.up.before` for pre-start hooks and `lifecycle.processes` for long-running host tasks. +- Lifecycle hooks and processes receive the selected overlay's `global` values plus `host` overrides; service-scoped values remain container-specific unless a host command explicitly selects that scope. - For fixed-port host helpers such as SSM tunnels or local proxies, set `singleton.ports` and usually `onConflict: "adopt"` so Hack reuses a healthy existing listener instead of starting duplicate tunnel stacks. - `singleton` is a listener guard, not process ownership transfer; adopted external processes are left running on `hack down`. - 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`. +- 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. +- Dependency installer services are detected generically by command or `hack.dependencies.bootstrap=true`; registry env references are preflighted before container mutation. Optional `hack.dependencies.cache-volume`, `hack.dependencies.lockfiles`, and `hack.dependencies.runtime-files` labels enable lockfile/runtime-keyed volumes shared across compatible worktrees. Workspaces (mux-managed, tmux-first by default): - Picker: `hack session` for persistent project workspaces. @@ -94,28 +126,10 @@ Host-side env helpers: - Interactive host shell with injected env: `hack host shell --env qa --scope api` - Run inside an already-running service container: `hack exec api -- bun test` -Tickets (git-backed): -- Create: `hack tickets create --title "..." --body-stdin` -- List/show: `hack tickets list`, `hack tickets show T-AB12CD34EF` -- Status/sync: `hack tickets status T-AB12CD34EF in_progress`, `hack tickets sync` - -Global infra: -- Bootstrap once: `hack global install` -- Start/stop/status: `hack global up`, `hack global down`, `hack global status` -- Use `hack global up` before Loki/Grafana queries if global logging is offline. - -Unsupported experimental remote nodes + dispatch: -- These commands are source-available but outside the supported v3 product contract. -- Pair/register nodes: `hack node pair ...`, then verify with `hack node list` and `hack node status --watch`. -- Repair SSH for remote Git/mutagen: `hack node ssh setup --node `. -- On node host, inspect workspace map via `hack node workspace list|resolve|attach|remove`. -- Inspect/repair controller-side route bridge with `hack node routes status` and `hack node routes repair`. -- Dispatch remote commands: `hack dispatch run --project --node default --branch --runner generic -- ""`. - -When to use a branch instance: -- You need two versions running at once (PR review, experiments, migrations). -- You want to keep a stable environment while testing another branch. -- Use `--branch ` on `hack up/open/logs/down` to target it. +Branch instances (parallel envs): +- Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. +- Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). +- Linked worktrees pick a branch instance automatically (see Linked git worktrees). Run commands inside services: - One-off: `hack run ` (uses `docker compose run --rm`) @@ -128,6 +142,11 @@ Project targeting: - Else use `--project ` (registry) or `--path `. - List projects: `hack projects --json` +Global infra: +- Bootstrap once: `hack global install` +- Start/stop/status: `hack global up`, `hack global down`, `hack global status` +- Use `hack global up` before Loki/Grafana queries if global logging is offline. + Daemon (optional): - Start for faster JSON status/ps: `hack daemon start` - Check status: `hack daemon status` @@ -137,19 +156,21 @@ Docker compose notes: - Use `docker compose -f .hack/docker-compose.yml exec ` only if you need exec into a running container. Agent integration maintenance: -- Project-level hack commands auto-check integration drift and attempt auto-sync (docs/skills/MCP). +- Project-level hack commands auto-check integration drift and attempt auto-sync (project docs, client skills/rules, shared global skills, and MCP). +- When drift is detected, Hack reports it before repair and tells the agent to reload after repair; it never silently leaves the session using cached rules. - Set `HACK_SETUP_SYNC_MODE=warn` to only warn, or `HACK_SETUP_SYNC_MODE=off` to disable. - Refresh project + user integrations: `hack setup sync --all-scopes` - Audit integration state only: `hack setup sync --all-scopes --check` - Remove generated integration artifacts: `hack setup sync --all-scopes --remove` - After upgrading CLI: `hack update` then `hack setup sync --all-scopes` +- When changing hack itself: interface or behavior changes must update docs/ in the same change (regenerate the CLI reference with `bun run docs:cli-reference`). Agent setup (CLI-first): - Cursor rules: `hack setup cursor` - Claude hooks: `hack setup claude` - Codex skill: `hack setup codex` -- Tickets skill: `hack setup tickets` - Refresh all local agent integrations: `hack setup sync --all-scopes` +- Agent-assisted onboarding: `hack init --with claude|codex|both` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. - Init prompt: `hack agent init` (use --client cursor|claude|codex to open) - Init patterns: `hack agent patterns` - MCP (no-shell only): `hack setup mcp` diff --git a/src/agents/init-patterns.ts b/src/agents/init-patterns.ts index dd144b96..e9c4109d 100644 --- a/src/agents/init-patterns.ts +++ b/src/agents/init-patterns.ts @@ -70,7 +70,6 @@ export function renderAgentInitPatterns(): string { "- Use `hack run ` for one-off tasks.", "- Verify with `hack ps`, `hack open --json`, `hack logs --pretty`.", "- For long-lived agent work, run in managed mux sessions (`hack session start --new --name agent-1`).", - "- Track follow-up work in git-backed tickets (`hack tickets ...`).", "", "Patterns to consider:", "- Deps service: run `bun install` inside a container and share node_modules via a volume.", diff --git a/src/agents/instruction-source.ts b/src/agents/instruction-source.ts index 7b927ff3..bf126aa0 100644 --- a/src/agents/instruction-source.ts +++ b/src/agents/instruction-source.ts @@ -8,6 +8,9 @@ * and bullets live here so content can never drift between surfaces. */ +import pkg from "../../package.json"; +import { HACK_AGENT_INTEGRATION_CONTENT_REVISION } from "./integration-revision.ts"; + export type InstructionSurface = "docs" | "skill" | "rules" | "primer"; export type InstructionHeadingStyle = "markdown" | "plain"; @@ -20,6 +23,9 @@ export type InstructionSection = { }; const ALL_SURFACES = ["docs", "skill", "rules", "primer"] as const; +export const HACK_AGENT_INTEGRATION_CLI_VERSION = ( + pkg as { readonly version: string } +).version; /** * Canonical instruction sections in render order. @@ -31,6 +37,17 @@ const ALL_SURFACES = ["docs", "skill", "rules", "primer"] as const; * - `primer`: `hack agent prime` session primer (concise subset). */ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ + { + id: "freshness", + title: "Integration freshness", + surfaces: ALL_SURFACES, + bullets: [ + `These instructions were generated by hack CLI v${HACK_AGENT_INTEGRATION_CLI_VERSION}; 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.", + ], + }, { id: "product-boundary", title: "Product boundary", @@ -113,7 +130,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ "Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose..override.yml`.", "Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands).", "Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`.", - "Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).", + "Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).", ], }, { @@ -123,6 +140,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ bullets: [ "Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments.", "`hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance.", + "Before `hack up` or `hack restart` auto-targets a new branch instance, Hack warns when the same worktree already owns a non-terminal instance; pass `--branch ` to make the target explicit.", "`hack doctor` flags divergent secret keys and dev_host collisions across checkouts.", ], }, @@ -182,11 +200,17 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ bullets: [ "Put host setup in `.hack/hack.config.json` under `startup`/`lifecycle` (not ad-hoc terminal tabs).", "Use `lifecycle.up.before` for pre-start hooks and `lifecycle.processes` for long-running host tasks.", + "Lifecycle hooks and processes receive the selected overlay's `global` values plus `host` overrides; service-scoped values remain container-specific unless a host command explicitly selects that scope.", 'For fixed-port host helpers such as SSM tunnels or local proxies, set `singleton.ports` and usually `onConflict: "adopt"` so Hack reuses a healthy existing listener instead of starting duplicate tunnel stacks.', "`singleton` is a listener guard, not process ownership transfer; adopted external processes are left running on `hack down`.", "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`.", + "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.", + "Dependency installer services are detected generically by command or `hack.dependencies.bootstrap=true`; registry env references are preflighted before container mutation. Optional `hack.dependencies.cache-volume`, `hack.dependencies.lockfiles`, and `hack.dependencies.runtime-files` labels enable lockfile/runtime-keyed volumes shared across compatible worktrees.", ], }, { @@ -279,7 +303,8 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ title: "Agent integration maintenance", surfaces: ALL_SURFACES, bullets: [ - "Project-level hack commands auto-check integration drift and attempt auto-sync (docs/skills/MCP).", + "Project-level hack commands auto-check integration drift and attempt auto-sync (project docs, client skills/rules, shared global skills, and MCP).", + "When drift is detected, Hack reports it before repair and tells the agent to reload after repair; it never silently leaves the session using cached rules.", "Set `HACK_SETUP_SYNC_MODE=warn` to only warn, or `HACK_SETUP_SYNC_MODE=off` to disable.", "Refresh project + user integrations: `hack setup sync --all-scopes`", "Audit integration state only: `hack setup sync --all-scopes --check`", @@ -304,14 +329,6 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ "MCP install (explicit): `hack mcp install --all --scope project`", ], }, - { - id: "extensions", - title: "Optional extensions", - surfaces: ALL_SURFACES, - bullets: [ - "A local git-backed tickets extension exists (`hack tickets`) — only use it when the project explicitly uses it.", - ], - }, ]; /** @@ -364,7 +381,15 @@ function renderSection(opts: { readonly section: InstructionSection; readonly headingStyle: InstructionHeadingStyle; }): string { - const bullets = opts.section.bullets.map((bullet) => `- ${bullet}`); + const contentRevision = + opts.section.id === "freshness" + ? [ + `Content revision: \`${HACK_AGENT_INTEGRATION_CONTENT_REVISION}\` (version alone is not a freshness guarantee).`, + ] + : []; + const bullets = [...opts.section.bullets, ...contentRevision].map( + (bullet) => `- ${bullet}` + ); if (opts.headingStyle === "markdown") { return [`## ${opts.section.title}`, "", ...bullets].join("\n"); } diff --git a/src/agents/integration-revision.ts b/src/agents/integration-revision.ts new file mode 100644 index 00000000..a5b6d364 --- /dev/null +++ b/src/agents/integration-revision.ts @@ -0,0 +1,6 @@ +/** + * Content fingerprint for generated Hack agent guidance. The instruction + * source test recomputes this value and fails whenever guidance changes + * without a revision update. + */ +export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "b8663fad3ef4"; diff --git a/src/agents/shared-skill.ts b/src/agents/shared-skill.ts new file mode 100644 index 00000000..52c6d870 --- /dev/null +++ b/src/agents/shared-skill.ts @@ -0,0 +1,179 @@ +import { rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +import { + ensureDir, + pathExists, + readTextFile, + writeTextFileIfChanged, +} from "../lib/fs.ts"; +import { renderCodexSkill } from "./codex-skill.ts"; +import { normalizeInstructionText } from "./instruction-source.ts"; + +export type SharedSkillResult = { + readonly status: + | "created" + | "updated" + | "noop" + | "absent" + | "stale" + | "deprecated" + | "removed" + | "missing" + | "error"; + readonly path: string; + readonly message?: string; +}; + +const SHARED_SKILLS_DIR = ".ai/skills"; +const HACK_CLI_SKILL_NAME = "hack-cli"; +const SKILL_FILENAME = "SKILL.md"; +const HACK_CLI_MARKER = /name:\s*hack-cli\b/i; + +const LEGACY_SHARED_SKILLS = [ + { + name: "hack", + markers: [ + /name:\s*hack\b/i, + /homepage:\s*https:\/\/github\.com\/hack-dance\/hack-cli/i, + ], + }, + { + name: "hack-tickets", + markers: [/name:\s*hack-tickets\b/i], + }, +] as const; + +/** Install the canonical Hack skill in the shared agent skill root. */ +export async function installSharedHackSkill(): Promise { + const resolved = resolveSharedSkillPath({ skillName: HACK_CLI_SKILL_NAME }); + if (!resolved.ok) { + return { status: "error", path: SKILL_FILENAME, message: resolved.message }; + } + + await ensureDir(dirname(resolved.path)); + const existed = await pathExists(resolved.path); + const result = await writeTextFileIfChanged( + resolved.path, + renderCodexSkill() + ); + let status: SharedSkillResult["status"] = "noop"; + if (result.changed) { + status = existed ? "updated" : "created"; + } + return { status, path: resolved.path }; +} + +/** Check the shared Hack skill against the same canonical render used by Codex. */ +export async function checkSharedHackSkill(): Promise { + const resolved = resolveSharedSkillPath({ skillName: HACK_CLI_SKILL_NAME }); + if (!resolved.ok) { + return { status: "error", path: SKILL_FILENAME, message: resolved.message }; + } + + const content = await readTextFile(resolved.path); + if (!content) { + return { status: "missing", path: resolved.path }; + } + if (!HACK_CLI_MARKER.test(content)) { + return { + status: "error", + path: resolved.path, + message: "Shared Hack skill is missing the hack-cli marker.", + }; + } + if ( + normalizeInstructionText({ text: content }) !== + normalizeInstructionText({ text: renderCodexSkill() }) + ) { + return { + status: "stale", + path: resolved.path, + message: `Shared Hack skill is stale at ${resolved.path}. Run: hack setup sync --all-scopes`, + }; + } + return { status: "noop", path: resolved.path }; +} + +export async function removeSharedHackSkill(): Promise { + const resolved = resolveSharedSkillPath({ skillName: HACK_CLI_SKILL_NAME }); + if (!resolved.ok) { + return { status: "error", path: SKILL_FILENAME, message: resolved.message }; + } + if (!(await pathExists(resolved.path))) { + return { status: "missing", path: resolved.path }; + } + await rm(dirname(resolved.path), { recursive: true, force: true }); + return { status: "removed", path: resolved.path }; +} + +/** Report known superseded shared skills without treating arbitrary user skills as owned. */ +export async function checkDeprecatedSharedHackSkills(): Promise< + SharedSkillResult[] +> { + const results: SharedSkillResult[] = []; + for (const legacy of LEGACY_SHARED_SKILLS) { + const resolved = resolveSharedSkillPath({ skillName: legacy.name }); + if (!resolved.ok) { + results.push({ + status: "error", + path: SKILL_FILENAME, + message: resolved.message, + }); + continue; + } + const content = await readTextFile(resolved.path); + if (!content) { + results.push({ status: "absent", path: resolved.path }); + continue; + } + const owned = legacy.markers.every((marker) => marker.test(content)); + results.push({ + status: owned ? "deprecated" : "error", + path: resolved.path, + message: owned + ? `Deprecated Hack skill is still installed at ${resolved.path}. Run: hack setup sync --all-scopes` + : `Refusing to remove unrecognized skill at ${resolved.path}`, + }); + } + return results; +} + +/** Remove only legacy skills whose known ownership markers still match. */ +export async function removeDeprecatedSharedHackSkills(): Promise< + SharedSkillResult[] +> { + const checked = await checkDeprecatedSharedHackSkills(); + const results: SharedSkillResult[] = []; + for (const result of checked) { + if ( + result.status === "noop" || + result.status === "absent" || + result.status === "error" + ) { + results.push(result); + continue; + } + await rm(dirname(result.path), { recursive: true, force: true }); + results.push({ status: "removed", path: result.path }); + } + return results; +} + +function resolveSharedSkillPath(opts: { + readonly skillName: string; +}): + | { readonly ok: true; readonly path: string } + | { readonly ok: false; readonly message: string } { + const home = (process.env.HOME ?? "").trim(); + if (!home) { + return { + ok: false, + message: "HOME is not set; cannot resolve shared skills.", + }; + } + return { + ok: true, + path: resolve(home, SHARED_SKILLS_DIR, opts.skillName, SKILL_FILENAME), + }; +} diff --git a/src/backends/runtime-backend.ts b/src/backends/runtime-backend.ts index 7d0075d5..c58fcb08 100644 --- a/src/backends/runtime-backend.ts +++ b/src/backends/runtime-backend.ts @@ -3,6 +3,8 @@ import { readLinesFromStream } from "../ui/lines.ts"; import { createStructuredLogGrouper } from "../ui/log-group.ts"; export type RuntimeBackendName = "compose"; +const DEFAULT_COMPOSE_STARTUP_TIMEOUT_MS = 90_000; +const DEFAULT_COMPOSE_INSPECTION_TIMEOUT_MS = 15_000; export interface RuntimeBackend { readonly name: RuntimeBackendName; @@ -29,11 +31,16 @@ export interface RuntimeBaseOptions { export interface RuntimeUpOptions extends RuntimeBaseOptions { readonly detach: boolean; + readonly services?: readonly string[]; + readonly noDeps?: boolean; + readonly forceRecreate?: boolean; } export interface RuntimeDownOptions extends RuntimeBaseOptions {} -export interface RuntimePsOptions extends RuntimeBaseOptions {} +export interface RuntimePsOptions extends RuntimeBaseOptions { + readonly all?: boolean; +} export interface RuntimeRunOptions extends RuntimeBaseOptions { readonly service: string; @@ -71,12 +78,16 @@ export const composeRuntimeBackend: RuntimeBackend = { ...buildComposeArgs(opts), "up", ...(opts.detach ? ["-d"] : []), + ...(opts.noDeps ? ["--no-deps"] : []), + ...(opts.forceRecreate ? ["--force-recreate"] : []), + ...(opts.services ?? []), ]; if (opts.detach) { return await run(cmd, { cwd: opts.cwd, env: opts.env, stdout: opts.routeStdoutToStderr ? "stderr" : "inherit", + timeoutMs: DEFAULT_COMPOSE_STARTUP_TIMEOUT_MS, }); } @@ -123,11 +134,23 @@ export const composeRuntimeBackend: RuntimeBackend = { cwd: opts.cwd, env: opts.env, stdout: opts.routeStdoutToStderr ? "stderr" : "inherit", + timeoutMs: DEFAULT_COMPOSE_STARTUP_TIMEOUT_MS, }); }, async psJson(opts) { - const cmd = [...buildComposeArgs(opts), "ps", "--format", "json"]; - return await exec(cmd, { cwd: opts.cwd, stdin: "ignore", env: opts.env }); + const cmd = [ + ...buildComposeArgs(opts), + "ps", + ...(opts.all ? ["--all"] : []), + "--format", + "json", + ]; + return await exec(cmd, { + cwd: opts.cwd, + stdin: "ignore", + env: opts.env, + timeoutMs: DEFAULT_COMPOSE_INSPECTION_TIMEOUT_MS, + }); }, async ps(opts) { const cmd = [...buildComposeArgs(opts), "ps"]; diff --git a/src/cli/integration-sync.ts b/src/cli/integration-sync.ts index 9e7acd3e..7caa9a06 100644 --- a/src/cli/integration-sync.ts +++ b/src/cli/integration-sync.ts @@ -1,16 +1,24 @@ import { checkClaudeHooks, installClaudeHooks } from "../agents/claude.ts"; import { checkCodexSkill, installCodexSkill } from "../agents/codex-skill.ts"; import { checkCursorRules, installCursorRules } from "../agents/cursor.ts"; -import { resolveTicketsIntegrationEnablement } from "../control-plane/extensions/tickets/enablement.ts"; +import { HACK_AGENT_INTEGRATION_CLI_VERSION } from "../agents/instruction-source.ts"; import { - checkTicketsSkill, - installTicketsSkill, - type TicketsSkillResult, + checkDeprecatedSharedHackSkills, + checkSharedHackSkill, + installSharedHackSkill, + removeDeprecatedSharedHackSkills, +} from "../agents/shared-skill.ts"; +import { + checkDeprecatedTicketsAgentDocs, + removeTicketsAgentDocs, +} from "../control-plane/extensions/tickets/agent-docs.ts"; +import { + checkDeprecatedTicketsSkill, + removeTicketsSkill, } from "../control-plane/extensions/tickets/tickets-skill.ts"; import { findProjectContext } from "../lib/project.ts"; import { type AgentDocCheckResult, - type AgentDocUpdateResult, checkAgentDocs, upsertAgentDocs, } from "../mcp/agent-docs.ts"; @@ -24,8 +32,16 @@ import { logger } from "../ui/logger.ts"; type IntegrationSyncMode = "auto" | "warn" | "off"; +export type AgentIntegrationFreshnessReport = { + readonly status: "current" | "stale"; + readonly cliVersion: string; + readonly fixCommand: string; + readonly verifyCommand: string; +}; + const SYNC_COMMAND = "hack setup sync --all-scopes"; const INTEGRATION_SYNC_MODE_ENV = "HACK_SETUP_SYNC_MODE"; +const VERIFY_COMMAND = "hack setup sync --all-scopes --check"; const SKIP_TOP_LEVEL = new Set([ "setup", "mcp", @@ -67,10 +83,18 @@ export async function maybeEnsureAgentIntegrations(opts: { } if (mode === "auto") { + logger.warn({ + message: + "Detected stale Hack agent integrations. Refreshing project and global rules before this command continues.", + }); const autoSync = await autoSyncIntegrations({ projectRoot: project.projectRoot, }); if (autoSync.ok) { + logger.warn({ + message: + "Refreshed Hack agent integrations. Reload the agent session before relying on cached Hack rules; verify with: hack setup sync --all-scopes --check", + }); return; } logger.warn({ @@ -80,14 +104,44 @@ export async function maybeEnsureAgentIntegrations(opts: { } logger.warn({ - message: `Agent integrations are out of sync (docs/skills/MCP). Run: ${SYNC_COMMAND}`, + message: `Hack agent integrations are stale (project/global docs, skills, or MCP). Do not rely on cached rules. Run: ${SYNC_COMMAND}, then reload the agent session.`, }); } +/** Inspect project and global generated guidance without mutating it. */ +export async function inspectAgentIntegrationFreshness(opts: { + readonly projectRoot: string; +}): Promise { + const drift = await detectIntegrationDrift(opts); + return { + status: drift.hasDrift ? "stale" : "current", + cliVersion: HACK_AGENT_INTEGRATION_CLI_VERSION, + fixCommand: SYNC_COMMAND, + verifyCommand: VERIFY_COMMAND, + }; +} + +/** Render an upfront status block suitable for SessionStart hooks and agents. */ +export function renderAgentIntegrationFreshnessNotice(opts: { + readonly report: AgentIntegrationFreshnessReport; +}): string { + if (opts.report.status === "current") { + return `Hack agent integration freshness: current (CLI v${opts.report.cliVersion}).`; + } + return [ + `WARNING: Hack agent integrations are stale for CLI v${opts.report.cliVersion}.`, + "Do not rely on cached Hack rules or skills until they are refreshed.", + `Fix project + global integrations: ${opts.report.fixCommand}`, + `Verify: ${opts.report.verifyCommand}`, + "Then reload the agent session so it reads the updated rules.", + ].join("\n"); +} + function shouldRunIntegrationGuard(opts: { readonly commandPath: readonly string[]; }): boolean { - if (!(process.stdout.isTTY || process.stderr.isTTY)) { + const explicitMode = (process.env[INTEGRATION_SYNC_MODE_ENV] ?? "").trim(); + if (!(process.stdout.isTTY || process.stderr.isTTY || explicitMode)) { return false; } @@ -114,10 +168,6 @@ function resolveIntegrationSyncMode(): IntegrationSyncMode { async function detectIntegrationDrift(opts: { readonly projectRoot: string; }): Promise<{ readonly hasDrift: boolean }> { - const ticketsEnablement = await resolveTicketsIntegrationEnablement({ - projectRoot: opts.projectRoot, - }); - const [ cursorProject, cursorUser, @@ -127,6 +177,9 @@ async function detectIntegrationDrift(opts: { codexUser, ticketsProject, ticketsUser, + ticketsDocs, + sharedSkill, + deprecatedSharedSkills, mcpProject, mcpUser, docs, @@ -137,12 +190,17 @@ async function detectIntegrationDrift(opts: { checkClaudeHooks({ scope: "user" }), checkCodexSkill({ scope: "project", projectRoot: opts.projectRoot }), checkCodexSkill({ scope: "user" }), - ticketsEnablement.project - ? checkTicketsSkill({ scope: "project", projectRoot: opts.projectRoot }) - : skippedTicketsResult({ scope: "project" }), - ticketsEnablement.global - ? checkTicketsSkill({ scope: "user" }) - : skippedTicketsResult({ scope: "user" }), + checkDeprecatedTicketsSkill({ + scope: "project", + projectRoot: opts.projectRoot, + }), + checkDeprecatedTicketsSkill({ scope: "user" }), + checkDeprecatedTicketsAgentDocs({ + projectRoot: opts.projectRoot, + targets: ["agents", "claude"], + }), + checkSharedHackSkill(), + checkDeprecatedSharedHackSkills(), checkMcpConfig({ scope: "project", projectRoot: opts.projectRoot, @@ -167,6 +225,7 @@ async function detectIntegrationDrift(opts: { codexUser.status, ticketsProject.status, ticketsUser.status, + sharedSkill.status, ] as const; const singleDrift = singleChecks.some((status) => @@ -174,27 +233,25 @@ async function detectIntegrationDrift(opts: { ); const mcpDrift = hasMcpDrift({ checks: [...mcpProject, ...mcpUser] }); const docsDrift = hasDocDrift({ checks: docs }); + const deprecatedDocsDrift = ticketsDocs.some( + (check) => check.status !== "noop" && check.status !== "absent" + ); + const deprecatedSharedDrift = deprecatedSharedSkills.some( + (check) => check.status !== "noop" && check.status !== "absent" + ); - return { hasDrift: singleDrift || mcpDrift || docsDrift }; + return { + hasDrift: + singleDrift || + mcpDrift || + docsDrift || + deprecatedDocsDrift || + deprecatedSharedDrift, + }; } function hasSingleCheckDrift(status: string): boolean { - return status !== "noop"; -} - -/** - * Placeholder result for tickets integrations when the tickets extension is - * disabled for the checked scope. Tickets is optional/legacy: its skill must - * never count as drift (or get auto-installed) unless explicitly enabled. - */ -function skippedTicketsResult(opts: { - readonly scope: "project" | "user"; -}): TicketsSkillResult { - return { - scope: opts.scope, - status: "noop", - path: "(tickets extension disabled)", - }; + return status !== "noop" && status !== "absent"; } function hasMcpDrift(opts: { @@ -212,10 +269,6 @@ function hasDocDrift(opts: { async function autoSyncIntegrations(opts: { readonly projectRoot: string; }): Promise<{ readonly ok: boolean }> { - const ticketsEnablement = await resolveTicketsIntegrationEnablement({ - projectRoot: opts.projectRoot, - }); - const [ cursorProject, cursorUser, @@ -225,6 +278,9 @@ async function autoSyncIntegrations(opts: { codexUser, ticketsProject, ticketsUser, + ticketsDocs, + sharedSkill, + deprecatedSharedSkills, mcpProject, mcpUser, docs, @@ -235,12 +291,14 @@ async function autoSyncIntegrations(opts: { installClaudeHooks({ scope: "user" }), installCodexSkill({ scope: "project", projectRoot: opts.projectRoot }), installCodexSkill({ scope: "user" }), - ticketsEnablement.project - ? installTicketsSkill({ scope: "project", projectRoot: opts.projectRoot }) - : skippedTicketsResult({ scope: "project" }), - ticketsEnablement.global - ? installTicketsSkill({ scope: "user" }) - : skippedTicketsResult({ scope: "user" }), + removeTicketsSkill({ scope: "project", projectRoot: opts.projectRoot }), + removeTicketsSkill({ scope: "user" }), + removeTicketsAgentDocs({ + projectRoot: opts.projectRoot, + targets: ["agents", "claude"], + }), + installSharedHackSkill(), + removeDeprecatedSharedHackSkills(), installMcpConfig({ scope: "project", projectRoot: opts.projectRoot, @@ -265,6 +323,7 @@ async function autoSyncIntegrations(opts: { codexUser.status, ticketsProject.status, ticketsUser.status, + sharedSkill.status, ] as const; const singleErrors = singleStatuses.some((status) => hasSingleInstallError(status) @@ -273,8 +332,20 @@ async function autoSyncIntegrations(opts: { results: [...mcpProject, ...mcpUser], }); const docsErrors = hasDocInstallErrors({ results: docs }); + const ticketsDocsErrors = hasDocInstallErrors({ results: ticketsDocs }); + const deprecatedSharedErrors = deprecatedSharedSkills.some( + (result) => result.status === "error" + ); - return { ok: !(singleErrors || mcpErrors || docsErrors) }; + return { + ok: !( + singleErrors || + mcpErrors || + docsErrors || + ticketsDocsErrors || + deprecatedSharedErrors + ), + }; } function hasSingleInstallError(status: string): boolean { @@ -288,7 +359,7 @@ function hasMcpInstallErrors(opts: { } function hasDocInstallErrors(opts: { - readonly results: readonly AgentDocUpdateResult[]; + readonly results: readonly { readonly status: string }[]; }): boolean { return opts.results.some((result) => result.status === "error"); } diff --git a/src/cli/spec.ts b/src/cli/spec.ts index 86bec15f..40adddb7 100644 --- a/src/cli/spec.ts +++ b/src/cli/spec.ts @@ -59,7 +59,7 @@ export const CLI_SPEC = defineCli({ highlights: [ "Run multiple repos or branches at the same time without port conflicts.", "Give every project a stable HTTPS URL like https://myapp.hack.", - "Keep env, sessions, diagnostics, and optional tickets close to the repo.", + "Keep env, sessions, and diagnostics close to the repo.", ] as const, globalOptions: [optNoInteractive], commands: [ diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 8f322a4a..73d180ec 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -16,6 +16,10 @@ import { defineOption, withHandler, } from "../cli/command.ts"; +import { + inspectAgentIntegrationFreshness, + renderAgentIntegrationFreshnessNotice, +} from "../cli/integration-sync.ts"; import { optPath } from "../cli/options.ts"; import { openUrl } from "../lib/os.ts"; import { @@ -97,14 +101,32 @@ export const agentCommand = defineCommand({ ], } as const); -function handleAgentPrime({ +async function handleAgentPrime({ + ctx, args: _args, }: { readonly ctx: CliContext; readonly args: PrimeArgs; }): Promise { - process.stdout.write(renderAgentPrimer()); - return Promise.resolve(0); + const project = await findProjectContext(ctx.cwd); + if (!project) { + process.stdout.write( + [ + "Hack agent integration freshness: not checked outside a Hack project.", + "From a project, run: hack setup sync --all-scopes --check", + "", + renderAgentPrimer(), + ].join("\n") + ); + return 0; + } + const report = await inspectAgentIntegrationFreshness({ + projectRoot: project.projectRoot, + }); + process.stdout.write( + `${renderAgentIntegrationFreshnessNotice({ report })}\n\n${renderAgentPrimer()}` + ); + return 0; } function handleAgentPatterns({ diff --git a/src/commands/config.ts b/src/commands/config.ts index 2800c6bb..b6b35c28 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -84,6 +84,7 @@ const handleConfigGet: CommandHandlerFor = async ({ pathOpt: args.options.path, projectOpt: args.options.project, globalOpt: args.options.global === true, + touchRegistration: false, }); const key = (args.positionals.key ?? "").trim(); @@ -128,6 +129,7 @@ const handleConfigSet: CommandHandlerFor = async ({ pathOpt: args.options.path, projectOpt: args.options.project, globalOpt: args.options.global === true, + touchRegistration: false, }); const key = (args.positionals.key ?? "").trim(); @@ -204,6 +206,7 @@ async function resolveProjectForArgs(opts: { readonly pathOpt: string | undefined; readonly projectOpt: string | undefined; readonly globalOpt: boolean; + readonly touchRegistration: boolean; }): Promise { if (opts.globalOpt) { if (opts.pathOpt || opts.projectOpt) { @@ -227,7 +230,9 @@ async function resolveProjectForArgs(opts: { `Unknown project "${name}". Run 'hack init' in that repo (or run 'hack projects' to see registered projects).` ); } - await touchProjectRegistration(fromRegistry); + if (opts.touchRegistration) { + await touchProjectRegistration(fromRegistry); + } return { scope: "project", project: fromRegistry }; } @@ -235,7 +240,9 @@ async function resolveProjectForArgs(opts: { ? resolve(opts.ctx.cwd, opts.pathOpt) : opts.ctx.cwd; const project = await requireProjectContext(startDir); - await touchProjectRegistration(project); + if (opts.touchRegistration) { + await touchProjectRegistration(project); + } return { scope: "project", project }; } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index f3500fd3..fd4dfead 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,9 +1,16 @@ import { lookup } from "node:dns/promises"; +import { homedir } from "node:os"; import { dirname, resolve } from "node:path"; import { note, spinner } from "@clack/prompts"; import { YAML } from "bun"; +import { HACK_AGENT_INTEGRATION_CONTENT_REVISION } from "../agents/integration-revision.ts"; import type { CommandHandlerFor } from "../cli/command.ts"; -import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; +import { + CliUsageError, + defineCommand, + defineOption, + withHandler, +} from "../cli/command.ts"; import { optJson, optPath } from "../cli/options.ts"; import { DEFAULT_CADDY_IP, @@ -26,9 +33,6 @@ import { } from "../constants.ts"; import { resolveGatewayConfig } from "../control-plane/extensions/gateway/config.ts"; import { listGatewayTokens } from "../control-plane/extensions/gateway/tokens.ts"; -import { resolveTicketsIntegrationEnablement } from "../control-plane/extensions/tickets/enablement.ts"; -import { createGitTicketsChannel } from "../control-plane/extensions/tickets/tickets-git-channel.ts"; -import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; import { probeDaemonApi } from "../daemon/client.ts"; import { resolveDaemonPaths } from "../daemon/paths.ts"; import { buildDaemonStatusReport, readDaemonStatus } from "../daemon/status.ts"; @@ -95,6 +99,7 @@ import { repairProjectLifecycleSessions, } from "../lib/project-lifecycle-hygiene.ts"; import { + findIncompleteRuntimeProjects, findMissingRegistryEntries, findOrphanRuntimeProjects, scopeRuntimeHygieneToProject, @@ -134,15 +139,6 @@ interface TimedCheckResult extends CheckResult { readonly durationMs: number; } -const noopLogger = { - info: (_input: { readonly message: string }) => { - // Intentionally quiet during read-only health checks. - }, - warn: (_input: { readonly message: string }) => { - // Intentionally quiet during read-only health checks. - }, -} as const; - const optFix = defineOption({ name: "fix", type: "boolean", @@ -192,7 +188,7 @@ const DOCTOR_SUMMARY_GROUPS = [ ]), }, { - title: "Runtime", + title: "Global runtime & agents", checks: new Set([ "docker daemon", `network:${DEFAULT_INGRESS_NETWORK}`, @@ -206,6 +202,7 @@ const DOCTOR_SUMMARY_GROUPS = [ "proxy ports", "caddy local ca", "host tls trust", + "agent integrations", ]), }, { @@ -239,8 +236,8 @@ const DOCTOR_SUMMARY_GROUPS = [ ]), }, { - title: "Sessions & tickets", - checks: new Set(["sessions mux", "tickets git"]), + title: "Sessions", + checks: new Set(["sessions mux"]), }, ] as const; @@ -248,6 +245,11 @@ const handleDoctor: CommandHandlerFor = async ({ args, }): Promise => { const json = args.options.json === true; + assertDoctorOptionCompatibility({ + json, + fix: args.options.fix === true, + migrateEnvConfig: args.options.migrateEnvConfig === true, + }); const results: TimedCheckResult[] = []; const s = createDoctorProgress({ json }); s.start("Running doctor checks..."); @@ -473,6 +475,22 @@ const handleDoctor: CommandHandlerFor = async ({ checkProject({ startDir }) ); results.push(projectCtx); + const agentIntegrationCheck = await runCheck( + s, + "agent integrations", + () => checkAgentIntegrations({ startDir }), + { timeoutMs: 5000 } + ); + results.push( + agentIntegrationCheck.status === "error" + ? { + ...agentIntegrationCheck, + status: "warn", + message: + "Freshness audit unavailable (verify: hack setup sync --all-scopes --check)", + } + : agentIntegrationCheck + ); results.push( await runCheck(s, "sessions mux", () => checkSessionsMuxConfig({ startDir }) @@ -563,11 +581,6 @@ const handleDoctor: CommandHandlerFor = async ({ } ) ); - results.push( - await runCheck(s, "tickets git", () => - checkProjectTicketsGitHealth({ startDir }) - ) - ); } else { results.push({ name: "DEV_HOST", @@ -581,12 +594,6 @@ const handleDoctor: CommandHandlerFor = async ({ message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`, durationMs: 0, }); - results.push({ - name: "tickets git", - status: "warn", - message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`, - durationMs: 0, - }); } s.stop(`Doctor checks complete (${results.length} checks)`); @@ -597,14 +604,12 @@ const handleDoctor: CommandHandlerFor = async ({ return finishDoctorJson({ results, hasError, - fixRequested: - args.options.fix === true || args.options.migrateEnvConfig === true, }); } await renderDoctorSummary(results); emitSlowChecksNote(results); - renderMacNote(); + renderMacNote(results); if (!args.options.fix) { await renderRecoveryGuidance(results); } @@ -671,19 +676,25 @@ export function buildDoctorJsonData(opts: { function finishDoctorJson(opts: { readonly results: readonly TimedCheckResult[]; readonly hasError: boolean; - readonly fixRequested: boolean; }): number { - if (opts.fixRequested) { - process.stderr.write( - "--fix/--migrate-env-config are ignored with --json; run them separately.\n" - ); - } emitCliResult({ result: okResult({ data: buildDoctorJsonData({ results: opts.results }) }), }); return opts.hasError ? 1 : 0; } +export function assertDoctorOptionCompatibility(opts: { + readonly json: boolean; + readonly fix: boolean; + readonly migrateEnvConfig: boolean; +}): void { + if (opts.json && (opts.fix || opts.migrateEnvConfig)) { + throw new CliUsageError( + "--json cannot be combined with --fix or --migrate-env-config because repair commands mutate state. Run the repair and JSON audit separately." + ); + } +} + type DoctorProgress = { start(message?: string): void; stop(message?: string): void; @@ -1015,6 +1026,45 @@ async function checkDaemonStatus(): Promise { }; } +async function checkAgentIntegrations(opts: { + readonly startDir: string; +}): Promise { + try { + return await checkAgentIntegrationsUnsafe(opts); + } catch { + return { + name: "agent integrations", + status: "warn", + message: + "Freshness audit unavailable (verify: hack setup sync --all-scopes --check)", + }; + } +} + +async function checkAgentIntegrationsUnsafe(opts: { + readonly startDir: string; +}): Promise { + const project = await findProjectContext(opts.startDir); + const report = await inspectDoctorAgentIntegrations({ + projectRoot: project?.projectRoot ?? null, + }); + return report.status === "current" + ? { + name: "agent integrations", + status: "ok", + message: project + ? "Project and global guidance current" + : "Global guidance current", + } + : { + name: "agent integrations", + status: "warn", + message: project + ? "Project or global guidance is stale (run: hack setup sync --all-scopes, reload the agent session, verify: hack setup sync --all-scopes --check)" + : "Global guidance is stale (run: hack setup sync --global, reload the agent session, verify: hack setup sync --global --check)", + }; +} + async function checkGatewayConfig(): Promise { const resolved = await resolveGatewayConfig(); const configPath = resolveGlobalConfigPath(); @@ -1685,87 +1735,6 @@ async function checkDevHost({ }; } -async function checkProjectTicketsGitHealth({ - startDir, -}: { - readonly startDir: string; -}): Promise { - const project = await findProjectContext(startDir); - if (!project) { - return { - name: "tickets git", - status: "warn", - message: `Missing ${HACK_PROJECT_DIR_PRIMARY}/ (run 'hack init' in a repo)`, - }; - } - - const ticketsEnablement = await resolveTicketsIntegrationEnablement({ - projectRoot: project.projectRoot, - }); - if (!ticketsEnablement.project) { - return { - name: "tickets git", - status: "ok", - message: "Disabled", - }; - } - - const controlPlane = await readControlPlaneConfig({ - projectDir: project.projectDir, - }); - const gitConfig = controlPlane.config.tickets.git; - if (!gitConfig.enabled) { - return { - name: "tickets git", - status: "ok", - message: "Disabled", - }; - } - - const channel = createGitTicketsChannel({ - projectRoot: project.projectRoot, - config: gitConfig, - logger: noopLogger, - }); - const inspected = await channel.inspect(); - if (!inspected.ok) { - return { - name: "tickets git", - status: "warn", - message: inspected.error, - }; - } - - const health = inspected.health; - const problems: string[] = []; - if (health.hasRefDivergence) { - const remoteOid = health.remoteRefOid?.slice(0, 8) ?? "missing"; - const legacyOid = health.legacyRefOid?.slice(0, 8) ?? "missing"; - problems.push( - `hidden ref diverges from legacy branch (${remoteOid} vs ${legacyOid})` - ); - } else if (health.hasLegacyRef && health.legacyRef) { - problems.push(`legacy ref ${health.legacyRef} still exists`); - } - if (health.hasNonTicketFiles) { - problems.push("non-ticket files present"); - } - - if (problems.length === 0) { - return { - name: "tickets git", - status: "ok", - message: `Healthy (${health.remoteRef})`, - }; - } - - return { - name: "tickets git", - status: "warn", - message: `${problems.join("; ")} (run: hack doctor --fix)`, - }; -} - async function checkProjectRuntimeHygiene({ startDir, }: { @@ -1798,13 +1767,19 @@ async function checkProjectRuntimeHygiene({ projects: registry.projects, runtime: runtimeResult.runtime, }); + const baseProjectName = await resolveDoctorBaseProjectName(ctx); const [missing, orphaned] = await Promise.all([ findMissingRegistryEntries({ projects: scoped.projects }), findOrphanRuntimeProjects({ runtime: scoped.runtime }), ]); + const incomplete = findIncompleteRuntimeProjects({ runtime: scoped.runtime }); - if (missing.length === 0 && orphaned.length === 0) { + if ( + missing.length === 0 && + orphaned.length === 0 && + incomplete.length === 0 + ) { return { name: "runtime hygiene", status: "ok", @@ -1814,20 +1789,37 @@ async function checkProjectRuntimeHygiene({ const details: string[] = []; if (missing.length > 0) { + const names = missing.map((entry) => entry.project.name).join(", "); details.push( - `${missing.length} missing registry entr${missing.length === 1 ? "y" : "ies"}` + `${missing.length} missing registry entr${missing.length === 1 ? "y" : "ies"}: ${names}` ); } if (orphaned.length > 0) { + const names = orphaned.map((entry) => entry.project).join(", "); details.push( - `${orphaned.length} orphaned runtime project${orphaned.length === 1 ? "" : "s"}` + `${orphaned.length} orphaned runtime project${orphaned.length === 1 ? "" : "s"}: ${names}` ); } + if (incomplete.length > 0) { + const names = incomplete + .map((entry) => `${entry.project} [${entry.createdServices.join(", ")}]`) + .join("; "); + details.push( + `${incomplete.length} incomplete runtime project${incomplete.length === 1 ? "" : "s"} with containers stuck in Created: ${names}` + ); + } + + const guidance = [ + ...(missing.length > 0 || orphaned.length > 0 + ? [`run: hack projects prune --project ${baseProjectName}`] + : []), + ...(incomplete.length > 0 ? ["repair: hack doctor --fix"] : []), + ]; return { name: "runtime hygiene", status: "warn", - message: `${details.join("; ")} (run: hack projects prune)`, + message: `${details.join("; ")} (${guidance.join("; ")})`, }; } @@ -2419,15 +2411,20 @@ async function runDoctorFix(opts: { await maybeInstallMutagenForDoctorFix(); await maybeUntrackGeneratedFiles({ startDir: opts.startDir }); + await maybeRepairHackd(); + await maybeRepairAgentIntegrations({ startDir: opts.startDir }); const dockerOk = await dockerInfoOk(); if (!dockerOk) { - note("Docker is not reachable; cannot apply fixes.", "doctor"); + note( + "Docker is not reachable; daemon and agent repairs were still applied, but Docker-backed fixes were skipped.", + "doctor" + ); return; } await maybeRepairProjectLifecycleSessions({ startDir: opts.startDir }); - await maybeRepairHackd(); + await maybeRepairIncompleteRuntime({ startDir: opts.startDir }); const paths = getGlobalPaths(); await ensureDir(paths.caddyDir); @@ -2452,7 +2449,6 @@ async function runDoctorFix(opts: { await maybeExportCaddyCaCert({ paths }); await maybeRepairMacHostTlsTrust(); await maybeMigrateDnsmasq(); - await maybeRepairProjectTicketsGitHealth({ startDir: opts.startDir }); if (opts.migrateEnvConfig) { await maybeMigrateProjectEnvConfig({ startDir: opts.startDir }); } @@ -2460,6 +2456,48 @@ async function runDoctorFix(opts: { renderSkippedDoctorFixSteps(); } +async function maybeRepairIncompleteRuntime(opts: { + readonly startDir: string; +}): Promise { + const project = await findProjectContext(opts.startDir); + if (!project) { + return; + } + const [registry, runtimeResult] = await Promise.all([ + readProjectsRegistry(), + readRuntimeProjects({ includeGlobal: false }), + ]); + if (!runtimeResult.ok) { + return; + } + const scoped = scopeRuntimeHygieneToProject({ + projectRoot: project.projectRoot, + projectDir: project.projectDir, + projects: registry.projects, + runtime: runtimeResult.runtime, + }); + const incomplete = findIncompleteRuntimeProjects({ runtime: scoped.runtime }); + for (const entry of incomplete) { + if (entry.containerIds.length === 0) { + note( + `Could not identify Created containers for ${entry.project}; left unchanged.`, + "runtime repair" + ); + continue; + } + const code = await run(["docker", "start", ...entry.containerIds], { + stdin: "ignore", + timeoutMs: 30_000, + }); + note( + code === 0 + ? `Started Created services for ${entry.project}: ${entry.createdServices.join(", ")}` + : `Failed to start Created services for ${entry.project} (exit ${code}); no containers were removed.`, + "runtime repair" + ); + } +} + /** * Prints a single summary of destructive/system-level `--fix` steps that * were skipped because the run was non-interactive (no TTY, `--no-interactive`, @@ -2699,8 +2737,7 @@ export async function buildDoctorRemediationPlanLines(opts: { readonly migrateEnvConfig: boolean; }): Promise { const steps = [ - "Review and repair local network, CoreDNS, CA, host TLS env, and daemon drift where needed.", - "Repair tickets refs if the project repo needs it.", + "Review and repair global Docker networks, CoreDNS, CA, host TLS env, daemon drift, and agent integration freshness where needed.", ]; const project = await findProjectContext(opts.startDir); @@ -2817,115 +2854,6 @@ async function maybeUntrackGeneratedFiles(opts: { note(notes.join("\n"), "generated files"); } -async function maybeRepairProjectTicketsGitHealth(opts: { - readonly startDir: string; -}): Promise { - const project = await findProjectContext(opts.startDir); - if (!project) { - return; - } - - const ticketsEnablement = await resolveTicketsIntegrationEnablement({ - projectRoot: project.projectRoot, - }); - if (!ticketsEnablement.project) { - return; - } - - const controlPlane = await readControlPlaneConfig({ - projectDir: project.projectDir, - }); - const gitConfig = controlPlane.config.tickets.git; - if (!gitConfig.enabled) { - return; - } - - const channel = createGitTicketsChannel({ - projectRoot: project.projectRoot, - config: gitConfig, - logger: { - info: ({ message }) => note(message, "tickets"), - warn: ({ message }) => note(message, "tickets"), - }, - }); - const inspected = await channel.inspect(); - if (!inspected.ok) { - note(`Unable to inspect tickets git health: ${inspected.error}`, "doctor"); - return; - } - - const health = inspected.health; - if ( - !( - health.hasRefDivergence || - health.hasLegacyRef || - health.hasNonTicketFiles - ) - ) { - return; - } - - const reasons: string[] = []; - if (health.hasRefDivergence) { - reasons.push("hidden ref diverges from legacy branch"); - } else if (health.hasLegacyRef) { - reasons.push("legacy branch still exists"); - } - if (health.hasNonTicketFiles) { - reasons.push("non-ticket files present"); - } - - const okRepair = await doctorConfirm({ - message: `Repair tickets git storage now? (${reasons.join("; ")})`, - initialValue: true, - }); - if (!okRepair) { - return; - } - - const pruneLegacyRef = - health.hasLegacyRef && - (await doctorConfirm({ - message: `Prune legacy tickets ref ${health.legacyRef ?? "refs/heads/hack/tickets"} after repair?`, - initialValue: true, - })); - - const repaired = await channel.repair({ pruneLegacyRef }); - if (!repaired.ok) { - note(`Tickets repair failed: ${repaired.error}`, "doctor"); - return; - } - - const legacyRepairStatus = describeLegacyRepairStatus({ - hadLegacyRef: health.hasLegacyRef, - pruneLegacyRef, - didPruneLegacy: repaired.didPruneLegacy, - }); - const lines = [ - `commit: ${repaired.didCommit ? "created" : "noop"}`, - `push: ${repaired.didPush ? "pushed" : "skipped"}`, - `legacy ref: ${legacyRepairStatus}`, - ]; - if (repaired.pruneError) { - lines.push(`legacy prune error: ${repaired.pruneError}`); - } - note(lines.join("\n"), "tickets repair"); -} - -function describeLegacyRepairStatus(input: { - readonly hadLegacyRef: boolean; - readonly pruneLegacyRef: boolean; - readonly didPruneLegacy: boolean; -}): string { - if (!input.hadLegacyRef) { - return "not present"; - } - if (!input.pruneLegacyRef) { - return "left intact"; - } - return input.didPruneLegacy ? "pruned" : "prune failed"; -} - async function maybeRepairProjectLifecycleSessions(opts: { readonly startDir: string; }): Promise { @@ -3070,6 +2998,62 @@ async function maybeRepairHackd(): Promise { } } +async function maybeRepairAgentIntegrations(opts: { + readonly startDir: string; +}): Promise { + const project = await findProjectContext(opts.startDir); + const report = await inspectDoctorAgentIntegrations({ + projectRoot: project?.projectRoot ?? null, + }); + if (report.status !== "stale") { + return; + } + const ok = await doctorConfirm({ + message: project + ? "Refresh stale project and global agent integrations now?" + : "Refresh stale global agent integrations now?", + initialValue: true, + }); + if (ok) { + await runHackSubcommand({ + args: project + ? ["setup", "sync", "--all-scopes"] + : ["setup", "sync", "--global"], + }); + note( + "Agent integrations refreshed. Reload active agent sessions so cached rules are replaced.", + "agent integrations" + ); + } +} + +export async function inspectDoctorAgentIntegrations(opts: { + readonly projectRoot: string | null; + readonly homeDir?: string; +}): Promise<{ readonly status: "current" | "stale" }> { + const home = (opts.homeDir ?? process.env.HOME ?? homedir()).trim(); + const paths = [ + ...(opts.projectRoot + ? [ + resolve(opts.projectRoot, ".cursor", "rules", "hack.mdc"), + resolve(opts.projectRoot, ".codex", "skills", "hack-cli", "SKILL.md"), + resolve(opts.projectRoot, "AGENTS.md"), + resolve(opts.projectRoot, "CLAUDE.md"), + ] + : []), + resolve(home, ".cursor", "rules", "hack.mdc"), + resolve(home, ".codex", "skills", "hack-cli", "SKILL.md"), + resolve(home, ".ai", "skills", "hack-cli", "SKILL.md"), + ]; + const marker = `Content revision: \`${HACK_AGENT_INTEGRATION_CONTENT_REVISION}\``; + const contents = await Promise.all(paths.map((path) => readTextFile(path))); + return { + status: contents.every((content) => content?.includes(marker)) + ? "current" + : "stale", + }; +} + async function resolveDaemonReportForDoctorFix(): Promise< ReturnType > { @@ -3554,7 +3538,10 @@ export function buildDoctorSummaryLines(input: { const ungrouped = input.results.filter( (result) => - !DOCTOR_SUMMARY_GROUPS.some((group) => group.checks.has(result.name)) + !( + isHiddenDoctorCheck(result) || + DOCTOR_SUMMARY_GROUPS.some((group) => group.checks.has(result.name)) + ) ); if (ungrouped.length > 0) { const issues = ungrouped.filter( @@ -3571,6 +3558,10 @@ export function buildDoctorSummaryLines(input: { return lines; } +function isHiddenDoctorCheck(result: RecoveryCheckResult): boolean { + return result.name === "tickets git"; +} + async function renderDoctorSummary( results: readonly TimedCheckResult[] ): Promise { @@ -3621,8 +3612,9 @@ function summarizeDoctorGroupIssues(input: { function isIgnorableDoctorSummaryIssue(issue: RecoveryCheckResult): boolean { return ( - issue.name === "gateway tokens" && - issue.message.includes("No active tokens") + issue.message === "Not found (optional)" || + (issue.name === "gateway tokens" && + issue.message.includes("No active tokens")) ); } @@ -3657,8 +3649,14 @@ function summarizeDoctorIssue(issue: RecoveryCheckResult): string { return `${issue.name}: ${issue.message}`; } -function renderMacNote(): void { - if (isMac()) { +function renderMacNote(results: readonly RecoveryCheckResult[]): void { + const dnsNeedsSetup = results.some( + (result) => + DOCTOR_SUMMARY_GROUPS.find( + (group) => group.title === "Resolver & DNS" + )?.checks.has(result.name) && result.status !== "ok" + ); + if (isMac() && dnsNeedsSetup) { note( [ "macOS tip:", diff --git a/src/commands/env.ts b/src/commands/env.ts index 035ceb63..1eaaab47 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -1,6 +1,7 @@ import { homedir } from "node:os"; import { dirname, relative, resolve } from "node:path"; import { confirm, isCancel, password, select, text } from "@clack/prompts"; +import { YAML } from "bun"; import type { CliContext, CommandHandlerFor } from "../cli/command.ts"; import { @@ -13,7 +14,9 @@ import { optEnv, optJson, optPath, optProject } from "../cli/options.ts"; import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; import { updateGlobalConfig } from "../lib/config.ts"; import { resolveGlobalConfigPath } from "../lib/config-paths.ts"; +import { readTextFile } from "../lib/fs.ts"; import { isRecord } from "../lib/guards.ts"; +import { resolveHackInvocation } from "../lib/hack-cli.ts"; import type { HackEnvStorageSummary, HackEnvValueState, @@ -172,6 +175,24 @@ const listSpec = defineCommand({ subcommands: [], } as const); +const explainSpec = defineCommand({ + name: "explain", + summary: "Explain a resolved env value without revealing it", + group: "Diagnostics", + options: [optPath, optProject, optEnv, optJson, optService, optTarget], + positionals: [{ name: "key", required: true }], + subcommands: [], +} as const); + +const applySpec = defineCommand({ + name: "apply", + summary: "Recreate one service with its resolved env", + group: "Project", + options: [optPath, optProject, optEnv, optJson, optService], + positionals: [], + subcommands: [], +} as const); + const addSpec = defineCommand({ name: "add", summary: "Add or update an env value", @@ -1041,6 +1062,233 @@ const handleEnvList: CommandHandlerFor = async ({ }); }; +type EnvExplainPayload = { + readonly key: string; + readonly available: boolean; + readonly secret: boolean; + readonly format: "project_env_config_v1" | "legacy_env_contract"; + readonly environment: string | null; + readonly requested_scope: string; + readonly effective_scope: string | null; + readonly target: "host" | "compose"; + readonly delivered_to: readonly string[]; + readonly source: string | null; + readonly precedence: readonly string[]; +}; + +function renderEnvExplain(payload: EnvExplainPayload, json: boolean): void { + if (json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + process.stdout.write( + `${[ + `key\t${payload.key}`, + `available\t${payload.available ? "yes" : "no"}`, + `secret\t${payload.secret ? "yes" : "no"}`, + `environment\t${payload.environment ?? "base"}`, + `requested_scope\t${payload.requested_scope}`, + `effective_scope\t${payload.effective_scope ?? "none"}`, + `target\t${payload.target}`, + `delivered_to\t${payload.delivered_to.join(",") || "none"}`, + `source\t${payload.source ?? "none"}`, + `precedence\t${payload.precedence.join(" -> ") || "none"}`, + ].join("\n")}\n` + ); +} + +function resolveEnvExplainDelivery(opts: { + readonly available: boolean; + readonly target: "host" | "compose"; +}): readonly string[] { + if (!opts.available) { + return []; + } + return opts.target === "host" ? ["host", "lifecycle"] : ["compose"]; +} + +async function findModernEnvSourceFile(opts: { + readonly files: readonly string[]; + readonly scope: string; + readonly key: string; +}): Promise { + for (const file of [...opts.files].reverse()) { + const text = await readTextFile(file); + if (!text) { + continue; + } + try { + const parsed: unknown = YAML.parse(text); + const values = isRecord(parsed) ? parsed.values : undefined; + const scopes = isRecord(values) ? values : undefined; + const scope = + scopes && isRecord(scopes[opts.scope]) ? scopes[opts.scope] : null; + if (scope && Object.hasOwn(scope, opts.key)) { + return file; + } + } catch { + // The resolver already reports malformed env files; explanation stays redacted and best-effort. + } + } + return null; +} + +async function resolveModernEnvExplain(opts: { + readonly modern: NonNullable< + Awaited> + >; + readonly key: string; + readonly scope: string; + readonly target: "host" | "compose"; +}): Promise { + const hostValue = opts.modern.merged.values.host?.[opts.key]; + const scopedValue = + opts.scope === "global" + ? undefined + : opts.modern.merged.values[opts.scope]?.[opts.key]; + const globalValue = opts.modern.merged.values.global?.[opts.key]; + const usesHost = opts.target === "host" && hostValue !== undefined; + let effectiveScope: string | null = null; + let storedValue: unknown; + if (usesHost) { + effectiveScope = "host"; + storedValue = hostValue; + } else if (scopedValue !== undefined) { + effectiveScope = opts.scope; + storedValue = scopedValue; + } else if (globalValue !== undefined) { + effectiveScope = "global"; + storedValue = globalValue; + } + const sourceFile = + effectiveScope === null + ? null + : await findModernEnvSourceFile({ + files: opts.modern.files, + scope: effectiveScope, + key: opts.key, + }); + return { + key: opts.key, + available: storedValue !== undefined, + secret: storedValue !== undefined && isModernSecretStoredValue(storedValue), + format: "project_env_config_v1", + environment: opts.modern.selection.effectiveEnv, + requested_scope: opts.scope, + effective_scope: effectiveScope, + target: opts.target, + delivered_to: resolveEnvExplainDelivery({ + available: storedValue !== undefined, + target: opts.target, + }), + source: + effectiveScope === null + ? null + : `${effectiveScope}:${sourceFile ?? "project env"}`, + precedence: opts.modern.files, + }; +} + +const handleEnvExplain: CommandHandlerFor = async ({ + ctx, + args, +}): Promise => { + const project = await resolveProjectForEnv({ + ctx, + pathOpt: args.options.path, + projectOpt: args.options.project, + }); + const projectName = await resolveProjectName(project); + const envName = resolveRequestedEnvName({ envOption: args.options.env }); + const parsed = parseProjectEnvTarget({ + keyOrPath: args.positionals.key, + scopeOverride: args.options.service, + }); + const target = resolveHostEnvTarget({ targetOption: args.options.target }); + const modern = await loadModernProjectEnv({ project, envName }); + if (modern) { + renderEnvExplain( + await resolveModernEnvExplain({ + modern, + key: parsed.key, + scope: parsed.scope, + target, + }), + args.options.json === true + ); + return 0; + } + + const resolved = await loadResolvedEnvState({ + projectDir: project.projectDir, + projectName, + envName, + }); + if (!resolved) { + return 1; + } + const state = resolved.values.find((value) => value.key === parsed.key); + const relevant = + state !== undefined && + (state.services === null || state.services.includes(parsed.scope)); + renderEnvExplain( + { + key: parsed.key, + available: relevant && state?.value !== null, + secret: state?.source === "keychain", + format: "legacy_env_contract", + environment: resolved.envSelection.effectiveEnv, + requested_scope: parsed.scope, + effective_scope: relevant ? parsed.scope : null, + target, + delivered_to: resolveEnvExplainDelivery({ + available: relevant && state?.value !== null, + target, + }), + source: relevant ? (state?.resolvedFrom ?? state?.source ?? null) : null, + precedence: [ + resolved.contractPath, + resolved.envPath, + "process environment", + "secret backend", + ], + }, + args.options.json === true + ); + return 0; +}; + +const handleEnvApply: CommandHandlerFor = async ({ + ctx, + args, +}): Promise => { + const service = args.options.service?.trim() ?? ""; + if (service.length === 0 || service === "global") { + throw new CliUsageError("--service is required."); + } + const project = await resolveProjectForEnv({ + ctx, + pathOpt: args.options.path, + projectOpt: args.options.project, + }); + const invocation = await resolveHackInvocation(); + const command = [ + invocation.bin, + ...invocation.args, + "restart", + service, + "--path", + project.projectRoot, + ]; + if (args.options.env) { + command.push("--env", args.options.env); + } + if (args.options.json) { + command.push("--json"); + } + return await run(command, { cwd: project.projectRoot }); +}; + function maskModernEnvValue(input: { readonly modern: NonNullable< Awaited> @@ -2123,6 +2371,8 @@ export const envCommand = defineCommand({ positionals: [], subcommands: [ withHandler(listSpec, handleEnvList), + withHandler(explainSpec, handleEnvExplain), + withHandler(applySpec, handleEnvApply), withHandler(addSpec, handleEnvAdd), withHandler(setSpec, handleEnvSet), withHandler(materializeSpec, handleEnvMaterialize), diff --git a/src/commands/project.ts b/src/commands/project.ts index fbaa2596..4231c8e1 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -98,7 +98,13 @@ import { errorResultFromUnknown, okResult, } from "../lib/cli-result.ts"; +import { + buildStartupIncompleteMessage, + type ComposeServiceState, + classifyComposeStartupState, +} from "../lib/compose-startup-state.ts"; import { resolveGlobalHackDir } from "../lib/config-paths.ts"; +import { resolveDependencyCacheOverride } from "../lib/dependency-cache.ts"; import { parseDurationMs } from "../lib/duration.ts"; import { isSlimExecutionMode, @@ -156,6 +162,7 @@ import { migrateLegacyProjectEnv, projectEnvConfigExists, resolveProjectEnvConfig, + selectProjectEnvValuesForExecutionTarget, } from "../lib/project-env-config.ts"; import { resolveProjectExecutionTarget } from "../lib/project-execution.ts"; import { @@ -186,12 +193,22 @@ import { resolveRegisteredProjectByName, upsertProjectRegistration, } from "../lib/projects-registry.ts"; +import { + discoverDependencyBootstrapServices, + preflightRegistryCredentials, + RegistryCredentialPreflightError, +} from "../lib/registry-credential-preflight.ts"; +import { readRuntimeProjects } from "../lib/runtime-projects.ts"; import { formatSecretStoreDescriptor, resolveSecretStore, } from "../lib/secret-store.ts"; import { exec, findExecutableInPath, run as runShell } from "../lib/shell.ts"; import { parseTimeInput } from "../lib/time.ts"; +import { + buildWorktreeRetargetWarning, + findSameCheckoutRetargetConflicts, +} from "../lib/worktree-runtime-target.ts"; import { upsertAgentDocs } from "../mcp/agent-docs.ts"; import type { McpTarget } from "../mcp/install.ts"; import { installMcpConfig } from "../mcp/install.ts"; @@ -417,11 +434,17 @@ const logsOptions = [ const logsPositionals = [{ name: "service", required: false }] as const; const openOptions = [optPath, optProject, optBranch, optJson] as const; const openPositionals = [{ name: "target", required: false }] as const; +const serviceLifecyclePositionals = [ + { name: "services", required: false, multiple: true }, +] as const; type InitArgs = CommandArgs; -type UpArgs = CommandArgs; +type UpArgs = CommandArgs; type DownArgs = CommandArgs; -type RestartArgs = CommandArgs; +type RestartArgs = CommandArgs< + typeof restartOptions, + typeof serviceLifecyclePositionals +>; type PsArgs = CommandArgs; type RunArgs = CommandArgs; type ExecArgs = CommandArgs; @@ -444,7 +467,7 @@ const upSpec = defineCommand({ summary: "Start project services (docker compose up)", group: "Project", options: upOptions, - positionals: [], + positionals: serviceLifecyclePositionals, subcommands: [], } as const); @@ -463,10 +486,10 @@ export const downCommand = withHandler(downSpec, handleDown); const restartSpec = defineCommand({ name: "restart", - summary: "Restart project services (down then up)", + summary: "Restart the project or selected services", group: "Project", options: restartOptions, - positionals: [], + positionals: serviceLifecyclePositionals, subcommands: [], } as const); @@ -544,10 +567,34 @@ function resolveRequestedEnvName(input: { return envName; } +export function resolveRequestedComposeServices(opts: { + readonly requested: readonly string[] | undefined; + readonly available: readonly string[]; +}): readonly string[] { + const requested = [ + ...new Set( + (opts.requested ?? []).map((service) => service.trim()).filter(Boolean) + ), + ]; + if (requested.length === 0) { + return []; + } + + const available = new Set(opts.available); + const unknown = requested.filter((service) => !available.has(service)); + if (unknown.length > 0) { + throw new CliUsageError( + `Unknown compose service${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}` + ); + } + return requested; +} + async function resolveProjectForArgs(opts: { readonly ctx: CliContext; readonly pathOpt: string | undefined; readonly projectOpt: string | undefined; + readonly touchRegistration?: boolean; }) { if (opts.pathOpt && opts.projectOpt) { throw new CliUsageError("Use either --path or --project (not both)."); @@ -564,13 +611,17 @@ async function resolveProjectForArgs(opts: { `Unknown project "${name}". Run 'hack init' in that repo (or run 'hack projects' to see registered projects).` ); } - await touchProjectRegistration(fromRegistry); + if (opts.touchRegistration !== false) { + await touchProjectRegistration(fromRegistry); + } return fromRegistry; } const startDir = resolveStartDir(opts.ctx, opts.pathOpt); const project = await requireProjectContext(startDir); - await touchProjectRegistration(project); + if (opts.touchRegistration !== false) { + await touchProjectRegistration(project); + } return project; } @@ -596,6 +647,7 @@ async function resolveEffectiveBranchForCommand(opts: { readonly project: Awaited>; readonly branchOption: string | undefined; readonly noticeToStderr?: boolean; + readonly warnOnRetarget?: boolean; }): Promise { const explicit = resolveBranchSlug(opts.branchOption); if (explicit) { @@ -620,6 +672,31 @@ async function resolveEffectiveBranchForCommand(opts: { } else { logger.info({ message }); } + if (opts.warnOnRetarget === true) { + const baseProjectName = await resolveComposeProjectName({ + project: opts.project, + cfg, + }); + const targetComposeProject = `${baseProjectName}--${resolved.branch}`; + const runtime = await readRuntimeProjects({ includeGlobal: false }); + const warning = runtime.ok + ? buildWorktreeRetargetWarning({ + targetComposeProject, + conflicts: findSameCheckoutRetargetConflicts({ + currentProjectDir: opts.project.projectDir, + targetComposeProject, + runtime: runtime.runtime, + }), + }) + : null; + if (warning) { + if (opts.noticeToStderr === true) { + process.stderr.write(`WARNING: ${warning}\n`); + } else { + logger.warn({ message: warning }); + } + } + } } return resolved.branch; } @@ -633,6 +710,7 @@ export type LifecycleJsonData = { readonly composeProject: string; readonly services: { readonly started: readonly string[]; + readonly completed: readonly string[]; readonly stopped: readonly string[]; readonly failed: readonly string[]; }; @@ -648,6 +726,7 @@ export function buildLifecycleJsonData(opts: { readonly branch: string | null; readonly composeProject: string; readonly started?: readonly string[]; + readonly completed?: readonly string[]; readonly stopped?: readonly string[]; readonly failed?: readonly string[]; readonly durationMs: number; @@ -659,6 +738,7 @@ export function buildLifecycleJsonData(opts: { composeProject: opts.composeProject, services: { started: [...(opts.started ?? [])].sort(), + completed: [...(opts.completed ?? [])].sort(), stopped: [...(opts.stopped ?? [])].sort(), failed: [...(opts.failed ?? [])].sort(), }, @@ -666,11 +746,6 @@ export function buildLifecycleJsonData(opts: { }; } -type ComposeServiceState = { - readonly service: string; - readonly state: string; -}; - /** * Snapshot compose service states (best-effort) for `--json` payloads. */ @@ -678,35 +753,66 @@ async function readComposeServiceStates(opts: { readonly project: Awaited>; readonly composeProjectName: string | null; readonly profiles: readonly string[]; + readonly services?: readonly string[]; }): Promise { const res = await composeRuntimeBackend.psJson({ composeFiles: [opts.project.composeFile], composeProject: opts.composeProjectName, profiles: opts.profiles, cwd: dirname(opts.project.composeFile), + all: true, }); if (res.exitCode !== 0) { return []; } + const serviceFilter = opts.services ? new Set(opts.services) : null; return parseJsonLines(res.stdout) .map((entry) => ({ service: getString(entry, "Service") ?? "", - state: getString(entry, "State") ?? "", + state: (getString(entry, "State") ?? "").toLowerCase(), + exitCode: parseComposeExitCode(entry.ExitCode), })) - .filter((entry) => entry.service.length > 0); + .filter( + (entry) => + entry.service.length > 0 && + (serviceFilter === null || serviceFilter.has(entry.service)) + ); } -function splitServiceStates(states: readonly ComposeServiceState[]): { - readonly running: readonly string[]; - readonly notRunning: readonly string[]; +function parseComposeExitCode(value: unknown): number | null { + if (typeof value === "number" && Number.isInteger(value)) { + return value; + } + if (typeof value !== "string" || value.trim().length === 0) { + return null; + } + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) ? parsed : null; +} + +function startupSnapshotIsIncomplete(opts: { + readonly states: readonly ComposeServiceState[]; + readonly failed: readonly string[]; +}): boolean { + return opts.states.length === 0 || opts.failed.length > 0; +} + +function composeStartupFailure( + code: number, + action: string +): { + readonly code: "E_COMPOSE_FAILED" | "E_STARTUP_TIMEOUT"; + readonly message: string; } { - const running = states - .filter((entry) => entry.state === "running") - .map((entry) => entry.service); - const notRunning = states - .filter((entry) => entry.state !== "running") - .map((entry) => entry.service); - return { running, notRunning }; + return code === 124 + ? { + code: "E_STARTUP_TIMEOUT", + message: `${action} timed out; the Compose process group was terminated and the runtime may require repair.`, + } + : { + code: "E_COMPOSE_FAILED", + message: `${action} failed (exit ${code})`, + }; } /** @@ -725,6 +831,15 @@ function buildLifecycleJsonErrorResult(opts: { readonly error: unknown }) { if (opts.error instanceof CliUsageError) { return errorResult({ code: "E_USAGE", message: opts.error.message }); } + if (opts.error instanceof RegistryCredentialPreflightError) { + return errorResult({ + code: "E_ENV_KEY_MISSING", + message: opts.error.message, + detail: { + keys: [...new Set(opts.error.missing.map((entry) => entry.key))], + }, + }); + } return errorResultFromUnknown({ error: opts.error }); } @@ -1150,6 +1265,8 @@ async function resolveModernComposeEnvOverrides(opts: { }): Promise<{ readonly composeFiles: readonly string[]; readonly env: Readonly>; + readonly lifecycleEnv: Readonly>; + readonly preflightEnv: Readonly>; readonly effectiveEnvName: string | null; } | null> { const modern = await resolveProjectEnvConfig({ @@ -1161,6 +1278,18 @@ async function resolveModernComposeEnvOverrides(opts: { if (!modern) { return null; } + const lifecycleEnv = selectProjectEnvValuesForExecutionTarget({ + resolved: modern, + scopeName: "global", + target: "host", + }); + const preflightEnv = Object.assign( + {}, + modern.globalEnv, + ...opts.targetServices.map( + (service) => modern.serviceEnv[service] ?? modern.globalEnv + ) + ); for (const scope of modern.unknownScopes) { logger.warn({ @@ -1181,6 +1310,8 @@ async function resolveModernComposeEnvOverrides(opts: { return { composeFiles: [], env: modern.globalEnv, + lifecycleEnv, + preflightEnv, effectiveEnvName: modern.selection.effectiveEnv, }; } @@ -1196,11 +1327,13 @@ async function resolveModernComposeEnvOverrides(opts: { return { composeFiles: [overridePath], env: modern.globalEnv, + lifecycleEnv, + preflightEnv, effectiveEnvName: modern.selection.effectiveEnv, }; } -async function resolveLifecycleEnvForProject(opts: { +export async function resolveLifecycleEnvForProject(opts: { readonly project: Awaited>; readonly projectName: string; readonly envName?: string | null; @@ -1220,7 +1353,11 @@ async function resolveLifecycleEnvForProject(opts: { serviceNames, }); if (modern) { - return modern.globalEnv; + return selectProjectEnvValuesForExecutionTarget({ + resolved: modern, + scopeName: "global", + target: "host", + }); } const envResolved = await resolveHackEnv({ @@ -1245,6 +1382,8 @@ async function resolveComposeEnvOverrides(opts: { }): Promise<{ readonly composeFiles: readonly string[]; readonly env: Readonly>; + readonly lifecycleEnv: Readonly>; + readonly preflightEnv: Readonly>; readonly effectiveEnvName: string | null; }> { await maybePromptLegacyProjectEnvMigration({ @@ -1271,6 +1410,8 @@ async function resolveComposeEnvOverrides(opts: { return { composeFiles: [], env: {}, + lifecycleEnv: {}, + preflightEnv: {}, effectiveEnvName: resolved.envSelection.effectiveEnv, }; } @@ -1311,6 +1452,8 @@ async function resolveComposeEnvOverrides(opts: { return { composeFiles: [], env: resolved.envForCompose, + lifecycleEnv: resolved.envForCompose, + preflightEnv: resolved.envForCompose, effectiveEnvName: resolved.envSelection.effectiveEnv, }; } @@ -1327,10 +1470,35 @@ async function resolveComposeEnvOverrides(opts: { return { composeFiles: [overridePath], env: resolved.envForCompose, + lifecycleEnv: resolved.envForCompose, + preflightEnv: resolved.envForCompose, effectiveEnvName: resolved.envSelection.effectiveEnv, }; } +async function assertRegistryCredentialsAvailable(opts: { + readonly projectRoot: string; + readonly composeFile: string; + readonly targetServices: readonly string[]; + readonly env: Readonly>; +}): Promise { + const bootstrapServices = await discoverDependencyBootstrapServices({ + composeFile: opts.composeFile, + }); + if ( + !bootstrapServices.some((service) => opts.targetServices.includes(service)) + ) { + return; + } + const result = await preflightRegistryCredentials({ + projectRoot: opts.projectRoot, + env: opts.env, + }); + if (result.missing.length > 0) { + throw new RegistryCredentialPreflightError({ missing: result.missing }); + } +} + function logRelevantLegacyEnvWarnings(opts: { readonly resolved: Awaited>; readonly targetServices: readonly string[]; @@ -1983,7 +2151,26 @@ function createLifecycleProcessStarter(opts: { operationSignalCleanup = installLifecycleSignalCleanup({ cleanup: starterAbort, }); + const pendingEntry: LifecycleStateEntry = { + composeProject: opts.composeProject, + projectName: opts.projectName, + branch: opts.branch, + sessionName, + backend: backendName, + ownershipToken, + definitionHash: opts.definitionHash, + processes: [], + updatedAt: new Date().toISOString(), + }; + createdOwnershipToken = ownershipToken; try { + // Persist ownership before creating the mux session. If the CLI is + // SIGKILLed after session creation, the next up can still prove that + // the empty session belongs to this Compose instance and replace it. + await upsertLifecycleStateEntry({ + projectDir: opts.project.projectDir, + entry: pendingEntry, + }); const created = await backend.createSession({ name: sessionName, cwd: opts.project.projectRoot, @@ -1992,21 +2179,6 @@ function createLifecycleProcessStarter(opts: { if (!created.ok) { throw new Error(`Failed to create lifecycle session: ${sessionName}`); } - createdOwnershipToken = ownershipToken; - await upsertLifecycleStateEntry({ - projectDir: opts.project.projectDir, - entry: { - composeProject: opts.composeProject, - projectName: opts.projectName, - branch: opts.branch, - sessionName, - backend: backendName, - ownershipToken, - definitionHash: opts.definitionHash, - processes: [], - updatedAt: new Date().toISOString(), - }, - }); if (backendName === "tmux") { for (const [key, value] of Object.entries(opts.env)) { await exec( @@ -2017,6 +2189,14 @@ function createLifecycleProcessStarter(opts: { ); } } + } catch (error: unknown) { + await removeLifecycleStateEntryIfOwned({ + projectDir: opts.project.projectDir, + composeProject: opts.composeProject, + ownershipToken, + }); + createdOwnershipToken = null; + throw error; } finally { settleSessionCreation(); } @@ -5533,6 +5713,7 @@ function buildRemoteLifecycleCommand(opts: { readonly profiles: readonly string[]; readonly envName?: string | null; readonly detach?: boolean; + readonly services?: readonly string[]; }): readonly string[] { const args = [opts.action, "--target", "local"] as string[]; if (opts.envName) { @@ -5547,6 +5728,7 @@ function buildRemoteLifecycleCommand(opts: { if (opts.action === "up" && opts.detach === true) { args.push("--detach"); } + args.push(...(opts.services ?? [])); return args; } @@ -5561,6 +5743,7 @@ async function runRemoteLifecycleCommand(opts: { readonly envName?: string | null; readonly requestedTarget: string | undefined; readonly detach?: boolean; + readonly services?: readonly string[]; }): Promise { const target = await resolveProjectLifecycleTarget({ project: opts.project, @@ -5580,6 +5763,7 @@ async function runRemoteLifecycleCommand(opts: { profiles: opts.profiles, envName: opts.envName, detach: opts.detach, + services: opts.services, }); const invocation = await resolveHackInvocation(); const dispatchArgs = [...invocation.args, "dispatch", "run"] as string[]; @@ -5669,6 +5853,7 @@ async function runUpCommand({ project, branchOption: args.options.branch, noticeToStderr: json, + warnOnRetarget: true, }); const profiles = parseCsvList(args.options.profile); @@ -5691,6 +5876,7 @@ async function runUpCommand({ envName, requestedTarget: args.options.target, detach, + services: args.positionals.services, }); if (remoteUpCode !== null) { if (json) { @@ -5743,39 +5929,65 @@ async function runUpCommand({ const composeFilesWithInternal = internalOverride ? [...composeFiles, internalOverride] : composeFiles; + const dependencyCache = await resolveDependencyCacheOverride({ + projectRoot: project.projectRoot, + projectDir: project.projectDir, + projectName, + composeFile: project.composeFile, + }); + const composeFilesWithRuntimeOverrides = dependencyCache.overridePath + ? [...composeFilesWithInternal, dependencyCache.overridePath] + : composeFilesWithInternal; - const targetServices = await readComposeServiceNames(project.composeFile); + const allServiceNames = await readComposeServiceNames(project.composeFile); + const requestedServices = resolveRequestedComposeServices({ + requested: args.positionals.services, + available: allServiceNames, + }); + const serviceScoped = requestedServices.length > 0; + const targetServices = serviceScoped ? requestedServices : allServiceNames; const envOverrides = await resolveComposeEnvOverrides({ project, projectName, targetServices, - allServiceNames: targetServices, + allServiceNames, envName, }); const composeFilesWithEnv = [ - ...composeFilesWithInternal, + ...composeFilesWithRuntimeOverrides, ...envOverrides.composeFiles, ]; - await persistProjectRuntimeEnvSelection({ - projectDir: project.projectDir, - composeProject: lifecycleComposeProject, - envName: envOverrides.effectiveEnvName, + await assertRegistryCredentialsAvailable({ + projectRoot: project.projectRoot, + composeFile: project.composeFile, + targetServices, + env: envOverrides.preflightEnv, }); + if (!serviceScoped) { + await persistProjectRuntimeEnvSelection({ + projectDir: project.projectDir, + composeProject: lifecycleComposeProject, + envName: envOverrides.effectiveEnvName, + }); + } + let lifecycleCleanup: LifecycleOperationCleanup | null = null; let lifecycleSignalCleanup: { readonly dispose: () => void } | null = null; try { - const lifecycleUp = await runLifecycleUpBeforeAndProcesses({ - title: "Lifecycle (up before)", - project, - cfg, - projectName, - branch, - env: envOverrides.env, - effectiveEnvName: envOverrides.effectiveEnvName, - composeProject: lifecycleComposeProject, - }); + const lifecycleUp = serviceScoped + ? { code: 0, cleanup: null, signalCleanup: null, sessionName: null } + : await runLifecycleUpBeforeAndProcesses({ + title: "Lifecycle (up before)", + project, + cfg, + projectName, + branch, + env: envOverrides.lifecycleEnv, + effectiveEnvName: envOverrides.effectiveEnvName, + composeProject: lifecycleComposeProject, + }); if (lifecycleUp.code !== 0) { if (json) { return emitLifecycleResult({ @@ -5822,6 +6034,8 @@ async function runUpCommand({ cwd: dirname(project.composeFile), env: envOverrides.env, routeStdoutToStderr: json, + services: targetServices, + noDeps: serviceScoped, }); if (upCode !== 0) { await lifecycleCleanup?.(); @@ -5830,10 +6044,11 @@ async function runUpCommand({ composeProject: lifecycleComposeProject, }); if (json) { + const failure = composeStartupFailure(upCode, "docker compose up"); return emitLifecycleResult({ result: errorResult({ - code: "E_COMPOSE_FAILED", - message: `docker compose up failed (exit ${upCode})`, + code: failure.code, + message: failure.message, detail: { exitCode: upCode }, }), exitCode: upCode, @@ -5842,24 +6057,22 @@ async function runUpCommand({ return upCode; } - const afterCode = await runLifecycleCommands({ - title: "Lifecycle (up after)", - commands: cfg.lifecycle?.up?.after, - projectRoot: project.projectRoot, - env: envOverrides.env, - projectDir: project.projectDir, - composeProject: lifecycleComposeProject, - }); - - if (!json) { - if (afterCode !== 0) { - await lifecycleCleanup?.(); - } - return afterCode; - } + const afterCode = serviceScoped + ? 0 + : await runLifecycleCommands({ + title: "Lifecycle (up after)", + commands: cfg.lifecycle?.up?.after, + projectRoot: project.projectRoot, + env: envOverrides.lifecycleEnv, + projectDir: project.projectDir, + composeProject: lifecycleComposeProject, + }); if (afterCode !== 0) { await lifecycleCleanup?.(); + if (!json) { + return afterCode; + } return emitLifecycleResult({ result: errorResult({ code: "E_LIFECYCLE_FAILED", @@ -5873,8 +6086,42 @@ async function runUpCommand({ project, composeProjectName, profiles, + services: targetServices, }); - const { running, notRunning } = splitServiceStates(states); + const startup = classifyComposeStartupState(states); + if (startupSnapshotIsIncomplete({ states, failed: startup.failed })) { + await lifecycleCleanup?.(); + await removeProjectRuntimeStateEntry({ + projectDir: project.projectDir, + composeProject: lifecycleComposeProject, + }); + const message = buildStartupIncompleteMessage({ + composeProject: composeProjectName ?? baseProjectName, + failed: startup.failed, + }); + if (!json) { + logger.error({ message }); + return 1; + } + return emitLifecycleResult({ + result: errorResult({ + code: "E_STARTUP_INCOMPLETE", + message, + detail: { + running: startup.running, + completed: startup.completed, + failed: startup.failed, + ...(states.length === 0 + ? { inspection: "no_services_reported" } + : {}), + }, + }), + exitCode: 1, + }); + } + if (!json) { + return 0; + } return emitLifecycleResult({ result: okResult({ data: buildLifecycleJsonData({ @@ -5882,8 +6129,8 @@ async function runUpCommand({ project: baseProjectName, branch, composeProject: composeProjectName ?? baseProjectName, - started: running, - failed: notRunning, + started: startup.running, + completed: startup.completed, durationMs: Date.now() - (startedAtMs ?? Date.now()), }), }), @@ -6141,7 +6388,7 @@ async function runDownCommand({ project: baseProjectName, branch, composeProject: composeProjectName ?? baseProjectName, - stopped: splitServiceStates(statesBeforeDown).running, + stopped: classifyComposeStartupState(statesBeforeDown).running, durationMs: Date.now() - (startedAtMs ?? Date.now()), }), }), @@ -6159,6 +6406,7 @@ async function runRestartDownPhase(opts: { readonly branch: string | null; readonly routeStdoutToStderr?: boolean; readonly envForCompose: Readonly>; + readonly preserveComposeRuntime?: boolean; }): Promise { const lifecycleDownCleanup = async (): Promise => { await stopLifecycleProcessesBestEffort({ @@ -6186,13 +6434,15 @@ async function runRestartDownPhase(opts: { return downBefore; } - downCode = await composeRuntimeBackend.down({ - composeFiles: [opts.project.composeFile], - composeProject: opts.composeProjectName, - profiles: opts.profiles, - cwd: dirname(opts.project.composeFile), - routeStdoutToStderr: opts.routeStdoutToStderr === true, - }); + downCode = opts.preserveComposeRuntime + ? 0 + : await composeRuntimeBackend.down({ + composeFiles: [opts.project.composeFile], + composeProject: opts.composeProjectName, + profiles: opts.profiles, + cwd: dirname(opts.project.composeFile), + routeStdoutToStderr: opts.routeStdoutToStderr === true, + }); await lifecycleDownCleanup(); } finally { signalCleanup.dispose(); @@ -6201,10 +6451,12 @@ async function runRestartDownPhase(opts: { return downCode; } - await maybeManageProjectLogsAfterDown({ - project: opts.project, - branch: opts.branch, - }); + if (!opts.preserveComposeRuntime) { + await maybeManageProjectLogsAfterDown({ + project: opts.project, + branch: opts.branch, + }); + } const downAfter = await runLifecycleCommands({ title: "Lifecycle (restart down after)", @@ -6257,6 +6509,15 @@ async function runRestartUpPhase(opts: { const composeFilesWithInternal = internalOverride ? [...composeFiles, internalOverride] : composeFiles; + const dependencyCache = await resolveDependencyCacheOverride({ + projectRoot: opts.project.projectRoot, + projectDir: opts.project.projectDir, + projectName: opts.projectName, + composeFile: opts.project.composeFile, + }); + const composeFilesWithRuntimeOverrides = dependencyCache.overridePath + ? [...composeFilesWithInternal, dependencyCache.overridePath] + : composeFilesWithInternal; const targetServices = await readComposeServiceNames( opts.project.composeFile @@ -6268,8 +6529,14 @@ async function runRestartUpPhase(opts: { allServiceNames: targetServices, envName: opts.envName, }); + await assertRegistryCredentialsAvailable({ + projectRoot: opts.project.projectRoot, + composeFile: opts.project.composeFile, + targetServices, + env: envOverrides.preflightEnv, + }); const composeFilesWithEnv = [ - ...composeFilesWithInternal, + ...composeFilesWithRuntimeOverrides, ...envOverrides.composeFiles, ]; @@ -6288,7 +6555,7 @@ async function runRestartUpPhase(opts: { cfg: opts.cfg, projectName: opts.projectName, branch: opts.branch, - env: envOverrides.env, + env: envOverrides.lifecycleEnv, effectiveEnvName: envOverrides.effectiveEnvName, composeProject: opts.lifecycleComposeProject, }); @@ -6323,13 +6590,35 @@ async function runRestartUpPhase(opts: { cwd: dirname(opts.project.composeFile), env: envOverrides.env, routeStdoutToStderr: opts.routeStdoutToStderr === true, + forceRecreate: true, }); if (upCode !== 0) { - await lifecycleCleanup?.(); - await removeProjectRuntimeStateEntry({ - projectDir: opts.project.projectDir, - composeProject: opts.lifecycleComposeProject, + const repairCode = await composeRuntimeBackend.up({ + composeFiles: composeFilesWithEnv, + composeProject: opts.composeProjectName, + profiles: opts.profiles, + detach: true, + cwd: dirname(opts.project.composeFile), + env: envOverrides.env, + routeStdoutToStderr: opts.routeStdoutToStderr === true, }); + if (repairCode === 0) { + logger.warn({ + message: + "Restart failed, but Hack repaired the Compose runtime to a startable state.", + }); + } else { + logger.error({ + message: `Restart failed and automatic runtime repair also failed (exit ${repairCode}). Run 'hack doctor --fix'.`, + }); + } + if (repairCode !== 0) { + await lifecycleCleanup?.(); + await removeProjectRuntimeStateEntry({ + projectDir: opts.project.projectDir, + composeProject: opts.lifecycleComposeProject, + }); + } return upCode; } @@ -6337,7 +6626,7 @@ async function runRestartUpPhase(opts: { title: "Lifecycle (restart up after)", commands: opts.cfg.lifecycle?.up?.after, projectRoot: opts.project.projectRoot, - env: envOverrides.env, + env: envOverrides.lifecycleEnv, projectDir: opts.project.projectDir, composeProject: opts.lifecycleComposeProject, }); @@ -6350,6 +6639,144 @@ async function runRestartUpPhase(opts: { } } +type TargetedServiceRestartResult = + | { + readonly ok: true; + readonly code: 0; + readonly running: readonly string[]; + readonly completed: readonly string[]; + } + | { + readonly ok: false; + readonly code: number; + readonly errorCode: + | "E_COMPOSE_FAILED" + | "E_STARTUP_INCOMPLETE" + | "E_STARTUP_TIMEOUT"; + readonly message: string; + readonly running: readonly string[]; + readonly completed: readonly string[]; + }; + +/** + * Recreate only the explicitly selected services. Project lifecycle hooks are + * intentionally not run because their scope is the whole runtime, not an + * individual Compose service. + */ +async function runTargetedServiceRestart(opts: { + readonly project: Awaited>; + readonly cfg: Awaited>; + readonly projectName: string; + readonly composeProjectName: string | null; + readonly profiles: readonly string[]; + readonly branch: string | null; + readonly envName?: string | null; + readonly services: readonly string[]; + readonly allServiceNames: readonly string[]; + readonly routeStdoutToStderr: boolean; +}): Promise { + await maybeSyncOauthAliasesInCompose({ project: opts.project }); + const devHost = opts.branch + ? await resolveBranchDevHost({ project: opts.project }) + : null; + const aliasHost = + opts.branch && devHost + ? resolveBranchAliasHost({ devHost, cfg: opts.cfg }) + : null; + const internalOverride = await resolveInternalComposeOverride({ + project: opts.project, + cfg: opts.cfg, + branch: opts.branch, + devHost, + aliasHost, + }); + const composeFiles = + opts.branch && devHost + ? await resolveBranchComposeFiles({ + project: opts.project, + branch: opts.branch, + devHost, + aliasHost, + }) + : [opts.project.composeFile]; + const envOverrides = await resolveComposeEnvOverrides({ + project: opts.project, + projectName: opts.projectName, + targetServices: opts.services, + allServiceNames: opts.allServiceNames, + envName: opts.envName, + }); + await assertRegistryCredentialsAvailable({ + projectRoot: opts.project.projectRoot, + composeFile: opts.project.composeFile, + targetServices: opts.services, + env: envOverrides.preflightEnv, + }); + const dependencyCache = await resolveDependencyCacheOverride({ + projectRoot: opts.project.projectRoot, + projectDir: opts.project.projectDir, + projectName: opts.projectName, + composeFile: opts.project.composeFile, + }); + const code = await composeRuntimeBackend.up({ + composeFiles: [ + ...composeFiles, + ...(internalOverride ? [internalOverride] : []), + ...(dependencyCache.overridePath ? [dependencyCache.overridePath] : []), + ...envOverrides.composeFiles, + ], + composeProject: opts.composeProjectName, + profiles: opts.profiles, + detach: true, + noDeps: true, + forceRecreate: true, + services: opts.services, + cwd: dirname(opts.project.composeFile), + env: envOverrides.env, + routeStdoutToStderr: opts.routeStdoutToStderr, + }); + if (code !== 0) { + const failure = composeStartupFailure(code, "Targeted service restart"); + return { + ok: false, + code, + errorCode: failure.code, + message: failure.message, + running: [], + completed: [], + }; + } + + const states = await readComposeServiceStates({ + project: opts.project, + composeProjectName: opts.composeProjectName, + profiles: opts.profiles, + services: opts.services, + }); + const startup = classifyComposeStartupState(states); + if (startupSnapshotIsIncomplete({ states, failed: startup.failed })) { + return { + ok: false, + code: 1, + errorCode: "E_STARTUP_INCOMPLETE", + message: buildStartupIncompleteMessage({ + composeProject: + opts.composeProjectName ?? + defaultProjectSlugFromPath(opts.project.projectRoot), + failed: startup.failed, + }), + running: startup.running, + completed: startup.completed, + }; + } + return { + ok: true, + code: 0, + running: startup.running, + completed: startup.completed, + }; +} + async function handleRestart({ ctx, args, @@ -6384,6 +6811,7 @@ async function handleRestart({ } } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: restart coordinates preflight, scoped recreation, lifecycle preparation, repair, and JSON reporting in one transaction. async function runRestartCommand({ ctx, args, @@ -6407,6 +6835,7 @@ async function runRestartCommand({ project, branchOption: args.options.branch, noticeToStderr: json, + warnOnRetarget: true, }); const profiles = parseCsvList(args.options.profile); @@ -6418,6 +6847,7 @@ async function runRestartCommand({ profiles, envName, requestedTarget: args.options.target, + services: args.positionals.services, }); if (remoteRestartCode !== null) { if (json) { @@ -6445,6 +6875,52 @@ async function runRestartCommand({ branch, }); const projectName = sanitizeProjectSlug(baseProjectName); + const allServiceNames = await readComposeServiceNames(project.composeFile); + const requestedServices = resolveRequestedComposeServices({ + requested: args.positionals.services, + available: allServiceNames, + }); + if (requestedServices.length > 0) { + const result = await runTargetedServiceRestart({ + project, + cfg, + projectName, + composeProjectName, + profiles, + branch, + envName, + services: requestedServices, + allServiceNames, + routeStdoutToStderr: json, + }); + if (!json) { + return result.code; + } + if (!result.ok) { + return emitLifecycleResult({ + result: errorResult({ + code: result.errorCode, + message: result.message, + detail: { services: requestedServices }, + }), + exitCode: result.code, + }); + } + return emitLifecycleResult({ + result: okResult({ + data: buildLifecycleJsonData({ + action: "restart", + project: baseProjectName, + branch, + composeProject: composeProjectName ?? baseProjectName, + started: result.running, + completed: result.completed, + durationMs: Date.now() - (startedAtMs ?? Date.now()), + }), + }), + exitCode: 0, + }); + } const effectiveEnvName = await resolveStoredRuntimeEnvName({ requestedEnvName: envName, projectDir: project.projectDir, @@ -6455,6 +6931,19 @@ async function runRestartCommand({ projectName, envName: effectiveEnvName, }); + const preflightEnv = await resolveComposeEnvOverrides({ + project, + projectName, + targetServices: allServiceNames, + allServiceNames, + envName: effectiveEnvName, + }); + await assertRegistryCredentialsAvailable({ + projectRoot: project.projectRoot, + composeFile: project.composeFile, + targetServices: allServiceNames, + env: preflightEnv.preflightEnv, + }); const downCode = await runRestartDownPhase({ project, @@ -6466,6 +6955,7 @@ async function runRestartCommand({ branch, envForCompose: lifecycleEnv, routeStdoutToStderr: json, + preserveComposeRuntime: true, }); if (downCode !== 0) { if (json) { @@ -6495,20 +6985,19 @@ async function runRestartCommand({ profiles, branch, envName: effectiveEnvName, - // `--json` implies detach so the command terminates with a result. - detach: json, + detach: true, routeStdoutToStderr: json, }); - if (!json) { - return upCode; - } - if (upCode !== 0) { + if (!json) { + return upCode; + } + const failure = composeStartupFailure(upCode, "Restart up phase"); return emitLifecycleResult({ result: errorResult({ - code: "E_COMPOSE_FAILED", - message: `Restart up phase failed (exit ${upCode})`, + code: failure.code, + message: failure.message, detail: { exitCode: upCode, phase: "up" }, }), exitCode: upCode, @@ -6520,7 +7009,46 @@ async function runRestartCommand({ composeProjectName, profiles, }); - const { running, notRunning } = splitServiceStates(states); + const startup = classifyComposeStartupState(states); + if (startupSnapshotIsIncomplete({ states, failed: startup.failed })) { + await stopLifecycleProcessesBestEffort({ + project, + cfg, + projectName, + branch, + composeProject: lifecycleComposeProject, + }); + await removeProjectRuntimeStateEntry({ + projectDir: project.projectDir, + composeProject: lifecycleComposeProject, + }); + const message = buildStartupIncompleteMessage({ + composeProject: composeProjectName ?? baseProjectName, + failed: startup.failed, + }); + if (!json) { + logger.error({ message }); + return 1; + } + return emitLifecycleResult({ + result: errorResult({ + code: "E_STARTUP_INCOMPLETE", + message, + detail: { + running: startup.running, + completed: startup.completed, + failed: startup.failed, + ...(states.length === 0 + ? { inspection: "no_services_reported" } + : {}), + }, + }), + exitCode: 1, + }); + } + if (!json) { + return 0; + } return emitLifecycleResult({ result: okResult({ data: buildLifecycleJsonData({ @@ -6528,8 +7056,8 @@ async function runRestartCommand({ project: baseProjectName, branch, composeProject: composeProjectName ?? baseProjectName, - started: running, - failed: notRunning, + started: startup.running, + completed: startup.completed, durationMs: Date.now() - (startedAtMs ?? Date.now()), }), }), @@ -6691,6 +7219,7 @@ async function handlePs({ ctx, pathOpt: args.options.path, projectOpt: args.options.project, + touchRegistration: false, }); const json = args.options.json === true; const branch = await resolveEffectiveBranchForCommand({ @@ -6700,7 +7229,6 @@ async function handlePs({ }); const profiles = parseCsvList(args.options.profile); - await touchBranchUsageIfNeeded({ project, branch }); const cfg = await readProjectConfig(project); if (cfg.parseError) { const configPath = cfg.configPath ?? project.configFile; @@ -7366,6 +7894,7 @@ async function handleLogs({ ctx, pathOpt: args.options.path, projectOpt: args.options.project, + touchRegistration: false, }); const json = args.options.json === true; const branch = await resolveEffectiveBranchForCommand({ @@ -7383,7 +7912,6 @@ async function handleLogs({ until: args.options.until, }); - await touchBranchUsageIfNeeded({ project, branch }); const wantsLokiExplicit = computeWantsLokiExplicit({ options: args.options }); const validation = validateLogsArgs({ forceCompose: args.options.compose === true, @@ -7544,6 +8072,7 @@ async function handleOpen({ ctx, pathOpt: args.options.path, projectOpt: args.options.project, + touchRegistration: false, }); const json = args.options.json === true; const branch = await resolveEffectiveBranchForCommand({ @@ -7553,7 +8082,6 @@ async function handleOpen({ }); const derivedHost = `${defaultProjectSlugFromPath(project.projectRoot)}.${DEFAULT_PROJECT_TLD}`; const devHost = (await readProjectDevHost(project)) ?? derivedHost; - await touchBranchUsageIfNeeded({ project, branch }); const cfg = await readProjectConfig(project); if (cfg.parseError) { const configPath = cfg.configPath ?? project.configFile; diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 1fd1f5ce..106a487b 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -31,6 +31,7 @@ import { } from "../lib/projects-registry.ts"; import type { RuntimeContainer, + RuntimeProject, RuntimeService, } from "../lib/runtime-projects.ts"; import { @@ -98,7 +99,7 @@ const statusSpec = defineCommand({ expandInRootHelp: true, } as const); -const pruneOptions = [optIncludeGlobal, optJson] as const; +const pruneOptions = [optProject, optIncludeGlobal, optJson] as const; const pruneSpec = defineCommand({ name: "prune", summary: "Remove stale registry entries and stop orphaned containers", @@ -122,10 +123,11 @@ const handleProjects: CommandHandlerFor = async ({ ctx, args, }): Promise => { - const filter = + const requestedProject = typeof args.options.project === "string" ? sanitizeName(args.options.project) - : null; + : ""; + const filter = requestedProject.length > 0 ? requestedProject : null; await touchCwdProjectRegistration({ cwd: ctx.cwd }); return await runProjects({ filter, @@ -206,16 +208,28 @@ async function applyPrune(opts: { const handlePrune: CommandHandlerFor = async ({ args, }): Promise => { + const filter = + typeof args.options.project === "string" + ? sanitizeName(args.options.project) + : null; const includeGlobal = args.options.includeGlobal === true; const json = args.options.json === true; const registry = await readProjectsRegistry(); const candidates = await collectPruneRegistryCandidates({ - projects: registry.projects, + projects: filterPruneRegistryProjects({ + projects: registry.projects, + filter, + }), }); const runtimeResult = await readRuntimeProjects({ includeGlobal }); const orphaned = runtimeResult.ok - ? await findOrphanRuntimeProjects({ runtime: runtimeResult.runtime }) + ? await findOrphanRuntimeProjects({ + runtime: filterPruneRuntimeProjects({ + runtime: runtimeResult.runtime, + filter, + }), + }) : []; const orphanedContainerCount = orphaned.reduce( (sum, entry) => sum + entry.containerIds.length, @@ -1074,6 +1088,31 @@ function sanitizeName(value: string): string { return value.trim().toLowerCase(); } +function filterPruneRegistryProjects(opts: { + readonly projects: readonly RegisteredProject[]; + readonly filter: string | null; +}): readonly RegisteredProject[] { + if (!opts.filter) { + return opts.projects; + } + return opts.projects.filter( + (project) => sanitizeName(project.name) === opts.filter + ); +} + +function filterPruneRuntimeProjects(opts: { + readonly runtime: readonly RuntimeProject[]; + readonly filter: string | null; +}): readonly RuntimeProject[] { + if (!opts.filter) { + return opts.runtime; + } + return opts.runtime.filter((project) => { + const name = sanitizeName(project.project); + return name === opts.filter || name.startsWith(`${opts.filter}--`); + }); +} + function readNumberField(opts: { readonly json: Record; readonly key: string; @@ -1105,6 +1144,8 @@ function readRepairOutcomeField(opts: { export const __testOnlyProjectsCommand = { buildRuntimeRecoveryNotice, + filterPruneRegistryProjects, + filterPruneRuntimeProjects, parseDaemonRuntimeRecoveryMeta, }; diff --git a/src/commands/recovery-guidance.ts b/src/commands/recovery-guidance.ts index 17df1409..64baae37 100644 --- a/src/commands/recovery-guidance.ts +++ b/src/commands/recovery-guidance.ts @@ -16,6 +16,8 @@ export type DoctorRecoveryGuidance = { const FIX_CHECKS = new Set(["caddy local ca", "dns:hack", "dns:hack.gy"]); const SAFE_SHELL_ARG_PATTERN = /^[A-Za-z0-9_./:-]+$/u; +const PROJECTS_PRUNE_COMMAND_PATTERN = + /hack projects prune(?: --project [A-Za-z0-9_.-]+)?/u; function pushUnique(target: string[], value: string): void { if (!target.includes(value)) { @@ -82,7 +84,8 @@ export function buildDoctorRecoveryGuidance(input: { } if (result.message.includes("hack projects prune")) { - pushUnique(temporaryBreakage, "hack projects prune"); + const command = result.message.match(PROJECTS_PRUNE_COMMAND_PATTERN)?.[0]; + pushUnique(temporaryBreakage, command ?? "hack projects prune"); continue; } @@ -97,11 +100,26 @@ export function buildDoctorRecoveryGuidance(input: { continue; } + if (result.message.includes("hack daemon restart")) { + pushUnique(temporaryBreakage, "hack daemon restart"); + continue; + } + if (result.message.includes("hack daemon start")) { pushUnique(temporaryBreakage, "hack daemon start"); continue; } + if (result.message.includes("hack setup sync --all-scopes")) { + pushUnique(configurationRepair, "hack setup sync --all-scopes"); + continue; + } + + if (result.message.includes("hack setup sync --global")) { + pushUnique(configurationRepair, "hack setup sync --global"); + continue; + } + if (result.message.includes("sudo brew services restart dnsmasq")) { pushUnique(temporaryBreakage, "sudo brew services restart dnsmasq"); continue; @@ -217,7 +235,7 @@ export function buildRecoveryWorkflowLines(input: { } if (scoped.temporaryBreakage.length > 0) { - lines.push(`${stepNumber}. Temporary breakage:`); + lines.push(`${stepNumber}. Fix now:`); for (const command of scoped.temporaryBreakage) { lines.push(` - \`${command}\``); } @@ -225,7 +243,7 @@ export function buildRecoveryWorkflowLines(input: { } if (scoped.configurationRepair.length > 0) { - lines.push(`${stepNumber}. Configuration repair:`); + lines.push(`${stepNumber}. Repair configuration:`); for (const command of scoped.configurationRepair) { lines.push(` - \`${command}\``); } @@ -233,7 +251,7 @@ export function buildRecoveryWorkflowLines(input: { } if (scoped.followUp.length > 0) { - lines.push(`${stepNumber}. Manual follow-up:`); + lines.push(`${stepNumber}. Investigate:`); for (const item of scoped.followUp) { lines.push(` - ${item}`); } diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 8dd6d799..24a2dfe1 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -21,6 +21,13 @@ import { installCursorRules, removeCursorRules, } from "../agents/cursor.ts"; +import { + checkDeprecatedSharedHackSkills, + checkSharedHackSkill, + installSharedHackSkill, + removeDeprecatedSharedHackSkills, + removeSharedHackSkill, +} from "../agents/shared-skill.ts"; import type { CliContext, CommandArgs } from "../cli/command.ts"; import { CliUsageError, @@ -29,10 +36,12 @@ import { withHandler, } from "../cli/command.ts"; import { optPath } from "../cli/options.ts"; -import { resolveTicketsIntegrationEnablement } from "../control-plane/extensions/tickets/enablement.ts"; import { - checkTicketsSkill, - installTicketsSkill, + checkDeprecatedTicketsAgentDocs, + removeTicketsAgentDocs, +} from "../control-plane/extensions/tickets/agent-docs.ts"; +import { + checkDeprecatedTicketsSkill, removeTicketsSkill, type TicketsSkillResult, } from "../control-plane/extensions/tickets/tickets-skill.ts"; @@ -208,7 +217,7 @@ const codexSpec = defineCommand({ const ticketsSpec = defineCommand({ name: "tickets", - summary: "Install Codex skill for hack tickets usage", + summary: "Remove or audit the deprecated Hack Tickets skill", group: "Agents", options: setupTicketsOptions, positionals: [], @@ -226,7 +235,8 @@ const agentsSpec = defineCommand({ const syncSpec = defineCommand({ name: "sync", - summary: "Refresh agent docs, skills, and MCP configs", + summary: + "Refresh project/global agent guidance and remove deprecated artifacts", group: "Agents", options: setupSyncOptions, positionals: [], @@ -623,21 +633,19 @@ async function handleSetupTickets({ logger.info({ message: - 'Note: tickets is an optional/legacy extension — it is no longer part of default agent instructions (enable via controlPlane.extensions["dance.hack.tickets"].enabled).', + "Hack Tickets agent integrations are deprecated. This command now audits or removes the legacy skill; it never installs it.", }); - let result: Awaited>; + let result: TicketsSkillResult; if (action === "check") { - result = await checkTicketsSkill({ scope, projectRoot }); - } else if (action === "remove") { - result = await removeTicketsSkill({ scope, projectRoot }); + result = await checkDeprecatedTicketsSkill({ scope, projectRoot }); } else { - result = await installTicketsSkill({ scope, projectRoot }); + result = await removeTicketsSkill({ scope, projectRoot }); } return logSingleResult({ action, - okMessage: "Tickets skill", + okMessage: "Deprecated Tickets skill", result, }); } @@ -717,10 +725,6 @@ async function handleSetupSync({ const projectRoot = includesProject ? await resolveSetupRoot({ ctx, pathOpt: args.options.path }) : undefined; - const ticketsEnablement = await resolveTicketsIntegrationEnablement({ - projectRoot, - }); - let exitCode = 0; if (includesProject && projectRoot) { @@ -729,19 +733,12 @@ async function handleSetupSync({ await runProjectScopeSync({ action, projectRoot, - ticketsEnabled: ticketsEnablement.project, }) ); } if (includesUser) { - exitCode = Math.max( - exitCode, - await runUserScopeSync({ - action, - ticketsEnabled: ticketsEnablement.global, - }) - ); + exitCode = Math.max(exitCode, await runUserScopeSync({ action })); } return exitCode; @@ -749,20 +746,18 @@ async function handleSetupSync({ /** * Run one sync action across all project-scope integrations and log results. - * Tickets is optional/legacy: its skill is only checked/installed when the - * extension is enabled for the project; removal always runs so leftover - * artifacts get cleaned up even after the extension was disabled. + * Deprecated Tickets agent artifacts are always audited and removed by sync. */ async function runProjectScopeSync(opts: { readonly action: SetupSyncAction; readonly projectRoot: string; - readonly ticketsEnabled: boolean; }): Promise { - const { action, projectRoot, ticketsEnabled } = opts; + const { action, projectRoot } = opts; let cursorResult: Awaited>; let claudeResult: Awaited>; let codexResult: Awaited>; - let ticketsResult: TicketsSkillResult | null; + let ticketsResult: TicketsSkillResult; + let ticketsDocsResults: SetupMultiLogResult[]; let mcpResults: SetupMultiLogResult[]; let docsResults: SetupMultiLogResult[]; @@ -770,9 +765,14 @@ async function runProjectScopeSync(opts: { cursorResult = await checkCursorRules({ scope: "project", projectRoot }); claudeResult = await checkClaudeHooks({ scope: "project", projectRoot }); codexResult = await checkCodexSkill({ scope: "project", projectRoot }); - ticketsResult = ticketsEnabled - ? await checkTicketsSkill({ scope: "project", projectRoot }) - : null; + ticketsResult = await checkDeprecatedTicketsSkill({ + scope: "project", + projectRoot, + }); + ticketsDocsResults = await checkDeprecatedTicketsAgentDocs({ + projectRoot, + targets: ["agents", "claude"], + }); mcpResults = await checkMcpConfig({ scope: "project", targets: ["cursor", "claude", "codex"], @@ -787,6 +787,10 @@ async function runProjectScopeSync(opts: { claudeResult = await removeClaudeHooks({ scope: "project", projectRoot }); codexResult = await removeCodexSkill({ scope: "project", projectRoot }); ticketsResult = await removeTicketsSkill({ scope: "project", projectRoot }); + ticketsDocsResults = await removeTicketsAgentDocs({ + projectRoot, + targets: ["agents", "claude"], + }); mcpResults = await removeMcpConfig({ scope: "project", targets: ["cursor", "claude", "codex"], @@ -800,9 +804,11 @@ async function runProjectScopeSync(opts: { cursorResult = await installCursorRules({ scope: "project", projectRoot }); claudeResult = await installClaudeHooks({ scope: "project", projectRoot }); codexResult = await installCodexSkill({ scope: "project", projectRoot }); - ticketsResult = ticketsEnabled - ? await installTicketsSkill({ scope: "project", projectRoot }) - : null; + ticketsResult = await removeTicketsSkill({ scope: "project", projectRoot }); + ticketsDocsResults = await removeTicketsAgentDocs({ + projectRoot, + targets: ["agents", "claude"], + }); mcpResults = await installMcpConfig({ scope: "project", targets: ["cursor", "claude", "codex"], @@ -821,9 +827,6 @@ async function runProjectScopeSync(opts: { [cursorResult, "Cursor integration (project)"], [claudeResult, "Claude integration (project)"], [codexResult, "Codex integration (project)"], - ...(ticketsResult - ? ([[ticketsResult, "Tickets skill (project)"]] as const) - : []), ]; let exitCode = 0; @@ -833,6 +836,23 @@ async function runProjectScopeSync(opts: { logSingleResult({ action, okMessage, result }) ); } + const cleanupAction = action === "check" ? "check" : "remove"; + exitCode = Math.max( + exitCode, + logSingleResult({ + action: cleanupAction, + okMessage: "Deprecated Tickets skill (project)", + result: ticketsResult, + }) + ); + exitCode = Math.max( + exitCode, + logMultiResults({ + action: cleanupAction, + okMessage: "Deprecated Tickets instructions", + results: ticketsDocsResults, + }) + ); exitCode = Math.max( exitCode, logMultiResults({ @@ -854,27 +874,28 @@ async function runProjectScopeSync(opts: { /** * Run one sync action across all global (user) scope integrations and log - * results. The tickets skill follows the global-config enablement only; - * removal always runs (cleanup of leftovers is always safe). + * results. Shared `~/.ai/skills` guidance is managed alongside client-specific + * integrations, and known legacy Hack skills are cleaned up safely. */ async function runUserScopeSync(opts: { readonly action: SetupSyncAction; - readonly ticketsEnabled: boolean; }): Promise { - const { action, ticketsEnabled } = opts; + const { action } = opts; let cursorResult: Awaited>; let claudeResult: Awaited>; let codexResult: Awaited>; - let ticketsResult: TicketsSkillResult | null; + let ticketsResult: TicketsSkillResult; + let sharedSkillResult: SetupMultiLogResult & { readonly path: string }; + let legacySharedResults: SetupMultiLogResult[]; let mcpResults: SetupMultiLogResult[]; if (action === "check") { cursorResult = await checkCursorRules({ scope: "user" }); claudeResult = await checkClaudeHooks({ scope: "user" }); codexResult = await checkCodexSkill({ scope: "user" }); - ticketsResult = ticketsEnabled - ? await checkTicketsSkill({ scope: "user" }) - : null; + ticketsResult = await checkDeprecatedTicketsSkill({ scope: "user" }); + sharedSkillResult = await checkSharedHackSkill(); + legacySharedResults = await checkDeprecatedSharedHackSkills(); mcpResults = await checkMcpConfig({ scope: "user", targets: ["cursor", "claude", "codex"], @@ -884,6 +905,8 @@ async function runUserScopeSync(opts: { claudeResult = await removeClaudeHooks({ scope: "user" }); codexResult = await removeCodexSkill({ scope: "user" }); ticketsResult = await removeTicketsSkill({ scope: "user" }); + sharedSkillResult = await removeSharedHackSkill(); + legacySharedResults = await removeDeprecatedSharedHackSkills(); mcpResults = await removeMcpConfig({ scope: "user", targets: ["cursor", "claude", "codex"], @@ -892,9 +915,9 @@ async function runUserScopeSync(opts: { cursorResult = await installCursorRules({ scope: "user" }); claudeResult = await installClaudeHooks({ scope: "user" }); codexResult = await installCodexSkill({ scope: "user" }); - ticketsResult = ticketsEnabled - ? await installTicketsSkill({ scope: "user" }) - : null; + ticketsResult = await removeTicketsSkill({ scope: "user" }); + sharedSkillResult = await installSharedHackSkill(); + legacySharedResults = await removeDeprecatedSharedHackSkills(); mcpResults = await installMcpConfig({ scope: "user", targets: ["cursor", "claude", "codex"], @@ -908,9 +931,7 @@ async function runUserScopeSync(opts: { [cursorResult, "Cursor integration (global)"], [claudeResult, "Claude integration (global)"], [codexResult, "Codex integration (global)"], - ...(ticketsResult - ? ([[ticketsResult, "Tickets skill (global)"]] as const) - : []), + [sharedSkillResult, "Shared Hack skill (global)"], ]; let exitCode = 0; @@ -920,6 +941,23 @@ async function runUserScopeSync(opts: { logSingleResult({ action, okMessage, result }) ); } + const cleanupAction = action === "check" ? "check" : "remove"; + exitCode = Math.max( + exitCode, + logSingleResult({ + action: cleanupAction, + okMessage: "Deprecated Tickets skill (global)", + result: ticketsResult, + }) + ); + exitCode = Math.max( + exitCode, + logMultiResults({ + action: cleanupAction, + okMessage: "Deprecated shared Hack skills", + results: legacySharedResults, + }) + ); exitCode = Math.max( exitCode, logMultiResults({ @@ -1134,6 +1172,12 @@ function logCheckResult(opts: { readonly message?: string; }; }): number { + if (opts.result.status === "absent") { + logger.success({ + message: `${opts.okMessage} not installed at ${opts.path}`, + }); + return 0; + } if (opts.result.status === "missing") { logger.warn({ message: `${opts.okMessage} not installed at ${opts.path}`, @@ -1148,6 +1192,14 @@ function logCheckResult(opts: { }); return 1; } + if (opts.result.status === "deprecated") { + logger.warn({ + message: + opts.result.message ?? + `${opts.okMessage} is deprecated at ${opts.path}`, + }); + return 1; + } logger.success({ message: `${opts.okMessage} installed at ${opts.path}` }); return 0; } diff --git a/src/commands/tickets.ts b/src/commands/tickets.ts index 6ba73dc9..f91ec7e8 100644 --- a/src/commands/tickets.ts +++ b/src/commands/tickets.ts @@ -14,7 +14,7 @@ import { logger } from "../ui/logger.ts"; */ const ticketsSpec = defineCommand({ name: "tickets", - summary: "Track repo-local work without leaving git", + summary: "Deprecated: legacy repo-local Tickets compatibility commands", description: [ "Usage:", " hack tickets list", @@ -26,6 +26,7 @@ const ticketsSpec = defineCommand({ " hack tickets setup", " hack tickets tui", "", + "Deprecated compatibility surface. It is no longer installed into agent instructions or skills.", "Alias for `hack x tickets `. Requires extension enabled.", ].join("\n"), group: "Integrations", @@ -45,6 +46,10 @@ async function handleTickets({ readonly ctx: CliContext; readonly args: TicketsArgs; }): Promise { + logger.warn({ + message: + "Hack Tickets is deprecated and no longer part of agent guidance. Commands remain available only for compatibility and migration.", + }); // Parse command early to check if it's setup (which bypasses enable check) const invocation = parseTicketsInvocation({ argv: args.raw.argv }); const isSetupCommand = invocation.command === "setup"; diff --git a/src/commands/update.ts b/src/commands/update.ts index 3a025437..579f259e 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -11,6 +11,7 @@ import { ensureBundledMutagenInstalled, getMutagenPath, } from "../lib/mutagen.ts"; +import { findProjectContext } from "../lib/project.ts"; import { compareVersions, detectHackInstall, @@ -20,6 +21,7 @@ import { resolveUpdateTarget, selectCliTarballAsset, } from "../lib/self-update.ts"; +import { exec } from "../lib/shell.ts"; const optCheck = defineOption({ name: "check", @@ -228,6 +230,9 @@ const handleUpdate: CommandHandlerFor = async ({ } const mutagenProvision = await ensureMutagenAfterUpdate(); + const agentIntegrations = await syncAgentIntegrationsAfterUpdate({ + binaryPath: bin.path, + }); return writeResult({ json: args.options.json === true, @@ -241,8 +246,11 @@ const handleUpdate: CommandHandlerFor = async ({ binaryPath: bin.path, assetsDir, mutagen: mutagenProvision, + agentIntegrations, }, - human: `Updated to v${latestVersion}.`, + human: agentIntegrations.synced + ? `Updated to v${latestVersion}; refreshed ${agentIntegrations.scope === "all" ? "project and global" : "global"} agent integrations.` + : `Updated to v${latestVersion}. ${agentIntegrations.warning}`, }); }; @@ -287,6 +295,38 @@ type MutagenProvisionResult = { readonly warning?: string; }; +type AgentIntegrationUpdateResult = { + readonly synced: boolean; + readonly scope: "global" | "all"; + readonly warning?: string; +}; + +/** Refresh the newly installed CLI's generated rules before the old process exits. */ +async function syncAgentIntegrationsAfterUpdate(opts: { + readonly binaryPath: string; +}): Promise { + const project = await findProjectContext(process.cwd()); + const scope = project ? "all" : "global"; + const args = [ + opts.binaryPath, + "setup", + "sync", + ...(project ? ["--all-scopes"] : ["--global"]), + ]; + const result = await exec(args, { stdin: "ignore" }); + if (result.exitCode === 0) { + return { synced: true, scope }; + } + const detail = result.stderr.trim() || result.stdout.trim(); + return { + synced: false, + scope, + warning: detail + ? `Agent integration refresh failed: ${detail}` + : "Agent integration refresh failed; run hack setup sync --all-scopes.", + }; +} + async function ensureMutagenAfterUpdate(): Promise { const existing = getMutagenPath(); if (existing) { @@ -417,6 +457,7 @@ type UpdateOutput = readonly binaryPath: string; readonly assetsDir: string; readonly mutagen?: MutagenProvisionResult; + readonly agentIntegrations?: AgentIntegrationUpdateResult; } | { readonly ok: false; diff --git a/src/control-plane/extensions/tickets/agent-docs.ts b/src/control-plane/extensions/tickets/agent-docs.ts index 2cca5214..916a5020 100644 --- a/src/control-plane/extensions/tickets/agent-docs.ts +++ b/src/control-plane/extensions/tickets/agent-docs.ts @@ -17,7 +17,13 @@ export type TicketsAgentDocUpdateResult = { export type TicketsAgentDocCheckResult = { readonly target: TicketsAgentDocTarget; - readonly status: "present" | "missing" | "error"; + readonly status: + | "present" + | "missing" + | "noop" + | "absent" + | "deprecated" + | "error"; readonly path: string; readonly message?: string; }; @@ -61,6 +67,52 @@ export async function upsertTicketsAgentDocs(opts: { return results; } +/** Check that deprecated tickets-only instruction blocks are absent. */ +export async function checkDeprecatedTicketsAgentDocs(opts: { + readonly projectRoot: string; + readonly targets: readonly TicketsAgentDocTarget[]; +}): Promise { + const results: TicketsAgentDocCheckResult[] = []; + for (const target of opts.targets) { + const path = resolveTicketsAgentDocPath({ + projectRoot: opts.projectRoot, + target, + }); + try { + const existing = await readTextFile(path); + const hasStart = existing?.includes(DOC_MARKER_START) === true; + const hasEnd = existing?.includes(DOC_MARKER_END) === true; + if (!(hasStart || hasEnd)) { + results.push({ target, status: "absent", path }); + continue; + } + if (!(hasStart && hasEnd)) { + results.push({ + target, + status: "error", + path, + message: "Malformed deprecated Hack Tickets markers.", + }); + continue; + } + results.push({ + target, + status: "deprecated", + path, + message: `Deprecated Hack Tickets instructions remain at ${path}. Run: hack setup sync --all-scopes`, + }); + } catch (error: unknown) { + results.push({ + target, + status: "error", + path, + message: error instanceof Error ? error.message : "Failed to read file", + }); + } + } + return results; +} + export async function checkTicketsAgentDocs(opts: { readonly projectRoot: string; readonly targets: readonly TicketsAgentDocTarget[]; diff --git a/src/control-plane/extensions/tickets/commands.ts b/src/control-plane/extensions/tickets/commands.ts index 8fb205c1..eabaaba3 100644 --- a/src/control-plane/extensions/tickets/commands.ts +++ b/src/control-plane/extensions/tickets/commands.ts @@ -4,12 +4,11 @@ import { gumConfirm, isGumAvailable } from "../../../ui/gum.ts"; import { isTty } from "../../../ui/terminal.ts"; import type { ExtensionCommand, ExtensionCommandContext } from "../types.ts"; import { - checkTicketsAgentDocs, + checkDeprecatedTicketsAgentDocs, removeTicketsAgentDocs, type TicketsAgentDocCheckResult, type TicketsAgentDocRemoveResult, type TicketsAgentDocUpdateResult, - upsertTicketsAgentDocs, } from "./agent-docs.ts"; import { isTicketDocumentKind, @@ -32,8 +31,7 @@ import { } from "./store.ts"; import { createGitTicketsChannel } from "./tickets-git-channel.ts"; import { - checkTicketsSkill, - installTicketsSkill, + checkDeprecatedTicketsSkill, removeTicketsSkill, } from "./tickets-skill.ts"; import { normalizeTicketRef, normalizeTicketRefs } from "./util.ts"; @@ -41,11 +39,12 @@ import { normalizeTicketRef, normalizeTicketRefs } from "./util.ts"; const TICKET_REF_SEPARATOR_PATTERN = /[,\s]+/; let didPromptTicketsGitHealth = false; +let didWarnTicketsDeprecation = false; export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ { name: "setup", - summary: "Install tickets integrations (skill + agent docs)", + summary: "Deprecated: remove Tickets agent integrations and repair storage", scope: "project", // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Tickets setup intentionally coordinates repo, skill, and docs repair in one CLI flow. handler: async ({ ctx, args }) => { @@ -54,12 +53,6 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ return 1; } - // Enable the extension in project config if not already enabled - await ensureTicketsExtensionEnabled({ - projectDir: ctx.project.projectDir, - logger: ctx.logger, - }); - const parsed = parseTicketsSetupArgs({ args }); if (!parsed.ok) { ctx.logger.error({ message: parsed.error }); @@ -138,20 +131,15 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ } } - let skill: Awaited>; + let skill: Awaited>; const skillProjectRoot = scope === "project" ? projectRoot : undefined; if (action === "check") { - skill = await checkTicketsSkill({ - scope, - projectRoot: skillProjectRoot, - }); - } else if (action === "remove") { - skill = await removeTicketsSkill({ + skill = await checkDeprecatedTicketsSkill({ scope, projectRoot: skillProjectRoot, }); } else { - skill = await installTicketsSkill({ + skill = await removeTicketsSkill({ scope, projectRoot: skillProjectRoot, }); @@ -162,17 +150,12 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ | TicketsAgentDocRemoveResult[] | TicketsAgentDocUpdateResult[]; if (action === "check") { - docs = await checkTicketsAgentDocs({ - projectRoot, - targets: resolvedTargets, - }); - } else if (action === "remove") { - docs = await removeTicketsAgentDocs({ + docs = await checkDeprecatedTicketsAgentDocs({ projectRoot, targets: resolvedTargets, }); } else { - docs = await upsertTicketsAgentDocs({ + docs = await removeTicketsAgentDocs({ projectRoot, targets: resolvedTargets, }); @@ -186,9 +169,10 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ } await display.panel({ - title: "Tickets setup", - tone: "success", + title: "Tickets deprecated", + tone: "warn", lines: [ + "Agent skills and instruction blocks are no longer installed.", `skill: ${skill.status} (${skill.path})`, ...docs.map((r) => `${r.target}: ${r.status} (${r.path})`), `repo.gitignore: ${repoGitignore.status} (${repoGitignore.path})`, @@ -202,7 +186,13 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ await maybeEnsureTicketsGitHealth({ ctx, json: parsed.value.json }); } - return docs.some((r) => r.status === "error") || skill.status === "error" + const deprecatedFound = + action === "check" && + (skill.status === "deprecated" || + docs.some((result) => result.status === "deprecated")); + return docs.some((r) => r.status === "error") || + skill.status === "error" || + deprecatedFound ? 1 : 0; }, @@ -1237,8 +1227,6 @@ type MutableTicketsSetupArgs = { type TicketsSetupNeeds = { readonly needsGitignore: boolean; readonly needsUntrack: boolean; - readonly needsSkill: boolean; - readonly needsDocs: boolean; }; type TicketDetailResult = Awaited< @@ -2093,16 +2081,17 @@ async function maybeEnsureTicketsSetup(opts: { return; } + if (!didWarnTicketsDeprecation) { + didWarnTicketsDeprecation = true; + opts.ctx.logger.warn({ + message: + "Hack Tickets is deprecated. Agent skills and instruction blocks are no longer installed; use this command only for compatibility or migration.", + }); + } + const projectRoot = opts.ctx.project.projectRoot; const repoState = await checkTicketsRepoState({ projectRoot }); - - const skill = await checkTicketsSkill({ scope: "project", projectRoot }); - const docs = await checkTicketsAgentDocs({ - projectRoot, - targets: ["agents", "claude"], - }); - - const needs = getTicketsSetupNeeds({ repoState, skill, docs }); + const needs = getTicketsSetupNeeds({ repoState }); if (!hasIncompleteTicketsSetup({ needs })) { return; } @@ -2227,29 +2216,17 @@ async function maybeEnsureTicketsGitHealth(opts: { function getTicketsSetupNeeds(opts: { readonly repoState: Awaited>; - readonly skill: Awaited>; - readonly docs: readonly TicketsAgentDocCheckResult[]; }): TicketsSetupNeeds { return { needsGitignore: opts.repoState.gitignore.status === "missing", needsUntrack: opts.repoState.tracked.status === "tracked", - needsSkill: - opts.skill.status === "missing" || opts.skill.status === "error", - needsDocs: opts.docs.some( - (doc) => doc.status === "missing" || doc.status === "error" - ), }; } function hasIncompleteTicketsSetup(opts: { readonly needs: TicketsSetupNeeds; }): boolean { - return ( - opts.needs.needsGitignore || - opts.needs.needsUntrack || - opts.needs.needsSkill || - opts.needs.needsDocs - ); + return opts.needs.needsGitignore || opts.needs.needsUntrack; } function buildTicketsSetupNotices(opts: { @@ -2262,9 +2239,6 @@ function buildTicketsSetupNotices(opts: { if (opts.needs.needsUntrack) { notices.push("untrack .hack/tickets from main branch"); } - if (opts.needs.needsSkill || opts.needs.needsDocs) { - notices.push("run tickets setup"); - } return notices; } @@ -2288,24 +2262,6 @@ async function repairTicketsSetup(opts: { ); } - if (opts.needs.needsSkill) { - const installed = await installTicketsSkill({ - scope: "project", - projectRoot: opts.projectRoot, - }); - lines.push(`skill: ${installed.status} (${installed.path})`); - } - - if (opts.needs.needsDocs) { - const updatedDocs = await upsertTicketsAgentDocs({ - projectRoot: opts.projectRoot, - targets: ["agents", "claude"], - }); - lines.push( - ...updatedDocs.map((doc) => `${doc.target}: ${doc.status} (${doc.path})`) - ); - } - return lines; } @@ -2456,62 +2412,3 @@ function applyTicketsSetupToken(opts: { return { ok: false, error: `Unknown option: ${opts.token}` }; } - -/** - * Ensures the tickets extension is enabled in the project config. - * Reads the project's hack.config.json and adds the extension enabled flag if missing. - */ -async function ensureTicketsExtensionEnabled(opts: { - readonly projectDir: string; - readonly logger: ExtensionCommandContext["logger"]; -}): Promise { - const { resolve } = await import("node:path"); - const { readTextFile, writeTextFileIfChanged } = await import( - "../../../lib/fs.ts" - ); - const { isRecord } = await import("../../../lib/guards.ts"); - - const configPath = resolve(opts.projectDir, "hack.config.json"); - const text = await readTextFile(configPath); - if (text === null) { - return; - } - - let config: Record; - try { - const parsed = JSON.parse(text); - if (!isRecord(parsed)) { - return; - } - config = parsed; - } catch { - return; - } - - const controlPlane = isRecord(config.controlPlane) - ? config.controlPlane - : ({} as Record); - const extensions = isRecord(controlPlane.extensions) - ? controlPlane.extensions - : ({} as Record); - const ticketsConfig = isRecord(extensions["dance.hack.tickets"]) - ? extensions["dance.hack.tickets"] - : ({} as Record); - - if (ticketsConfig.enabled === true) { - return; - } - - ticketsConfig.enabled = true; - extensions["dance.hack.tickets"] = ticketsConfig; - controlPlane.extensions = extensions; - config.controlPlane = controlPlane; - - const nextText = `${JSON.stringify(config, null, 2)}\n`; - const result = await writeTextFileIfChanged(configPath, nextText); - if (result.changed) { - opts.logger.success({ - message: `Enabled dance.hack.tickets in ${configPath}`, - }); - } -} diff --git a/src/control-plane/extensions/tickets/extension.ts b/src/control-plane/extensions/tickets/extension.ts index 28d67be8..896d632e 100644 --- a/src/control-plane/extensions/tickets/extension.ts +++ b/src/control-plane/extensions/tickets/extension.ts @@ -7,7 +7,7 @@ export const TICKETS_EXTENSION: ExtensionDefinition = { version: "0.1.0", scopes: ["project"], cliNamespace: "tickets", - summary: "Git-backed tickets and runs", + summary: "Deprecated git-backed Tickets compatibility surface", }, commands: TICKETS_COMMANDS, }; diff --git a/src/control-plane/extensions/tickets/tickets-skill.ts b/src/control-plane/extensions/tickets/tickets-skill.ts index 601feff2..edb1b1c6 100644 --- a/src/control-plane/extensions/tickets/tickets-skill.ts +++ b/src/control-plane/extensions/tickets/tickets-skill.ts @@ -18,6 +18,8 @@ export type TicketsSkillResult = { | "created" | "updated" | "noop" + | "absent" + | "deprecated" | "removed" | "missing" | "error"; @@ -83,6 +85,25 @@ export async function checkTicketsSkill(opts: { return { scope: opts.scope, status: hasMarker ? "noop" : "error", path }; } +/** Check that the deprecated tickets skill is absent from an agent scope. */ +export async function checkDeprecatedTicketsSkill(opts: { + readonly scope: TicketsSkillScope; + readonly projectRoot?: string; +}): Promise { + const result = await checkTicketsSkill(opts); + if (result.status === "missing") { + return { ...result, status: "absent" }; + } + if (result.status === "noop") { + return { + ...result, + status: "deprecated", + message: `Deprecated hack-tickets skill is still installed at ${result.path}. Run: hack setup sync --all-scopes`, + }; + } + return result; +} + export async function removeTicketsSkill(opts: { readonly scope: TicketsSkillScope; readonly projectRoot?: string; diff --git a/src/lib/cli-result.ts b/src/lib/cli-result.ts index af3c0e65..0d4a4bd5 100644 --- a/src/lib/cli-result.ts +++ b/src/lib/cli-result.ts @@ -21,6 +21,8 @@ export type HackErrorCode = | "E_PROJECT_NOT_FOUND" | "E_SERVICE_NOT_FOUND" | "E_COMPOSE_FAILED" + | "E_STARTUP_INCOMPLETE" + | "E_STARTUP_TIMEOUT" | "E_LIFECYCLE_FAILED" | "E_ENV_KEY_MISSING" | "E_PERMISSION" diff --git a/src/lib/compose-startup-state.ts b/src/lib/compose-startup-state.ts new file mode 100644 index 00000000..ed2bd93a --- /dev/null +++ b/src/lib/compose-startup-state.ts @@ -0,0 +1,44 @@ +export type ComposeServiceState = { + readonly service: string; + readonly state: string; + readonly exitCode: number | null; +}; + +export type ComposeStartupState = { + readonly running: readonly string[]; + readonly completed: readonly string[]; + readonly failed: readonly string[]; +}; + +/** Classify Compose services without treating successful one-shot containers as failures. */ +export function classifyComposeStartupState( + states: readonly ComposeServiceState[] +): ComposeStartupState { + const running: string[] = []; + const completed: string[] = []; + const failed: string[] = []; + + for (const entry of states) { + if (entry.state === "running") { + running.push(entry.service); + continue; + } + if (entry.state === "exited" && entry.exitCode === 0) { + completed.push(entry.service); + continue; + } + failed.push(entry.service); + } + + return { running, completed, failed }; +} + +export function buildStartupIncompleteMessage(opts: { + readonly composeProject: string; + readonly failed: readonly string[]; +}): string { + if (opts.failed.length === 0) { + return `Startup incomplete for ${opts.composeProject}: Compose reported no services after startup`; + } + return `Startup incomplete for ${opts.composeProject}: ${[...opts.failed].sort().join(", ")} did not reach running or successful completion`; +} diff --git a/src/lib/dependency-cache.ts b/src/lib/dependency-cache.ts new file mode 100644 index 00000000..42f671ff --- /dev/null +++ b/src/lib/dependency-cache.ts @@ -0,0 +1,225 @@ +import { createHash } from "node:crypto"; +import { basename, resolve } from "node:path"; +import { YAML } from "bun"; +import { + ensureDir, + pathExists, + readTextFile, + writeTextFileIfChanged, +} from "./fs.ts"; +import { isRecord } from "./guards.ts"; + +const CACHE_VOLUME_LABEL = "hack.dependencies.cache-volume"; +const LOCKFILES_LABEL = "hack.dependencies.lockfiles"; +const RUNTIME_FILES_LABEL = "hack.dependencies.runtime-files"; +const DEFAULT_LOCKFILES = [ + "bun.lock", + "bun.lockb", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "uv.lock", + "poetry.lock", + "Cargo.lock", + "go.sum", +] as const; +const DEFAULT_RUNTIME_FILES = [ + "package.json", + ".mise.toml", + "mise.toml", + ".tool-versions", + ".node-version", + ".nvmrc", +] as const; + +type DependencyCacheDeclaration = { + readonly service: string; + readonly volume: string; + readonly lockfiles: readonly string[]; + readonly runtimeFiles: readonly string[]; +}; + +export type DependencyCacheResolution = { + readonly overridePath: string | null; + readonly fingerprint: string | null; + readonly volumes: readonly { + readonly logicalName: string; + readonly resolvedName: string; + readonly services: readonly string[]; + }[]; + readonly inputs: readonly string[]; +}; + +function parseCsv(value: unknown): readonly string[] { + if (typeof value !== "string") { + return []; + } + return [ + ...new Set( + value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) + ), + ]; +} + +function normalizeLabels(value: unknown): Readonly> { + if (isRecord(value)) { + return value; + } + if (!Array.isArray(value)) { + return {}; + } + const labels: Record = {}; + for (const entry of value) { + if (typeof entry !== "string") { + continue; + } + const separator = entry.indexOf("="); + if (separator > 0) { + labels[entry.slice(0, separator)] = entry.slice(separator + 1); + } + } + return labels; +} + +function parseDeclarations( + parsed: unknown +): readonly DependencyCacheDeclaration[] { + if (!(isRecord(parsed) && isRecord(parsed.services))) { + return []; + } + const declarations: DependencyCacheDeclaration[] = []; + for (const [service, rawService] of Object.entries(parsed.services)) { + if (!isRecord(rawService)) { + continue; + } + const labels = normalizeLabels(rawService.labels); + const volume = labels[CACHE_VOLUME_LABEL]; + if (typeof volume !== "string" || volume.trim().length === 0) { + continue; + } + declarations.push({ + service, + volume: volume.trim(), + lockfiles: parseCsv(labels[LOCKFILES_LABEL]), + runtimeFiles: parseCsv(labels[RUNTIME_FILES_LABEL]), + }); + } + return declarations; +} + +async function resolveExistingInputs(opts: { + readonly projectRoot: string; + readonly declarations: readonly DependencyCacheDeclaration[]; +}): Promise { + const configuredLockfiles = opts.declarations.flatMap( + (entry) => entry.lockfiles + ); + const configuredRuntimeFiles = opts.declarations.flatMap( + (entry) => entry.runtimeFiles + ); + const candidates = [ + ...(configuredLockfiles.length > 0 + ? configuredLockfiles + : DEFAULT_LOCKFILES), + ...(configuredRuntimeFiles.length > 0 + ? configuredRuntimeFiles + : DEFAULT_RUNTIME_FILES), + ]; + const paths: string[] = []; + for (const candidate of [...new Set(candidates)]) { + const path = resolve(opts.projectRoot, candidate); + if (await pathExists(path)) { + paths.push(path); + } + } + return paths.sort((left, right) => left.localeCompare(right)); +} + +async function fingerprintFiles(opts: { + readonly projectRoot: string; + readonly files: readonly string[]; +}): Promise { + const hash = createHash("sha256"); + for (const file of opts.files) { + hash.update(file.slice(opts.projectRoot.length)); + hash.update("\0"); + hash.update((await readTextFile(file)) ?? ""); + hash.update("\0"); + } + return hash.digest("hex").slice(0, 16); +} + +function sanitizeVolumeSegment(value: string): string { + return value + .toLowerCase() + .replaceAll(/[^a-z0-9_.-]/g, "-") + .replaceAll(/-+/g, "-"); +} + +export async function resolveDependencyCacheOverride(opts: { + readonly projectRoot: string; + readonly projectDir: string; + readonly projectName: string; + readonly composeFile: string; +}): Promise { + const composeText = await readTextFile(opts.composeFile); + if (!composeText) { + return { overridePath: null, fingerprint: null, volumes: [], inputs: [] }; + } + let parsed: unknown; + try { + parsed = YAML.parse(composeText); + } catch { + return { overridePath: null, fingerprint: null, volumes: [], inputs: [] }; + } + const declarations = parseDeclarations(parsed); + if (declarations.length === 0) { + return { overridePath: null, fingerprint: null, volumes: [], inputs: [] }; + } + const inputs = await resolveExistingInputs({ + projectRoot: opts.projectRoot, + declarations, + }); + if (inputs.length === 0) { + return { overridePath: null, fingerprint: null, volumes: [], inputs: [] }; + } + const fingerprint = await fingerprintFiles({ + projectRoot: opts.projectRoot, + files: inputs, + }); + const grouped = new Map>(); + for (const declaration of declarations) { + const services = grouped.get(declaration.volume) ?? new Set(); + services.add(declaration.service); + grouped.set(declaration.volume, services); + } + const volumes = [...grouped.entries()].map(([logicalName, services]) => ({ + logicalName, + resolvedName: [ + "hack-cache", + sanitizeVolumeSegment(opts.projectName), + sanitizeVolumeSegment(basename(logicalName)), + fingerprint, + ].join("-"), + services: [...services].sort((left, right) => left.localeCompare(right)), + })); + const override = { + volumes: Object.fromEntries( + volumes.map((volume) => [ + volume.logicalName, + { name: volume.resolvedName }, + ]) + ), + }; + const internalDir = resolve(opts.projectDir, ".internal"); + await ensureDir(internalDir); + const overridePath = resolve( + internalDir, + "compose.dependencies.override.yml" + ); + await writeTextFileIfChanged(overridePath, YAML.stringify(override)); + return { overridePath, fingerprint, volumes, inputs }; +} diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index 34ea561e..02c1ad47 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -98,6 +98,9 @@ export type ProjectEnvResolvedConfig = { readonly files: readonly string[]; readonly globalEnv: Readonly>; readonly hostEnv: Readonly>; + readonly hostTargetEnv: Readonly< + Record>> + >; readonly serviceEnv: Readonly< Record>> >; @@ -136,15 +139,10 @@ export function selectProjectEnvValuesForExecutionTarget(opts: { if (opts.target !== "host") { return selected; } - - const hostOverrides = opts.resolved.hostEnv; - if (!hostOverrides) { - return selected; - } - return { - ...selected, - ...hostOverrides, - }; + const scopeName = normalizeProjectEnvScopeName({ + scopeName: opts.scopeName, + }); + return { ...(opts.resolved.hostTargetEnv[scopeName] ?? selected) }; } export function isValidProjectEnvScopeName(opts: { @@ -780,21 +778,23 @@ export async function resolveProjectEnvConfig(opts: { ); } + const envLayers = [ + defaultRead.exists ? defaultRead.config : null, + overlayRead?.exists ? overlayRead.config : null, + localDefaultRead.exists ? localDefaultRead.config : null, + localOverlayRead?.exists ? localOverlayRead.config : null, + ]; const merged = mergeProjectEnvConfigLayers({ - layers: [ - defaultRead.exists ? defaultRead.config : null, - overlayRead?.exists ? overlayRead.config : null, - localDefaultRead.exists ? localDefaultRead.config : null, - localOverlayRead?.exists ? localOverlayRead.config : null, - ], + layers: envLayers, environment: selection.effectiveEnv ?? "default", }); const keyText = await resolveProjectEnvKey({ projectRoot: opts.projectRoot, required: hasSecretEntries({ config: merged }), }); - const globalEnv = resolveProjectEnvScopeValues({ - values: merged.values.global ?? {}, + const globalEnv = resolveLayeredProjectEnvValuesForScopes({ + layers: envLayers, + scopeNames: ["global"], keyText, }); @@ -807,8 +807,9 @@ export async function resolveProjectEnvConfig(opts: { ); const hostEnv = hostScopeConflictsWithService ? {} - : resolveProjectEnvScopeValues({ - values: merged.values[PROJECT_ENV_HOST_SCOPE] ?? {}, + : resolveLayeredProjectEnvValuesForScopes({ + layers: envLayers, + scopeNames: [PROJECT_ENV_HOST_SCOPE], keyText, }); const unknownScopes = declaredScopes @@ -821,16 +822,30 @@ export async function resolveProjectEnvConfig(opts: { ...declaredScopes.filter((scope) => scope !== "global"), ]); const serviceEnv: Record> = {}; + const hostTargetEnv: Record> = {}; for (const serviceName of serviceSet) { - const scopedValues = resolveProjectEnvScopeValues({ - values: merged.values[serviceName] ?? {}, + const composeScopeNames = + serviceName === "global" ? ["global"] : ["global", serviceName]; + serviceEnv[serviceName] = resolveLayeredProjectEnvValuesForScopes({ + layers: envLayers, + scopeNames: composeScopeNames, + keyText, + }); + hostTargetEnv[serviceName] = resolveLayeredProjectEnvValuesForScopes({ + layers: envLayers, + scopeNames: hostScopeConflictsWithService + ? composeScopeNames + : [...composeScopeNames, PROJECT_ENV_HOST_SCOPE], keyText, }); - serviceEnv[serviceName] = { - ...globalEnv, - ...scopedValues, - }; } + hostTargetEnv.global = resolveLayeredProjectEnvValuesForScopes({ + layers: envLayers, + scopeNames: hostScopeConflictsWithService + ? ["global"] + : ["global", PROJECT_ENV_HOST_SCOPE], + keyText, + }); const files = [selection.defaultPath]; if (selection.overlayPath && overlayRead?.exists) { @@ -849,6 +864,7 @@ export async function resolveProjectEnvConfig(opts: { files, globalEnv, hostEnv, + hostTargetEnv, serviceEnv, declaredScopes, unknownScopes, @@ -895,6 +911,35 @@ function resolveProjectEnvScopeValues(opts: { return out; } +/** + * Resolve env precedence by source layer first, then target specificity within + * each layer. A named overlay's global value therefore overrides a base-file + * host/service value, while an overlay host/service value still overrides the + * overlay global value. + */ +function resolveLayeredProjectEnvValuesForScopes(opts: { + readonly layers: readonly (ProjectEnvConfig | null)[]; + readonly scopeNames: readonly string[]; + readonly keyText: string | null; +}): Record { + const out: Record = {}; + for (const layer of opts.layers) { + if (!layer) { + continue; + } + for (const scopeName of opts.scopeNames) { + Object.assign( + out, + resolveProjectEnvScopeValues({ + values: layer.values[scopeName] ?? {}, + keyText: opts.keyText, + }) + ); + } + } + return out; +} + function hasSecretEntries(opts: { readonly config: ProjectEnvConfig; }): boolean { diff --git a/src/lib/project-runtime-hygiene.ts b/src/lib/project-runtime-hygiene.ts index a8d6e41e..537ce321 100644 --- a/src/lib/project-runtime-hygiene.ts +++ b/src/lib/project-runtime-hygiene.ts @@ -16,6 +16,13 @@ export type OrphanedRuntimeProject = { readonly containerIds: readonly string[]; }; +export type IncompleteRuntimeProject = { + readonly project: string; + readonly workingDir: string | null; + readonly createdServices: readonly string[]; + readonly containerIds: readonly string[]; +}; + export function scopeRuntimeHygieneToProject(input: { readonly projectRoot: string; readonly projectDir: string; @@ -97,6 +104,49 @@ export async function findOrphanRuntimeProjects(input: { return out; } +/** Find Compose projects with regular service containers left in the pre-start Created state. */ +export function findIncompleteRuntimeProjects(input: { + readonly runtime: readonly RuntimeProject[]; +}): IncompleteRuntimeProject[] { + const incomplete: IncompleteRuntimeProject[] = []; + for (const project of input.runtime) { + const createdServices = [...project.services.values()] + .filter((service) => + service.containers.some( + (container) => + container.state.trim().toLowerCase() === "created" && + container.labels?.["hack.lifecycle.process"] !== "true" + ) + ) + .map((service) => service.service) + .sort((left, right) => left.localeCompare(right)); + const containerIds = [...project.services.values()] + .flatMap((service) => + service.containers + .filter( + (container) => + container.state.trim().toLowerCase() === "created" && + container.labels?.["hack.lifecycle.process"] !== "true" + ) + .map((container) => container.id) + ) + .filter((id) => id.length > 0) + .sort((left, right) => left.localeCompare(right)); + if (createdServices.length === 0) { + continue; + } + incomplete.push({ + project: project.project, + workingDir: project.workingDir, + createdServices, + containerIds, + }); + } + return incomplete.sort((left, right) => + left.project.localeCompare(right.project) + ); +} + function collectContainerIds(project: RuntimeProject): readonly string[] { const out: string[] = []; for (const service of project.services.values()) { diff --git a/src/lib/registry-credential-preflight.ts b/src/lib/registry-credential-preflight.ts new file mode 100644 index 00000000..ecb08d07 --- /dev/null +++ b/src/lib/registry-credential-preflight.ts @@ -0,0 +1,137 @@ +import { resolve } from "node:path"; +import { YAML } from "bun"; +import { readTextFile } from "./fs.ts"; +import { isRecord } from "./guards.ts"; + +const REGISTRY_CONFIG_FILES = [".npmrc", ".yarnrc.yml", "bunfig.toml"] as const; +const ENV_REFERENCE_PATTERN = /\$\{([A-Z_][A-Z0-9_]*)\}/g; +const DEPENDENCY_INSTALL_PATTERN = + /(?:^|\s)(?:bun|npm|pnpm|yarn)(?:\s+run)?\s+(?:install|ci)(?:\s|$)/i; + +export type RegistryCredentialReference = { + readonly key: string; + readonly path: string; + readonly line: number; +}; + +export class RegistryCredentialPreflightError extends Error { + readonly missing: readonly RegistryCredentialReference[]; + + constructor(opts: { + readonly missing: readonly RegistryCredentialReference[]; + }) { + super(formatRegistryCredentialPreflightFailure({ missing: opts.missing })); + this.name = "RegistryCredentialPreflightError"; + this.missing = opts.missing; + } +} + +export async function discoverRegistryCredentialReferences(opts: { + readonly projectRoot: string; +}): Promise { + const references: RegistryCredentialReference[] = []; + for (const filename of REGISTRY_CONFIG_FILES) { + const path = resolve(opts.projectRoot, filename); + const text = await readTextFile(path); + if (text === null) { + continue; + } + for (const [index, line] of text.split("\n").entries()) { + for (const match of line.matchAll(ENV_REFERENCE_PATTERN)) { + const key = match[1]; + if (key) { + references.push({ key, path, line: index + 1 }); + } + } + } + } + return references; +} + +function serializeComposeCommand(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (Array.isArray(value)) { + return value.filter((entry) => typeof entry === "string").join(" "); + } + return ""; +} + +function hasDependencyBootstrapLabel(value: unknown): boolean { + if (isRecord(value)) { + return ( + value["hack.dependencies.bootstrap"] === true || + value["hack.dependencies.bootstrap"] === "true" + ); + } + if (!Array.isArray(value)) { + return false; + } + return value.some( + (entry) => + typeof entry === "string" && + entry.trim().toLowerCase() === "hack.dependencies.bootstrap=true" + ); +} + +export async function discoverDependencyBootstrapServices(opts: { + readonly composeFile: string; +}): Promise { + const text = await readTextFile(opts.composeFile); + if (!text) { + return []; + } + try { + const parsed: unknown = YAML.parse(text); + const services = + isRecord(parsed) && isRecord(parsed.services) ? parsed.services : null; + if (!services) { + return []; + } + return Object.entries(services) + .filter(([, value]) => { + if (!isRecord(value)) { + return false; + } + const command = `${serializeComposeCommand(value.entrypoint)} ${serializeComposeCommand(value.command)}`; + return ( + hasDependencyBootstrapLabel(value.labels) || + DEPENDENCY_INSTALL_PATTERN.test(command) + ); + }) + .map(([service]) => service) + .sort((left, right) => left.localeCompare(right)); + } catch { + return []; + } +} + +export async function preflightRegistryCredentials(opts: { + readonly projectRoot: string; + readonly env: Readonly>; +}): Promise<{ + readonly references: readonly RegistryCredentialReference[]; + readonly missing: readonly RegistryCredentialReference[]; +}> { + const references = await discoverRegistryCredentialReferences({ + projectRoot: opts.projectRoot, + }); + return { + references, + missing: references.filter((reference) => { + const value = opts.env[reference.key]; + return typeof value !== "string" || value.length === 0; + }), + }; +} + +export function formatRegistryCredentialPreflightFailure(opts: { + readonly missing: readonly RegistryCredentialReference[]; +}): string { + const unique = [...new Set(opts.missing.map((reference) => reference.key))]; + const locations = opts.missing + .map((reference) => `${reference.path}:${reference.line}`) + .join(", "); + return `Missing package-registry credential${unique.length === 1 ? "" : "s"}: ${unique.join(", ")}. Referenced by ${locations}. Add the value to the selected Hack env overlay before startup.`; +} diff --git a/src/lib/shell.ts b/src/lib/shell.ts index 9b54aaed..bd82469a 100644 --- a/src/lib/shell.ts +++ b/src/lib/shell.ts @@ -8,6 +8,7 @@ export interface ExecOptions { readonly cwd?: string; readonly env?: Record; readonly stdin?: "inherit" | "pipe" | "ignore"; + readonly timeoutMs?: number; } /** @@ -41,14 +42,21 @@ export async function exec( stdin: opts.stdin ?? "inherit", stdout: "pipe", stderr: "pipe", + detached: opts.timeoutMs !== undefined, + }); + + const timeout = installSubprocessTimeout({ + pid: proc.pid, + timeoutMs: opts.timeoutMs, }); const stdoutText = await streamToText(proc.stdout); const stderrText = await streamToText(proc.stderr); const exitCode = await proc.exited; + timeout.dispose(); return { - exitCode, + exitCode: timeout.didTimeout() ? 124 : exitCode, stdout: stdoutText, stderr: stderrText, }; @@ -64,6 +72,7 @@ export interface RunOptions { * envelope while subprocess output remains visible to humans. */ readonly stdout?: "inherit" | "stderr"; + readonly timeoutMs?: number; } export async function run( @@ -76,9 +85,51 @@ export async function run( stdin: opts.stdin ?? "inherit", stdout: opts.stdout === "stderr" ? 2 : "inherit", stderr: "inherit", + detached: opts.timeoutMs !== undefined, + }); + const timeout = installSubprocessTimeout({ + pid: proc.pid, + timeoutMs: opts.timeoutMs, }); + const exitCode = await proc.exited; + timeout.dispose(); + return timeout.didTimeout() ? 124 : exitCode; +} - return await proc.exited; +function installSubprocessTimeout(opts: { + readonly pid: number; + readonly timeoutMs: number | undefined; +}): { readonly dispose: () => void; readonly didTimeout: () => boolean } { + let timedOut = false; + if (opts.timeoutMs === undefined) { + return { dispose: () => undefined, didTimeout: () => false }; + } + let forceKillTimer: ReturnType | null = null; + const signalProcessGroup = (signal: NodeJS.Signals): void => { + try { + process.kill(-opts.pid, signal); + } catch { + try { + process.kill(opts.pid, signal); + } catch { + return; + } + } + }; + const timer = setTimeout(() => { + timedOut = true; + signalProcessGroup("SIGTERM"); + forceKillTimer = setTimeout(() => signalProcessGroup("SIGKILL"), 2000); + }, opts.timeoutMs); + return { + dispose: () => { + clearTimeout(timer); + if (forceKillTimer) { + clearTimeout(forceKillTimer); + } + }, + didTimeout: () => timedOut, + }; } async function streamToText( diff --git a/src/lib/worktree-runtime-target.ts b/src/lib/worktree-runtime-target.ts new file mode 100644 index 00000000..40c20a57 --- /dev/null +++ b/src/lib/worktree-runtime-target.ts @@ -0,0 +1,86 @@ +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; + +import type { RuntimeProject } from "./runtime-projects.ts"; + +const RELEVANT_RUNTIME_STATES = new Set([ + "created", + "paused", + "restarting", + "running", +]); + +export type SameCheckoutRetargetConflict = { + readonly composeProject: string; + readonly states: readonly string[]; +}; + +/** Find non-terminal instances from the same checkout that differ from an auto-derived target. */ +export function findSameCheckoutRetargetConflicts(opts: { + readonly currentProjectDir: string; + readonly targetComposeProject: string; + readonly runtime: readonly RuntimeProject[]; +}): readonly SameCheckoutRetargetConflict[] { + const currentProjectDir = canonicalPath(opts.currentProjectDir); + const conflicts: SameCheckoutRetargetConflict[] = []; + + for (const project of opts.runtime) { + if ( + project.project === opts.targetComposeProject || + project.workingDir === null || + canonicalPath(project.workingDir) !== currentProjectDir + ) { + continue; + } + + const states = collectRelevantStates({ project }); + if (states.length === 0) { + continue; + } + conflicts.push({ composeProject: project.project, states }); + } + + return conflicts.sort((left, right) => + left.composeProject.localeCompare(right.composeProject) + ); +} + +export function buildWorktreeRetargetWarning(opts: { + readonly targetComposeProject: string; + readonly conflicts: readonly SameCheckoutRetargetConflict[]; +}): string | null { + if (opts.conflicts.length === 0) { + return null; + } + const existing = opts.conflicts + .map( + (conflict) => + `"${conflict.composeProject}" (${conflict.states.join(", ")})` + ) + .join(", "); + return `This worktree already owns ${existing}; auto-targeting new instance "${opts.targetComposeProject}". Pass --branch to target an existing instance explicitly.`; +} + +function collectRelevantStates(opts: { + readonly project: RuntimeProject; +}): readonly string[] { + const states = new Set(); + for (const service of opts.project.services.values()) { + for (const container of service.containers) { + const state = container.state.trim().toLowerCase(); + if (RELEVANT_RUNTIME_STATES.has(state)) { + states.add(state); + } + } + } + return [...states].sort((left, right) => left.localeCompare(right)); +} + +function canonicalPath(path: string): string { + const resolved = resolve(path); + try { + return realpathSync.native(resolved); + } catch { + return resolved; + } +} diff --git a/tests/agent-instruction-source.test.ts b/tests/agent-instruction-source.test.ts index dfddb77e..201dd1bb 100644 --- a/tests/agent-instruction-source.test.ts +++ b/tests/agent-instruction-source.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -16,6 +17,7 @@ import { INSTRUCTION_SECTIONS, type InstructionSurface, } from "../src/agents/instruction-source.ts"; +import { HACK_AGENT_INTEGRATION_CONTENT_REVISION } from "../src/agents/integration-revision.ts"; import { renderAgentPrimer } from "../src/agents/primer.ts"; import { CLI_SPEC } from "../src/cli/spec.ts"; import { @@ -99,23 +101,40 @@ test("no stale command patterns in any surface", () => { } }); -test("tickets appear at most once, only as an optional extension", () => { +test("deprecated tickets do not appear in generated agent guidance", () => { for (const [surface, rendered] of Object.entries(RENDERED_SURFACES)) { - const ticketLines = rendered - .split("\n") - // The gitignore pattern literal `tickets/` (managed-files section) is - // a filename, not tickets promotion — the thing this test guards. - .map((line) => line.replaceAll("`tickets/`", "")) - .filter((line) => /ticket/i.test(line)); expect( - ticketLines.length, - `surface "${surface}" mentions tickets more than once` - ).toBeLessThanOrEqual(1); - const only = ticketLines[0]; - if (only !== undefined) { - expect(only).toContain("hack tickets"); - expect(only).toContain("only use it when the project explicitly uses it"); - } + rendered, + `surface "${surface}" mentions deprecated Tickets` + ).not.toMatch(/hack[ -]?tickets|dance\.hack\.tickets/i); + } +}); + +test("all generated surfaces expose integration freshness and repair upfront", () => { + for (const [surface, rendered] of Object.entries(RENDERED_SURFACES)) { + expect(rendered, `surface "${surface}" lacks freshness status`).toContain( + "Integration freshness" + ); + expect(rendered).toContain("hack setup sync --all-scopes --check"); + expect(rendered).toContain("hack setup sync --all-scopes"); + expect(rendered).toContain("reload the agent session"); + } +}); + +test("agent integration content revision changes with canonical guidance", () => { + const revision = createHash("sha256") + .update( + JSON.stringify( + INSTRUCTION_SECTIONS.filter((section) => section.id !== "freshness") + ) + ) + .digest("hex") + .slice(0, 12); + expect(HACK_AGENT_INTEGRATION_CONTENT_REVISION).toBe(revision); + for (const rendered of Object.values(RENDERED_SURFACES)) { + expect(rendered).toContain( + `Content revision: \`${HACK_AGENT_INTEGRATION_CONTENT_REVISION}\`` + ); } }); diff --git a/tests/cli-help.test.ts b/tests/cli-help.test.ts index 41e74efe..b2b9c2af 100644 --- a/tests/cli-help.test.ts +++ b/tests/cli-help.test.ts @@ -15,7 +15,7 @@ test("root help leads with the local-first offer and new command grouping", () = expect(help).toContain("Unsupported experimental:"); expect(help).toContain("Extension commands:"); expect(help).toMatch( - /hack tickets(?: \[args\.\.\.\])?\s+Track repo-local work without leaving git/ + /hack tickets(?: \[args\.\.\.\])?\s+Deprecated: legacy repo-local Tickets compatibility commands/ ); expect(help).toMatch( /hack auth(?: \[args\.\.\.\])?\s+Removed: Hack account sign-in no longer ships with the local-first CLI/ @@ -39,6 +39,9 @@ test("markdown help preserves the local-first grouping", () => { expect(help).toContain("### Local helpers"); expect(help).toContain("### Unsupported experimental"); expect(help).toContain("`hack tickets [args...]`"); + expect(help).toContain( + "Deprecated: legacy repo-local Tickets compatibility commands" + ); expect(help).toContain("`hack auth [args...]`"); expect(help).toContain("`hack linear [args...]`"); }); diff --git a/tests/config-command.test.ts b/tests/config-command.test.ts index 65b1c555..2f55ec5e 100644 --- a/tests/config-command.test.ts +++ b/tests/config-command.test.ts @@ -1,5 +1,12 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { + access, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -7,15 +14,18 @@ let tempDir: string | null = null; let originalHome: string | undefined; let originalLogger: string | undefined; let originalGlobalConfigPath: string | undefined; +let originalSetupSyncMode: string | undefined; beforeEach(async () => { originalHome = process.env.HOME; originalLogger = process.env.HACK_LOGGER; originalGlobalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; + originalSetupSyncMode = process.env.HACK_SETUP_SYNC_MODE; tempDir = await mkdtemp(join(tmpdir(), "hack-config-command-")); process.env.HOME = tempDir; process.env.HACK_LOGGER = "console"; process.env.HACK_GLOBAL_CONFIG_PATH = join(tempDir, "hack.config.json"); + process.env.HACK_SETUP_SYNC_MODE = "off"; }); afterEach(async () => { @@ -26,6 +36,7 @@ afterEach(async () => { process.env.HOME = originalHome; process.env.HACK_LOGGER = originalLogger; process.env.HACK_GLOBAL_CONFIG_PATH = originalGlobalConfigPath; + process.env.HACK_SETUP_SYNC_MODE = originalSetupSyncMode; }); test("config set --global updates extension enabled using bracket path", async () => { @@ -104,6 +115,41 @@ test("config set --global cleans stale legacy cloudflare mirror on canonical upd expect(parsed.controlPlane["dance.hack.cloudflare"]).toBeUndefined(); }); +test("config get does not create or lock the global project registry", async () => { + if (!tempDir) { + throw new Error("Missing temp directory"); + } + const projectRoot = join(tempDir, "repo"); + const projectDir = join(projectRoot, ".hack"); + await mkdir(projectDir, { recursive: true }); + await writeFile( + join(projectDir, "hack.config.json"), + '{"name":"read-only-project","dev_host":"read-only.hack"}\n' + ); + await writeFile( + join(projectDir, "docker-compose.yml"), + "services:\n api:\n image: alpine\n" + ); + + const { runCli } = await import("../src/cli/run.ts"); + expect(await runCli(["config", "get", "--path", projectRoot, "name"])).toBe( + 0 + ); + const lockPath = join(tempDir, ".hack", "projects.json.lock"); + const registryPath = join(tempDir, ".hack", "projects.json"); + expect(await exists(lockPath)).toBe(false); + expect(await exists(registryPath)).toBe(false); +}); + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + async function writeBaseGlobalConfig(): Promise { const configPath = globalConfigPathForTest(); await mkdir(dirname(configPath), { recursive: true }); diff --git a/tests/crash-capture.test.ts b/tests/crash-capture.test.ts index 05935621..8878a713 100644 --- a/tests/crash-capture.test.ts +++ b/tests/crash-capture.test.ts @@ -417,7 +417,7 @@ test("crash capture readme groups inferred restart actions into sections", () => failedCommands: ["hack_global_status", "hack_daemon_status"], }); - expect(text).toContain("Temporary breakage:"); + expect(text).toContain("Fix now:"); expect(text).toContain("- `hack global up`"); expect(text).toContain("- `hack daemon start`"); expect(text).toContain("Verify:"); diff --git a/tests/dependency-cache.test.ts b/tests/dependency-cache.test.ts new file mode 100644 index 00000000..ddaf7dfa --- /dev/null +++ b/tests/dependency-cache.test.ts @@ -0,0 +1,72 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { resolveDependencyCacheOverride } from "../src/lib/dependency-cache.ts"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((path) => rm(path, { recursive: true })) + ); +}); + +async function createProject(): Promise<{ + readonly projectRoot: string; + readonly projectDir: string; + readonly composeFile: string; +}> { + const projectRoot = await mkdtemp( + resolve(tmpdir(), "hack-dependency-cache-") + ); + tempDirs.push(projectRoot); + const projectDir = resolve(projectRoot, ".hack"); + await mkdir(projectDir); + const composeFile = resolve(projectDir, "docker-compose.yml"); + await writeFile( + composeFile, + [ + "services:", + " installer-any-name:", + " image: oven/bun", + " labels:", + " hack.dependencies.cache-volume: workspace-dependencies", + " hack.dependencies.lockfiles: bun.lock,package.json", + "volumes:", + " workspace-dependencies: {}", + "", + ].join("\n") + ); + await writeFile(resolve(projectRoot, "bun.lock"), "lock-v1\n"); + await writeFile( + resolve(projectRoot, "package.json"), + '{"packageManager":"bun@1.3.14"}\n' + ); + return { projectRoot, projectDir, composeFile }; +} + +test("dependency cache shares a lockfile and runtime keyed volume", async () => { + const project = await createProject(); + const first = await resolveDependencyCacheOverride({ + ...project, + projectName: "generic-project", + }); + expect(first.fingerprint).toHaveLength(16); + expect(first.volumes[0]?.logicalName).toBe("workspace-dependencies"); + expect(first.volumes[0]?.resolvedName).toContain(first.fingerprint ?? ""); + expect(first.overridePath).not.toBeNull(); + const override = await readFile(first.overridePath ?? "", "utf8"); + expect(override).toContain("workspace-dependencies"); + expect(override).toContain(first.volumes[0]?.resolvedName ?? "missing"); + + await writeFile(resolve(project.projectRoot, "bun.lock"), "lock-v2\n"); + const second = await resolveDependencyCacheOverride({ + ...project, + projectName: "generic-project", + }); + expect(second.fingerprint).not.toBe(first.fingerprint); + expect(second.volumes[0]?.resolvedName).not.toBe( + first.volumes[0]?.resolvedName + ); +}); diff --git a/tests/doctor-command.test.ts b/tests/doctor-command.test.ts index 3d7dee17..d8b12dc5 100644 --- a/tests/doctor-command.test.ts +++ b/tests/doctor-command.test.ts @@ -2,10 +2,12 @@ import { expect, test } from "bun:test"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; - +import { HACK_AGENT_INTEGRATION_CONTENT_REVISION } from "../src/agents/integration-revision.ts"; import { + assertDoctorOptionCompatibility, buildDoctorRemediationPlanLines, buildDoctorSummaryLines, + inspectDoctorAgentIntegrations, } from "../src/commands/doctor.ts"; import { buildDoctorRecoveryGuidance, @@ -69,6 +71,23 @@ test("doctor guidance distinguishes restartable proxy drift from deeper repair", expect(guidance.capture).toEqual(["hack crash-capture --path "]); }); +test("doctor rejects mutating repair flags combined with json", () => { + expect(() => + assertDoctorOptionCompatibility({ + json: true, + fix: true, + migrateEnvConfig: false, + }) + ).toThrow("--json cannot be combined with --fix"); + expect(() => + assertDoctorOptionCompatibility({ + json: true, + fix: false, + migrateEnvConfig: false, + }) + ).not.toThrow(); +}); + test("doctor remediation plan mentions env migration when requested for a legacy project", async () => { const root = await createDoctorTestProject({ legacy: true }); try { @@ -78,10 +97,9 @@ test("doctor remediation plan mentions env migration when requested for a legacy }); expect(lines).toEqual([ - "1. Review and repair local network, CoreDNS, CA, host TLS env, and daemon drift where needed.", - "2. Repair tickets refs if the project repo needs it.", - "3. Reconcile lifecycle sessions and remove only ownership-proven sessions whose Compose instance is absent.", - "4. Prompt to migrate legacy env config (.hack/hack.env.json) to hack.env.*.yaml.", + "1. Review and repair global Docker networks, CoreDNS, CA, host TLS env, daemon drift, and agent integration freshness where needed.", + "2. Reconcile lifecycle sessions and remove only ownership-proven sessions whose Compose instance is absent.", + "3. Prompt to migrate legacy env config (.hack/hack.env.json) to hack.env.*.yaml.", ]); } finally { await rm(root, { recursive: true, force: true }); @@ -97,10 +115,9 @@ test("doctor remediation plan skips env migration when modern env files already }); expect(lines).toEqual([ - "1. Review and repair local network, CoreDNS, CA, host TLS env, and daemon drift where needed.", - "2. Repair tickets refs if the project repo needs it.", - "3. Reconcile lifecycle sessions and remove only ownership-proven sessions whose Compose instance is absent.", - "4. Skip env migration because this project already uses hack.env.*.yaml.", + "1. Review and repair global Docker networks, CoreDNS, CA, host TLS env, daemon drift, and agent integration freshness where needed.", + "2. Reconcile lifecycle sessions and remove only ownership-proven sessions whose Compose instance is absent.", + "3. Skip env migration because this project already uses hack.env.*.yaml.", ]); } finally { await rm(root, { recursive: true, force: true }); @@ -158,6 +175,48 @@ test("doctor guidance includes daemon recovery for stale local api state", () => expect(guidance.configurationRepair).toEqual([]); }); +test("doctor guidance routes global agent drift to global sync", () => { + const guidance = buildDoctorRecoveryGuidance({ + results: [ + { + name: "agent integrations", + status: "warn", + message: + "Global guidance is stale (run: hack setup sync --global, reload the agent session)", + }, + ], + }); + + expect(guidance.configurationRepair).toEqual(["hack setup sync --global"]); +}); + +test("doctor audits global agent guidance without a project", async () => { + const home = await mkdtemp(join(tmpdir(), "hack-doctor-global-agents-")); + const marker = `Content revision: \`${HACK_AGENT_INTEGRATION_CONTENT_REVISION}\``; + const paths = [ + join(home, ".cursor", "rules", "hack.mdc"), + join(home, ".codex", "skills", "hack-cli", "SKILL.md"), + join(home, ".ai", "skills", "hack-cli", "SKILL.md"), + ]; + try { + for (const path of paths) { + await mkdir(join(path, ".."), { recursive: true }); + await writeFile(path, `${marker}\n`); + } + + await expect( + inspectDoctorAgentIntegrations({ projectRoot: null, homeDir: home }) + ).resolves.toEqual({ status: "current" }); + + await writeFile(paths[0] ?? "", "stale\n"); + await expect( + inspectDoctorAgentIntegrations({ projectRoot: null, homeDir: home }) + ).resolves.toEqual({ status: "stale" }); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + test("doctor guidance routes runtime hygiene drift to projects prune", () => { const guidance = buildDoctorRecoveryGuidance({ results: [ @@ -175,6 +234,23 @@ test("doctor guidance routes runtime hygiene drift to projects prune", () => { expect(guidance.followUp).toEqual([]); }); +test("doctor guidance preserves project-scoped prune commands", () => { + const guidance = buildDoctorRecoveryGuidance({ + results: [ + { + name: "runtime hygiene", + status: "warn", + message: + "1 orphaned runtime project: msp--old (run: hack projects prune --project msp)", + }, + ], + }); + + expect(guidance.temporaryBreakage).toEqual([ + "hack projects prune --project msp", + ]); +}); + test("doctor guidance routes stale lifecycle state to hack down", () => { const guidance = buildDoctorRecoveryGuidance({ results: [ @@ -334,11 +410,10 @@ test("doctor summary groups detailed checks into concise sections", () => { }); expect(lines).toEqual([ - "Dependencies: warn - optional missing: caddy", - "Runtime: ok", + "Dependencies: ok", + "Global runtime & agents: ok", "Resolver & DNS: ok", "Project & env: warn - runtime hygiene: 1 missing registry entry; 1 orphaned runtime project (run: hack projects prune); lifecycle hygiene: 1 stale lifecycle state entry; 2 orphaned lifecycle process groups (run: hack down); +2 more", - "Sessions & tickets: ok", ]); }); @@ -364,10 +439,10 @@ test("recovery next steps quote repo paths for copy-paste safety", () => { ]); }); -test("recovery next steps leave projects prune unscoped", () => { +test("recovery next steps preserve already scoped projects prune", () => { const nextSteps = buildRecoveryNextSteps({ guidance: { - temporaryBreakage: ["hack projects prune", "hack down"], + temporaryBreakage: ["hack projects prune --project msp", "hack down"], configurationRepair: ["hack doctor --fix", "hack env materialize"], followUp: [], verify: ["hack doctor"], @@ -379,7 +454,7 @@ test("recovery next steps leave projects prune unscoped", () => { expect(nextSteps).toEqual([ "Run `hack doctor --path '/tmp/work repo'` to classify restart versus repair work.", - "Temporary breakage: `hack projects prune`.", + "Temporary breakage: `hack projects prune --project msp`.", "Temporary breakage: `hack down --path '/tmp/work repo'`.", "Configuration repair: `hack doctor --fix --path '/tmp/work repo'`.", "Configuration repair: `hack env materialize --path '/tmp/work repo'`.", @@ -404,11 +479,11 @@ test("recovery workflow lines scope repo-specific commands for doctor output", ( expect(lines).toEqual([ "1. Classify:", " - `hack doctor --path '/tmp/work repo'`", - "2. Temporary breakage:", + "2. Fix now:", " - `hack restart --path '/tmp/work repo'`", - "3. Configuration repair:", + "3. Repair configuration:", " - `hack doctor --fix --path '/tmp/work repo'`", - "4. Manual follow-up:", + "4. Investigate:", " - gateway tokens: No active tokens", "5. Verify:", " - `hack doctor --path '/tmp/work repo'`", diff --git a/tests/doctor-tickets-dir-gating.test.ts b/tests/doctor-tickets-dir-gating.test.ts index 49965301..3480c5f3 100644 --- a/tests/doctor-tickets-dir-gating.test.ts +++ b/tests/doctor-tickets-dir-gating.test.ts @@ -163,19 +163,6 @@ async function runGit(args: readonly string[], cwd: string): Promise { return stdout.trim(); } -async function gitCheckIgnore(opts: { - readonly repoRoot: string; - readonly path: string; -}): Promise { - const proc = Bun.spawn({ - cmd: ["git", "check-ignore", "-q", "--", opts.path], - cwd: opts.repoRoot, - stdout: "ignore", - stderr: "ignore", - }); - return (await proc.exited) === 0; -} - async function createFixtureRepo(opts: { readonly ticketsEnabled: boolean; }): Promise { @@ -241,15 +228,12 @@ test("hack doctor --fix does not create .hack/tickets/ when the extension is dis expect(await dirExists(resolve(repoRoot, ".hack", "tickets"))).toBe(false); }); -test("hack doctor creates .hack/tickets/ when the extension is enabled, and it is gitignored", async () => { +test("hack doctor does not initialize deprecated Tickets storage when legacy enablement remains", async () => { const repoRoot = await createFixtureRepo({ ticketsEnabled: true }); await runDoctor({ repoRoot }); - expect(await dirExists(resolve(repoRoot, ".hack", "tickets"))).toBe(true); - expect( - await gitCheckIgnore({ repoRoot, path: ".hack/tickets/git/bare.git" }) - ).toBe(true); + expect(await dirExists(resolve(repoRoot, ".hack", "tickets"))).toBe(false); const status = await runGit(["status", "--porcelain"], repoRoot); expect(status).toBe(""); diff --git a/tests/e2e/fixture.ts b/tests/e2e/fixture.ts index ca1b8cd8..2731cb35 100644 --- a/tests/e2e/fixture.ts +++ b/tests/e2e/fixture.ts @@ -34,6 +34,8 @@ export type MonorepoFixture = { export type LifecycleFixtureOptions = { /** Adds a lifecycle.up.before hook that writes this marker file. */ readonly upBeforeMarkerFile?: string; + /** Adds a lifecycle.up.before hook that records resolved global/host env. */ + readonly upBeforeEnvMarkerFile?: string; /** Adds a long-running lifecycle host process (bun sleep loop). */ readonly persistentProcess?: boolean; /** Makes docker compose reject the fixture after lifecycle startup. */ @@ -339,6 +341,9 @@ async function writeHackConfig(opts: { "values:", " global:", " E2E_PLAIN: plain-value", + " host:", + " E2E_PLAIN: host-value", + " E2E_HOST_ONLY: host-only", "", ].join("\n") ); @@ -361,10 +366,18 @@ function buildLifecycleConfig(opts: { cwd: ".", }); } + if (opts.lifecycle.upBeforeEnvMarkerFile) { + upBefore.push({ + name: "e2e-env-marker", + command: `printf "%s|%s" "$E2E_PLAIN" "$E2E_HOST_ONLY" > "${opts.lifecycle.upBeforeEnvMarkerFile}"`, + cwd: ".", + }); + } if (opts.lifecycle.persistentProcess === true) { processes.push({ name: "e2e-sleeper", - command: "bun -e 'setInterval(() => {}, 60000)'", + command: + 'test "$E2E_PLAIN" = host-value && test "$E2E_HOST_ONLY" = host-only && bun -e \'setInterval(() => {}, 60000)\'', cwd: ".", }); } diff --git a/tests/e2e/harness.ts b/tests/e2e/harness.ts index 5e9c4ddf..8bf8e573 100644 --- a/tests/e2e/harness.ts +++ b/tests/e2e/harness.ts @@ -407,7 +407,7 @@ export type IsolationCanaryResult = * ever having written anything outside the temp dirs. * * Probe B (write, only after A passes): register a throwaway canary project - * via `hack config get name` and assert (1) the canary name does NOT appear + * via `hack projects --json` and assert (1) the canary name does NOT appear * in the real ~/.hack/projects.json (targeted removal + abort if it does) * and (2) a projects.json containing the canary appeared under HACK_HOME. */ @@ -467,7 +467,7 @@ async function runRegistryWriteProbe(opts: { const probeB = await runCli({ hackHome: opts.hackHome, - invocation: { args: ["config", "get", "name"], cwd: projectRoot }, + invocation: { args: ["projects", "--json"], cwd: projectRoot }, }); // Precise leak check: the canary name appearing in the REAL registry is diff --git a/tests/e2e/scenarios/agent-docs-sync.ts b/tests/e2e/scenarios/agent-docs-sync.ts index 4d7bb9cd..33a75689 100644 --- a/tests/e2e/scenarios/agent-docs-sync.ts +++ b/tests/e2e/scenarios/agent-docs-sync.ts @@ -1,3 +1,4 @@ +import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { createMonorepoFixture } from "../fixture.ts"; @@ -24,6 +25,7 @@ export const agentDocsSyncScenario: Scenario = { withHackConfig: true, }); const agentsPath = join(fixture.root, "AGENTS.md"); + const isolatedUserEnv = { HOME: ctx.hackHome }; const upsert = await ctx.cli({ args: ["setup", "agents", "--agents-md"], @@ -79,6 +81,28 @@ export const agentDocsSyncScenario: Scenario = { result: staleCheck, }); + const stalePrime = await ctx.cli({ + args: ["agent", "prime"], + cwd: fixture.root, + env: isolatedUserEnv, + }); + expectExit({ + result: stalePrime, + codes: [0], + message: "agent primer should still render while integrations are stale", + }); + expect({ + that: + stalePrime.stdout.includes( + "WARNING: Hack agent integrations are stale" + ) && + stalePrime.stdout.includes("hack setup sync --all-scopes") && + stalePrime.stdout.includes("reload the agent session"), + message: + "agent primer should expose stale project/global guidance upfront", + result: stalePrime, + }); + const repair = await ctx.cli({ args: ["setup", "agents", "--agents-md"], cwd: fixture.root, @@ -105,9 +129,69 @@ export const agentDocsSyncScenario: Scenario = { message: "check after repair should report clean (exit 0)", }); + const projectTicketsSkill = join( + fixture.root, + ".codex", + "skills", + "hack-tickets", + "SKILL.md" + ); + const globalTicketsSkill = join( + ctx.hackHome, + ".codex", + "skills", + "hack-tickets", + "SKILL.md" + ); + const sharedLegacyHackSkill = join( + ctx.hackHome, + ".ai", + "skills", + "hack", + "SKILL.md" + ); + const sharedTicketsSkill = join( + ctx.hackHome, + ".ai", + "skills", + "hack-tickets", + "SKILL.md" + ); + for (const path of [ + projectTicketsSkill, + globalTicketsSkill, + sharedLegacyHackSkill, + sharedTicketsSkill, + ]) { + await mkdir(join(path, ".."), { recursive: true }); + } + await Bun.write(projectTicketsSkill, "---\nname: hack-tickets\n---\n"); + await Bun.write(globalTicketsSkill, "---\nname: hack-tickets\n---\n"); + await Bun.write( + sharedLegacyHackSkill, + "---\nname: hack\nhomepage: https://github.com/hack-dance/hack-cli\n---\n" + ); + await Bun.write(sharedTicketsSkill, "---\nname: hack-tickets\n---\n"); + const agentDocsWithTickets = `${await Bun.file(agentsPath).text()}\n\nlegacy tickets guidance\n\n`; + await Bun.write(agentsPath, agentDocsWithTickets); + + const deprecatedCheck = await ctx.cli({ + args: ["setup", "sync", "--all-scopes", "--check"], + cwd: fixture.root, + env: isolatedUserEnv, + }); + expect({ + that: + deprecatedCheck.exitCode !== 0 && + deprecatedCheck.combined.toLowerCase().includes("deprecated"), + message: "sync check should expose legacy Tickets guidance as deprecated", + result: deprecatedCheck, + }); + const fullSync = await ctx.cli({ - args: ["setup", "sync"], + args: ["setup", "sync", "--all-scopes"], cwd: fixture.root, + env: isolatedUserEnv, }); expectExit({ result: fullSync, @@ -115,8 +199,9 @@ export const agentDocsSyncScenario: Scenario = { message: "hack setup sync (project scope) should succeed", }); const fullSyncCheck = await ctx.cli({ - args: ["setup", "sync", "--check"], + args: ["setup", "sync", "--all-scopes", "--check"], cwd: fixture.root, + env: isolatedUserEnv, }); expectExit({ result: fullSyncCheck, @@ -124,5 +209,75 @@ export const agentDocsSyncScenario: Scenario = { message: "hack setup sync --check right after hack setup sync should be clean", }); + + const currentPrime = await ctx.cli({ + args: ["agent", "prime"], + cwd: fixture.root, + env: isolatedUserEnv, + }); + expectExit({ + result: currentPrime, + codes: [0], + message: "agent primer should render after project/global repair", + }); + expect({ + that: currentPrime.stdout.includes( + "Hack agent integration freshness: current" + ), + message: "agent primer should report current guidance after repair", + result: currentPrime, + }); + + const syncedAgents = await Bun.file(agentsPath).text(); + expect({ + that: + syncedAgents.includes("Integration freshness") && + !/hack[ -]?tickets|dance\.hack\.tickets/i.test(syncedAgents), + message: "synced agent docs should be freshness-stamped and ticket-free", + }); + for (const path of [ + projectTicketsSkill, + globalTicketsSkill, + sharedLegacyHackSkill, + sharedTicketsSkill, + ]) { + expect({ + that: !(await Bun.file(path).exists()), + message: `sync should remove deprecated skill at ${path}`, + }); + } + + await Bun.write( + agentsPath, + syncedAgents.replace( + MARKER_START, + `${MARKER_START}\nSTALE-AUTO-SYNC-PROBE` + ) + ); + const autoRepair = await ctx.cli({ + args: ["config", "get", "name"], + cwd: fixture.root, + env: { ...isolatedUserEnv, HACK_SETUP_SYNC_MODE: "auto" }, + }); + expectExit({ + result: autoRepair, + codes: [0], + message: "a normal project command should auto-repair integration drift", + }); + expect({ + that: + autoRepair.stderr.includes("Detected stale Hack agent integrations") && + autoRepair.stderr.includes("Reload the agent session"), + message: + "auto-repair must announce stale guidance and reload requirement", + result: autoRepair, + }); + expect({ + that: !(await Bun.file(agentsPath).text()).includes( + "STALE-AUTO-SYNC-PROBE" + ), + message: "auto-sync should repair the stale managed instruction block", + result: autoRepair, + }); }, }; diff --git a/tests/e2e/scenarios/lifecycle-host-process.ts b/tests/e2e/scenarios/lifecycle-host-process.ts index c009527f..d4122a6f 100644 --- a/tests/e2e/scenarios/lifecycle-host-process.ts +++ b/tests/e2e/scenarios/lifecycle-host-process.ts @@ -22,12 +22,14 @@ export const lifecycleHostProcessScenario: Scenario = { await requireDockerPreconditions({ ctx }); const markerFile = join(ctx.tempRoot, "lifecycle-marker.txt"); + const envMarkerFile = join(ctx.tempRoot, "lifecycle-env-marker.txt"); const fixture = await createMonorepoFixture({ parentDir: ctx.tempRoot, withHackConfig: true, lifecycle: { persistentProcess: true, upBeforeMarkerFile: markerFile, + upBeforeEnvMarkerFile: envMarkerFile, standaloneContainers: true, }, }); @@ -55,6 +57,18 @@ export const lifecycleHostProcessScenario: Scenario = { message: "lifecycle marker file has unexpected content", result: up, }); + const envMarker = Bun.file(envMarkerFile); + expect({ + that: await envMarker.exists(), + message: `lifecycle env hook did not run (missing marker ${envMarkerFile})`, + result: up, + }); + expect({ + that: (await envMarker.text()) === "host-value|host-only", + message: + "lifecycle hooks did not receive global values with host overrides", + result: up, + }); const statePath = join( fixture.hackDir, diff --git a/tests/e2e/scenarios/lifecycle-session-recovery.ts b/tests/e2e/scenarios/lifecycle-session-recovery.ts index 3eed9b63..945785b1 100644 --- a/tests/e2e/scenarios/lifecycle-session-recovery.ts +++ b/tests/e2e/scenarios/lifecycle-session-recovery.ts @@ -186,7 +186,7 @@ export const lifecycleSessionRecoveryScenario: Scenario = { .filter((line) => line.length > 0); expect({ that: - restartCalls.length >= 3 && + restartCalls.length >= 2 && restartCalls.every( (line) => !( @@ -330,6 +330,46 @@ export const lifecycleSessionRecoveryScenario: Scenario = { message: "SIGTERM left lifecycle state behind", }); + const hardKillResult = await runSignalProbe({ + cwd: fixture.root, + hackHome: ctx.hackHome, + path, + sessionName, + signal: "SIGKILL", + }); + expect({ + that: hardKillResult.exitCode === 137, + message: `SIGKILLed up should exit 137, got ${hardKillResult.exitCode}\n${hardKillResult.stderr}`, + }); + expect({ + that: Boolean( + (await readState({ statePath })).entries[0]?.ownershipToken + ), + message: + "SIGKILL between lifecycle startup and Compose lost ownership state", + }); + const recoveredAfterHardKill = await ctx.cli({ + args: ["up", "--detach", "--json"], + cwd: fixture.root, + env: cliEnv, + }); + expectExit({ + result: recoveredAfterHardKill, + codes: [0], + message: + "up should adopt or replace an ownership-proven lifecycle session after SIGKILL", + }); + const downAfterHardKill = await ctx.cli({ + args: ["down", "--json"], + cwd: fixture.root, + env: cliEnv, + }); + expectExit({ + result: downAfterHardKill, + codes: [0], + message: "down should clean the recovered SIGKILL lifecycle session", + }); + const downFailureFixture = await createMonorepoFixture({ parentDir: ctx.tempRoot, withHackConfig: true, @@ -528,6 +568,10 @@ async function writeFakeDocker(opts: { 'if [ -n "${E2E_DOCKER_LOG:-}" ]; then printf "%s\\n" "$*" >> "$E2E_DOCKER_LOG"; fi', 'if [ "$1" = "compose" ]; then', ' case " $* " in', + ' *" ps "*)', + ' printf \'%s\\n\' \'{"Service":"api","State":"running","ExitCode":0}\'', + " exit 0", + " ;;", ' *" up "*)', ' if [ "${E2E_DOCKER_BLOCK_UP:-}" = "1" ]; then', ' parent_pid="$PPID"', @@ -611,6 +655,7 @@ async function runSignalProbe(opts: { readonly hackHome: string; readonly path: string; readonly sessionName: string; + readonly signal?: "SIGTERM" | "SIGKILL"; }): Promise<{ readonly exitCode: number; readonly stderr: string }> { const proc = Bun.spawn(resolveCliSpawnArgs(["up", "--detach", "--json"]), { cwd: opts.cwd, @@ -635,7 +680,7 @@ async function runSignalProbe(opts: { await proc.exited; throw new Error("timed out waiting for lifecycle session before SIGTERM"); } - proc.kill("SIGTERM"); + proc.kill(opts.signal ?? "SIGTERM"); const [exitCode, stderr] = await Promise.all([ proc.exited, new Response(proc.stderr).text(), diff --git a/tests/env-command-modern.test.ts b/tests/env-command-modern.test.ts index efebd36d..4bfcc40f 100644 --- a/tests/env-command-modern.test.ts +++ b/tests/env-command-modern.test.ts @@ -99,6 +99,35 @@ test("modern env list rejects unknown service scopes", async () => { expect(result.stderr).toContain("Unknown env scope: typo_service"); }); +test("env explain reports precedence and delivery without revealing values", async () => { + const projectRoot = await createProject(); + const result = await runCliWithCapturedOutput([ + "env", + "explain", + "SERVICE_TOKEN", + "--path", + projectRoot, + "--service", + "api", + "--target", + "compose", + "--json", + ]); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout) as Record; + expect(payload).toMatchObject({ + key: "SERVICE_TOKEN", + available: true, + secret: true, + requested_scope: "api", + effective_scope: "api", + target: "compose", + delivered_to: ["compose"], + }); + expect(result.stdout).not.toContain("super-secret-token"); +}); + async function createProject(): Promise { if (!tempDir) { throw new Error("Missing temp directory"); diff --git a/tests/lifecycle-json.test.ts b/tests/lifecycle-json.test.ts index 1e6b2d97..fe3b53cb 100644 --- a/tests/lifecycle-json.test.ts +++ b/tests/lifecycle-json.test.ts @@ -45,6 +45,7 @@ test("buildLifecycleJsonData shapes the envelope payload with sorted services", branch: "feat-x", composeProject: "demo--feat-x", started: ["web", "api"], + completed: ["migrate"], failed: ["worker"], durationMs: 1234, }); @@ -56,6 +57,7 @@ test("buildLifecycleJsonData shapes the envelope payload with sorted services", composeProject: "demo--feat-x", services: { started: ["api", "web"], + completed: ["migrate"], stopped: [], failed: ["worker"], }, @@ -72,7 +74,12 @@ test("buildLifecycleJsonData defaults omitted service lists to empty", () => { stopped: ["api"], durationMs: 10, }); - expect(data.services).toEqual({ started: [], stopped: ["api"], failed: [] }); + expect(data.services).toEqual({ + started: [], + completed: [], + stopped: ["api"], + failed: [], + }); }); for (const action of ["up", "down", "restart"] as const) { diff --git a/tests/project-env-config.test.ts b/tests/project-env-config.test.ts index e17d2e4a..d131e719 100644 --- a/tests/project-env-config.test.ts +++ b/tests/project-env-config.test.ts @@ -1159,6 +1159,57 @@ test("host service scopes keep their existing meaning when the repo has a host s }); }); +test("named overlay globals override base host and service values", async () => { + const repo = await createRepo(); + await writeFile( + resolve(repo.projectDir, "hack.env.default.yaml"), + [ + "version: 1", + "environment: default", + "secretsprovider: project_key", + "values:", + " global:", + ' E2E_PLAIN: "base-global"', + " host:", + ' E2E_PLAIN: "base-host"', + " api:", + ' E2E_PLAIN: "base-api"', + "", + ].join("\n") + ); + await writeFile( + resolve(repo.projectDir, "hack.env.qa.yaml"), + [ + "version: 1", + "environment: qa", + "secretsprovider: project_key", + "values:", + " global:", + ' E2E_PLAIN: "qa-global"', + "", + ].join("\n") + ); + + const resolved = await resolveProjectEnvConfig({ + projectRoot: repo.projectRoot, + projectDir: repo.projectDir, + envName: "qa", + serviceNames: ["api", "web"], + }); + if (!resolved) { + throw new Error("Expected resolved env config."); + } + + expect(resolved.serviceEnv.api?.E2E_PLAIN).toBe("qa-global"); + expect( + selectProjectEnvValuesForExecutionTarget({ + resolved, + scopeName: "global", + target: "host", + }).E2E_PLAIN + ).toBe("qa-global"); +}); + test("migrateLegacyProjectEnv converts legacy base and overlay values into new config files", async () => { const repo = await createRepo(); process.env.HACK_SECRETS_FILE_KEY = "legacy-migrate-key"; diff --git a/tests/project-lifecycle-env.test.ts b/tests/project-lifecycle-env.test.ts new file mode 100644 index 00000000..7bcd60d5 --- /dev/null +++ b/tests/project-lifecycle-env.test.ts @@ -0,0 +1,131 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +import { resolveLifecycleEnvForProject } from "../src/commands/project.ts"; +import { + PROJECT_COMPOSE_FILENAME, + PROJECT_CONFIG_FILENAME, + PROJECT_ENV_CONFIG_DEFAULT_FILENAME, + PROJECT_ENV_FILENAME, +} from "../src/constants.ts"; +import type { ProjectContext } from "../src/lib/project.ts"; + +const tempDirs = new Set(); + +afterEach(async () => { + for (const tempDir of tempDirs) { + await rm(tempDir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); + +test("lifecycle host execution applies host overrides on top of global overlay values", async () => { + const project = await createProject({ + envConfig: [ + "version: 1", + "environment: default", + "secretsprovider: project_key", + "values:", + " global:", + ' SHARED_MODE: "container"', + ' GLOBAL_ONLY: "global-value"', + " host:", + ' SHARED_MODE: "host"', + ' HOST_ONLY: "host-value"', + " api:", + ' SERVICE_ONLY: "service-value"', + "", + ].join("\n"), + }); + + const env = await resolveLifecycleEnvForProject({ + project, + projectName: "lifecycle-env-test", + envName: null, + }); + + expect(env).toEqual({ + GLOBAL_ONLY: "global-value", + HOST_ONLY: "host-value", + SHARED_MODE: "host", + }); + expect(env.SERVICE_ONLY).toBeUndefined(); +}); + +test("lifecycle host execution resolves host overrides from the selected overlay", async () => { + const project = await createProject({ + envConfig: [ + "version: 1", + "environment: default", + "secretsprovider: project_key", + "values:", + " global:", + ' EXECUTION_MODE: "local"', + " host:", + ' RUNNER_KIND: "laptop"', + "", + ].join("\n"), + overlayConfig: [ + "version: 1", + "environment: runner", + "secretsprovider: project_key", + "values:", + " global:", + ' EXECUTION_MODE: "runner"', + " host:", + ' RUNNER_KIND: "remote"', + "", + ].join("\n"), + }); + + const env = await resolveLifecycleEnvForProject({ + project, + projectName: "lifecycle-env-test", + envName: "runner", + }); + + expect(env).toEqual({ + EXECUTION_MODE: "runner", + RUNNER_KIND: "remote", + }); +}); + +async function createProject(opts: { + readonly envConfig: string; + readonly overlayConfig?: string; +}): Promise { + const projectRoot = await mkdtemp(resolve(tmpdir(), "hack-lifecycle-env-")); + tempDirs.add(projectRoot); + const projectDir = resolve(projectRoot, ".hack"); + await mkdir(projectDir, { recursive: true }); + + const composeFile = resolve(projectDir, PROJECT_COMPOSE_FILENAME); + const configFile = resolve(projectDir, PROJECT_CONFIG_FILENAME); + const envFile = resolve(projectDir, PROJECT_ENV_FILENAME); + await writeFile(composeFile, "services:\n api: {}\n"); + await writeFile( + configFile, + `${JSON.stringify({ name: "lifecycle-env-test", dev_host: "lifecycle-env.hack" }, null, 2)}\n` + ); + await writeFile( + resolve(projectDir, PROJECT_ENV_CONFIG_DEFAULT_FILENAME), + opts.envConfig + ); + if (opts.overlayConfig) { + await writeFile( + resolve(projectDir, "hack.env.runner.yaml"), + opts.overlayConfig + ); + } + + return { + projectRoot, + projectDirName: ".hack", + projectDir, + composeFile, + envFile, + configFile, + }; +} diff --git a/tests/project-runtime-hygiene.test.ts b/tests/project-runtime-hygiene.test.ts index 4f3960d5..63ec72da 100644 --- a/tests/project-runtime-hygiene.test.ts +++ b/tests/project-runtime-hygiene.test.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { + findIncompleteRuntimeProjects, findMissingRegistryEntries, findOrphanRuntimeProjects, scopeRuntimeHygieneToProject, @@ -119,6 +120,53 @@ test("findOrphanRuntimeProjects reports missing working dirs and compose files", ]); }); +test("findIncompleteRuntimeProjects reports regular services stuck in Created", () => { + const runtime = buildRuntimeProject({ + project: "interrupted", + workingDir: "/tmp/interrupted/.hack", + containerIds: ["api-1", "worker-1"], + }); + runtime.services.get("app")?.containers.forEach((container) => { + Object.assign(container, { state: "created", status: "Created" }); + }); + + expect(findIncompleteRuntimeProjects({ runtime: [runtime] })).toEqual([ + { + project: "interrupted", + workingDir: "/tmp/interrupted/.hack", + createdServices: ["app"], + containerIds: ["api-1", "worker-1"], + }, + ]); +}); + +test("findIncompleteRuntimeProjects ignores terminal services and lifecycle pseudo-services", () => { + const terminal = buildRuntimeProject({ + project: "terminal", + workingDir: "/tmp/terminal/.hack", + containerIds: ["terminal-1"], + }); + terminal.services.get("app")?.containers.forEach((container) => { + Object.assign(container, { state: "exited", status: "Exited (0)" }); + }); + const lifecycle = buildRuntimeProject({ + project: "lifecycle", + workingDir: "/tmp/lifecycle/.hack", + containerIds: ["lifecycle-1"], + }); + lifecycle.services.get("app")?.containers.forEach((container) => { + Object.assign(container, { + state: "created", + status: "Created", + labels: { "hack.lifecycle.process": "true" }, + }); + }); + + expect( + findIncompleteRuntimeProjects({ runtime: [terminal, lifecycle] }) + ).toEqual([]); +}); + test("scopeRuntimeHygieneToProject excludes unrelated registered and runtime projects", () => { const projectRoot = "/tmp/current-repo"; const projectDir = "/tmp/current-repo/.hack"; diff --git a/tests/project-startup-state.test.ts b/tests/project-startup-state.test.ts new file mode 100644 index 00000000..538a4127 --- /dev/null +++ b/tests/project-startup-state.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "bun:test"; + +import { + buildStartupIncompleteMessage, + classifyComposeStartupState, +} from "../src/lib/compose-startup-state.ts"; + +test("startup accepts running services and successful one-shot services", () => { + expect( + classifyComposeStartupState([ + { service: "api", state: "running", exitCode: 0 }, + { service: "migrate", state: "exited", exitCode: 0 }, + ]) + ).toEqual({ + running: ["api"], + completed: ["migrate"], + failed: [], + }); +}); + +test("startup rejects containers left created even when compose returned success", () => { + expect( + classifyComposeStartupState([ + { service: "api", state: "created", exitCode: 0 }, + { service: "worker", state: "created", exitCode: null }, + ]) + ).toEqual({ + running: [], + completed: [], + failed: ["api", "worker"], + }); +}); + +test("startup rejects non-zero exits and unstable runtime states", () => { + expect( + classifyComposeStartupState([ + { service: "api", state: "exited", exitCode: 1 }, + { service: "worker", state: "restarting", exitCode: null }, + { service: "cache", state: "dead", exitCode: null }, + ]) + ).toEqual({ + running: [], + completed: [], + failed: ["api", "worker", "cache"], + }); +}); + +test("an empty post-start snapshot is incomplete", () => { + expect( + buildStartupIncompleteMessage({ composeProject: "demo", failed: [] }) + ).toBe( + "Startup incomplete for demo: Compose reported no services after startup" + ); +}); diff --git a/tests/project-up-startup-state.test.ts b/tests/project-up-startup-state.test.ts new file mode 100644 index 00000000..0c5335a0 --- /dev/null +++ b/tests/project-up-startup-state.test.ts @@ -0,0 +1,394 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +import { CLI_SPEC } from "../src/cli/spec.ts"; +import { + PROJECT_COMPOSE_FILENAME, + PROJECT_CONFIG_FILENAME, +} from "../src/constants.ts"; +import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; + +const psRows: string[] = []; +const errorMessages: string[] = []; +const warnMessages: string[] = []; +const upEnvs: Array> | undefined> = []; +const tempDirs = new Set(); +const originalHackHome = process.env.HACK_HOME; +let autoBranch: string | null = null; +let runtimeProjects: readonly Record[] = []; + +const branchesMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/branches.ts", + overrides: { + resolveEffectiveBranch: async () => + autoBranch + ? { branch: autoBranch, source: "worktree", gitBranch: autoBranch } + : { branch: null, source: "none", gitBranch: null }, + }, +}); + +const runtimeProjectsMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/runtime-projects.ts", + overrides: { + readRuntimeProjects: async () => ({ + ok: true, + runtime: runtimeProjects, + error: null, + checkedAtMs: Date.now(), + }), + }, +}); + +const runtimeBackendMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/backends/runtime-backend.ts", + overrides: { + composeRuntimeBackend: { + name: "compose", + up: async (opts: { readonly env?: Readonly> }) => { + upEnvs.push(opts.env); + return 0; + }, + down: async () => 0, + psJson: async () => ({ + exitCode: 0, + stdout: psRows.join("\n"), + stderr: "", + }), + ps: async () => 0, + run: async () => 0, + exec: async () => 0, + }, + }, +}); + +const loggerMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/ui/logger.ts", + overrides: { + logger: { + debug: () => {}, + info: () => {}, + warn: (input: { readonly message: string }) => { + warnMessages.push(input.message); + }, + error: (input: { readonly message: string }) => { + errorMessages.push(input.message); + }, + success: () => {}, + step: () => {}, + }, + }, +}); + +const { restartCommand, upCommand } = await import( + "../src/commands/project.ts" +); + +beforeAll(() => { + branchesMock.activate(); + runtimeBackendMock.activate(); + runtimeProjectsMock.activate(); + loggerMock.activate(); +}); + +afterEach(async () => { + psRows.length = 0; + errorMessages.length = 0; + warnMessages.length = 0; + upEnvs.length = 0; + autoBranch = null; + runtimeProjects = []; + for (const tempDir of tempDirs) { + await rm(tempDir, { recursive: true, force: true }); + } + tempDirs.clear(); + process.env.HACK_HOME = originalHackHome; +}); + +afterAll(() => { + branchesMock.deactivate(); + runtimeBackendMock.deactivate(); + runtimeProjectsMock.deactivate(); + loggerMock.deactivate(); +}); + +test("up returns failure when compose reports a created service after exit zero", async () => { + const projectRoot = await createProject(); + psRows.push( + JSON.stringify({ Service: "api", State: "created", 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 returns failure when compose reports no services after exit zero", async () => { + const projectRoot = await createProject(); + + const exitCode = await runDetachedUp({ projectRoot }); + + expect(exitCode).toBe(1); + expect(errorMessages).toContain( + "Startup incomplete for startup-state-test: Compose reported no services after startup" + ); +}); + +test("up accepts running and successfully completed one-shot services", async () => { + const projectRoot = await createProject(); + psRows.push( + JSON.stringify({ Service: "api", State: "running", ExitCode: 0 }), + JSON.stringify({ Service: "migrate", State: "exited", ExitCode: 0 }) + ); + + const exitCode = await runDetachedUp({ projectRoot }); + + expect(exitCode).toBe(0); + expect(errorMessages).toEqual([]); +}); + +test("restart returns failure when compose reports a created service after exit zero", async () => { + const projectRoot = await createProject(); + psRows.push( + JSON.stringify({ Service: "api", State: "created", ExitCode: 0 }) + ); + + const exitCode = await runRestart({ projectRoot }); + + expect(exitCode).toBe(1); + expect(errorMessages).toContain( + "Startup incomplete for startup-state-test: api did not reach running or successful completion" + ); +}); + +test("up --json emits E_STARTUP_INCOMPLETE for a created service", async () => { + const projectRoot = await createProject(); + psRows.push( + JSON.stringify({ Service: "api", State: "created", ExitCode: 0 }) + ); + + const result = await runJsonUp({ projectRoot }); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { + code: "E_STARTUP_INCOMPLETE", + detail: { running: [], completed: [], failed: ["api"] }, + }, + }); +}); + +test("up lifecycle hooks receive host overrides while compose keeps global values", async () => { + const markerFile = resolve(tmpdir(), `hack-lifecycle-env-${Date.now()}.txt`); + tempDirs.add(markerFile); + const projectRoot = await createProject({ lifecycleMarkerFile: markerFile }); + psRows.push( + JSON.stringify({ Service: "api", State: "running", ExitCode: 0 }) + ); + + const exitCode = await runDetachedUp({ projectRoot }); + + expect(exitCode).toBe(0); + expect(await readFile(markerFile, "utf8")).toBe("host|host-only"); + expect(upEnvs).toEqual([{ SHARED_MODE: "compose" }]); +}); + +test("up warns before an auto-derived branch retargets the same worktree", async () => { + const projectRoot = await createProject(); + autoBranch = "new-branch"; + runtimeProjects = [ + { + project: "startup-state-test--old-branch", + workingDir: resolve(projectRoot, ".hack"), + isGlobal: false, + services: new Map([ + [ + "api", + { + service: "api", + containers: [{ state: "running" }], + }, + ], + ]), + }, + ]; + psRows.push( + JSON.stringify({ Service: "api", State: "running", ExitCode: 0 }) + ); + + const exitCode = await runDetachedUp({ projectRoot }); + + expect(exitCode).toBe(0); + expect(warnMessages).toContain( + 'This worktree already owns "startup-state-test--old-branch" (running); auto-targeting new instance "startup-state-test--new-branch". Pass --branch to target an existing instance explicitly.' + ); +}); + +async function runDetachedUp(opts: { + readonly projectRoot: string; +}): Promise { + const input = { + ctx: { cwd: opts.projectRoot, cli: CLI_SPEC }, + args: { + options: { + path: opts.projectRoot, + project: undefined, + env: "base", + branch: undefined, + detach: true, + profile: undefined, + target: undefined, + json: false, + }, + positionals: {}, + raw: { + argv: ["--path", opts.projectRoot, "--env", "base", "--detach"], + positionals: [], + }, + }, + } as unknown as Parameters[0]; + + return await upCommand.handler(input); +} + +async function runRestart(opts: { + readonly projectRoot: string; +}): Promise { + const input = { + ctx: { cwd: opts.projectRoot, cli: CLI_SPEC }, + args: { + options: { + path: opts.projectRoot, + project: undefined, + env: "base", + branch: undefined, + profile: undefined, + target: undefined, + json: false, + }, + positionals: {}, + raw: { + argv: ["--path", opts.projectRoot, "--env", "base"], + positionals: [], + }, + }, + } as unknown as Parameters[0]; + + return await restartCommand.handler(input); +} + +async function runJsonUp(opts: { + readonly projectRoot: string; +}): Promise<{ readonly exitCode: number; readonly stdout: string }> { + let stdout = ""; + const originalWrite = process.stdout.write; + process.stdout.write = ((chunk: string | Uint8Array) => { + stdout += + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + return true; + }) as typeof process.stdout.write; + + try { + const input = { + ctx: { cwd: opts.projectRoot, cli: CLI_SPEC }, + args: { + options: { + path: opts.projectRoot, + project: undefined, + env: "base", + branch: undefined, + detach: false, + profile: undefined, + target: undefined, + json: true, + }, + positionals: {}, + raw: { + argv: ["--path", opts.projectRoot, "--env", "base", "--json"], + positionals: [], + }, + }, + } as unknown as Parameters[0]; + + return { exitCode: await upCommand.handler(input), stdout }; + } finally { + process.stdout.write = originalWrite; + } +} + +async function createProject(opts?: { + readonly lifecycleMarkerFile?: string; +}): Promise { + const projectRoot = await mkdtemp( + resolve(tmpdir(), "hack-up-startup-state-") + ); + tempDirs.add(projectRoot); + process.env.HACK_HOME = resolve(projectRoot, ".global-hack"); + const projectDir = resolve(projectRoot, ".hack"); + await mkdir(projectDir, { recursive: true }); + await writeFile( + resolve(projectDir, PROJECT_COMPOSE_FILENAME), + [ + "name: startup-state-test", + "services:", + " api:", + " image: alpine:3.20", + " migrate:", + " image: alpine:3.20", + "", + ].join("\n") + ); + await writeFile( + resolve(projectDir, PROJECT_CONFIG_FILENAME), + `${JSON.stringify( + { + name: "startup-state-test", + dev_host: "startup-state-test.hack", + internal: { dns: false, tls: false }, + ...(opts?.lifecycleMarkerFile + ? { + lifecycle: { + up: { + before: [ + { + name: "capture-env", + command: `printf "%s|%s" "$SHARED_MODE" "$HOST_ONLY" > "${opts.lifecycleMarkerFile}"`, + }, + ], + after: [], + }, + down: { before: [], after: [] }, + }, + } + : {}), + }, + null, + 2 + )}\n` + ); + await writeFile( + resolve(projectDir, "hack.env.default.yaml"), + [ + "version: 1", + "environment: default", + "secretsprovider: project_key", + "values:", + " global:", + ' SHARED_MODE: "compose"', + " host:", + ' SHARED_MODE: "host"', + ' HOST_ONLY: "host-only"', + "", + ].join("\n") + ); + return projectRoot; +} diff --git a/tests/projects-prune.test.ts b/tests/projects-prune.test.ts index a14af2df..83bfaece 100644 --- a/tests/projects-prune.test.ts +++ b/tests/projects-prune.test.ts @@ -2,13 +2,14 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; - +import { __testOnlyProjectsCommand } from "../src/commands/projects.ts"; import { findDeadProjectRegistrations, type RegisteredProject, readProjectsRegistry, removeProjectsById, } from "../src/lib/projects-registry.ts"; +import type { RuntimeProject } from "../src/lib/runtime-projects.ts"; let tempDir: string | null = null; let originalHackHome: string | undefined; @@ -134,3 +135,41 @@ test("prune removes dead entries from the registry and keeps live ones", async ( const after = await readProjectsRegistry(); expect(after.projects.map((entry) => entry.id)).toEqual(["live00000001"]); }); + +test("project-scoped prune selects only one project family", () => { + const alpha = buildRegistration({ + id: "alpha0000001", + name: "alpha", + repoRoot: "/missing/alpha", + }); + const beta = buildRegistration({ + id: "beta00000001", + name: "beta", + repoRoot: "/missing/beta", + }); + const runtime = [ + runtimeProject("alpha"), + runtimeProject("alpha--feature-one"), + runtimeProject("beta--feature-two"), + ]; + + expect( + __testOnlyProjectsCommand + .filterPruneRegistryProjects({ projects: [alpha, beta], filter: "alpha" }) + .map((project) => project.name) + ).toEqual(["alpha"]); + expect( + __testOnlyProjectsCommand + .filterPruneRuntimeProjects({ runtime, filter: "alpha" }) + .map((project) => project.project) + ).toEqual(["alpha", "alpha--feature-one"]); +}); + +function runtimeProject(project: string): RuntimeProject { + return { + project, + workingDir: `/missing/${project}`, + services: new Map(), + isGlobal: false, + }; +} diff --git a/tests/registry-credential-preflight.test.ts b/tests/registry-credential-preflight.test.ts new file mode 100644 index 00000000..19ce2c76 --- /dev/null +++ b/tests/registry-credential-preflight.test.ts @@ -0,0 +1,66 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { + discoverDependencyBootstrapServices, + discoverRegistryCredentialReferences, + preflightRegistryCredentials, +} from "../src/lib/registry-credential-preflight.ts"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((path) => rm(path, { recursive: true })) + ); +}); + +async function createTempProject(): Promise { + const path = await mkdtemp(resolve(tmpdir(), "hack-registry-preflight-")); + tempDirs.push(path); + return path; +} + +test("registry preflight reports referenced credentials without values", async () => { + const projectRoot = await createTempProject(); + await writeFile( + resolve(projectRoot, ".npmrc"), + "@private:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}\n" + ); + + expect(await discoverRegistryCredentialReferences({ projectRoot })).toEqual([ + { + key: "GITHUB_TOKEN", + path: resolve(projectRoot, ".npmrc"), + line: 2, + }, + ]); + const missing = await preflightRegistryCredentials({ projectRoot, env: {} }); + expect(missing.missing.map((entry) => entry.key)).toEqual(["GITHUB_TOKEN"]); + const present = await preflightRegistryCredentials({ + projectRoot, + env: { GITHUB_TOKEN: "redacted-token" }, + }); + expect(present.missing).toEqual([]); +}); + +test("dependency bootstrap detection is name agnostic", async () => { + const projectRoot = await createTempProject(); + const composeFile = resolve(projectRoot, "compose.yml"); + await writeFile( + composeFile, + [ + "services:", + " arbitrary-installer:", + " image: oven/bun", + " command: bun install --frozen-lockfile", + " api:", + " image: app", + "", + ].join("\n") + ); + expect(await discoverDependencyBootstrapServices({ composeFile })).toEqual([ + "arbitrary-installer", + ]); +}); diff --git a/tests/runtime-backend.test.ts b/tests/runtime-backend.test.ts index 2fd25d18..9ee446e2 100644 --- a/tests/runtime-backend.test.ts +++ b/tests/runtime-backend.test.ts @@ -95,6 +95,35 @@ test("composeRuntimeBackend.up builds compose args with profiles and detach", as ]); }); +test("composeRuntimeBackend.up targets services without dependencies", async () => { + const composeRuntimeBackend = await loadComposeRuntimeBackend(); + await composeRuntimeBackend.up({ + composeFiles: ["docker-compose.yml"], + composeProject: "myproj", + profiles: [], + detach: true, + noDeps: true, + forceRecreate: true, + services: ["chat-q", "sim-runner"], + cwd: "/tmp", + }); + + expect(runCalls[0]).toEqual([ + "docker", + "compose", + "-p", + "myproj", + "-f", + "docker-compose.yml", + "up", + "-d", + "--no-deps", + "--force-recreate", + "chat-q", + "sim-runner", + ]); +}); + test("composeRuntimeBackend.down builds compose args", async () => { const composeRuntimeBackend = await loadComposeRuntimeBackend(); await composeRuntimeBackend.down({ @@ -137,6 +166,30 @@ test("composeRuntimeBackend.psJson uses exec with json format", async () => { ]); }); +test("composeRuntimeBackend.psJson can include non-running containers", async () => { + const composeRuntimeBackend = await loadComposeRuntimeBackend(); + await composeRuntimeBackend.psJson({ + composeFiles: ["docker-compose.yml"], + composeProject: "proj", + profiles: [], + cwd: "/tmp", + all: true, + }); + + expect(execCalls[0]).toEqual([ + "docker", + "compose", + "-p", + "proj", + "-f", + "docker-compose.yml", + "ps", + "--all", + "--format", + "json", + ]); +}); + test("composeRuntimeBackend.run supports workdir and args", async () => { const composeRuntimeBackend = await loadComposeRuntimeBackend(); await composeRuntimeBackend.run({ diff --git a/tests/shared-agent-skill.test.ts b/tests/shared-agent-skill.test.ts new file mode 100644 index 00000000..e24ac97d --- /dev/null +++ b/tests/shared-agent-skill.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + checkDeprecatedSharedHackSkills, + checkSharedHackSkill, + installSharedHackSkill, + removeDeprecatedSharedHackSkills, +} from "../src/agents/shared-skill.ts"; + +let tempHome: string | null = null; +let originalHome: string | undefined; + +beforeEach(async () => { + originalHome = process.env.HOME; + tempHome = await mkdtemp(join(tmpdir(), "hack-shared-skill-")); + process.env.HOME = tempHome; +}); + +afterEach(async () => { + if (originalHome === undefined) { + Reflect.deleteProperty(process.env, "HOME"); + } else { + process.env.HOME = originalHome; + } + if (tempHome) { + await rm(tempHome, { recursive: true, force: true }); + tempHome = null; + } +}); + +test("shared Hack skill installs current ticket-free guidance and detects drift", async () => { + expect((await checkSharedHackSkill()).status).toBe("missing"); + + const installed = await installSharedHackSkill(); + expect(installed.status).toBe("created"); + expect(installed.path).toBe( + join(tempHome ?? "", ".ai", "skills", "hack-cli", "SKILL.md") + ); + + const content = await Bun.file(installed.path).text(); + expect(content).toContain("Integration freshness"); + expect(content).not.toMatch(/hack[ -]?tickets/i); + expect((await checkSharedHackSkill()).status).toBe("noop"); + + await Bun.write( + installed.path, + content.replace("Integration freshness", "Old rules") + ); + const stale = await checkSharedHackSkill(); + expect(stale.status).toBe("stale"); + expect(stale.message).toContain("hack setup sync --all-scopes"); +}); + +test("known legacy shared Hack skills are reported and removed", async () => { + const hackPath = join(tempHome ?? "", ".ai", "skills", "hack", "SKILL.md"); + const ticketsPath = join( + tempHome ?? "", + ".ai", + "skills", + "hack-tickets", + "SKILL.md" + ); + await mkdir(join(hackPath, ".."), { recursive: true }); + await mkdir(join(ticketsPath, ".."), { recursive: true }); + await Bun.write( + hackPath, + "---\nname: hack\nhomepage: https://github.com/hack-dance/hack-cli\n---\n" + ); + await Bun.write(ticketsPath, "---\nname: hack-tickets\n---\n"); + + const checked = await checkDeprecatedSharedHackSkills(); + expect(checked.map((result) => result.status)).toEqual([ + "deprecated", + "deprecated", + ]); + + const removed = await removeDeprecatedSharedHackSkills(); + expect(removed.map((result) => result.status)).toEqual([ + "removed", + "removed", + ]); + expect(await Bun.file(hackPath).exists()).toBe(false); + expect(await Bun.file(ticketsPath).exists()).toBe(false); +}); + +test("unrecognized shared hack alias is protected from cleanup", async () => { + const hackPath = join(tempHome ?? "", ".ai", "skills", "hack", "SKILL.md"); + await mkdir(join(hackPath, ".."), { recursive: true }); + await Bun.write(hackPath, "---\nname: hack\n---\nuser-owned\n"); + + const removed = await removeDeprecatedSharedHackSkills(); + expect(removed[0]?.status).toBe("error"); + expect(await Bun.file(hackPath).exists()).toBe(true); +}); diff --git a/tests/shell-timeout.test.ts b/tests/shell-timeout.test.ts new file mode 100644 index 00000000..d7f8fed8 --- /dev/null +++ b/tests/shell-timeout.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test"; + +import { exec } from "../src/lib/shell.ts"; + +test("exec timeout terminates the subprocess group and reports 124", async () => { + const startedAt = Date.now(); + const result = await exec(["/bin/sh", "-c", "sleep 5"], { + stdin: "ignore", + timeoutMs: 50, + }); + + expect(result.exitCode).toBe(124); + expect(Date.now() - startedAt).toBeLessThan(2000); +}); diff --git a/tests/worktree-runtime-target.test.ts b/tests/worktree-runtime-target.test.ts new file mode 100644 index 00000000..6795f7da --- /dev/null +++ b/tests/worktree-runtime-target.test.ts @@ -0,0 +1,121 @@ +import { expect, test } from "bun:test"; + +import type { + RuntimeContainer, + RuntimeProject, +} from "../src/lib/runtime-projects.ts"; +import { + buildWorktreeRetargetWarning, + findSameCheckoutRetargetConflicts, +} from "../src/lib/worktree-runtime-target.ts"; + +test("retarget detection finds running and created instances from the same checkout", () => { + const conflicts = findSameCheckoutRetargetConflicts({ + currentProjectDir: "/repo/.hack", + targetComposeProject: "demo--new-branch", + runtime: [ + buildRuntimeProject({ + project: "demo--old-branch", + workingDir: "/repo/.hack", + states: ["running"], + }), + buildRuntimeProject({ + project: "demo--interrupted", + workingDir: "/repo/.hack", + states: ["created"], + }), + buildRuntimeProject({ + project: "demo--new-branch", + workingDir: "/repo/.hack", + states: ["created"], + }), + buildRuntimeProject({ + project: "demo--other-checkout", + workingDir: "/other/.hack", + states: ["running"], + }), + ], + }); + + expect(conflicts).toEqual([ + { composeProject: "demo--interrupted", states: ["created"] }, + { composeProject: "demo--old-branch", states: ["running"] }, + ]); +}); + +test("retarget detection ignores terminal instances from the same checkout", () => { + expect( + findSameCheckoutRetargetConflicts({ + currentProjectDir: "/repo/.hack", + targetComposeProject: "demo--new-branch", + runtime: [ + buildRuntimeProject({ + project: "demo--old-branch", + workingDir: "/repo/.hack", + states: ["exited"], + }), + ], + }) + ).toEqual([]); +}); + +test("retarget warning names existing and new compose projects", () => { + expect( + buildWorktreeRetargetWarning({ + targetComposeProject: "demo--new-branch", + conflicts: [{ composeProject: "demo--old-branch", states: ["running"] }], + }) + ).toBe( + 'This worktree already owns "demo--old-branch" (running); auto-targeting new instance "demo--new-branch". Pass --branch to target an existing instance explicitly.' + ); +}); + +function buildRuntimeProject(opts: { + readonly project: string; + readonly workingDir: string; + readonly states: readonly string[]; +}): RuntimeProject { + return { + project: opts.project, + workingDir: opts.workingDir, + isGlobal: false, + services: new Map([ + [ + "app", + { + service: "app", + containers: opts.states.map((state, index) => + buildContainer({ + project: opts.project, + workingDir: opts.workingDir, + state, + index, + }) + ), + }, + ], + ]), + }; +} + +function buildContainer(opts: { + readonly project: string; + readonly workingDir: string; + readonly state: string; + readonly index: number; +}): RuntimeContainer { + return { + id: `${opts.project}-${opts.index}`, + project: opts.project, + service: "app", + state: opts.state, + status: opts.state, + name: `${opts.project}-app-${opts.index}`, + ports: "", + workingDir: opts.workingDir, + image: "alpine:3.20", + labels: null, + mounts: [], + networks: [], + }; +} From 54d7feeeac0b826a60d88fd8f4ff3956402daf1b Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 9 Jul 2026 22:09:43 -0400 Subject: [PATCH 2/5] Ensure branch overrides stay ignored --- src/commands/project.ts | 1 + tests/hack-gitignore.test.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/commands/project.ts b/src/commands/project.ts index 4231c8e1..40f0d6ac 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -2902,6 +2902,7 @@ async function resolveBranchComposeFiles(opts: { return [opts.project.composeFile]; } + await ensureHackDirGitignore({ projectDir: opts.project.projectDir }); const overrideDir = resolve(opts.project.projectDir, ".branch"); await ensureDir(overrideDir); const overridePath = resolve( diff --git a/tests/hack-gitignore.test.ts b/tests/hack-gitignore.test.ts index f2f9463d..e00af790 100644 --- a/tests/hack-gitignore.test.ts +++ b/tests/hack-gitignore.test.ts @@ -181,6 +181,7 @@ test("nested .hack/.gitignore makes git ignore generated files, including in lin for (const path of [ ".hack/.internal/compose.override.yml", ".hack/.branch/compose.x.override.yml", + ".hack/.branch/compose.0b88.override.yml", ".hack/.env", ".hack/.env.state.json", ".hack/hack.env.local.yaml", @@ -199,6 +200,7 @@ test("nested .hack/.gitignore makes git ignore generated files, including in lin for (const path of [ ".hack/.internal/compose.override.yml", ".hack/.branch/compose.x.override.yml", + ".hack/.branch/compose.0b88.override.yml", ".hack/.env.state.json", ".hack/hack.env.qa.local.yaml", ".hack/tickets/git/bare.git", From eba4d8dd4f23fb963eb272029a4a264ea1db0d12 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 9 Jul 2026 22:19:40 -0400 Subject: [PATCH 3/5] Stabilize runtime integrity CI --- src/mux/tmux-backend.ts | 23 ++++++- tests/e2e/scenarios/agent-docs-sync.ts | 5 +- tests/e2e/scenarios/worktree-registry.ts | 7 +- tests/project-up-startup-state.test.ts | 11 +++ tests/tickets-store.test.ts | 2 + tests/tmux-backend.test.ts | 88 ++++++++++++++++++++++++ 6 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 tests/tmux-backend.test.ts diff --git a/src/mux/tmux-backend.ts b/src/mux/tmux-backend.ts index 434fd3c7..cd30c08d 100644 --- a/src/mux/tmux-backend.ts +++ b/src/mux/tmux-backend.ts @@ -8,6 +8,7 @@ import type { } from "./mux-backend.ts"; const LIFECYCLE_OWNER_OPTION = "@hack_lifecycle_owner"; +const LIFECYCLE_OWNER_ENV = "HACK_LIFECYCLE_OWNER_TOKEN"; function parseIntOrNull(value: string | undefined): number | null { const n = Number.parseInt(value ?? "", 10); @@ -106,6 +107,9 @@ export function createTmuxBackend(): MuxBackend { args.push("-e", `${key}=${value}`); } } + if (opts.lifecycleOwnerToken) { + args.push("-e", `${LIFECYCLE_OWNER_ENV}=${opts.lifecycleOwnerToken}`); + } const result = await exec(args, { stdin: "ignore" }); if (result.exitCode !== 0) { @@ -160,11 +164,24 @@ export function createTmuxBackend(): MuxBackend { ["tmux", "show-options", "-v", "-t", opts.name, LIFECYCLE_OWNER_OPTION], { stdin: "ignore" } ); - if (result.exitCode !== 0) { + if (result.exitCode === 0) { + const token = result.stdout.trim(); + if (token.length > 0) { + return token; + } + } + const environment = await exec( + ["tmux", "show-environment", "-t", opts.name, LIFECYCLE_OWNER_ENV], + { stdin: "ignore" } + ); + if (environment.exitCode !== 0) { return null; } - const token = result.stdout.trim(); - return token.length > 0 ? token : null; + const assignment = environment.stdout.trim(); + const prefix = `${LIFECYCLE_OWNER_ENV}=`; + return assignment.startsWith(prefix) + ? assignment.slice(prefix.length) + : null; }; const listSessionWindowNames = async (opts: { diff --git a/tests/e2e/scenarios/agent-docs-sync.ts b/tests/e2e/scenarios/agent-docs-sync.ts index 33a75689..2b778a53 100644 --- a/tests/e2e/scenarios/agent-docs-sync.ts +++ b/tests/e2e/scenarios/agent-docs-sync.ts @@ -266,8 +266,9 @@ export const agentDocsSyncScenario: Scenario = { }); expect({ that: - autoRepair.stderr.includes("Detected stale Hack agent integrations") && - autoRepair.stderr.includes("Reload the agent session"), + autoRepair.combined.includes( + "Detected stale Hack agent integrations" + ) && autoRepair.combined.includes("Reload the agent session"), message: "auto-repair must announce stale guidance and reload requirement", result: autoRepair, diff --git a/tests/e2e/scenarios/worktree-registry.ts b/tests/e2e/scenarios/worktree-registry.ts index 1700bf86..7705a749 100644 --- a/tests/e2e/scenarios/worktree-registry.ts +++ b/tests/e2e/scenarios/worktree-registry.ts @@ -41,7 +41,7 @@ export const worktreeRegistryScenario: Scenario = { }); const fromPrimary = await ctx.cli({ - args: ["config", "get", "name"], + args: ["projects", "--json"], cwd: fixture.root, }); expectExit({ @@ -55,7 +55,7 @@ export const worktreeRegistryScenario: Scenario = { branch: "e2e-registry", }); const fromWorktree = await ctx.cli({ - args: ["config", "get", "name"], + args: ["projects", "--json"], cwd: worktreePath, }); expectExit({ @@ -110,7 +110,8 @@ export const worktreeRegistryScenario: Scenario = { const realWorktreePath = await realpath(worktreePath); const worktrees = entries[0]?.worktrees ?? null; expect({ - that: worktrees?.some((entry) => entry.path === realWorktreePath) === true, + that: + worktrees?.some((entry) => entry.path === realWorktreePath) === true, message: "worktree checkout should be recorded on the registration (worktrees array)", result: projects, diff --git a/tests/project-up-startup-state.test.ts b/tests/project-up-startup-state.test.ts index 0c5335a0..c0ca8898 100644 --- a/tests/project-up-startup-state.test.ts +++ b/tests/project-up-startup-state.test.ts @@ -66,6 +66,15 @@ const runtimeBackendMock = await registerScopedModuleMock({ }, }); +const shellMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/shell.ts", + overrides: { + findExecutableInPath: (executableName: string) => + executableName === "docker" ? "/usr/bin/docker" : null, + }, +}); + const loggerMock = await registerScopedModuleMock({ importerPath: import.meta.path, specifier: "../src/ui/logger.ts", @@ -93,6 +102,7 @@ beforeAll(() => { branchesMock.activate(); runtimeBackendMock.activate(); runtimeProjectsMock.activate(); + shellMock.activate(); loggerMock.activate(); }); @@ -114,6 +124,7 @@ afterAll(() => { branchesMock.deactivate(); runtimeBackendMock.deactivate(); runtimeProjectsMock.deactivate(); + shellMock.deactivate(); loggerMock.deactivate(); }); diff --git a/tests/tickets-store.test.ts b/tests/tickets-store.test.ts index b70a53d0..e064122f 100644 --- a/tests/tickets-store.test.ts +++ b/tests/tickets-store.test.ts @@ -1404,6 +1404,8 @@ async function runAllowFail(opts: { env: { ...process.env, HOME: process.env.HOME ?? homedir(), + HACK_SETUP_SYNC_MODE: "off", + NO_COLOR: "1", }, }); diff --git a/tests/tmux-backend.test.ts b/tests/tmux-backend.test.ts new file mode 100644 index 00000000..7b97a4a2 --- /dev/null +++ b/tests/tmux-backend.test.ts @@ -0,0 +1,88 @@ +import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test"; + +import type { ExecOptions } from "../src/lib/shell.ts"; +import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; + +const execCalls: Array<{ + readonly command: readonly string[]; + readonly options: ExecOptions; +}> = []; + +const shellMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/shell.ts", + overrides: { + exec: async (command: readonly string[], options: ExecOptions = {}) => { + execCalls.push({ command: [...command], options }); + if (command.includes("list-sessions")) { + return { + exitCode: 0, + stdout: "demo\t0\t/repo\t1\t1783569600\n", + stderr: "", + }; + } + if (command.includes("show-options")) { + return { exitCode: 1, stdout: "", stderr: "missing option" }; + } + if (command.includes("show-environment")) { + return { + exitCode: 0, + stdout: "HACK_LIFECYCLE_OWNER_TOKEN=owner-123\n", + stderr: "", + }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }, + findExecutableInPath: () => "/usr/bin/tmux", + }, +}); + +beforeAll(() => { + shellMock.activate(); +}); + +beforeEach(() => { + execCalls.length = 0; +}); + +afterAll(() => { + shellMock.deactivate(); +}); + +async function loadTmuxBackend() { + const { createTmuxBackend } = await import( + `../src/mux/tmux-backend.ts?test=${Date.now()}-${Math.random()}` + ); + return createTmuxBackend(); +} + +test("tmux creates lifecycle ownership atomically and reads the env fallback", async () => { + const backend = await loadTmuxBackend(); + const created = await backend.createSession({ + name: "demo", + cwd: "/repo", + lifecycleOwnerToken: "owner-123", + }); + const ownerToken = await backend.readLifecycleOwnerToken?.({ name: "demo" }); + + expect(created.ok).toBe(true); + expect(ownerToken).toBe("owner-123"); + expect(execCalls[0]?.command).toEqual([ + "tmux", + "new-session", + "-d", + "-s", + "demo", + "-c", + "/repo", + "-e", + "HACK_LIFECYCLE_OWNER_TOKEN=owner-123", + ]); + expect(execCalls.map(({ command }) => command)).toContainEqual([ + "tmux", + "show-environment", + "-t", + "demo", + "HACK_LIFECYCLE_OWNER_TOKEN", + ]); +}); From c13ea11c819358d6deae92cf36c5f858502eabe9 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 9 Jul 2026 22:32:18 -0400 Subject: [PATCH 4/5] Address runtime integrity review findings --- src/commands/project.ts | 111 ++++++++++++++------ src/commands/tickets.ts | 17 ++- src/lib/compose-startup-state.ts | 11 +- src/lib/registry-credential-preflight.ts | 73 ++++++++++--- src/lib/shell.ts | 4 + tests/project-startup-state.test.ts | 21 +++- tests/project-up-startup-state.test.ts | 28 +++++ tests/registry-credential-preflight.test.ts | 27 +++++ tests/shell-timeout.test.ts | 56 +++++++++- 9 files changed, 291 insertions(+), 57 deletions(-) diff --git a/src/commands/project.ts b/src/commands/project.ts index 40f0d6ac..8cd41681 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -195,6 +195,7 @@ import { } from "../lib/projects-registry.ts"; import { discoverDependencyBootstrapServices, + discoverSuccessfulCompletionServices, preflightRegistryCredentials, RegistryCredentialPreflightError, } from "../lib/registry-credential-preflight.ts"; @@ -1266,7 +1267,9 @@ async function resolveModernComposeEnvOverrides(opts: { readonly composeFiles: readonly string[]; readonly env: Readonly>; readonly lifecycleEnv: Readonly>; - readonly preflightEnv: Readonly>; + readonly preflightEnvByService: Readonly< + Record>> + >; readonly effectiveEnvName: string | null; } | null> { const modern = await resolveProjectEnvConfig({ @@ -1283,12 +1286,11 @@ async function resolveModernComposeEnvOverrides(opts: { scopeName: "global", target: "host", }); - const preflightEnv = Object.assign( - {}, - modern.globalEnv, - ...opts.targetServices.map( - (service) => modern.serviceEnv[service] ?? modern.globalEnv - ) + const preflightEnvByService = Object.fromEntries( + opts.targetServices.map((service) => [ + service, + modern.serviceEnv[service] ?? modern.globalEnv, + ]) ); for (const scope of modern.unknownScopes) { @@ -1311,7 +1313,7 @@ async function resolveModernComposeEnvOverrides(opts: { composeFiles: [], env: modern.globalEnv, lifecycleEnv, - preflightEnv, + preflightEnvByService, effectiveEnvName: modern.selection.effectiveEnv, }; } @@ -1328,7 +1330,7 @@ async function resolveModernComposeEnvOverrides(opts: { composeFiles: [overridePath], env: modern.globalEnv, lifecycleEnv, - preflightEnv, + preflightEnvByService, effectiveEnvName: modern.selection.effectiveEnv, }; } @@ -1383,7 +1385,9 @@ async function resolveComposeEnvOverrides(opts: { readonly composeFiles: readonly string[]; readonly env: Readonly>; readonly lifecycleEnv: Readonly>; - readonly preflightEnv: Readonly>; + readonly preflightEnvByService: Readonly< + Record>> + >; readonly effectiveEnvName: string | null; }> { await maybePromptLegacyProjectEnvMigration({ @@ -1405,13 +1409,30 @@ async function resolveComposeEnvOverrides(opts: { resolved, targetServices: opts.targetServices, }); + const preflightEnvByService = Object.fromEntries( + opts.targetServices.map((service) => { + const env: Record = {}; + for (const value of resolved.values) { + if ( + value.value !== null && + isEnvVarRelevantToServices({ + services: [service], + varServices: value.services, + }) + ) { + env[value.key] = value.value; + } + } + return [service, env]; + }) + ); if (resolved.contract.vars.length === 0) { return { composeFiles: [], env: {}, lifecycleEnv: {}, - preflightEnv: {}, + preflightEnvByService, effectiveEnvName: resolved.envSelection.effectiveEnv, }; } @@ -1453,7 +1474,7 @@ async function resolveComposeEnvOverrides(opts: { composeFiles: [], env: resolved.envForCompose, lifecycleEnv: resolved.envForCompose, - preflightEnv: resolved.envForCompose, + preflightEnvByService, effectiveEnvName: resolved.envSelection.effectiveEnv, }; } @@ -1471,7 +1492,7 @@ async function resolveComposeEnvOverrides(opts: { composeFiles: [overridePath], env: resolved.envForCompose, lifecycleEnv: resolved.envForCompose, - preflightEnv: resolved.envForCompose, + preflightEnvByService, effectiveEnvName: resolved.envSelection.effectiveEnv, }; } @@ -1480,22 +1501,27 @@ async function assertRegistryCredentialsAvailable(opts: { readonly projectRoot: string; readonly composeFile: string; readonly targetServices: readonly string[]; - readonly env: Readonly>; + readonly envByService: Readonly< + Record>> + >; }): Promise { const bootstrapServices = await discoverDependencyBootstrapServices({ composeFile: opts.composeFile, }); - if ( - !bootstrapServices.some((service) => opts.targetServices.includes(service)) - ) { - return; - } - const result = await preflightRegistryCredentials({ - projectRoot: opts.projectRoot, - env: opts.env, - }); - if (result.missing.length > 0) { - throw new RegistryCredentialPreflightError({ missing: result.missing }); + for (const service of bootstrapServices) { + if (!opts.targetServices.includes(service)) { + continue; + } + const result = await preflightRegistryCredentials({ + projectRoot: opts.projectRoot, + env: opts.envByService[service] ?? {}, + }); + if (result.missing.length > 0) { + throw new RegistryCredentialPreflightError({ + missing: result.missing, + service, + }); + } } } @@ -5963,7 +5989,7 @@ async function runUpCommand({ projectRoot: project.projectRoot, composeFile: project.composeFile, targetServices, - env: envOverrides.preflightEnv, + envByService: envOverrides.preflightEnvByService, }); if (!serviceScoped) { @@ -6089,7 +6115,14 @@ async function runUpCommand({ profiles, services: targetServices, }); - const startup = classifyComposeStartupState(states); + const successfulCompletionServices = new Set( + await discoverSuccessfulCompletionServices({ + composeFile: project.composeFile, + }) + ); + const startup = classifyComposeStartupState(states, { + successfulCompletionServices, + }); if (startupSnapshotIsIncomplete({ states, failed: startup.failed })) { await lifecycleCleanup?.(); await removeProjectRuntimeStateEntry({ @@ -6534,7 +6567,7 @@ async function runRestartUpPhase(opts: { projectRoot: opts.project.projectRoot, composeFile: opts.project.composeFile, targetServices, - env: envOverrides.preflightEnv, + envByService: envOverrides.preflightEnvByService, }); const composeFilesWithEnv = [ ...composeFilesWithRuntimeOverrides, @@ -6711,7 +6744,7 @@ async function runTargetedServiceRestart(opts: { projectRoot: opts.project.projectRoot, composeFile: opts.project.composeFile, targetServices: opts.services, - env: envOverrides.preflightEnv, + envByService: envOverrides.preflightEnvByService, }); const dependencyCache = await resolveDependencyCacheOverride({ projectRoot: opts.project.projectRoot, @@ -6754,7 +6787,14 @@ async function runTargetedServiceRestart(opts: { profiles: opts.profiles, services: opts.services, }); - const startup = classifyComposeStartupState(states); + const successfulCompletionServices = new Set( + await discoverSuccessfulCompletionServices({ + composeFile: opts.project.composeFile, + }) + ); + const startup = classifyComposeStartupState(states, { + successfulCompletionServices, + }); if (startupSnapshotIsIncomplete({ states, failed: startup.failed })) { return { ok: false, @@ -6943,7 +6983,7 @@ async function runRestartCommand({ projectRoot: project.projectRoot, composeFile: project.composeFile, targetServices: allServiceNames, - env: preflightEnv.preflightEnv, + envByService: preflightEnv.preflightEnvByService, }); const downCode = await runRestartDownPhase({ @@ -7010,7 +7050,14 @@ async function runRestartCommand({ composeProjectName, profiles, }); - const startup = classifyComposeStartupState(states); + const successfulCompletionServices = new Set( + await discoverSuccessfulCompletionServices({ + composeFile: project.composeFile, + }) + ); + const startup = classifyComposeStartupState(states, { + successfulCompletionServices, + }); if (startupSnapshotIsIncomplete({ states, failed: startup.failed })) { await stopLifecycleProcessesBestEffort({ project, diff --git a/src/commands/tickets.ts b/src/commands/tickets.ts index f91ec7e8..3e8c3e19 100644 --- a/src/commands/tickets.ts +++ b/src/commands/tickets.ts @@ -46,12 +46,9 @@ async function handleTickets({ readonly ctx: CliContext; readonly args: TicketsArgs; }): Promise { - logger.warn({ - message: - "Hack Tickets is deprecated and no longer part of agent guidance. Commands remain available only for compatibility and migration.", - }); // Parse command early to check if it's setup (which bypasses enable check) const invocation = parseTicketsInvocation({ argv: args.raw.argv }); + warnTicketsDeprecationUnlessJson({ invocation }); const isSetupCommand = invocation.command === "setup"; const loaded = await loadExtensionManagerForCli({ cwd: ctx.cwd }); @@ -163,6 +160,18 @@ async function handleTickets({ }); } +function warnTicketsDeprecationUnlessJson(opts: { + readonly invocation: TicketsInvocation; +}): void { + if (opts.invocation.args.includes("--json")) { + return; + } + logger.warn({ + message: + "Hack Tickets is deprecated and no longer part of agent guidance. Commands remain available only for compatibility and migration.", + }); +} + type TicketsInvocation = { readonly command?: string; readonly args: readonly string[]; diff --git a/src/lib/compose-startup-state.ts b/src/lib/compose-startup-state.ts index ed2bd93a..3951d5a4 100644 --- a/src/lib/compose-startup-state.ts +++ b/src/lib/compose-startup-state.ts @@ -12,7 +12,10 @@ export type ComposeStartupState = { /** Classify Compose services without treating successful one-shot containers as failures. */ export function classifyComposeStartupState( - states: readonly ComposeServiceState[] + states: readonly ComposeServiceState[], + opts: { + readonly successfulCompletionServices?: ReadonlySet; + } = {} ): ComposeStartupState { const running: string[] = []; const completed: string[] = []; @@ -23,7 +26,11 @@ export function classifyComposeStartupState( running.push(entry.service); continue; } - if (entry.state === "exited" && entry.exitCode === 0) { + if ( + entry.state === "exited" && + entry.exitCode === 0 && + opts.successfulCompletionServices?.has(entry.service) + ) { completed.push(entry.service); continue; } diff --git a/src/lib/registry-credential-preflight.ts b/src/lib/registry-credential-preflight.ts index ecb08d07..fa554709 100644 --- a/src/lib/registry-credential-preflight.ts +++ b/src/lib/registry-credential-preflight.ts @@ -16,13 +16,21 @@ export type RegistryCredentialReference = { export class RegistryCredentialPreflightError extends Error { readonly missing: readonly RegistryCredentialReference[]; + readonly service?: string; constructor(opts: { readonly missing: readonly RegistryCredentialReference[]; + readonly service?: string; }) { - super(formatRegistryCredentialPreflightFailure({ missing: opts.missing })); + super( + formatRegistryCredentialPreflightFailure({ + missing: opts.missing, + service: opts.service, + }) + ); this.name = "RegistryCredentialPreflightError"; this.missing = opts.missing; + this.service = opts.service; } } @@ -58,20 +66,21 @@ function serializeComposeCommand(value: unknown): string { return ""; } -function hasDependencyBootstrapLabel(value: unknown): boolean { - if (isRecord(value)) { - return ( - value["hack.dependencies.bootstrap"] === true || - value["hack.dependencies.bootstrap"] === "true" - ); +function hasComposeLabel(opts: { + readonly labels: unknown; + readonly name: string; +}): boolean { + const { labels, name } = opts; + if (isRecord(labels)) { + return labels[name] === true || labels[name] === "true"; } - if (!Array.isArray(value)) { + if (!Array.isArray(labels)) { return false; } - return value.some( + return labels.some( (entry) => typeof entry === "string" && - entry.trim().toLowerCase() === "hack.dependencies.bootstrap=true" + entry.trim().toLowerCase() === `${name.toLowerCase()}=true` ); } @@ -96,8 +105,10 @@ export async function discoverDependencyBootstrapServices(opts: { } const command = `${serializeComposeCommand(value.entrypoint)} ${serializeComposeCommand(value.command)}`; return ( - hasDependencyBootstrapLabel(value.labels) || - DEPENDENCY_INSTALL_PATTERN.test(command) + hasComposeLabel({ + labels: value.labels, + name: "hack.dependencies.bootstrap", + }) || DEPENDENCY_INSTALL_PATTERN.test(command) ); }) .map(([service]) => service) @@ -107,6 +118,40 @@ 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; +}): Promise { + const bootstrapServices = await discoverDependencyBootstrapServices(opts); + const text = await readTextFile(opts.composeFile); + if (!text) { + return bootstrapServices; + } + try { + const parsed: unknown = YAML.parse(text); + const services = + isRecord(parsed) && isRecord(parsed.services) ? parsed.services : null; + if (!services) { + return bootstrapServices; + } + const explicitOneShots = Object.entries(services) + .filter( + ([, value]) => + isRecord(value) && + hasComposeLabel({ + labels: value.labels, + name: "hack.service.one-shot", + }) + ) + .map(([service]) => service); + return [...new Set([...bootstrapServices, ...explicitOneShots])].sort( + (left, right) => left.localeCompare(right) + ); + } catch { + return bootstrapServices; + } +} + export async function preflightRegistryCredentials(opts: { readonly projectRoot: string; readonly env: Readonly>; @@ -128,10 +173,12 @@ export async function preflightRegistryCredentials(opts: { export function formatRegistryCredentialPreflightFailure(opts: { readonly missing: readonly RegistryCredentialReference[]; + readonly service?: string; }): string { const unique = [...new Set(opts.missing.map((reference) => reference.key))]; const locations = opts.missing .map((reference) => `${reference.path}:${reference.line}`) .join(", "); - return `Missing package-registry credential${unique.length === 1 ? "" : "s"}: ${unique.join(", ")}. Referenced by ${locations}. Add the value to the selected Hack env overlay before startup.`; + const service = opts.service ? ` for service ${opts.service}` : ""; + return `Missing package-registry credential${unique.length === 1 ? "" : "s"}${service}: ${unique.join(", ")}. Referenced by ${locations}. Add the value to that service's selected Hack env scope before startup.`; } diff --git a/src/lib/shell.ts b/src/lib/shell.ts index bd82469a..b64f0aa2 100644 --- a/src/lib/shell.ts +++ b/src/lib/shell.ts @@ -126,6 +126,10 @@ function installSubprocessTimeout(opts: { clearTimeout(timer); if (forceKillTimer) { clearTimeout(forceKillTimer); + // The direct child may honor SIGTERM before its descendants do. Once + // the child exits, finish cleaning the detached process group instead + // of cancelling the only pending SIGKILL and orphaning descendants. + signalProcessGroup("SIGKILL"); } }, didTimeout: () => timedOut, diff --git a/tests/project-startup-state.test.ts b/tests/project-startup-state.test.ts index 538a4127..ba2751fd 100644 --- a/tests/project-startup-state.test.ts +++ b/tests/project-startup-state.test.ts @@ -7,10 +7,15 @@ import { test("startup accepts running services and successful one-shot services", () => { expect( - classifyComposeStartupState([ - { service: "api", state: "running", exitCode: 0 }, - { service: "migrate", state: "exited", exitCode: 0 }, - ]) + classifyComposeStartupState( + [ + { service: "api", state: "running", exitCode: 0 }, + { service: "migrate", state: "exited", exitCode: 0 }, + ], + { + successfulCompletionServices: new Set(["migrate"]), + } + ) ).toEqual({ running: ["api"], completed: ["migrate"], @@ -18,6 +23,14 @@ test("startup accepts running services and successful one-shot services", () => }); }); +test("startup rejects an unmarked long-running service that exited zero", () => { + expect( + classifyComposeStartupState([ + { service: "api", state: "exited", exitCode: 0 }, + ]) + ).toEqual({ running: [], completed: [], failed: ["api"] }); +}); + test("startup rejects containers left created even when compose returned success", () => { expect( classifyComposeStartupState([ diff --git a/tests/project-up-startup-state.test.ts b/tests/project-up-startup-state.test.ts index c0ca8898..938968ad 100644 --- a/tests/project-up-startup-state.test.ts +++ b/tests/project-up-startup-state.test.ts @@ -166,6 +166,15 @@ test("up accepts running and successfully completed one-shot services", async () expect(errorMessages).toEqual([]); }); +test("registry credentials must exist in the bootstrap service scope", async () => { + const projectRoot = await createProject({ registryTokenScope: "api" }); + + await expect(runDetachedUp({ projectRoot })).rejects.toThrow( + "Missing package-registry credential for service deps: GITHUB_TOKEN" + ); + expect(upEnvs).toEqual([]); +}); + test("restart returns failure when compose reports a created service after exit zero", async () => { const projectRoot = await createProject(); psRows.push( @@ -338,6 +347,7 @@ async function runJsonUp(opts: { async function createProject(opts?: { readonly lifecycleMarkerFile?: string; + readonly registryTokenScope?: "api" | "deps"; }): Promise { const projectRoot = await mkdtemp( resolve(tmpdir(), "hack-up-startup-state-") @@ -355,6 +365,15 @@ async function createProject(opts?: { " image: alpine:3.20", " migrate:", " image: alpine:3.20", + " labels:", + ' hack.service.one-shot: "true"', + ...(opts?.registryTokenScope + ? [ + " deps:", + " image: oven/bun", + " command: bun install --frozen-lockfile", + ] + : []), "", ].join("\n") ); @@ -398,8 +417,17 @@ async function createProject(opts?: { " host:", ' SHARED_MODE: "host"', ' HOST_ONLY: "host-only"', + ...(opts?.registryTokenScope + ? [` ${opts.registryTokenScope}:`, ' GITHUB_TOKEN: "scoped-token"'] + : []), "", ].join("\n") ); + if (opts?.registryTokenScope) { + await writeFile( + resolve(projectRoot, ".npmrc"), + "//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}\n" + ); + } return projectRoot; } diff --git a/tests/registry-credential-preflight.test.ts b/tests/registry-credential-preflight.test.ts index 19ce2c76..26fba7dc 100644 --- a/tests/registry-credential-preflight.test.ts +++ b/tests/registry-credential-preflight.test.ts @@ -5,6 +5,7 @@ import { resolve } from "node:path"; import { discoverDependencyBootstrapServices, discoverRegistryCredentialReferences, + discoverSuccessfulCompletionServices, preflightRegistryCredentials, } from "../src/lib/registry-credential-preflight.ts"; @@ -64,3 +65,29 @@ test("dependency bootstrap detection is name agnostic", async () => { "arbitrary-installer", ]); }); + +test("successful completion requires installer semantics or an explicit one-shot label", async () => { + const projectRoot = await createTempProject(); + const composeFile = resolve(projectRoot, "compose.yml"); + await writeFile( + composeFile, + [ + "services:", + " installer:", + " image: oven/bun", + " command: bun install", + " migrate:", + " image: app", + " labels:", + ' hack.service.one-shot: "true"', + " api:", + " image: app", + "", + ].join("\n") + ); + + expect(await discoverSuccessfulCompletionServices({ composeFile })).toEqual([ + "installer", + "migrate", + ]); +}); diff --git a/tests/shell-timeout.test.ts b/tests/shell-timeout.test.ts index d7f8fed8..33e745e3 100644 --- a/tests/shell-timeout.test.ts +++ b/tests/shell-timeout.test.ts @@ -1,6 +1,25 @@ -import { expect, test } from "bun:test"; +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; -import { exec } from "../src/lib/shell.ts"; +import { exec, run } from "../src/lib/shell.ts"; + +const tempDirs: string[] = []; +const childPids: number[] = []; + +afterEach(async () => { + for (const pid of childPids.splice(0)) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // Already reaped by the timeout cleanup under test. + } + } + await Promise.all( + tempDirs.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ); +}); test("exec timeout terminates the subprocess group and reports 124", async () => { const startedAt = Date.now(); @@ -12,3 +31,36 @@ test("exec timeout terminates the subprocess group and reports 124", async () => expect(result.exitCode).toBe(124); expect(Date.now() - startedAt).toBeLessThan(2000); }); + +test("run timeout kills descendants after the direct child exits on SIGTERM", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "hack-shell-timeout-")); + tempDirs.push(dir); + const pidFile = resolve(dir, "child.pid"); + + const exitCode = await run( + [ + "/bin/sh", + "-c", + `trap 'exit 0' TERM; (trap '' TERM; sleep 30) & echo $! > '${pidFile}'; wait`, + ], + { stdin: "ignore", timeoutMs: 50 } + ); + expect(exitCode).toBe(124); + + const childPid = Number.parseInt( + (await readFile(pidFile, "utf8")).trim(), + 10 + ); + childPids.push(childPid); + await Bun.sleep(50); + expect(isProcessAlive(childPid)).toBe(false); +}); + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} From ff03aea5a97eb24240a78a70825bc2695d5141f9 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 9 Jul 2026 22:39:18 -0400 Subject: [PATCH 5/5] Preserve profile and env overlay semantics --- src/commands/project.ts | 2 +- src/lib/project-env-config.ts | 15 +++++------ tests/project-env-config.test.ts | 35 ++++++++++++++++++++++++++ tests/project-up-startup-state.test.ts | 9 ++++++- 4 files changed, 50 insertions(+), 11 deletions(-) diff --git a/src/commands/project.ts b/src/commands/project.ts index 8cd41681..dcb7c779 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -6061,7 +6061,7 @@ async function runUpCommand({ cwd: dirname(project.composeFile), env: envOverrides.env, routeStdoutToStderr: json, - services: targetServices, + services: serviceScoped ? targetServices : undefined, noDeps: serviceScoped, }); if (upCode !== 0) { diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index 02c1ad47..aef57b50 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -922,22 +922,19 @@ function resolveLayeredProjectEnvValuesForScopes(opts: { readonly scopeNames: readonly string[]; readonly keyText: string | null; }): Record { - const out: Record = {}; + const storedValues: Record = {}; for (const layer of opts.layers) { if (!layer) { continue; } for (const scopeName of opts.scopeNames) { - Object.assign( - out, - resolveProjectEnvScopeValues({ - values: layer.values[scopeName] ?? {}, - keyText: opts.keyText, - }) - ); + Object.assign(storedValues, layer.values[scopeName] ?? {}); } } - return out; + return resolveProjectEnvScopeValues({ + values: storedValues, + keyText: opts.keyText, + }); } function hasSecretEntries(opts: { diff --git a/tests/project-env-config.test.ts b/tests/project-env-config.test.ts index d131e719..43a15d20 100644 --- a/tests/project-env-config.test.ts +++ b/tests/project-env-config.test.ts @@ -427,6 +427,41 @@ test("resolveProjectEnvConfig falls back to HACK_ENV_SECRET_KEY when the key fil expect(resolved?.serviceEnv.api?.SERVICE_TOKEN).toBe("super-secret-token"); }); +test("resolveProjectEnvConfig decrypts only effective values after overlays", async () => { + const repo = await createRepo(); + await setProjectEnvValue({ + projectRoot: repo.projectRoot, + projectDir: repo.projectDir, + envName: null, + scope: "api", + key: "SERVICE_TOKEN", + value: "stale-secret", + secret: true, + }); + await unlink(resolve(repo.projectRoot, PROJECT_ENV_KEY_FILENAME)); + await writeFile( + resolve(repo.projectDir, "hack.env.qa.yaml"), + [ + "version: 1", + "environment: qa", + "secretsprovider: project_key", + "values:", + " api:", + " SERVICE_TOKEN: overlay-plain", + "", + ].join("\n") + ); + + const resolved = await resolveProjectEnvConfig({ + projectRoot: repo.projectRoot, + projectDir: repo.projectDir, + envName: "qa", + serviceNames: ["api", "web"], + }); + + expect(resolved?.serviceEnv.api?.SERVICE_TOKEN).toBe("overlay-plain"); +}); + test("resolveProjectEnvConfig merges shared and worktree-local overlays in order", async () => { const repo = await createRepo(); diff --git a/tests/project-up-startup-state.test.ts b/tests/project-up-startup-state.test.ts index 938968ad..be7e42e2 100644 --- a/tests/project-up-startup-state.test.ts +++ b/tests/project-up-startup-state.test.ts @@ -14,6 +14,7 @@ const psRows: string[] = []; const errorMessages: string[] = []; const warnMessages: string[] = []; const upEnvs: Array> | undefined> = []; +const upServiceSelections: Array = []; const tempDirs = new Set(); const originalHackHome = process.env.HACK_HOME; let autoBranch: string | null = null; @@ -49,8 +50,12 @@ const runtimeBackendMock = await registerScopedModuleMock({ overrides: { composeRuntimeBackend: { name: "compose", - up: async (opts: { readonly env?: Readonly> }) => { + up: async (opts: { + readonly env?: Readonly>; + readonly services?: readonly string[]; + }) => { upEnvs.push(opts.env); + upServiceSelections.push(opts.services); return 0; }, down: async () => 0, @@ -111,6 +116,7 @@ afterEach(async () => { errorMessages.length = 0; warnMessages.length = 0; upEnvs.length = 0; + upServiceSelections.length = 0; autoBranch = null; runtimeProjects = []; for (const tempDir of tempDirs) { @@ -164,6 +170,7 @@ test("up accepts running and successfully completed one-shot services", async () expect(exitCode).toBe(0); expect(errorMessages).toEqual([]); + expect(upServiceSelections).toEqual([undefined]); }); test("registry credentials must exist in the bootstrap service scope", async () => {