@monte3l/groundwork 1.0.0-rc.3 → 1.0.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -242,6 +242,27 @@ export function formatCapsSummary(baseline, installed) {
242
242
  lines.push(`= ${formatCounts(total)}${overCap.length > 0 ? ` ⚠ over cap: ${overCap.join(", ")}` : ""}`);
243
243
  return { text: lines.join("\n"), overCap: overCap.length > 0 };
244
244
  }
245
+ /**
246
+ * Fresh mode's closing "setup required" block: one heading, then each pack
247
+ * that declares `setupSteps` (in `packs` order) followed by its commands,
248
+ * each indented two spaces. `undefined` when no pack declares any, so the
249
+ * caller prints nothing extra.
250
+ */
251
+ function formatSetupBlock(targetDir, packs) {
252
+ const lines = [];
253
+ for (const { manifest } of packs) {
254
+ const steps = manifest.setupSteps ?? [];
255
+ if (steps.length === 0)
256
+ continue;
257
+ lines.push(`pack "${manifest.name}":`, ...steps.map((step) => ` ${step}`));
258
+ }
259
+ if (lines.length === 0)
260
+ return undefined;
261
+ return [
262
+ `\nthe installed pack(s) need setup before the first \`pnpm verify\` -- run these in ${targetDir}:`,
263
+ ...lines,
264
+ ].join("\n");
265
+ }
245
266
  /**
246
267
  * Fresh mode's pre-flight over the `/customize` skill's own destination
247
268
  * (`.claude/skills/customize/`): each directory component must be missing
@@ -295,6 +316,18 @@ function runFresh(options, platform) {
295
316
  : summary.text);
296
317
  }
297
318
  const { targetDir, skipInstall, projectName } = options;
319
+ // Built once, now that the project and its packs are on disk, and printed
320
+ // exactly once per run: at the very end on success, or just before the
321
+ // throw when git init / pnpm install fails -- a plain re-run of a
322
+ // non-empty target adopts it, and adopt mode never prints setupSteps. The
323
+ // /customize-skill failure below deliberately does not print it: its
324
+ // message says to re-run with --fresh --force, which prints it on success.
325
+ const setupBlock = formatSetupBlock(targetDir, packs);
326
+ const printSetupBlock = () => {
327
+ if (setupBlock !== undefined) {
328
+ console.log(paint(process.stdout, "warning", setupBlock));
329
+ }
330
+ };
298
331
  let pluginResult;
299
332
  try {
300
333
  pluginResult = installCustomizeSkill(targetDir);
@@ -314,6 +347,7 @@ function runFresh(options, platform) {
314
347
  gitInit(targetDir);
315
348
  }
316
349
  catch (error) {
350
+ printSetupBlock();
317
351
  throw new Error(`git init failed, but the project was written to ${targetDir}; run \`git init\`${skipInstall ? "" : " and `pnpm install`"} there yourself`, { cause: error });
318
352
  }
319
353
  console.log("initialized git repository");
@@ -323,11 +357,13 @@ function runFresh(options, platform) {
323
357
  }
324
358
  catch (error) {
325
359
  console.log(paint(process.stdout, "warning", `\n${projectName} written to ${targetDir}, but dependencies are not installed`));
360
+ printSetupBlock();
326
361
  throw new Error(`${describeInstallFailure(error)}; the project was written to ${targetDir} -- run \`pnpm install\` there yourself to finish`, { cause: error });
327
362
  }
328
363
  console.log("installed dependencies");
329
364
  }
330
365
  console.log(paint(process.stdout, "success", `\n✓ ${projectName} is ready at ${targetDir}`));
366
+ printSetupBlock();
331
367
  }
332
368
  /**
333
369
  * Explains why the post-emission `pnpm install` failed: a missing binary
package/dist/packs.d.ts CHANGED
@@ -19,6 +19,8 @@ export interface PackManifest {
19
19
  } | undefined;
20
20
  wiring: PackWiring;
21
21
  adoptNotes: string | undefined;
22
+ /** Shell commands, run in the target directory, that a fresh install needs before its first `pnpm verify` (fresh mode prints them; see main.ts). Optional: absent means no setup. */
23
+ setupSteps?: string[] | undefined;
22
24
  }
23
25
  export interface Pack {
24
26
  manifest: PackManifest;
@@ -28,7 +30,7 @@ export interface Pack {
28
30
  export declare function packsRootDir(): string;
29
31
  /** Names of every pack directory that has a `pack.json` under `root`, sorted for deterministic install order. `root` defaults to `templates/packs`, overridable for tests. */
30
32
  export declare function listPackNames(root?: string): string[];
31
- /** Loads and validates one pack's manifest from under `root` (default `templates/packs`, overridable for tests). Throws, naming the available packs, if unknown; throws naming the pack and the problem if malformed, including a prototype-sensitive key in its wiring (see {@link assertValidWiringShape}). */
33
+ /** Loads and validates one pack's manifest from under `root` (default `templates/packs`, overridable for tests). Throws, naming the available packs, if unknown; throws naming the pack and the problem if malformed, including a prototype-sensitive key in its wiring (see {@link assertValidWiringShape}) or a `setupSteps` that is present but not a non-empty array of single-line, non-empty strings. */
32
34
  export declare function loadPack(name: string, root?: string): Pack;
33
35
  export interface PackInstallResult {
34
36
  filesWritten: string[];
package/dist/packs.js CHANGED
@@ -132,7 +132,13 @@ function assertValidWiringShape(name, wiring) {
132
132
  assertValidVerifyStep(name, step, index);
133
133
  });
134
134
  }
135
- /** Loads and validates one pack's manifest from under `root` (default `templates/packs`, overridable for tests). Throws, naming the available packs, if unknown; throws naming the pack and the problem if malformed, including a prototype-sensitive key in its wiring (see {@link assertValidWiringShape}). */
135
+ /** True when `steps` is a non-empty array of non-empty strings, none containing a line break -- each is printed as one indented line. */
136
+ function isValidSetupSteps(steps) {
137
+ return (Array.isArray(steps) &&
138
+ steps.length > 0 &&
139
+ steps.every((step) => typeof step === "string" && step !== "" && !/[\r\n]/.test(step)));
140
+ }
141
+ /** Loads and validates one pack's manifest from under `root` (default `templates/packs`, overridable for tests). Throws, naming the available packs, if unknown; throws naming the pack and the problem if malformed, including a prototype-sensitive key in its wiring (see {@link assertValidWiringShape}) or a `setupSteps` that is present but not a non-empty array of single-line, non-empty strings. */
136
142
  export function loadPack(name, root = packsRootDir()) {
137
143
  const packDir = join(root, name);
138
144
  const manifestPath = join(packDir, "pack.json");
@@ -176,6 +182,10 @@ export function loadPack(name, root = packsRootDir()) {
176
182
  if (!isValidBudget(budget)) {
177
183
  throw new Error(`pack "${name}": pack.json's budget must set ${CAP_KEYS.join(", ")} to non-negative integers`);
178
184
  }
185
+ const setupSteps = manifest.setupSteps;
186
+ if (setupSteps !== undefined && !isValidSetupSteps(setupSteps)) {
187
+ throw new Error(`pack "${name}": pack.json's setupSteps must be a non-empty array of single-line, non-empty strings`);
188
+ }
179
189
  // pack-stage.ts's stagePacks keys each pack's staging directory on
180
190
  // manifest.name, so a mismatch would let two pack directories collide.
181
191
  if (manifestName !== name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monte3l/groundwork",
3
- "version": "1.0.0-rc.3",
3
+ "version": "1.0.0-rc.4",
4
4
  "description": "CLI that writes a TypeScript toolchain and Claude Code setup into an empty directory, or -- against an existing project -- read-only surveys it and reports the differences. No prompts, and no network access beyond the package install.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -65,6 +65,15 @@ already enumerate groups rather than individual steps.
65
65
  - `wiring.verifySteps` — entries appended to `bin/lib/verify-steps.packs.json`.
66
66
  - `adoptNotes` — free text surfaced verbatim in `/customize`'s Step 0
67
67
  confirmation round when the pack applies to an adopted project.
68
+ - `setupSteps` — the exact commands a user must run in the new project after
69
+ the pack is installed and before the first `pnpm verify`, or verify fails
70
+ (for example, `publishing` needs `@changesets/cli` added, since a pack can
71
+ never edit `dependencies`). A non-empty array of single-line, non-empty
72
+ strings; `loadPack` rejects anything else. Fresh mode prints every installed
73
+ pack's steps in one block after its `ready` line. Adopt mode neither prints
74
+ them nor adds them to the inventory (the staged `pack.json` still carries the
75
+ field verbatim): a pack that is adopt-capable and needs setup says so in
76
+ `adoptNotes`. Optional.
68
77
 
69
78
  ## Install path
70
79
 
@@ -4,6 +4,6 @@
4
4
  "private": true,
5
5
  "description": "Pins the exact npm version the release workflow's publish job installs, so the pin is verified by a committed lockfile's integrity hash rather than a bare version string in YAML.",
6
6
  "dependencies": {
7
- "npm": "11.20.0"
7
+ "npm": "12.2.0"
8
8
  }
9
9
  }
@@ -185,12 +185,14 @@ jobs:
185
185
  package-manager-cache: false
186
186
  # Only the changesets CLI is needed here, not any package's lifecycle scripts.
187
187
  - run: pnpm install --frozen-lockfile --ignore-scripts
188
- # `npm stage publish` needs npm >= 11.15.0 (Node >= 22.14.0 -- already
189
- # covered by .node-version). Pinned to an exact version, not a range or
190
- # `latest`: this job holds the OIDC token, and an unpinned
191
- # package-manager install is exactly what OpenSSF Scorecard's
192
- # Pinned-Dependencies check flags. `.github/release-tools/` pins the
193
- # exact tarball via its committed lockfile's integrity hash, and
188
+ # `npm stage publish` needs npm >= 11.15.0. The pinned npm (12.2.0)
189
+ # declares engines Node ^22.22.2 || ^24.15.0 || >=26. .node-version's
190
+ # `24` meets that on GitHub-hosted runners, whose cached 24.x is recent;
191
+ # a self-hosted runner with an older cached 24.x does not. Pinned to an
192
+ # exact version, not a range or `latest`: this job holds the OIDC token,
193
+ # and an unpinned package-manager install is exactly what OpenSSF
194
+ # Scorecard's Pinned-Dependencies check flags. `.github/release-tools/`
195
+ # pins the exact tarball via its committed lockfile's integrity hash, and
194
196
  # `npm ci` verifies the install against it -- bumping the pin is then
195
197
  # a normal reviewed PR (a lockfile diff), not a hand-edited version
196
198
  # string.
@@ -31,5 +31,11 @@
31
31
  }
32
32
  ]
33
33
  },
34
- "adoptNotes": "REQUIRED right after install, before the first pnpm verify -- two one-time steps, or pnpm verify fails immediately, not from a real regression but from two setup gaps this pack cannot close itself: (1) `pnpm add -D @changesets/cli` -- the wiring contract only extends package.json's scripts, never its dependencies (see templates/packs/README.md), so the wired `changeset`/`version:packages` scripts reference a binary knip's own unlisted-binaries check correctly flags as missing until this is installed; (2) `node bin/check-license-headers.mjs --fix`, then commit the result -- templates/core has never had a license-header gate before this pack, so every one of its own files (hooks, bin/ scripts, CI workflows, the placeholder src/tests, and the workflows of any other installed pack such as `github` or `supply-chain`) is missing the SPDX header this pack's own license-headers gate now requires, the same one-time cost this repo itself paid when it adopted the same gate (see CLAUDE.md's Definition of Done). In a hand-copied install the CLI does not substitute tokens, so replace every literal `__PROJECT_NAME__` in the copied files (REUSE.toml, the SPDX headers in bin/, the workflows) with the project's real name first. Fresh mode only: a release pipeline encodes decisions (whether the package is actually public, which registry, whether a GitHub App backs the version PR) this pack cannot survey or guess at safely, so it is staged like every other pack (at .groundwork/packs/publishing/) but never auto-installed in adopt mode -- if an adopted project wants it, copy .groundwork/packs/publishing/files/ by hand, dropping the `.staged` suffix from every name (or copy templates/packs/publishing/files/ from this repo directly) and work through the one-time setup below. check-publish-version.mjs is deliberately NOT one of this pack's wired verify steps: with changesets, package.json's version on the release branch is always the one just published between releases, so a version-already-published check running on every ordinary push (via pnpm verify / pre-push) would fail every single time until the next version PR lands -- it is instead invoked directly as a step inside release.yml's pack job, the one place \"is this version about to collide with an already-published one\" is actually the right question to ask. The two remaining verify-group gates (dts-deps, license-headers) install anywhere a bin/verify.mjs-shaped gate runner exists; wire them into the project's real one if it differs. Both check-publish-version.mjs and check-dts-deps.mjs no-op (checked: 0, not a failure) on a package.json with `private: true` (the baseline's own default) or (check-dts-deps.mjs only) with no dist/**/*.d.ts yet -- flip `private` to `false` (and set a real `name`) once the package is actually meant to publish. The pack assumes a public package: `.changeset/config.json` ships `access: public`, npm refuses `restricted` for an unscoped name, and the publish shim always passes `--provenance`, which npm ties to a public source repository -- a private scoped package needs `access: restricted` and provenance removed from bin/lib/npm-publish-args.mjs. `@changesets/changelog-github` (and its `repo` option) is a drop-in swap for `.changeset/config.json`'s plain default changelog generator, once this project's GitHub repository is known, for changelog entries that link back to the originating PR/commit -- see `.changeset/README.md`. One-time setup before release.yml's first real run (distinct from the two pnpm-verify prerequisites above), distilled from this repo's own .claude/rules/releases.md: (1) npm cannot configure a trusted publisher for a package that doesn't exist yet -- publish once by hand with a temporary token first; (2) the trusted publisher is bound to the exact workflow filename `release.yml` -- renaming or moving it breaks publishing until reconfigured on npmjs.com; (3) set the trusted publisher's allowed actions to staged-only (`npm stage publish`, npm's own default and recommendation since 2026-09-03) -- a maintainer runs `npm stage approve <id>` (2FA, never automatable) before a version actually installs; (4) create an `npm-publish` GitHub environment (via `gh api`, not committed as JSON) with a required reviewer, restricted to the release branch, so the `publish` job itself pauses before the git tag/Release/stage-publish exist; (5) if branch protection requires status checks on every PR with no bypass actor, the version-PR job needs a GitHub App installation token (`APP_CLIENT_ID`/`APP_PRIVATE_KEY` repo secrets), not the default GITHUB_TOKEN, or those checks never trigger and the PR can never merge -- if this project has no such rule, `github-token: ${{ secrets.GITHUB_TOKEN }}` is enough and the app-token step can be dropped. release.yml pins `.github/release-tools/` (the npm CLI version) but the baseline's dependabot.yml only covers the github-actions ecosystem, so that pin goes stale unless you add an `npm` entry with `directory: \"/.github/release-tools\"` to the project's own .github/dependabot.yml. Security note, as of 2026-10-01: the npm pinned in the shipped `.github/release-tools/package-lock.json` (11.20.0) bundles undici 6.28.0, ip-address 10.5.0 and brace-expansion 5.0.9, which carry open advisories; every npm release found that day (11.20.0, 11.21.0, 12.2.0) bundles the same versions, and overrides cannot change bundled dependencies. Reading the npm tarballs and lockfile, no reach was found from what the shipped workflow runs (`npm ci --ignore-scripts`, `npm stage publish <tarball>` and `npm stage list`; the tarball is packed earlier by pnpm, not npm, and the shipped workflow configures no proxy; if your runner sets one, for example HTTPS_PROXY on a self-hosted runner behind an egress proxy, the ip-address advisories may apply), and the minimatch callers on those commands (Arborist, and tuf-js under sigstore) take their patterns from your own lockfile and from signed registry metadata, but that is analysis, not a run of the code. Re-check on the day of each release and bump the pinned npm as soon as a release bundling undici >= 6.28.1, ip-address >= 10.7.1 and brace-expansion >= 5.0.12 exists. The secret-scanning and Scorecard workflows that used to ship here are now the separate `supply-chain` pack."
34
+ "setupSteps": [
35
+ "pnpm add -D @changesets/cli",
36
+ "git add -A",
37
+ "node bin/check-license-headers.mjs --fix",
38
+ "git add -A"
39
+ ],
40
+ "adoptNotes": "REQUIRED right after install, before the first pnpm verify -- two one-time steps, or pnpm verify fails immediately, not from a real regression but from two setup gaps this pack cannot close itself: (1) `pnpm add -D @changesets/cli` -- the wiring contract only extends package.json's scripts, never its dependencies (see templates/packs/README.md), so the wired `changeset`/`version:packages` scripts reference a binary knip's own unlisted-binaries check correctly flags as missing until this is installed; (2) `git add -A`, then `node bin/check-license-headers.mjs --fix`, then `git add -A` again and commit the result (the gate only checks git-tracked files, so a fresh project with nothing staged passes it vacuously and fails on every file at its first push) -- templates/core has never had a license-header gate before this pack, so every one of its own files (hooks, bin/ scripts, CI workflows, the placeholder src/tests, and the workflows of any other installed pack such as `github` or `supply-chain`) is missing the SPDX header this pack's own license-headers gate now requires, the same one-time cost this repo itself paid when it adopted the same gate (see CLAUDE.md's Definition of Done). In a hand-copied install the CLI does not substitute tokens, so replace every literal `__PROJECT_NAME__` in the copied files (REUSE.toml, the SPDX headers in bin/, the workflows) with the project's real name first. Fresh mode only: a release pipeline encodes decisions (whether the package is actually public, which registry, whether a GitHub App backs the version PR) this pack cannot survey or guess at safely, so it is staged like every other pack (at .groundwork/packs/publishing/) but never auto-installed in adopt mode -- if an adopted project wants it, copy .groundwork/packs/publishing/files/ by hand, dropping the `.staged` suffix from every name (or copy templates/packs/publishing/files/ from this repo directly) and work through the one-time setup below. check-publish-version.mjs is deliberately NOT one of this pack's wired verify steps: with changesets, package.json's version on the release branch is always the one just published between releases, so a version-already-published check running on every ordinary push (via pnpm verify / pre-push) would fail every single time until the next version PR lands -- it is instead invoked directly as a step inside release.yml's pack job, the one place \"is this version about to collide with an already-published one\" is actually the right question to ask. The two remaining verify-group gates (dts-deps, license-headers) install anywhere a bin/verify.mjs-shaped gate runner exists; wire them into the project's real one if it differs. Both check-publish-version.mjs and check-dts-deps.mjs no-op (checked: 0, not a failure) on a package.json with `private: true` (the baseline's own default) or (check-dts-deps.mjs only) with no dist/**/*.d.ts yet -- flip `private` to `false` (and set a real `name`) once the package is actually meant to publish. The pack assumes a public package: `.changeset/config.json` ships `access: public`, npm refuses `restricted` for an unscoped name, and the publish shim always passes `--provenance`, which npm ties to a public source repository -- a private scoped package needs `access: restricted` and provenance removed from bin/lib/npm-publish-args.mjs. `@changesets/changelog-github` (and its `repo` option) is a drop-in swap for `.changeset/config.json`'s plain default changelog generator, once this project's GitHub repository is known, for changelog entries that link back to the originating PR/commit -- see `.changeset/README.md`. One-time setup before release.yml's first real run (distinct from the two pnpm-verify prerequisites above), distilled from this repo's own .claude/rules/releases.md: (1) npm cannot configure a trusted publisher for a package that doesn't exist yet -- publish once by hand with a temporary token first; (2) the trusted publisher is bound to the exact workflow filename `release.yml` -- renaming or moving it breaks publishing until reconfigured on npmjs.com; (3) set the trusted publisher's allowed actions to staged-only (`npm stage publish`, npm's own default and recommendation since 2026-09-03) -- a maintainer runs `npm stage approve <id>` (2FA, never automatable) before a version actually installs; (4) create an `npm-publish` GitHub environment (via `gh api`, not committed as JSON) with a required reviewer, restricted to the release branch, so the `publish` job itself pauses before the git tag/Release/stage-publish exist; (5) if branch protection requires status checks on every PR with no bypass actor, the version-PR job needs a GitHub App installation token (`APP_CLIENT_ID`/`APP_PRIVATE_KEY` repo secrets), not the default GITHUB_TOKEN, or those checks never trigger and the PR can never merge -- if this project has no such rule, `github-token: ${{ secrets.GITHUB_TOKEN }}` is enough and the app-token step can be dropped. release.yml pins `.github/release-tools/` (the npm CLI version) but the baseline's dependabot.yml only covers the github-actions ecosystem, so that pin goes stale unless you add an `npm` entry with `directory: \"/.github/release-tools\"` to the project's own .github/dependabot.yml. Security note, as of 2026-10-01: the npm pinned in the shipped `.github/release-tools/package-lock.json` (12.2.0) bundles undici 6.28.0, ip-address 10.5.0 and brace-expansion 5.0.9, which carry open advisories; every npm release found that day (11.20.0, 11.21.0, 12.2.0) bundles the same versions, and overrides cannot change bundled dependencies. Reading the npm tarballs and lockfile, no reach was found from what the shipped workflow runs (`npm ci --ignore-scripts`, `npm stage publish <tarball>` and `npm stage list`; the tarball is packed earlier by pnpm, not npm, and the shipped workflow configures no proxy; if your runner sets one, for example HTTPS_PROXY on a self-hosted runner behind an egress proxy, the ip-address advisories may apply), and the minimatch callers on those commands (Arborist, and tuf-js under sigstore) take their patterns from your own lockfile and from signed registry metadata, but that is analysis, not a run of the code. Re-check on the day of each release and bump the pinned npm as soon as a release bundling undici >= 6.28.1, ip-address >= 10.7.1 and brace-expansion >= 5.0.12 exists. The secret-scanning and Scorecard workflows that used to ship here are now the separate `supply-chain` pack."
35
41
  }