santifer/career-ops · error · Error
portals.yml not found
Error message
portals.yml not found
What it means
parsePortals reads portals.yml (the scanner configuration defining title filters and tracked companies) and throws this error when the file is missing or empty. The library refuses to proceed because no portal configuration means no companies or filters to scan.
Solutions
- Copy the template: cp templates/portals.example.yml portals.yml
- Run the career-ops onboarding (doctor.mjs / first-run flow) which creates portals.yml
- Check you are running from the repository root, not a subdirectory
- If reading programmatically, pass the YAML text directly via parsePortals(rawOverride)
Example fix
// before
const config = parsePortals(); // throws if portals.yml missing
// after
import { existsSync } from 'fs';
if (!existsSync('portals.yml')) copyFileSync('templates/portals.example.yml', 'portals.yml');
const config = parsePortals(); Defensive patterns
Strategy: validation
Validate before calling
if (!existsSync('portals.yml')) { copyFileSync('templates/portals.example.yml', 'portals.yml'); } Type guard
const hasPortals = (root) => existsSync(path.join(root, 'portals.yml'));
Try / catch
try { const config = parsePortals(); } catch (e) { if (e.message === 'portals.yml not found') { console.error('Run onboarding: cp templates/portals.example.yml portals.yml'); process.exit(1); } throw e; } Prevention
- Complete onboarding (doctor.mjs) which creates portals.yml
- Run scripts from the repo root or set the working directory explicitly
- Do not delete portals.yml; customize it instead
- In tests, always pass rawOverride YAML text
When it happens
Trigger: parsePortals() called when portals.yml does not exist in the working directory; readFile('portals.yml') returns empty string (empty file); tests calling parsePortals() without rawOverride while the repo has no portals.yml.
Common situations: Fresh clone before onboarding Step 3 (portals.yml is copied from templates/portals.example.yml); running the script from the wrong working directory; user deleted or renamed portals.yml; empty file after a failed write.
Understand the failure class
Background: "Config file not found": what it means and how to fix it in docker-sync, Maven, Vagrant, Turborepo and other tools — this error's family across 60 libraries.
Related errors
- portals file not found
- arbeitsagentur: entry
- Cannot read benchmarks at
- config/profile.yml is empty or invalid YAML: fill it in…
- config/profile.yml not found: fill it in first
AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16).
Data as JSON: /api/errors/70b90d632be0c9f9.
Report an issue: GitHub.
Appendix: source
Thrown at openrouter-runner.mjs:472
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
const html = await r.text();
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 16_000);
} catch (e) {
throw new Error(`Could not fetch job page: ${e.message}`);
}
}
// ---------------------------------------------------------------------------
// portals.yml parser — reads the canonical schema with js-yaml (same library and
// field names as scan.mjs: `title_filter.positive/negative` + `tracked_companies`),
// so it never drifts from the main scanner. The runner's no-CLI scan path covers
// companies that expose a direct JSON `api:`; careers_url-only / Playwright /
// search-query companies are handled by the full /career-ops scan pipeline.
// `rawOverride` lets tests feed YAML text directly (see test-all.mjs drift guard).
// ---------------------------------------------------------------------------
export function parsePortals(rawOverride) {
const raw = rawOverride ?? readFile('portals.yml');
if (!raw) throw new Error('portals.yml not found');
const config = yaml.load(raw) || {};
// The shared predicate rather than a second copy of the matching rules. This
// path kept its own `includes` loop, and the two had drifted three ways: an
// empty positive list accepted every title in scan.mjs and rejected every
// title here, AND-groups worked only in scan.mjs, and a non-string YAML entry
// was dropped there but coerced into a live keyword here. A `word:` prefix
// would have become the fourth — read as literal text, it would have matched
// nothing, so the shipped `word:Intern` would stop rejecting "Operations
// Intern" here while still working in scan.mjs.
//
// Side effect worth naming, since it changes this path's verdicts rather than
// just its structure: it now also gets the 2-3 char rule. Measured over 2324
// real titles that moves one verdict, and it moves it the permissive way —
// the negative "iOS" had been matching inside "Biosamples". Nothing becomes
// newly rejected.
const titleMatches = buildTitleFilter(config.title_filter);
View on GitHub (pinned to aac998c7ed)