@bensandee/tooling 0.45.1 → 0.46.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.
Files changed (2) hide show
  1. package/dist/bin.mjs +318 -397
  2. package/package.json +3 -3
package/dist/bin.mjs CHANGED
@@ -4,16 +4,16 @@ import { defineCommand, runMain } from "citty";
4
4
  import * as p from "@clack/prompts";
5
5
  import { isCancel, select } from "@clack/prompts";
6
6
  import path from "node:path";
7
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
7
+ import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
8
8
  import { execFileSync, execSync } from "node:child_process";
9
9
  import JSON5 from "json5";
10
10
  import { parse } from "jsonc-parser";
11
11
  import { z } from "zod";
12
12
  import { FatalError, TransientError, UnexpectedError } from "@bensandee/common";
13
13
  import { isMap, isScalar, parse as parse$1, parseDocument, stringify, visit } from "yaml";
14
- import { createHmac } from "node:crypto";
15
14
  import picomatch from "picomatch";
16
15
  import { tmpdir } from "node:os";
16
+ import { createHmac } from "node:crypto";
17
17
  //#region src/types.ts
18
18
  const LEGACY_TOOLS = [
19
19
  "eslint",
@@ -1167,6 +1167,18 @@ function hasEnginesNode(ctx) {
1167
1167
  function computeNodeVersionYaml(ctx) {
1168
1168
  return hasEnginesNode(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
1169
1169
  }
1170
+ /** Build the docker publish env block, omitting registry vars when derivable at runtime. */
1171
+ function dockerPublishEnv(registryDerivable) {
1172
+ return {
1173
+ ...registryDerivable ? {} : {
1174
+ DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
1175
+ DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE")
1176
+ },
1177
+ DOCKER_USERNAME: actionsExpr("secrets.DOCKER_USERNAME"),
1178
+ DOCKER_PASSWORD: actionsExpr("secrets.DOCKER_PASSWORD"),
1179
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
1180
+ };
1181
+ }
1170
1182
  //#endregion
1171
1183
  //#region src/generators/publish-ci.ts
1172
1184
  /**
@@ -1179,18 +1191,6 @@ function hasRepositoryUrl(ctx) {
1179
1191
  if (!raw) return false;
1180
1192
  return parsePackageJson(raw)?.repository !== void 0;
1181
1193
  }
1182
- /** Build the docker publish env block, omitting registry vars when derivable at runtime. */
1183
- function dockerPublishEnv$1(registryDerivable) {
1184
- return {
1185
- ...registryDerivable ? {} : {
1186
- DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
1187
- DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE")
1188
- },
1189
- DOCKER_USERNAME: actionsExpr("secrets.DOCKER_USERNAME"),
1190
- DOCKER_PASSWORD: actionsExpr("secrets.DOCKER_PASSWORD"),
1191
- RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
1192
- };
1193
- }
1194
1194
  function publishSteps(nodeVersionYaml, registryDerivable) {
1195
1195
  return [
1196
1196
  { step: { uses: "actions/checkout@v6" } },
@@ -1202,13 +1202,13 @@ function publishSteps(nodeVersionYaml, registryDerivable) {
1202
1202
  { step: { run: "pnpm install --frozen-lockfile" } },
1203
1203
  { step: {
1204
1204
  name: "Publish Docker images",
1205
- env: dockerPublishEnv$1(registryDerivable),
1205
+ env: dockerPublishEnv(registryDerivable),
1206
1206
  run: "pnpm exec bst docker:publish"
1207
1207
  } }
1208
1208
  ];
1209
1209
  }
1210
1210
  const DockerMapSchema = z.object({ docker: z.record(z.string(), z.unknown()).optional() });
1211
- const PostReleaseHookSchema$1 = z.object({
1211
+ const PostReleaseHookSchema = z.object({
1212
1212
  postReleaseHook: z.string().optional(),
1213
1213
  postReleaseHookSecrets: z.array(z.string()).optional()
1214
1214
  });
@@ -1217,7 +1217,7 @@ function getPostReleaseHookCommand(ctx) {
1217
1217
  const raw = ctx.read(".tooling.json");
1218
1218
  if (!raw) return void 0;
1219
1219
  try {
1220
- const result = PostReleaseHookSchema$1.safeParse(JSON.parse(raw));
1220
+ const result = PostReleaseHookSchema.safeParse(JSON.parse(raw));
1221
1221
  if (!result.success) return void 0;
1222
1222
  return result.data.postReleaseHook;
1223
1223
  } catch (error) {
@@ -1230,7 +1230,7 @@ function getPostReleaseHookSecrets(ctx) {
1230
1230
  const raw = ctx.read(".tooling.json");
1231
1231
  if (!raw) return void 0;
1232
1232
  try {
1233
- const result = PostReleaseHookSchema$1.safeParse(JSON.parse(raw));
1233
+ const result = PostReleaseHookSchema.safeParse(JSON.parse(raw));
1234
1234
  if (!result.success) return void 0;
1235
1235
  return result.data.postReleaseHookSecrets;
1236
1236
  } catch (error) {
@@ -1584,7 +1584,7 @@ async function generatePackageJson(ctx) {
1584
1584
  const devDeps = { ...ROOT_DEV_DEPS };
1585
1585
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1586
1586
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.10.1";
1587
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.45.1";
1587
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.46.0";
1588
1588
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.12.0";
1589
1589
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
1590
1590
  "@changesets/cli": "2.30.0",
@@ -2208,9 +2208,10 @@ function hookSecretEnv(secrets) {
2208
2208
  }
2209
2209
  /** Step that runs after release, gated on the POST_RELEASE_HOOK_URL secret being set. */
2210
2210
  function postReleaseHookStep(opts) {
2211
+ const ifCondition = opts?.condition ?? (opts?.guardMain ? "github.ref == 'refs/heads/main'" : void 0);
2211
2212
  return { step: {
2212
2213
  name: "Post-release hook",
2213
- ...opts?.guardMain && { if: "github.ref == 'refs/heads/main'" },
2214
+ ...ifCondition && { if: ifCondition },
2214
2215
  env: hookSecretEnv(opts?.secrets),
2215
2216
  run: opts?.command ?? DEFAULT_POST_RELEASE_COMMAND
2216
2217
  } };
@@ -2282,35 +2283,25 @@ function simpleReleaseCommand() {
2282
2283
  "pnpm exec bst release:simple $FLAGS"
2283
2284
  ].join("\n");
2284
2285
  }
2285
- /** Build the docker publish env block, omitting registry vars when derivable at runtime. */
2286
- function dockerPublishEnv(registryDerivable) {
2287
- return {
2288
- ...registryDerivable ? {} : {
2289
- DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
2290
- DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE")
2291
- },
2292
- DOCKER_USERNAME: actionsExpr("secrets.DOCKER_USERNAME"),
2293
- DOCKER_PASSWORD: actionsExpr("secrets.DOCKER_PASSWORD"),
2294
- RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
2286
+ function simpleReleaseSteps(ci, nodeVersionYaml, publishesNpm, hasDocker, registryDerivable) {
2287
+ const releaseEnv = ci === "github" ? { GITHUB_TOKEN: actionsExpr("github.token") } : {
2288
+ FORGEJO_SERVER_URL: actionsExpr("github.server_url"),
2289
+ FORGEJO_REPOSITORY: actionsExpr("github.repository"),
2290
+ RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN")
2295
2291
  };
2296
- }
2297
- function simpleReleaseSteps(ci, nodeVersionYaml, publishesNpm, hasDocker, registryDerivable, hookSecrets) {
2292
+ const pushedIf = "steps.release.outputs.pushed == 'true'";
2298
2293
  const releaseStep = { step: {
2294
+ id: "release",
2299
2295
  name: "Release",
2300
2296
  env: {
2301
- ...ci === "github" ? { GITHUB_TOKEN: actionsExpr("github.token") } : {
2302
- FORGEJO_SERVER_URL: actionsExpr("github.server_url"),
2303
- FORGEJO_REPOSITORY: actionsExpr("github.repository"),
2304
- RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN")
2305
- },
2306
- ...hookSecretEnv(hookSecrets),
2297
+ ...releaseEnv,
2307
2298
  RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
2308
2299
  },
2309
2300
  run: simpleReleaseCommand()
2310
2301
  } };
2311
2302
  const dockerStep = { step: {
2312
2303
  name: "Publish Docker images",
2313
- if: "success()",
2304
+ if: pushedIf,
2314
2305
  env: dockerPublishEnv(registryDerivable),
2315
2306
  run: "pnpm exec bst docker:publish"
2316
2307
  } };
@@ -2320,10 +2311,10 @@ function simpleReleaseSteps(ci, nodeVersionYaml, publishesNpm, hasDocker, regist
2320
2311
  ...hasDocker ? [dockerStep] : []
2321
2312
  ];
2322
2313
  }
2323
- function buildSteps(strategy, ci, nodeVersionYaml, publishesNpm, hasDocker, registryDerivable, hookSecrets) {
2314
+ function buildSteps(strategy, ci, nodeVersionYaml, publishesNpm, hasDocker, registryDerivable) {
2324
2315
  switch (strategy) {
2325
2316
  case "release-it": return releaseItSteps(ci, nodeVersionYaml, publishesNpm);
2326
- case "simple": return simpleReleaseSteps(ci, nodeVersionYaml, publishesNpm, hasDocker, registryDerivable, hookSecrets);
2317
+ case "simple": return simpleReleaseSteps(ci, nodeVersionYaml, publishesNpm, hasDocker, registryDerivable);
2327
2318
  default: return null;
2328
2319
  }
2329
2320
  }
@@ -2346,7 +2337,7 @@ async function generateReleaseCi(ctx) {
2346
2337
  const hasDocker = hasDockerPackages(ctx);
2347
2338
  const registryDerivable = hasRepositoryUrl(ctx);
2348
2339
  const hookSecrets = getPostReleaseHookSecrets(ctx);
2349
- const steps = buildSteps(ctx.config.releaseStrategy, ctx.config.ci, nodeVersionYaml, publishesNpm, hasDocker, registryDerivable, hookSecrets);
2340
+ const steps = buildSteps(ctx.config.releaseStrategy, ctx.config.ci, nodeVersionYaml, publishesNpm, hasDocker, registryDerivable);
2350
2341
  if (!steps) return {
2351
2342
  filePath,
2352
2343
  action: "skipped",
@@ -2356,6 +2347,11 @@ async function generateReleaseCi(ctx) {
2356
2347
  command: getPostReleaseHookCommand(ctx),
2357
2348
  secrets: hookSecrets
2358
2349
  }));
2350
+ else if (ctx.config.releaseStrategy === "simple") steps.push(postReleaseHookStep({
2351
+ command: getPostReleaseHookCommand(ctx),
2352
+ secrets: hookSecrets,
2353
+ condition: "steps.release.outputs.pushed == 'true'"
2354
+ }));
2359
2355
  const on = ctx.config.releaseStrategy === "simple" ? simpleWorkflowDispatchTrigger() : { workflow_dispatch: null };
2360
2356
  const content = buildWorkflowYaml({
2361
2357
  ci: ctx.config.ci,
@@ -2391,8 +2387,9 @@ async function generateReleaseCi(ctx) {
2391
2387
  //#endregion
2392
2388
  //#region src/generators/ci.ts
2393
2389
  /** Build the release step for the check job (changesets strategy). */
2394
- function changesetsReleaseStep(ci, publishesNpm, hookSecrets) {
2390
+ function changesetsReleaseStep(ci, publishesNpm) {
2395
2391
  if (ci === "github") return { step: {
2392
+ id: "release",
2396
2393
  uses: "changesets/action@v1",
2397
2394
  if: "github.ref == 'refs/heads/main'",
2398
2395
  with: {
@@ -2405,6 +2402,7 @@ function changesetsReleaseStep(ci, publishesNpm, hookSecrets) {
2405
2402
  }
2406
2403
  } };
2407
2404
  return { step: {
2405
+ id: "release",
2408
2406
  name: "Release",
2409
2407
  if: "github.ref == 'refs/heads/main'",
2410
2408
  env: {
@@ -2412,7 +2410,6 @@ function changesetsReleaseStep(ci, publishesNpm, hookSecrets) {
2412
2410
  FORGEJO_REPOSITORY: actionsExpr("github.repository"),
2413
2411
  RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
2414
2412
  ...publishesNpm && { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") },
2415
- ...hookSecretEnv(hookSecrets),
2416
2413
  RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
2417
2414
  },
2418
2415
  run: "pnpm exec bst release:changesets"
@@ -2462,14 +2459,22 @@ async function generateCi(ctx) {
2462
2459
  const publishesNpm = ctx.config.publishNpm === true;
2463
2460
  const hookCommand = getPostReleaseHookCommand(ctx);
2464
2461
  const hookSecrets = getPostReleaseHookSecrets(ctx);
2465
- const needsHookStep = isChangesets && isGitHub;
2462
+ const hasDocker = isChangesets && hasDockerPackages(ctx);
2463
+ const registryDerivable = hasDocker && hasRepositoryUrl(ctx);
2464
+ const publishedIf = "steps.release.outputs.published == 'true'";
2466
2465
  const steps = [
2467
2466
  ...checkSteps(nodeVersionYaml, isChangesets && publishesNpm),
2468
- ...isChangesets ? [changesetsReleaseStep(ctx.config.ci, publishesNpm, hookSecrets)] : [],
2469
- ...needsHookStep ? [postReleaseHookStep({
2467
+ ...isChangesets ? [changesetsReleaseStep(ctx.config.ci, publishesNpm)] : [],
2468
+ ...hasDocker ? [{ step: {
2469
+ name: "Publish Docker images",
2470
+ if: publishedIf,
2471
+ env: dockerPublishEnv(registryDerivable),
2472
+ run: "pnpm exec bst docker:publish"
2473
+ } }] : [],
2474
+ ...isChangesets ? [postReleaseHookStep({
2470
2475
  command: hookCommand,
2471
2476
  secrets: hookSecrets,
2472
- guardMain: true
2477
+ condition: publishedIf
2473
2478
  })] : []
2474
2479
  ];
2475
2480
  const content = buildWorkflowYaml({
@@ -3344,7 +3349,7 @@ async function runGenerators(ctx) {
3344
3349
  function generateMigratePrompt(results, config, detected) {
3345
3350
  const sections = [];
3346
3351
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3347
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.45.1 repo:sync\` on ${timestamp}_\n`);
3352
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.46.0 repo:sync\` on ${timestamp}_\n`);
3348
3353
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
3349
3354
  if (changed.length > 0) {
3350
3355
  const items = changed.map((r) => {
@@ -4251,6 +4256,111 @@ async function runVersionMode(executor, config) {
4251
4256
  };
4252
4257
  }
4253
4258
  //#endregion
4259
+ //#region src/release/publish.ts
4260
+ /** Mode 2: publish to npm, push tags, and create Forgejo releases. */
4261
+ async function runPublishMode(executor, config) {
4262
+ log.info("No changesets — publishing packages");
4263
+ const publishResult = executor.exec("pnpm", ["changeset", "publish"], { cwd: config.cwd });
4264
+ debugExec(config, "pnpm changeset publish", publishResult);
4265
+ if (publishResult.exitCode !== 0) throw new FatalError(`pnpm changeset publish failed (exit code ${String(publishResult.exitCode)}):\n${publishResult.stderr}`);
4266
+ const stdoutTags = parseNewTags(publishResult.stdout + "\n" + publishResult.stderr);
4267
+ debug(config, `Tags from publish stdout: ${stdoutTags.length > 0 ? stdoutTags.join(", ") : "(none)"}`);
4268
+ const expectedTags = computeExpectedTags(executor.listWorkspacePackages(config.cwd));
4269
+ debug(config, `Expected tags from workspace packages: ${expectedTags.length > 0 ? expectedTags.join(", ") : "(none)"}`);
4270
+ const remoteTags = parseRemoteTags(executor.exec("git", [
4271
+ "ls-remote",
4272
+ "--tags",
4273
+ "origin"
4274
+ ], { cwd: config.cwd }).stdout);
4275
+ debug(config, `Remote tags: ${remoteTags.length > 0 ? remoteTags.join(", ") : "(none)"}`);
4276
+ const remoteSet = new Set(remoteTags);
4277
+ const tagsToPush = reconcileTags(expectedTags, remoteTags, stdoutTags);
4278
+ debug(config, `Reconciled tags to push: ${tagsToPush.length > 0 ? tagsToPush.join(", ") : "(none)"}`);
4279
+ if (config.dryRun) {
4280
+ if (tagsToPush.length === 0) {
4281
+ log.info("No packages were published");
4282
+ return { mode: "none" };
4283
+ }
4284
+ log.info(`Tags to process: ${tagsToPush.join(", ")}`);
4285
+ log.info("[dry-run] Would push tags and create releases");
4286
+ return {
4287
+ mode: "publish",
4288
+ tags: tagsToPush
4289
+ };
4290
+ }
4291
+ const conn = {
4292
+ serverUrl: config.serverUrl,
4293
+ repository: config.repository,
4294
+ token: config.token
4295
+ };
4296
+ const remoteExpectedTags = expectedTags.filter((t) => remoteSet.has(t) && !tagsToPush.includes(t));
4297
+ const tagsWithMissingReleases = [];
4298
+ for (const tag of remoteExpectedTags) if (!await findRelease(executor, conn, tag)) tagsWithMissingReleases.push(tag);
4299
+ const allTags = [...tagsToPush, ...tagsWithMissingReleases];
4300
+ if (allTags.length === 0) {
4301
+ log.info("No packages were published");
4302
+ return { mode: "none" };
4303
+ }
4304
+ log.info(`Tags to process: ${allTags.join(", ")}`);
4305
+ const errors = [];
4306
+ for (const tag of allTags) try {
4307
+ if (!remoteSet.has(tag)) {
4308
+ if (executor.exec("git", [
4309
+ "tag",
4310
+ "-l",
4311
+ tag
4312
+ ], { cwd: config.cwd }).stdout.trim() === "") {
4313
+ const tagResult = executor.exec("git", ["tag", tag], { cwd: config.cwd });
4314
+ if (tagResult.exitCode !== 0) throw new FatalError(`Failed to create tag ${tag}: ${tagResult.stderr || tagResult.stdout}`);
4315
+ }
4316
+ const pushTagResult = executor.exec("git", [
4317
+ "push",
4318
+ "origin",
4319
+ `refs/tags/${tag}`
4320
+ ], { cwd: config.cwd });
4321
+ if (pushTagResult.exitCode !== 0) throw new FatalError(`Failed to push tag ${tag}: ${pushTagResult.stderr || pushTagResult.stdout}`);
4322
+ }
4323
+ if (await ensureRelease(executor, conn, tag) === "exists") log.warn(`Release for ${tag} already exists — skipping`);
4324
+ else log.info(`Created release for ${tag}`);
4325
+ } catch (error) {
4326
+ errors.push({
4327
+ tag,
4328
+ error
4329
+ });
4330
+ log.warn(`Failed to process ${tag}: ${error instanceof Error ? error.message : String(error)}`);
4331
+ }
4332
+ if (errors.length > 0) throw new TransientError(`Failed to create releases for: ${errors.map((e) => e.tag).join(", ")}`);
4333
+ const slidingTags = allTags.flatMap((t) => {
4334
+ const atIndex = t.lastIndexOf("@");
4335
+ if (atIndex <= 0) return [];
4336
+ const name = t.slice(0, atIndex);
4337
+ return computeSlidingTags(t.slice(atIndex + 1)).map((suffix) => `${name}@${suffix}`);
4338
+ });
4339
+ if (slidingTags.length > 0) {
4340
+ for (const tag of slidingTags) {
4341
+ const tagResult = executor.exec("git", [
4342
+ "tag",
4343
+ "-f",
4344
+ tag
4345
+ ], { cwd: config.cwd });
4346
+ if (tagResult.exitCode !== 0) log.warn(`Warning: Failed to create sliding tag ${tag}: ${tagResult.stderr || tagResult.stdout}`);
4347
+ }
4348
+ const forcePushResult = executor.exec("git", [
4349
+ "push",
4350
+ "origin",
4351
+ ...slidingTags.map((t) => `refs/tags/${t}`),
4352
+ "--force"
4353
+ ], { cwd: config.cwd });
4354
+ debugExec(config, "force-push sliding tags", forcePushResult);
4355
+ if (forcePushResult.exitCode !== 0) log.warn(`Warning: Failed to push sliding tags: ${forcePushResult.stderr || forcePushResult.stdout}`);
4356
+ else log.info(`Created sliding tags: ${slidingTags.join(", ")}`);
4357
+ }
4358
+ return {
4359
+ mode: "publish",
4360
+ tags: allTags
4361
+ };
4362
+ }
4363
+ //#endregion
4254
4364
  //#region src/release/connection.ts
4255
4365
  const RepositorySchema = z.union([z.string(), z.object({ url: z.string() })]);
4256
4366
  /**
@@ -4364,345 +4474,56 @@ function configureGitIdentity(executor, platform, cwd) {
4364
4474
  ], { cwd });
4365
4475
  }
4366
4476
  //#endregion
4367
- //#region src/release/webhook.ts
4368
- /** Build a webhook payload from changesets publish context. */
4369
- function buildChangesetsPayload(options) {
4370
- const tagName = options.tags.join(", ");
4371
- const actor = options.actor ?? ciActorName(options.platform ?? "forgejo");
4372
- return {
4373
- action: "published",
4374
- release: {
4375
- tag_name: tagName,
4376
- name: tagName,
4377
- draft: false,
4378
- prerelease: false,
4379
- author: { login: actor }
4380
- },
4381
- repository: {
4382
- full_name: options.repository,
4383
- html_url: `${options.serverUrl}/${options.repository}`
4477
+ //#region src/commands/release-changesets.ts
4478
+ const releaseForgejoCommand = defineCommand({
4479
+ meta: {
4480
+ name: "release:changesets",
4481
+ description: "Changesets version/publish for Forgejo CI"
4482
+ },
4483
+ args: {
4484
+ "dry-run": {
4485
+ type: "boolean",
4486
+ description: "Skip push, API calls, and publishing side effects"
4384
4487
  },
4385
- sender: { login: actor }
4386
- };
4387
- }
4388
- /** Build a webhook payload from simple release context. */
4389
- function buildSimplePayload(options) {
4390
- const repository = options.repository ?? "";
4391
- const serverUrl = options.serverUrl ?? "https://github.com";
4392
- const actor = options.actor ?? ciActorName(options.platform ?? "github");
4488
+ verbose: {
4489
+ type: "boolean",
4490
+ description: "Enable detailed debug logging (also enabled by TOOLING_DEBUG env var)"
4491
+ }
4492
+ },
4493
+ async run({ args }) {
4494
+ const result = await runRelease(buildReleaseConfig({
4495
+ dryRun: args["dry-run"] === true,
4496
+ verbose: args.verbose === true || isEnvVerbose()
4497
+ }), createRealExecutor());
4498
+ if (result.mode === "publish") {
4499
+ const outputFile = process.env["GITHUB_OUTPUT"];
4500
+ if (outputFile) try {
4501
+ appendFileSync(outputFile, "published=true\n");
4502
+ } catch (_error) {}
4503
+ }
4504
+ if (result.mode === "none") process.exitCode = 0;
4505
+ }
4506
+ });
4507
+ /** Build release config from environment / package.json and CLI flags. */
4508
+ function buildReleaseConfig(flags) {
4509
+ const resolved = resolveConnection(process.cwd());
4510
+ if (resolved.type !== "forgejo") throw new FatalError("release:changesets requires a Forgejo repository");
4393
4511
  return {
4394
- action: "published",
4395
- release: {
4396
- tag_name: options.tag,
4397
- name: options.tag,
4398
- draft: false,
4399
- prerelease: options.prerelease ?? false,
4400
- author: { login: actor }
4401
- },
4402
- repository: {
4403
- full_name: repository,
4404
- html_url: repository ? `${serverUrl}/${repository}` : ""
4405
- },
4406
- sender: { login: actor }
4512
+ ...resolved.conn,
4513
+ cwd: process.cwd(),
4514
+ dryRun: flags.dryRun ?? false,
4515
+ verbose: flags.verbose ?? false
4407
4516
  };
4408
4517
  }
4409
- /** Build a webhook payload from GitHub Actions environment variables. */
4410
- function buildPayloadFromEnv() {
4411
- const repository = process.env["GITHUB_REPOSITORY"];
4518
+ /** Resolve the current branch from CI env vars or git. */
4519
+ function getCurrentBranch(executor, cwd) {
4412
4520
  const ref = process.env["GITHUB_REF"];
4413
- const serverUrl = process.env["GITHUB_SERVER_URL"] ?? "https://github.com";
4414
- const actor = process.env["GITHUB_ACTOR"] ?? "github-actions[bot]";
4415
- if (!repository || !ref) return void 0;
4416
- const tagName = ref.startsWith("refs/tags/") ? ref.slice(10) : ref;
4417
- return {
4418
- action: "published",
4419
- release: {
4420
- tag_name: tagName,
4421
- name: tagName,
4422
- draft: false,
4423
- prerelease: false,
4424
- author: { login: actor }
4425
- },
4426
- repository: {
4427
- full_name: repository,
4428
- html_url: `${serverUrl}/${repository}`
4429
- },
4430
- sender: { login: actor }
4431
- };
4432
- }
4433
- /**
4434
- * Compute the HMAC-SHA256 hex digest of data using the given secret.
4435
- * Returns the signature in GitHub-style format: `sha256=<hex>`.
4436
- */
4437
- function computeSignature(secret, data) {
4438
- const hmac = createHmac("sha256", secret);
4439
- hmac.update(data);
4440
- return `sha256=${hmac.digest("hex")}`;
4441
- }
4442
- /**
4443
- * POST to a webhook URL with GitHub-style HMAC-SHA256 authentication.
4444
- *
4445
- * Sends a JSON-serialized release payload (or empty string if no payload).
4446
- * If a secret is provided, computes the HMAC-SHA256 signature of the body
4447
- * and includes it in the `X-Hub-Signature-256` header.
4448
- *
4449
- * Never throws — returns a result object indicating success or failure.
4450
- */
4451
- async function sendWebhook(options) {
4452
- const { url, secret, payload, fetcher = globalThis.fetch } = options;
4453
- const body = payload ? JSON.stringify(payload) : "";
4454
- const headers = { "Content-Type": "application/json" };
4455
- if (secret) headers["X-Hub-Signature-256"] = computeSignature(secret, body);
4456
- try {
4457
- const response = await fetcher(url, {
4458
- method: "POST",
4459
- headers,
4460
- body
4461
- });
4462
- if (!response.ok) return {
4463
- ok: false,
4464
- status: response.status,
4465
- error: `HTTP ${String(response.status)}: ${await response.text().catch(() => "")}`
4466
- };
4467
- return {
4468
- ok: true,
4469
- status: response.status
4470
- };
4471
- } catch (error) {
4472
- return {
4473
- ok: false,
4474
- error: error instanceof Error ? error.message : String(error)
4475
- };
4476
- }
4477
- }
4478
- /**
4479
- * Send a webhook and log the result. Intended for CLI usage.
4480
- * Never throws.
4481
- */
4482
- async function sendWebhookWithLogging(url, secret, payload) {
4483
- const result = await sendWebhook({
4484
- url,
4485
- secret,
4486
- payload
4487
- });
4488
- if (result.ok) log.info("Post-release hook completed successfully");
4489
- else log.warn(`Post-release hook failed: ${result.error ?? "unknown error"}`);
4490
- }
4491
- //#endregion
4492
- //#region src/release/hook.ts
4493
- const PostReleaseHookSchema = z.object({ postReleaseHook: z.string().optional() });
4494
- /** Read the postReleaseHook command override from .tooling.json at runtime. */
4495
- function loadHookCommand(executor, cwd) {
4496
- const configPath = path.join(cwd, ".tooling.json");
4497
- const raw = executor.readFile(configPath);
4498
- if (!raw) return void 0;
4499
- try {
4500
- const result = PostReleaseHookSchema.safeParse(JSON.parse(raw));
4501
- if (!result.success) return void 0;
4502
- return result.data.postReleaseHook;
4503
- } catch (_error) {
4504
- return;
4505
- }
4506
- }
4507
- /**
4508
- * Execute the post-release hook after a successful publish.
4509
- *
4510
- * Uses the `postReleaseHook` command from `.tooling.json` if configured,
4511
- * otherwise POSTs to `POST_RELEASE_HOOK_URL` with HMAC-SHA256 authentication
4512
- * using `WEBHOOK_SECRET`. Both env vars must be set by the CI workflow.
4513
- *
4514
- * Failures are logged as warnings — they never fail the release.
4515
- */
4516
- async function runPostReleaseHook(executor, config, payload) {
4517
- if (config.dryRun) {
4518
- debug(config, "Skipping post-release hook (dry-run)");
4519
- return;
4520
- }
4521
- const customCommand = loadHookCommand(executor, config.cwd);
4522
- if (customCommand) {
4523
- debug(config, `Running custom post-release hook: ${customCommand}`);
4524
- executeHookCommand(executor, config, customCommand);
4525
- return;
4526
- }
4527
- const hookUrl = process.env["POST_RELEASE_HOOK_URL"];
4528
- if (!hookUrl) {
4529
- debug(config, "No POST_RELEASE_HOOK_URL set — skipping post-release hook");
4530
- return;
4531
- }
4532
- const hookSecret = process.env["WEBHOOK_SECRET"];
4533
- debug(config, `Running post-release hook: POST ${hookUrl}`);
4534
- const result = await sendWebhook({
4535
- url: hookUrl,
4536
- secret: hookSecret || void 0,
4537
- payload,
4538
- fetcher: executor.fetch.bind(executor)
4539
- });
4540
- if (result.ok) log.info("Post-release hook completed successfully");
4541
- else log.warn(`Post-release hook failed: ${result.error ?? "unknown error"}`);
4542
- }
4543
- function executeHookCommand(executor, config, command) {
4544
- try {
4545
- const result = executor.exec("sh", ["-c", command], { cwd: config.cwd });
4546
- if (result.exitCode !== 0) log.warn(`Post-release hook failed (exit code ${String(result.exitCode)}): ${result.stderr || result.stdout}`);
4547
- else log.info("Post-release hook completed successfully");
4548
- } catch (error) {
4549
- log.warn(`Post-release hook error: ${error instanceof Error ? error.message : String(error)}`);
4550
- }
4551
- }
4552
- //#endregion
4553
- //#region src/release/publish.ts
4554
- /** Mode 2: publish to npm, push tags, and create Forgejo releases. */
4555
- async function runPublishMode(executor, config) {
4556
- log.info("No changesets — publishing packages");
4557
- const publishResult = executor.exec("pnpm", ["changeset", "publish"], { cwd: config.cwd });
4558
- debugExec(config, "pnpm changeset publish", publishResult);
4559
- if (publishResult.exitCode !== 0) throw new FatalError(`pnpm changeset publish failed (exit code ${String(publishResult.exitCode)}):\n${publishResult.stderr}`);
4560
- const stdoutTags = parseNewTags(publishResult.stdout + "\n" + publishResult.stderr);
4561
- debug(config, `Tags from publish stdout: ${stdoutTags.length > 0 ? stdoutTags.join(", ") : "(none)"}`);
4562
- const expectedTags = computeExpectedTags(executor.listWorkspacePackages(config.cwd));
4563
- debug(config, `Expected tags from workspace packages: ${expectedTags.length > 0 ? expectedTags.join(", ") : "(none)"}`);
4564
- const remoteTags = parseRemoteTags(executor.exec("git", [
4565
- "ls-remote",
4566
- "--tags",
4567
- "origin"
4568
- ], { cwd: config.cwd }).stdout);
4569
- debug(config, `Remote tags: ${remoteTags.length > 0 ? remoteTags.join(", ") : "(none)"}`);
4570
- const remoteSet = new Set(remoteTags);
4571
- const tagsToPush = reconcileTags(expectedTags, remoteTags, stdoutTags);
4572
- debug(config, `Reconciled tags to push: ${tagsToPush.length > 0 ? tagsToPush.join(", ") : "(none)"}`);
4573
- if (config.dryRun) {
4574
- if (tagsToPush.length === 0) {
4575
- log.info("No packages were published");
4576
- return { mode: "none" };
4577
- }
4578
- log.info(`Tags to process: ${tagsToPush.join(", ")}`);
4579
- log.info("[dry-run] Would push tags and create releases");
4580
- return {
4581
- mode: "publish",
4582
- tags: tagsToPush
4583
- };
4584
- }
4585
- const conn = {
4586
- serverUrl: config.serverUrl,
4587
- repository: config.repository,
4588
- token: config.token
4589
- };
4590
- const remoteExpectedTags = expectedTags.filter((t) => remoteSet.has(t) && !tagsToPush.includes(t));
4591
- const tagsWithMissingReleases = [];
4592
- for (const tag of remoteExpectedTags) if (!await findRelease(executor, conn, tag)) tagsWithMissingReleases.push(tag);
4593
- const allTags = [...tagsToPush, ...tagsWithMissingReleases];
4594
- if (allTags.length === 0) {
4595
- log.info("No packages were published");
4596
- return { mode: "none" };
4597
- }
4598
- log.info(`Tags to process: ${allTags.join(", ")}`);
4599
- const errors = [];
4600
- for (const tag of allTags) try {
4601
- if (!remoteSet.has(tag)) {
4602
- if (executor.exec("git", [
4603
- "tag",
4604
- "-l",
4605
- tag
4606
- ], { cwd: config.cwd }).stdout.trim() === "") {
4607
- const tagResult = executor.exec("git", ["tag", tag], { cwd: config.cwd });
4608
- if (tagResult.exitCode !== 0) throw new FatalError(`Failed to create tag ${tag}: ${tagResult.stderr || tagResult.stdout}`);
4609
- }
4610
- const pushTagResult = executor.exec("git", [
4611
- "push",
4612
- "origin",
4613
- `refs/tags/${tag}`
4614
- ], { cwd: config.cwd });
4615
- if (pushTagResult.exitCode !== 0) throw new FatalError(`Failed to push tag ${tag}: ${pushTagResult.stderr || pushTagResult.stdout}`);
4616
- }
4617
- if (await ensureRelease(executor, conn, tag) === "exists") log.warn(`Release for ${tag} already exists — skipping`);
4618
- else log.info(`Created release for ${tag}`);
4619
- } catch (error) {
4620
- errors.push({
4621
- tag,
4622
- error
4623
- });
4624
- log.warn(`Failed to process ${tag}: ${error instanceof Error ? error.message : String(error)}`);
4625
- }
4626
- if (errors.length > 0) throw new TransientError(`Failed to create releases for: ${errors.map((e) => e.tag).join(", ")}`);
4627
- const slidingTags = allTags.flatMap((t) => {
4628
- const atIndex = t.lastIndexOf("@");
4629
- if (atIndex <= 0) return [];
4630
- const name = t.slice(0, atIndex);
4631
- return computeSlidingTags(t.slice(atIndex + 1)).map((suffix) => `${name}@${suffix}`);
4632
- });
4633
- if (slidingTags.length > 0) {
4634
- for (const tag of slidingTags) {
4635
- const tagResult = executor.exec("git", [
4636
- "tag",
4637
- "-f",
4638
- tag
4639
- ], { cwd: config.cwd });
4640
- if (tagResult.exitCode !== 0) log.warn(`Warning: Failed to create sliding tag ${tag}: ${tagResult.stderr || tagResult.stdout}`);
4641
- }
4642
- const forcePushResult = executor.exec("git", [
4643
- "push",
4644
- "origin",
4645
- ...slidingTags.map((t) => `refs/tags/${t}`),
4646
- "--force"
4647
- ], { cwd: config.cwd });
4648
- debugExec(config, "force-push sliding tags", forcePushResult);
4649
- if (forcePushResult.exitCode !== 0) log.warn(`Warning: Failed to push sliding tags: ${forcePushResult.stderr || forcePushResult.stdout}`);
4650
- else log.info(`Created sliding tags: ${slidingTags.join(", ")}`);
4651
- }
4652
- await runPostReleaseHook(executor, config, buildChangesetsPayload({
4653
- tags: allTags,
4654
- repository: config.repository,
4655
- serverUrl: config.serverUrl
4656
- }));
4657
- return {
4658
- mode: "publish",
4659
- tags: allTags
4660
- };
4661
- }
4662
- //#endregion
4663
- //#region src/commands/release-changesets.ts
4664
- const releaseForgejoCommand = defineCommand({
4665
- meta: {
4666
- name: "release:changesets",
4667
- description: "Changesets version/publish for Forgejo CI"
4668
- },
4669
- args: {
4670
- "dry-run": {
4671
- type: "boolean",
4672
- description: "Skip push, API calls, and publishing side effects"
4673
- },
4674
- verbose: {
4675
- type: "boolean",
4676
- description: "Enable detailed debug logging (also enabled by TOOLING_DEBUG env var)"
4677
- }
4678
- },
4679
- async run({ args }) {
4680
- if ((await runRelease(buildReleaseConfig({
4681
- dryRun: args["dry-run"] === true,
4682
- verbose: args.verbose === true || isEnvVerbose()
4683
- }), createRealExecutor())).mode === "none") process.exitCode = 0;
4684
- }
4685
- });
4686
- /** Build release config from environment / package.json and CLI flags. */
4687
- function buildReleaseConfig(flags) {
4688
- const resolved = resolveConnection(process.cwd());
4689
- if (resolved.type !== "forgejo") throw new FatalError("release:changesets requires a Forgejo repository");
4690
- return {
4691
- ...resolved.conn,
4692
- cwd: process.cwd(),
4693
- dryRun: flags.dryRun ?? false,
4694
- verbose: flags.verbose ?? false
4695
- };
4696
- }
4697
- /** Resolve the current branch from CI env vars or git. */
4698
- function getCurrentBranch(executor, cwd) {
4699
- const ref = process.env["GITHUB_REF"];
4700
- if (ref?.startsWith("refs/heads/")) return ref.slice(11);
4701
- return executor.exec("git", [
4702
- "rev-parse",
4703
- "--abbrev-ref",
4704
- "HEAD"
4705
- ], { cwd }).stdout.trim();
4521
+ if (ref?.startsWith("refs/heads/")) return ref.slice(11);
4522
+ return executor.exec("git", [
4523
+ "rev-parse",
4524
+ "--abbrev-ref",
4525
+ "HEAD"
4526
+ ], { cwd }).stdout.trim();
4706
4527
  }
4707
4528
  /** Core release logic — testable with a mock executor. */
4708
4529
  async function runRelease(config, executor) {
@@ -4970,16 +4791,6 @@ async function runSimpleRelease(executor, config) {
4970
4791
  }
4971
4792
  let releaseCreated = false;
4972
4793
  if (!config.noRelease && config.platform) releaseCreated = await createPlatformRelease(executor, config, tag);
4973
- if (pushed) {
4974
- const conn = config.platform?.type === "forgejo" ? config.platform.conn : void 0;
4975
- await runPostReleaseHook(executor, config, buildSimplePayload({
4976
- tag,
4977
- repository: conn?.repository,
4978
- serverUrl: conn?.serverUrl,
4979
- prerelease: Boolean(config.prerelease),
4980
- platform: config.platform?.type
4981
- }));
4982
- }
4983
4794
  return {
4984
4795
  version,
4985
4796
  tag,
@@ -5078,7 +4889,12 @@ const releaseSimpleCommand = defineCommand({
5078
4889
  prerelease: args.prerelease,
5079
4890
  platform
5080
4891
  };
5081
- await runSimpleRelease(createRealExecutor(), config);
4892
+ if ((await runSimpleRelease(createRealExecutor(), config)).pushed) {
4893
+ const outputFile = process.env["GITHUB_OUTPUT"];
4894
+ if (outputFile) try {
4895
+ appendFileSync(outputFile, "pushed=true\n");
4896
+ } catch (_error) {}
4897
+ }
5082
4898
  }
5083
4899
  });
5084
4900
  //#endregion
@@ -5088,13 +4904,13 @@ const CHECKS = [
5088
4904
  { name: "typecheck" },
5089
4905
  { name: "lint" },
5090
4906
  { name: "test" },
5091
- { name: "docker:build" },
5092
4907
  {
5093
4908
  name: "format",
5094
4909
  args: "--check"
5095
4910
  },
5096
4911
  { name: "knip" },
5097
4912
  { name: "tooling:check" },
4913
+ { name: "docker:build" },
5098
4914
  { name: "docker:check" }
5099
4915
  ];
5100
4916
  /** Check if a name matches any skip pattern. Supports glob syntax via picomatch. */
@@ -6105,6 +5921,111 @@ const forgejoSecretsCommand = defineCommand({
6105
5921
  }
6106
5922
  });
6107
5923
  //#endregion
5924
+ //#region src/release/webhook.ts
5925
+ /** Build a webhook payload from simple release context. */
5926
+ function buildSimplePayload(options) {
5927
+ const repository = options.repository ?? "";
5928
+ const serverUrl = options.serverUrl ?? "https://github.com";
5929
+ const actor = options.actor ?? ciActorName(options.platform ?? "github");
5930
+ return {
5931
+ action: "published",
5932
+ release: {
5933
+ tag_name: options.tag,
5934
+ name: options.tag,
5935
+ draft: false,
5936
+ prerelease: options.prerelease ?? false,
5937
+ author: { login: actor }
5938
+ },
5939
+ repository: {
5940
+ full_name: repository,
5941
+ html_url: repository ? `${serverUrl}/${repository}` : ""
5942
+ },
5943
+ sender: { login: actor }
5944
+ };
5945
+ }
5946
+ /** Build a webhook payload from GitHub Actions environment variables. */
5947
+ function buildPayloadFromEnv() {
5948
+ const repository = process.env["GITHUB_REPOSITORY"];
5949
+ const ref = process.env["GITHUB_REF"];
5950
+ const serverUrl = process.env["GITHUB_SERVER_URL"] ?? "https://github.com";
5951
+ const actor = process.env["GITHUB_ACTOR"] ?? "github-actions[bot]";
5952
+ if (!repository || !ref) return void 0;
5953
+ const tagName = ref.startsWith("refs/tags/") ? ref.slice(10) : ref;
5954
+ return {
5955
+ action: "published",
5956
+ release: {
5957
+ tag_name: tagName,
5958
+ name: tagName,
5959
+ draft: false,
5960
+ prerelease: false,
5961
+ author: { login: actor }
5962
+ },
5963
+ repository: {
5964
+ full_name: repository,
5965
+ html_url: `${serverUrl}/${repository}`
5966
+ },
5967
+ sender: { login: actor }
5968
+ };
5969
+ }
5970
+ /**
5971
+ * Compute the HMAC-SHA256 hex digest of data using the given secret.
5972
+ * Returns the signature in GitHub-style format: `sha256=<hex>`.
5973
+ */
5974
+ function computeSignature(secret, data) {
5975
+ const hmac = createHmac("sha256", secret);
5976
+ hmac.update(data);
5977
+ return `sha256=${hmac.digest("hex")}`;
5978
+ }
5979
+ /**
5980
+ * POST to a webhook URL with GitHub-style HMAC-SHA256 authentication.
5981
+ *
5982
+ * Sends a JSON-serialized release payload (or empty string if no payload).
5983
+ * If a secret is provided, computes the HMAC-SHA256 signature of the body
5984
+ * and includes it in the `X-Hub-Signature-256` header.
5985
+ *
5986
+ * Never throws — returns a result object indicating success or failure.
5987
+ */
5988
+ async function sendWebhook(options) {
5989
+ const { url, secret, payload, fetcher = globalThis.fetch } = options;
5990
+ const body = payload ? JSON.stringify(payload) : "";
5991
+ const headers = { "Content-Type": "application/json" };
5992
+ if (secret) headers["X-Hub-Signature-256"] = computeSignature(secret, body);
5993
+ try {
5994
+ const response = await fetcher(url, {
5995
+ method: "POST",
5996
+ headers,
5997
+ body
5998
+ });
5999
+ if (!response.ok) return {
6000
+ ok: false,
6001
+ status: response.status,
6002
+ error: `HTTP ${String(response.status)}: ${await response.text().catch(() => "")}`
6003
+ };
6004
+ return {
6005
+ ok: true,
6006
+ status: response.status
6007
+ };
6008
+ } catch (error) {
6009
+ return {
6010
+ ok: false,
6011
+ error: error instanceof Error ? error.message : String(error)
6012
+ };
6013
+ }
6014
+ }
6015
+ /**
6016
+ * Send a webhook and log the result. Intended for CLI usage.
6017
+ * Never throws.
6018
+ */
6019
+ async function sendWebhookWithLogging(url, secret, payload) {
6020
+ const result = await sendWebhook({
6021
+ url,
6022
+ secret,
6023
+ payload
6024
+ });
6025
+ if (result.ok) log.info("Post-release hook completed successfully");
6026
+ else log.warn(`Post-release hook failed: ${result.error ?? "unknown error"}`);
6027
+ }
6028
+ //#endregion
6108
6029
  //#region src/commands/webhook-send.ts
6109
6030
  const webhookSendCommand = defineCommand({
6110
6031
  meta: {
@@ -6155,7 +6076,7 @@ const webhookSendCommand = defineCommand({
6155
6076
  const main = defineCommand({
6156
6077
  meta: {
6157
6078
  name: "bst",
6158
- version: "0.45.1",
6079
+ version: "0.46.0",
6159
6080
  description: "Bootstrap and maintain standardized TypeScript project tooling"
6160
6081
  },
6161
6082
  subCommands: {
@@ -6175,7 +6096,7 @@ const main = defineCommand({
6175
6096
  "webhook:send": webhookSendCommand
6176
6097
  }
6177
6098
  });
6178
- console.log(`@bensandee/tooling v0.45.1`);
6099
+ console.log(`@bensandee/tooling v0.46.0`);
6179
6100
  async function run() {
6180
6101
  await runMain(main);
6181
6102
  process.exit(process.exitCode ?? 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.45.1",
3
+ "version": "0.46.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -54,8 +54,8 @@
54
54
  "prettier": "3.8.1",
55
55
  "release-it": "19.2.4",
56
56
  "tsdown": "0.21.6",
57
- "@bensandee/config": "0.10.1",
58
- "@bensandee/claude-hooks": "0.6.3"
57
+ "@bensandee/claude-hooks": "0.6.3",
58
+ "@bensandee/config": "0.10.1"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "tsdown",