skydive-cli 0.2.0-beta.615 → 0.2.0-beta.624
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/dist/js/bin.mjs +758 -118
- package/dist/js/{boot-BJZrdTk0.mjs → boot-CI6nXtmu.mjs} +232 -18
- package/dist/js/client-CKlIpyAb.mjs +5 -0
- package/dist/js/{client-BigvBsPk.mjs → client-DT9bWi1D.mjs} +148 -8
- package/dist/js/daemon-CqhMYzpN.mjs +6 -0
- package/dist/js/daemon-DfMgVlYE.mjs +607 -0
- package/dist/js/{install-B5sKLfRc.mjs → install-DKTnh1gy.mjs} +2 -2
- package/dist/js/{print-nefugWMx.mjs → print-CB6L_1YB.mjs} +1 -1
- package/dist/js/{print-CNIvmpqW.mjs → print-DaPpTqxu.mjs} +1 -1
- package/dist/js/{print-share-rM8kNeEw.mjs → print-share-D8Nn1qFq.mjs} +2 -2
- package/dist/js/{raw-pty-Dbi2kb9v.mjs → raw-pty-BcjbTjHJ.mjs} +1 -1
- package/dist/js/raw-pty-ChUHav4d.mjs +5 -0
- package/package.json +1 -1
- package/dist/js/api-CLTp-mKM.mjs +0 -145
- package/dist/js/client-D6UUwESi.mjs +0 -6
- package/dist/js/raw-pty-pspO57gT.mjs +0 -5
- /package/dist/js/{client-BVOAwU8M.mjs → client-DfcJFEbh.mjs} +0 -0
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { C as setActiveWorkspace, S as listWorkspaces, a as WORDMARK, b as getActiveWorkspaceId, c as applyTheme, d as noColorRequested, f as theme, g as themeVersion, h as themeModeFromColorFgBg, i as MARK_CELLS, l as findTheme, m as themeMode, n as buildCrashReport, p as themeForMode, r as writeCrashReport, s as DEFAULT_THEME_ID, t as installCrashHandler, u as monoTheme, v as themesForMode } from "./install-
|
|
3
|
-
import {
|
|
2
|
+
import { C as setActiveWorkspace, S as listWorkspaces, a as WORDMARK, b as getActiveWorkspaceId, c as applyTheme, d as noColorRequested, f as theme, g as themeVersion, h as themeModeFromColorFgBg, i as MARK_CELLS, l as findTheme, m as themeMode, n as buildCrashReport, p as themeForMode, r as writeCrashReport, s as DEFAULT_THEME_ID, t as installCrashHandler, u as monoTheme, v as themesForMode } from "./install-DKTnh1gy.mjs";
|
|
3
|
+
import { L as saveTheme, P as resolveWebUrl, T as getSavedTheme, c as cardActionErrorMessage, d as reconcileMaskedInput, f as resolveConnectUrl, i as resolveAgent, l as parseExternalOauthConnectParams, m as specKeyFor, p as parseConnectCard, s as MASK_CHAR, u as parseOauthConnectParams, v as DEFAULT_API_URL, w as getReviewStateDir, x as getConfigPath, y as DEFAULT_APP_URL } from "./print-DaPpTqxu.mjs";
|
|
4
4
|
import { a as errorMessage, i as sendErrorMessage, n as createRestClient, o as isRecord, r as errorDetail, t as HttpError } from "./rest-Q5yl_b2-.mjs";
|
|
5
|
-
import "./
|
|
6
|
-
import {
|
|
7
|
-
import { t as
|
|
8
|
-
import { t as runRawPtyPassthrough } from "./raw-pty-
|
|
5
|
+
import { t as PortalClient } from "./client-DT9bWi1D.mjs";
|
|
6
|
+
import { d as makeLineParser, f as parseDaemonMessage, l as daemonPaths, n as ensureDaemonRunning, s as LOCAL_PROTOCOL_VERSION, u as encodeLine } from "./daemon-DfMgVlYE.mjs";
|
|
7
|
+
import { t as SandboxStream } from "./client-DfcJFEbh.mjs";
|
|
8
|
+
import { t as runRawPtyPassthrough } from "./raw-pty-BcjbTjHJ.mjs";
|
|
9
9
|
import * as os$1 from "node:os";
|
|
10
10
|
import { homedir, platform, release, tmpdir } from "node:os";
|
|
11
11
|
import path, { basename, extname, isAbsolute, join, win32 } from "node:path";
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
import open from "open";
|
|
14
14
|
import { execFile, spawn } from "node:child_process";
|
|
15
|
-
import { createHash } from "node:crypto";
|
|
15
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
16
16
|
import { constants } from "node:fs";
|
|
17
|
+
import { connect, createConnection } from "node:net";
|
|
18
|
+
import { access, appendFile, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
17
19
|
import { MarkdownRenderable, RenderableEvents, StyledText, SyntaxStyle, createCliRenderer, decodePasteBytes, detectLinks, fg, link } from "@opentui/core";
|
|
18
20
|
import { createRoot, extend, useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
19
21
|
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
20
22
|
import { create } from "zustand";
|
|
21
|
-
import { connect, createConnection } from "node:net";
|
|
22
|
-
import { access, appendFile, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
23
23
|
import { Fragment, jsx, jsxs } from "@opentui/react/jsx-runtime";
|
|
24
24
|
import fuzzysort from "fuzzysort";
|
|
25
25
|
import { fileURLToPath } from "node:url";
|
|
@@ -228,23 +228,222 @@ async function resolveInitialScreen({ rest, agentSelector, conversationId, newCo
|
|
|
228
228
|
};
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/chat/portal/daemon-client.ts
|
|
233
|
+
var PortalDaemonClient = class {
|
|
234
|
+
sessionId = randomUUID();
|
|
235
|
+
socket = null;
|
|
236
|
+
disposed = false;
|
|
237
|
+
reconnectTimer = null;
|
|
238
|
+
lastBind = null;
|
|
239
|
+
wantEnabled = false;
|
|
240
|
+
pendingGrants = /* @__PURE__ */ new Set();
|
|
241
|
+
grantedAgentIds = /* @__PURE__ */ new Set();
|
|
242
|
+
constructor(opts) {
|
|
243
|
+
this.opts = opts;
|
|
244
|
+
}
|
|
245
|
+
/** Spawn the daemon if needed and attach. Idempotent. */
|
|
246
|
+
async start() {
|
|
247
|
+
if (this.disposed) return;
|
|
248
|
+
await ensureDaemonRunning(this.opts.appUrl);
|
|
249
|
+
this.connect();
|
|
250
|
+
}
|
|
251
|
+
connect() {
|
|
252
|
+
if (this.disposed) return;
|
|
253
|
+
const { socketPath } = daemonPaths(this.opts.appUrl);
|
|
254
|
+
const socket = connect(socketPath);
|
|
255
|
+
socket.setEncoding("utf8");
|
|
256
|
+
this.socket = socket;
|
|
257
|
+
const parse = makeLineParser();
|
|
258
|
+
socket.on("connect", () => {
|
|
259
|
+
this.send({
|
|
260
|
+
t: "hello",
|
|
261
|
+
v: LOCAL_PROTOCOL_VERSION,
|
|
262
|
+
sessionId: this.sessionId,
|
|
263
|
+
token: this.opts.sessionToken
|
|
264
|
+
});
|
|
265
|
+
if (this.wantEnabled) this.send({ t: "enable" });
|
|
266
|
+
if (this.lastBind) this.send({
|
|
267
|
+
t: "bind",
|
|
268
|
+
sessionId: this.sessionId,
|
|
269
|
+
conversationId: this.lastBind.conversationId,
|
|
270
|
+
cwd: this.lastBind.cwd
|
|
271
|
+
});
|
|
272
|
+
for (const agentId of this.pendingGrants) this.send({
|
|
273
|
+
t: "grant",
|
|
274
|
+
agentId
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
socket.on("data", (chunk) => {
|
|
278
|
+
for (const line of parse(chunk)) {
|
|
279
|
+
const msg = parseDaemonMessage(line);
|
|
280
|
+
if (msg) this.onMessage(msg);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
const onClose = () => {
|
|
284
|
+
if (this.socket === socket) this.socket = null;
|
|
285
|
+
this.scheduleReconnect();
|
|
286
|
+
};
|
|
287
|
+
socket.on("close", onClose);
|
|
288
|
+
socket.on("error", onClose);
|
|
289
|
+
}
|
|
290
|
+
scheduleReconnect() {
|
|
291
|
+
if (this.disposed || this.reconnectTimer) return;
|
|
292
|
+
this.reconnectTimer = setTimeout(() => {
|
|
293
|
+
this.reconnectTimer = null;
|
|
294
|
+
this.start();
|
|
295
|
+
}, 500);
|
|
296
|
+
this.reconnectTimer.unref();
|
|
297
|
+
}
|
|
298
|
+
onMessage(msg) {
|
|
299
|
+
if (msg.t === "state") {
|
|
300
|
+
this.grantedAgentIds = new Set(msg.grantedAgentIds);
|
|
301
|
+
this.opts.onState({
|
|
302
|
+
status: msg.status,
|
|
303
|
+
machineName: msg.machineName,
|
|
304
|
+
friendlyName: msg.friendlyName,
|
|
305
|
+
error: msg.error,
|
|
306
|
+
grantedAgentIds: msg.grantedAgentIds
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** Whether this agent is currently authorized on the machine (last daemon state). */
|
|
311
|
+
isGranted(agentId) {
|
|
312
|
+
return this.grantedAgentIds.has(agentId);
|
|
313
|
+
}
|
|
314
|
+
/** Whether the user has turned machine sharing on from this CLI. */
|
|
315
|
+
isEnabled() {
|
|
316
|
+
return this.wantEnabled;
|
|
317
|
+
}
|
|
318
|
+
send(msg) {
|
|
319
|
+
if (this.socket?.writable) this.socket.write(encodeLine(msg));
|
|
320
|
+
}
|
|
321
|
+
/** Turn machine sharing on (the daemon connects the portal). */
|
|
322
|
+
enable() {
|
|
323
|
+
this.wantEnabled = true;
|
|
324
|
+
this.send({ t: "enable" });
|
|
325
|
+
}
|
|
326
|
+
/** Turn machine sharing off. */
|
|
327
|
+
disable() {
|
|
328
|
+
this.wantEnabled = false;
|
|
329
|
+
this.send({ t: "disable" });
|
|
330
|
+
}
|
|
331
|
+
/** Register / update this conversation's working directory for exec routing. */
|
|
332
|
+
bind(conversationId, cwd) {
|
|
333
|
+
this.lastBind = {
|
|
334
|
+
conversationId,
|
|
335
|
+
cwd
|
|
336
|
+
};
|
|
337
|
+
this.send({
|
|
338
|
+
t: "bind",
|
|
339
|
+
sessionId: this.sessionId,
|
|
340
|
+
conversationId,
|
|
341
|
+
cwd
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Authorize one agent to run commands on this machine. Resolves once the
|
|
346
|
+
* request is sent to the daemon (the daemon performs the grant and pushes the
|
|
347
|
+
* updated state); kept async so it's a drop-in for the old in-process client's
|
|
348
|
+
* awaited `grantAgent`.
|
|
349
|
+
*/
|
|
350
|
+
grantAgent(agentId) {
|
|
351
|
+
this.pendingGrants.add(agentId);
|
|
352
|
+
this.send({
|
|
353
|
+
t: "grant",
|
|
354
|
+
agentId
|
|
355
|
+
});
|
|
356
|
+
return Promise.resolve();
|
|
357
|
+
}
|
|
358
|
+
/** Detach this CLI. The daemon keeps running for other clients. */
|
|
359
|
+
dispose() {
|
|
360
|
+
this.disposed = true;
|
|
361
|
+
if (this.reconnectTimer) {
|
|
362
|
+
clearTimeout(this.reconnectTimer);
|
|
363
|
+
this.reconnectTimer = null;
|
|
364
|
+
}
|
|
365
|
+
this.send({
|
|
366
|
+
t: "bye",
|
|
367
|
+
sessionId: this.sessionId
|
|
368
|
+
});
|
|
369
|
+
try {
|
|
370
|
+
this.socket?.destroy();
|
|
371
|
+
} catch (_error) {}
|
|
372
|
+
this.socket = null;
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
//#endregion
|
|
377
|
+
//#region src/chat/portal/create-portal-client.ts
|
|
378
|
+
/**
|
|
379
|
+
* Escape hatch: setting `SKYDIVE_NO_DAEMON` (to any non-empty value)
|
|
380
|
+
* makes the CLI bypass the portal daemon and open the portal connection
|
|
381
|
+
* in-process, the pre-daemon behavior. This is a LOCAL env switch, not a remote
|
|
382
|
+
* flag: it takes effect on the next CLI start and lets a user (or us) fall back
|
|
383
|
+
* instantly if the daemon misbehaves in the wild, without shipping a new build.
|
|
384
|
+
* The daemon is the default because it fixes the multi-process presence flap.
|
|
385
|
+
*/
|
|
386
|
+
function portalDaemonDisabled() {
|
|
387
|
+
return !!process.env.SKYDIVE_NO_DAEMON;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Build the portal client the app should use: the daemon client by default, or
|
|
391
|
+
* the in-process `PortalClient` when the kill switch is set. The in-process
|
|
392
|
+
* client always runs exec in the launch cwd (it has no conversation map), which
|
|
393
|
+
* is the exact pre-daemon behavior.
|
|
394
|
+
*/
|
|
395
|
+
function createPortalClient(opts) {
|
|
396
|
+
if (portalDaemonDisabled()) {
|
|
397
|
+
const client = new PortalClient({
|
|
398
|
+
appUrl: opts.appUrl,
|
|
399
|
+
sessionToken: opts.sessionToken,
|
|
400
|
+
resolveCwd: () => process.cwd(),
|
|
401
|
+
onState: (state) => opts.onState({
|
|
402
|
+
status: state.status,
|
|
403
|
+
machineName: state.machineName,
|
|
404
|
+
friendlyName: state.friendlyName,
|
|
405
|
+
error: state.error,
|
|
406
|
+
grantedAgentIds: state.grantedAgentIds
|
|
407
|
+
})
|
|
408
|
+
});
|
|
409
|
+
return {
|
|
410
|
+
start: () => Promise.resolve(),
|
|
411
|
+
enable: () => client.enable(),
|
|
412
|
+
disable: () => client.disable(),
|
|
413
|
+
grantAgent: (agentId) => client.grantAgent(agentId),
|
|
414
|
+
isGranted: (agentId) => client.isGranted(agentId),
|
|
415
|
+
bind: () => {},
|
|
416
|
+
dispose: () => client.dispose()
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
return new PortalDaemonClient(opts);
|
|
420
|
+
}
|
|
421
|
+
|
|
231
422
|
//#endregion
|
|
232
423
|
//#region src/chat/portal/use-portal.ts
|
|
233
424
|
/**
|
|
234
|
-
* Owns the
|
|
235
|
-
* sync with
|
|
236
|
-
*
|
|
425
|
+
* Owns the portal DAEMON CLIENT lifecycle for the app session and keeps the
|
|
426
|
+
* store in sync with the daemon's status.
|
|
427
|
+
*
|
|
428
|
+
* The CLI no longer opens the portal connection itself: it attaches to a
|
|
429
|
+
* per-host daemon (spawned on demand) that owns the single portal WebSocket, so
|
|
430
|
+
* multiple `skydive` processes on one machine share one portal identity instead
|
|
431
|
+
* of fighting over it (the flap). This hook just wires the daemon client into
|
|
432
|
+
* the store; the chat screen registers each conversation's working directory via
|
|
433
|
+
* `portalClient.bind(...)` so the daemon runs that conversation's exec in the
|
|
434
|
+
* right place.
|
|
435
|
+
*
|
|
237
436
|
* `shareMachine` opts in from launch (the `--share-machine` flag); otherwise the
|
|
238
|
-
* user turns sharing on from the chat screen.
|
|
437
|
+
* user turns sharing on from the chat screen. Detaching on unmount leaves the
|
|
438
|
+
* daemon running for any other attached CLIs.
|
|
239
439
|
*/
|
|
240
440
|
function usePortal({ appUrl, sessionToken, shareMachine }) {
|
|
241
441
|
const syncPortal = useStore((s) => s.syncPortal);
|
|
242
442
|
const setPortalClient = useStore((s) => s.setPortalClient);
|
|
243
443
|
useEffect(() => {
|
|
244
|
-
const client =
|
|
444
|
+
const client = createPortalClient({
|
|
245
445
|
appUrl,
|
|
246
446
|
sessionToken,
|
|
247
|
-
cwd: process.cwd(),
|
|
248
447
|
onState: (state) => syncPortal({
|
|
249
448
|
status: state.status,
|
|
250
449
|
machineName: state.machineName,
|
|
@@ -253,7 +452,9 @@ function usePortal({ appUrl, sessionToken, shareMachine }) {
|
|
|
253
452
|
})
|
|
254
453
|
});
|
|
255
454
|
setPortalClient(client);
|
|
256
|
-
|
|
455
|
+
client.start().then(() => {
|
|
456
|
+
if (shareMachine) client.enable();
|
|
457
|
+
});
|
|
257
458
|
return () => {
|
|
258
459
|
client.dispose();
|
|
259
460
|
setPortalClient(null);
|
|
@@ -9191,6 +9392,16 @@ function ChatScreen({ agent, conversation }) {
|
|
|
9191
9392
|
agentHost.setSession(conversationId);
|
|
9192
9393
|
setResumeConversation(conversationId);
|
|
9193
9394
|
}, [agentHost, conversationId]);
|
|
9395
|
+
const bindPortalCwd = useCallback(() => {
|
|
9396
|
+
if (!conversationId || !portalClient) return;
|
|
9397
|
+
if (portal.status === "off") return;
|
|
9398
|
+
const cwd = shellSessionRef.current?.cwd ?? process.cwd();
|
|
9399
|
+
portalClient.bind(conversationId, cwd);
|
|
9400
|
+
}, [
|
|
9401
|
+
conversationId,
|
|
9402
|
+
portalClient,
|
|
9403
|
+
portal.status
|
|
9404
|
+
]);
|
|
9194
9405
|
const scrollRef = useRef(null);
|
|
9195
9406
|
const composerRef = useRef(null);
|
|
9196
9407
|
const jumpToBottom = useCallback(() => {
|
|
@@ -9761,6 +9972,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
9761
9972
|
...m,
|
|
9762
9973
|
exit: exitCode
|
|
9763
9974
|
} : m));
|
|
9975
|
+
bindPortalCwd();
|
|
9764
9976
|
} catch (err) {
|
|
9765
9977
|
appendOutput(`\n${errorMessage(err)}`);
|
|
9766
9978
|
exitCode = 1;
|
|
@@ -9770,7 +9982,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
9770
9982
|
} : m));
|
|
9771
9983
|
}
|
|
9772
9984
|
sendContent(formatShellContext(command, captured, exitCode), [], { echo: false });
|
|
9773
|
-
}, [sendContent]);
|
|
9985
|
+
}, [sendContent, bindPortalCwd]);
|
|
9774
9986
|
const runSandboxExec = useCallback((command) => {
|
|
9775
9987
|
if (!appUrl || !sessionToken) return;
|
|
9776
9988
|
const id = crypto.randomUUID();
|
|
@@ -10099,6 +10311,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10099
10311
|
if (portal.status === "off") {
|
|
10100
10312
|
declinedRef.current.delete(agent.id);
|
|
10101
10313
|
portalClient.enable();
|
|
10314
|
+
if (conversationId) portalClient.bind(conversationId, shellSessionRef.current?.cwd ?? process.cwd());
|
|
10102
10315
|
} else {
|
|
10103
10316
|
portalClient.disable();
|
|
10104
10317
|
setPortalPrompt(null);
|
|
@@ -10107,7 +10320,8 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10107
10320
|
portalClient,
|
|
10108
10321
|
portal.status,
|
|
10109
10322
|
agent.id,
|
|
10110
|
-
setPortalPrompt
|
|
10323
|
+
setPortalPrompt,
|
|
10324
|
+
conversationId
|
|
10111
10325
|
]);
|
|
10112
10326
|
const confirmGrant = useCallback(() => {
|
|
10113
10327
|
if (!portalClient) return;
|
|
@@ -1,10 +1,66 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as errorMessage } from "./rest-Q5yl_b2-.mjs";
|
|
3
|
-
import
|
|
2
|
+
import { a as errorMessage, t as HttpError } from "./rest-Q5yl_b2-.mjs";
|
|
3
|
+
import os from "node:os";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { WebSocket } from "ws";
|
|
7
7
|
|
|
8
|
+
//#region src/chat/portal/machine.ts
|
|
9
|
+
/**
|
|
10
|
+
* Identity this machine registers under when the CLI shares it via the portal.
|
|
11
|
+
*
|
|
12
|
+
* The `-cli` suffix / `(CLI)` label keep a CLI-shared machine a DISTINCT portal
|
|
13
|
+
* device from the same host's Skydive Desktop app. `portal_device` is unique on
|
|
14
|
+
* (org, user, machineName), and directives route to whichever socket holds the
|
|
15
|
+
* device — if the CLI and desktop registered the same name they'd share a
|
|
16
|
+
* device row and both execute every directive. Distinct names also make the
|
|
17
|
+
* grant UI unambiguous about which surface is being authorized.
|
|
18
|
+
*/
|
|
19
|
+
function machineIdentity() {
|
|
20
|
+
const host = (os.hostname() || "machine").trim().replace(/\.local$/i, "") || "machine";
|
|
21
|
+
return {
|
|
22
|
+
machineName: `${host}-cli`,
|
|
23
|
+
friendlyName: `${host} (CLI)`
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const INHERITED_ENV = [
|
|
27
|
+
"HOME",
|
|
28
|
+
"USER",
|
|
29
|
+
"LOGNAME",
|
|
30
|
+
"SHELL",
|
|
31
|
+
"LANG",
|
|
32
|
+
"LC_ALL",
|
|
33
|
+
"TMPDIR",
|
|
34
|
+
"TERM",
|
|
35
|
+
"PATH"
|
|
36
|
+
];
|
|
37
|
+
function buildEnv(extra) {
|
|
38
|
+
const env = {};
|
|
39
|
+
for (const key of INHERITED_ENV) {
|
|
40
|
+
const value = process.env[key];
|
|
41
|
+
if (value !== void 0) env[key] = value;
|
|
42
|
+
}
|
|
43
|
+
if (extra) for (const [key, value] of Object.entries(extra)) env[key] = value;
|
|
44
|
+
return env;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Build the desktop-portal WebSocket URL from the chat origin. Mirrors the Rust
|
|
48
|
+
* desktop client: http→ws, https→wss, scheme-less defaults to wss, and the
|
|
49
|
+
* machine/label ride as query pairs (percent-encoded by URL).
|
|
50
|
+
*/
|
|
51
|
+
function portalWsUrl(appUrl, machine, label) {
|
|
52
|
+
const base = appUrl.replace(/\/+$/, "");
|
|
53
|
+
let wsBase;
|
|
54
|
+
if (base.startsWith("https://")) wsBase = `wss://${base.slice(8)}`;
|
|
55
|
+
else if (base.startsWith("http://")) wsBase = `ws://${base.slice(7)}`;
|
|
56
|
+
else wsBase = `wss://${base}`;
|
|
57
|
+
const url = new URL(`${wsBase}/api/v1/portal/desktop`);
|
|
58
|
+
url.searchParams.set("machine", machine);
|
|
59
|
+
url.searchParams.set("label", label);
|
|
60
|
+
return url.toString();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
//#endregion
|
|
8
64
|
//#region ../portal-protocol/src/index.ts
|
|
9
65
|
const MAX_WS_FRAME_BYTES = 16 * 1024 * 1024;
|
|
10
66
|
const T_DATA = 1;
|
|
@@ -31,7 +87,8 @@ const ctrlMessageSchema = z.discriminatedUnion("t", [
|
|
|
31
87
|
z.object({
|
|
32
88
|
t: z.literal("open"),
|
|
33
89
|
argv: z.array(z.string()),
|
|
34
|
-
env: z.record(z.string()).nullable()
|
|
90
|
+
env: z.record(z.string()).nullable(),
|
|
91
|
+
conversationId: z.string().nullable().optional()
|
|
35
92
|
}),
|
|
36
93
|
z.object({ t: z.literal("stdin_eof") }),
|
|
37
94
|
z.object({ t: z.literal("pause") }),
|
|
@@ -103,7 +160,7 @@ var JobManager = class {
|
|
|
103
160
|
handleCtrl(id, msg) {
|
|
104
161
|
switch (msg.t) {
|
|
105
162
|
case "open":
|
|
106
|
-
this.startJob(id, msg.argv, msg.env);
|
|
163
|
+
this.startJob(id, msg.argv, msg.env, msg.conversationId ?? null);
|
|
107
164
|
return;
|
|
108
165
|
case "stdin_eof":
|
|
109
166
|
this.jobs.get(id)?.child.stdin.end();
|
|
@@ -133,7 +190,7 @@ var JobManager = class {
|
|
|
133
190
|
job.child.stderr.resume();
|
|
134
191
|
}
|
|
135
192
|
}
|
|
136
|
-
startJob(id, argv, env) {
|
|
193
|
+
startJob(id, argv, env, conversationId) {
|
|
137
194
|
const [program, ...args] = argv;
|
|
138
195
|
if (!program) {
|
|
139
196
|
this.opts.send(encodeCtrl(id, {
|
|
@@ -145,7 +202,7 @@ var JobManager = class {
|
|
|
145
202
|
let child;
|
|
146
203
|
try {
|
|
147
204
|
child = spawn(program, args, {
|
|
148
|
-
cwd: this.opts.
|
|
205
|
+
cwd: this.opts.resolveCwd(conversationId),
|
|
149
206
|
env: buildEnv(env),
|
|
150
207
|
stdio: [
|
|
151
208
|
"pipe",
|
|
@@ -194,6 +251,89 @@ var JobManager = class {
|
|
|
194
251
|
}
|
|
195
252
|
};
|
|
196
253
|
|
|
254
|
+
//#endregion
|
|
255
|
+
//#region src/chat/portal/api.ts
|
|
256
|
+
/**
|
|
257
|
+
* The portal's session-authed REST surface, shared by `PortalClient` (the
|
|
258
|
+
* TUI/`portal open` connection) and the `skydive portal` management
|
|
259
|
+
* commands, so the endpoint contracts and response schemas live in exactly
|
|
260
|
+
* one place.
|
|
261
|
+
*/
|
|
262
|
+
const deviceSchema = z.object({
|
|
263
|
+
id: z.string(),
|
|
264
|
+
machineName: z.string(),
|
|
265
|
+
friendlyName: z.string(),
|
|
266
|
+
connected: z.boolean(),
|
|
267
|
+
lastSeen: z.string().nullable(),
|
|
268
|
+
grantedAgentIds: z.array(z.string())
|
|
269
|
+
});
|
|
270
|
+
const devicesResponseSchema = z.object({
|
|
271
|
+
devices: z.array(deviceSchema),
|
|
272
|
+
agents: z.array(z.object({
|
|
273
|
+
id: z.string(),
|
|
274
|
+
name: z.string()
|
|
275
|
+
}))
|
|
276
|
+
});
|
|
277
|
+
const deviceTokenSchema = z.object({ token: z.string().min(1) });
|
|
278
|
+
async function portalFetch(auth, path, init) {
|
|
279
|
+
const res = await fetch(`${auth.appUrl}${path}`, {
|
|
280
|
+
method: init.method,
|
|
281
|
+
headers: {
|
|
282
|
+
authorization: `Bearer ${auth.sessionToken}`,
|
|
283
|
+
accept: "application/json",
|
|
284
|
+
...init.body ? { "content-type": "application/json" } : {}
|
|
285
|
+
},
|
|
286
|
+
...init.body ? { body: init.body } : {}
|
|
287
|
+
});
|
|
288
|
+
if (!res.ok) {
|
|
289
|
+
const body = await res.text().catch(() => "");
|
|
290
|
+
throw new HttpError(res.status, body);
|
|
291
|
+
}
|
|
292
|
+
return res.json();
|
|
293
|
+
}
|
|
294
|
+
async function fetchPortalDevices(auth) {
|
|
295
|
+
const json = await portalFetch(auth, "/api/v1/portal/devices", { method: "GET" });
|
|
296
|
+
return devicesResponseSchema.parse(json);
|
|
297
|
+
}
|
|
298
|
+
const registerResponseSchema = z.object({ device: z.object({ id: z.string() }) });
|
|
299
|
+
/**
|
|
300
|
+
* Register this machine's device row without connecting. Connecting registers
|
|
301
|
+
* as a side effect; this covers granting an agent on a machine that has never
|
|
302
|
+
* shared yet (the grant references the device row).
|
|
303
|
+
*/
|
|
304
|
+
async function registerPortalDevice(auth, { machineName, friendlyName }) {
|
|
305
|
+
const json = await portalFetch(auth, "/api/v1/portal/devices", {
|
|
306
|
+
method: "POST",
|
|
307
|
+
body: JSON.stringify({
|
|
308
|
+
machineName,
|
|
309
|
+
friendlyName
|
|
310
|
+
})
|
|
311
|
+
});
|
|
312
|
+
return registerResponseSchema.parse(json).device;
|
|
313
|
+
}
|
|
314
|
+
/** Short-lived token the machine presents when dialing the portal WebSocket. */
|
|
315
|
+
async function mintPortalDeviceToken(auth) {
|
|
316
|
+
const json = await portalFetch(auth, "/api/v1/portal/device-token", { method: "POST" });
|
|
317
|
+
return deviceTokenSchema.parse(json).token;
|
|
318
|
+
}
|
|
319
|
+
async function grantPortalAccess(auth, { deviceId, agentId }) {
|
|
320
|
+
await portalFetch(auth, `/api/v1/portal/devices/${encodeURIComponent(deviceId)}/grants`, {
|
|
321
|
+
method: "POST",
|
|
322
|
+
body: JSON.stringify({ agentId })
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
async function revokePortalAccess(auth, { deviceId, agentId }) {
|
|
326
|
+
await portalFetch(auth, `/api/v1/portal/devices/${encodeURIComponent(deviceId)}/grants/${encodeURIComponent(agentId)}`, { method: "DELETE" });
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* The device row for a given machine identity. Matching is by `machineName`
|
|
330
|
+
* equality — the stable per-surface handle (`<host>-cli` vs the desktop's
|
|
331
|
+
* `<host>`), not the display label.
|
|
332
|
+
*/
|
|
333
|
+
function findThisDevice(devices, machineName) {
|
|
334
|
+
return devices.find((device) => device.machineName === machineName) ?? null;
|
|
335
|
+
}
|
|
336
|
+
|
|
197
337
|
//#endregion
|
|
198
338
|
//#region src/chat/portal/client.ts
|
|
199
339
|
const INITIAL_BACKOFF_MS = 500;
|
|
@@ -309,7 +449,7 @@ var PortalClient = class {
|
|
|
309
449
|
});
|
|
310
450
|
this.ws = ws;
|
|
311
451
|
const jobs = new JobManager({
|
|
312
|
-
|
|
452
|
+
resolveCwd: this.opts.resolveCwd,
|
|
313
453
|
send: (frame) => {
|
|
314
454
|
if (ws.readyState === WebSocket.OPEN) ws.send(frame);
|
|
315
455
|
}
|
|
@@ -363,4 +503,4 @@ function sleep(ms) {
|
|
|
363
503
|
}
|
|
364
504
|
|
|
365
505
|
//#endregion
|
|
366
|
-
export { PortalClient as t };
|
|
506
|
+
export { registerPortalDevice as a, grantPortalAccess as i, fetchPortalDevices as n, revokePortalAccess as o, findThisDevice as r, machineIdentity as s, PortalClient as t };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "./rest-Q5yl_b2-.mjs";
|
|
3
|
+
import "./client-DT9bWi1D.mjs";
|
|
4
|
+
import { a as runPortalDaemon, i as queryDaemonStatus, n as ensureDaemonRunning, o as stopDaemon, r as isDaemonListening, t as PortalDaemon } from "./daemon-DfMgVlYE.mjs";
|
|
5
|
+
|
|
6
|
+
export { runPortalDaemon };
|