feat(cli): one interactive codegraph daemon command, replaces stop/list (#863)
Collapses the unreleased daemon controls into a single interactive command. `codegraph daemon` (alias `daemons`) opens an arrow-key picker (current project's daemon first, pre-selected), enter stops it, or pick "Stop all"; non-TTY prints a plain list. Removes stop/list/ps; reuses the unchanged daemon-registry machinery; the pick->stop loop is in daemon-manager.ts behind an injectable select (unit tested). Validated live on macOS/Linux (real clack picker driven via pty) and Windows (real runDaemonPicker + stopDaemonAt against a real daemon). Closes #845 follow-up.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Interactive daemon manager — the logic behind `codegraph daemon` / `daemons`.
|
||||
*
|
||||
* Kept separate from the CLI (which owns the @clack/prompts wiring) so the
|
||||
* selection/stop loop is unit-testable with a fake `select`: no TTY, no clack,
|
||||
* no real daemons. The CLI passes the real clack `select`/`isCancel` plus the
|
||||
* registry's list/stop functions.
|
||||
*/
|
||||
import * as path from 'path';
|
||||
import type { DaemonRecord, StopResult } from './daemon-registry';
|
||||
|
||||
/** Sentinel option values (not real roots, so they can't collide with a project path). */
|
||||
export const STOP_ALL = '__stop_all__';
|
||||
export const CANCEL = '__cancel__';
|
||||
|
||||
export interface PickItem {
|
||||
value: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/** Compact uptime: `45s`, `12m`, `3h 5m`. */
|
||||
export function formatUptime(ms: number): string {
|
||||
const s = Math.max(0, Math.floor(ms / 1000));
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ordered, UI-ready option list: the current project's daemon first
|
||||
* (so it's the auto-selected default), the rest newest-first, then "Stop all"
|
||||
* (only when there's more than one) and "Cancel".
|
||||
*/
|
||||
export function buildPickItems(daemons: DaemonRecord[], cwdRoot: string | null, now: number): PickItem[] {
|
||||
const cwd = cwdRoot != null ? path.resolve(cwdRoot) : null;
|
||||
const ordered = [...daemons].sort((a, b) => {
|
||||
if (cwd) {
|
||||
const aCur = path.resolve(a.root) === cwd;
|
||||
const bCur = path.resolve(b.root) === cwd;
|
||||
if (aCur && !bCur) return -1;
|
||||
if (bCur && !aCur) return 1;
|
||||
}
|
||||
return b.startedAt - a.startedAt; // newest first
|
||||
});
|
||||
|
||||
const items: PickItem[] = ordered.map((d) => {
|
||||
const current = cwd != null && path.resolve(d.root) === cwd;
|
||||
return {
|
||||
value: d.root,
|
||||
label: current ? `${d.root} (current project)` : d.root,
|
||||
hint: `pid ${d.pid} · up ${formatUptime(now - d.startedAt)} · Running`,
|
||||
};
|
||||
});
|
||||
|
||||
if (items.length > 1) items.push({ value: STOP_ALL, label: 'Stop all', hint: '' });
|
||||
items.push({ value: CANCEL, label: 'Cancel', hint: '' });
|
||||
return items;
|
||||
}
|
||||
|
||||
export interface PickerDeps {
|
||||
list: () => DaemonRecord[];
|
||||
stop: (root: string) => Promise<StopResult>;
|
||||
stopAll: () => Promise<StopResult[]>;
|
||||
/** Realpath'd root of the current project's daemon, or null. */
|
||||
cwdRoot: string | null;
|
||||
now: () => number;
|
||||
/** Render the picker; returns the chosen value or a cancel sentinel. */
|
||||
select: (opts: { message: string; options: PickItem[]; initialValue: string }) => Promise<unknown>;
|
||||
isCancel: (v: unknown) => boolean;
|
||||
/** Per-action note (e.g. "Stopped daemon …"). */
|
||||
note: (msg: string) => void;
|
||||
/** Final line + teardown (clack outro). */
|
||||
done: (msg: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a daemon → stop it → re-prompt with what's left, until the user cancels
|
||||
* (Esc / Ctrl-C / "Cancel"), picks "Stop all", or nothing remains.
|
||||
*/
|
||||
export async function runDaemonPicker(deps: PickerDeps): Promise<void> {
|
||||
for (;;) {
|
||||
const daemons = deps.list();
|
||||
if (daemons.length === 0) {
|
||||
deps.done('All daemons stopped.');
|
||||
return;
|
||||
}
|
||||
|
||||
const items = buildPickItems(daemons, deps.cwdRoot, deps.now());
|
||||
const choice = await deps.select({
|
||||
message: 'Select a daemon to stop',
|
||||
options: items,
|
||||
initialValue: items[0]?.value ?? CANCEL, // daemons.length > 0 here, so items[0] is a daemon
|
||||
});
|
||||
|
||||
if (deps.isCancel(choice) || choice === CANCEL) {
|
||||
deps.done('Cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (choice === STOP_ALL) {
|
||||
const results = await deps.stopAll();
|
||||
const n = results.filter((r) => r.outcome === 'term' || r.outcome === 'kill').length;
|
||||
deps.note(`Stopped ${n} daemon${n === 1 ? '' : 's'}.`);
|
||||
deps.done('Done.');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deps.stop(String(choice));
|
||||
const forced = result.outcome === 'kill' ? ', forced' : '';
|
||||
deps.note(`Stopped daemon (pid ${result.pid}${forced}) — ${choice}`);
|
||||
// Loop: the next iteration re-lists; if more remain it re-prompts, otherwise
|
||||
// the top-of-loop empty check prints "All daemons stopped."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user