#!/usr/bin/env bun /** * Remove one or more versions of a @suaveplan/* package from the Verdaccio * registry at https://npmjs.fq.io/. Used when a forced-republish accidentally * bumped a genesis package past 0.0.0 and we want to reclaim the version. * * Usage: * bun scripts/unpublish.ts --package @suaveplan/foo --version 0.0.1 * bun scripts/unpublish.ts --package @suaveplan/foo --version 0.0.1 --confirm * bun scripts/unpublish.ts --package @suaveplan/foo --all --confirm * * Defaults to dry-run — nothing is deleted unless `--confirm` is passed. * * Requires `NPM_TOKEN` in .env with unpublish scope on the Verdaccio registry. * (Verified: the token currently in use has unpublish rights.) */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { CONFIG } from "./lib/config.js"; const execFileAsync = promisify(execFile); const REGISTRY = CONFIG.registry.verdaccio.url; interface CliOptions { package: string | null; version: string | null; all: boolean; confirm: boolean; } function parseArgs(argv: readonly string[]): CliOptions { const opts: CliOptions = { package: null, version: null, all: false, confirm: false, }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === "--package") { opts.package = argv[++i] ?? null; } else if (arg === "--version") { opts.version = argv[++i] ?? null; } else if (arg === "--all") { opts.all = true; } else if (arg === "--confirm") { opts.confirm = true; } } return opts; } function printHelp(): void { console.log(`Usage: bun scripts/unpublish.ts --package <@suaveplan/name> --version bun scripts/unpublish.ts --package <@suaveplan/name> --all Flags: --confirm Actually perform the unpublish (default is dry-run) Examples: bun scripts/unpublish.ts --package @suaveplan/error --version 0.0.1 bun scripts/unpublish.ts --package @suaveplan/error --version 0.0.1 --confirm bun scripts/unpublish.ts --package @suaveplan/error --all --confirm`); } async function listVersions(pkg: string): Promise { try { const { stdout } = await execFileAsync("npm", [ "view", pkg, "versions", "--json", `--registry=${REGISTRY}`, ]); const parsed = JSON.parse(stdout) as string | string[]; return Array.isArray(parsed) ? parsed : [parsed]; } catch { return []; } } async function unpublishOne(pkg: string, version: string): Promise { await execFileAsync("npm", [ "unpublish", `${pkg}@${version}`, "--force", `--registry=${REGISTRY}`, ]); } function validatePackageName(name: string): void { if (!name.startsWith("@suaveplan/")) { throw new Error( `Package must start with '@suaveplan/' — refusing to operate on '${name}'.` ); } // Defence in depth: block path traversal / shell metacharacters even // though execFile doesn't invoke a shell. if (!/^@suaveplan\/[a-z0-9-]+$/.test(name)) { throw new Error( `Package name '${name}' fails strict validation (lowercase, digits, hyphens only).` ); } } function validateVersion(v: string): void { if (!/^\d+\.\d+\.\d+(?:-[0-9a-zA-Z.-]+)?$/.test(v)) { throw new Error(`Version '${v}' is not a valid semver.`); } } async function main(): Promise { const opts = parseArgs(process.argv.slice(2)); if (opts.package === null) { printHelp(); process.exit(1); } validatePackageName(opts.package); const existing = await listVersions(opts.package); if (existing.length === 0) { console.log(`No versions of ${opts.package} found on ${REGISTRY}.`); return; } let targets: string[]; if (opts.all) { targets = existing; } else if (opts.version !== null) { validateVersion(opts.version); if (!existing.includes(opts.version)) { console.log( `Version ${opts.version} not found on registry for ${opts.package}. Existing: ${existing.join(", ")}` ); return; } targets = [opts.version]; } else { console.error("Must pass either --version or --all."); printHelp(); process.exit(1); } console.log(`Package: ${opts.package}`); console.log(`Registry: ${REGISTRY}`); console.log(`Versions to unpublish: ${targets.join(", ")}`); console.log( `Remaining after op: ${ existing.filter((v) => !targets.includes(v)).join(", ") || "(none)" }` ); if (!opts.confirm) { console.log("\nšŸ” DRY RUN — pass --confirm to actually unpublish."); return; } for (const version of targets) { process.stdout.write(` • ${opts.package}@${version} ... `); try { await unpublishOne(opts.package, version); console.log("āœ…"); } catch (err) { console.log(`āŒ ${(err as Error).message}`); process.exit(1); } } console.log(`\nDone. ${targets.length} version(s) unpublished.`); } main().catch((err) => { console.error(err); process.exit(1); });