// Shared machinery for the pm2 install profiles (ecosystem.light.config.cjs, // ecosystem.mac.light.config.cjs). // // A profile is a SUBSET of ecosystem.config.cjs, declared as names plus reasons. It never restates how // a process is launched — `script` and `args` are read from the host file at load — because a // hand-copied process list is exactly what failed here: the macOS list was written on 2026-07-28 and // within days was starting a sidecar that had been split in two and pointing at a pty entry point that // had moved. Neither failure said anything; the processes simply did not come up. // // So the rule is: ecosystem.config.cjs is the only place a launch command is written down, and a // profile only decides which of them to run. // // Two consistency checks, both of which turn a silent breakage into a loud one at load: // 1. a name the profile INCLUDES that the host no longer defines — the app was renamed or removed // 2. an app the host defines that the profile neither includes nor excludes — a new sidecar, which // must be classified deliberately rather than defaulting to absent because nobody noticed // // The second is the one that matters over time. Without it, every sidecar added to the host silently // stays out of every profile, and the profiles quietly stop meaning what their comments claim. /** * @param {object} spec * @param {string} spec.file this profile's filename, for error messages * @param {string[]} spec.include app names to run, in start order * @param {Record} spec.excluded app name → why it is not in this profile */ // The directory holding the platform's package.json, found by walking up from this file. Independent of // where in the tree this config is kept, and of where pm2 was invoked from. function repoRoot() { const { existsSync, readFileSync } = require('node:fs'); const { dirname, join } = require('node:path'); let dir = __dirname; for (;;) { const manifest = join(dir, 'package.json'); if (existsSync(manifest)) { try { if (JSON.parse(readFileSync(manifest, 'utf8')).name === 'officer') return dir; } catch { // Unparseable is not ours; keep walking. } } const up = dirname(dir); if (up === dir) throw new Error("ecosystem.profile.cjs: could not find the platform's package.json above " + __dirname); dir = up; } } function defineProfile({ file, include, excluded }) { const full = require('./ecosystem.config.cjs'); const byName = new Map(full.apps.map((app) => [app.name, app])); const missing = include.filter((name) => !byName.has(name)); if (missing.length) { throw new Error( `${file}: ${missing.join(', ')} not found in ecosystem.config.cjs — the app was renamed or ` + `removed. Update this profile's include list.`, ); } const unclassified = full.apps .map((app) => app.name) .filter((name) => !include.includes(name) && !(name in excluded)); if (unclassified.length) { throw new Error( `${file}: ${unclassified.join(', ')} is in ecosystem.config.cjs but neither included nor ` + `excluded here. Add it to the include list, or to the excluded map with a reason.`, ); } // `cwd` is pinned because Bun auto-loads .env from the working directory (and the pty sidecar does // `import 'dotenv/config'`). Without it, starting pm2 from anywhere but the repo root silently falls // back to the default PORT with no POSTGRES_URL. // // It also decides where the install is. src/servers/data-path.ts derives OFFICER_ROOT as the PARENT of // the working directory, and data/, capabilities/ and dockers/ hang off that — so a wrong cwd does not // fail, it relocates the whole install. `assertInstallLayout` is the boot check that catches it. // // This was `__dirname`, with a comment asserting "__dirname is the repo root — this file sits beside // ecosystem.config.cjs". That stopped being true the moment these files were moved into // ecosystem-files/, and nothing said so. Found by walking up to the package.json instead, which is // true wherever this file ends up living. return { apps: include.map((name) => ({ ...byName.get(name), cwd: repoRoot() })) }; } module.exports = { defineProfile };