skydive-cli 0.5.0 → 0.6.0-beta.100
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 +15 -0
- package/dist/js/bin.mjs +867 -89
- package/dist/js/{boot-DD4T-61U.mjs → boot--2bLAufE.mjs} +11404 -9140
- package/dist/js/client-BfwRLRDF.mjs +5 -0
- package/dist/js/{client-Btq6bMzX.mjs → client-Cxk3khQu.mjs} +40 -5
- package/dist/js/{client--k9cjfkX.mjs → client-HdxGBXPK.mjs} +98 -11
- package/dist/js/{daemon-CCgNLD0H.mjs → daemon--Yp4BWJq.mjs} +2 -2
- package/dist/js/{daemon-Do1jU2UF.mjs → daemon-B8c3Z1Te.mjs} +228 -30
- package/dist/js/daemon-client-5ZIZn3qg.mjs +8 -0
- package/dist/js/{daemon-client-Dvad009G.mjs → daemon-client-xaS4xE-L.mjs} +29 -4
- package/dist/js/{install-CtAVvERm.mjs → install-ZUWt-WGX.mjs} +49 -3
- package/dist/js/{print-D_UEjdSw.mjs → print-B_jFXZ1W.mjs} +71 -10
- package/dist/js/{print-Bx8qUC9U.mjs → print-CIzEvzQk.mjs} +2 -2
- package/dist/js/{print-share-Cz0EO2RK.mjs → print-share-Cl9t3Etr.mjs} +1 -1
- package/dist/js/{raw-pty-D5PhKZSl.mjs → raw-pty-B-WKaaE3.mjs} +1 -1
- package/dist/js/raw-pty-BJIs_xjQ.mjs +5 -0
- package/dist/js/{rest-Dc0EEok3.mjs → rest-83NiD7BB.mjs} +1 -1
- package/dist/js/{rest-B9__Zsuk.mjs → rest-YroXUA7G.mjs} +89 -6
- package/package.json +10 -8
- package/dist/js/client-Ct0-JZSS.mjs +0 -5
- package/dist/js/daemon-client-C7nE-lLK.mjs +0 -8
- package/dist/js/raw-pty-Ci2qFR9F.mjs +0 -5
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { a as portalDaemonBuild, i as isNewerBuild, n as PortalClient, o as isRecord } from "./client-HdxGBXPK.mjs";
|
|
3
3
|
import { t as defaultTlsCertSource } from "./tls-cert-Rua2oV7n.mjs";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
+
import { access, appendFile, mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
import { spawn } from "node:child_process";
|
|
8
9
|
import { createHash } from "node:crypto";
|
|
9
10
|
import { connect, createServer } from "node:net";
|
|
10
|
-
import { access, appendFile, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
11
11
|
|
|
12
12
|
//#region ../portal-daemon/src/local-protocol.ts
|
|
13
13
|
/**
|
|
@@ -66,6 +66,18 @@ function canonicalPortalAppUrl(appUrl) {
|
|
|
66
66
|
if (host === "skydive.com" || host === "www.skydive.com" || host === "api.skydive.com") return "https://api.skydive.com";
|
|
67
67
|
return trimmed;
|
|
68
68
|
}
|
|
69
|
+
/** Prefix every Windows named pipe lives under (the pipe filesystem root). */
|
|
70
|
+
const WINDOWS_PIPE_PREFIX = "\\\\.\\pipe\\";
|
|
71
|
+
/**
|
|
72
|
+
* Whether a daemon control-socket path is a Windows named pipe rather than a
|
|
73
|
+
* Unix-domain-socket FILE. Pipes have no on-disk file: they cannot be
|
|
74
|
+
* `stat`ed, `unlink`ed, or purged by a tmp cleaner, and the OS frees the name
|
|
75
|
+
* the moment the owning server closes — so every file-based lifecycle step
|
|
76
|
+
* (stale-file cleanup, the socket-ownership watch) must be skipped for them.
|
|
77
|
+
*/
|
|
78
|
+
function isNamedPipePath(socketPath) {
|
|
79
|
+
return socketPath.startsWith(WINDOWS_PIPE_PREFIX);
|
|
80
|
+
}
|
|
69
81
|
/**
|
|
70
82
|
* Per-user, per-app daemon socket + state paths. Scoped by OS user and a hash
|
|
71
83
|
* of the CANONICAL appUrl so two logins or two deployments (prod vs a PR
|
|
@@ -73,17 +85,42 @@ function canonicalPortalAppUrl(appUrl) {
|
|
|
73
85
|
* every alias of one deployment (see {@link canonicalPortalAppUrl}) shares
|
|
74
86
|
* one. Kept under the OS temp dir because Unix socket paths are
|
|
75
87
|
* length-limited (~104 bytes on macOS).
|
|
88
|
+
*
|
|
89
|
+
* On Windows a `node:net` server cannot listen on a filesystem path at all —
|
|
90
|
+
* binding the tmp-dir path fails with EACCES and the daemon dies silently
|
|
91
|
+
* (ANY-7285's Windows facet) — so the socket is a named pipe under
|
|
92
|
+
* `\\.\pipe\`, keyed by the same (user, appUrl-hash) pair. State and log
|
|
93
|
+
* files stay under the temp dir on every platform.
|
|
94
|
+
*
|
|
95
|
+
* Darwin `sockaddr_un.sun_path` is 104 bytes including NUL (103 usable).
|
|
96
|
+
* `os.tmpdir()` on macOS is `$TMPDIR` (`/var/folders/<id>/T`, often ~50
|
|
97
|
+
* chars). Adding `skydive-portal-<user>-<hash>/daemon.sock` overruns that
|
|
98
|
+
* cap: bind() fails, the sidecar handshake still reports the long path, and
|
|
99
|
+
* connect() returns ENOENT (or Go's "path must be shorter than SUN_LEN").
|
|
100
|
+
* Stay in `os.tmpdir()` (do not relocate to `/tmp`) and shorten the leaf
|
|
101
|
+
* when the composed path would blow the cap. Windows named pipes have no
|
|
102
|
+
* sun_path, so they keep the long leaf.
|
|
76
103
|
*/
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
104
|
+
const SUN_PATH_MAX = 103;
|
|
105
|
+
function daemonPaths(appUrl, platform = process.platform) {
|
|
106
|
+
const parent = os.tmpdir();
|
|
107
|
+
const key = `skydive-portal-${safeSegment(os.userInfo().username || "user")}-${createHash("sha256").update(canonicalPortalAppUrl(appUrl)).digest("hex").slice(0, 12)}`;
|
|
108
|
+
const dir = path.join(parent, key);
|
|
109
|
+
const paths = {
|
|
82
110
|
dir,
|
|
83
|
-
socketPath: path.join(dir, "daemon.sock"),
|
|
111
|
+
socketPath: platform === "win32" ? `${WINDOWS_PIPE_PREFIX}${key}` : path.join(dir, "daemon.sock"),
|
|
84
112
|
statePath: path.join(dir, "state.json"),
|
|
85
113
|
logPath: path.join(dir, "daemon.log")
|
|
86
114
|
};
|
|
115
|
+
if (platform === "win32" || Buffer.byteLength(paths.socketPath) <= SUN_PATH_MAX) return paths;
|
|
116
|
+
const short = createHash("sha256").update(`${os.userInfo().username || "user"}\0${canonicalPortalAppUrl(appUrl)}`).digest("hex").slice(0, 12);
|
|
117
|
+
const shortDir = path.join(parent, `skydive-p-${short}`);
|
|
118
|
+
return {
|
|
119
|
+
dir: shortDir,
|
|
120
|
+
socketPath: path.join(shortDir, "d.sock"),
|
|
121
|
+
statePath: path.join(shortDir, "state.json"),
|
|
122
|
+
logPath: path.join(shortDir, "daemon.log")
|
|
123
|
+
};
|
|
87
124
|
}
|
|
88
125
|
function safeSegment(value) {
|
|
89
126
|
return value.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 24) || "user";
|
|
@@ -336,6 +373,24 @@ function parseDaemonMessage(line) {
|
|
|
336
373
|
/** Grace period after the last client detaches before the daemon exits. */
|
|
337
374
|
const IDLE_SHUTDOWN_MS = 3e4;
|
|
338
375
|
/**
|
|
376
|
+
* Cadence of the socket-ownership watch (see checkSocketOwnership). The
|
|
377
|
+
* singleton is enforced ONLY through the socket path, so this bounds how long
|
|
378
|
+
* a daemon can keep dialing after losing the path — the window in which a
|
|
379
|
+
* spawner can create a duplicate dialer. The server-side connect flap guard
|
|
380
|
+
* caps the damage inside that window.
|
|
381
|
+
*/
|
|
382
|
+
const SOCKET_WATCH_TICK_MS = 3e4;
|
|
383
|
+
/**
|
|
384
|
+
* Grace window after a server stand-down: the check fires the instant the
|
|
385
|
+
* close lands, but the WINNER's socket bind may still be in flight — a check
|
|
386
|
+
* run at t=0 can see our own still-good socket, conclude we are the owner, and
|
|
387
|
+
* redial into the supersede war the stand-down exists to end. On a
|
|
388
|
+
* stand-down-triggered check, wait this out once and re-stat before
|
|
389
|
+
* concluding ownership. The periodic watch tick runs grace-free (nothing just
|
|
390
|
+
* displaced us there).
|
|
391
|
+
*/
|
|
392
|
+
const STAND_DOWN_GRACE_MS = 1500;
|
|
393
|
+
/**
|
|
339
394
|
* Upper bound an incumbent daemon waits, during a graceful handoff, for the
|
|
340
395
|
* successor to dial out and claim presence before giving up and releasing the
|
|
341
396
|
* slot itself. Comfortably above a normal successor boot + WS dial (a few
|
|
@@ -358,17 +413,26 @@ var PortalDaemon = class {
|
|
|
358
413
|
handingOff = false;
|
|
359
414
|
sessionToken = null;
|
|
360
415
|
deviceToken = null;
|
|
361
|
-
|
|
416
|
+
socketWatch = null;
|
|
417
|
+
socketId = null;
|
|
418
|
+
socketCheckRunning = false;
|
|
419
|
+
socketWatchTickMs;
|
|
420
|
+
standDownGraceMs;
|
|
421
|
+
exit;
|
|
422
|
+
constructor(appUrl, options) {
|
|
362
423
|
this.appUrl = canonicalPortalAppUrl(appUrl);
|
|
363
424
|
this.paths = daemonPaths(this.appUrl);
|
|
364
425
|
this.fallbackCwd = process.env.HOME ?? process.cwd();
|
|
426
|
+
this.socketWatchTickMs = options?.socketWatchTickMs ?? SOCKET_WATCH_TICK_MS;
|
|
427
|
+
this.standDownGraceMs = options?.standDownGraceMs ?? STAND_DOWN_GRACE_MS;
|
|
428
|
+
this.exit = options?.exit ?? (() => process.exit(0));
|
|
365
429
|
}
|
|
366
430
|
/** Start listening. Rejects if the socket is already held by another daemon. */
|
|
367
431
|
async listen() {
|
|
368
432
|
await mkdir(this.paths.dir, { recursive: true });
|
|
369
433
|
await this.loadState();
|
|
370
434
|
await this.clearStaleSocket();
|
|
371
|
-
|
|
435
|
+
await new Promise((resolve, reject) => {
|
|
372
436
|
const server = createServer((socket) => this.onClientConnect(socket));
|
|
373
437
|
server.on("error", reject);
|
|
374
438
|
server.listen(this.paths.socketPath, () => {
|
|
@@ -377,8 +441,128 @@ var PortalDaemon = class {
|
|
|
377
441
|
resolve();
|
|
378
442
|
});
|
|
379
443
|
});
|
|
444
|
+
if (!isNamedPipePath(this.paths.socketPath)) {
|
|
445
|
+
await this.recordSocketIdentity();
|
|
446
|
+
this.startSocketWatch();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Remember which socket FILE we bound (dev+ino), so the watch can tell "our
|
|
451
|
+
* file is still there" from "someone re-created the path" — the path alone
|
|
452
|
+
* can't: a duplicate daemon's fresh socket lives at the identical path.
|
|
453
|
+
*/
|
|
454
|
+
async recordSocketIdentity() {
|
|
455
|
+
const s = await stat(this.paths.socketPath);
|
|
456
|
+
this.socketId = {
|
|
457
|
+
dev: s.dev,
|
|
458
|
+
ino: s.ino
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* The singleton is enforced ONLY through the socket path — status, stop, and
|
|
463
|
+
* newest-build takeover all reach a daemon by connecting to it. If the file
|
|
464
|
+
* vanishes under us (the OS tmp cleaner purges /var/folders temp items that
|
|
465
|
+
* haven't been touched in days; the daemon holds the bound fd and never
|
|
466
|
+
* notices), this daemon keeps its portal connection and its attached clients
|
|
467
|
+
* but becomes unreachable and unmanageable: every new `skydive` spawn finds
|
|
468
|
+
* no socket and starts a DUPLICATE daemon, and the two fight a supersede war
|
|
469
|
+
* over the machine's presence slot (the 2026-08-25 flap loop). So the daemon
|
|
470
|
+
* re-verifies its claim on a timer: re-bind if the path is free, stand down
|
|
471
|
+
* if another daemon beat us to it.
|
|
472
|
+
*/
|
|
473
|
+
startSocketWatch() {
|
|
474
|
+
this.socketWatch = setInterval(() => {
|
|
475
|
+
this.runOwnershipCheck();
|
|
476
|
+
}, this.socketWatchTickMs);
|
|
477
|
+
this.socketWatch.unref();
|
|
478
|
+
}
|
|
479
|
+
/** One socket-ownership pass, shared by the watch timer (grace-free) and the
|
|
480
|
+
* client's 'stand-down' state (with grace — see STAND_DOWN_GRACE_MS).
|
|
481
|
+
*/
|
|
482
|
+
runOwnershipCheck(graceMs = 0) {
|
|
483
|
+
if (this.socketCheckRunning) return;
|
|
484
|
+
this.socketCheckRunning = true;
|
|
485
|
+
this.checkSocketOwnership(graceMs).catch((error) => this.logError("socket ownership check failed", error)).finally(() => {
|
|
486
|
+
this.socketCheckRunning = false;
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
stopSocketWatch() {
|
|
490
|
+
if (this.socketWatch) {
|
|
491
|
+
clearInterval(this.socketWatch);
|
|
492
|
+
this.socketWatch = null;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
async checkSocketOwnership(graceMs = 0) {
|
|
496
|
+
if (!this.server || this.handingOff) return;
|
|
497
|
+
const current = await stat(this.paths.socketPath).catch(() => null);
|
|
498
|
+
if (current && this.socketId && current.dev === this.socketId.dev && current.ino === this.socketId.ino) {
|
|
499
|
+
if (graceMs > 0) {
|
|
500
|
+
await new Promise((r) => setTimeout(r, graceMs));
|
|
501
|
+
const recheck = await stat(this.paths.socketPath).catch(() => null);
|
|
502
|
+
const socketId = this.socketId;
|
|
503
|
+
if (recheck && socketId && !(recheck.dev === socketId.dev && recheck.ino === socketId.ino)) {
|
|
504
|
+
this.logInfo("control socket taken over during stand-down grace; standing down");
|
|
505
|
+
this.standDown();
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (current) {
|
|
512
|
+
this.logInfo("control socket path is owned by another daemon; standing down");
|
|
513
|
+
this.standDown();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
await this.rebindSocket();
|
|
518
|
+
this.logInfo("control socket file vanished (tmp cleaner?); re-bound");
|
|
519
|
+
} catch (error) {
|
|
520
|
+
this.logError("control socket re-bind failed; standing down", error);
|
|
521
|
+
this.standDown();
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Re-create the socket file at our path. The old server object keeps serving
|
|
526
|
+
* its ESTABLISHED connections (close() only stops accepting), so attached
|
|
527
|
+
* clients ride through the swap; only the listener moves to the new file.
|
|
528
|
+
*/
|
|
529
|
+
async rebindSocket() {
|
|
530
|
+
const old = this.server;
|
|
531
|
+
this.server = null;
|
|
532
|
+
old?.close();
|
|
533
|
+
await mkdir(this.paths.dir, { recursive: true });
|
|
534
|
+
await new Promise((resolve, reject) => {
|
|
535
|
+
const server = createServer((socket) => this.onClientConnect(socket));
|
|
536
|
+
server.on("error", reject);
|
|
537
|
+
server.listen(this.paths.socketPath, () => {
|
|
538
|
+
this.server = server;
|
|
539
|
+
resolve();
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
await this.recordSocketIdentity();
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* This daemon lost the singleton claim: tear everything down and exit.
|
|
546
|
+
* Attached clients see their control socket close and re-attach through the
|
|
547
|
+
* path — reaching whichever daemon owns it now (or spawning a fresh one) —
|
|
548
|
+
* so the machine converges on a single dialer instead of a supersede war.
|
|
549
|
+
*/
|
|
550
|
+
standDown() {
|
|
551
|
+
this.stopSocketWatch();
|
|
552
|
+
for (const conn of this.conns) try {
|
|
553
|
+
conn.socket.destroy();
|
|
554
|
+
} catch (_error) {}
|
|
555
|
+
this.conns.clear();
|
|
556
|
+
this.client?.dispose();
|
|
557
|
+
this.server?.close();
|
|
558
|
+
this.server = null;
|
|
559
|
+
this.exit();
|
|
380
560
|
}
|
|
381
561
|
async clearStaleSocket() {
|
|
562
|
+
if (isNamedPipePath(this.paths.socketPath)) {
|
|
563
|
+
if (await isDaemonListening(this.paths.socketPath)) throw new Error("daemon already running");
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
382
566
|
if (!await pathExists(this.paths.socketPath)) return;
|
|
383
567
|
if (await new Promise((resolve) => {
|
|
384
568
|
const probe = connect(this.paths.socketPath);
|
|
@@ -494,13 +678,23 @@ var PortalDaemon = class {
|
|
|
494
678
|
tlsCertSource: defaultTlsCertSource(process.env, (msg) => this.logInfo(msg)),
|
|
495
679
|
persistedMachineName: this.persistedMachineName,
|
|
496
680
|
onMachineName: (name, source) => this.onMachineName(name, source),
|
|
497
|
-
onState: (state) =>
|
|
498
|
-
this.lastState = state;
|
|
499
|
-
this.broadcastState();
|
|
500
|
-
}
|
|
681
|
+
onState: (state) => this.handleClientState(state)
|
|
501
682
|
});
|
|
502
683
|
}
|
|
503
684
|
/**
|
|
685
|
+
* Client state transition. A server stand-down ('stand-down' status — the
|
|
686
|
+
* superseded/flap-cooldown close, distinguishable from an ordinary close
|
|
687
|
+
* through the status) runs the ownership check NOW with grace, so a
|
|
688
|
+
* duplicate that already lost the control socket yields within one server
|
|
689
|
+
* round trip instead of waiting for the next watch tick and fighting
|
|
690
|
+
* (supersede war).
|
|
691
|
+
*/
|
|
692
|
+
handleClientState(state) {
|
|
693
|
+
if (state.status === "stand-down") this.runOwnershipCheck(this.standDownGraceMs);
|
|
694
|
+
this.lastState = state;
|
|
695
|
+
this.broadcastState();
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
504
698
|
* The identity resolved on connect. Persist the name so a later scutil
|
|
505
699
|
* failure reuses it instead of adopting the volatile hostname, and log the
|
|
506
700
|
* source — a `hostname-fallback` after we'd previously registered under
|
|
@@ -560,15 +754,17 @@ var PortalDaemon = class {
|
|
|
560
754
|
/** Close the listener without exiting the process (tests own the process). */
|
|
561
755
|
stopForTest() {
|
|
562
756
|
this.clearIdleTimer();
|
|
757
|
+
this.stopSocketWatch();
|
|
563
758
|
this.client?.dispose();
|
|
564
759
|
this.server?.close();
|
|
565
760
|
this.server = null;
|
|
566
761
|
}
|
|
567
762
|
shutdown() {
|
|
568
763
|
if (this.conns.size > 0) return;
|
|
764
|
+
this.stopSocketWatch();
|
|
569
765
|
this.client?.dispose();
|
|
570
766
|
this.server?.close();
|
|
571
|
-
|
|
767
|
+
this.exit();
|
|
572
768
|
}
|
|
573
769
|
/** Snapshot for a `status` control request. */
|
|
574
770
|
statusResult() {
|
|
@@ -580,7 +776,7 @@ var PortalDaemon = class {
|
|
|
580
776
|
appUrl: this.appUrl,
|
|
581
777
|
build: portalDaemonBuild(),
|
|
582
778
|
portal: {
|
|
583
|
-
status: portal?.status ?? "off",
|
|
779
|
+
status: portal?.status === "stand-down" ? "connecting" : portal?.status ?? "off",
|
|
584
780
|
machineName: portal?.machineName ?? "",
|
|
585
781
|
friendlyName: portal?.friendlyName ?? "",
|
|
586
782
|
error: portal?.error ?? null,
|
|
@@ -597,13 +793,7 @@ var PortalDaemon = class {
|
|
|
597
793
|
* their live exec/tunnel relays), drops the portal connection, and exits.
|
|
598
794
|
*/
|
|
599
795
|
forceShutdown() {
|
|
600
|
-
|
|
601
|
-
conn.socket.destroy();
|
|
602
|
-
} catch (_error) {}
|
|
603
|
-
this.conns.clear();
|
|
604
|
-
this.client?.dispose();
|
|
605
|
-
this.server?.close();
|
|
606
|
-
process.exit(0);
|
|
796
|
+
this.standDown();
|
|
607
797
|
}
|
|
608
798
|
/**
|
|
609
799
|
* Graceful takeover step-down (a newer daemon is replacing us). Unlike
|
|
@@ -624,6 +814,7 @@ var PortalDaemon = class {
|
|
|
624
814
|
async gracefulHandoffShutdown() {
|
|
625
815
|
if (this.handingOff) return;
|
|
626
816
|
this.handingOff = true;
|
|
817
|
+
this.stopSocketWatch();
|
|
627
818
|
for (const conn of this.conns) try {
|
|
628
819
|
conn.socket.destroy();
|
|
629
820
|
} catch (_error) {}
|
|
@@ -634,7 +825,7 @@ var PortalDaemon = class {
|
|
|
634
825
|
await this.client.beginHandoff(HANDOFF_MAX_WAIT_MS);
|
|
635
826
|
this.client.dispose();
|
|
636
827
|
}
|
|
637
|
-
|
|
828
|
+
this.exit();
|
|
638
829
|
}
|
|
639
830
|
async loadState() {
|
|
640
831
|
try {
|
|
@@ -678,13 +869,19 @@ var PortalDaemon = class {
|
|
|
678
869
|
* drains attached clients, who reconnect within ~500ms) and spawn from this
|
|
679
870
|
* binary. Both spawners apply the same rule, so a host converges on the
|
|
680
871
|
* newest installed build with at most one bounce.
|
|
872
|
+
*
|
|
873
|
+
* Returns whether a daemon is actually listening when it's done. A spawned
|
|
874
|
+
* daemon that never binds (its process died on startup — e.g. the Windows
|
|
875
|
+
* EACCES bind failure this used to swallow) resolves `false` so callers can
|
|
876
|
+
* SAY so, instead of silently handing the caller a socket nobody serves and
|
|
877
|
+
* leaving the reconnect loop to spin forever with no visible error.
|
|
681
878
|
*/
|
|
682
879
|
async function ensureDaemonRunning(appUrl) {
|
|
683
880
|
const { socketPath } = daemonPaths(appUrl);
|
|
684
881
|
if (await isDaemonListening(socketPath)) {
|
|
685
882
|
const status = await queryDaemonStatus(appUrl);
|
|
686
|
-
if (!status || !isNewerBuild(portalDaemonBuild(), status.build)) return;
|
|
687
|
-
if (await stopDaemonForHandoff(appUrl) === "failed") return;
|
|
883
|
+
if (!status || !isNewerBuild(portalDaemonBuild(), status.build)) return true;
|
|
884
|
+
if (await stopDaemonForHandoff(appUrl) === "failed") return true;
|
|
688
885
|
}
|
|
689
886
|
const entry = process.argv[1];
|
|
690
887
|
const args = entry ? [
|
|
@@ -698,9 +895,10 @@ async function ensureDaemonRunning(appUrl) {
|
|
|
698
895
|
env: process.env
|
|
699
896
|
}).unref();
|
|
700
897
|
for (let i = 0; i < 50; i += 1) {
|
|
701
|
-
if (await isDaemonListening(socketPath)) return;
|
|
898
|
+
if (await isDaemonListening(socketPath)) return true;
|
|
702
899
|
await sleep(100);
|
|
703
900
|
}
|
|
901
|
+
return false;
|
|
704
902
|
}
|
|
705
903
|
/**
|
|
706
904
|
* Entry the CLI calls when launched with PORTAL_DAEMON_FLAG: become the daemon.
|
|
@@ -718,10 +916,10 @@ function runPortalDaemon(argv) {
|
|
|
718
916
|
* CLI's re-exec entry above and the standalone `main.ts` entry.
|
|
719
917
|
*/
|
|
720
918
|
function startPortalDaemon(appUrl) {
|
|
721
|
-
return new PortalDaemon(appUrl).listen();
|
|
919
|
+
return new PortalDaemon(appUrl, null).listen();
|
|
722
920
|
}
|
|
723
921
|
async function isDaemonListening(socketPath) {
|
|
724
|
-
if (!await pathExists(socketPath)) return false;
|
|
922
|
+
if (!isNamedPipePath(socketPath) && !await pathExists(socketPath)) return false;
|
|
725
923
|
return new Promise((resolve) => {
|
|
726
924
|
const probe = connect(socketPath);
|
|
727
925
|
probe.on("connect", () => {
|
|
@@ -848,7 +1046,7 @@ async function stopDaemon(appUrl) {
|
|
|
848
1046
|
function stateMessage(state, declined) {
|
|
849
1047
|
return {
|
|
850
1048
|
t: "state",
|
|
851
|
-
status: state.status,
|
|
1049
|
+
status: state.status === "stand-down" ? "connecting" : state.status,
|
|
852
1050
|
machineName: state.machineName,
|
|
853
1051
|
friendlyName: state.friendlyName,
|
|
854
1052
|
error: state.error,
|
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { d as daemonPaths, f as encodeLine, l as LOCAL_PROTOCOL_VERSION, m as parseDaemonMessage, n as ensureDaemonRunning, p as makeLineParser } from "./daemon-
|
|
2
|
+
import { d as daemonPaths, f as encodeLine, l as LOCAL_PROTOCOL_VERSION, m as parseDaemonMessage, n as ensureDaemonRunning, p as makeLineParser } from "./daemon-B8c3Z1Te.mjs";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { connect } from "node:net";
|
|
5
5
|
|
|
6
6
|
//#region ../portal-daemon/src/daemon-client.ts
|
|
7
|
+
/** Reconnect delay when an established daemon connection drops. */
|
|
8
|
+
const RECONNECT_DELAY_MS = 500;
|
|
9
|
+
/** Base delay between daemon SPAWN retries (doubles per consecutive failure). */
|
|
10
|
+
const START_RETRY_BASE_MS = 2e3;
|
|
11
|
+
/** Consecutive failed spawns before start() stops respawning. */
|
|
12
|
+
const MAX_START_ATTEMPTS = 3;
|
|
7
13
|
var PortalDaemonClient = class {
|
|
8
14
|
sessionId = randomUUID();
|
|
9
15
|
socket = null;
|
|
10
16
|
disposed = false;
|
|
11
17
|
reconnectTimer = null;
|
|
18
|
+
startFailures = 0;
|
|
12
19
|
lastBind = null;
|
|
13
20
|
fallbackCwd = null;
|
|
14
21
|
wantEnabled = false;
|
|
@@ -21,7 +28,21 @@ var PortalDaemonClient = class {
|
|
|
21
28
|
/** Spawn the daemon if needed and attach. Idempotent. */
|
|
22
29
|
async start() {
|
|
23
30
|
if (this.disposed) return;
|
|
24
|
-
await ensureDaemonRunning(this.opts.appUrl)
|
|
31
|
+
if (!await ensureDaemonRunning(this.opts.appUrl)) {
|
|
32
|
+
this.startFailures += 1;
|
|
33
|
+
const exhausted = this.startFailures >= MAX_START_ATTEMPTS;
|
|
34
|
+
this.opts.onState({
|
|
35
|
+
status: "error",
|
|
36
|
+
machineName: "",
|
|
37
|
+
friendlyName: "",
|
|
38
|
+
error: exhausted ? "portal daemon failed to start repeatedly; toggle sharing to retry" : "portal daemon failed to start (control socket never came up)",
|
|
39
|
+
grantedAgentIds: [],
|
|
40
|
+
declinedAgentIds: []
|
|
41
|
+
});
|
|
42
|
+
if (!exhausted) this.scheduleReconnect(START_RETRY_BASE_MS * 2 ** (this.startFailures - 1));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
this.startFailures = 0;
|
|
25
46
|
this.connect();
|
|
26
47
|
}
|
|
27
48
|
connect() {
|
|
@@ -74,12 +95,12 @@ var PortalDaemonClient = class {
|
|
|
74
95
|
socket.on("close", onClose);
|
|
75
96
|
socket.on("error", onClose);
|
|
76
97
|
}
|
|
77
|
-
scheduleReconnect() {
|
|
98
|
+
scheduleReconnect(delayMs = RECONNECT_DELAY_MS) {
|
|
78
99
|
if (this.disposed || this.reconnectTimer) return;
|
|
79
100
|
this.reconnectTimer = setTimeout(() => {
|
|
80
101
|
this.reconnectTimer = null;
|
|
81
102
|
this.start();
|
|
82
|
-
},
|
|
103
|
+
}, delayMs);
|
|
83
104
|
this.reconnectTimer.unref();
|
|
84
105
|
}
|
|
85
106
|
onMessage(msg) {
|
|
@@ -109,6 +130,10 @@ var PortalDaemonClient = class {
|
|
|
109
130
|
/** Turn machine sharing on (the daemon connects the portal). */
|
|
110
131
|
enable() {
|
|
111
132
|
this.wantEnabled = true;
|
|
133
|
+
if (this.startFailures >= MAX_START_ATTEMPTS) {
|
|
134
|
+
this.startFailures = 0;
|
|
135
|
+
this.start();
|
|
136
|
+
}
|
|
112
137
|
this.send({ t: "enable" });
|
|
113
138
|
}
|
|
114
139
|
/** Turn machine sharing off. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { c as version$1 } from "./rest-
|
|
3
|
-
import {
|
|
2
|
+
import { c as version$1 } from "./rest-YroXUA7G.mjs";
|
|
3
|
+
import { D as getConfigPath } from "./print-B_jFXZ1W.mjs";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { err, ok } from "neverthrow";
|
|
@@ -1547,6 +1547,51 @@ function formatMb(bytes) {
|
|
|
1547
1547
|
return `${(bytes / MB).toFixed(1)} MB`;
|
|
1548
1548
|
}
|
|
1549
1549
|
|
|
1550
|
+
//#endregion
|
|
1551
|
+
//#region src/chat/tui/autowrap.ts
|
|
1552
|
+
/**
|
|
1553
|
+
* Why the TUI turns the terminal's autowrap off for its lifetime.
|
|
1554
|
+
*
|
|
1555
|
+
* OpenTUI's incremental cell-diff emitter positions every run with an
|
|
1556
|
+
* absolute cursor move — it never relies on the terminal to advance the
|
|
1557
|
+
* cursor — but it leaves the terminal's autowrap (DECAWM) enabled. When a
|
|
1558
|
+
* run reaches the last column, the host terminal wraps its cursor to column
|
|
1559
|
+
* 1 of the next row and paints the run's tail there, into cells owned by a
|
|
1560
|
+
* different element. The diff buffer then records the *intended* cells, so
|
|
1561
|
+
* those stray bytes are never repainted and unrelated text smears together
|
|
1562
|
+
* until a full repaint (a resize). Filed upstream with a repro and root
|
|
1563
|
+
* cause: https://github.com/anomalyco/opentui/issues/1187 — unfixed there,
|
|
1564
|
+
* the consumer-side fix (verified in numux#115) is to disable DECAWM while
|
|
1565
|
+
* the renderer runs. Pane content still wraps, inside OpenTUI's own grid.
|
|
1566
|
+
*/
|
|
1567
|
+
const AUTOWRAP_OFF = "\x1B[?7l";
|
|
1568
|
+
const AUTOWRAP_ON = "\x1B[?7h";
|
|
1569
|
+
/** Escape hatch: if any surface turns out to depend on host autowrap. */
|
|
1570
|
+
const keepAutowrap = () => Boolean(process.env["SKYDIVE_TUI_AUTOWRAP"]);
|
|
1571
|
+
let disabledBy = false;
|
|
1572
|
+
/**
|
|
1573
|
+
* Disable host autowrap so the diff emitter's runs can never wrap onto the
|
|
1574
|
+
* next row. No-op when stdout is not a terminal (piped output, tests) and
|
|
1575
|
+
* under `SKYDIVE_TUI_AUTOWRAP`.
|
|
1576
|
+
*/
|
|
1577
|
+
function disableTerminalAutowrap(stdout = process.stdout) {
|
|
1578
|
+
if (!stdout.isTTY || keepAutowrap() || disabledBy) return;
|
|
1579
|
+
disabledBy = true;
|
|
1580
|
+
stdout.write(AUTOWRAP_OFF);
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Restore host autowrap. The terminal mode is a property of the host, so
|
|
1584
|
+
* every teardown path must run this even when the renderer cannot finish
|
|
1585
|
+
* its own teardown: the renderer's `onDestroy` hook covers renderer-driven
|
|
1586
|
+
* exits, and the forced-fallback reset in `hard-exit.ts` carries the
|
|
1587
|
+
* restore for a teardown that never got there. Idempotent.
|
|
1588
|
+
*/
|
|
1589
|
+
function restoreTerminalAutowrap(stdout = process.stdout) {
|
|
1590
|
+
if (!disabledBy) return;
|
|
1591
|
+
disabledBy = false;
|
|
1592
|
+
stdout.write(AUTOWRAP_ON);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1550
1595
|
//#endregion
|
|
1551
1596
|
//#region src/crash/install.ts
|
|
1552
1597
|
let installed = false;
|
|
@@ -1566,6 +1611,7 @@ function installCrashHandler(opts = {}) {
|
|
|
1566
1611
|
if (installed) return;
|
|
1567
1612
|
installed = true;
|
|
1568
1613
|
process.on("uncaughtException", (error) => {
|
|
1614
|
+
restoreTerminalAutowrap();
|
|
1569
1615
|
const memory = safeMemory();
|
|
1570
1616
|
printFatalNotice(writeCrashReport(buildCrashReport({
|
|
1571
1617
|
reason: "uncaughtException",
|
|
@@ -1594,4 +1640,4 @@ function printFatalNotice(file, memory) {
|
|
|
1594
1640
|
}
|
|
1595
1641
|
|
|
1596
1642
|
//#endregion
|
|
1597
|
-
export {
|
|
1643
|
+
export { aliasActivationHint as A, getSessionIdentity as B, themeMode as C, themesForMode as D, themes as E, SUPPORTED_SHELLS as F, resolveWorkspaceId as H, defaultInstallEnv as I, detectShell as L, installAgentAlias as M, slugifyAliasName as N, buildImportSeedPrompt as O, takenAliasNames as P, ensureActiveOrganization as R, themeForMode as S, themeVersion as T, setActiveWorkspace as U, listWorkspaces as V, applyTheme as _, writeCrashReport as a, noColorRequested as b, brandHelpArt as c, maybeStartProfiling as d, profileDir as f, DEFAULT_THEME_ID as g, writeArtifact as h, buildCrashReport as i, dedupeAliasName as j, machineOsFromPlatform as k, splashFitsWidth as l, record as m, disableTerminalAutowrap as n, MARK_CELLS as o, profilingEnabled as p, restoreTerminalAutowrap as r, WORDMARK as s, installCrashHandler as t, getAutomaticLogsDir as u, findTheme as v, themeModeFromColorFgBg as w, theme as x, monoTheme as y, getActiveWorkspaceId as z };
|