import { resolve, dirname, join, basename, parse, sep, relative, isAbsolute } from 'path'; import { fileURLToPath } from 'url'; import { existsSync, readFileSync, realpathSync } from 'fs'; import { homedir } from 'os'; const __dirname = dirname(fileURLToPath(import.meta.url)); // The directories under Claude Code's plugins root that hold a plugin's files // on Claude Code's behalf: cache/ (the per-version copy an install loads), // marketplaces/ (the marketplace clone, re-fetched on refresh) and synced/ // (plugins synced from claude.ai). Claude Code replaces each of them on its // own schedule, so none of them can hold a user's files. const PLUGIN_COPY_SUBDIRS = ['cache', 'marketplaces', 'synced']; /** * Claude Code's plugins roots: CLAUDE_CODE_PLUGIN_CACHE_DIR when set, and * `plugins/` under the config directory (CLAUDE_CONFIG_DIR, else ~/.claude). * Both are listed because the variable may be set for one process and not * another, and a copy under either is still a copy. * * @param {NodeJS.ProcessEnv} [env] * @returns {string[]} */ export function claudePluginsRoots(env = process.env) { const roots = []; const cacheDir = env.CLAUDE_CODE_PLUGIN_CACHE_DIR?.trim(); if (cacheDir) roots.push(resolve(cacheDir)); const configDir = env.CLAUDE_CONFIG_DIR?.trim(); roots.push(join(configDir ? resolve(configDir) : join(homedir(), '.claude'), 'plugins')); return roots; } function spellings(path) { const out = [resolve(path)]; try { out.push(realpathSync(path)); } catch { // Not on disk yet: the lexical spelling is all there is. } return process.platform === 'win32' ? out.map((p) => p.toLowerCase()) : out; } function isWithin(child, parent) { const rel = relative(parent, child); return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); } /** * True when `dir` is inside a copy Claude Code keeps for an installed plugin * (see PLUGIN_COPY_SUBDIRS). A checkout loaded in place, such as a marketplace * added from a local path or a project's .claude/skills/, is not a copy. * * @param {string} [dir] Defaults to this file's own directory: the code root * @param {NodeJS.ProcessEnv} [env] * @returns {boolean} */ export function isPluginInstallCopy(dir = __dirname, env = process.env) { const candidates = spellings(dir); return claudePluginsRoots(env).some((root) => PLUGIN_COPY_SUBDIRS.some((sub) => { const parents = spellings(join(root, sub)); return candidates.some((c) => parents.some((p) => isWithin(c, p))); })); } /** * Where a plugin install keeps the user's files when neither the environment * nor a marker says where: ~/career-ops-data. * * @returns {string} */ export function pluginDefaultDataRoot() { return join(homedir(), 'career-ops-data'); } /** * Thrown by getCareerOpsRoot() when an explicit data root (an environment * variable or the marker file) resolves inside a plugin install copy. Claude * Code deletes that copy on the next plugin update, so every write there would * be lost. The override is refused rather than redirected: it names a folder * on purpose, and silently writing somewhere else would hide the mistake. */ export class UnsafeDataRootError extends Error { constructor(root, source) { super( `${source} points inside a Claude Code plugin install (${root}), which Claude Code replaces on every plugin update. ` + `Set it to a folder outside ~/.claude/plugins, or remove it to use ${pluginDefaultDataRoot()}.`, ); this.name = 'UnsafeDataRootError'; this.code = 'CAREER_OPS_UNSAFE_DATA_ROOT'; this.root = root; this.source = source; } } function explicitRoot(root, source) { if (isPluginInstallCopy(root)) throw new UnsafeDataRootError(root, source); return root; } /** * Returns the resolved career-ops data directory root. * Priority: process.env.CAREER_OPS_ROOT or process.env.CAREER_OPS_DATA_DIR > * .career-ops-data marker file > ~/career-ops-data when the code root is a * Claude Code plugin install copy > codebase root (__dirname). * * @returns {string} Absolute path to the data root * @throws {UnsafeDataRootError} When the environment or the marker names a * folder inside a plugin install copy */ export function getCareerOpsRoot() { const envName = process.env.CAREER_OPS_ROOT?.trim() ? 'CAREER_OPS_ROOT' : 'CAREER_OPS_DATA_DIR'; const env = process.env.CAREER_OPS_ROOT?.trim() || process.env.CAREER_OPS_DATA_DIR?.trim(); if (env) { return explicitRoot(resolve(__dirname, env), envName); } const markerFile = join(__dirname, '.career-ops-data'); if (existsSync(markerFile)) { let content = ''; try { content = readFileSync(markerFile, 'utf-8').trim(); } catch { // ignore read errors } if (content) { return explicitRoot(resolve(__dirname, content), 'The .career-ops-data marker'); } } // A plugin install copy is replaced on every plugin update, so the code // root can't double as the data root there. if (isPluginInstallCopy(__dirname)) { return pluginDefaultDataRoot(); } return __dirname; } /** * True when CAREER_OPS_PROFILE names a profile. Callers that must tell "an * override is set" from "none" use this instead of comparing paths: an override * may name the data-root profile itself. * * @returns {boolean} */ export function hasProfileOverride() { return Boolean(process.env.CAREER_OPS_PROFILE?.trim()); } /** * Returns the path to the user's config/profile.yml. * Priority: rootDir argument > process.env.CAREER_OPS_PROFILE > data root. * A relative override resolves against the working directory, like the other * lane variables (CAREER_OPS_PORTALS, CAREER_OPS_PIPELINE, CAREER_OPS_SCAN_HISTORY). * * Every reader of the profile resolves it here. The override used to be * copied by hand into a handful of modules while the rest built the data-root * path themselves, so one install could read two different profiles (#5026). * A caller that passes its own rootDir has already said which workspace it * means, so the environment does not redirect it. * * @param {string} [rootDir] Explicit workspace root, when the caller has one * @returns {string} Path to the profile file: absolute unless the caller passed a relative rootDir */ export function getProfilePath(rootDir) { if (rootDir) return join(rootDir, 'config', 'profile.yml'); const override = process.env.CAREER_OPS_PROFILE?.trim(); return override ? resolve(override) : join(getCareerOpsRoot(), 'config', 'profile.yml'); } /** * Canonicalize a tracker path: absolute + realpath (macOS's symlinked tmpdir * among others). This must be applied by EVERY resolver of the same tracker: * two implementations briefly coexisted, the un-canonicalized one derived * different lock paths (/var vs /private/var) for the same file, and shared * writer exclusion silently broke. This module is the single source of truth; * tracker-utils.mjs re-exports from here so both import paths agree. * Deliberately dependency-light (fs/path only): test fixtures copy this file * standalone next to the scripts they exercise. */ export function canonicalizeTrackerPath(path) { const absolutePath = resolve(path); try { return realpathSync(absolutePath); } catch { return absolutePath; } } /** * The spelling the Go dashboard canonicalizes a path to, for the tracker lock. * * Only the lock may use it. Every other path in the repository is compared with * realpathSync() spellings, so returning it from canonicalizeTrackerPath() made * a short %TEMP% fail containment and backup-path checks across the tracker * scripts. * * @param {string} path - An absolute path, already through realpathSync. * @returns {string} The path with each component as the filesystem stores it on * Windows; unchanged elsewhere. */ export function storedSpelling(path) { return process.platform === 'win32' ? namesAsStoredOnWindows(path) : path; } /** * Spell each component of a Windows path the way the filesystem stores it. * * realpathSync() resolves links but keeps a component as it was written, so an * 8.3 short name (`C:\Users\RUNNER~1\...`, which %TEMP% often is) stays short. * The Go dashboard canonicalizes with filepath.EvalSymlinks, which writes the * long name, and the lock name is a hash of the path string: the two languages * derived two lock directories for one tracker and stopped excluding each other * (#4957). realpathSync.native() would expand the short name, but it also * resolves a `subst` drive to the directory behind it, which Go keeps as * written, so it would trade this divergence for another. Only each * component's own name is taken from it; the drive or share stays as given, * except that a drive letter is upper-cased, as EvalSymlinks writes it * (realpathSync keeps `c:\` as it was typed). * * @param {string} path - An absolute path with no links left in it. * @returns {string} */ function namesAsStoredOnWindows(path) { const { root } = parse(path); let out = /^[a-z]:/.test(root) ? root[0].toUpperCase() + root.slice(1) : root; for (const part of path.slice(root.length).split(sep).filter(Boolean)) { let name = part; try { name = basename(realpathSync.native(join(out, part))); } catch { // Unreadable here: keep the component as realpathSync wrote it. } out = join(out, name); } return out; } /** * Returns the canonical path to the tracker applications.md file for reading. * Priority: process.env.CAREER_OPS_TRACKER > root/data/applications.md > root/applications.md. * * @param {string} rootDir The career-ops data root directory * @returns {string} Canonical absolute path to the tracker file */ export function resolveTrackerPath(rootDir) { return canonicalizeTrackerPath(rawTrackerPath(rootDir)); } /** * The tracker path before canonicalization: the lexical location, with no * symlink along the path resolved. resolveTrackerPath() realpaths this for the * lock key, but workspace derivation uses the raw form instead (see * resolveWorkspaceRootFor) so a symlinked `data/` does not realpath the * workspace out of the repo (#3169). * * @param {string} rootDir The career-ops data root directory * @returns {string} Uncanonicalized tracker path */ export function rawTrackerPath(rootDir) { const env = process.env.CAREER_OPS_TRACKER?.trim(); return env ? env : existsSync(join(rootDir, 'data/applications.md')) ? join(rootDir, 'data/applications.md') : join(rootDir, 'applications.md'); } /** * Returns the resolved path to the tracker applications.md file for writing. * Priority: process.env.CAREER_OPS_TRACKER > root/data/applications.md. * Does not check for file existence, providing a deterministic write target. * * @param {string} root The career-ops data root directory * @returns {string} Absolute path to the tracker file for writing */ export function resolveTrackerPathForWrite(root) { const env = process.env.CAREER_OPS_TRACKER?.trim(); if (env) { // Same canonicalization as the read path (see canonicalizeTrackerPath): an // un-canonicalized env override derives divergent lock paths on symlinked // tmpdirs and breaks shared writer exclusion. return canonicalizeTrackerPath(env); } return join(root, 'data/applications.md'); } /** * Every batch/batch-state.tsv to take report numbers from. batch-runner.sh keeps * the file beside itself, in the checkout, wherever the data root is. batch/ is * System Layer, and batch-tailor.mjs, reconcile-pipeline.mjs, sync-batch-input.mjs * and the dashboard (#5254) all read the file there. A data root can hold a copy * of its own, and the failed rows in it still name reports that must not be * merged or handed out again. The runner never copies them into the checkout's * file, so both are read: preferring the checkout's once it existed dropped * them. In the default layout the two are one file. * * @param {string} rootDir The career-ops data root directory * @returns {string[]} Absolute paths, the checkout's first; a reader skips one that does not exist */ export function resolveBatchStatePaths(rootDir) { const checkout = join(__dirname, 'batch', 'batch-state.tsv'); const dataRoot = resolve(rootDir, 'batch', 'batch-state.tsv'); return dataRoot === checkout ? [checkout] : [checkout, dataRoot]; }