@treeport/treeport 0.1.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.
@@ -0,0 +1,865 @@
1
+ #!/usr/bin/env node
2
+ import { E as parseTerminalRuntimeMetadata, _ as SOCKET_IO_PATH, g as parseProductEvent, h as parseEventsSnapshot, t as TERMINAL_CAPTURE_MAX_LINES } from "../../dist-CUkImh2W.js";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { Command, CommanderError } from "commander";
6
+ import { io } from "socket.io-client";
7
+ import crypto from "node:crypto";
8
+ import { spawn } from "node:child_process";
9
+ import fsSync from "node:fs";
10
+ import os from "node:os";
11
+ import { fileURLToPath } from "node:url";
12
+ //#region src/cli/args.ts
13
+ function extractJsonOutput(args) {
14
+ const separator = args.indexOf("--");
15
+ const index = args.findIndex((value, valueIndex) => value === "--json" && (separator === -1 || valueIndex < separator));
16
+ if (index === -1) return false;
17
+ args.splice(index, 1);
18
+ return true;
19
+ }
20
+ //#endregion
21
+ //#region src/cli/lifecycle.ts
22
+ const DEFAULT_HOST = "127.0.0.1";
23
+ const DEFAULT_PORT = 8733;
24
+ function listenerUrl(host, port) {
25
+ return `http://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`;
26
+ }
27
+ function expandHome(value) {
28
+ return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
29
+ }
30
+ function localPaths(env = process.env) {
31
+ const defaultDataDir = env.XDG_DATA_HOME ? path.join(expandHome(env.XDG_DATA_HOME), "treeport") : process.platform === "darwin" ? path.join(os.homedir(), "Library", "Application Support", "treeport") : path.join(os.homedir(), ".local", "share", "treeport");
32
+ const dataDir = path.resolve(expandHome(env.TREEPORT_DATA_DIR?.trim() || defaultDataDir));
33
+ const runtimeDir = path.resolve(expandHome(env.TREEPORT_RUNTIME_DIR?.trim() || (env.XDG_RUNTIME_DIR ? path.join(env.XDG_RUNTIME_DIR, "treeport") : path.join(os.tmpdir(), `treeport-${process.getuid?.() ?? "user"}`))));
34
+ return {
35
+ dataDir,
36
+ runtimeDir,
37
+ preferencesPath: path.join(dataDir, "config.json"),
38
+ statePath: path.join(runtimeDir, "daemon.json"),
39
+ lockPath: path.join(dataDir, "daemon.lock"),
40
+ logPath: path.join(dataDir, "logs", "daemon.log")
41
+ };
42
+ }
43
+ async function readJson(filePath) {
44
+ return fs.readFile(filePath, "utf8").then((value) => JSON.parse(value)).catch(() => null);
45
+ }
46
+ async function preferences() {
47
+ return await readJson(localPaths().preferencesPath) ?? {};
48
+ }
49
+ async function resolveLocalApiUrl() {
50
+ const explicit = process.env.TREEPORT_API_URL?.trim();
51
+ if (explicit) return explicit.replace(/\/$/, "");
52
+ const saved = await preferences();
53
+ return listenerUrl(process.env.TREEPORT_HOST?.trim() || process.env.HOST?.trim() || saved.host || DEFAULT_HOST, Number.parseInt(process.env.TREEPORT_PORT?.trim() || process.env.PORT?.trim() || String(saved.port ?? DEFAULT_PORT), 10));
54
+ }
55
+ async function resolvePackagePath(...segments) {
56
+ const candidates = [fileURLToPath(new URL("../../../", import.meta.url)), fileURLToPath(new URL("../../", import.meta.url))];
57
+ for (const candidate of candidates) if (await fs.access(path.join(candidate, "package.json")).then(() => true).catch(() => false)) return path.join(candidate, ...segments);
58
+ throw new Error("Could not locate the Treeport package directory");
59
+ }
60
+ async function treeportVersion() {
61
+ return (await readJson(await resolvePackagePath("package.json")))?.version ?? "development";
62
+ }
63
+ function processExists(pid) {
64
+ try {
65
+ process.kill(pid, 0);
66
+ return true;
67
+ } catch (error) {
68
+ return error.code === "EPERM";
69
+ }
70
+ }
71
+ async function health(apiUrl, timeoutMs = 1500) {
72
+ const signal = AbortSignal.timeout(timeoutMs);
73
+ return fetch(`${apiUrl}/api/health`, { signal }).then(async (response) => {
74
+ if (!response.ok) return null;
75
+ const value = await response.json();
76
+ return value.ok && typeof value.pid === "number" ? value : null;
77
+ }).catch(() => null);
78
+ }
79
+ function matchesOwnership(state, observed) {
80
+ return observed.pid === state.pid && observed.instanceId === state.instanceId && path.resolve(state.dataDir) === localPaths().dataDir;
81
+ }
82
+ async function readState() {
83
+ const value = await readJson(localPaths().statePath);
84
+ return value && typeof value.pid === "number" && typeof value.instanceId === "string" && typeof value.apiUrl === "string" && typeof value.dataDir === "string" ? value : null;
85
+ }
86
+ async function removeStaleState(state) {
87
+ const paths = localPaths();
88
+ for (const filePath of [paths.statePath, paths.lockPath]) if ((await readJson(filePath))?.instanceId === state.instanceId) await fs.rm(filePath, { force: true });
89
+ }
90
+ async function stopOwned(state) {
91
+ if (!processExists(state.pid)) {
92
+ await removeStaleState(state);
93
+ return;
94
+ }
95
+ const observed = await health(state.apiUrl);
96
+ if (!observed || !matchesOwnership(state, observed)) throw new Error(`Refusing to stop PID ${state.pid}: Treeport could not verify ownership. Check ${localPaths().statePath}.`);
97
+ process.kill(state.pid, "SIGTERM");
98
+ const deadline = Date.now() + 7e3;
99
+ while (Date.now() < deadline) {
100
+ if (!processExists(state.pid)) {
101
+ await removeStaleState(state);
102
+ return;
103
+ }
104
+ await new Promise((resolve) => setTimeout(resolve, 100));
105
+ }
106
+ throw new Error(`Treeport did not stop within 7 seconds. See ${localPaths().logPath}.`);
107
+ }
108
+ async function executableCheck(executable, args) {
109
+ return new Promise((resolve) => {
110
+ const child = spawn(executable, args, { stdio: [
111
+ "ignore",
112
+ "pipe",
113
+ "pipe"
114
+ ] });
115
+ let output = "";
116
+ child.stdout.setEncoding("utf8");
117
+ child.stderr.setEncoding("utf8");
118
+ child.stdout.on("data", (chunk) => {
119
+ output += chunk;
120
+ });
121
+ child.stderr.on("data", (chunk) => {
122
+ output += chunk;
123
+ });
124
+ child.once("error", (error) => resolve({
125
+ ok: false,
126
+ detail: error.message
127
+ }));
128
+ child.once("close", (code) => resolve({
129
+ ok: code === 0,
130
+ detail: output.trim() || `exited with status ${code ?? 1}`
131
+ }));
132
+ });
133
+ }
134
+ async function runDoctor() {
135
+ const paths = localPaths();
136
+ const gitPath = process.env.TREEPORT_GIT_PATH?.trim() || "git";
137
+ const tmuxPath = process.env.TREEPORT_TMUX_PATH?.trim() || "tmux";
138
+ const [git, tmux] = await Promise.all([executableCheck(gitPath, ["--version"]), executableCheck(tmuxPath, ["-V"])]);
139
+ const tmuxMatch = /tmux\s+(\d+)\.(\d+)/i.exec(tmux.detail);
140
+ const tmuxSupported = Boolean(tmux.ok && tmuxMatch && (Number(tmuxMatch[1]) > 3 || Number(tmuxMatch[1]) === 3 && Number(tmuxMatch[2]) >= 2));
141
+ const checkDirectory = (directoryPath) => fs.mkdir(directoryPath, {
142
+ recursive: true,
143
+ mode: 448
144
+ }).then(() => ({
145
+ ok: true,
146
+ detail: directoryPath
147
+ })).catch((error) => ({
148
+ ok: false,
149
+ detail: `${directoryPath}: ${error instanceof Error ? error.message : String(error)}`
150
+ }));
151
+ const [dataDirectory, runtimeDirectory] = await Promise.all([checkDirectory(paths.dataDir), checkDirectory(paths.runtimeDir)]);
152
+ return [
153
+ {
154
+ name: "Node",
155
+ ok: true,
156
+ detail: process.version
157
+ },
158
+ {
159
+ name: "Git",
160
+ ...git
161
+ },
162
+ {
163
+ name: "tmux",
164
+ ok: tmuxSupported,
165
+ detail: tmuxSupported ? tmux.detail : `${tmux.detail}. Treeport requires tmux 3.2 or newer.`
166
+ },
167
+ {
168
+ name: "Data directory",
169
+ ...dataDirectory
170
+ },
171
+ {
172
+ name: "Runtime directory",
173
+ ...runtimeDirectory
174
+ }
175
+ ];
176
+ }
177
+ async function daemonStatus() {
178
+ const state = await readState();
179
+ if (!state) return {
180
+ running: false,
181
+ state: null,
182
+ health: null,
183
+ verified: false
184
+ };
185
+ if (!processExists(state.pid)) {
186
+ await removeStaleState(state);
187
+ return {
188
+ running: false,
189
+ state: null,
190
+ health: null,
191
+ verified: false
192
+ };
193
+ }
194
+ const observed = await health(state.apiUrl);
195
+ return {
196
+ running: Boolean(observed),
197
+ state,
198
+ health: observed,
199
+ verified: Boolean(observed && matchesOwnership(state, observed))
200
+ };
201
+ }
202
+ async function daemonUp(options) {
203
+ if (options.port !== void 0 && (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535)) throw new Error("--port must be an integer between 1 and 65535");
204
+ const paths = localPaths();
205
+ const saved = await preferences();
206
+ const next = {
207
+ host: options.host?.trim() || saved.host || DEFAULT_HOST,
208
+ port: options.port ?? saved.port ?? DEFAULT_PORT
209
+ };
210
+ if (options.host !== void 0 || options.port !== void 0) {
211
+ await fs.mkdir(paths.dataDir, {
212
+ recursive: true,
213
+ mode: 448
214
+ });
215
+ const temporaryPath = `${paths.preferencesPath}.${process.pid}.tmp`;
216
+ await fs.writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 384 });
217
+ await fs.rename(temporaryPath, paths.preferencesPath);
218
+ }
219
+ const host = options.host?.trim() || process.env.TREEPORT_HOST?.trim() || next.host;
220
+ const port = Number.parseInt(options.port === void 0 ? process.env.TREEPORT_PORT?.trim() || String(next.port) : String(options.port), 10);
221
+ const apiUrl = options.host !== void 0 || options.port !== void 0 ? listenerUrl(host, port) : process.env.TREEPORT_API_URL?.trim() || listenerUrl(host, port);
222
+ const currentVersion = await treeportVersion();
223
+ const existing = await daemonStatus();
224
+ if (existing.state) {
225
+ if (!existing.running || !existing.verified) throw new Error(`Treeport PID ${existing.state.pid} is running but ownership or health could not be verified. See ${paths.logPath}.`);
226
+ if (existing.health?.version === currentVersion && existing.state.apiUrl === apiUrl) return {
227
+ alreadyRunning: true,
228
+ apiUrl: existing.state.apiUrl,
229
+ pid: existing.state.pid
230
+ };
231
+ await stopOwned(existing.state);
232
+ }
233
+ const failedChecks = (await runDoctor()).filter((check) => !check.ok);
234
+ if (failedChecks.length) throw new Error(failedChecks.map((check) => `${check.name}: ${check.detail}`).join("\n"));
235
+ const serverEntry = await resolvePackagePath("dist", "node", "server", "index.js");
236
+ const webDist = await resolvePackagePath("dist", "web");
237
+ await fs.access(serverEntry);
238
+ await fs.mkdir(path.dirname(paths.logPath), {
239
+ recursive: true,
240
+ mode: 448
241
+ });
242
+ if (await fs.stat(paths.logPath).then((value) => value.size).catch(() => 0) > 5 * 1024 * 1024) {
243
+ await fs.rm(`${paths.logPath}.1`, { force: true });
244
+ await fs.rename(paths.logPath, `${paths.logPath}.1`);
245
+ }
246
+ const instanceId = crypto.randomUUID();
247
+ const childEnvironment = {
248
+ ...process.env,
249
+ TREEPORT_HOST: host,
250
+ TREEPORT_PORT: String(port),
251
+ TREEPORT_API_URL: apiUrl,
252
+ TREEPORT_DATA_DIR: paths.dataDir,
253
+ TREEPORT_RUNTIME_DIR: paths.runtimeDir,
254
+ TREEPORT_APP_VERSION: currentVersion,
255
+ TREEPORT_INSTANCE_ID: instanceId,
256
+ TREEPORT_INSTALLATION_METHOD: process.env.TREEPORT_INSTALLATION_METHOD?.trim() || "npm",
257
+ TREEPORT_WEB_DIST: webDist
258
+ };
259
+ if (options.foreground) {
260
+ console.log(`Treeport will listen on ${apiUrl}`);
261
+ const child = spawn(process.execPath, [serverEntry], {
262
+ env: childEnvironment,
263
+ stdio: "inherit"
264
+ });
265
+ const code = await new Promise((resolve, reject) => {
266
+ child.once("error", reject);
267
+ child.once("close", (value) => resolve(value ?? 1));
268
+ });
269
+ if (code !== 0) throw new Error(`Treeport exited with status ${code}`);
270
+ return {
271
+ alreadyRunning: false,
272
+ apiUrl,
273
+ pid: child.pid ?? 0
274
+ };
275
+ }
276
+ const log = fsSync.openSync(paths.logPath, "a", 384);
277
+ const child = spawn(process.execPath, [serverEntry], {
278
+ env: childEnvironment,
279
+ detached: true,
280
+ stdio: [
281
+ "ignore",
282
+ log,
283
+ log
284
+ ]
285
+ });
286
+ child.unref();
287
+ fsSync.closeSync(log);
288
+ const deadline = Date.now() + 15e3;
289
+ while (Date.now() < deadline) {
290
+ const observed = await health(apiUrl, 500);
291
+ if (observed && observed.pid === child.pid && observed.instanceId === instanceId && observed.version === currentVersion) return {
292
+ alreadyRunning: false,
293
+ apiUrl,
294
+ pid: child.pid ?? observed.pid
295
+ };
296
+ if (child.pid && !processExists(child.pid)) break;
297
+ await new Promise((resolve) => setTimeout(resolve, 100));
298
+ }
299
+ const recentLog = await fs.readFile(paths.logPath, "utf8").then((value) => value.split("\n").slice(-20).join("\n").trim()).catch(() => "");
300
+ throw new Error(`Treeport did not become ready at ${apiUrl}. See ${paths.logPath}.${recentLog ? `\n\n${recentLog}` : ""}`);
301
+ }
302
+ async function daemonDown() {
303
+ const state = await readState();
304
+ if (!state) return { wasRunning: false };
305
+ await stopOwned(state);
306
+ return { wasRunning: true };
307
+ }
308
+ async function readDaemonLogs(lines = 100) {
309
+ return (await fs.readFile(localPaths().logPath, "utf8").catch((error) => {
310
+ if (error.code === "ENOENT") return "";
311
+ throw error;
312
+ })).split("\n").slice(-lines - 1).join("\n");
313
+ }
314
+ //#endregion
315
+ //#region src/cli/index.ts
316
+ const configuredApiUrl = process.env.TREEPORT_API_URL?.trim();
317
+ const apiUrl = (await resolveLocalApiUrl()).replace(/\/$/, "");
318
+ const contextPrefix = "TREEPORT";
319
+ const contextProjectId = process.env.TREEPORT_PROJECT_ID?.trim();
320
+ const contextWorktreeId = process.env.TREEPORT_WORKTREE_ID?.trim();
321
+ const contextTerminalId = process.env.TREEPORT_TERMINAL_ID?.trim();
322
+ const rawArgs = process.argv.slice(2);
323
+ const jsonOutput = extractJsonOutput(rawArgs);
324
+ var CliError = class extends Error {
325
+ exitCode;
326
+ code;
327
+ details;
328
+ constructor(message, exitCode, code, details) {
329
+ super(message);
330
+ this.exitCode = exitCode;
331
+ this.code = code ?? (exitCode === 2 ? "USAGE_ERROR" : exitCode === 3 ? "DAEMON_UNREACHABLE" : exitCode === 4 ? "WAIT_TIMEOUT" : exitCode === 5 ? "DOMAIN_ERROR" : "UNEXPECTED_ERROR");
332
+ this.details = details;
333
+ }
334
+ };
335
+ async function request(pathname, options = {}) {
336
+ const controller = new AbortController();
337
+ const externalSignal = options.signal;
338
+ const abort = () => controller.abort();
339
+ if (externalSignal?.aborted) controller.abort();
340
+ else externalSignal?.addEventListener("abort", abort, { once: true });
341
+ const timeout = setTimeout(abort, 9e4);
342
+ try {
343
+ const response = await fetch(`${apiUrl}${pathname}`, {
344
+ ...options,
345
+ signal: controller.signal,
346
+ headers: {
347
+ accept: "application/json",
348
+ ...options.body ? { "content-type": "application/json" } : {},
349
+ ...options.headers
350
+ }
351
+ });
352
+ const body = await response.json().catch(() => ({}));
353
+ if (!response.ok) {
354
+ const error = body.error;
355
+ throw new CliError(error?.message || `HTTP ${response.status}`, 5, error?.code || "API_ERROR", error?.details);
356
+ }
357
+ return body;
358
+ } catch (error) {
359
+ if (error instanceof CliError) throw error;
360
+ throw new CliError(`Cannot reach Treeport daemon at ${apiUrl}: ${error instanceof Error ? error.message : String(error)}`, 3, "DAEMON_UNREACHABLE");
361
+ } finally {
362
+ clearTimeout(timeout);
363
+ externalSignal?.removeEventListener("abort", abort);
364
+ }
365
+ }
366
+ function commandArgv(args) {
367
+ const separator = args.indexOf("--");
368
+ if (separator === -1) return;
369
+ const argv = args.slice(separator + 1);
370
+ args.splice(separator);
371
+ if (!argv.length) throw new CliError("Expected a command after --", 2);
372
+ return argv;
373
+ }
374
+ async function canonical(value) {
375
+ return fs.realpath(path.resolve(value)).catch(() => path.resolve(value));
376
+ }
377
+ async function projects() {
378
+ return (await request("/api/projects")).projects;
379
+ }
380
+ function pathContains(candidate, parent) {
381
+ const relative = path.relative(parent, candidate);
382
+ return relative === "" || !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative);
383
+ }
384
+ async function resolveProject(identifier) {
385
+ const list = await projects();
386
+ const direct = list.find((project) => project.id === identifier);
387
+ if (direct) return direct;
388
+ if (identifier === "." && contextProjectId) {
389
+ const environmentMatch = list.find((project) => project.id === contextProjectId);
390
+ if (environmentMatch) return environmentMatch;
391
+ }
392
+ const candidate = await canonical(identifier);
393
+ const match = list.find((project) => pathContains(candidate, project.repositoryPath) || project.worktrees.some((worktree) => pathContains(candidate, worktree.path)));
394
+ if (!match) throw new CliError(`No registered project matches ${identifier}`, 5);
395
+ return match;
396
+ }
397
+ async function resolveWorktree(identifier) {
398
+ const all = (await projects()).flatMap((project) => project.worktrees);
399
+ const direct = all.find((worktree) => worktree.id === identifier);
400
+ if (direct) return direct;
401
+ if (identifier === "." && contextWorktreeId) {
402
+ const environmentMatch = all.find((worktree) => worktree.id === contextWorktreeId);
403
+ if (environmentMatch) return environmentMatch;
404
+ }
405
+ const candidate = await canonical(identifier);
406
+ const match = all.filter((worktree) => pathContains(candidate, worktree.path)).sort((a, b) => b.path.length - a.path.length)[0];
407
+ if (!match) throw new CliError(`No registered worktree matches ${identifier}`, 5);
408
+ return match;
409
+ }
410
+ function resolveTerminalId(identifier) {
411
+ if (identifier !== ".") return identifier;
412
+ const terminalId = contextTerminalId;
413
+ if (!terminalId) {
414
+ const variable = `${contextPrefix}_TERMINAL_ID`;
415
+ throw new CliError(`Cannot resolve . without ${variable}`, 5, "TREEPORT_CONTEXT_INCOMPLETE", { missing: [variable] });
416
+ }
417
+ return terminalId;
418
+ }
419
+ function parseCaptureLines(value) {
420
+ if (!/^[1-9]\d*$/.test(value)) throw new CliError(`--lines must be an integer between 1 and ${TERMINAL_CAPTURE_MAX_LINES}`, 2);
421
+ const lines = Number(value);
422
+ if (!Number.isSafeInteger(lines) || lines > 5e3) throw new CliError(`--lines must be an integer between 1 and ${TERMINAL_CAPTURE_MAX_LINES}`, 2);
423
+ return lines;
424
+ }
425
+ function parseDuration(value) {
426
+ const match = /^(\d+)(ms|s|m|h)$/.exec(value);
427
+ if (!match) throw new CliError("Timeout must be a positive duration such as 500ms, 30s, 5m, or 1h", 2);
428
+ const timeoutMs = Number(match[1]) * {
429
+ ms: 1,
430
+ s: 1e3,
431
+ m: 6e4,
432
+ h: 36e5
433
+ }[match[2]];
434
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2147483647) throw new CliError("Timeout must be between 1ms and 2147483647ms", 2);
435
+ return timeoutMs;
436
+ }
437
+ async function inspectTerminal(terminalId, signal) {
438
+ return request(`/api/terminals/${encodeURIComponent(terminalId)}`, signal ? { signal } : {});
439
+ }
440
+ async function waitForTerminal(terminalId, condition, timeoutMs) {
441
+ let observation = null;
442
+ let bellBaseline = null;
443
+ const matched = (observedAt) => {
444
+ if (!observation) return null;
445
+ if (!(condition === "idle" && observation.metadata.progress === null || condition === "working" && observation.metadata.progress !== null || condition === "exit" && observation.terminal.status === "exited" || condition === "bell" && bellBaseline !== null && (observation.metadata.bell?.sequence ?? 0) > bellBaseline)) return null;
446
+ return {
447
+ condition,
448
+ observedAt: condition === "bell" ? observation.metadata.bell?.at ?? observedAt : observedAt,
449
+ ...observation
450
+ };
451
+ };
452
+ const controller = new AbortController();
453
+ let cancellation = null;
454
+ const interrupt = () => {
455
+ cancellation = "interrupt";
456
+ controller.abort();
457
+ };
458
+ process.once("SIGINT", interrupt);
459
+ const timeout = timeoutMs === void 0 ? null : setTimeout(() => {
460
+ cancellation = "timeout";
461
+ controller.abort();
462
+ }, timeoutMs);
463
+ timeout?.unref();
464
+ let events = null;
465
+ try {
466
+ observation = await inspectTerminal(terminalId, controller.signal);
467
+ if (condition === "bell") bellBaseline = observation.metadata.bell?.sequence ?? 0;
468
+ const immediate = matched((/* @__PURE__ */ new Date()).toISOString());
469
+ if (immediate) return immediate;
470
+ events = io(`${apiUrl}/events`, {
471
+ path: SOCKET_IO_PATH,
472
+ transports: ["websocket"],
473
+ forceNew: true,
474
+ autoConnect: false,
475
+ reconnection: false,
476
+ retries: 0
477
+ });
478
+ return await new Promise((resolve, reject) => {
479
+ let settled = false;
480
+ let queue = Promise.resolve();
481
+ const finish = (result) => {
482
+ if (!settled) {
483
+ settled = true;
484
+ resolve(result);
485
+ }
486
+ };
487
+ const fail = (error) => {
488
+ if (!settled) {
489
+ settled = true;
490
+ reject(error);
491
+ }
492
+ };
493
+ const enqueue = (task) => {
494
+ queue = queue.then(task);
495
+ queue.catch(fail);
496
+ };
497
+ controller.signal.addEventListener("abort", () => fail(/* @__PURE__ */ new Error("Terminal wait cancelled")), { once: true });
498
+ events.on("snapshot", (value) => enqueue(async () => {
499
+ const snapshot = parseEventsSnapshot(value);
500
+ if (!snapshot || !observation) throw new CliError("Treeport daemon sent an invalid event snapshot", 3, "DAEMON_PROTOCOL_ERROR");
501
+ const metadata = snapshot.terminalMetadata.find((item) => item.terminalId === terminalId);
502
+ if (metadata) observation = {
503
+ ...observation,
504
+ metadata
505
+ };
506
+ const snapshotMatch = matched(snapshot.at);
507
+ if (snapshotMatch) {
508
+ finish(snapshotMatch);
509
+ return;
510
+ }
511
+ observation = await inspectTerminal(terminalId, controller.signal);
512
+ if (cancellation) throw new Error("Terminal wait cancelled");
513
+ const refreshedMatch = matched((/* @__PURE__ */ new Date()).toISOString());
514
+ if (refreshedMatch) finish(refreshedMatch);
515
+ }));
516
+ events.on("product_event", (value) => enqueue(async () => {
517
+ const event = parseProductEvent(value);
518
+ if (!event) throw new CliError("Treeport daemon sent an invalid product event", 3, "DAEMON_PROTOCOL_ERROR");
519
+ if (event.type !== "terminal.metadata" && event.type !== "terminal.updated" && event.type !== "terminal.removed") return;
520
+ if (event.data.terminalId !== terminalId) return;
521
+ if (event.type === "terminal.removed") throw new CliError(`Terminal ${terminalId} was removed while waiting`, 5, "TERMINAL_REMOVED", {
522
+ terminalId,
523
+ condition
524
+ });
525
+ if (event.type === "terminal.metadata") {
526
+ const metadata = parseTerminalRuntimeMetadata(event.data);
527
+ if (!metadata || !observation) throw new CliError("Treeport daemon sent invalid terminal metadata", 3, "DAEMON_PROTOCOL_ERROR");
528
+ observation = {
529
+ ...observation,
530
+ metadata
531
+ };
532
+ } else {
533
+ observation = await inspectTerminal(terminalId, controller.signal);
534
+ if (cancellation) throw new Error("Terminal wait cancelled");
535
+ }
536
+ const result = matched(event.at);
537
+ if (result) finish(result);
538
+ }));
539
+ events.on("connect_error", (error) => fail(new CliError(`Cannot reach Treeport daemon at ${apiUrl}: ${error.message}`, 3, "DAEMON_UNREACHABLE")));
540
+ events.on("disconnect", () => fail(new CliError("Treeport daemon event channel disconnected before the condition was observed", 3, "DAEMON_DISCONNECTED")));
541
+ events.connect();
542
+ });
543
+ } catch (error) {
544
+ if (cancellation === "timeout") throw new CliError(`Timed out waiting for terminal ${terminalId} to reach ${condition}`, 4, "WAIT_TIMEOUT", {
545
+ terminalId,
546
+ until: condition,
547
+ timeoutMs,
548
+ lastObservation: observation
549
+ });
550
+ if (cancellation === "interrupt") throw new CliError("Interrupted", 130, "INTERRUPTED");
551
+ if (error instanceof CliError) throw error;
552
+ throw new CliError(`Treeport daemon event channel failed: ${error instanceof Error ? error.message : String(error)}`, 3, "DAEMON_DISCONNECTED");
553
+ } finally {
554
+ if (timeout) clearTimeout(timeout);
555
+ process.off("SIGINT", interrupt);
556
+ controller.abort();
557
+ events?.removeAllListeners();
558
+ events?.disconnect();
559
+ }
560
+ }
561
+ function print(value, human) {
562
+ if (jsonOutput) console.log(JSON.stringify(value));
563
+ else console.log(human ? human() : JSON.stringify(value, null, 2));
564
+ }
565
+ const agentGuidance = `AI agents:
566
+ If you're an AI agent, use \`treeport skills\` to see the usage guide.
567
+ `;
568
+ async function main(args) {
569
+ const argv = args[0] === "spawn" || args[0] === "terminal" && args[1] === "create" ? commandArgv(args) : void 0;
570
+ let parserError = "";
571
+ const program = new Command().name("treeport").description("Manage Treeport projects, worktrees, and terminals.").option("--json", "emit machine-readable JSON").addHelpText("beforeAll", agentGuidance).configureOutput({ writeErr: (value) => {
572
+ parserError += value;
573
+ } }).showHelpAfterError().exitOverride();
574
+ program.action(() => {
575
+ process.stdout.write(program.helpInformation());
576
+ });
577
+ const upCommand = program.command("up").description("Ensure the local Treeport daemon is running").option("--host <address>", "listener address").option("--port <port>", "listener port").option("--foreground", "run in the foreground").option("--json", "emit machine-readable JSON");
578
+ upCommand.action(async () => {
579
+ const options = upCommand.opts();
580
+ const port = options.port === void 0 ? void 0 : Number(options.port);
581
+ const result = await daemonUp({
582
+ ...options.host === void 0 ? {} : { host: options.host },
583
+ ...port === void 0 ? {} : { port },
584
+ ...options.foreground === void 0 ? {} : { foreground: options.foreground }
585
+ });
586
+ if (options.foreground) return;
587
+ print(result, () => `Treeport is up\n${result.apiUrl}`);
588
+ const listenerHost = new URL(result.apiUrl).hostname;
589
+ if (![
590
+ "127.0.0.1",
591
+ "::1",
592
+ "[::1]",
593
+ "localhost"
594
+ ].includes(listenerHost)) process.stderr.write("Warning: Treeport has no authentication. Use only a trusted private network.\n");
595
+ });
596
+ const downCommand = program.command("down").description("Stop the local daemon and preserve terminal sessions").option("--terminate-terminals", "terminate every Treeport-owned tmux server").option("--force", "confirm termination of all terminals").option("--json", "emit machine-readable JSON");
597
+ downCommand.action(async () => {
598
+ const options = downCommand.opts();
599
+ if (options.terminateTerminals && !options.force) throw new CliError("Re-run with --terminate-terminals --force to confirm termination of every terminal.", 2);
600
+ if (options.terminateTerminals) await request("/api/admin/terminate-terminals", { method: "POST" });
601
+ const result = await daemonDown();
602
+ print(result, () => result.wasRunning ? "Treeport is down" : "Treeport is already down");
603
+ });
604
+ program.command("status").description("Show local daemon status").option("--json", "emit machine-readable JSON").action(async () => {
605
+ const status = await daemonStatus();
606
+ const projectList = status.verified ? await projects() : [];
607
+ const result = {
608
+ ...status,
609
+ projects: projectList.length,
610
+ worktrees: projectList.reduce((count, project) => count + project.worktrees.length, 0),
611
+ terminals: projectList.reduce((count, project) => count + project.worktrees.reduce((worktreeCount, worktree) => worktreeCount + worktree.terminals.length, 0), 0)
612
+ };
613
+ print(result, () => {
614
+ if (!status.state) return "Treeport is down";
615
+ if (!status.running || !status.verified) return `Treeport is unhealthy (PID ${status.state.pid})\nLogs: ${path.join(status.state.dataDir, "logs", "daemon.log")}`;
616
+ return `Treeport is up\n${status.state.apiUrl}\nVersion: ${status.health?.version}\nPID: ${status.state.pid}\nProjects: ${result.projects}\nWorktrees: ${result.worktrees}\nTerminals: ${result.terminals}`;
617
+ });
618
+ });
619
+ const logsCommand = program.command("logs").description("Show recent daemon logs").option("--lines <count>", "number of lines", "100");
620
+ logsCommand.action(async () => {
621
+ const lines = Number(logsCommand.opts().lines);
622
+ if (!Number.isInteger(lines) || lines < 1 || lines > 1e4) throw new CliError("--lines must be an integer between 1 and 10000", 2);
623
+ process.stdout.write(await readDaemonLogs(lines));
624
+ });
625
+ program.command("doctor").description("Diagnose local requirements and paths").option("--json", "emit machine-readable JSON").action(async () => {
626
+ const checks = await runDoctor();
627
+ print(checks, () => checks.map((check) => `${check.ok ? "ok" : "error"}\t${check.name}\t${check.detail}`).join("\n"));
628
+ if (checks.some((check) => !check.ok)) process.exitCode = 1;
629
+ });
630
+ program.command("version").description("Show CLI and daemon versions").option("--json", "emit machine-readable JSON").action(async () => {
631
+ const [cli, status] = await Promise.all([treeportVersion(), daemonStatus()]);
632
+ const result = {
633
+ cli,
634
+ daemon: status.verified ? status.health?.version ?? null : null
635
+ };
636
+ print(result, () => `CLI: ${result.cli}\nDaemon: ${result.daemon ?? "not running"}`);
637
+ });
638
+ program.command("skills").description("Print the Treeport usage guide for AI agents").action(async () => {
639
+ process.stdout.write(await fs.readFile(await resolvePackagePath("skills", "treeport", "SKILL.md"), "utf8"));
640
+ });
641
+ program.command("context").description("Show the current Treeport-managed terminal context").option("--json", "emit machine-readable JSON").action(async () => {
642
+ const projectId = contextProjectId;
643
+ const worktreeId = contextWorktreeId;
644
+ const terminalId = contextTerminalId;
645
+ if (![
646
+ projectId,
647
+ worktreeId,
648
+ terminalId
649
+ ].filter(Boolean).length) {
650
+ print({
651
+ managed: false,
652
+ reason: "outside_treeport"
653
+ }, () => "Not running in a Treeport-managed terminal.");
654
+ return;
655
+ }
656
+ const missing = [
657
+ ...!configuredApiUrl ? [`${contextPrefix}_API_URL`] : [],
658
+ ...!projectId ? [`${contextPrefix}_PROJECT_ID`] : [],
659
+ ...!worktreeId ? [`${contextPrefix}_WORKTREE_ID`] : [],
660
+ ...!terminalId ? [`${contextPrefix}_TERMINAL_ID`] : []
661
+ ];
662
+ if (missing.length) throw new CliError(`Incomplete Treeport context; missing ${missing.join(", ")}`, 5, "TREEPORT_CONTEXT_INCOMPLETE", { missing });
663
+ const project = (await request(`/api/projects/${encodeURIComponent(projectId)}`)).project;
664
+ const worktree = project.worktrees.find((candidate) => candidate.id === worktreeId);
665
+ if (!worktree) throw new CliError("Treeport context worktree does not belong to the current project", 5, "TREEPORT_CONTEXT_INVALID", {
666
+ projectId,
667
+ worktreeId
668
+ });
669
+ const terminal = worktree.terminals.find((candidate) => candidate.id === terminalId);
670
+ if (!terminal) throw new CliError("Treeport context terminal does not belong to the current worktree", 5, "TREEPORT_CONTEXT_INVALID", {
671
+ worktreeId,
672
+ terminalId
673
+ });
674
+ const context = {
675
+ managed: true,
676
+ apiUrl,
677
+ project: {
678
+ id: project.id,
679
+ name: project.name,
680
+ repositoryPath: project.repositoryPath,
681
+ mainWorktreePath: project.mainWorktreePath,
682
+ defaultBranch: project.defaultBranch,
683
+ availability: project.availability
684
+ },
685
+ worktree: {
686
+ id: worktree.id,
687
+ projectId: worktree.projectId,
688
+ name: worktree.name,
689
+ path: worktree.path,
690
+ head: worktree.head,
691
+ branch: worktree.branch,
692
+ detached: worktree.detached,
693
+ kind: worktree.kind,
694
+ status: worktree.status
695
+ },
696
+ terminal: {
697
+ id: terminal.id,
698
+ worktreeId: terminal.worktreeId,
699
+ name: terminal.name,
700
+ status: terminal.status,
701
+ exitCode: terminal.exitCode
702
+ }
703
+ };
704
+ print(context, () => `Treeport context\n\nProject: ${context.project.name} (${context.project.id})\nWorktree: ${context.worktree.name} (${context.worktree.id})\nPath: ${context.worktree.path}\nTerminal: ${context.terminal.name} (${context.terminal.id}) — ${context.terminal.status}\nAPI: ${context.apiUrl}`);
705
+ });
706
+ const projectCommand = program.command("project").description("Register and list projects");
707
+ projectCommand.action(() => {
708
+ throw new CliError(projectCommand.helpInformation(), 2);
709
+ });
710
+ projectCommand.command("add").description("Register a Git repository").argument("<path>", "repository path").option("--json", "emit machine-readable JSON").action(async (repository) => {
711
+ const body = await request("/api/projects", {
712
+ method: "POST",
713
+ body: JSON.stringify({ path: await canonical(repository) })
714
+ });
715
+ print(body.project, () => `Registered ${body.project.name} (${body.project.id})\n${body.project.repositoryPath}`);
716
+ });
717
+ projectCommand.command("list").description("List registered projects").option("--json", "emit machine-readable JSON").action(async () => {
718
+ const list = await projects();
719
+ print(list, () => list.map((project) => `${project.id}\t${project.name}\t${project.repositoryPath}`).join("\n"));
720
+ });
721
+ const worktreeCommand = program.command("worktree").description("List, create, and remove worktrees");
722
+ worktreeCommand.action(() => {
723
+ throw new CliError(worktreeCommand.helpInformation(), 2);
724
+ });
725
+ const worktreeListCommand = worktreeCommand.command("list").description("List discovered worktrees").option("--project <id-or-path>", "limit results to a project").option("--json", "emit machine-readable JSON");
726
+ worktreeListCommand.action(async () => {
727
+ const { project: projectIdentifier } = worktreeListCommand.opts();
728
+ const list = projectIdentifier ? (await resolveProject(projectIdentifier)).worktrees : (await projects()).flatMap((project) => project.worktrees);
729
+ print(list, () => list.map((worktree) => `${worktree.id}\t${worktree.name}\t${worktree.branch ?? `detached@${worktree.head.slice(0, 8)}`}\t${worktree.status}\t${worktree.path}`).join("\n"));
730
+ });
731
+ const worktreeCreateCommand = worktreeCommand.command("create").description("Create a linked worktree").requiredOption("--project <id-or-path>", "project to create from").requiredOption("--name <name>", "worktree name").option("--from-current", "base the worktree on the current worktree").option("--json", "emit machine-readable JSON");
732
+ worktreeCreateCommand.action(async () => {
733
+ const options = worktreeCreateCommand.opts();
734
+ const project = await resolveProject(options.project);
735
+ const sourceWorktreeId = options.fromCurrent ? (await resolveWorktree(".")).id : void 0;
736
+ const result = await request(`/api/projects/${project.id}/worktrees`, {
737
+ method: "POST",
738
+ body: JSON.stringify({
739
+ name: options.name,
740
+ base: options.fromCurrent ? "current" : "default",
741
+ ...sourceWorktreeId ? { sourceWorktreeId } : {}
742
+ })
743
+ });
744
+ print(result, () => `Created ${result.worktree.name} (${result.worktree.id})\n${result.worktree.path}${result.setupError ? `\nSetup error: ${result.setupError}` : ""}`);
745
+ });
746
+ const worktreeRemoveCommand = worktreeCommand.command("remove").description("Remove a linked worktree").argument("<id-or-path-or-dot>", "worktree to remove").option("--force", "confirm destructive removal warnings").option("--json", "emit machine-readable JSON");
747
+ worktreeRemoveCommand.action(async (identifier) => {
748
+ const { force: confirmed } = worktreeRemoveCommand.opts();
749
+ const worktree = await resolveWorktree(identifier);
750
+ const preview = (await request(`/api/worktrees/${worktree.id}/remove-preview`)).preview;
751
+ if (!preview.eligible) throw new CliError(preview.reasons.join("\n"), 5);
752
+ if (preview.warnings.length && !confirmed) throw new CliError(`${preview.warnings.join("\n")}\nRe-run with --force to confirm removal.`, 5);
753
+ const result = await request(`/api/worktrees/${worktree.id}/remove`, {
754
+ method: "POST",
755
+ body: JSON.stringify({
756
+ confirmationToken: preview.confirmationToken,
757
+ confirmDestructive: preview.warnings.length > 0
758
+ })
759
+ });
760
+ print(result.operation, () => `Remove accepted: ${result.operation.id}`);
761
+ });
762
+ const terminalCommand = program.command("terminal").description("Manage persistent worktree terminals");
763
+ terminalCommand.action(() => {
764
+ throw new CliError(terminalCommand.helpInformation(), 2);
765
+ });
766
+ const terminalListCommand = terminalCommand.command("list").description("List terminals").option("--worktree <id-or-path>", "limit results to a worktree").option("--json", "emit machine-readable JSON");
767
+ terminalListCommand.action(async () => {
768
+ const { worktree: identifier } = terminalListCommand.opts();
769
+ const list = identifier ? (await resolveWorktree(identifier)).terminals : (await projects()).flatMap((project) => project.worktrees.flatMap((worktree) => worktree.terminals));
770
+ print(list, () => list.map((terminal) => `${terminal.id}\t${terminal.name}\t${terminal.status}\t${JSON.stringify(terminal.argv)}`).join("\n"));
771
+ });
772
+ const terminalCreateCommand = terminalCommand.command("create").description("Create a persistent terminal").usage("[options] [-- <command> args...]").requiredOption("--worktree <id-or-path-or-dot>", "owning worktree").requiredOption("--name <name>", "terminal name").option("--json", "emit machine-readable JSON").addHelpText("after", "\nCommand arguments may be passed after --.\n");
773
+ terminalCreateCommand.action(async () => {
774
+ const options = terminalCreateCommand.opts();
775
+ const result = await request(`/api/worktrees/${(await resolveWorktree(options.worktree)).id}/terminals`, {
776
+ method: "POST",
777
+ body: JSON.stringify({
778
+ name: options.name,
779
+ ...argv ? { argv } : {}
780
+ })
781
+ });
782
+ print(result.terminal, () => `Created ${result.terminal.name} (${result.terminal.id})`);
783
+ });
784
+ terminalCommand.command("inspect").description("Inspect terminal status and runtime metadata").argument("<terminal-id-or-dot>", "terminal to inspect").option("--json", "emit machine-readable JSON").action(async (identifier) => {
785
+ const observation = await inspectTerminal(resolveTerminalId(identifier));
786
+ print(observation, () => {
787
+ const { terminal, metadata } = observation;
788
+ const progress = metadata.progress ? `working (${metadata.progress.state}${metadata.progress.value === null ? "" : ` ${metadata.progress.value}%`})` : "idle";
789
+ const status = terminal.status === "exited" ? `exited${terminal.exitCode === null ? "" : ` (${terminal.exitCode})`}` : terminal.status;
790
+ return `Terminal: ${terminal.name} (${terminal.id})\nStatus: ${status}\nTitle: ${metadata.title ?? "—"}\nProgress: ${progress}\nStarted: ${metadata.progressStartedAt ?? "—"}\nCleared: ${metadata.progressClearedAt ?? "—"}\nBell: ${metadata.bell ? `${metadata.bell.at} (#${metadata.bell.sequence})` : "—"}`;
791
+ });
792
+ });
793
+ const terminalCaptureCommand = terminalCommand.command("capture").description("Capture recent terminal output").argument("<terminal-id-or-dot>", "terminal to capture").option("--lines <count>", "number of lines to capture").option("--json", "emit machine-readable JSON");
794
+ terminalCaptureCommand.action(async (identifier) => {
795
+ const { lines: rawLines } = terminalCaptureCommand.opts();
796
+ const lines = rawLines === void 0 ? 200 : parseCaptureLines(rawLines);
797
+ const terminalId = resolveTerminalId(identifier);
798
+ const capture = await request(`/api/terminals/${encodeURIComponent(terminalId)}/capture?lines=${lines}`);
799
+ if (jsonOutput) print(capture);
800
+ else {
801
+ process.stdout.write(capture.content);
802
+ if (capture.content && !capture.content.endsWith("\n")) process.stdout.write("\n");
803
+ }
804
+ });
805
+ const terminalWaitCommand = terminalCommand.command("wait").description("Wait for a terminal runtime condition").argument("<terminal-id-or-dot>", "terminal to observe").requiredOption("--until <idle|working|bell|exit>", "condition to wait for").option("--timeout <duration>", "maximum wait, such as 30s or 5m").option("--json", "emit machine-readable JSON");
806
+ terminalWaitCommand.action(async (identifier) => {
807
+ const options = terminalWaitCommand.opts();
808
+ if (![
809
+ "idle",
810
+ "working",
811
+ "bell",
812
+ "exit"
813
+ ].includes(options.until)) throw new CliError("--until must be one of idle, working, bell, or exit", 2);
814
+ const result = await waitForTerminal(resolveTerminalId(identifier), options.until, options.timeout === void 0 ? void 0 : parseDuration(options.timeout));
815
+ print(result, () => `${result.terminal.name} (${result.terminal.id}) reached ${result.condition} at ${result.observedAt}`);
816
+ });
817
+ terminalCommand.command("delete").description("Delete a terminal").argument("<terminal-id>", "terminal to delete").option("--json", "emit machine-readable JSON").action(async (terminalId) => {
818
+ await request(`/api/terminals/${terminalId}`, { method: "DELETE" });
819
+ print({
820
+ ok: true,
821
+ terminalId
822
+ }, () => `Deleted ${terminalId}`);
823
+ });
824
+ const spawnCommand = program.command("spawn").description("Create a worktree and its first terminal").usage("[options] [-- <command> args...]").requiredOption("--project <id-or-path-or-dot>", "project to create from").requiredOption("--worktree-name <name>", "worktree name").requiredOption("--name <terminal-name>", "terminal name").option("--from-current", "base the worktree on the current worktree").option("--json", "emit machine-readable JSON").addHelpText("after", "\nCommand arguments may be passed after --.\n");
825
+ spawnCommand.action(async () => {
826
+ const options = spawnCommand.opts();
827
+ const project = await resolveProject(options.project);
828
+ const sourceWorktreeId = options.fromCurrent ? (await resolveWorktree(".")).id : void 0;
829
+ const result = await request("/api/spawn", {
830
+ method: "POST",
831
+ body: JSON.stringify({
832
+ project: project.id,
833
+ worktreeName: options.worktreeName,
834
+ name: options.name,
835
+ base: options.fromCurrent ? "current" : "default",
836
+ ...sourceWorktreeId ? { sourceWorktreeId } : {},
837
+ ...argv ? { argv } : {}
838
+ })
839
+ });
840
+ print(result, () => `Created worktree ${result.worktree.name} (${result.worktree.id})\nPath: ${result.worktree.path}\n${result.terminal ? `Terminal: ${result.terminal.name} (${result.terminal.id}) — ${result.terminal.status}` : "Terminal: not created"}${result.setupError ? `\nSetup error: ${result.setupError}` : ""}${result.terminalError ? `\nTerminal error: ${result.terminalError}` : ""}`);
841
+ });
842
+ try {
843
+ await program.parseAsync(args, { from: "user" });
844
+ } catch (error) {
845
+ if (error instanceof CommanderError) {
846
+ if (error.exitCode === 0) return;
847
+ throw new CliError(parserError.trim() || error.message, 2, "USAGE_ERROR");
848
+ }
849
+ throw error;
850
+ }
851
+ }
852
+ main(rawArgs).catch((error) => {
853
+ const cliError = error instanceof CliError ? error : new CliError(error instanceof Error ? error.message : String(error), 1);
854
+ if (jsonOutput) {
855
+ const body = { error: {
856
+ code: cliError.code,
857
+ message: cliError.message,
858
+ ...cliError.details === void 0 ? {} : { details: cliError.details }
859
+ } };
860
+ process.stderr.write(`${JSON.stringify(body)}\n`);
861
+ } else process.stderr.write(`${cliError.message}\n`);
862
+ process.exitCode = cliError.exitCode;
863
+ });
864
+ //#endregion
865
+ export {};