@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.
- package/dist/base/fixers.js +9 -4
- package/dist/base/github-settings.js +10 -5
- package/dist/cli/commands/doctor.js +6 -3
- package/dist/cli/generators/git.js +71 -12
- package/dist/cli/generators/github-actions.js +5 -2
- package/dist/cli/generators/misc.js +46 -0
- package/dist/cli/generators/package-json.js +36 -5
- package/dist/languages/js/checks.js +82 -0
- package/dist/languages/js/ci.js +52 -36
- package/dist/languages/js/fixers.js +27 -7
- package/package.json +1 -1
package/dist/base/fixers.js
CHANGED
|
@@ -39,16 +39,21 @@ export const BASE_FIXERS = [
|
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
41
|
target: 'commitlint',
|
|
42
|
-
description: 'Scaffold commitlint.config.mjs
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
461
|
+
console.error(chalk.yellow(` could not apply ${label}: ${r.stderr.trim() || 'gh error'}`));
|
|
457
462
|
}
|
|
458
463
|
if (applied.length === 0)
|
|
459
|
-
console.
|
|
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
|
-
|
|
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
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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']) {
|
package/dist/languages/js/ci.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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 {
|
|
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
|
-
|
|
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