@treeport/treeport 0.4.0 → 0.6.1

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,11 @@
1
1
  #!/usr/bin/env node
2
- import { A as parseEventsSnapshot, M as SOCKET_IO_PATH, j as parseProductEvent, k as webPanelInputSchema, n as parseDurationMs, r as TERMINAL_CAPTURE_MAX_LINES, t as assertLoopbackHost } from "../../loopback-D7k_J_Wl.js";
2
+ import { B as parseEventsSnapshot, H as SOCKET_IO_PATH, R as webPanelInputSchema, V as parseProductEvent, t as TERMINAL_CAPTURE_MAX_LINES } from "../../dist-Crk_Xr82.js";
3
+ import { A as treeportVersion, C as disableTailscaleRemote, D as resolvePackagePath, E as resolveLocalApiUrl, M as parseDurationMs, O as runDoctor, S as daemonUp, T as readDaemonLogs, _ as serviceStatus, b as daemonHealth, d as serviceDisable, f as serviceDoctorCheck, g as serviceStart, h as serviceRun, k as tailscaleRemoteStatus, l as readServiceLogs, m as serviceInstalled, p as serviceEnable, s as runLocalUpdate, t as LocalUpdateError, u as serviceApply, v as serviceStop, w as enableTailscaleRemote, x as daemonStatus, y as daemonDown } from "../../update-qVp7yL5D.js";
3
4
  import fs from "node:fs/promises";
4
5
  import path from "node:path";
5
6
  import { Command, CommanderError } from "commander";
6
7
  import { io } from "socket.io-client";
7
- import { z } from "zod";
8
8
  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
9
  //#region src/cli/args.ts
14
10
  function extractJsonOutput(args) {
15
11
  const separator = args.indexOf("--");
@@ -82,1568 +78,6 @@ async function openWorkspace(workspaceUrl, options = {}) {
82
78
  throw new OpenWorkspaceError(`Treeport registered the folder, but could not open it automatically.${browser.stderr ? ` ${browser.stderr}` : ""}\nOpen this URL manually: ${workspaceUrl}`);
83
79
  }
84
80
  //#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
- runtimeExecutable: z.string().min(1).nullable().default(null),
605
- runtimeEntrypoint: z.string().min(1).nullable().default(null),
606
- installationMethod: z.enum(["curl", "npm"]),
607
- definitionName: z.string().min(1),
608
- definitionPath: z.string().min(1),
609
- definitionHash: z.string().length(64),
610
- environmentHash: z.string().length(64),
611
- environment: z.record(z.string(), z.string()),
612
- requestedState: z.enum(["running", "stopped"]),
613
- pendingAdministratorRequestId: z.string().nullable(),
614
- createdAt: z.string(),
615
- updatedAt: z.string()
616
- });
617
- const administratorRequestSchema = z.strictObject({
618
- schemaVersion: z.literal(1),
619
- id: z.string().uuid(),
620
- operation: z.enum([
621
- "enable",
622
- "start",
623
- "stop",
624
- "disable"
625
- ]),
626
- createdAt: z.string(),
627
- expiresAt: z.string(),
628
- uid: z.number().int().nonnegative(),
629
- gid: z.number().int().nonnegative(),
630
- username: z.string().min(1),
631
- group: z.string().min(1),
632
- home: z.string().min(1),
633
- serviceRecordPath: z.string().min(1),
634
- runnerPath: z.string().min(1),
635
- definitionName: z.string().min(1),
636
- definitionPath: z.string().min(1),
637
- stagedDefinitionPath: z.string().min(1),
638
- definitionHash: z.string().length(64),
639
- apiUrl: z.string().min(1),
640
- cliEntrypoint: z.string().min(1),
641
- runtimeExecutable: z.string().min(1),
642
- runtimeEntrypoint: z.string().min(1)
643
- });
644
- function managerForPlatform(platform = process.platform) {
645
- return platform === "darwin" ? "launchd" : platform === "linux" ? "systemd" : null;
646
- }
647
- function servicePaths(env = process.env) {
648
- const paths = localPaths(env);
649
- const directory = path.join(paths.dataDir, "service");
650
- return {
651
- directory,
652
- recordPath: path.join(directory, "service.json"),
653
- runnerPath: path.join(directory, "run"),
654
- requestsDirectory: path.join(directory, "requests"),
655
- stagedDefinitionPath: path.join(directory, "treeport.plist")
656
- };
657
- }
658
- async function readJson(filePath, schema) {
659
- return fs.readFile(filePath, "utf8").then((value) => schema.parse(JSON.parse(value))).catch(() => null);
660
- }
661
- async function writeJson(filePath, value) {
662
- await fs.mkdir(path.dirname(filePath), {
663
- recursive: true,
664
- mode: 448
665
- });
666
- const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
667
- await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
668
- await fs.rename(temporaryPath, filePath);
669
- }
670
- function fingerprint(value) {
671
- const parsed = z.string().safeParse(value);
672
- const source = parsed.success ? parsed.data : JSON.stringify(Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))));
673
- return crypto.createHash("sha256").update(source).digest("hex");
674
- }
675
- function xml(value) {
676
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
677
- }
678
- function shellQuote(value) {
679
- return `'${value.replaceAll("'", `'\\''`)}'`;
680
- }
681
- function createAdministratorCommand(input) {
682
- return `sudo ${input.installationMethod === "curl" ? shellQuote(input.cliEntrypoint) : `${shellQuote(input.runtimeExecutable)} ${shellQuote(input.runtimeEntrypoint)}`} service apply --request ${shellQuote(input.requestPath)}`;
683
- }
684
- function systemdValue(value) {
685
- return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("%", "%%").replaceAll("\n", "\\n");
686
- }
687
- function createLaunchdDefinition(input) {
688
- return {
689
- label: input.label,
690
- programArguments: [input.runnerPath],
691
- username: input.username,
692
- group: input.group,
693
- environment: input.environment,
694
- workingDirectory: input.home,
695
- standardOutPath: input.logPath,
696
- standardErrorPath: input.logPath,
697
- keepAlive: true,
698
- processType: "Background",
699
- throttleInterval: 10,
700
- exitTimeOut: 10,
701
- abandonProcessGroup: true,
702
- umask: 63
703
- };
704
- }
705
- function serializeLaunchdDefinition(definition) {
706
- 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");
707
- const argumentsXml = definition.programArguments.map((argument) => ` <string>${xml(argument)}</string>`).join("\n");
708
- return `<?xml version="1.0" encoding="UTF-8"?>
709
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
710
- <plist version="1.0">
711
- <dict>
712
- <key>Label</key>
713
- <string>${xml(definition.label)}</string>
714
- <key>ProgramArguments</key>
715
- <array>
716
- ${argumentsXml}
717
- </array>
718
- <key>UserName</key>
719
- <string>${xml(definition.username)}</string>
720
- <key>GroupName</key>
721
- <string>${xml(definition.group)}</string>
722
- <key>EnvironmentVariables</key>
723
- <dict>
724
- ${environment}
725
- </dict>
726
- <key>WorkingDirectory</key>
727
- <string>${xml(definition.workingDirectory)}</string>
728
- <key>StandardOutPath</key>
729
- <string>${xml(definition.standardOutPath)}</string>
730
- <key>StandardErrorPath</key>
731
- <string>${xml(definition.standardErrorPath)}</string>
732
- <key>KeepAlive</key>
733
- <true/>
734
- <key>ProcessType</key>
735
- <string>${definition.processType}</string>
736
- <key>ThrottleInterval</key>
737
- <integer>${definition.throttleInterval}</integer>
738
- <key>ExitTimeOut</key>
739
- <integer>${definition.exitTimeOut}</integer>
740
- <key>AbandonProcessGroup</key>
741
- <true/>
742
- <key>Umask</key>
743
- <integer>${definition.umask}</integer>
744
- </dict>
745
- </plist>
746
- `;
747
- }
748
- function createSystemdDefinition(input) {
749
- return {
750
- description: "Treeport daemon",
751
- execStart: input.runnerPath,
752
- environment: input.environment,
753
- restart: "always",
754
- restartSeconds: 5,
755
- timeoutStopSeconds: 10,
756
- killMode: "process",
757
- wantedBy: "default.target"
758
- };
759
- }
760
- function serializeSystemdDefinition(definition) {
761
- const environment = Object.entries(definition.environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, value]) => `Environment="${systemdValue(name)}=${systemdValue(value)}"`).join("\n");
762
- return `[Unit]
763
- Description=${definition.description}
764
-
765
- [Service]
766
- Type=simple
767
- ExecStart="${systemdValue(definition.execStart)}"
768
- ${environment}
769
- Restart=${definition.restart}
770
- RestartSec=${definition.restartSeconds}
771
- TimeoutStopSec=${definition.timeoutStopSeconds}
772
- KillMode=${definition.killMode}
773
-
774
- [Install]
775
- WantedBy=${definition.wantedBy}
776
- `;
777
- }
778
- async function runCommand(executable, args, environment = process.env) {
779
- return new Promise((resolve) => {
780
- const child = spawn(executable, args, {
781
- env: environment,
782
- stdio: [
783
- "ignore",
784
- "pipe",
785
- "pipe"
786
- ]
787
- });
788
- let stdout = "";
789
- let stderr = "";
790
- child.stdout.setEncoding("utf8");
791
- child.stderr.setEncoding("utf8");
792
- child.stdout.on("data", (value) => {
793
- stdout += value;
794
- });
795
- child.stderr.on("data", (value) => {
796
- stderr += value;
797
- });
798
- child.once("error", (error) => {
799
- resolve({
800
- code: 127,
801
- stdout,
802
- stderr: error.message
803
- });
804
- });
805
- child.once("close", (code) => {
806
- resolve({
807
- code: code ?? 1,
808
- stdout,
809
- stderr
810
- });
811
- });
812
- });
813
- }
814
- function commandError(command, result) {
815
- const detail = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n");
816
- return /* @__PURE__ */ new Error(`${command} failed${detail ? `: ${detail}` : ` with status ${result.code}`}`);
817
- }
818
- async function executablePath(name) {
819
- const candidates = name === "launchctl" ? ["/bin/launchctl", "/usr/bin/launchctl"] : [`/usr/bin/${name}`, `/bin/${name}`];
820
- for (const candidate of candidates) if (await fs.access(candidate, constants.X_OK).then(() => true).catch(() => false)) return candidate;
821
- return name;
822
- }
823
- async function primaryGroup(username) {
824
- const result = await runCommand(await executablePath("id"), ["-gn", username]);
825
- if (result.code !== 0 || !result.stdout.trim()) throw commandError("id -gn", result);
826
- return result.stdout.trim();
827
- }
828
- function currentEntrypoint() {
829
- const value = process.env.TREEPORT_CLI_ENTRYPOINT?.trim() || process.argv[1]?.trim();
830
- return value ? path.resolve(value) : null;
831
- }
832
- async function ensureEntrypoint(installationMethod) {
833
- const entrypoint = currentEntrypoint();
834
- if (!entrypoint) throw new Error("Treeport could not identify a stable CLI entrypoint. Install Treeport with npm or the curl installer, then retry.");
835
- await fs.access(entrypoint, constants.X_OK).catch(() => {
836
- throw new Error(`Treeport cannot execute its stable CLI entrypoint at ${entrypoint}. Reinstall Treeport, then retry.`);
837
- });
838
- if (installationMethod === "npm") {
839
- const [actual, expected] = await Promise.all([fs.realpath(entrypoint), fs.realpath(await resolvePackagePath("bin", "treeport.mjs"))]);
840
- if (actual !== expected) throw new Error(`The current CLI entrypoint is not the installed Treeport npm bin: ${entrypoint}`);
841
- }
842
- return entrypoint;
843
- }
844
- async function currentAdministratorRuntime() {
845
- const invokedEntrypoint = process.argv[1]?.trim();
846
- if (!invokedEntrypoint) throw new Error("Treeport could not identify its Node entrypoint.");
847
- const runtimeEntrypoint = path.resolve(invokedEntrypoint);
848
- const [runtimeExecutable, actualEntrypoint, packageBinEntrypoint, packageCliEntrypoint] = await Promise.all([
849
- fs.realpath(process.execPath),
850
- fs.realpath(runtimeEntrypoint),
851
- fs.realpath(await resolvePackagePath("bin", "treeport.mjs")),
852
- fs.realpath(await resolvePackagePath("dist", "node", "cli", "index.js"))
853
- ]);
854
- if (actualEntrypoint !== packageBinEntrypoint && actualEntrypoint !== packageCliEntrypoint) throw new Error(`Treeport cannot use an unrecognized package entrypoint for administrator commands: ${runtimeEntrypoint}`);
855
- await Promise.all([fs.access(runtimeExecutable, constants.X_OK), fs.access(runtimeEntrypoint, constants.R_OK)]);
856
- return {
857
- runtimeExecutable,
858
- runtimeEntrypoint
859
- };
860
- }
861
- function cacheDirectory(home, env) {
862
- const configured = env.TREEPORT_CACHE_DIR?.trim();
863
- if (configured) return path.resolve(configured.replace(/^~(?=\/|$)/, home));
864
- if (env.XDG_CACHE_HOME?.trim()) return path.join(path.resolve(env.XDG_CACHE_HOME.replace(/^~(?=\/|$)/, home)), "treeport");
865
- return process.platform === "darwin" ? path.join(home, "Library", "Caches", "treeport") : path.join(home, ".cache", "treeport");
866
- }
867
- function createServiceEnvironment(input) {
868
- const env = input.env ?? process.env;
869
- const url = new URL(input.apiUrl);
870
- assertLoopbackHost(url.hostname);
871
- const result = {
872
- HOME: input.user.homedir,
873
- USER: input.user.username,
874
- LOGNAME: input.user.username,
875
- PATH: env.PATH?.trim() || "/usr/local/bin:/usr/bin:/bin",
876
- TREEPORT_HOST: url.hostname,
877
- TREEPORT_PORT: url.port || "80",
878
- TREEPORT_API_URL: input.apiUrl,
879
- TREEPORT_DATA_DIR: input.paths.dataDir,
880
- TREEPORT_RUNTIME_DIR: input.paths.runtimeDir,
881
- TREEPORT_CACHE_DIR: cacheDirectory(input.user.homedir, env),
882
- TREEPORT_DATABASE_PATH: env.TREEPORT_DATABASE_PATH?.trim() || path.join(input.paths.dataDir, "treeport.db"),
883
- TREEPORT_SHELL: env.TREEPORT_SHELL?.trim() || env.SHELL?.trim() || "/bin/sh",
884
- TREEPORT_TMUX_PATH: env.TREEPORT_TMUX_PATH?.trim() || "tmux",
885
- TREEPORT_GIT_PATH: env.TREEPORT_GIT_PATH?.trim() || "git",
886
- TREEPORT_GH_PATH: env.TREEPORT_GH_PATH?.trim() || "gh",
887
- TREEPORT_DAEMON_LIFECYCLE: "service",
888
- TREEPORT_INSTALLATION_METHOD: input.installationMethod,
889
- TREEPORT_SERVICE_RECORD: input.recordPath
890
- };
891
- for (const [name, value] of Object.entries(env)) if (value !== void 0 && (name === "LANG" || name === "LC_ALL" || name.startsWith("LC_"))) result[name] = value;
892
- return result;
893
- }
894
- function definitionForRecord(record) {
895
- if (record.manager === "launchd") return serializeLaunchdDefinition(createLaunchdDefinition({
896
- label: record.definitionName,
897
- runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath,
898
- username: record.username,
899
- group: record.group,
900
- environment: record.environment,
901
- home: record.home,
902
- logPath: record.logPath
903
- }));
904
- return serializeSystemdDefinition(createSystemdDefinition({
905
- runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath,
906
- environment: record.environment
907
- }));
908
- }
909
- function runnerSource(record) {
910
- return `#!/bin/sh
911
- set -u
912
- entrypoint=${shellQuote(record.cliEntrypoint)}
913
- record=${shellQuote(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).recordPath)}
914
- log=${shellQuote(record.logPath)}
915
- reported=0
916
- while [ ! -x "$entrypoint" ]; do
917
- if [ "$reported" -eq 0 ]; then
918
- mkdir -p "$(dirname "$log")"
919
- 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"
920
- reported=1
921
- fi
922
- sleep 60
923
- done
924
- export TREEPORT_SERVICE_RECORD="$record"
925
- exec "$entrypoint" service run
926
- `;
927
- }
928
- async function currentRecord() {
929
- return readJson(servicePaths().recordPath, serviceRecordSchema);
930
- }
931
- async function saveRecord(record) {
932
- await writeJson(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).recordPath, record);
933
- }
934
- async function managerState(record) {
935
- if (record.manager === "launchd") {
936
- const launchctl = await executablePath("launchctl");
937
- const [active, disabled, definitionExists] = await Promise.all([
938
- runCommand(launchctl, ["print", `system/${record.definitionName}`]),
939
- runCommand(launchctl, ["print-disabled", "system"]),
940
- fs.access(record.definitionPath).then(() => true).catch(() => false)
941
- ]);
942
- return {
943
- active: active.code === 0,
944
- enabled: definitionExists && !disabled.stdout.includes(`"${record.definitionName}" => true`),
945
- lingering: true,
946
- managerIssue: null
947
- };
948
- }
949
- const systemctl = await executablePath("systemctl");
950
- const [active, enabled, linger] = await Promise.all([
951
- runCommand(systemctl, [
952
- "--user",
953
- "is-active",
954
- record.definitionName
955
- ]),
956
- runCommand(systemctl, [
957
- "--user",
958
- "is-enabled",
959
- record.definitionName
960
- ]),
961
- runCommand(await executablePath("loginctl"), [
962
- "show-user",
963
- record.username,
964
- "-p",
965
- "Linger",
966
- "--value"
967
- ])
968
- ]);
969
- return {
970
- active: active.code === 0 && active.stdout.trim() === "active",
971
- enabled: enabled.code === 0 && enabled.stdout.trim() === "enabled",
972
- lingering: linger.code === 0 && linger.stdout.trim() === "yes",
973
- 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
974
- };
975
- }
976
- function administratorCommand(record) {
977
- const requestId = record.pendingAdministratorRequestId;
978
- if (!requestId || !record.runtimeExecutable || !record.runtimeEntrypoint) return null;
979
- const requestPath = path.join(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).requestsDirectory, `${requestId}.json`);
980
- return createAdministratorCommand({
981
- installationMethod: record.installationMethod,
982
- cliEntrypoint: record.cliEntrypoint,
983
- runtimeExecutable: record.runtimeExecutable,
984
- runtimeEntrypoint: record.runtimeEntrypoint,
985
- requestPath
986
- });
987
- }
988
- async function untrackedDefinition() {
989
- const manager = managerForPlatform();
990
- if (!manager) return null;
991
- const user = os.userInfo();
992
- const name = manager === "launchd" ? `app.treeport.daemon.${user.uid}` : "treeport.service";
993
- const definitionPath = manager === "launchd" ? `/Library/LaunchDaemons/${name}.plist` : path.join(process.env.XDG_CONFIG_HOME?.trim() || path.join(user.homedir, ".config"), "systemd", "user", name);
994
- return await fs.access(definitionPath).then(() => true).catch(() => false) ? {
995
- manager,
996
- name,
997
- path: definitionPath
998
- } : null;
999
- }
1000
- async function serviceInstalled() {
1001
- return await currentRecord() !== null || await untrackedDefinition() !== null;
1002
- }
1003
- async function serviceStatus() {
1004
- const manager = managerForPlatform();
1005
- const record = await currentRecord();
1006
- if (!manager) return {
1007
- supported: false,
1008
- manager: null,
1009
- state: "disabled",
1010
- installed: false,
1011
- enabledAtBoot: false,
1012
- active: false,
1013
- healthy: false,
1014
- rebootReady: false,
1015
- definitionMatches: false,
1016
- environmentMatches: false,
1017
- entrypointMatches: false,
1018
- requestedState: null,
1019
- definitionPath: null,
1020
- entrypoint: null,
1021
- daemon: null,
1022
- issues: [`Treeport service mode does not support ${process.platform}.`],
1023
- recoveryCommands: [],
1024
- administratorCommand: null
1025
- };
1026
- if (!record) {
1027
- const untracked = await untrackedDefinition();
1028
- if (!untracked) return {
1029
- supported: true,
1030
- manager,
1031
- state: "disabled",
1032
- installed: false,
1033
- enabledAtBoot: false,
1034
- active: false,
1035
- healthy: false,
1036
- rebootReady: false,
1037
- definitionMatches: false,
1038
- environmentMatches: false,
1039
- entrypointMatches: false,
1040
- requestedState: null,
1041
- definitionPath: null,
1042
- entrypoint: null,
1043
- daemon: null,
1044
- issues: [],
1045
- recoveryCommands: ["treeport service enable"],
1046
- administratorCommand: null
1047
- };
1048
- return {
1049
- supported: true,
1050
- manager,
1051
- state: "stale",
1052
- installed: true,
1053
- enabledAtBoot: true,
1054
- active: (untracked.manager === "launchd" ? await runCommand(await executablePath("launchctl"), ["print", `system/${untracked.name}`]) : await runCommand(await executablePath("systemctl"), [
1055
- "--user",
1056
- "is-active",
1057
- untracked.name
1058
- ])).code === 0,
1059
- healthy: false,
1060
- rebootReady: false,
1061
- definitionMatches: false,
1062
- environmentMatches: false,
1063
- entrypointMatches: false,
1064
- requestedState: null,
1065
- definitionPath: untracked.path,
1066
- entrypoint: null,
1067
- daemon: null,
1068
- 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.`],
1069
- recoveryCommands: [],
1070
- administratorCommand: null
1071
- };
1072
- }
1073
- const paths = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
1074
- const [managerStatus, definitionContent, entrypointExists, runtimeExecutableExists, runtimeEntrypointExists, currentRuntime, daemon] = await Promise.all([
1075
- managerState(record),
1076
- fs.readFile(record.definitionPath, "utf8").catch(() => ""),
1077
- fs.access(record.cliEntrypoint, constants.X_OK).then(() => true).catch(() => false),
1078
- record.runtimeExecutable ? fs.access(record.runtimeExecutable, constants.X_OK).then(() => true).catch(() => false) : Promise.resolve(false),
1079
- record.runtimeEntrypoint ? fs.access(record.runtimeEntrypoint, constants.R_OK).then(() => true).catch(() => false) : Promise.resolve(false),
1080
- currentAdministratorRuntime().catch(() => null),
1081
- daemonStatus()
1082
- ]);
1083
- const definitionPresent = definitionContent !== "";
1084
- const definitionMatches = definitionPresent && fingerprint(definitionContent) === record.definitionHash;
1085
- const invokedEntrypoint = currentEntrypoint();
1086
- const entrypointMatches = Boolean(entrypointExists && runtimeExecutableExists && runtimeEntrypointExists && currentRuntime && record.runtimeExecutable === currentRuntime.runtimeExecutable && record.runtimeEntrypoint === currentRuntime.runtimeEntrypoint && (invokedEntrypoint === null || path.resolve(invokedEntrypoint) === path.resolve(record.cliEntrypoint)));
1087
- const environmentMatches = fingerprint(createServiceEnvironment({
1088
- user: {
1089
- uid: record.uid,
1090
- gid: record.gid,
1091
- username: record.username,
1092
- homedir: record.home,
1093
- shell: record.environment.TREEPORT_SHELL ?? null
1094
- },
1095
- paths: localPaths({
1096
- TREEPORT_DATA_DIR: record.dataDir,
1097
- TREEPORT_RUNTIME_DIR: record.runtimeDir
1098
- }),
1099
- apiUrl: record.apiUrl,
1100
- recordPath: paths.recordPath,
1101
- installationMethod: record.installationMethod
1102
- })) === record.environmentHash;
1103
- const healthy = Boolean(daemon.verified && daemon.health?.daemonLifecycle === "service" && daemon.state?.daemonLifecycle === "service" && path.resolve(daemon.state.dataDir) === path.resolve(record.dataDir));
1104
- const installed = managerStatus.enabled;
1105
- const rebootReady = installed && (record.manager === "launchd" || managerStatus.lingering);
1106
- const pendingCommand = administratorCommand(record) ?? (record.manager === "systemd" && managerStatus.enabled && !managerStatus.lingering ? `sudo loginctl enable-linger ${record.username}` : null);
1107
- const issues = [];
1108
- const recoveryCommands = [];
1109
- if (record.manager !== manager) issues.push(`The service record uses ${record.manager}, but this host requires ${manager}.`);
1110
- if (!definitionMatches && !record.pendingAdministratorRequestId) {
1111
- issues.push(definitionPresent ? `The service definition at ${record.definitionPath} was changed.` : `The service definition is missing at ${record.definitionPath}.`);
1112
- recoveryCommands.push("treeport service enable");
1113
- }
1114
- if (definitionMatches && !installed && !record.pendingAdministratorRequestId) {
1115
- issues.push("The service definition is not enabled for startup after reboot.");
1116
- recoveryCommands.push("treeport service enable");
1117
- }
1118
- if (!entrypointMatches) {
1119
- issues.push(`The service CLI entrypoint or Node runtime is unavailable or moved: ${record.cliEntrypoint}`);
1120
- recoveryCommands.push("treeport service enable");
1121
- }
1122
- if (!environmentMatches) {
1123
- issues.push("The service environment differs from the current Treeport environment.");
1124
- recoveryCommands.push("treeport service enable");
1125
- }
1126
- if (record.manager === "systemd" && installed && !managerStatus.lingering) {
1127
- issues.push(`User lingering is disabled for ${record.username}.`);
1128
- recoveryCommands.push(`sudo loginctl enable-linger ${record.username}`);
1129
- }
1130
- if (managerStatus.managerIssue) issues.push(managerStatus.managerIssue);
1131
- if (installed && record.requestedState === "running" && !healthy && !record.pendingAdministratorRequestId) {
1132
- issues.push("The supervised Treeport daemon is not healthy.");
1133
- recoveryCommands.push("treeport start");
1134
- }
1135
- const stale = record.manager !== manager || !definitionMatches || !environmentMatches || !entrypointMatches || definitionPresent && !installed || managerStatus.managerIssue !== null;
1136
- return {
1137
- supported: true,
1138
- manager,
1139
- 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",
1140
- installed,
1141
- enabledAtBoot: installed,
1142
- active: managerStatus.active,
1143
- healthy,
1144
- rebootReady,
1145
- definitionMatches,
1146
- environmentMatches,
1147
- entrypointMatches,
1148
- requestedState: record.requestedState,
1149
- definitionPath: record.definitionPath,
1150
- entrypoint: record.cliEntrypoint,
1151
- daemon,
1152
- issues,
1153
- recoveryCommands: [...new Set(recoveryCommands)],
1154
- administratorCommand: pendingCommand
1155
- };
1156
- }
1157
- async function prepareRecord() {
1158
- if (process.getuid?.() === 0) throw new Error("Run `treeport service enable` as the user who will run Treeport, not as root.");
1159
- const manager = managerForPlatform();
1160
- if (!manager) throw new Error(`Treeport service mode supports macOS launchd and Linux systemd; found ${process.platform}.`);
1161
- const explicitApiUrl = process.env.TREEPORT_API_URL?.trim();
1162
- if (explicitApiUrl) assertLoopbackHost(new URL(explicitApiUrl).hostname);
1163
- const user = os.userInfo();
1164
- const paths = localPaths();
1165
- const locations = servicePaths();
1166
- const apiUrl = await resolveLocalApiUrl();
1167
- const listener = new URL(apiUrl);
1168
- if (listener.protocol !== "http:") throw new Error("Treeport service mode requires a local HTTP loopback URL.");
1169
- assertLoopbackHost(listener.hostname);
1170
- const installationMethod = process.env.TREEPORT_INSTALLATION_METHOD?.trim() === "curl" ? "curl" : "npm";
1171
- const [cliEntrypoint, administratorRuntime] = await Promise.all([ensureEntrypoint(installationMethod), currentAdministratorRuntime()]);
1172
- const group = await primaryGroup(user.username);
1173
- const definitionName = manager === "launchd" ? `app.treeport.daemon.${user.uid}` : "treeport.service";
1174
- const definitionPath = manager === "launchd" ? `/Library/LaunchDaemons/${definitionName}.plist` : path.join(process.env.XDG_CONFIG_HOME?.trim() || path.join(user.homedir, ".config"), "systemd", "user", definitionName);
1175
- const environment = createServiceEnvironment({
1176
- user,
1177
- paths,
1178
- apiUrl,
1179
- recordPath: locations.recordPath,
1180
- installationMethod
1181
- });
1182
- const now = (/* @__PURE__ */ new Date()).toISOString();
1183
- const previous = await currentRecord();
1184
- 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.`);
1185
- if (previous && path.resolve(previous.dataDir) !== paths.dataDir) throw new Error(`Treeport service mode already uses ${previous.dataDir}. Disable it before enabling ${paths.dataDir}.`);
1186
- const base = {
1187
- schemaVersion: 1,
1188
- manager,
1189
- platform: process.platform,
1190
- uid: user.uid,
1191
- gid: user.gid,
1192
- username: user.username,
1193
- group,
1194
- home: user.homedir,
1195
- dataDir: paths.dataDir,
1196
- runtimeDir: paths.runtimeDir,
1197
- logPath: paths.logPath,
1198
- apiUrl,
1199
- cliEntrypoint,
1200
- runtimeExecutable: administratorRuntime.runtimeExecutable,
1201
- runtimeEntrypoint: administratorRuntime.runtimeEntrypoint,
1202
- installationMethod,
1203
- definitionName,
1204
- definitionPath,
1205
- definitionHash: "0".repeat(64),
1206
- environmentHash: fingerprint(environment),
1207
- environment,
1208
- requestedState: "running",
1209
- pendingAdministratorRequestId: null,
1210
- createdAt: previous?.createdAt ?? now,
1211
- updatedAt: now
1212
- };
1213
- const definition = definitionForRecord(base);
1214
- return {
1215
- record: {
1216
- ...base,
1217
- definitionHash: fingerprint(definition)
1218
- },
1219
- definition
1220
- };
1221
- }
1222
- async function writeServiceFiles(record, definition) {
1223
- const locations = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
1224
- await Promise.all([fs.mkdir(path.dirname(record.logPath), {
1225
- recursive: true,
1226
- mode: 448
1227
- }), fs.mkdir(locations.requestsDirectory, {
1228
- recursive: true,
1229
- mode: 448
1230
- })]);
1231
- await fs.writeFile(locations.runnerPath, runnerSource(record), { mode: 448 });
1232
- await fs.chmod(locations.runnerPath, 448);
1233
- if (record.manager === "launchd") await fs.writeFile(locations.stagedDefinitionPath, definition, { mode: 384 });
1234
- else {
1235
- await fs.mkdir(path.dirname(record.definitionPath), {
1236
- recursive: true,
1237
- mode: 448
1238
- });
1239
- const temporaryPath = `${record.definitionPath}.${process.pid}.tmp`;
1240
- await fs.writeFile(temporaryPath, definition, { mode: 384 });
1241
- await fs.rename(temporaryPath, record.definitionPath);
1242
- }
1243
- await saveRecord(record);
1244
- }
1245
- async function prepareAdministratorRequest(record, operation) {
1246
- const runtime = record.runtimeExecutable && record.runtimeEntrypoint ? {
1247
- runtimeExecutable: record.runtimeExecutable,
1248
- runtimeEntrypoint: record.runtimeEntrypoint
1249
- } : await currentAdministratorRuntime();
1250
- const requestRecord = {
1251
- ...record,
1252
- ...runtime
1253
- };
1254
- const locations = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
1255
- const id = crypto.randomUUID();
1256
- const now = /* @__PURE__ */ new Date();
1257
- const request = {
1258
- schemaVersion: 1,
1259
- id,
1260
- operation,
1261
- createdAt: now.toISOString(),
1262
- expiresAt: new Date(now.getTime() + 15 * 6e4).toISOString(),
1263
- uid: record.uid,
1264
- gid: record.gid,
1265
- username: record.username,
1266
- group: record.group,
1267
- home: record.home,
1268
- serviceRecordPath: locations.recordPath,
1269
- runnerPath: locations.runnerPath,
1270
- definitionName: record.definitionName,
1271
- definitionPath: record.definitionPath,
1272
- stagedDefinitionPath: locations.stagedDefinitionPath,
1273
- definitionHash: record.definitionHash,
1274
- apiUrl: record.apiUrl,
1275
- cliEntrypoint: record.cliEntrypoint,
1276
- runtimeExecutable: requestRecord.runtimeExecutable,
1277
- runtimeEntrypoint: requestRecord.runtimeEntrypoint
1278
- };
1279
- await writeJson(path.join(locations.requestsDirectory, `${id}.json`), request);
1280
- const next = {
1281
- ...requestRecord,
1282
- pendingAdministratorRequestId: id,
1283
- updatedAt: now.toISOString()
1284
- };
1285
- await saveRecord(next);
1286
- return {
1287
- record: next,
1288
- command: administratorCommand(next)
1289
- };
1290
- }
1291
- async function waitForService(record) {
1292
- const deadline = Date.now() + 15e3;
1293
- const version = await treeportVersion();
1294
- while (Date.now() < deadline) {
1295
- const observed = await daemonHealth(record.apiUrl, 500);
1296
- if (observed?.daemonLifecycle === "service" && observed.instanceId && observed.version === version) return;
1297
- await new Promise((resolve) => setTimeout(resolve, 150));
1298
- }
1299
- throw new Error(`Treeport service did not become ready at ${record.apiUrl}. See ${record.logPath}.`);
1300
- }
1301
- async function serviceEnable() {
1302
- const existing = await serviceStatus();
1303
- if (existing.state === "healthy" && existing.definitionMatches && existing.environmentMatches && existing.entrypointMatches) return {
1304
- status: existing,
1305
- changed: false,
1306
- administratorCommand: null
1307
- };
1308
- const { record, definition } = await prepareRecord();
1309
- const failedChecks = (await runDoctor()).filter((check) => !check.ok);
1310
- if (failedChecks.length) throw new Error(failedChecks.map((check) => `${check.name}: ${check.detail}`).join("\n"));
1311
- const systemctl = record.manager === "systemd" ? await executablePath("systemctl") : null;
1312
- if (systemctl) {
1313
- const managerAvailable = await runCommand(systemctl, ["--user", "show-environment"]);
1314
- if (managerAvailable.code !== 0) throw commandError("systemctl --user", managerAvailable);
1315
- }
1316
- await writeServiceFiles(record, definition);
1317
- if (record.manager === "launchd") {
1318
- await daemonDown();
1319
- const prepared = await prepareAdministratorRequest(record, "enable");
1320
- return {
1321
- status: await serviceStatus(),
1322
- changed: true,
1323
- administratorCommand: prepared.command
1324
- };
1325
- }
1326
- if (!systemctl) throw new Error("Treeport could not resolve the systemd command.");
1327
- await daemonDown();
1328
- const reload = await runCommand(systemctl, ["--user", "daemon-reload"]);
1329
- if (reload.code !== 0) {
1330
- await Promise.all([fs.rm(record.definitionPath, { force: true }), fs.rm(servicePaths().directory, {
1331
- recursive: true,
1332
- force: true
1333
- })]);
1334
- await daemonUp({});
1335
- throw commandError("systemctl --user daemon-reload", reload);
1336
- }
1337
- const enabled = await runCommand(systemctl, [
1338
- "--user",
1339
- "enable",
1340
- "--now",
1341
- record.definitionName
1342
- ]);
1343
- if (enabled.code !== 0) {
1344
- await fs.rm(record.definitionPath, { force: true });
1345
- await runCommand(systemctl, ["--user", "daemon-reload"]);
1346
- await fs.rm(servicePaths().directory, {
1347
- recursive: true,
1348
- force: true
1349
- });
1350
- await daemonUp({});
1351
- throw commandError("systemctl --user enable --now", enabled);
1352
- }
1353
- const startupError = await waitForService(record).then(() => null, (error) => error);
1354
- if (startupError) {
1355
- await runCommand(systemctl, [
1356
- "--user",
1357
- "disable",
1358
- "--now",
1359
- record.definitionName
1360
- ]);
1361
- await fs.rm(record.definitionPath, { force: true });
1362
- await runCommand(systemctl, ["--user", "daemon-reload"]);
1363
- await fs.rm(servicePaths().directory, {
1364
- recursive: true,
1365
- force: true
1366
- });
1367
- await daemonUp({});
1368
- throw startupError;
1369
- }
1370
- const status = await serviceStatus();
1371
- return {
1372
- status,
1373
- changed: true,
1374
- administratorCommand: status.administratorCommand
1375
- };
1376
- }
1377
- async function serviceStart() {
1378
- const record = await currentRecord();
1379
- if (!record) throw new Error("Treeport service mode is disabled. Run `treeport service enable` first.");
1380
- const current = await serviceStatus();
1381
- if (current.state === "healthy") return {
1382
- status: current,
1383
- changed: false,
1384
- administratorCommand: null
1385
- };
1386
- if (current.administratorCommand) return {
1387
- status: current,
1388
- changed: false,
1389
- administratorCommand: current.administratorCommand
1390
- };
1391
- if (!current.definitionMatches || !current.entrypointMatches) throw new Error("The Treeport service definition is stale. Run `treeport service enable` to repair it.");
1392
- const next = {
1393
- ...record,
1394
- requestedState: "running",
1395
- pendingAdministratorRequestId: null,
1396
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1397
- };
1398
- await saveRecord(next);
1399
- if (record.manager === "launchd") {
1400
- const prepared = await prepareAdministratorRequest(next, "start");
1401
- return {
1402
- status: await serviceStatus(),
1403
- changed: true,
1404
- administratorCommand: prepared.command
1405
- };
1406
- }
1407
- const result = await runCommand(await executablePath("systemctl"), [
1408
- "--user",
1409
- "start",
1410
- record.definitionName
1411
- ]);
1412
- if (result.code !== 0) throw commandError("systemctl --user start", result);
1413
- await waitForService(next);
1414
- return {
1415
- status: await serviceStatus(),
1416
- changed: true,
1417
- administratorCommand: null
1418
- };
1419
- }
1420
- async function serviceStop() {
1421
- const record = await currentRecord();
1422
- if (!record) throw new Error("Treeport service mode is disabled.");
1423
- const current = await serviceStatus();
1424
- if (current.state === "stopped") return {
1425
- status: current,
1426
- changed: false,
1427
- administratorCommand: null
1428
- };
1429
- const next = {
1430
- ...record,
1431
- requestedState: "stopped",
1432
- pendingAdministratorRequestId: null,
1433
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1434
- };
1435
- await saveRecord(next);
1436
- if (record.manager === "launchd") {
1437
- const prepared = await prepareAdministratorRequest(next, "stop");
1438
- return {
1439
- status: await serviceStatus(),
1440
- changed: true,
1441
- administratorCommand: prepared.command
1442
- };
1443
- }
1444
- const result = await runCommand(await executablePath("systemctl"), [
1445
- "--user",
1446
- "stop",
1447
- record.definitionName
1448
- ]);
1449
- if (result.code !== 0) {
1450
- await saveRecord(record);
1451
- throw commandError("systemctl --user stop", result);
1452
- }
1453
- return {
1454
- status: await serviceStatus(),
1455
- changed: true,
1456
- administratorCommand: null
1457
- };
1458
- }
1459
- async function serviceDisable() {
1460
- const record = await currentRecord();
1461
- if (!record) return {
1462
- status: await serviceStatus(),
1463
- changed: false,
1464
- administratorCommand: null
1465
- };
1466
- if (record.manager === "launchd") {
1467
- const prepared = await prepareAdministratorRequest({
1468
- ...record,
1469
- pendingAdministratorRequestId: null
1470
- }, "disable");
1471
- return {
1472
- status: await serviceStatus(),
1473
- changed: true,
1474
- administratorCommand: prepared.command
1475
- };
1476
- }
1477
- const systemctl = await executablePath("systemctl");
1478
- const disabled = await runCommand(systemctl, [
1479
- "--user",
1480
- "disable",
1481
- "--now",
1482
- record.definitionName
1483
- ]);
1484
- if (disabled.code !== 0 && !disabled.stderr.includes("does not exist")) throw commandError("systemctl --user disable --now", disabled);
1485
- await fs.rm(record.definitionPath, { force: true });
1486
- await runCommand(systemctl, ["--user", "daemon-reload"]);
1487
- await fs.rm(servicePaths().directory, {
1488
- recursive: true,
1489
- force: true
1490
- });
1491
- return {
1492
- status: await serviceStatus(),
1493
- changed: true,
1494
- administratorCommand: null
1495
- };
1496
- }
1497
- async function serviceApply(requestPath) {
1498
- if (process.platform !== "darwin") throw new Error("Treeport service apply is only available for macOS LaunchDaemons.");
1499
- if (process.getuid?.() !== 0) throw new Error("Run the printed service apply command with sudo or as root.");
1500
- if (!path.isAbsolute(requestPath)) throw new Error("The service apply request path must be absolute.");
1501
- const metadata = await fs.lstat(requestPath);
1502
- if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("The service apply request must be a regular file, not a symlink.");
1503
- if ((metadata.mode & 63) !== 0) throw new Error("The service apply request must not be readable or writable by other users.");
1504
- const request = await readJson(requestPath, administratorRequestSchema);
1505
- if (!request) throw new Error("The service apply request is invalid.");
1506
- if (metadata.uid !== request.uid) throw new Error("The service apply request owner does not match its target user.");
1507
- if (Date.parse(request.expiresAt) <= Date.now()) throw new Error("The service apply request expired. Run the original Treeport command again.");
1508
- const currentRuntime = await currentAdministratorRuntime().catch(() => null);
1509
- const invokedRuntimeEntrypoint = process.argv[1] ? path.resolve(process.argv[1]) : null;
1510
- if (!currentRuntime || currentRuntime.runtimeExecutable !== request.runtimeExecutable || currentRuntime.runtimeEntrypoint !== request.runtimeEntrypoint || invokedRuntimeEntrypoint !== request.runtimeEntrypoint) throw new Error("The service apply command did not use the approved Treeport Node runtime and package entrypoint.");
1511
- const usedPath = `${requestPath}.used`;
1512
- if (await fs.access(usedPath).then(() => true).catch(() => false)) throw new Error("The service apply request was already used.");
1513
- const account = os.userInfo({ encoding: "utf8" });
1514
- const idResult = await runCommand(await executablePath("id"), ["-u", request.username]);
1515
- if (idResult.code !== 0 || Number(idResult.stdout.trim()) !== request.uid) throw new Error("The service apply target user no longer matches the host account.");
1516
- const record = await readJson(request.serviceRecordPath, serviceRecordSchema);
1517
- 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.runtimeExecutable !== request.runtimeExecutable || record.runtimeEntrypoint !== request.runtimeEntrypoint || record.definitionHash !== request.definitionHash || record.pendingAdministratorRequestId !== request.id) throw new Error("The service apply request does not match the current Treeport service record.");
1518
- if (account.uid !== 0) throw new Error("Treeport service apply lost root privileges.");
1519
- const launchctl = await executablePath("launchctl");
1520
- const target = `system/${request.definitionName}`;
1521
- if (request.operation === "enable") {
1522
- const staged = await fs.readFile(request.stagedDefinitionPath, "utf8");
1523
- 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.");
1524
- const temporaryPath = `${request.definitionPath}.${process.pid}.tmp`;
1525
- await fs.copyFile(request.stagedDefinitionPath, temporaryPath);
1526
- await fs.chown(temporaryPath, 0, 0);
1527
- await fs.chmod(temporaryPath, 420);
1528
- await fs.rename(temporaryPath, request.definitionPath);
1529
- await runCommand(launchctl, ["bootout", target]);
1530
- const enabled = await runCommand(launchctl, ["enable", target]);
1531
- if (enabled.code !== 0) throw commandError("launchctl enable", enabled);
1532
- const bootstrapped = await runCommand(launchctl, [
1533
- "bootstrap",
1534
- "system",
1535
- request.definitionPath
1536
- ]);
1537
- if (bootstrapped.code !== 0) throw commandError("launchctl bootstrap", bootstrapped);
1538
- } else if (request.operation === "start") {
1539
- const enabled = await runCommand(launchctl, ["enable", target]);
1540
- if (enabled.code !== 0) throw commandError("launchctl enable", enabled);
1541
- const started = (await runCommand(launchctl, ["print", target])).code === 0 ? await runCommand(launchctl, ["kickstart", target]) : await runCommand(launchctl, [
1542
- "bootstrap",
1543
- "system",
1544
- request.definitionPath
1545
- ]);
1546
- if (started.code !== 0) throw commandError("launchctl start", started);
1547
- } else if (request.operation === "stop") {
1548
- const stopped = await runCommand(launchctl, ["bootout", target]);
1549
- if (stopped.code !== 0 && !stopped.stderr.includes("No such process")) throw commandError("launchctl bootout", stopped);
1550
- } else {
1551
- const installed = await fs.readFile(request.definitionPath, "utf8").catch(() => "");
1552
- if (installed && fingerprint(installed) !== request.definitionHash) throw new Error("Refusing to remove a LaunchDaemon definition that Treeport did not create.");
1553
- await runCommand(launchctl, ["bootout", target]);
1554
- await fs.rm(request.definitionPath, { force: true });
1555
- }
1556
- if (request.operation === "enable" || request.operation === "start") await waitForService(record);
1557
- await fs.rename(requestPath, usedPath);
1558
- if (request.operation === "disable") await fs.rm(path.dirname(request.serviceRecordPath), {
1559
- recursive: true,
1560
- force: true
1561
- });
1562
- else {
1563
- await writeJson(request.serviceRecordPath, {
1564
- ...record,
1565
- requestedState: request.operation === "stop" ? "stopped" : "running",
1566
- pendingAdministratorRequestId: null,
1567
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1568
- });
1569
- await fs.chown(request.serviceRecordPath, request.uid, request.gid);
1570
- }
1571
- return {
1572
- operation: request.operation,
1573
- applied: true
1574
- };
1575
- }
1576
- async function serviceRun() {
1577
- const recordPath = process.env.TREEPORT_SERVICE_RECORD?.trim();
1578
- if (!recordPath || !path.isAbsolute(recordPath)) throw new Error("Treeport service run requires a valid service record.");
1579
- const record = await readJson(recordPath, serviceRecordSchema);
1580
- if (!record) throw new Error(`Treeport service record is invalid: ${recordPath}`);
1581
- if (process.getuid?.() === 0 || process.getuid?.() !== record.uid) throw new Error(`Treeport service must run as ${record.username} (UID ${record.uid}), never as root.`);
1582
- await writeJson(recordPath, {
1583
- ...record,
1584
- requestedState: "running",
1585
- pendingAdministratorRequestId: null,
1586
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1587
- });
1588
- const [version, serverEntry, webDist] = await Promise.all([
1589
- treeportVersion(),
1590
- resolvePackagePath("dist", "node", "server", "index.js"),
1591
- resolvePackagePath("dist", "web")
1592
- ]);
1593
- Object.assign(process.env, record.environment, {
1594
- TREEPORT_APP_VERSION: version,
1595
- TREEPORT_INSTANCE_ID: crypto.randomUUID(),
1596
- TREEPORT_WEB_DIST: webDist,
1597
- TREEPORT_DAEMON_LIFECYCLE: "service"
1598
- });
1599
- await import(pathToFileURL(serverEntry).href);
1600
- }
1601
- async function readServiceLogs(lines) {
1602
- const record = await currentRecord();
1603
- if (!record || record.manager === "launchd") return (await fs.readFile(record?.logPath ?? localPaths().logPath, "utf8").catch((error) => {
1604
- if (error.code === "ENOENT") return "";
1605
- throw error;
1606
- })).split("\n").slice(-lines - 1).join("\n");
1607
- const result = await runCommand(await executablePath("journalctl"), [
1608
- "--user",
1609
- "--unit",
1610
- record.definitionName,
1611
- "--no-pager",
1612
- "--lines",
1613
- String(lines)
1614
- ]);
1615
- if (result.code !== 0) throw commandError("journalctl --user", result);
1616
- return result.stdout;
1617
- }
1618
- async function serviceDoctorCheck() {
1619
- const status = await serviceStatus();
1620
- if (!status.supported) return {
1621
- name: "Service supervision",
1622
- ok: false,
1623
- detail: status.issues.join(" ")
1624
- };
1625
- if (status.state === "disabled") return {
1626
- name: "Service supervision",
1627
- ok: true,
1628
- detail: "disabled (opt in with `treeport service enable`)"
1629
- };
1630
- if (status.state === "healthy") return {
1631
- name: "Service supervision",
1632
- ok: true,
1633
- detail: `${status.manager}; enabled at boot and healthy`
1634
- };
1635
- if (status.state === "stopped") return {
1636
- name: "Service supervision",
1637
- ok: true,
1638
- detail: `${status.manager}; intentionally stopped and enabled for next boot`
1639
- };
1640
- return {
1641
- name: "Service supervision",
1642
- ok: false,
1643
- detail: status.issues.join(" ") || `state: ${status.state}`
1644
- };
1645
- }
1646
- //#endregion
1647
81
  //#region src/cli/application.ts
1648
82
  const contextPrefix = "TREEPORT";
1649
83
  let configuredApiUrl;
@@ -1661,6 +95,7 @@ let writeStderr = (value) => {
1661
95
  process.stderr.write(value);
1662
96
  };
1663
97
  let requestedExitCode = 0;
98
+ let cliEnvironment = process.env;
1664
99
  var CliError = class extends Error {
1665
100
  exitCode;
1666
101
  code;
@@ -1682,10 +117,12 @@ async function resolveDaemonLifecycle() {
1682
117
  return await serviceInstalled() ? "service" : "treeport";
1683
118
  }
1684
119
  function formatServiceStatus(status) {
120
+ 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";
1685
121
  const lines = [
1686
122
  `Treeport service: ${status.state}`,
123
+ `Mode: ${mode}`,
1687
124
  `Manager: ${status.manager ?? "unsupported"}`,
1688
- `Starts at boot: ${status.enabledAtBoot ? "yes" : "no"}`,
125
+ `Starts before login: ${status.enabledAtBoot ? "yes" : "no"}`,
1689
126
  `Active: ${status.active ? "yes" : "no"}`,
1690
127
  `Definition: ${status.definitionPath ?? "not installed"}`
1691
128
  ];
@@ -1789,9 +226,12 @@ async function resolveProject(identifier) {
1789
226
  const environmentMatch = list.find((project) => project.id === contextProjectId);
1790
227
  if (environmentMatch) return environmentMatch;
1791
228
  }
1792
- const candidate = await canonical(identifier);
1793
- const match = list.find((project) => pathContains(candidate, project.repositoryPath) || project.worktrees.some((worktree) => pathContains(candidate, worktree.path)));
1794
- if (!match) throw new CliError(`No registered project matches ${identifier}`, 5);
229
+ const candidate = await canonical(identifier ?? ".");
230
+ const match = list.flatMap((project) => [project.rootPath, ...project.worktrees.map((item) => item.path)].map((root) => ({
231
+ project,
232
+ root
233
+ }))).filter(({ root }) => pathContains(candidate, root)).sort((left, right) => right.root.length - left.root.length)[0]?.project;
234
+ if (!match) throw new CliError(identifier === void 0 ? `No registered project contains ${candidate}. Specify --project <id-or-path>.` : `No registered project matches ${identifier}`, 5);
1795
235
  return match;
1796
236
  }
1797
237
  async function packageSource(value) {
@@ -1846,7 +286,37 @@ async function webPanelLaunchCwd(worktree) {
1846
286
  });
1847
287
  return path.relative(worktreeRoot, cwd) || ".";
1848
288
  }
1849
- function webPanelUrl(worktree, panelId) {
289
+ async function resolveBrowserPanel(panelId) {
290
+ const candidates = (await projects()).flatMap((project) => project.worktrees.flatMap((worktree) => worktree.panels.filter((panel) => panel.kind === "browser").map((panel) => ({
291
+ panel,
292
+ worktree
293
+ }))));
294
+ if (panelId) {
295
+ const match = candidates.find((candidate) => candidate.panel.id === panelId);
296
+ if (!match) throw new CliError(`Browser ${panelId} was not found`, 5);
297
+ return match;
298
+ }
299
+ const worktree = await resolveWorktree(".");
300
+ const matches = candidates.filter((candidate) => candidate.worktree.id === worktree.id);
301
+ if (matches.length === 1) return matches[0];
302
+ if (matches.length === 0) throw new CliError(`No Browser is open in worktree ${worktree.name}`, 5, "BROWSER_PANEL_NOT_FOUND");
303
+ throw new CliError(`More than one Browser is open in worktree ${worktree.name}; specify --panel`, 5, "BROWSER_PANEL_AMBIGUOUS", { panelIds: matches.map((candidate) => candidate.panel.id) });
304
+ }
305
+ async function runBrowserAgentCommand(command, args, panelId) {
306
+ const { panel } = await resolveBrowserPanel(panelId);
307
+ const result = await request(`/api/panels/${encodeURIComponent(panel.id)}/browser-agent`, {
308
+ method: "POST",
309
+ body: JSON.stringify({
310
+ command,
311
+ args
312
+ })
313
+ });
314
+ return {
315
+ panelId: panel.id,
316
+ output: result.output
317
+ };
318
+ }
319
+ function panelUrl(worktree, panelId) {
1850
320
  const target = new URL(apiUrl);
1851
321
  target.pathname = `/projects/${encodeURIComponent(worktree.projectId)}/worktrees/${encodeURIComponent(worktree.id)}/panels/${encodeURIComponent(panelId)}`;
1852
322
  target.search = "";
@@ -2002,13 +472,10 @@ async function waitForTerminal(terminalId, condition, timeoutMs) {
2002
472
  function print(value, human) {
2003
473
  writeStdout(`${jsonOutput ? JSON.stringify(value) : human ? human() : JSON.stringify(value, null, 2)}\n`);
2004
474
  }
2005
- const agentGuidance = `AI agents:
2006
- If you're an AI agent, use \`treeport skills\` to see the usage guide.
2007
- `;
2008
475
  async function main(args) {
2009
476
  const argv = args[0] === "spawn" || args[0] === "terminal" && args[1] === "create" ? commandArgv(args) : void 0;
2010
477
  let parserError = "";
2011
- const program = new Command().name("treeport").usage("[options] [folder] [command]").description("Manage Treeport projects, trees, and terminals.").argument("[folder]", "folder inside a Git repository to open").option("--json", "emit machine-readable JSON").addHelpText("beforeAll", agentGuidance).configureOutput({
478
+ 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").configureOutput({
2012
479
  writeOut: writeStdout,
2013
480
  writeErr: (value) => {
2014
481
  parserError += value;
@@ -2035,12 +502,9 @@ async function main(args) {
2035
502
  const registered = await request("/api/projects", {
2036
503
  method: "POST",
2037
504
  body: JSON.stringify({ path: canonicalFolder })
2038
- }).catch((error) => {
2039
- if (error instanceof CliError && error.code === "NOT_A_GIT_REPOSITORY") throw new CliError(`No Git repository contains ${canonicalFolder}.`, error.exitCode, error.code, error.details);
2040
- throw error;
2041
505
  });
2042
506
  const targetWorktree = registered.project.worktrees.filter((worktree) => !worktree.prunable && pathContains(canonicalFolder, worktree.path)).sort((left, right) => right.path.length - left.path.length)[0];
2043
- if (!targetWorktree) throw new CliError(`Git did not report an active worktree containing ${canonicalFolder}.`, 5, "WORKTREE_NOT_FOUND", {
507
+ if (!targetWorktree) throw new CliError(`Treeport did not find a workspace containing ${canonicalFolder}.`, 5, "WORKTREE_NOT_FOUND", {
2044
508
  path: canonicalFolder,
2045
509
  projectId: registered.project.id
2046
510
  });
@@ -2048,7 +512,10 @@ async function main(args) {
2048
512
  target.pathname = `/projects/${encodeURIComponent(registered.project.id)}/worktrees/${encodeURIComponent(targetWorktree.id)}`;
2049
513
  target.search = "";
2050
514
  target.hash = "";
2051
- const opened = await openWorkspace(target.href).catch((error) => {
515
+ const opened = contextTerminalId ? await request(`/api/worktrees/${encodeURIComponent(targetWorktree.id)}/open`, {
516
+ method: "POST",
517
+ body: JSON.stringify({ sourceTerminalId: contextTerminalId })
518
+ }).then(() => ({ client: "current" })) : await openWorkspace(target.href).catch((error) => {
2052
519
  if (error instanceof OpenWorkspaceError) throw new CliError(error.message, 1, "OPEN_FAILED", { url: target.href });
2053
520
  throw error;
2054
521
  });
@@ -2056,9 +523,10 @@ async function main(args) {
2056
523
  projectId: registered.project.id,
2057
524
  worktreeId: targetWorktree.id,
2058
525
  path: canonicalFolder,
526
+ projectKind: registered.project.kind,
2059
527
  url: target.href,
2060
528
  client: opened.client
2061
- }, () => `Opened ${registered.project.name} / ${targetWorktree.name} in the ${opened.client === "desktop" ? "Treeport desktop app" : "browser"}\n${target.href}`);
529
+ }, () => `Opened ${registered.project.name} / ${targetWorktree.name} in the ${opened.client === "desktop" ? "Treeport desktop app" : opened.client === "current" ? "current Treeport client" : "browser"}\n${target.href}`);
2062
530
  });
2063
531
  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");
2064
532
  startCommand.action(async () => {
@@ -2101,8 +569,9 @@ async function main(args) {
2101
569
  serviceCommand.action(() => {
2102
570
  writeStdout(serviceCommand.helpInformation());
2103
571
  });
2104
- serviceCommand.command("enable").description("Enable startup after reboot and unexpected-exit restarts").option("--json", "emit machine-readable JSON").action(async () => {
2105
- const result = await serviceEnable();
572
+ 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");
573
+ serviceEnableCommand.action(async () => {
574
+ const result = await serviceEnable(serviceEnableCommand.opts().headless ? "headless" : "user");
2106
575
  print(result, () => formatServiceStatus(result.status));
2107
576
  if (result.status.state === "action_required") requestedExitCode = 1;
2108
577
  });
@@ -2232,6 +701,7 @@ async function main(args) {
2232
701
  worktreeId,
2233
702
  terminalId
2234
703
  });
704
+ const treeContext = (await request(`/api/worktrees/${encodeURIComponent(worktree.id)}/context`)).context;
2235
705
  const context = {
2236
706
  managed: true,
2237
707
  apiUrl,
@@ -2239,6 +709,8 @@ async function main(args) {
2239
709
  project: {
2240
710
  id: project.id,
2241
711
  name: project.name,
712
+ kind: project.kind,
713
+ rootPath: project.rootPath,
2242
714
  repositoryPath: project.repositoryPath,
2243
715
  mainWorktreePath: project.mainWorktreePath,
2244
716
  defaultBranch: project.defaultBranch,
@@ -2252,7 +724,8 @@ async function main(args) {
2252
724
  head: worktree.head,
2253
725
  branch: worktree.branch,
2254
726
  detached: worktree.detached,
2255
- kind: worktree.kind
727
+ kind: worktree.kind,
728
+ context: treeContext
2256
729
  },
2257
730
  terminal: {
2258
731
  id: terminal.id,
@@ -2262,8 +735,108 @@ async function main(args) {
2262
735
  exitCode: terminal.exitCode
2263
736
  }
2264
737
  };
2265
- 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"}`);
738
+ print(context, () => {
739
+ const entries = Object.entries(context.worktree.context);
740
+ const treeContextText = entries.length === 0 ? "—" : entries.map(([key, value]) => {
741
+ const [first = "", ...rest] = [...value].map((character) => {
742
+ const code = character.charCodeAt(0);
743
+ return code !== 10 && (code <= 31 || code >= 127 && code <= 159) ? `\\u${code.toString(16).padStart(4, "0")}` : character;
744
+ }).join("").split("\n");
745
+ return ` ${key}: ${first}${rest.length > 0 ? `\n${rest.map((line) => ` ${line}`).join("\n")}` : ""}`;
746
+ }).join("\n");
747
+ return `Treeport context\n\nProject: ${context.project.name} (${context.project.id})\nTree: ${context.worktree.name} (${context.worktree.id})\nPath: ${context.worktree.path}\nContext:\n${treeContextText}\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"}`;
748
+ });
749
+ });
750
+ const browserCommand = program.command("browser").description("Manage Browser and its hosted Chromium");
751
+ browserCommand.action(() => {
752
+ writeStdout(browserCommand.helpInformation());
753
+ });
754
+ const browserOpenCommand = browserCommand.command("open").description("Open Browser and request client navigation").argument("[url]", "absolute HTTP or HTTPS URL").requiredOption("--worktree <id-or-path-or-dot>", "owning tree").option("--json", "emit machine-readable JSON");
755
+ browserOpenCommand.action(async (url) => {
756
+ const worktree = await resolveWorktree(browserOpenCommand.opts().worktree);
757
+ const body = {
758
+ url,
759
+ sourceTerminalId: contextWorktreeId === worktree.id ? contextTerminalId ?? null : null
760
+ };
761
+ const result = await request(`/api/worktrees/${encodeURIComponent(worktree.id)}/browser-panels`, {
762
+ method: "POST",
763
+ body: JSON.stringify(body)
764
+ });
765
+ const output = {
766
+ ...result,
767
+ url: panelUrl(worktree, result.panel.id)
768
+ };
769
+ print(output, () => `Opened ${result.panel.title} (${result.panel.id})\n${output.url}`);
770
+ });
771
+ browserCommand.command("install").description("Install the Chromium build used by Browser").option("--json", "emit machine-readable JSON").action(async () => {
772
+ const result = await request("/api/browser/install", { method: "POST" });
773
+ print(result, () => result.message);
774
+ });
775
+ browserCommand.command("status").description("Show hosted browser installation status").option("--json", "emit machine-readable JSON").action(async () => {
776
+ const result = await request("/api/browser/status");
777
+ print(result, () => `${result.installed ? "Chromium is installed" : "Chromium is not installed"}\nLaunch ready: ${result.launchReady ? "yes" : "no"}\nPlaywright: ${result.playwrightVersion}\nBrowser: ${result.channel} ${result.browserRevision}\nExecutable: ${result.executablePath}${result.launchError ? `\nLaunch error: ${result.launchError}` : ""}`);
2266
778
  });
779
+ browserCommand.command("remove").description("Remove Treeport's hosted Chromium build").option("--json", "emit machine-readable JSON").action(async () => {
780
+ await request("/api/browser/install", { method: "DELETE" });
781
+ print({ removed: true }, () => "Removed Treeport hosted Chromium");
782
+ });
783
+ browserCommand.command("list").description("List open Browser sessions").option("--json", "emit machine-readable JSON").action(async () => {
784
+ const panels = (await projects()).flatMap((project) => project.worktrees.flatMap((worktree) => worktree.panels.filter((panel) => panel.kind === "browser").map((panel) => ({
785
+ panelId: panel.id,
786
+ title: panel.title,
787
+ worktreeId: worktree.id,
788
+ worktree: worktree.name,
789
+ projectId: project.id,
790
+ project: project.name
791
+ }))));
792
+ print(panels, () => panels.length ? panels.map((panel) => `${panel.panelId}\t${panel.project} / ${panel.worktree}\t${panel.title}`).join("\n") : "Browser is not open.");
793
+ });
794
+ const printAgentResult = async (command, args, panelId) => {
795
+ const result = await runBrowserAgentCommand(command, args, panelId);
796
+ print(result, () => result.output);
797
+ };
798
+ const browserSnapshotCommand = browserCommand.command("snapshot").description("Capture an accessibility snapshot of the hosted page").option("--panel <panel-id>", "Browser ID").option("--json", "emit machine-readable JSON");
799
+ browserSnapshotCommand.action(async () => printAgentResult("snapshot", [], browserSnapshotCommand.opts().panel));
800
+ const browserClickCommand = browserCommand.command("click").description("Click an element from the latest browser snapshot").argument("<target>", "Playwright element reference or selector").option("--panel <panel-id>", "Browser ID").option("--json", "emit machine-readable JSON");
801
+ browserClickCommand.action(async (target) => printAgentResult("click", [target], browserClickCommand.opts().panel));
802
+ const browserFillCommand = browserCommand.command("fill").description("Fill an editable element from the latest browser snapshot").argument("<target>", "Playwright element reference or selector").argument("<text>", "text to enter").option("--panel <panel-id>", "Browser ID").option("--json", "emit machine-readable JSON");
803
+ browserFillCommand.action(async (target, text) => printAgentResult("fill", [target, text], browserFillCommand.opts().panel));
804
+ const browserPressCommand = browserCommand.command("press").description("Press a key in the hosted page").argument("<key>", "Playwright key name").option("--panel <panel-id>", "Browser ID").option("--json", "emit machine-readable JSON");
805
+ browserPressCommand.action(async (key) => printAgentResult("press", [key], browserPressCommand.opts().panel));
806
+ const browserGotoCommand = browserCommand.command("goto").description("Navigate the hosted page").argument("<url>", "absolute HTTP or HTTPS URL").option("--panel <panel-id>", "Browser ID").option("--json", "emit machine-readable JSON");
807
+ browserGotoCommand.action(async (url) => printAgentResult("goto", [url], browserGotoCommand.opts().panel));
808
+ const browserConsoleCommand = browserCommand.command("console").description("List page console messages").argument("[level]", "minimum console level").option("--panel <panel-id>", "Browser ID").option("--json", "emit machine-readable JSON");
809
+ browserConsoleCommand.action(async (level) => printAgentResult("console", level ? [level] : [], browserConsoleCommand.opts().panel));
810
+ for (const [name, description, agentName] of [
811
+ [
812
+ "back",
813
+ "Go back in the hosted page",
814
+ "go-back"
815
+ ],
816
+ [
817
+ "forward",
818
+ "Go forward in the hosted page",
819
+ "go-forward"
820
+ ],
821
+ [
822
+ "reload",
823
+ "Reload the hosted page",
824
+ "reload"
825
+ ],
826
+ [
827
+ "network",
828
+ "List page network requests",
829
+ "requests"
830
+ ],
831
+ [
832
+ "screenshot",
833
+ "Capture a screenshot of the hosted page",
834
+ "screenshot"
835
+ ]
836
+ ]) {
837
+ const command = browserCommand.command(name).description(description).option("--panel <panel-id>", "Browser ID").option("--json", "emit machine-readable JSON");
838
+ command.action(async () => printAgentResult(agentName, [], command.opts().panel));
839
+ }
2267
840
  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");
2268
841
  installCommand.action(async (source) => {
2269
842
  const options = installCommand.opts();
@@ -2296,10 +869,24 @@ async function main(args) {
2296
869
  return lines.join("\n");
2297
870
  });
2298
871
  });
2299
- 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");
872
+ 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");
2300
873
  updatePackagesCommand.action(async (source) => {
2301
874
  const options = updatePackagesCommand.opts();
2302
- 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);
875
+ if (source && options.packages) throw new CliError("Specify a package source or --packages, not both.", 2);
876
+ if (!source && !options.packages) {
877
+ if (await resolveDaemonLifecycle() === "external") throw new CliError("Cannot update Treeport because this daemon lifecycle is externally managed.", 5, "UPDATE_EXTERNAL_REFUSED");
878
+ const selfUpdateOptions = { environment: cliEnvironment };
879
+ if (!jsonOutput) selfUpdateOptions.progress = (message) => writeStderr(`${message}\n`);
880
+ const result = await runLocalUpdate(selfUpdateOptions).catch((error) => {
881
+ if (error instanceof LocalUpdateError) throw new CliError(error.message, error.exitCode, error.code, error.details);
882
+ throw error;
883
+ });
884
+ print(result, () => {
885
+ if (result.status === "current") return `Treeport ${result.toVersion} is current`;
886
+ 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`;
887
+ });
888
+ return;
889
+ }
2303
890
  const results = (await request("/api/packages/update", {
2304
891
  method: "POST",
2305
892
  body: JSON.stringify(source ? { source: await packageSource(source) } : {})
@@ -2321,16 +908,16 @@ async function main(args) {
2321
908
  projectCommand.action(() => {
2322
909
  throw new CliError(projectCommand.helpInformation(), 2);
2323
910
  });
2324
- projectCommand.command("add").description("Register a Git repository").argument("<path>", "repository path").option("--json", "emit machine-readable JSON").action(async (repository) => {
911
+ projectCommand.command("add").description("Register a folder or Git repository").argument("<path>", "folder path").option("--json", "emit machine-readable JSON").action(async (repository) => {
2325
912
  const body = await request("/api/projects", {
2326
913
  method: "POST",
2327
914
  body: JSON.stringify({ path: await canonical(repository) })
2328
915
  });
2329
- print(body.project, () => `Registered ${body.project.name} (${body.project.id})\n${body.project.repositoryPath}`);
916
+ print(body.project, () => `Registered ${body.project.name} (${body.project.id})\n${body.project.rootPath}`);
2330
917
  });
2331
918
  projectCommand.command("list").description("List registered projects").option("--json", "emit machine-readable JSON").action(async () => {
2332
919
  const list = await projects();
2333
- print(list, () => list.map((project) => `${project.id}\t${project.name}\t${project.repositoryPath}`).join("\n"));
920
+ print(list, () => list.map((project) => `${project.id}\t${project.name}\t${project.kind}\t${project.rootPath}`).join("\n"));
2334
921
  });
2335
922
  const worktreeCommand = program.command("worktree").description("List, create, and remove trees");
2336
923
  worktreeCommand.action(() => {
@@ -2340,9 +927,9 @@ async function main(args) {
2340
927
  worktreeListCommand.action(async () => {
2341
928
  const { project: projectIdentifier } = worktreeListCommand.opts();
2342
929
  const list = projectIdentifier ? (await resolveProject(projectIdentifier)).worktrees : (await projects()).flatMap((project) => project.worktrees);
2343
- print(list, () => list.map((worktree) => `${worktree.id}\t${worktree.name}\t${worktree.branch ?? `detached@${worktree.head.slice(0, 8)}`}\t${worktree.path}`).join("\n"));
930
+ 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"));
2344
931
  });
2345
- const worktreeCreateCommand = worktreeCommand.command("create").description("Create a linked tree").requiredOption("--project <id-or-path>", "project to create from").requiredOption("--name <name>", "Tree name").option("--from-current", "base the tree on the current tree").option("--json", "emit machine-readable JSON");
932
+ 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");
2346
933
  worktreeCreateCommand.action(async () => {
2347
934
  const options = worktreeCreateCommand.opts();
2348
935
  const project = await resolveProject(options.project);
@@ -2401,7 +988,7 @@ async function main(args) {
2401
988
  });
2402
989
  const output = {
2403
990
  ...result,
2404
- url: webPanelUrl(worktree, result.panel.id)
991
+ url: panelUrl(worktree, result.panel.id)
2405
992
  };
2406
993
  print(output, () => `${result.reused ? "Reused" : "Opened"} ${result.panel.title} (${result.panel.id})\n${output.url}`);
2407
994
  });
@@ -2467,7 +1054,7 @@ async function main(args) {
2467
1054
  terminalId
2468
1055
  }, () => `Deleted ${terminalId}`);
2469
1056
  });
2470
- const spawnCommand = program.command("spawn").description("Create a tree and its first terminal").usage("[options] [-- <command> args...]").requiredOption("--project <id-or-path-or-dot>", "project to create from").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");
1057
+ 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");
2471
1058
  spawnCommand.action(async () => {
2472
1059
  const options = spawnCommand.opts();
2473
1060
  const project = await resolveProject(options.project);
@@ -2495,6 +1082,7 @@ async function main(args) {
2495
1082
  }
2496
1083
  async function runCliApplication(options) {
2497
1084
  const environment = options.environment ?? process.env;
1085
+ cliEnvironment = environment;
2498
1086
  configuredApiUrl = environment.TREEPORT_API_URL?.trim();
2499
1087
  apiUrl = (await resolveLocalApiUrl(environment)).replace(/\/$/, "");
2500
1088
  contextProjectId = environment.TREEPORT_PROJECT_ID?.trim() || void 0;