Skip to content

Commit e83f978

Browse files
authored
Merge pull request #7 from dmno-dev/feat/jsr-support
feat: `fledgling jsr` — claim packages on JSR + link repo for OIDC publishing
2 parents 7e88a40 + 2447a6a commit e83f978

11 files changed

Lines changed: 922 additions & 173 deletions

File tree

.bumpy/jsr-support.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'fledgling': minor
3+
---
4+
5+
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.

README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Run bare `fledgling` in a terminal and you get an interactive wizard (powered by
3434
| `fledgling add [packages…]` | Claim names + set up trusted publishing for the given packages |
3535
| `fledgling sync` | Reconcile trusted publishing on npm with your config |
3636
| `fledgling init` | Write the trusted-publishing config to your `package.json` |
37+
| `fledgling jsr [packages…]` | Claim packages on [JSR](https://jsr.io) + link the repo for OIDC publishing |
3738

3839
## Why
3940

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

241242
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.)
242243

244+
## `fledgling jsr` — the same story on JSR
245+
246+
[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:
247+
248+
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.
249+
2. **Claim** — create each missing package on jsr.io via the JSR management API.
250+
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).
251+
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.
252+
253+
```sh
254+
npx fledgling jsr # plan (interactive confirm in a terminal)
255+
npx fledgling jsr --yes # apply: scaffold + claim + link
256+
npx fledgling jsr "@scope/*" --yes # a subset
257+
```
258+
259+
It's **idempotent** — claimed packages are skipped and the repo link is re-asserted, so re-run it whenever you add a package.
260+
261+
### Prerequisites
262+
263+
- A **JSR scope** you're a member of (create one at [jsr.io/new](https://jsr.io/new)) — fledgling doesn't create scopes.
264+
- 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.
265+
266+
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:
267+
268+
```jsonc
269+
{
270+
"fledgling": {
271+
"jsr": {
272+
"scope": "myscope", // JSR scope for unscoped npm names (or override with --scope)
273+
"manifest": true, // set false to never scaffold jsr.json
274+
"metadata": true, // set false to never sync description / runtime compat
275+
"runtimeCompat": { // default runtime-compatibility flags (part of the JSR score)
276+
"node": true, "deno": true, "bun": true, "browser": true, "workerd": true
277+
}
278+
}
279+
}
280+
}
281+
```
282+
283+
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.
284+
285+
### Flags
286+
287+
| Flag | Description |
288+
|------|-------------|
289+
| `-y, --yes` | Apply without prompting |
290+
| `--dry-run` | Print a plan without prompting (non-interactive) |
291+
| `--scope <scope>` | JSR scope for packages whose npm name has none (or to override it) |
292+
| `--repo <owner/repo>` | GitHub repo to link (default: auto-detected from git `origin`) |
293+
| `--token <token>` | JSR access token (prefer `$JSR_TOKEN` over the flag) |
294+
| `--skip-manifest` | Don't scaffold missing `jsr.json` manifests |
295+
| `--skip-link` | Only claim names — don't link the repo |
296+
| `--skip-metadata` | Don't sync score metadata (description / runtime compat) |
297+
298+
### Good to know
299+
300+
- **Rate limits are handled.** JSR's management API throttles bulk operations aggressively; fledgling backs off (honouring `Retry-After`) and paces itself between packages.
301+
- **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.
302+
- **JSR's OIDC is GitHub-only** today, and there's no per-workflow/environment config — the repo link is the whole setup.
303+
- **JSR publishes TS source**, so scaffolded manifests point at your source entry (your `development`/`source` export condition, or `./src/index.ts`), not built output.
304+
305+
> 🙏 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.
306+
243307
## Shell completions
244308

245309
`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:

src/args.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* gunshi keeps the matched subcommand name in `positionals` (e.g. `add foo` →
3+
* `['add','foo']` with `commandPath: ['add']`), so drop the command path to get
4+
* the real package selectors. The default command has an empty path, so this is a
5+
* no-op there.
6+
*/
7+
export type Ctx = { values: Record<string, any>; positionals?: string[]; commandPath?: string[] };
8+
export const selectorsOf = (ctx: Ctx): string[] => (ctx.positionals ?? []).slice(ctx.commandPath?.length ?? 0);
9+
10+
/** The npm-shaped flag set shared by the default, `add`, and `sync` commands. */
11+
export const npmArgs = {
12+
// run options (per invocation)
13+
yes: { type: 'boolean', short: 'y', description: 'Apply changes without prompting (default: interactive / dry run)' },
14+
'dry-run': { type: 'boolean', description: 'Print a plan without prompts (non-interactive)' },
15+
new: { type: 'boolean', description: 'Treat unmatched names as brand-new packages to claim' },
16+
'skip-publish': { type: 'boolean', description: 'Only set up trusted publishing' },
17+
'skip-trust': { type: 'boolean', description: 'Only claim names' },
18+
force: { type: 'boolean', description: 'Replace an existing trusted publisher (revoke + re-create)' },
19+
'placeholder-version': { type: 'string', default: '0.0.0', description: 'Placeholder version to publish' },
20+
tag: { type: 'string', description: 'dist-tag for placeholders' },
21+
otp: { type: 'string', description: 'npm 2FA one-time password (used for every npm call this run)' },
22+
'otp-secret': { type: 'string', description: 'TOTP secret to generate 2FA codes from (use $FLEDGLING_OTP_SECRET to avoid shell history)' },
23+
// config — best set once in package.json "fledgling" (run `fledgling init`); flags override.
24+
// No gunshi defaults here, so config can fill them in.
25+
provider: { type: 'string', description: '[config] CI provider: github (default), gitlab, circleci' },
26+
registry: { type: 'string', description: '[config] npm registry URL (default: your npm config)' },
27+
permissions: { type: 'string', description: '[config] permissions to grant: publish (default), stage, both' },
28+
repo: { type: 'string', description: '[config][github/gitlab] repo (default: auto-detected from git origin)' },
29+
workflow: { type: 'string', description: '[config][github/gitlab] publishing workflow filename (default: release.yml)' },
30+
env: { type: 'string', description: '[config][github/gitlab] CI environment (default: none)' },
31+
'org-id': { type: 'string', description: '[config][circleci] organization UUID' },
32+
'project-id': { type: 'string', description: '[config][circleci] project UUID' },
33+
'pipeline-definition-id': { type: 'string', description: '[config][circleci] pipeline definition UUID' },
34+
'vcs-origin': { type: 'string', description: '[config][circleci] VCS origin, e.g. github/owner/repo' },
35+
'context-id': { type: 'string', multiple: true, description: '[config][circleci] context UUID (repeatable)' },
36+
} as const;

src/cli.ts

Lines changed: 6 additions & 165 deletions
Original file line numberDiff line numberDiff line change
@@ -2,173 +2,14 @@
22
import { cli } from 'gunshi';
33
import pc from 'picocolors';
44
import { maybeHandleCompletion } from './completion.js';
5-
import { findWorkspaceRoot, discoverPackages, detectRepo } from './workspace.js';
6-
import { npmAuthCheck, checkNpmVersion } from './npm.js';
7-
import { resolveTargets, processTarget, summarize, validateTrustSettings, buildSettings, applyIgnore, type Reporter } from './core.js';
8-
import { loadConfig } from './config.js';
9-
import { twoFactorDisabledWarning } from './ui.js';
10-
import { runWizard } from './interactive.js';
11-
import { runInit } from './init.js';
12-
import { runSync } from './sync.js';
5+
import { entryCommand, addCommand } from './commands/add.command.js';
6+
import { syncCommand } from './commands/sync.command.js';
7+
import { initCommand } from './commands/init.command.js';
8+
import { jsrCommand } from './commands/jsr.command.js';
139

1410
declare const __VERSION__: string;
1511
const VERSION = __VERSION__;
1612

17-
const args = {
18-
// run options (per invocation)
19-
yes: { type: 'boolean', short: 'y', description: 'Apply changes without prompting (default: interactive / dry run)' },
20-
'dry-run': { type: 'boolean', description: 'Print a plan without prompts (non-interactive)' },
21-
new: { type: 'boolean', description: 'Treat unmatched names as brand-new packages to claim' },
22-
'skip-publish': { type: 'boolean', description: 'Only set up trusted publishing' },
23-
'skip-trust': { type: 'boolean', description: 'Only claim names' },
24-
force: { type: 'boolean', description: 'Replace an existing trusted publisher (revoke + re-create)' },
25-
'placeholder-version': { type: 'string', default: '0.0.0', description: 'Placeholder version to publish' },
26-
tag: { type: 'string', description: 'dist-tag for placeholders' },
27-
otp: { type: 'string', description: 'npm 2FA one-time password (used for every npm call this run)' },
28-
'otp-secret': { type: 'string', description: 'TOTP secret to generate 2FA codes from (use $FLEDGLING_OTP_SECRET to avoid shell history)' },
29-
// config — best set once in package.json "fledgling" (run `fledgling init`); flags override.
30-
// No gunshi defaults here, so config can fill them in.
31-
provider: { type: 'string', description: '[config] CI provider: github (default), gitlab, circleci' },
32-
registry: { type: 'string', description: '[config] npm registry URL (default: your npm config)' },
33-
permissions: { type: 'string', description: '[config] permissions to grant: publish (default), stage, both' },
34-
repo: { type: 'string', description: '[config][github/gitlab] repo (default: auto-detected from git origin)' },
35-
workflow: { type: 'string', description: '[config][github/gitlab] publishing workflow filename (default: release.yml)' },
36-
env: { type: 'string', description: '[config][github/gitlab] CI environment (default: none)' },
37-
'org-id': { type: 'string', description: '[config][circleci] organization UUID' },
38-
'project-id': { type: 'string', description: '[config][circleci] project UUID' },
39-
'pipeline-definition-id': { type: 'string', description: '[config][circleci] pipeline definition UUID' },
40-
'vcs-origin': { type: 'string', description: '[config][circleci] VCS origin, e.g. github/owner/repo' },
41-
'context-id': { type: 'string', multiple: true, description: '[config][circleci] context UUID (repeatable)' },
42-
} as const;
43-
44-
/** Non-interactive path: a plan by default, applies with --yes. */
45-
function runPlain(values: Record<string, any>, selectors: string[]): number {
46-
const root = findWorkspaceRoot();
47-
const config = loadConfig(root);
48-
const discovered = applyIgnore(discoverPackages(root), config.ignore);
49-
const repo = values.repo ?? detectRepo(root)?.slug;
50-
51-
const resolved = resolveTargets(discovered, selectors, !!values.new, root);
52-
if (resolved.error) {
53-
console.error(pc.red(resolved.error));
54-
return 1;
55-
}
56-
if (resolved.targets.length === 0) {
57-
console.error(pc.red('No public packages found in this workspace.'));
58-
return 1;
59-
}
60-
61-
const dryRun = !values.yes;
62-
const settings = buildSettings(values, config, repo, dryRun);
63-
// Trusted publishing only makes sense once a package lives in a repo/CI. A brand-new
64-
// name isn't necessarily there yet — so if we can't resolve a trust config for an
65-
// all-new claim, skip trust (with a note) rather than blocking the name claim. Once
66-
// it's in a repo, `fledgling sync` (or a passed --repo) wires up trust.
67-
const allNew = resolved.targets.every(t => t.isNew);
68-
if (!settings.skipTrust && allNew && validateTrustSettings(settings)) {
69-
console.log(pc.dim('No repo/CI context for a new name — skipping trusted publishing. Run `fledgling sync` once it lives in a repo.'));
70-
settings.skipTrust = true;
71-
}
72-
const trustError = validateTrustSettings(settings);
73-
if (trustError) {
74-
console.error(pc.red(trustError));
75-
return 1;
76-
}
77-
// Only the apply path (`--yes`) hits npm. Require login, and warn (don't stop) on a
78-
// disabled-2FA account so it gets a clear heads-up instead of a raw 403 on first claim.
79-
if (!dryRun) {
80-
const auth = npmAuthCheck(settings.registry);
81-
if (!auth.who) {
82-
console.error(pc.red('Not logged in to npm. Run `npm login` (with 2FA) and retry.'));
83-
return 1;
84-
}
85-
if (auth.twoFactorDisabled) console.error(twoFactorDisabledWarning);
86-
}
87-
// Trusted publishing needs 2FA. Interactively (a TTY), npm prompts for it itself —
88-
// a browser approval shared across the run. Non-interactively (CI / piped) it can't
89-
// prompt, so pass --otp; npm surfaces a clear error during the operation otherwise.
90-
91-
console.log(`${dryRun ? pc.yellow('dry run') : pc.green('apply')}${pc.bold('fledgling')} · ${resolved.targets.length} package(s)\n`);
92-
const reporter: Reporter = {
93-
step: m => console.log(' ' + pc.green('✓') + ' ' + m),
94-
skip: m => console.log(' ' + pc.dim('· ' + m)),
95-
fail: m => console.error(' ' + pc.red('✗') + ' ' + m),
96-
};
97-
const sum = summarize(resolved.targets.map(t => processTarget(t, settings, reporter)));
98-
99-
console.log(
100-
`\n${dryRun ? pc.yellow('dry run complete') : pc.green('done')} — ` +
101-
`claimed ${sum.claimed} (skipped ${sum.claimSkipped}), trusted ${sum.trusted} (skipped ${sum.trustSkipped})` +
102-
(sum.failed ? pc.red(`, failed ${sum.failed}`) : ''),
103-
);
104-
if (dryRun) console.log(pc.dim('Re-run with --yes to apply (needs npm login + 2FA).'));
105-
return sum.failed > 0 ? 1 : 0;
106-
}
107-
108-
/**
109-
* gunshi keeps the matched subcommand name in `positionals` (e.g. `add foo` →
110-
* `['add','foo']` with `commandPath: ['add']`), so drop the command path to get
111-
* the real package selectors. The default command has an empty path, so this is a
112-
* no-op there.
113-
*/
114-
type Ctx = { values: Record<string, any>; positionals?: string[]; commandPath?: string[] };
115-
const selectorsOf = (ctx: Ctx): string[] => (ctx.positionals ?? []).slice(ctx.commandPath?.length ?? 0);
116-
117-
/** The create flow (wizard in a TTY, plan/apply otherwise). Shared by the default command and `add`. */
118-
async function createRun(ctx: Ctx): Promise<void> {
119-
const npmErr = checkNpmVersion();
120-
if (npmErr) {
121-
console.error(pc.red(npmErr));
122-
process.exitCode = 1;
123-
return;
124-
}
125-
const values = ctx.values;
126-
const selectors = selectorsOf(ctx);
127-
const interactive = !!process.stdout.isTTY && !values.yes && !values['dry-run'];
128-
const code = interactive ? await runWizard(values, selectors) : runPlain(values, selectors);
129-
if (code) process.exitCode = code;
130-
}
131-
132-
// Default command (`fledgling`, no subcommand) → the interactive wizard.
133-
const entry = {
134-
name: 'fledgling',
135-
description: 'Claim package names and set up trusted publishing',
136-
args,
137-
run: createRun,
138-
};
139-
140-
const addCommand = {
141-
name: 'add',
142-
description: 'Claim names + set up trusted publishing for the given packages',
143-
args,
144-
run: createRun,
145-
};
146-
147-
const syncCommand = {
148-
name: 'sync',
149-
description: 'Reconcile trusted publishing on npm with your config',
150-
args,
151-
async run(ctx: Ctx) {
152-
const npmErr = checkNpmVersion();
153-
if (npmErr) {
154-
console.error(pc.red(npmErr));
155-
process.exitCode = 1;
156-
return;
157-
}
158-
const code = await runSync(ctx.values, selectorsOf(ctx));
159-
if (code) process.exitCode = code;
160-
},
161-
};
162-
163-
const initCommand = {
164-
name: 'init',
165-
description: 'Write trusted-publishing config to your package.json',
166-
async run() {
167-
const code = await runInit();
168-
if (code) process.exitCode = code;
169-
},
170-
};
171-
17213
const rawArgv = process.argv.slice(2);
17314

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

17920
try {
180-
await cli(rawArgv, entry, {
21+
await cli(rawArgv, entryCommand, {
18122
name: 'fledgling',
18223
version: VERSION,
18324
description: '🐣 Create and set up packages on npm with trusted publishing',
184-
subCommands: { add: addCommand, sync: syncCommand, init: initCommand },
25+
subCommands: { add: addCommand, sync: syncCommand, init: initCommand, jsr: jsrCommand },
18526
renderHeader: null, // no auto-printed banner on every run
18627
});
18728
} catch (e) {

0 commit comments

Comments
 (0)