@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.
- package/dist/base/fixers.js +2 -1
- package/dist/base/secrets.js +126 -0
- package/dist/cli/commands/doctor.js +4 -2
- package/dist/cli/commands/setup.js +10 -2
- package/dist/cli/generators/brand.js +23 -0
- package/dist/cli/generators/docs-site.js +28 -7
- package/dist/cli/generators/github-actions.js +2 -2
- package/dist/cli/utils/lockfile.js +20 -0
- package/dist/languages/js/ci.js +20 -1
- package/dist/languages/js/fixers.js +7 -3
- package/package.json +2 -2
package/dist/base/fixers.js
CHANGED
|
@@ -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,
|
|
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
|
},
|
package/dist/languages/js/ci.js
CHANGED
|
@@ -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, {
|
|
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.
|
|
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.
|
|
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",
|