From d961663ce730a9bdf94bad6e36471536ef952768 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 18 Jun 2026 10:36:20 -0700 Subject: [PATCH 1/2] feat: prompt for a new package name in the wizard (with availability check) When the wizard finds no package.json, or when run as a bare `fledgling --new` with no names, prompt for a name to claim instead of bailing. The name is format-validated as you type (npm rules) and its availability is checked on submit via a fast HTTP HEAD to the registry, so a taken name re-prompts rather than failing later at claim time. Adds validatePackageName() and isNameAvailable() to npm.ts. --- src/interactive.ts | 40 ++++++++++++++++++++++++++++-------- src/npm.ts | 51 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/interactive.ts b/src/interactive.ts index f2e079c..087daa2 100644 --- a/src/interactive.ts +++ b/src/interactive.ts @@ -1,7 +1,7 @@ import * as p from '@clack/prompts'; import pc from 'picocolors'; import { findWorkspaceRoot, discoverPackages, detectRepo, type Pkg } from './workspace.js'; -import { npmWhoami, publishedNames, warmNpmAuth } from './npm.js'; +import { npmWhoami, publishedNames, warmNpmAuth, validatePackageName, isNameAvailable } from './npm.js'; import { resolveTargets, processTarget, @@ -49,14 +49,36 @@ export async function runWizard(values: Record, selectors: string[] // --- choose targets --- const onlyTrust = !!values['skip-publish']; let targets: Pkg[]; - if (discovered.length === 0 && selectors.length === 0) { - const name = await p.text({ - message: 'No packages found here. Name one to claim:', - placeholder: '@scope/my-package', - validate: v => (v?.trim() ? undefined : 'Enter a package name'), - }); - if (cancelled(name)) return cancel(); - targets = [{ name: String(name).trim(), dir: root, manifest: { name: String(name).trim() } }]; + // Prompt for a brand-new name when there's nothing to discover, or when the user asked + // for a bare `--new` (no names given). Format is validated as you type; availability is + // checked on submit so a taken name re-prompts instead of failing later at claim time. + const promptForNewName = selectors.length === 0 && (discovered.length === 0 || newClaim); + if (promptForNewName) { + const firstHere = discovered.length === 0; + let pkg: Pkg | undefined; + while (!pkg) { + const name = await p.text({ + message: firstHere ? 'No package.json here — name one to claim:' : 'Name to claim:', + placeholder: '@scope/my-package', + validate: v => validatePackageName((v ?? '').trim()), + }); + if (cancelled(name)) return cancel(); + const trimmed = String(name).trim(); + const nameSpin = hatchSpinner(); + nameSpin.start(`Checking ${pc.cyan(trimmed)} on npm…`); + const available = await isNameAvailable(trimmed, registry); + if (available === false) { + nameSpin.stop(pc.red(`📦 ${pc.cyan(trimmed)} is already taken — try another. ❌`)); + continue; + } + nameSpin.stop( + available === true + ? pc.green(`✓ ${pc.cyan(trimmed)} is available`) + : pc.dim(`Couldn't reach npm to check ${trimmed} — continuing (verified at claim time).`), + ); + pkg = { name: trimmed, dir: root, manifest: { name: trimmed }, isNew: true }; + } + targets = [pkg]; } else { const resolved = resolveTargets(discovered, selectors, !!values.new, root); if (resolved.error) { diff --git a/src/npm.ts b/src/npm.ts index cb5b98b..9e7107b 100644 --- a/src/npm.ts +++ b/src/npm.ts @@ -137,6 +137,57 @@ export async function publishedNames(names: string[], registry?: string, concurr return found; } +/** + * Validate a name against npm's package-name rules. Returns an error message to show + * the user, or undefined if the name is well-formed. (Format only — availability is a + * separate network check, see `isNameAvailable`.) + */ +export function validatePackageName(name: string): string | undefined { + if (!name) return 'Enter a package name'; + if (name.length > 214) return 'Too long — npm names are 214 characters max'; + if (name.trim() !== name) return 'No leading or trailing spaces'; + if (/[A-Z]/.test(name)) return 'Must be lowercase'; + const scoped = name.match(/^@([^/]+)\/([^/]+)$/); + if (name.startsWith('@') && !scoped) return 'Scoped names look like @scope/name'; + const parts = scoped ? [scoped[1], scoped[2]] : [name]; + for (const part of parts) { + if (!part) return 'Missing scope or name'; + if (/^[._]/.test(part)) return "Can't start with a dot or underscore"; + // npm allows url-safe chars; reject anything that would need encoding. + if (!/^[a-z0-9._~-]+$/.test(part)) return 'Use letters, numbers, and - . _ ~ only'; + } + return undefined; +} + +/** Registry base URL (no trailing slash) — the configured one, else the public npm registry. */ +function registryBase(registry?: string): string { + return (registry ?? 'https://registry.npmjs.org').replace(/\/+$/, ''); +} + +/** + * Is `name` free to claim on the registry? `true` = available (404), `false` = taken, + * `null` = couldn't tell (network/registry error — caller should let the user proceed). + * + * Uses a direct HTTP HEAD instead of `npm view` so it's fast enough to run interactively + * (no subprocess, ~one round-trip). The authoritative check still happens at claim time. + */ +export async function isNameAvailable(name: string, registry?: string, timeoutMs = 4000): Promise { + // The registry encodes the scope slash as %2f; everything else is path-safe. + const url = `${registryBase(registry)}/${name.replace('/', '%2f')}`; + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const res = await fetch(url, { method: 'HEAD', signal: ctrl.signal }); + if (res.status === 404) return true; + if (res.ok) return false; + return null; // 4xx/5xx we don't understand → unknown + } catch { + return null; // offline, DNS failure, abort, etc. + } finally { + clearTimeout(timer); + } +} + function withOtp(args: string[], otp?: string): string[] { if (otp) args.push(`--otp=${otp}`); return args; From 7c94dfd34a76b3c955645ab9b939ad629e9838e5 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 18 Jun 2026 16:12:09 -0700 Subject: [PATCH 2/2] chore: add bump file for new name wizard --- .bumpy/new-name-wizard.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .bumpy/new-name-wizard.md diff --git a/.bumpy/new-name-wizard.md b/.bumpy/new-name-wizard.md new file mode 100644 index 0000000..5a69365 --- /dev/null +++ b/.bumpy/new-name-wizard.md @@ -0,0 +1,5 @@ +--- +fledgling: minor +--- + +Prompt for a new package name in the wizard.