@rtorcato/repo-tooling 3.29.0 → 3.30.0
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/AGENTS.md +2 -0
- package/dist/cli/commands/doctor.js +36 -1
- package/dist/cli/commands/setup.js +80 -22
- package/dist/cli/index.js +5 -0
- package/dist/cli/utils/json-schema.js +64 -0
- package/dist/cli/utils/lockfile.js +21 -13
- package/dist/cli/utils/reference-rules.js +125 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -19,6 +19,8 @@ Every command supports `--json` and a non-interactive mode. Combine with `--yes`
|
|
|
19
19
|
| `setup --config-schema` | ✅ | ✅ (JSON Schema) | Print the JSON Schema for `ProjectConfig`. Use to validate configs before scaffolding. |
|
|
20
20
|
| `setup --dry-run` | ✅ | ✅ | Print resolved config + file list without writing. Pair with `--preset` or `--config`. |
|
|
21
21
|
| `doctor --json` | ✅ | ✅ | Audit a project. Returns `{ directory, results: [{ check, status, detail, hint? }] }`. Status: `ok` / `drift` / `missing` / `optional-missing` / `declared`. |
|
|
22
|
+
| `doctor --rules-from <owner/repo>` | ✅ | ✅ | Report how this repo's `config`/`rules` differ from a reference repo's `.repo-tooling.json` (read over `gh`). Informational only — adds a `rulesReference` key to the JSON, never a check result, never affects the exit code. |
|
|
23
|
+
| `setup --from <owner/repo>` | ❌ (seeds the wizard) | `--dry-run` only | Seed the wizard's defaults from a reference repo's recorded config. Seeded, not skipped — every question is still asked. |
|
|
22
24
|
| `fix --json --yes` | ✅ | ✅ | Walk every doctor finding, apply fixers. Returns `FixActionRecord[]` with `status: applied | dry-run | skipped | already-ok | unsupported`. |
|
|
23
25
|
| `fix <target> --json --yes` | ✅ | ✅ | Apply one fixer. Targets from `list --json`. |
|
|
24
26
|
| `fix --dry-run` | ✅ | ✅ | Print what each fixer would write without writing. Combine with `--json`. |
|
|
@@ -19,6 +19,7 @@ import { checkMilestones } from '../../base/milestones.js';
|
|
|
19
19
|
import { checkGitIdentity, checkGitIdentityHistory } from '../../base/git-identity.js';
|
|
20
20
|
import { checkCopiedAssets } from '../utils/copied-assets.js';
|
|
21
21
|
import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
|
|
22
|
+
import { compareRulesWithReference } from '../utils/reference-rules.js';
|
|
22
23
|
import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
|
|
23
24
|
import { checkAiSetup, checkBrand, checkCodeowners, checkClaudeSkills, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, checkRecommendedMcp, checkRequiredSkills, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
|
|
24
25
|
import { allDeps, checkAreTheTypesWrong, checkBiome, checkClaudeWorktreeSettings, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPackageManager, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
|
|
@@ -465,11 +466,43 @@ export function summarize(results) {
|
|
|
465
466
|
declared: results.filter((r) => r.status === 'declared').length,
|
|
466
467
|
};
|
|
467
468
|
}
|
|
469
|
+
/**
|
|
470
|
+
* Print the `--rules-from` comparison (#563). Deliberately not a CheckResult:
|
|
471
|
+
* two repos legitimately differ, so a difference is never a finding about either
|
|
472
|
+
* one, and keeping it out of `results` is what makes "never affects the exit
|
|
473
|
+
* code" structural rather than a promise the next check has to remember.
|
|
474
|
+
*/
|
|
475
|
+
function printRulesComparison(comparison) {
|
|
476
|
+
console.log(chalk.cyan(`\n📐 Rules vs ${comparison.reference}`), chalk.gray('(informational — differences are not drift and do not affect the exit code)\n'));
|
|
477
|
+
if (!comparison.compared) {
|
|
478
|
+
console.log(` ${chalk.gray('➖')} ${chalk.gray(`not compared — ${comparison.reason}`)}\n`);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const differences = comparison.differences ?? [];
|
|
482
|
+
if (differences.length === 0) {
|
|
483
|
+
console.log(` ${chalk.green('✅')} ${chalk.gray('identical — no differences to report')}\n`);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const show = (v) => (v === undefined ? chalk.dim('(absent)') : JSON.stringify(v));
|
|
487
|
+
for (const d of differences) {
|
|
488
|
+
console.log(` ${chalk.bold(d.path)}`);
|
|
489
|
+
console.log(` ${chalk.gray('here:')} ${show(d.local)}`);
|
|
490
|
+
console.log(` ${chalk.gray(`${comparison.reference}:`)} ${show(d.reference)}`);
|
|
491
|
+
}
|
|
492
|
+
console.log(chalk.gray(`\n ${differences.length} difference(s).\n`));
|
|
493
|
+
}
|
|
468
494
|
export async function doctorCommand(options = {}) {
|
|
469
495
|
const dir = options.directory ?? process.cwd();
|
|
470
496
|
const results = await runDoctor(dir, options.skillsDir);
|
|
497
|
+
const comparison = options.rulesFrom
|
|
498
|
+
? await compareRulesWithReference(dir, options.rulesFrom)
|
|
499
|
+
: null;
|
|
471
500
|
if (options.json) {
|
|
472
|
-
console.log(JSON.stringify({
|
|
501
|
+
console.log(JSON.stringify({
|
|
502
|
+
directory: path.resolve(dir),
|
|
503
|
+
results,
|
|
504
|
+
...(comparison ? { rulesReference: comparison } : {}),
|
|
505
|
+
}, null, 2));
|
|
473
506
|
}
|
|
474
507
|
else {
|
|
475
508
|
console.log(chalk.cyan(`\n🩺 Diagnosing ${path.resolve(dir)} against ${PACKAGE} presets...\n`));
|
|
@@ -491,6 +524,8 @@ export async function doctorCommand(options = {}) {
|
|
|
491
524
|
}
|
|
492
525
|
console.log();
|
|
493
526
|
}
|
|
527
|
+
if (comparison)
|
|
528
|
+
printRulesComparison(comparison);
|
|
494
529
|
}
|
|
495
530
|
const summary = summarize(results);
|
|
496
531
|
const exitCode = summary.drift > 0 || summary.missing > 0 ? 1 : 0;
|
|
@@ -8,6 +8,7 @@ import { detectLanguage } from '../utils/detect-language.js';
|
|
|
8
8
|
import { formatGeneratedFiles } from '../utils/format.js';
|
|
9
9
|
import { installDependencies } from '../utils/install.js';
|
|
10
10
|
import { LOCKFILE_NAME, writeLockfile } from '../utils/lockfile.js';
|
|
11
|
+
import { fetchReferenceLockfile } from '../utils/reference-rules.js';
|
|
11
12
|
import { buildPresetConfig, computeFileList, CONFIG_SCHEMA, PRESET_NAMES, validateProjectConfig, } from './setup-presets.js';
|
|
12
13
|
/**
|
|
13
14
|
* The opinionated extras a preset turns on, in the order they're offered for
|
|
@@ -63,11 +64,36 @@ async function reviewPresetConfig(config) {
|
|
|
63
64
|
}
|
|
64
65
|
return reviewed;
|
|
65
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Read a reference repo's recorded config to seed the wizard (#563). Only
|
|
69
|
+
* `record.config` — the answers — crosses over: `assets` and the
|
|
70
|
+
* `writtenBy`/`writtenAt` stamps describe the reference's own history, and this
|
|
71
|
+
* repo's lockfile gets its own on the first write. `rules` is left behind too,
|
|
72
|
+
* since an `exceptions` entry excuses a deviation in the repo that wrote it.
|
|
73
|
+
*
|
|
74
|
+
* `null` means the reference could not be read — reported plainly and declined,
|
|
75
|
+
* the way `doctor --rules-from` reports "not compared". Falling through to an
|
|
76
|
+
* unseeded wizard would be worse: the user asked for that repo's answers, and
|
|
77
|
+
* generic defaults would be written as though they had confirmed them.
|
|
78
|
+
*/
|
|
79
|
+
async function seedFromReference(reference) {
|
|
80
|
+
const result = await fetchReferenceLockfile(reference);
|
|
81
|
+
if (!result.ok) {
|
|
82
|
+
console.error(chalk.red(`\n❌ Cannot seed from ${reference} — ${result.reason}.`));
|
|
83
|
+
console.error(chalk.gray(' Re-run without --from to answer the wizard from scratch.\n'));
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
console.log(chalk.cyan(`\n🌱 Seeding defaults from ${reference} — every answer is still yours to change.\n`));
|
|
87
|
+
return result.lockfile.record.config;
|
|
88
|
+
}
|
|
66
89
|
/** `null` means the run was declined, not that it failed — see promptForConfig. */
|
|
67
90
|
async function resolveConfig(options) {
|
|
68
91
|
if (options.config && options.preset) {
|
|
69
92
|
console.warn(chalk.yellow('⚠️ Both --config and --preset given; --config wins.\n'));
|
|
70
93
|
}
|
|
94
|
+
if (options.from && (options.config || options.preset)) {
|
|
95
|
+
console.warn(chalk.yellow('⚠️ --from seeds the wizard, which --config/--preset skip; ignoring --from.\n'));
|
|
96
|
+
}
|
|
71
97
|
if (options.config) {
|
|
72
98
|
const configPath = path.resolve(options.config);
|
|
73
99
|
if (!(await fs.pathExists(configPath))) {
|
|
@@ -99,7 +125,14 @@ async function resolveConfig(options) {
|
|
|
99
125
|
return config;
|
|
100
126
|
return reviewPresetConfig(config);
|
|
101
127
|
}
|
|
102
|
-
|
|
128
|
+
let seed;
|
|
129
|
+
if (options.from) {
|
|
130
|
+
const seeded = await seedFromReference(options.from);
|
|
131
|
+
if (seeded === null)
|
|
132
|
+
return null;
|
|
133
|
+
seed = seeded;
|
|
134
|
+
}
|
|
135
|
+
return promptForConfig(path.resolve(options.directory), seed);
|
|
103
136
|
}
|
|
104
137
|
export async function setupProject(options) {
|
|
105
138
|
if (options.configSchema) {
|
|
@@ -165,7 +198,7 @@ const SCAFFOLDABLE = ['js', 'swift'];
|
|
|
165
198
|
* like. Languages setup can't scaffold yet are still offered — hiding them reads
|
|
166
199
|
* as "repo-tooling is JS-only" when the honest answer is "not yet" (#139).
|
|
167
200
|
*/
|
|
168
|
-
async function promptForLanguage(targetDir) {
|
|
201
|
+
async function promptForLanguage(targetDir, seed) {
|
|
169
202
|
const detected = await detectLanguage(targetDir);
|
|
170
203
|
const { language } = await inquirer.prompt([
|
|
171
204
|
{
|
|
@@ -178,8 +211,10 @@ async function promptForLanguage(targetDir) {
|
|
|
178
211
|
: `${module.label} (${module.supported ? 'doctor/fix only — no setup preset yet' : 'setup lands with its module'})`,
|
|
179
212
|
value: module.id,
|
|
180
213
|
})),
|
|
214
|
+
// What this dir already looks like beats the reference's language: the
|
|
215
|
+
// files on disk are evidence, the reference is only a suggestion.
|
|
181
216
|
// 'unknown' is a bare dir mid-setup — JS is the historical default.
|
|
182
|
-
default: detected
|
|
217
|
+
default: detected !== 'unknown' ? detected : (seed?.language ?? 'js'),
|
|
183
218
|
},
|
|
184
219
|
]);
|
|
185
220
|
return LANGUAGES[language];
|
|
@@ -196,9 +231,16 @@ function explainNotScaffoldable(module) {
|
|
|
196
231
|
console.log(chalk.gray(` ${module.label} setup presets are tracked at\n` +
|
|
197
232
|
' https://github.com/rtorcato/repo-tooling/issues/139\n'));
|
|
198
233
|
}
|
|
199
|
-
/**
|
|
200
|
-
|
|
201
|
-
|
|
234
|
+
/**
|
|
235
|
+
* `null` when the chosen language has no scaffolding yet; nothing is written.
|
|
236
|
+
*
|
|
237
|
+
* @param seed A reference repo's recorded config (#563). It moves the *defaults*
|
|
238
|
+
* only — every question is still asked, so seeding can never write an answer
|
|
239
|
+
* the user did not see. `projectName` is deliberately never seeded: a new repo
|
|
240
|
+
* is not the reference repo.
|
|
241
|
+
*/
|
|
242
|
+
async function promptForConfig(targetDir, seed) {
|
|
243
|
+
const language = await promptForLanguage(targetDir, seed);
|
|
202
244
|
// Gate on SCAFFOLDABLE, not `module.supported` — Swift is supported (#286)
|
|
203
245
|
// but has no preset yet (#288), and falling through here would write a
|
|
204
246
|
// package.json into a Swift repo.
|
|
@@ -228,6 +270,18 @@ async function promptForConfig(targetDir) {
|
|
|
228
270
|
// Turborepo only makes sense in a pnpm-workspace monorepo, so the prompt is
|
|
229
271
|
// only offered when one is already present in the target dir.
|
|
230
272
|
const hasWorkspace = await fs.pathExists(path.join(targetDir, 'pnpm-workspace.yaml'));
|
|
273
|
+
// Two questions ask for something the config stores as a set of booleans, so
|
|
274
|
+
// their seeded default has to be derived rather than read.
|
|
275
|
+
const seededReleaseTool = !seed
|
|
276
|
+
? 'semantic-release'
|
|
277
|
+
: seed.semanticRelease
|
|
278
|
+
? 'semantic-release'
|
|
279
|
+
: seed.changesets
|
|
280
|
+
? 'changesets'
|
|
281
|
+
: seed.releasePlease
|
|
282
|
+
? 'release-please'
|
|
283
|
+
: 'none';
|
|
284
|
+
const seededOrchestrator = !seed ? 'turbo' : seed.turborepo ? 'turbo' : seed.nx ? 'nx' : 'none';
|
|
231
285
|
const answers = await inquirer.prompt([
|
|
232
286
|
{
|
|
233
287
|
type: 'input',
|
|
@@ -240,6 +294,7 @@ async function promptForConfig(targetDir) {
|
|
|
240
294
|
type: 'select',
|
|
241
295
|
name: 'projectType',
|
|
242
296
|
message: '🏗️ What type of project are you building?',
|
|
297
|
+
default: seed?.projectType,
|
|
243
298
|
choices: [
|
|
244
299
|
{ name: '📚 Library/Package', value: 'library' },
|
|
245
300
|
{ name: '🌐 Web Application', value: 'web-app' },
|
|
@@ -252,7 +307,7 @@ async function promptForConfig(targetDir) {
|
|
|
252
307
|
type: 'confirm',
|
|
253
308
|
name: 'useTypeScript',
|
|
254
309
|
message: '📘 Do you want to use TypeScript?',
|
|
255
|
-
default: true,
|
|
310
|
+
default: seed?.typescript.enabled ?? true,
|
|
256
311
|
},
|
|
257
312
|
{
|
|
258
313
|
type: 'select',
|
|
@@ -277,6 +332,7 @@ async function promptForConfig(targetDir) {
|
|
|
277
332
|
}
|
|
278
333
|
return baseChoices;
|
|
279
334
|
},
|
|
335
|
+
default: seed?.typescript.config,
|
|
280
336
|
when: (answers) => answers.useTypeScript,
|
|
281
337
|
},
|
|
282
338
|
{
|
|
@@ -289,7 +345,7 @@ async function promptForConfig(targetDir) {
|
|
|
289
345
|
{ name: '🔥 Both Biome + ESLint', value: 'both' },
|
|
290
346
|
{ name: '❌ None', value: 'none' },
|
|
291
347
|
],
|
|
292
|
-
default: 'biome',
|
|
348
|
+
default: seed?.linting.tool ?? 'biome',
|
|
293
349
|
},
|
|
294
350
|
{
|
|
295
351
|
type: 'select',
|
|
@@ -302,13 +358,14 @@ async function promptForConfig(targetDir) {
|
|
|
302
358
|
}
|
|
303
359
|
return choices;
|
|
304
360
|
},
|
|
361
|
+
default: seed?.linting.eslintConfig,
|
|
305
362
|
when: (answers) => answers.lintingTool === 'eslint' || answers.lintingTool === 'both',
|
|
306
363
|
},
|
|
307
364
|
{
|
|
308
365
|
type: 'confirm',
|
|
309
366
|
name: 'oxlint',
|
|
310
367
|
message: '🦀 Also run Oxlint alongside (50–100× faster than ESLint)?',
|
|
311
|
-
default: false,
|
|
368
|
+
default: seed?.oxlint ?? false,
|
|
312
369
|
when: (answers) => answers.lintingTool !== 'none',
|
|
313
370
|
},
|
|
314
371
|
{
|
|
@@ -322,7 +379,7 @@ async function promptForConfig(targetDir) {
|
|
|
322
379
|
{ name: '🌲 Cypress (E2E)', value: 'cypress' },
|
|
323
380
|
{ name: '❌ None', value: 'none' },
|
|
324
381
|
],
|
|
325
|
-
default: 'vitest',
|
|
382
|
+
default: seed?.testing.framework ?? 'vitest',
|
|
326
383
|
},
|
|
327
384
|
{
|
|
328
385
|
type: 'select',
|
|
@@ -334,19 +391,19 @@ async function promptForConfig(targetDir) {
|
|
|
334
391
|
{ name: '🔄 Both', value: 'both' },
|
|
335
392
|
],
|
|
336
393
|
when: (answers) => answers.testingFramework === 'jest',
|
|
337
|
-
default: 'node',
|
|
394
|
+
default: seed?.testing.environment ?? 'node',
|
|
338
395
|
},
|
|
339
396
|
{
|
|
340
397
|
type: 'confirm',
|
|
341
398
|
name: 'gitHooks',
|
|
342
399
|
message: '🪝 Set up Git hooks (Husky + lint-staged)?',
|
|
343
|
-
default: true,
|
|
400
|
+
default: seed?.gitHooks ?? true,
|
|
344
401
|
},
|
|
345
402
|
{
|
|
346
403
|
type: 'confirm',
|
|
347
404
|
name: 'commitLint',
|
|
348
405
|
message: '📝 Set up conventional commit linting?',
|
|
349
|
-
default: true,
|
|
406
|
+
default: seed?.commitLint ?? true,
|
|
350
407
|
when: (answers) => answers.gitHooks,
|
|
351
408
|
},
|
|
352
409
|
{
|
|
@@ -359,40 +416,40 @@ async function promptForConfig(targetDir) {
|
|
|
359
416
|
{ name: '🙏 Release Please (Google, release-PR-driven)', value: 'release-please' },
|
|
360
417
|
{ name: '❌ None', value: 'none' },
|
|
361
418
|
],
|
|
362
|
-
default:
|
|
419
|
+
default: seededReleaseTool,
|
|
363
420
|
when: (answers) => answers.projectType === 'library',
|
|
364
421
|
},
|
|
365
422
|
{
|
|
366
423
|
type: 'confirm',
|
|
367
424
|
name: 'treeshakeCheck',
|
|
368
425
|
message: '🌳 Add a tree-shake verification check (apps/treeshake-check)?',
|
|
369
|
-
default: false,
|
|
426
|
+
default: seed?.treeshakeCheck ?? false,
|
|
370
427
|
when: (answers) => answers.projectType === 'library',
|
|
371
428
|
},
|
|
372
429
|
{
|
|
373
430
|
type: 'confirm',
|
|
374
431
|
name: 'publint',
|
|
375
432
|
message: '📦 Add publint to lint your package before publishing?',
|
|
376
|
-
default: true,
|
|
433
|
+
default: seed?.publint ?? true,
|
|
377
434
|
when: (answers) => answers.projectType === 'library',
|
|
378
435
|
},
|
|
379
436
|
{
|
|
380
437
|
type: 'confirm',
|
|
381
438
|
name: 'badges',
|
|
382
439
|
message: '🔖 Add status badges (CI, npm, coverage, license) to the README?',
|
|
383
|
-
default: true,
|
|
440
|
+
default: seed?.badges ?? true,
|
|
384
441
|
},
|
|
385
442
|
{
|
|
386
443
|
type: 'confirm',
|
|
387
444
|
name: 'securityAutomation',
|
|
388
445
|
message: '🛡️ Include security automation (Dependabot + CodeQL)?',
|
|
389
|
-
default: true,
|
|
446
|
+
default: seed?.securityAutomation ?? true,
|
|
390
447
|
},
|
|
391
448
|
{
|
|
392
449
|
type: 'confirm',
|
|
393
450
|
name: 'aiSetup',
|
|
394
451
|
message: '🤖 Add AI agent rules (AGENTS.md, CLAUDE.md, Cursor, Copilot, Claude skill)?',
|
|
395
|
-
default: true,
|
|
452
|
+
default: seed?.aiSetup ?? true,
|
|
396
453
|
},
|
|
397
454
|
{
|
|
398
455
|
type: 'select',
|
|
@@ -403,14 +460,14 @@ async function promptForConfig(targetDir) {
|
|
|
403
460
|
{ name: '🔷 Nx (nx.json)', value: 'nx' },
|
|
404
461
|
{ name: '❌ None', value: 'none' },
|
|
405
462
|
],
|
|
406
|
-
default:
|
|
463
|
+
default: seededOrchestrator,
|
|
407
464
|
when: () => hasWorkspace,
|
|
408
465
|
},
|
|
409
466
|
{
|
|
410
467
|
type: 'confirm',
|
|
411
468
|
name: 'tailwind',
|
|
412
469
|
message: '🎨 Add Tailwind CSS v4 (PostCSS plugin + globals.css)?',
|
|
413
|
-
default: true,
|
|
470
|
+
default: seed?.tailwind ?? true,
|
|
414
471
|
// Only meaningful for frontend project types.
|
|
415
472
|
when: (answers) => answers.projectType === 'web-app' ||
|
|
416
473
|
answers.projectType === 'react-app' ||
|
|
@@ -433,13 +490,14 @@ async function promptForConfig(targetDir) {
|
|
|
433
490
|
}
|
|
434
491
|
return choices;
|
|
435
492
|
},
|
|
493
|
+
default: seed?.bundler,
|
|
436
494
|
when: (answers) => answers.projectType !== 'nextjs-app', // Next.js has its own bundler
|
|
437
495
|
},
|
|
438
496
|
{
|
|
439
497
|
type: 'confirm',
|
|
440
498
|
name: 'bun',
|
|
441
499
|
message: '🥟 Target the Bun runtime (bunfig.toml + Bun-typed tsconfig)?',
|
|
442
|
-
default: false,
|
|
500
|
+
default: seed?.bun ?? false,
|
|
443
501
|
// A runtime flag, not a project type — offer it for Node-compatible
|
|
444
502
|
// library/API targets, not the browser-framework types.
|
|
445
503
|
when: (answers) => answers.projectType === 'library' || answers.projectType === 'node-api',
|
package/dist/cli/index.js
CHANGED
|
@@ -34,6 +34,8 @@ program
|
|
|
34
34
|
.option('--config <path>', 'Skip prompts; read a ProjectConfig or .repo-tooling.json lockfile from <path>')
|
|
35
35
|
.option('--dry-run', 'Print the resolved config and file list, write nothing')
|
|
36
36
|
.option('--config-schema', 'Print the JSON Schema for ProjectConfig and exit')
|
|
37
|
+
// Seeds the wizard, never skips it (#563) — every answer is still yours.
|
|
38
|
+
.option('--from <owner/repo>', "Seed the wizard's defaults from that repo's .repo-tooling.json instead of the built-in ones (read via `gh`)")
|
|
37
39
|
.action(setupProject);
|
|
38
40
|
program
|
|
39
41
|
.command('copy <config>')
|
|
@@ -321,6 +323,9 @@ program
|
|
|
321
323
|
// The read side of `fix --skills-dir` (#485). No --yes/--json requirement
|
|
322
324
|
// here: doctor never writes, so an unresolved directory is just reported.
|
|
323
325
|
.option('--skills-dir <path>', 'Where `fix claude-skills` installs user-global agent skills (default: ~/.claude/skills). Pass the same path `fix` was given, or the skill reports as not installed')
|
|
326
|
+
// Rules are per-repo (#563): this compares them against another repo's, and
|
|
327
|
+
// only reports. Never drift, never a fixer, never part of the exit code.
|
|
328
|
+
.option('--rules-from <owner/repo>', "Report how this repo's config and rules differ from that repo's .repo-tooling.json (informational; read via `gh`)")
|
|
324
329
|
.action(doctorCommand);
|
|
325
330
|
program
|
|
326
331
|
.command('fix [target]')
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A JSON Schema validator covering exactly the keyword subset `lockfileSchema()`
|
|
3
|
+
* and `CONFIG_SCHEMA` use: type (object/array/string/integer/boolean), enum,
|
|
4
|
+
* minLength, required, properties, additionalProperties (`false` or a schema)
|
|
5
|
+
* and items. Returns one message per problem; an empty array means valid.
|
|
6
|
+
*
|
|
7
|
+
* Hand-rolled because the repo has no JSON Schema validator and the schemas lean
|
|
8
|
+
* on seven keywords — not enough to justify an ajv dependency. It lives in src
|
|
9
|
+
* rather than in a test because a reference repo's lockfile is untrusted input
|
|
10
|
+
* that has to be schema-checked before it is read (#563).
|
|
11
|
+
*/
|
|
12
|
+
// ponytail: `format: date-time` is not checked, and neither are composition
|
|
13
|
+
// keywords — none appear in either schema. Reach for ajv the day one does.
|
|
14
|
+
export function validateAgainstSchema(value, schema, path = '$') {
|
|
15
|
+
const errors = [];
|
|
16
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
17
|
+
errors.push(`${path}: ${JSON.stringify(value)} is not one of ${schema.enum.join(', ')}`);
|
|
18
|
+
}
|
|
19
|
+
if (schema.type === 'array') {
|
|
20
|
+
if (!Array.isArray(value))
|
|
21
|
+
return [`${path}: expected array`];
|
|
22
|
+
if (schema.items) {
|
|
23
|
+
for (const [i, item] of value.entries()) {
|
|
24
|
+
errors.push(...validateAgainstSchema(item, schema.items, `${path}[${i}]`));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return errors;
|
|
28
|
+
}
|
|
29
|
+
if (schema.type === 'object') {
|
|
30
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
31
|
+
return [`${path}: expected object`];
|
|
32
|
+
}
|
|
33
|
+
const obj = value;
|
|
34
|
+
for (const key of schema.required ?? []) {
|
|
35
|
+
if (!(key in obj))
|
|
36
|
+
errors.push(`${path}: missing required property "${key}"`);
|
|
37
|
+
}
|
|
38
|
+
for (const [key, child] of Object.entries(obj)) {
|
|
39
|
+
const property = schema.properties?.[key];
|
|
40
|
+
if (property)
|
|
41
|
+
errors.push(...validateAgainstSchema(child, property, `${path}.${key}`));
|
|
42
|
+
else if (schema.additionalProperties === false) {
|
|
43
|
+
errors.push(`${path}: unknown property "${key}"`);
|
|
44
|
+
}
|
|
45
|
+
else if (typeof schema.additionalProperties === 'object') {
|
|
46
|
+
errors.push(...validateAgainstSchema(child, schema.additionalProperties, `${path}.${key}`));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return errors;
|
|
50
|
+
}
|
|
51
|
+
if (schema.type === 'integer') {
|
|
52
|
+
if (!Number.isInteger(value))
|
|
53
|
+
errors.push(`${path}: expected integer`);
|
|
54
|
+
}
|
|
55
|
+
else if (schema.type && typeof value !== schema.type) {
|
|
56
|
+
errors.push(`${path}: expected ${schema.type}, got ${typeof value}`);
|
|
57
|
+
}
|
|
58
|
+
if (schema.minLength !== undefined &&
|
|
59
|
+
typeof value === 'string' &&
|
|
60
|
+
value.length < schema.minLength) {
|
|
61
|
+
errors.push(`${path}: shorter than minLength ${schema.minLength}`);
|
|
62
|
+
}
|
|
63
|
+
return errors;
|
|
64
|
+
}
|
|
@@ -184,6 +184,26 @@ function migrate(raw) {
|
|
|
184
184
|
...(Object.keys(rules).length > 0 ? { rules } : {}),
|
|
185
185
|
};
|
|
186
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Normalize already-parsed JSON into a Lockfile, or null when it isn't one.
|
|
189
|
+
* Split out of readLockfile so a lockfile fetched from somewhere other than the
|
|
190
|
+
* filesystem — a reference repo, over `gh` — goes through the same migration
|
|
191
|
+
* before anything reads it (#563).
|
|
192
|
+
*/
|
|
193
|
+
export function parseLockfile(raw) {
|
|
194
|
+
if (typeof raw !== 'object' || raw === null)
|
|
195
|
+
return null;
|
|
196
|
+
const obj = raw;
|
|
197
|
+
if (typeof obj.version !== 'number')
|
|
198
|
+
return null;
|
|
199
|
+
// v4+ keeps config under `record`; v1–v3 keep it at the top level (#559).
|
|
200
|
+
const config = obj.version >= LOCKFILE_VERSION
|
|
201
|
+
? obj.record?.config
|
|
202
|
+
: obj.config;
|
|
203
|
+
if (typeof config !== 'object' || config === null)
|
|
204
|
+
return null;
|
|
205
|
+
return migrate(obj);
|
|
206
|
+
}
|
|
187
207
|
export async function readLockfile(dir) {
|
|
188
208
|
let filepath = path.join(dir, LOCKFILE_NAME);
|
|
189
209
|
if (!(await fs.pathExists(filepath))) {
|
|
@@ -194,19 +214,7 @@ export async function readLockfile(dir) {
|
|
|
194
214
|
filepath = legacy;
|
|
195
215
|
}
|
|
196
216
|
try {
|
|
197
|
-
|
|
198
|
-
if (typeof raw !== 'object' || raw === null)
|
|
199
|
-
return null;
|
|
200
|
-
const obj = raw;
|
|
201
|
-
if (typeof obj.version !== 'number')
|
|
202
|
-
return null;
|
|
203
|
-
// v4+ keeps config under `record`; v1–v3 keep it at the top level (#559).
|
|
204
|
-
const config = obj.version >= LOCKFILE_VERSION
|
|
205
|
-
? obj.record?.config
|
|
206
|
-
: obj.config;
|
|
207
|
-
if (typeof config !== 'object' || config === null)
|
|
208
|
-
return null;
|
|
209
|
-
return migrate(obj);
|
|
217
|
+
return parseLockfile((await fs.readJson(filepath)));
|
|
210
218
|
}
|
|
211
219
|
catch {
|
|
212
220
|
return null;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { realGhExec } from '../../base/github-settings.js';
|
|
2
|
+
import { validateAgainstSchema } from './json-schema.js';
|
|
3
|
+
import { LOCKFILE_NAME, lockfileSchema, parseLockfile, readLockfile, } from './lockfile.js';
|
|
4
|
+
/**
|
|
5
|
+
* Rules are per-repo (#563): there is no guideline package, so sharing a
|
|
6
|
+
* guideline means pointing at another repo. This module is the one fetcher both
|
|
7
|
+
* halves of that share — `doctor --rules-from` reads a reference repo's rules to
|
|
8
|
+
* report differences, and `setup --from` reads the same file to seed the wizard.
|
|
9
|
+
*
|
|
10
|
+
* The reference is untrusted input from end to end: the `owner/repo` string is
|
|
11
|
+
* shape-checked before it reaches an API path, the response is size-capped, and
|
|
12
|
+
* the JSON is validated against the published schema before a single field is
|
|
13
|
+
* read. Nothing here ever writes, and nothing is ever applied.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* `owner/repo`. The injection boundary — the reference is interpolated into the
|
|
17
|
+
* gh API path below, so anything with a slash, `..` or a shell metacharacter in
|
|
18
|
+
* it has to be rejected here rather than defended against later.
|
|
19
|
+
*/
|
|
20
|
+
const REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
21
|
+
/**
|
|
22
|
+
* Generous by four orders of magnitude — a real lockfile is ~1 KB. This is not a
|
|
23
|
+
* tuning knob, it is the "huge file" guard: the response is fully buffered by the
|
|
24
|
+
* time we see it, so the cap stops us parsing and diffing a repo's 40 MB joke.
|
|
25
|
+
*/
|
|
26
|
+
const MAX_BYTES = 128 * 1024;
|
|
27
|
+
/**
|
|
28
|
+
* The GitHub arm of the fetch. Another forge is a second function of this shape
|
|
29
|
+
* dispatched from fetchReferenceLockfile — everything downstream (size cap,
|
|
30
|
+
* parse, schema validation, diff) is forge-agnostic and already shared.
|
|
31
|
+
*/
|
|
32
|
+
async function fetchFromGitHub(reference, exec) {
|
|
33
|
+
const gh = exec ?? ((args, stdin) => realGhExec(args, stdin));
|
|
34
|
+
// `Accept: raw` returns the file itself rather than the base64-in-JSON envelope.
|
|
35
|
+
const r = await gh([
|
|
36
|
+
'api',
|
|
37
|
+
`repos/${reference}/contents/${LOCKFILE_NAME}`,
|
|
38
|
+
'-H',
|
|
39
|
+
'Accept: application/vnd.github.raw',
|
|
40
|
+
]);
|
|
41
|
+
if (r.ok)
|
|
42
|
+
return { ok: true, text: r.stdout };
|
|
43
|
+
if (/HTTP 404/.test(r.stderr)) {
|
|
44
|
+
return { ok: false, reason: `${reference} has no ${LOCKFILE_NAME} (or is not visible to you)` };
|
|
45
|
+
}
|
|
46
|
+
const detail = r.stderr.trim().split('\n').pop() ?? 'gh failed';
|
|
47
|
+
return { ok: false, reason: `could not read ${reference}: ${detail}` };
|
|
48
|
+
}
|
|
49
|
+
function parseReference(reference, text) {
|
|
50
|
+
if (Buffer.byteLength(text) > MAX_BYTES) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
reason: `${reference}'s ${LOCKFILE_NAME} is larger than ${MAX_BYTES} bytes`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = JSON.parse(text);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { ok: false, reason: `${reference}'s ${LOCKFILE_NAME} is not valid JSON` };
|
|
62
|
+
}
|
|
63
|
+
// Migrate first, then validate: an older reference is flat on disk and would
|
|
64
|
+
// fail the current schema for a reason that says nothing about its rules.
|
|
65
|
+
const lockfile = parseLockfile(raw);
|
|
66
|
+
if (!lockfile) {
|
|
67
|
+
return { ok: false, reason: `${reference}'s ${LOCKFILE_NAME} is not a recognisable lockfile` };
|
|
68
|
+
}
|
|
69
|
+
const errors = validateAgainstSchema(lockfile, lockfileSchema());
|
|
70
|
+
if (errors.length > 0) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
reason: `${reference}'s ${LOCKFILE_NAME} fails the published schema: ${errors.slice(0, 3).join('; ')}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, lockfile };
|
|
77
|
+
}
|
|
78
|
+
export async function fetchReferenceLockfile(reference, exec) {
|
|
79
|
+
if (!REFERENCE.test(reference)) {
|
|
80
|
+
return { ok: false, reason: `"${reference}" is not an owner/repo reference` };
|
|
81
|
+
}
|
|
82
|
+
const fetched = await fetchFromGitHub(reference, exec);
|
|
83
|
+
if (!fetched.ok)
|
|
84
|
+
return fetched;
|
|
85
|
+
return parseReference(reference, fetched.text);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The comparable half of a lockfile: the resolved config plus the human-written
|
|
89
|
+
* rules. `assets` hashes and the `writtenBy`/`writtenAt` stamps are excluded on
|
|
90
|
+
* purpose — they differ between any two repos by construction, so diffing them
|
|
91
|
+
* would bury every difference that means something.
|
|
92
|
+
*/
|
|
93
|
+
function rulesView(lock) {
|
|
94
|
+
return { config: lock.record.config, ...(lock.rules ?? {}) };
|
|
95
|
+
}
|
|
96
|
+
function flatten(value, prefix = '', out = new Map()) {
|
|
97
|
+
// Arrays are leaves: `requiredSkills` differing by one entry is one difference
|
|
98
|
+
// about the list, not a per-index report that shifts when the order does.
|
|
99
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
100
|
+
for (const [key, child] of Object.entries(value)) {
|
|
101
|
+
flatten(child, prefix ? `${prefix}.${key}` : key, out);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
out.set(prefix, value);
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
export function diffRules(local, reference) {
|
|
109
|
+
const mine = local ? flatten(rulesView(local)) : new Map();
|
|
110
|
+
const theirs = flatten(rulesView(reference));
|
|
111
|
+
return [...new Set([...mine.keys(), ...theirs.keys()])]
|
|
112
|
+
.sort()
|
|
113
|
+
.filter((p) => JSON.stringify(mine.get(p)) !== JSON.stringify(theirs.get(p)))
|
|
114
|
+
.map((p) => ({ path: p, local: mine.get(p), reference: theirs.get(p) }));
|
|
115
|
+
}
|
|
116
|
+
export async function compareRulesWithReference(dir, reference, exec) {
|
|
117
|
+
const result = await fetchReferenceLockfile(reference, exec);
|
|
118
|
+
if (!result.ok)
|
|
119
|
+
return { reference, compared: false, reason: result.reason };
|
|
120
|
+
return {
|
|
121
|
+
reference,
|
|
122
|
+
compared: true,
|
|
123
|
+
differences: diffRules(await readLockfile(dir), result.lockfile),
|
|
124
|
+
};
|
|
125
|
+
}
|
package/package.json
CHANGED