@treeport/treeport 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,10 @@
1
1
  #!/usr/bin/env node
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-Dyv_owrb.js";
2
+ import { A as treeportVersion, C as disableTailscaleRemote, D as resolvePackagePath, E as resolveLocalApiUrl, M as parseDurationMs, N as TERMINAL_CAPTURE_MAX_LINES, O as runDoctor, S as daemonUp, T as readDaemonLogs, _ as serviceStatus, b as daemonHealth, ct as parseEventsSnapshot, d as serviceDisable, f as serviceDoctorCheck, g as serviceStart, h as serviceRun, k as tailscaleRemoteStatus, l as readServiceLogs, lt as parseProductEvent, m as serviceInstalled, p as serviceEnable, s as runLocalUpdate, st as webPanelInputSchema, t as LocalUpdateError, u as serviceApply, ut as SOCKET_IO_PATH, v as serviceStop, w as enableTailscaleRemote, x as daemonStatus, y as daemonDown } from "../../update-BW-a6Bd-.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 { z } from "zod";
8
7
  import { spawn } from "node:child_process";
9
- import crypto from "node:crypto";
10
- import fsSync, { constants } from "node:fs";
11
- import os from "node:os";
12
- import { fileURLToPath, pathToFileURL } from "node:url";
13
8
  //#region src/cli/args.ts
14
9
  function extractJsonOutput(args) {
15
10
  const separator = args.indexOf("--");
@@ -82,1519 +77,6 @@ async function openWorkspace(workspaceUrl, options = {}) {
82
77
  throw new OpenWorkspaceError(`Treeport registered the folder, but could not open it automatically.${browser.stderr ? ` ${browser.stderr}` : ""}\nOpen this URL manually: ${workspaceUrl}`);
83
78
  }
84
79
  //#endregion
85
- //#region src/cli/lifecycle.ts
86
- const DEFAULT_HOST = "127.0.0.1";
87
- const DEFAULT_PORT = 8733;
88
- function listenerUrl(host, port) {
89
- return `http://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`;
90
- }
91
- function expandHome(value) {
92
- return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
93
- }
94
- function localPaths(env = process.env) {
95
- 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");
96
- const dataDir = path.resolve(expandHome(env.TREEPORT_DATA_DIR?.trim() || defaultDataDir));
97
- 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"}`))));
98
- return {
99
- dataDir,
100
- runtimeDir,
101
- preferencesPath: path.join(dataDir, "config.json"),
102
- statePath: path.join(runtimeDir, "daemon.json"),
103
- lockPath: path.join(dataDir, "daemon.lock"),
104
- logPath: path.join(dataDir, "logs", "daemon.log")
105
- };
106
- }
107
- async function readJson$1(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
- daemonLifecycle: z.enum([
127
- "treeport",
128
- "service",
129
- "external"
130
- ])
131
- });
132
- const healthRecordSchema = z.strictObject({
133
- ok: z.literal(true),
134
- version: z.string(),
135
- protocolVersion: z.number(),
136
- hostname: z.string().optional(),
137
- pid: z.number(),
138
- instanceId: z.string().nullable(),
139
- installationMethod: z.string(),
140
- daemonLifecycle: z.enum([
141
- "treeport",
142
- "service",
143
- "external"
144
- ]),
145
- url: z.string()
146
- });
147
- async function preferences(env = process.env) {
148
- return await readJson$1(localPaths(env).preferencesPath, preferencesSchema) ?? {};
149
- }
150
- async function savePreferences(value) {
151
- const paths = localPaths();
152
- await fs.mkdir(paths.dataDir, {
153
- recursive: true,
154
- mode: 448
155
- });
156
- const temporaryPath = `${paths.preferencesPath}.${process.pid}.tmp`;
157
- await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
158
- await fs.rename(temporaryPath, paths.preferencesPath);
159
- }
160
- async function resolveLocalApiUrl(env = process.env) {
161
- const explicit = env.TREEPORT_API_URL?.trim();
162
- const managedApiUrl = env.TREEPORT_MANAGED_API_URL?.trim();
163
- const daemonRecordPath = env.TREEPORT_DAEMON_RECORD?.trim();
164
- if (explicit && explicit !== managedApiUrl) return explicit.replace(/\/$/, "");
165
- if (managedApiUrl && daemonRecordPath) {
166
- const record = await readJson$1(path.resolve(expandHome(daemonRecordPath)), daemonRecordSchema);
167
- if (record) return record.apiUrl.replace(/\/$/, "");
168
- }
169
- if (explicit) return explicit.replace(/\/$/, "");
170
- const saved = await preferences(env);
171
- 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));
172
- }
173
- async function resolvePackagePath(...segments) {
174
- const candidates = [fileURLToPath(new URL("../../../", import.meta.url)), fileURLToPath(new URL("../../", import.meta.url))];
175
- for (const candidate of candidates) if (await fs.access(path.join(candidate, "package.json")).then(() => true).catch(() => false)) return path.join(candidate, ...segments);
176
- throw new Error("Could not locate the Treeport package directory");
177
- }
178
- async function treeportVersion() {
179
- return (await readJson$1(await resolvePackagePath("package.json"), z.looseObject({ version: z.string().optional() })))?.version ?? "development";
180
- }
181
- function processExists(pid) {
182
- try {
183
- process.kill(pid, 0);
184
- return true;
185
- } catch (error) {
186
- return error.code === "EPERM";
187
- }
188
- }
189
- async function daemonHealth(apiUrl, timeoutMs = 1500) {
190
- const signal = AbortSignal.timeout(timeoutMs);
191
- return fetch(`${apiUrl}/api/health`, { signal }).then(async (response) => {
192
- if (!response.ok) return null;
193
- const result = healthRecordSchema.safeParse(await response.json());
194
- return result.success ? result.data : null;
195
- }).catch(() => null);
196
- }
197
- function matchesOwnership(state, observed) {
198
- return observed.pid === state.pid && observed.instanceId === state.instanceId && path.resolve(state.dataDir) === localPaths().dataDir;
199
- }
200
- async function readState() {
201
- return readJson$1(localPaths().statePath, daemonRecordSchema);
202
- }
203
- async function removeStaleState(state) {
204
- const paths = localPaths();
205
- for (const filePath of [paths.statePath, paths.lockPath]) if ((await readJson$1(filePath, z.looseObject({ instanceId: z.string() })))?.instanceId === state.instanceId) await fs.rm(filePath, { force: true });
206
- }
207
- async function stopOwned(state) {
208
- if (!processExists(state.pid)) {
209
- await removeStaleState(state);
210
- return;
211
- }
212
- const observed = await daemonHealth(state.apiUrl);
213
- if (!observed || !matchesOwnership(state, observed)) throw new Error(`Refusing to stop PID ${state.pid}: Treeport could not verify ownership. Check ${localPaths().statePath}.`);
214
- process.kill(state.pid, "SIGTERM");
215
- const deadline = Date.now() + 7e3;
216
- while (Date.now() < deadline) {
217
- if (!processExists(state.pid)) {
218
- await removeStaleState(state);
219
- return;
220
- }
221
- await new Promise((resolve) => setTimeout(resolve, 100));
222
- }
223
- throw new Error(`Treeport did not stop within 7 seconds. See ${localPaths().logPath}.`);
224
- }
225
- async function executableCheck(executable, args) {
226
- return new Promise((resolve) => {
227
- const child = spawn(executable, args, { stdio: [
228
- "ignore",
229
- "pipe",
230
- "pipe"
231
- ] });
232
- let output = "";
233
- child.stdout.setEncoding("utf8");
234
- child.stderr.setEncoding("utf8");
235
- child.stdout.on("data", (chunk) => {
236
- output += chunk;
237
- });
238
- child.stderr.on("data", (chunk) => {
239
- output += chunk;
240
- });
241
- child.once("error", (error) => resolve({
242
- ok: false,
243
- detail: error.message
244
- }));
245
- child.once("close", (code) => resolve({
246
- ok: code === 0,
247
- detail: output.trim() || `exited with status ${code ?? 1}`
248
- }));
249
- });
250
- }
251
- const tailscaleStatusResponseSchema = z.looseObject({
252
- BackendState: z.string().optional(),
253
- Self: z.looseObject({ DNSName: z.string().optional() }).optional()
254
- });
255
- const tailscaleServeConfigurationSchema = z.lazy(() => z.looseObject({
256
- TCP: z.record(z.string(), z.looseObject({})).optional(),
257
- Foreground: z.record(z.string(), tailscaleServeConfigurationSchema).optional(),
258
- Web: z.record(z.string(), z.looseObject({ Handlers: z.record(z.string(), z.looseObject({ Proxy: z.string().optional() })).optional() })).optional()
259
- }));
260
- async function tailscale(args) {
261
- return new Promise((resolve, reject) => {
262
- const child = spawn("tailscale", args, { stdio: [
263
- "ignore",
264
- "pipe",
265
- "pipe"
266
- ] });
267
- let stdout = "";
268
- let stderr = "";
269
- child.stdout.setEncoding("utf8");
270
- child.stderr.setEncoding("utf8");
271
- child.stdout.on("data", (chunk) => {
272
- stdout += chunk;
273
- });
274
- child.stderr.on("data", (chunk) => {
275
- stderr += chunk;
276
- });
277
- 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}`)));
278
- child.once("close", (code) => {
279
- if (code === 0) {
280
- resolve(stdout);
281
- return;
282
- }
283
- const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
284
- reject(/* @__PURE__ */ new Error(`Tailscale ${args[0]} failed${detail ? `: ${detail}` : ` (status ${code ?? 1})`}`));
285
- });
286
- });
287
- }
288
- function tailscaleJson(value, command, schema) {
289
- const result = schema.safeParse(JSON.parse(value));
290
- if (!result.success) throw new Error(`Tailscale ${command} returned an invalid JSON response`);
291
- return result.data;
292
- }
293
- function remotePreference(value) {
294
- if (value.remote === void 0) return null;
295
- 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");
296
- return value.remote;
297
- }
298
- function localProxyTarget(apiUrl) {
299
- if (!URL.canParse(apiUrl)) throw new Error("Treeport remote access requires a loopback daemon URL");
300
- const url = new URL(apiUrl);
301
- if (url.protocol !== "http:" || ![
302
- "127.0.0.1",
303
- "localhost",
304
- "::1",
305
- "[::1]"
306
- ].includes(url.hostname)) throw new Error("Treeport remote access requires a loopback daemon. Run `treeport start --host 127.0.0.1`, then try again.");
307
- return `http://${url.host}`;
308
- }
309
- function portIsServed(config, port) {
310
- const tcp = config.TCP;
311
- if (tcp && Object.hasOwn(tcp, String(port))) return true;
312
- return Object.values(config.Foreground ?? {}).some((value) => portIsServed(value, port));
313
- }
314
- function rootProxyForPort(config, port) {
315
- for (const [hostPort, server] of Object.entries(config.Web ?? {})) {
316
- if (!hostPort.endsWith(`:${port}`)) continue;
317
- const proxy = server.Handlers?.["/"]?.Proxy;
318
- if (proxy !== void 0) return proxy;
319
- }
320
- return null;
321
- }
322
- function proxyMatches(actual, expected) {
323
- return actual !== null && expected !== void 0 && actual.replace(/\/$/, "") === expected.replace(/\/$/, "");
324
- }
325
- async function tailscaleServeConfig() {
326
- return tailscaleJson(await tailscale([
327
- "serve",
328
- "status",
329
- "--json"
330
- ]), "serve status", tailscaleServeConfigurationSchema);
331
- }
332
- async function tailscaleRemoteUrl(port) {
333
- const status = tailscaleJson(await tailscale(["status", "--json"]), "status", tailscaleStatusResponseSchema);
334
- if (status.BackendState !== "Running") throw new Error("Tailscale is not connected. Run `tailscale up` then try again.");
335
- const dnsName = status.Self?.DNSName;
336
- if (!dnsName?.trim()) throw new Error("Tailscale did not report a DNS name. Enable MagicDNS, then try again.");
337
- return `https://${dnsName.trim().replace(/\.$/, "")}${port === 443 ? "" : `:${port}`}`;
338
- }
339
- async function enableTailscaleRemote(options) {
340
- 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");
341
- const saved = await preferences();
342
- const remote = remotePreference(saved);
343
- const port = options.port ?? remote?.port ?? DEFAULT_PORT;
344
- 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.`);
345
- const expectedTarget = localProxyTarget((await daemonStatus()).state?.apiUrl ?? await resolveLocalApiUrl());
346
- const [url, config] = await Promise.all([tailscaleRemoteUrl(port), tailscaleServeConfig()]);
347
- const existingTarget = rootProxyForPort(config, port);
348
- 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>\`.`);
349
- const target = localProxyTarget((options.daemon ?? await daemonUp({})).apiUrl);
350
- const alreadyEnabled = proxyMatches(existingTarget, target);
351
- if (!alreadyEnabled) await tailscale([
352
- "serve",
353
- "--bg",
354
- `--https=${port}`,
355
- target
356
- ]);
357
- await savePreferences({
358
- ...saved,
359
- remote: {
360
- port,
361
- target
362
- }
363
- });
364
- return {
365
- alreadyEnabled,
366
- port,
367
- url
368
- };
369
- }
370
- async function tailscaleRemoteStatus() {
371
- const remote = remotePreference(await preferences());
372
- if (!remote) return {
373
- configured: false,
374
- active: false,
375
- port: null,
376
- url: null
377
- };
378
- const [url, config] = await Promise.all([tailscaleRemoteUrl(remote.port), tailscaleServeConfig()]);
379
- return {
380
- configured: true,
381
- active: proxyMatches(rootProxyForPort(config, remote.port), remote.target),
382
- port: remote.port,
383
- url
384
- };
385
- }
386
- async function disableTailscaleRemote() {
387
- const saved = await preferences();
388
- const remote = remotePreference(saved);
389
- if (!remote) return {
390
- wasEnabled: false,
391
- changedTailscale: false
392
- };
393
- if (proxyMatches(rootProxyForPort(await tailscaleServeConfig(), remote.port), remote.target)) {
394
- await tailscale([
395
- "serve",
396
- `--https=${remote.port}`,
397
- "off"
398
- ]);
399
- delete saved.remote;
400
- await savePreferences(saved);
401
- return {
402
- wasEnabled: true,
403
- changedTailscale: true
404
- };
405
- }
406
- delete saved.remote;
407
- await savePreferences(saved);
408
- return {
409
- wasEnabled: false,
410
- changedTailscale: false
411
- };
412
- }
413
- async function runDoctor() {
414
- const paths = localPaths();
415
- const gitPath = process.env.TREEPORT_GIT_PATH?.trim() || "git";
416
- const tmuxPath = process.env.TREEPORT_TMUX_PATH?.trim() || "tmux";
417
- const [git, tmux] = await Promise.all([executableCheck(gitPath, ["--version"]), executableCheck(tmuxPath, ["-V"])]);
418
- const tmuxMatch = /tmux\s+(\d+)\.(\d+)/i.exec(tmux.detail);
419
- const tmuxSupported = Boolean(tmux.ok && tmuxMatch && (Number(tmuxMatch[1]) > 3 || Number(tmuxMatch[1]) === 3 && Number(tmuxMatch[2]) >= 2));
420
- const checkDirectory = (directoryPath) => fs.mkdir(directoryPath, {
421
- recursive: true,
422
- mode: 448
423
- }).then(() => ({
424
- ok: true,
425
- detail: directoryPath
426
- })).catch((error) => ({
427
- ok: false,
428
- detail: `${directoryPath}: ${error instanceof Error ? error.message : String(error)}`
429
- }));
430
- const [dataDirectory, runtimeDirectory] = await Promise.all([checkDirectory(paths.dataDir), checkDirectory(paths.runtimeDir)]);
431
- return [
432
- {
433
- name: "Node",
434
- ok: true,
435
- detail: process.version
436
- },
437
- {
438
- name: "Git",
439
- ...git
440
- },
441
- {
442
- name: "tmux",
443
- ok: tmuxSupported,
444
- detail: tmuxSupported ? tmux.detail : `${tmux.detail}. Treeport requires tmux 3.2 or newer.`
445
- },
446
- {
447
- name: "Data directory",
448
- ...dataDirectory
449
- },
450
- {
451
- name: "Runtime directory",
452
- ...runtimeDirectory
453
- }
454
- ];
455
- }
456
- async function daemonStatus() {
457
- const state = await readState();
458
- if (!state) return {
459
- running: false,
460
- state: null,
461
- health: null,
462
- verified: false
463
- };
464
- if (!processExists(state.pid)) {
465
- await removeStaleState(state);
466
- return {
467
- running: false,
468
- state: null,
469
- health: null,
470
- verified: false
471
- };
472
- }
473
- const observed = await daemonHealth(state.apiUrl);
474
- return {
475
- running: Boolean(observed),
476
- state,
477
- health: observed,
478
- verified: Boolean(observed && matchesOwnership(state, observed))
479
- };
480
- }
481
- async function daemonUp(options) {
482
- 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");
483
- const paths = localPaths();
484
- const saved = await preferences();
485
- const next = {
486
- ...saved,
487
- host: options.host?.trim() || saved.host || DEFAULT_HOST,
488
- port: options.port ?? saved.port ?? DEFAULT_PORT
489
- };
490
- const host = options.host?.trim() || process.env.TREEPORT_HOST?.trim() || process.env.HOST?.trim() || next.host;
491
- assertLoopbackHost(host);
492
- if (options.host !== void 0 || options.port !== void 0) await savePreferences(next);
493
- const port = Number.parseInt(options.port === void 0 ? process.env.TREEPORT_PORT?.trim() || process.env.PORT?.trim() || String(next.port) : String(options.port), 10);
494
- const apiUrl = options.host !== void 0 || options.port !== void 0 ? listenerUrl(host, port) : process.env.TREEPORT_API_URL?.trim() || listenerUrl(host, port);
495
- const currentVersion = await treeportVersion();
496
- const existing = await daemonStatus();
497
- if (existing.state) {
498
- 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}.`);
499
- if (existing.health?.version === currentVersion && existing.state.apiUrl === apiUrl) return {
500
- alreadyRunning: true,
501
- apiUrl: existing.state.apiUrl,
502
- pid: existing.state.pid
503
- };
504
- await stopOwned(existing.state);
505
- }
506
- const failedChecks = (await runDoctor()).filter((check) => !check.ok);
507
- if (failedChecks.length) throw new Error(failedChecks.map((check) => `${check.name}: ${check.detail}`).join("\n"));
508
- const serverEntry = await resolvePackagePath("dist", "node", "server", "index.js");
509
- const webDist = await resolvePackagePath("dist", "web");
510
- await fs.access(serverEntry);
511
- await fs.mkdir(path.dirname(paths.logPath), {
512
- recursive: true,
513
- mode: 448
514
- });
515
- if (await fs.stat(paths.logPath).then((value) => value.size).catch(() => 0) > 5 * 1024 * 1024) {
516
- await fs.rm(`${paths.logPath}.1`, { force: true });
517
- await fs.rename(paths.logPath, `${paths.logPath}.1`);
518
- }
519
- const instanceId = crypto.randomUUID();
520
- const childEnvironment = {
521
- ...process.env,
522
- TREEPORT_HOST: host,
523
- TREEPORT_PORT: String(port),
524
- TREEPORT_API_URL: apiUrl,
525
- TREEPORT_DATA_DIR: paths.dataDir,
526
- TREEPORT_RUNTIME_DIR: paths.runtimeDir,
527
- TREEPORT_APP_VERSION: currentVersion,
528
- TREEPORT_INSTANCE_ID: instanceId,
529
- TREEPORT_INSTALLATION_METHOD: process.env.TREEPORT_INSTALLATION_METHOD?.trim() || "npm",
530
- TREEPORT_DAEMON_LIFECYCLE: "treeport",
531
- TREEPORT_WEB_DIST: webDist
532
- };
533
- if (options.foreground) {
534
- console.log(`Treeport will listen on ${apiUrl}`);
535
- const child = spawn(process.execPath, [serverEntry], {
536
- env: childEnvironment,
537
- stdio: "inherit"
538
- });
539
- const code = await new Promise((resolve, reject) => {
540
- child.once("error", reject);
541
- child.once("close", (value) => resolve(value ?? 1));
542
- });
543
- if (code !== 0) throw new Error(`Treeport exited with status ${code}`);
544
- return {
545
- alreadyRunning: false,
546
- apiUrl,
547
- pid: child.pid ?? 0
548
- };
549
- }
550
- const log = fsSync.openSync(paths.logPath, "a", 384);
551
- const child = spawn(process.execPath, [serverEntry], {
552
- env: childEnvironment,
553
- detached: true,
554
- stdio: [
555
- "ignore",
556
- log,
557
- log
558
- ]
559
- });
560
- child.unref();
561
- fsSync.closeSync(log);
562
- const deadline = Date.now() + 15e3;
563
- while (Date.now() < deadline) {
564
- const observed = await daemonHealth(apiUrl, 500);
565
- if (observed && observed.pid === child.pid && observed.instanceId === instanceId && observed.version === currentVersion) return {
566
- alreadyRunning: false,
567
- apiUrl,
568
- pid: child.pid ?? observed.pid
569
- };
570
- if (child.pid && !processExists(child.pid)) break;
571
- await new Promise((resolve) => setTimeout(resolve, 100));
572
- }
573
- const recentLog = await fs.readFile(paths.logPath, "utf8").then((value) => value.split("\n").slice(-20).join("\n").trim()).catch(() => "");
574
- throw new Error(`Treeport did not become ready at ${apiUrl}. See ${paths.logPath}.${recentLog ? `\n\n${recentLog}` : ""}`);
575
- }
576
- async function daemonDown() {
577
- const state = await readState();
578
- if (!state) return { wasRunning: false };
579
- await stopOwned(state);
580
- return { wasRunning: true };
581
- }
582
- async function readDaemonLogs(lines = 100) {
583
- return (await fs.readFile(localPaths().logPath, "utf8").catch((error) => {
584
- if (error.code === "ENOENT") return "";
585
- throw error;
586
- })).split("\n").slice(-lines - 1).join("\n");
587
- }
588
- //#endregion
589
- //#region src/cli/service.ts
590
- const serviceRecordSchema = z.strictObject({
591
- schemaVersion: z.literal(1),
592
- manager: z.enum(["launchd", "systemd"]),
593
- platform: z.string(),
594
- uid: z.number().int().nonnegative(),
595
- gid: z.number().int().nonnegative(),
596
- username: z.string().min(1),
597
- group: z.string().min(1),
598
- home: z.string().min(1),
599
- dataDir: z.string().min(1),
600
- runtimeDir: z.string().min(1),
601
- logPath: z.string().min(1),
602
- apiUrl: z.string().min(1),
603
- cliEntrypoint: z.string().min(1),
604
- installationMethod: z.enum(["curl", "npm"]),
605
- definitionName: z.string().min(1),
606
- definitionPath: z.string().min(1),
607
- definitionHash: z.string().length(64),
608
- environmentHash: z.string().length(64),
609
- environment: z.record(z.string(), z.string()),
610
- requestedState: z.enum(["running", "stopped"]),
611
- pendingAdministratorRequestId: z.string().nullable(),
612
- createdAt: z.string(),
613
- updatedAt: z.string()
614
- });
615
- const administratorRequestSchema = z.strictObject({
616
- schemaVersion: z.literal(1),
617
- id: z.string().uuid(),
618
- operation: z.enum([
619
- "enable",
620
- "start",
621
- "stop",
622
- "disable"
623
- ]),
624
- createdAt: z.string(),
625
- expiresAt: z.string(),
626
- uid: z.number().int().nonnegative(),
627
- gid: z.number().int().nonnegative(),
628
- username: z.string().min(1),
629
- group: z.string().min(1),
630
- home: z.string().min(1),
631
- serviceRecordPath: z.string().min(1),
632
- runnerPath: z.string().min(1),
633
- definitionName: z.string().min(1),
634
- definitionPath: z.string().min(1),
635
- stagedDefinitionPath: z.string().min(1),
636
- definitionHash: z.string().length(64),
637
- apiUrl: z.string().min(1),
638
- cliEntrypoint: z.string().min(1)
639
- });
640
- function managerForPlatform(platform = process.platform) {
641
- return platform === "darwin" ? "launchd" : platform === "linux" ? "systemd" : null;
642
- }
643
- function servicePaths(env = process.env) {
644
- const paths = localPaths(env);
645
- const directory = path.join(paths.dataDir, "service");
646
- return {
647
- directory,
648
- recordPath: path.join(directory, "service.json"),
649
- runnerPath: path.join(directory, "run"),
650
- requestsDirectory: path.join(directory, "requests"),
651
- stagedDefinitionPath: path.join(directory, "treeport.plist")
652
- };
653
- }
654
- async function readJson(filePath, schema) {
655
- return fs.readFile(filePath, "utf8").then((value) => schema.parse(JSON.parse(value))).catch(() => null);
656
- }
657
- async function writeJson(filePath, value) {
658
- await fs.mkdir(path.dirname(filePath), {
659
- recursive: true,
660
- mode: 448
661
- });
662
- const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
663
- await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
664
- await fs.rename(temporaryPath, filePath);
665
- }
666
- function fingerprint(value) {
667
- const source = typeof value === "string" ? value : JSON.stringify(Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))));
668
- return crypto.createHash("sha256").update(source).digest("hex");
669
- }
670
- function xml(value) {
671
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
672
- }
673
- function shellQuote(value) {
674
- return `'${value.replaceAll("'", `'\\''`)}'`;
675
- }
676
- function systemdValue(value) {
677
- return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("%", "%%").replaceAll("\n", "\\n");
678
- }
679
- function createLaunchdDefinition(input) {
680
- return {
681
- label: input.label,
682
- programArguments: [input.runnerPath],
683
- username: input.username,
684
- group: input.group,
685
- environment: input.environment,
686
- workingDirectory: input.home,
687
- standardOutPath: input.logPath,
688
- standardErrorPath: input.logPath,
689
- keepAlive: true,
690
- processType: "Background",
691
- throttleInterval: 10,
692
- exitTimeOut: 10,
693
- abandonProcessGroup: true,
694
- umask: 63
695
- };
696
- }
697
- function serializeLaunchdDefinition(definition) {
698
- const environment = Object.entries(definition.environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, value]) => ` <key>${xml(name)}</key>\n <string>${xml(value)}</string>`).join("\n");
699
- const argumentsXml = definition.programArguments.map((argument) => ` <string>${xml(argument)}</string>`).join("\n");
700
- return `<?xml version="1.0" encoding="UTF-8"?>
701
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
702
- <plist version="1.0">
703
- <dict>
704
- <key>Label</key>
705
- <string>${xml(definition.label)}</string>
706
- <key>ProgramArguments</key>
707
- <array>
708
- ${argumentsXml}
709
- </array>
710
- <key>UserName</key>
711
- <string>${xml(definition.username)}</string>
712
- <key>GroupName</key>
713
- <string>${xml(definition.group)}</string>
714
- <key>EnvironmentVariables</key>
715
- <dict>
716
- ${environment}
717
- </dict>
718
- <key>WorkingDirectory</key>
719
- <string>${xml(definition.workingDirectory)}</string>
720
- <key>StandardOutPath</key>
721
- <string>${xml(definition.standardOutPath)}</string>
722
- <key>StandardErrorPath</key>
723
- <string>${xml(definition.standardErrorPath)}</string>
724
- <key>KeepAlive</key>
725
- <true/>
726
- <key>ProcessType</key>
727
- <string>${definition.processType}</string>
728
- <key>ThrottleInterval</key>
729
- <integer>${definition.throttleInterval}</integer>
730
- <key>ExitTimeOut</key>
731
- <integer>${definition.exitTimeOut}</integer>
732
- <key>AbandonProcessGroup</key>
733
- <true/>
734
- <key>Umask</key>
735
- <integer>${definition.umask}</integer>
736
- </dict>
737
- </plist>
738
- `;
739
- }
740
- function createSystemdDefinition(input) {
741
- return {
742
- description: "Treeport daemon",
743
- execStart: input.runnerPath,
744
- environment: input.environment,
745
- restart: "always",
746
- restartSeconds: 5,
747
- timeoutStopSeconds: 10,
748
- killMode: "process",
749
- wantedBy: "default.target"
750
- };
751
- }
752
- function serializeSystemdDefinition(definition) {
753
- const environment = Object.entries(definition.environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, value]) => `Environment="${systemdValue(name)}=${systemdValue(value)}"`).join("\n");
754
- return `[Unit]
755
- Description=${definition.description}
756
-
757
- [Service]
758
- Type=simple
759
- ExecStart="${systemdValue(definition.execStart)}"
760
- ${environment}
761
- Restart=${definition.restart}
762
- RestartSec=${definition.restartSeconds}
763
- TimeoutStopSec=${definition.timeoutStopSeconds}
764
- KillMode=${definition.killMode}
765
-
766
- [Install]
767
- WantedBy=${definition.wantedBy}
768
- `;
769
- }
770
- async function runCommand(executable, args, environment = process.env) {
771
- return new Promise((resolve) => {
772
- const child = spawn(executable, args, {
773
- env: environment,
774
- stdio: [
775
- "ignore",
776
- "pipe",
777
- "pipe"
778
- ]
779
- });
780
- let stdout = "";
781
- let stderr = "";
782
- child.stdout.setEncoding("utf8");
783
- child.stderr.setEncoding("utf8");
784
- child.stdout.on("data", (value) => {
785
- stdout += value;
786
- });
787
- child.stderr.on("data", (value) => {
788
- stderr += value;
789
- });
790
- child.once("error", (error) => {
791
- resolve({
792
- code: 127,
793
- stdout,
794
- stderr: error.message
795
- });
796
- });
797
- child.once("close", (code) => {
798
- resolve({
799
- code: code ?? 1,
800
- stdout,
801
- stderr
802
- });
803
- });
804
- });
805
- }
806
- function commandError(command, result) {
807
- const detail = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n");
808
- return /* @__PURE__ */ new Error(`${command} failed${detail ? `: ${detail}` : ` with status ${result.code}`}`);
809
- }
810
- async function executablePath(name) {
811
- const candidates = name === "launchctl" ? ["/bin/launchctl", "/usr/bin/launchctl"] : [`/usr/bin/${name}`, `/bin/${name}`];
812
- for (const candidate of candidates) if (await fs.access(candidate, constants.X_OK).then(() => true).catch(() => false)) return candidate;
813
- return name;
814
- }
815
- async function primaryGroup(username) {
816
- const result = await runCommand(await executablePath("id"), ["-gn", username]);
817
- if (result.code !== 0 || !result.stdout.trim()) throw commandError("id -gn", result);
818
- return result.stdout.trim();
819
- }
820
- function currentEntrypoint() {
821
- const value = process.env.TREEPORT_CLI_ENTRYPOINT?.trim() || process.argv[1]?.trim();
822
- return value ? path.resolve(value) : null;
823
- }
824
- async function ensureEntrypoint(installationMethod) {
825
- const entrypoint = currentEntrypoint();
826
- if (!entrypoint) throw new Error("Treeport could not identify a stable CLI entrypoint. Install Treeport with npm or the curl installer, then retry.");
827
- await fs.access(entrypoint, constants.X_OK).catch(() => {
828
- throw new Error(`Treeport cannot execute its stable CLI entrypoint at ${entrypoint}. Reinstall Treeport, then retry.`);
829
- });
830
- if (installationMethod === "npm") {
831
- const [actual, expected] = await Promise.all([fs.realpath(entrypoint), fs.realpath(await resolvePackagePath("bin", "treeport.mjs"))]);
832
- if (actual !== expected) throw new Error(`The current CLI entrypoint is not the installed Treeport npm bin: ${entrypoint}`);
833
- }
834
- return entrypoint;
835
- }
836
- function cacheDirectory(home, env) {
837
- const configured = env.TREEPORT_CACHE_DIR?.trim();
838
- if (configured) return path.resolve(configured.replace(/^~(?=\/|$)/, home));
839
- if (env.XDG_CACHE_HOME?.trim()) return path.join(path.resolve(env.XDG_CACHE_HOME.replace(/^~(?=\/|$)/, home)), "treeport");
840
- return process.platform === "darwin" ? path.join(home, "Library", "Caches", "treeport") : path.join(home, ".cache", "treeport");
841
- }
842
- function createServiceEnvironment(input) {
843
- const env = input.env ?? process.env;
844
- const url = new URL(input.apiUrl);
845
- assertLoopbackHost(url.hostname);
846
- const result = {
847
- HOME: input.user.homedir,
848
- USER: input.user.username,
849
- LOGNAME: input.user.username,
850
- PATH: env.PATH?.trim() || "/usr/local/bin:/usr/bin:/bin",
851
- TREEPORT_HOST: url.hostname,
852
- TREEPORT_PORT: url.port || "80",
853
- TREEPORT_API_URL: input.apiUrl,
854
- TREEPORT_DATA_DIR: input.paths.dataDir,
855
- TREEPORT_RUNTIME_DIR: input.paths.runtimeDir,
856
- TREEPORT_CACHE_DIR: cacheDirectory(input.user.homedir, env),
857
- TREEPORT_DATABASE_PATH: env.TREEPORT_DATABASE_PATH?.trim() || path.join(input.paths.dataDir, "treeport.db"),
858
- TREEPORT_SHELL: env.TREEPORT_SHELL?.trim() || env.SHELL?.trim() || "/bin/sh",
859
- TREEPORT_TMUX_PATH: env.TREEPORT_TMUX_PATH?.trim() || "tmux",
860
- TREEPORT_GIT_PATH: env.TREEPORT_GIT_PATH?.trim() || "git",
861
- TREEPORT_GH_PATH: env.TREEPORT_GH_PATH?.trim() || "gh",
862
- TREEPORT_DAEMON_LIFECYCLE: "service",
863
- TREEPORT_INSTALLATION_METHOD: input.installationMethod,
864
- TREEPORT_SERVICE_RECORD: input.recordPath
865
- };
866
- for (const [name, value] of Object.entries(env)) if (value !== void 0 && (name === "LANG" || name === "LC_ALL" || name.startsWith("LC_"))) result[name] = value;
867
- return result;
868
- }
869
- function definitionForRecord(record) {
870
- if (record.manager === "launchd") return serializeLaunchdDefinition(createLaunchdDefinition({
871
- label: record.definitionName,
872
- runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath,
873
- username: record.username,
874
- group: record.group,
875
- environment: record.environment,
876
- home: record.home,
877
- logPath: record.logPath
878
- }));
879
- return serializeSystemdDefinition(createSystemdDefinition({
880
- runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath,
881
- environment: record.environment
882
- }));
883
- }
884
- function runnerSource(record) {
885
- return `#!/bin/sh
886
- set -u
887
- entrypoint=${shellQuote(record.cliEntrypoint)}
888
- record=${shellQuote(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).recordPath)}
889
- log=${shellQuote(record.logPath)}
890
- reported=0
891
- while [ ! -x "$entrypoint" ]; do
892
- if [ "$reported" -eq 0 ]; then
893
- mkdir -p "$(dirname "$log")"
894
- printf '%s Treeport service cannot start because %s is missing. Reinstall Treeport, then run treeport service enable or treeport service disable.\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$entrypoint" >> "$log"
895
- reported=1
896
- fi
897
- sleep 60
898
- done
899
- export TREEPORT_SERVICE_RECORD="$record"
900
- exec "$entrypoint" service run
901
- `;
902
- }
903
- async function currentRecord() {
904
- return readJson(servicePaths().recordPath, serviceRecordSchema);
905
- }
906
- async function saveRecord(record) {
907
- await writeJson(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).recordPath, record);
908
- }
909
- async function managerState(record) {
910
- if (record.manager === "launchd") {
911
- const launchctl = await executablePath("launchctl");
912
- const [active, disabled, definitionExists] = await Promise.all([
913
- runCommand(launchctl, ["print", `system/${record.definitionName}`]),
914
- runCommand(launchctl, ["print-disabled", "system"]),
915
- fs.access(record.definitionPath).then(() => true).catch(() => false)
916
- ]);
917
- return {
918
- active: active.code === 0,
919
- enabled: definitionExists && !disabled.stdout.includes(`"${record.definitionName}" => true`),
920
- lingering: true,
921
- managerIssue: null
922
- };
923
- }
924
- const systemctl = await executablePath("systemctl");
925
- const [active, enabled, linger] = await Promise.all([
926
- runCommand(systemctl, [
927
- "--user",
928
- "is-active",
929
- record.definitionName
930
- ]),
931
- runCommand(systemctl, [
932
- "--user",
933
- "is-enabled",
934
- record.definitionName
935
- ]),
936
- runCommand(await executablePath("loginctl"), [
937
- "show-user",
938
- record.username,
939
- "-p",
940
- "Linger",
941
- "--value"
942
- ])
943
- ]);
944
- return {
945
- active: active.code === 0 && active.stdout.trim() === "active",
946
- enabled: enabled.code === 0 && enabled.stdout.trim() === "enabled",
947
- lingering: linger.code === 0 && linger.stdout.trim() === "yes",
948
- managerIssue: active.code === 127 || active.stderr.includes("Failed to connect to bus") || active.stderr.includes("No medium found") ? "The systemd user manager is not available." : linger.code === 127 ? "loginctl is not available." : null
949
- };
950
- }
951
- function administratorCommand(record) {
952
- const requestId = record.pendingAdministratorRequestId;
953
- if (!requestId) return null;
954
- const requestPath = path.join(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).requestsDirectory, `${requestId}.json`);
955
- return `sudo ${shellQuote(record.cliEntrypoint)} service apply --request ${shellQuote(requestPath)}`;
956
- }
957
- async function untrackedDefinition() {
958
- const manager = managerForPlatform();
959
- if (!manager) return null;
960
- const user = os.userInfo();
961
- const name = manager === "launchd" ? `app.treeport.daemon.${user.uid}` : "treeport.service";
962
- const definitionPath = manager === "launchd" ? `/Library/LaunchDaemons/${name}.plist` : path.join(process.env.XDG_CONFIG_HOME?.trim() || path.join(user.homedir, ".config"), "systemd", "user", name);
963
- return await fs.access(definitionPath).then(() => true).catch(() => false) ? {
964
- manager,
965
- name,
966
- path: definitionPath
967
- } : null;
968
- }
969
- async function serviceInstalled() {
970
- return await currentRecord() !== null || await untrackedDefinition() !== null;
971
- }
972
- async function serviceStatus() {
973
- const manager = managerForPlatform();
974
- const record = await currentRecord();
975
- if (!manager) return {
976
- supported: false,
977
- manager: null,
978
- state: "disabled",
979
- installed: false,
980
- enabledAtBoot: false,
981
- active: false,
982
- healthy: false,
983
- rebootReady: false,
984
- definitionMatches: false,
985
- environmentMatches: false,
986
- entrypointMatches: false,
987
- requestedState: null,
988
- definitionPath: null,
989
- entrypoint: null,
990
- daemon: null,
991
- issues: [`Treeport service mode does not support ${process.platform}.`],
992
- recoveryCommands: [],
993
- administratorCommand: null
994
- };
995
- if (!record) {
996
- const untracked = await untrackedDefinition();
997
- if (!untracked) return {
998
- supported: true,
999
- manager,
1000
- state: "disabled",
1001
- installed: false,
1002
- enabledAtBoot: false,
1003
- active: false,
1004
- healthy: false,
1005
- rebootReady: false,
1006
- definitionMatches: false,
1007
- environmentMatches: false,
1008
- entrypointMatches: false,
1009
- requestedState: null,
1010
- definitionPath: null,
1011
- entrypoint: null,
1012
- daemon: null,
1013
- issues: [],
1014
- recoveryCommands: ["treeport service enable"],
1015
- administratorCommand: null
1016
- };
1017
- return {
1018
- supported: true,
1019
- manager,
1020
- state: "stale",
1021
- installed: true,
1022
- enabledAtBoot: true,
1023
- active: (untracked.manager === "launchd" ? await runCommand(await executablePath("launchctl"), ["print", `system/${untracked.name}`]) : await runCommand(await executablePath("systemctl"), [
1024
- "--user",
1025
- "is-active",
1026
- untracked.name
1027
- ])).code === 0,
1028
- healthy: false,
1029
- rebootReady: false,
1030
- definitionMatches: false,
1031
- environmentMatches: false,
1032
- entrypointMatches: false,
1033
- requestedState: null,
1034
- definitionPath: untracked.path,
1035
- entrypoint: null,
1036
- daemon: null,
1037
- issues: [`A Treeport service definition exists at ${untracked.path}, but its service record is missing. Restore the original Treeport data directory or ask an administrator to inspect and remove the definition.`],
1038
- recoveryCommands: [],
1039
- administratorCommand: null
1040
- };
1041
- }
1042
- const paths = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
1043
- const [managerStatus, definitionContent, entrypointExists, daemon] = await Promise.all([
1044
- managerState(record),
1045
- fs.readFile(record.definitionPath, "utf8").catch(() => ""),
1046
- fs.access(record.cliEntrypoint, constants.X_OK).then(() => true).catch(() => false),
1047
- daemonStatus()
1048
- ]);
1049
- const definitionPresent = definitionContent !== "";
1050
- const definitionMatches = definitionPresent && fingerprint(definitionContent) === record.definitionHash;
1051
- const invokedEntrypoint = currentEntrypoint();
1052
- const entrypointMatches = entrypointExists && (invokedEntrypoint === null || path.resolve(invokedEntrypoint) === path.resolve(record.cliEntrypoint));
1053
- const environmentMatches = fingerprint(createServiceEnvironment({
1054
- user: {
1055
- uid: record.uid,
1056
- gid: record.gid,
1057
- username: record.username,
1058
- homedir: record.home,
1059
- shell: record.environment.TREEPORT_SHELL ?? null
1060
- },
1061
- paths: localPaths({
1062
- TREEPORT_DATA_DIR: record.dataDir,
1063
- TREEPORT_RUNTIME_DIR: record.runtimeDir
1064
- }),
1065
- apiUrl: record.apiUrl,
1066
- recordPath: paths.recordPath,
1067
- installationMethod: record.installationMethod
1068
- })) === record.environmentHash;
1069
- const healthy = Boolean(daemon.verified && daemon.health?.daemonLifecycle === "service" && daemon.state?.daemonLifecycle === "service" && path.resolve(daemon.state.dataDir) === path.resolve(record.dataDir));
1070
- const installed = managerStatus.enabled;
1071
- const rebootReady = installed && (record.manager === "launchd" || managerStatus.lingering);
1072
- const pendingCommand = administratorCommand(record) ?? (record.manager === "systemd" && managerStatus.enabled && !managerStatus.lingering ? `sudo loginctl enable-linger ${record.username}` : null);
1073
- const issues = [];
1074
- const recoveryCommands = [];
1075
- if (record.manager !== manager) issues.push(`The service record uses ${record.manager}, but this host requires ${manager}.`);
1076
- if (!definitionMatches && !record.pendingAdministratorRequestId) {
1077
- issues.push(definitionPresent ? `The service definition at ${record.definitionPath} was changed.` : `The service definition is missing at ${record.definitionPath}.`);
1078
- recoveryCommands.push("treeport service enable");
1079
- }
1080
- if (definitionMatches && !installed && !record.pendingAdministratorRequestId) {
1081
- issues.push("The service definition is not enabled for startup after reboot.");
1082
- recoveryCommands.push("treeport service enable");
1083
- }
1084
- if (!entrypointMatches) {
1085
- issues.push(`The service CLI entrypoint is unavailable or moved: ${record.cliEntrypoint}`);
1086
- recoveryCommands.push("treeport service enable");
1087
- }
1088
- if (!environmentMatches) {
1089
- issues.push("The service environment differs from the current Treeport environment.");
1090
- recoveryCommands.push("treeport service enable");
1091
- }
1092
- if (record.manager === "systemd" && installed && !managerStatus.lingering) {
1093
- issues.push(`User lingering is disabled for ${record.username}.`);
1094
- recoveryCommands.push(`sudo loginctl enable-linger ${record.username}`);
1095
- }
1096
- if (managerStatus.managerIssue) issues.push(managerStatus.managerIssue);
1097
- if (installed && record.requestedState === "running" && !healthy && !record.pendingAdministratorRequestId) {
1098
- issues.push("The supervised Treeport daemon is not healthy.");
1099
- recoveryCommands.push("treeport start");
1100
- }
1101
- const stale = record.manager !== manager || !definitionMatches || !environmentMatches || !entrypointMatches || definitionPresent && !installed || managerStatus.managerIssue !== null;
1102
- return {
1103
- supported: true,
1104
- manager,
1105
- state: record.pendingAdministratorRequestId || record.manager === "systemd" && installed && !managerStatus.lingering ? "action_required" : stale ? "stale" : healthy ? "healthy" : installed && record.requestedState === "stopped" ? "stopped" : installed && managerStatus.active ? "starting" : installed ? "unhealthy" : "disabled",
1106
- installed,
1107
- enabledAtBoot: installed,
1108
- active: managerStatus.active,
1109
- healthy,
1110
- rebootReady,
1111
- definitionMatches,
1112
- environmentMatches,
1113
- entrypointMatches,
1114
- requestedState: record.requestedState,
1115
- definitionPath: record.definitionPath,
1116
- entrypoint: record.cliEntrypoint,
1117
- daemon,
1118
- issues,
1119
- recoveryCommands: [...new Set(recoveryCommands)],
1120
- administratorCommand: pendingCommand
1121
- };
1122
- }
1123
- async function prepareRecord() {
1124
- if (process.getuid?.() === 0) throw new Error("Run `treeport service enable` as the user who will run Treeport, not as root.");
1125
- const manager = managerForPlatform();
1126
- if (!manager) throw new Error(`Treeport service mode supports macOS launchd and Linux systemd; found ${process.platform}.`);
1127
- const explicitApiUrl = process.env.TREEPORT_API_URL?.trim();
1128
- if (explicitApiUrl) assertLoopbackHost(new URL(explicitApiUrl).hostname);
1129
- const user = os.userInfo();
1130
- const paths = localPaths();
1131
- const locations = servicePaths();
1132
- const apiUrl = await resolveLocalApiUrl();
1133
- const listener = new URL(apiUrl);
1134
- if (listener.protocol !== "http:") throw new Error("Treeport service mode requires a local HTTP loopback URL.");
1135
- assertLoopbackHost(listener.hostname);
1136
- const installationMethod = process.env.TREEPORT_INSTALLATION_METHOD?.trim() === "curl" ? "curl" : "npm";
1137
- const cliEntrypoint = await ensureEntrypoint(installationMethod);
1138
- const group = await primaryGroup(user.username);
1139
- const definitionName = manager === "launchd" ? `app.treeport.daemon.${user.uid}` : "treeport.service";
1140
- const definitionPath = manager === "launchd" ? `/Library/LaunchDaemons/${definitionName}.plist` : path.join(process.env.XDG_CONFIG_HOME?.trim() || path.join(user.homedir, ".config"), "systemd", "user", definitionName);
1141
- const environment = createServiceEnvironment({
1142
- user,
1143
- paths,
1144
- apiUrl,
1145
- recordPath: locations.recordPath,
1146
- installationMethod
1147
- });
1148
- const now = (/* @__PURE__ */ new Date()).toISOString();
1149
- const previous = await currentRecord();
1150
- if (await fs.access(definitionPath).then(() => true).catch(() => false) && !previous) throw new Error(`A Treeport service definition already exists at ${definitionPath}. Disable it from its original data directory before enabling another service.`);
1151
- if (previous && path.resolve(previous.dataDir) !== paths.dataDir) throw new Error(`Treeport service mode already uses ${previous.dataDir}. Disable it before enabling ${paths.dataDir}.`);
1152
- const base = {
1153
- schemaVersion: 1,
1154
- manager,
1155
- platform: process.platform,
1156
- uid: user.uid,
1157
- gid: user.gid,
1158
- username: user.username,
1159
- group,
1160
- home: user.homedir,
1161
- dataDir: paths.dataDir,
1162
- runtimeDir: paths.runtimeDir,
1163
- logPath: paths.logPath,
1164
- apiUrl,
1165
- cliEntrypoint,
1166
- installationMethod,
1167
- definitionName,
1168
- definitionPath,
1169
- definitionHash: "0".repeat(64),
1170
- environmentHash: fingerprint(environment),
1171
- environment,
1172
- requestedState: "running",
1173
- pendingAdministratorRequestId: null,
1174
- createdAt: previous?.createdAt ?? now,
1175
- updatedAt: now
1176
- };
1177
- const definition = definitionForRecord(base);
1178
- return {
1179
- record: {
1180
- ...base,
1181
- definitionHash: fingerprint(definition)
1182
- },
1183
- definition
1184
- };
1185
- }
1186
- async function writeServiceFiles(record, definition) {
1187
- const locations = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
1188
- await Promise.all([fs.mkdir(path.dirname(record.logPath), {
1189
- recursive: true,
1190
- mode: 448
1191
- }), fs.mkdir(locations.requestsDirectory, {
1192
- recursive: true,
1193
- mode: 448
1194
- })]);
1195
- await fs.writeFile(locations.runnerPath, runnerSource(record), { mode: 448 });
1196
- await fs.chmod(locations.runnerPath, 448);
1197
- if (record.manager === "launchd") await fs.writeFile(locations.stagedDefinitionPath, definition, { mode: 384 });
1198
- else {
1199
- await fs.mkdir(path.dirname(record.definitionPath), {
1200
- recursive: true,
1201
- mode: 448
1202
- });
1203
- const temporaryPath = `${record.definitionPath}.${process.pid}.tmp`;
1204
- await fs.writeFile(temporaryPath, definition, { mode: 384 });
1205
- await fs.rename(temporaryPath, record.definitionPath);
1206
- }
1207
- await saveRecord(record);
1208
- }
1209
- async function prepareAdministratorRequest(record, operation) {
1210
- const locations = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
1211
- const id = crypto.randomUUID();
1212
- const now = /* @__PURE__ */ new Date();
1213
- const request = {
1214
- schemaVersion: 1,
1215
- id,
1216
- operation,
1217
- createdAt: now.toISOString(),
1218
- expiresAt: new Date(now.getTime() + 15 * 6e4).toISOString(),
1219
- uid: record.uid,
1220
- gid: record.gid,
1221
- username: record.username,
1222
- group: record.group,
1223
- home: record.home,
1224
- serviceRecordPath: locations.recordPath,
1225
- runnerPath: locations.runnerPath,
1226
- definitionName: record.definitionName,
1227
- definitionPath: record.definitionPath,
1228
- stagedDefinitionPath: locations.stagedDefinitionPath,
1229
- definitionHash: record.definitionHash,
1230
- apiUrl: record.apiUrl,
1231
- cliEntrypoint: record.cliEntrypoint
1232
- };
1233
- await writeJson(path.join(locations.requestsDirectory, `${id}.json`), request);
1234
- const next = {
1235
- ...record,
1236
- pendingAdministratorRequestId: id,
1237
- updatedAt: now.toISOString()
1238
- };
1239
- await saveRecord(next);
1240
- return {
1241
- record: next,
1242
- command: administratorCommand(next)
1243
- };
1244
- }
1245
- async function waitForService(record) {
1246
- const deadline = Date.now() + 15e3;
1247
- const version = await treeportVersion();
1248
- while (Date.now() < deadline) {
1249
- const observed = await daemonHealth(record.apiUrl, 500);
1250
- if (observed?.daemonLifecycle === "service" && observed.instanceId && observed.version === version) return;
1251
- await new Promise((resolve) => setTimeout(resolve, 150));
1252
- }
1253
- throw new Error(`Treeport service did not become ready at ${record.apiUrl}. See ${record.logPath}.`);
1254
- }
1255
- async function serviceEnable() {
1256
- const existing = await serviceStatus();
1257
- if (existing.state === "healthy" && existing.definitionMatches && existing.environmentMatches && existing.entrypointMatches) return {
1258
- status: existing,
1259
- changed: false,
1260
- administratorCommand: null
1261
- };
1262
- const { record, definition } = await prepareRecord();
1263
- const failedChecks = (await runDoctor()).filter((check) => !check.ok);
1264
- if (failedChecks.length) throw new Error(failedChecks.map((check) => `${check.name}: ${check.detail}`).join("\n"));
1265
- const systemctl = record.manager === "systemd" ? await executablePath("systemctl") : null;
1266
- if (systemctl) {
1267
- const managerAvailable = await runCommand(systemctl, ["--user", "show-environment"]);
1268
- if (managerAvailable.code !== 0) throw commandError("systemctl --user", managerAvailable);
1269
- }
1270
- await writeServiceFiles(record, definition);
1271
- if (record.manager === "launchd") {
1272
- await daemonDown();
1273
- const prepared = await prepareAdministratorRequest(record, "enable");
1274
- return {
1275
- status: await serviceStatus(),
1276
- changed: true,
1277
- administratorCommand: prepared.command
1278
- };
1279
- }
1280
- if (!systemctl) throw new Error("Treeport could not resolve the systemd command.");
1281
- await daemonDown();
1282
- const reload = await runCommand(systemctl, ["--user", "daemon-reload"]);
1283
- if (reload.code !== 0) {
1284
- await Promise.all([fs.rm(record.definitionPath, { force: true }), fs.rm(servicePaths().directory, {
1285
- recursive: true,
1286
- force: true
1287
- })]);
1288
- await daemonUp({});
1289
- throw commandError("systemctl --user daemon-reload", reload);
1290
- }
1291
- const enabled = await runCommand(systemctl, [
1292
- "--user",
1293
- "enable",
1294
- "--now",
1295
- record.definitionName
1296
- ]);
1297
- if (enabled.code !== 0) {
1298
- await fs.rm(record.definitionPath, { force: true });
1299
- await runCommand(systemctl, ["--user", "daemon-reload"]);
1300
- await fs.rm(servicePaths().directory, {
1301
- recursive: true,
1302
- force: true
1303
- });
1304
- await daemonUp({});
1305
- throw commandError("systemctl --user enable --now", enabled);
1306
- }
1307
- const startupError = await waitForService(record).then(() => null, (error) => error);
1308
- if (startupError) {
1309
- await runCommand(systemctl, [
1310
- "--user",
1311
- "disable",
1312
- "--now",
1313
- record.definitionName
1314
- ]);
1315
- await fs.rm(record.definitionPath, { force: true });
1316
- await runCommand(systemctl, ["--user", "daemon-reload"]);
1317
- await fs.rm(servicePaths().directory, {
1318
- recursive: true,
1319
- force: true
1320
- });
1321
- await daemonUp({});
1322
- throw startupError;
1323
- }
1324
- const status = await serviceStatus();
1325
- return {
1326
- status,
1327
- changed: true,
1328
- administratorCommand: status.administratorCommand
1329
- };
1330
- }
1331
- async function serviceStart() {
1332
- const record = await currentRecord();
1333
- if (!record) throw new Error("Treeport service mode is disabled. Run `treeport service enable` first.");
1334
- const current = await serviceStatus();
1335
- if (current.state === "healthy") return {
1336
- status: current,
1337
- changed: false,
1338
- administratorCommand: null
1339
- };
1340
- if (current.administratorCommand) return {
1341
- status: current,
1342
- changed: false,
1343
- administratorCommand: current.administratorCommand
1344
- };
1345
- if (!current.definitionMatches || !current.entrypointMatches) throw new Error("The Treeport service definition is stale. Run `treeport service enable` to repair it.");
1346
- const next = {
1347
- ...record,
1348
- requestedState: "running",
1349
- pendingAdministratorRequestId: null,
1350
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1351
- };
1352
- await saveRecord(next);
1353
- if (record.manager === "launchd") {
1354
- const prepared = await prepareAdministratorRequest(next, "start");
1355
- return {
1356
- status: await serviceStatus(),
1357
- changed: true,
1358
- administratorCommand: prepared.command
1359
- };
1360
- }
1361
- const result = await runCommand(await executablePath("systemctl"), [
1362
- "--user",
1363
- "start",
1364
- record.definitionName
1365
- ]);
1366
- if (result.code !== 0) throw commandError("systemctl --user start", result);
1367
- await waitForService(next);
1368
- return {
1369
- status: await serviceStatus(),
1370
- changed: true,
1371
- administratorCommand: null
1372
- };
1373
- }
1374
- async function serviceStop() {
1375
- const record = await currentRecord();
1376
- if (!record) throw new Error("Treeport service mode is disabled.");
1377
- const current = await serviceStatus();
1378
- if (current.state === "stopped") return {
1379
- status: current,
1380
- changed: false,
1381
- administratorCommand: null
1382
- };
1383
- const next = {
1384
- ...record,
1385
- requestedState: "stopped",
1386
- pendingAdministratorRequestId: null,
1387
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1388
- };
1389
- await saveRecord(next);
1390
- if (record.manager === "launchd") {
1391
- const prepared = await prepareAdministratorRequest(next, "stop");
1392
- return {
1393
- status: await serviceStatus(),
1394
- changed: true,
1395
- administratorCommand: prepared.command
1396
- };
1397
- }
1398
- const result = await runCommand(await executablePath("systemctl"), [
1399
- "--user",
1400
- "stop",
1401
- record.definitionName
1402
- ]);
1403
- if (result.code !== 0) {
1404
- await saveRecord(record);
1405
- throw commandError("systemctl --user stop", result);
1406
- }
1407
- return {
1408
- status: await serviceStatus(),
1409
- changed: true,
1410
- administratorCommand: null
1411
- };
1412
- }
1413
- async function serviceDisable() {
1414
- const record = await currentRecord();
1415
- if (!record) return {
1416
- status: await serviceStatus(),
1417
- changed: false,
1418
- administratorCommand: null
1419
- };
1420
- if (record.manager === "launchd") {
1421
- const prepared = await prepareAdministratorRequest({
1422
- ...record,
1423
- pendingAdministratorRequestId: null
1424
- }, "disable");
1425
- return {
1426
- status: await serviceStatus(),
1427
- changed: true,
1428
- administratorCommand: prepared.command
1429
- };
1430
- }
1431
- const systemctl = await executablePath("systemctl");
1432
- const disabled = await runCommand(systemctl, [
1433
- "--user",
1434
- "disable",
1435
- "--now",
1436
- record.definitionName
1437
- ]);
1438
- if (disabled.code !== 0 && !disabled.stderr.includes("does not exist")) throw commandError("systemctl --user disable --now", disabled);
1439
- await fs.rm(record.definitionPath, { force: true });
1440
- await runCommand(systemctl, ["--user", "daemon-reload"]);
1441
- await fs.rm(servicePaths().directory, {
1442
- recursive: true,
1443
- force: true
1444
- });
1445
- return {
1446
- status: await serviceStatus(),
1447
- changed: true,
1448
- administratorCommand: null
1449
- };
1450
- }
1451
- async function serviceApply(requestPath) {
1452
- if (process.platform !== "darwin") throw new Error("Treeport service apply is only available for macOS LaunchDaemons.");
1453
- if (process.getuid?.() !== 0) throw new Error("Run the printed service apply command with sudo or as root.");
1454
- if (!path.isAbsolute(requestPath)) throw new Error("The service apply request path must be absolute.");
1455
- const metadata = await fs.lstat(requestPath);
1456
- if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("The service apply request must be a regular file, not a symlink.");
1457
- if ((metadata.mode & 63) !== 0) throw new Error("The service apply request must not be readable or writable by other users.");
1458
- const request = await readJson(requestPath, administratorRequestSchema);
1459
- if (!request) throw new Error("The service apply request is invalid.");
1460
- if (metadata.uid !== request.uid) throw new Error("The service apply request owner does not match its target user.");
1461
- if (Date.parse(request.expiresAt) <= Date.now()) throw new Error("The service apply request expired. Run the original Treeport command again.");
1462
- const usedPath = `${requestPath}.used`;
1463
- if (await fs.access(usedPath).then(() => true).catch(() => false)) throw new Error("The service apply request was already used.");
1464
- const account = os.userInfo({ encoding: "utf8" });
1465
- const idResult = await runCommand(await executablePath("id"), ["-u", request.username]);
1466
- if (idResult.code !== 0 || Number(idResult.stdout.trim()) !== request.uid) throw new Error("The service apply target user no longer matches the host account.");
1467
- const record = await readJson(request.serviceRecordPath, serviceRecordSchema);
1468
- if (!record || record.uid !== request.uid || record.username !== request.username || record.manager !== "launchd" || record.definitionName !== request.definitionName || record.definitionPath !== request.definitionPath || record.cliEntrypoint !== request.cliEntrypoint || record.definitionHash !== request.definitionHash || record.pendingAdministratorRequestId !== request.id) throw new Error("The service apply request does not match the current Treeport service record.");
1469
- if (account.uid !== 0) throw new Error("Treeport service apply lost root privileges.");
1470
- const launchctl = await executablePath("launchctl");
1471
- const target = `system/${request.definitionName}`;
1472
- if (request.operation === "enable") {
1473
- const staged = await fs.readFile(request.stagedDefinitionPath, "utf8");
1474
- if (fingerprint(staged) !== request.definitionHash || !staged.includes(`<string>${xml(request.username)}</string>`) || !staged.includes(`<string>${xml(request.runnerPath)}</string>`)) throw new Error("The staged LaunchDaemon definition does not match the approved request.");
1475
- const temporaryPath = `${request.definitionPath}.${process.pid}.tmp`;
1476
- await fs.copyFile(request.stagedDefinitionPath, temporaryPath);
1477
- await fs.chown(temporaryPath, 0, 0);
1478
- await fs.chmod(temporaryPath, 420);
1479
- await fs.rename(temporaryPath, request.definitionPath);
1480
- await runCommand(launchctl, ["bootout", target]);
1481
- const enabled = await runCommand(launchctl, ["enable", target]);
1482
- if (enabled.code !== 0) throw commandError("launchctl enable", enabled);
1483
- const bootstrapped = await runCommand(launchctl, [
1484
- "bootstrap",
1485
- "system",
1486
- request.definitionPath
1487
- ]);
1488
- if (bootstrapped.code !== 0) throw commandError("launchctl bootstrap", bootstrapped);
1489
- } else if (request.operation === "start") {
1490
- const enabled = await runCommand(launchctl, ["enable", target]);
1491
- if (enabled.code !== 0) throw commandError("launchctl enable", enabled);
1492
- const started = (await runCommand(launchctl, ["print", target])).code === 0 ? await runCommand(launchctl, ["kickstart", target]) : await runCommand(launchctl, [
1493
- "bootstrap",
1494
- "system",
1495
- request.definitionPath
1496
- ]);
1497
- if (started.code !== 0) throw commandError("launchctl start", started);
1498
- } else if (request.operation === "stop") {
1499
- const stopped = await runCommand(launchctl, ["bootout", target]);
1500
- if (stopped.code !== 0 && !stopped.stderr.includes("No such process")) throw commandError("launchctl bootout", stopped);
1501
- } else {
1502
- const installed = await fs.readFile(request.definitionPath, "utf8").catch(() => "");
1503
- if (installed && fingerprint(installed) !== request.definitionHash) throw new Error("Refusing to remove a LaunchDaemon definition that Treeport did not create.");
1504
- await runCommand(launchctl, ["bootout", target]);
1505
- await fs.rm(request.definitionPath, { force: true });
1506
- }
1507
- if (request.operation === "enable" || request.operation === "start") await waitForService(record);
1508
- await fs.rename(requestPath, usedPath);
1509
- if (request.operation === "disable") await fs.rm(path.dirname(request.serviceRecordPath), {
1510
- recursive: true,
1511
- force: true
1512
- });
1513
- else {
1514
- await writeJson(request.serviceRecordPath, {
1515
- ...record,
1516
- requestedState: request.operation === "stop" ? "stopped" : "running",
1517
- pendingAdministratorRequestId: null,
1518
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1519
- });
1520
- await fs.chown(request.serviceRecordPath, request.uid, request.gid);
1521
- }
1522
- return {
1523
- operation: request.operation,
1524
- applied: true
1525
- };
1526
- }
1527
- async function serviceRun() {
1528
- const recordPath = process.env.TREEPORT_SERVICE_RECORD?.trim();
1529
- if (!recordPath || !path.isAbsolute(recordPath)) throw new Error("Treeport service run requires a valid service record.");
1530
- const record = await readJson(recordPath, serviceRecordSchema);
1531
- if (!record) throw new Error(`Treeport service record is invalid: ${recordPath}`);
1532
- if (process.getuid?.() === 0 || process.getuid?.() !== record.uid) throw new Error(`Treeport service must run as ${record.username} (UID ${record.uid}), never as root.`);
1533
- await writeJson(recordPath, {
1534
- ...record,
1535
- requestedState: "running",
1536
- pendingAdministratorRequestId: null,
1537
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1538
- });
1539
- const [version, serverEntry, webDist] = await Promise.all([
1540
- treeportVersion(),
1541
- resolvePackagePath("dist", "node", "server", "index.js"),
1542
- resolvePackagePath("dist", "web")
1543
- ]);
1544
- Object.assign(process.env, record.environment, {
1545
- TREEPORT_APP_VERSION: version,
1546
- TREEPORT_INSTANCE_ID: crypto.randomUUID(),
1547
- TREEPORT_WEB_DIST: webDist,
1548
- TREEPORT_DAEMON_LIFECYCLE: "service"
1549
- });
1550
- await import(pathToFileURL(serverEntry).href);
1551
- }
1552
- async function readServiceLogs(lines) {
1553
- const record = await currentRecord();
1554
- if (!record || record.manager === "launchd") return (await fs.readFile(record?.logPath ?? localPaths().logPath, "utf8").catch((error) => {
1555
- if (error.code === "ENOENT") return "";
1556
- throw error;
1557
- })).split("\n").slice(-lines - 1).join("\n");
1558
- const result = await runCommand(await executablePath("journalctl"), [
1559
- "--user",
1560
- "--unit",
1561
- record.definitionName,
1562
- "--no-pager",
1563
- "--lines",
1564
- String(lines)
1565
- ]);
1566
- if (result.code !== 0) throw commandError("journalctl --user", result);
1567
- return result.stdout;
1568
- }
1569
- async function serviceDoctorCheck() {
1570
- const status = await serviceStatus();
1571
- if (!status.supported) return {
1572
- name: "Service supervision",
1573
- ok: false,
1574
- detail: status.issues.join(" ")
1575
- };
1576
- if (status.state === "disabled") return {
1577
- name: "Service supervision",
1578
- ok: true,
1579
- detail: "disabled (opt in with `treeport service enable`)"
1580
- };
1581
- if (status.state === "healthy") return {
1582
- name: "Service supervision",
1583
- ok: true,
1584
- detail: `${status.manager}; enabled at boot and healthy`
1585
- };
1586
- if (status.state === "stopped") return {
1587
- name: "Service supervision",
1588
- ok: true,
1589
- detail: `${status.manager}; intentionally stopped and enabled for next boot`
1590
- };
1591
- return {
1592
- name: "Service supervision",
1593
- ok: false,
1594
- detail: status.issues.join(" ") || `state: ${status.state}`
1595
- };
1596
- }
1597
- //#endregion
1598
80
  //#region src/cli/application.ts
1599
81
  const contextPrefix = "TREEPORT";
1600
82
  let configuredApiUrl;
@@ -1612,6 +94,7 @@ let writeStderr = (value) => {
1612
94
  process.stderr.write(value);
1613
95
  };
1614
96
  let requestedExitCode = 0;
97
+ let cliEnvironment = process.env;
1615
98
  var CliError = class extends Error {
1616
99
  exitCode;
1617
100
  code;
@@ -1633,10 +116,12 @@ async function resolveDaemonLifecycle() {
1633
116
  return await serviceInstalled() ? "service" : "treeport";
1634
117
  }
1635
118
  function formatServiceStatus(status) {
119
+ const mode = status.mode === "headless" ? "advanced headless (starts before login)" : status.mode === "user" && status.manager === "launchd" ? "user/login (starts after login)" : status.mode === "user" ? "user service" : "not installed";
1636
120
  const lines = [
1637
121
  `Treeport service: ${status.state}`,
122
+ `Mode: ${mode}`,
1638
123
  `Manager: ${status.manager ?? "unsupported"}`,
1639
- `Starts at boot: ${status.enabledAtBoot ? "yes" : "no"}`,
124
+ `Starts before login: ${status.enabledAtBoot ? "yes" : "no"}`,
1640
125
  `Active: ${status.active ? "yes" : "no"}`,
1641
126
  `Definition: ${status.definitionPath ?? "not installed"}`
1642
127
  ];
@@ -1668,14 +153,13 @@ async function request(pathname, options = {}) {
1668
153
  else externalSignal?.addEventListener("abort", abort, { once: true });
1669
154
  const timeout = setTimeout(abort, 9e4);
1670
155
  try {
156
+ const headers = new Headers({ accept: "application/json" });
157
+ if (options.body) headers.set("content-type", "application/json");
158
+ new Headers(options.headers).forEach((value, key) => headers.set(key, value));
1671
159
  const response = await fetch(`${apiUrl}${pathname}`, {
1672
160
  ...options,
1673
161
  signal: controller.signal,
1674
- headers: {
1675
- accept: "application/json",
1676
- ...options.body ? { "content-type": "application/json" } : {},
1677
- ...options.headers
1678
- }
162
+ headers
1679
163
  });
1680
164
  const body = await response.json().catch(() => ({}));
1681
165
  if (!response.ok) {
@@ -1700,18 +184,18 @@ async function createWorktree(projectId, input) {
1700
184
  await new Promise((resolve) => setTimeout(resolve, 100));
1701
185
  operation = (await request(`/api/operations/${encodeURIComponent(operation.id)}`)).operation;
1702
186
  }
1703
- if (operation.status === "failed") throw new CliError(operation.error ?? "Worktree creation failed", 5, "WORKTREE_CREATION_FAILED");
1704
- if (operation.kind !== "create") throw new CliError("Worktree creation returned an unexpected operation kind", 5, "INVALID_OPERATION_RESULT");
1705
- const worktreeId = typeof operation.result?.worktreeId === "string" ? operation.result.worktreeId : operation.worktreeId;
1706
- if (!worktreeId) throw new CliError("Completed worktree creation did not identify its worktree", 5, "INVALID_OPERATION_RESULT");
187
+ if (operation.status === "failed") throw new CliError(operation.error ?? "Tree creation failed", 5, "WORKTREE_CREATION_FAILED");
188
+ if (operation.kind !== "create") throw new CliError("Tree creation returned an unexpected operation kind", 5, "INVALID_OPERATION_RESULT");
189
+ const worktreeId = operation.result?.worktreeId ?? operation.worktreeId;
190
+ if (!worktreeId) throw new CliError("Completed tree creation did not identify its tree", 5, "INVALID_OPERATION_RESULT");
1707
191
  const worktree = (await request(`/api/projects/${encodeURIComponent(projectId)}`)).project.worktrees.find((item) => item.id === worktreeId);
1708
- if (!worktree) throw new CliError(`Created worktree ${worktreeId} was not found`, 5, "INVALID_OPERATION_RESULT");
1709
- const terminalId = typeof operation.result?.terminalId === "string" ? operation.result.terminalId : null;
192
+ if (!worktree) throw new CliError(`Created tree ${worktreeId} was not found`, 5, "INVALID_OPERATION_RESULT");
193
+ const terminalId = operation.result?.terminalId ?? null;
1710
194
  return {
1711
195
  worktree,
1712
196
  terminal: worktree.terminals.find((item) => item.id === terminalId) ?? null,
1713
- terminalError: typeof operation.result?.terminalError === "string" ? operation.result.terminalError : null,
1714
- setupError: typeof operation.result?.setupError === "string" ? operation.result.setupError : null
197
+ terminalError: operation.result?.terminalError ?? null,
198
+ setupError: operation.result?.setupError ?? null
1715
199
  };
1716
200
  }
1717
201
  function commandArgv(args) {
@@ -1741,9 +225,12 @@ async function resolveProject(identifier) {
1741
225
  const environmentMatch = list.find((project) => project.id === contextProjectId);
1742
226
  if (environmentMatch) return environmentMatch;
1743
227
  }
1744
- const candidate = await canonical(identifier);
1745
- const match = list.find((project) => pathContains(candidate, project.repositoryPath) || project.worktrees.some((worktree) => pathContains(candidate, worktree.path)));
1746
- if (!match) throw new CliError(`No registered project matches ${identifier}`, 5);
228
+ const candidate = await canonical(identifier ?? ".");
229
+ const match = list.flatMap((project) => [project.rootPath, ...project.worktrees.map((item) => item.path)].map((root) => ({
230
+ project,
231
+ root
232
+ }))).filter(({ root }) => pathContains(candidate, root)).sort((left, right) => right.root.length - left.root.length)[0]?.project;
233
+ if (!match) throw new CliError(identifier === void 0 ? `No registered project contains ${candidate}. Specify --project <id-or-path>.` : `No registered project matches ${identifier}`, 5);
1747
234
  return match;
1748
235
  }
1749
236
  async function packageSource(value) {
@@ -1764,7 +251,7 @@ async function resolveWorktree(identifier) {
1764
251
  }
1765
252
  const candidate = await canonical(identifier);
1766
253
  const match = all.filter((worktree) => pathContains(candidate, worktree.path)).sort((a, b) => b.path.length - a.path.length)[0];
1767
- if (!match) throw new CliError(`No registered worktree matches ${identifier}`, 5);
254
+ if (!match) throw new CliError(`No registered tree matches ${identifier}`, 5);
1768
255
  return match;
1769
256
  }
1770
257
  function parseWebPanelInput(value) {
@@ -1776,8 +263,9 @@ function parseWebPanelInput(value) {
1776
263
  } catch (error) {
1777
264
  throw new CliError(`--input must contain valid JSON: ${error instanceof Error ? error.message : String(error)}`, 2);
1778
265
  }
1779
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new CliError("--input must contain a JSON object", 2);
1780
- return parsed;
266
+ const validated = webPanelInputSchema.safeParse(parsed);
267
+ if (!validated.success) throw new CliError("--input must contain a JSON object", 2);
268
+ return validated.data;
1781
269
  }
1782
270
  async function webPanelDefinition(worktreeId, identifier) {
1783
271
  const definitions = (await request(`/api/worktrees/${encodeURIComponent(worktreeId)}/web-panel-definitions`)).definitions;
@@ -1786,11 +274,11 @@ async function webPanelDefinition(worktreeId, identifier) {
1786
274
  const matches = definitions.filter((definition) => decodeURIComponent(definition.id.split(":").at(-1) ?? "") === identifier);
1787
275
  if (matches.length === 1) return matches[0];
1788
276
  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) });
1789
- throw new CliError(`Web panel ${identifier} is not available in this worktree`, 5, "WEB_PANEL_DEFINITION_NOT_FOUND");
277
+ throw new CliError(`Web panel ${identifier} is not available in this tree`, 5, "WEB_PANEL_DEFINITION_NOT_FOUND");
1790
278
  }
1791
279
  async function webPanelLaunchCwd(worktree) {
1792
280
  const [cwd, worktreeRoot] = await Promise.all([canonical(workingDirectory), canonical(worktree.path)]);
1793
- if (!pathContains(cwd, worktreeRoot)) throw new CliError(`The current directory is outside worktree ${worktree.name}`, 5, "INVALID_WEB_PANEL_LAUNCH_CWD", {
281
+ if (!pathContains(cwd, worktreeRoot)) throw new CliError(`The current directory is outside tree ${worktree.name}`, 5, "INVALID_WEB_PANEL_LAUNCH_CWD", {
1794
282
  cwd,
1795
283
  worktreeId: worktree.id,
1796
284
  worktreePath: worktree.path
@@ -1876,10 +364,10 @@ async function waitForTerminal(terminalId, condition, timeoutMs) {
1876
364
  resolve(result);
1877
365
  }
1878
366
  };
1879
- const fail = (error) => {
367
+ const fail = (cause) => {
1880
368
  if (!settled) {
1881
369
  settled = true;
1882
- reject(error);
370
+ reject(cause);
1883
371
  }
1884
372
  };
1885
373
  const enqueue = (task) => {
@@ -1959,7 +447,7 @@ const agentGuidance = `AI agents:
1959
447
  async function main(args) {
1960
448
  const argv = args[0] === "spawn" || args[0] === "terminal" && args[1] === "create" ? commandArgv(args) : void 0;
1961
449
  let parserError = "";
1962
- 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({
450
+ const program = new Command().name("treeport").usage("[options] [folder] [command]").description("Manage Treeport projects, trees, and terminals.").argument("[folder]", "folder or folder inside a Git repository to open").option("--json", "emit machine-readable JSON").addHelpText("beforeAll", agentGuidance).configureOutput({
1963
451
  writeOut: writeStdout,
1964
452
  writeErr: (value) => {
1965
453
  parserError += value;
@@ -1972,7 +460,7 @@ async function main(args) {
1972
460
  }
1973
461
  const absoluteFolder = path.resolve(workingDirectory, folder);
1974
462
  if (!(await fs.stat(absoluteFolder).catch((error) => {
1975
- 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 });
463
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") throw new CliError(`Folder does not exist: ${absoluteFolder}`, 5, "FOLDER_NOT_FOUND", { path: absoluteFolder });
1976
464
  throw new CliError(`Cannot access folder ${absoluteFolder}: ${error instanceof Error ? error.message : String(error)}`, 5, "FOLDER_UNREADABLE", { path: absoluteFolder });
1977
465
  })).isDirectory()) throw new CliError(`Path is not a folder: ${absoluteFolder}`, 5, "FOLDER_NOT_DIRECTORY", { path: absoluteFolder });
1978
466
  const canonicalFolder = await fs.realpath(absoluteFolder).catch((error) => {
@@ -1986,12 +474,9 @@ async function main(args) {
1986
474
  const registered = await request("/api/projects", {
1987
475
  method: "POST",
1988
476
  body: JSON.stringify({ path: canonicalFolder })
1989
- }).catch((error) => {
1990
- if (error instanceof CliError && error.code === "NOT_A_GIT_REPOSITORY") throw new CliError(`No Git repository contains ${canonicalFolder}.`, error.exitCode, error.code, error.details);
1991
- throw error;
1992
477
  });
1993
478
  const targetWorktree = registered.project.worktrees.filter((worktree) => !worktree.prunable && pathContains(canonicalFolder, worktree.path)).sort((left, right) => right.path.length - left.path.length)[0];
1994
- if (!targetWorktree) throw new CliError(`Git did not report an active worktree containing ${canonicalFolder}.`, 5, "WORKTREE_NOT_FOUND", {
479
+ if (!targetWorktree) throw new CliError(`Treeport did not find a workspace containing ${canonicalFolder}.`, 5, "WORKTREE_NOT_FOUND", {
1995
480
  path: canonicalFolder,
1996
481
  projectId: registered.project.id
1997
482
  });
@@ -1999,7 +484,10 @@ async function main(args) {
1999
484
  target.pathname = `/projects/${encodeURIComponent(registered.project.id)}/worktrees/${encodeURIComponent(targetWorktree.id)}`;
2000
485
  target.search = "";
2001
486
  target.hash = "";
2002
- const opened = await openWorkspace(target.href).catch((error) => {
487
+ const opened = contextTerminalId ? await request(`/api/worktrees/${encodeURIComponent(targetWorktree.id)}/open`, {
488
+ method: "POST",
489
+ body: JSON.stringify({ sourceTerminalId: contextTerminalId })
490
+ }).then(() => ({ client: "current" })) : await openWorkspace(target.href).catch((error) => {
2003
491
  if (error instanceof OpenWorkspaceError) throw new CliError(error.message, 1, "OPEN_FAILED", { url: target.href });
2004
492
  throw error;
2005
493
  });
@@ -2007,9 +495,10 @@ async function main(args) {
2007
495
  projectId: registered.project.id,
2008
496
  worktreeId: targetWorktree.id,
2009
497
  path: canonicalFolder,
498
+ projectKind: registered.project.kind,
2010
499
  url: target.href,
2011
500
  client: opened.client
2012
- }, () => `Opened ${registered.project.name} / ${targetWorktree.name} in the ${opened.client === "desktop" ? "Treeport desktop app" : "browser"}\n${target.href}`);
501
+ }, () => `Opened ${registered.project.name} / ${targetWorktree.name} in the ${opened.client === "desktop" ? "Treeport desktop app" : opened.client === "current" ? "current Treeport client" : "browser"}\n${target.href}`);
2013
502
  });
2014
503
  const startCommand = program.command("start").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");
2015
504
  startCommand.action(async () => {
@@ -2024,11 +513,11 @@ async function main(args) {
2024
513
  return;
2025
514
  }
2026
515
  const port = options.port === void 0 ? void 0 : Number(options.port);
2027
- const result = await daemonUp({
2028
- ...options.host === void 0 ? {} : { host: options.host },
2029
- ...port === void 0 ? {} : { port },
2030
- ...options.foreground === void 0 ? {} : { foreground: options.foreground }
2031
- });
516
+ const daemonOptions = {};
517
+ if (options.host !== void 0) daemonOptions.host = options.host;
518
+ if (port !== void 0) daemonOptions.port = port;
519
+ if (options.foreground !== void 0) daemonOptions.foreground = options.foreground;
520
+ const result = await daemonUp(daemonOptions);
2032
521
  if (options.foreground) return;
2033
522
  print(result, () => `Treeport is running\n${result.apiUrl}`);
2034
523
  });
@@ -2052,8 +541,9 @@ async function main(args) {
2052
541
  serviceCommand.action(() => {
2053
542
  writeStdout(serviceCommand.helpInformation());
2054
543
  });
2055
- serviceCommand.command("enable").description("Enable startup after reboot and unexpected-exit restarts").option("--json", "emit machine-readable JSON").action(async () => {
2056
- const result = await serviceEnable();
544
+ const serviceEnableCommand = serviceCommand.command("enable").description("Enable automatic startup and unexpected-exit restarts").option("--headless", "use advanced macOS startup before login (requires an administrator)").option("--json", "emit machine-readable JSON");
545
+ serviceEnableCommand.action(async () => {
546
+ const result = await serviceEnable(serviceEnableCommand.opts().headless ? "headless" : "user");
2057
547
  print(result, () => formatServiceStatus(result.status));
2058
548
  if (result.status.state === "action_required") requestedExitCode = 1;
2059
549
  });
@@ -2090,10 +580,10 @@ async function main(args) {
2090
580
  const port = options.port === void 0 ? void 0 : Number(options.port);
2091
581
  if (port !== void 0 && (!Number.isInteger(port) || port < 1 || port > 65535)) throw new CliError("--port must be an integer between 1 and 65535", 2);
2092
582
  const serviceDaemon = lifecycle === "service" ? await ensureServiceDaemon() : void 0;
2093
- const result = await enableTailscaleRemote({
2094
- ...port === void 0 ? {} : { port },
2095
- ...serviceDaemon === void 0 ? {} : { daemon: serviceDaemon }
2096
- });
583
+ const remoteOptions = {};
584
+ if (port !== void 0) remoteOptions.port = port;
585
+ if (serviceDaemon !== void 0) remoteOptions.daemon = serviceDaemon;
586
+ const result = await enableTailscaleRemote(remoteOptions);
2097
587
  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.`);
2098
588
  });
2099
589
  remoteCommand.command("status").description("Show Tailscale remote access status").option("--json", "emit machine-readable JSON").action(async () => {
@@ -2124,7 +614,7 @@ async function main(args) {
2124
614
  print(result, () => {
2125
615
  if (!status.state) return supervision ? formatServiceStatus(supervision) : "Treeport is stopped";
2126
616
  if (!status.running || !status.verified) return `Treeport is unhealthy (PID ${status.state.pid})\nLogs: ${path.join(status.state.dataDir, "logs", "daemon.log")}`;
2127
- return `Treeport is running\n${status.state.apiUrl}\nLifecycle: ${status.health?.daemonLifecycle}\nVersion: ${status.health?.version}\nPID: ${status.state.pid}\nProjects: ${result.projects}\nWorktrees: ${result.worktrees}\nTerminals: ${result.terminals}`;
617
+ return `Treeport is running\n${status.state.apiUrl}\nLifecycle: ${status.health?.daemonLifecycle}\nVersion: ${status.health?.version}\nPID: ${status.state.pid}\nProjects: ${result.projects}\nTrees: ${result.worktrees}\nTerminals: ${result.terminals}`;
2128
618
  });
2129
619
  });
2130
620
  const logsCommand = program.command("logs").description("Show recent daemon logs").option("--lines <count>", "number of lines", "100");
@@ -2174,12 +664,12 @@ async function main(args) {
2174
664
  if (missing.length) throw new CliError(`Incomplete Treeport context; missing ${missing.join(", ")}`, 5, "TREEPORT_CONTEXT_INCOMPLETE", { missing });
2175
665
  const project = (await request(`/api/projects/${encodeURIComponent(projectId)}`)).project;
2176
666
  const worktree = project.worktrees.find((candidate) => candidate.id === worktreeId);
2177
- if (!worktree) throw new CliError("Treeport context worktree does not belong to the current project", 5, "TREEPORT_CONTEXT_INVALID", {
667
+ if (!worktree) throw new CliError("Treeport context tree does not belong to the current project", 5, "TREEPORT_CONTEXT_INVALID", {
2178
668
  projectId,
2179
669
  worktreeId
2180
670
  });
2181
671
  const terminal = worktree.terminals.find((candidate) => candidate.id === terminalId);
2182
- if (!terminal) throw new CliError("Treeport context terminal does not belong to the current worktree", 5, "TREEPORT_CONTEXT_INVALID", {
672
+ if (!terminal) throw new CliError("Treeport context terminal does not belong to the current tree", 5, "TREEPORT_CONTEXT_INVALID", {
2183
673
  worktreeId,
2184
674
  terminalId
2185
675
  });
@@ -2190,6 +680,8 @@ async function main(args) {
2190
680
  project: {
2191
681
  id: project.id,
2192
682
  name: project.name,
683
+ kind: project.kind,
684
+ rootPath: project.rootPath,
2193
685
  repositoryPath: project.repositoryPath,
2194
686
  mainWorktreePath: project.mainWorktreePath,
2195
687
  defaultBranch: project.defaultBranch,
@@ -2213,29 +705,27 @@ async function main(args) {
2213
705
  exitCode: terminal.exitCode
2214
706
  }
2215
707
  };
2216
- 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" : context.daemonLifecycle === "service" ? "managed by the OS service" : "managed by Treeport"}`);
708
+ print(context, () => `Treeport context\n\nProject: ${context.project.name} (${context.project.id})\nTree: ${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" : context.daemonLifecycle === "service" ? "managed by the OS service" : "managed by Treeport"}`);
2217
709
  });
2218
710
  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");
2219
711
  installCommand.action(async (source) => {
2220
712
  const options = installCommand.opts();
713
+ const body = { source: await packageSource(source) };
714
+ if (options.local) body.projectId = await localPackageProjectId();
2221
715
  const result = (await request("/api/packages/install", {
2222
716
  method: "POST",
2223
- body: JSON.stringify({
2224
- source: await packageSource(source),
2225
- ...options.local ? { projectId: await localPackageProjectId() } : {}
2226
- })
717
+ body: JSON.stringify(body)
2227
718
  })).result;
2228
719
  print(result, () => `Installed ${result.source}${result.scope === "project" ? ` for project ${result.projectId}` : " globally"}`);
2229
720
  });
2230
721
  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");
2231
722
  removePackageCommand.action(async (source) => {
2232
723
  const options = removePackageCommand.opts();
724
+ const body = { source: await packageSource(source) };
725
+ if (options.local) body.projectId = await localPackageProjectId();
2233
726
  const result = (await request("/api/packages/remove", {
2234
727
  method: "POST",
2235
- body: JSON.stringify({
2236
- source: await packageSource(source),
2237
- ...options.local ? { projectId: await localPackageProjectId() } : {}
2238
- })
728
+ body: JSON.stringify(body)
2239
729
  })).result;
2240
730
  print(result, () => `Removed ${result.source}`);
2241
731
  });
@@ -2249,10 +739,24 @@ async function main(args) {
2249
739
  return lines.join("\n");
2250
740
  });
2251
741
  });
2252
- 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");
742
+ const updatePackagesCommand = program.command("update").description("Update Treeport or explicitly update configured packages").argument("[source]", "one configured npm: source").option("--packages", "update every eligible configured package").option("--json", "emit machine-readable JSON");
2253
743
  updatePackagesCommand.action(async (source) => {
2254
744
  const options = updatePackagesCommand.opts();
2255
- 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);
745
+ if (source && options.packages) throw new CliError("Specify a package source or --packages, not both.", 2);
746
+ if (!source && !options.packages) {
747
+ if (await resolveDaemonLifecycle() === "external") throw new CliError("Cannot update Treeport because this daemon lifecycle is externally managed.", 5, "UPDATE_EXTERNAL_REFUSED");
748
+ const selfUpdateOptions = { environment: cliEnvironment };
749
+ if (!jsonOutput) selfUpdateOptions.progress = (message) => writeStderr(`${message}\n`);
750
+ const result = await runLocalUpdate(selfUpdateOptions).catch((error) => {
751
+ if (error instanceof LocalUpdateError) throw new CliError(error.message, error.exitCode, error.code, error.details);
752
+ throw error;
753
+ });
754
+ print(result, () => {
755
+ if (result.status === "current") return `Treeport ${result.toVersion} is current`;
756
+ return result.daemon.wasRunning ? `Updated Treeport from ${result.fromVersion} to ${result.toVersion} and restarted the ${result.daemon.lifecycle === "service" ? "service" : "daemon"}` : `Updated Treeport from ${result.fromVersion} to ${result.toVersion}; Treeport remains stopped`;
757
+ });
758
+ return;
759
+ }
2256
760
  const results = (await request("/api/packages/update", {
2257
761
  method: "POST",
2258
762
  body: JSON.stringify(source ? { source: await packageSource(source) } : {})
@@ -2274,40 +778,41 @@ async function main(args) {
2274
778
  projectCommand.action(() => {
2275
779
  throw new CliError(projectCommand.helpInformation(), 2);
2276
780
  });
2277
- projectCommand.command("add").description("Register a Git repository").argument("<path>", "repository path").option("--json", "emit machine-readable JSON").action(async (repository) => {
781
+ projectCommand.command("add").description("Register a folder or Git repository").argument("<path>", "folder path").option("--json", "emit machine-readable JSON").action(async (repository) => {
2278
782
  const body = await request("/api/projects", {
2279
783
  method: "POST",
2280
784
  body: JSON.stringify({ path: await canonical(repository) })
2281
785
  });
2282
- print(body.project, () => `Registered ${body.project.name} (${body.project.id})\n${body.project.repositoryPath}`);
786
+ print(body.project, () => `Registered ${body.project.name} (${body.project.id})\n${body.project.rootPath}`);
2283
787
  });
2284
788
  projectCommand.command("list").description("List registered projects").option("--json", "emit machine-readable JSON").action(async () => {
2285
789
  const list = await projects();
2286
- print(list, () => list.map((project) => `${project.id}\t${project.name}\t${project.repositoryPath}`).join("\n"));
790
+ print(list, () => list.map((project) => `${project.id}\t${project.name}\t${project.kind}\t${project.rootPath}`).join("\n"));
2287
791
  });
2288
- const worktreeCommand = program.command("worktree").description("List, create, and remove worktrees");
792
+ const worktreeCommand = program.command("worktree").description("List, create, and remove trees");
2289
793
  worktreeCommand.action(() => {
2290
794
  throw new CliError(worktreeCommand.helpInformation(), 2);
2291
795
  });
2292
- 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");
796
+ const worktreeListCommand = worktreeCommand.command("list").description("List discovered trees").option("--project <id-or-path>", "limit results to a project").option("--json", "emit machine-readable JSON");
2293
797
  worktreeListCommand.action(async () => {
2294
798
  const { project: projectIdentifier } = worktreeListCommand.opts();
2295
799
  const list = projectIdentifier ? (await resolveProject(projectIdentifier)).worktrees : (await projects()).flatMap((project) => project.worktrees);
2296
- print(list, () => list.map((worktree) => `${worktree.id}\t${worktree.name}\t${worktree.branch ?? `detached@${worktree.head.slice(0, 8)}`}\t${worktree.path}`).join("\n"));
800
+ print(list, () => list.map((worktree) => `${worktree.id}\t${worktree.name}\t${worktree.kind === "folder" ? "folder" : worktree.branch ?? `detached@${worktree.head.slice(0, 8)}`}\t${worktree.path}`).join("\n"));
2297
801
  });
2298
- 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");
802
+ const worktreeCreateCommand = worktreeCommand.command("create").description("Create a linked tree").option("--project <id-or-path>", "project to create from (default: current folder)").requiredOption("--name <name>", "Tree name").option("--from-current", "base the tree on the current tree").option("--json", "emit machine-readable JSON");
2299
803
  worktreeCreateCommand.action(async () => {
2300
804
  const options = worktreeCreateCommand.opts();
2301
805
  const project = await resolveProject(options.project);
2302
806
  const sourceWorktreeId = options.fromCurrent ? (await resolveWorktree(".")).id : void 0;
2303
- const result = await createWorktree(project.id, {
807
+ const request = {
2304
808
  name: options.name,
2305
- base: options.fromCurrent ? "current" : "default",
2306
- ...sourceWorktreeId ? { sourceWorktreeId } : {}
2307
- });
2308
- print(result, () => `Created ${result.worktree.name} (${result.worktree.id})\n${result.worktree.path}${result.setupError ? `\nSetup error: ${result.setupError}` : ""}`);
809
+ base: options.fromCurrent ? "current" : "default"
810
+ };
811
+ if (sourceWorktreeId) request.sourceWorktreeId = sourceWorktreeId;
812
+ const result = await createWorktree(project.id, request);
813
+ print(result, () => `Created tree ${result.worktree.name} (${result.worktree.id})\n${result.worktree.path}${result.setupError ? `\nSetup error: ${result.setupError}` : ""}`);
2309
814
  });
2310
- 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");
815
+ const worktreeRemoveCommand = worktreeCommand.command("remove").description("Remove a linked tree").argument("<id-or-path-or-dot>", "Tree to remove").option("--force", "confirm destructive removal warnings").option("--json", "emit machine-readable JSON");
2311
816
  worktreeRemoveCommand.action(async (identifier) => {
2312
817
  const { force: confirmed } = worktreeRemoveCommand.opts();
2313
818
  const worktree = await resolveWorktree(identifier);
@@ -2325,18 +830,18 @@ async function main(args) {
2325
830
  await new Promise((resolve) => setTimeout(resolve, 100));
2326
831
  operation = (await request(`/api/operations/${encodeURIComponent(operation.id)}`)).operation;
2327
832
  }
2328
- if (operation.status === "failed") throw new CliError(operation.error ?? "Worktree removal failed", 5, "WORKTREE_REMOVAL_FAILED");
2329
- if (operation.kind !== "remove" || !operation.result) throw new CliError("Worktree removal returned an unexpected operation result", 5, "INVALID_OPERATION_RESULT");
833
+ if (operation.status === "failed") throw new CliError(operation.error ?? "Tree removal failed", 5, "WORKTREE_REMOVAL_FAILED");
834
+ if (operation.kind !== "remove" || !operation.result) throw new CliError("Tree removal returned an unexpected operation result", 5, "INVALID_OPERATION_RESULT");
2330
835
  print(operation.result, () => {
2331
836
  const warning = operation.result?.cleanup.warning;
2332
- return `Removed ${worktree.name} (${worktree.id})${warning ? `\nWarning: ${warning}` : ""}`;
837
+ return `Removed tree ${worktree.name} (${worktree.id})${warning ? `\nWarning: ${warning}` : ""}`;
2333
838
  });
2334
839
  });
2335
840
  const webPanelCommand = program.command("web-panel").description("Open persistent web panels");
2336
841
  webPanelCommand.action(() => {
2337
842
  throw new CliError(webPanelCommand.helpInformation(), 2);
2338
843
  });
2339
- 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");
844
+ 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 tree").option("--input <json>", "structured panel input as a JSON object").option("--new", "create a separate panel instance").option("--json", "emit machine-readable JSON");
2340
845
  webPanelOpenCommand.action(async (identifier) => {
2341
846
  const options = webPanelOpenCommand.opts();
2342
847
  const worktree = await resolveWorktree(options.worktree);
@@ -2357,25 +862,25 @@ async function main(args) {
2357
862
  };
2358
863
  print(output, () => `${result.reused ? "Reused" : "Opened"} ${result.panel.title} (${result.panel.id})\n${output.url}`);
2359
864
  });
2360
- const terminalCommand = program.command("terminal").description("Manage persistent worktree terminals");
865
+ const terminalCommand = program.command("terminal").description("Manage persistent tree terminals");
2361
866
  terminalCommand.action(() => {
2362
867
  throw new CliError(terminalCommand.helpInformation(), 2);
2363
868
  });
2364
- const terminalListCommand = terminalCommand.command("list").description("List terminals").option("--worktree <id-or-path>", "limit results to a worktree").option("--json", "emit machine-readable JSON");
869
+ const terminalListCommand = terminalCommand.command("list").description("List terminals").option("--worktree <id-or-path>", "limit results to a tree").option("--json", "emit machine-readable JSON");
2365
870
  terminalListCommand.action(async () => {
2366
871
  const { worktree: identifier } = terminalListCommand.opts();
2367
872
  const list = identifier ? (await resolveWorktree(identifier)).terminals : (await projects()).flatMap((project) => project.worktrees.flatMap((worktree) => worktree.terminals));
2368
873
  print(list, () => list.map((terminal) => `${terminal.id}\t${terminal.name}\t${terminal.status}\t${JSON.stringify(terminal.argv)}`).join("\n"));
2369
874
  });
2370
- 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");
875
+ const terminalCreateCommand = terminalCommand.command("create").description("Create a persistent terminal").usage("[options] [-- <command> args...]").requiredOption("--worktree <id-or-path-or-dot>", "owning tree").requiredOption("--name <name>", "terminal name").option("--json", "emit machine-readable JSON").addHelpText("after", "\nCommand arguments may be passed after --.\n");
2371
876
  terminalCreateCommand.action(async () => {
2372
877
  const options = terminalCreateCommand.opts();
2373
- const result = await request(`/api/worktrees/${(await resolveWorktree(options.worktree)).id}/terminals`, {
878
+ const worktree = await resolveWorktree(options.worktree);
879
+ const body = { name: options.name };
880
+ if (argv) body.argv = argv;
881
+ const result = await request(`/api/worktrees/${worktree.id}/terminals`, {
2374
882
  method: "POST",
2375
- body: JSON.stringify({
2376
- name: options.name,
2377
- ...argv ? { argv } : {}
2378
- })
883
+ body: JSON.stringify(body)
2379
884
  });
2380
885
  print(result.terminal, () => `Created ${result.terminal.name} (${result.terminal.id})`);
2381
886
  });
@@ -2419,21 +924,21 @@ async function main(args) {
2419
924
  terminalId
2420
925
  }, () => `Deleted ${terminalId}`);
2421
926
  });
2422
- 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");
927
+ const spawnCommand = program.command("spawn").description("Create a tree and its first terminal").usage("[options] [-- <command> args...]").option("--project <id-or-path-or-dot>", "project to create from (default: current folder)").requiredOption("--worktree-name <name>", "Tree name").requiredOption("--name <terminal-name>", "terminal name").option("--from-current", "base the tree on the current tree").option("--json", "emit machine-readable JSON").addHelpText("after", "\nCommand arguments may be passed after --.\n");
2423
928
  spawnCommand.action(async () => {
2424
929
  const options = spawnCommand.opts();
2425
930
  const project = await resolveProject(options.project);
2426
931
  const sourceWorktreeId = options.fromCurrent ? (await resolveWorktree(".")).id : void 0;
2427
- const result = await createWorktree(project.id, {
932
+ const initialTerminal = { name: options.name };
933
+ if (argv) initialTerminal.argv = argv;
934
+ const request = {
2428
935
  name: options.worktreeName,
2429
936
  base: options.fromCurrent ? "current" : "default",
2430
- initialTerminal: {
2431
- name: options.name,
2432
- ...argv ? { argv } : {}
2433
- },
2434
- ...sourceWorktreeId ? { sourceWorktreeId } : {}
2435
- });
2436
- 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}` : ""}`);
937
+ initialTerminal
938
+ };
939
+ if (sourceWorktreeId) request.sourceWorktreeId = sourceWorktreeId;
940
+ const result = await createWorktree(project.id, request);
941
+ print(result, () => `Created tree ${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}` : ""}`);
2437
942
  });
2438
943
  try {
2439
944
  await program.parseAsync(args, { from: "user" });
@@ -2447,6 +952,7 @@ async function main(args) {
2447
952
  }
2448
953
  async function runCliApplication(options) {
2449
954
  const environment = options.environment ?? process.env;
955
+ cliEnvironment = environment;
2450
956
  configuredApiUrl = environment.TREEPORT_API_URL?.trim();
2451
957
  apiUrl = (await resolveLocalApiUrl(environment)).replace(/\/$/, "");
2452
958
  contextProjectId = environment.TREEPORT_PROJECT_ID?.trim() || void 0;
@@ -2465,9 +971,9 @@ async function runCliApplication(options) {
2465
971
  if (jsonOutput) {
2466
972
  const body = { error: {
2467
973
  code: cliError.code,
2468
- message: cliError.message,
2469
- ...cliError.details === void 0 ? {} : { details: cliError.details }
974
+ message: cliError.message
2470
975
  } };
976
+ if (cliError.details !== void 0) body.error.details = cliError.details;
2471
977
  writeStderr(`${JSON.stringify(body)}\n`);
2472
978
  } else writeStderr(`${cliError.message}\n`);
2473
979
  requestedExitCode = cliError.exitCode;