copilot-hub 0.1.7 → 0.1.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/README.md +19 -0
- package/package.json +1 -1
- package/scripts/dist/cli.mjs +126 -6
- package/scripts/dist/daemon.mjs +388 -0
- package/scripts/dist/service.mjs +535 -0
- package/scripts/dist/supervisor.mjs +22 -3
- package/scripts/src/cli.mts +148 -6
- package/scripts/src/daemon.mts +458 -0
- package/scripts/src/service.mts +651 -0
- package/scripts/src/supervisor.mts +25 -3
package/README.md
CHANGED
|
@@ -81,6 +81,7 @@ copilot-hub start
|
|
|
81
81
|
```
|
|
82
82
|
|
|
83
83
|
`start` runs guided setup automatically if required values are missing.
|
|
84
|
+
On interactive terminals, `start` can also offer OS-native service installation when it is not yet configured.
|
|
84
85
|
|
|
85
86
|
## Quick start from source
|
|
86
87
|
|
|
@@ -175,6 +176,24 @@ npm run format:check
|
|
|
175
176
|
npm run check:apps
|
|
176
177
|
```
|
|
177
178
|
|
|
179
|
+
Service mode (optional, OS-native):
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
copilot-hub service install
|
|
183
|
+
copilot-hub service status
|
|
184
|
+
copilot-hub service stop
|
|
185
|
+
copilot-hub service start
|
|
186
|
+
copilot-hub service uninstall
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Service mode runs a persistent daemon that keeps `agent-engine` and `control-plane` alive and auto-restarts them if one exits.
|
|
190
|
+
|
|
191
|
+
Service backend by OS:
|
|
192
|
+
|
|
193
|
+
- Windows: Task Scheduler (`CopilotHub`) with user-startup fallback if task creation is denied
|
|
194
|
+
- Linux: systemd user service (`copilot-hub.service`)
|
|
195
|
+
- macOS: launchd agent (`com.copilot-hub.service`)
|
|
196
|
+
|
|
178
197
|
## npm release (CI)
|
|
179
198
|
|
|
180
199
|
Publishing is automated from GitHub Actions on tags (`v*`).
|
package/package.json
CHANGED
package/scripts/dist/cli.mjs
CHANGED
|
@@ -9,6 +9,8 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
9
9
|
const __dirname = path.dirname(__filename);
|
|
10
10
|
const repoRoot = path.resolve(__dirname, "..", "..");
|
|
11
11
|
const packageJsonPath = path.join(repoRoot, "package.json");
|
|
12
|
+
const runtimeDir = path.join(repoRoot, ".copilot-hub");
|
|
13
|
+
const servicePromptStatePath = path.join(runtimeDir, "service-onboarding.json");
|
|
12
14
|
const nodeBin = process.execPath;
|
|
13
15
|
const agentEngineEnvPath = path.join(repoRoot, "apps", "agent-engine", ".env");
|
|
14
16
|
const controlPlaneEnvPath = path.join(repoRoot, "apps", "control-plane", ".env");
|
|
@@ -39,19 +41,37 @@ async function main() {
|
|
|
39
41
|
runNode(["scripts/dist/configure.mjs", "--required-only"]);
|
|
40
42
|
runNode(["scripts/dist/ensure-shared-build.mjs"]);
|
|
41
43
|
await ensureCodexLogin();
|
|
44
|
+
await maybeOfferServiceInstall();
|
|
45
|
+
if (isServiceAlreadyInstalled()) {
|
|
46
|
+
runNode(["scripts/dist/service.mjs", "start"]);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
42
49
|
runNode(["scripts/dist/supervisor.mjs", "up"]);
|
|
43
50
|
return;
|
|
44
51
|
}
|
|
45
52
|
case "stop": {
|
|
53
|
+
if (isServiceAlreadyInstalled()) {
|
|
54
|
+
runNode(["scripts/dist/service.mjs", "stop"]);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
46
57
|
runNode(["scripts/dist/supervisor.mjs", "down"]);
|
|
47
58
|
return;
|
|
48
59
|
}
|
|
49
60
|
case "restart": {
|
|
50
61
|
runNode(["scripts/dist/ensure-shared-build.mjs"]);
|
|
62
|
+
if (isServiceAlreadyInstalled()) {
|
|
63
|
+
runNode(["scripts/dist/service.mjs", "stop"]);
|
|
64
|
+
runNode(["scripts/dist/service.mjs", "start"]);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
51
67
|
runNode(["scripts/dist/supervisor.mjs", "restart"]);
|
|
52
68
|
return;
|
|
53
69
|
}
|
|
54
70
|
case "status": {
|
|
71
|
+
if (isServiceAlreadyInstalled()) {
|
|
72
|
+
runNode(["scripts/dist/daemon.mjs", "status"]);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
55
75
|
runNode(["scripts/dist/supervisor.mjs", "status"]);
|
|
56
76
|
return;
|
|
57
77
|
}
|
|
@@ -63,6 +83,10 @@ async function main() {
|
|
|
63
83
|
runNode(["scripts/dist/configure.mjs"]);
|
|
64
84
|
return;
|
|
65
85
|
}
|
|
86
|
+
case "service": {
|
|
87
|
+
runNode(["scripts/dist/service.mjs", ...rawArgs.slice(1)]);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
66
90
|
default: {
|
|
67
91
|
printUsage();
|
|
68
92
|
process.exit(1);
|
|
@@ -70,16 +94,33 @@ async function main() {
|
|
|
70
94
|
}
|
|
71
95
|
}
|
|
72
96
|
function runNode(scriptArgs) {
|
|
73
|
-
const result =
|
|
74
|
-
cwd: repoRoot,
|
|
75
|
-
stdio: "inherit",
|
|
76
|
-
shell: false,
|
|
77
|
-
});
|
|
97
|
+
const result = runNodeCapture(scriptArgs, "inherit");
|
|
78
98
|
const code = Number.isInteger(result.status) ? result.status : 1;
|
|
79
99
|
if (code !== 0) {
|
|
80
100
|
process.exit(code);
|
|
81
101
|
}
|
|
82
102
|
}
|
|
103
|
+
function runNodeCapture(scriptArgs, stdioMode = "pipe") {
|
|
104
|
+
const stdio = stdioMode === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"];
|
|
105
|
+
const result = spawnSync(nodeBin, scriptArgs, {
|
|
106
|
+
cwd: repoRoot,
|
|
107
|
+
stdio,
|
|
108
|
+
shell: false,
|
|
109
|
+
encoding: "utf8",
|
|
110
|
+
});
|
|
111
|
+
const stdout = String(result.stdout ?? "").trim();
|
|
112
|
+
const stderr = String(result.stderr ?? "").trim();
|
|
113
|
+
const combinedOutput = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
114
|
+
const ok = !result.error && result.status === 0;
|
|
115
|
+
return {
|
|
116
|
+
ok,
|
|
117
|
+
status: result.status,
|
|
118
|
+
stdout,
|
|
119
|
+
stderr,
|
|
120
|
+
combinedOutput,
|
|
121
|
+
error: result.error,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
83
124
|
async function ensureCodexLogin() {
|
|
84
125
|
const resolved = resolveCodexBinForStart();
|
|
85
126
|
let codexBin = resolved.bin;
|
|
@@ -149,6 +190,81 @@ async function ensureCodexLogin() {
|
|
|
149
190
|
}
|
|
150
191
|
console.log("Codex login configured.");
|
|
151
192
|
}
|
|
193
|
+
async function maybeOfferServiceInstall() {
|
|
194
|
+
if (!process.stdin.isTTY) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (!isServiceSupportedOnCurrentPlatform()) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (isServiceAlreadyInstalled()) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const state = readServicePromptState();
|
|
204
|
+
if (state?.decision === "declined") {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const rl = createInterface({ input, output });
|
|
208
|
+
let shouldInstall = false;
|
|
209
|
+
try {
|
|
210
|
+
shouldInstall = await askYesNo(rl, "Enable OS-native auto-start service now? (recommended for reliability)", false);
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
rl.close();
|
|
214
|
+
}
|
|
215
|
+
if (!shouldInstall) {
|
|
216
|
+
writeServicePromptState("declined");
|
|
217
|
+
console.log("Service setup skipped. You can run 'copilot-hub service install' anytime.");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const install = runNodeCapture(["scripts/dist/service.mjs", "install"], "inherit");
|
|
221
|
+
if (!install.ok) {
|
|
222
|
+
console.log("Service install failed. Continuing in local mode.");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
writeServicePromptState("accepted");
|
|
226
|
+
}
|
|
227
|
+
function isServiceSupportedOnCurrentPlatform() {
|
|
228
|
+
return (process.platform === "win32" || process.platform === "linux" || process.platform === "darwin");
|
|
229
|
+
}
|
|
230
|
+
function isServiceAlreadyInstalled() {
|
|
231
|
+
const status = runNodeCapture(["scripts/dist/service.mjs", "status"], "pipe");
|
|
232
|
+
const message = String(status.combinedOutput ?? "").toLowerCase();
|
|
233
|
+
if (message.includes("service not installed")) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
if (message.includes("not installed")) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
return status.ok;
|
|
240
|
+
}
|
|
241
|
+
function readServicePromptState() {
|
|
242
|
+
if (!fs.existsSync(servicePromptStatePath)) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const raw = fs.readFileSync(servicePromptStatePath, "utf8");
|
|
247
|
+
const parsed = JSON.parse(raw);
|
|
248
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function writeServicePromptState(decision) {
|
|
255
|
+
try {
|
|
256
|
+
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
257
|
+
fs.writeFileSync(servicePromptStatePath, `${JSON.stringify({
|
|
258
|
+
decision: String(decision ?? "")
|
|
259
|
+
.trim()
|
|
260
|
+
.toLowerCase(),
|
|
261
|
+
updatedAt: new Date().toISOString(),
|
|
262
|
+
}, null, 2)}\n`, "utf8");
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// Non-critical state cache only.
|
|
266
|
+
}
|
|
267
|
+
}
|
|
152
268
|
async function recoverCodexBinary({ resolved, status }) {
|
|
153
269
|
const detected = findDetectedCodexBin();
|
|
154
270
|
if (detected && detected !== resolved.bin) {
|
|
@@ -469,7 +585,11 @@ function spawnNpm(args, options) {
|
|
|
469
585
|
});
|
|
470
586
|
}
|
|
471
587
|
function printUsage() {
|
|
472
|
-
console.log(
|
|
588
|
+
console.log([
|
|
589
|
+
"Usage: node scripts/dist/cli.mjs <start|stop|restart|status|logs|configure|service|version|help>",
|
|
590
|
+
"Service management:",
|
|
591
|
+
" node scripts/dist/cli.mjs service <install|uninstall|status|start|stop|help>",
|
|
592
|
+
].join("\n"));
|
|
473
593
|
}
|
|
474
594
|
function readPackageVersion() {
|
|
475
595
|
try {
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
const repoRoot = path.resolve(__dirname, "..", "..");
|
|
10
|
+
const runtimeDir = path.join(repoRoot, ".copilot-hub");
|
|
11
|
+
const pidsDir = path.join(runtimeDir, "pids");
|
|
12
|
+
const logsDir = path.join(repoRoot, "logs");
|
|
13
|
+
const daemonStatePath = path.join(pidsDir, "daemon.json");
|
|
14
|
+
const daemonLogPath = path.join(logsDir, "service-daemon.log");
|
|
15
|
+
const daemonScriptPath = path.join(repoRoot, "scripts", "dist", "daemon.mjs");
|
|
16
|
+
const supervisorScriptPath = path.join(repoRoot, "scripts", "dist", "supervisor.mjs");
|
|
17
|
+
const nodeBin = process.execPath;
|
|
18
|
+
const BASE_CHECK_MS = 5000;
|
|
19
|
+
const MAX_BACKOFF_MS = 60000;
|
|
20
|
+
const action = String(process.argv[2] ?? "status")
|
|
21
|
+
.trim()
|
|
22
|
+
.toLowerCase();
|
|
23
|
+
try {
|
|
24
|
+
await main();
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
console.error(getErrorMessage(error));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
async function main() {
|
|
31
|
+
switch (action) {
|
|
32
|
+
case "start":
|
|
33
|
+
await startDaemonProcess();
|
|
34
|
+
return;
|
|
35
|
+
case "run":
|
|
36
|
+
await runDaemonLoop();
|
|
37
|
+
return;
|
|
38
|
+
case "stop":
|
|
39
|
+
await stopDaemonProcess();
|
|
40
|
+
return;
|
|
41
|
+
case "status":
|
|
42
|
+
showDaemonStatus();
|
|
43
|
+
return;
|
|
44
|
+
case "help":
|
|
45
|
+
printUsage();
|
|
46
|
+
return;
|
|
47
|
+
default:
|
|
48
|
+
printUsage();
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function startDaemonProcess() {
|
|
53
|
+
ensureScripts();
|
|
54
|
+
ensureRuntimeDirs();
|
|
55
|
+
const existingPid = getRunningDaemonPid();
|
|
56
|
+
if (existingPid > 0) {
|
|
57
|
+
console.log(`[daemon] already running (pid ${existingPid})`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
removeDaemonState();
|
|
61
|
+
const logFd = fs.openSync(daemonLogPath, "a");
|
|
62
|
+
let child;
|
|
63
|
+
try {
|
|
64
|
+
child = spawn(nodeBin, [daemonScriptPath, "run"], {
|
|
65
|
+
cwd: repoRoot,
|
|
66
|
+
detached: true,
|
|
67
|
+
stdio: ["ignore", logFd, logFd],
|
|
68
|
+
windowsHide: true,
|
|
69
|
+
shell: false,
|
|
70
|
+
env: process.env,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
fs.closeSync(logFd);
|
|
75
|
+
}
|
|
76
|
+
const pid = normalizePid(child?.pid);
|
|
77
|
+
if (pid <= 0) {
|
|
78
|
+
throw new Error("Failed to spawn daemon process.");
|
|
79
|
+
}
|
|
80
|
+
child.unref();
|
|
81
|
+
const ready = await waitForExit(pid, 250, false);
|
|
82
|
+
if (ready) {
|
|
83
|
+
throw new Error(`Daemon process exited immediately (pid ${pid}). Check logs: ${daemonLogPath}`);
|
|
84
|
+
}
|
|
85
|
+
console.log(`[daemon] started (pid ${pid})`);
|
|
86
|
+
}
|
|
87
|
+
async function runDaemonLoop() {
|
|
88
|
+
ensureScripts();
|
|
89
|
+
ensureRuntimeDirs();
|
|
90
|
+
const existingPid = getRunningDaemonPid();
|
|
91
|
+
if (existingPid > 0 && existingPid !== process.pid) {
|
|
92
|
+
console.log(`[daemon] already running (pid ${existingPid})`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
writeDaemonState({
|
|
96
|
+
pid: process.pid,
|
|
97
|
+
startedAt: new Date().toISOString(),
|
|
98
|
+
command: `${nodeBin} ${daemonScriptPath} run`,
|
|
99
|
+
});
|
|
100
|
+
const state = { stopping: false, shuttingDown: false };
|
|
101
|
+
setupSignalHandlers(state);
|
|
102
|
+
console.log(`[daemon] running (pid ${process.pid})`);
|
|
103
|
+
let failureCount = 0;
|
|
104
|
+
while (!state.stopping) {
|
|
105
|
+
const ensureResult = runSupervisor("ensure", { allowFailure: true });
|
|
106
|
+
if (ensureResult.ok) {
|
|
107
|
+
if (failureCount > 0) {
|
|
108
|
+
console.log("[daemon] workers recovered.");
|
|
109
|
+
}
|
|
110
|
+
failureCount = 0;
|
|
111
|
+
await sleepInterruptible(BASE_CHECK_MS, () => state.stopping);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
failureCount += 1;
|
|
115
|
+
const delay = computeBackoffDelay(failureCount);
|
|
116
|
+
const reason = firstLine(ensureResult.combinedOutput) ||
|
|
117
|
+
`supervisor ensure exited with code ${String(ensureResult.status ?? "unknown")}`;
|
|
118
|
+
console.error(`[daemon] worker health check failed: ${reason}. Retrying in ${Math.ceil(delay / 1000)}s.`);
|
|
119
|
+
await sleepInterruptible(delay, () => state.stopping);
|
|
120
|
+
}
|
|
121
|
+
await shutdownDaemon(state, { reason: "stop-request", exitCode: 0 });
|
|
122
|
+
}
|
|
123
|
+
async function stopDaemonProcess() {
|
|
124
|
+
ensureRuntimeDirs();
|
|
125
|
+
const pid = getRunningDaemonPid();
|
|
126
|
+
if (pid <= 0) {
|
|
127
|
+
removeDaemonState();
|
|
128
|
+
runSupervisor("down", { allowFailure: true });
|
|
129
|
+
console.log("[daemon] not running.");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
await terminateProcess(pid);
|
|
133
|
+
if (isProcessRunning(pid)) {
|
|
134
|
+
throw new Error(`Daemon did not stop cleanly (pid ${pid}).`);
|
|
135
|
+
}
|
|
136
|
+
removeDaemonState();
|
|
137
|
+
runSupervisor("down", { allowFailure: true });
|
|
138
|
+
console.log("[daemon] stopped.");
|
|
139
|
+
}
|
|
140
|
+
function showDaemonStatus() {
|
|
141
|
+
ensureRuntimeDirs();
|
|
142
|
+
const pid = getRunningDaemonPid();
|
|
143
|
+
const running = pid > 0;
|
|
144
|
+
if (!running) {
|
|
145
|
+
removeDaemonState();
|
|
146
|
+
}
|
|
147
|
+
console.log("\n=== daemon ===");
|
|
148
|
+
console.log(`running: ${running ? "yes" : "no"}`);
|
|
149
|
+
console.log(`pid: ${running ? String(pid) : "-"}`);
|
|
150
|
+
console.log(`logFile: ${daemonLogPath}`);
|
|
151
|
+
if (!fs.existsSync(supervisorScriptPath)) {
|
|
152
|
+
console.log("\n(worker status unavailable: supervisor script missing)");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
console.log("\n=== workers ===");
|
|
156
|
+
runSupervisor("status", { allowFailure: true, stdio: "inherit" });
|
|
157
|
+
}
|
|
158
|
+
function setupSignalHandlers(state) {
|
|
159
|
+
const requestStop = () => {
|
|
160
|
+
state.stopping = true;
|
|
161
|
+
};
|
|
162
|
+
process.on("SIGINT", requestStop);
|
|
163
|
+
process.on("SIGTERM", requestStop);
|
|
164
|
+
process.on("SIGHUP", requestStop);
|
|
165
|
+
process.on("uncaughtException", (error) => {
|
|
166
|
+
if (!state.shuttingDown) {
|
|
167
|
+
console.error(`[daemon] uncaught exception: ${getErrorMessage(error)}`);
|
|
168
|
+
}
|
|
169
|
+
state.stopping = true;
|
|
170
|
+
void shutdownDaemon(state, { reason: "uncaught-exception", exitCode: 1 });
|
|
171
|
+
});
|
|
172
|
+
process.on("unhandledRejection", (reason) => {
|
|
173
|
+
if (!state.shuttingDown) {
|
|
174
|
+
console.error(`[daemon] unhandled rejection: ${getErrorMessage(reason)}`);
|
|
175
|
+
}
|
|
176
|
+
state.stopping = true;
|
|
177
|
+
void shutdownDaemon(state, { reason: "unhandled-rejection", exitCode: 1 });
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
async function shutdownDaemon(state, { reason, exitCode }) {
|
|
181
|
+
if (state.shuttingDown) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
state.shuttingDown = true;
|
|
185
|
+
console.log(`[daemon] stopping (${reason})...`);
|
|
186
|
+
runSupervisor("down", { allowFailure: true });
|
|
187
|
+
removeDaemonState();
|
|
188
|
+
process.exit(exitCode);
|
|
189
|
+
}
|
|
190
|
+
function ensureScripts() {
|
|
191
|
+
if (!fs.existsSync(supervisorScriptPath)) {
|
|
192
|
+
throw new Error([
|
|
193
|
+
"Supervisor script is missing.",
|
|
194
|
+
"Run 'npm run build:scripts' (or reinstall package) and retry.",
|
|
195
|
+
].join("\n"));
|
|
196
|
+
}
|
|
197
|
+
if (!fs.existsSync(daemonScriptPath)) {
|
|
198
|
+
throw new Error([
|
|
199
|
+
"Daemon script is missing.",
|
|
200
|
+
"Run 'npm run build:scripts' (or reinstall package) and retry.",
|
|
201
|
+
].join("\n"));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function ensureRuntimeDirs() {
|
|
205
|
+
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
206
|
+
fs.mkdirSync(pidsDir, { recursive: true });
|
|
207
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
208
|
+
}
|
|
209
|
+
function getRunningDaemonPid() {
|
|
210
|
+
const state = readDaemonState();
|
|
211
|
+
const pid = normalizePid(state?.pid);
|
|
212
|
+
if (pid <= 0) {
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
return isProcessRunning(pid) ? pid : 0;
|
|
216
|
+
}
|
|
217
|
+
function readDaemonState() {
|
|
218
|
+
if (!fs.existsSync(daemonStatePath)) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
return JSON.parse(fs.readFileSync(daemonStatePath, "utf8"));
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function writeDaemonState(value) {
|
|
229
|
+
fs.mkdirSync(path.dirname(daemonStatePath), { recursive: true });
|
|
230
|
+
fs.writeFileSync(daemonStatePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
231
|
+
}
|
|
232
|
+
function removeDaemonState() {
|
|
233
|
+
if (!fs.existsSync(daemonStatePath)) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
fs.rmSync(daemonStatePath, { force: true });
|
|
237
|
+
}
|
|
238
|
+
function normalizePid(value) {
|
|
239
|
+
const pid = Number.parseInt(String(value ?? ""), 10);
|
|
240
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
return pid;
|
|
244
|
+
}
|
|
245
|
+
function isProcessRunning(pid) {
|
|
246
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
process.kill(pid, 0);
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async function terminateProcess(pid) {
|
|
261
|
+
if (process.platform === "win32") {
|
|
262
|
+
await killTreeWindows(pid);
|
|
263
|
+
if (!(await waitForExit(pid, 7000))) {
|
|
264
|
+
await killTreeWindows(pid);
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
sendSignal(pid, "SIGTERM");
|
|
269
|
+
if (await waitForExit(pid, 7000)) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
sendSignal(pid, "SIGKILL");
|
|
273
|
+
await waitForExit(pid, 2000);
|
|
274
|
+
}
|
|
275
|
+
function killTreeWindows(pid) {
|
|
276
|
+
return new Promise((resolve) => {
|
|
277
|
+
const child = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
278
|
+
stdio: "ignore",
|
|
279
|
+
shell: false,
|
|
280
|
+
windowsHide: true,
|
|
281
|
+
});
|
|
282
|
+
child.once("error", () => resolve());
|
|
283
|
+
child.once("exit", () => resolve());
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
function sendSignal(pid, signal) {
|
|
287
|
+
try {
|
|
288
|
+
process.kill(-pid, signal);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// continue
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
process.kill(pid, signal);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// ignore
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async function waitForExit(pid, timeoutMs, expectExit = true) {
|
|
302
|
+
const deadline = Date.now() + timeoutMs;
|
|
303
|
+
while (Date.now() < deadline) {
|
|
304
|
+
const running = isProcessRunning(pid);
|
|
305
|
+
if (expectExit && !running) {
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
if (!expectExit && running) {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
await sleep(100);
|
|
312
|
+
}
|
|
313
|
+
const stillRunning = isProcessRunning(pid);
|
|
314
|
+
return expectExit ? !stillRunning : stillRunning === false;
|
|
315
|
+
}
|
|
316
|
+
function runSupervisor(actionValue, { allowFailure = false, stdio = "pipe" } = {}) {
|
|
317
|
+
return runChecked(nodeBin, [supervisorScriptPath, String(actionValue ?? "").trim()], {
|
|
318
|
+
allowFailure,
|
|
319
|
+
stdio,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
function runChecked(command, args, { stdio = "pipe", allowFailure = false } = {}) {
|
|
323
|
+
const spawnStdio = stdio;
|
|
324
|
+
const result = spawnSync(command, args, {
|
|
325
|
+
cwd: repoRoot,
|
|
326
|
+
shell: false,
|
|
327
|
+
stdio: spawnStdio,
|
|
328
|
+
encoding: "utf8",
|
|
329
|
+
env: process.env,
|
|
330
|
+
});
|
|
331
|
+
const stdout = String(result.stdout ?? "").trim();
|
|
332
|
+
const stderr = String(result.stderr ?? "").trim();
|
|
333
|
+
const combinedOutput = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
334
|
+
const spawnErrorCode = String(result.error?.code ?? "")
|
|
335
|
+
.trim()
|
|
336
|
+
.toUpperCase();
|
|
337
|
+
const ok = !result.error && result.status === 0;
|
|
338
|
+
if (!ok && !allowFailure) {
|
|
339
|
+
const errorMessage = result.error && spawnErrorCode
|
|
340
|
+
? `${command} failed (${spawnErrorCode}).`
|
|
341
|
+
: combinedOutput || `${command} exited with code ${String(result.status ?? "unknown")}.`;
|
|
342
|
+
throw new Error(errorMessage);
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
ok,
|
|
346
|
+
status: result.status,
|
|
347
|
+
stdout,
|
|
348
|
+
stderr,
|
|
349
|
+
combinedOutput,
|
|
350
|
+
spawnErrorCode,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function computeBackoffDelay(failureCount) {
|
|
354
|
+
const power = Math.max(0, failureCount - 1);
|
|
355
|
+
const calculated = BASE_CHECK_MS * 2 ** power;
|
|
356
|
+
return Math.min(calculated, MAX_BACKOFF_MS);
|
|
357
|
+
}
|
|
358
|
+
async function sleepInterruptible(ms, shouldStop) {
|
|
359
|
+
const deadline = Date.now() + ms;
|
|
360
|
+
while (Date.now() < deadline) {
|
|
361
|
+
if (shouldStop()) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
await sleep(Math.min(250, Math.max(1, deadline - Date.now())));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function sleep(ms) {
|
|
368
|
+
return new Promise((resolve) => {
|
|
369
|
+
setTimeout(resolve, ms);
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
function firstLine(value) {
|
|
373
|
+
const text = String(value ?? "").trim();
|
|
374
|
+
if (!text) {
|
|
375
|
+
return "";
|
|
376
|
+
}
|
|
377
|
+
const [line] = text.split(/\r?\n/, 1);
|
|
378
|
+
return String(line ?? "").trim();
|
|
379
|
+
}
|
|
380
|
+
function getErrorMessage(error) {
|
|
381
|
+
if (error instanceof Error && error.message) {
|
|
382
|
+
return error.message;
|
|
383
|
+
}
|
|
384
|
+
return String(error ?? "Unknown error.");
|
|
385
|
+
}
|
|
386
|
+
function printUsage() {
|
|
387
|
+
console.log("Usage: node scripts/dist/daemon.mjs <start|run|stop|status|help>");
|
|
388
|
+
}
|