#!/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 [--target ] [--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 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; } }