// Starting and stopping a sidecar's PM2 process. // // ── Why shell out rather than use the pm2 API ── // // PM2 is already the supervisor for every process here and the ecosystem file is already the definition // of how each one runs. Importing pm2 as a library would put a second thing in charge of that, and the // failure mode is two supervisors disagreeing about what should be running. The CLI is the same // interface a human uses, which also means an owner can undo anything the app store did with a command // they already know. // // ── The one PM2 fact that matters here ── // // `pm2 start ` only works for a process PM2 has already seen. A sidecar that has never run is not // in PM2's list, and starting it by name fails with "process or namespace not found". So a first // install has to start it FROM THE ECOSYSTEM FILE, and every start after that can go by name. // // `startProcess` handles both without the caller having to know which case it is in, because the caller // genuinely cannot know: a resumed install, a re-enable after a restart, and a first install all arrive // at the same line. export type Pm2Result = { ok: true } | { ok: false; error: string }; const ECOSYSTEM = 'ecosystem.config.cjs'; async function pm2(args: string[], cwd: string): Promise<{ code: number; out: string }> { const proc = Bun.spawn(['pm2', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' }); const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); const code = await proc.exited; return { code, out: `${out}${err}`.trim() }; } /** Is this process known to PM2 at all — running, stopped or errored? */ export async function isKnownToPm2(name: string, cwd: string): Promise { const { code, out } = await pm2(['jlist'], cwd); if (code !== 0) return false; try { const list = JSON.parse(out) as Array<{ name?: string }>; return list.some((p) => p.name === name); } catch { // jlist printing something unparseable means PM2 is in a state we should not guess about. Saying // "not known" makes the caller start from the ecosystem file, which is the safe direction — it // works whether or not the process exists. return false; } } /** * Start a sidecar, whether or not PM2 has seen it before. * * `--only` is what keeps this surgical: starting the ecosystem file without it would bring up every * process in the estate, which on a light install is precisely the thing the user opted out of. */ export async function startProcess(name: string, cwd: string): Promise { const known = await isKnownToPm2(name, cwd); const args = known ? ['start', name] : ['start', ECOSYSTEM, '--only', name]; const { code, out } = await pm2(args, cwd); if (code !== 0) return { ok: false, error: out || `pm2 ${args.join(' ')} exited ${code}` }; return { ok: true }; } /** * Stop it, leaving it in PM2's list. * * Stop rather than delete, deliberately. A stopped process still appears in `pm2 list` as stopped, * which is the honest picture for a disabled sidecar — deleting it would make a disabled service * indistinguishable from one that was never installed, both to PM2 and to anyone looking. */ export async function stopProcess(name: string, cwd: string): Promise { const { code, out } = await pm2(['stop', name], cwd); // Stopping something that is not there is the desired end state, not an error. This happens on a // resumed uninstall, and treating it as a failure would leave the row un-uninstallable. if (code !== 0 && !/not found|doesn't exist/i.test(out)) { return { ok: false, error: out || `pm2 stop ${name} exited ${code}` }; } return { ok: true }; } /** Remove it from PM2 entirely. Uninstall only — see `stopProcess` for why disable does not do this. */ export async function deleteProcess(name: string, cwd: string): Promise { const { code, out } = await pm2(['delete', name], cwd); if (code !== 0 && !/not found|doesn't exist/i.test(out)) { return { ok: false, error: out || `pm2 delete ${name} exited ${code}` }; } return { ok: true }; } /** 'online' | 'stopped' | 'errored' | … , or null when PM2 has never heard of it. */ export async function processStatus(name: string, cwd: string): Promise { const { code, out } = await pm2(['jlist'], cwd); if (code !== 0) return null; try { const list = JSON.parse(out) as Array<{ name?: string; pm2_env?: { status?: string } }>; return list.find((p) => p.name === name)?.pm2_env?.status ?? null; } catch { return null; } }