@agent-team-foundation/first-tree-hub 0.8.6 → 0.9.0

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.
@@ -1,7 +1,8 @@
1
1
  import { f as __require, l as __commonJSMin, m as __toESM } from "./esm-CYu4tXXn.mjs";
2
- import { C as setConfigValue, S as serverConfigSchema, _ 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, x as resolveConfigReadonly } from "./bootstrap-99vUYmLs.mjs";
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-n9Y2yGTT.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-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-hub");
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/onboard.ts
4510
- const STATE_FILE = join(DEFAULT_HOME_DIR$1, ".onboard-state.json");
4511
- /** Save current onboard args to state file for resume. */
4512
- function saveOnboardState(args) {
4513
- mkdirSync(DEFAULT_HOME_DIR$1, { recursive: true });
4514
- writeFileSync(STATE_FILE, JSON.stringify({ args }, null, 2));
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
- /** Load saved onboard args from state file. */
4517
- function loadOnboardState() {
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 JSON.parse(readFileSync(STATE_FILE, "utf-8")).args;
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
- async function onboardCheck(args) {
4525
- const items = [];
4526
- const creds = loadCredentials();
4527
- if (creds) items.push({
4528
- key: "connect",
4529
- label: "Signed in",
4530
- status: "ok",
4531
- value: creds.serverUrl
4532
- });
4533
- else items.push({
4534
- key: "connect",
4535
- label: "Signed in",
4536
- status: "missing_required",
4537
- hint: "Run `first-tree-hub client connect <server-url>` first"
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
- items.push({
4565
- key: "server",
4566
- label: "Server URL",
4567
- status: "missing_required",
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
- return lines.join("\n");
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
- async function createAgentViaAdmin(serverUrl, accessToken, body) {
4620
- const res = await fetch(`${serverUrl}/api/v1/admin/agents`, {
4621
- method: "POST",
4622
- headers: {
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
- async function onboardCreate(args) {
4636
- const serverUrl = resolveServerUrl(args.server).replace(/\/+$/, "");
4637
- const accessToken = await ensureFreshAccessToken();
4638
- const metadata = {};
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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-n9Y2yGTT.mjs").then((n) => n.r);
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-Dlx5GuFi.mjs
7810
+ //#region ../server/dist/app-BDvasXc4.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");
@@ -12340,6 +13021,11 @@ function clientWsRoutes(notifier, instanceId) {
12340
13021
  clearTimeout(authTimeout);
12341
13022
  scheduleAuthExpiry(claims.exp);
12342
13023
  socket.send(JSON.stringify({ type: "auth:ok" }));
13024
+ socket.send(JSON.stringify({
13025
+ type: "server:welcome",
13026
+ serverCommandVersion: app.commandVersion,
13027
+ serverTimeMs: Date.now()
13028
+ }));
12343
13029
  } catch (err) {
12344
13030
  const message = err instanceof Error ? err.message : "auth failure";
12345
13031
  socket.send(JSON.stringify({
@@ -14697,6 +15383,21 @@ function createPulseAggregator(options) {
14697
15383
  ingest
14698
15384
  };
14699
15385
  }
15386
+ /**
15387
+ * Resolve the Command-package version advertised to clients. Prefers the
15388
+ * value the Command CLI explicitly injected; otherwise falls back to the
15389
+ * server workspace's own package.json (dev mode, `pnpm --filter … dev`).
15390
+ * Returning a string (rather than undefined) keeps the welcome frame well-
15391
+ * formed — the client treats the value advisorily.
15392
+ */
15393
+ function resolveCommandVersion(injected) {
15394
+ if (injected && injected.trim().length > 0) return injected;
15395
+ try {
15396
+ const pkg = createRequire(import.meta.url)("../package.json");
15397
+ if (typeof pkg.version === "string" && pkg.version.length > 0) return pkg.version;
15398
+ } catch {}
15399
+ return "0.0.0";
15400
+ }
14700
15401
  async function buildApp(config) {
14701
15402
  applyLoggerConfig({
14702
15403
  level: config.observability.logging.level,
@@ -14710,6 +15411,9 @@ async function buildApp(config) {
14710
15411
  const db = connectDatabase(config.database.url);
14711
15412
  app.decorate("db", db);
14712
15413
  app.decorate("config", config);
15414
+ const commandVersion = resolveCommandVersion(config.commandVersion);
15415
+ app.decorate("commandVersion", commandVersion);
15416
+ app.log.info({ commandVersion }, "Hub server advertising command version");
14713
15417
  const listenClient = postgres(config.database.url, { max: 1 });
14714
15418
  const notifier = createNotifier(listenClient);
14715
15419
  await app.register(websocket);
@@ -14906,6 +15610,10 @@ async function buildApp(config) {
14906
15610
  return app;
14907
15611
  }
14908
15612
  //#endregion
15613
+ //#region src/core/version.ts
15614
+ const pkg = createRequire(import.meta.url)("../../package.json");
15615
+ const COMMAND_VERSION = typeof pkg.version === "string" && pkg.version.length > 0 ? pkg.version : "0.0.0";
15616
+ //#endregion
14909
15617
  //#region src/core/server.ts
14910
15618
  /**
14911
15619
  * Full server start orchestration:
@@ -14918,7 +15626,7 @@ async function buildApp(config) {
14918
15626
  * 7. Start Fastify server
14919
15627
  */
14920
15628
  async function startServer(options) {
14921
- process.stderr.write("\n First Tree Hub v0.1.0\n\n");
15629
+ process.stderr.write(`\n First Tree Hub v${COMMAND_VERSION}\n\n`);
14922
15630
  const cliArgs = {};
14923
15631
  if (options.port !== void 0) cliArgs.server = { port: options.port };
14924
15632
  if (options.host !== void 0) cliArgs.server = {
@@ -14957,7 +15665,8 @@ async function startServer(options) {
14957
15665
  const config = {
14958
15666
  ...serverConfig,
14959
15667
  webDistPath: webDistPath ?? void 0,
14960
- instanceId: `srv_${randomUUID().slice(0, 8)}`
15668
+ instanceId: `srv_${randomUUID().slice(0, 8)}`,
15669
+ commandVersion: COMMAND_VERSION
14961
15670
  };
14962
15671
  const { initTelemetry, shutdownTelemetry } = await import("./observability-Xi-sEZI7.mjs");
14963
15672
  await initTelemetry(serverConfig.observability.tracing, config.instanceId);
@@ -14983,417 +15692,206 @@ async function startServer(options) {
14983
15692
  process.stderr.write(" Open the URL above in your browser to get started.\n");
14984
15693
  process.stderr.write(" Press Ctrl+C to stop.\n\n");
14985
15694
  }
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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`.
15695
+ /**
15696
+ * Resolve web dist path.
15697
+ * 1. npm install: embedded at dist/web/ (relative to the built CLI)
15698
+ * 2. Monorepo dev: resolved from @first-tree-hub/web package (builds if needed)
15180
15699
  */
15181
- function waitForLabelEvicted(target, label, timeoutMs) {
15182
- const deadline = Date.now() + timeoutMs;
15183
- while (Date.now() < deadline) {
15184
- if (spawnSync("launchctl", ["print", `${target}/${label}`], {
15185
- encoding: "utf-8",
15186
- timeout: 2e3,
15700
+ function resolveWebDist() {
15701
+ const embeddedPath = join(dirname(fileURLToPath(import.meta.url)), "..", "web");
15702
+ if (existsSync(join(embeddedPath, "index.html"))) return embeddedPath;
15703
+ try {
15704
+ const webDir = dirname(fileURLToPath(import.meta.resolve("@first-tree-hub/web/package.json")));
15705
+ const distPath = join(webDir, "dist");
15706
+ const indexPath = join(distPath, "index.html");
15707
+ if (existsSync(indexPath)) return distPath;
15708
+ status("Web", "building...");
15709
+ execSync("pnpm --filter @first-tree-hub/web build", {
15187
15710
  stdio: [
15188
15711
  "ignore",
15189
15712
  "ignore",
15190
15713
  "pipe"
15191
- ]
15192
- }).status !== 0) return true;
15193
- sleepSync(200);
15194
- }
15195
- return false;
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);
15714
+ ],
15715
+ cwd: join(webDir, "../..")
15716
+ });
15717
+ if (existsSync(indexPath)) return distPath;
15718
+ } catch {}
15251
15719
  }
15252
- function renderSystemdUnit(invocation) {
15253
- return `[Unit]
15254
- Description=First Tree Hub Client
15255
- After=network-online.target
15256
- Wants=network-online.target
15257
-
15258
- [Service]
15259
- Type=simple
15260
- ExecStart=${invocation.kind === "bin" ? `${shellQuote(invocation.program)} client start --no-interactive` : `${shellQuote(invocation.program)} ${invocation.args.map(shellQuote).join(" ")} client start --no-interactive`}
15261
- Restart=always
15262
- RestartSec=10
15263
- StandardOutput=append:${join(LOG_DIR, "client.out.log")}
15264
- StandardError=append:${join(LOG_DIR, "client.err.log")}
15265
- Environment=PATH=/usr/local/bin:/usr/bin:/bin
15266
-
15267
- [Install]
15268
- WantedBy=default.target
15269
- `;
15720
+ //#endregion
15721
+ //#region src/core/update.ts
15722
+ const PACKAGE_NAME = "@agent-team-foundation/first-tree-hub";
15723
+ /**
15724
+ * Pick the `npm` binary to invoke for self-update. Background service units
15725
+ * hard-code a minimal PATH (/usr/local/bin, /opt/homebrew/bin, /usr/bin,
15726
+ * /bin) that misses nvm / asdf / Volta toolchain directories — the client
15727
+ * launches fine from an absolute path resolved at install time, but a plain
15728
+ * `spawn("npm")` then ENOENTs. Node and npm always ship side-by-side, so
15729
+ * `dirname(execPath)/npm` is the most reliable fallback across those
15730
+ * managers; if the sibling is missing (e.g. corporate custom layout) we
15731
+ * fall back to PATH lookup.
15732
+ */
15733
+ function resolveNpmCommand() {
15734
+ const binName = process.platform === "win32" ? "npm.cmd" : "npm";
15735
+ const sibling = join(dirname(process.execPath), binName);
15736
+ if (existsSync(sibling)) return sibling;
15737
+ return "npm";
15270
15738
  }
15271
- function shellQuote(value) {
15272
- if (/^[A-Za-z0-9_\-./:=]+$/.test(value)) return value;
15273
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
15739
+ /**
15740
+ * Detect how the CLI was launched. Used by the update path to decide whether
15741
+ * `npm install -g <pkg>@latest` makes sense.
15742
+ *
15743
+ * - `"global"`: launched from an `npm install -g` install. The self-update
15744
+ * reinstalls the same package at `@latest`.
15745
+ * - `"source"`: launched from inside a git checkout (dev / monorepo). Update
15746
+ * is a no-op; operator should `git pull`.
15747
+ * - `"npx"` (fallback): any other path (e.g. one-shot `npx`, pnpm dlx). Auto
15748
+ * update is not safe; log a hint and skip.
15749
+ */
15750
+ function detectInstallMode(argv1 = process.argv[1] ?? "") {
15751
+ if (!argv1) return "npx";
15752
+ let dir = dirname(resolve(argv1));
15753
+ for (let i = 0; i < 10; i++) {
15754
+ if (existsSync(resolve(dir, ".git"))) return "source";
15755
+ const pkgPath = resolve(dir, "package.json");
15756
+ if (existsSync(pkgPath)) try {
15757
+ if (JSON.parse(readFileSync(pkgPath, "utf8")).name === PACKAGE_NAME) {
15758
+ if (/\/(?:_npx|\.npm\/_npx)\//.test(dir)) return "npx";
15759
+ return "global";
15760
+ }
15761
+ } catch {}
15762
+ const parent = dirname(dir);
15763
+ if (parent === dir) break;
15764
+ dir = parent;
15765
+ }
15766
+ return "npx";
15274
15767
  }
15275
- function systemdState() {
15276
- if (!existsSync(systemdUnitPath())) return { state: "not-installed" };
15277
- const res = spawnSync("systemctl", [
15278
- "--user",
15279
- "is-active",
15280
- SYSTEMD_UNIT
15281
- ], {
15282
- encoding: "utf-8",
15283
- timeout: 5e3,
15284
- stdio: [
15768
+ /**
15769
+ * Install `<pkg>@latest` globally. Returns after the child exits. Does not
15770
+ * exit the parent process — callers are expected to handle that (so the
15771
+ * UpdateManager can attempt the restart itself while this function remains
15772
+ * side-effect-scoped).
15773
+ */
15774
+ async function installGlobalLatest() {
15775
+ return new Promise((resolvePromise) => {
15776
+ const child = spawn(resolveNpmCommand(), [
15777
+ "install",
15778
+ "-g",
15779
+ `${PACKAGE_NAME}@latest`
15780
+ ], { stdio: [
15285
15781
  "ignore",
15286
15782
  "pipe",
15287
15783
  "pipe"
15288
- ]
15784
+ ] });
15785
+ const stdoutChunks = [];
15786
+ const stderrChunks = [];
15787
+ child.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
15788
+ child.stderr.on("data", (chunk) => {
15789
+ stderrChunks.push(chunk);
15790
+ process.stderr.write(chunk);
15791
+ });
15792
+ child.on("error", (err) => {
15793
+ resolvePromise({
15794
+ ok: false,
15795
+ mode: "global",
15796
+ reason: err instanceof Error ? err.message : String(err)
15797
+ });
15798
+ });
15799
+ child.on("exit", (code) => {
15800
+ if (code === 0) resolvePromise({
15801
+ ok: true,
15802
+ mode: "global",
15803
+ installedVersion: parseInstalledVersion(Buffer.concat(stdoutChunks).toString("utf8"))
15804
+ });
15805
+ else {
15806
+ const stderr = Buffer.concat(stderrChunks).toString("utf8").trim();
15807
+ resolvePromise({
15808
+ ok: false,
15809
+ mode: "global",
15810
+ reason: `npm install -g exited with code ${code}${stderr ? `: ${stderr.split("\n").slice(-3).join(" | ")}` : ""}`
15811
+ });
15812
+ }
15813
+ });
15289
15814
  });
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
15815
  }
15349
15816
  /**
15350
- * Install the background service for the current platform.
15351
- *
15352
- * @throws {Error} if the platform is not supported or the service manager fails.
15817
+ * Best-effort extraction of the version npm reported as installed. npm's
15818
+ * stdout lines look like `+ @agent-team-foundation/first-tree-hub@0.9.2`.
15819
+ * Returns null if nothing matches callers treat null as "install succeeded
15820
+ * but version unknown".
15353
15821
  */
15354
- function installClientService() {
15355
- if (process.platform === "darwin") return installLaunchd();
15356
- if (process.platform === "linux") return installSystemd();
15357
- throw new Error(`Background service install is not supported on ${process.platform}. Run \`first-tree-hub client start\` manually to keep the computer online.`);
15358
- }
15359
- /** Report the current service state without modifying anything. */
15360
- function getClientServiceStatus() {
15361
- if (process.platform === "darwin") {
15362
- const { state, detail } = launchdState();
15363
- return {
15364
- platform: "launchd",
15365
- label: LAUNCHD_LABEL,
15366
- unitPath: launchdPlistPath(),
15367
- logDir: LOG_DIR,
15368
- state,
15369
- detail
15370
- };
15371
- }
15372
- if (process.platform === "linux") {
15373
- const { state, detail } = systemdState();
15374
- return {
15375
- platform: "systemd",
15376
- label: SYSTEMD_UNIT,
15377
- unitPath: systemdUnitPath(),
15378
- logDir: LOG_DIR,
15379
- state,
15380
- detail
15381
- };
15822
+ function parseInstalledVersion(stdout) {
15823
+ const match = new RegExp(`${escapeForRegex(PACKAGE_NAME)}@(\\S+)`).exec(stdout);
15824
+ if (!match?.[1]) return null;
15825
+ const cleaned = match[1].replace(/[,\s)]+$/, "");
15826
+ return semver.valid(cleaned) ?? cleaned;
15827
+ }
15828
+ function escapeForRegex(s) {
15829
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15830
+ }
15831
+ /** Interactive update prompt. Defaults to N on timeout. */
15832
+ const promptUpdate = async ({ currentVersion, targetVersion, timeoutSeconds }) => {
15833
+ 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?`;
15834
+ try {
15835
+ const controller = new AbortController();
15836
+ const timer = setTimeout(() => controller.abort(), timeoutSeconds * 1e3);
15837
+ try {
15838
+ return await confirm({
15839
+ message,
15840
+ default: false
15841
+ }, { signal: controller.signal });
15842
+ } finally {
15843
+ clearTimeout(timer);
15844
+ }
15845
+ } catch {
15846
+ return false;
15382
15847
  }
15383
- return {
15384
- platform: "unsupported",
15385
- label: "",
15386
- unitPath: "",
15387
- logDir: LOG_DIR,
15388
- state: "not-installed",
15389
- detail: `platform ${process.platform} not supported`
15848
+ };
15849
+ /**
15850
+ * Update prompt that always declines. Wired in when the operator passes
15851
+ * `--no-interactive` — the UpdateManager will log the drift and move on
15852
+ * instead of blocking on a TTY confirm.
15853
+ */
15854
+ const declineUpdate = async () => false;
15855
+ /**
15856
+ * Build the command-layer `executeUpdate` callback.
15857
+ *
15858
+ * `managed=true` means a process supervisor (launchd / systemd / Docker
15859
+ * `restart`) is expected to relaunch us after `process.exit` — the callback
15860
+ * installs the new bits and exits with `SELF_RESTART_EXIT_CODE` so the
15861
+ * relaunch picks up the new binary.
15862
+ *
15863
+ * `managed=false` means the process is running standalone (e.g. manual
15864
+ * `client start`, `client connect --no-service`, CI without a supervisor).
15865
+ * Exiting in that mode would leave the client offline until an operator
15866
+ * noticed — so the callback instead prints a restart hint, returns
15867
+ * `{ installed: true }`, and the UpdateManager stops retrying until the
15868
+ * operator restarts manually.
15869
+ */
15870
+ function createExecuteUpdate({ managed }) {
15871
+ return async () => {
15872
+ const mode = detectInstallMode();
15873
+ if (mode === "source") {
15874
+ process.stderr.write(" [update] Running from source checkout — self-update skipped. Use `git pull` instead.\n");
15875
+ return { installed: false };
15876
+ }
15877
+ if (mode === "npx") {
15878
+ 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");
15879
+ return { installed: false };
15880
+ }
15881
+ process.stderr.write(" [update] Running `npm install -g @agent-team-foundation/first-tree-hub@latest`...\n");
15882
+ const result = await installGlobalLatest();
15883
+ if (!result.ok) {
15884
+ process.stderr.write(` [update] Install failed: ${result.reason}\n`);
15885
+ return { installed: false };
15886
+ }
15887
+ const installed = result.installedVersion ?? "latest";
15888
+ if (managed) {
15889
+ process.stderr.write(` [update] Installed ${installed}. Restarting (exit 75).\n`);
15890
+ process.exit(75);
15891
+ }
15892
+ 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`);
15893
+ return { installed: true };
15390
15894
  };
15391
15895
  }
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
15896
  //#endregion
15399
- export { stopPostgres as A, checkServerReachable as C, status as D, blank as E, SdkError as F, SessionRegistry as I, cleanWorkspaces as L, createOwner as M, hasUser as N, ensurePostgres as O, FirstTreeHubSDK as P, applyClientLoggerConfig as R, checkServerHealth as S, printResults as T, checkClientConfig as _, uninstallClientService as a, checkNodeVersion as b, promptAddAgent as c, loadOnboardState as d, onboardCheck as f, checkAgentConfigs as g, runMigrations as h, resolveCliInvocation as i, ClientRuntime as j, isDockerAvailable as k, promptMissingFields as l, saveOnboardState as m, installClientService as n, startServer as o, onboardCreate as p, isServiceSupported as r, isInteractive as s, getClientServiceStatus as t, formatCheckReport as u, checkDatabase as v, checkWebSocket as w, checkServerConfig as x, checkDocker as y };
15897
+ 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 };