@agent-team-foundation/first-tree-hub 0.10.10 → 0.10.12
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.
|
@@ -585,11 +585,14 @@ const serverConfigSchema = defineConfig({
|
|
|
585
585
|
})
|
|
586
586
|
}) }),
|
|
587
587
|
cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
|
|
588
|
+
trustProxy: field(z.boolean().default(false), { env: "FIRST_TREE_HUB_TRUST_PROXY" }),
|
|
588
589
|
rateLimit: optional({
|
|
589
590
|
max: field(z.number().default(100), { env: "FIRST_TREE_HUB_RATE_LIMIT_MAX" }),
|
|
590
591
|
loginMax: field(z.number().default(5), { env: "FIRST_TREE_HUB_RATE_LIMIT_LOGIN_MAX" }),
|
|
591
|
-
webhookMax: field(z.number().default(60), { env: "FIRST_TREE_HUB_RATE_LIMIT_WEBHOOK_MAX" })
|
|
592
|
+
webhookMax: field(z.number().default(60), { env: "FIRST_TREE_HUB_RATE_LIMIT_WEBHOOK_MAX" }),
|
|
593
|
+
agentMessageMax: field(z.number().default(30), { env: "FIRST_TREE_HUB_RATE_LIMIT_AGENT_MESSAGE_MAX" })
|
|
592
594
|
}),
|
|
595
|
+
ws: optional({ maxPayload: field(z.number().int().min(1024).default(262144), { env: "FIRST_TREE_HUB_WS_MAX_PAYLOAD" }) }),
|
|
593
596
|
inbox: optional({ maxInFlightPerAgent: field(z.number().int().min(1).max(1024).default(32), { env: "FIRST_TREE_HUB_INBOX_MAX_IN_FLIGHT_PER_AGENT" }) }),
|
|
594
597
|
kael: optional({
|
|
595
598
|
endpoint: field(z.string(), { env: "KAEL_ENDPOINT" }),
|
|
@@ -690,15 +693,23 @@ function resolveAccessToken() {
|
|
|
690
693
|
* promise so N concurrent callers resolve from a single HTTP call.
|
|
691
694
|
*/
|
|
692
695
|
let inflightRefresh = null;
|
|
696
|
+
/** Default freshness window for HTTP callers: refresh if token expires within 30s. */
|
|
697
|
+
const DEFAULT_MIN_VALIDITY_MS = 3e4;
|
|
693
698
|
/**
|
|
694
699
|
* Ensure the persisted access token is fresh. Call before any API request
|
|
695
700
|
* when using persisted credentials. Returns the (possibly refreshed) access
|
|
696
701
|
* token. Service-user API keys are out of scope for this milestone.
|
|
702
|
+
*
|
|
703
|
+
* `opts.minValidityMs` raises the freshness bar — refresh when the cached
|
|
704
|
+
* token has less than that much life left. The WS proactive-refresh path
|
|
705
|
+
* passes a value that overlaps its lead window so it never receives a
|
|
706
|
+
* token already inside the "about to expire" zone.
|
|
697
707
|
*/
|
|
698
|
-
async function ensureFreshAccessToken() {
|
|
708
|
+
async function ensureFreshAccessToken(opts) {
|
|
709
|
+
const minValidityMs = opts?.minValidityMs ?? DEFAULT_MIN_VALIDITY_MS;
|
|
699
710
|
const creds = loadCredentials();
|
|
700
711
|
if (!creds) throw new Error("No credentials found. Run `first-tree-hub client connect <server-url>` to sign in.");
|
|
701
|
-
if (!
|
|
712
|
+
if (!isTokenStale(creds.accessToken, minValidityMs)) return creds.accessToken;
|
|
702
713
|
if (inflightRefresh) return inflightRefresh;
|
|
703
714
|
inflightRefresh = (async () => {
|
|
704
715
|
const res = await fetch(`${creds.serverUrl}/api/v1/auth/refresh`, {
|
|
@@ -723,13 +734,13 @@ async function ensureFreshAccessToken() {
|
|
|
723
734
|
}
|
|
724
735
|
/** Back-compat alias retained so existing call sites keep compiling. */
|
|
725
736
|
const ensureFreshAdminToken = ensureFreshAccessToken;
|
|
726
|
-
function
|
|
737
|
+
function isTokenStale(token, minValidityMs) {
|
|
727
738
|
try {
|
|
728
739
|
const parts = token.split(".");
|
|
729
740
|
if (parts.length !== 3 || !parts[1]) return true;
|
|
730
741
|
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
|
|
731
742
|
if (!payload.exp) return false;
|
|
732
|
-
return payload.exp * 1e3 < Date.now() +
|
|
743
|
+
return payload.exp * 1e3 < Date.now() + minValidityMs;
|
|
733
744
|
} catch {
|
|
734
745
|
return true;
|
|
735
746
|
}
|
package/dist/cli/index.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import "../observability-DPyf745N-BSc8QNcR.mjs";
|
|
3
|
-
import { $ as
|
|
3
|
+
import { $ as findStaleAliases, A as checkDatabase, B as installClientService, C as runHomeMigration, D as checkAgentConfigs, E as runMigrations, F as checkServerReachable, G as stopClientService, I as checkWebSocket, L as printResults, M as checkNodeVersion, N as checkServerConfig, O as checkBackgroundService, P as checkServerHealth, R as reconcileAgentConfigs, S as saveOnboardState, T as migrateLocalAgentDirs, U as restartClientService, V as isServiceSupported, W as startClientService, X as ClientRuntime, Y as stopPostgres, Z as handleClientOrgMismatch, _ as promptMissingFields, _t as probeCapabilities, a as declineUpdate, at as fail, b as onboardCheck, c as detectInstallMode, ct as print, d as startServer, dt as ClientOrgMismatchError, et as formatStaleReason, f as COMMAND_VERSION, ft as ClientUserMismatchError, g as promptAddAgent, gt as cleanWorkspaces, h as isInteractive, ht as SessionRegistry, i as createExecuteUpdate, it as resolveReplyToFromEnv, j as checkDocker, k as checkClientConfig, l as fetchLatestVersion, lt as setJsonMode, m as uploadClientCapabilities, mt as SdkError, nt as createOwner, o as promptUpdate, ot as success, p as reconcileLocalRuntimeProviders, pt as FirstTreeHubSDK, r as registerSaaSConnectCommand, s as PACKAGE_NAME, tt as removeLocalAgent, u as installGlobalLatest, v as formatCheckReport, vt as applyClientLoggerConfig, w as createApiNameResolver, x as onboardCreate, y as loadOnboardState, yt as configureClientLoggerForService, z as getClientServiceStatus } from "../saas-connect-DLVGb8OH.mjs";
|
|
4
4
|
import "../logger-core-BTmvdflj-DjW8FM4T.mjs";
|
|
5
|
-
import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, b as resetConfig, c as saveCredentials, d as DEFAULT_HOME_DIR, f as agentConfigSchema, g as initConfig, h as getConfigValue, i as loadCredentials, l as DEFAULT_CONFIG_DIR, n as ensureFreshAccessToken, o as resolveServerUrl, p as clientConfigSchema, r as ensureFreshAdminToken, s as saveAgentConfig, u as DEFAULT_DATA_DIR, w as setConfigValue, x as resetConfigMeta, y as readConfigFile } from "../bootstrap-
|
|
5
|
+
import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, b as resetConfig, c as saveCredentials, d as DEFAULT_HOME_DIR, f as agentConfigSchema, g as initConfig, h as getConfigValue, i as loadCredentials, l as DEFAULT_CONFIG_DIR, n as ensureFreshAccessToken, o as resolveServerUrl, p as clientConfigSchema, r as ensureFreshAdminToken, s as saveAgentConfig, u as DEFAULT_DATA_DIR, w as setConfigValue, x as resetConfigMeta, y as readConfigFile } from "../bootstrap-CDeXqhkQ.mjs";
|
|
6
6
|
import "../dist-DwbhZyGi.mjs";
|
|
7
7
|
import { n as bindFeishuUser, t as bindFeishuBot } from "../feishu-viiZmwcn.mjs";
|
|
8
8
|
import "../invitation-B1pjAyOz-BaCA9PII.mjs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
11
|
+
import * as semver from "semver";
|
|
11
12
|
import { Command } from "commander";
|
|
12
13
|
import { confirm, input, password, select } from "@inquirer/prompts";
|
|
13
14
|
//#region src/commands/agent-config.ts
|
|
@@ -235,7 +236,7 @@ function createSdk(agentName) {
|
|
|
235
236
|
const { serverUrl, agentId } = resolveLocalAgent(agentName);
|
|
236
237
|
return new FirstTreeHubSDK({
|
|
237
238
|
serverUrl,
|
|
238
|
-
getAccessToken: () => ensureFreshAccessToken(),
|
|
239
|
+
getAccessToken: (opts) => ensureFreshAccessToken(opts),
|
|
239
240
|
agentId
|
|
240
241
|
});
|
|
241
242
|
}
|
|
@@ -335,7 +336,7 @@ function registerAgentCommands(program) {
|
|
|
335
336
|
const clientId = readClientId();
|
|
336
337
|
const sdk = new FirstTreeHubSDK({
|
|
337
338
|
serverUrl,
|
|
338
|
-
getAccessToken: () => ensureFreshAccessToken()
|
|
339
|
+
getAccessToken: (opts) => ensureFreshAccessToken(opts)
|
|
339
340
|
});
|
|
340
341
|
const stale = await findStaleAliases({
|
|
341
342
|
clientId,
|
|
@@ -1081,8 +1082,37 @@ function registerConnectCommand(parent) {
|
|
|
1081
1082
|
function registerClientCommands(program) {
|
|
1082
1083
|
const client = program.command("client").description("Client runtime — connect agents to the server");
|
|
1083
1084
|
registerConnectCommand(client);
|
|
1084
|
-
client.command("start").description("Start client — connect all configured agents to the server").option("--no-interactive", "Skip interactive prompts (for Docker/CI)").action(async (options) => {
|
|
1085
|
+
client.command("start").description("Start client — connect all configured agents to the server").option("--no-interactive", "Skip interactive prompts (for Docker/CI)").option("--foreground", "Run inline instead of delegating to the background service (for debugging)").action(async (options) => {
|
|
1085
1086
|
try {
|
|
1087
|
+
const isSupervisorChild = options.interactive === false && process.env.FIRST_TREE_HUB_SERVICE_MODE === "1";
|
|
1088
|
+
if (!(options.foreground === true || isSupervisorChild) && isServiceSupported()) {
|
|
1089
|
+
const svc = getClientServiceStatus();
|
|
1090
|
+
if (svc.state === "active") {
|
|
1091
|
+
print.line("\n");
|
|
1092
|
+
print.line(` Service is already running (${svc.platform}${svc.detail ? `, ${svc.detail}` : ""}).\n`);
|
|
1093
|
+
print.line(" Use `first-tree-hub client restart` to restart, or `--foreground` to run inline.\n\n");
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
if (svc.state === "inactive") {
|
|
1097
|
+
const res = startClientService();
|
|
1098
|
+
if (!res.ok) {
|
|
1099
|
+
print.line(`\n Failed to start service: ${res.reason}\n`);
|
|
1100
|
+
print.line(" Try `--foreground` to run inline instead.\n\n");
|
|
1101
|
+
process.exit(1);
|
|
1102
|
+
}
|
|
1103
|
+
const after = getClientServiceStatus();
|
|
1104
|
+
print.line("\n");
|
|
1105
|
+
print.line(` Started ${after.platform} service${after.detail ? ` (${after.detail})` : ""}.\n`);
|
|
1106
|
+
const journalHint = after.platform === "systemd" ? ` (or \`journalctl --user -u ${after.label.replace(/\.service$/, "")}\`)` : "";
|
|
1107
|
+
print.line(` Logs: ${after.logDir}${journalHint}\n\n`);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (svc.state === "unknown") {
|
|
1111
|
+
print.line(`\n Service state could not be determined (${svc.platform}${svc.detail ? `: ${svc.detail}` : ""}).\n`);
|
|
1112
|
+
print.line(" Inspect with `first-tree-hub client doctor`, or pass `--foreground` to bypass.\n\n");
|
|
1113
|
+
process.exit(1);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1086
1116
|
await promptMissingFields({
|
|
1087
1117
|
schema: clientConfigSchema,
|
|
1088
1118
|
role: "client",
|
|
@@ -1191,7 +1221,7 @@ function registerClientCommands(program) {
|
|
|
1191
1221
|
});
|
|
1192
1222
|
const sdk = new FirstTreeHubSDK({
|
|
1193
1223
|
serverUrl,
|
|
1194
|
-
getAccessToken: () => ensureFreshAccessToken()
|
|
1224
|
+
getAccessToken: (opts) => ensureFreshAccessToken(opts)
|
|
1195
1225
|
});
|
|
1196
1226
|
agentCheck = await reconcileAgentConfigs({
|
|
1197
1227
|
clientId: cfg.client.id,
|
|
@@ -1212,11 +1242,72 @@ function registerClientCommands(program) {
|
|
|
1212
1242
|
checkBackgroundService()
|
|
1213
1243
|
]);
|
|
1214
1244
|
});
|
|
1215
|
-
client.command("stop").description("Stop the
|
|
1216
|
-
|
|
1217
|
-
|
|
1245
|
+
client.command("stop").description("Stop the background service (preserves auto-start; use `client start` to bring it back)").action(() => {
|
|
1246
|
+
if (!isServiceSupported()) {
|
|
1247
|
+
print.line(`\n Service control not supported on ${process.platform}.\n`);
|
|
1248
|
+
print.line(" If running inline, use Ctrl+C or kill the process.\n\n");
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
const svc = getClientServiceStatus();
|
|
1252
|
+
if (svc.state === "not-installed") {
|
|
1253
|
+
print.line("\n No background service installed — nothing to stop.\n");
|
|
1254
|
+
print.line(" If running inline, use Ctrl+C or kill the process.\n\n");
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
if (svc.state === "inactive") {
|
|
1258
|
+
print.line("\n Service is already stopped.\n\n");
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
const res = stopClientService();
|
|
1262
|
+
if (!res.ok) {
|
|
1263
|
+
print.line(`\n Failed to stop service: ${res.reason}\n\n`);
|
|
1264
|
+
process.exit(1);
|
|
1265
|
+
}
|
|
1266
|
+
print.line(`\n Stopped ${svc.platform} service.\n`);
|
|
1267
|
+
print.line(" Auto-start on next login is preserved. Run `first-tree-hub client start` to bring it back.\n\n");
|
|
1268
|
+
});
|
|
1269
|
+
client.command("restart").description("Restart the background service").action(() => {
|
|
1270
|
+
if (!isServiceSupported()) {
|
|
1271
|
+
print.line(`\n Service control not supported on ${process.platform}.\n`);
|
|
1272
|
+
print.line(" Restart your inline `client start` process manually.\n\n");
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
if (getClientServiceStatus().state === "not-installed") {
|
|
1276
|
+
print.line("\n No background service installed.\n");
|
|
1277
|
+
print.line(" Run `first-tree-hub client connect <url>` first.\n\n");
|
|
1278
|
+
process.exit(1);
|
|
1279
|
+
}
|
|
1280
|
+
const res = restartClientService();
|
|
1281
|
+
if (!res.ok) {
|
|
1282
|
+
print.line(`\n Failed to restart service: ${res.reason}\n\n`);
|
|
1283
|
+
process.exit(1);
|
|
1284
|
+
}
|
|
1285
|
+
const after = getClientServiceStatus();
|
|
1286
|
+
print.line(`\n Restarted ${after.platform} service${after.detail ? ` (${after.detail})` : ""}.\n\n`);
|
|
1218
1287
|
});
|
|
1219
|
-
client.command("status").description("Show
|
|
1288
|
+
client.command("status").description("Show CLI, service, hub, and agent status (one-screen overview)").action(() => {
|
|
1289
|
+
print.line("\n");
|
|
1290
|
+
print.line(` CLI: ${COMMAND_VERSION}\n`);
|
|
1291
|
+
if (isServiceSupported()) {
|
|
1292
|
+
const svc = getClientServiceStatus();
|
|
1293
|
+
const tail = svc.platform === "systemd" ? ` (logs: journalctl --user -u ${svc.label.replace(/\.service$/, "")} -f)` : "";
|
|
1294
|
+
if (svc.state === "active") print.line(` Service: ✓ running (${svc.platform}${svc.detail ? `, ${svc.detail}` : ""})${tail}\n`);
|
|
1295
|
+
else if (svc.state === "inactive") print.line(` Service: ✗ stopped (${svc.platform}${svc.detail ? `, ${svc.detail}` : ""})\n`);
|
|
1296
|
+
else if (svc.state === "not-installed") print.line(" Service: not installed — run `first-tree-hub client connect <url>`\n");
|
|
1297
|
+
else print.line(` Service: unknown (${svc.platform}${svc.detail ? `, ${svc.detail}` : ""})\n`);
|
|
1298
|
+
} else print.line(` Service: not supported on ${process.platform} (runs inline)\n`);
|
|
1299
|
+
const clientYaml = join(DEFAULT_CONFIG_DIR, "client.yaml");
|
|
1300
|
+
if (existsSync(clientYaml)) try {
|
|
1301
|
+
const cfg = readConfigFile(clientYaml);
|
|
1302
|
+
const serverUrl = getNested(cfg, "server.url");
|
|
1303
|
+
const clientId = getNested(cfg, "client.id");
|
|
1304
|
+
print.line(` Hub: ${serverUrl ?? "(not configured)"}\n`);
|
|
1305
|
+
print.line(` Client: ${clientId ?? "(not configured)"}\n`);
|
|
1306
|
+
} catch (err) {
|
|
1307
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1308
|
+
print.line(` Hub: (could not read ${clientYaml}: ${msg.slice(0, 60)})\n`);
|
|
1309
|
+
}
|
|
1310
|
+
else print.line(" Hub: (not configured — run `first-tree-hub client connect <url>`)\n");
|
|
1220
1311
|
const agentsDir = join(DEFAULT_CONFIG_DIR, "agents");
|
|
1221
1312
|
try {
|
|
1222
1313
|
const agents = loadAgents({
|
|
@@ -1224,14 +1315,14 @@ function registerClientCommands(program) {
|
|
|
1224
1315
|
agentsDir
|
|
1225
1316
|
});
|
|
1226
1317
|
if (agents.size === 0) {
|
|
1227
|
-
print.line("
|
|
1318
|
+
print.line(" Agents: 0 configured\n\n");
|
|
1228
1319
|
return;
|
|
1229
1320
|
}
|
|
1230
|
-
print.line(
|
|
1231
|
-
for (const [name, config] of agents) print.line(`
|
|
1321
|
+
print.line(` Agents: ${agents.size} configured\n\n`);
|
|
1322
|
+
for (const [name, config] of agents) print.line(` ${name.padEnd(20)} runtime: ${config.runtime.padEnd(14)} agentId: ${config.agentId}\n`);
|
|
1232
1323
|
print.line("\n");
|
|
1233
1324
|
} catch {
|
|
1234
|
-
print.line("
|
|
1325
|
+
print.line(" Agents: (no agents directory)\n\n");
|
|
1235
1326
|
}
|
|
1236
1327
|
});
|
|
1237
1328
|
client.command("hub-list").description("List clients on the Hub server").option("--server <url>", "Hub server URL").action(async (options) => {
|
|
@@ -1303,7 +1394,7 @@ function registerClientCommands(program) {
|
|
|
1303
1394
|
try {
|
|
1304
1395
|
const sdk = new FirstTreeHubSDK({
|
|
1305
1396
|
serverUrl,
|
|
1306
|
-
getAccessToken: () => ensureFreshAccessToken()
|
|
1397
|
+
getAccessToken: (opts) => ensureFreshAccessToken(opts)
|
|
1307
1398
|
});
|
|
1308
1399
|
const stale = await findStaleAliases({
|
|
1309
1400
|
clientId,
|
|
@@ -1374,6 +1465,15 @@ function timeSince(isoDate) {
|
|
|
1374
1465
|
if (hours < 24) return `${hours}h ${minutes % 60}m`;
|
|
1375
1466
|
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
1376
1467
|
}
|
|
1468
|
+
/** Read a `dot.path.like.this` from a parsed YAML object, returning string | null. */
|
|
1469
|
+
function getNested(obj, path) {
|
|
1470
|
+
let cur = obj;
|
|
1471
|
+
for (const part of path.split(".")) {
|
|
1472
|
+
if (cur === null || cur === void 0 || typeof cur !== "object") return null;
|
|
1473
|
+
cur = cur[part];
|
|
1474
|
+
}
|
|
1475
|
+
return typeof cur === "string" ? cur : null;
|
|
1476
|
+
}
|
|
1377
1477
|
//#endregion
|
|
1378
1478
|
//#region src/commands/config.ts
|
|
1379
1479
|
function resolveConfigPath(flags) {
|
|
@@ -1471,13 +1571,13 @@ function isSecretField(schema, dotPath) {
|
|
|
1471
1571
|
//#region src/commands/onboard.ts
|
|
1472
1572
|
async function promptMissing(args) {
|
|
1473
1573
|
if (!args.server) try {
|
|
1474
|
-
const { resolveServerUrl } = await import("../bootstrap-
|
|
1574
|
+
const { resolveServerUrl } = await import("../bootstrap-CDeXqhkQ.mjs").then((n) => n.t);
|
|
1475
1575
|
resolveServerUrl();
|
|
1476
1576
|
} catch {
|
|
1477
1577
|
args.server = await input({ message: "Hub server URL:" });
|
|
1478
1578
|
saveOnboardState(args);
|
|
1479
1579
|
}
|
|
1480
|
-
const { loadCredentials } = await import("../bootstrap-
|
|
1580
|
+
const { loadCredentials } = await import("../bootstrap-CDeXqhkQ.mjs").then((n) => n.t);
|
|
1481
1581
|
if (!loadCredentials()) throw new Error("No saved credentials. Run `first-tree-hub client connect <server-url>` before onboarding.");
|
|
1482
1582
|
if (!args.id) {
|
|
1483
1583
|
args.id = await input({ message: "Agent ID:" });
|
|
@@ -1679,62 +1779,92 @@ function registerServerCommands(program) {
|
|
|
1679
1779
|
});
|
|
1680
1780
|
}
|
|
1681
1781
|
//#endregion
|
|
1682
|
-
//#region src/commands/
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1782
|
+
//#region src/commands/update.ts
|
|
1783
|
+
/**
|
|
1784
|
+
* `first-tree-hub update` — user-driven CLI upgrade.
|
|
1785
|
+
*
|
|
1786
|
+
* Lives at the top level (not under `client`) because the tarball bundles
|
|
1787
|
+
* server / client / web / shared into a single artifact: upgrading affects
|
|
1788
|
+
* the whole CLI, not the client subsystem alone.
|
|
1789
|
+
*
|
|
1790
|
+
* Pairs with — but does not replace — the server-driven UpdateManager
|
|
1791
|
+
* (packages/client/src/runtime/update-manager.ts), which fires automatically
|
|
1792
|
+
* when a connected client falls behind the server-bundled version. This
|
|
1793
|
+
* command is the manual equivalent: same install + restart sequence, but
|
|
1794
|
+
* triggered on the operator's terms.
|
|
1795
|
+
*/
|
|
1796
|
+
function registerUpdateCommand(program) {
|
|
1797
|
+
program.command("update").description("Upgrade first-tree-hub to the latest published version and restart the service").option("--check", "Only check whether a newer version is available; do not install").option("--no-restart", "Install the new version but skip restarting the background service").action(async (options) => {
|
|
1798
|
+
const mode = detectInstallMode();
|
|
1799
|
+
if (mode === "source") {
|
|
1800
|
+
print.line("\n Running from a source checkout — `update` is a no-op.\n");
|
|
1801
|
+
print.line(" Use `git pull` instead.\n\n");
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
if (mode === "npx") {
|
|
1805
|
+
print.line("\n Not launched from a global npm install — cannot self-update.\n");
|
|
1806
|
+
print.line(` Install globally first: npm i -g ${PACKAGE_NAME}\n\n`);
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
print.line("\n Checking npm registry...\n");
|
|
1810
|
+
const latest = fetchLatestVersion();
|
|
1811
|
+
if (!latest.ok) {
|
|
1812
|
+
print.line(` Could not fetch latest version: ${latest.reason}\n\n`);
|
|
1813
|
+
process.exit(1);
|
|
1814
|
+
}
|
|
1815
|
+
const current = COMMAND_VERSION;
|
|
1816
|
+
if ((semver.valid(current) ? semver.compare(current, latest.version) : -1) >= 0) {
|
|
1817
|
+
print.line(` Already on ${current} (latest is ${latest.version}).\n\n`);
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
if (options.check) {
|
|
1821
|
+
print.line(` Update available: ${current} → ${latest.version}\n`);
|
|
1822
|
+
print.line(" Run `first-tree-hub update` to install.\n\n");
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
print.line(` Updating ${current} → ${latest.version}...\n`);
|
|
1826
|
+
const installRes = await installGlobalLatest();
|
|
1827
|
+
if (!installRes.ok) {
|
|
1828
|
+
print.line(`\n Install failed: ${installRes.reason}\n\n`);
|
|
1829
|
+
process.exit(1);
|
|
1830
|
+
}
|
|
1831
|
+
const installed = installRes.installedVersion ?? latest.version;
|
|
1832
|
+
print.line(` Installed ${installed}.\n`);
|
|
1833
|
+
if (options.restart === false) {
|
|
1834
|
+
print.line(" Skipping restart (--no-restart). Run `first-tree-hub client restart` when ready.\n\n");
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
if (!isServiceSupported()) {
|
|
1838
|
+
print.line(` No service manager on ${process.platform}; restart your inline `);
|
|
1839
|
+
print.line("`client start` process to pick up the new version.\n\n");
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
const svc = getClientServiceStatus();
|
|
1843
|
+
if (svc.state === "not-installed") {
|
|
1844
|
+
print.line(" No background service installed — nothing to restart.\n");
|
|
1845
|
+
print.line(" Run `first-tree-hub client connect <url>` to set one up.\n\n");
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1689
1848
|
try {
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
} else print.line(` Server: ✗ unhealthy (${res.status})\n`);
|
|
1696
|
-
} catch {
|
|
1697
|
-
print.line(` Server: ✗ not running (${serverUrl})\n`);
|
|
1849
|
+
installClientService();
|
|
1850
|
+
} catch (err) {
|
|
1851
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1852
|
+
print.line(` warning: unit-file refresh failed: ${msg}\n`);
|
|
1853
|
+
print.line(" Continuing with restart against the old unit.\n");
|
|
1698
1854
|
}
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
print.line(" Agents: error reading config\n");
|
|
1711
|
-
}
|
|
1712
|
-
else print.line(" Agents: 0 configured\n");
|
|
1713
|
-
const clientConfigPath = join(DEFAULT_CONFIG_DIR, "client.yaml");
|
|
1714
|
-
if (existsSync(clientConfigPath)) {
|
|
1715
|
-
const clientServerUrl = getNestedValue(readConfigFile(clientConfigPath), "server.url");
|
|
1716
|
-
print.line(` Client: configured → ${clientServerUrl}\n`);
|
|
1717
|
-
} else print.line(" Client: not configured\n");
|
|
1718
|
-
print.line("\n");
|
|
1855
|
+
if (svc.state === "inactive") {
|
|
1856
|
+
print.line(" Service is stopped — leaving it stopped. Use `client start` to bring it up.\n\n");
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
const restartRes = restartClientService();
|
|
1860
|
+
if (!restartRes.ok) {
|
|
1861
|
+
print.line(`\n Service restart failed: ${restartRes.reason}\n`);
|
|
1862
|
+
print.line(" Run `first-tree-hub client restart` to retry.\n\n");
|
|
1863
|
+
process.exit(1);
|
|
1864
|
+
}
|
|
1865
|
+
print.line(` Service restarted on ${installed}.\n\n`);
|
|
1719
1866
|
});
|
|
1720
1867
|
}
|
|
1721
|
-
function getNestedValue(obj, dotPath) {
|
|
1722
|
-
const parts = dotPath.split(".");
|
|
1723
|
-
let current = obj;
|
|
1724
|
-
for (const part of parts) {
|
|
1725
|
-
if (current === null || current === void 0 || typeof current !== "object") return void 0;
|
|
1726
|
-
current = current[part];
|
|
1727
|
-
}
|
|
1728
|
-
return current;
|
|
1729
|
-
}
|
|
1730
|
-
function formatUptime(seconds) {
|
|
1731
|
-
const days = Math.floor(seconds / 86400);
|
|
1732
|
-
const hours = Math.floor(seconds % 86400 / 3600);
|
|
1733
|
-
const mins = Math.floor(seconds % 3600 / 60);
|
|
1734
|
-
if (days > 0) return `${days}d ${hours}h`;
|
|
1735
|
-
if (hours > 0) return `${hours}h ${mins}m`;
|
|
1736
|
-
return `${mins}m`;
|
|
1737
|
-
}
|
|
1738
1868
|
//#endregion
|
|
1739
1869
|
//#region src/cli/index.ts
|
|
1740
1870
|
runHomeMigration();
|
|
@@ -1759,7 +1889,7 @@ registerServerCommands(program);
|
|
|
1759
1889
|
registerClientCommands(program);
|
|
1760
1890
|
registerAgentCommands(program);
|
|
1761
1891
|
registerConfigCommands(program);
|
|
1762
|
-
|
|
1892
|
+
registerUpdateCommand(program);
|
|
1763
1893
|
registerOnboardCommand(program);
|
|
1764
1894
|
program.parse();
|
|
1765
1895
|
//#endregion
|
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import "./observability-DPyf745N-BSc8QNcR.mjs";
|
|
2
|
-
import { A as
|
|
2
|
+
import { A as checkDatabase, B as installClientService, C as runHomeMigration, D as checkAgentConfigs, E as runMigrations, F as checkServerReachable, G as stopClientService, H as resolveCliInvocation, I as checkWebSocket, J as isDockerAvailable, K as uninstallClientService, L as printResults, M as checkNodeVersion, N as checkServerConfig, P as checkServerHealth, Q as rotateClientIdWithBackup, U as restartClientService, V as isServiceSupported, W as startClientService, X as ClientRuntime, Y as stopPostgres, Z as handleClientOrgMismatch, _ as promptMissingFields, b as onboardCheck, d as startServer, g as promptAddAgent, h as isInteractive, j as checkDocker, k as checkClientConfig, mt as SdkError, n as deriveHubUrlFromToken, nt as createOwner, pt as FirstTreeHubSDK, q as ensurePostgres, rt as hasUser, st as blank, t as HubUrlDerivationError, ut as status, v as formatCheckReport, x as onboardCreate, z as getClientServiceStatus } from "./saas-connect-DLVGb8OH.mjs";
|
|
3
3
|
import "./logger-core-BTmvdflj-DjW8FM4T.mjs";
|
|
4
|
-
import { a as resolveAccessToken, n as ensureFreshAccessToken, o as resolveServerUrl, r as ensureFreshAdminToken } from "./bootstrap-
|
|
4
|
+
import { a as resolveAccessToken, n as ensureFreshAccessToken, o as resolveServerUrl, r as ensureFreshAdminToken } from "./bootstrap-CDeXqhkQ.mjs";
|
|
5
5
|
import "./dist-DwbhZyGi.mjs";
|
|
6
6
|
import { n as bindFeishuUser, t as bindFeishuBot } from "./feishu-viiZmwcn.mjs";
|
|
7
7
|
import "./invitation-B1pjAyOz-BaCA9PII.mjs";
|
|
8
|
-
export { ClientRuntime, FirstTreeHubSDK, HubUrlDerivationError, SdkError, bindFeishuBot, bindFeishuUser, blank, checkAgentConfigs, checkClientConfig, checkDatabase, checkDocker, checkNodeVersion, checkServerConfig, checkServerHealth, checkServerReachable, checkWebSocket, createOwner, deriveHubUrlFromToken, ensureFreshAccessToken, ensureFreshAdminToken, ensurePostgres, formatCheckReport, getClientServiceStatus, handleClientOrgMismatch, hasUser, installClientService, isDockerAvailable, isInteractive, isServiceSupported, onboardCheck, onboardCreate, printResults, promptAddAgent, promptMissingFields, resolveAccessToken, resolveCliInvocation, resolveServerUrl, rotateClientIdWithBackup, runHomeMigration, runMigrations, startServer, status, stopPostgres, uninstallClientService };
|
|
8
|
+
export { ClientRuntime, FirstTreeHubSDK, HubUrlDerivationError, SdkError, bindFeishuBot, bindFeishuUser, blank, checkAgentConfigs, checkClientConfig, checkDatabase, checkDocker, checkNodeVersion, checkServerConfig, checkServerHealth, checkServerReachable, checkWebSocket, createOwner, deriveHubUrlFromToken, ensureFreshAccessToken, ensureFreshAdminToken, ensurePostgres, formatCheckReport, getClientServiceStatus, handleClientOrgMismatch, hasUser, installClientService, isDockerAvailable, isInteractive, isServiceSupported, onboardCheck, onboardCreate, printResults, promptAddAgent, promptMissingFields, resolveAccessToken, resolveCliInvocation, resolveServerUrl, restartClientService, rotateClientIdWithBackup, runHomeMigration, runMigrations, startClientService, startServer, status, stopClientService, stopPostgres, uninstallClientService };
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { m as __toESM } from "./esm-CYu4tXXn.mjs";
|
|
2
2
|
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$1, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-DPyf745N-BSc8QNcR.mjs";
|
|
3
|
-
import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, b as resetConfig, c as saveCredentials, 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, x as resetConfigMeta } from "./bootstrap-
|
|
3
|
+
import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, b as resetConfig, c as saveCredentials, 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, x as resetConfigMeta } from "./bootstrap-CDeXqhkQ.mjs";
|
|
4
4
|
import { $ as refreshTokenSchema, A as createOrgFromMeSchema, B as imageInlineContentSchema, C as clientRegisterSchema, D as createAgentSchema, E as createAdapterMappingSchema, F as dryRunAgentRuntimeConfigSchema, G as isReservedAgentName$1, H as inboxDeliverFrameSchema$1, I as extractMentions, J as loginSchema, K as joinByInvitationSchema, L as githubCallbackQuerySchema, M as createTaskSchema, N as defaultRuntimeConfigPayload, O as createChatSchema, P as delegateFeishuUserSchema, Q as rebindAgentSchema, R as githubDevCallbackQuerySchema, S as clientCapabilitiesSchema$1, St as wsAuthFrameSchema, T as createAdapterConfigSchema, U as inboxPollQuerySchema, V as inboxAckFrameSchema, W as isRedactedEnvValue, X as notificationQuerySchema, Y as messageSourceSchema$1, Z as paginationQuerySchema, _ as adminUpdateTaskSchema, _t as updateClientCapabilitiesSchema, a as AGENT_STATUSES, at as sendToAgentSchema, b as agentRuntimeConfigPayloadSchema$1, bt as updateSystemConfigSchema, ct as sessionEventSchema$1, d as TASK_HEALTH_SIGNALS, dt as switchOrgSchema, et as runtimeStateMessageSchema, f as TASK_STATUSES, ft as taskListQuerySchema, g as adminCreateTaskSchema, gt as updateChatSchema, h as addParticipantSchema, ht as updateAgentSchema, i as AGENT_SOURCES, it as sendMessageSchema, j as createOrganizationSchema, k as createMemberSchema, l as SYSTEM_CONFIG_DEFAULTS, lt as sessionReconcileRequestSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, mt as updateAgentRuntimeConfigSchema, n as AGENT_NAME_REGEX$1, nt as scanMentionTokens, o as AGENT_TYPES, ot as sessionCompletionMessageSchema, p as TASK_TERMINAL_STATUSES, pt as updateAdapterConfigSchema, q as linkTaskChatSchema, r as AGENT_SELECTOR_HEADER$1, rt as selfServiceFeishuBotSchema, s as AGENT_VISIBILITY, st as sessionEventMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as safeRedirectPath, u as TASK_CREATOR_TYPES, ut as sessionStateMessageSchema, v as agentBindRequestSchema, vt as updateMemberSchema, w as connectTokenExchangeSchema, x as agentTypeSchema$1, xt as updateTaskStatusSchema, y as agentPinnedMessageSchema$1, yt as updateOrganizationSchema, z as githubStartQuerySchema } from "./dist-DwbhZyGi.mjs";
|
|
5
5
|
import { _ as recordRedemption, a as ConflictError, b as uuidv7, c as UnauthorizedError, d as findActiveByToken, f as getActiveInvitation, h as organizations, i as ClientUserMismatchError$1, l as buildInviteUrl, m as invitations, n as BadRequestError, o as ForbiddenError, p as invitationRedemptions, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as ensureActiveInvitation, y as users } from "./invitation-B1pjAyOz-BaCA9PII.mjs";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { ZodError, z } from "zod";
|
|
8
|
-
import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
8
|
+
import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
9
9
|
import { Writable } from "node:stream";
|
|
10
10
|
import { homedir, hostname, platform, tmpdir, userInfo } from "node:os";
|
|
11
11
|
import { EventEmitter } from "node:events";
|
|
@@ -1539,11 +1539,14 @@ defineConfig({
|
|
|
1539
1539
|
})
|
|
1540
1540
|
}) }),
|
|
1541
1541
|
cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
|
|
1542
|
+
trustProxy: field(z.boolean().default(false), { env: "FIRST_TREE_HUB_TRUST_PROXY" }),
|
|
1542
1543
|
rateLimit: optional({
|
|
1543
1544
|
max: field(z.number().default(100), { env: "FIRST_TREE_HUB_RATE_LIMIT_MAX" }),
|
|
1544
1545
|
loginMax: field(z.number().default(5), { env: "FIRST_TREE_HUB_RATE_LIMIT_LOGIN_MAX" }),
|
|
1545
|
-
webhookMax: field(z.number().default(60), { env: "FIRST_TREE_HUB_RATE_LIMIT_WEBHOOK_MAX" })
|
|
1546
|
+
webhookMax: field(z.number().default(60), { env: "FIRST_TREE_HUB_RATE_LIMIT_WEBHOOK_MAX" }),
|
|
1547
|
+
agentMessageMax: field(z.number().default(30), { env: "FIRST_TREE_HUB_RATE_LIMIT_AGENT_MESSAGE_MAX" })
|
|
1546
1548
|
}),
|
|
1549
|
+
ws: optional({ maxPayload: field(z.number().int().min(1024).default(262144), { env: "FIRST_TREE_HUB_WS_MAX_PAYLOAD" }) }),
|
|
1547
1550
|
inbox: optional({ maxInFlightPerAgent: field(z.number().int().min(1).max(1024).default(32), { env: "FIRST_TREE_HUB_INBOX_MAX_IN_FLIGHT_PER_AGENT" }) }),
|
|
1548
1551
|
kael: optional({
|
|
1549
1552
|
endpoint: field(z.string(), { env: "KAEL_ENDPOINT" }),
|
|
@@ -2061,7 +2064,7 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2061
2064
|
this.reconnectAttempt = 0;
|
|
2062
2065
|
this.wsLogger.debug("socket opened, sending auth");
|
|
2063
2066
|
try {
|
|
2064
|
-
const token = await this.getAccessToken();
|
|
2067
|
+
const token = await this.getAccessToken({ minValidityMs: AUTH_REFRESH_LEAD_MS + 5e3 });
|
|
2065
2068
|
ws.send(JSON.stringify({
|
|
2066
2069
|
type: "auth",
|
|
2067
2070
|
token
|
|
@@ -2093,7 +2096,11 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2093
2096
|
settle(reject, typedErr ?? /* @__PURE__ */ new Error(`WebSocket closed before ready (code ${code})`));
|
|
2094
2097
|
return;
|
|
2095
2098
|
}
|
|
2096
|
-
this.wsLogger.
|
|
2099
|
+
if (code === 1e3) this.wsLogger.info({
|
|
2100
|
+
code,
|
|
2101
|
+
wasRegistered
|
|
2102
|
+
}, "disconnected");
|
|
2103
|
+
else this.wsLogger.warn({
|
|
2097
2104
|
code,
|
|
2098
2105
|
wasRegistered
|
|
2099
2106
|
}, "disconnected");
|
|
@@ -6254,7 +6261,7 @@ var ClientRuntime = class {
|
|
|
6254
6261
|
serverUrl,
|
|
6255
6262
|
clientId,
|
|
6256
6263
|
sdkVersion: options.currentVersion,
|
|
6257
|
-
getAccessToken: () => ensureFreshAccessToken()
|
|
6264
|
+
getAccessToken: (opts) => ensureFreshAccessToken(opts)
|
|
6258
6265
|
});
|
|
6259
6266
|
registerBuiltinHandlers();
|
|
6260
6267
|
this.connection.on("auth:expired", () => {
|
|
@@ -6637,6 +6644,37 @@ function runCapture(program, args, timeoutMs) {
|
|
|
6637
6644
|
]
|
|
6638
6645
|
});
|
|
6639
6646
|
if (res.status === 0) return { ok: true };
|
|
6647
|
+
if (res.signal) return {
|
|
6648
|
+
ok: false,
|
|
6649
|
+
stderr: `${program} timed out after ${timeoutMs}ms (signal=${res.signal})`,
|
|
6650
|
+
code: null
|
|
6651
|
+
};
|
|
6652
|
+
return {
|
|
6653
|
+
ok: false,
|
|
6654
|
+
stderr: (res.stderr ?? "").trim(),
|
|
6655
|
+
code: res.status
|
|
6656
|
+
};
|
|
6657
|
+
}
|
|
6658
|
+
/** Same as runCapture but also returns stdout — for queries (loginctl show-user, etc.). */
|
|
6659
|
+
function runCaptureOut(program, args, timeoutMs) {
|
|
6660
|
+
const res = spawnSync(program, args, {
|
|
6661
|
+
encoding: "utf-8",
|
|
6662
|
+
timeout: timeoutMs,
|
|
6663
|
+
stdio: [
|
|
6664
|
+
"ignore",
|
|
6665
|
+
"pipe",
|
|
6666
|
+
"pipe"
|
|
6667
|
+
]
|
|
6668
|
+
});
|
|
6669
|
+
if (res.status === 0) return {
|
|
6670
|
+
ok: true,
|
|
6671
|
+
stdout: (res.stdout ?? "").trim()
|
|
6672
|
+
};
|
|
6673
|
+
if (res.signal) return {
|
|
6674
|
+
ok: false,
|
|
6675
|
+
stderr: `${program} timed out after ${timeoutMs}ms (signal=${res.signal})`,
|
|
6676
|
+
code: null
|
|
6677
|
+
};
|
|
6640
6678
|
return {
|
|
6641
6679
|
ok: false,
|
|
6642
6680
|
stderr: (res.stderr ?? "").trim(),
|
|
@@ -6647,8 +6685,40 @@ function sleepSync(ms) {
|
|
|
6647
6685
|
const shared = new Int32Array(new SharedArrayBuffer(4));
|
|
6648
6686
|
Atomics.wait(shared, 0, 0, ms);
|
|
6649
6687
|
}
|
|
6650
|
-
|
|
6651
|
-
|
|
6688
|
+
/**
|
|
6689
|
+
* Map a `FIRST_TREE_HUB_HOME` basename to the suffix appended to the
|
|
6690
|
+
* service manager's unit name / label.
|
|
6691
|
+
*
|
|
6692
|
+
* Why this exists: `FIRST_TREE_HUB_HOME` already isolates config /
|
|
6693
|
+
* credentials / workspace under a separate home dir, but until now the
|
|
6694
|
+
* systemd unit name and launchd label were hard-coded — so a developer
|
|
6695
|
+
* running with an isolated home would still rewrite the same
|
|
6696
|
+
* `first-tree-hub-client.service` unit file as the prod install. This
|
|
6697
|
+
* derivation closes that loop: dev homes get their own unit name and
|
|
6698
|
+
* coexist with prod.
|
|
6699
|
+
*
|
|
6700
|
+
* Rule:
|
|
6701
|
+
* - "hub" → "" (default home; preserves the existing prod
|
|
6702
|
+
* unit name `first-tree-hub-client.service` for
|
|
6703
|
+
* every machine already in the field)
|
|
6704
|
+
* - "hub-<x>" → "<x>" ("hub-test" → "test", giving
|
|
6705
|
+
* `first-tree-hub-client-test.service`)
|
|
6706
|
+
* - anything else → the basename verbatim (a custom home like
|
|
6707
|
+
* "~/.first-tree/foo" yields suffix "foo")
|
|
6708
|
+
*
|
|
6709
|
+
* Empty / falsy basenames defensively fall back to the default — we
|
|
6710
|
+
* never want to silently drop a user's intent into prod's unit name.
|
|
6711
|
+
*/
|
|
6712
|
+
function deriveServiceSuffix(homeBasename) {
|
|
6713
|
+
if (!homeBasename) return "";
|
|
6714
|
+
if (homeBasename === "hub") return "";
|
|
6715
|
+
if (homeBasename.startsWith("hub-")) return homeBasename.slice(4) || homeBasename;
|
|
6716
|
+
return homeBasename;
|
|
6717
|
+
}
|
|
6718
|
+
const SERVICE_SUFFIX = deriveServiceSuffix(basename(DEFAULT_HOME_DIR$1));
|
|
6719
|
+
const LAUNCHD_LABEL = SERVICE_SUFFIX ? `dev.first-tree-hub.client.${SERVICE_SUFFIX}` : "dev.first-tree-hub.client";
|
|
6720
|
+
const SYSTEMD_UNIT = SERVICE_SUFFIX ? `first-tree-hub-client-${SERVICE_SUFFIX}.service` : "first-tree-hub-client.service";
|
|
6721
|
+
const SYSLOG_IDENT = SERVICE_SUFFIX ? `first-tree-hub-client-${SERVICE_SUFFIX}` : "first-tree-hub-client";
|
|
6652
6722
|
const LOG_DIR = join(DEFAULT_HOME_DIR$1, "logs");
|
|
6653
6723
|
function whichBin(name) {
|
|
6654
6724
|
try {
|
|
@@ -6663,23 +6733,36 @@ function whichBin(name) {
|
|
|
6663
6733
|
/**
|
|
6664
6734
|
* Resolve how the service should launch the CLI.
|
|
6665
6735
|
*
|
|
6666
|
-
*
|
|
6667
|
-
*
|
|
6668
|
-
*
|
|
6669
|
-
*
|
|
6736
|
+
* Two regimes:
|
|
6737
|
+
*
|
|
6738
|
+
* ① Prod (default home, empty service suffix) — prefer the installed
|
|
6739
|
+
* `first-tree-hub` bin on PATH (usually a shim under /usr/local/bin
|
|
6740
|
+
* or ~/.npm-global/bin). Using the shim means an `npm i -g … @latest`
|
|
6741
|
+
* atomically swaps the binary the unit launches, no unit rewrite
|
|
6742
|
+
* needed.
|
|
6743
|
+
*
|
|
6744
|
+
* ② Dev / isolated (non-empty suffix from a custom FIRST_TREE_HUB_HOME)
|
|
6745
|
+
* — pin to the running interpreter + script path. This skips the
|
|
6746
|
+
* PATH lookup, which would otherwise resolve `first-tree-hub` to
|
|
6747
|
+
* the operator's prod global install — making the dev unit silently
|
|
6748
|
+
* run prod code against a dev home (i.e., the whole isolation story
|
|
6749
|
+
* collapses with no error message). Pinning execPath+argv[1] forces
|
|
6750
|
+
* the dev unit to launch the dev build that just installed it.
|
|
6670
6751
|
*/
|
|
6671
|
-
function resolveCliInvocation() {
|
|
6672
|
-
|
|
6673
|
-
|
|
6674
|
-
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
|
|
6680
|
-
|
|
6681
|
-
|
|
6682
|
-
|
|
6752
|
+
function resolveCliInvocation(serviceSuffix = SERVICE_SUFFIX) {
|
|
6753
|
+
if (serviceSuffix === "") {
|
|
6754
|
+
const bin = whichBin("first-tree-hub");
|
|
6755
|
+
if (bin && isAbsolute(bin)) try {
|
|
6756
|
+
return {
|
|
6757
|
+
kind: "bin",
|
|
6758
|
+
program: realpathSync(bin)
|
|
6759
|
+
};
|
|
6760
|
+
} catch {
|
|
6761
|
+
return {
|
|
6762
|
+
kind: "bin",
|
|
6763
|
+
program: bin
|
|
6764
|
+
};
|
|
6765
|
+
}
|
|
6683
6766
|
}
|
|
6684
6767
|
const script = process.argv[1];
|
|
6685
6768
|
if (!script) throw new Error("Cannot resolve CLI entry point (process.argv[1] is empty).");
|
|
@@ -6729,7 +6812,7 @@ ${argsXml}
|
|
|
6729
6812
|
<key>PATH</key>
|
|
6730
6813
|
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
|
|
6731
6814
|
<key>FIRST_TREE_HUB_SERVICE_MODE</key>
|
|
6732
|
-
<string>1</string>
|
|
6815
|
+
<string>1</string>${SERVICE_SUFFIX ? `\n <key>FIRST_TREE_HUB_HOME</key>\n <string>${escapeXml(DEFAULT_HOME_DIR$1)}</string>` : ""}
|
|
6733
6816
|
</dict>
|
|
6734
6817
|
<key>RunAtLoad</key>
|
|
6735
6818
|
<true/>
|
|
@@ -6773,9 +6856,12 @@ function launchdState() {
|
|
|
6773
6856
|
const stateLine = out.split(/\r?\n/).find((l) => l.trim().startsWith("state ="));
|
|
6774
6857
|
const pidLine = out.split(/\r?\n/).find((l) => l.trim().startsWith("pid ="));
|
|
6775
6858
|
if (stateLine?.includes("running")) {
|
|
6776
|
-
const
|
|
6859
|
+
const pidStr = pidLine?.split("=")[1]?.trim();
|
|
6860
|
+
const pidNum = pidStr ? Number(pidStr) : NaN;
|
|
6861
|
+
const pid = Number.isFinite(pidNum) && pidNum > 0 ? pidNum : void 0;
|
|
6777
6862
|
return {
|
|
6778
6863
|
state: "active",
|
|
6864
|
+
pid,
|
|
6779
6865
|
detail: pid ? `pid ${pid}` : "running"
|
|
6780
6866
|
};
|
|
6781
6867
|
}
|
|
@@ -6818,7 +6904,7 @@ function installLaunchd() {
|
|
|
6818
6904
|
if (!bootoutRes.ok) {
|
|
6819
6905
|
if (!/not find|no such|not loaded/i.test(bootoutRes.stderr)) print.line(` warning: launchctl bootout: ${bootoutRes.stderr || `exit ${bootoutRes.code ?? "unknown"}`}\n`);
|
|
6820
6906
|
}
|
|
6821
|
-
waitForLabelEvicted(target, LAUNCHD_LABEL, 1e4);
|
|
6907
|
+
if (!waitForLabelEvicted(target, LAUNCHD_LABEL, 1e4)) print.line(" warning: launchctl bootout still settling after 10s; bootstrap may need a retry\n");
|
|
6822
6908
|
let lastBootstrapErr = null;
|
|
6823
6909
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
6824
6910
|
const res = runCapture("launchctl", [
|
|
@@ -6836,13 +6922,14 @@ function installLaunchd() {
|
|
|
6836
6922
|
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 client connect <server-url>\`.`);
|
|
6837
6923
|
const enableRes = runCapture("launchctl", ["enable", `${target}/${LAUNCHD_LABEL}`], 5e3);
|
|
6838
6924
|
if (!enableRes.ok) print.line(` warning: launchctl enable: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n`);
|
|
6839
|
-
const { state, detail } = launchdState();
|
|
6925
|
+
const { state, pid, detail } = launchdState();
|
|
6840
6926
|
return {
|
|
6841
6927
|
platform: "launchd",
|
|
6842
6928
|
label: LAUNCHD_LABEL,
|
|
6843
6929
|
unitPath: plistPath,
|
|
6844
6930
|
logDir: LOG_DIR,
|
|
6845
6931
|
state,
|
|
6932
|
+
pid,
|
|
6846
6933
|
detail
|
|
6847
6934
|
};
|
|
6848
6935
|
}
|
|
@@ -6865,20 +6952,25 @@ function systemdUnitPath() {
|
|
|
6865
6952
|
function renderSystemdUnit(invocation) {
|
|
6866
6953
|
return `[Unit]
|
|
6867
6954
|
Description=First Tree Hub Client
|
|
6868
|
-
|
|
6869
|
-
|
|
6955
|
+
StartLimitIntervalSec=300
|
|
6956
|
+
StartLimitBurst=10
|
|
6870
6957
|
|
|
6871
6958
|
[Service]
|
|
6872
6959
|
Type=simple
|
|
6873
6960
|
ExecStart=${invocation.kind === "bin" ? `${shellQuote(invocation.program)} client start --no-interactive` : `${shellQuote(invocation.program)} ${invocation.args.map(shellQuote).join(" ")} client start --no-interactive`}
|
|
6874
|
-
Restart=
|
|
6961
|
+
Restart=on-failure
|
|
6875
6962
|
RestartSec=10
|
|
6876
|
-
|
|
6877
|
-
|
|
6963
|
+
SuccessExitStatus=0
|
|
6964
|
+
RestartForceExitStatus=75
|
|
6965
|
+
KillSignal=SIGTERM
|
|
6966
|
+
KillMode=mixed
|
|
6967
|
+
TimeoutStopSec=30
|
|
6968
|
+
StandardOutput=journal
|
|
6969
|
+
StandardError=journal
|
|
6970
|
+
SyslogIdentifier=${SYSLOG_IDENT}
|
|
6878
6971
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
|
6879
6972
|
Environment=FIRST_TREE_HUB_SERVICE_MODE=1
|
|
6880
|
-
|
|
6881
|
-
[Install]
|
|
6973
|
+
${SERVICE_SUFFIX ? `Environment=FIRST_TREE_HUB_HOME=${shellQuote(DEFAULT_HOME_DIR$1)}\n` : ""}[Install]
|
|
6882
6974
|
WantedBy=default.target
|
|
6883
6975
|
`;
|
|
6884
6976
|
}
|
|
@@ -6902,15 +6994,75 @@ function systemdState() {
|
|
|
6902
6994
|
]
|
|
6903
6995
|
});
|
|
6904
6996
|
const out = (res.stdout ?? "").trim();
|
|
6905
|
-
if (res.status === 0 && out === "active")
|
|
6906
|
-
|
|
6907
|
-
|
|
6908
|
-
|
|
6997
|
+
if (res.status === 0 && out === "active") {
|
|
6998
|
+
const pid = readSystemdMainPid();
|
|
6999
|
+
return {
|
|
7000
|
+
state: "active",
|
|
7001
|
+
pid,
|
|
7002
|
+
detail: pid ? `pid ${pid}` : "running"
|
|
7003
|
+
};
|
|
7004
|
+
}
|
|
6909
7005
|
return {
|
|
6910
7006
|
state: "inactive",
|
|
6911
7007
|
detail: out || "unit present but not active"
|
|
6912
7008
|
};
|
|
6913
7009
|
}
|
|
7010
|
+
function readSystemdMainPid() {
|
|
7011
|
+
const res = runCaptureOut("systemctl", [
|
|
7012
|
+
"--user",
|
|
7013
|
+
"show",
|
|
7014
|
+
SYSTEMD_UNIT,
|
|
7015
|
+
"-p",
|
|
7016
|
+
"MainPID",
|
|
7017
|
+
"--value"
|
|
7018
|
+
], 5e3);
|
|
7019
|
+
if (!res.ok) return void 0;
|
|
7020
|
+
const n = Number(res.stdout);
|
|
7021
|
+
return Number.isFinite(n) && n > 0 ? n : void 0;
|
|
7022
|
+
}
|
|
7023
|
+
/**
|
|
7024
|
+
* Best-effort `loginctl enable-linger` for the current user.
|
|
7025
|
+
*
|
|
7026
|
+
* Why this matters: a `--user` systemd service is tied to the user's session.
|
|
7027
|
+
* Without linger, when the user logs out (closes their last SSH session,
|
|
7028
|
+
* graphical session ends, etc.) the user's systemd manager exits and stops
|
|
7029
|
+
* every service it owns — including ours. The next login restarts everything,
|
|
7030
|
+
* which is silently wrong: agents go offline for hours and the operator has
|
|
7031
|
+
* no obvious cause.
|
|
7032
|
+
*
|
|
7033
|
+
* `enable-linger <self>` is allowed without sudo on systemd ≥ 240 thanks to
|
|
7034
|
+
* polkit's `org.freedesktop.login1.set-self-linger` rule. On older distros
|
|
7035
|
+
* or hardened setups it requires polkit auth — we don't try to escalate;
|
|
7036
|
+
* the warning printed by the caller is the operator's signal to run it
|
|
7037
|
+
* manually.
|
|
7038
|
+
*/
|
|
7039
|
+
function tryEnableLinger() {
|
|
7040
|
+
const username = userInfo().username;
|
|
7041
|
+
if (!username) return {
|
|
7042
|
+
ok: false,
|
|
7043
|
+
reason: "could not determine username"
|
|
7044
|
+
};
|
|
7045
|
+
const showRes = runCaptureOut("loginctl", [
|
|
7046
|
+
"show-user",
|
|
7047
|
+
username,
|
|
7048
|
+
"-p",
|
|
7049
|
+
"Linger",
|
|
7050
|
+
"--value"
|
|
7051
|
+
], 5e3);
|
|
7052
|
+
if (showRes.ok && showRes.stdout === "yes") return {
|
|
7053
|
+
ok: true,
|
|
7054
|
+
alreadyOn: true
|
|
7055
|
+
};
|
|
7056
|
+
const res = runCapture("loginctl", ["enable-linger", username], 5e3);
|
|
7057
|
+
if (res.ok) return {
|
|
7058
|
+
ok: true,
|
|
7059
|
+
alreadyOn: false
|
|
7060
|
+
};
|
|
7061
|
+
return {
|
|
7062
|
+
ok: false,
|
|
7063
|
+
reason: res.stderr || `exit ${res.code ?? "unknown"}`
|
|
7064
|
+
};
|
|
7065
|
+
}
|
|
6914
7066
|
function installSystemd() {
|
|
6915
7067
|
const invocation = resolveCliInvocation();
|
|
6916
7068
|
ensureLogDir();
|
|
@@ -6919,6 +7071,8 @@ function installSystemd() {
|
|
|
6919
7071
|
writeFileSync(unitPath, renderSystemdUnit(invocation), { mode: 420 });
|
|
6920
7072
|
const reloadRes = runCapture("systemctl", ["--user", "daemon-reload"], 5e3);
|
|
6921
7073
|
if (!reloadRes.ok) throw new Error(`systemctl --user daemon-reload failed: ${reloadRes.stderr || `exit ${reloadRes.code ?? "unknown"}`}`);
|
|
7074
|
+
const lingerRes = tryEnableLinger();
|
|
7075
|
+
if (!lingerRes.ok) print.line(` warning: loginctl enable-linger failed: ${lingerRes.reason}\n The service will stop when you log out. Run manually: sudo loginctl enable-linger ${userInfo().username}\n`);
|
|
6922
7076
|
const enableRes = runCapture("systemctl", [
|
|
6923
7077
|
"--user",
|
|
6924
7078
|
"enable",
|
|
@@ -6926,13 +7080,14 @@ function installSystemd() {
|
|
|
6926
7080
|
SYSTEMD_UNIT
|
|
6927
7081
|
], 1e4);
|
|
6928
7082
|
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 client connect <server-url>\`.`);
|
|
6929
|
-
const { state, detail } = systemdState();
|
|
7083
|
+
const { state, pid, detail } = systemdState();
|
|
6930
7084
|
return {
|
|
6931
7085
|
platform: "systemd",
|
|
6932
7086
|
label: SYSTEMD_UNIT,
|
|
6933
7087
|
unitPath,
|
|
6934
7088
|
logDir: LOG_DIR,
|
|
6935
7089
|
state,
|
|
7090
|
+
pid,
|
|
6936
7091
|
detail
|
|
6937
7092
|
};
|
|
6938
7093
|
}
|
|
@@ -6973,24 +7128,26 @@ function installClientService() {
|
|
|
6973
7128
|
/** Report the current service state without modifying anything. */
|
|
6974
7129
|
function getClientServiceStatus() {
|
|
6975
7130
|
if (process.platform === "darwin") {
|
|
6976
|
-
const { state, detail } = launchdState();
|
|
7131
|
+
const { state, pid, detail } = launchdState();
|
|
6977
7132
|
return {
|
|
6978
7133
|
platform: "launchd",
|
|
6979
7134
|
label: LAUNCHD_LABEL,
|
|
6980
7135
|
unitPath: launchdPlistPath(),
|
|
6981
7136
|
logDir: LOG_DIR,
|
|
6982
7137
|
state,
|
|
7138
|
+
pid,
|
|
6983
7139
|
detail
|
|
6984
7140
|
};
|
|
6985
7141
|
}
|
|
6986
7142
|
if (process.platform === "linux") {
|
|
6987
|
-
const { state, detail } = systemdState();
|
|
7143
|
+
const { state, pid, detail } = systemdState();
|
|
6988
7144
|
return {
|
|
6989
7145
|
platform: "systemd",
|
|
6990
7146
|
label: SYSTEMD_UNIT,
|
|
6991
7147
|
unitPath: systemdUnitPath(),
|
|
6992
7148
|
logDir: LOG_DIR,
|
|
6993
7149
|
state,
|
|
7150
|
+
pid,
|
|
6994
7151
|
detail
|
|
6995
7152
|
};
|
|
6996
7153
|
}
|
|
@@ -7003,6 +7160,137 @@ function getClientServiceStatus() {
|
|
|
7003
7160
|
detail: `platform ${process.platform} not supported`
|
|
7004
7161
|
};
|
|
7005
7162
|
}
|
|
7163
|
+
/** Start the service. No-op + ok if already running. */
|
|
7164
|
+
function startClientService() {
|
|
7165
|
+
if (process.platform === "linux") {
|
|
7166
|
+
const res = runCapture("systemctl", [
|
|
7167
|
+
"--user",
|
|
7168
|
+
"start",
|
|
7169
|
+
SYSTEMD_UNIT
|
|
7170
|
+
], 15e3);
|
|
7171
|
+
if (!res.ok) return {
|
|
7172
|
+
ok: false,
|
|
7173
|
+
reason: res.stderr || `exit ${res.code ?? "unknown"}`
|
|
7174
|
+
};
|
|
7175
|
+
return { ok: true };
|
|
7176
|
+
}
|
|
7177
|
+
if (process.platform === "darwin") {
|
|
7178
|
+
const target = launchctlDomainTarget();
|
|
7179
|
+
const plistPath = launchdPlistPath();
|
|
7180
|
+
if (!existsSync(plistPath)) return {
|
|
7181
|
+
ok: false,
|
|
7182
|
+
reason: "service not installed"
|
|
7183
|
+
};
|
|
7184
|
+
if (runCaptureOut("launchctl", ["print", `${target}/${LAUNCHD_LABEL}`], 5e3).ok) {
|
|
7185
|
+
const res = runCapture("launchctl", ["kickstart", `${target}/${LAUNCHD_LABEL}`], 1e4);
|
|
7186
|
+
if (!res.ok) return {
|
|
7187
|
+
ok: false,
|
|
7188
|
+
reason: res.stderr || `exit ${res.code ?? "unknown"}`
|
|
7189
|
+
};
|
|
7190
|
+
return { ok: true };
|
|
7191
|
+
}
|
|
7192
|
+
const res = runCapture("launchctl", [
|
|
7193
|
+
"bootstrap",
|
|
7194
|
+
target,
|
|
7195
|
+
plistPath
|
|
7196
|
+
], 1e4);
|
|
7197
|
+
if (!res.ok) return {
|
|
7198
|
+
ok: false,
|
|
7199
|
+
reason: res.stderr || `exit ${res.code ?? "unknown"}`
|
|
7200
|
+
};
|
|
7201
|
+
return { ok: true };
|
|
7202
|
+
}
|
|
7203
|
+
return {
|
|
7204
|
+
ok: false,
|
|
7205
|
+
reason: `service control not supported on ${process.platform}`
|
|
7206
|
+
};
|
|
7207
|
+
}
|
|
7208
|
+
/**
|
|
7209
|
+
* Stop the service without disabling auto-start on next boot/login.
|
|
7210
|
+
*
|
|
7211
|
+
* systemd: `systemctl --user stop` — unit stays enabled, so a reboot or
|
|
7212
|
+
* `client start` brings it back. Combined with `Restart=on-failure +
|
|
7213
|
+
* SuccessExitStatus=0` in the unit, the SIGTERM path actually terminates
|
|
7214
|
+
* (the bug `Restart=always` had: stop would be immediately undone).
|
|
7215
|
+
*
|
|
7216
|
+
* launchd: `launchctl bootout` — unloads the running registration but
|
|
7217
|
+
* leaves the plist in `~/Library/LaunchAgents/`, so the next user login
|
|
7218
|
+
* (or `client start`) reloads it.
|
|
7219
|
+
*/
|
|
7220
|
+
function stopClientService() {
|
|
7221
|
+
if (process.platform === "linux") {
|
|
7222
|
+
const res = runCapture("systemctl", [
|
|
7223
|
+
"--user",
|
|
7224
|
+
"stop",
|
|
7225
|
+
SYSTEMD_UNIT
|
|
7226
|
+
], 35e3);
|
|
7227
|
+
if (!res.ok) return {
|
|
7228
|
+
ok: false,
|
|
7229
|
+
reason: res.stderr || `exit ${res.code ?? "unknown"}`
|
|
7230
|
+
};
|
|
7231
|
+
return { ok: true };
|
|
7232
|
+
}
|
|
7233
|
+
if (process.platform === "darwin") {
|
|
7234
|
+
const res = runCapture("launchctl", ["bootout", `${launchctlDomainTarget()}/${LAUNCHD_LABEL}`], 3e4);
|
|
7235
|
+
if (!res.ok) {
|
|
7236
|
+
if (/not find|no such|not loaded/i.test(res.stderr)) return {
|
|
7237
|
+
ok: true,
|
|
7238
|
+
detail: "not running"
|
|
7239
|
+
};
|
|
7240
|
+
return {
|
|
7241
|
+
ok: false,
|
|
7242
|
+
reason: res.stderr || `exit ${res.code ?? "unknown"}`
|
|
7243
|
+
};
|
|
7244
|
+
}
|
|
7245
|
+
return { ok: true };
|
|
7246
|
+
}
|
|
7247
|
+
return {
|
|
7248
|
+
ok: false,
|
|
7249
|
+
reason: `service control not supported on ${process.platform}`
|
|
7250
|
+
};
|
|
7251
|
+
}
|
|
7252
|
+
/** Restart the service. Equivalent to stop + start, but uses the manager's atomic primitive. */
|
|
7253
|
+
function restartClientService() {
|
|
7254
|
+
if (process.platform === "linux") {
|
|
7255
|
+
const res = runCapture("systemctl", [
|
|
7256
|
+
"--user",
|
|
7257
|
+
"restart",
|
|
7258
|
+
SYSTEMD_UNIT
|
|
7259
|
+
], 45e3);
|
|
7260
|
+
if (!res.ok) return {
|
|
7261
|
+
ok: false,
|
|
7262
|
+
reason: res.stderr || `exit ${res.code ?? "unknown"}`
|
|
7263
|
+
};
|
|
7264
|
+
return { ok: true };
|
|
7265
|
+
}
|
|
7266
|
+
if (process.platform === "darwin") {
|
|
7267
|
+
const target = launchctlDomainTarget();
|
|
7268
|
+
const plistPath = launchdPlistPath();
|
|
7269
|
+
if (!existsSync(plistPath)) return {
|
|
7270
|
+
ok: false,
|
|
7271
|
+
reason: "service not installed"
|
|
7272
|
+
};
|
|
7273
|
+
if (runCapture("launchctl", [
|
|
7274
|
+
"kickstart",
|
|
7275
|
+
"-k",
|
|
7276
|
+
`${target}/${LAUNCHD_LABEL}`
|
|
7277
|
+
], 3e4).ok) return { ok: true };
|
|
7278
|
+
const bootstrapRes = runCapture("launchctl", [
|
|
7279
|
+
"bootstrap",
|
|
7280
|
+
target,
|
|
7281
|
+
plistPath
|
|
7282
|
+
], 1e4);
|
|
7283
|
+
if (!bootstrapRes.ok) return {
|
|
7284
|
+
ok: false,
|
|
7285
|
+
reason: bootstrapRes.stderr || `exit ${bootstrapRes.code ?? "unknown"}`
|
|
7286
|
+
};
|
|
7287
|
+
return { ok: true };
|
|
7288
|
+
}
|
|
7289
|
+
return {
|
|
7290
|
+
ok: false,
|
|
7291
|
+
reason: `service control not supported on ${process.platform}`
|
|
7292
|
+
};
|
|
7293
|
+
}
|
|
7006
7294
|
/** Uninstall the background service. No-op if not installed. */
|
|
7007
7295
|
function uninstallClientService() {
|
|
7008
7296
|
if (process.platform === "darwin") return uninstallLaunchd();
|
|
@@ -8756,7 +9044,7 @@ function createFeedbackHandler(config) {
|
|
|
8756
9044
|
return { handle };
|
|
8757
9045
|
}
|
|
8758
9046
|
//#endregion
|
|
8759
|
-
//#region ../server/dist/app-
|
|
9047
|
+
//#region ../server/dist/app-O9kCTpaF.mjs
|
|
8760
9048
|
var __defProp = Object.defineProperty;
|
|
8761
9049
|
var __exportAll = (all, no_symbols) => {
|
|
8762
9050
|
let target = {};
|
|
@@ -14195,8 +14483,40 @@ const editMessageSchema = z.object({
|
|
|
14195
14483
|
format: z.string().optional(),
|
|
14196
14484
|
content: z.unknown()
|
|
14197
14485
|
});
|
|
14486
|
+
/**
|
|
14487
|
+
* Per-agent rate limit on outbound message writes. Keyed by `agent.uuid`
|
|
14488
|
+
* (populated by `agentSelectorHook`, which runs as an onRequest hook before
|
|
14489
|
+
* the global limiter — registered with `hook: "preHandler"` — fires).
|
|
14490
|
+
*
|
|
14491
|
+
* Rationale: agent ↔ agent reply loops are the documented failure mode
|
|
14492
|
+
* (`mention_only` is the semantic guard; this is the hard ceiling).
|
|
14493
|
+
*
|
|
14494
|
+
* The IP fallback is **defensive scaffolding, not a real code path**. These
|
|
14495
|
+
* routes mount under `/agent` which forces `memberAuth + agentSelector`
|
|
14496
|
+
* onRequest hooks (see app.ts) — a missing `req.agent` would have already
|
|
14497
|
+
* 403'd before this preHandler runs. The fallback exists so that if a future
|
|
14498
|
+
* refactor reorders hooks (or detaches one of these routes from the agent
|
|
14499
|
+
* scope), the limiter degrades to per-IP keying with a logged warning rather
|
|
14500
|
+
* than silently keying everyone to the same `undefined` bucket.
|
|
14501
|
+
*/
|
|
14502
|
+
function agentMessageWriteRateLimit(max) {
|
|
14503
|
+
return { rateLimit: {
|
|
14504
|
+
max,
|
|
14505
|
+
timeWindow: "1 minute",
|
|
14506
|
+
keyGenerator: (req) => {
|
|
14507
|
+
const agentId = req.agent?.uuid;
|
|
14508
|
+
if (agentId) return `agent:${agentId}`;
|
|
14509
|
+
log$2.warn({
|
|
14510
|
+
ip: req.ip,
|
|
14511
|
+
route: req.routeOptions?.url ?? req.url
|
|
14512
|
+
}, "rate-limit keyGenerator fell back to IP — req.agent missing on a route under /agent (hook order regression?)");
|
|
14513
|
+
return `ip:${req.ip}`;
|
|
14514
|
+
}
|
|
14515
|
+
} };
|
|
14516
|
+
}
|
|
14198
14517
|
async function agentMessageRoutes(app) {
|
|
14199
|
-
app.
|
|
14518
|
+
const writeRateLimit = agentMessageWriteRateLimit(app.config.rateLimit?.agentMessageMax ?? 30);
|
|
14519
|
+
app.post("/:chatId/messages", { config: writeRateLimit }, async (request, reply) => {
|
|
14200
14520
|
const identity = requireAgent(request);
|
|
14201
14521
|
await assertParticipant(app.db, request.params.chatId, identity.uuid);
|
|
14202
14522
|
const body = sendMessageSchema.parse(request.body);
|
|
@@ -14240,7 +14560,8 @@ async function agentMessageRoutes(app) {
|
|
|
14240
14560
|
});
|
|
14241
14561
|
}
|
|
14242
14562
|
async function agentSendToAgentRoutes(app) {
|
|
14243
|
-
app.
|
|
14563
|
+
const writeRateLimit = agentMessageWriteRateLimit(app.config.rateLimit?.agentMessageMax ?? 30);
|
|
14564
|
+
app.post("/:name/messages", { config: writeRateLimit }, async (request, reply) => {
|
|
14244
14565
|
const identity = requireAgent(request);
|
|
14245
14566
|
const body = sendToAgentSchema.parse(request.body);
|
|
14246
14567
|
const { message: msg, recipients } = await sendToAgent(app.db, identity.uuid, request.params.name, body);
|
|
@@ -18015,7 +18336,11 @@ async function buildApp(config) {
|
|
|
18015
18336
|
format: config.observability.logging.format,
|
|
18016
18337
|
bridgeToSpanLevel: config.observability.logging.bridgeToSpanLevel
|
|
18017
18338
|
});
|
|
18018
|
-
const app = Fastify({
|
|
18339
|
+
const app = Fastify({
|
|
18340
|
+
loggerInstance: rootLogger$1,
|
|
18341
|
+
trustProxy: config.trustProxy
|
|
18342
|
+
});
|
|
18343
|
+
if (config.trustProxy) app.log.warn("trustProxy=true — Fastify trusts ANY upstream's x-forwarded-for. Ensure Cloudflare / CapRover is the only ingress; do NOT expose this container's port to the public internet directly.");
|
|
18019
18344
|
const otelPlugin = getFastifyOtelPlugin();
|
|
18020
18345
|
if (otelPlugin) await app.register(otelPlugin);
|
|
18021
18346
|
await app.register(observabilityPlugin);
|
|
@@ -18027,7 +18352,7 @@ async function buildApp(config) {
|
|
|
18027
18352
|
app.log.info({ commandVersion }, "Hub server advertising command version");
|
|
18028
18353
|
const listenClient = postgres(config.database.url, { max: 1 });
|
|
18029
18354
|
const notifier = createNotifier(listenClient);
|
|
18030
|
-
await app.register(websocket);
|
|
18355
|
+
await app.register(websocket, { options: { maxPayload: config.ws?.maxPayload ?? 65536 } });
|
|
18031
18356
|
const corsOrigin = config.cors?.origin;
|
|
18032
18357
|
const isDev = process.env.NODE_ENV !== "production";
|
|
18033
18358
|
await app.register(cors, {
|
|
@@ -18036,7 +18361,8 @@ async function buildApp(config) {
|
|
|
18036
18361
|
});
|
|
18037
18362
|
await app.register(rateLimit, {
|
|
18038
18363
|
max: config.rateLimit?.max ?? 100,
|
|
18039
|
-
timeWindow: "1 minute"
|
|
18364
|
+
timeWindow: "1 minute",
|
|
18365
|
+
hook: "preHandler"
|
|
18040
18366
|
});
|
|
18041
18367
|
const memberAuth = memberAuthHook(db, config.secrets.jwtSecret);
|
|
18042
18368
|
const adminOnly = requireAdminRoleHook();
|
|
@@ -18053,6 +18379,10 @@ async function buildApp(config) {
|
|
|
18053
18379
|
details: error.issues,
|
|
18054
18380
|
...traceField
|
|
18055
18381
|
});
|
|
18382
|
+
if (error instanceof Error && "statusCode" in error && typeof error.statusCode === "number" && error.statusCode >= 400 && error.statusCode < 500) return reply.status(error.statusCode).send({
|
|
18383
|
+
error: error.message,
|
|
18384
|
+
...traceField
|
|
18385
|
+
});
|
|
18056
18386
|
request.log.error({ err: error }, "unhandled request error");
|
|
18057
18387
|
return reply.status(500).send({
|
|
18058
18388
|
error: "Internal server error",
|
|
@@ -18426,12 +18756,27 @@ function resolveNpmCommand() {
|
|
|
18426
18756
|
*/
|
|
18427
18757
|
function detectInstallMode(argv1 = process.argv[1] ?? "") {
|
|
18428
18758
|
if (!argv1) return "npx";
|
|
18429
|
-
let
|
|
18759
|
+
let resolvedArgv1;
|
|
18760
|
+
try {
|
|
18761
|
+
resolvedArgv1 = realpathSync(argv1);
|
|
18762
|
+
} catch {
|
|
18763
|
+
resolvedArgv1 = argv1;
|
|
18764
|
+
}
|
|
18765
|
+
const start = dirname(resolve(resolvedArgv1));
|
|
18766
|
+
{
|
|
18767
|
+
let dir = start;
|
|
18768
|
+
for (let i = 0; i < 10; i++) {
|
|
18769
|
+
if (existsSync(resolve(dir, ".git"))) return "source";
|
|
18770
|
+
const parent = dirname(dir);
|
|
18771
|
+
if (parent === dir) break;
|
|
18772
|
+
dir = parent;
|
|
18773
|
+
}
|
|
18774
|
+
}
|
|
18775
|
+
let dir = start;
|
|
18430
18776
|
for (let i = 0; i < 10; i++) {
|
|
18431
|
-
if (existsSync(resolve(dir, ".git"))) return "source";
|
|
18432
18777
|
const pkgPath = resolve(dir, "package.json");
|
|
18433
18778
|
if (existsSync(pkgPath)) try {
|
|
18434
|
-
if (JSON.parse(readFileSync(pkgPath, "utf8")).name ===
|
|
18779
|
+
if (JSON.parse(readFileSync(pkgPath, "utf8")).name === "@agent-team-foundation/first-tree-hub") {
|
|
18435
18780
|
if (/\/(?:_npx|\.npm\/_npx)\//.test(dir)) return "npx";
|
|
18436
18781
|
return "global";
|
|
18437
18782
|
}
|
|
@@ -18505,6 +18850,42 @@ function parseInstalledVersion(stdout) {
|
|
|
18505
18850
|
function escapeForRegex(s) {
|
|
18506
18851
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
18507
18852
|
}
|
|
18853
|
+
/**
|
|
18854
|
+
* Look up the latest published version of the CLI package.
|
|
18855
|
+
*
|
|
18856
|
+
* Uses `npm view <pkg> version` (rather than fetch'ing registry.npmjs.org
|
|
18857
|
+
* directly) so the user's `.npmrc` registry, proxy, and auth settings are
|
|
18858
|
+
* honored — important for corporate users routed through Verdaccio /
|
|
18859
|
+
* Artifactory mirrors.
|
|
18860
|
+
*/
|
|
18861
|
+
function fetchLatestVersion(timeoutMs = 1e4) {
|
|
18862
|
+
const res = spawnSync(resolveNpmCommand(), [
|
|
18863
|
+
"view",
|
|
18864
|
+
PACKAGE_NAME,
|
|
18865
|
+
"version"
|
|
18866
|
+
], {
|
|
18867
|
+
encoding: "utf-8",
|
|
18868
|
+
timeout: timeoutMs,
|
|
18869
|
+
stdio: [
|
|
18870
|
+
"ignore",
|
|
18871
|
+
"pipe",
|
|
18872
|
+
"pipe"
|
|
18873
|
+
]
|
|
18874
|
+
});
|
|
18875
|
+
if (res.status !== 0) return {
|
|
18876
|
+
ok: false,
|
|
18877
|
+
reason: (res.stderr ?? "").trim() || `npm view exited with code ${res.status}`
|
|
18878
|
+
};
|
|
18879
|
+
const version = (res.stdout ?? "").trim();
|
|
18880
|
+
if (!semver.valid(version)) return {
|
|
18881
|
+
ok: false,
|
|
18882
|
+
reason: `npm view returned non-semver value: ${version.slice(0, 80)}`
|
|
18883
|
+
};
|
|
18884
|
+
return {
|
|
18885
|
+
ok: true,
|
|
18886
|
+
version
|
|
18887
|
+
};
|
|
18888
|
+
}
|
|
18508
18889
|
/** Interactive update prompt. Defaults to N on timeout. */
|
|
18509
18890
|
const promptUpdate = async ({ currentVersion, targetVersion, timeoutSeconds }) => {
|
|
18510
18891
|
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?`;
|
|
@@ -18754,4 +19135,4 @@ function registerSaaSConnectCommand(program) {
|
|
|
18754
19135
|
});
|
|
18755
19136
|
}
|
|
18756
19137
|
//#endregion
|
|
18757
|
-
export {
|
|
19138
|
+
export { findStaleAliases as $, checkDatabase as A, installClientService as B, runHomeMigration as C, checkAgentConfigs as D, runMigrations as E, checkServerReachable as F, stopClientService as G, resolveCliInvocation as H, checkWebSocket as I, isDockerAvailable as J, uninstallClientService as K, printResults as L, checkNodeVersion as M, checkServerConfig as N, checkBackgroundService as O, checkServerHealth as P, rotateClientIdWithBackup as Q, reconcileAgentConfigs as R, saveOnboardState as S, migrateLocalAgentDirs as T, restartClientService as U, isServiceSupported as V, startClientService as W, ClientRuntime as X, stopPostgres as Y, handleClientOrgMismatch as Z, promptMissingFields as _, probeCapabilities as _t, declineUpdate as a, fail as at, onboardCheck as b, detectInstallMode as c, print as ct, startServer as d, ClientOrgMismatchError as dt, formatStaleReason as et, COMMAND_VERSION as f, ClientUserMismatchError as ft, promptAddAgent as g, cleanWorkspaces as gt, isInteractive as h, SessionRegistry as ht, createExecuteUpdate as i, resolveReplyToFromEnv as it, checkDocker as j, checkClientConfig as k, fetchLatestVersion as l, setJsonMode as lt, uploadClientCapabilities as m, SdkError as mt, deriveHubUrlFromToken as n, createOwner as nt, promptUpdate as o, success as ot, reconcileLocalRuntimeProviders as p, FirstTreeHubSDK as pt, ensurePostgres as q, registerSaaSConnectCommand as r, hasUser as rt, PACKAGE_NAME as s, blank as st, HubUrlDerivationError as t, removeLocalAgent as tt, installGlobalLatest as u, status as ut, formatCheckReport as v, applyClientLoggerConfig as vt, createApiNameResolver as w, onboardCreate as x, loadOnboardState as y, configureClientLoggerForService as yt, getClientServiceStatus as z };
|