skydive-cli 0.2.0 → 0.3.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/CHANGELOG.md +42 -0
- package/README.md +114 -22
- package/dist/js/bin.mjs +1261 -128
- package/dist/js/{boot-CGaXUEer.mjs → boot-HAgDg_b4.mjs} +2507 -194
- package/dist/js/{client-B3ZhhF7e.mjs → client-Dc7GZ3PG.mjs} +148 -8
- package/dist/js/client-DuwxEDG4.mjs +5 -0
- package/dist/js/daemon-Ch_MSDmQ.mjs +6 -0
- package/dist/js/daemon-Co4CtpXZ.mjs +607 -0
- package/dist/js/{theme-CuQhvqzN.mjs → install-CMYBFvR2.mjs} +256 -186
- package/dist/js/{print-BZH0b3KB.mjs → print-CPCFci60.mjs} +2 -2
- package/dist/js/{print-Bin4u16d.mjs → print-Cd-bVSEJ.mjs} +224 -10
- package/dist/js/{print-share-D7OSxvE2.mjs → print-share-7qfpQtWK.mjs} +3 -3
- package/dist/js/{raw-pty-GAvxm2ol.mjs → raw-pty-BcjbTjHJ.mjs} +1 -1
- package/dist/js/raw-pty-ChUHav4d.mjs +5 -0
- package/dist/js/{rest-CamHVOce.mjs → rest-BlN_uWmL.mjs} +150 -19
- package/dist/js/rest-CDTXCmUb.mjs +4 -0
- package/package.json +3 -1
- package/dist/js/api-CDTKq_5Q.mjs +0 -145
- package/dist/js/client-D6NAkL9e.mjs +0 -6
- package/dist/js/raw-pty-2_VA1kw_.mjs +0 -5
- package/dist/js/rest-COkLEZOB.mjs +0 -4
- package/dist/js/util-CeisaZVY.mjs +0 -15
- /package/dist/js/{client-CpEvH2Pq.mjs → client-DfcJFEbh.mjs} +0 -0
- /package/dist/js/{output-B4cW10Ph.mjs → output-DYzzdXYV.mjs} +0 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { o as isRecord } from "./rest-BlN_uWmL.mjs";
|
|
3
|
+
import { t as PortalClient } from "./client-Dc7GZ3PG.mjs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { connect, createServer } from "node:net";
|
|
10
|
+
import { access, appendFile, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
11
|
+
|
|
12
|
+
//#region src/chat/portal/local-protocol.ts
|
|
13
|
+
/**
|
|
14
|
+
* Local IPC between the portal DAEMON and the `skydive` CLI processes attached
|
|
15
|
+
* to it, on one host.
|
|
16
|
+
*
|
|
17
|
+
* Why a daemon at all: every `skydive` process used to open its OWN portal
|
|
18
|
+
* WebSocket and register under the identical `<host>-cli` machine name. Portal
|
|
19
|
+
* presence is a single key per (org, machineName), so N processes overwrote it
|
|
20
|
+
* on every heartbeat and the server superseded them in turn — the portal
|
|
21
|
+
* flapped "connected / reconnecting" once per heartbeat while never settling.
|
|
22
|
+
*
|
|
23
|
+
* The daemon fixes this by construction: ONE long-lived process per (user,
|
|
24
|
+
* appUrl) owns the single portal WebSocket and is the only writer of the
|
|
25
|
+
* presence key, so there is nothing to fight over. Every `skydive` process is a
|
|
26
|
+
* thin CLIENT: on startup it spawns the daemon if absent, connects to this Unix
|
|
27
|
+
* socket, registers its conversation→cwd binding, and otherwise ignores the
|
|
28
|
+
* portal entirely.
|
|
29
|
+
*
|
|
30
|
+
* Per-conversation cwd: an agent's `platform portal exec` carries the
|
|
31
|
+
* originating `ANYONE_CONVERSATION_ID`; the server threads it into the `open`
|
|
32
|
+
* directive; the daemon looks the conversation up in its live `conversationId →
|
|
33
|
+
* cwd` map (populated + updated by the attached CLI, including after `!cd`) and
|
|
34
|
+
* runs the child there. So exec for a conversation always lands in that
|
|
35
|
+
* conversation's current shell directory, deterministically, regardless of how
|
|
36
|
+
* many terminals are open.
|
|
37
|
+
*
|
|
38
|
+
* Wire format: newline-delimited JSON, one object per line, both directions —
|
|
39
|
+
* the same shape as the cmux control socket already used elsewhere in the CLI.
|
|
40
|
+
*/
|
|
41
|
+
/** Bump when the client/daemon message shapes change incompatibly. */
|
|
42
|
+
const LOCAL_PROTOCOL_VERSION = 1;
|
|
43
|
+
/**
|
|
44
|
+
* argv flag that makes the CLI entry run AS the portal daemon instead of
|
|
45
|
+
* dispatching a normal command. `ensureDaemonRunning` respawns this same
|
|
46
|
+
* executable with `<flag> <appUrl>`; `bin.ts` routes it before yargs, mirroring
|
|
47
|
+
* the update-check worker. Kept here so both the spawner and the entry agree.
|
|
48
|
+
*/
|
|
49
|
+
const PORTAL_DAEMON_FLAG = "--skydive-internal-portal-daemon";
|
|
50
|
+
/**
|
|
51
|
+
* Per-user, per-app daemon socket + state paths. Scoped by OS user and a hash
|
|
52
|
+
* of the appUrl so two logins or two deployments (prod vs a PR preview) get
|
|
53
|
+
* distinct daemons and never share a portal identity. Kept under the OS temp
|
|
54
|
+
* dir because Unix socket paths are length-limited (~104 bytes on macOS).
|
|
55
|
+
*/
|
|
56
|
+
function daemonPaths(appUrl) {
|
|
57
|
+
const user = safeSegment(os.userInfo().username || "user");
|
|
58
|
+
const app = createHash("sha256").update(appUrl).digest("hex").slice(0, 12);
|
|
59
|
+
const dir = path.join(os.tmpdir(), `skydive-portal-${user}-${app}`);
|
|
60
|
+
return {
|
|
61
|
+
dir,
|
|
62
|
+
socketPath: path.join(dir, "daemon.sock"),
|
|
63
|
+
statePath: path.join(dir, "state.json"),
|
|
64
|
+
logPath: path.join(dir, "daemon.log")
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function safeSegment(value) {
|
|
68
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 24) || "user";
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* `hello` — a CLI attaches. `sessionId` is unique per CLI process; `token` is
|
|
72
|
+
* the CLI's session token, which the daemon needs to mint the portal device
|
|
73
|
+
* token (the daemon is spawned lazily and holds no session of its own). The
|
|
74
|
+
* daemon uses the FIRST hello's token to connect and refreshes from later
|
|
75
|
+
* hellos so a long-lived daemon can outlive the CLI that spawned it.
|
|
76
|
+
*/
|
|
77
|
+
const clientHelloSchema = z.object({
|
|
78
|
+
t: z.literal("hello"),
|
|
79
|
+
v: z.number(),
|
|
80
|
+
sessionId: z.string().min(1),
|
|
81
|
+
token: z.string().min(1)
|
|
82
|
+
});
|
|
83
|
+
/**
|
|
84
|
+
* `bind` — associate a conversation with a working directory. Sent on chat
|
|
85
|
+
* start and re-sent whenever the conversation's shell cwd changes (e.g. after
|
|
86
|
+
* `!cd`), so the daemon always runs that conversation's exec in the right
|
|
87
|
+
* place. `conversationId` may be null for a brand-new conversation with no id
|
|
88
|
+
* yet; the CLI re-binds once the server assigns one.
|
|
89
|
+
*/
|
|
90
|
+
const clientBindSchema = z.object({
|
|
91
|
+
t: z.literal("bind"),
|
|
92
|
+
sessionId: z.string().min(1),
|
|
93
|
+
conversationId: z.string().min(1),
|
|
94
|
+
cwd: z.string().min(1)
|
|
95
|
+
});
|
|
96
|
+
/** `enable`/`disable` — turn machine sharing on/off (user-initiated). */
|
|
97
|
+
const clientEnableSchema = z.object({ t: z.literal("enable") });
|
|
98
|
+
const clientDisableSchema = z.object({ t: z.literal("disable") });
|
|
99
|
+
/** `grant` — authorize one agent to reach this machine (user-initiated). */
|
|
100
|
+
const clientGrantSchema = z.object({
|
|
101
|
+
t: z.literal("grant"),
|
|
102
|
+
agentId: z.string().min(1)
|
|
103
|
+
});
|
|
104
|
+
/** `bye` — a CLI detaches cleanly (its conversations drop from the map). */
|
|
105
|
+
const clientByeSchema = z.object({
|
|
106
|
+
t: z.literal("bye"),
|
|
107
|
+
sessionId: z.string().min(1)
|
|
108
|
+
});
|
|
109
|
+
/**
|
|
110
|
+
* `status` — an operator control request (`skydive portal daemon status`). The
|
|
111
|
+
* daemon replies with a single `status_result` and the connection closes; it is
|
|
112
|
+
* not a full attach, so it carries no sessionId and never becomes a client.
|
|
113
|
+
*/
|
|
114
|
+
const clientStatusSchema = z.object({ t: z.literal("status") });
|
|
115
|
+
/**
|
|
116
|
+
* `shutdown` — an operator hard-takedown (`skydive portal daemon stop`). The
|
|
117
|
+
* daemon closes the portal connection, disconnects every attached client, and
|
|
118
|
+
* exits — cutting live exec/tunnel connections, not just refusing new ones.
|
|
119
|
+
*/
|
|
120
|
+
const clientShutdownSchema = z.object({ t: z.literal("shutdown") });
|
|
121
|
+
const clientMessageSchema = z.discriminatedUnion("t", [
|
|
122
|
+
clientHelloSchema,
|
|
123
|
+
clientBindSchema,
|
|
124
|
+
clientEnableSchema,
|
|
125
|
+
clientDisableSchema,
|
|
126
|
+
clientGrantSchema,
|
|
127
|
+
clientByeSchema,
|
|
128
|
+
clientStatusSchema,
|
|
129
|
+
clientShutdownSchema
|
|
130
|
+
]);
|
|
131
|
+
/**
|
|
132
|
+
* `state` — the shared portal status, pushed to every attached client so each
|
|
133
|
+
* process shows the same connected/error/grant state the daemon holds.
|
|
134
|
+
*/
|
|
135
|
+
const daemonStateSchema = z.object({
|
|
136
|
+
t: z.literal("state"),
|
|
137
|
+
status: z.enum([
|
|
138
|
+
"off",
|
|
139
|
+
"connecting",
|
|
140
|
+
"connected",
|
|
141
|
+
"error"
|
|
142
|
+
]),
|
|
143
|
+
machineName: z.string(),
|
|
144
|
+
friendlyName: z.string(),
|
|
145
|
+
error: z.string().nullable(),
|
|
146
|
+
grantedAgentIds: z.array(z.string())
|
|
147
|
+
});
|
|
148
|
+
/** `hello_ok` — the daemon accepts an attach and reports its protocol version. */
|
|
149
|
+
const daemonHelloOkSchema = z.object({
|
|
150
|
+
t: z.literal("hello_ok"),
|
|
151
|
+
v: z.number()
|
|
152
|
+
});
|
|
153
|
+
/**
|
|
154
|
+
* `status_result` — the daemon's reply to a `status` control request: a snapshot
|
|
155
|
+
* of everything an operator wants from `skydive portal daemon status` — the
|
|
156
|
+
* portal connection state, how many CLIs are attached, and the conversation→cwd
|
|
157
|
+
* routing map. The daemon sends one and the control connection closes.
|
|
158
|
+
*/
|
|
159
|
+
const daemonStatusResultSchema = z.object({
|
|
160
|
+
t: z.literal("status_result"),
|
|
161
|
+
v: z.number(),
|
|
162
|
+
pid: z.number(),
|
|
163
|
+
appUrl: z.string(),
|
|
164
|
+
portal: z.object({
|
|
165
|
+
status: z.enum([
|
|
166
|
+
"off",
|
|
167
|
+
"connecting",
|
|
168
|
+
"connected",
|
|
169
|
+
"error"
|
|
170
|
+
]),
|
|
171
|
+
machineName: z.string(),
|
|
172
|
+
friendlyName: z.string(),
|
|
173
|
+
error: z.string().nullable(),
|
|
174
|
+
grantedAgentIds: z.array(z.string())
|
|
175
|
+
}),
|
|
176
|
+
clientCount: z.number(),
|
|
177
|
+
cwds: z.record(z.string(), z.string())
|
|
178
|
+
});
|
|
179
|
+
const daemonMessageSchema = z.discriminatedUnion("t", [
|
|
180
|
+
daemonStateSchema,
|
|
181
|
+
daemonHelloOkSchema,
|
|
182
|
+
daemonStatusResultSchema
|
|
183
|
+
]);
|
|
184
|
+
function encodeLine(msg) {
|
|
185
|
+
return `${JSON.stringify(msg)}\n`;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Stateful line splitter: feed raw socket chunks, get back complete JSON lines.
|
|
189
|
+
* A partial trailing line is buffered until its newline arrives.
|
|
190
|
+
*/
|
|
191
|
+
function makeLineParser() {
|
|
192
|
+
let buf = "";
|
|
193
|
+
return (chunk) => {
|
|
194
|
+
buf += chunk;
|
|
195
|
+
const lines = [];
|
|
196
|
+
let nl = buf.indexOf("\n");
|
|
197
|
+
while (nl !== -1) {
|
|
198
|
+
lines.push(buf.slice(0, nl));
|
|
199
|
+
buf = buf.slice(nl + 1);
|
|
200
|
+
nl = buf.indexOf("\n");
|
|
201
|
+
}
|
|
202
|
+
return lines;
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/** Parse one line into a client message, or null if malformed. */
|
|
206
|
+
function parseClientMessage(line) {
|
|
207
|
+
try {
|
|
208
|
+
return clientMessageSchema.parse(JSON.parse(line));
|
|
209
|
+
} catch (_error) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Parse one line into a daemon message, or null if malformed. */
|
|
214
|
+
function parseDaemonMessage(line) {
|
|
215
|
+
try {
|
|
216
|
+
return daemonMessageSchema.parse(JSON.parse(line));
|
|
217
|
+
} catch (_error) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
//#endregion
|
|
223
|
+
//#region src/chat/portal/daemon.ts
|
|
224
|
+
/**
|
|
225
|
+
* The portal DAEMON: one long-lived process per (user, appUrl) that owns the
|
|
226
|
+
* SINGLE portal WebSocket to the server and is the only writer of the machine's
|
|
227
|
+
* presence key. Every `skydive` CLI attaches to it over a Unix socket instead
|
|
228
|
+
* of opening its own portal connection, so N concurrent CLIs can no longer
|
|
229
|
+
* fight over one machine identity (the flap) — there is exactly one identity by
|
|
230
|
+
* construction.
|
|
231
|
+
*
|
|
232
|
+
* State it owns:
|
|
233
|
+
* - the one `PortalClient` (portal WS + device registration + grants);
|
|
234
|
+
* - a live `conversationId -> cwd` map, populated and updated by attached CLIs
|
|
235
|
+
* (including after `!cd`), and PERSISTED so a daemon restart doesn't lose
|
|
236
|
+
* where a conversation's exec should run. An exec directive carries its
|
|
237
|
+
* originating conversation id; the daemon runs the child in that
|
|
238
|
+
* conversation's cwd.
|
|
239
|
+
*
|
|
240
|
+
* Lifecycle: spawned lazily by the first CLI that finds no daemon listening.
|
|
241
|
+
* It exits on its own once the last CLI detaches (after a short linger, so it
|
|
242
|
+
* survives rapid session churn), so there is never an orphan holding the portal
|
|
243
|
+
* open after every chat is closed.
|
|
244
|
+
*/
|
|
245
|
+
/** Grace period after the last client detaches before the daemon exits. */
|
|
246
|
+
const IDLE_SHUTDOWN_MS = 3e4;
|
|
247
|
+
var PortalDaemon = class {
|
|
248
|
+
paths;
|
|
249
|
+
server = null;
|
|
250
|
+
client = null;
|
|
251
|
+
lastState = null;
|
|
252
|
+
conns = /* @__PURE__ */ new Set();
|
|
253
|
+
cwds = /* @__PURE__ */ new Map();
|
|
254
|
+
fallbackCwd;
|
|
255
|
+
idleTimer = null;
|
|
256
|
+
sessionToken = null;
|
|
257
|
+
constructor(appUrl) {
|
|
258
|
+
this.appUrl = appUrl;
|
|
259
|
+
this.paths = daemonPaths(appUrl);
|
|
260
|
+
this.fallbackCwd = process.env.HOME ?? process.cwd();
|
|
261
|
+
}
|
|
262
|
+
/** Start listening. Rejects if the socket is already held by another daemon. */
|
|
263
|
+
async listen() {
|
|
264
|
+
await mkdir(this.paths.dir, { recursive: true });
|
|
265
|
+
await this.loadState();
|
|
266
|
+
await this.clearStaleSocket();
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
const server = createServer((socket) => this.onClientConnect(socket));
|
|
269
|
+
server.on("error", reject);
|
|
270
|
+
server.listen(this.paths.socketPath, () => {
|
|
271
|
+
this.server = server;
|
|
272
|
+
this.armIdleTimer();
|
|
273
|
+
resolve();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
async clearStaleSocket() {
|
|
278
|
+
if (!await pathExists(this.paths.socketPath)) return;
|
|
279
|
+
if (await new Promise((resolve) => {
|
|
280
|
+
const probe = connect(this.paths.socketPath);
|
|
281
|
+
probe.on("connect", () => {
|
|
282
|
+
probe.destroy();
|
|
283
|
+
resolve(true);
|
|
284
|
+
});
|
|
285
|
+
probe.on("error", () => resolve(false));
|
|
286
|
+
})) throw new Error("daemon already running");
|
|
287
|
+
await unlink(this.paths.socketPath).catch((_error) => {});
|
|
288
|
+
}
|
|
289
|
+
onClientConnect(socket) {
|
|
290
|
+
const conn = {
|
|
291
|
+
socket,
|
|
292
|
+
sessionId: null,
|
|
293
|
+
conversations: /* @__PURE__ */ new Set()
|
|
294
|
+
};
|
|
295
|
+
this.conns.add(conn);
|
|
296
|
+
this.clearIdleTimer();
|
|
297
|
+
socket.setEncoding("utf8");
|
|
298
|
+
const parse = makeLineParser();
|
|
299
|
+
socket.on("data", (chunk) => {
|
|
300
|
+
for (const line of parse(chunk)) this.onClientLine(conn, line);
|
|
301
|
+
});
|
|
302
|
+
const drop = () => this.onClientClose(conn);
|
|
303
|
+
socket.on("close", drop);
|
|
304
|
+
socket.on("error", drop);
|
|
305
|
+
}
|
|
306
|
+
onClientLine(conn, line) {
|
|
307
|
+
const msg = parseClientMessage(line);
|
|
308
|
+
if (!msg) return;
|
|
309
|
+
switch (msg.t) {
|
|
310
|
+
case "hello":
|
|
311
|
+
conn.sessionId = msg.sessionId;
|
|
312
|
+
this.sessionToken = msg.token;
|
|
313
|
+
this.ensureClient();
|
|
314
|
+
this.send(conn, {
|
|
315
|
+
t: "hello_ok",
|
|
316
|
+
v: LOCAL_PROTOCOL_VERSION
|
|
317
|
+
});
|
|
318
|
+
if (this.lastState) this.send(conn, stateMessage(this.lastState));
|
|
319
|
+
return;
|
|
320
|
+
case "bind":
|
|
321
|
+
conn.conversations.add(msg.conversationId);
|
|
322
|
+
this.cwds.set(msg.conversationId, msg.cwd);
|
|
323
|
+
this.fallbackCwd = msg.cwd;
|
|
324
|
+
this.persistState();
|
|
325
|
+
return;
|
|
326
|
+
case "enable":
|
|
327
|
+
this.ensureClient();
|
|
328
|
+
this.client?.enable();
|
|
329
|
+
return;
|
|
330
|
+
case "disable":
|
|
331
|
+
this.client?.disable();
|
|
332
|
+
return;
|
|
333
|
+
case "grant":
|
|
334
|
+
this.ensureClient();
|
|
335
|
+
this.client?.grantAgent(msg.agentId).catch((error) => {
|
|
336
|
+
this.logError("grantAgent failed", error);
|
|
337
|
+
});
|
|
338
|
+
return;
|
|
339
|
+
case "bye":
|
|
340
|
+
this.onClientClose(conn);
|
|
341
|
+
return;
|
|
342
|
+
case "status":
|
|
343
|
+
this.send(conn, this.statusResult());
|
|
344
|
+
return;
|
|
345
|
+
case "shutdown":
|
|
346
|
+
this.forceShutdown();
|
|
347
|
+
return;
|
|
348
|
+
default: return msg;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
onClientClose(conn) {
|
|
352
|
+
if (!this.conns.has(conn)) return;
|
|
353
|
+
this.conns.delete(conn);
|
|
354
|
+
try {
|
|
355
|
+
conn.socket.destroy();
|
|
356
|
+
} catch (_error) {}
|
|
357
|
+
if (this.conns.size === 0) this.armIdleTimer();
|
|
358
|
+
}
|
|
359
|
+
/** Create the single PortalClient the first time a client needs the portal. */
|
|
360
|
+
ensureClient() {
|
|
361
|
+
if (this.client || !this.sessionToken) return;
|
|
362
|
+
this.client = new PortalClient({
|
|
363
|
+
appUrl: this.appUrl,
|
|
364
|
+
sessionToken: this.sessionToken,
|
|
365
|
+
resolveCwd: (conversationId) => this.resolveCwd(conversationId),
|
|
366
|
+
onState: (state) => {
|
|
367
|
+
this.lastState = state;
|
|
368
|
+
this.broadcast(stateMessage(state));
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
/** The cwd an exec for `conversationId` runs in. */
|
|
373
|
+
resolveCwd(conversationId) {
|
|
374
|
+
if (conversationId) {
|
|
375
|
+
const cwd = this.cwds.get(conversationId);
|
|
376
|
+
if (cwd) return cwd;
|
|
377
|
+
}
|
|
378
|
+
return this.fallbackCwd;
|
|
379
|
+
}
|
|
380
|
+
send(conn, msg) {
|
|
381
|
+
if (conn.socket.writable) conn.socket.write(encodeLine(msg));
|
|
382
|
+
}
|
|
383
|
+
broadcast(msg) {
|
|
384
|
+
for (const conn of this.conns) this.send(conn, msg);
|
|
385
|
+
}
|
|
386
|
+
armIdleTimer() {
|
|
387
|
+
this.clearIdleTimer();
|
|
388
|
+
this.idleTimer = setTimeout(() => this.shutdown(), IDLE_SHUTDOWN_MS);
|
|
389
|
+
this.idleTimer.unref();
|
|
390
|
+
}
|
|
391
|
+
clearIdleTimer() {
|
|
392
|
+
if (this.idleTimer) {
|
|
393
|
+
clearTimeout(this.idleTimer);
|
|
394
|
+
this.idleTimer = null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/** Append a line to the daemon log file (best-effort, for post-hoc debugging). */
|
|
398
|
+
logError(context, error) {
|
|
399
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
400
|
+
const line = `${(/* @__PURE__ */ new Date()).toISOString()} ${context}: ${message}\n`;
|
|
401
|
+
appendFile(this.paths.logPath, line).catch((_error) => {});
|
|
402
|
+
}
|
|
403
|
+
shutdown() {
|
|
404
|
+
if (this.conns.size > 0) return;
|
|
405
|
+
this.client?.dispose();
|
|
406
|
+
this.server?.close();
|
|
407
|
+
process.exit(0);
|
|
408
|
+
}
|
|
409
|
+
/** Snapshot for a `status` control request. */
|
|
410
|
+
statusResult() {
|
|
411
|
+
const portal = this.lastState;
|
|
412
|
+
return {
|
|
413
|
+
t: "status_result",
|
|
414
|
+
v: LOCAL_PROTOCOL_VERSION,
|
|
415
|
+
pid: process.pid,
|
|
416
|
+
appUrl: this.appUrl,
|
|
417
|
+
portal: {
|
|
418
|
+
status: portal?.status ?? "off",
|
|
419
|
+
machineName: portal?.machineName ?? "",
|
|
420
|
+
friendlyName: portal?.friendlyName ?? "",
|
|
421
|
+
error: portal?.error ?? null,
|
|
422
|
+
grantedAgentIds: portal?.grantedAgentIds ?? []
|
|
423
|
+
},
|
|
424
|
+
clientCount: [...this.conns].filter((c) => c.sessionId !== null).length,
|
|
425
|
+
cwds: Object.fromEntries(this.cwds)
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Operator-initiated hard takedown: unlike the idle `shutdown`, this ignores
|
|
430
|
+
* the client-count guard, actively disconnects every attached CLI (cutting
|
|
431
|
+
* their live exec/tunnel relays), drops the portal connection, and exits.
|
|
432
|
+
*/
|
|
433
|
+
forceShutdown() {
|
|
434
|
+
for (const conn of this.conns) try {
|
|
435
|
+
conn.socket.destroy();
|
|
436
|
+
} catch (_error) {}
|
|
437
|
+
this.conns.clear();
|
|
438
|
+
this.client?.dispose();
|
|
439
|
+
this.server?.close();
|
|
440
|
+
process.exit(0);
|
|
441
|
+
}
|
|
442
|
+
async loadState() {
|
|
443
|
+
try {
|
|
444
|
+
const raw = await readFile(this.paths.statePath, "utf8");
|
|
445
|
+
const parsed = JSON.parse(raw);
|
|
446
|
+
if (isRecord(parsed) && parsed.version === LOCAL_PROTOCOL_VERSION && isRecord(parsed.cwds)) {
|
|
447
|
+
for (const [id, cwd] of Object.entries(parsed.cwds)) if (typeof cwd === "string") this.cwds.set(id, cwd);
|
|
448
|
+
}
|
|
449
|
+
} catch (_error) {}
|
|
450
|
+
}
|
|
451
|
+
persistState() {
|
|
452
|
+
const state = {
|
|
453
|
+
version: LOCAL_PROTOCOL_VERSION,
|
|
454
|
+
cwds: Object.fromEntries(this.cwds)
|
|
455
|
+
};
|
|
456
|
+
writeFile(this.paths.statePath, JSON.stringify(state)).catch((error) => {
|
|
457
|
+
this.logError("persistState failed", error);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
/**
|
|
462
|
+
* Spawn a detached daemon process for this appUrl if one isn't already running.
|
|
463
|
+
* The CLI calls this on startup; it returns once the daemon is listening (or
|
|
464
|
+
* immediately if one already is). It respawns THIS executable (the bundled CLI
|
|
465
|
+
* entry) with PORTAL_DAEMON_FLAG, which `bin.ts` routes to `runPortalDaemon`
|
|
466
|
+
* before dispatching a normal command — the same pattern the update-check
|
|
467
|
+
* worker uses, so it survives bundling (import.meta.url points at the bundle,
|
|
468
|
+
* not a standalone daemon module).
|
|
469
|
+
*/
|
|
470
|
+
async function ensureDaemonRunning(appUrl) {
|
|
471
|
+
const { socketPath } = daemonPaths(appUrl);
|
|
472
|
+
if (await isDaemonListening(socketPath)) return;
|
|
473
|
+
const entry = process.argv[1];
|
|
474
|
+
const args = entry ? [
|
|
475
|
+
entry,
|
|
476
|
+
PORTAL_DAEMON_FLAG,
|
|
477
|
+
appUrl
|
|
478
|
+
] : [PORTAL_DAEMON_FLAG, appUrl];
|
|
479
|
+
spawn(process.execPath, args, {
|
|
480
|
+
detached: true,
|
|
481
|
+
stdio: "ignore",
|
|
482
|
+
env: process.env
|
|
483
|
+
}).unref();
|
|
484
|
+
for (let i = 0; i < 50; i += 1) {
|
|
485
|
+
if (await isDaemonListening(socketPath)) return;
|
|
486
|
+
await sleep(100);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Entry the CLI calls when launched with PORTAL_DAEMON_FLAG: become the daemon.
|
|
491
|
+
* `appUrl` is the argv token right after the flag.
|
|
492
|
+
*/
|
|
493
|
+
function runPortalDaemon(argv) {
|
|
494
|
+
const appUrl = argv[argv.indexOf(PORTAL_DAEMON_FLAG) + 1];
|
|
495
|
+
if (!appUrl) process.exit(1);
|
|
496
|
+
new PortalDaemon(appUrl).listen().catch(() => process.exit(1));
|
|
497
|
+
}
|
|
498
|
+
async function isDaemonListening(socketPath) {
|
|
499
|
+
if (!await pathExists(socketPath)) return false;
|
|
500
|
+
return new Promise((resolve) => {
|
|
501
|
+
const probe = connect(socketPath);
|
|
502
|
+
probe.on("connect", () => {
|
|
503
|
+
probe.destroy();
|
|
504
|
+
resolve(true);
|
|
505
|
+
});
|
|
506
|
+
probe.on("error", () => resolve(false));
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
async function pathExists(p) {
|
|
510
|
+
try {
|
|
511
|
+
await access(p);
|
|
512
|
+
return true;
|
|
513
|
+
} catch (_error) {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/** Open a short-lived control connection to the daemon, or null if none. */
|
|
518
|
+
function connectControl(socketPath) {
|
|
519
|
+
return new Promise((resolve) => {
|
|
520
|
+
const sock = connect(socketPath);
|
|
521
|
+
sock.once("connect", () => resolve(sock));
|
|
522
|
+
sock.once("error", () => resolve(null));
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Query a running daemon for its status snapshot. Returns null when no daemon is
|
|
527
|
+
* listening (so callers can print "not running") or if it doesn't reply in time.
|
|
528
|
+
*/
|
|
529
|
+
async function queryDaemonStatus(appUrl) {
|
|
530
|
+
const { socketPath } = daemonPaths(appUrl);
|
|
531
|
+
const sock = await connectControl(socketPath);
|
|
532
|
+
if (!sock) return null;
|
|
533
|
+
return new Promise((resolve) => {
|
|
534
|
+
const parse = makeLineParser();
|
|
535
|
+
let settled = false;
|
|
536
|
+
const finish = (result) => {
|
|
537
|
+
if (settled) return;
|
|
538
|
+
settled = true;
|
|
539
|
+
clearTimeout(timer);
|
|
540
|
+
sock.destroy();
|
|
541
|
+
resolve(result);
|
|
542
|
+
};
|
|
543
|
+
const timer = setTimeout(() => finish(null), 3e3);
|
|
544
|
+
sock.setEncoding("utf8");
|
|
545
|
+
sock.on("data", (chunk) => {
|
|
546
|
+
for (const line of parse(chunk)) {
|
|
547
|
+
const msg = parseDaemonMessage(line);
|
|
548
|
+
if (msg?.t === "status_result") finish(msg);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
sock.on("error", () => finish(null));
|
|
552
|
+
sock.on("close", () => finish(null));
|
|
553
|
+
sock.write(encodeLine({ t: "status" }));
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Hard-stop a running daemon. Preferred path: send `shutdown` over the control
|
|
558
|
+
* socket so it drains clients and exits cleanly. If the socket is unresponsive
|
|
559
|
+
* (a wedged daemon), fall back to SIGTERM then SIGKILL by the pid the status
|
|
560
|
+
* reports. Returns what actually happened.
|
|
561
|
+
*/
|
|
562
|
+
async function stopDaemon(appUrl) {
|
|
563
|
+
const { socketPath } = daemonPaths(appUrl);
|
|
564
|
+
if (!await isDaemonListening(socketPath)) return "not-running";
|
|
565
|
+
const status = await queryDaemonStatus(appUrl);
|
|
566
|
+
const sock = await connectControl(socketPath);
|
|
567
|
+
if (sock) {
|
|
568
|
+
sock.write(encodeLine({ t: "shutdown" }));
|
|
569
|
+
for (let i = 0; i < 30; i += 1) {
|
|
570
|
+
await sleep(100);
|
|
571
|
+
if (!await isDaemonListening(socketPath)) {
|
|
572
|
+
sock.destroy();
|
|
573
|
+
return "stopped";
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
sock.destroy();
|
|
577
|
+
}
|
|
578
|
+
const pid = status?.pid;
|
|
579
|
+
if (typeof pid === "number") try {
|
|
580
|
+
process.kill(pid, "SIGTERM");
|
|
581
|
+
for (let i = 0; i < 20; i += 1) {
|
|
582
|
+
await sleep(100);
|
|
583
|
+
if (!await isDaemonListening(socketPath)) return "killed";
|
|
584
|
+
}
|
|
585
|
+
process.kill(pid, "SIGKILL");
|
|
586
|
+
return "killed";
|
|
587
|
+
} catch (_error) {
|
|
588
|
+
return await isDaemonListening(socketPath) ? "failed" : "killed";
|
|
589
|
+
}
|
|
590
|
+
return await isDaemonListening(socketPath) ? "failed" : "stopped";
|
|
591
|
+
}
|
|
592
|
+
function stateMessage(state) {
|
|
593
|
+
return {
|
|
594
|
+
t: "state",
|
|
595
|
+
status: state.status,
|
|
596
|
+
machineName: state.machineName,
|
|
597
|
+
friendlyName: state.friendlyName,
|
|
598
|
+
error: state.error,
|
|
599
|
+
grantedAgentIds: state.grantedAgentIds
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
function sleep(ms) {
|
|
603
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
//#endregion
|
|
607
|
+
export { runPortalDaemon as a, PORTAL_DAEMON_FLAG as c, makeLineParser as d, parseDaemonMessage as f, queryDaemonStatus as i, daemonPaths as l, ensureDaemonRunning as n, stopDaemon as o, isDaemonListening as r, LOCAL_PROTOCOL_VERSION as s, PortalDaemon as t, encodeLine as u };
|