Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .bumpy/publish-boolean.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
```
Expand All @@ -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 —
Expand All @@ -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`.
Expand Down Expand Up @@ -228,7 +230,7 @@ Better set once in `package.json` (see [Configuration](#configuration)); as flag
| Flag | Config key | Default |
|------|-----------|---------|
| `--provider <p>` | `provider` | `github` |
| `--permissions <p>` | `permissions` | `publish` |
| `--publish` / `--no-publish` | `publish` | `--publish` |
| `--registry <url>` | `registry` | _npm config_ |
| `--repo <owner/repo>` | _(auto-detected)_ | git `origin` |
| `--workflow <file>` | `workflow` | `release.yml` |
Expand Down
3 changes: 2 additions & 1 deletion src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)' },
Expand Down
2 changes: 1 addition & 1 deletion src/commands/add.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function runPlain(values: Record<string, any>, 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
Expand Down
18 changes: 7 additions & 11 deletions src/commands/init.command.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -62,17 +62,13 @@ export async function runInit(): Promise<number> {
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();
Expand Down
49 changes: 40 additions & 9 deletions src/commands/sync.command.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -53,7 +55,7 @@ export async function runSync(values: Record<string, any>, 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) {
Expand Down Expand Up @@ -103,28 +105,50 @@ export async function runSync(values: Record<string, any>, 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)}`);
Expand All @@ -138,6 +162,11 @@ export async function runSync(values: Record<string, any>, 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 });
Expand All @@ -155,7 +184,9 @@ export async function runSync(values: Record<string, any>, 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);
}
}
Expand Down
8 changes: 1 addition & 7 deletions src/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 32 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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<FledglingConfig, 'publish' | 'permissions'>,
): { 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 {};
Expand Down
Loading