@treeport/treeport 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { A as 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 { A as treeportVersion, C as disableTailscaleRemote, D as resolvePackagePath, E as resolveLocalApiUrl, M as parseDurationMs, N as TERMINAL_CAPTURE_MAX_LINES, O as runDoctor, S as daemonUp, T as readDaemonLogs, _ as serviceStatus, b as daemonHealth, ct as parseEventsSnapshot, d as serviceDisable, f as serviceDoctorCheck, g as serviceStart, h as serviceRun, k as tailscaleRemoteStatus, l as readServiceLogs, lt as parseProductEvent, m as serviceInstalled, p as serviceEnable, s as runLocalUpdate, st as webPanelInputSchema, t as LocalUpdateError, u as serviceApply, ut as SOCKET_IO_PATH, v as serviceStop, w as enableTailscaleRemote, x as daemonStatus, y as daemonDown } from "../../update-BW-a6Bd-.js";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { Command, CommanderError } from "commander";
6
6
  import { io } from "socket.io-client";
7
- import { z } from "zod";
8
7
  import { spawn } from "node:child_process";
9
- import crypto from "node:crypto";
10
- import fsSync, { constants } from "node:fs";
11
- import os from "node:os";
12
- import { fileURLToPath, pathToFileURL } from "node:url";
13
8
  //#region src/cli/args.ts
14
9
  function extractJsonOutput(args) {
15
10
  const separator = args.indexOf("--");
@@ -82,1568 +77,6 @@ async function openWorkspace(workspaceUrl, options = {}) {
82
77
  throw new OpenWorkspaceError(`Treeport registered the folder, but could not open it automatically.${browser.stderr ? ` ${browser.stderr}` : ""}\nOpen this URL manually: ${workspaceUrl}`);
83
78
  }
84
79
  //#endregion
85
- //#region src/cli/lifecycle.ts
86
- const DEFAULT_HOST = "127.0.0.1";
87
- const DEFAULT_PORT = 8733;
88
- function listenerUrl(host, port) {
89
- return `http://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`;
90
- }
91
- function expandHome(value) {
92
- return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
93
- }
94
- function localPaths(env = process.env) {
95
- const defaultDataDir = env.XDG_DATA_HOME ? path.join(expandHome(env.XDG_DATA_HOME), "treeport") : process.platform === "darwin" ? path.join(os.homedir(), "Library", "Application Support", "treeport") : path.join(os.homedir(), ".local", "share", "treeport");
96
- const dataDir = path.resolve(expandHome(env.TREEPORT_DATA_DIR?.trim() || defaultDataDir));
97
- const runtimeDir = path.resolve(expandHome(env.TREEPORT_RUNTIME_DIR?.trim() || (env.XDG_RUNTIME_DIR ? path.join(env.XDG_RUNTIME_DIR, "treeport") : path.join(os.tmpdir(), `treeport-${process.getuid?.() ?? "user"}`))));
98
- return {
99
- dataDir,
100
- runtimeDir,
101
- preferencesPath: path.join(dataDir, "config.json"),
102
- statePath: path.join(runtimeDir, "daemon.json"),
103
- lockPath: path.join(dataDir, "daemon.lock"),
104
- logPath: path.join(dataDir, "logs", "daemon.log")
105
- };
106
- }
107
- async function readJson$1(filePath, schema) {
108
- return fs.readFile(filePath, "utf8").then((value) => schema.parse(JSON.parse(value))).catch(() => null);
109
- }
110
- const preferencesSchema = z.looseObject({
111
- host: z.string().optional(),
112
- port: z.number().optional(),
113
- remote: z.strictObject({
114
- port: z.number(),
115
- target: z.string()
116
- }).optional()
117
- });
118
- const daemonRecordSchema = z.strictObject({
119
- pid: z.number(),
120
- instanceId: z.string(),
121
- version: z.string(),
122
- apiUrl: z.string(),
123
- dataDir: z.string(),
124
- startedAt: z.string(),
125
- installationMethod: z.string(),
126
- daemonLifecycle: z.enum([
127
- "treeport",
128
- "service",
129
- "external"
130
- ])
131
- });
132
- const healthRecordSchema = z.strictObject({
133
- ok: z.literal(true),
134
- version: z.string(),
135
- protocolVersion: z.number(),
136
- hostname: z.string().optional(),
137
- pid: z.number(),
138
- instanceId: z.string().nullable(),
139
- installationMethod: z.string(),
140
- daemonLifecycle: z.enum([
141
- "treeport",
142
- "service",
143
- "external"
144
- ]),
145
- url: z.string()
146
- });
147
- async function preferences(env = process.env) {
148
- return await readJson$1(localPaths(env).preferencesPath, preferencesSchema) ?? {};
149
- }
150
- async function savePreferences(value) {
151
- const paths = localPaths();
152
- await fs.mkdir(paths.dataDir, {
153
- recursive: true,
154
- mode: 448
155
- });
156
- const temporaryPath = `${paths.preferencesPath}.${process.pid}.tmp`;
157
- await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
158
- await fs.rename(temporaryPath, paths.preferencesPath);
159
- }
160
- async function resolveLocalApiUrl(env = process.env) {
161
- const explicit = env.TREEPORT_API_URL?.trim();
162
- const managedApiUrl = env.TREEPORT_MANAGED_API_URL?.trim();
163
- const daemonRecordPath = env.TREEPORT_DAEMON_RECORD?.trim();
164
- if (explicit && explicit !== managedApiUrl) return explicit.replace(/\/$/, "");
165
- if (managedApiUrl && daemonRecordPath) {
166
- const record = await readJson$1(path.resolve(expandHome(daemonRecordPath)), daemonRecordSchema);
167
- if (record) return record.apiUrl.replace(/\/$/, "");
168
- }
169
- if (explicit) return explicit.replace(/\/$/, "");
170
- const saved = await preferences(env);
171
- return listenerUrl(env.TREEPORT_HOST?.trim() || env.HOST?.trim() || saved.host || DEFAULT_HOST, Number.parseInt(env.TREEPORT_PORT?.trim() || env.PORT?.trim() || String(saved.port ?? DEFAULT_PORT), 10));
172
- }
173
- async function resolvePackagePath(...segments) {
174
- const candidates = [fileURLToPath(new URL("../../../", import.meta.url)), fileURLToPath(new URL("../../", import.meta.url))];
175
- for (const candidate of candidates) if (await fs.access(path.join(candidate, "package.json")).then(() => true).catch(() => false)) return path.join(candidate, ...segments);
176
- throw new Error("Could not locate the Treeport package directory");
177
- }
178
- async function treeportVersion() {
179
- return (await readJson$1(await resolvePackagePath("package.json"), z.looseObject({ version: z.string().optional() })))?.version ?? "development";
180
- }
181
- function processExists(pid) {
182
- try {
183
- process.kill(pid, 0);
184
- return true;
185
- } catch (error) {
186
- return error.code === "EPERM";
187
- }
188
- }
189
- async function daemonHealth(apiUrl, timeoutMs = 1500) {
190
- const signal = AbortSignal.timeout(timeoutMs);
191
- return fetch(`${apiUrl}/api/health`, { signal }).then(async (response) => {
192
- if (!response.ok) return null;
193
- const result = healthRecordSchema.safeParse(await response.json());
194
- return result.success ? result.data : null;
195
- }).catch(() => null);
196
- }
197
- function matchesOwnership(state, observed) {
198
- return observed.pid === state.pid && observed.instanceId === state.instanceId && path.resolve(state.dataDir) === localPaths().dataDir;
199
- }
200
- async function readState() {
201
- return readJson$1(localPaths().statePath, daemonRecordSchema);
202
- }
203
- async function removeStaleState(state) {
204
- const paths = localPaths();
205
- for (const filePath of [paths.statePath, paths.lockPath]) if ((await readJson$1(filePath, z.looseObject({ instanceId: z.string() })))?.instanceId === state.instanceId) await fs.rm(filePath, { force: true });
206
- }
207
- async function stopOwned(state) {
208
- if (!processExists(state.pid)) {
209
- await removeStaleState(state);
210
- return;
211
- }
212
- const observed = await daemonHealth(state.apiUrl);
213
- if (!observed || !matchesOwnership(state, observed)) throw new Error(`Refusing to stop PID ${state.pid}: Treeport could not verify ownership. Check ${localPaths().statePath}.`);
214
- process.kill(state.pid, "SIGTERM");
215
- const deadline = Date.now() + 7e3;
216
- while (Date.now() < deadline) {
217
- if (!processExists(state.pid)) {
218
- await removeStaleState(state);
219
- return;
220
- }
221
- await new Promise((resolve) => setTimeout(resolve, 100));
222
- }
223
- throw new Error(`Treeport did not stop within 7 seconds. See ${localPaths().logPath}.`);
224
- }
225
- async function executableCheck(executable, args) {
226
- return new Promise((resolve) => {
227
- const child = spawn(executable, args, { stdio: [
228
- "ignore",
229
- "pipe",
230
- "pipe"
231
- ] });
232
- let output = "";
233
- child.stdout.setEncoding("utf8");
234
- child.stderr.setEncoding("utf8");
235
- child.stdout.on("data", (chunk) => {
236
- output += chunk;
237
- });
238
- child.stderr.on("data", (chunk) => {
239
- output += chunk;
240
- });
241
- child.once("error", (error) => resolve({
242
- ok: false,
243
- detail: error.message
244
- }));
245
- child.once("close", (code) => resolve({
246
- ok: code === 0,
247
- detail: output.trim() || `exited with status ${code ?? 1}`
248
- }));
249
- });
250
- }
251
- const tailscaleStatusResponseSchema = z.looseObject({
252
- BackendState: z.string().optional(),
253
- Self: z.looseObject({ DNSName: z.string().optional() }).optional()
254
- });
255
- const tailscaleServeConfigurationSchema = z.lazy(() => z.looseObject({
256
- TCP: z.record(z.string(), z.looseObject({})).optional(),
257
- Foreground: z.record(z.string(), tailscaleServeConfigurationSchema).optional(),
258
- Web: z.record(z.string(), z.looseObject({ Handlers: z.record(z.string(), z.looseObject({ Proxy: z.string().optional() })).optional() })).optional()
259
- }));
260
- async function tailscale(args) {
261
- return new Promise((resolve, reject) => {
262
- const child = spawn("tailscale", args, { stdio: [
263
- "ignore",
264
- "pipe",
265
- "pipe"
266
- ] });
267
- let stdout = "";
268
- let stderr = "";
269
- child.stdout.setEncoding("utf8");
270
- child.stderr.setEncoding("utf8");
271
- child.stdout.on("data", (chunk) => {
272
- stdout += chunk;
273
- });
274
- child.stderr.on("data", (chunk) => {
275
- stderr += chunk;
276
- });
277
- child.once("error", (error) => reject(/* @__PURE__ */ new Error(error.code === "ENOENT" ? "Tailscale is required for remote access. Install it from https://tailscale.com/download, run `tailscale up`, then retry." : `Could not run Tailscale: ${error.message}`)));
278
- child.once("close", (code) => {
279
- if (code === 0) {
280
- resolve(stdout);
281
- return;
282
- }
283
- const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
284
- reject(/* @__PURE__ */ new Error(`Tailscale ${args[0]} failed${detail ? `: ${detail}` : ` (status ${code ?? 1})`}`));
285
- });
286
- });
287
- }
288
- function tailscaleJson(value, command, schema) {
289
- const result = schema.safeParse(JSON.parse(value));
290
- if (!result.success) throw new Error(`Tailscale ${command} returned an invalid JSON response`);
291
- return result.data;
292
- }
293
- function remotePreference(value) {
294
- if (value.remote === void 0) return null;
295
- if (!Number.isInteger(value.remote.port) || value.remote.port < 1 || value.remote.port > 65535 || !value.remote.target) throw new Error("Treeport remote access preferences are invalid");
296
- return value.remote;
297
- }
298
- function localProxyTarget(apiUrl) {
299
- if (!URL.canParse(apiUrl)) throw new Error("Treeport remote access requires a loopback daemon URL");
300
- const url = new URL(apiUrl);
301
- if (url.protocol !== "http:" || ![
302
- "127.0.0.1",
303
- "localhost",
304
- "::1",
305
- "[::1]"
306
- ].includes(url.hostname)) throw new Error("Treeport remote access requires a loopback daemon. Run `treeport start --host 127.0.0.1`, then try again.");
307
- return `http://${url.host}`;
308
- }
309
- function portIsServed(config, port) {
310
- const tcp = config.TCP;
311
- if (tcp && Object.hasOwn(tcp, String(port))) return true;
312
- return Object.values(config.Foreground ?? {}).some((value) => portIsServed(value, port));
313
- }
314
- function rootProxyForPort(config, port) {
315
- for (const [hostPort, server] of Object.entries(config.Web ?? {})) {
316
- if (!hostPort.endsWith(`:${port}`)) continue;
317
- const proxy = server.Handlers?.["/"]?.Proxy;
318
- if (proxy !== void 0) return proxy;
319
- }
320
- return null;
321
- }
322
- function proxyMatches(actual, expected) {
323
- return actual !== null && expected !== void 0 && actual.replace(/\/$/, "") === expected.replace(/\/$/, "");
324
- }
325
- async function tailscaleServeConfig() {
326
- return tailscaleJson(await tailscale([
327
- "serve",
328
- "status",
329
- "--json"
330
- ]), "serve status", tailscaleServeConfigurationSchema);
331
- }
332
- async function tailscaleRemoteUrl(port) {
333
- const status = tailscaleJson(await tailscale(["status", "--json"]), "status", tailscaleStatusResponseSchema);
334
- if (status.BackendState !== "Running") throw new Error("Tailscale is not connected. Run `tailscale up` then try again.");
335
- const dnsName = status.Self?.DNSName;
336
- if (!dnsName?.trim()) throw new Error("Tailscale did not report a DNS name. Enable MagicDNS, then try again.");
337
- return `https://${dnsName.trim().replace(/\.$/, "")}${port === 443 ? "" : `:${port}`}`;
338
- }
339
- async function enableTailscaleRemote(options) {
340
- if (options.port !== void 0 && (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535)) throw new Error("--port must be an integer between 1 and 65535");
341
- const saved = await preferences();
342
- const remote = remotePreference(saved);
343
- const port = options.port ?? remote?.port ?? DEFAULT_PORT;
344
- if (remote && remote.port !== port) throw new Error(`Treeport remote access is already configured on port ${remote.port}. Run \`treeport remote disable\` before choosing another port.`);
345
- const expectedTarget = localProxyTarget((await daemonStatus()).state?.apiUrl ?? await resolveLocalApiUrl());
346
- const [url, config] = await Promise.all([tailscaleRemoteUrl(port), tailscaleServeConfig()]);
347
- const existingTarget = rootProxyForPort(config, port);
348
- if ((portIsServed(config, port) || existingTarget !== null) && !proxyMatches(existingTarget, expectedTarget) && !proxyMatches(existingTarget, remote?.target)) throw new Error(`Tailscale Serve already uses port ${port}. Choose another port with \`treeport remote enable --port <port>\`.`);
349
- const target = localProxyTarget((options.daemon ?? await daemonUp({})).apiUrl);
350
- const alreadyEnabled = proxyMatches(existingTarget, target);
351
- if (!alreadyEnabled) await tailscale([
352
- "serve",
353
- "--bg",
354
- `--https=${port}`,
355
- target
356
- ]);
357
- await savePreferences({
358
- ...saved,
359
- remote: {
360
- port,
361
- target
362
- }
363
- });
364
- return {
365
- alreadyEnabled,
366
- port,
367
- url
368
- };
369
- }
370
- async function tailscaleRemoteStatus() {
371
- const remote = remotePreference(await preferences());
372
- if (!remote) return {
373
- configured: false,
374
- active: false,
375
- port: null,
376
- url: null
377
- };
378
- const [url, config] = await Promise.all([tailscaleRemoteUrl(remote.port), tailscaleServeConfig()]);
379
- return {
380
- configured: true,
381
- active: proxyMatches(rootProxyForPort(config, remote.port), remote.target),
382
- port: remote.port,
383
- url
384
- };
385
- }
386
- async function disableTailscaleRemote() {
387
- const saved = await preferences();
388
- const remote = remotePreference(saved);
389
- if (!remote) return {
390
- wasEnabled: false,
391
- changedTailscale: false
392
- };
393
- if (proxyMatches(rootProxyForPort(await tailscaleServeConfig(), remote.port), remote.target)) {
394
- await tailscale([
395
- "serve",
396
- `--https=${remote.port}`,
397
- "off"
398
- ]);
399
- delete saved.remote;
400
- await savePreferences(saved);
401
- return {
402
- wasEnabled: true,
403
- changedTailscale: true
404
- };
405
- }
406
- delete saved.remote;
407
- await savePreferences(saved);
408
- return {
409
- wasEnabled: false,
410
- changedTailscale: false
411
- };
412
- }
413
- async function runDoctor() {
414
- const paths = localPaths();
415
- const gitPath = process.env.TREEPORT_GIT_PATH?.trim() || "git";
416
- const tmuxPath = process.env.TREEPORT_TMUX_PATH?.trim() || "tmux";
417
- const [git, tmux] = await Promise.all([executableCheck(gitPath, ["--version"]), executableCheck(tmuxPath, ["-V"])]);
418
- const tmuxMatch = /tmux\s+(\d+)\.(\d+)/i.exec(tmux.detail);
419
- const tmuxSupported = Boolean(tmux.ok && tmuxMatch && (Number(tmuxMatch[1]) > 3 || Number(tmuxMatch[1]) === 3 && Number(tmuxMatch[2]) >= 2));
420
- const checkDirectory = (directoryPath) => fs.mkdir(directoryPath, {
421
- recursive: true,
422
- mode: 448
423
- }).then(() => ({
424
- ok: true,
425
- detail: directoryPath
426
- })).catch((error) => ({
427
- ok: false,
428
- detail: `${directoryPath}: ${error instanceof Error ? error.message : String(error)}`
429
- }));
430
- const [dataDirectory, runtimeDirectory] = await Promise.all([checkDirectory(paths.dataDir), checkDirectory(paths.runtimeDir)]);
431
- return [
432
- {
433
- name: "Node",
434
- ok: true,
435
- detail: process.version
436
- },
437
- {
438
- name: "Git",
439
- ...git
440
- },
441
- {
442
- name: "tmux",
443
- ok: tmuxSupported,
444
- detail: tmuxSupported ? tmux.detail : `${tmux.detail}. Treeport requires tmux 3.2 or newer.`
445
- },
446
- {
447
- name: "Data directory",
448
- ...dataDirectory
449
- },
450
- {
451
- name: "Runtime directory",
452
- ...runtimeDirectory
453
- }
454
- ];
455
- }
456
- async function daemonStatus() {
457
- const state = await readState();
458
- if (!state) return {
459
- running: false,
460
- state: null,
461
- health: null,
462
- verified: false
463
- };
464
- if (!processExists(state.pid)) {
465
- await removeStaleState(state);
466
- return {
467
- running: false,
468
- state: null,
469
- health: null,
470
- verified: false
471
- };
472
- }
473
- const observed = await daemonHealth(state.apiUrl);
474
- return {
475
- running: Boolean(observed),
476
- state,
477
- health: observed,
478
- verified: Boolean(observed && matchesOwnership(state, observed))
479
- };
480
- }
481
- async function daemonUp(options) {
482
- if (options.port !== void 0 && (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535)) throw new Error("--port must be an integer between 1 and 65535");
483
- const paths = localPaths();
484
- const saved = await preferences();
485
- const next = {
486
- ...saved,
487
- host: options.host?.trim() || saved.host || DEFAULT_HOST,
488
- port: options.port ?? saved.port ?? DEFAULT_PORT
489
- };
490
- const host = options.host?.trim() || process.env.TREEPORT_HOST?.trim() || process.env.HOST?.trim() || next.host;
491
- assertLoopbackHost(host);
492
- if (options.host !== void 0 || options.port !== void 0) await savePreferences(next);
493
- const port = Number.parseInt(options.port === void 0 ? process.env.TREEPORT_PORT?.trim() || process.env.PORT?.trim() || String(next.port) : String(options.port), 10);
494
- const apiUrl = options.host !== void 0 || options.port !== void 0 ? listenerUrl(host, port) : process.env.TREEPORT_API_URL?.trim() || listenerUrl(host, port);
495
- const currentVersion = await treeportVersion();
496
- const existing = await daemonStatus();
497
- if (existing.state) {
498
- if (!existing.running || !existing.verified) throw new Error(`Treeport PID ${existing.state.pid} is running but ownership or health could not be verified. See ${paths.logPath}.`);
499
- if (existing.health?.version === currentVersion && existing.state.apiUrl === apiUrl) return {
500
- alreadyRunning: true,
501
- apiUrl: existing.state.apiUrl,
502
- pid: existing.state.pid
503
- };
504
- await stopOwned(existing.state);
505
- }
506
- const failedChecks = (await runDoctor()).filter((check) => !check.ok);
507
- if (failedChecks.length) throw new Error(failedChecks.map((check) => `${check.name}: ${check.detail}`).join("\n"));
508
- const serverEntry = await resolvePackagePath("dist", "node", "server", "index.js");
509
- const webDist = await resolvePackagePath("dist", "web");
510
- await fs.access(serverEntry);
511
- await fs.mkdir(path.dirname(paths.logPath), {
512
- recursive: true,
513
- mode: 448
514
- });
515
- if (await fs.stat(paths.logPath).then((value) => value.size).catch(() => 0) > 5 * 1024 * 1024) {
516
- await fs.rm(`${paths.logPath}.1`, { force: true });
517
- await fs.rename(paths.logPath, `${paths.logPath}.1`);
518
- }
519
- const instanceId = crypto.randomUUID();
520
- const childEnvironment = {
521
- ...process.env,
522
- TREEPORT_HOST: host,
523
- TREEPORT_PORT: String(port),
524
- TREEPORT_API_URL: apiUrl,
525
- TREEPORT_DATA_DIR: paths.dataDir,
526
- TREEPORT_RUNTIME_DIR: paths.runtimeDir,
527
- TREEPORT_APP_VERSION: currentVersion,
528
- TREEPORT_INSTANCE_ID: instanceId,
529
- TREEPORT_INSTALLATION_METHOD: process.env.TREEPORT_INSTALLATION_METHOD?.trim() || "npm",
530
- TREEPORT_DAEMON_LIFECYCLE: "treeport",
531
- TREEPORT_WEB_DIST: webDist
532
- };
533
- if (options.foreground) {
534
- console.log(`Treeport will listen on ${apiUrl}`);
535
- const child = spawn(process.execPath, [serverEntry], {
536
- env: childEnvironment,
537
- stdio: "inherit"
538
- });
539
- const code = await new Promise((resolve, reject) => {
540
- child.once("error", reject);
541
- child.once("close", (value) => resolve(value ?? 1));
542
- });
543
- if (code !== 0) throw new Error(`Treeport exited with status ${code}`);
544
- return {
545
- alreadyRunning: false,
546
- apiUrl,
547
- pid: child.pid ?? 0
548
- };
549
- }
550
- const log = fsSync.openSync(paths.logPath, "a", 384);
551
- const child = spawn(process.execPath, [serverEntry], {
552
- env: childEnvironment,
553
- detached: true,
554
- stdio: [
555
- "ignore",
556
- log,
557
- log
558
- ]
559
- });
560
- child.unref();
561
- fsSync.closeSync(log);
562
- const deadline = Date.now() + 15e3;
563
- while (Date.now() < deadline) {
564
- const observed = await daemonHealth(apiUrl, 500);
565
- if (observed && observed.pid === child.pid && observed.instanceId === instanceId && observed.version === currentVersion) return {
566
- alreadyRunning: false,
567
- apiUrl,
568
- pid: child.pid ?? observed.pid
569
- };
570
- if (child.pid && !processExists(child.pid)) break;
571
- await new Promise((resolve) => setTimeout(resolve, 100));
572
- }
573
- const recentLog = await fs.readFile(paths.logPath, "utf8").then((value) => value.split("\n").slice(-20).join("\n").trim()).catch(() => "");
574
- throw new Error(`Treeport did not become ready at ${apiUrl}. See ${paths.logPath}.${recentLog ? `\n\n${recentLog}` : ""}`);
575
- }
576
- async function daemonDown() {
577
- const state = await readState();
578
- if (!state) return { wasRunning: false };
579
- await stopOwned(state);
580
- return { wasRunning: true };
581
- }
582
- async function readDaemonLogs(lines = 100) {
583
- return (await fs.readFile(localPaths().logPath, "utf8").catch((error) => {
584
- if (error.code === "ENOENT") return "";
585
- throw error;
586
- })).split("\n").slice(-lines - 1).join("\n");
587
- }
588
- //#endregion
589
- //#region src/cli/service.ts
590
- const serviceRecordSchema = z.strictObject({
591
- schemaVersion: z.literal(1),
592
- manager: z.enum(["launchd", "systemd"]),
593
- platform: z.string(),
594
- uid: z.number().int().nonnegative(),
595
- gid: z.number().int().nonnegative(),
596
- username: z.string().min(1),
597
- group: z.string().min(1),
598
- home: z.string().min(1),
599
- dataDir: z.string().min(1),
600
- runtimeDir: z.string().min(1),
601
- logPath: z.string().min(1),
602
- apiUrl: z.string().min(1),
603
- cliEntrypoint: z.string().min(1),
604
- 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
80
  //#region src/cli/application.ts
1648
81
  const contextPrefix = "TREEPORT";
1649
82
  let configuredApiUrl;
@@ -1661,6 +94,7 @@ let writeStderr = (value) => {
1661
94
  process.stderr.write(value);
1662
95
  };
1663
96
  let requestedExitCode = 0;
97
+ let cliEnvironment = process.env;
1664
98
  var CliError = class extends Error {
1665
99
  exitCode;
1666
100
  code;
@@ -1682,10 +116,12 @@ async function resolveDaemonLifecycle() {
1682
116
  return await serviceInstalled() ? "service" : "treeport";
1683
117
  }
1684
118
  function formatServiceStatus(status) {
119
+ const mode = status.mode === "headless" ? "advanced headless (starts before login)" : status.mode === "user" && status.manager === "launchd" ? "user/login (starts after login)" : status.mode === "user" ? "user service" : "not installed";
1685
120
  const lines = [
1686
121
  `Treeport service: ${status.state}`,
122
+ `Mode: ${mode}`,
1687
123
  `Manager: ${status.manager ?? "unsupported"}`,
1688
- `Starts at boot: ${status.enabledAtBoot ? "yes" : "no"}`,
124
+ `Starts before login: ${status.enabledAtBoot ? "yes" : "no"}`,
1689
125
  `Active: ${status.active ? "yes" : "no"}`,
1690
126
  `Definition: ${status.definitionPath ?? "not installed"}`
1691
127
  ];
@@ -1789,9 +225,12 @@ async function resolveProject(identifier) {
1789
225
  const environmentMatch = list.find((project) => project.id === contextProjectId);
1790
226
  if (environmentMatch) return environmentMatch;
1791
227
  }
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);
228
+ const candidate = await canonical(identifier ?? ".");
229
+ const match = list.flatMap((project) => [project.rootPath, ...project.worktrees.map((item) => item.path)].map((root) => ({
230
+ project,
231
+ root
232
+ }))).filter(({ root }) => pathContains(candidate, root)).sort((left, right) => right.root.length - left.root.length)[0]?.project;
233
+ if (!match) throw new CliError(identifier === void 0 ? `No registered project contains ${candidate}. Specify --project <id-or-path>.` : `No registered project matches ${identifier}`, 5);
1795
234
  return match;
1796
235
  }
1797
236
  async function packageSource(value) {
@@ -2008,7 +447,7 @@ const agentGuidance = `AI agents:
2008
447
  async function main(args) {
2009
448
  const argv = args[0] === "spawn" || args[0] === "terminal" && args[1] === "create" ? commandArgv(args) : void 0;
2010
449
  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({
450
+ const program = new Command().name("treeport").usage("[options] [folder] [command]").description("Manage Treeport projects, trees, and terminals.").argument("[folder]", "folder or folder inside a Git repository to open").option("--json", "emit machine-readable JSON").addHelpText("beforeAll", agentGuidance).configureOutput({
2012
451
  writeOut: writeStdout,
2013
452
  writeErr: (value) => {
2014
453
  parserError += value;
@@ -2035,12 +474,9 @@ async function main(args) {
2035
474
  const registered = await request("/api/projects", {
2036
475
  method: "POST",
2037
476
  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
477
  });
2042
478
  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", {
479
+ if (!targetWorktree) throw new CliError(`Treeport did not find a workspace containing ${canonicalFolder}.`, 5, "WORKTREE_NOT_FOUND", {
2044
480
  path: canonicalFolder,
2045
481
  projectId: registered.project.id
2046
482
  });
@@ -2048,7 +484,10 @@ async function main(args) {
2048
484
  target.pathname = `/projects/${encodeURIComponent(registered.project.id)}/worktrees/${encodeURIComponent(targetWorktree.id)}`;
2049
485
  target.search = "";
2050
486
  target.hash = "";
2051
- const opened = await openWorkspace(target.href).catch((error) => {
487
+ const opened = contextTerminalId ? await request(`/api/worktrees/${encodeURIComponent(targetWorktree.id)}/open`, {
488
+ method: "POST",
489
+ body: JSON.stringify({ sourceTerminalId: contextTerminalId })
490
+ }).then(() => ({ client: "current" })) : await openWorkspace(target.href).catch((error) => {
2052
491
  if (error instanceof OpenWorkspaceError) throw new CliError(error.message, 1, "OPEN_FAILED", { url: target.href });
2053
492
  throw error;
2054
493
  });
@@ -2056,9 +495,10 @@ async function main(args) {
2056
495
  projectId: registered.project.id,
2057
496
  worktreeId: targetWorktree.id,
2058
497
  path: canonicalFolder,
498
+ projectKind: registered.project.kind,
2059
499
  url: target.href,
2060
500
  client: opened.client
2061
- }, () => `Opened ${registered.project.name} / ${targetWorktree.name} in the ${opened.client === "desktop" ? "Treeport desktop app" : "browser"}\n${target.href}`);
501
+ }, () => `Opened ${registered.project.name} / ${targetWorktree.name} in the ${opened.client === "desktop" ? "Treeport desktop app" : opened.client === "current" ? "current Treeport client" : "browser"}\n${target.href}`);
2062
502
  });
2063
503
  const startCommand = program.command("start").description("Ensure the local Treeport daemon is running").option("--host <address>", "loopback listener address").option("--port <port>", "listener port").option("--foreground", "run in the foreground").option("--json", "emit machine-readable JSON");
2064
504
  startCommand.action(async () => {
@@ -2101,8 +541,9 @@ async function main(args) {
2101
541
  serviceCommand.action(() => {
2102
542
  writeStdout(serviceCommand.helpInformation());
2103
543
  });
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();
544
+ const serviceEnableCommand = serviceCommand.command("enable").description("Enable automatic startup and unexpected-exit restarts").option("--headless", "use advanced macOS startup before login (requires an administrator)").option("--json", "emit machine-readable JSON");
545
+ serviceEnableCommand.action(async () => {
546
+ const result = await serviceEnable(serviceEnableCommand.opts().headless ? "headless" : "user");
2106
547
  print(result, () => formatServiceStatus(result.status));
2107
548
  if (result.status.state === "action_required") requestedExitCode = 1;
2108
549
  });
@@ -2239,6 +680,8 @@ async function main(args) {
2239
680
  project: {
2240
681
  id: project.id,
2241
682
  name: project.name,
683
+ kind: project.kind,
684
+ rootPath: project.rootPath,
2242
685
  repositoryPath: project.repositoryPath,
2243
686
  mainWorktreePath: project.mainWorktreePath,
2244
687
  defaultBranch: project.defaultBranch,
@@ -2296,10 +739,24 @@ async function main(args) {
2296
739
  return lines.join("\n");
2297
740
  });
2298
741
  });
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");
742
+ const updatePackagesCommand = program.command("update").description("Update Treeport or explicitly update configured packages").argument("[source]", "one configured npm: source").option("--packages", "update every eligible configured package").option("--json", "emit machine-readable JSON");
2300
743
  updatePackagesCommand.action(async (source) => {
2301
744
  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);
745
+ if (source && options.packages) throw new CliError("Specify a package source or --packages, not both.", 2);
746
+ if (!source && !options.packages) {
747
+ if (await resolveDaemonLifecycle() === "external") throw new CliError("Cannot update Treeport because this daemon lifecycle is externally managed.", 5, "UPDATE_EXTERNAL_REFUSED");
748
+ const selfUpdateOptions = { environment: cliEnvironment };
749
+ if (!jsonOutput) selfUpdateOptions.progress = (message) => writeStderr(`${message}\n`);
750
+ const result = await runLocalUpdate(selfUpdateOptions).catch((error) => {
751
+ if (error instanceof LocalUpdateError) throw new CliError(error.message, error.exitCode, error.code, error.details);
752
+ throw error;
753
+ });
754
+ print(result, () => {
755
+ if (result.status === "current") return `Treeport ${result.toVersion} is current`;
756
+ return result.daemon.wasRunning ? `Updated Treeport from ${result.fromVersion} to ${result.toVersion} and restarted the ${result.daemon.lifecycle === "service" ? "service" : "daemon"}` : `Updated Treeport from ${result.fromVersion} to ${result.toVersion}; Treeport remains stopped`;
757
+ });
758
+ return;
759
+ }
2303
760
  const results = (await request("/api/packages/update", {
2304
761
  method: "POST",
2305
762
  body: JSON.stringify(source ? { source: await packageSource(source) } : {})
@@ -2321,16 +778,16 @@ async function main(args) {
2321
778
  projectCommand.action(() => {
2322
779
  throw new CliError(projectCommand.helpInformation(), 2);
2323
780
  });
2324
- projectCommand.command("add").description("Register a Git repository").argument("<path>", "repository path").option("--json", "emit machine-readable JSON").action(async (repository) => {
781
+ projectCommand.command("add").description("Register a folder or Git repository").argument("<path>", "folder path").option("--json", "emit machine-readable JSON").action(async (repository) => {
2325
782
  const body = await request("/api/projects", {
2326
783
  method: "POST",
2327
784
  body: JSON.stringify({ path: await canonical(repository) })
2328
785
  });
2329
- print(body.project, () => `Registered ${body.project.name} (${body.project.id})\n${body.project.repositoryPath}`);
786
+ print(body.project, () => `Registered ${body.project.name} (${body.project.id})\n${body.project.rootPath}`);
2330
787
  });
2331
788
  projectCommand.command("list").description("List registered projects").option("--json", "emit machine-readable JSON").action(async () => {
2332
789
  const list = await projects();
2333
- print(list, () => list.map((project) => `${project.id}\t${project.name}\t${project.repositoryPath}`).join("\n"));
790
+ print(list, () => list.map((project) => `${project.id}\t${project.name}\t${project.kind}\t${project.rootPath}`).join("\n"));
2334
791
  });
2335
792
  const worktreeCommand = program.command("worktree").description("List, create, and remove trees");
2336
793
  worktreeCommand.action(() => {
@@ -2340,9 +797,9 @@ async function main(args) {
2340
797
  worktreeListCommand.action(async () => {
2341
798
  const { project: projectIdentifier } = worktreeListCommand.opts();
2342
799
  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"));
800
+ print(list, () => list.map((worktree) => `${worktree.id}\t${worktree.name}\t${worktree.kind === "folder" ? "folder" : worktree.branch ?? `detached@${worktree.head.slice(0, 8)}`}\t${worktree.path}`).join("\n"));
2344
801
  });
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");
802
+ const worktreeCreateCommand = worktreeCommand.command("create").description("Create a linked tree").option("--project <id-or-path>", "project to create from (default: current folder)").requiredOption("--name <name>", "Tree name").option("--from-current", "base the tree on the current tree").option("--json", "emit machine-readable JSON");
2346
803
  worktreeCreateCommand.action(async () => {
2347
804
  const options = worktreeCreateCommand.opts();
2348
805
  const project = await resolveProject(options.project);
@@ -2467,7 +924,7 @@ async function main(args) {
2467
924
  terminalId
2468
925
  }, () => `Deleted ${terminalId}`);
2469
926
  });
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");
927
+ const spawnCommand = program.command("spawn").description("Create a tree and its first terminal").usage("[options] [-- <command> args...]").option("--project <id-or-path-or-dot>", "project to create from (default: current folder)").requiredOption("--worktree-name <name>", "Tree name").requiredOption("--name <terminal-name>", "terminal name").option("--from-current", "base the tree on the current tree").option("--json", "emit machine-readable JSON").addHelpText("after", "\nCommand arguments may be passed after --.\n");
2471
928
  spawnCommand.action(async () => {
2472
929
  const options = spawnCommand.opts();
2473
930
  const project = await resolveProject(options.project);
@@ -2495,6 +952,7 @@ async function main(args) {
2495
952
  }
2496
953
  async function runCliApplication(options) {
2497
954
  const environment = options.environment ?? process.env;
955
+ cliEnvironment = environment;
2498
956
  configuredApiUrl = environment.TREEPORT_API_URL?.trim();
2499
957
  apiUrl = (await resolveLocalApiUrl(environment)).replace(/\/$/, "");
2500
958
  contextProjectId = environment.TREEPORT_PROJECT_ID?.trim() || void 0;