@effectstream/orchestrator 0.100.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,92 @@
1
+ import type { ProcessConfig } from "./config.ts";
2
+ import { readStatePort, DEFAULT_API_PORT } from "./port-check.ts";
3
+
4
+ export { DEFAULT_API_PORT };
5
+
6
+ export type RemoteProcess = {
7
+ name: string;
8
+ pid: number | null;
9
+ ports: number[];
10
+ status: string;
11
+ startedAt: string | null;
12
+ endedAt: string | null;
13
+ exitCode: number | null;
14
+ link: string | null;
15
+ critical: boolean;
16
+ description: string | null;
17
+ logFile: string | null;
18
+ };
19
+
20
+ export class OrchestratorClient {
21
+ private baseUrl: string;
22
+
23
+ constructor(port?: number) {
24
+ const p = port ?? readStatePort() ?? DEFAULT_API_PORT;
25
+ this.baseUrl = `http://localhost:${p}`;
26
+ }
27
+
28
+ async isRunning(): Promise<boolean> {
29
+ try {
30
+ const res = await fetch(`${this.baseUrl}/health`, {
31
+ signal: AbortSignal.timeout(1000),
32
+ });
33
+ return res.ok;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ async getProcesses(): Promise<RemoteProcess[]> {
40
+ const res = await fetch(`${this.baseUrl}/processes`);
41
+ if (!res.ok) throw new Error(`API error ${res.status}`);
42
+ const body = (await res.json()) as { processes: RemoteProcess[] };
43
+ return body.processes;
44
+ }
45
+
46
+ async restart(name: string): Promise<{ success: boolean; error?: string }> {
47
+ const res = await fetch(`${this.baseUrl}/restart`, {
48
+ method: "POST",
49
+ headers: { "Content-Type": "application/json" },
50
+ body: JSON.stringify({ name }),
51
+ });
52
+ return res.json() as Promise<{ success: boolean; error?: string }>;
53
+ }
54
+
55
+ async stop(name?: string): Promise<{ success: boolean; error?: string }> {
56
+ const res = await fetch(`${this.baseUrl}/stop`, {
57
+ method: "POST",
58
+ headers: { "Content-Type": "application/json" },
59
+ body: JSON.stringify({ name }),
60
+ });
61
+ return res.json() as Promise<{ success: boolean; error?: string }>;
62
+ }
63
+
64
+ async start(processes: ProcessConfig[], opts?: { only?: string[]; except?: string[]; serial?: boolean; noDeps?: boolean }): Promise<{ success: boolean; queued?: string[]; error?: string }> {
65
+ const res = await fetch(`${this.baseUrl}/start`, {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/json" },
68
+ body: JSON.stringify({ processes, only: opts?.only, except: opts?.except, serial: opts?.serial, noDeps: opts?.noDeps }),
69
+ });
70
+ return res.json() as Promise<{ success: boolean; queued?: string[]; error?: string }>;
71
+ }
72
+
73
+ async silence(names: string[], unsilence = false): Promise<{ success: boolean; silenced?: string[]; error?: string }> {
74
+ const res = await fetch(`${this.baseUrl}/silence`, {
75
+ method: "POST",
76
+ headers: { "Content-Type": "application/json" },
77
+ body: JSON.stringify({ names, unsilence }),
78
+ });
79
+ return res.json() as Promise<{ success: boolean; silenced?: string[]; error?: string }>;
80
+ }
81
+
82
+ async getSilenced(): Promise<string[]> {
83
+ const res = await fetch(`${this.baseUrl}/silence`);
84
+ if (!res.ok) throw new Error(`API error ${res.status}`);
85
+ const body = (await res.json()) as { silenced: string[] };
86
+ return body.silenced;
87
+ }
88
+
89
+ async shutdown(): Promise<void> {
90
+ await fetch(`${this.baseUrl}/shutdown`, { method: "POST" }).catch(() => {});
91
+ }
92
+ }
@@ -0,0 +1,183 @@
1
+ import type { ProcessConfig } from "./config.ts";
2
+ import type { ProcessManager, ManagedProcess } from "./process-manager.ts";
3
+ import { runProcesses, type RunOptions } from "./task-runner.ts";
4
+
5
+ function json(data: unknown, status = 200): Response {
6
+ return new Response(JSON.stringify(data, null, 2), {
7
+ status,
8
+ headers: { "Content-Type": "application/json" },
9
+ });
10
+ }
11
+
12
+ function serialize(p: ManagedProcess) {
13
+ return {
14
+ name: p.name,
15
+ pid: p.pid,
16
+ ports: p.ports,
17
+ status: p.status,
18
+ startedAt: p.startedAt?.toISOString() ?? null,
19
+ endedAt: p.endedAt?.toISOString() ?? null,
20
+ exitCode: p.exitCode,
21
+ link: p.config.link ?? null,
22
+ critical: p.config.critical ?? true,
23
+ description: p.config.description ?? null,
24
+ logFile: p.logFile ?? null,
25
+ };
26
+ }
27
+
28
+ export type ApiServerOptions = {
29
+ port: number;
30
+ manager: ProcessManager;
31
+ /** Called when the /shutdown endpoint is hit. */
32
+ onShutdown: () => Promise<void>;
33
+ /** Callback hooks for task events (used by both local and remote starts). */
34
+ runCallbacks?: Pick<RunOptions, "onTaskStart" | "onTaskRunning" | "onTaskDone" | "onTaskFailed" | "onShutdown">;
35
+ };
36
+
37
+ /**
38
+ * Starts a Bun HTTP API server so that external CLI commands
39
+ * (status, restart, stop) can communicate with the running daemon.
40
+ *
41
+ * Routes:
42
+ * GET /health → { ok: true }
43
+ * GET /processes → { processes: [...] }
44
+ * POST /restart → { name } → restarts named process
45
+ * POST /stop → { name? } → stops named process or all
46
+ * POST /shutdown → graceful full shutdown
47
+ */
48
+ export function startApiServer({ port, manager, onShutdown, runCallbacks }: ApiServerOptions): ReturnType<typeof Bun.serve> {
49
+ return Bun.serve({
50
+ port,
51
+ async fetch(req) {
52
+ const url = new URL(req.url);
53
+
54
+ // ── Health ────────────────────────────────────────────────────────────
55
+ if (req.method === "GET" && url.pathname === "/health") {
56
+ return json({ ok: true });
57
+ }
58
+
59
+ // ── Process list ──────────────────────────────────────────────────────
60
+ if (req.method === "GET" && url.pathname === "/processes") {
61
+ return json({ processes: manager.getAll().map(serialize) });
62
+ }
63
+
64
+ // ── Start additional processes ────────────────────────────────────────
65
+ if (req.method === "POST" && url.pathname === "/start") {
66
+ let body: { processes: ProcessConfig[]; only?: string[]; except?: string[]; serial?: boolean; noDeps?: boolean };
67
+ try {
68
+ body = (await req.json()) as typeof body;
69
+ } catch {
70
+ return json({ error: "Invalid JSON body" }, 400);
71
+ }
72
+
73
+ if (!Array.isArray(body.processes) || body.processes.length === 0) {
74
+ return json({ error: "Missing or empty field: processes" }, 400);
75
+ }
76
+
77
+ // Launch in the background so the HTTP response returns immediately
78
+ runProcesses(body.processes, manager, {
79
+ only: body.only,
80
+ except: body.except,
81
+ serial: body.serial,
82
+ noDeps: body.noDeps,
83
+ ...runCallbacks,
84
+ }).catch((err) => {
85
+ console.error(`[API /start] Error: ${err?.message ?? err}`);
86
+ });
87
+
88
+ // Report which processes will actually be considered (after filters)
89
+ let queued = body.processes.map((p) => p.name);
90
+ if (body.only) {
91
+ const onlySet = new Set(body.only);
92
+ queued = queued.filter((n) => onlySet.has(n));
93
+ }
94
+ if (body.except) {
95
+ const exceptSet = new Set(body.except);
96
+ queued = queued.filter((n) => !exceptSet.has(n));
97
+ }
98
+
99
+ return json({ success: true, queued });
100
+ }
101
+
102
+ // ── Restart ───────────────────────────────────────────────────────────
103
+ if (req.method === "POST" && url.pathname === "/restart") {
104
+ let body: { name: string };
105
+ try {
106
+ body = (await req.json()) as { name: string };
107
+ } catch {
108
+ return json({ error: "Invalid JSON body" }, 400);
109
+ }
110
+
111
+ if (!body.name) return json({ error: "Missing field: name" }, 400);
112
+
113
+ const result = await manager.restart(body.name);
114
+ if (!result) {
115
+ return json(
116
+ { error: `No process named "${body.name}" found` },
117
+ 404,
118
+ );
119
+ }
120
+ return json({ success: true, name: body.name });
121
+ }
122
+
123
+ // ── Stop ──────────────────────────────────────────────────────────────
124
+ if (req.method === "POST" && url.pathname === "/stop") {
125
+ let body: { name?: string } = {};
126
+ try {
127
+ body = (await req.json()) as { name?: string };
128
+ } catch {
129
+ /* empty body is fine */
130
+ }
131
+
132
+ if (body.name) {
133
+ const ok = await manager.stop(body.name);
134
+ if (!ok) {
135
+ return json(
136
+ { error: `No running process named "${body.name}"` },
137
+ 404,
138
+ );
139
+ }
140
+ return json({ success: true, name: body.name });
141
+ }
142
+
143
+ await manager.stopAll();
144
+ return json({ success: true, stopped: "all" });
145
+ }
146
+
147
+ // ── Silence / Unsilence ──────────────────────────────────────────────
148
+ if (req.method === "POST" && url.pathname === "/silence") {
149
+ let body: { names: string[]; unsilence?: boolean };
150
+ try {
151
+ body = (await req.json()) as typeof body;
152
+ } catch {
153
+ return json({ error: "Invalid JSON body" }, 400);
154
+ }
155
+ if (!Array.isArray(body.names) || body.names.length === 0) {
156
+ return json({ error: "Missing or empty field: names" }, 400);
157
+ }
158
+ for (const name of body.names) {
159
+ if (body.unsilence) {
160
+ manager.unsilence(name);
161
+ } else {
162
+ manager.silence(name);
163
+ }
164
+ }
165
+ return json({ success: true, silenced: manager.getSilenced() });
166
+ }
167
+
168
+ if (req.method === "GET" && url.pathname === "/silence") {
169
+ return json({ silenced: manager.getSilenced() });
170
+ }
171
+
172
+ // ── Shutdown ──────────────────────────────────────────────────────────
173
+ if (req.method === "POST" && url.pathname === "/shutdown") {
174
+ // Respond first, then shut down
175
+ const response = json({ success: true });
176
+ setImmediate(onShutdown);
177
+ return response;
178
+ }
179
+
180
+ return json({ error: "Not found" }, 404);
181
+ },
182
+ });
183
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * orchestrator CLI
4
+ *
5
+ * Commands:
6
+ * start [config] Start processes (runs as daemon, exposes HTTP API)
7
+ * status Show process status (via daemon or port checks)
8
+ * restart <name> Restart a named process
9
+ * stop [name] Stop a named process, or all processes
10
+ * list [config] List processes defined in a config without starting them
11
+ * silence [name...] Suppress terminal output for processes
12
+ * unsilence <name...> Resume terminal output for processes
13
+ * logs [name...] Follow background daemon log files
14
+ *
15
+ * Global options:
16
+ * --port <n> Orchestrator API port (default: 4747)
17
+ * --help, -h Print help
18
+ */
19
+
20
+ import { runStartCommand } from "./commands/start.ts";
21
+ import { runStatusCommand } from "./commands/status.ts";
22
+ import { runRestartCommand } from "./commands/restart.ts";
23
+ import { runStopCommand } from "./commands/stop.ts";
24
+ import { runListCommand } from "./commands/list.ts";
25
+ import { runSilenceCommand } from "./commands/silence.ts";
26
+ import { runLogsCommand } from "./commands/logs.ts";
27
+
28
+ // ── Minimal arg parser ────────────────────────────────────────────────────────
29
+
30
+ export type ParsedArgs = {
31
+ command: string;
32
+ positionals: string[];
33
+ flags: Record<string, string | boolean>;
34
+ };
35
+
36
+ const SHORT_TO_LONG: Record<string, string> = {
37
+ b: "background",
38
+ c: "config",
39
+ e: "except",
40
+ f: "follow",
41
+ h: "help",
42
+ o: "only",
43
+ p: "port",
44
+ s: "serial",
45
+ };
46
+
47
+ function parseArgs(argv: string[]): ParsedArgs {
48
+ const positionals: string[] = [];
49
+ const flags: Record<string, string | boolean> = {};
50
+ let i = 0;
51
+ while (i < argv.length) {
52
+ const arg = argv[i];
53
+ if (arg.startsWith("--")) {
54
+ const eq = arg.indexOf("=");
55
+ if (eq !== -1) {
56
+ flags[arg.slice(2, eq)] = arg.slice(eq + 1);
57
+ } else if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
58
+ flags[arg.slice(2)] = argv[++i];
59
+ } else {
60
+ flags[arg.slice(2)] = true;
61
+ }
62
+ } else if (arg.startsWith("-") && arg.length === 2) {
63
+ const key = SHORT_TO_LONG[arg.slice(1)] ?? arg.slice(1);
64
+ if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
65
+ flags[key] = argv[++i];
66
+ } else {
67
+ flags[key] = true;
68
+ }
69
+ } else {
70
+ positionals.push(arg);
71
+ }
72
+ i++;
73
+ }
74
+ const [command = "", ...rest] = positionals;
75
+ return { command, positionals: rest, flags };
76
+ }
77
+
78
+ // ── Main ──────────────────────────────────────────────────────────────────────
79
+
80
+ const { command, positionals, flags } = parseArgs(process.argv.slice(2));
81
+
82
+ if (!command || flags["help"]) {
83
+ printHelp();
84
+ process.exit(0);
85
+ }
86
+
87
+ const port = flags["port"] ? Number(flags["port"]) : undefined;
88
+
89
+ try {
90
+ switch (command) {
91
+ case "start":
92
+ await runStartCommand({ positionals, flags, port });
93
+ break;
94
+
95
+ case "status":
96
+ await runStatusCommand({ positionals, flags, port });
97
+ break;
98
+
99
+ case "restart":
100
+ await runRestartCommand({ positionals, flags, port });
101
+ break;
102
+
103
+ case "stop":
104
+ await runStopCommand({ positionals, flags, port });
105
+ break;
106
+
107
+ case "list":
108
+ await runListCommand({ positionals, flags });
109
+ break;
110
+
111
+ case "silence":
112
+ await runSilenceCommand({ positionals, flags, port });
113
+ break;
114
+
115
+ case "unsilence":
116
+ await runSilenceCommand({ positionals, flags: { ...flags, unsilence: true }, port });
117
+ break;
118
+
119
+ case "logs":
120
+ await runLogsCommand({ positionals, flags });
121
+ break;
122
+
123
+ default:
124
+ console.error(`Unknown command: ${command}`);
125
+ printHelp();
126
+ process.exit(1);
127
+ }
128
+ } catch (err: any) {
129
+ console.error(`\x1b[31mError:\x1b[0m ${err?.message ?? err}`);
130
+ process.exit(1);
131
+ }
132
+
133
+ // ── Help ──────────────────────────────────────────────────────────────────────
134
+
135
+ function printHelp() {
136
+ console.log(`\x1b[1morchestrator\x1b[0m — Bun-based process orchestration CLI
137
+
138
+ \x1b[1mUsage:\x1b[0m
139
+ orchestrator <command> [options]
140
+
141
+ \x1b[1mCommands:\x1b[0m
142
+ start [config|name...] Start all processes, or specific ones by name
143
+ status Show status of running processes
144
+ restart <name> Restart a specific process by name
145
+ stop [name] Stop a specific process (or all if no name given)
146
+ list [config] List processes in config without starting them
147
+ silence [name...] Suppress terminal output for processes (no args = show list)
148
+ unsilence <name...> Resume terminal output for processes
149
+ logs [name...] Follow background daemon log files (like tail -f)
150
+
151
+ \x1b[1mGlobal options:\x1b[0m
152
+ -c, --config <path> Config file (default: auto-detected from daemon, package.json, or orchestrator.config.ts)
153
+ -h, --help Print help
154
+
155
+ \x1b[1mOptions for \`start\`:\x1b[0m
156
+ -b, --background Run as a detached background daemon; logs go to files
157
+ -p, --port <n> Orchestrator API port (default: 4747)
158
+ -o, --only <p1,p2,...> Run only these processes (+ their dependencies)
159
+ -e, --except <p1,p2,...> Skip these processes
160
+ -s, --serial Launch processes one at a time instead of in parallel waves
161
+ --no-deps Launch only named processes, skip dependency resolution
162
+ --log-dir <path> Log directory when using -b (default: .orchestrator-logs)
163
+ --no-api Disable the HTTP API server
164
+ --silence <p1,p2,...> Suppress terminal output for these processes
165
+
166
+ \x1b[1mOptions for \`status\`:\x1b[0m
167
+ -f, --follow Continuously refresh the status table (1s interval)
168
+
169
+ \x1b[1mExamples:\x1b[0m
170
+ orchestrator start orchestrator.config.ts
171
+ orchestrator start hardhat
172
+ orchestrator start hardhat deploy-evm-contracts
173
+ orchestrator start --only=midnight-node,midnight-indexer
174
+ orchestrator start --except=avail-client
175
+ orchestrator status
176
+ orchestrator restart midnight-node
177
+ orchestrator stop batcher
178
+ orchestrator start --silence=midnight-node,bitcoin-core
179
+ orchestrator silence midnight-node bitcoin-core
180
+ orchestrator unsilence midnight-node
181
+ orchestrator list orchestrator.config.ts
182
+ `);
183
+ }
@@ -0,0 +1,63 @@
1
+ import type { ParsedArgs } from "../cli.ts";
2
+ import { logError } from "../display.ts";
3
+ import { loadConfig, findDefaultConfig, findPackageJsonConfig } from "../load-config.ts";
4
+ import { readStateConfigPath } from "../port-check.ts";
5
+
6
+ type ListOptions = Pick<ParsedArgs, "positionals" | "flags">;
7
+
8
+ export async function runListCommand(opts: ListOptions): Promise<void> {
9
+ const configPath =
10
+ (opts.flags["config"] as string | undefined) ??
11
+ opts.positionals[0] ??
12
+ readStateConfigPath() ??
13
+ (await findPackageJsonConfig()) ??
14
+ (await findDefaultConfig());
15
+
16
+ if (!configPath) {
17
+ logError("(3) No config file found. Pass one as an argument or create orchestrator.config.ts.");
18
+ process.exit(1);
19
+ }
20
+
21
+ let config;
22
+ try {
23
+ config = await loadConfig(configPath);
24
+ } catch (err: any) {
25
+ logError(err.message);
26
+ process.exit(1);
27
+ }
28
+
29
+ const c = {
30
+ reset: "\x1b[0m",
31
+ bold: "\x1b[1m",
32
+ dim: "\x1b[2m",
33
+ cyan: "\x1b[36m",
34
+ gray: "\x1b[90m",
35
+ green: "\x1b[32m",
36
+ };
37
+
38
+ console.log(`${c.bold}Processes in ${configPath}${c.reset}\n`);
39
+
40
+ for (const p of config.processes) {
41
+ const ports = p.stopProcessAtPort?.join(", ") ?? "";
42
+ const deps = (p.dependsOn ?? []).join(", ");
43
+ const cmd = `${p.command ?? "bun"} ${p.args.join(" ")}`;
44
+ const wait = p.waitToExit ? "wait-to-exit" : "background";
45
+ const crit = p.critical === false
46
+ ? `${c.dim}non-critical${c.reset}`
47
+ : `${c.dim}critical${c.reset}`;
48
+
49
+ console.log(` ${c.bold}${c.cyan}${p.name}${c.reset}`);
50
+ if (p.description) {
51
+ console.log(` ${c.dim}${p.description}${c.reset}`);
52
+ }
53
+ console.log(` cmd: ${c.dim}${cmd}${c.reset}`);
54
+ if (ports) console.log(` ports: ${ports}`);
55
+ if (deps) console.log(` deps: ${deps}`);
56
+ const manual = p.autoStart === false ? ` ${c.dim}manual${c.reset}` : "";
57
+ console.log(` mode: ${wait} ${crit}${manual}`);
58
+ if (p.link) console.log(` link: ${c.green}${p.link}${c.reset}`);
59
+ console.log();
60
+ }
61
+
62
+ console.log(`${c.dim}Total: ${config.processes.length} process(es)${c.reset}`);
63
+ }
@@ -0,0 +1,142 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import type { ParsedArgs } from "../cli.ts";
4
+
5
+ type LogsOptions = Pick<ParsedArgs, "positionals" | "flags">;
6
+
7
+ /**
8
+ * `logs` command — tails all (or specific) background daemon log files.
9
+ *
10
+ * orchestrator logs # follow all logs
11
+ * orchestrator logs sync batcher # follow only sync.log and batcher.log
12
+ * orchestrator logs --log-dir=./logs # custom log directory
13
+ */
14
+ export async function runLogsCommand(opts: LogsOptions): Promise<void> {
15
+ const logDir = path.resolve(
16
+ (opts.flags["log-dir"] as string | undefined) ??
17
+ path.join(process.cwd(), ".orchestrator-logs"),
18
+ );
19
+
20
+ if (!fs.existsSync(logDir)) {
21
+ throw new Error(
22
+ `Log directory not found: ${logDir}\nStart the orchestrator with --background first.`,
23
+ );
24
+ }
25
+
26
+ // Determine which log files to follow
27
+ const filterNames = opts.positionals.length > 0 ? opts.positionals : null;
28
+
29
+ const allFiles = fs.readdirSync(logDir).filter((f) => f.endsWith(".log")).sort();
30
+ const files = filterNames
31
+ ? allFiles.filter((f) => filterNames.some((name) => f === `${name}.log`))
32
+ : allFiles;
33
+
34
+ if (files.length === 0) {
35
+ const msg = filterNames
36
+ ? `No log files found for: ${filterNames.join(", ")}\nAvailable: ${allFiles.map((f) => f.replace(/\.log$/, "")).join(", ") || "(none)"}`
37
+ : `No log files found in ${logDir}`;
38
+ throw new Error(msg);
39
+ }
40
+
41
+ const col = {
42
+ reset: "\x1b[0m",
43
+ bold: "\x1b[1m",
44
+ dim: "\x1b[2m",
45
+ cyan: "\x1b[36m",
46
+ };
47
+
48
+ console.log(`${col.bold}Following logs${col.reset} from ${col.cyan}${logDir}${col.reset}`);
49
+ console.log(`${col.dim}Files: ${files.join(", ")}${col.reset}`);
50
+ console.log(`${col.dim}Press Ctrl+C to stop${col.reset}\n`);
51
+
52
+ // Assign a color to each file for visual distinction
53
+ const palette = [
54
+ "\x1b[32m", // green
55
+ "\x1b[33m", // yellow
56
+ "\x1b[34m", // blue
57
+ "\x1b[35m", // magenta
58
+ "\x1b[36m", // cyan
59
+ "\x1b[91m", // bright red
60
+ "\x1b[92m", // bright green
61
+ "\x1b[93m", // bright yellow
62
+ "\x1b[94m", // bright blue
63
+ "\x1b[95m", // bright magenta
64
+ ];
65
+
66
+ const maxNameLen = Math.max(...files.map((f) => f.replace(/\.log$/, "").length));
67
+
68
+ // Track file watchers for cleanup
69
+ const watchers: fs.FSWatcher[] = [];
70
+ const filePositions = new Map<string, number>();
71
+
72
+ for (let i = 0; i < files.length; i++) {
73
+ const file = files[i];
74
+ const filePath = path.join(logDir, file);
75
+ const name = file.replace(/\.log$/, "");
76
+ const color = palette[i % palette.length];
77
+ const prefix = `${color}${name.padEnd(maxNameLen)}${col.reset} ${col.dim}|${col.reset} `;
78
+
79
+ // Read existing content from the end (last 50 lines) for context
80
+ const initialContent = readTail(filePath, 50);
81
+ if (initialContent) {
82
+ for (const line of initialContent.split("\n")) {
83
+ if (line) console.log(`${prefix}${line}`);
84
+ }
85
+ }
86
+
87
+ // Track current position
88
+ const stat = fs.statSync(filePath);
89
+ filePositions.set(filePath, stat.size);
90
+
91
+ // Watch for changes
92
+ const watcher = fs.watch(filePath, () => {
93
+ const prevPos = filePositions.get(filePath) ?? 0;
94
+ let currentSize: number;
95
+ try {
96
+ currentSize = fs.statSync(filePath).size;
97
+ } catch {
98
+ return;
99
+ }
100
+
101
+ if (currentSize <= prevPos) {
102
+ // File was truncated — reset position
103
+ filePositions.set(filePath, 0);
104
+ return;
105
+ }
106
+
107
+ // Read new bytes
108
+ const fd = fs.openSync(filePath, "r");
109
+ const buf = Buffer.alloc(currentSize - prevPos);
110
+ fs.readSync(fd, buf, 0, buf.length, prevPos);
111
+ fs.closeSync(fd);
112
+ filePositions.set(filePath, currentSize);
113
+
114
+ const text = buf.toString("utf-8");
115
+ for (const line of text.split("\n")) {
116
+ if (line) console.log(`${prefix}${line}`);
117
+ }
118
+ });
119
+ watchers.push(watcher);
120
+ }
121
+
122
+ // Keep alive until Ctrl+C
123
+ await new Promise<void>((resolve) => {
124
+ const cleanup = () => {
125
+ for (const w of watchers) w.close();
126
+ resolve();
127
+ };
128
+ process.on("SIGINT", cleanup);
129
+ process.on("SIGTERM", cleanup);
130
+ });
131
+ }
132
+
133
+ /** Read the last N lines of a file. */
134
+ function readTail(filePath: string, lines: number): string {
135
+ try {
136
+ const content = fs.readFileSync(filePath, "utf-8");
137
+ const allLines = content.split("\n");
138
+ return allLines.slice(-lines).join("\n");
139
+ } catch {
140
+ return "";
141
+ }
142
+ }
@@ -0,0 +1,34 @@
1
+ import type { ParsedArgs } from "../cli.ts";
2
+ import { OrchestratorClient } from "../api-client.ts";
3
+ import { logInfo, logError } from "../display.ts";
4
+
5
+ type RestartOptions = Pick<ParsedArgs, "positionals" | "flags"> & {
6
+ port?: number;
7
+ };
8
+
9
+ export async function runRestartCommand(opts: RestartOptions): Promise<void> {
10
+ const name = opts.positionals[0];
11
+ if (!name) {
12
+ logError("Usage: orchestrator restart <process-name>");
13
+ process.exit(1);
14
+ }
15
+
16
+ const client = new OrchestratorClient(opts.port);
17
+
18
+ if (!(await client.isRunning())) {
19
+ logError(
20
+ "Orchestrator daemon is not running. Start it with: orchestrator start",
21
+ );
22
+ process.exit(1);
23
+ }
24
+
25
+ logInfo(`Restarting "${name}"…`);
26
+ const result = await client.restart(name);
27
+
28
+ if (result.error) {
29
+ logError(result.error);
30
+ process.exit(1);
31
+ }
32
+
33
+ logInfo(`"${name}" restarted successfully.`);
34
+ }