Skip to content
Open
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
5 changes: 5 additions & 0 deletions .bumpy/jsr-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'fledgling': minor
---

New `fledgling jsr` command — the same first-publish story, on [JSR](https://jsr.io). Scaffolds missing `jsr.json` manifests from `package.json`, claims each package on jsr.io (JSR has no create-on-first-publish), links your GitHub repo so CI publishes token-lessly via OIDC (`npx jsr publish`, no `JSR_TOKEN` secret), and syncs the score metadata JSR only stores server-side — package **description** (from `package.json`) and **runtime compatibility** (from `fledgling.jsr.runtimeCompat` config). Idempotent (reconciles metadata drift on every run), rate-limit aware, and stops cleanly at JSR's 20-new-packages-per-week scope quota. Auth via a full-access JSR token in `$JSR_TOKEN`; configure a default scope for unscoped packages with `fledgling.jsr.scope`. Thanks to [@Saeris](https://github.com/Saeris) for the proposal and reference implementation this is built on.
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Run bare `fledgling` in a terminal and you get an interactive wizard (powered by
| `fledgling add [packages…]` | Claim names + set up trusted publishing for the given packages |
| `fledgling sync` | Reconcile trusted publishing on npm with your config |
| `fledgling init` | Write the trusted-publishing config to your `package.json` |
| `fledgling jsr [packages…]` | Claim packages on [JSR](https://jsr.io) + link the repo for OIDC publishing |

## Why

Expand Down Expand Up @@ -240,6 +241,69 @@ fledgling sync "@scope/*" # a subset

Use it after changing your `fledgling` config, or to set up trust on packages that were published without it. (It uses the same config/flags as the main command.)

## `fledgling jsr` — the same story on JSR

[JSR](https://jsr.io) has **no "create on first publish"** — every package must already exist on jsr.io before anything (CI or a human) can publish a version to it. For a monorepo that's the exact papercut fledgling exists to remove, so `fledgling jsr` does for JSR what the main command does for npm:

1. **Scaffold** — create a minimal `jsr.json` (name, version, a source `exports` entry) from each `package.json` where missing. An existing `jsr.json`/`deno.json` is authoritative and never rewritten.
2. **Claim** — create each missing package on jsr.io via the JSR management API.
3. **Link** — link your GitHub repo to each package, which is JSR's whole trusted-publishing setup: any workflow in the linked repo can then publish **token-lessly via OIDC** (`npx jsr publish` with `permissions: id-token: write` — no `JSR_TOKEN` secret in CI).
4. **Sync score metadata** — reconcile each package's **description** (from `package.json`) and **runtime compatibility** (from config) to jsr.io. [JSR scores packages](https://jsr.io/docs/scoring) partly on these, and — unlike npm — they live *only* on jsr.io (the `jsr.json` manifest has no `description` field), so they can't ride along at publish time. fledgling reconciles them here, where it already holds the token; only what's changed is pushed.

```sh
npx fledgling jsr # plan (interactive confirm in a terminal)
npx fledgling jsr --yes # apply: scaffold + claim + link
npx fledgling jsr "@scope/*" --yes # a subset
```

It's **idempotent** — claimed packages are skipped and the repo link is re-asserted, so re-run it whenever you add a package.

### Prerequisites

- A **JSR scope** you're a member of (create one at [jsr.io/new](https://jsr.io/new)) — fledgling doesn't create scopes.
- A JSR **personal access token** with **full access** in `$JSR_TOKEN` (jsr.io → Account → Tokens). A token restricted to "package publish" can publish versions but **cannot** create packages or link a repo — those are management operations. The token is used once, locally; it does **not** go into CI.

JSR names always have a scope. Packages whose npm name already has one (`@scope/pkg`) map straight across; for unscoped packages, set the scope once:

```jsonc
{
"fledgling": {
"jsr": {
"scope": "myscope", // JSR scope for unscoped npm names (or override with --scope)
"manifest": true, // set false to never scaffold jsr.json
"metadata": true, // set false to never sync description / runtime compat
"runtimeCompat": { // default runtime-compatibility flags (part of the JSR score)
"node": true, "deno": true, "bun": true, "browser": true, "workerd": true
}
}
}
}
```

The **description** is taken automatically from each package's `package.json` (collapsed to a single line and clamped to JSR's 250-char limit). **`runtimeCompat`** is deliberately *not* inferred — set it explicitly, either as the `fledgling.jsr.runtimeCompat` default above or per-package in that package's own `package.json` (a package's own value wins). Only runtimes you mark are changed; re-running reconciles drift, so edit `package.json` and re-run to update jsr.io.

### Flags

| Flag | Description |
|------|-------------|
| `-y, --yes` | Apply without prompting |
| `--dry-run` | Print a plan without prompting (non-interactive) |
| `--scope <scope>` | JSR scope for packages whose npm name has none (or to override it) |
| `--repo <owner/repo>` | GitHub repo to link (default: auto-detected from git `origin`) |
| `--token <token>` | JSR access token (prefer `$JSR_TOKEN` over the flag) |
| `--skip-manifest` | Don't scaffold missing `jsr.json` manifests |
| `--skip-link` | Only claim names — don't link the repo |
| `--skip-metadata` | Don't sync score metadata (description / runtime compat) |

### Good to know

- **Rate limits are handled.** JSR's management API throttles bulk operations aggressively; fledgling backs off (honouring `Retry-After`) and paces itself between packages.
- **20 new packages per scope per rolling week.** JSR hard-caps new package creation, so a larger monorepo can't be bootstrapped in one run. fledgling detects the quota, stops cleanly, and lists what's left — re-run after the reset (or ask jsr.io for a raise); it picks up where it left off.
- **JSR's OIDC is GitHub-only** today, and there's no per-workflow/environment config — the repo link is the whole setup.
- **JSR publishes TS source**, so scaffolded manifests point at your source entry (your `development`/`source` export condition, or `./src/index.ts`), not built output.

> 🙏 Thanks to [@Saeris](https://github.com/Saeris) for the groundwork that made this feature possible — the [proposal and reference implementation](https://github.com/mirrordown/mirrordown) (including the live findings on JSR's rate limits and weekly quota) that `fledgling jsr` is built on.

## Shell completions

`fledgling` ships tab-completion (via [`@bomb.sh/tab`](https://github.com/bombshell-dev/tab)) that completes package names and flags. Install it for your shell:
Expand Down
36 changes: 36 additions & 0 deletions src/args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* gunshi keeps the matched subcommand name in `positionals` (e.g. `add foo` →
* `['add','foo']` with `commandPath: ['add']`), so drop the command path to get
* the real package selectors. The default command has an empty path, so this is a
* no-op there.
*/
export type Ctx = { values: Record<string, any>; positionals?: string[]; commandPath?: string[] };
export const selectorsOf = (ctx: Ctx): string[] => (ctx.positionals ?? []).slice(ctx.commandPath?.length ?? 0);

/** The npm-shaped flag set shared by the default, `add`, and `sync` commands. */
export const npmArgs = {
// run options (per invocation)
yes: { type: 'boolean', short: 'y', description: 'Apply changes without prompting (default: interactive / dry run)' },
'dry-run': { type: 'boolean', description: 'Print a plan without prompts (non-interactive)' },
new: { type: 'boolean', description: 'Treat unmatched names as brand-new packages to claim' },
'skip-publish': { type: 'boolean', description: 'Only set up trusted publishing' },
'skip-trust': { type: 'boolean', description: 'Only claim names' },
force: { type: 'boolean', description: 'Replace an existing trusted publisher (revoke + re-create)' },
'placeholder-version': { type: 'string', default: '0.0.0', description: 'Placeholder version to publish' },
tag: { type: 'string', description: 'dist-tag for placeholders' },
otp: { type: 'string', description: 'npm 2FA one-time password (used for every npm call this run)' },
'otp-secret': { type: 'string', description: 'TOTP secret to generate 2FA codes from (use $FLEDGLING_OTP_SECRET to avoid shell history)' },
// config — best set once in package.json "fledgling" (run `fledgling init`); flags override.
// 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' },
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)' },
'org-id': { type: 'string', description: '[config][circleci] organization UUID' },
'project-id': { type: 'string', description: '[config][circleci] project UUID' },
'pipeline-definition-id': { type: 'string', description: '[config][circleci] pipeline definition UUID' },
'vcs-origin': { type: 'string', description: '[config][circleci] VCS origin, e.g. github/owner/repo' },
'context-id': { type: 'string', multiple: true, description: '[config][circleci] context UUID (repeatable)' },
} as const;
171 changes: 6 additions & 165 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,173 +2,14 @@
import { cli } from 'gunshi';
import pc from 'picocolors';
import { maybeHandleCompletion } from './completion.js';
import { findWorkspaceRoot, discoverPackages, detectRepo } from './workspace.js';
import { npmAuthCheck, checkNpmVersion } from './npm.js';
import { resolveTargets, processTarget, summarize, validateTrustSettings, buildSettings, applyIgnore, type Reporter } from './core.js';
import { loadConfig } from './config.js';
import { twoFactorDisabledWarning } from './ui.js';
import { runWizard } from './interactive.js';
import { runInit } from './init.js';
import { runSync } from './sync.js';
import { entryCommand, addCommand } from './commands/add.command.js';
import { syncCommand } from './commands/sync.command.js';
import { initCommand } from './commands/init.command.js';
import { jsrCommand } from './commands/jsr.command.js';

declare const __VERSION__: string;
const VERSION = __VERSION__;

const args = {
// run options (per invocation)
yes: { type: 'boolean', short: 'y', description: 'Apply changes without prompting (default: interactive / dry run)' },
'dry-run': { type: 'boolean', description: 'Print a plan without prompts (non-interactive)' },
new: { type: 'boolean', description: 'Treat unmatched names as brand-new packages to claim' },
'skip-publish': { type: 'boolean', description: 'Only set up trusted publishing' },
'skip-trust': { type: 'boolean', description: 'Only claim names' },
force: { type: 'boolean', description: 'Replace an existing trusted publisher (revoke + re-create)' },
'placeholder-version': { type: 'string', default: '0.0.0', description: 'Placeholder version to publish' },
tag: { type: 'string', description: 'dist-tag for placeholders' },
otp: { type: 'string', description: 'npm 2FA one-time password (used for every npm call this run)' },
'otp-secret': { type: 'string', description: 'TOTP secret to generate 2FA codes from (use $FLEDGLING_OTP_SECRET to avoid shell history)' },
// config — best set once in package.json "fledgling" (run `fledgling init`); flags override.
// 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' },
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)' },
'org-id': { type: 'string', description: '[config][circleci] organization UUID' },
'project-id': { type: 'string', description: '[config][circleci] project UUID' },
'pipeline-definition-id': { type: 'string', description: '[config][circleci] pipeline definition UUID' },
'vcs-origin': { type: 'string', description: '[config][circleci] VCS origin, e.g. github/owner/repo' },
'context-id': { type: 'string', multiple: true, description: '[config][circleci] context UUID (repeatable)' },
} as const;

/** Non-interactive path: a plan by default, applies with --yes. */
function runPlain(values: Record<string, any>, selectors: string[]): number {
const root = findWorkspaceRoot();
const config = loadConfig(root);
const discovered = applyIgnore(discoverPackages(root), config.ignore);
const repo = values.repo ?? detectRepo(root)?.slug;

const resolved = resolveTargets(discovered, selectors, !!values.new, root);
if (resolved.error) {
console.error(pc.red(resolved.error));
return 1;
}
if (resolved.targets.length === 0) {
console.error(pc.red('No public packages found in this workspace.'));
return 1;
}

const dryRun = !values.yes;
const settings = buildSettings(values, config, repo, dryRun);
// 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
// it's in a repo, `fledgling sync` (or a passed --repo) wires up trust.
const allNew = resolved.targets.every(t => t.isNew);
if (!settings.skipTrust && allNew && validateTrustSettings(settings)) {
console.log(pc.dim('No repo/CI context for a new name — skipping trusted publishing. Run `fledgling sync` once it lives in a repo.'));
settings.skipTrust = true;
}
const trustError = validateTrustSettings(settings);
if (trustError) {
console.error(pc.red(trustError));
return 1;
}
// Only the apply path (`--yes`) hits npm. Require login, and warn (don't stop) on a
// disabled-2FA account so it gets a clear heads-up instead of a raw 403 on first claim.
if (!dryRun) {
const auth = npmAuthCheck(settings.registry);
if (!auth.who) {
console.error(pc.red('Not logged in to npm. Run `npm login` (with 2FA) and retry.'));
return 1;
}
if (auth.twoFactorDisabled) console.error(twoFactorDisabledWarning);
}
// Trusted publishing needs 2FA. Interactively (a TTY), npm prompts for it itself —
// a browser approval shared across the run. Non-interactively (CI / piped) it can't
// prompt, so pass --otp; npm surfaces a clear error during the operation otherwise.

console.log(`${dryRun ? pc.yellow('dry run') : pc.green('apply')} — ${pc.bold('fledgling')} · ${resolved.targets.length} package(s)\n`);
const reporter: Reporter = {
step: m => console.log(' ' + pc.green('✓') + ' ' + m),
skip: m => console.log(' ' + pc.dim('· ' + m)),
fail: m => console.error(' ' + pc.red('✗') + ' ' + m),
};
const sum = summarize(resolved.targets.map(t => processTarget(t, settings, reporter)));

console.log(
`\n${dryRun ? pc.yellow('dry run complete') : pc.green('done')} — ` +
`claimed ${sum.claimed} (skipped ${sum.claimSkipped}), trusted ${sum.trusted} (skipped ${sum.trustSkipped})` +
(sum.failed ? pc.red(`, failed ${sum.failed}`) : ''),
);
if (dryRun) console.log(pc.dim('Re-run with --yes to apply (needs npm login + 2FA).'));
return sum.failed > 0 ? 1 : 0;
}

/**
* gunshi keeps the matched subcommand name in `positionals` (e.g. `add foo` →
* `['add','foo']` with `commandPath: ['add']`), so drop the command path to get
* the real package selectors. The default command has an empty path, so this is a
* no-op there.
*/
type Ctx = { values: Record<string, any>; positionals?: string[]; commandPath?: string[] };
const selectorsOf = (ctx: Ctx): string[] => (ctx.positionals ?? []).slice(ctx.commandPath?.length ?? 0);

/** The create flow (wizard in a TTY, plan/apply otherwise). Shared by the default command and `add`. */
async function createRun(ctx: Ctx): Promise<void> {
const npmErr = checkNpmVersion();
if (npmErr) {
console.error(pc.red(npmErr));
process.exitCode = 1;
return;
}
const values = ctx.values;
const selectors = selectorsOf(ctx);
const interactive = !!process.stdout.isTTY && !values.yes && !values['dry-run'];
const code = interactive ? await runWizard(values, selectors) : runPlain(values, selectors);
if (code) process.exitCode = code;
}

// Default command (`fledgling`, no subcommand) → the interactive wizard.
const entry = {
name: 'fledgling',
description: 'Claim package names and set up trusted publishing',
args,
run: createRun,
};

const addCommand = {
name: 'add',
description: 'Claim names + set up trusted publishing for the given packages',
args,
run: createRun,
};

const syncCommand = {
name: 'sync',
description: 'Reconcile trusted publishing on npm with your config',
args,
async run(ctx: Ctx) {
const npmErr = checkNpmVersion();
if (npmErr) {
console.error(pc.red(npmErr));
process.exitCode = 1;
return;
}
const code = await runSync(ctx.values, selectorsOf(ctx));
if (code) process.exitCode = code;
},
};

const initCommand = {
name: 'init',
description: 'Write trusted-publishing config to your package.json',
async run() {
const code = await runInit();
if (code) process.exitCode = code;
},
};

const rawArgv = process.argv.slice(2);

// shell completion (`fledgling complete …`) is handled by @bomb.sh/tab, before gunshi
Expand All @@ -177,11 +18,11 @@ if (maybeHandleCompletion(rawArgv)) {
}

try {
await cli(rawArgv, entry, {
await cli(rawArgv, entryCommand, {
name: 'fledgling',
version: VERSION,
description: '🐣 Create and set up packages on npm with trusted publishing',
subCommands: { add: addCommand, sync: syncCommand, init: initCommand },
subCommands: { add: addCommand, sync: syncCommand, init: initCommand, jsr: jsrCommand },
renderHeader: null, // no auto-printed banner on every run
});
} catch (e) {
Expand Down
Loading