@rtorcato/repo-tooling 3.2.3 → 3.2.5

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.
@@ -0,0 +1,461 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import chalk from 'chalk';
4
+ import fs from 'fs-extra';
5
+ const GH_TIMEOUT_MS = 10_000;
6
+ /**
7
+ * Real `gh` runner — never rejects; a missing/failing gh resolves ok:false.
8
+ * `cwd` scopes gh's repo resolution to the target dir so `-d/--directory` is
9
+ * honored (gh otherwise resolves the remote from process.cwd()). Not annotated
10
+ * `: GhExec` so the optional cwd stays callable; still assignable where GhExec
11
+ * is expected.
12
+ */
13
+ export const realGhExec = (args, stdin, cwd) => new Promise((resolve) => {
14
+ let settled = false;
15
+ const done = (r) => {
16
+ if (settled)
17
+ return;
18
+ settled = true;
19
+ clearTimeout(timer);
20
+ resolve(r);
21
+ };
22
+ // gh args are internal/derived from gh itself (never user free-text), so
23
+ // shell:false + an args array keeps this injection-safe.
24
+ const child = spawn('gh', args, {
25
+ cwd,
26
+ stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
27
+ });
28
+ let stdout = '';
29
+ let stderr = '';
30
+ const timer = setTimeout(() => {
31
+ child.kill();
32
+ done({ ok: false, stdout: '', stderr: 'gh timed out', code: null });
33
+ }, GH_TIMEOUT_MS);
34
+ child.stdout?.on('data', (d) => {
35
+ stdout += d;
36
+ });
37
+ child.stderr?.on('data', (d) => {
38
+ stderr += d;
39
+ });
40
+ child.on('close', (code) => done({ ok: code === 0, stdout, stderr, code }));
41
+ child.on('error', (err) => done({ ok: false, stdout: '', stderr: String(err), code: null }));
42
+ if (stdin !== undefined && child.stdin) {
43
+ child.stdin.write(stdin);
44
+ child.stdin.end();
45
+ }
46
+ });
47
+ /** The standard doctor checks these settings against. */
48
+ export const GITHUB_STANDARD = {
49
+ // Required status contexts on the default branch (strict off — see below).
50
+ requiredContexts: ['lint', 'typecheck', 'build', 'test'],
51
+ };
52
+ const CODE_SCANNING_CHECK = 'Code-scanning gate';
53
+ const CHECK_NAMES = [
54
+ 'Branch protection',
55
+ 'Merge settings',
56
+ 'Workflow permissions',
57
+ CODE_SCANNING_CHECK,
58
+ ];
59
+ // Recommended CodeQL alert thresholds — GitHub's UI defaults. This is the
60
+ // override surface: bump them here for a stricter/looser fleet-wide baseline.
61
+ // ponytail: module constants, not per-repo config, until a repo actually needs to differ.
62
+ const CODE_SCANNING_THRESHOLDS = {
63
+ security_alerts_threshold: 'high_or_higher',
64
+ alerts_threshold: 'errors',
65
+ };
66
+ const CODE_SCANNING_RULESET_NAME = 'code-scanning-main';
67
+ /** The branch ruleset POSTed to require code-scanning results before merge (#269). */
68
+ const CODE_SCANNING_RULESET_BODY = JSON.stringify({
69
+ name: CODE_SCANNING_RULESET_NAME,
70
+ target: 'branch',
71
+ enforcement: 'active',
72
+ // ~DEFAULT_BRANCH keeps this branch-name-agnostic across the fleet.
73
+ conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
74
+ rules: [
75
+ {
76
+ type: 'code_scanning',
77
+ parameters: {
78
+ code_scanning_tools: [{ tool: 'CodeQL', ...CODE_SCANNING_THRESHOLDS }],
79
+ },
80
+ },
81
+ ],
82
+ });
83
+ /** All three checks as an `ok` skip — keeps them out of next-steps and exit code. */
84
+ function skipAll(reason) {
85
+ return CHECK_NAMES.map((check) => ({ check, status: 'ok', detail: `skipped — ${reason}` }));
86
+ }
87
+ function skip(check, reason) {
88
+ return { check, status: 'ok', detail: `skipped — ${reason}` };
89
+ }
90
+ /**
91
+ * One combined probe: proves gh is installed + authed + has a GitHub remote +
92
+ * online, and carries identity, default branch, and the merge settings. Reads
93
+ * the REST repo endpoint (gh resolves the `{owner}/{repo}` placeholder from the
94
+ * remote) rather than `gh repo view --json` because auto-merge (`allow_auto_merge`)
95
+ * is not a `gh repo view` field at all — requesting it fails the whole call.
96
+ */
97
+ async function probeRepo(exec) {
98
+ const r = await exec(['api', 'repos/{owner}/{repo}']);
99
+ if (!r.ok)
100
+ return { skip: probeFailureReason(r) };
101
+ let d;
102
+ try {
103
+ d = JSON.parse(r.stdout);
104
+ }
105
+ catch {
106
+ return { skip: 'could not parse gh output' };
107
+ }
108
+ const nwo = typeof d.full_name === 'string' ? d.full_name : undefined;
109
+ if (!nwo)
110
+ return { skip: 'no GitHub remote' };
111
+ return {
112
+ info: {
113
+ nwo,
114
+ branch: typeof d.default_branch === 'string' ? d.default_branch : 'main',
115
+ autoMerge: d.allow_auto_merge === true,
116
+ squashMerge: d.allow_squash_merge === true,
117
+ deleteOnMerge: d.delete_branch_on_merge === true,
118
+ mergeVisible: ['allow_auto_merge', 'allow_squash_merge', 'delete_branch_on_merge'].every((k) => typeof d[k] === 'boolean'),
119
+ },
120
+ };
121
+ }
122
+ export async function checkGitHubSettings(dir, exec) {
123
+ // Cheap gate first: no .git → never spawn (keeps tmp-dir doctor runs offline).
124
+ if (!(await fs.pathExists(path.join(dir, '.git'))))
125
+ return skipAll('not a git repository');
126
+ // Bind gh's cwd to the target dir so its repo resolution honors `-d` (#218).
127
+ const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
128
+ const probe = await probeRepo(gh);
129
+ if ('skip' in probe)
130
+ return skipAll(probe.skip);
131
+ const { info } = probe;
132
+ return [
133
+ await checkBranchProtection(gh, info.nwo, info.branch),
134
+ checkMergeSettings(info),
135
+ await checkWorkflowPermissions(gh, info.nwo),
136
+ await checkCodeScanningRuleset(gh, info.nwo, info.branch, dir),
137
+ ];
138
+ }
139
+ function probeFailureReason(probe) {
140
+ if (probe.code === null)
141
+ return 'gh not installed';
142
+ if (/not logged|gh auth login|authentication/i.test(probe.stderr))
143
+ return 'gh not authenticated';
144
+ return 'not a GitHub repo or gh unavailable';
145
+ }
146
+ async function checkBranchProtection(exec, nwo, branch) {
147
+ const check = 'Branch protection';
148
+ const r = await exec(['api', `repos/${nwo}/branches/${branch}/protection`]);
149
+ if (!r.ok) {
150
+ if (/404|not found/i.test(r.stderr)) {
151
+ return {
152
+ check,
153
+ status: 'optional-missing',
154
+ detail: `${branch} is unprotected`,
155
+ hint: `Protect ${branch}: require checks ${GITHUB_STANDARD.requiredContexts.join(', ')}, no force-push/deletions`,
156
+ };
157
+ }
158
+ if (/403|forbidden/i.test(r.stderr))
159
+ return skip(check, 'token lacks admin access');
160
+ return skip(check, 'could not read branch protection');
161
+ }
162
+ let p;
163
+ try {
164
+ p = JSON.parse(r.stdout);
165
+ }
166
+ catch {
167
+ return skip(check, 'could not parse protection response');
168
+ }
169
+ const deltas = [];
170
+ const contexts = p.required_status_checks?.contexts ?? [];
171
+ // A matrix job reports each leg as `test (node 22)`, `test (node 24)`, etc.
172
+ // Treat any `<ctx> (...)` variant as satisfying the bare `<ctx>` requirement,
173
+ // so matrix CIs aren't falsely flagged as missing the check.
174
+ const isSatisfied = (c) => contexts.some((ctx) => ctx === c || ctx.startsWith(`${c} (`));
175
+ const missing = GITHUB_STANDARD.requiredContexts.filter((c) => !isSatisfied(c));
176
+ if (missing.length)
177
+ deltas.push(`missing required checks: ${missing.join(', ')}`);
178
+ if (p.required_status_checks?.strict === true)
179
+ deltas.push('strict status checks on (should be off)');
180
+ // enforce_admins must stay off so the App/RELEASE_TOKEN can bypass protection
181
+ // to push semantic-release's version commit (see Release-token doctor check).
182
+ if (p.enforce_admins?.enabled === true)
183
+ deltas.push('enforce_admins on (blocks release bypass)');
184
+ if (p.allow_force_pushes?.enabled === true)
185
+ deltas.push('force pushes allowed');
186
+ if (p.allow_deletions?.enabled === true)
187
+ deltas.push('branch deletions allowed');
188
+ // Required human review deadlocks solo Dependabot auto-merge.
189
+ if (p.required_pull_request_reviews)
190
+ deltas.push('required PR reviews on (deadlocks auto-merge)');
191
+ if (deltas.length)
192
+ return {
193
+ check,
194
+ status: 'drift',
195
+ detail: deltas.join('; '),
196
+ hint: `Align ${branch} protection with the standard`,
197
+ };
198
+ return { check, status: 'ok', detail: `${branch} protected per standard` };
199
+ }
200
+ function checkMergeSettings(info) {
201
+ const check = 'Merge settings';
202
+ // The token can't see the merge-setting fields (no admin:read) — skip rather
203
+ // than misreport the absent booleans as "disabled" (a false-positive drift).
204
+ if (!info.mergeVisible)
205
+ return skip(check, 'token lacks admin:read for merge settings');
206
+ const deltas = [];
207
+ if (!info.autoMerge)
208
+ deltas.push('auto-merge disabled');
209
+ if (!info.squashMerge)
210
+ deltas.push('squash-merge disabled');
211
+ if (!info.deleteOnMerge)
212
+ deltas.push('delete-branch-on-merge disabled');
213
+ if (deltas.length)
214
+ return {
215
+ check,
216
+ status: 'drift',
217
+ detail: deltas.join('; '),
218
+ hint: 'Enable auto-merge, squash-merge, and delete-branch-on-merge in repo settings',
219
+ };
220
+ return { check, status: 'ok', detail: 'auto-merge, squash-merge, delete-on-merge all on' };
221
+ }
222
+ async function checkWorkflowPermissions(exec, nwo) {
223
+ const check = 'Workflow permissions';
224
+ const r = await exec(['api', `repos/${nwo}/actions/permissions/workflow`]);
225
+ if (!r.ok) {
226
+ if (/403|forbidden/i.test(r.stderr))
227
+ return skip(check, 'token lacks admin access');
228
+ return skip(check, 'could not read workflow permissions');
229
+ }
230
+ let p;
231
+ try {
232
+ p = JSON.parse(r.stdout);
233
+ }
234
+ catch {
235
+ return skip(check, 'could not parse permissions response');
236
+ }
237
+ const deltas = [];
238
+ if (p.default_workflow_permissions !== 'read')
239
+ deltas.push(`default permissions '${p.default_workflow_permissions}' (should be read)`);
240
+ if (p.can_approve_pull_request_reviews === true)
241
+ deltas.push('workflows can approve PRs (should be off)');
242
+ if (deltas.length)
243
+ return {
244
+ check,
245
+ status: 'drift',
246
+ detail: deltas.join('; '),
247
+ hint: 'Set default workflow permissions to read-only and disable workflow PR approvals',
248
+ };
249
+ return { check, status: 'ok', detail: 'read-only default, no workflow PR approvals' };
250
+ }
251
+ /**
252
+ * True when CodeQL/code-scanning is enabled for the repo. Covers both ways it
253
+ * ships: an advanced-setup workflow on disk (what `fix codeql` scaffolds) or
254
+ * GitHub's default setup (no file — ask the code-scanning API). Mirrors doctor's
255
+ * on-disk CodeQL check; kept inline to avoid a doctor↔settings import cycle.
256
+ */
257
+ async function codeqlEnabled(gh, nwo, dir) {
258
+ const workflowsDir = path.join(dir, '.github', 'workflows');
259
+ if (await fs.pathExists(workflowsDir)) {
260
+ try {
261
+ for (const f of await fs.readdir(workflowsDir)) {
262
+ if (!/\.ya?ml$/.test(f))
263
+ continue;
264
+ const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
265
+ if (/github\/codeql-action/.test(content))
266
+ return true;
267
+ }
268
+ }
269
+ catch {
270
+ // fall through to the API probe
271
+ }
272
+ }
273
+ // Default setup leaves no workflow file — the API is the only signal.
274
+ const r = await gh(['api', `repos/${nwo}/code-scanning/default-setup`]);
275
+ if (!r.ok)
276
+ return false;
277
+ try {
278
+ return JSON.parse(r.stdout).state === 'configured';
279
+ }
280
+ catch {
281
+ return false;
282
+ }
283
+ }
284
+ /** True when a ruleset's ref_name conditions cover the default branch. */
285
+ function rulesetTargetsBranch(conditions, branch) {
286
+ const include = conditions?.ref_name?.include ?? [];
287
+ return include.some((ref) => ref === '~DEFAULT_BRANCH' || ref === '~ALL' || ref === `refs/heads/${branch}`);
288
+ }
289
+ /**
290
+ * Does an active branch ruleset enforce a code_scanning rule on the default
291
+ * branch? The list endpoint omits rules/conditions, so active branch rulesets
292
+ * are fetched by id to inspect them. 'skip' on any read failure (self-skips like
293
+ * the other checks).
294
+ */
295
+ async function hasCodeScanningRuleset(gh, nwo, branch) {
296
+ const list = await gh(['api', `repos/${nwo}/rulesets`]);
297
+ if (!list.ok)
298
+ return 'skip';
299
+ let rulesets;
300
+ try {
301
+ rulesets = JSON.parse(list.stdout);
302
+ }
303
+ catch {
304
+ return 'skip';
305
+ }
306
+ if (!Array.isArray(rulesets))
307
+ return 'skip';
308
+ const active = rulesets.filter((r) => r.target === 'branch' && r.enforcement === 'active' && typeof r.id === 'number');
309
+ for (const rs of active) {
310
+ const detail = await gh(['api', `repos/${nwo}/rulesets/${rs.id}`]);
311
+ if (!detail.ok)
312
+ continue;
313
+ let full;
314
+ try {
315
+ full = JSON.parse(detail.stdout);
316
+ }
317
+ catch {
318
+ continue;
319
+ }
320
+ const enforcesCodeScanning = (full.rules ?? []).some((r) => r.type === 'code_scanning');
321
+ if (enforcesCodeScanning && rulesetTargetsBranch(full.conditions, branch))
322
+ return 'yes';
323
+ }
324
+ return 'no';
325
+ }
326
+ /**
327
+ * The #269 gap: CodeQL results are advisory by default — a High alert still
328
+ * merges unless a branch ruleset requires the code-scanning check. Only
329
+ * meaningful where CodeQL is actually on, so it no-ops otherwise.
330
+ */
331
+ async function checkCodeScanningRuleset(gh, nwo, branch, dir) {
332
+ const check = CODE_SCANNING_CHECK;
333
+ if (!(await codeqlEnabled(gh, nwo, dir))) {
334
+ return { check, status: 'ok', detail: 'CodeQL not enabled — no code-scanning gate needed' };
335
+ }
336
+ const found = await hasCodeScanningRuleset(gh, nwo, branch);
337
+ if (found === 'skip')
338
+ return skip(check, 'could not read rulesets');
339
+ if (found === 'yes') {
340
+ return { check, status: 'ok', detail: `active ruleset requires code-scanning on ${branch}` };
341
+ }
342
+ return {
343
+ check,
344
+ status: 'drift',
345
+ detail: `CodeQL is on but no active ruleset requires code-scanning on ${branch} (High alerts stay advisory)`,
346
+ hint: 'Run `npx @rtorcato/repo-tooling fix github-settings` to add a code_scanning branch ruleset that blocks merge on High+ CodeQL alerts',
347
+ };
348
+ }
349
+ /** The branch-protection body PUT to the API — mirrors the doctor standard. */
350
+ const PROTECTION_BODY = JSON.stringify({
351
+ required_status_checks: { strict: false, contexts: GITHUB_STANDARD.requiredContexts },
352
+ // enforce_admins off so the App/RELEASE_TOKEN can bypass to push release commits.
353
+ enforce_admins: false,
354
+ // Required human review would deadlock solo Dependabot auto-merge.
355
+ required_pull_request_reviews: null,
356
+ restrictions: null,
357
+ allow_force_pushes: false,
358
+ allow_deletions: false,
359
+ });
360
+ /** Pure: the exact `gh api` invocations for whatever deviates ([] when compliant). */
361
+ export function buildGhApplyCommands(state) {
362
+ const commands = [];
363
+ if (state.merge)
364
+ commands.push({
365
+ label: 'merge settings (auto-merge, squash, delete-on-merge)',
366
+ args: [
367
+ 'api',
368
+ '-X',
369
+ 'PATCH',
370
+ `repos/${state.nwo}`,
371
+ '-F',
372
+ 'allow_auto_merge=true',
373
+ '-F',
374
+ 'allow_squash_merge=true',
375
+ '-F',
376
+ 'delete_branch_on_merge=true',
377
+ ],
378
+ });
379
+ if (state.protection)
380
+ commands.push({
381
+ label: `branch protection on ${state.branch}`,
382
+ args: [
383
+ 'api',
384
+ '-X',
385
+ 'PUT',
386
+ `repos/${state.nwo}/branches/${state.branch}/protection`,
387
+ '--input',
388
+ '-',
389
+ ],
390
+ stdin: PROTECTION_BODY,
391
+ });
392
+ if (state.workflow)
393
+ commands.push({
394
+ label: 'workflow permissions (read-only)',
395
+ args: [
396
+ 'api',
397
+ '-X',
398
+ 'PUT',
399
+ `repos/${state.nwo}/actions/permissions/workflow`,
400
+ '-f',
401
+ 'default_workflow_permissions=read',
402
+ '-F',
403
+ 'can_approve_pull_request_reviews=false',
404
+ ],
405
+ });
406
+ return commands;
407
+ }
408
+ /**
409
+ * Re-reads GitHub state and applies only the deltas via `gh api` (idempotent —
410
+ * a compliant repo is a no-op). Returns human labels for what changed, or `[]`
411
+ * on skip (no gh/auth/remote, or already compliant), logging the reason. Mirrors
412
+ * the "no package.json found — skipping" fixer pattern. Read-only unless a delta
413
+ * exists, so re-runs (walk-all hits it up to 3×) are safe.
414
+ */
415
+ export async function applyGithubSettings(dir, exec) {
416
+ if (!(await fs.pathExists(path.join(dir, '.git')))) {
417
+ console.log(chalk.gray(' skipped — not a git repository'));
418
+ return [];
419
+ }
420
+ // Bind gh's cwd to the target dir so its repo resolution honors `-d` (#218).
421
+ const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
422
+ const probe = await probeRepo(gh);
423
+ if ('skip' in probe) {
424
+ console.log(chalk.gray(` skipped — ${probe.skip}`));
425
+ return [];
426
+ }
427
+ const { info } = probe;
428
+ // Re-read via the same checks so the delta logic stays single-sourced. A 403
429
+ // (no admin) reports `ok` → treated as "nothing to apply", never a failed PUT.
430
+ const bp = await checkBranchProtection(gh, info.nwo, info.branch);
431
+ const wp = await checkWorkflowPermissions(gh, info.nwo);
432
+ const commands = buildGhApplyCommands({
433
+ nwo: info.nwo,
434
+ branch: info.branch,
435
+ merge: checkMergeSettings(info).status === 'drift',
436
+ protection: bp.status === 'optional-missing' || bp.status === 'drift',
437
+ workflow: wp.status === 'drift',
438
+ });
439
+ const applied = [];
440
+ for (const cmd of commands) {
441
+ const r = await gh(cmd.args, cmd.stdin);
442
+ if (r.ok)
443
+ applied.push(cmd.label);
444
+ else
445
+ console.log(chalk.yellow(` could not apply ${cmd.label}: ${r.stderr.trim() || 'gh error'}`));
446
+ }
447
+ // Code-scanning ruleset (#269): POST only when CodeQL is on and no active gate
448
+ // covers the default branch — the check re-read keeps this idempotent.
449
+ const cs = await checkCodeScanningRuleset(gh, info.nwo, info.branch, dir);
450
+ if (cs.status === 'drift') {
451
+ const label = `code-scanning ruleset on ${info.branch}`;
452
+ const r = await gh(['api', '-X', 'POST', `repos/${info.nwo}/rulesets`, '--input', '-'], CODE_SCANNING_RULESET_BODY);
453
+ if (r.ok)
454
+ applied.push(label);
455
+ else
456
+ console.log(chalk.yellow(` could not apply ${label}: ${r.stderr.trim() || 'gh error'}`));
457
+ }
458
+ if (applied.length === 0)
459
+ console.log(chalk.gray(' already configured — nothing to apply'));
460
+ return applied;
461
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -40,7 +40,17 @@ export async function generateBiomeConfig(targetDir) {
40
40
  export async function generateESLintConfig(config, targetDir) {
41
41
  const eslintConfigPath = path.join(targetDir, 'eslint.config.mjs');
42
42
  const configType = config.linting.eslintConfig || 'base';
43
- const eslintConfig = `import { default as config } from '@rtorcato/repo-tooling/eslint/${configType}'
43
+ // eslint/nextjs is a fragment: it registers the @next/next plugin and its
44
+ // rules but sets no parser, so on its own it can't even parse .tsx ("Parsing
45
+ // error: Unexpected token <"). It has to be layered onto the base config,
46
+ // which is what brings typescript-eslint.
47
+ const eslintConfig = configType === 'nextjs'
48
+ ? `import base from '@rtorcato/repo-tooling/eslint/base'
49
+ import nextjs from '@rtorcato/repo-tooling/eslint/nextjs'
50
+
51
+ export default [...base, ...nextjs]
52
+ `
53
+ : `import { default as config } from '@rtorcato/repo-tooling/eslint/${configType}'
44
54
 
45
55
  export default config
46
56
  `;
@@ -237,6 +237,24 @@ function getDependencies(config) {
237
237
  if (config.linting.tool === 'eslint' || config.linting.tool === 'both') {
238
238
  deps['eslint'] = '^9.0.0';
239
239
  deps['prettier'] = '^3.0.0';
240
+ // Everything tooling/eslint/base.mjs imports. All optional peers of
241
+ // repo-tooling, so pnpm skips them and `eslint .` dies on the config
242
+ // import — the scaffold shipped a lint script that could never run.
243
+ deps['@eslint/js'] = '^9.0.0';
244
+ deps['@typescript-eslint/eslint-plugin'] = '^8.0.0';
245
+ deps['typescript-eslint'] = '^8.0.0';
246
+ deps['eslint-plugin-import'] = '^2.0.0';
247
+ deps['eslint-plugin-jest'] = '^29.0.0';
248
+ }
249
+ // The shipped prettier preset lists this in `plugins`, and the nextjs eslint
250
+ // preset imports the Next plugin. Both are only *optional* peers of
251
+ // repo-tooling, so pnpm skips them and the scaffold's first format/lint dies
252
+ // with ERR_MODULE_NOT_FOUND.
253
+ if (config.formatting.tool === 'prettier') {
254
+ deps['@ianvs/prettier-plugin-sort-imports'] = '^4.7.0';
255
+ }
256
+ if (config.linting.eslintConfig === 'nextjs') {
257
+ deps['@next/eslint-plugin-next'] = '^16.2.11';
240
258
  }
241
259
  // Testing frameworks
242
260
  if (config.testing.framework === 'vitest') {
@@ -266,6 +284,10 @@ function getDependencies(config) {
266
284
  }
267
285
  else if (config.bundler === 'esbuild') {
268
286
  deps['esbuild'] = '^0.25.0';
287
+ // The generated build.mjs imports this directly. It's only an *optional*
288
+ // peer of repo-tooling, so pnpm won't pull it in — without it here every
289
+ // esbuild scaffold fails its first `pnpm build` with ERR_MODULE_NOT_FOUND.
290
+ deps['esbuild-node-externals'] = '^1.18.0';
269
291
  }
270
292
  else if (config.bundler === 'rollup') {
271
293
  deps['rollup'] = '^4.0.0';
@@ -278,6 +300,11 @@ function getDependencies(config) {
278
300
  }
279
301
  else if (config.bundler === 'vite') {
280
302
  deps['vite'] = '^6.0.0';
303
+ // generateViteConfig emits `import react from '@vitejs/plugin-react'` for
304
+ // react-app, so the plugin has to be installed or `vite build` can't even
305
+ // load the config. Keep this condition in step with that one.
306
+ if (config.projectType === 'react-app')
307
+ deps['@vitejs/plugin-react'] = '^5.0.0';
281
308
  }
282
309
  // Tailwind CSS v4 — CSS-first, wired via the PostCSS plugin.
283
310
  if (config.tailwind) {
@@ -16,10 +16,21 @@ export async function generateTestingConfigs(config, targetDir) {
16
16
  }
17
17
  export async function generateVitestConfig(config, targetDir) {
18
18
  const vitestConfigPath = path.join(targetDir, 'vitest.config.ts');
19
+ // Vite reads `jsx` from tsconfig, and the next preset sets it to "preserve" so
20
+ // Next's own compiler handles the transform. Vitest has no such compiler, so
21
+ // .tsx tests fail to parse ("Unexpected token <") unless we opt back in here.
22
+ // `oxc` (not `esbuild`) is the Vite 8 spelling — vitest ^4 resolves Vite 8,
23
+ // and Vite 8 silently ignores the old `esbuild` key.
24
+ const jsxOverride = config.projectType === 'nextjs-app'
25
+ ? ` // tsconfig sets jsx: "preserve" for Next's compiler; vitest needs a
26
+ // transform of its own or .tsx tests won't parse.
27
+ oxc: { jsx: { runtime: 'automatic' } },
28
+ `
29
+ : '';
19
30
  const vitestConfig = `import { defineConfig } from 'vitest/config'
20
31
 
21
32
  export default defineConfig({
22
- test: {
33
+ ${jsxOverride} test: {
23
34
  globals: true,
24
35
  environment: '${config.testing.environment === 'browser' ? 'jsdom' : 'node'}',
25
36
  setupFiles: ['./vitest.setup.ts'],
@@ -19,6 +19,12 @@ export async function generateTSConfig(config, targetDir) {
19
19
  tsconfig.compilerOptions.outDir = './dist';
20
20
  tsconfig.compilerOptions.rootDir = './src';
21
21
  }
22
+ // web-app is the only browser target that lands on the `base` preset, whose
23
+ // lib is ES2022 — so `document`/`window` don't typecheck. The react and next
24
+ // presets already add the DOM libs themselves.
25
+ if (config.projectType === 'web-app' && preset === 'base') {
26
+ tsconfig.compilerOptions.lib = ['ES2022', 'DOM', 'DOM.Iterable'];
27
+ }
22
28
  if (config.projectType === 'nextjs-app') {
23
29
  tsconfig.include = ['next-env.d.ts', 'src', 'app', 'pages', 'components', 'reset.d.ts'];
24
30
  tsconfig.exclude.push('.next');
@@ -102,7 +102,7 @@ export function githubJobs(config) {
102
102
  ? `
103
103
 
104
104
  - name: 📊 Upload coverage to Codecov
105
- uses: codecov/codecov-action@v5
105
+ uses: codecov/codecov-action@v7
106
106
  with:
107
107
  token: \${{ secrets.CODECOV_TOKEN }}
108
108
  fail_ci_if_error: false`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.2.3",
3
+ "version": "3.2.5",
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": [
@@ -46,6 +46,7 @@
46
46
  "files": [
47
47
  "dist/cli/**/*.js",
48
48
  "dist/languages/**/*.js",
49
+ "dist/base/**/*.js",
49
50
  "tooling/commitlint/commitlint.mjs",
50
51
  "tooling/commitlint/commitlint.d.mts",
51
52
  "tooling/esbuild/index.mjs",
@@ -43,6 +43,14 @@ export default tseslint.config(
43
43
  ignores: [
44
44
  //
45
45
  '**/*.config.js',
46
+ // Root-level TS config files are outside the tsconfig `include` of every
47
+ // app preset, and this config lints type-aware (parserOptions.project),
48
+ // so linting them fails with "file was not found in any of the provided
49
+ // project(s)". The .js forms were already ignored; these are the same
50
+ // thing (the scaffold emits vitest.config.ts / vitest.setup.ts).
51
+ '**/*.config.ts',
52
+ '**/*.config.mts',
53
+ '**/*.setup.ts',
46
54
  '.turbo',
47
55
  'dist',
48
56
  'build',
@@ -26,7 +26,7 @@ jobs:
26
26
  node-version-file: .nvmrc
27
27
 
28
28
  - name: Setup pnpm
29
- uses: pnpm/action-setup@v4
29
+ uses: pnpm/action-setup@v6
30
30
 
31
31
  - name: Install dependencies
32
32
  run: pnpm install --frozen-lockfile
@@ -9,8 +9,12 @@
9
9
  // "moduleResolution": "node", // Prefer "bundler" if you're using Vite, esbuild, etc.
10
10
  // "target": "esnext", //
11
11
  "lib": ["ES2022"], // Specify library files to be included in the compilation
12
- "rootDir": "src",
13
- "outDir": "dist",
12
+ // ${configDir} for the same reason as paths/tsBuildInfoFile below: a bare
13
+ // "src" resolves against THIS file, i.e. inside node_modules, so every
14
+ // consumer that didn't redeclare rootDir got TS6059 "not under rootDir" the
15
+ // moment a file outside the preset's own directory entered the program.
16
+ "rootDir": "${configDir}/src",
17
+ "outDir": "${configDir}/dist",
14
18
 
15
19
 
16
20
  // 🔍 JavaScript Support
@@ -2,12 +2,19 @@
2
2
  "extends": "./tsconfig.base.json",
3
3
  "compilerOptions": {
4
4
  "jsx": "preserve", // Let Next.js handle JSX transformation
5
+ // The base pins rootDir to src/, but `include` below spans app/, pages/ and
6
+ // components/ — all siblings of src/ — so tsc raised TS6059 on any Next repo
7
+ // using the app router. These presets are noEmit, so rootDir buys nothing
8
+ // here beyond that trap; widen it to the project root.
9
+ "rootDir": "${configDir}",
5
10
  "lib": ["DOM", "DOM.Iterable", "ESNext"], // Include DOM types for browser APIs
6
11
  "module": "esnext", // Next.js expects ESNext modules
7
12
  "moduleResolution": "bundler", // Modern module resolution for Next.js
8
13
  "allowJs": true, // Allow JavaScript files
9
14
  "esModuleInterop": true, // Interop for CommonJS/ESM
10
- "types": ["react", "tailwindcss", "vitest"],
15
+ // No "tailwindcss" — see tsconfig.react.json; it broke TS2688 for every
16
+ // project without Tailwind installed.
17
+ "types": ["react", "vitest"],
11
18
  "plugins": [ { "name": "next" } ], // Next.js plugin for TypeScript
12
19
  // ${configDir} anchors paths to the consuming project (drops deprecated baseUrl)
13
20
  "paths": {