@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,49 @@
|
|
|
1
|
+
import type { ParsedArgs } from "../cli.ts";
|
|
2
|
+
import { OrchestratorClient } from "../api-client.ts";
|
|
3
|
+
import { logInfo, logError } from "../display.ts";
|
|
4
|
+
|
|
5
|
+
type SilenceOptions = Pick<ParsedArgs, "positionals" | "flags"> & {
|
|
6
|
+
port?: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export async function runSilenceCommand(opts: SilenceOptions): Promise<void> {
|
|
10
|
+
const names = opts.positionals;
|
|
11
|
+
const unsilence = opts.flags["unsilence"] === true;
|
|
12
|
+
|
|
13
|
+
const client = new OrchestratorClient(opts.port);
|
|
14
|
+
|
|
15
|
+
if (!(await client.isRunning())) {
|
|
16
|
+
logError(
|
|
17
|
+
"Orchestrator daemon is not running. Start it with: orchestrator start",
|
|
18
|
+
);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (names.length === 0) {
|
|
23
|
+
// Show currently silenced processes
|
|
24
|
+
const silenced = await client.getSilenced();
|
|
25
|
+
if (silenced.length === 0) {
|
|
26
|
+
logInfo("No processes are silenced.");
|
|
27
|
+
} else {
|
|
28
|
+
logInfo(`Silenced processes: ${silenced.join(", ")}`);
|
|
29
|
+
}
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const result = await client.silence(names, unsilence);
|
|
34
|
+
|
|
35
|
+
if (result.error) {
|
|
36
|
+
logError(result.error);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (unsilence) {
|
|
41
|
+
logInfo(`Unsilenced: ${names.join(", ")}`);
|
|
42
|
+
} else {
|
|
43
|
+
logInfo(`Silenced: ${names.join(", ")}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (result.silenced && result.silenced.length > 0) {
|
|
47
|
+
logInfo(`Currently silenced: ${result.silenced.join(", ")}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import type { ParsedArgs } from "../cli.ts";
|
|
4
|
+
import { OrchestratorClient } from "../api-client.ts";
|
|
5
|
+
import { ProcessManager } from "../process-manager.ts";
|
|
6
|
+
import { runProcesses } from "../task-runner.ts";
|
|
7
|
+
import { startApiServer } from "../api-server.ts";
|
|
8
|
+
import {
|
|
9
|
+
writeState,
|
|
10
|
+
readStateConfigPath,
|
|
11
|
+
clearStatePort,
|
|
12
|
+
freePort,
|
|
13
|
+
DEFAULT_API_PORT,
|
|
14
|
+
} from "../port-check.ts";
|
|
15
|
+
import {
|
|
16
|
+
logTaskStart,
|
|
17
|
+
logTaskRunning,
|
|
18
|
+
logTaskDone,
|
|
19
|
+
logTaskFailed,
|
|
20
|
+
logInfo,
|
|
21
|
+
logWarn,
|
|
22
|
+
logError,
|
|
23
|
+
} from "../display.ts";
|
|
24
|
+
import { loadConfig, findDefaultConfig, findPackageJsonConfig } from "../load-config.ts";
|
|
25
|
+
|
|
26
|
+
type StartOptions = Pick<ParsedArgs, "positionals" | "flags"> & {
|
|
27
|
+
port?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Resolve and load the orchestrator config file. */
|
|
31
|
+
async function resolveConfig(opts: StartOptions) {
|
|
32
|
+
// Only use positional[0] as config path if it looks like a file path
|
|
33
|
+
const positionalConfig = opts.positionals[0]?.includes(".") || opts.positionals[0]?.includes("/")
|
|
34
|
+
? opts.positionals[0]
|
|
35
|
+
: undefined;
|
|
36
|
+
|
|
37
|
+
const configPath =
|
|
38
|
+
(opts.flags["config"] as string | undefined) ??
|
|
39
|
+
positionalConfig ??
|
|
40
|
+
readStateConfigPath() ??
|
|
41
|
+
(await findPackageJsonConfig()) ??
|
|
42
|
+
(await findDefaultConfig()) ??
|
|
43
|
+
(() => {
|
|
44
|
+
throw new Error(
|
|
45
|
+
"(1) No config file found. Pass one as an argument or create orchestrator.config.ts.",
|
|
46
|
+
);
|
|
47
|
+
})();
|
|
48
|
+
|
|
49
|
+
logInfo(`Loading config: ${configPath}`);
|
|
50
|
+
return { configPath, config: await loadConfig(configPath) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Parse --only and --except flags into arrays. */
|
|
54
|
+
function parseFilters(flags: Record<string, string | boolean>) {
|
|
55
|
+
const only =
|
|
56
|
+
typeof flags["only"] === "string"
|
|
57
|
+
? flags["only"].split(",").map((s) => s.trim()).filter(Boolean)
|
|
58
|
+
: undefined;
|
|
59
|
+
|
|
60
|
+
const except =
|
|
61
|
+
typeof flags["except"] === "string"
|
|
62
|
+
? flags["except"].split(",").map((s) => s.trim()).filter(Boolean)
|
|
63
|
+
: undefined;
|
|
64
|
+
|
|
65
|
+
return { only, except };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function runStartCommand(opts: StartOptions): Promise<void> {
|
|
69
|
+
// ── Background mode ────────────────────────────────────────────────────────
|
|
70
|
+
if (opts.flags["background"] === true) {
|
|
71
|
+
await spawnBackground(opts);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Resolve config ─────────────────────────────────────────────────────────
|
|
76
|
+
const { configPath, config } = await resolveConfig(opts);
|
|
77
|
+
let { only, except } = parseFilters(opts.flags);
|
|
78
|
+
|
|
79
|
+
// Treat positional args as process names (shorthand for --only)
|
|
80
|
+
// e.g. `start hardhat` is the same as `start --only=hardhat`
|
|
81
|
+
if (!only && opts.positionals.length > 0) {
|
|
82
|
+
const processNames = new Set(config.processes.map((p) => p.name));
|
|
83
|
+
const names = opts.positionals.filter((p) => processNames.has(p));
|
|
84
|
+
if (names.length > 0) {
|
|
85
|
+
only = names;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── API port ───────────────────────────────────────────────────────────────
|
|
90
|
+
const apiPort =
|
|
91
|
+
opts.port ??
|
|
92
|
+
(typeof opts.flags["port"] === "string" ? Number(opts.flags["port"]) : undefined) ??
|
|
93
|
+
config.apiPort ??
|
|
94
|
+
DEFAULT_API_PORT;
|
|
95
|
+
|
|
96
|
+
const noApi = opts.flags["no-api"] === true;
|
|
97
|
+
const serial = opts.flags["serial"] === true;
|
|
98
|
+
const noDeps = opts.flags["no-deps"] === true;
|
|
99
|
+
const silenceFlag =
|
|
100
|
+
typeof opts.flags["silence"] === "string"
|
|
101
|
+
? opts.flags["silence"].split(",").map((s) => s.trim()).filter(Boolean)
|
|
102
|
+
: undefined;
|
|
103
|
+
|
|
104
|
+
// ── Check for existing daemon ──────────────────────────────────────────────
|
|
105
|
+
// If an orchestrator daemon is already running, delegate to it via API.
|
|
106
|
+
if (!noApi) {
|
|
107
|
+
const client = new OrchestratorClient(apiPort);
|
|
108
|
+
if (await client.isRunning()) {
|
|
109
|
+
logInfo(`Orchestrator daemon already running on port ${apiPort} — delegating via API`);
|
|
110
|
+
|
|
111
|
+
if (only) logInfo(`Running only: ${only.join(", ")}`);
|
|
112
|
+
if (except) logInfo(`Skipping: ${except.join(", ")}`);
|
|
113
|
+
|
|
114
|
+
const result = await client.start(config.processes, { only, except, serial, noDeps });
|
|
115
|
+
if (result.success) {
|
|
116
|
+
logInfo(`Queued processes: ${result.queued?.join(", ") ?? "none"}`);
|
|
117
|
+
} else {
|
|
118
|
+
logError(`Failed to start via API: ${result.error}`);
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Foreground mode hint ───────────────────────────────────────────────────
|
|
125
|
+
// Skip when this is a re-spawned child from --background (has --log-dir)
|
|
126
|
+
if (!opts.flags["log-dir"]) {
|
|
127
|
+
const col = { reset: "\x1b[0m", dim: "\x1b[2m", cyan: "\x1b[36m" };
|
|
128
|
+
console.log(
|
|
129
|
+
`\n${col.dim}Tip: Run with ${col.cyan}--background${col.dim} to daemonize and view status/logs separately.${col.reset}\n`
|
|
130
|
+
);
|
|
131
|
+
await Bun.sleep(250);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Per-process log directory (set by --background re-spawn) ──────────────
|
|
135
|
+
const logDirFlag = opts.flags["log-dir"] as string | undefined;
|
|
136
|
+
if (logDirFlag) fs.mkdirSync(logDirFlag, { recursive: true });
|
|
137
|
+
|
|
138
|
+
// ── Process manager ────────────────────────────────────────────────────────
|
|
139
|
+
const manager = new ProcessManager();
|
|
140
|
+
if (logDirFlag) manager.logDir = logDirFlag;
|
|
141
|
+
if (silenceFlag) {
|
|
142
|
+
for (const name of silenceFlag) manager.silence(name);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Task event callbacks (shared between local runs and API-triggered runs)
|
|
146
|
+
const runCallbacks = {
|
|
147
|
+
onTaskStart: (name: string, logFile: string | null) => logTaskStart(name, logFile ?? undefined),
|
|
148
|
+
onTaskRunning: logTaskRunning,
|
|
149
|
+
onTaskDone: logTaskDone,
|
|
150
|
+
onTaskFailed: logTaskFailed,
|
|
151
|
+
onShutdown: undefined as ((reason: string) => void) | undefined,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// ── Graceful shutdown ──────────────────────────────────────────────────────
|
|
155
|
+
let shuttingDown = false;
|
|
156
|
+
let apiServer: ReturnType<typeof startApiServer> | undefined;
|
|
157
|
+
|
|
158
|
+
const shutdown = async (reason?: string) => {
|
|
159
|
+
if (shuttingDown) return;
|
|
160
|
+
shuttingDown = true;
|
|
161
|
+
if (reason) logWarn(`Shutting down: ${reason}`);
|
|
162
|
+
else logInfo("Shutting down…");
|
|
163
|
+
apiServer?.stop(true);
|
|
164
|
+
await manager.stopAll();
|
|
165
|
+
|
|
166
|
+
// Kill any orphan processes still occupying configured ports
|
|
167
|
+
for (const proc of config.processes) {
|
|
168
|
+
for (const port of proc.stopProcessAtPort ?? []) {
|
|
169
|
+
await freePort(port);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
clearStatePort();
|
|
174
|
+
process.exit(reason ? 1 : 0);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
runCallbacks.onShutdown = shutdown;
|
|
178
|
+
|
|
179
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
180
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
181
|
+
|
|
182
|
+
// ── API server ─────────────────────────────────────────────────────────────
|
|
183
|
+
if (!noApi) {
|
|
184
|
+
try {
|
|
185
|
+
apiServer = startApiServer({
|
|
186
|
+
port: apiPort,
|
|
187
|
+
manager,
|
|
188
|
+
onShutdown: () => shutdown("shutdown API request"),
|
|
189
|
+
runCallbacks,
|
|
190
|
+
});
|
|
191
|
+
writeState(apiPort, path.resolve(configPath));
|
|
192
|
+
logInfo(`API server listening on http://localhost:${apiPort}`);
|
|
193
|
+
} catch (err: any) {
|
|
194
|
+
logWarn(`Could not start API server on port ${apiPort}: ${err.message}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Launch ─────────────────────────────────────────────────────────────────
|
|
199
|
+
if (only) logInfo(`Running only: ${only.join(", ")}`);
|
|
200
|
+
if (except) logInfo(`Skipping: ${except.join(", ")}`);
|
|
201
|
+
if (serial) logInfo("Serial mode: launching processes one at a time");
|
|
202
|
+
if (noDeps) logInfo("No-deps mode: skipping dependency resolution");
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
await runProcesses(config.processes, manager, {
|
|
206
|
+
only,
|
|
207
|
+
except,
|
|
208
|
+
serial,
|
|
209
|
+
noDeps,
|
|
210
|
+
...runCallbacks,
|
|
211
|
+
});
|
|
212
|
+
} catch (err: any) {
|
|
213
|
+
logError(err.message);
|
|
214
|
+
await shutdown(err.message);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// If no background processes remain (all were waitToExit) and no API server,
|
|
219
|
+
// exit cleanly instead of hanging forever.
|
|
220
|
+
const hasBackground = manager.getAll().some(p => p.status === "running");
|
|
221
|
+
if (!hasBackground && noApi) {
|
|
222
|
+
logInfo("All processes completed. Exiting.");
|
|
223
|
+
process.exit(0);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
logInfo("All processes launched. Orchestrator is running. Press Ctrl+C to stop.");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Background helper ─────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
async function spawnBackground(opts: StartOptions): Promise<void> {
|
|
232
|
+
const apiPort =
|
|
233
|
+
opts.port ??
|
|
234
|
+
(typeof opts.flags["port"] === "string" ? Number(opts.flags["port"]) : undefined) ??
|
|
235
|
+
DEFAULT_API_PORT;
|
|
236
|
+
|
|
237
|
+
const client = new OrchestratorClient(apiPort);
|
|
238
|
+
if (await client.isRunning()) {
|
|
239
|
+
const col = { reset: "\x1b[0m", bold: "\x1b[1m", red: "\x1b[31m", dim: "\x1b[2m", cyan: "\x1b[36m" };
|
|
240
|
+
const relCliPath = path.relative(process.cwd(), path.resolve(import.meta.dir, "../cli.ts")) || ".";
|
|
241
|
+
console.error(`\n${col.red}${col.bold}An orchestrator daemon is already running on port ${apiPort}.${col.reset}\n`);
|
|
242
|
+
console.error(`${col.dim}Check status:${col.reset} bun ${relCliPath} status`);
|
|
243
|
+
console.error(`${col.dim}View logs:${col.reset} bun ${relCliPath} logs`);
|
|
244
|
+
console.error(`${col.dim}Stop it:${col.reset} bun ${relCliPath} stop`);
|
|
245
|
+
console.error();
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const configPath =
|
|
250
|
+
(opts.flags["config"] as string | undefined) ??
|
|
251
|
+
opts.positionals[0] ??
|
|
252
|
+
readStateConfigPath() ??
|
|
253
|
+
(await findPackageJsonConfig()) ??
|
|
254
|
+
(await findDefaultConfig()) ??
|
|
255
|
+
(() => {
|
|
256
|
+
throw new Error(
|
|
257
|
+
"(2) No config file found. Pass one as an argument or create orchestrator.config.ts.",
|
|
258
|
+
);
|
|
259
|
+
})();
|
|
260
|
+
|
|
261
|
+
// Default log dir: <cwd>/.orchestrator-logs
|
|
262
|
+
const logDir = path.resolve(
|
|
263
|
+
(opts.flags["log-dir"] as string | undefined) ??
|
|
264
|
+
path.join(process.cwd(), ".orchestrator-logs"),
|
|
265
|
+
);
|
|
266
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
267
|
+
|
|
268
|
+
const orchLogPath = path.join(logDir, "orchestrator.log");
|
|
269
|
+
|
|
270
|
+
// Build daemon args: same start command without --background, plus --log-dir
|
|
271
|
+
const daemonArgs: string[] = ["start", configPath, `--log-dir=${logDir}`];
|
|
272
|
+
if (opts.flags["only"]) daemonArgs.push(`--only=${opts.flags["only"]}`);
|
|
273
|
+
if (opts.flags["except"]) daemonArgs.push(`--except=${opts.flags["except"]}`);
|
|
274
|
+
if (opts.flags["port"]) daemonArgs.push(`--port=${opts.flags["port"]}`);
|
|
275
|
+
if (opts.flags["no-api"]) daemonArgs.push("--no-api");
|
|
276
|
+
if (opts.flags["serial"]) daemonArgs.push("--serial");
|
|
277
|
+
if (opts.flags["no-deps"]) daemonArgs.push("--no-deps");
|
|
278
|
+
|
|
279
|
+
// Open orchestrator log in write mode (fresh on each background start)
|
|
280
|
+
const orchFd = fs.openSync(orchLogPath, "w");
|
|
281
|
+
|
|
282
|
+
const cliPath = path.resolve(import.meta.dir, "../cli.ts");
|
|
283
|
+
const daemon = Bun.spawn(["bun", cliPath, ...daemonArgs], {
|
|
284
|
+
stdout: orchFd,
|
|
285
|
+
stderr: orchFd,
|
|
286
|
+
stdin: "ignore",
|
|
287
|
+
detached: true,
|
|
288
|
+
});
|
|
289
|
+
fs.closeSync(orchFd);
|
|
290
|
+
daemon.unref();
|
|
291
|
+
|
|
292
|
+
// Print summary with relative paths for easy copy-paste
|
|
293
|
+
const col = { reset: "\x1b[0m", bold: "\x1b[1m", cyan: "\x1b[36m", green: "\x1b[32m", dim: "\x1b[2m" };
|
|
294
|
+
const relLogDir = path.relative(process.cwd(), logDir) || ".";
|
|
295
|
+
const relOrchLog = path.relative(process.cwd(), orchLogPath) || ".";
|
|
296
|
+
const relCliPath = path.relative(process.cwd(), cliPath) || ".";
|
|
297
|
+
console.log(`\n${col.bold}Orchestrator started in background${col.reset} (PID ${daemon.pid})\n`);
|
|
298
|
+
console.log(`${col.bold}Log directory:${col.reset} ${col.cyan}${relLogDir}${col.reset}`);
|
|
299
|
+
console.log(` ${col.dim}orchestrator:${col.reset} ${relOrchLog}`);
|
|
300
|
+
console.log(` ${col.dim}processes:${col.reset} ${relLogDir}/<process-name>.log\n`);
|
|
301
|
+
console.log(`${col.dim}Tail all logs:${col.reset} tail -f ${relLogDir}/*.log`);
|
|
302
|
+
console.log(`${col.dim}Check status:${col.reset} bun ${relCliPath} status`);
|
|
303
|
+
console.log(`${col.dim}Stop all:${col.reset} bun ${relCliPath} stop\n`);
|
|
304
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ParsedArgs } from "../cli.ts";
|
|
2
|
+
import { OrchestratorClient, type RemoteProcess } from "../api-client.ts";
|
|
3
|
+
import { printStatusTable, type StatusRow, logError, logWarn } from "../display.ts";
|
|
4
|
+
import { findDefaultConfig, findPackageJsonConfig, loadConfig } from "../load-config.ts";
|
|
5
|
+
import { isPortInUse, pidsByPort, readStateConfigPath } from "../port-check.ts";
|
|
6
|
+
import type { ProcessConfig } from "../config.ts";
|
|
7
|
+
|
|
8
|
+
type StatusOptions = Pick<ParsedArgs, "positionals" | "flags"> & {
|
|
9
|
+
port?: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
async function fetchStatus(
|
|
13
|
+
opts: StatusOptions,
|
|
14
|
+
configs: ProcessConfig[],
|
|
15
|
+
): Promise<{ rows: StatusRow[]; daemonRunning: boolean }> {
|
|
16
|
+
const client = new OrchestratorClient(opts.port);
|
|
17
|
+
const daemonRunning = await client.isRunning();
|
|
18
|
+
const daemonProcesses = daemonRunning ? await client.getProcesses() : [];
|
|
19
|
+
const daemonMap = new Map(daemonProcesses.map((p) => [p.name, p]));
|
|
20
|
+
|
|
21
|
+
const untracked = configs.filter((c) => !daemonMap.has(c.name));
|
|
22
|
+
const portResults = await Promise.all(
|
|
23
|
+
untracked.map(async (c) => {
|
|
24
|
+
const ports = c.stopProcessAtPort ?? [];
|
|
25
|
+
if (ports.length === 0) return { name: c.name, portStatus: "no-ports" as const, pid: undefined };
|
|
26
|
+
const results = await Promise.all(ports.map((p) => isPortInUse(p)));
|
|
27
|
+
const allUp = results.every(Boolean);
|
|
28
|
+
const someUp = results.some(Boolean);
|
|
29
|
+
const portStatus = allUp ? "up" : someUp ? "partial" : ("down" as const);
|
|
30
|
+
const firstUpIdx = results.findIndex(Boolean);
|
|
31
|
+
const pid = firstUpIdx >= 0 ? (pidsByPort(ports[firstUpIdx])[0] ?? undefined) : undefined;
|
|
32
|
+
return { name: c.name, portStatus, pid };
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
const portMap = new Map(portResults.map((r) => [r.name, r]));
|
|
36
|
+
|
|
37
|
+
const daemonPidMap = new Map<string, number | undefined>();
|
|
38
|
+
await Promise.all(
|
|
39
|
+
daemonProcesses.map(async (p) => {
|
|
40
|
+
const cfg = configs.find((c) => c.name === p.name);
|
|
41
|
+
const ports = cfg?.stopProcessAtPort ?? [];
|
|
42
|
+
if (ports.length === 0) { daemonPidMap.set(p.name, undefined); return; }
|
|
43
|
+
const up = await Promise.all(ports.map((port) => isPortInUse(port)));
|
|
44
|
+
const firstUpIdx = up.findIndex(Boolean);
|
|
45
|
+
const actualPid = firstUpIdx >= 0 ? (pidsByPort(ports[firstUpIdx])[0] ?? undefined) : undefined;
|
|
46
|
+
daemonPidMap.set(p.name, actualPid);
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const rows: StatusRow[] = configs.map((c) => {
|
|
51
|
+
const daemon = daemonMap.get(c.name);
|
|
52
|
+
if (daemon) return { source: "daemon" as const, config: c, daemon, actualPid: daemonPidMap.get(c.name) };
|
|
53
|
+
const { portStatus, pid } = portMap.get(c.name)!;
|
|
54
|
+
return { source: "port" as const, config: c, portStatus, pid };
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return { rows, daemonRunning };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function runStatusCommand(opts: StatusOptions): Promise<void> {
|
|
61
|
+
const configPath =
|
|
62
|
+
(opts.flags["config"] as string | undefined) ??
|
|
63
|
+
opts.positionals[0] ??
|
|
64
|
+
readStateConfigPath() ??
|
|
65
|
+
(await findPackageJsonConfig()) ??
|
|
66
|
+
(await findDefaultConfig());
|
|
67
|
+
|
|
68
|
+
if (!configPath) {
|
|
69
|
+
logError(
|
|
70
|
+
"No config file found. Pass one with --config or create orchestrator.config.ts",
|
|
71
|
+
);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let configs: ProcessConfig[];
|
|
76
|
+
try {
|
|
77
|
+
const config = await loadConfig(configPath);
|
|
78
|
+
configs = config.processes;
|
|
79
|
+
} catch (err: any) {
|
|
80
|
+
logError(err.message);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const follow = opts.flags["follow"] === true;
|
|
85
|
+
|
|
86
|
+
if (!follow) {
|
|
87
|
+
const { rows, daemonRunning } = await fetchStatus(opts, configs);
|
|
88
|
+
if (!daemonRunning) {
|
|
89
|
+
logWarn("No orchestrator daemon detected — showing port-based status.");
|
|
90
|
+
}
|
|
91
|
+
printStatusTable(rows, daemonRunning);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Follow mode: refresh every second
|
|
96
|
+
process.on("SIGINT", () => process.exit(0));
|
|
97
|
+
process.on("SIGTERM", () => process.exit(0));
|
|
98
|
+
|
|
99
|
+
let firstRender = true;
|
|
100
|
+
while (true) {
|
|
101
|
+
process.stdout.write("\x1b[H");
|
|
102
|
+
const { rows, daemonRunning } = await fetchStatus(opts, configs);
|
|
103
|
+
if (!daemonRunning && firstRender) {
|
|
104
|
+
logWarn("No orchestrator daemon detected — showing port-based status.");
|
|
105
|
+
}
|
|
106
|
+
printStatusTable(rows, daemonRunning);
|
|
107
|
+
process.stdout.write("\x1b[J");
|
|
108
|
+
firstRender = false;
|
|
109
|
+
await Bun.sleep(1000);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ParsedArgs } from "../cli.ts";
|
|
2
|
+
import type { ProcessConfig } from "../config.ts";
|
|
3
|
+
import { OrchestratorClient } from "../api-client.ts";
|
|
4
|
+
import { loadConfig, findDefaultConfig, findPackageJsonConfig } from "../load-config.ts";
|
|
5
|
+
import { pidsByPort, freePort, readStateConfigPath } from "../port-check.ts";
|
|
6
|
+
import { logInfo, logError, logWarn } from "../display.ts";
|
|
7
|
+
|
|
8
|
+
type StopOptions = Pick<ParsedArgs, "positionals" | "flags"> & {
|
|
9
|
+
port?: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function runStopCommand(opts: StopOptions): Promise<void> {
|
|
13
|
+
const name = opts.positionals[0]; // optional
|
|
14
|
+
|
|
15
|
+
const client = new OrchestratorClient(opts.port);
|
|
16
|
+
|
|
17
|
+
// If the daemon is running, delegate to it
|
|
18
|
+
if (await client.isRunning()) {
|
|
19
|
+
if (name) {
|
|
20
|
+
logInfo(`Stopping "${name}"…`);
|
|
21
|
+
const result = await client.stop(name);
|
|
22
|
+
if (result.error) {
|
|
23
|
+
logError(result.error);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
logInfo(`"${name}" stopped.`);
|
|
27
|
+
} else {
|
|
28
|
+
logWarn("Stopping all processes and shutting down the orchestrator…");
|
|
29
|
+
await client.shutdown();
|
|
30
|
+
logInfo("Shutdown signal sent.");
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// No daemon — fall back to port-based kill
|
|
36
|
+
logWarn("No orchestrator daemon detected — killing processes by port.");
|
|
37
|
+
|
|
38
|
+
const configPath =
|
|
39
|
+
(opts.flags["config"] as string | undefined) ??
|
|
40
|
+
opts.positionals[1] ??
|
|
41
|
+
readStateConfigPath() ??
|
|
42
|
+
(await findPackageJsonConfig()) ??
|
|
43
|
+
(await findDefaultConfig());
|
|
44
|
+
|
|
45
|
+
if (!configPath) {
|
|
46
|
+
logError("No config file found. Cannot determine which ports to free.");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let configs: ProcessConfig[];
|
|
51
|
+
try {
|
|
52
|
+
const config = await loadConfig(configPath);
|
|
53
|
+
configs = config.processes;
|
|
54
|
+
} catch (err: any) {
|
|
55
|
+
logError(err.message);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// If a name is given, only kill that process's ports
|
|
60
|
+
if (name) {
|
|
61
|
+
const proc = configs.find((c) => c.name === name);
|
|
62
|
+
if (!proc) {
|
|
63
|
+
logError(`Process "${name}" not found in config.`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
const ports = proc.stopProcessAtPort ?? [];
|
|
67
|
+
if (ports.length === 0) {
|
|
68
|
+
logWarn(`Process "${name}" has no ports defined — nothing to kill.`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
for (const port of ports) {
|
|
72
|
+
const pids = pidsByPort(port);
|
|
73
|
+
if (pids.length > 0) {
|
|
74
|
+
logInfo(`Killing PID ${pids.join(", ")} on port ${port} (${name})`);
|
|
75
|
+
await freePort(port);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
logInfo(`"${name}" stopped.`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Kill all processes that have ports defined
|
|
83
|
+
let killed = 0;
|
|
84
|
+
for (const proc of configs) {
|
|
85
|
+
for (const port of proc.stopProcessAtPort ?? []) {
|
|
86
|
+
const pids = pidsByPort(port);
|
|
87
|
+
if (pids.length > 0) {
|
|
88
|
+
logInfo(`Killing PID ${pids.join(", ")} on port ${port} (${proc.name})`);
|
|
89
|
+
await freePort(port);
|
|
90
|
+
killed++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (killed === 0) {
|
|
96
|
+
logInfo("No processes found on any configured ports.");
|
|
97
|
+
} else {
|
|
98
|
+
logInfo(`Stopped ${killed} process(es).`);
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration types for orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* ProcessConfig is intentionally compatible with the v1 ProcessLaunch schema
|
|
5
|
+
* so existing launch-*.ts scripts can be reused with minor changes.
|
|
6
|
+
*/
|
|
7
|
+
export type ProcessConfig = {
|
|
8
|
+
/** Unique name for this process. */
|
|
9
|
+
name: string;
|
|
10
|
+
/** Human-readable description (shown in status output). */
|
|
11
|
+
description?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Executable to run. Defaults to "bun".
|
|
14
|
+
* Use "deno" for legacy deno-task-based processes.
|
|
15
|
+
*/
|
|
16
|
+
command?: string;
|
|
17
|
+
/** Arguments passed to the command. */
|
|
18
|
+
args: string[];
|
|
19
|
+
/** Names of processes that must complete/start before this one. */
|
|
20
|
+
dependsOn?: string[];
|
|
21
|
+
/**
|
|
22
|
+
* If set, these ports are freed (kill any occupying process) before launch.
|
|
23
|
+
* Also used by the `status` command for port-based liveness detection.
|
|
24
|
+
*/
|
|
25
|
+
stopProcessAtPort?: number[];
|
|
26
|
+
/**
|
|
27
|
+
* If true, dependents wait for this process to EXIT before starting.
|
|
28
|
+
* If false (default), dependents start as soon as this process is launched.
|
|
29
|
+
*/
|
|
30
|
+
waitToExit?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* system-dependency: a core service (e.g. a chain node); failure triggers shutdown.
|
|
33
|
+
* secondary: optional service; failure is logged but orchestrator keeps running.
|
|
34
|
+
* Defaults to "system-dependency".
|
|
35
|
+
*/
|
|
36
|
+
type?: "system-dependency" | "secondary";
|
|
37
|
+
/** Extra environment variables for the process. */
|
|
38
|
+
env?: Record<string, string>;
|
|
39
|
+
/** Working directory for the process. Defaults to process.cwd(). */
|
|
40
|
+
cwd?: string;
|
|
41
|
+
/**
|
|
42
|
+
* If true (default), a non-zero exit code triggers a full shutdown.
|
|
43
|
+
* If false, the failure is reported but the orchestrator keeps running.
|
|
44
|
+
*/
|
|
45
|
+
critical?: boolean;
|
|
46
|
+
/** Optional URL shown in status output (e.g. a UI endpoint). */
|
|
47
|
+
link?: string;
|
|
48
|
+
/**
|
|
49
|
+
* If false, this process is skipped during a normal `start` and only runs
|
|
50
|
+
* when explicitly requested via `--only`. Useful for one-off tasks like
|
|
51
|
+
* contract compilation that aren't part of the default startup flow.
|
|
52
|
+
* Defaults to true.
|
|
53
|
+
*/
|
|
54
|
+
autoStart?: boolean;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type OrchestratorConfig = {
|
|
58
|
+
/** Ordered list of processes to manage. */
|
|
59
|
+
processes: ProcessConfig[];
|
|
60
|
+
/**
|
|
61
|
+
* Port for the orchestrator HTTP API used by status/restart/stop commands.
|
|
62
|
+
* Defaults to 4747. Overrideable with --port CLI flag.
|
|
63
|
+
*/
|
|
64
|
+
apiPort?: number;
|
|
65
|
+
};
|