diff --git a/.bumpy/publish-boolean.md b/.bumpy/publish-boolean.md new file mode 100644 index 0000000..2d42406 --- /dev/null +++ b/.bumpy/publish-boolean.md @@ -0,0 +1,7 @@ +--- +'fledgling': minor +--- + +**`publish: true|false` replaces `permissions`.** npm grants every trusted publisher staged publishing (`npm stage`) — a config created with `--allow-publish` alone reads back with both permissions. The only real choice is whether the publisher may also `npm publish` directly, so the config key is now a boolean (`"publish": true`, default) with `--publish` / `--no-publish` flags. The old `permissions: publish | stage | both` still works (`stage` → `false`, the rest → `true`) with a deprecation note, and `fledgling init` asks the yes/no question. `sync` no longer reports every package as out of sync over the implied `createStagedPackage`. + +**Failed trust reads are no longer reported as "not configured".** `sync` and the wizard read each package's trust config with a captured `npm trust list --json`, which can't run npm's browser 2FA itself. When that read failed (no remembered 2FA approval), it was silently treated as an empty config — so `sync` claimed nothing was set up, and would happily offer to "fix" everything. Reads now distinguish a failure (`EOTP` etc.) from an empty config: `sync` probes right after npm's interactive approval (with a short retry for the registry's "remember for 5 minutes" grace to kick in) and stops with a clear message if the approval didn't stick; a read that fails mid-run is listed as "couldn't be read" and left alone; and the add/wizard flow fails that package instead of writing blind. diff --git a/README.md b/README.md index 7870085..2615612 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ npx fledgling init "provider": "github", // github | gitlab | circleci "workflow": "release.yml", // the workflow whose job runs `npm publish` "environment": "publish", // CI environment for the trusted publisher (optional) - "permissions": "publish" // publish | stage | both + "publish": true // allow direct `npm publish` (staged publishing is always allowed) } } ``` @@ -96,13 +96,15 @@ npx fledgling init "pipelineDefinitionId": "…", "vcsOrigin": "github/owner/repo", "contextIds": ["…"], // optional - "permissions": "publish" + "publish": true } } ``` Add `"registry"` to either block to target a non-default npm registry. +> `"permissions": "publish" | "stage" | "both"` is the old form and still works (`stage` → `"publish": false`, the rest → `true`) with a deprecation note. npm grants every trusted publisher `npm stage`; the only real choice is direct publish. + ### Excluding packages fledgling skips any package marked `"private": true`. To exclude **public** packages too — @@ -128,7 +130,7 @@ globs, and tab completion. | `repo` | _auto-detected_ from git `origin` | override with `--repo` | | `workflow` | `release.yml` | the workflow whose job publishes | | `environment` | **none** | Optional and **unset by default** — the trusted publisher then isn't tied to a CI environment (it works, but adds no environment gate). Setting one (e.g. `publish`) is recommended for security, and `fledgling init` pre-fills it. | -| `permissions` | `publish` | `publish`, `stage` (held for 2FA approval), or `both` | +| `publish` | `true` | may the trusted publisher run `npm publish` directly? `false` = staged only (`npm stage`, held for 2FA approval). Staging is always allowed. | | `registry` | _your npm config_ | optional custom npm registry URL | **CircleCI** uses `orgId`, `projectId`, `pipelineDefinitionId`, `vcsOrigin`, and optional `contextIds` instead of `repo`/`workflow`/`environment`. @@ -228,7 +230,7 @@ Better set once in `package.json` (see [Configuration](#configuration)); as flag | Flag | Config key | Default | |------|-----------|---------| | `--provider

` | `provider` | `github` | -| `--permissions

` | `permissions` | `publish` | +| `--publish` / `--no-publish` | `publish` | `--publish` | | `--registry ` | `registry` | _npm config_ | | `--repo ` | _(auto-detected)_ | git `origin` | | `--workflow ` | `workflow` | `release.yml` | diff --git a/src/args.ts b/src/args.ts index bb878ef..a5088b2 100644 --- a/src/args.ts +++ b/src/args.ts @@ -27,7 +27,8 @@ export const npmArgs = { // No gunshi defaults here, so config can fill them in. provider: { type: 'string', description: '[config] CI provider: github (default), gitlab, circleci' }, registry: { type: 'string', description: '[config] npm registry URL (default: your npm config)' }, - permissions: { type: 'string', description: '[config] permissions to grant: publish (default), stage, both' }, + publish: { type: 'boolean', negatable: true, description: '[config] let the trusted publisher run `npm publish` directly (default); --no-publish = staged only (npm stage is always allowed)' }, + permissions: { type: 'string', description: '[deprecated] use --publish / --no-publish' }, repo: { type: 'string', description: '[config][github/gitlab] repo (default: auto-detected from git origin)' }, workflow: { type: 'string', description: '[config][github/gitlab] publishing workflow filename (default: release.yml)' }, env: { type: 'string', description: '[config][github/gitlab] CI environment (default: none)' }, diff --git a/src/commands/add.command.ts b/src/commands/add.command.ts index db56021..25f773f 100644 --- a/src/commands/add.command.ts +++ b/src/commands/add.command.ts @@ -25,7 +25,7 @@ function runPlain(values: Record, selectors: string[]): number { } const dryRun = !values.yes; - const settings = buildSettings(values, config, repo, dryRun); + const settings = buildSettings(values, config, repo, dryRun, m => console.error(pc.yellow(m))); // Trusted publishing only makes sense once a package lives in a repo/CI. A brand-new // name isn't necessarily there yet — so if we can't resolve a trust config for an // all-new claim, skip trust (with a note) rather than blocking the name claim. Once diff --git a/src/commands/init.command.ts b/src/commands/init.command.ts index 7435b59..0e3903a 100644 --- a/src/commands/init.command.ts +++ b/src/commands/init.command.ts @@ -1,7 +1,7 @@ import * as p from '@clack/prompts'; import pc from 'picocolors'; import { findWorkspaceRoot, detectRepo } from '../workspace.js'; -import { loadConfig, writeConfig, type FledglingConfig, type Permission, type Provider } from '../config.js'; +import { loadConfig, writeConfig, resolvePublish, type FledglingConfig, type Provider } from '../config.js'; import { hatchIntro, note } from '../ui.js'; const CANCEL = Symbol('cancel'); @@ -62,17 +62,13 @@ export async function runInit(): Promise { if (environment) config.environment = environment; } - const permissions = await p.select({ - message: 'Publish permissions to grant:', - options: [ - { value: 'publish', label: 'publish', hint: 'standard npm publish' }, - { value: 'stage', label: 'staged', hint: 'npm stage — held for 2FA approval' }, - { value: 'both', label: 'both' }, - ], - initialValue: existing.permissions ?? 'publish', + // npm always lets a trusted publisher `npm stage`; direct `npm publish` is the choice. + const publish = await p.confirm({ + message: 'Allow direct npm publish? (staged publishing — held for 2FA approval — is always allowed)', + initialValue: resolvePublish({}, existing).publish, }); - if (p.isCancel(permissions)) return cancel(); - config.permissions = permissions as Permission; + if (p.isCancel(publish)) return cancel(); + config.publish = publish; const registry = await ask('Custom npm registry (blank for default):', existing.registry, false); if (registry === CANCEL) return cancel(); diff --git a/src/commands/sync.command.ts b/src/commands/sync.command.ts index 1ed2c33..e0cb95d 100644 --- a/src/commands/sync.command.ts +++ b/src/commands/sync.command.ts @@ -1,5 +1,6 @@ import * as p from '@clack/prompts'; import pc from 'picocolors'; +import { setTimeout as sleep } from 'node:timers/promises'; import { findWorkspaceRoot, discoverPackages, detectRepo, type Pkg } from '../workspace.js'; import { npmAuthCheck, checkNpmVersion, listTrust, configureTrust, revokeTrust, warmNpmAuth, publishedNames } from '../npm.js'; import { npmArgs, selectorsOf, type Ctx } from '../args.js'; @@ -12,6 +13,7 @@ import { describeTrustDiff, describeConfig, applyIgnore, + trustReadHint, } from '../core.js'; import { loadConfig } from '../config.js'; import { hatchSpinner, hatchIntro, otpBoxReminder, reportNpmAuth, note } from '../ui.js'; @@ -53,7 +55,7 @@ export async function runSync(values: Record, selectors: string[]): } let targets = resolved.targets; - const settings = buildSettings(values, config, repo, false); // apply mode + const settings = buildSettings(values, config, repo, false, m => p.log.warn(pc.yellow(m))); // apply mode settings.skipPublish = true; const err = validateTrustSettings(settings); if (err) { @@ -103,28 +105,50 @@ export async function runSync(values: Record, selectors: string[]): } } + // Prove the approval actually carried over before scanning: one captured read of the + // package we just warmed with. If npm still wants 2FA here, every read below would + // fail the same way — and a failed read must never be reported as "not configured". + // The registry's "remember for 5 minutes" grace can take a beat to become visible + // to a fresh request, so give it a few tries before declaring it missing. + let probe = listTrust(targets[0].name, settings.registry, settings); + for (let attempt = 1; !probe.ok && probe.code === 'EOTP' && attempt < 4; attempt++) { + await sleep(1500 * attempt); + probe = listTrust(targets[0].name, settings.registry, settings); + } + if (!probe.ok) { + p.cancel(pc.red(`Can't read trust settings — npm: ${probe.message}`) + pc.yellow(trustReadHint(probe.code, settings))); + return 1; + } + p.log.step(`Checking trusted publishing for ${pc.bold(String(targets.length))} package(s)…`); - type Item = { t: Pkg; status: 'in-sync' | 'drift' | 'missing'; diff?: string[] }; - const items: Item[] = targets.map(t => { - const entries = listTrust(t.name, settings.registry, settings); - if (!entries.length) return { t, status: 'missing' }; - if (trustMatches(entries[0], settings)) return { t, status: 'in-sync' }; - return { t, status: 'drift', diff: describeTrustDiff(entries[0], settings) }; + type Item = { t: Pkg; status: 'in-sync' | 'drift' | 'missing' | 'unknown'; diff?: string[]; error?: string }; + const items: Item[] = targets.map((t, i) => { + const read = i === 0 ? probe : listTrust(t.name, settings.registry, settings); + if (!read.ok) return { t, status: 'unknown', error: read.message }; + if (!read.entries.length) return { t, status: 'missing' }; + if (trustMatches(read.entries[0], settings)) return { t, status: 'in-sync' }; + return { t, status: 'drift', diff: describeTrustDiff(read.entries[0], settings) }; }); const missing = items.filter(i => i.status === 'missing'); const drift = items.filter(i => i.status === 'drift'); + const unknown = items.filter(i => i.status === 'unknown'); const inSync = items.filter(i => i.status === 'in-sync').length; const todo = [...missing, ...drift]; - if (!todo.length) { + if (!todo.length && !unknown.length) { p.outro(pc.green(`All ${targets.length} package(s) are in sync 🐣`)); return 0; } const statusLines: string[] = []; if (inSync) statusLines.push(pc.green(`✓ ${inSync} in sync`)); + if (unknown.length) { + // A read that failed mid-run (2FA window lapsed, network) — not "missing", just unknown. + statusLines.push(pc.red(`${unknown.length} couldn't be read (left alone):`)); + for (const i of unknown) statusLines.push(` ${pc.red('?')} ${pc.cyan(i.t.name)} ${pc.dim(`— ${i.error}`)}`); + } if (missing.length) { statusLines.push(pc.yellow(`${missing.length} not configured:`)); for (const i of missing) statusLines.push(` ${pc.green('+')} ${pc.cyan(i.t.name)}`); @@ -138,6 +162,11 @@ export async function runSync(values: Record, selectors: string[]): } note(statusLines.join('\n'), 'Trust status'); + if (!todo.length) { + p.outro(pc.red(`${unknown.length} package(s) couldn't be checked — re-run once npm's 2FA is approved.`)); + return 1; + } + const apply = values.yes ? true : await p.confirm({ message: `Fix ${todo.length} package(s) to match your config?`, initialValue: true }); @@ -155,7 +184,9 @@ export async function runSync(values: Record, selectors: string[]): const applyOne = (i: Item): void => { if (i.status === 'drift') { // npm allows one config per package — revoke the existing one, then re-create - for (const e of listTrust(i.t.name, settings.registry, settings)) { + const read = listTrust(i.t.name, settings.registry, settings); + if (!read.ok) throw new Error(`couldn't re-read its trust config to replace it (npm: ${read.message})`); + for (const e of read.entries) { if (e.id) revokeTrust(i.t.name, e.id, settings.registry, settings); } } diff --git a/src/completion.ts b/src/completion.ts index 01c6260..83f3b7a 100644 --- a/src/completion.ts +++ b/src/completion.ts @@ -13,25 +13,19 @@ const completeProvider = (): Completion[] => [ { value: 'gitlab' }, { value: 'circleci' }, ]; -const completePermissions = (): Completion[] => [ - { value: 'publish' }, - { value: 'stage' }, - { value: 'both' }, -]; /** Handlers for the npm-shaped commands (default / add / sync). */ const npmConfig = { args: { packages: { handler: completePackages }, provider: { handler: completeProvider }, - permissions: { handler: completePermissions }, }, }; /** * Shell completion plugin. Subcommands and every flag are derived automatically * from the commands' `args` schemas; we only supply handlers for the dynamic - * values (workspace package names + the enum-ish `--provider` / `--permissions`). + * values (workspace package names + the enum-ish `--provider`). * * Installs via the auto-generated `complete` subcommand: * fledgling complete zsh >> ~/.zshrc (or bash | fish | powershell) diff --git a/src/config.ts b/src/config.ts index d14508f..3d810e3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,7 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import type { RuntimeCompat } from './jsr.js'; -/** npm trusted-publisher permissions to grant. */ +/** @deprecated Old `permissions` values — see `resolvePublish`. */ export type Permission = 'publish' | 'stage' | 'both'; export type Provider = 'github' | 'gitlab' | 'circleci'; @@ -29,6 +29,12 @@ export interface FledglingConfig { /** Package names/globs to exclude from fledgling entirely (besides `"private": true`). */ ignore?: string[]; provider?: Provider; + /** + * May the trusted publisher run `npm publish` directly? (default: true). npm always + * allows `npm stage` for a trusted publisher; this is the only choice npm offers. + */ + publish?: boolean; + /** @deprecated Use `publish`. `publish`/`both` → true, `stage` → false. */ permissions?: Permission; /** custom npm registry (defaults to the configured/default registry) */ registry?: string; @@ -44,6 +50,31 @@ export interface FledglingConfig { jsr?: JsrConfig; } +/** + * Resolve the direct-publish choice: `--publish`/`--no-publish` → config `publish` → the + * deprecated `permissions` (flag or config) → true. Returns a deprecation note when the + * old key decided it, for the caller to surface. + */ +export function resolvePublish( + values: { publish?: boolean; permissions?: string }, + config: Pick, +): { publish: boolean; deprecated?: string } { + if (values.publish !== undefined) return { publish: values.publish }; + if (config.publish !== undefined) return { publish: config.publish }; + const legacy = values.permissions ?? config.permissions; + if (legacy !== undefined) { + const publish = legacy !== 'stage'; + const where = values.permissions !== undefined ? '--permissions' : '`"permissions"` in your fledgling config'; + return { + publish, + deprecated: + `${where} is deprecated — npm always allows staged publishing, so the only choice is direct publish. ` + + `Use ${publish ? '`"publish": true` (or --publish)' : '`"publish": false` (or --no-publish)'} instead.`, + }; + } + return { publish: true }; +} + export function loadConfig(root: string): FledglingConfig { const file = join(root, 'package.json'); if (!existsSync(file)) return {}; diff --git a/src/core.ts b/src/core.ts index 42299c5..5b3468b 100644 --- a/src/core.ts +++ b/src/core.ts @@ -10,7 +10,7 @@ import { type TrustOptions, type TrustEntry, } from './npm.js'; -import { loadConfig, type Permission, type Provider, type FledglingConfig } from './config.js'; +import { loadConfig, resolvePublish, type Provider, type FledglingConfig } from './config.js'; export interface Settings { dryRun: boolean; @@ -22,7 +22,8 @@ export interface Settings { otp?: string; otpSecret?: string; provider: Provider; - permissions: Permission; + /** Allow direct `npm publish` (npm always allows `npm stage`). */ + publish: boolean; registry?: string; // github / gitlab repo?: string; @@ -57,20 +58,26 @@ export function validateTrustSettings(s: Settings): string | null { return null; } -/** Resolve a setting with precedence: CLI flag → fledgling config → built-in default. */ +/** + * Resolve a setting with precedence: CLI flag → fledgling config → built-in default. + * `warn` receives the deprecation note if the old `permissions` key decided `publish`. + */ export function buildSettings( values: Record, config: FledglingConfig, repo: string | undefined, dryRun: boolean, + warn: (msg: string) => void = () => {}, ): Settings { + const pub = resolvePublish(values, config); + if (pub.deprecated) warn(pub.deprecated); return { dryRun, skipPublish: !!values['skip-publish'], skipTrust: !!values['skip-trust'] || config.trust === false, force: !!values.force, provider: (values.provider ?? config.provider ?? 'github') as Provider, - permissions: (values.permissions ?? config.permissions ?? 'publish') as Permission, + publish: pub.publish, registry: values.registry ?? config.registry, repo, workflow: values.workflow ?? config.workflow ?? 'release.yml', @@ -92,7 +99,7 @@ export function buildSettings( export function toTrustOptions(s: Settings): TrustOptions { return { provider: s.provider, - permissions: s.permissions, + publish: s.publish, registry: s.registry, otp: s.otp, otpSecret: s.otpSecret, @@ -110,19 +117,21 @@ export function toTrustOptions(s: Settings): TrustOptions { // --- drift detection: compare an existing remote config to the desired settings --- -const PERMS: Record = { - publish: ['createPackage'], - stage: ['createStagedPackage'], - both: ['createPackage', 'createStagedPackage'], -}; const eq = (a: string | undefined, b: string | undefined) => (a || undefined) === (b || undefined); const sameList = (a: string[] | undefined, b: string[] | undefined) => [...(a ?? [])].sort().join(',') === [...(b ?? [])].sort().join(','); +/** + * Can this config publish directly? npm grants `createStagedPackage` to every trusted + * publisher (a config made with `--allow-publish` alone reads back with both), so the + * only thing to compare is whether `createPackage` is there. + */ +const canPublish = (e: TrustEntry): boolean => !!e.permissions?.includes('createPackage'); +const yesNo = (b: boolean) => (b ? 'yes' : 'no'); /** Does an existing trusted-publisher config match the desired settings? */ export function trustMatches(e: TrustEntry, s: Settings): boolean { if (e.type !== s.provider) return false; - if (!sameList(e.permissions, PERMS[s.permissions])) return false; + if (canPublish(e) !== s.publish) return false; if (s.provider === 'circleci') { return ( eq(e.orgId, s.orgId) && @@ -145,9 +154,7 @@ export function describeTrustDiff(e: TrustEntry, s: Settings): string[] { `${pc.dim(key)} ${fmt(from, pc.red)} ${pc.dim('→')} ${fmt(to, pc.green)}`; const d: string[] = []; if (e.type !== s.provider) d.push(delta('provider', e.type, s.provider)); - if (!sameList(e.permissions, PERMS[s.permissions])) { - d.push(delta('permissions', (e.permissions ?? []).join('+'), s.permissions)); - } + if (canPublish(e) !== s.publish) d.push(delta('publish', yesNo(canPublish(e)), yesNo(s.publish))); if (s.provider === 'circleci') { if (!eq(e.orgId, s.orgId)) d.push(delta('org-id', e.orgId, s.orgId)); if (!eq(e.projectId, s.projectId)) d.push(delta('project-id', e.projectId, s.projectId)); @@ -165,14 +172,14 @@ export function describeTrustDiff(e: TrustEntry, s: Settings): string[] { export type TrustView = Pick< Settings, - 'provider' | 'permissions' | 'registry' | 'repo' | 'workflow' | 'env' | 'orgId' | 'projectId' | 'pipelineDefinitionId' | 'vcsOrigin' | 'contextIds' + 'provider' | 'publish' | 'registry' | 'repo' | 'workflow' | 'env' | 'orgId' | 'projectId' | 'pipelineDefinitionId' | 'vcsOrigin' | 'contextIds' >; /** The desired trusted-publishing config, formatted for display. */ export function describeConfig(c: TrustView): string { const v = (val?: string) => (val ? pc.cyan(val) : pc.dim('(none)')); const row = (label: string, val?: string) => `${`${label}:`.padEnd(12)} ${v(val)}`; - const lines = [row('provider', c.provider), row('permissions', c.permissions)]; + const lines = [row('provider', c.provider), row('publish', c.publish ? 'yes (direct + staged)' : 'no (staged only)')]; if (c.provider === 'circleci') { lines.push( row('org-id', c.orgId), @@ -258,6 +265,17 @@ export function resolveTargets(discovered: Pkg[], selectors: string[], isNew: bo return { targets }; } +/** + * What to do about a failed trust read. `EOTP` means npm needed 2FA and couldn't prompt + * (our reads capture stdout) — the browser approval either wasn't ticked to be remembered + * or has lapsed. + */ +export function trustReadHint(code: string, s: Pick): string { + if (code !== 'EOTP') return ''; + if (s.otp || s.otpSecret) return ' — the one-time code was rejected; check --otp / --otp-secret'; + return ` — re-run and tick "don't ask again for 5 minutes" when npm opens the browser, or pass --otp`; +} + /** Claim + trust one package. Reports progress; returns a structured result. */ export function processTarget(t: Pkg, s: Settings, report: Reporter): TargetResult { const result: TargetResult = { name: t.name, claim: 'na', trust: 'na' }; @@ -302,7 +320,15 @@ export function processTarget(t: Pkg, s: Settings, report: Reporter): TargetResu // Only packages that already existed can have a trust config — a name we just // claimed starts empty, so skip the read (which needs npm's warmed 2FA) for it. // The write below relies on npm's own interactive 2FA; dry-run is best-effort. - const existing = existedBefore ? listTrust(t.name, s.registry, s) : []; + const read = existedBefore ? listTrust(t.name, s.registry, s) : { ok: true as const, entries: [] }; + if (!read.ok && !s.dryRun) { + // We can't tell whether a config exists, so don't write blind (npm allows one + // per package, and --force needs the id to revoke). Say why, and stop here. + report.fail(`${t.name} — couldn't read its trust config (npm: ${read.message})${trustReadHint(read.code, s)}`); + result.trust = 'fail'; + return result; + } + const existing = read.ok ? read.entries : []; if (existing.length && !s.force) { report.skip(`${t.name} — trust already configured (use --force to replace)`); result.trust = 'skip'; diff --git a/src/interactive.ts b/src/interactive.ts index ba94ff8..183117d 100644 --- a/src/interactive.ts +++ b/src/interactive.ts @@ -13,7 +13,7 @@ import { type TargetResult, type TrustView, } from './core.js'; -import { loadConfig, type Permission, type Provider } from './config.js'; +import { loadConfig, resolvePublish, type Provider } from './config.js'; import { hatchSpinner, hatchIntro, cmd, otpBoxReminder, reportNpmAuth, note } from './ui.js'; const cancelled = (v: unknown): boolean => p.isCancel(v); @@ -172,7 +172,9 @@ export async function runWizard(values: Record, selectors: string[] const skipPublish = onlyTrust; let skipTrust = !!values['skip-trust'] || config.trust === false; const provider = (values.provider ?? config.provider ?? 'github') as Provider; - const permissions = (values.permissions ?? config.permissions ?? 'publish') as Permission; + const pub = resolvePublish(values, config); + if (pub.deprecated) p.log.warn(pc.yellow(pub.deprecated)); + const publish = pub.publish; let repo: string | undefined = values.repo ?? repoInfo?.slug; const workflow: string = values.workflow ?? config.workflow ?? 'release.yml'; const env: string | undefined = values.env ?? config.environment; @@ -228,7 +230,7 @@ export async function runWizard(values: Record, selectors: string[] 'Plan', ); if (!skipTrust) { - const view: TrustView = { provider, permissions, registry, repo, workflow, env, orgId, projectId, pipelineDefinitionId, vcsOrigin, contextIds }; + const view: TrustView = { provider, publish, registry, repo, workflow, env, orgId, projectId, pipelineDefinitionId, vcsOrigin, contextIds }; note( `${describeConfig(view)}\n\n${pc.italic(pc.dim('Change these with `fledgling init`'))}`, 'Trusted publishing settings', @@ -249,7 +251,7 @@ export async function runWizard(values: Record, selectors: string[] skipTrust, force: !!values.force, provider, - permissions, + publish, registry, repo, workflow, diff --git a/src/npm.ts b/src/npm.ts index 87e5b13..1b2d9b0 100644 --- a/src/npm.ts +++ b/src/npm.ts @@ -4,7 +4,7 @@ import { createHmac } from 'node:crypto'; import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import type { Permission, Provider } from './config.js'; +import type { Provider } from './config.js'; const execFileP = promisify(execFile); @@ -22,7 +22,8 @@ export interface PublishOptions extends OtpCreds { export interface TrustOptions extends OtpCreds { provider: Provider; - permissions: Permission; + /** Allow direct `npm publish` (staged publishing is always allowed). */ + publish: boolean; registry?: string; dryRun: boolean; // github / gitlab @@ -309,23 +310,45 @@ export function warmNpmAuth(name: string, registry?: string): boolean { } } +/** + * The outcome of reading a package's trust config: the entries (possibly none), or a + * read failure — most often `EOTP`, meaning npm needed 2FA and couldn't prompt for it. + * Callers must not treat a failed read as "not configured". + */ +export type TrustRead = { ok: true; entries: TrustEntry[] } | { ok: false; code: string; message: string }; + /** * Existing trusted-publisher configs (npm allows at most one per package). * `npm trust list` needs 2FA; we capture its stdout to parse the JSON, so it can't run * npm's interactive auth itself — warm npm's session cache first (see `warmNpmAuth`), - * or pass `otp`. Returns `[]` if it can't read (or there's no config). + * or pass `otp`. A read that fails (no cached 2FA, offline, …) is reported as such — + * `ok: false` — rather than as an empty config. */ -export function listTrust(name: string, registry?: string, creds?: OtpCreds): TrustEntry[] { +export function listTrust(name: string, registry?: string, creds?: OtpCreds): TrustRead { + let out: string; try { - const out = execFileSync('npm', withOtp(withRegistry(['trust', 'list', name, '--json'], registry), nextOtp(creds)), { + out = execFileSync('npm', withOtp(withRegistry(['trust', 'list', name, '--json'], registry), nextOtp(creds)), { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8', }).trim(); - if (!out) return []; + } catch (e) { + // With --json, npm prints its error as JSON on stdout: { error: { code, summary } }. + const stdout = String((e as { stdout?: string }).stdout ?? '').trim(); + try { + const err = JSON.parse(stdout)?.error; + if (err?.code) return { ok: false, code: err.code, message: err.summary ?? err.code }; + } catch { + /* not JSON */ + } + return { ok: false, code: 'UNKNOWN', message: (e as Error).message }; + } + if (!out) return { ok: true, entries: [] }; + try { const parsed = JSON.parse(out); // npm emits a bare object (or array) of { id, type, permissions } - return Array.isArray(parsed) ? parsed : [parsed]; + if (parsed?.error) return { ok: false, code: parsed.error.code ?? 'UNKNOWN', message: parsed.error.summary ?? '' }; + return { ok: true, entries: Array.isArray(parsed) ? parsed : [parsed] }; } catch { - return []; + return { ok: false, code: 'EPARSE', message: 'could not parse `npm trust list --json` output' }; } } @@ -359,8 +382,9 @@ export function configureTrust(name: string, opts: TrustOptions): void { args.push(opts.provider === 'gitlab' ? '--project' : '--repo', opts.repo!); if (opts.env) args.push('--env', opts.env); } - if (opts.permissions === 'publish' || opts.permissions === 'both') args.push('--allow-publish'); - if (opts.permissions === 'stage' || opts.permissions === 'both') args.push('--allow-stage-publish'); + // npm grants staged publishing to every trusted publisher; direct publish is the choice. + if (opts.publish) args.push('--allow-publish'); + args.push('--allow-stage-publish'); withRegistry(args, opts.registry); withOtp(args, nextOtp(opts)); args.push(opts.dryRun ? '--dry-run' : '-y');