@rtorcato/repo-tooling 4.6.0 → 4.8.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.
@@ -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 { addReadmeBanner, generateBrand, renderBrand, resolveBrandMeta, } from '../cli/generators/brand.js';
18
+ import { addReadmeBanner, generateBrand, renderBrand, resolveBrandMeta, syncBrandToDocs, } 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';
@@ -274,6 +274,7 @@ export const BASE_FIXERS = [
274
274
  const tagline = lock?.rules?.brand?.tagline;
275
275
  const filesWritten = await generateBrand(pkg, targetDir, tagline);
276
276
  filesWritten.push(...((await renderBrand(targetDir)) ?? []));
277
+ filesWritten.push(...(await syncBrandToDocs(targetDir)));
277
278
  const { name } = await resolveBrandMeta(pkg, targetDir, tagline);
278
279
  const readme = await addReadmeBanner(targetDir, name);
279
280
  if (readme && !filesWritten.includes(readme))
@@ -0,0 +1,126 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { jobEnvironment, realGhExec, workflowJobs } from './github-settings.js';
4
+ const WHERE_TO_GET = {
5
+ CODECOV_TOKEN: 'codecov.io -> your repo -> Settings -> Repository Upload Token',
6
+ RELEASE_TOKEN: 'a fine-grained PAT with contents: write (or a GitHub App token) that can push past branch protection',
7
+ };
8
+ const withoutComments = (s) => s
9
+ .split('\n')
10
+ .filter((l) => !l.trimStart().startsWith('#'))
11
+ .join('\n');
12
+ function usesIn(text, file, job, environment) {
13
+ const clean = withoutComments(text);
14
+ const found = new Map();
15
+ for (const m of clean.matchAll(/secrets\.([A-Za-z_][A-Za-z0-9_]*)/g)) {
16
+ const name = m[1];
17
+ if (name === 'GITHUB_TOKEN' || found.has(name))
18
+ continue;
19
+ const fallback = new RegExp(`secrets\\.${name}\\s*\\|\\|\\s*secrets\\.GITHUB_TOKEN`).test(clean);
20
+ found.set(name, { name, file, job, environment, fallback });
21
+ }
22
+ return [...found.values()];
23
+ }
24
+ /** Every non-GITHUB_TOKEN secret the workflows (and a composite action.yml) reference. */
25
+ export async function collectSecretUses(dir) {
26
+ const uses = [];
27
+ const wfDir = path.join(dir, '.github', 'workflows');
28
+ try {
29
+ const files = (await fs.pathExists(wfDir)) ? (await fs.readdir(wfDir)).sort() : [];
30
+ for (const f of files) {
31
+ if (!/\.ya?ml$/.test(f))
32
+ continue;
33
+ const content = await fs.readFile(path.join(wfDir, f), 'utf-8');
34
+ for (const [job, body] of workflowJobs(content)) {
35
+ uses.push(...usesIn(body, f, job, jobEnvironment(withoutComments(body))));
36
+ }
37
+ }
38
+ for (const a of ['action.yml', 'action.yaml']) {
39
+ const p = path.join(dir, a);
40
+ if (await fs.pathExists(p))
41
+ uses.push(...usesIn(await fs.readFile(p, 'utf-8'), a, '-', null));
42
+ }
43
+ }
44
+ catch {
45
+ return [];
46
+ }
47
+ return uses;
48
+ }
49
+ /** One entry per secret name, for setup's Next Steps. */
50
+ export function requiredSecrets(uses) {
51
+ const byName = new Map();
52
+ for (const u of uses) {
53
+ const r = byName.get(u.name) ?? {
54
+ name: u.name,
55
+ usedBy: [],
56
+ whereToGet: WHERE_TO_GET[u.name] ?? null,
57
+ fallback: true,
58
+ };
59
+ if (!r.usedBy.includes(u.file))
60
+ r.usedBy.push(u.file);
61
+ r.fallback &&= u.fallback;
62
+ byName.set(u.name, r);
63
+ }
64
+ return [...byName.values()];
65
+ }
66
+ /** `gh secret set` lines plus where-to-get, for every secret in the list. */
67
+ export function secretHints(secrets) {
68
+ return secrets.map((s) => `gh secret set ${s.name}${s.whereToGet ? ` # ${s.whereToGet}` : ''}`);
69
+ }
70
+ async function listNames(gh, endpoint) {
71
+ const r = await gh(['api', `${endpoint}?per_page=100`]);
72
+ if (!r.ok)
73
+ return null;
74
+ try {
75
+ const d = JSON.parse(r.stdout);
76
+ return new Set((d.secrets ?? []).flatMap((s) => (s.name ? [s.name] : [])));
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ const CHECK = 'Repository secrets';
83
+ const skip = (why) => ({
84
+ check: CHECK,
85
+ status: 'ok',
86
+ detail: `not checked (${why})`,
87
+ });
88
+ export async function checkRepositorySecrets(dir, exec) {
89
+ if (!(await fs.pathExists(path.join(dir, '.git'))))
90
+ return skip('not a git repository');
91
+ const uses = await collectSecretUses(dir);
92
+ if (uses.length === 0)
93
+ return { check: CHECK, status: 'ok', detail: 'no secrets referenced' };
94
+ const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
95
+ const repo = await listNames(gh, 'repos/{owner}/{repo}/actions/secrets');
96
+ if (!repo)
97
+ return skip("can't list secrets — needs gh with admin access");
98
+ // Best effort: an org scope that errors just contributes nothing.
99
+ const org = (await listNames(gh, 'repos/{owner}/{repo}/actions/organization-secrets')) ?? new Set();
100
+ const envs = new Map();
101
+ for (const e of new Set(uses.flatMap((u) => (u.environment ? [u.environment] : [])))) {
102
+ envs.set(e, await listNames(gh, `repos/{owner}/{repo}/environments/${e}/secrets`));
103
+ }
104
+ const missing = uses.filter((u) => {
105
+ if (repo.has(u.name) || org.has(u.name))
106
+ return false;
107
+ if (!u.environment)
108
+ return true;
109
+ const env = envs.get(u.environment);
110
+ // An unreadable environment can't prove absence.
111
+ return env ? !env.has(u.name) : false;
112
+ });
113
+ if (missing.length === 0) {
114
+ return { check: CHECK, status: 'ok', detail: 'all referenced secrets are set' };
115
+ }
116
+ const hard = missing.some((u) => !u.fallback);
117
+ const names = missing
118
+ .map((u) => `${u.name} (${u.file})${u.fallback ? ' [falls back to GITHUB_TOKEN]' : ''}`)
119
+ .join(', ');
120
+ return {
121
+ check: CHECK,
122
+ status: hard ? 'missing' : 'drift',
123
+ detail: `not set: ${names}. Dependabot-triggered runs read Dependabot secrets, not these`,
124
+ hint: secretHints(requiredSecrets(missing)).join('\n'),
125
+ };
126
+ }
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import chalk from 'chalk';
3
3
  import fs from 'fs-extra';
4
4
  import { renderGitHubWorkflow } from '../../base/ci.js';
5
- import { githubJobs, scriptsOf } from '../../languages/js/ci.js';
5
+ import { githubJobs, hasBin, scriptsOf } from '../../languages/js/ci.js';
6
6
  import { inferProjectConfig } from '../../languages/js/fixers.js';
7
7
  import { checkNpmTrustedPublisher, checkPublishJob, findNpmPublishJob, NPM_OIDC_CHECK, } from '../../languages/js/npm-trust.js';
8
8
  import { PERL_GIT_HOOKS, runPerlChecks } from '../../languages/perl/checks.js';
@@ -14,6 +14,7 @@ import { SWIFT_GIT_HOOKS, runSwiftChecks } from '../../languages/swift/checks.js
14
14
  import { readSwiftPackage, renderSwiftWorkflow } from '../../languages/swift/ci.js';
15
15
  import { detectAuditLanguage } from '../utils/detect-language.js';
16
16
  import { checkGitHubSettings } from '../../base/github-settings.js';
17
+ import { checkRepositorySecrets } from '../../base/secrets.js';
17
18
  import { checkMilestones } from '../../base/milestones.js';
18
19
  import { checkGitIdentity, checkGitIdentityHistory } from '../../base/git-identity.js';
19
20
  import { checkCopiedAssets } from '../utils/copied-assets.js';
@@ -211,6 +212,7 @@ async function runBaseChecks(dir, lock, opts) {
211
212
  // GitHub repo-settings drift (branch protection, merge settings, workflow
212
213
  // permissions). Read-only; self-skips as `ok` outside a live GitHub repo.
213
214
  results.push(...(await checkGitHubSettings(dir)));
215
+ results.push(await checkRepositorySecrets(dir));
214
216
  // Milestone hygiene (#397) — same seam, same self-skip.
215
217
  results.push(await checkMilestones(dir));
216
218
  results.push(await checkGitLabCI(dir));
@@ -391,7 +393,7 @@ export async function runDoctor(dir) {
391
393
  },
392
394
  // Same `scripts` gating the fixer applies, or the workflow doctor
393
395
  // compares against would reference steps the fixer never writes (#364).
394
- presetWorkflow: renderGitHubWorkflow(githubJobs(inferProjectConfig(pkg), { scripts: scriptsOf(pkg) })),
396
+ presetWorkflow: renderGitHubWorkflow(githubJobs(inferProjectConfig(pkg), { scripts: scriptsOf(pkg), bin: hasBin(pkg) })),
395
397
  language,
396
398
  codeqlLanguages: languageModule.codeqlLanguages,
397
399
  })));
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import chalk from 'chalk';
3
3
  import fs from 'fs-extra';
4
4
  import inquirer from 'inquirer';
5
+ import { collectSecretUses, requiredSecrets, secretHints } from '../../base/secrets.js';
5
6
  import { formatNpmPublishGuide, npmPublishGuide, } from '../../languages/js/npm-trust.js';
6
7
  import { LANGUAGES } from '../../languages/registry.js';
7
8
  import { generateConfigs } from '../generators/index.js';
@@ -182,7 +183,7 @@ export async function setupProject(options) {
182
183
  await formatGeneratedFiles(config, targetDir);
183
184
  }
184
185
  console.log(chalk.green('\n✅ Setup completed successfully!\n'));
185
- showNextSteps(config, targetDir);
186
+ await showNextSteps(config, targetDir);
186
187
  }
187
188
  catch (error) {
188
189
  console.error(chalk.red('\n❌ Setup failed:'), error);
@@ -580,7 +581,7 @@ export function npmPublishFor(config) {
580
581
  environment: null,
581
582
  });
582
583
  }
583
- function showNextSteps(config, _targetDir) {
584
+ async function showNextSteps(config, targetDir) {
584
585
  console.log(chalk.bold('\n📋 Next Steps:\n'));
585
586
  const steps = [];
586
587
  // Every step in the else-branch names a pnpm script. Swift's equivalents are
@@ -615,6 +616,13 @@ function showNextSteps(config, _targetDir) {
615
616
  for (const line of formatNpmPublishGuide(npmPublish, true))
616
617
  console.log(` ${line}`);
617
618
  }
619
+ const secrets = requiredSecrets(await collectSecretUses(targetDir));
620
+ if (secrets.length > 0) {
621
+ console.log(chalk.bold('\n🔐 GitHub setup (secrets the workflows need):\n'));
622
+ for (const line of secretHints(secrets))
623
+ console.log(` ${line}`);
624
+ console.log(' Then: `fix github-settings` (branch protection), `fix release-environment` (release gate), and `doctor` to confirm');
625
+ }
618
626
  const skipped = collectSkippedFixSuggestions(config);
619
627
  if (skipped.length > 0) {
620
628
  console.log(chalk.bold('\n💡 Want to add something you skipped?\n'));
@@ -402,6 +402,29 @@ export async function renderBrand(targetDir) {
402
402
  }
403
403
  return written;
404
404
  }
405
+ /** brand/ file → docs-site static/img file. The ico and card PNG exist only once rendered. */
406
+ const DOCS_ASSETS = ['favicon.svg', 'favicon.ico', 'social-card.png'];
407
+ /**
408
+ * Copy the brand favicon and social card into the docs site's `static/img`
409
+ * (#680). Copy-if-missing, and a no-op without `apps/docs`, so `fix brand` and
410
+ * `fix docs-site` reach the same tree in either order — each calls it.
411
+ */
412
+ export async function syncBrandToDocs(targetDir) {
413
+ if (!(await fs.pathExists(path.join(targetDir, 'apps', 'docs'))))
414
+ return [];
415
+ const img = path.join('apps', 'docs', 'static', 'img');
416
+ const written = [];
417
+ for (const name of DOCS_ASSETS) {
418
+ const src = path.join(targetDir, 'brand', name);
419
+ const dest = path.join(targetDir, img, name);
420
+ if (!(await fs.pathExists(src)) || (await fs.pathExists(dest)))
421
+ continue;
422
+ await fs.ensureDir(path.dirname(dest));
423
+ await fs.copyFile(src, dest);
424
+ written.push(path.join(img, name));
425
+ }
426
+ return written;
427
+ }
405
428
  export const BANNER_START = '<!-- js-tooling:banner:start -->';
406
429
  export const BANNER_END = '<!-- js-tooling:banner:end -->';
407
430
  /** The README `<picture>` banner, mobile variant under 640px, as a delimited block. */
@@ -6,6 +6,7 @@ import { coverageUploadWorkflow } from '../../base/checks.js';
6
6
  import { jsBadgeAudience } from '../../languages/js/checks.js';
7
7
  import { copyPreset, PRESETS } from '../utils/copy-preset.js';
8
8
  import { buildBadgeRow, parseRepository } from './badges.js';
9
+ import { syncBrandToDocs } from './brand.js';
9
10
  import { DOCS_SITE_BUILDS, mergeAllowBuilds } from './pnpm-workspace.js';
10
11
  import { inferSubpathsFromExports } from './treeshake.js';
11
12
  /**
@@ -103,7 +104,7 @@ function jsString(value) {
103
104
  return JSON.stringify(value);
104
105
  return `'${JSON.stringify(value).slice(1, -1).replaceAll('\\"', '"').replaceAll("'", "\\'")}'`;
105
106
  }
106
- function docusaurusConfig(meta, typedocModules) {
107
+ function docusaurusConfig(meta, typedocModules, siblings = []) {
107
108
  const owner = meta.owner ?? 'your-org';
108
109
  const repo = meta.repo ?? meta.title;
109
110
  const ghUrl = `https://github.com/${owner}/${repo}`;
@@ -115,6 +116,17 @@ function docusaurusConfig(meta, typedocModules) {
115
116
  const typedocPlugins = typedocModules.length
116
117
  ? `\t\t...getTypedocPlugins([${typedocModules.map(jsString).join(', ')}]),\n`
117
118
  : '';
119
+ const siblingNav = siblings
120
+ .map((s) => `\t\t\t\t{ href: ${jsString(s.href)}, label: ${jsString(s.label)}, position: 'left' },\n`)
121
+ .join('');
122
+ const siblingFooter = siblings.length
123
+ ? `\t\t\t\t{
124
+ \t\t\t\t\ttitle: 'Projects',
125
+ \t\t\t\t\titems: [
126
+ ${siblings.map((s) => `\t\t\t\t\t\t{ label: ${jsString(s.label)}, href: ${jsString(s.href)} },\n`).join('')}\t\t\t\t\t],
127
+ \t\t\t\t},
128
+ `
129
+ : '';
118
130
  return `import type * as Preset from '@docusaurus/preset-classic'
119
131
  import type { Config } from '@docusaurus/types'
120
132
  import { themes as prismThemes } from 'prism-react-renderer'
@@ -176,6 +188,7 @@ ${typedocPlugins}\t\t[
176
188
  \t],
177
189
 
178
190
  \tthemeConfig: {
191
+ \t\timage: 'img/social-card.png',
179
192
  \t\tcolorMode: {
180
193
  \t\t\tdefaultMode: 'dark',
181
194
  \t\t\trespectPrefersColorScheme: true,
@@ -184,7 +197,7 @@ ${typedocPlugins}\t\t[
184
197
  \t\t\ttitle: '${meta.title}',
185
198
  \t\t\titems: [
186
199
  \t\t\t\t{ to: '/docs', position: 'left', label: 'Docs' },
187
- \t\t\t\t{
200
+ ${siblingNav}\t\t\t\t{
188
201
  \t\t\t\t\thref: '${ghUrl}',
189
202
  \t\t\t\t\tlabel: 'GitHub',
190
203
  \t\t\t\t\tposition: 'right',
@@ -205,7 +218,7 @@ ${typedocPlugins}\t\t[
205
218
  \t\t\t\t\t\t{ label: 'Issues', href: '${ghUrl}/issues' },
206
219
  \t\t\t\t\t],
207
220
  \t\t\t\t},
208
- \t\t\t],
221
+ ${siblingFooter}\t\t\t],
209
222
  \t\t\tcopyright: \`Copyright © \${new Date().getFullYear()} ${meta.title}. Built with Docusaurus.\`,
210
223
  \t\t},
211
224
  \t\t// \`theme\` is the LIGHT-mode Prism theme and \`darkTheme\` the dark one. Both
@@ -320,7 +333,10 @@ function docsPackageJson(meta, typedoc) {
320
333
  };
321
334
  return `${JSON.stringify(pkg, null, 2)}\n`;
322
335
  }
323
- function introDoc(meta, badges) {
336
+ function introDoc(meta, badges, siblings = []) {
337
+ const related = siblings.length
338
+ ? `\n## Related projects\n\n${siblings.map((s) => `- [${s.label}](${s.href})`).join('\n')}\n`
339
+ : '';
324
340
  return `---
325
341
  title: ${meta.title}
326
342
  slug: /
@@ -334,7 +350,7 @@ ${meta.tagline}
334
350
  Welcome to the docs. Edit \`apps/docs/docs/intro.md\` to get started, and add
335
351
  more markdown files under \`apps/docs/docs/\` — they appear in the sidebar
336
352
  automatically.
337
- `;
353
+ ${related}`;
338
354
  }
339
355
  /** The per-repo workflow that drives the shared reusable deploy on push to main. */
340
356
  function docsWorkflow(meta) {
@@ -384,6 +400,7 @@ export default function Home() {
384
400
  export async function generateDocsSite(pkg, targetDir, options = {}) {
385
401
  const meta = inferSiteMeta(pkg);
386
402
  const accent = options.primaryColor ?? DEFAULT_ACCENT;
403
+ const siblings = options.siblings ?? [];
387
404
  const written = [];
388
405
  // Shared assets (only-if-missing copies of the shipped presets).
389
406
  written.push(...(await copyPresetIfMissing('docusaurus-sync-changelog', targetDir)));
@@ -407,12 +424,12 @@ export async function generateDocsSite(pkg, targetDir, options = {}) {
407
424
  // Project-specific scaffold.
408
425
  const files = [
409
426
  [`${DOCS_APP}/package.json`, docsPackageJson(meta, typedocModules.length > 0)],
410
- [`${DOCS_APP}/docusaurus.config.ts`, docusaurusConfig(meta, typedocModules)],
427
+ [`${DOCS_APP}/docusaurus.config.ts`, docusaurusConfig(meta, typedocModules, siblings)],
411
428
  [`${DOCS_APP}/sidebars.ts`, SIDEBARS],
412
429
  [`${DOCS_APP}/tsconfig.json`, TSCONFIG],
413
430
  [`${DOCS_APP}/src/css/custom.css`, customCss(accent)],
414
431
  [`${DOCS_APP}/src/pages/index.tsx`, HOME_PAGE],
415
- [`${DOCS_APP}/docs/intro.md`, introDoc(meta, badges)],
432
+ [`${DOCS_APP}/docs/intro.md`, introDoc(meta, badges, siblings)],
416
433
  ['.github/workflows/docs.yml', docsWorkflow(meta)],
417
434
  ];
418
435
  // TypeDoc emits docs/api/<id> on build — keep the generated tree out of git.
@@ -427,6 +444,10 @@ export async function generateDocsSite(pkg, targetDir, options = {}) {
427
444
  if (w)
428
445
  written.push(w);
429
446
  }
447
+ // static/img always exists (the config points at img/favicon.ico); the brand
448
+ // assets go in when brand/ has them (#680).
449
+ await fs.ensureDir(path.join(targetDir, DOCS_APP, 'static', 'img'));
450
+ written.push(...(await syncBrandToDocs(targetDir)));
430
451
  const ws = await ensureWorkspace(targetDir);
431
452
  if (ws)
432
453
  written.push(ws);
@@ -31,7 +31,7 @@ export const CI_WORKFLOW = '.github/workflows/ci.yml';
31
31
  * scripts itself as part of the same scaffold.
32
32
  * @returns the files actually written, relative to targetDir.
33
33
  */
34
- export async function generateGitHubActions(config, targetDir, { overwrite = false, scripts } = {}) {
34
+ export async function generateGitHubActions(config, targetDir, { overwrite = false, scripts, bin, } = {}) {
35
35
  const workflowsDir = path.join(targetDir, '.github', 'workflows');
36
36
  await fs.ensureDir(workflowsDir);
37
37
  // This is the JS path specifically. Swift (#287) renders its own workflow
@@ -40,7 +40,7 @@ export async function generateGitHubActions(config, targetDir, { overwrite = fal
40
40
  // shared entry point would mean inventing a fake config to pass in. Both
41
41
  // paths meet at renderGitHubWorkflow() in src/base/ci.ts, which is the seam
42
42
  // that actually matters.
43
- const workflow = renderGitHubWorkflow(githubJobs(config, { scripts }));
43
+ const workflow = renderGitHubWorkflow(githubJobs(config, { scripts, bin }));
44
44
  const ciPath = path.join(workflowsDir, 'ci.yml');
45
45
  const existing = (await fs.pathExists(ciPath)) ? await fs.readFile(ciPath, 'utf-8') : null;
46
46
  const filesWritten = [];
@@ -171,6 +171,26 @@ export function lockfileSchema() {
171
171
  },
172
172
  },
173
173
  },
174
+ docs: {
175
+ type: 'object',
176
+ additionalProperties: false,
177
+ description: 'Inputs to `fix docs-site`.',
178
+ properties: {
179
+ siblings: {
180
+ type: 'array',
181
+ description: 'Sibling projects to link from the scaffolded docs site (navbar, footer, intro page). Opt-in: when absent the scaffold emits no such links.',
182
+ items: {
183
+ type: 'object',
184
+ additionalProperties: false,
185
+ required: ['label', 'href'],
186
+ properties: {
187
+ label: { type: 'string', minLength: 1 },
188
+ href: { type: 'string', minLength: 1 },
189
+ },
190
+ },
191
+ },
192
+ },
193
+ },
174
194
  },
175
195
  },
176
196
  },
@@ -73,6 +73,10 @@ const DEPENDENCIES_JOB = {
73
73
  - name: 📦 Install dependencies
74
74
  run: pnpm install --frozen-lockfile`,
75
75
  };
76
+ /** `JobOptions.bin` for a real repo. */
77
+ export function hasBin(pkg) {
78
+ return Boolean(pkg?.bin);
79
+ }
76
80
  /**
77
81
  * `JobOptions.scripts` for a real repo. A package.json with no `scripts` block
78
82
  * yields `{}` — "this repo has no scripts", which gates every step off —
@@ -160,6 +164,21 @@ export function githubJobs(config, opts = {}) {
160
164
  const publint = config.publint
161
165
  ? '\n - name: 🔍 Validate package with publint\n run: pnpm exec publint --strict\n'
162
166
  : '';
167
+ // npm's own `bin[…] invalid and removed` warning and a bin pnpm never
168
+ // links both hide until the tarball is installed somewhere else (#693).
169
+ const packSmoke = opts.bin
170
+ ? `
171
+ - name: 📦 Smoke-test the packed tarball
172
+ run: |
173
+ set -e
174
+ BIN=$(node -p "const p=require('./package.json');typeof p.bin==='string'?p.name.split('/').pop():Object.keys(p.bin)[0]")
175
+ TARBALL="$PWD/$(npm pack --silent | tail -n1)"
176
+ tar -tzf "$TARBALL" | grep -q . || { echo "::error::npm pack produced an empty tarball"; exit 1; }
177
+ SMOKE=$(mktemp -d)
178
+ cd "$SMOKE" && npm init -y >/dev/null && npm install "$TARBALL"
179
+ ./node_modules/.bin/"$BIN" --version
180
+ `
181
+ : '';
163
182
  jobs.push({
164
183
  id: 'build',
165
184
  needs: ['dependencies'],
@@ -167,7 +186,7 @@ export function githubJobs(config, opts = {}) {
167
186
 
168
187
  - name: 🏗️ Build project
169
188
  run: pnpm build
170
- ${attw}${publint}
189
+ ${attw}${publint}${packSmoke}
171
190
  - name: 📦 Upload build artifacts
172
191
  uses: actions/upload-artifact@v7
173
192
  with:
@@ -9,7 +9,7 @@ import { generateGitLabCI } from '../../cli/generators/gitlab-ci.js';
9
9
  import { GH_WORKFLOWS, generateGhWorkflow } from '../../cli/generators/github-workflows.js';
10
10
  import { BIOME_CONFIG, generateBiomeConfig, generateESLintConfig, generatePrettierConfig, } from '../../cli/generators/linting.js';
11
11
  import { alignNodeVersion, ensureEnginesNode, ensurePackageManager, generateKnipConfig, generateNvmrc, generateSizeLimitConfig, generateVscodeExtensions, } from '../../cli/generators/misc.js';
12
- import { scriptsOf } from './ci.js';
12
+ import { hasBin, scriptsOf } from './ci.js';
13
13
  import { NPM_OIDC_CHECK, NPM_TRUST_FIXER } from './npm-trust.js';
14
14
  import { usesPnpm } from './checks.js';
15
15
  import { SIZE_LIMIT_SCRIPTS, SIZE_LIMIT_VERSION, composeVerifyScriptFromPkg, ensureScripts, } from '../../cli/generators/package-json.js';
@@ -76,7 +76,7 @@ import { generateDocsSite } from '../../cli/generators/docs-site.js';
76
76
  import { generateTypedocConfig, generateTypedocWorkflow } from '../../cli/generators/typedoc.js';
77
77
  import { copyPreset } from '../../cli/utils/copy-preset.js';
78
78
  import { identifiablePresetHashes } from '../../cli/utils/copied-assets.js';
79
- import { LOCKFILE_NAME, writeLockfile } from '../../cli/utils/lockfile.js';
79
+ import { LOCKFILE_NAME, readLockfile, writeLockfile } from '../../cli/utils/lockfile.js';
80
80
  // The fixer contract moved to src/base/fixers.ts when Swift became the second
81
81
  // module (#286) — import it from there.
82
82
  import { FixerAbort } from '../../base/fixers.js';
@@ -451,6 +451,7 @@ export const FIXERS = [
451
451
  overwrite: result.check === 'GitHub Actions',
452
452
  // Only reference scripts this repo actually has (#364).
453
453
  scripts: scriptsOf(pkg),
454
+ bin: hasBin(pkg),
454
455
  });
455
456
  // `pnpm/action-setup` is emitted without a `version:` input, so without
456
457
  // this every job dies at setup with "No pnpm version is specified".
@@ -773,7 +774,10 @@ export const FIXERS = [
773
774
  }
774
775
  }
775
776
  const typedoc = hasTypedocConfig || 'typedoc' in deps;
776
- const filesWritten = await generateDocsSite(pkg, targetDir, { typedoc });
777
+ const filesWritten = await generateDocsSite(pkg, targetDir, {
778
+ typedoc,
779
+ siblings: (await readLockfile(targetDir))?.rules?.docs?.siblings,
780
+ });
777
781
  return { filesWritten };
778
782
  },
779
783
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "4.6.0",
3
+ "version": "4.8.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": [
@@ -248,7 +248,7 @@
248
248
  "@next/eslint-plugin-next": "^16.3.2",
249
249
  "@playwright/test": "^1.62.1",
250
250
  "@rollup/plugin-typescript": "^12.3.0",
251
- "@rtorcato/repo-ai": "^3.1.1",
251
+ "@rtorcato/repo-ai": "^3.1.2",
252
252
  "@semantic-release/commit-analyzer": "^13.0.1",
253
253
  "@semantic-release/exec": "^7.1.0",
254
254
  "@semantic-release/github": "^12.0.9",