@agent-team-foundation/first-tree-hub 0.8.6 → 0.9.1
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/{bootstrap-99vUYmLs.mjs → bootstrap-CWcBzk6C.mjs} +124 -4
- package/dist/cli/index.mjs +26 -12
- package/dist/{core-e1-NPfEC.mjs → core-DzuW7b5v.mjs} +1168 -558
- package/dist/{feishu-n9Y2yGTT.mjs → feishu-GlaczcVf.mjs} +5 -0
- package/dist/index.mjs +4 -4
- package/dist/web/assets/{index-nMyXPMPC.js → index-CDv9Rfc_.js} +73 -73
- package/dist/web/assets/index-DlK6gHQF.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +3 -1
- package/dist/web/assets/index-D9iKLIsB.css +0 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { f as __require, l as __commonJSMin, m as __toESM } from "./esm-CYu4tXXn.mjs";
|
|
2
|
-
import { C as
|
|
2
|
+
import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, d as DEFAULT_HOME_DIR$1, f as agentConfigSchema, g as initConfig, i as loadCredentials, l as DEFAULT_CONFIG_DIR, m as collectMissingPrompts, n as ensureFreshAccessToken, o as resolveServerUrl, p as clientConfigSchema, s as saveAgentConfig, u as DEFAULT_DATA_DIR$1, v as migrateLegacyHome, w as setConfigValue } from "./bootstrap-CWcBzk6C.mjs";
|
|
3
3
|
import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-CJzDFY_G-CmvgUuzc.mjs";
|
|
4
|
-
import { $ as updateAdapterConfigSchema, A as createMemberSchema, B as notificationQuerySchema, C as agentTypeSchema$1, D as createAdapterMappingSchema, E as createAdapterConfigSchema, F as inboxPollQuerySchema, G as sendMessageSchema, H as refreshTokenSchema, I as isRedactedEnvValue, J as sessionEventMessageSchema, K as sendToAgentSchema, L as linkTaskChatSchema, M as createTaskSchema, N as delegateFeishuUserSchema, O as createAgentSchema, P as dryRunAgentRuntimeConfigSchema, Q as taskListQuerySchema, R as loginSchema, S as agentRuntimeConfigPayloadSchema$1, T as connectTokenExchangeSchema, U as runtimeStateMessageSchema, V as paginationQuerySchema, W as selfServiceFeishuBotSchema, X as sessionReconcileRequestSchema, Y as sessionEventSchema$1, Z as sessionStateMessageSchema, _ as addParticipantSchema, a as AGENT_SELECTOR_HEADER$1, at as updateSystemConfigSchema, b as agentBindRequestSchema, c as AGENT_TYPES, d as SYSTEM_CONFIG_DEFAULTS, et as updateAgentRuntimeConfigSchema, f as TASK_CREATOR_TYPES, g as WS_AUTH_FRAME_TIMEOUT_MS, h as TASK_TERMINAL_STATUSES, i as AGENT_BIND_REJECT_REASONS, it as updateOrganizationSchema, j as createOrganizationSchema, k as createChatSchema, l as AGENT_VISIBILITY, m as TASK_STATUSES, nt as updateChatSchema, o as AGENT_SOURCES, ot as updateTaskStatusSchema, p as TASK_HEALTH_SIGNALS, q as sessionCompletionMessageSchema, rt as updateMemberSchema, s as AGENT_STATUSES, st as wsAuthFrameSchema, tt as updateAgentSchema, u as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, v as adminCreateTaskSchema, w as clientRegisterSchema, x as agentPinnedMessageSchema$1, y as adminUpdateTaskSchema, z as messageSourceSchema$1 } from "./feishu-
|
|
4
|
+
import { $ as updateAdapterConfigSchema, A as createMemberSchema, B as notificationQuerySchema, C as agentTypeSchema$1, D as createAdapterMappingSchema, E as createAdapterConfigSchema, F as inboxPollQuerySchema, G as sendMessageSchema, H as refreshTokenSchema, I as isRedactedEnvValue, J as sessionEventMessageSchema, K as sendToAgentSchema, L as linkTaskChatSchema, M as createTaskSchema, N as delegateFeishuUserSchema, O as createAgentSchema, P as dryRunAgentRuntimeConfigSchema, Q as taskListQuerySchema, R as loginSchema, S as agentRuntimeConfigPayloadSchema$1, T as connectTokenExchangeSchema, U as runtimeStateMessageSchema, V as paginationQuerySchema, W as selfServiceFeishuBotSchema, X as sessionReconcileRequestSchema, Y as sessionEventSchema$1, Z as sessionStateMessageSchema, _ as addParticipantSchema, a as AGENT_SELECTOR_HEADER$1, at as updateSystemConfigSchema, b as agentBindRequestSchema, c as AGENT_TYPES, d as SYSTEM_CONFIG_DEFAULTS, et as updateAgentRuntimeConfigSchema, f as TASK_CREATOR_TYPES, g as WS_AUTH_FRAME_TIMEOUT_MS, h as TASK_TERMINAL_STATUSES, i as AGENT_BIND_REJECT_REASONS, it as updateOrganizationSchema, j as createOrganizationSchema, k as createChatSchema, l as AGENT_VISIBILITY, m as TASK_STATUSES, nt as updateChatSchema, o as AGENT_SOURCES, ot as updateTaskStatusSchema, p as TASK_HEALTH_SIGNALS, q as sessionCompletionMessageSchema, rt as updateMemberSchema, s as AGENT_STATUSES, st as wsAuthFrameSchema, tt as updateAgentSchema, u as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, v as adminCreateTaskSchema, w as clientRegisterSchema, x as agentPinnedMessageSchema$1, y as adminUpdateTaskSchema, z as messageSourceSchema$1 } from "./feishu-GlaczcVf.mjs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
5
6
|
import { copyFileSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs";
|
|
6
7
|
import { dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
7
8
|
import { ZodError, z } from "zod";
|
|
@@ -13,13 +14,14 @@ import { EventEmitter } from "node:events";
|
|
|
13
14
|
import WebSocket from "ws";
|
|
14
15
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
15
16
|
import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
|
|
17
|
+
import * as semver from "semver";
|
|
16
18
|
import bcrypt from "bcrypt";
|
|
17
19
|
import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
|
|
18
20
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
19
21
|
import postgres from "postgres";
|
|
20
22
|
import { fileURLToPath } from "node:url";
|
|
21
23
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
|
22
|
-
import { input, password, select } from "@inquirer/prompts";
|
|
24
|
+
import { confirm, input, password, select } from "@inquirer/prompts";
|
|
23
25
|
import cors from "@fastify/cors";
|
|
24
26
|
import rateLimit from "@fastify/rate-limit";
|
|
25
27
|
import fastifyStatic from "@fastify/static";
|
|
@@ -959,6 +961,18 @@ z.object({
|
|
|
959
961
|
type: z.literal("auth"),
|
|
960
962
|
token: z.string().min(1)
|
|
961
963
|
});
|
|
964
|
+
/**
|
|
965
|
+
* Advisory frame sent server → client immediately after `auth:ok`. It carries
|
|
966
|
+
* the Command-package version the server was bundled with, so the client can
|
|
967
|
+
* detect version drift on startup and on each reconnect. `.passthrough()` so
|
|
968
|
+
* future server versions may add fields without breaking older clients that
|
|
969
|
+
* validate this frame.
|
|
970
|
+
*/
|
|
971
|
+
const serverWelcomeFrameSchema = z.object({
|
|
972
|
+
type: z.literal("server:welcome"),
|
|
973
|
+
serverCommandVersion: z.string().min(1),
|
|
974
|
+
serverTimeMs: z.number().int().nonnegative()
|
|
975
|
+
}).passthrough();
|
|
962
976
|
const FETCH_TIMEOUT_MS = 15e3;
|
|
963
977
|
var FirstTreeHubSDK = class {
|
|
964
978
|
_baseUrl;
|
|
@@ -1132,6 +1146,8 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1132
1146
|
reconnectAttempt = 0;
|
|
1133
1147
|
closing = false;
|
|
1134
1148
|
registered = false;
|
|
1149
|
+
/** Count of `server:welcome` frames received; drives `isReconnect` flag. */
|
|
1150
|
+
welcomeFramesReceived = 0;
|
|
1135
1151
|
boundAgents = /* @__PURE__ */ new Map();
|
|
1136
1152
|
/** Agents scheduled to rebind automatically on every reconnect. */
|
|
1137
1153
|
desiredBindings = /* @__PURE__ */ new Map();
|
|
@@ -1324,6 +1340,20 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1324
1340
|
}));
|
|
1325
1341
|
return;
|
|
1326
1342
|
}
|
|
1343
|
+
if (type === "server:welcome") {
|
|
1344
|
+
const parsed = serverWelcomeFrameSchema.safeParse(msg);
|
|
1345
|
+
if (!parsed.success) {
|
|
1346
|
+
process.stderr.write(`[ClientConnection] Ignoring malformed server:welcome frame: ${parsed.error.issues.map((i) => i.message).join(", ")}\n`);
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
const isReconnect = this.welcomeFramesReceived > 0;
|
|
1350
|
+
this.welcomeFramesReceived++;
|
|
1351
|
+
this.emit("server:welcome", {
|
|
1352
|
+
frame: parsed.data,
|
|
1353
|
+
isReconnect
|
|
1354
|
+
});
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1327
1357
|
if (type === "auth:rejected" || type === "auth:expired") {
|
|
1328
1358
|
if (type === "auth:expired") this.emit("auth:expired");
|
|
1329
1359
|
this.ws?.close(4401, type);
|
|
@@ -1552,6 +1582,24 @@ defineConfig({
|
|
|
1552
1582
|
max_sessions: field(z.number().int().positive().default(10))
|
|
1553
1583
|
}
|
|
1554
1584
|
});
|
|
1585
|
+
/**
|
|
1586
|
+
* Phase-dependent defaults that flip with release milestones. Kept as a plain
|
|
1587
|
+
* module-level constant so reviews of the beta→GA transition are a one-line
|
|
1588
|
+
* diff, and so tests can mock this module to exercise both branches.
|
|
1589
|
+
*/
|
|
1590
|
+
const UPDATE_POLICIES = [
|
|
1591
|
+
"auto",
|
|
1592
|
+
"prompt",
|
|
1593
|
+
"off"
|
|
1594
|
+
];
|
|
1595
|
+
/**
|
|
1596
|
+
* Default value of `update.policy` on the Client config. During the beta this
|
|
1597
|
+
* is `"auto"` — operators rarely know to `npm i -g` weekly and we chase the
|
|
1598
|
+
* latest published Command by default. The GA PR flips it to `"prompt"` and
|
|
1599
|
+
* bumps Command to `1.0.0`.
|
|
1600
|
+
*/
|
|
1601
|
+
const UPDATE_POLICY_DEFAULT = "auto";
|
|
1602
|
+
const updatePolicySchema = z.enum(UPDATE_POLICIES);
|
|
1555
1603
|
defineConfig({
|
|
1556
1604
|
server: { url: field(z.string(), {
|
|
1557
1605
|
env: "FIRST_TREE_HUB_SERVER_URL",
|
|
@@ -1564,11 +1612,18 @@ defineConfig({
|
|
|
1564
1612
|
auto: "client-id",
|
|
1565
1613
|
env: "FIRST_TREE_HUB_CLIENT_ID"
|
|
1566
1614
|
}) },
|
|
1615
|
+
update: {
|
|
1616
|
+
policy: field(updatePolicySchema.default(UPDATE_POLICY_DEFAULT), { env: "FIRST_TREE_HUB_UPDATE_POLICY" }),
|
|
1617
|
+
restart_quiet_seconds: field(z.number().int().min(1).max(3600).default(30), { env: "FIRST_TREE_HUB_UPDATE_RESTART_QUIET_SECONDS" }),
|
|
1618
|
+
restart_check_interval_seconds: field(z.number().int().min(5).max(300).default(10), { env: "FIRST_TREE_HUB_UPDATE_RESTART_CHECK_INTERVAL_SECONDS" }),
|
|
1619
|
+
prompt_timeout_seconds: field(z.number().int().min(10).max(600).default(60), { env: "FIRST_TREE_HUB_UPDATE_PROMPT_TIMEOUT_SECONDS" })
|
|
1620
|
+
},
|
|
1567
1621
|
logLevel: field(logLevelSchema.default("info"), { env: "FIRST_TREE_HUB_LOG_LEVEL" })
|
|
1568
1622
|
});
|
|
1569
|
-
const DEFAULT_HOME_DIR = process.env.FIRST_TREE_HUB_HOME ?? join(homedir(), ".first-tree
|
|
1623
|
+
const DEFAULT_HOME_DIR = process.env.FIRST_TREE_HUB_HOME ?? join(homedir(), ".first-tree", "hub");
|
|
1570
1624
|
join(DEFAULT_HOME_DIR, "config");
|
|
1571
1625
|
const DEFAULT_DATA_DIR = join(DEFAULT_HOME_DIR, "data");
|
|
1626
|
+
join(homedir(), ".first-tree-hub");
|
|
1572
1627
|
defineConfig({
|
|
1573
1628
|
database: {
|
|
1574
1629
|
url: field(z.string(), {
|
|
@@ -3235,6 +3290,20 @@ var SessionManager = class {
|
|
|
3235
3290
|
get totalCount() {
|
|
3236
3291
|
return this.sessions.size;
|
|
3237
3292
|
}
|
|
3293
|
+
/**
|
|
3294
|
+
* Snapshot used by the UpdateManager's quiet gate to decide whether it is
|
|
3295
|
+
* safe to exit the process for a self-update. `activeCount` is the number of
|
|
3296
|
+
* sessions currently handling a message; `lastActivityMs` is the most recent
|
|
3297
|
+
* activity timestamp across all tracked sessions (0 when there are none).
|
|
3298
|
+
*/
|
|
3299
|
+
getQuietGateSnapshot() {
|
|
3300
|
+
let lastActivityMs = 0;
|
|
3301
|
+
for (const entry of this.sessions.values()) if (entry.lastActivity > lastActivityMs) lastActivityMs = entry.lastActivity;
|
|
3302
|
+
return {
|
|
3303
|
+
activeCount: this._activeCount,
|
|
3304
|
+
lastActivityMs
|
|
3305
|
+
};
|
|
3306
|
+
}
|
|
3238
3307
|
/** Return the current aggregate runtime state, or null if no sessions have reported. */
|
|
3239
3308
|
getAggregateRuntimeState() {
|
|
3240
3309
|
return this.lastReportedRuntimeState;
|
|
@@ -3558,6 +3627,17 @@ var AgentSlot = class {
|
|
|
3558
3627
|
get clientConnection() {
|
|
3559
3628
|
return this.config.clientConnection;
|
|
3560
3629
|
}
|
|
3630
|
+
/**
|
|
3631
|
+
* Snapshot of this slot's busy/idle state used by the UpdateManager's
|
|
3632
|
+
* quiet gate. Returns zeros before `start()` has built the session manager,
|
|
3633
|
+
* which is the same semantics: idle.
|
|
3634
|
+
*/
|
|
3635
|
+
getQuietGateSnapshot() {
|
|
3636
|
+
return this.sessionManager?.getQuietGateSnapshot() ?? {
|
|
3637
|
+
activeCount: 0,
|
|
3638
|
+
lastActivityMs: 0
|
|
3639
|
+
};
|
|
3640
|
+
}
|
|
3561
3641
|
async start(contextTreePath) {
|
|
3562
3642
|
const sdk = (await this.clientConnection.bindAgent(this.config.agentId, this.config.runtimeType ?? this.config.type, this.config.runtimeVersion)).sdk;
|
|
3563
3643
|
this.sdk = sdk;
|
|
@@ -3741,6 +3821,147 @@ z.object({
|
|
|
3741
3821
|
server: z.url().default("http://localhost:8000"),
|
|
3742
3822
|
agents: z.record(z.string(), agentSlotConfigSchema).refine((agents) => Object.keys(agents).length > 0, "At least one agent must be defined")
|
|
3743
3823
|
});
|
|
3824
|
+
/**
|
|
3825
|
+
* Version-drift decision flow. Install, prompt, and exit are delegated to
|
|
3826
|
+
* command-layer callbacks so the Client package stays free of CLI /
|
|
3827
|
+
* filesystem knowledge.
|
|
3828
|
+
*/
|
|
3829
|
+
var UpdateManager = class UpdateManager {
|
|
3830
|
+
options;
|
|
3831
|
+
connection;
|
|
3832
|
+
welcomeListener;
|
|
3833
|
+
updateInFlight = false;
|
|
3834
|
+
quietGateTimer = null;
|
|
3835
|
+
disposed = false;
|
|
3836
|
+
/**
|
|
3837
|
+
* Set when a standalone (unmanaged) executeUpdate reports `installed: true`
|
|
3838
|
+
* without exiting. The new bits are on disk; subsequent welcome frames must
|
|
3839
|
+
* not re-invoke npm since a restart is the only way to pick them up.
|
|
3840
|
+
*/
|
|
3841
|
+
pendingRestart = false;
|
|
3842
|
+
constructor(connection, options) {
|
|
3843
|
+
this.connection = connection;
|
|
3844
|
+
this.options = options;
|
|
3845
|
+
this.welcomeListener = (welcome) => {
|
|
3846
|
+
this.onWelcome(welcome).catch((err) => {
|
|
3847
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3848
|
+
this.options.log("warn", `update decision failed: ${msg}`);
|
|
3849
|
+
});
|
|
3850
|
+
};
|
|
3851
|
+
}
|
|
3852
|
+
/** Attach a manager to a connection. Returns the instance so callers can dispose. */
|
|
3853
|
+
static attach(connection, options) {
|
|
3854
|
+
const mgr = new UpdateManager(connection, options);
|
|
3855
|
+
connection.on("server:welcome", mgr.welcomeListener);
|
|
3856
|
+
return mgr;
|
|
3857
|
+
}
|
|
3858
|
+
dispose() {
|
|
3859
|
+
if (this.disposed) return;
|
|
3860
|
+
this.disposed = true;
|
|
3861
|
+
this.connection.off("server:welcome", this.welcomeListener);
|
|
3862
|
+
if (this.quietGateTimer) {
|
|
3863
|
+
clearTimeout(this.quietGateTimer);
|
|
3864
|
+
this.quietGateTimer = null;
|
|
3865
|
+
}
|
|
3866
|
+
}
|
|
3867
|
+
async onWelcome(welcome) {
|
|
3868
|
+
if (this.disposed || this.updateInFlight || this.pendingRestart) return;
|
|
3869
|
+
this.updateInFlight = true;
|
|
3870
|
+
try {
|
|
3871
|
+
await this.decide(welcome);
|
|
3872
|
+
} finally {
|
|
3873
|
+
this.updateInFlight = false;
|
|
3874
|
+
}
|
|
3875
|
+
}
|
|
3876
|
+
async decide(welcome) {
|
|
3877
|
+
const { serverCommandVersion: target } = welcome.frame;
|
|
3878
|
+
const current = this.options.currentVersion;
|
|
3879
|
+
if (!semver.valid(target)) {
|
|
3880
|
+
this.options.log("warn", `Server advertised invalid version "${target}"; skipping drift check`);
|
|
3881
|
+
return;
|
|
3882
|
+
}
|
|
3883
|
+
if (!semver.valid(current)) {
|
|
3884
|
+
this.options.log("warn", `Own version "${current}" is not valid SemVer; skipping drift check`);
|
|
3885
|
+
return;
|
|
3886
|
+
}
|
|
3887
|
+
if (semver.eq(target, current)) {
|
|
3888
|
+
this.options.log("debug", `Server advertises ${target}, matching running version`);
|
|
3889
|
+
return;
|
|
3890
|
+
}
|
|
3891
|
+
if (semver.lt(target, current)) {
|
|
3892
|
+
this.options.log("info", `Server advertises ${target}, running ${current} (ahead)`);
|
|
3893
|
+
return;
|
|
3894
|
+
}
|
|
3895
|
+
const policy = this.options.updateConfig.policy;
|
|
3896
|
+
if (policy === "off") {
|
|
3897
|
+
this.options.log("info", `Server advertises ${target}, running ${current}; self-update disabled (policy=off)`);
|
|
3898
|
+
return;
|
|
3899
|
+
}
|
|
3900
|
+
if (policy === "prompt") {
|
|
3901
|
+
if (!this.options.isTTY) {
|
|
3902
|
+
this.options.log("warn", `Update available (${current} → ${target}) but policy=prompt requires a terminal; operator action required`);
|
|
3903
|
+
return;
|
|
3904
|
+
}
|
|
3905
|
+
if (!await this.options.prompt({
|
|
3906
|
+
currentVersion: current,
|
|
3907
|
+
targetVersion: target,
|
|
3908
|
+
timeoutSeconds: this.options.updateConfig.prompt_timeout_seconds
|
|
3909
|
+
})) {
|
|
3910
|
+
this.options.log("info", `Update declined by operator (still running ${current})`);
|
|
3911
|
+
return;
|
|
3912
|
+
}
|
|
3913
|
+
await this.runUpdate(current, target);
|
|
3914
|
+
return;
|
|
3915
|
+
}
|
|
3916
|
+
this.options.log("info", `Server advertises ${target}, running ${current}; policy=auto`);
|
|
3917
|
+
if (this.options.isTTY) {
|
|
3918
|
+
this.options.log("info", "Auto-update starting in 5s");
|
|
3919
|
+
await sleep(5e3);
|
|
3920
|
+
if (this.disposed) return;
|
|
3921
|
+
}
|
|
3922
|
+
if (welcome.isReconnect) {
|
|
3923
|
+
await this.waitForQuietGate();
|
|
3924
|
+
if (this.disposed) return;
|
|
3925
|
+
}
|
|
3926
|
+
await this.runUpdate(current, target);
|
|
3927
|
+
}
|
|
3928
|
+
async waitForQuietGate() {
|
|
3929
|
+
const quietMs = this.options.updateConfig.restart_quiet_seconds * 1e3;
|
|
3930
|
+
const intervalMs = this.options.updateConfig.restart_check_interval_seconds * 1e3;
|
|
3931
|
+
while (!this.disposed) {
|
|
3932
|
+
const snapshot = this.options.getQuietGateSnapshot();
|
|
3933
|
+
const now = Date.now();
|
|
3934
|
+
const idleFor = snapshot.lastActivityMs === 0 ? Number.POSITIVE_INFINITY : now - snapshot.lastActivityMs;
|
|
3935
|
+
if (snapshot.activeCount === 0 && idleFor >= quietMs) return;
|
|
3936
|
+
this.options.log("debug", `Quiet gate: activeCount=${snapshot.activeCount}, idleFor=${Math.round(idleFor)}ms; re-checking in ${intervalMs}ms`);
|
|
3937
|
+
await new Promise((resolve) => {
|
|
3938
|
+
this.quietGateTimer = setTimeout(() => {
|
|
3939
|
+
this.quietGateTimer = null;
|
|
3940
|
+
resolve();
|
|
3941
|
+
}, intervalMs);
|
|
3942
|
+
});
|
|
3943
|
+
}
|
|
3944
|
+
}
|
|
3945
|
+
async runUpdate(current, target) {
|
|
3946
|
+
try {
|
|
3947
|
+
if ((await this.options.executeUpdate({
|
|
3948
|
+
currentVersion: current,
|
|
3949
|
+
targetVersion: target
|
|
3950
|
+
})).installed) {
|
|
3951
|
+
this.pendingRestart = true;
|
|
3952
|
+
this.options.log("info", `Update ${target} installed; restart required to pick it up (no further self-update attempts until restart)`);
|
|
3953
|
+
return;
|
|
3954
|
+
}
|
|
3955
|
+
this.options.log("warn", "Self-update did not complete; will retry on next welcome frame");
|
|
3956
|
+
} catch (err) {
|
|
3957
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3958
|
+
this.options.log("warn", `Self-update threw: ${msg}`);
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
};
|
|
3962
|
+
function sleep(ms) {
|
|
3963
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3964
|
+
}
|
|
3744
3965
|
//#endregion
|
|
3745
3966
|
//#region src/core/admin.ts
|
|
3746
3967
|
/**
|
|
@@ -3841,6 +4062,8 @@ var ClientRuntime = class {
|
|
|
3841
4062
|
agents = [];
|
|
3842
4063
|
agentNames = /* @__PURE__ */ new Set();
|
|
3843
4064
|
agentIds = /* @__PURE__ */ new Set();
|
|
4065
|
+
options;
|
|
4066
|
+
updateManager = null;
|
|
3844
4067
|
watcher = null;
|
|
3845
4068
|
debounceTimer = null;
|
|
3846
4069
|
/**
|
|
@@ -3849,11 +4072,13 @@ var ClientRuntime = class {
|
|
|
3849
4072
|
* `agent:pinned` handler knows where to materialise new configs.
|
|
3850
4073
|
*/
|
|
3851
4074
|
agentsDir = null;
|
|
3852
|
-
constructor(serverUrl, clientId) {
|
|
4075
|
+
constructor(serverUrl, clientId, options = {}) {
|
|
3853
4076
|
this.serverUrl = serverUrl;
|
|
4077
|
+
this.options = options;
|
|
3854
4078
|
this.connection = new ClientConnection({
|
|
3855
4079
|
serverUrl,
|
|
3856
4080
|
clientId,
|
|
4081
|
+
sdkVersion: options.currentVersion,
|
|
3857
4082
|
getAccessToken: () => ensureFreshAccessToken()
|
|
3858
4083
|
});
|
|
3859
4084
|
registerBuiltinHandlers();
|
|
@@ -3892,6 +4117,13 @@ var ClientRuntime = class {
|
|
|
3892
4117
|
this.agentIds.add(config.agentId);
|
|
3893
4118
|
}
|
|
3894
4119
|
async start() {
|
|
4120
|
+
if (this.options.currentVersion && this.options.update) this.updateManager = UpdateManager.attach(this.connection, {
|
|
4121
|
+
currentVersion: this.options.currentVersion,
|
|
4122
|
+
...this.options.update,
|
|
4123
|
+
isTTY: Boolean(process.stdout.isTTY),
|
|
4124
|
+
log: (level, msg) => process.stderr.write(` [update/${level}] ${msg}\n`),
|
|
4125
|
+
getQuietGateSnapshot: () => this.aggregateQuietGate()
|
|
4126
|
+
});
|
|
3895
4127
|
await this.connection.connect();
|
|
3896
4128
|
process.stderr.write(` \u2713 Client registered: ${this.connection.clientId}\n`);
|
|
3897
4129
|
if (this.agents.length === 0) {
|
|
@@ -3935,9 +4167,24 @@ var ClientRuntime = class {
|
|
|
3935
4167
|
}
|
|
3936
4168
|
async stop() {
|
|
3937
4169
|
this.unwatchAgentsDir();
|
|
4170
|
+
this.updateManager?.dispose();
|
|
4171
|
+
this.updateManager = null;
|
|
3938
4172
|
await Promise.allSettled(this.agents.map((a) => a.slot.stop()));
|
|
3939
4173
|
await this.connection.disconnect();
|
|
3940
4174
|
}
|
|
4175
|
+
aggregateQuietGate() {
|
|
4176
|
+
let activeCount = 0;
|
|
4177
|
+
let lastActivityMs = 0;
|
|
4178
|
+
for (const entry of this.agents) {
|
|
4179
|
+
const snap = entry.slot.getQuietGateSnapshot();
|
|
4180
|
+
activeCount += snap.activeCount;
|
|
4181
|
+
if (snap.lastActivityMs > lastActivityMs) lastActivityMs = snap.lastActivityMs;
|
|
4182
|
+
}
|
|
4183
|
+
return {
|
|
4184
|
+
activeCount,
|
|
4185
|
+
lastActivityMs
|
|
4186
|
+
};
|
|
4187
|
+
}
|
|
3941
4188
|
scanForNewAgents(agentsDir) {
|
|
3942
4189
|
try {
|
|
3943
4190
|
const all = loadAgents({
|
|
@@ -4506,136 +4753,570 @@ async function runMigrations(databaseUrl) {
|
|
|
4506
4753
|
}
|
|
4507
4754
|
}
|
|
4508
4755
|
//#endregion
|
|
4509
|
-
//#region src/core/
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4756
|
+
//#region src/core/service-install.ts
|
|
4757
|
+
/**
|
|
4758
|
+
* Run a subprocess capturing stderr so failures surface a meaningful error
|
|
4759
|
+
* instead of Node's opaque "Command failed". Used for launchctl/systemctl —
|
|
4760
|
+
* anywhere the stderr message is diagnostically crucial.
|
|
4761
|
+
*/
|
|
4762
|
+
function runCapture(program, args, timeoutMs) {
|
|
4763
|
+
const res = spawnSync(program, args, {
|
|
4764
|
+
encoding: "utf-8",
|
|
4765
|
+
timeout: timeoutMs,
|
|
4766
|
+
stdio: [
|
|
4767
|
+
"ignore",
|
|
4768
|
+
"pipe",
|
|
4769
|
+
"pipe"
|
|
4770
|
+
]
|
|
4771
|
+
});
|
|
4772
|
+
if (res.status === 0) return { ok: true };
|
|
4773
|
+
return {
|
|
4774
|
+
ok: false,
|
|
4775
|
+
stderr: (res.stderr ?? "").trim(),
|
|
4776
|
+
code: res.status
|
|
4777
|
+
};
|
|
4515
4778
|
}
|
|
4516
|
-
|
|
4517
|
-
|
|
4779
|
+
function sleepSync(ms) {
|
|
4780
|
+
const shared = new Int32Array(new SharedArrayBuffer(4));
|
|
4781
|
+
Atomics.wait(shared, 0, 0, ms);
|
|
4782
|
+
}
|
|
4783
|
+
const LAUNCHD_LABEL = "dev.first-tree-hub.client";
|
|
4784
|
+
const SYSTEMD_UNIT = "first-tree-hub-client.service";
|
|
4785
|
+
const LOG_DIR = join(DEFAULT_HOME_DIR$1, "logs");
|
|
4786
|
+
function whichBin(name) {
|
|
4518
4787
|
try {
|
|
4519
|
-
return
|
|
4788
|
+
return execFileSync(process.platform === "win32" ? "where" : "which", [name], {
|
|
4789
|
+
encoding: "utf-8",
|
|
4790
|
+
timeout: 3e3
|
|
4791
|
+
}).split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] ?? null;
|
|
4520
4792
|
} catch {
|
|
4521
4793
|
return null;
|
|
4522
4794
|
}
|
|
4523
4795
|
}
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
try {
|
|
4540
|
-
const serverUrl = resolveServerUrl(args.server);
|
|
4541
|
-
items.push({
|
|
4542
|
-
key: "server",
|
|
4543
|
-
label: "Server URL",
|
|
4544
|
-
status: "ok",
|
|
4545
|
-
value: serverUrl
|
|
4546
|
-
});
|
|
4547
|
-
try {
|
|
4548
|
-
const res = await fetch(`${serverUrl}/api/v1/health`);
|
|
4549
|
-
items.push({
|
|
4550
|
-
key: "server_reachable",
|
|
4551
|
-
label: "Server reachable",
|
|
4552
|
-
status: res.ok ? "ok" : "error",
|
|
4553
|
-
value: res.ok ? "healthy" : `HTTP ${res.status}`
|
|
4554
|
-
});
|
|
4555
|
-
} catch {
|
|
4556
|
-
items.push({
|
|
4557
|
-
key: "server_reachable",
|
|
4558
|
-
label: "Server reachable",
|
|
4559
|
-
status: "error",
|
|
4560
|
-
hint: "Cannot connect to server"
|
|
4561
|
-
});
|
|
4562
|
-
}
|
|
4796
|
+
/**
|
|
4797
|
+
* Resolve how the service should launch the CLI.
|
|
4798
|
+
*
|
|
4799
|
+
* Prefers the installed `first-tree-hub` bin on PATH (usually a shim under
|
|
4800
|
+
* /usr/local/bin or ~/.npm-global/bin). Falls back to invoking the current
|
|
4801
|
+
* Node interpreter against the running script (handles `pnpm dev`, tsx, and
|
|
4802
|
+
* dev-only global installs).
|
|
4803
|
+
*/
|
|
4804
|
+
function resolveCliInvocation() {
|
|
4805
|
+
const bin = whichBin("first-tree-hub");
|
|
4806
|
+
if (bin && isAbsolute(bin)) try {
|
|
4807
|
+
return {
|
|
4808
|
+
kind: "bin",
|
|
4809
|
+
program: realpathSync(bin)
|
|
4810
|
+
};
|
|
4563
4811
|
} catch {
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
hint: "Provide via --server, FIRST_TREE_HUB_SERVER_URL, or config"
|
|
4569
|
-
});
|
|
4570
|
-
}
|
|
4571
|
-
if (args.id) items.push({
|
|
4572
|
-
key: "id",
|
|
4573
|
-
label: "Agent ID",
|
|
4574
|
-
status: "ok",
|
|
4575
|
-
value: args.id
|
|
4576
|
-
});
|
|
4577
|
-
else items.push({
|
|
4578
|
-
key: "id",
|
|
4579
|
-
label: "Agent ID",
|
|
4580
|
-
status: "missing_required",
|
|
4581
|
-
hint: "Provide via --id"
|
|
4582
|
-
});
|
|
4583
|
-
if (args.type) items.push({
|
|
4584
|
-
key: "type",
|
|
4585
|
-
label: "Agent type",
|
|
4586
|
-
status: "ok",
|
|
4587
|
-
value: args.type
|
|
4588
|
-
});
|
|
4589
|
-
else items.push({
|
|
4590
|
-
key: "type",
|
|
4591
|
-
label: "Agent type",
|
|
4592
|
-
status: "missing_required",
|
|
4593
|
-
hint: "Provide via --type"
|
|
4594
|
-
});
|
|
4595
|
-
if (args.type && args.type !== "human") if (args.clientId) items.push({
|
|
4596
|
-
key: "client",
|
|
4597
|
-
label: "Target client",
|
|
4598
|
-
status: "ok",
|
|
4599
|
-
value: args.clientId
|
|
4600
|
-
});
|
|
4601
|
-
else items.push({
|
|
4602
|
-
key: "client",
|
|
4603
|
-
label: "Target client",
|
|
4604
|
-
status: "ok",
|
|
4605
|
-
value: "(unbound — claimed on first WS connect)"
|
|
4606
|
-
});
|
|
4607
|
-
return items;
|
|
4608
|
-
}
|
|
4609
|
-
function formatCheckReport(items) {
|
|
4610
|
-
const lines = [];
|
|
4611
|
-
for (const item of items) {
|
|
4612
|
-
const icon = item.status === "ok" ? "✅" : item.status === "missing_required" ? "❌" : item.status === "error" ? "❌" : item.status === "warning" ? "⚠️" : "⬜";
|
|
4613
|
-
const valueStr = item.value ? ` ${item.value}` : "";
|
|
4614
|
-
const hintStr = item.hint ? ` (${item.hint})` : "";
|
|
4615
|
-
lines.push(` ${icon} ${item.label.padEnd(20)}${valueStr}${hintStr}`);
|
|
4812
|
+
return {
|
|
4813
|
+
kind: "bin",
|
|
4814
|
+
program: bin
|
|
4815
|
+
};
|
|
4616
4816
|
}
|
|
4617
|
-
|
|
4817
|
+
const script = process.argv[1];
|
|
4818
|
+
if (!script) throw new Error("Cannot resolve CLI entry point (process.argv[1] is empty).");
|
|
4819
|
+
const scriptAbs = isAbsolute(script) ? script : join(process.cwd(), script);
|
|
4820
|
+
return {
|
|
4821
|
+
kind: "node",
|
|
4822
|
+
program: process.execPath,
|
|
4823
|
+
args: [scriptAbs]
|
|
4824
|
+
};
|
|
4618
4825
|
}
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
Authorization: `Bearer ${accessToken}`,
|
|
4624
|
-
"Content-Type": "application/json"
|
|
4625
|
-
},
|
|
4626
|
-
body: JSON.stringify(body),
|
|
4627
|
-
signal: AbortSignal.timeout(1e4)
|
|
4826
|
+
function ensureLogDir() {
|
|
4827
|
+
mkdirSync(LOG_DIR, {
|
|
4828
|
+
recursive: true,
|
|
4829
|
+
mode: 448
|
|
4628
4830
|
});
|
|
4629
|
-
if (!res.ok) {
|
|
4630
|
-
const errBody = await res.json().catch(() => ({}));
|
|
4631
|
-
throw new Error(errBody.error ?? `Failed to create agent (HTTP ${res.status})`);
|
|
4632
|
-
}
|
|
4633
|
-
return await res.json();
|
|
4634
4831
|
}
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4832
|
+
function launchdPlistPath() {
|
|
4833
|
+
return join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
4834
|
+
}
|
|
4835
|
+
function renderPlist(invocation) {
|
|
4836
|
+
const argsXml = (invocation.kind === "bin" ? [
|
|
4837
|
+
invocation.program,
|
|
4838
|
+
"client",
|
|
4839
|
+
"start",
|
|
4840
|
+
"--no-interactive"
|
|
4841
|
+
] : [
|
|
4842
|
+
invocation.program,
|
|
4843
|
+
...invocation.args,
|
|
4844
|
+
"client",
|
|
4845
|
+
"start",
|
|
4846
|
+
"--no-interactive"
|
|
4847
|
+
]).map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
|
|
4848
|
+
const outLog = join(LOG_DIR, "client.out.log");
|
|
4849
|
+
const errLog = join(LOG_DIR, "client.err.log");
|
|
4850
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
4851
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTD/PropertyList-1.0.dtd">
|
|
4852
|
+
<plist version="1.0">
|
|
4853
|
+
<dict>
|
|
4854
|
+
<key>Label</key>
|
|
4855
|
+
<string>${LAUNCHD_LABEL}</string>
|
|
4856
|
+
<key>ProgramArguments</key>
|
|
4857
|
+
<array>
|
|
4858
|
+
${argsXml}
|
|
4859
|
+
</array>
|
|
4860
|
+
<key>EnvironmentVariables</key>
|
|
4861
|
+
<dict>
|
|
4862
|
+
<key>PATH</key>
|
|
4863
|
+
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
|
|
4864
|
+
</dict>
|
|
4865
|
+
<key>RunAtLoad</key>
|
|
4866
|
+
<true/>
|
|
4867
|
+
<key>KeepAlive</key>
|
|
4868
|
+
<dict>
|
|
4869
|
+
<key>SuccessfulExit</key>
|
|
4870
|
+
<false/>
|
|
4871
|
+
</dict>
|
|
4872
|
+
<key>ThrottleInterval</key>
|
|
4873
|
+
<integer>10</integer>
|
|
4874
|
+
<key>StandardOutPath</key>
|
|
4875
|
+
<string>${escapeXml(outLog)}</string>
|
|
4876
|
+
<key>StandardErrorPath</key>
|
|
4877
|
+
<string>${escapeXml(errLog)}</string>
|
|
4878
|
+
</dict>
|
|
4879
|
+
</plist>
|
|
4880
|
+
`;
|
|
4881
|
+
}
|
|
4882
|
+
function escapeXml(value) {
|
|
4883
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
4884
|
+
}
|
|
4885
|
+
function launchctlDomainTarget() {
|
|
4886
|
+
return `gui/${userInfo().uid}`;
|
|
4887
|
+
}
|
|
4888
|
+
function launchdState() {
|
|
4889
|
+
if (!existsSync(launchdPlistPath())) return { state: "not-installed" };
|
|
4890
|
+
const res = spawnSync("launchctl", ["print", `${launchctlDomainTarget()}/${LAUNCHD_LABEL}`], {
|
|
4891
|
+
encoding: "utf-8",
|
|
4892
|
+
timeout: 5e3,
|
|
4893
|
+
stdio: [
|
|
4894
|
+
"ignore",
|
|
4895
|
+
"pipe",
|
|
4896
|
+
"pipe"
|
|
4897
|
+
]
|
|
4898
|
+
});
|
|
4899
|
+
if (res.status !== 0) return {
|
|
4900
|
+
state: "inactive",
|
|
4901
|
+
detail: "plist present but not loaded"
|
|
4902
|
+
};
|
|
4903
|
+
const out = res.stdout ?? "";
|
|
4904
|
+
const stateLine = out.split(/\r?\n/).find((l) => l.trim().startsWith("state ="));
|
|
4905
|
+
const pidLine = out.split(/\r?\n/).find((l) => l.trim().startsWith("pid ="));
|
|
4906
|
+
if (stateLine?.includes("running")) {
|
|
4907
|
+
const pid = pidLine?.split("=")[1]?.trim();
|
|
4908
|
+
return {
|
|
4909
|
+
state: "active",
|
|
4910
|
+
detail: pid ? `pid ${pid}` : "running"
|
|
4911
|
+
};
|
|
4912
|
+
}
|
|
4913
|
+
return {
|
|
4914
|
+
state: "inactive",
|
|
4915
|
+
detail: stateLine?.trim() ?? "loaded"
|
|
4916
|
+
};
|
|
4917
|
+
}
|
|
4918
|
+
/**
|
|
4919
|
+
* Poll `launchctl print` until the label disappears, confirming launchd has
|
|
4920
|
+
* finished the async eviction kicked off by `bootout`. Required because
|
|
4921
|
+
* `bootout` returns before the actual unload completes when the service has
|
|
4922
|
+
* active WebSocket connections — a follow-up `bootstrap` against a still-
|
|
4923
|
+
* registered label fails with `Bootstrap failed: 5: Input/output error`.
|
|
4924
|
+
*/
|
|
4925
|
+
function waitForLabelEvicted(target, label, timeoutMs) {
|
|
4926
|
+
const deadline = Date.now() + timeoutMs;
|
|
4927
|
+
while (Date.now() < deadline) {
|
|
4928
|
+
if (spawnSync("launchctl", ["print", `${target}/${label}`], {
|
|
4929
|
+
encoding: "utf-8",
|
|
4930
|
+
timeout: 2e3,
|
|
4931
|
+
stdio: [
|
|
4932
|
+
"ignore",
|
|
4933
|
+
"ignore",
|
|
4934
|
+
"pipe"
|
|
4935
|
+
]
|
|
4936
|
+
}).status !== 0) return true;
|
|
4937
|
+
sleepSync(200);
|
|
4938
|
+
}
|
|
4939
|
+
return false;
|
|
4940
|
+
}
|
|
4941
|
+
function installLaunchd() {
|
|
4942
|
+
const invocation = resolveCliInvocation();
|
|
4943
|
+
ensureLogDir();
|
|
4944
|
+
const plistPath = launchdPlistPath();
|
|
4945
|
+
mkdirSync(dirname(plistPath), { recursive: true });
|
|
4946
|
+
writeFileSync(plistPath, renderPlist(invocation), { mode: 420 });
|
|
4947
|
+
const target = launchctlDomainTarget();
|
|
4948
|
+
const bootoutRes = runCapture("launchctl", ["bootout", `${target}/${LAUNCHD_LABEL}`], 15e3);
|
|
4949
|
+
if (!bootoutRes.ok) {
|
|
4950
|
+
if (!/not find|no such|not loaded/i.test(bootoutRes.stderr)) process.stderr.write(` warning: launchctl bootout: ${bootoutRes.stderr || `exit ${bootoutRes.code ?? "unknown"}`}\n`);
|
|
4951
|
+
}
|
|
4952
|
+
waitForLabelEvicted(target, LAUNCHD_LABEL, 1e4);
|
|
4953
|
+
let lastBootstrapErr = null;
|
|
4954
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
4955
|
+
const res = runCapture("launchctl", [
|
|
4956
|
+
"bootstrap",
|
|
4957
|
+
target,
|
|
4958
|
+
plistPath
|
|
4959
|
+
], 1e4);
|
|
4960
|
+
if (res.ok) {
|
|
4961
|
+
lastBootstrapErr = null;
|
|
4962
|
+
break;
|
|
4963
|
+
}
|
|
4964
|
+
lastBootstrapErr = res;
|
|
4965
|
+
if (attempt < 2) sleepSync(1e3);
|
|
4966
|
+
}
|
|
4967
|
+
if (lastBootstrapErr) throw new Error(`launchctl bootstrap failed: ${lastBootstrapErr.stderr || `exit ${lastBootstrapErr.code ?? "unknown"}`}\n Command: launchctl bootstrap ${target} ${plistPath}\n Recovery: \`launchctl bootout ${target}/${LAUNCHD_LABEL}\` then \`first-tree-hub service install\`.`);
|
|
4968
|
+
const enableRes = runCapture("launchctl", ["enable", `${target}/${LAUNCHD_LABEL}`], 5e3);
|
|
4969
|
+
if (!enableRes.ok) process.stderr.write(` warning: launchctl enable: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n`);
|
|
4970
|
+
const { state, detail } = launchdState();
|
|
4971
|
+
return {
|
|
4972
|
+
platform: "launchd",
|
|
4973
|
+
label: LAUNCHD_LABEL,
|
|
4974
|
+
unitPath: plistPath,
|
|
4975
|
+
logDir: LOG_DIR,
|
|
4976
|
+
state,
|
|
4977
|
+
detail
|
|
4978
|
+
};
|
|
4979
|
+
}
|
|
4980
|
+
function uninstallLaunchd() {
|
|
4981
|
+
const plistPath = launchdPlistPath();
|
|
4982
|
+
const res = runCapture("launchctl", ["bootout", `${launchctlDomainTarget()}/${LAUNCHD_LABEL}`], 15e3);
|
|
4983
|
+
if (!res.ok && !/not find|no such|not loaded/i.test(res.stderr)) process.stderr.write(` warning: bootout during uninstall: ${res.stderr || `exit ${res.code ?? "unknown"}`}\n`);
|
|
4984
|
+
if (existsSync(plistPath)) rmSync(plistPath);
|
|
4985
|
+
return {
|
|
4986
|
+
platform: "launchd",
|
|
4987
|
+
label: LAUNCHD_LABEL,
|
|
4988
|
+
unitPath: plistPath,
|
|
4989
|
+
logDir: LOG_DIR,
|
|
4990
|
+
state: "not-installed"
|
|
4991
|
+
};
|
|
4992
|
+
}
|
|
4993
|
+
function systemdUnitPath() {
|
|
4994
|
+
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "systemd", "user", SYSTEMD_UNIT);
|
|
4995
|
+
}
|
|
4996
|
+
function renderSystemdUnit(invocation) {
|
|
4997
|
+
return `[Unit]
|
|
4998
|
+
Description=First Tree Hub Client
|
|
4999
|
+
After=network-online.target
|
|
5000
|
+
Wants=network-online.target
|
|
5001
|
+
|
|
5002
|
+
[Service]
|
|
5003
|
+
Type=simple
|
|
5004
|
+
ExecStart=${invocation.kind === "bin" ? `${shellQuote(invocation.program)} client start --no-interactive` : `${shellQuote(invocation.program)} ${invocation.args.map(shellQuote).join(" ")} client start --no-interactive`}
|
|
5005
|
+
Restart=always
|
|
5006
|
+
RestartSec=10
|
|
5007
|
+
StandardOutput=append:${join(LOG_DIR, "client.out.log")}
|
|
5008
|
+
StandardError=append:${join(LOG_DIR, "client.err.log")}
|
|
5009
|
+
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
|
5010
|
+
|
|
5011
|
+
[Install]
|
|
5012
|
+
WantedBy=default.target
|
|
5013
|
+
`;
|
|
5014
|
+
}
|
|
5015
|
+
function shellQuote(value) {
|
|
5016
|
+
if (/^[A-Za-z0-9_\-./:=]+$/.test(value)) return value;
|
|
5017
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
5018
|
+
}
|
|
5019
|
+
function systemdState() {
|
|
5020
|
+
if (!existsSync(systemdUnitPath())) return { state: "not-installed" };
|
|
5021
|
+
const res = spawnSync("systemctl", [
|
|
5022
|
+
"--user",
|
|
5023
|
+
"is-active",
|
|
5024
|
+
SYSTEMD_UNIT
|
|
5025
|
+
], {
|
|
5026
|
+
encoding: "utf-8",
|
|
5027
|
+
timeout: 5e3,
|
|
5028
|
+
stdio: [
|
|
5029
|
+
"ignore",
|
|
5030
|
+
"pipe",
|
|
5031
|
+
"pipe"
|
|
5032
|
+
]
|
|
5033
|
+
});
|
|
5034
|
+
const out = (res.stdout ?? "").trim();
|
|
5035
|
+
if (res.status === 0 && out === "active") return {
|
|
5036
|
+
state: "active",
|
|
5037
|
+
detail: "running"
|
|
5038
|
+
};
|
|
5039
|
+
return {
|
|
5040
|
+
state: "inactive",
|
|
5041
|
+
detail: out || "unit present but not active"
|
|
5042
|
+
};
|
|
5043
|
+
}
|
|
5044
|
+
function installSystemd() {
|
|
5045
|
+
const invocation = resolveCliInvocation();
|
|
5046
|
+
ensureLogDir();
|
|
5047
|
+
const unitPath = systemdUnitPath();
|
|
5048
|
+
mkdirSync(dirname(unitPath), { recursive: true });
|
|
5049
|
+
writeFileSync(unitPath, renderSystemdUnit(invocation), { mode: 420 });
|
|
5050
|
+
const reloadRes = runCapture("systemctl", ["--user", "daemon-reload"], 5e3);
|
|
5051
|
+
if (!reloadRes.ok) throw new Error(`systemctl --user daemon-reload failed: ${reloadRes.stderr || `exit ${reloadRes.code ?? "unknown"}`}`);
|
|
5052
|
+
const enableRes = runCapture("systemctl", [
|
|
5053
|
+
"--user",
|
|
5054
|
+
"enable",
|
|
5055
|
+
"--now",
|
|
5056
|
+
SYSTEMD_UNIT
|
|
5057
|
+
], 1e4);
|
|
5058
|
+
if (!enableRes.ok) throw new Error(`systemctl --user enable --now ${SYSTEMD_UNIT} failed: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n Recovery: \`systemctl --user stop ${SYSTEMD_UNIT}\` then \`first-tree-hub service install\`.`);
|
|
5059
|
+
const { state, detail } = systemdState();
|
|
5060
|
+
return {
|
|
5061
|
+
platform: "systemd",
|
|
5062
|
+
label: SYSTEMD_UNIT,
|
|
5063
|
+
unitPath,
|
|
5064
|
+
logDir: LOG_DIR,
|
|
5065
|
+
state,
|
|
5066
|
+
detail
|
|
5067
|
+
};
|
|
5068
|
+
}
|
|
5069
|
+
function uninstallSystemd() {
|
|
5070
|
+
const unitPath = systemdUnitPath();
|
|
5071
|
+
const disableRes = runCapture("systemctl", [
|
|
5072
|
+
"--user",
|
|
5073
|
+
"disable",
|
|
5074
|
+
"--now",
|
|
5075
|
+
SYSTEMD_UNIT
|
|
5076
|
+
], 1e4);
|
|
5077
|
+
if (!disableRes.ok && !/not found|no such|not loaded/i.test(disableRes.stderr)) process.stderr.write(` warning: systemctl disable during uninstall: ${disableRes.stderr || `exit ${disableRes.code ?? "unknown"}`}\n`);
|
|
5078
|
+
if (existsSync(unitPath)) rmSync(unitPath);
|
|
5079
|
+
const reloadRes = runCapture("systemctl", ["--user", "daemon-reload"], 5e3);
|
|
5080
|
+
if (!reloadRes.ok) process.stderr.write(` warning: systemctl daemon-reload during uninstall: ${reloadRes.stderr || `exit ${reloadRes.code ?? "unknown"}`}\n`);
|
|
5081
|
+
return {
|
|
5082
|
+
platform: "systemd",
|
|
5083
|
+
label: SYSTEMD_UNIT,
|
|
5084
|
+
unitPath,
|
|
5085
|
+
logDir: LOG_DIR,
|
|
5086
|
+
state: "not-installed"
|
|
5087
|
+
};
|
|
5088
|
+
}
|
|
5089
|
+
/** Is background-service install supported on the current platform? */
|
|
5090
|
+
function isServiceSupported() {
|
|
5091
|
+
return process.platform === "darwin" || process.platform === "linux";
|
|
5092
|
+
}
|
|
5093
|
+
/**
|
|
5094
|
+
* Install the background service for the current platform.
|
|
5095
|
+
*
|
|
5096
|
+
* @throws {Error} if the platform is not supported or the service manager fails.
|
|
5097
|
+
*/
|
|
5098
|
+
function installClientService() {
|
|
5099
|
+
if (process.platform === "darwin") return installLaunchd();
|
|
5100
|
+
if (process.platform === "linux") return installSystemd();
|
|
5101
|
+
throw new Error(`Background service install is not supported on ${process.platform}. Run \`first-tree-hub client start\` manually to keep the computer online.`);
|
|
5102
|
+
}
|
|
5103
|
+
/** Report the current service state without modifying anything. */
|
|
5104
|
+
function getClientServiceStatus() {
|
|
5105
|
+
if (process.platform === "darwin") {
|
|
5106
|
+
const { state, detail } = launchdState();
|
|
5107
|
+
return {
|
|
5108
|
+
platform: "launchd",
|
|
5109
|
+
label: LAUNCHD_LABEL,
|
|
5110
|
+
unitPath: launchdPlistPath(),
|
|
5111
|
+
logDir: LOG_DIR,
|
|
5112
|
+
state,
|
|
5113
|
+
detail
|
|
5114
|
+
};
|
|
5115
|
+
}
|
|
5116
|
+
if (process.platform === "linux") {
|
|
5117
|
+
const { state, detail } = systemdState();
|
|
5118
|
+
return {
|
|
5119
|
+
platform: "systemd",
|
|
5120
|
+
label: SYSTEMD_UNIT,
|
|
5121
|
+
unitPath: systemdUnitPath(),
|
|
5122
|
+
logDir: LOG_DIR,
|
|
5123
|
+
state,
|
|
5124
|
+
detail
|
|
5125
|
+
};
|
|
5126
|
+
}
|
|
5127
|
+
return {
|
|
5128
|
+
platform: "unsupported",
|
|
5129
|
+
label: "",
|
|
5130
|
+
unitPath: "",
|
|
5131
|
+
logDir: LOG_DIR,
|
|
5132
|
+
state: "not-installed",
|
|
5133
|
+
detail: `platform ${process.platform} not supported`
|
|
5134
|
+
};
|
|
5135
|
+
}
|
|
5136
|
+
/** Uninstall the background service. No-op if not installed. */
|
|
5137
|
+
function uninstallClientService() {
|
|
5138
|
+
if (process.platform === "darwin") return uninstallLaunchd();
|
|
5139
|
+
if (process.platform === "linux") return uninstallSystemd();
|
|
5140
|
+
return getClientServiceStatus();
|
|
5141
|
+
}
|
|
5142
|
+
//#endregion
|
|
5143
|
+
//#region src/core/migrate-home.ts
|
|
5144
|
+
/**
|
|
5145
|
+
* Run the one-shot legacy home migration at CLI startup and, if it succeeds,
|
|
5146
|
+
* re-register the background service so launchd/systemd pick up the new
|
|
5147
|
+
* `StandardOutPath` / `StandardErrorPath` / `ExecStart` log paths (those are
|
|
5148
|
+
* baked into the plist/unit file at install time — when we populate the new
|
|
5149
|
+
* home, those paths would otherwise still point at the old location).
|
|
5150
|
+
*
|
|
5151
|
+
* Copy-only semantics: the legacy `~/.first-tree-hub/` tree is preserved
|
|
5152
|
+
* as a safety net. The user can inspect/fall-back to it, and can delete it
|
|
5153
|
+
* manually once they've confirmed the new layout is healthy.
|
|
5154
|
+
*
|
|
5155
|
+
* Contract:
|
|
5156
|
+
* - Synchronous and cheap when there's nothing to do (most runs — the
|
|
5157
|
+
* steady state is "new dir populated", which short-circuits the copy).
|
|
5158
|
+
* - Never throws — migration failures and service re-register failures
|
|
5159
|
+
* both fall through to a stderr warning so the CLI command still runs.
|
|
5160
|
+
* - Idempotent — safe to call on every CLI invocation.
|
|
5161
|
+
* - Skips service re-register when we are already running AS the service
|
|
5162
|
+
* (launchd/systemd invoke the CLI with `--no-interactive`), because the
|
|
5163
|
+
* re-register would bootout our own process mid-execution.
|
|
5164
|
+
*/
|
|
5165
|
+
function runHomeMigration() {
|
|
5166
|
+
const result = migrateLegacyHome({
|
|
5167
|
+
newHome: DEFAULT_HOME_DIR$1,
|
|
5168
|
+
envOverride: process.env.FIRST_TREE_HUB_HOME ?? null
|
|
5169
|
+
});
|
|
5170
|
+
if (!result.migrated) {
|
|
5171
|
+
if (result.reason === "failed") process.stderr.write(`[first-tree-hub] WARNING: failed to auto-migrate legacy home ${result.from} → ${result.to}: ${result.error ?? "unknown error"}\n Resolve manually: cp -R "${result.from}" "${result.to}"\n`);
|
|
5172
|
+
return;
|
|
5173
|
+
}
|
|
5174
|
+
process.stderr.write(`[first-tree-hub] Copied client home to new layout: ${result.from} → ${result.to}\n (Legacy directory preserved as a backup — delete it manually once you've verified the new location works.)\n`);
|
|
5175
|
+
if (process.argv.includes("--no-interactive")) {
|
|
5176
|
+
process.stderr.write("[first-tree-hub] Note: running as background service — skipped auto re-register to avoid self-termination.\n Run `first-tree-hub client service install` from a terminal to refresh log paths.\n");
|
|
5177
|
+
return;
|
|
5178
|
+
}
|
|
5179
|
+
const status = getClientServiceStatus();
|
|
5180
|
+
if (status.platform === "unsupported" || status.state === "not-installed") return;
|
|
5181
|
+
try {
|
|
5182
|
+
installClientService();
|
|
5183
|
+
process.stderr.write(`[first-tree-hub] Re-registered background service with new home paths.\n`);
|
|
5184
|
+
} catch (err) {
|
|
5185
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
5186
|
+
process.stderr.write(`[first-tree-hub] WARNING: home migration succeeded but re-registering the background service failed: ${msg}\n Run \`first-tree-hub client service install\` to refresh log paths.\n`);
|
|
5187
|
+
}
|
|
5188
|
+
}
|
|
5189
|
+
//#endregion
|
|
5190
|
+
//#region src/core/onboard.ts
|
|
5191
|
+
const STATE_FILE = join(DEFAULT_HOME_DIR$1, ".onboard-state.json");
|
|
5192
|
+
/** Save current onboard args to state file for resume. */
|
|
5193
|
+
function saveOnboardState(args) {
|
|
5194
|
+
mkdirSync(DEFAULT_HOME_DIR$1, { recursive: true });
|
|
5195
|
+
writeFileSync(STATE_FILE, JSON.stringify({ args }, null, 2));
|
|
5196
|
+
}
|
|
5197
|
+
/** Load saved onboard args from state file. */
|
|
5198
|
+
function loadOnboardState() {
|
|
5199
|
+
try {
|
|
5200
|
+
return JSON.parse(readFileSync(STATE_FILE, "utf-8")).args;
|
|
5201
|
+
} catch {
|
|
5202
|
+
return null;
|
|
5203
|
+
}
|
|
5204
|
+
}
|
|
5205
|
+
async function onboardCheck(args) {
|
|
5206
|
+
const items = [];
|
|
5207
|
+
const creds = loadCredentials();
|
|
5208
|
+
if (creds) items.push({
|
|
5209
|
+
key: "connect",
|
|
5210
|
+
label: "Signed in",
|
|
5211
|
+
status: "ok",
|
|
5212
|
+
value: creds.serverUrl
|
|
5213
|
+
});
|
|
5214
|
+
else items.push({
|
|
5215
|
+
key: "connect",
|
|
5216
|
+
label: "Signed in",
|
|
5217
|
+
status: "missing_required",
|
|
5218
|
+
hint: "Run `first-tree-hub client connect <server-url>` first"
|
|
5219
|
+
});
|
|
5220
|
+
try {
|
|
5221
|
+
const serverUrl = resolveServerUrl(args.server);
|
|
5222
|
+
items.push({
|
|
5223
|
+
key: "server",
|
|
5224
|
+
label: "Server URL",
|
|
5225
|
+
status: "ok",
|
|
5226
|
+
value: serverUrl
|
|
5227
|
+
});
|
|
5228
|
+
try {
|
|
5229
|
+
const res = await fetch(`${serverUrl}/api/v1/health`);
|
|
5230
|
+
items.push({
|
|
5231
|
+
key: "server_reachable",
|
|
5232
|
+
label: "Server reachable",
|
|
5233
|
+
status: res.ok ? "ok" : "error",
|
|
5234
|
+
value: res.ok ? "healthy" : `HTTP ${res.status}`
|
|
5235
|
+
});
|
|
5236
|
+
} catch {
|
|
5237
|
+
items.push({
|
|
5238
|
+
key: "server_reachable",
|
|
5239
|
+
label: "Server reachable",
|
|
5240
|
+
status: "error",
|
|
5241
|
+
hint: "Cannot connect to server"
|
|
5242
|
+
});
|
|
5243
|
+
}
|
|
5244
|
+
} catch {
|
|
5245
|
+
items.push({
|
|
5246
|
+
key: "server",
|
|
5247
|
+
label: "Server URL",
|
|
5248
|
+
status: "missing_required",
|
|
5249
|
+
hint: "Provide via --server, FIRST_TREE_HUB_SERVER_URL, or config"
|
|
5250
|
+
});
|
|
5251
|
+
}
|
|
5252
|
+
if (args.id) items.push({
|
|
5253
|
+
key: "id",
|
|
5254
|
+
label: "Agent ID",
|
|
5255
|
+
status: "ok",
|
|
5256
|
+
value: args.id
|
|
5257
|
+
});
|
|
5258
|
+
else items.push({
|
|
5259
|
+
key: "id",
|
|
5260
|
+
label: "Agent ID",
|
|
5261
|
+
status: "missing_required",
|
|
5262
|
+
hint: "Provide via --id"
|
|
5263
|
+
});
|
|
5264
|
+
if (args.type) items.push({
|
|
5265
|
+
key: "type",
|
|
5266
|
+
label: "Agent type",
|
|
5267
|
+
status: "ok",
|
|
5268
|
+
value: args.type
|
|
5269
|
+
});
|
|
5270
|
+
else items.push({
|
|
5271
|
+
key: "type",
|
|
5272
|
+
label: "Agent type",
|
|
5273
|
+
status: "missing_required",
|
|
5274
|
+
hint: "Provide via --type"
|
|
5275
|
+
});
|
|
5276
|
+
if (args.type && args.type !== "human") if (args.clientId) items.push({
|
|
5277
|
+
key: "client",
|
|
5278
|
+
label: "Target client",
|
|
5279
|
+
status: "ok",
|
|
5280
|
+
value: args.clientId
|
|
5281
|
+
});
|
|
5282
|
+
else items.push({
|
|
5283
|
+
key: "client",
|
|
5284
|
+
label: "Target client",
|
|
5285
|
+
status: "ok",
|
|
5286
|
+
value: "(unbound — claimed on first WS connect)"
|
|
5287
|
+
});
|
|
5288
|
+
return items;
|
|
5289
|
+
}
|
|
5290
|
+
function formatCheckReport(items) {
|
|
5291
|
+
const lines = [];
|
|
5292
|
+
for (const item of items) {
|
|
5293
|
+
const icon = item.status === "ok" ? "✅" : item.status === "missing_required" ? "❌" : item.status === "error" ? "❌" : item.status === "warning" ? "⚠️" : "⬜";
|
|
5294
|
+
const valueStr = item.value ? ` ${item.value}` : "";
|
|
5295
|
+
const hintStr = item.hint ? ` (${item.hint})` : "";
|
|
5296
|
+
lines.push(` ${icon} ${item.label.padEnd(20)}${valueStr}${hintStr}`);
|
|
5297
|
+
}
|
|
5298
|
+
return lines.join("\n");
|
|
5299
|
+
}
|
|
5300
|
+
async function createAgentViaAdmin(serverUrl, accessToken, body) {
|
|
5301
|
+
const res = await fetch(`${serverUrl}/api/v1/admin/agents`, {
|
|
5302
|
+
method: "POST",
|
|
5303
|
+
headers: {
|
|
5304
|
+
Authorization: `Bearer ${accessToken}`,
|
|
5305
|
+
"Content-Type": "application/json"
|
|
5306
|
+
},
|
|
5307
|
+
body: JSON.stringify(body),
|
|
5308
|
+
signal: AbortSignal.timeout(1e4)
|
|
5309
|
+
});
|
|
5310
|
+
if (!res.ok) {
|
|
5311
|
+
const errBody = await res.json().catch(() => ({}));
|
|
5312
|
+
throw new Error(errBody.error ?? `Failed to create agent (HTTP ${res.status})`);
|
|
5313
|
+
}
|
|
5314
|
+
return await res.json();
|
|
5315
|
+
}
|
|
5316
|
+
async function onboardCreate(args) {
|
|
5317
|
+
const serverUrl = resolveServerUrl(args.server).replace(/\/+$/, "");
|
|
5318
|
+
const accessToken = await ensureFreshAccessToken();
|
|
5319
|
+
const metadata = {};
|
|
4639
5320
|
if (args.role) metadata.role = args.role;
|
|
4640
5321
|
if (args.domains) metadata.domains = args.domains.split(",").map((d) => d.trim());
|
|
4641
5322
|
process.stderr.write(`Creating agent "${args.id}"...\n`);
|
|
@@ -4673,7 +5354,7 @@ async function onboardCreate(args) {
|
|
|
4673
5354
|
}
|
|
4674
5355
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
4675
5356
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
4676
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
5357
|
+
const { bindFeishuBot } = await import("./feishu-GlaczcVf.mjs").then((n) => n.r);
|
|
4677
5358
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
4678
5359
|
if (!targetAgentUuid) process.stderr.write(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
4679
5360
|
else {
|
|
@@ -7126,7 +7807,7 @@ var require_secure_json_parse = /* @__PURE__ */ __commonJSMin(((exports, module)
|
|
|
7126
7807
|
module.exports.scan = filter;
|
|
7127
7808
|
}));
|
|
7128
7809
|
//#endregion
|
|
7129
|
-
//#region ../server/dist/app-
|
|
7810
|
+
//#region ../server/dist/app-BcKNAbK-.mjs
|
|
7130
7811
|
var import_multipart = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
7131
7812
|
const Busboy = require_main();
|
|
7132
7813
|
const os = __require("node:os");
|
|
@@ -10430,17 +11111,35 @@ async function createNotification(db, data) {
|
|
|
10430
11111
|
pushToWebhook(db, notification).catch(() => {});
|
|
10431
11112
|
return notification;
|
|
10432
11113
|
}
|
|
10433
|
-
/**
|
|
10434
|
-
|
|
11114
|
+
/**
|
|
11115
|
+
* List notifications with pagination and optional filters, scoped to the
|
|
11116
|
+
* caller's visible agents.
|
|
11117
|
+
*
|
|
11118
|
+
* Rule: a member sees a notification iff
|
|
11119
|
+
* - it carries an `agentId` the member can see
|
|
11120
|
+
* (`agents.visibility = organization` OR `agents.managerId = self`), OR
|
|
11121
|
+
* - it has no `agentId` (org-wide system notification)
|
|
11122
|
+
*
|
|
11123
|
+
* Private agents owned by other members never surface.
|
|
11124
|
+
*/
|
|
11125
|
+
async function listNotifications(db, orgId, memberId, query) {
|
|
11126
|
+
const visibleAgents = await loadVisibleAgentIds$1(db, orgId, memberId);
|
|
11127
|
+
if (query.agentId && !visibleAgents.has(query.agentId)) return {
|
|
11128
|
+
items: [],
|
|
11129
|
+
nextCursor: null
|
|
11130
|
+
};
|
|
10435
11131
|
const conditions = [eq(notifications.organizationId, orgId)];
|
|
10436
11132
|
if (query.cursor) conditions.push(lt(notifications.createdAt, new Date(query.cursor)));
|
|
10437
11133
|
if (query.severity) conditions.push(eq(notifications.severity, query.severity));
|
|
10438
11134
|
if (query.read !== void 0) conditions.push(eq(notifications.read, query.read));
|
|
10439
11135
|
if (query.agentId) conditions.push(eq(notifications.agentId, query.agentId));
|
|
10440
11136
|
const where = and(...conditions);
|
|
10441
|
-
const
|
|
10442
|
-
const
|
|
10443
|
-
const
|
|
11137
|
+
const overscanFactor = 4;
|
|
11138
|
+
const targetLimit = query.limit;
|
|
11139
|
+
const rawLimit = Math.min(targetLimit * overscanFactor + 1, 400);
|
|
11140
|
+
const visible = (await db.select().from(notifications).where(where).orderBy(desc(notifications.createdAt)).limit(rawLimit)).filter((n) => n.agentId === null || visibleAgents.has(n.agentId));
|
|
11141
|
+
const hasMore = visible.length > targetLimit;
|
|
11142
|
+
const items = hasMore ? visible.slice(0, targetLimit) : visible;
|
|
10444
11143
|
const last = items[items.length - 1];
|
|
10445
11144
|
const nextCursor = hasMore && last ? last.createdAt.toISOString() : null;
|
|
10446
11145
|
return {
|
|
@@ -10451,36 +11150,116 @@ async function listNotifications(db, orgId, query) {
|
|
|
10451
11150
|
nextCursor
|
|
10452
11151
|
};
|
|
10453
11152
|
}
|
|
10454
|
-
/** Mark a single notification as read, scoped to organization. */
|
|
10455
|
-
async function markRead(db, notificationId,
|
|
10456
|
-
const [
|
|
11153
|
+
/** Mark a single notification as read, scoped to organization + visible agents. */
|
|
11154
|
+
async function markRead(db, notificationId, orgId, memberId) {
|
|
11155
|
+
const [existing] = await db.select({
|
|
11156
|
+
id: notifications.id,
|
|
11157
|
+
agentId: notifications.agentId
|
|
11158
|
+
}).from(notifications).where(and(eq(notifications.id, notificationId), eq(notifications.organizationId, orgId))).limit(1);
|
|
11159
|
+
if (!existing) return null;
|
|
11160
|
+
if (existing.agentId) {
|
|
11161
|
+
if (!(await loadVisibleAgentIds$1(db, orgId, memberId)).has(existing.agentId)) return null;
|
|
11162
|
+
}
|
|
11163
|
+
const [updated] = await db.update(notifications).set({ read: true }).where(and(eq(notifications.id, notificationId), eq(notifications.organizationId, orgId))).returning();
|
|
10457
11164
|
return updated ?? null;
|
|
10458
11165
|
}
|
|
10459
|
-
/** Mark all notifications
|
|
10460
|
-
async function markAllRead(db, orgId) {
|
|
10461
|
-
|
|
11166
|
+
/** Mark all notifications visible to this member as read. */
|
|
11167
|
+
async function markAllRead(db, orgId, memberId) {
|
|
11168
|
+
const visible = await loadVisibleAgentIds$1(db, orgId, memberId);
|
|
11169
|
+
const idsToMark = (await db.select({
|
|
11170
|
+
id: notifications.id,
|
|
11171
|
+
agentId: notifications.agentId
|
|
11172
|
+
}).from(notifications).where(and(eq(notifications.organizationId, orgId), eq(notifications.read, false))).limit(1e3)).filter((n) => n.agentId === null || visible.has(n.agentId)).map((n) => n.id);
|
|
11173
|
+
if (idsToMark.length === 0) return;
|
|
11174
|
+
const batchSize = 200;
|
|
11175
|
+
for (let i = 0; i < idsToMark.length; i += batchSize) {
|
|
11176
|
+
const batch = idsToMark.slice(i, i + batchSize);
|
|
11177
|
+
await db.update(notifications).set({ read: true }).where(and(eq(notifications.organizationId, orgId), eq(notifications.read, false), inArray(notifications.id, batch)));
|
|
11178
|
+
}
|
|
11179
|
+
}
|
|
11180
|
+
/**
|
|
11181
|
+
* Shared visibility predicate. Mirrors
|
|
11182
|
+
* {@link packages/server/src/services/access-control.ts#agentVisibilityCondition}
|
|
11183
|
+
* but returns a Set because the notification query joins are mostly in Node.
|
|
11184
|
+
*/
|
|
11185
|
+
async function loadVisibleAgentIds$1(db, orgId, memberId) {
|
|
11186
|
+
const rows = await db.select({ id: agents.uuid }).from(agents).where(and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED), or(eq(agents.visibility, AGENT_VISIBILITY.ORGANIZATION), eq(agents.managerId, memberId))));
|
|
11187
|
+
return new Set(rows.map((r) => r.id));
|
|
11188
|
+
}
|
|
11189
|
+
async function resolveAgentContext(db, agentId) {
|
|
11190
|
+
const [agent] = await db.select({
|
|
11191
|
+
organizationId: agents.organizationId,
|
|
11192
|
+
name: agents.name,
|
|
11193
|
+
displayName: agents.displayName,
|
|
11194
|
+
clientId: agents.clientId
|
|
11195
|
+
}).from(agents).where(eq(agents.uuid, agentId)).limit(1);
|
|
11196
|
+
if (!agent) return null;
|
|
11197
|
+
let clientLabel = null;
|
|
11198
|
+
if (agent.clientId) {
|
|
11199
|
+
const [client] = await db.select({
|
|
11200
|
+
hostname: clients.hostname,
|
|
11201
|
+
id: clients.id
|
|
11202
|
+
}).from(clients).where(eq(clients.id, agent.clientId)).limit(1);
|
|
11203
|
+
clientLabel = client?.hostname ?? agent.clientId;
|
|
11204
|
+
}
|
|
11205
|
+
return {
|
|
11206
|
+
organizationId: agent.organizationId,
|
|
11207
|
+
agentName: agent.displayName ?? agent.name ?? agentId,
|
|
11208
|
+
clientId: agent.clientId,
|
|
11209
|
+
clientLabel
|
|
11210
|
+
};
|
|
11211
|
+
}
|
|
11212
|
+
async function resolveChatContext(db, chatId) {
|
|
11213
|
+
const [chat] = await db.select({ topic: chats.topic }).from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
11214
|
+
const shortId = chatId.slice(0, 8);
|
|
11215
|
+
return { chatLabel: chat?.topic && chat.topic.trim().length > 0 ? chat.topic.trim() : `Chat ${shortId}` };
|
|
11216
|
+
}
|
|
11217
|
+
/**
|
|
11218
|
+
* Compose a human-readable message for each notification type.
|
|
11219
|
+
*
|
|
11220
|
+
* Keep subjects consistent with what the dashboard shows the member:
|
|
11221
|
+
* - Session-scoped events → subject is the chat (topic / "Chat xxxxxxxx")
|
|
11222
|
+
* - Client-scoped events → subject is the computer (hostname / clientId)
|
|
11223
|
+
* - Agent-scoped events → subject is the agent display name
|
|
11224
|
+
*/
|
|
11225
|
+
function composeMessage(type, agentCtx, chatCtx) {
|
|
11226
|
+
const agent = agentCtx.agentName;
|
|
11227
|
+
const computer = agentCtx.clientLabel ?? "Unknown computer";
|
|
11228
|
+
const chat = chatCtx?.chatLabel ?? null;
|
|
11229
|
+
switch (type) {
|
|
11230
|
+
case "session_completed": return chat ? `${chat} completed` : `${agent} completed a task`;
|
|
11231
|
+
case "session_error": return chat ? `${chat} hit an error` : `${agent} hit a session error`;
|
|
11232
|
+
case "agent_disconnected": return `Computer ${computer} disconnected`;
|
|
11233
|
+
case "agent_connected": return `Computer ${computer} reconnected`;
|
|
11234
|
+
case "agent_stale": return `Computer ${computer} is unresponsive`;
|
|
11235
|
+
case "agent_error": return `${agent} entered error state`;
|
|
11236
|
+
case "agent_blocked": return `${agent} is blocked`;
|
|
11237
|
+
case "agent_needs_decision": return chat ? `${agent} needs a decision in ${chat}` : `${agent} needs a decision`;
|
|
11238
|
+
default: return `${agent} event`;
|
|
11239
|
+
}
|
|
10462
11240
|
}
|
|
10463
11241
|
/**
|
|
10464
|
-
* Convenience: create a notification for an agent event, resolving org
|
|
10465
|
-
*
|
|
11242
|
+
* Convenience: create a notification for an agent event, resolving org,
|
|
11243
|
+
* agent display name, computer hostname, and chat topic automatically.
|
|
11244
|
+
* Callers supply the event type and severity; the message text is generated
|
|
11245
|
+
* here so language/phrasing is centralized (see {@link composeMessage}).
|
|
11246
|
+
*
|
|
11247
|
+
* Fire-and-forget — errors are swallowed so event producers never fail just
|
|
11248
|
+
* because the notification pipeline is unhealthy.
|
|
10466
11249
|
*/
|
|
10467
|
-
async function notifyAgentEvent(db, agentId, type, severity,
|
|
11250
|
+
async function notifyAgentEvent(db, agentId, type, severity, chatId) {
|
|
10468
11251
|
try {
|
|
10469
|
-
const
|
|
10470
|
-
|
|
10471
|
-
|
|
10472
|
-
displayName: agents.displayName
|
|
10473
|
-
}).from(agents).where(eq(agents.uuid, agentId)).limit(1);
|
|
10474
|
-
if (!agent) return;
|
|
10475
|
-
const name = agent.displayName ?? agent.name ?? agentId;
|
|
10476
|
-
const resolvedMessage = message.replace(agentId, name);
|
|
11252
|
+
const agentCtx = await resolveAgentContext(db, agentId);
|
|
11253
|
+
if (!agentCtx) return;
|
|
11254
|
+
const message = composeMessage(type, agentCtx, chatId ? await resolveChatContext(db, chatId) : null);
|
|
10477
11255
|
await createNotification(db, {
|
|
10478
|
-
organizationId:
|
|
11256
|
+
organizationId: agentCtx.organizationId,
|
|
10479
11257
|
type,
|
|
10480
11258
|
severity,
|
|
10481
11259
|
agentId,
|
|
10482
11260
|
chatId: chatId ?? null,
|
|
10483
|
-
|
|
11261
|
+
clientId: agentCtx.clientId,
|
|
11262
|
+
message
|
|
10484
11263
|
});
|
|
10485
11264
|
} catch {}
|
|
10486
11265
|
}
|
|
@@ -10488,6 +11267,7 @@ function pushToAdminWs(notification) {
|
|
|
10488
11267
|
broadcastToAdmins({
|
|
10489
11268
|
type: "notification",
|
|
10490
11269
|
organizationId: notification.organizationId,
|
|
11270
|
+
agentId: notification.agentId ?? null,
|
|
10491
11271
|
data: notification
|
|
10492
11272
|
});
|
|
10493
11273
|
}
|
|
@@ -10504,26 +11284,34 @@ async function pushToWebhook(db, notification) {
|
|
|
10504
11284
|
} catch {}
|
|
10505
11285
|
}
|
|
10506
11286
|
async function adminNotificationRoutes(app) {
|
|
10507
|
-
/**
|
|
11287
|
+
/**
|
|
11288
|
+
* GET /admin/notifications — list notifications visible to the caller.
|
|
11289
|
+
*
|
|
11290
|
+
* Scoped by (a) organization (via JWT) and (b) per-agent visibility: the
|
|
11291
|
+
* member only sees notifications whose agentId is visible to them
|
|
11292
|
+
* (organization-visible agents or agents they manage), plus org-wide
|
|
11293
|
+
* system notifications with no agentId. This mirrors the rule the admin
|
|
11294
|
+
* WebSocket route enforces on live pushes — REST and WS stay in sync.
|
|
11295
|
+
*/
|
|
10508
11296
|
app.get("/", async (request) => {
|
|
10509
11297
|
const member = requireMember(request);
|
|
10510
11298
|
const query = notificationQuerySchema.parse(request.query);
|
|
10511
|
-
return listNotifications(app.db, member.organizationId, query);
|
|
11299
|
+
return listNotifications(app.db, member.organizationId, member.memberId, query);
|
|
10512
11300
|
});
|
|
10513
11301
|
/** POST /admin/notifications/:id/read — mark a single notification as read */
|
|
10514
11302
|
app.post("/:id/read", async (request) => {
|
|
10515
11303
|
const member = requireMember(request);
|
|
10516
|
-
const result = await markRead(app.db, request.params.id, member.organizationId);
|
|
11304
|
+
const result = await markRead(app.db, request.params.id, member.organizationId, member.memberId);
|
|
10517
11305
|
if (!result) throw new NotFoundError(`Notification "${request.params.id}" not found`);
|
|
10518
11306
|
return {
|
|
10519
11307
|
...result,
|
|
10520
11308
|
createdAt: result.createdAt.toISOString()
|
|
10521
11309
|
};
|
|
10522
11310
|
});
|
|
10523
|
-
/** POST /admin/notifications/read-all — mark all notifications as read */
|
|
11311
|
+
/** POST /admin/notifications/read-all — mark all visible notifications as read */
|
|
10524
11312
|
app.post("/read-all", async (request) => {
|
|
10525
11313
|
const member = requireMember(request);
|
|
10526
|
-
await markAllRead(app.db, member.organizationId);
|
|
11314
|
+
await markAllRead(app.db, member.organizationId, member.memberId);
|
|
10527
11315
|
return { status: "ok" };
|
|
10528
11316
|
});
|
|
10529
11317
|
}
|
|
@@ -11634,6 +12422,8 @@ function adminWsRoutes(notifier, jwtSecret) {
|
|
|
11634
12422
|
const orgId = payload.organizationId;
|
|
11635
12423
|
if (typeof orgId !== "string" || orgId.length === 0) return;
|
|
11636
12424
|
const isPulseTick = payload.type === "pulse:tick" && typeof payload.agents === "object" && payload.agents !== null;
|
|
12425
|
+
const isNotification = payload.type === "notification";
|
|
12426
|
+
const notificationAgentId = isNotification && typeof payload.agentId === "string" && payload.agentId.length > 0 ? payload.agentId : null;
|
|
11637
12427
|
const sharedData = isPulseTick ? null : JSON.stringify(payload);
|
|
11638
12428
|
for (const [ws, meta] of adminSockets) {
|
|
11639
12429
|
if (ws.readyState !== 1 || meta.organizationId !== orgId) continue;
|
|
@@ -11643,7 +12433,10 @@ function adminWsRoutes(notifier, jwtSecret) {
|
|
|
11643
12433
|
...payload,
|
|
11644
12434
|
agents: filtered
|
|
11645
12435
|
}));
|
|
11646
|
-
} else
|
|
12436
|
+
} else {
|
|
12437
|
+
if (isNotification && notificationAgentId && !meta.visibleAgentIds.has(notificationAgentId)) continue;
|
|
12438
|
+
ws.send(sharedData);
|
|
12439
|
+
}
|
|
11647
12440
|
}
|
|
11648
12441
|
}
|
|
11649
12442
|
registerAdminBroadcaster(broadcastOrgScoped);
|
|
@@ -12340,6 +13133,11 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
12340
13133
|
clearTimeout(authTimeout);
|
|
12341
13134
|
scheduleAuthExpiry(claims.exp);
|
|
12342
13135
|
socket.send(JSON.stringify({ type: "auth:ok" }));
|
|
13136
|
+
socket.send(JSON.stringify({
|
|
13137
|
+
type: "server:welcome",
|
|
13138
|
+
serverCommandVersion: app.commandVersion,
|
|
13139
|
+
serverTimeMs: Date.now()
|
|
13140
|
+
}));
|
|
12343
13141
|
} catch (err) {
|
|
12344
13142
|
const message = err instanceof Error ? err.message : "auth failure";
|
|
12345
13143
|
socket.send(JSON.stringify({
|
|
@@ -12559,8 +13357,8 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
12559
13357
|
organizationId: session.organizationId,
|
|
12560
13358
|
notifier
|
|
12561
13359
|
});
|
|
12562
|
-
if (payload.runtimeState === "error" && shouldNotify(agentId, "agent_error")) notifyAgentEvent(app.db, agentId, "agent_error", "high"
|
|
12563
|
-
else if (payload.runtimeState === "blocked" && shouldNotify(agentId, "agent_blocked")) notifyAgentEvent(app.db, agentId, "agent_blocked", "medium"
|
|
13360
|
+
if (payload.runtimeState === "error" && shouldNotify(agentId, "agent_error")) notifyAgentEvent(app.db, agentId, "agent_error", "high").catch(() => {});
|
|
13361
|
+
else if (payload.runtimeState === "blocked" && shouldNotify(agentId, "agent_blocked")) notifyAgentEvent(app.db, agentId, "agent_blocked", "medium").catch(() => {});
|
|
12564
13362
|
} else if (type === "session:event") {
|
|
12565
13363
|
const agentId = parsed.data.agentId;
|
|
12566
13364
|
if (!agentId || !boundAgents.has(agentId)) {
|
|
@@ -12591,7 +13389,7 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
12591
13389
|
return;
|
|
12592
13390
|
}
|
|
12593
13391
|
const payload = sessionCompletionMessageSchema.parse(msg);
|
|
12594
|
-
if (shouldNotify(agentId, `session_completed:${payload.chatId}`)) notifyAgentEvent(app.db, agentId, "session_completed", "low",
|
|
13392
|
+
if (shouldNotify(agentId, `session_completed:${payload.chatId}`)) notifyAgentEvent(app.db, agentId, "session_completed", "low", payload.chatId).catch(() => {});
|
|
12595
13393
|
} else if (type === "heartbeat") {
|
|
12596
13394
|
if (clientId) {
|
|
12597
13395
|
await heartbeatClient(app.db, clientId);
|
|
@@ -12616,7 +13414,7 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
12616
13414
|
notifier.unsubscribe(info.inboxId, socket);
|
|
12617
13415
|
if (getAgentClientId(agentId) === clientId) try {
|
|
12618
13416
|
await unbindAgent(app.db, agentId);
|
|
12619
|
-
if (shouldNotify(agentId, "agent_disconnected")) notifyAgentEvent(app.db, agentId, "agent_disconnected", "medium"
|
|
13417
|
+
if (shouldNotify(agentId, "agent_disconnected")) notifyAgentEvent(app.db, agentId, "agent_disconnected", "medium").catch(() => {});
|
|
12620
13418
|
} catch {}
|
|
12621
13419
|
}
|
|
12622
13420
|
boundAgents.clear();
|
|
@@ -14118,7 +14916,7 @@ function createBackgroundTasks(app, instanceId, adapterManager, kaelRuntime) {
|
|
|
14118
14916
|
count: staleAgents.length,
|
|
14119
14917
|
agentIds: staleAgents
|
|
14120
14918
|
}, "marked agents as stale");
|
|
14121
|
-
for (const agentId of staleAgents) notifyAgentEvent(app.db, agentId, "agent_stale", "medium"
|
|
14919
|
+
for (const agentId of staleAgents) notifyAgentEvent(app.db, agentId, "agent_stale", "medium").catch(() => {});
|
|
14122
14920
|
}
|
|
14123
14921
|
} catch (err) {
|
|
14124
14922
|
log.error({ err }, "failed to heartbeat / cleanup presence");
|
|
@@ -14697,6 +15495,21 @@ function createPulseAggregator(options) {
|
|
|
14697
15495
|
ingest
|
|
14698
15496
|
};
|
|
14699
15497
|
}
|
|
15498
|
+
/**
|
|
15499
|
+
* Resolve the Command-package version advertised to clients. Prefers the
|
|
15500
|
+
* value the Command CLI explicitly injected; otherwise falls back to the
|
|
15501
|
+
* server workspace's own package.json (dev mode, `pnpm --filter … dev`).
|
|
15502
|
+
* Returning a string (rather than undefined) keeps the welcome frame well-
|
|
15503
|
+
* formed — the client treats the value advisorily.
|
|
15504
|
+
*/
|
|
15505
|
+
function resolveCommandVersion(injected) {
|
|
15506
|
+
if (injected && injected.trim().length > 0) return injected;
|
|
15507
|
+
try {
|
|
15508
|
+
const pkg = createRequire(import.meta.url)("../package.json");
|
|
15509
|
+
if (typeof pkg.version === "string" && pkg.version.length > 0) return pkg.version;
|
|
15510
|
+
} catch {}
|
|
15511
|
+
return "0.0.0";
|
|
15512
|
+
}
|
|
14700
15513
|
async function buildApp(config) {
|
|
14701
15514
|
applyLoggerConfig({
|
|
14702
15515
|
level: config.observability.logging.level,
|
|
@@ -14710,6 +15523,9 @@ async function buildApp(config) {
|
|
|
14710
15523
|
const db = connectDatabase(config.database.url);
|
|
14711
15524
|
app.decorate("db", db);
|
|
14712
15525
|
app.decorate("config", config);
|
|
15526
|
+
const commandVersion = resolveCommandVersion(config.commandVersion);
|
|
15527
|
+
app.decorate("commandVersion", commandVersion);
|
|
15528
|
+
app.log.info({ commandVersion }, "Hub server advertising command version");
|
|
14713
15529
|
const listenClient = postgres(config.database.url, { max: 1 });
|
|
14714
15530
|
const notifier = createNotifier(listenClient);
|
|
14715
15531
|
await app.register(websocket);
|
|
@@ -14906,6 +15722,10 @@ async function buildApp(config) {
|
|
|
14906
15722
|
return app;
|
|
14907
15723
|
}
|
|
14908
15724
|
//#endregion
|
|
15725
|
+
//#region src/core/version.ts
|
|
15726
|
+
const pkg = createRequire(import.meta.url)("../../package.json");
|
|
15727
|
+
const COMMAND_VERSION = typeof pkg.version === "string" && pkg.version.length > 0 ? pkg.version : "0.0.0";
|
|
15728
|
+
//#endregion
|
|
14909
15729
|
//#region src/core/server.ts
|
|
14910
15730
|
/**
|
|
14911
15731
|
* Full server start orchestration:
|
|
@@ -14918,7 +15738,7 @@ async function buildApp(config) {
|
|
|
14918
15738
|
* 7. Start Fastify server
|
|
14919
15739
|
*/
|
|
14920
15740
|
async function startServer(options) {
|
|
14921
|
-
process.stderr.write(
|
|
15741
|
+
process.stderr.write(`\n First Tree Hub v${COMMAND_VERSION}\n\n`);
|
|
14922
15742
|
const cliArgs = {};
|
|
14923
15743
|
if (options.port !== void 0) cliArgs.server = { port: options.port };
|
|
14924
15744
|
if (options.host !== void 0) cliArgs.server = {
|
|
@@ -14957,7 +15777,8 @@ async function startServer(options) {
|
|
|
14957
15777
|
const config = {
|
|
14958
15778
|
...serverConfig,
|
|
14959
15779
|
webDistPath: webDistPath ?? void 0,
|
|
14960
|
-
instanceId: `srv_${randomUUID().slice(0, 8)}
|
|
15780
|
+
instanceId: `srv_${randomUUID().slice(0, 8)}`,
|
|
15781
|
+
commandVersion: COMMAND_VERSION
|
|
14961
15782
|
};
|
|
14962
15783
|
const { initTelemetry, shutdownTelemetry } = await import("./observability-Xi-sEZI7.mjs");
|
|
14963
15784
|
await initTelemetry(serverConfig.observability.tracing, config.instanceId);
|
|
@@ -14983,417 +15804,206 @@ async function startServer(options) {
|
|
|
14983
15804
|
process.stderr.write(" Open the URL above in your browser to get started.\n");
|
|
14984
15805
|
process.stderr.write(" Press Ctrl+C to stop.\n\n");
|
|
14985
15806
|
}
|
|
14986
|
-
/**
|
|
14987
|
-
* Resolve web dist path.
|
|
14988
|
-
* 1. npm install: embedded at dist/web/ (relative to the built CLI)
|
|
14989
|
-
* 2. Monorepo dev: resolved from @first-tree-hub/web package (builds if needed)
|
|
14990
|
-
*/
|
|
14991
|
-
function resolveWebDist() {
|
|
14992
|
-
const embeddedPath = join(dirname(fileURLToPath(import.meta.url)), "..", "web");
|
|
14993
|
-
if (existsSync(join(embeddedPath, "index.html"))) return embeddedPath;
|
|
14994
|
-
try {
|
|
14995
|
-
const webDir = dirname(fileURLToPath(import.meta.resolve("@first-tree-hub/web/package.json")));
|
|
14996
|
-
const distPath = join(webDir, "dist");
|
|
14997
|
-
const indexPath = join(distPath, "index.html");
|
|
14998
|
-
if (existsSync(indexPath)) return distPath;
|
|
14999
|
-
status("Web", "building...");
|
|
15000
|
-
execSync("pnpm --filter @first-tree-hub/web build", {
|
|
15001
|
-
stdio: [
|
|
15002
|
-
"ignore",
|
|
15003
|
-
"ignore",
|
|
15004
|
-
"pipe"
|
|
15005
|
-
],
|
|
15006
|
-
cwd: join(webDir, "../..")
|
|
15007
|
-
});
|
|
15008
|
-
if (existsSync(indexPath)) return distPath;
|
|
15009
|
-
} catch {}
|
|
15010
|
-
}
|
|
15011
|
-
//#endregion
|
|
15012
|
-
//#region src/core/service-install.ts
|
|
15013
|
-
/**
|
|
15014
|
-
* Run a subprocess capturing stderr so failures surface a meaningful error
|
|
15015
|
-
* instead of Node's opaque "Command failed". Used for launchctl/systemctl —
|
|
15016
|
-
* anywhere the stderr message is diagnostically crucial.
|
|
15017
|
-
*/
|
|
15018
|
-
function runCapture(program, args, timeoutMs) {
|
|
15019
|
-
const res = spawnSync(program, args, {
|
|
15020
|
-
encoding: "utf-8",
|
|
15021
|
-
timeout: timeoutMs,
|
|
15022
|
-
stdio: [
|
|
15023
|
-
"ignore",
|
|
15024
|
-
"pipe",
|
|
15025
|
-
"pipe"
|
|
15026
|
-
]
|
|
15027
|
-
});
|
|
15028
|
-
if (res.status === 0) return { ok: true };
|
|
15029
|
-
return {
|
|
15030
|
-
ok: false,
|
|
15031
|
-
stderr: (res.stderr ?? "").trim(),
|
|
15032
|
-
code: res.status
|
|
15033
|
-
};
|
|
15034
|
-
}
|
|
15035
|
-
function sleepSync(ms) {
|
|
15036
|
-
const shared = new Int32Array(new SharedArrayBuffer(4));
|
|
15037
|
-
Atomics.wait(shared, 0, 0, ms);
|
|
15038
|
-
}
|
|
15039
|
-
const LAUNCHD_LABEL = "dev.first-tree-hub.client";
|
|
15040
|
-
const SYSTEMD_UNIT = "first-tree-hub-client.service";
|
|
15041
|
-
const LOG_DIR = join(DEFAULT_HOME_DIR$1, "logs");
|
|
15042
|
-
function whichBin(name) {
|
|
15043
|
-
try {
|
|
15044
|
-
return execFileSync(process.platform === "win32" ? "where" : "which", [name], {
|
|
15045
|
-
encoding: "utf-8",
|
|
15046
|
-
timeout: 3e3
|
|
15047
|
-
}).split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] ?? null;
|
|
15048
|
-
} catch {
|
|
15049
|
-
return null;
|
|
15050
|
-
}
|
|
15051
|
-
}
|
|
15052
|
-
/**
|
|
15053
|
-
* Resolve how the service should launch the CLI.
|
|
15054
|
-
*
|
|
15055
|
-
* Prefers the installed `first-tree-hub` bin on PATH (usually a shim under
|
|
15056
|
-
* /usr/local/bin or ~/.npm-global/bin). Falls back to invoking the current
|
|
15057
|
-
* Node interpreter against the running script (handles `pnpm dev`, tsx, and
|
|
15058
|
-
* dev-only global installs).
|
|
15059
|
-
*/
|
|
15060
|
-
function resolveCliInvocation() {
|
|
15061
|
-
const bin = whichBin("first-tree-hub");
|
|
15062
|
-
if (bin && isAbsolute(bin)) try {
|
|
15063
|
-
return {
|
|
15064
|
-
kind: "bin",
|
|
15065
|
-
program: realpathSync(bin)
|
|
15066
|
-
};
|
|
15067
|
-
} catch {
|
|
15068
|
-
return {
|
|
15069
|
-
kind: "bin",
|
|
15070
|
-
program: bin
|
|
15071
|
-
};
|
|
15072
|
-
}
|
|
15073
|
-
const script = process.argv[1];
|
|
15074
|
-
if (!script) throw new Error("Cannot resolve CLI entry point (process.argv[1] is empty).");
|
|
15075
|
-
const scriptAbs = isAbsolute(script) ? script : join(process.cwd(), script);
|
|
15076
|
-
return {
|
|
15077
|
-
kind: "node",
|
|
15078
|
-
program: process.execPath,
|
|
15079
|
-
args: [scriptAbs]
|
|
15080
|
-
};
|
|
15081
|
-
}
|
|
15082
|
-
function ensureLogDir() {
|
|
15083
|
-
mkdirSync(LOG_DIR, {
|
|
15084
|
-
recursive: true,
|
|
15085
|
-
mode: 448
|
|
15086
|
-
});
|
|
15087
|
-
}
|
|
15088
|
-
function launchdPlistPath() {
|
|
15089
|
-
return join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
15090
|
-
}
|
|
15091
|
-
function renderPlist(invocation) {
|
|
15092
|
-
const argsXml = (invocation.kind === "bin" ? [
|
|
15093
|
-
invocation.program,
|
|
15094
|
-
"client",
|
|
15095
|
-
"start",
|
|
15096
|
-
"--no-interactive"
|
|
15097
|
-
] : [
|
|
15098
|
-
invocation.program,
|
|
15099
|
-
...invocation.args,
|
|
15100
|
-
"client",
|
|
15101
|
-
"start",
|
|
15102
|
-
"--no-interactive"
|
|
15103
|
-
]).map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
|
|
15104
|
-
const outLog = join(LOG_DIR, "client.out.log");
|
|
15105
|
-
const errLog = join(LOG_DIR, "client.err.log");
|
|
15106
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
15107
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTD/PropertyList-1.0.dtd">
|
|
15108
|
-
<plist version="1.0">
|
|
15109
|
-
<dict>
|
|
15110
|
-
<key>Label</key>
|
|
15111
|
-
<string>${LAUNCHD_LABEL}</string>
|
|
15112
|
-
<key>ProgramArguments</key>
|
|
15113
|
-
<array>
|
|
15114
|
-
${argsXml}
|
|
15115
|
-
</array>
|
|
15116
|
-
<key>EnvironmentVariables</key>
|
|
15117
|
-
<dict>
|
|
15118
|
-
<key>PATH</key>
|
|
15119
|
-
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
|
|
15120
|
-
</dict>
|
|
15121
|
-
<key>RunAtLoad</key>
|
|
15122
|
-
<true/>
|
|
15123
|
-
<key>KeepAlive</key>
|
|
15124
|
-
<dict>
|
|
15125
|
-
<key>SuccessfulExit</key>
|
|
15126
|
-
<false/>
|
|
15127
|
-
</dict>
|
|
15128
|
-
<key>ThrottleInterval</key>
|
|
15129
|
-
<integer>10</integer>
|
|
15130
|
-
<key>StandardOutPath</key>
|
|
15131
|
-
<string>${escapeXml(outLog)}</string>
|
|
15132
|
-
<key>StandardErrorPath</key>
|
|
15133
|
-
<string>${escapeXml(errLog)}</string>
|
|
15134
|
-
</dict>
|
|
15135
|
-
</plist>
|
|
15136
|
-
`;
|
|
15137
|
-
}
|
|
15138
|
-
function escapeXml(value) {
|
|
15139
|
-
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
15140
|
-
}
|
|
15141
|
-
function launchctlDomainTarget() {
|
|
15142
|
-
return `gui/${userInfo().uid}`;
|
|
15143
|
-
}
|
|
15144
|
-
function launchdState() {
|
|
15145
|
-
if (!existsSync(launchdPlistPath())) return { state: "not-installed" };
|
|
15146
|
-
const res = spawnSync("launchctl", ["print", `${launchctlDomainTarget()}/${LAUNCHD_LABEL}`], {
|
|
15147
|
-
encoding: "utf-8",
|
|
15148
|
-
timeout: 5e3,
|
|
15149
|
-
stdio: [
|
|
15150
|
-
"ignore",
|
|
15151
|
-
"pipe",
|
|
15152
|
-
"pipe"
|
|
15153
|
-
]
|
|
15154
|
-
});
|
|
15155
|
-
if (res.status !== 0) return {
|
|
15156
|
-
state: "inactive",
|
|
15157
|
-
detail: "plist present but not loaded"
|
|
15158
|
-
};
|
|
15159
|
-
const out = res.stdout ?? "";
|
|
15160
|
-
const stateLine = out.split(/\r?\n/).find((l) => l.trim().startsWith("state ="));
|
|
15161
|
-
const pidLine = out.split(/\r?\n/).find((l) => l.trim().startsWith("pid ="));
|
|
15162
|
-
if (stateLine?.includes("running")) {
|
|
15163
|
-
const pid = pidLine?.split("=")[1]?.trim();
|
|
15164
|
-
return {
|
|
15165
|
-
state: "active",
|
|
15166
|
-
detail: pid ? `pid ${pid}` : "running"
|
|
15167
|
-
};
|
|
15168
|
-
}
|
|
15169
|
-
return {
|
|
15170
|
-
state: "inactive",
|
|
15171
|
-
detail: stateLine?.trim() ?? "loaded"
|
|
15172
|
-
};
|
|
15173
|
-
}
|
|
15174
|
-
/**
|
|
15175
|
-
* Poll `launchctl print` until the label disappears, confirming launchd has
|
|
15176
|
-
* finished the async eviction kicked off by `bootout`. Required because
|
|
15177
|
-
* `bootout` returns before the actual unload completes when the service has
|
|
15178
|
-
* active WebSocket connections — a follow-up `bootstrap` against a still-
|
|
15179
|
-
* registered label fails with `Bootstrap failed: 5: Input/output error`.
|
|
15807
|
+
/**
|
|
15808
|
+
* Resolve web dist path.
|
|
15809
|
+
* 1. npm install: embedded at dist/web/ (relative to the built CLI)
|
|
15810
|
+
* 2. Monorepo dev: resolved from @first-tree-hub/web package (builds if needed)
|
|
15180
15811
|
*/
|
|
15181
|
-
function
|
|
15182
|
-
const
|
|
15183
|
-
|
|
15184
|
-
|
|
15185
|
-
|
|
15186
|
-
|
|
15812
|
+
function resolveWebDist() {
|
|
15813
|
+
const embeddedPath = join(dirname(fileURLToPath(import.meta.url)), "..", "web");
|
|
15814
|
+
if (existsSync(join(embeddedPath, "index.html"))) return embeddedPath;
|
|
15815
|
+
try {
|
|
15816
|
+
const webDir = dirname(fileURLToPath(import.meta.resolve("@first-tree-hub/web/package.json")));
|
|
15817
|
+
const distPath = join(webDir, "dist");
|
|
15818
|
+
const indexPath = join(distPath, "index.html");
|
|
15819
|
+
if (existsSync(indexPath)) return distPath;
|
|
15820
|
+
status("Web", "building...");
|
|
15821
|
+
execSync("pnpm --filter @first-tree-hub/web build", {
|
|
15187
15822
|
stdio: [
|
|
15188
15823
|
"ignore",
|
|
15189
15824
|
"ignore",
|
|
15190
15825
|
"pipe"
|
|
15191
|
-
]
|
|
15192
|
-
|
|
15193
|
-
|
|
15194
|
-
|
|
15195
|
-
|
|
15196
|
-
}
|
|
15197
|
-
function installLaunchd() {
|
|
15198
|
-
const invocation = resolveCliInvocation();
|
|
15199
|
-
ensureLogDir();
|
|
15200
|
-
const plistPath = launchdPlistPath();
|
|
15201
|
-
mkdirSync(dirname(plistPath), { recursive: true });
|
|
15202
|
-
writeFileSync(plistPath, renderPlist(invocation), { mode: 420 });
|
|
15203
|
-
const target = launchctlDomainTarget();
|
|
15204
|
-
const bootoutRes = runCapture("launchctl", ["bootout", `${target}/${LAUNCHD_LABEL}`], 15e3);
|
|
15205
|
-
if (!bootoutRes.ok) {
|
|
15206
|
-
if (!/not find|no such|not loaded/i.test(bootoutRes.stderr)) process.stderr.write(` warning: launchctl bootout: ${bootoutRes.stderr || `exit ${bootoutRes.code ?? "unknown"}`}\n`);
|
|
15207
|
-
}
|
|
15208
|
-
waitForLabelEvicted(target, LAUNCHD_LABEL, 1e4);
|
|
15209
|
-
let lastBootstrapErr = null;
|
|
15210
|
-
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
15211
|
-
const res = runCapture("launchctl", [
|
|
15212
|
-
"bootstrap",
|
|
15213
|
-
target,
|
|
15214
|
-
plistPath
|
|
15215
|
-
], 1e4);
|
|
15216
|
-
if (res.ok) {
|
|
15217
|
-
lastBootstrapErr = null;
|
|
15218
|
-
break;
|
|
15219
|
-
}
|
|
15220
|
-
lastBootstrapErr = res;
|
|
15221
|
-
if (attempt < 2) sleepSync(1e3);
|
|
15222
|
-
}
|
|
15223
|
-
if (lastBootstrapErr) throw new Error(`launchctl bootstrap failed: ${lastBootstrapErr.stderr || `exit ${lastBootstrapErr.code ?? "unknown"}`}\n Command: launchctl bootstrap ${target} ${plistPath}\n Recovery: \`launchctl bootout ${target}/${LAUNCHD_LABEL}\` then \`first-tree-hub service install\`.`);
|
|
15224
|
-
const enableRes = runCapture("launchctl", ["enable", `${target}/${LAUNCHD_LABEL}`], 5e3);
|
|
15225
|
-
if (!enableRes.ok) process.stderr.write(` warning: launchctl enable: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n`);
|
|
15226
|
-
const { state, detail } = launchdState();
|
|
15227
|
-
return {
|
|
15228
|
-
platform: "launchd",
|
|
15229
|
-
label: LAUNCHD_LABEL,
|
|
15230
|
-
unitPath: plistPath,
|
|
15231
|
-
logDir: LOG_DIR,
|
|
15232
|
-
state,
|
|
15233
|
-
detail
|
|
15234
|
-
};
|
|
15235
|
-
}
|
|
15236
|
-
function uninstallLaunchd() {
|
|
15237
|
-
const plistPath = launchdPlistPath();
|
|
15238
|
-
const res = runCapture("launchctl", ["bootout", `${launchctlDomainTarget()}/${LAUNCHD_LABEL}`], 15e3);
|
|
15239
|
-
if (!res.ok && !/not find|no such|not loaded/i.test(res.stderr)) process.stderr.write(` warning: bootout during uninstall: ${res.stderr || `exit ${res.code ?? "unknown"}`}\n`);
|
|
15240
|
-
if (existsSync(plistPath)) rmSync(plistPath);
|
|
15241
|
-
return {
|
|
15242
|
-
platform: "launchd",
|
|
15243
|
-
label: LAUNCHD_LABEL,
|
|
15244
|
-
unitPath: plistPath,
|
|
15245
|
-
logDir: LOG_DIR,
|
|
15246
|
-
state: "not-installed"
|
|
15247
|
-
};
|
|
15248
|
-
}
|
|
15249
|
-
function systemdUnitPath() {
|
|
15250
|
-
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "systemd", "user", SYSTEMD_UNIT);
|
|
15826
|
+
],
|
|
15827
|
+
cwd: join(webDir, "../..")
|
|
15828
|
+
});
|
|
15829
|
+
if (existsSync(indexPath)) return distPath;
|
|
15830
|
+
} catch {}
|
|
15251
15831
|
}
|
|
15252
|
-
|
|
15253
|
-
|
|
15254
|
-
|
|
15255
|
-
|
|
15256
|
-
|
|
15257
|
-
|
|
15258
|
-
|
|
15259
|
-
|
|
15260
|
-
|
|
15261
|
-
|
|
15262
|
-
|
|
15263
|
-
|
|
15264
|
-
|
|
15265
|
-
|
|
15266
|
-
|
|
15267
|
-
|
|
15268
|
-
|
|
15269
|
-
|
|
15832
|
+
//#endregion
|
|
15833
|
+
//#region src/core/update.ts
|
|
15834
|
+
const PACKAGE_NAME = "@agent-team-foundation/first-tree-hub";
|
|
15835
|
+
/**
|
|
15836
|
+
* Pick the `npm` binary to invoke for self-update. Background service units
|
|
15837
|
+
* hard-code a minimal PATH (/usr/local/bin, /opt/homebrew/bin, /usr/bin,
|
|
15838
|
+
* /bin) that misses nvm / asdf / Volta toolchain directories — the client
|
|
15839
|
+
* launches fine from an absolute path resolved at install time, but a plain
|
|
15840
|
+
* `spawn("npm")` then ENOENTs. Node and npm always ship side-by-side, so
|
|
15841
|
+
* `dirname(execPath)/npm` is the most reliable fallback across those
|
|
15842
|
+
* managers; if the sibling is missing (e.g. corporate custom layout) we
|
|
15843
|
+
* fall back to PATH lookup.
|
|
15844
|
+
*/
|
|
15845
|
+
function resolveNpmCommand() {
|
|
15846
|
+
const binName = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
15847
|
+
const sibling = join(dirname(process.execPath), binName);
|
|
15848
|
+
if (existsSync(sibling)) return sibling;
|
|
15849
|
+
return "npm";
|
|
15270
15850
|
}
|
|
15271
|
-
|
|
15272
|
-
|
|
15273
|
-
|
|
15851
|
+
/**
|
|
15852
|
+
* Detect how the CLI was launched. Used by the update path to decide whether
|
|
15853
|
+
* `npm install -g <pkg>@latest` makes sense.
|
|
15854
|
+
*
|
|
15855
|
+
* - `"global"`: launched from an `npm install -g` install. The self-update
|
|
15856
|
+
* reinstalls the same package at `@latest`.
|
|
15857
|
+
* - `"source"`: launched from inside a git checkout (dev / monorepo). Update
|
|
15858
|
+
* is a no-op; operator should `git pull`.
|
|
15859
|
+
* - `"npx"` (fallback): any other path (e.g. one-shot `npx`, pnpm dlx). Auto
|
|
15860
|
+
* update is not safe; log a hint and skip.
|
|
15861
|
+
*/
|
|
15862
|
+
function detectInstallMode(argv1 = process.argv[1] ?? "") {
|
|
15863
|
+
if (!argv1) return "npx";
|
|
15864
|
+
let dir = dirname(resolve(argv1));
|
|
15865
|
+
for (let i = 0; i < 10; i++) {
|
|
15866
|
+
if (existsSync(resolve(dir, ".git"))) return "source";
|
|
15867
|
+
const pkgPath = resolve(dir, "package.json");
|
|
15868
|
+
if (existsSync(pkgPath)) try {
|
|
15869
|
+
if (JSON.parse(readFileSync(pkgPath, "utf8")).name === PACKAGE_NAME) {
|
|
15870
|
+
if (/\/(?:_npx|\.npm\/_npx)\//.test(dir)) return "npx";
|
|
15871
|
+
return "global";
|
|
15872
|
+
}
|
|
15873
|
+
} catch {}
|
|
15874
|
+
const parent = dirname(dir);
|
|
15875
|
+
if (parent === dir) break;
|
|
15876
|
+
dir = parent;
|
|
15877
|
+
}
|
|
15878
|
+
return "npx";
|
|
15274
15879
|
}
|
|
15275
|
-
|
|
15276
|
-
|
|
15277
|
-
|
|
15278
|
-
|
|
15279
|
-
|
|
15280
|
-
|
|
15281
|
-
|
|
15282
|
-
|
|
15283
|
-
|
|
15284
|
-
|
|
15880
|
+
/**
|
|
15881
|
+
* Install `<pkg>@latest` globally. Returns after the child exits. Does not
|
|
15882
|
+
* exit the parent process — callers are expected to handle that (so the
|
|
15883
|
+
* UpdateManager can attempt the restart itself while this function remains
|
|
15884
|
+
* side-effect-scoped).
|
|
15885
|
+
*/
|
|
15886
|
+
async function installGlobalLatest() {
|
|
15887
|
+
return new Promise((resolvePromise) => {
|
|
15888
|
+
const child = spawn(resolveNpmCommand(), [
|
|
15889
|
+
"install",
|
|
15890
|
+
"-g",
|
|
15891
|
+
`${PACKAGE_NAME}@latest`
|
|
15892
|
+
], { stdio: [
|
|
15285
15893
|
"ignore",
|
|
15286
15894
|
"pipe",
|
|
15287
15895
|
"pipe"
|
|
15288
|
-
]
|
|
15896
|
+
] });
|
|
15897
|
+
const stdoutChunks = [];
|
|
15898
|
+
const stderrChunks = [];
|
|
15899
|
+
child.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
|
|
15900
|
+
child.stderr.on("data", (chunk) => {
|
|
15901
|
+
stderrChunks.push(chunk);
|
|
15902
|
+
process.stderr.write(chunk);
|
|
15903
|
+
});
|
|
15904
|
+
child.on("error", (err) => {
|
|
15905
|
+
resolvePromise({
|
|
15906
|
+
ok: false,
|
|
15907
|
+
mode: "global",
|
|
15908
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
15909
|
+
});
|
|
15910
|
+
});
|
|
15911
|
+
child.on("exit", (code) => {
|
|
15912
|
+
if (code === 0) resolvePromise({
|
|
15913
|
+
ok: true,
|
|
15914
|
+
mode: "global",
|
|
15915
|
+
installedVersion: parseInstalledVersion(Buffer.concat(stdoutChunks).toString("utf8"))
|
|
15916
|
+
});
|
|
15917
|
+
else {
|
|
15918
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf8").trim();
|
|
15919
|
+
resolvePromise({
|
|
15920
|
+
ok: false,
|
|
15921
|
+
mode: "global",
|
|
15922
|
+
reason: `npm install -g exited with code ${code}${stderr ? `: ${stderr.split("\n").slice(-3).join(" | ")}` : ""}`
|
|
15923
|
+
});
|
|
15924
|
+
}
|
|
15925
|
+
});
|
|
15289
15926
|
});
|
|
15290
|
-
const out = (res.stdout ?? "").trim();
|
|
15291
|
-
if (res.status === 0 && out === "active") return {
|
|
15292
|
-
state: "active",
|
|
15293
|
-
detail: "running"
|
|
15294
|
-
};
|
|
15295
|
-
return {
|
|
15296
|
-
state: "inactive",
|
|
15297
|
-
detail: out || "unit present but not active"
|
|
15298
|
-
};
|
|
15299
|
-
}
|
|
15300
|
-
function installSystemd() {
|
|
15301
|
-
const invocation = resolveCliInvocation();
|
|
15302
|
-
ensureLogDir();
|
|
15303
|
-
const unitPath = systemdUnitPath();
|
|
15304
|
-
mkdirSync(dirname(unitPath), { recursive: true });
|
|
15305
|
-
writeFileSync(unitPath, renderSystemdUnit(invocation), { mode: 420 });
|
|
15306
|
-
const reloadRes = runCapture("systemctl", ["--user", "daemon-reload"], 5e3);
|
|
15307
|
-
if (!reloadRes.ok) throw new Error(`systemctl --user daemon-reload failed: ${reloadRes.stderr || `exit ${reloadRes.code ?? "unknown"}`}`);
|
|
15308
|
-
const enableRes = runCapture("systemctl", [
|
|
15309
|
-
"--user",
|
|
15310
|
-
"enable",
|
|
15311
|
-
"--now",
|
|
15312
|
-
SYSTEMD_UNIT
|
|
15313
|
-
], 1e4);
|
|
15314
|
-
if (!enableRes.ok) throw new Error(`systemctl --user enable --now ${SYSTEMD_UNIT} failed: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n Recovery: \`systemctl --user stop ${SYSTEMD_UNIT}\` then \`first-tree-hub service install\`.`);
|
|
15315
|
-
const { state, detail } = systemdState();
|
|
15316
|
-
return {
|
|
15317
|
-
platform: "systemd",
|
|
15318
|
-
label: SYSTEMD_UNIT,
|
|
15319
|
-
unitPath,
|
|
15320
|
-
logDir: LOG_DIR,
|
|
15321
|
-
state,
|
|
15322
|
-
detail
|
|
15323
|
-
};
|
|
15324
|
-
}
|
|
15325
|
-
function uninstallSystemd() {
|
|
15326
|
-
const unitPath = systemdUnitPath();
|
|
15327
|
-
const disableRes = runCapture("systemctl", [
|
|
15328
|
-
"--user",
|
|
15329
|
-
"disable",
|
|
15330
|
-
"--now",
|
|
15331
|
-
SYSTEMD_UNIT
|
|
15332
|
-
], 1e4);
|
|
15333
|
-
if (!disableRes.ok && !/not found|no such|not loaded/i.test(disableRes.stderr)) process.stderr.write(` warning: systemctl disable during uninstall: ${disableRes.stderr || `exit ${disableRes.code ?? "unknown"}`}\n`);
|
|
15334
|
-
if (existsSync(unitPath)) rmSync(unitPath);
|
|
15335
|
-
const reloadRes = runCapture("systemctl", ["--user", "daemon-reload"], 5e3);
|
|
15336
|
-
if (!reloadRes.ok) process.stderr.write(` warning: systemctl daemon-reload during uninstall: ${reloadRes.stderr || `exit ${reloadRes.code ?? "unknown"}`}\n`);
|
|
15337
|
-
return {
|
|
15338
|
-
platform: "systemd",
|
|
15339
|
-
label: SYSTEMD_UNIT,
|
|
15340
|
-
unitPath,
|
|
15341
|
-
logDir: LOG_DIR,
|
|
15342
|
-
state: "not-installed"
|
|
15343
|
-
};
|
|
15344
|
-
}
|
|
15345
|
-
/** Is background-service install supported on the current platform? */
|
|
15346
|
-
function isServiceSupported() {
|
|
15347
|
-
return process.platform === "darwin" || process.platform === "linux";
|
|
15348
15927
|
}
|
|
15349
15928
|
/**
|
|
15350
|
-
*
|
|
15351
|
-
*
|
|
15352
|
-
*
|
|
15929
|
+
* Best-effort extraction of the version npm reported as installed. npm's
|
|
15930
|
+
* stdout lines look like `+ @agent-team-foundation/first-tree-hub@0.9.2`.
|
|
15931
|
+
* Returns null if nothing matches — callers treat null as "install succeeded
|
|
15932
|
+
* but version unknown".
|
|
15353
15933
|
*/
|
|
15354
|
-
function
|
|
15355
|
-
|
|
15356
|
-
if (
|
|
15357
|
-
|
|
15358
|
-
|
|
15359
|
-
|
|
15360
|
-
function
|
|
15361
|
-
|
|
15362
|
-
|
|
15363
|
-
|
|
15364
|
-
|
|
15365
|
-
|
|
15366
|
-
|
|
15367
|
-
|
|
15368
|
-
|
|
15369
|
-
|
|
15370
|
-
|
|
15371
|
-
|
|
15372
|
-
|
|
15373
|
-
|
|
15374
|
-
|
|
15375
|
-
|
|
15376
|
-
|
|
15377
|
-
|
|
15378
|
-
|
|
15379
|
-
state,
|
|
15380
|
-
detail
|
|
15381
|
-
};
|
|
15934
|
+
function parseInstalledVersion(stdout) {
|
|
15935
|
+
const match = new RegExp(`${escapeForRegex(PACKAGE_NAME)}@(\\S+)`).exec(stdout);
|
|
15936
|
+
if (!match?.[1]) return null;
|
|
15937
|
+
const cleaned = match[1].replace(/[,\s)]+$/, "");
|
|
15938
|
+
return semver.valid(cleaned) ?? cleaned;
|
|
15939
|
+
}
|
|
15940
|
+
function escapeForRegex(s) {
|
|
15941
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
15942
|
+
}
|
|
15943
|
+
/** Interactive update prompt. Defaults to N on timeout. */
|
|
15944
|
+
const promptUpdate = async ({ currentVersion, targetVersion, timeoutSeconds }) => {
|
|
15945
|
+
const message = `A newer First Tree Hub client is available.\n You: ${currentVersion}\n Server bundled with: ${targetVersion}\n Will install: latest on npm (>= ${targetVersion})\n Updating will restart the client and briefly interrupt any active sessions.\n Update now?`;
|
|
15946
|
+
try {
|
|
15947
|
+
const controller = new AbortController();
|
|
15948
|
+
const timer = setTimeout(() => controller.abort(), timeoutSeconds * 1e3);
|
|
15949
|
+
try {
|
|
15950
|
+
return await confirm({
|
|
15951
|
+
message,
|
|
15952
|
+
default: false
|
|
15953
|
+
}, { signal: controller.signal });
|
|
15954
|
+
} finally {
|
|
15955
|
+
clearTimeout(timer);
|
|
15956
|
+
}
|
|
15957
|
+
} catch {
|
|
15958
|
+
return false;
|
|
15382
15959
|
}
|
|
15383
|
-
|
|
15384
|
-
|
|
15385
|
-
|
|
15386
|
-
|
|
15387
|
-
|
|
15388
|
-
|
|
15389
|
-
|
|
15960
|
+
};
|
|
15961
|
+
/**
|
|
15962
|
+
* Update prompt that always declines. Wired in when the operator passes
|
|
15963
|
+
* `--no-interactive` — the UpdateManager will log the drift and move on
|
|
15964
|
+
* instead of blocking on a TTY confirm.
|
|
15965
|
+
*/
|
|
15966
|
+
const declineUpdate = async () => false;
|
|
15967
|
+
/**
|
|
15968
|
+
* Build the command-layer `executeUpdate` callback.
|
|
15969
|
+
*
|
|
15970
|
+
* `managed=true` means a process supervisor (launchd / systemd / Docker
|
|
15971
|
+
* `restart`) is expected to relaunch us after `process.exit` — the callback
|
|
15972
|
+
* installs the new bits and exits with `SELF_RESTART_EXIT_CODE` so the
|
|
15973
|
+
* relaunch picks up the new binary.
|
|
15974
|
+
*
|
|
15975
|
+
* `managed=false` means the process is running standalone (e.g. manual
|
|
15976
|
+
* `client start`, `client connect --no-service`, CI without a supervisor).
|
|
15977
|
+
* Exiting in that mode would leave the client offline until an operator
|
|
15978
|
+
* noticed — so the callback instead prints a restart hint, returns
|
|
15979
|
+
* `{ installed: true }`, and the UpdateManager stops retrying until the
|
|
15980
|
+
* operator restarts manually.
|
|
15981
|
+
*/
|
|
15982
|
+
function createExecuteUpdate({ managed }) {
|
|
15983
|
+
return async () => {
|
|
15984
|
+
const mode = detectInstallMode();
|
|
15985
|
+
if (mode === "source") {
|
|
15986
|
+
process.stderr.write(" [update] Running from source checkout — self-update skipped. Use `git pull` instead.\n");
|
|
15987
|
+
return { installed: false };
|
|
15988
|
+
}
|
|
15989
|
+
if (mode === "npx") {
|
|
15990
|
+
process.stderr.write(" [update] Cannot self-update — not launched from a global npm install.\n Run `npm i -g @agent-team-foundation/first-tree-hub` manually.\n");
|
|
15991
|
+
return { installed: false };
|
|
15992
|
+
}
|
|
15993
|
+
process.stderr.write(" [update] Running `npm install -g @agent-team-foundation/first-tree-hub@latest`...\n");
|
|
15994
|
+
const result = await installGlobalLatest();
|
|
15995
|
+
if (!result.ok) {
|
|
15996
|
+
process.stderr.write(` [update] Install failed: ${result.reason}\n`);
|
|
15997
|
+
return { installed: false };
|
|
15998
|
+
}
|
|
15999
|
+
const installed = result.installedVersion ?? "latest";
|
|
16000
|
+
if (managed) {
|
|
16001
|
+
process.stderr.write(` [update] Installed ${installed}. Restarting (exit 75).\n`);
|
|
16002
|
+
process.exit(75);
|
|
16003
|
+
}
|
|
16004
|
+
process.stderr.write(` [update] Installed ${installed}. Restart the client manually (Ctrl+C then \`first-tree-hub client start\`) to pick up the new version.\n`);
|
|
16005
|
+
return { installed: true };
|
|
15390
16006
|
};
|
|
15391
16007
|
}
|
|
15392
|
-
/** Uninstall the background service. No-op if not installed. */
|
|
15393
|
-
function uninstallClientService() {
|
|
15394
|
-
if (process.platform === "darwin") return uninstallLaunchd();
|
|
15395
|
-
if (process.platform === "linux") return uninstallSystemd();
|
|
15396
|
-
return getClientServiceStatus();
|
|
15397
|
-
}
|
|
15398
16008
|
//#endregion
|
|
15399
|
-
export {
|
|
16009
|
+
export { printResults as A, SdkError as B, checkDatabase as C, checkServerHealth as D, checkServerConfig as E, stopPostgres as F, cleanWorkspaces as H, ClientRuntime as I, createOwner as L, status as M, ensurePostgres as N, checkServerReachable as O, isDockerAvailable as P, hasUser as R, checkClientConfig as S, checkNodeVersion as T, applyClientLoggerConfig as U, SessionRegistry as V, isServiceSupported as _, COMMAND_VERSION as a, runMigrations as b, promptMissingFields as c, onboardCheck as d, onboardCreate as f, installClientService as g, getClientServiceStatus as h, startServer as i, blank as j, checkWebSocket as k, formatCheckReport as l, runHomeMigration as m, declineUpdate as n, isInteractive as o, saveOnboardState as p, promptUpdate as r, promptAddAgent as s, createExecuteUpdate as t, loadOnboardState as u, resolveCliInvocation as v, checkDocker as w, checkAgentConfigs as x, uninstallClientService as y, FirstTreeHubSDK as z };
|