@bensandee/tooling 0.87.0 → 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.
Files changed (2) hide show
  1. package/dist/bin.mjs +2026 -1848
  2. package/package.json +1 -1
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.87.0"
272
+ version: "0.87.1"
273
273
  });
274
274
  //#endregion
275
275
  //#region ../cli-kit/src/logger.ts
@@ -3050,57 +3050,6 @@ function loadRegistryHostOverride(cwd) {
3050
3050
  }
3051
3051
  }
3052
3052
  //#endregion
3053
- //#region src/utils/generated-header.ts
3054
- /**
3055
- * Leading comment headers for fully generated files.
3056
- *
3057
- * These mark a file as managed by `@bensandee/tooling` so a reader knows that
3058
- * `bst repo:sync` may overwrite it, and points at the supported ways to keep
3059
- * custom content. Only used for files that are largely (re)generated wholesale
3060
- * -- not for files that are merged into (package.json, tsconfig.json, etc.).
3061
- */
3062
- /**
3063
- * Stable substring present in every managed-file header. Used to detect whether an
3064
- * existing file already carries the *current* header -- a comment-only difference is
3065
- * invisible to `normalizeWorkflow()` (it strips comments before comparing), so generators
3066
- * check for this marker to know a one-time header upgrade is still needed.
3067
- *
3068
- * The trailing `--` is load-bearing: it is what made the 2026-07-27 ASCII fix reach files
3069
- * that were already synced. Widen this marker whenever the header's leading text changes,
3070
- * or the change lands in the template and never in anyone's repo.
3071
- */
3072
- const MANAGED_HEADER_MARKER = "Managed by @bensandee/tooling --";
3073
- /** Render a comment block: each line is prefixed with `comment`; empty lines stay bare. */
3074
- function managedFileHeader(comment, lines) {
3075
- return lines.map((line) => line === "" ? comment : `${comment} ${line}`).join("\n") + "\n";
3076
- }
3077
- /**
3078
- * Header for generated CI workflow files (`ci.yml`, `release.yml`).
3079
- *
3080
- * The literal `@bensandee/tooling:ignore` directive is kept mid-line on purpose so
3081
- * `isToolingIgnored()` (which looks for the directive at the start of a comment) does
3082
- * not mistake this header for an opt-out.
3083
- */
3084
- const WORKFLOW_MANAGED_HEADER = managedFileHeader("#", [
3085
- "Managed by @bensandee/tooling -- `bst repo:sync` regenerates this workflow,",
3086
- "so direct edits are overwritten on the next sync.",
3087
- "To preserve changes, wrap them between `# @tooling:custom` and",
3088
- "`# @tooling:endcustom` markers, or stop managing this file by making",
3089
- "`# @bensandee/tooling:ignore` the first line."
3090
- ]);
3091
- /** Header for the generated `lefthook.yml` pre-commit config. */
3092
- const LEFTHOOK_MANAGED_HEADER = managedFileHeader("#", [
3093
- "Managed by @bensandee/tooling -- `bst repo:sync` keeps the `lint` and",
3094
- "`format` pre-commit commands current. Other commands you add are preserved.",
3095
- "To stop managing this file, make `# @bensandee/tooling:ignore` the first line."
3096
- ]);
3097
- /**
3098
- * Header for a generated agent guide (`.agent/guides/*.md`). One line, because guide ownership
3099
- * is decided by the marker on the first line alone. It spells the ignore directive out in words
3100
- * rather than as the literal comment, which would close this one early.
3101
- */
3102
- 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";
3103
- //#endregion
3104
3053
  //#region src/utils/debug.ts
3105
3054
  const verboseLogger = createLogger({ level: "trace" });
3106
3055
  /** Check if verbose/debug mode is enabled via environment variables. */
@@ -5684,6 +5633,136 @@ function contextAsDockerReader(ctx) {
5684
5633
  };
5685
5634
  }
5686
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
5687
5766
  //#region src/utils/yaml-merge.ts
5688
5767
  const IGNORE_PATTERN = "@bensandee/tooling:ignore";
5689
5768
  const CUSTOM_START = "# @tooling:custom";
@@ -5992,535 +6071,790 @@ function buildWorkflowYaml(options) {
5992
6071
  return ensureSchemaComment(WORKFLOW_MANAGED_HEADER + "\n" + readable, options.ci);
5993
6072
  }
5994
6073
  //#endregion
5995
- //#region src/generators/publish-ci.ts
5996
- const RegistryHostSchema = z.object({ registryHost: z.string().optional() });
6074
+ //#region src/generators/mise-pin.ts
5997
6075
  /**
5998
- * Resolve the docker-registry host at gen time so the workflow generator can
5999
- * pick registry-specific secret names. Honors `.tooling.json#registryHost`
6000
- * first, then git remote `origin` (the operational host, not a mirror-pointed
6001
- * package.json), then derives from `package.json#repository` using the same
6002
- * parser as the runtime (`parseGitUrl` handles https, git+https, and ssh shapes).
6003
- * Returns null if neither source yields a host -- in that case the generator
6004
- * falls back to neutral `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` secrets and
6005
- * lets the runtime resolve the host from `DOCKER_REGISTRY_HOST` (still emitted
6006
- * into the step).
6076
+ * The mise version and release-asset digest the fleet's CI and the agent runner base image agree
6077
+ * to run. This is the one place that agreement is recorded; `packages/agent/test/runner-base-image-dockerfile.test.ts`
6078
+ * (task 8 of the mise-toolchain-provisioning plan) reads both constants back through
6079
+ * `readRepoFile` and asserts the base image's own `ARG MISE_VERSION` and `mise.sha256` manifest
6080
+ * agree with them, so the two packages cannot drift without a failing test on either side.
6081
+ *
6082
+ * Each constant sits under its own `// renovate:` marker comment, in the three-line grammar
6083
+ * `packages/agent/docker/agent-goose-image/goose.sha256` uses for its own digest line, so the same
6084
+ * shape of custom regex manager in the root `renovate.json` can rewrite both this version and this
6085
+ * digest (`customManagers` entries scoped to this file).
6007
6086
  */
6008
- function resolveGenTimeHost(ctx) {
6009
- const toolingRaw = ctx.read(".tooling.json");
6010
- if (toolingRaw) try {
6011
- const parsed = RegistryHostSchema.safeParse(JSON.parse(toolingRaw));
6012
- if (parsed.success && parsed.data.registryHost && parsed.data.registryHost.length > 0) return parsed.data.registryHost;
6013
- } catch (error) {
6014
- log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6015
- }
6016
- const originUrl = detectGitRemoteUrl(ctx.targetDir);
6017
- if (originUrl !== null) {
6018
- const parsedOrigin = parseGitUrl(originUrl);
6019
- if (parsedOrigin) return registryHostFromHostname(parsedOrigin.hostname);
6020
- }
6021
- const raw = ctx.read("package.json");
6022
- if (!raw) return null;
6023
- const repo = parsePackageJson(raw)?.repository;
6024
- let urlStr = null;
6025
- if (typeof repo === "string") urlStr = repo;
6026
- else if (repo && typeof repo === "object") urlStr = repo.url;
6027
- if (typeof urlStr !== "string") return null;
6028
- const parsed = parseGitUrl(urlStr);
6029
- if (!parsed) return null;
6030
- return registryHostFromHostname(parsed.hostname);
6087
+ const MISE_VERSION = "2026.10.3";
6088
+ const MISE_LINUX_X64_SHA256 = "8d48bc510b7d844fad0bc7156c0855c2a1e7230c51e498a7f6b63b021f8b57c5";
6089
+ /** Where generated Forgejo workflows fetch the shared mise setup action: a composite action in
6090
+ * `bensandee/common`, on the forge every fleet repo lives on. GitHub cannot reach it; the
6091
+ * generators keep inline steps there. */
6092
+ const SETUP_MISE_ACTION_URL = "https://code.orangebikelabs.com/bensandee/common/actions/setup-mise";
6093
+ /** The tag in `bensandee/common` that marks the current release of the action. Generated `uses:`
6094
+ * lines carry it as their comment, and Renovate moves the SHA below to wherever it points. */
6095
+ const SETUP_MISE_ACTION_TAG = "setup-mise@latest";
6096
+ const SETUP_MISE_ACTION_SHA = "db99e4e9ee46bfeaca2146e7ae55cc700b4c23bf";
6097
+ //#endregion
6098
+ //#region src/generators/ci-utils.ts
6099
+ /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
6100
+ function actionsExpr(expr) {
6101
+ return `\${{ ${expr} }}`;
6031
6102
  }
6032
- const PostReleaseHookSchema = z.object({
6033
- postReleaseHook: z.string().optional(),
6034
- postReleaseHookSecrets: z.array(z.string()).optional()
6035
- });
6036
- const ReleaseAssetsSchema = z.object({ releaseAssets: z.array(ReleaseAssetSchema).optional() });
6037
- const VersionStampConfigSchema = z.object({ versionStamp: z.array(VersionStampSchema).optional() });
6038
- const DeploySchema = z.object({ deploy: DeployConfigSchema.optional() });
6103
+ const MAIN_BRANCH_REF = "refs/heads/main";
6104
+ /** `if:` condition that gates a step to push events on the default branch. */
6105
+ const IS_MAIN_BRANCH = `github.ref == '${MAIN_BRANCH_REF}'`;
6039
6106
  /**
6040
- * Read the `deploy` block from .tooling.json, if configured.
6107
+ * Negated form for concurrency `cancel-in-progress` and similar uses.
6041
6108
  *
6042
- * The full file is already validated against ToolingConfigSchema at sync time,
6043
- * so an invalid `deploy` block fails earlier -- undefined here means "not set".
6044
- */
6045
- function getDeployConfig(ctx) {
6046
- const raw = ctx.read(".tooling.json");
6047
- if (!raw) return void 0;
6048
- try {
6049
- const result = DeploySchema.safeParse(JSON.parse(raw));
6050
- if (!result.success) return void 0;
6051
- return result.data.deploy;
6052
- } catch (error) {
6053
- log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6054
- return;
6055
- }
6056
- }
6057
- /** Read the postReleaseHook command override from .tooling.json, if configured. */
6058
- function getPostReleaseHookCommand(ctx) {
6059
- const raw = ctx.read(".tooling.json");
6060
- if (!raw) return void 0;
6061
- try {
6062
- const result = PostReleaseHookSchema.safeParse(JSON.parse(raw));
6063
- if (!result.success) return void 0;
6064
- return result.data.postReleaseHook;
6065
- } catch (error) {
6066
- log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6067
- return;
6068
- }
6069
- }
6070
- /** Read the postReleaseHookSecrets array from .tooling.json, if configured. */
6071
- function getPostReleaseHookSecrets(ctx) {
6072
- const raw = ctx.read(".tooling.json");
6073
- if (!raw) return void 0;
6074
- try {
6075
- const result = PostReleaseHookSchema.safeParse(JSON.parse(raw));
6076
- if (!result.success) return void 0;
6077
- return result.data.postReleaseHookSecrets;
6078
- } catch (error) {
6079
- log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6080
- return;
6081
- }
6082
- }
6109
+ * Main is exempt from cancellation on purpose, and the exemption stays: a main run publishes and
6110
+ * pushes tags, and cancelling one mid-flight can leave a partial publish -- some packages on the
6111
+ * registry, no matching tags, and no run left alive to finish. A superseded feature-branch run
6112
+ * wastes minutes; a half-published release costs a manual unwind.
6113
+ *
6114
+ * So several main runs can be in flight at once, each versioning from its own triggering commit.
6115
+ * Overlap safety rests on one invariant: **main advances solely via commits that trigger a CI
6116
+ * run.** The `[skip ci]` coverage, health, and egress records go to `bst/*` orphan branches and
6117
+ * never to main; a `[skip ci]` commit pushed straight to main would leave a superseded run skipping
6118
+ * and its changesets stranded until the next push, which is why the supersession guard skips rather
6119
+ * than fails. Three guards carry the overlap itself: supersession plus a `--force-with-lease`
6120
+ * version push (`release/publish/version.ts`), the `headBumpedVersion` publish guard plus reading
6121
+ * remote tags and releases before acting (`commands/release-changesets.ts`,
6122
+ * `release/publish/publish.ts`), and floating git and Docker tags that only move forward
6123
+ * (`release/publish/floating-tags.ts`, `release/docker.ts`). Such a run may also version pending
6124
+ * changesets after it publishes -- the stale version-PR merge -- under the same supersession guard
6125
+ * and push lease as any other version run. A later commit publishes a bump only when its tag is
6126
+ * missing and every main run since the package's last release tag has settled
6127
+ * (`release/publish/unpublished-bump.ts`), so no run that could still publish it is in flight; a
6128
+ * manual re-run of one of those runs is the overlap left. On Forgejo the concurrency block is
6129
+ * only a PR-run cancellation mechanism -- it holds a main run in its group and, in Forgejo 16,
6130
+ * intermittently fails to release it, which is why main runs get a group of their own there. GitHub's shape stays shared
6131
+ * because its queue works: one run, one pending, a newer arrival replacing the pending, so
6132
+ * `changesets/action@v1` never needs a guard of its own. Any new side effect in a main run must be
6133
+ * idempotent under overlap or be listed here with the guard that makes it so.
6134
+ */
6135
+ const IS_NOT_MAIN_BRANCH = `github.ref != '${MAIN_BRANCH_REF}'`;
6083
6136
  /**
6084
- * Names of the Docker images `detectDockerPackages` discovers for this repo --
6085
- * by package convention, compose service, or `.tooling.json#docker` override.
6086
- * Routed through the same chokepoint every other Docker consumer uses, so a
6087
- * compose-only image (no convention Dockerfile, no override) still gates the
6088
- * scripts and workflow steps below instead of silently dropping them.
6137
+ * Job-level `env:` block that evaluates the default-branch check once, so steps
6138
+ * can gate on `env.IS_MAIN` instead of repeating the `github.ref` expression.
6089
6139
  */
6090
- function getDockerPackageNames(ctx) {
6091
- return detectContextDockerPackages(ctx).map((pkg) => pkg.imageName);
6092
- }
6093
- /** Check whether any Docker packages exist by convention or .tooling.json config. */
6094
- function hasDockerPackages(ctx) {
6095
- return getDockerPackageNames(ctx).length > 0;
6096
- }
6097
- /** Paths (relative to repo root) of packages whose package.json has a `build:release-assets` script. */
6098
- function detectReleaseAssetsConventionDirs(ctx) {
6099
- const dirs = [];
6100
- if (ctx.config.structure === "monorepo") {
6101
- const packages = getMonorepoPackages(ctx.targetDir);
6102
- for (const pkg of packages) {
6103
- const dirName = pkg.name.split("/").pop() ?? pkg.name;
6104
- const pkgRaw = ctx.read(`packages/${dirName}/package.json`);
6105
- if (!pkgRaw) continue;
6106
- if (parsePackageJson(pkgRaw)?.scripts?.["build:release-assets"]) dirs.push(`packages/${dirName}`);
6107
- }
6108
- } else {
6109
- const pkgRaw = ctx.read("package.json");
6110
- if (pkgRaw) {
6111
- if (parsePackageJson(pkgRaw)?.scripts?.["build:release-assets"]) dirs.push(".");
6112
- }
6113
- }
6114
- return dirs;
6115
- }
6116
- /** Check whether release assets are configured by convention or in .tooling.json. */
6117
- function hasReleaseAssets(ctx) {
6118
- if (detectReleaseAssetsConventionDirs(ctx).length > 0) return true;
6119
- const raw = ctx.read(".tooling.json");
6120
- if (!raw) return false;
6121
- try {
6122
- const result = ReleaseAssetsSchema.safeParse(JSON.parse(raw));
6123
- if (!result.success) return false;
6124
- return !!result.data.releaseAssets && result.data.releaseAssets.length > 0;
6125
- } catch (error) {
6126
- log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6127
- return false;
6128
- }
6129
- }
6140
+ const IS_MAIN_JOB_ENV = { IS_MAIN: actionsExpr(IS_MAIN_BRANCH) };
6141
+ /** `if:` condition referencing the job-level `IS_MAIN` env var (see {@link IS_MAIN_JOB_ENV}). */
6142
+ const IS_MAIN_ENV_GUARD = "env.IS_MAIN == 'true'";
6130
6143
  /**
6131
- * Check whether the repo configures `versionStamp`. A repo with a `versionStamp` block but no
6132
- * `releaseAssets` and no `build:release-assets` script would otherwise get no assets step, and
6133
- * nothing would ever run the stamp -- see the two `hasReleaseAssets(ctx) || hasVersionStamp(ctx)`
6134
- * call sites in `ci.ts` and `release-ci.ts`. Deliberately not folded into `hasReleaseAssets`
6135
- * itself: `gitignore.ts` shares that predicate to decide whether to ignore `release-assets/`, and
6136
- * a stamp-only repo produces no such directory.
6144
+ * npm's own registry-key rule for an `.npmrc` auth line: strip the scheme, keep
6145
+ * host/port/path, and end with exactly one slash. `actions/setup-node` derives the
6146
+ * same key from a registry URL; this mirrors that so a hand-written line matches
6147
+ * what npm looks up at publish time -- npm-registry-fetch walks the request path
6148
+ * upward through ancestor keys until one matches.
6137
6149
  */
6138
- function hasVersionStamp(ctx) {
6139
- const raw = ctx.read(".tooling.json");
6140
- if (!raw) return false;
6141
- try {
6142
- const result = VersionStampConfigSchema.safeParse(JSON.parse(raw));
6143
- if (!result.success) return false;
6144
- return !!result.data.versionStamp && result.data.versionStamp.length > 0;
6145
- } catch (error) {
6146
- log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6147
- return false;
6148
- }
6150
+ function npmrcAuthKey(registryUrl) {
6151
+ return `${registryUrl.replace(/^https?:/i, "").replace(/\/+$/, "")}/`;
6149
6152
  }
6150
- /** Where `ctx.remove()` puts what it archives. Named here only to describe it to the user. */
6151
- const ARCHIVE_DIR$2 = ".tooling-archived";
6153
+ const NODE_AUTH_TOKEN_VAR = ["$", "{NODE_AUTH_TOKEN}"].join("");
6152
6154
  /**
6153
- * Archive the manual `publish.yml` this generator used to write; it writes nothing. Docker
6154
- * images publish from the release workflow, and the manual workflow was a thin wrapper around
6155
- * `bst docker:publish` that nobody ran -- run that command instead. Only a file carrying the
6156
- * managed header is removed: a hand-written `publish.yml`, or one opted out with the ignore
6157
- * comment, belongs to the repo.
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.
6158
6170
  */
6159
- async function generateDeployCi(ctx) {
6160
- const filePath = "deploy-ci";
6161
- if (ctx.config.ci === "none") return {
6162
- filePath,
6163
- action: "skipped",
6164
- description: "Publish workflow not applicable"
6165
- };
6166
- const workflowPath = ctx.config.ci === "github" ? ".github/workflows/publish.yml" : ".forgejo/workflows/publish.yml";
6167
- const existing = ctx.read(workflowPath);
6168
- if (existing === void 0) return {
6169
- filePath,
6170
- action: "skipped",
6171
- description: "No publish workflow to retire"
6172
- };
6173
- if (isToolingIgnored(existing) || !existing.includes("Managed by @bensandee/tooling --")) return {
6174
- filePath: workflowPath,
6175
- action: "skipped",
6176
- description: "Publish workflow is not managed by tooling"
6177
- };
6178
- ctx.remove(workflowPath);
6179
- return {
6180
- filePath: workflowPath,
6181
- action: "removed",
6182
- description: `Moved to ${ARCHIVE_DIR$2}/${workflowPath} (run \`bst docker:publish\` to publish by hand)`
6171
+ function npmAuthWiring(ctx, publishes) {
6172
+ const registries = publishes ? declaredPublishRegistries(ctx.targetDir, ctx.config.structure, ctx.packageJson) : [];
6173
+ if (registries.length === 0) return {
6174
+ publishRun: (command) => command,
6175
+ tokenEnv: {}
6183
6176
  };
6184
- }
6185
- //#endregion
6186
- //#region src/codebase-health/config.ts
6187
- const DEFAULT_HEALTH_HISTORY_BRANCH = "bst/health-metrics";
6188
- const TS_EXTENSIONS = [".ts", ".tsx"];
6189
- const JS_EXTENSIONS = [
6190
- ".js",
6191
- ".jsx",
6192
- ".mjs",
6193
- ".cjs"
6194
- ];
6195
- const DEFAULT_DIRECTORY_FILE_LIMIT = 20;
6196
- const DEFAULT_FAN_LIMIT = 10;
6197
- const DEFAULT_MODULE_SIZE_LIMIT = 200;
6198
- /** The extensions dependency-cruiser should glob for a given stack. */
6199
- function defaultExtensions(stack) {
6200
- if (stack === "typescript") return TS_EXTENSIONS;
6201
- if (stack === "javascript") return JS_EXTENSIONS;
6202
- return [];
6203
- }
6204
- /** Tolerant of a key `health` does not declare, shared by both entry points below:
6205
- * `resolveHealthConfig` (the CLI) and `resolveHealthConfigFromContext` (the generator). The
6206
- * generator's own caller runs behind `repo:sync`'s strict root read, which has already
6207
- * rejected the document, so leniency here is unreachable there rather than a semantic change
6208
- * for that path. */
6209
- const ToolingShallowSchema$2 = z.object({ health: tolerantBlock(HealthConfigSchema).optional() });
6210
- function defaultSrc(isMonorepo) {
6211
- return isMonorepo ? ["packages/*/src"] : ["src"];
6212
- }
6213
- function resolveFromOverrides(overrides, isMonorepo, stack) {
6214
- const o = overrides ?? {};
6215
- const enabled = o.enabled === true;
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
+ ];
6216
6185
  return {
6217
- enabled,
6218
- src: o.src ?? defaultSrc(isMonorepo),
6219
- exclude: o.exclude ?? [],
6220
- extensions: o.extensions ?? defaultExtensions(stack),
6221
- directoryFileLimit: o.directoryFileLimit ?? DEFAULT_DIRECTORY_FILE_LIMIT,
6222
- fanInLimit: o.fanInLimit ?? DEFAULT_FAN_LIMIT,
6223
- fanOutLimit: o.fanOutLimit ?? DEFAULT_FAN_LIMIT,
6224
- moduleSizeLimit: o.moduleSizeLimit ?? DEFAULT_MODULE_SIZE_LIMIT,
6225
- crossPackage: o.crossPackage ?? true,
6226
- historyEnabled: enabled && o.history !== "none",
6227
- historyBranch: o.historyBranch ?? DEFAULT_HEALTH_HISTORY_BRANCH,
6228
- tolerance: o.tolerance ?? 0,
6229
- graphMetricsEnabled: stack !== "none",
6230
- exempt: o.exempt ?? {}
6186
+ publishRun: (command) => [...setup, command].join("\n"),
6187
+ tokenEnv: { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
6231
6188
  };
6232
6189
  }
6233
- function resolveHealthConfigFromContext(reader, opts) {
6234
- return resolveFromOverrides(parseToolingConfigSlice(reader.read(".tooling.json"), ToolingShallowSchema$2, { block: "health" })?.health, opts.isMonorepo, reader.config.stack);
6235
- }
6190
+ const MISE_RELEASE_URL = `https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-x64`;
6236
6191
  /**
6237
- * The two facts about a tree that `resolveHealthConfig` detects but does not return: the stack and
6238
- * whether it is a monorepo. Exported for `health:check`'s reference read, which must resolve the
6239
- * default branch's `health` block with the *same* context the working tree's block was resolved
6240
- * with -- and which therefore has to come out of this module rather than a fresh import, because
6241
- * the command that needs it sits at its fan-out ceiling.
6192
+ * The node script `inlineMiseSteps` pipes `mise env --json` into, run under the mise it just
6193
+ * installed. Reads the env map off stdin (mise exec passes it straight through) and exports every
6194
+ * key except `PATH` into `$GITHUB_ENV` in heredoc form -- the form that survives any value,
6195
+ * including one containing `$` or quotes -- then appends mise's shim directory and the
6196
+ * `~/.local/bin` install location to `$GITHUB_PATH` so every later step sees node, pnpm, and any
6197
+ * other declared tool on PATH with no prefix.
6242
6198
  */
6243
- function detectHealthContext(targetDir) {
6244
- return {
6245
- stack: resolveStack(targetDir),
6246
- isMonorepo: existsSync(path.join(targetDir, "packages"))
6247
- };
6248
- }
6249
- /** Resolve from the real filesystem (used by CLI commands). */
6250
- function resolveHealthConfig(targetDir) {
6251
- const { stack, isMonorepo } = detectHealthContext(targetDir);
6252
- return resolveHealthConfigFromContext({
6253
- exists: (rel) => existsSync(path.join(targetDir, rel)),
6254
- read: (rel) => {
6255
- const full = path.join(targetDir, rel);
6256
- if (!existsSync(full)) return void 0;
6257
- try {
6258
- return readFileSync(full, "utf-8");
6259
- } catch (_error) {
6260
- return;
6261
- }
6262
- },
6263
- config: { stack }
6264
- }, { isMonorepo });
6265
- }
6266
- //#endregion
6267
- //#region src/utils/script-forwarding.ts
6268
- /**
6269
- * pnpm appends args forwarded via `pnpm <script> <args>` to the END of the
6270
- * resolved script string. A forwarded flag therefore only reaches its intended
6271
- * command when that command is the LAST command in the script. The generated
6272
- * CI workflow depends on this two-hop forwarding:
6273
- *
6274
- * pnpm ci:check --with-coverage
6275
- * -> pnpm check --skip 'docker:*' --with-coverage
6276
- * -> bst checks:run --skip 'docker:*' --with-coverage
6277
- *
6278
- * If `check` or `ci:check` ends in a different command (e.g. an `&&` chain),
6279
- * the forwarded flags silently land on the wrong command -- tests run without
6280
- * coverage and `--skip` becomes a no-op. This module detects that shape so
6281
- * callers can surface a precise diagnosis instead of a confusing downstream
6282
- * failure.
6283
- */
6284
- /** A script's commands, split on `&&`, `||`, `|`, and `;`, each trimmed. */
6285
- function commandSegments(script) {
6286
- return script.split(/&&|\|\|?|;/).map((segment) => segment.trim());
6287
- }
6288
- /** The final command of a script, after splitting on `&&`, `||`, `|`, and `;`. */
6289
- function lastCommandSegment(script) {
6290
- return commandSegments(script).at(-1) ?? "";
6199
+ const MISE_ENV_EXPORT_SCRIPT = [
6200
+ "const fs = require(\"fs\");",
6201
+ "const env = JSON.parse(fs.readFileSync(0, \"utf8\"));",
6202
+ "let out = \"\";",
6203
+ "for (const [key, value] of Object.entries(env)) {",
6204
+ " if (key === \"PATH\") continue;",
6205
+ " out += key + \"<<BST_MISE_ENV_EOF\\n\" + value + \"\\nBST_MISE_ENV_EOF\\n\";",
6206
+ "}",
6207
+ "fs.appendFileSync(process.env.GITHUB_ENV, out);",
6208
+ "fs.appendFileSync(",
6209
+ " process.env.GITHUB_PATH,",
6210
+ " process.env.HOME + \"/.local/share/mise/shims\\n\" + process.env.HOME + \"/.local/bin\\n\",",
6211
+ ");"
6212
+ ].join("\n");
6213
+ /** A fresh object per call: one shared object in two jobs would serialize as a YAML alias. */
6214
+ function pnpmStoreCacheStep() {
6215
+ return { step: {
6216
+ name: "Cache pnpm store",
6217
+ uses: "actions/cache@v4",
6218
+ with: {
6219
+ path: "~/.local/share/pnpm/store",
6220
+ key: `pnpm-${actionsExpr("runner.os")}-${actionsExpr("hashFiles('pnpm-lock.yaml')")}`
6221
+ }
6222
+ } };
6291
6223
  }
6224
+ /** Actions whose ref the generator pins itself, so `preservePinnedActions` must not keep a
6225
+ * repo's older ref for them. */
6226
+ const GENERATOR_OWNED_ACTIONS = [{
6227
+ name: SETUP_MISE_ACTION_URL,
6228
+ comment: SETUP_MISE_ACTION_TAG
6229
+ }];
6292
6230
  /**
6293
- * True when args forwarded to a `check` script would reach `bst checks:run` -- its last command
6294
- * must invoke `bst checks:run`. Trailing flags like `--add` are fine; only the position of the
6295
- * invocation matters.
6231
+ * The steps every workflow generator uses in place of `pnpm/action-setup` + `actions/setup-node`:
6232
+ * install the pinned `mise`, `mise install` the repo's `mise.toml` toolchain, export its env into
6233
+ * the rest of the job, and cache the pnpm store. `MISE_VERSION`/`MISE_LINUX_X64_SHA256`
6234
+ * (`mise-pin.ts`) are the same pin the agent runner's base image installs, checked by
6235
+ * `runner-base-image-dockerfile.test.ts`.
6296
6236
  *
6297
- * The leading `[\s/]` alternative is what lets a wrapped invocation (`pnpm exec bst checks:run`,
6298
- * or an absolute path ending in `/bst`) still count. It also used to admit a `bin.mjs checks:run`
6299
- * spelling for the tooling repo's own `check` script, which invoked
6300
- * `node packages/tooling-cli/dist/bin.mjs` directly; that repo now depends on itself as
6301
- * `workspace:*` and runs `pnpm exec bst checks:run`, so the exception has no caller left.
6302
- */
6303
- function isCheckScriptForwardingSafe(script) {
6304
- return /(?:^|[\s/])bst\s+checks:run(?=\s|$)/.test(lastCommandSegment(script));
6305
- }
6306
- /**
6307
- * True when args forwarded to a `ci:check` script would reach `pnpm check`
6308
- * -- its last command must invoke `pnpm check` (or `pnpm run check`).
6237
+ * On Forgejo the mise half is the shared composite action in `bensandee/common`, pinned by commit.
6238
+ * GitHub resolves actions only from github.com, so it keeps the same steps inline.
6309
6239
  */
6310
- function isCiCheckScriptForwardingSafe(script) {
6311
- return /(?:^|\s)pnpm\s+(?:run\s+)?check(?=\s|$)/.test(lastCommandSegment(script));
6240
+ function miseSetupSteps(ci) {
6241
+ if (ci !== "forgejo") return inlineMiseSteps();
6242
+ return [{ step: {
6243
+ name: "Set up mise",
6244
+ uses: `${SETUP_MISE_ACTION_URL}@${SETUP_MISE_ACTION_SHA}`,
6245
+ with: {
6246
+ version: MISE_VERSION,
6247
+ sha256: MISE_LINUX_X64_SHA256
6248
+ }
6249
+ } }, pnpmStoreCacheStep()];
6312
6250
  }
6313
6251
  /**
6314
- * Inspect a package.json `scripts` record for `check` / `ci:check` definitions
6315
- * whose shape would swallow pnpm-forwarded args. Returns a human-readable hint
6316
- * naming the problem and the fix, or undefined when forwarding is sound.
6252
+ * The inline form of the shared action, for GitHub. Uses `mise env --json` piped into the
6253
+ * just-installed mise's own `node -e`, not `mise env --shell bash`: the JSON form is
6254
+ * shell-independent and the interpreter reading it is guaranteed to exist (mise just installed
6255
+ * it), so this needs no assumption about which shell the runner's `run:` steps use.
6317
6256
  */
6318
- function diagnoseCheckScriptForwarding(scripts) {
6319
- if (!scripts) return void 0;
6320
- const problems = [];
6321
- const check = scripts["check"];
6322
- if (check !== void 0 && !isCheckScriptForwardingSafe(check)) problems.push(`the \`check\` script does not end with \`bst checks:run\` (its last command is \`${lastCommandSegment(check)}\`)`);
6323
- const ciCheck = scripts["ci:check"];
6324
- if (ciCheck !== void 0 && !isCiCheckScriptForwardingSafe(ciCheck)) problems.push(`the \`ci:check\` script does not end with \`pnpm check\` (its last command is \`${lastCommandSegment(ciCheck)}\`)`);
6325
- if (problems.length === 0) return void 0;
6326
- return `Likely cause: ${problems.join(", and ")}. pnpm appends forwarded args (e.g. \`--with-coverage\`, \`--skip 'docker:*'\`) to the end of the resolved script, so they never reach \`bst checks:run\`. Make \`bst checks:run\` the last command -- move extra checks into \`bst checks:run --add <name,...>\`.`;
6257
+ function inlineMiseSteps() {
6258
+ const installScript = [
6259
+ "curl -fsSL -o \"$RUNNER_TEMP/mise\" " + MISE_RELEASE_URL,
6260
+ `echo "${MISE_LINUX_X64_SHA256} $RUNNER_TEMP/mise" | sha256sum -c`,
6261
+ "mkdir -p \"$HOME/.local/bin\" && install -m 0755 \"$RUNNER_TEMP/mise\" \"$HOME/.local/bin/mise\"",
6262
+ "export MISE_TRUSTED_CONFIG_PATHS=\"$GITHUB_WORKSPACE\"",
6263
+ "export MISE_YES=1",
6264
+ "\"$HOME/.local/bin/mise\" install",
6265
+ `"$HOME/.local/bin/mise" env --json | "$HOME/.local/bin/mise" exec -- node -e '${MISE_ENV_EXPORT_SCRIPT}'`
6266
+ ].join("\n");
6267
+ return [
6268
+ { step: {
6269
+ name: "Cache mise tools",
6270
+ uses: "actions/cache@v4",
6271
+ with: {
6272
+ path: "~/.local/share/mise",
6273
+ key: `mise-${actionsExpr("runner.os")}-${actionsExpr("hashFiles('mise.toml')")}`
6274
+ }
6275
+ } },
6276
+ pnpmStoreCacheStep(),
6277
+ { step: {
6278
+ name: "Install mise",
6279
+ env: { GITHUB_TOKEN: "" },
6280
+ run: installScript
6281
+ } }
6282
+ ];
6327
6283
  }
6328
6284
  /**
6329
- * The toolchain scripts `MANAGED_SCRIPTS` (package-json.ts) deliberately does NOT
6330
- * force-converge. They are add-if-absent: once a value exists, sync never looks at
6331
- * it again -- because force-converging `build` would stomp a Vite project's
6332
- * `vite build`, which is the entire point of the `builder` axis. The cost of that
6333
- * freedom is that these scripts can drift into nonsense and nothing notices, so we
6334
- * diagnose the one drift shape that fails *silently* rather than loudly.
6335
- */
6336
- const TOOLCHAIN_SCRIPTS = [
6337
- "build",
6338
- "typecheck",
6339
- "lint",
6340
- "test",
6341
- "knip",
6342
- "format"
6343
- ];
6344
- /**
6345
- * True when a pnpm invocation asks for recursion (`-r`) and then pins itself to the
6346
- * workspace root (`-w`) -- `pnpm -r -w --parallel exec tsc --noEmit`.
6285
+ * Build the docker publish env block.
6347
6286
  *
6348
- * `-w` wins: the command runs exactly once, in the workspace root, and never descends
6349
- * into `packages/*`. A monorepo root holds no source, so `tsc --noEmit` there typechecks
6350
- * nothing and **exits 0**. That is the failure mode this exists for -- the script is not
6351
- * broken, it is a no-op that reports success, so it survives in CI indefinitely. This
6352
- * repo's own `typecheck` sat in exactly that state until it was found by accident.
6287
+ * The LHS env-var names (`DOCKER_USERNAME` / `DOCKER_PASSWORD`) match what
6288
+ * publish-docker.ts reads at runtime. The RHS secret references are
6289
+ * registry-specific (see {@link dockerSecretNames}) so downstream repos can
6290
+ * stop relying on the user/org-level `DOCKER_USERNAME` / `DOCKER_PASSWORD`
6291
+ * secrets -- which the Forgejo act fork auto-injects into every `container:`
6292
+ * image pull regardless of the target registry.
6353
6293
  *
6354
- * Scanned per `&&`/`||`/`|`/`;` segment so a chained script can't hide the conflict, and
6355
- * only up to the first non-flag token so a *script's own* `-w` (e.g. `vitest --watch`,
6356
- * `tsdown -w`) is never mistaken for pnpm's.
6294
+ * When `host` is `null` (couldn't be derived at gen time), the secret references
6295
+ * fall back to neutral `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` and the
6296
+ * workflow keeps `DOCKER_REGISTRY_HOST` / `DOCKER_REGISTRY_NAMESPACE` so the CLI
6297
+ * resolves the registry at runtime.
6357
6298
  */
6358
- function recursionIsPinnedToRoot(script) {
6359
- return script.split(/&&|\|\|?|;/).some((segment) => {
6360
- const tokens = segment.trim().split(/\s+/);
6361
- if (tokens[0] !== "pnpm") return false;
6362
- let recursive = false;
6363
- let workspaceRoot = false;
6364
- for (const token of tokens.slice(1)) {
6365
- if (!token.startsWith("-")) break;
6366
- if (token === "--recursive") recursive = true;
6367
- else if (token === "--workspace-root") workspaceRoot = true;
6368
- else if (/^-[a-zA-Z]+$/.test(token)) {
6369
- if (token.includes("r")) recursive = true;
6370
- if (token.includes("w")) workspaceRoot = true;
6371
- }
6372
- }
6373
- return recursive && workspaceRoot;
6374
- });
6299
+ function dockerPublishEnv(host) {
6300
+ const { username, password } = dockerSecretNames(host);
6301
+ return {
6302
+ ...host === null ? {
6303
+ DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
6304
+ DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE")
6305
+ } : {},
6306
+ DOCKER_USERNAME: actionsExpr(`secrets.${username}`),
6307
+ DOCKER_PASSWORD: actionsExpr(`secrets.${password}`),
6308
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
6309
+ };
6375
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";
6376
6313
  /**
6377
- * Inspect the unmanaged toolchain scripts for silent-no-op shapes. Returns a
6378
- * human-readable hint, or undefined when they look sound.
6314
+ * Retire a workflow file tooling used to generate and no longer writes.
6379
6315
  *
6380
- * This is a **warning, not a convergence**: sync must not rewrite these scripts (see
6381
- * {@link TOOLCHAIN_SCRIPTS}), so the only honest move is to tell the human what looks
6382
- * wrong and let them fix it.
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.
6383
6324
  */
6384
- function diagnoseToolchainScripts(scripts) {
6385
- if (!scripts) return void 0;
6386
- const pinned = TOOLCHAIN_SCRIPTS.filter((name) => {
6387
- const script = scripts[name];
6388
- return script !== void 0 && recursionIsPinnedToRoot(script);
6389
- });
6390
- if (pinned.length === 0) return void 0;
6391
- return `${pinned.map((name) => `\`${name}\``).join(", ")} ${pinned.length === 1 ? "combines" : "combine"} pnpm's \`-r\` (recursive) with \`-w\` (workspace-root). \`-w\` wins, so the command runs once at the workspace root and never descends into \`packages/*\` -- in a monorepo whose root holds no source, that is a no-op that still exits 0. Drop the \`-w\`.`;
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
+ };
6392
6341
  }
6342
+ //#endregion
6343
+ //#region src/generators/publish-ci.ts
6344
+ const RegistryHostSchema = z.object({ registryHost: z.string().optional() });
6393
6345
  /**
6394
- * Filesystem wrapper around {@link diagnoseCheckScriptForwarding}: reads
6395
- * `package.json` from `targetDir` (best-effort) and diagnoses its scripts.
6396
- */
6397
- function diagnoseCheckScriptForwardingAt(targetDir) {
6398
- let scripts;
6399
- try {
6400
- scripts = parsePackageJson(readFileSync(path.join(targetDir, "package.json"), "utf-8"))?.scripts;
6401
- } catch (_error) {
6402
- return;
6346
+ * Resolve the docker-registry host at gen time so the workflow generator can
6347
+ * pick registry-specific secret names. Honors `.tooling.json#registryHost`
6348
+ * first, then git remote `origin` (the operational host, not a mirror-pointed
6349
+ * package.json), then derives from `package.json#repository` using the same
6350
+ * parser as the runtime (`parseGitUrl` handles https, git+https, and ssh shapes).
6351
+ * Returns null if neither source yields a host -- in that case the generator
6352
+ * falls back to neutral `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` secrets and
6353
+ * lets the runtime resolve the host from `DOCKER_REGISTRY_HOST` (still emitted
6354
+ * into the step).
6355
+ */
6356
+ function resolveGenTimeHost(ctx) {
6357
+ const toolingRaw = ctx.read(".tooling.json");
6358
+ if (toolingRaw) try {
6359
+ const parsed = RegistryHostSchema.safeParse(JSON.parse(toolingRaw));
6360
+ if (parsed.success && parsed.data.registryHost && parsed.data.registryHost.length > 0) return parsed.data.registryHost;
6361
+ } catch (error) {
6362
+ log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6403
6363
  }
6404
- return diagnoseCheckScriptForwarding(scripts);
6364
+ const originUrl = detectGitRemoteUrl(ctx.targetDir);
6365
+ if (originUrl !== null) {
6366
+ const parsedOrigin = parseGitUrl(originUrl);
6367
+ if (parsedOrigin) return registryHostFromHostname(parsedOrigin.hostname);
6368
+ }
6369
+ const raw = ctx.read("package.json");
6370
+ if (!raw) return null;
6371
+ const repo = parsePackageJson(raw)?.repository;
6372
+ let urlStr = null;
6373
+ if (typeof repo === "string") urlStr = repo;
6374
+ else if (repo && typeof repo === "object") urlStr = repo.url;
6375
+ if (typeof urlStr !== "string") return null;
6376
+ const parsed = parseGitUrl(urlStr);
6377
+ if (!parsed) return null;
6378
+ return registryHostFromHostname(parsed.hostname);
6405
6379
  }
6406
- //#endregion
6407
- //#region src/generators/package-json.ts
6380
+ const PostReleaseHookSchema = z.object({
6381
+ postReleaseHook: z.string().optional(),
6382
+ postReleaseHookSecrets: z.array(z.string()).optional()
6383
+ });
6384
+ const ReleaseAssetsSchema = z.object({ releaseAssets: z.array(ReleaseAssetSchema).optional() });
6385
+ const VersionStampConfigSchema = z.object({ versionStamp: z.array(VersionStampSchema).optional() });
6386
+ const DeploySchema = z.object({ deploy: DeployConfigSchema.optional() });
6408
6387
  /**
6409
- * Single source of truth for the supported Node floor: `engines.node` (`>=<floor>`) is the
6410
- * honest minimum this package declares. `mise.toml`'s `tools.node` (`generators/mise.ts`) is the
6411
- * exact pin every consumer -- the agent's containers, generated CI, a developer's shell -- now
6412
- * installs and runs, seeded from this same floor on first generation.
6388
+ * Read the `deploy` block from .tooling.json, if configured.
6413
6389
  *
6414
- * `devEngines.runtime` used to carry a caret form of this floor for `actions/setup-node` and
6415
- * pnpm's runtime-download check to read; both are retired by the shared "Install mise" workflow
6416
- * step and `mise install` respectively, so the field is removed wherever this generator finds it
6417
- * (`applyMetadataDefaults`, `generateWorkspaceEngines`) rather than kept in sync.
6390
+ * The full file is already validated against ToolingConfigSchema at sync time,
6391
+ * so an invalid `deploy` block fails earlier -- undefined here means "not set".
6418
6392
  */
6419
- const NODE_ENGINES_FLOOR = "24.13.0";
6420
- const ENGINES_NODE = `>=${NODE_ENGINES_FLOOR}`;
6421
- function versionPart(s) {
6422
- return s && s !== "x" && s !== "*" ? Number(s) : 0;
6393
+ function getDeployConfig(ctx) {
6394
+ const raw = ctx.read(".tooling.json");
6395
+ if (!raw) return void 0;
6396
+ try {
6397
+ const result = DeploySchema.safeParse(JSON.parse(raw));
6398
+ if (!result.success) return void 0;
6399
+ return result.data.deploy;
6400
+ } catch (error) {
6401
+ log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6402
+ return;
6403
+ }
6423
6404
  }
6424
- function compareTuples(a, b) {
6425
- for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i];
6426
- return 0;
6405
+ /** Read the postReleaseHook command override from .tooling.json, if configured. */
6406
+ function getPostReleaseHookCommand(ctx) {
6407
+ const raw = ctx.read(".tooling.json");
6408
+ if (!raw) return void 0;
6409
+ try {
6410
+ const result = PostReleaseHookSchema.safeParse(JSON.parse(raw));
6411
+ if (!result.success) return void 0;
6412
+ return result.data.postReleaseHook;
6413
+ } catch (error) {
6414
+ log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6415
+ return;
6416
+ }
6417
+ }
6418
+ /** Read the postReleaseHookSecrets array from .tooling.json, if configured. */
6419
+ function getPostReleaseHookSecrets(ctx) {
6420
+ const raw = ctx.read(".tooling.json");
6421
+ if (!raw) return void 0;
6422
+ try {
6423
+ const result = PostReleaseHookSchema.safeParse(JSON.parse(raw));
6424
+ if (!result.success) return void 0;
6425
+ return result.data.postReleaseHookSecrets;
6426
+ } catch (error) {
6427
+ log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6428
+ return;
6429
+ }
6427
6430
  }
6428
6431
  /**
6429
- * Effective lower bound of a single conjunction (space-separated comparators like
6430
- * `>=24.14.0 <25.0.0`). Upper bounds (`<`, `<=`) don't raise the floor and are
6431
- * ignored; the floor is the **max** of the remaining lower-bound tokens (`>=`,
6432
- * `>`, `^`, `~`, `=`, bare, `<major>.x`). Missing/wildcard positions read as 0.
6433
- * `undefined` when no token carries a numeric lower bound.
6432
+ * Names of the Docker images `detectDockerPackages` discovers for this repo --
6433
+ * by package convention, compose service, or `.tooling.json#docker` override.
6434
+ * Routed through the same chokepoint every other Docker consumer uses, so a
6435
+ * compose-only image (no convention Dockerfile, no override) still gates the
6436
+ * scripts and workflow steps below instead of silently dropping them.
6434
6437
  */
6435
- function conjunctionFloor(conjunction) {
6436
- let floor;
6437
- for (const token of conjunction.trim().split(/\s+/)) {
6438
- if (!token || token.startsWith("<")) continue;
6439
- const match = /(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?/.exec(token);
6440
- if (!match) continue;
6441
- const candidate = [
6442
- Number(match[1]),
6443
- versionPart(match[2]),
6444
- versionPart(match[3])
6445
- ];
6446
- if (!floor || compareTuples(candidate, floor) > 0) floor = candidate;
6438
+ function getDockerPackageNames(ctx) {
6439
+ return detectContextDockerPackages(ctx).map((pkg) => pkg.imageName);
6440
+ }
6441
+ /** Check whether any Docker packages exist by convention or .tooling.json config. */
6442
+ function hasDockerPackages(ctx) {
6443
+ return getDockerPackageNames(ctx).length > 0;
6444
+ }
6445
+ /** Paths (relative to repo root) of packages whose package.json has a `build:release-assets` script. */
6446
+ function detectReleaseAssetsConventionDirs(ctx) {
6447
+ const dirs = [];
6448
+ if (ctx.config.structure === "monorepo") {
6449
+ const packages = getMonorepoPackages(ctx.targetDir);
6450
+ for (const pkg of packages) {
6451
+ const dirName = pkg.name.split("/").pop() ?? pkg.name;
6452
+ const pkgRaw = ctx.read(`packages/${dirName}/package.json`);
6453
+ if (!pkgRaw) continue;
6454
+ if (parsePackageJson(pkgRaw)?.scripts?.["build:release-assets"]) dirs.push(`packages/${dirName}`);
6455
+ }
6456
+ } else {
6457
+ const pkgRaw = ctx.read("package.json");
6458
+ if (pkgRaw) {
6459
+ if (parsePackageJson(pkgRaw)?.scripts?.["build:release-assets"]) dirs.push(".");
6460
+ }
6461
+ }
6462
+ return dirs;
6463
+ }
6464
+ /** Check whether release assets are configured by convention or in .tooling.json. */
6465
+ function hasReleaseAssets(ctx) {
6466
+ if (detectReleaseAssetsConventionDirs(ctx).length > 0) return true;
6467
+ const raw = ctx.read(".tooling.json");
6468
+ if (!raw) return false;
6469
+ try {
6470
+ const result = ReleaseAssetsSchema.safeParse(JSON.parse(raw));
6471
+ if (!result.success) return false;
6472
+ return !!result.data.releaseAssets && result.data.releaseAssets.length > 0;
6473
+ } catch (error) {
6474
+ log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6475
+ return false;
6447
6476
  }
6448
- return floor;
6449
6477
  }
6450
6478
  /**
6451
- * Lowest version a semver range permits, as a `[major, minor, patch]` tuple --
6452
- * what pnpm resolves a `devEngines.runtime.version` to when cross-checking
6453
- * `engines.node`. Handles floor-first forms (`>=24.14.0`, `^24.13.0`, `24.x`,
6454
- * `24`), compound ranges in any order (`>=24.14.0 <25.0.0`, `<25 >=24.14.0` ->
6455
- * `24.14.0`), and `||` alternation (the minimum across alternatives).
6456
- *
6457
- * Returns `undefined` when no floor can be determined (e.g. `*`, `latest`, an
6458
- * alternative with only an upper bound) -- callers must warn rather than guess.
6479
+ * Check whether the repo configures `versionStamp`. A repo with a `versionStamp` block but no
6480
+ * `releaseAssets` and no `build:release-assets` script would otherwise get no assets step, and
6481
+ * nothing would ever run the stamp -- see the two `hasReleaseAssets(ctx) || hasVersionStamp(ctx)`
6482
+ * call sites in `ci.ts` and `release-ci.ts`. Deliberately not folded into `hasReleaseAssets`
6483
+ * itself: `gitignore.ts` shares that predicate to decide whether to ignore `release-assets/`, and
6484
+ * a stamp-only repo produces no such directory.
6459
6485
  */
6460
- function minVersionTuple(range) {
6461
- let min;
6462
- for (const alternative of range.split("||")) {
6463
- const floor = conjunctionFloor(alternative);
6464
- if (!floor) return void 0;
6465
- if (!min || compareTuples(floor, min) < 0) min = floor;
6486
+ function hasVersionStamp(ctx) {
6487
+ const raw = ctx.read(".tooling.json");
6488
+ if (!raw) return false;
6489
+ try {
6490
+ const result = VersionStampConfigSchema.safeParse(JSON.parse(raw));
6491
+ if (!result.success) return false;
6492
+ return !!result.data.versionStamp && result.data.versionStamp.length > 0;
6493
+ } catch (error) {
6494
+ log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6495
+ return false;
6466
6496
  }
6467
- return min;
6468
6497
  }
6469
6498
  /**
6470
- * Remove `devEngines.runtime` from a package.json object in place, dropping `devEngines`
6471
- * entirely once it is left empty. Returns `true` when something was removed, so callers can push
6472
- * their own change description (the wording differs between the root generator and the
6473
- * nested-workspace pass).
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.
6474
6504
  */
6475
- function removeDevEnginesRuntime(pkg) {
6476
- if (!pkg["devEngines"]?.runtime) return false;
6477
- delete pkg["devEngines"].runtime;
6478
- if (Object.keys(pkg["devEngines"]).length === 0) delete pkg["devEngines"];
6479
- return true;
6505
+ async function generateDeployCi(ctx) {
6506
+ const filePath = "deploy-ci";
6507
+ if (ctx.config.ci === "none") return {
6508
+ filePath,
6509
+ action: "skipped",
6510
+ description: "Publish workflow not applicable"
6511
+ };
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"
6516
+ };
6480
6517
  }
6481
- /** Scripts every repo gets, whatever its language. */
6482
- const HARNESS_SCRIPTS = {
6483
- check: "bst checks:run",
6484
- "ci:check": "pnpm check --skip 'docker:*'",
6485
- "tooling:check": "bst repo:sync --check",
6486
- "docs:check": "bst docs:check",
6487
- "tooling:sync": "bst repo:sync"
6488
- };
6489
- /** Scripts that need a JS/TS toolchain. */
6490
- const JS_SCRIPTS_SINGLE = {
6491
- test: "vitest run",
6492
- "test:coverage": "vitest run --coverage",
6493
- lint: "oxlint",
6494
- "lint:report": "bst lint:report",
6495
- knip: "knip"
6496
- };
6497
- const JS_SCRIPTS_MONOREPO = {
6498
- test: "pnpm -r test",
6499
- "test:coverage": "vitest run --coverage",
6500
- lint: "oxlint",
6501
- "lint:report": "bst lint:report",
6502
- knip: "knip"
6503
- };
6504
- /** Scripts that only make sense with a TypeScript compiler. */
6505
- const TS_SCRIPTS_SINGLE = {
6506
- typecheck: "tsc --noEmit",
6507
- "lint:types": "bst lint:types"
6508
- };
6509
- const TS_SCRIPTS_MONOREPO = {
6510
- typecheck: "pnpm -r --parallel run typecheck",
6511
- "lint:types": "bst lint:types"
6512
- };
6513
- /** Scripts owned by the builder. Only tsdown is ours to write. */
6514
- const TSDOWN_SCRIPTS_SINGLE = {
6515
- build: "tsdown",
6516
- dev: "tsdown --watch"
6517
- };
6518
- const TSDOWN_SCRIPTS_MONOREPO = { build: "pnpm -r build" };
6519
- /** Compose the standard `scripts` map from stack/builder/structure. Content-identical
6520
- * (same keys, same values) to the old flat tables for a typescript + tsdown project
6521
- * (single or monorepo) -- key insertion order differs since harness scripts now compose
6522
- * first rather than last, but that's immaterial: `mergeManagedScripts` mutates existing
6523
- * keys in place and only appends keys that were previously absent. */
6518
+ //#endregion
6519
+ //#region src/codebase-health/config.ts
6520
+ const DEFAULT_HEALTH_HISTORY_BRANCH = "bst/health-metrics";
6521
+ const TS_EXTENSIONS = [".ts", ".tsx"];
6522
+ const JS_EXTENSIONS = [
6523
+ ".js",
6524
+ ".jsx",
6525
+ ".mjs",
6526
+ ".cjs"
6527
+ ];
6528
+ const DEFAULT_DIRECTORY_FILE_LIMIT = 20;
6529
+ const DEFAULT_FAN_LIMIT = 10;
6530
+ const DEFAULT_MODULE_SIZE_LIMIT = 200;
6531
+ /** The extensions dependency-cruiser should glob for a given stack. */
6532
+ function defaultExtensions(stack) {
6533
+ if (stack === "typescript") return TS_EXTENSIONS;
6534
+ if (stack === "javascript") return JS_EXTENSIONS;
6535
+ return [];
6536
+ }
6537
+ /** Tolerant of a key `health` does not declare, shared by both entry points below:
6538
+ * `resolveHealthConfig` (the CLI) and `resolveHealthConfigFromContext` (the generator). The
6539
+ * generator's own caller runs behind `repo:sync`'s strict root read, which has already
6540
+ * rejected the document, so leniency here is unreachable there rather than a semantic change
6541
+ * for that path. */
6542
+ const ToolingShallowSchema$2 = z.object({ health: tolerantBlock(HealthConfigSchema).optional() });
6543
+ function defaultSrc(isMonorepo) {
6544
+ return isMonorepo ? ["packages/*/src"] : ["src"];
6545
+ }
6546
+ function resolveFromOverrides(overrides, isMonorepo, stack) {
6547
+ const o = overrides ?? {};
6548
+ const enabled = o.enabled === true;
6549
+ return {
6550
+ enabled,
6551
+ src: o.src ?? defaultSrc(isMonorepo),
6552
+ exclude: o.exclude ?? [],
6553
+ extensions: o.extensions ?? defaultExtensions(stack),
6554
+ directoryFileLimit: o.directoryFileLimit ?? DEFAULT_DIRECTORY_FILE_LIMIT,
6555
+ fanInLimit: o.fanInLimit ?? DEFAULT_FAN_LIMIT,
6556
+ fanOutLimit: o.fanOutLimit ?? DEFAULT_FAN_LIMIT,
6557
+ moduleSizeLimit: o.moduleSizeLimit ?? DEFAULT_MODULE_SIZE_LIMIT,
6558
+ crossPackage: o.crossPackage ?? true,
6559
+ historyEnabled: enabled && o.history !== "none",
6560
+ historyBranch: o.historyBranch ?? DEFAULT_HEALTH_HISTORY_BRANCH,
6561
+ tolerance: o.tolerance ?? 0,
6562
+ graphMetricsEnabled: stack !== "none",
6563
+ exempt: o.exempt ?? {}
6564
+ };
6565
+ }
6566
+ function resolveHealthConfigFromContext(reader, opts) {
6567
+ return resolveFromOverrides(parseToolingConfigSlice(reader.read(".tooling.json"), ToolingShallowSchema$2, { block: "health" })?.health, opts.isMonorepo, reader.config.stack);
6568
+ }
6569
+ /**
6570
+ * The two facts about a tree that `resolveHealthConfig` detects but does not return: the stack and
6571
+ * whether it is a monorepo. Exported for `health:check`'s reference read, which must resolve the
6572
+ * default branch's `health` block with the *same* context the working tree's block was resolved
6573
+ * with -- and which therefore has to come out of this module rather than a fresh import, because
6574
+ * the command that needs it sits at its fan-out ceiling.
6575
+ */
6576
+ function detectHealthContext(targetDir) {
6577
+ return {
6578
+ stack: resolveStack(targetDir),
6579
+ isMonorepo: existsSync(path.join(targetDir, "packages"))
6580
+ };
6581
+ }
6582
+ /** Resolve from the real filesystem (used by CLI commands). */
6583
+ function resolveHealthConfig(targetDir) {
6584
+ const { stack, isMonorepo } = detectHealthContext(targetDir);
6585
+ return resolveHealthConfigFromContext({
6586
+ exists: (rel) => existsSync(path.join(targetDir, rel)),
6587
+ read: (rel) => {
6588
+ const full = path.join(targetDir, rel);
6589
+ if (!existsSync(full)) return void 0;
6590
+ try {
6591
+ return readFileSync(full, "utf-8");
6592
+ } catch (_error) {
6593
+ return;
6594
+ }
6595
+ },
6596
+ config: { stack }
6597
+ }, { isMonorepo });
6598
+ }
6599
+ //#endregion
6600
+ //#region src/utils/script-forwarding.ts
6601
+ /**
6602
+ * pnpm appends args forwarded via `pnpm <script> <args>` to the END of the
6603
+ * resolved script string. A forwarded flag therefore only reaches its intended
6604
+ * command when that command is the LAST command in the script. The generated
6605
+ * CI workflow depends on this two-hop forwarding:
6606
+ *
6607
+ * pnpm ci:check --with-coverage
6608
+ * -> pnpm check --skip 'docker:*' --with-coverage
6609
+ * -> bst checks:run --skip 'docker:*' --with-coverage
6610
+ *
6611
+ * If `check` or `ci:check` ends in a different command (e.g. an `&&` chain),
6612
+ * the forwarded flags silently land on the wrong command -- tests run without
6613
+ * coverage and `--skip` becomes a no-op. This module detects that shape so
6614
+ * callers can surface a precise diagnosis instead of a confusing downstream
6615
+ * failure.
6616
+ */
6617
+ /** A script's commands, split on `&&`, `||`, `|`, and `;`, each trimmed. */
6618
+ function commandSegments(script) {
6619
+ return script.split(/&&|\|\|?|;/).map((segment) => segment.trim());
6620
+ }
6621
+ /** The final command of a script, after splitting on `&&`, `||`, `|`, and `;`. */
6622
+ function lastCommandSegment(script) {
6623
+ return commandSegments(script).at(-1) ?? "";
6624
+ }
6625
+ /**
6626
+ * True when args forwarded to a `check` script would reach `bst checks:run` -- its last command
6627
+ * must invoke `bst checks:run`. Trailing flags like `--add` are fine; only the position of the
6628
+ * invocation matters.
6629
+ *
6630
+ * The leading `[\s/]` alternative is what lets a wrapped invocation (`pnpm exec bst checks:run`,
6631
+ * or an absolute path ending in `/bst`) still count. It also used to admit a `bin.mjs checks:run`
6632
+ * spelling for the tooling repo's own `check` script, which invoked
6633
+ * `node packages/tooling-cli/dist/bin.mjs` directly; that repo now depends on itself as
6634
+ * `workspace:*` and runs `pnpm exec bst checks:run`, so the exception has no caller left.
6635
+ */
6636
+ function isCheckScriptForwardingSafe(script) {
6637
+ return /(?:^|[\s/])bst\s+checks:run(?=\s|$)/.test(lastCommandSegment(script));
6638
+ }
6639
+ /**
6640
+ * True when args forwarded to a `ci:check` script would reach `pnpm check`
6641
+ * -- its last command must invoke `pnpm check` (or `pnpm run check`).
6642
+ */
6643
+ function isCiCheckScriptForwardingSafe(script) {
6644
+ return /(?:^|\s)pnpm\s+(?:run\s+)?check(?=\s|$)/.test(lastCommandSegment(script));
6645
+ }
6646
+ /**
6647
+ * Inspect a package.json `scripts` record for `check` / `ci:check` definitions
6648
+ * whose shape would swallow pnpm-forwarded args. Returns a human-readable hint
6649
+ * naming the problem and the fix, or undefined when forwarding is sound.
6650
+ */
6651
+ function diagnoseCheckScriptForwarding(scripts) {
6652
+ if (!scripts) return void 0;
6653
+ const problems = [];
6654
+ const check = scripts["check"];
6655
+ if (check !== void 0 && !isCheckScriptForwardingSafe(check)) problems.push(`the \`check\` script does not end with \`bst checks:run\` (its last command is \`${lastCommandSegment(check)}\`)`);
6656
+ const ciCheck = scripts["ci:check"];
6657
+ if (ciCheck !== void 0 && !isCiCheckScriptForwardingSafe(ciCheck)) problems.push(`the \`ci:check\` script does not end with \`pnpm check\` (its last command is \`${lastCommandSegment(ciCheck)}\`)`);
6658
+ if (problems.length === 0) return void 0;
6659
+ return `Likely cause: ${problems.join(", and ")}. pnpm appends forwarded args (e.g. \`--with-coverage\`, \`--skip 'docker:*'\`) to the end of the resolved script, so they never reach \`bst checks:run\`. Make \`bst checks:run\` the last command -- move extra checks into \`bst checks:run --add <name,...>\`.`;
6660
+ }
6661
+ /**
6662
+ * The toolchain scripts `MANAGED_SCRIPTS` (package-json.ts) deliberately does NOT
6663
+ * force-converge. They are add-if-absent: once a value exists, sync never looks at
6664
+ * it again -- because force-converging `build` would stomp a Vite project's
6665
+ * `vite build`, which is the entire point of the `builder` axis. The cost of that
6666
+ * freedom is that these scripts can drift into nonsense and nothing notices, so we
6667
+ * diagnose the one drift shape that fails *silently* rather than loudly.
6668
+ */
6669
+ const TOOLCHAIN_SCRIPTS = [
6670
+ "build",
6671
+ "typecheck",
6672
+ "lint",
6673
+ "test",
6674
+ "knip",
6675
+ "format"
6676
+ ];
6677
+ /**
6678
+ * True when a pnpm invocation asks for recursion (`-r`) and then pins itself to the
6679
+ * workspace root (`-w`) -- `pnpm -r -w --parallel exec tsc --noEmit`.
6680
+ *
6681
+ * `-w` wins: the command runs exactly once, in the workspace root, and never descends
6682
+ * into `packages/*`. A monorepo root holds no source, so `tsc --noEmit` there typechecks
6683
+ * nothing and **exits 0**. That is the failure mode this exists for -- the script is not
6684
+ * broken, it is a no-op that reports success, so it survives in CI indefinitely. This
6685
+ * repo's own `typecheck` sat in exactly that state until it was found by accident.
6686
+ *
6687
+ * Scanned per `&&`/`||`/`|`/`;` segment so a chained script can't hide the conflict, and
6688
+ * only up to the first non-flag token so a *script's own* `-w` (e.g. `vitest --watch`,
6689
+ * `tsdown -w`) is never mistaken for pnpm's.
6690
+ */
6691
+ function recursionIsPinnedToRoot(script) {
6692
+ return script.split(/&&|\|\|?|;/).some((segment) => {
6693
+ const tokens = segment.trim().split(/\s+/);
6694
+ if (tokens[0] !== "pnpm") return false;
6695
+ let recursive = false;
6696
+ let workspaceRoot = false;
6697
+ for (const token of tokens.slice(1)) {
6698
+ if (!token.startsWith("-")) break;
6699
+ if (token === "--recursive") recursive = true;
6700
+ else if (token === "--workspace-root") workspaceRoot = true;
6701
+ else if (/^-[a-zA-Z]+$/.test(token)) {
6702
+ if (token.includes("r")) recursive = true;
6703
+ if (token.includes("w")) workspaceRoot = true;
6704
+ }
6705
+ }
6706
+ return recursive && workspaceRoot;
6707
+ });
6708
+ }
6709
+ /**
6710
+ * Inspect the unmanaged toolchain scripts for silent-no-op shapes. Returns a
6711
+ * human-readable hint, or undefined when they look sound.
6712
+ *
6713
+ * This is a **warning, not a convergence**: sync must not rewrite these scripts (see
6714
+ * {@link TOOLCHAIN_SCRIPTS}), so the only honest move is to tell the human what looks
6715
+ * wrong and let them fix it.
6716
+ */
6717
+ function diagnoseToolchainScripts(scripts) {
6718
+ if (!scripts) return void 0;
6719
+ const pinned = TOOLCHAIN_SCRIPTS.filter((name) => {
6720
+ const script = scripts[name];
6721
+ return script !== void 0 && recursionIsPinnedToRoot(script);
6722
+ });
6723
+ if (pinned.length === 0) return void 0;
6724
+ return `${pinned.map((name) => `\`${name}\``).join(", ")} ${pinned.length === 1 ? "combines" : "combine"} pnpm's \`-r\` (recursive) with \`-w\` (workspace-root). \`-w\` wins, so the command runs once at the workspace root and never descends into \`packages/*\` -- in a monorepo whose root holds no source, that is a no-op that still exits 0. Drop the \`-w\`.`;
6725
+ }
6726
+ /**
6727
+ * Filesystem wrapper around {@link diagnoseCheckScriptForwarding}: reads
6728
+ * `package.json` from `targetDir` (best-effort) and diagnoses its scripts.
6729
+ */
6730
+ function diagnoseCheckScriptForwardingAt(targetDir) {
6731
+ let scripts;
6732
+ try {
6733
+ scripts = parsePackageJson(readFileSync(path.join(targetDir, "package.json"), "utf-8"))?.scripts;
6734
+ } catch (_error) {
6735
+ return;
6736
+ }
6737
+ return diagnoseCheckScriptForwarding(scripts);
6738
+ }
6739
+ //#endregion
6740
+ //#region src/generators/package-json.ts
6741
+ /**
6742
+ * Single source of truth for the supported Node floor: `engines.node` (`>=<floor>`) is the
6743
+ * honest minimum this package declares. `mise.toml`'s `tools.node` (`generators/mise.ts`) is the
6744
+ * exact pin every consumer -- the agent's containers, generated CI, a developer's shell -- now
6745
+ * installs and runs, seeded on first generation from the highest of this floor, `engines.node`,
6746
+ * and the `devEngines.runtime` range removed below.
6747
+ *
6748
+ * `devEngines.runtime` used to carry a caret form of this floor for `actions/setup-node` and
6749
+ * pnpm's runtime-download check to read; both are retired by the shared "Install mise" workflow
6750
+ * step and `mise install` respectively, so the field is removed wherever this generator finds it
6751
+ * (`applyMetadataDefaults`, `generateWorkspaceEngines`) rather than kept in sync.
6752
+ */
6753
+ const NODE_ENGINES_FLOOR = "24.13.0";
6754
+ const ENGINES_NODE = `>=${NODE_ENGINES_FLOOR}`;
6755
+ function versionPart(s) {
6756
+ return s && s !== "x" && s !== "*" ? Number(s) : 0;
6757
+ }
6758
+ function compareTuples(a, b) {
6759
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i];
6760
+ return 0;
6761
+ }
6762
+ /**
6763
+ * Effective lower bound of a single conjunction (space-separated comparators like
6764
+ * `>=24.14.0 <25.0.0`). Upper bounds (`<`, `<=`) don't raise the floor and are
6765
+ * ignored; the floor is the **max** of the remaining lower-bound tokens (`>=`,
6766
+ * `>`, `^`, `~`, `=`, bare, `<major>.x`). Missing/wildcard positions read as 0.
6767
+ * `undefined` when no token carries a numeric lower bound.
6768
+ */
6769
+ function conjunctionFloor(conjunction) {
6770
+ let floor;
6771
+ for (const token of conjunction.trim().split(/\s+/)) {
6772
+ if (!token || token.startsWith("<")) continue;
6773
+ const match = /(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?/.exec(token);
6774
+ if (!match) continue;
6775
+ const candidate = [
6776
+ Number(match[1]),
6777
+ versionPart(match[2]),
6778
+ versionPart(match[3])
6779
+ ];
6780
+ if (!floor || compareTuples(candidate, floor) > 0) floor = candidate;
6781
+ }
6782
+ return floor;
6783
+ }
6784
+ /**
6785
+ * Lowest version a semver range permits, as a `[major, minor, patch]` tuple --
6786
+ * what pnpm resolves a `devEngines.runtime.version` to when cross-checking
6787
+ * `engines.node`. Handles floor-first forms (`>=24.14.0`, `^24.13.0`, `24.x`,
6788
+ * `24`), compound ranges in any order (`>=24.14.0 <25.0.0`, `<25 >=24.14.0` ->
6789
+ * `24.14.0`), and `||` alternation (the minimum across alternatives).
6790
+ *
6791
+ * Returns `undefined` when no floor can be determined (e.g. `*`, `latest`, an
6792
+ * alternative with only an upper bound) -- callers must warn rather than guess.
6793
+ */
6794
+ function minVersionTuple(range) {
6795
+ let min;
6796
+ for (const alternative of range.split("||")) {
6797
+ const floor = conjunctionFloor(alternative);
6798
+ if (!floor) return void 0;
6799
+ if (!min || compareTuples(floor, min) < 0) min = floor;
6800
+ }
6801
+ return min;
6802
+ }
6803
+ /**
6804
+ * Remove `devEngines.runtime` from a package.json object in place, dropping `devEngines`
6805
+ * entirely once it is left empty. Returns `true` when something was removed, so callers can push
6806
+ * their own change description (the wording differs between the root generator and the
6807
+ * nested-workspace pass).
6808
+ */
6809
+ function removeDevEnginesRuntime(pkg) {
6810
+ if (!pkg["devEngines"]?.runtime) return false;
6811
+ delete pkg["devEngines"].runtime;
6812
+ if (Object.keys(pkg["devEngines"]).length === 0) delete pkg["devEngines"];
6813
+ return true;
6814
+ }
6815
+ /** Scripts every repo gets, whatever its language. */
6816
+ const HARNESS_SCRIPTS = {
6817
+ check: "bst checks:run",
6818
+ "ci:check": "pnpm check --skip 'docker:*'",
6819
+ "tooling:check": "bst repo:sync --check",
6820
+ "docs:check": "bst docs:check",
6821
+ "tooling:sync": "bst repo:sync"
6822
+ };
6823
+ /** Scripts that need a JS/TS toolchain. */
6824
+ const JS_SCRIPTS_SINGLE = {
6825
+ test: "vitest run",
6826
+ "test:coverage": "vitest run --coverage",
6827
+ lint: "oxlint",
6828
+ "lint:report": "bst lint:report",
6829
+ knip: "knip"
6830
+ };
6831
+ const JS_SCRIPTS_MONOREPO = {
6832
+ test: "pnpm -r test",
6833
+ "test:coverage": "vitest run --coverage",
6834
+ lint: "oxlint",
6835
+ "lint:report": "bst lint:report",
6836
+ knip: "knip"
6837
+ };
6838
+ /** Scripts that only make sense with a TypeScript compiler. */
6839
+ const TS_SCRIPTS_SINGLE = {
6840
+ typecheck: "tsc --noEmit",
6841
+ "lint:types": "bst lint:types"
6842
+ };
6843
+ const TS_SCRIPTS_MONOREPO = {
6844
+ typecheck: "pnpm -r --parallel run typecheck",
6845
+ "lint:types": "bst lint:types"
6846
+ };
6847
+ /** Scripts owned by the builder. Only tsdown is ours to write. */
6848
+ const TSDOWN_SCRIPTS_SINGLE = {
6849
+ build: "tsdown",
6850
+ dev: "tsdown --watch"
6851
+ };
6852
+ const TSDOWN_SCRIPTS_MONOREPO = { build: "pnpm -r build" };
6853
+ /** Compose the standard `scripts` map from stack/builder/structure. Content-identical
6854
+ * (same keys, same values) to the old flat tables for a typescript + tsdown project
6855
+ * (single or monorepo) -- key insertion order differs since harness scripts now compose
6856
+ * first rather than last, but that's immaterial: `mergeManagedScripts` mutates existing
6857
+ * keys in place and only appends keys that were previously absent. */
6524
6858
  function standardScripts(config) {
6525
6859
  const mono = config.structure === "monorepo";
6526
6860
  const scripts = { ...HARNESS_SCRIPTS };
@@ -7119,7 +7453,7 @@ function buildManagedDevDeps(ctx) {
7119
7453
  const devDeps = { ...rootDevDeps(ctx.config) };
7120
7454
  if (!isMonorepo) Object.assign(devDeps, perPackageDevDeps(ctx.config));
7121
7455
  if (ctx.config.stack !== "none" || ctx.config.ci === "forgejo") devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.14.5";
7122
- if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.87.0";
7456
+ if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.87.1";
7123
7457
  if (ctx.config.useEslintPlugin && ctx.config.stack !== "none") devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.16.0";
7124
7458
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
7125
7459
  "@changesets/cli": "3.0.3",
@@ -8351,1411 +8685,1110 @@ async function generateOxfmt(ctx) {
8351
8685
  let jsonValue;
8352
8686
  try {
8353
8687
  jsonValue = JSON.parse(existing);
8354
- } catch {
8355
- const config = { ignorePatterns: required };
8356
- ctx.write(filePath, JSON.stringify(config, null, 2) + "\n");
8357
- return {
8358
- filePath,
8359
- action: "updated",
8360
- description: "Replaced malformed .oxfmtrc.json"
8361
- };
8362
- }
8363
- const parseResult = OxfmtConfigSchema.safeParse(jsonValue);
8364
- if (!parseResult.success) {
8365
- const config = { ignorePatterns: required };
8366
- ctx.write(filePath, JSON.stringify(config, null, 2) + "\n");
8367
- return {
8368
- filePath,
8369
- action: "updated",
8370
- description: "Replaced malformed .oxfmtrc.json"
8371
- };
8372
- }
8373
- const parsed = parseResult.data;
8374
- const ignorePatterns = Array.isArray(parsed["ignorePatterns"]) ? parsed["ignorePatterns"] : [];
8375
- const strings = ignorePatterns.filter((p) => typeof p === "string");
8376
- const existingSet = new Set(strings);
8377
- const missing = required.filter((p) => !existingSet.has(p));
8378
- const kept = strings.filter((p) => !SUPERSEDED_IGNORE_PATTERNS.has(p));
8379
- const removed = strings.filter((p) => SUPERSEDED_IGNORE_PATTERNS.has(p));
8380
- const nonStringPatterns = ignorePatterns.filter((p) => typeof p !== "string");
8381
- if (missing.length === 0 && removed.length === 0) return {
8382
- filePath,
8383
- action: "skipped",
8384
- description: "Existing oxfmt config already has required ignorePatterns"
8385
- };
8386
- parsed["ignorePatterns"] = [
8387
- ...kept,
8388
- ...nonStringPatterns,
8389
- ...missing
8390
- ];
8391
- ctx.write(filePath, JSON.stringify(parsed, null, 2) + "\n");
8392
- const changeParts = [];
8393
- if (missing.length > 0) changeParts.push(`Added ignorePatterns: ${missing.join(", ")}`);
8394
- if (removed.length > 0) changeParts.push(`removed: ${removed.join(", ")}`);
8395
- return {
8396
- filePath,
8397
- action: "updated",
8398
- description: changeParts.join("; ")
8399
- };
8400
- }
8401
- async function generatePrettier(ctx) {
8402
- const filePath = ".prettierrc";
8403
- if (ctx.exists(filePath)) return {
8404
- filePath,
8405
- action: "skipped",
8406
- description: "Existing prettier config preserved"
8407
- };
8408
- ctx.write(filePath, PRETTIER_CONFIG);
8409
- return {
8410
- filePath,
8411
- action: "created",
8412
- description: "Generated .prettierrc"
8413
- };
8414
- }
8415
- //#endregion
8416
- //#region src/generators/tsdown.ts
8417
- /**
8418
- * Exported so `package-json.ts#reclaimManagedDevDeps` can gate the `tsdown`
8419
- * devDependency reclaim on the same generated-template evidence this generator
8420
- * uses for the config file itself -- the two must never disagree about what
8421
- * "no longer used" means (see the block comment there).
8422
- */
8423
- const TSDOWN_CONFIG = `import { defineConfig } from "tsdown";
8424
-
8425
- export default defineConfig({
8426
- entry: ["src/index.ts"],
8427
- });
8428
- `;
8429
- async function generateTsdown(ctx) {
8430
- const filePath = "tsdown.config.ts";
8431
- if (ctx.config.structure === "monorepo") return {
8432
- filePath,
8433
- action: "skipped",
8434
- description: "Monorepo: tsdown config belongs in individual packages"
8435
- };
8436
- if (ctx.config.builder !== "tsdown") {
8437
- if (ctx.read(filePath) !== void 0 && isGeneratedTemplate(ctx, filePath, TSDOWN_CONFIG)) return reportStaleConfig(filePath, `builder is ${ctx.config.builder}`);
8438
- return {
8439
- filePath,
8440
- action: "skipped",
8441
- description: `Builder is ${ctx.config.builder}; tsdown config not managed`
8442
- };
8443
- }
8444
- const existing = ctx.read(filePath);
8445
- const config = formattedTemplate(ctx, filePath, TSDOWN_CONFIG);
8446
- if (existing) {
8447
- if (existing === config) return {
8448
- filePath,
8449
- action: "skipped",
8450
- description: "Already configured"
8451
- };
8452
- if (await ctx.confirmOverwrite(filePath) === "skip") return {
8453
- filePath,
8454
- action: "skipped",
8455
- description: "Existing tsdown config preserved"
8456
- };
8457
- }
8458
- ctx.write(filePath, config);
8459
- return {
8460
- filePath,
8461
- action: existing ? "updated" : "created",
8462
- description: "Generated tsdown.config.ts"
8463
- };
8464
- }
8465
- //#endregion
8466
- //#region src/generators/mise.ts
8467
- /**
8468
- * `mise.toml` owns exactly two keys, `tools.node` and `tools.pnpm`, and carries every other byte
8469
- * -- other tools, `[env]`, `[tasks.*]`, comments -- through untouched. `smol-toml`'s `parse` is
8470
- * used only to validate the file and to inspect the owned keys' shapes; it preserves no layout, so
8471
- * every mutation below is a textual edit against the raw source rather than a reserialization.
8472
- * `generators/package-json.ts` documents the same "manage the keys, not the file" posture for
8473
- * `devEngines.runtime`.
8474
- */
8475
- const TOOLS_HEADER_RE = /^\[tools\]\s*$/m;
8476
- /** Any top-level table header (`[x]` or `[x.y]`) or array-of-tables header (`[[x]]`), used to find
8477
- * where `[tools]`'s own range ends -- an array-of-tables entry is a fresh table just as much as a
8478
- * `[x]` header is, so it must terminate the body range too, or an owned line appended past it
8479
- * lands inside that entry instead of `[tools]`. */
8480
- const TOP_LEVEL_HEADER_RE = /^\[{1,2}[^[].*\]{1,2}\s*$/m;
8481
- const PNPM_LINE_RE = /^pnpm\s*=.*$/m;
8482
- /** Tolerant shape of the file's `[tools]` table: string or table form for every key, so a
8483
- * table-form `node`/`pnpm` is caught rather than mistaken for absent. */
8484
- const ToolsTableSchema = z.record(z.string(), z.union([z.string(), z.record(z.string(), z.unknown())]));
8485
- /** Tolerant shape of the whole parsed document -- only `tools` is read. */
8486
- const ParsedTomlSchema = z.looseObject({ tools: z.unknown().optional() });
8487
- /** The `[tools]` table's body range: right after its header line to the next top-level header or
8488
- * EOF. `undefined` when there is no `[tools]` table at all. */
8489
- function toolsRange(source) {
8490
- const headerMatch = TOOLS_HEADER_RE.exec(source);
8491
- if (!headerMatch) return void 0;
8492
- const start = headerMatch.index + headerMatch[0].length;
8493
- const rest = source.slice(start);
8494
- const nextHeader = TOP_LEVEL_HEADER_RE.exec(rest);
8495
- return {
8496
- start,
8497
- end: nextHeader ? start + nextHeader.index : source.length
8498
- };
8499
- }
8500
- /** Strip the version out of `packageManager` (`pnpm@11.27.0` -> `11.27.0`). */
8501
- function pnpmVersionFromPackageManager(packageManager) {
8502
- return /^pnpm@(\S+)$/.exec(packageManager ?? "")?.[1];
8503
- }
8504
- /** The exact pin to seed `tools.node` with: the higher of `enginesNode`'s own floor (e.g. a repo
8505
- * whose `package.json` already declares `>=24.14.0`) and {@link NODE_ENGINES_FLOOR}, so a loose
8506
- * `engines.node` (`>=24`, `24.x`) can never seed a pin below the supported floor -- the same
8507
- * derive-from-engines discipline the retired `devEngines.runtime` generator used. */
8508
- function nodePinFor(enginesNode) {
8509
- const tuple = enginesNode ? minVersionTuple(enginesNode) : void 0;
8510
- const floorTuple = minVersionTuple(NODE_ENGINES_FLOOR);
8511
- if (!tuple || !floorTuple) return NODE_ENGINES_FLOOR;
8512
- return compareTuples(tuple, floorTuple) > 0 ? tuple.join(".") : NODE_ENGINES_FLOOR;
8513
- }
8514
- /** The two owned lines, in order, for a file created from scratch (or a `[tools]` table added to
8515
- * one that had none). */
8516
- function ownedLines(pnpmVersion, enginesNode) {
8517
- const lines = [`[tools]`, `node = "${nodePinFor(enginesNode)}"`];
8518
- if (pnpmVersion) lines.push(`pnpm = "${pnpmVersion}"`);
8519
- return lines;
8520
- }
8521
- /** Append a `key = "value"` line to a `[tools]` body, respecting whatever trailing newline it
8522
- * already has. */
8523
- function appendLine(body, line) {
8524
- return (body.endsWith("\n") || body.length === 0 ? body : body + "\n") + line + "\n";
8525
- }
8526
- /** `action: "failed"` naming an owned key present in table form. */
8527
- function tableFormFailure(filePath, key) {
8528
- return {
8529
- filePath,
8530
- action: "failed",
8531
- description: `mise.toml: tools.${key} is a table; repo:sync owns it as a plain string pin`
8532
- };
8533
- }
8534
- /** `true` when `pinned` provably resolves below `enginesNode`'s floor. Only two pin shapes are
8535
- * compared: an exact `X.Y.Z` against the whole floor, and a bare major (`"24"`, what `mise use
8536
- * node@24` writes) against the floor's major alone. Anything else -- `"24.12"`, `"lts"`, a prefix
8537
- * mise resolves -- is left for mise to resolve, because the toolchain gate in
8538
- * `commands/sync-gates.ts` treats such pins as legitimate and checks the version mise resolves
8539
- * them to. Reading `"24"` as `24.0.0` would refuse a pin that resolves to the newest 24.x. An
8540
- * unparseable `enginesNode` compares nothing. */
8541
- function isBelowFloor(pinned, enginesNode) {
8542
- const floor = minVersionTuple(enginesNode);
8543
- if (floor === void 0) return false;
8544
- const exact = /^(\d+)\.(\d+)\.(\d+)$/.exec(pinned);
8545
- if (exact) return compareTuples([
8546
- Number(exact[1]),
8547
- Number(exact[2]),
8548
- Number(exact[3])
8549
- ], floor) < 0;
8550
- const major = /^(\d+)$/.exec(pinned);
8551
- if (major) return Number(major[1]) < floor[0];
8552
- return false;
8553
- }
8554
- /** Resolve the two facts `mise.toml` needs from `package.json`: the engines floor to validate an
8555
- * existing `tools.node` pin against, and the pnpm version to keep `tools.pnpm` in step with. */
8556
- function readPackageJsonFacts(ctx) {
8557
- const pkgRaw = ctx.read("package.json");
8558
- const pkg = pkgRaw ? parsePackageJson(pkgRaw) : void 0;
8559
- return {
8560
- enginesNode: pkg?.engines?.["node"],
8561
- pnpmVersion: pnpmVersionFromPackageManager(pkg?.packageManager)
8562
- };
8563
- }
8564
- /** Edit an existing `[tools]` body in place: add `node` only when absent, rewrite `pnpm` only on
8565
- * drift. Returns the new body and the change descriptions, or `undefined` for the body when
8566
- * nothing changed. */
8567
- function editToolsBody(body, existingNode, existingPnpm, pnpmVersion, enginesNode) {
8568
- let newBody = body;
8569
- const changes = [];
8570
- if (existingNode === void 0) {
8571
- const pin = nodePinFor(enginesNode);
8572
- newBody = appendLine(newBody, `node = "${pin}"`);
8573
- changes.push(`added tools.node = "${pin}"`);
8574
- }
8575
- if (pnpmVersion && existingPnpm !== pnpmVersion) {
8576
- if (existingPnpm === void 0) {
8577
- newBody = appendLine(newBody, `pnpm = "${pnpmVersion}"`);
8578
- changes.push(`added tools.pnpm = "${pnpmVersion}"`);
8579
- } else {
8580
- newBody = newBody.replace(PNPM_LINE_RE, (line) => {
8581
- return `pnpm = "${pnpmVersion}"${/(\s*#.*)$/.exec(line)?.[1] ?? ""}`;
8582
- });
8583
- changes.push(`updated tools.pnpm ${existingPnpm} -> ${pnpmVersion}`);
8584
- }
8585
- }
8586
- return {
8587
- body: newBody,
8588
- changes
8589
- };
8590
- }
8591
- async function generateMise(ctx) {
8592
- const filePath = "mise.toml";
8593
- const { enginesNode, pnpmVersion } = readPackageJsonFacts(ctx);
8594
- const existing = ctx.read(filePath);
8595
- if (!existing) {
8596
- ctx.write(filePath, ownedLines(pnpmVersion, enginesNode).join("\n") + "\n");
8597
- return {
8598
- filePath,
8599
- action: "created",
8600
- description: "Generated mise.toml"
8601
- };
8602
- }
8603
- let parsed;
8604
- try {
8605
- parsed = parse$2(existing);
8606
- } catch (error) {
8688
+ } catch {
8689
+ const config = { ignorePatterns: required };
8690
+ ctx.write(filePath, JSON.stringify(config, null, 2) + "\n");
8607
8691
  return {
8608
8692
  filePath,
8609
- action: "failed",
8610
- description: `Could not parse existing mise.toml -- ${error instanceof Error ? error.message : String(error)}`
8693
+ action: "updated",
8694
+ description: "Replaced malformed .oxfmtrc.json"
8611
8695
  };
8612
8696
  }
8613
- const document = ParsedTomlSchema.safeParse(parsed);
8614
- const parsedTools = document.success ? document.data.tools ?? {} : {};
8615
- const toolsResult = ToolsTableSchema.safeParse(parsedTools);
8616
- const toolsTable = toolsResult.success ? toolsResult.data : {};
8617
- if (typeof toolsTable["node"] === "object") return tableFormFailure(filePath, "node");
8618
- if (typeof toolsTable["pnpm"] === "object") return tableFormFailure(filePath, "pnpm");
8619
- const existingNode = toolsTable["node"];
8620
- const existingPnpm = toolsTable["pnpm"];
8621
- if (existingNode !== void 0 && enginesNode !== void 0 && isBelowFloor(existingNode, enginesNode)) return {
8622
- filePath,
8623
- action: "failed",
8624
- description: `mise.toml: tools.node "${existingNode}" is below engines.node "${enginesNode}"`
8625
- };
8626
- const range = toolsRange(existing);
8627
- let text;
8628
- let changes;
8629
- if (range === void 0) {
8630
- if (Object.keys(toolsTable).length > 0) return {
8697
+ const parseResult = OxfmtConfigSchema.safeParse(jsonValue);
8698
+ if (!parseResult.success) {
8699
+ const config = { ignorePatterns: required };
8700
+ ctx.write(filePath, JSON.stringify(config, null, 2) + "\n");
8701
+ return {
8631
8702
  filePath,
8632
- action: "failed",
8633
- description: `mise.toml: tools.* is declared with dotted keys; repo:sync only manages a [tools] table`
8703
+ action: "updated",
8704
+ description: "Replaced malformed .oxfmtrc.json"
8634
8705
  };
8635
- text = ownedLines(pnpmVersion, enginesNode).join("\n") + "\n\n" + existing;
8636
- changes = ["added [tools] table"];
8637
- } else {
8638
- const body = existing.slice(range.start, range.end);
8639
- const edited = editToolsBody(body, existingNode, existingPnpm, pnpmVersion, enginesNode);
8640
- changes = edited.changes;
8641
- text = edited.body === body ? existing : existing.slice(0, range.start) + edited.body + existing.slice(range.end);
8642
8706
  }
8643
- if (changes.length === 0) return {
8707
+ const parsed = parseResult.data;
8708
+ const ignorePatterns = Array.isArray(parsed["ignorePatterns"]) ? parsed["ignorePatterns"] : [];
8709
+ const strings = ignorePatterns.filter((p) => typeof p === "string");
8710
+ const existingSet = new Set(strings);
8711
+ const missing = required.filter((p) => !existingSet.has(p));
8712
+ const kept = strings.filter((p) => !SUPERSEDED_IGNORE_PATTERNS.has(p));
8713
+ const removed = strings.filter((p) => SUPERSEDED_IGNORE_PATTERNS.has(p));
8714
+ const nonStringPatterns = ignorePatterns.filter((p) => typeof p !== "string");
8715
+ if (missing.length === 0 && removed.length === 0) return {
8644
8716
  filePath,
8645
8717
  action: "skipped",
8646
- description: "Already up to spec"
8718
+ description: "Existing oxfmt config already has required ignorePatterns"
8647
8719
  };
8648
- ctx.write(filePath, text);
8720
+ parsed["ignorePatterns"] = [
8721
+ ...kept,
8722
+ ...nonStringPatterns,
8723
+ ...missing
8724
+ ];
8725
+ ctx.write(filePath, JSON.stringify(parsed, null, 2) + "\n");
8726
+ const changeParts = [];
8727
+ if (missing.length > 0) changeParts.push(`Added ignorePatterns: ${missing.join(", ")}`);
8728
+ if (removed.length > 0) changeParts.push(`removed: ${removed.join(", ")}`);
8649
8729
  return {
8650
8730
  filePath,
8651
8731
  action: "updated",
8652
- description: changes.join(", ")
8732
+ description: changeParts.join("; ")
8653
8733
  };
8654
8734
  }
8655
- let current = {
8656
- warn(message) {
8657
- process.stderr.write(`${message}\n`);
8658
- },
8659
- debug() {}
8660
- };
8661
- function setForgejoLogger(logger) {
8662
- current = logger;
8663
- }
8664
- /** Stable call-site surface: modules bind `log` once; injection swaps the target underneath. */
8665
- const log = {
8666
- warn(message) {
8667
- current.warn(message);
8668
- },
8669
- debug(message) {
8670
- current.debug(message);
8671
- }
8672
- };
8673
- //#endregion
8674
- //#region ../forgejo/src/releases.ts
8675
- /** Patient-repair attempts (delete frozen release, wait, retry) before escalating. */
8676
- const SETTLE_ATTEMPTS = 4;
8677
- /** Base backoff between patient-repair attempts; doubles each attempt. */
8678
- const SETTLE_BASE_DELAY_MS = 2e3;
8679
- /** Cap on a single patient-repair backoff. */
8680
- const SETTLE_MAX_DELAY_MS = 16e3;
8681
- /** Check whether a Forgejo release already exists for a given tag. */
8682
- async function findRelease(executor, conn, tag) {
8683
- const encodedTag = encodeURIComponent(tag);
8684
- const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/tags/${encodedTag}`;
8685
- const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
8686
- if (res.status === 200) return true;
8687
- if (res.status === 404) return false;
8688
- throw new TransientError(await forgejoErrorMessage(res, {
8689
- verb: "check release",
8690
- resource: tag
8691
- }));
8692
- }
8693
- const ReleaseSchema = z.object({
8694
- id: z.number(),
8695
- tag_name: z.string().optional(),
8696
- created_at: z.string().optional(),
8697
- published_at: z.string().optional(),
8698
- html_url: z.string().nullish(),
8699
- prerelease: z.boolean().optional()
8700
- });
8701
- /**
8702
- * Detect the signature of a broken Forgejo release: a `created_at` that maps to
8703
- * the Unix epoch (`created_unix = 0`).
8704
- *
8705
- * Forgejo auto-creates a "tag placeholder" release row when a tag is pushed.
8706
- * If our `POST /releases` races that placeholder, Forgejo promotes the
8707
- * placeholder in place (flipping `is_tag` to false) **without** resetting
8708
- * `created_unix`, leaving a published release stuck at the epoch. Such a
8709
- * release sorts as the oldest, so `/releases/latest` never returns it.
8710
- *
8711
- * The epoch renders as `1969-12-31T18:00:00-06:00` or `1970-01-01T00:00:00Z`
8712
- * depending on the server timezone; both parse to `<= 0` ms. A missing or
8713
- * unparseable date is treated as broken too, so the caller repairs rather than
8714
- * ships it.
8715
- */
8716
- function isEpochReleaseDate(date) {
8717
- if (!date) return true;
8718
- const ms = new Date(date).getTime();
8719
- return Number.isNaN(ms) || ms <= 0;
8720
- }
8721
- /**
8722
- * Fetch the Forgejo release for a tag, returning the parsed object or `null`
8723
- * when no release exists (404). Unlike {@link findRelease}, this exposes the
8724
- * release `id` and dates so callers can inspect/repair an epoch-dated release.
8725
- */
8726
- async function getReleaseByTag(executor, conn, tag) {
8727
- const encodedTag = encodeURIComponent(tag);
8728
- const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/tags/${encodedTag}`;
8729
- const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
8730
- if (res.status === 404) return null;
8731
- if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
8732
- verb: "check release",
8733
- resource: tag
8734
- }));
8735
- const parsed = ReleaseSchema.safeParse(await res.json());
8736
- if (!parsed.success) throw new UnexpectedError(`Unexpected release response: ${parsed.error.message}`);
8737
- return parsed.data;
8738
- }
8739
- /**
8740
- * Create a Forgejo release for a given tag.
8741
- *
8742
- * When `options.target` (a commit SHA or branch) is provided and the tag does
8743
- * not yet exist, Forgejo **creates the tag itself** from that commitish and
8744
- * stamps `created_unix = now` synchronously -- sidestepping the buggy async
8745
- * stamping of a pushed tag. The created tag is lightweight.
8746
- */
8747
- async function createRelease(executor, conn, tag, options = {}) {
8748
- const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases`;
8749
- const payload = {
8750
- tag_name: tag,
8751
- name: tag,
8752
- body: `Published ${tag}`,
8753
- prerelease: options.prerelease ?? false
8735
+ async function generatePrettier(ctx) {
8736
+ const filePath = ".prettierrc";
8737
+ if (ctx.exists(filePath)) return {
8738
+ filePath,
8739
+ action: "skipped",
8740
+ description: "Existing prettier config preserved"
8741
+ };
8742
+ ctx.write(filePath, PRETTIER_CONFIG);
8743
+ return {
8744
+ filePath,
8745
+ action: "created",
8746
+ description: "Generated .prettierrc"
8754
8747
  };
8755
- if (options.target) payload["target_commitish"] = options.target;
8756
- const res = await executor.fetch(url, {
8757
- method: "POST",
8758
- headers: {
8759
- Authorization: `token ${conn.token}`,
8760
- "Content-Type": "application/json"
8761
- },
8762
- body: JSON.stringify(payload)
8763
- });
8764
- if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
8765
- verb: "create release",
8766
- resource: tag
8767
- }));
8768
- }
8769
- /**
8770
- * Delete a git tag from the repository (`DELETE /repos/{repo}/tags/{tag}`).
8771
- *
8772
- * Used by {@link ensureRelease} to take tag ownership away from the buggy
8773
- * push-ingestion path: once the tag is gone, recreating the release with a
8774
- * `target_commitish` makes Forgejo create the tag fresh with a real date. A 404
8775
- * (already gone) is treated as success.
8776
- */
8777
- async function deleteTag(executor, conn, tag) {
8778
- const encodedTag = encodeURIComponent(tag);
8779
- const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/tags/${encodedTag}`;
8780
- const res = await executor.fetch(url, {
8781
- method: "DELETE",
8782
- headers: { Authorization: `token ${conn.token}` }
8783
- });
8784
- if (!res.ok && res.status !== 404) throw new TransientError(await forgejoErrorMessage(res, {
8785
- verb: "delete tag",
8786
- resource: tag
8787
- }));
8788
- }
8789
- z.looseObject({
8790
- name: z.string(),
8791
- id: z.string(),
8792
- commit: z.object({ sha: z.string().regex(/^[0-9a-f]{40}$/) })
8793
- });
8794
- /**
8795
- * Delete a Forgejo release by id. Does **not** delete the underlying git tag
8796
- * (no `delete_tag` query param) -- the tag is the source of truth, and a
8797
- * subsequent `POST /releases` relies on it still existing to insert a fresh
8798
- * row with `created_unix = now`. A 404 (already gone) is treated as success.
8799
- */
8800
- async function deleteRelease(executor, conn, releaseId) {
8801
- const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/${String(releaseId)}`;
8802
- const res = await executor.fetch(url, {
8803
- method: "DELETE",
8804
- headers: { Authorization: `token ${conn.token}` }
8805
- });
8806
- if (!res.ok && res.status !== 404) throw new TransientError(await forgejoErrorMessage(res, {
8807
- verb: "delete release",
8808
- resource: String(releaseId)
8809
- }));
8810
- }
8811
- /**
8812
- * Flag an existing Forgejo release as a prerelease (`PATCH /repos/{repo}/releases/{id}`).
8813
- *
8814
- * {@link ensureRelease} uses this on the path where the release was already there, and so does a
8815
- * rerun, so a rerun over a release an earlier unflagged run created can still
8816
- * keep the forge's Latest pointer off a prerelease. It sets the flag and never clears it, which is
8817
- * why the body is fixed at
8818
- * `{ prerelease: true }` and why the write is a fixed-field PATCH: `{ idempotent: true }`, the
8819
- * convention every other fixed-field PATCH in this package passes (see `updateForgejoComment` in
8820
- * `issue-comments.ts`), because a re-sent convergent PATCH is a no-op.
8821
- *
8822
- * The JSON body is {@link createRelease}'s shape (one `Authorization` header plus a content type),
8823
- * but unlike {@link deleteRelease} against the same URL a 404 is **not** tolerated here: a row that
8824
- * vanished mid-run means the flag was not applied, and the caller must know.
8825
- */
8826
- async function markReleasePrerelease(executor, conn, releaseId, tag) {
8827
- const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/${String(releaseId)}`;
8828
- const res = await executor.fetch(url, {
8829
- method: "PATCH",
8830
- headers: {
8831
- Authorization: `token ${conn.token}`,
8832
- "Content-Type": "application/json"
8833
- },
8834
- body: JSON.stringify({ prerelease: true })
8835
- }, { idempotent: true });
8836
- if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
8837
- verb: "edit release",
8838
- resource: tag
8839
- }));
8840
8748
  }
8749
+ //#endregion
8750
+ //#region src/generators/tsdown.ts
8841
8751
  /**
8842
- * Ensure a Forgejo release exists for a tag **with a real created date**.
8843
- *
8844
- * Forgejo stamps a pushed annotated tag's date *asynchronously*; a release
8845
- * creation that races that stamp freezes the row at the Unix epoch (see
8846
- * {@link isEpochReleaseDate}) -- a published-but-invisible release, because
8847
- * `/releases/latest` sorts by date. The failure is intermittent. The repair:
8848
- *
8849
- * 1. A real-dated release already exists -> `"exists"`.
8850
- * 2. Otherwise create the release and read it back. On epoch, delete the frozen
8851
- * release and wait (increasing backoff) for Forgejo to settle the tag's date,
8852
- * then retry -- **preserving the pushed (annotated) tag**. The frozen release
8853
- * is never deleted-and-left-empty.
8854
- * 3. If the date still never settles and a `target` commit is known, delete the
8855
- * tag and recreate the release with `target_commitish` so **Forgejo creates
8856
- * the tag itself**, stamped `now`. The tag becomes lightweight. -> `"repaired"`.
8857
- * 4. If nothing yields a real date, leave a visible release row (rather than only
8858
- * an invisible tag) and throw loudly.
8859
- *
8860
- * Returns `{ status, release }` -- see {@link EnsureReleaseOutcome}.
8752
+ * Exported so `package-json.ts#reclaimManagedDevDeps` can gate the `tsdown`
8753
+ * devDependency reclaim on the same generated-template evidence this generator
8754
+ * uses for the config file itself -- the two must never disagree about what
8755
+ * "no longer used" means (see the block comment there).
8861
8756
  */
8862
- async function ensureRelease(executor, conn, tag, options = {}) {
8863
- const attempts = options.attempts ?? SETTLE_ATTEMPTS;
8864
- const baseDelayMs = options.baseDelayMs ?? SETTLE_BASE_DELAY_MS;
8865
- const existing = await getReleaseByTag(executor, conn, tag);
8866
- if (existing && !isEpochReleaseDate(existing.created_at)) {
8867
- if (options.prerelease === true && existing.prerelease === false) await markReleasePrerelease(executor, conn, existing.id, tag);
8757
+ const TSDOWN_CONFIG = `import { defineConfig } from "tsdown";
8758
+
8759
+ export default defineConfig({
8760
+ entry: ["src/index.ts"],
8761
+ });
8762
+ `;
8763
+ async function generateTsdown(ctx) {
8764
+ const filePath = "tsdown.config.ts";
8765
+ if (ctx.config.structure === "monorepo") return {
8766
+ filePath,
8767
+ action: "skipped",
8768
+ description: "Monorepo: tsdown config belongs in individual packages"
8769
+ };
8770
+ if (ctx.config.builder !== "tsdown") {
8771
+ if (ctx.read(filePath) !== void 0 && isGeneratedTemplate(ctx, filePath, TSDOWN_CONFIG)) return reportStaleConfig(filePath, `builder is ${ctx.config.builder}`);
8868
8772
  return {
8869
- status: "exists",
8870
- release: releaseRef(existing)
8773
+ filePath,
8774
+ action: "skipped",
8775
+ description: `Builder is ${ctx.config.builder}; tsdown config not managed`
8871
8776
  };
8872
8777
  }
8873
- let current = existing;
8874
- let repairing = false;
8875
- for (let attempt = 1; attempt <= attempts; attempt++) {
8876
- if (current && isEpochReleaseDate(current.created_at)) {
8877
- await deleteRelease(executor, conn, current.id);
8878
- current = null;
8879
- repairing = true;
8880
- await settleBackoff(attempt, baseDelayMs);
8881
- }
8882
- try {
8883
- await createRelease(executor, conn, tag, {
8884
- target: options.target,
8885
- prerelease: options.prerelease
8886
- });
8887
- } catch (error) {
8888
- const raced = await getReleaseByTag(executor, conn, tag);
8889
- if (raced && !isEpochReleaseDate(raced.created_at)) return {
8890
- status: "race",
8891
- release: releaseRef(raced)
8892
- };
8893
- current = raced;
8894
- if (attempt >= attempts) break;
8895
- log.warn(`Release creation attempt ${String(attempt)}/${String(attempts)} failed: ${describeError(error)}`);
8896
- await settleBackoff(attempt, baseDelayMs);
8897
- continue;
8898
- }
8899
- current = await getReleaseByTag(executor, conn, tag);
8900
- if (current && !isEpochReleaseDate(current.created_at)) return {
8901
- status: repairing ? "repaired" : "created",
8902
- release: releaseRef(current)
8778
+ const existing = ctx.read(filePath);
8779
+ const config = formattedTemplate(ctx, filePath, TSDOWN_CONFIG);
8780
+ if (existing) {
8781
+ if (existing === config) return {
8782
+ filePath,
8783
+ action: "skipped",
8784
+ description: "Already configured"
8785
+ };
8786
+ if (await ctx.confirmOverwrite(filePath) === "skip") return {
8787
+ filePath,
8788
+ action: "skipped",
8789
+ description: "Existing tsdown config preserved"
8903
8790
  };
8904
- if (attempt < attempts) log.warn(`Release for ${tag} came back epoch-dated (attempt ${String(attempt)}/${String(attempts)}) -- waiting for Forgejo to settle the tag's date before retrying`);
8905
- }
8906
- const recreated = options.target ? await recreateTagViaApi(executor, conn, tag, {
8907
- target: options.target,
8908
- prerelease: options.prerelease,
8909
- current,
8910
- attempts,
8911
- baseDelayMs
8912
- }) : null;
8913
- if (recreated) return {
8914
- status: "repaired",
8915
- release: releaseRef(recreated)
8916
- };
8917
- throw new TransientError(`Release for ${tag} never got a real (non-epoch) created date after ${String(attempts)} attempts. This is an intermittent Forgejo bug: a pushed tag's date settles asynchronously and a racing release creation freezes it at the Unix epoch (1969/1970). ` + (options.target ? `Recreating the tag via the API did not help -- investigate the server or upgrade Forgejo.` : `Pass the release tag's target commit so the tag can be recreated, or retry the release.`));
8918
- }
8919
- async function recreateTagViaApi(executor, conn, tag, opts) {
8920
- const { target, prerelease, current, attempts, baseDelayMs } = opts;
8921
- log.warn(`Release for ${tag} stayed epoch-dated -- recreating the tag via the API so Forgejo stamps a real date (the tag becomes lightweight)`);
8922
- if (current) await deleteRelease(executor, conn, current.id);
8923
- await deleteTag(executor, conn, tag);
8924
- for (let attempt = 1; attempt <= attempts; attempt++) {
8925
- try {
8926
- await createRelease(executor, conn, tag, {
8927
- target,
8928
- prerelease
8929
- });
8930
- } catch (error) {
8931
- if (attempt >= attempts) throw new TransientError(`Release for ${tag} was deleted to repair its epoch date, but recreating it from ${target} failed: ${describeError(error)}. The tag no longer exists on the server -- re-push it (or re-run the release) to restore it.`);
8932
- await settleBackoff(attempt, baseDelayMs);
8933
- continue;
8934
- }
8935
- const recreated = await getReleaseByTag(executor, conn, tag);
8936
- if (recreated && !isEpochReleaseDate(recreated.created_at)) return recreated;
8937
- return null;
8938
8791
  }
8939
- return null;
8940
- }
8941
- /**
8942
- * The `{ id, url }` projection of a release row carried back to a caller. A server that omits
8943
- * `html_url` yields `url: null` -- the id is still the row's identity.
8944
- */
8945
- function releaseRef(release) {
8792
+ ctx.write(filePath, config);
8946
8793
  return {
8947
- id: release.id,
8948
- url: release.html_url ?? null
8794
+ filePath,
8795
+ action: existing ? "updated" : "created",
8796
+ description: "Generated tsdown.config.ts"
8949
8797
  };
8950
8798
  }
8951
- /** Exponential backoff (capped) between patient-repair attempts; a non-positive base is a no-op. */
8952
- async function settleBackoff(attempt, baseDelayMs) {
8953
- if (baseDelayMs <= 0) return;
8954
- const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), SETTLE_MAX_DELAY_MS);
8955
- await new Promise((resolve) => setTimeout(resolve, delay));
8956
- }
8957
- /** Upload an asset file to a Forgejo release. */
8958
- async function uploadReleaseAsset(executor, conn, tag, options) {
8959
- const encodedTag = encodeURIComponent(tag);
8960
- const releaseUrl = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/tags/${encodedTag}`;
8961
- const releaseRes = await executor.fetch(releaseUrl, { headers: { Authorization: `token ${conn.token}` } });
8962
- if (!releaseRes.ok) throw new TransientError(await forgejoErrorMessage(releaseRes, {
8963
- verb: "get release",
8964
- resource: tag
8965
- }));
8966
- const parsed = z.object({ id: z.number() }).safeParse(await releaseRes.json());
8967
- if (!parsed.success) throw new UnexpectedError(`Unexpected release response: ${parsed.error.message}`);
8968
- const releaseId = parsed.data.id;
8969
- const uploadUrl = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/${String(releaseId)}/assets?name=${encodeURIComponent(options.assetName)}`;
8970
- const form = new FormData();
8971
- form.append("attachment", new Blob([options.fileBuffer]), options.assetName);
8972
- const res = await executor.fetch(uploadUrl, {
8973
- method: "POST",
8974
- headers: { Authorization: `token ${conn.token}` },
8975
- body: form
8976
- }, { timeoutMs: LONG_FETCH_TIMEOUT_MS });
8977
- if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
8978
- verb: "upload asset",
8979
- resource: `${options.assetName} to ${tag}`
8980
- }));
8981
- }
8982
8799
  //#endregion
8983
- //#region src/release/publish/version-of-record.ts
8984
- const FOREIGN_VERSION_FILES = [{
8985
- file: "Cargo.toml",
8986
- sections: ["package"]
8987
- }, {
8988
- file: "pyproject.toml",
8989
- sections: ["project", "tool.poetry"]
8990
- }];
8991
- /** A static `version = "..."` assignment. `version.workspace = true` is deliberately not a match. */
8992
- const STATIC_VERSION = /^\s*version\s*=\s*"[^"]*"/;
8800
+ //#region src/generators/mise.ts
8993
8801
  /**
8994
- * True when one of the named TOML tables holds a static `version = "..."`. A
8995
- * dependency-free section scan, not a full TOML parse: each `[table]` header
8996
- * (or `[[array]]`) opens a section that runs until the next header, so a
8997
- * `version` under `[tool.poetry.dependencies]` never counts as the package
8998
- * version. The failure mode is a false negative (a version we don't recognize),
8999
- * which lands on today's behavior -- no regression.
8802
+ * `mise.toml` owns exactly two keys, `tools.node` and `tools.pnpm`, and carries every other byte
8803
+ * -- other tools, `[env]`, `[tasks.*]`, comments -- through untouched. `smol-toml`'s `parse` is
8804
+ * used only to validate the file and to inspect the owned keys' shapes; it preserves no layout, so
8805
+ * every mutation below is a textual edit against the raw source rather than a reserialization.
8806
+ * `generators/package-json.ts` documents the same "manage the keys, not the file" posture for
8807
+ * `devEngines.runtime`.
9000
8808
  */
9001
- function sectionHasStaticVersion(content, sections) {
9002
- const wanted = new Set(sections);
9003
- let current;
9004
- for (const rawLine of content.split("\n")) {
9005
- const line = rawLine.trim();
9006
- const header = /^\[+\s*([^\]]+?)\s*\]+/.exec(line);
9007
- if (header) {
9008
- current = header[1];
9009
- continue;
9010
- }
9011
- if (current !== void 0 && wanted.has(current) && STATIC_VERSION.test(line)) return true;
9012
- }
9013
- return false;
8809
+ const TOOLS_HEADER_RE = /^\[tools\]\s*$/m;
8810
+ /** Any top-level table header (`[x]` or `[x.y]`) or array-of-tables header (`[[x]]`), used to find
8811
+ * where `[tools]`'s own range ends -- an array-of-tables entry is a fresh table just as much as a
8812
+ * `[x]` header is, so it must terminate the body range too, or an owned line appended past it
8813
+ * lands inside that entry instead of `[tools]`. */
8814
+ const TOP_LEVEL_HEADER_RE = /^\[{1,2}[^[].*\]{1,2}\s*$/m;
8815
+ const PNPM_LINE_RE = /^pnpm\s*=.*$/m;
8816
+ /** Tolerant shape of the file's `[tools]` table: string or table form for every key, so a
8817
+ * table-form `node`/`pnpm` is caught rather than mistaken for absent. */
8818
+ const ToolsTableSchema = z.record(z.string(), z.union([z.string(), z.record(z.string(), z.unknown())]));
8819
+ /** Tolerant shape of the whole parsed document -- only `tools` is read. */
8820
+ const ParsedTomlSchema = z.looseObject({ tools: z.unknown().optional() });
8821
+ /** The `[tools]` table's body range: right after its header line to the next top-level header or
8822
+ * EOF. `undefined` when there is no `[tools]` table at all. */
8823
+ function toolsRange(source) {
8824
+ const headerMatch = TOOLS_HEADER_RE.exec(source);
8825
+ if (!headerMatch) return void 0;
8826
+ const start = headerMatch.index + headerMatch[0].length;
8827
+ const rest = source.slice(start);
8828
+ const nextHeader = TOP_LEVEL_HEADER_RE.exec(rest);
8829
+ return {
8830
+ start,
8831
+ end: nextHeader ? start + nextHeader.index : source.length
8832
+ };
9014
8833
  }
9015
- /**
9016
- * The filename of a foreign version-of-record (`Cargo.toml`, `pyproject.toml`)
9017
- * that carries an explicit static version, or `undefined` when none is present.
9018
- *
9019
- * On a `stack: none` repo this is proof that `package.json` is only the bst
9020
- * harness and the real version lives elsewhere -- the trigger for `release:simple`
9021
- * to refuse rather than silently bump the wrong file.
9022
- */
9023
- function detectForeignVersionOfRecord(targetDir) {
9024
- for (const { file, sections } of FOREIGN_VERSION_FILES) {
9025
- const full = path.join(targetDir, file);
9026
- if (!existsSync(full)) continue;
9027
- let content;
9028
- try {
9029
- content = readFileSync(full, "utf-8");
9030
- } catch (_error) {
9031
- continue;
9032
- }
9033
- if (sectionHasStaticVersion(content, sections)) return file;
9034
- }
8834
+ /** Strip the version out of `packageManager` (`pnpm@11.27.0` -> `11.27.0`). */
8835
+ function pnpmVersionFromPackageManager(packageManager) {
8836
+ return /^pnpm@(\S+)$/.exec(packageManager ?? "")?.[1];
9035
8837
  }
9036
- /**
9037
- * The version-of-record conflict `release:simple` refuses on: the filename when
9038
- * `stack: none` (so `package.json` is provably just the harness) meets a
9039
- * `releaseStrategy: simple` and a foreign version-of-record file, else
9040
- * `undefined`. A `javascript` repo (its `package.json` legitimately IS the
9041
- * version) and an empty `stack: none` repo (no competing file) are both allowed.
9042
- */
9043
- function releaseTargetConflict(stack, releaseStrategy, targetDir) {
9044
- if (stack !== "none" || releaseStrategy !== "simple") return void 0;
9045
- return detectForeignVersionOfRecord(targetDir);
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;
9046
8857
  }
9047
- //#endregion
9048
- //#region src/release/publish/version-stamp.ts
9049
- function splitLines(content) {
9050
- const trailingNewline = content.endsWith("\n");
8858
+ /** The two owned lines, in order, for a file created from scratch (or a `[tools]` table added to
8859
+ * one that had none). */
8860
+ function ownedLines(pnpmVersion, nodePin) {
8861
+ const lines = [`[tools]`, `node = "${nodePin}"`];
8862
+ if (pnpmVersion) lines.push(`pnpm = "${pnpmVersion}"`);
8863
+ return lines;
8864
+ }
8865
+ /** Append a `key = "value"` line to a `[tools]` body, respecting whatever trailing newline it
8866
+ * already has. */
8867
+ function appendLine(body, line) {
8868
+ return (body.endsWith("\n") || body.length === 0 ? body : body + "\n") + line + "\n";
8869
+ }
8870
+ /** `action: "failed"` naming an owned key present in table form. */
8871
+ function tableFormFailure(filePath, key) {
9051
8872
  return {
9052
- lines: (trailingNewline ? content.slice(0, -1) : content).split("\n"),
9053
- trailingNewline
8873
+ filePath,
8874
+ action: "failed",
8875
+ description: `mise.toml: tools.${key} is a table; repo:sync owns it as a plain string pin`
9054
8876
  };
9055
8877
  }
9056
- function joinLines({ lines, trailingNewline }) {
9057
- return lines.join("\n") + (trailingNewline ? "\n" : "");
9058
- }
9059
- function stripCr(line) {
9060
- return line.endsWith("\r") ? line.slice(0, -1) : line;
8878
+ /** `true` when `pinned` provably resolves below `enginesNode`'s floor. Only two pin shapes are
8879
+ * compared: an exact `X.Y.Z` against the whole floor, and a bare major (`"24"`, what `mise use
8880
+ * node@24` writes) against the floor's major alone. Anything else -- `"24.12"`, `"lts"`, a prefix
8881
+ * mise resolves -- is left for mise to resolve, because the toolchain gate in
8882
+ * `commands/sync-gates.ts` treats such pins as legitimate and checks the version mise resolves
8883
+ * them to. Reading `"24"` as `24.0.0` would refuse a pin that resolves to the newest 24.x. An
8884
+ * unparseable `enginesNode` compares nothing. */
8885
+ function isBelowFloor(pinned, enginesNode) {
8886
+ const floor = minVersionTuple(enginesNode);
8887
+ if (floor === void 0) return false;
8888
+ const exact = /^(\d+)\.(\d+)\.(\d+)$/.exec(pinned);
8889
+ if (exact) return compareTuples([
8890
+ Number(exact[1]),
8891
+ Number(exact[2]),
8892
+ Number(exact[3])
8893
+ ], floor) < 0;
8894
+ const major = /^(\d+)$/.exec(pinned);
8895
+ if (major) return Number(major[1]) < floor[0];
8896
+ return false;
9061
8897
  }
9062
- /** `prefix + version + suffix` -- the exact line a matching entry is replaced with. */
9063
- function expectedStampLine(entry, version) {
9064
- return entry.prefix + version + (entry.suffix ?? "");
8898
+ /** Resolve the two facts `mise.toml` needs from `package.json`: the engines floor to validate an
8899
+ * existing `tools.node` pin against, and the pnpm version to keep `tools.pnpm` in step with. */
8900
+ function readPackageJsonFacts(ctx) {
8901
+ const pkgRaw = ctx.read("package.json");
8902
+ const pkg = pkgRaw ? parsePackageJson(pkgRaw) : void 0;
8903
+ return {
8904
+ enginesNode: pkg?.engines?.["node"],
8905
+ devEngines: pkg?.devEngines,
8906
+ pnpmVersion: pnpmVersionFromPackageManager(pkg?.packageManager)
8907
+ };
9065
8908
  }
9066
- /**
9067
- * Indexes (into `content.split("\n")`, post trailing-newline removal) of every line that
9068
- * starts with `entry.prefix`, ends with `entry.suffix` (default: none), and is at least
9069
- * `prefix.length + suffix.length` characters long so the two cannot overlap on a
9070
- * degenerate line. Compared against each line with a trailing `\r` stripped.
9071
- */
9072
- function matchingLineIndexes(content, entry) {
9073
- const suffix = entry.suffix ?? "";
9074
- const minLength = entry.prefix.length + suffix.length;
9075
- const { lines } = splitLines(content);
9076
- const indexes = [];
9077
- lines.forEach((rawLine, index) => {
9078
- const line = stripCr(rawLine);
9079
- if (line.startsWith(entry.prefix) && line.endsWith(suffix) && line.length >= minLength) indexes.push(index);
9080
- });
9081
- return indexes;
8909
+ /** Edit an existing `[tools]` body in place: add `node` only when absent, rewrite `pnpm` only on
8910
+ * drift. Returns the new body and the change descriptions, or `undefined` for the body when
8911
+ * nothing changed. */
8912
+ function editToolsBody(body, existingNode, existingPnpm, pnpmVersion, nodePin) {
8913
+ let newBody = body;
8914
+ const changes = [];
8915
+ if (existingNode === void 0) {
8916
+ newBody = appendLine(newBody, `node = "${nodePin}"`);
8917
+ changes.push(`added tools.node = "${nodePin}"`);
8918
+ }
8919
+ if (pnpmVersion && existingPnpm !== pnpmVersion) {
8920
+ if (existingPnpm === void 0) {
8921
+ newBody = appendLine(newBody, `pnpm = "${pnpmVersion}"`);
8922
+ changes.push(`added tools.pnpm = "${pnpmVersion}"`);
8923
+ } else {
8924
+ newBody = newBody.replace(PNPM_LINE_RE, (line) => {
8925
+ return `pnpm = "${pnpmVersion}"${/(\s*#.*)$/.exec(line)?.[1] ?? ""}`;
8926
+ });
8927
+ changes.push(`updated tools.pnpm ${existingPnpm} -> ${pnpmVersion}`);
8928
+ }
8929
+ }
8930
+ return {
8931
+ body: newBody,
8932
+ changes
8933
+ };
9082
8934
  }
9083
8935
  /**
9084
- * `undefined` when exactly one line in `content` matches `entry`; otherwise the
9085
- * user-facing sentence describing the zero- or many-match failure, naming the file, the
9086
- * prefix, and the fix (a more specific prefix, or a second `versionStamp` entry). Both the
9087
- * `release:assets` stamp phase and the `repo:sync` static gate report through this one
9088
- * function so the two messages cannot drift apart.
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.
9089
8939
  */
9090
- function describeStampProblem(content, entry) {
9091
- const indexes = matchingLineIndexes(content, entry);
9092
- if (indexes.length === 1) return void 0;
9093
- const suffixClause = entry.suffix === void 0 ? "" : ` and suffix ${JSON.stringify(entry.suffix)}`;
9094
- const prefixClause = `prefix ${JSON.stringify(entry.prefix)}${suffixClause}`;
9095
- if (indexes.length === 0) return `${entry.file}: no line matches ${prefixClause}. Fix the prefix/suffix to match the file's actual content.`;
9096
- return `${entry.file}: ${indexes.length} lines match ${prefixClause}, which is ambiguous. Use a more specific prefix, or split this into a second versionStamp entry.`;
8940
+ async function generateMise(ctx, priorDevEngines) {
8941
+ const filePath = "mise.toml";
8942
+ const { enginesNode, devEngines, pnpmVersion } = readPackageJsonFacts(ctx);
8943
+ const nodePin = nodePinFor(enginesNode, devEnginesNodeVersion(priorDevEngines ?? devEngines));
8944
+ const existing = ctx.read(filePath);
8945
+ if (!existing) {
8946
+ ctx.write(filePath, ownedLines(pnpmVersion, nodePin).join("\n") + "\n");
8947
+ return {
8948
+ filePath,
8949
+ action: "created",
8950
+ description: "Generated mise.toml"
8951
+ };
8952
+ }
8953
+ let parsed;
8954
+ try {
8955
+ parsed = parse$2(existing);
8956
+ } catch (error) {
8957
+ return {
8958
+ filePath,
8959
+ action: "failed",
8960
+ description: `Could not parse existing mise.toml -- ${error instanceof Error ? error.message : String(error)}`
8961
+ };
8962
+ }
8963
+ const document = ParsedTomlSchema.safeParse(parsed);
8964
+ const parsedTools = document.success ? document.data.tools ?? {} : {};
8965
+ const toolsResult = ToolsTableSchema.safeParse(parsedTools);
8966
+ const toolsTable = toolsResult.success ? toolsResult.data : {};
8967
+ if (typeof toolsTable["node"] === "object") return tableFormFailure(filePath, "node");
8968
+ if (typeof toolsTable["pnpm"] === "object") return tableFormFailure(filePath, "pnpm");
8969
+ const existingNode = toolsTable["node"];
8970
+ const existingPnpm = toolsTable["pnpm"];
8971
+ if (existingNode !== void 0 && enginesNode !== void 0 && isBelowFloor(existingNode, enginesNode)) return {
8972
+ filePath,
8973
+ action: "failed",
8974
+ description: `mise.toml: tools.node "${existingNode}" is below engines.node "${enginesNode}"`
8975
+ };
8976
+ const range = toolsRange(existing);
8977
+ let text;
8978
+ let changes;
8979
+ if (range === void 0) {
8980
+ if (Object.keys(toolsTable).length > 0) return {
8981
+ filePath,
8982
+ action: "failed",
8983
+ description: `mise.toml: tools.* is declared with dotted keys; repo:sync only manages a [tools] table`
8984
+ };
8985
+ text = ownedLines(pnpmVersion, nodePin).join("\n") + "\n\n" + existing;
8986
+ changes = ["added [tools] table"];
8987
+ } else {
8988
+ const body = existing.slice(range.start, range.end);
8989
+ const edited = editToolsBody(body, existingNode, existingPnpm, pnpmVersion, nodePin);
8990
+ changes = edited.changes;
8991
+ text = edited.body === body ? existing : existing.slice(0, range.start) + edited.body + existing.slice(range.end);
8992
+ }
8993
+ if (changes.length === 0) return {
8994
+ filePath,
8995
+ action: "skipped",
8996
+ description: "Already up to spec"
8997
+ };
8998
+ ctx.write(filePath, text);
8999
+ return {
9000
+ filePath,
9001
+ action: "updated",
9002
+ description: changes.join(", ")
9003
+ };
9097
9004
  }
9098
- /**
9099
- * Replaces the single line in `content` matching `entry` with `expectedStampLine(entry,
9100
- * version)`, preserving that line's trailing `\r` and the file's trailing newline (or its
9101
- * absence). Idempotent: stamping an already-stamped line reproduces the file byte for
9102
- * byte. Precondition: `describeStampProblem(content, entry)` is `undefined` -- callers
9103
- * must check that first, since this indexes the sole match directly.
9104
- */
9105
- function stampContent(content, entry, version) {
9106
- const split = splitLines(content);
9107
- const [index] = matchingLineIndexes(content, entry);
9108
- const hasCr = split.lines[index].endsWith("\r");
9109
- const replacement = expectedStampLine(entry, version);
9110
- split.lines[index] = hasCr ? `${replacement}\r` : replacement;
9111
- return joinLines(split);
9005
+ let current = {
9006
+ warn(message) {
9007
+ process.stderr.write(`${message}\n`);
9008
+ },
9009
+ debug() {}
9010
+ };
9011
+ function setForgejoLogger(logger) {
9012
+ current = logger;
9112
9013
  }
9014
+ /** Stable call-site surface: modules bind `log` once; injection swaps the target underneath. */
9015
+ const log = {
9016
+ warn(message) {
9017
+ current.warn(message);
9018
+ },
9019
+ debug(message) {
9020
+ current.debug(message);
9021
+ }
9022
+ };
9113
9023
  //#endregion
9114
- //#region src/release/publish/assets.ts
9115
- const CONVENTION_SCRIPT = "build:release-assets";
9116
- const CONVENTION_OUTPUT_DIR = "release-assets";
9117
- function configuredTargets(cwd, toolingConfig) {
9118
- return (toolingConfig?.releaseAssets ?? []).map((asset) => ({
9119
- commandCwd: cwd,
9120
- command: asset.command,
9121
- files: [{
9122
- path: path.resolve(cwd, asset.file),
9123
- ...asset.name && { name: asset.name }
9124
- }]
9125
- }));
9126
- }
9127
- function conventionTargets(executor, cwd) {
9128
- const targets = [];
9129
- const checkDir = (relDir) => {
9130
- const absDir = path.resolve(cwd, relDir);
9131
- const raw = executor.readFile(path.join(absDir, "package.json"));
9132
- if (!raw) return;
9133
- if (!parsePackageJson(raw)?.scripts?.[CONVENTION_SCRIPT]) return;
9134
- targets.push({
9135
- commandCwd: absDir,
9136
- command: [
9137
- "pnpm",
9138
- "run",
9139
- CONVENTION_SCRIPT
9140
- ],
9141
- files: [],
9142
- collectFromDir: path.join(absDir, CONVENTION_OUTPUT_DIR)
9143
- });
9144
- };
9145
- checkDir(".");
9146
- for (const dir of executor.listPackageDirs(cwd)) checkDir(path.join("packages", dir));
9147
- return targets;
9024
+ //#region ../forgejo/src/releases.ts
9025
+ /** Patient-repair attempts (delete frozen release, wait, retry) before escalating. */
9026
+ const SETTLE_ATTEMPTS = 4;
9027
+ /** Base backoff between patient-repair attempts; doubles each attempt. */
9028
+ const SETTLE_BASE_DELAY_MS = 2e3;
9029
+ /** Cap on a single patient-repair backoff. */
9030
+ const SETTLE_MAX_DELAY_MS = 16e3;
9031
+ /** Check whether a Forgejo release already exists for a given tag. */
9032
+ async function findRelease(executor, conn, tag) {
9033
+ const encodedTag = encodeURIComponent(tag);
9034
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/tags/${encodedTag}`;
9035
+ const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
9036
+ if (res.status === 200) return true;
9037
+ if (res.status === 404) return false;
9038
+ throw new TransientError(await forgejoErrorMessage(res, {
9039
+ verb: "check release",
9040
+ resource: tag
9041
+ }));
9148
9042
  }
9043
+ const ReleaseSchema = z.object({
9044
+ id: z.number(),
9045
+ tag_name: z.string().optional(),
9046
+ created_at: z.string().optional(),
9047
+ published_at: z.string().optional(),
9048
+ html_url: z.string().nullish(),
9049
+ prerelease: z.boolean().optional()
9050
+ });
9149
9051
  /**
9150
- * The release version `versionStamp` writes into each configured file: the root
9151
- * `package.json#version`, the same source `release:simple` reads. A missing file, unparseable
9152
- * JSON, and a manifest with no `version` are all rejected -- a missing version is the "ships
9153
- * 0.0.0" failure this design exists to catch, never a skip.
9052
+ * Detect the signature of a broken Forgejo release: a `created_at` that maps to
9053
+ * the Unix epoch (`created_unix = 0`).
9054
+ *
9055
+ * Forgejo auto-creates a "tag placeholder" release row when a tag is pushed.
9056
+ * If our `POST /releases` races that placeholder, Forgejo promotes the
9057
+ * placeholder in place (flipping `is_tag` to false) **without** resetting
9058
+ * `created_unix`, leaving a published release stuck at the epoch. Such a
9059
+ * release sorts as the oldest, so `/releases/latest` never returns it.
9060
+ *
9061
+ * The epoch renders as `1969-12-31T18:00:00-06:00` or `1970-01-01T00:00:00Z`
9062
+ * depending on the server timezone; both parse to `<= 0` ms. A missing or
9063
+ * unparseable date is treated as broken too, so the caller repairs rather than
9064
+ * ships it.
9154
9065
  */
9155
- function resolveStampVersion(executor, cwd) {
9156
- const pkgPath = path.join(cwd, "package.json");
9157
- const raw = executor.readFile(pkgPath);
9158
- if (raw === null) throw new FatalError(`release:assets could not read package.json at ${pkgPath} to resolve the version for versionStamp.`);
9159
- const pkg = parsePackageJson(raw);
9160
- if (!pkg?.version) throw new FatalError(`release:assets could not find a "version" field in package.json at ${pkgPath}. versionStamp needs a version to stamp.`);
9161
- return pkg.version;
9066
+ function isEpochReleaseDate(date) {
9067
+ if (!date) return true;
9068
+ const ms = new Date(date).getTime();
9069
+ return Number.isNaN(ms) || ms <= 0;
9162
9070
  }
9163
9071
  /**
9164
- * Stamps every `versionStamp` entry with `version`, before any build command runs. Throws
9165
- * `describeStampProblem`'s message on a zero- or many-match config, so the two consumers
9166
- * (this phase and `repo:sync`'s static gate) cannot report the failure differently.
9072
+ * Fetch the Forgejo release for a tag, returning the parsed object or `null`
9073
+ * when no release exists (404). Unlike {@link findRelease}, this exposes the
9074
+ * release `id` and dates so callers can inspect/repair an epoch-dated release.
9167
9075
  */
9168
- function stampVersionFiles(executor, cwd, toolingConfig, version) {
9169
- const entries = toolingConfig?.versionStamp ?? [];
9170
- const stamped = [];
9171
- for (const entry of entries) {
9172
- const filePath = path.resolve(cwd, entry.file);
9173
- const content = executor.readFile(filePath);
9174
- if (content === null) throw new FatalError(`versionStamp file not found: ${entry.file} (resolved to ${filePath})`);
9175
- const problem = describeStampProblem(content, entry);
9176
- if (problem) throw new FatalError(`versionStamp: ${problem}`);
9177
- const expectedLine = expectedStampLine(entry, version);
9178
- executor.writeFile(filePath, stampContent(content, entry, version));
9179
- log$1.info(`Stamped ${entry.file}: ${expectedLine}`);
9180
- stamped.push({
9181
- path: filePath,
9182
- expectedLine
9183
- });
9184
- }
9185
- return stamped;
9076
+ async function getReleaseByTag(executor, conn, tag) {
9077
+ const encodedTag = encodeURIComponent(tag);
9078
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/tags/${encodedTag}`;
9079
+ const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
9080
+ if (res.status === 404) return null;
9081
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
9082
+ verb: "check release",
9083
+ resource: tag
9084
+ }));
9085
+ const parsed = ReleaseSchema.safeParse(await res.json());
9086
+ if (!parsed.success) throw new UnexpectedError(`Unexpected release response: ${parsed.error.message}`);
9087
+ return parsed.data;
9186
9088
  }
9187
9089
  /**
9188
- * Re-reads each stamped file and requires exactly one line equal to its `expectedLine`. Runs
9189
- * after the build loop -- the only failure this read-back can catch that the stamp phase did not
9190
- * already is the build overwriting the stamp (a `build:release-assets` script that regenerates or
9191
- * copies over the stamped file).
9090
+ * Create a Forgejo release for a given tag.
9091
+ *
9092
+ * When `options.target` (a commit SHA or branch) is provided and the tag does
9093
+ * not yet exist, Forgejo **creates the tag itself** from that commitish and
9094
+ * stamps `created_unix = now` synchronously -- sidestepping the buggy async
9095
+ * stamping of a pushed tag. The created tag is lightweight.
9192
9096
  */
9193
- function verifyStamp(executor, stamped) {
9194
- for (const { path: filePath, expectedLine } of stamped) {
9195
- const matches = (executor.readFile(filePath) ?? "").split("\n").filter((line) => (line.endsWith("\r") ? line.slice(0, -1) : line) === expectedLine).length;
9196
- if (matches !== 1) throw new FatalError(`versionStamp verify failed for ${filePath}: expected exactly one line "${expectedLine}" after the build, found ${matches}. The build likely overwrote the stamp -- check for a script that regenerates or copies over this file.`);
9197
- }
9097
+ async function createRelease(executor, conn, tag, options = {}) {
9098
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases`;
9099
+ const payload = {
9100
+ tag_name: tag,
9101
+ name: tag,
9102
+ body: `Published ${tag}`,
9103
+ prerelease: options.prerelease ?? false
9104
+ };
9105
+ if (options.target) payload["target_commitish"] = options.target;
9106
+ const res = await executor.fetch(url, {
9107
+ method: "POST",
9108
+ headers: {
9109
+ Authorization: `token ${conn.token}`,
9110
+ "Content-Type": "application/json"
9111
+ },
9112
+ body: JSON.stringify(payload)
9113
+ });
9114
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
9115
+ verb: "create release",
9116
+ resource: tag
9117
+ }));
9198
9118
  }
9199
9119
  /**
9200
- * The whole stamp phase, gated on a non-empty `versionStamp`: refuse a foreign
9201
- * version-of-record, resolve the release version, and stamp every entry. Returns `[]`
9202
- * untouched (no real-filesystem read, no executor read) when the repo configures no
9203
- * `versionStamp`, so no existing call path changes behaviour.
9120
+ * Delete a git tag from the repository (`DELETE /repos/{repo}/tags/{tag}`).
9121
+ *
9122
+ * Used by {@link ensureRelease} to take tag ownership away from the buggy
9123
+ * push-ingestion path: once the tag is gone, recreating the release with a
9124
+ * `target_commitish` makes Forgejo create the tag fresh with a real date. A 404
9125
+ * (already gone) is treated as success.
9204
9126
  */
9205
- function runVersionStampPhase(executor, config) {
9206
- if ((config.toolingConfig?.versionStamp ?? []).length === 0) return [];
9207
- const conflict = detectForeignVersionOfRecord(config.cwd);
9208
- if (conflict) throw new FatalError(`release:assets stamps package.json's version into versionStamp files, but this repo's version-of-record is ${conflict}. A stamp would carry a version the ${conflict} artifact never gets. Release this repo through its own toolchain, or remove the versionStamp block.`);
9209
- const version = resolveStampVersion(executor, config.cwd);
9210
- return stampVersionFiles(executor, config.cwd, config.toolingConfig, version);
9211
- }
9212
- function runBuild(executor, target, config) {
9213
- if (!target.command || target.command.length === 0) return;
9214
- const [file, ...args] = target.command;
9215
- if (!file) return;
9216
- const label = target.commandCwd === config.cwd ? "root" : path.relative(config.cwd, target.commandCwd);
9217
- log$1.info(`Building assets in ${label}: ${target.command.join(" ")}`);
9218
- executor.execInherit(file, args, { cwd: target.commandCwd });
9219
- debug(config, `Asset build completed for ${label}`);
9220
- }
9221
- function collectFromDir(executor, target) {
9222
- if (!target.collectFromDir) return;
9223
- const before = target.files.length;
9224
- for (const entry of executor.listFiles(target.collectFromDir)) target.files.push({ path: path.join(target.collectFromDir, entry) });
9225
- if (target.files.length === before) throw new FatalError(`${CONVENTION_SCRIPT} ran in ${target.commandCwd} but emitted no files to ${target.collectFromDir}. Update your script to write artifacts there, or declare assets explicitly via releaseAssets in .tooling.json.`);
9226
- }
9227
- function uploadToGitHub(executor, config, tag, asset) {
9228
- const assetName = asset.name ?? path.basename(asset.path);
9229
- const result = executor.exec("gh", [
9230
- "release",
9231
- "upload",
9232
- tag,
9233
- asset.path,
9234
- "--clobber"
9235
- ], { cwd: config.cwd });
9236
- debugExec(config, `gh release upload ${tag} ${assetName}`, result);
9237
- if (result.exitCode !== 0) throw new FatalError(`GitHub release upload failed for ${assetName}: ${result.stderr || result.stdout}`);
9238
- log$1.info(`Uploaded ${assetName} to ${tag} (GitHub)`);
9239
- }
9240
- async function uploadToForgejo(executor, conn, tag, asset) {
9241
- const assetName = asset.name ?? path.basename(asset.path);
9242
- const fileBuffer = executor.readFileBuffer(asset.path);
9243
- if (!fileBuffer) throw new FatalError(`Asset file not found: ${asset.path}`);
9244
- await uploadReleaseAsset(executor, conn, tag, {
9245
- assetName,
9246
- fileBuffer
9127
+ async function deleteTag(executor, conn, tag) {
9128
+ const encodedTag = encodeURIComponent(tag);
9129
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/tags/${encodedTag}`;
9130
+ const res = await executor.fetch(url, {
9131
+ method: "DELETE",
9132
+ headers: { Authorization: `token ${conn.token}` }
9247
9133
  });
9248
- log$1.info(`Uploaded ${assetName} to ${tag} (Forgejo)`);
9249
- }
9250
- async function runReleaseAssets(executor, config) {
9251
- debug(config, `Config: dryRun=${String(config.dryRun)} cwd=${config.cwd}`);
9252
- const targets = [...configuredTargets(config.cwd, config.toolingConfig), ...conventionTargets(executor, config.cwd)];
9253
- const stampedFiles = runVersionStampPhase(executor, config);
9254
- for (const target of targets) {
9255
- runBuild(executor, target, config);
9256
- collectFromDir(executor, target);
9257
- }
9258
- verifyStamp(executor, stampedFiles);
9259
- if (targets.length === 0) {
9260
- log$1.info(`No release assets configured in .tooling.json or by convention (${CONVENTION_SCRIPT} script)`);
9261
- return;
9262
- }
9263
- if (config.dryRun) {
9264
- log$1.info("[dry-run] Skipping upload");
9265
- return;
9266
- }
9267
- if (!config.platform) throw new FatalError("Platform connection is required for asset upload");
9268
- const tagResult = executor.exec("git", [
9269
- "tag",
9270
- "--points-at",
9271
- "HEAD"
9272
- ], { cwd: config.cwd });
9273
- debugExec(config, "git tag --points-at HEAD", tagResult);
9274
- if (tagResult.exitCode !== 0) throw new FatalError("Could not determine current tags");
9275
- const tags = tagResult.stdout.trim().split("\n").filter(Boolean);
9276
- if (tags.length === 0) {
9277
- log$1.info("No tags pointing to HEAD, skipping upload");
9278
- return;
9279
- }
9280
- let uploaded = 0;
9281
- for (const tag of tags) {
9282
- if (!await releaseExists(executor, config, tag)) {
9283
- log$1.info(`No release found for ${tag}, skipping (likely a sliding tag)`);
9284
- continue;
9285
- }
9286
- log$1.info(`Uploading assets to release ${tag}`);
9287
- for (const target of targets) for (const asset of target.files) if (config.platform.type === "github") uploadToGitHub(executor, config, tag, asset);
9288
- else await uploadToForgejo(executor, config.platform.conn, tag, asset);
9289
- uploaded++;
9290
- }
9291
- if (uploaded === 0) log$1.info("No releases found for any tag pointing to HEAD, skipping upload");
9292
- }
9293
- async function releaseExists(executor, config, tag) {
9294
- if (!config.platform) return false;
9295
- if (config.platform.type === "forgejo") return findRelease(executor, config.platform.conn, tag);
9296
- const result = executor.exec("gh", [
9297
- "release",
9298
- "view",
9299
- tag
9300
- ], { cwd: config.cwd });
9301
- debugExec(config, `gh release view ${tag}`, result);
9302
- return result.exitCode === 0;
9134
+ if (!res.ok && res.status !== 404) throw new TransientError(await forgejoErrorMessage(res, {
9135
+ verb: "delete tag",
9136
+ resource: tag
9137
+ }));
9138
+ }
9139
+ z.looseObject({
9140
+ name: z.string(),
9141
+ id: z.string(),
9142
+ commit: z.object({ sha: z.string().regex(/^[0-9a-f]{40}$/) })
9143
+ });
9144
+ /**
9145
+ * Delete a Forgejo release by id. Does **not** delete the underlying git tag
9146
+ * (no `delete_tag` query param) -- the tag is the source of truth, and a
9147
+ * subsequent `POST /releases` relies on it still existing to insert a fresh
9148
+ * row with `created_unix = now`. A 404 (already gone) is treated as success.
9149
+ */
9150
+ async function deleteRelease(executor, conn, releaseId) {
9151
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/${String(releaseId)}`;
9152
+ const res = await executor.fetch(url, {
9153
+ method: "DELETE",
9154
+ headers: { Authorization: `token ${conn.token}` }
9155
+ });
9156
+ if (!res.ok && res.status !== 404) throw new TransientError(await forgejoErrorMessage(res, {
9157
+ verb: "delete release",
9158
+ resource: String(releaseId)
9159
+ }));
9303
9160
  }
9304
- //#endregion
9305
- //#region src/generators/gitignore.ts
9306
- /** Entries that every project should have -- repo:sync --check flags these as missing. */
9307
- const REQUIRED_ENTRIES = [
9308
- "node_modules/",
9309
- ".pnpm-store/",
9310
- "dist/",
9311
- "*.tsbuildinfo",
9312
- ".env",
9313
- ".env.*",
9314
- "!.env.example"
9315
- ];
9316
9161
  /**
9317
- * Tooling-specific entries added during init/update but not required for repo:sync --check.
9162
+ * Flag an existing Forgejo release as a prerelease (`PATCH /repos/{repo}/releases/{id}`).
9318
9163
  *
9319
- * `.superpowers/` is agent scratch state (task briefs, review diffs, the SDD progress ledger)
9320
- * that the superpowers plugin writes into whatever repo it runs in. The plugin's own protection
9321
- * is a self-ignoring `.superpowers/sdd/.gitignore` it creates as a side effect of running a
9322
- * skill -- untracked, so it never reaches a clone, and scoped to `sdd/`, so any sibling folder a
9323
- * future skill writes lands untracked-but-not-ignored and one `git add -A` commits it. Ignore
9324
- * the whole directory here for the same reason as `.tooling-archived/`: it is tool-generated
9325
- * scratch that appears without the user asking, so the tool should be what ignores it.
9164
+ * {@link ensureRelease} uses this on the path where the release was already there, and so does a
9165
+ * rerun, so a rerun over a release an earlier unflagged run created can still
9166
+ * keep the forge's Latest pointer off a prerelease. It sets the flag and never clears it, which is
9167
+ * why the body is fixed at
9168
+ * `{ prerelease: true }` and why the write is a fixed-field PATCH: `{ idempotent: true }`, the
9169
+ * convention every other fixed-field PATCH in this package passes (see `updateForgejoComment` in
9170
+ * `issue-comments.ts`), because a re-sent convergent PATCH is a no-op.
9171
+ *
9172
+ * The JSON body is {@link createRelease}'s shape (one `Authorization` header plus a content type),
9173
+ * but unlike {@link deleteRelease} against the same URL a 404 is **not** tolerated here: a row that
9174
+ * vanished mid-run means the flag was not applied, and the caller must know.
9326
9175
  */
9327
- const OPTIONAL_ENTRIES = [
9328
- ".tooling-migrate.md",
9329
- ".tooling-archived/",
9330
- ".claude/hooks/bst-hook.audit.local.jsonl*",
9331
- ".superpowers/",
9332
- "coverage/"
9333
- ];
9176
+ async function markReleasePrerelease(executor, conn, releaseId, tag) {
9177
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/${String(releaseId)}`;
9178
+ const res = await executor.fetch(url, {
9179
+ method: "PATCH",
9180
+ headers: {
9181
+ Authorization: `token ${conn.token}`,
9182
+ "Content-Type": "application/json"
9183
+ },
9184
+ body: JSON.stringify({ prerelease: true })
9185
+ }, { idempotent: true });
9186
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
9187
+ verb: "edit release",
9188
+ resource: tag
9189
+ }));
9190
+ }
9334
9191
  /**
9335
- * Optional entries added only when a feature is actually configured.
9336
- * Patterns are written without a leading slash so they match at any depth --
9337
- * a single `release-assets/` entry in the root .gitignore covers monorepo
9338
- * subpackage output like `packages/app/release-assets/` too.
9192
+ * Ensure a Forgejo release exists for a tag **with a real created date**.
9193
+ *
9194
+ * Forgejo stamps a pushed annotated tag's date *asynchronously*; a release
9195
+ * creation that races that stamp freezes the row at the Unix epoch (see
9196
+ * {@link isEpochReleaseDate}) -- a published-but-invisible release, because
9197
+ * `/releases/latest` sorts by date. The failure is intermittent. The repair:
9198
+ *
9199
+ * 1. A real-dated release already exists -> `"exists"`.
9200
+ * 2. Otherwise create the release and read it back. On epoch, delete the frozen
9201
+ * release and wait (increasing backoff) for Forgejo to settle the tag's date,
9202
+ * then retry -- **preserving the pushed (annotated) tag**. The frozen release
9203
+ * is never deleted-and-left-empty.
9204
+ * 3. If the date still never settles and a `target` commit is known, delete the
9205
+ * tag and recreate the release with `target_commitish` so **Forgejo creates
9206
+ * the tag itself**, stamped `now`. The tag becomes lightweight. -> `"repaired"`.
9207
+ * 4. If nothing yields a real date, leave a visible release row (rather than only
9208
+ * an invisible tag) and throw loudly.
9209
+ *
9210
+ * Returns `{ status, release }` -- see {@link EnsureReleaseOutcome}.
9339
9211
  */
9340
- function conditionalEntries(ctx) {
9341
- const entries = [];
9342
- if (hasReleaseAssets(ctx)) entries.push(`${CONVENTION_OUTPUT_DIR}/`);
9343
- return entries;
9344
- }
9345
- /** Normalize a gitignore entry for comparison: strip leading `/` and trailing `/`. */
9346
- function normalizeEntry(entry) {
9347
- let s = entry.trim();
9348
- if (s.startsWith("/")) s = s.slice(1);
9349
- if (s.endsWith("/")) s = s.slice(0, -1);
9350
- return s;
9351
- }
9352
- async function generateGitignore(ctx) {
9353
- const filePath = ".gitignore";
9354
- const allEntries = [
9355
- ...REQUIRED_ENTRIES,
9356
- ...OPTIONAL_ENTRIES,
9357
- ...conditionalEntries(ctx)
9358
- ];
9359
- const existing = ctx.read(filePath);
9360
- if (existing) {
9361
- const existingNormalized = new Set(existing.split("\n").map(normalizeEntry).filter((line) => line.length > 0));
9362
- const missing = allEntries.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
9363
- if (missing.length === 0) return {
9364
- filePath,
9365
- action: "skipped",
9366
- description: "Already has all standard entries"
9367
- };
9368
- const missingRequired = REQUIRED_ENTRIES.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
9369
- const updated = existing.trimEnd() + "\n\n# Added by @bensandee/tooling\n" + missing.join("\n") + "\n";
9370
- ctx.write(filePath, updated);
9371
- if (missingRequired.length === 0) return {
9372
- filePath,
9373
- action: "skipped",
9374
- description: "Only optional entries missing"
9375
- };
9212
+ async function ensureRelease(executor, conn, tag, options = {}) {
9213
+ const attempts = options.attempts ?? SETTLE_ATTEMPTS;
9214
+ const baseDelayMs = options.baseDelayMs ?? SETTLE_BASE_DELAY_MS;
9215
+ const existing = await getReleaseByTag(executor, conn, tag);
9216
+ if (existing && !isEpochReleaseDate(existing.created_at)) {
9217
+ if (options.prerelease === true && existing.prerelease === false) await markReleasePrerelease(executor, conn, existing.id, tag);
9376
9218
  return {
9377
- filePath,
9378
- action: "updated",
9379
- description: `Appended ${String(missing.length)} missing entries`
9219
+ status: "exists",
9220
+ release: releaseRef(existing)
9380
9221
  };
9381
9222
  }
9382
- ctx.write(filePath, allEntries.join("\n") + "\n");
9383
- return {
9384
- filePath,
9385
- action: "created",
9386
- description: "Generated .gitignore"
9223
+ let current = existing;
9224
+ let repairing = false;
9225
+ for (let attempt = 1; attempt <= attempts; attempt++) {
9226
+ if (current && isEpochReleaseDate(current.created_at)) {
9227
+ await deleteRelease(executor, conn, current.id);
9228
+ current = null;
9229
+ repairing = true;
9230
+ await settleBackoff(attempt, baseDelayMs);
9231
+ }
9232
+ try {
9233
+ await createRelease(executor, conn, tag, {
9234
+ target: options.target,
9235
+ prerelease: options.prerelease
9236
+ });
9237
+ } catch (error) {
9238
+ const raced = await getReleaseByTag(executor, conn, tag);
9239
+ if (raced && !isEpochReleaseDate(raced.created_at)) return {
9240
+ status: "race",
9241
+ release: releaseRef(raced)
9242
+ };
9243
+ current = raced;
9244
+ if (attempt >= attempts) break;
9245
+ log.warn(`Release creation attempt ${String(attempt)}/${String(attempts)} failed: ${describeError(error)}`);
9246
+ await settleBackoff(attempt, baseDelayMs);
9247
+ continue;
9248
+ }
9249
+ current = await getReleaseByTag(executor, conn, tag);
9250
+ if (current && !isEpochReleaseDate(current.created_at)) return {
9251
+ status: repairing ? "repaired" : "created",
9252
+ release: releaseRef(current)
9253
+ };
9254
+ if (attempt < attempts) log.warn(`Release for ${tag} came back epoch-dated (attempt ${String(attempt)}/${String(attempts)}) -- waiting for Forgejo to settle the tag's date before retrying`);
9255
+ }
9256
+ const recreated = options.target ? await recreateTagViaApi(executor, conn, tag, {
9257
+ target: options.target,
9258
+ prerelease: options.prerelease,
9259
+ current,
9260
+ attempts,
9261
+ baseDelayMs
9262
+ }) : null;
9263
+ if (recreated) return {
9264
+ status: "repaired",
9265
+ release: releaseRef(recreated)
9387
9266
  };
9267
+ throw new TransientError(`Release for ${tag} never got a real (non-epoch) created date after ${String(attempts)} attempts. This is an intermittent Forgejo bug: a pushed tag's date settles asynchronously and a racing release creation freezes it at the Unix epoch (1969/1970). ` + (options.target ? `Recreating the tag via the API did not help -- investigate the server or upgrade Forgejo.` : `Pass the release tag's target commit so the tag can be recreated, or retry the release.`));
9388
9268
  }
9389
- //#endregion
9390
- //#region src/generators/gitattributes.ts
9391
- const MANAGED_HEADER = ["# Managed by @bensandee/tooling: LF working tree on every platform, overriding", "# core.autocrlf/core.eol at checkout. Rules below this line win over it."].join("\n");
9392
- /** Placed only when prepending above a consumer's existing content -- explains, in the file
9393
- * itself, why the rule goes first rather than last (the code has no other way to show it). */
9394
- const PREPEND_REASON = ["# Prepended above your existing rules: .gitattributes is last-match-wins, so a rule", "# placed below would override any binary/-text marks you already have."].join("\n");
9395
- const RULE_LINE = "* text=auto eol=lf";
9396
- /**
9397
- * True when `content` already carries a rule equivalent to `* text=auto eol=lf`: a
9398
- * non-comment line whose first whitespace-separated token is exactly `*` and whose
9399
- * remaining tokens include both `text=auto` and `eol=lf`, in either order. Token-based so
9400
- * reordered or extra-whitespace variants still count, but a narrower pattern (`*.ts ...`)
9401
- * or a commented-out line does not.
9402
- */
9403
- function hasLfRule(content) {
9404
- return content.split("\n").some((line) => {
9405
- const trimmed = line.trim();
9406
- if (trimmed.startsWith("#")) return false;
9407
- const tokens = trimmed.split(/\s+/).filter(Boolean);
9408
- if (tokens[0] !== "*") return false;
9409
- const rest = new Set(tokens.slice(1));
9410
- return rest.has("text=auto") && rest.has("eol=lf");
9411
- });
9269
+ async function recreateTagViaApi(executor, conn, tag, opts) {
9270
+ const { target, prerelease, current, attempts, baseDelayMs } = opts;
9271
+ log.warn(`Release for ${tag} stayed epoch-dated -- recreating the tag via the API so Forgejo stamps a real date (the tag becomes lightweight)`);
9272
+ if (current) await deleteRelease(executor, conn, current.id);
9273
+ await deleteTag(executor, conn, tag);
9274
+ for (let attempt = 1; attempt <= attempts; attempt++) {
9275
+ try {
9276
+ await createRelease(executor, conn, tag, {
9277
+ target,
9278
+ prerelease
9279
+ });
9280
+ } catch (error) {
9281
+ if (attempt >= attempts) throw new TransientError(`Release for ${tag} was deleted to repair its epoch date, but recreating it from ${target} failed: ${describeError(error)}. The tag no longer exists on the server -- re-push it (or re-run the release) to restore it.`);
9282
+ await settleBackoff(attempt, baseDelayMs);
9283
+ continue;
9284
+ }
9285
+ const recreated = await getReleaseByTag(executor, conn, tag);
9286
+ if (recreated && !isEpochReleaseDate(recreated.created_at)) return recreated;
9287
+ return null;
9288
+ }
9289
+ return null;
9412
9290
  }
9413
9291
  /**
9414
- * Scaffold `.gitattributes` with `* text=auto eol=lf`, the rule that makes the working tree
9415
- * LF regardless of a developer's global `core.autocrlf`/`core.eol`. No managed-file header
9416
- * from `utils/generated-header.ts` -- this file is merged into like `.gitignore`, not
9417
- * wholesale-regenerated. The rule is required, not optional: a missing rule is
9418
- * created/updated and fails `repo:sync --check` like any other drift.
9292
+ * The `{ id, url }` projection of a release row carried back to a caller. A server that omits
9293
+ * `html_url` yields `url: null` -- the id is still the row's identity.
9419
9294
  */
9420
- async function generateGitattributes(ctx) {
9421
- const filePath = ".gitattributes";
9422
- const existing = ctx.read(filePath);
9423
- if (existing === void 0 || existing.trim() === "") {
9424
- ctx.write(filePath, `${MANAGED_HEADER}\n${RULE_LINE}\n`);
9425
- return {
9426
- filePath,
9427
- action: existing === void 0 ? "created" : "updated",
9428
- description: "Generated .gitattributes"
9429
- };
9430
- }
9431
- if (hasLfRule(existing)) return {
9432
- filePath,
9433
- action: "skipped",
9434
- description: "Already has the LF rule"
9435
- };
9436
- const updated = `${MANAGED_HEADER}\n${PREPEND_REASON}\n${RULE_LINE}\n\n${existing}`;
9437
- ctx.write(filePath, updated);
9295
+ function releaseRef(release) {
9438
9296
  return {
9439
- filePath,
9440
- action: "updated",
9441
- description: "Prepended the LF rule"
9297
+ id: release.id,
9298
+ url: release.html_url ?? null
9442
9299
  };
9443
9300
  }
9301
+ /** Exponential backoff (capped) between patient-repair attempts; a non-positive base is a no-op. */
9302
+ async function settleBackoff(attempt, baseDelayMs) {
9303
+ if (baseDelayMs <= 0) return;
9304
+ const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), SETTLE_MAX_DELAY_MS);
9305
+ await new Promise((resolve) => setTimeout(resolve, delay));
9306
+ }
9307
+ /** Upload an asset file to a Forgejo release. */
9308
+ async function uploadReleaseAsset(executor, conn, tag, options) {
9309
+ const encodedTag = encodeURIComponent(tag);
9310
+ const releaseUrl = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/tags/${encodedTag}`;
9311
+ const releaseRes = await executor.fetch(releaseUrl, { headers: { Authorization: `token ${conn.token}` } });
9312
+ if (!releaseRes.ok) throw new TransientError(await forgejoErrorMessage(releaseRes, {
9313
+ verb: "get release",
9314
+ resource: tag
9315
+ }));
9316
+ const parsed = z.object({ id: z.number() }).safeParse(await releaseRes.json());
9317
+ if (!parsed.success) throw new UnexpectedError(`Unexpected release response: ${parsed.error.message}`);
9318
+ const releaseId = parsed.data.id;
9319
+ const uploadUrl = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/${String(releaseId)}/assets?name=${encodeURIComponent(options.assetName)}`;
9320
+ const form = new FormData();
9321
+ form.append("attachment", new Blob([options.fileBuffer]), options.assetName);
9322
+ const res = await executor.fetch(uploadUrl, {
9323
+ method: "POST",
9324
+ headers: { Authorization: `token ${conn.token}` },
9325
+ body: form
9326
+ }, { timeoutMs: LONG_FETCH_TIMEOUT_MS });
9327
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
9328
+ verb: "upload asset",
9329
+ resource: `${options.assetName} to ${tag}`
9330
+ }));
9331
+ }
9444
9332
  //#endregion
9445
- //#region src/release/publish/publish-config-lint.ts
9446
- const FIX = `add "publishConfig": { "registry": "<your registry URL>" } to its package.json`;
9333
+ //#region src/release/publish/version-of-record.ts
9334
+ const FOREIGN_VERSION_FILES = [{
9335
+ file: "Cargo.toml",
9336
+ sections: ["package"]
9337
+ }, {
9338
+ file: "pyproject.toml",
9339
+ sections: ["project", "tool.poetry"]
9340
+ }];
9341
+ /** A static `version = "..."` assignment. `version.workspace = true` is deliberately not a match. */
9342
+ const STATIC_VERSION = /^\s*version\s*=\s*"[^"]*"/;
9447
9343
  /**
9448
- * Whether a value can serve as a publish destination: an absolute http(s) URL.
9449
- * Presence alone is not the test -- a relative or empty registry is as silently
9450
- * wrong as an absent one, and npm resolves neither to the place the author meant.
9344
+ * True when one of the named TOML tables holds a static `version = "..."`. A
9345
+ * dependency-free section scan, not a full TOML parse: each `[table]` header
9346
+ * (or `[[array]]`) opens a section that runs until the next header, so a
9347
+ * `version` under `[tool.poetry.dependencies]` never counts as the package
9348
+ * version. The failure mode is a false negative (a version we don't recognize),
9349
+ * which lands on today's behavior -- no regression.
9451
9350
  */
9452
- function isUsableRegistry(value) {
9453
- if (typeof value !== "string" || value.trim() === "") return false;
9454
- try {
9455
- const url = new URL(value);
9456
- return url.protocol === "https:" || url.protocol === "http:";
9457
- } catch {
9458
- return false;
9351
+ function sectionHasStaticVersion(content, sections) {
9352
+ const wanted = new Set(sections);
9353
+ let current;
9354
+ for (const rawLine of content.split("\n")) {
9355
+ const line = rawLine.trim();
9356
+ const header = /^\[+\s*([^\]]+?)\s*\]+/.exec(line);
9357
+ if (header) {
9358
+ current = header[1];
9359
+ continue;
9360
+ }
9361
+ if (current !== void 0 && wanted.has(current) && STATIC_VERSION.test(line)) return true;
9459
9362
  }
9460
- }
9461
- function isRecord(value) {
9462
- return typeof value === "object" && value !== null && !Array.isArray(value);
9363
+ return false;
9463
9364
  }
9464
9365
  /**
9465
- * Require every package this repo would publish to declare where it publishes.
9466
- *
9467
- * npm and pnpm use one `registry` key for reads and writes alike, so a project
9468
- * `.npmrc` aimed at an install cache silently repoints `pnpm publish` too. A
9469
- * package that never declares its destination inherits one, and an inherited
9470
- * destination can be changed by a file that never mentions publishing.
9366
+ * The filename of a foreign version-of-record (`Cargo.toml`, `pyproject.toml`)
9367
+ * that carries an explicit static version, or `undefined` when none is present.
9471
9368
  *
9472
- * Reports rather than repairs: the correct registry is not derivable here, and
9473
- * writing a guess would redirect a working release exactly the way this gate
9474
- * exists to prevent.
9369
+ * On a `stack: none` repo this is proof that `package.json` is only the bst
9370
+ * harness and the real version lives elsewhere -- the trigger for `release:simple`
9371
+ * to refuse rather than silently bump the wrong file.
9475
9372
  */
9476
- function lintPublishConfig(targetDir, structure) {
9477
- const issues = [];
9478
- for (const pkg of getPublishablePackages(targetDir, structure)) {
9479
- const manifest = readPackageJson(pkg.dir);
9480
- if (!manifest) continue;
9481
- const file = path.relative(targetDir, path.join(pkg.dir, "package.json"));
9482
- const pc = manifest.publishConfig;
9483
- const registry = isRecord(pc) ? pc["registry"] : void 0;
9484
- if (registry === void 0) {
9485
- issues.push({
9486
- kind: "missing",
9487
- package: pkg.name,
9488
- file,
9489
- 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}.`
9490
- });
9373
+ function detectForeignVersionOfRecord(targetDir) {
9374
+ for (const { file, sections } of FOREIGN_VERSION_FILES) {
9375
+ const full = path.join(targetDir, file);
9376
+ if (!existsSync(full)) continue;
9377
+ let content;
9378
+ try {
9379
+ content = readFileSync(full, "utf-8");
9380
+ } catch (_error) {
9491
9381
  continue;
9492
9382
  }
9493
- if (!isUsableRegistry(registry)) issues.push({
9494
- kind: "malformed",
9495
- package: pkg.name,
9496
- file,
9497
- 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}.`
9498
- });
9383
+ if (sectionHasStaticVersion(content, sections)) return file;
9499
9384
  }
9500
- return issues;
9501
9385
  }
9502
9386
  /**
9503
- * The registries this repo's publishable packages actually declare, distinct and in
9504
- * first-seen order.
9505
- *
9506
- * Reuses the same "absolute http(s) URL" narrowing as {@link lintPublishConfig} rather
9507
- * than re-deriving it -- a registry this misses here would fail that gate first, so
9508
- * `repo:sync` never reaches a generator with an undeclared or malformed registry. A
9509
- * caller that invokes a generator directly (or a repo whose publishable set is empty,
9510
- * e.g. every package private) still gets a safe empty result.
9387
+ * The version-of-record conflict `release:simple` refuses on: the filename when
9388
+ * `stack: none` (so `package.json` is provably just the harness) meets a
9389
+ * `releaseStrategy: simple` and a foreign version-of-record file, else
9390
+ * `undefined`. A `javascript` repo (its `package.json` legitimately IS the
9391
+ * version) and an empty `stack: none` repo (no competing file) are both allowed.
9511
9392
  */
9512
- function declaredPublishRegistries(targetDir, structure, rootPackageJson) {
9513
- const registries = [];
9514
- for (const pkg of getPublishablePackages(targetDir, structure, rootPackageJson)) {
9515
- const manifest = structure === "single" ? rootPackageJson ?? readPackageJson(pkg.dir) : readPackageJson(pkg.dir);
9516
- if (!manifest) continue;
9517
- const pc = manifest.publishConfig;
9518
- const registry = isRecord(pc) ? pc["registry"] : void 0;
9519
- if (typeof registry === "string" && isUsableRegistry(registry) && !registries.includes(registry)) registries.push(registry);
9520
- }
9521
- return registries;
9393
+ function releaseTargetConflict(stack, releaseStrategy, targetDir) {
9394
+ if (stack !== "none" || releaseStrategy !== "simple") return void 0;
9395
+ return detectForeignVersionOfRecord(targetDir);
9522
9396
  }
9523
9397
  //#endregion
9524
- //#region src/generators/mise-pin.ts
9398
+ //#region src/release/publish/version-stamp.ts
9399
+ function splitLines(content) {
9400
+ const trailingNewline = content.endsWith("\n");
9401
+ return {
9402
+ lines: (trailingNewline ? content.slice(0, -1) : content).split("\n"),
9403
+ trailingNewline
9404
+ };
9405
+ }
9406
+ function joinLines({ lines, trailingNewline }) {
9407
+ return lines.join("\n") + (trailingNewline ? "\n" : "");
9408
+ }
9409
+ function stripCr(line) {
9410
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
9411
+ }
9412
+ /** `prefix + version + suffix` -- the exact line a matching entry is replaced with. */
9413
+ function expectedStampLine(entry, version) {
9414
+ return entry.prefix + version + (entry.suffix ?? "");
9415
+ }
9525
9416
  /**
9526
- * The mise version and release-asset digest the fleet's CI and the agent runner base image agree
9527
- * to run. This is the one place that agreement is recorded; `packages/agent/test/runner-base-image-dockerfile.test.ts`
9528
- * (task 8 of the mise-toolchain-provisioning plan) reads both constants back through
9529
- * `readRepoFile` and asserts the base image's own `ARG MISE_VERSION` and `mise.sha256` manifest
9530
- * agree with them, so the two packages cannot drift without a failing test on either side.
9531
- *
9532
- * Each constant sits under its own `// renovate:` marker comment, in the three-line grammar
9533
- * `packages/agent/docker/agent-goose-image/goose.sha256` uses for its own digest line, so the same
9534
- * shape of custom regex manager in the root `renovate.json` can rewrite both this version and this
9535
- * digest (`customManagers` entries scoped to this file).
9417
+ * Indexes (into `content.split("\n")`, post trailing-newline removal) of every line that
9418
+ * starts with `entry.prefix`, ends with `entry.suffix` (default: none), and is at least
9419
+ * `prefix.length + suffix.length` characters long so the two cannot overlap on a
9420
+ * degenerate line. Compared against each line with a trailing `\r` stripped.
9536
9421
  */
9537
- const MISE_VERSION = "2026.10.2";
9538
- const MISE_LINUX_X64_SHA256 = "8f5f6660336f572830e33cd9b378d3131e529a0d4c4f0c553776be90a1ba302a";
9539
- /** Where generated Forgejo workflows fetch the shared mise setup action: a composite action in
9540
- * `bensandee/common`, on the forge every fleet repo lives on. GitHub cannot reach it; the
9541
- * generators keep inline steps there. */
9542
- const SETUP_MISE_ACTION_URL = "https://code.orangebikelabs.com/bensandee/common/actions/setup-mise";
9543
- /** The tag in `bensandee/common` that marks the current release of the action. Generated `uses:`
9544
- * lines carry it as their comment, and Renovate moves the SHA below to wherever it points. */
9545
- const SETUP_MISE_ACTION_TAG = "setup-mise@latest";
9546
- const SETUP_MISE_ACTION_SHA = "db99e4e9ee46bfeaca2146e7ae55cc700b4c23bf";
9547
- //#endregion
9548
- //#region src/generators/ci-utils.ts
9549
- /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
9550
- function actionsExpr(expr) {
9551
- return `\${{ ${expr} }}`;
9422
+ function matchingLineIndexes(content, entry) {
9423
+ const suffix = entry.suffix ?? "";
9424
+ const minLength = entry.prefix.length + suffix.length;
9425
+ const { lines } = splitLines(content);
9426
+ const indexes = [];
9427
+ lines.forEach((rawLine, index) => {
9428
+ const line = stripCr(rawLine);
9429
+ if (line.startsWith(entry.prefix) && line.endsWith(suffix) && line.length >= minLength) indexes.push(index);
9430
+ });
9431
+ return indexes;
9552
9432
  }
9553
- const MAIN_BRANCH_REF = "refs/heads/main";
9554
- /** `if:` condition that gates a step to push events on the default branch. */
9555
- const IS_MAIN_BRANCH = `github.ref == '${MAIN_BRANCH_REF}'`;
9556
9433
  /**
9557
- * Negated form for concurrency `cancel-in-progress` and similar uses.
9558
- *
9559
- * Main is exempt from cancellation on purpose, and the exemption stays: a main run publishes and
9560
- * pushes tags, and cancelling one mid-flight can leave a partial publish -- some packages on the
9561
- * registry, no matching tags, and no run left alive to finish. A superseded feature-branch run
9562
- * wastes minutes; a half-published release costs a manual unwind.
9563
- *
9564
- * So several main runs can be in flight at once, each versioning from its own triggering commit.
9565
- * Overlap safety rests on one invariant: **main advances solely via commits that trigger a CI
9566
- * run.** The `[skip ci]` coverage, health, and egress records go to `bst/*` orphan branches and
9567
- * never to main; a `[skip ci]` commit pushed straight to main would leave a superseded run skipping
9568
- * and its changesets stranded until the next push, which is why the supersession guard skips rather
9569
- * than fails. Three guards carry the overlap itself: supersession plus a `--force-with-lease`
9570
- * version push (`release/publish/version.ts`), the `headBumpedVersion` publish guard plus reading
9571
- * remote tags and releases before acting (`commands/release-changesets.ts`,
9572
- * `release/publish/publish.ts`), and floating git and Docker tags that only move forward
9573
- * (`release/publish/floating-tags.ts`, `release/docker.ts`). Such a run may also version pending
9574
- * changesets after it publishes -- the stale version-PR merge -- under the same supersession guard
9575
- * and push lease as any other version run. A later commit publishes a bump only when its tag is
9576
- * missing and every main run since the package's last release tag has settled
9577
- * (`release/publish/unpublished-bump.ts`), so no run that could still publish it is in flight; a
9578
- * manual re-run of one of those runs is the overlap left. On Forgejo the concurrency block is
9579
- * only a PR-run cancellation mechanism -- it holds a main run in its group and, in Forgejo 16,
9580
- * intermittently fails to release it, which is why main runs get a group of their own there. GitHub's shape stays shared
9581
- * because its queue works: one run, one pending, a newer arrival replacing the pending, so
9582
- * `changesets/action@v1` never needs a guard of its own. Any new side effect in a main run must be
9583
- * idempotent under overlap or be listed here with the guard that makes it so.
9434
+ * `undefined` when exactly one line in `content` matches `entry`; otherwise the
9435
+ * user-facing sentence describing the zero- or many-match failure, naming the file, the
9436
+ * prefix, and the fix (a more specific prefix, or a second `versionStamp` entry). Both the
9437
+ * `release:assets` stamp phase and the `repo:sync` static gate report through this one
9438
+ * function so the two messages cannot drift apart.
9584
9439
  */
9585
- const IS_NOT_MAIN_BRANCH = `github.ref != '${MAIN_BRANCH_REF}'`;
9440
+ function describeStampProblem(content, entry) {
9441
+ const indexes = matchingLineIndexes(content, entry);
9442
+ if (indexes.length === 1) return void 0;
9443
+ const suffixClause = entry.suffix === void 0 ? "" : ` and suffix ${JSON.stringify(entry.suffix)}`;
9444
+ const prefixClause = `prefix ${JSON.stringify(entry.prefix)}${suffixClause}`;
9445
+ if (indexes.length === 0) return `${entry.file}: no line matches ${prefixClause}. Fix the prefix/suffix to match the file's actual content.`;
9446
+ return `${entry.file}: ${indexes.length} lines match ${prefixClause}, which is ambiguous. Use a more specific prefix, or split this into a second versionStamp entry.`;
9447
+ }
9448
+ /**
9449
+ * Replaces the single line in `content` matching `entry` with `expectedStampLine(entry,
9450
+ * version)`, preserving that line's trailing `\r` and the file's trailing newline (or its
9451
+ * absence). Idempotent: stamping an already-stamped line reproduces the file byte for
9452
+ * byte. Precondition: `describeStampProblem(content, entry)` is `undefined` -- callers
9453
+ * must check that first, since this indexes the sole match directly.
9454
+ */
9455
+ function stampContent(content, entry, version) {
9456
+ const split = splitLines(content);
9457
+ const [index] = matchingLineIndexes(content, entry);
9458
+ const hasCr = split.lines[index].endsWith("\r");
9459
+ const replacement = expectedStampLine(entry, version);
9460
+ split.lines[index] = hasCr ? `${replacement}\r` : replacement;
9461
+ return joinLines(split);
9462
+ }
9463
+ //#endregion
9464
+ //#region src/release/publish/assets.ts
9465
+ const CONVENTION_SCRIPT = "build:release-assets";
9466
+ const CONVENTION_OUTPUT_DIR = "release-assets";
9467
+ function configuredTargets(cwd, toolingConfig) {
9468
+ return (toolingConfig?.releaseAssets ?? []).map((asset) => ({
9469
+ commandCwd: cwd,
9470
+ command: asset.command,
9471
+ files: [{
9472
+ path: path.resolve(cwd, asset.file),
9473
+ ...asset.name && { name: asset.name }
9474
+ }]
9475
+ }));
9476
+ }
9477
+ function conventionTargets(executor, cwd) {
9478
+ const targets = [];
9479
+ const checkDir = (relDir) => {
9480
+ const absDir = path.resolve(cwd, relDir);
9481
+ const raw = executor.readFile(path.join(absDir, "package.json"));
9482
+ if (!raw) return;
9483
+ if (!parsePackageJson(raw)?.scripts?.[CONVENTION_SCRIPT]) return;
9484
+ targets.push({
9485
+ commandCwd: absDir,
9486
+ command: [
9487
+ "pnpm",
9488
+ "run",
9489
+ CONVENTION_SCRIPT
9490
+ ],
9491
+ files: [],
9492
+ collectFromDir: path.join(absDir, CONVENTION_OUTPUT_DIR)
9493
+ });
9494
+ };
9495
+ checkDir(".");
9496
+ for (const dir of executor.listPackageDirs(cwd)) checkDir(path.join("packages", dir));
9497
+ return targets;
9498
+ }
9586
9499
  /**
9587
- * Job-level `env:` block that evaluates the default-branch check once, so steps
9588
- * can gate on `env.IS_MAIN` instead of repeating the `github.ref` expression.
9500
+ * The release version `versionStamp` writes into each configured file: the root
9501
+ * `package.json#version`, the same source `release:simple` reads. A missing file, unparseable
9502
+ * JSON, and a manifest with no `version` are all rejected -- a missing version is the "ships
9503
+ * 0.0.0" failure this design exists to catch, never a skip.
9589
9504
  */
9590
- const IS_MAIN_JOB_ENV = { IS_MAIN: actionsExpr(IS_MAIN_BRANCH) };
9591
- /** `if:` condition referencing the job-level `IS_MAIN` env var (see {@link IS_MAIN_JOB_ENV}). */
9592
- const IS_MAIN_ENV_GUARD = "env.IS_MAIN == 'true'";
9505
+ function resolveStampVersion(executor, cwd) {
9506
+ const pkgPath = path.join(cwd, "package.json");
9507
+ const raw = executor.readFile(pkgPath);
9508
+ if (raw === null) throw new FatalError(`release:assets could not read package.json at ${pkgPath} to resolve the version for versionStamp.`);
9509
+ const pkg = parsePackageJson(raw);
9510
+ if (!pkg?.version) throw new FatalError(`release:assets could not find a "version" field in package.json at ${pkgPath}. versionStamp needs a version to stamp.`);
9511
+ return pkg.version;
9512
+ }
9593
9513
  /**
9594
- * npm's own registry-key rule for an `.npmrc` auth line: strip the scheme, keep
9595
- * host/port/path, and end with exactly one slash. `actions/setup-node` derives the
9596
- * same key from a registry URL; this mirrors that so a hand-written line matches
9597
- * what npm looks up at publish time -- npm-registry-fetch walks the request path
9598
- * upward through ancestor keys until one matches.
9514
+ * Stamps every `versionStamp` entry with `version`, before any build command runs. Throws
9515
+ * `describeStampProblem`'s message on a zero- or many-match config, so the two consumers
9516
+ * (this phase and `repo:sync`'s static gate) cannot report the failure differently.
9599
9517
  */
9600
- function npmrcAuthKey(registryUrl) {
9601
- return `${registryUrl.replace(/^https?:/i, "").replace(/\/+$/, "")}/`;
9518
+ function stampVersionFiles(executor, cwd, toolingConfig, version) {
9519
+ const entries = toolingConfig?.versionStamp ?? [];
9520
+ const stamped = [];
9521
+ for (const entry of entries) {
9522
+ const filePath = path.resolve(cwd, entry.file);
9523
+ const content = executor.readFile(filePath);
9524
+ if (content === null) throw new FatalError(`versionStamp file not found: ${entry.file} (resolved to ${filePath})`);
9525
+ const problem = describeStampProblem(content, entry);
9526
+ if (problem) throw new FatalError(`versionStamp: ${problem}`);
9527
+ const expectedLine = expectedStampLine(entry, version);
9528
+ executor.writeFile(filePath, stampContent(content, entry, version));
9529
+ log$1.info(`Stamped ${entry.file}: ${expectedLine}`);
9530
+ stamped.push({
9531
+ path: filePath,
9532
+ expectedLine
9533
+ });
9534
+ }
9535
+ return stamped;
9602
9536
  }
9603
- const NODE_AUTH_TOKEN_VAR = ["$", "{NODE_AUTH_TOKEN}"].join("");
9604
9537
  /**
9605
- * Build both halves of a job's npm auth wiring together, so one value decides
9606
- * them: the auth lines the publishing step writes, and the step-level
9607
- * `NODE_AUTH_TOKEN` it carries. A job that gets none of the declared registries
9608
- * gets neither -- including a `publishes: true` job whose publishable set is
9609
- * empty (every package private), since there is then no npmrc line for a real
9610
- * secret to pair with.
9611
- *
9612
- * The lines go into a temp userconfig exported only inside the publishing
9613
- * step's shell, never `~/.npmrc` or `$GITHUB_ENV`: pnpm 11 warns
9614
- * `Failed to replace env in config` on every call that reads the placeholder
9615
- * while the token is unset or empty, so a line in a shared file makes install, `ci:check`,
9616
- * and every step after the publish warn. An empty job-level default does not
9617
- * silence it, and `${NODE_AUTH_TOKEN:-}` is rejected as an empty fallback and
9618
- * warns in every case. The `-n` guard keeps a run without the secret quiet; the
9619
- * single quotes keep the token itself off disk, since npm expands it at read time.
9538
+ * Re-reads each stamped file and requires exactly one line equal to its `expectedLine`. Runs
9539
+ * after the build loop -- the only failure this read-back can catch that the stamp phase did not
9540
+ * already is the build overwriting the stamp (a `build:release-assets` script that regenerates or
9541
+ * copies over the stamped file).
9620
9542
  */
9621
- function npmAuthWiring(ctx, publishes) {
9622
- const registries = publishes ? declaredPublishRegistries(ctx.targetDir, ctx.config.structure, ctx.packageJson) : [];
9623
- if (registries.length === 0) return {
9624
- publishRun: (command) => command,
9625
- tokenEnv: {}
9626
- };
9627
- const setup = [
9628
- "if [ -n \"$NODE_AUTH_TOKEN\" ]; then",
9629
- " NPM_CONFIG_USERCONFIG=\"$(mktemp)\"",
9630
- " export NPM_CONFIG_USERCONFIG",
9631
- " if [ -f ~/.npmrc ]; then cat ~/.npmrc > \"$NPM_CONFIG_USERCONFIG\"; fi",
9632
- ...registries.map((registry) => ` printf '\\n%s\\n' '${npmrcAuthKey(registry)}:_authToken=${NODE_AUTH_TOKEN_VAR}' >> "$NPM_CONFIG_USERCONFIG"`),
9633
- "fi"
9634
- ];
9635
- return {
9636
- publishRun: (command) => [...setup, command].join("\n"),
9637
- tokenEnv: { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
9638
- };
9543
+ function verifyStamp(executor, stamped) {
9544
+ for (const { path: filePath, expectedLine } of stamped) {
9545
+ const matches = (executor.readFile(filePath) ?? "").split("\n").filter((line) => (line.endsWith("\r") ? line.slice(0, -1) : line) === expectedLine).length;
9546
+ if (matches !== 1) throw new FatalError(`versionStamp verify failed for ${filePath}: expected exactly one line "${expectedLine}" after the build, found ${matches}. The build likely overwrote the stamp -- check for a script that regenerates or copies over this file.`);
9547
+ }
9639
9548
  }
9640
- const MISE_RELEASE_URL = `https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-x64`;
9641
9549
  /**
9642
- * The node script `inlineMiseSteps` pipes `mise env --json` into, run under the mise it just
9643
- * installed. Reads the env map off stdin (mise exec passes it straight through) and exports every
9644
- * key except `PATH` into `$GITHUB_ENV` in heredoc form -- the form that survives any value,
9645
- * including one containing `$` or quotes -- then appends mise's shim directory and the
9646
- * `~/.local/bin` install location to `$GITHUB_PATH` so every later step sees node, pnpm, and any
9647
- * other declared tool on PATH with no prefix.
9550
+ * The whole stamp phase, gated on a non-empty `versionStamp`: refuse a foreign
9551
+ * version-of-record, resolve the release version, and stamp every entry. Returns `[]`
9552
+ * untouched (no real-filesystem read, no executor read) when the repo configures no
9553
+ * `versionStamp`, so no existing call path changes behaviour.
9648
9554
  */
9649
- const MISE_ENV_EXPORT_SCRIPT = [
9650
- "const fs = require(\"fs\");",
9651
- "const env = JSON.parse(fs.readFileSync(0, \"utf8\"));",
9652
- "let out = \"\";",
9653
- "for (const [key, value] of Object.entries(env)) {",
9654
- " if (key === \"PATH\") continue;",
9655
- " out += key + \"<<BST_MISE_ENV_EOF\\n\" + value + \"\\nBST_MISE_ENV_EOF\\n\";",
9656
- "}",
9657
- "fs.appendFileSync(process.env.GITHUB_ENV, out);",
9658
- "fs.appendFileSync(",
9659
- " process.env.GITHUB_PATH,",
9660
- " process.env.HOME + \"/.local/share/mise/shims\\n\" + process.env.HOME + \"/.local/bin\\n\",",
9661
- ");"
9662
- ].join("\n");
9663
- /** A fresh object per call: one shared object in two jobs would serialize as a YAML alias. */
9664
- function pnpmStoreCacheStep() {
9665
- return { step: {
9666
- name: "Cache pnpm store",
9667
- uses: "actions/cache@v4",
9668
- with: {
9669
- path: "~/.local/share/pnpm/store",
9670
- key: `pnpm-${actionsExpr("runner.os")}-${actionsExpr("hashFiles('pnpm-lock.yaml')")}`
9555
+ function runVersionStampPhase(executor, config) {
9556
+ if ((config.toolingConfig?.versionStamp ?? []).length === 0) return [];
9557
+ const conflict = detectForeignVersionOfRecord(config.cwd);
9558
+ if (conflict) throw new FatalError(`release:assets stamps package.json's version into versionStamp files, but this repo's version-of-record is ${conflict}. A stamp would carry a version the ${conflict} artifact never gets. Release this repo through its own toolchain, or remove the versionStamp block.`);
9559
+ const version = resolveStampVersion(executor, config.cwd);
9560
+ return stampVersionFiles(executor, config.cwd, config.toolingConfig, version);
9561
+ }
9562
+ function runBuild(executor, target, config) {
9563
+ if (!target.command || target.command.length === 0) return;
9564
+ const [file, ...args] = target.command;
9565
+ if (!file) return;
9566
+ const label = target.commandCwd === config.cwd ? "root" : path.relative(config.cwd, target.commandCwd);
9567
+ log$1.info(`Building assets in ${label}: ${target.command.join(" ")}`);
9568
+ executor.execInherit(file, args, { cwd: target.commandCwd });
9569
+ debug(config, `Asset build completed for ${label}`);
9570
+ }
9571
+ function collectFromDir(executor, target) {
9572
+ if (!target.collectFromDir) return;
9573
+ const before = target.files.length;
9574
+ for (const entry of executor.listFiles(target.collectFromDir)) target.files.push({ path: path.join(target.collectFromDir, entry) });
9575
+ if (target.files.length === before) throw new FatalError(`${CONVENTION_SCRIPT} ran in ${target.commandCwd} but emitted no files to ${target.collectFromDir}. Update your script to write artifacts there, or declare assets explicitly via releaseAssets in .tooling.json.`);
9576
+ }
9577
+ function uploadToGitHub(executor, config, tag, asset) {
9578
+ const assetName = asset.name ?? path.basename(asset.path);
9579
+ const result = executor.exec("gh", [
9580
+ "release",
9581
+ "upload",
9582
+ tag,
9583
+ asset.path,
9584
+ "--clobber"
9585
+ ], { cwd: config.cwd });
9586
+ debugExec(config, `gh release upload ${tag} ${assetName}`, result);
9587
+ if (result.exitCode !== 0) throw new FatalError(`GitHub release upload failed for ${assetName}: ${result.stderr || result.stdout}`);
9588
+ log$1.info(`Uploaded ${assetName} to ${tag} (GitHub)`);
9589
+ }
9590
+ async function uploadToForgejo(executor, conn, tag, asset) {
9591
+ const assetName = asset.name ?? path.basename(asset.path);
9592
+ const fileBuffer = executor.readFileBuffer(asset.path);
9593
+ if (!fileBuffer) throw new FatalError(`Asset file not found: ${asset.path}`);
9594
+ await uploadReleaseAsset(executor, conn, tag, {
9595
+ assetName,
9596
+ fileBuffer
9597
+ });
9598
+ log$1.info(`Uploaded ${assetName} to ${tag} (Forgejo)`);
9599
+ }
9600
+ async function runReleaseAssets(executor, config) {
9601
+ debug(config, `Config: dryRun=${String(config.dryRun)} cwd=${config.cwd}`);
9602
+ const targets = [...configuredTargets(config.cwd, config.toolingConfig), ...conventionTargets(executor, config.cwd)];
9603
+ const stampedFiles = runVersionStampPhase(executor, config);
9604
+ for (const target of targets) {
9605
+ runBuild(executor, target, config);
9606
+ collectFromDir(executor, target);
9607
+ }
9608
+ verifyStamp(executor, stampedFiles);
9609
+ if (targets.length === 0) {
9610
+ log$1.info(`No release assets configured in .tooling.json or by convention (${CONVENTION_SCRIPT} script)`);
9611
+ return;
9612
+ }
9613
+ if (config.dryRun) {
9614
+ log$1.info("[dry-run] Skipping upload");
9615
+ return;
9616
+ }
9617
+ if (!config.platform) throw new FatalError("Platform connection is required for asset upload");
9618
+ const tagResult = executor.exec("git", [
9619
+ "tag",
9620
+ "--points-at",
9621
+ "HEAD"
9622
+ ], { cwd: config.cwd });
9623
+ debugExec(config, "git tag --points-at HEAD", tagResult);
9624
+ if (tagResult.exitCode !== 0) throw new FatalError("Could not determine current tags");
9625
+ const tags = tagResult.stdout.trim().split("\n").filter(Boolean);
9626
+ if (tags.length === 0) {
9627
+ log$1.info("No tags pointing to HEAD, skipping upload");
9628
+ return;
9629
+ }
9630
+ let uploaded = 0;
9631
+ for (const tag of tags) {
9632
+ if (!await releaseExists(executor, config, tag)) {
9633
+ log$1.info(`No release found for ${tag}, skipping (likely a sliding tag)`);
9634
+ continue;
9671
9635
  }
9672
- } };
9636
+ log$1.info(`Uploading assets to release ${tag}`);
9637
+ for (const target of targets) for (const asset of target.files) if (config.platform.type === "github") uploadToGitHub(executor, config, tag, asset);
9638
+ else await uploadToForgejo(executor, config.platform.conn, tag, asset);
9639
+ uploaded++;
9640
+ }
9641
+ if (uploaded === 0) log$1.info("No releases found for any tag pointing to HEAD, skipping upload");
9673
9642
  }
9674
- /** Actions whose ref the generator pins itself, so `preservePinnedActions` must not keep a
9675
- * repo's older ref for them. */
9676
- const GENERATOR_OWNED_ACTIONS = [{
9677
- name: SETUP_MISE_ACTION_URL,
9678
- comment: SETUP_MISE_ACTION_TAG
9679
- }];
9643
+ async function releaseExists(executor, config, tag) {
9644
+ if (!config.platform) return false;
9645
+ if (config.platform.type === "forgejo") return findRelease(executor, config.platform.conn, tag);
9646
+ const result = executor.exec("gh", [
9647
+ "release",
9648
+ "view",
9649
+ tag
9650
+ ], { cwd: config.cwd });
9651
+ debugExec(config, `gh release view ${tag}`, result);
9652
+ return result.exitCode === 0;
9653
+ }
9654
+ //#endregion
9655
+ //#region src/generators/gitignore.ts
9656
+ /** Entries that every project should have -- repo:sync --check flags these as missing. */
9657
+ const REQUIRED_ENTRIES = [
9658
+ "node_modules/",
9659
+ ".pnpm-store/",
9660
+ "dist/",
9661
+ "*.tsbuildinfo",
9662
+ ".env",
9663
+ ".env.*",
9664
+ "!.env.example"
9665
+ ];
9680
9666
  /**
9681
- * The steps every workflow generator uses in place of `pnpm/action-setup` + `actions/setup-node`:
9682
- * install the pinned `mise`, `mise install` the repo's `mise.toml` toolchain, export its env into
9683
- * the rest of the job, and cache the pnpm store. `MISE_VERSION`/`MISE_LINUX_X64_SHA256`
9684
- * (`mise-pin.ts`) are the same pin the agent runner's base image installs, checked by
9685
- * `runner-base-image-dockerfile.test.ts`.
9667
+ * Tooling-specific entries added during init/update but not required for repo:sync --check.
9686
9668
  *
9687
- * On Forgejo the mise half is the shared composite action in `bensandee/common`, pinned by commit.
9688
- * GitHub resolves actions only from github.com, so it keeps the same steps inline.
9669
+ * `.superpowers/` is agent scratch state (task briefs, review diffs, the SDD progress ledger)
9670
+ * that the superpowers plugin writes into whatever repo it runs in. The plugin's own protection
9671
+ * is a self-ignoring `.superpowers/sdd/.gitignore` it creates as a side effect of running a
9672
+ * skill -- untracked, so it never reaches a clone, and scoped to `sdd/`, so any sibling folder a
9673
+ * future skill writes lands untracked-but-not-ignored and one `git add -A` commits it. Ignore
9674
+ * the whole directory here for the same reason as `.tooling-archived/`: it is tool-generated
9675
+ * scratch that appears without the user asking, so the tool should be what ignores it.
9689
9676
  */
9690
- function miseSetupSteps(ci) {
9691
- if (ci !== "forgejo") return inlineMiseSteps();
9692
- return [{ step: {
9693
- name: "Set up mise",
9694
- uses: `${SETUP_MISE_ACTION_URL}@${SETUP_MISE_ACTION_SHA}`,
9695
- with: {
9696
- version: MISE_VERSION,
9697
- sha256: MISE_LINUX_X64_SHA256
9698
- }
9699
- } }, pnpmStoreCacheStep()];
9700
- }
9677
+ const OPTIONAL_ENTRIES = [
9678
+ ".tooling-migrate.md",
9679
+ ".tooling-archived/",
9680
+ ".claude/hooks/bst-hook.audit.local.jsonl*",
9681
+ ".superpowers/",
9682
+ "coverage/"
9683
+ ];
9701
9684
  /**
9702
- * The inline form of the shared action, for GitHub. Uses `mise env --json` piped into the
9703
- * just-installed mise's own `node -e`, not `mise env --shell bash`: the JSON form is
9704
- * shell-independent and the interpreter reading it is guaranteed to exist (mise just installed
9705
- * it), so this needs no assumption about which shell the runner's `run:` steps use.
9685
+ * Optional entries added only when a feature is actually configured.
9686
+ * Patterns are written without a leading slash so they match at any depth --
9687
+ * a single `release-assets/` entry in the root .gitignore covers monorepo
9688
+ * subpackage output like `packages/app/release-assets/` too.
9706
9689
  */
9707
- function inlineMiseSteps() {
9708
- const installScript = [
9709
- "curl -fsSL -o \"$RUNNER_TEMP/mise\" " + MISE_RELEASE_URL,
9710
- `echo "${MISE_LINUX_X64_SHA256} $RUNNER_TEMP/mise" | sha256sum -c`,
9711
- "mkdir -p \"$HOME/.local/bin\" && install -m 0755 \"$RUNNER_TEMP/mise\" \"$HOME/.local/bin/mise\"",
9712
- "export MISE_TRUSTED_CONFIG_PATHS=\"$GITHUB_WORKSPACE\"",
9713
- "export MISE_YES=1",
9714
- "\"$HOME/.local/bin/mise\" install",
9715
- `"$HOME/.local/bin/mise" env --json | "$HOME/.local/bin/mise" exec -- node -e '${MISE_ENV_EXPORT_SCRIPT}'`
9716
- ].join("\n");
9717
- return [
9718
- { step: {
9719
- name: "Cache mise tools",
9720
- uses: "actions/cache@v4",
9721
- with: {
9722
- path: "~/.local/share/mise",
9723
- key: `mise-${actionsExpr("runner.os")}-${actionsExpr("hashFiles('mise.toml')")}`
9724
- }
9725
- } },
9726
- pnpmStoreCacheStep(),
9727
- { step: {
9728
- name: "Install mise",
9729
- env: { GITHUB_TOKEN: "" },
9730
- run: installScript
9731
- } }
9690
+ function conditionalEntries(ctx) {
9691
+ const entries = [];
9692
+ if (hasReleaseAssets(ctx)) entries.push(`${CONVENTION_OUTPUT_DIR}/`);
9693
+ return entries;
9694
+ }
9695
+ /** Normalize a gitignore entry for comparison: strip leading `/` and trailing `/`. */
9696
+ function normalizeEntry(entry) {
9697
+ let s = entry.trim();
9698
+ if (s.startsWith("/")) s = s.slice(1);
9699
+ if (s.endsWith("/")) s = s.slice(0, -1);
9700
+ return s;
9701
+ }
9702
+ async function generateGitignore(ctx) {
9703
+ const filePath = ".gitignore";
9704
+ const allEntries = [
9705
+ ...REQUIRED_ENTRIES,
9706
+ ...OPTIONAL_ENTRIES,
9707
+ ...conditionalEntries(ctx)
9732
9708
  ];
9709
+ const existing = ctx.read(filePath);
9710
+ if (existing) {
9711
+ const existingNormalized = new Set(existing.split("\n").map(normalizeEntry).filter((line) => line.length > 0));
9712
+ const missing = allEntries.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
9713
+ if (missing.length === 0) return {
9714
+ filePath,
9715
+ action: "skipped",
9716
+ description: "Already has all standard entries"
9717
+ };
9718
+ const missingRequired = REQUIRED_ENTRIES.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
9719
+ const updated = existing.trimEnd() + "\n\n# Added by @bensandee/tooling\n" + missing.join("\n") + "\n";
9720
+ ctx.write(filePath, updated);
9721
+ if (missingRequired.length === 0) return {
9722
+ filePath,
9723
+ action: "skipped",
9724
+ description: "Only optional entries missing"
9725
+ };
9726
+ return {
9727
+ filePath,
9728
+ action: "updated",
9729
+ description: `Appended ${String(missing.length)} missing entries`
9730
+ };
9731
+ }
9732
+ ctx.write(filePath, allEntries.join("\n") + "\n");
9733
+ return {
9734
+ filePath,
9735
+ action: "created",
9736
+ description: "Generated .gitignore"
9737
+ };
9733
9738
  }
9739
+ //#endregion
9740
+ //#region src/generators/gitattributes.ts
9741
+ const MANAGED_HEADER = ["# Managed by @bensandee/tooling: LF working tree on every platform, overriding", "# core.autocrlf/core.eol at checkout. Rules below this line win over it."].join("\n");
9742
+ /** Placed only when prepending above a consumer's existing content -- explains, in the file
9743
+ * itself, why the rule goes first rather than last (the code has no other way to show it). */
9744
+ const PREPEND_REASON = ["# Prepended above your existing rules: .gitattributes is last-match-wins, so a rule", "# placed below would override any binary/-text marks you already have."].join("\n");
9745
+ const RULE_LINE = "* text=auto eol=lf";
9734
9746
  /**
9735
- * Build the docker publish env block.
9736
- *
9737
- * The LHS env-var names (`DOCKER_USERNAME` / `DOCKER_PASSWORD`) match what
9738
- * publish-docker.ts reads at runtime. The RHS secret references are
9739
- * registry-specific (see {@link dockerSecretNames}) so downstream repos can
9740
- * stop relying on the user/org-level `DOCKER_USERNAME` / `DOCKER_PASSWORD`
9741
- * secrets -- which the Forgejo act fork auto-injects into every `container:`
9742
- * image pull regardless of the target registry.
9743
- *
9744
- * When `host` is `null` (couldn't be derived at gen time), the secret references
9745
- * fall back to neutral `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` and the
9746
- * workflow keeps `DOCKER_REGISTRY_HOST` / `DOCKER_REGISTRY_NAMESPACE` so the CLI
9747
- * resolves the registry at runtime.
9747
+ * True when `content` already carries a rule equivalent to `* text=auto eol=lf`: a
9748
+ * non-comment line whose first whitespace-separated token is exactly `*` and whose
9749
+ * remaining tokens include both `text=auto` and `eol=lf`, in either order. Token-based so
9750
+ * reordered or extra-whitespace variants still count, but a narrower pattern (`*.ts ...`)
9751
+ * or a commented-out line does not.
9748
9752
  */
9749
- function dockerPublishEnv(host) {
9750
- const { username, password } = dockerSecretNames(host);
9753
+ function hasLfRule(content) {
9754
+ return content.split("\n").some((line) => {
9755
+ const trimmed = line.trim();
9756
+ if (trimmed.startsWith("#")) return false;
9757
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
9758
+ if (tokens[0] !== "*") return false;
9759
+ const rest = new Set(tokens.slice(1));
9760
+ return rest.has("text=auto") && rest.has("eol=lf");
9761
+ });
9762
+ }
9763
+ /**
9764
+ * Scaffold `.gitattributes` with `* text=auto eol=lf`, the rule that makes the working tree
9765
+ * LF regardless of a developer's global `core.autocrlf`/`core.eol`. No managed-file header
9766
+ * from `utils/generated-header.ts` -- this file is merged into like `.gitignore`, not
9767
+ * wholesale-regenerated. The rule is required, not optional: a missing rule is
9768
+ * created/updated and fails `repo:sync --check` like any other drift.
9769
+ */
9770
+ async function generateGitattributes(ctx) {
9771
+ const filePath = ".gitattributes";
9772
+ const existing = ctx.read(filePath);
9773
+ if (existing === void 0 || existing.trim() === "") {
9774
+ ctx.write(filePath, `${MANAGED_HEADER}\n${RULE_LINE}\n`);
9775
+ return {
9776
+ filePath,
9777
+ action: existing === void 0 ? "created" : "updated",
9778
+ description: "Generated .gitattributes"
9779
+ };
9780
+ }
9781
+ if (hasLfRule(existing)) return {
9782
+ filePath,
9783
+ action: "skipped",
9784
+ description: "Already has the LF rule"
9785
+ };
9786
+ const updated = `${MANAGED_HEADER}\n${PREPEND_REASON}\n${RULE_LINE}\n\n${existing}`;
9787
+ ctx.write(filePath, updated);
9751
9788
  return {
9752
- ...host === null ? {
9753
- DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
9754
- DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE")
9755
- } : {},
9756
- DOCKER_USERNAME: actionsExpr(`secrets.${username}`),
9757
- DOCKER_PASSWORD: actionsExpr(`secrets.${password}`),
9758
- RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
9789
+ filePath,
9790
+ action: "updated",
9791
+ description: "Prepended the LF rule"
9759
9792
  };
9760
9793
  }
9761
9794
  //#endregion
@@ -9952,21 +9985,36 @@ function buildSteps(strategy, ctx) {
9952
9985
  default: return null;
9953
9986
  }
9954
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
+ }
9955
10003
  async function generateReleaseCi(ctx) {
9956
10004
  const filePath = "release-ci";
9957
- 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 {
9958
10007
  filePath,
9959
10008
  action: "skipped",
9960
10009
  description: "Release CI workflow not applicable"
9961
10010
  };
9962
- const publishesNpm = ctx.config.publishNpm === true;
9963
- if (ctx.config.releaseStrategy === "changesets") return {
10011
+ if (!hasReleaseWorkflow(ctx.config.releaseStrategy)) return {
9964
10012
  filePath,
9965
10013
  action: "skipped",
9966
10014
  description: "Release step included in CI workflow"
9967
10015
  };
9968
- const isGitHub = ctx.config.ci === "github";
9969
- 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);
9970
10018
  const hasDocker = hasDockerPackages(ctx) && ctx.config.publishDocker;
9971
10019
  const hasAssets = hasReleaseAssets(ctx) || hasVersionStamp(ctx);
9972
10020
  const host = resolveGenTimeHost(ctx);
@@ -10173,7 +10221,13 @@ function checkSteps(ci, coverageEnabled, emitDockerSweepStep) {
10173
10221
  } }
10174
10222
  ];
10175
10223
  }
10176
- /** 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
+ */
10177
10231
  function ciWorkflowPath(ci, releaseStrategy) {
10178
10232
  return `${ci === "github" ? ".github/workflows" : ".forgejo/workflows"}/${releaseStrategy === "changesets" ? "ci.yml" : "check.yml"}`;
10179
10233
  }
@@ -11172,7 +11226,7 @@ function buildConfig$1(config) {
11172
11226
  ].join("\n");
11173
11227
  }
11174
11228
  /** Where `ctx.remove()` puts what it archives. Named here only to describe it to the user. */
11175
- const ARCHIVE_DIR$1 = ".tooling-archived";
11229
+ const ARCHIVE_DIR = ".tooling-archived";
11176
11230
  /** Common client-side git hooks that husky may have configured. */
11177
11231
  const HUSKY_HOOK_NAMES = [
11178
11232
  "pre-commit",
@@ -11206,7 +11260,7 @@ function archiveHuskyHooks(ctx, results) {
11206
11260
  results.push({
11207
11261
  filePath: huskyPath,
11208
11262
  action: "archived",
11209
- description: `Moved to ${ARCHIVE_DIR$1}/${huskyPath}`
11263
+ description: `Moved to ${ARCHIVE_DIR}/${huskyPath}`
11210
11264
  });
11211
11265
  found = true;
11212
11266
  }
@@ -11221,7 +11275,7 @@ function archiveLintStagedConfigs(ctx, results) {
11221
11275
  results.push({
11222
11276
  filePath: lsPath,
11223
11277
  action: "archived",
11224
- description: `Moved to ${ARCHIVE_DIR$1}/${lsPath}`
11278
+ description: `Moved to ${ARCHIVE_DIR}/${lsPath}`
11225
11279
  });
11226
11280
  found = true;
11227
11281
  }
@@ -11429,12 +11483,17 @@ async function generateVscodeSettings(ctx) {
11429
11483
  }
11430
11484
  //#endregion
11431
11485
  //#region src/generators/coverage-ci.ts
11432
- /** Where `ctx.remove()` puts what it archives. Named here only to describe it to the user. */
11433
- const ARCHIVE_DIR = ".tooling-archived";
11434
11486
  /**
11435
- * Cleanup-only generator: coverage steps now run inline in ci.yml (see
11436
- * generateCi). Any legacy coverage.yml left over from a previous version is
11437
- * 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.
11438
11497
  */
11439
11498
  async function generateCoverageCi(ctx) {
11440
11499
  const filePath = "coverage-ci";
@@ -11444,19 +11503,23 @@ async function generateCoverageCi(ctx) {
11444
11503
  description: "CI workflow not requested"
11445
11504
  };
11446
11505
  const workflowPath = ctx.config.ci === "github" ? ".github/workflows/coverage.yml" : ".forgejo/workflows/coverage.yml";
11447
- if (ctx.read(workflowPath) !== void 0) {
11448
- ctx.remove(workflowPath);
11449
- return {
11450
- filePath: workflowPath,
11451
- action: "archived",
11452
- description: `Moved to ${ARCHIVE_DIR}/${workflowPath} (coverage now runs inline in ci.yml)`
11453
- };
11454
- }
11455
- return {
11506
+ const existing = ctx.read(workflowPath);
11507
+ if (existing === void 0) return {
11456
11508
  filePath,
11457
11509
  action: "skipped",
11458
11510
  description: "Coverage runs inline in ci.yml"
11459
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
+ };
11460
11523
  }
11461
11524
  //#endregion
11462
11525
  //#region src/docs-check/blocks.ts
@@ -12937,12 +13000,74 @@ async function generateVersionrc(ctx) {
12937
13000
  };
12938
13001
  }
12939
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
12940
13064
  //#region src/generators/pipeline.ts
12941
13065
  /** Run all generators sequentially and return their results. */
12942
13066
  async function runGenerators(ctx) {
12943
13067
  const results = [];
13068
+ const priorDevEngines = ctx.packageJson?.devEngines;
12944
13069
  results.push(await generatePackageJson(ctx));
12945
- results.push(await generateMise(ctx));
13070
+ results.push(await generateMise(ctx, priorDevEngines));
12946
13071
  results.push(...await generateWorkspaceEngines(ctx));
12947
13072
  results.push(await generatePnpmWorkspace(ctx));
12948
13073
  results.push(...await generateTsconfig(ctx));
@@ -12961,6 +13086,7 @@ async function runGenerators(ctx) {
12961
13086
  results.push(await generateVersionrc(ctx));
12962
13087
  results.push(await generateChangesets(ctx));
12963
13088
  results.push(await generateReleaseCi(ctx));
13089
+ results.push(...await generateStaleCiWorkflows(ctx));
12964
13090
  results.push(await generateDeployCi(ctx));
12965
13091
  results.push(await generateCoverageCi(ctx));
12966
13092
  results.push(...await generateClaudeMd(ctx));
@@ -12993,7 +13119,7 @@ function reportFailures(failed, command) {
12993
13119
  function generateMigratePrompt(results, config, detected) {
12994
13120
  const sections = [];
12995
13121
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
12996
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.87.0 repo:sync\` on ${timestamp}_\n`);
13122
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.87.1 repo:sync\` on ${timestamp}_\n`);
12997
13123
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
12998
13124
  if (changed.length > 0) {
12999
13125
  const items = changed.map((r) => {
@@ -22082,12 +22208,16 @@ const PROBE_TIMEOUT_SECONDS = "5";
22082
22208
  * Pinned by digest, not a tag: a tag-pinned probe image would re-pull (and
22083
22209
  * could silently change) on every check run, reintroducing this exact
22084
22210
  * issue's silent-timeout symptom on the very shared-agent CI runners it
22085
- * targets, since registry egress is often restricted there. Resolved from
22086
- * `curlimages/curl:8.21.0` (the latest stable tag as of 2026-07-16) via the
22087
- * Docker Hub registry API -- this is the digest of the multi-arch manifest
22088
- * 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.
22089
22219
  */
22090
- const PROBE_IMAGE = "curlimages/curl@sha256:7c12af72ceb38b7432ab85e1a265cff6ae58e06f95539d539b654f2cfa64bb13";
22220
+ const PROBE_IMAGE = "curlimages/curl:8.21.0@sha256:7c12af72ceb38b7432ab85e1a265cff6ae58e06f95539d539b654f2cfa64bb13";
22091
22221
  /** Look up the health status of a specific service from container info. */
22092
22222
  function getContainerHealth(containers, serviceName) {
22093
22223
  return containers.find((c) => c.Service === serviceName)?.Health ?? "unknown";
@@ -32363,6 +32493,11 @@ function taskLabelNumber(text) {
32363
32493
  * statement speaks for every future slice, the progress note only for this one. `**Progress:**`
32364
32494
  * and `**Blocked:**` alone declare nothing.
32365
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
+ *
32366
32501
  * A box is the **retire task** when its title starts with optional `**` or `__`, then the
32367
32502
  * literal `Retire this plan`, word-bounded (not followed by a letter or digit). That
32368
32503
  * classification carries no judgement about whether the task is done; `isOutstanding` reads a
@@ -32390,6 +32525,49 @@ function noteKind(label) {
32390
32525
  if (label === "Blocked") return "blocked";
32391
32526
  return "declined";
32392
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
+ }
32393
32571
  /**
32394
32572
  * The checkbox marker stripped, then a `**`/`__` span wrapping the whole title unwrapped (only
32395
32573
  * when the closing marker matches the opening one, so a title merely starting or ending with the
@@ -32426,7 +32604,7 @@ function parsePlanBoxes(planText) {
32426
32604
  break;
32427
32605
  }
32428
32606
  }
32429
- const title = unwrapEmphasis(box.rawTitle);
32607
+ const title = stripStrong(unwrapEmphasis(firstSentence$1(boxParagraph(lines, fenced, box.index, blockEnd, box.rawTitle))));
32430
32608
  const retireTask = RETIRE_TASK_REGEX.test(title);
32431
32609
  if (box.checked) return {
32432
32610
  line: box.index,
@@ -32959,7 +33137,7 @@ setForgejoLogger(log$1);
32959
33137
  * `local <sha>[-dirty]` otherwise -- so an artifact built from the working tree
32960
33138
  * can't pass itself off as the published release whose version it carries.
32961
33139
  */
32962
- const displayVersion = "0.87.0";
33140
+ const displayVersion = "0.87.1";
32963
33141
  /**
32964
33142
  * The registered `bst` subcommand map, exported so both the JSON entry path below (resolving a
32965
33143
  * command name before citty parses anything) and the coverage test (enumerating every