@kmmao/happy-agent 0.3.1 → 0.3.3
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/dist/index.cjs +147 -1
- package/dist/index.d.cts +31 -46
- package/dist/index.d.mts +31 -46
- package/dist/index.mjs +148 -2
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -18,7 +18,7 @@ var path = require('path');
|
|
|
18
18
|
var fs = require('fs');
|
|
19
19
|
var os = require('os');
|
|
20
20
|
|
|
21
|
-
var version = "0.3.
|
|
21
|
+
var version = "0.3.3";
|
|
22
22
|
|
|
23
23
|
function loadConfig() {
|
|
24
24
|
const serverUrl = (process.env.HAPPY_SERVER_URL ?? "https://happyserve.xycloud.info").replace(/\/+$/, "");
|
|
@@ -1351,11 +1351,114 @@ class SessionClient extends node_events.EventEmitter {
|
|
|
1351
1351
|
}
|
|
1352
1352
|
}
|
|
1353
1353
|
|
|
1354
|
+
const NOT_INSTALLED = Object.freeze({ status: "not-installed" });
|
|
1355
|
+
const DISCONNECTED = Object.freeze({ status: "disconnected" });
|
|
1356
|
+
const DETECT_TIMEOUT_MS = 3e3;
|
|
1357
|
+
async function detectTailscale() {
|
|
1358
|
+
try {
|
|
1359
|
+
const raw = await execTailscale(["status", "--json"]);
|
|
1360
|
+
return parseTailscaleStatus(raw);
|
|
1361
|
+
} catch (err) {
|
|
1362
|
+
if (isNotFound(err)) {
|
|
1363
|
+
logger.debug("[TAILSCALE] tailscale binary not found");
|
|
1364
|
+
return NOT_INSTALLED;
|
|
1365
|
+
}
|
|
1366
|
+
logger.debug(`[TAILSCALE] detection failed: ${String(err)}`);
|
|
1367
|
+
return DISCONNECTED;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
async function detectTailscaleServe() {
|
|
1371
|
+
try {
|
|
1372
|
+
const raw = await execTailscale(["serve", "status", "--json"]);
|
|
1373
|
+
return parseTailscaleServeStatus(raw);
|
|
1374
|
+
} catch (err) {
|
|
1375
|
+
logger.debug(`[TAILSCALE] serve detection failed: ${String(err)}`);
|
|
1376
|
+
return [];
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
function execTailscale(args) {
|
|
1380
|
+
return new Promise((resolve, reject) => {
|
|
1381
|
+
child_process.execFile("tailscale", args, { timeout: DETECT_TIMEOUT_MS }, (err, stdout) => {
|
|
1382
|
+
if (err) return reject(err);
|
|
1383
|
+
resolve(stdout);
|
|
1384
|
+
});
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
function parseTailscaleStatus(raw) {
|
|
1388
|
+
let json;
|
|
1389
|
+
try {
|
|
1390
|
+
json = JSON.parse(raw);
|
|
1391
|
+
} catch {
|
|
1392
|
+
logger.debug("[TAILSCALE] failed to parse status JSON");
|
|
1393
|
+
return DISCONNECTED;
|
|
1394
|
+
}
|
|
1395
|
+
const backendState = json.BackendState;
|
|
1396
|
+
if (backendState && backendState !== "Running") {
|
|
1397
|
+
logger.debug(`[TAILSCALE] backend state: ${backendState}`);
|
|
1398
|
+
return DISCONNECTED;
|
|
1399
|
+
}
|
|
1400
|
+
const self = json.Self;
|
|
1401
|
+
if (!self) {
|
|
1402
|
+
logger.debug("[TAILSCALE] no Self node in status output");
|
|
1403
|
+
return DISCONNECTED;
|
|
1404
|
+
}
|
|
1405
|
+
const ips = self.TailscaleIPs ?? [];
|
|
1406
|
+
const ipv4 = ips.find((ip) => ip.includes("."));
|
|
1407
|
+
const ipv6 = ips.find((ip) => ip.includes(":"));
|
|
1408
|
+
const dnsName = self.DNSName;
|
|
1409
|
+
const hostname = dnsName?.replace(/\.$/, "").split(".")[0];
|
|
1410
|
+
const tailnetName = dnsName ? dnsName.replace(/\.$/, "").split(".").slice(1).join(".") : void 0;
|
|
1411
|
+
const version = json.Version;
|
|
1412
|
+
logger.debug(
|
|
1413
|
+
`[TAILSCALE] detected: ipv4=${ipv4 ?? "none"}, hostname=${hostname ?? "none"}`
|
|
1414
|
+
);
|
|
1415
|
+
return {
|
|
1416
|
+
status: "connected",
|
|
1417
|
+
ipv4,
|
|
1418
|
+
ipv6,
|
|
1419
|
+
hostname,
|
|
1420
|
+
tailnetName,
|
|
1421
|
+
version
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
function parseTailscaleServeStatus(raw) {
|
|
1425
|
+
let json;
|
|
1426
|
+
try {
|
|
1427
|
+
json = JSON.parse(raw);
|
|
1428
|
+
} catch {
|
|
1429
|
+
logger.debug("[TAILSCALE] failed to parse serve status JSON");
|
|
1430
|
+
return [];
|
|
1431
|
+
}
|
|
1432
|
+
const web = json.Web ?? {};
|
|
1433
|
+
const allowFunnel = json.AllowFunnel ?? {};
|
|
1434
|
+
const entries = [];
|
|
1435
|
+
for (const [hostPort, config] of Object.entries(web)) {
|
|
1436
|
+
const colonIdx = hostPort.lastIndexOf(":");
|
|
1437
|
+
if (colonIdx === -1) continue;
|
|
1438
|
+
const hostname = hostPort.slice(0, colonIdx);
|
|
1439
|
+
const port = parseInt(hostPort.slice(colonIdx + 1), 10);
|
|
1440
|
+
if (!Number.isFinite(port)) continue;
|
|
1441
|
+
const handlers = config.Handlers ?? {};
|
|
1442
|
+
const rootHandler = handlers["/"];
|
|
1443
|
+
const target = rootHandler?.Proxy ?? "unknown";
|
|
1444
|
+
const funnel = allowFunnel[hostPort] === true;
|
|
1445
|
+
entries.push({ port, protocol: "HTTPS", target, funnel, hostname });
|
|
1446
|
+
}
|
|
1447
|
+
logger.debug(`[TAILSCALE] detected ${entries.length} serve entries`);
|
|
1448
|
+
return entries;
|
|
1449
|
+
}
|
|
1450
|
+
function isNotFound(err) {
|
|
1451
|
+
return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
const TAILSCALE_REFRESH_MS = 5 * 60 * 1e3;
|
|
1354
1455
|
class MachineClient {
|
|
1355
1456
|
machine;
|
|
1356
1457
|
rpcHandlerManager;
|
|
1357
1458
|
socket;
|
|
1358
1459
|
keepAliveInterval = null;
|
|
1460
|
+
tailscaleRefreshInterval = null;
|
|
1461
|
+
lastTailscaleInfo = null;
|
|
1359
1462
|
token;
|
|
1360
1463
|
serverUrl;
|
|
1361
1464
|
onEphemeral;
|
|
@@ -1393,12 +1496,21 @@ class MachineClient {
|
|
|
1393
1496
|
this.socket.on("connect", () => {
|
|
1394
1497
|
logger.debug("[MACHINE] Connected to server");
|
|
1395
1498
|
this.rpcHandlerManager.onSocketConnect(this.socket);
|
|
1499
|
+
this.updateDaemonState((state) => ({
|
|
1500
|
+
...state,
|
|
1501
|
+
status: "running",
|
|
1502
|
+
pid: process.pid,
|
|
1503
|
+
startedAt: Date.now(),
|
|
1504
|
+
tailscale: this.lastTailscaleInfo ?? state?.tailscale
|
|
1505
|
+
}));
|
|
1396
1506
|
this.startKeepAlive();
|
|
1507
|
+
this.startTailscaleRefresh();
|
|
1397
1508
|
});
|
|
1398
1509
|
this.socket.on("disconnect", () => {
|
|
1399
1510
|
logger.debug("[MACHINE] Disconnected from server");
|
|
1400
1511
|
this.rpcHandlerManager.onSocketDisconnect();
|
|
1401
1512
|
this.stopKeepAlive();
|
|
1513
|
+
this.stopTailscaleRefresh();
|
|
1402
1514
|
});
|
|
1403
1515
|
this.socket.on(
|
|
1404
1516
|
"rpc-request",
|
|
@@ -1512,9 +1624,14 @@ class MachineClient {
|
|
|
1512
1624
|
// -----------------------------------------------------------------------
|
|
1513
1625
|
// Lifecycle
|
|
1514
1626
|
// -----------------------------------------------------------------------
|
|
1627
|
+
/** Seed initial Tailscale info detected before connect. */
|
|
1628
|
+
setTailscaleInfo(info) {
|
|
1629
|
+
this.lastTailscaleInfo = info;
|
|
1630
|
+
}
|
|
1515
1631
|
shutdown() {
|
|
1516
1632
|
logger.debug("[MACHINE] Shutting down");
|
|
1517
1633
|
this.stopKeepAlive();
|
|
1634
|
+
this.stopTailscaleRefresh();
|
|
1518
1635
|
this.rpcHandlerManager.onSocketDisconnect();
|
|
1519
1636
|
this.socket?.close();
|
|
1520
1637
|
}
|
|
@@ -1536,6 +1653,35 @@ class MachineClient {
|
|
|
1536
1653
|
this.keepAliveInterval = null;
|
|
1537
1654
|
}
|
|
1538
1655
|
}
|
|
1656
|
+
startTailscaleRefresh() {
|
|
1657
|
+
this.stopTailscaleRefresh();
|
|
1658
|
+
this.tailscaleRefreshInterval = setInterval(async () => {
|
|
1659
|
+
const info = await detectTailscale();
|
|
1660
|
+
const serves = info.status === "connected" ? await detectTailscaleServe() : [];
|
|
1661
|
+
const fullInfo = { ...info, serves };
|
|
1662
|
+
if (tailscaleChanged(this.lastTailscaleInfo, fullInfo)) {
|
|
1663
|
+
logger.debug(
|
|
1664
|
+
`[MACHINE] Tailscale changed: ${this.lastTailscaleInfo?.status} \u2192 ${fullInfo.status}, serves: ${serves.length}`
|
|
1665
|
+
);
|
|
1666
|
+
this.lastTailscaleInfo = fullInfo;
|
|
1667
|
+
this.updateDaemonState((state) => {
|
|
1668
|
+
if (!state) return { status: "running", tailscale: fullInfo };
|
|
1669
|
+
return { ...state, tailscale: fullInfo };
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
}, TAILSCALE_REFRESH_MS);
|
|
1673
|
+
logger.debug("[MACHINE] Tailscale refresh started (5m interval)");
|
|
1674
|
+
}
|
|
1675
|
+
stopTailscaleRefresh() {
|
|
1676
|
+
if (this.tailscaleRefreshInterval) {
|
|
1677
|
+
clearInterval(this.tailscaleRefreshInterval);
|
|
1678
|
+
this.tailscaleRefreshInterval = null;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
function tailscaleChanged(prev, next) {
|
|
1683
|
+
if (!prev) return true;
|
|
1684
|
+
return prev.status !== next.status || prev.ipv4 !== next.ipv4 || prev.ipv6 !== next.ipv6 || prev.hostname !== next.hostname || JSON.stringify(prev.serves) !== JSON.stringify(next.serves);
|
|
1539
1685
|
}
|
|
1540
1686
|
|
|
1541
1687
|
function formatTime(ts) {
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { MachineMetadata, DaemonState } from '@kmmao/happy-wire';
|
|
3
3
|
import { EventEmitter } from 'node:events';
|
|
4
4
|
import { Socket } from 'socket.io-client';
|
|
5
5
|
|
|
@@ -58,51 +58,7 @@ type DecryptedSession = {
|
|
|
58
58
|
dataEncryptionKey: string | null;
|
|
59
59
|
encryption: SessionEncryption;
|
|
60
60
|
};
|
|
61
|
-
|
|
62
|
-
host: z.ZodString;
|
|
63
|
-
platform: z.ZodString;
|
|
64
|
-
happyCliVersion: z.ZodString;
|
|
65
|
-
homeDir: z.ZodString;
|
|
66
|
-
happyHomeDir: z.ZodString;
|
|
67
|
-
happyLibDir: z.ZodString;
|
|
68
|
-
}, z.core.$strip>;
|
|
69
|
-
type MachineMetadata = z.infer<typeof MachineMetadataSchema>;
|
|
70
|
-
declare const DaemonStateSchema: z.ZodObject<{
|
|
71
|
-
status: z.ZodUnion<readonly [z.ZodEnum<{
|
|
72
|
-
running: "running";
|
|
73
|
-
"shutting-down": "shutting-down";
|
|
74
|
-
}>, z.ZodString]>;
|
|
75
|
-
pid: z.ZodOptional<z.ZodNumber>;
|
|
76
|
-
httpPort: z.ZodOptional<z.ZodNumber>;
|
|
77
|
-
startedAt: z.ZodOptional<z.ZodNumber>;
|
|
78
|
-
shutdownRequestedAt: z.ZodOptional<z.ZodNumber>;
|
|
79
|
-
shutdownSource: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
|
|
80
|
-
unknown: "unknown";
|
|
81
|
-
"mobile-app": "mobile-app";
|
|
82
|
-
cli: "cli";
|
|
83
|
-
"os-signal": "os-signal";
|
|
84
|
-
}>, z.ZodString]>>;
|
|
85
|
-
tailscale: z.ZodOptional<z.ZodObject<{
|
|
86
|
-
status: z.ZodEnum<{
|
|
87
|
-
connected: "connected";
|
|
88
|
-
disconnected: "disconnected";
|
|
89
|
-
"not-installed": "not-installed";
|
|
90
|
-
}>;
|
|
91
|
-
ipv4: z.ZodOptional<z.ZodString>;
|
|
92
|
-
ipv6: z.ZodOptional<z.ZodString>;
|
|
93
|
-
hostname: z.ZodOptional<z.ZodString>;
|
|
94
|
-
tailnetName: z.ZodOptional<z.ZodString>;
|
|
95
|
-
version: z.ZodOptional<z.ZodString>;
|
|
96
|
-
serves: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
97
|
-
port: z.ZodNumber;
|
|
98
|
-
protocol: z.ZodString;
|
|
99
|
-
target: z.ZodString;
|
|
100
|
-
funnel: z.ZodBoolean;
|
|
101
|
-
hostname: z.ZodString;
|
|
102
|
-
}, z.core.$strip>>>;
|
|
103
|
-
}, z.core.$strip>>;
|
|
104
|
-
}, z.core.$strip>;
|
|
105
|
-
type DaemonState = z.infer<typeof DaemonStateSchema>;
|
|
61
|
+
|
|
106
62
|
type Machine = {
|
|
107
63
|
readonly id: string;
|
|
108
64
|
readonly encryptionKey: Uint8Array;
|
|
@@ -298,6 +254,29 @@ declare class SessionClient extends EventEmitter {
|
|
|
298
254
|
private setupSocketListeners;
|
|
299
255
|
}
|
|
300
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Tailscale detection utility for happy-agent.
|
|
259
|
+
*
|
|
260
|
+
* Adapted from happy-cli/src/utils/tailscale.ts — keep in sync.
|
|
261
|
+
*/
|
|
262
|
+
type TailscaleStatus = "connected" | "disconnected" | "not-installed";
|
|
263
|
+
type TailscaleServeEntry = {
|
|
264
|
+
port: number;
|
|
265
|
+
protocol: string;
|
|
266
|
+
target: string;
|
|
267
|
+
funnel: boolean;
|
|
268
|
+
hostname: string;
|
|
269
|
+
};
|
|
270
|
+
type TailscaleInfo = {
|
|
271
|
+
status: TailscaleStatus;
|
|
272
|
+
ipv4?: string;
|
|
273
|
+
ipv6?: string;
|
|
274
|
+
hostname?: string;
|
|
275
|
+
tailnetName?: string;
|
|
276
|
+
version?: string;
|
|
277
|
+
serves?: TailscaleServeEntry[];
|
|
278
|
+
};
|
|
279
|
+
|
|
301
280
|
/**
|
|
302
281
|
* Machine WebSocket client — trimmed from CLI's ApiMachineClient.
|
|
303
282
|
*
|
|
@@ -331,6 +310,8 @@ declare class MachineClient {
|
|
|
331
310
|
readonly rpcHandlerManager: RpcHandlerManager;
|
|
332
311
|
private socket;
|
|
333
312
|
private keepAliveInterval;
|
|
313
|
+
private tailscaleRefreshInterval;
|
|
314
|
+
private lastTailscaleInfo;
|
|
334
315
|
private readonly token;
|
|
335
316
|
private readonly serverUrl;
|
|
336
317
|
private readonly onEphemeral?;
|
|
@@ -338,9 +319,13 @@ declare class MachineClient {
|
|
|
338
319
|
connect(): void;
|
|
339
320
|
updateMachineMetadata(handler: (metadata: MachineMetadata | null) => MachineMetadata): Promise<void>;
|
|
340
321
|
updateDaemonState(handler: (state: DaemonState | null) => DaemonState): Promise<void>;
|
|
322
|
+
/** Seed initial Tailscale info detected before connect. */
|
|
323
|
+
setTailscaleInfo(info: TailscaleInfo): void;
|
|
341
324
|
shutdown(): void;
|
|
342
325
|
private startKeepAlive;
|
|
343
326
|
private stopKeepAlive;
|
|
327
|
+
private startTailscaleRefresh;
|
|
328
|
+
private stopTailscaleRefresh;
|
|
344
329
|
}
|
|
345
330
|
|
|
346
331
|
export { MachineClient, RpcHandlerManager, SessionClient, authLogin, authLogout, authStatus, createRpcHandlerManager, createSession, deleteSession, fetchMessagesAfterSeq, getOrCreateMachine, getSessionMessages, listActiveSessions, listMachines, listSessions, loadConfig, readCredentials, requireCredentials, resolveSessionEncryption, sendMessagesBatch };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { MachineMetadata, DaemonState } from '@kmmao/happy-wire';
|
|
3
3
|
import { EventEmitter } from 'node:events';
|
|
4
4
|
import { Socket } from 'socket.io-client';
|
|
5
5
|
|
|
@@ -58,51 +58,7 @@ type DecryptedSession = {
|
|
|
58
58
|
dataEncryptionKey: string | null;
|
|
59
59
|
encryption: SessionEncryption;
|
|
60
60
|
};
|
|
61
|
-
|
|
62
|
-
host: z.ZodString;
|
|
63
|
-
platform: z.ZodString;
|
|
64
|
-
happyCliVersion: z.ZodString;
|
|
65
|
-
homeDir: z.ZodString;
|
|
66
|
-
happyHomeDir: z.ZodString;
|
|
67
|
-
happyLibDir: z.ZodString;
|
|
68
|
-
}, z.core.$strip>;
|
|
69
|
-
type MachineMetadata = z.infer<typeof MachineMetadataSchema>;
|
|
70
|
-
declare const DaemonStateSchema: z.ZodObject<{
|
|
71
|
-
status: z.ZodUnion<readonly [z.ZodEnum<{
|
|
72
|
-
running: "running";
|
|
73
|
-
"shutting-down": "shutting-down";
|
|
74
|
-
}>, z.ZodString]>;
|
|
75
|
-
pid: z.ZodOptional<z.ZodNumber>;
|
|
76
|
-
httpPort: z.ZodOptional<z.ZodNumber>;
|
|
77
|
-
startedAt: z.ZodOptional<z.ZodNumber>;
|
|
78
|
-
shutdownRequestedAt: z.ZodOptional<z.ZodNumber>;
|
|
79
|
-
shutdownSource: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
|
|
80
|
-
unknown: "unknown";
|
|
81
|
-
"mobile-app": "mobile-app";
|
|
82
|
-
cli: "cli";
|
|
83
|
-
"os-signal": "os-signal";
|
|
84
|
-
}>, z.ZodString]>>;
|
|
85
|
-
tailscale: z.ZodOptional<z.ZodObject<{
|
|
86
|
-
status: z.ZodEnum<{
|
|
87
|
-
connected: "connected";
|
|
88
|
-
disconnected: "disconnected";
|
|
89
|
-
"not-installed": "not-installed";
|
|
90
|
-
}>;
|
|
91
|
-
ipv4: z.ZodOptional<z.ZodString>;
|
|
92
|
-
ipv6: z.ZodOptional<z.ZodString>;
|
|
93
|
-
hostname: z.ZodOptional<z.ZodString>;
|
|
94
|
-
tailnetName: z.ZodOptional<z.ZodString>;
|
|
95
|
-
version: z.ZodOptional<z.ZodString>;
|
|
96
|
-
serves: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
97
|
-
port: z.ZodNumber;
|
|
98
|
-
protocol: z.ZodString;
|
|
99
|
-
target: z.ZodString;
|
|
100
|
-
funnel: z.ZodBoolean;
|
|
101
|
-
hostname: z.ZodString;
|
|
102
|
-
}, z.core.$strip>>>;
|
|
103
|
-
}, z.core.$strip>>;
|
|
104
|
-
}, z.core.$strip>;
|
|
105
|
-
type DaemonState = z.infer<typeof DaemonStateSchema>;
|
|
61
|
+
|
|
106
62
|
type Machine = {
|
|
107
63
|
readonly id: string;
|
|
108
64
|
readonly encryptionKey: Uint8Array;
|
|
@@ -298,6 +254,29 @@ declare class SessionClient extends EventEmitter {
|
|
|
298
254
|
private setupSocketListeners;
|
|
299
255
|
}
|
|
300
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Tailscale detection utility for happy-agent.
|
|
259
|
+
*
|
|
260
|
+
* Adapted from happy-cli/src/utils/tailscale.ts — keep in sync.
|
|
261
|
+
*/
|
|
262
|
+
type TailscaleStatus = "connected" | "disconnected" | "not-installed";
|
|
263
|
+
type TailscaleServeEntry = {
|
|
264
|
+
port: number;
|
|
265
|
+
protocol: string;
|
|
266
|
+
target: string;
|
|
267
|
+
funnel: boolean;
|
|
268
|
+
hostname: string;
|
|
269
|
+
};
|
|
270
|
+
type TailscaleInfo = {
|
|
271
|
+
status: TailscaleStatus;
|
|
272
|
+
ipv4?: string;
|
|
273
|
+
ipv6?: string;
|
|
274
|
+
hostname?: string;
|
|
275
|
+
tailnetName?: string;
|
|
276
|
+
version?: string;
|
|
277
|
+
serves?: TailscaleServeEntry[];
|
|
278
|
+
};
|
|
279
|
+
|
|
301
280
|
/**
|
|
302
281
|
* Machine WebSocket client — trimmed from CLI's ApiMachineClient.
|
|
303
282
|
*
|
|
@@ -331,6 +310,8 @@ declare class MachineClient {
|
|
|
331
310
|
readonly rpcHandlerManager: RpcHandlerManager;
|
|
332
311
|
private socket;
|
|
333
312
|
private keepAliveInterval;
|
|
313
|
+
private tailscaleRefreshInterval;
|
|
314
|
+
private lastTailscaleInfo;
|
|
334
315
|
private readonly token;
|
|
335
316
|
private readonly serverUrl;
|
|
336
317
|
private readonly onEphemeral?;
|
|
@@ -338,9 +319,13 @@ declare class MachineClient {
|
|
|
338
319
|
connect(): void;
|
|
339
320
|
updateMachineMetadata(handler: (metadata: MachineMetadata | null) => MachineMetadata): Promise<void>;
|
|
340
321
|
updateDaemonState(handler: (state: DaemonState | null) => DaemonState): Promise<void>;
|
|
322
|
+
/** Seed initial Tailscale info detected before connect. */
|
|
323
|
+
setTailscaleInfo(info: TailscaleInfo): void;
|
|
341
324
|
shutdown(): void;
|
|
342
325
|
private startKeepAlive;
|
|
343
326
|
private stopKeepAlive;
|
|
327
|
+
private startTailscaleRefresh;
|
|
328
|
+
private stopTailscaleRefresh;
|
|
344
329
|
}
|
|
345
330
|
|
|
346
331
|
export { MachineClient, RpcHandlerManager, SessionClient, authLogin, authLogout, authStatus, createRpcHandlerManager, createSession, deleteSession, fetchMessagesAfterSeq, getOrCreateMachine, getSessionMessages, listActiveSessions, listMachines, listSessions, loadConfig, readCredentials, requireCredentials, resolveSessionEncryption, sendMessagesBatch };
|
package/dist/index.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import axios, { AxiosError } from 'axios';
|
|
|
8
8
|
import qrcode from 'qrcode-terminal';
|
|
9
9
|
import { EventEmitter } from 'node:events';
|
|
10
10
|
import { io } from 'socket.io-client';
|
|
11
|
-
import { exec } from 'child_process';
|
|
11
|
+
import { exec, execFile } from 'child_process';
|
|
12
12
|
import { promisify } from 'util';
|
|
13
13
|
import { readFile, mkdir, writeFile, readdir, stat } from 'fs/promises';
|
|
14
14
|
import { createHash as createHash$1 } from 'crypto';
|
|
@@ -16,7 +16,7 @@ import { join as join$1, resolve } from 'path';
|
|
|
16
16
|
import { realpathSync } from 'fs';
|
|
17
17
|
import { tmpdir } from 'os';
|
|
18
18
|
|
|
19
|
-
var version = "0.3.
|
|
19
|
+
var version = "0.3.3";
|
|
20
20
|
|
|
21
21
|
function loadConfig() {
|
|
22
22
|
const serverUrl = (process.env.HAPPY_SERVER_URL ?? "https://happyserve.xycloud.info").replace(/\/+$/, "");
|
|
@@ -1349,11 +1349,114 @@ class SessionClient extends EventEmitter {
|
|
|
1349
1349
|
}
|
|
1350
1350
|
}
|
|
1351
1351
|
|
|
1352
|
+
const NOT_INSTALLED = Object.freeze({ status: "not-installed" });
|
|
1353
|
+
const DISCONNECTED = Object.freeze({ status: "disconnected" });
|
|
1354
|
+
const DETECT_TIMEOUT_MS = 3e3;
|
|
1355
|
+
async function detectTailscale() {
|
|
1356
|
+
try {
|
|
1357
|
+
const raw = await execTailscale(["status", "--json"]);
|
|
1358
|
+
return parseTailscaleStatus(raw);
|
|
1359
|
+
} catch (err) {
|
|
1360
|
+
if (isNotFound(err)) {
|
|
1361
|
+
logger.debug("[TAILSCALE] tailscale binary not found");
|
|
1362
|
+
return NOT_INSTALLED;
|
|
1363
|
+
}
|
|
1364
|
+
logger.debug(`[TAILSCALE] detection failed: ${String(err)}`);
|
|
1365
|
+
return DISCONNECTED;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
async function detectTailscaleServe() {
|
|
1369
|
+
try {
|
|
1370
|
+
const raw = await execTailscale(["serve", "status", "--json"]);
|
|
1371
|
+
return parseTailscaleServeStatus(raw);
|
|
1372
|
+
} catch (err) {
|
|
1373
|
+
logger.debug(`[TAILSCALE] serve detection failed: ${String(err)}`);
|
|
1374
|
+
return [];
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
function execTailscale(args) {
|
|
1378
|
+
return new Promise((resolve, reject) => {
|
|
1379
|
+
execFile("tailscale", args, { timeout: DETECT_TIMEOUT_MS }, (err, stdout) => {
|
|
1380
|
+
if (err) return reject(err);
|
|
1381
|
+
resolve(stdout);
|
|
1382
|
+
});
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
function parseTailscaleStatus(raw) {
|
|
1386
|
+
let json;
|
|
1387
|
+
try {
|
|
1388
|
+
json = JSON.parse(raw);
|
|
1389
|
+
} catch {
|
|
1390
|
+
logger.debug("[TAILSCALE] failed to parse status JSON");
|
|
1391
|
+
return DISCONNECTED;
|
|
1392
|
+
}
|
|
1393
|
+
const backendState = json.BackendState;
|
|
1394
|
+
if (backendState && backendState !== "Running") {
|
|
1395
|
+
logger.debug(`[TAILSCALE] backend state: ${backendState}`);
|
|
1396
|
+
return DISCONNECTED;
|
|
1397
|
+
}
|
|
1398
|
+
const self = json.Self;
|
|
1399
|
+
if (!self) {
|
|
1400
|
+
logger.debug("[TAILSCALE] no Self node in status output");
|
|
1401
|
+
return DISCONNECTED;
|
|
1402
|
+
}
|
|
1403
|
+
const ips = self.TailscaleIPs ?? [];
|
|
1404
|
+
const ipv4 = ips.find((ip) => ip.includes("."));
|
|
1405
|
+
const ipv6 = ips.find((ip) => ip.includes(":"));
|
|
1406
|
+
const dnsName = self.DNSName;
|
|
1407
|
+
const hostname = dnsName?.replace(/\.$/, "").split(".")[0];
|
|
1408
|
+
const tailnetName = dnsName ? dnsName.replace(/\.$/, "").split(".").slice(1).join(".") : void 0;
|
|
1409
|
+
const version = json.Version;
|
|
1410
|
+
logger.debug(
|
|
1411
|
+
`[TAILSCALE] detected: ipv4=${ipv4 ?? "none"}, hostname=${hostname ?? "none"}`
|
|
1412
|
+
);
|
|
1413
|
+
return {
|
|
1414
|
+
status: "connected",
|
|
1415
|
+
ipv4,
|
|
1416
|
+
ipv6,
|
|
1417
|
+
hostname,
|
|
1418
|
+
tailnetName,
|
|
1419
|
+
version
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
function parseTailscaleServeStatus(raw) {
|
|
1423
|
+
let json;
|
|
1424
|
+
try {
|
|
1425
|
+
json = JSON.parse(raw);
|
|
1426
|
+
} catch {
|
|
1427
|
+
logger.debug("[TAILSCALE] failed to parse serve status JSON");
|
|
1428
|
+
return [];
|
|
1429
|
+
}
|
|
1430
|
+
const web = json.Web ?? {};
|
|
1431
|
+
const allowFunnel = json.AllowFunnel ?? {};
|
|
1432
|
+
const entries = [];
|
|
1433
|
+
for (const [hostPort, config] of Object.entries(web)) {
|
|
1434
|
+
const colonIdx = hostPort.lastIndexOf(":");
|
|
1435
|
+
if (colonIdx === -1) continue;
|
|
1436
|
+
const hostname = hostPort.slice(0, colonIdx);
|
|
1437
|
+
const port = parseInt(hostPort.slice(colonIdx + 1), 10);
|
|
1438
|
+
if (!Number.isFinite(port)) continue;
|
|
1439
|
+
const handlers = config.Handlers ?? {};
|
|
1440
|
+
const rootHandler = handlers["/"];
|
|
1441
|
+
const target = rootHandler?.Proxy ?? "unknown";
|
|
1442
|
+
const funnel = allowFunnel[hostPort] === true;
|
|
1443
|
+
entries.push({ port, protocol: "HTTPS", target, funnel, hostname });
|
|
1444
|
+
}
|
|
1445
|
+
logger.debug(`[TAILSCALE] detected ${entries.length} serve entries`);
|
|
1446
|
+
return entries;
|
|
1447
|
+
}
|
|
1448
|
+
function isNotFound(err) {
|
|
1449
|
+
return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
const TAILSCALE_REFRESH_MS = 5 * 60 * 1e3;
|
|
1352
1453
|
class MachineClient {
|
|
1353
1454
|
machine;
|
|
1354
1455
|
rpcHandlerManager;
|
|
1355
1456
|
socket;
|
|
1356
1457
|
keepAliveInterval = null;
|
|
1458
|
+
tailscaleRefreshInterval = null;
|
|
1459
|
+
lastTailscaleInfo = null;
|
|
1357
1460
|
token;
|
|
1358
1461
|
serverUrl;
|
|
1359
1462
|
onEphemeral;
|
|
@@ -1391,12 +1494,21 @@ class MachineClient {
|
|
|
1391
1494
|
this.socket.on("connect", () => {
|
|
1392
1495
|
logger.debug("[MACHINE] Connected to server");
|
|
1393
1496
|
this.rpcHandlerManager.onSocketConnect(this.socket);
|
|
1497
|
+
this.updateDaemonState((state) => ({
|
|
1498
|
+
...state,
|
|
1499
|
+
status: "running",
|
|
1500
|
+
pid: process.pid,
|
|
1501
|
+
startedAt: Date.now(),
|
|
1502
|
+
tailscale: this.lastTailscaleInfo ?? state?.tailscale
|
|
1503
|
+
}));
|
|
1394
1504
|
this.startKeepAlive();
|
|
1505
|
+
this.startTailscaleRefresh();
|
|
1395
1506
|
});
|
|
1396
1507
|
this.socket.on("disconnect", () => {
|
|
1397
1508
|
logger.debug("[MACHINE] Disconnected from server");
|
|
1398
1509
|
this.rpcHandlerManager.onSocketDisconnect();
|
|
1399
1510
|
this.stopKeepAlive();
|
|
1511
|
+
this.stopTailscaleRefresh();
|
|
1400
1512
|
});
|
|
1401
1513
|
this.socket.on(
|
|
1402
1514
|
"rpc-request",
|
|
@@ -1510,9 +1622,14 @@ class MachineClient {
|
|
|
1510
1622
|
// -----------------------------------------------------------------------
|
|
1511
1623
|
// Lifecycle
|
|
1512
1624
|
// -----------------------------------------------------------------------
|
|
1625
|
+
/** Seed initial Tailscale info detected before connect. */
|
|
1626
|
+
setTailscaleInfo(info) {
|
|
1627
|
+
this.lastTailscaleInfo = info;
|
|
1628
|
+
}
|
|
1513
1629
|
shutdown() {
|
|
1514
1630
|
logger.debug("[MACHINE] Shutting down");
|
|
1515
1631
|
this.stopKeepAlive();
|
|
1632
|
+
this.stopTailscaleRefresh();
|
|
1516
1633
|
this.rpcHandlerManager.onSocketDisconnect();
|
|
1517
1634
|
this.socket?.close();
|
|
1518
1635
|
}
|
|
@@ -1534,6 +1651,35 @@ class MachineClient {
|
|
|
1534
1651
|
this.keepAliveInterval = null;
|
|
1535
1652
|
}
|
|
1536
1653
|
}
|
|
1654
|
+
startTailscaleRefresh() {
|
|
1655
|
+
this.stopTailscaleRefresh();
|
|
1656
|
+
this.tailscaleRefreshInterval = setInterval(async () => {
|
|
1657
|
+
const info = await detectTailscale();
|
|
1658
|
+
const serves = info.status === "connected" ? await detectTailscaleServe() : [];
|
|
1659
|
+
const fullInfo = { ...info, serves };
|
|
1660
|
+
if (tailscaleChanged(this.lastTailscaleInfo, fullInfo)) {
|
|
1661
|
+
logger.debug(
|
|
1662
|
+
`[MACHINE] Tailscale changed: ${this.lastTailscaleInfo?.status} \u2192 ${fullInfo.status}, serves: ${serves.length}`
|
|
1663
|
+
);
|
|
1664
|
+
this.lastTailscaleInfo = fullInfo;
|
|
1665
|
+
this.updateDaemonState((state) => {
|
|
1666
|
+
if (!state) return { status: "running", tailscale: fullInfo };
|
|
1667
|
+
return { ...state, tailscale: fullInfo };
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
}, TAILSCALE_REFRESH_MS);
|
|
1671
|
+
logger.debug("[MACHINE] Tailscale refresh started (5m interval)");
|
|
1672
|
+
}
|
|
1673
|
+
stopTailscaleRefresh() {
|
|
1674
|
+
if (this.tailscaleRefreshInterval) {
|
|
1675
|
+
clearInterval(this.tailscaleRefreshInterval);
|
|
1676
|
+
this.tailscaleRefreshInterval = null;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
function tailscaleChanged(prev, next) {
|
|
1681
|
+
if (!prev) return true;
|
|
1682
|
+
return prev.status !== next.status || prev.ipv4 !== next.ipv4 || prev.ipv6 !== next.ipv6 || prev.hostname !== next.hostname || JSON.stringify(prev.serves) !== JSON.stringify(next.serves);
|
|
1537
1683
|
}
|
|
1538
1684
|
|
|
1539
1685
|
function formatTime(ts) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kmmao/happy-agent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "CLI client for controlling Happy Coder agents remotely",
|
|
5
5
|
"author": "Kirill Dubovitskiy",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"release": "npx --no-install release-it"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@kmmao/happy-wire": "^0.
|
|
46
|
+
"@kmmao/happy-wire": "^0.3.0",
|
|
47
47
|
"axios": "^1.13.5",
|
|
48
48
|
"chalk": "^5.6.2",
|
|
49
49
|
"commander": "^13.1.0",
|