Skip to content
Merged
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/new-name-wizard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
fledgling: minor
---

Prompt for a new package name in the wizard.
40 changes: 31 additions & 9 deletions src/interactive.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -49,14 +49,36 @@ export async function runWizard(values: Record<string, any>, 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) {
Expand Down
51 changes: 51 additions & 0 deletions src/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean | null> {
// 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;
Expand Down