Ctrl K
ComunidadGuías…

scripts/cloudflare-deployment-profile.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * Select the authored core Cloudflare deployment or its optional Sandbox
 * overlay. This module is imported by Vite and also backs the cross-platform
 * npm convenience commands; no shell-specific environment assignment is
 * required.
 */
import { spawnSync } from 'node:child_process';
import { readdirSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { QA_TARGETS } from '../src/config/qa-targets.ts';

export const CORE_DEPLOYMENT_PROFILE = 'core';
export const SANDBOX_DEPLOYMENT_PROFILE = 'sandbox';
export const ACTIVE_CLOUDFLARE_DEPLOYMENT_TARGETS = QA_TARGETS;
const TARGET_DEFINITIONS = Object.freeze(Object.fromEntries(
  ACTIVE_CLOUDFLARE_DEPLOYMENT_TARGETS.map((target) => [target, Object.freeze({
    target,
    workerName: `chickpea-${target}-live`,
    authDatabaseBinding: 'AUTH_DB',
    authDatabaseName: `chickpea-auth-db-${target}-live`,
  })]),
));
const TARGET_VAR = 'CHICKPEA_DEPLOY_TARGET';
const TARGET_AUTH_DB_ID_VAR = 'CHICKPEA_DEPLOY_AUTH_DB_ID';
const TARGET_SCHEMA_GENERATION_VAR = 'CHICKPEA_DEPLOY_SCHEMA_GENERATION';
const AUTH_DB_SCHEMA_GENERATION_VAR = 'CHICKPEA_AUTH_DB_SCHEMA_GENERATION';
const DURABLE_OBJECT_SCHEMA_GENERATION_VAR = 'CHICKPEA_DURABLE_OBJECT_SCHEMA_GENERATION';
const TARGET_STATE_MODE_VAR = 'CHICKPEA_DEPLOY_STATE_MODE';
const TELEMETRY_ENVIRONMENT_VAR = 'CHICKPEA_TELEMETRY_ENVIRONMENT';
const QA_TELEMETRY_ENVIRONMENT = 'test';
// Vite bundles this helper into a temporary config module, so import.meta.url
// no longer points at `scripts/` during a build. Vite and the npm wrappers run
// from the project root; resolve there to match Wrangler's authored-path
// behavior and keep the generated deploy redirect valid.
const SANDBOX_DOCKERFILE_PATH = path.resolve(process.cwd(), 'Dockerfile');

export function resolveCloudflareDeploymentProfile(value = process.env.CHICKPEA_DEPLOY_PROFILE) {
  if (value === undefined || value === '' || value === CORE_DEPLOYMENT_PROFILE) {
    return CORE_DEPLOYMENT_PROFILE;
  }
  if (value === SANDBOX_DEPLOYMENT_PROFILE) return SANDBOX_DEPLOYMENT_PROFILE;
  throw new Error(
    `Invalid CHICKPEA_DEPLOY_PROFILE=${JSON.stringify(value)}. ` +
      'Use "core" (or leave it unset) or use "sandbox".',
  );
}

export function resolveCloudflareDeploymentTarget(value = process.env[TARGET_VAR]) {
  if (value === undefined || value === '') return undefined;
  if (typeof value === 'string' && ACTIVE_CLOUDFLARE_DEPLOYMENT_TARGETS.includes(value)) {
    return value;
  }
  throw new Error(
    `Invalid ${TARGET_VAR}=${JSON.stringify(value)}. ` +
      `Use exactly one of ${ACTIVE_CLOUDFLARE_DEPLOYMENT_TARGETS.join(', ')}.`,
  );
}

export function assertCloudflareQATelemetryEnvironment(value, target) {
  if (!ACTIVE_CLOUDFLARE_DEPLOYMENT_TARGETS.includes(target)) {
    throw new Error(`Cloudflare QA telemetry target ${JSON.stringify(target)} is invalid.`);
  }
  if (value !== QA_TELEMETRY_ENVIRONMENT) {
    throw new Error(
      `Cloudflare deployment target ${target} requires ${TELEMETRY_ENVIRONMENT_VAR}=${QA_TELEMETRY_ENVIRONMENT}.`,
    );
  }
  return QA_TELEMETRY_ENVIRONMENT;
}

function trimmed(value) {
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}

function onlyAuthDatabase(config, target) {
  const authDatabases = (config.d1_databases ?? []).filter(
    (binding) => binding?.binding === 'AUTH_DB',
  );
  if (authDatabases.length !== 1) {
    throw new Error(
      `Cloudflare deployment target ${target} requires exactly one AUTH_DB binding.`,
    );
  }
  return authDatabases[0];
}

function d1SchemaGeneration(authDatabase) {
  const configuredDirectory = trimmed(authDatabase.migrations_dir);
  if (!configuredDirectory) {
    throw new Error('Cloudflare deployment target requires reviewed AUTH_DB migrations.');
  }
  const directory = path.isAbsolute(configuredDirectory)
    ? configuredDirectory
    : path.resolve(process.cwd(), configuredDirectory);
  let migrations;
  try {
    migrations = readdirSync(directory)
      .filter((name) => name.endsWith('.sql'))
      .sort();
  } catch (error) {
    throw new Error(
      `Unable to resolve the AUTH_DB schema generation: ${
        error instanceof Error ? error.message : String(error)
      }`,
    );
  }
  const current = migrations.at(-1);
  if (!current) throw new Error('Unable to resolve the AUTH_DB schema generation: no SQL migrations.');
  return path.basename(current, '.sql');
}

function durableObjectSchemaGeneration(config) {
  const migrations = config.migrations ?? [];
  const tags = migrations.map((migration) => trimmed(migration?.tag));
  if (tags.length === 0 || tags.some((tag) => tag === undefined) || new Set(tags).size !== tags.length) {
    throw new Error('Unable to resolve one ordered Durable Object schema generation.');
  }
  return tags.at(-1);
}

function combinedSchemaGeneration(d1Generation, durableObjectGeneration) {
  return `d1:${d1Generation};do:${durableObjectGeneration}`;
}

function targetDefinition(target, coordinates) {
  const definition = TARGET_DEFINITIONS[target];
  if (target !== 'violet' || !coordinates) return definition;
  const { workerName, authDatabaseName } = coordinates;
  if (!/^[a-z0-9][a-z0-9_-]{0,62}$/u.test(workerName ?? '')
    || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/u.test(authDatabaseName ?? '')) {
    throw new Error('Violet requires exact registered Worker and AUTH_DB names.');
  }
  return { ...definition, workerName, authDatabaseName };
}

/**
 * Validate the selected immutable identity against non-secret registrations
 * supplied by the environment-registry layer. This helper intentionally does
 * not load or define that registry; callers provide only its resolved target
 * coordinates.
 */
export function validateCloudflareDeploymentTargetIdentity(
  selected,
  registeredTargetIdentities = [],
) {
  if (!Array.isArray(registeredTargetIdentities)) {
    throw new Error('Cloudflare deployment target identities must be an array.');
  }
  if (!ACTIVE_CLOUDFLARE_DEPLOYMENT_TARGETS.includes(selected?.target)) {
    throw new Error('Cloudflare selected deployment target identity is invalid.');
  }
  const selectedRegistration = registeredTargetIdentities.find(({ target }) => target === selected.target);
  const selectedDefinition = targetDefinition(selected.target, selectedRegistration ?? selected);
  if (
    selected.workerName !== selectedDefinition.workerName ||
    selected.authDatabaseBinding !== selectedDefinition.authDatabaseBinding ||
    selected.authDatabaseName !== selectedDefinition.authDatabaseName
  ) {
    throw new Error(
      `The immutable AUTH_DB identity for ${selected.target} does not agree with its Worker, binding, and database name.`,
    );
  }
  const immutableOwners = new Map();
  for (const registration of registeredTargetIdentities) {
    const target = registration?.target;
    if (!ACTIVE_CLOUDFLARE_DEPLOYMENT_TARGETS.includes(target)) {
      throw new Error(`Cloudflare registered deployment target ${JSON.stringify(target)} is invalid.`);
    }
    const definition = targetDefinition(target, registration);
    const databaseId = trimmed(registration?.authDatabaseId);
    const coordinatesAgree =
      registration?.workerName === definition.workerName &&
      registration?.authDatabaseBinding === definition.authDatabaseBinding &&
      registration?.authDatabaseName === definition.authDatabaseName;
    if (!coordinatesAgree) {
      throw new Error(
        `The immutable AUTH_DB identity for ${target} does not agree with its Worker, binding, and database name.`,
      );
    }
    if (
      target === selected.target &&
      selected.authDatabaseId &&
      databaseId &&
      databaseId !== selected.authDatabaseId
    ) {
      throw new Error(`The immutable AUTH_DB identity for ${target} disagrees with the selected ID.`);
    }
    if (!databaseId) continue;
    const previousOwner = immutableOwners.get(databaseId);
    if (previousOwner && previousOwner !== target) {
      throw new Error(
        `AUTH_DB identity ${databaseId} is registered to both ${previousOwner} and ${target}.`,
      );
    }
    immutableOwners.set(databaseId, target);
    if (selected.authDatabaseId === databaseId && target !== selected.target) {
      throw new Error(
        `AUTH_DB identity ${databaseId} is registered to ${target}, not selected target ${selected.target}.`,
      );
    }
  }
  return selected;
}

function prepareCloudflareDeploymentTarget(config, env, options) {
  const target = resolveCloudflareDeploymentTarget(env[TARGET_VAR]);
  if (!target) return undefined;
  const workerName = trimmed(env.CHICKPEA_DEPLOY_WORKER_NAME);
  const authDatabaseName = trimmed(env.CHICKPEA_DEPLOY_AUTH_DB_NAME);
  if ((workerName || authDatabaseName) && target !== 'violet') {
    throw new Error('Only an adopted Violet installation supports registered resource names.');
  }
  const definition = targetDefinition(target, workerName || authDatabaseName ? { workerName, authDatabaseName } : undefined);
  const authDatabase = onlyAuthDatabase(config, target);
  const configuredDatabaseId = trimmed(authDatabase.database_id);
  const immutableDatabaseId = trimmed(env[TARGET_AUTH_DB_ID_VAR]);
  if (
    authDatabase.database_name === definition.authDatabaseName &&
    configuredDatabaseId &&
    configuredDatabaseId !== immutableDatabaseId
  ) {
    throw new Error(
      `Cloudflare deployment target ${target} has AUTH_DB identity ${configuredDatabaseId}, ` +
      `but ${TARGET_AUTH_DB_ID_VAR} does not select that immutable database.`,
    );
  }
  const d1Generation = d1SchemaGeneration(authDatabase);
  const durableObjectGeneration = durableObjectSchemaGeneration(config);
  const schemaGeneration = combinedSchemaGeneration(d1Generation, durableObjectGeneration);
  const stateMode = immutableDatabaseId ? 'permanent' : 'disposable';
  if (stateMode === 'permanent' && trimmed(env[TARGET_SCHEMA_GENERATION_VAR]) !== schemaGeneration) {
    throw new Error(
      `Cloudflare permanent target ${target} must be explicitly advanced to schema generation ` +
      `${schemaGeneration}; older revisions are barred.`,
    );
  }
  const selected = {
    ...definition,
    authDatabaseId: immutableDatabaseId,
    d1SchemaGeneration: d1Generation,
    durableObjectSchemaGeneration: durableObjectGeneration,
    schemaGeneration,
    stateMode,
  };
  validateCloudflareDeploymentTargetIdentity(
    selected,
    options.registeredTargetIdentities ?? [],
  );
  return { selected, authDatabase };
}

export function readCloudflareDeploymentTargetTuple(config, env = process.env) {
  const requestedTarget = resolveCloudflareDeploymentTarget(env[TARGET_VAR]);
  const stampedTarget = trimmed(config.vars?.[TARGET_VAR]);
  if (!requestedTarget) {
    if (stampedTarget) {
      throw new Error(
        `Cloudflare deployment target mismatch: no target selected, generated ${stampedTarget}.`,
      );
    }
    return undefined;
  }
  const authDatabase = onlyAuthDatabase(config, requestedTarget);
  const definition = targetDefinition(requestedTarget, requestedTarget === 'violet' ? {
    workerName: trimmed(env.CHICKPEA_DEPLOY_WORKER_NAME) ?? TARGET_DEFINITIONS.violet.workerName,
    authDatabaseName: trimmed(env.CHICKPEA_DEPLOY_AUTH_DB_NAME) ?? TARGET_DEFINITIONS.violet.authDatabaseName,
  } : undefined);
  const immutableDatabaseId = trimmed(env[TARGET_AUTH_DB_ID_VAR]);
  const databaseId = trimmed(authDatabase.database_id);
  const d1Generation = trimmed(config.vars?.[AUTH_DB_SCHEMA_GENERATION_VAR]);
  const durableObjectGeneration = trimmed(config.vars?.[DURABLE_OBJECT_SCHEMA_GENERATION_VAR]);
  const schemaGeneration = trimmed(config.vars?.[TARGET_SCHEMA_GENERATION_VAR]);
  const stateMode = trimmed(config.vars?.[TARGET_STATE_MODE_VAR]);
  const expectedSchemaGeneration = d1Generation && durableObjectGeneration
    ? combinedSchemaGeneration(d1Generation, durableObjectGeneration)
    : undefined;
  const failures = [];
  if (stampedTarget !== requestedTarget) failures.push(`target stamp ${requestedTarget}`);
  if (config.name !== definition.workerName) failures.push(`Worker ${definition.workerName}`);
  if (config.topLevelName !== undefined && config.topLevelName !== definition.workerName) {
    failures.push(`top-level Worker ${definition.workerName}`);
  }
  if (authDatabase.database_name !== definition.authDatabaseName) {
    failures.push(`AUTH_DB name ${definition.authDatabaseName}`);
  }
  if (!d1Generation) failures.push('D1 schema generation');
  if (!durableObjectGeneration) failures.push('Durable Object schema generation');
  if (!schemaGeneration || schemaGeneration !== expectedSchemaGeneration) {
    failures.push('one combined schema generation');
  }
  if (immutableDatabaseId && databaseId !== immutableDatabaseId) {
    failures.push(`immutable AUTH_DB ID ${immutableDatabaseId}`);
  }
  const expectedStateMode = immutableDatabaseId ? 'permanent' : 'disposable';
  if (stateMode !== expectedStateMode) failures.push(`${expectedStateMode} state mode`);
  try {
    assertCloudflareQATelemetryEnvironment(
      config.vars?.[TELEMETRY_ENVIRONMENT_VAR],
      requestedTarget,
    );
  } catch {
    failures.push(`${TELEMETRY_ENVIRONMENT_VAR}=${QA_TELEMETRY_ENVIRONMENT}`);
  }
  if (
    immutableDatabaseId &&
    trimmed(env[TARGET_SCHEMA_GENERATION_VAR]) !== schemaGeneration
  ) {
    failures.push(`permanent schema advancement ${schemaGeneration}`);
  }
  if (failures.length) {
    throw new Error(
      `Cloudflare deployment target ${requestedTarget} is missing or unsafe: ${failures.join(', ')}.`,
    );
  }
  return {
    ...definition,
    authDatabaseId: databaseId,
    d1SchemaGeneration: d1Generation,
    durableObjectSchemaGeneration: durableObjectGeneration,
    schemaGeneration,
    stateMode,
  };
}

export function formatCloudflareDeploymentTargetTuple(tuple) {
  return (
    `Deployment target: target=${tuple.target} worker=${tuple.workerName} ` +
    `auth_db=${tuple.authDatabaseBinding}/${tuple.authDatabaseName} ` +
    `auth_db_id=${tuple.authDatabaseId ?? 'disposable'} ` +
    `d1_schema=${tuple.d1SchemaGeneration} ` +
    `do_schema=${tuple.durableObjectSchemaGeneration} state=${tuple.stateMode}`
  );
}

function sandboxResources(config) {
  const bindings = config.durable_objects?.bindings ?? [];
  const sandboxBindings = bindings.filter(
    (binding) => binding?.name === 'SANDBOX' || binding?.class_name === 'Sandbox',
  );
  const containers = config.containers ?? [];
  const sandboxContainers = containers.filter((container) => container?.class_name === 'Sandbox');
  return { bindings, sandboxBindings, containers, sandboxContainers };
}

export function classifyCloudflareDeploymentProfile(config) {
  const { sandboxBindings, containers, sandboxContainers } = sandboxResources(config);
  if (sandboxBindings.length === 0 && containers.length === 0) return CORE_DEPLOYMENT_PROFILE;
  const container = sandboxContainers[0];
  const effectiveWorkerName = config.topLevelName ?? config.name;
  const derivedContainerName =
    typeof effectiveWorkerName === 'string' ? `${effectiveWorkerName}-sandbox` : undefined;
  if (
    sandboxBindings.length === 1 &&
    sandboxBindings[0]?.name === 'SANDBOX' &&
    sandboxBindings[0]?.class_name === 'Sandbox' &&
    containers.length === 1 &&
    sandboxContainers.length === 1 &&
    container?.instance_type === 'standard-1' &&
    container?.max_instances === 25 &&
    path.isAbsolute(container?.image ?? '') &&
    path.resolve(container.image) === SANDBOX_DOCKERFILE_PATH &&
    (container.name === undefined || container.name === derivedContainerName)
  ) {
    return SANDBOX_DEPLOYMENT_PROFILE;
  }
  throw new Error('Cloudflare artifact contains partial or duplicate Sandbox infrastructure.');
}

export function applyCloudflareDeploymentProfile(config, env = process.env, options = {}) {
  const profile = resolveCloudflareDeploymentProfile(env.CHICKPEA_DEPLOY_PROFILE);
  if (profile === SANDBOX_DEPLOYMENT_PROFILE && classifyCloudflareDeploymentProfile(config) !== CORE_DEPLOYMENT_PROFILE) {
    throw new Error('The Sandbox overlay must be applied to the slim core Cloudflare config.');
  }
  const preparedTarget = prepareCloudflareDeploymentTarget(config, env, options);
  const overrideName = env.WRANGLER_CI_OVERRIDE_NAME;
  if (
    preparedTarget &&
    typeof overrideName === 'string' &&
    overrideName.length > 0 &&
    overrideName !== preparedTarget.selected.workerName
  ) {
    throw new Error(
      `WRANGLER_CI_OVERRIDE_NAME must match selected target Worker ${preparedTarget.selected.workerName}.`,
    );
  }
  if (preparedTarget) {
    const { selected, authDatabase } = preparedTarget;
    config.name = selected.workerName;
    config.topLevelName = selected.workerName;
    authDatabase.database_name = selected.authDatabaseName;
    authDatabase.database_id = selected.authDatabaseId ?? '';
    config.vars = {
      ...(config.vars ?? {}),
      [TARGET_VAR]: selected.target,
      [AUTH_DB_SCHEMA_GENERATION_VAR]: selected.d1SchemaGeneration,
      [DURABLE_OBJECT_SCHEMA_GENERATION_VAR]: selected.durableObjectSchemaGeneration,
      [TARGET_SCHEMA_GENERATION_VAR]: selected.schemaGeneration,
      [TARGET_STATE_MODE_VAR]: selected.stateMode,
      [TELEMETRY_ENVIRONMENT_VAR]: QA_TELEMETRY_ENVIRONMENT,
    };
  } else if (typeof overrideName === 'string' && overrideName.length > 0) {
    // Wrangler applies this name during Deploy-button builds. Applying it at
    // Vite config time too lets the plugin derive a matching, collision-safe
    // Container application name instead of reusing `chickpea-sandbox`.
    config.name = overrideName;
    config.topLevelName = overrideName;
  }

  if (profile === SANDBOX_DEPLOYMENT_PROFILE) {
    config.durable_objects ??= {};
    config.durable_objects.bindings ??= [];
    config.durable_objects.bindings.push({ name: 'SANDBOX', class_name: 'Sandbox' });
    config.containers = [
      {
        class_name: 'Sandbox',
        // The Vite customizer runs after Wrangler has resolved authored paths,
        // so keep the generated redirect portable by resolving this new path
        // explicitly just as the plugin does for authored container entries.
        image: SANDBOX_DOCKERFILE_PATH,
        instance_type: 'standard-1',
        max_instances: 25,
      },
    ];
    // Coding-workspace checkpoints. No bucket_name: Wrangler provisions the
    // bucket on the first deploy and reuses it by binding name afterwards, so
    // every install path (CLI, Deploy button, Workers Builds) gets one. The
    // Worker's maintenance cron deletes checkpoints past their 3-day window.
    config.r2_buckets = [...(config.r2_buckets ?? []), { binding: 'BACKUP_BUCKET' }];
  }
  return preparedTarget?.selected;
}

function runNpmScript(profile, script, forwardedArgs) {
  const npmExecPath = process.env.npm_execpath;
  const command = npmExecPath ? process.execPath : process.platform === 'win32' ? 'npm.cmd' : 'npm';
  const args = npmExecPath
    ? [npmExecPath, 'run', script, ...(forwardedArgs.length ? ['--', ...forwardedArgs] : [])]
    : ['run', script, ...(forwardedArgs.length ? ['--', ...forwardedArgs] : [])];
  const result = spawnSync(command, args, {
    env: { ...process.env, CHICKPEA_DEPLOY_PROFILE: profile },
    stdio: 'inherit',
  });
  if (result.error) throw result.error;
  process.exit(result.status ?? 1);
}

if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
  const [profileValue, script, ...forwardedArgs] = process.argv.slice(2);
  const profile = resolveCloudflareDeploymentProfile(profileValue);
  if (profile !== SANDBOX_DEPLOYMENT_PROFILE || !['build', 'deploy'].includes(script ?? '')) {
    console.error(
      'Usage: node scripts/cloudflare-deployment-profile.mjs sandbox <build|deploy> [arguments...]',
    );
    process.exit(2);
  }
  runNpmScript(profile, script, forwardedArgs);
}