@bensandee/tooling 0.67.0 → 0.68.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.
package/README.md CHANGED
@@ -530,6 +530,16 @@ Two things follow from this that are worth knowing:
530
530
 
531
531
  `docker-compose.check.env` is exempt from the bst-hook's env-file deny rules, so an agent working in the repo can create and read it. It is a committed, placeholder-holding file for a throwaway stack — do not put real secrets in it.
532
532
 
533
+ ### Derived HTTP health checks
534
+
535
+ A service with a published port but no compose-level `healthcheck:` automatically gets an HTTP health check — no configuration needed. That check is probed **from inside the service's own container network namespace** (`docker run --network container:<id> curlimages/curl ... http://localhost:<containerPort>/`), not from the process running `bst docker:check`.
536
+
537
+ This matters because compose publishes a port in the _host's_ network namespace, not the caller's. On a normal developer machine those are the same thing, so probing `localhost:<hostPort>` directly works fine — but on a CI runner that shares the host's docker daemon over a mounted socket (the standard Forgejo/GitHub Actions runner shape), the job container's `localhost` is its own netns, and the published port is unreachable from it. Probing from inside the target's own namespace works in every topology, including that one.
538
+
539
+ The probe image (`curlimages/curl`) is pinned by digest, and a `docker run` exit code of `125` (Docker's convention for "the daemon failed to start the container at all" — missing image, pull denied, daemon unreachable) fails the check immediately instead of waiting out the full timeout.
540
+
541
+ **This only applies to auto-derived checks.** A health check you configure yourself via `.tooling.json`'s `dockerCheck.healthChecks` is still probed with a direct `fetch()` from wherever `bst docker:check` runs — an arbitrary user-supplied URL isn't necessarily compose-related at all, so making it reachable from the caller remains your responsibility.
542
+
533
543
  ### Library API
534
544
 
535
545
  The `@bensandee/tooling/docker-check` export provides utilities for checking Docker Compose stacks programmatically.
package/dist/bin.mjs CHANGED
@@ -993,7 +993,7 @@ const DEFAULT_STALE_DAYS = 30;
993
993
  /** `.tooling.json#docs` — overrides only; a conventional repo omits the key. */
994
994
  const DocsConfigSchema = z.strictObject({
995
995
  enforced: z.union([z.boolean(), z.array(z.string())]).optional().meta({ description: "Which discovered pairs fail the build. true = all; an array names active dirs (e.g. [\"docs/plans\"]); default false = every violation is advisory." }),
996
- dirs: z.array(z.string()).optional().meta({ description: "Extra active doc dirs beyond convention (e.g. [\"docs/rfcs\"]). Each retires into its own completed/ subdir." }),
996
+ dirs: z.array(z.string()).optional().meta({ description: "Extra active doc dirs beyond convention (e.g. [\"docs/rfcs\"]). Each retires into its own retired/ subdir." }),
997
997
  staleDays: z.number().int().nonnegative().optional().meta({ description: "Days before an untouched active doc is flagged (default 30; 0 disables the rule)." }),
998
998
  staleEnforced: z.boolean().optional().meta({ description: "Make the staleness rule fatal inside an enforced pair (default false: staleness is always advisory)." })
999
999
  });
@@ -3337,7 +3337,7 @@ function buildManagedDevDeps(ctx) {
3337
3337
  const devDeps = { ...rootDevDeps(ctx.config) };
3338
3338
  if (!isMonorepo) Object.assign(devDeps, perPackageDevDeps(ctx.config));
3339
3339
  if (ctx.config.stack !== "none" || ctx.config.ci === "forgejo") devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.13.1";
3340
- if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.67.0";
3340
+ if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.68.0";
3341
3341
  if (ctx.config.useEslintPlugin && ctx.config.stack !== "none") devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.13.2";
3342
3342
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
3343
3343
  "@changesets/cli": "2.31.0",
@@ -6677,7 +6677,7 @@ const AGENT_RULES_TAIL = [
6677
6677
  "",
6678
6678
  "A plan's **directory** is the source of truth for whether it shipped — not its `Status:` line, not its checkboxes. Read the source it names.",
6679
6679
  "",
6680
- "Retiring the plan is the last task of the plan. Move it into `completed/` (`docs/plan-foo.md` → `docs/completed/completed-foo.md`, not `completed-plan-foo.md`; date-prefixed superpowers plans keep their name), set `Status: **completed YYYY-MM-DD**`, and add a short \"Shipped:\" paragraph naming what landed and where the truth now lives. Pick the honest verb — `completed` / `superseded` / `abandoned` — and never fabricate a `Shipped:` paragraph for code that isn't in the tree. A plan that lands one phase of a larger spec retires the plan and leaves the spec open.",
6680
+ "Retiring the plan is the last task of the plan. The **directory** encodes one binary — retired or not; the **status word** carries the outcome. Move it into `retired/` (`docs/plan-foo.md` → `docs/retired/completed-foo.md`, not `completed-plan-foo.md`; date-prefixed superpowers plans keep their name), set `Status: **completed YYYY-MM-DD**`, and add a short \"Shipped:\" paragraph naming what landed and where the truth now lives. Pick the honest verb — `completed` / `superseded` / `abandoned` / `rejected` — and never fabricate a `Shipped:` paragraph for code that isn't in the tree. A plan that lands one phase of a larger spec retires the plan and leaves the spec open.",
6681
6681
  "",
6682
6682
  "`bst docs:check` enforces the mechanical half (status ↔ directory, dated retirements, links, staleness). It cannot tell whether the code shipped — that's your job."
6683
6683
  ];
@@ -7137,43 +7137,73 @@ function parseHostPortSegment(segment) {
7137
7137
  port: parsed
7138
7138
  };
7139
7139
  }
7140
- /** Parse a single port mapping string and extract the host port. */
7140
+ /** Parse the container-port portion of a mapping — a plain literal number. */
7141
+ function parseContainerPortSegment(segment) {
7142
+ const parsed = Number.parseInt(segment.trim(), 10);
7143
+ return Number.isNaN(parsed) ? void 0 : parsed;
7144
+ }
7145
+ /** Parse a single port mapping string and extract both the host and container ports. */
7141
7146
  function parsePortString(port) {
7142
7147
  const withoutProtocol = port.split("/")[0];
7143
7148
  if (!withoutProtocol) return void 0;
7144
7149
  if (withoutProtocol.startsWith("${")) {
7145
7150
  const close = withoutProtocol.indexOf("}");
7146
7151
  if (close === -1) return void 0;
7147
- return parseHostPortSegment(withoutProtocol.slice(0, close + 1));
7152
+ const hostPort = parseHostPortSegment(withoutProtocol.slice(0, close + 1));
7153
+ const rest = withoutProtocol.slice(close + 1);
7154
+ return {
7155
+ hostPort,
7156
+ containerPort: rest.startsWith(":") ? parseContainerPortSegment(rest.slice(1)) : void 0
7157
+ };
7148
7158
  }
7149
7159
  const parts = withoutProtocol.split(":");
7150
- if (parts.length === 2) return parts[0] ? parseHostPortSegment(parts[0]) : void 0;
7151
- if (parts.length === 3) return parts[1] ? parseHostPortSegment(parts[1]) : void 0;
7160
+ if (parts.length === 2) return {
7161
+ hostPort: parts[0] ? parseHostPortSegment(parts[0]) : void 0,
7162
+ containerPort: parts[1] ? parseContainerPortSegment(parts[1]) : void 0
7163
+ };
7164
+ if (parts.length === 3) return {
7165
+ hostPort: parts[1] ? parseHostPortSegment(parts[1]) : void 0,
7166
+ containerPort: parts[2] ? parseContainerPortSegment(parts[2]) : void 0
7167
+ };
7152
7168
  }
7153
7169
  /**
7154
- * Find the first usable host port from a service's port mappings, returning
7155
- * either a literal `hostPort` or a `portEnvVar` (with its default as `hostPort`).
7170
+ * Find the first usable port mapping from a service's port mappings, returning
7171
+ * either a literal `hostPort` or a `portEnvVar` (with its default as `hostPort`),
7172
+ * plus the container-side port from that same mapping.
7156
7173
  */
7157
7174
  function findHostPort(ports) {
7158
7175
  for (const port of ports ?? []) {
7159
- const extracted = extractHostPort(port);
7176
+ const extracted = extractPortMapping(port);
7160
7177
  if (!extracted) continue;
7161
- if (extracted.kind === "literal") return { hostPort: extracted.port };
7178
+ const { hostPort, containerPort } = extracted;
7179
+ if (hostPort === void 0) return { containerPort };
7180
+ if (hostPort.kind === "literal") return {
7181
+ hostPort: hostPort.port,
7182
+ containerPort
7183
+ };
7162
7184
  return {
7163
- portEnvVar: extracted.name,
7164
- hostPort: extracted.default
7185
+ portEnvVar: hostPort.name,
7186
+ hostPort: hostPort.default,
7187
+ containerPort
7165
7188
  };
7166
7189
  }
7167
7190
  return {};
7168
7191
  }
7169
- function extractHostPort(port) {
7192
+ /** Parse the container-side literal from a long-syntax `target:` value. */
7193
+ function parseLongSyntaxTarget(target) {
7194
+ if (typeof target === "number") return target;
7195
+ if (typeof target === "string") return parseContainerPortSegment(target);
7196
+ }
7197
+ function extractPortMapping(port) {
7170
7198
  if (typeof port === "string") return parsePortString(port);
7171
7199
  const published = port.published;
7172
- if (typeof published === "number") return {
7173
- kind: "literal",
7174
- port: published
7200
+ return {
7201
+ hostPort: typeof published === "number" ? {
7202
+ kind: "literal",
7203
+ port: published
7204
+ } : typeof published === "string" ? parseHostPortSegment(published) : void 0,
7205
+ containerPort: parseLongSyntaxTarget(port.target)
7175
7206
  };
7176
- if (typeof published === "string") return parseHostPortSegment(published);
7177
7207
  }
7178
7208
  /** Parse compose files and return service info (names, ports, healthcheck presence). */
7179
7209
  function parseComposeServices(cwd, composeFiles) {
@@ -7191,10 +7221,12 @@ function parseComposeServices(cwd, composeFiles) {
7191
7221
  const existing = serviceMap.get(name);
7192
7222
  let hostPort = existing?.hostPort;
7193
7223
  let portEnvVar = existing?.portEnvVar;
7224
+ let containerPort = existing?.containerPort;
7194
7225
  if (hostPort === void 0 && portEnvVar === void 0) {
7195
7226
  const found = findHostPort(service.ports);
7196
7227
  hostPort = found.hostPort;
7197
7228
  portEnvVar = found.portEnvVar;
7229
+ containerPort = found.containerPort;
7198
7230
  }
7199
7231
  const buildDockerfile = resolveBuildDockerfile(cwd, service.build);
7200
7232
  serviceMap.set(name, {
@@ -7203,6 +7235,7 @@ function parseComposeServices(cwd, composeFiles) {
7203
7235
  buildDockerfile: existing?.buildDockerfile ?? buildDockerfile,
7204
7236
  hostPort,
7205
7237
  portEnvVar,
7238
+ containerPort,
7206
7239
  hasHealthcheck: existing?.hasHealthcheck ?? service.healthcheck !== void 0
7207
7240
  });
7208
7241
  }
@@ -7312,22 +7345,28 @@ function isLocallyBuiltService(service, locallyBuiltImageNames) {
7312
7345
  return false;
7313
7346
  }
7314
7347
  /**
7315
- * Generate health checks from parsed services: services with exposed ports get
7316
- * HTTP checks, unless they define a compose-level healthcheck.
7348
+ * Generate health checks from parsed services: services with a discoverable
7349
+ * container port get an HTTP check, unless they define a compose-level
7350
+ * healthcheck.
7317
7351
  *
7318
- * `resolvedPorts` maps service names to ports allocated at runtime (for
7319
- * `${PORT}`-parameterized services); a resolved port overrides the parsed
7320
- * `hostPort` so the check URL targets the port the stack actually binds.
7321
- */
7322
- function deriveHealthChecks(services, resolvedPorts = /* @__PURE__ */ new Map()) {
7352
+ * The check probes from inside the target's own network namespace
7353
+ * (`viaNetworkService`) rather than via the host-published port — a
7354
+ * host-published port is unreachable when `docker:check` itself runs inside a
7355
+ * container sharing the host's docker daemon (e.g. a Forgejo/GitHub Actions
7356
+ * runner), since compose publishes the port in the *host's* netns, not the
7357
+ * caller's. Gating on `containerPort` (a static literal from the compose file)
7358
+ * rather than `hostPort` also means this works even when the host side is
7359
+ * `${PORT}`-parameterized and not yet resolved.
7360
+ */
7361
+ function deriveHealthChecks(services) {
7323
7362
  const checks = [];
7324
7363
  for (const s of services) {
7325
7364
  if (s.hasHealthcheck) continue;
7326
- const port = resolvedPorts.get(s.name) ?? s.hostPort;
7327
- if (port === void 0) continue;
7365
+ if (s.containerPort === void 0) continue;
7328
7366
  checks.push({
7329
7367
  name: s.name,
7330
- url: `http://localhost:${String(port)}/`
7368
+ url: `http://localhost:${String(s.containerPort)}/`,
7369
+ viaNetworkService: s.name
7331
7370
  });
7332
7371
  }
7333
7372
  return checks;
@@ -7455,7 +7494,7 @@ function createFileReader() {
7455
7494
  function generateMigratePrompt(results, config, detected) {
7456
7495
  const sections = [];
7457
7496
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
7458
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.67.0 repo:sync\` on ${timestamp}_\n`);
7497
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.68.0 repo:sync\` on ${timestamp}_\n`);
7459
7498
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
7460
7499
  if (changed.length > 0) {
7461
7500
  const items = changed.map((r) => {
@@ -10039,13 +10078,15 @@ function findFreePort() {
10039
10078
  //#endregion
10040
10079
  //#region src/docker-check/compose.ts
10041
10080
  /** Zod schema for a single container entry from `docker compose ps --format json`.
10042
- * State/ExitCode are optional: older compose versions may omit them, in which
10043
- * case exit-code checking degrades to the pre-existing health-only gating. */
10081
+ * State/ExitCode/ID are optional: older compose versions may omit them, in which
10082
+ * case exit-code checking degrades to the pre-existing health-only gating, and a
10083
+ * network-namespace probe (which needs `ID`) simply can't run. */
10044
10084
  const ContainerInfoSchema = z.object({
10045
10085
  Service: z.string(),
10046
10086
  Health: z.string(),
10047
10087
  State: z.string().optional(),
10048
- ExitCode: z.number().optional()
10088
+ ExitCode: z.number().optional(),
10089
+ ID: z.string().optional()
10049
10090
  });
10050
10091
  /** Build the `docker compose` base args from config. */
10051
10092
  function composeArgs(config) {
@@ -10149,15 +10190,52 @@ function composeDown(executor, config) {
10149
10190
  });
10150
10191
  } catch (_error) {}
10151
10192
  }
10152
- /** Show logs for a specific service (or all services if not specified). Swallows errors. */
10193
+ /**
10194
+ * Show logs for a specific service (or all services if not specified). Captured
10195
+ * via `executor.exec` (not streamed via `execInherit`) and re-emitted through
10196
+ * `executor.log`/`executor.logError`, with an explicit "(no log output)" marker
10197
+ * when both streams come back empty — a streamed dump gives no way to tell "the
10198
+ * app produced nothing because nothing ever reached it" from "the dump itself
10199
+ * silently failed", which cost real debugging time chasing issue #300's
10200
+ * shared-daemon timeout (the derived HTTP probe never reached the app, so its
10201
+ * logs were empty on every failing run). Swallows errors.
10202
+ */
10153
10203
  function composeLogs(executor, config, service) {
10154
10204
  try {
10155
10205
  const args = [...composeArgs(config), "logs"];
10156
10206
  if (service) args.push(service);
10157
- executor.execInherit("docker", args, {
10207
+ const result = executor.exec("docker", args, {
10208
+ cwd: config.cwd,
10209
+ env: config.env
10210
+ });
10211
+ executor.log(service ? `-- ${service} logs --` : "-- logs --");
10212
+ if (result.stdout.trim()) executor.log(result.stdout);
10213
+ if (result.stderr.trim()) executor.logError(result.stderr);
10214
+ if (!result.stdout.trim() && !result.stderr.trim()) executor.log("(no log output)");
10215
+ } catch (_error) {}
10216
+ }
10217
+ /**
10218
+ * Dump `docker compose ps -a` in its default plain-text table alongside
10219
+ * `composeLogs()` at every failure/timeout site — a container-state snapshot
10220
+ * (exit code, "Restarting", etc.) survives even when the target emitted no
10221
+ * logs at all, which is exactly what happened chasing issue #300's
10222
+ * shared-daemon timeout (the probe never reached the app, so `composeLogs()`
10223
+ * alone gave no signal). Swallows errors, matching `composeLogs()`.
10224
+ */
10225
+ function composePsDump(executor, config) {
10226
+ try {
10227
+ const result = executor.exec("docker", [
10228
+ ...composeArgs(config),
10229
+ "ps",
10230
+ "-a"
10231
+ ], {
10158
10232
  cwd: config.cwd,
10159
10233
  env: config.env
10160
10234
  });
10235
+ executor.log("-- compose ps -a --");
10236
+ if (result.stdout.trim()) executor.log(result.stdout);
10237
+ if (result.stderr.trim()) executor.logError(result.stderr);
10238
+ if (!result.stdout.trim() && !result.stderr.trim()) executor.log("(no ps output)");
10161
10239
  } catch (_error) {}
10162
10240
  }
10163
10241
  /**
@@ -10193,6 +10271,51 @@ function composePs(executor, config) {
10193
10271
  }
10194
10272
  //#endregion
10195
10273
  //#region src/docker-check/health.ts
10274
+ /**
10275
+ * `docker run`'s exit code when the *daemon* fails to start the container at
10276
+ * all (image not found, pull access denied/timeout, daemon unreachable) —
10277
+ * Docker's own longstanding CLI convention, distinct from the containerized
10278
+ * process's own exit code (curl's own failure codes — e.g. 7 = connection
10279
+ * refused, 28 = timeout — live in a disjoint, lower range). A 125 here means
10280
+ * the probe itself is broken and must fail fast, not degrade to "keep
10281
+ * polling" for the full timeout — the exact silent-timeout symptom this
10282
+ * feature exists to fix.
10283
+ */
10284
+ const DOCKER_RUN_START_FAILURE_EXIT_CODE = 125;
10285
+ /**
10286
+ * `curl`'s per-attempt connect/total timeout, in seconds, for the network-mode
10287
+ * probe. Deliberately short — polling already retries every `pollIntervalMs`
10288
+ * (5s by default, see `check.ts`), so a stuck probe should fail that one
10289
+ * attempt quickly rather than block the next poll.
10290
+ */
10291
+ const PROBE_TIMEOUT_SECONDS = "5";
10292
+ /**
10293
+ * The image used to probe a service from inside its own network namespace
10294
+ * (`docker run --network container:<id> ...`). `curlimages/curl` is tiny,
10295
+ * official, and guaranteed to have `curl` — unlike the target service's own
10296
+ * image, which the derived-check feature must assume nothing about beyond
10297
+ * "listens on a port" (see `deriveHealthChecks()` in `detect.ts`).
10298
+ *
10299
+ * Why a dedicated pinned image instead of reusing something already present:
10300
+ * this repo builds no always-present agent/runner image to piggyback on, so
10301
+ * there's nothing guaranteed to already be on a CI runner's docker host the
10302
+ * way there might be elsewhere. The no-new-image alternative — `docker
10303
+ * compose exec <service> curl ...` against the target's own, already-pulled
10304
+ * image — was considered and rejected: it would require every target image
10305
+ * to ship both a shell and an HTTP client, an assumption the derived-check
10306
+ * feature is designed to avoid (it only activates for services with no
10307
+ * compose-level healthcheck at all, so it must assume as little as possible
10308
+ * about what's actually in that image).
10309
+ *
10310
+ * Pinned by digest, not a tag: a tag-pinned probe image would re-pull (and
10311
+ * could silently change) on every check run, reintroducing this exact
10312
+ * issue's silent-timeout symptom on the very shared-daemon CI runners it
10313
+ * targets, since registry egress is often restricted there. Resolved from
10314
+ * `curlimages/curl:8.21.0` (the latest stable tag as of 2026-07-16) via the
10315
+ * Docker Hub registry API — this is the digest of the multi-arch manifest
10316
+ * list, so `docker run` still resolves the correct platform automatically.
10317
+ */
10318
+ const PROBE_IMAGE = "curlimages/curl@sha256:7c12af72ceb38b7432ab85e1a265cff6ae58e06f95539d539b654f2cfa64bb13";
10196
10319
  /** Look up the health status of a specific service from container info. */
10197
10320
  function getContainerHealth(containers, serviceName) {
10198
10321
  return containers.find((c) => c.Service === serviceName)?.Health ?? "unknown";
@@ -10207,8 +10330,69 @@ function getContainerExitCode(containers, serviceName) {
10207
10330
  if (container?.State !== "exited") return void 0;
10208
10331
  return container.ExitCode;
10209
10332
  }
10333
+ /**
10334
+ * The container ID of a service, when reported (an older compose may omit `ID`).
10335
+ * Used to probe a service from inside its own network namespace via
10336
+ * `docker run --network container:<id>`.
10337
+ */
10338
+ function getContainerId(containers, serviceName) {
10339
+ return containers.find((c) => c.Service === serviceName)?.ID;
10340
+ }
10341
+ /**
10342
+ * Probe a service from inside its own network namespace via a throwaway
10343
+ * `docker run --network container:<id>` using the pinned curl probe image
10344
+ * (see `PROBE_IMAGE`) — this is what makes the check work when the caller
10345
+ * (e.g. a Forgejo runner sharing the host docker daemon) can't reach the
10346
+ * target's published host port directly. Deliberately *not* wrapped in the
10347
+ * blanket try/catch `checkHttpHealth`'s direct-`fetch()` path uses: a
10348
+ * docker-level probe failure (missing image, unreachable daemon) must
10349
+ * surface, not silently degrade to "keep polling" for the full timeout —
10350
+ * the exact symptom this feature exists to fix. See
10351
+ * `DOCKER_RUN_START_FAILURE_EXIT_CODE` for the 125-exit fail-fast path, and
10352
+ * `executor.exec` throwing outright (e.g. the `docker` binary missing) is
10353
+ * likewise left unhandled here, to propagate the same way.
10354
+ *
10355
+ * `--network container:<id>` (join the target's own namespace) rather than
10356
+ * `--network <project>_default` (join the compose project's network) needs no
10357
+ * project/network name derivation, and keeps working regardless of a custom
10358
+ * top-level `networks:` stanza or `network_mode: host` on the target — it
10359
+ * just joins whatever namespace the target is already in, whatever that is.
10360
+ */
10361
+ async function checkHttpHealthViaNetwork(executor, compose, check, viaNetworkService, containers) {
10362
+ const containerId = getContainerId(containers, viaNetworkService);
10363
+ if (containerId === void 0) return false;
10364
+ const result = executor.exec("docker", [
10365
+ "run",
10366
+ "--rm",
10367
+ "--network",
10368
+ `container:${containerId}`,
10369
+ PROBE_IMAGE,
10370
+ "curl",
10371
+ "-s",
10372
+ "-o",
10373
+ "/dev/null",
10374
+ "-w",
10375
+ "%{http_code}",
10376
+ "--connect-timeout",
10377
+ PROBE_TIMEOUT_SECONDS,
10378
+ "--max-time",
10379
+ PROBE_TIMEOUT_SECONDS,
10380
+ check.url
10381
+ ], {
10382
+ cwd: compose.cwd,
10383
+ env: compose.env
10384
+ });
10385
+ if (result.exitCode === DOCKER_RUN_START_FAILURE_EXIT_CODE) throw new UnexpectedError(`docker run failed to start the probe image ${PROBE_IMAGE}: ${result.stderr.trim() || "(no stderr captured)"}`);
10386
+ const status = Number.parseInt(result.stdout, 10);
10387
+ const httpResult = {
10388
+ status: Number.isNaN(status) ? 0 : status,
10389
+ ok: status >= 200 && status < 300
10390
+ };
10391
+ return await check.validate(httpResult);
10392
+ }
10210
10393
  /** Run a single HTTP health check, returning true if the validator passes. */
10211
- async function checkHttpHealth(executor, check) {
10394
+ async function checkHttpHealth(executor, compose, check, containers) {
10395
+ if (check.viaNetworkService !== void 0) return checkHttpHealthViaNetwork(executor, compose, check, check.viaNetworkService, containers);
10212
10396
  try {
10213
10397
  const response = await executor.fetch(check.url);
10214
10398
  return await check.validate(response);
@@ -10248,10 +10432,10 @@ function withFilledVariables(executor, config) {
10248
10432
  };
10249
10433
  }
10250
10434
  /** Probe any not-yet-healthy HTTP endpoints, flipping them to healthy in `healthStatus`. */
10251
- async function updateHttpHealth(executor, healthChecks, healthStatus) {
10435
+ async function updateHttpHealth(executor, compose, healthChecks, healthStatus, containers) {
10252
10436
  for (const check of healthChecks) {
10253
10437
  if (healthStatus.get(check.name)) continue;
10254
- if (await checkHttpHealth(executor, check)) {
10438
+ if (await checkHttpHealth(executor, compose, check, containers)) {
10255
10439
  healthStatus.set(check.name, true);
10256
10440
  executor.log(`${check.name} is healthy!`);
10257
10441
  }
@@ -10273,6 +10457,7 @@ function runSmoketestStep(executor, config, cleanup, startTime) {
10273
10457
  } catch (error) {
10274
10458
  const message = error instanceof Error ? error.message : String(error);
10275
10459
  executor.logError(`Smoketest failed: ${message}`);
10460
+ composePsDump(executor, compose);
10276
10461
  for (const service of compose.services) composeLogs(executor, compose, service);
10277
10462
  cleanup();
10278
10463
  return {
@@ -10320,6 +10505,7 @@ async function runDockerCheck(executor, rawConfig) {
10320
10505
  } catch (error) {
10321
10506
  const message = error instanceof Error ? error.message : String(error);
10322
10507
  executor.logError(`Compose stack failed to start — dumping logs`);
10508
+ composePsDump(executor, compose);
10323
10509
  for (const service of compose.services) composeLogs(executor, compose, service);
10324
10510
  cleanup();
10325
10511
  return {
@@ -10344,6 +10530,7 @@ async function runDockerCheck(executor, rawConfig) {
10344
10530
  if (status !== "" && status !== "unknown") sawHealthcheck = true;
10345
10531
  if (status === "unhealthy") {
10346
10532
  executor.logError(`Container ${service} is unhealthy`);
10533
+ composePsDump(executor, compose);
10347
10534
  composeLogs(executor, compose, service);
10348
10535
  cleanup();
10349
10536
  return {
@@ -10358,6 +10545,7 @@ async function runDockerCheck(executor, rawConfig) {
10358
10545
  if (exitCode !== void 0 && exitCode !== 0) {
10359
10546
  const message = `${service} exited with code ${String(exitCode)}`;
10360
10547
  executor.logError(`Container ${message}`);
10548
+ composePsDump(executor, compose);
10361
10549
  composeLogs(executor, compose, service);
10362
10550
  cleanup();
10363
10551
  return {
@@ -10368,7 +10556,7 @@ async function runDockerCheck(executor, rawConfig) {
10368
10556
  };
10369
10557
  }
10370
10558
  }
10371
- await updateHttpHealth(executor, config.healthChecks, healthStatus);
10559
+ await updateHttpHealth(executor, compose, config.healthChecks, healthStatus, containers);
10372
10560
  if (allContainersHealthy && [...healthStatus.values()].every(Boolean)) {
10373
10561
  if (!settlePolled && config.healthChecks.length === 0 && !sawHealthcheck) {
10374
10562
  settlePolled = true;
@@ -10395,6 +10583,7 @@ async function runDockerCheck(executor, rawConfig) {
10395
10583
  await executor.sleep(pollIntervalMs);
10396
10584
  }
10397
10585
  executor.logError("Timeout waiting for stack to become healthy");
10586
+ composePsDump(executor, compose);
10398
10587
  for (const service of compose.services) composeLogs(executor, compose, service);
10399
10588
  cleanup();
10400
10589
  return {
@@ -10444,7 +10633,8 @@ function toHttpHealthChecks(checks) {
10444
10633
  return checks.map((check) => ({
10445
10634
  name: check.name,
10446
10635
  url: check.url,
10447
- validate: async (res) => check.status ? res.status === check.status : res.ok
10636
+ validate: async (res) => check.status ? res.status === check.status : res.ok,
10637
+ viaNetworkService: check.viaNetworkService
10448
10638
  }));
10449
10639
  }
10450
10640
  /** Write the generated check overlay to a temp file. Returns the absolute path. */
@@ -10523,24 +10713,15 @@ const dockerCheckCommand = defineCommand({
10523
10713
  ];
10524
10714
  const portVarServices = services.filter((s) => s.portEnvVar === "PORT");
10525
10715
  let composeEnv;
10526
- let healthChecks = defaults.healthChecks;
10527
10716
  if (portVarServices.length > 0) {
10528
10717
  if (portVarServices.length > 1) log.warn(`Multiple services reference \${PORT} (${portVarServices.map((s) => s.name).join(", ")}); they cannot share a host port.`);
10529
10718
  const externalPort = process.env["PORT"] ?? (defaults.envFile !== void 0 ? composeEnvFileValue(composeCwd, defaults.envFile, "PORT") : void 0);
10530
- let resolvedPort;
10531
- if (externalPort !== void 0) {
10532
- log.info(`PORT is already set (${externalPort}) — using the configured value.`);
10533
- const parsed = Number.parseInt(externalPort, 10);
10534
- resolvedPort = Number.isNaN(parsed) ? void 0 : parsed;
10535
- } else {
10536
- resolvedPort = await findFreePort();
10719
+ if (externalPort !== void 0) log.info(`PORT is already set (${externalPort}) — using the configured value.`);
10720
+ else {
10721
+ const resolvedPort = await findFreePort();
10537
10722
  composeEnv = { PORT: String(resolvedPort) };
10538
10723
  log.info(`Allocated free host port ${String(resolvedPort)} for \${PORT}.`);
10539
10724
  }
10540
- if (resolvedPort !== void 0 && toolingConfig?.dockerCheck?.healthChecks === void 0) {
10541
- const port = resolvedPort;
10542
- healthChecks = deriveHealthChecks(services, new Map(portVarServices.map((s) => [s.name, port])));
10543
- }
10544
10725
  }
10545
10726
  try {
10546
10727
  const config = {
@@ -10554,7 +10735,7 @@ const dockerCheckCommand = defineCommand({
10554
10735
  },
10555
10736
  buildCommand: defaults.buildCommand,
10556
10737
  buildCwd: defaults.buildCwd,
10557
- healthChecks: healthChecks ? toHttpHealthChecks(healthChecks) : [],
10738
+ healthChecks: defaults.healthChecks ? toHttpHealthChecks(defaults.healthChecks) : [],
10558
10739
  smoketest: defaults.smoketest,
10559
10740
  smoketestCwd: defaults.smoketestCwd,
10560
10741
  timeoutMs: args.timeout ? Number.parseInt(args.timeout, 10) : defaults.timeoutMs,
@@ -12934,7 +13115,9 @@ const healthStatusCommand = defineCommand({
12934
13115
  //#endregion
12935
13116
  //#region src/docs-check/discover.ts
12936
13117
  const DOCS_ROOT = "docs";
12937
- const RETIRED_DIR = "completed";
13118
+ /** Canonical retirement dir name: encodes the binary (retired or not); the outcome lives in the doc's Status line. */
13119
+ const RETIRED_DIR = "retired";
13120
+ const RETIRED_DIR_NAMES = [RETIRED_DIR, "completed"];
12938
13121
  /** A directory with one of these names holds plan/spec docs. */
12939
13122
  const PLAN_DIR_NAMES = /* @__PURE__ */ new Set(["plans", "specs"]);
12940
13123
  /** `docs/` is a general folder: only the `plan-` prefix is a tracked doc there. */
@@ -12951,7 +13134,7 @@ function listDocs(exec, dir, pattern) {
12951
13134
  if (!entries) return [];
12952
13135
  return entries.filter((e) => !e.isDirectory && e.name.endsWith(".md")).filter((e) => pattern?.test(e.name) ?? true).map((e) => `${dir}/${e.name}`).toSorted();
12953
13136
  }
12954
- /** Every `plans`/`specs` directory under docs/, depth-limited, skipping completed/ dirs. */
13137
+ /** Every `plans`/`specs` directory under docs/, depth-limited, skipping retired/completed dirs. */
12955
13138
  function conventionalActiveDirs(exec) {
12956
13139
  const found = [];
12957
13140
  const walk = (dir, depth) => {
@@ -12959,7 +13142,7 @@ function conventionalActiveDirs(exec) {
12959
13142
  const entries = exec.readDir(dir);
12960
13143
  if (!entries) return;
12961
13144
  for (const entry of entries) {
12962
- if (!entry.isDirectory || entry.name === RETIRED_DIR) continue;
13145
+ if (!entry.isDirectory || RETIRED_DIR_NAMES.includes(entry.name)) continue;
12963
13146
  const child = `${dir}/${entry.name}`;
12964
13147
  if (PLAN_DIR_NAMES.has(entry.name)) found.push(child);
12965
13148
  walk(child, depth + 1);
@@ -12977,18 +13160,20 @@ function discoverPairs(exec, config) {
12977
13160
  for (const active of [...conventionalActiveDirs(exec), ...config.extraDirs]) candidates.push({
12978
13161
  active,
12979
13162
  retired: `${active}/${RETIRED_DIR}`,
13163
+ retiredDirs: RETIRED_DIR_NAMES.map((name) => `${active}/${name}`),
12980
13164
  enforced: isEnforced(active, config.enforced)
12981
13165
  });
12982
13166
  candidates.push({
12983
13167
  active: DOCS_ROOT,
12984
13168
  retired: `${DOCS_ROOT}/${RETIRED_DIR}`,
13169
+ retiredDirs: RETIRED_DIR_NAMES.map((name) => `${DOCS_ROOT}/${name}`),
12985
13170
  activePattern: LEGACY_ACTIVE_PATTERN,
12986
13171
  enforced: isEnforced(DOCS_ROOT, config.enforced)
12987
13172
  });
12988
13173
  const seen = /* @__PURE__ */ new Set();
12989
13174
  return candidates.filter((pair) => {
12990
13175
  if (seen.has(pair.active)) return false;
12991
- if (!(listDocs(exec, pair.active, pair.activePattern).length > 0 || listDocs(exec, pair.retired).length > 0)) return false;
13176
+ if (!(listDocs(exec, pair.active, pair.activePattern).length > 0 || pair.retiredDirs.some((dir) => listDocs(exec, dir).length > 0))) return false;
12992
13177
  seen.add(pair.active);
12993
13178
  return true;
12994
13179
  });
@@ -13141,8 +13326,8 @@ function relativeLinks(body, fileDir) {
13141
13326
  }
13142
13327
  //#endregion
13143
13328
  //#region src/docs-check/rules.ts
13144
- /** Status keywords that mean "this work is over" — the doc belongs in completed/. */
13145
- const TERMINAL_STATUS = /\b(completed|shipped|superseded|abandoned|cancelled|canceled)\b/i;
13329
+ /** Status keywords that mean "this work is over" — the doc belongs in retired/. */
13330
+ const TERMINAL_STATUS = /\b(completed|shipped|superseded|abandoned|cancelled|canceled|rejected)\b/i;
13146
13331
  /**
13147
13332
  * Qualifiers that mean the work is still open, and which WIN over a terminal
13148
13333
  * keyword in the same status. A partially-landed umbrella spec reads
@@ -13154,7 +13339,7 @@ const TERMINAL_STATUS = /\b(completed|shipped|superseded|abandoned|cancelled|can
13154
13339
  */
13155
13340
  const OPEN_STATUS = /\b(partial|partially|in[ -]progress|wip|draft|approved|proposed|blocked|planned|open)\b/i;
13156
13341
  /** A terminal status must carry the date it landed, so history stays legible. */
13157
- const TERMINAL_STATUS_DATED = /\b(completed|shipped|superseded|abandoned|cancelled|canceled)\b[^\n]*?\d{4}-\d{2}-\d{2}/i;
13342
+ const TERMINAL_STATUS_DATED = /\b(completed|shipped|superseded|abandoned|cancelled|canceled|rejected)\b[^\n]*?\d{4}-\d{2}-\d{2}/i;
13158
13343
  /**
13159
13344
  * The paragraph a retired doc owes its reader: where the truth lives now.
13160
13345
  * Deliberately loose at both ends — NOT anchored to line-start (docs run it
@@ -13163,7 +13348,7 @@ const TERMINAL_STATUS_DATED = /\b(completed|shipped|superseded|abandoned|cancell
13163
13348
  * only.**`). The rule is that a retired doc SAYS where the truth went; policing
13164
13349
  * its punctuation would only teach people to route around the check.
13165
13350
  */
13166
- const RETIRE_PARAGRAPH = /(?:\*\*)?\b(?:Shipped|Superseded|Abandoned)\b\s*(?:[:—–]|--?\s)/i;
13351
+ const RETIRE_PARAGRAPH = /(?:\*\*)?\b(?:Shipped|Superseded|Abandoned|Rejected)\b\s*(?:[:—–]|--?\s)/i;
13167
13352
  /** `Status:` must appear in the doc's header, not buried in a code sample. */
13168
13353
  const STATUS_LINE = /^\s*(?:>\s*)?(?:\*\*)?Status(?:\*\*)?:\s*(.+)$/im;
13169
13354
  const STATUS_SCAN_LINES = 30;
@@ -13195,8 +13380,8 @@ function checkStatus(input, status) {
13195
13380
  if (!terminal && isRetired) violations.push({
13196
13381
  file,
13197
13382
  rule: "R2 retired-but-not-done",
13198
- message: `Doc lives in \`completed/\` but its status ("${status}") doesn't say completed/shipped/superseded.`,
13199
- fix: "Set `Status: **completed YYYY-MM-DD**` (or superseded/abandoned) — or move it back to the active dir if it isn't actually done."
13383
+ message: `Doc lives in \`${retiredDir}/\` but its status ("${status}") doesn't say completed/shipped/superseded/rejected.`,
13384
+ fix: "Set `Status: **completed YYYY-MM-DD**` (or superseded/abandoned/rejected) — or move it back to the active dir if it isn't actually done."
13200
13385
  });
13201
13386
  if (isRetired && terminal && !TERMINAL_STATUS_DATED.test(status)) violations.push({
13202
13387
  file,
@@ -13207,7 +13392,7 @@ function checkStatus(input, status) {
13207
13392
  if (isRetired && terminal && !RETIRE_PARAGRAPH.test(body)) violations.push({
13208
13393
  file,
13209
13394
  rule: "R3 missing-retire-paragraph",
13210
- message: "Retired doc has no `Shipped:` / `Superseded:` / `Abandoned:` paragraph.",
13395
+ message: "Retired doc has no `Shipped:` / `Superseded:` / `Abandoned:` / `Rejected:` paragraph.",
13211
13396
  fix: "Add one after the Status line naming what landed and where the post-implementation truth now lives (CLAUDE.md, the source files, …)."
13212
13397
  });
13213
13398
  return violations;
@@ -13218,7 +13403,7 @@ function checkLinks(input) {
13218
13403
  file: input.file,
13219
13404
  rule: "R4 broken-link",
13220
13405
  message: `Relative link \`${link.raw}\` doesn't resolve (looked for ${link.target}).`,
13221
- fix: "Fix the path — a doc moved into `completed/` sits one level deeper, so each `../` needs one more `../`. If the link points at something the work deleted, it can never be re-pathed: de-link it (plain code span)."
13406
+ fix: "Fix the path — a doc moved into `retired/` sits one level deeper, so each `../` needs one more `../`. If the link points at something the work deleted, it can never be re-pathed: de-link it (plain code span)."
13222
13407
  }));
13223
13408
  }
13224
13409
  /**
@@ -13266,7 +13451,7 @@ function checkPair(exec, pair, config, opts) {
13266
13451
  const linkExists = (target) => exec.exists(target);
13267
13452
  const violations = [];
13268
13453
  const activeFiles = listDocs(exec, pair.active, pair.activePattern);
13269
- const retiredFiles = listDocs(exec, pair.retired);
13454
+ const retiredFiles = pair.retiredDirs.flatMap((dir) => listDocs(exec, dir));
13270
13455
  for (const [files, isRetired] of [[activeFiles, false], [retiredFiles, true]]) for (const file of files) {
13271
13456
  const body = exec.readFile(file);
13272
13457
  if (body === void 0) continue;
@@ -14474,7 +14659,7 @@ const issueDownloadCommand = defineCommand({
14474
14659
  * `local <sha>[-dirty]` otherwise — so an artifact built from the working tree
14475
14660
  * can't pass itself off as the published release whose version it carries.
14476
14661
  */
14477
- const displayVersion = "0.67.0";
14662
+ const displayVersion = "0.68.0";
14478
14663
  const main = defineCommand({
14479
14664
  meta: {
14480
14665
  name: "bst",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.67.0",
3
+ "version": "0.68.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -344,7 +344,7 @@
344
344
  ]
345
345
  },
346
346
  "dirs": {
347
- "description": "Extra active doc dirs beyond convention (e.g. [\"docs/rfcs\"]). Each retires into its own completed/ subdir.",
347
+ "description": "Extra active doc dirs beyond convention (e.g. [\"docs/rfcs\"]). Each retires into its own retired/ subdir.",
348
348
  "type": "array",
349
349
  "items": {
350
350
  "type": "string"