@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.
- package/README.md +3 -3
- package/dist/node/cli/index.js +118 -1612
- package/dist/node/server/core/launcher.js +35 -2
- package/dist/node/server/index.js +1193 -572
- package/dist/{shell-integration-7aBNr-p0.js → shell-integration-Be_c91lw.js} +17 -15
- package/dist/update-BW-a6Bd-.js +3107 -0
- package/dist/web/assets/index-Cr4UkmRD.js +146 -0
- package/dist/web/assets/index-he-SubzL.css +2 -0
- package/dist/web/index.html +2 -2
- package/dist/web/manifest.webmanifest +2 -2
- package/drizzle/0008_recent_project_visibility.sql +4 -0
- package/drizzle/0009_open_folders.sql +148 -0
- package/drizzle/meta/0008_snapshot.json +792 -0
- package/drizzle/meta/0009_snapshot.json +804 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +4 -4
- package/skills/treeport/SKILL.md +31 -22
- package/dist/loopback-Dyv_owrb.js +0 -407
- package/dist/web/assets/index-0q5frbNy.css +0 -2
- package/dist/web/assets/index-CUy0IkGL.js +0 -146
|
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
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 };
|