Ctrl K
ComunidadGuías…

onboard-write.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
// Writes config/profile.yml and portals.yml from one JSON file of onboarding
// answers. The templates are copied line for line and only the answered
// fields change, so every comment and every tracked company survives.

import { existsSync, mkdirSync, readFileSync } from 'fs';
import { dirname, join, resolve } from 'path';
import { fileURLToPath } from 'url';
import { loadYaml } from './lib/yaml-document.mjs';
import { compileKeyword, foldTitle } from './title-keywords.mjs';
import { getCareerOpsRoot, getProfilePath } from './path-resolver.mjs';
import { writeFileAtomic } from './tracker-utils.mjs';
import { isMainModule } from './lib/is-main-module.mjs';

const CODE_ROOT = dirname(fileURLToPath(import.meta.url));
const PROFILE_TEMPLATE = join(CODE_ROOT, 'config', 'profile.example.yml');
const PORTALS_TEMPLATE = join(CODE_ROOT, 'templates', 'portals.example.yml');
const DEFAULT_MAX_AGE_DAYS = 45;
const CONTACT_KEYS = ['phone', 'linkedin', 'portfolio_url', 'github', 'twitter'];
const NOT_A_PLACE = /^(remote|hybrid|anywhere|on-?site)$/i;

const USAGE = `
  onboard-write.mjs: write config/profile.yml and portals.yml from onboarding answers

  node onboard-write.mjs --answers <file.json | -> [--target <dir>] [--force] [--only profile|portals]

  Existing files are kept unless --force. --only limits the run to one file. Prints {"written":[],"kept":[]} as JSON.
  Answers shape:
  {
    "candidate": { "full_name", "email", "location", "phone"?, "linkedin"?, "portfolio_url"?, "github"?, "twitter"?, "years_of_experience"? },
    "target_roles": ["..."],
    "compensation": { "target_range", "currency"?, "minimum"?, "location_flexibility"? },
    "location": { "country", "timezone", "city"?, "visa_status"?, "authorized_in"?, "needs_sponsorship"? },
    "spend_tier": "economy" | "standard" | "premium",
    "cv"?: { "max_pages"?: 1, "strict_pages"?: true },
    "archetypes": [{ "name", "level", "fit": "primary" | "secondary" | "adjacent" }]?,
    "narrative": { "headline"?, "exit_story"?, "superpowers"?: ["..."], "proof_points"?: [{ "name", "url"?, "hero_metric"? }] }?,
    "portals": {
      "positive": ["title keyword", ...],
      "negative": ["extra excluded word", ...],
      "countries": ["United States"],
      "cities": ["Berlin"],
      "max_posting_age_days": 45,
      "experience_filter"?: { "stretch_years"?: 1, "action"?: "label" | "drop" }
    }
  }
`;

class AnswerError extends Error {}

function parseArgs(argv) {
  const opts = { force: false };
  const value = (i, flag) => {
    const v = argv[i];
    if (v === undefined || (v.startsWith('-') && v !== '-')) throw new AnswerError(`${flag} needs a value`);
    return v;
  };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === '--answers') opts.answers = value(++i, a);
    else if (a === '--target') opts.target = value(++i, a);
    else if (a === '--force') opts.force = true;
    else if (a === '--only') opts.only = value(++i, a);
    else if (a === '--help' || a === '-h') opts.help = true;
    else throw new AnswerError(`unknown argument: ${a}`);
  }
  if (opts.only !== undefined && !['profile', 'portals'].includes(opts.only)) {
    throw new AnswerError(`--only takes profile or portals, got "${opts.only}"`);
  }
  return opts;
}

function list(value) {
  return Array.isArray(value) ? value.map((v) => String(v).trim()).filter(Boolean) : [];
}

function required(value, field) {
  const text = typeof value === 'string' ? value.trim() : '';
  if (!text) throw new AnswerError(`missing answer: ${field}`);
  return text;
}

export function normalizeAnswers(raw) {
  const a = raw && typeof raw === 'object' ? raw : {};
  const candidate = a.candidate || {};
  const location = a.location || {};
  const compensation = a.compensation || {};
  const portals = a.portals || {};
  const roles = list(a.target_roles);
  if (!roles.length) throw new AnswerError('missing answer: target_roles');
  const positive = list(portals.positive);
  if (!positive.length) throw new AnswerError('missing answer: portals.positive');
  const tier = a.spend_tier ?? 'standard';
  if (!['economy', 'standard', 'premium'].includes(tier)) {
    throw new AnswerError(`spend_tier must be economy, standard or premium, got "${tier}"`);
  }
  const places = [...list(portals.countries), ...list(portals.cities)];
  const notPlace = places.find((p) => NOT_A_PLACE.test(p));
  if (notPlace) {
    throw new AnswerError(`portals.countries and portals.cities take places only, not "${notPlace}". An allow-list entry like that admits every country, so leave it out.`);
  }
  const archetypes = Array.isArray(a.archetypes) ? a.archetypes.map((x, i) => ({
    name: required(x?.name, `archetypes[${i}].name`),
    level: x?.level?.trim() || '',
    fit: ['primary', 'secondary', 'adjacent'].includes(x?.fit) ? x.fit : 'primary',
  })) : [];
  const narrative = a.narrative || {};
  const age = portals.max_posting_age_days ?? DEFAULT_MAX_AGE_DAYS;
  if (!Number.isInteger(age) || age < 0) {
    throw new AnswerError('portals.max_posting_age_days must be a whole number of days (0 turns it off)');
  }
  const years = candidate.years_of_experience ?? null;
  if (years !== null && (typeof years !== 'number' || !Number.isFinite(years) || years < 0 || years > 60)) {
    throw new AnswerError('candidate.years_of_experience must be a number from 0 to 60');
  }
  const ef = portals.experience_filter;
  let experienceFilter = null;
  if (ef !== undefined && ef !== null && ef !== false) {
    if (years === null) throw new AnswerError('portals.experience_filter needs candidate.years_of_experience');
    const opts = typeof ef === 'object' ? ef : {};
    const stretch = opts.stretch_years ?? 1;
    if (typeof stretch !== 'number' || !Number.isFinite(stretch) || stretch < 0 || stretch > 60) {
      throw new AnswerError('portals.experience_filter.stretch_years must be a number from 0 to 60');
    }
    const action = opts.action ?? 'label';
    if (action !== 'drop' && action !== 'label') throw new AnswerError('portals.experience_filter.action must be drop or label');
    experienceFilter = { stretch_years: stretch, action };
  }
  // #6325: an optional CV page budget, asked for during onboarding when the
  // candidate is early-career. Absent keys stay commented out in the profile.
  const cv = a.cv && typeof a.cv === 'object' ? a.cv : {};
  const maxPages = cv.max_pages ?? null;
  if (maxPages !== null && (!Number.isInteger(maxPages) || maxPages < 1)) {
    throw new AnswerError('cv.max_pages must be a whole number of pages, 1 or more');
  }
  const strictPages = cv.strict_pages ?? null;
  if (strictPages !== null && typeof strictPages !== 'boolean') {
    throw new AnswerError('cv.strict_pages must be true or false');
  }
  const negative = list(portals.negative);
  for (const n of negative) {
    const matches = compileKeyword(foldTitle(n));
    const title = positive.find((p) => matches(foldTitle(p)));
    if (title) throw new AnswerError(`portals.negative "${n}" rejects the title "${title}"`);
  }
  return {
    candidate: {
      full_name: required(candidate.full_name, 'candidate.full_name'),
      email: required(candidate.email, 'candidate.email'),
      location: required(candidate.location, 'candidate.location'),
      ...Object.fromEntries(CONTACT_KEYS.map((k) => [k, typeof candidate[k] === 'string' ? candidate[k].trim() : ''])),
    },
    years_of_experience: years,
    target_roles: roles,
    compensation: {
      target_range: required(compensation.target_range, 'compensation.target_range'),
      currency: required(compensation.currency, 'compensation.currency'),
      minimum: compensation.minimum?.trim() || '',
      location_flexibility: compensation.location_flexibility?.trim() || '',
    },
    location: {
      country: required(location.country, 'location.country'),
      city: location.city?.trim() || '',
      timezone: required(location.timezone, 'location.timezone'),
      visa_status: location.visa_status?.trim() || '',
      authorized_in: list(location.authorized_in),
      needs_sponsorship: location.needs_sponsorship === true,
    },
    spend_tier: tier,
    cv: { max_pages: maxPages, strict_pages: strictPages },
    archetypes,
    narrative: {
      headline: narrative.headline?.trim() || '',
      exit_story: narrative.exit_story?.trim() || '',
      superpowers: list(narrative.superpowers),
      proof_points: (Array.isArray(narrative.proof_points) ? narrative.proof_points : []).map((x, i) => ({
        name: required(x?.name, `narrative.proof_points[${i}].name`),
        url: x?.url?.trim() || '',
        hero_metric: x?.hero_metric?.trim() || '',
      })),
    },
    portals: {
      positive,
      negative,
      countries: list(portals.countries),
      cities: list(portals.cities),
      max_posting_age_days: age,
      experience_filter: experienceFilter,
    },
  };
}

const q = (s) => JSON.stringify(s);

function sectionBounds(lines, section) {
  const start = lines.findIndex((l) => l === `${section}:`);
  if (start < 0) throw new Error(`template has no top-level "${section}:"`);
  let end = start + 1;
  while (end < lines.length && !/^[A-Za-z_]/.test(lines[end])) end++;
  return [start, end];
}

function keyLine(lines, section, key) {
  const [start, end] = sectionBounds(lines, section);
  for (let i = start + 1; i < end; i++) {
    if (lines[i].startsWith(`  ${key}:`)) return i;
  }
  throw new Error(`template has no ${section}.${key}`);
}

function setScalar(lines, section, key, value) {
  const i = keyLine(lines, section, key);
  const comment = lines[i].match(/\s+#[^"]*$/)?.[0] ?? '';
  lines[i] = `  ${key}: ${value}${comment}`;
}

function replaceNested(lines, at, nested) {
  let end = at + 1;
  while (end < lines.length && lines[end].startsWith('    ')) end++;
  lines.splice(at + 1, end - at - 1, ...nested);
}

function setList(lines, section, key, items) {
  const at = keyLine(lines, section, key);
  if (items.length) {
    lines[at] = `  ${key}:`;
    replaceNested(lines, at, items);
  } else {
    lines[at] = `  ${key}: []`;
    replaceNested(lines, at, []);
  }
}

function replaceItems(lines, at, items, indent) {
  let end = at + 1;
  while (end < lines.length && lines[end].startsWith(`${indent}- `)) end++;
  lines.splice(at + 1, end - at - 1, ...items.map((s) => `${indent}- ${q(s)}`));
}

export function buildProfile(template, a) {
  const lines = template.split('\n');
  for (const [k, v] of Object.entries(a.candidate)) setScalar(lines, 'candidate', k, q(v));
  replaceItems(lines, keyLine(lines, 'target_roles', 'primary'), a.target_roles, '    ');
  setList(lines, 'target_roles', 'archetypes', a.archetypes.map((x) => `    - { name: ${q(x.name)}, level: ${q(x.level)}, fit: ${x.fit} }`));
  setScalar(lines, 'narrative', 'headline', q(a.narrative.headline));
  setScalar(lines, 'narrative', 'exit_story', q(a.narrative.exit_story));
  setList(lines, 'narrative', 'superpowers', a.narrative.superpowers.map((x) => `    - ${q(x)}`));
  setList(lines, 'narrative', 'proof_points', a.narrative.proof_points.map((x) => `    - { name: ${q(x.name)}, url: ${q(x.url)}, hero_metric: ${q(x.hero_metric)} }`));
  for (const [k, v] of Object.entries(a.compensation)) setScalar(lines, 'compensation', k, q(v));
  const { authorized_in, needs_sponsorship, ...loc } = a.location;
  for (const [k, v] of Object.entries(loc)) setScalar(lines, 'location', k, q(v));
  setScalar(lines, 'location', 'authorized_in', `[${authorized_in.map(q).join(', ')}]`);
  setScalar(lines, 'location', 'needs_sponsorship', String(needs_sponsorship));
  if (a.years_of_experience !== null && a.years_of_experience !== undefined) {
    const at = lines.findIndex((l) => /^  # years_of_experience: /.test(l));
    if (at < 0) throw new Error('template has no "# years_of_experience" line');
    lines[at] = `  years_of_experience: ${a.years_of_experience}`;
  }
  const tier = lines.findIndex((l) => l.startsWith('spend_tier:'));
  if (tier < 0) throw new Error('template has no spend_tier');
  lines[tier] = `spend_tier: ${a.spend_tier}`;
  // #6325: uncomment the page-budget lines the example profile ships under cv:.
  for (const key of ['max_pages', 'strict_pages']) {
    const value = a.cv?.[key];
    if (value === null || value === undefined) continue;
    const at = lines.findIndex((l) => l.startsWith(`  # ${key}: `));
    if (at < 0) throw new Error(`template has no "# ${key}" line`);
    lines[at] = `  ${key}: ${value}`;
  }
  return lines.join('\n');
}

function blockAfter(lines, header, nextHeader) {
  const start = lines.findIndex((l) => l === header);
  const next = lines.findIndex((l, i) => i > start && l === nextHeader);
  if (start < 0 || next < 0) throw new Error(`template has no "${header.trim()}" block`);
  return [start, next];
}

export function buildPortals(template, a) {
  const p = a.portals;
  const lines = template.split('\n');
  const shippedNegatives = loadYaml(template).title_filter.negative.map((n) => foldTitle(String(n)));

  // Drop shipped negatives that would reject a user positive under the same
  // word:/stem:/substring rules the scanner uses.
  const conflicting = new Set(shippedNegatives.filter((negative) => {
    const matches = compileKeyword(negative);
    return p.positive.some((positive) => matches(foldTitle(positive)));
  }));
  const [negStart, negEnd] = blockAfter(lines, '  negative:', '  seniority_boost:');
  for (let i = negEnd - 1; i > negStart; i--) {
    const match = lines[i].match(/^\s+-\s+(.+)$/);
    if (!match) continue;
    let value;
    try { value = loadYaml(match[1]); } catch { continue; }
    if (typeof value === 'string' && conflicting.has(foldTitle(value))) lines.splice(i, 1);
  }
  const [, filteredNegEnd] = blockAfter(lines, '  negative:', '  seniority_boost:');
  let negInsert = filteredNegEnd;
  while (lines[negInsert - 1].trim() === '') negInsert--;
  const extra = [...new Set(p.negative)].filter((n) => !shippedNegatives.includes(foldTitle(n)));
  lines.splice(negInsert, 0, ...extra.map((n) => `    - ${q(n)}`));

  // Replace active positive entries only; keep the template's keyword guidance.
  const [posStart, posEnd] = blockAfter(lines, '  positive:', '  negative:');
  const keptPositiveNotes = [];
  for (let i = posStart + 1; i < posEnd; i++) {
    const line = lines[i];
    if (/^\s*#/.test(line) || line.trim() === '') keptPositiveNotes.push(line);
  }
  lines.splice(posStart + 1, posEnd - posStart - 1, ...keptPositiveNotes, ...p.positive.map((s) => `    - ${q(s)}`));

  if (p.max_posting_age_days > 0) {
    const age = lines.findIndex((l) => /^# max_posting_age_days: \d+$/.test(l));
    if (age < 0) throw new Error('template has no "# max_posting_age_days" line');
    lines[age] = `max_posting_age_days: ${p.max_posting_age_days}`;
  }

  if (p.experience_filter) {
    const at = lines.findIndex((l) => l.startsWith('# -- Title filter --'));
    if (at < 0) throw new Error('template has no title-filter section to anchor experience_filter');
    lines.splice(at, 0, 'experience_filter:', '  enabled: true',
      `  stretch_years: ${p.experience_filter.stretch_years}`, `  action: ${p.experience_filter.action}`, '');
  }

  if (p.countries.length || p.cities.length) {
    const at = lines.findIndex((l) => l.startsWith('# -- Country-eligibility filter'));
    if (at < 0) throw new Error('template has no country-eligibility section to anchor location_filter');
    const block = ['location_filter:'];
    if (p.countries.length) block.push('  always_allow:', ...p.countries.map((c) => `    - ${q(c)}`));
    block.push('  allow:', ...[...p.countries, ...p.cities].map((c) => `    - ${q(c)}`), '');
    lines.splice(at, 0, ...block);
  }
  return lines.join('\n');
}

function checkProfile(text, a) {
  const p = loadYaml(text);
  const ok = p.candidate.full_name === a.candidate.full_name
    && JSON.stringify(p.target_roles.primary) === JSON.stringify(a.target_roles)
    && p.location.country === a.location.country
    && p.spend_tier === a.spend_tier
    && (a.years_of_experience === null || a.years_of_experience === undefined || p.candidate.years_of_experience === a.years_of_experience)
    && ['max_pages', 'strict_pages'].every((k) => a.cv?.[k] === null || a.cv?.[k] === undefined || p.cv?.[k] === a.cv[k]);
  if (!ok) throw new Error('profile.yml did not read back as written');
}

function checkPortals(text, templateText, a) {
  const c = loadYaml(text);
  const t = loadYaml(templateText);
  const ok = JSON.stringify(c.title_filter.positive) === JSON.stringify(a.portals.positive)
    && c.tracked_companies.length === t.tracked_companies.length
    && (a.portals.max_posting_age_days > 0 ? c.max_posting_age_days === a.portals.max_posting_age_days : c.max_posting_age_days === undefined)
    && (a.portals.experience_filter
      ? c.experience_filter?.stretch_years === a.portals.experience_filter.stretch_years && c.experience_filter?.action === a.portals.experience_filter.action
      : c.experience_filter === undefined);
  if (!ok) throw new Error('portals.yml did not read back as written');
}

function readAnswers(source) {
  if (!source) throw new AnswerError('--answers <file.json | -> is required');
  const text = source === '-' ? readFileSync(0, 'utf8') : readFileSync(resolve(source), 'utf8');
  try {
    return JSON.parse(text);
  } catch (err) {
    throw new AnswerError(`answers are not valid JSON: ${err.message}`);
  }
}

export function main(argv = process.argv.slice(2)) {
  const opts = parseArgs(argv);
  if (opts.help) {
    console.log(USAGE);
    return 0;
  }
  const answers = normalizeAnswers(readAnswers(opts.answers));
  const root = opts.target ? resolve(opts.target) : getCareerOpsRoot();
  const targets = [
    {
      key: 'profile',
      rel: 'config/profile.yml',
      path: getProfilePath(opts.target ? root : undefined),
      build: () => {
        const text = buildProfile(readFileSync(PROFILE_TEMPLATE, 'utf8'), answers);
        checkProfile(text, answers);
        return text;
      },
    },
    {
      key: 'portals',
      rel: 'portals.yml',
      path: opts.target ? join(root, 'portals.yml') : (process.env.CAREER_OPS_PORTALS || join(root, 'portals.yml')),
      build: () => {
        const template = readFileSync(PORTALS_TEMPLATE, 'utf8');
        const text = buildPortals(template, answers);
        checkPortals(text, template, answers);
        return text;
      },
    },
  ];
  const planned = targets.map((t) => ({
    ...t,
    keep: (opts.only !== undefined && opts.only !== t.key) || (existsSync(t.path) && !opts.force),
  }));
  const texts = planned.map((t) => (t.keep ? null : t.build()));
  planned.forEach((t, i) => {
    if (t.keep) return;
    mkdirSync(dirname(t.path), { recursive: true });
    writeFileAtomic(t.path, texts[i]);
  });
  console.log(JSON.stringify({
    written: planned.filter((t) => !t.keep).map((t) => t.rel),
    kept: planned.filter((t) => t.keep).map((t) => t.rel),
  }));
  return 0;
}

if (isMainModule(import.meta.url)) {
  try {
    process.exitCode = main();
  } catch (err) {
    console.error(`onboard-write: ${err.message}`);
    if (err instanceof AnswerError) console.error(USAGE);
    process.exitCode = 1;
  }
}