mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-02 02:07:25 +08:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Its release pipeline publishes a set of npm packages from CI with npm trusted publishing (GitHub OIDC), gated by `scripts/release-package-manifest.json` > - A brand-new package name cannot be published by CI directly: the PR bootstrap gate requires the name to resolve on npm, and a trusted-publisher rule can only be configured after the package page exists > - The current bootstrap helper closes that gap by building the package locally and publishing its real output from a maintainer machine — before the PR that adds the package has passed CI or review > - This pull request replaces that flow: the helper now publishes a minimal deprecated placeholder at version `0.0.0` that only reserves the name, so every real version ships from CI > - The benefit is that unreviewed build output never reaches npm, and the bootstrap runs from any checkout (including `master`, before the new package's PR merges) with no local build ## Linked Issues or Issue Description **What existing behavior does this improve?** The one-time npm bootstrap for a brand-new release package (`pnpm run release:bootstrap-package`). **Current behavior** The helper builds the target package locally and publishes the real build output from a maintainer machine. That content has not passed repository CI or review at publish time. The helper also requires the new package to exist in the local workspace, so it must run from the (unmerged) PR branch that adds the package. **Proposed behavior** The helper publishes a three-file placeholder at version `0.0.0` (manifest, README, and an `index.js` that throws a descriptive error), waits for the registry to show the package, then deprecates it. The PR bootstrap gate (`scripts/check-release-package-bootstrap.mjs`) only requires the name to resolve on the registry, so the placeholder satisfies it. The first real calver release from CI supersedes the placeholder, and a stable release moves `latest` off it — the same `latest` window that existed under the old flow, but containing an explicit inert stub instead of unreviewed code. **Reason and benefit** Real package content only ever reaches npm from CI, after review and merge. The bootstrap becomes safer (scope guard refuses names outside `@paperclipai/`, already-published names are rejected) and simpler (no local build, no workspace state, runs from any checkout). **Breaking changes** None at runtime. The helper's CLI surface changes: it now takes a package name only (no directory selector) and drops `--skip-build`. `doc/PUBLISHING.md` is updated to match. ## What Changed - `scripts/bootstrap-npm-package.mjs`: replaced the build-and-publish flow with a placeholder publish — stages `package.json` + `README.md` + throwing `index.js` at version `0.0.0` in a temp directory, previews with `npm publish --dry-run`, and publishes only with `--publish`. One-time passwords are prompted interactively (never passed as arguments, since they are single-use and would land in shell history), with re-prompt on a rejected or expired code. After publishing, the helper polls the registry until the package is visible (a first publish can lag by minutes; verified live at ~5 minutes), requiring two consecutive sightings before prompting for a second code and deprecating the placeholder so accidental installs warn loudly; on timeout or failure it prints the exact manual `npm deprecate` command. Added an `@paperclipai/`-scope guard and a fail-fast error when `--publish` runs without an interactive terminal. Removed the workspace-plan dependency so it runs from any checkout. - `scripts/bootstrap-npm-package.test.mjs`: rewrote for the new interface — argument parsing, scope validation, the generated placeholder files (manifest shape, throwing entry point, README), the OTP re-prompt loop, and the registry poll (consecutive-sighting requirement, timeout, transient-error tolerance) via injected fakes. - `doc/PUBLISHING.md`: rewrote the "One-time bootstrap sequence for a new package" section for the placeholder flow, including the `latest` dist-tag window and the trusted-publishing setup ordering (placeholder publish → trusted publisher rule → `"publishFromCi": true`). - `.github/scripts/check-pr-release-bootstrap.mjs` (+ test, + wiring in `run-quality-gates.mjs`): new informational commitperclip notice on PRs that need this bootstrap. It fires when the PR newly release-enables a package that is missing from npm, or adds an unpublished `publishFromCi: false` package that published packages declare a `workspace:*` dependency on, and names the exact maintainer command — so contributors know the red `policy` check is not theirs to fix. It never fails the gate (the `policy` job remains the enforcer), only looks up scope-validated names on the registry, and stays quiet on registry errors. ## Verification - `node --test scripts/bootstrap-npm-package.test.mjs`: 13/13 pass - `node --test .github/scripts/tests/*.test.mjs`: 147/147 pass (10 new for the PR notice) - `pnpm run test:release-registry`: 82/82 pass - Replayed the new PR notice against a real historical PR's live API data (files, manifest at base and head refs): with the registry in its pre-bootstrap state it produces the exact maintainer instruction; with the package bootstrapped it stays silent - Full live end-to-end run: the flow bootstrapped `@paperclipai/adapter-kimi-local` for real — dry-run preview (634-byte, 3-file tarball), publish, registry visibility after ~5 minutes of propagation lag, deprecation confirmed via `npm view ... deprecated` - Guards verified live: an already-published name is rejected, an out-of-scope name (`left-pad`) is rejected, unknown options (including the removed `--otp`) are rejected, and `--publish` in a non-interactive shell fails fast before any network call ## Risks - The `latest` dist-tag points at the deprecated `0.0.0` placeholder until the first stable release supersedes it. This window also existed under the old flow (which parked `latest` at a locally built version); internal consumers are unaffected because release version rewrites pin exact calver versions. - The registry poll caps at ~10 minutes. If propagation is slower than that, the helper prints the exact `npm deprecate ... --otp <code>` command to run manually once `npm view` resolves. - The helper no longer validates the name against the workspace release plan, so a typo within the `@paperclipai/` scope would reserve a wrong name. The dry-run preview shows the exact name before any publish. ## Model Used - Anthropic, **Claude Fable 5** (`claude-fable-5`) via Claude Code, with repository, shell, and Git tooling. It analyzed the existing bootstrap flow and the release scripts (`release-package-map.mjs`, `check-release-package-bootstrap.mjs`, `release.sh` dist-tag handling), wrote the replacement script and tests, updated the documentation, and ran the verification above. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
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);
|
|
}
|
|
}
|