@bensandee/tooling 0.43.0 → 0.44.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.
package/README.md CHANGED
@@ -96,6 +96,7 @@ To skip a workflow file entirely, add `# @bensandee/tooling:ignore` as the first
96
96
  | `tooling release:trigger` | Trigger a release workflow. |
97
97
  | `tooling forgejo:create-release` | Create a Forgejo release from a tag. |
98
98
  | `tooling changesets:merge` | Merge a changesets version PR. |
99
+ | `tooling webhook:send <url>` | POST to a webhook URL with HMAC-SHA256 auth. Secret from `WEBHOOK_SECRET` env var. |
99
100
 
100
101
  #### `release:simple`
101
102
 
package/dist/bin.mjs CHANGED
@@ -11,6 +11,7 @@ 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";
14
15
  import picomatch from "picomatch";
15
16
  import { tmpdir } from "node:os";
16
17
  //#region src/types.ts
@@ -626,7 +627,7 @@ const ToolingConfigSchema = z.strictObject({
626
627
  dockerCheck: z.union([z.literal(false), DockerCheckConfigSchema]).optional().meta({ description: "Docker health check configuration or false to disable" }),
627
628
  registryHost: z.string().optional().meta({ description: "Docker registry hostname (e.g. ghcr.io, code.example.com)" }),
628
629
  registryNamespace: z.string().optional().meta({ description: "Docker registry namespace (e.g. ghcr.io/myorg, code.example.com/myorg)" }),
629
- postReleaseHook: z.string().optional().meta({ description: "Override the post-release hook command (default: curl POST_RELEASE_HOOK_URL secret if set)" }),
630
+ postReleaseHook: z.string().optional().meta({ description: "Override the post-release hook command (default: POST to POST_RELEASE_HOOK_URL with HMAC-SHA256 auth)" }),
630
631
  postReleaseHookSecrets: z.array(z.string()).optional().meta({ description: "Additional secret names to inject as env vars in the post-release hook step (e.g. ['WEBHOOK_SECRET'])" })
631
632
  });
632
633
  /** Load saved tooling config from the target directory. Returns undefined if missing, throws on invalid. */
@@ -1571,7 +1572,7 @@ async function generatePackageJson(ctx) {
1571
1572
  const devDeps = { ...ROOT_DEV_DEPS };
1572
1573
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1573
1574
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.10.1";
1574
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.43.0";
1575
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.44.0";
1575
1576
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.12.0";
1576
1577
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
1577
1578
  "@changesets/cli": "2.30.0",
@@ -2181,16 +2182,14 @@ async function generateGitignore(ctx) {
2181
2182
  //#region src/generators/release-ci.ts
2182
2183
  const DEFAULT_POST_RELEASE_COMMAND = [
2183
2184
  "if [ -n \"$POST_RELEASE_HOOK_URL\" ]; then",
2184
- " curl -fsS -X POST",
2185
- " $",
2186
- "{POST_RELEASE_HOOK_SECRET:+-H \"Authorization: Bearer $POST_RELEASE_HOOK_SECRET\"}",
2185
+ " pnpm dlx @bensandee/tooling webhook:send",
2187
2186
  " \"$POST_RELEASE_HOOK_URL\"; fi"
2188
2187
  ].join("");
2189
- /** Build env vars for hook secrets to inject into a Release step (for runtime hook execution). */
2188
+ /** Build env vars for hook secrets to inject into a workflow step. */
2190
2189
  function hookSecretEnv(secrets) {
2191
2190
  const env = {
2192
2191
  POST_RELEASE_HOOK_URL: actionsExpr("secrets.POST_RELEASE_HOOK_URL"),
2193
- POST_RELEASE_HOOK_SECRET: actionsExpr("secrets.POST_RELEASE_HOOK_SECRET")
2192
+ WEBHOOK_SECRET: actionsExpr("secrets.POST_RELEASE_HOOK_SECRET")
2194
2193
  };
2195
2194
  for (const name of secrets ?? []) env[name] = actionsExpr(`secrets.${name}`);
2196
2195
  return env;
@@ -3333,7 +3332,7 @@ async function runGenerators(ctx) {
3333
3332
  function generateMigratePrompt(results, config, detected) {
3334
3333
  const sections = [];
3335
3334
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3336
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.43.0 repo:sync\` on ${timestamp}_\n`);
3335
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.44.0 repo:sync\` on ${timestamp}_\n`);
3337
3336
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
3338
3337
  if (changed.length > 0) {
3339
3338
  const items = changed.map((r) => {
@@ -4240,6 +4239,64 @@ async function runVersionMode(executor, config) {
4240
4239
  };
4241
4240
  }
4242
4241
  //#endregion
4242
+ //#region src/release/webhook.ts
4243
+ /**
4244
+ * Compute the HMAC-SHA256 hex digest of data using the given secret.
4245
+ * Returns the signature in GitHub-style format: `sha256=<hex>`.
4246
+ */
4247
+ function computeSignature(secret, data) {
4248
+ const hmac = createHmac("sha256", secret);
4249
+ hmac.update(data);
4250
+ return `sha256=${hmac.digest("hex")}`;
4251
+ }
4252
+ /**
4253
+ * POST to a webhook URL with GitHub-style HMAC-SHA256 authentication.
4254
+ *
4255
+ * Sends an empty body. If a secret is provided, computes the HMAC-SHA256
4256
+ * signature and includes it in the `X-Hub-Signature-256` header.
4257
+ *
4258
+ * Never throws — returns a result object indicating success or failure.
4259
+ */
4260
+ async function sendWebhook(options) {
4261
+ const { url, secret, fetcher = globalThis.fetch } = options;
4262
+ const body = "";
4263
+ const headers = { "Content-Type": "application/json" };
4264
+ if (secret) headers["X-Hub-Signature-256"] = computeSignature(secret, body);
4265
+ try {
4266
+ const response = await fetcher(url, {
4267
+ method: "POST",
4268
+ headers,
4269
+ body
4270
+ });
4271
+ if (!response.ok) return {
4272
+ ok: false,
4273
+ status: response.status,
4274
+ error: `HTTP ${String(response.status)}: ${await response.text().catch(() => "")}`
4275
+ };
4276
+ return {
4277
+ ok: true,
4278
+ status: response.status
4279
+ };
4280
+ } catch (error) {
4281
+ return {
4282
+ ok: false,
4283
+ error: error instanceof Error ? error.message : String(error)
4284
+ };
4285
+ }
4286
+ }
4287
+ /**
4288
+ * Send a webhook and log the result. Intended for CLI usage.
4289
+ * Never throws.
4290
+ */
4291
+ async function sendWebhookWithLogging(url, secret) {
4292
+ const result = await sendWebhook({
4293
+ url,
4294
+ secret
4295
+ });
4296
+ if (result.ok) log.info("Post-release hook completed successfully");
4297
+ else log.warn(`Post-release hook failed: ${result.error ?? "unknown error"}`);
4298
+ }
4299
+ //#endregion
4243
4300
  //#region src/release/hook.ts
4244
4301
  const PostReleaseHookSchema = z.object({ postReleaseHook: z.string().optional() });
4245
4302
  /** Read the postReleaseHook command override from .tooling.json at runtime. */
@@ -4259,12 +4316,12 @@ function loadHookCommand(executor, cwd) {
4259
4316
  * Execute the post-release hook after a successful publish.
4260
4317
  *
4261
4318
  * Uses the `postReleaseHook` command from `.tooling.json` if configured,
4262
- * otherwise curls `POST_RELEASE_HOOK_URL` with optional Bearer auth from
4263
- * `POST_RELEASE_HOOK_SECRET`. Both env vars must be set by the CI workflow.
4319
+ * otherwise POSTs to `POST_RELEASE_HOOK_URL` with HMAC-SHA256 authentication
4320
+ * using `WEBHOOK_SECRET`. Both env vars must be set by the CI workflow.
4264
4321
  *
4265
4322
  * Failures are logged as warnings — they never fail the release.
4266
4323
  */
4267
- function runPostReleaseHook(executor, config) {
4324
+ async function runPostReleaseHook(executor, config) {
4268
4325
  if (config.dryRun) {
4269
4326
  debug(config, "Skipping post-release hook (dry-run)");
4270
4327
  return;
@@ -4280,22 +4337,15 @@ function runPostReleaseHook(executor, config) {
4280
4337
  debug(config, "No POST_RELEASE_HOOK_URL set — skipping post-release hook");
4281
4338
  return;
4282
4339
  }
4283
- const hookSecret = process.env["POST_RELEASE_HOOK_SECRET"];
4284
- const args = [
4285
- "-fsS",
4286
- "-X",
4287
- "POST"
4288
- ];
4289
- if (hookSecret) args.push("-H", `Authorization: Bearer ${hookSecret}`);
4290
- args.push(hookUrl);
4291
- debug(config, `Running post-release hook: curl ${args.map((a) => a.includes(" ") ? `"${a}"` : a).join(" ")}`);
4292
- try {
4293
- const result = executor.exec("curl", args, { cwd: config.cwd });
4294
- if (result.exitCode !== 0) log.warn(`Post-release hook failed (exit code ${String(result.exitCode)}): ${result.stderr || result.stdout}`);
4295
- else log.info("Post-release hook completed successfully");
4296
- } catch (error) {
4297
- log.warn(`Post-release hook error: ${error instanceof Error ? error.message : String(error)}`);
4298
- }
4340
+ const hookSecret = process.env["WEBHOOK_SECRET"];
4341
+ debug(config, `Running post-release hook: POST ${hookUrl}`);
4342
+ const result = await sendWebhook({
4343
+ url: hookUrl,
4344
+ secret: hookSecret || void 0,
4345
+ fetcher: executor.fetch.bind(executor)
4346
+ });
4347
+ if (result.ok) log.info("Post-release hook completed successfully");
4348
+ else log.warn(`Post-release hook failed: ${result.error ?? "unknown error"}`);
4299
4349
  }
4300
4350
  function executeHookCommand(executor, config, command) {
4301
4351
  try {
@@ -4406,7 +4456,7 @@ async function runPublishMode(executor, config) {
4406
4456
  if (forcePushResult.exitCode !== 0) log.warn(`Warning: Failed to push sliding tags: ${forcePushResult.stderr || forcePushResult.stdout}`);
4407
4457
  else log.info(`Created sliding tags: ${slidingTags.join(", ")}`);
4408
4458
  }
4409
- runPostReleaseHook(executor, config);
4459
+ await runPostReleaseHook(executor, config);
4410
4460
  return {
4411
4461
  mode: "publish",
4412
4462
  tags: allTags
@@ -4830,7 +4880,7 @@ async function runSimpleRelease(executor, config) {
4830
4880
  }
4831
4881
  let releaseCreated = false;
4832
4882
  if (!config.noRelease && config.platform) releaseCreated = await createPlatformRelease(executor, config, tag);
4833
- if (pushed) runPostReleaseHook(executor, config);
4883
+ if (pushed) await runPostReleaseHook(executor, config);
4834
4884
  return {
4835
4885
  version,
4836
4886
  tag,
@@ -5955,11 +6005,28 @@ const forgejoSecretsCommand = defineCommand({
5955
6005
  }
5956
6006
  });
5957
6007
  //#endregion
6008
+ //#region src/commands/webhook-send.ts
6009
+ const webhookSendCommand = defineCommand({
6010
+ meta: {
6011
+ name: "webhook:send",
6012
+ description: "Send a POST request to a webhook URL with GitHub-style HMAC-SHA256 authentication"
6013
+ },
6014
+ args: { url: {
6015
+ type: "positional",
6016
+ description: "Webhook URL to POST to",
6017
+ required: true
6018
+ } },
6019
+ async run({ args }) {
6020
+ const secret = process.env["WEBHOOK_SECRET"];
6021
+ await sendWebhookWithLogging(args.url, secret || void 0);
6022
+ }
6023
+ });
6024
+ //#endregion
5958
6025
  //#region src/bin.ts
5959
6026
  const main = defineCommand({
5960
6027
  meta: {
5961
6028
  name: "bst",
5962
- version: "0.43.0",
6029
+ version: "0.44.0",
5963
6030
  description: "Bootstrap and maintain standardized TypeScript project tooling"
5964
6031
  },
5965
6032
  subCommands: {
@@ -5975,10 +6042,11 @@ const main = defineCommand({
5975
6042
  "docker:check": dockerCheckCommand,
5976
6043
  "hooks:audit": hooksAuditCommand,
5977
6044
  "setup:secrets": setupSecretsCommand,
5978
- "forgejo:secrets": forgejoSecretsCommand
6045
+ "forgejo:secrets": forgejoSecretsCommand,
6046
+ "webhook:send": webhookSendCommand
5979
6047
  }
5980
6048
  });
5981
- console.log(`@bensandee/tooling v0.43.0`);
6049
+ console.log(`@bensandee/tooling v0.44.0`);
5982
6050
  async function run() {
5983
6051
  await runMain(main);
5984
6052
  process.exit(process.exitCode ?? 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.43.0",
3
+ "version": "0.44.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/claude-hooks": "0.6.1",
58
- "@bensandee/config": "0.10.1"
57
+ "@bensandee/config": "0.10.1",
58
+ "@bensandee/claude-hooks": "0.6.1"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "tsdown",
@@ -170,7 +170,7 @@
170
170
  "type": "string"
171
171
  },
172
172
  "postReleaseHook": {
173
- "description": "Override the post-release hook command (default: curl POST_RELEASE_HOOK_URL secret if set)",
173
+ "description": "Override the post-release hook command (default: POST to POST_RELEASE_HOOK_URL with HMAC-SHA256 auth)",
174
174
  "type": "string"
175
175
  },
176
176
  "postReleaseHookSecrets": {