Many projects need host-side setup around hack up (auth, local proxies, tunnels). Lifecycle config lets you run short-lived commands and manage long-running host processes alongside the project runtime.
Lifecycle is configured in .hack/hack.config.json under lifecycle.
You can also use a shorthand startup array for common hack up startup flows.
Some lifecycle-managed helpers bind fixed local ports and are easy to start outside Hack:
- AWS SSM port-forwarding tunnels
- local API or database proxies
- search/dashboard tunnels
Without an ownership hint, hack up can only assume it should launch the configured command again. If a
matching helper is already running manually, or a previous reconnect loop already restored it, that can
create duplicate supervisors, partial port conflicts, or aggressive cleanup/restart churn.
singleton exists to make those fixed-port helpers explicit. It gives Hack a conservative rule:
- start the helper when none of its expected listeners exist
- adopt the existing helper when the full listener set is already healthy and the config opts into reuse
- fail fast on partial occupancy instead of trying to replace part of a live tunnel stack
That keeps Hack local-first and predictable. It avoids turning "something equivalent is already running" into "kill and recreate it", which is especially important for brittle port-forwarding workflows.
{
"$schema": "https://schemas.hack/hack.config.schema.json",
"lifecycle": {
"up": {
"before": [
{ "name": "aws sso login", "command": "aws sso login", "cwd": "." },
{
"name": "aws-ssm-proxy",
"command": "bun run proxy",
"cwd": "packages/infra",
"persistent": true,
"singleton": {
"ports": [3306, 9200, 9201, 8443, 8444, 8445],
"onConflict": "adopt"
}
}
],
"after": []
},
"down": {
"before": [],
"after": []
},
"processes": [
{
"name": "aws-proxy",
"command": "bun run dev:aws-proxy",
"cwd": "."
}
]
}
}startup is a concise alias for startup hooks/processes:
persistent: false(default) maps tolifecycle.up.beforepersistent: truemaps tolifecycle.processes
{
"$schema": "https://schemas.hack/hack.config.schema.json",
"startup": [
{
"name": "aws sso",
"run": "aws sso login",
"persistent": false
},
{
"name": "aws-ssm-proxy",
"run": "cd packages/infra && bun run proxy",
"persistent": true,
"singleton": {
"ports": [3306, 9200, 9201, 8443, 8444, 8445],
"onConflict": "adopt"
}
}
]
}Each startup item can be:
- a string command (one-shot startup hook), or
- an object with:
run(orcommand) requirednameoptionalcwdoptionalpersistentoptional boolean (defaultfalse)singletonoptional object withportsand optionalonConflict
cwd is always resolved from the repo root (not from .hack/).
A persistent: true startup entry with no name gets a generated name (startup-1, startup-2, ...
in config order). That generated name is what shows up as the window name and what you pass to
hack logs <name>, so naming persistent entries explicitly makes logs easier to find.
Hook lists live under:
lifecycle.up.beforelifecycle.up.afterlifecycle.down.beforelifecycle.down.after
Each entry can be either:
- a string (shell command), or
- an object:
name(optional): label for logscommand(required): shell commandcwd(optional): working directory; relative paths are resolved from repo rootpersistent(optional):truemeans "start this as a managed lifecycle process"singleton(optional): local listener policy with:portsrequired array of local TCP listener portsonConflictoptional"adopt"or"fail"(default"fail")
Hooks run on the host as sh -c <command>. Failures stop the operation.
Commands inherit the CLI process environment (including PATH), plus resolved project env vars.
persistent behavior:
- In
lifecycle.up.before,persistent: truestarts the command in the lifecycle mux session and immediately continues to the next hook. - Ordering is preserved: each persistent hook is started in sequence before moving to later hooks.
- In other hook lists (
up.after,down.before,down.after),persistentis ignored and the hook runs as a normal blocking command.
singleton behavior:
- Hack checks the configured local TCP listener ports before starting the lifecycle process.
- If none of the configured ports are in use, Hack starts the process normally.
- If all configured ports already have listeners and
onConflictis"adopt", Hack skips startup, records an adoption note, and leaves the external process alone onhack down. - If only some configured ports are already occupied, Hack fails fast instead of launching a competing partial replacement.
- If all configured ports already have listeners and
onConflictis omitted or"fail", Hack stops with an explicit error so the operator can decide whether to stop or reuse the existing process.
Important boundaries:
singletonis a listener-level guard, not a deep process identity check.- Adoption means "Hack observed the full expected local listener set and will not start another copy". It does not mean Hack now owns or can safely tear down that external process.
hack downonly stops processes Hack actually launched inside the lifecycle session. Adopted external listeners are intentionally left alone.
Long-running processes live under lifecycle.processes and are objects with:
name(required): stable identifier (used for window naming)command(required): shell command (run in a mux session shell)cwd(optional): working directory (defaults to repo root)singleton(optional): listener-ownership policy withportsandonConflict
Processes receive the resolved env contract (see env.md) as their environment.
Lifecycle output is now surfaced across CLI/runtime views:
hack logsincludes lifecycle hook/process output alongside compose logs.hack logs <service>supports lifecycle service names, including persistent process names.hack projects --detailsincludes aStartup & lifecyclesection with hooks + persistent processes.- Runtime inventories include persistent lifecycle processes as synthetic services.
- The macOS project detail view includes a
Startup & Lifecyclesection in the main project overview.
- Resolve env contract (and optionally prompt for missing required env in interactive shells).
- Run
lifecycle.up.beforehooks in order. - For
up.beforehooks withpersistent: true, start each as a managed lifecycle process and continue immediately. - Start lifecycle processes (if any) inside the same dedicated lifecycle session.
- Run
docker compose up(orup -dwhen--detach). - Run
lifecycle.up.afterhooks.
Before starting persistent work, Hack reconciles the expected lifecycle session with
.hack/.internal/lifecycle/state.json:
- A session whose mux ownership token, saved definition hash, and live process windows all match is adopted without starting duplicate processes.
- A token-owned stale session is replaced. Pre-ownership tmux sessions are replaced only when saved state, checkout path, creation time, and window count provide deterministic legacy ownership proof.
- A same-name session without matching ownership proof is left untouched and
hack upfails with an actionable collision error.
If the operation later fails in Compose or up.after, Hack removes only the lifecycle session created
by that operation. The same exact-token cleanup runs for supported SIGINT and SIGTERM paths, including
signals received while the mux session is still being initialized.
- Run
lifecycle.down.beforehooks. - Run
docker compose down. - Stop lifecycle processes by killing the lifecycle session.
- Run
lifecycle.down.afterhooks.
If lifecycle.down.before fails, shutdown is aborted before Compose or lifecycle processes are
stopped. hack restart preserves the same guard semantics during its down phase.
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 <service...> 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 <name> targets only that explicit branch.
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. If Compose exits successfully but one or more containers remain created, exit
non-zero, or enter another failed runtime state, Hack returns E_STARTUP_INCOMPLETE. Successful
one-shot services (exited with code 0) are reported under services.completed and do not fail startup.
Hack recognizes dependency installers, services labeled hack.service.one-shot=true, and services
referenced by another service with Compose condition: service_completed_successfully. An unrelated
service that merely exits zero is still treated as incomplete startup.
Lifecycle failures are one of the primary consumers of the --json error envelope,
since hook and process startup are common failure points around hack up.
Lifecycle processes run inside the configured mux backend (tmux or zellij), using the same selection rules as hack session.
Config:
- Project:
.hack/hack.config.json→sessions.mux = auto|tmux|zellij|none - Env override:
HACK_SESSIONS_MUX=auto|tmux|zellij|none
Lifecycle session name:
- No branch:
<project>--lifecycle - With
--branch <name>:<project>--lifecycle-<branch>
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 <service>.
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.
- Current lifecycle sessions carry the same random ownership token in mux metadata and persisted state. Cleanup requires an exact token match; deterministic names alone never authorize session teardown.
- Healthy-session adoption also matches a stable SHA-256 fingerprint of the effective overlay and sorted lifecycle environment. Environment names, keys, and values are never persisted; changing an overlay or value replaces the owned session so host helpers cannot retain stale configuration.
- For tmux-backed lifecycle sessions, Hack also persists the pane PID and the wrapped command's actual
process-group metadata to
.hack/.internal/lifecycle/state.json, with per-hook/process output logged to.hack/.internal/lifecycle/*.log. If tmux pane state disappears before teardown,hack downstill uses that persisted metadata to clean up any live lifecycle process groups instead of leaving orphaned host processes behind. hack doctorreports stale lifecycle state, leaderless process groups, ownership collisions, and ownership-proven sessions with no running Compose instance. Recently updated sessions are treated as possible in-flight startups for five minutes and stay untouched.hack doctor --fixreaps 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 withhack ps --jsonbefore deciding whether to retry or tear it down.
- For long-running setup commands, use either
lifecycle.processesorlifecycle.up.beforewithpersistent: true. - For local proxy/tunnel helpers that bind fixed ports, prefer
singleton.portswithonConflict: "adopt"sohack upcan reuse an already-running external tunnel instead of racing it. - Keep non-persistent hooks short and deterministic.
- Store lifecycle secrets in
hack.env.*.yamlas encryptedsecure:values and prefer runtime injection over relying on.hack/.env. - If a hook requires interactive auth (e.g. browser-based SSO), it will still work; it runs with
stdin: inherit. - Global
--no-interactive(orHACK_NO_INTERACTIVE=1) affects only the env-prompt step inhack up(step 1 above): with prompting disallowed, missing required env is never prompted for — the run fails fast with an actionable error instead of blocking on a prompt, following the same--no-interactiveconvention used across the CLI (documented defaults orE_INTERACTIVE_REQUIRED). It does not change hookstdinbehavior — a hook that itself blocks on interactive auth still blocks under--no-interactive.