@rtorcato/repo-tooling 4.4.0 → 4.5.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/AGENTS.md CHANGED
@@ -92,7 +92,7 @@ npx @rtorcato/repo-tooling fix bun --yes --json # Bun runtime/test confi
92
92
 
93
93
  `fix` defaults the confirm prompt to **No** for drift cases (existing file that doesn't extend our preset). The `--yes` flag is required to overwrite drift. Safe-merge fixers (`biome`, `engines`, `husky`, `package-json`) never overwrite — they add/merge — and use friendlier prompt wording. `fix --json` implies `--yes` (prompts would corrupt JSON output).
94
94
 
95
- Fixers marked `explicitOnly` are exempt from `fix` all *and* from `fix --yes` — they only run when named as the target. Today that is `release-environment` (changes what a merge does).
95
+ Fixers marked `explicitOnly` are exempt from `fix` all *and* from `fix --yes` — they only run when named as the target. Today that is `release-environment` (changes what a merge does) and `npm-trusted-publisher` (registers an OIDC trusted publisher on npm via `npm trust github`; refuses when the package isn't on npm yet, local npm < 11.15.0, or not logged in).
96
96
 
97
97
  The same goes for every `optional-missing` finding: a bulk `fix` records it `skipped`, because optional tools are often mutually exclusive (Biome / ESLint / Prettier / Oxlint, semantic-release / Changesets / Release Please) and installing them all is never the intent (#630). Name the one you want — `fix editorconfig --yes`.
98
98
 
@@ -380,6 +380,22 @@ export async function checkCodeQL(dir, languages) {
380
380
  hint: 'Run `npx @rtorcato/repo-tooling fix codeql` to scaffold CodeQL security scanning',
381
381
  };
382
382
  }
383
+ /** The workflow file that uploads coverage via codecov-action, or null when none does. */
384
+ export async function coverageUploadWorkflow(dir) {
385
+ const workflowsDir = path.join(dir, '.github', 'workflows');
386
+ try {
387
+ const files = (await fs.readdir(workflowsDir)).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
388
+ for (const f of files) {
389
+ const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
390
+ if (/codecov\/codecov-action/.test(content))
391
+ return f;
392
+ }
393
+ }
394
+ catch {
395
+ // no workflows dir
396
+ }
397
+ return null;
398
+ }
383
399
  // A README that advertises a Codecov badge but a CI that never uploads coverage
384
400
  // leaves the badge permanently red. Only flags when the badge is actually present
385
401
  // (no badge → nothing to back, so it's not applicable).
@@ -393,24 +409,13 @@ export async function checkCoverageUpload(dir) {
393
409
  detail: 'no coverage badge in README (nothing to back)',
394
410
  };
395
411
  }
396
- const workflowsDir = path.join(dir, '.github', 'workflows');
397
- if (await fs.pathExists(workflowsDir)) {
398
- try {
399
- const files = (await fs.readdir(workflowsDir)).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
400
- for (const f of files) {
401
- const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
402
- if (/codecov\/codecov-action/.test(content)) {
403
- return {
404
- check: 'Coverage upload',
405
- status: 'ok',
406
- detail: `coverage badge backed by codecov-action in .github/workflows/${f}`,
407
- };
408
- }
409
- }
410
- }
411
- catch {
412
- // fall through to drift
413
- }
412
+ const workflow = await coverageUploadWorkflow(dir);
413
+ if (workflow) {
414
+ return {
415
+ check: 'Coverage upload',
416
+ status: 'ok',
417
+ detail: `coverage badge backed by codecov-action in .github/workflows/${workflow}`,
418
+ };
414
419
  }
415
420
  return {
416
421
  check: 'Coverage upload',
@@ -4,6 +4,7 @@ import fs from 'fs-extra';
4
4
  import { renderGitHubWorkflow } from '../../base/ci.js';
5
5
  import { githubJobs, scriptsOf } from '../../languages/js/ci.js';
6
6
  import { inferProjectConfig } from '../../languages/js/fixers.js';
7
+ import { checkNpmTrustedPublisher, checkPublishJob, findNpmPublishJob, NPM_OIDC_CHECK, } from '../../languages/js/npm-trust.js';
7
8
  import { PERL_GIT_HOOKS, runPerlChecks } from '../../languages/perl/checks.js';
8
9
  import { readPerlProject, renderPerlWorkflow } from '../../languages/perl/ci.js';
9
10
  import { PYTHON_GIT_HOOKS, runPythonChecks } from '../../languages/python/checks.js';
@@ -68,38 +69,19 @@ async function checkReleaseToken(dir) {
68
69
  };
69
70
  }
70
71
  }
71
- // Flags a release workflow still authenticating npm publish with a long-lived
72
- // NPM_TOKEN secret instead of OIDC trusted publishing (#201). npm is deprecating
73
- // 2FA-bypass tokens; OIDC needs no secret and adds provenance for free. Only
74
- // relevant for public packages that actually publish to npm.
72
+ // OIDC trusted publishing (#201, #687): the publishing job must not use an
73
+ // NPM_TOKEN secret, must carry `id-token: write`, and must run npm ≥ 11.5.1.
74
+ // Only relevant for public packages that actually publish to npm.
75
75
  async function checkNpmOidcPublish(dir, pkg) {
76
- const check = 'npm OIDC publish';
76
+ const check = NPM_OIDC_CHECK;
77
77
  if (!pkg || pkg.private === true) {
78
78
  return { check, status: 'optional-missing', detail: 'private package — no npm publish' };
79
79
  }
80
- const workflowsDir = path.join(dir, '.github', 'workflows');
81
- if (!(await fs.pathExists(workflowsDir))) {
82
- return { check, status: 'optional-missing', detail: 'no .github/workflows/' };
83
- }
84
80
  try {
85
- const files = await fs.readdir(workflowsDir);
86
- for (const f of files) {
87
- if (!(f.endsWith('.yml') || f.endsWith('.yaml')))
88
- continue;
89
- const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
90
- if (!/semantic-release/.test(content))
91
- continue;
92
- if (/secrets\.NPM_TOKEN/.test(content)) {
93
- return {
94
- check,
95
- status: 'drift',
96
- detail: `${f} authenticates npm publish with NPM_TOKEN`,
97
- hint: 'Migrate to OIDC trusted publishing: add a Trusted Publisher for each published package on npmjs.com (Settings → Trusted Publisher), then run `fix github-actions` to drop NPM_TOKEN (the release job keeps `id-token: write`). npm is deprecating 2FA-bypass tokens.',
98
- };
99
- }
100
- return { check, status: 'ok', detail: `${f} publishes via OIDC (no NPM_TOKEN)` };
101
- }
102
- return { check, status: 'optional-missing', detail: 'no semantic-release workflow found' };
81
+ const job = await findNpmPublishJob(dir);
82
+ if (!job)
83
+ return { check, status: 'optional-missing', detail: 'no semantic-release workflow found' };
84
+ return checkPublishJob(job);
103
85
  }
104
86
  catch {
105
87
  return { check, status: 'optional-missing', detail: 'unable to read .github/workflows/' };
@@ -377,6 +359,7 @@ export async function runDoctor(dir) {
377
359
  results.push(await checkSizeLimit(targetDir, pkg));
378
360
  results.push(await checkReleaseToken(targetDir));
379
361
  results.push(await checkNpmOidcPublish(targetDir, pkg));
362
+ results.push(await checkNpmTrustedPublisher(targetDir, pkg));
380
363
  results.push(await checkTypedoc(targetDir, pkg));
381
364
  results.push(await checkAreTheTypesWrong(targetDir, pkg));
382
365
  results.push(await checkPublint(targetDir, pkg));
@@ -22,6 +22,8 @@ export const FIX_TARGETS = {
22
22
  'Tree-shake check': 'treeshake-check',
23
23
  'GitHub Actions': 'github-actions',
24
24
  'Coverage upload': 'github-actions',
25
+ 'npm OIDC publish': 'github-actions',
26
+ 'npm trusted publisher': 'npm-trusted-publisher',
25
27
  Dependabot: 'dependabot',
26
28
  CodeQL: 'codeql',
27
29
  'Branch protection': 'github-settings',
@@ -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 { formatNpmPublishGuide, npmPublishGuide, } from '../../languages/js/npm-trust.js';
5
6
  import { LANGUAGES } from '../../languages/registry.js';
6
7
  import { generateConfigs } from '../generators/index.js';
7
8
  import { detectLanguage } from '../utils/detect-language.js';
@@ -157,7 +158,8 @@ export async function setupProject(options) {
157
158
  return;
158
159
  if (dryRun) {
159
160
  const files = computeFileList(config);
160
- console.log(JSON.stringify({ directory: targetDir, config, files }, null, 2));
161
+ const npmPublish = npmPublishFor(config);
162
+ console.log(JSON.stringify({ directory: targetDir, config, files, ...(npmPublish && { npmPublish }) }, null, 2));
161
163
  return;
162
164
  }
163
165
  if (!interactive) {
@@ -561,6 +563,23 @@ async function promptForConfig(targetDir, seed) {
561
563
  bun: answers.bun ?? false,
562
564
  };
563
565
  }
566
+ /**
567
+ * The npmjs.com trusted-publisher values for a scaffold whose release job
568
+ * publishes (#687). Owner/repo aren't known yet at setup time, so they stay
569
+ * placeholders; `fix npm-trusted-publisher` derives them once the repo exists.
570
+ * The generated job declares no environment until `fix release-environment`.
571
+ */
572
+ export function npmPublishFor(config) {
573
+ if (config.language === 'swift' || config.projectType !== 'library' || !config.semanticRelease)
574
+ return null;
575
+ return npmPublishGuide({
576
+ name: config.projectName,
577
+ owner: '<owner>',
578
+ repo: '<repo>',
579
+ file: 'ci.yml',
580
+ environment: null,
581
+ });
582
+ }
564
583
  function showNextSteps(config, _targetDir) {
565
584
  console.log(chalk.bold('\n📋 Next Steps:\n'));
566
585
  const steps = [];
@@ -589,6 +608,13 @@ function showNextSteps(config, _targetDir) {
589
608
  steps.forEach((step, index) => {
590
609
  console.log(` ${index + 1}. ${step}`);
591
610
  });
611
+ // A brand-new package isn't on npm yet, so the bootstrap order always applies.
612
+ const npmPublish = npmPublishFor(config);
613
+ if (npmPublish) {
614
+ console.log(chalk.bold('\n📦 Publish to npm (OIDC trusted publishing — no NPM_TOKEN):\n'));
615
+ for (const line of formatNpmPublishGuide(npmPublish, true))
616
+ console.log(` ${line}`);
617
+ }
592
618
  const skipped = collectSkippedFixSuggestions(config);
593
619
  if (skipped.length > 0) {
594
620
  console.log(chalk.bold('\n💡 Want to add something you skipped?\n'));
@@ -31,7 +31,7 @@ export function parseRepository(repository) {
31
31
  * repo-specific is derivable, so a badge row isn't worth adding.
32
32
  */
33
33
  export function buildBadgeRow(inputs) {
34
- const { name, owner, repo, branch = 'main', isPrivate = false } = inputs;
34
+ const { name, owner, repo, branch = 'main', isPrivate = false, bundled = true, uploadsCoverage = true, } = inputs;
35
35
  const slug = owner && repo ? `${owner}/${repo}` : null;
36
36
  const badges = [];
37
37
  let specific = false;
@@ -43,9 +43,11 @@ export function buildBadgeRow(inputs) {
43
43
  specific = true;
44
44
  badges.push(`[![npm version](https://img.shields.io/npm/v/${name})](https://www.npmjs.com/package/${name})`);
45
45
  badges.push(`[![npm downloads](https://img.shields.io/npm/dm/${name})](https://www.npmjs.com/package/${name})`);
46
+ }
47
+ if (!isPrivate && name && bundled) {
46
48
  badges.push(`[![Bundle size](https://img.shields.io/bundlephobia/minzip/${name})](https://bundlephobia.com/package/${name})`);
47
49
  }
48
- if (!isPrivate && slug) {
50
+ if (!isPrivate && slug && uploadsCoverage) {
49
51
  badges.push(`[![Coverage](https://codecov.io/gh/${slug}/branch/${branch}/graph/badge.svg)](https://codecov.io/gh/${slug})`);
50
52
  }
51
53
  badges.push('[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)');
@@ -1,6 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
3
  import selfPackageJson from '../../../package.json' with { type: 'json' };
4
+ import { coverageUploadWorkflow } from '../../base/checks.js';
5
+ import { jsBadgeAudience } from '../../languages/js/checks.js';
4
6
  import { copyPreset, PRESETS } from '../utils/copy-preset.js';
5
7
  import { buildBadgeRow, parseRepository } from './badges.js';
6
8
  import { DOCS_SITE_BUILDS, mergeAllowBuilds } from './pnpm-workspace.js';
@@ -385,11 +387,15 @@ export async function generateDocsSite(pkg, targetDir, options = {}) {
385
387
  // The docs homepage carries the same badge set as the README (#169), derived
386
388
  // from package.json + repo; visibility-aware (private repos drop npm/coverage).
387
389
  // Plain row (no upsert delimiters) to stay MDX-safe in the generated intro.
390
+ // Bundlephobia only for a published library, Codecov only when CI uploads
391
+ // coverage — the same rules doctor's badge/coverage checks use (#675).
388
392
  const badges = buildBadgeRow({
389
393
  name: pkg?.name,
390
394
  owner: meta.owner ?? undefined,
391
395
  repo: meta.repo ?? undefined,
392
396
  isPrivate: pkg?.private === true,
397
+ bundled: jsBadgeAudience(pkg) === 'public',
398
+ uploadsCoverage: (await coverageUploadWorkflow(targetDir)) !== null,
393
399
  });
394
400
  // Opt-in TypeDoc API section (#229): only wire it when enabled AND the
395
401
  // package actually exposes source modules to document.
@@ -212,6 +212,12 @@ ${attw}${publint}
212
212
  git config --global user.name "github-actions[bot]"
213
213
  git config --global user.email "github-actions[bot]@users.noreply.github.com"
214
214
 
215
+ # OIDC trusted publishing needs npm >= 11.5.1; Node 22 bundles npm 10, whose
216
+ # \`npm publish\` fails with ENEEDAUTH. Pinned to a range, not \`latest\`, so a
217
+ # future npm major can't change publishing unannounced.
218
+ - name: 📦 Upgrade npm for OIDC trusted publishing
219
+ run: npm install -g npm@^11.5.1
220
+
215
221
  - name: 🚀 Run semantic-release
216
222
  # Publishes to npm via OIDC trusted publishing — no NPM_TOKEN. Requires
217
223
  # the \`id-token: write\` permission above + a trusted publisher configured
@@ -10,6 +10,7 @@ import { GH_WORKFLOWS, generateGhWorkflow } from '../../cli/generators/github-wo
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
12
  import { scriptsOf } from './ci.js';
13
+ import { NPM_OIDC_CHECK, NPM_TRUST_FIXER } from './npm-trust.js';
13
14
  import { usesPnpm } from './checks.js';
14
15
  import { SIZE_LIMIT_SCRIPTS, SIZE_LIMIT_VERSION, composeVerifyScriptFromPkg, ensureScripts, } from '../../cli/generators/package-json.js';
15
16
  /** Kept in step with the biome branch of getScripts() in package-json.ts. */
@@ -438,7 +439,7 @@ export const FIXERS = [
438
439
  {
439
440
  target: 'github-actions',
440
441
  description: 'Scaffold .github/workflows/ci.yml (+ codecov.yml when tests run)',
441
- appliesTo: ['GitHub Actions', 'Coverage upload', 'npm OIDC publish'],
442
+ appliesTo: ['GitHub Actions', 'Coverage upload', NPM_OIDC_CHECK],
442
443
  outputs: [CI_WORKFLOW, 'codecov.yml', 'package.json (packageManager field)'],
443
444
  canFixDrift: true,
444
445
  async run({ targetDir, pkg, result }) {
@@ -462,6 +463,7 @@ export const FIXERS = [
462
463
  return { filesWritten };
463
464
  },
464
465
  },
466
+ NPM_TRUST_FIXER,
465
467
  {
466
468
  target: 'gitlab-ci',
467
469
  description: 'Scaffold .gitlab-ci.yml (lint/typecheck/test/build mirrored from GitHub Actions)',
@@ -0,0 +1,351 @@
1
+ /**
2
+ * npm OIDC trusted publishing (#687): what the publishing job needs, whether the
3
+ * registry has a trusted publisher wired to it, and the fixer that registers one.
4
+ *
5
+ * Everything that talks to npm goes through `NpmExec`, the same resolve-never-
6
+ * reject seam as `GhExec`, so tests never touch the registry and a network or
7
+ * auth failure degrades to "couldn't check" rather than a failed doctor run.
8
+ */
9
+ import { execFile } from 'node:child_process';
10
+ import path from 'node:path';
11
+ import chalk from 'chalk';
12
+ import fs from 'fs-extra';
13
+ import inquirer from 'inquirer';
14
+ import { FixerAbort } from '../../base/fixers.js';
15
+ import { jobEnvironment, workflowJobs } from '../../base/github-settings.js';
16
+ import { parseRepository } from '../../cli/generators/badges.js';
17
+ const NPM_TIMEOUT_MS = 15_000;
18
+ /** Real `npm` runner — never rejects. Args are derived, never free text, and execFile runs no shell. */
19
+ export const realNpmExec = (args) => new Promise((resolve) => {
20
+ execFile('npm', args, { timeout: NPM_TIMEOUT_MS }, (err, stdout, stderr) => {
21
+ const code = err ? (typeof err.code === 'number' ? err.code : null) : 0;
22
+ resolve({ ok: !err, stdout: String(stdout), stderr: String(stderr), code });
23
+ });
24
+ });
25
+ export const NPM_OIDC_CHECK = 'npm OIDC publish';
26
+ export const NPM_TRUST_CHECK = 'npm trusted publisher';
27
+ export const NPM_TRUST_FIX = 'npm-trusted-publisher';
28
+ /** OIDC publish needs npm ≥ 11.5.1 — Node 22 bundles npm 10. */
29
+ export const NPM_OIDC_MIN = '11.5.1';
30
+ /** `npm trust` landed in npm 11.15.0. */
31
+ export const NPM_TRUST_MIN = '11.15.0';
32
+ export const NPM_UPGRADE_CMD = `npm install -g npm@^${NPM_OIDC_MIN}`;
33
+ const NPM_UPGRADE = /\bnpm\s+(?:install|i)\s+(?:-g|--global)\s+npm@/;
34
+ const PUBLISHES = /semantic-release|\bnpm\s+publish\b/;
35
+ /** a ≥ b for dotted numeric versions (prerelease tags ignored). */
36
+ export function versionAtLeast(a, b) {
37
+ const pa = a.trim().split(/[.-]/).map(Number);
38
+ const pb = b.split('.').map(Number);
39
+ for (let i = 0; i < 3; i++) {
40
+ const x = pa[i] ?? 0;
41
+ const y = pb[i] ?? 0;
42
+ if (Number.isNaN(x))
43
+ return false;
44
+ if (x !== y)
45
+ return x > y;
46
+ }
47
+ return true;
48
+ }
49
+ const uncommented = (s) => s
50
+ .split('\n')
51
+ .filter((l) => !l.trimStart().startsWith('#'))
52
+ .join('\n');
53
+ /** The first workflow job that publishes to npm, or null. Throws when the directory can't be read. */
54
+ export async function findNpmPublishJob(dir) {
55
+ const workflowsDir = path.join(dir, '.github', 'workflows');
56
+ if (!(await fs.pathExists(workflowsDir)))
57
+ return null;
58
+ for (const file of (await fs.readdir(workflowsDir)).sort()) {
59
+ if (!/\.ya?ml$/.test(file))
60
+ continue;
61
+ const content = await fs.readFile(path.join(workflowsDir, file), 'utf-8');
62
+ if (!PUBLISHES.test(uncommented(content)))
63
+ continue;
64
+ const jobs = [...workflowJobs(content).values()].map(uncommented);
65
+ const body = jobs.find((b) => PUBLISHES.test(b)) ?? uncommented(content);
66
+ return { file, body, content, environment: jobEnvironment(body) };
67
+ }
68
+ return null;
69
+ }
70
+ /**
71
+ * What the publishing job itself needs for OIDC: no NPM_TOKEN, `id-token: write`,
72
+ * and an npm new enough to use the token.
73
+ */
74
+ export function checkPublishJob(job) {
75
+ const check = NPM_OIDC_CHECK;
76
+ const f = job.file;
77
+ if (/secrets\.NPM_TOKEN/.test(job.content)) {
78
+ return {
79
+ check,
80
+ status: 'drift',
81
+ detail: `${f} authenticates npm publish with NPM_TOKEN`,
82
+ hint: 'Migrate to OIDC trusted publishing: add a Trusted Publisher for each published package on npmjs.com (Settings → Trusted Publisher), then run `fix github-actions` to drop NPM_TOKEN (the release job keeps `id-token: write`). npm is deprecating 2FA-bypass tokens.',
83
+ };
84
+ }
85
+ // The grant may sit on the job or at workflow level (everything before `jobs:`).
86
+ const workflowLevel = uncommented(job.content.split(/^jobs:/m)[0] ?? '');
87
+ if (!/id-token:\s*write/.test(job.body) && !/id-token:\s*write/.test(workflowLevel)) {
88
+ return {
89
+ check,
90
+ status: 'drift',
91
+ detail: `${f}: the publishing job lacks \`id-token: write\` — npm can't mint an OIDC token`,
92
+ hint: 'Add `permissions: { id-token: write }` to the publishing job (or run `fix github-actions`)',
93
+ };
94
+ }
95
+ if (!NPM_UPGRADE.test(job.body)) {
96
+ return {
97
+ check,
98
+ status: 'drift',
99
+ detail: `${f}: the publishing job runs the npm bundled with Node — OIDC publish needs npm ≥ ${NPM_OIDC_MIN} and fails with ENEEDAUTH below it`,
100
+ hint: `add \`${NPM_UPGRADE_CMD}\` before semantic-release`,
101
+ };
102
+ }
103
+ return { check, status: 'ok', detail: `${f} publishes via OIDC (no NPM_TOKEN)` };
104
+ }
105
+ export function npmPublishGuide(opts) {
106
+ const env = opts.environment ? ` --env ${opts.environment}` : '';
107
+ return {
108
+ package: opts.name,
109
+ where: 'npmjs.com → package → Settings → Trusted Publisher → GitHub Actions',
110
+ organizationOrUser: opts.owner,
111
+ repository: opts.repo,
112
+ workflowFilename: opts.file,
113
+ environment: opts.environment,
114
+ command: `npm trust github ${opts.name} --file ${opts.file} --repo ${opts.owner}/${opts.repo}${env} --allow-publish`,
115
+ bootstrap: [
116
+ "Publish one version BELOW semantic-release's next one by hand: `npm publish --access public --provenance=false --otp=<code>`",
117
+ `Register the trusted publisher: \`npx @rtorcato/repo-tooling fix ${NPM_TRUST_FIX}\` (or on npmjs.com)`,
118
+ 'Let CI take over — every later release publishes via OIDC',
119
+ ],
120
+ };
121
+ }
122
+ /** The guide as text lines, for Next Steps and doctor hints. Never mentions an NPM_TOKEN secret. */
123
+ export function formatNpmPublishGuide(g, unpublished) {
124
+ const lines = [
125
+ `On ${g.where}:`,
126
+ ` Organization or user: ${g.organizationOrUser}`,
127
+ ` Repository: ${g.repository}`,
128
+ ` Workflow filename: ${g.workflowFilename}`,
129
+ ];
130
+ if (g.environment)
131
+ lines.push(` Environment: ${g.environment}`);
132
+ lines.push(`Or from a logged-in npm ≥ ${NPM_TRUST_MIN}: ${g.command}`);
133
+ if (unpublished) {
134
+ lines.push(`${g.package} isn't on npm yet — npm only accepts a trusted publisher for an existing package:`);
135
+ for (const [i, s] of g.bootstrap.entries())
136
+ lines.push(` ${i + 1}. ${s}`);
137
+ }
138
+ return lines;
139
+ }
140
+ /** A trusted-publisher entry's string leaves, lowercased — npm's JSON shape is matched loosely. */
141
+ function stringsOf(v, out = []) {
142
+ if (typeof v === 'string')
143
+ out.push(v.toLowerCase());
144
+ else if (Array.isArray(v))
145
+ for (const x of v)
146
+ stringsOf(x, out);
147
+ else if (v && typeof v === 'object')
148
+ for (const x of Object.values(v))
149
+ stringsOf(x, out);
150
+ return out;
151
+ }
152
+ /** `npm trust list --json` → its entries, whether npm wraps them or not. */
153
+ function trustEntries(json) {
154
+ if (Array.isArray(json))
155
+ return json;
156
+ if (json && typeof json === 'object') {
157
+ const arr = Object.values(json).find(Array.isArray);
158
+ if (arr)
159
+ return arr;
160
+ return Object.keys(json).length > 0 ? [json] : [];
161
+ }
162
+ return [];
163
+ }
164
+ /**
165
+ * Compare `npm trust list` entries against the publishing job. Returns null on a
166
+ * match, else why nothing matches.
167
+ * ponytail: matches on string leaves rather than a fixed schema — npm's JSON
168
+ * shape isn't pinned in its docs; tighten once it is.
169
+ */
170
+ export function trustMismatch(entries, want) {
171
+ if (entries.length === 0)
172
+ return 'no trusted publisher is registered';
173
+ const nwo = want.nwo.toLowerCase();
174
+ const file = want.file.toLowerCase();
175
+ const env = want.environment?.toLowerCase() ?? null;
176
+ const leaves = entries.map((e) => stringsOf(e));
177
+ const forRepo = leaves.filter((s) => s.some((x) => x === nwo || x.endsWith(`/${nwo}`)));
178
+ if (forRepo.length === 0)
179
+ return `no trusted publisher for ${want.nwo}`;
180
+ const forFile = forRepo.filter((s) => s.some((x) => x === file || x.endsWith(`/${file}`) || x.includes(`/${file}@`)));
181
+ if (forFile.length === 0)
182
+ return `the trusted publisher for ${want.nwo} names a different workflow (want ${want.file})`;
183
+ if (env && !forFile.some((s) => s.includes(env))) {
184
+ return `the trusted publisher for ${want.nwo} has no \`${want.environment}\` environment, but the job declares one`;
185
+ }
186
+ return null;
187
+ }
188
+ const NPM_NAME = /^(@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*$/;
189
+ const ENV_NAME = /^[\w.-]+$/;
190
+ /** What the check and the fixer both need, or why it can't be had. Network only past the offline gates. */
191
+ async function trustContext(dir, pkg, npm, opts) {
192
+ if (!pkg || pkg.private === true)
193
+ return { skip: 'private package — no npm publish' };
194
+ const name = typeof pkg.name === 'string' ? pkg.name : null;
195
+ if (!name)
196
+ return { skip: 'package.json has no name' };
197
+ // Untrusted (audited repo's package.json): reject anything npm could parse as a flag.
198
+ if (!NPM_NAME.test(name))
199
+ return { skip: 'package.json `name` is not a valid npm package name' };
200
+ let job;
201
+ try {
202
+ job = await findNpmPublishJob(dir);
203
+ }
204
+ catch {
205
+ return { skip: 'unable to read .github/workflows/' };
206
+ }
207
+ if (!job)
208
+ return { skip: 'no workflow publishes to npm' };
209
+ if (job.environment && !ENV_NAME.test(job.environment)) {
210
+ return { skip: 'the publish job declares an environment with unsupported characters' };
211
+ }
212
+ const repo = parseRepository(pkg.repository);
213
+ if (!repo)
214
+ return { skip: 'package.json `repository` names no GitHub repo' };
215
+ const nwo = `${repo.owner}/${repo.repo}`;
216
+ const guide = npmPublishGuide({ name, ...repo, file: job.file, environment: job.environment });
217
+ const view = await npm(['view', '--', name, 'version']);
218
+ if (!view.ok) {
219
+ if (/E404|404 Not Found/i.test(view.stderr)) {
220
+ return { skip: `${name} is not on npm yet`, unpublished: true, guide };
221
+ }
222
+ return { skip: 'could not reach the npm registry', guide };
223
+ }
224
+ if (opts.needLogin && process.env.CI)
225
+ return { skip: 'running in CI — no npm login', guide };
226
+ const ver = await npm(['--version']);
227
+ if (!ver.ok || !versionAtLeast(ver.stdout, NPM_TRUST_MIN)) {
228
+ return {
229
+ skip: `local npm ${ver.stdout.trim() || '?'} is older than ${NPM_TRUST_MIN} (\`npm install -g npm@^${NPM_TRUST_MIN}\`)`,
230
+ guide,
231
+ };
232
+ }
233
+ const who = await npm(['whoami']);
234
+ if (!who.ok)
235
+ return { skip: 'not logged in to npm (`npm login`)', guide };
236
+ return { guide, nwo, job };
237
+ }
238
+ /**
239
+ * Is a trusted publisher registered on npm, matching the publishing job? Every
240
+ * "couldn't check" is `optional-missing` with the manual steps — never a failure.
241
+ */
242
+ export async function checkNpmTrustedPublisher(dir, pkg, npm = realNpmExec) {
243
+ const check = NPM_TRUST_CHECK;
244
+ const ctx = await trustContext(dir, pkg, npm, { needLogin: true });
245
+ if ('skip' in ctx) {
246
+ return {
247
+ check,
248
+ status: 'optional-missing',
249
+ detail: ctx.skip,
250
+ ...(ctx.guide && {
251
+ hint: formatNpmPublishGuide(ctx.guide, ctx.unpublished === true).join('\n'),
252
+ }),
253
+ };
254
+ }
255
+ const list = await npm(['trust', 'list', '--json', '--', ctx.guide.package]);
256
+ let entries = null;
257
+ if (list.ok) {
258
+ try {
259
+ entries = trustEntries(JSON.parse(list.stdout || '[]'));
260
+ }
261
+ catch {
262
+ entries = null;
263
+ }
264
+ }
265
+ if (!entries) {
266
+ return {
267
+ check,
268
+ status: 'optional-missing',
269
+ detail: 'could not read `npm trust list`',
270
+ hint: formatNpmPublishGuide(ctx.guide, false).join('\n'),
271
+ };
272
+ }
273
+ const why = trustMismatch(entries, {
274
+ nwo: ctx.nwo,
275
+ file: ctx.job.file,
276
+ environment: ctx.job.environment,
277
+ });
278
+ if (why) {
279
+ return {
280
+ check,
281
+ status: 'drift',
282
+ detail: `${ctx.guide.package}: ${why}`,
283
+ hint: `Run \`npx @rtorcato/repo-tooling fix ${NPM_TRUST_FIX}\`, or: ${ctx.guide.command}`,
284
+ };
285
+ }
286
+ return {
287
+ check,
288
+ status: 'ok',
289
+ detail: `${ctx.guide.package} trusts ${ctx.nwo} ${ctx.job.file}${ctx.job.environment ? ` (${ctx.job.environment})` : ''}`,
290
+ };
291
+ }
292
+ /**
293
+ * Register the trusted publisher with `npm trust github`, flags derived from the
294
+ * publishing job. Dry-runs first and asks before the real call.
295
+ */
296
+ export async function applyNpmTrustedPublisher(dir, pkg, assumeYes, npm = realNpmExec) {
297
+ const ctx = await trustContext(dir, pkg, npm, { needLogin: true });
298
+ if ('skip' in ctx) {
299
+ const hint = ctx.unpublished && ctx.guide ? ctx.guide.bootstrap.join(' → ') : undefined;
300
+ throw new FixerAbort('npm-trust-unavailable', ctx.skip, hint);
301
+ }
302
+ const args = [
303
+ 'trust',
304
+ 'github',
305
+ '--file',
306
+ ctx.job.file,
307
+ '--repo',
308
+ ctx.nwo,
309
+ ...(ctx.job.environment ? ['--env', ctx.job.environment] : []),
310
+ '--allow-publish',
311
+ ];
312
+ // `--` last so the name can never be read as a flag.
313
+ const tail = ['--', ctx.guide.package];
314
+ const dry = await npm([...args, '--dry-run', ...tail]);
315
+ if (!dry.ok) {
316
+ throw new FixerAbort('npm-trust-failed', `npm trust --dry-run failed: ${dry.stderr.trim()}`);
317
+ }
318
+ console.error(chalk.gray(dry.stdout.trim()));
319
+ if (!assumeYes) {
320
+ if (!process.stdin.isTTY)
321
+ return [];
322
+ const { confirm } = await inquirer.prompt([
323
+ {
324
+ type: 'confirm',
325
+ name: 'confirm',
326
+ message: 'Register this trusted publisher on npm?',
327
+ default: false,
328
+ },
329
+ ]);
330
+ if (confirm !== true)
331
+ return [];
332
+ }
333
+ const real = await npm([...args, '--yes', ...tail]);
334
+ if (!real.ok)
335
+ throw new FixerAbort('npm-trust-failed', `npm trust failed: ${real.stderr.trim()}`);
336
+ return [`npm trusted publisher for ${ctx.guide.package} (remote, via npm trust)`];
337
+ }
338
+ export const NPM_TRUST_FIXER = {
339
+ target: NPM_TRUST_FIX,
340
+ description: 'Register the npm trusted publisher (OIDC) for this package with `npm trust github`, derived from the publishing job (#687)',
341
+ appliesTo: [NPM_TRUST_CHECK],
342
+ outputs: ['npm trusted publisher (remote, via npm trust)'],
343
+ // safe-add keeps `--diff` from shadow-running it (that would call npm for real);
344
+ // explicitOnly because it changes registry state, like release-environment.
345
+ riskLevel: 'safe-add',
346
+ explicitOnly: true,
347
+ canFixDrift: true,
348
+ async run({ targetDir, pkg, assumeYes }) {
349
+ return { filesWritten: await applyNpmTrustedPublisher(targetDir, pkg, assumeYes) };
350
+ },
351
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "4.4.0",
3
+ "version": "4.5.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": [