From 27bebbb3c87d8ac69cf1df7c952e1618a22a46a2 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 17 Sep 2026 12:19:33 -0700 Subject: [PATCH] feat: add `fledgling pypi` to claim names + hand off trusted publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 yet — but it also has no API for any of it: every publisher-management route is a session + CSRF protected HTML form, and the only machine endpoints (`/_/oidc/audience`, `/_/oidc/mint-token`) mint tokens rather than manage publishers. There's no `npm trust` equivalent to call. So `fledgling pypi` automates the half that can be automated, which is also the half that matters: a pending publisher does *not* reserve the name, so until something is published anyone can take it and invalidate the pending publisher. Each name gets a minimal placeholder sdist — built in-process, no Python toolchain — and then the command prints every value PyPI's web form wants plus the exact page per package. Claiming also sidesteps PyPI's 3-pending-publisher cap, since a project that exists is configured on its own settings page. Names are passed explicitly rather than discovered from pyproject.toml: claiming is most useful before there's a package to discover, and it keeps the npm-shaped workspace/config machinery out of it. Verified the generated sdist against warehouse's actual checks using Python's own libraries — tarfile/commonpath/PKG-INFO lookup, packaging.utils.parse_sdist_filename, the PEP 625 expected-filename comparison, and packaging.metadata.Metadata.from_email. That caught a real bug: os.path.commonpath() on a single path returns the whole path, so a one-member tarball made warehouse look for `foo-0.0.0/PKG-INFO/PKG-INFO` and reject every upload. The archive now carries a directory member, as any real sdist does. The multipart upload itself is unexercised — it needs a live token, so rehearse with `--test` against TestPyPI before claiming a real name. --- .bumpy/pypi-support.md | 5 + README.md | 48 ++++++ src/cli.ts | 3 +- src/commands/pypi.command.ts | 288 ++++++++++++++++++++++++++++++++++ src/pypi.ts | 296 +++++++++++++++++++++++++++++++++++ 5 files changed, 639 insertions(+), 1 deletion(-) create mode 100644 .bumpy/pypi-support.md create mode 100644 src/commands/pypi.command.ts create mode 100644 src/pypi.ts diff --git a/.bumpy/pypi-support.md b/.bumpy/pypi-support.md new file mode 100644 index 0000000..ea03433 --- /dev/null +++ b/.bumpy/pypi-support.md @@ -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`. diff --git a/README.md b/README.md index 7870085..08d22a7 100644 --- a/README.md +++ b/README.md @@ -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 ` | Claim names on [PyPI](https://pypi.org) + print the trusted-publishing checklist | ## Why @@ -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 ` | PyPI API token (prefer `$PYPI_TOKEN` over the flag) | +| `--placeholder-version ` | Placeholder version (default `0.0.0`) | +| `--summary ` | Summary for the placeholder release | +| `--test` | Use TestPyPI instead of PyPI | +| `--repository-url ` / `--index-url ` | Point at a custom index | +| `--repo ` | Repo for the checklist (default: auto-detected from git `origin`) | +| `--workflow ` / `--env ` | 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: diff --git a/src/cli.ts b/src/cli.ts index 8a278df..1648b05 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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__; @@ -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 `) — subcommands + flags are derived // from each command's `args`; see completion.ts for the dynamic-value handlers. plugins: [completionPlugin()], diff --git a/src/commands/pypi.command.ts b/src/commands/pypi.command.ts new file mode 100644 index 0000000..4e48110 --- /dev/null +++ b/src/commands/pypi.command.ts @@ -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, names: string[]): Promise { + 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(); + 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; + }, +}; diff --git a/src/pypi.ts b/src/pypi.ts new file mode 100644 index 0000000..9fc27de --- /dev/null +++ b/src/pypi.ts @@ -0,0 +1,296 @@ +import { gzipSync } from 'node:zlib'; +import { createHash } from 'node:crypto'; + +/** + * PyPI client + minimal-sdist builder — the PyPI analogue of npm.ts / jsr.ts. + * + * PyPI differs from both in the ways that shape this module: + * - **Trusted publishing can't be configured programmatically.** 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. So unlike `npm trust` / JSR's `PATCH`, there's + * nothing to call — fledgling claims the name and hands you a checklist. + * - **There's no create-on-first-publish problem.** A "pending publisher" can be + * registered for a project that doesn't exist yet. But it does *not* reserve the + * name — anyone else can still take it, which invalidates the pending publisher. + * Claiming is what actually locks a name down, which is what this module does. + * - **Uploading needs no Python toolchain.** A minimal sdist is a gzipped tar + * holding a single PKG-INFO, and PyPI reads the metadata from the multipart + * *form fields*, not out of the archive (warehouse/forklift/metadata.py::parse). + */ + +export interface PypiRegistry { + /** Display name, for messages. */ + label: string; + /** Legacy upload endpoint (twine's `repository-url`). */ + upload: string; + /** Web/JSON API base — `{index}/pypi/{name}/json` and the management pages. */ + index: string; +} + +export const PYPI: PypiRegistry = { + label: 'PyPI', + upload: 'https://upload.pypi.org/legacy/', + index: 'https://pypi.org', +}; + +export const TEST_PYPI: PypiRegistry = { + label: 'TestPyPI', + upload: 'https://test.pypi.org/legacy/', + index: 'https://test.pypi.org', +}; + +// PyPI asks API clients to identify themselves with a descriptive User-Agent. +declare const __VERSION__: string; +const USER_AGENT = `fledgling/${typeof __VERSION__ === 'string' ? __VERSION__ : '0.0.0'}; https://github.com/dmno-dev/fledgling`; + +/** PyPI's project-name shape (warehouse's `PROJECT_NAME_RE`). */ +const PROJECT_NAME_RE = /^([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9])$/; + +/** + * PEP 503 normalization — lowercase, and runs of `.`, `-`, `_` collapse to a single + * `-`. This is the identity PyPI actually indexes projects by, so `My.Pkg`, + * `my_pkg` and `my--pkg` are all the same project. + */ +export function normalizePypiName(name: string): string { + return name.replace(/[-_.]+/g, '-').toLowerCase(); +} + +/** + * The filename stem PEP 625 mandates for an sdist: the PEP 503 normalized name with + * hyphens as underscores. Warehouse rejects anything else outright, comparing against + * `f"{project.normalized_name.replace('-', '_')}-{version}.tar.gz"`. + */ +export function sdistStem(name: string): string { + return normalizePypiName(name).replace(/-/g, '_'); +} + +/** Validate a name's *shape* (availability is a separate check — see `nameStatus`). */ +export function validatePypiName(name: string): string | undefined { + if (!name) return 'Enter a package name'; + if (name.length > 214) return 'Too long — PyPI names are 214 characters max'; + if (name.trim() !== name) return 'No leading or trailing spaces'; + if (!PROJECT_NAME_RE.test(name)) { + return 'Start and end with a letter or digit; letters, digits, and - . _ in between'; + } + return undefined; +} + +/** + * Validate a placeholder version. Deliberately stricter than PEP 440: warehouse builds + * the expected filename from the *canonicalized* version, so anything PEP 440 would + * rewrite (`1.0-alpha` → `1.0a0`, `01.0` → `1.0`) would fail the filename check with a + * confusing error. Plain dotted release segments are their own canonical form. + */ +export function validatePypiVersion(version: string): string | undefined { + if (!/^(0|[1-9]\d*)(\.(0|[1-9]\d*))*$/.test(version)) { + return `"${version}" isn't usable as a placeholder version — use plain dotted numbers like 0.0.0 (no pre-release suffixes or leading zeros)`; + } + return undefined; +} + +// --- minimal sdist ------------------------------------------------------------ + +/** A tar member: a regular file, or a directory (no data, path ends with `/`). */ +interface TarEntry { + path: string; + data?: Buffer; + dir?: boolean; +} + +/** One 512-byte ustar header. Fields are NUL-terminated octal, per POSIX tar. */ +function tarHeader(entry: TarEntry, size: number): Buffer { + const h = Buffer.alloc(512); + const write = (s: string, off: number, len: number) => h.write(s.slice(0, len), off, len, 'utf8'); + const octal = (n: number, off: number, len: number) => write(n.toString(8).padStart(len - 1, '0') + '\0', off, len); + + write(entry.path, 0, 100); + octal(entry.dir ? 0o755 : 0o644, 100, 8); // mode + octal(0, 108, 8); // uid + octal(0, 116, 8); // gid + octal(size, 124, 12); + octal(0, 136, 12); // mtime — 0 keeps the archive byte-for-byte reproducible + h.fill(' ', 148, 156); // checksum is summed with this field blank… + write(entry.dir ? '5' : '0', 156, 1); // typeflag + write('ustar', 257, 6); // magic (the 6th byte stays NUL) + write('00', 263, 2); // version + write('root', 265, 32); // uname + write('root', 297, 32); // gname + + let sum = 0; + for (const b of h) sum += b; + write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8); // …then written back as 6 octal digits + return h; +} + +/** A tar archive of `entries`, padded to 512-byte blocks and closed with two zero blocks. */ +function tar(entries: TarEntry[]): Buffer { + const parts: Buffer[] = []; + for (const entry of entries) { + const data = entry.data ?? Buffer.alloc(0); + parts.push(tarHeader(entry, data.length)); + if (data.length) { + parts.push(data); + const pad = (512 - (data.length % 512)) % 512; + if (pad) parts.push(Buffer.alloc(pad)); + } + } + parts.push(Buffer.alloc(1024)); + return Buffer.concat(parts); +} + +export interface SdistMeta { + name: string; + version: string; + summary: string; +} + +export interface Sdist extends SdistMeta { + filename: string; + /** + * A plain Uint8Array rather than a Buffer, so it drops straight into a `Blob` + * (Buffer's backing store is `ArrayBufferLike`, which `BlobPart` won't accept). + */ + bytes: Uint8Array; + /** Hex SHA-256 of `bytes` — PyPI requires a digest in the upload form. */ + sha256: string; +} + +/** + * Build the smallest sdist PyPI accepts: a gzipped tar holding `{stem}-{version}/` + * and a single `PKG-INFO` inside it. The PKG-INFO mirrors the upload form fields + * exactly, so it doesn't matter which of the two PyPI ends up parsing. + * + * The directory member is load-bearing, not decoration. Warehouse locates the sdist + * root with `os.path.commonpath(tar.getnames())` and then requires `{root}/PKG-INFO` + * — and `commonpath` of a *single* path returns that whole path, so a lone + * `foo-0.0.0/PKG-INFO` makes warehouse look for `foo-0.0.0/PKG-INFO/PKG-INFO` and + * reject the upload. Two members make the common prefix the directory, as it is in + * any real sdist. + */ +export function buildSdist(meta: SdistMeta): Sdist { + const stem = sdistStem(meta.name); + const dir = `${stem}-${meta.version}`; + const pkgInfo = [ + 'Metadata-Version: 2.1', + `Name: ${meta.name}`, + `Version: ${meta.version}`, + `Summary: ${meta.summary}`, + '', + ].join('\n'); + const gz = gzipSync( + tar([ + { path: `${dir}/`, dir: true }, + { path: `${dir}/PKG-INFO`, data: Buffer.from(pkgInfo, 'utf8') }, + ]), + { level: 9 }, + ); + const bytes = new Uint8Array(gz); + return { + ...meta, + filename: `${dir}.tar.gz`, + bytes, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; +} + +// --- client ------------------------------------------------------------------- + +export interface PypiResponse { + status: number; + ok: boolean; + body: string; +} + +/** Is the name claimed? `unknown` when the registry couldn't be reached. */ +export type NameStatus = 'free' | 'taken' | 'unknown'; + +export interface PypiClient { + nameStatus(name: string): Promise; + upload(dist: Sdist): Promise; +} + +export function pypiClient(registry: PypiRegistry, token?: string): PypiClient { + return { + async nameStatus(name) { + // The JSON API is the cheapest existence check, but it only catches *exact* + // (normalized) collisions — see `AVAILABILITY_CAVEAT`. + try { + const res = await fetch(`${registry.index}/pypi/${normalizePypiName(name)}/json`, { + method: 'GET', + headers: { 'user-agent': USER_AGENT }, + }); + if (res.status === 404) return 'free'; + if (res.ok) return 'taken'; + return 'unknown'; + } catch { + return 'unknown'; + } + }, + + async upload(dist) { + const form = new FormData(); + form.set(':action', 'file_upload'); + form.set('protocol_version', '1'); + // Core metadata, as the lowercased/underscored form fields warehouse expects. + form.set('metadata_version', '2.1'); + form.set('name', dist.name); + form.set('version', dist.version); + form.set('summary', dist.summary); + // File metadata. + form.set('filetype', 'sdist'); + form.set('pyversion', 'source'); + form.set('sha256_digest', dist.sha256); + form.set('content', new Blob([dist.bytes], { type: 'application/octet-stream' }), dist.filename); + + // PyPI API tokens authenticate as HTTP Basic with the literal user `__token__`. + const auth = Buffer.from(`__token__:${token ?? ''}`).toString('base64'); + try { + const res = await fetch(registry.upload, { + method: 'POST', + headers: { authorization: `Basic ${auth}`, 'user-agent': USER_AGENT }, + body: form, + }); + return { status: res.status, ok: res.ok, body: (await res.text()).trim() }; + } catch (e) { + return { status: 0, ok: false, body: (e as Error).message }; + } + }, + }; +} + +/** A short human reason from an upload failure — PyPI's errors are plain text. */ +export function pypiErrorReason(res: PypiResponse): string { + if (res.status === 403) { + return `${res.body || 'Forbidden'}\n(a 403 usually means the token is wrong/revoked, or the account is missing a verified email or 2FA)`; + } + // Warehouse prefixes many 400s with the HTML error page title; keep the first lines. + const body = res.body.split('\n').slice(0, 4).join('\n'); + return body || `HTTP ${res.status}`; +} + +/** Do PyPI API tokens look like this? (Format check only — the server is authoritative.) */ +export function looksLikeToken(token: string): boolean { + return token.startsWith('pypi-'); +} + +/** + * Why a "free" result isn't a promise. PyPI rejects names on rules none of which are + * queryable: PEP 503 normalization, "ultranormalization" (strip `.-_`, `l`/`i`→`1`, + * `o`→`0` — so `my-lib` and `myl1b` collide), a prohibited-names list, stdlib + * collisions, and a typosquatting corpus. `check_project_name` only runs on submit. + */ +export const AVAILABILITY_CAVEAT = + 'PyPI also blocks names that merely *resemble* an existing one: it strips . - _ and\n' + + 'reads l/i as 1 and o as 0, so "my-lib" and "myl1b" collide. Stdlib names and typosquats\n' + + "are blocked too. None of that is queryable up front — you'll find out on upload."; + +/** The trusted-publishing settings page for a project that exists. */ +export function publishingSettingsUrl(registry: PypiRegistry, name: string): string { + return `${registry.index}/manage/project/${normalizePypiName(name)}/settings/publishing/`; +} + +/** The pending-publisher page — for projects that don't exist yet (max 3 at a time). */ +export function pendingPublisherUrl(registry: PypiRegistry): string { + return `${registry.index}/manage/account/publishing/`; +}