@bensandee/tooling 0.51.1 → 0.52.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 +227 -20
  2. package/package.json +1 -1
package/dist/bin.mjs CHANGED
@@ -1596,7 +1596,7 @@ async function generatePackageJson(ctx) {
1596
1596
  const devDeps = { ...ROOT_DEV_DEPS };
1597
1597
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1598
1598
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.10.2";
1599
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.51.1";
1599
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.52.0";
1600
1600
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.12.0";
1601
1601
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
1602
1602
  "@changesets/cli": "2.30.0",
@@ -3370,7 +3370,7 @@ async function runGenerators(ctx) {
3370
3370
  function generateMigratePrompt(results, config, detected) {
3371
3371
  const sections = [];
3372
3372
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3373
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.51.1 repo:sync\` on ${timestamp}_\n`);
3373
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.52.0 repo:sync\` on ${timestamp}_\n`);
3374
3374
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
3375
3375
  if (changed.length > 0) {
3376
3376
  const items = changed.map((r) => {
@@ -4125,6 +4125,116 @@ async function deleteActionSecret(executor, conn, secretName) {
4125
4125
  throw new TransientError(`Failed to delete secret ${secretName}: ${String(res.status)} ${res.statusText}\n${body}`);
4126
4126
  }
4127
4127
  }
4128
+ const WebhookSchema = z.object({
4129
+ id: z.number(),
4130
+ type: z.string(),
4131
+ active: z.boolean(),
4132
+ config: z.record(z.string(), z.string()).optional()
4133
+ });
4134
+ const WebhookListSchema = z.array(WebhookSchema);
4135
+ async function fetchHooks(executor, conn, endpoint) {
4136
+ const res = await executor.fetch(`${conn.serverUrl}/api/v1${endpoint}`, { headers: { Authorization: `token ${conn.token}` } });
4137
+ if (res.status === 401 || res.status === 403) return {
4138
+ status: "unauthorized",
4139
+ httpStatus: res.status
4140
+ };
4141
+ if (!res.ok) {
4142
+ const body = await responseBodyText(res);
4143
+ return {
4144
+ status: "error",
4145
+ httpStatus: res.status,
4146
+ message: `${res.statusText}\n${body}`
4147
+ };
4148
+ }
4149
+ const parsed = WebhookListSchema.safeParse(await res.json());
4150
+ if (!parsed.success) throw new UnexpectedError(`Unexpected webhook list response for ${endpoint}: ${parsed.error.message}`);
4151
+ return {
4152
+ status: "ok",
4153
+ hooks: parsed.data
4154
+ };
4155
+ }
4156
+ /** List system-level webhooks. Requires admin token (read:admin scope). */
4157
+ async function listSystemHooks(executor, conn) {
4158
+ return fetchHooks(executor, conn, "/admin/hooks");
4159
+ }
4160
+ /** List webhooks owned by the authenticated user. Requires read:user scope. */
4161
+ async function listUserHooks(executor, conn) {
4162
+ return fetchHooks(executor, conn, "/user/hooks");
4163
+ }
4164
+ /** List webhooks for an organization. Requires read:organization scope. */
4165
+ async function listOrgHooks(executor, conn, org) {
4166
+ return fetchHooks(executor, conn, `/orgs/${encodeURIComponent(org)}/hooks`);
4167
+ }
4168
+ /** List webhooks for a repository. Requires read:repository + repo admin. */
4169
+ async function listRepoHooks(executor, conn, repository) {
4170
+ return fetchHooks(executor, conn, `/repos/${repository}/hooks`);
4171
+ }
4172
+ const AuthenticatedUserSchema = z.object({ login: z.string() });
4173
+ /** Return the login of the authenticated user, or null if the token is invalid. */
4174
+ async function getAuthenticatedUser(executor, conn) {
4175
+ const res = await executor.fetch(`${conn.serverUrl}/api/v1/user`, { headers: { Authorization: `token ${conn.token}` } });
4176
+ if (!res.ok) return {
4177
+ ok: false,
4178
+ httpStatus: res.status
4179
+ };
4180
+ const parsed = AuthenticatedUserSchema.safeParse(await res.json());
4181
+ if (!parsed.success) throw new UnexpectedError(`Unexpected /user response: ${parsed.error.message}`);
4182
+ return {
4183
+ ok: true,
4184
+ login: parsed.data.login
4185
+ };
4186
+ }
4187
+ /**
4188
+ * Probe whether the token has admin access by requesting `/admin/users?limit=1`.
4189
+ *
4190
+ * Returns true on 2xx, false on 401/403. Any other status is treated as not-admin
4191
+ * to keep enumeration conservative.
4192
+ */
4193
+ async function hasAdminAccess(executor, conn) {
4194
+ return (await executor.fetch(`${conn.serverUrl}/api/v1/admin/users?limit=1`, { headers: { Authorization: `token ${conn.token}` } })).ok;
4195
+ }
4196
+ const OrgSchema = z.object({ username: z.string() });
4197
+ const OrgListSchema = z.array(OrgSchema);
4198
+ const PAGE_LIMIT = 50;
4199
+ async function paginate(executor, conn, endpoint, extract) {
4200
+ const all = [];
4201
+ let page = 1;
4202
+ const separator = endpoint.includes("?") ? "&" : "?";
4203
+ for (;;) {
4204
+ const url = `${conn.serverUrl}/api/v1${endpoint}${separator}page=${String(page)}&limit=${String(PAGE_LIMIT)}`;
4205
+ const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
4206
+ if (!res.ok) {
4207
+ const body = await responseBodyText(res);
4208
+ throw new TransientError(`Failed to fetch ${endpoint} (page ${String(page)}): ${String(res.status)} ${res.statusText}\n${body}`);
4209
+ }
4210
+ const items = extract(await res.json());
4211
+ all.push(...items);
4212
+ if (items.length < PAGE_LIMIT) break;
4213
+ page++;
4214
+ }
4215
+ return all;
4216
+ }
4217
+ /**
4218
+ * List organizations. When `mode` is "admin", uses `/admin/orgs` (all orgs on
4219
+ * the instance). When "user", uses `/user/orgs` (only the token user's orgs).
4220
+ */
4221
+ async function listOrgs(executor, conn, mode) {
4222
+ const endpoint = mode === "admin" ? "/admin/orgs" : "/user/orgs";
4223
+ return (await paginate(executor, conn, endpoint, (raw) => {
4224
+ const parsed = OrgListSchema.safeParse(raw);
4225
+ if (!parsed.success) throw new UnexpectedError(`Unexpected ${endpoint} response: ${parsed.error.message}`);
4226
+ return parsed.data;
4227
+ })).map((o) => o.username);
4228
+ }
4229
+ const RepoSearchSchema = z.object({ data: z.array(z.object({ full_name: z.string() })) });
4230
+ /** List repositories visible to the token via `/repos/search`. */
4231
+ async function searchRepos(executor, conn) {
4232
+ return (await paginate(executor, conn, "/repos/search", (raw) => {
4233
+ const parsed = RepoSearchSchema.safeParse(raw);
4234
+ if (!parsed.success) throw new UnexpectedError(`Unexpected /repos/search response: ${parsed.error.message}`);
4235
+ return parsed.data.data;
4236
+ })).map((r) => r.full_name);
4237
+ }
4128
4238
  //#endregion
4129
4239
  //#region src/release/version.ts
4130
4240
  const BRANCH = "changeset-release/main";
@@ -5480,15 +5590,33 @@ function parseComposeServices(cwd, composeFiles) {
5480
5590
  function stripComposeVariables(image) {
5481
5591
  return image.replace(/\$\{[^}]*\}/g, "");
5482
5592
  }
5593
+ /** Resolve a compose service image string to its bare name (variables and :tag stripped). */
5594
+ function bareImageName(image) {
5595
+ return stripComposeVariables(image).split(":")[0] || void 0;
5596
+ }
5483
5597
  /** Extract deduplicated bare image names (without tags) from parsed services. */
5484
5598
  function extractComposeImageNames(services) {
5485
5599
  const names = /* @__PURE__ */ new Set();
5486
5600
  for (const service of services) if (service.image) {
5487
- const bare = stripComposeVariables(service.image).split(":")[0];
5601
+ const bare = bareImageName(service.image);
5488
5602
  if (bare) names.add(bare);
5489
5603
  }
5490
5604
  return [...names];
5491
5605
  }
5606
+ /**
5607
+ * Whether a service's image refers to an image this repo builds locally.
5608
+ * Matches the bare image name (variables/tag stripped) against the list of
5609
+ * detected Docker package image names, using the same exact-or-suffix match
5610
+ * as the cross-reference warning in docker:check.
5611
+ */
5612
+ function isLocallyBuiltService(service, locallyBuiltImageNames) {
5613
+ if (!service.image) return false;
5614
+ const bare = bareImageName(service.image);
5615
+ if (!bare) return false;
5616
+ if (locallyBuiltImageNames.has(bare)) return true;
5617
+ for (const name of locallyBuiltImageNames) if (bare.endsWith(`/${name}`)) return true;
5618
+ return false;
5619
+ }
5492
5620
  /** Generate health checks from parsed services: services with exposed ports get HTTP checks, unless they define a compose-level healthcheck. */
5493
5621
  function deriveHealthChecks(services) {
5494
5622
  return services.filter((s) => s.hostPort !== void 0 && !s.hasHealthcheck).map((s) => ({
@@ -5518,27 +5646,22 @@ const CHECK_HEALTHCHECK = {
5518
5646
  start_interval: "5s"
5519
5647
  };
5520
5648
  /**
5521
- * Whether a service image references a compose variable substitution (e.g. `${REGISTRY:-}…`).
5522
- * Consumers use this pattern to parametrize the build-vs-registry split, so such services
5523
- * are locally built rather than pulled from a registry — forcing `pull_policy: never` for
5524
- * the check avoids failures when the base compose sets `pull_policy: always` for prod deploys.
5525
- */
5526
- function isLocallyBuiltImage(image) {
5527
- return image !== void 0 && /\$\{[^}]*\}/.test(image);
5528
- }
5529
- /**
5530
5649
  * Generate a check overlay YAML string from parsed services.
5531
5650
  * Sets `restart: "no"` on all services, overrides healthcheck intervals
5532
5651
  * for services that define a healthcheck in the base compose file, and
5533
- * forces `pull_policy: never` on services whose image uses a variable
5534
- * substitution (locally-built images that aren't pushed to a registry).
5652
+ * forces `pull_policy: never` on services whose image is locally built by
5653
+ * this repo (matched against `locallyBuiltImageNames`). The override
5654
+ * prevents pull failures when the consumer's base compose sets
5655
+ * `pull_policy: always` for prod deploys against images that only exist
5656
+ * in the local Docker cache after `docker:build`.
5535
5657
  */
5536
- function generateCheckOverlay(services) {
5658
+ function generateCheckOverlay(services, locallyBuiltImageNames = []) {
5659
+ const localSet = new Set(locallyBuiltImageNames);
5537
5660
  const serviceOverrides = {};
5538
5661
  for (const service of services) {
5539
5662
  const override = { restart: "no" };
5540
5663
  if (service.hasHealthcheck) override["healthcheck"] = { ...CHECK_HEALTHCHECK };
5541
- if (isLocallyBuiltImage(service.image)) override["pull_policy"] = "never";
5664
+ if (isLocallyBuiltService(service, localSet)) override["pull_policy"] = "never";
5542
5665
  serviceOverrides[service.name] = override;
5543
5666
  }
5544
5667
  return stringify({ services: serviceOverrides });
@@ -5649,15 +5772,19 @@ const dockerCheckCommand = defineCommand({
5649
5772
  const services = parseComposeServices(composeCwd, defaults.composeFiles);
5650
5773
  const fileReader = createFileReader();
5651
5774
  const rootPkgRaw = fileReader.readFile(path.join(cwd, "package.json"));
5775
+ const locallyBuiltImageNames = [];
5652
5776
  if (rootPkgRaw) {
5653
5777
  const rootPkg = parsePackageJson(rootPkgRaw);
5654
5778
  if (rootPkg?.name) {
5655
5779
  const dockerPackages = detectDockerPackages(fileReader, cwd, rootPkg.name);
5656
5780
  const composeImages = extractComposeImageNames(services);
5657
- for (const pkg of dockerPackages) if (!composeImages.some((img) => img === pkg.imageName || img.endsWith(`/${pkg.imageName}`))) log.warn(`Docker package "${pkg.dir}" (image: ${pkg.imageName}) is not referenced in any compose service.`);
5781
+ for (const pkg of dockerPackages) {
5782
+ locallyBuiltImageNames.push(pkg.imageName);
5783
+ if (!composeImages.some((img) => img === pkg.imageName || img.endsWith(`/${pkg.imageName}`))) log.warn(`Docker package "${pkg.dir}" (image: ${pkg.imageName}) is not referenced in any compose service.`);
5784
+ }
5658
5785
  }
5659
5786
  }
5660
- const tempOverlayPath = writeTempOverlay(generateCheckOverlay(services));
5787
+ const tempOverlayPath = writeTempOverlay(generateCheckOverlay(services, locallyBuiltImageNames));
5661
5788
  const composeFiles = [
5662
5789
  ...defaults.composeFiles,
5663
5790
  tempOverlayPath,
@@ -6329,6 +6456,85 @@ const forgejoSecretsCommand = defineCommand({
6329
6456
  }
6330
6457
  });
6331
6458
  //#endregion
6459
+ //#region src/commands/forgejo-webhooks.ts
6460
+ /** Render a WebhookFetchStatus, logging warnings for unauthorized/error cases. */
6461
+ function collectGroup(label, scopeHint, status) {
6462
+ if (status.status === "ok") return status.hooks.length > 0 ? {
6463
+ label,
6464
+ hooks: status.hooks
6465
+ } : null;
6466
+ if (status.status === "unauthorized") {
6467
+ log.warn(`${label}: HTTP ${String(status.httpStatus)} — token likely missing scope: ${scopeHint}`);
6468
+ return null;
6469
+ }
6470
+ log.warn(`${label}: HTTP ${String(status.httpStatus)} ${status.message}`);
6471
+ return null;
6472
+ }
6473
+ async function runListWebhooks(executor, conn) {
6474
+ const userResult = await getAuthenticatedUser(executor, conn);
6475
+ if (!userResult.ok) throw new FatalError(`Token is invalid or lacks read:user scope (HTTP ${String(userResult.httpStatus)})`);
6476
+ const user = userResult.login;
6477
+ const isAdmin = await hasAdminAccess(executor, conn);
6478
+ const groups = [];
6479
+ const systemGroup = collectGroup("System", "read:admin", await listSystemHooks(executor, conn));
6480
+ if (systemGroup) groups.push(systemGroup);
6481
+ const userGroup = collectGroup(`User: ${user}`, "read:user", await listUserHooks(executor, conn));
6482
+ if (userGroup) groups.push(userGroup);
6483
+ const orgs = await listOrgs(executor, conn, isAdmin ? "admin" : "user");
6484
+ for (const org of orgs) {
6485
+ const orgGroup = collectGroup(`Org: ${org}`, "read:organization", await listOrgHooks(executor, conn, org));
6486
+ if (orgGroup) groups.push(orgGroup);
6487
+ }
6488
+ const repos = await searchRepos(executor, conn);
6489
+ for (const repo of repos) {
6490
+ const repoGroup = collectGroup(`Repo: ${repo}`, "read:repository + repo admin", await listRepoHooks(executor, conn, repo));
6491
+ if (repoGroup) groups.push(repoGroup);
6492
+ }
6493
+ return {
6494
+ user,
6495
+ isAdmin,
6496
+ groups,
6497
+ totalCount: groups.reduce((sum, g) => sum + g.hooks.length, 0)
6498
+ };
6499
+ }
6500
+ function renderText(inventory, serverUrl) {
6501
+ log.info(`Forgejo Webhook Inventory — ${serverUrl}`);
6502
+ log.info(`Token user: ${inventory.user}${inventory.isAdmin ? " (admin)" : ""}`);
6503
+ if (inventory.groups.length === 0) {
6504
+ log.info("No webhooks found.");
6505
+ return;
6506
+ }
6507
+ for (const group of inventory.groups) {
6508
+ log.info(`${group.label} (${String(group.hooks.length)} webhook(s))`);
6509
+ for (const hook of group.hooks) {
6510
+ const url = hook.config?.["url"] ?? "n/a";
6511
+ log.info(` id=${String(hook.id)} type=${hook.type} active=${String(hook.active)} url=${url}`);
6512
+ }
6513
+ }
6514
+ log.info(`Total: ${String(inventory.totalCount)} webhook(s)`);
6515
+ if (inventory.isAdmin) log.info("Note: User-level webhooks for other accounts cannot be enumerated via the API. Check the admin UI or run this command with tokens from individual user accounts.");
6516
+ }
6517
+ const forgejoWebhooksCommand = defineCommand({
6518
+ meta: {
6519
+ name: "forgejo:webhooks",
6520
+ description: "Enumerate webhooks across a Forgejo instance (system, user, org, and repo levels)"
6521
+ },
6522
+ args: { json: {
6523
+ type: "boolean",
6524
+ description: "Emit results as JSON instead of a human-readable summary"
6525
+ } },
6526
+ async run({ args }) {
6527
+ const resolved = resolveConnection(process.cwd());
6528
+ if (resolved.type !== "forgejo") throw new FatalError("forgejo:webhooks requires a Forgejo repository");
6529
+ const inventory = await runListWebhooks(createRealExecutor(), resolved.conn);
6530
+ if (args.json) {
6531
+ console.log(JSON.stringify(inventory, null, 2));
6532
+ return;
6533
+ }
6534
+ renderText(inventory, resolved.conn.serverUrl);
6535
+ }
6536
+ });
6537
+ //#endregion
6332
6538
  //#region src/release/webhook.ts
6333
6539
  /** Build a webhook payload from simple release context. */
6334
6540
  function buildSimplePayload(options) {
@@ -6487,7 +6693,7 @@ const webhookSendCommand = defineCommand({
6487
6693
  const main = defineCommand({
6488
6694
  meta: {
6489
6695
  name: "bst",
6490
- version: "0.51.1",
6696
+ version: "0.52.0",
6491
6697
  description: "Bootstrap and maintain standardized TypeScript project tooling"
6492
6698
  },
6493
6699
  subCommands: {
@@ -6505,10 +6711,11 @@ const main = defineCommand({
6505
6711
  "hooks:sync": hooksSyncCommand,
6506
6712
  "setup:secrets": setupSecretsCommand,
6507
6713
  "forgejo:secrets": forgejoSecretsCommand,
6714
+ "forgejo:webhooks": forgejoWebhooksCommand,
6508
6715
  "webhook:send": webhookSendCommand
6509
6716
  }
6510
6717
  });
6511
- console.log(`@bensandee/tooling v0.51.1`);
6718
+ console.log(`@bensandee/tooling v0.52.0`);
6512
6719
  async function run() {
6513
6720
  await runMain(main);
6514
6721
  process.exit(process.exitCode ?? 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.51.1",
3
+ "version": "0.52.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {