From 0b4bd20171c624c34c8e55df839535db6d6f5a4a Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 6 Jul 2026 13:06:15 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20add=20`fledgling=20jsr`=20=E2=80=94?= =?UTF-8?q?=20claim=20packages=20on=20JSR=20+=20link=20repo=20for=20OIDC?= =?UTF-8?q?=20publishing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpy/jsr-support.md | 5 + README.md | 56 ++++++++++ src/cli.ts | 25 ++++- src/completion.ts | 19 ++++ src/config.ts | 9 ++ src/jsr-cmd.ts | 251 ++++++++++++++++++++++++++++++++++++++++++ src/jsr.ts | 231 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 .bumpy/jsr-support.md create mode 100644 src/jsr-cmd.ts create mode 100644 src/jsr.ts diff --git a/.bumpy/jsr-support.md b/.bumpy/jsr-support.md new file mode 100644 index 0000000..362d081 --- /dev/null +++ b/.bumpy/jsr-support.md @@ -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), and links your GitHub repo so CI publishes token-lessly via OIDC (`npx jsr publish`, no `JSR_TOKEN` secret). Idempotent, 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. diff --git a/README.md b/README.md index 2f93a3e..a9d2d66 100644 --- a/README.md +++ b/README.md @@ -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 @@ -240,6 +241,61 @@ 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). + +```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 + } + } +} +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `-y, --yes` | Apply without prompting | +| `--dry-run` | Print a plan without prompting (non-interactive) | +| `--scope ` | JSR scope for packages whose npm name has none (or to override it) | +| `--repo ` | GitHub repo to link (default: auto-detected from git `origin`) | +| `--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 | + +### 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: diff --git a/src/cli.ts b/src/cli.ts index cb2cc62..8e1ec57 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import { twoFactorDisabledWarning } from './ui.js'; import { runWizard } from './interactive.js'; import { runInit } from './init.js'; import { runSync } from './sync.js'; +import { runJsr } from './jsr-cmd.js'; declare const __VERSION__: string; const VERSION = __VERSION__; @@ -160,6 +161,28 @@ const syncCommand = { }, }; +// JSR's model needs none of the npm machinery (no npm CLI, no OTP, no provider config) — +// so the `jsr` command takes its own small arg set instead of the npm-shaped one. +const jsrArgs = { + 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)' }, + scope: { type: 'string', description: '[config] JSR scope for packages whose npm name has none (or to override it)' }, + repo: { type: 'string', description: 'GitHub repo to link for OIDC publishing (default: auto-detected from git origin)' }, + token: { type: 'string', description: 'JSR personal access token, FULL access (default: $JSR_TOKEN)' }, + 'skip-manifest': { type: 'boolean', description: "Don't scaffold missing jsr.json manifests" }, + 'skip-link': { type: 'boolean', description: "Only claim names — don't link the GitHub repo" }, +} as const; + +const jsrCommand = { + name: 'jsr', + description: 'Claim packages on JSR + link the repo for token-less OIDC publishing', + args: jsrArgs, + async run(ctx: Ctx) { + const code = await runJsr(ctx.values, selectorsOf(ctx)); + if (code) process.exitCode = code; + }, +}; + const initCommand = { name: 'init', description: 'Write trusted-publishing config to your package.json', @@ -181,7 +204,7 @@ try { 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) { diff --git a/src/completion.ts b/src/completion.ts index c9ff743..845ac6c 100644 --- a/src/completion.ts +++ b/src/completion.ts @@ -50,10 +50,29 @@ function withPackageArgsAndFlags(cmd: Cmd): void { for (const [flag, desc] of FLAGS) cmd.option(flag, desc); } +const JSR_FLAGS: [string, string][] = [ + ['--yes', 'apply changes without prompting'], + ['--dry-run', 'print a plan without prompting'], + ['--scope', 'JSR scope for unscoped packages'], + ['--repo', 'GitHub repo to link (owner/repo)'], + ['--token', 'JSR personal access token (full access)'], + ['--skip-manifest', "don't scaffold missing jsr.json manifests"], + ['--skip-link', "only claim names — don't link the repo"], +]; + function defineCompletions(): void { // subcommands: `add` / `sync` take package selectors + flags, `init` takes nothing withPackageArgsAndFlags(t.command('add', 'claim names + set up trusted publishing') as unknown as Cmd); withPackageArgsAndFlags(t.command('sync', 'reconcile trusted publishing with your config') as unknown as Cmd); + const jsr = t.command('jsr', 'claim packages on JSR + link the repo for OIDC') as unknown as Cmd; + jsr.argument( + 'packages', + complete => { + for (const p of workspacePackages()) complete(p.name, 'package'); + }, + true, + ); + for (const [flag, desc] of JSR_FLAGS) jsr.option(flag, desc); t.command('init', 'write trusted-publishing config to package.json'); // bare `fledgling …` (default command) also accepts selectors + flags diff --git a/src/config.ts b/src/config.ts index 124a08f..2dfa2f0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,14 @@ export type Permission = 'publish' | 'stage' | 'both'; export type Provider = 'github' | 'gitlab' | 'circleci'; +/** JSR settings, used by `fledgling jsr`. */ +export interface JsrConfig { + /** JSR scope (with or without the @) for packages whose npm name doesn't carry one. */ + scope?: string; + /** Set to false to skip scaffolding missing jsr.json manifests. */ + manifest?: boolean; +} + /** Persisted config, read from the `"fledgling"` key of the root package.json. */ export interface FledglingConfig { /** Set to false to skip trusted publishing by default (just claim names). */ @@ -25,6 +33,7 @@ export interface FledglingConfig { pipelineDefinitionId?: string; vcsOrigin?: string; contextIds?: string[]; + jsr?: JsrConfig; } export function loadConfig(root: string): FledglingConfig { diff --git a/src/jsr-cmd.ts b/src/jsr-cmd.ts new file mode 100644 index 0000000..a1b4ec3 --- /dev/null +++ b/src/jsr-cmd.ts @@ -0,0 +1,251 @@ +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 { resolveTargets, applyIgnore } from './core.js'; +import { loadConfig } from './config.js'; +import { hatchSpinner, hatchIntro, cmd, note } from './ui.js'; +import { + jsrClient, + resolveJsrName, + ensureJsrManifest, + isWeeklyLimit, + jsrErrorReason, + JSR_COOLDOWN_MS, + type JsrName, +} from './jsr.js'; + +interface Item { + pkg: Pkg; + jsr: JsrName; + exists?: boolean; + needsManifest?: boolean; +} + +/** + * `fledgling jsr` — the JSR analogue of the main flow: create ("claim") each package + * on jsr.io up front (JSR has no create-on-first-publish), scaffold missing jsr.json + * manifests, and link the GitHub repo so CI publishes token-lessly via OIDC. + * Idempotent: claimed packages are skipped and the repo link is re-asserted. + */ +export async function runJsr(values: Record, selectors: string[]): Promise { + console.log(); + await hatchIntro('fledgling jsr'); + + const root = findWorkspaceRoot(); + const config = loadConfig(root); + + const spin = hatchSpinner(); + spin.start('Scanning workspace'); + const discovered = applyIgnore(discoverPackages(root), config.ignore); + const repoInfo = detectRepo(root); + spin.stop(`Found ${pc.bold(String(discovered.length))} package(s)${repoInfo ? ` · ${pc.dim(repoInfo.slug)}` : ''}`); + + const resolved = resolveTargets(discovered, selectors, false, root); + if (resolved.error) { + p.cancel(pc.red(resolved.error)); + return 1; + } + if (!resolved.targets.length) { + p.cancel(pc.red('No public packages found in this workspace.')); + return 1; + } + + // --- map workspace packages to JSR names (manifest → --scope/config → npm scope) --- + const scope: string | undefined = values.scope ?? config.jsr?.scope; + const items: Item[] = []; + for (const pkg of resolved.targets) { + const { jsr, error } = resolveJsrName(pkg, scope); + if (jsr) items.push({ pkg, jsr }); + else p.log.warn(pc.yellow(error!)); + } + if (!items.length) { + p.cancel(pc.red('No packages could be mapped to a JSR name.')); + return 1; + } + + // --- the GitHub repo to link (JSR's OIDC is the repo link, and GitHub-only) --- + let repoSlug: string | undefined = values.repo; + if (repoSlug && !/^[^/]+\/[^/]+$/.test(repoSlug)) { + p.cancel(pc.red(`--repo should be owner/repo, got "${repoSlug}"`)); + return 1; + } + if (!repoSlug && repoInfo) { + if (repoInfo.host === 'github') repoSlug = repoInfo.slug; + else p.log.warn(pc.yellow(`JSR's OIDC publishing is GitHub-only — can't link ${repoInfo.slug} (${repoInfo.host}).`)); + } + const skipLink = !!values['skip-link'] || !repoSlug; + if (skipLink && !values['skip-link']) { + p.log.warn(pc.yellow('No GitHub repo to link — claiming names only. Re-run `fledgling jsr` from the repo to enable OIDC publishing.')); + } + + // --- auth + mode: a full-access token applies; without one we can only preview --- + const token: string | undefined = values.token ?? process.env.JSR_TOKEN; + const client = jsrClient(token, wait => p.log.message(pc.dim(`JSR rate limit — waiting ${Math.round(wait / 1000)}s…`))); + let who: string | null = null; + if (token) { + const authSpin = hatchSpinner(); + authSpin.start('Checking JSR token…'); + who = await client.whoami(); + authSpin.stop( + who + ? `JSR token OK — authenticated as ${pc.green(who)}` + : pc.yellow('JSR token did not authenticate (invalid or expired?)'), + ); + } + let dryRun = !!values['dry-run'] || !who; + if (!token && !values['dry-run']) { + p.log.warn( + pc.yellow('JSR_TOKEN not set — this run will be a dry run.\n') + + pc.dim('Create a FULL-access token (jsr.io → Account → Tokens; "package publish" scope is not enough) and re-run.'), + ); + } + + // --- what's already claimed? (public reads — works for dry runs too) --- + const existsSpin = hatchSpinner(); + existsSpin.start('Checking which packages exist on JSR…'); + for (const it of items) it.exists = await client.packageExists(it.jsr); + const toClaim = items.filter(it => !it.exists); + existsSpin.stop( + toClaim.length === 0 + ? `All ${items.length} package(s) already on JSR` + : `${items.length - toClaim.length} of ${items.length} already on JSR — ${toClaim.length} to claim`, + ); + + const skipManifest = !!values['skip-manifest'] || config.jsr?.manifest === false; + if (!skipManifest) { + for (const it of items) it.needsManifest = ensureJsrManifest(it.pkg, it.jsr, true).action === 'created'; + } + const manifestCount = items.filter(it => it.needsManifest).length; + + if (!toClaim.length && skipLink && !manifestCount) { + p.outro(pc.green('Nothing to do — everything is already on JSR. 🐣')); + return 0; + } + + // --- plan + confirm --- + note( + [ + `${pc.bold(String(items.length))} package(s): ${items.map(it => `📦 ${pc.cyan(it.jsr.full)}`).join(', ')}`, + manifestCount ? pc.green(`• scaffold ${manifestCount} missing jsr.json manifest(s)`) : '', + toClaim.length ? pc.green(`• claim ${toClaim.length} unclaimed name(s) on jsr.io`) : '', + skipLink ? pc.dim('• repo linking skipped') : pc.green(`• link ${repoSlug} for token-less OIDC publishing`), + ] + .filter(Boolean) + .join('\n'), + 'Plan', + ); + + let apply = !dryRun && !!values.yes; + if (!dryRun && !values.yes) { + if (process.stdout.isTTY) { + const ans = await p.confirm({ message: `Apply now as ${pc.green(who!)}?`, initialValue: true }); + if (p.isCancel(ans)) { + p.cancel('Cancelled.'); + return 1; + } + apply = !!ans; + } else { + p.log.info(pc.dim('Non-interactive without --yes — dry run only.')); + } + } + dryRun = !apply; + + if (dryRun) { + for (const it of items) { + const actions = [ + it.needsManifest ? 'scaffold jsr.json' : '', + it.exists ? '' : 'claim', + skipLink ? '' : 'link repo', + ].filter(Boolean); + if (actions.length) p.log.message(`${pc.dim('would')} ${actions.join(' + ')} ${pc.cyan(it.jsr.full)}`); + else p.log.message(pc.dim(`nothing to do ${it.jsr.full}`)); + } + p.outro(pc.yellow(`Dry run — ${toClaim.length} to claim. Re-run with --yes (and JSR_TOKEN) to apply.`)); + return 0; + } + + // --- preflight: the token must be able to manage every target scope --- + for (const s of [...new Set(items.map(it => it.jsr.scope))]) { + const access = await client.scopeAccess(s); + if (access !== 'ok') { + p.cancel( + pc.red( + access === 'bad-token' + ? 'The JSR token is invalid or expired.' + : `This token can't manage @${s} — use a FULL-access token for a scope you're a member of (jsr.io → Account → Tokens).`, + ), + ); + return 1; + } + } + + // --- apply: scaffold, claim, link — stopping cleanly at JSR's weekly quota --- + let claimed = 0; + let linked = 0; + const failures: string[] = []; + let blockedFrom = -1; + for (let i = 0; i < items.length; i++) { + const it = items[i]; + try { + if (it.needsManifest) { + const m = ensureJsrManifest(it.pkg, it.jsr, false); + if (m.entryExists === false) { + p.log.warn(pc.yellow(`${it.jsr.full} — scaffolded jsr.json points at ${m.entry}, which doesn't exist yet. Edit it before publishing.`)); + } + } + let didClaim = false; + if (!it.exists) { + const r = await client.createPackage(it.jsr); + if (isWeeklyLimit(r)) { + blockedFrom = i; + break; + } + if (!r.ok) throw new Error(`claim failed — ${jsrErrorReason(r)}`); + claimed++; + didClaim = true; + } + let didLink = false; + if (!skipLink) { + const [owner, repoName] = repoSlug!.split('/'); + const l = await client.linkRepo(it.jsr, owner, repoName); + if (!l.ok) throw new Error(`repo link failed — ${jsrErrorReason(l)}`); + linked++; + didLink = true; + } + const did = [didClaim && 'claimed', didLink && (didClaim ? 'linked' : 're-linked'), it.needsManifest && 'jsr.json created'] + .filter(Boolean) + .join(' + '); + p.log.success(`${it.jsr.full} — ${did || 'already set up'}`); + } catch (e) { + p.log.error(`${it.jsr.full} — ${(e as Error).message}`); + failures.push(it.jsr.full); + } + if (i < items.length - 1) await sleep(JSR_COOLDOWN_MS); + } + + const blocked = blockedFrom >= 0 ? items.slice(blockedFrom).filter(it => !it.exists) : []; + if (blocked.length) { + note( + `JSR allows at most ${pc.bold('20 new packages per scope per rolling week')}, and this scope just hit it.\n` + + `${blocked.length} package(s) not claimed yet:\n` + + blocked.map(it => ` ${pc.dim('·')} ${pc.cyan(it.jsr.full)}`).join('\n') + + `\n\nRe-run ${cmd('fledgling jsr')} after the quota resets (or ask jsr.io for a raise) — already-claimed packages are skipped.`, + '🛑 Weekly quota reached', + ); + } + + if (!skipLink && linked && !failures.length) { + p.log.info( + `CI can now publish token-lessly: a workflow in ${pc.dim(repoSlug!)} with ${pc.bold('permissions: id-token: write')} running ${cmd('npx jsr publish')}.`, + ); + } + p.outro( + failures.length + ? pc.red(`Done with ${failures.length} failure(s) — claimed ${claimed}, linked ${linked}.`) + : blocked.length + ? pc.yellow(`Claimed ${claimed}, linked ${linked} — ${blocked.length} blocked by the weekly quota.`) + : pc.green(`Done — claimed ${claimed}, linked ${linked}. 🐣`), + ); + return failures.length || blocked.length ? 1 : 0; +} diff --git a/src/jsr.ts b/src/jsr.ts new file mode 100644 index 0000000..37d8be2 --- /dev/null +++ b/src/jsr.ts @@ -0,0 +1,231 @@ +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; +import type { Pkg } from './workspace.js'; + +/** + * JSR management API (https://api.jsr.io) client + helpers, the JSR analogue of npm.ts. + * + * JSR's model differs from npm's in ways that shape this module: + * - There is no "create on first publish" — every package must be *created* up front + * (a metadata record with no versions) via `POST /scopes/{scope}/packages`. + * - OIDC trusted publishing is just a repo link (`PATCH githubRepository`) — no + * per-provider/workflow/environment config, and GitHub-only today. Any workflow in + * the linked repo can then publish token-lessly (`npx jsr publish`). + * - Auth is a bearer token (jsr.io → Account → Tokens) — none of npm's 2FA machinery. + * The token must be FULL access: one restricted to "package publish" can publish + * versions but 403s (`missingPermission`) on create/link, which are management ops. + * It's used once, locally — it does not go into CI. + */ + +export const JSR_API = 'https://api.jsr.io'; + +/** Pause between packages — JSR's management API 429s bulk claims after only a few calls. */ +export const JSR_COOLDOWN_MS = 1000; +const MAX_RETRIES = 6; // 429 backoff attempts per request + +/** A JSR package identity: `@scope/name`, split for API paths (which take them bare). */ +export interface JsrName { + scope: string; + name: string; + /** "@scope/name" */ + full: string; +} + +export interface JsrResponse { + status: number; + ok: boolean; + body: string; +} + +export interface JsrClient { + request(method: string, path: string, body?: unknown): Promise; + /** The token's display name (`GET /user`), or null if it can't be read. */ + whoami(): Promise; + /** Can this token manage `scope`? (`GET /user/member/{scope}` — 200 = member.) */ + scopeAccess(scope: string): Promise<'ok' | 'bad-token' | 'no-access'>; + /** Is the name claimed on JSR? (404 = free.) */ + packageExists(n: JsrName): Promise; + createPackage(n: JsrName): Promise; + /** Link the GitHub repo — this is what enables token-less OIDC publishing from CI. */ + linkRepo(n: JsrName, owner: string, repo: string): Promise; +} + +/** + * Rate-limit-aware client. Every request retries 429s honouring `Retry-After` when + * present, else exponential backoff (capped 30s); `onRetry` lets the UI narrate waits. + */ +export function jsrClient(token?: string, onRetry?: (waitMs: number) => void): JsrClient { + async function request(method: string, path: string, body?: unknown, attempt = 0): Promise { + const res = await fetch(`${JSR_API}${path}`, { + method, + headers: { + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + if (res.status === 429 && attempt < MAX_RETRIES) { + const retryAfter = Number(res.headers.get('retry-after')); + const wait = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : Math.min(1000 * 2 ** attempt, 30_000); + onRetry?.(wait); + await sleep(wait); + return request(method, path, body, attempt + 1); + } + return { status: res.status, ok: res.ok, body: await res.text() }; + } + + return { + request, + async whoami() { + try { + const res = await request('GET', '/user'); + if (!res.ok) return null; + const user = JSON.parse(res.body); + return user?.name ?? null; + } catch { + return null; + } + }, + async scopeAccess(scope) { + const res = await request('GET', `/user/member/${scope}`); + if (res.status === 200) return 'ok'; + if (res.status === 401) return 'bad-token'; + return 'no-access'; + }, + async packageExists(n) { + return (await request('GET', `/scopes/${n.scope}/packages/${n.name}`)).status === 200; + }, + createPackage(n) { + return request('POST', `/scopes/${n.scope}/packages`, { package: n.name }); + }, + linkRepo(n, owner, repo) { + return request('PATCH', `/scopes/${n.scope}/packages/${n.name}`, { + githubRepository: { owner, name: repo }, + }); + }, + }; +} + +/** + * JSR caps NEW packages at 20 per scope per rolling week. Once hit, every remaining + * create fails the same way — callers should stop and report, not grind through. + */ +export function isWeeklyLimit(res: JsrResponse): boolean { + return res.status === 400 && res.body.includes('weeklyPackageLimitExceeded'); +} + +/** A short human reason from a JSR error body (`{ code, message }`), else the raw body. */ +export function jsrErrorReason(res: JsrResponse): string { + try { + const parsed = JSON.parse(res.body); + if (parsed?.code === 'missingPermission') { + return 'token lacks permission — claim/link need a FULL-access token, not "package publish"'; + } + if (parsed?.message) return String(parsed.message); + } catch { + /* not JSON */ + } + return res.body || `HTTP ${res.status}`; +} + +/** Parse "@scope/name" into parts; null if it isn't that shape. */ +export function parseJsrName(full: string): JsrName | null { + const m = full.match(/^@([^/]+)\/([^/]+)$/); + return m ? { scope: m[1], name: m[2], full } : null; +} + +/** + * Validate against JSR's naming rules (lowercase letters/digits/hyphens, starting with + * a letter, 2+ chars). Format only, and looser than the server — the API stays + * authoritative for length caps etc. + */ +export function validateJsrName(n: JsrName): string | undefined { + for (const [what, part] of [['scope', n.scope], ['name', n.name]] as const) { + if (part.length < 2) return `JSR ${what} "${part}" is too short (2+ characters)`; + if (!/^[a-z][a-z0-9-]*$/.test(part) || part.endsWith('-')) { + return `"${part}" isn't a valid JSR ${what} — lowercase letters, digits, and inner hyphens only`; + } + } + return undefined; +} + +/** The parsed jsr.json / deno.json in `dir` that carries a `name`, if any. */ +function existingManifest(dir: string): { file: string; manifest: Record } | null { + for (const f of ['jsr.json', 'deno.json']) { + const file = join(dir, f); + if (!existsSync(file)) continue; + try { + const manifest = JSON.parse(readFileSync(file, 'utf8')); + if (manifest?.name) return { file, manifest }; + } catch { + /* malformed — treat as absent, scaffolding will report */ + } + } + return null; +} + +/** + * The JSR name for a workspace package. Precedence: an existing jsr.json/deno.json + * `name` (it's what JSR reads at publish time, so it's authoritative) → an explicit + * scope (flag/config) applied to the npm name's base → the npm name's own scope. + */ +export function resolveJsrName(pkg: Pkg, scopeOverride?: string): { jsr?: JsrName; error?: string } { + const existing = existingManifest(pkg.dir); + if (existing) { + const jsr = parseJsrName(existing.manifest.name); + return jsr ? { jsr } : { error: `${existing.file} has a name that isn't @scope/name: "${existing.manifest.name}"` }; + } + const scoped = parseJsrName(pkg.name); + const scope = scopeOverride?.replace(/^@/, '') ?? scoped?.scope; + if (!scope) { + return { error: `${pkg.name} — JSR names need a scope; pass --scope, set fledgling.jsr.scope, or add a jsr.json` }; + } + const base = scoped?.name ?? pkg.name; + const jsr: JsrName = { scope, name: base, full: `@${scope}/${base}` }; + const invalid = validateJsrName(jsr); + return invalid ? { error: `${pkg.name} — ${invalid}; add a jsr.json with the JSR name you want` } : { jsr }; +} + +/** + * The source entry point for a scaffolded manifest. JSR publishes TS *source*, so + * prefer the package's `development`/`source` export condition (or a direct .ts + * export) over built output, then fall back to conventional source locations. + */ +function sourceEntry(pkg: Pkg): { entry: string; exists: boolean } { + const exports = pkg.manifest.exports; + const dot = typeof exports === 'string' ? exports : exports?.['.']; + const fromExports = + typeof dot === 'string' ? (dot.endsWith('.ts') ? dot : undefined) : (dot?.development ?? dot?.source); + const candidates = [fromExports, './src/index.ts', './index.ts', './mod.ts'].filter(Boolean) as string[]; + for (const entry of candidates) { + if (existsSync(join(pkg.dir, entry))) return { entry, exists: true }; + } + return { entry: candidates[0], exists: false }; +} + +export interface ManifestResult { + action: 'ok' | 'created'; + /** Entry point written into a created manifest. */ + entry?: string; + /** False when the entry file doesn't exist on disk yet — the user needs to edit it. */ + entryExists?: boolean; +} + +/** + * Ensure `pkg` has a JSR manifest, scaffolding jsr.json from package.json when missing. + * An existing jsr.json/deno.json (with a name) is authoritative and never rewritten. + */ +export function ensureJsrManifest(pkg: Pkg, jsr: JsrName, dryRun: boolean): ManifestResult { + if (existingManifest(pkg.dir)) return { action: 'ok' }; + const { entry, exists } = sourceEntry(pkg); + if (!dryRun) { + const manifest = { + name: jsr.full, + version: pkg.manifest.version ?? '0.0.0', + exports: { '.': entry }, + }; + writeFileSync(join(pkg.dir, 'jsr.json'), JSON.stringify(manifest, null, 2) + '\n'); + } + return { action: 'created', entry, entryExists: exists }; +} From e5a9458a0f49adaaaced0b59887f7b24b2e84981 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 7 Jul 2026 11:08:58 -0700 Subject: [PATCH 2/5] fix(jsr): send a User-Agent identifying fledgling (JSR asks management-API clients to) --- src/jsr.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/jsr.ts b/src/jsr.ts index 37d8be2..52d483b 100644 --- a/src/jsr.ts +++ b/src/jsr.ts @@ -24,6 +24,11 @@ export const JSR_API = 'https://api.jsr.io'; export const JSR_COOLDOWN_MS = 1000; const MAX_RETRIES = 6; // 429 backoff attempts per request +// JSR asks management-API clients to identify themselves ("/; ") and +// reserves the right to block tools that don't — so we send a real User-Agent. +declare const __VERSION__: string; +const USER_AGENT = `fledgling/${typeof __VERSION__ === 'string' ? __VERSION__ : '0.0.0'}; https://github.com/dmno-dev/fledgling`; + /** A JSR package identity: `@scope/name`, split for API paths (which take them bare). */ export interface JsrName { scope: string; @@ -60,6 +65,7 @@ export function jsrClient(token?: string, onRetry?: (waitMs: number) => void): J const res = await fetch(`${JSR_API}${path}`, { method, headers: { + 'user-agent': USER_AGENT, ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}), }, From 33d44763256badbd4ccbb25506080f39592f8c74 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 7 Jul 2026 11:15:31 -0700 Subject: [PATCH 3/5] feat(jsr): sync package score metadata (description + runtimeCompat) to jsr.io MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSR scores packages partly on having a description and marked runtime compatibility, and both live only on jsr.io — the jsr.json manifest has no description field, so they can't be set at publish time. Reconcile them in `fledgling jsr` (which already holds the full-access token): description from package.json, runtimeCompat from config (no inference). Drift-aware and idempotent — only changed fields are PATCHed, each as its own request since JSR's updatePackage body is a oneOf. Opt out with --skip-metadata. --- .bumpy/jsr-support.md | 2 +- README.md | 10 +++++- src/cli.ts | 1 + src/completion.ts | 1 + src/config.ts | 8 +++++ src/jsr-cmd.ts | 72 ++++++++++++++++++++++++++++++++++++++----- src/jsr.ts | 70 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 154 insertions(+), 10 deletions(-) diff --git a/.bumpy/jsr-support.md b/.bumpy/jsr-support.md index 362d081..110de6f 100644 --- a/.bumpy/jsr-support.md +++ b/.bumpy/jsr-support.md @@ -2,4 +2,4 @@ '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), and links your GitHub repo so CI publishes token-lessly via OIDC (`npx jsr publish`, no `JSR_TOKEN` secret). Idempotent, 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. +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. diff --git a/README.md b/README.md index a9d2d66..d681453 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,7 @@ Use it after changing your `fledgling` config, or to set up trust on packages th 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) @@ -269,12 +270,18 @@ JSR names always have a scope. Packages whose npm name already has one (`@scope/ "fledgling": { "jsr": { "scope": "myscope", // JSR scope for unscoped npm names (or override with --scope) - "manifest": true // set false to never scaffold jsr.json + "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 | @@ -286,6 +293,7 @@ JSR names always have a scope. Packages whose npm name already has one (`@scope/ | `--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 diff --git a/src/cli.ts b/src/cli.ts index 8e1ec57..987b11b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -171,6 +171,7 @@ const jsrArgs = { token: { type: 'string', description: 'JSR personal access token, FULL access (default: $JSR_TOKEN)' }, 'skip-manifest': { type: 'boolean', description: "Don't scaffold missing jsr.json manifests" }, 'skip-link': { type: 'boolean', description: "Only claim names — don't link the GitHub repo" }, + 'skip-metadata': { type: 'boolean', description: "Don't sync score metadata (description / runtime compat) to JSR" }, } as const; const jsrCommand = { diff --git a/src/completion.ts b/src/completion.ts index 845ac6c..79949a2 100644 --- a/src/completion.ts +++ b/src/completion.ts @@ -58,6 +58,7 @@ const JSR_FLAGS: [string, string][] = [ ['--token', 'JSR personal access token (full access)'], ['--skip-manifest', "don't scaffold missing jsr.json manifests"], ['--skip-link', "only claim names — don't link the repo"], + ['--skip-metadata', "don't sync description / runtime compat to JSR"], ]; function defineCompletions(): void { diff --git a/src/config.ts b/src/config.ts index 2dfa2f0..d14508f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,6 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; +import type { RuntimeCompat } from './jsr.js'; /** npm trusted-publisher permissions to grant. */ export type Permission = 'publish' | 'stage' | 'both'; @@ -12,6 +13,13 @@ export interface JsrConfig { scope?: string; /** Set to false to skip scaffolding missing jsr.json manifests. */ manifest?: boolean; + /** Set to false to skip syncing score metadata (description / runtime compat) to JSR. */ + metadata?: boolean; + /** + * Default runtime-compatibility flags to publish to JSR (part of the package score). + * A package's own `fledgling.jsr.runtimeCompat` in its package.json overrides this. + */ + runtimeCompat?: RuntimeCompat; } /** Persisted config, read from the `"fledgling"` key of the root package.json. */ diff --git a/src/jsr-cmd.ts b/src/jsr-cmd.ts index a1b4ec3..67595cc 100644 --- a/src/jsr-cmd.ts +++ b/src/jsr-cmd.ts @@ -11,8 +11,13 @@ import { ensureJsrManifest, isWeeklyLimit, jsrErrorReason, + normalizeDescription, + runtimeCompatDiffers, + describeRuntimeCompat, JSR_COOLDOWN_MS, type JsrName, + type JsrPackageMeta, + type RuntimeCompat, } from './jsr.js'; interface Item { @@ -20,6 +25,11 @@ interface Item { jsr: JsrName; exists?: boolean; needsManifest?: boolean; + current?: JsrPackageMeta; + /** A description to push (present only when it differs from what's on JSR). */ + descDrift?: string; + /** Runtime-compat flags to push (present only when they differ from JSR). */ + rcDrift?: RuntimeCompat; } /** @@ -101,10 +111,13 @@ export async function runJsr(values: Record, selectors: string[]): ); } - // --- what's already claimed? (public reads — works for dry runs too) --- + // --- what's already claimed, and what metadata does it carry? (public reads) --- const existsSpin = hatchSpinner(); existsSpin.start('Checking which packages exist on JSR…'); - for (const it of items) it.exists = await client.packageExists(it.jsr); + for (const it of items) { + it.current = (await client.getPackage(it.jsr)) ?? undefined; + it.exists = it.current !== undefined; + } const toClaim = items.filter(it => !it.exists); existsSpin.stop( toClaim.length === 0 @@ -118,7 +131,26 @@ export async function runJsr(values: Record, selectors: string[]): } const manifestCount = items.filter(it => it.needsManifest).length; - if (!toClaim.length && skipLink && !manifestCount) { + // --- metadata drift (description from package.json, runtimeCompat from config) --- + // JSR scores packages on this metadata, and it lives only on jsr.io — the jsr.json + // manifest has no description field, so it can't ride along at publish time. We + // reconcile it here (the tool already holds the full-access token). A newly-claimed + // package starts blank, so its whole desired metadata reads as drift. + const skipMetadata = !!values['skip-metadata'] || config.jsr?.metadata === false; + if (!skipMetadata) { + for (const it of items) { + const desc = normalizeDescription(it.pkg.manifest.description); + if (desc) { + if (desc.truncated) p.log.warn(pc.yellow(`${it.jsr.full} — description exceeds 250 chars; truncating for JSR.`)); + if (desc.value !== (it.current?.description ?? '')) it.descDrift = desc.value; + } + const desiredRc: RuntimeCompat | undefined = it.pkg.manifest.fledgling?.jsr?.runtimeCompat ?? config.jsr?.runtimeCompat; + if (desiredRc && runtimeCompatDiffers(it.current?.runtimeCompat, desiredRc)) it.rcDrift = desiredRc; + } + } + const metaCount = items.filter(it => it.descDrift || it.rcDrift).length; + + if (!toClaim.length && skipLink && !manifestCount && !metaCount) { p.outro(pc.green('Nothing to do — everything is already on JSR. 🐣')); return 0; } @@ -130,6 +162,7 @@ export async function runJsr(values: Record, selectors: string[]): manifestCount ? pc.green(`• scaffold ${manifestCount} missing jsr.json manifest(s)`) : '', toClaim.length ? pc.green(`• claim ${toClaim.length} unclaimed name(s) on jsr.io`) : '', skipLink ? pc.dim('• repo linking skipped') : pc.green(`• link ${repoSlug} for token-less OIDC publishing`), + metaCount ? pc.green(`• sync score metadata (description / runtime compat) on ${metaCount} package(s)`) : '', ] .filter(Boolean) .join('\n'), @@ -157,6 +190,8 @@ export async function runJsr(values: Record, selectors: string[]): it.needsManifest ? 'scaffold jsr.json' : '', it.exists ? '' : 'claim', skipLink ? '' : 'link repo', + it.descDrift ? 'set description' : '', + it.rcDrift ? `set runtimes (${describeRuntimeCompat(it.rcDrift)})` : '', ].filter(Boolean); if (actions.length) p.log.message(`${pc.dim('would')} ${actions.join(' + ')} ${pc.cyan(it.jsr.full)}`); else p.log.message(pc.dim(`nothing to do ${it.jsr.full}`)); @@ -180,9 +215,10 @@ export async function runJsr(values: Record, selectors: string[]): } } - // --- apply: scaffold, claim, link — stopping cleanly at JSR's weekly quota --- + // --- apply: scaffold, claim, link, metadata — stopping cleanly at JSR's weekly quota --- let claimed = 0; let linked = 0; + let metaSynced = 0; const failures: string[] = []; let blockedFrom = -1; for (let i = 0; i < items.length; i++) { @@ -213,7 +249,26 @@ export async function runJsr(values: Record, selectors: string[]): linked++; didLink = true; } - const did = [didClaim && 'claimed', didLink && (didClaim ? 'linked' : 're-linked'), it.needsManifest && 'jsr.json created'] + // Metadata is a separate PATCH per field (JSR's updatePackage body is a oneOf). + // The package exists by now (just claimed or pre-existing), so these can't 404. + const metaBits: string[] = []; + if (it.descDrift !== undefined) { + const r = await client.setDescription(it.jsr, it.descDrift); + if (!r.ok) throw new Error(`set description — ${jsrErrorReason(r)}`); + metaBits.push('description'); + } + if (it.rcDrift) { + const r = await client.setRuntimeCompat(it.jsr, it.rcDrift); + if (!r.ok) throw new Error(`set runtimes — ${jsrErrorReason(r)}`); + metaBits.push(`runtimes ${describeRuntimeCompat(it.rcDrift)}`); + } + if (metaBits.length) metaSynced++; + const did = [ + didClaim && 'claimed', + didLink && (didClaim ? 'linked' : 're-linked'), + it.needsManifest && 'jsr.json created', + ...metaBits, + ] .filter(Boolean) .join(' + '); p.log.success(`${it.jsr.full} — ${did || 'already set up'}`); @@ -240,12 +295,13 @@ export async function runJsr(values: Record, selectors: string[]): `CI can now publish token-lessly: a workflow in ${pc.dim(repoSlug!)} with ${pc.bold('permissions: id-token: write')} running ${cmd('npx jsr publish')}.`, ); } + const tally = `claimed ${claimed}, linked ${linked}, metadata ${metaSynced}`; p.outro( failures.length - ? pc.red(`Done with ${failures.length} failure(s) — claimed ${claimed}, linked ${linked}.`) + ? pc.red(`Done with ${failures.length} failure(s) — ${tally}.`) : blocked.length - ? pc.yellow(`Claimed ${claimed}, linked ${linked} — ${blocked.length} blocked by the weekly quota.`) - : pc.green(`Done — claimed ${claimed}, linked ${linked}. 🐣`), + ? pc.yellow(`${tally} — ${blocked.length} blocked by the weekly quota.`) + : pc.green(`Done — ${tally}. 🐣`), ); return failures.length || blocked.length ? 1 : 0; } diff --git a/src/jsr.ts b/src/jsr.ts index 52d483b..d6aff60 100644 --- a/src/jsr.ts +++ b/src/jsr.ts @@ -43,17 +43,49 @@ export interface JsrResponse { body: string; } +/** + * Per-runtime compatibility flags, part of a package's JSR score. Every field is a + * tri-state: true (compatible), false (not), or null/absent (unknown — the default). + */ +export interface RuntimeCompat { + node?: boolean | null; + deno?: boolean | null; + bun?: boolean | null; + browser?: boolean | null; + workerd?: boolean | null; +} + +export const RUNTIME_KEYS = ['node', 'deno', 'bun', 'browser', 'workerd'] as const; + +/** JSR caps a package description at 250 chars (server pattern `^.{0,250}$`). */ +export const DESCRIPTION_MAX = 250; + +/** The score-affecting metadata JSR stores per package (settable only via the API). */ +export interface JsrPackageMeta { + description?: string; + runtimeCompat?: RuntimeCompat; +} + export interface JsrClient { request(method: string, path: string, body?: unknown): Promise; /** The token's display name (`GET /user`), or null if it can't be read. */ whoami(): Promise; /** Can this token manage `scope`? (`GET /user/member/{scope}` — 200 = member.) */ scopeAccess(scope: string): Promise<'ok' | 'bad-token' | 'no-access'>; + /** Package metadata (`GET`), or null if it doesn't exist yet (404). */ + getPackage(n: JsrName): Promise; /** Is the name claimed on JSR? (404 = free.) */ packageExists(n: JsrName): Promise; createPackage(n: JsrName): Promise; /** Link the GitHub repo — this is what enables token-less OIDC publishing from CI. */ linkRepo(n: JsrName, owner: string, repo: string): Promise; + /** + * Set the package description. A standalone PATCH: JSR's updatePackage body is a + * `oneOf`, so description / repo link / runtimeCompat each need their own request. + */ + setDescription(n: JsrName, description: string): Promise; + /** Set runtime-compatibility flags (its own PATCH — see `setDescription`). */ + setRuntimeCompat(n: JsrName, rc: RuntimeCompat): Promise; } /** @@ -99,6 +131,16 @@ export function jsrClient(token?: string, onRetry?: (waitMs: number) => void): J if (res.status === 401) return 'bad-token'; return 'no-access'; }, + async getPackage(n) { + const res = await request('GET', `/scopes/${n.scope}/packages/${n.name}`); + if (res.status !== 200) return null; + try { + const pkg = JSON.parse(res.body); + return { description: pkg?.description ?? undefined, runtimeCompat: pkg?.runtimeCompat ?? undefined }; + } catch { + return {}; // exists, but we couldn't read its metadata — treat as empty + } + }, async packageExists(n) { return (await request('GET', `/scopes/${n.scope}/packages/${n.name}`)).status === 200; }, @@ -110,9 +152,37 @@ export function jsrClient(token?: string, onRetry?: (waitMs: number) => void): J githubRepository: { owner, name: repo }, }); }, + setDescription(n, description) { + return request('PATCH', `/scopes/${n.scope}/packages/${n.name}`, { description }); + }, + setRuntimeCompat(n, rc) { + return request('PATCH', `/scopes/${n.scope}/packages/${n.name}`, { runtimeCompat: rc }); + }, }; } +/** + * Normalize a package.json description for JSR: collapse whitespace and clamp to JSR's + * 250-char limit. Returns the value plus whether it had to be truncated (so the caller + * can warn), or undefined when there's no description to set. + */ +export function normalizeDescription(raw?: string): { value: string; truncated: boolean } | undefined { + const collapsed = String(raw ?? '').replace(/\s+/g, ' ').trim(); + if (!collapsed) return undefined; + return { value: collapsed.slice(0, DESCRIPTION_MAX), truncated: collapsed.length > DESCRIPTION_MAX }; +} + +/** Does `desired` differ from what's `current` on JSR, across the known runtime flags? */ +export function runtimeCompatDiffers(current: RuntimeCompat | undefined, desired: RuntimeCompat): boolean { + return RUNTIME_KEYS.some(k => (desired[k] ?? null) !== (current?.[k] ?? null)); +} + +/** Compact display of the runtimes marked compatible, e.g. `node+deno+bun`. */ +export function describeRuntimeCompat(rc: RuntimeCompat): string { + const on = RUNTIME_KEYS.filter(k => rc[k]); + return on.length ? on.join('+') : '(none)'; +} + /** * JSR caps NEW packages at 20 per scope per rolling week. Once hit, every remaining * create fails the same way — callers should stop and report, not grind through. From 39a210595856876c984013e14d4a45c77a39cfe6 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 9 Jul 2026 17:01:09 -0700 Subject: [PATCH 4/5] refactor: move each command's definition into its own file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli.ts previously held the npm-shaped arg schema, the default/add command logic (runPlain/createRun), and every gunshi command object inline — the odd one out, since sync/init/jsr already kept their behavior in their own files. Extract the shared flag schema + gunshi plumbing (Ctx, selectorsOf) into args.ts, move the default/add command (which share createRun) into add.ts, and have sync.ts/init.ts/jsr-cmd.ts each export their own full command object (name, description, args, run) next to the logic it calls. cli.ts is now just imports + gunshi wiring. --- src/add.ts | 102 ++++++++++++++++++++++++++ src/args.ts | 36 +++++++++ src/cli.ts | 193 ++----------------------------------------------- src/init.ts | 9 +++ src/jsr-cmd.ts | 24 ++++++ src/sync.ts | 19 ++++- 6 files changed, 194 insertions(+), 189 deletions(-) create mode 100644 src/add.ts create mode 100644 src/args.ts diff --git a/src/add.ts b/src/add.ts new file mode 100644 index 0000000..a8f20c4 --- /dev/null +++ b/src/add.ts @@ -0,0 +1,102 @@ +import pc from 'picocolors'; +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 { npmArgs, selectorsOf, type Ctx } from './args.js'; + +/** Non-interactive path: a plan by default, applies with --yes. */ +function runPlain(values: Record, 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; +} + +/** The create flow (wizard in a TTY, plan/apply otherwise). Shared by the default command and `add`. */ +async function createRun(ctx: Ctx): Promise { + 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. */ +export const entryCommand = { + name: 'fledgling', + description: 'Claim package names and set up trusted publishing', + args: npmArgs, + run: createRun, +}; + +export const addCommand = { + name: 'add', + description: 'Claim names + set up trusted publishing for the given packages', + args: npmArgs, + run: createRun, +}; diff --git a/src/args.ts b/src/args.ts new file mode 100644 index 0000000..1adf881 --- /dev/null +++ b/src/args.ts @@ -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; 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; diff --git a/src/cli.ts b/src/cli.ts index 987b11b..c5b514d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,197 +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 { runJsr } from './jsr-cmd.js'; +import { entryCommand, addCommand } from './add.js'; +import { syncCommand } from './sync.js'; +import { initCommand } from './init.js'; +import { jsrCommand } from './jsr-cmd.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, 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; 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 { - 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; - }, -}; - -// JSR's model needs none of the npm machinery (no npm CLI, no OTP, no provider config) — -// so the `jsr` command takes its own small arg set instead of the npm-shaped one. -const jsrArgs = { - 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)' }, - scope: { type: 'string', description: '[config] JSR scope for packages whose npm name has none (or to override it)' }, - repo: { type: 'string', description: 'GitHub repo to link for OIDC publishing (default: auto-detected from git origin)' }, - token: { type: 'string', description: 'JSR personal access token, FULL access (default: $JSR_TOKEN)' }, - 'skip-manifest': { type: 'boolean', description: "Don't scaffold missing jsr.json manifests" }, - 'skip-link': { type: 'boolean', description: "Only claim names — don't link the GitHub repo" }, - 'skip-metadata': { type: 'boolean', description: "Don't sync score metadata (description / runtime compat) to JSR" }, -} as const; - -const jsrCommand = { - name: 'jsr', - description: 'Claim packages on JSR + link the repo for token-less OIDC publishing', - args: jsrArgs, - async run(ctx: Ctx) { - const code = await runJsr(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 @@ -201,7 +18,7 @@ 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', diff --git a/src/init.ts b/src/init.ts index 2658b8d..2652250 100644 --- a/src/init.ts +++ b/src/init.ts @@ -88,3 +88,12 @@ function cancel(): number { p.cancel('Cancelled.'); return 1; } + +export const initCommand = { + name: 'init', + description: 'Write trusted-publishing config to your package.json', + async run() { + const code = await runInit(); + if (code) process.exitCode = code; + }, +}; diff --git a/src/jsr-cmd.ts b/src/jsr-cmd.ts index 67595cc..4c946ef 100644 --- a/src/jsr-cmd.ts +++ b/src/jsr-cmd.ts @@ -5,6 +5,7 @@ import { findWorkspaceRoot, discoverPackages, detectRepo, type Pkg } from './wor import { resolveTargets, applyIgnore } from './core.js'; import { loadConfig } from './config.js'; import { hatchSpinner, hatchIntro, cmd, note } from './ui.js'; +import { selectorsOf, type Ctx } from './args.js'; import { jsrClient, resolveJsrName, @@ -20,6 +21,19 @@ import { type RuntimeCompat, } from './jsr.js'; +/** `fledgling jsr`'s own flags — JSR needs none of the npm-shaped `args` in cli.ts + * (no npm CLI, no OTP, no provider/workflow/environment config). */ +export const jsrArgs = { + 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)' }, + scope: { type: 'string', description: '[config] JSR scope for packages whose npm name has none (or to override it)' }, + repo: { type: 'string', description: 'GitHub repo to link for OIDC publishing (default: auto-detected from git origin)' }, + token: { type: 'string', description: 'JSR personal access token, FULL access (default: $JSR_TOKEN)' }, + 'skip-manifest': { type: 'boolean', description: "Don't scaffold missing jsr.json manifests" }, + 'skip-link': { type: 'boolean', description: "Only claim names — don't link the GitHub repo" }, + 'skip-metadata': { type: 'boolean', description: "Don't sync score metadata (description / runtime compat) to JSR" }, +} as const; + interface Item { pkg: Pkg; jsr: JsrName; @@ -305,3 +319,13 @@ export async function runJsr(values: Record, selectors: string[]): ); return failures.length || blocked.length ? 1 : 0; } + +export const jsrCommand = { + name: 'jsr', + description: 'Claim packages on JSR + link the repo for token-less OIDC publishing', + args: jsrArgs, + async run(ctx: Ctx) { + const code = await runJsr(ctx.values, selectorsOf(ctx)); + if (code) process.exitCode = code; + }, +}; diff --git a/src/sync.ts b/src/sync.ts index 18ac931..bd6585b 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,7 +1,8 @@ import * as p from '@clack/prompts'; import pc from 'picocolors'; import { findWorkspaceRoot, discoverPackages, detectRepo, type Pkg } from './workspace.js'; -import { npmAuthCheck, listTrust, configureTrust, revokeTrust, warmNpmAuth, publishedNames } from './npm.js'; +import { npmAuthCheck, checkNpmVersion, listTrust, configureTrust, revokeTrust, warmNpmAuth, publishedNames } from './npm.js'; +import { npmArgs, selectorsOf, type Ctx } from './args.js'; import { resolveTargets, validateTrustSettings, @@ -176,3 +177,19 @@ export async function runSync(values: Record, selectors: string[]): p.outro(failed ? pc.red(`Done with ${failed} failure(s).`) : pc.green(`Synced ${fixed} package(s) 🐣`)); return failed > 0 ? 1 : 0; } + +export const syncCommand = { + name: 'sync', + description: 'Reconcile trusted publishing on npm with your config', + args: npmArgs, + 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; + }, +}; From 2447a6a2181ccc844d1c9bc1aa1ba4653ec61011 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 9 Jul 2026 17:04:01 -0700 Subject: [PATCH 5/5] refactor: move command files into src/commands/ as *.command.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consistent naming across commands: add.command.ts, sync.command.ts, init.command.ts, jsr.command.ts, all under src/commands/. Only import paths changed (adjusted for the extra directory level) — no behavior change. --- src/cli.ts | 8 ++++---- src/{add.ts => commands/add.command.ts} | 14 +++++++------- src/{init.ts => commands/init.command.ts} | 6 +++--- src/{jsr-cmd.ts => commands/jsr.command.ts} | 16 ++++++++-------- src/{sync.ts => commands/sync.command.ts} | 12 ++++++------ 5 files changed, 28 insertions(+), 28 deletions(-) rename src/{add.ts => commands/add.command.ts} (92%) rename src/{init.ts => commands/init.command.ts} (96%) rename src/{jsr-cmd.ts => commands/jsr.command.ts} (97%) rename src/{sync.ts => commands/sync.command.ts} (96%) diff --git a/src/cli.ts b/src/cli.ts index c5b514d..e6d32ea 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,10 +2,10 @@ import { cli } from 'gunshi'; import pc from 'picocolors'; import { maybeHandleCompletion } from './completion.js'; -import { entryCommand, addCommand } from './add.js'; -import { syncCommand } from './sync.js'; -import { initCommand } from './init.js'; -import { jsrCommand } from './jsr-cmd.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__; diff --git a/src/add.ts b/src/commands/add.command.ts similarity index 92% rename from src/add.ts rename to src/commands/add.command.ts index a8f20c4..db56021 100644 --- a/src/add.ts +++ b/src/commands/add.command.ts @@ -1,11 +1,11 @@ import pc from 'picocolors'; -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 { npmArgs, selectorsOf, type Ctx } from './args.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 { npmArgs, selectorsOf, type Ctx } from '../args.js'; /** Non-interactive path: a plan by default, applies with --yes. */ function runPlain(values: Record, selectors: string[]): number { diff --git a/src/init.ts b/src/commands/init.command.ts similarity index 96% rename from src/init.ts rename to src/commands/init.command.ts index 2652250..7435b59 100644 --- a/src/init.ts +++ b/src/commands/init.command.ts @@ -1,8 +1,8 @@ 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 { hatchIntro, note } from './ui.js'; +import { findWorkspaceRoot, detectRepo } from '../workspace.js'; +import { loadConfig, writeConfig, type FledglingConfig, type Permission, type Provider } from '../config.js'; +import { hatchIntro, note } from '../ui.js'; const CANCEL = Symbol('cancel'); /** Prompt for required text; returns the trimmed value or CANCEL. */ diff --git a/src/jsr-cmd.ts b/src/commands/jsr.command.ts similarity index 97% rename from src/jsr-cmd.ts rename to src/commands/jsr.command.ts index 4c946ef..e760ad6 100644 --- a/src/jsr-cmd.ts +++ b/src/commands/jsr.command.ts @@ -1,11 +1,11 @@ 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 { resolveTargets, applyIgnore } from './core.js'; -import { loadConfig } from './config.js'; -import { hatchSpinner, hatchIntro, cmd, note } from './ui.js'; -import { selectorsOf, type Ctx } from './args.js'; +import { findWorkspaceRoot, discoverPackages, detectRepo, type Pkg } from '../workspace.js'; +import { resolveTargets, applyIgnore } from '../core.js'; +import { loadConfig } from '../config.js'; +import { hatchSpinner, hatchIntro, cmd, note } from '../ui.js'; +import { selectorsOf, type Ctx } from '../args.js'; import { jsrClient, resolveJsrName, @@ -19,10 +19,10 @@ import { type JsrName, type JsrPackageMeta, type RuntimeCompat, -} from './jsr.js'; +} from '../jsr.js'; -/** `fledgling jsr`'s own flags — JSR needs none of the npm-shaped `args` in cli.ts - * (no npm CLI, no OTP, no provider/workflow/environment config). */ +/** `fledgling jsr`'s own flags — JSR needs none of the npm-shaped `args` (no npm + * CLI, no OTP, no provider/workflow/environment config). */ export const jsrArgs = { 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)' }, diff --git a/src/sync.ts b/src/commands/sync.command.ts similarity index 96% rename from src/sync.ts rename to src/commands/sync.command.ts index bd6585b..1ed2c33 100644 --- a/src/sync.ts +++ b/src/commands/sync.command.ts @@ -1,8 +1,8 @@ import * as p from '@clack/prompts'; import pc from 'picocolors'; -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'; +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'; import { resolveTargets, validateTrustSettings, @@ -12,9 +12,9 @@ import { describeTrustDiff, describeConfig, applyIgnore, -} from './core.js'; -import { loadConfig } from './config.js'; -import { hatchSpinner, hatchIntro, otpBoxReminder, reportNpmAuth, note } from './ui.js'; +} from '../core.js'; +import { loadConfig } from '../config.js'; +import { hatchSpinner, hatchIntro, otpBoxReminder, reportNpmAuth, note } from '../ui.js'; /** * `fledgling sync` — reconcile trusted publishing across the workspace.