Ctrl K
ComunidadGuías…

scripts/upgrade.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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; });
}