@bensandee/tooling 0.17.0 → 0.18.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/dist/bin.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as isExecSyncError } from "./exec-CC49vrkM.mjs";
2
+ import { l as createRealExecutor$1, t as runVerification, u as isExecSyncError } from "./verify-BaWlzdPh.mjs";
3
3
  import { defineCommand, runMain } from "citty";
4
4
  import * as p from "@clack/prompts";
5
5
  import path from "node:path";
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
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 } from "yaml";
11
11
  import { execSync } from "node:child_process";
12
12
  import { FatalError, TransientError, UnexpectedError } from "@bensandee/common";
13
13
  //#region src/types.ts
@@ -656,7 +656,7 @@ function getAddedDevDepNames(config) {
656
656
  const deps = { ...ROOT_DEV_DEPS };
657
657
  if (config.structure !== "monorepo") Object.assign(deps, PER_PACKAGE_DEV_DEPS);
658
658
  deps["@bensandee/config"] = "0.8.2";
659
- deps["@bensandee/tooling"] = "0.17.0";
659
+ deps["@bensandee/tooling"] = "0.18.0";
660
660
  if (config.formatter === "oxfmt") deps["oxfmt"] = "0.35.0";
661
661
  if (config.formatter === "prettier") deps["prettier"] = "3.8.1";
662
662
  addReleaseDeps(deps, config);
@@ -677,7 +677,7 @@ async function generatePackageJson(ctx) {
677
677
  const devDeps = { ...ROOT_DEV_DEPS };
678
678
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
679
679
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.8.2";
680
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.17.0";
680
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.18.0";
681
681
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.2";
682
682
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = "0.35.0";
683
683
  if (ctx.config.formatter === "prettier") devDeps["prettier"] = "3.8.1";
@@ -4257,11 +4257,159 @@ const dockerBuildCommand = defineCommand({
4257
4257
  }
4258
4258
  });
4259
4259
  //#endregion
4260
+ //#region src/docker-verify/detect.ts
4261
+ /** Compose file names to scan, in priority order. */
4262
+ const COMPOSE_FILE_CANDIDATES = [
4263
+ "docker-compose.yaml",
4264
+ "docker-compose.yml",
4265
+ "compose.yaml",
4266
+ "compose.yml"
4267
+ ];
4268
+ /** Zod schema for the subset of compose YAML we care about. */
4269
+ const ComposePortSchema = z.union([z.string(), z.object({
4270
+ published: z.union([z.string(), z.number()]),
4271
+ target: z.union([z.string(), z.number()]).optional()
4272
+ }).passthrough()]);
4273
+ const ComposeServiceSchema = z.object({
4274
+ ports: z.array(ComposePortSchema).optional(),
4275
+ healthcheck: z.unknown().optional()
4276
+ }).passthrough();
4277
+ const ComposeFileSchema = z.object({ services: z.record(z.string(), ComposeServiceSchema).optional() }).passthrough();
4278
+ /** Detect which compose files exist at conventional paths. */
4279
+ function detectComposeFiles(cwd) {
4280
+ return COMPOSE_FILE_CANDIDATES.filter((name) => existsSync(path.join(cwd, name)));
4281
+ }
4282
+ /** Parse a single port mapping string and extract the host port. */
4283
+ function parsePortString(port) {
4284
+ const withoutProtocol = port.split("/")[0];
4285
+ if (!withoutProtocol) return void 0;
4286
+ const parts = withoutProtocol.split(":");
4287
+ if (parts.length === 2) {
4288
+ const host = parts[0];
4289
+ if (!host) return void 0;
4290
+ const parsed = Number.parseInt(host, 10);
4291
+ return Number.isNaN(parsed) ? void 0 : parsed;
4292
+ }
4293
+ if (parts.length === 3) {
4294
+ const host = parts[1];
4295
+ if (!host) return void 0;
4296
+ const parsed = Number.parseInt(host, 10);
4297
+ return Number.isNaN(parsed) ? void 0 : parsed;
4298
+ }
4299
+ }
4300
+ /** Extract the host port from a compose port definition. */
4301
+ function extractHostPort(port) {
4302
+ if (typeof port === "string") return parsePortString(port);
4303
+ const published = port.published;
4304
+ if (typeof published === "number") return published;
4305
+ if (typeof published === "string") {
4306
+ const parsed = Number.parseInt(published, 10);
4307
+ return Number.isNaN(parsed) ? void 0 : parsed;
4308
+ }
4309
+ }
4310
+ /** Parse compose files and return service info (names, ports, healthcheck presence). */
4311
+ function parseComposeServices(cwd, composeFiles) {
4312
+ const serviceMap = /* @__PURE__ */ new Map();
4313
+ for (const file of composeFiles) {
4314
+ let parsed;
4315
+ try {
4316
+ const content = readFileSync(path.join(cwd, file), "utf-8");
4317
+ parsed = ComposeFileSchema.safeParse(parse$1(content));
4318
+ } catch (_error) {
4319
+ continue;
4320
+ }
4321
+ if (!parsed.success || !parsed.data.services) continue;
4322
+ for (const [name, service] of Object.entries(parsed.data.services)) {
4323
+ const existing = serviceMap.get(name);
4324
+ let hostPort = existing?.hostPort;
4325
+ if (hostPort === void 0 && service.ports) for (const port of service.ports) {
4326
+ const extracted = extractHostPort(port);
4327
+ if (extracted !== void 0) {
4328
+ hostPort = extracted;
4329
+ break;
4330
+ }
4331
+ }
4332
+ serviceMap.set(name, {
4333
+ name,
4334
+ hostPort,
4335
+ hasHealthcheck: existing?.hasHealthcheck ?? service.healthcheck !== void 0
4336
+ });
4337
+ }
4338
+ }
4339
+ return [...serviceMap.values()];
4340
+ }
4341
+ /** Generate health checks from parsed services: services with exposed ports get HTTP checks, unless they define a compose-level healthcheck. */
4342
+ function deriveHealthChecks(services) {
4343
+ return services.filter((s) => s.hostPort !== void 0 && !s.hasHealthcheck).map((s) => ({
4344
+ name: s.name,
4345
+ url: `http://localhost:${String(s.hostPort)}/`
4346
+ }));
4347
+ }
4348
+ /** Auto-detect compose config from conventional file locations. */
4349
+ function computeVerifyDefaults(cwd) {
4350
+ const composeFiles = detectComposeFiles(cwd);
4351
+ if (composeFiles.length === 0) return {};
4352
+ const services = parseComposeServices(cwd, composeFiles);
4353
+ const healthChecks = deriveHealthChecks(services);
4354
+ return {
4355
+ composeFiles,
4356
+ services: services.map((s) => s.name),
4357
+ healthChecks: healthChecks.length > 0 ? healthChecks : void 0
4358
+ };
4359
+ }
4360
+ //#endregion
4361
+ //#region src/commands/docker-verify.ts
4362
+ /** Convert declarative health checks to functional ones. */
4363
+ function toHttpHealthChecks(checks) {
4364
+ return checks.map((check) => ({
4365
+ name: check.name,
4366
+ url: check.url,
4367
+ validate: async (res) => check.status ? res.status === check.status : res.ok
4368
+ }));
4369
+ }
4370
+ const dockerVerifyCommand = defineCommand({
4371
+ meta: {
4372
+ name: "docker:verify",
4373
+ description: "Verify Docker Compose stack health by auto-detecting services from compose files"
4374
+ },
4375
+ args: {
4376
+ timeout: {
4377
+ type: "string",
4378
+ description: "Maximum time to wait for health checks, in ms (default: 120000)"
4379
+ },
4380
+ "poll-interval": {
4381
+ type: "string",
4382
+ description: "Interval between polling attempts, in ms (default: 5000)"
4383
+ }
4384
+ },
4385
+ async run({ args }) {
4386
+ const cwd = process.cwd();
4387
+ const defaults = computeVerifyDefaults(cwd);
4388
+ if (!defaults.composeFiles || defaults.composeFiles.length === 0) throw new FatalError("No compose files found. Expected docker-compose.yaml or compose.yaml.");
4389
+ if (!defaults.services || defaults.services.length === 0) throw new FatalError("No services found in compose files.");
4390
+ const config = {
4391
+ compose: {
4392
+ cwd,
4393
+ composeFiles: defaults.composeFiles,
4394
+ envFile: defaults.envFile,
4395
+ services: defaults.services
4396
+ },
4397
+ buildCommand: defaults.buildCommand,
4398
+ buildCwd: defaults.buildCwd,
4399
+ healthChecks: defaults.healthChecks ? toHttpHealthChecks(defaults.healthChecks) : [],
4400
+ timeoutMs: args.timeout ? Number.parseInt(args.timeout, 10) : defaults.timeoutMs,
4401
+ pollIntervalMs: args["poll-interval"] ? Number.parseInt(args["poll-interval"], 10) : defaults.pollIntervalMs
4402
+ };
4403
+ const result = await runVerification(createRealExecutor$1(), config);
4404
+ if (!result.success) throw new FatalError(`Verification failed (${result.reason}): ${result.message}`);
4405
+ }
4406
+ });
4407
+ //#endregion
4260
4408
  //#region src/bin.ts
4261
4409
  const main = defineCommand({
4262
4410
  meta: {
4263
4411
  name: "tooling",
4264
- version: "0.17.0",
4412
+ version: "0.18.0",
4265
4413
  description: "Bootstrap and maintain standardized TypeScript project tooling"
4266
4414
  },
4267
4415
  subCommands: {
@@ -4273,10 +4421,11 @@ const main = defineCommand({
4273
4421
  "changesets:merge": releaseMergeCommand,
4274
4422
  "release:simple": releaseSimpleCommand,
4275
4423
  "docker:publish": publishDockerCommand,
4276
- "docker:build": dockerBuildCommand
4424
+ "docker:build": dockerBuildCommand,
4425
+ "docker:verify": dockerVerifyCommand
4277
4426
  }
4278
4427
  });
4279
- console.log(`@bensandee/tooling v0.17.0`);
4428
+ console.log(`@bensandee/tooling v0.18.0`);
4280
4429
  runMain(main);
4281
4430
  //#endregion
4282
4431
  export {};
@@ -1,218 +1,2 @@
1
- import { t as isExecSyncError } from "../exec-CC49vrkM.mjs";
2
- import { z } from "zod";
3
- import { execSync } from "node:child_process";
4
- //#region src/docker-verify/executor.ts
5
- /** Create an executor that runs real commands, fetches, and manages process signals. */
6
- function createRealExecutor() {
7
- return {
8
- exec(command, options) {
9
- try {
10
- return {
11
- stdout: execSync(command, {
12
- cwd: options?.cwd,
13
- env: options?.env ? {
14
- ...process.env,
15
- ...options.env
16
- } : void 0,
17
- encoding: "utf-8",
18
- stdio: [
19
- "pipe",
20
- "pipe",
21
- "pipe"
22
- ]
23
- }),
24
- stderr: "",
25
- exitCode: 0
26
- };
27
- } catch (err) {
28
- if (isExecSyncError(err)) return {
29
- stdout: err.stdout,
30
- stderr: err.stderr,
31
- exitCode: err.status
32
- };
33
- return {
34
- stdout: "",
35
- stderr: "",
36
- exitCode: 1
37
- };
38
- }
39
- },
40
- execInherit(command, options) {
41
- execSync(command, {
42
- cwd: options?.cwd,
43
- env: options?.env ? {
44
- ...process.env,
45
- ...options.env
46
- } : void 0,
47
- stdio: "inherit"
48
- });
49
- },
50
- fetch: globalThis.fetch,
51
- now: () => Date.now(),
52
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
53
- onSignal(signal, handler) {
54
- process.on(signal, handler);
55
- return () => {
56
- process.removeListener(signal, handler);
57
- };
58
- },
59
- log: (msg) => console.log(msg),
60
- logError: (msg) => console.error(msg)
61
- };
62
- }
63
- //#endregion
64
- //#region src/docker-verify/compose.ts
65
- /** Zod schema for a single container entry from `docker compose ps --format json`. */
66
- const ContainerInfoSchema = z.object({
67
- Service: z.string(),
68
- Health: z.string()
69
- });
70
- /** Build the `docker compose` base command string from config. */
71
- function composeCommand(config) {
72
- return `docker compose ${config.composeFiles.map((f) => `-f ${f}`).join(" ")}${config.envFile ? ` --env-file ${config.envFile}` : ""}`;
73
- }
74
- /** Run the build command if configured. */
75
- function buildImages(executor, config) {
76
- if (!config.buildCommand) return;
77
- executor.execInherit(config.buildCommand, { cwd: config.buildCwd ?? config.compose.cwd });
78
- }
79
- /** Start the compose stack in detached mode. */
80
- function composeUp(executor, config) {
81
- executor.execInherit(`${composeCommand(config)} up -d`, { cwd: config.cwd });
82
- }
83
- /** Tear down the compose stack, removing volumes and orphans. Swallows errors. */
84
- function composeDown(executor, config) {
85
- try {
86
- executor.execInherit(`${composeCommand(config)} down -v --remove-orphans`, { cwd: config.cwd });
87
- } catch (_error) {}
88
- }
89
- /** Show logs for a specific service (or all services if not specified). Swallows errors. */
90
- function composeLogs(executor, config, service) {
91
- try {
92
- const suffix = service ? ` ${service}` : "";
93
- executor.execInherit(`${composeCommand(config)} logs${suffix}`, { cwd: config.cwd });
94
- } catch (_error) {}
95
- }
96
- /**
97
- * Query container status via `docker compose ps --format json`.
98
- * Handles both JSON array and newline-delimited JSON (varies by docker compose version).
99
- */
100
- function composePs(executor, config) {
101
- const output = executor.exec(`${composeCommand(config)} ps --format json`, { cwd: config.cwd }).stdout.trim();
102
- if (!output) return [];
103
- const ArraySchema = z.array(ContainerInfoSchema);
104
- try {
105
- const direct = ArraySchema.safeParse(JSON.parse(output));
106
- if (direct.success) return direct.data;
107
- const single = ContainerInfoSchema.safeParse(JSON.parse(output));
108
- if (single.success) return [single.data];
109
- } catch (_error) {}
110
- try {
111
- const joined = `[${output.split("\n").join(",")}]`;
112
- const delimited = ArraySchema.safeParse(JSON.parse(joined));
113
- return delimited.success ? delimited.data : [];
114
- } catch (_error) {
115
- return [];
116
- }
117
- }
118
- //#endregion
119
- //#region src/docker-verify/health.ts
120
- /** Look up the health status of a specific service from container info. */
121
- function getContainerHealth(containers, serviceName) {
122
- return containers.find((c) => c.Service === serviceName)?.Health ?? "unknown";
123
- }
124
- /** Run a single HTTP health check, returning true if the validator passes. */
125
- async function checkHttpHealth(executor, check) {
126
- try {
127
- const response = await executor.fetch(check.url);
128
- return await check.validate(response);
129
- } catch (_error) {
130
- return false;
131
- }
132
- }
133
- //#endregion
134
- //#region src/docker-verify/verify.ts
135
- const DEFAULT_TIMEOUT_MS = 12e4;
136
- const DEFAULT_POLL_INTERVAL_MS = 5e3;
137
- /** Run the full Docker image verification lifecycle. */
138
- async function runVerification(executor, config) {
139
- const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
140
- const pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
141
- const { compose } = config;
142
- const cleanup = () => composeDown(executor, compose);
143
- const disposeInt = executor.onSignal("SIGINT", () => {
144
- cleanup();
145
- process.exit(1);
146
- });
147
- const disposeTerm = executor.onSignal("SIGTERM", () => {
148
- cleanup();
149
- process.exit(1);
150
- });
151
- try {
152
- if (config.buildCommand) {
153
- executor.log("Building images...");
154
- buildImages(executor, config);
155
- }
156
- executor.log("Starting compose stack...");
157
- composeUp(executor, compose);
158
- executor.log(`Waiting for stack to be healthy (max ${timeoutMs / 1e3}s)...`);
159
- const startTime = executor.now();
160
- const healthStatus = new Map(config.healthChecks.map((c) => [c.name, false]));
161
- while (executor.now() - startTime < timeoutMs) {
162
- const containers = composePs(executor, compose);
163
- for (const service of compose.services) if (getContainerHealth(containers, service) === "unhealthy") {
164
- executor.logError(`Container ${service} is unhealthy`);
165
- composeLogs(executor, compose, service);
166
- cleanup();
167
- return {
168
- success: false,
169
- reason: "unhealthy-container",
170
- message: service,
171
- elapsedMs: executor.now() - startTime
172
- };
173
- }
174
- for (const check of config.healthChecks) if (!healthStatus.get(check.name)) {
175
- if (await checkHttpHealth(executor, check)) {
176
- healthStatus.set(check.name, true);
177
- executor.log(`${check.name} is healthy!`);
178
- }
179
- }
180
- if ([...healthStatus.values()].every(Boolean)) {
181
- executor.log("Verification successful! All systems operational.");
182
- cleanup();
183
- return {
184
- success: true,
185
- elapsedMs: executor.now() - startTime
186
- };
187
- }
188
- const elapsed = Math.floor((executor.now() - startTime) / 1e3);
189
- if (elapsed > 0 && elapsed % 5 === 0) {
190
- const statuses = [...healthStatus.entries()].map(([name, ok]) => `${name}=${ok ? "OK" : "Pending"}`).join(", ");
191
- executor.log(`Waiting... (${elapsed}s elapsed). ${statuses}`);
192
- }
193
- await executor.sleep(pollIntervalMs);
194
- }
195
- executor.logError("Timeout waiting for stack to become healthy");
196
- for (const service of compose.services) composeLogs(executor, compose, service);
197
- cleanup();
198
- return {
199
- success: false,
200
- reason: "timeout",
201
- message: "Exceeded timeout",
202
- elapsedMs: executor.now() - startTime
203
- };
204
- } catch (error) {
205
- cleanup();
206
- return {
207
- success: false,
208
- reason: "error",
209
- message: error instanceof Error ? error.message : String(error),
210
- elapsedMs: 0
211
- };
212
- } finally {
213
- disposeInt();
214
- disposeTerm();
215
- }
216
- }
217
- //#endregion
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 runVerification } from "../verify-BaWlzdPh.mjs";
218
2
  export { checkHttpHealth, composeCommand, composeDown, composeLogs, composePs, composeUp, createRealExecutor, getContainerHealth, runVerification };
@@ -0,0 +1,223 @@
1
+ import { z } from "zod";
2
+ import { execSync } from "node:child_process";
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-verify/executor.ts
10
+ /** Create an executor that runs real commands, fetches, and manages process signals. */
11
+ function createRealExecutor() {
12
+ return {
13
+ exec(command, options) {
14
+ try {
15
+ return {
16
+ stdout: execSync(command, {
17
+ cwd: options?.cwd,
18
+ env: options?.env ? {
19
+ ...process.env,
20
+ ...options.env
21
+ } : void 0,
22
+ encoding: "utf-8",
23
+ stdio: [
24
+ "pipe",
25
+ "pipe",
26
+ "pipe"
27
+ ]
28
+ }),
29
+ stderr: "",
30
+ exitCode: 0
31
+ };
32
+ } catch (err) {
33
+ if (isExecSyncError(err)) return {
34
+ stdout: err.stdout,
35
+ stderr: err.stderr,
36
+ exitCode: err.status
37
+ };
38
+ return {
39
+ stdout: "",
40
+ stderr: "",
41
+ exitCode: 1
42
+ };
43
+ }
44
+ },
45
+ execInherit(command, options) {
46
+ execSync(command, {
47
+ cwd: options?.cwd,
48
+ env: options?.env ? {
49
+ ...process.env,
50
+ ...options.env
51
+ } : void 0,
52
+ stdio: "inherit"
53
+ });
54
+ },
55
+ fetch: globalThis.fetch,
56
+ now: () => Date.now(),
57
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
58
+ onSignal(signal, handler) {
59
+ process.on(signal, handler);
60
+ return () => {
61
+ process.removeListener(signal, handler);
62
+ };
63
+ },
64
+ log: (msg) => console.log(msg),
65
+ logError: (msg) => console.error(msg)
66
+ };
67
+ }
68
+ //#endregion
69
+ //#region src/docker-verify/compose.ts
70
+ /** Zod schema for a single container entry from `docker compose ps --format json`. */
71
+ const ContainerInfoSchema = z.object({
72
+ Service: z.string(),
73
+ Health: z.string()
74
+ });
75
+ /** Build the `docker compose` base command string from config. */
76
+ function composeCommand(config) {
77
+ return `docker compose ${config.composeFiles.map((f) => `-f ${f}`).join(" ")}${config.envFile ? ` --env-file ${config.envFile}` : ""}`;
78
+ }
79
+ /** Run the build command if configured. */
80
+ function buildImages(executor, config) {
81
+ if (!config.buildCommand) return;
82
+ executor.execInherit(config.buildCommand, { cwd: config.buildCwd ?? config.compose.cwd });
83
+ }
84
+ /** Start the compose stack in detached mode. */
85
+ function composeUp(executor, config) {
86
+ executor.execInherit(`${composeCommand(config)} up -d`, { cwd: config.cwd });
87
+ }
88
+ /** Tear down the compose stack, removing volumes and orphans. Swallows errors. */
89
+ function composeDown(executor, config) {
90
+ try {
91
+ executor.execInherit(`${composeCommand(config)} down -v --remove-orphans`, { cwd: config.cwd });
92
+ } catch (_error) {}
93
+ }
94
+ /** Show logs for a specific service (or all services if not specified). Swallows errors. */
95
+ function composeLogs(executor, config, service) {
96
+ try {
97
+ const suffix = service ? ` ${service}` : "";
98
+ executor.execInherit(`${composeCommand(config)} logs${suffix}`, { cwd: config.cwd });
99
+ } catch (_error) {}
100
+ }
101
+ /**
102
+ * Query container status via `docker compose ps --format json`.
103
+ * Handles both JSON array and newline-delimited JSON (varies by docker compose version).
104
+ */
105
+ function composePs(executor, config) {
106
+ const output = executor.exec(`${composeCommand(config)} ps --format json`, { cwd: config.cwd }).stdout.trim();
107
+ if (!output) return [];
108
+ const ArraySchema = z.array(ContainerInfoSchema);
109
+ try {
110
+ const direct = ArraySchema.safeParse(JSON.parse(output));
111
+ if (direct.success) return direct.data;
112
+ const single = ContainerInfoSchema.safeParse(JSON.parse(output));
113
+ if (single.success) return [single.data];
114
+ } catch (_error) {}
115
+ try {
116
+ const joined = `[${output.split("\n").join(",")}]`;
117
+ const delimited = ArraySchema.safeParse(JSON.parse(joined));
118
+ return delimited.success ? delimited.data : [];
119
+ } catch (_error) {
120
+ return [];
121
+ }
122
+ }
123
+ //#endregion
124
+ //#region src/docker-verify/health.ts
125
+ /** Look up the health status of a specific service from container info. */
126
+ function getContainerHealth(containers, serviceName) {
127
+ return containers.find((c) => c.Service === serviceName)?.Health ?? "unknown";
128
+ }
129
+ /** Run a single HTTP health check, returning true if the validator passes. */
130
+ async function checkHttpHealth(executor, check) {
131
+ try {
132
+ const response = await executor.fetch(check.url);
133
+ return await check.validate(response);
134
+ } catch (_error) {
135
+ return false;
136
+ }
137
+ }
138
+ //#endregion
139
+ //#region src/docker-verify/verify.ts
140
+ const DEFAULT_TIMEOUT_MS = 12e4;
141
+ const DEFAULT_POLL_INTERVAL_MS = 5e3;
142
+ /** Run the full Docker image verification lifecycle. */
143
+ async function runVerification(executor, config) {
144
+ const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
145
+ const pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
146
+ const { compose } = config;
147
+ const cleanup = () => composeDown(executor, compose);
148
+ const disposeInt = executor.onSignal("SIGINT", () => {
149
+ cleanup();
150
+ process.exit(1);
151
+ });
152
+ const disposeTerm = executor.onSignal("SIGTERM", () => {
153
+ cleanup();
154
+ process.exit(1);
155
+ });
156
+ try {
157
+ if (config.buildCommand) {
158
+ executor.log("Building images...");
159
+ buildImages(executor, config);
160
+ }
161
+ executor.log("Starting compose stack...");
162
+ composeUp(executor, compose);
163
+ executor.log(`Waiting for stack to be healthy (max ${timeoutMs / 1e3}s)...`);
164
+ const startTime = executor.now();
165
+ const healthStatus = new Map(config.healthChecks.map((c) => [c.name, false]));
166
+ while (executor.now() - startTime < timeoutMs) {
167
+ const containers = composePs(executor, compose);
168
+ for (const service of compose.services) if (getContainerHealth(containers, service) === "unhealthy") {
169
+ executor.logError(`Container ${service} is unhealthy`);
170
+ composeLogs(executor, compose, service);
171
+ cleanup();
172
+ return {
173
+ success: false,
174
+ reason: "unhealthy-container",
175
+ message: service,
176
+ elapsedMs: executor.now() - startTime
177
+ };
178
+ }
179
+ for (const check of config.healthChecks) if (!healthStatus.get(check.name)) {
180
+ if (await checkHttpHealth(executor, check)) {
181
+ healthStatus.set(check.name, true);
182
+ executor.log(`${check.name} is healthy!`);
183
+ }
184
+ }
185
+ if ([...healthStatus.values()].every(Boolean)) {
186
+ executor.log("Verification successful! All systems operational.");
187
+ cleanup();
188
+ return {
189
+ success: true,
190
+ elapsedMs: executor.now() - startTime
191
+ };
192
+ }
193
+ const elapsed = Math.floor((executor.now() - startTime) / 1e3);
194
+ if (elapsed > 0 && elapsed % 5 === 0) {
195
+ const statuses = [...healthStatus.entries()].map(([name, ok]) => `${name}=${ok ? "OK" : "Pending"}`).join(", ");
196
+ executor.log(`Waiting... (${elapsed}s elapsed). ${statuses}`);
197
+ }
198
+ await executor.sleep(pollIntervalMs);
199
+ }
200
+ executor.logError("Timeout waiting for stack to become healthy");
201
+ for (const service of compose.services) composeLogs(executor, compose, service);
202
+ cleanup();
203
+ return {
204
+ success: false,
205
+ reason: "timeout",
206
+ message: "Exceeded timeout",
207
+ elapsedMs: executor.now() - startTime
208
+ };
209
+ } catch (error) {
210
+ cleanup();
211
+ return {
212
+ success: false,
213
+ reason: "error",
214
+ message: error instanceof Error ? error.message : String(error),
215
+ elapsedMs: 0
216
+ };
217
+ } finally {
218
+ disposeInt();
219
+ disposeTerm();
220
+ }
221
+ }
222
+ //#endregion
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, runVerification as t, isExecSyncError as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "bin": {
6
6
  "tooling": "./dist/bin.mjs"
@@ -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 };