@rtorcato/repo-tooling 4.3.0 → 4.4.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.
@@ -100,7 +100,7 @@ export async function checkCommunityHealth(dir) {
100
100
  hint: 'Run `npx @rtorcato/repo-tooling fix community-health` to scaffold them',
101
101
  };
102
102
  }
103
- const BRAND_HINT = 'Run `npx @rtorcato/repo-tooling fix brand` to scaffold brand/ (SVG sources + render.sh), then run `brand/render.sh`';
103
+ const BRAND_HINT = 'Run `npx @rtorcato/repo-tooling fix brand` to scaffold brand/ (SVG sources + render.sh) and render the PNGs (needs librsvg)';
104
104
  /** The two banners the README consumes — the pair `brand/` exists to keep regenerable. */
105
105
  const BANNERS = ['banner', 'banner-mobile'];
106
106
  /**
@@ -15,7 +15,7 @@ import path from 'node:path';
15
15
  import chalk from 'chalk';
16
16
  import fs from 'fs-extra';
17
17
  import { installAgentRules, installAiSetup } from '../cli/generators/agent-rules.js';
18
- import { generateBrand } from '../cli/generators/brand.js';
18
+ import { addReadmeBanner, generateBrand, renderBrand, resolveBrandMeta, } from '../cli/generators/brand.js';
19
19
  import { generateCommunityHealth } from '../cli/generators/community-health.js';
20
20
  import { generateCommitlintConfig } from '../cli/generators/git.js';
21
21
  import { generateCodeowners, generateEditorConfig } from '../cli/generators/misc.js';
@@ -164,12 +164,13 @@ export const BASE_FIXERS = [
164
164
  },
165
165
  {
166
166
  target: 'github-settings',
167
- description: 'Apply branch protection + auto-merge + workflow permissions + code-scanning ruleset on GitHub via gh api (mutates the remote repo, not files)',
167
+ description: 'Apply branch protection + auto-merge + workflow permissions + Dependabot security updates + code-scanning ruleset on GitHub via gh api (mutates the remote repo, not files)',
168
168
  appliesTo: [
169
169
  'Branch protection',
170
170
  'Merge settings',
171
171
  'Workflow permissions',
172
172
  'Code-scanning gate',
173
+ 'Security updates',
173
174
  ],
174
175
  outputs: ['GitHub repo settings (remote, via gh api)'],
175
176
  // safe-add is load-bearing: it exempts this fixer from the `--diff` shadow-run
@@ -249,24 +250,34 @@ export const BASE_FIXERS = [
249
250
  {
250
251
  target: 'brand',
251
252
  selfSafe: true,
252
- description: 'Scaffold brand/ — banner, mobile-banner and social-card SVG sources + render.sh, and repoint a README still on root-level banner paths',
253
+ description: 'Scaffold brand/ — favicon, banner, mobile-banner and social-card SVG sources + render.sh — render the PNGs and favicon.ico when rsvg-convert is on PATH, and add the README banner',
253
254
  appliesTo: ['Brand assets'],
254
255
  outputs: [
256
+ 'brand/favicon.svg',
255
257
  'brand/banner.svg',
256
258
  'brand/banner-mobile.svg',
257
259
  'brand/social-card.svg',
258
260
  'brand/render.sh',
261
+ 'brand/banner.png',
262
+ 'brand/banner-mobile.png',
263
+ 'brand/social-card.png',
264
+ 'brand/favicon-512.png',
265
+ 'brand/favicon.ico',
259
266
  'README.md',
260
267
  ],
261
- // Every SVG is written only when absent and the README edit rewrites two
262
- // image paths — hand-edited art is never clobbered.
268
+ // Every SVG is written only when absent, PNGs are re-rendered only when
269
+ // older than their source, and the README edit is a delimited block (or
270
+ // two image paths) — hand-edited art is never clobbered.
263
271
  riskLevel: 'safe-merge',
264
272
  canFixDrift: true,
265
273
  async run({ targetDir, pkg, lock }) {
266
- const filesWritten = await generateBrand(pkg, targetDir, lock?.rules?.brand?.tagline);
267
- if (filesWritten.some((f) => f.endsWith('.svg'))) {
268
- console.error(chalk.dim(' next: run `brand/render.sh` to render the PNGs (needs librsvg — `brew install librsvg`)'));
269
- }
274
+ const tagline = lock?.rules?.brand?.tagline;
275
+ const filesWritten = await generateBrand(pkg, targetDir, tagline);
276
+ filesWritten.push(...((await renderBrand(targetDir)) ?? []));
277
+ const { name } = await resolveBrandMeta(pkg, targetDir, tagline);
278
+ const readme = await addReadmeBanner(targetDir, name);
279
+ if (readme && !filesWritten.includes(readme))
280
+ filesWritten.push(readme);
270
281
  return { filesWritten };
271
282
  },
272
283
  },
@@ -54,10 +54,12 @@ export const GITHUB_STANDARD = {
54
54
  const CODE_SCANNING_CHECK = 'Code-scanning gate';
55
55
  export const RELEASE_GATE_CHECK = 'Release gate';
56
56
  export const RELEASE_ENV_CHECK = 'Release environment';
57
+ const SECURITY_UPDATES_CHECK = 'Security updates';
57
58
  const CHECK_NAMES = [
58
59
  'Branch protection',
59
60
  'Merge settings',
60
61
  'Workflow permissions',
62
+ SECURITY_UPDATES_CHECK,
61
63
  CODE_SCANNING_CHECK,
62
64
  RELEASE_GATE_CHECK,
63
65
  RELEASE_ENV_CHECK,
@@ -147,6 +149,7 @@ export async function checkGitHubSettings(dir, exec) {
147
149
  await checkBranchProtection(gh, info.nwo, info.branch),
148
150
  checkMergeSettings(info),
149
151
  await checkWorkflowPermissions(gh, info.nwo),
152
+ await checkSecurityUpdates(gh, info.nwo),
150
153
  await checkCodeScanningRuleset(gh, info.nwo, info.branch, dir),
151
154
  ...(await checkReleaseGate(gh, info.nwo, dir)),
152
155
  ];
@@ -277,6 +280,60 @@ async function checkWorkflowPermissions(exec, nwo) {
277
280
  };
278
281
  return { check, status: 'ok', detail: 'read-only default, no workflow PR approvals' };
279
282
  }
283
+ /**
284
+ * Dependabot vulnerability alerts and automated security fixes (#692). A
285
+ * `dependabot.yml` only schedules version bumps; these two repo toggles are what
286
+ * surface and patch advisories, and both default off on a new repo. Neither
287
+ * endpoint has a body worth reading for alerts: 204 is on, 404 is off.
288
+ */
289
+ async function readSecurityUpdates(exec, nwo) {
290
+ const enabled = async (endpoint) => {
291
+ const r = await exec(['api', `repos/${nwo}/${endpoint}`]);
292
+ if (!r.ok) {
293
+ if (/404|not found/i.test(r.stderr))
294
+ return false;
295
+ if (/403|forbidden/i.test(r.stderr))
296
+ return { skip: 'token lacks admin access' };
297
+ return { skip: `could not read ${endpoint}` };
298
+ }
299
+ if (endpoint === 'vulnerability-alerts')
300
+ return true;
301
+ try {
302
+ return JSON.parse(r.stdout).enabled === true;
303
+ }
304
+ catch {
305
+ return { skip: `could not parse ${endpoint} response` };
306
+ }
307
+ };
308
+ const alerts = await enabled('vulnerability-alerts');
309
+ if (typeof alerts !== 'boolean')
310
+ return alerts;
311
+ const fixes = await enabled('automated-security-fixes');
312
+ if (typeof fixes !== 'boolean')
313
+ return fixes;
314
+ return { alerts, fixes };
315
+ }
316
+ async function checkSecurityUpdates(exec, nwo) {
317
+ const check = SECURITY_UPDATES_CHECK;
318
+ const s = await readSecurityUpdates(exec, nwo);
319
+ if ('skip' in s)
320
+ return skip(check, s.skip);
321
+ const deltas = [];
322
+ if (!s.alerts)
323
+ deltas.push('vulnerability alerts disabled');
324
+ if (!s.fixes)
325
+ deltas.push('automated security fixes disabled');
326
+ // optional-missing, not drift: doctor promotes it when the lock records
327
+ // securityAutomation: true, and demotes it when the lock records false.
328
+ if (deltas.length)
329
+ return {
330
+ check,
331
+ status: 'optional-missing',
332
+ detail: deltas.join('; '),
333
+ hint: 'Run `npx @rtorcato/repo-tooling fix github-settings` to enable Dependabot alerts and security updates',
334
+ };
335
+ return { check, status: 'ok', detail: 'vulnerability alerts and automated security fixes on' };
336
+ }
280
337
  /**
281
338
  * True when CodeQL/code-scanning is enabled for the repo. Covers both ways it
282
339
  * ships: an advanced-setup workflow on disk (what `fix codeql` scaffolds) or
@@ -764,6 +821,17 @@ export function buildGhApplyCommands(state) {
764
821
  'can_approve_pull_request_reviews=false',
765
822
  ],
766
823
  });
824
+ // Alerts first: GitHub refuses security fixes on a repo with alerts off.
825
+ if (state.alerts)
826
+ commands.push({
827
+ label: 'vulnerability alerts',
828
+ args: ['api', '-X', 'PUT', `repos/${state.nwo}/vulnerability-alerts`],
829
+ });
830
+ if (state.securityFixes)
831
+ commands.push({
832
+ label: 'automated security fixes',
833
+ args: ['api', '-X', 'PUT', `repos/${state.nwo}/automated-security-fixes`],
834
+ });
767
835
  return commands;
768
836
  }
769
837
  /**
@@ -795,12 +863,15 @@ export async function applyGithubSettings(dir, exec) {
795
863
  // (no admin) reports `ok` → treated as "nothing to apply", never a failed PUT.
796
864
  const bp = await checkBranchProtection(gh, info.nwo, info.branch);
797
865
  const wp = await checkWorkflowPermissions(gh, info.nwo);
866
+ const sec = await readSecurityUpdates(gh, info.nwo);
798
867
  const commands = buildGhApplyCommands({
799
868
  nwo: info.nwo,
800
869
  branch: info.branch,
801
870
  merge: checkMergeSettings(info).status === 'drift',
802
871
  protection: bp.status === 'optional-missing' || bp.status === 'drift',
803
872
  workflow: wp.status === 'drift',
873
+ alerts: !('skip' in sec) && !sec.alerts,
874
+ securityFixes: !('skip' in sec) && !sec.fixes,
804
875
  });
805
876
  const applied = [];
806
877
  for (const cmd of commands) {
@@ -140,14 +140,26 @@ function checkLockfile(lock) {
140
140
  detail: `.repo-tooling.json v${lock.version} (record written by ${lock.record.writtenBy})`,
141
141
  };
142
142
  }
143
+ // Checks whose absence `securityAutomation: true` turns from an optional gap
144
+ // into drift (#692): the lock says the repo chose them, so silence is a lie.
145
+ const REQUIRED_BY_SECURITY_AUTOMATION = new Set(['Dependabot', 'Security updates']);
143
146
  // Lockfile-driven demotion: if the lock records an intentional opt-out for a
144
147
  // check that's currently optional-missing, demote it to ok with a clear detail.
148
+ // The converse holds for a recorded opt-in (#692): promote it to drift.
145
149
  function demoteDeclined(results, lock) {
146
150
  if (!lock)
147
151
  return results;
148
152
  return results.map((r) => {
149
153
  if (r.status !== 'optional-missing')
150
154
  return r;
155
+ if (lock.record.config.securityAutomation === true &&
156
+ REQUIRED_BY_SECURITY_AUTOMATION.has(r.check)) {
157
+ return {
158
+ ...r,
159
+ status: 'drift',
160
+ detail: `${r.detail}, but .repo-tooling.json records securityAutomation: true`,
161
+ };
162
+ }
151
163
  if (!declinedInLock(lock, r.check))
152
164
  return r;
153
165
  return {
@@ -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
+ 'Security updates': 'github-settings',
31
32
  Milestones: 'milestones',
32
33
  CODEOWNERS: 'codeowners',
33
34
  'GitLab CI': 'gitlab-ci',
@@ -159,6 +160,7 @@ export function declinedInLock(lock, checkName) {
159
160
  case 'Merge settings':
160
161
  case 'Workflow permissions':
161
162
  case 'Code-scanning gate':
163
+ case 'Security updates':
162
164
  return c.securityAutomation === false;
163
165
  case 'publint':
164
166
  return c.publint === false;
@@ -12,6 +12,7 @@
12
12
  * and falls back to a neutral grey. Nothing about any particular org is baked
13
13
  * in; the templates are meant to be hand-edited afterwards.
14
14
  */
15
+ import { execFileSync, spawnSync } from 'node:child_process';
15
16
  import path from 'node:path';
16
17
  import fs from 'fs-extra';
17
18
  /** Grey, so an unbranded repo reads as unbranded rather than borrowing a colour. */
@@ -80,10 +81,11 @@ async function accentFromDocsTheme(targetDir) {
80
81
  return dark[1];
81
82
  return css.match(/--ifm-color-primary:\s*(#[0-9a-fA-F]{6})/)?.[1] ?? null;
82
83
  }
84
+ /** Where a repo already keeps a favicon — the docs site's first. */
85
+ const EXISTING_FAVICONS = [path.join('apps', 'docs', 'static', 'img', 'favicon.svg'), 'favicon.svg'];
83
86
  /** Failing that, the favicon's own ink — the other place a repo commits its colour. */
84
87
  async function accentFromFavicon(targetDir) {
85
- const candidates = [path.join('apps', 'docs', 'static', 'img', 'favicon.svg'), 'favicon.svg'];
86
- for (const rel of candidates) {
88
+ for (const rel of EXISTING_FAVICONS) {
87
89
  const file = path.join(targetDir, rel);
88
90
  if (!(await fs.pathExists(file)))
89
91
  continue;
@@ -123,17 +125,23 @@ export async function resolveBrandMeta(pkg, targetDir, tagline) {
123
125
  };
124
126
  }
125
127
  /**
126
- * The logo mark: a rounded square in the accent carrying the project's initial.
127
- * Authored on a 32 viewBox so it matches the favicon's geometry and can be
128
- * swapped for the real favicon glyph verbatim.
128
+ * The logo tile: a rounded square in the accent carrying the project's
129
+ * initial. It *is* `brand/favicon.svg`, and every canvas below draws that file
130
+ * rather than a copy of it, so swapping in a real glyph is a one-file edit (#678).
129
131
  */
130
- function mark(meta, translate, scale) {
132
+ export function faviconSvg(meta) {
131
133
  const initial = esc((meta.name[0] ?? '?').toUpperCase());
132
- return ` <!-- Logo mark: a 32 viewBox, so the real favicon glyph can be pasted in over it. -->
133
- <g transform="translate(${translate}) scale(${scale})">
134
- <rect width="32" height="32" rx="8" fill="${meta.accent}"/>
135
- <text x="16" y="23" text-anchor="middle" font-family="Avenir Next" font-weight="800" font-size="19" fill="${INK}">${initial}</text>
136
- </g>`;
134
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
135
+ <title>${esc(meta.name)}</title>
136
+ <rect width="32" height="32" rx="8" fill="${meta.accent}"/>
137
+ <text x="16" y="23" text-anchor="middle" font-family="Avenir Next" font-weight="800" font-size="19" fill="${INK}">${initial}</text>
138
+ </svg>
139
+ `;
140
+ }
141
+ /** The logo mark: `brand/favicon.svg`, drawn `size` px square. */
142
+ function mark(x, y, size) {
143
+ return ` <!-- Logo mark: brand/favicon.svg — edit that file to change it on every canvas. -->
144
+ <image href="favicon.svg" x="${x}" y="${y}" width="${size}" height="${size}"/>`;
137
145
  }
138
146
  /** `repo-tooling` renders as a muted `repo-` and an accented `tooling`. */
139
147
  function wordmark(meta) {
@@ -188,7 +196,7 @@ function canvas(meta, w, h, glow) {
188
196
  /** 1280×320 README banner — left-aligned lockup, install pill on the right. */
189
197
  export function bannerSvg(meta) {
190
198
  return `${canvas(meta, 1280, 320, { cx: 0.16, cy: 0 })}
191
- ${mark(meta, '60 88', 2.25)}
199
+ ${mark(60, 88, 72)}
192
200
 
193
201
  <text x="156" y="150" font-family="Avenir Next" font-weight="800" font-size="62" letter-spacing="-1.5">${wordmark(meta)}</text>
194
202
 
@@ -200,7 +208,7 @@ ${installPanel(meta, { x: 845, y: 118, w: 378, h: 84, size: 20 })}
200
208
  /** 1280×786 mobile banner — the same content stacked so it stays legible on a phone. */
201
209
  export function bannerMobileSvg(meta) {
202
210
  return `${canvas(meta, 1280, 786, { cx: 0.12, cy: 0.05 })}
203
- ${mark(meta, '565 104', 4.6875)}
211
+ ${mark(565, 104, 150)}
204
212
 
205
213
  <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>
206
214
 
@@ -212,7 +220,7 @@ ${installPanel(meta, { x: 427, y: 650, w: 426, h: 78, size: 24 })}
212
220
  /** 1280×640 Open Graph / GitHub social card. Keep content inside an ~8% safe inset. */
213
221
  export function socialCardSvg(meta) {
214
222
  return `${canvas(meta, 1280, 640, { cx: 0.1, cy: 0.05 })}
215
- ${mark(meta, '590 120', 3.125)}
223
+ ${mark(590, 120, 100)}
216
224
 
217
225
  <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>
218
226
 
@@ -229,7 +237,7 @@ ${installPanel(meta, { x: 427, y: 470, w: 426, h: 78, size: 24 })}
229
237
  export const RENDER_SH = `#!/usr/bin/env bash
230
238
  # Render the committed brand PNGs from their SVG sources.
231
239
  # Sizes come from the brand-asset spec: 1280x320 banner, 1280x786 mobile,
232
- # 1280x640 social card, 512x512 PWA icon.
240
+ # 1280x640 social card, 512x512 PWA icon. (\`fix brand\` also packs favicon.ico.)
233
241
  set -euo pipefail
234
242
  cd "$(dirname "$0")/.."
235
243
 
@@ -241,7 +249,9 @@ fi
241
249
 
242
250
  rsvg-convert -w 1280 -h 320 brand/banner.svg -o brand/banner.png
243
251
  rsvg-convert -w 1280 -h 786 brand/banner-mobile.svg -o brand/banner-mobile.png
244
- echo "rendered: brand/banner.png brand/banner-mobile.png"
252
+ rsvg-convert -w 1280 -h 640 brand/social-card.svg -o brand/social-card.png
253
+ rsvg-convert -w 512 -h 512 brand/favicon.svg -o brand/favicon-512.png
254
+ echo "rendered: brand/banner.png brand/banner-mobile.png brand/social-card.png brand/favicon-512.png"
245
255
 
246
256
  # The docs-site assets, rendered only when the site exists to hold them.
247
257
  img=apps/docs/static/img
@@ -279,15 +289,25 @@ export async function repointReadmeBanners(targetDir) {
279
289
  await fs.writeFile(file, next);
280
290
  return 'README.md';
281
291
  }
292
+ /** A favicon the repo already commits beats the generated initial tile. */
293
+ async function existingFavicon(targetDir) {
294
+ for (const rel of EXISTING_FAVICONS) {
295
+ const file = path.join(targetDir, rel);
296
+ if (await fs.pathExists(file))
297
+ return fs.readFile(file, 'utf-8');
298
+ }
299
+ return null;
300
+ }
282
301
  /**
283
- * Scaffold `brand/`: three SVG sources + the render script, then repoint a
284
- * README still on the old root-level paths. Every file is written only when
285
- * absent, so `fix brand` is idempotent.
302
+ * Scaffold `brand/`: the favicon tile, three SVG sources that draw it, and the
303
+ * render script, then repoint a README still on the old root-level paths.
304
+ * Every file is written only when absent, so `fix brand` is idempotent.
286
305
  */
287
306
  export async function generateBrand(pkg, targetDir, tagline) {
288
307
  const meta = await resolveBrandMeta(pkg, targetDir, tagline);
289
308
  const written = [];
290
309
  const files = [
310
+ ['brand/favicon.svg', (await existingFavicon(targetDir)) ?? faviconSvg(meta)],
291
311
  ['brand/banner.svg', bannerSvg(meta)],
292
312
  ['brand/banner-mobile.svg', bannerMobileSvg(meta)],
293
313
  ['brand/social-card.svg', socialCardSvg(meta)],
@@ -307,3 +327,118 @@ export async function generateBrand(pkg, targetDir, tagline) {
307
327
  written.push(readme);
308
328
  return written;
309
329
  }
330
+ /** Printed when `rsvg-convert` is not on PATH — the sources are still written. */
331
+ export const RSVG_HINT = ' next: install librsvg to render the brand PNGs (`brew install librsvg`, apt: `apt-get install librsvg2-bin`), then re-run `fix brand` or `brand/render.sh`';
332
+ /** `[source, output, width, height]` under `brand/` — the same set render.sh draws. */
333
+ const RENDERS = [
334
+ ['banner.svg', 'banner.png', 1280, 320],
335
+ ['banner-mobile.svg', 'banner-mobile.png', 1280, 786],
336
+ ['social-card.svg', 'social-card.png', 1280, 640],
337
+ ['favicon.svg', 'favicon-512.png', 512, 512],
338
+ ['favicon.svg', 'favicon.ico', 32, 32],
339
+ ];
340
+ /** Classic favicon sizes packed into favicon.ico. */
341
+ const ICO_SIZES = [16, 32];
342
+ /**
343
+ * An ICO container holding PNG frames — every browser since IE Vista reads
344
+ * PNG-in-ICO, so no bitmap conversion is needed.
345
+ */
346
+ export function packIco(frames) {
347
+ const header = Buffer.alloc(6 + 16 * frames.length);
348
+ header.writeUInt16LE(1, 2); // type: icon
349
+ header.writeUInt16LE(frames.length, 4);
350
+ let offset = header.length;
351
+ frames.forEach(([size, png], i) => {
352
+ const e = 6 + 16 * i;
353
+ header.writeUInt8(size % 256, e); // 0 means 256
354
+ header.writeUInt8(size % 256, e + 1);
355
+ header.writeUInt16LE(1, e + 4); // colour planes
356
+ header.writeUInt16LE(32, e + 6); // bits per pixel
357
+ header.writeUInt32LE(png.length, e + 8);
358
+ header.writeUInt32LE(offset, e + 12);
359
+ offset += png.length;
360
+ });
361
+ return Buffer.concat([header, ...frames.map(([, png]) => png)]);
362
+ }
363
+ async function mtime(file) {
364
+ return (await fs.stat(file)).mtimeMs;
365
+ }
366
+ /**
367
+ * Render every `brand/` PNG (and favicon.ico) that is missing or older than its
368
+ * source — or than favicon.svg, which every canvas draws. Returns the files
369
+ * written, or null when `rsvg-convert` is not on PATH (after printing
370
+ * {@link RSVG_HINT}). Nothing stale means nothing to do and no PATH lookup.
371
+ */
372
+ export async function renderBrand(targetDir) {
373
+ const brand = path.join(targetDir, 'brand');
374
+ const favicon = path.join(brand, 'favicon.svg');
375
+ const stale = [];
376
+ for (const job of RENDERS) {
377
+ const [src, out] = job;
378
+ const srcFile = path.join(brand, src);
379
+ const outFile = path.join(brand, out);
380
+ if (!(await fs.pathExists(srcFile)))
381
+ continue;
382
+ const newest = Math.max(await mtime(srcFile), (await fs.pathExists(favicon)) ? await mtime(favicon) : 0);
383
+ if (!(await fs.pathExists(outFile)) || (await mtime(outFile)) < newest)
384
+ stale.push(job);
385
+ }
386
+ if (stale.length === 0)
387
+ return [];
388
+ if (spawnSync('rsvg-convert', ['--version']).error) {
389
+ // stderr, not stdout: `fix --json` owns stdout (#357).
390
+ console.error(RSVG_HINT);
391
+ return null;
392
+ }
393
+ // cwd = brand/ so each canvas's `href="favicon.svg"` resolves beside it.
394
+ const rsvg = (src, w, h) => execFileSync('rsvg-convert', ['-w', String(w), '-h', String(h), src], { cwd: brand });
395
+ const written = [];
396
+ for (const [src, out, w, h] of stale) {
397
+ const png = out.endsWith('.ico')
398
+ ? packIco(ICO_SIZES.map((s) => [s, rsvg(src, s, s)]))
399
+ : rsvg(src, w, h);
400
+ await fs.writeFile(path.join(brand, out), png);
401
+ written.push(`brand/${out}`);
402
+ }
403
+ return written;
404
+ }
405
+ export const BANNER_START = '<!-- js-tooling:banner:start -->';
406
+ export const BANNER_END = '<!-- js-tooling:banner:end -->';
407
+ /** The README `<picture>` banner, mobile variant under 640px, as a delimited block. */
408
+ export function buildBannerBlock(name) {
409
+ return `${BANNER_START}
410
+ <picture>
411
+ <source media="(max-width: 640px)" srcset="./brand/banner-mobile.png">
412
+ <img src="./brand/banner.png" alt="${esc(name)} banner" width="1600">
413
+ </picture>
414
+ ${BANNER_END}`;
415
+ }
416
+ /**
417
+ * Put the banner block at the top of a README. Refreshes an existing block in
418
+ * place; leaves alone a README that already shows a banner outside one (a
419
+ * hand-written `<picture>`); otherwise prepends. Idempotent.
420
+ */
421
+ export function upsertBanner(readme, block) {
422
+ const start = readme.indexOf(BANNER_START);
423
+ const end = readme.indexOf(BANNER_END);
424
+ if (start !== -1 && end > start) {
425
+ return readme.slice(0, start) + block + readme.slice(end + BANNER_END.length);
426
+ }
427
+ if (/banner(?:-mobile)?\.png/.test(readme))
428
+ return readme;
429
+ return `${block}\n\n${readme}`;
430
+ }
431
+ /** Add the banner block to README.md once `brand/banner.png` exists to show. */
432
+ export async function addReadmeBanner(targetDir, name) {
433
+ const file = path.join(targetDir, 'README.md');
434
+ if (!(await fs.pathExists(file)))
435
+ return null;
436
+ if (!(await fs.pathExists(path.join(targetDir, 'brand', 'banner.png'))))
437
+ return null;
438
+ const readme = await fs.readFile(file, 'utf-8');
439
+ const next = upsertBanner(readme, buildBannerBlock(name));
440
+ if (next === readme)
441
+ return null;
442
+ await fs.writeFile(file, next);
443
+ return 'README.md';
444
+ }
@@ -29,6 +29,9 @@ const SELF_RANGE = `^${selfPackageJson.version}`;
29
29
  const DOCS_APP = 'apps/docs';
30
30
  /** Docusaurus's neutral green — the default accent, meant to be branded over. */
31
31
  const DEFAULT_ACCENT = { light: '#2e8555', dark: '#25c2a0' };
32
+ // One range for every @docusaurus/* package; TypeScript tracks repo-tooling's own devDependency (tested).
33
+ export const DOCUSAURUS_RANGE = '^3.10.2';
34
+ export const TYPESCRIPT_RANGE = '~7.0.2';
32
35
  /**
33
36
  * Source modules to document with TypeDoc: single-segment subpath exports
34
37
  * (`./errors` → `errors`), which map to `src/<id>/index.ts`. Multi-segment
@@ -288,8 +291,8 @@ function docsPackageJson(meta, typedoc) {
288
291
  typecheck: 'tsc --noEmit',
289
292
  },
290
293
  dependencies: {
291
- '@docusaurus/core': '^3.10.2',
292
- '@docusaurus/preset-classic': '^3.8.1',
294
+ '@docusaurus/core': DOCUSAURUS_RANGE,
295
+ '@docusaurus/preset-classic': DOCUSAURUS_RANGE,
293
296
  '@easyops-cn/docusaurus-search-local': '^0.55.2',
294
297
  '@mdx-js/react': '^3.1.0',
295
298
  clsx: '^2.1.1',
@@ -298,12 +301,12 @@ function docsPackageJson(meta, typedoc) {
298
301
  'react-dom': '^19.0.0',
299
302
  },
300
303
  devDependencies: {
301
- '@docusaurus/module-type-aliases': '^3.10.2',
302
- '@docusaurus/tsconfig': '^3.8.1',
303
- '@docusaurus/types': '^3.10.2',
304
+ '@docusaurus/module-type-aliases': DOCUSAURUS_RANGE,
305
+ '@docusaurus/tsconfig': DOCUSAURUS_RANGE,
306
+ '@docusaurus/types': DOCUSAURUS_RANGE,
304
307
  '@rtorcato/repo-tooling': SELF_RANGE,
305
308
  '@types/react': '^19.0.0',
306
- typescript: '~5.6.3',
309
+ typescript: TYPESCRIPT_RANGE,
307
310
  ...typedocDevDeps,
308
311
  },
309
312
  browserslist: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "4.3.0",
3
+ "version": "4.4.0",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [