Ctrl K
ComunidadGuías…

scripts/lane-seed.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * Seed standing test connections into a QA lane.
 *
 *   npm run lane:seed -- <amber|cobalt|violet> (--fixtures | --agent <agentId>) [--replace] [--manifest <path>] [--dry-run]
 *
 * --fixtures seeds the lane's standing, unpublished `qa-fixtures` Agent (created
 * when missing). --agent seeds a named Agent instead. A connection already
 * seeded with the same credential is `present`; a rotated or unrecorded one is
 * `stale` and left alone unless --replace rewrites it in place.
 *
 * The manifest (default ~/.chickpea/qa-seed.json) holds no secrets. It names
 * catalog connectors and, for token connectors, which name in the lane
 * secrets file carries the credential:
 *
 *   { "schemaVersion": "chickpea-lane-seed/v1",
 *     "connections": [
 *       { "connector": "asana", "secret": "ASANA_QA_TOKEN" },
 *       { "connector": "zendesk", "secret": "ZENDESK_QA_TOKEN", "fields": { "workspaceSubdomain": "acme" } },
 *       { "connector": "gmail" } ] }
 *
 * Values come from the lane secrets file (LANE__NAME overrides NAME) and go
 * straight to the lane's seed route; they are never printed. Token connectors
 * are created on the Agent; OAuth and managed connectors come back as Admin
 * setup links to finish with a consent click in a signed-in browser.
 *
 * There is no --bind: Chickpea binds each connection to exactly one Agent for
 * life, so fixture connections cannot be attached to a run-owned Agent.
 */
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import {
  laneCredentialsDirectory,
  LANE_SECRET_TARGETS,
  laneSecretValue,
  readLaneSecretEntries,
  readLaneSeedToken,
} from './lib/lane-secrets.mjs';

const SEED_PATH = '/internal/environment/seed';
const AGENT_ID = /^[a-z0-9][a-z0-9_-]{0,127}$/;
const SECRET_NAME = /^[A-Z][A-Z0-9_]*$/;

/** The default manifest path; shared with scripts/cloud-private-home.mjs. */
export function defaultSeedManifest() {
  return path.join(homedir(), '.chickpea', 'qa-seed.json');
}

export const FIXTURES_AGENT_ID = 'qa-fixtures';
const BIND_UNSUPPORTED = 'There is no --bind. Chickpea binds each connection to exactly one Agent, so ' +
  'a fixture connection cannot be shared with a run-owned Agent. Seed with --fixtures and run ' +
  'credential-backed cases on the qa-fixtures Agent, or seed a run-owned Agent with --agent and ' +
  'disconnect its connections at cleanup.';

export function parseArguments(argv) {
  const [lane, ...rest] = argv;
  const options = {
    lane,
    manifest: defaultSeedManifest(),
    dryRun: false,
    fixtures: false,
    replace: false,
  };
  for (let index = 0; index < rest.length; index += 1) {
    const flag = rest[index];
    if (flag === '--agent') options.agentId = rest[++index];
    else if (flag === '--fixtures') options.fixtures = true;
    else if (flag === '--replace') options.replace = true;
    else if (flag === '--manifest') options.manifest = rest[++index];
    else if (flag === '--dry-run') options.dryRun = true;
    else if (flag === '--bind' || flag.startsWith('--bind=')) throw new Error(BIND_UNSUPPORTED);
    else throw new Error(`Unknown argument "${flag}".`);
  }
  if (!LANE_SECRET_TARGETS.includes(options.lane)) throw new Error(`Choose a lane: ${LANE_SECRET_TARGETS.join(', ')}.`);
  if (options.fixtures && options.agentId !== undefined) {
    throw new Error('Pass either --fixtures or --agent <agentId>, not both.');
  }
  if (!options.fixtures && !AGENT_ID.test(options.agentId ?? '')) {
    throw new Error('Pass --fixtures for the standing fixtures Agent, or --agent <agentId> for a named Agent.');
  }
  if (!path.isAbsolute(options.manifest ?? '')) throw new Error('--manifest must be an absolute path.');
  return options;
}

export function parseManifest(text) {
  let manifest;
  try { manifest = JSON.parse(text); } catch { throw new Error('The seed manifest is not readable JSON.'); }
  if (manifest?.schemaVersion !== 'chickpea-lane-seed/v1' || !Array.isArray(manifest.connections) ||
      manifest.connections.length === 0) {
    throw new Error('The seed manifest needs schemaVersion "chickpea-lane-seed/v1" and a non-empty connections list.');
  }
  return manifest.connections.map((entry, index) => {
    if (typeof entry?.connector !== 'string' || !entry.connector.trim()) {
      throw new Error(`Seed manifest connection ${index + 1} needs a connector.`);
    }
    if (entry.secret !== undefined && (typeof entry.secret !== 'string' || !SECRET_NAME.test(entry.secret))) {
      throw new Error(`Seed manifest connection ${index + 1} names an invalid secret.`);
    }
    if (entry.fields !== undefined && (typeof entry.fields !== 'object' || entry.fields === null ||
        Object.values(entry.fields).some((value) => typeof value !== 'string'))) {
      throw new Error(`Seed manifest connection ${index + 1} has invalid fields.`);
    }
    return {
      connector: entry.connector.trim(),
      ...(entry.secret ? { secret: entry.secret } : {}),
      ...(entry.fields ? { fields: { ...entry.fields } } : {}),
    };
  });
}

/** Build the request body. Returns names that were declared but empty so they can be reported. */
export function buildSeedRequest({ lane, agentId, fixtures = false, replace = false, connections, entries }) {
  const missing = [];
  const body = {
    ...(fixtures ? { fixtures: true } : { agentId }),
    ...(replace ? { replace: true } : {}),
    connections: connections.map((connection) => {
      const credential = connection.secret && entries ? laneSecretValue(entries, lane, connection.secret) : undefined;
      if (connection.secret && !credential) missing.push(connection.secret);
      return {
        connector: connection.connector,
        ...(credential ? { credential } : {}),
        ...(connection.fields ? { fields: connection.fields } : {}),
      };
    }),
  };
  return { body, missing };
}

export function laneOrigin(lane, env = process.env) {
  const file = path.join(laneCredentialsDirectory(env), `${lane}-live.json`);
  if (!existsSync(file)) throw new Error(`No lane origin: ${file} is missing.`);
  let origin;
  try { origin = JSON.parse(readFileSync(file, 'utf8')).origin; } catch { origin = undefined; }
  const url = typeof origin === 'string' ? new URL(origin) : undefined;
  if (!url || url.protocol !== 'https:' || url.username || url.password) {
    throw new Error(`The ${lane} lane origin in ${file} must be an https URL without credentials.`);
  }
  return url.origin;
}

export function describeResults(response) {
  return (response.connections ?? []).map((result) => {
    const detail = [result.connectionId ?? result.adminUrl, result.reason ?? result.error]
      .filter(Boolean).join(' ');
    return `  ${result.connector.padEnd(24)} ${result.status.padEnd(18)} ${detail}`.trimEnd();
  });
}

async function main() {
  const options = parseArguments(process.argv.slice(2));
  if (!existsSync(options.manifest)) throw new Error(`No seed manifest at ${options.manifest}.`);
  const connections = parseManifest(readFileSync(options.manifest, 'utf8'));
  const entries = readLaneSecretEntries();
  const { body, missing } = buildSeedRequest({
    lane: options.lane,
    agentId: options.agentId,
    fixtures: options.fixtures,
    replace: options.replace,
    connections,
    entries,
  });
  const agentLabel = options.fixtures ? `${FIXTURES_AGENT_ID} (fixtures)` : options.agentId;
  if (missing.length) console.log(`Empty in the lane secrets file (sent without a credential): ${missing.join(', ')}`);
  if (options.dryRun) {
    for (const connection of body.connections) {
      console.log(`  ${connection.connector.padEnd(24)} ${connection.credential ? 'credential set' : 'no credential'}`);
    }
    return;
  }
  const seedToken = readLaneSeedToken(options.lane);
  if (!seedToken) {
    throw new Error(`The ${options.lane} lane has no seed token yet. Deploy it once with the guarded deploy.`);
  }
  const origin = laneOrigin(options.lane);
  const response = await fetch(new URL(SEED_PATH, origin), {
    method: 'POST',
    headers: { Authorization: `Bearer ${seedToken}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
    redirect: 'error',
    signal: AbortSignal.timeout(180_000),
  });
  const text = await response.text();
  let parsed;
  try { parsed = JSON.parse(text); } catch { parsed = undefined; }
  if (!response.ok) {
    const code = typeof parsed?.error === 'string' ? parsed.error : `http_${response.status}`;
    if (code === 'agent_inactive') {
      throw new Error(`Agent ${agentLabel} is disabled or archived on ${options.lane}; enable it before seeding connections.`);
    }
    throw new Error(response.status === 404 && !parsed?.error
      ? `The ${options.lane} lane did not accept its seed token (404). Redeploy the lane so it serves the seed route and token.`
      : `Seeding ${options.lane} failed: ${code}.`);
  }
  console.log(`Seeded ${options.lane} Agent ${agentLabel}:`);
  for (const line of describeResults(parsed)) console.log(line);
  const consent = (parsed.connections ?? []).filter((result) => result.status === 'needs_consent');
  if (consent.length) console.log('Open each setup link in a browser signed in to the lane Admin to finish consent.');
  if ((parsed.connections ?? []).some((result) => result.status === 'stale')) {
    console.log('A stale connection holds a different or unrecorded credential. Rerun with --replace to rewrite it in place.');
  }
  if ((parsed.connections ?? []).some((result) =>
    ['failed', 'unknown_connector', 'missing_credential', 'stale'].includes(result.status))) {
    process.exitCode = 1;
  }
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  main().catch((error) => {
    console.error(error instanceof Error ? error.message : String(error));
    process.exit(1);
  });
}