180 lines
5.3 KiB
TypeScript
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);
|
|
});
|