Initial Commit
This commit is contained in:
179
scripts/unpublish.ts
Normal file
179
scripts/unpublish.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user