@bensandee/tooling 0.24.0 → 0.25.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
@@ -22,7 +22,7 @@ const LEGACY_TOOLS = [
22
22
  //#endregion
23
23
  //#region src/utils/json.ts
24
24
  const StringRecord = z.record(z.string(), z.string());
25
- const PackageJsonSchema = z.object({
25
+ const PackageJsonSchema = z.looseObject({
26
26
  name: z.string().optional(),
27
27
  version: z.string().optional(),
28
28
  private: z.boolean().optional(),
@@ -35,20 +35,21 @@ const PackageJsonSchema = z.object({
35
35
  main: z.string().optional(),
36
36
  types: z.string().optional(),
37
37
  typings: z.string().optional(),
38
- engines: StringRecord.optional()
39
- }).loose();
40
- const TsconfigSchema = z.object({
38
+ engines: StringRecord.optional(),
39
+ repository: z.union([z.string(), z.looseObject({ url: z.string() })]).optional()
40
+ });
41
+ const TsconfigSchema = z.looseObject({
41
42
  extends: z.union([z.string(), z.array(z.string())]).optional(),
42
43
  include: z.array(z.string()).optional(),
43
44
  exclude: z.array(z.string()).optional(),
44
45
  files: z.array(z.string()).optional(),
45
- references: z.array(z.object({ path: z.string() }).loose()).optional(),
46
+ references: z.array(z.looseObject({ path: z.string() })).optional(),
46
47
  compilerOptions: z.record(z.string(), z.unknown()).optional()
47
- }).loose();
48
- const RenovateSchema = z.object({
48
+ });
49
+ const RenovateSchema = z.looseObject({
49
50
  $schema: z.string().optional(),
50
51
  extends: z.array(z.string()).optional()
51
- }).loose();
52
+ });
52
53
  /** Parse a JSONC string as a tsconfig.json. Returns a typed object with `{}` fallback on failure. */
53
54
  function parseTsconfig(raw) {
54
55
  const result = TsconfigSchema.safeParse(parse(raw));
@@ -63,7 +64,7 @@ function parseRenovateJson(raw) {
63
64
  return {};
64
65
  }
65
66
  }
66
- const ChangesetConfigSchema = z.object({ commit: z.union([z.boolean(), z.string()]).optional() }).loose();
67
+ const ChangesetConfigSchema = z.looseObject({ commit: z.union([z.boolean(), z.string()]).optional() });
67
68
  /** Parse a JSON string as a .changeset/config.json. Returns `undefined` on failure. */
68
69
  function parseChangesetConfig(raw) {
69
70
  try {
@@ -108,6 +109,7 @@ function detectProject(targetDir) {
108
109
  hasReleaseItConfig: exists(".release-it.json") || exists(".release-it.yaml") || exists(".release-it.toml"),
109
110
  hasSimpleReleaseConfig: exists(".versionrc") || exists(".versionrc.json") || exists(".versionrc.js"),
110
111
  hasChangesetsConfig: exists(".changeset/config.json"),
112
+ hasRepositoryField: !!readPackageJson(targetDir)?.repository,
111
113
  legacyConfigs: detectLegacyConfigs(targetDir)
112
114
  };
113
115
  }
@@ -980,8 +982,8 @@ function addReleaseDeps(deps, config) {
980
982
  function getAddedDevDepNames(config) {
981
983
  const deps = { ...ROOT_DEV_DEPS };
982
984
  if (config.structure !== "monorepo") Object.assign(deps, PER_PACKAGE_DEV_DEPS);
983
- deps["@bensandee/config"] = "0.8.2";
984
- deps["@bensandee/tooling"] = "0.24.0";
985
+ deps["@bensandee/config"] = "0.9.0";
986
+ deps["@bensandee/tooling"] = "0.25.1";
985
987
  if (config.formatter === "oxfmt") deps["oxfmt"] = "0.35.0";
986
988
  if (config.formatter === "prettier") deps["prettier"] = "3.8.1";
987
989
  addReleaseDeps(deps, config);
@@ -1005,8 +1007,8 @@ async function generatePackageJson(ctx) {
1005
1007
  }
1006
1008
  const devDeps = { ...ROOT_DEV_DEPS };
1007
1009
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1008
- devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.8.2";
1009
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.24.0";
1010
+ devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.9.0";
1011
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.25.1";
1010
1012
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.2";
1011
1013
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = "0.35.0";
1012
1014
  if (ctx.config.formatter === "prettier") devDeps["prettier"] = "3.8.1";
@@ -1894,7 +1896,8 @@ function buildSettings(ctx) {
1894
1896
  instructions: [
1895
1897
  "Use pnpm, not npm/yarn/npx. Run binaries with `pnpm exec`.",
1896
1898
  "No typecasts (as/any). Use zod schemas, type guards, or narrowing instead.",
1897
- "Fix lint violations instead of suppressing them. Only add disable comments when suppression is genuinely the best option."
1899
+ "Fix lint violations instead of suppressing them. Only add disable comments when suppression is genuinely the best option.",
1900
+ "Prefer extensionless imports; if an extension is required, use .ts over .js."
1898
1901
  ],
1899
1902
  enabledPlugins,
1900
1903
  extraKnownMarketplaces
@@ -2475,7 +2478,7 @@ const SCHEMA_NPM_PATH = "@bensandee/config/schemas/forgejo-workflow.schema.json"
2475
2478
  const SCHEMA_LOCAL_PATH = ".vscode/forgejo-workflow.schema.json";
2476
2479
  const SETTINGS_PATH = ".vscode/settings.json";
2477
2480
  const SCHEMA_GLOB = ".forgejo/workflows/*.{yml,yaml}";
2478
- const VscodeSettingsSchema = z.object({ "yaml.schemas": z.record(z.string(), z.unknown()).default({}) }).loose();
2481
+ const VscodeSettingsSchema = z.looseObject({ "yaml.schemas": z.record(z.string(), z.unknown()).default({}) });
2479
2482
  function readSchemaFromNodeModules(targetDir) {
2480
2483
  const candidate = path.join(targetDir, "node_modules", SCHEMA_NPM_PATH);
2481
2484
  if (!existsSync(candidate)) return void 0;
@@ -2924,6 +2927,17 @@ function generateMigratePrompt(results, config, detected) {
2924
2927
  }
2925
2928
  sections.push("## Migration tasks");
2926
2929
  sections.push("");
2930
+ if (config.releaseStrategy !== "none" && !detected.hasRepositoryField) {
2931
+ sections.push("### Add repository field to package.json");
2932
+ sections.push("");
2933
+ sections.push(`The release strategy \`${config.releaseStrategy}\` requires a \`repository\` field in \`package.json\` so that \`release:trigger\` can determine the correct hosting platform (Forgejo vs GitHub).`);
2934
+ sections.push("");
2935
+ sections.push("Add the appropriate repository URL to `package.json`:");
2936
+ sections.push("");
2937
+ sections.push("- For Forgejo: `\"repository\": \"https://your-forgejo-instance.com/owner/repo\"`");
2938
+ sections.push("- For GitHub: `\"repository\": \"https://github.com/owner/repo\"`");
2939
+ sections.push("");
2940
+ }
2927
2941
  const legacyToRemove = detected.legacyConfigs.filter((legacy) => !(legacy.tool === "prettier" && config.formatter === "prettier"));
2928
2942
  if (legacyToRemove.length > 0) {
2929
2943
  sections.push("### Remove legacy tooling");
@@ -3096,6 +3110,7 @@ async function runInit(config, options = {}) {
3096
3110
  if (p.isCancel(result)) return "skip";
3097
3111
  return result;
3098
3112
  }));
3113
+ if (config.releaseStrategy !== "none" && !ctx.packageJson?.repository) p.log.warn(`package.json is missing a "repository" field — required for release strategy "${config.releaseStrategy}"`);
3099
3114
  logDetectionSummary(ctx);
3100
3115
  s.start("Generating configuration files...");
3101
3116
  let results;
@@ -3588,7 +3603,8 @@ async function runVersionMode(executor, config) {
3588
3603
  debugExec(config, "pnpm install --no-frozen-lockfile", executor.exec("pnpm install --no-frozen-lockfile", { cwd: config.cwd }));
3589
3604
  const { title, body } = buildPrContent(executor, config.cwd, packagesBefore);
3590
3605
  debug(config, `PR title: ${title}`);
3591
- executor.exec("git add -A", { cwd: config.cwd });
3606
+ const addResult = executor.exec("git add -A", { cwd: config.cwd });
3607
+ if (addResult.exitCode !== 0) throw new FatalError(`git add failed: ${addResult.stderr || addResult.stdout}`);
3592
3608
  const remainingChangesets = executor.listChangesetFiles(config.cwd);
3593
3609
  if (remainingChangesets.length > 0) p.log.warn(`Changeset files still present after versioning: ${remainingChangesets.join(", ")}`);
3594
3610
  debug(config, `Changeset files after versioning: ${remainingChangesets.length > 0 ? remainingChangesets.join(", ") : "(none — all consumed)"}`);
@@ -3608,7 +3624,9 @@ async function runVersionMode(executor, config) {
3608
3624
  pr: "none"
3609
3625
  };
3610
3626
  }
3611
- debugExec(config, "git push", executor.exec(`git push origin "HEAD:refs/heads/${BRANCH}" --force`, { cwd: config.cwd }));
3627
+ const pushResult = executor.exec(`git push origin "HEAD:refs/heads/${BRANCH}" --force`, { cwd: config.cwd });
3628
+ debugExec(config, "git push", pushResult);
3629
+ if (pushResult.exitCode !== 0) throw new FatalError(`git push failed (exit code ${String(pushResult.exitCode)}):\n${pushResult.stderr || pushResult.stdout}`);
3612
3630
  const conn = {
3613
3631
  serverUrl: config.serverUrl,
3614
3632
  repository: config.repository,
@@ -3700,8 +3718,12 @@ async function runPublishMode(executor, config) {
3700
3718
  const errors = [];
3701
3719
  for (const tag of allTags) try {
3702
3720
  if (!remoteSet.has(tag)) {
3703
- if (executor.exec(`git tag -l ${JSON.stringify(tag)}`, { cwd: config.cwd }).stdout.trim() === "") executor.exec(`git tag ${JSON.stringify(tag)}`, { cwd: config.cwd });
3704
- executor.exec(`git push origin refs/tags/${tag}`, { cwd: config.cwd });
3721
+ if (executor.exec(`git tag -l ${JSON.stringify(tag)}`, { cwd: config.cwd }).stdout.trim() === "") {
3722
+ const tagResult = executor.exec(`git tag ${JSON.stringify(tag)}`, { cwd: config.cwd });
3723
+ if (tagResult.exitCode !== 0) throw new FatalError(`Failed to create tag ${tag}: ${tagResult.stderr || tagResult.stdout}`);
3724
+ }
3725
+ const pushTagResult = executor.exec(`git push origin refs/tags/${tag}`, { cwd: config.cwd });
3726
+ if (pushTagResult.exitCode !== 0) throw new FatalError(`Failed to push tag ${tag}: ${pushTagResult.stderr || pushTagResult.stdout}`);
3705
3727
  }
3706
3728
  if (await findRelease(executor, conn, tag)) p.log.warn(`Release for ${tag} already exists — skipping`);
3707
3729
  else {
@@ -3893,7 +3915,8 @@ async function triggerForgejo(conn, ref) {
3893
3915
  p.log.info(`Triggered release workflow on Forgejo (ref: ${ref})`);
3894
3916
  }
3895
3917
  function triggerGitHub(ref) {
3896
- createRealExecutor().exec(`gh workflow run release.yml --ref ${ref}`, { cwd: process.cwd() });
3918
+ const result = createRealExecutor().exec(`gh workflow run release.yml --ref ${ref}`, { cwd: process.cwd() });
3919
+ if (result.exitCode !== 0) throw new FatalError(`Failed to trigger GitHub workflow: ${result.stderr || result.stdout || "unknown error"}`);
3897
3920
  p.log.info(`Triggered release workflow on GitHub (ref: ${ref})`);
3898
3921
  }
3899
3922
  //#endregion
@@ -3962,7 +3985,8 @@ function mergeGitHub(dryRun) {
3962
3985
  p.log.info(`[dry-run] Would merge PR #${prNum} and delete branch ${HEAD_BRANCH}`);
3963
3986
  return;
3964
3987
  }
3965
- executor.exec(`gh pr merge ${HEAD_BRANCH} --merge --delete-branch`, { cwd: process.cwd() });
3988
+ const result = executor.exec(`gh pr merge ${HEAD_BRANCH} --merge --delete-branch`, { cwd: process.cwd() });
3989
+ if (result.exitCode !== 0) throw new FatalError(`Failed to merge PR: ${result.stderr || result.stdout || "unknown error"}`);
3966
3990
  p.log.info(`Merged changesets PR and deleted branch ${HEAD_BRANCH}`);
3967
3991
  }
3968
3992
  //#endregion
@@ -4033,7 +4057,10 @@ async function runSimpleRelease(executor, config) {
4033
4057
  let slidingTags = [];
4034
4058
  if (!config.noSlidingTags && pushed) {
4035
4059
  slidingTags = computeSlidingTags(version);
4036
- for (const slidingTag of slidingTags) executor.exec(`git tag -f ${slidingTag}`, { cwd: config.cwd });
4060
+ for (const slidingTag of slidingTags) {
4061
+ const slidingTagResult = executor.exec(`git tag -f ${slidingTag}`, { cwd: config.cwd });
4062
+ if (slidingTagResult.exitCode !== 0) throw new FatalError(`Failed to create sliding tag ${slidingTag}: ${slidingTagResult.stderr || slidingTagResult.stdout}`);
4063
+ }
4037
4064
  const forcePushResult = executor.exec(`git push origin ${slidingTags.join(" ")} --force`, { cwd: config.cwd });
4038
4065
  debugExec(config, "force-push sliding tags", forcePushResult);
4039
4066
  if (forcePushResult.exitCode !== 0) p.log.warn(`Warning: Failed to push sliding tags: ${forcePushResult.stderr || forcePushResult.stdout}`);
@@ -4288,6 +4315,22 @@ const publishDockerCommand = defineCommand({
4288
4315
  });
4289
4316
  //#endregion
4290
4317
  //#region src/commands/docker-build.ts
4318
+ /**
4319
+ * Detect if cwd is inside a packages/ subdirectory.
4320
+ * If so, return the project root and the relative package dir.
4321
+ * Otherwise return cwd as-is with no packageDir.
4322
+ */
4323
+ function detectSubpackage(cwd) {
4324
+ const parent = path.dirname(cwd);
4325
+ if (path.basename(parent) === "packages" && existsSync(path.join(parent, "..", "package.json"))) return {
4326
+ root: path.dirname(parent),
4327
+ packageDir: `packages/${path.basename(cwd)}`
4328
+ };
4329
+ return {
4330
+ root: cwd,
4331
+ packageDir: void 0
4332
+ };
4333
+ }
4291
4334
  const dockerBuildCommand = defineCommand({
4292
4335
  meta: {
4293
4336
  name: "docker:build",
@@ -4312,9 +4355,16 @@ const dockerBuildCommand = defineCommand({
4312
4355
  const executor = createRealExecutor();
4313
4356
  const rawExtra = args._ ?? [];
4314
4357
  const extraArgs = Array.isArray(rawExtra) ? rawExtra.map(String) : [String(rawExtra)];
4358
+ let cwd = process.cwd();
4359
+ let packageDir = args.package;
4360
+ if (!packageDir) {
4361
+ const detected = detectSubpackage(cwd);
4362
+ cwd = detected.root;
4363
+ packageDir = detected.packageDir;
4364
+ }
4315
4365
  runDockerBuild(executor, {
4316
- cwd: process.cwd(),
4317
- packageDir: args.package,
4366
+ cwd,
4367
+ packageDir,
4318
4368
  verbose: args.verbose === true,
4319
4369
  extraArgs: extraArgs.filter((a) => a.length > 0)
4320
4370
  });
@@ -4330,16 +4380,16 @@ const COMPOSE_FILE_CANDIDATES = [
4330
4380
  "compose.yml"
4331
4381
  ];
4332
4382
  /** Zod schema for the subset of compose YAML we care about. */
4333
- const ComposePortSchema = z.union([z.string(), z.object({
4383
+ const ComposePortSchema = z.union([z.string(), z.looseObject({
4334
4384
  published: z.union([z.string(), z.number()]),
4335
4385
  target: z.union([z.string(), z.number()]).optional()
4336
- }).loose()]);
4337
- const ComposeServiceSchema = z.object({
4386
+ })]);
4387
+ const ComposeServiceSchema = z.looseObject({
4338
4388
  image: z.string().optional(),
4339
4389
  ports: z.array(ComposePortSchema).optional(),
4340
4390
  healthcheck: z.unknown().optional()
4341
- }).loose();
4342
- const ComposeFileSchema = z.object({ services: z.record(z.string(), ComposeServiceSchema).optional() }).loose();
4391
+ });
4392
+ const ComposeFileSchema = z.looseObject({ services: z.record(z.string(), ComposeServiceSchema).optional() });
4343
4393
  /** Directories to scan for compose files, in priority order. */
4344
4394
  const COMPOSE_DIR_CANDIDATES = [".", "docker"];
4345
4395
  /** Detect which compose files exist at conventional paths.
@@ -4418,11 +4468,20 @@ function parseComposeServices(cwd, composeFiles) {
4418
4468
  }
4419
4469
  return [...serviceMap.values()];
4420
4470
  }
4471
+ /**
4472
+ * Strip Docker Compose variable substitutions from an image string.
4473
+ * Handles `${VAR:-default}`, `${VAR-default}`, `${VAR:+alt}`, `${VAR+alt}`,
4474
+ * `${VAR:?err}`, `${VAR?err}`, and plain `${VAR}`.
4475
+ * Nested braces are not supported.
4476
+ */
4477
+ function stripComposeVariables(image) {
4478
+ return image.replace(/\$\{[^}]*\}/g, "");
4479
+ }
4421
4480
  /** Extract deduplicated bare image names (without tags) from parsed services. */
4422
4481
  function extractComposeImageNames(services) {
4423
4482
  const names = /* @__PURE__ */ new Set();
4424
4483
  for (const service of services) if (service.image) {
4425
- const bare = service.image.split(":")[0];
4484
+ const bare = stripComposeVariables(service.image).split(":")[0];
4426
4485
  if (bare) names.add(bare);
4427
4486
  }
4428
4487
  return [...names];
@@ -4607,7 +4666,7 @@ const dockerCheckCommand = defineCommand({
4607
4666
  const main = defineCommand({
4608
4667
  meta: {
4609
4668
  name: "tooling",
4610
- version: "0.24.0",
4669
+ version: "0.25.1",
4611
4670
  description: "Bootstrap and maintain standardized TypeScript project tooling"
4612
4671
  },
4613
4672
  subCommands: {
@@ -4623,7 +4682,7 @@ const main = defineCommand({
4623
4682
  "docker:check": dockerCheckCommand
4624
4683
  }
4625
4684
  });
4626
- console.log(`@bensandee/tooling v0.24.0`);
4685
+ console.log(`@bensandee/tooling v0.25.1`);
4627
4686
  async function run() {
4628
4687
  await runMain(main);
4629
4688
  process.exit(process.exitCode ?? 0);
package/dist/index.d.mts CHANGED
@@ -15,6 +15,9 @@ declare const PackageJsonSchema: z.ZodObject<{
15
15
  types: z.ZodOptional<z.ZodString>;
16
16
  typings: z.ZodOptional<z.ZodString>;
17
17
  engines: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
18
+ repository: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
19
+ url: z.ZodString;
20
+ }, z.core.$loose>]>>;
18
21
  }, z.core.$loose>;
19
22
  type PackageJson = z.infer<typeof PackageJsonSchema>;
20
23
  //#endregion
@@ -91,6 +94,8 @@ interface DetectedProjectState {
91
94
  hasReleaseItConfig: boolean;
92
95
  hasSimpleReleaseConfig: boolean;
93
96
  hasChangesetsConfig: boolean;
97
+ /** Whether package.json has a repository field (needed for release workflows) */
98
+ hasRepositoryField: boolean;
94
99
  /** Legacy tooling configs found */
95
100
  legacyConfigs: LegacyConfig[];
96
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "bin": {
6
6
  "tooling": "./dist/bin.mjs"
@@ -43,7 +43,7 @@
43
43
  "tsdown": "0.21.2",
44
44
  "typescript": "5.9.3",
45
45
  "vitest": "4.0.18",
46
- "@bensandee/config": "0.8.2"
46
+ "@bensandee/config": "0.9.0"
47
47
  },
48
48
  "optionalDependencies": {
49
49
  "@changesets/cli": "^2.29.4",