@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/dist/bin.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { t as isExecSyncError } from "./exec-CC49vrkM.mjs";
2
3
  import { defineCommand, runMain } from "citty";
3
4
  import * as p from "@clack/prompts";
4
5
  import { execSync } from "node:child_process";
@@ -106,7 +107,7 @@ function detectProject(targetDir) {
106
107
  hasKnipConfig: exists("knip.json") || exists("knip.jsonc") || exists("knip.ts") || exists("knip.mts") || exists("knip.config.ts") || exists("knip.config.mts"),
107
108
  hasRenovateConfig: exists("renovate.json") || exists("renovate.json5") || exists(".renovaterc") || exists(".renovaterc.json") || exists(".github/renovate.json") || exists(".github/renovate.json5"),
108
109
  hasReleaseItConfig: exists(".release-it.json") || exists(".release-it.yaml") || exists(".release-it.toml"),
109
- hasCommitAndTagVersionConfig: exists(".versionrc") || exists(".versionrc.json") || exists(".versionrc.js"),
110
+ hasSimpleReleaseConfig: exists(".versionrc") || exists(".versionrc.json") || exists(".versionrc.js"),
110
111
  hasChangesetsConfig: exists(".changeset/config.json"),
111
112
  legacyConfigs: detectLegacyConfigs(targetDir)
112
113
  };
@@ -208,7 +209,7 @@ function getMonorepoPackages(targetDir) {
208
209
  function isCancelled(value) {
209
210
  return p.isCancel(value);
210
211
  }
211
- async function runInitPrompts(targetDir) {
212
+ async function runInitPrompts(targetDir, saved) {
212
213
  p.intro("@bensandee/tooling repo:init");
213
214
  const existingPkg = readPackageJson(targetDir);
214
215
  const detected = detectProject(targetDir);
@@ -217,7 +218,7 @@ async function runInitPrompts(targetDir) {
217
218
  const detectedMonorepo = detectMonorepo(targetDir);
218
219
  const structure = await p.select({
219
220
  message: "Project structure",
220
- initialValue: detectedMonorepo ? "monorepo" : "single",
221
+ initialValue: saved?.structure ?? (detectedMonorepo ? "monorepo" : "single"),
221
222
  options: [{
222
223
  value: "single",
223
224
  label: "Single repo"
@@ -232,7 +233,7 @@ async function runInitPrompts(targetDir) {
232
233
  }
233
234
  const useEslintPlugin = await p.confirm({
234
235
  message: "Include @bensandee/eslint-plugin?",
235
- initialValue: true
236
+ initialValue: saved?.useEslintPlugin ?? true
236
237
  });
237
238
  if (isCancelled(useEslintPlugin)) {
238
239
  p.cancel("Cancelled.");
@@ -241,7 +242,7 @@ async function runInitPrompts(targetDir) {
241
242
  const hasExistingPrettier = detected.legacyConfigs.some((l) => l.tool === "prettier");
242
243
  const formatter = await p.select({
243
244
  message: "Formatter",
244
- initialValue: hasExistingPrettier ? "prettier" : "oxfmt",
245
+ initialValue: saved?.formatter ?? (hasExistingPrettier ? "prettier" : "oxfmt"),
245
246
  options: [{
246
247
  value: "oxfmt",
247
248
  label: "oxfmt",
@@ -257,7 +258,7 @@ async function runInitPrompts(targetDir) {
257
258
  }
258
259
  const setupVitest = await p.confirm({
259
260
  message: "Set up vitest with a starter test?",
260
- initialValue: !isExisting
261
+ initialValue: saved?.setupVitest ?? !isExisting
261
262
  });
262
263
  if (isCancelled(setupVitest)) {
263
264
  p.cancel("Cancelled.");
@@ -265,6 +266,7 @@ async function runInitPrompts(targetDir) {
265
266
  }
266
267
  const ci = await p.select({
267
268
  message: "CI workflow",
269
+ initialValue: saved?.ci,
268
270
  options: [
269
271
  {
270
272
  value: "forgejo",
@@ -288,7 +290,7 @@ async function runInitPrompts(targetDir) {
288
290
  if (ci === "github") {
289
291
  const renovateAnswer = await p.confirm({
290
292
  message: "Set up Renovate for automated dependency updates?",
291
- initialValue: true
293
+ initialValue: saved?.setupRenovate ?? true
292
294
  });
293
295
  if (isCancelled(renovateAnswer)) {
294
296
  p.cancel("Cancelled.");
@@ -298,7 +300,7 @@ async function runInitPrompts(targetDir) {
298
300
  }
299
301
  const releaseStrategy = await p.select({
300
302
  message: "Release management",
301
- initialValue: "none",
303
+ initialValue: saved?.releaseStrategy ?? "none",
302
304
  options: [
303
305
  {
304
306
  value: "none",
@@ -315,9 +317,9 @@ async function runInitPrompts(targetDir) {
315
317
  hint: "PR-based versioning"
316
318
  },
317
319
  {
318
- value: "commit-and-tag-version",
319
- label: "commit-and-tag-version",
320
- hint: "conventional commits, automatic CHANGELOG"
320
+ value: "simple",
321
+ label: "Simple",
322
+ hint: "uses commit-and-tag-version internally"
321
323
  }
322
324
  ]
323
325
  });
@@ -337,7 +339,7 @@ async function runInitPrompts(targetDir) {
337
339
  p.note(detections.join("\n"), "Detected package types");
338
340
  const applyDetected = await p.confirm({
339
341
  message: "Apply detected tsconfig bases to packages?",
340
- initialValue: true
342
+ initialValue: saved?.detectPackageTypes ?? true
341
343
  });
342
344
  if (isCancelled(applyDetected)) {
343
345
  p.cancel("Cancelled.");
@@ -348,7 +350,7 @@ async function runInitPrompts(targetDir) {
348
350
  } else {
349
351
  const projectTypeAnswer = await p.select({
350
352
  message: "Project type",
351
- initialValue: "default",
353
+ initialValue: saved?.projectType ?? "default",
352
354
  options: [
353
355
  {
354
356
  value: "default",
@@ -407,7 +409,7 @@ function buildDefaultConfig(targetDir, flags) {
407
409
  setupVitest: !detected.hasVitestConfig,
408
410
  ci: flags.noCi ? "none" : DEFAULT_CI,
409
411
  setupRenovate: true,
410
- releaseStrategy: detected.hasReleaseItConfig ? "release-it" : detected.hasCommitAndTagVersionConfig ? "commit-and-tag-version" : detected.hasChangesetsConfig ? "changesets" : "none",
412
+ releaseStrategy: detected.hasReleaseItConfig ? "release-it" : detected.hasSimpleReleaseConfig ? "simple" : detected.hasChangesetsConfig ? "changesets" : "none",
411
413
  projectType: "default",
412
414
  detectPackageTypes: true,
413
415
  targetDir
@@ -581,7 +583,7 @@ function addReleaseDeps(deps, config) {
581
583
  deps["release-it"] = "18.1.2";
582
584
  if (config.structure === "monorepo") deps["@release-it/bumper"] = "7.0.2";
583
585
  break;
584
- case "commit-and-tag-version":
586
+ case "simple":
585
587
  deps["commit-and-tag-version"] = "12.5.0";
586
588
  break;
587
589
  case "changesets":
@@ -593,8 +595,8 @@ function addReleaseDeps(deps, config) {
593
595
  function getAddedDevDepNames(config) {
594
596
  const deps = { ...ROOT_DEV_DEPS };
595
597
  if (config.structure !== "monorepo") Object.assign(deps, PER_PACKAGE_DEV_DEPS);
596
- deps["@bensandee/config"] = "0.7.1";
597
- deps["@bensandee/tooling"] = "0.13.0";
598
+ deps["@bensandee/config"] = "0.8.1";
599
+ deps["@bensandee/tooling"] = "0.14.1";
598
600
  if (config.formatter === "oxfmt") deps["oxfmt"] = "0.35.0";
599
601
  if (config.formatter === "prettier") deps["prettier"] = "3.8.1";
600
602
  addReleaseDeps(deps, config);
@@ -614,9 +616,9 @@ async function generatePackageJson(ctx) {
614
616
  if (ctx.config.releaseStrategy !== "none" && ctx.config.releaseStrategy !== "changesets") allScripts["trigger-release"] = "pnpm exec tooling release:trigger";
615
617
  const devDeps = { ...ROOT_DEV_DEPS };
616
618
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
617
- devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.7.1";
618
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.13.0";
619
- if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.0";
619
+ devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.8.1";
620
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.14.1";
621
+ if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.2";
620
622
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = "0.35.0";
621
623
  if (ctx.config.formatter === "prettier") devDeps["prettier"] = "3.8.1";
622
624
  addReleaseDeps(devDeps, ctx.config);
@@ -1964,25 +1966,13 @@ permissions:
1964
1966
  - name: Release
1965
1967
  env:
1966
1968
  GITHUB_TOKEN: \${{ github.token }}
1967
- NODE_AUTH_TOKEN: \${{ secrets.NPM_TOKEN }}
1968
- run: |
1969
- pnpm exec commit-and-tag-version
1970
- git push --follow-tags
1971
- TAG=$(git describe --tags --abbrev=0)
1972
- pnpm publish --no-git-checks
1973
- gh release create "$TAG" --generate-notes` : `
1969
+ run: pnpm exec tooling release:simple` : `
1974
1970
  - name: Release
1975
1971
  env:
1976
1972
  FORGEJO_SERVER_URL: \${{ github.server_url }}
1977
1973
  FORGEJO_REPOSITORY: \${{ github.repository }}
1978
1974
  FORGEJO_TOKEN: \${{ secrets.FORGEJO_TOKEN }}
1979
- NODE_AUTH_TOKEN: \${{ secrets.NPM_TOKEN }}
1980
- run: |
1981
- pnpm exec commit-and-tag-version
1982
- git push --follow-tags
1983
- TAG=$(git describe --tags --abbrev=0)
1984
- pnpm publish --no-git-checks
1985
- pnpm exec tooling release:create-forgejo-release --tag "$TAG"`;
1975
+ run: pnpm exec tooling release:simple`;
1986
1976
  return `${workflowSchemaComment(ci)}name: Release
1987
1977
  on:
1988
1978
  workflow_dispatch:
@@ -2083,10 +2073,10 @@ function requiredReleaseSteps(strategy, nodeVersionYaml) {
2083
2073
  step: { run: "pnpm release-it --ci" }
2084
2074
  });
2085
2075
  break;
2086
- case "commit-and-tag-version":
2076
+ case "simple":
2087
2077
  steps.push({
2088
- match: { run: "commit-and-tag-version" },
2089
- step: { run: "pnpm exec commit-and-tag-version" }
2078
+ match: { run: "release:simple" },
2079
+ step: { run: "pnpm exec tooling release:simple" }
2090
2080
  });
2091
2081
  break;
2092
2082
  case "changesets":
@@ -2101,7 +2091,7 @@ function requiredReleaseSteps(strategy, nodeVersionYaml) {
2101
2091
  function buildWorkflow(strategy, ci, nodeVersionYaml) {
2102
2092
  switch (strategy) {
2103
2093
  case "release-it": return releaseItWorkflow(ci, nodeVersionYaml);
2104
- case "commit-and-tag-version": return commitAndTagVersionWorkflow(ci, nodeVersionYaml);
2094
+ case "simple": return commitAndTagVersionWorkflow(ci, nodeVersionYaml);
2105
2095
  case "changesets": return changesetsWorkflow(ci, nodeVersionYaml);
2106
2096
  default: return null;
2107
2097
  }
@@ -2444,7 +2434,7 @@ const ToolingConfigSchema = z.object({
2444
2434
  setupRenovate: z.boolean().optional(),
2445
2435
  releaseStrategy: z.enum([
2446
2436
  "release-it",
2447
- "commit-and-tag-version",
2437
+ "simple",
2448
2438
  "changesets",
2449
2439
  "none"
2450
2440
  ]).optional(),
@@ -2545,14 +2535,14 @@ const initCommand = defineCommand({
2545
2535
  },
2546
2536
  async run({ args }) {
2547
2537
  const targetDir = path.resolve(args.dir ?? ".");
2538
+ const saved = loadToolingConfig(targetDir);
2548
2539
  await runInit(args.yes ? (() => {
2549
- const saved = loadToolingConfig(targetDir);
2550
2540
  const detected = buildDefaultConfig(targetDir, {
2551
2541
  eslintPlugin: args["eslint-plugin"] === true ? true : void 0,
2552
2542
  noCi: args["no-ci"] === true ? true : void 0
2553
2543
  });
2554
2544
  return saved ? mergeWithSavedConfig(detected, saved) : detected;
2555
- })() : await runInitPrompts(targetDir), args["no-prompt"] === true ? { noPrompt: true } : {});
2545
+ })() : await runInitPrompts(targetDir, saved), args["no-prompt"] === true ? { noPrompt: true } : {});
2556
2546
  }
2557
2547
  });
2558
2548
  async function runInit(config, options = {}) {
@@ -2732,12 +2722,6 @@ function lineDiff(oldText, newText) {
2732
2722
  return lines;
2733
2723
  }
2734
2724
  //#endregion
2735
- //#region src/utils/exec.ts
2736
- /** Type guard for `execSync` errors that carry stdout/stderr/status. */
2737
- function isExecSyncError(err) {
2738
- 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";
2739
- }
2740
- //#endregion
2741
2725
  //#region src/release/executor.ts
2742
2726
  /** Create an executor that runs real commands, fetches, and reads the filesystem. */
2743
2727
  function createRealExecutor() {
@@ -3347,10 +3331,10 @@ function triggerGitHub(ref) {
3347
3331
  p.log.info(`Triggered release workflow on GitHub (ref: ${ref})`);
3348
3332
  }
3349
3333
  //#endregion
3350
- //#region src/commands/release-create-forgejo-release.ts
3334
+ //#region src/commands/forgejo-create-release.ts
3351
3335
  const createForgejoReleaseCommand = defineCommand({
3352
3336
  meta: {
3353
- name: "release:create-forgejo-release",
3337
+ name: "forgejo:create-release",
3354
3338
  description: "Create a Forgejo release for a given tag"
3355
3339
  },
3356
3340
  args: { tag: {
@@ -3360,7 +3344,7 @@ const createForgejoReleaseCommand = defineCommand({
3360
3344
  } },
3361
3345
  async run({ args }) {
3362
3346
  const resolved = resolveConnection(process.cwd());
3363
- if (resolved.type !== "forgejo") throw new FatalError("release:create-forgejo-release requires a Forgejo repository");
3347
+ if (resolved.type !== "forgejo") throw new FatalError("forgejo:create-release requires a Forgejo repository");
3364
3348
  const executor = createRealExecutor();
3365
3349
  const conn = resolved.conn;
3366
3350
  if (await findRelease(executor, conn, args.tag)) {
@@ -3372,11 +3356,11 @@ const createForgejoReleaseCommand = defineCommand({
3372
3356
  }
3373
3357
  });
3374
3358
  //#endregion
3375
- //#region src/commands/release-merge.ts
3359
+ //#region src/commands/changesets-merge.ts
3376
3360
  const HEAD_BRANCH = "changeset-release/main";
3377
3361
  const releaseMergeCommand = defineCommand({
3378
3362
  meta: {
3379
- name: "release:merge",
3363
+ name: "changesets:merge",
3380
3364
  description: "Merge the open changesets version PR"
3381
3365
  },
3382
3366
  args: { "dry-run": {
@@ -3416,6 +3400,179 @@ function mergeGitHub(dryRun) {
3416
3400
  p.log.info(`Merged changesets PR and deleted branch ${HEAD_BRANCH}`);
3417
3401
  }
3418
3402
  //#endregion
3403
+ //#region src/release/simple.ts
3404
+ /**
3405
+ * Compute sliding version tags from a semver version string.
3406
+ * For "1.2.3" returns ["v1", "v1.2"]. Strips prerelease suffixes.
3407
+ */
3408
+ function computeSlidingTags(version) {
3409
+ const parts = (version.split("-")[0] ?? version).split(".");
3410
+ if (parts.length < 2 || !parts[0] || !parts[1]) throw new FatalError(`Invalid version format "${version}". Expected semver (X.Y.Z)`);
3411
+ return [`v${parts[0]}`, `v${parts[0]}.${parts[1]}`];
3412
+ }
3413
+ /** Build the commit-and-tag-version command with appropriate flags. */
3414
+ function buildCommand(config) {
3415
+ const args = ["pnpm exec commit-and-tag-version"];
3416
+ if (config.dryRun) args.push("--dry-run");
3417
+ if (config.firstRelease) args.push("--first-release");
3418
+ if (config.releaseAs) args.push(`--release-as ${config.releaseAs}`);
3419
+ if (config.prerelease) args.push(`--prerelease ${config.prerelease}`);
3420
+ return args.join(" ");
3421
+ }
3422
+ /** Read the current version from package.json. */
3423
+ function readVersion(executor, cwd) {
3424
+ const raw = executor.readFile(path.join(cwd, "package.json"));
3425
+ if (!raw) throw new FatalError("Could not read package.json");
3426
+ const pkg = parsePackageJson(raw);
3427
+ if (!pkg?.version) throw new FatalError("No version field found in package.json");
3428
+ return pkg.version;
3429
+ }
3430
+ /** Run the full commit-and-tag-version release flow. */
3431
+ async function runSimpleRelease(executor, config) {
3432
+ const command = buildCommand(config);
3433
+ p.log.info(`Running: ${command}`);
3434
+ const versionResult = executor.exec(command, { cwd: config.cwd });
3435
+ debugExec(config, "commit-and-tag-version", versionResult);
3436
+ if (versionResult.exitCode !== 0) throw new FatalError(`commit-and-tag-version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr || versionResult.stdout}`);
3437
+ const version = readVersion(executor, config.cwd);
3438
+ debug(config, `New version: ${version}`);
3439
+ const tagResult = executor.exec("git describe --tags --abbrev=0", { cwd: config.cwd });
3440
+ debugExec(config, "git describe", tagResult);
3441
+ const tag = tagResult.stdout.trim();
3442
+ if (!tag) throw new FatalError("Could not determine the new tag from git describe");
3443
+ p.log.info(`Version ${version} tagged as ${tag}`);
3444
+ if (config.dryRun) {
3445
+ const slidingTags = config.noSlidingTags ? [] : computeSlidingTags(version);
3446
+ p.log.info(`[dry-run] Would push to origin with --follow-tags`);
3447
+ if (slidingTags.length > 0) p.log.info(`[dry-run] Would create sliding tags: ${slidingTags.join(", ")}`);
3448
+ if (!config.noRelease && config.platform) p.log.info(`[dry-run] Would create ${config.platform.type} release for ${tag}`);
3449
+ return {
3450
+ version,
3451
+ tag,
3452
+ slidingTags,
3453
+ pushed: false,
3454
+ releaseCreated: false
3455
+ };
3456
+ }
3457
+ let pushed = false;
3458
+ if (!config.noPush) {
3459
+ const branch = executor.exec("git rev-parse --abbrev-ref HEAD", { cwd: config.cwd }).stdout.trim() || "main";
3460
+ debug(config, `Pushing to origin/${branch}`);
3461
+ const pushResult = executor.exec(`git push --follow-tags origin ${branch}`, { cwd: config.cwd });
3462
+ debugExec(config, "git push", pushResult);
3463
+ if (pushResult.exitCode !== 0) throw new FatalError(`git push failed (exit code ${String(pushResult.exitCode)}):\n${pushResult.stderr || pushResult.stdout}`);
3464
+ pushed = true;
3465
+ p.log.info("Pushed to origin");
3466
+ }
3467
+ let slidingTags = [];
3468
+ if (!config.noSlidingTags && pushed) {
3469
+ slidingTags = computeSlidingTags(version);
3470
+ for (const slidingTag of slidingTags) executor.exec(`git tag -f ${slidingTag}`, { cwd: config.cwd });
3471
+ const forcePushResult = executor.exec(`git push origin ${slidingTags.join(" ")} --force`, { cwd: config.cwd });
3472
+ debugExec(config, "force-push sliding tags", forcePushResult);
3473
+ if (forcePushResult.exitCode !== 0) p.log.warn(`Warning: Failed to push sliding tags: ${forcePushResult.stderr || forcePushResult.stdout}`);
3474
+ else p.log.info(`Created sliding tags: ${slidingTags.join(", ")}`);
3475
+ }
3476
+ let releaseCreated = false;
3477
+ if (!config.noRelease && config.platform) releaseCreated = await createPlatformRelease(executor, config, tag);
3478
+ return {
3479
+ version,
3480
+ tag,
3481
+ slidingTags,
3482
+ pushed,
3483
+ releaseCreated
3484
+ };
3485
+ }
3486
+ async function createPlatformRelease(executor, config, tag) {
3487
+ if (!config.platform) return false;
3488
+ if (config.platform.type === "forgejo") {
3489
+ if (await findRelease(executor, config.platform.conn, tag)) {
3490
+ debug(config, `Release for ${tag} already exists, skipping`);
3491
+ return false;
3492
+ }
3493
+ await createRelease(executor, config.platform.conn, tag);
3494
+ p.log.info(`Created Forgejo release for ${tag}`);
3495
+ return true;
3496
+ }
3497
+ const ghResult = executor.exec(`gh release create ${tag} --generate-notes`, { cwd: config.cwd });
3498
+ debugExec(config, "gh release create", ghResult);
3499
+ if (ghResult.exitCode !== 0) {
3500
+ p.log.warn(`Warning: Failed to create GitHub release: ${ghResult.stderr || ghResult.stdout}`);
3501
+ return false;
3502
+ }
3503
+ p.log.info(`Created GitHub release for ${tag}`);
3504
+ return true;
3505
+ }
3506
+ //#endregion
3507
+ //#region src/commands/release-simple.ts
3508
+ const releaseSimpleCommand = defineCommand({
3509
+ meta: {
3510
+ name: "release:simple",
3511
+ description: "Run commit-and-tag-version, push, create sliding tags, and create a platform release"
3512
+ },
3513
+ args: {
3514
+ "dry-run": {
3515
+ type: "boolean",
3516
+ description: "Pass --dry-run to commit-and-tag-version and skip all remote operations"
3517
+ },
3518
+ verbose: {
3519
+ type: "boolean",
3520
+ description: "Enable detailed debug logging (also enabled by RELEASE_DEBUG env var)"
3521
+ },
3522
+ "no-push": {
3523
+ type: "boolean",
3524
+ description: "Run commit-and-tag-version but skip push and remote operations"
3525
+ },
3526
+ "no-sliding-tags": {
3527
+ type: "boolean",
3528
+ description: "Skip creating sliding major/minor version tags (vX, vX.Y)"
3529
+ },
3530
+ "no-release": {
3531
+ type: "boolean",
3532
+ description: "Skip Forgejo/GitHub release creation"
3533
+ },
3534
+ "first-release": {
3535
+ type: "boolean",
3536
+ description: "Pass --first-release to commit-and-tag-version (skip version bump)"
3537
+ },
3538
+ "release-as": {
3539
+ type: "string",
3540
+ description: "Force a specific version (passed to commit-and-tag-version --release-as)"
3541
+ },
3542
+ prerelease: {
3543
+ type: "string",
3544
+ description: "Create a prerelease with the given tag (e.g., beta, alpha)"
3545
+ }
3546
+ },
3547
+ async run({ args }) {
3548
+ const cwd = process.cwd();
3549
+ const verbose = args.verbose === true || process.env["RELEASE_DEBUG"] === "true";
3550
+ const noRelease = args["no-release"] === true;
3551
+ let platform;
3552
+ if (!noRelease) {
3553
+ const resolved = resolveConnection(cwd);
3554
+ if (resolved.type === "forgejo") platform = {
3555
+ type: "forgejo",
3556
+ conn: resolved.conn
3557
+ };
3558
+ else platform = { type: "github" };
3559
+ }
3560
+ const config = {
3561
+ cwd,
3562
+ dryRun: args["dry-run"] === true,
3563
+ verbose,
3564
+ noPush: args["no-push"] === true,
3565
+ noSlidingTags: args["no-sliding-tags"] === true,
3566
+ noRelease,
3567
+ firstRelease: args["first-release"] === true,
3568
+ releaseAs: args["release-as"],
3569
+ prerelease: args.prerelease,
3570
+ platform
3571
+ };
3572
+ await runSimpleRelease(createRealExecutor(), config);
3573
+ }
3574
+ });
3575
+ //#endregion
3419
3576
  //#region src/commands/repo-run-checks.ts
3420
3577
  const CHECKS = [
3421
3578
  { name: "build" },
@@ -3457,6 +3614,7 @@ function runRunChecks(targetDir, options = {}) {
3457
3614
  const skip = options.skip ?? /* @__PURE__ */ new Set();
3458
3615
  const add = options.add ?? [];
3459
3616
  const isCI = Boolean(process.env["CI"]);
3617
+ const failFast = options.failFast ?? !isCI;
3460
3618
  const definedScripts = getScripts(targetDir);
3461
3619
  const addedNames = new Set(add);
3462
3620
  const allChecks = [...CHECKS, ...add.map((name) => ({ name }))];
@@ -3480,6 +3638,7 @@ function runRunChecks(targetDir, options = {}) {
3480
3638
  if (isCI) ciLog(`::error::${check.name} failed`);
3481
3639
  p.log.error(`${check.name} failed`);
3482
3640
  failures.push(check.name);
3641
+ if (failFast) return 1;
3483
3642
  }
3484
3643
  }
3485
3644
  if (notDefined.length > 0) p.log.info(`Skipped (not defined): ${notDefined.join(", ")}`);
@@ -3510,12 +3669,18 @@ const runChecksCommand = defineCommand({
3510
3669
  type: "string",
3511
3670
  description: "Comma-separated list of additional check names to run (uses pnpm run <name>)",
3512
3671
  required: false
3672
+ },
3673
+ "fail-fast": {
3674
+ type: "boolean",
3675
+ description: "Stop on first failure (default: true in dev, false in CI)",
3676
+ required: false
3513
3677
  }
3514
3678
  },
3515
3679
  run({ args }) {
3516
3680
  const exitCode = runRunChecks(path.resolve(args.dir ?? "."), {
3517
3681
  skip: args.skip ? new Set(args.skip.split(",").map((s) => s.trim())) : void 0,
3518
- add: args.add ? args.add.split(",").map((s) => s.trim()) : void 0
3682
+ add: args.add ? args.add.split(",").map((s) => s.trim()) : void 0,
3683
+ failFast: args["fail-fast"] === true ? true : args["fail-fast"] === false ? false : void 0
3519
3684
  });
3520
3685
  process.exitCode = exitCode;
3521
3686
  }
@@ -3525,7 +3690,7 @@ const runChecksCommand = defineCommand({
3525
3690
  const main = defineCommand({
3526
3691
  meta: {
3527
3692
  name: "tooling",
3528
- version: "0.13.0",
3693
+ version: "0.14.1",
3529
3694
  description: "Bootstrap and maintain standardized TypeScript project tooling"
3530
3695
  },
3531
3696
  subCommands: {
@@ -3535,11 +3700,12 @@ const main = defineCommand({
3535
3700
  "checks:run": runChecksCommand,
3536
3701
  "release:changesets": releaseForgejoCommand,
3537
3702
  "release:trigger": releaseTriggerCommand,
3538
- "release:create-forgejo-release": createForgejoReleaseCommand,
3539
- "release:merge": releaseMergeCommand
3703
+ "forgejo:create-release": createForgejoReleaseCommand,
3704
+ "changesets:merge": releaseMergeCommand,
3705
+ "release:simple": releaseSimpleCommand
3540
3706
  }
3541
3707
  });
3542
- console.log(`@bensandee/tooling v0.13.0`);
3708
+ console.log(`@bensandee/tooling v0.14.1`);
3543
3709
  runMain(main);
3544
3710
  //#endregion
3545
3711
  export {};
@@ -0,0 +1,117 @@
1
+ import { z } from "zod";
2
+
3
+ //#region src/release/types.d.ts
4
+ /** Result of executing a shell command. */
5
+ interface ExecResult {
6
+ stdout: string;
7
+ stderr: string;
8
+ exitCode: number;
9
+ }
10
+ /** Options for executing a shell command. */
11
+ interface ExecOptions {
12
+ cwd?: string;
13
+ env?: Record<string, string>;
14
+ }
15
+ //#endregion
16
+ //#region src/docker-verify/types.d.ts
17
+ /** Abstraction over side effects for testability. */
18
+ interface DockerVerifyExecutor {
19
+ /** Run a shell command and return the result (stdout/stderr captured). */
20
+ exec(command: string, options?: ExecOptions): ExecResult;
21
+ /** Run a shell command, streaming stdout/stderr to the console. */
22
+ execInherit(command: string, options?: ExecOptions): void;
23
+ /** Perform an HTTP request. */
24
+ fetch(url: string, init?: RequestInit): Promise<Response>;
25
+ /** Get current time in ms (mockable clock). */
26
+ now(): number;
27
+ /** Sleep for the given duration (mockable delay). */
28
+ sleep(ms: number): Promise<void>;
29
+ /** Register a process signal handler. Returns a dispose function. */
30
+ onSignal(signal: NodeJS.Signals, handler: () => void): () => void;
31
+ /** Log a message to the console. */
32
+ log(message: string): void;
33
+ /** Log an error to the console. */
34
+ logError(message: string): void;
35
+ }
36
+ /** A single HTTP health check definition. */
37
+ interface HttpHealthCheck {
38
+ /** Human-readable name for logging (e.g. "API", "Frontend"). */
39
+ name: string;
40
+ /** URL to fetch. */
41
+ url: string;
42
+ /** Validate the response. Return true if healthy. */
43
+ validate: (response: Response) => Promise<boolean>;
44
+ }
45
+ /** Docker compose configuration. */
46
+ interface ComposeConfig {
47
+ /** Working directory for docker compose commands. */
48
+ cwd: string;
49
+ /** Compose files to use (e.g. ["docker-compose.yaml", "docker-compose.verify.yaml"]). */
50
+ composeFiles: string[];
51
+ /** Optional env file for compose. */
52
+ envFile?: string;
53
+ /** Service names to monitor for container-level health. */
54
+ services: string[];
55
+ }
56
+ /** Full verification configuration. */
57
+ interface VerifyConfig {
58
+ /** Docker compose settings. */
59
+ compose: ComposeConfig;
60
+ /** Optional build command to run before starting compose (e.g. "pnpm image:build"). */
61
+ buildCommand?: string;
62
+ /** Working directory for the build command (defaults to compose.cwd). */
63
+ buildCwd?: string;
64
+ /** HTTP health checks to poll. All must pass for success. */
65
+ healthChecks: HttpHealthCheck[];
66
+ /** Maximum time to wait for all checks to pass, in ms. Default: 120000. */
67
+ timeoutMs?: number;
68
+ /** Interval between polling attempts, in ms. Default: 5000. */
69
+ pollIntervalMs?: number;
70
+ }
71
+ /** Result of the verification run. */
72
+ type VerifyResult = {
73
+ success: true;
74
+ elapsedMs: number;
75
+ } | {
76
+ success: false;
77
+ reason: "timeout" | "unhealthy-container" | "error";
78
+ message: string;
79
+ elapsedMs: number;
80
+ };
81
+ //#endregion
82
+ //#region src/docker-verify/executor.d.ts
83
+ /** Create an executor that runs real commands, fetches, and manages process signals. */
84
+ declare function createRealExecutor(): DockerVerifyExecutor;
85
+ //#endregion
86
+ //#region src/docker-verify/verify.d.ts
87
+ /** Run the full Docker image verification lifecycle. */
88
+ declare function runVerification(executor: DockerVerifyExecutor, config: VerifyConfig): Promise<VerifyResult>;
89
+ //#endregion
90
+ //#region src/docker-verify/compose.d.ts
91
+ /** Zod schema for a single container entry from `docker compose ps --format json`. */
92
+ declare const ContainerInfoSchema: z.ZodObject<{
93
+ Service: z.ZodString;
94
+ Health: z.ZodString;
95
+ }, z.core.$strip>;
96
+ type ContainerInfo = z.infer<typeof ContainerInfoSchema>;
97
+ /** Build the `docker compose` base command string from config. */
98
+ declare function composeCommand(config: ComposeConfig): string;
99
+ /** Start the compose stack in detached mode. */
100
+ declare function composeUp(executor: DockerVerifyExecutor, config: ComposeConfig): void;
101
+ /** Tear down the compose stack, removing volumes and orphans. Swallows errors. */
102
+ declare function composeDown(executor: DockerVerifyExecutor, config: ComposeConfig): void;
103
+ /** Show logs for a specific service (or all services if not specified). Swallows errors. */
104
+ declare function composeLogs(executor: DockerVerifyExecutor, config: ComposeConfig, service?: string): void;
105
+ /**
106
+ * Query container status via `docker compose ps --format json`.
107
+ * Handles both JSON array and newline-delimited JSON (varies by docker compose version).
108
+ */
109
+ declare function composePs(executor: DockerVerifyExecutor, config: ComposeConfig): ContainerInfo[];
110
+ //#endregion
111
+ //#region src/docker-verify/health.d.ts
112
+ /** Look up the health status of a specific service from container info. */
113
+ declare function getContainerHealth(containers: ContainerInfo[], serviceName: string): string;
114
+ /** Run a single HTTP health check, returning true if the validator passes. */
115
+ declare function checkHttpHealth(executor: DockerVerifyExecutor, check: HttpHealthCheck): Promise<boolean>;
116
+ //#endregion
117
+ export { type ComposeConfig, type ContainerInfo, type DockerVerifyExecutor, type HttpHealthCheck, type VerifyConfig, type VerifyResult, checkHttpHealth, composeCommand, composeDown, composeLogs, composePs, composeUp, createRealExecutor, getContainerHealth, runVerification };