@rtorcato/repo-tooling 3.45.0 → 4.1.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 +3 -25
- package/README.md +13 -16
- package/dist/base/checks.js +0 -132
- package/dist/base/fixers.js +2 -125
- package/dist/base/github-settings.js +0 -13
- package/dist/cli/commands/doctor.js +3 -23
- package/dist/cli/commands/fix-targets.js +4 -7
- package/dist/cli/commands/fix.js +1 -9
- package/dist/cli/commands/setup-presets.js +1 -0
- package/dist/cli/generators/brand.js +26 -6
- package/dist/cli/generators/docs-site.js +27 -14
- package/dist/cli/generators/pnpm-workspace.js +26 -0
- package/dist/cli/index.js +14 -102
- package/dist/cli/utils/lockfile.js +16 -7
- package/dist/languages/js/ci.js +9 -2
- package/dist/languages/js/fixers.js +31 -3
- package/package.json +1 -1
- package/dist/base/agent-user.js +0 -68
- package/dist/base/ai-loop-identity.js +0 -80
- package/dist/base/labels.js +0 -220
- package/dist/cli/commands/loop-cleanup.js +0 -100
- package/dist/cli/commands/loop-env.js +0 -76
- package/dist/cli/commands/loop-guard.js +0 -232
- package/dist/cli/commands/loop-marker.js +0 -169
- package/dist/cli/commands/loop-reap.js +0 -202
- package/dist/cli/commands/loop-tick.js +0 -349
- package/dist/cli/commands/loop-worktree.js +0 -132
- package/dist/cli/generators/claude-skills.js +0 -240
- package/skills/ai-issue/SKILL.md +0 -74
- package/skills/ai-issue-loop/SKILL.md +0 -597
- package/skills/ai-loop-status/SKILL.md +0 -122
- package/skills/ai-workflow/SKILL.md +0 -335
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* PNG, so a banner can be recoloured, retitled or resized instead of being a
|
|
7
7
|
* committed binary nobody can regenerate.
|
|
8
8
|
*
|
|
9
|
-
* Everything in the emitted SVGs is derived from the consuming repo — name
|
|
10
|
-
*
|
|
9
|
+
* Everything in the emitted SVGs is derived from the consuming repo — name from
|
|
10
|
+
* its package.json, tagline from `rules.brand.tagline` in .repo-tooling.json or
|
|
11
|
+
* else the package.json description (#666), accent from its own docs theme or favicon —
|
|
11
12
|
* and falls back to a neutral grey. Nothing about any particular org is baked
|
|
12
13
|
* in; the templates are meant to be hand-edited afterwards.
|
|
13
14
|
*/
|
|
@@ -94,14 +95,29 @@ async function accentFromFavicon(targetDir) {
|
|
|
94
95
|
}
|
|
95
96
|
return null;
|
|
96
97
|
}
|
|
97
|
-
|
|
98
|
+
/**
|
|
99
|
+
* The narrowest tagline budget any canvas uses (the mobile banner). A tagline
|
|
100
|
+
* that needs more than two lines of it crowds the layout and gets cut off.
|
|
101
|
+
*/
|
|
102
|
+
const TAGLINE_MAX_CHARS = 42;
|
|
103
|
+
/** True when `tagline` fits in two lines on every canvas, without an ellipsis. */
|
|
104
|
+
export function taglineFits(tagline) {
|
|
105
|
+
const words = tagline.split(/\s+/).filter(Boolean).join(' ');
|
|
106
|
+
return wrapText(tagline, TAGLINE_MAX_CHARS, 2).join(' ') === words;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* `tagline` is `rules.brand.tagline` from .repo-tooling.json: a short line
|
|
110
|
+
* written for the banner. Without it the package.json description stands in,
|
|
111
|
+
* which is often a full sentence too long for the canvas (#666).
|
|
112
|
+
*/
|
|
113
|
+
export async function resolveBrandMeta(pkg, targetDir, tagline) {
|
|
98
114
|
const pkgName = typeof pkg?.name === 'string' ? pkg.name : undefined;
|
|
99
115
|
const name = pkgName?.split('/').pop() ?? path.basename(path.resolve(targetDir));
|
|
100
116
|
const description = typeof pkg?.description === 'string' ? pkg.description : '';
|
|
101
117
|
const accent = (await accentFromDocsTheme(targetDir)) ?? (await accentFromFavicon(targetDir)) ?? NEUTRAL_ACCENT;
|
|
102
118
|
return {
|
|
103
119
|
name,
|
|
104
|
-
tagline: description || 'Add a
|
|
120
|
+
tagline: tagline || description || 'Add a short tagline as rules.brand.tagline in .repo-tooling.json.',
|
|
105
121
|
accent,
|
|
106
122
|
install: pkgName && pkg?.private !== true ? pkgName : null,
|
|
107
123
|
};
|
|
@@ -268,8 +284,8 @@ export async function repointReadmeBanners(targetDir) {
|
|
|
268
284
|
* README still on the old root-level paths. Every file is written only when
|
|
269
285
|
* absent, so `fix brand` is idempotent.
|
|
270
286
|
*/
|
|
271
|
-
export async function generateBrand(pkg, targetDir) {
|
|
272
|
-
const meta = await resolveBrandMeta(pkg, targetDir);
|
|
287
|
+
export async function generateBrand(pkg, targetDir, tagline) {
|
|
288
|
+
const meta = await resolveBrandMeta(pkg, targetDir, tagline);
|
|
273
289
|
const written = [];
|
|
274
290
|
const files = [
|
|
275
291
|
['brand/banner.svg', bannerSvg(meta)],
|
|
@@ -282,6 +298,10 @@ export async function generateBrand(pkg, targetDir) {
|
|
|
282
298
|
if (w)
|
|
283
299
|
written.push(w);
|
|
284
300
|
}
|
|
301
|
+
// stderr, not stdout: `fix --json` owns stdout (#357).
|
|
302
|
+
if (written.some((f) => f.endsWith('.svg')) && !taglineFits(meta.tagline)) {
|
|
303
|
+
console.error(' warning: the tagline needs more than two lines and will be cut off on the mobile banner — set a shorter one as rules.brand.tagline in .repo-tooling.json');
|
|
304
|
+
}
|
|
285
305
|
const readme = await repointReadmeBanners(targetDir);
|
|
286
306
|
if (readme)
|
|
287
307
|
written.push(readme);
|
|
@@ -3,6 +3,7 @@ import fs from 'fs-extra';
|
|
|
3
3
|
import selfPackageJson from '../../../package.json' with { type: 'json' };
|
|
4
4
|
import { copyPreset, PRESETS } from '../utils/copy-preset.js';
|
|
5
5
|
import { buildBadgeRow, parseRepository } from './badges.js';
|
|
6
|
+
import { DOCS_SITE_BUILDS, mergeAllowBuilds } from './pnpm-workspace.js';
|
|
6
7
|
import { inferSubpathsFromExports } from './treeshake.js';
|
|
7
8
|
/**
|
|
8
9
|
* What a scaffolded docs site should depend on for *this* CLI — read from the
|
|
@@ -64,24 +65,25 @@ async function writeIfMissing(targetDir, rel, contents) {
|
|
|
64
65
|
await fs.writeFile(file, contents);
|
|
65
66
|
return rel;
|
|
66
67
|
}
|
|
67
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Ensure `pnpm-workspace.yaml` lists `apps/*` and approves the site's build
|
|
70
|
+
* scripts (idempotent).
|
|
71
|
+
*/
|
|
68
72
|
async function ensureWorkspace(targetDir) {
|
|
69
73
|
const rel = 'pnpm-workspace.yaml';
|
|
70
74
|
const file = path.join(targetDir, rel);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
await fs.writeFile(file, next);
|
|
79
|
-
return rel;
|
|
80
|
-
}
|
|
81
|
-
await fs.writeFile(file, `packages:\n - 'apps/*'\n\n${body}`);
|
|
82
|
-
return rel;
|
|
75
|
+
const body = (await fs.pathExists(file)) ? await fs.readFile(file, 'utf8') : '';
|
|
76
|
+
let next = body;
|
|
77
|
+
// Already a workspace covering apps/* (either `apps/*` or a broader glob).
|
|
78
|
+
if (!/^\s*-\s*['"]?apps\/\*/m.test(body)) {
|
|
79
|
+
next = /^packages:/m.test(body)
|
|
80
|
+
? body.replace(/^packages:\n/m, "packages:\n - 'apps/*'\n")
|
|
81
|
+
: `packages:\n - 'apps/*'\n${body ? `\n${body}` : ''}`;
|
|
83
82
|
}
|
|
84
|
-
|
|
83
|
+
next = mergeAllowBuilds(next, DOCS_SITE_BUILDS);
|
|
84
|
+
if (next === body)
|
|
85
|
+
return null;
|
|
86
|
+
await fs.writeFile(file, next);
|
|
85
87
|
return rel;
|
|
86
88
|
}
|
|
87
89
|
/** The GitHub Pages base path Docusaurus serves under, e.g. `/repo-tooling/`. */
|
|
@@ -380,6 +382,16 @@ export default defineConfig({
|
|
|
380
382
|
})
|
|
381
383
|
`;
|
|
382
384
|
}
|
|
385
|
+
// routeBasePath is '/docs', so the site root has no page of its own and the
|
|
386
|
+
// navbar logo links to a 404 on every page (#664). Redirect it to the docs.
|
|
387
|
+
// Tabs/no semicolons to match the Biome preset the consuming repo is linted with.
|
|
388
|
+
const HOME_PAGE = `import { Redirect } from '@docusaurus/router'
|
|
389
|
+
import useBaseUrl from '@docusaurus/useBaseUrl'
|
|
390
|
+
|
|
391
|
+
export default function Home() {
|
|
392
|
+
\treturn <Redirect to={useBaseUrl('/docs')} />
|
|
393
|
+
}
|
|
394
|
+
`;
|
|
383
395
|
const SMOKE_SPEC = `import { expect, test } from '@playwright/test'
|
|
384
396
|
|
|
385
397
|
// Smoke test: assert the built site serves and its core UI renders. Deliberately
|
|
@@ -426,6 +438,7 @@ export async function generateDocsSite(pkg, targetDir, options = {}) {
|
|
|
426
438
|
[`${DOCS_APP}/sidebars.ts`, SIDEBARS],
|
|
427
439
|
[`${DOCS_APP}/tsconfig.json`, TSCONFIG],
|
|
428
440
|
[`${DOCS_APP}/src/css/custom.css`, customCss(accent)],
|
|
441
|
+
[`${DOCS_APP}/src/pages/index.tsx`, HOME_PAGE],
|
|
429
442
|
[`${DOCS_APP}/docs/intro.md`, introDoc(meta, badges)],
|
|
430
443
|
[`${DOCS_APP}/playwright.config.ts`, playwrightConfig(meta)],
|
|
431
444
|
[`${DOCS_APP}/tests/smoke.spec.ts`, SMOKE_SPEC],
|
|
@@ -156,6 +156,32 @@ function insertUnder(yaml, key, item) {
|
|
|
156
156
|
lines.splice(at + 1, 0, item);
|
|
157
157
|
return lines.join('\n');
|
|
158
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* The build-script decisions a Docusaurus site needs under pnpm 11 (#663): it
|
|
161
|
+
* pulls in all four, and an undecided one fails the install. core-js's
|
|
162
|
+
* postinstall is only a banner, so it is declined; esbuild and sharp compile.
|
|
163
|
+
*/
|
|
164
|
+
export const DOCS_SITE_BUILDS = {
|
|
165
|
+
'core-js': false,
|
|
166
|
+
'core-js-pure': false,
|
|
167
|
+
esbuild: true,
|
|
168
|
+
sharp: true,
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* Merge `builds` into the `allowBuilds:` map, adding only the packages that
|
|
172
|
+
* carry no decision yet — an existing value, whichever way it went, stays.
|
|
173
|
+
*/
|
|
174
|
+
export function mergeAllowBuilds(yaml, builds) {
|
|
175
|
+
const entries = Object.entries(builds)
|
|
176
|
+
.filter(([name]) => !approved(yaml, name))
|
|
177
|
+
.map(([name, allow]) => ` ${asKey(name)}: ${allow}`)
|
|
178
|
+
.join('\n');
|
|
179
|
+
if (!entries)
|
|
180
|
+
return yaml;
|
|
181
|
+
if (section(yaml, 'allowBuilds'))
|
|
182
|
+
return insertUnder(yaml, 'allowBuilds', entries);
|
|
183
|
+
return `${yaml.replace(/\n*$/, '\n')}\nallowBuilds:\n${entries}\n`;
|
|
184
|
+
}
|
|
159
185
|
/** Merge every missing managed setting into `yaml` and return the new contents. */
|
|
160
186
|
export function upsertPnpmSettings(yaml, needsEsbuild, glob) {
|
|
161
187
|
let next = yaml;
|
package/dist/cli/index.js
CHANGED
|
@@ -5,13 +5,6 @@ import { Command } from 'commander';
|
|
|
5
5
|
import fs from 'fs-extra';
|
|
6
6
|
import { doctorCommand } from './commands/doctor.js';
|
|
7
7
|
import { fixCommand } from './commands/fix.js';
|
|
8
|
-
import { loopCleanupCommand } from './commands/loop-cleanup.js';
|
|
9
|
-
import { loopEnvCommand } from './commands/loop-env.js';
|
|
10
|
-
import { loopGuardCommand } from './commands/loop-guard.js';
|
|
11
|
-
import { loopCommentCommand, loopVerdictCommand } from './commands/loop-marker.js';
|
|
12
|
-
import { loopReapCommand } from './commands/loop-reap.js';
|
|
13
|
-
import { loopTickCommand } from './commands/loop-tick.js';
|
|
14
|
-
import { loopWorktreeAddCommand } from './commands/loop-worktree.js';
|
|
15
8
|
import { setupProject } from './commands/setup.js';
|
|
16
9
|
import { copyPreset, PRESETS } from './utils/copy-preset.js';
|
|
17
10
|
import { getToolVersion } from './utils/version.js';
|
|
@@ -297,12 +290,6 @@ const TOOL_CATALOG = [
|
|
|
297
290
|
exports: [],
|
|
298
291
|
fixTarget: 'ai',
|
|
299
292
|
},
|
|
300
|
-
{
|
|
301
|
-
name: 'Claude skills',
|
|
302
|
-
description: 'Install the ai-issue-loop skill into ~/.claude/skills (user-global, opt-in — see --skills-dir)',
|
|
303
|
-
exports: [],
|
|
304
|
-
fixTarget: 'claude-skills',
|
|
305
|
-
},
|
|
306
293
|
];
|
|
307
294
|
program
|
|
308
295
|
.command('list')
|
|
@@ -326,9 +313,6 @@ program
|
|
|
326
313
|
.description('🩺 Diagnose project alignment with @rtorcato/repo-tooling presets')
|
|
327
314
|
.option('-d, --directory <path>', 'Target directory to diagnose', process.cwd())
|
|
328
315
|
.option('--json', 'Emit machine-readable JSON output')
|
|
329
|
-
// The read side of `fix --skills-dir` (#485). No --yes/--json requirement
|
|
330
|
-
// here: doctor never writes, so an unresolved directory is just reported.
|
|
331
|
-
.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')
|
|
332
316
|
// Rules are per-repo (#563): this compares them against another repo's, and
|
|
333
317
|
// only reports. Never drift, never a fixer, never part of the exit code.
|
|
334
318
|
.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`)")
|
|
@@ -343,11 +327,6 @@ program
|
|
|
343
327
|
.option('--list', 'List all registered fix targets and exit')
|
|
344
328
|
.option('--resync', 'Re-scaffold every file recorded in .repo-tooling.json')
|
|
345
329
|
.option('--diff', 'Show a unified diff of each change before confirming')
|
|
346
|
-
.option('--skills-dir <path>', 'Where `fix claude-skills` installs user-global agent skills (default: ~/.claude/skills). Required with --yes/--json when that directory does not exist')
|
|
347
|
-
// Deliberately not folded into --yes: unattended runs pass --yes, and this is
|
|
348
|
-
// the one overwrite that destroys work living outside the repo (#480).
|
|
349
|
-
.option('--force-skills', 'Let `fix claude-skills` overwrite a locally modified skill instead of refusing')
|
|
350
|
-
.option('--gh-config-dir <path>', 'gh profile signed in as rules.aiLoop.agentUser, for `fix ai-loop-identity` (default: ~/.config/gh-<agentUser>)')
|
|
351
330
|
.action((target, options) => fixCommand(target, {
|
|
352
331
|
directory: options.directory,
|
|
353
332
|
yes: options.yes,
|
|
@@ -356,88 +335,21 @@ program
|
|
|
356
335
|
list: options.list,
|
|
357
336
|
resync: options.resync,
|
|
358
337
|
diff: options.diff,
|
|
359
|
-
skillsDir: options.skillsDir,
|
|
360
|
-
forceSkills: options.forceSkills,
|
|
361
|
-
ghConfigDir: options.ghConfigDir,
|
|
362
338
|
}));
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
loop
|
|
368
|
-
.
|
|
369
|
-
.
|
|
370
|
-
.
|
|
371
|
-
.
|
|
372
|
-
.
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
' 2 root is not a repairable main checkout (bare clone, linked worktree, or not a repo) — halt the tick\n')
|
|
378
|
-
.action(loopGuardCommand);
|
|
379
|
-
loop
|
|
380
|
-
.command('env')
|
|
381
|
-
.description('🧭 Resolve ROOT, WT_ROOT, OWNER_REPO, AGENT_USER, HUMAN_USER and ME once')
|
|
382
|
-
.option('-d, --dir <path>', 'Directory to resolve from — main checkout or any worktree', process.cwd())
|
|
383
|
-
.option('--json', 'Emit machine-readable JSON output')
|
|
384
|
-
.addHelpText('after', "\nWithout --json, prints KEY='value' lines for eval. Empty means none.\n" +
|
|
385
|
-
'Exits 1 when the checkout or its GitHub repo cannot be resolved.\n')
|
|
386
|
-
.action(loopEnvCommand);
|
|
387
|
-
loop
|
|
388
|
-
.command('cleanup')
|
|
389
|
-
.description('🧹 Remove ai-* worktrees whose PR landed on main or was closed')
|
|
390
|
-
.option('--root <path>', 'Main checkout the loop branches worktrees from', process.cwd())
|
|
391
|
-
.option('--worktree-root <path>', 'Where ai-* worktrees live (default: <root>-worktrees)')
|
|
392
|
-
.option('--json', 'Emit machine-readable JSON output')
|
|
393
|
-
.addHelpText('after', '\nPass `removed` from --json to `loop guard --removed`.\n' +
|
|
394
|
-
'Exits 1 when a worktree removal failed.\n')
|
|
395
|
-
.action(loopCleanupCommand);
|
|
396
|
-
loop
|
|
397
|
-
.command('worktree')
|
|
398
|
-
.description('🌳 Loop worktree mechanics')
|
|
399
|
-
.command('add <slug>')
|
|
400
|
-
.description('🌱 Create ai-<issue>-<slug> off origin/main, link its deps, assert none are missing')
|
|
401
|
-
.option('--root <path>', 'Main checkout to branch the worktree from', process.cwd())
|
|
402
|
-
.option('--worktree-root <path>', 'Where ai-* worktrees live (default: <root>-worktrees)')
|
|
403
|
-
.option('--base <ref>', 'Ref to branch from', 'origin/main')
|
|
404
|
-
.option('--json', 'Emit machine-readable JSON output')
|
|
405
|
-
.addHelpText('after', '\nExit 1 when the worktree was not created or a symlinkDirectories entry is left\n' +
|
|
406
|
-
'unlinked — do not spawn an implementer. needsInstall means no list was declared.\n')
|
|
407
|
-
.action(loopWorktreeAddCommand);
|
|
408
|
-
loop
|
|
409
|
-
.command('reap')
|
|
410
|
-
.description('🪦 Report agents stalled past 45 minutes and what to do about each')
|
|
411
|
-
.option('--root <path>', 'Main checkout the loop branches worktrees from', process.cwd())
|
|
412
|
-
.option('--worktree-root <path>', 'Where ai-* worktrees live (default: <root>-worktrees)')
|
|
413
|
-
.option('--json', 'Emit machine-readable JSON output')
|
|
414
|
-
.addHelpText('after', '\nRead-only: prints a verdict per stall (block / drop-label / remove-worktree).\n' +
|
|
415
|
-
'Exits 1 when a gh query failed and the report is incomplete.\n')
|
|
416
|
-
.action(loopReapCommand);
|
|
417
|
-
loop
|
|
418
|
-
.command('comment <pr>')
|
|
419
|
-
.description("💬 Upsert the loop's one decision-marker comment on a PR")
|
|
420
|
-
.requiredOption('--body-file <path>', 'Comment text, without the marker (- for stdin)')
|
|
421
|
-
.option('-d, --dir <path>', 'Directory to resolve the GitHub repo from', process.cwd())
|
|
422
|
-
.option('--json', 'Emit machine-readable JSON output')
|
|
423
|
-
.action(loopCommentCommand);
|
|
424
|
-
loop
|
|
425
|
-
.command('verdict <pr>')
|
|
426
|
-
.description("⚖️ Read a reviewer arm's verdict marker for the PR's current head")
|
|
427
|
-
.requiredOption('--arm <arm>', 'Reviewer arm: code or sec')
|
|
428
|
-
.option('-d, --dir <path>', 'Directory to resolve the GitHub repo from', process.cwd())
|
|
429
|
-
.option('--json', 'Emit machine-readable JSON output')
|
|
430
|
-
.addHelpText('after', '\nPrints PASS, PASS-NOTES, CHANGES, or an empty line when none is posted.\n' +
|
|
431
|
-
'Exits 1 when the PR or its reviews cannot be read.\n')
|
|
432
|
-
.action(loopVerdictCommand);
|
|
433
|
-
loop
|
|
434
|
-
.command('tick')
|
|
435
|
-
.description("📋 Compute one tick's work list — guard, cleanup, reap, verdicts, pickups")
|
|
436
|
-
.option('--root <path>', 'Main checkout, or any worktree of it', process.cwd())
|
|
437
|
-
.option('--json', 'Emit machine-readable JSON output')
|
|
438
|
-
.addHelpText('after', '\nWrites no GitHub state: the caller applies every label, comment and spawn.\n' +
|
|
439
|
-
"Exit 1 or 2 halts the tick (loop guard's codes, or an unresolvable checkout).\n")
|
|
440
|
-
.action(loopTickCommand);
|
|
339
|
+
// The ai-issue-loop pipeline moved to @rtorcato/repo-ai (#658). Kept as a
|
|
340
|
+
// pointer so a stale skill calling `repo-tooling loop …` fails loudly with the
|
|
341
|
+
// fix, instead of commander's generic "unknown command".
|
|
342
|
+
program
|
|
343
|
+
.command('loop', { hidden: true })
|
|
344
|
+
.argument('[args...]')
|
|
345
|
+
.allowUnknownOption()
|
|
346
|
+
.helpOption(false)
|
|
347
|
+
.action(() => {
|
|
348
|
+
console.error(chalk.red('`repo-tooling loop` moved to @rtorcato/repo-ai.') +
|
|
349
|
+
'\n Run `npx @rtorcato/repo-ai loop …`, and refresh the skills with' +
|
|
350
|
+
'\n `npx @rtorcato/repo-ai fix claude-skills --force-skills`.');
|
|
351
|
+
process.exitCode = 1;
|
|
352
|
+
});
|
|
441
353
|
program.hook('preAction', async (_, actionCommand) => {
|
|
442
354
|
const name = actionCommand.name();
|
|
443
355
|
if (name === 'setup' || name === 'doctor' || name === 'fix') {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
import { CONFIG_SCHEMA, validateProjectConfig } from '../commands/setup-presets.js';
|
|
4
|
-
import { SHIPPED_SKILLS } from '../generators/claude-skills.js';
|
|
5
4
|
import { getToolVersion } from './version.js';
|
|
6
5
|
export const LOCKFILE_NAME = '.repo-tooling.json';
|
|
7
6
|
// Package and bin name used before the js-tooling→repo-tooling rename (#272).
|
|
@@ -51,9 +50,8 @@ export const DEFAULT_RULES = {
|
|
|
51
50
|
* `pnpm schema:generate` and gated by tests/cli/utils/lockfile-schema.test.ts.
|
|
52
51
|
*
|
|
53
52
|
* A function, not a const: lockfile.ts sits in an import cycle with
|
|
54
|
-
* setup-presets.ts (via the swift scaffolder)
|
|
55
|
-
*
|
|
56
|
-
* while this module evaluates. Reading them here, at call time, is safe.
|
|
53
|
+
* setup-presets.ts (via the swift scaffolder), so CONFIG_SCHEMA is in its TDZ
|
|
54
|
+
* while this module evaluates. Reading it here, at call time, is safe.
|
|
57
55
|
*/
|
|
58
56
|
// ponytail: key sets are compiler-checked against the type; a changed field
|
|
59
57
|
// *type* (string → number) still needs both lines edited by hand.
|
|
@@ -113,7 +111,7 @@ export function lockfileSchema() {
|
|
|
113
111
|
aiLoop: {
|
|
114
112
|
type: 'object',
|
|
115
113
|
additionalProperties: false,
|
|
116
|
-
description: 'Settings for the ai-issue-loop skills. Repo-scoped on purpose: committed here they travel with the repo and survive a new laptop.',
|
|
114
|
+
description: 'Settings for the ai-issue-loop skills from @rtorcato/repo-ai. Repo-scoped on purpose: committed here they travel with the repo and survive a new laptop.',
|
|
117
115
|
properties: {
|
|
118
116
|
agentUser: {
|
|
119
117
|
type: 'string',
|
|
@@ -123,8 +121,8 @@ export function lockfileSchema() {
|
|
|
123
121
|
},
|
|
124
122
|
requiredSkills: {
|
|
125
123
|
type: 'array',
|
|
126
|
-
items: { type: 'string'
|
|
127
|
-
description:
|
|
124
|
+
items: { type: 'string' },
|
|
125
|
+
description: "Agent skills this repo's workflows depend on. Audited by `npx @rtorcato/repo-ai doctor`, which reports a missing or stale installed copy; it never installs one for you.",
|
|
128
126
|
},
|
|
129
127
|
mcp: {
|
|
130
128
|
type: 'object',
|
|
@@ -162,6 +160,17 @@ export function lockfileSchema() {
|
|
|
162
160
|
additionalProperties: { type: 'string', minLength: 1 },
|
|
163
161
|
description: 'Declared exceptions: doctor check name → the reason this repo deliberately deviates. The reason is mandatory and non-empty — doctor shows the check as `declared` with it (never hidden) and stops failing the run for it. An entry naming a check doctor does not run is itself reported as drift.',
|
|
164
162
|
},
|
|
163
|
+
brand: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
additionalProperties: false,
|
|
166
|
+
description: 'Inputs to `fix brand`, which scaffolds the banner and social-card SVGs.',
|
|
167
|
+
properties: {
|
|
168
|
+
tagline: {
|
|
169
|
+
type: 'string',
|
|
170
|
+
description: 'Short line for the banners and social card, used in place of the package.json description. Keep it to two lines of about 42 characters; `fix brand` warns when it is longer.',
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
165
174
|
},
|
|
166
175
|
},
|
|
167
176
|
},
|
package/dist/languages/js/ci.js
CHANGED
|
@@ -78,6 +78,13 @@ export function scriptsOf(pkg) {
|
|
|
78
78
|
function hasScript(opts, script) {
|
|
79
79
|
return !opts.scripts || script in opts.scripts;
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* A bundler, or a known `build` script — a plain-`tsc` repo records `bundler:
|
|
83
|
+
* 'none'` (#661) but still has a build to run.
|
|
84
|
+
*/
|
|
85
|
+
function buildsSomething(config, opts) {
|
|
86
|
+
return config.bundler !== 'none' || (opts.scripts !== undefined && 'build' in opts.scripts);
|
|
87
|
+
}
|
|
81
88
|
/** Emit a step only when the script it runs exists (or we can't know yet). */
|
|
82
89
|
function stepFor(opts, script, step) {
|
|
83
90
|
return hasScript(opts, script) ? step : null;
|
|
@@ -95,7 +102,7 @@ function jobSteps(steps) {
|
|
|
95
102
|
export function githubJobs(config, opts = {}) {
|
|
96
103
|
const hasTypeScript = config.typescript.enabled;
|
|
97
104
|
const hasTests = config.testing.framework !== 'none';
|
|
98
|
-
const hasBuild = config
|
|
105
|
+
const hasBuild = buildsSomething(config, opts);
|
|
99
106
|
const isLibrary = config.projectType === 'library';
|
|
100
107
|
const hasCoverage = usesCoverage(config);
|
|
101
108
|
const jobs = [DEPENDENCIES_JOB];
|
|
@@ -230,7 +237,7 @@ export function gitlabSpec(config, opts = {}) {
|
|
|
230
237
|
const hasTypeScript = config.typescript.enabled;
|
|
231
238
|
const hasTests = config.testing.framework !== 'none';
|
|
232
239
|
const hasLint = config.linting.tool !== 'none';
|
|
233
|
-
const hasBuild = config
|
|
240
|
+
const hasBuild = buildsSomething(config, opts);
|
|
234
241
|
const test = gitlabTest(config);
|
|
235
242
|
const jobs = [
|
|
236
243
|
...(hasLint && hasScript(opts, lintScript(config))
|
|
@@ -79,8 +79,33 @@ import { LOCKFILE_NAME, writeLockfile } from '../../cli/utils/lockfile.js';
|
|
|
79
79
|
// The fixer contract moved to src/base/fixers.ts when Swift became the second
|
|
80
80
|
// module (#286) — import it from there.
|
|
81
81
|
import { FixerAbort } from '../../base/fixers.js';
|
|
82
|
+
// The bundlers ProjectConfig can record, in the order a repo carrying more than
|
|
83
|
+
// one is attributed to (a vite app that also pulls in esbuild is a vite app).
|
|
84
|
+
const BUNDLERS = ['tsup', 'vite', 'rolldown', 'rollup', 'esbuild'];
|
|
85
|
+
const BUNDLER_CONFIGS = {
|
|
86
|
+
tsup: [
|
|
87
|
+
'tsup.config.ts',
|
|
88
|
+
'tsup.config.mts',
|
|
89
|
+
'tsup.config.js',
|
|
90
|
+
'tsup.config.mjs',
|
|
91
|
+
'tsup.config.json',
|
|
92
|
+
],
|
|
93
|
+
vite: ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs'],
|
|
94
|
+
rolldown: ['rolldown.config.ts', 'rolldown.config.mjs', 'rolldown.config.js'],
|
|
95
|
+
rollup: ['rollup.config.ts', 'rollup.config.mjs', 'rollup.config.js'],
|
|
96
|
+
esbuild: ['build.mjs'],
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* The bundler the repo actually uses, from its deps or (given `dir`) its config
|
|
100
|
+
* file — `none` when there is neither. Never a preset default: a plain-`tsc`
|
|
101
|
+
* repo used to be recorded as tsup (#661).
|
|
102
|
+
*/
|
|
103
|
+
function detectBundler(deps, dir) {
|
|
104
|
+
return (BUNDLERS.find((b) => b in deps ||
|
|
105
|
+
(dir !== undefined && BUNDLER_CONFIGS[b].some((f) => fs.existsSync(path.join(dir, f))))) ?? 'none');
|
|
106
|
+
}
|
|
82
107
|
/** Exported so doctor can render the preset ci.yml it compares against (#349). */
|
|
83
|
-
export function inferProjectConfig(pkg) {
|
|
108
|
+
export function inferProjectConfig(pkg, dir) {
|
|
84
109
|
const deps = {
|
|
85
110
|
...(pkg?.dependencies ?? {}),
|
|
86
111
|
...(pkg?.devDependencies ?? {}),
|
|
@@ -92,6 +117,7 @@ export function inferProjectConfig(pkg) {
|
|
|
92
117
|
projectType = 'react-app';
|
|
93
118
|
return {
|
|
94
119
|
projectName: pkg?.name ?? 'project',
|
|
120
|
+
language: 'js',
|
|
95
121
|
projectType,
|
|
96
122
|
typescript: {
|
|
97
123
|
enabled: true,
|
|
@@ -109,7 +135,7 @@ export function inferProjectConfig(pkg) {
|
|
|
109
135
|
commitLint: true,
|
|
110
136
|
semanticRelease: pkg?.private !== true,
|
|
111
137
|
securityAutomation: true,
|
|
112
|
-
bundler:
|
|
138
|
+
bundler: detectBundler(deps, dir),
|
|
113
139
|
};
|
|
114
140
|
}
|
|
115
141
|
export async function readPackageJson(dir) {
|
|
@@ -879,7 +905,9 @@ export const FIXERS = [
|
|
|
879
905
|
console.error(chalk.yellow(' no package.json found — skipping'));
|
|
880
906
|
return { filesWritten: [] };
|
|
881
907
|
}
|
|
882
|
-
const config = lock
|
|
908
|
+
const config = lock
|
|
909
|
+
? lock.record.config
|
|
910
|
+
: inferProjectConfig(pkg, targetDir);
|
|
883
911
|
// Recorded hashes win: they capture the pristine content at copy time,
|
|
884
912
|
// which a byte-match against today's shipped asset can only approximate.
|
|
885
913
|
const assets = { ...(await identifiablePresetHashes(targetDir)), ...lock?.record.assets };
|
package/package.json
CHANGED
package/dist/base/agent-user.js
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import fs from 'fs-extra';
|
|
3
|
-
import { realGhExec } from './github-settings.js';
|
|
4
|
-
/**
|
|
5
|
-
* `aiLoop.agentUser` assignability (#530). The skills that consume the option
|
|
6
|
-
* (`ai-issue-loop`, `ai-workflow`) verify it at runtime and *silently assign
|
|
7
|
-
* nothing* on failure — by design, so a deleted bot account or a bot never
|
|
8
|
-
* added as a collaborator degrades the loop with no visible symptom. This
|
|
9
|
-
* check is where that failure becomes visible.
|
|
10
|
-
*
|
|
11
|
-
* Read-only, on the same `gh` seam as labels.ts and milestones.ts. Everything
|
|
12
|
-
* derives from the consuming repo: the login from its lockfile, the repo from
|
|
13
|
-
* its own remote via gh's `{owner}/{repo}` placeholders.
|
|
14
|
-
*/
|
|
15
|
-
const CHECK = 'AI loop agent';
|
|
16
|
-
/**
|
|
17
|
-
* GitHub login shape: 1–39 chars, alphanumerics and single hyphens, no leading
|
|
18
|
-
* or trailing hyphen. The injection boundary — the login is interpolated into
|
|
19
|
-
* the API path below.
|
|
20
|
-
*/
|
|
21
|
-
export const LOGIN = /^[a-zA-Z0-9](?:-?[a-zA-Z0-9]){0,38}$/;
|
|
22
|
-
const skip = (reason) => ({
|
|
23
|
-
check: CHECK,
|
|
24
|
-
status: 'ok',
|
|
25
|
-
detail: `skipped — ${reason}`,
|
|
26
|
-
});
|
|
27
|
-
export async function checkAgentUser(dir, agentUser, exec) {
|
|
28
|
-
// Absent field ⇒ not applicable — the single-identity model is the default,
|
|
29
|
-
// not a requirement (#521).
|
|
30
|
-
if (!agentUser) {
|
|
31
|
-
return {
|
|
32
|
-
check: CHECK,
|
|
33
|
-
status: 'ok',
|
|
34
|
-
detail: 'not applicable — no rules.aiLoop.agentUser in .repo-tooling.json',
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
if (!LOGIN.test(agentUser)) {
|
|
38
|
-
return {
|
|
39
|
-
check: CHECK,
|
|
40
|
-
status: 'drift',
|
|
41
|
-
detail: `rules.aiLoop.agentUser "${agentUser}" is not a valid GitHub login`,
|
|
42
|
-
hint: 'Fix or remove rules.aiLoop.agentUser in .repo-tooling.json',
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
// Cheap gate first: no .git → never spawn (keeps tmp-dir doctor runs offline).
|
|
46
|
-
if (!(await fs.pathExists(path.join(dir, '.git'))))
|
|
47
|
-
return skip('not a git repository');
|
|
48
|
-
const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
|
|
49
|
-
// 204 when the login can be assigned issues on this repo, 404 otherwise.
|
|
50
|
-
const r = await gh(['api', `repos/{owner}/{repo}/assignees/${agentUser}`]);
|
|
51
|
-
if (r.ok) {
|
|
52
|
-
return {
|
|
53
|
-
check: CHECK,
|
|
54
|
-
status: 'ok',
|
|
55
|
-
detail: `rules.aiLoop.agentUser "${agentUser}" is an assignable collaborator`,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
if (/HTTP 404/.test(r.stderr)) {
|
|
59
|
-
return {
|
|
60
|
-
check: CHECK,
|
|
61
|
-
status: 'drift',
|
|
62
|
-
detail: `rules.aiLoop.agentUser "${agentUser}" is not an assignable collaborator — the loop skills will silently assign nothing`,
|
|
63
|
-
hint: `Add the account as a collaborator (\`gh api -X PUT repos/{owner}/{repo}/collaborators/${agentUser}\`) or remove rules.aiLoop.agentUser from .repo-tooling.json`,
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
// Offline, unauthenticated, or gh missing — not evidence of drift.
|
|
67
|
-
return skip('could not verify assignability');
|
|
68
|
-
}
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `fix ai-loop-identity` (#638): point this checkout's Claude sessions at a gh
|
|
3
|
-
* profile signed in as `rules.aiLoop.agentUser`, so `loop guard` stops halting
|
|
4
|
-
* on `identity: mismatch`. `.repo-tooling.json` (committed) says *who* the agent
|
|
5
|
-
* is; the profile path is machine-specific, so it lands in the gitignored,
|
|
6
|
-
* per-checkout `.claude/settings.local.json`.
|
|
7
|
-
*/
|
|
8
|
-
import path from 'node:path';
|
|
9
|
-
import fs from 'fs-extra';
|
|
10
|
-
import { configuredAgentUser } from '../cli/commands/loop-guard.js';
|
|
11
|
-
import { LOGIN } from './agent-user.js';
|
|
12
|
-
import { FixerAbort } from './fixers.js';
|
|
13
|
-
import { realGhExec } from './github-settings.js';
|
|
14
|
-
export const SETTINGS_LOCAL = '.claude/settings.local.json';
|
|
15
|
-
/** The profile dir the fixer looks in when `--gh-config-dir` is not given. */
|
|
16
|
-
export function defaultGhConfigDir(agentUser, home) {
|
|
17
|
-
return path.join(home, '.config', `gh-${agentUser}`);
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Returns the files written. Writes nothing — and aborts with the `gh auth
|
|
21
|
-
* login` command as the hint — unless the profile really is the agent account.
|
|
22
|
-
*/
|
|
23
|
-
export async function setupAgentIdentity(targetDir, opts) {
|
|
24
|
-
const agentUser = await configuredAgentUser(targetDir);
|
|
25
|
-
if (!agentUser) {
|
|
26
|
-
console.error(' nothing to do — no rules.aiLoop.agentUser in .repo-tooling.json');
|
|
27
|
-
return [];
|
|
28
|
-
}
|
|
29
|
-
// The login comes from a committed file and becomes a path segment below.
|
|
30
|
-
if (!LOGIN.test(agentUser)) {
|
|
31
|
-
throw new FixerAbort('invalid-agent-user', `rules.aiLoop.agentUser "${agentUser}" is not a valid GitHub login`, 'Fix or remove rules.aiLoop.agentUser in .repo-tooling.json');
|
|
32
|
-
}
|
|
33
|
-
const dir = path.resolve(opts.ghConfigDir ?? defaultGhConfigDir(agentUser, opts.home));
|
|
34
|
-
const gh = opts.gh ?? ((args, env) => realGhExec(args, undefined, targetDir, env));
|
|
35
|
-
const login = (await fs.pathExists(dir))
|
|
36
|
-
? await gh(['api', 'user', '--jq', '.login'], { GH_CONFIG_DIR: dir }).then((r) => r.ok ? r.stdout.trim() : '')
|
|
37
|
-
: '';
|
|
38
|
-
// GitHub logins are case-insensitive, so a case difference is one account.
|
|
39
|
-
if (login.toLowerCase() !== agentUser.toLowerCase()) {
|
|
40
|
-
throw new FixerAbort('agent-identity-unavailable', `${dir} is ${login ? `signed in as ${login}, not ${agentUser}` : `not signed in to gh as ${agentUser}`} — nothing written`, `Sign in once, in a browser logged in as ${agentUser}, then re-run:\n GH_CONFIG_DIR=${dir} gh auth login --hostname github.com --web --scopes repo`);
|
|
41
|
-
}
|
|
42
|
-
const written = [];
|
|
43
|
-
const settingsPath = path.join(targetDir, SETTINGS_LOCAL);
|
|
44
|
-
let settings = {};
|
|
45
|
-
if (await fs.pathExists(settingsPath)) {
|
|
46
|
-
try {
|
|
47
|
-
settings = await fs.readJson(settingsPath);
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
throw new FixerAbort('settings-local-unparseable', `${SETTINGS_LOCAL} is not valid JSON — not overwriting it`, `Fix the file by hand, or add "env": {"GH_CONFIG_DIR": "${dir}"} to it yourself`);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
const env = (settings.env ?? {});
|
|
54
|
-
if (env.GH_CONFIG_DIR !== dir) {
|
|
55
|
-
await fs.outputJson(settingsPath, { ...settings, env: { ...env, GH_CONFIG_DIR: dir } }, { spaces: 2 });
|
|
56
|
-
written.push(SETTINGS_LOCAL);
|
|
57
|
-
}
|
|
58
|
-
if (await ensureIgnored(targetDir))
|
|
59
|
-
written.push('.gitignore');
|
|
60
|
-
return written;
|
|
61
|
-
}
|
|
62
|
-
// ponytail: exact-line match on the usual spellings; a glob that happens to
|
|
63
|
-
// cover the file gets a redundant (harmless) line. `git check-ignore` if that bites.
|
|
64
|
-
const IGNORE_LINES = new Set([
|
|
65
|
-
SETTINGS_LOCAL,
|
|
66
|
-
`/${SETTINGS_LOCAL}`,
|
|
67
|
-
'.claude',
|
|
68
|
-
'.claude/',
|
|
69
|
-
'/.claude',
|
|
70
|
-
'/.claude/',
|
|
71
|
-
]);
|
|
72
|
-
async function ensureIgnored(targetDir) {
|
|
73
|
-
const file = path.join(targetDir, '.gitignore');
|
|
74
|
-
const existing = (await fs.pathExists(file)) ? await fs.readFile(file, 'utf-8') : '';
|
|
75
|
-
if (existing.split('\n').some((l) => IGNORE_LINES.has(l.trim())))
|
|
76
|
-
return false;
|
|
77
|
-
const separator = existing === '' || existing.endsWith('\n') ? '' : '\n';
|
|
78
|
-
await fs.writeFile(file, `${existing}${separator}${SETTINGS_LOCAL}\n`);
|
|
79
|
-
return true;
|
|
80
|
-
}
|