@rtorcato/repo-tooling 3.7.0 → 3.7.2

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.
@@ -39,16 +39,21 @@ export const BASE_FIXERS = [
39
39
  },
40
40
  {
41
41
  target: 'commitlint',
42
- description: 'Scaffold commitlint.config.mjs exporting the preset',
42
+ description: 'Scaffold commitlint.config.mjs + the husky commit-msg hook, and install @commitlint/cli',
43
43
  // Conventional Commits is a repo convention, not a JS one (#309) — the
44
44
  // config is identical in any repo. Running commitlint still needs node on
45
45
  // PATH, which is why the Swift hooks don't wire a commit-msg hook.
46
46
  appliesTo: ['Commitlint'],
47
- outputs: ['commitlint.config.mjs'],
47
+ outputs: ['commitlint.config.mjs', '.husky/commit-msg', 'package.json (devDependencies)'],
48
+ // safe-merge: only missing devDependencies are added, existing ranges stand.
49
+ riskLevel: 'safe-merge',
48
50
  canFixDrift: true,
49
51
  async run({ targetDir }) {
50
- await generateCommitlintConfig(targetDir);
51
- return { filesWritten: ['commitlint.config.mjs'] };
52
+ const filesWritten = await generateCommitlintConfig(targetDir);
53
+ if (filesWritten.includes('package.json')) {
54
+ console.error(chalk.dim(' reminder: run `pnpm install` to install commitlint'));
55
+ }
56
+ return { filesWritten };
52
57
  },
53
58
  },
54
59
  {
@@ -411,17 +411,22 @@ export function buildGhApplyCommands(state) {
411
411
  * on skip (no gh/auth/remote, or already compliant), logging the reason. Mirrors
412
412
  * the "no package.json found — skipping" fixer pattern. Read-only unless a delta
413
413
  * exists, so re-runs (walk-all hits it up to 3×) are safe.
414
+ *
415
+ * Reached from the `github-settings` fixer, so its advisories go to
416
+ * `console.error` for the same reason the fixers' do (#357): stdout carries the
417
+ * `--json` payload. #358 fixed the fixer files but not this one — it isn't a
418
+ * fixer file, it's just called by one.
414
419
  */
415
420
  export async function applyGithubSettings(dir, exec) {
416
421
  if (!(await fs.pathExists(path.join(dir, '.git')))) {
417
- console.log(chalk.gray(' skipped — not a git repository'));
422
+ console.error(chalk.gray(' skipped — not a git repository'));
418
423
  return [];
419
424
  }
420
425
  // Bind gh's cwd to the target dir so its repo resolution honors `-d` (#218).
421
426
  const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
422
427
  const probe = await probeRepo(gh);
423
428
  if ('skip' in probe) {
424
- console.log(chalk.gray(` skipped — ${probe.skip}`));
429
+ console.error(chalk.gray(` skipped — ${probe.skip}`));
425
430
  return [];
426
431
  }
427
432
  const { info } = probe;
@@ -442,7 +447,7 @@ export async function applyGithubSettings(dir, exec) {
442
447
  if (r.ok)
443
448
  applied.push(cmd.label);
444
449
  else
445
- console.log(chalk.yellow(` could not apply ${cmd.label}: ${r.stderr.trim() || 'gh error'}`));
450
+ console.error(chalk.yellow(` could not apply ${cmd.label}: ${r.stderr.trim() || 'gh error'}`));
446
451
  }
447
452
  // Code-scanning ruleset (#269): POST only when CodeQL is on and no active gate
448
453
  // covers the default branch — the check re-read keeps this idempotent.
@@ -453,9 +458,9 @@ export async function applyGithubSettings(dir, exec) {
453
458
  if (r.ok)
454
459
  applied.push(label);
455
460
  else
456
- console.log(chalk.yellow(` could not apply ${label}: ${r.stderr.trim() || 'gh error'}`));
461
+ console.error(chalk.yellow(` could not apply ${label}: ${r.stderr.trim() || 'gh error'}`));
457
462
  }
458
463
  if (applied.length === 0)
459
- console.log(chalk.gray(' already configured — nothing to apply'));
464
+ console.error(chalk.gray(' already configured — nothing to apply'));
460
465
  return applied;
461
466
  }
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import chalk from 'chalk';
3
3
  import fs from 'fs-extra';
4
4
  import { renderGitHubWorkflow } from '../../base/ci.js';
5
- import { githubJobs } from '../../languages/js/ci.js';
5
+ import { githubJobs, scriptsOf } from '../../languages/js/ci.js';
6
6
  import { inferProjectConfig } from '../../languages/js/fixers.js';
7
7
  import { PERL_GIT_HOOKS, runPerlChecks } from '../../languages/perl/checks.js';
8
8
  import { readPerlProject, renderPerlWorkflow } from '../../languages/perl/ci.js';
@@ -17,7 +17,7 @@ import { checkGitIdentity } from '../../base/git-identity.js';
17
17
  import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
18
18
  import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
19
19
  import { checkAiSetup, checkCodeowners, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
20
- import { allDeps, checkAreTheTypesWrong, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
20
+ import { allDeps, checkAreTheTypesWrong, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
21
21
  export { evaluateNodeVersion };
22
22
  const PACKAGE = '@rtorcato/repo-tooling';
23
23
  // Detects the broken-release-on-protected-main footgun: a workflow that runs
@@ -302,6 +302,7 @@ export async function runDoctor(dir) {
302
302
  results.push(await checkPublint(targetDir, pkg));
303
303
  results.push(await checkTreeshakeSetup(targetDir, pkg));
304
304
  results.push(await checkPnpmWorkspace(targetDir, pkg));
305
+ results.push(await checkBuildApprovals(targetDir, pkg));
305
306
  // Turborepo is monorepo-only — only surface the check when a workspace exists.
306
307
  if (await fs.pathExists(path.join(targetDir, 'pnpm-workspace.yaml'))) {
307
308
  results.push(await checkTurborepo(targetDir));
@@ -318,7 +319,9 @@ export async function runDoctor(dir) {
318
319
  results.push(...(await runBaseChecks(targetDir, lock, {
319
320
  hooks: jsGitHooksProfile(pkg),
320
321
  badges: { audience: jsBadgeAudience(pkg), fixTarget: 'badges' },
321
- presetWorkflow: renderGitHubWorkflow(githubJobs(inferProjectConfig(pkg))),
322
+ // Same `scripts` gating the fixer applies, or the workflow doctor
323
+ // compares against would reference steps the fixer never writes (#364).
324
+ presetWorkflow: renderGitHubWorkflow(githubJobs(inferProjectConfig(pkg), { scripts: scriptsOf(pkg) })),
322
325
  language,
323
326
  codeqlLanguages: languageModule.codeqlLanguages,
324
327
  })));
@@ -41,14 +41,10 @@ export async function generateHuskyConfig(config, targetDir) {
41
41
  await generatePrePushHook(targetDir);
42
42
  }
43
43
  }
44
- // Commit-msg hook (if commitlint is enabled). husky v10 format — no v9
45
- // bootstrap. $1 is still the commit-message file path git passes through.
46
- if (config.commitLint) {
47
- const commitMsgPath = path.join(huskyDir, 'commit-msg');
48
- const commitMsgContent = 'npx --no -- commitlint --edit $1\n';
49
- await fs.writeFile(commitMsgPath, commitMsgContent);
50
- await fs.chmod(commitMsgPath, 0o755);
51
- }
44
+ // The commit-msg hook is NOT written here (#362). It runs commitlint via
45
+ // `npx --no`, which refuses to install a missing binary, so emitting it
46
+ // without also installing @commitlint/cli rejected every commit in the repo.
47
+ // It belongs to generateCommitlintConfig, which owns both halves.
52
48
  // lint-staged configuration in package.json
53
49
  const packageJsonPath = path.join(targetDir, 'package.json');
54
50
  const packageJson = await fs.readJson(packageJsonPath);
@@ -71,11 +67,74 @@ export async function generatePrePushHook(targetDir) {
71
67
  await fs.writeFile(prePushPath, PRE_PUSH_HOOK_CONTENT);
72
68
  await fs.chmod(prePushPath, 0o755);
73
69
  }
70
+ /** Package versions the commit-msg hook needs on disk to run at all. */
71
+ export const COMMITLINT_DEPS = {
72
+ '@commitlint/cli': '^20.0.0',
73
+ '@commitlint/config-conventional': '^20.0.0',
74
+ };
75
+ /** True when husky owns this repo's hooks — the only case where .husky/commit-msg runs. */
76
+ async function usesHusky(targetDir) {
77
+ if (await fs.pathExists(path.join(targetDir, '.husky')))
78
+ return true;
79
+ const pkgPath = path.join(targetDir, 'package.json');
80
+ if (!(await fs.pathExists(pkgPath)))
81
+ return false;
82
+ const pkg = (await fs.readJson(pkgPath));
83
+ const deps = {
84
+ ...(pkg.dependencies ?? {}),
85
+ ...(pkg.devDependencies ?? {}),
86
+ };
87
+ return 'husky' in deps;
88
+ }
89
+ /**
90
+ * commitlint's config *and* the hook that runs it, as one unit (#362).
91
+ *
92
+ * These used to be split across two targets: `fix husky` wrote
93
+ * `.husky/commit-msg`, `fix commitlint` wrote the config, and neither installed
94
+ * `@commitlint/cli`. Since the hook body is `npx --no -- commitlint --edit $1`
95
+ * and `--no` refuses to install anything, a repo that ran only `fix husky` had
96
+ * every `git commit` rejected — discovered at the worst possible moment, and
97
+ * looking like a broken repo rather than a missing opt-in.
98
+ *
99
+ * The hook is only written when husky owns the repo's hooks. The Swift path
100
+ * uses `core.hooksPath` and deliberately wires no commit-msg hook, so a
101
+ * `.husky/` file there would simply never run.
102
+ *
103
+ * @returns the files written, relative to targetDir.
104
+ */
74
105
  export async function generateCommitlintConfig(targetDir) {
75
- const commitlintConfigPath = path.join(targetDir, 'commitlint.config.mjs');
76
- const commitlintConfig = `export { default } from '@rtorcato/repo-tooling/commitlint/config'
77
- `;
78
- await fs.writeFile(commitlintConfigPath, commitlintConfig);
106
+ const filesWritten = ['commitlint.config.mjs'];
107
+ await fs.writeFile(path.join(targetDir, 'commitlint.config.mjs'), `export { default } from '@rtorcato/repo-tooling/commitlint/config'\n`);
108
+ if (await usesHusky(targetDir)) {
109
+ // husky v10 format — no v9 bootstrap. $1 is the commit-message file path
110
+ // git passes through.
111
+ const huskyDir = path.join(targetDir, '.husky');
112
+ await fs.ensureDir(huskyDir);
113
+ const commitMsgPath = path.join(huskyDir, 'commit-msg');
114
+ await fs.writeFile(commitMsgPath, 'npx --no -- commitlint --edit $1\n');
115
+ await fs.chmod(commitMsgPath, 0o755);
116
+ filesWritten.push('.husky/commit-msg');
117
+ }
118
+ // @commitlint/cli is an *optional* peer of repo-tooling, so it never arrives
119
+ // transitively — without this the hook we just wrote cannot run.
120
+ const pkgPath = path.join(targetDir, 'package.json');
121
+ if (await fs.pathExists(pkgPath)) {
122
+ const pkg = (await fs.readJson(pkgPath));
123
+ const devDeps = { ...(pkg.devDependencies ?? {}) };
124
+ let added = false;
125
+ for (const [name, range] of Object.entries(COMMITLINT_DEPS)) {
126
+ if (!devDeps[name]) {
127
+ devDeps[name] = range;
128
+ added = true;
129
+ }
130
+ }
131
+ if (added) {
132
+ pkg.devDependencies = devDeps;
133
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
134
+ filesWritten.push('package.json');
135
+ }
136
+ }
137
+ return filesWritten;
79
138
  }
80
139
  async function generateGitignore(config, targetDir) {
81
140
  const gitignorePath = path.join(targetDir, '.gitignore');
@@ -26,9 +26,12 @@ export const CI_WORKFLOW = '.github/workflows/ci.yml';
26
26
  * #340). Same self-enforced safe-add as github-workflows.ts, widened to "or is
27
27
  * byte-identical anyway" so a no-op regeneration still reports honestly. Only a
28
28
  * caller that has told the user this workflow itself is drifting passes true.
29
+ * @param scripts The target's package.json scripts, so the workflow only calls
30
+ * commands that exist (#364). Omit on the `setup` path, which writes the
31
+ * scripts itself as part of the same scaffold.
29
32
  * @returns the files actually written, relative to targetDir.
30
33
  */
31
- export async function generateGitHubActions(config, targetDir, { overwrite = false } = {}) {
34
+ export async function generateGitHubActions(config, targetDir, { overwrite = false, scripts } = {}) {
32
35
  const workflowsDir = path.join(targetDir, '.github', 'workflows');
33
36
  await fs.ensureDir(workflowsDir);
34
37
  // This is the JS path specifically. Swift (#287) renders its own workflow
@@ -37,7 +40,7 @@ export async function generateGitHubActions(config, targetDir, { overwrite = fal
37
40
  // shared entry point would mean inventing a fake config to pass in. Both
38
41
  // paths meet at renderGitHubWorkflow() in src/base/ci.ts, which is the seam
39
42
  // that actually matters.
40
- const workflow = renderGitHubWorkflow(githubJobs(config));
43
+ const workflow = renderGitHubWorkflow(githubJobs(config, { scripts }));
41
44
  const ciPath = path.join(workflowsDir, 'ci.yml');
42
45
  const existing = (await fs.pathExists(ciPath)) ? await fs.readFile(ciPath, 'utf-8') : null;
43
46
  const filesWritten = [];
@@ -1,5 +1,11 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import path from 'node:path';
2
3
  import fs from 'fs-extra';
4
+ /**
5
+ * Only used when pnpm isn't on PATH to ask. corepack requires an exact
6
+ * `pnpm@x.y.z` — a range is rejected — so this has to be a concrete version.
7
+ */
8
+ export const PNPM_FALLBACK_VERSION = '11.1.3';
3
9
  const EDITORCONFIG_CONTENT = `root = true
4
10
 
5
11
  [*]
@@ -194,6 +200,46 @@ export async function ensureEnginesNode(targetDir, version = '>=22') {
194
200
  await fs.writeJson(pkgPath, pkg, { spaces: 2 });
195
201
  return 'added';
196
202
  }
203
+ /** The pnpm this machine runs, or null when pnpm isn't on PATH. */
204
+ export function detectPnpmVersion() {
205
+ try {
206
+ const out = execFileSync('pnpm', ['--version'], {
207
+ encoding: 'utf-8',
208
+ stdio: ['ignore', 'pipe', 'ignore'],
209
+ });
210
+ return /^\s*(\d+\.\d+\.\d+)/.exec(out)?.[1] ?? null;
211
+ }
212
+ catch {
213
+ return null;
214
+ }
215
+ }
216
+ /**
217
+ * Pin `packageManager` so `pnpm/action-setup` has a version to resolve (#364).
218
+ *
219
+ * The generated workflow uses `pnpm/action-setup` with no `version:` input,
220
+ * which is correct *provided* something declares the version. Nothing in the
221
+ * `fix` path did, so every scaffolded pipeline died at setup with "No pnpm
222
+ * version is specified" — before a single check ran.
223
+ *
224
+ * The value is detected from the pnpm actually running, not hardcoded: this is
225
+ * a public CLI and a baked-in version would pin strangers' repos to whatever
226
+ * was current when this file was last touched. `PNPM_FALLBACK_VERSION` is only
227
+ * for a machine with no pnpm on PATH, and corepack will fetch it either way.
228
+ *
229
+ * @returns 'added' | 'already-set' | 'no-package-json'
230
+ */
231
+ export async function ensurePackageManager(targetDir, version = detectPnpmVersion() ?? PNPM_FALLBACK_VERSION) {
232
+ const pkgPath = path.join(targetDir, 'package.json');
233
+ if (!(await fs.pathExists(pkgPath)))
234
+ return 'no-package-json';
235
+ const pkg = (await fs.readJson(pkgPath));
236
+ if (typeof pkg.packageManager === 'string' && pkg.packageManager.trim() !== '') {
237
+ return 'already-set';
238
+ }
239
+ pkg.packageManager = `pnpm@${version}`;
240
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
241
+ return 'added';
242
+ }
197
243
  /**
198
244
  * Build a knip config whose `entry` matches the project's build model, read
199
245
  * from package.json `exports`:
@@ -1,9 +1,6 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'node:path';
3
- // Exact pnpm version for the `packageManager` field — corepack requires a
4
- // pinned `pnpm@x.y.z` (not a range), and it's the single source of truth for
5
- // pnpm/action-setup, so the generated workflows drop their `version:` inputs.
6
- const PNPM_VERSION = '11.1.3';
3
+ import { PNPM_FALLBACK_VERSION, detectPnpmVersion } from './misc.js';
7
4
  export async function generatePackageJson(config, targetDir) {
8
5
  const packageJsonPath = path.join(targetDir, 'package.json');
9
6
  let existingPackageJson = {};
@@ -16,7 +13,9 @@ export async function generatePackageJson(config, targetDir) {
16
13
  version: '0.1.0',
17
14
  description: '',
18
15
  type: 'module',
19
- packageManager: `pnpm@${PNPM_VERSION}`,
16
+ // The single source of truth for pnpm/action-setup, which is why the
17
+ // generated workflows carry no `version:` input (#364).
18
+ packageManager: `pnpm@${detectPnpmVersion() ?? PNPM_FALLBACK_VERSION}`,
20
19
  ...existingPackageJson,
21
20
  scripts: {
22
21
  ...getScripts(config, { includeTreeshake }),
@@ -161,6 +160,34 @@ function getScripts(config, opts = {}) {
161
160
  }
162
161
  return scripts;
163
162
  }
163
+ /**
164
+ * Add any of `scripts` the package.json doesn't already define, leaving every
165
+ * existing value alone. Returns true when something was written.
166
+ *
167
+ * A `fix` target that scaffolds a tool's config but no way to run it leaves the
168
+ * repo half-wired: the generated CI called `pnpm check`, and `fix verify`
169
+ * refused to compose a chain, both because no target ever created that script
170
+ * (#364).
171
+ */
172
+ export async function ensureScripts(targetDir, scripts) {
173
+ const pkgPath = path.join(targetDir, 'package.json');
174
+ if (!(await fs.pathExists(pkgPath)))
175
+ return false;
176
+ const pkg = (await fs.readJson(pkgPath));
177
+ const existing = { ...(pkg.scripts ?? {}) };
178
+ let added = false;
179
+ for (const [name, command] of Object.entries(scripts)) {
180
+ if (!existing[name]) {
181
+ existing[name] = command;
182
+ added = true;
183
+ }
184
+ }
185
+ if (!added)
186
+ return false;
187
+ pkg.scripts = existing;
188
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
189
+ return true;
190
+ }
164
191
  export function composeVerifyScript(config, opts = {}) {
165
192
  const cmds = [];
166
193
  if (config.typescript.enabled)
@@ -216,6 +243,10 @@ export function composeVerifyScriptFromPkg(pkg, opts = {}) {
216
243
  cmds.push('pnpm test --ci');
217
244
  else if (deps['@playwright/test'] || deps.cypress)
218
245
  cmds.push('pnpm test:e2e');
246
+ // A repo can run tests without any runner we recognise — `node --test`, for
247
+ // one. Its `test` script is still the thing verify should call (#364).
248
+ else if (scripts.test)
249
+ cmds.push('pnpm test');
219
250
  if (opts.includeTreeshake)
220
251
  cmds.push('pnpm treeshake');
221
252
  if (deps.publint || scripts.publint)
@@ -861,6 +861,88 @@ export async function checkPnpmWorkspace(dir, pkg) {
861
861
  hint,
862
862
  };
863
863
  }
864
+ /** The lifecycle scripts pnpm refuses to run until the package is approved. */
865
+ const BUILD_LIFECYCLE_SCRIPTS = ['preinstall', 'install', 'postinstall'];
866
+ /**
867
+ * Package names that already have a build decision recorded — approved or
868
+ * declined, both count. `allowBuilds` is a map (pnpm 11); the older
869
+ * `onlyBuiltDependencies` / `ignoredBuiltDependencies` lists are still read so
870
+ * a repo that hasn't migrated isn't nagged about choices it already made.
871
+ */
872
+ export function decidedBuilds(yaml) {
873
+ const decided = new Set();
874
+ const lines = yaml.split('\n');
875
+ let inBlock = false;
876
+ for (const line of lines) {
877
+ if (/^(allowBuilds|onlyBuiltDependencies|ignoredBuiltDependencies):/.test(line)) {
878
+ inBlock = true;
879
+ continue;
880
+ }
881
+ if (/^\S/.test(line)) {
882
+ inBlock = false;
883
+ continue;
884
+ }
885
+ if (!inBlock)
886
+ continue;
887
+ // ` esbuild: true` (map) or ` - esbuild` (list).
888
+ const name = /^\s*-?\s*['"]?(@?[\w./-]+?)['"]?\s*:?\s*(?:true|false)?\s*$/.exec(line)?.[1];
889
+ if (name)
890
+ decided.add(name);
891
+ }
892
+ return decided;
893
+ }
894
+ /**
895
+ * Direct dependencies whose install would run a build script (#364).
896
+ *
897
+ * pnpm 11 turned undecided build scripts into a hard error, so
898
+ * `pnpm install --frozen-lockfile` exits non-zero with ERR_PNPM_IGNORED_BUILDS
899
+ * — a guaranteed CI failure. Locally the same message reads as advisory, which
900
+ * is exactly why it gets missed until the pipeline goes red.
901
+ *
902
+ * Only direct dependencies are inspected: reaching transitive ones means
903
+ * walking node_modules/.pnpm, and the packages that trip this in practice
904
+ * (better-sqlite3, @prisma/client, esbuild) are ones the repo asked for by
905
+ * name. A transitive offender is a false negative, never a false positive.
906
+ */
907
+ export async function checkBuildApprovals(dir, pkg) {
908
+ const check = 'pnpm build approvals';
909
+ const deps = allDeps(pkg);
910
+ const modulesDir = path.join(dir, 'node_modules');
911
+ if (Object.keys(deps).length === 0 || !(await fs.pathExists(modulesDir))) {
912
+ // Nothing installed to inspect — the manifest alone can't say which
913
+ // packages ship an install script.
914
+ return { check, status: 'ok', detail: 'no installed dependencies to inspect' };
915
+ }
916
+ const yamlPath = path.join(dir, WORKSPACE_FILE);
917
+ const decided = decidedBuilds((await fs.pathExists(yamlPath)) ? await fs.readFile(yamlPath, 'utf-8') : '');
918
+ const undecided = [];
919
+ for (const name of Object.keys(deps)) {
920
+ if (decided.has(name))
921
+ continue;
922
+ const depPkgPath = path.join(modulesDir, name, 'package.json');
923
+ if (!(await fs.pathExists(depPkgPath)))
924
+ continue;
925
+ let scripts = {};
926
+ try {
927
+ const depPkg = (await fs.readJson(depPkgPath));
928
+ scripts = depPkg.scripts ?? {};
929
+ }
930
+ catch {
931
+ continue;
932
+ }
933
+ if (BUILD_LIFECYCLE_SCRIPTS.some((s) => scripts[s]))
934
+ undecided.push(name);
935
+ }
936
+ if (undecided.length === 0) {
937
+ return { check, status: 'ok', detail: 'every dependency with a build script has a decision' };
938
+ }
939
+ return {
940
+ check,
941
+ status: 'drift',
942
+ detail: `${undecided.length} dependenc${undecided.length === 1 ? 'y' : 'ies'} with undecided build scripts: ${undecided.join(', ')}`,
943
+ hint: `Record a decision per package under \`allowBuilds:\` in ${WORKSPACE_FILE} (\`name: true\` to run it, \`false\` to skip). pnpm 11 fails \`pnpm install --frozen-lockfile\` outright while any is undecided, so CI cannot pass until each is answered.`,
944
+ };
945
+ }
864
946
  /** The docs app dir (apps/docs or apps/doc) if a Docusaurus config lives there. */
865
947
  export async function findDocsAppDir(dir) {
866
948
  for (const app of ['apps/docs', 'apps/doc']) {
@@ -2,10 +2,12 @@
2
2
  export function usesCoverage(config) {
3
3
  return config.testing.framework === 'vitest';
4
4
  }
5
+ /** The package.json script the lint job runs — `check` under Biome, else `lint`. */
6
+ function lintScript(config) {
7
+ return config.linting.tool === 'biome' || config.linting.tool === 'both' ? 'check' : 'lint';
8
+ }
5
9
  function lintCommand(config) {
6
- return config.linting.tool === 'biome' || config.linting.tool === 'both'
7
- ? 'pnpm check'
8
- : 'pnpm lint';
10
+ return `pnpm ${lintScript(config)}`;
9
11
  }
10
12
  /**
11
13
  * Checkout → Node → pnpm → restore the cache the `dependencies` job primed.
@@ -62,40 +64,53 @@ const DEPENDENCIES_JOB = {
62
64
  - name: 📦 Install dependencies
63
65
  run: pnpm install --frozen-lockfile`,
64
66
  };
67
+ /**
68
+ * `JobOptions.scripts` for a real repo. A package.json with no `scripts` block
69
+ * yields `{}` — "this repo has no scripts", which gates every step off —
70
+ * whereas no package.json at all yields undefined, the setup shape.
71
+ */
72
+ export function scriptsOf(pkg) {
73
+ if (!pkg)
74
+ return undefined;
75
+ return pkg.scripts ?? {};
76
+ }
77
+ /** Emit a step only when the script it runs exists (or we can't know yet). */
78
+ function stepFor(opts, script, step) {
79
+ return !opts.scripts || script in opts.scripts ? step : null;
80
+ }
81
+ /** Join the steps that survived gating onto the shared setup preamble. */
82
+ function jobSteps(steps) {
83
+ const kept = steps.filter((s) => s !== null);
84
+ return kept.length > 0 ? [SETUP_STEPS, ...kept].join('\n\n') : null;
85
+ }
65
86
  /**
66
87
  * The GitHub Actions jobs for a JS repo, in workflow order. The `release` job
67
88
  * needs the ids of everything that ran before it, so the list is assembled
68
89
  * rather than filtered from a fixed shape.
69
90
  */
70
- export function githubJobs(config) {
91
+ export function githubJobs(config, opts = {}) {
71
92
  const hasTypeScript = config.typescript.enabled;
72
93
  const hasTests = config.testing.framework !== 'none';
73
94
  const hasBuild = config.bundler !== 'none';
74
95
  const isLibrary = config.projectType === 'library';
75
96
  const hasCoverage = usesCoverage(config);
76
- const jobs = [
77
- DEPENDENCIES_JOB,
78
- {
79
- id: 'lint',
80
- needs: ['dependencies'],
81
- steps: `${SETUP_STEPS}
82
-
83
- - name: 🔍 Run linting
84
- run: ${lintCommand(config)}
85
-
86
- - name: 🧹 Check for unused files, exports, and dependencies
87
- run: pnpm knip`,
88
- },
89
- ];
97
+ const jobs = [DEPENDENCIES_JOB];
98
+ const lintSteps = jobSteps([
99
+ stepFor(opts, lintScript(config), ` - name: 🔍 Run linting
100
+ run: ${lintCommand(config)}`),
101
+ stepFor(opts, 'knip', ` - name: 🧹 Check for unused files, exports, and dependencies
102
+ run: pnpm knip`),
103
+ ]);
104
+ if (lintSteps) {
105
+ jobs.push({ id: 'lint', needs: ['dependencies'], steps: lintSteps });
106
+ }
90
107
  if (hasTypeScript) {
91
- jobs.push({
92
- id: 'typecheck',
93
- needs: ['dependencies'],
94
- steps: `${SETUP_STEPS}
95
-
96
- - name: 🔍 Type check
97
- run: pnpm typecheck`,
98
- });
108
+ const steps = jobSteps([
109
+ stepFor(opts, 'typecheck', ` - name: 🔍 Type check
110
+ run: pnpm typecheck`),
111
+ ]);
112
+ if (steps)
113
+ jobs.push({ id: 'typecheck', needs: ['dependencies'], steps });
99
114
  }
100
115
  if (hasTests) {
101
116
  const coverageUpload = hasCoverage
@@ -107,18 +122,19 @@ export function githubJobs(config) {
107
122
  token: \${{ secrets.CODECOV_TOKEN }}
108
123
  fail_ci_if_error: false`
109
124
  : '';
110
- jobs.push({
111
- id: 'test',
112
- needs: ['dependencies'],
113
- steps: `${SETUP_STEPS}
114
-
115
- - name: 🧪 Run tests
116
- run: ${hasCoverage ? 'pnpm coverage' : 'pnpm test'}${coverageUpload}`,
117
- });
125
+ // Fall back to `pnpm test` when the repo has no coverage script — the
126
+ // coverage upload goes with it, since there'd be no lcov to upload.
127
+ const useCoverage = hasCoverage && (!opts.scripts || 'coverage' in opts.scripts);
128
+ const steps = jobSteps([
129
+ stepFor(opts, useCoverage ? 'coverage' : 'test', ` - name: 🧪 Run tests
130
+ run: ${useCoverage ? 'pnpm coverage' : 'pnpm test'}${useCoverage ? coverageUpload : ''}`),
131
+ ]);
132
+ if (steps)
133
+ jobs.push({ id: 'test', needs: ['dependencies'], steps });
118
134
  }
119
- if (hasBuild) {
135
+ if (hasBuild && (!opts.scripts || 'build' in opts.scripts)) {
120
136
  // attw and publint validate what's about to be published — libraries only.
121
- const attw = isLibrary
137
+ const attw = isLibrary && (!opts.scripts || 'attw' in opts.scripts)
122
138
  ? '\n - name: 🔍 Validate type resolution (are-the-types-wrong)\n run: pnpm attw\n'
123
139
  : '';
124
140
  const publint = config.publint
@@ -8,8 +8,16 @@ import { CI_WORKFLOW, generateGitHubActions } from '../../cli/generators/github-
8
8
  import { generateGitLabCI } from '../../cli/generators/gitlab-ci.js';
9
9
  import { GH_WORKFLOWS, generateGhWorkflow } from '../../cli/generators/github-workflows.js';
10
10
  import { generateESLintConfig, generatePrettierConfig } from '../../cli/generators/linting.js';
11
- import { alignNodeVersion, ensureEnginesNode, generateKnipConfig, generateNvmrc, generateSizeLimitConfig, generateVscodeExtensions, } from '../../cli/generators/misc.js';
12
- import { composeVerifyScriptFromPkg } from '../../cli/generators/package-json.js';
11
+ import { alignNodeVersion, ensureEnginesNode, ensurePackageManager, generateKnipConfig, generateNvmrc, generateSizeLimitConfig, generateVscodeExtensions, } from '../../cli/generators/misc.js';
12
+ import { scriptsOf } from './ci.js';
13
+ import { composeVerifyScriptFromPkg, ensureScripts } from '../../cli/generators/package-json.js';
14
+ /** Kept in step with the biome branch of getScripts() in package-json.ts. */
15
+ const BIOME_SCRIPTS = {
16
+ lint: 'biome lint .',
17
+ format: 'biome format .',
18
+ check: 'biome check .',
19
+ 'check:fix': 'biome check --fix .',
20
+ };
13
21
  import { WORKSPACE_FILE, dependsOnEsbuild, ensurePnpmSettings, familyGlob, } from '../../cli/generators/pnpm-workspace.js';
14
22
  import { generatePostcss } from '../../cli/generators/postcss.js';
15
23
  import { generateCypressConfig, generateVitestConfig } from '../../cli/generators/testing.js';
@@ -114,13 +122,18 @@ const GH_WORKFLOW_FIXERS = GH_WORKFLOWS.map((name) => ({
114
122
  export const FIXERS = [
115
123
  {
116
124
  target: 'biome',
117
- description: 'Scaffold biome.json extending the @rtorcato/repo-tooling preset',
125
+ description: 'Scaffold biome.json extending the @rtorcato/repo-tooling preset, plus the scripts that run it',
118
126
  appliesTo: ['Biome'],
119
- outputs: ['biome.json'],
127
+ outputs: ['biome.json', 'package.json (scripts)'],
120
128
  canFixDrift: true,
121
129
  async run({ targetDir }) {
122
130
  const result = await copyPreset('biome', targetDir);
123
- return { filesWritten: [result.target] };
131
+ const filesWritten = [result.target];
132
+ // A config with no way to run it left `pnpm check` undefined, which the
133
+ // generated CI called and `fix verify` needed to compose a chain (#364).
134
+ if (await ensureScripts(targetDir, BIOME_SCRIPTS))
135
+ filesWritten.push('package.json');
136
+ return { filesWritten };
124
137
  },
125
138
  },
126
139
  {
@@ -193,7 +206,7 @@ export const FIXERS = [
193
206
  },
194
207
  {
195
208
  target: 'husky',
196
- description: 'Set up Husky + lint-staged (and a `pnpm verify` pre-push hook)',
209
+ description: 'Set up Husky + lint-staged (and a `pnpm verify` pre-push hook). Commit-message linting is `fix commitlint`.',
197
210
  appliesTo: ['Git hooks', 'lint-staged', 'Pre-push hook'],
198
211
  outputs: ['.husky/pre-commit', '.husky/pre-push', 'package.json (lint-staged field)'],
199
212
  riskLevel: 'safe-merge',
@@ -304,7 +317,7 @@ export const FIXERS = [
304
317
  target: 'github-actions',
305
318
  description: 'Scaffold .github/workflows/ci.yml (+ codecov.yml when tests run)',
306
319
  appliesTo: ['GitHub Actions', 'Coverage upload', 'npm OIDC publish'],
307
- outputs: [CI_WORKFLOW, 'codecov.yml'],
320
+ outputs: [CI_WORKFLOW, 'codecov.yml', 'package.json (packageManager field)'],
308
321
  canFixDrift: true,
309
322
  async run({ targetDir, pkg, result }) {
310
323
  // Only a `GitHub Actions` finding means the user was shown that the
@@ -313,7 +326,14 @@ export const FIXERS = [
313
326
  // customized ci.yml down with it (#349).
314
327
  const filesWritten = await generateGitHubActions(inferProjectConfig(pkg), targetDir, {
315
328
  overwrite: result.check === 'GitHub Actions',
329
+ // Only reference scripts this repo actually has (#364).
330
+ scripts: scriptsOf(pkg),
316
331
  });
332
+ // `pnpm/action-setup` is emitted without a `version:` input, so without
333
+ // this every job dies at setup with "No pnpm version is specified".
334
+ if ((await ensurePackageManager(targetDir)) === 'added') {
335
+ filesWritten.push('package.json');
336
+ }
317
337
  if (!filesWritten.includes(CI_WORKFLOW)) {
318
338
  console.error(chalk.yellow(` ${CI_WORKFLOW} differs from the preset — left as-is; run \`fix github-actions --diff\` to see the delta`));
319
339
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.7.0",
3
+ "version": "3.7.2",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [