scripts/upgrade.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createInterface } from 'node:readline/promises';
import { assertNodeVersion } from './lib/node-version.mjs';
import { wranglerInspector } from './lib/inspect-deployment.mjs';
import { assertCompatibleRelease, inventoryDigest, normalizeStoredInstallation, overlayInstallation, validateInstallation, validateTarget, wranglerProfileArgs } from './lib/upgrade-installation.mjs';
import { assertPrivatePath, readPrivateJson, writePrivateJson, createRecoveryAuthority, validateRecoveryAuthority } from './lib/upgrade-receipt.mjs';
import { fetchReleaseSource, releaseTag, resolveOfficialRelease, verifyReleaseSource } from './lib/upgrade-source.mjs';
import { executePreparedUpgrade, requestDeliveryRecovery } from './lib/upgrade-execution.mjs';
import { AUTH_SCHEMA_QUERY, expectedAuthSchema, normalizeAuthSchemaRows } from './lib/auth-schema.mjs';
import { builtWorkerConfigPath } from './lib/built-worker-config.mjs';
import { prepareNpmInstallPolicy } from './lib/npm-install-policy.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const HELP = `Chickpea guided upgrades (Cloudflare)
Select an existing installation once, after Cloudflare login:
npm run upgrade -- --configure --account ACCOUNT_ID --worker WORKER_NAME --profile core --url https://YOUR_CHICKPEA_HOST
Inspect an exact release, without deploying:
npm run upgrade -- --to v0.1.1 --preflight
Upgrade after reviewing and confirming the selected Worker:
npm run upgrade -- --to v0.1.1
Resume or recover using the private receipt printed by the command:
npm run upgrade -- --resume /absolute/path/to/receipt.json
npm run upgrade -- --recover /absolute/path/to/receipt.json
Use --wrangler-profile NAME during --configure to retain a named Wrangler login
across inspection, deployment, resume, and recovery. This is separate from --profile core.
Use --installation NAME for multiple installations (default: default).
Recovery restores eligible previous code, not a data backup.
Unversioned installations require the evidence described in docs/runbooks/upgrading.md.
`;
function parseArgs(args) {
const result = {};
const flags = new Set(['help', 'configure', 'preflight']);
const values = new Set(['account', 'worker', 'profile', 'wrangler-profile', 'url', 'to', 'installation', 'resume', 'recover']);
for (let i = 0; i < args.length; i++) {
const key = args[i].startsWith('--') ? args[i].slice(2) : '';
if ((!flags.has(key) && !values.has(key)) || Object.hasOwn(result, key)) throw new Error('Unknown or repeated upgrade argument. Run npm run upgrade -- --help.');
if (flags.has(key)) result[key] = true;
else {
if (!args[i + 1] || args[i + 1].startsWith('--')) throw new Error('An upgrade argument is missing its value.');
result[key] = args[++i];
}
}
if (result.help) return result;
if ([result.configure, result.to, result.resume, result.recover].filter(Boolean).length !== 1) throw new Error('Choose exactly one operation: --configure, --to, --resume, or --recover.');
if (!result.configure && (result.account || result.worker || result.profile || result['wrangler-profile'] || result.url)) throw new Error('Target overrides are accepted only during --configure.');
if (result.preflight && !result.to) throw new Error('--preflight requires --to.');
if (result.to) releaseTag(result.to);
if (!/^[a-z0-9][a-z0-9-]{0,40}$/.test(result.installation ?? 'default')) throw new Error('Installation names use lowercase letters, digits, and hyphens.');
return result;
}
function privateDirectory(directory) {
mkdirSync(directory, { recursive: true, mode: 0o700 });
assertPrivatePath(directory, { directory: true });
return realpathSync(directory);
}
function targetEnvironment(target, { deploy = false, build = false } = {}) {
const env = { ...process.env };
for (const key of Object.keys(env)) {
if (key.startsWith('CHICKPEA_DEPLOY_') || key.startsWith('CHICKPEA_LOCAL_') || key.startsWith('DEPLOY_TEST_') ||
(key.startsWith('WRANGLER_') && key !== 'WRANGLER_HOME') || key === 'CHICKPEA_UPGRADE_CONTEXT' || key === 'CLOUDFLARE_ENV') delete env[key];
}
Object.assign(env, { CLOUDFLARE_ACCOUNT_ID: target.account, CHICKPEA_DEPLOY_PROFILE: target.profile });
if (!build) env.WRANGLER_CI_OVERRIDE_NAME = target.worker;
if (deploy) env.CHICKPEA_DEPLOY_TARGET = 'production';
return env;
}
const NPM_DIAGNOSTIC_CODES = new Set(['E401', 'E403', 'E404', 'EACCES', 'EPERM', 'ENOENT', 'ENOSPC',
'EINTEGRITY', 'ERESOLVE', 'EUSAGE', 'EBADENGINE', 'ELOCKVERIFY', 'ESTRICTALLOWSCRIPTS',
'EALLOWSCRIPTS', 'ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'ETIMEDOUT',
'ERR_SOCKET_TIMEOUT', 'CERT_HAS_EXPIRED', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'SELF_SIGNED_CERT_IN_CHAIN']);
function subprocess(command, args, cwd, env, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { cwd, env,
stdio: options.npmDiagnostics ? ['ignore', 'ignore', 'pipe'] : options.quiet ? 'ignore' : 'inherit',
...(options.signal ? { signal: options.signal } : {}) });
const codes = new Set();
let line = '';
child.stderr?.setEncoding('utf8');
child.stderr?.on('data', (chunk) => {
// Retain only recognized error codes. Paths, registry URLs, credentials
// and arbitrary hook output must not reach the terminal or receipt.
for (const character of chunk) {
if (character === '\n') {
const code = /^npm (?:error|ERR!) code ([A-Z0-9_]+)\r?$/.exec(line)?.[1];
if (NPM_DIAGNOSTIC_CODES.has(code)) codes.add(code);
line = '';
} else if (line.length <= 512) line += character;
}
});
child.once('error', () => reject(new Error('Unable to start the required local command.')));
child.once('close', (code) => code === 0 ? resolve() : reject(new Error(options.npmDiagnostics
? `NPM_INSTALL_FAILED: npm ci failed (exit ${code ?? 'interrupted'}${codes.size ? `; ${[...codes].join(', ')}` : ''}). Preserve the verified source and use the receipt to retry after checking npm configuration and registry access. Raw dependency output was withheld because it may contain credentials.`
: 'A local build or guarded deployment failed. Use the receipt to inspect and retry.')));
});
}
function npm(args, cwd, env, options) {
const executable = process.env.npm_execpath;
return executable ? subprocess(process.execPath, [executable, ...args], cwd, env, options) : subprocess('npm', args, cwd, env, options);
}
function readEvent(directory) {
const file = path.join(directory, 'deployment.json');
return existsSync(file) ? readPrivateJson(file) : undefined;
}
function lockTarget(directory) {
const file = path.join(directory, 'operation.json');
if (existsSync(file)) {
const previous = readPrivateJson(file);
if (!Number.isSafeInteger(previous.pid) || previous.pid < 1) throw new Error('Unreadable upgrade lock. Preserve it and inspect the prior operation.');
try { process.kill(previous.pid, 0); throw new Error('Another upgrade process holds this installation.'); }
catch (error) { if (error.code !== 'ESRCH') throw error; }
unlinkSync(file);
}
try { writeFileSync(file, JSON.stringify({ pid: process.pid }), { mode: 0o600, flag: 'wx' }); }
catch { throw new Error('Another upgrade process acquired this installation.'); }
return () => unlinkSync(file);
}
function schemaInspection(sourceRoot, configPath, target) {
const config = JSON.parse(readFileSync(configPath, 'utf8'));
const expected = expectedAuthSchema({ config, configPath });
const result = spawnSync(process.execPath, [path.join(root, 'node_modules/wrangler/bin/wrangler.js'), 'd1', 'execute', 'AUTH_DB', '--remote', '--json', '--command', AUTH_SCHEMA_QUERY, '--config', configPath, ...wranglerProfileArgs(target)],
{ cwd: sourceRoot, env: targetEnvironment(target), encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
if (result.error || result.status !== 0) throw new Error('Unable to inspect the existing AUTH_DB schema. No deployment was attempted.');
let payload;
try { payload = JSON.parse(result.stdout); } catch { throw new Error('Unreadable AUTH_DB schema response.'); }
if (!Array.isArray(payload) || payload.length !== 1 || payload[0]?.success === false ||
JSON.stringify(normalizeAuthSchemaRows(payload[0]?.results)) !== JSON.stringify(expected)) {
throw new Error('The existing AUTH_DB schema differs from the verified release. Preserve the database and investigate before upgrading.');
}
}
async function assertOfficialReceiptRelease(release, role) {
if (!release || typeof release !== 'object' || Array.isArray(release) ||
typeof release.tag !== 'string' || typeof release.version !== 'string' ||
typeof release.commit !== 'string') {
throw new Error(`Stored ${role} release identity is invalid. Preserve the receipt and investigate before retrying.`);
}
const official = await resolveOfficialRelease(release.tag);
if (release.tag !== official.tag || release.version !== official.version || release.commit !== official.commit) {
throw new Error(`Stored ${role} release identity does not match its immutable official release. Preserve the receipt and investigate before retrying.`);
}
return official;
}
/** Run the guided updater with immutable official releases. */
export async function runUpgrade(args = process.argv.slice(2)) {
const options = parseArgs(args);
if (options.help) { console.log(HELP); return; }
assertNodeVersion();
const stateRoot = privateDirectory(path.join(homedir(), '.chickpea', 'upgrades'));
const installations = privateDirectory(path.join(stateRoot, 'installations'));
const receipts = privateDirectory(path.join(stateRoot, 'receipts'));
const installationFile = path.join(installations, `${options.installation ?? 'default'}.json`);
let receipt; let directory;
if (options.resume || options.recover) {
const file = options.resume ?? options.recover;
if (!path.isAbsolute(file) || path.basename(file) !== 'receipt.json') throw new Error('Use the exact absolute receipt path printed by the original command.');
directory = path.dirname(file);
assertPrivatePath(directory, { directory: true });
if (path.dirname(realpathSync(directory)) !== receipts) throw new Error('Receipt belongs to a different upgrade-state directory.');
receipt = readPrivateJson(file);
if (receipt.schema !== 1 || receipt.id !== path.basename(directory)) throw new Error('Unknown or malformed upgrade receipt.');
// A retained checkout proves only what was downloaded earlier. Re-resolve
// both tags on every continuation so edited or legacy receipts cannot turn
// a different commit into executable upgrade or recovery source.
await assertOfficialReceiptRelease(receipt.previous, 'previous');
await assertOfficialReceiptRelease(receipt.destination, 'destination');
}
const stored = receipt ? undefined : options.configure ? undefined : readPrivateJson(installationFile);
const target = validateTarget(receipt?.target ?? (options.configure ? { ...options, wranglerProfile: options['wrangler-profile'] } : stored?.target));
if (!target.url) throw new Error('Configure the existing Chickpea HTTPS origin with --url before upgrading.');
const targetLock = privateDirectory(path.join(stateRoot, inventoryDigest({ account: target.account, worker: target.worker })));
const unlock = lockTarget(targetLock);
const inspectionDirectory = mkdtempSync(path.join(stateRoot, 'inspection-'));
try {
const inspectionConfigPath = path.join(inspectionDirectory, 'wrangler.json');
const inspectionConfig = { name: target.worker, account_id: target.account, compatibility_date: '2026-08-20' };
writePrivateJson(inspectionConfigPath, inspectionConfig);
const inspect = () => validateInstallation(wranglerInspector({ root, configPath: inspectionConfigPath, args: wranglerProfileArgs(target), env: targetEnvironment(target) }).inspect());
const current = inspect();
if (stored && (stored.schema !== 1 || stored.bindingsDigest !== inventoryDigest(current.resources))) throw new Error('The selected installation resources changed since configuration. Inspect the account and Worker before configuring again.');
if (options.configure) {
if (existsSync(installationFile)) throw new Error('This installation name is already configured. Use a new --installation name after reviewing the changed target.');
const previous = await resolveOfficialRelease(`v${current.version}`);
if (previous.commit !== current.commit) throw new Error('Serving source does not match the official release. Follow the adoption guide.');
writePrivateJson(installationFile, { schema: 1, target, bindingsDigest: inventoryDigest(current.resources) });
console.log(`Configured existing Worker ${target.worker} in account ${target.account} (${target.profile}; Wrangler login: ${target.wranglerProfile ?? 'automatic'}).`);
return;
}
if (!receipt) {
const destination = await resolveOfficialRelease(options.to);
const previous = await resolveOfficialRelease(`v${current.version}`);
if (current.commit !== previous.commit) throw new Error('Installed source does not match its official release tag. Follow the adoption guide.');
directory = mkdtempSync(path.join(receipts, 'upgrade-'));
console.log(`Private receipt: ${path.join(directory, 'receipt.json')}`);
receipt = { schema: 1, id: path.basename(directory), createdAt: new Date().toISOString(), target, previous, destination, stage: 'inspecting' };
writePrivateJson(path.join(directory, 'receipt.json'), receipt);
writePrivateJson(path.join(directory, 'installation.json'), current);
fetchReleaseSource(path.join(directory, 'previous'), previous);
fetchReleaseSource(path.join(directory, 'destination'), destination);
}
const initial = normalizeStoredInstallation(readPrivateJson(path.join(directory, 'installation.json')));
const previousRoot = path.join(directory, 'previous');
const destinationRoot = path.join(directory, 'destination');
// Verify retained source again on every retry/recovery, before running any
// dependency or build command from it. Missing partial downloads stop.
const previous = verifyReleaseSource(previousRoot, receipt.previous);
const destination = verifyReleaseSource(destinationRoot, receipt.destination);
assertCompatibleRelease(previous.manifest, destination.manifest);
// Releases before v0.1.8 declared `recovery: previous-code-only`. Every later
// one installs transport recovery, whether or not it declares it.
if (destination.manifest.recovery !== 'previous-code-only') {
if (!receipt.recovery) {
if (readEvent(directory)?.knownVersions?.length) throw new Error('Deployed upgrade is missing its recovery authority. Preserve the receipt.');
receipt.recovery = createRecoveryAuthority();
writePrivateJson(path.join(directory, 'receipt.json'), receipt);
}
validateRecoveryAuthority(receipt.recovery);
}
inspectionConfig.d1_databases = [{ binding: 'AUTH_DB', database_name: 'chickpea-auth-db', database_id: initial.databaseId,
migrations_dir: path.join(previousRoot, 'migrations/better-auth') }];
writePrivateJson(inspectionConfigPath, inspectionConfig);
schemaInspection(previousRoot, inspectionConfigPath, target);
const receiptPath = path.join(directory, 'receipt.json');
const contextPath = path.join(directory, 'context.json');
const save = (value) => writePrivateJson(receiptPath, { ...value, updatedAt: new Date().toISOString() });
const sourceRoot = (source) => source.commit === receipt.previous.commit ? previousRoot : destinationRoot;
const deployEnvironment = () => ({ ...targetEnvironment(target, { deploy: true }), CHICKPEA_UPGRADE_CONTEXT: contextPath });
const writeContext = (source, installation) => writePrivateJson(contextPath, { schema: 1, target, installation, source, sourceRoot: sourceRoot(source), ...(source.commit === receipt.destination.commit && receipt.recovery ? { recovery: receipt.recovery } : {}) });
const prepare = async (source, installation) => {
const checkout = sourceRoot(source);
verifyReleaseSource(checkout, source);
// Build retained releases with their authored name: v0.1.0's size check
// assumes that output directory. Retarget only generated configuration,
// leaving verified source, compiled app identity, and DO classes intact.
const buildEnvironment = targetEnvironment(target, { build: true });
const policy = prepareNpmInstallPolicy({ checkout, source, receiptRoot: directory, env: buildEnvironment });
try {
console.log(`Installing verified dependencies with npm ${policy.version} and the reviewed script policy...`);
// npm configuration and hook output can contain credentials. Keep it
// out of the public terminal and receipt; failures retain source for retry.
await npm(['ci', '--no-audit', '--no-fund', ...policy.args], checkout, policy.env, { npmDiagnostics: true, signal: policy.signal });
} finally { policy.dispose(); }
verifyReleaseSource(checkout, source);
await npm(['run', 'build'], checkout, buildEnvironment);
const configPath = builtWorkerConfigPath(checkout);
const config = JSON.parse(readFileSync(configPath, 'utf8'));
config.name = target.worker;
config.topLevelName = target.worker;
overlayInstallation(config, installation, target);
// Preserve the overlay only in the generated artifact, never tracked source.
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
writeContext(source, installation);
await subprocess(process.execPath, [path.join(root, 'scripts/deploy-with-epilogue.mjs'), '--skip-build', '--preflight-only', ...wranglerProfileArgs(target)], checkout, deployEnvironment());
};
if (options.preflight) {
await prepare(receipt.destination, current);
save({ ...receipt, stage: 'prepared' });
console.log(`Preflight passed for ${target.worker}: v${current.version} → ${receipt.destination.tag}. No deployment was attempted.\nReceipt: ${receiptPath}\nTooling directory: ${root}\nRetained destination source: ${destinationRoot}`);
return;
}
const result = await executePreparedUpgrade({ receipt, initial, direction: options.recover ? 'recover' : receipt.direction ?? 'upgrade', inspect,
readEvent: () => readEvent(directory), save, prepare,
recoverDelivery: (installation) => requestDeliveryRecovery({ url: target.url,
workerVersion: installation.workerVersion, capability: validateRecoveryAuthority(receipt.recovery).capability }),
confirm: async (source, installation, direction) => {
console.log(`\n${direction === 'recover' ? 'Recover previous code' : 'Upgrade'}: ${target.worker}\nCloudflare account: ${target.account}\nProfile: ${target.profile}\nWrangler login: ${target.wranglerProfile ?? 'automatic'}\nChickpea URL: ${target.url}\nServing: v${installation.version} (${installation.commit.slice(0, 12)})\nDestination: ${source.tag} (${source.commit.slice(0, 12)})\nExisting data and credential roots will be retained. This is not a data backup.\nReceipt: ${receiptPath}`);
if (!process.stdin.isTTY) throw new Error('Interactive confirmation is required. Run this command in your terminal.');
const input = createInterface({ input: process.stdin, output: process.stdout });
try { return (await input.question(`Type ${target.worker} to continue: `)).trim() === target.worker; }
finally { input.close(); }
},
deploy: async (source, installation) => {
writeContext(source, installation);
await subprocess(process.execPath, [path.join(root, 'scripts/deploy-with-epilogue.mjs'), '--skip-build', ...wranglerProfileArgs(target)], sourceRoot(source), deployEnvironment());
},
});
const servingSource = result === 'recovered' ? previousRoot : result === 'succeeded' ? destinationRoot : undefined;
console.log(`Upgrade ${result}. Receipt: ${receiptPath}\nTooling directory: ${root}${servingSource ? `\nRetained serving source: ${servingSource}` : ''}`);
} finally {
rmSync(inspectionDirectory, { recursive: true, force: true });
unlock();
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
runUpgrade().catch((error) => { console.error(error instanceof Error ? error.message : 'Upgrade failed. Preserve its private receipt.'); process.exitCode = 1; });
}