@treeport/treeport 0.3.0 → 0.5.0

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