@bensandee/tooling 0.64.1 → 0.65.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.
@@ -109,7 +109,7 @@ declare function createRealExecutor(): DockerCheckExecutor;
109
109
  //#endregion
110
110
  //#region src/docker-check/check.d.ts
111
111
  /** Run the full Docker check lifecycle. */
112
- declare function runDockerCheck(executor: DockerCheckExecutor, config: CheckConfig): Promise<CheckResult>;
112
+ declare function runDockerCheck(executor: DockerCheckExecutor, rawConfig: CheckConfig): Promise<CheckResult>;
113
113
  //#endregion
114
114
  //#region src/docker-check/compose.d.ts
115
115
  /** Zod schema for a single container entry from `docker compose ps --format json`. */
@@ -242,6 +242,12 @@ declare const ToolingConfigSchema: z.ZodObject<{
242
242
  historyBranch: z.ZodOptional<z.ZodString>;
243
243
  tolerance: z.ZodOptional<z.ZodNumber>;
244
244
  }, z.core.$strip>>;
245
+ docs: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
246
+ enforced: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodArray<z.ZodString>]>>;
247
+ dirs: z.ZodOptional<z.ZodArray<z.ZodString>>;
248
+ staleDays: z.ZodOptional<z.ZodNumber>;
249
+ staleEnforced: z.ZodOptional<z.ZodBoolean>;
250
+ }, z.core.$strict>]>>;
245
251
  deploy: z.ZodOptional<z.ZodObject<{
246
252
  dispatchRepo: z.ZodString;
247
253
  stack: z.ZodOptional<z.ZodString>;
@@ -250,6 +256,22 @@ declare const ToolingConfigSchema: z.ZodObject<{
250
256
  }, z.core.$strict>>;
251
257
  pnpmWorkspace: z.ZodOptional<z.ZodLiteral<false>>;
252
258
  defaultBranch: z.ZodOptional<z.ZodString>;
259
+ unmanagedDevDependencies: z.ZodOptional<z.ZodArray<z.ZodString>>;
260
+ stack: z.ZodOptional<z.ZodEnum<{
261
+ none: "none";
262
+ typescript: "typescript";
263
+ javascript: "javascript";
264
+ }>>;
265
+ builder: z.ZodOptional<z.ZodEnum<{
266
+ none: "none";
267
+ custom: "custom";
268
+ tsdown: "tsdown";
269
+ vite: "vite";
270
+ }>>;
271
+ checks: z.ZodOptional<z.ZodObject<{
272
+ add: z.ZodOptional<z.ZodArray<z.ZodString>>;
273
+ skip: z.ZodOptional<z.ZodArray<z.ZodString>>;
274
+ }, z.core.$strict>>;
253
275
  }, z.core.$strict>;
254
276
  type ToolingConfig = z.infer<typeof ToolingConfigSchema>;
255
277
  //#endregion
@@ -1,2 +1,2 @@
1
- import { a as composeArgs, c as composePs, f as lintComposeFiles, i as getContainerHealth, l as composeUp, n as runDockerCheck, o as composeDown, r as checkHttpHealth, s as composeLogs, t as deriveProjectName, u as createRealExecutor } from "../project-name-CHPvgMTP.mjs";
1
+ import { a as composeArgs, c as composePs, d as lintComposeFiles, i as getContainerHealth, l as composeUp, n as runDockerCheck, o as composeDown, r as checkHttpHealth, s as composeLogs, t as deriveProjectName, u as createRealExecutor } from "../project-name-CnyTIy5a.mjs";
2
2
  export { checkHttpHealth, composeArgs, composeDown, composeLogs, composePs, composeUp, createRealExecutor, deriveProjectName, getContainerHealth, lintComposeFiles, runDockerCheck };
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { a as LegacyConfig, i as GeneratorResult, n as Generator, o as ProjectConfig, r as GeneratorContext, t as DetectedProjectState } from "./types-CMKT2loB.mjs";
1
+ import { a as LegacyConfig, i as GeneratorResult, n as Generator, o as ProjectConfig, r as GeneratorContext, t as DetectedProjectState } from "./types-C6cLSf_a.mjs";
2
2
  export type { DetectedProjectState, Generator, GeneratorContext, GeneratorResult, LegacyConfig, ProjectConfig };
@@ -1,27 +1,11 @@
1
- import * as clack from "@clack/prompts";
2
1
  import path from "node:path";
3
2
  import { existsSync, readFileSync, readdirSync } from "node:fs";
4
- import { execFileSync } from "node:child_process";
3
+ import { execFileSync, spawnSync } from "node:child_process";
5
4
  import { z } from "zod";
6
5
  import { parse } from "yaml";
7
- //#region src/utils/log.ts
8
- const out = (msg) => console.log(msg);
9
- const isCI = Boolean(process.env["CI"]);
10
- const log = isCI ? {
11
- info: out,
12
- warn: (msg) => out(`[warn] ${msg}`),
13
- error: (msg) => out(`[error] ${msg}`),
14
- success: (msg) => out(`✓ ${msg}`)
15
- } : clack.log;
16
- function note(body, title) {
17
- if (isCI) {
18
- if (title) out(`--- ${title} ---`);
19
- out(body);
20
- } else clack.note(body, title);
21
- }
22
- //#endregion
23
6
  //#region src/utils/debug.ts
24
7
  const REDACT_PATTERNS = [/x-access-token:[^\s@]+/g, /Authorization:\s*(?:token|Bearer)\s+\S+/gi];
8
+ const debugOut = (msg) => console.error(msg);
25
9
  /** Redact secrets (tokens, credentials) from text before logging. */
26
10
  function redact(text) {
27
11
  let result = text;
@@ -34,7 +18,7 @@ function isEnvVerbose() {
34
18
  }
35
19
  /** Log a debug message when verbose mode is enabled. */
36
20
  function debug(config, message) {
37
- if (config.verbose) log.info(`[debug] ${message}`);
21
+ if (config.verbose) debugOut(`[debug] ${message}`);
38
22
  }
39
23
  /** Log the result of an exec call when verbose mode is enabled. */
40
24
  function debugExec(config, label, result) {
@@ -42,7 +26,7 @@ function debugExec(config, label, result) {
42
26
  const lines = [`[debug] ${redact(label)} (exit code ${String(result.exitCode)})`];
43
27
  if (result.stdout.trim()) lines.push(` stdout: ${redact(result.stdout.trim())}`);
44
28
  if (result.stderr.trim()) lines.push(` stderr: ${redact(result.stderr.trim())}`);
45
- log.info(lines.join("\n"));
29
+ debugOut(lines.join("\n"));
46
30
  }
47
31
  //#endregion
48
32
  //#region src/docker-check/compose-lint.ts
@@ -54,7 +38,7 @@ const ComposeFileShape = z.looseObject({ name: z.unknown().optional() });
54
38
  */
55
39
  const COMPOSE_DIR_CANDIDATES = [".", "docker"];
56
40
  /** Base compose file names whose project namespace defaults to dir basename. */
57
- const BASE_COMPOSE_FILES = new Set([
41
+ const BASE_COMPOSE_FILES = /* @__PURE__ */ new Set([
58
42
  "docker-compose.yml",
59
43
  "docker-compose.yaml",
60
44
  "compose.yml",
@@ -187,47 +171,29 @@ function readTopLevelName(absPath) {
187
171
  return value;
188
172
  }
189
173
  //#endregion
190
- //#region src/utils/exec.ts
191
- /** Type guard for `execSync` errors that carry stdout/stderr/status. */
192
- function isExecSyncError(err) {
193
- 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";
194
- }
195
- //#endregion
196
174
  //#region src/docker-check/executor.ts
197
175
  /** Create an executor that runs real commands, fetches, and manages process signals. */
198
176
  function createRealExecutor() {
199
177
  return {
200
178
  exec(file, args, options) {
201
- try {
202
- return {
203
- stdout: execFileSync(file, args, {
204
- cwd: options?.cwd,
205
- env: options?.env ? {
206
- ...process.env,
207
- ...options.env
208
- } : void 0,
209
- encoding: "utf-8",
210
- stdio: [
211
- "pipe",
212
- "pipe",
213
- "pipe"
214
- ]
215
- }),
216
- stderr: "",
217
- exitCode: 0
218
- };
219
- } catch (err) {
220
- if (isExecSyncError(err)) return {
221
- stdout: err.stdout,
222
- stderr: err.stderr,
223
- exitCode: err.status
224
- };
225
- return {
226
- stdout: "",
227
- stderr: "",
228
- exitCode: 1
229
- };
230
- }
179
+ const result = spawnSync(file, args, {
180
+ cwd: options?.cwd,
181
+ env: options?.env ? {
182
+ ...process.env,
183
+ ...options.env
184
+ } : void 0,
185
+ encoding: "utf-8",
186
+ stdio: [
187
+ "pipe",
188
+ "pipe",
189
+ "pipe"
190
+ ]
191
+ });
192
+ return {
193
+ stdout: result.stdout ?? "",
194
+ stderr: result.stderr ?? "",
195
+ exitCode: result.status ?? 1
196
+ };
231
197
  },
232
198
  execInherit(file, args, options) {
233
199
  execFileSync(file, args, {
@@ -270,6 +236,55 @@ function composeArgs(config) {
270
236
  if (config.envFile) args.push("--env-file", config.envFile);
271
237
  return args;
272
238
  }
239
+ /**
240
+ * Compose's unset-interpolation warning. The variable name is quoted, and the
241
+ * quotes arrive backslash-escaped when stderr is a pipe (which it always is
242
+ * here) but bare when it is a TTY — so both forms are accepted.
243
+ */
244
+ const UNSET_VARIABLE_WARNING = /The\s+\\?"([^"\\]+)\\?"\s+variable is not set/g;
245
+ /** Names of the interpolation variables compose warned were unset, in order, de-duplicated. */
246
+ function parseUnsetVariables(stderr) {
247
+ const names = /* @__PURE__ */ new Set();
248
+ for (const match of stderr.matchAll(UNSET_VARIABLE_WARNING)) {
249
+ const name = match[1];
250
+ if (name) names.add(name);
251
+ }
252
+ return [...names];
253
+ }
254
+ /**
255
+ * Ask compose which interpolation variables are unset, and map each to an empty
256
+ * string so it can be injected into the child env of every later compose call.
257
+ *
258
+ * Compose warns `The "FOO" variable is not set` once per file per invocation —
259
+ * including on `down` — for a `${FOO}` with no default. Nothing expressible in
260
+ * the check overlay silences that: interpolation happens per-file, before the
261
+ * merge. Setting the variable does silence it, and setting it to the *empty
262
+ * string* is what compose already substitutes, so the value each container sees
263
+ * is unchanged. A non-empty placeholder would not be: a service that dials
264
+ * `${LYRION_HOST}` would start resolving a hostname where today it gets "".
265
+ *
266
+ * Detection is delegated to compose rather than re-implementing its grammar:
267
+ * `config` has already consulted `--env-file` and the process env, so it reports
268
+ * exactly the variables that still need filling, in every position (`image:`,
269
+ * `ports:`, `volumes:`, …) and every form, with `$$` escapes and `${FOO:-def}`
270
+ * defaults already accounted for. If the wording ever changes we fill nothing
271
+ * and the warnings simply come back — a `${FOO:?required}` error is never masked.
272
+ *
273
+ * Variables already present in `config.env` are left alone: the free-port logic
274
+ * owns PORT, and an empty string there would unbind the published port.
275
+ */
276
+ function fillUnsetVariables(executor, config) {
277
+ const result = executor.exec("docker", [...composeArgs(config), "config"], {
278
+ cwd: config.cwd,
279
+ env: config.env
280
+ });
281
+ const filled = {};
282
+ for (const name of parseUnsetVariables(result.stderr)) {
283
+ if (config.env && name in config.env) continue;
284
+ filled[name] = "";
285
+ }
286
+ return filled;
287
+ }
273
288
  /** Run the build command if configured. */
274
289
  function buildImages(executor, config) {
275
290
  if (!config.buildCommand) return;
@@ -372,6 +387,33 @@ async function checkHttpHealth(executor, check) {
372
387
  //#region src/docker-check/check.ts
373
388
  const DEFAULT_TIMEOUT_MS = 12e4;
374
389
  const DEFAULT_POLL_INTERVAL_MS = 5e3;
390
+ /**
391
+ * Fill any compose interpolation variable that is still unset with an empty
392
+ * string, so `${FOO}` with no default stops warning on every compose call —
393
+ * `up` and, after the check has already passed, teardown. See
394
+ * `fillUnsetVariables` for why empty (not a placeholder) and why compose itself
395
+ * decides which variables need it.
396
+ *
397
+ * This runs here rather than in the `docker:check` command so the env reaches
398
+ * every compose invocation *and* the smoketest through one `CheckConfig`, and so
399
+ * programmatic consumers of the `docker-check` export get it too.
400
+ */
401
+ function withFilledVariables(executor, config) {
402
+ const filled = fillUnsetVariables(executor, config.compose);
403
+ const names = Object.keys(filled);
404
+ if (names.length === 0) return config;
405
+ executor.log(`Unset compose variables set to an empty string: ${names.join(", ")}. To supply real values, put them in ${config.compose.envFile ?? "docker-compose.check.env"} beside the compose file.`);
406
+ return {
407
+ ...config,
408
+ compose: {
409
+ ...config.compose,
410
+ env: {
411
+ ...config.compose.env,
412
+ ...filled
413
+ }
414
+ }
415
+ };
416
+ }
375
417
  /** Probe any not-yet-healthy HTTP endpoints, flipping them to healthy in `healthStatus`. */
376
418
  async function updateHttpHealth(executor, healthChecks, healthStatus) {
377
419
  for (const check of healthChecks) {
@@ -409,7 +451,8 @@ function runSmoketestStep(executor, config, cleanup, startTime) {
409
451
  }
410
452
  }
411
453
  /** Run the full Docker check lifecycle. */
412
- async function runDockerCheck(executor, config) {
454
+ async function runDockerCheck(executor, rawConfig) {
455
+ const config = withFilledVariables(executor, rawConfig);
413
456
  const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
414
457
  const pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
415
458
  const { compose } = config;
@@ -540,4 +583,4 @@ function sanitize(name) {
540
583
  return name.replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "repo";
541
584
  }
542
585
  //#endregion
543
- export { log as _, composeArgs as a, composePs as c, isExecSyncError as d, lintComposeFiles as f, redact as g, isEnvVerbose as h, getContainerHealth as i, composeUp as l, debugExec as m, runDockerCheck as n, composeDown as o, debug as p, checkHttpHealth as r, composeLogs as s, deriveProjectName as t, createRealExecutor as u, note as v };
586
+ export { composeArgs as a, composePs as c, lintComposeFiles as d, debug as f, redact as h, getContainerHealth as i, composeUp as l, isEnvVerbose as m, runDockerCheck as n, composeDown as o, debugExec as p, checkHttpHealth as r, composeLogs as s, deriveProjectName as t, createRealExecutor as u };
@@ -9,6 +9,7 @@ declare const PackageJsonSchema: z.ZodObject<{
9
9
  scripts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
10
10
  dependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
11
11
  devDependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
12
+ peerDependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
12
13
  bin: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
13
14
  exports: z.ZodOptional<z.ZodUnknown>;
14
15
  main: z.ZodOptional<z.ZodString>;
@@ -37,6 +38,10 @@ type PackageJson = z.infer<typeof PackageJsonSchema>;
37
38
  type CiPlatform = "github" | "forgejo" | "none";
38
39
  type ReleaseStrategy = "release-it" | "simple" | "changesets" | "none";
39
40
  type Formatter = "oxfmt" | "prettier";
41
+ /** Whether the repo has TS/JS source at all. Never inferred from absence. */
42
+ type Stack = "typescript" | "javascript" | "none";
43
+ /** What compiles the project, if anything. */
44
+ type Builder = "tsdown" | "vite" | "custom" | "none";
40
45
  /** User's answers from the interactive prompt or CLI flags. */
41
46
  interface ProjectConfig {
42
47
  /** Project name (from package.json name or user input) */
@@ -47,6 +52,10 @@ interface ProjectConfig {
47
52
  useEslintPlugin: boolean;
48
53
  /** Formatter choice */
49
54
  formatter: Formatter;
55
+ /** Whether the repo has TS/JS source at all */
56
+ stack: Stack;
57
+ /** What compiles the project */
58
+ builder: Builder;
50
59
  /** Set up vitest with a starter test */
51
60
  setupVitest: boolean;
52
61
  /** CI platform choice */
@@ -88,6 +97,13 @@ interface GeneratorContext {
88
97
  write: (relativePath: string, content: string) => void;
89
98
  /** Remove a file from the target directory (no-op if not found) */
90
99
  remove: (relativePath: string) => void;
100
+ /**
101
+ * Rewrite generated content the way the target repo's own formatter would — so a
102
+ * generated file is a fixed point of `pnpm format` and can't drift against
103
+ * `repo:sync --check`. Returns the content unchanged when the formatter isn't
104
+ * installed yet. See `utils/format.ts`.
105
+ */
106
+ format: (relativePath: string, content: string) => string;
91
107
  /** Prompt user for conflict resolution on non-mergeable files */
92
108
  confirmOverwrite: (relativePath: string) => Promise<"overwrite" | "skip">;
93
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.64.1",
3
+ "version": "0.65.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -47,16 +47,16 @@
47
47
  "zod": "^4.4.3"
48
48
  },
49
49
  "devDependencies": {
50
- "@bensandee/config": "0.12.0",
50
+ "@bensandee/config": "0.13.1",
51
51
  "@changesets/cli": "2.31.0",
52
52
  "@release-it/bumper": "7.0.6",
53
- "@types/node": "24.13.0",
53
+ "@types/node": "24.13.2",
54
54
  "@types/picomatch": "4.0.3",
55
55
  "commit-and-tag-version": "12.7.3",
56
- "prettier": "3.8.3",
56
+ "prettier": "3.8.4",
57
57
  "release-it": "20.2.0",
58
- "tsdown": "0.22.2",
59
- "@bensandee/claude-hooks": "0.8.2"
58
+ "tsdown": "0.22.3",
59
+ "@bensandee/claude-hooks": "0.9.0"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "tsdown",
@@ -314,6 +314,52 @@
314
314
  },
315
315
  "additionalProperties": false
316
316
  },
317
+ "docs": {
318
+ "description": "Plan/spec lifecycle gate (`bst docs:check`). Overrides only — dirs are discovered by convention. Set to false to disable. Violations are advisory until a pair is named in `enforced`.",
319
+ "anyOf": [
320
+ {
321
+ "type": "boolean",
322
+ "const": false
323
+ },
324
+ {
325
+ "type": "object",
326
+ "properties": {
327
+ "enforced": {
328
+ "description": "Which discovered pairs fail the build. true = all; an array names active dirs (e.g. [\"docs/plans\"]); default false = every violation is advisory.",
329
+ "anyOf": [
330
+ {
331
+ "type": "boolean"
332
+ },
333
+ {
334
+ "type": "array",
335
+ "items": {
336
+ "type": "string"
337
+ }
338
+ }
339
+ ]
340
+ },
341
+ "dirs": {
342
+ "description": "Extra active doc dirs beyond convention (e.g. [\"docs/rfcs\"]). Each retires into its own completed/ subdir.",
343
+ "type": "array",
344
+ "items": {
345
+ "type": "string"
346
+ }
347
+ },
348
+ "staleDays": {
349
+ "description": "Days before an untouched active doc is flagged (default 30; 0 disables the rule).",
350
+ "type": "integer",
351
+ "minimum": 0,
352
+ "maximum": 9007199254740991
353
+ },
354
+ "staleEnforced": {
355
+ "description": "Make the staleness rule fatal inside an enforced pair (default false: staleness is always advisory).",
356
+ "type": "boolean"
357
+ }
358
+ },
359
+ "additionalProperties": false
360
+ }
361
+ ]
362
+ },
317
363
  "deploy": {
318
364
  "description": "Generate a deploy-dispatch step in the release workflow that triggers a workflow_dispatch event in another repo after a successful release. The resolved release token must have rights on `dispatchRepo`.",
319
365
  "type": "object",
@@ -346,6 +392,44 @@
346
392
  "defaultBranch": {
347
393
  "description": "Repository default branch name. Used by `checks:run` to decide when to enable coverage automatically. Default: detected via git (origin/HEAD), falling back to 'main'.",
348
394
  "type": "string"
395
+ },
396
+ "unmanagedDevDependencies": {
397
+ "description": "devDependency names repo:sync must never add or re-pin. Escape hatch for holding a tooling-managed dep (oxlint, vitest, knip, ...) at a deliberately off-canonical version; without it, repo:sync raises any managed devDep whose version has drifted below the canonical pin.",
398
+ "type": "array",
399
+ "items": {
400
+ "type": "string"
401
+ }
402
+ },
403
+ "stack": {
404
+ "description": "Whether the repo has TS/JS source. Detected from source evidence; TypeScript is never inferred from absence.",
405
+ "type": "string",
406
+ "enum": ["typescript", "javascript", "none"]
407
+ },
408
+ "builder": {
409
+ "description": "What compiles the project. Detected from the package.json build script, then config-file presence.",
410
+ "type": "string",
411
+ "enum": ["tsdown", "vite", "custom", "none"]
412
+ },
413
+ "checks": {
414
+ "description": "Check-pipeline overrides: extra steps to add, and steps to skip (unioned with CI defaults).",
415
+ "type": "object",
416
+ "properties": {
417
+ "add": {
418
+ "description": "Extra package.json scripts to run as first-class check steps, appended to the built-in list (e.g. ['lint:php']). An added step with no matching script is a hard failure.",
419
+ "type": "array",
420
+ "items": {
421
+ "type": "string"
422
+ }
423
+ },
424
+ "skip": {
425
+ "description": "Check steps to skip, unioned with the built-in CI defaults (never replacing them). Supports glob patterns.",
426
+ "type": "array",
427
+ "items": {
428
+ "type": "string"
429
+ }
430
+ }
431
+ },
432
+ "additionalProperties": false
349
433
  }
350
434
  },
351
435
  "additionalProperties": false