@rtorcato/repo-tooling 3.8.7 → 3.9.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/README.md +2 -2
- package/dist/base/checks.js +52 -0
- package/dist/base/fixers.js +41 -1
- package/dist/base/milestones.js +141 -0
- package/dist/cli/commands/doctor.js +7 -2
- package/dist/cli/commands/fix-targets.js +6 -0
- package/dist/cli/commands/setup-presets.js +1 -1
- package/dist/cli/generators/agent-rules.js +58 -0
- package/dist/cli/generators/brand.js +284 -0
- package/dist/languages/js/checks.js +39 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<picture>
|
|
2
|
-
<source media="(max-width: 640px)" srcset="./banner-mobile.png">
|
|
3
|
-
<img src="./banner.png" alt="repo-tooling banner" width="1600">
|
|
2
|
+
<source media="(max-width: 640px)" srcset="./brand/banner-mobile.png">
|
|
3
|
+
<img src="./brand/banner.png" alt="repo-tooling banner" width="1600">
|
|
4
4
|
</picture>
|
|
5
5
|
|
|
6
6
|
<br>
|
package/dist/base/checks.js
CHANGED
|
@@ -99,6 +99,58 @@ export async function checkCommunityHealth(dir) {
|
|
|
99
99
|
hint: 'Run `npx @rtorcato/repo-tooling fix community-health` to scaffold them',
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
|
+
const BRAND_HINT = 'Run `npx @rtorcato/repo-tooling fix brand` to scaffold brand/ (SVG sources + render.sh), then run `brand/render.sh`';
|
|
103
|
+
/** The two banners the README consumes — the pair `brand/` exists to keep regenerable. */
|
|
104
|
+
const BANNERS = ['banner', 'banner-mobile'];
|
|
105
|
+
/**
|
|
106
|
+
* Brand assets (#395). A committed PNG with no SVG beside it can't be
|
|
107
|
+
* recoloured, retitled or resized — which was the state of every repo in the
|
|
108
|
+
* family but one. Three signals, all language-agnostic:
|
|
109
|
+
*
|
|
110
|
+
* 1. a rendered banner with no `brand/<name>.svg` source
|
|
111
|
+
* 2. a `brand/` folder with no `render.sh` — sources nobody can render
|
|
112
|
+
* 3. a README still pointing at the pre-amendment root-level `./banner.png`
|
|
113
|
+
*
|
|
114
|
+
* A repo with no brand images at all is `optional-missing`, not a finding:
|
|
115
|
+
* there's nothing broken to fix, and banners are opt-in.
|
|
116
|
+
*/
|
|
117
|
+
export async function checkBrand(dir) {
|
|
118
|
+
const check = 'Brand assets';
|
|
119
|
+
const has = (rel) => fs.pathExists(path.join(dir, rel));
|
|
120
|
+
const problems = [];
|
|
121
|
+
const orphans = [];
|
|
122
|
+
for (const name of BANNERS) {
|
|
123
|
+
const rendered = (await has(`${name}.png`)) || (await has(`brand/${name}.png`));
|
|
124
|
+
if (rendered && !(await has(`brand/${name}.svg`)))
|
|
125
|
+
orphans.push(`${name}.png`);
|
|
126
|
+
}
|
|
127
|
+
if (orphans.length > 0) {
|
|
128
|
+
problems.push(`${orphans.join(' + ')} committed with no brand/*.svg source`);
|
|
129
|
+
}
|
|
130
|
+
const brandDir = await has('brand');
|
|
131
|
+
if (brandDir && !(await has('brand/render.sh'))) {
|
|
132
|
+
problems.push('brand/ has no render.sh — the PNGs cannot be regenerated');
|
|
133
|
+
}
|
|
134
|
+
const readmePath = path.join(dir, 'README.md');
|
|
135
|
+
if (await fs.pathExists(readmePath)) {
|
|
136
|
+
const readme = await fs.readFile(readmePath, 'utf-8');
|
|
137
|
+
if (/(?<!brand\/)banner(?:-mobile)?\.png/.test(readme)) {
|
|
138
|
+
problems.push('README points at root-level banner PNGs (the spec moved them to brand/)');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (problems.length > 0) {
|
|
142
|
+
return { check, status: 'drift', detail: problems.join('; '), hint: BRAND_HINT };
|
|
143
|
+
}
|
|
144
|
+
if (!brandDir) {
|
|
145
|
+
return {
|
|
146
|
+
check,
|
|
147
|
+
status: 'optional-missing',
|
|
148
|
+
detail: 'no brand/ folder — the repo ships no regenerable brand sources',
|
|
149
|
+
hint: BRAND_HINT,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return { check, status: 'ok', detail: 'brand/ holds the SVG sources and render.sh' };
|
|
153
|
+
}
|
|
102
154
|
/**
|
|
103
155
|
* `org/action@vN`, the only form the generators emit. Same shape as the scan in
|
|
104
156
|
* tests/cli/generators/action-pins.test.ts — major-only, because that's the
|
package/dist/base/fixers.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import chalk from 'chalk';
|
|
15
15
|
import { installAgentRules, installAiSetup } from '../cli/generators/agent-rules.js';
|
|
16
|
+
import { generateBrand } from '../cli/generators/brand.js';
|
|
16
17
|
import { generateCommunityHealth } from '../cli/generators/community-health.js';
|
|
17
18
|
import { generateCommitlintConfig } from '../cli/generators/git.js';
|
|
18
19
|
import { generateCodeowners, generateEditorConfig } from '../cli/generators/misc.js';
|
|
@@ -21,6 +22,7 @@ import { copyPreset } from '../cli/utils/copy-preset.js';
|
|
|
21
22
|
import { detectLanguage } from '../cli/utils/detect-language.js';
|
|
22
23
|
import { resolveLanguageModule } from '../languages/registry.js';
|
|
23
24
|
import { applyGithubSettings } from './github-settings.js';
|
|
25
|
+
import { closeCompletedMilestones } from './milestones.js';
|
|
24
26
|
/** The repo's language module, resolved from its marker files. */
|
|
25
27
|
async function moduleFor(targetDir) {
|
|
26
28
|
return resolveLanguageModule(await detectLanguage(targetDir));
|
|
@@ -114,6 +116,19 @@ export const BASE_FIXERS = [
|
|
|
114
116
|
return { filesWritten: await applyGithubSettings(targetDir) };
|
|
115
117
|
},
|
|
116
118
|
},
|
|
119
|
+
{
|
|
120
|
+
target: 'milestones',
|
|
121
|
+
description: 'Close 100%-complete open milestones on GitHub via gh api (mutates the remote repo, not files). Never deletes or creates one',
|
|
122
|
+
appliesTo: ['Milestones'],
|
|
123
|
+
outputs: ['GitHub milestones (remote, via gh api)'],
|
|
124
|
+
// safe-add for the same reason github-settings is: it exempts this fixer
|
|
125
|
+
// from the `--diff` shadow-run, which executes run() for a mere preview.
|
|
126
|
+
riskLevel: 'safe-add',
|
|
127
|
+
canFixDrift: true,
|
|
128
|
+
async run({ targetDir }) {
|
|
129
|
+
return { filesWritten: await closeCompletedMilestones(targetDir) };
|
|
130
|
+
},
|
|
131
|
+
},
|
|
117
132
|
{
|
|
118
133
|
target: 'codeowners',
|
|
119
134
|
description: 'Scaffold .github/CODEOWNERS with commented examples',
|
|
@@ -144,10 +159,33 @@ export const BASE_FIXERS = [
|
|
|
144
159
|
return { filesWritten };
|
|
145
160
|
},
|
|
146
161
|
},
|
|
162
|
+
{
|
|
163
|
+
target: 'brand',
|
|
164
|
+
description: 'Scaffold brand/ — banner, mobile-banner and social-card SVG sources + render.sh, and repoint a README still on root-level banner paths',
|
|
165
|
+
appliesTo: ['Brand assets'],
|
|
166
|
+
outputs: [
|
|
167
|
+
'brand/banner.svg',
|
|
168
|
+
'brand/banner-mobile.svg',
|
|
169
|
+
'brand/social-card.svg',
|
|
170
|
+
'brand/render.sh',
|
|
171
|
+
'README.md',
|
|
172
|
+
],
|
|
173
|
+
// Every SVG is written only when absent and the README edit rewrites two
|
|
174
|
+
// image paths — hand-edited art is never clobbered.
|
|
175
|
+
riskLevel: 'safe-merge',
|
|
176
|
+
canFixDrift: true,
|
|
177
|
+
async run({ targetDir, pkg }) {
|
|
178
|
+
const filesWritten = await generateBrand(pkg, targetDir);
|
|
179
|
+
if (filesWritten.some((f) => f.endsWith('.svg'))) {
|
|
180
|
+
console.error(chalk.dim(' next: run `brand/render.sh` to render the PNGs (needs librsvg — `brew install librsvg`)'));
|
|
181
|
+
}
|
|
182
|
+
return { filesWritten };
|
|
183
|
+
},
|
|
184
|
+
},
|
|
147
185
|
{
|
|
148
186
|
target: 'ai',
|
|
149
187
|
description: 'Install all AI agent files at once (AGENTS.md, CLAUDE.md, Cursor, Copilot, Claude skill, MCP example)',
|
|
150
|
-
appliesTo: ['AI setup'],
|
|
188
|
+
appliesTo: ['AI setup', 'Claude worktree settings'],
|
|
151
189
|
outputs: [
|
|
152
190
|
'AGENTS.md',
|
|
153
191
|
'CLAUDE.md',
|
|
@@ -155,6 +193,8 @@ export const BASE_FIXERS = [
|
|
|
155
193
|
'.github/copilot-instructions.md',
|
|
156
194
|
'.claude/skills/repo-tooling.md',
|
|
157
195
|
'.mcp.json.example',
|
|
196
|
+
// Only written for a repo with a package.json — nothing to symlink otherwise.
|
|
197
|
+
'.claude/settings.json',
|
|
158
198
|
// Only written when the repo ships its own skills/<name>/SKILL.md.
|
|
159
199
|
'README.md',
|
|
160
200
|
],
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import { realGhExec } from './github-settings.js';
|
|
5
|
+
/**
|
|
6
|
+
* Milestone hygiene (#397). `doctor` already audits GitHub-side state, and an
|
|
7
|
+
* open milestone at 100% is the same class of drift: "open" stops meaning "in
|
|
8
|
+
* flight", so the milestone list carries no signal.
|
|
9
|
+
*
|
|
10
|
+
* Read-only, on the same `gh` seam as github-settings.ts. No repo probe is
|
|
11
|
+
* needed — `gh` expands `{owner}/{repo}` from the remote itself.
|
|
12
|
+
*/
|
|
13
|
+
const CHECK = 'Milestones';
|
|
14
|
+
/**
|
|
15
|
+
* A milestone with no completion criterion can never close, so it is
|
|
16
|
+
* structurally a label. Warned about, never fixed — the call is the
|
|
17
|
+
* maintainer's.
|
|
18
|
+
*/
|
|
19
|
+
const CATCH_ALL_TITLE = /backlog|post-\d|someday/i;
|
|
20
|
+
const skip = (reason) => ({
|
|
21
|
+
check: CHECK,
|
|
22
|
+
status: 'ok',
|
|
23
|
+
detail: `skipped — ${reason}`,
|
|
24
|
+
});
|
|
25
|
+
const titles = (ms) => ms.map((m) => `"${m.title}"`).join(', ');
|
|
26
|
+
async function readMilestones(gh) {
|
|
27
|
+
// `-X GET` is load-bearing: `-f`/`-F` without an explicit method make `gh`
|
|
28
|
+
// switch to POST, which would hit the *create* milestone endpoint. With it,
|
|
29
|
+
// the fields land in the query string and the path keeps gh's
|
|
30
|
+
// {owner}/{repo} placeholders intact.
|
|
31
|
+
const r = await gh([
|
|
32
|
+
'api',
|
|
33
|
+
'-X',
|
|
34
|
+
'GET',
|
|
35
|
+
'repos/{owner}/{repo}/milestones',
|
|
36
|
+
'-f',
|
|
37
|
+
'state=all',
|
|
38
|
+
'-F',
|
|
39
|
+
'per_page=100',
|
|
40
|
+
]);
|
|
41
|
+
if (!r.ok)
|
|
42
|
+
return null;
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(r.stdout);
|
|
45
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function classify(milestones) {
|
|
52
|
+
const open = milestones.filter((m) => m.state === 'open');
|
|
53
|
+
return {
|
|
54
|
+
// The number guard is the injection boundary: it is interpolated into the
|
|
55
|
+
// PATCH path by the fixer below.
|
|
56
|
+
complete: open.filter((m) => m.open_issues === 0 && m.closed_issues > 0 && Number.isInteger(m.number)),
|
|
57
|
+
// GitHub renders the bar as closed/total, so an empty milestone is a
|
|
58
|
+
// permanent 0% that can never move.
|
|
59
|
+
empty: open.filter((m) => m.open_issues === 0 && m.closed_issues === 0),
|
|
60
|
+
catchAll: open.filter((m) => CATCH_ALL_TITLE.test(m.title)),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The catch-all warning rides in `detail` rather than being its own finding:
|
|
65
|
+
* `fix` deliberately can't resolve it (converting a milestone to a label is a
|
|
66
|
+
* planning decision), and a permanently-unfixable drift row would nag forever.
|
|
67
|
+
*/
|
|
68
|
+
const catchAllNote = (catchAll) => catchAll.length === 0
|
|
69
|
+
? ''
|
|
70
|
+
: ` — note: ${titles(catchAll)} has no completion criterion so it can never close; that is a label, not a milestone`;
|
|
71
|
+
export async function checkMilestones(dir, exec) {
|
|
72
|
+
// Cheap gate first: no .git → never spawn (keeps tmp-dir doctor runs offline).
|
|
73
|
+
if (!(await fs.pathExists(path.join(dir, '.git'))))
|
|
74
|
+
return skip('not a git repository');
|
|
75
|
+
const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
|
|
76
|
+
const milestones = await readMilestones(gh);
|
|
77
|
+
if (!milestones)
|
|
78
|
+
return skip('could not read milestones');
|
|
79
|
+
// Using no milestones at all is a legitimate choice, not drift.
|
|
80
|
+
if (milestones.length === 0)
|
|
81
|
+
return skip('repo uses no milestones');
|
|
82
|
+
const { complete, empty, catchAll } = classify(milestones);
|
|
83
|
+
const deltas = [];
|
|
84
|
+
if (complete.length)
|
|
85
|
+
deltas.push(`100% complete but still open: ${titles(complete)}`);
|
|
86
|
+
if (empty.length)
|
|
87
|
+
deltas.push(`no issues, so a permanent 0%: ${titles(empty)}`);
|
|
88
|
+
const note = catchAllNote(catchAll);
|
|
89
|
+
if (deltas.length)
|
|
90
|
+
return {
|
|
91
|
+
check: CHECK,
|
|
92
|
+
status: 'drift',
|
|
93
|
+
detail: deltas.join('; ') + note,
|
|
94
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix milestones` to close the 100%-complete ones. Empty milestones are left alone — file their issues or delete them by hand',
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
check: CHECK,
|
|
98
|
+
status: 'ok',
|
|
99
|
+
detail: `${milestones.length} milestone(s); none complete-but-open or empty${note}`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Closes every open milestone that is 100% complete. Deliberately the only
|
|
104
|
+
* mutation: deleting an empty milestone or creating a missing one would destroy
|
|
105
|
+
* or invent planning intent. Idempotent — a clean repo is a no-op.
|
|
106
|
+
*
|
|
107
|
+
* Advisories go to `console.error`; stdout carries the `--json` payload (#357).
|
|
108
|
+
*/
|
|
109
|
+
export async function closeCompletedMilestones(dir, exec) {
|
|
110
|
+
if (!(await fs.pathExists(path.join(dir, '.git')))) {
|
|
111
|
+
console.error(chalk.gray(' skipped — not a git repository'));
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
|
|
115
|
+
const milestones = await readMilestones(gh);
|
|
116
|
+
if (!milestones) {
|
|
117
|
+
console.error(chalk.gray(' skipped — could not read milestones'));
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
const { complete, empty } = classify(milestones);
|
|
121
|
+
const closed = [];
|
|
122
|
+
for (const m of complete) {
|
|
123
|
+
const r = await gh([
|
|
124
|
+
'api',
|
|
125
|
+
'-X',
|
|
126
|
+
'PATCH',
|
|
127
|
+
`repos/{owner}/{repo}/milestones/${m.number}`,
|
|
128
|
+
'-f',
|
|
129
|
+
'state=closed',
|
|
130
|
+
]);
|
|
131
|
+
if (r.ok)
|
|
132
|
+
closed.push(`closed milestone "${m.title}"`);
|
|
133
|
+
else
|
|
134
|
+
console.error(chalk.yellow(` could not close "${m.title}": ${r.stderr.trim() || 'gh error'}`));
|
|
135
|
+
}
|
|
136
|
+
if (empty.length)
|
|
137
|
+
console.error(chalk.gray(` left ${empty.length} empty milestone(s) alone — file their issues or delete them by hand`));
|
|
138
|
+
if (closed.length === 0)
|
|
139
|
+
console.error(chalk.gray(' no 100%-complete open milestones to close'));
|
|
140
|
+
return closed;
|
|
141
|
+
}
|
|
@@ -13,11 +13,12 @@ import { SWIFT_GIT_HOOKS, runSwiftChecks } from '../../languages/swift/checks.js
|
|
|
13
13
|
import { readSwiftPackage, renderSwiftWorkflow } from '../../languages/swift/ci.js';
|
|
14
14
|
import { detectLanguage } from '../utils/detect-language.js';
|
|
15
15
|
import { checkGitHubSettings } from '../../base/github-settings.js';
|
|
16
|
+
import { checkMilestones } from '../../base/milestones.js';
|
|
16
17
|
import { checkGitIdentity } from '../../base/git-identity.js';
|
|
17
18
|
import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
|
|
18
19
|
import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
|
|
19
|
-
import { checkAiSetup, checkCodeowners, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
|
|
20
|
-
import { allDeps, checkAreTheTypesWrong, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPackageManager, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
|
|
20
|
+
import { checkAiSetup, checkBrand, checkCodeowners, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
|
|
21
|
+
import { allDeps, checkAreTheTypesWrong, 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';
|
|
21
22
|
export { evaluateNodeVersion };
|
|
22
23
|
const PACKAGE = '@rtorcato/repo-tooling';
|
|
23
24
|
// Detects the broken-release-on-protected-main footgun: a workflow that runs
|
|
@@ -163,9 +164,12 @@ async function runBaseChecks(dir, lock, opts) {
|
|
|
163
164
|
// GitHub repo-settings drift (branch protection, merge settings, workflow
|
|
164
165
|
// permissions). Read-only; self-skips as `ok` outside a live GitHub repo.
|
|
165
166
|
results.push(...(await checkGitHubSettings(dir)));
|
|
167
|
+
// Milestone hygiene (#397) — same seam, same self-skip.
|
|
168
|
+
results.push(await checkMilestones(dir));
|
|
166
169
|
results.push(await checkGitLabCI(dir));
|
|
167
170
|
results.push(await checkCodeowners(dir));
|
|
168
171
|
results.push(await checkCommunityHealth(dir));
|
|
172
|
+
results.push(await checkBrand(dir));
|
|
169
173
|
results.push(await checkAiSetup(dir));
|
|
170
174
|
results.push(await checkReadmeBadges(dir, opts.badges.audience, opts.badges.fixTarget));
|
|
171
175
|
results.push(await checkCoverageUpload(dir));
|
|
@@ -304,6 +308,7 @@ export async function runDoctor(dir) {
|
|
|
304
308
|
results.push(await checkTreeshakeSetup(targetDir, pkg));
|
|
305
309
|
results.push(await checkPnpmWorkspace(targetDir, pkg));
|
|
306
310
|
results.push(await checkBuildApprovals(targetDir, pkg));
|
|
311
|
+
results.push(await checkClaudeWorktreeSettings(targetDir));
|
|
307
312
|
// Turborepo is monorepo-only — only surface the check when a workspace exists.
|
|
308
313
|
if (await fs.pathExists(path.join(targetDir, 'pnpm-workspace.yaml'))) {
|
|
309
314
|
results.push(await checkTurborepo(targetDir));
|
|
@@ -28,6 +28,7 @@ export const FIX_TARGETS = {
|
|
|
28
28
|
'Merge settings': 'github-settings',
|
|
29
29
|
'Workflow permissions': 'github-settings',
|
|
30
30
|
'Code-scanning gate': 'github-settings',
|
|
31
|
+
Milestones: 'milestones',
|
|
31
32
|
CODEOWNERS: 'codeowners',
|
|
32
33
|
'GitLab CI': 'gitlab-ci',
|
|
33
34
|
Turborepo: 'turborepo',
|
|
@@ -41,8 +42,10 @@ export const FIX_TARGETS = {
|
|
|
41
42
|
'are-the-types-wrong': 'attw',
|
|
42
43
|
publint: 'publint',
|
|
43
44
|
'README badges': 'badges',
|
|
45
|
+
'Brand assets': 'brand',
|
|
44
46
|
TypeDoc: 'typedoc',
|
|
45
47
|
'AI setup': 'ai',
|
|
48
|
+
'Claude worktree settings': 'ai',
|
|
46
49
|
};
|
|
47
50
|
/**
|
|
48
51
|
* Where the Swift module's fixers shadow (or extend) the JS-named defaults
|
|
@@ -154,6 +157,9 @@ export function declinedInLock(lock, checkName) {
|
|
|
154
157
|
case 'README badges':
|
|
155
158
|
return c.badges === false;
|
|
156
159
|
case 'AI setup':
|
|
160
|
+
// The same recorded choice: the worktree settings are part of what
|
|
161
|
+
// `fix ai` writes, so a repo that declined AI setup declined them too.
|
|
162
|
+
case 'Claude worktree settings':
|
|
157
163
|
return c.aiSetup === false;
|
|
158
164
|
case 'Turborepo':
|
|
159
165
|
return c.turborepo === false;
|
|
@@ -273,7 +273,7 @@ export function computeFileList(config) {
|
|
|
273
273
|
// so it's written for every JS scaffold.
|
|
274
274
|
files.push('pnpm-workspace.yaml');
|
|
275
275
|
if (config.aiSetup) {
|
|
276
|
-
files.push('AGENTS.md', 'CLAUDE.md', '.cursor/rules/repo-tooling.mdc', '.github/copilot-instructions.md', '.claude/skills/repo-tooling.md', '.mcp.json.example');
|
|
276
|
+
files.push('AGENTS.md', 'CLAUDE.md', '.cursor/rules/repo-tooling.mdc', '.github/copilot-instructions.md', '.claude/skills/repo-tooling.md', '.mcp.json.example', '.claude/settings.json');
|
|
277
277
|
}
|
|
278
278
|
if (config.turborepo)
|
|
279
279
|
files.push('turbo.json');
|
|
@@ -28,6 +28,60 @@ export function hasStaleAgentBlock(content) {
|
|
|
28
28
|
const end = content.indexOf(BLOCK_END, bodyStart);
|
|
29
29
|
return content.slice(bodyStart, end === -1 ? undefined : end).includes(LEGACY_TOOL_NAME);
|
|
30
30
|
}
|
|
31
|
+
export const CLAUDE_SETTINGS_FILE = path.join('.claude', 'settings.json');
|
|
32
|
+
/**
|
|
33
|
+
* Directories Claude Code symlinks from the main checkout into a fresh
|
|
34
|
+
* worktree instead of leaving the agent to reinstall them (#396). An agent
|
|
35
|
+
* working a per-issue worktree can typecheck immediately rather than paying a
|
|
36
|
+
* full `pnpm install` first, on every issue.
|
|
37
|
+
*/
|
|
38
|
+
export const WORKTREE_SYMLINK_DIRS = ['node_modules'];
|
|
39
|
+
function asObject(value) {
|
|
40
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
41
|
+
? value
|
|
42
|
+
: null;
|
|
43
|
+
}
|
|
44
|
+
/** Parsed `.claude/settings.json`, or null when absent, malformed, or not an object. */
|
|
45
|
+
export async function readClaudeSettings(targetDir) {
|
|
46
|
+
try {
|
|
47
|
+
return asObject(await fs.readJson(path.join(targetDir, CLAUDE_SETTINGS_FILE)));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** The `worktree.symlinkDirectories` entries already recorded, ignoring junk. */
|
|
54
|
+
export function worktreeSymlinkDirs(settings) {
|
|
55
|
+
const dirs = asObject(settings?.worktree)?.symlinkDirectories;
|
|
56
|
+
return Array.isArray(dirs) ? dirs.filter((d) => typeof d === 'string') : [];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Upsert `worktree.symlinkDirectories` into `.claude/settings.json`. Repos
|
|
60
|
+
* already keep `hooks` and `permissions` in that file, so only the one key is
|
|
61
|
+
* merged and everything else survives — the same contract the AGENTS.md and
|
|
62
|
+
* copilot-instructions blocks give.
|
|
63
|
+
*
|
|
64
|
+
* Returns the written path, or null when there is nothing to write: a repo with
|
|
65
|
+
* no package.json has no node_modules to symlink (Swift, Python, Perl), and a
|
|
66
|
+
* file that doesn't parse is left for a human rather than clobbered.
|
|
67
|
+
*/
|
|
68
|
+
export async function installClaudeSettings(targetDir) {
|
|
69
|
+
if (!(await fs.pathExists(path.join(targetDir, 'package.json'))))
|
|
70
|
+
return null;
|
|
71
|
+
const file = path.join(targetDir, CLAUDE_SETTINGS_FILE);
|
|
72
|
+
const exists = await fs.pathExists(file);
|
|
73
|
+
const settings = exists ? await readClaudeSettings(targetDir) : {};
|
|
74
|
+
if (!settings)
|
|
75
|
+
return null;
|
|
76
|
+
const existing = worktreeSymlinkDirs(settings);
|
|
77
|
+
const symlinkDirectories = [
|
|
78
|
+
...existing,
|
|
79
|
+
...WORKTREE_SYMLINK_DIRS.filter((d) => !existing.includes(d)),
|
|
80
|
+
];
|
|
81
|
+
const worktree = { ...asObject(settings.worktree), symlinkDirectories };
|
|
82
|
+
await fs.outputJson(file, { ...settings, worktree }, { spaces: 2 });
|
|
83
|
+
return CLAUDE_SETTINGS_FILE;
|
|
84
|
+
}
|
|
31
85
|
/** Read the shipped skill and split its frontmatter from the markdown body. */
|
|
32
86
|
async function readSkill() {
|
|
33
87
|
const raw = await fs.readFile(path.join(getPackageRoot(), SOURCE), 'utf8');
|
|
@@ -86,6 +140,10 @@ export async function installAiSetup(targetDir) {
|
|
|
86
140
|
written.push(await installAgentRules(targetDir, 'copilot'));
|
|
87
141
|
written.push((await copyPreset('claude-skill', targetDir)).target);
|
|
88
142
|
written.push((await copyPreset('mcp-example', targetDir)).target);
|
|
143
|
+
// Only for repos that have node_modules to symlink — see installClaudeSettings.
|
|
144
|
+
const claudeSettings = await installClaudeSettings(targetDir);
|
|
145
|
+
if (claudeSettings)
|
|
146
|
+
written.push(claudeSettings);
|
|
89
147
|
// If this repo ships its own skills (skills/<name>/SKILL.md), document their
|
|
90
148
|
// one-command `npx skills add` install in README.md. No-op for repos without.
|
|
91
149
|
const skillsDoc = await installSkillsInstallDocs(targetDir);
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brand generator (#395) — the sources half of the brand-asset spec (#318).
|
|
3
|
+
*
|
|
4
|
+
* #318 standardised *which* images a repo ships; this writes **where they come
|
|
5
|
+
* from**: `brand/` holds the SVG sources and `brand/render.sh` renders them to
|
|
6
|
+
* PNG, so a banner can be recoloured, retitled or resized instead of being a
|
|
7
|
+
* committed binary nobody can regenerate.
|
|
8
|
+
*
|
|
9
|
+
* Everything in the emitted SVGs is derived from the consuming repo — name and
|
|
10
|
+
* tagline from its package.json, accent from its own docs theme or favicon —
|
|
11
|
+
* and falls back to a neutral grey. Nothing about any particular org is baked
|
|
12
|
+
* in; the templates are meant to be hand-edited afterwards.
|
|
13
|
+
*/
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import fs from 'fs-extra';
|
|
16
|
+
/** Grey, so an unbranded repo reads as unbranded rather than borrowing a colour. */
|
|
17
|
+
const NEUTRAL_ACCENT = '#8b95a7';
|
|
18
|
+
/** The cool counter-glow in the corner opposite the accent one. Fixed — it reads as depth, not brand. */
|
|
19
|
+
const COUNTER_GLOW = '#6e7bff';
|
|
20
|
+
const INK = '#0A0E16';
|
|
21
|
+
const TEXT = '#e6edf3';
|
|
22
|
+
const MUTED = '#9ba6b8';
|
|
23
|
+
function esc(s) {
|
|
24
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Greedy word wrap to a character budget. Character-budgeted rather than
|
|
28
|
+
* measured because there is no text metric available here — the templates are
|
|
29
|
+
* meant to be nudged by hand once rendered.
|
|
30
|
+
*/
|
|
31
|
+
export function wrapText(text, maxChars, maxLines) {
|
|
32
|
+
const lines = [];
|
|
33
|
+
let line = '';
|
|
34
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
35
|
+
const next = line ? `${line} ${word}` : word;
|
|
36
|
+
if (next.length > maxChars && line) {
|
|
37
|
+
lines.push(line);
|
|
38
|
+
line = word;
|
|
39
|
+
if (lines.length === maxLines)
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
line = next;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (lines.length < maxLines && line)
|
|
47
|
+
lines.push(line);
|
|
48
|
+
// Anything that didn't fit is dropped rather than overflowing the canvas.
|
|
49
|
+
if (lines.length === maxLines && text.length > lines.join(' ').length) {
|
|
50
|
+
lines[maxLines - 1] = `${lines[maxLines - 1]}…`;
|
|
51
|
+
}
|
|
52
|
+
return lines;
|
|
53
|
+
}
|
|
54
|
+
/** Near-black and near-white are background, not brand — skip them when sniffing a favicon. */
|
|
55
|
+
function isBackgroundColour(hex) {
|
|
56
|
+
const n = Number.parseInt(hex.slice(1), 16);
|
|
57
|
+
const r = (n >> 16) & 0xff;
|
|
58
|
+
const g = (n >> 8) & 0xff;
|
|
59
|
+
const b = n & 0xff;
|
|
60
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
61
|
+
return luminance < 60 || luminance > 225;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The docs site's own accent, which is the most deliberate colour choice a repo
|
|
65
|
+
* makes. Prefers the dark-mode value: these banners sit on a dark canvas.
|
|
66
|
+
*/
|
|
67
|
+
async function accentFromDocsTheme(targetDir) {
|
|
68
|
+
const file = path.join(targetDir, 'apps', 'docs', 'src', 'css', 'custom.css');
|
|
69
|
+
if (!(await fs.pathExists(file)))
|
|
70
|
+
return null;
|
|
71
|
+
const css = await fs.readFile(file, 'utf-8');
|
|
72
|
+
const dark = css.match(/\[data-theme=["']dark["']\][\s\S]*?--ifm-color-primary:\s*(#[0-9a-fA-F]{6})/);
|
|
73
|
+
if (dark?.[1])
|
|
74
|
+
return dark[1];
|
|
75
|
+
return css.match(/--ifm-color-primary:\s*(#[0-9a-fA-F]{6})/)?.[1] ?? null;
|
|
76
|
+
}
|
|
77
|
+
/** Failing that, the favicon's own ink — the other place a repo commits its colour. */
|
|
78
|
+
async function accentFromFavicon(targetDir) {
|
|
79
|
+
const candidates = [path.join('apps', 'docs', 'static', 'img', 'favicon.svg'), 'favicon.svg'];
|
|
80
|
+
for (const rel of candidates) {
|
|
81
|
+
const file = path.join(targetDir, rel);
|
|
82
|
+
if (!(await fs.pathExists(file)))
|
|
83
|
+
continue;
|
|
84
|
+
const svg = await fs.readFile(file, 'utf-8');
|
|
85
|
+
for (const [hex] of svg.matchAll(/#[0-9a-fA-F]{6}\b/g)) {
|
|
86
|
+
if (!isBackgroundColour(hex))
|
|
87
|
+
return hex;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
export async function resolveBrandMeta(pkg, targetDir) {
|
|
93
|
+
const pkgName = typeof pkg?.name === 'string' ? pkg.name : undefined;
|
|
94
|
+
const name = pkgName?.split('/').pop() ?? path.basename(path.resolve(targetDir));
|
|
95
|
+
const description = typeof pkg?.description === 'string' ? pkg.description : '';
|
|
96
|
+
const accent = (await accentFromDocsTheme(targetDir)) ?? (await accentFromFavicon(targetDir)) ?? NEUTRAL_ACCENT;
|
|
97
|
+
return {
|
|
98
|
+
name,
|
|
99
|
+
tagline: description || 'Add a one-line tagline to package.json "description".',
|
|
100
|
+
accent,
|
|
101
|
+
install: pkgName && pkg?.private !== true ? pkgName : null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The logo mark: a rounded square in the accent carrying the project's initial.
|
|
106
|
+
* Authored on a 32 viewBox so it matches the favicon's geometry and can be
|
|
107
|
+
* swapped for the real favicon glyph verbatim.
|
|
108
|
+
*/
|
|
109
|
+
function mark(meta, translate, scale) {
|
|
110
|
+
const initial = esc((meta.name[0] ?? '?').toUpperCase());
|
|
111
|
+
return ` <!-- Logo mark: a 32 viewBox, so the real favicon glyph can be pasted in over it. -->
|
|
112
|
+
<g transform="translate(${translate}) scale(${scale})">
|
|
113
|
+
<rect width="32" height="32" rx="8" fill="${meta.accent}"/>
|
|
114
|
+
<text x="16" y="23" text-anchor="middle" font-family="Avenir Next" font-weight="800" font-size="19" fill="${INK}">${initial}</text>
|
|
115
|
+
</g>`;
|
|
116
|
+
}
|
|
117
|
+
/** `repo-tooling` renders as a muted `repo-` and an accented `tooling`. */
|
|
118
|
+
function wordmark(meta) {
|
|
119
|
+
const i = meta.name.lastIndexOf('-');
|
|
120
|
+
if (i <= 0)
|
|
121
|
+
return `<tspan fill="${meta.accent}">${esc(meta.name)}</tspan>`;
|
|
122
|
+
return `<tspan fill="${TEXT}">${esc(meta.name.slice(0, i + 1))}</tspan><tspan fill="${meta.accent}">${esc(meta.name.slice(i + 1))}</tspan>`;
|
|
123
|
+
}
|
|
124
|
+
function taglineBlock(meta, opts) {
|
|
125
|
+
// Three lines: every canvas has room for a third, and cutting a real tagline
|
|
126
|
+
// short is worse than one extra line of copy.
|
|
127
|
+
const lines = wrapText(meta.tagline, opts.maxChars, 3);
|
|
128
|
+
// librsvg does not reset x on a y-only tspan, so every line repeats x.
|
|
129
|
+
const tspans = lines
|
|
130
|
+
.map((l, i) => `\t\t<tspan x="${opts.x}" y="${opts.y + i * opts.step}">${esc(l)}</tspan>`)
|
|
131
|
+
.join('\n');
|
|
132
|
+
const anchor = opts.centred ? ' text-anchor="middle"' : '';
|
|
133
|
+
return ` <text${anchor} font-family="Avenir Next" font-weight="500" font-size="${opts.size}" fill="${MUTED}">
|
|
134
|
+
${tspans}
|
|
135
|
+
</text>`;
|
|
136
|
+
}
|
|
137
|
+
/** The install pill. Omitted entirely for a repo with nothing to `npm i`. */
|
|
138
|
+
function installPanel(meta, opts) {
|
|
139
|
+
if (!meta.install)
|
|
140
|
+
return '';
|
|
141
|
+
return `
|
|
142
|
+
<rect x="${opts.x}" y="${opts.y}" width="${opts.w}" height="${opts.h}" rx="14" fill="#11151d" stroke="#232936" stroke-width="1"/>
|
|
143
|
+
<text xml:space="preserve" x="${opts.x + opts.w / 2}" y="${opts.y + opts.h / 2 + opts.size / 3}" text-anchor="middle" font-family="Menlo" font-size="${opts.size}"><tspan fill="${meta.accent}">npm i </tspan><tspan fill="${TEXT}">${esc(meta.install)}</tspan></text>`;
|
|
144
|
+
}
|
|
145
|
+
function canvas(meta, w, h, glow) {
|
|
146
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" role="img" aria-label="${esc(meta.name)} — ${esc(meta.tagline)}">
|
|
147
|
+
<defs>
|
|
148
|
+
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
149
|
+
<stop offset="0" stop-color="#0d1117"/>
|
|
150
|
+
<stop offset="1" stop-color="#090c13"/>
|
|
151
|
+
</linearGradient>
|
|
152
|
+
<radialGradient id="glow" cx="${glow.cx}" cy="${glow.cy}" r="0.55">
|
|
153
|
+
<stop offset="0" stop-color="${meta.accent}" stop-opacity="0.15"/>
|
|
154
|
+
<stop offset="1" stop-color="${meta.accent}" stop-opacity="0"/>
|
|
155
|
+
</radialGradient>
|
|
156
|
+
<radialGradient id="glow2" cx="0.92" cy="1" r="0.5">
|
|
157
|
+
<stop offset="0" stop-color="${COUNTER_GLOW}" stop-opacity="0.12"/>
|
|
158
|
+
<stop offset="1" stop-color="${COUNTER_GLOW}" stop-opacity="0"/>
|
|
159
|
+
</radialGradient>
|
|
160
|
+
</defs>
|
|
161
|
+
|
|
162
|
+
<rect width="${w}" height="${h}" fill="url(#bg)"/>
|
|
163
|
+
<rect width="${w}" height="${h}" fill="url(#glow)"/>
|
|
164
|
+
<rect width="${w}" height="${h}" fill="url(#glow2)"/>
|
|
165
|
+
`;
|
|
166
|
+
}
|
|
167
|
+
/** 1280×320 README banner — left-aligned lockup, install pill on the right. */
|
|
168
|
+
export function bannerSvg(meta) {
|
|
169
|
+
return `${canvas(meta, 1280, 320, { cx: 0.16, cy: 0 })}
|
|
170
|
+
${mark(meta, '60 88', 2.25)}
|
|
171
|
+
|
|
172
|
+
<text x="156" y="150" font-family="Avenir Next" font-weight="800" font-size="62" letter-spacing="-1.5">${wordmark(meta)}</text>
|
|
173
|
+
|
|
174
|
+
${taglineBlock(meta, { x: 62, y: 198, step: 28, size: 20, centred: false, maxChars: 44 })}
|
|
175
|
+
${installPanel(meta, { x: 845, y: 118, w: 378, h: 84, size: 20 })}
|
|
176
|
+
</svg>
|
|
177
|
+
`;
|
|
178
|
+
}
|
|
179
|
+
/** 1280×786 mobile banner — the same content stacked so it stays legible on a phone. */
|
|
180
|
+
export function bannerMobileSvg(meta) {
|
|
181
|
+
return `${canvas(meta, 1280, 786, { cx: 0.12, cy: 0.05 })}
|
|
182
|
+
${mark(meta, '565 104', 4.6875)}
|
|
183
|
+
|
|
184
|
+
<text x="640" y="360" text-anchor="middle" font-family="Avenir Next" font-weight="800" font-size="76" letter-spacing="-1.8">${wordmark(meta)}</text>
|
|
185
|
+
|
|
186
|
+
${taglineBlock(meta, { x: 640, y: 510, step: 44, size: 30, centred: true, maxChars: 42 })}
|
|
187
|
+
${installPanel(meta, { x: 427, y: 650, w: 426, h: 78, size: 24 })}
|
|
188
|
+
</svg>
|
|
189
|
+
`;
|
|
190
|
+
}
|
|
191
|
+
/** 1280×640 Open Graph / GitHub social card. Keep content inside an ~8% safe inset. */
|
|
192
|
+
export function socialCardSvg(meta) {
|
|
193
|
+
return `${canvas(meta, 1280, 640, { cx: 0.1, cy: 0.05 })}
|
|
194
|
+
${mark(meta, '590 120', 3.125)}
|
|
195
|
+
|
|
196
|
+
<text x="640" y="300" text-anchor="middle" font-family="Avenir Next" font-weight="800" font-size="76" letter-spacing="-1.8">${wordmark(meta)}</text>
|
|
197
|
+
|
|
198
|
+
${taglineBlock(meta, { x: 640, y: 372, step: 42, size: 28, centred: true, maxChars: 44 })}
|
|
199
|
+
${installPanel(meta, { x: 427, y: 470, w: 426, h: 78, size: 24 })}
|
|
200
|
+
</svg>
|
|
201
|
+
`;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The render script. Sizes come from the #318 spec. It self-skips outputs whose
|
|
205
|
+
* source or destination doesn't exist, so the same script works in a repo with
|
|
206
|
+
* no docs site.
|
|
207
|
+
*/
|
|
208
|
+
export const RENDER_SH = `#!/usr/bin/env bash
|
|
209
|
+
# Render the committed brand PNGs from their SVG sources.
|
|
210
|
+
# Sizes come from the brand-asset spec: 1280x320 banner, 1280x786 mobile,
|
|
211
|
+
# 1280x640 social card, 512x512 PWA icon.
|
|
212
|
+
set -euo pipefail
|
|
213
|
+
cd "$(dirname "$0")/.."
|
|
214
|
+
|
|
215
|
+
if ! command -v rsvg-convert >/dev/null 2>&1; then
|
|
216
|
+
echo "brand/render.sh needs librsvg — install it with: brew install librsvg" >&2
|
|
217
|
+
echo "(apt: apt-get install librsvg2-bin)" >&2
|
|
218
|
+
exit 1
|
|
219
|
+
fi
|
|
220
|
+
|
|
221
|
+
rsvg-convert -w 1280 -h 320 brand/banner.svg -o brand/banner.png
|
|
222
|
+
rsvg-convert -w 1280 -h 786 brand/banner-mobile.svg -o brand/banner-mobile.png
|
|
223
|
+
echo "rendered: brand/banner.png brand/banner-mobile.png"
|
|
224
|
+
|
|
225
|
+
# The docs-site assets, rendered only when the site exists to hold them.
|
|
226
|
+
img=apps/docs/static/img
|
|
227
|
+
if [ -d "$img" ]; then
|
|
228
|
+
rsvg-convert -w 1280 -h 640 brand/social-card.svg -o "$img/social-card.png"
|
|
229
|
+
echo "rendered: $img/social-card.png"
|
|
230
|
+
if [ -f "$img/favicon.svg" ]; then
|
|
231
|
+
rsvg-convert -w 512 -h 512 "$img/favicon.svg" -o "$img/favicon-512.png"
|
|
232
|
+
echo "rendered: $img/favicon-512.png"
|
|
233
|
+
fi
|
|
234
|
+
fi
|
|
235
|
+
`;
|
|
236
|
+
/** Write `contents` at `rel` only when absent, so re-running never clobbers hand-edited art. */
|
|
237
|
+
async function writeIfMissing(targetDir, rel, contents, mode) {
|
|
238
|
+
const file = path.join(targetDir, rel);
|
|
239
|
+
if (await fs.pathExists(file))
|
|
240
|
+
return null;
|
|
241
|
+
await fs.ensureDir(path.dirname(file));
|
|
242
|
+
await fs.writeFile(file, contents, mode ? { mode } : undefined);
|
|
243
|
+
return rel;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Repoint a README still using the pre-amendment root-level banner paths at
|
|
247
|
+
* `brand/`. Only the two banner `srcset`/`src` values move — nothing else in the
|
|
248
|
+
* README is touched.
|
|
249
|
+
*/
|
|
250
|
+
export async function repointReadmeBanners(targetDir) {
|
|
251
|
+
const file = path.join(targetDir, 'README.md');
|
|
252
|
+
if (!(await fs.pathExists(file)))
|
|
253
|
+
return null;
|
|
254
|
+
const readme = await fs.readFile(file, 'utf-8');
|
|
255
|
+
const next = readme.replace(/(?<!brand\/)(?:\.\/)?(banner(?:-mobile)?\.png)/g, './brand/$1');
|
|
256
|
+
if (next === readme)
|
|
257
|
+
return null;
|
|
258
|
+
await fs.writeFile(file, next);
|
|
259
|
+
return 'README.md';
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Scaffold `brand/`: three SVG sources + the render script, then repoint a
|
|
263
|
+
* README still on the old root-level paths. Every file is written only when
|
|
264
|
+
* absent, so `fix brand` is idempotent.
|
|
265
|
+
*/
|
|
266
|
+
export async function generateBrand(pkg, targetDir) {
|
|
267
|
+
const meta = await resolveBrandMeta(pkg, targetDir);
|
|
268
|
+
const written = [];
|
|
269
|
+
const files = [
|
|
270
|
+
['brand/banner.svg', bannerSvg(meta)],
|
|
271
|
+
['brand/banner-mobile.svg', bannerMobileSvg(meta)],
|
|
272
|
+
['brand/social-card.svg', socialCardSvg(meta)],
|
|
273
|
+
['brand/render.sh', RENDER_SH, 0o755],
|
|
274
|
+
];
|
|
275
|
+
for (const [rel, contents, mode] of files) {
|
|
276
|
+
const w = await writeIfMissing(targetDir, rel, contents, mode);
|
|
277
|
+
if (w)
|
|
278
|
+
written.push(w);
|
|
279
|
+
}
|
|
280
|
+
const readme = await repointReadmeBanners(targetDir);
|
|
281
|
+
if (readme)
|
|
282
|
+
written.push(readme);
|
|
283
|
+
return written;
|
|
284
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
import { hookHasUncommented } from '../../base/checks.js';
|
|
4
|
+
import { CLAUDE_SETTINGS_FILE, readClaudeSettings, worktreeSymlinkDirs, } from '../../cli/generators/agent-rules.js';
|
|
4
5
|
import { WORKSPACE_FILE, dependsOnEsbuild, familyGlob, missingPnpmSettings, } from '../../cli/generators/pnpm-workspace.js';
|
|
5
6
|
const PACKAGE = '@rtorcato/repo-tooling';
|
|
6
7
|
const NODE_MIN_MAJOR = 22;
|
|
@@ -951,6 +952,44 @@ export async function checkTreeshakeSetup(dir, pkg) {
|
|
|
951
952
|
hint: 'Run `npx @rtorcato/repo-tooling fix treeshake-check` to scaffold an esbuild metafile assertion',
|
|
952
953
|
};
|
|
953
954
|
}
|
|
955
|
+
/**
|
|
956
|
+
* A Claude Code worktree starts with no node_modules, so every agent working
|
|
957
|
+
* one pays a full install before it can typecheck, lint or test — unless
|
|
958
|
+
* `.claude/settings.json` tells Claude to symlink the directory from the main
|
|
959
|
+
* checkout (#396). JS-only: the other language modules have nothing to symlink.
|
|
960
|
+
*/
|
|
961
|
+
export async function checkClaudeWorktreeSettings(dir) {
|
|
962
|
+
const check = 'Claude worktree settings';
|
|
963
|
+
const hint = `Run \`npx ${PACKAGE} fix ai\` to merge worktree.symlinkDirectories into ${CLAUDE_SETTINGS_FILE}`;
|
|
964
|
+
if (!(await fs.pathExists(path.join(dir, CLAUDE_SETTINGS_FILE)))) {
|
|
965
|
+
return {
|
|
966
|
+
check,
|
|
967
|
+
status: 'optional-missing',
|
|
968
|
+
detail: `no ${CLAUDE_SETTINGS_FILE} — agent worktrees reinstall node_modules from scratch`,
|
|
969
|
+
hint,
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
const settings = await readClaudeSettings(dir);
|
|
973
|
+
if (!settings) {
|
|
974
|
+
return {
|
|
975
|
+
check,
|
|
976
|
+
status: 'drift',
|
|
977
|
+
detail: `${CLAUDE_SETTINGS_FILE} is not a readable JSON object`,
|
|
978
|
+
// `fix ai` deliberately skips an unparseable file rather than clobbering
|
|
979
|
+
// hand-written hooks/permissions, so this one is on the human.
|
|
980
|
+
hint: `Repair the JSON in ${CLAUDE_SETTINGS_FILE} by hand — \`fix ai\` refuses to overwrite it`,
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
if (worktreeSymlinkDirs(settings).includes('node_modules')) {
|
|
984
|
+
return { check, status: 'ok', detail: 'worktree.symlinkDirectories carries node_modules' };
|
|
985
|
+
}
|
|
986
|
+
return {
|
|
987
|
+
check,
|
|
988
|
+
status: 'optional-missing',
|
|
989
|
+
detail: `${CLAUDE_SETTINGS_FILE} has no worktree.symlinkDirectories entry for node_modules`,
|
|
990
|
+
hint,
|
|
991
|
+
};
|
|
992
|
+
}
|
|
954
993
|
export async function checkTurborepo(dir) {
|
|
955
994
|
const hasTurbo = await fs.pathExists(path.join(dir, 'turbo.json'));
|
|
956
995
|
const hasNx = await fs.pathExists(path.join(dir, 'nx.json'));
|
package/package.json
CHANGED