jishushell 0.0.1 → 0.4.2

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.
Files changed (136) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +36 -0
  3. package/THIRD-PARTY-NOTICES +387 -0
  4. package/dist/auth.d.ts +6 -0
  5. package/dist/auth.js +88 -0
  6. package/dist/auth.js.map +1 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +290 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config.d.ts +24 -0
  11. package/dist/config.js +226 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/constants.d.ts +3 -0
  14. package/dist/constants.js +15 -0
  15. package/dist/constants.js.map +1 -0
  16. package/dist/control.d.ts +44 -0
  17. package/dist/control.js +1359 -0
  18. package/dist/control.js.map +1 -0
  19. package/dist/crypto-shim.d.ts +1 -0
  20. package/dist/crypto-shim.js +2 -0
  21. package/dist/crypto-shim.js.map +1 -0
  22. package/dist/doctor.d.ts +46 -0
  23. package/dist/doctor.js +937 -0
  24. package/dist/doctor.js.map +1 -0
  25. package/dist/install.d.ts +27 -0
  26. package/dist/install.js +570 -0
  27. package/dist/install.js.map +1 -0
  28. package/dist/routes/auth.d.ts +4 -0
  29. package/dist/routes/auth.js +151 -0
  30. package/dist/routes/auth.js.map +1 -0
  31. package/dist/routes/instances.d.ts +2 -0
  32. package/dist/routes/instances.js +1303 -0
  33. package/dist/routes/instances.js.map +1 -0
  34. package/dist/routes/setup.d.ts +2 -0
  35. package/dist/routes/setup.js +139 -0
  36. package/dist/routes/setup.js.map +1 -0
  37. package/dist/routes/system.d.ts +2 -0
  38. package/dist/routes/system.js +102 -0
  39. package/dist/routes/system.js.map +1 -0
  40. package/dist/server.d.ts +6 -0
  41. package/dist/server.js +392 -0
  42. package/dist/server.js.map +1 -0
  43. package/dist/services/instance-manager.d.ts +67 -0
  44. package/dist/services/instance-manager.js +1319 -0
  45. package/dist/services/instance-manager.js.map +1 -0
  46. package/dist/services/llm-proxy/adapters.d.ts +3 -0
  47. package/dist/services/llm-proxy/adapters.js +309 -0
  48. package/dist/services/llm-proxy/adapters.js.map +1 -0
  49. package/dist/services/llm-proxy/circuit-breaker.d.ts +9 -0
  50. package/dist/services/llm-proxy/circuit-breaker.js +73 -0
  51. package/dist/services/llm-proxy/circuit-breaker.js.map +1 -0
  52. package/dist/services/llm-proxy/encryption.d.ts +6 -0
  53. package/dist/services/llm-proxy/encryption.js +61 -0
  54. package/dist/services/llm-proxy/encryption.js.map +1 -0
  55. package/dist/services/llm-proxy/index.d.ts +24 -0
  56. package/dist/services/llm-proxy/index.js +708 -0
  57. package/dist/services/llm-proxy/index.js.map +1 -0
  58. package/dist/services/llm-proxy/rate-limiter.d.ts +1 -0
  59. package/dist/services/llm-proxy/rate-limiter.js +39 -0
  60. package/dist/services/llm-proxy/rate-limiter.js.map +1 -0
  61. package/dist/services/llm-proxy/sse.d.ts +10 -0
  62. package/dist/services/llm-proxy/sse.js +378 -0
  63. package/dist/services/llm-proxy/sse.js.map +1 -0
  64. package/dist/services/llm-proxy/ssrf.d.ts +16 -0
  65. package/dist/services/llm-proxy/ssrf.js +185 -0
  66. package/dist/services/llm-proxy/ssrf.js.map +1 -0
  67. package/dist/services/llm-proxy/types.d.ts +52 -0
  68. package/dist/services/llm-proxy/types.js +2 -0
  69. package/dist/services/llm-proxy/types.js.map +1 -0
  70. package/dist/services/llm-proxy/usage.d.ts +12 -0
  71. package/dist/services/llm-proxy/usage.js +108 -0
  72. package/dist/services/llm-proxy/usage.js.map +1 -0
  73. package/dist/services/nomad-manager.d.ts +22 -0
  74. package/dist/services/nomad-manager.js +828 -0
  75. package/dist/services/nomad-manager.js.map +1 -0
  76. package/dist/services/plugin-installer.d.ts +22 -0
  77. package/dist/services/plugin-installer.js +102 -0
  78. package/dist/services/plugin-installer.js.map +1 -0
  79. package/dist/services/process-manager.d.ts +25 -0
  80. package/dist/services/process-manager.js +531 -0
  81. package/dist/services/process-manager.js.map +1 -0
  82. package/dist/services/setup-manager.d.ts +93 -0
  83. package/dist/services/setup-manager.js +1922 -0
  84. package/dist/services/setup-manager.js.map +1 -0
  85. package/dist/services/system-monitor.d.ts +1 -0
  86. package/dist/services/system-monitor.js +79 -0
  87. package/dist/services/system-monitor.js.map +1 -0
  88. package/dist/services/telemetry/activation.d.ts +12 -0
  89. package/dist/services/telemetry/activation.js +75 -0
  90. package/dist/services/telemetry/activation.js.map +1 -0
  91. package/dist/services/telemetry/client.d.ts +21 -0
  92. package/dist/services/telemetry/client.js +47 -0
  93. package/dist/services/telemetry/client.js.map +1 -0
  94. package/dist/services/telemetry/device-fingerprint.d.ts +18 -0
  95. package/dist/services/telemetry/device-fingerprint.js +123 -0
  96. package/dist/services/telemetry/device-fingerprint.js.map +1 -0
  97. package/dist/services/telemetry/heartbeat.d.ts +13 -0
  98. package/dist/services/telemetry/heartbeat.js +81 -0
  99. package/dist/services/telemetry/heartbeat.js.map +1 -0
  100. package/dist/services/telemetry/index.d.ts +3 -0
  101. package/dist/services/telemetry/index.js +4 -0
  102. package/dist/services/telemetry/index.js.map +1 -0
  103. package/dist/types.d.ts +51 -0
  104. package/dist/types.js +2 -0
  105. package/dist/types.js.map +1 -0
  106. package/dist/utils/safe-json.d.ts +2 -0
  107. package/dist/utils/safe-json.js +80 -0
  108. package/dist/utils/safe-json.js.map +1 -0
  109. package/dist/utils/ttl-cache.d.ts +29 -0
  110. package/dist/utils/ttl-cache.js +77 -0
  111. package/dist/utils/ttl-cache.js.map +1 -0
  112. package/install/jishu-install.sh +2920 -0
  113. package/install/jishu-uninstall.sh +811 -0
  114. package/install/post-install.sh +110 -0
  115. package/install/post-uninstall.sh +46 -0
  116. package/package.json +57 -8
  117. package/public/assets/Dashboard-CAOQDYDR.js +1 -0
  118. package/public/assets/InitPassword-CkehIkJG.js +1 -0
  119. package/public/assets/InstanceDetail-CzW2S95J.js +14 -0
  120. package/public/assets/Login-RkjzTNWg.js +1 -0
  121. package/public/assets/NewInstance-DdbErdjA.js +1 -0
  122. package/public/assets/Settings-BUD7zwv9.js +1 -0
  123. package/public/assets/Setup-RRTIERGG.js +1 -0
  124. package/public/assets/index-77Ug7feY.css +1 -0
  125. package/public/assets/index-DfRnVUQR.js +16 -0
  126. package/public/assets/providers-lBSOjUWy.js +1 -0
  127. package/public/assets/usePolling-CqQ8hrNc.js +1 -0
  128. package/public/assets/vendor-i18n-Bvxxh8Di.js +9 -0
  129. package/public/assets/vendor-react-DONn7uBV.js +59 -0
  130. package/public/index.html +15 -0
  131. package/scripts/build-image.sh +55 -0
  132. package/scripts/run.sh +310 -0
  133. package/scripts/setup-pi.sh +80 -0
  134. package/scripts/start-feishu1.js +46 -0
  135. package/index.js +0 -0
  136. package/jishushell-0.0.1.tgz +0 -0
@@ -0,0 +1,1922 @@
1
+ import { execFileSync, execSync, spawn as nodeSpawn } from "child_process";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "fs";
3
+ import { userInfo, homedir } from "node:os";
4
+ import { randomBytes } from "node:crypto";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, join } from "path";
7
+ import { StringDecoder } from "string_decoder";
8
+ import { fileURLToPath } from "url";
9
+ import { JISHUSHELL_HOME, getPanelConfig, savePanelConfig, setOpenclawDockerImage, getOpenclawDockerImage, isOfficialImage, CUSTOM_IMAGE_PREFIX, DEFAULT_OPENCLAW_DOCKER_IMAGE } from "../config.js";
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+ // ── Paths ──────────────────────────────────────────────────────────
13
+ const BIN_DIR = join(JISHUSHELL_HOME, "bin");
14
+ const PACKAGES_DIR = join(JISHUSHELL_HOME, "packages");
15
+ const OPENCLAW_PKG_DIR = join(PACKAGES_DIR, "openclaw");
16
+ /** npm global-prefix layout: lib/node_modules/<pkg>, bin/<cmd> */
17
+ const OPENCLAW_MODULES = join(OPENCLAW_PKG_DIR, "lib", "node_modules");
18
+ const OPENCLAW_BIN_DIR = join(OPENCLAW_PKG_DIR, "bin");
19
+ const NOMAD_BIN = join(BIN_DIR, "nomad");
20
+ const NOMAD_CONFIG_DIR = join(JISHUSHELL_HOME, "nomad");
21
+ const NOMAD_DATA_DIR = join(JISHUSHELL_HOME, "nomad", "data");
22
+ const NOMAD_ALLOC_DIR = join(JISHUSHELL_HOME, "nomad", "data", "alloc");
23
+ const NOMAD_VERSION = "1.11.3";
24
+ let _serverPort = 8090;
25
+ export function setServerPort(port) { _serverPort = port; }
26
+ // ── Resolve non-root service user (board-agnostic) ─────────────────
27
+ /**
28
+ * Determine which user the systemd service should run as.
29
+ *
30
+ * Strategy (in order):
31
+ * 1. If current process is NOT root, return the real username directly.
32
+ * 2. Under sudo, trust the kernel-set SUDO_UID (numeric, hard to spoof)
33
+ * and reverse-lookup via `getent passwd <uid>`.
34
+ * 3. Fallback: scan /etc/passwd for the first user with UID >= 1000
35
+ * (works on any board — pi, orangepi, rock, nvidia, etc.).
36
+ * 4. Throw instead of guessing — running as the wrong user is worse
37
+ * than a visible installation error.
38
+ */
39
+ function resolveServiceUser() {
40
+ const current = userInfo();
41
+ if (current.uid !== 0)
42
+ return current.username;
43
+ // Under sudo: SUDO_UID is set by the kernel and is numeric
44
+ const sudoUid = parseInt(process.env.SUDO_UID || "", 10);
45
+ if (Number.isFinite(sudoUid) && sudoUid >= 1000) {
46
+ try {
47
+ const entry = execFileSync("getent", ["passwd", String(sudoUid)], {
48
+ encoding: "utf8",
49
+ timeout: 3000,
50
+ }).trim();
51
+ const name = entry.split(":")[0];
52
+ if (name)
53
+ return name;
54
+ }
55
+ catch { /* getent failed, continue */ }
56
+ }
57
+ // Board-agnostic fallback: first real user (UID >= 1000, exclude nobody)
58
+ try {
59
+ const firstUser = execFileSync("getent", ["passwd"], {
60
+ encoding: "utf8",
61
+ timeout: 3000,
62
+ })
63
+ .split("\n")
64
+ .map((line) => line.split(":"))
65
+ .find((cols) => {
66
+ const uid = parseInt(cols[2], 10);
67
+ return uid >= 1000 && uid < 65534;
68
+ });
69
+ if (firstUser?.[0])
70
+ return firstUser[0];
71
+ }
72
+ catch { /* /etc/passwd unreadable */ }
73
+ throw new Error("Cannot determine service user. Run with a non-root user or set SUDO_UID.");
74
+ }
75
+ const tasks = new Map();
76
+ const TASK_MAX_AGE = 600000; // 10 minutes
77
+ // Periodically clean up completed tasks to prevent memory accumulation
78
+ setInterval(() => {
79
+ const now = Date.now();
80
+ for (const [id, task] of tasks) {
81
+ if (task.status !== "running" && now - parseInt(id.split("-").pop() || "0") > TASK_MAX_AGE) {
82
+ tasks.delete(id);
83
+ }
84
+ }
85
+ }, 60000).unref();
86
+ function createTask(name) {
87
+ const id = `${name}-${Date.now()}`;
88
+ const task = { id, name, status: "running", events: [], listeners: new Set() };
89
+ tasks.set(id, task);
90
+ return task;
91
+ }
92
+ const MAX_TASK_EVENTS = 500;
93
+ function emitTask(task, event) {
94
+ task.events.push(event);
95
+ // Cap events to prevent unbounded memory growth on long-running tasks
96
+ // (e.g., Docker pull/build can produce thousands of progress lines)
97
+ if (task.events.length > MAX_TASK_EVENTS) {
98
+ task.events.splice(0, task.events.length - MAX_TASK_EVENTS);
99
+ }
100
+ for (const listener of task.listeners) {
101
+ listener(event);
102
+ }
103
+ }
104
+ export function getTask(id) {
105
+ return tasks.get(id);
106
+ }
107
+ export function getTaskSnapshot(id) {
108
+ const task = tasks.get(id);
109
+ if (!task)
110
+ return undefined;
111
+ return {
112
+ id: task.id,
113
+ name: task.name,
114
+ status: task.status,
115
+ events: [...task.events],
116
+ };
117
+ }
118
+ /** Find running tasks, optionally filtered by name prefix */
119
+ export function getRunningTasks(namePrefix) {
120
+ const result = [];
121
+ for (const [id, task] of tasks) {
122
+ if (task.status !== "running")
123
+ continue;
124
+ if (namePrefix && !task.name.startsWith(namePrefix))
125
+ continue;
126
+ result.push({ id, name: task.name });
127
+ }
128
+ return result;
129
+ }
130
+ export function subscribeTask(id, listener) {
131
+ const task = tasks.get(id);
132
+ if (!task)
133
+ return null;
134
+ task.listeners.add(listener);
135
+ // Send existing events
136
+ for (const event of task.events) {
137
+ listener(event);
138
+ }
139
+ return () => task.listeners.delete(listener);
140
+ }
141
+ const ANSI_ESCAPE_RE = /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
142
+ function sanitizeTaskLine(line) {
143
+ return line
144
+ .replace(ANSI_ESCAPE_RE, "")
145
+ .replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, "")
146
+ .trimEnd();
147
+ }
148
+ /** Run a shell command as a spawned process, streaming output to a task */
149
+ function spawnWithTask(task, cmd, args, options = {}) {
150
+ return new Promise((resolve) => {
151
+ const env = { ...process.env, ...options.env };
152
+ const child = nodeSpawn(cmd, args, {
153
+ env,
154
+ cwd: options.cwd,
155
+ timeout: options.timeout || 600000,
156
+ });
157
+ let output = "";
158
+ const stdoutDecoder = new StringDecoder("utf8");
159
+ const stderrDecoder = new StringDecoder("utf8");
160
+ let stdoutPending = "";
161
+ let stderrPending = "";
162
+ const emitLine = (rawLine) => {
163
+ const line = sanitizeTaskLine(rawLine);
164
+ if (!line.trim())
165
+ return;
166
+ // Try to parse progress
167
+ if (options.progressParser) {
168
+ const pct = options.progressParser(line);
169
+ if (pct !== null) {
170
+ emitTask(task, { type: "progress", message: line, progress: pct });
171
+ return;
172
+ }
173
+ }
174
+ emitTask(task, { type: "log", message: line });
175
+ };
176
+ const handleDecodedText = (text, stream) => {
177
+ if (!text)
178
+ return;
179
+ output += text;
180
+ const normalized = `${stream === "stdout" ? stdoutPending : stderrPending}${text}`
181
+ .replace(/\r\n/g, "\n")
182
+ .replace(/\r/g, "\n");
183
+ const lines = normalized.split("\n");
184
+ const pending = lines.pop() ?? "";
185
+ if (stream === "stdout")
186
+ stdoutPending = pending;
187
+ else
188
+ stderrPending = pending;
189
+ for (const line of lines) {
190
+ emitLine(line);
191
+ }
192
+ };
193
+ child.stdout?.on("data", (data) => {
194
+ handleDecodedText(stdoutDecoder.write(data), "stdout");
195
+ });
196
+ child.stderr?.on("data", (data) => {
197
+ handleDecodedText(stderrDecoder.write(data), "stderr");
198
+ });
199
+ child.on("close", (code) => {
200
+ handleDecodedText(stdoutDecoder.end(), "stdout");
201
+ handleDecodedText(stderrDecoder.end(), "stderr");
202
+ emitLine(stdoutPending);
203
+ emitLine(stderrPending);
204
+ resolve({ ok: code === 0, output });
205
+ });
206
+ child.on("error", (err) => {
207
+ emitTask(task, { type: "log", message: `Error: ${err.message}` });
208
+ resolve({ ok: false, output: err.message });
209
+ });
210
+ });
211
+ }
212
+ // ── Progress parsers ───────────────────────────────────────────────
213
+ function npmProgressParser(line) {
214
+ // npm shows "added X packages" at the end
215
+ if (line.includes("added") && line.includes("packages"))
216
+ return 100;
217
+ // npm progress: "reify:packagename: timing"
218
+ if (line.includes("reify:"))
219
+ return null; // just a log line
220
+ return null;
221
+ }
222
+ function dockerBuildProgressParser(line) {
223
+ // Docker build steps: "Step 1/6", "Step 2/6", etc.
224
+ const legacyMatch = line.match(/Step\s+(\d+)\/(\d+)/);
225
+ if (legacyMatch) {
226
+ return Math.round((parseInt(legacyMatch[1], 10) / parseInt(legacyMatch[2], 10)) * 100);
227
+ }
228
+ // BuildKit plain output: "#5 [2/4] COPY node_modules ..."
229
+ const buildkitMatch = line.match(/\[(\d+)\/(\d+)\]/);
230
+ if (buildkitMatch) {
231
+ return Math.round((parseInt(buildkitMatch[1], 10) / parseInt(buildkitMatch[2], 10)) * 100);
232
+ }
233
+ if (/exporting to image/i.test(line))
234
+ return 95;
235
+ if (/naming to /i.test(line))
236
+ return 98;
237
+ return null;
238
+ }
239
+ function curlProgressParser(line) {
240
+ // curl with -# shows progress
241
+ const match = line.match(/(\d+\.?\d*)%/);
242
+ if (match)
243
+ return Math.round(parseFloat(match[1]));
244
+ return null;
245
+ }
246
+ // ── Dir size tracker for npm installs ──────────────────────────────
247
+ function getDirSizeMB(dir) {
248
+ try {
249
+ const result = execFileSync("du", ["-sm", dir], { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
250
+ return parseInt(result.split("\t")[0]) || 0;
251
+ }
252
+ catch {
253
+ return 0;
254
+ }
255
+ }
256
+ function checkCommand(cmd, versionFlag = "--version") {
257
+ try {
258
+ const version = execFileSync(cmd, [versionFlag], { encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }).trim();
259
+ let cmdPath = "";
260
+ try {
261
+ cmdPath = execFileSync("which", [cmd], { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
262
+ }
263
+ catch { }
264
+ return { ok: true, version, path: cmdPath };
265
+ }
266
+ catch {
267
+ return { ok: false, version: "", path: "" };
268
+ }
269
+ }
270
+ function isProcessRunning(name) {
271
+ try {
272
+ const result = execFileSync("pgrep", ["-f", name], { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
273
+ return result.trim().length > 0;
274
+ }
275
+ catch {
276
+ return false;
277
+ }
278
+ }
279
+ function isPortListening(port) {
280
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
281
+ return false;
282
+ const p = String(port);
283
+ try {
284
+ if (process.platform === "darwin") {
285
+ const result = execFileSync("lsof", ["-iTCP:" + p, "-sTCP:LISTEN", "-t"], { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
286
+ return result.trim().length > 0;
287
+ }
288
+ else {
289
+ // Linux: prefer ss, fall back to netstat
290
+ try {
291
+ const result = execFileSync("ss", ["-tlnp"], { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
292
+ return new RegExp(`:${p}\\s`).test(result);
293
+ }
294
+ catch {
295
+ const result = execFileSync("netstat", ["-tlnp"], { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
296
+ return new RegExp(`:${p}\\s`).test(result);
297
+ }
298
+ }
299
+ }
300
+ catch {
301
+ return false;
302
+ }
303
+ }
304
+ function checkSudo() {
305
+ try {
306
+ execSync("sudo -n true 2>/dev/null", { timeout: 3000 });
307
+ return true;
308
+ }
309
+ catch {
310
+ return false;
311
+ }
312
+ }
313
+ /** Check if kernel memory cgroup is enabled (required for Docker memory stats on RPi) */
314
+ function isCgroupMemoryEnabled() {
315
+ try {
316
+ const cgroups = readFileSync("/proc/cgroups", "utf-8");
317
+ const memLine = cgroups.split("\n").find(l => l.startsWith("memory\t"));
318
+ if (!memLine)
319
+ return false;
320
+ // Format: name hierarchy num_cgroups enabled
321
+ return memLine.trim().endsWith("1");
322
+ }
323
+ catch {
324
+ return true; /* non-Linux or unreadable — assume ok */
325
+ }
326
+ }
327
+ /** Enable cgroup memory in boot cmdline if not already enabled. Returns true if a reboot is needed. */
328
+ export function ensureCgroupMemory() {
329
+ if (isCgroupMemoryEnabled())
330
+ return false;
331
+ const candidates = ["/boot/firmware/cmdline.txt", "/boot/cmdline.txt"];
332
+ for (const f of candidates) {
333
+ if (!existsSync(f))
334
+ continue;
335
+ try {
336
+ const content = readFileSync(f, "utf-8").trim();
337
+ if (content.includes("cgroup_memory=1"))
338
+ return false; // already in cmdline, just needs reboot
339
+ const patched = `${content} cgroup_memory=1 cgroup_enable=memory`;
340
+ execFileSync("sudo", ["cp", f, f + ".bak"], { timeout: 5000 });
341
+ // Write to tmp file then sudo cp to avoid shell interpolation of file content
342
+ const tmpPath = join(dirname(f), ".cmdline.tmp");
343
+ writeFileSync(tmpPath, patched + "\n");
344
+ execFileSync("sudo", ["cp", tmpPath, f], { timeout: 5000 });
345
+ try {
346
+ unlinkSync(tmpPath);
347
+ }
348
+ catch { }
349
+ console.log(`[setup] enabled cgroup memory in ${f} (reboot required)`);
350
+ return true;
351
+ }
352
+ catch (e) {
353
+ console.warn(`[setup] failed to patch ${f}:`, e.message);
354
+ }
355
+ }
356
+ return false;
357
+ }
358
+ function canAccessDockerDaemon(timeout = 10000) {
359
+ try {
360
+ execFileSync("docker", ["info"], { timeout, stdio: "ignore" });
361
+ return true;
362
+ }
363
+ catch { }
364
+ try {
365
+ execFileSync("sudo", ["-n", "docker", "info"], { timeout, stdio: "ignore" });
366
+ return true;
367
+ }
368
+ catch { }
369
+ return false;
370
+ }
371
+ function getDockerVersionLine(timeout = 10000) {
372
+ try {
373
+ return execFileSync("docker", ["--version"], { encoding: "utf-8", timeout }).trim();
374
+ }
375
+ catch { }
376
+ try {
377
+ return execFileSync("sudo", ["-n", "docker", "--version"], { encoding: "utf-8", timeout }).trim();
378
+ }
379
+ catch { }
380
+ return "installed";
381
+ }
382
+ export function getSetupStatus() {
383
+ // Fast path: if setup is already complete, do lightweight checks before returning cached result
384
+ const config = getPanelConfig();
385
+ if (config.service_manager) {
386
+ const localBin = join(OPENCLAW_BIN_DIR, "openclaw");
387
+ const localBinOk = existsSync(localBin);
388
+ const fastDockerImageReady = checkDockerImageExists();
389
+ const baseTag = resolveDockerImageTag();
390
+ const official = isOfficialImage(baseTag);
391
+ // Official image: Docker image alone is sufficient. Slim base: also needs npm package.
392
+ const openclawOk = official ? fastDockerImageReady : (localBinOk || fastDockerImageReady);
393
+ // Lightweight validation: verify critical services are actually available
394
+ const dockerOk = canAccessDockerDaemon(5000);
395
+ const nomadOk = isPortListening(4646);
396
+ const hasSudo = checkSudo();
397
+ // If any critical check fails, fall through to the slow full check path
398
+ if (!openclawOk || !dockerOk || !nomadOk || !hasSudo) {
399
+ // Fall through to full check below
400
+ }
401
+ else {
402
+ // Fetch real versions (cheap execs, no fallback to slow path)
403
+ let dockerVer = "installed";
404
+ try {
405
+ dockerVer = execFileSync("docker", ["--version"], { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim().split("\n")[0];
406
+ }
407
+ catch { }
408
+ let nomadVer = "installed";
409
+ try {
410
+ nomadVer = execFileSync(NOMAD_BIN, ["--version"], { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim().split("\n")[0];
411
+ }
412
+ catch { }
413
+ let openclawVer = "installed";
414
+ let openclawPath = localBin;
415
+ if (official && fastDockerImageReady) {
416
+ // Official image: extract version from Docker image tag
417
+ openclawVer = baseTag.split(":").pop() || "local";
418
+ openclawPath = baseTag;
419
+ }
420
+ else {
421
+ // Prefer npm package.json for accurate OpenClaw version.
422
+ try {
423
+ const pkg = join(OPENCLAW_MODULES, "openclaw", "package.json");
424
+ if (existsSync(pkg))
425
+ openclawVer = JSON.parse(readFileSync(pkg, "utf-8")).version || openclawVer;
426
+ }
427
+ catch { }
428
+ // Fallback: extract version from old-style openclaw:* image tag (legacy migration path).
429
+ if (openclawVer === "installed" && fastDockerImageReady) {
430
+ const imageTag = resolveDockerImageTag();
431
+ if (/^openclaw:/i.test(imageTag)) {
432
+ openclawVer = imageTag.replace(/^openclaw:/i, "");
433
+ openclawPath = imageTag;
434
+ }
435
+ }
436
+ }
437
+ // Official image: only Docker image needed.
438
+ // Slim base (jishushell-base:*): both npm package and Docker image required.
439
+ const needsNpmForMount = /^jishushell-base:/i.test(baseTag);
440
+ const ready = official
441
+ ? fastDockerImageReady
442
+ : (openclawOk && fastDockerImageReady && (!needsNpmForMount || localBinOk));
443
+ return {
444
+ node: { name: "Node.js", installed: true, running: true, version: process.version, path: process.execPath },
445
+ docker: { name: "Docker", installed: true, running: true, version: dockerVer, path: "" },
446
+ nomad: { name: "Nomad", installed: true, running: true, version: nomadVer, path: NOMAD_BIN },
447
+ openclaw: { name: "OpenClaw", installed: openclawOk, running: false, version: openclawVer, path: openclawPath },
448
+ dockerImageReady: fastDockerImageReady,
449
+ ready,
450
+ providerConfigured: !!config.default_provider,
451
+ hasSudo: true,
452
+ };
453
+ }
454
+ }
455
+ const NODE_MINIMUM_MAJOR = 22;
456
+ // Use the currently-running Node binary (process.execPath) as the ground
457
+ // truth — avoids false-negative when PATH doesn't include nvm's bin dir
458
+ // (e.g. when started via systemd with a minimal environment).
459
+ const nodeVersion = process.version;
460
+ const nodeMajor = parseInt(nodeVersion.replace("v", "").split(".")[0], 10);
461
+ const nodeStatus = {
462
+ name: "Node.js",
463
+ installed: true,
464
+ running: true,
465
+ version: nodeVersion,
466
+ path: process.execPath,
467
+ needsUpgrade: nodeMajor < NODE_MINIMUM_MAJOR,
468
+ };
469
+ const docker = checkCommand("docker");
470
+ // Use docker info to verify daemon accessibility (not just process existence)
471
+ // Also try sudo in case the user hasn't logged out since being added to the docker group
472
+ const dockerRunning = canAccessDockerDaemon(10000);
473
+ const dockerStatus = {
474
+ name: "Docker",
475
+ installed: docker.ok,
476
+ running: dockerRunning,
477
+ version: docker.version.split("\n")[0],
478
+ path: docker.path,
479
+ };
480
+ let nomad = checkCommand("nomad");
481
+ if (!nomad.ok && existsSync(NOMAD_BIN)) {
482
+ nomad = checkCommand(NOMAD_BIN);
483
+ }
484
+ const nomadRunning = isPortListening(4646);
485
+ const nomadStatus = {
486
+ name: "Nomad",
487
+ installed: nomad.ok,
488
+ running: nomadRunning,
489
+ version: nomad.version.split("\n")[0],
490
+ path: nomad.path || NOMAD_BIN,
491
+ };
492
+ let openclaw = checkCommand("openclaw");
493
+ if (!openclaw.ok) {
494
+ const localBin = join(OPENCLAW_BIN_DIR, "openclaw");
495
+ if (existsSync(localBin)) {
496
+ let ver = "installed";
497
+ try {
498
+ const pkg = join(OPENCLAW_MODULES, "openclaw", "package.json");
499
+ if (existsSync(pkg)) {
500
+ const pkgJson = JSON.parse(readFileSync(pkg, "utf-8"));
501
+ ver = pkgJson.version || "installed";
502
+ }
503
+ }
504
+ catch { }
505
+ openclaw = { ok: true, version: ver, path: localBin };
506
+ }
507
+ }
508
+ const dockerImageReady = checkDockerImageExists();
509
+ // If the Docker image already exists, treat openclaw as installed even when
510
+ // the local npm package is absent — the image is all that's needed to run instances.
511
+ if (!openclaw.ok && dockerImageReady) {
512
+ const imageTag = resolveDockerImageTag();
513
+ // Official image: extract version from tag (e.g. ghcr.io/openclaw/openclaw:2026.3.31 → 2026.3.31)
514
+ // Legacy image: strip openclaw: prefix
515
+ const tagVersion = isOfficialImage(imageTag)
516
+ ? (imageTag.split(":").pop() || "local")
517
+ : imageTag.replace(/^openclaw:/i, "");
518
+ openclaw = { ok: true, version: tagVersion, path: imageTag };
519
+ }
520
+ const openclawStatus = {
521
+ name: "OpenClaw",
522
+ installed: openclaw.ok,
523
+ running: false,
524
+ version: openclaw.version.split("\n")[0],
525
+ path: openclaw.path,
526
+ };
527
+ const ready = nodeStatus.installed && !nodeStatus.needsUpgrade && dockerStatus.installed && dockerStatus.running && nomadStatus.installed && nomadStatus.running && openclawStatus.installed && dockerImageReady;
528
+ const providerConfigured = !!getPanelConfig().default_provider;
529
+ // Read-only check: report whether cgroup memory needs fixing (actual fix is in POST install routes)
530
+ let needsReboot;
531
+ if (dockerStatus.installed && !isCgroupMemoryEnabled()) {
532
+ needsReboot = true;
533
+ }
534
+ const runningTasks = getRunningTasks();
535
+ return { node: nodeStatus, docker: dockerStatus, nomad: nomadStatus, openclaw: openclawStatus, ready, providerConfigured, dockerImageReady, hasSudo: checkSudo(), needsReboot, runningTasks: runningTasks.length ? runningTasks : undefined };
536
+ }
537
+ // ── Upgrade Node.js ────────────────────────────────────────────────
538
+ export async function upgradeNode(targetMajor = 22) {
539
+ try {
540
+ const current = process.version; // e.g. "v20.20.1"
541
+ const currentMajor = parseInt(current.replace("v", "").split(".")[0]);
542
+ if (currentMajor >= targetMajor) {
543
+ return { ok: true, message: `Node.js ${current} already meets requirement (>= ${targetMajor})` };
544
+ }
545
+ const task = createTask("node-upgrade");
546
+ emitTask(task, { type: "progress", message: `升级 Node.js: ${current} → v${targetMajor}`, progress: 0 });
547
+ if (process.platform === "darwin") {
548
+ try {
549
+ execSync("brew --version", { timeout: 5000 });
550
+ }
551
+ catch {
552
+ emitTask(task, { type: "error", message: "未找到 Homebrew" });
553
+ task.status = "error";
554
+ return { ok: false, message: "Homebrew not found", error: "Install Homebrew first: https://brew.sh", taskId: task.id };
555
+ }
556
+ emitTask(task, { type: "progress", message: `安装 Node.js ${targetMajor} (brew)...`, progress: 20 });
557
+ const installResult = await spawnWithTask(task, "brew", ["install", `node@${targetMajor}`], { timeout: 300000 });
558
+ if (!installResult.ok) {
559
+ emitTask(task, { type: "error", message: "Node.js 安装失败" });
560
+ task.status = "error";
561
+ return { ok: false, message: "Node.js install failed", error: installResult.output, taskId: task.id };
562
+ }
563
+ emitTask(task, { type: "progress", message: "链接 Node.js...", progress: 80 });
564
+ try {
565
+ execSync(`brew link --overwrite --force node@${targetMajor}`, { timeout: 30000 });
566
+ }
567
+ catch { }
568
+ }
569
+ else {
570
+ // Use NodeSource setup script
571
+ emitTask(task, { type: "log", message: "下载 NodeSource 安装脚本..." });
572
+ emitTask(task, { type: "progress", message: "配置软件源...", progress: 10 });
573
+ // Use a unique temp path to prevent TOCTOU race: a fixed name like
574
+ // /tmp/nodesource_setup_22.sh could be replaced by another process
575
+ // between download (curl) and execution (sudo bash).
576
+ const nsScriptPath = join(tmpdir(), `nodesource_setup_${targetMajor}_${randomBytes(8).toString("hex")}.sh`);
577
+ const dlResult = await spawnWithTask(task, "curl", ["-fsSL", `https://deb.nodesource.com/setup_${targetMajor}.x`, "-o", nsScriptPath], { timeout: 60000 });
578
+ if (!dlResult.ok) {
579
+ emitTask(task, { type: "error", message: "NodeSource 脚本下载失败" });
580
+ task.status = "error";
581
+ return { ok: false, message: "NodeSource script download failed", error: dlResult.output, taskId: task.id };
582
+ }
583
+ const setupResult = await spawnWithTask(task, "sudo", ["-E", "bash", nsScriptPath], { timeout: 120000 });
584
+ if (!setupResult.ok) {
585
+ emitTask(task, { type: "error", message: "NodeSource 配置失败" });
586
+ task.status = "error";
587
+ return { ok: false, message: "Node.js source setup failed", error: setupResult.output, taskId: task.id };
588
+ }
589
+ emitTask(task, { type: "progress", message: "安装 Node.js...", progress: 50 });
590
+ const installResult = await spawnWithTask(task, "sudo", ["apt-get", "install", "-y", "nodejs"], { timeout: 300000 });
591
+ if (!installResult.ok) {
592
+ emitTask(task, { type: "error", message: "Node.js 安装失败" });
593
+ task.status = "error";
594
+ return { ok: false, message: "Node.js install failed", error: installResult.output, taskId: task.id };
595
+ }
596
+ }
597
+ const newVersion = execSync("node --version", { encoding: "utf-8", timeout: 5000 }).trim();
598
+ emitTask(task, { type: "done", message: `Node.js 升级完成: ${newVersion}`, progress: 100 });
599
+ task.status = "done";
600
+ return { ok: true, message: `Node.js upgraded to ${newVersion}. 请重启 JishuShell 以使用新版本。`, taskId: task.id };
601
+ }
602
+ catch (e) {
603
+ return { ok: false, message: "Node.js upgrade failed", error: e.message };
604
+ }
605
+ }
606
+ // ── Install Docker (async with progress) ───────────────────────────
607
+ async function installDockerWithTask(task) {
608
+ try {
609
+ emitTask(task, { type: "progress", message: "开始安装 Docker...", progress: 0 });
610
+ const user = execSync("whoami", { encoding: "utf-8", timeout: 5000 }).trim();
611
+ // ── 尝试 get.docker.com 便捷脚本 ──────────────────────────────
612
+ emitTask(task, { type: "log", message: "下载 Docker 安装脚本..." });
613
+ emitTask(task, { type: "progress", message: "下载中...", progress: 5 });
614
+ // Unique temp path prevents TOCTOU: another user can't swap a fixed /tmp/get-docker.sh
615
+ // between our download and the sudo sh invocation.
616
+ const dockerScriptPath = join(tmpdir(), `get-docker_${randomBytes(8).toString("hex")}.sh`);
617
+ const dlResult = await spawnWithTask(task, "curl", ["-fsSL", "https://get.docker.com", "-o", dockerScriptPath], {
618
+ timeout: 60000,
619
+ });
620
+ let scriptOk = false;
621
+ if (dlResult.ok) {
622
+ emitTask(task, { type: "log", message: "运行 Docker 安装脚本..." });
623
+ const scriptResult = await spawnWithTask(task, "sudo", ["sh", dockerScriptPath], {
624
+ timeout: 600000,
625
+ progressParser: (line) => {
626
+ if (line.includes("Executing docker install script"))
627
+ return 10;
628
+ if (line.includes("apt-get update"))
629
+ return 20;
630
+ if (line.includes("Installing packages"))
631
+ return 40;
632
+ if (line.includes("docker-ce"))
633
+ return 60;
634
+ if (line.includes("containerd"))
635
+ return 70;
636
+ if (line.includes("Created symlink"))
637
+ return 85;
638
+ return null;
639
+ },
640
+ });
641
+ scriptOk = scriptResult.ok;
642
+ if (!scriptOk) {
643
+ emitTask(task, { type: "log", message: "便捷脚本安装失败,尝试 apt 仓库方式..." });
644
+ }
645
+ }
646
+ else {
647
+ emitTask(task, { type: "log", message: "下载便捷脚本失败,尝试 apt 仓库方式..." });
648
+ }
649
+ // ── 回退: apt 仓库手动安装 (兼容 Debian trixie 等预发行版) ────
650
+ if (!scriptOk) {
651
+ emitTask(task, { type: "progress", message: "通过 apt 仓库安装 Docker...", progress: 15 });
652
+ // 检测系统 ID 和版本代号,对 trixie/sid 等降级至 bookworm
653
+ let codename = "bookworm";
654
+ let repoOs = "debian";
655
+ try {
656
+ const osRelease = readFileSync("/etc/os-release", "utf-8");
657
+ const codenameMatch = osRelease.match(/^VERSION_CODENAME=(.+)$/m);
658
+ const distroId = (osRelease.match(/^ID=(.+)$/m)?.[1] || "debian").trim();
659
+ const raw = (codenameMatch?.[1] || "").trim();
660
+ repoOs = (distroId === "ubuntu") ? "ubuntu" : "debian";
661
+ // Debian testing/unstable: trixie, sid → 使用上一个稳定版仓库
662
+ const unstableCodenames = new Set(["trixie", "forky", "sid", "unstable", "testing"]);
663
+ const ubuntuFallback = "noble";
664
+ if (distroId === "ubuntu" && unstableCodenames.has(raw)) {
665
+ codename = ubuntuFallback;
666
+ }
667
+ else if (unstableCodenames.has(raw)) {
668
+ codename = "bookworm";
669
+ }
670
+ else {
671
+ codename = raw || "bookworm";
672
+ }
673
+ }
674
+ catch { }
675
+ emitTask(task, { type: "log", message: `使用仓库 codename: ${codename} (OS: ${repoOs})` });
676
+ const aptSteps = [
677
+ ["sudo", ["apt-get", "update", "-qq"]],
678
+ ["sudo", ["apt-get", "install", "-y", "-qq", "ca-certificates", "curl", "gnupg"]],
679
+ ["sudo", ["install", "-m", "0755", "-d", "/etc/apt/keyrings"]],
680
+ ["sudo", ["bash", "-c",
681
+ `curl -fsSL https://download.docker.com/linux/${repoOs}/gpg | gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg && chmod a+r /etc/apt/keyrings/docker.gpg`
682
+ ]],
683
+ ["sudo", ["bash", "-c",
684
+ `echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${repoOs} ${codename} stable" > /etc/apt/sources.list.d/docker.list`
685
+ ]],
686
+ ["sudo", ["apt-get", "update", "-qq"]],
687
+ ["sudo", ["apt-get", "install", "-y", "-qq",
688
+ "docker-ce", "docker-ce-cli", "containerd.io", "docker-buildx-plugin", "docker-compose-plugin"
689
+ ]],
690
+ ];
691
+ const progressSteps = [5, 15, 20, 30, 35, 45, 80];
692
+ for (let i = 0; i < aptSteps.length; i++) {
693
+ const [cmd, args] = aptSteps[i];
694
+ emitTask(task, { type: "progress", message: `${cmd} ${args[0]}...`, progress: progressSteps[i] });
695
+ const r = await spawnWithTask(task, cmd, args, { timeout: 300000 });
696
+ if (!r.ok) {
697
+ emitTask(task, { type: "error", message: `apt 安装失败 (步骤 ${i + 1}): ${args.join(" ")}` });
698
+ task.status = "error";
699
+ return { ok: false, message: "Docker apt installation failed", error: r.output, taskId: task.id };
700
+ }
701
+ }
702
+ }
703
+ // ── 收尾 ───────────────────────────────────────────────────────
704
+ emitTask(task, { type: "progress", message: "配置用户权限...", progress: 90 });
705
+ try {
706
+ execFileSync("sudo", ["groupadd", "docker"], { timeout: 10000 });
707
+ }
708
+ catch { }
709
+ try {
710
+ execFileSync("sudo", ["usermod", "-aG", "docker", user], { timeout: 10000 });
711
+ }
712
+ catch { }
713
+ emitTask(task, { type: "log", message: "注意: Docker 用户组变更可能需要重新登录才能生效" });
714
+ emitTask(task, { type: "progress", message: "启动 Docker 服务...", progress: 93 });
715
+ try {
716
+ execSync("sudo systemctl enable --now docker", { timeout: 30000 });
717
+ }
718
+ catch { }
719
+ // Enable cgroup memory for Docker stats (RPi default has it disabled)
720
+ const needsReboot = ensureCgroupMemory();
721
+ if (needsReboot) {
722
+ emitTask(task, { type: "log", message: "已启用内存 cgroup(需要重启生效,Docker 内存统计才会正常)" });
723
+ }
724
+ const version = getDockerVersionLine(10000);
725
+ // 用 sudo docker 验证 daemon(当前用户还未加入 docker 组时需要 sudo)
726
+ const daemonOk = canAccessDockerDaemon(10000);
727
+ if (daemonOk) {
728
+ const doneMsg = needsReboot
729
+ ? `Docker 安装完成: ${version}(需要重启以启用内存统计)`
730
+ : `Docker 安装完成: ${version}`;
731
+ emitTask(task, { type: "done", message: doneMsg, progress: 100 });
732
+ task.status = "done";
733
+ return { ok: true, message: `Docker installed: ${version}`, needsReboot, taskId: task.id };
734
+ }
735
+ else {
736
+ emitTask(task, { type: "error", message: `Docker 已安装但 daemon 未运行: ${version}` });
737
+ task.status = "error";
738
+ return { ok: false, message: `Docker installed but daemon not accessible`, error: "Try: sudo systemctl start docker", taskId: task.id };
739
+ }
740
+ }
741
+ catch (e) {
742
+ return { ok: false, message: "Docker installation failed", error: e.message };
743
+ }
744
+ }
745
+ /** Ensure current user is in docker group and service is enabled (idempotent) */
746
+ function ensureDockerPostInstall() {
747
+ try {
748
+ const user = execSync("whoami", { encoding: "utf-8", timeout: 5000 }).trim();
749
+ try {
750
+ execFileSync("sudo", ["groupadd", "docker"], { timeout: 5000, stdio: "pipe" });
751
+ }
752
+ catch { }
753
+ try {
754
+ execFileSync("sudo", ["usermod", "-aG", "docker", user], { timeout: 5000 });
755
+ }
756
+ catch { }
757
+ try {
758
+ execFileSync("sudo", ["systemctl", "enable", "--now", "docker"], { timeout: 10000 });
759
+ }
760
+ catch { }
761
+ }
762
+ catch { }
763
+ }
764
+ export async function installDocker() {
765
+ if (canAccessDockerDaemon(5000)) {
766
+ ensureDockerPostInstall();
767
+ const needsReboot = ensureCgroupMemory();
768
+ return { ok: true, message: "Docker already installed", needsReboot };
769
+ }
770
+ const task = createTask("docker");
771
+ return installDockerWithTask(task);
772
+ }
773
+ export function startInstallDocker() {
774
+ if (canAccessDockerDaemon(5000)) {
775
+ ensureDockerPostInstall();
776
+ ensureCgroupMemory();
777
+ return { ok: true, message: "Docker already installed" };
778
+ }
779
+ const task = createTask("docker");
780
+ void installDockerWithTask(task).catch((err) => {
781
+ emitTask(task, { type: "error", message: `Docker 安装失败: ${err?.message || err}` });
782
+ task.status = "error";
783
+ });
784
+ return { ok: true, message: "Docker install started", taskId: task.id };
785
+ }
786
+ // ── Install Nomad (async with progress) ────────────────────────────
787
+ function getNomadDownloadUrl() {
788
+ const arch = process.arch === "arm64" ? "arm64" : "amd64";
789
+ const os = process.platform === "linux" ? "linux" : "darwin";
790
+ return `https://releases.hashicorp.com/nomad/${NOMAD_VERSION}/nomad_${NOMAD_VERSION}_${os}_${arch}.zip`;
791
+ }
792
+ export async function installNomad() {
793
+ try {
794
+ if (existsSync(NOMAD_BIN)) {
795
+ // Boundary check 1: is it a regular file?
796
+ try {
797
+ const stat = (await import("fs")).statSync(NOMAD_BIN);
798
+ if (!stat.isFile()) {
799
+ try {
800
+ unlinkSync(NOMAD_BIN);
801
+ }
802
+ catch { }
803
+ }
804
+ }
805
+ catch { }
806
+ if (existsSync(NOMAD_BIN)) {
807
+ // Boundary check 2: is it executable?
808
+ try {
809
+ chmodSync(NOMAD_BIN, 0o755);
810
+ }
811
+ catch { }
812
+ // Boundary check 3: does it actually run?
813
+ try {
814
+ const version = execSync(`"${NOMAD_BIN}" version`, { encoding: "utf-8", timeout: 5000 }).trim();
815
+ // Ensure Nomad is started even if already installed
816
+ if (!isPortListening(4646)) {
817
+ try {
818
+ installNomadSystemd();
819
+ }
820
+ catch { }
821
+ if (!isPortListening(4646)) {
822
+ await startNomad();
823
+ }
824
+ }
825
+ return { ok: true, message: `Nomad already installed: ${version.split("\n")[0]}` };
826
+ }
827
+ catch {
828
+ // Binary is corrupt or wrong arch — remove and reinstall
829
+ try {
830
+ unlinkSync(NOMAD_BIN);
831
+ }
832
+ catch { }
833
+ }
834
+ }
835
+ }
836
+ // Fallback: if NOMAD_BIN still doesn't exist, check for a system-installed Nomad
837
+ // (e.g. installed via apt/dnf) and create a symlink so the systemd service path resolves.
838
+ if (!existsSync(NOMAD_BIN)) {
839
+ try {
840
+ const systemNomad = execSync("which nomad 2>/dev/null", { encoding: "utf-8" }).trim();
841
+ if (systemNomad && existsSync(systemNomad)) {
842
+ mkdirSync(BIN_DIR, { recursive: true });
843
+ symlinkSync(systemNomad, NOMAD_BIN);
844
+ const version = execSync(`${NOMAD_BIN} version`, { encoding: "utf-8", timeout: 5000 }).trim();
845
+ console.log(`[nomad] Linked system nomad ${systemNomad} → ${NOMAD_BIN}`);
846
+ return { ok: true, message: `Nomad linked from system: ${version.split("\n")[0]}` };
847
+ }
848
+ }
849
+ catch { /* system nomad not found — proceed to download */ }
850
+ }
851
+ const task = createTask("nomad");
852
+ mkdirSync(BIN_DIR, { recursive: true });
853
+ const url = getNomadDownloadUrl();
854
+ const zipPath = join(BIN_DIR, "nomad.zip");
855
+ emitTask(task, { type: "progress", message: "下载 Nomad...", progress: 0 });
856
+ const result = await spawnWithTask(task, "curl", ["-fL", "--progress-bar", url, "-o", zipPath], {
857
+ timeout: 300000,
858
+ progressParser: curlProgressParser,
859
+ });
860
+ if (!result.ok) {
861
+ emitTask(task, { type: "error", message: "Nomad 下载失败" });
862
+ task.status = "error";
863
+ return { ok: false, message: "Nomad download failed", error: result.output, taskId: task.id };
864
+ }
865
+ emitTask(task, { type: "progress", message: "解压 Nomad...", progress: 80 });
866
+ try {
867
+ execSync("which unzip", { stdio: "ignore" });
868
+ }
869
+ catch {
870
+ emitTask(task, { type: "log", message: "未找到 unzip,尝试安装..." });
871
+ try {
872
+ if (process.platform === "linux") {
873
+ execSync("sudo apt-get update -qq && sudo apt-get install -y -qq unzip", { timeout: 60000 });
874
+ }
875
+ else if (process.platform === "darwin") {
876
+ execSync("brew install unzip", { timeout: 60000 });
877
+ }
878
+ }
879
+ catch (e) {
880
+ emitTask(task, { type: "error", message: "安装 unzip 失败,请手动安装后重试" });
881
+ throw e;
882
+ }
883
+ }
884
+ execFileSync("unzip", ["-o", zipPath, "-d", BIN_DIR], { timeout: 30000 });
885
+ chmodSync(NOMAD_BIN, 0o755);
886
+ try {
887
+ unlinkSync(zipPath);
888
+ }
889
+ catch { }
890
+ const version = execFileSync(NOMAD_BIN, ["version"], { encoding: "utf-8", timeout: 5000 }).trim();
891
+ emitTask(task, { type: "done", message: `Nomad 安装完成: ${version}`, progress: 100 });
892
+ task.status = "done";
893
+ return { ok: true, message: `Nomad installed: ${version}`, taskId: task.id };
894
+ }
895
+ catch (e) {
896
+ return { ok: false, message: "Nomad installation failed", error: e.message };
897
+ }
898
+ }
899
+ // ── Configure & start Nomad ────────────────────────────────────────
900
+ /** Fix ~/.jishushell dirs if they were accidentally created as root (e.g. from an old sudo install). */
901
+ function fixNomadDirOwnership() {
902
+ try {
903
+ const { statSync } = require("fs");
904
+ const stat = statSync(NOMAD_CONFIG_DIR);
905
+ if (stat.uid === 0) {
906
+ // Directory is owned by root — try to repair with sudo -n (non-interactive)
907
+ const currentUser = process.env.USER || process.env.LOGNAME || "";
908
+ if (currentUser) {
909
+ try {
910
+ execFileSync("sudo", ["-n", "chown", "-R", `${currentUser}:${currentUser}`, JISHUSHELL_HOME], { timeout: 10000, stdio: "pipe" });
911
+ }
912
+ catch { /* sudo not available or no NOPASSWD — ignore */ }
913
+ }
914
+ }
915
+ }
916
+ catch { /* stat failed — dir doesn't exist yet, no action needed */ }
917
+ }
918
+ function writeNomadConfig() {
919
+ fixNomadDirOwnership();
920
+ mkdirSync(NOMAD_CONFIG_DIR, { recursive: true, mode: 0o755 });
921
+ chmodSync(NOMAD_CONFIG_DIR, 0o755);
922
+ mkdirSync(NOMAD_DATA_DIR, { recursive: true, mode: 0o755 });
923
+ chmodSync(NOMAD_DATA_DIR, 0o755);
924
+ mkdirSync(NOMAD_ALLOC_DIR, { recursive: true, mode: 0o755 });
925
+ chmodSync(NOMAD_ALLOC_DIR, 0o755);
926
+ const config = `
927
+ data_dir = "${NOMAD_DATA_DIR}"
928
+
929
+ bind_addr = "127.0.0.1"
930
+
931
+ leave_on_terminate = false
932
+
933
+ advertise {
934
+ http = "127.0.0.1"
935
+ rpc = "127.0.0.1"
936
+ serf = "127.0.0.1"
937
+ }
938
+
939
+ server {
940
+ enabled = true
941
+ bootstrap_expect = 1
942
+ }
943
+
944
+ client {
945
+ enabled = true
946
+ servers = ["127.0.0.1:4647"]
947
+ alloc_dir = "${NOMAD_ALLOC_DIR}"
948
+
949
+ drain_on_shutdown {
950
+ deadline = "30s"
951
+ force = true
952
+ ignore_system_jobs = true
953
+ }
954
+ }
955
+
956
+ plugin "docker" {
957
+ config {
958
+ disable_log_collection = true
959
+ volumes {
960
+ enabled = true
961
+ }
962
+ }
963
+ }
964
+
965
+ acl {
966
+ enabled = true
967
+ }
968
+ `;
969
+ writeFileSync(join(NOMAD_CONFIG_DIR, "nomad.hcl"), config);
970
+ }
971
+ export function loadNomadToken() {
972
+ if (process.env.NOMAD_TOKEN)
973
+ return;
974
+ const candidates = [
975
+ join(JISHUSHELL_HOME, "nomad.env"),
976
+ "/etc/jishushell/nomad.env",
977
+ ];
978
+ for (const envFile of candidates) {
979
+ if (!existsSync(envFile))
980
+ continue;
981
+ try {
982
+ const match = readFileSync(envFile, "utf-8").match(/^NOMAD_TOKEN=(.+)$/m);
983
+ if (match) {
984
+ process.env.NOMAD_TOKEN = match[1].trim();
985
+ return;
986
+ }
987
+ }
988
+ catch {
989
+ // no read permission, try next candidate
990
+ }
991
+ }
992
+ const legacy = getPanelConfig().nomad_token;
993
+ if (legacy)
994
+ process.env.NOMAD_TOKEN = legacy;
995
+ }
996
+ /** Verify the current NOMAD_TOKEN is accepted by the Nomad API. Returns false if it is missing or rejected (HTTP 403/401). */
997
+ async function isNomadTokenValid() {
998
+ const token = process.env.NOMAD_TOKEN || getPanelConfig().nomad_token;
999
+ if (!token)
1000
+ return false;
1001
+ try {
1002
+ const resp = await fetch("http://127.0.0.1:4646/v1/acl/token/self", {
1003
+ headers: { "X-Nomad-Token": token },
1004
+ signal: AbortSignal.timeout(5000),
1005
+ });
1006
+ return resp.status === 200;
1007
+ }
1008
+ catch {
1009
+ return false;
1010
+ }
1011
+ }
1012
+ function nomadApiHeaders(extra = {}) {
1013
+ const token = process.env.NOMAD_TOKEN || getPanelConfig().nomad_token;
1014
+ return token ? { "X-Nomad-Token": token, ...extra } : extra;
1015
+ }
1016
+ export async function ensureNomadMemoryOversubscriptionEnabled() {
1017
+ try {
1018
+ const resp = await fetch("http://127.0.0.1:4646/v1/operator/scheduler/configuration", {
1019
+ headers: nomadApiHeaders(),
1020
+ signal: AbortSignal.timeout(5000),
1021
+ });
1022
+ if (resp.status !== 200)
1023
+ return;
1024
+ const payload = await resp.json();
1025
+ const scheduler = payload?.SchedulerConfig;
1026
+ if (!scheduler || typeof scheduler !== "object")
1027
+ return;
1028
+ if (scheduler.MemoryOversubscriptionEnabled === true)
1029
+ return;
1030
+ const putResp = await fetch("http://127.0.0.1:4646/v1/operator/scheduler/configuration", {
1031
+ method: "PUT",
1032
+ headers: nomadApiHeaders({ "Content-Type": "application/json" }),
1033
+ body: JSON.stringify({ ...scheduler, MemoryOversubscriptionEnabled: true }),
1034
+ signal: AbortSignal.timeout(5000),
1035
+ });
1036
+ if (putResp.status === 200) {
1037
+ console.log("[nomad] Enabled memory oversubscription so MemoryMaxMB becomes an actual task limit.");
1038
+ }
1039
+ else {
1040
+ console.warn(`[nomad] Failed to enable memory oversubscription: HTTP ${putResp.status}`);
1041
+ }
1042
+ }
1043
+ catch (e) {
1044
+ console.warn("[nomad] Failed to enable memory oversubscription:", e.message);
1045
+ }
1046
+ }
1047
+ /** Shared post-start sequence: load token, bootstrap ACL, enable oversubscription, enable node. */
1048
+ async function finalizeNomadStartup() {
1049
+ loadNomadToken();
1050
+ await bootstrapNomadACL();
1051
+ await ensureNomadMemoryOversubscriptionEnabled();
1052
+ enableNomadNodeEligibility();
1053
+ }
1054
+ async function bootstrapNomadACL() {
1055
+ // If a token is already loaded, verify it's still valid before trusting it.
1056
+ if (process.env.NOMAD_TOKEN || getPanelConfig().nomad_token) {
1057
+ if (await isNomadTokenValid())
1058
+ return;
1059
+ console.log("[nomad] Stored ACL token is invalid — re-bootstrapping...");
1060
+ delete process.env.NOMAD_TOKEN;
1061
+ }
1062
+ const nomadPath = existsSync(NOMAD_BIN) ? NOMAD_BIN : "nomad";
1063
+ const doBootstrap = async () => {
1064
+ try {
1065
+ const raw = execFileSync(nomadPath, ["acl", "bootstrap", "-json"], {
1066
+ timeout: 10000,
1067
+ encoding: "utf-8",
1068
+ env: { ...process.env, NOMAD_ADDR: "http://127.0.0.1:4646" },
1069
+ });
1070
+ return JSON.parse(raw).SecretID ?? null;
1071
+ }
1072
+ catch (e) {
1073
+ // Return the raw stderr/stdout so caller can inspect it
1074
+ throw e;
1075
+ }
1076
+ };
1077
+ const saveToken = (token) => {
1078
+ const envFile = join(JISHUSHELL_HOME, "nomad.env");
1079
+ const envContent = `NOMAD_TOKEN=${token}\n`;
1080
+ writeFileSync(envFile, envContent, { mode: 0o600 });
1081
+ try {
1082
+ execFileSync("sudo", ["-n", "mkdir", "-p", "/etc/jishushell"], { timeout: 5000, stdio: "pipe" });
1083
+ writeFileSync("/tmp/.nomad-env-tmp", envContent, { mode: 0o600 });
1084
+ execFileSync("sudo", ["-n", "cp", "/tmp/.nomad-env-tmp", "/etc/jishushell/nomad.env"], { timeout: 5000, stdio: "pipe" });
1085
+ execFileSync("sudo", ["-n", "chmod", "600", "/etc/jishushell/nomad.env"], { timeout: 5000, stdio: "pipe" });
1086
+ try {
1087
+ unlinkSync("/tmp/.nomad-env-tmp");
1088
+ }
1089
+ catch { }
1090
+ }
1091
+ catch (copyErr) {
1092
+ console.warn("[nomad] Could not sync token to /etc/jishushell/nomad.env:", copyErr.message);
1093
+ }
1094
+ process.env.NOMAD_TOKEN = token;
1095
+ console.log("[nomad] ACL token saved to", envFile);
1096
+ };
1097
+ try {
1098
+ const token = await doBootstrap();
1099
+ if (token)
1100
+ saveToken(token);
1101
+ return;
1102
+ }
1103
+ catch (e) {
1104
+ const msg = (e.stderr || e.stdout || e.message || "").toString();
1105
+ // "ACL bootstrap already done" — extract reset index and perform a reset
1106
+ const resetMatch = msg.match(/reset index[:\s]+(\d+)/i);
1107
+ if (!resetMatch) {
1108
+ if (!msg.includes("bootstrap"))
1109
+ console.warn("[nomad] ACL bootstrap warning:", msg);
1110
+ return;
1111
+ }
1112
+ const resetIndex = resetMatch[1];
1113
+ console.log(`[nomad] Bootstrap already done (reset index: ${resetIndex}). Performing ACL bootstrap reset...`);
1114
+ // Write the reset trigger file (Nomad reads this on startup to allow re-bootstrap)
1115
+ const resetFile = join(NOMAD_DATA_DIR, "server", "acl-bootstrap-reset");
1116
+ try {
1117
+ writeFileSync(resetFile, resetIndex, { encoding: "utf-8" });
1118
+ }
1119
+ catch (writeErr) {
1120
+ console.warn("[nomad] Could not write acl-bootstrap-reset file:", writeErr.message);
1121
+ return;
1122
+ }
1123
+ // Restart Nomad so it picks up the reset file
1124
+ try {
1125
+ execFileSync("sudo", ["-n", "systemctl", "restart", "nomad"], { timeout: 15000, stdio: "pipe" });
1126
+ }
1127
+ catch {
1128
+ // No passwordless sudo — try pkill/re-spawn path (best effort)
1129
+ try {
1130
+ execFileSync("pkill", ["-TERM", "-f", "nomad agent"], { stdio: "pipe" });
1131
+ }
1132
+ catch { }
1133
+ }
1134
+ // Wait for Nomad to come back
1135
+ for (let i = 0; i < 20; i++) {
1136
+ await new Promise(r => setTimeout(r, 1000));
1137
+ if (isPortListening(4646))
1138
+ break;
1139
+ }
1140
+ // Bootstrap again — reset file is consumed by Nomad on first start after restart
1141
+ try {
1142
+ const token = await doBootstrap();
1143
+ if (token)
1144
+ saveToken(token);
1145
+ }
1146
+ catch (e2) {
1147
+ console.warn("[nomad] ACL bootstrap after reset failed:", (e2.stderr || e2.message || "").toString().split("\n")[0]);
1148
+ }
1149
+ }
1150
+ }
1151
+ function enableNomadNodeEligibility() {
1152
+ const nomadPath = existsSync(NOMAD_BIN) ? NOMAD_BIN : "nomad";
1153
+ try {
1154
+ const raw = execSync(`${nomadPath} node status -self -json`, {
1155
+ timeout: 5000,
1156
+ encoding: "utf-8",
1157
+ env: { ...process.env, NOMAD_ADDR: "http://127.0.0.1:4646" },
1158
+ });
1159
+ const nodeId = JSON.parse(raw).ID;
1160
+ if (!nodeId)
1161
+ return;
1162
+ execSync(`${nomadPath} node eligibility -enable ${nodeId}`, {
1163
+ timeout: 5000,
1164
+ env: { ...process.env, NOMAD_ADDR: "http://127.0.0.1:4646" },
1165
+ });
1166
+ }
1167
+ catch {
1168
+ }
1169
+ }
1170
+ export async function startNomad() {
1171
+ try {
1172
+ writeNomadConfig();
1173
+ if (isPortListening(4646)) {
1174
+ await finalizeNomadStartup();
1175
+ return { ok: true, message: "Nomad already running" };
1176
+ }
1177
+ // If systemd manages nomad, delegate to systemctl to avoid spawning a competing process
1178
+ const isSystemdManaged = (() => {
1179
+ try {
1180
+ const result = execFileSync("systemctl", ["is-enabled", "nomad"], { stdio: "pipe", timeout: 3000 });
1181
+ return result.toString().trim() === "enabled";
1182
+ }
1183
+ catch {
1184
+ return false;
1185
+ }
1186
+ })();
1187
+ if (isSystemdManaged) {
1188
+ try {
1189
+ execFileSync("sudo", ["-n", "systemctl", "start", "nomad"], { stdio: "pipe", timeout: 10000 });
1190
+ }
1191
+ catch {
1192
+ // sudo -n failed (no passwordless sudo); service may start on its own
1193
+ }
1194
+ for (let i = 0; i < 15; i++) {
1195
+ await new Promise(r => setTimeout(r, 1000));
1196
+ if (isPortListening(4646)) {
1197
+ await finalizeNomadStartup();
1198
+ return { ok: true, message: "Nomad started via systemd" };
1199
+ }
1200
+ }
1201
+ return { ok: false, message: "Nomad start timed out", error: "Port 4646 not listening after 15s" };
1202
+ }
1203
+ if (process.platform === "darwin") {
1204
+ const launchdPlist = join(process.env.HOME || dirname(JISHUSHELL_HOME), "Library/LaunchAgents/com.jishushell.nomad.plist");
1205
+ if (existsSync(launchdPlist)) {
1206
+ for (let i = 0; i < 30; i++) {
1207
+ await new Promise(r => setTimeout(r, 1000));
1208
+ if (isPortListening(4646)) {
1209
+ await finalizeNomadStartup();
1210
+ return { ok: true, message: "Nomad started" };
1211
+ }
1212
+ }
1213
+ return { ok: false, message: "Nomad start timed out", error: "Port 4646 not listening after 30s" };
1214
+ }
1215
+ }
1216
+ const nomadPath = existsSync(NOMAD_BIN) ? NOMAD_BIN : "nomad";
1217
+ try {
1218
+ execFileSync(nomadPath, ["version"], { timeout: 5000, stdio: "pipe" });
1219
+ }
1220
+ catch {
1221
+ return { ok: false, message: "Nomad not installed", error: "Install Nomad first" };
1222
+ }
1223
+ const configPath = join(NOMAD_CONFIG_DIR, "nomad.hcl");
1224
+ const logPath = join(NOMAD_CONFIG_DIR, "nomad.log");
1225
+ // Use spawn with detached to avoid execSync shell injection and ensure proper daemonization
1226
+ try {
1227
+ const { openSync, closeSync } = await import("fs");
1228
+ const logFd = openSync(logPath, "a");
1229
+ // Run as current (non-root) user — Nomad only binds to 127.0.0.1:4646/4647/4648
1230
+ // (non-privileged ports) and all data dirs live under ~/.jishushell/, so sudo is not needed.
1231
+ const cmd = nomadPath;
1232
+ const args = ["agent", `-config=${configPath}`];
1233
+ const child = nodeSpawn(cmd, args, {
1234
+ detached: true,
1235
+ stdio: ["ignore", logFd, logFd],
1236
+ });
1237
+ child.unref();
1238
+ closeSync(logFd);
1239
+ }
1240
+ catch (e) {
1241
+ return { ok: false, message: "Failed to start Nomad", error: `launch failed: ${e.message}. Check sudo/permissions.` };
1242
+ }
1243
+ for (let i = 0; i < 15; i++) {
1244
+ await new Promise(r => setTimeout(r, 1000));
1245
+ if (isPortListening(4646)) {
1246
+ await finalizeNomadStartup();
1247
+ return { ok: true, message: "Nomad started" };
1248
+ }
1249
+ }
1250
+ return { ok: false, message: "Nomad start timed out", error: "Port 4646 not listening after 15s" };
1251
+ }
1252
+ catch (e) {
1253
+ return { ok: false, message: "Failed to start Nomad", error: e.message };
1254
+ }
1255
+ }
1256
+ export async function stopNomad() {
1257
+ try {
1258
+ if (!isPortListening(4646))
1259
+ return { ok: true, message: "Nomad not running" };
1260
+ // SIGTERM: graceful shutdown — Nomad flushes state, drains allocs (force=true in HCL)
1261
+ // Nomad runs as the current user; no sudo needed to send SIGTERM
1262
+ try {
1263
+ execSync("pkill -TERM -f 'nomad agent'", { timeout: 5000 });
1264
+ }
1265
+ catch { }
1266
+ // Wait up to 35s for process to exit (drain_on_shutdown deadline + grace)
1267
+ for (let i = 0; i < 35; i++) {
1268
+ await new Promise(r => setTimeout(r, 1000));
1269
+ if (!isPortListening(4646))
1270
+ return { ok: true, message: "Nomad stopped" };
1271
+ }
1272
+ return { ok: true, message: "Nomad stop requested" };
1273
+ }
1274
+ catch (e) {
1275
+ return { ok: false, message: "Failed to stop Nomad", error: e.message };
1276
+ }
1277
+ }
1278
+ // ── Install Nomad as systemd service ───────────────────────────────
1279
+ export function installNomadSystemd() {
1280
+ try {
1281
+ let nomadPath = NOMAD_BIN;
1282
+ if (!existsSync(NOMAD_BIN)) {
1283
+ try {
1284
+ nomadPath = execFileSync("which", ["nomad"], { encoding: "utf-8", timeout: 5000 }).trim();
1285
+ }
1286
+ catch {
1287
+ nomadPath = "nomad";
1288
+ }
1289
+ }
1290
+ const configPath = join(NOMAD_CONFIG_DIR, "nomad.hcl");
1291
+ writeNomadConfig();
1292
+ if (process.platform === "darwin") {
1293
+ const plistLabel = "com.jishushell.nomad";
1294
+ const logPath = join(NOMAD_CONFIG_DIR, "nomad.log");
1295
+ const primarySocket = join(homedir(), ".docker", "run", "docker.sock");
1296
+ const dockerSock = existsSync(primarySocket) ? primarySocket : "/var/run/docker.sock";
1297
+ const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
1298
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1299
+ <plist version="1.0">
1300
+ <dict>
1301
+ <key>Label</key><string>${plistLabel}</string>
1302
+ <key>ProgramArguments</key>
1303
+ <array>
1304
+ <string>${nomadPath}</string>
1305
+ <string>agent</string>
1306
+ <string>-config=${configPath}</string>
1307
+ </array>
1308
+ <key>EnvironmentVariables</key>
1309
+ <dict>
1310
+ <key>DOCKER_HOST</key><string>unix://${dockerSock}</string>
1311
+ <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
1312
+ </dict>
1313
+ <key>RunAtLoad</key><true/>
1314
+ <key>KeepAlive</key><true/>
1315
+ <key>StandardOutPath</key><string>${logPath}</string>
1316
+ <key>StandardErrorPath</key><string>${logPath}</string>
1317
+ </dict>
1318
+ </plist>`;
1319
+ const plistPath = join(process.env.HOME || dirname(JISHUSHELL_HOME), `Library/LaunchAgents/${plistLabel}.plist`);
1320
+ mkdirSync(dirname(plistPath), { recursive: true });
1321
+ writeFileSync(plistPath, plistContent);
1322
+ try {
1323
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null`);
1324
+ }
1325
+ catch { }
1326
+ execSync(`launchctl load -w "${plistPath}"`, { timeout: 15000 });
1327
+ return { ok: true, message: "Nomad launchd agent installed and started" };
1328
+ }
1329
+ // Nomad runs as the current (non-root) user so all ~/.jishushell/ files remain accessible.
1330
+ // Group=docker gives the process access to /var/run/docker.sock for the Docker driver.
1331
+ const currentUser = process.env.USER || process.env.LOGNAME || process.env.SUDO_USER || "jishu";
1332
+ const serviceContent = `[Unit]
1333
+ Description=Nomad Agent
1334
+ After=network-online.target docker.service
1335
+ Wants=network-online.target
1336
+
1337
+ [Service]
1338
+ User=${currentUser}
1339
+ SupplementaryGroups=docker
1340
+ Type=simple
1341
+ EnvironmentFile=-/etc/jishushell/nomad.env
1342
+ ExecStart=${nomadPath} agent -config=${configPath}
1343
+ Restart=on-failure
1344
+ RestartSec=3
1345
+
1346
+ [Install]
1347
+ WantedBy=multi-user.target
1348
+ `;
1349
+ const servicePath = "/etc/systemd/system/nomad.service";
1350
+ execFileSync("sudo", ["mkdir", "-p", "/etc/jishushell"], { timeout: 5000 });
1351
+ writeFileSync("/tmp/nomad.service", serviceContent);
1352
+ execFileSync("sudo", ["cp", "/tmp/nomad.service", servicePath], { timeout: 5000 });
1353
+ execFileSync("sudo", ["systemctl", "daemon-reload"], { timeout: 5000 });
1354
+ execFileSync("sudo", ["systemctl", "enable", "--now", "nomad"], { timeout: 15000 });
1355
+ // Also restart if already running so it picks up the new User= directive
1356
+ try {
1357
+ execFileSync("sudo", ["systemctl", "restart", "nomad"], { timeout: 15000 });
1358
+ }
1359
+ catch { }
1360
+ return { ok: true, message: "Nomad systemd service installed and started" };
1361
+ }
1362
+ catch (e) {
1363
+ return { ok: false, message: "Failed to install Nomad systemd service", error: e.message };
1364
+ }
1365
+ }
1366
+ // ── Install JishuShell as systemd service ─────────────────────────
1367
+ export function installJishushellSystemd(port) {
1368
+ try {
1369
+ const resolvedPort = port || _serverPort;
1370
+ // Use the node binary that is currently running this process — avoids
1371
+ // "which node" failing when PATH doesn't include nvm's bin dir.
1372
+ const nodeBin = process.execPath;
1373
+ // dist/cli.js lives next to this file (dist/services/setup-manager.js → dist/cli.js)
1374
+ const cliBin = join(__dirname, "..", "cli.js");
1375
+ // The real owner home: prefer JISHUSHELL_HOME's parent so we don't rely on $HOME.
1376
+ const realHome = dirname(JISHUSHELL_HOME);
1377
+ if (process.platform === "darwin") {
1378
+ const plistLabel = "com.jishushell.panel";
1379
+ const logPath = join(JISHUSHELL_HOME, "panel.log");
1380
+ const wrapperPath = join(JISHUSHELL_HOME, "bin", "jishushell-panel-start");
1381
+ const nomadEnvPath = join(JISHUSHELL_HOME, "nomad.env");
1382
+ const wrapperContent = `#!/bin/sh
1383
+ [ -f "${nomadEnvPath}" ] && . "${nomadEnvPath}"
1384
+ export JISHUSHELL_HOME="${JISHUSHELL_HOME}"
1385
+ export HOME="${realHome}"
1386
+ export NODE_ENV=production
1387
+ export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin:/Applications/Docker.app/Contents/Resources/bin:${dirname(nodeBin)}:\${PATH}"
1388
+ exec "${nodeBin}" "${cliBin}" serve --port ${resolvedPort}
1389
+ `;
1390
+ mkdirSync(dirname(wrapperPath), { recursive: true });
1391
+ writeFileSync(wrapperPath, wrapperContent, { mode: 0o755 });
1392
+ const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
1393
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1394
+ <plist version="1.0">
1395
+ <dict>
1396
+ <key>Label</key><string>${plistLabel}</string>
1397
+ <key>ProgramArguments</key>
1398
+ <array>
1399
+ <string>/bin/sh</string>
1400
+ <string>${wrapperPath}</string>
1401
+ </array>
1402
+ <key>RunAtLoad</key><true/>
1403
+ <key>KeepAlive</key><true/>
1404
+ <key>StandardOutPath</key><string>${logPath}</string>
1405
+ <key>StandardErrorPath</key><string>${logPath}</string>
1406
+ </dict>
1407
+ </plist>`;
1408
+ const plistPath = join(process.env.HOME || realHome, `Library/LaunchAgents/${plistLabel}.plist`);
1409
+ mkdirSync(dirname(plistPath), { recursive: true });
1410
+ writeFileSync(plistPath, plistContent);
1411
+ const panelAlreadyRunning = isPortListening(resolvedPort);
1412
+ if (!panelAlreadyRunning) {
1413
+ try {
1414
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null`);
1415
+ }
1416
+ catch { }
1417
+ execSync(`launchctl load -w "${plistPath}"`, { timeout: 15000 });
1418
+ }
1419
+ return { ok: true, message: "JishuShell launchd agent installed and enabled (port " + resolvedPort + ")" };
1420
+ }
1421
+ const currentUser = resolveServiceUser();
1422
+ const serviceContent = `[Unit]
1423
+ Description=JishuShell Panel
1424
+ After=network-online.target
1425
+ Wants=network-online.target
1426
+
1427
+ [Service]
1428
+ Type=simple
1429
+ User=${currentUser}
1430
+ SupplementaryGroups=docker
1431
+ Environment=HOME=${realHome}
1432
+ Environment=JISHUSHELL_HOME=${JISHUSHELL_HOME}
1433
+ EnvironmentFile=-/etc/jishushell/jishushell.env
1434
+ EnvironmentFile=-/etc/jishushell/nomad.env
1435
+ ExecStart=${nodeBin} ${cliBin} serve --port ${resolvedPort}
1436
+ Restart=on-failure
1437
+ RestartSec=5
1438
+ Environment=NODE_ENV=production
1439
+
1440
+ [Install]
1441
+ WantedBy=multi-user.target
1442
+ `;
1443
+ execFileSync("sudo", ["mkdir", "-p", "/etc/jishushell"], { timeout: 5000 });
1444
+ writeFileSync("/tmp/jishushell.service", serviceContent);
1445
+ execFileSync("sudo", ["cp", "/tmp/jishushell.service", "/etc/systemd/system/jishushell.service"], { timeout: 5000 });
1446
+ execFileSync("sudo", ["systemctl", "daemon-reload"], { timeout: 5000 });
1447
+ execFileSync("sudo", ["systemctl", "enable", "jishushell"], { timeout: 15000 });
1448
+ // Don't start now — the current process is already running on this port.
1449
+ // The service will auto-start on next reboot.
1450
+ return { ok: true, message: "JishuShell systemd service installed and enabled (port " + resolvedPort + ")" };
1451
+ }
1452
+ catch (e) {
1453
+ return { ok: false, message: "Failed to install JishuShell systemd service", error: e.message };
1454
+ }
1455
+ }
1456
+ // ── Install OpenClaw (async with progress) ─────────────────────────
1457
+ const OPENCLAW_EXPECTED_SIZE_MB = 700;
1458
+ export async function installOpenclaw(version = "latest") {
1459
+ try {
1460
+ const openclawPkgDir = join(OPENCLAW_MODULES, "openclaw");
1461
+ if (existsSync(openclawPkgDir)) {
1462
+ const ver = getLocalOpenclawVersion() || "unknown";
1463
+ return { ok: true, message: `OpenClaw already installed: ${ver}` };
1464
+ }
1465
+ const task = createTask("openclaw");
1466
+ mkdirSync(OPENCLAW_PKG_DIR, { recursive: true });
1467
+ emitTask(task, { type: "progress", message: "开始安装 OpenClaw...", progress: 0 });
1468
+ // Monitor directory size for progress estimation
1469
+ const sizeTracker = setInterval(() => {
1470
+ const sizeMB = getDirSizeMB(OPENCLAW_PKG_DIR);
1471
+ const pct = Math.min(95, Math.round((sizeMB / OPENCLAW_EXPECTED_SIZE_MB) * 95));
1472
+ if (pct > 0) {
1473
+ emitTask(task, { type: "progress", message: `下载安装中... ${sizeMB}MB / ~${OPENCLAW_EXPECTED_SIZE_MB}MB`, progress: pct });
1474
+ }
1475
+ }, 3000);
1476
+ // Use npm install -g with --prefix so npm uses global-install semantics:
1477
+ // packages go to <prefix>/lib/node_modules/, bins to <prefix>/bin/
1478
+ // This makes postinstall scripts run naturally (no manual workarounds needed).
1479
+ const result = await spawnWithTask(task, "npm", ["install", "-g", "--prefix", OPENCLAW_PKG_DIR, `openclaw@${version}`], { timeout: 600000, progressParser: npmProgressParser });
1480
+ clearInterval(sizeTracker);
1481
+ if (!result.ok) {
1482
+ emitTask(task, { type: "error", message: "OpenClaw 安装失败" });
1483
+ task.status = "error";
1484
+ return { ok: false, message: "OpenClaw installation failed", error: result.output, taskId: task.id };
1485
+ }
1486
+ // Read version from package.json since openclaw --version needs Node 22+
1487
+ let ver = "installed";
1488
+ try {
1489
+ const pkg = join(OPENCLAW_MODULES, "openclaw", "package.json");
1490
+ if (existsSync(pkg)) {
1491
+ const pkgJson = JSON.parse(readFileSync(pkg, "utf-8"));
1492
+ ver = pkgJson.version || "installed";
1493
+ }
1494
+ }
1495
+ catch { }
1496
+ emitTask(task, { type: "done", message: `OpenClaw 安装完成: ${ver}`, progress: 100 });
1497
+ task.status = "done";
1498
+ return { ok: true, message: `OpenClaw installed: ${ver}`, taskId: task.id };
1499
+ }
1500
+ catch (e) {
1501
+ return { ok: false, message: "OpenClaw installation failed", error: e.message };
1502
+ }
1503
+ }
1504
+ // ── Helpers ──────────────────────────────────────────────────────
1505
+ function getLocalOpenclawVersion() {
1506
+ try {
1507
+ const pkg = join(OPENCLAW_MODULES, "openclaw", "package.json");
1508
+ if (existsSync(pkg))
1509
+ return JSON.parse(readFileSync(pkg, "utf-8")).version || "";
1510
+ }
1511
+ catch { }
1512
+ return "";
1513
+ }
1514
+ function resolveDockerInvocation() {
1515
+ try {
1516
+ execFileSync("docker", ["info"], { timeout: 5000, stdio: "ignore" });
1517
+ return { cmd: "docker", argsPrefix: [] };
1518
+ }
1519
+ catch { }
1520
+ try {
1521
+ execFileSync("sudo", ["-n", "docker", "info"], { timeout: 5000, stdio: "ignore" });
1522
+ return { cmd: "sudo", argsPrefix: ["-n", "docker"] };
1523
+ }
1524
+ catch { }
1525
+ return { cmd: "docker", argsPrefix: [] };
1526
+ }
1527
+ function checkDockerImageExists() {
1528
+ const tag = resolveDockerImageTag();
1529
+ try {
1530
+ const invocation = resolveDockerInvocation();
1531
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "image", "inspect", tag], { timeout: 5000, stdio: "ignore" });
1532
+ return true;
1533
+ }
1534
+ catch {
1535
+ return false;
1536
+ }
1537
+ }
1538
+ // The stable tag for the JishuShell base image (slim — no OpenClaw binary baked in).
1539
+ // This tag does NOT change when OpenClaw is upgraded; the binary is bind-mounted from
1540
+ // the host at runtime. Rebuild this image only when system packages (Node.js, python3…)
1541
+ // need to change.
1542
+ export const BASE_IMAGE_TAG = "jishushell-base:v1";
1543
+ function resolveDockerImageTag() {
1544
+ // 1. Environment variable takes precedence (same as runtime getOpenclawDockerImage)
1545
+ if (process.env.OPENCLAW_DOCKER_IMAGE)
1546
+ return process.env.OPENCLAW_DOCKER_IMAGE;
1547
+ // 2. Stored in panel.json — honours custom images and pre-existing openclaw:*
1548
+ // images that haven't been migrated yet (backward compatibility).
1549
+ const stored = getPanelConfig().openclaw_image;
1550
+ if (typeof stored === "string" && stored.trim())
1551
+ return stored;
1552
+ // 3. Scan local daemon — prefer official ghcr.io image, fall back to legacy jishushell-base.
1553
+ try {
1554
+ const invocation = resolveDockerInvocation();
1555
+ const output = execFileSync(invocation.cmd, [...invocation.argsPrefix, "images", "--format", "{{.Repository}}:{{.Tag}}"], { encoding: "utf-8", timeout: 5000 });
1556
+ const lines = output.split("\n").map(l => l.trim());
1557
+ // Prefer custom image (built with Python), then official, then legacy
1558
+ const custom = lines.find(l => l.startsWith(CUSTOM_IMAGE_PREFIX + ":"));
1559
+ if (custom)
1560
+ return custom;
1561
+ const official = lines.find(l => l.startsWith("ghcr.io/openclaw/openclaw:"));
1562
+ if (official)
1563
+ return official;
1564
+ const legacy = lines.find(l => /^jishushell-base:/i.test(l));
1565
+ if (legacy)
1566
+ return legacy;
1567
+ }
1568
+ catch { }
1569
+ return getOpenclawDockerImage();
1570
+ }
1571
+ // Base image and mirror list for the OpenClaw Docker build.
1572
+ // Mirrors are tried in order when docker.io is unreachable (e.g. behind GFW or rate-limited).
1573
+ const DOCKER_BASE_IMAGE = "node:22-slim";
1574
+ const DOCKER_BASE_MIRRORS = [
1575
+ "node:22-slim",
1576
+ "hub-mirror.c.163.com/library/node:22-slim",
1577
+ "mirrors.tencent.com/library/node:22-slim",
1578
+ "registry.cn-hangzhou.aliyuncs.com/library/node:22-slim",
1579
+ ];
1580
+ // Pull DOCKER_BASE_IMAGE from mirrors if not already cached locally.
1581
+ // Returns the image content digest (sha256:…) so that the subsequent
1582
+ // docker build can use `FROM <digest>`, which BuildKit resolves from
1583
+ // the local daemon without any outbound registry metadata request.
1584
+ async function ensureDockerBaseImage(invocation, task) {
1585
+ // Fast path: already in local daemon cache — return image name (not digest) for Dockerfile FROM
1586
+ try {
1587
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "image", "inspect", DOCKER_BASE_IMAGE], {
1588
+ timeout: 5000,
1589
+ stdio: "ignore",
1590
+ });
1591
+ emitTask(task, { type: "log", message: `基础镜像已缓存: ${DOCKER_BASE_IMAGE}` });
1592
+ return DOCKER_BASE_IMAGE;
1593
+ }
1594
+ catch { /* not cached, fall through */ }
1595
+ for (const mirror of DOCKER_BASE_MIRRORS) {
1596
+ emitTask(task, { type: "log", message: `拉取基础镜像: ${mirror} ...` });
1597
+ const result = await spawnWithTask(task, invocation.cmd, [...invocation.argsPrefix, "pull", mirror], { timeout: 300000 });
1598
+ if (result.ok) {
1599
+ if (mirror !== DOCKER_BASE_IMAGE) {
1600
+ try {
1601
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "tag", mirror, DOCKER_BASE_IMAGE], { timeout: 10000 });
1602
+ }
1603
+ catch { /* tag failure is non-fatal */ }
1604
+ }
1605
+ emitTask(task, { type: "log", message: `基础镜像就绪: ${DOCKER_BASE_IMAGE}` });
1606
+ return DOCKER_BASE_IMAGE;
1607
+ }
1608
+ emitTask(task, { type: "log", message: ` → ${mirror} 不可达,尝试下一个镜像源...` });
1609
+ }
1610
+ throw new Error(`无法获取基础镜像 ${DOCKER_BASE_IMAGE}。请检查网络或手动执行: docker pull ${DOCKER_BASE_MIRRORS[1]}`);
1611
+ }
1612
+ function resolveVersionedBuildTag() {
1613
+ try {
1614
+ const pkg = join(OPENCLAW_MODULES, "openclaw", "package.json");
1615
+ if (existsSync(pkg)) {
1616
+ const ver = JSON.parse(readFileSync(pkg, "utf-8")).version;
1617
+ if (ver)
1618
+ return `${CUSTOM_IMAGE_PREFIX}:${ver}`;
1619
+ }
1620
+ }
1621
+ catch { }
1622
+ return resolveDockerImageTag();
1623
+ }
1624
+ async function buildOpenclawDockerImageWithTask(task, tag) {
1625
+ const targetTag = tag || resolveVersionedBuildTag();
1626
+ try {
1627
+ const invocation = resolveDockerInvocation();
1628
+ // Fast check: if image with this exact tag exists, skip build (tag encodes version)
1629
+ try {
1630
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "image", "inspect", targetTag], {
1631
+ timeout: 10000,
1632
+ stdio: "ignore",
1633
+ });
1634
+ setOpenclawDockerImage(targetTag);
1635
+ emitTask(task, { type: "done", message: `Docker 镜像已存在: ${targetTag}`, progress: 100 });
1636
+ task.status = "done";
1637
+ return { ok: true, message: `Docker image ${targetTag} already exists`, taskId: task.id };
1638
+ }
1639
+ catch { /* image not found, proceed to build */ }
1640
+ // Clean up old openclaw:* images that predate the new slim-base architecture.
1641
+ // Skip if the stored tag matches targetTag or is a remote registry image.
1642
+ const oldTag = getPanelConfig().openclaw_image;
1643
+ if (oldTag && oldTag !== targetTag && !oldTag.includes("/") && /^openclaw:/i.test(oldTag)) {
1644
+ try {
1645
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "rmi", oldTag], { timeout: 15000, stdio: "ignore" });
1646
+ }
1647
+ catch { }
1648
+ }
1649
+ // Ensure node:22-slim base image is in the local daemon cache before building.
1650
+ emitTask(task, { type: "progress", message: "准备基础镜像...", progress: 5 });
1651
+ const baseImageId = await ensureDockerBaseImage(invocation, task);
1652
+ // Slim Dockerfile: no COPY of openclaw node_modules — the binary is bind-mounted
1653
+ // from the host at runtime, so this image needs no OpenClaw-specific layers.
1654
+ // Build context is an empty temp directory (no files to COPY).
1655
+ const dockerfile = `FROM ${baseImageId}
1656
+ RUN apt-get update && apt-get install -y --no-install-recommends \\
1657
+ ca-certificates curl \\
1658
+ python3 python3-pip python3-venv python3-dev && \\
1659
+ ln -sf /usr/bin/python3 /usr/local/bin/python && \\
1660
+ rm -rf /var/lib/apt/lists/*
1661
+ WORKDIR /data
1662
+ USER node
1663
+ ENTRYPOINT ["node", "/usr/lib/node_modules/openclaw/openclaw.mjs"]
1664
+ CMD ["gateway", "run", "--port", "18789", "--allow-unconfigured"]
1665
+ `;
1666
+ // Use a temp dir as build context — no files to COPY means no large transfer.
1667
+ const buildDir = join(tmpdir(), `jishushell-base-build-${Date.now()}`);
1668
+ mkdirSync(buildDir, { recursive: true });
1669
+ const dockerfilePath = join(buildDir, "Dockerfile");
1670
+ writeFileSync(dockerfilePath, dockerfile);
1671
+ emitTask(task, { type: "progress", message: `构建基础镜像 ${targetTag}(无需拷贝二进制,速度极快)...`, progress: 10 });
1672
+ let result;
1673
+ try {
1674
+ result = await spawnWithTask(task, invocation.cmd, [...invocation.argsPrefix, "build", "--network=host", "-t", targetTag, buildDir], {
1675
+ timeout: 1800000,
1676
+ progressParser: dockerBuildProgressParser,
1677
+ env: {},
1678
+ });
1679
+ }
1680
+ finally {
1681
+ // Always clean up temp build dir
1682
+ try {
1683
+ execSync(`rm -rf "${buildDir}"`, { timeout: 5000 });
1684
+ }
1685
+ catch { }
1686
+ }
1687
+ if (!result.ok) {
1688
+ // Clean up dangling images from failed build
1689
+ try {
1690
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "image", "prune", "-f"], { timeout: 15000, stdio: "ignore" });
1691
+ }
1692
+ catch { }
1693
+ emitTask(task, { type: "error", message: "Docker 镜像构建失败" });
1694
+ task.status = "error";
1695
+ return { ok: false, message: "Docker image build failed", error: result.output, taskId: task.id };
1696
+ }
1697
+ const localTag = `${CUSTOM_IMAGE_PREFIX}:local`;
1698
+ if (targetTag !== localTag) {
1699
+ try {
1700
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "tag", targetTag, localTag], { timeout: 10000, stdio: "ignore" });
1701
+ }
1702
+ catch { }
1703
+ }
1704
+ setOpenclawDockerImage(localTag);
1705
+ emitTask(task, { type: "done", message: `Docker 镜像构建完成: ${targetTag}`, progress: 100 });
1706
+ task.status = "done";
1707
+ return { ok: true, message: `Docker image ${targetTag} built`, taskId: task.id };
1708
+ }
1709
+ catch (e) {
1710
+ // Clean up build artifacts on unexpected errors
1711
+ try {
1712
+ execSync("docker image prune -f", { timeout: 15000, stdio: "ignore" });
1713
+ }
1714
+ catch { }
1715
+ emitTask(task, { type: "error", message: `Docker 镜像构建失败: ${e.message}` });
1716
+ task.status = "error";
1717
+ return { ok: false, message: "Docker image build failed", error: e.message, taskId: task.id };
1718
+ }
1719
+ }
1720
+ export async function buildOpenclawDockerImage(tag) {
1721
+ const task = createTask("openclaw-docker");
1722
+ return buildOpenclawDockerImageWithTask(task, tag);
1723
+ }
1724
+ export function startBuildOpenclawDockerImage(tag) {
1725
+ const task = createTask("openclaw-docker");
1726
+ void buildOpenclawDockerImageWithTask(task, tag).catch((err) => {
1727
+ emitTask(task, { type: "error", message: `Docker 镜像构建失败: ${err?.message || err}` });
1728
+ task.status = "error";
1729
+ });
1730
+ return { ok: true, message: "Docker image build started", taskId: task.id };
1731
+ }
1732
+ // ── Build OpenClaw Docker image from npm package + Python ─────────
1733
+ async function buildCustomOpenclawImageWithTask(task, tag) {
1734
+ const targetTag = tag || DEFAULT_OPENCLAW_DOCKER_IMAGE;
1735
+ try {
1736
+ const invocation = resolveDockerInvocation();
1737
+ // Fast check: if image already exists locally, skip
1738
+ try {
1739
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "image", "inspect", targetTag], {
1740
+ timeout: 10000,
1741
+ stdio: "ignore",
1742
+ });
1743
+ setOpenclawDockerImage(targetTag);
1744
+ emitTask(task, { type: "done", message: `Docker 镜像已存在: ${targetTag}`, progress: 100 });
1745
+ task.status = "done";
1746
+ return { ok: true, message: `Docker image ${targetTag} already exists`, taskId: task.id };
1747
+ }
1748
+ catch { /* image not found, proceed */ }
1749
+ // Clean up old legacy images
1750
+ const oldTag = getPanelConfig().openclaw_image;
1751
+ if (oldTag && oldTag !== targetTag && /^jishushell-base:/i.test(oldTag)) {
1752
+ try {
1753
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "rmi", oldTag], { timeout: 15000, stdio: "ignore" });
1754
+ }
1755
+ catch { }
1756
+ }
1757
+ // Step 1: Ensure OpenClaw npm package is installed (used as build context)
1758
+ const openclawPkgDir = join(OPENCLAW_MODULES, "openclaw");
1759
+ if (!existsSync(openclawPkgDir)) {
1760
+ emitTask(task, { type: "progress", message: "安装 OpenClaw npm 包...", progress: 5 });
1761
+ const installResult = await installOpenclaw();
1762
+ if (!installResult.ok) {
1763
+ emitTask(task, { type: "error", message: "OpenClaw 安装失败" });
1764
+ task.status = "error";
1765
+ return { ok: false, message: "OpenClaw npm install failed", error: installResult.error, taskId: task.id };
1766
+ }
1767
+ }
1768
+ // Step 2: Ensure base image is available
1769
+ emitTask(task, { type: "progress", message: "准备基础镜像...", progress: 10 });
1770
+ const baseImageId = await ensureDockerBaseImage(invocation, task);
1771
+ // Step 3: Build image — COPY node_modules + Python
1772
+ emitTask(task, { type: "progress", message: `构建 OpenClaw 镜像(含 Python): ${targetTag} ...`, progress: 30 });
1773
+ const dockerfile = `FROM ${baseImageId}
1774
+ USER root
1775
+ RUN apt-get update && apt-get install -y --no-install-recommends \\
1776
+ procps hostname curl git lsof openssl \\
1777
+ python3 python3-pip python3-venv python3-dev && \\
1778
+ ln -sf /usr/bin/python3 /usr/local/bin/python && \\
1779
+ rm -rf /var/lib/apt/lists/*
1780
+ WORKDIR /app
1781
+ COPY --chown=node:node lib/node_modules/ ./node_modules/
1782
+ RUN ln -sf /app/node_modules/openclaw/openclaw.mjs /app/openclaw.mjs && \\
1783
+ ln -sf /app/node_modules/openclaw/openclaw.mjs /usr/local/bin/openclaw && \\
1784
+ cp /app/node_modules/openclaw/package.json /app/package.json 2>/dev/null || true
1785
+ USER node
1786
+ CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
1787
+ `;
1788
+ // Write Dockerfile into the npm package directory (build context)
1789
+ const dockerfilePath = join(OPENCLAW_PKG_DIR, "Dockerfile");
1790
+ writeFileSync(dockerfilePath, dockerfile);
1791
+ let buildResult;
1792
+ try {
1793
+ buildResult = await spawnWithTask(task, invocation.cmd, [...invocation.argsPrefix, "build", "--network=host", "-t", targetTag, OPENCLAW_PKG_DIR], { timeout: 1800000, progressParser: dockerBuildProgressParser });
1794
+ }
1795
+ finally {
1796
+ try {
1797
+ unlinkSync(dockerfilePath);
1798
+ }
1799
+ catch { }
1800
+ }
1801
+ if (!buildResult.ok) {
1802
+ try {
1803
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "image", "prune", "-f"], { timeout: 15000, stdio: "ignore" });
1804
+ }
1805
+ catch { }
1806
+ emitTask(task, { type: "error", message: "Docker 镜像构建失败" });
1807
+ task.status = "error";
1808
+ return { ok: false, message: "Docker image build failed", error: buildResult.output, taskId: task.id };
1809
+ }
1810
+ setOpenclawDockerImage(targetTag);
1811
+ emitTask(task, { type: "done", message: `OpenClaw 镜像就绪: ${targetTag}(含 Python)`, progress: 100 });
1812
+ task.status = "done";
1813
+ return { ok: true, message: `Docker image ${targetTag} built`, taskId: task.id };
1814
+ }
1815
+ catch (e) {
1816
+ emitTask(task, { type: "error", message: `镜像构建失败: ${e.message}` });
1817
+ task.status = "error";
1818
+ return { ok: false, message: "Docker image build failed", error: e.message, taskId: task.id };
1819
+ }
1820
+ }
1821
+ export async function buildCustomOpenclawImage(tag) {
1822
+ const task = createTask("openclaw-docker-build");
1823
+ return buildCustomOpenclawImageWithTask(task, tag);
1824
+ }
1825
+ export function startBuildCustomOpenclawImage(tag) {
1826
+ const task = createTask("openclaw-docker-build");
1827
+ void buildCustomOpenclawImageWithTask(task, tag).catch((err) => {
1828
+ emitTask(task, { type: "error", message: `镜像构建失败: ${err?.message || err}` });
1829
+ task.status = "error";
1830
+ });
1831
+ return { ok: true, message: "Docker image build started", taskId: task.id };
1832
+ }
1833
+ export async function runFullSetup(options = {}) {
1834
+ const steps = [];
1835
+ let allOk = true;
1836
+ const defaults = {
1837
+ installNomad: true,
1838
+ installOpenclaw: true,
1839
+ buildDockerImage: true,
1840
+ ...options,
1841
+ };
1842
+ // Check Docker by daemon accessibility (docker info), not just binary existence
1843
+ const dockerReady = canAccessDockerDaemon(10000);
1844
+ if (!dockerReady) {
1845
+ steps.push({ step: "docker", status: "running", message: "Installing Docker..." });
1846
+ const result = await installDocker();
1847
+ steps[steps.length - 1].status = result.ok ? "done" : "error";
1848
+ steps[steps.length - 1].message = result.message;
1849
+ if (!result.ok)
1850
+ allOk = false;
1851
+ }
1852
+ if (defaults.installNomad) {
1853
+ steps.push({ step: "nomad", status: "running", message: "Installing Nomad..." });
1854
+ const result = await installNomad();
1855
+ steps[steps.length - 1].status = result.ok ? "done" : "error";
1856
+ steps[steps.length - 1].message = result.message;
1857
+ if (!result.ok)
1858
+ allOk = false;
1859
+ if (result.ok) {
1860
+ steps.push({ step: "nomad-systemd", status: "running", message: "Configuring Nomad auto-start..." });
1861
+ const sysResult = installNomadSystemd();
1862
+ steps[steps.length - 1].status = sysResult.ok ? "done" : "error";
1863
+ steps[steps.length - 1].message = sysResult.message;
1864
+ if (!sysResult.ok) {
1865
+ // Fallback to manual start if systemd fails
1866
+ steps.push({ step: "nomad-start", status: "running", message: "Starting Nomad..." });
1867
+ const startResult = await startNomad();
1868
+ steps[steps.length - 1].status = startResult.ok ? "done" : "error";
1869
+ steps[steps.length - 1].message = startResult.message;
1870
+ if (!startResult.ok)
1871
+ allOk = false;
1872
+ }
1873
+ }
1874
+ }
1875
+ if (defaults.installOpenclaw) {
1876
+ steps.push({ step: "openclaw", status: "running", message: "Installing OpenClaw..." });
1877
+ const result = await installOpenclaw();
1878
+ steps[steps.length - 1].status = result.ok ? "done" : "error";
1879
+ steps[steps.length - 1].message = result.message;
1880
+ if (!result.ok)
1881
+ allOk = false;
1882
+ }
1883
+ // Prepare Docker image: pull official image or build slim base (legacy).
1884
+ if (defaults.buildDockerImage) {
1885
+ // Restart Nomad so it re-detects Docker driver after Docker was installed
1886
+ try {
1887
+ execSync("sudo systemctl restart nomad 2>/dev/null || true", { timeout: 15000 });
1888
+ for (let i = 0; i < 10; i++) {
1889
+ await new Promise(r => setTimeout(r, 2000));
1890
+ if (isPortListening(4646))
1891
+ break;
1892
+ }
1893
+ }
1894
+ catch { }
1895
+ steps.push({ step: "docker-image", status: "running", message: "Building OpenClaw Docker image..." });
1896
+ const imgResult = isOfficialImage()
1897
+ ? await buildCustomOpenclawImage()
1898
+ : await buildOpenclawDockerImage();
1899
+ steps[steps.length - 1].status = imgResult.ok ? "done" : "error";
1900
+ steps[steps.length - 1].message = imgResult.message;
1901
+ if (!imgResult.ok)
1902
+ allOk = false;
1903
+ }
1904
+ if (isPortListening(4646)) {
1905
+ await finalizeNomadStartup();
1906
+ }
1907
+ // Only mark setup as complete if all critical steps succeeded
1908
+ if (!allOk) {
1909
+ return { ok: false, steps };
1910
+ }
1911
+ const config = getPanelConfig();
1912
+ config.service_manager = "nomad";
1913
+ config.nomad_driver = "docker";
1914
+ savePanelConfig(config);
1915
+ // Install jishushell systemd service (best-effort — does not block setup completion)
1916
+ steps.push({ step: "jishushell-systemd", status: "running", message: "Configuring JishuShell auto-start..." });
1917
+ const jsResult = installJishushellSystemd();
1918
+ steps[steps.length - 1].status = jsResult.ok ? "done" : "error";
1919
+ steps[steps.length - 1].message = jsResult.message;
1920
+ return { ok: allOk, steps };
1921
+ }
1922
+ //# sourceMappingURL=setup-manager.js.map