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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/pypi-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'fledgling': minor
---

New `fledgling pypi` command — claim package names on [PyPI](https://pypi.org), then a checklist for the rest. PyPI is the odd one out: it has no create-on-first-publish problem (a *pending publisher* can be registered for a project that doesn't exist), but it also has **no API** for trusted publishers — every publisher-management route is a CSRF-protected web form, so there's no `npm trust` equivalent to call. So fledgling does the half that can be automated, which is also the half that matters: a pending publisher does **not** reserve the name, and until something is published anyone can take it. fledgling uploads a minimal placeholder sdist per name — built in-process, no Python toolchain needed — then prints every value PyPI's form wants plus the exact page for each package. Claiming also sidesteps PyPI's 3-pending-publisher cap, since a project that exists is configured on its own settings page. Auth via a PyPI API token in `$PYPI_TOKEN` (used locally, never in CI); rehearse against TestPyPI with `--test`.
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Run bare `fledgling` in a terminal and you get an interactive wizard (powered by
| `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 |
| `fledgling pypi <names…>` | Claim names on [PyPI](https://pypi.org) + print the trusted-publishing checklist |

## Why

Expand Down Expand Up @@ -325,6 +326,53 @@ The **description** is taken automatically from each package's `package.json` (c

> 🙏 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.

## `fledgling pypi` — claim the name, then a checklist

PyPI is the odd one out, and it's worth saying plainly why:

- **The good news:** PyPI has no create-on-first-publish problem. A [pending publisher](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/) can be registered for a project that doesn't exist yet, and the first OIDC publish creates it. No placeholder needed.
- **The bad news:** there is **no API** for any of it. Every publisher-management route on PyPI is a session + CSRF protected HTML form; the only machine endpoints (`/_/oidc/audience`, `/_/oidc/mint-token`) *mint* tokens, they don't manage publishers. There's no `npm trust` equivalent to call.

So `fledgling pypi` does the half that can be automated, and makes the other half as short as possible:

1. **Claim** — upload a minimal placeholder sdist for each name. This is the part that actually matters: a pending publisher **does not reserve the name**, so until something is published, anyone can take it out from under you (which invalidates your pending publisher).
2. **Hand off** — print every field value PyPI's form wants, and the exact page for each package.

```sh
npx fledgling pypi my-great-new-idea # plan (interactive confirm in a terminal)
npx fledgling pypi pkg-one pkg-two --yes # apply
npx fledgling pypi my-pkg --test --yes # rehearse against TestPyPI first
```

Names are passed explicitly rather than discovered from `pyproject.toml` — claiming a name is most useful *before* there's a package to discover.

### Requirements

- A **PyPI API token** in `$PYPI_TOKEN` ([create one](https://pypi.org/manage/account/token/)). Used once, locally; it does **not** go into CI — that's the whole point of the trusted publisher you set up afterwards.
- Your PyPI account needs a **verified email** and **2FA** — warehouse rejects token uploads without both.
- No Python toolchain. fledgling builds the sdist itself (a gzipped tar holding one `PKG-INFO`); PyPI reads the metadata from the upload form fields.

### Flags

| Flag | Description |
|------|-------------|
| `-y, --yes` | Apply without prompting |
| `--dry-run` | Print a plan without prompting (non-interactive) |
| `--token <token>` | PyPI API token (prefer `$PYPI_TOKEN` over the flag) |
| `--placeholder-version <v>` | Placeholder version (default `0.0.0`) |
| `--summary <text>` | Summary for the placeholder release |
| `--test` | Use TestPyPI instead of PyPI |
| `--repository-url <url>` / `--index-url <url>` | Point at a custom index |
| `--repo <owner/repo>` | Repo for the checklist (default: auto-detected from git `origin`) |
| `--workflow <file>` / `--env <name>` | Workflow filename / CI environment for the checklist |

### Good to know

- **Availability checks are best-effort.** fledgling asks PyPI's JSON API whether a name exists, but PyPI also rejects names that merely *resemble* an existing one — it strips `.`, `-`, `_` and reads `l`/`i` as `1` and `o` as `0`, so `my-lib` and `myl1b` collide. Stdlib names and typosquats are blocked too. None of that is queryable up front; you find out on upload.
- **Claiming raises the cap.** Pending publishers are limited to **3 per account**. Once a name is claimed the project exists, so you configure it on its own project settings page instead — and there's no limit on those.
- **PEP 625 naming.** PyPI indexes by the [PEP 503](https://peps.python.org/pep-0503/) normalized name, so `My.Pkg`, `my_pkg` and `my--pkg` are one project; fledgling normalizes, dedupes, and names the sdist the way warehouse demands.
- **Placeholder versions are plain dotted numbers.** Anything PEP 440 would rewrite (`1.0-alpha` → `1.0a0`) is rejected up front, because warehouse builds the expected filename from the canonicalized version and the mismatch produces a baffling error.

## Shell completions

`fledgling` ships tab-completion (via [`@bomb.sh/tab`](https://github.com/bombshell-dev/tab)) that completes package names and flags. Install it for your shell:
Expand Down
3 changes: 2 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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';
import { pypiCommand } from './commands/pypi.command.js';

declare const __VERSION__: string;
const VERSION = __VERSION__;
Expand All @@ -17,7 +18,7 @@ try {
name: 'fledgling',
version: VERSION,
description: '🐣 Create and set up packages on npm with trusted publishing',
subCommands: { add: addCommand, sync: syncCommand, init: initCommand, jsr: jsrCommand },
subCommands: { add: addCommand, sync: syncCommand, init: initCommand, jsr: jsrCommand, pypi: pypiCommand },
// shell completion (`fledgling complete <shell>`) — subcommands + flags are derived
// from each command's `args`; see completion.ts for the dynamic-value handlers.
plugins: [completionPlugin()],
Expand Down
288 changes: 288 additions & 0 deletions src/commands/pypi.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
import * as p from '@clack/prompts';
import pc from 'picocolors';
import { findWorkspaceRoot, detectRepo } from '../workspace.js';
import { loadConfig } from '../config.js';
import { hatchSpinner, hatchIntro, cmd, note } from '../ui.js';
import { selectorsOf, type Ctx } from '../args.js';
import {
pypiClient,
buildSdist,
validatePypiName,
validatePypiVersion,
normalizePypiName,
publishingSettingsUrl,
pendingPublisherUrl,
pypiErrorReason,
looksLikeToken,
AVAILABILITY_CAVEAT,
PYPI,
TEST_PYPI,
type PypiRegistry,
type NameStatus,
} from '../pypi.js';

/**
* `fledgling pypi`'s own flags. Names are given explicitly rather than discovered:
* PyPI packages live in pyproject.toml, which this command deliberately doesn't read
* (see the module note in pypi.ts) — claiming a name is useful precisely *before*
* there's a package to discover.
*/
export const pypiArgs = {
packages: { type: 'positional', multiple: true, required: false, description: 'PyPI package name(s) to claim' },
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)' },
token: { type: 'string', description: 'PyPI API token (default: $PYPI_TOKEN)' },
'placeholder-version': { type: 'string', default: '0.0.0', description: 'Placeholder version to publish' },
summary: { type: 'string', description: 'Summary for the placeholder release' },
test: { type: 'boolean', description: 'Use TestPyPI (test.pypi.org) instead of PyPI' },
'repository-url': { type: 'string', description: 'Upload endpoint (default: https://upload.pypi.org/legacy/)' },
'index-url': { type: 'string', description: 'Web/JSON API base for lookups (default: https://pypi.org)' },
repo: { type: 'string', description: 'Repo for the trusted-publishing checklist (default: auto-detected from git origin)' },
workflow: { type: 'string', description: 'Publishing workflow filename for the checklist (default: release.yml)' },
env: { type: 'string', description: 'CI environment for the checklist (default: none)' },
} as const;

const DEFAULT_SUMMARY = 'Name reserved — no release published yet.';

interface Item {
name: string;
status: NameStatus;
claimed?: boolean;
}

/**
* `fledgling pypi` — claim PyPI names, then hand off trusted publishing.
*
* PyPI has no management API for trusted publishers (see pypi.ts), so this command
* does the half that *can* be automated — uploading a minimal placeholder sdist to
* lock each name down — and then prints the exact values to paste into PyPI's web
* form. Idempotent: names already on PyPI are skipped.
*/
export async function runPypi(values: Record<string, any>, names: string[]): Promise<number> {
console.log();
await hatchIntro('fledgling pypi');

if (!names.length) {
p.cancel(pc.red('Pass the name(s) to claim, e.g. `fledgling pypi my-great-new-idea`.'));
return 1;
}

const root = findWorkspaceRoot();
const config = loadConfig(root);

// --- registry: PyPI, TestPyPI, or a custom pair of endpoints ---
const base = values.test ? TEST_PYPI : PYPI;
const registry: PypiRegistry = {
label: values['repository-url'] || values['index-url'] ? 'custom registry' : base.label,
upload: values['repository-url'] ?? base.upload,
index: (values['index-url'] ?? base.index).replace(/\/+$/, ''),
};

// --- validate names + version up front, before touching the network ---
const version: string = values['placeholder-version'];
const versionError = validatePypiVersion(version);
if (versionError) {
p.cancel(pc.red(versionError));
return 1;
}
const summary: string = values.summary ?? DEFAULT_SUMMARY;

const invalid = names.map(n => ({ n, err: validatePypiName(n) })).filter(x => x.err);
if (invalid.length) {
for (const { n, err } of invalid) p.log.error(pc.red(`"${n}" — ${err}`));
p.cancel(pc.red('Fix the name(s) and re-run.'));
return 1;
}
// PyPI indexes by the PEP 503 normalized name, so two spellings of one name are one
// claim — dedupe before we start uploading the same thing twice.
const seen = new Set<string>();
const items: Item[] = [];
for (const name of names) {
const key = normalizePypiName(name);
if (seen.has(key)) {
p.log.warn(pc.yellow(`"${name}" is the same PyPI project as an earlier name (both normalize to "${key}") — skipping the duplicate.`));
continue;
}
seen.add(key);
items.push({ name, status: 'unknown' });
}

// --- auth: a token applies; without one we can only preview ---
const token: string | undefined = values.token ?? process.env.PYPI_TOKEN;
if (token && !looksLikeToken(token)) {
p.log.warn(pc.yellow("That token doesn't start with `pypi-` — PyPI API tokens do. Uploads will probably 403."));
}
if (!token && !values['dry-run']) {
p.log.warn(
pc.yellow('PYPI_TOKEN not set — this run will be a dry run.\n') +
pc.dim(`Create a token at ${registry.index}/manage/account/token/ and re-run. Your account needs a verified email and 2FA.`),
);
}

const client = pypiClient(registry, token);

// --- what's already claimed? ---
const spin = hatchSpinner();
spin.start(`Checking names on ${registry.label}…`);
await Promise.all(items.map(async it => void (it.status = await client.nameStatus(it.name))));
const toClaim = items.filter(it => it.status === 'free');
const taken = items.filter(it => it.status === 'taken');
const unknown = items.filter(it => it.status === 'unknown');
spin.stop(
toClaim.length === 0 && !unknown.length
? `All ${items.length} name(s) already on ${registry.label}`
: `${toClaim.length} of ${items.length} name(s) free to claim`,
);
if (unknown.length) {
p.log.warn(pc.yellow(`Couldn't reach ${registry.label} for: ${unknown.map(it => it.name).join(', ')} — will attempt the claim anyway.`));
}
if (taken.length) {
p.log.info(pc.dim(`Already on ${registry.label}: ${taken.map(it => it.name).join(', ')}`));
}

// --- the values you'll need for the (manual) trusted-publishing step ---
const repoInfo = detectRepo(root);
const repoSlug: string | undefined = values.repo ?? repoInfo?.slug;
const workflow: string = values.workflow ?? config.workflow ?? 'release.yml';
const environment: string | undefined = values.env ?? config.environment;

// The checklist is the real deliverable, so it always prints — and always *before*
// clack's outro, which closes the flow's box.
const checklist = () => printTrustedPublishingSteps(items, registry, repoSlug, repoInfo?.host, workflow, environment);

const attempts = items.filter(it => it.status !== 'taken');
if (!attempts.length) {
checklist();
p.outro(pc.green(`Nothing to claim — every name is already on ${registry.label}. 🐣`));
return 0;
}

// --- plan + confirm ---
note(
[
`${pc.bold(String(attempts.length))} name(s) to claim on ${pc.bold(registry.label)}:`,
...attempts.map(it => ` 📦 ${pc.cyan(it.name)} ${pc.dim(`→ ${normalizePypiName(it.name)} @ ${version}`)}`),
'',
pc.dim(`Uploads a minimal placeholder sdist (one PKG-INFO, no code) to lock the name down.`),
pc.dim(`Trusted publishing itself has no API — you'll get a checklist for it at the end.`),
].join('\n'),
'Plan',
);
p.log.warn(pc.yellow(AVAILABILITY_CAVEAT));

let dryRun = !!values['dry-run'] || !token;
let apply = !dryRun && !!values.yes;
if (!dryRun && !values.yes) {
if (process.stdout.isTTY) {
const ans = await p.confirm({ message: `Claim ${attempts.length} name(s) on ${registry.label} now?`, 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 attempts) p.log.message(`${pc.dim('would claim')} ${pc.cyan(it.name)}`);
checklist();
p.outro(pc.yellow(`Dry run — ${attempts.length} to claim. Re-run with --yes (and PYPI_TOKEN) to apply.`));
return 0;
}

// --- apply ---
const failures: string[] = [];
for (const it of attempts) {
const dist = buildSdist({ name: it.name, version, summary });
const claimSpin = hatchSpinner();
claimSpin.start(`Claiming ${it.name}…`);
const res = await client.upload(dist);
if (res.ok) {
it.claimed = true;
claimSpin.stop(`${pc.green('claimed')} ${pc.cyan(it.name)} ${pc.dim(`@ ${version}`)}`);
} else {
claimSpin.stop(pc.red(`${it.name} — claim failed`));
p.log.error(pypiErrorReason(res));
failures.push(it.name);
}
}

const claimed = attempts.filter(it => it.claimed).length;
checklist();
p.outro(
failures.length
? pc.red(`Done with ${failures.length} failure(s) — claimed ${claimed}.`)
: pc.green(`Done — claimed ${claimed}. 🐣`),
);
return failures.length ? 1 : 0;
}

/**
* The manual half. PyPI's publisher management is web-only, so the most useful thing
* fledgling can do is compute every field value and point at the exact page.
*
* Which page depends on whether the project exists: a claimed name has a project-level
* settings page (and no cap), while a name that was never claimed has to go through
* pending publishers — capped at 3 per account, and *not* a name reservation.
*/
function printTrustedPublishingSteps(
items: Item[],
registry: PypiRegistry,
repoSlug: string | undefined,
host: 'github' | 'gitlab' | undefined,
workflow: string,
environment: string | undefined,
): void {
const exists = items.filter(it => it.claimed || it.status === 'taken');
const pending = items.filter(it => !it.claimed && it.status !== 'taken');

const row = (label: string, value?: string) =>
` ${`${label}:`.padEnd(18)} ${value ? pc.cyan(value) : pc.dim('(none)')}`;
const [owner, repoName] = (repoSlug ?? '').split('/');

const lines: string[] = [
`PyPI has ${pc.bold('no API')} for trusted publishers — this part is a web form. Values for this repo:`,
'',
row(host === 'gitlab' ? 'Namespace' : 'Owner', owner),
row(host === 'gitlab' ? 'Project' : 'Repository name', repoName),
row('Workflow name', workflow),
row('Environment name', environment),
];
if (!repoSlug) {
lines.push('', pc.yellow('No git remote detected — pass --repo owner/repo to fill the first two in.'));
}
if (host === 'gitlab') {
lines.push('', pc.dim('Detected a GitLab remote — use the GitLab tab on the PyPI form (the fields differ slightly).'));
}
if (exists.length) {
lines.push('', pc.bold('Claimed / existing projects') + pc.dim(' — one page each, no limit:'));
for (const it of exists) lines.push(` ${pc.dim('·')} ${pc.underline(publishingSettingsUrl(registry, it.name))}`);
}
if (pending.length) {
lines.push(
'',
pc.bold('Not claimed yet') + pc.dim(' — register these as pending publishers:'),
` ${pc.dim('·')} ${pc.underline(pendingPublisherUrl(registry))} ${pc.dim(`(${pending.map(it => it.name).join(', ')})`)}`,
pc.dim(' Max 3 pending publishers per account, and a pending publisher does NOT reserve the name.'),
);
}
lines.push(
'',
`Then publish from CI with ${pc.bold('permissions: id-token: write')} and ${cmd('pypa/gh-action-pypi-publish')} — no PyPI token in CI.`,
);

note(lines.join('\n'), '🔑 Trusted publishing (manual)');
}

export const pypiCommand = {
name: 'pypi',
description: 'Claim package names on PyPI + get the trusted-publishing checklist',
args: pypiArgs,
async run(ctx: Ctx) {
const code = await runPypi(ctx.values, selectorsOf(ctx));
if (code) process.exitCode = code;
},
};
Loading