@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.
- package/dist/bin.mjs +227 -20
- 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.
|
|
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.
|
|
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 =
|
|
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
|
|
5534
|
-
*
|
|
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 (
|
|
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)
|
|
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.
|
|
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.
|
|
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);
|