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');