@agent-team-foundation/first-tree-hub 0.12.9 → 0.13.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.
- package/dist/{bootstrap-BCZC1ki6.mjs → bootstrap-Cya2OoHz.mjs} +7 -7
- package/dist/cli/index.mjs +268 -480
- package/dist/{client-OMwJMCQt-R1T06ZH6.mjs → client-BH4CmUL0-CybE3kuP.mjs} +92 -8
- package/dist/{client-CjGIGddS-BrpazWa3.mjs → client-h4KZ3b9o-CQyibXig.mjs} +3 -3
- package/dist/{dist-CnjqakXS.mjs → dist-C8yStx2L.mjs} +160 -36
- package/dist/drizzle/0041_notifications_dedup_key.sql +29 -0
- package/dist/drizzle/0042_notifications_drop_legacy_types.sql +36 -0
- package/dist/drizzle/0043_onboarding_completed_at.sql +32 -0
- package/dist/drizzle/meta/_journal.json +21 -0
- package/dist/{errors-CF5evtJt-B0NTIVPt.mjs → errors-LPcARA4K-Dbrptiyz.mjs} +2 -1
- package/dist/{feishu-DrnBbl8T.mjs → feishu-D_vnqC6a.mjs} +1 -1
- package/dist/index.mjs +7 -7
- package/dist/invitation-CNv7gfFF-D93KQte0.mjs +4 -0
- package/dist/{invitation-Bg0TRiyx-BsZH4GCS.mjs → invitation-DZO4NX3P-BPxTeHf-.mjs} +2 -2
- package/dist/{saas-connect-CXZhK485.mjs → saas-connect-Bb5LR4y6.mjs} +1499 -716
- package/dist/web/assets/{index-BPMrSv_A.js → index-CJcRUZ8l.js} +1 -1
- package/dist/web/assets/index-DL_9NFkt.js +421 -0
- package/dist/web/assets/index-DaWEZnjh.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/invitation-C299fxkP-KKslbta2.mjs +0 -4
- package/dist/web/assets/index-DxAYxUpz.css +0 -1
- package/dist/web/assets/index-ntmzuk5X.js +0 -421
package/dist/cli/index.mjs
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import "../observability-BAScT_5S-BcW9HgkG.mjs";
|
|
3
|
-
import { $ as formatStaleReason, A as checkDocker, B as isServiceSupported, C as createApiNameResolver, D as checkBackgroundService, E as checkAgentConfigs, F as checkWebSocket, H as restartClientService, I as printResults, J as stopPostgres, L as reconcileAgentConfigs, M as checkServerConfig, N as checkServerHealth, O as checkClientConfig, P as checkServerReachable, Q as findStaleAliases, R as getClientServiceStatus, S as runHomeMigration, T as runMigrations, U as startClientService, W as stopClientService, X as handleClientOrgMismatch, Y as ClientRuntime, _ as formatCheckReport, a as declineUpdate, at as fail, b as onboardCreate, c as detectInstallMode, ct as ClientUserMismatchError, d as startServer, dt as SessionRegistry, et as removeLocalAgent, f as reconcileLocalRuntimeProviders, ft as cleanWorkspaces, g as promptMissingFields, h as promptAddAgent, ht as configureClientLoggerForService, i as createExecuteUpdate, it as resolveSenderName, j as checkNodeVersion, k as checkDatabase, l as fetchLatestVersion, lt as FirstTreeHubSDK, m as isInteractive, mt as applyClientLoggerConfig, o as promptUpdate, ot as success, p as uploadClientCapabilities, pt as probeCapabilities, r as registerSaaSConnectCommand, rt as resolveReplyToFromEnv, s as PACKAGE_NAME, st as ClientOrgMismatchError, tt as createOwner, u as installGlobalLatest, ut as SdkError, v as loadOnboardState, w as migrateLocalAgentDirs, x as saveOnboardState, y as onboardCheck, z as installClientService } from "../saas-connect-
|
|
3
|
+
import { $ as formatStaleReason, A as checkDocker, B as isServiceSupported, C as createApiNameResolver, D as checkBackgroundService, E as checkAgentConfigs, F as checkWebSocket, H as restartClientService, I as printResults, J as stopPostgres, L as reconcileAgentConfigs, M as checkServerConfig, N as checkServerHealth, O as checkClientConfig, P as checkServerReachable, Q as findStaleAliases, R as getClientServiceStatus, S as runHomeMigration, T as runMigrations, U as startClientService, W as stopClientService, X as handleClientOrgMismatch, Y as ClientRuntime, _ as formatCheckReport, a as declineUpdate, at as fail, b as onboardCreate, c as detectInstallMode, ct as ClientUserMismatchError, d as startServer, dt as SessionRegistry, et as removeLocalAgent, f as reconcileLocalRuntimeProviders, ft as cleanWorkspaces, g as promptMissingFields, h as promptAddAgent, ht as configureClientLoggerForService, i as createExecuteUpdate, it as resolveSenderName, j as checkNodeVersion, k as checkDatabase, l as fetchLatestVersion, lt as FirstTreeHubSDK, m as isInteractive, mt as applyClientLoggerConfig, o as promptUpdate, ot as success, p as uploadClientCapabilities, pt as probeCapabilities, r as registerSaaSConnectCommand, rt as resolveReplyToFromEnv, s as PACKAGE_NAME, st as ClientOrgMismatchError, tt as createOwner, u as installGlobalLatest, ut as SdkError, v as loadOnboardState, w as migrateLocalAgentDirs, x as saveOnboardState, y as onboardCheck, z as installClientService } from "../saas-connect-Bb5LR4y6.mjs";
|
|
4
4
|
import "../logger-core-BTmvdflj-DjW8FM4T.mjs";
|
|
5
|
-
import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, _ as getConfigValue, a as ensureFreshAdminToken, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR,
|
|
5
|
+
import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, _ as getConfigValue, a as ensureFreshAdminToken, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR, v as initConfig, w as resolveConfigReadonly, x as readConfigFile, y as loadAgents } from "../bootstrap-Cya2OoHz.mjs";
|
|
6
6
|
import { a as print, n as CLI_USER_AGENT, o as setJsonMode, r as COMMAND_VERSION, t as cliFetch } from "../cli-fetch--tiwKm5S.mjs";
|
|
7
|
-
import "../dist-
|
|
8
|
-
import { n as bindFeishuUser, t as bindFeishuBot } from "../feishu-
|
|
9
|
-
import "../errors-
|
|
7
|
+
import "../dist-C8yStx2L.mjs";
|
|
8
|
+
import { n as bindFeishuUser, t as bindFeishuBot } from "../feishu-D_vnqC6a.mjs";
|
|
9
|
+
import "../errors-LPcARA4K-Dbrptiyz.mjs";
|
|
10
10
|
import "../src-DNBS5Yjj.mjs";
|
|
11
|
-
import "../client-
|
|
12
|
-
import "../invitation-
|
|
11
|
+
import "../client-BH4CmUL0-CybE3kuP.mjs";
|
|
12
|
+
import "../invitation-DZO4NX3P-BPxTeHf-.mjs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
15
15
|
import * as semver from "semver";
|
|
16
16
|
import { Command } from "commander";
|
|
17
|
-
import { confirm, input,
|
|
17
|
+
import { confirm, input, select } from "@inquirer/prompts";
|
|
18
18
|
//#region src/commands/agent-config.ts
|
|
19
19
|
async function resolveAgentRecord(serverUrl, adminToken, agentName) {
|
|
20
20
|
const res = await cliFetch(`${serverUrl}/api/v1/me/managed-agents`, {
|
|
@@ -78,7 +78,7 @@ function printConfig(cfg) {
|
|
|
78
78
|
}
|
|
79
79
|
function registerAgentConfigCommands(parent) {
|
|
80
80
|
const config = parent.command("config").description("Manage agent runtime configuration (Step 8)");
|
|
81
|
-
config.command("
|
|
81
|
+
config.command("show <agent>").description("Show the current runtime config for an agent").action(async (agentName) => {
|
|
82
82
|
const serverUrl = resolveServerUrl(process.env.FIRST_TREE_HUB_SERVER_URL);
|
|
83
83
|
const adminToken = await ensureFreshAdminToken();
|
|
84
84
|
const { uuid } = await resolveAgentRecord(serverUrl, adminToken, agentName);
|
|
@@ -209,7 +209,7 @@ const DEFAULT_WORKSPACE_TTL_MS = 10080 * 60 * 1e3;
|
|
|
209
209
|
* agents configured they must pick one with `--agent <name>` (next step of
|
|
210
210
|
* CLI polish) or rely on a single entry.
|
|
211
211
|
*/
|
|
212
|
-
function resolveLocalAgent(agentName) {
|
|
212
|
+
function resolveLocalAgent$1(agentName) {
|
|
213
213
|
const agentsDir = join(DEFAULT_CONFIG_DIR, "agents");
|
|
214
214
|
const agents = loadAgents({
|
|
215
215
|
schema: agentConfigSchema,
|
|
@@ -223,8 +223,8 @@ function resolveLocalAgent(agentName) {
|
|
|
223
223
|
let resolvedName;
|
|
224
224
|
if (resolution.kind === "ok") resolvedName = resolution.name;
|
|
225
225
|
else if (resolution.kind === "none") fail("MISSING_AGENT", "No agent configured. Run `first-tree-hub agent add` first.", 2);
|
|
226
|
-
else if (resolution.kind === "envMismatch") fail("ENV_AGENT_NOT_LOCAL", `FIRST_TREE_HUB_AGENT_ID="${resolution.envAgentId}" is not configured on this machine. Available local agents: ${resolution.available.join(", ")}. Pick one explicitly
|
|
227
|
-
else fail("AMBIGUOUS_AGENT", `Multiple agents are configured on this machine (${resolution.available.join(", ")}) and FIRST_TREE_HUB_AGENT_ID is not set, so the CLI can't tell which one is the sender. Specify it explicitly
|
|
226
|
+
else if (resolution.kind === "envMismatch") fail("ENV_AGENT_NOT_LOCAL", `FIRST_TREE_HUB_AGENT_ID="${resolution.envAgentId}" is not configured on this machine. Available local agents: ${resolution.available.join(", ")}. Pick one explicitly with \`--agent <senderName>\`.`, 2);
|
|
227
|
+
else fail("AMBIGUOUS_AGENT", `Multiple agents are configured on this machine (${resolution.available.join(", ")}) and FIRST_TREE_HUB_AGENT_ID is not set, so the CLI can't tell which one is the sender. Specify it explicitly with \`--agent <senderName>\`.`, 2);
|
|
228
228
|
const cfg = agents.get(resolvedName);
|
|
229
229
|
if (!cfg) fail("UNKNOWN_AGENT", `Agent "${resolvedName}" not found in ${agentsDir}`, 2);
|
|
230
230
|
let serverUrl;
|
|
@@ -238,8 +238,8 @@ function resolveLocalAgent(agentName) {
|
|
|
238
238
|
agentId: cfg.agentId
|
|
239
239
|
};
|
|
240
240
|
}
|
|
241
|
-
function createSdk(agentName) {
|
|
242
|
-
const { serverUrl, agentId } = resolveLocalAgent(agentName);
|
|
241
|
+
function createSdk$1(agentName) {
|
|
242
|
+
const { serverUrl, agentId } = resolveLocalAgent$1(agentName);
|
|
243
243
|
return new FirstTreeHubSDK({
|
|
244
244
|
serverUrl,
|
|
245
245
|
getAccessToken: (opts) => ensureFreshAccessToken(opts),
|
|
@@ -247,7 +247,7 @@ function createSdk(agentName) {
|
|
|
247
247
|
userAgent: CLI_USER_AGENT
|
|
248
248
|
});
|
|
249
249
|
}
|
|
250
|
-
function handleSdkError(error) {
|
|
250
|
+
function handleSdkError$1(error) {
|
|
251
251
|
if (error instanceof SdkError) {
|
|
252
252
|
const exitCode = error.statusCode === 401 ? 3 : 1;
|
|
253
253
|
fail(`HTTP_${error.statusCode}`, error.message, exitCode);
|
|
@@ -255,31 +255,12 @@ function handleSdkError(error) {
|
|
|
255
255
|
if (error instanceof TypeError && "cause" in error) fail("CONNECTION_ERROR", `Cannot connect to server: ${error.message}`, 6);
|
|
256
256
|
fail("UNKNOWN_ERROR", error instanceof Error ? error.message : String(error), 1);
|
|
257
257
|
}
|
|
258
|
-
function parseLimit(value, max) {
|
|
258
|
+
function parseLimit$1(value, max) {
|
|
259
259
|
const limit = Number.parseInt(value, 10);
|
|
260
260
|
if (Number.isNaN(limit) || limit < 1 || limit > max) fail("INVALID_LIMIT", `Limit must be between 1 and ${max}.`, 2);
|
|
261
261
|
return limit;
|
|
262
262
|
}
|
|
263
|
-
|
|
264
|
-
function readStdin() {
|
|
265
|
-
if (process.stdin.isTTY) return Promise.resolve(null);
|
|
266
|
-
return new Promise((resolve, reject) => {
|
|
267
|
-
const chunks = [];
|
|
268
|
-
let totalSize = 0;
|
|
269
|
-
process.stdin.on("data", (chunk) => {
|
|
270
|
-
totalSize += chunk.length;
|
|
271
|
-
if (totalSize > MAX_STDIN_BYTES) {
|
|
272
|
-
process.stdin.destroy();
|
|
273
|
-
reject(/* @__PURE__ */ new Error(`stdin exceeds ${MAX_STDIN_BYTES} bytes`));
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
chunks.push(chunk);
|
|
277
|
-
});
|
|
278
|
-
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
279
|
-
process.stdin.on("error", reject);
|
|
280
|
-
});
|
|
281
|
-
}
|
|
282
|
-
async function resolveAgent(serverUrl, adminToken, agentName) {
|
|
263
|
+
async function resolveAgent$1(serverUrl, adminToken, agentName) {
|
|
283
264
|
const res = await cliFetch(`${serverUrl}/api/v1/me/managed-agents`, {
|
|
284
265
|
headers: { Authorization: `Bearer ${adminToken}` },
|
|
285
266
|
signal: AbortSignal.timeout(1e4)
|
|
@@ -293,7 +274,7 @@ async function resolveAgent(serverUrl, adminToken, agentName) {
|
|
|
293
274
|
* Read the persisted `client.id` from `client.yaml`. Required by `agent
|
|
294
275
|
* prune` to filter the user-scoped `listMyAgents` response down to "what
|
|
295
276
|
* actually binds on THIS machine". `fail()` instead of throwing so the
|
|
296
|
-
* "no client.yaml — run
|
|
277
|
+
* "no client.yaml — run connect <token> first" path renders as a clean
|
|
297
278
|
* CLI error rather than a stack trace.
|
|
298
279
|
*/
|
|
299
280
|
function readClientId() {
|
|
@@ -430,7 +411,7 @@ function registerAgentCommands(program) {
|
|
|
430
411
|
fail("LIST_ERROR", error instanceof Error ? error.message : String(error));
|
|
431
412
|
}
|
|
432
413
|
});
|
|
433
|
-
agent.command("create <name>").description("Create an agent on Hub and bind it locally").requiredOption("--type <type>", "Agent type (human, personal_assistant, autonomous_agent)").requiredOption("--client-id <id>", "Client (machine) that will run this agent — must be owned by you. Run `first-tree-hub
|
|
414
|
+
agent.command("create <name>").description("Create an agent on Hub and bind it locally").requiredOption("--type <type>", "Agent type (human, personal_assistant, autonomous_agent)").requiredOption("--client-id <id>", "Client (machine) that will run this agent — must be owned by you. Run `first-tree-hub connect <token>` on that machine first.").option("--runtime <runtime>", "Runtime handler (default: claude-code)", "claude-code").option("--display-name <name>", "Display name").option("--org <id>", "Target organization id (required when you belong to multiple orgs)").option("--server <url>", "Hub server URL").action(async (name, options) => {
|
|
434
415
|
try {
|
|
435
416
|
const serverUrl = resolveServerUrl(options.server);
|
|
436
417
|
const adminToken = await ensureFreshAccessToken();
|
|
@@ -487,7 +468,7 @@ function registerAgentCommands(program) {
|
|
|
487
468
|
});
|
|
488
469
|
if (!meRes.ok) fail("ME_ERROR", `Failed to fetch current member (HTTP ${meRes.status})`, 1);
|
|
489
470
|
const me = await meRes.json();
|
|
490
|
-
const target = await resolveAgent(serverUrl, accessToken, agentName);
|
|
471
|
+
const target = await resolveAgent$1(serverUrl, accessToken, agentName);
|
|
491
472
|
const patchRes = await cliFetch(`${serverUrl}/api/v1/agents/${target.uuid}`, {
|
|
492
473
|
method: "PATCH",
|
|
493
474
|
headers: {
|
|
@@ -526,11 +507,11 @@ function registerAgentCommands(program) {
|
|
|
526
507
|
print.line(` ${totalRemoved} workspace(s) cleaned.\n`);
|
|
527
508
|
});
|
|
528
509
|
const bind = agent.command("bind").description("Bind an agent to a client machine or external IM account");
|
|
529
|
-
bind.command("client <agentName>").description("Bind an unbound agent to a client machine (first-time bind only — ID is immutable once set)").requiredOption("--client-id <id>", "Client (machine) ID — must be owned by you. Run `first-tree-hub
|
|
510
|
+
bind.command("client <agentName>").description("Bind an unbound agent to a client machine (first-time bind only — ID is immutable once set)").requiredOption("--client-id <id>", "Client (machine) ID — must be owned by you. Run `first-tree-hub connect <token>` on that machine first.").option("--server <url>", "Hub server URL").action(async (agentName, options) => {
|
|
530
511
|
try {
|
|
531
512
|
const serverUrl = resolveServerUrl(options.server);
|
|
532
513
|
const accessToken = await ensureFreshAccessToken();
|
|
533
|
-
const target = await resolveAgent(serverUrl, accessToken, agentName);
|
|
514
|
+
const target = await resolveAgent$1(serverUrl, accessToken, agentName);
|
|
534
515
|
const patchRes = await cliFetch(`${serverUrl}/api/v1/agents/${target.uuid}`, {
|
|
535
516
|
method: "PATCH",
|
|
536
517
|
headers: {
|
|
@@ -554,7 +535,7 @@ function registerAgentCommands(program) {
|
|
|
554
535
|
try {
|
|
555
536
|
if (options.platform !== "feishu") fail("UNSUPPORTED_PLATFORM", `Platform "${options.platform}" is not supported. Use "feishu".`);
|
|
556
537
|
const serverUrl = resolveServerUrl(options.server);
|
|
557
|
-
const { agentId } = resolveLocalAgent(options.agent);
|
|
538
|
+
const { agentId } = resolveLocalAgent$1(options.agent);
|
|
558
539
|
await bindFeishuBot(serverUrl, await ensureFreshAccessToken(), agentId, options.appId, options.appSecret);
|
|
559
540
|
print.line("Feishu bot bound successfully.\n");
|
|
560
541
|
success({
|
|
@@ -569,7 +550,7 @@ function registerAgentCommands(program) {
|
|
|
569
550
|
try {
|
|
570
551
|
if (options.platform !== "feishu") fail("UNSUPPORTED_PLATFORM", `Platform "${options.platform}" is not supported. Use "feishu".`);
|
|
571
552
|
const serverUrl = resolveServerUrl(options.server);
|
|
572
|
-
const { agentId } = resolveLocalAgent(options.agent);
|
|
553
|
+
const { agentId } = resolveLocalAgent$1(options.agent);
|
|
573
554
|
await bindFeishuUser(serverUrl, await ensureFreshAccessToken(), agentId, humanAgentId, options.feishuId);
|
|
574
555
|
print.line(`Feishu user ${options.feishuId} bound to ${humanAgentId}.\n`);
|
|
575
556
|
success({
|
|
@@ -581,62 +562,6 @@ function registerAgentCommands(program) {
|
|
|
581
562
|
fail("BIND_USER_ERROR", error instanceof Error ? error.message : String(error));
|
|
582
563
|
}
|
|
583
564
|
});
|
|
584
|
-
agent.command("send <target> [message]").description("Send a message to an agent or chat").option("-f, --format <format>", "Message format (text|markdown|card)", "text").option("--chat", "Treat target as chat ID instead of agent ID").option("-m, --metadata <json>", "JSON metadata to attach").option("--reply-to <messageId>", "Message ID to reply to").option("--reply-to-inbox <inboxId>", "Cross-chat reply target inbox").option("--reply-to-chat <chatId>", "Cross-chat reply target chat").option("--agent <name>", "Agent name on the Hub (default: first configured on this client)").action(async (target, message, options) => {
|
|
585
|
-
try {
|
|
586
|
-
const content = message ?? await readStdin();
|
|
587
|
-
if (!content) fail("NO_MESSAGE", "No message provided. Pass as argument or pipe via stdin.", 2);
|
|
588
|
-
let metadata;
|
|
589
|
-
if (options.metadata) try {
|
|
590
|
-
metadata = JSON.parse(options.metadata);
|
|
591
|
-
} catch {
|
|
592
|
-
fail("INVALID_METADATA", "Metadata must be valid JSON.", 2);
|
|
593
|
-
}
|
|
594
|
-
const { replyToInbox, replyToChat } = resolveReplyToFromEnv(process.env, {
|
|
595
|
-
replyToInbox: options.replyToInbox,
|
|
596
|
-
replyToChat: options.replyToChat
|
|
597
|
-
});
|
|
598
|
-
const sdk = createSdk(options.agent);
|
|
599
|
-
if (options.chat) success(await sdk.sendMessage(target, {
|
|
600
|
-
format: options.format,
|
|
601
|
-
content,
|
|
602
|
-
metadata,
|
|
603
|
-
inReplyTo: options.replyTo,
|
|
604
|
-
replyToInbox,
|
|
605
|
-
replyToChat
|
|
606
|
-
}));
|
|
607
|
-
else success(await sdk.sendToAgent(target, {
|
|
608
|
-
format: options.format,
|
|
609
|
-
content,
|
|
610
|
-
metadata,
|
|
611
|
-
replyToInbox,
|
|
612
|
-
replyToChat
|
|
613
|
-
}));
|
|
614
|
-
} catch (error) {
|
|
615
|
-
handleSdkError(error);
|
|
616
|
-
}
|
|
617
|
-
});
|
|
618
|
-
agent.command("chats").description("List chats this agent participates in").option("-l, --limit <number>", "Maximum chats to return (1-100)", "20").option("--cursor <cursor>", "Pagination cursor from previous response").option("--agent <name>", "Agent name on the Hub (default: first configured on this client)").action(async (options) => {
|
|
619
|
-
try {
|
|
620
|
-
const limit = parseLimit(options.limit, 100);
|
|
621
|
-
success(await createSdk(options.agent).listChats({
|
|
622
|
-
limit,
|
|
623
|
-
cursor: options.cursor
|
|
624
|
-
}));
|
|
625
|
-
} catch (error) {
|
|
626
|
-
handleSdkError(error);
|
|
627
|
-
}
|
|
628
|
-
});
|
|
629
|
-
agent.command("history <chatId>").description("View message history in a chat").option("-l, --limit <number>", "Maximum messages to return (1-100)", "20").option("--cursor <cursor>", "Pagination cursor from previous response").option("--agent <name>", "Agent name on the Hub (default: first configured on this client)").action(async (chatId, options) => {
|
|
630
|
-
try {
|
|
631
|
-
const limit = parseLimit(options.limit, 100);
|
|
632
|
-
success(await createSdk(options.agent).listMessages(chatId, {
|
|
633
|
-
limit,
|
|
634
|
-
cursor: options.cursor
|
|
635
|
-
}));
|
|
636
|
-
} catch (error) {
|
|
637
|
-
handleSdkError(error);
|
|
638
|
-
}
|
|
639
|
-
});
|
|
640
565
|
agent.command("status [name]").description("Show agent runtime status from Hub server").option("--server <url>", "Hub server URL").action(async (name, options) => {
|
|
641
566
|
try {
|
|
642
567
|
const serverUrl = resolveServerUrl(options?.server);
|
|
@@ -720,11 +645,12 @@ function registerAgentCommands(program) {
|
|
|
720
645
|
fail("RESET_ERROR", error instanceof Error ? error.message : String(error));
|
|
721
646
|
}
|
|
722
647
|
});
|
|
723
|
-
|
|
648
|
+
const sessionCmd = agent.command("session").description("Session lifecycle commands");
|
|
649
|
+
sessionCmd.command("list <agent-name>").description("List sessions for an agent").option("--server <url>", "Hub server URL").option("--state <state>", "Filter by session state (active/suspended/evicted)").action(async (agentName, options) => {
|
|
724
650
|
try {
|
|
725
651
|
const serverUrl = resolveServerUrl(options.server);
|
|
726
652
|
const adminToken = await ensureFreshAccessToken();
|
|
727
|
-
const agentId = (await resolveAgent(serverUrl, adminToken, agentName)).uuid;
|
|
653
|
+
const agentId = (await resolveAgent$1(serverUrl, adminToken, agentName)).uuid;
|
|
728
654
|
const response = await cliFetch(`${serverUrl}/api/v1/agents/${agentId}/sessions${options.state ? `?state=${options.state}` : ""}`, {
|
|
729
655
|
headers: { Authorization: `Bearer ${adminToken}` },
|
|
730
656
|
signal: AbortSignal.timeout(1e4)
|
|
@@ -748,12 +674,11 @@ function registerAgentCommands(program) {
|
|
|
748
674
|
fail("SESSIONS_ERROR", error instanceof Error ? error.message : String(error));
|
|
749
675
|
}
|
|
750
676
|
});
|
|
751
|
-
const sessionCmd = agent.command("session").description("Session lifecycle commands");
|
|
752
677
|
for (const [cmd, desc] of [["suspend", "Suspend a session"], ["terminate", "Terminate a session"]]) sessionCmd.command(`${cmd} <agent-name> <chat-id>`).description(desc).option("--server <url>", "Hub server URL").action(async (agentName, chatId, options) => {
|
|
753
678
|
try {
|
|
754
679
|
const serverUrl = resolveServerUrl(options.server);
|
|
755
680
|
const adminToken = await ensureFreshAccessToken();
|
|
756
|
-
const agentId = (await resolveAgent(serverUrl, adminToken, agentName)).uuid;
|
|
681
|
+
const agentId = (await resolveAgent$1(serverUrl, adminToken, agentName)).uuid;
|
|
757
682
|
const response = await cliFetch(`${serverUrl}/api/v1/agents/${agentId}/sessions/${chatId}/${cmd}`, {
|
|
758
683
|
method: "POST",
|
|
759
684
|
headers: { Authorization: `Bearer ${adminToken}` },
|
|
@@ -768,7 +693,167 @@ function registerAgentCommands(program) {
|
|
|
768
693
|
fail("SESSION_CMD_ERROR", error instanceof Error ? error.message : String(error));
|
|
769
694
|
}
|
|
770
695
|
});
|
|
771
|
-
agent.command("
|
|
696
|
+
const debugCmd = agent.command("debug", { hidden: true }).description("Low-level SDK debug commands");
|
|
697
|
+
debugCmd.command("register").description("Register this agent and return identity info").option("--agent <name>", "Agent name on the Hub (default: first configured on this client)").action(async (options) => {
|
|
698
|
+
try {
|
|
699
|
+
success(await createSdk$1(options.agent).register());
|
|
700
|
+
} catch (error) {
|
|
701
|
+
handleSdkError$1(error);
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
debugCmd.command("pull").description("Pull pending messages from inbox").option("-l, --limit <number>", "Maximum entries to return", "10").option("-a, --ack", "Automatically ACK entries after pulling").option("--agent <name>", "Agent name on the Hub (default: first configured on this client)").action(async (options) => {
|
|
705
|
+
try {
|
|
706
|
+
const sdk = createSdk$1(options.agent);
|
|
707
|
+
const limit = parseLimit$1(options.limit, 50);
|
|
708
|
+
const result = await sdk.pull(limit);
|
|
709
|
+
if (options.ack && result.entries.length > 0) await Promise.all(result.entries.map((entry) => sdk.ack(entry.id)));
|
|
710
|
+
success(result);
|
|
711
|
+
} catch (error) {
|
|
712
|
+
handleSdkError$1(error);
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
//#endregion
|
|
717
|
+
//#region src/commands/chat.ts
|
|
718
|
+
function resolveLocalAgent(agentName) {
|
|
719
|
+
const agentsDir = join(DEFAULT_CONFIG_DIR, "agents");
|
|
720
|
+
const agents = loadAgents({
|
|
721
|
+
schema: agentConfigSchema,
|
|
722
|
+
agentsDir
|
|
723
|
+
});
|
|
724
|
+
const resolution = resolveSenderName({
|
|
725
|
+
override: agentName,
|
|
726
|
+
envAgentId: process.env.FIRST_TREE_HUB_AGENT_ID,
|
|
727
|
+
agents
|
|
728
|
+
});
|
|
729
|
+
let resolvedName;
|
|
730
|
+
if (resolution.kind === "ok") resolvedName = resolution.name;
|
|
731
|
+
else if (resolution.kind === "none") fail("MISSING_AGENT", "No agent configured. Run `first-tree-hub agent add` first.", 2);
|
|
732
|
+
else if (resolution.kind === "envMismatch") fail("ENV_AGENT_NOT_LOCAL", `FIRST_TREE_HUB_AGENT_ID="${resolution.envAgentId}" is not configured on this machine. Available local agents: ${resolution.available.join(", ")}. Pick one explicitly: \`first-tree-hub chat send --agent <senderName> <recipientName> "..."\`.`, 2);
|
|
733
|
+
else fail("AMBIGUOUS_AGENT", `Multiple agents are configured on this machine (${resolution.available.join(", ")}) and FIRST_TREE_HUB_AGENT_ID is not set, so the CLI can't tell which one is the sender. Specify it explicitly: \`first-tree-hub chat send --agent <senderName> <recipientName> "..."\` (--agent picks the SENDER; the recipient is the next positional argument).`, 2);
|
|
734
|
+
const cfg = agents.get(resolvedName);
|
|
735
|
+
if (!cfg) fail("UNKNOWN_AGENT", `Agent "${resolvedName}" not found in ${agentsDir}`, 2);
|
|
736
|
+
let serverUrl;
|
|
737
|
+
try {
|
|
738
|
+
serverUrl = resolveServerUrl(process.env.FIRST_TREE_HUB_SERVER_URL);
|
|
739
|
+
} catch (error) {
|
|
740
|
+
fail("MISSING_SERVER_URL", error instanceof Error ? error.message : String(error), 2);
|
|
741
|
+
}
|
|
742
|
+
return {
|
|
743
|
+
serverUrl,
|
|
744
|
+
agentId: cfg.agentId
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
function createSdk(agentName) {
|
|
748
|
+
const { serverUrl, agentId } = resolveLocalAgent(agentName);
|
|
749
|
+
return new FirstTreeHubSDK({
|
|
750
|
+
serverUrl,
|
|
751
|
+
getAccessToken: (opts) => ensureFreshAccessToken(opts),
|
|
752
|
+
agentId,
|
|
753
|
+
userAgent: CLI_USER_AGENT
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
function handleSdkError(error) {
|
|
757
|
+
if (error instanceof SdkError) {
|
|
758
|
+
const exitCode = error.statusCode === 401 ? 3 : 1;
|
|
759
|
+
fail(`HTTP_${error.statusCode}`, error.message, exitCode);
|
|
760
|
+
}
|
|
761
|
+
if (error instanceof TypeError && "cause" in error) fail("CONNECTION_ERROR", `Cannot connect to server: ${error.message}`, 6);
|
|
762
|
+
fail("UNKNOWN_ERROR", error instanceof Error ? error.message : String(error), 1);
|
|
763
|
+
}
|
|
764
|
+
function parseLimit(value, max) {
|
|
765
|
+
const limit = Number.parseInt(value, 10);
|
|
766
|
+
if (Number.isNaN(limit) || limit < 1 || limit > max) fail("INVALID_LIMIT", `Limit must be between 1 and ${max}.`, 2);
|
|
767
|
+
return limit;
|
|
768
|
+
}
|
|
769
|
+
const MAX_STDIN_BYTES = 10 * 1024 * 1024;
|
|
770
|
+
function readStdin() {
|
|
771
|
+
if (process.stdin.isTTY) return Promise.resolve(null);
|
|
772
|
+
return new Promise((resolve, reject) => {
|
|
773
|
+
const chunks = [];
|
|
774
|
+
let totalSize = 0;
|
|
775
|
+
process.stdin.on("data", (chunk) => {
|
|
776
|
+
totalSize += chunk.length;
|
|
777
|
+
if (totalSize > MAX_STDIN_BYTES) {
|
|
778
|
+
process.stdin.destroy();
|
|
779
|
+
reject(/* @__PURE__ */ new Error(`stdin exceeds ${MAX_STDIN_BYTES} bytes`));
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
chunks.push(chunk);
|
|
783
|
+
});
|
|
784
|
+
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
785
|
+
process.stdin.on("error", reject);
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
async function resolveAgent(serverUrl, adminToken, agentName) {
|
|
789
|
+
const res = await cliFetch(`${serverUrl}/api/v1/me/managed-agents`, {
|
|
790
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
791
|
+
signal: AbortSignal.timeout(1e4)
|
|
792
|
+
});
|
|
793
|
+
if (!res.ok) fail("FETCH_ERROR", `Failed to list agents: ${res.status}`, 1);
|
|
794
|
+
const found = (await res.json()).find((a) => a.name === agentName || a.uuid === agentName);
|
|
795
|
+
if (!found) fail("NOT_FOUND", `Agent "${agentName}" not found`, 1);
|
|
796
|
+
return found;
|
|
797
|
+
}
|
|
798
|
+
function registerChatCommands(program) {
|
|
799
|
+
const chat = program.command("chat").description("Chats and messaging — list, history, send, open");
|
|
800
|
+
chat.command("send <target> [message]").description("Send a message to an agent or chat").option("-f, --format <format>", "Message format (text|markdown|card)", "text").option("--chat", "Treat target as chat ID instead of agent ID").option("-m, --metadata <json>", "JSON metadata to attach").option("--reply-to <messageId>", "Message ID to reply to").option("--reply-to-inbox <inboxId>", "Cross-chat reply target inbox").option("--reply-to-chat <chatId>", "Cross-chat reply target chat").option("--agent <name>", "Agent name on the Hub (default: first configured on this client)").action(async (target, message, options) => {
|
|
801
|
+
try {
|
|
802
|
+
const content = message ?? await readStdin();
|
|
803
|
+
if (!content) fail("NO_MESSAGE", "No message provided. Pass as argument or pipe via stdin.", 2);
|
|
804
|
+
let metadata;
|
|
805
|
+
if (options.metadata) try {
|
|
806
|
+
metadata = JSON.parse(options.metadata);
|
|
807
|
+
} catch {
|
|
808
|
+
fail("INVALID_METADATA", "Metadata must be valid JSON.", 2);
|
|
809
|
+
}
|
|
810
|
+
const { replyToInbox, replyToChat } = resolveReplyToFromEnv(process.env, {
|
|
811
|
+
replyToInbox: options.replyToInbox,
|
|
812
|
+
replyToChat: options.replyToChat
|
|
813
|
+
});
|
|
814
|
+
const sdk = createSdk(options.agent);
|
|
815
|
+
if (options.chat) success(await sdk.sendMessage(target, {
|
|
816
|
+
format: options.format,
|
|
817
|
+
content,
|
|
818
|
+
metadata,
|
|
819
|
+
inReplyTo: options.replyTo,
|
|
820
|
+
replyToInbox,
|
|
821
|
+
replyToChat
|
|
822
|
+
}));
|
|
823
|
+
else success(await sdk.sendToAgent(target, {
|
|
824
|
+
format: options.format,
|
|
825
|
+
content,
|
|
826
|
+
metadata,
|
|
827
|
+
replyToInbox,
|
|
828
|
+
replyToChat
|
|
829
|
+
}));
|
|
830
|
+
} catch (error) {
|
|
831
|
+
handleSdkError(error);
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
chat.command("list").description("List chats this agent participates in").option("-l, --limit <number>", "Maximum chats to return (1-100)", "20").option("--cursor <cursor>", "Pagination cursor from previous response").option("--agent <name>", "Agent name on the Hub (default: first configured on this client)").action(async (options) => {
|
|
835
|
+
try {
|
|
836
|
+
const limit = parseLimit(options.limit, 100);
|
|
837
|
+
success(await createSdk(options.agent).listChats({
|
|
838
|
+
limit,
|
|
839
|
+
cursor: options.cursor
|
|
840
|
+
}));
|
|
841
|
+
} catch (error) {
|
|
842
|
+
handleSdkError(error);
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
chat.command("history <chatId>").description("View message history in a chat").option("-l, --limit <number>", "Maximum messages to return (1-100)", "20").option("--cursor <cursor>", "Pagination cursor from previous response").option("--agent <name>", "Agent name on the Hub (default: first configured on this client)").action(async (chatId, options) => {
|
|
846
|
+
try {
|
|
847
|
+
const limit = parseLimit(options.limit, 100);
|
|
848
|
+
success(await createSdk(options.agent).listMessages(chatId, {
|
|
849
|
+
limit,
|
|
850
|
+
cursor: options.cursor
|
|
851
|
+
}));
|
|
852
|
+
} catch (error) {
|
|
853
|
+
handleSdkError(error);
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
chat.command("open <agent-name>").description("Open an interactive chat with an agent (as the current member's human agent)").option("--server <url>", "Hub server URL").action(async (agentName, options) => {
|
|
772
857
|
try {
|
|
773
858
|
const serverUrl = resolveServerUrl(options.server);
|
|
774
859
|
const adminToken = await ensureFreshAccessToken();
|
|
@@ -857,277 +942,6 @@ function registerAgentCommands(program) {
|
|
|
857
942
|
fail("CHAT_ERROR", error instanceof Error ? error.message : String(error));
|
|
858
943
|
}
|
|
859
944
|
});
|
|
860
|
-
agent.command("register").description("Register this agent and return identity info").option("--agent <name>", "Agent name on the Hub (default: first configured on this client)").action(async (options) => {
|
|
861
|
-
try {
|
|
862
|
-
success(await createSdk(options.agent).register());
|
|
863
|
-
} catch (error) {
|
|
864
|
-
handleSdkError(error);
|
|
865
|
-
}
|
|
866
|
-
});
|
|
867
|
-
agent.command("pull").description("Pull pending messages from inbox").option("-l, --limit <number>", "Maximum entries to return", "10").option("-a, --ack", "Automatically ACK entries after pulling").option("--agent <name>", "Agent name on the Hub (default: first configured on this client)").action(async (options) => {
|
|
868
|
-
try {
|
|
869
|
-
const sdk = createSdk(options.agent);
|
|
870
|
-
const limit = parseLimit(options.limit, 50);
|
|
871
|
-
const result = await sdk.pull(limit);
|
|
872
|
-
if (options.ack && result.entries.length > 0) await Promise.all(result.entries.map((entry) => sdk.ack(entry.id)));
|
|
873
|
-
success(result);
|
|
874
|
-
} catch (error) {
|
|
875
|
-
handleSdkError(error);
|
|
876
|
-
}
|
|
877
|
-
});
|
|
878
|
-
}
|
|
879
|
-
//#endregion
|
|
880
|
-
//#region src/commands/connect.ts
|
|
881
|
-
/** Decode a JWT payload without signature verification. For UI purposes only. */
|
|
882
|
-
function decodeJwtPayload(token) {
|
|
883
|
-
try {
|
|
884
|
-
const parts = token.split(".");
|
|
885
|
-
if (parts.length !== 3 || !parts[1]) return null;
|
|
886
|
-
const raw = Buffer.from(parts[1], "base64url").toString();
|
|
887
|
-
const obj = JSON.parse(raw);
|
|
888
|
-
if (typeof obj !== "object" || obj === null) return null;
|
|
889
|
-
return obj;
|
|
890
|
-
} catch {
|
|
891
|
-
return null;
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
/**
|
|
895
|
-
* Detect if the current home already holds a setup for a *different* account,
|
|
896
|
-
* and give the operator a chance to back out before we overwrite credentials.
|
|
897
|
-
*
|
|
898
|
-
* Why this gate exists: running `client connect` implicitly overwrites
|
|
899
|
-
* `~/.first-tree/hub/config/credentials.json`. Without this prompt, someone
|
|
900
|
-
* onboarding a second account on their own machine silently logs themselves
|
|
901
|
-
* out of the first account — they'd only notice when their "main" agents
|
|
902
|
-
* appeared offline later. We treat single-account-per-machine as the product
|
|
903
|
-
* default; the `FIRST_TREE_HUB_HOME` env var remains the advanced escape
|
|
904
|
-
* hatch for power users who want parallel setups.
|
|
905
|
-
*
|
|
906
|
-
* The caller passes the *new* memberId directly so this gate can run BEFORE
|
|
907
|
-
* auth. That matters for the `--token` path: connect tokens are single-use;
|
|
908
|
-
* if we auth first and the user picks Cancel, the token is burned even
|
|
909
|
-
* though nothing changed on disk. Decoding the connect token locally lets
|
|
910
|
-
* us return early without spending it.
|
|
911
|
-
*
|
|
912
|
-
* Behavior:
|
|
913
|
-
* - No existing credentials → proceed silently (first-time install).
|
|
914
|
-
* - Existing credentials, same memberId → proceed silently (reconnect /
|
|
915
|
-
* token refresh — common + safe).
|
|
916
|
-
* - Existing credentials, memberId indeterminate → prompt with
|
|
917
|
-
* "unknown account" label so the user can decide.
|
|
918
|
-
* - Existing credentials, different memberId → prompt [Replace / Cancel].
|
|
919
|
-
* Cancel prints the isolation guide and returns "cancel".
|
|
920
|
-
*/
|
|
921
|
-
async function promptReplaceOrCancel(newMemberId, newServerUrl) {
|
|
922
|
-
const existing = loadCredentials();
|
|
923
|
-
if (!existing) return "proceed";
|
|
924
|
-
const existingPayload = decodeJwtPayload(existing.accessToken);
|
|
925
|
-
const existingMemberId = typeof existingPayload?.memberId === "string" ? existingPayload.memberId : null;
|
|
926
|
-
if (existingMemberId && existingMemberId === newMemberId) return "proceed";
|
|
927
|
-
const existingMember = existingMemberId ? `member ${existingMemberId.slice(0, 8)}` : "unknown account";
|
|
928
|
-
const existingOrg = typeof existingPayload?.organizationId === "string" ? existingPayload.organizationId : null;
|
|
929
|
-
const serviceStatus = getClientServiceStatus();
|
|
930
|
-
const serviceLine = serviceStatus.state === "active" ? `running (${serviceStatus.detail ?? "live"})` : serviceStatus.state === "inactive" ? `installed but not running${serviceStatus.detail ? ` — ${serviceStatus.detail}` : ""}` : "not installed";
|
|
931
|
-
print.line("\n");
|
|
932
|
-
print.line(" ⚠️ This computer is already connected to the Hub under another account.\n\n");
|
|
933
|
-
print.line(` Existing account: ${existingMember}\n`);
|
|
934
|
-
if (existingOrg) print.line(` Organization: ${existingOrg.slice(0, 8)}\n`);
|
|
935
|
-
print.line(` Server: ${existing.serverUrl}\n`);
|
|
936
|
-
print.line(` Background service: ${serviceLine}\n\n`);
|
|
937
|
-
print.line(" Replacing only affects THIS computer. Your agents, messages, and\n");
|
|
938
|
-
print.line(" settings on the Hub itself are untouched.\n\n");
|
|
939
|
-
if (await select({
|
|
940
|
-
message: "How would you like to continue?",
|
|
941
|
-
choices: [{
|
|
942
|
-
name: "Replace — log out the other account and set up this one",
|
|
943
|
-
value: "replace"
|
|
944
|
-
}, {
|
|
945
|
-
name: "Cancel — keep the existing setup on this computer",
|
|
946
|
-
value: "cancel"
|
|
947
|
-
}]
|
|
948
|
-
}) === "cancel") {
|
|
949
|
-
printIsolationGuide(newServerUrl);
|
|
950
|
-
return "cancel";
|
|
951
|
-
}
|
|
952
|
-
return "proceed";
|
|
953
|
-
}
|
|
954
|
-
function printIsolationGuide(newServerUrl) {
|
|
955
|
-
print.line("\n Cancelled. The existing account on this computer is untouched.\n\n");
|
|
956
|
-
print.line(" To run this new account alongside it (advanced — no background service):\n\n");
|
|
957
|
-
print.line(" export FIRST_TREE_HUB_HOME=\"$HOME/.first-tree/hub-<label>\"\n");
|
|
958
|
-
print.line(` first-tree-hub client connect ${newServerUrl} --token <token>\n`);
|
|
959
|
-
print.line(" first-tree-hub client start\n\n");
|
|
960
|
-
print.line(" Notes:\n");
|
|
961
|
-
print.line(" - Run the commands in a FRESH terminal (the isolated home must be set first).\n");
|
|
962
|
-
print.line(" - In isolated mode the client stays online only while that terminal runs.\n");
|
|
963
|
-
print.line(" - The main account's background service is not affected.\n\n");
|
|
964
|
-
}
|
|
965
|
-
/**
|
|
966
|
-
* Authenticate via connect token — exchange for full JWT credentials.
|
|
967
|
-
*/
|
|
968
|
-
async function authenticateWithToken(url, token) {
|
|
969
|
-
const res = await cliFetch(`${url}/api/v1/auth/connect-token`, {
|
|
970
|
-
method: "POST",
|
|
971
|
-
headers: { "Content-Type": "application/json" },
|
|
972
|
-
body: JSON.stringify({ token }),
|
|
973
|
-
signal: AbortSignal.timeout(1e4)
|
|
974
|
-
});
|
|
975
|
-
if (!res.ok) fail("AUTH_ERROR", (await res.json().catch(() => ({}))).error ?? `Token exchange failed (HTTP ${res.status})`, 1);
|
|
976
|
-
return await res.json();
|
|
977
|
-
}
|
|
978
|
-
/**
|
|
979
|
-
* Authenticate via interactive username/password login.
|
|
980
|
-
*/
|
|
981
|
-
async function authenticateInteractive(url) {
|
|
982
|
-
print.line("\n Log in to Hub:\n");
|
|
983
|
-
const username = await input({ message: " Username:" });
|
|
984
|
-
const pw = await password({ message: " Password:" });
|
|
985
|
-
const loginRes = await cliFetch(`${url}/api/v1/auth/login`, {
|
|
986
|
-
method: "POST",
|
|
987
|
-
headers: { "Content-Type": "application/json" },
|
|
988
|
-
body: JSON.stringify({
|
|
989
|
-
username,
|
|
990
|
-
password: pw
|
|
991
|
-
}),
|
|
992
|
-
signal: AbortSignal.timeout(1e4)
|
|
993
|
-
});
|
|
994
|
-
if (!loginRes.ok) fail("AUTH_ERROR", (await loginRes.json().catch(() => ({}))).error ?? `Login failed (HTTP ${loginRes.status})`, 1);
|
|
995
|
-
return await loginRes.json();
|
|
996
|
-
}
|
|
997
|
-
function registerConnectCommand(parent) {
|
|
998
|
-
parent.command("connect <server-url>").description("Connect to a Hub server — configure, authenticate, and install the background service").option("--token <token>", "Connect token (from Hub web console) — skips interactive login").option("--no-service", "Skip background service install (runs inline until Ctrl+C)").action(async (serverUrl, options) => {
|
|
999
|
-
try {
|
|
1000
|
-
const url = serverUrl.replace(/\/+$/, "");
|
|
1001
|
-
let preAuthDecided = false;
|
|
1002
|
-
if (options.token) {
|
|
1003
|
-
const connectPayload = decodeJwtPayload(options.token);
|
|
1004
|
-
const newMemberId = typeof connectPayload?.memberId === "string" ? connectPayload.memberId : null;
|
|
1005
|
-
if (newMemberId !== null) {
|
|
1006
|
-
if (await promptReplaceOrCancel(newMemberId, url) === "cancel") return;
|
|
1007
|
-
preAuthDecided = true;
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
const tokens = options.token ? await authenticateWithToken(url, options.token) : await authenticateInteractive(url);
|
|
1011
|
-
if (!preAuthDecided) {
|
|
1012
|
-
const newPayload = decodeJwtPayload(tokens.accessToken);
|
|
1013
|
-
const newMemberId = typeof newPayload?.memberId === "string" ? newPayload.memberId : null;
|
|
1014
|
-
if (newMemberId !== null) {
|
|
1015
|
-
if (await promptReplaceOrCancel(newMemberId, url) === "cancel") return;
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
|
-
setConfigValue(join(DEFAULT_CONFIG_DIR, "client.yaml"), "server.url", url);
|
|
1019
|
-
print.line(`\n \u2713 Server configured: ${url}\n`);
|
|
1020
|
-
saveCredentials({
|
|
1021
|
-
...tokens,
|
|
1022
|
-
serverUrl: url
|
|
1023
|
-
});
|
|
1024
|
-
print.line(" ✓ Authenticated\n");
|
|
1025
|
-
resetConfig();
|
|
1026
|
-
resetConfigMeta();
|
|
1027
|
-
const config = await initConfig({
|
|
1028
|
-
schema: clientConfigSchema,
|
|
1029
|
-
role: "client"
|
|
1030
|
-
});
|
|
1031
|
-
print.line(` \u2713 Connected as this computer (id: ${config.client.id})\n`);
|
|
1032
|
-
if (options.service !== false && isServiceSupported()) {
|
|
1033
|
-
const info = installClientService();
|
|
1034
|
-
print.line(` \u2713 Installed as a background service (${info.platform}) — you can close this terminal\n\n`);
|
|
1035
|
-
print.line(` Unit: ${info.unitPath}\n`);
|
|
1036
|
-
print.line(` Logs: ${info.logDir}\n`);
|
|
1037
|
-
if (info.state === "active" && info.detail) print.line(` State: running (${info.detail})\n`);
|
|
1038
|
-
print.line("\n");
|
|
1039
|
-
return;
|
|
1040
|
-
}
|
|
1041
|
-
if (options.service === false) print.line(" (--no-service) running inline — Ctrl+C to stop\n");
|
|
1042
|
-
else print.line(` Background service not supported on ${process.platform}; running inline.\n`);
|
|
1043
|
-
const agentsDir = join(DEFAULT_CONFIG_DIR, "agents");
|
|
1044
|
-
try {
|
|
1045
|
-
await migrateLocalAgentDirs({
|
|
1046
|
-
agentsDir,
|
|
1047
|
-
workspacesDir: join(DEFAULT_DATA_DIR, "workspaces"),
|
|
1048
|
-
sessionsDir: join(DEFAULT_DATA_DIR, "sessions"),
|
|
1049
|
-
resolver: createApiNameResolver(config.server.url, () => ensureFreshAccessToken())
|
|
1050
|
-
});
|
|
1051
|
-
} catch (err) {
|
|
1052
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1053
|
-
print.status("⚠️", `agent-dir migration skipped: ${msg}`);
|
|
1054
|
-
}
|
|
1055
|
-
let probedCapabilities = null;
|
|
1056
|
-
try {
|
|
1057
|
-
const accessToken = await ensureFreshAccessToken();
|
|
1058
|
-
probedCapabilities = await probeCapabilities();
|
|
1059
|
-
await reconcileLocalRuntimeProviders({
|
|
1060
|
-
serverUrl: config.server.url,
|
|
1061
|
-
accessToken,
|
|
1062
|
-
agentsDir,
|
|
1063
|
-
log: (level, msg) => print.status(level === "warn" ? "⚠️" : "•", msg)
|
|
1064
|
-
});
|
|
1065
|
-
} catch (err) {
|
|
1066
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1067
|
-
print.status("⚠️", `runtime-provider reconcile skipped: ${msg}`);
|
|
1068
|
-
}
|
|
1069
|
-
const agents = loadAgents({
|
|
1070
|
-
schema: agentConfigSchema,
|
|
1071
|
-
agentsDir
|
|
1072
|
-
});
|
|
1073
|
-
const runtime = new ClientRuntime(config.server.url, config.client.id, {
|
|
1074
|
-
currentVersion: COMMAND_VERSION,
|
|
1075
|
-
update: {
|
|
1076
|
-
updateConfig: config.update,
|
|
1077
|
-
prompt: promptUpdate,
|
|
1078
|
-
executeUpdate: createExecuteUpdate({ managed: false })
|
|
1079
|
-
}
|
|
1080
|
-
});
|
|
1081
|
-
for (const [name, agentConfig] of agents) runtime.addAgent(name, agentConfig);
|
|
1082
|
-
await runtime.start();
|
|
1083
|
-
if (probedCapabilities) try {
|
|
1084
|
-
const accessToken = await ensureFreshAccessToken();
|
|
1085
|
-
await uploadClientCapabilities({
|
|
1086
|
-
serverUrl: config.server.url,
|
|
1087
|
-
accessToken,
|
|
1088
|
-
clientId: config.client.id,
|
|
1089
|
-
capabilities: probedCapabilities
|
|
1090
|
-
});
|
|
1091
|
-
} catch (err) {
|
|
1092
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1093
|
-
print.status("⚠️", `capabilities upload skipped: ${msg}`);
|
|
1094
|
-
}
|
|
1095
|
-
runtime.watchAgentsDir(agentsDir);
|
|
1096
|
-
const shutdown = async () => {
|
|
1097
|
-
print.line("\n Shutting down...\n");
|
|
1098
|
-
runtime.unwatchAgentsDir();
|
|
1099
|
-
await runtime.stop();
|
|
1100
|
-
process.exit(0);
|
|
1101
|
-
};
|
|
1102
|
-
process.on("SIGINT", () => void shutdown());
|
|
1103
|
-
process.on("SIGTERM", () => void shutdown());
|
|
1104
|
-
await new Promise(() => {});
|
|
1105
|
-
} catch (error) {
|
|
1106
|
-
if (error.name === "ExitPromptError") {
|
|
1107
|
-
print.line("\n Cancelled.\n");
|
|
1108
|
-
return;
|
|
1109
|
-
}
|
|
1110
|
-
if (error instanceof ClientUserMismatchError) {
|
|
1111
|
-
print.line("\n");
|
|
1112
|
-
print.line(" ⚠️ This client.yaml is owned by a different user.\n");
|
|
1113
|
-
print.line(" Run `first-tree-hub client claim --confirm` to transfer ownership\n");
|
|
1114
|
-
print.line(" to your account. The previous owner's agents will be unpinned\n");
|
|
1115
|
-
print.line(" from this machine.\n\n");
|
|
1116
|
-
process.exit(1);
|
|
1117
|
-
}
|
|
1118
|
-
if (error instanceof ClientOrgMismatchError) await handleClientOrgMismatch(error, {
|
|
1119
|
-
managed: false,
|
|
1120
|
-
configDir: DEFAULT_CONFIG_DIR,
|
|
1121
|
-
rerunCommand: `first-tree-hub client connect ${serverUrl}${options.token ? " --token <token>" : ""}${options.service === false ? " --no-service" : ""}`
|
|
1122
|
-
});
|
|
1123
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
1124
|
-
print.line(` Error: ${msg}\n`);
|
|
1125
|
-
process.exit(1);
|
|
1126
|
-
} finally {
|
|
1127
|
-
resetConfig();
|
|
1128
|
-
resetConfigMeta();
|
|
1129
|
-
}
|
|
1130
|
-
});
|
|
1131
945
|
}
|
|
1132
946
|
//#endregion
|
|
1133
947
|
//#region src/commands/client.ts
|
|
@@ -1142,7 +956,6 @@ function isWslDbusOvermount(reason) {
|
|
|
1142
956
|
}
|
|
1143
957
|
function registerClientCommands(program) {
|
|
1144
958
|
const client = program.command("client").description("Client runtime — connect agents to the server");
|
|
1145
|
-
registerConnectCommand(client);
|
|
1146
959
|
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) => {
|
|
1147
960
|
try {
|
|
1148
961
|
const isSupervisorChild = options.interactive === false && process.env.FIRST_TREE_HUB_SERVICE_MODE === "1";
|
|
@@ -1362,7 +1175,7 @@ function registerClientCommands(program) {
|
|
|
1362
1175
|
}
|
|
1363
1176
|
if (getClientServiceStatus().state === "not-installed") {
|
|
1364
1177
|
print.line("\n No background service installed.\n");
|
|
1365
|
-
print.line(" Run `first-tree-hub
|
|
1178
|
+
print.line(" Run `first-tree-hub connect <token>` first.\n\n");
|
|
1366
1179
|
process.exit(1);
|
|
1367
1180
|
}
|
|
1368
1181
|
const res = restartClientService();
|
|
@@ -1381,7 +1194,7 @@ function registerClientCommands(program) {
|
|
|
1381
1194
|
const tail = svc.platform === "systemd" ? ` (logs: journalctl --user -u ${svc.label.replace(/\.service$/, "")} -f)` : "";
|
|
1382
1195
|
if (svc.state === "active") print.line(` Service: ✓ running (${svc.platform}${svc.detail ? `, ${svc.detail}` : ""})${tail}\n`);
|
|
1383
1196
|
else if (svc.state === "inactive") print.line(` Service: ✗ stopped (${svc.platform}${svc.detail ? `, ${svc.detail}` : ""})\n`);
|
|
1384
|
-
else if (svc.state === "not-installed") print.line(" Service: not installed — run `first-tree-hub
|
|
1197
|
+
else if (svc.state === "not-installed") print.line(" Service: not installed — run `first-tree-hub connect <token>`\n");
|
|
1385
1198
|
else print.line(` Service: unknown (${svc.platform}${svc.detail ? `, ${svc.detail}` : ""})\n`);
|
|
1386
1199
|
} else print.line(` Service: not supported on ${process.platform} (runs inline)\n`);
|
|
1387
1200
|
const clientYaml = join(DEFAULT_CONFIG_DIR, "client.yaml");
|
|
@@ -1395,7 +1208,7 @@ function registerClientCommands(program) {
|
|
|
1395
1208
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1396
1209
|
print.line(` Hub: (could not read ${clientYaml}: ${msg.slice(0, 60)})\n`);
|
|
1397
1210
|
}
|
|
1398
|
-
else print.line(" Hub: (not configured — run `first-tree-hub
|
|
1211
|
+
else print.line(" Hub: (not configured — run `first-tree-hub connect <token>`)\n");
|
|
1399
1212
|
const creds = loadCredentials();
|
|
1400
1213
|
if (creds) {
|
|
1401
1214
|
const exp = decodeJwtExpSeconds(creds.refreshToken);
|
|
@@ -1413,7 +1226,7 @@ function registerClientCommands(program) {
|
|
|
1413
1226
|
print.line(` Auth: ✓ refresh token valid for ~${days}d\n`);
|
|
1414
1227
|
}
|
|
1415
1228
|
}
|
|
1416
|
-
} else print.line(" Auth: (no credentials — run `first-tree-hub
|
|
1229
|
+
} else print.line(" Auth: (no credentials — run `first-tree-hub connect <token>`)\n");
|
|
1417
1230
|
const agentsDir = join(DEFAULT_CONFIG_DIR, "agents");
|
|
1418
1231
|
try {
|
|
1419
1232
|
const agents = loadAgents({
|
|
@@ -1431,7 +1244,7 @@ function registerClientCommands(program) {
|
|
|
1431
1244
|
print.line(" Agents: (no agents directory)\n\n");
|
|
1432
1245
|
}
|
|
1433
1246
|
});
|
|
1434
|
-
client.command("
|
|
1247
|
+
client.command("list").description("List clients on the Hub server").option("--server <url>", "Hub server URL").action(async (options) => {
|
|
1435
1248
|
try {
|
|
1436
1249
|
const serverUrl = resolveServerUrl(options.server);
|
|
1437
1250
|
const token = await ensureFreshAccessToken();
|
|
@@ -1556,7 +1369,7 @@ function registerClientCommands(program) {
|
|
|
1556
1369
|
resetConfigMeta();
|
|
1557
1370
|
}
|
|
1558
1371
|
});
|
|
1559
|
-
client.command("
|
|
1372
|
+
client.command("disconnect <clientId>").description("Force-disconnect a client from the Hub server").option("--server <url>", "Hub server URL").action(async (clientId, options) => {
|
|
1560
1373
|
try {
|
|
1561
1374
|
const serverUrl = resolveServerUrl(options.server);
|
|
1562
1375
|
const token = await ensureFreshAccessToken();
|
|
@@ -1571,109 +1384,47 @@ function registerClientCommands(program) {
|
|
|
1571
1384
|
fail("DISCONNECT_ERROR", error instanceof Error ? error.message : String(error));
|
|
1572
1385
|
}
|
|
1573
1386
|
});
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
const
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
1584
|
-
}
|
|
1585
|
-
/** Read a `dot.path.like.this` from a parsed YAML object, returning string | null. */
|
|
1586
|
-
function getNested(obj, path) {
|
|
1587
|
-
let cur = obj;
|
|
1588
|
-
for (const part of path.split(".")) {
|
|
1589
|
-
if (cur === null || cur === void 0 || typeof cur !== "object") return null;
|
|
1590
|
-
cur = cur[part];
|
|
1591
|
-
}
|
|
1592
|
-
return typeof cur === "string" ? cur : null;
|
|
1593
|
-
}
|
|
1594
|
-
/**
|
|
1595
|
-
* Pull the `exp` claim (in seconds since epoch) out of a JWT without
|
|
1596
|
-
* verifying the signature — the `client status` auth-health line just
|
|
1597
|
-
* needs the wall-clock countdown, not a trust decision. Returns null
|
|
1598
|
-
* for malformed tokens so the caller can render a friendly fallback
|
|
1599
|
-
* instead of crashing.
|
|
1600
|
-
*/
|
|
1601
|
-
function decodeJwtExpSeconds(token) {
|
|
1602
|
-
const parts = token.split(".");
|
|
1603
|
-
if (parts.length < 2 || !parts[1]) return null;
|
|
1604
|
-
try {
|
|
1605
|
-
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
1606
|
-
const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - b64.length % 4);
|
|
1607
|
-
const payload = JSON.parse(Buffer.from(b64 + pad, "base64").toString("utf-8"));
|
|
1608
|
-
return typeof payload.exp === "number" ? payload.exp : null;
|
|
1609
|
-
} catch {
|
|
1610
|
-
return null;
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
//#endregion
|
|
1614
|
-
//#region src/commands/config.ts
|
|
1615
|
-
function resolveConfigPath(flags) {
|
|
1616
|
-
if (flags.agent) return {
|
|
1617
|
-
path: join(DEFAULT_CONFIG_DIR, "agents", flags.agent, "agent.yaml"),
|
|
1618
|
-
schema: agentConfigSchema
|
|
1619
|
-
};
|
|
1620
|
-
if (flags.client) return {
|
|
1621
|
-
path: join(DEFAULT_CONFIG_DIR, "client.yaml"),
|
|
1622
|
-
schema: clientConfigSchema
|
|
1623
|
-
};
|
|
1624
|
-
return {
|
|
1625
|
-
path: join(DEFAULT_CONFIG_DIR, "server.yaml"),
|
|
1626
|
-
schema: serverConfigSchema
|
|
1627
|
-
};
|
|
1628
|
-
}
|
|
1629
|
-
function addScopeOptions(cmd) {
|
|
1630
|
-
return cmd.option("-s, --server", "Server config scope (default)").option("-c, --client", "Client config scope").option("-a, --agent <name>", "Agent config scope");
|
|
1631
|
-
}
|
|
1632
|
-
function registerConfigCommands(program) {
|
|
1633
|
-
const config = program.command("config").description("Configuration management");
|
|
1634
|
-
config.command("setup").description("Interactive configuration wizard").option("-s, --server", "Configure server (default)").option("-c, --client", "Configure client").action(async (flags) => {
|
|
1635
|
-
try {
|
|
1636
|
-
await promptMissingFields({
|
|
1637
|
-
schema: flags.client ? clientConfigSchema : serverConfigSchema,
|
|
1638
|
-
role: flags.client ? "client" : "server"
|
|
1639
|
-
});
|
|
1640
|
-
print.line("\n Configuration saved.\n");
|
|
1641
|
-
} catch (error) {
|
|
1642
|
-
if (error.name === "ExitPromptError") {
|
|
1643
|
-
print.line("\n Cancelled.\n");
|
|
1387
|
+
const config = client.command("config").description("View and modify this machine's client.yaml");
|
|
1388
|
+
const clientYamlPath = () => join(DEFAULT_CONFIG_DIR, "client.yaml");
|
|
1389
|
+
const clientSchema = clientConfigSchema;
|
|
1390
|
+
config.command("show [key]").description("Show client.yaml — print all values, or a single key with dot-notation").option("--show-secrets", "Show secret values in plaintext").action((key, flags) => {
|
|
1391
|
+
const path = clientYamlPath();
|
|
1392
|
+
if (key) {
|
|
1393
|
+
const value = getConfigValue(path, key);
|
|
1394
|
+
if (value === void 0) {
|
|
1395
|
+
print.line(` ${key}: (not set)\n`);
|
|
1644
1396
|
return;
|
|
1645
1397
|
}
|
|
1646
|
-
|
|
1398
|
+
const display = isSecretField(clientSchema, key) && !flags.showSecrets ? "***" : String(value);
|
|
1399
|
+
print.line(` ${key}: ${display}\n`);
|
|
1400
|
+
return;
|
|
1647
1401
|
}
|
|
1402
|
+
const values = readConfigFile(path);
|
|
1403
|
+
if (Object.keys(values).length === 0) {
|
|
1404
|
+
print.line(` No config found at ${path}\n`);
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
print.line(`\n Config: ${path}\n\n`);
|
|
1408
|
+
printFlat(values, clientSchema, "", flags.showSecrets ?? false);
|
|
1409
|
+
print.line("\n");
|
|
1648
1410
|
});
|
|
1649
|
-
|
|
1650
|
-
const { path } = resolveConfigPath(flags);
|
|
1411
|
+
config.command("set <key> <value>").description("Set a value in client.yaml (dot-notation)").action((key, value) => {
|
|
1651
1412
|
let parsed = value;
|
|
1652
1413
|
if (value === "true") parsed = true;
|
|
1653
1414
|
else if (value === "false") parsed = false;
|
|
1654
1415
|
else if (/^\d+$/.test(value)) parsed = Number(value);
|
|
1416
|
+
const path = clientYamlPath();
|
|
1655
1417
|
setConfigValue(path, key, parsed);
|
|
1656
1418
|
print.line(` Set ${key} in ${path}\n`);
|
|
1657
1419
|
});
|
|
1658
|
-
|
|
1659
|
-
const
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
else {
|
|
1663
|
-
const display = isSecretField(schema, key) && !flags.showSecrets ? "***" : String(value);
|
|
1664
|
-
print.line(` ${key}: ${display}\n`);
|
|
1665
|
-
}
|
|
1666
|
-
});
|
|
1667
|
-
addScopeOptions(config.command("list").description("List all config values")).option("--show-secrets", "Show secret values in plaintext").action((flags) => {
|
|
1668
|
-
const { path, schema } = resolveConfigPath(flags);
|
|
1669
|
-
const values = readConfigFile(path);
|
|
1670
|
-
if (Object.keys(values).length === 0) {
|
|
1671
|
-
print.line(` No config found at ${path}\n`);
|
|
1420
|
+
config.command("get <key>").description("Get a value from client.yaml (alias for `show <key>`)").option("--show-secrets", "Show secret values in plaintext").action((key, flags) => {
|
|
1421
|
+
const value = getConfigValue(clientYamlPath(), key);
|
|
1422
|
+
if (value === void 0) {
|
|
1423
|
+
print.line(` ${key}: (not set)\n`);
|
|
1672
1424
|
return;
|
|
1673
1425
|
}
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
print.line("\n");
|
|
1426
|
+
const display = isSecretField(clientSchema, key) && !flags.showSecrets ? "***" : String(value);
|
|
1427
|
+
print.line(` ${key}: ${display}\n`);
|
|
1677
1428
|
});
|
|
1678
1429
|
}
|
|
1679
1430
|
function printFlat(obj, schema, prefix, showSecrets) {
|
|
@@ -1686,7 +1437,6 @@ function printFlat(obj, schema, prefix, showSecrets) {
|
|
|
1686
1437
|
}
|
|
1687
1438
|
}
|
|
1688
1439
|
}
|
|
1689
|
-
/** Check if a dot-path corresponds to a secret field in the schema. */
|
|
1690
1440
|
function isSecretField(schema, dotPath) {
|
|
1691
1441
|
const parts = dotPath.split(".");
|
|
1692
1442
|
let current = schema;
|
|
@@ -1703,18 +1453,56 @@ function isSecretField(schema, dotPath) {
|
|
|
1703
1453
|
}
|
|
1704
1454
|
return false;
|
|
1705
1455
|
}
|
|
1456
|
+
function timeSince(isoDate) {
|
|
1457
|
+
const ms = Date.now() - new Date(isoDate).getTime();
|
|
1458
|
+
const seconds = Math.floor(ms / 1e3);
|
|
1459
|
+
if (seconds < 60) return `${seconds}s`;
|
|
1460
|
+
const minutes = Math.floor(seconds / 60);
|
|
1461
|
+
if (minutes < 60) return `${minutes}m`;
|
|
1462
|
+
const hours = Math.floor(minutes / 60);
|
|
1463
|
+
if (hours < 24) return `${hours}h ${minutes % 60}m`;
|
|
1464
|
+
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
1465
|
+
}
|
|
1466
|
+
/** Read a `dot.path.like.this` from a parsed YAML object, returning string | null. */
|
|
1467
|
+
function getNested(obj, path) {
|
|
1468
|
+
let cur = obj;
|
|
1469
|
+
for (const part of path.split(".")) {
|
|
1470
|
+
if (cur === null || cur === void 0 || typeof cur !== "object") return null;
|
|
1471
|
+
cur = cur[part];
|
|
1472
|
+
}
|
|
1473
|
+
return typeof cur === "string" ? cur : null;
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Pull the `exp` claim (in seconds since epoch) out of a JWT without
|
|
1477
|
+
* verifying the signature — the `client status` auth-health line just
|
|
1478
|
+
* needs the wall-clock countdown, not a trust decision. Returns null
|
|
1479
|
+
* for malformed tokens so the caller can render a friendly fallback
|
|
1480
|
+
* instead of crashing.
|
|
1481
|
+
*/
|
|
1482
|
+
function decodeJwtExpSeconds(token) {
|
|
1483
|
+
const parts = token.split(".");
|
|
1484
|
+
if (parts.length < 2 || !parts[1]) return null;
|
|
1485
|
+
try {
|
|
1486
|
+
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
1487
|
+
const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - b64.length % 4);
|
|
1488
|
+
const payload = JSON.parse(Buffer.from(b64 + pad, "base64").toString("utf-8"));
|
|
1489
|
+
return typeof payload.exp === "number" ? payload.exp : null;
|
|
1490
|
+
} catch {
|
|
1491
|
+
return null;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1706
1494
|
//#endregion
|
|
1707
1495
|
//#region src/commands/onboard.ts
|
|
1708
1496
|
async function promptMissing(args) {
|
|
1709
1497
|
if (!args.server) try {
|
|
1710
|
-
const { resolveServerUrl } = await import("../bootstrap-
|
|
1498
|
+
const { resolveServerUrl } = await import("../bootstrap-Cya2OoHz.mjs").then((n) => n.r);
|
|
1711
1499
|
resolveServerUrl();
|
|
1712
1500
|
} catch {
|
|
1713
1501
|
args.server = await input({ message: "Hub server URL:" });
|
|
1714
1502
|
saveOnboardState(args);
|
|
1715
1503
|
}
|
|
1716
|
-
const { loadCredentials } = await import("../bootstrap-
|
|
1717
|
-
if (!loadCredentials()) throw new Error("No saved credentials. Run `first-tree-hub
|
|
1504
|
+
const { loadCredentials } = await import("../bootstrap-Cya2OoHz.mjs").then((n) => n.r);
|
|
1505
|
+
if (!loadCredentials()) throw new Error("No saved credentials. Run `first-tree-hub connect <token>` before onboarding.");
|
|
1718
1506
|
if (!args.id) {
|
|
1719
1507
|
args.id = await input({ message: "Agent ID:" });
|
|
1720
1508
|
saveOnboardState(args);
|
|
@@ -1947,7 +1735,7 @@ function registerServerCommands(program) {
|
|
|
1947
1735
|
process.exit(1);
|
|
1948
1736
|
}
|
|
1949
1737
|
});
|
|
1950
|
-
server.command("
|
|
1738
|
+
server.command("migrate").description("Run database migrations").action(async () => {
|
|
1951
1739
|
try {
|
|
1952
1740
|
const tableCount = await runMigrations((await initConfig({
|
|
1953
1741
|
schema: serverConfigSchema,
|
|
@@ -1960,7 +1748,7 @@ function registerServerCommands(program) {
|
|
|
1960
1748
|
process.exit(1);
|
|
1961
1749
|
}
|
|
1962
1750
|
});
|
|
1963
|
-
server.command("admin
|
|
1751
|
+
server.command("admin").description("Admin user management").command("create").description("Create an admin user with organization").option("-u, --username <name>", "Admin username", "admin").option("-n, --name <name>", "Display name", "Admin").option("-o, --org <org>", "Organization slug", "default").option("-p, --password <pass>", "Password (auto-generated if omitted)").action(async (options) => {
|
|
1964
1752
|
try {
|
|
1965
1753
|
const result = await createOwner((await initConfig({
|
|
1966
1754
|
schema: serverConfigSchema,
|
|
@@ -2039,7 +1827,7 @@ function registerUpdateCommand(program) {
|
|
|
2039
1827
|
const svc = getClientServiceStatus();
|
|
2040
1828
|
if (svc.state === "not-installed") {
|
|
2041
1829
|
print.line(" No background service installed — nothing to restart.\n");
|
|
2042
|
-
print.line(" Run `first-tree-hub
|
|
1830
|
+
print.line(" Run `first-tree-hub connect <token>` to set one up.\n\n");
|
|
2043
1831
|
return;
|
|
2044
1832
|
}
|
|
2045
1833
|
try {
|
|
@@ -2085,7 +1873,7 @@ registerSaaSConnectCommand(program);
|
|
|
2085
1873
|
registerServerCommands(program);
|
|
2086
1874
|
registerClientCommands(program);
|
|
2087
1875
|
registerAgentCommands(program);
|
|
2088
|
-
|
|
1876
|
+
registerChatCommands(program);
|
|
2089
1877
|
registerUpdateCommand(program);
|
|
2090
1878
|
registerOnboardCommand(program);
|
|
2091
1879
|
registerOrgCommands(program);
|