@bensandee/tooling 0.86.1 → 0.87.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.mjs CHANGED
@@ -269,7 +269,7 @@ function loadEnvFileForBin(rawArgs, bin, io = processIo) {
269
269
  */
270
270
  const envFileLoad = loadEnvFileForBin(process.argv.slice(2), {
271
271
  name: "bst",
272
- version: "0.86.1"
272
+ version: "0.87.1"
273
273
  });
274
274
  //#endregion
275
275
  //#region ../cli-kit/src/logger.ts
@@ -1132,7 +1132,7 @@ const AsciiConfigSchema = z.strictObject({
1132
1132
  allow: z.array(z.string()).optional().meta({ description: "Codepoints (as U+XXXX) to drop from the effective deny-list" }),
1133
1133
  treatAsProse: z.array(z.string()).optional().meta({ description: "Path globs (matched the same way as exclude) whose files --fix classifies as prose wholesale regardless of extension, flattening rather than flagging. The opt-back-in for an extension with no dedicated lexical-context classifier where blind flattening is known safe. No built-in defaults." })
1134
1134
  });
1135
- const LintConfigSchema = z.strictObject({ strictTypeAware: z.boolean().optional().meta({ description: "Make `bst lint:types` (the type-aware oxlint gate, backed by oxlint-tsgolint) exit non-zero on any finding while the tooling is still in its report phase (default false; the enforce phase, a later tooling release, exits non-zero unconditionally). A missing oxlint-tsgolint binary is also a hard failure rather than a notice while this is set: arming the gate is what turns a platform gap into a build break, deliberately, because the repo that armed it is the one that asked for it." }) });
1135
+ const LintConfigSchema = z.strictObject({ strictTypeAware: z.boolean().optional().meta({ description: "Make a missing oxlint-tsgolint binary a hard failure for `bst lint:types` (the type-aware oxlint gate) rather than a notice (default false): arming it is what turns a platform gap into a build break, deliberately, because the repo that armed it is the one that asked for it. Findings block in every repo regardless of this setting." }) });
1136
1136
  const ChecksConfigSchema = z.strictObject({
1137
1137
  add: z.array(z.string()).optional().meta({ description: "Extra package.json scripts to run as first-class check steps, appended to the built-in list (e.g. ['lint:php']). An added step with no matching script is a hard failure." }),
1138
1138
  skip: z.array(z.string()).optional().meta({ description: "Check steps to skip, unioned with the built-in CI defaults (never replacing them). Supports glob patterns." }),
@@ -2310,6 +2310,18 @@ const LintSweepConfigSchema = z.strictObject({
2310
2310
  minWarnings: z.number().int().nonnegative().optional().meta({ description: "How many oxlint warnings the default branch must carry before the sweep opens a pull request (default 5). Below the floor the warn marker on checks:run is the whole response." }),
2311
2311
  cooldownDays: z.number().int().nonnegative().optional().meta({ description: "Days after a lint-sweep pull request merged or closed before the sweep may open another (default 7): a merged sweep whose declined warnings survive must not re-file them the next morning." })
2312
2312
  });
2313
+ /**
2314
+ * `.tooling.json#agent.egress`: the extra egress hosts this repo's agent containers may reach.
2315
+ *
2316
+ * Only the browse lane (GET and HEAD) is repo-declarable. The passthrough allows every method,
2317
+ * so it stays agent-owned -- see the non-goals of the per-repo egress spec.
2318
+ *
2319
+ * The list is validated as a list of non-empty strings here, not against the pattern grammar:
2320
+ * that grammar lives in the agent package and the dependency edge runs agent -> tooling, never
2321
+ * back. The agent checks every pattern when it reads the key and refuses the run with the bad
2322
+ * pattern named, so `repo:sync` deliberately accepts a malformed pattern.
2323
+ */
2324
+ const AgentEgressConfigSchema = z.strictObject({ browseHosts: z.array(z.string().min(1)).optional().meta({ description: "Host patterns this repo's agent containers may fetch from, on top of the hosts every run needs: an exact host (`fastdl.mongodb.org`) or a wildcard over its leftmost label (`*.example.com`). No ports. These join the browse lane, which serves GET and HEAD only." }) });
2313
2325
  const AgentConfigSchema = z.strictObject({
2314
2326
  autonomy: z.boolean().optional().meta({ description: "Opt into interior autonomy (default false). When a phase's latest rationale reports readiness \"ready\" with an empty openQuestions[] and the review phase is clean, the agent applies the existing plan -> implement -> review -> park-at-mergeable advance itself instead of parking for a human. Never applies to the design/spec gate or the final merge from the mergeable terminal -- those always wait on a human. Also arms the trunk-red detector: when this repo's default branch combined status settles red, the agent files a tracking issue naming the failing checks and labels it agent/spec, starting the fix through the ordinary spec/plan/implement/review pipeline. It also closes that issue, with a comment, if the default branch recovers on its own before a human acts on it. Also arms the world-changed retrigger sweep: a pull request labelled agent-flags/autopilot that is parked at a -stuck or -failed terminal is re-queued when the fact its park recorded stops being true (the default branch went green, or moved past a conflicted base)." }),
2315
2327
  preRunRebase: z.boolean().optional().meta({ description: "Merge the current default branch into the work branch before the implement (execute) phase's container starts (default true). A clean merge or an already-up-to-date branch runs the container as is; a conflicting merge is aborted, recorded on the run's status comment, and the run proceeds on the un-synced branch -- it never fails the run. Two consecutive conflicting attempts hand the unit to agent/repair before any container runs; a timeout or a failed merge never counts toward that pair. Set false to skip the attempt entirely for this repo: no fetch, no merge, no abort, and the branch is worked exactly as cloned." }),
@@ -2322,6 +2334,7 @@ const AgentConfigSchema = z.strictObject({
2322
2334
  "low"
2323
2335
  ])).optional().meta({ description: "Map this repo's own issue-label names onto the agent's four priority levels, e.g. {\"P1\": \"critical\", \"P4\": \"low\"}. The map UNIONS with the built-in priority/<level> names rather than replacing them, and the agent does not create aliased labels -- the repo owns names the agent did not choose. Priority affects claim order only: it never changes a run's model, effort, or any budget." }),
2324
2336
  triageAnalysis: z.boolean().optional().meta({ description: "Whether the agent may route its own agent-note/human-triage-needed issues through the agent/analyze stage (default: off). Unlike agent.backlog, which defaults on, this defaults off: a backlog run batches every pending finding into one container, while triage routing spends one container per issue -- arming it is the reversible direction, not disarming it." }),
2337
+ egress: AgentEgressConfigSchema.optional().meta({ description: "Extra egress hosts this repo's agent containers may reach. `browseHosts` joins the agent's browse lane (GET and HEAD only) on top of the hosts every run needs; the passthrough lane stays agent-owned. Each entry is a host pattern -- an exact host or a *.host wildcard, never a port. Read from the repo's DEFAULT BRANCH, never off a pull request's own tree, so a run can never widen its own egress: the declaration takes effect on the first container started after a human merges it. An entry whose pattern the agent cannot parse refuses the run rather than being dropped. Changing this block holds the pull request from automerge." }),
2325
2338
  findingHolds: FindingHoldsConfigSchema.optional().meta({ description: "Which of a pull request's linked open findings withhold its unattended merge. `rules` is ordered and the first `when` that matches decides; no match falls through to `default`; an absent block is { rules: [], default: true }, so every open linked finding holds by default. Read off the repo's DEFAULT BRANCH, never off the pull request's own tree -- a pull request cannot edit the rule deciding whether it is held. In a `mentions` entry a /.../flags pattern is a regular expression, a literal:<text> pattern is the substring <text> (how to name a path such as /src/utils), and anything else is a case-insensitive substring." })
2326
2339
  });
2327
2340
  /**
@@ -2345,7 +2358,8 @@ const AgentConfigSchema = z.strictObject({
2345
2358
  const TolerantAgentConfigSchema = z.object({
2346
2359
  ...AgentConfigSchema.shape,
2347
2360
  lintSweep: tolerantBlock(LintSweepConfigSchema).optional(),
2348
- findingHolds: z.unknown().optional()
2361
+ findingHolds: z.unknown().optional(),
2362
+ egress: z.unknown().optional()
2349
2363
  });
2350
2364
  const BranchPruneConfigSchema = z.strictObject({
2351
2365
  mergedDays: z.number().int().nonnegative().optional().meta({ description: "Days after a work branch's PR merged before bst branch:prune deletes it (default 7). Zero is valid on purpose (an explicit 'delete everything merged already'), matching the --merged-days CLI flag's contract." }),
@@ -3036,57 +3050,6 @@ function loadRegistryHostOverride(cwd) {
3036
3050
  }
3037
3051
  }
3038
3052
  //#endregion
3039
- //#region src/utils/generated-header.ts
3040
- /**
3041
- * Leading comment headers for fully generated files.
3042
- *
3043
- * These mark a file as managed by `@bensandee/tooling` so a reader knows that
3044
- * `bst repo:sync` may overwrite it, and points at the supported ways to keep
3045
- * custom content. Only used for files that are largely (re)generated wholesale
3046
- * -- not for files that are merged into (package.json, tsconfig.json, etc.).
3047
- */
3048
- /**
3049
- * Stable substring present in every managed-file header. Used to detect whether an
3050
- * existing file already carries the *current* header -- a comment-only difference is
3051
- * invisible to `normalizeWorkflow()` (it strips comments before comparing), so generators
3052
- * check for this marker to know a one-time header upgrade is still needed.
3053
- *
3054
- * The trailing `--` is load-bearing: it is what made the 2026-07-27 ASCII fix reach files
3055
- * that were already synced. Widen this marker whenever the header's leading text changes,
3056
- * or the change lands in the template and never in anyone's repo.
3057
- */
3058
- const MANAGED_HEADER_MARKER = "Managed by @bensandee/tooling --";
3059
- /** Render a comment block: each line is prefixed with `comment`; empty lines stay bare. */
3060
- function managedFileHeader(comment, lines) {
3061
- return lines.map((line) => line === "" ? comment : `${comment} ${line}`).join("\n") + "\n";
3062
- }
3063
- /**
3064
- * Header for generated CI workflow files (`ci.yml`, `release.yml`, `publish.yml`).
3065
- *
3066
- * The literal `@bensandee/tooling:ignore` directive is kept mid-line on purpose so
3067
- * `isToolingIgnored()` (which looks for the directive at the start of a comment) does
3068
- * not mistake this header for an opt-out.
3069
- */
3070
- const WORKFLOW_MANAGED_HEADER = managedFileHeader("#", [
3071
- "Managed by @bensandee/tooling -- `bst repo:sync` regenerates this workflow,",
3072
- "so direct edits are overwritten on the next sync.",
3073
- "To preserve changes, wrap them between `# @tooling:custom` and",
3074
- "`# @tooling:endcustom` markers, or stop managing this file by making",
3075
- "`# @bensandee/tooling:ignore` the first line."
3076
- ]);
3077
- /** Header for the generated `lefthook.yml` pre-commit config. */
3078
- const LEFTHOOK_MANAGED_HEADER = managedFileHeader("#", [
3079
- "Managed by @bensandee/tooling -- `bst repo:sync` keeps the `lint` and",
3080
- "`format` pre-commit commands current. Other commands you add are preserved.",
3081
- "To stop managing this file, make `# @bensandee/tooling:ignore` the first line."
3082
- ]);
3083
- /**
3084
- * Header for a generated agent guide (`.agent/guides/*.md`). One line, because guide ownership
3085
- * is decided by the marker on the first line alone. It spells the ignore directive out in words
3086
- * rather than as the literal comment, which would close this one early.
3087
- */
3088
- const GUIDE_MANAGED_HEADER = "<!-- Managed by @bensandee/tooling -- `bst repo:sync` rewrites this guide and archives local edits. To keep your own version, make the first line an HTML comment reading @bensandee/tooling:ignore. -->\n";
3089
- //#endregion
3090
3053
  //#region src/utils/debug.ts
3091
3054
  const verboseLogger = createLogger({ level: "trace" });
3092
3055
  /** Check if verbose/debug mode is enabled via environment variables. */
@@ -4077,6 +4040,77 @@ function tracedRunners(logger) {
4077
4040
  function fileErrorCode(error) {
4078
4041
  if (error instanceof Error && "code" in error && typeof error.code === "string") return error.code;
4079
4042
  }
4043
+ function listWorkspacePackages(cwd) {
4044
+ const packagesDir = path.join(cwd, "packages");
4045
+ const packages = [];
4046
+ try {
4047
+ for (const entry of readdirSync(packagesDir)) {
4048
+ const pkgPath = path.join(packagesDir, entry, "package.json");
4049
+ try {
4050
+ const pkg = parsePackageJson(readFileSync(pkgPath, "utf-8"));
4051
+ if (pkg?.name && pkg.version) packages.push({
4052
+ name: pkg.name,
4053
+ version: pkg.version,
4054
+ dir: entry
4055
+ });
4056
+ } catch (_error) {}
4057
+ }
4058
+ } catch (_error) {}
4059
+ return packages;
4060
+ }
4061
+ function readRegularFile(filePath, maxBytes) {
4062
+ let fd;
4063
+ try {
4064
+ fd = openSync(filePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
4065
+ } catch (error) {
4066
+ const code = fileErrorCode(error);
4067
+ if (code === "ENOENT") return { kind: "absent" };
4068
+ if (code === "ELOOP") return {
4069
+ kind: "refused",
4070
+ reason: "symlink"
4071
+ };
4072
+ return {
4073
+ kind: "refused",
4074
+ reason: "unreadable"
4075
+ };
4076
+ }
4077
+ try {
4078
+ const stat = fstatSync(fd);
4079
+ if (!stat.isFile()) return {
4080
+ kind: "refused",
4081
+ reason: "not-regular"
4082
+ };
4083
+ if (stat.size > maxBytes) return {
4084
+ kind: "refused",
4085
+ reason: "too-large"
4086
+ };
4087
+ const buffer = Buffer.alloc(maxBytes + 1);
4088
+ let bytesRead;
4089
+ try {
4090
+ bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
4091
+ } catch {
4092
+ return {
4093
+ kind: "refused",
4094
+ reason: "unreadable"
4095
+ };
4096
+ }
4097
+ if (bytesRead > maxBytes) return {
4098
+ kind: "refused",
4099
+ reason: "too-large"
4100
+ };
4101
+ return {
4102
+ kind: "ok",
4103
+ content: buffer.toString("utf8", 0, bytesRead)
4104
+ };
4105
+ } catch {
4106
+ return {
4107
+ kind: "refused",
4108
+ reason: "unreadable"
4109
+ };
4110
+ } finally {
4111
+ closeSync(fd);
4112
+ }
4113
+ }
4080
4114
  /**
4081
4115
  * Create an executor that runs real commands, fetches, and reads the filesystem. Subprocess and
4082
4116
  * network calls are traced through `logger` (default: the {@link rootLogger}); see
@@ -4093,24 +4127,7 @@ function createRealExecutor$1(logger = rootLogger) {
4093
4127
  return [];
4094
4128
  }
4095
4129
  },
4096
- listWorkspacePackages(cwd) {
4097
- const packagesDir = path.join(cwd, "packages");
4098
- const packages = [];
4099
- try {
4100
- for (const entry of readdirSync(packagesDir)) {
4101
- const pkgPath = path.join(packagesDir, entry, "package.json");
4102
- try {
4103
- const pkg = parsePackageJson(readFileSync(pkgPath, "utf-8"));
4104
- if (pkg?.name && pkg.version) packages.push({
4105
- name: pkg.name,
4106
- version: pkg.version,
4107
- dir: entry
4108
- });
4109
- } catch (_error) {}
4110
- }
4111
- } catch (_error) {}
4112
- return packages;
4113
- },
4130
+ listWorkspacePackages,
4114
4131
  listPackageDirs(cwd) {
4115
4132
  const packagesDir = path.join(cwd, "packages");
4116
4133
  try {
@@ -4140,59 +4157,7 @@ function createRealExecutor$1(logger = rootLogger) {
4140
4157
  return null;
4141
4158
  }
4142
4159
  },
4143
- readRegularFile(filePath, maxBytes) {
4144
- let fd;
4145
- try {
4146
- fd = openSync(filePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
4147
- } catch (error) {
4148
- const code = fileErrorCode(error);
4149
- if (code === "ENOENT") return { kind: "absent" };
4150
- if (code === "ELOOP") return {
4151
- kind: "refused",
4152
- reason: "symlink"
4153
- };
4154
- return {
4155
- kind: "refused",
4156
- reason: "unreadable"
4157
- };
4158
- }
4159
- try {
4160
- const stat = fstatSync(fd);
4161
- if (!stat.isFile()) return {
4162
- kind: "refused",
4163
- reason: "not-regular"
4164
- };
4165
- if (stat.size > maxBytes) return {
4166
- kind: "refused",
4167
- reason: "too-large"
4168
- };
4169
- const buffer = Buffer.alloc(maxBytes + 1);
4170
- let bytesRead;
4171
- try {
4172
- bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
4173
- } catch {
4174
- return {
4175
- kind: "refused",
4176
- reason: "unreadable"
4177
- };
4178
- }
4179
- if (bytesRead > maxBytes) return {
4180
- kind: "refused",
4181
- reason: "too-large"
4182
- };
4183
- return {
4184
- kind: "ok",
4185
- content: buffer.toString("utf8", 0, bytesRead)
4186
- };
4187
- } catch {
4188
- return {
4189
- kind: "refused",
4190
- reason: "unreadable"
4191
- };
4192
- } finally {
4193
- closeSync(fd);
4194
- }
4195
- },
4160
+ readRegularFile,
4196
4161
  writeFile(filePath, content) {
4197
4162
  mkdirSync(path.dirname(filePath), { recursive: true });
4198
4163
  writeFileSync(filePath, content);
@@ -5536,6 +5501,24 @@ function readPublishedImageVersion(executor, config, ref) {
5536
5501
  debug(config, `Unexpected manifest shape for ${ref}; no version label read`);
5537
5502
  }
5538
5503
  /**
5504
+ * Name every check-only image this run is about to publish or has just left out -- one line
5505
+ * per image with no explicit `publish` setting, since an explicit value either way is the
5506
+ * repo having answered the question already. Under `warn` the image still goes out and the
5507
+ * line is the notice that a later tooling release stops doing that; under `enforce` it did
5508
+ * not go out and the line says so. A dry run takes both lines verbatim: it is the run a
5509
+ * release rehearses, so a rehearsed phase has to read like the real one.
5510
+ */
5511
+ function reportCheckOnlyImages(packages, phase) {
5512
+ for (const pkg of packages) {
5513
+ if (pkg.checkOnlyIn === void 0 || pkg.docker.publish !== void 0) continue;
5514
+ if (phase === "warn") {
5515
+ log$1.warn(`Image "${pkg.imageName}" is declared only in ${pkg.checkOnlyIn}, the check overlay, so a later tooling release stops publishing it by default. Keep it: set publish to true for "${pkg.imageName}" under .tooling.json#docker. Settle it: set publish to false there, or move the service into a base compose file.`);
5516
+ continue;
5517
+ }
5518
+ log$1.info(`Skipped "${pkg.imageName}": it is declared only in ${pkg.checkOnlyIn}, the check overlay, so it was not pushed. Set publish to true for "${pkg.imageName}" under .tooling.json#docker to publish it anyway.`);
5519
+ }
5520
+ }
5521
+ /**
5539
5522
  * Run the full Docker publish pipeline:
5540
5523
  * 1. Build all images via runDockerBuild
5541
5524
  * 2. Login to registry
@@ -5555,24 +5538,6 @@ function readPublishedImageVersion(executor, config, ref) {
5555
5538
  * overlapping runs that both read a ref before either writes can still finish in the wrong order --
5556
5539
  * seconds wide, where an unconditional push was open for a whole run.
5557
5540
  */
5558
- /**
5559
- * Name every check-only image this run is about to publish or has just left out -- one line
5560
- * per image with no explicit `publish` setting, since an explicit value either way is the
5561
- * repo having answered the question already. Under `warn` the image still goes out and the
5562
- * line is the notice that a later tooling release stops doing that; under `enforce` it did
5563
- * not go out and the line says so. A dry run takes both lines verbatim: it is the run a
5564
- * release rehearses, so a rehearsed phase has to read like the real one.
5565
- */
5566
- function reportCheckOnlyImages(packages, phase) {
5567
- for (const pkg of packages) {
5568
- if (pkg.checkOnlyIn === void 0 || pkg.docker.publish !== void 0) continue;
5569
- if (phase === "warn") {
5570
- log$1.warn(`Image "${pkg.imageName}" is declared only in ${pkg.checkOnlyIn}, the check overlay, so a later tooling release stops publishing it by default. Keep it: set publish to true for "${pkg.imageName}" under .tooling.json#docker. Settle it: set publish to false there, or move the service into a base compose file.`);
5571
- continue;
5572
- }
5573
- log$1.info(`Skipped "${pkg.imageName}": it is declared only in ${pkg.checkOnlyIn}, the check overlay, so it was not pushed. Set publish to true for "${pkg.imageName}" under .tooling.json#docker to publish it anyway.`);
5574
- }
5575
- }
5576
5541
  function runDockerPublish(executor, config) {
5577
5542
  const checkOnlyPhase = config.checkOnlyPhase ?? "warn";
5578
5543
  const { packages } = runDockerBuild(executor, {
@@ -5668,6 +5633,136 @@ function contextAsDockerReader(ctx) {
5668
5633
  };
5669
5634
  }
5670
5635
  //#endregion
5636
+ //#region src/release/publish/publish-config-lint.ts
5637
+ const FIX = `add "publishConfig": { "registry": "<your registry URL>" } to its package.json`;
5638
+ /**
5639
+ * Whether a value can serve as a publish destination: an absolute http(s) URL.
5640
+ * Presence alone is not the test -- a relative or empty registry is as silently
5641
+ * wrong as an absent one, and npm resolves neither to the place the author meant.
5642
+ */
5643
+ function isUsableRegistry(value) {
5644
+ if (typeof value !== "string" || value.trim() === "") return false;
5645
+ try {
5646
+ const url = new URL(value);
5647
+ return url.protocol === "https:" || url.protocol === "http:";
5648
+ } catch {
5649
+ return false;
5650
+ }
5651
+ }
5652
+ function isRecord(value) {
5653
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5654
+ }
5655
+ /**
5656
+ * Require every package this repo would publish to declare where it publishes.
5657
+ *
5658
+ * npm and pnpm use one `registry` key for reads and writes alike, so a project
5659
+ * `.npmrc` aimed at an install cache silently repoints `pnpm publish` too. A
5660
+ * package that never declares its destination inherits one, and an inherited
5661
+ * destination can be changed by a file that never mentions publishing.
5662
+ *
5663
+ * Reports rather than repairs: the correct registry is not derivable here, and
5664
+ * writing a guess would redirect a working release exactly the way this gate
5665
+ * exists to prevent.
5666
+ */
5667
+ function lintPublishConfig(targetDir, structure) {
5668
+ const issues = [];
5669
+ for (const pkg of getPublishablePackages(targetDir, structure)) {
5670
+ const manifest = readPackageJson(pkg.dir);
5671
+ if (!manifest) continue;
5672
+ const file = path.relative(targetDir, path.join(pkg.dir, "package.json"));
5673
+ const pc = manifest.publishConfig;
5674
+ const registry = isRecord(pc) ? pc["registry"] : void 0;
5675
+ if (registry === void 0) {
5676
+ issues.push({
5677
+ kind: "missing",
5678
+ package: pkg.name,
5679
+ file,
5680
+ message: `${pkg.name} is published from this repo but declares no publishConfig.registry, so its release destination is inherited -- a project .npmrc can repoint it silently. In ${file}, ${FIX}.`
5681
+ });
5682
+ continue;
5683
+ }
5684
+ if (!isUsableRegistry(registry)) issues.push({
5685
+ kind: "malformed",
5686
+ package: pkg.name,
5687
+ file,
5688
+ message: `${pkg.name} declares publishConfig.registry as ${JSON.stringify(registry)}, which is not an absolute http(s) URL and cannot be published to. In ${file}, ${FIX}.`
5689
+ });
5690
+ }
5691
+ return issues;
5692
+ }
5693
+ /**
5694
+ * The registries this repo's publishable packages actually declare, distinct and in
5695
+ * first-seen order.
5696
+ *
5697
+ * Reuses the same "absolute http(s) URL" narrowing as {@link lintPublishConfig} rather
5698
+ * than re-deriving it -- a registry this misses here would fail that gate first, so
5699
+ * `repo:sync` never reaches a generator with an undeclared or malformed registry. A
5700
+ * caller that invokes a generator directly (or a repo whose publishable set is empty,
5701
+ * e.g. every package private) still gets a safe empty result.
5702
+ */
5703
+ function declaredPublishRegistries(targetDir, structure, rootPackageJson) {
5704
+ const registries = [];
5705
+ for (const pkg of getPublishablePackages(targetDir, structure, rootPackageJson)) {
5706
+ const manifest = structure === "single" ? rootPackageJson ?? readPackageJson(pkg.dir) : readPackageJson(pkg.dir);
5707
+ if (!manifest) continue;
5708
+ const pc = manifest.publishConfig;
5709
+ const registry = isRecord(pc) ? pc["registry"] : void 0;
5710
+ if (typeof registry === "string" && isUsableRegistry(registry) && !registries.includes(registry)) registries.push(registry);
5711
+ }
5712
+ return registries;
5713
+ }
5714
+ //#endregion
5715
+ //#region src/utils/generated-header.ts
5716
+ /**
5717
+ * Leading comment headers for fully generated files.
5718
+ *
5719
+ * These mark a file as managed by `@bensandee/tooling` so a reader knows that
5720
+ * `bst repo:sync` may overwrite it, and points at the supported ways to keep
5721
+ * custom content. Only used for files that are largely (re)generated wholesale
5722
+ * -- not for files that are merged into (package.json, tsconfig.json, etc.).
5723
+ */
5724
+ /**
5725
+ * Stable substring present in every managed-file header. Used to detect whether an
5726
+ * existing file already carries the *current* header -- a comment-only difference is
5727
+ * invisible to `normalizeWorkflow()` (it strips comments before comparing), so generators
5728
+ * check for this marker to know a one-time header upgrade is still needed.
5729
+ *
5730
+ * The trailing `--` is load-bearing: it is what made the 2026-07-27 ASCII fix reach files
5731
+ * that were already synced. Widen this marker whenever the header's leading text changes,
5732
+ * or the change lands in the template and never in anyone's repo.
5733
+ */
5734
+ const MANAGED_HEADER_MARKER = "Managed by @bensandee/tooling --";
5735
+ /** Render a comment block: each line is prefixed with `comment`; empty lines stay bare. */
5736
+ function managedFileHeader(comment, lines) {
5737
+ return lines.map((line) => line === "" ? comment : `${comment} ${line}`).join("\n") + "\n";
5738
+ }
5739
+ /**
5740
+ * Header for generated CI workflow files (`ci.yml`, `release.yml`).
5741
+ *
5742
+ * The literal `@bensandee/tooling:ignore` directive is kept mid-line on purpose so
5743
+ * `isToolingIgnored()` (which looks for the directive at the start of a comment) does
5744
+ * not mistake this header for an opt-out.
5745
+ */
5746
+ const WORKFLOW_MANAGED_HEADER = managedFileHeader("#", [
5747
+ "Managed by @bensandee/tooling -- `bst repo:sync` regenerates this workflow,",
5748
+ "so direct edits are overwritten on the next sync.",
5749
+ "To preserve changes, wrap them between `# @tooling:custom` and",
5750
+ "`# @tooling:endcustom` markers, or stop managing this file by making",
5751
+ "`# @bensandee/tooling:ignore` the first line."
5752
+ ]);
5753
+ /** Header for the generated `lefthook.yml` pre-commit config. */
5754
+ const LEFTHOOK_MANAGED_HEADER = managedFileHeader("#", [
5755
+ "Managed by @bensandee/tooling -- `bst repo:sync` keeps the `lint` and",
5756
+ "`format` pre-commit commands current. Other commands you add are preserved.",
5757
+ "To stop managing this file, make `# @bensandee/tooling:ignore` the first line."
5758
+ ]);
5759
+ /**
5760
+ * Header for a generated agent guide (`.agent/guides/*.md`). One line, because guide ownership
5761
+ * is decided by the marker on the first line alone. It spells the ignore directive out in words
5762
+ * rather than as the literal comment, which would close this one early.
5763
+ */
5764
+ const GUIDE_MANAGED_HEADER = "<!-- Managed by @bensandee/tooling -- `bst repo:sync` rewrites this guide and archives local edits. To keep your own version, make the first line an HTML comment reading @bensandee/tooling:ignore. -->\n";
5765
+ //#endregion
5671
5766
  //#region src/utils/yaml-merge.ts
5672
5767
  const IGNORE_PATTERN = "@bensandee/tooling:ignore";
5673
5768
  const CUSTOM_START = "# @tooling:custom";
@@ -5930,129 +6025,50 @@ function extractPinnedActions(existing) {
5930
6025
  * generator's step objects cannot carry. Preserving them would leave every repo on the commit it
5931
6026
  * first received.
5932
6027
  */
5933
- function preservePinnedActions(generated, existing, owned = []) {
5934
- let result = generated;
5935
- for (const { name, comment } of owned) {
5936
- const pattern = new RegExp(String.raw`^(\s*(?:-\s+)?uses:\s*)${escapeRegex(name)}@(\S+)(?:[ \t]+#[^\n]*)?$`, "gm");
5937
- result = result.replace(pattern, `$1${name}@$2 # ${comment}`);
5938
- }
5939
- if (!existing) return result;
5940
- const pins = extractPinnedActions(existing);
5941
- if (pins.size === 0) return result;
5942
- for (const [name, tail] of pins) {
5943
- if (owned.some((action) => action.name === name)) continue;
5944
- const pattern = new RegExp(String.raw`^(\s*(?:-\s+)?uses:\s*)${escapeRegex(name)}@\S+(?:[ \t]+#[^\n]*)?$`, "gm");
5945
- result = result.replace(pattern, `$1${name}@${tail}`);
5946
- }
5947
- return result;
5948
- }
5949
- /** Build a complete workflow YAML string from structured options. Single source of truth for workflow files. */
5950
- function buildWorkflowYaml(options) {
5951
- const doc = { name: options.name };
5952
- if (options.enableEmailNotifications) doc["enable-email-notifications"] = true;
5953
- doc["on"] = options.on;
5954
- if (options.permissions) doc["permissions"] = options.permissions;
5955
- if (options.concurrency) doc["concurrency"] = options.concurrency;
5956
- const jobs = { [options.jobName]: {
5957
- ...options.jobIf && { if: options.jobIf },
5958
- "runs-on": options.runsOn ?? "ubuntu-latest",
5959
- ...options.jobEnv && { env: options.jobEnv },
5960
- ...options.jobOutputs && { outputs: options.jobOutputs },
5961
- steps: options.steps.map((s) => s.step)
5962
- } };
5963
- for (const [name, def] of Object.entries(options.extraJobs ?? {})) jobs[name] = {
5964
- ...def.needs && { needs: def.needs },
5965
- ...def.if && { if: def.if },
5966
- ...def["continue-on-error"] && { "continue-on-error": true },
5967
- "runs-on": def.runsOn ?? "ubuntu-latest",
5968
- ...def.env && { env: def.env },
5969
- steps: def.steps.map((s) => s.step)
5970
- };
5971
- doc["jobs"] = jobs;
5972
- const readable = stringify(doc, {
5973
- lineWidth: 0,
5974
- nullStr: ""
5975
- }).replace(/^(?=\S)/gm, (match, offset) => offset === 0 ? match : `\n${match}`);
5976
- return ensureSchemaComment(WORKFLOW_MANAGED_HEADER + "\n" + readable, options.ci);
5977
- }
5978
- //#endregion
5979
- //#region src/release/publish/publish-config-lint.ts
5980
- const FIX = `add "publishConfig": { "registry": "<your registry URL>" } to its package.json`;
5981
- /**
5982
- * Whether a value can serve as a publish destination: an absolute http(s) URL.
5983
- * Presence alone is not the test -- a relative or empty registry is as silently
5984
- * wrong as an absent one, and npm resolves neither to the place the author meant.
5985
- */
5986
- function isUsableRegistry(value) {
5987
- if (typeof value !== "string" || value.trim() === "") return false;
5988
- try {
5989
- const url = new URL(value);
5990
- return url.protocol === "https:" || url.protocol === "http:";
5991
- } catch {
5992
- return false;
5993
- }
5994
- }
5995
- function isRecord(value) {
5996
- return typeof value === "object" && value !== null && !Array.isArray(value);
5997
- }
5998
- /**
5999
- * Require every package this repo would publish to declare where it publishes.
6000
- *
6001
- * npm and pnpm use one `registry` key for reads and writes alike, so a project
6002
- * `.npmrc` aimed at an install cache silently repoints `pnpm publish` too. A
6003
- * package that never declares its destination inherits one, and an inherited
6004
- * destination can be changed by a file that never mentions publishing.
6005
- *
6006
- * Reports rather than repairs: the correct registry is not derivable here, and
6007
- * writing a guess would redirect a working release exactly the way this gate
6008
- * exists to prevent.
6009
- */
6010
- function lintPublishConfig(targetDir, structure) {
6011
- const issues = [];
6012
- for (const pkg of getPublishablePackages(targetDir, structure)) {
6013
- const manifest = readPackageJson(pkg.dir);
6014
- if (!manifest) continue;
6015
- const file = path.relative(targetDir, path.join(pkg.dir, "package.json"));
6016
- const pc = manifest.publishConfig;
6017
- const registry = isRecord(pc) ? pc["registry"] : void 0;
6018
- if (registry === void 0) {
6019
- issues.push({
6020
- kind: "missing",
6021
- package: pkg.name,
6022
- file,
6023
- message: `${pkg.name} is published from this repo but declares no publishConfig.registry, so its release destination is inherited -- a project .npmrc can repoint it silently. In ${file}, ${FIX}.`
6024
- });
6025
- continue;
6026
- }
6027
- if (!isUsableRegistry(registry)) issues.push({
6028
- kind: "malformed",
6029
- package: pkg.name,
6030
- file,
6031
- message: `${pkg.name} declares publishConfig.registry as ${JSON.stringify(registry)}, which is not an absolute http(s) URL and cannot be published to. In ${file}, ${FIX}.`
6032
- });
6033
- }
6034
- return issues;
6035
- }
6036
- /**
6037
- * The registries this repo's publishable packages actually declare, distinct and in
6038
- * first-seen order.
6039
- *
6040
- * Reuses the same "absolute http(s) URL" narrowing as {@link lintPublishConfig} rather
6041
- * than re-deriving it -- a registry this misses here would fail that gate first, so
6042
- * `repo:sync` never reaches a generator with an undeclared or malformed registry. A
6043
- * caller that invokes a generator directly (or a repo whose publishable set is empty,
6044
- * e.g. every package private) still gets a safe empty result.
6045
- */
6046
- function declaredPublishRegistries(targetDir, structure, rootPackageJson) {
6047
- const registries = [];
6048
- for (const pkg of getPublishablePackages(targetDir, structure, rootPackageJson)) {
6049
- const manifest = structure === "single" ? rootPackageJson ?? readPackageJson(pkg.dir) : readPackageJson(pkg.dir);
6050
- if (!manifest) continue;
6051
- const pc = manifest.publishConfig;
6052
- const registry = isRecord(pc) ? pc["registry"] : void 0;
6053
- if (typeof registry === "string" && isUsableRegistry(registry) && !registries.includes(registry)) registries.push(registry);
6028
+ function preservePinnedActions(generated, existing, owned = []) {
6029
+ let result = generated;
6030
+ for (const { name, comment } of owned) {
6031
+ const pattern = new RegExp(String.raw`^(\s*(?:-\s+)?uses:\s*)${escapeRegex(name)}@(\S+)(?:[ \t]+#[^\n]*)?$`, "gm");
6032
+ result = result.replace(pattern, `$1${name}@$2 # ${comment}`);
6054
6033
  }
6055
- return registries;
6034
+ if (!existing) return result;
6035
+ const pins = extractPinnedActions(existing);
6036
+ if (pins.size === 0) return result;
6037
+ for (const [name, tail] of pins) {
6038
+ if (owned.some((action) => action.name === name)) continue;
6039
+ const pattern = new RegExp(String.raw`^(\s*(?:-\s+)?uses:\s*)${escapeRegex(name)}@\S+(?:[ \t]+#[^\n]*)?$`, "gm");
6040
+ result = result.replace(pattern, `$1${name}@${tail}`);
6041
+ }
6042
+ return result;
6043
+ }
6044
+ /** Build a complete workflow YAML string from structured options. Single source of truth for workflow files. */
6045
+ function buildWorkflowYaml(options) {
6046
+ const doc = { name: options.name };
6047
+ if (options.enableEmailNotifications) doc["enable-email-notifications"] = true;
6048
+ doc["on"] = options.on;
6049
+ if (options.permissions) doc["permissions"] = options.permissions;
6050
+ if (options.concurrency) doc["concurrency"] = options.concurrency;
6051
+ const jobs = { [options.jobName]: {
6052
+ ...options.jobIf && { if: options.jobIf },
6053
+ "runs-on": options.runsOn ?? "ubuntu-latest",
6054
+ ...options.jobEnv && { env: options.jobEnv },
6055
+ ...options.jobOutputs && { outputs: options.jobOutputs },
6056
+ steps: options.steps.map((s) => s.step)
6057
+ } };
6058
+ for (const [name, def] of Object.entries(options.extraJobs ?? {})) jobs[name] = {
6059
+ ...def.needs && { needs: def.needs },
6060
+ ...def.if && { if: def.if },
6061
+ ...def["continue-on-error"] && { "continue-on-error": true },
6062
+ "runs-on": def.runsOn ?? "ubuntu-latest",
6063
+ ...def.env && { env: def.env },
6064
+ steps: def.steps.map((s) => s.step)
6065
+ };
6066
+ doc["jobs"] = jobs;
6067
+ const readable = stringify(doc, {
6068
+ lineWidth: 0,
6069
+ nullStr: ""
6070
+ }).replace(/^(?=\S)/gm, (match, offset) => offset === 0 ? match : `\n${match}`);
6071
+ return ensureSchemaComment(WORKFLOW_MANAGED_HEADER + "\n" + readable, options.ci);
6056
6072
  }
6057
6073
  //#endregion
6058
6074
  //#region src/generators/mise-pin.ts
@@ -6068,8 +6084,8 @@ function declaredPublishRegistries(targetDir, structure, rootPackageJson) {
6068
6084
  * shape of custom regex manager in the root `renovate.json` can rewrite both this version and this
6069
6085
  * digest (`customManagers` entries scoped to this file).
6070
6086
  */
6071
- const MISE_VERSION = "2026.10.2";
6072
- const MISE_LINUX_X64_SHA256 = "8f5f6660336f572830e33cd9b378d3131e529a0d4c4f0c553776be90a1ba302a";
6087
+ const MISE_VERSION = "2026.10.3";
6088
+ const MISE_LINUX_X64_SHA256 = "8d48bc510b7d844fad0bc7156c0855c2a1e7230c51e498a7f6b63b021f8b57c5";
6073
6089
  /** Where generated Forgejo workflows fetch the shared mise setup action: a composite action in
6074
6090
  * `bensandee/common`, on the forge every fleet repo lives on. GitHub cannot reach it; the
6075
6091
  * generators keep inline steps there. */
@@ -6077,7 +6093,7 @@ const SETUP_MISE_ACTION_URL = "https://code.orangebikelabs.com/bensandee/common/
6077
6093
  /** The tag in `bensandee/common` that marks the current release of the action. Generated `uses:`
6078
6094
  * lines carry it as their comment, and Renovate moves the SHA below to wherever it points. */
6079
6095
  const SETUP_MISE_ACTION_TAG = "setup-mise@latest";
6080
- const SETUP_MISE_ACTION_SHA = "13f60e0f19ad57b5616f94c9bc18bf214bbe91fe";
6096
+ const SETUP_MISE_ACTION_SHA = "db99e4e9ee46bfeaca2146e7ae55cc700b4c23bf";
6081
6097
  //#endregion
6082
6098
  //#region src/generators/ci-utils.ts
6083
6099
  /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
@@ -6125,30 +6141,6 @@ const IS_MAIN_JOB_ENV = { IS_MAIN: actionsExpr(IS_MAIN_BRANCH) };
6125
6141
  /** `if:` condition referencing the job-level `IS_MAIN` env var (see {@link IS_MAIN_JOB_ENV}). */
6126
6142
  const IS_MAIN_ENV_GUARD = "env.IS_MAIN == 'true'";
6127
6143
  /**
6128
- * Job-level default for the npm auth token, declared as the empty string.
6129
- *
6130
- * This repo's own `Configure npm auth` step (see {@link npmAuthWiring}) appends
6131
- * `//<registry>/:_authToken=${NODE_AUTH_TOKEN}` lines to `~/.npmrc` for each
6132
- * declared `publishConfig.registry`. Only the publishing step defines that
6133
- * variable, so every earlier pnpm invocation -- install, and each nested pnpm
6134
- * inside `ci:check` -- hits an undefined var and prints
6135
- * `[WARN] Failed to replace env in config: ${NODE_AUTH_TOKEN}`. Declaring it
6136
- * empty at job level makes the interpolation succeed and the warning vanish; an
6137
- * empty token is not sent as credentials, so anonymous registry reads are
6138
- * unaffected.
6139
- *
6140
- * Deliberately empty rather than the real secret: the publishing step overrides
6141
- * it with `secrets.NPM_TOKEN` via step-level `env`, which keeps the token out of
6142
- * `pnpm install` (and any dependency's postinstall script) the way it is today.
6143
- * The empty default also has to survive steps *after* the publish step
6144
- * (`release:assets`, `docker:publish`, deploy dispatch): those shell out to pnpm
6145
- * again without the secret in scope, so without this default they would warn too.
6146
- *
6147
- * Emit this in lockstep with the `Configure npm auth` step -- both come from the
6148
- * same {@link npmAuthWiring} call, so a job that gets one gets the other.
6149
- */
6150
- const NPM_AUTH_JOB_ENV = { NODE_AUTH_TOKEN: "" };
6151
- /**
6152
6144
  * npm's own registry-key rule for an `.npmrc` auth line: strip the scheme, keep
6153
6145
  * host/port/path, and end with exactly one slash. `actions/setup-node` derives the
6154
6146
  * same key from a registry URL; this mirrors that so a hand-written line matches
@@ -6160,26 +6152,38 @@ function npmrcAuthKey(registryUrl) {
6160
6152
  }
6161
6153
  const NODE_AUTH_TOKEN_VAR = ["$", "{NODE_AUTH_TOKEN}"].join("");
6162
6154
  /**
6163
- * Build every half of a job's npm auth wiring together, so one value decides all
6164
- * three: the `Configure npm auth` step, the job-level `NODE_AUTH_TOKEN` default,
6165
- * and the step-level `NODE_AUTH_TOKEN` the publish step should carry. A job that
6166
- * gets none of the declared registries gets none of the three -- including a
6167
- * `publishes: true` job whose publishable set is empty (every package private),
6168
- * since there is then no npmrc line for a real secret to pair with.
6155
+ * Build both halves of a job's npm auth wiring together, so one value decides
6156
+ * them: the auth lines the publishing step writes, and the step-level
6157
+ * `NODE_AUTH_TOKEN` it carries. A job that gets none of the declared registries
6158
+ * gets neither -- including a `publishes: true` job whose publishable set is
6159
+ * empty (every package private), since there is then no npmrc line for a real
6160
+ * secret to pair with.
6161
+ *
6162
+ * The lines go into a temp userconfig exported only inside the publishing
6163
+ * step's shell, never `~/.npmrc` or `$GITHUB_ENV`: pnpm 11 warns
6164
+ * `Failed to replace env in config` on every call that reads the placeholder
6165
+ * while the token is unset or empty, so a line in a shared file makes install, `ci:check`,
6166
+ * and every step after the publish warn. An empty job-level default does not
6167
+ * silence it, and `${NODE_AUTH_TOKEN:-}` is rejected as an empty fallback and
6168
+ * warns in every case. The `-n` guard keeps a run without the secret quiet; the
6169
+ * single quotes keep the token itself off disk, since npm expands it at read time.
6169
6170
  */
6170
6171
  function npmAuthWiring(ctx, publishes) {
6171
6172
  const registries = publishes ? declaredPublishRegistries(ctx.targetDir, ctx.config.structure, ctx.packageJson) : [];
6172
6173
  if (registries.length === 0) return {
6173
- steps: [],
6174
- jobEnv: {},
6174
+ publishRun: (command) => command,
6175
6175
  tokenEnv: {}
6176
6176
  };
6177
+ const setup = [
6178
+ "if [ -n \"$NODE_AUTH_TOKEN\" ]; then",
6179
+ " NPM_CONFIG_USERCONFIG=\"$(mktemp)\"",
6180
+ " export NPM_CONFIG_USERCONFIG",
6181
+ " if [ -f ~/.npmrc ]; then cat ~/.npmrc > \"$NPM_CONFIG_USERCONFIG\"; fi",
6182
+ ...registries.map((registry) => ` printf '\\n%s\\n' '${npmrcAuthKey(registry)}:_authToken=${NODE_AUTH_TOKEN_VAR}' >> "$NPM_CONFIG_USERCONFIG"`),
6183
+ "fi"
6184
+ ];
6177
6185
  return {
6178
- steps: [{ step: {
6179
- name: "Configure npm auth",
6180
- run: registries.map((registry) => `printf '\\n%s\\n' '${npmrcAuthKey(registry)}:_authToken=${NODE_AUTH_TOKEN_VAR}' >> ~/.npmrc`).join("\n")
6181
- } }],
6182
- jobEnv: NPM_AUTH_JOB_ENV,
6186
+ publishRun: (command) => [...setup, command].join("\n"),
6183
6187
  tokenEnv: { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
6184
6188
  };
6185
6189
  }
@@ -6304,6 +6308,37 @@ function dockerPublishEnv(host) {
6304
6308
  RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
6305
6309
  };
6306
6310
  }
6311
+ /** Where `ctx.remove()` puts what it archives. Named here only to describe it to the user. */
6312
+ const ARCHIVE_DIR$1 = ".tooling-archived";
6313
+ /**
6314
+ * Retire a workflow file tooling used to generate and no longer writes.
6315
+ *
6316
+ * Returns `undefined` when there is no file at `workflowPath` -- nothing to report. A file
6317
+ * tooling does not manage (it carries the ignore comment, or no managed header) is left in
6318
+ * place with a `skipped` result naming it, so a plain `repo:sync` tells the human why the
6319
+ * leftover is still there. Any other file is removed: `ctx.remove()` archives before deleting
6320
+ * (see createContext), so no manual copy is needed.
6321
+ *
6322
+ * The result is `removed`, not `archived`: `repo:sync --check` counts only the former as drift,
6323
+ * and the agent's workflow sync sweep acts only on drift the check reports.
6324
+ */
6325
+ function retireManagedWorkflow(ctx, workflowPath, reason) {
6326
+ const existing = ctx.read(workflowPath);
6327
+ if (existing === void 0) return void 0;
6328
+ if (isToolingIgnored(existing) || !existing.includes("Managed by @bensandee/tooling --")) return {
6329
+ filePath: workflowPath,
6330
+ action: "skipped",
6331
+ description: `${workflowPath} is not managed by tooling, so it was left in place`
6332
+ };
6333
+ ctx.remove(workflowPath);
6334
+ let description = `Moved to ${ARCHIVE_DIR$1}/${workflowPath} (${reason})`;
6335
+ if (extractCustomBlocks(existing).length > 0) description += "; its # @tooling:custom blocks are kept only in the archive";
6336
+ return {
6337
+ filePath: workflowPath,
6338
+ action: "removed",
6339
+ description
6340
+ };
6341
+ }
6307
6342
  //#endregion
6308
6343
  //#region src/generators/publish-ci.ts
6309
6344
  const RegistryHostSchema = z.object({ registryHost: z.string().optional() });
@@ -6342,18 +6377,6 @@ function resolveGenTimeHost(ctx) {
6342
6377
  if (!parsed) return null;
6343
6378
  return registryHostFromHostname(parsed.hostname);
6344
6379
  }
6345
- function publishSteps(ci, host) {
6346
- return [
6347
- { step: { uses: "actions/checkout@v6" } },
6348
- ...miseSetupSteps(ci),
6349
- { step: { run: "pnpm install --frozen-lockfile" } },
6350
- { step: {
6351
- name: "Publish Docker images",
6352
- env: dockerPublishEnv(host),
6353
- run: "pnpm exec bst docker:publish"
6354
- } }
6355
- ];
6356
- }
6357
6380
  const PostReleaseHookSchema = z.object({
6358
6381
  postReleaseHook: z.string().optional(),
6359
6382
  postReleaseHookSecrets: z.array(z.string()).optional()
@@ -6472,63 +6495,24 @@ function hasVersionStamp(ctx) {
6472
6495
  return false;
6473
6496
  }
6474
6497
  }
6475
- /** Where `ctx.remove()` puts what it archives. Named here only to describe it to the user. */
6476
- const ARCHIVE_DIR$2 = ".tooling-archived";
6498
+ /**
6499
+ * Archive the manual `publish.yml` this generator used to write; it writes nothing. Docker
6500
+ * images publish from the release workflow, and the manual workflow was a thin wrapper around
6501
+ * `bst docker:publish` that nobody ran -- run that command instead. Only a file carrying the
6502
+ * managed header is removed: a hand-written `publish.yml`, or one opted out with the ignore
6503
+ * comment, belongs to the repo.
6504
+ */
6477
6505
  async function generateDeployCi(ctx) {
6478
6506
  const filePath = "deploy-ci";
6479
- if (!ctx.config.publishDocker || ctx.config.ci === "none") return {
6507
+ if (ctx.config.ci === "none") return {
6480
6508
  filePath,
6481
6509
  action: "skipped",
6482
- description: "Deploy CI workflow not applicable"
6510
+ description: "Publish workflow not applicable"
6483
6511
  };
6484
- const isGitHub = ctx.config.ci === "github";
6485
- const workflowPath = isGitHub ? ".github/workflows/publish.yml" : ".forgejo/workflows/publish.yml";
6486
- if (ctx.config.releaseStrategy === "simple") {
6487
- if (ctx.read(workflowPath) !== void 0) {
6488
- ctx.remove(workflowPath);
6489
- return {
6490
- filePath: workflowPath,
6491
- action: "archived",
6492
- description: `Moved to ${ARCHIVE_DIR$2}/${workflowPath} (docker publish runs in release.yml for simple strategy)`
6493
- };
6494
- }
6495
- return {
6496
- filePath,
6497
- action: "skipped",
6498
- description: "Publish workflow not generated for simple release strategy"
6499
- };
6500
- }
6501
- const host = resolveGenTimeHost(ctx);
6502
- const steps = publishSteps(ctx.config.ci, host);
6503
- const content = buildWorkflowYaml({
6504
- ci: ctx.config.ci,
6505
- name: "Publish",
6506
- on: { workflow_dispatch: null },
6507
- jobName: "publish",
6508
- steps
6509
- });
6510
- const alreadyExists = ctx.exists(workflowPath);
6511
- const existing = alreadyExists ? ctx.read(workflowPath) : void 0;
6512
- const withDevPath = applyWorkspaceToolingBuild(content, ctx.packageJson);
6513
- if (existing) {
6514
- if (isToolingIgnored(existing)) return {
6515
- filePath: workflowPath,
6516
- action: "skipped",
6517
- description: "Publish workflow has ignore comment"
6518
- };
6519
- if (existing.includes("Managed by @bensandee/tooling --") && normalizeWorkflow(existing) === normalizeWorkflow(withDevPath)) return {
6520
- filePath: workflowPath,
6521
- action: "skipped",
6522
- description: "Publish workflow already up to date"
6523
- };
6524
- }
6525
- const withPinnedActions = preservePinnedActions(withDevPath, existing, GENERATOR_OWNED_ACTIONS);
6526
- const final = existing ? insertCustomBlocks(withPinnedActions, extractCustomBlocks(existing)) : withPinnedActions;
6527
- ctx.write(workflowPath, final);
6528
- return {
6529
- filePath: workflowPath,
6530
- action: alreadyExists ? "updated" : "created",
6531
- description: `Generated ${isGitHub ? "GitHub" : "Forgejo"} Actions publish workflow`
6512
+ return retireManagedWorkflow(ctx, ctx.config.ci === "github" ? ".github/workflows/publish.yml" : ".forgejo/workflows/publish.yml", "run `bst docker:publish` to publish by hand") ?? {
6513
+ filePath,
6514
+ action: "skipped",
6515
+ description: "No publish workflow to retire"
6532
6516
  };
6533
6517
  }
6534
6518
  //#endregion
@@ -6758,7 +6742,8 @@ function diagnoseCheckScriptForwardingAt(targetDir) {
6758
6742
  * Single source of truth for the supported Node floor: `engines.node` (`>=<floor>`) is the
6759
6743
  * honest minimum this package declares. `mise.toml`'s `tools.node` (`generators/mise.ts`) is the
6760
6744
  * exact pin every consumer -- the agent's containers, generated CI, a developer's shell -- now
6761
- * installs and runs, seeded from this same floor on first generation.
6745
+ * installs and runs, seeded on first generation from the highest of this floor, `engines.node`,
6746
+ * and the `devEngines.runtime` range removed below.
6762
6747
  *
6763
6748
  * `devEngines.runtime` used to carry a caret form of this floor for `actions/setup-node` and
6764
6749
  * pnpm's runtime-download check to read; both are retired by the shared "Install mise" workflow
@@ -6923,7 +6908,7 @@ function perPackageDevDeps(config) {
6923
6908
  deps["typescript"] = {
6924
6909
  "@changesets/cli": "3.0.3",
6925
6910
  "@release-it/bumper": "8.0.1",
6926
- "@types/node": "24.13.5",
6911
+ "@types/node": "24.19.1",
6927
6912
  "@vitest/coverage-v8": "5.0.3",
6928
6913
  "commit-and-tag-version": "13.2.1",
6929
6914
  "knip": "6.39.0",
@@ -6937,12 +6922,12 @@ function perPackageDevDeps(config) {
6937
6922
  "tsdown": "0.23.0",
6938
6923
  "typescript": "6.0.3",
6939
6924
  "vitest": "5.0.3",
6940
- "pnpm": "11.27.1"
6925
+ "pnpm": "11.28.3"
6941
6926
  }["typescript"];
6942
6927
  deps["@types/node"] = {
6943
6928
  "@changesets/cli": "3.0.3",
6944
6929
  "@release-it/bumper": "8.0.1",
6945
- "@types/node": "24.13.5",
6930
+ "@types/node": "24.19.1",
6946
6931
  "@vitest/coverage-v8": "5.0.3",
6947
6932
  "commit-and-tag-version": "13.2.1",
6948
6933
  "knip": "6.39.0",
@@ -6956,13 +6941,13 @@ function perPackageDevDeps(config) {
6956
6941
  "tsdown": "0.23.0",
6957
6942
  "typescript": "6.0.3",
6958
6943
  "vitest": "5.0.3",
6959
- "pnpm": "11.27.1"
6944
+ "pnpm": "11.28.3"
6960
6945
  }["@types/node"];
6961
6946
  }
6962
6947
  if (config.builder === "tsdown") deps["tsdown"] = {
6963
6948
  "@changesets/cli": "3.0.3",
6964
6949
  "@release-it/bumper": "8.0.1",
6965
- "@types/node": "24.13.5",
6950
+ "@types/node": "24.19.1",
6966
6951
  "@vitest/coverage-v8": "5.0.3",
6967
6952
  "commit-and-tag-version": "13.2.1",
6968
6953
  "knip": "6.39.0",
@@ -6976,7 +6961,7 @@ function perPackageDevDeps(config) {
6976
6961
  "tsdown": "0.23.0",
6977
6962
  "typescript": "6.0.3",
6978
6963
  "vitest": "5.0.3",
6979
- "pnpm": "11.27.1"
6964
+ "pnpm": "11.28.3"
6980
6965
  }["tsdown"];
6981
6966
  return deps;
6982
6967
  }
@@ -6993,7 +6978,7 @@ function vitestDevDeps() {
6993
6978
  vitest: {
6994
6979
  "@changesets/cli": "3.0.3",
6995
6980
  "@release-it/bumper": "8.0.1",
6996
- "@types/node": "24.13.5",
6981
+ "@types/node": "24.19.1",
6997
6982
  "@vitest/coverage-v8": "5.0.3",
6998
6983
  "commit-and-tag-version": "13.2.1",
6999
6984
  "knip": "6.39.0",
@@ -7007,12 +6992,12 @@ function vitestDevDeps() {
7007
6992
  "tsdown": "0.23.0",
7008
6993
  "typescript": "6.0.3",
7009
6994
  "vitest": "5.0.3",
7010
- "pnpm": "11.27.1"
6995
+ "pnpm": "11.28.3"
7011
6996
  }["vitest"],
7012
6997
  "@vitest/coverage-v8": {
7013
6998
  "@changesets/cli": "3.0.3",
7014
6999
  "@release-it/bumper": "8.0.1",
7015
- "@types/node": "24.13.5",
7000
+ "@types/node": "24.19.1",
7016
7001
  "@vitest/coverage-v8": "5.0.3",
7017
7002
  "commit-and-tag-version": "13.2.1",
7018
7003
  "knip": "6.39.0",
@@ -7026,7 +7011,7 @@ function vitestDevDeps() {
7026
7011
  "tsdown": "0.23.0",
7027
7012
  "typescript": "6.0.3",
7028
7013
  "vitest": "5.0.3",
7029
- "pnpm": "11.27.1"
7014
+ "pnpm": "11.28.3"
7030
7015
  }["@vitest/coverage-v8"]
7031
7016
  };
7032
7017
  }
@@ -7040,7 +7025,7 @@ function rootDevDeps(config) {
7040
7025
  const deps = { lefthook: {
7041
7026
  "@changesets/cli": "3.0.3",
7042
7027
  "@release-it/bumper": "8.0.1",
7043
- "@types/node": "24.13.5",
7028
+ "@types/node": "24.19.1",
7044
7029
  "@vitest/coverage-v8": "5.0.3",
7045
7030
  "commit-and-tag-version": "13.2.1",
7046
7031
  "knip": "6.39.0",
@@ -7054,13 +7039,13 @@ function rootDevDeps(config) {
7054
7039
  "tsdown": "0.23.0",
7055
7040
  "typescript": "6.0.3",
7056
7041
  "vitest": "5.0.3",
7057
- "pnpm": "11.27.1"
7042
+ "pnpm": "11.28.3"
7058
7043
  }["lefthook"] };
7059
7044
  if (config.stack !== "none") {
7060
7045
  deps["knip"] = {
7061
7046
  "@changesets/cli": "3.0.3",
7062
7047
  "@release-it/bumper": "8.0.1",
7063
- "@types/node": "24.13.5",
7048
+ "@types/node": "24.19.1",
7064
7049
  "@vitest/coverage-v8": "5.0.3",
7065
7050
  "commit-and-tag-version": "13.2.1",
7066
7051
  "knip": "6.39.0",
@@ -7074,12 +7059,12 @@ function rootDevDeps(config) {
7074
7059
  "tsdown": "0.23.0",
7075
7060
  "typescript": "6.0.3",
7076
7061
  "vitest": "5.0.3",
7077
- "pnpm": "11.27.1"
7062
+ "pnpm": "11.28.3"
7078
7063
  }["knip"];
7079
7064
  deps["oxlint"] = {
7080
7065
  "@changesets/cli": "3.0.3",
7081
7066
  "@release-it/bumper": "8.0.1",
7082
- "@types/node": "24.13.5",
7067
+ "@types/node": "24.19.1",
7083
7068
  "@vitest/coverage-v8": "5.0.3",
7084
7069
  "commit-and-tag-version": "13.2.1",
7085
7070
  "knip": "6.39.0",
@@ -7093,14 +7078,14 @@ function rootDevDeps(config) {
7093
7078
  "tsdown": "0.23.0",
7094
7079
  "typescript": "6.0.3",
7095
7080
  "vitest": "5.0.3",
7096
- "pnpm": "11.27.1"
7081
+ "pnpm": "11.28.3"
7097
7082
  }["oxlint"];
7098
7083
  Object.assign(deps, vitestDevDeps());
7099
7084
  }
7100
7085
  if (config.stack === "typescript") deps["oxlint-tsgolint"] = {
7101
7086
  "@changesets/cli": "3.0.3",
7102
7087
  "@release-it/bumper": "8.0.1",
7103
- "@types/node": "24.13.5",
7088
+ "@types/node": "24.19.1",
7104
7089
  "@vitest/coverage-v8": "5.0.3",
7105
7090
  "commit-and-tag-version": "13.2.1",
7106
7091
  "knip": "6.39.0",
@@ -7114,7 +7099,7 @@ function rootDevDeps(config) {
7114
7099
  "tsdown": "0.23.0",
7115
7100
  "typescript": "6.0.3",
7116
7101
  "vitest": "5.0.3",
7117
- "pnpm": "11.27.1"
7102
+ "pnpm": "11.28.3"
7118
7103
  }["oxlint-tsgolint"];
7119
7104
  return deps;
7120
7105
  }
@@ -7145,7 +7130,7 @@ function addReleaseDeps(deps, config) {
7145
7130
  deps["release-it"] = {
7146
7131
  "@changesets/cli": "3.0.3",
7147
7132
  "@release-it/bumper": "8.0.1",
7148
- "@types/node": "24.13.5",
7133
+ "@types/node": "24.19.1",
7149
7134
  "@vitest/coverage-v8": "5.0.3",
7150
7135
  "commit-and-tag-version": "13.2.1",
7151
7136
  "knip": "6.39.0",
@@ -7159,12 +7144,12 @@ function addReleaseDeps(deps, config) {
7159
7144
  "tsdown": "0.23.0",
7160
7145
  "typescript": "6.0.3",
7161
7146
  "vitest": "5.0.3",
7162
- "pnpm": "11.27.1"
7147
+ "pnpm": "11.28.3"
7163
7148
  }["release-it"];
7164
7149
  if (config.structure === "monorepo") deps["@release-it/bumper"] = {
7165
7150
  "@changesets/cli": "3.0.3",
7166
7151
  "@release-it/bumper": "8.0.1",
7167
- "@types/node": "24.13.5",
7152
+ "@types/node": "24.19.1",
7168
7153
  "@vitest/coverage-v8": "5.0.3",
7169
7154
  "commit-and-tag-version": "13.2.1",
7170
7155
  "knip": "6.39.0",
@@ -7178,14 +7163,14 @@ function addReleaseDeps(deps, config) {
7178
7163
  "tsdown": "0.23.0",
7179
7164
  "typescript": "6.0.3",
7180
7165
  "vitest": "5.0.3",
7181
- "pnpm": "11.27.1"
7166
+ "pnpm": "11.28.3"
7182
7167
  }["@release-it/bumper"];
7183
7168
  break;
7184
7169
  case "simple":
7185
7170
  deps["commit-and-tag-version"] = {
7186
7171
  "@changesets/cli": "3.0.3",
7187
7172
  "@release-it/bumper": "8.0.1",
7188
- "@types/node": "24.13.5",
7173
+ "@types/node": "24.19.1",
7189
7174
  "@vitest/coverage-v8": "5.0.3",
7190
7175
  "commit-and-tag-version": "13.2.1",
7191
7176
  "knip": "6.39.0",
@@ -7199,13 +7184,13 @@ function addReleaseDeps(deps, config) {
7199
7184
  "tsdown": "0.23.0",
7200
7185
  "typescript": "6.0.3",
7201
7186
  "vitest": "5.0.3",
7202
- "pnpm": "11.27.1"
7187
+ "pnpm": "11.28.3"
7203
7188
  }["commit-and-tag-version"];
7204
7189
  break;
7205
7190
  case "changesets": deps["@changesets/cli"] = {
7206
7191
  "@changesets/cli": "3.0.3",
7207
7192
  "@release-it/bumper": "8.0.1",
7208
- "@types/node": "24.13.5",
7193
+ "@types/node": "24.19.1",
7209
7194
  "@vitest/coverage-v8": "5.0.3",
7210
7195
  "commit-and-tag-version": "13.2.1",
7211
7196
  "knip": "6.39.0",
@@ -7219,7 +7204,7 @@ function addReleaseDeps(deps, config) {
7219
7204
  "tsdown": "0.23.0",
7220
7205
  "typescript": "6.0.3",
7221
7206
  "vitest": "5.0.3",
7222
- "pnpm": "11.27.1"
7207
+ "pnpm": "11.28.3"
7223
7208
  }["@changesets/cli"];
7224
7209
  }
7225
7210
  }
@@ -7413,7 +7398,7 @@ function applyMetadataDefaults(pkg, ctx, changes) {
7413
7398
  const pinnedPnpm = {
7414
7399
  "@changesets/cli": "3.0.3",
7415
7400
  "@release-it/bumper": "8.0.1",
7416
- "@types/node": "24.13.5",
7401
+ "@types/node": "24.19.1",
7417
7402
  "@vitest/coverage-v8": "5.0.3",
7418
7403
  "commit-and-tag-version": "13.2.1",
7419
7404
  "knip": "6.39.0",
@@ -7427,7 +7412,7 @@ function applyMetadataDefaults(pkg, ctx, changes) {
7427
7412
  "tsdown": "0.23.0",
7428
7413
  "typescript": "6.0.3",
7429
7414
  "vitest": "5.0.3",
7430
- "pnpm": "11.27.1"
7415
+ "pnpm": "11.28.3"
7431
7416
  }["pnpm"];
7432
7417
  const desiredPm = pinnedPnpm ? `pnpm@${pinnedPnpm}` : void 0;
7433
7418
  if (desiredPm) {
@@ -7468,12 +7453,12 @@ function buildManagedDevDeps(ctx) {
7468
7453
  const devDeps = { ...rootDevDeps(ctx.config) };
7469
7454
  if (!isMonorepo) Object.assign(devDeps, perPackageDevDeps(ctx.config));
7470
7455
  if (ctx.config.stack !== "none" || ctx.config.ci === "forgejo") devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.14.5";
7471
- if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.86.1";
7472
- if (ctx.config.useEslintPlugin && ctx.config.stack !== "none") devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.15.2";
7456
+ if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.87.1";
7457
+ if (ctx.config.useEslintPlugin && ctx.config.stack !== "none") devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.16.0";
7473
7458
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
7474
7459
  "@changesets/cli": "3.0.3",
7475
7460
  "@release-it/bumper": "8.0.1",
7476
- "@types/node": "24.13.5",
7461
+ "@types/node": "24.19.1",
7477
7462
  "@vitest/coverage-v8": "5.0.3",
7478
7463
  "commit-and-tag-version": "13.2.1",
7479
7464
  "knip": "6.39.0",
@@ -7487,12 +7472,12 @@ function buildManagedDevDeps(ctx) {
7487
7472
  "tsdown": "0.23.0",
7488
7473
  "typescript": "6.0.3",
7489
7474
  "vitest": "5.0.3",
7490
- "pnpm": "11.27.1"
7475
+ "pnpm": "11.28.3"
7491
7476
  }["oxfmt"];
7492
7477
  if (ctx.config.formatter === "prettier") devDeps["prettier"] = {
7493
7478
  "@changesets/cli": "3.0.3",
7494
7479
  "@release-it/bumper": "8.0.1",
7495
- "@types/node": "24.13.5",
7480
+ "@types/node": "24.19.1",
7496
7481
  "@vitest/coverage-v8": "5.0.3",
7497
7482
  "commit-and-tag-version": "13.2.1",
7498
7483
  "knip": "6.39.0",
@@ -7506,7 +7491,7 @@ function buildManagedDevDeps(ctx) {
7506
7491
  "tsdown": "0.23.0",
7507
7492
  "typescript": "6.0.3",
7508
7493
  "vitest": "5.0.3",
7509
- "pnpm": "11.27.1"
7494
+ "pnpm": "11.28.3"
7510
7495
  }["prettier"];
7511
7496
  addReleaseDeps(devDeps, ctx.config);
7512
7497
  return devDeps;
@@ -7562,7 +7547,7 @@ async function generatePackageJson(ctx) {
7562
7547
  packageManager: `pnpm@${{
7563
7548
  "@changesets/cli": "3.0.3",
7564
7549
  "@release-it/bumper": "8.0.1",
7565
- "@types/node": "24.13.5",
7550
+ "@types/node": "24.19.1",
7566
7551
  "@vitest/coverage-v8": "5.0.3",
7567
7552
  "commit-and-tag-version": "13.2.1",
7568
7553
  "knip": "6.39.0",
@@ -7576,7 +7561,7 @@ async function generatePackageJson(ctx) {
7576
7561
  "tsdown": "0.23.0",
7577
7562
  "typescript": "6.0.3",
7578
7563
  "vitest": "5.0.3",
7579
- "pnpm": "11.27.1"
7564
+ "pnpm": "11.28.3"
7580
7565
  }["pnpm"]}`
7581
7566
  };
7582
7567
  ctx.write(filePath, JSON.stringify(pkg, null, 2) + "\n");
@@ -8394,6 +8379,17 @@ const CONFIG_WITH_LINT_RULES = `import recommended from "@bensandee/config/oxlin
8394
8379
  import bensandee from "@bensandee/eslint-plugin/recommended";
8395
8380
  import { defineConfig } from "oxlint";
8396
8381
 
8382
+ export default defineConfig({
8383
+ extends: [recommended],
8384
+ jsPlugins: bensandee.jsPlugins ?? [],
8385
+ rules: bensandee.rules ?? {},
8386
+ ignorePatterns: [${IGNORE_PATTERNS_SPREAD}],
8387
+ });
8388
+ `;
8389
+ const LEGACY_CONFIG_WITH_LINT_RULES = `import recommended from "@bensandee/config/oxlint/recommended";
8390
+ import bensandee from "@bensandee/eslint-plugin/recommended";
8391
+ import { defineConfig } from "oxlint";
8392
+
8397
8393
  // Report phase: every plugin rule runs and reports, none blocks. The severities are derived
8398
8394
  // from the plugin's own rule names rather than listed, so a rule the plugin adds arrives the
8399
8395
  // day it publishes -- enumerating them is how the plugin came to be installed and never
@@ -8426,7 +8422,11 @@ function addIgnorePatterns(content) {
8426
8422
  return content.slice(0, insertPos) + ` ignorePatterns: [${IGNORE_PATTERNS_SPREAD}],\n` + content.slice(insertPos);
8427
8423
  }
8428
8424
  const PLUGIN_IMPORT = "import bensandee from \"@bensandee/eslint-plugin/recommended\";\n";
8429
- const REPORT_SPREAD = " ...Object.fromEntries(Object.keys(bensandee.rules ?? {}).map((name) => [name, \"warn\"])),\n";
8425
+ const TIER_SPREAD = " ...bensandee.rules,\n";
8426
+ const LEGACY_REPORT_SPREAD = " ...Object.fromEntries(Object.keys(bensandee.rules ?? {}).map((name) => [name, \"warn\"])),\n";
8427
+ /** The text every report-phase severity map carries, whatever its shape -- in either quote
8428
+ * style, since the merged spread went through the repo's own formatter. */
8429
+ const REPORT_PHASE_MARKER = /\[name, (["'])warn\1\]/;
8430
8430
  const RULES_KEY = /^ {2}"?rules"?: \{/m;
8431
8431
  const RULES_KEY_INLINE = /^ {2}"?rules"?\s*[,:]/m;
8432
8432
  const JS_PLUGINS_KEY = /^ {2}"?jsPlugins"?: \[/m;
@@ -8473,28 +8473,29 @@ function addBensandeePlugin(content) {
8473
8473
  const rulesKey = RULES_KEY.exec(rulesRegion);
8474
8474
  if (rulesKey) {
8475
8475
  const at = insertPos + rulesKey.index + rulesKey[0].length;
8476
- return withPlugins.slice(0, at) + `\n${REPORT_SPREAD}` + withPlugins.slice(at);
8476
+ return withPlugins.slice(0, at) + `\n${TIER_SPREAD}` + withPlugins.slice(at);
8477
8477
  }
8478
8478
  if (RULES_KEY_INLINE.test(rulesRegion)) return UNRECOGNIZED_SHAPE;
8479
- return withPlugins.slice(0, afterJsPlugins) + ` rules: {\n${REPORT_SPREAD} },\n` + withPlugins.slice(afterJsPlugins);
8480
- }
8481
- /** The exact literal `addBensandeePlugin` splices in when it creates a `rules` key from
8482
- * scratch -- checked before {@link REPORT_SPREAD}'s own merged-in-place form below, since that
8483
- * one is a substring of this one and would otherwise match first and leave the empty `rules: {
8484
- * },` shell behind. */
8485
- const CREATED_RULES_BLOCK = ` rules: {\n${REPORT_SPREAD} },\n`;
8479
+ return withPlugins.slice(0, afterJsPlugins) + ` rules: {\n${TIER_SPREAD} },\n` + withPlugins.slice(afterJsPlugins);
8480
+ }
8481
+ /** The exact literals `addBensandeePlugin` splices in when it creates a `rules` key from
8482
+ * scratch, current and legacy -- checked before the merged-in-place spreads below, since each
8483
+ * spread is a substring of its block and would otherwise match first and leave the empty `rules:
8484
+ * { },` shell behind. */
8485
+ const CREATED_RULES_BLOCKS = [` rules: {\n${TIER_SPREAD} },\n`, ` rules: {\n${LEGACY_REPORT_SPREAD} },\n`];
8486
+ const MERGED_RULES_SPREADS = [`\n${TIER_SPREAD}`, `\n${LEGACY_REPORT_SPREAD}`];
8486
8487
  const CREATED_JS_PLUGINS_LINE = " jsPlugins: bensandee.jsPlugins ?? [],\n";
8487
8488
  const MERGED_JS_PLUGINS_SPREAD = /\.\.\.\(bensandee\.jsPlugins \?\? \[\]\),\s+/;
8488
8489
  /**
8489
8490
  * The exact inverse of {@link addBensandeePlugin}: strips the import line and whichever of the
8490
- * two `jsPlugins` and two `rules` shapes it spliced in, by removing those same literals rather
8491
- * than re-deriving positions. Returns `undefined` when there is nothing to remove (the content
8492
- * does not name the plugin -- keeps a re-sync a no-op), and {@link UNRECOGNIZED_SHAPE} when it
8493
- * does but none of the expected literals are present -- a hand-edit since injection, a shape
8494
- * this tool never injected, or (for the merged `jsPlugins` spread only) a formatter reflow
8495
- * wider than {@link MERGED_JS_PLUGINS_SPREAD} tolerates -- declined whole, the same rule
8496
- * `addBensandeePlugin` itself follows, rather than guessing at a partial removal that could
8497
- * corrupt the file.
8491
+ * two `jsPlugins` and two `rules` shapes it spliced in, the legacy `rules` literals included, by
8492
+ * removing those same literals rather than re-deriving positions. Returns `undefined` when
8493
+ * there is nothing to remove (the content does not name the plugin -- keeps a re-sync a no-op),
8494
+ * and {@link UNRECOGNIZED_SHAPE} when it does but none of the expected literals are present --
8495
+ * a hand-edit since injection, a shape this tool never injected, or (for the merged
8496
+ * `jsPlugins` spread only) a formatter reflow wider than {@link MERGED_JS_PLUGINS_SPREAD}
8497
+ * tolerates -- declined whole, the same rule `addBensandeePlugin` itself follows, rather than
8498
+ * guessing at a partial removal that could corrupt the file.
8498
8499
  */
8499
8500
  function removeBensandeePlugin(content) {
8500
8501
  if (!content.includes("@bensandee/eslint-plugin")) return void 0;
@@ -8503,10 +8504,17 @@ function removeBensandeePlugin(content) {
8503
8504
  if (result.includes(CREATED_JS_PLUGINS_LINE)) result = result.replace(CREATED_JS_PLUGINS_LINE, "");
8504
8505
  else if (MERGED_JS_PLUGINS_SPREAD.test(result)) result = result.replace(MERGED_JS_PLUGINS_SPREAD, "");
8505
8506
  else return UNRECOGNIZED_SHAPE;
8506
- if (result.includes(CREATED_RULES_BLOCK)) result = result.replace(CREATED_RULES_BLOCK, "");
8507
- else if (result.includes(`\n${REPORT_SPREAD}`)) result = result.replace(`\n${REPORT_SPREAD}`, "");
8508
- else return UNRECOGNIZED_SHAPE;
8509
- return result;
8507
+ const rulesLiteral = CREATED_RULES_BLOCKS.find((block) => result.includes(block)) ?? MERGED_RULES_SPREADS.find((spread) => result.includes(spread));
8508
+ if (rulesLiteral === void 0) return UNRECOGNIZED_SHAPE;
8509
+ return result.replace(rulesLiteral, "");
8510
+ }
8511
+ /**
8512
+ * Swap the report-phase spread an earlier release merged in for {@link TIER_SPREAD}. Only the
8513
+ * byte-exact literal is replaced: a map the repo has reflowed or edited is left for the caller
8514
+ * to report, since rewriting a shape this tool did not write could drop the repo's own rules.
8515
+ */
8516
+ function migrateReportSpread(content) {
8517
+ return content.replace(`\n${LEGACY_REPORT_SPREAD}`, `\n${TIER_SPREAD}`);
8510
8518
  }
8511
8519
  async function generateOxlint(ctx) {
8512
8520
  const filePath = "oxlint.config.ts";
@@ -8524,7 +8532,7 @@ async function generateOxlint(ctx) {
8524
8532
  action: "skipped",
8525
8533
  description: "Already configured"
8526
8534
  };
8527
- if (isGeneratedTemplate(ctx, filePath, ctx.config.useEslintPlugin ? CONFIG_PRESET_ONLY : CONFIG_WITH_LINT_RULES)) {
8535
+ if ((ctx.config.useEslintPlugin ? [CONFIG_PRESET_ONLY, LEGACY_CONFIG_WITH_LINT_RULES] : [CONFIG_WITH_LINT_RULES, LEGACY_CONFIG_WITH_LINT_RULES]).some((template) => isGeneratedTemplate(ctx, filePath, template))) {
8528
8536
  ctx.write(filePath, content);
8529
8537
  return {
8530
8538
  filePath,
@@ -8537,8 +8545,14 @@ async function generateOxlint(ctx) {
8537
8545
  let pluginResult;
8538
8546
  let withPlugin;
8539
8547
  if (ctx.config.useEslintPlugin) {
8540
- pluginResult = addBensandeePlugin(withIgnores);
8541
- withPlugin = typeof pluginResult === "string" ? pluginResult : withIgnores;
8548
+ const migrated = migrateReportSpread(withIgnores);
8549
+ pluginResult = addBensandeePlugin(migrated);
8550
+ withPlugin = typeof pluginResult === "string" ? pluginResult : migrated;
8551
+ if (pluginResult === void 0 && REPORT_PHASE_MARKER.test(migrated)) return {
8552
+ filePath,
8553
+ action: "failed",
8554
+ description: "Plugin severities not migrated: unrecognized config shape -- replace the all-\"warn\" rule map with \"...bensandee.rules,\""
8555
+ };
8542
8556
  } else {
8543
8557
  pluginResult = removeBensandeePlugin(withIgnores);
8544
8558
  withPlugin = typeof pluginResult === "string" ? pluginResult : withIgnores;
@@ -8821,20 +8835,30 @@ function toolsRange(source) {
8821
8835
  function pnpmVersionFromPackageManager(packageManager) {
8822
8836
  return /^pnpm@(\S+)$/.exec(packageManager ?? "")?.[1];
8823
8837
  }
8824
- /** The exact pin to seed `tools.node` with: the higher of `enginesNode`'s own floor (e.g. a repo
8825
- * whose `package.json` already declares `>=24.14.0`) and {@link NODE_ENGINES_FLOOR}, so a loose
8826
- * `engines.node` (`>=24`, `24.x`) can never seed a pin below the supported floor -- the same
8827
- * derive-from-engines discipline the retired `devEngines.runtime` generator used. */
8828
- function nodePinFor(enginesNode) {
8829
- const tuple = enginesNode ? minVersionTuple(enginesNode) : void 0;
8830
- const floorTuple = minVersionTuple(NODE_ENGINES_FLOOR);
8831
- if (!tuple || !floorTuple) return NODE_ENGINES_FLOOR;
8832
- return compareTuples(tuple, floorTuple) > 0 ? tuple.join(".") : NODE_ENGINES_FLOOR;
8838
+ /** The `version` range of the `node` entry in `devEngines.runtime`, which may be one entry or an
8839
+ * array of them. */
8840
+ function devEnginesNodeVersion(devEngines) {
8841
+ const runtime = devEngines?.runtime;
8842
+ if (!runtime) return void 0;
8843
+ return (Array.isArray(runtime) ? runtime : [runtime]).find((entry) => entry.name === "node")?.version;
8844
+ }
8845
+ /** The exact pin to seed `tools.node` with: the highest floor among `devEngines.runtime`'s node
8846
+ * range, `engines.node`, and {@link NODE_ENGINES_FLOOR}. Renovate's shared preset never raises
8847
+ * `engines.node`, so on its own it can seed a pin below the Node the repo actually runs;
8848
+ * `devEngines.runtime` is the range Renovate does raise, which is why it is read here even though
8849
+ * `generatePackageJson` removes it. An unparseable range is ignored. */
8850
+ function nodePinFor(enginesNode, devEnginesNode) {
8851
+ let pin = minVersionTuple(NODE_ENGINES_FLOOR);
8852
+ for (const range of [enginesNode, devEnginesNode]) {
8853
+ const tuple = range ? minVersionTuple(range) : void 0;
8854
+ if (tuple && (!pin || compareTuples(tuple, pin) > 0)) pin = tuple;
8855
+ }
8856
+ return pin ? pin.join(".") : NODE_ENGINES_FLOOR;
8833
8857
  }
8834
8858
  /** The two owned lines, in order, for a file created from scratch (or a `[tools]` table added to
8835
8859
  * one that had none). */
8836
- function ownedLines(pnpmVersion, enginesNode) {
8837
- const lines = [`[tools]`, `node = "${nodePinFor(enginesNode)}"`];
8860
+ function ownedLines(pnpmVersion, nodePin) {
8861
+ const lines = [`[tools]`, `node = "${nodePin}"`];
8838
8862
  if (pnpmVersion) lines.push(`pnpm = "${pnpmVersion}"`);
8839
8863
  return lines;
8840
8864
  }
@@ -8878,19 +8902,19 @@ function readPackageJsonFacts(ctx) {
8878
8902
  const pkg = pkgRaw ? parsePackageJson(pkgRaw) : void 0;
8879
8903
  return {
8880
8904
  enginesNode: pkg?.engines?.["node"],
8905
+ devEngines: pkg?.devEngines,
8881
8906
  pnpmVersion: pnpmVersionFromPackageManager(pkg?.packageManager)
8882
8907
  };
8883
8908
  }
8884
8909
  /** Edit an existing `[tools]` body in place: add `node` only when absent, rewrite `pnpm` only on
8885
8910
  * drift. Returns the new body and the change descriptions, or `undefined` for the body when
8886
8911
  * nothing changed. */
8887
- function editToolsBody(body, existingNode, existingPnpm, pnpmVersion, enginesNode) {
8912
+ function editToolsBody(body, existingNode, existingPnpm, pnpmVersion, nodePin) {
8888
8913
  let newBody = body;
8889
8914
  const changes = [];
8890
8915
  if (existingNode === void 0) {
8891
- const pin = nodePinFor(enginesNode);
8892
- newBody = appendLine(newBody, `node = "${pin}"`);
8893
- changes.push(`added tools.node = "${pin}"`);
8916
+ newBody = appendLine(newBody, `node = "${nodePin}"`);
8917
+ changes.push(`added tools.node = "${nodePin}"`);
8894
8918
  }
8895
8919
  if (pnpmVersion && existingPnpm !== pnpmVersion) {
8896
8920
  if (existingPnpm === void 0) {
@@ -8908,12 +8932,18 @@ function editToolsBody(body, existingNode, existingPnpm, pnpmVersion, enginesNod
8908
8932
  changes
8909
8933
  };
8910
8934
  }
8911
- async function generateMise(ctx) {
8935
+ /**
8936
+ * `priorDevEngines` is `package.json`'s `devEngines` as it stood before this sync: the pipeline
8937
+ * reads it before `generatePackageJson` removes `devEngines.runtime`, because by the time this
8938
+ * generator re-reads `package.json` the field is gone. Without it, the file read now is used.
8939
+ */
8940
+ async function generateMise(ctx, priorDevEngines) {
8912
8941
  const filePath = "mise.toml";
8913
- const { enginesNode, pnpmVersion } = readPackageJsonFacts(ctx);
8942
+ const { enginesNode, devEngines, pnpmVersion } = readPackageJsonFacts(ctx);
8943
+ const nodePin = nodePinFor(enginesNode, devEnginesNodeVersion(priorDevEngines ?? devEngines));
8914
8944
  const existing = ctx.read(filePath);
8915
8945
  if (!existing) {
8916
- ctx.write(filePath, ownedLines(pnpmVersion, enginesNode).join("\n") + "\n");
8946
+ ctx.write(filePath, ownedLines(pnpmVersion, nodePin).join("\n") + "\n");
8917
8947
  return {
8918
8948
  filePath,
8919
8949
  action: "created",
@@ -8952,11 +8982,11 @@ async function generateMise(ctx) {
8952
8982
  action: "failed",
8953
8983
  description: `mise.toml: tools.* is declared with dotted keys; repo:sync only manages a [tools] table`
8954
8984
  };
8955
- text = ownedLines(pnpmVersion, enginesNode).join("\n") + "\n\n" + existing;
8985
+ text = ownedLines(pnpmVersion, nodePin).join("\n") + "\n\n" + existing;
8956
8986
  changes = ["added [tools] table"];
8957
8987
  } else {
8958
8988
  const body = existing.slice(range.start, range.end);
8959
- const edited = editToolsBody(body, existingNode, existingPnpm, pnpmVersion, enginesNode);
8989
+ const edited = editToolsBody(body, existingNode, existingPnpm, pnpmVersion, nodePin);
8960
8990
  changes = edited.changes;
8961
8991
  text = edited.body === body ? existing : existing.slice(0, range.start) + edited.body + existing.slice(range.end);
8962
8992
  }
@@ -9813,7 +9843,7 @@ function postReleaseHookJob(opts) {
9813
9843
  needs: opts.needsJob,
9814
9844
  if: opts.publishCondition,
9815
9845
  "continue-on-error": true,
9816
- steps: [...commonSteps(opts.ci, []), { step: {
9846
+ steps: [...commonSteps(opts.ci), { step: {
9817
9847
  name: "Post-release hook",
9818
9848
  env: {
9819
9849
  ...hookSecretEnv(opts.secrets),
@@ -9852,21 +9882,20 @@ function deployDispatchStep(opts) {
9852
9882
  } };
9853
9883
  }
9854
9884
  /** Common setup steps shared by release-it and simple strategies. */
9855
- function commonSteps(ci, authSteps) {
9885
+ function commonSteps(ci) {
9856
9886
  return [
9857
9887
  { step: {
9858
9888
  uses: "actions/checkout@v6",
9859
9889
  with: { "fetch-depth": 0 }
9860
9890
  } },
9861
9891
  ...miseSetupSteps(ci),
9862
- ...authSteps,
9863
9892
  { step: { run: "pnpm install --frozen-lockfile" } }
9864
9893
  ];
9865
9894
  }
9866
9895
  function releaseItSteps(ci, npmAuth) {
9867
9896
  const tokenEnv = ci === "github" ? { GITHUB_TOKEN: actionsExpr("secrets.RELEASE_TOKEN") } : { RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN") };
9868
- return [...commonSteps(ci, npmAuth.steps), { step: {
9869
- run: "pnpm release-it --ci",
9897
+ return [...commonSteps(ci), { step: {
9898
+ run: npmAuth.publishRun("pnpm release-it --ci"),
9870
9899
  env: {
9871
9900
  ...tokenEnv,
9872
9901
  ...npmAuth.tokenEnv,
@@ -9917,7 +9946,7 @@ function simpleReleaseEnv() {
9917
9946
  };
9918
9947
  }
9919
9948
  function simpleReleaseSteps(ctx) {
9920
- const { ci, npmAuth, hasDocker, hasAssets, host } = ctx;
9949
+ const { ci, hasDocker, hasAssets, host } = ctx;
9921
9950
  const releaseEnv = connectionEnv(ci);
9922
9951
  const pushedIf = "steps.release.outputs.pushed == 'true'";
9923
9952
  const releaseStep = { step: {
@@ -9943,7 +9972,7 @@ function simpleReleaseSteps(ctx) {
9943
9972
  run: "pnpm exec bst docker:publish"
9944
9973
  } };
9945
9974
  return [
9946
- ...commonSteps(ci, npmAuth.steps),
9975
+ ...commonSteps(ci),
9947
9976
  releaseStep,
9948
9977
  ...hasAssets ? [assetsStep] : [],
9949
9978
  ...hasDocker ? [dockerStep] : []
@@ -9956,21 +9985,36 @@ function buildSteps(strategy, ctx) {
9956
9985
  default: return null;
9957
9986
  }
9958
9987
  }
9988
+ /**
9989
+ * Whether this release strategy gets its own `release.yml`.
9990
+ *
9991
+ * `changesets` releases from the CI workflow (`generateCi` writes that step inline) and `none`
9992
+ * releases nothing. Exported for `generateStaleCiWorkflows`, which asks the same question to
9993
+ * decide whether a `release.yml` on disk is still current -- the two must not answer it
9994
+ * differently or the retirer deletes a file this generator has just written.
9995
+ */
9996
+ function hasReleaseWorkflow(strategy) {
9997
+ return strategy === "simple" || strategy === "release-it";
9998
+ }
9999
+ /** Where this platform's release workflow lives. */
10000
+ function releaseWorkflowPath(ci) {
10001
+ return ci === "github" ? ".github/workflows/release.yml" : ".forgejo/workflows/release.yml";
10002
+ }
9959
10003
  async function generateReleaseCi(ctx) {
9960
10004
  const filePath = "release-ci";
9961
- if (ctx.config.releaseStrategy === "none" || ctx.config.ci === "none") return {
10005
+ const isGitHub = ctx.config.ci === "github";
10006
+ if (ctx.config.ci === "none" || ctx.config.releaseStrategy === "none") return {
9962
10007
  filePath,
9963
10008
  action: "skipped",
9964
10009
  description: "Release CI workflow not applicable"
9965
10010
  };
9966
- const publishesNpm = ctx.config.publishNpm === true;
9967
- if (ctx.config.releaseStrategy === "changesets") return {
10011
+ if (!hasReleaseWorkflow(ctx.config.releaseStrategy)) return {
9968
10012
  filePath,
9969
10013
  action: "skipped",
9970
10014
  description: "Release step included in CI workflow"
9971
10015
  };
9972
- const isGitHub = ctx.config.ci === "github";
9973
- const workflowPath = isGitHub ? ".github/workflows/release.yml" : ".forgejo/workflows/release.yml";
10016
+ const publishesNpm = ctx.config.publishNpm === true;
10017
+ const workflowPath = releaseWorkflowPath(ctx.config.ci);
9974
10018
  const hasDocker = hasDockerPackages(ctx) && ctx.config.publishDocker;
9975
10019
  const hasAssets = hasReleaseAssets(ctx) || hasVersionStamp(ctx);
9976
10020
  const host = resolveGenTimeHost(ctx);
@@ -10026,7 +10070,6 @@ async function generateReleaseCi(ctx) {
10026
10070
  on,
10027
10071
  ...isGitHub && { permissions: { contents: "write" } },
10028
10072
  jobName: "release",
10029
- ...Object.keys(npmAuth.jobEnv).length > 0 && { jobEnv: npmAuth.jobEnv },
10030
10073
  ...jobOutputs && { jobOutputs },
10031
10074
  steps,
10032
10075
  ...extraJobs && { extraJobs }
@@ -10099,7 +10142,7 @@ function healthSteps(includeHistory) {
10099
10142
  } }];
10100
10143
  }
10101
10144
  /** Build the release step for the check job (changesets strategy). */
10102
- function changesetsReleaseStep(ci, publishesNpm, tokenEnv) {
10145
+ function changesetsReleaseStep(ci, publishesNpm, npmAuth) {
10103
10146
  if (ci === "github") {
10104
10147
  const changesetsEnv = {
10105
10148
  GITHUB_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
@@ -10123,10 +10166,10 @@ function changesetsReleaseStep(ci, publishesNpm, tokenEnv) {
10123
10166
  FORGEJO_SERVER_URL: actionsExpr("github.server_url"),
10124
10167
  FORGEJO_REPOSITORY: actionsExpr("github.repository"),
10125
10168
  RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
10126
- ...tokenEnv,
10169
+ ...npmAuth.tokenEnv,
10127
10170
  RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
10128
10171
  },
10129
- run: "pnpm exec bst release:changesets"
10172
+ run: npmAuth.publishRun("pnpm exec bst release:changesets")
10130
10173
  } };
10131
10174
  }
10132
10175
  /**
@@ -10159,7 +10202,7 @@ function ciConcurrency(ci) {
10159
10202
  "cancel-in-progress": actionsExpr(IS_NOT_MAIN_BRANCH)
10160
10203
  };
10161
10204
  }
10162
- function checkSteps(ci, authSteps, coverageEnabled, emitDockerSweepStep) {
10205
+ function checkSteps(ci, coverageEnabled, emitDockerSweepStep) {
10163
10206
  const checkCommand = coverageEnabled ? "pnpm ci:check" : "pnpm ci:check --no-coverage";
10164
10207
  return [
10165
10208
  { step: {
@@ -10167,7 +10210,6 @@ function checkSteps(ci, authSteps, coverageEnabled, emitDockerSweepStep) {
10167
10210
  with: { "fetch-depth": 0 }
10168
10211
  } },
10169
10212
  ...miseSetupSteps(ci),
10170
- ...authSteps,
10171
10213
  { step: { run: "pnpm install --frozen-lockfile" } },
10172
10214
  ...emitDockerSweepStep ? [{ step: {
10173
10215
  name: "Sweep leaked Docker resources",
@@ -10179,7 +10221,13 @@ function checkSteps(ci, authSteps, coverageEnabled, emitDockerSweepStep) {
10179
10221
  } }
10180
10222
  ];
10181
10223
  }
10182
- /** Resolve the CI workflow filename based on release strategy. */
10224
+ /**
10225
+ * Resolve the CI workflow filename based on release strategy.
10226
+ *
10227
+ * Exported for `generateStaleCiWorkflows`, which derives the file the current strategy keeps
10228
+ * from this one function rather than a second copy of the mapping -- if the two could drift
10229
+ * apart, the retirer would delete a file this generator has just written.
10230
+ */
10183
10231
  function ciWorkflowPath(ci, releaseStrategy) {
10184
10232
  return `${ci === "github" ? ".github/workflows" : ".forgejo/workflows"}/${releaseStrategy === "changesets" ? "ci.yml" : "check.yml"}`;
10185
10233
  }
@@ -10220,10 +10268,10 @@ async function generateCi(ctx) {
10220
10268
  }
10221
10269
  const npmAuth = npmAuthWiring(ctx, isChangesets && publishesNpm && !isGitHub);
10222
10270
  const steps = [
10223
- ...checkSteps(ctx.config.ci, npmAuth.steps, coverage.enabled, hasDockerPackages(ctx)),
10271
+ ...checkSteps(ctx.config.ci, coverage.enabled, hasDockerPackages(ctx)),
10224
10272
  ...emitCoverageSteps ? coverageHistorySteps() : [],
10225
10273
  ...healthSteps(emitHealthHistory),
10226
- ...isChangesets ? [changesetsReleaseStep(ctx.config.ci, publishesNpm, npmAuth.tokenEnv)] : [],
10274
+ ...isChangesets ? [changesetsReleaseStep(ctx.config.ci, publishesNpm, npmAuth)] : [],
10227
10275
  ...isChangesets && isGitHub ? [githubChangesetsDeriveTagStep] : [],
10228
10276
  ...hasAssets ? [{ step: {
10229
10277
  name: "Upload Release Assets",
@@ -10261,10 +10309,7 @@ async function generateCi(ctx) {
10261
10309
  secrets: hookSecrets
10262
10310
  }) };
10263
10311
  }
10264
- const checkJobEnv = {
10265
- ...emitCoverageSteps || emitHealthHistory ? IS_MAIN_JOB_ENV : {},
10266
- ...npmAuth.jobEnv
10267
- };
10312
+ const checkJobEnv = emitCoverageSteps || emitHealthHistory ? IS_MAIN_JOB_ENV : {};
10268
10313
  const checksSlice = parseToolingConfigSlice(ctx.read(".tooling.json"), z.object({ checks: ChecksConfigSchema.optional() }), { block: "checks" });
10269
10314
  const skipOnVersionPr = isChangesets && checksSlice?.checks?.runOnVersionPr !== true;
10270
10315
  const content = buildWorkflowYaml({
@@ -11181,7 +11226,7 @@ function buildConfig$1(config) {
11181
11226
  ].join("\n");
11182
11227
  }
11183
11228
  /** Where `ctx.remove()` puts what it archives. Named here only to describe it to the user. */
11184
- const ARCHIVE_DIR$1 = ".tooling-archived";
11229
+ const ARCHIVE_DIR = ".tooling-archived";
11185
11230
  /** Common client-side git hooks that husky may have configured. */
11186
11231
  const HUSKY_HOOK_NAMES = [
11187
11232
  "pre-commit",
@@ -11215,7 +11260,7 @@ function archiveHuskyHooks(ctx, results) {
11215
11260
  results.push({
11216
11261
  filePath: huskyPath,
11217
11262
  action: "archived",
11218
- description: `Moved to ${ARCHIVE_DIR$1}/${huskyPath}`
11263
+ description: `Moved to ${ARCHIVE_DIR}/${huskyPath}`
11219
11264
  });
11220
11265
  found = true;
11221
11266
  }
@@ -11230,7 +11275,7 @@ function archiveLintStagedConfigs(ctx, results) {
11230
11275
  results.push({
11231
11276
  filePath: lsPath,
11232
11277
  action: "archived",
11233
- description: `Moved to ${ARCHIVE_DIR$1}/${lsPath}`
11278
+ description: `Moved to ${ARCHIVE_DIR}/${lsPath}`
11234
11279
  });
11235
11280
  found = true;
11236
11281
  }
@@ -11438,12 +11483,17 @@ async function generateVscodeSettings(ctx) {
11438
11483
  }
11439
11484
  //#endregion
11440
11485
  //#region src/generators/coverage-ci.ts
11441
- /** Where `ctx.remove()` puts what it archives. Named here only to describe it to the user. */
11442
- const ARCHIVE_DIR = ".tooling-archived";
11443
11486
  /**
11444
- * Cleanup-only generator: coverage steps now run inline in ci.yml (see
11445
- * generateCi). Any legacy coverage.yml left over from a previous version is
11446
- * archived to .tooling-archived/ on the next repo:sync.
11487
+ * Cleanup-only generator: coverage steps now run inline in the CI workflow (see generateCi), so
11488
+ * any legacy `coverage.yml` left over from an older sync is removed.
11489
+ *
11490
+ * The result is `removed`, not `archived`: `repo:sync --check` counts only the former as drift,
11491
+ * and the agent's workflow sync sweep acts only on drift the check reports. An `archived` result
11492
+ * let a plain `repo:sync` delete a file while `--check` called the repo up to date.
11493
+ *
11494
+ * The ignore comment is the only opt-out here, and there is deliberately no managed-header guard:
11495
+ * this file predates the header, so no legacy `coverage.yml` in a synced repo carries one. Requiring
11496
+ * it would leave every file this generator exists to remove exactly where it is.
11447
11497
  */
11448
11498
  async function generateCoverageCi(ctx) {
11449
11499
  const filePath = "coverage-ci";
@@ -11453,19 +11503,23 @@ async function generateCoverageCi(ctx) {
11453
11503
  description: "CI workflow not requested"
11454
11504
  };
11455
11505
  const workflowPath = ctx.config.ci === "github" ? ".github/workflows/coverage.yml" : ".forgejo/workflows/coverage.yml";
11456
- if (ctx.read(workflowPath) !== void 0) {
11457
- ctx.remove(workflowPath);
11458
- return {
11459
- filePath: workflowPath,
11460
- action: "archived",
11461
- description: `Moved to ${ARCHIVE_DIR}/${workflowPath} (coverage now runs inline in ci.yml)`
11462
- };
11463
- }
11464
- return {
11506
+ const existing = ctx.read(workflowPath);
11507
+ if (existing === void 0) return {
11465
11508
  filePath,
11466
11509
  action: "skipped",
11467
11510
  description: "Coverage runs inline in ci.yml"
11468
11511
  };
11512
+ if (isToolingIgnored(existing)) return {
11513
+ filePath: workflowPath,
11514
+ action: "skipped",
11515
+ description: "coverage.yml has ignore comment"
11516
+ };
11517
+ ctx.remove(workflowPath);
11518
+ return {
11519
+ filePath: workflowPath,
11520
+ action: "removed",
11521
+ description: `Moved to ${ARCHIVE_DIR$1}/${workflowPath} (coverage now runs inline in ci.yml)`
11522
+ };
11469
11523
  }
11470
11524
  //#endregion
11471
11525
  //#region src/docs-check/blocks.ts
@@ -12381,7 +12435,7 @@ function webUiGuideBody() {
12381
12435
  const mwg = `pnpm dlx modern-web-guidance@${{
12382
12436
  "@changesets/cli": "3.0.3",
12383
12437
  "@release-it/bumper": "8.0.1",
12384
- "@types/node": "24.13.5",
12438
+ "@types/node": "24.19.1",
12385
12439
  "@vitest/coverage-v8": "5.0.3",
12386
12440
  "commit-and-tag-version": "13.2.1",
12387
12441
  "knip": "6.39.0",
@@ -12395,7 +12449,7 @@ function webUiGuideBody() {
12395
12449
  "tsdown": "0.23.0",
12396
12450
  "typescript": "6.0.3",
12397
12451
  "vitest": "5.0.3",
12398
- "pnpm": "11.27.1"
12452
+ "pnpm": "11.28.3"
12399
12453
  }["modern-web-guidance"]}`;
12400
12454
  return [
12401
12455
  "# Web UI",
@@ -12946,12 +13000,74 @@ async function generateVersionrc(ctx) {
12946
13000
  };
12947
13001
  }
12948
13002
  //#endregion
13003
+ //#region src/generators/stale-ci-workflows.ts
13004
+ /**
13005
+ * Cleanup-only generator: retire the strategy-dependent CI workflow that the repo's current
13006
+ * `releaseStrategy` no longer writes.
13007
+ *
13008
+ * `generateCi` names its file after the release strategy (`ci.yml` for changesets, `check.yml`
13009
+ * for every other strategy), and `generateReleaseCi` writes `release.yml` for two of the four.
13010
+ * Changing `.tooling.json#releaseStrategy` therefore leaves the previous file on disk, where it
13011
+ * keeps running the check pipeline or a release job beside the new one. Nothing else removes it:
13012
+ * no generator names a file the current strategy does not use, so no generator reports it.
13013
+ *
13014
+ * The kept set comes from the two generators' own functions -- `ciWorkflowPath` for the CI file
13015
+ * and `hasReleaseWorkflow` for `release.yml` -- never from a table copied here. That is the whole
13016
+ * safety argument: this retirer cannot delete a file the generator has just written, because both
13017
+ * read one mapping.
13018
+ *
13019
+ * `ci: "none"` retires nothing. A repo that switched CI off is not claiming these files are
13020
+ * stale, and emptying a workflow directory a human chose to manage by hand is not this
13021
+ * generator's call.
13022
+ */
13023
+ async function generateStaleCiWorkflows(ctx) {
13024
+ const ci = ctx.config.ci;
13025
+ if (ci === "none") return [];
13026
+ const strategy = ctx.config.releaseStrategy;
13027
+ const keptCiPath = ciWorkflowPath(ci, strategy);
13028
+ const candidates = [
13029
+ ciWorkflowPath(ci, "changesets"),
13030
+ ciWorkflowPath(ci, "none"),
13031
+ releaseWorkflowPath(ci)
13032
+ ];
13033
+ const kept = /* @__PURE__ */ new Set([keptCiPath]);
13034
+ if (hasReleaseWorkflow(strategy)) kept.add(releaseWorkflowPath(ci));
13035
+ const results = [];
13036
+ for (const candidate of candidates) {
13037
+ if (kept.has(candidate)) continue;
13038
+ const result = retireManagedWorkflow(ctx, candidate, retireReason(strategy, candidate, keptCiPath, ci));
13039
+ if (result !== void 0) results.push(result);
13040
+ }
13041
+ return results;
13042
+ }
13043
+ /** Last path segment. `node:path` has no opinion about workflow separators, and these are
13044
+ * always POSIX paths, so this stays here rather than pulling in a platform-aware helper. */
13045
+ function baseName(workflowPath) {
13046
+ return workflowPath.slice(workflowPath.lastIndexOf("/") + 1);
13047
+ }
13048
+ /**
13049
+ * Why this file is stale, naming the strategy that made it so, so the sync sweep's pull request
13050
+ * and the `--check` output explain the deletion without the reader opening a generator.
13051
+ *
13052
+ * Every branch reads the kept path, never a strategy literal: a changesets repo runs its release
13053
+ * step inside `ci.yml`, so a retired `release.yml` there says the release moved there, and
13054
+ * anywhere else the strategy has no release workflow at all.
13055
+ */
13056
+ function retireReason(strategy, retiredPath, keptCiPath, ci) {
13057
+ const keptCiBaseName = baseName(keptCiPath);
13058
+ const prefix = `releaseStrategy "${strategy}"`;
13059
+ if (retiredPath !== releaseWorkflowPath(ci)) return `${prefix} runs checks in ${keptCiBaseName}`;
13060
+ if (keptCiPath === ciWorkflowPath(ci, "changesets")) return `${prefix} releases from ${keptCiBaseName}`;
13061
+ return `${prefix} has no release workflow`;
13062
+ }
13063
+ //#endregion
12949
13064
  //#region src/generators/pipeline.ts
12950
13065
  /** Run all generators sequentially and return their results. */
12951
13066
  async function runGenerators(ctx) {
12952
13067
  const results = [];
13068
+ const priorDevEngines = ctx.packageJson?.devEngines;
12953
13069
  results.push(await generatePackageJson(ctx));
12954
- results.push(await generateMise(ctx));
13070
+ results.push(await generateMise(ctx, priorDevEngines));
12955
13071
  results.push(...await generateWorkspaceEngines(ctx));
12956
13072
  results.push(await generatePnpmWorkspace(ctx));
12957
13073
  results.push(...await generateTsconfig(ctx));
@@ -12970,6 +13086,7 @@ async function runGenerators(ctx) {
12970
13086
  results.push(await generateVersionrc(ctx));
12971
13087
  results.push(await generateChangesets(ctx));
12972
13088
  results.push(await generateReleaseCi(ctx));
13089
+ results.push(...await generateStaleCiWorkflows(ctx));
12973
13090
  results.push(await generateDeployCi(ctx));
12974
13091
  results.push(await generateCoverageCi(ctx));
12975
13092
  results.push(...await generateClaudeMd(ctx));
@@ -13002,7 +13119,7 @@ function reportFailures(failed, command) {
13002
13119
  function generateMigratePrompt(results, config, detected) {
13003
13120
  const sections = [];
13004
13121
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
13005
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.86.1 repo:sync\` on ${timestamp}_\n`);
13122
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.87.1 repo:sync\` on ${timestamp}_\n`);
13006
13123
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
13007
13124
  if (changed.length > 0) {
13008
13125
  const items = changed.map((r) => {
@@ -13988,7 +14105,9 @@ function createFileReader() {
13988
14105
  * (`commands/sync-gates.ts`) -- see that gate's doc comment for why an absent mise warns rather
13989
14106
  * than fails -- plus, when `publishDocker` is on, one more per image only the check overlay
13990
14107
  * declares (`runCheckOnlyImageGate`, same file): an advisory that a later tooling release stops
13991
- * publishing it, counted rather than failed for the same reason the other two are.
14108
+ * publishing it, counted rather than failed for the same reason the other two are -- plus one
14109
+ * more per workflow file whose custom block installs packages with apt (`runWorkflowAptGate`,
14110
+ * same file), which the agent's containers cannot run.
13992
14111
  */
13993
14112
  const CHECKS_MARKER = "::bst-checks::";
13994
14113
  const WARN_LINE = new RegExp(`^${CHECKS_MARKER} warn (\\d+)\\s*$`);
@@ -15560,6 +15679,113 @@ const runChecksCommand = defineCommand({
15560
15679
  }
15561
15680
  });
15562
15681
  //#endregion
15682
+ //#region src/utils/workflow-paths.ts
15683
+ /**
15684
+ * The single definition of "workflow dir", shared by `repo:sync --check`'s advisory
15685
+ * classification and the workflow sync sweep's path allowlist -- so the two halves cannot
15686
+ * disagree about what counts as workflow output. Each prefix carries its trailing slash so a
15687
+ * file named e.g. `.github/workflowsX` never matches.
15688
+ */
15689
+ const WORKFLOW_DIR_PREFIXES = [".forgejo/workflows/", ".github/workflows/"];
15690
+ /** True when `filePath` (repo-relative) lives under one of the workflow directories. */
15691
+ function isWorkflowDirPath(filePath) {
15692
+ return WORKFLOW_DIR_PREFIXES.some((prefix) => filePath.startsWith(prefix));
15693
+ }
15694
+ //#endregion
15695
+ //#region src/commands/workflow-apt-steps.ts
15696
+ /** `apt-get install`, `apt install`, and either with options before the verb (`apt-get -y
15697
+ * install`). */
15698
+ const APT_INSTALL = /\bapt(?:-get)?(?:\s+-\S+)*\s+install\b/g;
15699
+ const COMMAND_END = /&&|\|\||;|\|/;
15700
+ /** Every readable `.yml`/`.yaml` file in the two workflow directories, as repo-relative paths. */
15701
+ function workflowFiles(targetDir) {
15702
+ const files = [];
15703
+ for (const prefix of WORKFLOW_DIR_PREFIXES) {
15704
+ let names;
15705
+ try {
15706
+ names = readdirSync(path.join(targetDir, prefix));
15707
+ } catch {
15708
+ continue;
15709
+ }
15710
+ for (const name of names) {
15711
+ if (!/\.ya?ml$/.test(name)) continue;
15712
+ const relative = `${prefix}${name}`;
15713
+ if (isReadableFile$1(path.join(targetDir, relative))) files.push(relative);
15714
+ }
15715
+ }
15716
+ return files;
15717
+ }
15718
+ /** False for a directory, a dangling symlink, or anything `stat` refuses: an advisory never fails
15719
+ * the run it advises, so a file it cannot look at is skipped, not thrown on. */
15720
+ function isReadableFile$1(file) {
15721
+ try {
15722
+ return statSync(file).isFile();
15723
+ } catch {
15724
+ return false;
15725
+ }
15726
+ }
15727
+ /** A file's text, or null when it cannot be read -- skipped for the same reason. */
15728
+ function readText(file) {
15729
+ try {
15730
+ return readFileSync(file, "utf8");
15731
+ } catch {
15732
+ return null;
15733
+ }
15734
+ }
15735
+ /** A custom block's content lines, with the markers and every other comment line dropped (a
15736
+ * commented-out apt step has been removed) and `\`-continued lines joined. */
15737
+ function logicalLines(blockLines) {
15738
+ const lines = [];
15739
+ let pending = "";
15740
+ for (const line of blockLines) {
15741
+ if (line.trim().startsWith("#")) continue;
15742
+ const trimmed = line.trimEnd();
15743
+ if (trimmed.endsWith("\\")) {
15744
+ pending += `${trimmed.slice(0, -1)} `;
15745
+ continue;
15746
+ }
15747
+ lines.push(pending + line);
15748
+ pending = "";
15749
+ }
15750
+ if (pending !== "") lines.push(pending);
15751
+ return lines;
15752
+ }
15753
+ /** The packages one line installs, or none when it runs no apt install. */
15754
+ function packagesIn(line) {
15755
+ const packages = [];
15756
+ for (const match of line.matchAll(APT_INSTALL)) {
15757
+ const command = line.slice(match.index + match[0].length).split(COMMAND_END)[0] ?? "";
15758
+ for (const token of command.split(/\s+/)) {
15759
+ if (token === "" || token.startsWith("-")) continue;
15760
+ packages.push(token);
15761
+ }
15762
+ }
15763
+ return packages;
15764
+ }
15765
+ /** True when `line` runs an apt install at all, packages or not. */
15766
+ function installsWithApt(line) {
15767
+ return line.search(APT_INSTALL) !== -1;
15768
+ }
15769
+ function findWorkflowAptSteps(targetDir) {
15770
+ const steps = [];
15771
+ for (const file of workflowFiles(targetDir)) {
15772
+ const content = readText(path.join(targetDir, file));
15773
+ if (content === null || isToolingIgnored(content)) continue;
15774
+ let matched = false;
15775
+ const packages = /* @__PURE__ */ new Set();
15776
+ for (const block of extractCustomBlocks(content)) for (const line of logicalLines(block.lines)) {
15777
+ if (!installsWithApt(line)) continue;
15778
+ matched = true;
15779
+ for (const name of packagesIn(line)) packages.add(name);
15780
+ }
15781
+ if (matched) steps.push({
15782
+ file,
15783
+ packages: [...packages]
15784
+ });
15785
+ }
15786
+ return steps.toSorted((a, b) => a.file.localeCompare(b.file));
15787
+ }
15788
+ //#endregion
15563
15789
  //#region src/commands/sync-gates.ts
15564
15790
  /**
15565
15791
  * The pre-generator gates and config diagnostics every `repo:sync` mode runs -- split out of
@@ -15619,6 +15845,23 @@ function runCheckOnlyImageGate(config, targetDir) {
15619
15845
  return images;
15620
15846
  }
15621
15847
  /**
15848
+ * Warn, once per workflow file, when a `@tooling:custom` block installs packages with apt. CI's
15849
+ * job runs as root and can do that; the agent's containers run without root and cannot, so the
15850
+ * package is present in CI and missing in the agent's measurement of the same tree. Advisory like
15851
+ * {@link runCheckOnlyImageGate}, and for the same reason it returns the entries: `repo:sync
15852
+ * --check` folds their count into the `::bst-checks:: warn` marker. It never fails a run and never
15853
+ * edits the block -- not every Debian package has a conda equivalent under the same name, so moving
15854
+ * it is a human's call.
15855
+ */
15856
+ function runWorkflowAptGate(targetDir) {
15857
+ const steps = findWorkflowAptSteps(targetDir);
15858
+ for (const step of steps) {
15859
+ const packages = step.packages.length > 0 ? step.packages.join(", ") : "packages";
15860
+ log$1.warn(`${step.file} installs ${packages} with apt in a custom block. The agent's containers cannot run that step, so its runs lack what CI has. Declare each tool in mise.toml as conda:<name> (both CI and the agent install from it), then remove the apt step.`);
15861
+ }
15862
+ return steps;
15863
+ }
15864
+ /**
15622
15865
  * Require a declared publish destination before any generator or apply step runs.
15623
15866
  * Returns the number of offenders (0 = clean). Each is emitted via `log.error` so
15624
15867
  * the user sees the full message and the fix.
@@ -15906,19 +16149,6 @@ function runToolchainGate(targetDir) {
15906
16149
  };
15907
16150
  }
15908
16151
  //#endregion
15909
- //#region src/utils/workflow-paths.ts
15910
- /**
15911
- * The single definition of "workflow dir", shared by `repo:sync --check`'s advisory
15912
- * classification and the workflow sync sweep's path allowlist -- so the two halves cannot
15913
- * disagree about what counts as workflow output. Each prefix carries its trailing slash so a
15914
- * file named e.g. `.github/workflowsX` never matches.
15915
- */
15916
- const WORKFLOW_DIR_PREFIXES = [".forgejo/workflows/", ".github/workflows/"];
15917
- /** True when `filePath` (repo-relative) lives under one of the workflow directories. */
15918
- function isWorkflowDirPath(filePath) {
15919
- return WORKFLOW_DIR_PREFIXES.some((prefix) => filePath.startsWith(prefix));
15920
- }
15921
- //#endregion
15922
16152
  //#region src/commands/repo-sync.ts
15923
16153
  const rawLog$1 = (msg) => console.log(msg);
15924
16154
  const syncCommand = defineCommand({
@@ -15958,7 +16188,7 @@ const syncCommand = defineCommand({
15958
16188
  },
15959
16189
  json: {
15960
16190
  type: "boolean",
15961
- description: "Emit one JSON document: status (up-to-date | drift | workflow-drift | failed), actionable, failed, toolchain, checkOnlyImages, warnCount, exitCode. --check only: the write mode prompts and inherits child streams."
16191
+ description: "Emit one JSON document: status (up-to-date | drift | workflow-drift | failed), actionable, failed, toolchain, checkOnlyImages, aptSteps, warnCount, exitCode. --check only: the write mode prompts and inherits child streams."
15962
16192
  }
15963
16193
  },
15964
16194
  async run({ args }) {
@@ -16035,6 +16265,7 @@ function gateFailure(toolchainWarned = false) {
16035
16265
  failed: [],
16036
16266
  toolchain: { warned: toolchainWarned },
16037
16267
  checkOnlyImages: [],
16268
+ aptSteps: [],
16038
16269
  warnCount: 0,
16039
16270
  exitCode: 1
16040
16271
  };
@@ -16045,8 +16276,8 @@ function gateFailure(toolchainWarned = false) {
16045
16276
  * withheld -- the same number rides in `warnCount`, and printing both would put a second thing on
16046
16277
  * the stream beside the payload.
16047
16278
  */
16048
- function upToDateReport(toolchain, checkOnlyImages) {
16049
- const warnCount = (toolchain.warned ? 1 : 0) + checkOnlyImages.length;
16279
+ function upToDateReport(toolchain, checkOnlyImages, aptSteps) {
16280
+ const warnCount = (toolchain.warned ? 1 : 0) + checkOnlyImages.length + aptSteps.length;
16050
16281
  if (warnCount > 0) {
16051
16282
  if (!isJsonMode()) rawLog$1(formatWarnMarker(warnCount));
16052
16283
  } else log$1.success("Repository is up to date.");
@@ -16056,6 +16287,7 @@ function upToDateReport(toolchain, checkOnlyImages) {
16056
16287
  failed: [],
16057
16288
  toolchain,
16058
16289
  checkOnlyImages,
16290
+ aptSteps,
16059
16291
  warnCount,
16060
16292
  exitCode: 0
16061
16293
  };
@@ -16091,28 +16323,30 @@ async function runCheckReport(targetDir) {
16091
16323
  toolchain: { warned: toolchain.warned }
16092
16324
  };
16093
16325
  const checkOnlyImages = runCheckOnlyImageGate(config, targetDir);
16326
+ const aptSteps = runWorkflowAptGate(targetDir);
16094
16327
  const { ctx, pendingWrites } = createDryRunContext(config);
16095
16328
  logDetectionSummary(ctx);
16096
16329
  const results = await runGenerators(ctx);
16097
16330
  const failed = failedResults(results);
16098
16331
  reportFailures(failed, "repo:sync");
16099
16332
  const actionable = actionableResults(results, pendingWrites, targetDir);
16100
- const toolchainWarnCount = (toolchain.warned ? 1 : 0) + checkOnlyImages.length;
16333
+ const advisoryWarnCount = (toolchain.warned ? 1 : 0) + checkOnlyImages.length + aptSteps.length;
16101
16334
  const toolchainState = { warned: toolchain.warned };
16102
- if (actionable.length === 0 && failed.length === 0) return upToDateReport(toolchainState, checkOnlyImages);
16335
+ if (actionable.length === 0 && failed.length === 0) return upToDateReport(toolchainState, checkOnlyImages, aptSteps);
16103
16336
  if (actionable.length === 0) return {
16104
16337
  status: "failed",
16105
16338
  actionable: [],
16106
16339
  failed,
16107
16340
  toolchain: toolchainState,
16108
16341
  checkOnlyImages: [],
16342
+ aptSteps: [],
16109
16343
  warnCount: 0,
16110
16344
  exitCode: 1
16111
16345
  };
16112
16346
  log$1.warn(`${actionable.length} file(s) would be changed by repo:sync`);
16113
16347
  for (const r of actionable) logActionableChange(r, targetDir, pendingWrites);
16114
16348
  if (failed.length === 0 && actionable.every((r) => isWorkflowDirPath(r.filePath))) {
16115
- const warnCount = actionable.length + toolchainWarnCount;
16349
+ const warnCount = actionable.length + advisoryWarnCount;
16116
16350
  if (!isJsonMode()) rawLog$1(formatWarnMarker(warnCount));
16117
16351
  return {
16118
16352
  status: "workflow-drift",
@@ -16120,6 +16354,7 @@ async function runCheckReport(targetDir) {
16120
16354
  failed: [],
16121
16355
  toolchain: toolchainState,
16122
16356
  checkOnlyImages,
16357
+ aptSteps,
16123
16358
  warnCount,
16124
16359
  exitCode: 0
16125
16360
  };
@@ -16130,6 +16365,7 @@ async function runCheckReport(targetDir) {
16130
16365
  failed,
16131
16366
  toolchain: toolchainState,
16132
16367
  checkOnlyImages: [],
16368
+ aptSteps: [],
16133
16369
  warnCount: 0,
16134
16370
  exitCode: 1
16135
16371
  };
@@ -16324,6 +16560,9 @@ const PullListItemSchema = z.object({
16324
16560
  updated_at: z.string().nullish(),
16325
16561
  labels: z.array(z.object({ name: z.string() })).nullish()
16326
16562
  });
16563
+ function updatedBefore(since) {
16564
+ return (pr) => pr.updatedAt !== null && Date.parse(pr.updatedAt) < since;
16565
+ }
16327
16566
  /**
16328
16567
  * Every pull request -- open, merged, and closed-unmerged -- by default, via `state=all`; a caller
16329
16568
  * may narrow the read with {@link ListPullsOptions.state}. Paged to an empty page or to
@@ -16351,7 +16590,9 @@ async function listPulls(executor, conn, options = {}) {
16351
16590
  limit: String(PULLS_PAGE_SIZE),
16352
16591
  page: String(page)
16353
16592
  });
16354
- if (options.sort !== void 0) params.set("sort", options.sort);
16593
+ const sort = options.updatedSince === void 0 ? options.sort : "recentupdate";
16594
+ if (sort !== void 0) params.set("sort", sort);
16595
+ for (const id of options.labelIds ?? []) params.append("labels", String(id));
16355
16596
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls?${params.toString()}`;
16356
16597
  const res = await executor.fetch(url, {
16357
16598
  headers,
@@ -16380,6 +16621,7 @@ async function listPulls(executor, conn, options = {}) {
16380
16621
  pulls.push(...items);
16381
16622
  if (items.length === 0) break;
16382
16623
  if (options.stopWhen !== void 0 && items.some(options.stopWhen)) break;
16624
+ if (options.updatedSince !== void 0 && items.some(updatedBefore(options.updatedSince))) break;
16383
16625
  if (options.limit !== void 0 && pulls.length >= options.limit) break;
16384
16626
  if (page === PULLS_MAX_PAGES) {
16385
16627
  log.warn(`PR listing on ${conn.repository} hit the ${String(PULLS_MAX_PAGES)}-page cap without an empty page; results may be truncated`);
@@ -21137,7 +21379,7 @@ function createRealLintTypesExecutor() {
21137
21379
  }
21138
21380
  //#endregion
21139
21381
  //#region src/lint-types/phase.ts
21140
- const LINT_TYPES_PHASE = "report";
21382
+ const LINT_TYPES_PHASE = "enforce";
21141
21383
  //#endregion
21142
21384
  //#region src/lint-types/report.ts
21143
21385
  /** oxlint spells a rule code `plugin(rule)` (e.g. `typescript(no-base-to-string)`); the escape
@@ -21158,8 +21400,8 @@ function formatFindingLine(diagnostic) {
21158
21400
  return `${span?.line !== void 0 && span.column !== void 0 ? `${diagnostic.filename}:${String(span.line)}:${String(span.column)}` : diagnostic.filename} ${formatRuleCode(diagnostic.code)} ${diagnostic.message}`;
21159
21401
  }
21160
21402
  /**
21161
- * Names no date: the enforce release has none, and inventing one the fleet cannot meet is
21162
- * worse than naming none.
21403
+ * Prints only when the phase is pinned to "report". Names no date: inventing one the fleet
21404
+ * cannot meet is worse than naming none.
21163
21405
  */
21164
21406
  const CONSEQUENCE_LINE = "These will block once a future release of the tooling flips lint:types from the report phase to the enforce phase.";
21165
21407
  const ESCAPE_HATCHES = [" - Turn a rule off in this repo's own oxlint.config.ts (e.g. \"typescript/<rule>\": \"off\") -- plain `pnpm lint` ignores type-aware rules entirely, so this never weakens it.", " - Skip the step entirely with .tooling.json#checks.skip: [\"lint:types\"]."];
@@ -21363,7 +21605,7 @@ function runLintTypes(executor, targetDir, extraArgs = []) {
21363
21605
  const lintTypesCommand = defineCommand({
21364
21606
  meta: {
21365
21607
  name: "lint:types",
21366
- description: "Run oxlint --type-aware (backed by oxlint-tsgolint) and report every type-aware finding, selected by subtracting a plain oxlint run. Report phase: prints findings, states they will block once a future tooling release flips to the enforce phase, exits 0 unless .tooling.json#lint.strictTypeAware is set. When findings do not block, the last stdout line is the ::bst-checks:: warn marker."
21608
+ description: "Run oxlint --type-aware (backed by oxlint-tsgolint) and report every type-aware finding, selected by subtracting a plain oxlint run. Enforce phase: prints findings and exits 1 when there are any. .tooling.json#lint.strictTypeAware makes a missing oxlint-tsgolint binary a failure instead of a notice."
21367
21609
  },
21368
21610
  args: {
21369
21611
  dir: {
@@ -21966,12 +22208,16 @@ const PROBE_TIMEOUT_SECONDS = "5";
21966
22208
  * Pinned by digest, not a tag: a tag-pinned probe image would re-pull (and
21967
22209
  * could silently change) on every check run, reintroducing this exact
21968
22210
  * issue's silent-timeout symptom on the very shared-agent CI runners it
21969
- * targets, since registry egress is often restricted there. Resolved from
21970
- * `curlimages/curl:8.21.0` (the latest stable tag as of 2026-07-16) via the
21971
- * Docker Hub registry API -- this is the digest of the multi-arch manifest
21972
- * list, so `docker run` still resolves the correct platform automatically.
22211
+ * targets, since registry egress is often restricted there. The digest is
22212
+ * the multi-arch manifest list's, so `docker run` still resolves the correct
22213
+ * platform automatically.
22214
+ *
22215
+ * The tag rides along only for Renovate: Docker resolves a `tag@digest`
22216
+ * reference by the digest alone, and the root `renovate.json` has a custom
22217
+ * manager that reads the tag as the current version and moves tag and digest
22218
+ * together.
21973
22219
  */
21974
- const PROBE_IMAGE = "curlimages/curl@sha256:7c12af72ceb38b7432ab85e1a265cff6ae58e06f95539d539b654f2cfa64bb13";
22220
+ const PROBE_IMAGE = "curlimages/curl:8.21.0@sha256:7c12af72ceb38b7432ab85e1a265cff6ae58e06f95539d539b654f2cfa64bb13";
21975
22221
  /** Look up the health status of a specific service from container info. */
21976
22222
  function getContainerHealth(containers, serviceName) {
21977
22223
  return containers.find((c) => c.Service === serviceName)?.Health ?? "unknown";
@@ -32247,6 +32493,11 @@ function taskLabelNumber(text) {
32247
32493
  * statement speaks for every future slice, the progress note only for this one. `**Progress:**`
32248
32494
  * and `**Blocked:**` alone declare nothing.
32249
32495
  *
32496
+ * A box's **title** is the first sentence of its paragraph, not of its line: markdown joins a
32497
+ * wrapped box into one paragraph, and a title cut at the wrap read as a fragment on the control
32498
+ * panel. `**` markers are dropped from it. The title is the identity the agent's box diff pairs
32499
+ * on, so it must stay a pure function of the box's own text.
32500
+ *
32250
32501
  * A box is the **retire task** when its title starts with optional `**` or `__`, then the
32251
32502
  * literal `Retire this plan`, word-bounded (not followed by a letter or digit). That
32252
32503
  * classification carries no judgement about whether the task is done; `isOutstanding` reads a
@@ -32274,6 +32525,49 @@ function noteKind(label) {
32274
32525
  if (label === "Blocked") return "blocked";
32275
32526
  return "declined";
32276
32527
  }
32528
+ /** A line that ends a box's paragraph: another list item, or a `**Progress:**`-shaped note. */
32529
+ const PARAGRAPH_BREAK_REGEX = /^[ \t]*(?:[-*+][ \t]|[0-9]+[.)][ \t]|\*\*(?:Progress|Blocked|Declined):\*\*)/;
32530
+ /** A word whose trailing period abbreviates rather than ends a sentence. */
32531
+ const ABBREVIATION_REGEX = /(?:^|[^A-Za-z])(?:e\.g|i\.e|etc|vs|cf)$/i;
32532
+ /**
32533
+ * The box line's text joined with the lines that continue its paragraph, one space apart. Markdown
32534
+ * renders a wrapped box as one paragraph, so a line break says nothing about where the title ends.
32535
+ * The paragraph stops at a blank line, a heading, a fenced line, another list item, or a note.
32536
+ */
32537
+ function boxParagraph(lines, fenced, start, end, first) {
32538
+ const parts = [first];
32539
+ for (let index = start + 1; index < end; index++) {
32540
+ const line = lines[index] ?? "";
32541
+ if (fenced[index] === true || line.trim() === "") break;
32542
+ if (HEADING_REGEX.test(line) || PARAGRAPH_BREAK_REGEX.test(line)) break;
32543
+ parts.push(line.trim());
32544
+ }
32545
+ return parts.join(" ").trim();
32546
+ }
32547
+ /**
32548
+ * The paragraph's first sentence: up to the first `.`, `!` or `?` outside an inline code span that
32549
+ * is followed by whitespace or the end, with any closing `*`, `_`, `)` or quote kept on it so a
32550
+ * bold span ending in a period stays whole. A common abbreviation (`e.g.`) does not end it. A
32551
+ * paragraph with no sentence end is returned whole.
32552
+ */
32553
+ function firstSentence$1(paragraph) {
32554
+ let inCode = false;
32555
+ for (let index = 0; index < paragraph.length; index++) {
32556
+ const char = paragraph[index];
32557
+ if (char === "`") inCode = !inCode;
32558
+ if (inCode || char !== "." && char !== "!" && char !== "?") continue;
32559
+ let end = index + 1;
32560
+ while (end < paragraph.length && /[*_)"']/.test(paragraph[end] ?? "")) end++;
32561
+ if (end < paragraph.length && !/\s/.test(paragraph[end] ?? "")) continue;
32562
+ if (char === "." && ABBREVIATION_REGEX.test(paragraph.slice(0, index))) continue;
32563
+ return paragraph.slice(0, end);
32564
+ }
32565
+ return paragraph;
32566
+ }
32567
+ /** `**` markers outside inline code spans removed: the title is shown as plain text. */
32568
+ function stripStrong(text) {
32569
+ return text.split(/(`[^`]*`)/).map((part) => part.startsWith("`") ? part : part.replaceAll("**", "")).join("").trim();
32570
+ }
32277
32571
  /**
32278
32572
  * The checkbox marker stripped, then a `**`/`__` span wrapping the whole title unwrapped (only
32279
32573
  * when the closing marker matches the opening one, so a title merely starting or ending with the
@@ -32310,7 +32604,7 @@ function parsePlanBoxes(planText) {
32310
32604
  break;
32311
32605
  }
32312
32606
  }
32313
- const title = unwrapEmphasis(box.rawTitle);
32607
+ const title = stripStrong(unwrapEmphasis(firstSentence$1(boxParagraph(lines, fenced, box.index, blockEnd, box.rawTitle))));
32314
32608
  const retireTask = RETIRE_TASK_REGEX.test(title);
32315
32609
  if (box.checked) return {
32316
32610
  line: box.index,
@@ -32843,7 +33137,7 @@ setForgejoLogger(log$1);
32843
33137
  * `local <sha>[-dirty]` otherwise -- so an artifact built from the working tree
32844
33138
  * can't pass itself off as the published release whose version it carries.
32845
33139
  */
32846
- const displayVersion = "0.86.1";
33140
+ const displayVersion = "0.87.1";
32847
33141
  /**
32848
33142
  * The registered `bst` subcommand map, exported so both the JSON entry path below (resolving a
32849
33143
  * command name before citty parses anything) and the coverage test (enumerating every