@treeport/treeport 0.1.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,12 @@
1
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";
2
+ import { A as parseProductEvent, j as SOCKET_IO_PATH, k as parseEventsSnapshot, n as parseDurationMs, r as TERMINAL_CAPTURE_MAX_LINES, t as assertLoopbackHost } from "../../loopback-4XVZbAD1.js";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { Command, CommanderError } from "commander";
6
6
  import { io } from "socket.io-client";
7
- import crypto from "node:crypto";
7
+ import { z } from "zod";
8
8
  import { spawn } from "node:child_process";
9
+ import crypto from "node:crypto";
9
10
  import fsSync from "node:fs";
10
11
  import os from "node:os";
11
12
  import { fileURLToPath } from "node:url";
@@ -18,6 +19,69 @@ function extractJsonOutput(args) {
18
19
  return true;
19
20
  }
20
21
  //#endregion
22
+ //#region src/cli/open.ts
23
+ const DESKTOP_BUNDLE_ID = "tech.noice.treeport";
24
+ const LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set([
25
+ "127.0.0.1",
26
+ "localhost",
27
+ "[::1]"
28
+ ]);
29
+ var OpenWorkspaceError = class extends Error {};
30
+ function defaultLaunch(executable, args) {
31
+ return new Promise((resolve) => {
32
+ const child = spawn(executable, args, { stdio: [
33
+ "ignore",
34
+ "ignore",
35
+ "pipe"
36
+ ] });
37
+ let settled = false;
38
+ let stderr = "";
39
+ child.stderr.setEncoding("utf8");
40
+ child.stderr.on("data", (chunk) => {
41
+ stderr = `${stderr}${chunk}`.slice(-4096);
42
+ });
43
+ child.once("error", (error) => {
44
+ if (!settled) {
45
+ settled = true;
46
+ resolve({
47
+ code: null,
48
+ stderr: error.message
49
+ });
50
+ }
51
+ });
52
+ child.once("close", (code) => {
53
+ if (!settled) {
54
+ settled = true;
55
+ resolve({
56
+ code,
57
+ stderr: stderr.trim()
58
+ });
59
+ }
60
+ });
61
+ });
62
+ }
63
+ function desktopCanOpen(workspaceUrl) {
64
+ if (!URL.canParse(workspaceUrl)) return false;
65
+ const url = new URL(workspaceUrl);
66
+ return !url.username && !url.password && (url.protocol === "https:" || url.protocol === "http:" && LOOPBACK_HOSTNAMES.has(url.hostname.toLowerCase()));
67
+ }
68
+ async function openWorkspace(workspaceUrl, options = {}) {
69
+ const platform = options.platform ?? process.platform;
70
+ const launch = options.launch ?? defaultLaunch;
71
+ if (platform === "darwin" && desktopCanOpen(workspaceUrl)) {
72
+ const deepLink = new URL("treeport://open");
73
+ deepLink.searchParams.set("url", workspaceUrl);
74
+ if ((await launch("open", [
75
+ "-b",
76
+ DESKTOP_BUNDLE_ID,
77
+ deepLink.href
78
+ ])).code === 0) return { client: "desktop" };
79
+ }
80
+ const browser = await launch(platform === "darwin" ? "open" : "xdg-open", [workspaceUrl]);
81
+ if (browser.code === 0) return { client: "browser" };
82
+ throw new OpenWorkspaceError(`Treeport registered the folder, but could not open it automatically.${browser.stderr ? ` ${browser.stderr}` : ""}\nOpen this URL manually: ${workspaceUrl}`);
83
+ }
84
+ //#endregion
21
85
  //#region src/cli/lifecycle.ts
22
86
  const DEFAULT_HOST = "127.0.0.1";
23
87
  const DEFAULT_PORT = 8733;
@@ -40,17 +104,62 @@ function localPaths(env = process.env) {
40
104
  logPath: path.join(dataDir, "logs", "daemon.log")
41
105
  };
42
106
  }
43
- async function readJson(filePath) {
44
- return fs.readFile(filePath, "utf8").then((value) => JSON.parse(value)).catch(() => null);
107
+ async function readJson(filePath, schema) {
108
+ return fs.readFile(filePath, "utf8").then((value) => schema.parse(JSON.parse(value))).catch(() => null);
109
+ }
110
+ const preferencesSchema = z.looseObject({
111
+ host: z.string().optional(),
112
+ port: z.number().optional(),
113
+ remote: z.strictObject({
114
+ port: z.number(),
115
+ target: z.string()
116
+ }).optional()
117
+ });
118
+ const daemonRecordSchema = z.strictObject({
119
+ pid: z.number(),
120
+ instanceId: z.string(),
121
+ version: z.string(),
122
+ apiUrl: z.string(),
123
+ dataDir: z.string(),
124
+ startedAt: z.string(),
125
+ installationMethod: z.string()
126
+ });
127
+ const healthRecordSchema = z.strictObject({
128
+ ok: z.literal(true),
129
+ version: z.string(),
130
+ protocolVersion: z.number(),
131
+ hostname: z.string().optional(),
132
+ pid: z.number(),
133
+ instanceId: z.string().nullable(),
134
+ installationMethod: z.string(),
135
+ daemonLifecycle: z.enum(["treeport", "external"]),
136
+ url: z.string()
137
+ });
138
+ async function preferences(env = process.env) {
139
+ return await readJson(localPaths(env).preferencesPath, preferencesSchema) ?? {};
45
140
  }
46
- async function preferences() {
47
- return await readJson(localPaths().preferencesPath) ?? {};
141
+ async function savePreferences(value) {
142
+ const paths = localPaths();
143
+ await fs.mkdir(paths.dataDir, {
144
+ recursive: true,
145
+ mode: 448
146
+ });
147
+ const temporaryPath = `${paths.preferencesPath}.${process.pid}.tmp`;
148
+ await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
149
+ await fs.rename(temporaryPath, paths.preferencesPath);
48
150
  }
49
- async function resolveLocalApiUrl() {
50
- const explicit = process.env.TREEPORT_API_URL?.trim();
151
+ async function resolveLocalApiUrl(env = process.env) {
152
+ const explicit = env.TREEPORT_API_URL?.trim();
153
+ const managedApiUrl = env.TREEPORT_MANAGED_API_URL?.trim();
154
+ const daemonRecordPath = env.TREEPORT_DAEMON_RECORD?.trim();
155
+ if (explicit && explicit !== managedApiUrl) return explicit.replace(/\/$/, "");
156
+ if (managedApiUrl && daemonRecordPath) {
157
+ const record = await readJson(path.resolve(expandHome(daemonRecordPath)), daemonRecordSchema);
158
+ if (record) return record.apiUrl.replace(/\/$/, "");
159
+ }
51
160
  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));
161
+ const saved = await preferences(env);
162
+ return listenerUrl(env.TREEPORT_HOST?.trim() || env.HOST?.trim() || saved.host || DEFAULT_HOST, Number.parseInt(env.TREEPORT_PORT?.trim() || env.PORT?.trim() || String(saved.port ?? DEFAULT_PORT), 10));
54
163
  }
55
164
  async function resolvePackagePath(...segments) {
56
165
  const candidates = [fileURLToPath(new URL("../../../", import.meta.url)), fileURLToPath(new URL("../../", import.meta.url))];
@@ -58,7 +167,7 @@ async function resolvePackagePath(...segments) {
58
167
  throw new Error("Could not locate the Treeport package directory");
59
168
  }
60
169
  async function treeportVersion() {
61
- return (await readJson(await resolvePackagePath("package.json")))?.version ?? "development";
170
+ return (await readJson(await resolvePackagePath("package.json"), z.looseObject({ version: z.string().optional() })))?.version ?? "development";
62
171
  }
63
172
  function processExists(pid) {
64
173
  try {
@@ -68,31 +177,30 @@ function processExists(pid) {
68
177
  return error.code === "EPERM";
69
178
  }
70
179
  }
71
- async function health(apiUrl, timeoutMs = 1500) {
180
+ async function daemonHealth(apiUrl, timeoutMs = 1500) {
72
181
  const signal = AbortSignal.timeout(timeoutMs);
73
182
  return fetch(`${apiUrl}/api/health`, { signal }).then(async (response) => {
74
183
  if (!response.ok) return null;
75
- const value = await response.json();
76
- return value.ok && typeof value.pid === "number" ? value : null;
184
+ const result = healthRecordSchema.safeParse(await response.json());
185
+ return result.success ? result.data : null;
77
186
  }).catch(() => null);
78
187
  }
79
188
  function matchesOwnership(state, observed) {
80
189
  return observed.pid === state.pid && observed.instanceId === state.instanceId && path.resolve(state.dataDir) === localPaths().dataDir;
81
190
  }
82
191
  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;
192
+ return readJson(localPaths().statePath, daemonRecordSchema);
85
193
  }
86
194
  async function removeStaleState(state) {
87
195
  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 });
196
+ for (const filePath of [paths.statePath, paths.lockPath]) if ((await readJson(filePath, z.looseObject({ instanceId: z.string() })))?.instanceId === state.instanceId) await fs.rm(filePath, { force: true });
89
197
  }
90
198
  async function stopOwned(state) {
91
199
  if (!processExists(state.pid)) {
92
200
  await removeStaleState(state);
93
201
  return;
94
202
  }
95
- const observed = await health(state.apiUrl);
203
+ const observed = await daemonHealth(state.apiUrl);
96
204
  if (!observed || !matchesOwnership(state, observed)) throw new Error(`Refusing to stop PID ${state.pid}: Treeport could not verify ownership. Check ${localPaths().statePath}.`);
97
205
  process.kill(state.pid, "SIGTERM");
98
206
  const deadline = Date.now() + 7e3;
@@ -131,6 +239,168 @@ async function executableCheck(executable, args) {
131
239
  }));
132
240
  });
133
241
  }
242
+ const tailscaleStatusResponseSchema = z.looseObject({
243
+ BackendState: z.string().optional(),
244
+ Self: z.looseObject({ DNSName: z.string().optional() }).optional()
245
+ });
246
+ const tailscaleServeConfigurationSchema = z.lazy(() => z.looseObject({
247
+ TCP: z.record(z.string(), z.looseObject({})).optional(),
248
+ Foreground: z.record(z.string(), tailscaleServeConfigurationSchema).optional(),
249
+ Web: z.record(z.string(), z.looseObject({ Handlers: z.record(z.string(), z.looseObject({ Proxy: z.string().optional() })).optional() })).optional()
250
+ }));
251
+ async function tailscale(args) {
252
+ return new Promise((resolve, reject) => {
253
+ const child = spawn("tailscale", args, { stdio: [
254
+ "ignore",
255
+ "pipe",
256
+ "pipe"
257
+ ] });
258
+ let stdout = "";
259
+ let stderr = "";
260
+ child.stdout.setEncoding("utf8");
261
+ child.stderr.setEncoding("utf8");
262
+ child.stdout.on("data", (chunk) => {
263
+ stdout += chunk;
264
+ });
265
+ child.stderr.on("data", (chunk) => {
266
+ stderr += chunk;
267
+ });
268
+ child.once("error", (error) => reject(/* @__PURE__ */ new Error(error.code === "ENOENT" ? "Tailscale is required for remote access. Install it from https://tailscale.com/download, run `tailscale up`, then retry." : `Could not run Tailscale: ${error.message}`)));
269
+ child.once("close", (code) => {
270
+ if (code === 0) {
271
+ resolve(stdout);
272
+ return;
273
+ }
274
+ const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
275
+ reject(/* @__PURE__ */ new Error(`Tailscale ${args[0]} failed${detail ? `: ${detail}` : ` (status ${code ?? 1})`}`));
276
+ });
277
+ });
278
+ }
279
+ function tailscaleJson(value, command, schema) {
280
+ const result = schema.safeParse(JSON.parse(value));
281
+ if (!result.success) throw new Error(`Tailscale ${command} returned an invalid JSON response`);
282
+ return result.data;
283
+ }
284
+ function remotePreference(value) {
285
+ if (value.remote === void 0) return null;
286
+ if (!Number.isInteger(value.remote.port) || value.remote.port < 1 || value.remote.port > 65535 || !value.remote.target) throw new Error("Treeport remote access preferences are invalid");
287
+ return value.remote;
288
+ }
289
+ function localProxyTarget(apiUrl) {
290
+ if (!URL.canParse(apiUrl)) throw new Error("Treeport remote access requires a loopback daemon URL");
291
+ const url = new URL(apiUrl);
292
+ if (url.protocol !== "http:" || ![
293
+ "127.0.0.1",
294
+ "localhost",
295
+ "::1",
296
+ "[::1]"
297
+ ].includes(url.hostname)) throw new Error("Treeport remote access requires a loopback daemon. Run `treeport up --host 127.0.0.1`, then try again.");
298
+ return `http://${url.host}`;
299
+ }
300
+ function portIsServed(config, port) {
301
+ const tcp = config.TCP;
302
+ if (tcp && Object.hasOwn(tcp, String(port))) return true;
303
+ return Object.values(config.Foreground ?? {}).some((value) => portIsServed(value, port));
304
+ }
305
+ function rootProxyForPort(config, port) {
306
+ for (const [hostPort, server] of Object.entries(config.Web ?? {})) {
307
+ if (!hostPort.endsWith(`:${port}`)) continue;
308
+ const proxy = server.Handlers?.["/"]?.Proxy;
309
+ if (proxy !== void 0) return proxy;
310
+ }
311
+ return null;
312
+ }
313
+ function proxyMatches(actual, expected) {
314
+ return actual !== null && expected !== void 0 && actual.replace(/\/$/, "") === expected.replace(/\/$/, "");
315
+ }
316
+ async function tailscaleServeConfig() {
317
+ return tailscaleJson(await tailscale([
318
+ "serve",
319
+ "status",
320
+ "--json"
321
+ ]), "serve status", tailscaleServeConfigurationSchema);
322
+ }
323
+ async function tailscaleRemoteUrl(port) {
324
+ const status = tailscaleJson(await tailscale(["status", "--json"]), "status", tailscaleStatusResponseSchema);
325
+ if (status.BackendState !== "Running") throw new Error("Tailscale is not connected. Run `tailscale up` then try again.");
326
+ const dnsName = status.Self?.DNSName;
327
+ if (!dnsName?.trim()) throw new Error("Tailscale did not report a DNS name. Enable MagicDNS, then try again.");
328
+ return `https://${dnsName.trim().replace(/\.$/, "")}${port === 443 ? "" : `:${port}`}`;
329
+ }
330
+ async function enableTailscaleRemote(options) {
331
+ 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");
332
+ const saved = await preferences();
333
+ const remote = remotePreference(saved);
334
+ const port = options.port ?? remote?.port ?? DEFAULT_PORT;
335
+ if (remote && remote.port !== port) throw new Error(`Treeport remote access is already configured on port ${remote.port}. Run \`treeport remote disable\` before choosing another port.`);
336
+ const expectedTarget = localProxyTarget((await daemonStatus()).state?.apiUrl ?? await resolveLocalApiUrl());
337
+ const [url, config] = await Promise.all([tailscaleRemoteUrl(port), tailscaleServeConfig()]);
338
+ const existingTarget = rootProxyForPort(config, port);
339
+ if ((portIsServed(config, port) || existingTarget !== null) && !proxyMatches(existingTarget, expectedTarget) && !proxyMatches(existingTarget, remote?.target)) throw new Error(`Tailscale Serve already uses port ${port}. Choose another port with \`treeport remote enable --port <port>\`.`);
340
+ const target = localProxyTarget((await daemonUp({})).apiUrl);
341
+ const alreadyEnabled = proxyMatches(existingTarget, target);
342
+ if (!alreadyEnabled) await tailscale([
343
+ "serve",
344
+ "--bg",
345
+ `--https=${port}`,
346
+ target
347
+ ]);
348
+ await savePreferences({
349
+ ...saved,
350
+ remote: {
351
+ port,
352
+ target
353
+ }
354
+ });
355
+ return {
356
+ alreadyEnabled,
357
+ port,
358
+ url
359
+ };
360
+ }
361
+ async function tailscaleRemoteStatus() {
362
+ const remote = remotePreference(await preferences());
363
+ if (!remote) return {
364
+ configured: false,
365
+ active: false,
366
+ port: null,
367
+ url: null
368
+ };
369
+ const [url, config] = await Promise.all([tailscaleRemoteUrl(remote.port), tailscaleServeConfig()]);
370
+ return {
371
+ configured: true,
372
+ active: proxyMatches(rootProxyForPort(config, remote.port), remote.target),
373
+ port: remote.port,
374
+ url
375
+ };
376
+ }
377
+ async function disableTailscaleRemote() {
378
+ const saved = await preferences();
379
+ const remote = remotePreference(saved);
380
+ if (!remote) return {
381
+ wasEnabled: false,
382
+ changedTailscale: false
383
+ };
384
+ if (proxyMatches(rootProxyForPort(await tailscaleServeConfig(), remote.port), remote.target)) {
385
+ await tailscale([
386
+ "serve",
387
+ `--https=${remote.port}`,
388
+ "off"
389
+ ]);
390
+ delete saved.remote;
391
+ await savePreferences(saved);
392
+ return {
393
+ wasEnabled: true,
394
+ changedTailscale: true
395
+ };
396
+ }
397
+ delete saved.remote;
398
+ await savePreferences(saved);
399
+ return {
400
+ wasEnabled: false,
401
+ changedTailscale: false
402
+ };
403
+ }
134
404
  async function runDoctor() {
135
405
  const paths = localPaths();
136
406
  const gitPath = process.env.TREEPORT_GIT_PATH?.trim() || "git";
@@ -191,7 +461,7 @@ async function daemonStatus() {
191
461
  verified: false
192
462
  };
193
463
  }
194
- const observed = await health(state.apiUrl);
464
+ const observed = await daemonHealth(state.apiUrl);
195
465
  return {
196
466
  running: Boolean(observed),
197
467
  state,
@@ -204,20 +474,14 @@ async function daemonUp(options) {
204
474
  const paths = localPaths();
205
475
  const saved = await preferences();
206
476
  const next = {
477
+ ...saved,
207
478
  host: options.host?.trim() || saved.host || DEFAULT_HOST,
208
479
  port: options.port ?? saved.port ?? DEFAULT_PORT
209
480
  };
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);
481
+ const host = options.host?.trim() || process.env.TREEPORT_HOST?.trim() || process.env.HOST?.trim() || next.host;
482
+ assertLoopbackHost(host);
483
+ if (options.host !== void 0 || options.port !== void 0) await savePreferences(next);
484
+ const port = Number.parseInt(options.port === void 0 ? process.env.TREEPORT_PORT?.trim() || process.env.PORT?.trim() || String(next.port) : String(options.port), 10);
221
485
  const apiUrl = options.host !== void 0 || options.port !== void 0 ? listenerUrl(host, port) : process.env.TREEPORT_API_URL?.trim() || listenerUrl(host, port);
222
486
  const currentVersion = await treeportVersion();
223
487
  const existing = await daemonStatus();
@@ -287,7 +551,7 @@ async function daemonUp(options) {
287
551
  fsSync.closeSync(log);
288
552
  const deadline = Date.now() + 15e3;
289
553
  while (Date.now() < deadline) {
290
- const observed = await health(apiUrl, 500);
554
+ const observed = await daemonHealth(apiUrl, 500);
291
555
  if (observed && observed.pid === child.pid && observed.instanceId === instanceId && observed.version === currentVersion) return {
292
556
  alreadyRunning: false,
293
557
  apiUrl,
@@ -312,15 +576,23 @@ async function readDaemonLogs(lines = 100) {
312
576
  })).split("\n").slice(-lines - 1).join("\n");
313
577
  }
314
578
  //#endregion
315
- //#region src/cli/index.ts
316
- const configuredApiUrl = process.env.TREEPORT_API_URL?.trim();
317
- const apiUrl = (await resolveLocalApiUrl()).replace(/\/$/, "");
579
+ //#region src/cli/application.ts
318
580
  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);
581
+ let configuredApiUrl;
582
+ let apiUrl = "";
583
+ let contextProjectId;
584
+ let contextWorktreeId;
585
+ let contextTerminalId;
586
+ let configuredDaemonLifecycle;
587
+ let jsonOutput = false;
588
+ let workingDirectory = process.cwd();
589
+ let writeStdout = (value) => {
590
+ process.stdout.write(value);
591
+ };
592
+ let writeStderr = (value) => {
593
+ process.stderr.write(value);
594
+ };
595
+ let requestedExitCode = 0;
324
596
  var CliError = class extends Error {
325
597
  exitCode;
326
598
  code;
@@ -332,6 +604,11 @@ var CliError = class extends Error {
332
604
  this.details = details;
333
605
  }
334
606
  };
607
+ async function resolveDaemonLifecycle() {
608
+ if (configuredDaemonLifecycle === "external") return "external";
609
+ if (configuredApiUrl) return (await daemonHealth(apiUrl))?.daemonLifecycle ?? "treeport";
610
+ return "treeport";
611
+ }
335
612
  async function request(pathname, options = {}) {
336
613
  const controller = new AbortController();
337
614
  const externalSignal = options.signal;
@@ -363,6 +640,29 @@ async function request(pathname, options = {}) {
363
640
  externalSignal?.removeEventListener("abort", abort);
364
641
  }
365
642
  }
643
+ async function createWorktree(projectId, input) {
644
+ let operation = (await request(`/api/projects/${encodeURIComponent(projectId)}/worktree-operations`, {
645
+ method: "POST",
646
+ body: JSON.stringify(input)
647
+ })).operation;
648
+ while (operation.status === "pending" || operation.status === "running") {
649
+ await new Promise((resolve) => setTimeout(resolve, 100));
650
+ operation = (await request(`/api/operations/${encodeURIComponent(operation.id)}`)).operation;
651
+ }
652
+ if (operation.status === "failed") throw new CliError(operation.error ?? "Worktree creation failed", 5, "WORKTREE_CREATION_FAILED");
653
+ if (operation.kind !== "create") throw new CliError("Worktree creation returned an unexpected operation kind", 5, "INVALID_OPERATION_RESULT");
654
+ const worktreeId = typeof operation.result?.worktreeId === "string" ? operation.result.worktreeId : operation.worktreeId;
655
+ if (!worktreeId) throw new CliError("Completed worktree creation did not identify its worktree", 5, "INVALID_OPERATION_RESULT");
656
+ const worktree = (await request(`/api/projects/${encodeURIComponent(projectId)}`)).project.worktrees.find((item) => item.id === worktreeId);
657
+ if (!worktree) throw new CliError(`Created worktree ${worktreeId} was not found`, 5, "INVALID_OPERATION_RESULT");
658
+ const terminalId = typeof operation.result?.terminalId === "string" ? operation.result.terminalId : null;
659
+ return {
660
+ worktree,
661
+ terminal: worktree.terminals.find((item) => item.id === terminalId) ?? null,
662
+ terminalError: typeof operation.result?.terminalError === "string" ? operation.result.terminalError : null,
663
+ setupError: typeof operation.result?.setupError === "string" ? operation.result.setupError : null
664
+ };
665
+ }
366
666
  function commandArgv(args) {
367
667
  const separator = args.indexOf("--");
368
668
  if (separator === -1) return;
@@ -372,7 +672,8 @@ function commandArgv(args) {
372
672
  return argv;
373
673
  }
374
674
  async function canonical(value) {
375
- return fs.realpath(path.resolve(value)).catch(() => path.resolve(value));
675
+ const resolved = path.resolve(workingDirectory, value);
676
+ return fs.realpath(resolved).catch(() => resolved);
376
677
  }
377
678
  async function projects() {
378
679
  return (await request("/api/projects")).projects;
@@ -394,6 +695,14 @@ async function resolveProject(identifier) {
394
695
  if (!match) throw new CliError(`No registered project matches ${identifier}`, 5);
395
696
  return match;
396
697
  }
698
+ async function packageSource(value) {
699
+ if (value.startsWith("npm:")) return value;
700
+ if (path.isAbsolute(value) || value === "." || value === ".." || value.startsWith("./") || value.startsWith("../") || value === "~" || value.startsWith("~/")) return canonical(value);
701
+ return value;
702
+ }
703
+ async function localPackageProjectId() {
704
+ return (await request(`/api/packages/project?${new URLSearchParams({ path: await canonical(workingDirectory) }).toString()}`)).project.id;
705
+ }
397
706
  async function resolveWorktree(identifier) {
398
707
  const all = (await projects()).flatMap((project) => project.worktrees);
399
708
  const direct = all.find((worktree) => worktree.id === identifier);
@@ -407,6 +716,43 @@ async function resolveWorktree(identifier) {
407
716
  if (!match) throw new CliError(`No registered worktree matches ${identifier}`, 5);
408
717
  return match;
409
718
  }
719
+ function parseWebPanelInput(value) {
720
+ if (value === void 0) return null;
721
+ if (Buffer.byteLength(value) > 65536) throw new CliError("Web panel input is limited to 64 KiB", 2);
722
+ let parsed;
723
+ try {
724
+ parsed = JSON.parse(value);
725
+ } catch (error) {
726
+ throw new CliError(`--input must contain valid JSON: ${error instanceof Error ? error.message : String(error)}`, 2);
727
+ }
728
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new CliError("--input must contain a JSON object", 2);
729
+ return parsed;
730
+ }
731
+ async function webPanelDefinition(worktreeId, identifier) {
732
+ const definitions = (await request(`/api/worktrees/${encodeURIComponent(worktreeId)}/web-panel-definitions`)).definitions;
733
+ const exact = definitions.find((definition) => definition.id === identifier);
734
+ if (exact) return exact;
735
+ const matches = definitions.filter((definition) => decodeURIComponent(definition.id.split(":").at(-1) ?? "") === identifier);
736
+ if (matches.length === 1) return matches[0];
737
+ if (matches.length > 1) throw new CliError(`Web panel name ${identifier} is ambiguous: ${matches.map((match) => match.id).join(", ")}`, 5, "WEB_PANEL_DEFINITION_AMBIGUOUS", { definitionIds: matches.map((match) => match.id) });
738
+ throw new CliError(`Web panel ${identifier} is not available in this worktree`, 5, "WEB_PANEL_DEFINITION_NOT_FOUND");
739
+ }
740
+ async function webPanelLaunchCwd(worktree) {
741
+ const [cwd, worktreeRoot] = await Promise.all([canonical(workingDirectory), canonical(worktree.path)]);
742
+ if (!pathContains(cwd, worktreeRoot)) throw new CliError(`The current directory is outside worktree ${worktree.name}`, 5, "INVALID_WEB_PANEL_LAUNCH_CWD", {
743
+ cwd,
744
+ worktreeId: worktree.id,
745
+ worktreePath: worktree.path
746
+ });
747
+ return path.relative(worktreeRoot, cwd) || ".";
748
+ }
749
+ function webPanelUrl(worktree, panelId) {
750
+ const target = new URL(apiUrl);
751
+ target.pathname = `/projects/${encodeURIComponent(worktree.projectId)}/worktrees/${encodeURIComponent(worktree.id)}/panels/${encodeURIComponent(panelId)}`;
752
+ target.search = "";
753
+ target.hash = "";
754
+ return target.href;
755
+ }
410
756
  function resolveTerminalId(identifier) {
411
757
  if (identifier !== ".") return identifier;
412
758
  const terminalId = contextTerminalId;
@@ -423,16 +769,11 @@ function parseCaptureLines(value) {
423
769
  return lines;
424
770
  }
425
771
  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;
772
+ try {
773
+ return parseDurationMs(value);
774
+ } catch (error) {
775
+ throw new CliError(error instanceof Error ? error.message : String(error), 2);
776
+ }
436
777
  }
437
778
  async function inspectTerminal(terminalId, signal) {
438
779
  return request(`/api/terminals/${encodeURIComponent(terminalId)}`, signal ? { signal } : {});
@@ -523,8 +864,8 @@ async function waitForTerminal(terminalId, condition, timeoutMs) {
523
864
  condition
524
865
  });
525
866
  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");
867
+ const { worktreeId: _worktreeId, ...metadata } = event.data;
868
+ if (!observation) throw new CliError("Treeport daemon sent invalid terminal metadata", 3, "DAEMON_PROTOCOL_ERROR");
528
869
  observation = {
529
870
  ...observation,
530
871
  metadata
@@ -559,8 +900,7 @@ async function waitForTerminal(terminalId, condition, timeoutMs) {
559
900
  }
560
901
  }
561
902
  function print(value, human) {
562
- if (jsonOutput) console.log(JSON.stringify(value));
563
- else console.log(human ? human() : JSON.stringify(value, null, 2));
903
+ writeStdout(`${jsonOutput ? JSON.stringify(value) : human ? human() : JSON.stringify(value, null, 2)}\n`);
564
904
  }
565
905
  const agentGuidance = `AI agents:
566
906
  If you're an AI agent, use \`treeport skills\` to see the usage guide.
@@ -568,14 +908,59 @@ const agentGuidance = `AI agents:
568
908
  async function main(args) {
569
909
  const argv = args[0] === "spawn" || args[0] === "terminal" && args[1] === "create" ? commandArgv(args) : void 0;
570
910
  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());
911
+ const program = new Command().name("treeport").usage("[options] [folder] [command]").description("Manage Treeport projects, worktrees, and terminals.").argument("[folder]", "folder inside a Git repository to open").option("--json", "emit machine-readable JSON").addHelpText("beforeAll", agentGuidance).configureOutput({
912
+ writeOut: writeStdout,
913
+ writeErr: (value) => {
914
+ parserError += value;
915
+ }
916
+ }).showHelpAfterError().exitOverride();
917
+ program.action(async (folder) => {
918
+ if (folder === void 0) {
919
+ writeStdout(program.helpInformation());
920
+ return;
921
+ }
922
+ const absoluteFolder = path.resolve(workingDirectory, folder);
923
+ if (!(await fs.stat(absoluteFolder).catch((error) => {
924
+ if ((typeof error === "object" && error !== null && "code" in error ? error.code : void 0) === "ENOENT") throw new CliError(`Folder does not exist: ${absoluteFolder}`, 5, "FOLDER_NOT_FOUND", { path: absoluteFolder });
925
+ throw new CliError(`Cannot access folder ${absoluteFolder}: ${error instanceof Error ? error.message : String(error)}`, 5, "FOLDER_UNREADABLE", { path: absoluteFolder });
926
+ })).isDirectory()) throw new CliError(`Path is not a folder: ${absoluteFolder}`, 5, "FOLDER_NOT_DIRECTORY", { path: absoluteFolder });
927
+ const canonicalFolder = await fs.realpath(absoluteFolder).catch((error) => {
928
+ throw new CliError(`Cannot access folder ${absoluteFolder}: ${error instanceof Error ? error.message : String(error)}`, 5, "FOLDER_UNREADABLE", { path: absoluteFolder });
929
+ });
930
+ if (await resolveDaemonLifecycle() === "external") {
931
+ if (!await daemonHealth(apiUrl)) throw new CliError(`Cannot reach the externally managed Treeport daemon at ${apiUrl}. Start it through the process that owns its lifecycle and retry.`, 3, "DAEMON_UNREACHABLE");
932
+ } else await daemonUp({});
933
+ const registered = await request("/api/projects", {
934
+ method: "POST",
935
+ body: JSON.stringify({ path: canonicalFolder })
936
+ }).catch((error) => {
937
+ if (error instanceof CliError && error.code === "NOT_A_GIT_REPOSITORY") throw new CliError(`No Git repository contains ${canonicalFolder}.`, error.exitCode, error.code, error.details);
938
+ throw error;
939
+ });
940
+ const targetWorktree = registered.project.worktrees.filter((worktree) => !worktree.prunable && pathContains(canonicalFolder, worktree.path)).sort((left, right) => right.path.length - left.path.length)[0];
941
+ if (!targetWorktree) throw new CliError(`Git did not report an active worktree containing ${canonicalFolder}.`, 5, "WORKTREE_NOT_FOUND", {
942
+ path: canonicalFolder,
943
+ projectId: registered.project.id
944
+ });
945
+ const target = new URL(apiUrl);
946
+ target.pathname = `/projects/${encodeURIComponent(registered.project.id)}/worktrees/${encodeURIComponent(targetWorktree.id)}`;
947
+ target.search = "";
948
+ target.hash = "";
949
+ const opened = await openWorkspace(target.href).catch((error) => {
950
+ if (error instanceof OpenWorkspaceError) throw new CliError(error.message, 1, "OPEN_FAILED", { url: target.href });
951
+ throw error;
952
+ });
953
+ print({
954
+ projectId: registered.project.id,
955
+ worktreeId: targetWorktree.id,
956
+ path: canonicalFolder,
957
+ url: target.href,
958
+ client: opened.client
959
+ }, () => `Opened ${registered.project.name} / ${targetWorktree.name} in the ${opened.client === "desktop" ? "Treeport desktop app" : "browser"}\n${target.href}`);
576
960
  });
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");
961
+ const upCommand = program.command("up").description("Ensure the local Treeport daemon is running").option("--host <address>", "loopback listener address").option("--port <port>", "listener port").option("--foreground", "run in the foreground").option("--json", "emit machine-readable JSON");
578
962
  upCommand.action(async () => {
963
+ if (await resolveDaemonLifecycle() === "external") throw new CliError("Cannot run `treeport up` because the daemon lifecycle is externally managed. Control the process that started Treeport instead.", 5, "DAEMON_LIFECYCLE_EXTERNAL");
579
964
  const options = upCommand.opts();
580
965
  const port = options.port === void 0 ? void 0 : Number(options.port);
581
966
  const result = await daemonUp({
@@ -585,22 +970,43 @@ async function main(args) {
585
970
  });
586
971
  if (options.foreground) return;
587
972
  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
973
  });
596
974
  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
975
  downCommand.action(async () => {
976
+ if (await resolveDaemonLifecycle() === "external") throw new CliError("Cannot run `treeport down` because the daemon lifecycle is externally managed. Control the process that started Treeport instead.", 5, "DAEMON_LIFECYCLE_EXTERNAL");
598
977
  const options = downCommand.opts();
599
978
  if (options.terminateTerminals && !options.force) throw new CliError("Re-run with --terminate-terminals --force to confirm termination of every terminal.", 2);
600
979
  if (options.terminateTerminals) await request("/api/admin/terminate-terminals", { method: "POST" });
601
980
  const result = await daemonDown();
602
981
  print(result, () => result.wasRunning ? "Treeport is down" : "Treeport is already down");
603
982
  });
983
+ const remoteCommand = program.command("remote").description("Expose Treeport privately through Tailscale Serve");
984
+ remoteCommand.action(() => {
985
+ writeStdout(remoteCommand.helpInformation());
986
+ });
987
+ const remoteEnableCommand = remoteCommand.command("enable").description("Enable private HTTPS access through Tailscale").option("--port <port>", "Tailscale HTTPS port (default: 8733)").option("--json", "emit machine-readable JSON");
988
+ remoteEnableCommand.action(async () => {
989
+ if (await resolveDaemonLifecycle() === "external") throw new CliError("Cannot run `treeport remote enable` because the daemon lifecycle is externally managed. Configure remote access through the process that started Treeport instead.", 5, "DAEMON_LIFECYCLE_EXTERNAL");
990
+ const options = remoteEnableCommand.opts();
991
+ const port = options.port === void 0 ? void 0 : Number(options.port);
992
+ if (port !== void 0 && (!Number.isInteger(port) || port < 1 || port > 65535)) throw new CliError("--port must be an integer between 1 and 65535", 2);
993
+ const result = await enableTailscaleRemote(port === void 0 ? {} : { port });
994
+ print(result, () => `Treeport remote access is ${result.alreadyEnabled ? "already enabled" : "enabled"}\n${result.url}\nTailscale authenticates each remote user. Access is limited by your Tailscale policy.`);
995
+ });
996
+ remoteCommand.command("status").description("Show Tailscale remote access status").option("--json", "emit machine-readable JSON").action(async () => {
997
+ const result = await tailscaleRemoteStatus();
998
+ print(result, () => {
999
+ if (!result.configured) return "Treeport remote access is disabled";
1000
+ return result.active ? `Treeport remote access is enabled\n${result.url}` : `Treeport remote access is unavailable\nExpected: ${result.url}\nThe Tailscale Serve route no longer points to Treeport.`;
1001
+ });
1002
+ });
1003
+ remoteCommand.command("disable").description("Disable Treeport Tailscale remote access").option("--json", "emit machine-readable JSON").action(async () => {
1004
+ const result = await disableTailscaleRemote();
1005
+ print(result, () => {
1006
+ if (result.changedTailscale) return "Treeport remote access is disabled";
1007
+ return result.wasEnabled ? "Treeport remote access is disabled" : "Treeport remote access was already disabled; the current Tailscale route was left unchanged.";
1008
+ });
1009
+ });
604
1010
  program.command("status").description("Show local daemon status").option("--json", "emit machine-readable JSON").action(async () => {
605
1011
  const status = await daemonStatus();
606
1012
  const projectList = status.verified ? await projects() : [];
@@ -620,12 +1026,12 @@ async function main(args) {
620
1026
  logsCommand.action(async () => {
621
1027
  const lines = Number(logsCommand.opts().lines);
622
1028
  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));
1029
+ writeStdout(await readDaemonLogs(lines));
624
1030
  });
625
1031
  program.command("doctor").description("Diagnose local requirements and paths").option("--json", "emit machine-readable JSON").action(async () => {
626
1032
  const checks = await runDoctor();
627
1033
  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;
1034
+ if (checks.some((check) => !check.ok)) requestedExitCode = 1;
629
1035
  });
630
1036
  program.command("version").description("Show CLI and daemon versions").option("--json", "emit machine-readable JSON").action(async () => {
631
1037
  const [cli, status] = await Promise.all([treeportVersion(), daemonStatus()]);
@@ -636,7 +1042,8 @@ async function main(args) {
636
1042
  print(result, () => `CLI: ${result.cli}\nDaemon: ${result.daemon ?? "not running"}`);
637
1043
  });
638
1044
  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"));
1045
+ const skill = await fs.readFile(await resolvePackagePath("skills", "treeport", "SKILL.md"), "utf8");
1046
+ writeStdout(await resolveDaemonLifecycle() === "external" ? skill.replace("\n# Treeport\n", "\n# Treeport\n\n> **Externally managed daemon lifecycle:** Do not run `treeport up`, `treeport down`, or `treeport remote enable`. The process that started Treeport owns startup, shutdown, remote exposure, and logs. Other Treeport commands continue to use the configured daemon normally.\n") : skill);
640
1047
  });
641
1048
  program.command("context").description("Show the current Treeport-managed terminal context").option("--json", "emit machine-readable JSON").action(async () => {
642
1049
  const projectId = contextProjectId;
@@ -674,6 +1081,7 @@ async function main(args) {
674
1081
  const context = {
675
1082
  managed: true,
676
1083
  apiUrl,
1084
+ daemonLifecycle: await resolveDaemonLifecycle(),
677
1085
  project: {
678
1086
  id: project.id,
679
1087
  name: project.name,
@@ -690,8 +1098,7 @@ async function main(args) {
690
1098
  head: worktree.head,
691
1099
  branch: worktree.branch,
692
1100
  detached: worktree.detached,
693
- kind: worktree.kind,
694
- status: worktree.status
1101
+ kind: worktree.kind
695
1102
  },
696
1103
  terminal: {
697
1104
  id: terminal.id,
@@ -701,7 +1108,62 @@ async function main(args) {
701
1108
  exitCode: terminal.exitCode
702
1109
  }
703
1110
  };
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}`);
1111
+ 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}\nLifecycle: ${context.daemonLifecycle === "external" ? "externally managed" : "managed by Treeport"}`);
1112
+ });
1113
+ const installCommand = program.command("install").description("Install and configure a Treeport package").argument("<source>", "npm: source or local directory").option("-l, --local", "configure the registered project containing the current directory").option("--json", "emit machine-readable JSON");
1114
+ installCommand.action(async (source) => {
1115
+ const options = installCommand.opts();
1116
+ const result = (await request("/api/packages/install", {
1117
+ method: "POST",
1118
+ body: JSON.stringify({
1119
+ source: await packageSource(source),
1120
+ ...options.local ? { projectId: await localPackageProjectId() } : {}
1121
+ })
1122
+ })).result;
1123
+ print(result, () => `Installed ${result.source}${result.scope === "project" ? ` for project ${result.projectId}` : " globally"}`);
1124
+ });
1125
+ const removePackageCommand = program.command("remove").alias("uninstall").description("Remove a configured Treeport package").argument("<source>", "npm: source or local directory").option("-l, --local", "remove from the registered project containing the current directory").option("--json", "emit machine-readable JSON");
1126
+ removePackageCommand.action(async (source) => {
1127
+ const options = removePackageCommand.opts();
1128
+ const result = (await request("/api/packages/remove", {
1129
+ method: "POST",
1130
+ body: JSON.stringify({
1131
+ source: await packageSource(source),
1132
+ ...options.local ? { projectId: await localPackageProjectId() } : {}
1133
+ })
1134
+ })).result;
1135
+ print(result, () => `Removed ${result.source}`);
1136
+ });
1137
+ program.command("list").description("List configured Treeport packages").option("--json", "emit machine-readable JSON").action(async () => {
1138
+ const result = await request("/api/packages");
1139
+ print(result, () => {
1140
+ const lines = result.packages.map((pkg) => {
1141
+ return `${pkg.scope === "global" ? "global" : `project:${pkg.projectName ?? pkg.projectId}`}\t${pkg.source}\t${pkg.resources.webPanels} web panels, ${pkg.resources.terminalPresets} terminal presets`;
1142
+ });
1143
+ lines.push(...result.diagnostics.map((item) => `error\t${item.scope}\t${item.source ?? item.path ?? "settings"}\t${item.message}`));
1144
+ return lines.join("\n");
1145
+ });
1146
+ });
1147
+ const updatePackagesCommand = program.command("update").description("Explicitly update configured Treeport packages").argument("[source]", "one configured npm: source").option("--packages", "update every eligible configured package").option("--json", "emit machine-readable JSON");
1148
+ updatePackagesCommand.action(async (source) => {
1149
+ const options = updatePackagesCommand.opts();
1150
+ if (!source && !options.packages || source && options.packages) throw new CliError("Specify a package source or --packages. Bare `treeport update` is reserved for a future Treeport self-update.", 2);
1151
+ const results = (await request("/api/packages/update", {
1152
+ method: "POST",
1153
+ body: JSON.stringify(source ? { source: await packageSource(source) } : {})
1154
+ })).results;
1155
+ print(results, () => results.map((result) => `${result.status}\t${result.scope}\t${result.source ?? "packages"}${result.reason ? `\t${result.reason}` : ""}`).join("\n"));
1156
+ });
1157
+ const reloadCommand = program.command("reload").description("Reload package settings and resources without restarting").option("-l, --local", "reload only the registered project containing the current directory").option("--json", "emit machine-readable JSON");
1158
+ reloadCommand.action(async () => {
1159
+ const options = reloadCommand.opts();
1160
+ const result = await request("/api/packages/reload", {
1161
+ method: "POST",
1162
+ body: JSON.stringify(options.local ? { projectId: await localPackageProjectId() } : {})
1163
+ });
1164
+ print(result, () => {
1165
+ return [...result.results.map((item) => `Reloaded ${item.scope === "global" ? "global packages" : `project ${item.projectId}`}`), ...result.diagnostics.map((item) => `Error: ${item.source ?? item.path ?? item.scope}: ${item.message}`)].join("\n");
1166
+ });
705
1167
  });
706
1168
  const projectCommand = program.command("project").description("Register and list projects");
707
1169
  projectCommand.action(() => {
@@ -726,20 +1188,17 @@ async function main(args) {
726
1188
  worktreeListCommand.action(async () => {
727
1189
  const { project: projectIdentifier } = worktreeListCommand.opts();
728
1190
  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"));
1191
+ print(list, () => list.map((worktree) => `${worktree.id}\t${worktree.name}\t${worktree.branch ?? `detached@${worktree.head.slice(0, 8)}`}\t${worktree.path}`).join("\n"));
730
1192
  });
731
1193
  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
1194
  worktreeCreateCommand.action(async () => {
733
1195
  const options = worktreeCreateCommand.opts();
734
1196
  const project = await resolveProject(options.project);
735
1197
  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
- })
1198
+ const result = await createWorktree(project.id, {
1199
+ name: options.name,
1200
+ base: options.fromCurrent ? "current" : "default",
1201
+ ...sourceWorktreeId ? { sourceWorktreeId } : {}
743
1202
  });
744
1203
  print(result, () => `Created ${result.worktree.name} (${result.worktree.id})\n${result.worktree.path}${result.setupError ? `\nSetup error: ${result.setupError}` : ""}`);
745
1204
  });
@@ -750,14 +1209,48 @@ async function main(args) {
750
1209
  const preview = (await request(`/api/worktrees/${worktree.id}/remove-preview`)).preview;
751
1210
  if (!preview.eligible) throw new CliError(preview.reasons.join("\n"), 5);
752
1211
  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`, {
1212
+ let operation = (await request(`/api/worktrees/${worktree.id}/remove`, {
754
1213
  method: "POST",
755
1214
  body: JSON.stringify({
756
1215
  confirmationToken: preview.confirmationToken,
757
1216
  confirmDestructive: preview.warnings.length > 0
758
1217
  })
1218
+ })).operation;
1219
+ while (operation.status === "pending" || operation.status === "running") {
1220
+ await new Promise((resolve) => setTimeout(resolve, 100));
1221
+ operation = (await request(`/api/operations/${encodeURIComponent(operation.id)}`)).operation;
1222
+ }
1223
+ if (operation.status === "failed") throw new CliError(operation.error ?? "Worktree removal failed", 5, "WORKTREE_REMOVAL_FAILED");
1224
+ if (operation.kind !== "remove" || !operation.result) throw new CliError("Worktree removal returned an unexpected operation result", 5, "INVALID_OPERATION_RESULT");
1225
+ print(operation.result, () => {
1226
+ const warning = operation.result?.cleanup.warning;
1227
+ return `Removed ${worktree.name} (${worktree.id})${warning ? `\nWarning: ${warning}` : ""}`;
1228
+ });
1229
+ });
1230
+ const webPanelCommand = program.command("web-panel").description("Open persistent web panels");
1231
+ webPanelCommand.action(() => {
1232
+ throw new CliError(webPanelCommand.helpInformation(), 2);
1233
+ });
1234
+ const webPanelOpenCommand = webPanelCommand.command("open").description("Create or reuse a web panel and request client navigation").argument("<definition>", "definition ID or unique short name").requiredOption("--worktree <id-or-path-or-dot>", "owning worktree").option("--input <json>", "structured panel input as a JSON object").option("--new", "create a separate panel instance").option("--json", "emit machine-readable JSON");
1235
+ webPanelOpenCommand.action(async (identifier) => {
1236
+ const options = webPanelOpenCommand.opts();
1237
+ const worktree = await resolveWorktree(options.worktree);
1238
+ const definition = await webPanelDefinition(worktree.id, identifier);
1239
+ const result = await request(`/api/worktrees/${encodeURIComponent(worktree.id)}/panels/open`, {
1240
+ method: "POST",
1241
+ body: JSON.stringify({
1242
+ definitionId: definition.id,
1243
+ input: parseWebPanelInput(options.input),
1244
+ launchCwd: await webPanelLaunchCwd(worktree),
1245
+ newInstance: options.new ?? false,
1246
+ sourceTerminalId: contextTerminalId ?? null
1247
+ })
759
1248
  });
760
- print(result.operation, () => `Remove accepted: ${result.operation.id}`);
1249
+ const output = {
1250
+ ...result,
1251
+ url: webPanelUrl(worktree, result.panel.id)
1252
+ };
1253
+ print(output, () => `${result.reused ? "Reused" : "Opened"} ${result.panel.title} (${result.panel.id})\n${output.url}`);
761
1254
  });
762
1255
  const terminalCommand = program.command("terminal").description("Manage persistent worktree terminals");
763
1256
  terminalCommand.action(() => {
@@ -798,8 +1291,8 @@ async function main(args) {
798
1291
  const capture = await request(`/api/terminals/${encodeURIComponent(terminalId)}/capture?lines=${lines}`);
799
1292
  if (jsonOutput) print(capture);
800
1293
  else {
801
- process.stdout.write(capture.content);
802
- if (capture.content && !capture.content.endsWith("\n")) process.stdout.write("\n");
1294
+ writeStdout(capture.content);
1295
+ if (capture.content && !capture.content.endsWith("\n")) writeStdout("\n");
803
1296
  }
804
1297
  });
805
1298
  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");
@@ -826,16 +1319,14 @@ async function main(args) {
826
1319
  const options = spawnCommand.opts();
827
1320
  const project = await resolveProject(options.project);
828
1321
  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,
1322
+ const result = await createWorktree(project.id, {
1323
+ name: options.worktreeName,
1324
+ base: options.fromCurrent ? "current" : "default",
1325
+ initialTerminal: {
834
1326
  name: options.name,
835
- base: options.fromCurrent ? "current" : "default",
836
- ...sourceWorktreeId ? { sourceWorktreeId } : {},
837
1327
  ...argv ? { argv } : {}
838
- })
1328
+ },
1329
+ ...sourceWorktreeId ? { sourceWorktreeId } : {}
839
1330
  });
840
1331
  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
1332
  });
@@ -849,17 +1340,37 @@ async function main(args) {
849
1340
  throw error;
850
1341
  }
851
1342
  }
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
- });
1343
+ async function runCliApplication(options) {
1344
+ const environment = options.environment ?? process.env;
1345
+ configuredApiUrl = environment.TREEPORT_API_URL?.trim();
1346
+ apiUrl = (await resolveLocalApiUrl(environment)).replace(/\/$/, "");
1347
+ contextProjectId = environment.TREEPORT_PROJECT_ID?.trim() || void 0;
1348
+ contextWorktreeId = environment.TREEPORT_WORKTREE_ID?.trim() || void 0;
1349
+ contextTerminalId = environment.TREEPORT_TERMINAL_ID?.trim() || void 0;
1350
+ configuredDaemonLifecycle = environment.TREEPORT_DAEMON_LIFECYCLE?.trim();
1351
+ jsonOutput = extractJsonOutput(options.args);
1352
+ workingDirectory = options.cwd ?? process.cwd();
1353
+ writeStdout = options.stdout ?? ((value) => process.stdout.write(value));
1354
+ writeStderr = options.stderr ?? ((value) => process.stderr.write(value));
1355
+ requestedExitCode = 0;
1356
+ try {
1357
+ await main([...options.args]);
1358
+ } catch (error) {
1359
+ const cliError = error instanceof CliError ? error : new CliError(error instanceof Error ? error.message : String(error), 1);
1360
+ if (jsonOutput) {
1361
+ const body = { error: {
1362
+ code: cliError.code,
1363
+ message: cliError.message,
1364
+ ...cliError.details === void 0 ? {} : { details: cliError.details }
1365
+ } };
1366
+ writeStderr(`${JSON.stringify(body)}\n`);
1367
+ } else writeStderr(`${cliError.message}\n`);
1368
+ requestedExitCode = cliError.exitCode;
1369
+ }
1370
+ return requestedExitCode;
1371
+ }
1372
+ //#endregion
1373
+ //#region src/cli/index.ts
1374
+ process.exitCode = await runCliApplication({ args: process.argv.slice(2) });
864
1375
  //#endregion
865
1376
  export {};