@bensandee/tooling 0.18.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
|
|
165
|
+
## Docker check
|
|
166
166
|
|
|
167
|
-
The `@bensandee/tooling/docker-
|
|
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,
|
|
173
|
-
import type {
|
|
172
|
+
import { createRealExecutor, runDockerCheck } from "@bensandee/tooling/docker-check";
|
|
173
|
+
import type { CheckConfig } from "@bensandee/tooling/docker-check";
|
|
174
174
|
|
|
175
|
-
const config:
|
|
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
|
|
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
|
-
| `
|
|
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
|
|
215
|
-
|
|
|
216
|
-
| `
|
|
217
|
-
| `ComposeConfig`
|
|
218
|
-
| `HttpHealthCheck`
|
|
219
|
-
| `
|
|
220
|
-
| `
|
|
221
|
-
| `ContainerInfo`
|
|
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 { l as createRealExecutor$1, t as
|
|
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, parse as parse$1, 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() }).
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
1375
|
-
|
|
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({}) }).
|
|
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,7 +4299,7 @@ const dockerBuildCommand = defineCommand({
|
|
|
4257
4299
|
}
|
|
4258
4300
|
});
|
|
4259
4301
|
//#endregion
|
|
4260
|
-
//#region src/docker-
|
|
4302
|
+
//#region src/docker-check/detect.ts
|
|
4261
4303
|
/** Compose file names to scan, in priority order. */
|
|
4262
4304
|
const COMPOSE_FILE_CANDIDATES = [
|
|
4263
4305
|
"docker-compose.yaml",
|
|
@@ -4269,15 +4311,29 @@ const COMPOSE_FILE_CANDIDATES = [
|
|
|
4269
4311
|
const ComposePortSchema = z.union([z.string(), z.object({
|
|
4270
4312
|
published: z.union([z.string(), z.number()]),
|
|
4271
4313
|
target: z.union([z.string(), z.number()]).optional()
|
|
4272
|
-
}).
|
|
4314
|
+
}).loose()]);
|
|
4273
4315
|
const ComposeServiceSchema = z.object({
|
|
4274
4316
|
ports: z.array(ComposePortSchema).optional(),
|
|
4275
4317
|
healthcheck: z.unknown().optional()
|
|
4276
|
-
}).
|
|
4277
|
-
const ComposeFileSchema = z.object({ services: z.record(z.string(), ComposeServiceSchema).optional() }).
|
|
4278
|
-
/**
|
|
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. */
|
|
4279
4324
|
function detectComposeFiles(cwd) {
|
|
4280
|
-
|
|
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
|
+
};
|
|
4281
4337
|
}
|
|
4282
4338
|
/** Parse a single port mapping string and extract the host port. */
|
|
4283
4339
|
function parsePortString(port) {
|
|
@@ -4345,20 +4401,61 @@ function deriveHealthChecks(services) {
|
|
|
4345
4401
|
url: `http://localhost:${String(s.hostPort)}/`
|
|
4346
4402
|
}));
|
|
4347
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
|
+
}
|
|
4348
4439
|
/** Auto-detect compose config from conventional file locations. */
|
|
4349
|
-
function
|
|
4350
|
-
const
|
|
4351
|
-
if (
|
|
4352
|
-
const
|
|
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);
|
|
4353
4447
|
const healthChecks = deriveHealthChecks(services);
|
|
4354
4448
|
return {
|
|
4355
|
-
|
|
4449
|
+
composeCwd: dir !== cwd ? dir : void 0,
|
|
4450
|
+
composeFiles: files,
|
|
4451
|
+
checkOverlay,
|
|
4452
|
+
envFile,
|
|
4356
4453
|
services: services.map((s) => s.name),
|
|
4357
4454
|
healthChecks: healthChecks.length > 0 ? healthChecks : void 0
|
|
4358
4455
|
};
|
|
4359
4456
|
}
|
|
4360
4457
|
//#endregion
|
|
4361
|
-
//#region src/commands/docker-
|
|
4458
|
+
//#region src/commands/docker-check.ts
|
|
4362
4459
|
/** Convert declarative health checks to functional ones. */
|
|
4363
4460
|
function toHttpHealthChecks(checks) {
|
|
4364
4461
|
return checks.map((check) => ({
|
|
@@ -4367,10 +4464,23 @@ function toHttpHealthChecks(checks) {
|
|
|
4367
4464
|
validate: async (res) => check.status ? res.status === check.status : res.ok
|
|
4368
4465
|
}));
|
|
4369
4466
|
}
|
|
4370
|
-
|
|
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({
|
|
4371
4481
|
meta: {
|
|
4372
|
-
name: "docker:
|
|
4373
|
-
description: "
|
|
4482
|
+
name: "docker:check",
|
|
4483
|
+
description: "Check Docker Compose stack health by auto-detecting services from compose files"
|
|
4374
4484
|
},
|
|
4375
4485
|
args: {
|
|
4376
4486
|
timeout: {
|
|
@@ -4384,24 +4494,48 @@ const dockerVerifyCommand = defineCommand({
|
|
|
4384
4494
|
},
|
|
4385
4495
|
async run({ args }) {
|
|
4386
4496
|
const cwd = process.cwd();
|
|
4387
|
-
|
|
4388
|
-
|
|
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
|
+
}
|
|
4389
4510
|
if (!defaults.services || defaults.services.length === 0) throw new FatalError("No services found in compose files.");
|
|
4390
|
-
const
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
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
|
+
}
|
|
4405
4539
|
}
|
|
4406
4540
|
});
|
|
4407
4541
|
//#endregion
|
|
@@ -4409,7 +4543,7 @@ const dockerVerifyCommand = defineCommand({
|
|
|
4409
4543
|
const main = defineCommand({
|
|
4410
4544
|
meta: {
|
|
4411
4545
|
name: "tooling",
|
|
4412
|
-
version: "0.
|
|
4546
|
+
version: "0.19.0",
|
|
4413
4547
|
description: "Bootstrap and maintain standardized TypeScript project tooling"
|
|
4414
4548
|
},
|
|
4415
4549
|
subCommands: {
|
|
@@ -4422,10 +4556,10 @@ const main = defineCommand({
|
|
|
4422
4556
|
"release:simple": releaseSimpleCommand,
|
|
4423
4557
|
"docker:publish": publishDockerCommand,
|
|
4424
4558
|
"docker:build": dockerBuildCommand,
|
|
4425
|
-
"docker:
|
|
4559
|
+
"docker:check": dockerCheckCommand
|
|
4426
4560
|
}
|
|
4427
4561
|
});
|
|
4428
|
-
console.log(`@bensandee/tooling v0.
|
|
4562
|
+
console.log(`@bensandee/tooling v0.19.0`);
|
|
4429
4563
|
runMain(main);
|
|
4430
4564
|
//#endregion
|
|
4431
4565
|
export {};
|
|
@@ -6,7 +6,7 @@ function isExecSyncError(err) {
|
|
|
6
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
7
|
}
|
|
8
8
|
//#endregion
|
|
9
|
-
//#region src/docker-
|
|
9
|
+
//#region src/docker-check/executor.ts
|
|
10
10
|
/** Create an executor that runs real commands, fetches, and manages process signals. */
|
|
11
11
|
function createRealExecutor() {
|
|
12
12
|
return {
|
|
@@ -66,7 +66,7 @@ function createRealExecutor() {
|
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
68
|
//#endregion
|
|
69
|
-
//#region src/docker-
|
|
69
|
+
//#region src/docker-check/compose.ts
|
|
70
70
|
/** Zod schema for a single container entry from `docker compose ps --format json`. */
|
|
71
71
|
const ContainerInfoSchema = z.object({
|
|
72
72
|
Service: z.string(),
|
|
@@ -121,7 +121,7 @@ function composePs(executor, config) {
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
//#endregion
|
|
124
|
-
//#region src/docker-
|
|
124
|
+
//#region src/docker-check/health.ts
|
|
125
125
|
/** Look up the health status of a specific service from container info. */
|
|
126
126
|
function getContainerHealth(containers, serviceName) {
|
|
127
127
|
return containers.find((c) => c.Service === serviceName)?.Health ?? "unknown";
|
|
@@ -136,11 +136,11 @@ async function checkHttpHealth(executor, check) {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
//#endregion
|
|
139
|
-
//#region src/docker-
|
|
139
|
+
//#region src/docker-check/check.ts
|
|
140
140
|
const DEFAULT_TIMEOUT_MS = 12e4;
|
|
141
141
|
const DEFAULT_POLL_INTERVAL_MS = 5e3;
|
|
142
|
-
/** Run the full Docker
|
|
143
|
-
async function
|
|
142
|
+
/** Run the full Docker check lifecycle. */
|
|
143
|
+
async function runDockerCheck(executor, config) {
|
|
144
144
|
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
145
145
|
const pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
146
146
|
const { compose } = config;
|
|
@@ -183,7 +183,7 @@ async function runVerification(executor, config) {
|
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
if ([...healthStatus.values()].every(Boolean)) {
|
|
186
|
-
executor.log("
|
|
186
|
+
executor.log("Check successful! All systems operational.");
|
|
187
187
|
cleanup();
|
|
188
188
|
return {
|
|
189
189
|
success: true,
|
|
@@ -220,4 +220,4 @@ async function runVerification(executor, config) {
|
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
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,
|
|
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-
|
|
16
|
+
//#region src/docker-check/types.d.ts
|
|
17
17
|
/** Abstraction over side effects for testability. */
|
|
18
|
-
interface
|
|
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.
|
|
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
|
|
57
|
-
interface
|
|
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
|
|
72
|
-
type
|
|
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-
|
|
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():
|
|
84
|
+
declare function createRealExecutor(): DockerCheckExecutor;
|
|
85
85
|
//#endregion
|
|
86
|
-
//#region src/docker-
|
|
87
|
-
/** Run the full Docker
|
|
88
|
-
declare function
|
|
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-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
109
|
+
declare function composePs(executor: DockerCheckExecutor, config: ComposeConfig): ContainerInfo[];
|
|
110
110
|
//#endregion
|
|
111
|
-
//#region src/docker-
|
|
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:
|
|
115
|
+
declare function checkHttpHealth(executor: DockerCheckExecutor, check: HttpHealthCheck): Promise<boolean>;
|
|
116
116
|
//#endregion
|
|
117
|
-
export { type
|
|
117
|
+
export { type CheckConfig, type CheckResult, type ComposeConfig, type ContainerInfo, type DockerCheckExecutor, type HttpHealthCheck, checkHttpHealth, composeCommand, composeDown, composeLogs, composePs, composeUp, createRealExecutor, getContainerHealth, runDockerCheck };
|
|
@@ -1,2 +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
|
|
2
|
-
export { checkHttpHealth, composeCommand, composeDown, composeLogs, composePs, composeUp, createRealExecutor, getContainerHealth,
|
|
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.
|
|
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-
|
|
22
|
-
"types": "./dist/docker-
|
|
23
|
-
"default": "./dist/docker-
|
|
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
|
},
|