@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 +10 -0
- package/dist/bin.mjs +251 -66
- package/package.json +1 -1
- package/tooling.schema.json +1 -1
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
|
|
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.
|
|
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 `
|
|
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
|
|
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
|
-
|
|
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
|
|
7151
|
-
|
|
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
|
|
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 =
|
|
7176
|
+
const extracted = extractPortMapping(port);
|
|
7160
7177
|
if (!extracted) continue;
|
|
7161
|
-
|
|
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:
|
|
7164
|
-
hostPort:
|
|
7185
|
+
portEnvVar: hostPort.name,
|
|
7186
|
+
hostPort: hostPort.default,
|
|
7187
|
+
containerPort
|
|
7165
7188
|
};
|
|
7166
7189
|
}
|
|
7167
7190
|
return {};
|
|
7168
7191
|
}
|
|
7169
|
-
|
|
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
|
-
|
|
7173
|
-
|
|
7174
|
-
|
|
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
|
|
7316
|
-
* HTTP
|
|
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
|
-
*
|
|
7319
|
-
*
|
|
7320
|
-
*
|
|
7321
|
-
|
|
7322
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
10531
|
-
|
|
10532
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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 `
|
|
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,
|
|
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.
|
|
14662
|
+
const displayVersion = "0.68.0";
|
|
14478
14663
|
const main = defineCommand({
|
|
14479
14664
|
meta: {
|
|
14480
14665
|
name: "bst",
|
package/package.json
CHANGED
package/tooling.schema.json
CHANGED
|
@@ -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
|
|
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"
|