449 lines
14 KiB
JavaScript
449 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
// One-time npm bootstrap for a brand-new release package. Publishes a minimal
|
|
// placeholder at version 0.0.0 — never the package's real build output — so:
|
|
//
|
|
// - the PR CI gate (scripts/check-release-package-bootstrap.mjs) passes, since
|
|
// it only requires the name to resolve on the registry
|
|
// - trusted publishing can be configured on npmjs.com (the package page must
|
|
// exist before a trusted publisher rule can be added)
|
|
// - real package content only ever reaches npm from CI, after the PR that adds
|
|
// the package has been reviewed and merged
|
|
//
|
|
// The first real calver release supersedes the placeholder, and a stable
|
|
// release moves `latest` off it. The placeholder needs no local build and no
|
|
// workspace state, so it can run from any checkout (including master, before
|
|
// the package's PR merges).
|
|
//
|
|
// npm one-time passwords are single-use and time-limited, so the helper
|
|
// prompts for them interactively (publish and deprecate each need their own
|
|
// code) and hands them to npm through its environment (npm_config_otp) —
|
|
// codes never appear on a command line, in shell history, or in a process
|
|
// listing. It also waits for the registry to show the package before
|
|
// deprecating — a first publish can take a few minutes to become visible on
|
|
// the read/write endpoints.
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { createInterface } from "node:readline/promises";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const PLACEHOLDER_VERSION = "0.0.0";
|
|
|
|
const SCOPE_RE = /^@paperclipai\/[a-z0-9][a-z0-9._-]*$/;
|
|
|
|
const REGISTRY_POLL_INTERVAL_MS = 15_000;
|
|
const REGISTRY_POLL_ATTEMPTS = 40; // ~10 minutes
|
|
// Require back-to-back sightings: the write endpoint used by `npm deprecate`
|
|
// can trail the read endpoint, so one extra interval is cheap insurance.
|
|
const REGISTRY_POLL_CONSECUTIVE = 2;
|
|
|
|
const OTP_ATTEMPTS = 3;
|
|
|
|
function usage() {
|
|
process.stderr.write(
|
|
[
|
|
"Usage:",
|
|
" node scripts/bootstrap-npm-package.mjs <package-name> [--publish]",
|
|
"",
|
|
"Publishes an empty placeholder at version 0.0.0 that reserves <package-name> on npm",
|
|
"so the release-bootstrap CI gate passes and trusted publishing can be configured.",
|
|
"Real package content is only ever published by CI. Without --publish this is a dry run.",
|
|
"",
|
|
"With --publish the helper prompts for npm one-time passwords interactively",
|
|
"(publish and deprecate each need their own code) and hands them to npm via its",
|
|
"environment, so codes never appear on a command line.",
|
|
"",
|
|
"Examples:",
|
|
" node scripts/bootstrap-npm-package.mjs @paperclipai/new-package",
|
|
" node scripts/bootstrap-npm-package.mjs @paperclipai/new-package --publish",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
export function parseArgs(argv) {
|
|
const flags = new Set();
|
|
let packageName = null;
|
|
|
|
for (const arg of argv) {
|
|
if (arg === "--") {
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--publish") {
|
|
flags.add(arg);
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--help" || arg === "-h") {
|
|
return { help: true, packageName: null, publish: false };
|
|
}
|
|
|
|
if (arg.startsWith("--")) {
|
|
throw new Error(`unknown option: ${arg}`);
|
|
}
|
|
|
|
if (packageName) {
|
|
throw new Error("expected exactly one package name");
|
|
}
|
|
|
|
packageName = arg;
|
|
}
|
|
|
|
return {
|
|
help: false,
|
|
packageName,
|
|
publish: flags.has("--publish"),
|
|
};
|
|
}
|
|
|
|
export function validatePackageName(packageName) {
|
|
if (!SCOPE_RE.test(packageName)) {
|
|
throw new Error(
|
|
`refusing to publish a placeholder for ${JSON.stringify(packageName)}: ` +
|
|
"the name must be a lowercase package inside the @paperclipai scope " +
|
|
"(this guard prevents accidental publishes to names we do not own).",
|
|
);
|
|
}
|
|
}
|
|
|
|
export function buildPlaceholderFiles(packageName) {
|
|
const deprecationNote =
|
|
`${packageName}@${PLACEHOLDER_VERSION} is a placeholder that reserves the package name ` +
|
|
"for Paperclip's release pipeline. It contains no functionality; the first real release " +
|
|
"supersedes it. See https://github.com/paperclipai/paperclip";
|
|
|
|
const packageJson = {
|
|
name: packageName,
|
|
version: PLACEHOLDER_VERSION,
|
|
description:
|
|
"Placeholder publish reserving this name for Paperclip's release pipeline. Do not install this version.",
|
|
license: "MIT",
|
|
main: "index.js",
|
|
files: ["index.js"],
|
|
repository: {
|
|
type: "git",
|
|
url: "git+https://github.com/paperclipai/paperclip.git",
|
|
},
|
|
homepage: "https://github.com/paperclipai/paperclip",
|
|
publishConfig: {
|
|
access: "public",
|
|
},
|
|
};
|
|
|
|
const indexJs = `throw new Error(${JSON.stringify(deprecationNote)});\n`;
|
|
|
|
const readme = [
|
|
`# ${packageName}`,
|
|
"",
|
|
`Version ${PLACEHOLDER_VERSION} is a **placeholder publish**. It reserves this package name so`,
|
|
"Paperclip's release-bootstrap CI gate can pass before the package's first real",
|
|
"release ships from CI. It intentionally contains no functionality.",
|
|
"",
|
|
"Real versions are published by the release workflow of",
|
|
"[paperclipai/paperclip](https://github.com/paperclipai/paperclip).",
|
|
"",
|
|
].join("\n");
|
|
|
|
return {
|
|
"package.json": `${JSON.stringify(packageJson, null, 2)}\n`,
|
|
"index.js": indexJs,
|
|
"README.md": readme,
|
|
deprecationNote,
|
|
};
|
|
}
|
|
|
|
function runNpm(args, options = {}) {
|
|
const result = spawnSync("npm", args, {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
...options,
|
|
});
|
|
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
|
|
const stdout = result.stdout ?? "";
|
|
const stderr = result.stderr ?? "";
|
|
if (stdout) process.stdout.write(stdout);
|
|
if (stderr) process.stderr.write(stderr);
|
|
|
|
return result;
|
|
}
|
|
|
|
export function ensureNpmAuth() {
|
|
const result = runNpm(["whoami"]);
|
|
|
|
if (result.status === 0) {
|
|
return;
|
|
}
|
|
|
|
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
|
if (/\bE401\b|401 Unauthorized/i.test(output)) {
|
|
throw new Error(
|
|
[
|
|
"npm auth check failed.",
|
|
"This usually means the machine is either not logged into npm yet or has a stale token in ~/.npmrc.",
|
|
"Run `npm logout --registry=https://registry.npmjs.org/` and then `npm login` or `npm adduser` on this maintainer machine with an npm account that can publish to the @paperclipai scope, then rerun with --publish.",
|
|
"Do not use this auth flow in CI; it is only for the one-time human bootstrap publish.",
|
|
].join(" "),
|
|
);
|
|
}
|
|
|
|
throw new Error("npm whoami failed");
|
|
}
|
|
|
|
export function inspectNpmPackage(packageName) {
|
|
// Deliberately quiet: for a fresh bootstrap the expected outcome is E404
|
|
// ("the name is free"), and npm's error dump for that reads like a failure.
|
|
// Output is only surfaced when the query fails for an unexpected reason.
|
|
const result = spawnSync("npm", ["view", packageName, "version", "--json"], {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
|
|
if (result.status === 0) {
|
|
const version = JSON.parse((result.stdout ?? "").trim());
|
|
return { exists: true, version };
|
|
}
|
|
|
|
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
|
if (/\bE404\b|404 Not Found|could not be found/i.test(output)) {
|
|
return { exists: false };
|
|
}
|
|
|
|
if (output) process.stderr.write(`${output}\n`);
|
|
throw new Error(`failed to query npm for ${packageName}`);
|
|
}
|
|
|
|
export async function promptOtp(rl, purpose) {
|
|
for (;;) {
|
|
const answer = (await rl.question(`Enter the npm one-time password to ${purpose}: `)).trim();
|
|
if (answer) return answer;
|
|
process.stdout.write("A one-time password is required.\n");
|
|
}
|
|
}
|
|
|
|
export async function waitForPackageVisible(
|
|
packageName,
|
|
{
|
|
attempts = REGISTRY_POLL_ATTEMPTS,
|
|
intervalMs = REGISTRY_POLL_INTERVAL_MS,
|
|
consecutive = REGISTRY_POLL_CONSECUTIVE,
|
|
inspect = inspectNpmPackage,
|
|
sleep = delay,
|
|
} = {},
|
|
) {
|
|
let seen = 0;
|
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
if (attempt > 0) await sleep(intervalMs);
|
|
|
|
let state = null;
|
|
try {
|
|
state = inspect(packageName);
|
|
} catch {
|
|
state = null; // transient registry error: keep polling
|
|
}
|
|
|
|
if (state?.exists) {
|
|
seen += 1;
|
|
if (seen >= consecutive) return true;
|
|
} else {
|
|
seen = 0;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function publishPlaceholder(packageName, stageDir, rl) {
|
|
for (let attempt = 1; attempt <= OTP_ATTEMPTS; attempt += 1) {
|
|
const otp = await promptOtp(rl, `publish ${packageName}@${PLACEHOLDER_VERSION}`);
|
|
// Hand the code to npm through its environment (npm_config_otp), not argv,
|
|
// so it never appears in a process listing.
|
|
const result = runNpm(["publish", "--access", "public"], {
|
|
cwd: stageDir,
|
|
env: { ...process.env, npm_config_otp: otp },
|
|
});
|
|
if (result.status === 0) return;
|
|
|
|
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
|
if (/\bEOTP\b|one-time password/i.test(output) && attempt < OTP_ATTEMPTS) {
|
|
process.stdout.write("The code was rejected or expired. Try a fresh one.\n");
|
|
continue;
|
|
}
|
|
throw new Error(`npm publish failed with status ${result.status ?? "unknown"}`);
|
|
}
|
|
throw new Error("npm publish failed: too many rejected one-time passwords");
|
|
}
|
|
|
|
async function deprecatePlaceholder(packageName, deprecationNote, rl) {
|
|
const spec = `${packageName}@${PLACEHOLDER_VERSION}`;
|
|
for (let attempt = 1; attempt <= OTP_ATTEMPTS; attempt += 1) {
|
|
const otp = await promptOtp(rl, `deprecate ${spec}`);
|
|
const result = runNpm(["deprecate", spec, deprecationNote], {
|
|
env: { ...process.env, npm_config_otp: otp },
|
|
});
|
|
if (result.status === 0) return true;
|
|
|
|
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
|
if (/\bEOTP\b|one-time password/i.test(output)) {
|
|
process.stdout.write("The code was rejected or expired. Try a fresh one.\n");
|
|
continue;
|
|
}
|
|
if (/\bE404\b|404 Not Found/i.test(output)) {
|
|
process.stdout.write(
|
|
"The registry's write endpoint has not caught up yet; waiting 30s before retrying...\n",
|
|
);
|
|
await delay(30_000);
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function printManualDeprecateFallback(packageName, deprecationNote) {
|
|
process.stdout.write(
|
|
[
|
|
"",
|
|
"The placeholder could not be deprecated automatically. Once `npm view` resolves the package, run:",
|
|
`npm deprecate ${packageName}@${PLACEHOLDER_VERSION} ${JSON.stringify(deprecationNote)} --otp <code>`,
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
function printNextSteps(packageName) {
|
|
process.stdout.write(
|
|
[
|
|
"",
|
|
"Next:",
|
|
`1. Open https://www.npmjs.com/package/${packageName}`,
|
|
"2. Go to Settings -> Trusted publishing",
|
|
"3. Add repository paperclipai/paperclip",
|
|
"4. Set workflow filename to release.yml",
|
|
"5. Optionally enable Settings -> Publishing access -> Require two-factor authentication and disallow tokens",
|
|
`6. Only then flip the package to "publishFromCi": true in scripts/release-package-manifest.json`,
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
async function stageAndPublish(packageName, { publish }) {
|
|
const files = buildPlaceholderFiles(packageName);
|
|
const stageDir = mkdtempSync(join(tmpdir(), "paperclip-npm-placeholder-"));
|
|
|
|
try {
|
|
for (const fileName of ["package.json", "index.js", "README.md"]) {
|
|
writeFileSync(join(stageDir, fileName), files[fileName]);
|
|
}
|
|
|
|
process.stdout.write(`Staged placeholder for ${packageName} in ${stageDir}\n`);
|
|
process.stdout.write(`Previewing publish payload (npm publish --dry-run)...\n`);
|
|
const dryRun = runNpm(["publish", "--dry-run", "--access", "public"], { cwd: stageDir });
|
|
if (dryRun.status !== 0) {
|
|
throw new Error(`npm publish --dry-run failed with status ${dryRun.status ?? "unknown"}`);
|
|
}
|
|
|
|
if (!publish) {
|
|
process.stdout.write(
|
|
[
|
|
"",
|
|
"Dry run complete. To publish the placeholder from an authenticated maintainer machine, run:",
|
|
`node scripts/bootstrap-npm-package.mjs ${packageName} --publish`,
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
try {
|
|
await publishPlaceholder(packageName, stageDir, rl);
|
|
process.stdout.write(`Placeholder ${packageName}@${PLACEHOLDER_VERSION} published.\n`);
|
|
|
|
process.stdout.write(
|
|
"Waiting for the registry to show the package before deprecating (a first publish can take a few minutes)...\n",
|
|
);
|
|
const visible = await waitForPackageVisible(packageName);
|
|
|
|
let deprecated = false;
|
|
if (visible) {
|
|
deprecated = await deprecatePlaceholder(packageName, files.deprecationNote, rl);
|
|
} else {
|
|
process.stdout.write("Timed out waiting for the registry to show the package.\n");
|
|
}
|
|
|
|
if (deprecated) {
|
|
process.stdout.write(`Deprecated ${packageName}@${PLACEHOLDER_VERSION}.\n`);
|
|
} else {
|
|
printManualDeprecateFallback(packageName, files.deprecationNote);
|
|
}
|
|
|
|
printNextSteps(packageName);
|
|
} finally {
|
|
rl.close();
|
|
}
|
|
} finally {
|
|
rmSync(stageDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function main(argv) {
|
|
const { help, packageName, publish } = parseArgs(argv);
|
|
|
|
if (help) {
|
|
usage();
|
|
return;
|
|
}
|
|
|
|
if (!packageName) {
|
|
usage();
|
|
throw new Error("missing package name");
|
|
}
|
|
|
|
validatePackageName(packageName);
|
|
|
|
if (publish && !process.stdin.isTTY) {
|
|
throw new Error(
|
|
"--publish needs an interactive terminal: the helper prompts for npm one-time passwords instead of taking them as arguments.",
|
|
);
|
|
}
|
|
|
|
const npmState = inspectNpmPackage(packageName);
|
|
if (npmState.exists) {
|
|
throw new Error(
|
|
`${packageName} already exists on npm at version ${npmState.version}; the bootstrap flow is only for names that have never been published`,
|
|
);
|
|
}
|
|
|
|
process.stdout.write(`${packageName} is not on npm yet; continuing with placeholder bootstrap.\n`);
|
|
|
|
if (publish) {
|
|
process.stdout.write("Checking npm auth with npm whoami...\n");
|
|
ensureNpmAuth();
|
|
}
|
|
|
|
await stageAndPublish(packageName, { publish });
|
|
}
|
|
|
|
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
|
|
if (isDirectRun) {
|
|
try {
|
|
await main(process.argv.slice(2));
|
|
} catch (error) {
|
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.exit(1);
|
|
}
|
|
}
|