@bensandee/tooling 0.50.2 → 0.51.1

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
  });
@@ -1367,65 +1369,65 @@ const PER_PACKAGE_DEV_DEPS = {
1367
1369
  "@types/node": {
1368
1370
  "@changesets/cli": "2.30.0",
1369
1371
  "@release-it/bumper": "7.0.5",
1370
- "@types/node": "24.12.0",
1372
+ "@types/node": "24.12.2",
1371
1373
  "commit-and-tag-version": "12.7.1",
1372
- "knip": "6.3.0",
1373
- "lefthook": "2.1.4",
1374
- "oxfmt": "0.43.0",
1375
- "oxlint": "1.58.0",
1376
- "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",
1377
1379
  "release-it": "19.2.4",
1378
1380
  "tsdown": "0.21.7",
1379
1381
  "typescript": "6.0.2",
1380
- "vitest": "4.1.2",
1382
+ "vitest": "4.1.4",
1381
1383
  "pnpm": "10.33.0"
1382
1384
  }["@types/node"],
1383
1385
  tsdown: {
1384
1386
  "@changesets/cli": "2.30.0",
1385
1387
  "@release-it/bumper": "7.0.5",
1386
- "@types/node": "24.12.0",
1388
+ "@types/node": "24.12.2",
1387
1389
  "commit-and-tag-version": "12.7.1",
1388
- "knip": "6.3.0",
1389
- "lefthook": "2.1.4",
1390
- "oxfmt": "0.43.0",
1391
- "oxlint": "1.58.0",
1392
- "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",
1393
1395
  "release-it": "19.2.4",
1394
1396
  "tsdown": "0.21.7",
1395
1397
  "typescript": "6.0.2",
1396
- "vitest": "4.1.2",
1398
+ "vitest": "4.1.4",
1397
1399
  "pnpm": "10.33.0"
1398
1400
  }["tsdown"],
1399
1401
  typescript: {
1400
1402
  "@changesets/cli": "2.30.0",
1401
1403
  "@release-it/bumper": "7.0.5",
1402
- "@types/node": "24.12.0",
1404
+ "@types/node": "24.12.2",
1403
1405
  "commit-and-tag-version": "12.7.1",
1404
- "knip": "6.3.0",
1405
- "lefthook": "2.1.4",
1406
- "oxfmt": "0.43.0",
1407
- "oxlint": "1.58.0",
1408
- "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",
1409
1411
  "release-it": "19.2.4",
1410
1412
  "tsdown": "0.21.7",
1411
1413
  "typescript": "6.0.2",
1412
- "vitest": "4.1.2",
1414
+ "vitest": "4.1.4",
1413
1415
  "pnpm": "10.33.0"
1414
1416
  }["typescript"],
1415
1417
  vitest: {
1416
1418
  "@changesets/cli": "2.30.0",
1417
1419
  "@release-it/bumper": "7.0.5",
1418
- "@types/node": "24.12.0",
1420
+ "@types/node": "24.12.2",
1419
1421
  "commit-and-tag-version": "12.7.1",
1420
- "knip": "6.3.0",
1421
- "lefthook": "2.1.4",
1422
- "oxfmt": "0.43.0",
1423
- "oxlint": "1.58.0",
1424
- "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",
1425
1427
  "release-it": "19.2.4",
1426
1428
  "tsdown": "0.21.7",
1427
1429
  "typescript": "6.0.2",
1428
- "vitest": "4.1.2",
1430
+ "vitest": "4.1.4",
1429
1431
  "pnpm": "10.33.0"
1430
1432
  }["vitest"]
1431
1433
  };
@@ -1434,49 +1436,49 @@ const ROOT_DEV_DEPS = {
1434
1436
  knip: {
1435
1437
  "@changesets/cli": "2.30.0",
1436
1438
  "@release-it/bumper": "7.0.5",
1437
- "@types/node": "24.12.0",
1439
+ "@types/node": "24.12.2",
1438
1440
  "commit-and-tag-version": "12.7.1",
1439
- "knip": "6.3.0",
1440
- "lefthook": "2.1.4",
1441
- "oxfmt": "0.43.0",
1442
- "oxlint": "1.58.0",
1443
- "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",
1444
1446
  "release-it": "19.2.4",
1445
1447
  "tsdown": "0.21.7",
1446
1448
  "typescript": "6.0.2",
1447
- "vitest": "4.1.2",
1449
+ "vitest": "4.1.4",
1448
1450
  "pnpm": "10.33.0"
1449
1451
  }["knip"],
1450
1452
  lefthook: {
1451
1453
  "@changesets/cli": "2.30.0",
1452
1454
  "@release-it/bumper": "7.0.5",
1453
- "@types/node": "24.12.0",
1455
+ "@types/node": "24.12.2",
1454
1456
  "commit-and-tag-version": "12.7.1",
1455
- "knip": "6.3.0",
1456
- "lefthook": "2.1.4",
1457
- "oxfmt": "0.43.0",
1458
- "oxlint": "1.58.0",
1459
- "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",
1460
1462
  "release-it": "19.2.4",
1461
1463
  "tsdown": "0.21.7",
1462
1464
  "typescript": "6.0.2",
1463
- "vitest": "4.1.2",
1465
+ "vitest": "4.1.4",
1464
1466
  "pnpm": "10.33.0"
1465
1467
  }["lefthook"],
1466
1468
  oxlint: {
1467
1469
  "@changesets/cli": "2.30.0",
1468
1470
  "@release-it/bumper": "7.0.5",
1469
- "@types/node": "24.12.0",
1471
+ "@types/node": "24.12.2",
1470
1472
  "commit-and-tag-version": "12.7.1",
1471
- "knip": "6.3.0",
1472
- "lefthook": "2.1.4",
1473
- "oxfmt": "0.43.0",
1474
- "oxlint": "1.58.0",
1475
- "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",
1476
1478
  "release-it": "19.2.4",
1477
1479
  "tsdown": "0.21.7",
1478
1480
  "typescript": "6.0.2",
1479
- "vitest": "4.1.2",
1481
+ "vitest": "4.1.4",
1480
1482
  "pnpm": "10.33.0"
1481
1483
  }["oxlint"]
1482
1484
  };
@@ -1507,33 +1509,33 @@ function addReleaseDeps(deps, config) {
1507
1509
  deps["release-it"] = {
1508
1510
  "@changesets/cli": "2.30.0",
1509
1511
  "@release-it/bumper": "7.0.5",
1510
- "@types/node": "24.12.0",
1512
+ "@types/node": "24.12.2",
1511
1513
  "commit-and-tag-version": "12.7.1",
1512
- "knip": "6.3.0",
1513
- "lefthook": "2.1.4",
1514
- "oxfmt": "0.43.0",
1515
- "oxlint": "1.58.0",
1516
- "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",
1517
1519
  "release-it": "19.2.4",
1518
1520
  "tsdown": "0.21.7",
1519
1521
  "typescript": "6.0.2",
1520
- "vitest": "4.1.2",
1522
+ "vitest": "4.1.4",
1521
1523
  "pnpm": "10.33.0"
1522
1524
  }["release-it"];
1523
1525
  if (config.structure === "monorepo") deps["@release-it/bumper"] = {
1524
1526
  "@changesets/cli": "2.30.0",
1525
1527
  "@release-it/bumper": "7.0.5",
1526
- "@types/node": "24.12.0",
1528
+ "@types/node": "24.12.2",
1527
1529
  "commit-and-tag-version": "12.7.1",
1528
- "knip": "6.3.0",
1529
- "lefthook": "2.1.4",
1530
- "oxfmt": "0.43.0",
1531
- "oxlint": "1.58.0",
1532
- "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",
1533
1535
  "release-it": "19.2.4",
1534
1536
  "tsdown": "0.21.7",
1535
1537
  "typescript": "6.0.2",
1536
- "vitest": "4.1.2",
1538
+ "vitest": "4.1.4",
1537
1539
  "pnpm": "10.33.0"
1538
1540
  }["@release-it/bumper"];
1539
1541
  break;
@@ -1541,17 +1543,17 @@ function addReleaseDeps(deps, config) {
1541
1543
  deps["commit-and-tag-version"] = {
1542
1544
  "@changesets/cli": "2.30.0",
1543
1545
  "@release-it/bumper": "7.0.5",
1544
- "@types/node": "24.12.0",
1546
+ "@types/node": "24.12.2",
1545
1547
  "commit-and-tag-version": "12.7.1",
1546
- "knip": "6.3.0",
1547
- "lefthook": "2.1.4",
1548
- "oxfmt": "0.43.0",
1549
- "oxlint": "1.58.0",
1550
- "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",
1551
1553
  "release-it": "19.2.4",
1552
1554
  "tsdown": "0.21.7",
1553
1555
  "typescript": "6.0.2",
1554
- "vitest": "4.1.2",
1556
+ "vitest": "4.1.4",
1555
1557
  "pnpm": "10.33.0"
1556
1558
  }["commit-and-tag-version"];
1557
1559
  break;
@@ -1559,17 +1561,17 @@ function addReleaseDeps(deps, config) {
1559
1561
  deps["@changesets/cli"] = {
1560
1562
  "@changesets/cli": "2.30.0",
1561
1563
  "@release-it/bumper": "7.0.5",
1562
- "@types/node": "24.12.0",
1564
+ "@types/node": "24.12.2",
1563
1565
  "commit-and-tag-version": "12.7.1",
1564
- "knip": "6.3.0",
1565
- "lefthook": "2.1.4",
1566
- "oxfmt": "0.43.0",
1567
- "oxlint": "1.58.0",
1568
- "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",
1569
1571
  "release-it": "19.2.4",
1570
1572
  "tsdown": "0.21.7",
1571
1573
  "typescript": "6.0.2",
1572
- "vitest": "4.1.2",
1574
+ "vitest": "4.1.4",
1573
1575
  "pnpm": "10.33.0"
1574
1576
  }["@changesets/cli"];
1575
1577
  break;
@@ -1594,38 +1596,38 @@ async function generatePackageJson(ctx) {
1594
1596
  const devDeps = { ...ROOT_DEV_DEPS };
1595
1597
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1596
1598
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.10.2";
1597
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.50.2";
1599
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.51.1";
1598
1600
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.12.0";
1599
1601
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
1600
1602
  "@changesets/cli": "2.30.0",
1601
1603
  "@release-it/bumper": "7.0.5",
1602
- "@types/node": "24.12.0",
1604
+ "@types/node": "24.12.2",
1603
1605
  "commit-and-tag-version": "12.7.1",
1604
- "knip": "6.3.0",
1605
- "lefthook": "2.1.4",
1606
- "oxfmt": "0.43.0",
1607
- "oxlint": "1.58.0",
1608
- "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",
1609
1611
  "release-it": "19.2.4",
1610
1612
  "tsdown": "0.21.7",
1611
1613
  "typescript": "6.0.2",
1612
- "vitest": "4.1.2",
1614
+ "vitest": "4.1.4",
1613
1615
  "pnpm": "10.33.0"
1614
1616
  }["oxfmt"];
1615
1617
  if (ctx.config.formatter === "prettier") devDeps["prettier"] = {
1616
1618
  "@changesets/cli": "2.30.0",
1617
1619
  "@release-it/bumper": "7.0.5",
1618
- "@types/node": "24.12.0",
1620
+ "@types/node": "24.12.2",
1619
1621
  "commit-and-tag-version": "12.7.1",
1620
- "knip": "6.3.0",
1621
- "lefthook": "2.1.4",
1622
- "oxfmt": "0.43.0",
1623
- "oxlint": "1.58.0",
1624
- "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",
1625
1627
  "release-it": "19.2.4",
1626
1628
  "tsdown": "0.21.7",
1627
1629
  "typescript": "6.0.2",
1628
- "vitest": "4.1.2",
1630
+ "vitest": "4.1.4",
1629
1631
  "pnpm": "10.33.0"
1630
1632
  }["prettier"];
1631
1633
  addReleaseDeps(devDeps, ctx.config);
@@ -1709,17 +1711,17 @@ async function generatePackageJson(ctx) {
1709
1711
  packageManager: `pnpm@${{
1710
1712
  "@changesets/cli": "2.30.0",
1711
1713
  "@release-it/bumper": "7.0.5",
1712
- "@types/node": "24.12.0",
1714
+ "@types/node": "24.12.2",
1713
1715
  "commit-and-tag-version": "12.7.1",
1714
- "knip": "6.3.0",
1715
- "lefthook": "2.1.4",
1716
- "oxfmt": "0.43.0",
1717
- "oxlint": "1.58.0",
1718
- "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",
1719
1721
  "release-it": "19.2.4",
1720
1722
  "tsdown": "0.21.7",
1721
1723
  "typescript": "6.0.2",
1722
- "vitest": "4.1.2",
1724
+ "vitest": "4.1.4",
1723
1725
  "pnpm": "10.33.0"
1724
1726
  }["pnpm"]}`
1725
1727
  };
@@ -2168,7 +2170,7 @@ const REQUIRED_ENTRIES = [
2168
2170
  const OPTIONAL_ENTRIES = [
2169
2171
  ".tooling-migrate.md",
2170
2172
  ".tooling-archived/",
2171
- ".claude/hooks/bst-hook.audit.local.jsonl"
2173
+ ".claude/hooks/bst-hook.audit.local.jsonl*"
2172
2174
  ];
2173
2175
  const ALL_ENTRIES = [...REQUIRED_ENTRIES, ...OPTIONAL_ENTRIES];
2174
2176
  /** Normalize a gitignore entry for comparison: strip leading `/` and trailing `/`. */
@@ -3368,7 +3370,7 @@ async function runGenerators(ctx) {
3368
3370
  function generateMigratePrompt(results, config, detected) {
3369
3371
  const sections = [];
3370
3372
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3371
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.50.2 repo:sync\` on ${timestamp}_\n`);
3373
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.51.1 repo:sync\` on ${timestamp}_\n`);
3372
3374
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
3373
3375
  if (changed.length > 0) {
3374
3376
  const items = changed.map((r) => {
@@ -5516,19 +5518,37 @@ const CHECK_HEALTHCHECK = {
5516
5518
  start_interval: "5s"
5517
5519
  };
5518
5520
  /**
5521
+ * Whether a service image references a compose variable substitution (e.g. `${REGISTRY:-}…`).
5522
+ * Consumers use this pattern to parametrize the build-vs-registry split, so such services
5523
+ * are locally built rather than pulled from a registry — forcing `pull_policy: never` for
5524
+ * the check avoids failures when the base compose sets `pull_policy: always` for prod deploys.
5525
+ */
5526
+ function isLocallyBuiltImage(image) {
5527
+ return image !== void 0 && /\$\{[^}]*\}/.test(image);
5528
+ }
5529
+ /**
5519
5530
  * Generate a check overlay YAML string from parsed services.
5520
- * Sets `restart: "no"` on all services, and overrides healthcheck intervals
5521
- * for services that define a healthcheck in the base compose file.
5531
+ * Sets `restart: "no"` on all services, overrides healthcheck intervals
5532
+ * for services that define a healthcheck in the base compose file, and
5533
+ * forces `pull_policy: never` on services whose image uses a variable
5534
+ * substitution (locally-built images that aren't pushed to a registry).
5522
5535
  */
5523
5536
  function generateCheckOverlay(services) {
5524
5537
  const serviceOverrides = {};
5525
5538
  for (const service of services) {
5526
5539
  const override = { restart: "no" };
5527
5540
  if (service.hasHealthcheck) override["healthcheck"] = { ...CHECK_HEALTHCHECK };
5541
+ if (isLocallyBuiltImage(service.image)) override["pull_policy"] = "never";
5528
5542
  serviceOverrides[service.name] = override;
5529
5543
  }
5530
5544
  return stringify({ services: serviceOverrides });
5531
5545
  }
5546
+ /** Detect a smoketest command from a `test:smoke` script in the root package.json. */
5547
+ function detectSmoketest(cwd) {
5548
+ try {
5549
+ if (parsePackageJson(readFileSync(path.join(cwd, "package.json"), "utf-8"))?.scripts?.["test:smoke"]) return ["pnpm", "test:smoke"];
5550
+ } catch (_error) {}
5551
+ }
5532
5552
  /** Auto-detect compose config from conventional file locations. */
5533
5553
  function computeCheckDefaults(cwd) {
5534
5554
  const { dir, files } = detectComposeFiles(cwd);
@@ -5538,13 +5558,15 @@ function computeCheckDefaults(cwd) {
5538
5558
  const envFile = detectCheckEnvFile(dir);
5539
5559
  const services = parseComposeServices(dir, files);
5540
5560
  const healthChecks = deriveHealthChecks(services);
5561
+ const smoketest = detectSmoketest(cwd);
5541
5562
  return {
5542
5563
  composeCwd: dir !== cwd ? dir : void 0,
5543
5564
  composeFiles: files,
5544
5565
  checkOverlay,
5545
5566
  envFile,
5546
5567
  services: services.map((s) => s.name),
5547
- healthChecks: healthChecks.length > 0 ? healthChecks : void 0
5568
+ healthChecks: healthChecks.length > 0 ? healthChecks : void 0,
5569
+ smoketest
5548
5570
  };
5549
5571
  }
5550
5572
  /** Create a DockerFileReader backed by the real filesystem. */
@@ -5605,11 +5627,15 @@ const dockerCheckCommand = defineCommand({
5605
5627
  },
5606
5628
  async run({ args }) {
5607
5629
  const cwd = process.cwd();
5608
- if (loadToolingConfig(cwd)?.dockerCheck === false) {
5630
+ const toolingConfig = loadToolingConfig(cwd);
5631
+ if (toolingConfig?.dockerCheck === false) {
5609
5632
  log.info("Docker check is disabled in .tooling.json");
5610
5633
  return;
5611
5634
  }
5612
- const defaults = computeCheckDefaults(cwd);
5635
+ const defaults = {
5636
+ ...computeCheckDefaults(cwd),
5637
+ ...toolingConfig?.dockerCheck
5638
+ };
5613
5639
  if (!defaults.composeFiles || defaults.composeFiles.length === 0) throw new FatalError("No compose files found. Expected docker-compose.yaml or compose.yaml in ./ or docker/.");
5614
5640
  if (!defaults.checkOverlay) {
5615
5641
  const composeCwd = defaults.composeCwd ?? cwd;
@@ -5648,6 +5674,8 @@ const dockerCheckCommand = defineCommand({
5648
5674
  buildCommand: defaults.buildCommand,
5649
5675
  buildCwd: defaults.buildCwd,
5650
5676
  healthChecks: defaults.healthChecks ? toHttpHealthChecks(defaults.healthChecks) : [],
5677
+ smoketest: defaults.smoketest,
5678
+ smoketestCwd: defaults.smoketestCwd,
5651
5679
  timeoutMs: args.timeout ? Number.parseInt(args.timeout, 10) : defaults.timeoutMs,
5652
5680
  pollIntervalMs: args["poll-interval"] ? Number.parseInt(args["poll-interval"], 10) : defaults.pollIntervalMs,
5653
5681
  verbose: args.verbose === true || isEnvVerbose()
@@ -6459,7 +6487,7 @@ const webhookSendCommand = defineCommand({
6459
6487
  const main = defineCommand({
6460
6488
  meta: {
6461
6489
  name: "bst",
6462
- version: "0.50.2",
6490
+ version: "0.51.1",
6463
6491
  description: "Bootstrap and maintain standardized TypeScript project tooling"
6464
6492
  },
6465
6493
  subCommands: {
@@ -6480,7 +6508,7 @@ const main = defineCommand({
6480
6508
  "webhook:send": webhookSendCommand
6481
6509
  }
6482
6510
  });
6483
- console.log(`@bensandee/tooling v0.50.2`);
6511
+ console.log(`@bensandee/tooling v0.51.1`);
6484
6512
  async function run() {
6485
6513
  await runMain(main);
6486
6514
  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.2",
3
+ "version": "0.51.1",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -48,14 +48,14 @@
48
48
  "devDependencies": {
49
49
  "@changesets/cli": "2.30.0",
50
50
  "@release-it/bumper": "7.0.5",
51
- "@types/node": "24.12.0",
51
+ "@types/node": "24.12.2",
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
- "@bensandee/config": "0.10.2",
58
- "@bensandee/claude-hooks": "0.7.0"
57
+ "@bensandee/claude-hooks": "0.7.0",
58
+ "@bensandee/config": "0.10.2"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "tsdown",
@@ -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",