@bensandee/tooling 0.13.0 → 0.14.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/CHANGELOG.md +225 -0
- package/README.md +121 -0
- package/dist/bin.d.mts +1 -0
- package/dist/bin.mjs +223 -57
- package/dist/docker-verify/index.d.mts +117 -0
- package/dist/docker-verify/index.mjs +218 -0
- package/dist/exec-CC49vrkM.mjs +7 -0
- package/dist/index.d.mts +104 -0
- package/package.json +19 -7
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { t as isExecSyncError } from "../exec-CC49vrkM.mjs";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { z } from "zod";
|
|
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
|
|
218
|
+
export { checkHttpHealth, composeCommand, composeDown, composeLogs, composePs, composeUp, createRealExecutor, getContainerHealth, runVerification };
|
|
@@ -0,0 +1,7 @@
|
|
|
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 };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
//#region src/utils/json.d.ts
|
|
4
|
+
declare const PackageJsonSchema: z.ZodObject<{
|
|
5
|
+
name: z.ZodOptional<z.ZodString>;
|
|
6
|
+
version: z.ZodOptional<z.ZodString>;
|
|
7
|
+
private: z.ZodOptional<z.ZodBoolean>;
|
|
8
|
+
type: z.ZodOptional<z.ZodString>;
|
|
9
|
+
scripts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
10
|
+
dependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
11
|
+
devDependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
12
|
+
bin: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
|
|
13
|
+
exports: z.ZodOptional<z.ZodUnknown>;
|
|
14
|
+
main: z.ZodOptional<z.ZodString>;
|
|
15
|
+
types: z.ZodOptional<z.ZodString>;
|
|
16
|
+
typings: z.ZodOptional<z.ZodString>;
|
|
17
|
+
engines: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
18
|
+
}, z.core.$loose>;
|
|
19
|
+
type PackageJson = z.infer<typeof PackageJsonSchema>;
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/types.d.ts
|
|
22
|
+
type CiPlatform = "github" | "forgejo" | "none";
|
|
23
|
+
type ReleaseStrategy = "release-it" | "simple" | "changesets" | "none";
|
|
24
|
+
/** User's answers from the interactive prompt or CLI flags. */
|
|
25
|
+
interface ProjectConfig {
|
|
26
|
+
/** Project name (from package.json name or user input) */
|
|
27
|
+
name: string;
|
|
28
|
+
/** Whether this is a new project or existing */
|
|
29
|
+
isNew: boolean;
|
|
30
|
+
/** Project structure */
|
|
31
|
+
structure: "single" | "monorepo";
|
|
32
|
+
/** Include @bensandee/eslint-plugin oxlint plugin */
|
|
33
|
+
useEslintPlugin: boolean;
|
|
34
|
+
/** Formatter choice */
|
|
35
|
+
formatter: "oxfmt" | "prettier";
|
|
36
|
+
/** Set up vitest with a starter test */
|
|
37
|
+
setupVitest: boolean;
|
|
38
|
+
/** CI platform choice */
|
|
39
|
+
ci: CiPlatform;
|
|
40
|
+
/** Set up Renovate for automated dependency updates */
|
|
41
|
+
setupRenovate: boolean;
|
|
42
|
+
/** Release management strategy */
|
|
43
|
+
releaseStrategy: ReleaseStrategy;
|
|
44
|
+
/** Project type determines tsconfig base configuration */
|
|
45
|
+
projectType: "default" | "node" | "react" | "library";
|
|
46
|
+
/** Auto-detect and configure tsconfig bases for monorepo packages */
|
|
47
|
+
detectPackageTypes: boolean;
|
|
48
|
+
/** Target directory (default: cwd) */
|
|
49
|
+
targetDir: string;
|
|
50
|
+
}
|
|
51
|
+
/** Result from a single generator: what file was written and how. */
|
|
52
|
+
interface GeneratorResult {
|
|
53
|
+
filePath: string;
|
|
54
|
+
action: "created" | "updated" | "skipped" | "archived";
|
|
55
|
+
/** Human-readable description of what changed */
|
|
56
|
+
description: string;
|
|
57
|
+
}
|
|
58
|
+
/** Context passed to each generator function. */
|
|
59
|
+
interface GeneratorContext {
|
|
60
|
+
config: ProjectConfig;
|
|
61
|
+
/** Absolute path to target directory */
|
|
62
|
+
targetDir: string;
|
|
63
|
+
/** Pre-parsed package.json from the target directory, or undefined if missing/invalid */
|
|
64
|
+
packageJson: PackageJson | undefined;
|
|
65
|
+
/** Check whether a file exists in the target directory */
|
|
66
|
+
exists: (relativePath: string) => boolean;
|
|
67
|
+
/** Read an existing file from the target directory, returns undefined if not found */
|
|
68
|
+
read: (relativePath: string) => string | undefined;
|
|
69
|
+
/** Write a file to the target directory (creating directories as needed) */
|
|
70
|
+
write: (relativePath: string, content: string) => void;
|
|
71
|
+
/** Remove a file from the target directory (no-op if not found) */
|
|
72
|
+
remove: (relativePath: string) => void;
|
|
73
|
+
/** Prompt user for conflict resolution on non-mergeable files */
|
|
74
|
+
confirmOverwrite: (relativePath: string) => Promise<"overwrite" | "skip">;
|
|
75
|
+
}
|
|
76
|
+
/** Generator function signature. */
|
|
77
|
+
type Generator = (ctx: GeneratorContext) => Promise<GeneratorResult>;
|
|
78
|
+
/** State detected from an existing project directory. */
|
|
79
|
+
interface DetectedProjectState {
|
|
80
|
+
hasPackageJson: boolean;
|
|
81
|
+
hasTsconfig: boolean;
|
|
82
|
+
hasOxlintConfig: boolean;
|
|
83
|
+
/** Legacy .oxlintrc.json found (should be migrated to oxlint.config.ts) */
|
|
84
|
+
hasLegacyOxlintJson: boolean;
|
|
85
|
+
hasGitignore: boolean;
|
|
86
|
+
hasVitestConfig: boolean;
|
|
87
|
+
hasTsdownConfig: boolean;
|
|
88
|
+
hasPnpmWorkspace: boolean;
|
|
89
|
+
hasKnipConfig: boolean;
|
|
90
|
+
hasRenovateConfig: boolean;
|
|
91
|
+
hasReleaseItConfig: boolean;
|
|
92
|
+
hasSimpleReleaseConfig: boolean;
|
|
93
|
+
hasChangesetsConfig: boolean;
|
|
94
|
+
/** Legacy tooling configs found */
|
|
95
|
+
legacyConfigs: LegacyConfig[];
|
|
96
|
+
}
|
|
97
|
+
declare const LEGACY_TOOLS: readonly ["eslint", "prettier", "jest", "webpack", "rollup"];
|
|
98
|
+
type LegacyTool = (typeof LEGACY_TOOLS)[number];
|
|
99
|
+
interface LegacyConfig {
|
|
100
|
+
tool: LegacyTool;
|
|
101
|
+
files: string[];
|
|
102
|
+
}
|
|
103
|
+
//#endregion
|
|
104
|
+
export { type DetectedProjectState, type Generator, type GeneratorContext, type GeneratorResult, type LegacyConfig, type ProjectConfig };
|
package/package.json
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bensandee/tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tooling": "./dist/bin.mjs"
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
|
-
"dist"
|
|
9
|
+
"dist",
|
|
10
|
+
"CHANGELOG.md"
|
|
10
11
|
],
|
|
11
12
|
"type": "module",
|
|
12
13
|
"imports": {
|
|
13
|
-
"#src
|
|
14
|
+
"#src/*.ts": "./src/*.ts"
|
|
14
15
|
},
|
|
15
16
|
"exports": {
|
|
16
|
-
".":
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.mts",
|
|
19
|
+
"default": "./dist/index.mjs"
|
|
20
|
+
},
|
|
17
21
|
"./bin": "./dist/bin.mjs",
|
|
22
|
+
"./docker-verify": {
|
|
23
|
+
"types": "./dist/docker-verify/index.d.mts",
|
|
24
|
+
"default": "./dist/docker-verify/index.mjs"
|
|
25
|
+
},
|
|
18
26
|
"./package.json": "./package.json"
|
|
19
27
|
},
|
|
20
28
|
"publishConfig": {
|
|
@@ -27,14 +35,18 @@
|
|
|
27
35
|
"jsonc-parser": "^3.3.1",
|
|
28
36
|
"yaml": "^2.8.2",
|
|
29
37
|
"zod": "^4.3.6",
|
|
30
|
-
"@bensandee/common": "0.1.
|
|
38
|
+
"@bensandee/common": "0.1.2"
|
|
31
39
|
},
|
|
32
40
|
"devDependencies": {
|
|
33
41
|
"@types/node": "24.12.0",
|
|
34
|
-
"tsdown": "0.21.
|
|
42
|
+
"tsdown": "0.21.2",
|
|
35
43
|
"typescript": "5.9.3",
|
|
36
44
|
"vitest": "4.0.18",
|
|
37
|
-
"@bensandee/config": "0.
|
|
45
|
+
"@bensandee/config": "0.8.1"
|
|
46
|
+
},
|
|
47
|
+
"optionalDependencies": {
|
|
48
|
+
"@changesets/cli": "^2.29.4",
|
|
49
|
+
"commit-and-tag-version": "^12.5.0"
|
|
38
50
|
},
|
|
39
51
|
"scripts": {
|
|
40
52
|
"build": "tsdown",
|