@miroshnik/elves 0.0.0 → 2026.1018.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/AGENTS.md +310 -0
  2. package/README.md +344 -0
  3. package/bin/ai-dev.ts +7 -0
  4. package/bin/elves.ts +2847 -0
  5. package/bin/shims/timeout +113 -0
  6. package/claude/CLAUDE.md +71 -0
  7. package/dist/github/scripts/github.js +3428 -0
  8. package/dist/spec/scripts/architecture.js +628 -0
  9. package/dist/spec/scripts/eslint-config.js +54 -0
  10. package/dist/spec/scripts/harness-qzb932c8.js +959 -0
  11. package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
  12. package/dist/spec/scripts/harness.js +53 -0
  13. package/dist/spec/scripts/knip-hints.cjs +34 -0
  14. package/docs/ci-concurrency.md +82 -0
  15. package/docs/cloud-sessions.md +203 -0
  16. package/docs/judgment.md +81 -0
  17. package/docs/parallel-checkouts.md +220 -0
  18. package/docs/pr-checks.md +117 -0
  19. package/docs/testing.md +304 -0
  20. package/package.json +70 -1
  21. package/skills/ci-wait/SKILL.md +180 -0
  22. package/skills/ci-wait/scripts/wait-ci.sh +377 -0
  23. package/skills/dashboard/SKILL.md +212 -0
  24. package/skills/dashboard/scripts/dashboard.ts +912 -0
  25. package/skills/dashboard/scripts/sessions.ts +772 -0
  26. package/skills/est/SKILL.md +221 -0
  27. package/skills/est/reference.md +381 -0
  28. package/skills/est/scripts/est.ts +4854 -0
  29. package/skills/github/SKILL.md +206 -0
  30. package/skills/github/locales/en.json +449 -0
  31. package/skills/github/locales/ru.json +449 -0
  32. package/skills/github/reference.md +1073 -0
  33. package/skills/github/scripts/github.ts +4390 -0
  34. package/skills/github/scripts/locale.ts +495 -0
  35. package/skills/github/scripts/package.json +3 -0
  36. package/skills/slot/SKILL.md +145 -0
  37. package/skills/slot/scripts/package.json +3 -0
  38. package/skills/slot/scripts/slot.ts +279 -0
  39. package/skills/spec/SKILL.md +247 -0
  40. package/skills/spec/canon.md +219 -0
  41. package/skills/spec/checks.md +709 -0
  42. package/skills/spec/ci.md +378 -0
  43. package/skills/spec/migration.md +125 -0
  44. package/skills/spec/reference.md +889 -0
  45. package/skills/spec/scripts/architecture.ts +839 -0
  46. package/skills/spec/scripts/c4.ts +184 -0
  47. package/skills/spec/scripts/eslint-config.ts +82 -0
  48. package/skills/spec/scripts/harness.ts +1483 -0
  49. package/skills/spec/scripts/knip-hints.cjs +34 -0
  50. package/skills/spec/scripts/knip-hints.d.cts +17 -0
  51. package/skills/spec/scripts/package-run.ts +22 -0
  52. package/skills/spec/scripts/package.json +3 -0
  53. package/skills/spec/scripts/spec-break.ts +232 -0
  54. package/skills/spec/scripts/spec-claims.ts +198 -0
  55. package/skills/spec/scripts/spec-diff.ts +1111 -0
  56. package/skills/spec/scripts/spec-doc.ts +744 -0
  57. package/skills/spec/scripts/spec-exceptions.ts +233 -0
  58. package/skills/spec/scripts/spec-publish.ts +214 -0
  59. package/skills/spec/scripts/spec-run.ts +235 -0
  60. package/skills/spec/scripts/spec-shard.ts +267 -0
  61. package/skills/spec/scripts/speclib.ts +826 -0
  62. package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
  63. package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
  64. package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
  65. package/skills/task-env/SKILL.md +231 -0
  66. package/skills/task-env/proxy/compose.yaml +51 -0
  67. package/skills/task-env/reference.md +254 -0
  68. package/skills/task-env/scripts/task-env.ts +1017 -0
@@ -0,0 +1,1017 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * task-env — a task's environment (the task-env skill): the task's stack is the Docker Compose project `<project>-<N>`;
4
+ * the machine's proxy — one Traefik per machine — gives containers of task stacks the addresses
5
+ * `http://<service>.<N>.<project>.localhost` by their labels, without ports on the host (tests/capabilities/task-env).
6
+ *
7
+ * `up` brings the task's stack up: the number from the branch `<type>/<N>-slug`, the Compose project `<project>-<N>`
8
+ * (without a number — `<project>`), the project's compose files plus the task's override on Compose's stdin — the
9
+ * proxy's labels and network for the HTTP services of `env.http` in `.agents/elves.json`; their addresses go to Compose's
10
+ * interpolation in `TASK_ENV_<SERVICE>_URL` (the compose file gives the app its own address); it prints the addresses.
11
+ * `e2e` brings `<project>-<N>-e2e` up anew with clean volumes and runs the command on the host with the e2e addresses
12
+ * in `TASK_ENV_*` variables; `down` takes both stacks of a task down with their volumes — `task close` calls it; `prune`
13
+ * takes down the stacks of the project's closed tasks left on the machine — orphans, the task's state by GitHub. A stack
14
+ * that didn't come up — each failed service with its state and the end of its log.
15
+ *
16
+ * `check` holds the project's compose file to the isolation rules: Compose keeps two stacks of one project apart by the
17
+ * project name — containers, volumes and networks get its prefix — and four entries bypass the prefix and break
18
+ * isolation silently: `container_name`, a fixed host port, a volume or a network with `name:` or `external`, an
19
+ * absolute path to code in `volumes` and `build`. It reads not the YAML but `docker compose config --format json`:
20
+ * Compose has already brought the forms of writing (short and long syntax, the override file, variables, `extends`) to
21
+ * one. Without path resolution — otherwise Compose makes relative paths absolute itself; with all profiles — otherwise
22
+ * heavy services in `profiles` aren't shown. `config` needs no Docker daemon, it works in the sandbox too. `check` of
23
+ * the Elves installer calls it for a project with a compose file.
24
+ *
25
+ * The proxy is the Compose project of `proxy/compose.yaml` next to this directory: the Docker provider with
26
+ * `exposedByDefault: false` (a container without labels isn't published), its own network, the `web` entry point on
27
+ * port 80 of 127.0.0.1, `restart: unless-stopped` (it comes back with Docker). A container leaves the routes when it
28
+ * stops: Traefik follows Docker events. A task stack joins the proxy network as `external` — the proxy creates it, so
29
+ * it goes up before the stack.
30
+ *
31
+ * The name (Compose project and network) and the port come from ELVES_PROXY and ELVES_PROXY_PORT, by default
32
+ * `elves-proxy` and 80: a test runs its own proxy next to the machine's one, a machine with port 80 busy takes
33
+ * another. The defaults live only here: the script passes both to the compose file explicitly (`composeEnv`), the file
34
+ * has none (`${…:?}`); `check -g` of the installer imports `proxyName`.
35
+ *
36
+ * The rename of the flow to Elves renamed the proxy: `FORMER_PROXY` → `PROXY`. For one release `proxy up` takes the
37
+ * former proxy's place: removes its container — it holds the port — and joins its network, where stacks brought up
38
+ * before the rename stay, so their addresses keep working; `check` names the former network in a project's compose
39
+ * file. Which network the proxy reaches a container through is deterministic: a task
40
+ * stack's service names it by a label, the provider's default is the former network while the proxy is in it
41
+ * (`proxyLabels`, `up`).
42
+ *
43
+ * The proxy's API (Traefik `/api/http/routers`) answers on its port by the host `<name>.localhost`: `proxyHosts` reads
44
+ * the hosts of its routes, `taskAddress` tells a task's address among them — the dashboard shows a task's services by
45
+ * them over the loopback, without the Docker socket.
46
+ *
47
+ * Docker runs outside the Claude Code sandbox (the sandbox denies its socket): the agent runs this script as an
48
+ * unsandboxed call — an `excludedCommands` entry for it doesn't take it out (SKILL.md, «Docker and the sandbox»).
49
+ */
50
+ import { spawnSync } from "node:child_process";
51
+ import { existsSync, readFileSync } from "node:fs";
52
+ import path from "node:path";
53
+ import { fileURLToPath } from "node:url";
54
+
55
+ /**
56
+ * Help — `--help` or `-h` before `--` (after it — the e2e command's arguments; tests/standards/cli-help): a command's
57
+ * synopsis (the first paragraph), what it does and examples; `task-env --help` — the synopses of all. The dispatcher takes
58
+ * only the commands from here: a command without help can't be called.
59
+ */
60
+ const HELP: Record<string, string> = {
61
+ up: `task-env up [--profile <p>]...
62
+
63
+ Brings the task's stack up — the Compose project <project>-<N> by the branch <type>/<N>-slug, <project> without a
64
+ number — and prints its addresses: env.http services of .agents/elves.json through the proxy, published ports on
65
+ 127.0.0.1; the HTTP addresses go to the compose file's interpolation in TASK_ENV_<SERVICE>_URL; --profile — Compose
66
+ profiles. Code 0 — up, 1 — a service failed (its state and log), 2 — not tried.
67
+
68
+ Examples:
69
+ task-env up
70
+ task-env up --profile worker`,
71
+ e2e: `task-env e2e [--profile <p>]... [-- <command>...]
72
+
73
+ The e2e stack <project>-<N>-e2e anew with clean volumes (its own addresses in the interpolation), then the command with
74
+ its addresses in TASK_ENV_PROJECT, TASK_ENV_<SERVICE>_URL, TASK_ENV_<SERVICE>_<PORT>; the exit code is the command's; the
75
+ stack stays until the next run.
76
+
77
+ Examples:
78
+ task-env e2e -- npx playwright test
79
+ task-env e2e --profile worker -- bun test e2e`,
80
+ down: `task-env down [<N>]
81
+
82
+ Takes the stacks of task N (default — the branch's) down with their volumes: dev and e2e; a line per stack: + down,
83
+ ○ none, ! not removed (code 2); no compose file in the checkout — nothing to take down, Docker isn't called; task close of
84
+ the github skill calls it.
85
+
86
+ Examples:
87
+ task-env down
88
+ task-env down 42`,
89
+ prune: `task-env prune
90
+
91
+ Takes down the stacks of the project's closed tasks left on the machine (orphans) with their volumes: the task's state —
92
+ by GitHub (gh); stacks of open tasks, of the main checkout, of numbers that aren't tasks and of another repository with
93
+ the same name — left.
94
+
95
+ Examples:
96
+ task-env prune`,
97
+ check: `task-env check [<dir>]
98
+
99
+ Holds the compose file in <dir> (default — the current directory) to the task isolation rules: no container_name, no
100
+ fixed host port, volumes and networks without name and external, code by paths relative to the compose file; one ❌ line
101
+ per violation; the network of the proxy's former name ai-dev-proxy — remove it, task-env up adds the proxy's network
102
+ itself. Code 0 — the rules hold or there is no compose file, 1 — violations, 2 — not checked (no docker compose, the
103
+ config doesn't parse).
104
+
105
+ Examples:
106
+ task-env check
107
+ task-env check services/api`,
108
+ "proxy up": `task-env proxy up
109
+
110
+ Starts the machine's proxy (Traefik) or brings it to the skill's compose file and waits until it is healthy; port 80 busy
111
+ — another one in ELVES_PROXY_PORT. The proxy before the rename (ai-dev-proxy) — its container removed, its network
112
+ joined: stacks brought up before the rename keep their addresses.
113
+
114
+ Examples:
115
+ task-env proxy up`,
116
+ "proxy down": `task-env proxy down
117
+
118
+ Stops and removes the proxy and its network.
119
+
120
+ Examples:
121
+ task-env proxy down`,
122
+ "proxy status": `task-env proxy status
123
+
124
+ Running — code 0, not running — 1 with the start command, Docker unavailable — 2.
125
+
126
+ Examples:
127
+ task-env proxy status`,
128
+ };
129
+
130
+ /** `task-env --help`: what the script is and the synopsis of each command. */
131
+ const OVERVIEW = [
132
+ "task-env — a task's environment: its Docker Compose stack and the machine's proxy of *.localhost addresses",
133
+ "",
134
+ ...Object.values(HELP).map((h) => h.split("\n\n")[0]!.replace(/^/gm, " ")),
135
+ "",
136
+ "task-env <command> --help — the command's details and examples",
137
+ ].join("\n");
138
+
139
+ /** The command the arguments start with: `proxy up`, `down`; none — undefined. */
140
+ const commandOf = (argv: string[]) => Object.keys(HELP).find((c) => `${argv.join(" ")} `.startsWith(`${c} `));
141
+
142
+ /** The help the arguments before `--` ask for (`--help`, `-h`): of their command, otherwise the overview; none — undefined. */
143
+ function helpOf(argv: string[]): string | undefined {
144
+ const own = argv.includes("--") ? argv.slice(0, argv.indexOf("--")) : argv;
145
+ if (!own.some((a) => a === "--help" || a === "-h")) return undefined;
146
+ const cmd = commandOf(own);
147
+ return cmd ? HELP[cmd] : OVERVIEW;
148
+ }
149
+
150
+ /** The proxy's compose file: the skill directory's `proxy/compose.yaml`. */
151
+ export const PROXY_FILE = fileURLToPath(new URL("../proxy/compose.yaml", import.meta.url));
152
+ /** The proxy's Compose service — the Traefik container. */
153
+ const SERVICE = "traefik";
154
+ /** The entry point routers of task stacks attach to: HTTP on the proxy's port. */
155
+ export const ENTRY_POINT = "web";
156
+
157
+ /** The proxy's default name: the installer and other scripts import it; the compose file has no default of its own. */
158
+ export const PROXY = "elves-proxy";
159
+
160
+ /**
161
+ * The proxy's default name before the rename of the flow (ai-dev → Elves), read for the transition — one release
162
+ * (#545): `proxy up` takes its place, `check` names its network in a project's compose file, `check -g` of the
163
+ * installer names a proxy left under this name.
164
+ */
165
+ export const FORMER_PROXY = "ai-dev-proxy";
166
+
167
+ /** The proxy's name — its Compose project and network; `check -g` of the installer looks for it by this function. */
168
+ export const proxyName = (env: NodeJS.ProcessEnv = process.env) => env.ELVES_PROXY || PROXY;
169
+
170
+ /**
171
+ * The former proxy `proxy up` replaces: of the default proxy — `FORMER_PROXY`, of a proxy with another name (a test's
172
+ * one) — `<name>-former`, so a test's proxy never touches the machine's former proxy.
173
+ */
174
+ export const formerProxyName = (env: NodeJS.ProcessEnv = process.env) => {
175
+ const name = proxyName(env);
176
+ return name === PROXY ? FORMER_PROXY : `${name}-former`;
177
+ };
178
+
179
+ /**
180
+ * The proxy's port on 127.0.0.1. AI_DEV_PROXY_PORT — the variable before the rename (#545): a profile set it when port
181
+ * 80 was busy, and the proxy keeps that port until the profile is edited.
182
+ */
183
+ export const proxyPort = (env: NodeJS.ProcessEnv = process.env) => env.ELVES_PROXY_PORT || env.AI_DEV_PROXY_PORT || "80";
184
+
185
+ /**
186
+ * The environment of the proxy's compose file: the name, the port and the provider's default network are passed
187
+ * explicitly — the file has no defaults of its own (`${…:?}`), they live only here (#396, question 1). The default
188
+ * network is the proxy's own unless `up` joins the former proxy's network (`up`); not a setting — the script sets it.
189
+ */
190
+ export const composeEnv = (env: NodeJS.ProcessEnv = process.env, network = proxyName(env)): NodeJS.ProcessEnv => ({
191
+ ...env,
192
+ ELVES_PROXY: proxyName(env),
193
+ ELVES_PROXY_PORT: proxyPort(env),
194
+ ELVES_PROXY_DEFAULT_NETWORK: network,
195
+ });
196
+
197
+ /** The host of the proxy's API: a router of the proxy's own labels on the `web` entry point (proxy/compose.yaml). */
198
+ export const apiHost = (env: NodeJS.ProcessEnv = process.env) => `${proxyName(env)}.localhost`;
199
+
200
+ /** A service's address through the machine's proxy: its port is in the address when it isn't 80. */
201
+ export const addressUrl = (host: string, port = proxyPort()) => `http://${host}${port === "80" ? "" : `:${port}`}`;
202
+
203
+ /** The command that starts the proxy — as the output names it. */
204
+ const START = `bun --no-env-file ${fileURLToPath(import.meta.url)} proxy up`;
205
+
206
+ // ----------------------------------------------------------------------------
207
+ // Addresses
208
+ // ----------------------------------------------------------------------------
209
+
210
+ /** A task's address `<service>.<N>.<project>.localhost`, e2e — `<service>.e2e.<N>.<project>.localhost`. */
211
+ export interface TaskAddress {
212
+ service: string;
213
+ task: number;
214
+ e2e: boolean;
215
+ project: string;
216
+ }
217
+
218
+ /** A DNS label and a Compose project name at once: lowercase letters, digits and `-`. */
219
+ const dnsLabel = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
220
+
221
+ /**
222
+ * `<project>` of stacks and addresses by the repository name (#393, question 6): `projectName` takes it from `origin`,
223
+ * the dashboard — from the task's repository.
224
+ */
225
+ export const hostProject = (repo: string) => dnsLabel(repo) || "project";
226
+
227
+ // the service is lazy: `web.e2e.42.shop` is e2e of web, not the service `web.e2e`; `web.1.42.shop` is the service web.1
228
+ const TASK_HOST = /^(.+?)\.(?:(e2e)\.)?(\d+)\.([a-z0-9-]+)\.localhost$/;
229
+
230
+ /** A task's address by its host; another host (the main checkout's stack, the proxy's API, a foreign one) — null. */
231
+ export function taskAddress(host: string): TaskAddress | null {
232
+ const m = TASK_HOST.exec(host.toLowerCase());
233
+ return m ? { service: m[1]!, task: Number(m[3]), e2e: !!m[2], project: m[4]! } : null;
234
+ }
235
+
236
+ /** The API answers locally: longer than this is a hang, not work. */
237
+ const API_TIMEOUT = 3_000;
238
+ /** Routers per API page — Traefik's default; the next page is in `X-Next-Page`, the last page names page 1. */
239
+ const PER_PAGE = 100;
240
+ const HOST_RULE = /Host\(\s*[`"]([^`"]+)[`"]\s*\)/g;
241
+
242
+ /** The part of a Traefik router (`/api/http/routers`) the hosts are read from. */
243
+ interface Router {
244
+ rule?: string;
245
+ status?: string;
246
+ entryPoints?: string[];
247
+ }
248
+
249
+ /**
250
+ * Hosts of the proxy's routes — from its API (Traefik `GET /api/http/routers`, page by page): `Host(…)` of the
251
+ * enabled routers of the `web` entry point; a router appears when its container starts and leaves when it stops. The
252
+ * proxy isn't running (the connection is refused) — null: without the proxy there are no addresses either; another
253
+ * failure — an exception with the reason.
254
+ */
255
+ export async function proxyHosts(env: NodeJS.ProcessEnv = process.env): Promise<string[] | null> {
256
+ const fail = (why: string) => new Error(`proxy ${proxyName(env)}: routes not read — ${why}`);
257
+ const hosts = new Set<string>();
258
+ for (let page = 1; ; ) {
259
+ const url = `http://127.0.0.1:${proxyPort(env)}/api/http/routers?per_page=${PER_PAGE}&page=${page}`;
260
+ let r: Response;
261
+ try {
262
+ r = await fetch(url, { headers: { Host: apiHost(env) }, signal: AbortSignal.timeout(API_TIMEOUT) });
263
+ } catch (e) {
264
+ const err = e as NodeJS.ErrnoException & { cause?: { code?: string } };
265
+ const code = err.code ?? err.cause?.code;
266
+ if (code === "ECONNREFUSED") return null;
267
+ if (err.name === "TimeoutError") throw fail(`127.0.0.1:${proxyPort(env)} didn't answer in ${API_TIMEOUT / 1000} s`);
268
+ if (code === "EPERM") throw fail(`connecting to 127.0.0.1:${proxyPort(env)} is denied here (an agent's sandbox?)`);
269
+ throw fail(`${code ? `${code}: ` : ""}${err.message}`);
270
+ }
271
+ // the proxy before its API (#396) or something else on the port: Traefik answers an unknown host with 404
272
+ if (!r.ok) throw fail(`127.0.0.1:${proxyPort(env)} answered ${apiHost(env)} with HTTP ${r.status} — a proxy without the API? task-env proxy up brings it to the compose file`);
273
+ let routers: Router[];
274
+ try {
275
+ routers = (await r.json()) as Router[];
276
+ } catch {
277
+ throw fail("the API answered not with JSON");
278
+ }
279
+ if (!Array.isArray(routers)) throw fail("the API answered not with a list of routers");
280
+ for (const x of routers) {
281
+ if (x.status !== "enabled" || !x.entryPoints?.includes(ENTRY_POINT)) continue;
282
+ for (const m of String(x.rule ?? "").matchAll(HOST_RULE)) hosts.add(m[1]!);
283
+ }
284
+ const next = Number(r.headers.get("X-Next-Page"));
285
+ if (!(next > page)) break;
286
+ page = next;
287
+ }
288
+ return [...hosts];
289
+ }
290
+
291
+ export interface ProxyRoute {
292
+ /** The Compose project of the stack: `<project>-<N>`, `<project>-<N>-e2e`, `<project>` without a task. */
293
+ stack: string;
294
+ /** The Compose service. */
295
+ service: string;
296
+ /** The address host: `<service>.<N>.<project>.localhost`. */
297
+ host: string;
298
+ /** The service's HTTP port inside the container. */
299
+ port: number;
300
+ /** The proxy's network — the one the proxy reaches the container through. */
301
+ network: string;
302
+ }
303
+
304
+ /**
305
+ * Traefik labels for an HTTP service of a task stack: a router by host on the `web` entry point and a service to the
306
+ * container's port. The router and the service are named `<stack>-<service>`: unique on the machine, and replicas of
307
+ * one service (`--scale`) carry the same labels — Traefik balances between them. The network for reaching the
308
+ * container — by a label (#545): without it Traefik takes the provider's default network, and when the container isn't
309
+ * in that one — the first of its networks in Go's map order, a random one per configuration, and a stack in its
310
+ * `default` and the proxy's network would answer or not by chance; the default is the former proxy's network while
311
+ * the proxy is in it (`up`).
312
+ */
313
+ export function proxyLabels(r: ProxyRoute): Record<string, string> {
314
+ const id = `${r.stack}-${r.service}`;
315
+ return {
316
+ "traefik.enable": "true",
317
+ "traefik.docker.network": r.network,
318
+ [`traefik.http.routers.${id}.rule`]: `Host(\`${r.host}\`)`,
319
+ [`traefik.http.routers.${id}.entrypoints`]: ENTRY_POINT,
320
+ [`traefik.http.routers.${id}.service`]: id,
321
+ [`traefik.http.services.${id}.loadbalancer.server.port`]: String(r.port),
322
+ };
323
+ }
324
+
325
+ interface Result {
326
+ code: number;
327
+ out: string;
328
+ }
329
+
330
+ /** The first line of a failed docker's output, or the launch error. */
331
+ function why(r: ReturnType<typeof spawnSync>) {
332
+ if (r.error) return (r.error as NodeJS.ErrnoException).code === "ENOENT" ? "docker not found — task-env needs Docker (Docker Desktop on macOS)" : r.error.message;
333
+ const text = `${r.stderr ?? ""}`.trim() || `${r.stdout ?? ""}`.trim();
334
+ const lines = text.split("\n").filter((l) => /error|cannot|denied|failed|not permitted/i.test(l));
335
+ return (lines.at(-1) ?? text.split("\n").at(-1) ?? `docker: code ${r.status}`).trim();
336
+ }
337
+
338
+ /** A hint for a docker failure: what to do about it. */
339
+ function hint(reason: string, env: NodeJS.ProcessEnv) {
340
+ if (/already allocated|address already in use|bind/i.test(reason)) return ` — port 127.0.0.1:${proxyPort(env)} is busy: free it or set ELVES_PROXY_PORT (the addresses then carry the port)`;
341
+ if (/cannot connect to the docker daemon|is the docker daemon running/i.test(reason)) return " — Docker isn't running: start it (Docker Desktop on macOS)";
342
+ if (/docker\.sock|not permitted|permission denied/i.test(reason)) return " — an agent's sandbox? Docker doesn't work in it: run this command outside the sandbox (SKILL.md, «Docker and the sandbox»)";
343
+ if (/address pools/i.test(reason)) return " — Docker is out of network subnets: take down stacks nobody needs or widen default-address-pools (SKILL.md, «Network subnets»)";
344
+ return "";
345
+ }
346
+
347
+ /**
348
+ * `docker compose` of the proxy. `former` — the former proxy's network to join: an override on Compose's stdin (the
349
+ * network isn't always on the machine, so it isn't in the file), and it becomes the provider's default network.
350
+ */
351
+ function compose(args: string[], env: NodeJS.ProcessEnv, former?: string) {
352
+ const override = former ? { services: { [SERVICE]: { networks: { proxy: null, former: null } } }, networks: { former: { name: former, external: true } } } : undefined;
353
+ return spawnSync("docker", ["compose", "-f", PROXY_FILE, ...(override ? ["-f", "-"] : []), ...args], {
354
+ encoding: "utf8",
355
+ input: override ? JSON.stringify(override) : undefined,
356
+ env: composeEnv(env, former),
357
+ });
358
+ }
359
+
360
+ /** The proxy containers of a Compose project in any state — a `format` line each; Docker unavailable — the reason. */
361
+ function proxyContainers(project: string, format: string, env: NodeJS.ProcessEnv): string[] | string {
362
+ const filters = ["--filter", `label=com.docker.compose.project=${project}`, "--filter", `label=com.docker.compose.service=${SERVICE}`];
363
+ const r = spawnSync("docker", ["ps", "--all", ...filters, "--format", format], { encoding: "utf8", env, timeout: 10_000 });
364
+ if (r.error || r.status !== 0) return why(r);
365
+ return r.stdout.split("\n").filter(Boolean);
366
+ }
367
+
368
+ /** Whether the proxy container runs: true / false; Docker unavailable — the reason. */
369
+ export function proxyRunning(env: NodeJS.ProcessEnv = process.env): boolean | string {
370
+ const states = proxyContainers(proxyName(env), "{{.State}}", env);
371
+ return typeof states === "string" ? states : states.includes("running");
372
+ }
373
+
374
+ /** Whether a network is on the machine; Docker unavailable — the reason. */
375
+ function networkExists(name: string, env: NodeJS.ProcessEnv): boolean | string {
376
+ const r = spawnSync("docker", ["network", "ls", "--filter", `name=^${name}$`, "--format", "{{.Name}}"], { encoding: "utf8", env, timeout: 10_000 });
377
+ if (r.error || r.status !== 0) return why(r);
378
+ return r.stdout.split("\n").includes(name);
379
+ }
380
+
381
+ function status(env: NodeJS.ProcessEnv): Result {
382
+ const name = proxyName(env);
383
+ const found = proxyContainers(name, "{{.State}}\t{{.Networks}}", env);
384
+ if (typeof found === "string") return { code: 2, out: `proxy ${name}: Docker unavailable — ${found}${hint(found, env)}` };
385
+ const running = found.map((l) => l.split("\t")).find(([state]) => state === "running");
386
+ if (!running) return { code: 1, out: `proxy ${name}: not running — start: ${START}` };
387
+ const former = formerProxyName(env);
388
+ const also = running[1]?.split(",").includes(former) ? `; and ${former} — of the proxy before the rename, with the stacks brought up before it` : "";
389
+ return { code: 0, out: `proxy ${name}: running — ${addressUrl("<service>.<N>.<project>.localhost", proxyPort(env))} on 127.0.0.1, routes by container labels (network ${name}${also})` };
390
+ }
391
+
392
+ /**
393
+ * `proxy up`: the proxy brought to the compose file and healthy. The transition from the proxy before the rename
394
+ * (#545, one release): its container holds the port — removed once the new one is created (a failed pull leaves the
395
+ * former proxy serving); its network stays — stacks brought up before the rename are in it — and the proxy joins it as
396
+ * the provider's default network: their services carry no network label, and a container outside the default network
397
+ * gets a random one of its networks (`proxyLabels`). No former network on the machine — the proxy is in its own only,
398
+ * the former one isn't created.
399
+ */
400
+ function up(env: NodeJS.ProcessEnv): Result {
401
+ const name = proxyName(env);
402
+ const former = formerProxyName(env);
403
+ const out: string[] = [];
404
+ const fail = (reason: string) => ({ code: 2, out: [...out, `proxy ${name}: not started — ${reason}${hint(reason, env)}`].join("\n") });
405
+ const old = proxyContainers(former, "{{.ID}}", env);
406
+ if (typeof old === "string") return fail(old);
407
+ const joined = networkExists(former, env);
408
+ if (typeof joined === "string") return fail(joined);
409
+ const network = joined ? former : undefined;
410
+ if (old.length) {
411
+ const created = compose(["up", "--no-start", "--quiet-pull"], env, network);
412
+ if (created.error || created.status !== 0) return fail(why(created));
413
+ const rm = spawnSync("docker", ["rm", "--force", ...old], { encoding: "utf8", env });
414
+ if (rm.error || rm.status !== 0) return fail(`the proxy before the rename, ${former}, not removed: ${why(rm)}`);
415
+ out.push(`proxy ${former}: removed — the proxy before the rename; ${name} takes its port${joined ? `, its network ${former} stays` : ""}`);
416
+ }
417
+ const r = compose(["up", "--detach", "--wait", "--quiet-pull"], env, network);
418
+ if (r.error || r.status !== 0) return fail(why(r));
419
+ const s = status(env);
420
+ return { code: s.code, out: [...out, s.out].join("\n") };
421
+ }
422
+
423
+ function down(env: NodeJS.ProcessEnv): Result {
424
+ const r = compose(["down"], env);
425
+ if (r.error || r.status !== 0) {
426
+ const reason = why(r);
427
+ const attached = /active endpoints/i.test(reason) ? " — task stacks are still in its network: take them down first" : hint(reason, env);
428
+ return { code: 2, out: `proxy ${proxyName(env)}: not removed — ${reason}${attached}` };
429
+ }
430
+ return { code: 0, out: `proxy ${proxyName(env)}: removed` };
431
+ }
432
+
433
+ // ----------------------------------------------------------------------------
434
+ // check — the compose file isolation rules
435
+ // ----------------------------------------------------------------------------
436
+
437
+ /**
438
+ * Compose files Docker Compose looks for, in its order. The installer imports the list — its `check` doesn't start this
439
+ * script in a project without a compose file.
440
+ */
441
+ export const COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"];
442
+
443
+ /**
444
+ * Sources of a bind mount that are the machine's resources, not code — the Docker socket, `/etc/localtime`, devices:
445
+ * they don't tie a stack to a checkout (#398, question 3).
446
+ */
447
+ const MACHINE = /^\/(?:var\/run|run|etc|dev|proc|sys)(?:\/|$)/;
448
+ /** `docker compose config` reads a few files — longer than this is a hang, not work. */
449
+ const CONFIG_TIMEOUT = 20_000;
450
+
451
+ interface Port {
452
+ target: number;
453
+ published?: string;
454
+ host_ip?: string;
455
+ protocol?: string;
456
+ }
457
+ interface Mount {
458
+ type: string;
459
+ source?: string;
460
+ }
461
+ interface Service {
462
+ container_name?: string;
463
+ ports?: Port[];
464
+ volumes?: Mount[];
465
+ build?: { context?: string; dockerfile?: string };
466
+ networks?: Record<string, unknown>;
467
+ }
468
+ interface Resource {
469
+ name?: string;
470
+ external?: boolean;
471
+ }
472
+ /** The part of the normalized `docker compose config --format json` the rules read. */
473
+ interface Config {
474
+ name: string;
475
+ services?: Record<string, Service>;
476
+ volumes?: Record<string, Resource>;
477
+ networks?: Record<string, Resource>;
478
+ }
479
+
480
+ const absolute = (p: string) => p.startsWith("/") || p === "~" || p.startsWith("~/");
481
+ const list = (names: string[]) => `${names.length === 1 ? "service" : "services"} ${names.join(", ")}`;
482
+
483
+ /** Violations of the isolation rules, one line each: services, then volumes, then networks; Compose sorts the keys. */
484
+ function violations(config: Config, file: string): string[] {
485
+ const out: string[] = [];
486
+ const line = (subject: string, what: string) => out.push(`❌ ${file}: ${subject} — ${what}`);
487
+ const services = Object.entries(config.services ?? {});
488
+ for (const [name, s] of services) {
489
+ const subject = `service ${name}`;
490
+ if (s.container_name) line(subject, `container_name ${s.container_name}: a second task stack can't start; remove it, Compose names containers by the project`);
491
+ for (const p of s.ports ?? []) {
492
+ if (!p.published) continue;
493
+ const proto = p.protocol && p.protocol !== "tcp" ? `/${p.protocol}` : "";
494
+ const host = p.host_ip ? `${p.host_ip}:` : "";
495
+ line(subject, `fixed host port ${host}${p.published}:${p.target}${proto}: two task stacks contend for it; publish 127.0.0.1::${p.target}${proto} or drop ports`);
496
+ }
497
+ // a URL context (a git repository) isn't absolute either: it starts neither with / nor with ~
498
+ for (const [field, p] of [["context", s.build?.context], ["dockerfile", s.build?.dockerfile]] as const) {
499
+ if (p && absolute(p)) line(subject, `build ${field}: absolute path ${p}: builds code outside the task's worktree; use a path relative to the compose file`);
500
+ }
501
+ for (const v of s.volumes ?? []) {
502
+ if (v.type === "bind" && v.source && absolute(v.source) && !MACHINE.test(v.source)) {
503
+ line(subject, `volumes: absolute path ${v.source}: mounts code outside the task's worktree; use a path relative to the compose file`);
504
+ }
505
+ }
506
+ }
507
+ const shared = (kind: "volume" | "network", key: string, r: Resource, users: string[], why: string) => {
508
+ const subject = `${kind} ${key} (${list(users)})`;
509
+ if (r.external) line(subject, `external: ${why}; drop external`);
510
+ else if (r.name && r.name !== `${config.name}_${key}`) line(subject, `name ${r.name}: ${why}; remove name, Compose prefixes it by the project`);
511
+ };
512
+ for (const [key, r] of Object.entries(config.volumes ?? {})) {
513
+ const users = services.filter(([, s]) => (s.volumes ?? []).some((v) => v.type === "volume" && v.source === key)).map(([n]) => n);
514
+ shared("volume", key, r, users, "task stacks share it, down -v of one wipes another's data");
515
+ }
516
+ for (const [key, r] of Object.entries(config.networks ?? {})) {
517
+ const users = services.filter(([, s]) => Object.hasOwn(s.networks ?? {}, key)).map(([n]) => n);
518
+ // the proxy's network under its name before the rename (#545): a compose file wrote it by hand, as the example of
519
+ // reference.md did; the proxy stays in that network one release (`up`), and a machine without it fails the stack
520
+ if (r.name === FORMER_PROXY) {
521
+ line(`network ${key} (${list(users)})`, `${FORMER_PROXY} is the machine's proxy before the rename, now ${PROXY}: remove the network from the compose file and its services; task-env up adds the proxy's network to the HTTP services of env.http itself`);
522
+ continue;
523
+ }
524
+ shared("network", key, r, users, "task stacks share it and reach each other's services");
525
+ }
526
+ return out;
527
+ }
528
+
529
+ /** `check`: the exit code; all lines go to stdout — `check` of the installer reads them from there. */
530
+ function check(dir: string): number {
531
+ const file = COMPOSE_FILES.find((f) => existsSync(path.join(dir, f)));
532
+ if (!file) return console.log(`no compose file in ${dir} — nothing to check`), 0;
533
+ const notChecked = (why: string) => (console.log(`○ ${file}: not checked — ${why}`), 2);
534
+ const r = spawnSync("docker", ["compose", "--profile", "*", "config", "--no-path-resolution", "--format", "json"], { cwd: dir, encoding: "utf8", timeout: CONFIG_TIMEOUT });
535
+ const error = r.error as NodeJS.ErrnoException | undefined;
536
+ if (error?.code === "ENOENT") return notChecked("docker not found");
537
+ if (error) return notChecked(`docker compose config: ${error.message}`);
538
+ if (r.status !== 0) return notChecked(`docker compose config: ${r.stderr.trim().split("\n")[0] || `exit code ${r.status}`}`);
539
+ let config: Config;
540
+ try {
541
+ config = JSON.parse(r.stdout) as Config;
542
+ } catch {
543
+ return notChecked("docker compose config printed no JSON");
544
+ }
545
+ const lines = violations(config, file);
546
+ if (!lines.length) return console.log(`${file}: the task isolation rules hold`), 0;
547
+ console.log(lines.join("\n"));
548
+ return 1;
549
+ }
550
+
551
+ // ----------------------------------------------------------------------------
552
+ // The task's stack — up, e2e, down
553
+ // ----------------------------------------------------------------------------
554
+
555
+ // canon task types — a copy of EST_TYPES and BRANCH_CONV_RE from skills/est/scripts/est.ts (and in skills/github/scripts/github.ts):
556
+ // no shared module: task-env imports no other skill (tests/standards/skill-imports); the tests/standards/task-branch
557
+ // standard keeps the parsing identical
558
+ const TASK_TYPES = ["feat", "fix", "docs", "refactor", "perf", "test", "chore", "ci", "build", "research"];
559
+ const TASK_BRANCH_RE = new RegExp("^(?:[\\p{L}\\p{N}_.-]+/)?(" + TASK_TYPES.join("|") + ")/(\\d{1,6})-", "iu");
560
+
561
+ /** A task branch by the canon — `[<area>/]<type>/<N>-<slug>`: the type (lowercased) and the number, otherwise null. */
562
+ export function taskBranch(branch: string): { type: string; issue: number } | null {
563
+ const m = TASK_BRANCH_RE.exec(branch);
564
+ return m ? { type: m[1]!.toLowerCase(), issue: parseInt(m[2]!, 10) } : null;
565
+ }
566
+
567
+ /**
568
+ * Override files Compose takes next to the compose file by itself, in its order (checked on Compose 5.6). With
569
+ * explicit `-f` it takes none — the script passes the project's one along with the task's override.
570
+ */
571
+ const OVERRIDE_FILES = ["compose.override.yml", "compose.override.yaml", "docker-compose.override.yml", "docker-compose.override.yaml"];
572
+ /** `docker compose up` output with an image build can be long; the default buffer would cut the run. */
573
+ const MAX_BUFFER = 64 * 1024 * 1024;
574
+ /** How long `up --wait` waits for healthchecks: a service that isn't healthy by then failed. */
575
+ const WAIT_TIMEOUT = "300";
576
+ /** Log lines of a failed service the report shows. */
577
+ const LOG_TAIL = "20";
578
+
579
+ const git = (dir: string, ...args: string[]) => {
580
+ const r = spawnSync("git", ["-C", dir, ...args], { encoding: "utf8" });
581
+ return r.status === 0 ? r.stdout.trim() : "";
582
+ };
583
+
584
+ /**
585
+ * `<project>` of stacks and addresses: the repository of `origin` (the last part of its URL without `.git`), without a
586
+ * remote — the main checkout's directory. Not the directory Compose takes by default: a worktree's is random
587
+ * (`session-379-…`), and stacks of one project would get different names (#393, question 6).
588
+ */
589
+ export function projectName(root: string): string {
590
+ const url = git(root, "remote", "get-url", "origin").replace(/\/+$/, "");
591
+ const common = git(root, "rev-parse", "--path-format=absolute", "--git-common-dir");
592
+ const name = url ? url.split(/[/:]/).at(-1)!.replace(/\.git$/, "") : common ? path.basename(path.dirname(common)) : path.basename(root);
593
+ return hostProject(name);
594
+ }
595
+
596
+ export interface Stack {
597
+ /** The Compose project: `<project>-<N>`, `<project>-<N>-e2e`, `<project>` without a task. */
598
+ name: string;
599
+ /** A service's address host: `<service>.<N>.<project>.localhost`, e2e — `<service>.e2e.<N>.<project>.localhost`. */
600
+ host: (service: string) => string;
601
+ }
602
+
603
+ /** The stack of task `task` of `project`; without a task — the main checkout's. */
604
+ export function stackOf(project: string, task: number | undefined, e2e = false): Stack {
605
+ const n = task === undefined ? [] : [String(task)];
606
+ const kind = e2e ? ["e2e"] : [];
607
+ return { name: [project, ...n, ...kind].join("-"), host: (service) => [dnsLabel(service), ...kind, ...n, project, "localhost"].join(".") };
608
+ }
609
+
610
+
611
+ /** The variable of a service for the e2e command: `TASK_ENV_<SERVICE>_<suffix>` (#393, question 7). */
612
+ const varOf = (service: string, suffix: string | number) => `TASK_ENV_${service.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_${suffix}`;
613
+
614
+ /**
615
+ * The project's settings file: `.agents/elves.json`; an install from before the rename has `.agents/ai-dev.json` until
616
+ * `update` moves it — read then (#545).
617
+ */
618
+ const MANIFESTS = [".agents/elves.json", ".agents/ai-dev.json"];
619
+
620
+ /**
621
+ * HTTP services of the project's stack and their ports inside the container — `env.http` of the project's settings
622
+ * (`MANIFESTS`, #393, question 4): in a compose file a database's port doesn't differ from an app's. No file or no
623
+ * field — none; a broken one — the reason, with the file read.
624
+ */
625
+ export function httpServices(root: string): Record<string, number> | string {
626
+ const name = MANIFESTS.find((f) => existsSync(path.join(root, f)));
627
+ if (!name) return {};
628
+ let http: unknown;
629
+ try {
630
+ http = (JSON.parse(readFileSync(path.join(root, name), "utf8")) as { env?: { http?: unknown } }).env?.http ?? {};
631
+ } catch (e) {
632
+ return `${name}: ${(e as Error).message}`;
633
+ }
634
+ const bad = `env.http of ${name}: a service → its HTTP port in the container expected, like {"web": 3000}`;
635
+ if (typeof http !== "object" || !http || Array.isArray(http)) return bad;
636
+ const ports = http as Record<string, unknown>;
637
+ return Object.values(ports).every((p) => Number.isInteger(p) && (p as number) > 0 && (p as number) < 65536) ? (ports as Record<string, number>) : bad;
638
+ }
639
+
640
+ /** Where the task's stack lives: the worktree's root, its compose files, the project's name, the task and HTTP services. */
641
+ interface Project {
642
+ root: string;
643
+ /** `-f` of the compose file and of the project's override file, if there is one. */
644
+ files: string[];
645
+ name: string;
646
+ task: number | undefined;
647
+ http: Record<string, number>;
648
+ }
649
+
650
+ function projectOf(cwd: string): Project | string {
651
+ const root = git(cwd, "rev-parse", "--show-toplevel") || cwd;
652
+ const file = COMPOSE_FILES.find((f) => existsSync(path.join(root, f)));
653
+ if (!file) return `no compose file in ${root} (${COMPOSE_FILES.join(", ")}) — the task's stack is the project's Compose`;
654
+ const extra = OVERRIDE_FILES.find((f) => existsSync(path.join(root, f)));
655
+ const http = httpServices(root);
656
+ if (typeof http === "string") return http;
657
+ const files = [file, ...(extra ? [extra] : [])].flatMap((f) => ["-f", f]);
658
+ return { root, files, name: projectName(root), task: taskBranch(git(root, "branch", "--show-current"))?.issue, http };
659
+ }
660
+
661
+ /** `docker compose` in cwd; vars — over the script's environment: Compose takes them into interpolation. */
662
+ const dc = (args: string[], cwd: string, input?: string, vars: Record<string, string> = {}) =>
663
+ spawnSync("docker", ["compose", ...args], { cwd, input, encoding: "utf8", maxBuffer: MAX_BUFFER, env: { ...process.env, ...vars } });
664
+
665
+ /**
666
+ * The stack's addresses for Compose's interpolation (#413): `TASK_ENV_<SERVICE>_URL` of each `env.http` service — the
667
+ * names the e2e command gets — so the compose file gives the app its own address (`APP_URL:
668
+ * ${TASK_ENV_WEB_URL:-http://localhost:3000}`). Known before `up`: the host is the stack's, the port is the proxy's. Only
669
+ * the environment of the script's Compose process — no file: `docker compose` by hand and CI get the compose file's
670
+ * default.
671
+ */
672
+ const urlVars = (project: Project, stack: Stack, env: NodeJS.ProcessEnv) =>
673
+ Object.fromEntries(Object.keys(project.http).map((service) => [varOf(service, "URL"), addressUrl(stack.host(service), proxyPort(env))]));
674
+
675
+ /**
676
+ * The task stack's override — the only thing the script adds to the project's Compose: the proxy's labels and network
677
+ * for HTTP services of the settings, nothing to the others (a database isn't published). A service's networks are its
678
+ * own as Compose normalized them (one without `networks` is in `default`) plus the proxy's: listed networks replace
679
+ * the implicit `default`, and a key with null merges with the project's keeping its aliases. The override goes to
680
+ * Compose's stdin (`-f -`), not into a file: a `compose.override.yaml` would be the project's, and `check` would name
681
+ * its external network. No HTTP service in the compose file — no override.
682
+ */
683
+ function overrideOf(config: Config, http: Record<string, number>, stack: Stack, proxy: string): object | undefined {
684
+ const services: Record<string, object> = {};
685
+ for (const [service, port] of Object.entries(http)) {
686
+ const s = config.services?.[service];
687
+ if (!s) continue;
688
+ const networks = Object.fromEntries([...Object.keys(s.networks ?? { default: null }), proxy].map((n) => [n, null]));
689
+ services[service] = { networks, labels: proxyLabels({ stack: stack.name, service: dnsLabel(service), host: stack.host(service), port, network: proxy }) };
690
+ }
691
+ if (!Object.keys(services).length) return undefined;
692
+ return { services, networks: { [proxy]: { external: true } } };
693
+ }
694
+
695
+ /** A container of `docker compose ps --format json`. */
696
+ interface Container {
697
+ Name: string;
698
+ Service: string;
699
+ State: string;
700
+ Health?: string;
701
+ ExitCode?: number;
702
+ Publishers?: { URL?: string; TargetPort: number; PublishedPort: number; Protocol?: string }[] | null;
703
+ }
704
+
705
+ /** Containers of a stack by its Compose project, from any directory — Compose finds them by labels; all — stopped too. */
706
+ function containers(stack: string, all = false): Container[] {
707
+ const r = dc(["-p", stack, "ps", ...(all ? ["--all"] : []), "--format", "json"], "/");
708
+ const out = r.status === 0 ? r.stdout.trim() : "";
709
+ if (!out) return [];
710
+ // Compose prints a container per line; before 2.21 — one JSON array
711
+ return out.startsWith("[") ? (JSON.parse(out) as Container[]) : out.split("\n").map((l) => JSON.parse(l) as Container);
712
+ }
713
+
714
+ /** Did a container fail: not running, not healthy; a one-shot that exited with 0 did its job. */
715
+ const failed = (c: Container) => (c.State !== "running" && !(c.State === "exited" && c.ExitCode === 0)) || c.Health === "unhealthy" || c.Health === "starting";
716
+
717
+ /** The last healthcheck's output of a container: why it isn't healthy. */
718
+ function healthOf(container: string): string {
719
+ const r = spawnSync("docker", ["inspect", "--format", "{{json .State.Health}}", container], { encoding: "utf8" });
720
+ try {
721
+ const last = (JSON.parse(r.stdout) as { Log?: { ExitCode: number; Output: string }[] }).Log?.at(-1);
722
+ return last ? `exit ${last.ExitCode}${last.Output.trim() ? ` — ${last.Output.trim().split("\n").slice(-3).join(" ")}` : ""}` : "no result yet";
723
+ } catch {
724
+ return why(r);
725
+ }
726
+ }
727
+
728
+ /**
729
+ * A stack that didn't come up: each failed service — its state and the last lines of its log (#393, question 5); no
730
+ * container failed (an image isn't pulled, a build broke) — Compose's own answer.
731
+ */
732
+ function report(stack: string, r: ReturnType<typeof spawnSync>, env: NodeJS.ProcessEnv): string[] {
733
+ const out = [`stack ${stack}: not up — ${why(r)}${hint(why(r), env)}`];
734
+ for (const c of containers(stack, true).filter(failed)) {
735
+ const state = [c.State === "exited" ? `exited (exit ${c.ExitCode})` : c.State, c.Health].filter(Boolean).join(", ");
736
+ out.push(`✗ ${c.Service}: ${state}`);
737
+ if (c.Health === "unhealthy") out.push(` healthcheck: ${healthOf(c.Name)}`);
738
+ const logs = dc(["-p", stack, "logs", "--no-color", "--no-log-prefix", "--tail", LOG_TAIL, c.Service], "/");
739
+ for (const l of `${logs.stdout}${logs.stderr}`.trimEnd().split("\n").filter(Boolean)) out.push(` | ${l}`);
740
+ }
741
+ // Compose's progress lines (a resource and its step) say nothing about the failure
742
+ const progress = /^\s*(?:Network|Volume|Container|Image)\s+\S+\s+(?:Creat|Start|Wait|Pull|Build|Healthy|Running|Recreat)\w*\s*$/;
743
+ if (out.length === 1) for (const l of `${r.stderr ?? ""}`.trimEnd().split("\n").filter((l) => l.trim() && !progress.test(l)).slice(-15)) out.push(` | ${l}`);
744
+ return out;
745
+ }
746
+
747
+ /** An address of a stack's service: a URL through the proxy or a published port on the loopback, with its variable. */
748
+ interface Address {
749
+ service: string;
750
+ value: string;
751
+ name: string;
752
+ note?: string;
753
+ }
754
+
755
+ /** Addresses of the running services: HTTP ones of the settings through the proxy, every published port on 127.0.0.1. */
756
+ function addressesOf(project: Project, stack: Stack, env: NodeJS.ProcessEnv): Address[] {
757
+ const out: Address[] = [];
758
+ const running = containers(stack.name).filter((c) => c.State === "running");
759
+ for (const service of [...new Set(running.map((c) => c.Service))].sort()) {
760
+ if (project.http[service]) out.push({ service, value: addressUrl(stack.host(service), proxyPort(env)), name: varOf(service, "URL") });
761
+ const seen = new Set<string>();
762
+ for (const p of running.find((c) => c.Service === service)!.Publishers ?? []) {
763
+ const proto = p.Protocol && p.Protocol !== "tcp" ? `/${p.Protocol}` : "";
764
+ if (!p.PublishedPort || seen.has(`${p.TargetPort}${proto}`)) continue;
765
+ seen.add(`${p.TargetPort}${proto}`);
766
+ const ip = !p.URL || p.URL === "0.0.0.0" || p.URL === "::" ? "127.0.0.1" : p.URL;
767
+ out.push({ service, value: `${ip}:${p.PublishedPort}`, name: varOf(service, `${p.TargetPort}${proto ? `_${p.Protocol!.toUpperCase()}` : ""}`), note: `→ ${p.TargetPort}${proto}` });
768
+ }
769
+ }
770
+ return out;
771
+ }
772
+
773
+ /**
774
+ * Brings the stack up: the project's compose files plus the override, `up --wait` with a build — the stack runs the
775
+ * worktree's code, not an image from another task's run. The proxy goes up first when the override joins its network
776
+ * and it isn't running (#393, question 8); a running one isn't touched. Both Compose calls get the stack's addresses
777
+ * for interpolation — `config` too: a required `${TASK_ENV_WEB_URL:?…}` would fail it.
778
+ */
779
+ function upStack(project: Project, stack: Stack, profiles: string[], env: NodeJS.ProcessEnv): { code: number; lines: string[] } {
780
+ const vars = urlVars(project, stack, env);
781
+ const config = dc([...project.files, "--profile", "*", "config", "--format", "json"], project.root, undefined, vars);
782
+ if (config.error || config.status !== 0) return { code: 2, lines: [`stack ${stack.name}: not started — docker compose config: ${why(config)}${hint(why(config), env)}`] };
783
+ const parsed = JSON.parse(config.stdout) as Config;
784
+ const lines = Object.keys(project.http)
785
+ .filter((s) => !parsed.services?.[s])
786
+ .map((s) => `! env.http: service ${s} isn't in the compose file — no address for it`);
787
+ const override = overrideOf(parsed, project.http, stack, proxyName(env));
788
+ if (override) {
789
+ const running = proxyRunning(env);
790
+ if (typeof running === "string") return { code: 2, lines: [...lines, `stack ${stack.name}: not started — Docker unavailable: ${running}${hint(running, env)}`] };
791
+ if (!running) {
792
+ const p = up(env);
793
+ if (p.code) return { code: 2, lines: [...lines, p.out] };
794
+ }
795
+ }
796
+ const args = ["-p", stack.name, ...project.files, ...(override ? ["-f", "-"] : []), ...profiles.flatMap((p) => ["--profile", p])];
797
+ const r = dc([...args, "--progress", "plain", "up", "--detach", "--wait", "--wait-timeout", WAIT_TIMEOUT, "--build", "--remove-orphans", "--quiet-pull"], project.root, override ? JSON.stringify(override) : undefined, vars);
798
+ if (r.error || r.status !== 0) return { code: r.error ? 2 : 1, lines: [...lines, ...report(stack.name, r, env)] };
799
+ return { code: 0, lines };
800
+ }
801
+
802
+ /**
803
+ * Takes a stack down with its volumes — by the Compose project from `/`, without the compose file: it may be gone
804
+ * already (`task close` after the worktree), and a compose file found by Compose would hide services of other profiles.
805
+ * Compose finds the stack's containers, networks and volumes by their labels — a stack left with volumes only too.
806
+ */
807
+ function downStack(stack: string, env: NodeJS.ProcessEnv): { ok: boolean; line: string } {
808
+ const r = dc(["-p", stack, "down", "--volumes", "--remove-orphans"], "/");
809
+ const ok = !r.error && r.status === 0;
810
+ return { ok, line: ok ? `+ stack ${stack}: down, with its volumes` : `! stack ${stack}: not removed — ${why(r)}${hint(why(r), env)}` };
811
+ }
812
+
813
+ /** The label Compose marks a stack's containers, volumes and networks with: the Compose project. */
814
+ const PROJECT_LABEL = "com.docker.compose.project";
815
+ /** Compose projects on the machine → the directories of their containers; no docker — missing, Docker unavailable — the reason. */
816
+ type Machine = Map<string, Set<string>> | { missing: true } | string;
817
+
818
+ /**
819
+ * Stacks on the machine — by the label of containers, volumes and networks, not `docker compose ls`: a stack whose
820
+ * containers are gone but volumes stay (`down` without `-v`, containers removed by hand) isn't in its list (#395,
821
+ * question 2). A container's directory (`working_dir` — where Compose ran) tells a stack of another repository with the
822
+ * same name (`prune`).
823
+ */
824
+ function machineStacks(env: NodeJS.ProcessEnv): Machine {
825
+ const stacks = new Map<string, Set<string>>();
826
+ for (const list of [["ps", "--all"], ["volume", "ls"], ["network", "ls"]]) {
827
+ const format = list[0] === "ps" ? `{{.Label "${PROJECT_LABEL}"}}\t{{.Label "${PROJECT_LABEL}.working_dir"}}` : `{{.Label "${PROJECT_LABEL}"}}`;
828
+ const r = spawnSync("docker", [...list, "--filter", `label=${PROJECT_LABEL}`, "--format", format], { encoding: "utf8", env, timeout: CONFIG_TIMEOUT });
829
+ if ((r.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return { missing: true };
830
+ if (r.error || r.status !== 0) return why(r);
831
+ for (const line of r.stdout.split("\n")) {
832
+ const [name, dir] = line.split("\t");
833
+ if (!name?.trim()) continue;
834
+ const dirs = stacks.get(name.trim()) ?? new Set<string>();
835
+ if (dir?.trim()) dirs.add(dir.trim());
836
+ stacks.set(name.trim(), dirs);
837
+ }
838
+ }
839
+ return stacks;
840
+ }
841
+
842
+ /** Address lines: the service column aligned; withVar — the variable the e2e command gets it in. */
843
+ function shown(addresses: Address[], withVar: boolean): string[] {
844
+ const width = Math.max(0, ...addresses.map((a) => a.service.length));
845
+ return addresses.map((a) => ` ${a.service.padEnd(width)} ${a.value}${a.note ? ` ${a.note}` : ""}${withVar ? ` ${a.name}` : ""}`);
846
+ }
847
+
848
+ /** `up`: the dev stack of the branch's task (without one — the main checkout's) and its addresses. */
849
+ function stackUp(project: Project, profiles: string[], env: NodeJS.ProcessEnv): number {
850
+ const stack = stackOf(project.name, project.task);
851
+ const r = upStack(project, stack, profiles, env);
852
+ if (r.code) return console.log(r.lines.join("\n")), r.code;
853
+ const addresses = addressesOf(project, stack, env);
854
+ console.log([...r.lines, `stack ${stack.name}: up${addresses.length ? "" : " — no HTTP service in env.http and no published port"}`, ...shown(addresses, false)].join("\n"));
855
+ console.log(`logs: docker compose -p ${stack.name} logs <service>; down: bun --no-env-file ${fileURLToPath(import.meta.url)} down${project.task === undefined ? "" : ` ${project.task}`}`);
856
+ return 0;
857
+ }
858
+
859
+ /**
860
+ * `e2e`: the task's e2e stack anew — with clean volumes, apart from the dev stack — then the run command on the host
861
+ * with the e2e addresses in variables; the exit code is the command's. The stack stays until the next run: a failed
862
+ * run is examined on it.
863
+ */
864
+ function stackE2e(project: Project, profiles: string[], command: string[], env: NodeJS.ProcessEnv): number {
865
+ const stack = stackOf(project.name, project.task, true);
866
+ const clean = downStack(stack.name, env);
867
+ if (!clean.ok) return console.log(clean.line), 2;
868
+ const r = upStack(project, stack, profiles, env);
869
+ if (r.code) return console.log(r.lines.join("\n")), r.code;
870
+ const addresses = addressesOf(project, stack, env);
871
+ console.log([...r.lines, `stack ${stack.name}: up, clean volumes — TASK_ENV_PROJECT=${stack.name}`, ...shown(addresses, true)].join("\n"));
872
+ if (!command.length) return 0;
873
+ const vars = Object.fromEntries([["TASK_ENV_PROJECT", stack.name], ...addresses.map((a) => [a.name, a.value])]);
874
+ const run = spawnSync(command[0]!, command.slice(1), { stdio: "inherit", env: { ...env, ...vars } });
875
+ if (run.error) return console.log(`e2e: ${command[0]} not started — ${run.error.message}`), 2;
876
+ return run.status ?? 1;
877
+ }
878
+
879
+ /**
880
+ * `down`: the task's stacks — dev and e2e — with their volumes, those that are on the machine; stacks of other tasks
881
+ * aren't touched. A checkout without a compose file has no task stacks: Docker isn't called — `task close` of every
882
+ * project calls `down`, and with Docker stopped a project without Compose would get a `!` line (#395, question 4).
883
+ */
884
+ function stackDown(root: string, task: number | undefined, env: NodeJS.ProcessEnv): number {
885
+ if (!COMPOSE_FILES.some((f) => existsSync(path.join(root, f)))) return console.log(`○ no compose file in ${root} — the project has no task stacks`), 0;
886
+ const name = projectName(root);
887
+ const stacks = [stackOf(name, task), stackOf(name, task, true)].map((s) => s.name);
888
+ const machine = machineStacks(env);
889
+ if (typeof machine === "string") return console.log(`! stacks ${stacks.join(", ")}: not removed — Docker unavailable: ${machine}${hint(machine, env)}`), 2;
890
+ if (!(machine instanceof Map)) return console.log("○ no Docker on the machine — no task stacks"), 0;
891
+ const present = stacks.filter((s) => machine.has(s));
892
+ if (!present.length) return console.log(`○ stacks ${stacks.join(", ")}: none on the machine`), 0;
893
+ const done = present.map((s) => downStack(s, env));
894
+ console.log(done.map((d) => d.line).join("\n"));
895
+ return done.every((d) => d.ok) ? 0 : 2;
896
+ }
897
+
898
+ // The checkout's GitHub repository by its origin — a copy of originRepo from skills/est/scripts/est.ts and skills/github/scripts/github.ts:
899
+ // no shared module: task-env imports no other skill (tests/standards/skill-imports); the tests/standards/origin-repo
900
+ // standard keeps the parsing identical
901
+ const ORIGIN_RE = /^(?:[a-z][a-z\d+.-]*:\/\/(?:[^@/\s]+@)?(?:ssh\.)?github\.com(?::\d+)?\/|(?:[^@/\s:]+@)?(?:ssh\.)?github\.com:)([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i;
902
+
903
+ /** `owner/repo` of a GitHub origin URL (host github.com or ssh.github.com, any case, with a port), otherwise null. */
904
+ export function originRepo(url: string): string | null {
905
+ const m = ORIGIN_RE.exec(url.trim());
906
+ return m ? `${m[1]}/${m[2]}` : null;
907
+ }
908
+
909
+ /** A task's state on GitHub (`gh api`): open, closed, missing — no such issue (a PR isn't a task); otherwise why not known. */
910
+ function taskState(repo: string, n: number, env: NodeJS.ProcessEnv): "open" | "closed" | "missing" | { error: string } {
911
+ const r = spawnSync("gh", ["api", `repos/${repo}/issues/${n}`], { encoding: "utf8", env, timeout: 30_000 });
912
+ if ((r.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return { error: "gh not found — prune checks tasks on GitHub with the gh CLI" };
913
+ if (r.error) return { error: r.error.message };
914
+ if (r.status !== 0) return /HTTP 404|Not Found/i.test(r.stderr) ? "missing" : { error: r.stderr.trim().split("\n").at(-1) || `gh: code ${r.status}` };
915
+ try {
916
+ const issue = JSON.parse(r.stdout) as { state?: string; pull_request?: unknown };
917
+ if (issue.pull_request) return "missing";
918
+ return issue.state === "closed" ? "closed" : issue.state === "open" ? "open" : { error: `gh: state ${issue.state}` };
919
+ } catch {
920
+ return { error: "gh printed no JSON" };
921
+ }
922
+ }
923
+
924
+ /**
925
+ * `prune`: stacks of the project's closed tasks left on the machine — orphans: a task closed without `task close`, a
926
+ * close that couldn't reach Docker, a task that removed the compose file. The stacks `<project>-<N>` and
927
+ * `<project>-<N>-e2e` of the machine (containers, volumes and networks, #395, question 2); the task's state — by GitHub:
928
+ * closed — the stacks go with their volumes; open, not an issue, not checked — left. A stack whose containers ran in an
929
+ * existing directory of another project — another repository with the same name (`shop-2` at the project `shop`) —
930
+ * left (#395, question 3). Without confirmation: a closed task's stack is nobody's (#395, question 1).
931
+ */
932
+ function prune(root: string, env: NodeJS.ProcessEnv): number {
933
+ const name = projectName(root);
934
+ const machine = machineStacks(env);
935
+ if (typeof machine === "string") return console.log(`! prune: Docker unavailable — ${machine}${hint(machine, env)}`), 2;
936
+ if (!(machine instanceof Map)) return console.log("○ no Docker on the machine — no task stacks"), 0;
937
+ const re = new RegExp(`^${name}-(\\d+)(-e2e)?$`);
938
+ const found = [...machine.keys()]
939
+ .map((stack) => ({ stack, m: re.exec(stack) }))
940
+ .filter((x) => x.m)
941
+ .map(({ stack, m }) => ({ stack, task: Number(m![1]), e2e: !!m![2] }))
942
+ .sort((a, b) => a.task - b.task || Number(a.e2e) - Number(b.e2e));
943
+ if (!found.length) return console.log(`○ no stacks of ${name} tasks on the machine`), 0;
944
+ const repo = originRepo(git(root, "remote", "get-url", "origin"));
945
+ if (!repo) return console.log(`! prune: the origin of ${root} isn't a GitHub repository — task states can't be checked; left: ${found.map((f) => f.stack).join(", ")}`), 2;
946
+ const states = new Map<number, ReturnType<typeof taskState>>();
947
+ let ok = true;
948
+ for (const { stack, task } of found) {
949
+ const other = [...machine.get(stack)!].find((dir) => existsSync(dir) && projectName(dir) !== name);
950
+ if (other) {
951
+ console.log(`○ stack ${stack}: its containers are from ${other} — another project, left`);
952
+ continue;
953
+ }
954
+ if (!states.has(task)) states.set(task, taskState(repo, task, env));
955
+ const state = states.get(task)!;
956
+ if (state === "closed") {
957
+ const d = downStack(stack, env);
958
+ console.log(d.ok ? `${d.line} — task #${task} is closed` : d.line);
959
+ ok &&= d.ok;
960
+ } else if (state === "open") console.log(`○ stack ${stack}: task #${task} is open — left`);
961
+ else if (state === "missing") console.log(`○ stack ${stack}: no task #${task} in ${repo} — left`);
962
+ else {
963
+ console.log(`! stack ${stack}: task #${task} not checked — ${state.error}; left`);
964
+ ok = false;
965
+ }
966
+ }
967
+ return ok ? 0 : 2;
968
+ }
969
+
970
+ /** `--profile <name>` options of `up` and `e2e` before the command: the profiles and what follows them. */
971
+ function profilesOf(args: string[]): { profiles: string[]; rest: string[] } | undefined {
972
+ const profiles: string[] = [];
973
+ let i = 0;
974
+ for (; i < args.length && args[i] !== "--"; i++) {
975
+ if (args[i] !== "--profile" || !args[i + 1]) return undefined;
976
+ profiles.push(args[++i]!);
977
+ }
978
+ return { profiles, rest: args.slice(i + 1) };
979
+ }
980
+
981
+ /** The stack commands: undefined — not one of them or wrong arguments. */
982
+ function stackCommand(group: string, args: string[], env: NodeJS.ProcessEnv): number | undefined {
983
+ if (group !== "up" && group !== "e2e" && group !== "down" && group !== "prune") return undefined;
984
+ if (group === "down" || group === "prune") {
985
+ if (group === "prune" ? args.length : args.length > 1 || (args[0] && !/^\d+$/.test(args[0]))) return undefined;
986
+ // the stacks are found by the Compose project: the task's worktree may be gone already
987
+ const root = git(process.cwd(), "rev-parse", "--show-toplevel") || process.cwd();
988
+ if (group === "prune") return prune(root, env);
989
+ return stackDown(root, args[0] ? Number(args[0]) : taskBranch(git(root, "branch", "--show-current"))?.issue, env);
990
+ }
991
+ const opts = profilesOf(args);
992
+ if (!opts || (group === "up" && opts.rest.length)) return undefined;
993
+ const project = projectOf(process.cwd());
994
+ if (typeof project === "string") return console.log(`task-env ${group}: ${project}`), 2;
995
+ return group === "up" ? stackUp(project, opts.profiles, env) : stackE2e(project, opts.profiles, opts.rest, env);
996
+ }
997
+
998
+ const COMMANDS: Record<string, (env: NodeJS.ProcessEnv) => Result> = { up, down, status };
999
+
1000
+ function main(argv: string[]): number {
1001
+ const help = helpOf(argv);
1002
+ if (help) return process.stdout.write(help + "\n"), 0;
1003
+ const command = commandOf(argv);
1004
+ const usage = () => (process.stderr.write(`${command ? HELP[command] : OVERVIEW}\n`), 2);
1005
+ if (!command) return usage();
1006
+ const [group, cmd = "", ...rest] = argv;
1007
+ if (group === "check" && !rest.length && !cmd.startsWith("-")) return check(path.resolve(cmd || "."));
1008
+ const stack = stackCommand(group!, argv.slice(1), process.env);
1009
+ if (stack !== undefined) return stack;
1010
+ const fn = group === "proxy" && !rest.length ? COMMANDS[cmd] : undefined;
1011
+ if (!fn) return usage();
1012
+ const r = fn(process.env);
1013
+ (r.code === 2 ? process.stderr : process.stdout).write(r.out + "\n");
1014
+ return r.code;
1015
+ }
1016
+
1017
+ if (import.meta.main) process.exitCode = main(process.argv.slice(2));