@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
package/src/display.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import type { RemoteProcess } from "./api-client.ts";
|
|
3
|
+
import type { ProcessConfig } from "./config.ts";
|
|
4
|
+
|
|
5
|
+
// ANSI colour helpers
|
|
6
|
+
const c = {
|
|
7
|
+
reset: "\x1b[0m",
|
|
8
|
+
bold: "\x1b[1m",
|
|
9
|
+
dim: "\x1b[2m",
|
|
10
|
+
green: "\x1b[32m",
|
|
11
|
+
yellow: "\x1b[33m",
|
|
12
|
+
red: "\x1b[31m",
|
|
13
|
+
blue: "\x1b[34m",
|
|
14
|
+
cyan: "\x1b[36m",
|
|
15
|
+
gray: "\x1b[90m",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function pad(s: string, width: number, align: "left" | "right" = "left"): string {
|
|
19
|
+
const len = stripAnsi(s).length;
|
|
20
|
+
const pad = Math.max(0, width - len);
|
|
21
|
+
return align === "left" ? s + " ".repeat(pad) : " ".repeat(pad) + s;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stripAnsi(s: string): string {
|
|
25
|
+
// eslint-disable-next-line no-control-regex
|
|
26
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function colorStatus(status: string, waitToExit?: boolean): string {
|
|
30
|
+
switch (status) {
|
|
31
|
+
case "running":
|
|
32
|
+
// Distinguish background services from tasks that will exit
|
|
33
|
+
if (waitToExit) return `${c.yellow}running…${c.reset}`;
|
|
34
|
+
return `${c.green}${status}${c.reset}`;
|
|
35
|
+
case "done":
|
|
36
|
+
return `${c.blue}${status}${c.reset}`;
|
|
37
|
+
case "failed":
|
|
38
|
+
return `${c.red}${status}${c.reset}`;
|
|
39
|
+
case "stopped":
|
|
40
|
+
return `${c.yellow}${status}${c.reset}`;
|
|
41
|
+
case "pending":
|
|
42
|
+
return `${c.gray}${status}${c.reset}`;
|
|
43
|
+
default:
|
|
44
|
+
return status;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function relativeTime(iso: string | null): string {
|
|
49
|
+
if (!iso) return c.gray + "—" + c.reset;
|
|
50
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
51
|
+
if (ms < 60_000) return `${Math.floor(ms / 1000)}s ago`;
|
|
52
|
+
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`;
|
|
53
|
+
return `${Math.floor(ms / 3_600_000)}h ago`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type StatusRow =
|
|
57
|
+
| { source: "daemon"; config: ProcessConfig; daemon: RemoteProcess; actualPid?: number }
|
|
58
|
+
| { source: "port"; config: ProcessConfig; portStatus: "up" | "partial" | "down" | "no-ports"; pid?: number };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Unified status table. Shows every process from the config in order.
|
|
62
|
+
* Processes tracked by the daemon show live data; others show port-based status.
|
|
63
|
+
*/
|
|
64
|
+
export function printStatusTable(rows: StatusRow[], daemonRunning: boolean): void {
|
|
65
|
+
const hasLogFiles = rows.some(
|
|
66
|
+
(r) => r.source === "daemon" && r.daemon.logFile,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const cols = { name: 28, status: 10, pid: 8, ports: 18, started: 12 };
|
|
70
|
+
const tailLabel = hasLogFiles ? "LOG" : "LINK";
|
|
71
|
+
|
|
72
|
+
const header =
|
|
73
|
+
c.bold +
|
|
74
|
+
pad("NAME", cols.name) +
|
|
75
|
+
" " +
|
|
76
|
+
pad("STATUS", cols.status) +
|
|
77
|
+
" " +
|
|
78
|
+
pad("PID", cols.pid, "right") +
|
|
79
|
+
" " +
|
|
80
|
+
pad("PORTS", cols.ports) +
|
|
81
|
+
" " +
|
|
82
|
+
pad("STARTED", cols.started) +
|
|
83
|
+
" " +
|
|
84
|
+
tailLabel +
|
|
85
|
+
c.reset;
|
|
86
|
+
|
|
87
|
+
const sep = c.dim + "─".repeat(110) + c.reset;
|
|
88
|
+
|
|
89
|
+
console.log(header);
|
|
90
|
+
console.log(sep);
|
|
91
|
+
|
|
92
|
+
for (const row of rows) {
|
|
93
|
+
const portList = (row.config.stopProcessAtPort ?? []).join(", ");
|
|
94
|
+
const ports = portList || c.gray + "—" + c.reset;
|
|
95
|
+
const nameLabel = row.config.name + (row.config.autoStart === false ? ` ${c.dim}*${c.reset}` : "");
|
|
96
|
+
|
|
97
|
+
if (row.source === "daemon") {
|
|
98
|
+
const { daemon: d } = row;
|
|
99
|
+
const pidVal = row.actualPid ?? d.pid;
|
|
100
|
+
const pid = pidVal != null ? String(pidVal) : c.gray + "—" + c.reset;
|
|
101
|
+
const relLog = d.logFile ? path.relative(process.cwd(), d.logFile) || "." : null;
|
|
102
|
+
const tail = hasLogFiles
|
|
103
|
+
? relLog ? `${c.dim}${relLog}${c.reset}` : ""
|
|
104
|
+
: d.link ? `${c.cyan}${d.link}${c.reset}` : "";
|
|
105
|
+
|
|
106
|
+
console.log(
|
|
107
|
+
pad(nameLabel, cols.name) +
|
|
108
|
+
" " +
|
|
109
|
+
pad(colorStatus(d.status, row.config.waitToExit), cols.status) +
|
|
110
|
+
" " +
|
|
111
|
+
pad(pid, cols.pid, "right") +
|
|
112
|
+
" " +
|
|
113
|
+
pad(ports, cols.ports) +
|
|
114
|
+
" " +
|
|
115
|
+
pad(relativeTime(d.startedAt), cols.started) +
|
|
116
|
+
" " +
|
|
117
|
+
tail,
|
|
118
|
+
);
|
|
119
|
+
} else {
|
|
120
|
+
const dash = c.gray + "—" + c.reset;
|
|
121
|
+
const status = colorPortStatus(row.portStatus);
|
|
122
|
+
const pid = row.pid != null ? String(row.pid) : dash;
|
|
123
|
+
console.log(
|
|
124
|
+
pad(nameLabel, cols.name) +
|
|
125
|
+
" " +
|
|
126
|
+
pad(status, cols.status) +
|
|
127
|
+
" " +
|
|
128
|
+
pad(pid, cols.pid, "right") +
|
|
129
|
+
" " +
|
|
130
|
+
pad(ports, cols.ports) +
|
|
131
|
+
" " +
|
|
132
|
+
pad(dash, cols.started) +
|
|
133
|
+
" " +
|
|
134
|
+
(row.config.link ? `${c.cyan}${row.config.link}${c.reset}` : ""),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const hasManual = rows.some((r) => r.config.autoStart === false);
|
|
140
|
+
if (hasManual) {
|
|
141
|
+
console.log(`\n${c.dim}* manual — only runs via --only, not during default start${c.reset}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!daemonRunning) {
|
|
145
|
+
console.log(
|
|
146
|
+
`\n${c.dim}(no daemon — port-based status only; start with: orchestrator start)${c.reset}`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function colorPortStatus(s: "up" | "partial" | "down" | "no-ports"): string {
|
|
152
|
+
switch (s) {
|
|
153
|
+
case "up": return `${c.green}up${c.reset}`;
|
|
154
|
+
case "partial": return `${c.yellow}partial${c.reset}`;
|
|
155
|
+
case "down": return `${c.red}down${c.reset}`;
|
|
156
|
+
case "no-ports": return `${c.gray}—${c.reset}`;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Prints a start event line to the terminal.
|
|
162
|
+
*/
|
|
163
|
+
export function logTaskStart(name: string, logFile?: string): void {
|
|
164
|
+
const suffix = logFile ? ` ${c.dim}→ ${logFile}${c.reset}` : "";
|
|
165
|
+
console.log(`${c.green}▶${c.reset} ${c.bold}${name}${c.reset}${suffix}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function logTaskRunning(name: string): void {
|
|
169
|
+
console.log(`${c.blue}●${c.reset} ${c.bold}${name}${c.reset} ${c.dim}running in background${c.reset}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function logTaskDone(name: string): void {
|
|
173
|
+
console.log(`${c.blue}✓${c.reset} ${c.bold}${name}${c.reset} ${c.dim}exited successfully${c.reset}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function logTaskFailed(name: string, code: number): void {
|
|
177
|
+
console.error(`${c.red}✗${c.reset} ${c.bold}${name}${c.reset} ${c.red}exited with code ${code}${c.reset}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function logInfo(msg: string): void {
|
|
181
|
+
console.log(`${c.cyan}ℹ${c.reset} ${msg}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function logError(msg: string): void {
|
|
185
|
+
console.error(`${c.red}✗${c.reset} ${msg}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function logWarn(msg: string): void {
|
|
189
|
+
console.warn(`${c.yellow}⚠${c.reset} ${msg}`);
|
|
190
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import type { OrchestratorConfig } from "./config.ts";
|
|
3
|
+
import { logWarn } from "./display.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Loads an OrchestratorConfig from a TypeScript or JSON file.
|
|
7
|
+
* Handles both absolute paths and paths relative to process.cwd().
|
|
8
|
+
*/
|
|
9
|
+
export async function loadConfig(filePath: string): Promise<OrchestratorConfig> {
|
|
10
|
+
// Ensure the path is absolute so `import()` works reliably
|
|
11
|
+
const abs = path.isAbsolute(filePath)
|
|
12
|
+
? filePath
|
|
13
|
+
: path.resolve(process.cwd(), filePath);
|
|
14
|
+
|
|
15
|
+
const mod = await import(abs);
|
|
16
|
+
const cfg: OrchestratorConfig = mod.default ?? mod;
|
|
17
|
+
|
|
18
|
+
if (!Array.isArray(cfg.processes)) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`Config file "${filePath}" must export an OrchestratorConfig with a "processes" array.`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The "sync" process is the main Effectstream sync engine. Warn if missing.
|
|
25
|
+
const hasSync = cfg.processes.some((p) => p.name === "sync");
|
|
26
|
+
if (!hasSync) {
|
|
27
|
+
logWarn(
|
|
28
|
+
`No "sync" process defined in config. ` +
|
|
29
|
+
`Add a process named "sync" (e.g. args: ["run", "node:start"]) to start the Effectstream sync engine.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return cfg;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Returns the path to the first default config file found, or null. */
|
|
37
|
+
export async function findDefaultConfig(): Promise<string | null> {
|
|
38
|
+
const candidates = [
|
|
39
|
+
"orchestrator.config.ts",
|
|
40
|
+
"orchestrator.config.js",
|
|
41
|
+
"orchestrator.config.json",
|
|
42
|
+
];
|
|
43
|
+
for (const name of candidates) {
|
|
44
|
+
const abs = path.resolve(process.cwd(), name);
|
|
45
|
+
if (await Bun.file(abs).exists()) return abs;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Checks package.json in cwd for an `effectstream.default` config path. */
|
|
51
|
+
export async function findPackageJsonConfig(): Promise<string | null> {
|
|
52
|
+
try {
|
|
53
|
+
const pkgPath = path.join(process.cwd(), "package.json");
|
|
54
|
+
const content = await Bun.file(pkgPath).text();
|
|
55
|
+
const pkg = JSON.parse(content);
|
|
56
|
+
const configRel = pkg?.effectstream?.default;
|
|
57
|
+
if (typeof configRel === "string") {
|
|
58
|
+
return path.resolve(process.cwd(), configRel);
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// no package.json or invalid JSON
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import * as net from "net";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as fs from "fs";
|
|
5
|
+
|
|
6
|
+
/** Returns true if something is accepting connections on `port`. */
|
|
7
|
+
export function isPortInUse(port: number, host = "127.0.0.1"): Promise<boolean> {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
const socket = net.createConnection({ port, host });
|
|
10
|
+
socket.setTimeout(500);
|
|
11
|
+
socket.once("connect", () => { socket.destroy(); resolve(true); });
|
|
12
|
+
socket.once("timeout", () => { socket.destroy(); resolve(false); });
|
|
13
|
+
socket.once("error", () => resolve(false));
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Polls until the port is in use or the timeout elapses. Returns true if port came up. */
|
|
18
|
+
export async function waitForPort(port: number, timeoutMs = 60_000): Promise<boolean> {
|
|
19
|
+
const deadline = Date.now() + timeoutMs;
|
|
20
|
+
while (Date.now() < deadline) {
|
|
21
|
+
if (await isPortInUse(port)) return true;
|
|
22
|
+
await Bun.sleep(500);
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns the PID(s) of processes listening on `port`, or an empty array if none.
|
|
29
|
+
* Uses `lsof -ti tcp:<port>` (macOS / Linux).
|
|
30
|
+
*/
|
|
31
|
+
export function pidsByPort(port: number): number[] {
|
|
32
|
+
const result = Bun.spawnSync(["lsof", "-ti", `tcp:${port}`], {
|
|
33
|
+
stderr: "pipe",
|
|
34
|
+
});
|
|
35
|
+
if (result.exitCode !== 0) return [];
|
|
36
|
+
return result.stdout
|
|
37
|
+
.toString()
|
|
38
|
+
.trim()
|
|
39
|
+
.split("\n")
|
|
40
|
+
.map((s) => parseInt(s.trim(), 10))
|
|
41
|
+
.filter((n) => !isNaN(n));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Kills any process occupying `port` via SIGTERM, then waits for the port to free.
|
|
46
|
+
* Uses `lsof` (macOS / Linux).
|
|
47
|
+
*/
|
|
48
|
+
export async function freePort(port: number): Promise<void> {
|
|
49
|
+
if (!(await isPortInUse(port))) return;
|
|
50
|
+
|
|
51
|
+
const result = Bun.spawnSync(["lsof", "-ti", `tcp:${port}`], {
|
|
52
|
+
stderr: "pipe",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (result.exitCode === 0) {
|
|
56
|
+
const pids = result.stdout
|
|
57
|
+
.toString()
|
|
58
|
+
.trim()
|
|
59
|
+
.split("\n")
|
|
60
|
+
.filter(Boolean);
|
|
61
|
+
|
|
62
|
+
for (const pid of pids) {
|
|
63
|
+
Bun.spawnSync(["kill", "-TERM", pid.trim()]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Wait up to 5 seconds for the port to free
|
|
67
|
+
let tries = 20;
|
|
68
|
+
while (tries-- > 0) {
|
|
69
|
+
if (!(await isPortInUse(port))) return;
|
|
70
|
+
await Bun.sleep(250);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Fall back to SIGKILL
|
|
74
|
+
for (const pid of pids) {
|
|
75
|
+
Bun.spawnSync(["kill", "-KILL", pid.trim()]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// State file: records the API port of the running daemon so CLI commands can
|
|
82
|
+
// discover it without requiring the user to pass --port every time.
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
const STATE_FILE = path.join(os.tmpdir(), ".orchestrator.port");
|
|
86
|
+
|
|
87
|
+
export const DEFAULT_API_PORT = 4747;
|
|
88
|
+
|
|
89
|
+
export function writeState(port: number, configPath: string): void {
|
|
90
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify({ port, configPath }), "utf8");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function readStatePort(): number | null {
|
|
94
|
+
try {
|
|
95
|
+
const text = fs.readFileSync(STATE_FILE, "utf8").trim();
|
|
96
|
+
if (text.startsWith("{")) {
|
|
97
|
+
const data = JSON.parse(text);
|
|
98
|
+
return typeof data.port === "number" ? data.port : null;
|
|
99
|
+
}
|
|
100
|
+
const n = parseInt(text, 10);
|
|
101
|
+
return isNaN(n) ? null : n;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function readStateConfigPath(): string | null {
|
|
108
|
+
try {
|
|
109
|
+
const text = fs.readFileSync(STATE_FILE, "utf8").trim();
|
|
110
|
+
if (text.startsWith("{")) {
|
|
111
|
+
const data = JSON.parse(text);
|
|
112
|
+
return typeof data.configPath === "string" ? data.configPath : null;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function clearStatePort(): void {
|
|
121
|
+
try {
|
|
122
|
+
fs.unlinkSync(STATE_FILE);
|
|
123
|
+
} catch {
|
|
124
|
+
// ignore
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import type { ProcessConfig } from "./config.ts";
|
|
4
|
+
import { freePort } from "./port-check.ts";
|
|
5
|
+
|
|
6
|
+
export type ProcessStatus = "pending" | "running" | "done" | "failed" | "stopped";
|
|
7
|
+
|
|
8
|
+
export type ManagedProcess = {
|
|
9
|
+
name: string;
|
|
10
|
+
pid: number | null;
|
|
11
|
+
/** Ports this process is expected to occupy (from config.stopProcessAtPort). */
|
|
12
|
+
ports: number[];
|
|
13
|
+
status: ProcessStatus;
|
|
14
|
+
config: ProcessConfig;
|
|
15
|
+
startedAt: Date | null;
|
|
16
|
+
endedAt: Date | null;
|
|
17
|
+
exitCode: number | null;
|
|
18
|
+
/** Absolute path to the log file, or null if logging to terminal. */
|
|
19
|
+
logFile: string | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type BunProc = ReturnType<typeof Bun.spawn>;
|
|
23
|
+
type StreamReader = ReadableStreamDefaultReader<Uint8Array>;
|
|
24
|
+
|
|
25
|
+
export type ChangeListener = (p: ManagedProcess) => void;
|
|
26
|
+
|
|
27
|
+
export class ProcessManager {
|
|
28
|
+
private processes = new Map<string, ManagedProcess>();
|
|
29
|
+
private procs = new Map<string, BunProc>();
|
|
30
|
+
/** Active stream readers per process — cancelled on stop to release file descriptors. */
|
|
31
|
+
private readers = new Map<string, StreamReader[]>();
|
|
32
|
+
private listeners: ChangeListener[] = [];
|
|
33
|
+
/** Processes currently being intentionally stopped/restarted — suppress exit handlers. */
|
|
34
|
+
private stopping = new Set<string>();
|
|
35
|
+
/** Processes whose terminal output is suppressed. */
|
|
36
|
+
private silenced = new Set<string>();
|
|
37
|
+
/**
|
|
38
|
+
* When set, each process's stdout+stderr are appended to
|
|
39
|
+
* `<logDir>/<process-name>.log` instead of inheriting the terminal.
|
|
40
|
+
*/
|
|
41
|
+
logDir: string | undefined;
|
|
42
|
+
|
|
43
|
+
onProcessChange(fn: ChangeListener): () => void {
|
|
44
|
+
this.listeners.push(fn);
|
|
45
|
+
return () => {
|
|
46
|
+
const i = this.listeners.indexOf(fn);
|
|
47
|
+
if (i !== -1) this.listeners.splice(i, 1);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private emit(p: ManagedProcess) {
|
|
52
|
+
for (const fn of this.listeners) fn(p);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getAll(): ManagedProcess[] {
|
|
56
|
+
return Array.from(this.processes.values());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get(name: string): ManagedProcess | undefined {
|
|
60
|
+
return this.processes.get(name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Spawns the process described by `config`.
|
|
65
|
+
* Returns a `waitForExit` promise that resolves with the exit code.
|
|
66
|
+
* When `this.logDir` is set, stdout+stderr are appended to
|
|
67
|
+
* `<logDir>/<name>.log` instead of the terminal.
|
|
68
|
+
*/
|
|
69
|
+
async launch(config: ProcessConfig): Promise<{ waitForExit: Promise<number>; logFile: string | null }> {
|
|
70
|
+
// Free any ports this process needs
|
|
71
|
+
for (const port of config.stopProcessAtPort ?? []) {
|
|
72
|
+
await freePort(port);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const command = config.command ?? "bun";
|
|
76
|
+
|
|
77
|
+
// Resolve log file destination
|
|
78
|
+
let logFile: string | null = null;
|
|
79
|
+
let outFd: number | "inherit" = "inherit";
|
|
80
|
+
if (this.logDir) {
|
|
81
|
+
logFile = path.join(this.logDir, `${config.name}.log`);
|
|
82
|
+
// Open in append mode so restarts accumulate in the same file
|
|
83
|
+
outFd = fs.openSync(logFile, "a");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// When logging to a file, write directly to the fd.
|
|
87
|
+
// Otherwise, pipe output so we can suppress silenced processes.
|
|
88
|
+
const usesPipe = typeof outFd !== "number";
|
|
89
|
+
|
|
90
|
+
const proc = Bun.spawn([command, ...config.args], {
|
|
91
|
+
env: {
|
|
92
|
+
...process.env,
|
|
93
|
+
...config.env,
|
|
94
|
+
FORCE_COLOR: "true",
|
|
95
|
+
},
|
|
96
|
+
cwd: config.cwd ?? process.cwd(),
|
|
97
|
+
stdout: usesPipe ? "pipe" : outFd,
|
|
98
|
+
stderr: usesPipe ? "pipe" : outFd,
|
|
99
|
+
stdin: "ignore",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// The child has inherited the fd — safe to close the parent's copy
|
|
103
|
+
if (typeof outFd === "number") fs.closeSync(outFd);
|
|
104
|
+
|
|
105
|
+
// Forward piped output to the terminal unless silenced, prefixed with [name]
|
|
106
|
+
if (usesPipe) {
|
|
107
|
+
const prefix = `\x1b[36m[${config.name}]\x1b[0m `;
|
|
108
|
+
const prefixBytes = new TextEncoder().encode(prefix);
|
|
109
|
+
const newline = 0x0a; // '\n'
|
|
110
|
+
const activeReaders: StreamReader[] = [];
|
|
111
|
+
|
|
112
|
+
const forward = (stream: ReadableStream<Uint8Array> | null, dest: typeof process.stdout) => {
|
|
113
|
+
if (!stream) return;
|
|
114
|
+
const reader = stream.getReader();
|
|
115
|
+
activeReaders.push(reader);
|
|
116
|
+
let atLineStart = true;
|
|
117
|
+
|
|
118
|
+
const pump = (): void => {
|
|
119
|
+
reader.read().then(({ done, value }) => {
|
|
120
|
+
if (done) {
|
|
121
|
+
reader.releaseLock();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (this.silenced.has(config.name)) {
|
|
125
|
+
pump();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Prefix each line with [processName]
|
|
130
|
+
const chunks: Uint8Array[] = [];
|
|
131
|
+
let start = 0;
|
|
132
|
+
for (let i = 0; i < value.length; i++) {
|
|
133
|
+
if (atLineStart) {
|
|
134
|
+
chunks.push(prefixBytes);
|
|
135
|
+
atLineStart = false;
|
|
136
|
+
}
|
|
137
|
+
if (value[i] === newline) {
|
|
138
|
+
chunks.push(value.subarray(start, i + 1));
|
|
139
|
+
start = i + 1;
|
|
140
|
+
atLineStart = true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Remaining bytes after last newline
|
|
144
|
+
if (start < value.length) {
|
|
145
|
+
chunks.push(value.subarray(start));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const chunk of chunks) {
|
|
149
|
+
dest.write(chunk);
|
|
150
|
+
}
|
|
151
|
+
pump();
|
|
152
|
+
}).catch(() => {});
|
|
153
|
+
};
|
|
154
|
+
pump();
|
|
155
|
+
};
|
|
156
|
+
forward(proc.stdout as ReadableStream<Uint8Array>, process.stdout);
|
|
157
|
+
forward(proc.stderr as ReadableStream<Uint8Array>, process.stderr);
|
|
158
|
+
this.readers.set(config.name, activeReaders);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const managed: ManagedProcess = {
|
|
162
|
+
name: config.name,
|
|
163
|
+
pid: proc.pid,
|
|
164
|
+
ports: config.stopProcessAtPort ?? [],
|
|
165
|
+
status: "running",
|
|
166
|
+
config,
|
|
167
|
+
startedAt: new Date(),
|
|
168
|
+
endedAt: null,
|
|
169
|
+
exitCode: null,
|
|
170
|
+
logFile,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
this.processes.set(config.name, managed);
|
|
174
|
+
this.procs.set(config.name, proc);
|
|
175
|
+
this.emit(managed);
|
|
176
|
+
|
|
177
|
+
const waitForExit = proc.exited.then((code) => {
|
|
178
|
+
managed.status = code === 0 ? "done" : "failed";
|
|
179
|
+
managed.exitCode = code;
|
|
180
|
+
managed.endedAt = new Date();
|
|
181
|
+
this.procs.delete(config.name);
|
|
182
|
+
this.emit(managed);
|
|
183
|
+
return code;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return { waitForExit, logFile };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Sends SIGTERM to the named process; waits for it to exit.
|
|
191
|
+
* Falls back to SIGKILL after 5 seconds.
|
|
192
|
+
*/
|
|
193
|
+
async stop(name: string): Promise<boolean> {
|
|
194
|
+
const proc = this.procs.get(name);
|
|
195
|
+
const managed = this.processes.get(name);
|
|
196
|
+
if (!proc || !managed) return false;
|
|
197
|
+
|
|
198
|
+
this.stopping.add(name);
|
|
199
|
+
|
|
200
|
+
// Cancel active stream readers to release file descriptors
|
|
201
|
+
const readers = this.readers.get(name);
|
|
202
|
+
if (readers) {
|
|
203
|
+
for (const reader of readers) {
|
|
204
|
+
reader.cancel().catch(() => {});
|
|
205
|
+
}
|
|
206
|
+
this.readers.delete(name);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
proc.kill("SIGTERM");
|
|
210
|
+
|
|
211
|
+
// Wait up to 5 s for graceful exit, then SIGKILL
|
|
212
|
+
const graceful = await Promise.race([
|
|
213
|
+
proc.exited.then(() => true),
|
|
214
|
+
Bun.sleep(5000).then(() => false),
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
if (!graceful) {
|
|
218
|
+
proc.kill("SIGKILL");
|
|
219
|
+
await proc.exited.catch(() => {});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
managed.status = "stopped";
|
|
223
|
+
managed.endedAt = new Date();
|
|
224
|
+
this.procs.delete(name);
|
|
225
|
+
this.stopping.delete(name);
|
|
226
|
+
this.emit(managed);
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Stops and re-launches a process. Returns null if the process was never started. */
|
|
231
|
+
async restart(name: string): Promise<{ waitForExit: Promise<number> } | null> {
|
|
232
|
+
const managed = this.processes.get(name);
|
|
233
|
+
if (!managed) return null;
|
|
234
|
+
await this.stop(name);
|
|
235
|
+
return this.launch(managed.config);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Stops all running processes in parallel. */
|
|
239
|
+
async stopAll(): Promise<void> {
|
|
240
|
+
await Promise.all(Array.from(this.procs.keys()).map((n) => this.stop(n)));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
isRunning(name: string): boolean {
|
|
244
|
+
return this.procs.has(name);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Returns true if the process is being intentionally stopped (via stop/restart). */
|
|
248
|
+
isStopping(name: string): boolean {
|
|
249
|
+
return this.stopping.has(name);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Suppress terminal output for the named process. */
|
|
253
|
+
silence(name: string): void {
|
|
254
|
+
this.silenced.add(name);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Resume terminal output for the named process. */
|
|
258
|
+
unsilence(name: string): void {
|
|
259
|
+
this.silenced.delete(name);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Returns the set of currently silenced process names. */
|
|
263
|
+
getSilenced(): string[] {
|
|
264
|
+
return [...this.silenced];
|
|
265
|
+
}
|
|
266
|
+
}
|