Files
suaveplan/scripts/unpublish.ts
2026-05-27 23:47:53 +01:00

180 lines
5.3 KiB
TypeScript

#!/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 <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<string[]> {
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<void> {
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<void> {
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 <v> 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);
});