@rtorcato/repo-tooling 4.1.0 → 4.2.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.
@@ -75,6 +75,7 @@ export const BASE_FIXERS = [
75
75
  },
76
76
  {
77
77
  target: 'editorconfig',
78
+ selfSafe: true,
78
79
  description: 'Scaffold .editorconfig (UTF-8, LF, tab indent)',
79
80
  appliesTo: ['EditorConfig'],
80
81
  outputs: ['.editorconfig'],
@@ -145,6 +146,7 @@ export const BASE_FIXERS = [
145
146
  },
146
147
  {
147
148
  target: 'codeql',
149
+ selfSafe: true,
148
150
  description: 'Scaffold .github/workflows/codeql.yml (security scanning)',
149
151
  appliesTo: ['CodeQL'],
150
152
  outputs: ['.github/workflows/codeql.yml'],
@@ -214,6 +216,7 @@ export const BASE_FIXERS = [
214
216
  },
215
217
  {
216
218
  target: 'codeowners',
219
+ selfSafe: true,
217
220
  description: 'Scaffold .github/CODEOWNERS with commented examples',
218
221
  appliesTo: ['CODEOWNERS'],
219
222
  outputs: ['.github/CODEOWNERS'],
@@ -226,6 +229,7 @@ export const BASE_FIXERS = [
226
229
  },
227
230
  {
228
231
  target: 'community-health',
232
+ selfSafe: true,
229
233
  description: 'Scaffold CONTRIBUTING.md, SECURITY.md, PR + issue templates',
230
234
  appliesTo: ['Community health'],
231
235
  outputs: [
@@ -244,6 +248,7 @@ export const BASE_FIXERS = [
244
248
  },
245
249
  {
246
250
  target: 'brand',
251
+ selfSafe: true,
247
252
  description: 'Scaffold brand/ — banner, mobile-banner and social-card SVG sources + render.sh, and repoint a README still on root-level banner paths',
248
253
  appliesTo: ['Brand assets'],
249
254
  outputs: [
@@ -86,9 +86,16 @@ async function ensureWorkspace(targetDir) {
86
86
  await fs.writeFile(file, next);
87
87
  return rel;
88
88
  }
89
- /** The GitHub Pages base path Docusaurus serves under, e.g. `/repo-tooling/`. */
90
- function siteBaseUrl(meta) {
91
- return `/${meta.repo ?? meta.title}/`;
89
+ /**
90
+ * A JS string literal in the Biome preset's quote style: single quotes, unless
91
+ * the value holds more single than double quotes — the same pick Biome makes.
92
+ */
93
+ function jsString(value) {
94
+ const singles = value.split("'").length - 1;
95
+ const doubles = value.split('"').length - 1;
96
+ if (singles > doubles)
97
+ return JSON.stringify(value);
98
+ return `'${JSON.stringify(value).slice(1, -1).replaceAll('\\"', '"').replaceAll("'", "\\'")}'`;
92
99
  }
93
100
  function docusaurusConfig(meta, typedocModules) {
94
101
  const owner = meta.owner ?? 'your-org';
@@ -100,111 +107,111 @@ function docusaurusConfig(meta, typedocModules) {
100
107
  ? "import { getTypedocPlugins } from '@rtorcato/repo-tooling/docusaurus'\n"
101
108
  : '';
102
109
  const typedocPlugins = typedocModules.length
103
- ? ` ...getTypedocPlugins(${JSON.stringify(typedocModules)}),\n`
110
+ ? `\t\t...getTypedocPlugins([${typedocModules.map(jsString).join(', ')}]),\n`
104
111
  : '';
105
112
  return `import type * as Preset from '@docusaurus/preset-classic'
106
113
  import type { Config } from '@docusaurus/types'
107
114
  import { themes as prismThemes } from 'prism-react-renderer'
108
115
  ${typedocImport}
109
116
  const config: Config = {
110
- title: '${meta.title}',
111
- tagline: ${JSON.stringify(meta.tagline)},
112
- favicon: 'img/favicon.ico',
117
+ \ttitle: '${meta.title}',
118
+ \ttagline: ${jsString(meta.tagline)},
119
+ \tfavicon: 'img/favicon.ico',
113
120
 
114
- url: 'https://${owner}.github.io',
115
- baseUrl: '/${repo}/',
121
+ \turl: 'https://${owner}.github.io',
122
+ \tbaseUrl: '/${repo}/',
116
123
 
117
- organizationName: '${owner}',
118
- projectName: '${repo}',
124
+ \torganizationName: '${owner}',
125
+ \tprojectName: '${repo}',
119
126
 
120
- onBrokenLinks: 'warn',
127
+ \tonBrokenLinks: 'warn',
121
128
 
122
- markdown: {
123
- format: 'detect',
124
- hooks: {
125
- onBrokenMarkdownLinks: 'warn',
126
- },
127
- },
129
+ \tmarkdown: {
130
+ \t\tformat: 'detect',
131
+ \t\thooks: {
132
+ \t\t\tonBrokenMarkdownLinks: 'warn',
133
+ \t\t},
134
+ \t},
128
135
 
129
- i18n: {
130
- defaultLocale: 'en',
131
- locales: ['en'],
132
- },
136
+ \ti18n: {
137
+ \t\tdefaultLocale: 'en',
138
+ \t\tlocales: ['en'],
139
+ \t},
133
140
 
134
- presets: [
135
- [
136
- 'classic',
137
- {
138
- docs: {
139
- sidebarPath: './sidebars.ts',
140
- routeBasePath: '/docs',
141
- editUrl: '${ghUrl}/edit/main/apps/docs/',
142
- },
143
- blog: false,
144
- theme: {
145
- customCss: './src/css/custom.css',
146
- },
147
- } satisfies Preset.Options,
148
- ],
149
- ],
141
+ \tpresets: [
142
+ \t\t[
143
+ \t\t\t'classic',
144
+ \t\t\t{
145
+ \t\t\t\tdocs: {
146
+ \t\t\t\t\tsidebarPath: './sidebars.ts',
147
+ \t\t\t\t\trouteBasePath: '/docs',
148
+ \t\t\t\t\teditUrl: '${ghUrl}/edit/main/apps/docs/',
149
+ \t\t\t\t},
150
+ \t\t\t\tblog: false,
151
+ \t\t\t\ttheme: {
152
+ \t\t\t\t\tcustomCss: './src/css/custom.css',
153
+ \t\t\t\t},
154
+ \t\t\t} satisfies Preset.Options,
155
+ \t\t],
156
+ \t],
150
157
 
151
- plugins: [
152
- ${typedocPlugins} [
153
- '@easyops-cn/docusaurus-search-local',
154
- {
155
- hashed: true,
156
- indexDocs: true,
157
- indexBlog: false,
158
- docsRouteBasePath: '/docs',
159
- highlightSearchTermsOnTargetPage: true,
160
- searchBarShortcutHint: false,
161
- },
162
- ],
163
- ],
158
+ \tplugins: [
159
+ ${typedocPlugins}\t\t[
160
+ \t\t\t'@easyops-cn/docusaurus-search-local',
161
+ \t\t\t{
162
+ \t\t\t\thashed: true,
163
+ \t\t\t\tindexDocs: true,
164
+ \t\t\t\tindexBlog: false,
165
+ \t\t\t\tdocsRouteBasePath: '/docs',
166
+ \t\t\t\thighlightSearchTermsOnTargetPage: true,
167
+ \t\t\t\tsearchBarShortcutHint: false,
168
+ \t\t\t},
169
+ \t\t],
170
+ \t],
164
171
 
165
- themeConfig: {
166
- colorMode: {
167
- defaultMode: 'dark',
168
- respectPrefersColorScheme: true,
169
- },
170
- navbar: {
171
- title: '${meta.title}',
172
- items: [
173
- { to: '/docs', position: 'left', label: 'Docs' },
174
- {
175
- href: '${ghUrl}',
176
- label: 'GitHub',
177
- position: 'right',
178
- },
179
- ],
180
- },
181
- footer: {
182
- style: 'dark',
183
- links: [
184
- {
185
- title: 'Docs',
186
- items: [{ label: 'Getting Started', to: '/docs' }],
187
- },
188
- {
189
- title: 'More',
190
- items: [
191
- { label: 'GitHub', href: '${ghUrl}' },
192
- { label: 'Issues', href: '${ghUrl}/issues' },
193
- ],
194
- },
195
- ],
196
- copyright: \`Copyright © \${new Date().getFullYear()} ${meta.title}. Built with Docusaurus.\`,
197
- },
198
- // \`theme\` is the LIGHT-mode Prism theme and \`darkTheme\` the dark one. Both
199
- // were vsDark here, which is why the shared stylesheet had to pin fenced
200
- // blocks dark in light mode too (#324). Keep this pairing and the CSS in
201
- // step — vsDark tokens on a light surface are unreadable.
202
- prism: {
203
- theme: prismThemes.vsLight,
204
- darkTheme: prismThemes.vsDark,
205
- additionalLanguages: ['bash', 'json', 'typescript'],
206
- },
207
- } satisfies Preset.ThemeConfig,
172
+ \tthemeConfig: {
173
+ \t\tcolorMode: {
174
+ \t\t\tdefaultMode: 'dark',
175
+ \t\t\trespectPrefersColorScheme: true,
176
+ \t\t},
177
+ \t\tnavbar: {
178
+ \t\t\ttitle: '${meta.title}',
179
+ \t\t\titems: [
180
+ \t\t\t\t{ to: '/docs', position: 'left', label: 'Docs' },
181
+ \t\t\t\t{
182
+ \t\t\t\t\thref: '${ghUrl}',
183
+ \t\t\t\t\tlabel: 'GitHub',
184
+ \t\t\t\t\tposition: 'right',
185
+ \t\t\t\t},
186
+ \t\t\t],
187
+ \t\t},
188
+ \t\tfooter: {
189
+ \t\t\tstyle: 'dark',
190
+ \t\t\tlinks: [
191
+ \t\t\t\t{
192
+ \t\t\t\t\ttitle: 'Docs',
193
+ \t\t\t\t\titems: [{ label: 'Getting Started', to: '/docs' }],
194
+ \t\t\t\t},
195
+ \t\t\t\t{
196
+ \t\t\t\t\ttitle: 'More',
197
+ \t\t\t\t\titems: [
198
+ \t\t\t\t\t\t{ label: 'GitHub', href: '${ghUrl}' },
199
+ \t\t\t\t\t\t{ label: 'Issues', href: '${ghUrl}/issues' },
200
+ \t\t\t\t\t],
201
+ \t\t\t\t},
202
+ \t\t\t],
203
+ \t\t\tcopyright: \`Copyright © \${new Date().getFullYear()} ${meta.title}. Built with Docusaurus.\`,
204
+ \t\t},
205
+ \t\t// \`theme\` is the LIGHT-mode Prism theme and \`darkTheme\` the dark one. Both
206
+ \t\t// were vsDark here, which is why the shared stylesheet had to pin fenced
207
+ \t\t// blocks dark in light mode too (#324). Keep this pairing and the CSS in
208
+ \t\t// step — vsDark tokens on a light surface are unreadable.
209
+ \t\tprism: {
210
+ \t\t\ttheme: prismThemes.vsLight,
211
+ \t\t\tdarkTheme: prismThemes.vsDark,
212
+ \t\t\tadditionalLanguages: ['bash', 'json', 'typescript'],
213
+ \t\t},
214
+ \t} satisfies Preset.ThemeConfig,
208
215
  }
209
216
 
210
217
  export default config
@@ -215,7 +222,7 @@ const SIDEBARS = `import type { SidebarsConfig } from '@docusaurus/plugin-conten
215
222
  // Autogenerated from the docs/ folder structure — add markdown files and they
216
223
  // appear here. Swap for an explicit list when you want to control ordering.
217
224
  const sidebars: SidebarsConfig = {
218
- docs: [{ type: 'autogenerated', dirName: '.' }],
225
+ \tdocs: [{ type: 'autogenerated', dirName: '.' }],
219
226
  }
220
227
 
221
228
  export default sidebars
@@ -246,13 +253,13 @@ function customCss(accent) {
246
253
  @import "./_jt-tokens.css";
247
254
 
248
255
  :root {
249
- --ifm-color-primary: ${accent.light};
250
- --jt-accent: ${accent.light};
256
+ \t--ifm-color-primary: ${accent.light};
257
+ \t--jt-accent: ${accent.light};
251
258
  }
252
259
 
253
260
  [data-theme="dark"] {
254
- --ifm-color-primary: ${accent.dark};
255
- --jt-accent: ${accent.dark};
261
+ \t--ifm-color-primary: ${accent.dark};
262
+ \t--jt-accent: ${accent.dark};
256
263
  }
257
264
  `;
258
265
  }
@@ -279,9 +286,6 @@ function docsPackageJson(meta, typedoc) {
279
286
  serve: 'docusaurus serve',
280
287
  clear: 'docusaurus clear',
281
288
  typecheck: 'tsc --noEmit',
282
- // Opt-in smoke test — builds, serves, and checks the site renders. Heavy
283
- // browser install, so it's a manual/CI-gated run, not part of `build`.
284
- 'test:e2e': 'playwright test',
285
289
  },
286
290
  dependencies: {
287
291
  '@docusaurus/core': '^3.10.2',
@@ -297,7 +301,6 @@ function docsPackageJson(meta, typedoc) {
297
301
  '@docusaurus/module-type-aliases': '^3.10.2',
298
302
  '@docusaurus/tsconfig': '^3.8.1',
299
303
  '@docusaurus/types': '^3.10.2',
300
- '@playwright/test': '^1.49.0',
301
304
  '@rtorcato/repo-tooling': SELF_RANGE,
302
305
  '@types/react': '^19.0.0',
303
306
  typescript: '~5.6.3',
@@ -354,34 +357,6 @@ jobs:
354
357
  build-filter: '${meta.docsPkgName}'
355
358
  `;
356
359
  }
357
- /**
358
- * Playwright config for the docs smoke test. Reuses the shipped preset, then
359
- * builds + serves the site on :3000 and points the base URL at the site's
360
- * GitHub Pages base path so routes resolve exactly as in production. One
361
- * browser keeps the CI browser install light.
362
- */
363
- function playwrightConfig(meta) {
364
- const url = `http://localhost:3000${siteBaseUrl(meta)}`;
365
- return `import { defineConfig, devices } from '@playwright/test'
366
- import base from '@rtorcato/repo-tooling/playwright'
367
-
368
- export default defineConfig({
369
- ...base,
370
- testDir: './tests',
371
- projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
372
- use: {
373
- ...base.use,
374
- baseURL: process.env.PLAYWRIGHT_BASE_URL ?? '${url}',
375
- },
376
- webServer: {
377
- command: 'pnpm run build && pnpm exec docusaurus serve --port 3000',
378
- url: process.env.PLAYWRIGHT_BASE_URL ?? '${url}',
379
- reuseExistingServer: !process.env.CI,
380
- timeout: 180_000,
381
- },
382
- })
383
- `;
384
- }
385
360
  // routeBasePath is '/docs', so the site root has no page of its own and the
386
361
  // navbar logo links to a 404 on every page (#664). Redirect it to the docs.
387
362
  // Tabs/no semicolons to match the Biome preset the consuming repo is linted with.
@@ -392,21 +367,6 @@ export default function Home() {
392
367
  \treturn <Redirect to={useBaseUrl('/docs')} />
393
368
  }
394
369
  `;
395
- const SMOKE_SPEC = `import { expect, test } from '@playwright/test'
396
-
397
- // Smoke test: assert the built site serves and its core UI renders. Deliberately
398
- // content-agnostic — it validates "the site builds and boots", not copy.
399
- test('homepage responds and renders the shell', async ({ page }) => {
400
- const res = await page.goto('./')
401
- expect(res?.ok()).toBeTruthy()
402
- await expect(page.locator('.navbar')).toBeVisible()
403
- })
404
-
405
- test('the starter doc renders a heading', async ({ page }) => {
406
- await page.goto('./')
407
- await expect(page.locator('h1')).toBeVisible()
408
- })
409
- `;
410
370
  /**
411
371
  * Scaffold the Docusaurus docs site. Writes each file only when missing and
412
372
  * returns the relative paths actually written, so `fix docs-site` is safe to
@@ -440,8 +400,6 @@ export async function generateDocsSite(pkg, targetDir, options = {}) {
440
400
  [`${DOCS_APP}/src/css/custom.css`, customCss(accent)],
441
401
  [`${DOCS_APP}/src/pages/index.tsx`, HOME_PAGE],
442
402
  [`${DOCS_APP}/docs/intro.md`, introDoc(meta, badges)],
443
- [`${DOCS_APP}/playwright.config.ts`, playwrightConfig(meta)],
444
- [`${DOCS_APP}/tests/smoke.spec.ts`, SMOKE_SPEC],
445
403
  ['.github/workflows/docs.yml', docsWorkflow(meta)],
446
404
  ];
447
405
  // TypeDoc emits docs/api/<id> on build — keep the generated tree out of git.
package/dist/cli/index.js CHANGED
@@ -1,22 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import path from 'node:path';
3
2
  import chalk from 'chalk';
4
3
  import { Command } from 'commander';
5
- import fs from 'fs-extra';
6
4
  import { doctorCommand } from './commands/doctor.js';
7
5
  import { fixCommand } from './commands/fix.js';
8
6
  import { setupProject } from './commands/setup.js';
7
+ import { selfRepoRefusal } from './self-repo.js';
9
8
  import { copyPreset, PRESETS } from './utils/copy-preset.js';
10
9
  import { getToolVersion } from './utils/version.js';
11
- async function isSelfRepo(dir) {
12
- try {
13
- const pkg = await fs.readJson(path.join(dir, 'package.json'));
14
- return pkg.name === '@rtorcato/repo-tooling';
15
- }
16
- catch {
17
- return false;
18
- }
19
- }
20
10
  const program = new Command();
21
11
  program
22
12
  .name('@rtorcato/repo-tooling')
@@ -351,29 +341,11 @@ program
351
341
  process.exitCode = 1;
352
342
  });
353
343
  program.hook('preAction', async (_, actionCommand) => {
354
- const name = actionCommand.name();
355
- if (name === 'setup' || name === 'doctor' || name === 'fix') {
356
- // `fix --list` is read-only and safe to run anywhere, including this repo.
357
- if (name === 'fix' && actionCommand.opts().list)
358
- return;
359
- // Dogfood escape hatch (#273): allow read-only `doctor` against this repo
360
- // so CI can audit our own config the same way it does consumers'. Scoped
361
- // to doctor — the mutating setup/fix stay blocked even with the flag set.
362
- if (name === 'doctor' && process.env.REPO_TOOLING_ALLOW_SELF === '1')
363
- return;
364
- // One mutating exception (#531): `fix lockfile` writes only
365
- // .repo-tooling.json — no scaffolding — so our own lockfile can be
366
- // migrated by the fixer we ship instead of by hand.
367
- if (name === 'fix' &&
368
- actionCommand.args[0] === 'lockfile' &&
369
- process.env.REPO_TOOLING_ALLOW_SELF === '1')
370
- return;
371
- const dir = actionCommand.opts().directory ?? process.cwd();
372
- if (await isSelfRepo(dir)) {
373
- console.log(chalk.yellow('\n⚠️ This command cannot be run inside the @rtorcato/repo-tooling repo itself.\n'));
374
- console.log(chalk.gray(' setup and doctor are for consumer projects, not for the tooling repo.\n'));
375
- process.exit(0);
376
- }
344
+ const refusal = await selfRepoRefusal(actionCommand.name(), actionCommand.args[0], actionCommand.opts());
345
+ if (refusal) {
346
+ console.log(chalk.yellow('\n⚠️ This command cannot be run inside the @rtorcato/repo-tooling repo itself.\n'));
347
+ console.log(chalk.gray(` ${refusal}\n`));
348
+ process.exit(0);
377
349
  }
378
350
  });
379
351
  // Handle unknown commands
@@ -0,0 +1,47 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { getFixers } from './commands/fix.js';
4
+ export async function isSelfRepo(dir) {
5
+ try {
6
+ const pkg = await fs.readJson(path.join(dir, 'package.json'));
7
+ return pkg.name === '@rtorcato/repo-tooling';
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ /**
14
+ * Why `setup` / `doctor` / `fix` must not run in `dir`, or null when it may.
15
+ * Most fixers write configs that import `@rtorcato/repo-tooling/...`, which
16
+ * this repo can't depend on, so inside it everything is refused unless
17
+ * `REPO_TOOLING_ALLOW_SELF=1` is set. With the flag, read-only `doctor` (#273)
18
+ * and `fix <target>` for a `selfSafe` target (#673) get through. A bare `fix`
19
+ * stays refused even then: it walks every fixer, self-safe or not.
20
+ */
21
+ export async function selfRepoRefusal(command, target, opts, env = process.env) {
22
+ if (command !== 'setup' && command !== 'doctor' && command !== 'fix')
23
+ return null;
24
+ // `fix --list` is read-only and safe to run anywhere, including this repo.
25
+ if (command === 'fix' && opts.list)
26
+ return null;
27
+ if (!(await isSelfRepo(opts.directory ?? process.cwd())))
28
+ return null;
29
+ const allowSelf = env.REPO_TOOLING_ALLOW_SELF === '1';
30
+ if (allowSelf && command === 'doctor')
31
+ return null;
32
+ if (allowSelf && command === 'fix') {
33
+ if (!target) {
34
+ const safe = getFixers()
35
+ .filter((f) => f.selfSafe)
36
+ .map((f) => f.target);
37
+ return `a bare \`fix\` runs every fixer, including ones whose output imports @rtorcato/repo-tooling — name a self-safe target instead: ${safe.join(', ')}.`;
38
+ }
39
+ const fixer = getFixers().find((f) => f.target.toLowerCase() === target.toLowerCase());
40
+ if (fixer?.selfSafe)
41
+ return null;
42
+ return fixer
43
+ ? `\`fix ${target}\` is not self-safe: its output imports or depends on @rtorcato/repo-tooling, which this repo cannot depend on.`
44
+ : `\`fix ${target}\` is not a known self-safe target.`;
45
+ }
46
+ return 'setup and doctor are for consumer projects, not for the tooling repo.';
47
+ }
@@ -546,6 +546,7 @@ export const FIXERS = [
546
546
  },
547
547
  {
548
548
  target: 'vscode-extensions',
549
+ selfSafe: true,
549
550
  description: 'Recommend the VS Code extensions matching the enabled tools (.vscode/extensions.json)',
550
551
  appliesTo: ['VS Code extensions'],
551
552
  outputs: ['.vscode/extensions.json'],
@@ -559,6 +560,7 @@ export const FIXERS = [
559
560
  },
560
561
  {
561
562
  target: 'nvmrc',
563
+ selfSafe: true,
562
564
  description: 'Scaffold .nvmrc pinned to Node 22',
563
565
  appliesTo: ['Node version pin'],
564
566
  outputs: ['.nvmrc'],
@@ -892,6 +894,7 @@ export const FIXERS = [
892
894
  },
893
895
  {
894
896
  target: 'lockfile',
897
+ selfSafe: true,
895
898
  description: `Scaffold ${LOCKFILE_NAME} recording current tool choices`,
896
899
  appliesTo: ['lockfile'],
897
900
  outputs: [LOCKFILE_NAME],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "4.1.0",
3
+ "version": "4.2.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": [