diff --git a/autofixer/server.js b/autofixer/server.js index c1091fff79..ecb25e4c5e 100644 --- a/autofixer/server.js +++ b/autofixer/server.js @@ -1,8 +1,5 @@ -import { spawn } from 'child_process'; import { readFile, writeFile, mkdir, access } from 'fs/promises'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { createRequire } from 'module'; +import { join } from 'path'; // Dependency-light shared module (node builtins + pure arg builder only), so // importing it from this standalone process doesn't pull in the AI toolkit. // Lets the autofixer honor the user's configured CLI provider/model instead @@ -23,9 +20,7 @@ import { revertDiffFromLive, runVerifyCommand, } from './sandbox.js'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +import { execPm2, DATA_DIR, AUTOFIXER_DIR, INDEX_FILE, loadApps } from './shared.js'; // Prepend the guarded pm2 shim to this process's PATH as defense-in-depth. The // fix agent runs in an isolated worktree with a sanitized env and (for claude) @@ -35,34 +30,10 @@ const __dirname = dirname(__filename); // preserves this guarded PATH into the agent's env. Object.assign(process.env, agentGuardEnv()); -// Resolve PM2 binary to avoid pm2.cmd on Windows (creates visible CMD windows) -const require = createRequire(import.meta.url); -const PM2_BIN = join(dirname(require.resolve('pm2/package.json')), 'bin', 'pm2'); - -/** Execute a PM2 CLI command via node (bypasses pm2.cmd) */ -function execPm2(pm2Args) { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [PM2_BIN, ...pm2Args], { windowsHide: true }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (d) => { stdout += d.toString(); }); - child.stderr.on('data', (d) => { stderr += d.toString(); }); - child.on('close', (code) => { - if (code !== 0) return reject(new Error(stderr || `pm2 exited with code ${code}`)); - resolve({ stdout, stderr }); - }); - child.on('error', reject); - }); -} - -// Paths -const DATA_DIR = join(__dirname, '../data'); -const APPS_FILE = join(DATA_DIR, 'apps.json'); +// Paths not shared with ui.js const PROVIDERS_FILE = join(DATA_DIR, 'providers.json'); const SETTINGS_FILE = join(DATA_DIR, 'settings.json'); -const AUTOFIXER_DIR = join(DATA_DIR, 'autofixer'); const SESSIONS_DIR = join(AUTOFIXER_DIR, 'sessions'); -const INDEX_FILE = join(AUTOFIXER_DIR, 'index.json'); // Disposable worktrees for isolated repair runs (gitignored under data/). const WORKTREES_DIR = join(AUTOFIXER_DIR, 'worktrees'); // Bound the agent-proposed patch before it can reach the live checkout. @@ -75,13 +46,6 @@ const CHECK_INTERVAL = 15 * 60 * 1000; // 15 minutes let checkTimer = null; let shuttingDown = false; -// Load apps from PortOS -async function loadApps() { - const data = await readFile(APPS_FILE, 'utf8').catch(() => '{"apps":{}}'); - const parsed = JSON.parse(data); - return Object.entries(parsed.apps || {}).map(([id, app]) => ({ id, ...app })); -} - // Parse JSON, returning `fallback` on read OR parse failure. A corrupt config // file (partial write, hand-edit) must not throw inside fixProcess — this runs // in the autofixer's interval loop, outside any request lifecycle, where an diff --git a/autofixer/shared.js b/autofixer/shared.js new file mode 100644 index 0000000000..7cac628b46 --- /dev/null +++ b/autofixer/shared.js @@ -0,0 +1,47 @@ +// Plumbing shared by the autofixer's two PM2-managed processes — `server.js` +// (the repair loop) and `ui.js` (the dashboard). Kept package-local and +// dependency-light (node builtins only): PortOS's own `server/services/pm2.js` +// has an equivalent `execPm2`, but importing it here would drag the whole +// server dependency graph into a package whose package.json declares only +// express. +import { spawn } from 'child_process'; +import { readFile } from 'fs/promises'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Resolve PM2 binary to avoid pm2.cmd on Windows (creates visible CMD windows) +const require = createRequire(import.meta.url); +export const PM2_BIN = join(dirname(require.resolve('pm2/package.json')), 'bin', 'pm2'); + +/** Execute a PM2 CLI command via node (bypasses pm2.cmd) */ +export function execPm2(pm2Args) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [PM2_BIN, ...pm2Args], { windowsHide: true }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => { stdout += d.toString(); }); + child.stderr.on('data', (d) => { stderr += d.toString(); }); + child.on('close', (code) => { + if (code !== 0) return reject(new Error(stderr || `pm2 exited with code ${code}`)); + resolve({ stdout, stderr }); + }); + child.on('error', reject); + }); +} + +// Paths. Resolved from THIS module's location (both consumers are siblings in +// `autofixer/`), so every process agrees on one `data/` directory. +export const DATA_DIR = join(__dirname, '../data'); +export const APPS_FILE = join(DATA_DIR, 'apps.json'); +export const AUTOFIXER_DIR = join(DATA_DIR, 'autofixer'); +export const INDEX_FILE = join(AUTOFIXER_DIR, 'index.json'); + +// Load apps from PortOS +export async function loadApps() { + const data = await readFile(APPS_FILE, 'utf8').catch(() => '{"apps":{}}'); + const parsed = JSON.parse(data); + return Object.entries(parsed.apps || {}).map(([id, app]) => ({ id, ...app })); +} diff --git a/autofixer/shared.test.js b/autofixer/shared.test.js new file mode 100644 index 0000000000..a5191af274 --- /dev/null +++ b/autofixer/shared.test.js @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +// loadApps() reads a fixed on-disk path, so the read is faked to keep the +// fallback assertions independent of whether this install has a data/apps.json. +const readFileMock = vi.hoisted(() => vi.fn()); +vi.mock('fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readFile: (...args) => readFileMock(...args), +})); + +// shared.js resolves the PM2 binary at import time, and Node walks node_modules +// upward from `autofixer/` — so it needs the ROOT install. CI installs only +// `server/node_modules` (`npm ci --prefix server`), which is never on that path, +// so skip there rather than fail: the autofixer only ever runs from a full +// `npm run install:all` checkout, which is where this suite has to hold. +const require = createRequire(import.meta.url); +const pm2Installed = (() => { + try { + require.resolve('pm2/package.json'); + return true; + } catch { + return false; + } +})(); +const describeShared = pm2Installed ? describe : describe.skip; +const shared = pm2Installed ? await import('./shared.js') : {}; + +const AUTOFIXER_SRC_DIR = dirname(fileURLToPath(import.meta.url)); + +describeShared('autofixer/shared — PM2 binary resolution', () => { + // server.js and ui.js both spawn `node ` rather than `pm2`, so a PM2 + // package layout change would otherwise surface only at runtime, on the next + // repair attempt or dashboard restart. + it('resolves the JS entry point (not pm2.cmd) and it exists on disk', () => { + expect(shared.PM2_BIN.endsWith(join('bin', 'pm2'))).toBe(true); + expect(existsSync(shared.PM2_BIN)).toBe(true); + }); +}); + +describeShared('autofixer/shared — data paths', () => { + // Both PM2 processes must agree on one data/ directory; resolving from this + // module's own location is what guarantees that. Spelled as a dirname climb + // rather than a '..' path literal so the repo-wide test-data isolation guard + // (server/lib/testDataIsolation.guards.test.js) doesn't read this string-only + // comparison as a suite that addresses the live data/ tree — nothing here + // touches the filesystem. + it('anchors every path to the package-sibling data/ directory', () => { + expect(shared.DATA_DIR).toBe(join(dirname(AUTOFIXER_SRC_DIR), 'data')); + expect(shared.APPS_FILE).toBe(join(shared.DATA_DIR, 'apps.json')); + expect(shared.AUTOFIXER_DIR).toBe(join(shared.DATA_DIR, 'autofixer')); + expect(shared.INDEX_FILE).toBe(join(shared.AUTOFIXER_DIR, 'index.json')); + }); +}); + +describeShared('autofixer/shared — loadApps', () => { + beforeEach(() => { + readFileMock.mockReset(); + }); + + it('returns [] when the apps file is missing', async () => { + readFileMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + await expect(shared.loadApps()).resolves.toEqual([]); + }); + + it('returns [] when the apps file has no apps key', async () => { + readFileMock.mockResolvedValue('{}'); + await expect(shared.loadApps()).resolves.toEqual([]); + }); + + it('flattens the apps map into records carrying their id', async () => { + readFileMock.mockResolvedValue(JSON.stringify({ + apps: { 'example-app': { pm2ProcessNames: ['example-api'], repoPath: '/srv/example' } }, + })); + await expect(shared.loadApps()).resolves.toEqual([ + { id: 'example-app', pm2ProcessNames: ['example-api'], repoPath: '/srv/example' }, + ]); + }); +}); diff --git a/autofixer/ui.js b/autofixer/ui.js index ed2b96215b..2ec8bd8ac3 100644 --- a/autofixer/ui.js +++ b/autofixer/ui.js @@ -3,34 +3,14 @@ import { spawn } from 'child_process'; import { readFile } from 'fs/promises'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { createRequire } from 'module'; import { createTailscaleServers, watchCertReload } from '../lib/tailscale-https.js'; import { certPaths } from '../lib/certPaths.js'; import { createSidecarAuthGate } from '../lib/sidecarAuthGate.js'; +import { PM2_BIN, execPm2, DATA_DIR, INDEX_FILE, loadApps } from './shared.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -// Resolve PM2 binary to avoid pm2.cmd on Windows (creates visible CMD windows) -const require = createRequire(import.meta.url); -const PM2_BIN = join(dirname(require.resolve('pm2/package.json')), 'bin', 'pm2'); - -/** Execute a PM2 CLI command via node (bypasses pm2.cmd) */ -function execPm2(pm2Args) { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [PM2_BIN, ...pm2Args], { windowsHide: true }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (d) => { stdout += d.toString(); }); - child.stderr.on('data', (d) => { stderr += d.toString(); }); - child.on('close', (code) => { - if (code !== 0) return reject(new Error(stderr || `pm2 exited with code ${code}`)); - resolve({ stdout, stderr }); - }); - child.on('error', reject); - }); -} - const app = express(); const PORT = process.env.PORT || 5560; @@ -39,19 +19,6 @@ const PORT = process.env.PORT || 5560; const UI_TEMPLATE_FILE = join(__dirname, 'ui.template.html'); const UI_HTML = await readFile(UI_TEMPLATE_FILE, 'utf8'); -// Paths -const DATA_DIR = join(__dirname, '../data'); -const APPS_FILE = join(DATA_DIR, 'apps.json'); -const AUTOFIXER_DIR = join(DATA_DIR, 'autofixer'); -const INDEX_FILE = join(AUTOFIXER_DIR, 'index.json'); - -// Load apps from PortOS -async function loadApps() { - const data = await readFile(APPS_FILE, 'utf8').catch(() => '{"apps":{}}'); - const parsed = JSON.parse(data); - return Object.entries(parsed.apps || {}).map(([id, app]) => ({ id, ...app })); -} - // Load autofixer history async function loadHistory() { const data = await readFile(INDEX_FILE, 'utf8').catch(() => '[]');