@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.
@@ -0,0 +1,2716 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { spawn } from "node:child_process";
5
+ import crypto from "node:crypto";
6
+ import fsSync, { constants } from "node:fs";
7
+ import os from "node:os";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
+ //#region src/duration.ts
10
+ const DURATION_UNITS = /* @__PURE__ */ new Map([
11
+ ["ms", 1],
12
+ ["s", 1e3],
13
+ ["m", 6e4],
14
+ ["h", 36e5]
15
+ ]);
16
+ const MAX_DURATION_MS = 2147483647;
17
+ function parseDurationMs(value) {
18
+ const match = /^(\d+)(ms|s|m|h)$/.exec(value);
19
+ if (!match) throw new Error("Timeout must be a positive duration such as 500ms, 30s, 5m, or 1h");
20
+ const amount = Number(match[1]);
21
+ const multiplier = DURATION_UNITS.get(match[2] ?? "");
22
+ if (multiplier === void 0) throw new Error("Timeout has an unsupported duration unit");
23
+ const timeoutMs = amount * multiplier;
24
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_DURATION_MS) throw new Error("Timeout must be between 1ms and 2147483647ms");
25
+ return timeoutMs;
26
+ }
27
+ //#endregion
28
+ //#region src/server/core/loopback.ts
29
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
30
+ "127.0.0.1",
31
+ "::1",
32
+ "localhost"
33
+ ]);
34
+ function isLoopbackHost(host) {
35
+ return LOOPBACK_HOSTS.has(host.trim().toLowerCase());
36
+ }
37
+ function assertLoopbackHost(host) {
38
+ if (isLoopbackHost(host)) return;
39
+ throw new Error("Treeport supports only loopback listeners. Run `treeport start --host 127.0.0.1`, then use `treeport remote enable` for private remote access.");
40
+ }
41
+ //#endregion
42
+ //#region src/cli/lifecycle.ts
43
+ const DEFAULT_HOST = "127.0.0.1";
44
+ const DEFAULT_PORT = 8733;
45
+ function listenerUrl(host, port) {
46
+ return `http://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`;
47
+ }
48
+ function expandHome(value) {
49
+ return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
50
+ }
51
+ function localPaths(env = process.env) {
52
+ 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");
53
+ const dataDir = path.resolve(expandHome(env.TREEPORT_DATA_DIR?.trim() || defaultDataDir));
54
+ 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"}`))));
55
+ return {
56
+ dataDir,
57
+ runtimeDir,
58
+ preferencesPath: path.join(dataDir, "config.json"),
59
+ statePath: path.join(runtimeDir, "daemon.json"),
60
+ lockPath: path.join(dataDir, "daemon.lock"),
61
+ logPath: path.join(dataDir, "logs", "daemon.log")
62
+ };
63
+ }
64
+ async function readJson$1(filePath, schema) {
65
+ return fs.readFile(filePath, "utf8").then((value) => schema.parse(JSON.parse(value))).catch(() => null);
66
+ }
67
+ const preferencesSchema = z.looseObject({
68
+ host: z.string().optional(),
69
+ port: z.number().optional(),
70
+ remote: z.strictObject({
71
+ port: z.number(),
72
+ target: z.string()
73
+ }).optional()
74
+ });
75
+ const daemonRecordSchema = z.strictObject({
76
+ pid: z.number(),
77
+ instanceId: z.string(),
78
+ version: z.string(),
79
+ apiUrl: z.string(),
80
+ dataDir: z.string(),
81
+ startedAt: z.string(),
82
+ installationMethod: z.string(),
83
+ daemonLifecycle: z.enum([
84
+ "treeport",
85
+ "service",
86
+ "external"
87
+ ])
88
+ });
89
+ const healthRecordSchema = z.strictObject({
90
+ ok: z.literal(true),
91
+ version: z.string(),
92
+ protocolVersion: z.number(),
93
+ hostname: z.string().optional(),
94
+ pid: z.number(),
95
+ instanceId: z.string().nullable(),
96
+ installationMethod: z.string(),
97
+ daemonLifecycle: z.enum([
98
+ "treeport",
99
+ "service",
100
+ "external"
101
+ ]),
102
+ url: z.string()
103
+ });
104
+ async function preferences(env = process.env) {
105
+ return await readJson$1(localPaths(env).preferencesPath, preferencesSchema) ?? {};
106
+ }
107
+ async function savePreferences(value) {
108
+ const paths = localPaths();
109
+ await fs.mkdir(paths.dataDir, {
110
+ recursive: true,
111
+ mode: 448
112
+ });
113
+ const temporaryPath = `${paths.preferencesPath}.${process.pid}.tmp`;
114
+ await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
115
+ await fs.rename(temporaryPath, paths.preferencesPath);
116
+ }
117
+ async function resolveLocalApiUrl(env = process.env) {
118
+ const explicit = env.TREEPORT_API_URL?.trim();
119
+ const managedApiUrl = env.TREEPORT_MANAGED_API_URL?.trim();
120
+ const daemonRecordPath = env.TREEPORT_DAEMON_RECORD?.trim();
121
+ if (explicit && explicit !== managedApiUrl) return explicit.replace(/\/$/, "");
122
+ if (managedApiUrl && daemonRecordPath) {
123
+ const record = await readJson$1(path.resolve(expandHome(daemonRecordPath)), daemonRecordSchema);
124
+ if (record) return record.apiUrl.replace(/\/$/, "");
125
+ }
126
+ if (explicit) return explicit.replace(/\/$/, "");
127
+ const saved = await preferences(env);
128
+ 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));
129
+ }
130
+ async function resolvePackagePath(...segments) {
131
+ const candidates = [
132
+ fileURLToPath(new URL("../", import.meta.url)),
133
+ fileURLToPath(new URL("../../../", import.meta.url)),
134
+ fileURLToPath(new URL("../../", import.meta.url))
135
+ ];
136
+ for (const candidate of candidates) if (await fs.access(path.join(candidate, "package.json")).then(() => true).catch(() => false)) return path.join(candidate, ...segments);
137
+ throw new Error("Could not locate the Treeport package directory");
138
+ }
139
+ async function treeportVersion() {
140
+ return (await readJson$1(await resolvePackagePath("package.json"), z.looseObject({ version: z.string().optional() })))?.version ?? "development";
141
+ }
142
+ function processExists$1(pid) {
143
+ try {
144
+ process.kill(pid, 0);
145
+ return true;
146
+ } catch (error) {
147
+ return error.code === "EPERM";
148
+ }
149
+ }
150
+ async function daemonHealth(apiUrl, timeoutMs = 1500) {
151
+ const signal = AbortSignal.timeout(timeoutMs);
152
+ return fetch(`${apiUrl}/api/health`, { signal }).then(async (response) => {
153
+ if (!response.ok) return null;
154
+ const result = healthRecordSchema.safeParse(await response.json());
155
+ return result.success ? result.data : null;
156
+ }).catch(() => null);
157
+ }
158
+ function matchesOwnership(state, observed) {
159
+ return observed.pid === state.pid && observed.instanceId === state.instanceId && path.resolve(state.dataDir) === localPaths().dataDir;
160
+ }
161
+ async function readState() {
162
+ return readJson$1(localPaths().statePath, daemonRecordSchema);
163
+ }
164
+ async function removeStaleState(state) {
165
+ const paths = localPaths();
166
+ 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 });
167
+ }
168
+ async function stopOwned(state) {
169
+ if (!processExists$1(state.pid)) {
170
+ await removeStaleState(state);
171
+ return;
172
+ }
173
+ const observed = await daemonHealth(state.apiUrl);
174
+ if (!observed || !matchesOwnership(state, observed)) throw new Error(`Refusing to stop PID ${state.pid}: Treeport could not verify ownership. Check ${localPaths().statePath}.`);
175
+ process.kill(state.pid, "SIGTERM");
176
+ const deadline = Date.now() + 7e3;
177
+ while (Date.now() < deadline) {
178
+ if (!processExists$1(state.pid)) {
179
+ await removeStaleState(state);
180
+ return;
181
+ }
182
+ await new Promise((resolve) => setTimeout(resolve, 100));
183
+ }
184
+ throw new Error(`Treeport did not stop within 7 seconds. See ${localPaths().logPath}.`);
185
+ }
186
+ async function executableCheck(executable, args) {
187
+ return new Promise((resolve) => {
188
+ const child = spawn(executable, args, { stdio: [
189
+ "ignore",
190
+ "pipe",
191
+ "pipe"
192
+ ] });
193
+ let output = "";
194
+ child.stdout.setEncoding("utf8");
195
+ child.stderr.setEncoding("utf8");
196
+ child.stdout.on("data", (chunk) => {
197
+ output += chunk;
198
+ });
199
+ child.stderr.on("data", (chunk) => {
200
+ output += chunk;
201
+ });
202
+ child.once("error", (error) => resolve({
203
+ ok: false,
204
+ detail: error.message
205
+ }));
206
+ child.once("close", (code) => resolve({
207
+ ok: code === 0,
208
+ detail: output.trim() || `exited with status ${code ?? 1}`
209
+ }));
210
+ });
211
+ }
212
+ const tailscaleStatusResponseSchema = z.looseObject({
213
+ BackendState: z.string().optional(),
214
+ Self: z.looseObject({ DNSName: z.string().optional() }).optional()
215
+ });
216
+ const tailscaleServeConfigurationSchema = z.lazy(() => z.looseObject({
217
+ TCP: z.record(z.string(), z.looseObject({})).optional(),
218
+ Foreground: z.record(z.string(), tailscaleServeConfigurationSchema).optional(),
219
+ Web: z.record(z.string(), z.looseObject({ Handlers: z.record(z.string(), z.looseObject({ Proxy: z.string().optional() })).optional() })).optional()
220
+ }));
221
+ async function tailscale(args) {
222
+ return new Promise((resolve, reject) => {
223
+ const child = spawn("tailscale", args, { stdio: [
224
+ "ignore",
225
+ "pipe",
226
+ "pipe"
227
+ ] });
228
+ let stdout = "";
229
+ let stderr = "";
230
+ child.stdout.setEncoding("utf8");
231
+ child.stderr.setEncoding("utf8");
232
+ child.stdout.on("data", (chunk) => {
233
+ stdout += chunk;
234
+ });
235
+ child.stderr.on("data", (chunk) => {
236
+ stderr += chunk;
237
+ });
238
+ 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}`)));
239
+ child.once("close", (code) => {
240
+ if (code === 0) {
241
+ resolve(stdout);
242
+ return;
243
+ }
244
+ const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
245
+ reject(/* @__PURE__ */ new Error(`Tailscale ${args[0]} failed${detail ? `: ${detail}` : ` (status ${code ?? 1})`}`));
246
+ });
247
+ });
248
+ }
249
+ function tailscaleJson(value, command, schema) {
250
+ const result = schema.safeParse(JSON.parse(value));
251
+ if (!result.success) throw new Error(`Tailscale ${command} returned an invalid JSON response`);
252
+ return result.data;
253
+ }
254
+ function remotePreference(value) {
255
+ if (value.remote === void 0) return null;
256
+ 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");
257
+ return value.remote;
258
+ }
259
+ function localProxyTarget(apiUrl) {
260
+ if (!URL.canParse(apiUrl)) throw new Error("Treeport remote access requires a loopback daemon URL");
261
+ const url = new URL(apiUrl);
262
+ if (url.protocol !== "http:" || ![
263
+ "127.0.0.1",
264
+ "localhost",
265
+ "::1",
266
+ "[::1]"
267
+ ].includes(url.hostname)) throw new Error("Treeport remote access requires a loopback daemon. Run `treeport start --host 127.0.0.1`, then try again.");
268
+ return `http://${url.host}`;
269
+ }
270
+ function portIsServed(config, port) {
271
+ const tcp = config.TCP;
272
+ if (tcp && Object.hasOwn(tcp, String(port))) return true;
273
+ return Object.values(config.Foreground ?? {}).some((value) => portIsServed(value, port));
274
+ }
275
+ function rootProxyForPort(config, port) {
276
+ for (const [hostPort, server] of Object.entries(config.Web ?? {})) {
277
+ if (!hostPort.endsWith(`:${port}`)) continue;
278
+ const proxy = server.Handlers?.["/"]?.Proxy;
279
+ if (proxy !== void 0) return proxy;
280
+ }
281
+ return null;
282
+ }
283
+ function proxyMatches(actual, expected) {
284
+ return actual !== null && expected !== void 0 && actual.replace(/\/$/, "") === expected.replace(/\/$/, "");
285
+ }
286
+ async function tailscaleServeConfig() {
287
+ return tailscaleJson(await tailscale([
288
+ "serve",
289
+ "status",
290
+ "--json"
291
+ ]), "serve status", tailscaleServeConfigurationSchema);
292
+ }
293
+ async function tailscaleRemoteUrl(port) {
294
+ const status = tailscaleJson(await tailscale(["status", "--json"]), "status", tailscaleStatusResponseSchema);
295
+ if (status.BackendState !== "Running") throw new Error("Tailscale is not connected. Run `tailscale up` then try again.");
296
+ const dnsName = status.Self?.DNSName;
297
+ if (!dnsName?.trim()) throw new Error("Tailscale did not report a DNS name. Enable MagicDNS, then try again.");
298
+ return `https://${dnsName.trim().replace(/\.$/, "")}${port === 443 ? "" : `:${port}`}`;
299
+ }
300
+ async function enableTailscaleRemote(options) {
301
+ 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");
302
+ const saved = await preferences();
303
+ const remote = remotePreference(saved);
304
+ const port = options.port ?? remote?.port ?? DEFAULT_PORT;
305
+ 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.`);
306
+ const expectedTarget = localProxyTarget((await daemonStatus()).state?.apiUrl ?? await resolveLocalApiUrl());
307
+ const [url, config] = await Promise.all([tailscaleRemoteUrl(port), tailscaleServeConfig()]);
308
+ const existingTarget = rootProxyForPort(config, port);
309
+ 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>\`.`);
310
+ const target = localProxyTarget((options.daemon ?? await daemonUp({})).apiUrl);
311
+ const alreadyEnabled = proxyMatches(existingTarget, target);
312
+ if (!alreadyEnabled) await tailscale([
313
+ "serve",
314
+ "--bg",
315
+ `--https=${port}`,
316
+ target
317
+ ]);
318
+ await savePreferences({
319
+ ...saved,
320
+ remote: {
321
+ port,
322
+ target
323
+ }
324
+ });
325
+ return {
326
+ alreadyEnabled,
327
+ port,
328
+ url
329
+ };
330
+ }
331
+ async function tailscaleRemoteStatus() {
332
+ const remote = remotePreference(await preferences());
333
+ if (!remote) return {
334
+ configured: false,
335
+ active: false,
336
+ port: null,
337
+ url: null
338
+ };
339
+ const [url, config] = await Promise.all([tailscaleRemoteUrl(remote.port), tailscaleServeConfig()]);
340
+ return {
341
+ configured: true,
342
+ active: proxyMatches(rootProxyForPort(config, remote.port), remote.target),
343
+ port: remote.port,
344
+ url
345
+ };
346
+ }
347
+ async function disableTailscaleRemote() {
348
+ const saved = await preferences();
349
+ const remote = remotePreference(saved);
350
+ if (!remote) return {
351
+ wasEnabled: false,
352
+ changedTailscale: false
353
+ };
354
+ if (proxyMatches(rootProxyForPort(await tailscaleServeConfig(), remote.port), remote.target)) {
355
+ await tailscale([
356
+ "serve",
357
+ `--https=${remote.port}`,
358
+ "off"
359
+ ]);
360
+ delete saved.remote;
361
+ await savePreferences(saved);
362
+ return {
363
+ wasEnabled: true,
364
+ changedTailscale: true
365
+ };
366
+ }
367
+ delete saved.remote;
368
+ await savePreferences(saved);
369
+ return {
370
+ wasEnabled: false,
371
+ changedTailscale: false
372
+ };
373
+ }
374
+ async function runDoctor() {
375
+ const paths = localPaths();
376
+ const gitPath = process.env.TREEPORT_GIT_PATH?.trim() || "git";
377
+ const tmuxPath = process.env.TREEPORT_TMUX_PATH?.trim() || "tmux";
378
+ const [git, tmux] = await Promise.all([executableCheck(gitPath, ["--version"]), executableCheck(tmuxPath, ["-V"])]);
379
+ const tmuxMatch = /tmux\s+(\d+)\.(\d+)/i.exec(tmux.detail);
380
+ const tmuxSupported = Boolean(tmux.ok && tmuxMatch && (Number(tmuxMatch[1]) > 3 || Number(tmuxMatch[1]) === 3 && Number(tmuxMatch[2]) >= 2));
381
+ const checkDirectory = (directoryPath) => fs.mkdir(directoryPath, {
382
+ recursive: true,
383
+ mode: 448
384
+ }).then(() => ({
385
+ ok: true,
386
+ detail: directoryPath
387
+ })).catch((error) => ({
388
+ ok: false,
389
+ detail: `${directoryPath}: ${error instanceof Error ? error.message : String(error)}`
390
+ }));
391
+ const [dataDirectory, runtimeDirectory] = await Promise.all([checkDirectory(paths.dataDir), checkDirectory(paths.runtimeDir)]);
392
+ return [
393
+ {
394
+ name: "Node",
395
+ ok: true,
396
+ detail: process.version
397
+ },
398
+ {
399
+ name: "Git",
400
+ ...git
401
+ },
402
+ {
403
+ name: "tmux",
404
+ ok: tmuxSupported,
405
+ detail: tmuxSupported ? tmux.detail : `${tmux.detail}. Treeport requires tmux 3.2 or newer.`
406
+ },
407
+ {
408
+ name: "Data directory",
409
+ ...dataDirectory
410
+ },
411
+ {
412
+ name: "Runtime directory",
413
+ ...runtimeDirectory
414
+ }
415
+ ];
416
+ }
417
+ async function daemonStatus() {
418
+ const state = await readState();
419
+ if (!state) return {
420
+ running: false,
421
+ state: null,
422
+ health: null,
423
+ verified: false
424
+ };
425
+ if (!processExists$1(state.pid)) {
426
+ await removeStaleState(state);
427
+ return {
428
+ running: false,
429
+ state: null,
430
+ health: null,
431
+ verified: false
432
+ };
433
+ }
434
+ const observed = await daemonHealth(state.apiUrl);
435
+ return {
436
+ running: Boolean(observed),
437
+ state,
438
+ health: observed,
439
+ verified: Boolean(observed && matchesOwnership(state, observed))
440
+ };
441
+ }
442
+ async function daemonUp(options) {
443
+ 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");
444
+ const paths = localPaths();
445
+ const saved = await preferences();
446
+ const next = {
447
+ ...saved,
448
+ host: options.host?.trim() || saved.host || DEFAULT_HOST,
449
+ port: options.port ?? saved.port ?? DEFAULT_PORT
450
+ };
451
+ const host = options.host?.trim() || process.env.TREEPORT_HOST?.trim() || process.env.HOST?.trim() || next.host;
452
+ assertLoopbackHost(host);
453
+ if (options.host !== void 0 || options.port !== void 0) await savePreferences(next);
454
+ const port = Number.parseInt(options.port === void 0 ? process.env.TREEPORT_PORT?.trim() || process.env.PORT?.trim() || String(next.port) : String(options.port), 10);
455
+ const apiUrl = options.host !== void 0 || options.port !== void 0 ? listenerUrl(host, port) : process.env.TREEPORT_API_URL?.trim() || listenerUrl(host, port);
456
+ const currentVersion = await treeportVersion();
457
+ const existing = await daemonStatus();
458
+ if (existing.state) {
459
+ 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}.`);
460
+ if (existing.health?.version === currentVersion && existing.state.apiUrl === apiUrl) return {
461
+ alreadyRunning: true,
462
+ apiUrl: existing.state.apiUrl,
463
+ pid: existing.state.pid
464
+ };
465
+ await stopOwned(existing.state);
466
+ }
467
+ const failedChecks = (await runDoctor()).filter((check) => !check.ok);
468
+ if (failedChecks.length) throw new Error(failedChecks.map((check) => `${check.name}: ${check.detail}`).join("\n"));
469
+ const serverEntry = await resolvePackagePath("dist", "node", "server", "index.js");
470
+ const webDist = await resolvePackagePath("dist", "web");
471
+ await fs.access(serverEntry);
472
+ await fs.mkdir(path.dirname(paths.logPath), {
473
+ recursive: true,
474
+ mode: 448
475
+ });
476
+ if (await fs.stat(paths.logPath).then((value) => value.size).catch(() => 0) > 5 * 1024 * 1024) {
477
+ await fs.rm(`${paths.logPath}.1`, { force: true });
478
+ await fs.rename(paths.logPath, `${paths.logPath}.1`);
479
+ }
480
+ const instanceId = crypto.randomUUID();
481
+ const childEnvironment = {
482
+ ...process.env,
483
+ TREEPORT_HOST: host,
484
+ TREEPORT_PORT: String(port),
485
+ TREEPORT_API_URL: apiUrl,
486
+ TREEPORT_DATA_DIR: paths.dataDir,
487
+ TREEPORT_RUNTIME_DIR: paths.runtimeDir,
488
+ TREEPORT_APP_VERSION: currentVersion,
489
+ TREEPORT_INSTANCE_ID: instanceId,
490
+ TREEPORT_INSTALLATION_METHOD: process.env.TREEPORT_INSTALLATION_METHOD?.trim() || "npm",
491
+ TREEPORT_DAEMON_LIFECYCLE: "treeport",
492
+ TREEPORT_WEB_DIST: webDist
493
+ };
494
+ if (options.foreground) {
495
+ console.log(`Treeport will listen on ${apiUrl}`);
496
+ const child = spawn(process.execPath, [serverEntry], {
497
+ env: childEnvironment,
498
+ stdio: "inherit"
499
+ });
500
+ const code = await new Promise((resolve, reject) => {
501
+ child.once("error", reject);
502
+ child.once("close", (value) => resolve(value ?? 1));
503
+ });
504
+ if (code !== 0) throw new Error(`Treeport exited with status ${code}`);
505
+ return {
506
+ alreadyRunning: false,
507
+ apiUrl,
508
+ pid: child.pid ?? 0
509
+ };
510
+ }
511
+ const log = fsSync.openSync(paths.logPath, "a", 384);
512
+ const child = spawn(process.execPath, [serverEntry], {
513
+ env: childEnvironment,
514
+ detached: true,
515
+ stdio: [
516
+ "ignore",
517
+ log,
518
+ log
519
+ ]
520
+ });
521
+ child.unref();
522
+ fsSync.closeSync(log);
523
+ const deadline = Date.now() + 15e3;
524
+ while (Date.now() < deadline) {
525
+ const observed = await daemonHealth(apiUrl, 500);
526
+ if (observed && observed.pid === child.pid && observed.instanceId === instanceId && observed.version === currentVersion) return {
527
+ alreadyRunning: false,
528
+ apiUrl,
529
+ pid: child.pid ?? observed.pid
530
+ };
531
+ if (child.pid && !processExists$1(child.pid)) break;
532
+ await new Promise((resolve) => setTimeout(resolve, 100));
533
+ }
534
+ const recentLog = await fs.readFile(paths.logPath, "utf8").then((value) => value.split("\n").slice(-20).join("\n").trim()).catch(() => "");
535
+ throw new Error(`Treeport did not become ready at ${apiUrl}. See ${paths.logPath}.${recentLog ? `\n\n${recentLog}` : ""}`);
536
+ }
537
+ async function daemonDown() {
538
+ const state = await readState();
539
+ if (!state) return { wasRunning: false };
540
+ await stopOwned(state);
541
+ return { wasRunning: true };
542
+ }
543
+ async function readDaemonLogs(lines = 100) {
544
+ return (await fs.readFile(localPaths().logPath, "utf8").catch((error) => {
545
+ if (error.code === "ENOENT") return "";
546
+ throw error;
547
+ })).split("\n").slice(-lines - 1).join("\n");
548
+ }
549
+ //#endregion
550
+ //#region src/cli/service.ts
551
+ const serviceRecordSchema = z.strictObject({
552
+ schemaVersion: z.literal(1),
553
+ manager: z.enum(["launchd", "systemd"]),
554
+ mode: z.enum(["user", "headless"]).optional(),
555
+ platform: z.string(),
556
+ uid: z.number().int().nonnegative(),
557
+ gid: z.number().int().nonnegative(),
558
+ username: z.string().min(1),
559
+ group: z.string().min(1),
560
+ home: z.string().min(1),
561
+ dataDir: z.string().min(1),
562
+ runtimeDir: z.string().min(1),
563
+ logPath: z.string().min(1),
564
+ apiUrl: z.string().min(1),
565
+ cliEntrypoint: z.string().min(1),
566
+ runtimeExecutable: z.string().min(1).nullable().default(null),
567
+ runtimeEntrypoint: z.string().min(1).nullable().default(null),
568
+ installationMethod: z.string().min(1),
569
+ definitionName: z.string().min(1),
570
+ definitionPath: z.string().min(1),
571
+ definitionHash: z.string().length(64),
572
+ environmentHash: z.string().length(64),
573
+ environment: z.record(z.string(), z.string()),
574
+ requestedState: z.enum(["running", "stopped"]),
575
+ pendingAdministratorRequestId: z.string().nullable(),
576
+ createdAt: z.string(),
577
+ updatedAt: z.string()
578
+ });
579
+ const administratorRequestSchema = z.strictObject({
580
+ schemaVersion: z.literal(1),
581
+ id: z.string().uuid(),
582
+ operation: z.enum([
583
+ "enable",
584
+ "start",
585
+ "stop",
586
+ "disable"
587
+ ]),
588
+ createdAt: z.string(),
589
+ expiresAt: z.string(),
590
+ uid: z.number().int().nonnegative(),
591
+ gid: z.number().int().nonnegative(),
592
+ username: z.string().min(1),
593
+ group: z.string().min(1),
594
+ home: z.string().min(1),
595
+ serviceRecordPath: z.string().min(1),
596
+ runnerPath: z.string().min(1),
597
+ definitionName: z.string().min(1),
598
+ definitionPath: z.string().min(1),
599
+ stagedDefinitionPath: z.string().min(1),
600
+ definitionHash: z.string().length(64),
601
+ apiUrl: z.string().min(1),
602
+ cliEntrypoint: z.string().min(1),
603
+ runtimeExecutable: z.string().min(1),
604
+ runtimeEntrypoint: z.string().min(1)
605
+ });
606
+ function managerForPlatform(platform = process.platform) {
607
+ return platform === "darwin" ? "launchd" : platform === "linux" ? "systemd" : null;
608
+ }
609
+ function servicePaths(env = process.env) {
610
+ const paths = localPaths(env);
611
+ const directory = path.join(paths.dataDir, "service");
612
+ return {
613
+ directory,
614
+ recordPath: path.join(directory, "service.json"),
615
+ runnerPath: path.join(directory, "run"),
616
+ requestsDirectory: path.join(directory, "requests"),
617
+ stagedDefinitionPath: path.join(directory, "treeport.plist")
618
+ };
619
+ }
620
+ function launchdLocation(input) {
621
+ const name = `app.treeport.daemon.${input.uid}`;
622
+ const domain = input.mode === "headless" ? "system" : `gui/${input.uid}`;
623
+ return {
624
+ name,
625
+ path: input.mode === "headless" ? `/Library/LaunchDaemons/${name}.plist` : path.join(input.home, "Library", "LaunchAgents", `${name}.plist`),
626
+ domain,
627
+ target: `${domain}/${name}`
628
+ };
629
+ }
630
+ function userLaunchdCommands(input) {
631
+ if (input.operation === "enable") return {
632
+ bootout: ["bootout", input.location.target],
633
+ enable: ["enable", input.location.target],
634
+ activate: [
635
+ "bootstrap",
636
+ input.location.domain,
637
+ input.definitionPath
638
+ ]
639
+ };
640
+ if (input.operation === "start") return {
641
+ bootout: null,
642
+ enable: ["enable", input.location.target],
643
+ activate: input.active ? [
644
+ "kickstart",
645
+ "-k",
646
+ input.location.target
647
+ ] : [
648
+ "bootstrap",
649
+ input.location.domain,
650
+ input.definitionPath
651
+ ]
652
+ };
653
+ return {
654
+ bootout: ["bootout", input.location.target],
655
+ enable: null,
656
+ activate: null
657
+ };
658
+ }
659
+ async function readJson(filePath, schema) {
660
+ return fs.readFile(filePath, "utf8").then((value) => schema.parse(JSON.parse(value))).catch(() => null);
661
+ }
662
+ async function writeJson$2(filePath, value) {
663
+ await fs.mkdir(path.dirname(filePath), {
664
+ recursive: true,
665
+ mode: 448
666
+ });
667
+ const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
668
+ await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
669
+ await fs.rename(temporaryPath, filePath);
670
+ }
671
+ function fingerprint(value) {
672
+ const parsed = z.string().safeParse(value);
673
+ const source = parsed.success ? parsed.data : JSON.stringify(Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))));
674
+ return crypto.createHash("sha256").update(source).digest("hex");
675
+ }
676
+ function xml(value) {
677
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
678
+ }
679
+ function shellQuote$1(value) {
680
+ return `'${value.replaceAll("'", `'\\''`)}'`;
681
+ }
682
+ function createAdministratorCommand(input) {
683
+ return `sudo ${shellQuote$1(input.runtimeExecutable)} ${shellQuote$1(input.runtimeEntrypoint)} service apply --request ${shellQuote$1(input.requestPath)}`;
684
+ }
685
+ function systemdValue(value) {
686
+ return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("%", "%%").replaceAll("\n", "\\n");
687
+ }
688
+ function createLaunchdDefinition(input) {
689
+ return {
690
+ label: input.label,
691
+ mode: input.mode,
692
+ programArguments: [input.runnerPath],
693
+ username: input.mode === "headless" ? input.username : null,
694
+ group: input.mode === "headless" ? input.group : null,
695
+ environment: input.environment,
696
+ workingDirectory: input.home,
697
+ standardOutPath: input.logPath,
698
+ standardErrorPath: input.logPath,
699
+ keepAlive: true,
700
+ processType: "Background",
701
+ throttleInterval: 10,
702
+ exitTimeOut: 10,
703
+ abandonProcessGroup: true,
704
+ umask: 63
705
+ };
706
+ }
707
+ function serializeLaunchdDefinition(definition) {
708
+ 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");
709
+ const argumentsXml = definition.programArguments.map((argument) => ` <string>${xml(argument)}</string>`).join("\n");
710
+ const account = definition.username && definition.group ? ` <key>UserName</key>\n <string>${xml(definition.username)}</string>\n <key>GroupName</key>\n <string>${xml(definition.group)}</string>\n` : "";
711
+ return `<?xml version="1.0" encoding="UTF-8"?>
712
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
713
+ <plist version="1.0">
714
+ <dict>
715
+ <key>Label</key>
716
+ <string>${xml(definition.label)}</string>
717
+ <key>ProgramArguments</key>
718
+ <array>
719
+ ${argumentsXml}
720
+ </array>
721
+ ${account} <key>EnvironmentVariables</key>
722
+ <dict>
723
+ ${environment}
724
+ </dict>
725
+ <key>WorkingDirectory</key>
726
+ <string>${xml(definition.workingDirectory)}</string>
727
+ <key>StandardOutPath</key>
728
+ <string>${xml(definition.standardOutPath)}</string>
729
+ <key>StandardErrorPath</key>
730
+ <string>${xml(definition.standardErrorPath)}</string>
731
+ <key>KeepAlive</key>
732
+ <true/>
733
+ <key>ProcessType</key>
734
+ <string>${definition.processType}</string>
735
+ <key>ThrottleInterval</key>
736
+ <integer>${definition.throttleInterval}</integer>
737
+ <key>ExitTimeOut</key>
738
+ <integer>${definition.exitTimeOut}</integer>
739
+ <key>AbandonProcessGroup</key>
740
+ <true/>
741
+ <key>Umask</key>
742
+ <integer>${definition.umask}</integer>
743
+ </dict>
744
+ </plist>
745
+ `;
746
+ }
747
+ function createSystemdDefinition(input) {
748
+ return {
749
+ description: "Treeport daemon",
750
+ execStart: input.runnerPath,
751
+ environment: input.environment,
752
+ restart: "always",
753
+ restartSeconds: 5,
754
+ timeoutStopSeconds: 10,
755
+ killMode: "process",
756
+ wantedBy: "default.target"
757
+ };
758
+ }
759
+ function serializeSystemdDefinition(definition) {
760
+ const environment = Object.entries(definition.environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, value]) => `Environment="${systemdValue(name)}=${systemdValue(value)}"`).join("\n");
761
+ return `[Unit]
762
+ Description=${definition.description}
763
+
764
+ [Service]
765
+ Type=simple
766
+ ExecStart="${systemdValue(definition.execStart)}"
767
+ ${environment}
768
+ Restart=${definition.restart}
769
+ RestartSec=${definition.restartSeconds}
770
+ TimeoutStopSec=${definition.timeoutStopSeconds}
771
+ KillMode=${definition.killMode}
772
+
773
+ [Install]
774
+ WantedBy=${definition.wantedBy}
775
+ `;
776
+ }
777
+ async function runCommand$1(executable, args, environment = process.env) {
778
+ return new Promise((resolve) => {
779
+ const child = spawn(executable, args, {
780
+ env: environment,
781
+ stdio: [
782
+ "ignore",
783
+ "pipe",
784
+ "pipe"
785
+ ]
786
+ });
787
+ let stdout = "";
788
+ let stderr = "";
789
+ child.stdout.setEncoding("utf8");
790
+ child.stderr.setEncoding("utf8");
791
+ child.stdout.on("data", (value) => {
792
+ stdout += value;
793
+ });
794
+ child.stderr.on("data", (value) => {
795
+ stderr += value;
796
+ });
797
+ child.once("error", (error) => {
798
+ resolve({
799
+ code: 127,
800
+ stdout,
801
+ stderr: error.message
802
+ });
803
+ });
804
+ child.once("close", (code) => {
805
+ resolve({
806
+ code: code ?? 1,
807
+ stdout,
808
+ stderr
809
+ });
810
+ });
811
+ });
812
+ }
813
+ function commandError(command, result) {
814
+ const detail = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n");
815
+ return /* @__PURE__ */ new Error(`${command} failed${detail ? `: ${detail}` : ` with status ${result.code}`}`);
816
+ }
817
+ async function executablePath(name) {
818
+ const candidates = name === "launchctl" ? ["/bin/launchctl", "/usr/bin/launchctl"] : [`/usr/bin/${name}`, `/bin/${name}`];
819
+ for (const candidate of candidates) if (await fs.access(candidate, constants.X_OK).then(() => true).catch(() => false)) return candidate;
820
+ return name;
821
+ }
822
+ async function primaryGroup(username) {
823
+ const result = await runCommand$1(await executablePath("id"), ["-gn", username]);
824
+ if (result.code !== 0 || !result.stdout.trim()) throw commandError("id -gn", result);
825
+ return result.stdout.trim();
826
+ }
827
+ function currentEntrypoint() {
828
+ const value = process.env.TREEPORT_CLI_ENTRYPOINT?.trim() || process.argv[1]?.trim();
829
+ return value ? path.resolve(value) : null;
830
+ }
831
+ async function ensureEntrypoint() {
832
+ const entrypoint = currentEntrypoint();
833
+ if (!entrypoint) throw new Error("Treeport could not identify a stable CLI entrypoint. Install Treeport with npm, then retry.");
834
+ await fs.access(entrypoint, constants.X_OK).catch(() => {
835
+ throw new Error(`Treeport cannot execute its stable CLI entrypoint at ${entrypoint}. Reinstall Treeport, then retry.`);
836
+ });
837
+ const [actual, expected] = await Promise.all([fs.realpath(entrypoint), fs.realpath(await resolvePackagePath("bin", "treeport.mjs"))]);
838
+ if (actual !== expected) {
839
+ const prefix = path.dirname(path.dirname(entrypoint));
840
+ const managedEntrypoint = path.join(prefix, "lib", "treeport", "current", "lib", "node_modules", "@treeport", "treeport", "bin", "treeport.mjs");
841
+ const [source, managed] = await Promise.all([fs.readFile(entrypoint, "utf8").catch(() => ""), fs.realpath(managedEntrypoint).catch(() => null)]);
842
+ if (!source.includes("TREEPORT_MANAGED_LAUNCHER=1") || managed !== expected) throw new Error(`The current CLI entrypoint is not the installed Treeport npm bin: ${entrypoint}`);
843
+ }
844
+ return entrypoint;
845
+ }
846
+ async function currentAdministratorRuntime() {
847
+ const invokedEntrypoint = process.argv[1]?.trim();
848
+ if (!invokedEntrypoint) throw new Error("Treeport could not identify its Node entrypoint.");
849
+ const runtimeEntrypoint = path.resolve(invokedEntrypoint);
850
+ const [runtimeExecutable, actualEntrypoint, packageBinEntrypoint, packageCliEntrypoint] = await Promise.all([
851
+ fs.realpath(process.execPath),
852
+ fs.realpath(runtimeEntrypoint),
853
+ fs.realpath(await resolvePackagePath("bin", "treeport.mjs")),
854
+ fs.realpath(await resolvePackagePath("dist", "node", "cli", "index.js"))
855
+ ]);
856
+ if (actualEntrypoint !== packageBinEntrypoint && actualEntrypoint !== packageCliEntrypoint) throw new Error(`Treeport cannot use an unrecognized package entrypoint for administrator commands: ${runtimeEntrypoint}`);
857
+ await Promise.all([fs.access(runtimeExecutable, constants.X_OK), fs.access(runtimeEntrypoint, constants.R_OK)]);
858
+ return {
859
+ runtimeExecutable,
860
+ runtimeEntrypoint
861
+ };
862
+ }
863
+ function cacheDirectory(home, env) {
864
+ const configured = env.TREEPORT_CACHE_DIR?.trim();
865
+ if (configured) return path.resolve(configured.replace(/^~(?=\/|$)/, home));
866
+ if (env.XDG_CACHE_HOME?.trim()) return path.join(path.resolve(env.XDG_CACHE_HOME.replace(/^~(?=\/|$)/, home)), "treeport");
867
+ return process.platform === "darwin" ? path.join(home, "Library", "Caches", "treeport") : path.join(home, ".cache", "treeport");
868
+ }
869
+ function createServiceEnvironment(input) {
870
+ const env = input.env ?? process.env;
871
+ const url = new URL(input.apiUrl);
872
+ assertLoopbackHost(url.hostname);
873
+ const result = {
874
+ HOME: input.user.homedir,
875
+ USER: input.user.username,
876
+ LOGNAME: input.user.username,
877
+ PATH: env.PATH?.trim() || "/usr/local/bin:/usr/bin:/bin",
878
+ TREEPORT_HOST: url.hostname,
879
+ TREEPORT_PORT: url.port || "80",
880
+ TREEPORT_API_URL: input.apiUrl,
881
+ TREEPORT_DATA_DIR: input.paths.dataDir,
882
+ TREEPORT_RUNTIME_DIR: input.paths.runtimeDir,
883
+ TREEPORT_CACHE_DIR: cacheDirectory(input.user.homedir, env),
884
+ TREEPORT_DATABASE_PATH: env.TREEPORT_DATABASE_PATH?.trim() || path.join(input.paths.dataDir, "treeport.db"),
885
+ TREEPORT_SHELL: env.TREEPORT_SHELL?.trim() || env.SHELL?.trim() || "/bin/sh",
886
+ TREEPORT_TMUX_PATH: env.TREEPORT_TMUX_PATH?.trim() || "tmux",
887
+ TREEPORT_GIT_PATH: env.TREEPORT_GIT_PATH?.trim() || "git",
888
+ TREEPORT_GH_PATH: env.TREEPORT_GH_PATH?.trim() || "gh",
889
+ TREEPORT_DAEMON_LIFECYCLE: "service",
890
+ TREEPORT_INSTALLATION_METHOD: input.installationMethod,
891
+ TREEPORT_SERVICE_RECORD: input.recordPath
892
+ };
893
+ for (const [name, value] of Object.entries(env)) if (value !== void 0 && (name === "LANG" || name === "LC_ALL" || name.startsWith("LC_"))) result[name] = value;
894
+ return result;
895
+ }
896
+ function definitionForRecord(record) {
897
+ if (record.manager === "launchd") return serializeLaunchdDefinition(createLaunchdDefinition({
898
+ label: record.definitionName,
899
+ mode: record.mode,
900
+ runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath,
901
+ username: record.username,
902
+ group: record.group,
903
+ environment: record.environment,
904
+ home: record.home,
905
+ logPath: record.logPath
906
+ }));
907
+ return serializeSystemdDefinition(createSystemdDefinition({
908
+ runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath,
909
+ environment: record.environment
910
+ }));
911
+ }
912
+ function runnerSource(record) {
913
+ return `#!/bin/sh
914
+ set -u
915
+ entrypoint=${shellQuote$1(record.cliEntrypoint)}
916
+ record=${shellQuote$1(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).recordPath)}
917
+ log=${shellQuote$1(record.logPath)}
918
+ reported=0
919
+ while [ ! -x "$entrypoint" ]; do
920
+ if [ "$reported" -eq 0 ]; then
921
+ mkdir -p "$(dirname "$log")"
922
+ 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"
923
+ reported=1
924
+ fi
925
+ sleep 60
926
+ done
927
+ export TREEPORT_SERVICE_RECORD="$record"
928
+ exec "$entrypoint" service run
929
+ `;
930
+ }
931
+ function storedServiceMode(input) {
932
+ return input.mode ?? (input.manager === "launchd" ? "headless" : "user");
933
+ }
934
+ async function readServiceRecord(recordPath) {
935
+ const record = await readJson(recordPath, serviceRecordSchema);
936
+ if (!record) return null;
937
+ return {
938
+ ...record,
939
+ mode: storedServiceMode(record)
940
+ };
941
+ }
942
+ async function currentRecord() {
943
+ return readServiceRecord(servicePaths().recordPath);
944
+ }
945
+ async function saveRecord(record) {
946
+ await writeJson$2(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).recordPath, record);
947
+ }
948
+ async function managerState(record) {
949
+ if (record.manager === "launchd") {
950
+ const launchctl = await executablePath("launchctl");
951
+ const location = launchdLocation({
952
+ uid: record.uid,
953
+ home: record.home,
954
+ mode: record.mode
955
+ });
956
+ const [active, disabled, definitionExists] = await Promise.all([
957
+ runCommand$1(launchctl, ["print", location.target]),
958
+ runCommand$1(launchctl, ["print-disabled", location.domain]),
959
+ fs.access(record.definitionPath).then(() => true).catch(() => false)
960
+ ]);
961
+ return {
962
+ active: active.code === 0,
963
+ enabled: definitionExists && !disabled.stdout.includes(`"${record.definitionName}" => true`),
964
+ lingering: true,
965
+ managerIssue: null
966
+ };
967
+ }
968
+ const systemctl = await executablePath("systemctl");
969
+ const [active, enabled, linger] = await Promise.all([
970
+ runCommand$1(systemctl, [
971
+ "--user",
972
+ "is-active",
973
+ record.definitionName
974
+ ]),
975
+ runCommand$1(systemctl, [
976
+ "--user",
977
+ "is-enabled",
978
+ record.definitionName
979
+ ]),
980
+ runCommand$1(await executablePath("loginctl"), [
981
+ "show-user",
982
+ record.username,
983
+ "-p",
984
+ "Linger",
985
+ "--value"
986
+ ])
987
+ ]);
988
+ return {
989
+ active: active.code === 0 && active.stdout.trim() === "active",
990
+ enabled: enabled.code === 0 && enabled.stdout.trim() === "enabled",
991
+ lingering: linger.code === 0 && linger.stdout.trim() === "yes",
992
+ 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
993
+ };
994
+ }
995
+ function administratorCommand(record) {
996
+ const requestId = record.pendingAdministratorRequestId;
997
+ if (!requestId || !record.runtimeExecutable || !record.runtimeEntrypoint) return null;
998
+ const requestPath = path.join(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).requestsDirectory, `${requestId}.json`);
999
+ return createAdministratorCommand({
1000
+ runtimeExecutable: record.runtimeExecutable,
1001
+ runtimeEntrypoint: record.runtimeEntrypoint,
1002
+ requestPath
1003
+ });
1004
+ }
1005
+ async function untrackedDefinition() {
1006
+ const manager = managerForPlatform();
1007
+ if (!manager) return null;
1008
+ const user = os.userInfo();
1009
+ if (manager === "launchd") {
1010
+ for (const mode of ["headless", "user"]) {
1011
+ const location = launchdLocation({
1012
+ uid: user.uid,
1013
+ home: user.homedir,
1014
+ mode
1015
+ });
1016
+ if (await fs.access(location.path).then(() => true).catch(() => false)) return {
1017
+ manager,
1018
+ mode,
1019
+ ...location
1020
+ };
1021
+ }
1022
+ return null;
1023
+ }
1024
+ const name = "treeport.service";
1025
+ const definitionPath = path.join(process.env.XDG_CONFIG_HOME?.trim() || path.join(user.homedir, ".config"), "systemd", "user", name);
1026
+ return await fs.access(definitionPath).then(() => true).catch(() => false) ? {
1027
+ manager,
1028
+ mode: "user",
1029
+ name,
1030
+ path: definitionPath,
1031
+ target: name
1032
+ } : null;
1033
+ }
1034
+ async function serviceInstalled() {
1035
+ return await currentRecord() !== null || await untrackedDefinition() !== null;
1036
+ }
1037
+ async function serviceStatus() {
1038
+ const manager = managerForPlatform();
1039
+ const record = await currentRecord();
1040
+ if (!manager) return {
1041
+ supported: false,
1042
+ manager: null,
1043
+ mode: null,
1044
+ state: "disabled",
1045
+ installed: false,
1046
+ enabledAtBoot: false,
1047
+ active: false,
1048
+ healthy: false,
1049
+ rebootReady: false,
1050
+ definitionMatches: false,
1051
+ environmentMatches: false,
1052
+ entrypointMatches: false,
1053
+ requestedState: null,
1054
+ definitionPath: null,
1055
+ entrypoint: null,
1056
+ daemon: null,
1057
+ issues: [`Treeport service mode does not support ${process.platform}.`],
1058
+ recoveryCommands: [],
1059
+ administratorCommand: null
1060
+ };
1061
+ if (!record) {
1062
+ const untracked = await untrackedDefinition();
1063
+ if (!untracked) return {
1064
+ supported: true,
1065
+ manager,
1066
+ mode: null,
1067
+ state: "disabled",
1068
+ installed: false,
1069
+ enabledAtBoot: false,
1070
+ active: false,
1071
+ healthy: false,
1072
+ rebootReady: false,
1073
+ definitionMatches: false,
1074
+ environmentMatches: false,
1075
+ entrypointMatches: false,
1076
+ requestedState: null,
1077
+ definitionPath: null,
1078
+ entrypoint: null,
1079
+ daemon: null,
1080
+ issues: [],
1081
+ recoveryCommands: ["treeport service enable"],
1082
+ administratorCommand: null
1083
+ };
1084
+ const active = untracked.manager === "launchd" ? await runCommand$1(await executablePath("launchctl"), ["print", untracked.target]) : await runCommand$1(await executablePath("systemctl"), [
1085
+ "--user",
1086
+ "is-active",
1087
+ untracked.name
1088
+ ]);
1089
+ return {
1090
+ supported: true,
1091
+ manager,
1092
+ mode: untracked.mode,
1093
+ state: "stale",
1094
+ installed: true,
1095
+ enabledAtBoot: untracked.mode === "headless",
1096
+ active: active.code === 0,
1097
+ healthy: false,
1098
+ rebootReady: false,
1099
+ definitionMatches: false,
1100
+ environmentMatches: false,
1101
+ entrypointMatches: false,
1102
+ requestedState: null,
1103
+ definitionPath: untracked.path,
1104
+ entrypoint: null,
1105
+ daemon: null,
1106
+ issues: [`A Treeport ${untracked.mode === "headless" ? "advanced headless " : ""}service definition exists at ${untracked.path}, but its service record is missing. Restore the original Treeport data directory before you manage it.${untracked.mode === "headless" ? " An administrator must approve removal of the system definition." : ""}`],
1107
+ recoveryCommands: [],
1108
+ administratorCommand: null
1109
+ };
1110
+ }
1111
+ const paths = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
1112
+ const [managerStatus, definitionContent, entrypointExists, daemon] = await Promise.all([
1113
+ managerState(record),
1114
+ fs.readFile(record.definitionPath, "utf8").catch(() => ""),
1115
+ fs.access(record.cliEntrypoint, constants.X_OK).then(() => true).catch(() => false),
1116
+ daemonStatus()
1117
+ ]);
1118
+ const definitionPresent = definitionContent !== "";
1119
+ const definitionMatches = definitionPresent && fingerprint(definitionContent) === record.definitionHash;
1120
+ const invokedEntrypoint = currentEntrypoint();
1121
+ const entrypointMatches = Boolean(entrypointExists && (invokedEntrypoint === null || path.resolve(invokedEntrypoint) === path.resolve(record.cliEntrypoint)));
1122
+ const environmentMatches = fingerprint(createServiceEnvironment({
1123
+ user: {
1124
+ uid: record.uid,
1125
+ gid: record.gid,
1126
+ username: record.username,
1127
+ homedir: record.home,
1128
+ shell: record.environment.TREEPORT_SHELL ?? null
1129
+ },
1130
+ paths: localPaths({
1131
+ TREEPORT_DATA_DIR: record.dataDir,
1132
+ TREEPORT_RUNTIME_DIR: record.runtimeDir
1133
+ }),
1134
+ apiUrl: record.apiUrl,
1135
+ recordPath: paths.recordPath,
1136
+ installationMethod: record.installationMethod
1137
+ })) === record.environmentHash;
1138
+ const healthy = Boolean(daemon.verified && daemon.health?.daemonLifecycle === "service" && daemon.state?.daemonLifecycle === "service" && path.resolve(daemon.state.dataDir) === path.resolve(record.dataDir));
1139
+ const installed = managerStatus.enabled;
1140
+ const enabledAtBoot = installed && (record.manager === "launchd" ? record.mode === "headless" : managerStatus.lingering);
1141
+ const rebootReady = enabledAtBoot;
1142
+ const pendingCommand = administratorCommand(record) ?? (record.manager === "systemd" && managerStatus.enabled && !managerStatus.lingering ? `sudo loginctl enable-linger ${record.username}` : null);
1143
+ const issues = [];
1144
+ const recoveryCommands = [];
1145
+ const repairCommand = record.manager === "launchd" && record.mode === "headless" ? "treeport service enable --headless" : "treeport service enable";
1146
+ if (record.manager !== manager) issues.push(`The service record uses ${record.manager}, but this host requires ${manager}.`);
1147
+ if (!definitionMatches && !record.pendingAdministratorRequestId) {
1148
+ issues.push(definitionPresent ? `The service definition at ${record.definitionPath} was changed.` : `The service definition is missing at ${record.definitionPath}.`);
1149
+ recoveryCommands.push(repairCommand);
1150
+ }
1151
+ if (definitionMatches && !installed && !record.pendingAdministratorRequestId) {
1152
+ issues.push(record.manager === "launchd" && record.mode === "user" ? "The service definition is not enabled for startup after login." : "The service definition is not enabled for startup after reboot.");
1153
+ recoveryCommands.push(repairCommand);
1154
+ }
1155
+ if (!entrypointMatches) {
1156
+ issues.push(`The service CLI entrypoint is unavailable or moved: ${record.cliEntrypoint}`);
1157
+ recoveryCommands.push(repairCommand);
1158
+ }
1159
+ if (!environmentMatches) {
1160
+ issues.push("The service environment differs from the current Treeport environment.");
1161
+ recoveryCommands.push(repairCommand);
1162
+ }
1163
+ if (record.manager === "systemd" && installed && !managerStatus.lingering) {
1164
+ issues.push(`User lingering is disabled for ${record.username}.`);
1165
+ recoveryCommands.push(`sudo loginctl enable-linger ${record.username}`);
1166
+ }
1167
+ if (managerStatus.managerIssue) issues.push(managerStatus.managerIssue);
1168
+ if (installed && record.requestedState === "running" && !healthy && !record.pendingAdministratorRequestId) {
1169
+ issues.push("The supervised Treeport daemon is not healthy.");
1170
+ recoveryCommands.push("treeport start");
1171
+ }
1172
+ const stale = record.manager !== manager || !definitionMatches || !environmentMatches || !entrypointMatches || definitionPresent && !installed || managerStatus.managerIssue !== null;
1173
+ const 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";
1174
+ return {
1175
+ supported: true,
1176
+ manager,
1177
+ mode: record.mode,
1178
+ state,
1179
+ installed,
1180
+ enabledAtBoot,
1181
+ active: managerStatus.active,
1182
+ healthy,
1183
+ rebootReady,
1184
+ definitionMatches,
1185
+ environmentMatches,
1186
+ entrypointMatches,
1187
+ requestedState: record.requestedState,
1188
+ definitionPath: record.definitionPath,
1189
+ entrypoint: record.cliEntrypoint,
1190
+ daemon,
1191
+ issues,
1192
+ recoveryCommands: [...new Set(recoveryCommands)],
1193
+ administratorCommand: pendingCommand
1194
+ };
1195
+ }
1196
+ async function prepareRecord(requestedMode) {
1197
+ if (process.getuid?.() === 0) throw new Error("Run `treeport service enable` as the user who will run Treeport, not as root.");
1198
+ const manager = managerForPlatform();
1199
+ if (!manager) throw new Error(`Treeport service mode supports macOS launchd and Linux systemd; found ${process.platform}.`);
1200
+ const explicitApiUrl = process.env.TREEPORT_API_URL?.trim();
1201
+ if (explicitApiUrl) assertLoopbackHost(new URL(explicitApiUrl).hostname);
1202
+ if (manager !== "launchd" && requestedMode === "headless") throw new Error("The `--headless` option is only available for the advanced macOS LaunchDaemon mode.");
1203
+ const mode = manager === "launchd" ? requestedMode : "user";
1204
+ const user = os.userInfo();
1205
+ const paths = localPaths();
1206
+ const locations = servicePaths();
1207
+ const apiUrl = await resolveLocalApiUrl();
1208
+ const listener = new URL(apiUrl);
1209
+ if (listener.protocol !== "http:") throw new Error("Treeport service mode requires a local HTTP loopback URL.");
1210
+ assertLoopbackHost(listener.hostname);
1211
+ const [cliEntrypoint, administratorRuntime] = await Promise.all([ensureEntrypoint(), manager === "launchd" && mode === "headless" ? currentAdministratorRuntime() : Promise.resolve(null)]);
1212
+ const group = await primaryGroup(user.username);
1213
+ const launchd = manager === "launchd" ? launchdLocation({
1214
+ uid: user.uid,
1215
+ home: user.homedir,
1216
+ mode
1217
+ }) : null;
1218
+ const definitionName = launchd?.name ?? "treeport.service";
1219
+ const definitionPath = launchd?.path ?? path.join(process.env.XDG_CONFIG_HOME?.trim() || path.join(user.homedir, ".config"), "systemd", "user", definitionName);
1220
+ const environment = createServiceEnvironment({
1221
+ user,
1222
+ paths,
1223
+ apiUrl,
1224
+ recordPath: locations.recordPath,
1225
+ installationMethod: "npm"
1226
+ });
1227
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1228
+ const previous = await currentRecord();
1229
+ const untracked = previous ? null : await untrackedDefinition();
1230
+ if (untracked) throw new Error(`A Treeport ${untracked.mode === "headless" ? "advanced headless " : ""}service definition already exists at ${untracked.path}. Restore its original Treeport data directory before you manage or remove it.`);
1231
+ if (previous && path.resolve(previous.dataDir) !== paths.dataDir) throw new Error(`Treeport service mode already uses ${previous.dataDir}. Disable it before enabling ${paths.dataDir}.`);
1232
+ if (previous && previous.mode !== mode) throw new Error(previous.mode === "headless" ? "Treeport uses the advanced headless service mode. Run `treeport service disable` with administrator approval. Then run `treeport service enable` to migrate to user/login mode." : "Treeport uses user/login service mode. Run `treeport service disable` first. Then run `treeport service enable --headless` to select advanced headless mode.");
1233
+ if (previous?.manager === "launchd" && path.resolve(previous.definitionPath) !== path.resolve(definitionPath)) throw new Error(`The service record points to an unexpected definition at ${previous.definitionPath}. Refusing to create another definition.`);
1234
+ const base = {
1235
+ schemaVersion: 1,
1236
+ manager,
1237
+ mode,
1238
+ platform: process.platform,
1239
+ uid: user.uid,
1240
+ gid: user.gid,
1241
+ username: user.username,
1242
+ group,
1243
+ home: user.homedir,
1244
+ dataDir: paths.dataDir,
1245
+ runtimeDir: paths.runtimeDir,
1246
+ logPath: paths.logPath,
1247
+ apiUrl,
1248
+ cliEntrypoint,
1249
+ runtimeExecutable: administratorRuntime?.runtimeExecutable ?? null,
1250
+ runtimeEntrypoint: administratorRuntime?.runtimeEntrypoint ?? null,
1251
+ installationMethod: "npm",
1252
+ definitionName,
1253
+ definitionPath,
1254
+ definitionHash: "0".repeat(64),
1255
+ environmentHash: fingerprint(environment),
1256
+ environment,
1257
+ requestedState: "running",
1258
+ pendingAdministratorRequestId: null,
1259
+ createdAt: previous?.createdAt ?? now,
1260
+ updatedAt: now
1261
+ };
1262
+ const definition = definitionForRecord(base);
1263
+ return {
1264
+ record: {
1265
+ ...base,
1266
+ definitionHash: fingerprint(definition)
1267
+ },
1268
+ definition
1269
+ };
1270
+ }
1271
+ async function writeServiceFiles(record, definition) {
1272
+ const locations = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
1273
+ await fs.mkdir(path.dirname(record.logPath), {
1274
+ recursive: true,
1275
+ mode: 448
1276
+ });
1277
+ if (record.mode === "headless") await fs.mkdir(locations.requestsDirectory, {
1278
+ recursive: true,
1279
+ mode: 448
1280
+ });
1281
+ await fs.writeFile(locations.runnerPath, runnerSource(record), { mode: 448 });
1282
+ await fs.chmod(locations.runnerPath, 448);
1283
+ if (record.manager === "launchd" && record.mode === "headless") await fs.writeFile(locations.stagedDefinitionPath, definition, { mode: 384 });
1284
+ else {
1285
+ await fs.mkdir(path.dirname(record.definitionPath), {
1286
+ recursive: true,
1287
+ mode: 448
1288
+ });
1289
+ const temporaryPath = `${record.definitionPath}.${process.pid}.tmp`;
1290
+ await fs.writeFile(temporaryPath, definition, { mode: 384 });
1291
+ await fs.rename(temporaryPath, record.definitionPath);
1292
+ }
1293
+ await saveRecord(record);
1294
+ }
1295
+ async function prepareAdministratorRequest(record, operation) {
1296
+ if (record.manager !== "launchd" || record.mode !== "headless") throw new Error("Administrator requests are only available for advanced macOS headless service mode.");
1297
+ const runtime = await currentAdministratorRuntime();
1298
+ const requestRecord = {
1299
+ ...record,
1300
+ ...runtime
1301
+ };
1302
+ const locations = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
1303
+ const id = crypto.randomUUID();
1304
+ const now = /* @__PURE__ */ new Date();
1305
+ const request = {
1306
+ schemaVersion: 1,
1307
+ id,
1308
+ operation,
1309
+ createdAt: now.toISOString(),
1310
+ expiresAt: new Date(now.getTime() + 15 * 6e4).toISOString(),
1311
+ uid: record.uid,
1312
+ gid: record.gid,
1313
+ username: record.username,
1314
+ group: record.group,
1315
+ home: record.home,
1316
+ serviceRecordPath: locations.recordPath,
1317
+ runnerPath: locations.runnerPath,
1318
+ definitionName: record.definitionName,
1319
+ definitionPath: record.definitionPath,
1320
+ stagedDefinitionPath: locations.stagedDefinitionPath,
1321
+ definitionHash: record.definitionHash,
1322
+ apiUrl: record.apiUrl,
1323
+ cliEntrypoint: record.cliEntrypoint,
1324
+ runtimeExecutable: requestRecord.runtimeExecutable,
1325
+ runtimeEntrypoint: requestRecord.runtimeEntrypoint
1326
+ };
1327
+ await writeJson$2(path.join(locations.requestsDirectory, `${id}.json`), request);
1328
+ const next = {
1329
+ ...requestRecord,
1330
+ pendingAdministratorRequestId: id,
1331
+ updatedAt: now.toISOString()
1332
+ };
1333
+ await saveRecord(next);
1334
+ return {
1335
+ record: next,
1336
+ command: administratorCommand(next)
1337
+ };
1338
+ }
1339
+ async function waitForService(record) {
1340
+ const deadline = Date.now() + 15e3;
1341
+ const version = await treeportVersion();
1342
+ while (Date.now() < deadline) {
1343
+ const observed = await daemonHealth(record.apiUrl, 500);
1344
+ if (observed?.daemonLifecycle === "service" && observed.instanceId && observed.version === version) return;
1345
+ await new Promise((resolve) => setTimeout(resolve, 150));
1346
+ }
1347
+ throw new Error(`Treeport service did not become ready at ${record.apiUrl}. See ${record.logPath}.`);
1348
+ }
1349
+ async function serviceEnable(mode = "user") {
1350
+ const existing = await serviceStatus();
1351
+ if (existing.mode === mode && existing.state === "healthy" && existing.definitionMatches && existing.environmentMatches && existing.entrypointMatches) return {
1352
+ status: existing,
1353
+ changed: false,
1354
+ administratorCommand: null
1355
+ };
1356
+ const { record, definition } = await prepareRecord(mode);
1357
+ const failedChecks = (await runDoctor()).filter((check) => !check.ok);
1358
+ if (failedChecks.length) throw new Error(failedChecks.map((check) => `${check.name}: ${check.detail}`).join("\n"));
1359
+ const systemctl = record.manager === "systemd" ? await executablePath("systemctl") : null;
1360
+ if (systemctl) {
1361
+ const managerAvailable = await runCommand$1(systemctl, ["--user", "show-environment"]);
1362
+ if (managerAvailable.code !== 0) throw commandError("systemctl --user", managerAvailable);
1363
+ }
1364
+ await writeServiceFiles(record, definition);
1365
+ if (record.manager === "launchd") {
1366
+ if (record.mode === "headless") {
1367
+ await daemonDown();
1368
+ const prepared = await prepareAdministratorRequest(record, "enable");
1369
+ return {
1370
+ status: await serviceStatus(),
1371
+ changed: true,
1372
+ administratorCommand: prepared.command
1373
+ };
1374
+ }
1375
+ const launchctl = await executablePath("launchctl");
1376
+ const location = launchdLocation({
1377
+ uid: record.uid,
1378
+ home: record.home,
1379
+ mode: record.mode
1380
+ });
1381
+ const commands = userLaunchdCommands({
1382
+ operation: "enable",
1383
+ location,
1384
+ definitionPath: record.definitionPath
1385
+ });
1386
+ await runCommand$1(launchctl, commands.bootout);
1387
+ await daemonDown();
1388
+ const enabled = await runCommand$1(launchctl, commands.enable);
1389
+ if (enabled.code !== 0) {
1390
+ await Promise.all([fs.rm(record.definitionPath, { force: true }), fs.rm(servicePaths().directory, {
1391
+ recursive: true,
1392
+ force: true
1393
+ })]);
1394
+ await daemonUp({});
1395
+ throw commandError("launchctl enable", enabled);
1396
+ }
1397
+ const bootstrapped = await runCommand$1(launchctl, commands.activate);
1398
+ if (bootstrapped.code !== 0) {
1399
+ await Promise.all([fs.rm(record.definitionPath, { force: true }), fs.rm(servicePaths().directory, {
1400
+ recursive: true,
1401
+ force: true
1402
+ })]);
1403
+ await daemonUp({});
1404
+ throw commandError("launchctl bootstrap", bootstrapped);
1405
+ }
1406
+ const startupError = await waitForService(record).then(() => null, (error) => error);
1407
+ if (startupError) {
1408
+ await runCommand$1(launchctl, ["bootout", location.target]);
1409
+ await Promise.all([fs.rm(record.definitionPath, { force: true }), fs.rm(servicePaths().directory, {
1410
+ recursive: true,
1411
+ force: true
1412
+ })]);
1413
+ await daemonUp({});
1414
+ throw startupError;
1415
+ }
1416
+ return {
1417
+ status: await serviceStatus(),
1418
+ changed: true,
1419
+ administratorCommand: null
1420
+ };
1421
+ }
1422
+ if (!systemctl) throw new Error("Treeport could not resolve the systemd command.");
1423
+ await daemonDown();
1424
+ const reload = await runCommand$1(systemctl, ["--user", "daemon-reload"]);
1425
+ if (reload.code !== 0) {
1426
+ await Promise.all([fs.rm(record.definitionPath, { force: true }), fs.rm(servicePaths().directory, {
1427
+ recursive: true,
1428
+ force: true
1429
+ })]);
1430
+ await daemonUp({});
1431
+ throw commandError("systemctl --user daemon-reload", reload);
1432
+ }
1433
+ const enabled = await runCommand$1(systemctl, [
1434
+ "--user",
1435
+ "enable",
1436
+ "--now",
1437
+ record.definitionName
1438
+ ]);
1439
+ if (enabled.code !== 0) {
1440
+ await fs.rm(record.definitionPath, { force: true });
1441
+ await runCommand$1(systemctl, ["--user", "daemon-reload"]);
1442
+ await fs.rm(servicePaths().directory, {
1443
+ recursive: true,
1444
+ force: true
1445
+ });
1446
+ await daemonUp({});
1447
+ throw commandError("systemctl --user enable --now", enabled);
1448
+ }
1449
+ const startupError = await waitForService(record).then(() => null, (error) => error);
1450
+ if (startupError) {
1451
+ await runCommand$1(systemctl, [
1452
+ "--user",
1453
+ "disable",
1454
+ "--now",
1455
+ record.definitionName
1456
+ ]);
1457
+ await fs.rm(record.definitionPath, { force: true });
1458
+ await runCommand$1(systemctl, ["--user", "daemon-reload"]);
1459
+ await fs.rm(servicePaths().directory, {
1460
+ recursive: true,
1461
+ force: true
1462
+ });
1463
+ await daemonUp({});
1464
+ throw startupError;
1465
+ }
1466
+ const status = await serviceStatus();
1467
+ return {
1468
+ status,
1469
+ changed: true,
1470
+ administratorCommand: status.administratorCommand
1471
+ };
1472
+ }
1473
+ async function serviceStart() {
1474
+ const record = await currentRecord();
1475
+ if (!record) throw new Error("Treeport service mode is disabled. Run `treeport service enable` first.");
1476
+ const current = await serviceStatus();
1477
+ if (current.state === "healthy") return {
1478
+ status: current,
1479
+ changed: false,
1480
+ administratorCommand: null
1481
+ };
1482
+ if (current.administratorCommand) return {
1483
+ status: current,
1484
+ changed: false,
1485
+ administratorCommand: current.administratorCommand
1486
+ };
1487
+ if (!current.definitionMatches || !current.entrypointMatches) throw new Error(record.manager === "launchd" && record.mode === "headless" ? "The Treeport service definition is stale. Run `treeport service enable --headless` to repair it." : "The Treeport service definition is stale. Run `treeport service enable` to repair it.");
1488
+ const next = {
1489
+ ...record,
1490
+ requestedState: "running",
1491
+ pendingAdministratorRequestId: null,
1492
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1493
+ };
1494
+ await saveRecord(next);
1495
+ if (record.manager === "launchd") {
1496
+ if (record.mode === "headless") {
1497
+ const prepared = await prepareAdministratorRequest(next, "start");
1498
+ return {
1499
+ status: await serviceStatus(),
1500
+ changed: true,
1501
+ administratorCommand: prepared.command
1502
+ };
1503
+ }
1504
+ const launchctl = await executablePath("launchctl");
1505
+ const location = launchdLocation({
1506
+ uid: record.uid,
1507
+ home: record.home,
1508
+ mode: record.mode
1509
+ });
1510
+ const active = await runCommand$1(launchctl, ["print", location.target]);
1511
+ const commands = userLaunchdCommands({
1512
+ operation: "start",
1513
+ location,
1514
+ definitionPath: record.definitionPath,
1515
+ active: active.code === 0
1516
+ });
1517
+ const enabled = await runCommand$1(launchctl, commands.enable);
1518
+ if (enabled.code !== 0) {
1519
+ await saveRecord(record);
1520
+ throw commandError("launchctl enable", enabled);
1521
+ }
1522
+ const started = await runCommand$1(launchctl, commands.activate);
1523
+ if (started.code !== 0) {
1524
+ await saveRecord(record);
1525
+ throw commandError("launchctl start", started);
1526
+ }
1527
+ await waitForService(next);
1528
+ return {
1529
+ status: await serviceStatus(),
1530
+ changed: true,
1531
+ administratorCommand: null
1532
+ };
1533
+ }
1534
+ const result = await runCommand$1(await executablePath("systemctl"), [
1535
+ "--user",
1536
+ "start",
1537
+ record.definitionName
1538
+ ]);
1539
+ if (result.code !== 0) throw commandError("systemctl --user start", result);
1540
+ await waitForService(next);
1541
+ return {
1542
+ status: await serviceStatus(),
1543
+ changed: true,
1544
+ administratorCommand: null
1545
+ };
1546
+ }
1547
+ async function serviceStop() {
1548
+ const record = await currentRecord();
1549
+ if (!record) throw new Error("Treeport service mode is disabled.");
1550
+ const current = await serviceStatus();
1551
+ if (current.state === "stopped") return {
1552
+ status: current,
1553
+ changed: false,
1554
+ administratorCommand: null
1555
+ };
1556
+ const next = {
1557
+ ...record,
1558
+ requestedState: "stopped",
1559
+ pendingAdministratorRequestId: null,
1560
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1561
+ };
1562
+ await saveRecord(next);
1563
+ if (record.manager === "launchd") {
1564
+ if (record.mode === "headless") {
1565
+ const prepared = await prepareAdministratorRequest(next, "stop");
1566
+ return {
1567
+ status: await serviceStatus(),
1568
+ changed: true,
1569
+ administratorCommand: prepared.command
1570
+ };
1571
+ }
1572
+ const commands = userLaunchdCommands({
1573
+ operation: "stop",
1574
+ location: launchdLocation({
1575
+ uid: record.uid,
1576
+ home: record.home,
1577
+ mode: record.mode
1578
+ }),
1579
+ definitionPath: record.definitionPath
1580
+ });
1581
+ const result = await runCommand$1(await executablePath("launchctl"), commands.bootout);
1582
+ if (result.code !== 0 && !result.stderr.includes("No such process")) {
1583
+ await saveRecord(record);
1584
+ throw commandError("launchctl bootout", result);
1585
+ }
1586
+ return {
1587
+ status: await serviceStatus(),
1588
+ changed: true,
1589
+ administratorCommand: null
1590
+ };
1591
+ }
1592
+ const result = await runCommand$1(await executablePath("systemctl"), [
1593
+ "--user",
1594
+ "stop",
1595
+ record.definitionName
1596
+ ]);
1597
+ if (result.code !== 0) {
1598
+ await saveRecord(record);
1599
+ throw commandError("systemctl --user stop", result);
1600
+ }
1601
+ return {
1602
+ status: await serviceStatus(),
1603
+ changed: true,
1604
+ administratorCommand: null
1605
+ };
1606
+ }
1607
+ async function serviceDisable() {
1608
+ const record = await currentRecord();
1609
+ if (!record) return {
1610
+ status: await serviceStatus(),
1611
+ changed: false,
1612
+ administratorCommand: null
1613
+ };
1614
+ if (record.manager === "launchd") {
1615
+ if (record.mode === "headless") {
1616
+ const prepared = await prepareAdministratorRequest({
1617
+ ...record,
1618
+ pendingAdministratorRequestId: null
1619
+ }, "disable");
1620
+ return {
1621
+ status: await serviceStatus(),
1622
+ changed: true,
1623
+ administratorCommand: prepared.command
1624
+ };
1625
+ }
1626
+ const installed = await fs.readFile(record.definitionPath, "utf8").catch((error) => {
1627
+ if (error.code === "ENOENT") return "";
1628
+ throw error;
1629
+ });
1630
+ if (installed && fingerprint(installed) !== record.definitionHash) throw new Error("Refusing to remove a LaunchAgent definition that Treeport did not create.");
1631
+ const commands = userLaunchdCommands({
1632
+ operation: "disable",
1633
+ location: launchdLocation({
1634
+ uid: record.uid,
1635
+ home: record.home,
1636
+ mode: record.mode
1637
+ }),
1638
+ definitionPath: record.definitionPath
1639
+ });
1640
+ const stopped = await runCommand$1(await executablePath("launchctl"), commands.bootout);
1641
+ if (stopped.code !== 0 && !stopped.stderr.includes("No such process")) throw commandError("launchctl bootout", stopped);
1642
+ await fs.rm(record.definitionPath, { force: true });
1643
+ await fs.rm(servicePaths().directory, {
1644
+ recursive: true,
1645
+ force: true
1646
+ });
1647
+ return {
1648
+ status: await serviceStatus(),
1649
+ changed: true,
1650
+ administratorCommand: null
1651
+ };
1652
+ }
1653
+ const systemctl = await executablePath("systemctl");
1654
+ const disabled = await runCommand$1(systemctl, [
1655
+ "--user",
1656
+ "disable",
1657
+ "--now",
1658
+ record.definitionName
1659
+ ]);
1660
+ if (disabled.code !== 0 && !disabled.stderr.includes("does not exist")) throw commandError("systemctl --user disable --now", disabled);
1661
+ await fs.rm(record.definitionPath, { force: true });
1662
+ await runCommand$1(systemctl, ["--user", "daemon-reload"]);
1663
+ await fs.rm(servicePaths().directory, {
1664
+ recursive: true,
1665
+ force: true
1666
+ });
1667
+ return {
1668
+ status: await serviceStatus(),
1669
+ changed: true,
1670
+ administratorCommand: null
1671
+ };
1672
+ }
1673
+ async function serviceApply(requestPath) {
1674
+ if (process.platform !== "darwin") throw new Error("Treeport service apply is only available for macOS LaunchDaemons.");
1675
+ if (process.getuid?.() !== 0) throw new Error("Run the printed service apply command with sudo or as root.");
1676
+ if (!path.isAbsolute(requestPath)) throw new Error("The service apply request path must be absolute.");
1677
+ const metadata = await fs.lstat(requestPath);
1678
+ if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("The service apply request must be a regular file, not a symlink.");
1679
+ if ((metadata.mode & 63) !== 0) throw new Error("The service apply request must not be readable or writable by other users.");
1680
+ const request = await readJson(requestPath, administratorRequestSchema);
1681
+ if (!request) throw new Error("The service apply request is invalid.");
1682
+ if (metadata.uid !== request.uid) throw new Error("The service apply request owner does not match its target user.");
1683
+ if (Date.parse(request.expiresAt) <= Date.now()) throw new Error("The service apply request expired. Run the original Treeport command again.");
1684
+ const currentRuntime = await currentAdministratorRuntime().catch(() => null);
1685
+ const invokedRuntimeEntrypoint = process.argv[1] ? path.resolve(process.argv[1]) : null;
1686
+ 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.");
1687
+ const usedPath = `${requestPath}.used`;
1688
+ if (await fs.access(usedPath).then(() => true).catch(() => false)) throw new Error("The service apply request was already used.");
1689
+ const account = os.userInfo({ encoding: "utf8" });
1690
+ const idResult = await runCommand$1(await executablePath("id"), ["-u", request.username]);
1691
+ if (idResult.code !== 0 || Number(idResult.stdout.trim()) !== request.uid) throw new Error("The service apply target user no longer matches the host account.");
1692
+ const record = await readServiceRecord(request.serviceRecordPath);
1693
+ if (!record || record.uid !== request.uid || record.username !== request.username || record.manager !== "launchd" || record.mode !== "headless" || 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.");
1694
+ if (account.uid !== 0) throw new Error("Treeport service apply lost root privileges.");
1695
+ const launchctl = await executablePath("launchctl");
1696
+ const target = `system/${request.definitionName}`;
1697
+ if (request.operation === "enable") {
1698
+ const staged = await fs.readFile(request.stagedDefinitionPath, "utf8");
1699
+ 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.");
1700
+ const temporaryPath = `${request.definitionPath}.${process.pid}.tmp`;
1701
+ await fs.copyFile(request.stagedDefinitionPath, temporaryPath);
1702
+ await fs.chown(temporaryPath, 0, 0);
1703
+ await fs.chmod(temporaryPath, 420);
1704
+ await fs.rename(temporaryPath, request.definitionPath);
1705
+ await runCommand$1(launchctl, ["bootout", target]);
1706
+ const enabled = await runCommand$1(launchctl, ["enable", target]);
1707
+ if (enabled.code !== 0) throw commandError("launchctl enable", enabled);
1708
+ const bootstrapped = await runCommand$1(launchctl, [
1709
+ "bootstrap",
1710
+ "system",
1711
+ request.definitionPath
1712
+ ]);
1713
+ if (bootstrapped.code !== 0) throw commandError("launchctl bootstrap", bootstrapped);
1714
+ } else if (request.operation === "start") {
1715
+ const enabled = await runCommand$1(launchctl, ["enable", target]);
1716
+ if (enabled.code !== 0) throw commandError("launchctl enable", enabled);
1717
+ const started = (await runCommand$1(launchctl, ["print", target])).code === 0 ? await runCommand$1(launchctl, ["kickstart", target]) : await runCommand$1(launchctl, [
1718
+ "bootstrap",
1719
+ "system",
1720
+ request.definitionPath
1721
+ ]);
1722
+ if (started.code !== 0) throw commandError("launchctl start", started);
1723
+ } else if (request.operation === "stop") {
1724
+ const stopped = await runCommand$1(launchctl, ["bootout", target]);
1725
+ if (stopped.code !== 0 && !stopped.stderr.includes("No such process")) throw commandError("launchctl bootout", stopped);
1726
+ } else {
1727
+ const installed = await fs.readFile(request.definitionPath, "utf8").catch(() => "");
1728
+ if (installed && fingerprint(installed) !== request.definitionHash) throw new Error("Refusing to remove a LaunchDaemon definition that Treeport did not create.");
1729
+ await runCommand$1(launchctl, ["bootout", target]);
1730
+ await fs.rm(request.definitionPath, { force: true });
1731
+ }
1732
+ if (request.operation === "enable" || request.operation === "start") await waitForService(record);
1733
+ await fs.rename(requestPath, usedPath);
1734
+ if (request.operation === "disable") await fs.rm(path.dirname(request.serviceRecordPath), {
1735
+ recursive: true,
1736
+ force: true
1737
+ });
1738
+ else {
1739
+ await writeJson$2(request.serviceRecordPath, {
1740
+ ...record,
1741
+ requestedState: request.operation === "stop" ? "stopped" : "running",
1742
+ pendingAdministratorRequestId: null,
1743
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1744
+ });
1745
+ await fs.chown(request.serviceRecordPath, request.uid, request.gid);
1746
+ }
1747
+ return {
1748
+ operation: request.operation,
1749
+ applied: true
1750
+ };
1751
+ }
1752
+ async function serviceRun() {
1753
+ const recordPath = process.env.TREEPORT_SERVICE_RECORD?.trim();
1754
+ if (!recordPath || !path.isAbsolute(recordPath)) throw new Error("Treeport service run requires a valid service record.");
1755
+ const record = await readServiceRecord(recordPath);
1756
+ if (!record) throw new Error(`Treeport service record is invalid: ${recordPath}`);
1757
+ if (process.getuid?.() === 0 || process.getuid?.() !== record.uid) throw new Error(`Treeport service must run as ${record.username} (UID ${record.uid}), never as root.`);
1758
+ await writeJson$2(recordPath, {
1759
+ ...record,
1760
+ requestedState: "running",
1761
+ pendingAdministratorRequestId: null,
1762
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1763
+ });
1764
+ const [version, serverEntry, webDist] = await Promise.all([
1765
+ treeportVersion(),
1766
+ resolvePackagePath("dist", "node", "server", "index.js"),
1767
+ resolvePackagePath("dist", "web")
1768
+ ]);
1769
+ Object.assign(process.env, record.environment, {
1770
+ TREEPORT_APP_VERSION: version,
1771
+ TREEPORT_INSTANCE_ID: crypto.randomUUID(),
1772
+ TREEPORT_WEB_DIST: webDist,
1773
+ TREEPORT_DAEMON_LIFECYCLE: "service"
1774
+ });
1775
+ await import(pathToFileURL(serverEntry).href);
1776
+ }
1777
+ async function readServiceLogs(lines) {
1778
+ const record = await currentRecord();
1779
+ if (!record || record.manager === "launchd") return (await fs.readFile(record?.logPath ?? localPaths().logPath, "utf8").catch((error) => {
1780
+ if (error.code === "ENOENT") return "";
1781
+ throw error;
1782
+ })).split("\n").slice(-lines - 1).join("\n");
1783
+ const result = await runCommand$1(await executablePath("journalctl"), [
1784
+ "--user",
1785
+ "--unit",
1786
+ record.definitionName,
1787
+ "--no-pager",
1788
+ "--lines",
1789
+ String(lines)
1790
+ ]);
1791
+ if (result.code !== 0) throw commandError("journalctl --user", result);
1792
+ return result.stdout;
1793
+ }
1794
+ async function serviceDoctorCheck() {
1795
+ const status = await serviceStatus();
1796
+ if (!status.supported) return {
1797
+ name: "Service supervision",
1798
+ ok: false,
1799
+ detail: status.issues.join(" ")
1800
+ };
1801
+ if (status.state === "disabled") return {
1802
+ name: "Service supervision",
1803
+ ok: true,
1804
+ detail: "disabled (opt in with `treeport service enable`)"
1805
+ };
1806
+ if (status.state === "healthy") return {
1807
+ name: "Service supervision",
1808
+ ok: true,
1809
+ detail: status.mode === "headless" ? `${status.manager}; advanced headless mode; healthy` : `${status.manager}; user service mode; healthy`
1810
+ };
1811
+ if (status.state === "stopped") return {
1812
+ name: "Service supervision",
1813
+ ok: true,
1814
+ detail: status.mode === "headless" ? `${status.manager}; advanced headless mode; intentionally stopped` : `${status.manager}; user service mode; intentionally stopped`
1815
+ };
1816
+ return {
1817
+ name: "Service supervision",
1818
+ ok: false,
1819
+ detail: status.issues.join(" ") || `state: ${status.state}`
1820
+ };
1821
+ }
1822
+ //#endregion
1823
+ //#region src/server/update-startup.ts
1824
+ const pendingSchema = z.strictObject({
1825
+ schemaVersion: z.literal(1),
1826
+ operationId: z.string().uuid(),
1827
+ targetVersion: z.string(),
1828
+ createdAt: z.string()
1829
+ });
1830
+ function updatePaths(dataDir) {
1831
+ const directory = path.join(dataDir, "updates");
1832
+ return {
1833
+ pending: path.join(directory, "pending-startup.json"),
1834
+ report: path.join(directory, "startup-report.json")
1835
+ };
1836
+ }
1837
+ async function writeJson$1(filePath, value) {
1838
+ await fs.mkdir(path.dirname(filePath), {
1839
+ recursive: true,
1840
+ mode: 448
1841
+ });
1842
+ const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
1843
+ await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
1844
+ await fs.rename(temporaryPath, filePath);
1845
+ }
1846
+ async function createUpdateStartupReporter(config) {
1847
+ const paths = updatePaths(config.dataDir);
1848
+ const pending = await fs.readFile(paths.pending, "utf8").then((value) => pendingSchema.safeParse(JSON.parse(value))).then((result) => result.success ? result.data : null).catch(() => null);
1849
+ const active = pending && pending.targetVersion === config.appVersion ? pending : null;
1850
+ const report = active ? {
1851
+ schemaVersion: 1,
1852
+ operationId: active.operationId,
1853
+ targetVersion: active.targetVersion,
1854
+ instanceId: config.instanceId ?? null,
1855
+ migrationState: "not_started",
1856
+ ready: false,
1857
+ error: null,
1858
+ logPath: path.join(config.dataDir, "logs", "daemon.log"),
1859
+ snapshotPaths: [],
1860
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1861
+ } : null;
1862
+ const save = async () => {
1863
+ if (!report) return;
1864
+ report.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1865
+ await writeJson$1(paths.report, report);
1866
+ };
1867
+ await save();
1868
+ return {
1869
+ async databaseOpening() {
1870
+ if (report) {
1871
+ report.migrationState = "unknown";
1872
+ await save();
1873
+ }
1874
+ },
1875
+ async databaseOpened(input) {
1876
+ if (report) {
1877
+ report.migrationState = input.migrationState;
1878
+ report.snapshotPaths = input.snapshotPaths;
1879
+ await save();
1880
+ }
1881
+ },
1882
+ async ready() {
1883
+ if (report) {
1884
+ report.ready = true;
1885
+ report.error = null;
1886
+ await save();
1887
+ await fs.rm(paths.pending, { force: true });
1888
+ }
1889
+ },
1890
+ async failed(error) {
1891
+ if (report) {
1892
+ report.error = error.message;
1893
+ await save();
1894
+ }
1895
+ }
1896
+ };
1897
+ }
1898
+ async function readUpdateStartupReport(dataDir) {
1899
+ const schema = z.strictObject({
1900
+ schemaVersion: z.literal(1),
1901
+ operationId: z.string().uuid(),
1902
+ targetVersion: z.string(),
1903
+ instanceId: z.string().nullable(),
1904
+ migrationState: z.enum([
1905
+ "not_started",
1906
+ "unchanged",
1907
+ "advanced",
1908
+ "unknown"
1909
+ ]),
1910
+ ready: z.boolean(),
1911
+ error: z.string().nullable(),
1912
+ logPath: z.string(),
1913
+ snapshotPaths: z.array(z.string()),
1914
+ updatedAt: z.string()
1915
+ });
1916
+ return fs.readFile(updatePaths(dataDir).report, "utf8").then((value) => schema.safeParse(JSON.parse(value))).then((result) => result.success ? result.data : null).catch(() => null);
1917
+ }
1918
+ //#endregion
1919
+ //#region src/cli/update.ts
1920
+ const PACKAGE_NAME = "@treeport/treeport";
1921
+ const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
1922
+ const DESTRUCTIVE_PHASES = /* @__PURE__ */ new Set([
1923
+ "stop",
1924
+ "activate",
1925
+ "restart",
1926
+ "health_check",
1927
+ "rollback",
1928
+ "recovery_required"
1929
+ ]);
1930
+ const operationSchema = z.strictObject({
1931
+ schemaVersion: z.literal(1),
1932
+ operationId: z.string().uuid(),
1933
+ phase: z.enum([
1934
+ "inspect",
1935
+ "resolve",
1936
+ "stage",
1937
+ "verify",
1938
+ "stop",
1939
+ "activate",
1940
+ "restart",
1941
+ "health_check",
1942
+ "rollback",
1943
+ "complete",
1944
+ "recovery_required"
1945
+ ]),
1946
+ fromVersion: z.string(),
1947
+ toVersion: z.string().nullable(),
1948
+ npmPrefix: z.string().nullable(),
1949
+ activeTarget: z.string().nullable(),
1950
+ stagedTarget: z.string().nullable(),
1951
+ previousTarget: z.string().nullable(),
1952
+ daemonWasRunning: z.boolean(),
1953
+ daemonLifecycle: z.enum(["treeport", "service"]).nullable(),
1954
+ serviceMode: z.enum(["user", "headless"]).nullable(),
1955
+ terminalIds: z.array(z.string()),
1956
+ activated: z.boolean(),
1957
+ migrationState: z.enum([
1958
+ "not_started",
1959
+ "unchanged",
1960
+ "advanced",
1961
+ "unknown"
1962
+ ]),
1963
+ rollbackAttempted: z.boolean(),
1964
+ rollbackSucceeded: z.boolean(),
1965
+ recoveryAction: z.string().nullable(),
1966
+ updatedAt: z.string()
1967
+ });
1968
+ const lockSchema = z.strictObject({
1969
+ operationId: z.string().uuid(),
1970
+ pid: z.number().int().positive(),
1971
+ fromVersion: z.string(),
1972
+ startedAt: z.string()
1973
+ });
1974
+ const packageSchema = z.looseObject({
1975
+ name: z.literal(PACKAGE_NAME),
1976
+ version: z.string()
1977
+ });
1978
+ const releaseSchema = z.looseObject({
1979
+ name: z.literal(PACKAGE_NAME),
1980
+ version: z.string(),
1981
+ dist: z.looseObject({
1982
+ tarball: z.string().url(),
1983
+ integrity: z.string().min(1)
1984
+ })
1985
+ });
1986
+ const packedReleaseSchema = z.tuple([z.looseObject({
1987
+ filename: z.string().min(1),
1988
+ integrity: z.string().min(1)
1989
+ })]);
1990
+ var LocalUpdateError = class extends Error {
1991
+ code;
1992
+ details;
1993
+ exitCode;
1994
+ constructor(code, message, details, exitCode) {
1995
+ super(message);
1996
+ this.code = code;
1997
+ this.details = details;
1998
+ this.exitCode = exitCode ?? ([
1999
+ "UPDATE_INSTALLATION_UNSUPPORTED",
2000
+ "UPDATE_INSTALLATION_NOT_WRITABLE",
2001
+ "UPDATE_REMOTE_REFUSED",
2002
+ "UPDATE_EXTERNAL_REFUSED",
2003
+ "UPDATE_IN_PROGRESS",
2004
+ "UPDATE_DOWNGRADE_REFUSED",
2005
+ "UPDATE_DAEMON_OWNERSHIP_FAILED",
2006
+ "UPDATE_SERVICE_ADMINISTRATOR_ACTION_REQUIRED"
2007
+ ].includes(code) ? 5 : 1);
2008
+ }
2009
+ };
2010
+ function processExists(pid) {
2011
+ try {
2012
+ process.kill(pid, 0);
2013
+ return true;
2014
+ } catch (error) {
2015
+ return error.code === "EPERM";
2016
+ }
2017
+ }
2018
+ function shellQuote(value) {
2019
+ return `'${value.replaceAll("'", `'\\''`)}'`;
2020
+ }
2021
+ async function runCommand(executable, args, environment) {
2022
+ return new Promise((resolve) => {
2023
+ const child = spawn(executable, args, {
2024
+ env: environment,
2025
+ stdio: [
2026
+ "ignore",
2027
+ "pipe",
2028
+ "pipe"
2029
+ ]
2030
+ });
2031
+ let stdout = "";
2032
+ let stderr = "";
2033
+ child.stdout.setEncoding("utf8");
2034
+ child.stderr.setEncoding("utf8");
2035
+ child.stdout.on("data", (value) => {
2036
+ stdout += value;
2037
+ });
2038
+ child.stderr.on("data", (value) => {
2039
+ stderr += value;
2040
+ });
2041
+ child.once("error", (error) => {
2042
+ resolve({
2043
+ code: 127,
2044
+ stdout,
2045
+ stderr: error.message
2046
+ });
2047
+ });
2048
+ child.once("close", (code) => {
2049
+ resolve({
2050
+ code: code ?? 1,
2051
+ stdout,
2052
+ stderr
2053
+ });
2054
+ });
2055
+ });
2056
+ }
2057
+ function commandFailure(command, result) {
2058
+ const detail = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n");
2059
+ return `${command} failed${detail ? `: ${detail}` : ` with status ${result.code}`}`;
2060
+ }
2061
+ async function writeJson(filePath, value) {
2062
+ await fs.mkdir(path.dirname(filePath), {
2063
+ recursive: true,
2064
+ mode: 448
2065
+ });
2066
+ const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
2067
+ await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
2068
+ await fs.rename(temporaryPath, filePath);
2069
+ }
2070
+ async function readOperation(filePath) {
2071
+ return fs.readFile(filePath, "utf8").then((value) => operationSchema.safeParse(JSON.parse(value))).then((result) => result.success ? result.data : null).catch(() => null);
2072
+ }
2073
+ async function readLocalUpdateProgress(dataDir) {
2074
+ const updateDirectory = path.join(dataDir, "updates");
2075
+ const [operation, lock] = await Promise.all([readOperation(path.join(updateDirectory, "operation.json")), fs.readFile(path.join(updateDirectory, "update.lock"), "utf8").then((value) => lockSchema.safeParse(JSON.parse(value))).then((result) => result.success ? result.data : null).catch(() => null)]);
2076
+ const active = Boolean(lock && processExists(lock.pid));
2077
+ const operationMatchesLock = !lock || operation?.operationId === lock.operationId;
2078
+ return {
2079
+ active,
2080
+ operationId: operationMatchesLock ? operation?.operationId ?? lock?.operationId ?? null : lock?.operationId ?? null,
2081
+ phase: operationMatchesLock ? operation?.phase ?? null : null,
2082
+ fromVersion: operationMatchesLock ? operation?.fromVersion ?? lock?.fromVersion ?? null : lock?.fromVersion ?? null,
2083
+ toVersion: operationMatchesLock ? operation?.toVersion ?? null : null,
2084
+ recoveryAction: operationMatchesLock ? operation?.recoveryAction ?? null : null,
2085
+ migrationState: operationMatchesLock ? operation?.migrationState ?? null : null
2086
+ };
2087
+ }
2088
+ function isCanonicalTreeportVersion(version) {
2089
+ return VERSION.test(version);
2090
+ }
2091
+ function compareTreeportVersions(left, right) {
2092
+ const leftMatch = VERSION.exec(left);
2093
+ const rightMatch = VERSION.exec(right);
2094
+ if (!leftMatch || !rightMatch) throw new LocalUpdateError("UPDATE_RELEASE_INVALID", `Treeport update requires canonical stable versions; found ${left} and ${right}.`, {
2095
+ fromVersion: left,
2096
+ toVersion: right
2097
+ });
2098
+ for (let index = 1; index <= 3; index += 1) {
2099
+ const difference = Number(leftMatch[index]) - Number(rightMatch[index]);
2100
+ if (difference !== 0) return difference;
2101
+ }
2102
+ return 0;
2103
+ }
2104
+ async function replaceSymlink(linkPath, target) {
2105
+ const temporaryPath = `${linkPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
2106
+ await fs.symlink(target, temporaryPath);
2107
+ await fs.rename(temporaryPath, linkPath);
2108
+ }
2109
+ async function terminalIds(apiUrl) {
2110
+ const result = await fetch(`${apiUrl}/api/projects`).then(async (response) => response.ok ? response.json() : null).catch(() => null);
2111
+ const parsed = z.looseObject({ projects: z.array(z.looseObject({ worktrees: z.array(z.looseObject({ terminals: z.array(z.looseObject({ id: z.string() })) })) })) }).safeParse(result);
2112
+ if (!parsed.success) throw new Error("Treeport could not read the terminal inventory.");
2113
+ return parsed.data.projects.flatMap((project) => project.worktrees).flatMap((worktree) => worktree.terminals).map((terminal) => terminal.id).sort();
2114
+ }
2115
+ async function startThroughStableEntrypoint(entrypoint, environment) {
2116
+ const result = await runCommand(entrypoint, ["start", "--json"], environment);
2117
+ if (result.code !== 0) throw new Error(commandFailure("treeport start", result));
2118
+ }
2119
+ async function inspectLocalUpdateInstallation(environment = process.env) {
2120
+ const entrypointValue = environment.TREEPORT_CLI_ENTRYPOINT?.trim();
2121
+ if (!entrypointValue || !path.isAbsolute(entrypointValue)) throw new LocalUpdateError("UPDATE_INSTALLATION_UNSUPPORTED", "Treeport could not identify a stable npm CLI entrypoint. Reinstall Treeport globally with npm, then retry.", { phase: "inspect" });
2122
+ const npm = await runCommand("npm", ["prefix", "--global"], environment);
2123
+ if (npm.code !== 0 || !npm.stdout.trim()) throw new LocalUpdateError("UPDATE_INSTALLATION_UNSUPPORTED", commandFailure("npm prefix --global", npm), { phase: "inspect" });
2124
+ const prefix = path.resolve(npm.stdout.trim());
2125
+ const entrypoint = path.resolve(entrypointValue);
2126
+ if (entrypoint !== path.join(prefix, "bin", "treeport")) throw new LocalUpdateError("UPDATE_INSTALLATION_UNSUPPORTED", `The active Treeport command is not in the current global npm prefix: ${entrypoint}`, {
2127
+ phase: "inspect",
2128
+ entrypoint,
2129
+ npmPrefix: prefix
2130
+ });
2131
+ const packageDirectory = path.dirname(await resolvePackagePath("package.json"));
2132
+ const directPackage = path.join(prefix, "lib", "node_modules", "@treeport", "treeport");
2133
+ const managedRoot = path.join(prefix, "lib", "treeport");
2134
+ const currentLink = path.join(managedRoot, "current");
2135
+ const managedPackage = path.join(currentLink, "lib", "node_modules", "@treeport", "treeport");
2136
+ const [actualPackage, actualDirect, actualManaged] = await Promise.all([
2137
+ fs.realpath(packageDirectory),
2138
+ fs.realpath(directPackage).catch(() => null),
2139
+ fs.realpath(managedPackage).catch(() => null)
2140
+ ]);
2141
+ if (actualPackage !== actualDirect && actualPackage !== actualManaged) throw new LocalUpdateError("UPDATE_INSTALLATION_UNSUPPORTED", "The active Treeport package does not belong to the current global npm prefix. Reinstall Treeport globally with npm, then retry.", {
2142
+ phase: "inspect",
2143
+ npmPrefix: prefix
2144
+ });
2145
+ const manifest = await fs.readFile(path.join(packageDirectory, "package.json"), "utf8").then((value) => packageSchema.safeParse(JSON.parse(value))).catch(() => null);
2146
+ if (!manifest?.success || !VERSION.test(manifest.data.version)) throw new LocalUpdateError("UPDATE_INSTALLATION_UNSUPPORTED", "The active Treeport package manifest is invalid.", { phase: "inspect" });
2147
+ await Promise.all([
2148
+ fs.access(entrypoint, constants.X_OK),
2149
+ fs.access(process.execPath, constants.X_OK),
2150
+ fs.mkdir(managedRoot, {
2151
+ recursive: true,
2152
+ mode: 448
2153
+ }),
2154
+ fs.mkdir(path.join(managedRoot, "versions"), {
2155
+ recursive: true,
2156
+ mode: 448
2157
+ })
2158
+ ]).catch((error) => {
2159
+ throw new LocalUpdateError("UPDATE_INSTALLATION_NOT_WRITABLE", "The global npm installation is not writable. Install Node and npm under your user account, reinstall Treeport globally, and retry.", {
2160
+ phase: "inspect",
2161
+ npmPrefix: prefix,
2162
+ cause: error instanceof Error ? error.message : String(error)
2163
+ });
2164
+ });
2165
+ const writeProbe = path.join(path.dirname(entrypoint), `.treeport-update-${process.pid}-${crypto.randomUUID()}`);
2166
+ await fs.writeFile(writeProbe, "", {
2167
+ mode: 384,
2168
+ flag: "wx"
2169
+ }).then(() => fs.rename(writeProbe, `${writeProbe}.renamed`)).then(() => fs.rm(`${writeProbe}.renamed`, { force: true })).catch(async (error) => {
2170
+ await fs.rm(writeProbe, { force: true });
2171
+ await fs.rm(`${writeProbe}.renamed`, { force: true });
2172
+ throw new LocalUpdateError("UPDATE_INSTALLATION_NOT_WRITABLE", "The global npm bin directory is not writable. Install Node and npm under your user account, reinstall Treeport globally, and retry.", {
2173
+ phase: "inspect",
2174
+ npmPrefix: prefix,
2175
+ cause: error instanceof Error ? error.message : String(error)
2176
+ });
2177
+ });
2178
+ return {
2179
+ prefix,
2180
+ packageDirectory,
2181
+ entrypoint,
2182
+ version: manifest.data.version,
2183
+ managedRoot,
2184
+ currentLink,
2185
+ versionsDirectory: path.join(managedRoot, "versions"),
2186
+ managed: actualPackage === actualManaged
2187
+ };
2188
+ }
2189
+ async function resolveLatestTreeportRelease(environment = process.env, operationId) {
2190
+ const releaseCommand = await runCommand("npm", [
2191
+ "view",
2192
+ `${PACKAGE_NAME}@latest`,
2193
+ "--json"
2194
+ ], environment);
2195
+ if (releaseCommand.code !== 0) throw new LocalUpdateError("UPDATE_RELEASE_RESOLUTION_FAILED", commandFailure("npm view", releaseCommand), operationId ? {
2196
+ phase: "resolve",
2197
+ operationId
2198
+ } : { phase: "resolve" });
2199
+ const release = await Promise.resolve(releaseCommand.stdout).then((value) => releaseSchema.safeParse(JSON.parse(value))).catch(() => null);
2200
+ if (!release?.success || !VERSION.test(release.data.version)) throw new LocalUpdateError("UPDATE_RELEASE_INVALID", "npm returned an invalid Treeport stable release.", operationId ? {
2201
+ phase: "resolve",
2202
+ operationId
2203
+ } : { phase: "resolve" });
2204
+ return release.data;
2205
+ }
2206
+ async function runLocalUpdate(options = {}) {
2207
+ const environment = options.environment ?? process.env;
2208
+ const progress = options.progress ?? (() => void 0);
2209
+ const explicitApiUrl = environment.TREEPORT_API_URL?.trim();
2210
+ if (explicitApiUrl) {
2211
+ const parsed = URL.canParse(explicitApiUrl) ? new URL(explicitApiUrl) : null;
2212
+ if (!parsed || ![
2213
+ "127.0.0.1",
2214
+ "localhost",
2215
+ "::1",
2216
+ "[::1]"
2217
+ ].includes(parsed.hostname)) throw new LocalUpdateError("UPDATE_REMOTE_REFUSED", "Run `treeport update` on the computer that owns the selected Treeport daemon.", {
2218
+ phase: "inspect",
2219
+ apiUrl: explicitApiUrl
2220
+ });
2221
+ }
2222
+ if (environment.TREEPORT_DAEMON_LIFECYCLE?.trim() === "external") throw new LocalUpdateError("UPDATE_EXTERNAL_REFUSED", "Cannot update Treeport because this daemon lifecycle is externally managed.", { phase: "inspect" });
2223
+ const paths = localPaths(environment);
2224
+ const updateDirectory = path.join(paths.dataDir, "updates");
2225
+ const lockPath = path.join(updateDirectory, "update.lock");
2226
+ const operationPath = path.join(updateDirectory, "operation.json");
2227
+ await fs.mkdir(updateDirectory, {
2228
+ recursive: true,
2229
+ mode: 448
2230
+ });
2231
+ const operationId = crypto.randomUUID();
2232
+ const staleOperation = await readOperation(operationPath);
2233
+ const provisionalVersion = await fs.readFile(await resolvePackagePath("package.json"), "utf8").then((value) => packageSchema.parse(JSON.parse(value)).version);
2234
+ const lock = {
2235
+ operationId,
2236
+ pid: process.pid,
2237
+ fromVersion: provisionalVersion,
2238
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
2239
+ };
2240
+ if (!await fs.open(lockPath, "wx", 384).then(async (file) => {
2241
+ await file.writeFile(`${JSON.stringify(lock)}\n`);
2242
+ await file.close();
2243
+ return true;
2244
+ }).catch(async (error) => {
2245
+ if (error.code !== "EEXIST") throw error;
2246
+ const owner = await fs.readFile(lockPath, "utf8").then((value) => lockSchema.safeParse(JSON.parse(value))).then((result) => result.success ? result.data : null).catch(() => null);
2247
+ if (owner && processExists(owner.pid)) throw new LocalUpdateError("UPDATE_IN_PROGRESS", `Treeport update ${owner.operationId} is already running.`, {
2248
+ phase: "inspect",
2249
+ operationId: owner.operationId
2250
+ });
2251
+ await fs.rm(lockPath, { force: true });
2252
+ const file = await fs.open(lockPath, "wx", 384);
2253
+ await file.writeFile(`${JSON.stringify(lock)}\n`);
2254
+ await file.close();
2255
+ return true;
2256
+ })) throw new LocalUpdateError("UPDATE_IN_PROGRESS", "Another Treeport update is already running.", { phase: "inspect" });
2257
+ let interrupted = false;
2258
+ const interrupt = () => {
2259
+ interrupted = true;
2260
+ };
2261
+ process.on("SIGINT", interrupt);
2262
+ process.on("SIGTERM", interrupt);
2263
+ let operation = {
2264
+ schemaVersion: 1,
2265
+ operationId,
2266
+ phase: "inspect",
2267
+ fromVersion: provisionalVersion,
2268
+ toVersion: null,
2269
+ npmPrefix: null,
2270
+ activeTarget: null,
2271
+ stagedTarget: null,
2272
+ previousTarget: null,
2273
+ daemonWasRunning: false,
2274
+ daemonLifecycle: null,
2275
+ serviceMode: null,
2276
+ terminalIds: [],
2277
+ activated: false,
2278
+ migrationState: "not_started",
2279
+ rollbackAttempted: false,
2280
+ rollbackSucceeded: false,
2281
+ recoveryAction: null,
2282
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2283
+ };
2284
+ let recoveryOperation = null;
2285
+ const save = async (phase) => {
2286
+ operation = {
2287
+ ...operation,
2288
+ phase,
2289
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2290
+ };
2291
+ if (recoveryOperation && !DESTRUCTIVE_PHASES.has(phase)) return;
2292
+ await writeJson(operationPath, operation);
2293
+ };
2294
+ let installation = null;
2295
+ try {
2296
+ installation = await inspectLocalUpdateInstallation(environment);
2297
+ operation = {
2298
+ ...operation,
2299
+ fromVersion: installation.version,
2300
+ npmPrefix: installation.prefix,
2301
+ activeTarget: installation.managed ? await fs.realpath(installation.currentLink).catch(() => installation.prefix) : installation.prefix
2302
+ };
2303
+ if (staleOperation && staleOperation.daemonWasRunning && DESTRUCTIVE_PHASES.has(staleOperation.phase) && !(await daemonStatus()).running) {
2304
+ const staleReport = await readUpdateStartupReport(paths.dataDir);
2305
+ if (Boolean(staleReport?.operationId === staleOperation.operationId && ["advanced", "unknown"].includes(staleReport.migrationState))) {
2306
+ if (staleOperation.previousTarget && operation.activeTarget === staleOperation.previousTarget) throw new LocalUpdateError("UPDATE_RECOVERY_REQUIRED", "The older Treeport version is active after a database migration may have started. Treeport will not start it.", {
2307
+ phase: "recovery_required",
2308
+ operationId: staleOperation.operationId,
2309
+ migrationState: staleReport?.migrationState ?? "unknown",
2310
+ recovery: "Install the same or a newer Treeport release and inspect the daemon log."
2311
+ });
2312
+ recoveryOperation = staleOperation;
2313
+ } else {
2314
+ if (staleOperation.previousTarget) await replaceSymlink(installation.currentLink, staleOperation.previousTarget);
2315
+ await fs.rm(path.join(updateDirectory, "pending-startup.json"), { force: true });
2316
+ await fs.rm(path.join(updateDirectory, "startup-report.json"), { force: true });
2317
+ await startThroughStableEntrypoint(installation.entrypoint, environment).catch((error) => {
2318
+ throw new LocalUpdateError("UPDATE_RECOVERY_REQUIRED", "Treeport restored the previous version but could not restart its daemon.", {
2319
+ phase: "recovery_required",
2320
+ operationId: staleOperation.operationId,
2321
+ cause: error instanceof Error ? error.message : String(error),
2322
+ recovery: "Inspect the daemon log, then run `treeport start`."
2323
+ });
2324
+ });
2325
+ await writeJson(operationPath, {
2326
+ ...staleOperation,
2327
+ phase: "complete",
2328
+ activated: false,
2329
+ rollbackAttempted: true,
2330
+ rollbackSucceeded: true,
2331
+ recoveryAction: "Run `treeport update` again.",
2332
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2333
+ });
2334
+ throw new LocalUpdateError("UPDATE_ROLLED_BACK", "Treeport recovered the interrupted update and restored the previous running version. Run `treeport update` again.", {
2335
+ phase: "rollback",
2336
+ operationId: staleOperation.operationId,
2337
+ migrationState: staleReport?.migrationState ?? "not_started",
2338
+ rollback: {
2339
+ attempted: true,
2340
+ safe: true,
2341
+ succeeded: true
2342
+ },
2343
+ recovery: "Run `treeport update` again."
2344
+ });
2345
+ }
2346
+ }
2347
+ await save("inspect");
2348
+ const initialDaemon = await daemonStatus();
2349
+ if (initialDaemon.state && !initialDaemon.verified) throw new LocalUpdateError("UPDATE_DAEMON_OWNERSHIP_FAILED", "Treeport found a daemon whose ownership or health could not be verified.", {
2350
+ phase: "inspect",
2351
+ operationId,
2352
+ pid: initialDaemon.state.pid
2353
+ });
2354
+ if (initialDaemon.health?.daemonLifecycle === "external") throw new LocalUpdateError("UPDATE_EXTERNAL_REFUSED", "Cannot update Treeport because this daemon lifecycle is externally managed.", {
2355
+ phase: "inspect",
2356
+ operationId
2357
+ });
2358
+ if (explicitApiUrl && initialDaemon.state) {
2359
+ const selectedUrl = new URL(explicitApiUrl);
2360
+ const localUrl = new URL(initialDaemon.state.apiUrl);
2361
+ if (selectedUrl.protocol !== localUrl.protocol || (selectedUrl.port || "80") !== (localUrl.port || "80")) throw new LocalUpdateError("UPDATE_REMOTE_REFUSED", "The selected daemon is not the verified local Treeport daemon. Run the update against the local daemon.", {
2362
+ phase: "inspect",
2363
+ operationId,
2364
+ apiUrl: explicitApiUrl
2365
+ });
2366
+ }
2367
+ const installedService = await serviceInstalled();
2368
+ const serviceBefore = installedService ? await serviceStatus() : null;
2369
+ if (serviceBefore?.mode === "headless" && (serviceBefore.active || initialDaemon.running)) throw new LocalUpdateError("UPDATE_SERVICE_ADMINISTRATOR_ACTION_REQUIRED", "Stop the advanced headless service with its administrator action, then run `treeport update` again.", {
2370
+ phase: "inspect",
2371
+ operationId,
2372
+ mode: "headless"
2373
+ });
2374
+ if (installedService && initialDaemon.running && initialDaemon.health?.daemonLifecycle !== "service") throw new LocalUpdateError("UPDATE_DAEMON_OWNERSHIP_FAILED", "The running daemon does not belong to the installed Treeport service lifecycle.", {
2375
+ phase: "inspect",
2376
+ operationId
2377
+ });
2378
+ await save("resolve");
2379
+ progress("Resolving the latest Treeport release…");
2380
+ const release = await resolveLatestTreeportRelease(environment, operationId);
2381
+ operation.toVersion = release.version;
2382
+ const comparison = compareTreeportVersions(release.version, installation.version);
2383
+ if (comparison < 0) throw new LocalUpdateError("UPDATE_DOWNGRADE_REFUSED", `Treeport will not downgrade from ${installation.version} to ${release.version}.`, {
2384
+ phase: "resolve",
2385
+ operationId
2386
+ });
2387
+ if (comparison === 0 && recoveryOperation) throw new LocalUpdateError("UPDATE_RECOVERY_REQUIRED", "Treeport needs a newer release to recover after the interrupted database migration.", {
2388
+ phase: "recovery_required",
2389
+ operationId: recoveryOperation.operationId,
2390
+ migrationState: recoveryOperation.migrationState,
2391
+ recovery: "Install the next Treeport release when it is available and run `treeport update` again."
2392
+ });
2393
+ if (comparison === 0) {
2394
+ const currentTerminals = initialDaemon.verified ? await terminalIds(initialDaemon.state.apiUrl) : [];
2395
+ const currentLifecycle = initialDaemon.verified ? initialDaemon.health.daemonLifecycle === "service" ? "service" : initialDaemon.health.daemonLifecycle === "treeport" ? "treeport" : null : installedService ? "service" : "treeport";
2396
+ await save("complete");
2397
+ return {
2398
+ schemaVersion: 1,
2399
+ operationId,
2400
+ status: "current",
2401
+ phase: "complete",
2402
+ fromVersion: installation.version,
2403
+ toVersion: release.version,
2404
+ installation: { method: "npm" },
2405
+ daemon: {
2406
+ wasRunning: initialDaemon.verified,
2407
+ lifecycle: currentLifecycle,
2408
+ restarted: false,
2409
+ healthy: initialDaemon.verified,
2410
+ version: initialDaemon.health?.version ?? null
2411
+ },
2412
+ terminals: {
2413
+ before: currentTerminals.length,
2414
+ after: currentTerminals.length,
2415
+ preserved: true
2416
+ },
2417
+ rollback: {
2418
+ attempted: false,
2419
+ safe: true,
2420
+ succeeded: false
2421
+ }
2422
+ };
2423
+ }
2424
+ const stagingPath = path.join(installation.managedRoot, `.staging-${release.version}-${operationId}`);
2425
+ const targetPath = path.join(installation.versionsDirectory, release.version);
2426
+ operation.stagedTarget = stagingPath;
2427
+ await save("stage");
2428
+ progress(`Downloading Treeport ${release.version}…`);
2429
+ await fs.rm(stagingPath, {
2430
+ recursive: true,
2431
+ force: true
2432
+ });
2433
+ const downloadPath = path.join(installation.managedRoot, `.download-${operationId}`);
2434
+ await fs.rm(downloadPath, {
2435
+ recursive: true,
2436
+ force: true
2437
+ });
2438
+ await fs.mkdir(downloadPath, {
2439
+ recursive: true,
2440
+ mode: 448
2441
+ });
2442
+ const packed = await runCommand("npm", [
2443
+ "pack",
2444
+ `${PACKAGE_NAME}@${release.version}`,
2445
+ "--json",
2446
+ "--ignore-scripts",
2447
+ "--pack-destination",
2448
+ downloadPath
2449
+ ], environment);
2450
+ const packedRelease = await Promise.resolve(packed.stdout).then((value) => packedReleaseSchema.safeParse(packed.code === 0 ? JSON.parse(value) : null)).catch(() => null);
2451
+ if (!packedRelease?.success || packedRelease.data[0].integrity !== release.dist.integrity || path.basename(packedRelease.data[0].filename) !== packedRelease.data[0].filename) throw new LocalUpdateError("UPDATE_STAGING_FAILED", packed.code === 0 ? "The downloaded Treeport package did not match npm release integrity." : commandFailure("npm pack", packed), {
2452
+ phase: "stage",
2453
+ operationId,
2454
+ toVersion: release.version
2455
+ });
2456
+ const install = await runCommand("npm", [
2457
+ "install",
2458
+ "--global",
2459
+ "--prefix",
2460
+ stagingPath,
2461
+ "--ignore-scripts",
2462
+ "--no-audit",
2463
+ "--no-fund",
2464
+ path.join(downloadPath, packedRelease.data[0].filename)
2465
+ ], environment);
2466
+ await fs.rm(downloadPath, {
2467
+ recursive: true,
2468
+ force: true
2469
+ });
2470
+ if (install.code !== 0) throw new LocalUpdateError("UPDATE_STAGING_FAILED", commandFailure("npm install", install), {
2471
+ phase: "stage",
2472
+ operationId,
2473
+ toVersion: release.version
2474
+ });
2475
+ await save("verify");
2476
+ const stagedPackage = path.join(stagingPath, "lib", "node_modules", "@treeport", "treeport");
2477
+ const stagedManifest = await fs.readFile(path.join(stagedPackage, "package.json"), "utf8").then((value) => packageSchema.safeParse(JSON.parse(value))).catch(() => null);
2478
+ if (!stagedManifest?.success || stagedManifest.data.version !== release.version) throw new LocalUpdateError("UPDATE_VERIFICATION_FAILED", "The staged Treeport package does not match the resolved release.", {
2479
+ phase: "verify",
2480
+ operationId,
2481
+ toVersion: release.version
2482
+ });
2483
+ await Promise.all([
2484
+ "bin/treeport.mjs",
2485
+ "dist/node/cli/index.js",
2486
+ "dist/node/server/index.js",
2487
+ "dist/web/index.html",
2488
+ "drizzle/meta/_journal.json",
2489
+ "skills/treeport/SKILL.md"
2490
+ ].map((item) => fs.access(path.join(stagedPackage, item), constants.R_OK))).catch((error) => {
2491
+ throw new LocalUpdateError("UPDATE_VERIFICATION_FAILED", `The staged Treeport package is incomplete: ${error instanceof Error ? error.message : String(error)}`, {
2492
+ phase: "verify",
2493
+ operationId,
2494
+ toVersion: release.version
2495
+ });
2496
+ });
2497
+ const verificationData = await fs.mkdtemp(path.join(os.tmpdir(), "treeport-update-verify-"));
2498
+ const stagedVersion = await runCommand(process.execPath, [
2499
+ path.join(stagedPackage, "dist", "node", "cli", "index.js"),
2500
+ "version",
2501
+ "--json"
2502
+ ], {
2503
+ ...environment,
2504
+ TREEPORT_API_URL: "",
2505
+ TREEPORT_DATA_DIR: path.join(verificationData, "data"),
2506
+ TREEPORT_RUNTIME_DIR: path.join(verificationData, "runtime"),
2507
+ TREEPORT_CLI_ENTRYPOINT: path.join(stagingPath, "bin", "treeport")
2508
+ });
2509
+ await fs.rm(verificationData, {
2510
+ recursive: true,
2511
+ force: true
2512
+ });
2513
+ const verifiedVersion = await Promise.resolve(stagedVersion.stdout).then((value) => z.strictObject({
2514
+ cli: z.string(),
2515
+ daemon: z.string().nullable()
2516
+ }).safeParse(stagedVersion.code === 0 ? JSON.parse(value) : null)).catch(() => null);
2517
+ if (!verifiedVersion?.success || verifiedVersion.data.cli !== release.version) throw new LocalUpdateError("UPDATE_VERIFICATION_FAILED", `The staged Treeport CLI did not report version ${release.version}.`, {
2518
+ phase: "verify",
2519
+ operationId,
2520
+ toVersion: release.version
2521
+ });
2522
+ const daemonBefore = await daemonStatus();
2523
+ if (daemonBefore.state && !daemonBefore.verified) throw new LocalUpdateError("UPDATE_DAEMON_OWNERSHIP_FAILED", "Treeport daemon ownership changed while the update was staged.", {
2524
+ phase: "verify",
2525
+ operationId,
2526
+ pid: daemonBefore.state.pid
2527
+ });
2528
+ if (daemonBefore.running !== initialDaemon.running || daemonBefore.state?.instanceId !== initialDaemon.state?.instanceId) throw new LocalUpdateError("UPDATE_DAEMON_OWNERSHIP_FAILED", "Treeport daemon state changed while the update was staged. Retry the update.", {
2529
+ phase: "verify",
2530
+ operationId
2531
+ });
2532
+ operation.daemonWasRunning = daemonBefore.running && daemonBefore.verified || recoveryOperation !== null;
2533
+ operation.daemonLifecycle = recoveryOperation ? recoveryOperation.daemonLifecycle : operation.daemonWasRunning ? daemonBefore.health?.daemonLifecycle === "service" ? "service" : "treeport" : installedService ? "service" : "treeport";
2534
+ operation.serviceMode = recoveryOperation?.serviceMode ?? serviceBefore?.mode ?? null;
2535
+ operation.terminalIds = recoveryOperation ? recoveryOperation.terminalIds : operation.daemonWasRunning ? await terminalIds(daemonBefore.state.apiUrl) : [];
2536
+ if (interrupted) throw new LocalUpdateError("UPDATE_INTERRUPTED", "Treeport update was interrupted before activation. The installed version and daemon are unchanged.", {
2537
+ phase: "verify",
2538
+ operationId
2539
+ });
2540
+ await save("stop");
2541
+ progress("Stopping the Treeport daemon and preserving terminals…");
2542
+ if (operation.daemonWasRunning) if (operation.daemonLifecycle === "service") {
2543
+ if ((await serviceStop()).administratorCommand) throw new LocalUpdateError("UPDATE_SERVICE_ADMINISTRATOR_ACTION_REQUIRED", "The service requires administrator action and was not stopped.", {
2544
+ phase: "stop",
2545
+ operationId
2546
+ });
2547
+ } else await daemonDown();
2548
+ await save("activate");
2549
+ progress(`Activating Treeport ${release.version}…`);
2550
+ await fs.rm(targetPath, {
2551
+ recursive: true,
2552
+ force: true
2553
+ });
2554
+ await fs.rename(stagingPath, targetPath);
2555
+ operation.stagedTarget = targetPath;
2556
+ if (!await fs.lstat(installation.currentLink).then(() => true).catch(() => false)) await fs.symlink(installation.prefix, installation.currentLink);
2557
+ else if (!installation.managed) await replaceSymlink(installation.currentLink, installation.prefix);
2558
+ operation.previousTarget = await fs.realpath(installation.currentLink);
2559
+ await save("activate");
2560
+ const launcher = `#!/bin/sh\nset -eu\n# TREEPORT_MANAGED_LAUNCHER=1\nexport TREEPORT_INSTALLATION_METHOD=npm\nexport TREEPORT_CLI_ENTRYPOINT=${shellQuote(installation.entrypoint)}\nexec ${shellQuote(process.execPath)} ${shellQuote(path.join(installation.currentLink, "lib", "node_modules", "@treeport", "treeport", "bin", "treeport.mjs"))} "$@"\n`;
2561
+ const temporaryLauncher = `${installation.entrypoint}.${process.pid}.${operationId}.tmp`;
2562
+ await fs.writeFile(temporaryLauncher, launcher, { mode: 493 });
2563
+ await fs.chmod(temporaryLauncher, 493);
2564
+ await fs.rename(temporaryLauncher, installation.entrypoint);
2565
+ await replaceSymlink(installation.currentLink, targetPath);
2566
+ operation.activeTarget = targetPath;
2567
+ operation.activated = true;
2568
+ await save("activate");
2569
+ let daemonAfter = null;
2570
+ let terminalsAfter = [];
2571
+ if (operation.daemonWasRunning) {
2572
+ await writeJson(path.join(updateDirectory, "pending-startup.json"), {
2573
+ schemaVersion: 1,
2574
+ operationId,
2575
+ targetVersion: release.version,
2576
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2577
+ });
2578
+ await fs.rm(path.join(updateDirectory, "startup-report.json"), { force: true });
2579
+ await save("restart");
2580
+ progress(`Restarting the ${operation.daemonLifecycle === "service" ? "Treeport service" : "Treeport daemon"}…`);
2581
+ await startThroughStableEntrypoint(installation.entrypoint, environment);
2582
+ await save("health_check");
2583
+ const healthDeadline = Date.now() + 1e4;
2584
+ let report = await readUpdateStartupReport(paths.dataDir);
2585
+ daemonAfter = await daemonStatus();
2586
+ while (Date.now() < healthDeadline && (!daemonAfter.verified || daemonAfter.health?.version !== release.version || report?.operationId !== operationId || !report.ready)) {
2587
+ await new Promise((resolve) => setTimeout(resolve, 100));
2588
+ daemonAfter = await daemonStatus();
2589
+ report = await readUpdateStartupReport(paths.dataDir);
2590
+ }
2591
+ operation.migrationState = report?.operationId === operationId ? report.migrationState : "unknown";
2592
+ if (!daemonAfter.running || !daemonAfter.verified || daemonAfter.health?.version !== release.version || daemonAfter.health.daemonLifecycle !== operation.daemonLifecycle || path.resolve(daemonAfter.state.dataDir) !== paths.dataDir || report?.operationId !== operationId || !report.ready) throw new LocalUpdateError("UPDATE_HEALTH_VERIFICATION_FAILED", `Treeport ${release.version} did not pass startup verification.`, {
2593
+ phase: "health_check",
2594
+ operationId
2595
+ });
2596
+ if (operation.daemonLifecycle === "service") {
2597
+ const serviceAfter = await serviceStatus();
2598
+ if (!serviceAfter.healthy || !serviceAfter.installed || serviceAfter.definitionPath !== serviceBefore?.definitionPath || serviceAfter.mode !== serviceBefore.mode) throw new LocalUpdateError("UPDATE_HEALTH_VERIFICATION_FAILED", "The Treeport service did not preserve its enabled configuration.", {
2599
+ phase: "health_check",
2600
+ operationId
2601
+ });
2602
+ }
2603
+ terminalsAfter = await terminalIds(daemonAfter.state.apiUrl);
2604
+ const missing = operation.terminalIds.filter((terminalId) => !terminalsAfter.includes(terminalId));
2605
+ if (missing.length > 0) throw new LocalUpdateError("UPDATE_TERMINAL_VERIFICATION_FAILED", "Treeport restarted, but one or more terminal sessions were not recovered.", {
2606
+ phase: "health_check",
2607
+ operationId,
2608
+ terminalIds: missing
2609
+ });
2610
+ } else await fs.rm(path.join(updateDirectory, "pending-startup.json"), { force: true });
2611
+ await save("complete");
2612
+ const removable = (await fs.readdir(installation.versionsDirectory, { withFileTypes: true }).then((entries) => entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)).catch(() => [])).filter((name) => name !== release.version && path.join(installation.versionsDirectory, name) !== operation.previousTarget);
2613
+ await Promise.all(removable.map((name) => fs.rm(path.join(installation.versionsDirectory, name), {
2614
+ recursive: true,
2615
+ force: true
2616
+ }))).catch(() => void 0);
2617
+ return {
2618
+ schemaVersion: 1,
2619
+ operationId,
2620
+ status: "updated",
2621
+ phase: "complete",
2622
+ fromVersion: installation.version,
2623
+ toVersion: release.version,
2624
+ installation: { method: "npm" },
2625
+ daemon: {
2626
+ wasRunning: operation.daemonWasRunning,
2627
+ lifecycle: operation.daemonLifecycle,
2628
+ restarted: operation.daemonWasRunning,
2629
+ healthy: operation.daemonWasRunning ? Boolean(daemonAfter?.verified) : false,
2630
+ version: daemonAfter?.health?.version ?? null
2631
+ },
2632
+ terminals: {
2633
+ before: operation.terminalIds.length,
2634
+ after: terminalsAfter.length,
2635
+ preserved: operation.terminalIds.every((id) => terminalsAfter.includes(id))
2636
+ },
2637
+ rollback: {
2638
+ attempted: false,
2639
+ safe: true,
2640
+ succeeded: false
2641
+ }
2642
+ };
2643
+ } catch (error) {
2644
+ const failedPhase = operation.phase;
2645
+ if (!DESTRUCTIVE_PHASES.has(operation.phase)) {
2646
+ if (error instanceof LocalUpdateError) throw error;
2647
+ throw new LocalUpdateError(operation.phase === "resolve" ? "UPDATE_RELEASE_RESOLUTION_FAILED" : operation.phase === "stage" ? "UPDATE_STAGING_FAILED" : operation.phase === "verify" ? "UPDATE_VERIFICATION_FAILED" : "UPDATE_INSTALLATION_UNSUPPORTED", error instanceof Error ? error.message : String(error), {
2648
+ phase: operation.phase,
2649
+ operationId,
2650
+ fromVersion: operation.fromVersion,
2651
+ toVersion: operation.toVersion
2652
+ });
2653
+ }
2654
+ const startupReport = await readUpdateStartupReport(paths.dataDir);
2655
+ if (startupReport?.operationId === operationId) operation.migrationState = startupReport.migrationState;
2656
+ if (!["not_started", "unchanged"].includes(operation.migrationState)) {
2657
+ const serviceStopError = operation.daemonLifecycle === "service" ? await serviceStop().then(() => null, (cause) => cause instanceof Error ? cause.message : String(cause)) : null;
2658
+ operation.recoveryAction = serviceStopError ? `Keep the new version installed. Stop the service, then inspect the daemon log. Service stop failed: ${serviceStopError}` : "Keep the new version installed. Inspect the daemon log and repair with the same or a newer Treeport release.";
2659
+ await save("recovery_required");
2660
+ throw new LocalUpdateError("UPDATE_RECOVERY_REQUIRED", "The updated daemon did not become healthy after database migration began. Treeport did not start the older daemon.", {
2661
+ operationId,
2662
+ phase: failedPhase,
2663
+ fromVersion: operation.fromVersion,
2664
+ toVersion: operation.toVersion,
2665
+ migrationState: operation.migrationState,
2666
+ rollback: {
2667
+ attempted: false,
2668
+ safe: false,
2669
+ succeeded: false
2670
+ },
2671
+ logPath: startupReport?.logPath ?? paths.logPath,
2672
+ snapshotPaths: startupReport?.snapshotPaths ?? [],
2673
+ recovery: operation.recoveryAction
2674
+ });
2675
+ }
2676
+ operation.rollbackAttempted = true;
2677
+ await save("rollback");
2678
+ if (!installation) throw error;
2679
+ const rollbackError = await (async () => {
2680
+ if (operation.previousTarget) await replaceSymlink(installation.currentLink, operation.previousTarget);
2681
+ await fs.rm(path.join(updateDirectory, "pending-startup.json"), { force: true });
2682
+ if (operation.daemonWasRunning) await startThroughStableEntrypoint(installation.entrypoint, environment);
2683
+ })().then(() => null, (cause) => cause);
2684
+ operation.rollbackSucceeded = rollbackError === null;
2685
+ operation.recoveryAction = rollbackError ? "Inspect the active version and daemon log before starting Treeport." : "The previous Treeport version is active again.";
2686
+ await save(rollbackError ? "recovery_required" : "rollback");
2687
+ throw new LocalUpdateError(rollbackError ? "UPDATE_ROLLBACK_FAILED" : "UPDATE_ROLLED_BACK", rollbackError ? "The update failed and Treeport could not restore the previous running state." : "The update failed. Treeport restored the previous version.", {
2688
+ operationId,
2689
+ phase: failedPhase,
2690
+ fromVersion: operation.fromVersion,
2691
+ toVersion: operation.toVersion,
2692
+ migrationState: operation.migrationState,
2693
+ rollback: {
2694
+ attempted: true,
2695
+ safe: true,
2696
+ succeeded: rollbackError === null
2697
+ },
2698
+ cause: error instanceof Error ? error.message : String(error),
2699
+ recovery: operation.recoveryAction
2700
+ });
2701
+ } finally {
2702
+ process.off("SIGINT", interrupt);
2703
+ process.off("SIGTERM", interrupt);
2704
+ if (installation) await fs.rm(path.join(installation.managedRoot, `.download-${operationId}`), {
2705
+ recursive: true,
2706
+ force: true
2707
+ }).catch(() => void 0);
2708
+ if (!operation.activated && operation.stagedTarget && ["stage", "verify"].includes(operation.phase)) await fs.rm(operation.stagedTarget, {
2709
+ recursive: true,
2710
+ force: true
2711
+ }).catch(() => void 0);
2712
+ await fs.rm(lockPath, { force: true }).catch(() => void 0);
2713
+ }
2714
+ }
2715
+ //#endregion
2716
+ export { treeportVersion as A, disableTailscaleRemote as C, resolvePackagePath as D, resolveLocalApiUrl as E, parseDurationMs as M, runDoctor as O, daemonUp as S, readDaemonLogs as T, serviceStatus as _, readLocalUpdateProgress as a, daemonHealth as b, createUpdateStartupReporter as c, serviceDisable as d, serviceDoctorCheck as f, serviceStart as g, serviceRun as h, isCanonicalTreeportVersion as i, assertLoopbackHost as j, tailscaleRemoteStatus as k, readServiceLogs as l, serviceInstalled as m, compareTreeportVersions as n, resolveLatestTreeportRelease as o, serviceEnable as p, inspectLocalUpdateInstallation as r, runLocalUpdate as s, LocalUpdateError as t, serviceApply as u, serviceStop as v, enableTailscaleRemote as w, daemonStatus as x, daemonDown as y };