@bensandee/tooling 0.17.0 → 0.19.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
@@ -162,17 +162,17 @@ import type {
162
162
  | `DetectedProjectState` | Detected existing project state (package manager, CI, etc.) |
163
163
  | `LegacyConfig` | Legacy config detection for migration |
164
164
 
165
- ## Docker verification
165
+ ## Docker check
166
166
 
167
- The `@bensandee/tooling/docker-verify` export provides utilities for verifying Docker Compose stacks via health checks.
167
+ The `@bensandee/tooling/docker-check` export provides utilities for checking Docker Compose stacks via health checks.
168
168
 
169
169
  ### Quick start
170
170
 
171
171
  ```ts
172
- import { createRealExecutor, runVerification } from "@bensandee/tooling/docker-verify";
173
- import type { VerifyConfig } from "@bensandee/tooling/docker-verify";
172
+ import { createRealExecutor, runDockerCheck } from "@bensandee/tooling/docker-check";
173
+ import type { CheckConfig } from "@bensandee/tooling/docker-check";
174
174
 
175
- const config: VerifyConfig = {
175
+ const config: CheckConfig = {
176
176
  compose: {
177
177
  cwd: "./deploy",
178
178
  composeFiles: ["docker-compose.yaml"],
@@ -190,7 +190,7 @@ const config: VerifyConfig = {
190
190
  pollIntervalMs: 5_000,
191
191
  };
192
192
 
193
- const result = await runVerification(createRealExecutor(), config);
193
+ const result = await runDockerCheck(createRealExecutor(), config);
194
194
  if (!result.success) {
195
195
  console.error(result.reason, result.message);
196
196
  }
@@ -200,7 +200,7 @@ if (!result.success) {
200
200
 
201
201
  | Export | Description |
202
202
  | -------------------------------------- | ----------------------------------------------------------------- |
203
- | `runVerification(executor, config)` | Full lifecycle: build, compose up, health check polling, teardown |
203
+ | `runDockerCheck(executor, config)` | Full lifecycle: build, compose up, health check polling, teardown |
204
204
  | `createRealExecutor()` | Production executor (real shell, fetch, timers) |
205
205
  | `composeUp(executor, config)` | Start compose services |
206
206
  | `composeDown(executor, config)` | Stop and remove compose services |
@@ -211,11 +211,11 @@ if (!result.success) {
211
211
 
212
212
  ### Types
213
213
 
214
- | Type | Description |
215
- | ---------------------- | ------------------------------------------------------------------------------------------ |
216
- | `VerifyConfig` | Full verification config (compose settings, build command, health checks, timeouts) |
217
- | `ComposeConfig` | Docker Compose settings (cwd, compose files, env file, services) |
218
- | `HttpHealthCheck` | Health check definition (name, URL, validate function) |
219
- | `VerifyResult` | Result: `{ success: true, elapsedMs }` or `{ success: false, reason, message, elapsedMs }` |
220
- | `DockerVerifyExecutor` | Side-effect abstraction (exec, fetch, timers) for testability |
221
- | `ContainerInfo` | Container status info from `composePs` |
214
+ | Type | Description |
215
+ | --------------------- | ------------------------------------------------------------------------------------------ |
216
+ | `CheckConfig` | Full check config (compose settings, build command, health checks, timeouts) |
217
+ | `ComposeConfig` | Docker Compose settings (cwd, compose files, env file, services) |
218
+ | `HttpHealthCheck` | Health check definition (name, URL, validate function) |
219
+ | `CheckResult` | Result: `{ success: true, elapsedMs }` or `{ success: false, reason, message, elapsedMs }` |
220
+ | `DockerCheckExecutor` | Side-effect abstraction (exec, fetch, timers) for testability |
221
+ | `ContainerInfo` | Container status info from `composePs` |
package/dist/bin.mjs CHANGED
@@ -1,15 +1,16 @@
1
1
  #!/usr/bin/env node
2
- import { t as isExecSyncError } from "./exec-CC49vrkM.mjs";
2
+ import { l as createRealExecutor$1, t as runDockerCheck, u as isExecSyncError } from "./check-VAgrEX2D.mjs";
3
3
  import { defineCommand, runMain } from "citty";
4
4
  import * as p from "@clack/prompts";
5
5
  import path from "node:path";
6
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
6
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
7
7
  import JSON5 from "json5";
8
8
  import { parse } from "jsonc-parser";
9
9
  import { z } from "zod";
10
- import { isMap, isSeq, parseDocument } from "yaml";
10
+ import { isMap, isSeq, parse as parse$1, parseDocument, stringify } from "yaml";
11
11
  import { execSync } from "node:child_process";
12
12
  import { FatalError, TransientError, UnexpectedError } from "@bensandee/common";
13
+ import { tmpdir } from "node:os";
13
14
  //#region src/types.ts
14
15
  const LEGACY_TOOLS = [
15
16
  "eslint",
@@ -41,7 +42,7 @@ const TsconfigSchema = z.object({
41
42
  include: z.array(z.string()).optional(),
42
43
  exclude: z.array(z.string()).optional(),
43
44
  files: z.array(z.string()).optional(),
44
- references: z.array(z.object({ path: z.string() }).passthrough()).optional(),
45
+ references: z.array(z.object({ path: z.string() }).loose()).optional(),
45
46
  compilerOptions: z.record(z.string(), z.unknown()).optional()
46
47
  }).loose();
47
48
  const RenovateSchema = z.object({
@@ -456,7 +457,7 @@ const DeclarativeHealthCheckSchema = z.object({
456
457
  url: z.string(),
457
458
  status: z.number().int().optional()
458
459
  });
459
- const DockerVerifyConfigSchema = z.object({
460
+ const DockerCheckConfigSchema = z.object({
460
461
  composeFiles: z.array(z.string()).optional(),
461
462
  envFile: z.string().optional(),
462
463
  services: z.array(z.string()).optional(),
@@ -495,7 +496,7 @@ const ToolingConfigSchema = z.object({
495
496
  dockerfile: z.string(),
496
497
  context: z.string().default(".")
497
498
  })).optional(),
498
- dockerVerify: DockerVerifyConfigSchema.optional()
499
+ dockerCheck: z.union([z.literal(false), DockerCheckConfigSchema]).optional()
499
500
  });
500
501
  /** Load saved tooling config from the target directory. Returns undefined if missing or invalid. */
501
502
  function loadToolingConfig(targetDir) {
@@ -656,7 +657,7 @@ function getAddedDevDepNames(config) {
656
657
  const deps = { ...ROOT_DEV_DEPS };
657
658
  if (config.structure !== "monorepo") Object.assign(deps, PER_PACKAGE_DEV_DEPS);
658
659
  deps["@bensandee/config"] = "0.8.2";
659
- deps["@bensandee/tooling"] = "0.17.0";
660
+ deps["@bensandee/tooling"] = "0.19.0";
660
661
  if (config.formatter === "oxfmt") deps["oxfmt"] = "0.35.0";
661
662
  if (config.formatter === "prettier") deps["prettier"] = "3.8.1";
662
663
  addReleaseDeps(deps, config);
@@ -677,7 +678,7 @@ async function generatePackageJson(ctx) {
677
678
  const devDeps = { ...ROOT_DEV_DEPS };
678
679
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
679
680
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.8.2";
680
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.17.0";
681
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.19.0";
681
682
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.2";
682
683
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = "0.35.0";
683
684
  if (ctx.config.formatter === "prettier") devDeps["prettier"] = "3.8.1";
@@ -1266,6 +1267,34 @@ function mergeWorkflowSteps(existing, jobName, requiredSteps) {
1266
1267
  * Returns unchanged content if the job already exists, the file has an opt-out comment,
1267
1268
  * or the document can't be parsed.
1268
1269
  */
1270
+ /**
1271
+ * Ensure a `concurrency` block exists at the workflow top level.
1272
+ * Adds it if missing — never modifies an existing one.
1273
+ * Returns unchanged content if the file has an opt-out comment or can't be parsed.
1274
+ */
1275
+ function ensureWorkflowConcurrency(existing, concurrency) {
1276
+ if (isToolingIgnored(existing)) return {
1277
+ content: existing,
1278
+ changed: false
1279
+ };
1280
+ try {
1281
+ const doc = parseDocument(existing);
1282
+ if (doc.has("concurrency")) return {
1283
+ content: existing,
1284
+ changed: false
1285
+ };
1286
+ doc.set("concurrency", doc.createNode(concurrency));
1287
+ return {
1288
+ content: doc.toString(),
1289
+ changed: true
1290
+ };
1291
+ } catch {
1292
+ return {
1293
+ content: existing,
1294
+ changed: false
1295
+ };
1296
+ }
1297
+ }
1269
1298
  function addWorkflowJob(existing, jobName, jobConfig) {
1270
1299
  if (isToolingIgnored(existing)) return {
1271
1300
  content: existing,
@@ -1296,6 +1325,14 @@ function addWorkflowJob(existing, jobName, jobConfig) {
1296
1325
  }
1297
1326
  //#endregion
1298
1327
  //#region src/generators/ci.ts
1328
+ /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
1329
+ function actionsExpr$2(expr) {
1330
+ return `\${{ ${expr} }}`;
1331
+ }
1332
+ const CI_CONCURRENCY = {
1333
+ group: `ci-${actionsExpr$2("github.ref")}`,
1334
+ "cancel-in-progress": actionsExpr$2("github.ref != 'refs/heads/main'")
1335
+ };
1299
1336
  function hasEnginesNode$2(ctx) {
1300
1337
  const raw = ctx.read("package.json");
1301
1338
  if (!raw) return false;
@@ -1309,6 +1346,10 @@ ${emailNotifications}on:
1309
1346
  branches: [main]
1310
1347
  pull_request:
1311
1348
 
1349
+ concurrency:
1350
+ group: ci-${actionsExpr$2("github.ref")}
1351
+ cancel-in-progress: ${actionsExpr$2("github.ref != 'refs/heads/main'")}
1352
+
1312
1353
  jobs:
1313
1354
  check:
1314
1355
  runs-on: ubuntu-latest
@@ -1371,8 +1412,9 @@ async function generateCi(ctx) {
1371
1412
  const existing = ctx.read(filePath);
1372
1413
  if (existing) {
1373
1414
  const merged = mergeWorkflowSteps(existing, "check", requiredCheckSteps(nodeVersionYaml));
1374
- const withComment = ensureSchemaComment(merged.content, isGitHub ? "github" : "forgejo");
1375
- if (merged.changed || withComment !== merged.content) {
1415
+ const withConcurrency = ensureWorkflowConcurrency(merged.content, CI_CONCURRENCY);
1416
+ const withComment = ensureSchemaComment(withConcurrency.content, isGitHub ? "github" : "forgejo");
1417
+ if (merged.changed || withConcurrency.changed || withComment !== withConcurrency.content) {
1376
1418
  ctx.write(filePath, withComment);
1377
1419
  return {
1378
1420
  filePath,
@@ -2312,7 +2354,7 @@ const SCHEMA_NPM_PATH = "@bensandee/config/schemas/forgejo-workflow.schema.json"
2312
2354
  const SCHEMA_LOCAL_PATH = ".vscode/forgejo-workflow.schema.json";
2313
2355
  const SETTINGS_PATH = ".vscode/settings.json";
2314
2356
  const SCHEMA_GLOB = ".forgejo/workflows/*.{yml,yaml}";
2315
- const VscodeSettingsSchema = z.object({ "yaml.schemas": z.record(z.string(), z.unknown()).default({}) }).passthrough();
2357
+ const VscodeSettingsSchema = z.object({ "yaml.schemas": z.record(z.string(), z.unknown()).default({}) }).loose();
2316
2358
  function readSchemaFromNodeModules(targetDir) {
2317
2359
  const candidate = path.join(targetDir, "node_modules", SCHEMA_NPM_PATH);
2318
2360
  if (!existsSync(candidate)) return void 0;
@@ -4073,7 +4115,7 @@ function generateTags(version) {
4073
4115
  function imageRef(namespace, imageName, tag) {
4074
4116
  return `${namespace}/${imageName}:${tag}`;
4075
4117
  }
4076
- function log(message) {
4118
+ function log$1(message) {
4077
4119
  console.log(message);
4078
4120
  }
4079
4121
  function debug(verbose, message) {
@@ -4114,22 +4156,22 @@ function runDockerBuild(executor, config) {
4114
4156
  const repoName = readRepoName(executor, config.cwd);
4115
4157
  if (config.packageDir) {
4116
4158
  const pkg = readSinglePackageDocker(executor, config.cwd, config.packageDir, repoName);
4117
- log(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
4159
+ log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
4118
4160
  buildImage(executor, pkg, config.cwd, config.verbose, config.extraArgs);
4119
- log(`Built ${pkg.imageName}:latest`);
4161
+ log$1(`Built ${pkg.imageName}:latest`);
4120
4162
  return { packages: [pkg] };
4121
4163
  }
4122
4164
  const packages = detectDockerPackages(executor, config.cwd, repoName);
4123
4165
  if (packages.length === 0) {
4124
- log("No packages with docker config found");
4166
+ log$1("No packages with docker config found");
4125
4167
  return { packages: [] };
4126
4168
  }
4127
- log(`Found ${packages.length} Docker package(s): ${packages.map((p) => p.dir).join(", ")}`);
4169
+ log$1(`Found ${packages.length} Docker package(s): ${packages.map((p) => p.dir).join(", ")}`);
4128
4170
  for (const pkg of packages) {
4129
- log(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
4171
+ log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
4130
4172
  buildImage(executor, pkg, config.cwd, config.verbose, config.extraArgs);
4131
4173
  }
4132
- log(`Built ${packages.length} image(s)`);
4174
+ log$1(`Built ${packages.length} image(s)`);
4133
4175
  return { packages };
4134
4176
  }
4135
4177
  /**
@@ -4153,35 +4195,35 @@ function runDockerPublish(executor, config) {
4153
4195
  };
4154
4196
  for (const pkg of packages) if (!pkg.version) throw new FatalError(`Package ${pkg.dir} has docker config but no version in package.json`);
4155
4197
  if (!config.dryRun) {
4156
- log(`Logging in to ${config.registryHost}...`);
4198
+ log$1(`Logging in to ${config.registryHost}...`);
4157
4199
  const loginResult = executor.exec(`echo "${config.password}" | docker login ${config.registryHost} -u ${config.username} --password-stdin`);
4158
4200
  if (loginResult.exitCode !== 0) throw new FatalError(`Docker login failed: ${loginResult.stderr}`);
4159
- } else log("[dry-run] Skipping docker login");
4201
+ } else log$1("[dry-run] Skipping docker login");
4160
4202
  const allTags = [];
4161
4203
  try {
4162
4204
  for (const pkg of packages) {
4163
4205
  const tags = generateTags(pkg.version ?? "");
4164
- log(`${pkg.dir} v${pkg.version} → tags: ${tags.join(", ")}`);
4206
+ log$1(`${pkg.dir} v${pkg.version} → tags: ${tags.join(", ")}`);
4165
4207
  for (const tag of tags) {
4166
4208
  const ref = imageRef(config.registryNamespace, pkg.imageName, tag);
4167
4209
  allTags.push(ref);
4168
- log(`Tagging ${pkg.imageName} → ${ref}`);
4210
+ log$1(`Tagging ${pkg.imageName} → ${ref}`);
4169
4211
  const tagResult = executor.exec(`docker tag ${pkg.imageName} ${ref}`);
4170
4212
  if (tagResult.exitCode !== 0) throw new FatalError(`docker tag failed: ${tagResult.stderr}`);
4171
4213
  if (!config.dryRun) {
4172
- log(`Pushing ${ref}...`);
4214
+ log$1(`Pushing ${ref}...`);
4173
4215
  const pushResult = executor.exec(`docker push ${ref}`);
4174
4216
  if (pushResult.exitCode !== 0) throw new FatalError(`docker push failed: ${pushResult.stderr}`);
4175
- } else log(`[dry-run] Skipping push for ${ref}`);
4217
+ } else log$1(`[dry-run] Skipping push for ${ref}`);
4176
4218
  }
4177
4219
  }
4178
4220
  } finally {
4179
4221
  if (!config.dryRun) {
4180
- log(`Logging out from ${config.registryHost}...`);
4222
+ log$1(`Logging out from ${config.registryHost}...`);
4181
4223
  executor.exec(`docker logout ${config.registryHost}`);
4182
4224
  }
4183
4225
  }
4184
- log(`Published ${allTags.length} image tag(s)`);
4226
+ log$1(`Published ${allTags.length} image tag(s)`);
4185
4227
  return {
4186
4228
  packages,
4187
4229
  tags: allTags
@@ -4257,11 +4299,251 @@ const dockerBuildCommand = defineCommand({
4257
4299
  }
4258
4300
  });
4259
4301
  //#endregion
4302
+ //#region src/docker-check/detect.ts
4303
+ /** Compose file names to scan, in priority order. */
4304
+ const COMPOSE_FILE_CANDIDATES = [
4305
+ "docker-compose.yaml",
4306
+ "docker-compose.yml",
4307
+ "compose.yaml",
4308
+ "compose.yml"
4309
+ ];
4310
+ /** Zod schema for the subset of compose YAML we care about. */
4311
+ const ComposePortSchema = z.union([z.string(), z.object({
4312
+ published: z.union([z.string(), z.number()]),
4313
+ target: z.union([z.string(), z.number()]).optional()
4314
+ }).loose()]);
4315
+ const ComposeServiceSchema = z.object({
4316
+ ports: z.array(ComposePortSchema).optional(),
4317
+ healthcheck: z.unknown().optional()
4318
+ }).loose();
4319
+ const ComposeFileSchema = z.object({ services: z.record(z.string(), ComposeServiceSchema).optional() }).loose();
4320
+ /** Directories to scan for compose files, in priority order. */
4321
+ const COMPOSE_DIR_CANDIDATES = [".", "docker"];
4322
+ /** Detect which compose files exist at conventional paths.
4323
+ * Returns the resolved directory and the matching file names. */
4324
+ function detectComposeFiles(cwd) {
4325
+ for (const dir of COMPOSE_DIR_CANDIDATES) {
4326
+ const absDir = path.resolve(cwd, dir);
4327
+ const files = COMPOSE_FILE_CANDIDATES.filter((name) => existsSync(path.join(absDir, name)));
4328
+ if (files.length > 0) return {
4329
+ dir: absDir,
4330
+ files
4331
+ };
4332
+ }
4333
+ return {
4334
+ dir: cwd,
4335
+ files: []
4336
+ };
4337
+ }
4338
+ /** Parse a single port mapping string and extract the host port. */
4339
+ function parsePortString(port) {
4340
+ const withoutProtocol = port.split("/")[0];
4341
+ if (!withoutProtocol) return void 0;
4342
+ const parts = withoutProtocol.split(":");
4343
+ if (parts.length === 2) {
4344
+ const host = parts[0];
4345
+ if (!host) return void 0;
4346
+ const parsed = Number.parseInt(host, 10);
4347
+ return Number.isNaN(parsed) ? void 0 : parsed;
4348
+ }
4349
+ if (parts.length === 3) {
4350
+ const host = parts[1];
4351
+ if (!host) return void 0;
4352
+ const parsed = Number.parseInt(host, 10);
4353
+ return Number.isNaN(parsed) ? void 0 : parsed;
4354
+ }
4355
+ }
4356
+ /** Extract the host port from a compose port definition. */
4357
+ function extractHostPort(port) {
4358
+ if (typeof port === "string") return parsePortString(port);
4359
+ const published = port.published;
4360
+ if (typeof published === "number") return published;
4361
+ if (typeof published === "string") {
4362
+ const parsed = Number.parseInt(published, 10);
4363
+ return Number.isNaN(parsed) ? void 0 : parsed;
4364
+ }
4365
+ }
4366
+ /** Parse compose files and return service info (names, ports, healthcheck presence). */
4367
+ function parseComposeServices(cwd, composeFiles) {
4368
+ const serviceMap = /* @__PURE__ */ new Map();
4369
+ for (const file of composeFiles) {
4370
+ let parsed;
4371
+ try {
4372
+ const content = readFileSync(path.join(cwd, file), "utf-8");
4373
+ parsed = ComposeFileSchema.safeParse(parse$1(content));
4374
+ } catch (_error) {
4375
+ continue;
4376
+ }
4377
+ if (!parsed.success || !parsed.data.services) continue;
4378
+ for (const [name, service] of Object.entries(parsed.data.services)) {
4379
+ const existing = serviceMap.get(name);
4380
+ let hostPort = existing?.hostPort;
4381
+ if (hostPort === void 0 && service.ports) for (const port of service.ports) {
4382
+ const extracted = extractHostPort(port);
4383
+ if (extracted !== void 0) {
4384
+ hostPort = extracted;
4385
+ break;
4386
+ }
4387
+ }
4388
+ serviceMap.set(name, {
4389
+ name,
4390
+ hostPort,
4391
+ hasHealthcheck: existing?.hasHealthcheck ?? service.healthcheck !== void 0
4392
+ });
4393
+ }
4394
+ }
4395
+ return [...serviceMap.values()];
4396
+ }
4397
+ /** Generate health checks from parsed services: services with exposed ports get HTTP checks, unless they define a compose-level healthcheck. */
4398
+ function deriveHealthChecks(services) {
4399
+ return services.filter((s) => s.hostPort !== void 0 && !s.hasHealthcheck).map((s) => ({
4400
+ name: s.name,
4401
+ url: `http://localhost:${String(s.hostPort)}/`
4402
+ }));
4403
+ }
4404
+ /** Check overlay file name patterns, matched against the base compose file name. */
4405
+ const CHECK_OVERLAY_PATTERNS = [(base) => base.replace(/\.(yaml|yml)$/, `.check.$1`)];
4406
+ /** Detect a user-provided check overlay file alongside the base compose file. */
4407
+ function detectCheckOverlay(dir, baseFile) {
4408
+ for (const pattern of CHECK_OVERLAY_PATTERNS) {
4409
+ const candidate = pattern(baseFile);
4410
+ if (existsSync(path.join(dir, candidate))) return candidate;
4411
+ }
4412
+ }
4413
+ /** Detect a check env file in the compose directory. */
4414
+ function detectCheckEnvFile(dir) {
4415
+ if (existsSync(path.join(dir, "docker-compose.check.env"))) return "docker-compose.check.env";
4416
+ }
4417
+ /** Fast healthcheck intervals for the generated check overlay. */
4418
+ const CHECK_HEALTHCHECK = {
4419
+ interval: "5s",
4420
+ timeout: "5s",
4421
+ retries: 12,
4422
+ start_period: "10s",
4423
+ start_interval: "5s"
4424
+ };
4425
+ /**
4426
+ * Generate a check overlay YAML string from parsed services.
4427
+ * Sets `restart: "no"` on all services, and overrides healthcheck intervals
4428
+ * for services that define a healthcheck in the base compose file.
4429
+ */
4430
+ function generateCheckOverlay(services) {
4431
+ const serviceOverrides = {};
4432
+ for (const service of services) {
4433
+ const override = { restart: "no" };
4434
+ if (service.hasHealthcheck) override["healthcheck"] = { ...CHECK_HEALTHCHECK };
4435
+ serviceOverrides[service.name] = override;
4436
+ }
4437
+ return stringify({ services: serviceOverrides });
4438
+ }
4439
+ /** Auto-detect compose config from conventional file locations. */
4440
+ function computeCheckDefaults(cwd) {
4441
+ const { dir, files } = detectComposeFiles(cwd);
4442
+ if (files.length === 0) return {};
4443
+ const baseFile = files[0];
4444
+ const checkOverlay = baseFile ? detectCheckOverlay(dir, baseFile) : void 0;
4445
+ const envFile = detectCheckEnvFile(dir);
4446
+ const services = parseComposeServices(dir, files);
4447
+ const healthChecks = deriveHealthChecks(services);
4448
+ return {
4449
+ composeCwd: dir !== cwd ? dir : void 0,
4450
+ composeFiles: files,
4451
+ checkOverlay,
4452
+ envFile,
4453
+ services: services.map((s) => s.name),
4454
+ healthChecks: healthChecks.length > 0 ? healthChecks : void 0
4455
+ };
4456
+ }
4457
+ //#endregion
4458
+ //#region src/commands/docker-check.ts
4459
+ /** Convert declarative health checks to functional ones. */
4460
+ function toHttpHealthChecks(checks) {
4461
+ return checks.map((check) => ({
4462
+ name: check.name,
4463
+ url: check.url,
4464
+ validate: async (res) => check.status ? res.status === check.status : res.ok
4465
+ }));
4466
+ }
4467
+ /** Write the generated check overlay to a temp file. Returns the absolute path. */
4468
+ function writeTempOverlay(content) {
4469
+ const name = `tooling-check-overlay-${process.pid}.yaml`;
4470
+ const filePath = path.join(tmpdir(), name);
4471
+ writeFileSync(filePath, content, "utf-8");
4472
+ return filePath;
4473
+ }
4474
+ function log(message) {
4475
+ console.log(message);
4476
+ }
4477
+ function warn(message) {
4478
+ console.warn(message);
4479
+ }
4480
+ const dockerCheckCommand = defineCommand({
4481
+ meta: {
4482
+ name: "docker:check",
4483
+ description: "Check Docker Compose stack health by auto-detecting services from compose files"
4484
+ },
4485
+ args: {
4486
+ timeout: {
4487
+ type: "string",
4488
+ description: "Maximum time to wait for health checks, in ms (default: 120000)"
4489
+ },
4490
+ "poll-interval": {
4491
+ type: "string",
4492
+ description: "Interval between polling attempts, in ms (default: 5000)"
4493
+ }
4494
+ },
4495
+ async run({ args }) {
4496
+ const cwd = process.cwd();
4497
+ if (loadToolingConfig(cwd)?.dockerCheck === false) {
4498
+ log("Docker check is disabled in .tooling.json");
4499
+ return;
4500
+ }
4501
+ const defaults = computeCheckDefaults(cwd);
4502
+ if (!defaults.composeFiles || defaults.composeFiles.length === 0) throw new FatalError("No compose files found. Expected docker-compose.yaml or compose.yaml in ./ or docker/.");
4503
+ if (!defaults.checkOverlay) {
4504
+ const composeCwd = defaults.composeCwd ?? cwd;
4505
+ const expectedOverlay = (defaults.composeFiles[0] ?? "docker-compose.yaml").replace(/\.(yaml|yml)$/, ".check.$1");
4506
+ warn(`Compose files found but no check overlay. Create ${path.relative(cwd, path.join(composeCwd, expectedOverlay))} to enable docker:check.`);
4507
+ warn("To suppress this warning, set \"dockerCheck\": false in .tooling.json.");
4508
+ return;
4509
+ }
4510
+ if (!defaults.services || defaults.services.length === 0) throw new FatalError("No services found in compose files.");
4511
+ const composeCwd = defaults.composeCwd ?? cwd;
4512
+ const tempOverlayPath = writeTempOverlay(generateCheckOverlay(parseComposeServices(composeCwd, defaults.composeFiles)));
4513
+ const composeFiles = [
4514
+ ...defaults.composeFiles,
4515
+ tempOverlayPath,
4516
+ defaults.checkOverlay
4517
+ ];
4518
+ try {
4519
+ const config = {
4520
+ compose: {
4521
+ cwd: composeCwd,
4522
+ composeFiles,
4523
+ envFile: defaults.envFile,
4524
+ services: defaults.services
4525
+ },
4526
+ buildCommand: defaults.buildCommand,
4527
+ buildCwd: defaults.buildCwd,
4528
+ healthChecks: defaults.healthChecks ? toHttpHealthChecks(defaults.healthChecks) : [],
4529
+ timeoutMs: args.timeout ? Number.parseInt(args.timeout, 10) : defaults.timeoutMs,
4530
+ pollIntervalMs: args["poll-interval"] ? Number.parseInt(args["poll-interval"], 10) : defaults.pollIntervalMs
4531
+ };
4532
+ const result = await runDockerCheck(createRealExecutor$1(), config);
4533
+ if (!result.success) throw new FatalError(`Check failed (${result.reason}): ${result.message}`);
4534
+ } finally {
4535
+ try {
4536
+ unlinkSync(tempOverlayPath);
4537
+ } catch (_error) {}
4538
+ }
4539
+ }
4540
+ });
4541
+ //#endregion
4260
4542
  //#region src/bin.ts
4261
4543
  const main = defineCommand({
4262
4544
  meta: {
4263
4545
  name: "tooling",
4264
- version: "0.17.0",
4546
+ version: "0.19.0",
4265
4547
  description: "Bootstrap and maintain standardized TypeScript project tooling"
4266
4548
  },
4267
4549
  subCommands: {
@@ -4273,10 +4555,11 @@ const main = defineCommand({
4273
4555
  "changesets:merge": releaseMergeCommand,
4274
4556
  "release:simple": releaseSimpleCommand,
4275
4557
  "docker:publish": publishDockerCommand,
4276
- "docker:build": dockerBuildCommand
4558
+ "docker:build": dockerBuildCommand,
4559
+ "docker:check": dockerCheckCommand
4277
4560
  }
4278
4561
  });
4279
- console.log(`@bensandee/tooling v0.17.0`);
4562
+ console.log(`@bensandee/tooling v0.19.0`);
4280
4563
  runMain(main);
4281
4564
  //#endregion
4282
4565
  export {};
@@ -1,7 +1,12 @@
1
- import { t as isExecSyncError } from "../exec-CC49vrkM.mjs";
2
1
  import { z } from "zod";
3
2
  import { execSync } from "node:child_process";
4
- //#region src/docker-verify/executor.ts
3
+ //#region src/utils/exec.ts
4
+ /** Type guard for `execSync` errors that carry stdout/stderr/status. */
5
+ function isExecSyncError(err) {
6
+ return err instanceof Error && "stdout" in err && typeof err.stdout === "string" && "stderr" in err && typeof err.stderr === "string" && "status" in err && typeof err.status === "number";
7
+ }
8
+ //#endregion
9
+ //#region src/docker-check/executor.ts
5
10
  /** Create an executor that runs real commands, fetches, and manages process signals. */
6
11
  function createRealExecutor() {
7
12
  return {
@@ -61,7 +66,7 @@ function createRealExecutor() {
61
66
  };
62
67
  }
63
68
  //#endregion
64
- //#region src/docker-verify/compose.ts
69
+ //#region src/docker-check/compose.ts
65
70
  /** Zod schema for a single container entry from `docker compose ps --format json`. */
66
71
  const ContainerInfoSchema = z.object({
67
72
  Service: z.string(),
@@ -116,7 +121,7 @@ function composePs(executor, config) {
116
121
  }
117
122
  }
118
123
  //#endregion
119
- //#region src/docker-verify/health.ts
124
+ //#region src/docker-check/health.ts
120
125
  /** Look up the health status of a specific service from container info. */
121
126
  function getContainerHealth(containers, serviceName) {
122
127
  return containers.find((c) => c.Service === serviceName)?.Health ?? "unknown";
@@ -131,11 +136,11 @@ async function checkHttpHealth(executor, check) {
131
136
  }
132
137
  }
133
138
  //#endregion
134
- //#region src/docker-verify/verify.ts
139
+ //#region src/docker-check/check.ts
135
140
  const DEFAULT_TIMEOUT_MS = 12e4;
136
141
  const DEFAULT_POLL_INTERVAL_MS = 5e3;
137
- /** Run the full Docker image verification lifecycle. */
138
- async function runVerification(executor, config) {
142
+ /** Run the full Docker check lifecycle. */
143
+ async function runDockerCheck(executor, config) {
139
144
  const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
140
145
  const pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
141
146
  const { compose } = config;
@@ -178,7 +183,7 @@ async function runVerification(executor, config) {
178
183
  }
179
184
  }
180
185
  if ([...healthStatus.values()].every(Boolean)) {
181
- executor.log("Verification successful! All systems operational.");
186
+ executor.log("Check successful! All systems operational.");
182
187
  cleanup();
183
188
  return {
184
189
  success: true,
@@ -215,4 +220,4 @@ async function runVerification(executor, config) {
215
220
  }
216
221
  }
217
222
  //#endregion
218
- export { checkHttpHealth, composeCommand, composeDown, composeLogs, composePs, composeUp, createRealExecutor, getContainerHealth, runVerification };
223
+ export { composeDown as a, composeUp as c, composeCommand as i, createRealExecutor as l, checkHttpHealth as n, composeLogs as o, getContainerHealth as r, composePs as s, runDockerCheck as t, isExecSyncError as u };
@@ -13,9 +13,9 @@ interface ExecOptions {
13
13
  env?: Record<string, string>;
14
14
  }
15
15
  //#endregion
16
- //#region src/docker-verify/types.d.ts
16
+ //#region src/docker-check/types.d.ts
17
17
  /** Abstraction over side effects for testability. */
18
- interface DockerVerifyExecutor {
18
+ interface DockerCheckExecutor {
19
19
  /** Run a shell command and return the result (stdout/stderr captured). */
20
20
  exec(command: string, options?: ExecOptions): ExecResult;
21
21
  /** Run a shell command, streaming stdout/stderr to the console. */
@@ -46,15 +46,15 @@ interface HttpHealthCheck {
46
46
  interface ComposeConfig {
47
47
  /** Working directory for docker compose commands. */
48
48
  cwd: string;
49
- /** Compose files to use (e.g. ["docker-compose.yaml", "docker-compose.verify.yaml"]). */
49
+ /** Compose files to use (e.g. ["docker-compose.yaml", "docker-compose.check.yaml"]). */
50
50
  composeFiles: string[];
51
51
  /** Optional env file for compose. */
52
52
  envFile?: string;
53
53
  /** Service names to monitor for container-level health. */
54
54
  services: string[];
55
55
  }
56
- /** Full verification configuration. */
57
- interface VerifyConfig {
56
+ /** Full docker check configuration. */
57
+ interface CheckConfig {
58
58
  /** Docker compose settings. */
59
59
  compose: ComposeConfig;
60
60
  /** Optional build command to run before starting compose (e.g. "pnpm image:build"). */
@@ -68,8 +68,8 @@ interface VerifyConfig {
68
68
  /** Interval between polling attempts, in ms. Default: 5000. */
69
69
  pollIntervalMs?: number;
70
70
  }
71
- /** Result of the verification run. */
72
- type VerifyResult = {
71
+ /** Result of the docker check run. */
72
+ type CheckResult = {
73
73
  success: true;
74
74
  elapsedMs: number;
75
75
  } | {
@@ -79,15 +79,15 @@ type VerifyResult = {
79
79
  elapsedMs: number;
80
80
  };
81
81
  //#endregion
82
- //#region src/docker-verify/executor.d.ts
82
+ //#region src/docker-check/executor.d.ts
83
83
  /** Create an executor that runs real commands, fetches, and manages process signals. */
84
- declare function createRealExecutor(): DockerVerifyExecutor;
84
+ declare function createRealExecutor(): DockerCheckExecutor;
85
85
  //#endregion
86
- //#region src/docker-verify/verify.d.ts
87
- /** Run the full Docker image verification lifecycle. */
88
- declare function runVerification(executor: DockerVerifyExecutor, config: VerifyConfig): Promise<VerifyResult>;
86
+ //#region src/docker-check/check.d.ts
87
+ /** Run the full Docker check lifecycle. */
88
+ declare function runDockerCheck(executor: DockerCheckExecutor, config: CheckConfig): Promise<CheckResult>;
89
89
  //#endregion
90
- //#region src/docker-verify/compose.d.ts
90
+ //#region src/docker-check/compose.d.ts
91
91
  /** Zod schema for a single container entry from `docker compose ps --format json`. */
92
92
  declare const ContainerInfoSchema: z.ZodObject<{
93
93
  Service: z.ZodString;
@@ -97,21 +97,21 @@ type ContainerInfo = z.infer<typeof ContainerInfoSchema>;
97
97
  /** Build the `docker compose` base command string from config. */
98
98
  declare function composeCommand(config: ComposeConfig): string;
99
99
  /** Start the compose stack in detached mode. */
100
- declare function composeUp(executor: DockerVerifyExecutor, config: ComposeConfig): void;
100
+ declare function composeUp(executor: DockerCheckExecutor, config: ComposeConfig): void;
101
101
  /** Tear down the compose stack, removing volumes and orphans. Swallows errors. */
102
- declare function composeDown(executor: DockerVerifyExecutor, config: ComposeConfig): void;
102
+ declare function composeDown(executor: DockerCheckExecutor, config: ComposeConfig): void;
103
103
  /** Show logs for a specific service (or all services if not specified). Swallows errors. */
104
- declare function composeLogs(executor: DockerVerifyExecutor, config: ComposeConfig, service?: string): void;
104
+ declare function composeLogs(executor: DockerCheckExecutor, config: ComposeConfig, service?: string): void;
105
105
  /**
106
106
  * Query container status via `docker compose ps --format json`.
107
107
  * Handles both JSON array and newline-delimited JSON (varies by docker compose version).
108
108
  */
109
- declare function composePs(executor: DockerVerifyExecutor, config: ComposeConfig): ContainerInfo[];
109
+ declare function composePs(executor: DockerCheckExecutor, config: ComposeConfig): ContainerInfo[];
110
110
  //#endregion
111
- //#region src/docker-verify/health.d.ts
111
+ //#region src/docker-check/health.d.ts
112
112
  /** Look up the health status of a specific service from container info. */
113
113
  declare function getContainerHealth(containers: ContainerInfo[], serviceName: string): string;
114
114
  /** Run a single HTTP health check, returning true if the validator passes. */
115
- declare function checkHttpHealth(executor: DockerVerifyExecutor, check: HttpHealthCheck): Promise<boolean>;
115
+ declare function checkHttpHealth(executor: DockerCheckExecutor, check: HttpHealthCheck): Promise<boolean>;
116
116
  //#endregion
117
- export { type ComposeConfig, type ContainerInfo, type DockerVerifyExecutor, type HttpHealthCheck, type VerifyConfig, type VerifyResult, checkHttpHealth, composeCommand, composeDown, composeLogs, composePs, composeUp, createRealExecutor, getContainerHealth, runVerification };
117
+ export { type CheckConfig, type CheckResult, type ComposeConfig, type ContainerInfo, type DockerCheckExecutor, type HttpHealthCheck, checkHttpHealth, composeCommand, composeDown, composeLogs, composePs, composeUp, createRealExecutor, getContainerHealth, runDockerCheck };
@@ -0,0 +1,2 @@
1
+ import { a as composeDown, c as composeUp, i as composeCommand, l as createRealExecutor, n as checkHttpHealth, o as composeLogs, r as getContainerHealth, s as composePs, t as runDockerCheck } from "../check-VAgrEX2D.mjs";
2
+ export { checkHttpHealth, composeCommand, composeDown, composeLogs, composePs, composeUp, createRealExecutor, getContainerHealth, runDockerCheck };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "bin": {
6
6
  "tooling": "./dist/bin.mjs"
@@ -18,9 +18,9 @@
18
18
  "default": "./dist/index.mjs"
19
19
  },
20
20
  "./bin": "./dist/bin.mjs",
21
- "./docker-verify": {
22
- "types": "./dist/docker-verify/index.d.mts",
23
- "default": "./dist/docker-verify/index.mjs"
21
+ "./docker-check": {
22
+ "types": "./dist/docker-check/index.d.mts",
23
+ "default": "./dist/docker-check/index.mjs"
24
24
  },
25
25
  "./package.json": "./package.json"
26
26
  },
@@ -1,7 +0,0 @@
1
- //#region src/utils/exec.ts
2
- /** Type guard for `execSync` errors that carry stdout/stderr/status. */
3
- function isExecSyncError(err) {
4
- return err instanceof Error && "stdout" in err && typeof err.stdout === "string" && "stderr" in err && typeof err.stderr === "string" && "status" in err && typeof err.status === "number";
5
- }
6
- //#endregion
7
- export { isExecSyncError as t };