skydive-cli 0.5.0-beta.5 → 0.5.0-beta.52
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 +12 -0
- package/README.md +60 -12
- package/dist/js/api-TwLD7ibI.mjs +315 -0
- package/dist/js/{billing-blocked-Dgu5-oDy.mjs → billing-blocked-SE6tySLd.mjs} +1 -1
- package/dist/js/bin.mjs +658 -279
- package/dist/js/{boot-Brwh0it_.mjs → boot-D_VFIRJj.mjs} +3556 -995
- package/dist/js/chunk-BbwQpWto.mjs +33 -0
- package/dist/js/{client-Cn2af31H.mjs → client-Btq6bMzX.mjs} +108 -2
- package/dist/js/client-CkPQG8M1.mjs +5 -0
- package/dist/js/client-aFwHzCPG.mjs +963 -0
- package/dist/js/daemon-CORAITjj.mjs +7 -0
- package/dist/js/{daemon-client-DcuD4v12.mjs → daemon-client-Cfi66Xy9.mjs} +12 -8
- package/dist/js/daemon-client-DJPW9cRp.mjs +8 -0
- package/dist/js/{daemon-BhArnzeW.mjs → daemon-uzpOdRPL.mjs} +135 -10
- package/dist/js/dist-CRtjM7ba.mjs +1750 -0
- package/dist/js/forward-Ct8Vd2WW.mjs +208 -0
- package/dist/js/{profiler-LLaIFZgn.mjs → install-BBplm-Zp.mjs} +529 -215
- package/dist/js/launcher.mjs +49 -0
- package/dist/js/localhost-cert-Bn-UBUmj.mjs +67 -0
- package/dist/js/{print-ba_0hiV9.mjs → print-Cuao6LN6.mjs} +3 -3
- package/dist/js/{print-CbayCa87.mjs → print-DR6Gas-M.mjs} +291 -39
- package/dist/js/{print-share-uwUG16Ov.mjs → print-share-BypBUrNh.mjs} +9 -3
- package/dist/js/raw-pty-Ci2qFR9F.mjs +5 -0
- package/dist/js/{raw-pty-B6mAroiI.mjs → raw-pty-D5PhKZSl.mjs} +1 -1
- package/dist/js/rest-CJkBP2Jz.mjs +6 -0
- package/dist/js/{rest-BY2nADw5.mjs → rest-DkuT5_oX.mjs} +117 -28
- package/dist/js/tls-cert-CLgSQALB.mjs +4 -0
- package/dist/js/tls-cert-CV-pwxVN.mjs +67 -0
- package/package.json +11 -4
- package/dist/js/client-DZstLQ_1.mjs +0 -4
- package/dist/js/client-Dd5sMXPv.mjs +0 -620
- package/dist/js/daemon-CjzrUvXx.mjs +0 -5
- package/dist/js/daemon-client-D8bNXTxu.mjs +0 -6
- package/dist/js/raw-pty-3EkG-jjH.mjs +0 -5
- package/dist/js/rest-DADJh0bi.mjs +0 -6
- /package/dist/js/{billing-blocked-2wju4gC_.mjs → billing-blocked-D3l5kJlX.mjs} +0 -0
- /package/dist/js/{http-error-DzyrsLAZ.mjs → http-error-BF2NZZE3.mjs} +0 -0
- /package/dist/js/{output-DYzzdXYV.mjs → output-C9mb3sUB.mjs} +0 -0
|
@@ -1,620 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { t as HttpError } from "./http-error-DzyrsLAZ.mjs";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import { z } from "zod";
|
|
5
|
-
import { execFile, spawn } from "node:child_process";
|
|
6
|
-
import { WebSocket } from "ws";
|
|
7
|
-
|
|
8
|
-
//#region ../portal-daemon/src/machine.ts
|
|
9
|
-
/**
|
|
10
|
-
* Identity this machine registers under on the portal.
|
|
11
|
-
*
|
|
12
|
-
* One device row per (org, user, machine): the daemon owns the host's single
|
|
13
|
-
* portal connection, and every surface (CLI, TUI, desktop app) shares that
|
|
14
|
-
* row, so a grant applies to the machine no matter which surface the user
|
|
15
|
-
* shared from. The name must therefore match what the desktop app registers —
|
|
16
|
-
* on macOS that is `scutil --get LocalHostName` (the Rust client's own
|
|
17
|
-
* derivation, stabler than the network-assigned hostname), with the plain
|
|
18
|
-
* hostname as the fallback for other platforms or a failed read.
|
|
19
|
-
*
|
|
20
|
-
* Historically the CLI suffixed `-cli` to keep a device distinct from the
|
|
21
|
-
* desktop's; `unifyLegacyCliGrants` migrates grants off those rows.
|
|
22
|
-
*/
|
|
23
|
-
async function resolveMachineIdentity() {
|
|
24
|
-
const fallback = fallbackHost();
|
|
25
|
-
if (process.platform !== "darwin") return {
|
|
26
|
-
machineName: fallback,
|
|
27
|
-
friendlyName: fallback
|
|
28
|
-
};
|
|
29
|
-
const local = await scutilRead("LocalHostName") ?? fallback;
|
|
30
|
-
return {
|
|
31
|
-
machineName: local,
|
|
32
|
-
friendlyName: await scutilRead("ComputerName") ?? local
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
function fallbackHost() {
|
|
36
|
-
return (os.hostname() || "machine").trim().replace(/\.local$/i, "") || "machine";
|
|
37
|
-
}
|
|
38
|
-
function scutilRead(key) {
|
|
39
|
-
return new Promise((resolve) => {
|
|
40
|
-
execFile("/usr/sbin/scutil", ["--get", key], { timeout: 2e3 }, (error, stdout) => {
|
|
41
|
-
if (error) {
|
|
42
|
-
resolve(null);
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
const value = stdout.trim();
|
|
46
|
-
resolve(value.length > 0 ? value : null);
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
const INHERITED_ENV = [
|
|
51
|
-
"HOME",
|
|
52
|
-
"USER",
|
|
53
|
-
"LOGNAME",
|
|
54
|
-
"SHELL",
|
|
55
|
-
"LANG",
|
|
56
|
-
"LC_ALL",
|
|
57
|
-
"TMPDIR",
|
|
58
|
-
"TERM",
|
|
59
|
-
"PATH"
|
|
60
|
-
];
|
|
61
|
-
const DARWIN_PATH_PREPEND = ["/opt/homebrew/bin", "/usr/local/bin"];
|
|
62
|
-
function buildEnv(extra) {
|
|
63
|
-
const env = {};
|
|
64
|
-
for (const key of INHERITED_ENV) {
|
|
65
|
-
const value = process.env[key];
|
|
66
|
-
if (value !== void 0) env[key] = value;
|
|
67
|
-
}
|
|
68
|
-
if (process.platform === "darwin") {
|
|
69
|
-
const current = env.PATH ? env.PATH.split(":") : [];
|
|
70
|
-
const missing = DARWIN_PATH_PREPEND.filter((dir) => !current.includes(dir));
|
|
71
|
-
if (missing.length > 0) env.PATH = [...missing, ...current].join(":");
|
|
72
|
-
}
|
|
73
|
-
if (extra) for (const [key, value] of Object.entries(extra)) env[key] = value;
|
|
74
|
-
return env;
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Build the desktop-portal WebSocket URL from the chat origin. Mirrors the Rust
|
|
78
|
-
* desktop client: http→ws, https→wss, scheme-less defaults to wss, and the
|
|
79
|
-
* machine/label ride as query pairs (percent-encoded by URL).
|
|
80
|
-
*/
|
|
81
|
-
function portalWsUrl(appUrl, machine, label) {
|
|
82
|
-
const base = appUrl.replace(/\/+$/, "");
|
|
83
|
-
let wsBase;
|
|
84
|
-
if (base.startsWith("https://")) wsBase = `wss://${base.slice(8)}`;
|
|
85
|
-
else if (base.startsWith("http://")) wsBase = `ws://${base.slice(7)}`;
|
|
86
|
-
else wsBase = `wss://${base}`;
|
|
87
|
-
const url = new URL(`${wsBase}/api/v1/portal/desktop`);
|
|
88
|
-
url.searchParams.set("machine", machine);
|
|
89
|
-
url.searchParams.set("label", label);
|
|
90
|
-
return url.toString();
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
//#endregion
|
|
94
|
-
//#region ../portal-daemon/src/util.ts
|
|
95
|
-
/** Narrowing helper for the unknown JSON payloads the daemon reads (persisted
|
|
96
|
-
* state, wire frames). A type predicate (not an `as` cast), so call sites can
|
|
97
|
-
* read properties without asserting. */
|
|
98
|
-
function isRecord(value) {
|
|
99
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
100
|
-
}
|
|
101
|
-
/** Best-effort message from an unknown thrown value. */
|
|
102
|
-
function errorMessage(err) {
|
|
103
|
-
return err instanceof Error ? err.message : String(err);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
//#endregion
|
|
107
|
-
//#region ../portal-protocol/src/index.ts
|
|
108
|
-
const MAX_WS_FRAME_BYTES = 16 * 1024 * 1024;
|
|
109
|
-
const T_DATA = 1;
|
|
110
|
-
const T_CTRL = 2;
|
|
111
|
-
const STREAM = {
|
|
112
|
-
stdout: 0,
|
|
113
|
-
stderr: 1,
|
|
114
|
-
stdin: 2
|
|
115
|
-
};
|
|
116
|
-
const uuidToBytes = (id) => Buffer.from(id.replace(/-/g, ""), "hex");
|
|
117
|
-
const bytesToUuid = (b) => {
|
|
118
|
-
const h = b.toString("hex");
|
|
119
|
-
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
|
|
120
|
-
};
|
|
121
|
-
function encodeData(id, stream, seq, payload) {
|
|
122
|
-
const head = Buffer.allocUnsafe(22);
|
|
123
|
-
head[0] = T_DATA;
|
|
124
|
-
uuidToBytes(id).copy(head, 1);
|
|
125
|
-
head[17] = stream;
|
|
126
|
-
head.writeUInt32BE(seq >>> 0, 18);
|
|
127
|
-
return Buffer.concat([head, payload]);
|
|
128
|
-
}
|
|
129
|
-
const ctrlMessageSchema = z.discriminatedUnion("t", [
|
|
130
|
-
z.object({
|
|
131
|
-
t: z.literal("open"),
|
|
132
|
-
argv: z.array(z.string()),
|
|
133
|
-
env: z.record(z.string()).nullable(),
|
|
134
|
-
conversationId: z.string().nullable().optional()
|
|
135
|
-
}),
|
|
136
|
-
z.object({ t: z.literal("stdin_eof") }),
|
|
137
|
-
z.object({ t: z.literal("pause") }),
|
|
138
|
-
z.object({ t: z.literal("resume") }),
|
|
139
|
-
z.object({ t: z.literal("cancel") }),
|
|
140
|
-
z.object({
|
|
141
|
-
t: z.literal("close"),
|
|
142
|
-
exitCode: z.number(),
|
|
143
|
-
frames: z.number().int().nonnegative().optional()
|
|
144
|
-
}),
|
|
145
|
-
z.object({
|
|
146
|
-
t: z.literal("error"),
|
|
147
|
-
message: z.string()
|
|
148
|
-
})
|
|
149
|
-
]);
|
|
150
|
-
function encodeCtrl(id, obj) {
|
|
151
|
-
const head = Buffer.allocUnsafe(17);
|
|
152
|
-
head[0] = T_CTRL;
|
|
153
|
-
uuidToBytes(id).copy(head, 1);
|
|
154
|
-
return Buffer.concat([head, Buffer.from(JSON.stringify(obj), "utf8")]);
|
|
155
|
-
}
|
|
156
|
-
function decodeFrame(frame) {
|
|
157
|
-
const id = bytesToUuid(frame.subarray(1, 17));
|
|
158
|
-
if (frame[0] === T_DATA) return {
|
|
159
|
-
kind: "data",
|
|
160
|
-
id,
|
|
161
|
-
stream: frame[17] ?? 0,
|
|
162
|
-
seq: frame.readUInt32BE(18),
|
|
163
|
-
payload: frame.subarray(22)
|
|
164
|
-
};
|
|
165
|
-
return {
|
|
166
|
-
kind: "ctrl",
|
|
167
|
-
id,
|
|
168
|
-
obj: ctrlMessageSchema.parse(JSON.parse(frame.subarray(17).toString("utf8")))
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
//#endregion
|
|
173
|
-
//#region ../portal-daemon/src/exec.ts
|
|
174
|
-
/**
|
|
175
|
-
* Runs portal `exec` directives locally. Each `open` spawns a child process
|
|
176
|
-
* whose stdout/stderr stream back as data frames and whose stdin is fed by
|
|
177
|
-
* inbound data frames, with pause/resume backpressure and cancel/teardown that
|
|
178
|
-
* kill the child. This is the TypeScript counterpart of the desktop's Rust
|
|
179
|
-
* `portal/mod.rs` job machinery, minus the connection supervision (which lives
|
|
180
|
-
* in the client).
|
|
181
|
-
*/
|
|
182
|
-
var JobManager = class {
|
|
183
|
-
jobs = /* @__PURE__ */ new Map();
|
|
184
|
-
constructor(opts) {
|
|
185
|
-
this.opts = opts;
|
|
186
|
-
}
|
|
187
|
-
handleFrame(raw) {
|
|
188
|
-
let decoded;
|
|
189
|
-
try {
|
|
190
|
-
decoded = decodeFrame(raw);
|
|
191
|
-
} catch (_error) {
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
if (decoded.kind === "ctrl") this.handleCtrl(decoded.id, decoded.obj);
|
|
195
|
-
else if (decoded.stream === STREAM.stdin) this.jobs.get(decoded.id)?.child.stdin.write(decoded.payload);
|
|
196
|
-
}
|
|
197
|
-
killAll() {
|
|
198
|
-
for (const job of this.jobs.values()) {
|
|
199
|
-
job.settled = true;
|
|
200
|
-
job.child.kill("SIGKILL");
|
|
201
|
-
}
|
|
202
|
-
this.jobs.clear();
|
|
203
|
-
}
|
|
204
|
-
handleCtrl(id, msg) {
|
|
205
|
-
switch (msg.t) {
|
|
206
|
-
case "open":
|
|
207
|
-
this.startJob(id, msg.argv, msg.env, msg.conversationId ?? null);
|
|
208
|
-
return;
|
|
209
|
-
case "stdin_eof":
|
|
210
|
-
this.jobs.get(id)?.child.stdin.end();
|
|
211
|
-
return;
|
|
212
|
-
case "pause":
|
|
213
|
-
this.setPaused(id, true);
|
|
214
|
-
return;
|
|
215
|
-
case "resume":
|
|
216
|
-
this.setPaused(id, false);
|
|
217
|
-
return;
|
|
218
|
-
case "cancel":
|
|
219
|
-
this.jobs.get(id)?.child.kill("SIGKILL");
|
|
220
|
-
return;
|
|
221
|
-
case "close":
|
|
222
|
-
case "error": return;
|
|
223
|
-
default: return msg;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
setPaused(id, paused) {
|
|
227
|
-
const job = this.jobs.get(id);
|
|
228
|
-
if (!job) return;
|
|
229
|
-
if (paused) {
|
|
230
|
-
job.child.stdout.pause();
|
|
231
|
-
job.child.stderr.pause();
|
|
232
|
-
} else {
|
|
233
|
-
job.child.stdout.resume();
|
|
234
|
-
job.child.stderr.resume();
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
startJob(id, argv, env, conversationId) {
|
|
238
|
-
const [program, ...args] = argv;
|
|
239
|
-
if (!program) {
|
|
240
|
-
this.opts.send(encodeCtrl(id, {
|
|
241
|
-
t: "error",
|
|
242
|
-
message: "empty argv"
|
|
243
|
-
}));
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
let child;
|
|
247
|
-
try {
|
|
248
|
-
child = spawn(program, args, {
|
|
249
|
-
cwd: this.opts.resolveCwd(conversationId),
|
|
250
|
-
env: buildEnv(env),
|
|
251
|
-
stdio: [
|
|
252
|
-
"pipe",
|
|
253
|
-
"pipe",
|
|
254
|
-
"pipe"
|
|
255
|
-
]
|
|
256
|
-
});
|
|
257
|
-
} catch (err) {
|
|
258
|
-
this.opts.send(encodeCtrl(id, {
|
|
259
|
-
t: "error",
|
|
260
|
-
message: `spawn failed: ${errorMessage(err)}`
|
|
261
|
-
}));
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
const job = {
|
|
265
|
-
child,
|
|
266
|
-
seq: 0,
|
|
267
|
-
settled: false
|
|
268
|
-
};
|
|
269
|
-
this.jobs.set(id, job);
|
|
270
|
-
child.on("error", (err) => {
|
|
271
|
-
if (job.settled) return;
|
|
272
|
-
job.settled = true;
|
|
273
|
-
this.jobs.delete(id);
|
|
274
|
-
this.opts.send(encodeCtrl(id, {
|
|
275
|
-
t: "error",
|
|
276
|
-
message: `spawn failed: ${errorMessage(err)}`
|
|
277
|
-
}));
|
|
278
|
-
});
|
|
279
|
-
child.stdout.on("data", (chunk) => this.sendData(job, id, STREAM.stdout, chunk));
|
|
280
|
-
child.stderr.on("data", (chunk) => this.sendData(job, id, STREAM.stderr, chunk));
|
|
281
|
-
child.on("close", (code) => {
|
|
282
|
-
if (job.settled) return;
|
|
283
|
-
job.settled = true;
|
|
284
|
-
this.jobs.delete(id);
|
|
285
|
-
this.opts.send(encodeCtrl(id, {
|
|
286
|
-
t: "close",
|
|
287
|
-
exitCode: code ?? -1,
|
|
288
|
-
frames: job.seq
|
|
289
|
-
}));
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
sendData(job, id, stream, chunk) {
|
|
293
|
-
if (job.settled) return;
|
|
294
|
-
this.opts.send(encodeData(id, stream, job.seq, chunk));
|
|
295
|
-
job.seq = job.seq + 1 >>> 0;
|
|
296
|
-
}
|
|
297
|
-
};
|
|
298
|
-
|
|
299
|
-
//#endregion
|
|
300
|
-
//#region ../portal-daemon/src/api.ts
|
|
301
|
-
/**
|
|
302
|
-
* The portal's session-authed REST surface, shared by `PortalClient` (the
|
|
303
|
-
* TUI/`portal open` connection) and the `skydive portal` management
|
|
304
|
-
* commands, so the endpoint contracts and response schemas live in exactly
|
|
305
|
-
* one place.
|
|
306
|
-
*/
|
|
307
|
-
const deviceSchema = z.object({
|
|
308
|
-
id: z.string(),
|
|
309
|
-
machineName: z.string(),
|
|
310
|
-
friendlyName: z.string(),
|
|
311
|
-
connected: z.boolean(),
|
|
312
|
-
lastSeen: z.string().nullable(),
|
|
313
|
-
grantedAgentIds: z.array(z.string())
|
|
314
|
-
});
|
|
315
|
-
const devicesResponseSchema = z.object({
|
|
316
|
-
devices: z.array(deviceSchema),
|
|
317
|
-
agents: z.array(z.object({
|
|
318
|
-
id: z.string(),
|
|
319
|
-
name: z.string()
|
|
320
|
-
}))
|
|
321
|
-
});
|
|
322
|
-
const deviceTokenSchema = z.object({ token: z.string().min(1) });
|
|
323
|
-
async function portalFetch(auth, path, init) {
|
|
324
|
-
const res = await fetch(`${auth.appUrl}${path}`, {
|
|
325
|
-
method: init.method,
|
|
326
|
-
headers: {
|
|
327
|
-
authorization: `Bearer ${auth.sessionToken}`,
|
|
328
|
-
accept: "application/json",
|
|
329
|
-
...init.body ? { "content-type": "application/json" } : {}
|
|
330
|
-
},
|
|
331
|
-
...init.body ? { body: init.body } : {}
|
|
332
|
-
});
|
|
333
|
-
if (!res.ok) {
|
|
334
|
-
const body = await res.text().catch(() => "");
|
|
335
|
-
throw new HttpError(res.status, body);
|
|
336
|
-
}
|
|
337
|
-
return res.json();
|
|
338
|
-
}
|
|
339
|
-
async function fetchPortalDevices(auth) {
|
|
340
|
-
const json = await portalFetch(auth, "/api/v1/portal/devices", { method: "GET" });
|
|
341
|
-
return devicesResponseSchema.parse(json);
|
|
342
|
-
}
|
|
343
|
-
const registerResponseSchema = z.object({ device: z.object({ id: z.string() }) });
|
|
344
|
-
/**
|
|
345
|
-
* Register this machine's device row without connecting. Connecting registers
|
|
346
|
-
* as a side effect; this covers granting an agent on a machine that has never
|
|
347
|
-
* shared yet (the grant references the device row).
|
|
348
|
-
*/
|
|
349
|
-
async function registerPortalDevice(auth, { machineName, friendlyName }) {
|
|
350
|
-
const json = await portalFetch(auth, "/api/v1/portal/devices", {
|
|
351
|
-
method: "POST",
|
|
352
|
-
body: JSON.stringify({
|
|
353
|
-
machineName,
|
|
354
|
-
friendlyName
|
|
355
|
-
})
|
|
356
|
-
});
|
|
357
|
-
return registerResponseSchema.parse(json).device;
|
|
358
|
-
}
|
|
359
|
-
/** Short-lived token the machine presents when dialing the portal WebSocket. */
|
|
360
|
-
async function mintPortalDeviceToken(auth) {
|
|
361
|
-
const json = await portalFetch(auth, "/api/v1/portal/device-token", { method: "POST" });
|
|
362
|
-
return deviceTokenSchema.parse(json).token;
|
|
363
|
-
}
|
|
364
|
-
async function grantPortalAccess(auth, { deviceId, agentId }) {
|
|
365
|
-
await portalFetch(auth, `/api/v1/portal/devices/${encodeURIComponent(deviceId)}/grants`, {
|
|
366
|
-
method: "POST",
|
|
367
|
-
body: JSON.stringify({ agentId })
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
async function revokePortalAccess(auth, { deviceId, agentId }) {
|
|
371
|
-
await portalFetch(auth, `/api/v1/portal/devices/${encodeURIComponent(deviceId)}/grants/${encodeURIComponent(agentId)}`, { method: "DELETE" });
|
|
372
|
-
}
|
|
373
|
-
/**
|
|
374
|
-
* The device row for a given machine identity. Matching is by `machineName`
|
|
375
|
-
* equality — the stable handle the machine registers under, not the display
|
|
376
|
-
* label.
|
|
377
|
-
*/
|
|
378
|
-
function findThisDevice(devices, machineName) {
|
|
379
|
-
return devices.find((device) => device.machineName === machineName) ?? null;
|
|
380
|
-
}
|
|
381
|
-
/**
|
|
382
|
-
* One-time grant migration onto the merged device. Earlier CLI builds
|
|
383
|
-
* registered a separate `<machineName>-cli` device, so a user's existing
|
|
384
|
-
* approvals hang off that row; the merged device would start with zero grants
|
|
385
|
-
* and every already-authorized agent would ask again. Copy any grant the
|
|
386
|
-
* merged device is missing (the grant endpoint upserts, so re-runs are
|
|
387
|
-
* no-ops). The legacy row is left in place — an old CLI build may still
|
|
388
|
-
* connect under it. Returns how many grants were copied.
|
|
389
|
-
*/
|
|
390
|
-
async function unifyLegacyCliGrants(auth, machineName) {
|
|
391
|
-
const { devices } = await fetchPortalDevices(auth);
|
|
392
|
-
const merged = findThisDevice(devices, machineName);
|
|
393
|
-
const legacy = findThisDevice(devices, `${machineName}-cli`);
|
|
394
|
-
if (!merged || !legacy) return 0;
|
|
395
|
-
const have = new Set(merged.grantedAgentIds);
|
|
396
|
-
const missing = legacy.grantedAgentIds.filter((id) => !have.has(id));
|
|
397
|
-
for (const agentId of missing) await grantPortalAccess(auth, {
|
|
398
|
-
deviceId: merged.id,
|
|
399
|
-
agentId
|
|
400
|
-
});
|
|
401
|
-
return missing.length;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
//#endregion
|
|
405
|
-
//#region ../portal-daemon/src/client.ts
|
|
406
|
-
const INITIAL_BACKOFF_MS = 500;
|
|
407
|
-
const MAX_BACKOFF_MS = 1e4;
|
|
408
|
-
/**
|
|
409
|
-
* Shares the local machine with agents over the portal: dials OUT to the api's
|
|
410
|
-
* desktop-portal WebSocket (authenticating with a short-lived device token
|
|
411
|
-
* minted from the CLI session), then runs inbound `exec` directives via a
|
|
412
|
-
* `JobManager`. Reconnects with backoff while enabled; disabling drops presence
|
|
413
|
-
* and kills any in-flight children. No inbound port is ever opened.
|
|
414
|
-
*
|
|
415
|
-
* Access stays default-deny: connecting only makes the machine reachable — an
|
|
416
|
-
* agent can't run anything until the user grants it (`grantAgent`).
|
|
417
|
-
*/
|
|
418
|
-
var PortalClient = class {
|
|
419
|
-
enabled = false;
|
|
420
|
-
disposed = false;
|
|
421
|
-
ws = null;
|
|
422
|
-
jobs = null;
|
|
423
|
-
status = "off";
|
|
424
|
-
error = null;
|
|
425
|
-
deviceId = null;
|
|
426
|
-
granted = /* @__PURE__ */ new Set();
|
|
427
|
-
machineName = "";
|
|
428
|
-
friendlyName = "";
|
|
429
|
-
legacyGrantsChecked = false;
|
|
430
|
-
constructor(opts) {
|
|
431
|
-
this.opts = opts;
|
|
432
|
-
}
|
|
433
|
-
isEnabled() {
|
|
434
|
-
return this.enabled;
|
|
435
|
-
}
|
|
436
|
-
isGranted(agentId) {
|
|
437
|
-
return this.granted.has(agentId);
|
|
438
|
-
}
|
|
439
|
-
/** Agents currently authorized on this machine (last known server state). */
|
|
440
|
-
grantedAgentIds() {
|
|
441
|
-
return [...this.granted];
|
|
442
|
-
}
|
|
443
|
-
enable() {
|
|
444
|
-
if (this.enabled || this.disposed) return;
|
|
445
|
-
this.enabled = true;
|
|
446
|
-
this.error = null;
|
|
447
|
-
this.connectLoop();
|
|
448
|
-
}
|
|
449
|
-
disable() {
|
|
450
|
-
if (!this.enabled) return;
|
|
451
|
-
this.enabled = false;
|
|
452
|
-
this.jobs?.killAll();
|
|
453
|
-
this.ws?.close();
|
|
454
|
-
this.ws = null;
|
|
455
|
-
this.deviceId = null;
|
|
456
|
-
this.granted = /* @__PURE__ */ new Set();
|
|
457
|
-
this.setStatus("off");
|
|
458
|
-
}
|
|
459
|
-
/**
|
|
460
|
-
* Tear down for good (app quit). Kills children synchronously and closes the
|
|
461
|
-
* socket so it stops holding the event loop open — otherwise the process
|
|
462
|
-
* would hang after the TUI is destroyed.
|
|
463
|
-
*/
|
|
464
|
-
dispose() {
|
|
465
|
-
this.disposed = true;
|
|
466
|
-
this.enabled = false;
|
|
467
|
-
this.jobs?.killAll();
|
|
468
|
-
this.jobs = null;
|
|
469
|
-
this.ws?.close();
|
|
470
|
-
this.ws = null;
|
|
471
|
-
}
|
|
472
|
-
/** Grant one agent access to this machine (default-deny; user-initiated). */
|
|
473
|
-
async grantAgent(agentId) {
|
|
474
|
-
const auth = this.sessionAuth();
|
|
475
|
-
if (!auth) throw new Error("granting needs a signed-in CLI session on this machine (the desktop connection alone cannot manage grants)");
|
|
476
|
-
await grantPortalAccess(auth, {
|
|
477
|
-
deviceId: await this.ensureDeviceId(),
|
|
478
|
-
agentId
|
|
479
|
-
});
|
|
480
|
-
this.granted.add(agentId);
|
|
481
|
-
this.emit();
|
|
482
|
-
}
|
|
483
|
-
/** Session-authed REST auth, or null when only a device token is held. */
|
|
484
|
-
sessionAuth() {
|
|
485
|
-
const { sessionToken } = this.opts.credentials();
|
|
486
|
-
return sessionToken ? {
|
|
487
|
-
appUrl: this.opts.appUrl,
|
|
488
|
-
sessionToken
|
|
489
|
-
} : null;
|
|
490
|
-
}
|
|
491
|
-
/**
|
|
492
|
-
* The bearer token for the portal WebSocket dial: minted from the session
|
|
493
|
-
* when one is held, otherwise the pre-minted device token as-is.
|
|
494
|
-
*/
|
|
495
|
-
async wsToken() {
|
|
496
|
-
const creds = this.opts.credentials();
|
|
497
|
-
if (creds.sessionToken) return mintPortalDeviceToken({
|
|
498
|
-
appUrl: this.opts.appUrl,
|
|
499
|
-
sessionToken: creds.sessionToken
|
|
500
|
-
});
|
|
501
|
-
if (creds.deviceToken) return creds.deviceToken;
|
|
502
|
-
throw new Error("no portal credentials (not signed in)");
|
|
503
|
-
}
|
|
504
|
-
setStatus(status, error = null) {
|
|
505
|
-
this.status = status;
|
|
506
|
-
this.error = error;
|
|
507
|
-
this.emit();
|
|
508
|
-
}
|
|
509
|
-
emit() {
|
|
510
|
-
this.opts.onState({
|
|
511
|
-
status: this.status,
|
|
512
|
-
machineName: this.machineName,
|
|
513
|
-
friendlyName: this.friendlyName,
|
|
514
|
-
error: this.error,
|
|
515
|
-
grantedAgentIds: [...this.granted]
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
async connectLoop() {
|
|
519
|
-
if (!this.machineName) {
|
|
520
|
-
const identity = await resolveMachineIdentity();
|
|
521
|
-
this.machineName = identity.machineName;
|
|
522
|
-
this.friendlyName = identity.friendlyName;
|
|
523
|
-
}
|
|
524
|
-
let backoff = INITIAL_BACKOFF_MS;
|
|
525
|
-
while (this.enabled && !this.disposed) {
|
|
526
|
-
this.setStatus("connecting");
|
|
527
|
-
try {
|
|
528
|
-
const token = await this.wsToken();
|
|
529
|
-
await this.runConnection(token);
|
|
530
|
-
backoff = INITIAL_BACKOFF_MS;
|
|
531
|
-
} catch (err) {
|
|
532
|
-
if (!this.enabled || this.disposed) break;
|
|
533
|
-
this.setStatus("error", errorMessage(err));
|
|
534
|
-
}
|
|
535
|
-
if (!this.enabled || this.disposed) break;
|
|
536
|
-
await sleep(backoff);
|
|
537
|
-
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
runConnection(token) {
|
|
541
|
-
return new Promise((resolve) => {
|
|
542
|
-
const ws = new WebSocket(portalWsUrl(this.opts.appUrl, this.machineName, this.friendlyName), {
|
|
543
|
-
headers: { authorization: `Bearer ${token}` },
|
|
544
|
-
maxPayload: MAX_WS_FRAME_BYTES
|
|
545
|
-
});
|
|
546
|
-
this.ws = ws;
|
|
547
|
-
const jobs = new JobManager({
|
|
548
|
-
resolveCwd: this.opts.resolveCwd,
|
|
549
|
-
send: (frame) => {
|
|
550
|
-
if (ws.readyState === WebSocket.OPEN) ws.send(frame);
|
|
551
|
-
}
|
|
552
|
-
});
|
|
553
|
-
this.jobs = jobs;
|
|
554
|
-
ws.on("open", () => {
|
|
555
|
-
this.setStatus("connected");
|
|
556
|
-
this.syncDeviceState();
|
|
557
|
-
});
|
|
558
|
-
ws.on("message", (data, isBinary) => {
|
|
559
|
-
if (isBinary) jobs.handleFrame(toBuffer(data));
|
|
560
|
-
});
|
|
561
|
-
ws.on("error", (err) => {
|
|
562
|
-
this.error = errorMessage(err);
|
|
563
|
-
});
|
|
564
|
-
ws.on("close", () => {
|
|
565
|
-
jobs.killAll();
|
|
566
|
-
if (this.jobs === jobs) this.jobs = null;
|
|
567
|
-
if (this.ws === ws) this.ws = null;
|
|
568
|
-
resolve();
|
|
569
|
-
});
|
|
570
|
-
});
|
|
571
|
-
}
|
|
572
|
-
async ensureDeviceId() {
|
|
573
|
-
if (this.deviceId) return this.deviceId;
|
|
574
|
-
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
575
|
-
await this.refreshDevice();
|
|
576
|
-
if (this.deviceId) return this.deviceId;
|
|
577
|
-
await sleep(300);
|
|
578
|
-
}
|
|
579
|
-
throw new Error("this machine is not connected yet");
|
|
580
|
-
}
|
|
581
|
-
async refreshDevice() {
|
|
582
|
-
const auth = this.sessionAuth();
|
|
583
|
-
if (!auth) return;
|
|
584
|
-
try {
|
|
585
|
-
const { devices } = await fetchPortalDevices(auth);
|
|
586
|
-
const mine = findThisDevice(devices, this.machineName);
|
|
587
|
-
if (!mine) return;
|
|
588
|
-
this.deviceId = mine.id;
|
|
589
|
-
this.granted = new Set(mine.grantedAgentIds);
|
|
590
|
-
this.emit();
|
|
591
|
-
} catch (_error) {}
|
|
592
|
-
}
|
|
593
|
-
/**
|
|
594
|
-
* Post-connect device sync: read our row, then (once per process) copy any
|
|
595
|
-
* grants still hanging off the legacy `<host>-cli` device onto this one so
|
|
596
|
-
* agents the user already approved keep their access under the merged
|
|
597
|
-
* identity.
|
|
598
|
-
*/
|
|
599
|
-
async syncDeviceState() {
|
|
600
|
-
await this.refreshDevice();
|
|
601
|
-
if (this.legacyGrantsChecked) return;
|
|
602
|
-
const auth = this.sessionAuth();
|
|
603
|
-
if (!auth) return;
|
|
604
|
-
this.legacyGrantsChecked = true;
|
|
605
|
-
try {
|
|
606
|
-
if (await unifyLegacyCliGrants(auth, this.machineName) > 0) await this.refreshDevice();
|
|
607
|
-
} catch (_error) {}
|
|
608
|
-
}
|
|
609
|
-
};
|
|
610
|
-
function toBuffer(data) {
|
|
611
|
-
if (Buffer.isBuffer(data)) return data;
|
|
612
|
-
if (Array.isArray(data)) return Buffer.concat(data);
|
|
613
|
-
return Buffer.from(data);
|
|
614
|
-
}
|
|
615
|
-
function sleep(ms) {
|
|
616
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
//#endregion
|
|
620
|
-
export { registerPortalDevice as a, resolveMachineIdentity as c, grantPortalAccess as i, fetchPortalDevices as n, revokePortalAccess as o, findThisDevice as r, isRecord as s, PortalClient as t };
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import "./client-Dd5sMXPv.mjs";
|
|
3
|
-
import { a as runPortalDaemon, i as queryDaemonStatus, n as ensureDaemonRunning, o as startPortalDaemon, r as isDaemonListening, s as stopDaemon, t as PortalDaemon } from "./daemon-BhArnzeW.mjs";
|
|
4
|
-
|
|
5
|
-
export { runPortalDaemon };
|
|
File without changes
|
|
File without changes
|
|
File without changes
|