#!/usr/bin/env node /** * update-system.mjs — Safe auto-updater for career-ops * * Updates ONLY system layer files (modes, scripts, dashboard, templates). * NEVER touches user data (cv.md, profile.yml, _profile.md, data/, reports/). * * Usage: * node update-system.mjs check # Check if update available * node update-system.mjs apply # Apply update (after user confirms) * node update-system.mjs rollback # Rollback last update * node update-system.mjs dismiss # Dismiss update check * * See DATA_CONTRACT.md for the full system/user layer definitions. */ import { execFile, execFileSync, execSync } from 'child_process'; import { readFileSync, writeFileSync, existsSync, unlinkSync, rmSync } from 'fs'; import { join, dirname, posix as pathPosix } from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; // NOTE: this file must stay *self-loading* — no static (top-level) relative // imports. A pre-#1245 client's apply() self-reexec checks out ONLY // update-system.mjs before re-execing the target updater, so a static top-level // relative import here crashes that re-exec with ERR_MODULE_NOT_FOUND on the // old→new jump, before the fuller checkout that would materialize the imported // module ever runs (#1706). Local modules (e.g. the skill-entrypoints helper // under scaffolder/) are instead pulled in lazily at their point of use, by // which time the full update stage has already checked them out. The // updater-migration and test-all suites enforce this invariant. const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = __dirname; const CANONICAL_REPO = 'https://github.com/santifer/career-ops.git'; const RAW_VERSION_URL = 'https://raw.githubusercontent.com/santifer/career-ops/main/VERSION'; const RELEASES_API = 'https://api.github.com/repos/santifer/career-ops/releases/latest'; // Matches a semver, with or without a leading `v` and an optional // Release Please component prefix (e.g. `career-ops-v1.9.0` → `1.9.0`). // Anchoring on `(?:^|-)` lets the releases-API fallback parse our tags, // which Release Please always prefixes with the component name. export const SEMVER_RE = /(?:^|-)v?(\d+\.\d+\.\d+)$/i; // 120s: local git commands are normally instant, but a cloud-evicted working // tree (iCloud "optimize storage", OneDrive dehydration) can stall a plain // `git status` for a minute of pure I/O wait re-materializing files (#1393). export const DEFAULT_GIT_TIMEOUT_MS = parsePositiveInt(process.env.CAREER_OPS_GIT_TIMEOUT_MS, 120000); export const DEFAULT_GIT_FETCH_TIMEOUT_MS = parsePositiveInt( process.env.CAREER_OPS_GIT_FETCH_TIMEOUT_MS, Math.max(DEFAULT_GIT_TIMEOUT_MS, 300000), ); export const NPM_INSTALL_TIMEOUT_MS = parsePositiveInt(process.env.CAREER_OPS_NPM_INSTALL_TIMEOUT_MS, 60000); export const PLAYWRIGHT_INSTALL_TIMEOUT_MS = parsePositiveInt(process.env.CAREER_OPS_PLAYWRIGHT_INSTALL_TIMEOUT_MS, 120000); export const DASHBOARD_REBUILD_TIMEOUT_MS = parsePositiveInt(process.env.CAREER_OPS_DASHBOARD_REBUILD_TIMEOUT_MS, 60000); export const UPDATE_PATH_CHECKOUT_BUDGET_MS = parsePositiveInt(process.env.CAREER_OPS_UPDATE_PATH_CHECKOUT_BUDGET_MS, 5000); export const REEXEC_BUFFER_TIMEOUT_MS = parsePositiveInt(process.env.CAREER_OPS_REEXEC_BUFFER_TIMEOUT_MS, 60000); // System layer paths — ONLY these files get updated const SYSTEM_PATHS = [ 'modes/README.md', 'modes/_shared.md', 'modes/_profile.template.md', 'modes/_custom.template.md', 'modes/oferta.md', 'modes/pdf.md', 'modes/cover.md', 'modes/email.md', 'modes/add.md', 'modes/scan.md', 'modes/batch.md', 'modes/apply.md', 'modes/auto-pipeline.md', 'modes/contacto.md', 'modes/deep.md', 'modes/ofertas.md', 'modes/pipeline.md', 'modes/project.md', 'modes/tracker.md', 'modes/training.md', 'modes/interview.md', 'modes/interview-redflag.md', 'modes/latex.md', 'modes/latex-tex.md', 'modes/followup.md', 'modes/offer-prep.md', 'modes/interview-prep.md', 'modes/interview/', 'interview-prep/sessions/.gitkeep', 'interview-prep/sessions/README.md', 'modes/patterns.md', 'modes/titles.md', 'modes/upskill.md', 'modes/update.md', 'modes/agent-inbox.md', 'modes/reply-watch.md', 'modes/ar/', 'modes/da/', 'modes/de/', 'modes/de/interview/', 'modes/fr/', 'modes/fr/interview/', 'modes/hi/', 'modes/es/', 'modes/es/interview/', 'modes/id/', 'modes/it/', 'modes/ja/', 'modes/ko/', 'modes/pl/', 'modes/pt/', 'modes/ru/', 'modes/tr/', 'modes/ua/', 'modes/heuristics/', 'modes/regional/', 'modes/zh/', 'modes/zh-TW/', 'CLAUDE.md', 'CODEX.md', 'OPENCODE.md', 'AGENTS.md', 'GEMINI.md', 'KIMI.md', 'build-dashboard.mjs', 'generate-pdf.mjs', 'generate-latex.mjs', 'extract-latex-content.mjs', 'patch-latex-content.mjs', 'lib/latex-escape.mjs', 'lib/latex-content.mjs', 'img-to-pdf.mjs', 'archive-posting.mjs', 'application-answers.mjs', 'generate-cover-letter.mjs', 'merge-tracker.mjs', 'tracker-links.mjs', 'tracker.mjs', 'find.mjs', 'verify-pipeline.mjs', 'reconcile-pipeline.mjs', 'dedup-tracker.mjs', 'add-entry.mjs', 'role-matcher.mjs', 'tracker-utils.mjs', 'tracker-parse.mjs', 'tracker-aliases.json', 'set-status.mjs', 'set-status-tests.mjs', 'normalize-statuses.mjs', 'cv-sync-check.mjs', 'verify-cv-facts.mjs', 'update-system.mjs', 'reserve-report-num.mjs', 'scan.mjs', 'classify-tier.mjs', 'scan-ats-full.mjs', 'match-star.mjs', 'prepare-application.mjs', 'providers/', 'seeds/', 'tests/', 'doctor.mjs', 'check-liveness.mjs', 'liveness-core.mjs', 'liveness-api.mjs', 'liveness-browser.mjs', 'browser-extract.mjs', 'analyze-patterns.mjs', 'upskill.mjs', 'stats.mjs', 'detect-reposts.mjs', 'fingerprint-core.mjs', 'process-quality.mjs', 'process-quality.test.mjs', 'salary-gap.mjs', 'funnel-velocity.mjs', 'assessment-log.mjs', 'followup-cadence.mjs', 'followup-cadence.test.mjs', 'invite-match.mjs', 'invite-match.test.mjs', 'agent-inbox.mjs', 'followup-seed.mjs', 'followup-seed-tests.mjs', 'gemini-eval.mjs', 'ollama-eval.mjs', 'openai-eval.mjs', 'openai-tailor.mjs', 'eval-golden.mjs', 'evals/', 'openrouter-runner.mjs', 'test-all.mjs', 'detect-reposts.test.mjs', 'test-salary-filter.mjs', 'test-trust-validator.mjs', 'tracker-columns-tests.mjs', 'agent-inbox-tests.mjs', 'validate-portals.mjs', 'verify-portals.mjs', 'updater-migration-tests.mjs', 'validate-system-paths-coverage.mjs', 'reply-matcher.mjs', 'reply-matcher.test.mjs', 'reply-watch.mjs', 'batch/batch-prompt.md', 'batch/batch-runner.sh', 'batch/README.md', 'dashboard/', 'templates/', 'config/cv-facts.example.json', 'fonts/', 'examples/', 'config/profile.example.yml', '.env.example', '.editorconfig', '.agents/', '.claude/skills/', '.opencode/skills/', '.opencode/commands/', '.claude-plugin/', '.qwen/', '.antigravitycli/skills/', '.grok/skills/', '.kimi/skills/', 'docs/', 'writing-samples/README.md', 'VERSION', 'DATA_CONTRACT.md', 'CONTRIBUTING.md', 'MAINTAINERS.md', 'ARCHITECTURE.md', 'README.md', 'README.ar.md', 'README.cn.md', 'README.da.md', 'README.de.md', 'README.es.md', 'README.fr.md', 'README.hi.md', 'README.ja.md', 'README.ko-KR.md', 'README.pl.md', 'README.pt-BR.md', 'README.ru.md', 'README.ua.md', 'README.zh-TW.md', 'CHANGELOG.md', 'CODE_OF_CONDUCT.md', 'CONTRIBUTORS.md', 'GOVERNANCE.md', 'LEGAL_DISCLAIMER.md', 'SECURITY.md', 'SUPPORT.md', 'TRADEMARK.md', 'LICENSE', 'CITATION.cff', '.editorconfig', '.github/', 'package.json', 'build-cv-latex.mjs', 'build-cv-html.mjs', 'cv-templates.mjs', 'test/cv-templates.test.mjs', 'test/cover-resolver.test.mjs', 'scaffolder/', 'Dockerfile', 'docker-compose.yml', '.dockerignore', 'cops', 'DOCKER.md', 'plugins/', 'plugins.mjs', 'plugins-registry/', 'plugins-registry.json', 'plugin-install.mjs', 'plugin-audit.mjs', 'validate-plugin-registry.mjs', 'config/plugins.example.yml', ]; const BOOTSTRAP_PATHS = [ '.agents/', '.opencode/skills/', '.antigravitycli/skills/', '.grok/skills/', '.kimi/skills/', 'providers/', 'liveness-browser.mjs', 'tracker-links.mjs', 'role-matcher.mjs', 'tracker-utils.mjs', 'tracker-parse.mjs', 'tracker-aliases.json', 'scaffolder/', 'reserve-report-num.mjs', 'updater-migration-tests.mjs', 'validate-portals.mjs', 'tracker-columns-tests.mjs', 'plugins/', 'plugins.mjs', 'plugins-registry/', 'plugins-registry.json', 'plugin-install.mjs', 'plugin-audit.mjs', 'validate-plugin-registry.mjs', 'config/plugins.example.yml', 'agent-inbox.mjs', 'agent-inbox-tests.mjs', ]; // User layer paths — NEVER touch these (safety check) const USER_PATHS = [ 'cv.md', 'config/profile.yml', 'modes/_profile.md', 'modes/_custom.md', 'voice-dna.md', 'portals.yml', 'article-digest.md', 'interview-prep/', 'data/', 'reports/', 'output/', 'jds/', 'writing-samples/', 'config/plugins.yml', 'plugins.local/', 'plugins.lock', '.claude/settings.json', '.claude/hooks/', ]; function parseVersionFile(raw) { // VERSION may carry a release-please marker, e.g. "1.6.0 # x-release-please-version". // Take the first whitespace-delimited token so the marker doesn't break semver parsing. return raw.trim().split(/\s+/)[0] || ''; } function localVersion() { const vPath = join(ROOT, 'VERSION'); return existsSync(vPath) ? parseVersionFile(readFileSync(vPath, 'utf-8')) : '0.0.0'; } function compareVersions(a, b) { const pa = a.split('.').map(Number); const pb = b.split('.').map(Number); for (let i = 0; i < 3; i++) { if ((pa[i] || 0) < (pb[i] || 0)) return -1; if ((pa[i] || 0) > (pb[i] || 0)) return 1; } return 0; } function updateBackupBranchName(version, date = new Date()) { const stamp = date.toISOString() .replace(/[-:]/g, '') .replace(/\.\d{3}Z$/, 'Z'); return `backup-pre-update-${version}-${stamp}`; } function backupTimestamp(branchName) { const match = branchName.match(/-(\d{8}T\d{6}Z)$/); if (!match) return 0; const [date, time] = match[1].split('T'); return Date.parse( `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}T${time.slice(0, 2)}:${time.slice(2, 4)}:${time.slice(4, 6)}Z`, ) || 0; } function newestBackupBranch(branches) { const branchList = branches.split('\n').map(b => b.trim()).filter(Boolean); if (branchList.length === 0) return null; // Prefer timestamped backup branches created by current versions. Older // backups are still accepted below for rollback compatibility. const timestamped = branchList .map(branch => ({ branch, timestamp: backupTimestamp(branch) })) .filter(entry => entry.timestamp > 0) .sort((a, b) => b.timestamp - a.timestamp); return timestamped[0]?.branch || branchList[0]; } export function parsePositiveInt(value, fallback) { const parsed = Number.parseInt(String(value || ''), 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } export function gitTimeoutMs(args) { return args[0] === 'fetch' ? DEFAULT_GIT_FETCH_TIMEOUT_MS : DEFAULT_GIT_TIMEOUT_MS; } export function reexecTimeoutMs(updatePathCount = SYSTEM_PATHS.length + BOOTSTRAP_PATHS.length) { return Math.max( 120000, DEFAULT_GIT_FETCH_TIMEOUT_MS + DEFAULT_GIT_TIMEOUT_MS * 3 + UPDATE_PATH_CHECKOUT_BUDGET_MS * Math.max(0, updatePathCount) + NPM_INSTALL_TIMEOUT_MS + PLAYWRIGHT_INSTALL_TIMEOUT_MS + DASHBOARD_REBUILD_TIMEOUT_MS + REEXEC_BUFFER_TIMEOUT_MS, ); } function describeGitCommand(args) { return `git ${args.join(' ')}`; } function isTimeoutLikeError(err) { return err?.code === 'ETIMEDOUT' || err?.signal === 'SIGTERM'; } function timeoutSeconds(timeout) { return Math.round(timeout / 1000); } function gitTimeoutEnvVar(args) { return args[0] === 'fetch' ? 'CAREER_OPS_GIT_FETCH_TIMEOUT_MS' : 'CAREER_OPS_GIT_TIMEOUT_MS'; } function gitIn(root, ...args) { const timeout = gitTimeoutMs(args); try { return execFileSync('git', args, { cwd: root, encoding: 'utf-8', timeout }).trim(); } catch (err) { if (isTimeoutLikeError(err)) { throw new Error(`${describeGitCommand(args)} timed out after ${timeoutSeconds(timeout)}s. If your network is slow, retry or set ${gitTimeoutEnvVar(args)} to a larger value.`); } throw err; } } function git(...args) { return gitIn(ROOT, ...args); } function gitStatusEntries() { const status = git('status', '--porcelain'); if (!status) return []; return status.split('\n') .filter(Boolean) .map(line => ({ code: line.slice(0, 2), path: line.slice(3), })); } export function extractArrayFromSource(source, name) { const match = source.match(new RegExp(`const\\s+${name}\\s*=\\s*\\[([\\s\\S]*?)\\];`)); if (!match) return []; return Array.from(match[1].matchAll(/['"]([^'"]+)['"]/g), (entry) => entry[1]); } function mergePathLists(...lists) { const merged = []; const seen = new Set(); for (const list of lists) { for (const path of list) { if (seen.has(path)) continue; seen.add(path); merged.push(path); } } return merged; } // Files the self-reexec stage must check out so the TARGET update-system.mjs // loads without a missing-module crash. Today this is the entry plus its only // local import; resolveReexecCheckout derives the real set from the fetched // source, so this is only a defensive fallback if parsing ever misses one. const REEXEC_FALLBACK_FILES = ['update-system.mjs', 'scaffolder/bin/skill-entrypoints.mjs']; // Extracts static relative import/export specifiers ('./x.mjs', '../y.mjs') // from ESM source. Bare ('node:fs') and package ('js-yaml') specifiers are // ignored — only on-disk relative modules need to exist before re-exec. export function relativeImportSpecifiers(source) { const specs = new Set(); const fromRe = /\b(?:import|export)\b[^;]*?\bfrom\s*['"]([^'"]+)['"]/g; const bareRe = /\bimport\s*['"]([^'"]+)['"]/g; let match; while ((match = fromRe.exec(source))) specs.add(match[1]); while ((match = bareRe.exec(source))) specs.add(match[1]); return [...specs].filter((spec) => spec.startsWith('.')); } // Resolves the relative-import closure of `entry` within a git ref and returns // the repo-relative paths (forward-slash, Windows-safe) the re-exec stage must // check out. Only files actually present in the ref are returned; the known // fallback files are appended defensively. This generalizes the previously // hardcoded checkout list so a future new top-level import can't reintroduce // the self-reexec ERR_MODULE_NOT_FOUND crash (issue #1245). function resolveReexecCheckout(ref, entry) { const visited = new Set(); const present = new Set(); const order = []; const stack = [entry]; while (stack.length) { const file = stack.pop(); if (visited.has(file)) continue; visited.add(file); let source; try { source = git('show', `${ref}:${file}`); } catch { continue; // absent in this ref — leave it to the normal update stage } present.add(file); order.push(file); const dir = pathPosix.dirname(file); for (const spec of relativeImportSpecifiers(source)) { stack.push(pathPosix.join(dir, spec)); } } for (const file of REEXEC_FALLBACK_FILES) { if (present.has(file)) continue; try { git('show', `${ref}:${file}`); order.push(file); present.add(file); } catch { // Not in the target tree (older version) — nothing to check out. } } return order; } function repoPath(root, path) { return join(root, ...path.split('/')); } export function prepareMaterializedSkillEntrypointsForStage(paths, root = ROOT) { const prepared = []; for (const path of paths) { const entry = gitIn(root, 'ls-files', '-s', '--', path); if (!entry) continue; const mode = entry.split(/\s+/, 1)[0]; if (mode === '120000') { gitIn(root, 'rm', '--cached', '-f', '--', path); } prepared.push(path); } return prepared; } function revertPaths(paths) { if (paths.length === 0) return; // Must restore from HEAD, not from the index (#915 bug 1). After // `git checkout FETCH_HEAD -- ` the index already holds the new // content, so `git checkout -- ` (index→worktree) is a no-op. // `git checkout HEAD -- ` resets both the index and the worktree // to the pre-update commit, which is the correct rollback target. for (const p of paths) { try { git('checkout', 'HEAD', '--', p); } catch (err) { const pathspec = p.endsWith('/') ? p.slice(0, -1) : p; // Only remove if the path genuinely doesn't exist in HEAD. // Other errors (permissions, corrupt refs) should re-throw. let existsInHead = true; try { git('cat-file', '-e', `HEAD:${pathspec}`); } catch { existsInHead = false; } if (existsInHead) throw err; // Path was newly introduced by the update — remove it so the // working tree is consistent with HEAD. try { git('rm', '-r', '-f', '--ignore-unmatch', '--', pathspec); } catch { /* ignore */ } try { rmSync(join(ROOT, pathspec), { recursive: true, force: true }); } catch { /* already gone */ } } } } function addPaths(paths) { if (paths.length === 0) return; git('add', '--', ...paths); } function dashboardGoSourcesChanged() { try { const changed = git('diff', '--name-only', 'HEAD', '--', 'dashboard'); return changed .split('\n') .some(path => path.startsWith('dashboard/') && path.endsWith('.go')); } catch { return false; } } function rebuildDashboardBinaryIfNeeded() { if (!dashboardGoSourcesChanged()) return; try { execFileSync('go', ['build', '-o', 'career-dashboard', '.'], { cwd: join(ROOT, 'dashboard'), timeout: DASHBOARD_REBUILD_TIMEOUT_MS, stdio: 'pipe', }); console.log('dashboard binary rebuilt'); } catch { console.log('dashboard binary rebuild skipped -- run: cd dashboard && go build -o career-dashboard . manually'); } } // ── CHECK ─────────────────────────────────────────────────────── // curl helper used by check() — curl works inside the Claude Code sandbox // where Node's built-in fetch() fails (ENOTFOUND) because the sandbox // routes network traffic through an HTTP/HTTPS proxy that fetch() does // not respect but curl handles transparently. The --silent / --fail flags // match the failure-handling already used throughout apply(). function curlGet(url, extraArgs = []) { return new Promise((resolve) => { execFile( 'curl', ['--silent', '--fail', '--max-time', '10', ...extraArgs, url], { encoding: 'utf-8', timeout: 12000 }, (error, stdout) => { if (error) { resolve(null); } else { resolve(stdout.trim()); } } ); }); } async function check() { // Respect dismiss flag if (existsSync(join(ROOT, '.update-dismissed'))) { console.log(JSON.stringify({ status: 'dismissed' })); return; } const local = localVersion(); let remote = ''; let releaseVersion = ''; let changelog = ''; // Use curl instead of fetch() so the check works inside the Claude Code // sandbox (see curlGet() above for rationale). Two sources are tried; // both failing is the only true-offline signal. const [rawVersion, releaseRaw] = await Promise.all([ curlGet(RAW_VERSION_URL), curlGet(RELEASES_API, [ '--header', 'Accept: application/vnd.github.v3+json', '--header', 'User-Agent: career-ops-update-checker', ]), ]); if (rawVersion !== null) { try { const raw = parseVersionFile(rawVersion); const match = raw.match(SEMVER_RE); remote = match ? match[1] : ''; } catch { // Unparseable body; treat as no VERSION source } } if (releaseRaw !== null) { try { const release = JSON.parse(releaseRaw); changelog = release.body || ''; const rawTag = String(release.tag_name || '').trim(); const match = rawTag.match(SEMVER_RE); releaseVersion = match ? match[1] : ''; } catch { // Unparseable body; treat as no release source } } if (!remote && !releaseVersion) { // Both curl calls returned null → genuine network failure. // If one returned non-null but unparseable, remote/releaseVersion are // empty strings, which still reaches the offline branch — that's the // right conservative behaviour (no version = can't determine status). const bothNetworkFailed = rawVersion === null && releaseRaw === null; const status = bothNetworkFailed ? 'offline' : 'no-remote-version'; console.log(JSON.stringify({ status, local })); return; } // Use the higher version between VERSION file and GitHub Release // (handles cases where VERSION file is not bumped after a release, // or the raw host is unreachable but the API is). if (!remote) { remote = releaseVersion; } else if (releaseVersion && compareVersions(releaseVersion, remote) > 0) { remote = releaseVersion; } if (compareVersions(local, remote) >= 0) { console.log(JSON.stringify({ status: 'up-to-date', local, remote })); return; } console.log(JSON.stringify({ status: 'update-available', local, remote, changelog: changelog.slice(0, 500), })); } // ── APPLY ─────────────────────────────────────────────────────── async function apply() { const local = localVersion(); const initialStatusPaths = new Set(gitStatusEntries().map(entry => entry.path)); const isReexec = process.env.CAREER_OPS_UPDATE_REEXEC === '1'; // Check for lock const lockFile = join(ROOT, '.update-lock'); if (existsSync(lockFile) && !isReexec) { console.error('Update already in progress (.update-lock exists). If stuck, delete it manually.'); process.exit(1); } // Create lock if (!isReexec) { writeFileSync(lockFile, new Date().toISOString()); } try { // 1. Backup: create branch + stash uncommitted work (#915 bug 3). // The branch only captures committed state; any uncommitted edits are // invisible to `git branch` and can be lost if the update aborts. // `git stash create` builds a stash object without touching the stash // stack, giving a recoverable ref for WIP even if the update fails. const backupBranch = process.env.CAREER_OPS_UPDATE_BACKUP_BRANCH || updateBackupBranchName(local); if (!isReexec) { try { const wip = git('stash', 'create'); if (wip) { git('update-ref', `refs/backup-pre-update-wip/${local}`, wip); console.log(`WIP stash ref saved: refs/backup-pre-update-wip/${local} (recover with: git stash apply refs/backup-pre-update-wip/${local})`); } } catch { // Non-fatal: stash creation can fail in bare repos or empty trees. } git('branch', backupBranch); console.log(`Backup branch created: ${backupBranch}`); } // 2. Fetch from canonical repo console.log('Fetching latest from upstream...'); git('fetch', CANONICAL_REPO, 'main'); if (!isReexec) { const timeout = reexecTimeoutMs(); try { // The re-exec runs the TARGET updater, so every local module it imports // at load time must exist first. Resolve the fetched update-system.mjs's // relative-import closure and check out exactly those files, so a future // new top-level import can't reintroduce the self-reexec crash (#1245). const reexecFiles = resolveReexecCheckout('FETCH_HEAD', 'update-system.mjs'); git('checkout', 'FETCH_HEAD', '--', ...reexecFiles); execFileSync(process.execPath, ['update-system.mjs', 'apply'], { cwd: ROOT, stdio: 'inherit', timeout, env: { ...process.env, CAREER_OPS_UPDATE_REEXEC: '1', CAREER_OPS_UPDATE_BACKUP_BRANCH: backupBranch, }, }); return; } catch (err) { if (isTimeoutLikeError(err)) { console.error(`Updater self-reexec timed out after ${timeoutSeconds(timeout)}s.`); throw err; } console.error(`Updater self-reexec failed: ${err.message}`); throw err; } } // 3. Checkout system files only console.log('Updating system files...'); const updated = []; let remoteSystemPaths = []; try { const remoteUpdaterSource = git('show', 'FETCH_HEAD:update-system.mjs'); remoteSystemPaths = extractArrayFromSource(remoteUpdaterSource, 'SYSTEM_PATHS'); } catch { // Older targets may not have update-system.mjs. Fall back to the // local manifest plus bootstrap paths below. } // 3a. Keep bootstrap paths as a fallback for very old targets, but the // target updater's SYSTEM_PATHS is now the source of truth for new files. const updatePaths = mergePathLists(SYSTEM_PATHS, remoteSystemPaths, BOOTSTRAP_PATHS); for (const path of updatePaths) { try { git('checkout', 'FETCH_HEAD', '--', path); updated.push(path); } catch { // File may not exist in remote (new additions), skip } } // tests/ is auto-discovered and EXECUTED (tests/**/*.test.mjs), so stale // files left behind by upstream renames would run twice or crash the // suite. `git checkout` never deletes upstream-removed files (see the // limitation note in rollback below) — prune tracked extras against // FETCH_HEAD. Only git-tracked files are removed: a user's untracked // local experiments in tests/ are never touched. try { let remoteTests = new Set(); try { remoteTests = new Set( git('ls-tree', '-r', '--name-only', 'FETCH_HEAD', '--', 'tests/') .split('\n').filter(Boolean).map((p) => p.replace(/\\/g, '/')) ); } catch { // tests/ may not exist in older targets (ls-tree throws) — nothing to // prune. This is the only expected-and-silent failure in this block. } // An empty set means FETCH_HEAD has no tests/ at all (older target, or // ls-tree quietly returning nothing) — pruning against it would delete // every local test file. Only prune when the remote actually ships tests/. if (remoteTests.size > 0) { const localTests = git('ls-files', '--', 'tests/').split('\n').filter(Boolean); for (const f of localTests) { if (!remoteTests.has(f.replace(/\\/g, '/'))) { // Per-file isolation: one failed unlink (locked file, permissions) // must not abort pruning the rest. try { unlinkSync(join(ROOT, f)); // Raw path only: `updated` entries are reused as git pathspecs by // revertPaths() and the scoped commit below. Pushed only after a // successful unlink so failed deletions never enter `updated`. updated.push(f); console.log(`Pruned stale test file: ${f}`); } catch (err) { console.error(`Failed to prune stale test file ${f}: ${err.message}`); } } } } } catch (err) { // Unexpected failure (e.g. ls-files threw) — surface it instead of // silently skipping the prune step. console.error(`Stale-test prune step failed: ${err.message}`); } // Lazy import: keep update-system.mjs self-loading (see the top-of-file // note). scaffolder/ was just checked out by the update stage above, so the // module resolves here even on a pre-#1245 old→new re-exec. const { ensureSkillEntrypoints } = await import('./scaffolder/bin/skill-entrypoints.mjs'); const materializedSkillEntrypoints = ensureSkillEntrypoints(ROOT); if (materializedSkillEntrypoints.length > 0) { for (const path of materializedSkillEntrypoints) { if (!updated.includes(path)) updated.push(path); } console.log(`Materialized ${materializedSkillEntrypoints.length} skill entrypoint(s) for filesystems without symlink support`); } // 4. Validate: check NO user files were touched. // // Track which user paths the update unexpectedly touched so we // can exclude them from the revert and log what was preserved. const violatedUserPaths = new Set(); try { for (const entry of gitStatusEntries()) { const file = entry.path; if (initialStatusPaths.has(file)) continue; // Explicit SYSTEM_PATHS entries override USER_PATHS prefix matches. // (e.g. writing-samples/README.md is system-owned doc inside a user dir.) if (updatePaths.includes(file)) continue; for (const userPath of USER_PATHS) { if (file.startsWith(userPath)) { console.error(`SAFETY VIOLATION: User file was modified: ${file}`); violatedUserPaths.add(file); } } } } catch (err) { // Fail closed: if we can't validate the safety invariant we must // not silently proceed — that would let a real violation slip // through. Revert what we already applied and abort. console.error(`Aborting: could not validate user-layer safety (${err.message}).`); try { revertPaths(updated); } catch (revertErr) { // If the revert itself fails (likely whatever broke `git // status` also broke `git checkout --`), don't lose the // original validation error — chain it via `cause`. throw new Error( `Validation failed (${err.message}) and revert also failed (${revertErr.message})`, { cause: err }, ); } throw err; } if (violatedUserPaths.size > 0) { console.error('Aborting: user files were touched. Rolling back system files...'); // Revert ONLY the system-layer updates — never `git checkout` the // violated user paths back to HEAD. Doing so would overwrite the // user's working-tree content (accumulated STAR+R stories, local // edits) with whatever is committed upstream, causing data loss. // The user files were flagged as touched by the update, not by the // user; leaving them as-is is the safe choice — the user decides // what to do with them. const violation = new Error('Update aborted: user files were touched.'); try { revertPaths([...updated]); } catch (revertErr) { // If the revert itself fails, don't lose the safety-violation // diagnostic — chain it via `cause` so the user sees both. throw new Error( `Safety violation (${violation.message}) and revert also failed (${revertErr.message})`, { cause: violation }, ); } console.error(`User file(s) left as-is (your content was NOT overwritten):`); for (const f of violatedUserPaths) console.error(` ${f}`); // `throw` (not `process.exit`) so the outer `finally` runs and // .update-lock is removed. Exiting here would leak the lock and // permanently block subsequent updates until the user deletes // it manually. throw violation; } // 5. Install any new dependencies try { execSync('npm install --silent', { cwd: ROOT, timeout: NPM_INSTALL_TIMEOUT_MS }); } catch { console.log('npm install skipped (may need manual run)'); } // 5b. Ensure Playwright browser binary is up to date after npm install try { execSync('npx playwright install chromium', { cwd: ROOT, timeout: PLAYWRIGHT_INSTALL_TIMEOUT_MS, stdio: 'ignore' }); } catch { console.log('playwright install skipped (run manually: npx playwright install chromium)'); } // 6. Rebuild compiled dashboard if Go sources changed rebuildDashboardBinaryIfNeeded(); // 7. Commit the update const remote = localVersion(); // Re-read after checkout updated VERSION try { const pathsToStage = [...updated]; const dismissFile = join(ROOT, '.update-dismissed'); if (existsSync(dismissFile)) { unlinkSync(dismissFile); pathsToStage.push('.update-dismissed'); } prepareMaterializedSkillEntrypointsForStage(materializedSkillEntrypoints); addPaths(pathsToStage); // Scope the commit to only the staged update paths (#915 bug 2). // A bare `git commit` would sweep any unrelated pre-staged files into // the update commit. Passing the explicit pathspec list constrains the // commit to exactly the files this update touched. git('commit', '-m', `chore: auto-update system files to v${remote}`, '--', ...pathsToStage); } catch { // Nothing to commit (already up to date) } console.log(`\nUpdate complete: v${local} → v${remote}`); console.log(`Updated ${updated.length} system paths.`); console.log(`Rollback available: node update-system.mjs rollback`); } finally { // Remove lock if (!isReexec && existsSync(lockFile)) unlinkSync(lockFile); } } // ── ROLLBACK ──────────────────────────────────────────────────── function rollback() { // Find most recent backup branch try { const branches = git('for-each-ref', '--sort=-committerdate', '--format=%(refname:short)', 'refs/heads/backup-pre-update-*'); const latest = newestBackupBranch(branches); if (!latest) { console.error('No backup branches found. Nothing to rollback.'); process.exit(1); } console.log(`Rolling back to: ${latest}`); // Checkout system files from backup branch. // // Two failure modes for `git checkout` here: // (a) the path didn't exist in the backup branch — the apply() // that produced this backup was on an older version that // didn't track this path yet. Rollback must DELETE the path // so the working tree mirrors the backup state. // (b) anything else — propagate so we don't silently leave the // working tree in a partially-restored state. // // Limitation: `git checkout -- ` restores blobs from // the backup tree but doesn't remove files that were added INSIDE // an already-tracked directory between backup and rollback. Rolling // back per-file via `git diff --name-status ` would catch // that but is a larger change; tracked separately if it ever bites. const restored = []; const removed = []; for (const path of SYSTEM_PATHS) { try { git('checkout', latest, '--', path); restored.push(path); } catch (err) { const pathspec = path.endsWith('/') ? path.slice(0, -1) : path; let existedInBackup = true; try { git('cat-file', '-e', `${latest}:${pathspec}`); } catch { existedInBackup = false; } if (existedInBackup) { throw err; } // Path was introduced by a later apply() — remove it so the // tree truly matches the backup. `git rm` stages the deletion // for tracked files; `rmSync` cleans up the untracked-but- // on-disk case (e.g. an apply() that crashed between checkout // and commit, leaving the path untracked locally). git('rm', '-r', '-f', '--ignore-unmatch', '--', pathspec); try { rmSync(join(ROOT, pathspec), { recursive: true, force: true }); } catch { // Already gone, or not present on disk — fine. } removed.push(pathspec); } } if (restored.length > 0) addPaths(restored); const rollbackPaths = [...restored, ...removed]; try { // Scope the commit to the rollback paths (#915 bug 2). A bare // `git commit` would sweep unrelated staged files into the rollback. if (rollbackPaths.length > 0) { git('commit', '-m', `chore: rollback system files from ${latest}`, '--', ...rollbackPaths); } } catch { // Tolerate any commit failure here — the common case is the // "nothing to commit" no-op when the working tree already // matched the backup (e.g. user ran rollback twice). This // mirrors apply()'s broad-catch in the commit step; narrowing // to a specific git-error string is fragile and would diverge // from that pattern. Genuine setup problems (hooks, signing, // disk full) will resurface on the next normal git operation. } console.log(`Rollback complete. Restored ${restored.length} path(s) from ${latest}, removed ${removed.length} path(s) added after the backup.`); console.log('Your data (CV, profile, tracker, reports) was not affected.'); } catch (err) { console.error('Rollback failed:', err.message); process.exit(1); } } // ── DISMISS ───────────────────────────────────────────────────── function dismiss() { writeFileSync(join(ROOT, '.update-dismissed'), new Date().toISOString()); console.log('Update check dismissed. Run "node update-system.mjs check" or say "check for updates" to re-enable.'); } // ── MAIN ──────────────────────────────────────────────────────── // Only run the CLI when executed directly, so importing this module // (e.g. from test-all.mjs to exercise SEMVER_RE) does not trigger a // live update check. if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const cmd = process.argv[2] || 'check'; try { switch (cmd) { case 'check': await check(); break; case 'apply': await apply(); break; case 'rollback': rollback(); break; case 'dismiss': dismiss(); break; default: console.log('Usage: node update-system.mjs [check|apply|rollback|dismiss]'); process.exit(1); } } catch (err) { // Subcommands now `throw` on aborts so their outer `finally` blocks // run (e.g. apply() must release `.update-lock`). Print a clean // message here instead of letting Node spit out a stack trace. console.error(err.message || err); process.exit(1); } }