@bensandee/tooling 0.50.1 → 0.51.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
@@ -126,10 +126,11 @@ The generated release workflow exposes these as optional `workflow_dispatch` inp
126
126
 
127
127
  ### Docker
128
128
 
129
- | Command | Description |
130
- | ------------------------ | --------------------------------------------------- |
131
- | `tooling docker:build` | Build Docker images for discovered Docker packages. |
132
- | `tooling docker:publish` | Build, tag, and push Docker images to a registry. |
129
+ | Command | Description |
130
+ | ------------------------ | ------------------------------------------------------------------- |
131
+ | `tooling docker:build` | Build Docker images for discovered Docker packages. |
132
+ | `tooling docker:publish` | Build, tag, and push Docker images to a registry. |
133
+ | `tooling docker:check` | Start a Compose stack, run health checks, run smoketest, tear down. |
133
134
 
134
135
  Docker packages are discovered automatically. Any package with a `Dockerfile` or `docker/Dockerfile` is a Docker package. Image names are derived as `{root-package-name}-{package-name}`, build context defaults to `.` (project root). For single-package repos, `Dockerfile` or `docker/Dockerfile` at the project root is checked.
135
136
 
@@ -255,9 +256,32 @@ import type {
255
256
 
256
257
  ## Docker check
257
258
 
258
- The `@bensandee/tooling/docker-check` export provides utilities for checking Docker Compose stacks via health checks.
259
+ `docker:check` verifies that a Docker Compose stack starts correctly, passes health checks, and (optionally) survives a smoketest command. The lifecycle is: build images, compose up, poll health checks, run smoketest, compose down.
259
260
 
260
- ### Quick start
261
+ ### Smoketests
262
+
263
+ A smoketest is a command that runs against the live Docker stack after all containers are healthy and HTTP health checks pass. This is useful for running integration tests, API contract checks, or any validation that requires the full stack to be running.
264
+
265
+ **Auto-detection:** If the root `package.json` has a `"test:smoke"` script, it is automatically used as the smoketest command — no configuration needed.
266
+
267
+ **Manual configuration:** For non-conventional command names, configure via `.tooling.json`:
268
+
269
+ ```json
270
+ {
271
+ "dockerCheck": {
272
+ "smoketest": ["pnpm", "test:integration"],
273
+ "smoketestCwd": "."
274
+ }
275
+ }
276
+ ```
277
+
278
+ If the smoketest command exits non-zero, `docker:check` dumps container logs and fails with reason `"smoketest-failed"`.
279
+
280
+ ### Library API
281
+
282
+ The `@bensandee/tooling/docker-check` export provides utilities for checking Docker Compose stacks programmatically.
283
+
284
+ #### Quick start
261
285
 
262
286
  ```ts
263
287
  import { createRealExecutor, runDockerCheck } from "@bensandee/tooling/docker-check";
@@ -269,7 +293,7 @@ const config: CheckConfig = {
269
293
  composeFiles: ["docker-compose.yaml"],
270
294
  services: ["api", "db"],
271
295
  },
272
- buildCommand: "pnpm image:build",
296
+ buildCommand: ["pnpm", "image:build"],
273
297
  healthChecks: [
274
298
  {
275
299
  name: "API",
@@ -277,6 +301,7 @@ const config: CheckConfig = {
277
301
  validate: async (res) => res.ok,
278
302
  },
279
303
  ],
304
+ smoketest: ["pnpm", "test:smoke"],
280
305
  timeoutMs: 120_000,
281
306
  pollIntervalMs: 5_000,
282
307
  };
@@ -287,24 +312,24 @@ if (!result.success) {
287
312
  }
288
313
  ```
289
314
 
290
- ### Exports
315
+ #### Exports
291
316
 
292
- | Export | Description |
293
- | -------------------------------------- | ----------------------------------------------------------------- |
294
- | `runDockerCheck(executor, config)` | Full lifecycle: build, compose up, health check polling, teardown |
295
- | `createRealExecutor()` | Production executor (real shell, fetch, timers) |
296
- | `composeUp(executor, config)` | Start compose services |
297
- | `composeDown(executor, config)` | Stop and remove compose services |
298
- | `composeLogs(executor, config)` | Stream compose logs |
299
- | `composePs(executor, config)` | List running containers |
300
- | `checkHttpHealth(executor, check)` | Run a single HTTP health check |
301
- | `getContainerHealth(executor, config)` | Check container-level health status |
317
+ | Export | Description |
318
+ | -------------------------------------- | ---------------------------------------------------------------------------- |
319
+ | `runDockerCheck(executor, config)` | Full lifecycle: build, compose up, health check polling, smoketest, teardown |
320
+ | `createRealExecutor()` | Production executor (real shell, fetch, timers) |
321
+ | `composeUp(executor, config)` | Start compose services |
322
+ | `composeDown(executor, config)` | Stop and remove compose services |
323
+ | `composeLogs(executor, config)` | Stream compose logs |
324
+ | `composePs(executor, config)` | List running containers |
325
+ | `checkHttpHealth(executor, check)` | Run a single HTTP health check |
326
+ | `getContainerHealth(executor, config)` | Check container-level health status |
302
327
 
303
- ### Types
328
+ #### Types
304
329
 
305
330
  | Type | Description |
306
331
  | --------------------- | ------------------------------------------------------------------------------------------ |
307
- | `CheckConfig` | Full check config (compose settings, build command, health checks, timeouts) |
332
+ | `CheckConfig` | Full check config (compose settings, build command, health checks, smoketest, timeouts) |
308
333
  | `ComposeConfig` | Docker Compose settings (cwd, compose files, env file, services) |
309
334
  | `HttpHealthCheck` | Health check definition (name, URL, validate function) |
310
335
  | `CheckResult` | Result: `{ success: true, elapsedMs }` or `{ success: false, reason, message, elapsedMs }` |
package/dist/bin.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { d as debug, f as debugExec, g as note, h as log, l as createRealExecutor$1, m as redact, p as isEnvVerbose, t as runDockerCheck, u as isExecSyncError } from "./check-hutCTIXe.mjs";
2
+ import { d as debug, f as debugExec, g as note, h as log, l as createRealExecutor$1, m as redact, p as isEnvVerbose, t as runDockerCheck, u as isExecSyncError } from "./check-COzp4S_Q.mjs";
3
3
  import { defineCommand, runMain } from "citty";
4
4
  import * as p from "@clack/prompts";
5
5
  import { isCancel, select } from "@clack/prompts";
@@ -590,6 +590,8 @@ const DockerCheckConfigSchema = z.object({
590
590
  healthChecks: z.array(DeclarativeHealthCheckSchema).optional().meta({ description: "Health check definitions" }),
591
591
  buildCommand: z.array(z.string()).optional().meta({ description: "Command to build images before checking (e.g. [\"pnpm\", \"image:build\"])" }),
592
592
  buildCwd: z.string().optional().meta({ description: "Working directory for build command" }),
593
+ smoketest: z.array(z.string()).optional().meta({ description: "Command to run as a smoketest after health checks pass (e.g. [\"pnpm\", \"test:smoke\"])" }),
594
+ smoketestCwd: z.string().optional().meta({ description: "Working directory for smoketest command" }),
593
595
  timeoutMs: z.number().int().positive().optional().meta({ description: "Overall timeout in milliseconds" }),
594
596
  pollIntervalMs: z.number().int().positive().optional().meta({ description: "Poll interval in milliseconds" })
595
597
  });
@@ -663,13 +665,22 @@ const MONOREPO_IGNORED_KEYS = new Set(["setupVitest", "projectType"]);
663
665
  function saveToolingConfig(ctx, config) {
664
666
  const defaults = computeDefaults(config.targetDir);
665
667
  const isMonorepo = config.structure === "monorepo";
668
+ const existing = ctx.exists(CONFIG_FILE) ? ctx.read(CONFIG_FILE) : void 0;
669
+ const overrideKeySet = new Set(OVERRIDE_KEYS);
670
+ const preserved = {};
671
+ if (existing) {
672
+ const result = ToolingConfigSchema.safeParse(JSON.parse(existing));
673
+ if (result.success) {
674
+ for (const [key, value] of Object.entries(result.data)) if (key !== "$schema" && !overrideKeySet.has(key)) preserved[key] = value;
675
+ }
676
+ }
666
677
  const overrides = { $schema: "node_modules/@bensandee/tooling/tooling.schema.json" };
667
678
  for (const key of OVERRIDE_KEYS) {
668
679
  if (isMonorepo && MONOREPO_IGNORED_KEYS.has(key)) continue;
669
680
  if (config[key] !== defaults[key]) overrides[key] = config[key];
670
681
  }
682
+ Object.assign(overrides, preserved);
671
683
  const content = JSON.stringify(overrides, null, 2) + "\n";
672
- const existing = ctx.exists(CONFIG_FILE) ? ctx.read(CONFIG_FILE) : void 0;
673
684
  if (existing !== void 0 && contentEqual(CONFIG_FILE, existing, content)) return {
674
685
  filePath: CONFIG_FILE,
675
686
  action: "skipped",
@@ -1360,15 +1371,15 @@ const PER_PACKAGE_DEV_DEPS = {
1360
1371
  "@release-it/bumper": "7.0.5",
1361
1372
  "@types/node": "24.12.0",
1362
1373
  "commit-and-tag-version": "12.7.1",
1363
- "knip": "6.3.0",
1364
- "lefthook": "2.1.4",
1365
- "oxfmt": "0.43.0",
1366
- "oxlint": "1.58.0",
1367
- "prettier": "3.8.1",
1374
+ "knip": "6.3.1",
1375
+ "lefthook": "2.1.5",
1376
+ "oxfmt": "0.44.0",
1377
+ "oxlint": "1.59.0",
1378
+ "prettier": "3.8.2",
1368
1379
  "release-it": "19.2.4",
1369
1380
  "tsdown": "0.21.7",
1370
1381
  "typescript": "6.0.2",
1371
- "vitest": "4.1.2",
1382
+ "vitest": "4.1.4",
1372
1383
  "pnpm": "10.33.0"
1373
1384
  }["@types/node"],
1374
1385
  tsdown: {
@@ -1376,15 +1387,15 @@ const PER_PACKAGE_DEV_DEPS = {
1376
1387
  "@release-it/bumper": "7.0.5",
1377
1388
  "@types/node": "24.12.0",
1378
1389
  "commit-and-tag-version": "12.7.1",
1379
- "knip": "6.3.0",
1380
- "lefthook": "2.1.4",
1381
- "oxfmt": "0.43.0",
1382
- "oxlint": "1.58.0",
1383
- "prettier": "3.8.1",
1390
+ "knip": "6.3.1",
1391
+ "lefthook": "2.1.5",
1392
+ "oxfmt": "0.44.0",
1393
+ "oxlint": "1.59.0",
1394
+ "prettier": "3.8.2",
1384
1395
  "release-it": "19.2.4",
1385
1396
  "tsdown": "0.21.7",
1386
1397
  "typescript": "6.0.2",
1387
- "vitest": "4.1.2",
1398
+ "vitest": "4.1.4",
1388
1399
  "pnpm": "10.33.0"
1389
1400
  }["tsdown"],
1390
1401
  typescript: {
@@ -1392,15 +1403,15 @@ const PER_PACKAGE_DEV_DEPS = {
1392
1403
  "@release-it/bumper": "7.0.5",
1393
1404
  "@types/node": "24.12.0",
1394
1405
  "commit-and-tag-version": "12.7.1",
1395
- "knip": "6.3.0",
1396
- "lefthook": "2.1.4",
1397
- "oxfmt": "0.43.0",
1398
- "oxlint": "1.58.0",
1399
- "prettier": "3.8.1",
1406
+ "knip": "6.3.1",
1407
+ "lefthook": "2.1.5",
1408
+ "oxfmt": "0.44.0",
1409
+ "oxlint": "1.59.0",
1410
+ "prettier": "3.8.2",
1400
1411
  "release-it": "19.2.4",
1401
1412
  "tsdown": "0.21.7",
1402
1413
  "typescript": "6.0.2",
1403
- "vitest": "4.1.2",
1414
+ "vitest": "4.1.4",
1404
1415
  "pnpm": "10.33.0"
1405
1416
  }["typescript"],
1406
1417
  vitest: {
@@ -1408,15 +1419,15 @@ const PER_PACKAGE_DEV_DEPS = {
1408
1419
  "@release-it/bumper": "7.0.5",
1409
1420
  "@types/node": "24.12.0",
1410
1421
  "commit-and-tag-version": "12.7.1",
1411
- "knip": "6.3.0",
1412
- "lefthook": "2.1.4",
1413
- "oxfmt": "0.43.0",
1414
- "oxlint": "1.58.0",
1415
- "prettier": "3.8.1",
1422
+ "knip": "6.3.1",
1423
+ "lefthook": "2.1.5",
1424
+ "oxfmt": "0.44.0",
1425
+ "oxlint": "1.59.0",
1426
+ "prettier": "3.8.2",
1416
1427
  "release-it": "19.2.4",
1417
1428
  "tsdown": "0.21.7",
1418
1429
  "typescript": "6.0.2",
1419
- "vitest": "4.1.2",
1430
+ "vitest": "4.1.4",
1420
1431
  "pnpm": "10.33.0"
1421
1432
  }["vitest"]
1422
1433
  };
@@ -1427,15 +1438,15 @@ const ROOT_DEV_DEPS = {
1427
1438
  "@release-it/bumper": "7.0.5",
1428
1439
  "@types/node": "24.12.0",
1429
1440
  "commit-and-tag-version": "12.7.1",
1430
- "knip": "6.3.0",
1431
- "lefthook": "2.1.4",
1432
- "oxfmt": "0.43.0",
1433
- "oxlint": "1.58.0",
1434
- "prettier": "3.8.1",
1441
+ "knip": "6.3.1",
1442
+ "lefthook": "2.1.5",
1443
+ "oxfmt": "0.44.0",
1444
+ "oxlint": "1.59.0",
1445
+ "prettier": "3.8.2",
1435
1446
  "release-it": "19.2.4",
1436
1447
  "tsdown": "0.21.7",
1437
1448
  "typescript": "6.0.2",
1438
- "vitest": "4.1.2",
1449
+ "vitest": "4.1.4",
1439
1450
  "pnpm": "10.33.0"
1440
1451
  }["knip"],
1441
1452
  lefthook: {
@@ -1443,15 +1454,15 @@ const ROOT_DEV_DEPS = {
1443
1454
  "@release-it/bumper": "7.0.5",
1444
1455
  "@types/node": "24.12.0",
1445
1456
  "commit-and-tag-version": "12.7.1",
1446
- "knip": "6.3.0",
1447
- "lefthook": "2.1.4",
1448
- "oxfmt": "0.43.0",
1449
- "oxlint": "1.58.0",
1450
- "prettier": "3.8.1",
1457
+ "knip": "6.3.1",
1458
+ "lefthook": "2.1.5",
1459
+ "oxfmt": "0.44.0",
1460
+ "oxlint": "1.59.0",
1461
+ "prettier": "3.8.2",
1451
1462
  "release-it": "19.2.4",
1452
1463
  "tsdown": "0.21.7",
1453
1464
  "typescript": "6.0.2",
1454
- "vitest": "4.1.2",
1465
+ "vitest": "4.1.4",
1455
1466
  "pnpm": "10.33.0"
1456
1467
  }["lefthook"],
1457
1468
  oxlint: {
@@ -1459,15 +1470,15 @@ const ROOT_DEV_DEPS = {
1459
1470
  "@release-it/bumper": "7.0.5",
1460
1471
  "@types/node": "24.12.0",
1461
1472
  "commit-and-tag-version": "12.7.1",
1462
- "knip": "6.3.0",
1463
- "lefthook": "2.1.4",
1464
- "oxfmt": "0.43.0",
1465
- "oxlint": "1.58.0",
1466
- "prettier": "3.8.1",
1473
+ "knip": "6.3.1",
1474
+ "lefthook": "2.1.5",
1475
+ "oxfmt": "0.44.0",
1476
+ "oxlint": "1.59.0",
1477
+ "prettier": "3.8.2",
1467
1478
  "release-it": "19.2.4",
1468
1479
  "tsdown": "0.21.7",
1469
1480
  "typescript": "6.0.2",
1470
- "vitest": "4.1.2",
1481
+ "vitest": "4.1.4",
1471
1482
  "pnpm": "10.33.0"
1472
1483
  }["oxlint"]
1473
1484
  };
@@ -1500,15 +1511,15 @@ function addReleaseDeps(deps, config) {
1500
1511
  "@release-it/bumper": "7.0.5",
1501
1512
  "@types/node": "24.12.0",
1502
1513
  "commit-and-tag-version": "12.7.1",
1503
- "knip": "6.3.0",
1504
- "lefthook": "2.1.4",
1505
- "oxfmt": "0.43.0",
1506
- "oxlint": "1.58.0",
1507
- "prettier": "3.8.1",
1514
+ "knip": "6.3.1",
1515
+ "lefthook": "2.1.5",
1516
+ "oxfmt": "0.44.0",
1517
+ "oxlint": "1.59.0",
1518
+ "prettier": "3.8.2",
1508
1519
  "release-it": "19.2.4",
1509
1520
  "tsdown": "0.21.7",
1510
1521
  "typescript": "6.0.2",
1511
- "vitest": "4.1.2",
1522
+ "vitest": "4.1.4",
1512
1523
  "pnpm": "10.33.0"
1513
1524
  }["release-it"];
1514
1525
  if (config.structure === "monorepo") deps["@release-it/bumper"] = {
@@ -1516,15 +1527,15 @@ function addReleaseDeps(deps, config) {
1516
1527
  "@release-it/bumper": "7.0.5",
1517
1528
  "@types/node": "24.12.0",
1518
1529
  "commit-and-tag-version": "12.7.1",
1519
- "knip": "6.3.0",
1520
- "lefthook": "2.1.4",
1521
- "oxfmt": "0.43.0",
1522
- "oxlint": "1.58.0",
1523
- "prettier": "3.8.1",
1530
+ "knip": "6.3.1",
1531
+ "lefthook": "2.1.5",
1532
+ "oxfmt": "0.44.0",
1533
+ "oxlint": "1.59.0",
1534
+ "prettier": "3.8.2",
1524
1535
  "release-it": "19.2.4",
1525
1536
  "tsdown": "0.21.7",
1526
1537
  "typescript": "6.0.2",
1527
- "vitest": "4.1.2",
1538
+ "vitest": "4.1.4",
1528
1539
  "pnpm": "10.33.0"
1529
1540
  }["@release-it/bumper"];
1530
1541
  break;
@@ -1534,15 +1545,15 @@ function addReleaseDeps(deps, config) {
1534
1545
  "@release-it/bumper": "7.0.5",
1535
1546
  "@types/node": "24.12.0",
1536
1547
  "commit-and-tag-version": "12.7.1",
1537
- "knip": "6.3.0",
1538
- "lefthook": "2.1.4",
1539
- "oxfmt": "0.43.0",
1540
- "oxlint": "1.58.0",
1541
- "prettier": "3.8.1",
1548
+ "knip": "6.3.1",
1549
+ "lefthook": "2.1.5",
1550
+ "oxfmt": "0.44.0",
1551
+ "oxlint": "1.59.0",
1552
+ "prettier": "3.8.2",
1542
1553
  "release-it": "19.2.4",
1543
1554
  "tsdown": "0.21.7",
1544
1555
  "typescript": "6.0.2",
1545
- "vitest": "4.1.2",
1556
+ "vitest": "4.1.4",
1546
1557
  "pnpm": "10.33.0"
1547
1558
  }["commit-and-tag-version"];
1548
1559
  break;
@@ -1552,15 +1563,15 @@ function addReleaseDeps(deps, config) {
1552
1563
  "@release-it/bumper": "7.0.5",
1553
1564
  "@types/node": "24.12.0",
1554
1565
  "commit-and-tag-version": "12.7.1",
1555
- "knip": "6.3.0",
1556
- "lefthook": "2.1.4",
1557
- "oxfmt": "0.43.0",
1558
- "oxlint": "1.58.0",
1559
- "prettier": "3.8.1",
1566
+ "knip": "6.3.1",
1567
+ "lefthook": "2.1.5",
1568
+ "oxfmt": "0.44.0",
1569
+ "oxlint": "1.59.0",
1570
+ "prettier": "3.8.2",
1560
1571
  "release-it": "19.2.4",
1561
1572
  "tsdown": "0.21.7",
1562
1573
  "typescript": "6.0.2",
1563
- "vitest": "4.1.2",
1574
+ "vitest": "4.1.4",
1564
1575
  "pnpm": "10.33.0"
1565
1576
  }["@changesets/cli"];
1566
1577
  break;
@@ -1585,22 +1596,22 @@ async function generatePackageJson(ctx) {
1585
1596
  const devDeps = { ...ROOT_DEV_DEPS };
1586
1597
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1587
1598
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.10.2";
1588
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.50.1";
1599
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.51.0";
1589
1600
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.12.0";
1590
1601
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
1591
1602
  "@changesets/cli": "2.30.0",
1592
1603
  "@release-it/bumper": "7.0.5",
1593
1604
  "@types/node": "24.12.0",
1594
1605
  "commit-and-tag-version": "12.7.1",
1595
- "knip": "6.3.0",
1596
- "lefthook": "2.1.4",
1597
- "oxfmt": "0.43.0",
1598
- "oxlint": "1.58.0",
1599
- "prettier": "3.8.1",
1606
+ "knip": "6.3.1",
1607
+ "lefthook": "2.1.5",
1608
+ "oxfmt": "0.44.0",
1609
+ "oxlint": "1.59.0",
1610
+ "prettier": "3.8.2",
1600
1611
  "release-it": "19.2.4",
1601
1612
  "tsdown": "0.21.7",
1602
1613
  "typescript": "6.0.2",
1603
- "vitest": "4.1.2",
1614
+ "vitest": "4.1.4",
1604
1615
  "pnpm": "10.33.0"
1605
1616
  }["oxfmt"];
1606
1617
  if (ctx.config.formatter === "prettier") devDeps["prettier"] = {
@@ -1608,15 +1619,15 @@ async function generatePackageJson(ctx) {
1608
1619
  "@release-it/bumper": "7.0.5",
1609
1620
  "@types/node": "24.12.0",
1610
1621
  "commit-and-tag-version": "12.7.1",
1611
- "knip": "6.3.0",
1612
- "lefthook": "2.1.4",
1613
- "oxfmt": "0.43.0",
1614
- "oxlint": "1.58.0",
1615
- "prettier": "3.8.1",
1622
+ "knip": "6.3.1",
1623
+ "lefthook": "2.1.5",
1624
+ "oxfmt": "0.44.0",
1625
+ "oxlint": "1.59.0",
1626
+ "prettier": "3.8.2",
1616
1627
  "release-it": "19.2.4",
1617
1628
  "tsdown": "0.21.7",
1618
1629
  "typescript": "6.0.2",
1619
- "vitest": "4.1.2",
1630
+ "vitest": "4.1.4",
1620
1631
  "pnpm": "10.33.0"
1621
1632
  }["prettier"];
1622
1633
  addReleaseDeps(devDeps, ctx.config);
@@ -1702,15 +1713,15 @@ async function generatePackageJson(ctx) {
1702
1713
  "@release-it/bumper": "7.0.5",
1703
1714
  "@types/node": "24.12.0",
1704
1715
  "commit-and-tag-version": "12.7.1",
1705
- "knip": "6.3.0",
1706
- "lefthook": "2.1.4",
1707
- "oxfmt": "0.43.0",
1708
- "oxlint": "1.58.0",
1709
- "prettier": "3.8.1",
1716
+ "knip": "6.3.1",
1717
+ "lefthook": "2.1.5",
1718
+ "oxfmt": "0.44.0",
1719
+ "oxlint": "1.59.0",
1720
+ "prettier": "3.8.2",
1710
1721
  "release-it": "19.2.4",
1711
1722
  "tsdown": "0.21.7",
1712
1723
  "typescript": "6.0.2",
1713
- "vitest": "4.1.2",
1724
+ "vitest": "4.1.4",
1714
1725
  "pnpm": "10.33.0"
1715
1726
  }["pnpm"]}`
1716
1727
  };
@@ -2159,7 +2170,7 @@ const REQUIRED_ENTRIES = [
2159
2170
  const OPTIONAL_ENTRIES = [
2160
2171
  ".tooling-migrate.md",
2161
2172
  ".tooling-archived/",
2162
- ".claude/hooks/bst-hook.audit.local.jsonl"
2173
+ ".claude/hooks/bst-hook.audit.local.jsonl*"
2163
2174
  ];
2164
2175
  const ALL_ENTRIES = [...REQUIRED_ENTRIES, ...OPTIONAL_ENTRIES];
2165
2176
  /** Normalize a gitignore entry for comparison: strip leading `/` and trailing `/`. */
@@ -3359,7 +3370,7 @@ async function runGenerators(ctx) {
3359
3370
  function generateMigratePrompt(results, config, detected) {
3360
3371
  const sections = [];
3361
3372
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3362
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.50.1 repo:sync\` on ${timestamp}_\n`);
3373
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.51.0 repo:sync\` on ${timestamp}_\n`);
3363
3374
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
3364
3375
  if (changed.length > 0) {
3365
3376
  const items = changed.map((r) => {
@@ -5520,6 +5531,12 @@ function generateCheckOverlay(services) {
5520
5531
  }
5521
5532
  return stringify({ services: serviceOverrides });
5522
5533
  }
5534
+ /** Detect a smoketest command from a `test:smoke` script in the root package.json. */
5535
+ function detectSmoketest(cwd) {
5536
+ try {
5537
+ if (parsePackageJson(readFileSync(path.join(cwd, "package.json"), "utf-8"))?.scripts?.["test:smoke"]) return ["pnpm", "test:smoke"];
5538
+ } catch (_error) {}
5539
+ }
5523
5540
  /** Auto-detect compose config from conventional file locations. */
5524
5541
  function computeCheckDefaults(cwd) {
5525
5542
  const { dir, files } = detectComposeFiles(cwd);
@@ -5529,13 +5546,15 @@ function computeCheckDefaults(cwd) {
5529
5546
  const envFile = detectCheckEnvFile(dir);
5530
5547
  const services = parseComposeServices(dir, files);
5531
5548
  const healthChecks = deriveHealthChecks(services);
5549
+ const smoketest = detectSmoketest(cwd);
5532
5550
  return {
5533
5551
  composeCwd: dir !== cwd ? dir : void 0,
5534
5552
  composeFiles: files,
5535
5553
  checkOverlay,
5536
5554
  envFile,
5537
5555
  services: services.map((s) => s.name),
5538
- healthChecks: healthChecks.length > 0 ? healthChecks : void 0
5556
+ healthChecks: healthChecks.length > 0 ? healthChecks : void 0,
5557
+ smoketest
5539
5558
  };
5540
5559
  }
5541
5560
  /** Create a DockerFileReader backed by the real filesystem. */
@@ -5596,11 +5615,15 @@ const dockerCheckCommand = defineCommand({
5596
5615
  },
5597
5616
  async run({ args }) {
5598
5617
  const cwd = process.cwd();
5599
- if (loadToolingConfig(cwd)?.dockerCheck === false) {
5618
+ const toolingConfig = loadToolingConfig(cwd);
5619
+ if (toolingConfig?.dockerCheck === false) {
5600
5620
  log.info("Docker check is disabled in .tooling.json");
5601
5621
  return;
5602
5622
  }
5603
- const defaults = computeCheckDefaults(cwd);
5623
+ const defaults = {
5624
+ ...computeCheckDefaults(cwd),
5625
+ ...toolingConfig?.dockerCheck
5626
+ };
5604
5627
  if (!defaults.composeFiles || defaults.composeFiles.length === 0) throw new FatalError("No compose files found. Expected docker-compose.yaml or compose.yaml in ./ or docker/.");
5605
5628
  if (!defaults.checkOverlay) {
5606
5629
  const composeCwd = defaults.composeCwd ?? cwd;
@@ -5639,6 +5662,8 @@ const dockerCheckCommand = defineCommand({
5639
5662
  buildCommand: defaults.buildCommand,
5640
5663
  buildCwd: defaults.buildCwd,
5641
5664
  healthChecks: defaults.healthChecks ? toHttpHealthChecks(defaults.healthChecks) : [],
5665
+ smoketest: defaults.smoketest,
5666
+ smoketestCwd: defaults.smoketestCwd,
5642
5667
  timeoutMs: args.timeout ? Number.parseInt(args.timeout, 10) : defaults.timeoutMs,
5643
5668
  pollIntervalMs: args["poll-interval"] ? Number.parseInt(args["poll-interval"], 10) : defaults.pollIntervalMs,
5644
5669
  verbose: args.verbose === true || isEnvVerbose()
@@ -6450,7 +6475,7 @@ const webhookSendCommand = defineCommand({
6450
6475
  const main = defineCommand({
6451
6476
  meta: {
6452
6477
  name: "bst",
6453
- version: "0.50.1",
6478
+ version: "0.51.0",
6454
6479
  description: "Bootstrap and maintain standardized TypeScript project tooling"
6455
6480
  },
6456
6481
  subCommands: {
@@ -6471,7 +6496,7 @@ const main = defineCommand({
6471
6496
  "webhook:send": webhookSendCommand
6472
6497
  }
6473
6498
  });
6474
- console.log(`@bensandee/tooling v0.50.1`);
6499
+ console.log(`@bensandee/tooling v0.51.0`);
6475
6500
  async function run() {
6476
6501
  await runMain(main);
6477
6502
  process.exit(process.exitCode ?? 0);
@@ -128,6 +128,13 @@ function buildImages(executor, config) {
128
128
  if (!file) return;
129
129
  executor.execInherit(file, args, { cwd: config.buildCwd ?? config.compose.cwd });
130
130
  }
131
+ /** Run the smoketest command against the running stack. */
132
+ function runSmoketest(executor, config) {
133
+ if (!config.smoketest) return;
134
+ const [file, ...args] = config.smoketest;
135
+ if (!file) return;
136
+ executor.execInherit(file, args, { cwd: config.smoketestCwd ?? config.compose.cwd });
137
+ }
131
138
  /** Start the compose stack in detached mode. */
132
139
  function composeUp(executor, config) {
133
140
  executor.execInherit("docker", [
@@ -263,6 +270,24 @@ async function runDockerCheck(executor, config) {
263
270
  }
264
271
  }
265
272
  if (allContainersHealthy && [...healthStatus.values()].every(Boolean)) {
273
+ if (config.smoketest) {
274
+ executor.log(`Running smoketest: ${config.smoketest.join(" ")}`);
275
+ try {
276
+ runSmoketest(executor, config);
277
+ executor.log("Smoketest passed!");
278
+ } catch (error) {
279
+ const message = error instanceof Error ? error.message : String(error);
280
+ executor.logError(`Smoketest failed: ${message}`);
281
+ for (const service of compose.services) composeLogs(executor, compose, service);
282
+ cleanup();
283
+ return {
284
+ success: false,
285
+ reason: "smoketest-failed",
286
+ message,
287
+ elapsedMs: executor.now() - startTime
288
+ };
289
+ }
290
+ }
266
291
  executor.log("Check successful! All systems operational.");
267
292
  cleanup();
268
293
  return {
@@ -68,6 +68,10 @@ interface CheckConfig {
68
68
  buildCwd?: string;
69
69
  /** HTTP health checks to poll. All must pass for success. */
70
70
  healthChecks: HttpHealthCheck[];
71
+ /** Optional smoketest command to run after all health checks pass (e.g. ["pnpm", "test:smoke"]). */
72
+ smoketest?: string[];
73
+ /** Working directory for the smoketest command (defaults to compose.cwd). */
74
+ smoketestCwd?: string;
71
75
  /** Maximum time to wait for all checks to pass, in ms. Default: 120000. */
72
76
  timeoutMs?: number;
73
77
  /** Interval between polling attempts, in ms. Default: 5000. */
@@ -81,7 +85,7 @@ type CheckResult = {
81
85
  elapsedMs: number;
82
86
  } | {
83
87
  success: false;
84
- reason: "timeout" | "unhealthy-container" | "error";
88
+ reason: "timeout" | "unhealthy-container" | "smoketest-failed" | "error";
85
89
  message: string;
86
90
  elapsedMs: number;
87
91
  };
@@ -1,2 +1,2 @@
1
- import { a as composeDown, c as composeUp, i as composeArgs, l as createRealExecutor, n as checkHttpHealth, o as composeLogs, r as getContainerHealth, s as composePs, t as runDockerCheck } from "../check-hutCTIXe.mjs";
1
+ import { a as composeDown, c as composeUp, i as composeArgs, l as createRealExecutor, n as checkHttpHealth, o as composeLogs, r as getContainerHealth, s as composePs, t as runDockerCheck } from "../check-COzp4S_Q.mjs";
2
2
  export { checkHttpHealth, composeArgs, 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.50.1",
3
+ "version": "0.51.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@clack/prompts": "^1.2.0",
40
- "citty": "^0.2.1",
40
+ "citty": "^0.2.2",
41
41
  "json5": "^2.2.3",
42
42
  "jsonc-parser": "^3.3.1",
43
43
  "picomatch": "^4.0.4",
@@ -51,7 +51,7 @@
51
51
  "@types/node": "24.12.0",
52
52
  "@types/picomatch": "4.0.3",
53
53
  "commit-and-tag-version": "12.7.1",
54
- "prettier": "3.8.1",
54
+ "prettier": "3.8.2",
55
55
  "release-it": "19.2.4",
56
56
  "tsdown": "0.21.7",
57
57
  "@bensandee/claude-hooks": "0.7.0",
@@ -144,6 +144,17 @@
144
144
  "description": "Working directory for build command",
145
145
  "type": "string"
146
146
  },
147
+ "smoketest": {
148
+ "description": "Command to run as a smoketest after health checks pass (e.g. [\"pnpm\", \"test:smoke\"])",
149
+ "type": "array",
150
+ "items": {
151
+ "type": "string"
152
+ }
153
+ },
154
+ "smoketestCwd": {
155
+ "description": "Working directory for smoketest command",
156
+ "type": "string"
157
+ },
147
158
  "timeoutMs": {
148
159
  "description": "Overall timeout in milliseconds",
149
160
  "type": "integer",