@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.
- package/package.json +37 -0
- package/scripts/launch-avail.ts +64 -0
- package/scripts/launch-bitcoin.ts +62 -0
- package/scripts/launch-cardano.ts +99 -0
- package/scripts/launch-evm.ts +72 -0
- package/scripts/launch-midnight.ts +108 -0
- package/scripts/launch-near.ts +41 -0
- package/scripts/launch-pglite.ts +30 -0
- package/scripts/resolve-package.ts +42 -0
- package/scripts/wait-http.ts +24 -0
- package/scripts/wait-tcp.ts +30 -0
- package/src/api-client.ts +92 -0
- package/src/api-server.ts +183 -0
- package/src/cli.ts +183 -0
- package/src/commands/list.ts +63 -0
- package/src/commands/logs.ts +142 -0
- package/src/commands/restart.ts +34 -0
- package/src/commands/silence.ts +49 -0
- package/src/commands/start.ts +304 -0
- package/src/commands/status.ts +111 -0
- package/src/commands/stop.ts +100 -0
- package/src/config.ts +65 -0
- package/src/display.ts +190 -0
- package/src/load-config.ts +64 -0
- package/src/port-check.ts +126 -0
- package/src/process-manager.ts +266 -0
- package/src/task-runner.ts +276 -0
|
@@ -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
|
+
}
|