clay-server 2.5.0
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/LICENSE +21 -0
- package/README.md +281 -0
- package/bin/cli.js +2385 -0
- package/lib/cli-sessions.js +270 -0
- package/lib/config.js +237 -0
- package/lib/daemon.js +489 -0
- package/lib/ipc.js +112 -0
- package/lib/notes.js +120 -0
- package/lib/pages.js +664 -0
- package/lib/project.js +1433 -0
- package/lib/public/app.js +2795 -0
- package/lib/public/apple-touch-icon-dark.png +0 -0
- package/lib/public/apple-touch-icon.png +0 -0
- package/lib/public/css/base.css +264 -0
- package/lib/public/css/diff.css +128 -0
- package/lib/public/css/filebrowser.css +1114 -0
- package/lib/public/css/highlight.css +144 -0
- package/lib/public/css/icon-strip.css +296 -0
- package/lib/public/css/input.css +573 -0
- package/lib/public/css/menus.css +856 -0
- package/lib/public/css/messages.css +1445 -0
- package/lib/public/css/mobile-nav.css +354 -0
- package/lib/public/css/overlays.css +697 -0
- package/lib/public/css/rewind.css +505 -0
- package/lib/public/css/server-settings.css +761 -0
- package/lib/public/css/sidebar.css +936 -0
- package/lib/public/css/sticky-notes.css +358 -0
- package/lib/public/css/title-bar.css +314 -0
- package/lib/public/favicon-dark.svg +1 -0
- package/lib/public/favicon.svg +1 -0
- package/lib/public/icon-192-dark.png +0 -0
- package/lib/public/icon-192.png +0 -0
- package/lib/public/icon-512-dark.png +0 -0
- package/lib/public/icon-512.png +0 -0
- package/lib/public/icon-mono.svg +1 -0
- package/lib/public/index.html +762 -0
- package/lib/public/manifest.json +27 -0
- package/lib/public/modules/diff.js +398 -0
- package/lib/public/modules/events.js +21 -0
- package/lib/public/modules/filebrowser.js +1411 -0
- package/lib/public/modules/fileicons.js +172 -0
- package/lib/public/modules/icons.js +54 -0
- package/lib/public/modules/input.js +584 -0
- package/lib/public/modules/markdown.js +356 -0
- package/lib/public/modules/notifications.js +649 -0
- package/lib/public/modules/qrcode.js +70 -0
- package/lib/public/modules/rewind.js +345 -0
- package/lib/public/modules/server-settings.js +510 -0
- package/lib/public/modules/sidebar.js +1083 -0
- package/lib/public/modules/state.js +3 -0
- package/lib/public/modules/sticky-notes.js +688 -0
- package/lib/public/modules/terminal.js +697 -0
- package/lib/public/modules/theme.js +738 -0
- package/lib/public/modules/tools.js +1608 -0
- package/lib/public/modules/utils.js +56 -0
- package/lib/public/style.css +15 -0
- package/lib/public/sw.js +75 -0
- package/lib/push.js +124 -0
- package/lib/sdk-bridge.js +989 -0
- package/lib/server.js +582 -0
- package/lib/sessions.js +424 -0
- package/lib/terminal-manager.js +187 -0
- package/lib/terminal.js +24 -0
- package/lib/themes/ayu-light.json +9 -0
- package/lib/themes/catppuccin-latte.json +9 -0
- package/lib/themes/catppuccin-mocha.json +9 -0
- package/lib/themes/clay-light.json +10 -0
- package/lib/themes/clay.json +10 -0
- package/lib/themes/dracula.json +9 -0
- package/lib/themes/everforest-light.json +9 -0
- package/lib/themes/everforest.json +9 -0
- package/lib/themes/github-light.json +9 -0
- package/lib/themes/gruvbox-dark.json +9 -0
- package/lib/themes/gruvbox-light.json +9 -0
- package/lib/themes/monokai.json +9 -0
- package/lib/themes/nord-light.json +9 -0
- package/lib/themes/nord.json +9 -0
- package/lib/themes/one-dark.json +9 -0
- package/lib/themes/one-light.json +9 -0
- package/lib/themes/rose-pine-dawn.json +9 -0
- package/lib/themes/rose-pine.json +9 -0
- package/lib/themes/solarized-dark.json +9 -0
- package/lib/themes/solarized-light.json +9 -0
- package/lib/themes/tokyo-night-light.json +9 -0
- package/lib/themes/tokyo-night.json +9 -0
- package/lib/updater.js +97 -0
- package/package.json +47 -0
package/lib/daemon.js
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// --- Node version check ---
|
|
4
|
+
var nodeMajor = parseInt(process.versions.node.split(".")[0], 10);
|
|
5
|
+
if (nodeMajor < 20) {
|
|
6
|
+
console.error("\x1b[31m[clay] Node.js 20+ is required (current: " + process.version + ")\x1b[0m");
|
|
7
|
+
console.error("[clay] The Claude Agent SDK 0.2.40+ requires Node 20 for Symbol.dispose support.");
|
|
8
|
+
console.error("[clay] If you cannot upgrade Node, use claude-relay@2.4.3 which supports Node 18.");
|
|
9
|
+
console.error("");
|
|
10
|
+
console.error(" Upgrade Node: nvm install 22 && nvm use 22");
|
|
11
|
+
console.error(" Or use older: npx claude-relay@2.4.3");
|
|
12
|
+
process.exit(78); // EX_CONFIG — fatal config error, don't auto-restart
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Polyfill Symbol.dispose/asyncDispose if missing (Node 20.x may not have it)
|
|
16
|
+
if (!Symbol.dispose) Symbol.dispose = Symbol("Symbol.dispose");
|
|
17
|
+
if (!Symbol.asyncDispose) Symbol.asyncDispose = Symbol("Symbol.asyncDispose");
|
|
18
|
+
|
|
19
|
+
// Remove CLAUDECODE env var so the SDK can spawn Claude Code child processes
|
|
20
|
+
// (prevents "cannot be launched inside another Claude Code session" error)
|
|
21
|
+
delete process.env.CLAUDECODE;
|
|
22
|
+
|
|
23
|
+
var fs = require("fs");
|
|
24
|
+
var path = require("path");
|
|
25
|
+
var { loadConfig, saveConfig, socketPath, generateSlug, syncClayrc, writeCrashInfo, readCrashInfo, clearCrashInfo } = require("./config");
|
|
26
|
+
var { createIPCServer } = require("./ipc");
|
|
27
|
+
var { createServer, generateAuthToken } = require("./server");
|
|
28
|
+
|
|
29
|
+
var configFile = process.env.CLAY_CONFIG || process.env.CLAUDE_RELAY_CONFIG || require("./config").configPath();
|
|
30
|
+
var config;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
config = JSON.parse(fs.readFileSync(configFile, "utf8"));
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.error("[daemon] Failed to read config:", e.message);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- TLS ---
|
|
40
|
+
var tlsOptions = null;
|
|
41
|
+
if (config.tls) {
|
|
42
|
+
var os = require("os");
|
|
43
|
+
var certDir = path.join(process.env.CLAY_HOME || process.env.CLAUDE_RELAY_HOME || path.join(os.homedir(), ".clay"), "certs");
|
|
44
|
+
var keyPath = path.join(certDir, "key.pem");
|
|
45
|
+
var certPath = path.join(certDir, "cert.pem");
|
|
46
|
+
try {
|
|
47
|
+
tlsOptions = {
|
|
48
|
+
key: fs.readFileSync(keyPath),
|
|
49
|
+
cert: fs.readFileSync(certPath),
|
|
50
|
+
};
|
|
51
|
+
} catch (e) {
|
|
52
|
+
console.error("[daemon] TLS cert not found, falling back to HTTP");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
var caRoot = null;
|
|
57
|
+
try {
|
|
58
|
+
var { execSync } = require("child_process");
|
|
59
|
+
caRoot = path.join(
|
|
60
|
+
execSync("mkcert -CAROOT", { encoding: "utf8" }).trim(),
|
|
61
|
+
"rootCA.pem"
|
|
62
|
+
);
|
|
63
|
+
if (!fs.existsSync(caRoot)) caRoot = null;
|
|
64
|
+
} catch (e) {}
|
|
65
|
+
|
|
66
|
+
// --- Resolve LAN IP for share URL ---
|
|
67
|
+
var os2 = require("os");
|
|
68
|
+
var lanIp = (function () {
|
|
69
|
+
var ifaces = os2.networkInterfaces();
|
|
70
|
+
for (var addrs of Object.values(ifaces)) {
|
|
71
|
+
for (var i = 0; i < addrs.length; i++) {
|
|
72
|
+
if (addrs[i].family === "IPv4" && !addrs[i].internal && addrs[i].address.startsWith("100.")) return addrs[i].address;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (var addrs of Object.values(ifaces)) {
|
|
76
|
+
for (var i = 0; i < addrs.length; i++) {
|
|
77
|
+
if (addrs[i].family === "IPv4" && !addrs[i].internal) return addrs[i].address;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
})();
|
|
82
|
+
|
|
83
|
+
// --- Create multi-project server ---
|
|
84
|
+
var relay = createServer({
|
|
85
|
+
tlsOptions: tlsOptions,
|
|
86
|
+
caPath: caRoot,
|
|
87
|
+
pinHash: config.pinHash || null,
|
|
88
|
+
port: config.port,
|
|
89
|
+
debug: config.debug || false,
|
|
90
|
+
dangerouslySkipPermissions: config.dangerouslySkipPermissions || false,
|
|
91
|
+
lanHost: lanIp ? lanIp + ":" + config.port : null,
|
|
92
|
+
onAddProject: function (absPath) {
|
|
93
|
+
// Check if already registered
|
|
94
|
+
for (var j = 0; j < config.projects.length; j++) {
|
|
95
|
+
if (config.projects[j].path === absPath) {
|
|
96
|
+
return { ok: true, slug: config.projects[j].slug, existing: true };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
var slugs = config.projects.map(function (p) { return p.slug; });
|
|
100
|
+
var slug = generateSlug(absPath, slugs);
|
|
101
|
+
relay.addProject(absPath, slug);
|
|
102
|
+
config.projects.push({ path: absPath, slug: slug, addedAt: Date.now() });
|
|
103
|
+
saveConfig(config);
|
|
104
|
+
try { syncClayrc(config.projects); } catch (e) {}
|
|
105
|
+
console.log("[daemon] Added project (web):", slug, "→", absPath);
|
|
106
|
+
// Broadcast updated project list to all clients
|
|
107
|
+
relay.broadcastAll({
|
|
108
|
+
type: "projects_updated",
|
|
109
|
+
projects: relay.getProjects(),
|
|
110
|
+
projectCount: config.projects.length,
|
|
111
|
+
});
|
|
112
|
+
return { ok: true, slug: slug };
|
|
113
|
+
},
|
|
114
|
+
onRemoveProject: function (slug) {
|
|
115
|
+
var found = false;
|
|
116
|
+
for (var j = 0; j < config.projects.length; j++) {
|
|
117
|
+
if (config.projects[j].slug === slug) { found = true; break; }
|
|
118
|
+
}
|
|
119
|
+
if (!found) return { ok: false, error: "Project not found" };
|
|
120
|
+
relay.removeProject(slug);
|
|
121
|
+
config.projects = config.projects.filter(function (p) { return p.slug !== slug; });
|
|
122
|
+
saveConfig(config);
|
|
123
|
+
try { syncClayrc(config.projects); } catch (e) {}
|
|
124
|
+
console.log("[daemon] Removed project (web):", slug);
|
|
125
|
+
relay.broadcastAll({
|
|
126
|
+
type: "projects_updated",
|
|
127
|
+
projects: relay.getProjects(),
|
|
128
|
+
projectCount: config.projects.length,
|
|
129
|
+
});
|
|
130
|
+
return { ok: true };
|
|
131
|
+
},
|
|
132
|
+
onGetDaemonConfig: function () {
|
|
133
|
+
return {
|
|
134
|
+
port: config.port,
|
|
135
|
+
tls: !!tlsOptions,
|
|
136
|
+
debug: !!config.debug,
|
|
137
|
+
keepAwake: !!config.keepAwake,
|
|
138
|
+
pinEnabled: !!config.pinHash,
|
|
139
|
+
platform: process.platform,
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
onSetPin: function (pin) {
|
|
143
|
+
if (pin) {
|
|
144
|
+
config.pinHash = generateAuthToken(pin);
|
|
145
|
+
} else {
|
|
146
|
+
config.pinHash = null;
|
|
147
|
+
}
|
|
148
|
+
relay.setAuthToken(config.pinHash);
|
|
149
|
+
saveConfig(config);
|
|
150
|
+
console.log("[daemon] PIN", pin ? "set" : "removed", "(web)");
|
|
151
|
+
return { ok: true, pinEnabled: !!config.pinHash };
|
|
152
|
+
},
|
|
153
|
+
onSetKeepAwake: function (value) {
|
|
154
|
+
var want = !!value;
|
|
155
|
+
config.keepAwake = want;
|
|
156
|
+
saveConfig(config);
|
|
157
|
+
if (want && !caffeinateProc && process.platform === "darwin") {
|
|
158
|
+
try {
|
|
159
|
+
var { spawn: spawnCaff } = require("child_process");
|
|
160
|
+
caffeinateProc = spawnCaff("caffeinate", ["-di"], { stdio: "ignore", detached: false });
|
|
161
|
+
caffeinateProc.on("error", function () { caffeinateProc = null; });
|
|
162
|
+
} catch (e) {}
|
|
163
|
+
} else if (!want && caffeinateProc) {
|
|
164
|
+
try { caffeinateProc.kill(); } catch (e) {}
|
|
165
|
+
caffeinateProc = null;
|
|
166
|
+
}
|
|
167
|
+
console.log("[daemon] Keep awake:", want, "(web)");
|
|
168
|
+
return { ok: true, keepAwake: want };
|
|
169
|
+
},
|
|
170
|
+
onShutdown: function () {
|
|
171
|
+
console.log("[daemon] Shutdown requested via web UI");
|
|
172
|
+
gracefulShutdown();
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// --- Register projects ---
|
|
177
|
+
var projects = config.projects || [];
|
|
178
|
+
for (var i = 0; i < projects.length; i++) {
|
|
179
|
+
var p = projects[i];
|
|
180
|
+
if (fs.existsSync(p.path)) {
|
|
181
|
+
console.log("[daemon] Adding project:", p.slug, "→", p.path);
|
|
182
|
+
relay.addProject(p.path, p.slug, p.title);
|
|
183
|
+
} else {
|
|
184
|
+
console.log("[daemon] Skipping missing project:", p.path);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Sync ~/.clayrc on startup
|
|
189
|
+
try { syncClayrc(config.projects); } catch (e) {}
|
|
190
|
+
|
|
191
|
+
// --- IPC server ---
|
|
192
|
+
var ipc = createIPCServer(socketPath(), function (msg) {
|
|
193
|
+
switch (msg.cmd) {
|
|
194
|
+
case "add_project": {
|
|
195
|
+
if (!msg.path) return { ok: false, error: "missing path" };
|
|
196
|
+
var absPath = path.resolve(msg.path);
|
|
197
|
+
// Check if already registered
|
|
198
|
+
for (var j = 0; j < config.projects.length; j++) {
|
|
199
|
+
if (config.projects[j].path === absPath) {
|
|
200
|
+
return { ok: true, slug: config.projects[j].slug, existing: true };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
var slugs = config.projects.map(function (p) { return p.slug; });
|
|
204
|
+
var slug = generateSlug(absPath, slugs);
|
|
205
|
+
relay.addProject(absPath, slug);
|
|
206
|
+
config.projects.push({ path: absPath, slug: slug, addedAt: Date.now() });
|
|
207
|
+
saveConfig(config);
|
|
208
|
+
try { syncClayrc(config.projects); } catch (e) {}
|
|
209
|
+
console.log("[daemon] Added project:", slug, "→", absPath);
|
|
210
|
+
return { ok: true, slug: slug };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
case "remove_project": {
|
|
214
|
+
if (!msg.path && !msg.slug) return { ok: false, error: "missing path or slug" };
|
|
215
|
+
var target = msg.slug;
|
|
216
|
+
if (!target) {
|
|
217
|
+
var abs = path.resolve(msg.path);
|
|
218
|
+
for (var k = 0; k < config.projects.length; k++) {
|
|
219
|
+
if (config.projects[k].path === abs) {
|
|
220
|
+
target = config.projects[k].slug;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (!target) return { ok: false, error: "project not found" };
|
|
226
|
+
relay.removeProject(target);
|
|
227
|
+
config.projects = config.projects.filter(function (p) { return p.slug !== target; });
|
|
228
|
+
saveConfig(config);
|
|
229
|
+
try { syncClayrc(config.projects); } catch (e) {}
|
|
230
|
+
console.log("[daemon] Removed project:", target);
|
|
231
|
+
return { ok: true };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
case "get_status":
|
|
235
|
+
return {
|
|
236
|
+
ok: true,
|
|
237
|
+
pid: process.pid,
|
|
238
|
+
port: config.port,
|
|
239
|
+
tls: !!tlsOptions,
|
|
240
|
+
keepAwake: !!config.keepAwake,
|
|
241
|
+
projects: relay.getProjects(),
|
|
242
|
+
uptime: process.uptime(),
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
case "set_pin": {
|
|
246
|
+
config.pinHash = msg.pinHash || null;
|
|
247
|
+
relay.setAuthToken(config.pinHash);
|
|
248
|
+
saveConfig(config);
|
|
249
|
+
return { ok: true };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
case "set_project_title": {
|
|
253
|
+
if (!msg.slug) return { ok: false, error: "missing slug" };
|
|
254
|
+
var newTitle = msg.title || null;
|
|
255
|
+
relay.setProjectTitle(msg.slug, newTitle);
|
|
256
|
+
for (var ti = 0; ti < config.projects.length; ti++) {
|
|
257
|
+
if (config.projects[ti].slug === msg.slug) {
|
|
258
|
+
if (newTitle) {
|
|
259
|
+
config.projects[ti].title = newTitle;
|
|
260
|
+
} else {
|
|
261
|
+
delete config.projects[ti].title;
|
|
262
|
+
}
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
saveConfig(config);
|
|
267
|
+
try { syncClayrc(config.projects); } catch (e) {}
|
|
268
|
+
console.log("[daemon] Project title:", msg.slug, "→", newTitle || "(default)");
|
|
269
|
+
return { ok: true };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
case "set_keep_awake": {
|
|
273
|
+
var want = !!msg.value;
|
|
274
|
+
config.keepAwake = want;
|
|
275
|
+
saveConfig(config);
|
|
276
|
+
if (want && !caffeinateProc && process.platform === "darwin") {
|
|
277
|
+
try {
|
|
278
|
+
var { spawn: spawnCaff } = require("child_process");
|
|
279
|
+
caffeinateProc = spawnCaff("caffeinate", ["-di"], { stdio: "ignore", detached: false });
|
|
280
|
+
caffeinateProc.on("error", function () { caffeinateProc = null; });
|
|
281
|
+
} catch (e) {}
|
|
282
|
+
} else if (!want && caffeinateProc) {
|
|
283
|
+
try { caffeinateProc.kill(); } catch (e) {}
|
|
284
|
+
caffeinateProc = null;
|
|
285
|
+
}
|
|
286
|
+
console.log("[daemon] Keep awake:", want);
|
|
287
|
+
return { ok: true };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
case "shutdown":
|
|
291
|
+
console.log("[daemon] Shutdown requested via IPC");
|
|
292
|
+
gracefulShutdown();
|
|
293
|
+
return { ok: true };
|
|
294
|
+
|
|
295
|
+
case "update": {
|
|
296
|
+
console.log("[daemon] Update & restart requested via IPC");
|
|
297
|
+
|
|
298
|
+
// Dev mode (config.debug): just exit with code 120, cli.js dev watcher respawns daemon
|
|
299
|
+
if (config.debug) {
|
|
300
|
+
console.log("[daemon] Dev mode — restarting via dev watcher");
|
|
301
|
+
updateHandoff = true;
|
|
302
|
+
setTimeout(function () { gracefulShutdown(); }, 100);
|
|
303
|
+
return { ok: true };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Production: fetch latest via npx, then spawn updated daemon
|
|
307
|
+
var { execSync: execSyncUpd, spawn: spawnUpd } = require("child_process");
|
|
308
|
+
var updDaemonScript;
|
|
309
|
+
try {
|
|
310
|
+
// npx downloads the package and puts a bin symlink; `which` prints its path
|
|
311
|
+
var binPath = execSyncUpd(
|
|
312
|
+
"npx --yes --package=clay-server@latest -- which clay-server",
|
|
313
|
+
{ stdio: ["ignore", "pipe", "pipe"], timeout: 120000, encoding: "utf8" }
|
|
314
|
+
).trim();
|
|
315
|
+
// Resolve symlink to get the actual package directory
|
|
316
|
+
var realBin = fs.realpathSync(binPath);
|
|
317
|
+
updDaemonScript = path.join(path.dirname(realBin), "..", "lib", "daemon.js");
|
|
318
|
+
updDaemonScript = path.resolve(updDaemonScript);
|
|
319
|
+
console.log("[daemon] Resolved updated daemon:", updDaemonScript);
|
|
320
|
+
} catch (updErr) {
|
|
321
|
+
console.log("[daemon] npx resolve failed:", updErr.message);
|
|
322
|
+
// Fallback: restart with current code
|
|
323
|
+
updDaemonScript = path.join(__dirname, "daemon.js");
|
|
324
|
+
}
|
|
325
|
+
// Spawn new daemon process — it will retry if port is still in use
|
|
326
|
+
var { logPath: updLogPath, configPath: updConfigPath } = require("./config");
|
|
327
|
+
var updLogFd = fs.openSync(updLogPath(), "a");
|
|
328
|
+
var updChild = spawnUpd(process.execPath, [updDaemonScript], {
|
|
329
|
+
detached: true,
|
|
330
|
+
windowsHide: true,
|
|
331
|
+
stdio: ["ignore", updLogFd, updLogFd],
|
|
332
|
+
env: Object.assign({}, process.env, {
|
|
333
|
+
CLAY_CONFIG: updConfigPath(),
|
|
334
|
+
}),
|
|
335
|
+
});
|
|
336
|
+
updChild.unref();
|
|
337
|
+
fs.closeSync(updLogFd);
|
|
338
|
+
config.pid = updChild.pid;
|
|
339
|
+
saveConfig(config);
|
|
340
|
+
console.log("[daemon] Spawned new daemon (PID " + updChild.pid + "), shutting down...");
|
|
341
|
+
updateHandoff = true;
|
|
342
|
+
setTimeout(function () { gracefulShutdown(); }, 100);
|
|
343
|
+
return { ok: true };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
default:
|
|
347
|
+
return { ok: false, error: "unknown command: " + msg.cmd };
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// --- Start listening (with retry for port-in-use during update handoff) ---
|
|
352
|
+
var listenRetries = 0;
|
|
353
|
+
var MAX_LISTEN_RETRIES = 15;
|
|
354
|
+
|
|
355
|
+
function startListening() {
|
|
356
|
+
relay.server.listen(config.port, function () {
|
|
357
|
+
var protocol = tlsOptions ? "https" : "http";
|
|
358
|
+
console.log("[daemon] Listening on " + protocol + "://0.0.0.0:" + config.port);
|
|
359
|
+
console.log("[daemon] PID:", process.pid);
|
|
360
|
+
console.log("[daemon] Projects:", config.projects.length);
|
|
361
|
+
|
|
362
|
+
// Update PID in config
|
|
363
|
+
config.pid = process.pid;
|
|
364
|
+
saveConfig(config);
|
|
365
|
+
|
|
366
|
+
// Check for crash info from a previous crash and notify clients
|
|
367
|
+
var crashInfo = readCrashInfo();
|
|
368
|
+
if (crashInfo) {
|
|
369
|
+
console.log("[daemon] Recovered from crash at", new Date(crashInfo.time).toISOString());
|
|
370
|
+
console.log("[daemon] Crash reason:", crashInfo.reason);
|
|
371
|
+
// Delay notification so clients have time to reconnect
|
|
372
|
+
setTimeout(function () {
|
|
373
|
+
relay.broadcastAll({
|
|
374
|
+
type: "toast",
|
|
375
|
+
level: "warn",
|
|
376
|
+
message: "Server recovered from a crash and was automatically restarted.",
|
|
377
|
+
detail: crashInfo.reason || null,
|
|
378
|
+
});
|
|
379
|
+
}, 3000);
|
|
380
|
+
clearCrashInfo();
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
relay.server.on("error", function (err) {
|
|
386
|
+
if (err.code === "EADDRINUSE" && listenRetries < MAX_LISTEN_RETRIES) {
|
|
387
|
+
listenRetries++;
|
|
388
|
+
console.log("[daemon] Port " + config.port + " in use, retrying (" + listenRetries + "/" + MAX_LISTEN_RETRIES + ")...");
|
|
389
|
+
setTimeout(startListening, 1000);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
console.error("[daemon] Server error:", err.message);
|
|
393
|
+
writeCrashInfo({
|
|
394
|
+
reason: "Server error: " + err.message,
|
|
395
|
+
pid: process.pid,
|
|
396
|
+
time: Date.now(),
|
|
397
|
+
});
|
|
398
|
+
process.exit(1);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
startListening();
|
|
402
|
+
|
|
403
|
+
// --- HTTP onboarding server (only when TLS is active) ---
|
|
404
|
+
if (relay.onboardingServer) {
|
|
405
|
+
var onboardingPort = config.port + 1;
|
|
406
|
+
relay.onboardingServer.on("error", function (err) {
|
|
407
|
+
console.error("[daemon] Onboarding HTTP server error:", err.message);
|
|
408
|
+
});
|
|
409
|
+
relay.onboardingServer.listen(onboardingPort, function () {
|
|
410
|
+
console.log("[daemon] Onboarding HTTP on http://0.0.0.0:" + onboardingPort);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// --- Caffeinate (macOS) ---
|
|
415
|
+
var caffeinateProc = null;
|
|
416
|
+
if (config.keepAwake && process.platform === "darwin") {
|
|
417
|
+
try {
|
|
418
|
+
var { spawn } = require("child_process");
|
|
419
|
+
caffeinateProc = spawn("caffeinate", ["-di"], { stdio: "ignore", detached: false });
|
|
420
|
+
caffeinateProc.on("error", function () { caffeinateProc = null; });
|
|
421
|
+
} catch (e) {}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// --- Graceful shutdown ---
|
|
425
|
+
var updateHandoff = false; // true when shutting down for update (new daemon already spawned)
|
|
426
|
+
|
|
427
|
+
function gracefulShutdown() {
|
|
428
|
+
console.log("[daemon] Shutting down...");
|
|
429
|
+
var exitCode = updateHandoff ? 120 : 0; // 120 = update handoff, don't auto-restart
|
|
430
|
+
|
|
431
|
+
if (caffeinateProc) {
|
|
432
|
+
try { caffeinateProc.kill(); } catch (e) {}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
ipc.close();
|
|
436
|
+
|
|
437
|
+
// Remove PID from config (skip if update handoff — new daemon PID is already saved)
|
|
438
|
+
if (!updateHandoff) {
|
|
439
|
+
try {
|
|
440
|
+
var c = loadConfig();
|
|
441
|
+
if (c && c.pid === process.pid) {
|
|
442
|
+
delete c.pid;
|
|
443
|
+
saveConfig(c);
|
|
444
|
+
}
|
|
445
|
+
} catch (e) {}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
relay.destroyAll();
|
|
449
|
+
|
|
450
|
+
if (relay.onboardingServer) {
|
|
451
|
+
relay.onboardingServer.close();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
relay.server.close(function () {
|
|
455
|
+
console.log("[daemon] Server closed");
|
|
456
|
+
process.exit(exitCode);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// Force exit after 5 seconds
|
|
460
|
+
setTimeout(function () {
|
|
461
|
+
console.error("[daemon] Forced exit after timeout");
|
|
462
|
+
process.exit(1);
|
|
463
|
+
}, 5000);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
process.on("SIGTERM", gracefulShutdown);
|
|
467
|
+
process.on("SIGINT", gracefulShutdown);
|
|
468
|
+
|
|
469
|
+
// Last-resort cleanup: kill caffeinate if process exits without graceful shutdown
|
|
470
|
+
process.on("exit", function () {
|
|
471
|
+
if (caffeinateProc) {
|
|
472
|
+
try { caffeinateProc.kill(); } catch (e) {}
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// Windows emits SIGHUP when console window closes
|
|
477
|
+
if (process.platform === "win32") {
|
|
478
|
+
process.on("SIGHUP", gracefulShutdown);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
process.on("uncaughtException", function (err) {
|
|
482
|
+
console.error("[daemon] Uncaught exception:", err);
|
|
483
|
+
writeCrashInfo({
|
|
484
|
+
reason: err ? (err.stack || err.message || String(err)) : "unknown",
|
|
485
|
+
pid: process.pid,
|
|
486
|
+
time: Date.now(),
|
|
487
|
+
});
|
|
488
|
+
gracefulShutdown();
|
|
489
|
+
});
|
package/lib/ipc.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
var net = require("net");
|
|
2
|
+
var fs = require("fs");
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create IPC server on a Unix domain socket.
|
|
6
|
+
* handler(msg) should return a response object (or a Promise of one).
|
|
7
|
+
*/
|
|
8
|
+
function createIPCServer(sockPath, handler) {
|
|
9
|
+
// Remove stale socket file (not needed for Windows named pipes)
|
|
10
|
+
if (process.platform !== "win32") {
|
|
11
|
+
try { fs.unlinkSync(sockPath); } catch (e) {}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
var server = net.createServer(function (conn) {
|
|
15
|
+
var buffer = "";
|
|
16
|
+
conn.setEncoding("utf8");
|
|
17
|
+
|
|
18
|
+
conn.on("data", function (chunk) {
|
|
19
|
+
buffer += chunk;
|
|
20
|
+
var lines = buffer.split("\n");
|
|
21
|
+
buffer = lines.pop(); // keep incomplete line
|
|
22
|
+
|
|
23
|
+
for (var i = 0; i < lines.length; i++) {
|
|
24
|
+
if (!lines[i].trim()) continue;
|
|
25
|
+
try {
|
|
26
|
+
var msg = JSON.parse(lines[i]);
|
|
27
|
+
var result = handler(msg);
|
|
28
|
+
// Support both sync and async handlers
|
|
29
|
+
if (result && typeof result.then === "function") {
|
|
30
|
+
(function (c) {
|
|
31
|
+
result.then(function (res) {
|
|
32
|
+
try { c.write(JSON.stringify(res) + "\n"); } catch (e) {}
|
|
33
|
+
}).catch(function (err) {
|
|
34
|
+
try { c.write(JSON.stringify({ ok: false, error: err.message }) + "\n"); } catch (e) {}
|
|
35
|
+
});
|
|
36
|
+
})(conn);
|
|
37
|
+
} else {
|
|
38
|
+
conn.write(JSON.stringify(result) + "\n");
|
|
39
|
+
}
|
|
40
|
+
} catch (e) {
|
|
41
|
+
try { conn.write(JSON.stringify({ ok: false, error: "parse error" }) + "\n"); } catch (e2) {}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
conn.on("error", function () {});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
server.listen(sockPath);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
close: function () {
|
|
53
|
+
server.close();
|
|
54
|
+
if (process.platform !== "win32") {
|
|
55
|
+
try { fs.unlinkSync(sockPath); } catch (e) {}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Send a command to the daemon IPC server and wait for response.
|
|
63
|
+
* Returns a Promise resolving to the parsed response.
|
|
64
|
+
*/
|
|
65
|
+
function sendIPCCommand(sockPath, message) {
|
|
66
|
+
return new Promise(function (resolve) {
|
|
67
|
+
var client = net.connect(sockPath);
|
|
68
|
+
var buffer = "";
|
|
69
|
+
var done = false;
|
|
70
|
+
|
|
71
|
+
var timer = setTimeout(function () {
|
|
72
|
+
if (!done) {
|
|
73
|
+
done = true;
|
|
74
|
+
client.destroy();
|
|
75
|
+
resolve({ ok: false, error: "timeout" });
|
|
76
|
+
}
|
|
77
|
+
}, 3000);
|
|
78
|
+
|
|
79
|
+
client.on("connect", function () {
|
|
80
|
+
client.write(JSON.stringify(message) + "\n");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
client.on("data", function (chunk) {
|
|
84
|
+
buffer += chunk;
|
|
85
|
+
var idx = buffer.indexOf("\n");
|
|
86
|
+
if (idx !== -1 && !done) {
|
|
87
|
+
done = true;
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
try {
|
|
90
|
+
var resp = JSON.parse(buffer.substring(0, idx));
|
|
91
|
+
resolve(resp);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
resolve({ ok: false, error: "invalid response" });
|
|
94
|
+
}
|
|
95
|
+
client.destroy();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
client.on("error", function (err) {
|
|
100
|
+
if (!done) {
|
|
101
|
+
done = true;
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
resolve({ ok: false, error: err.code === "ECONNREFUSED" ? "daemon not responding" : err.message });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
createIPCServer: createIPCServer,
|
|
111
|
+
sendIPCCommand: sendIPCCommand,
|
|
112
|
+
};
|