@treeport/treeport 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/node/cli/index.js +47 -1589
- package/dist/node/server/core/launcher.js +16 -0
- package/dist/node/server/index.js +740 -210
- 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/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 +3 -3
- package/skills/treeport/SKILL.md +13 -4
- package/dist/loopback-D7k_J_Wl.js +0 -412
- package/dist/web/assets/index-DCtptjcH.js +0 -146
- package/dist/web/assets/index-Wj0w0nWP.css +0 -2
package/dist/node/cli/index.js
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { A as
|
|
2
|
+
import { A as treeportVersion, C as disableTailscaleRemote, D as resolvePackagePath, E as resolveLocalApiUrl, M as parseDurationMs, N as TERMINAL_CAPTURE_MAX_LINES, O as runDoctor, S as daemonUp, T as readDaemonLogs, _ as serviceStatus, b as daemonHealth, ct as parseEventsSnapshot, d as serviceDisable, f as serviceDoctorCheck, g as serviceStart, h as serviceRun, k as tailscaleRemoteStatus, l as readServiceLogs, lt as parseProductEvent, m as serviceInstalled, p as serviceEnable, s as runLocalUpdate, st as webPanelInputSchema, t as LocalUpdateError, u as serviceApply, ut as SOCKET_IO_PATH, v as serviceStop, w as enableTailscaleRemote, x as daemonStatus, y as daemonDown } from "../../update-BW-a6Bd-.js";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { Command, CommanderError } from "commander";
|
|
6
6
|
import { io } from "socket.io-client";
|
|
7
|
-
import { z } from "zod";
|
|
8
7
|
import { spawn } from "node:child_process";
|
|
9
|
-
import crypto from "node:crypto";
|
|
10
|
-
import fsSync, { constants } from "node:fs";
|
|
11
|
-
import os from "node:os";
|
|
12
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
8
|
//#region src/cli/args.ts
|
|
14
9
|
function extractJsonOutput(args) {
|
|
15
10
|
const separator = args.indexOf("--");
|
|
@@ -82,1568 +77,6 @@ async function openWorkspace(workspaceUrl, options = {}) {
|
|
|
82
77
|
throw new OpenWorkspaceError(`Treeport registered the folder, but could not open it automatically.${browser.stderr ? ` ${browser.stderr}` : ""}\nOpen this URL manually: ${workspaceUrl}`);
|
|
83
78
|
}
|
|
84
79
|
//#endregion
|
|
85
|
-
//#region src/cli/lifecycle.ts
|
|
86
|
-
const DEFAULT_HOST = "127.0.0.1";
|
|
87
|
-
const DEFAULT_PORT = 8733;
|
|
88
|
-
function listenerUrl(host, port) {
|
|
89
|
-
return `http://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`;
|
|
90
|
-
}
|
|
91
|
-
function expandHome(value) {
|
|
92
|
-
return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
|
|
93
|
-
}
|
|
94
|
-
function localPaths(env = process.env) {
|
|
95
|
-
const defaultDataDir = env.XDG_DATA_HOME ? path.join(expandHome(env.XDG_DATA_HOME), "treeport") : process.platform === "darwin" ? path.join(os.homedir(), "Library", "Application Support", "treeport") : path.join(os.homedir(), ".local", "share", "treeport");
|
|
96
|
-
const dataDir = path.resolve(expandHome(env.TREEPORT_DATA_DIR?.trim() || defaultDataDir));
|
|
97
|
-
const runtimeDir = path.resolve(expandHome(env.TREEPORT_RUNTIME_DIR?.trim() || (env.XDG_RUNTIME_DIR ? path.join(env.XDG_RUNTIME_DIR, "treeport") : path.join(os.tmpdir(), `treeport-${process.getuid?.() ?? "user"}`))));
|
|
98
|
-
return {
|
|
99
|
-
dataDir,
|
|
100
|
-
runtimeDir,
|
|
101
|
-
preferencesPath: path.join(dataDir, "config.json"),
|
|
102
|
-
statePath: path.join(runtimeDir, "daemon.json"),
|
|
103
|
-
lockPath: path.join(dataDir, "daemon.lock"),
|
|
104
|
-
logPath: path.join(dataDir, "logs", "daemon.log")
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
async function readJson$1(filePath, schema) {
|
|
108
|
-
return fs.readFile(filePath, "utf8").then((value) => schema.parse(JSON.parse(value))).catch(() => null);
|
|
109
|
-
}
|
|
110
|
-
const preferencesSchema = z.looseObject({
|
|
111
|
-
host: z.string().optional(),
|
|
112
|
-
port: z.number().optional(),
|
|
113
|
-
remote: z.strictObject({
|
|
114
|
-
port: z.number(),
|
|
115
|
-
target: z.string()
|
|
116
|
-
}).optional()
|
|
117
|
-
});
|
|
118
|
-
const daemonRecordSchema = z.strictObject({
|
|
119
|
-
pid: z.number(),
|
|
120
|
-
instanceId: z.string(),
|
|
121
|
-
version: z.string(),
|
|
122
|
-
apiUrl: z.string(),
|
|
123
|
-
dataDir: z.string(),
|
|
124
|
-
startedAt: z.string(),
|
|
125
|
-
installationMethod: z.string(),
|
|
126
|
-
daemonLifecycle: z.enum([
|
|
127
|
-
"treeport",
|
|
128
|
-
"service",
|
|
129
|
-
"external"
|
|
130
|
-
])
|
|
131
|
-
});
|
|
132
|
-
const healthRecordSchema = z.strictObject({
|
|
133
|
-
ok: z.literal(true),
|
|
134
|
-
version: z.string(),
|
|
135
|
-
protocolVersion: z.number(),
|
|
136
|
-
hostname: z.string().optional(),
|
|
137
|
-
pid: z.number(),
|
|
138
|
-
instanceId: z.string().nullable(),
|
|
139
|
-
installationMethod: z.string(),
|
|
140
|
-
daemonLifecycle: z.enum([
|
|
141
|
-
"treeport",
|
|
142
|
-
"service",
|
|
143
|
-
"external"
|
|
144
|
-
]),
|
|
145
|
-
url: z.string()
|
|
146
|
-
});
|
|
147
|
-
async function preferences(env = process.env) {
|
|
148
|
-
return await readJson$1(localPaths(env).preferencesPath, preferencesSchema) ?? {};
|
|
149
|
-
}
|
|
150
|
-
async function savePreferences(value) {
|
|
151
|
-
const paths = localPaths();
|
|
152
|
-
await fs.mkdir(paths.dataDir, {
|
|
153
|
-
recursive: true,
|
|
154
|
-
mode: 448
|
|
155
|
-
});
|
|
156
|
-
const temporaryPath = `${paths.preferencesPath}.${process.pid}.tmp`;
|
|
157
|
-
await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
|
|
158
|
-
await fs.rename(temporaryPath, paths.preferencesPath);
|
|
159
|
-
}
|
|
160
|
-
async function resolveLocalApiUrl(env = process.env) {
|
|
161
|
-
const explicit = env.TREEPORT_API_URL?.trim();
|
|
162
|
-
const managedApiUrl = env.TREEPORT_MANAGED_API_URL?.trim();
|
|
163
|
-
const daemonRecordPath = env.TREEPORT_DAEMON_RECORD?.trim();
|
|
164
|
-
if (explicit && explicit !== managedApiUrl) return explicit.replace(/\/$/, "");
|
|
165
|
-
if (managedApiUrl && daemonRecordPath) {
|
|
166
|
-
const record = await readJson$1(path.resolve(expandHome(daemonRecordPath)), daemonRecordSchema);
|
|
167
|
-
if (record) return record.apiUrl.replace(/\/$/, "");
|
|
168
|
-
}
|
|
169
|
-
if (explicit) return explicit.replace(/\/$/, "");
|
|
170
|
-
const saved = await preferences(env);
|
|
171
|
-
return listenerUrl(env.TREEPORT_HOST?.trim() || env.HOST?.trim() || saved.host || DEFAULT_HOST, Number.parseInt(env.TREEPORT_PORT?.trim() || env.PORT?.trim() || String(saved.port ?? DEFAULT_PORT), 10));
|
|
172
|
-
}
|
|
173
|
-
async function resolvePackagePath(...segments) {
|
|
174
|
-
const candidates = [fileURLToPath(new URL("../../../", import.meta.url)), fileURLToPath(new URL("../../", import.meta.url))];
|
|
175
|
-
for (const candidate of candidates) if (await fs.access(path.join(candidate, "package.json")).then(() => true).catch(() => false)) return path.join(candidate, ...segments);
|
|
176
|
-
throw new Error("Could not locate the Treeport package directory");
|
|
177
|
-
}
|
|
178
|
-
async function treeportVersion() {
|
|
179
|
-
return (await readJson$1(await resolvePackagePath("package.json"), z.looseObject({ version: z.string().optional() })))?.version ?? "development";
|
|
180
|
-
}
|
|
181
|
-
function processExists(pid) {
|
|
182
|
-
try {
|
|
183
|
-
process.kill(pid, 0);
|
|
184
|
-
return true;
|
|
185
|
-
} catch (error) {
|
|
186
|
-
return error.code === "EPERM";
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
async function daemonHealth(apiUrl, timeoutMs = 1500) {
|
|
190
|
-
const signal = AbortSignal.timeout(timeoutMs);
|
|
191
|
-
return fetch(`${apiUrl}/api/health`, { signal }).then(async (response) => {
|
|
192
|
-
if (!response.ok) return null;
|
|
193
|
-
const result = healthRecordSchema.safeParse(await response.json());
|
|
194
|
-
return result.success ? result.data : null;
|
|
195
|
-
}).catch(() => null);
|
|
196
|
-
}
|
|
197
|
-
function matchesOwnership(state, observed) {
|
|
198
|
-
return observed.pid === state.pid && observed.instanceId === state.instanceId && path.resolve(state.dataDir) === localPaths().dataDir;
|
|
199
|
-
}
|
|
200
|
-
async function readState() {
|
|
201
|
-
return readJson$1(localPaths().statePath, daemonRecordSchema);
|
|
202
|
-
}
|
|
203
|
-
async function removeStaleState(state) {
|
|
204
|
-
const paths = localPaths();
|
|
205
|
-
for (const filePath of [paths.statePath, paths.lockPath]) if ((await readJson$1(filePath, z.looseObject({ instanceId: z.string() })))?.instanceId === state.instanceId) await fs.rm(filePath, { force: true });
|
|
206
|
-
}
|
|
207
|
-
async function stopOwned(state) {
|
|
208
|
-
if (!processExists(state.pid)) {
|
|
209
|
-
await removeStaleState(state);
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
const observed = await daemonHealth(state.apiUrl);
|
|
213
|
-
if (!observed || !matchesOwnership(state, observed)) throw new Error(`Refusing to stop PID ${state.pid}: Treeport could not verify ownership. Check ${localPaths().statePath}.`);
|
|
214
|
-
process.kill(state.pid, "SIGTERM");
|
|
215
|
-
const deadline = Date.now() + 7e3;
|
|
216
|
-
while (Date.now() < deadline) {
|
|
217
|
-
if (!processExists(state.pid)) {
|
|
218
|
-
await removeStaleState(state);
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
222
|
-
}
|
|
223
|
-
throw new Error(`Treeport did not stop within 7 seconds. See ${localPaths().logPath}.`);
|
|
224
|
-
}
|
|
225
|
-
async function executableCheck(executable, args) {
|
|
226
|
-
return new Promise((resolve) => {
|
|
227
|
-
const child = spawn(executable, args, { stdio: [
|
|
228
|
-
"ignore",
|
|
229
|
-
"pipe",
|
|
230
|
-
"pipe"
|
|
231
|
-
] });
|
|
232
|
-
let output = "";
|
|
233
|
-
child.stdout.setEncoding("utf8");
|
|
234
|
-
child.stderr.setEncoding("utf8");
|
|
235
|
-
child.stdout.on("data", (chunk) => {
|
|
236
|
-
output += chunk;
|
|
237
|
-
});
|
|
238
|
-
child.stderr.on("data", (chunk) => {
|
|
239
|
-
output += chunk;
|
|
240
|
-
});
|
|
241
|
-
child.once("error", (error) => resolve({
|
|
242
|
-
ok: false,
|
|
243
|
-
detail: error.message
|
|
244
|
-
}));
|
|
245
|
-
child.once("close", (code) => resolve({
|
|
246
|
-
ok: code === 0,
|
|
247
|
-
detail: output.trim() || `exited with status ${code ?? 1}`
|
|
248
|
-
}));
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
const tailscaleStatusResponseSchema = z.looseObject({
|
|
252
|
-
BackendState: z.string().optional(),
|
|
253
|
-
Self: z.looseObject({ DNSName: z.string().optional() }).optional()
|
|
254
|
-
});
|
|
255
|
-
const tailscaleServeConfigurationSchema = z.lazy(() => z.looseObject({
|
|
256
|
-
TCP: z.record(z.string(), z.looseObject({})).optional(),
|
|
257
|
-
Foreground: z.record(z.string(), tailscaleServeConfigurationSchema).optional(),
|
|
258
|
-
Web: z.record(z.string(), z.looseObject({ Handlers: z.record(z.string(), z.looseObject({ Proxy: z.string().optional() })).optional() })).optional()
|
|
259
|
-
}));
|
|
260
|
-
async function tailscale(args) {
|
|
261
|
-
return new Promise((resolve, reject) => {
|
|
262
|
-
const child = spawn("tailscale", args, { stdio: [
|
|
263
|
-
"ignore",
|
|
264
|
-
"pipe",
|
|
265
|
-
"pipe"
|
|
266
|
-
] });
|
|
267
|
-
let stdout = "";
|
|
268
|
-
let stderr = "";
|
|
269
|
-
child.stdout.setEncoding("utf8");
|
|
270
|
-
child.stderr.setEncoding("utf8");
|
|
271
|
-
child.stdout.on("data", (chunk) => {
|
|
272
|
-
stdout += chunk;
|
|
273
|
-
});
|
|
274
|
-
child.stderr.on("data", (chunk) => {
|
|
275
|
-
stderr += chunk;
|
|
276
|
-
});
|
|
277
|
-
child.once("error", (error) => reject(/* @__PURE__ */ new Error(error.code === "ENOENT" ? "Tailscale is required for remote access. Install it from https://tailscale.com/download, run `tailscale up`, then retry." : `Could not run Tailscale: ${error.message}`)));
|
|
278
|
-
child.once("close", (code) => {
|
|
279
|
-
if (code === 0) {
|
|
280
|
-
resolve(stdout);
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
|
284
|
-
reject(/* @__PURE__ */ new Error(`Tailscale ${args[0]} failed${detail ? `: ${detail}` : ` (status ${code ?? 1})`}`));
|
|
285
|
-
});
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
function tailscaleJson(value, command, schema) {
|
|
289
|
-
const result = schema.safeParse(JSON.parse(value));
|
|
290
|
-
if (!result.success) throw new Error(`Tailscale ${command} returned an invalid JSON response`);
|
|
291
|
-
return result.data;
|
|
292
|
-
}
|
|
293
|
-
function remotePreference(value) {
|
|
294
|
-
if (value.remote === void 0) return null;
|
|
295
|
-
if (!Number.isInteger(value.remote.port) || value.remote.port < 1 || value.remote.port > 65535 || !value.remote.target) throw new Error("Treeport remote access preferences are invalid");
|
|
296
|
-
return value.remote;
|
|
297
|
-
}
|
|
298
|
-
function localProxyTarget(apiUrl) {
|
|
299
|
-
if (!URL.canParse(apiUrl)) throw new Error("Treeport remote access requires a loopback daemon URL");
|
|
300
|
-
const url = new URL(apiUrl);
|
|
301
|
-
if (url.protocol !== "http:" || ![
|
|
302
|
-
"127.0.0.1",
|
|
303
|
-
"localhost",
|
|
304
|
-
"::1",
|
|
305
|
-
"[::1]"
|
|
306
|
-
].includes(url.hostname)) throw new Error("Treeport remote access requires a loopback daemon. Run `treeport start --host 127.0.0.1`, then try again.");
|
|
307
|
-
return `http://${url.host}`;
|
|
308
|
-
}
|
|
309
|
-
function portIsServed(config, port) {
|
|
310
|
-
const tcp = config.TCP;
|
|
311
|
-
if (tcp && Object.hasOwn(tcp, String(port))) return true;
|
|
312
|
-
return Object.values(config.Foreground ?? {}).some((value) => portIsServed(value, port));
|
|
313
|
-
}
|
|
314
|
-
function rootProxyForPort(config, port) {
|
|
315
|
-
for (const [hostPort, server] of Object.entries(config.Web ?? {})) {
|
|
316
|
-
if (!hostPort.endsWith(`:${port}`)) continue;
|
|
317
|
-
const proxy = server.Handlers?.["/"]?.Proxy;
|
|
318
|
-
if (proxy !== void 0) return proxy;
|
|
319
|
-
}
|
|
320
|
-
return null;
|
|
321
|
-
}
|
|
322
|
-
function proxyMatches(actual, expected) {
|
|
323
|
-
return actual !== null && expected !== void 0 && actual.replace(/\/$/, "") === expected.replace(/\/$/, "");
|
|
324
|
-
}
|
|
325
|
-
async function tailscaleServeConfig() {
|
|
326
|
-
return tailscaleJson(await tailscale([
|
|
327
|
-
"serve",
|
|
328
|
-
"status",
|
|
329
|
-
"--json"
|
|
330
|
-
]), "serve status", tailscaleServeConfigurationSchema);
|
|
331
|
-
}
|
|
332
|
-
async function tailscaleRemoteUrl(port) {
|
|
333
|
-
const status = tailscaleJson(await tailscale(["status", "--json"]), "status", tailscaleStatusResponseSchema);
|
|
334
|
-
if (status.BackendState !== "Running") throw new Error("Tailscale is not connected. Run `tailscale up` then try again.");
|
|
335
|
-
const dnsName = status.Self?.DNSName;
|
|
336
|
-
if (!dnsName?.trim()) throw new Error("Tailscale did not report a DNS name. Enable MagicDNS, then try again.");
|
|
337
|
-
return `https://${dnsName.trim().replace(/\.$/, "")}${port === 443 ? "" : `:${port}`}`;
|
|
338
|
-
}
|
|
339
|
-
async function enableTailscaleRemote(options) {
|
|
340
|
-
if (options.port !== void 0 && (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535)) throw new Error("--port must be an integer between 1 and 65535");
|
|
341
|
-
const saved = await preferences();
|
|
342
|
-
const remote = remotePreference(saved);
|
|
343
|
-
const port = options.port ?? remote?.port ?? DEFAULT_PORT;
|
|
344
|
-
if (remote && remote.port !== port) throw new Error(`Treeport remote access is already configured on port ${remote.port}. Run \`treeport remote disable\` before choosing another port.`);
|
|
345
|
-
const expectedTarget = localProxyTarget((await daemonStatus()).state?.apiUrl ?? await resolveLocalApiUrl());
|
|
346
|
-
const [url, config] = await Promise.all([tailscaleRemoteUrl(port), tailscaleServeConfig()]);
|
|
347
|
-
const existingTarget = rootProxyForPort(config, port);
|
|
348
|
-
if ((portIsServed(config, port) || existingTarget !== null) && !proxyMatches(existingTarget, expectedTarget) && !proxyMatches(existingTarget, remote?.target)) throw new Error(`Tailscale Serve already uses port ${port}. Choose another port with \`treeport remote enable --port <port>\`.`);
|
|
349
|
-
const target = localProxyTarget((options.daemon ?? await daemonUp({})).apiUrl);
|
|
350
|
-
const alreadyEnabled = proxyMatches(existingTarget, target);
|
|
351
|
-
if (!alreadyEnabled) await tailscale([
|
|
352
|
-
"serve",
|
|
353
|
-
"--bg",
|
|
354
|
-
`--https=${port}`,
|
|
355
|
-
target
|
|
356
|
-
]);
|
|
357
|
-
await savePreferences({
|
|
358
|
-
...saved,
|
|
359
|
-
remote: {
|
|
360
|
-
port,
|
|
361
|
-
target
|
|
362
|
-
}
|
|
363
|
-
});
|
|
364
|
-
return {
|
|
365
|
-
alreadyEnabled,
|
|
366
|
-
port,
|
|
367
|
-
url
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
async function tailscaleRemoteStatus() {
|
|
371
|
-
const remote = remotePreference(await preferences());
|
|
372
|
-
if (!remote) return {
|
|
373
|
-
configured: false,
|
|
374
|
-
active: false,
|
|
375
|
-
port: null,
|
|
376
|
-
url: null
|
|
377
|
-
};
|
|
378
|
-
const [url, config] = await Promise.all([tailscaleRemoteUrl(remote.port), tailscaleServeConfig()]);
|
|
379
|
-
return {
|
|
380
|
-
configured: true,
|
|
381
|
-
active: proxyMatches(rootProxyForPort(config, remote.port), remote.target),
|
|
382
|
-
port: remote.port,
|
|
383
|
-
url
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
async function disableTailscaleRemote() {
|
|
387
|
-
const saved = await preferences();
|
|
388
|
-
const remote = remotePreference(saved);
|
|
389
|
-
if (!remote) return {
|
|
390
|
-
wasEnabled: false,
|
|
391
|
-
changedTailscale: false
|
|
392
|
-
};
|
|
393
|
-
if (proxyMatches(rootProxyForPort(await tailscaleServeConfig(), remote.port), remote.target)) {
|
|
394
|
-
await tailscale([
|
|
395
|
-
"serve",
|
|
396
|
-
`--https=${remote.port}`,
|
|
397
|
-
"off"
|
|
398
|
-
]);
|
|
399
|
-
delete saved.remote;
|
|
400
|
-
await savePreferences(saved);
|
|
401
|
-
return {
|
|
402
|
-
wasEnabled: true,
|
|
403
|
-
changedTailscale: true
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
delete saved.remote;
|
|
407
|
-
await savePreferences(saved);
|
|
408
|
-
return {
|
|
409
|
-
wasEnabled: false,
|
|
410
|
-
changedTailscale: false
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
async function runDoctor() {
|
|
414
|
-
const paths = localPaths();
|
|
415
|
-
const gitPath = process.env.TREEPORT_GIT_PATH?.trim() || "git";
|
|
416
|
-
const tmuxPath = process.env.TREEPORT_TMUX_PATH?.trim() || "tmux";
|
|
417
|
-
const [git, tmux] = await Promise.all([executableCheck(gitPath, ["--version"]), executableCheck(tmuxPath, ["-V"])]);
|
|
418
|
-
const tmuxMatch = /tmux\s+(\d+)\.(\d+)/i.exec(tmux.detail);
|
|
419
|
-
const tmuxSupported = Boolean(tmux.ok && tmuxMatch && (Number(tmuxMatch[1]) > 3 || Number(tmuxMatch[1]) === 3 && Number(tmuxMatch[2]) >= 2));
|
|
420
|
-
const checkDirectory = (directoryPath) => fs.mkdir(directoryPath, {
|
|
421
|
-
recursive: true,
|
|
422
|
-
mode: 448
|
|
423
|
-
}).then(() => ({
|
|
424
|
-
ok: true,
|
|
425
|
-
detail: directoryPath
|
|
426
|
-
})).catch((error) => ({
|
|
427
|
-
ok: false,
|
|
428
|
-
detail: `${directoryPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
429
|
-
}));
|
|
430
|
-
const [dataDirectory, runtimeDirectory] = await Promise.all([checkDirectory(paths.dataDir), checkDirectory(paths.runtimeDir)]);
|
|
431
|
-
return [
|
|
432
|
-
{
|
|
433
|
-
name: "Node",
|
|
434
|
-
ok: true,
|
|
435
|
-
detail: process.version
|
|
436
|
-
},
|
|
437
|
-
{
|
|
438
|
-
name: "Git",
|
|
439
|
-
...git
|
|
440
|
-
},
|
|
441
|
-
{
|
|
442
|
-
name: "tmux",
|
|
443
|
-
ok: tmuxSupported,
|
|
444
|
-
detail: tmuxSupported ? tmux.detail : `${tmux.detail}. Treeport requires tmux 3.2 or newer.`
|
|
445
|
-
},
|
|
446
|
-
{
|
|
447
|
-
name: "Data directory",
|
|
448
|
-
...dataDirectory
|
|
449
|
-
},
|
|
450
|
-
{
|
|
451
|
-
name: "Runtime directory",
|
|
452
|
-
...runtimeDirectory
|
|
453
|
-
}
|
|
454
|
-
];
|
|
455
|
-
}
|
|
456
|
-
async function daemonStatus() {
|
|
457
|
-
const state = await readState();
|
|
458
|
-
if (!state) return {
|
|
459
|
-
running: false,
|
|
460
|
-
state: null,
|
|
461
|
-
health: null,
|
|
462
|
-
verified: false
|
|
463
|
-
};
|
|
464
|
-
if (!processExists(state.pid)) {
|
|
465
|
-
await removeStaleState(state);
|
|
466
|
-
return {
|
|
467
|
-
running: false,
|
|
468
|
-
state: null,
|
|
469
|
-
health: null,
|
|
470
|
-
verified: false
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
const observed = await daemonHealth(state.apiUrl);
|
|
474
|
-
return {
|
|
475
|
-
running: Boolean(observed),
|
|
476
|
-
state,
|
|
477
|
-
health: observed,
|
|
478
|
-
verified: Boolean(observed && matchesOwnership(state, observed))
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
async function daemonUp(options) {
|
|
482
|
-
if (options.port !== void 0 && (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535)) throw new Error("--port must be an integer between 1 and 65535");
|
|
483
|
-
const paths = localPaths();
|
|
484
|
-
const saved = await preferences();
|
|
485
|
-
const next = {
|
|
486
|
-
...saved,
|
|
487
|
-
host: options.host?.trim() || saved.host || DEFAULT_HOST,
|
|
488
|
-
port: options.port ?? saved.port ?? DEFAULT_PORT
|
|
489
|
-
};
|
|
490
|
-
const host = options.host?.trim() || process.env.TREEPORT_HOST?.trim() || process.env.HOST?.trim() || next.host;
|
|
491
|
-
assertLoopbackHost(host);
|
|
492
|
-
if (options.host !== void 0 || options.port !== void 0) await savePreferences(next);
|
|
493
|
-
const port = Number.parseInt(options.port === void 0 ? process.env.TREEPORT_PORT?.trim() || process.env.PORT?.trim() || String(next.port) : String(options.port), 10);
|
|
494
|
-
const apiUrl = options.host !== void 0 || options.port !== void 0 ? listenerUrl(host, port) : process.env.TREEPORT_API_URL?.trim() || listenerUrl(host, port);
|
|
495
|
-
const currentVersion = await treeportVersion();
|
|
496
|
-
const existing = await daemonStatus();
|
|
497
|
-
if (existing.state) {
|
|
498
|
-
if (!existing.running || !existing.verified) throw new Error(`Treeport PID ${existing.state.pid} is running but ownership or health could not be verified. See ${paths.logPath}.`);
|
|
499
|
-
if (existing.health?.version === currentVersion && existing.state.apiUrl === apiUrl) return {
|
|
500
|
-
alreadyRunning: true,
|
|
501
|
-
apiUrl: existing.state.apiUrl,
|
|
502
|
-
pid: existing.state.pid
|
|
503
|
-
};
|
|
504
|
-
await stopOwned(existing.state);
|
|
505
|
-
}
|
|
506
|
-
const failedChecks = (await runDoctor()).filter((check) => !check.ok);
|
|
507
|
-
if (failedChecks.length) throw new Error(failedChecks.map((check) => `${check.name}: ${check.detail}`).join("\n"));
|
|
508
|
-
const serverEntry = await resolvePackagePath("dist", "node", "server", "index.js");
|
|
509
|
-
const webDist = await resolvePackagePath("dist", "web");
|
|
510
|
-
await fs.access(serverEntry);
|
|
511
|
-
await fs.mkdir(path.dirname(paths.logPath), {
|
|
512
|
-
recursive: true,
|
|
513
|
-
mode: 448
|
|
514
|
-
});
|
|
515
|
-
if (await fs.stat(paths.logPath).then((value) => value.size).catch(() => 0) > 5 * 1024 * 1024) {
|
|
516
|
-
await fs.rm(`${paths.logPath}.1`, { force: true });
|
|
517
|
-
await fs.rename(paths.logPath, `${paths.logPath}.1`);
|
|
518
|
-
}
|
|
519
|
-
const instanceId = crypto.randomUUID();
|
|
520
|
-
const childEnvironment = {
|
|
521
|
-
...process.env,
|
|
522
|
-
TREEPORT_HOST: host,
|
|
523
|
-
TREEPORT_PORT: String(port),
|
|
524
|
-
TREEPORT_API_URL: apiUrl,
|
|
525
|
-
TREEPORT_DATA_DIR: paths.dataDir,
|
|
526
|
-
TREEPORT_RUNTIME_DIR: paths.runtimeDir,
|
|
527
|
-
TREEPORT_APP_VERSION: currentVersion,
|
|
528
|
-
TREEPORT_INSTANCE_ID: instanceId,
|
|
529
|
-
TREEPORT_INSTALLATION_METHOD: process.env.TREEPORT_INSTALLATION_METHOD?.trim() || "npm",
|
|
530
|
-
TREEPORT_DAEMON_LIFECYCLE: "treeport",
|
|
531
|
-
TREEPORT_WEB_DIST: webDist
|
|
532
|
-
};
|
|
533
|
-
if (options.foreground) {
|
|
534
|
-
console.log(`Treeport will listen on ${apiUrl}`);
|
|
535
|
-
const child = spawn(process.execPath, [serverEntry], {
|
|
536
|
-
env: childEnvironment,
|
|
537
|
-
stdio: "inherit"
|
|
538
|
-
});
|
|
539
|
-
const code = await new Promise((resolve, reject) => {
|
|
540
|
-
child.once("error", reject);
|
|
541
|
-
child.once("close", (value) => resolve(value ?? 1));
|
|
542
|
-
});
|
|
543
|
-
if (code !== 0) throw new Error(`Treeport exited with status ${code}`);
|
|
544
|
-
return {
|
|
545
|
-
alreadyRunning: false,
|
|
546
|
-
apiUrl,
|
|
547
|
-
pid: child.pid ?? 0
|
|
548
|
-
};
|
|
549
|
-
}
|
|
550
|
-
const log = fsSync.openSync(paths.logPath, "a", 384);
|
|
551
|
-
const child = spawn(process.execPath, [serverEntry], {
|
|
552
|
-
env: childEnvironment,
|
|
553
|
-
detached: true,
|
|
554
|
-
stdio: [
|
|
555
|
-
"ignore",
|
|
556
|
-
log,
|
|
557
|
-
log
|
|
558
|
-
]
|
|
559
|
-
});
|
|
560
|
-
child.unref();
|
|
561
|
-
fsSync.closeSync(log);
|
|
562
|
-
const deadline = Date.now() + 15e3;
|
|
563
|
-
while (Date.now() < deadline) {
|
|
564
|
-
const observed = await daemonHealth(apiUrl, 500);
|
|
565
|
-
if (observed && observed.pid === child.pid && observed.instanceId === instanceId && observed.version === currentVersion) return {
|
|
566
|
-
alreadyRunning: false,
|
|
567
|
-
apiUrl,
|
|
568
|
-
pid: child.pid ?? observed.pid
|
|
569
|
-
};
|
|
570
|
-
if (child.pid && !processExists(child.pid)) break;
|
|
571
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
572
|
-
}
|
|
573
|
-
const recentLog = await fs.readFile(paths.logPath, "utf8").then((value) => value.split("\n").slice(-20).join("\n").trim()).catch(() => "");
|
|
574
|
-
throw new Error(`Treeport did not become ready at ${apiUrl}. See ${paths.logPath}.${recentLog ? `\n\n${recentLog}` : ""}`);
|
|
575
|
-
}
|
|
576
|
-
async function daemonDown() {
|
|
577
|
-
const state = await readState();
|
|
578
|
-
if (!state) return { wasRunning: false };
|
|
579
|
-
await stopOwned(state);
|
|
580
|
-
return { wasRunning: true };
|
|
581
|
-
}
|
|
582
|
-
async function readDaemonLogs(lines = 100) {
|
|
583
|
-
return (await fs.readFile(localPaths().logPath, "utf8").catch((error) => {
|
|
584
|
-
if (error.code === "ENOENT") return "";
|
|
585
|
-
throw error;
|
|
586
|
-
})).split("\n").slice(-lines - 1).join("\n");
|
|
587
|
-
}
|
|
588
|
-
//#endregion
|
|
589
|
-
//#region src/cli/service.ts
|
|
590
|
-
const serviceRecordSchema = z.strictObject({
|
|
591
|
-
schemaVersion: z.literal(1),
|
|
592
|
-
manager: z.enum(["launchd", "systemd"]),
|
|
593
|
-
platform: z.string(),
|
|
594
|
-
uid: z.number().int().nonnegative(),
|
|
595
|
-
gid: z.number().int().nonnegative(),
|
|
596
|
-
username: z.string().min(1),
|
|
597
|
-
group: z.string().min(1),
|
|
598
|
-
home: z.string().min(1),
|
|
599
|
-
dataDir: z.string().min(1),
|
|
600
|
-
runtimeDir: z.string().min(1),
|
|
601
|
-
logPath: z.string().min(1),
|
|
602
|
-
apiUrl: z.string().min(1),
|
|
603
|
-
cliEntrypoint: z.string().min(1),
|
|
604
|
-
runtimeExecutable: z.string().min(1).nullable().default(null),
|
|
605
|
-
runtimeEntrypoint: z.string().min(1).nullable().default(null),
|
|
606
|
-
installationMethod: z.enum(["curl", "npm"]),
|
|
607
|
-
definitionName: z.string().min(1),
|
|
608
|
-
definitionPath: z.string().min(1),
|
|
609
|
-
definitionHash: z.string().length(64),
|
|
610
|
-
environmentHash: z.string().length(64),
|
|
611
|
-
environment: z.record(z.string(), z.string()),
|
|
612
|
-
requestedState: z.enum(["running", "stopped"]),
|
|
613
|
-
pendingAdministratorRequestId: z.string().nullable(),
|
|
614
|
-
createdAt: z.string(),
|
|
615
|
-
updatedAt: z.string()
|
|
616
|
-
});
|
|
617
|
-
const administratorRequestSchema = z.strictObject({
|
|
618
|
-
schemaVersion: z.literal(1),
|
|
619
|
-
id: z.string().uuid(),
|
|
620
|
-
operation: z.enum([
|
|
621
|
-
"enable",
|
|
622
|
-
"start",
|
|
623
|
-
"stop",
|
|
624
|
-
"disable"
|
|
625
|
-
]),
|
|
626
|
-
createdAt: z.string(),
|
|
627
|
-
expiresAt: z.string(),
|
|
628
|
-
uid: z.number().int().nonnegative(),
|
|
629
|
-
gid: z.number().int().nonnegative(),
|
|
630
|
-
username: z.string().min(1),
|
|
631
|
-
group: z.string().min(1),
|
|
632
|
-
home: z.string().min(1),
|
|
633
|
-
serviceRecordPath: z.string().min(1),
|
|
634
|
-
runnerPath: z.string().min(1),
|
|
635
|
-
definitionName: z.string().min(1),
|
|
636
|
-
definitionPath: z.string().min(1),
|
|
637
|
-
stagedDefinitionPath: z.string().min(1),
|
|
638
|
-
definitionHash: z.string().length(64),
|
|
639
|
-
apiUrl: z.string().min(1),
|
|
640
|
-
cliEntrypoint: z.string().min(1),
|
|
641
|
-
runtimeExecutable: z.string().min(1),
|
|
642
|
-
runtimeEntrypoint: z.string().min(1)
|
|
643
|
-
});
|
|
644
|
-
function managerForPlatform(platform = process.platform) {
|
|
645
|
-
return platform === "darwin" ? "launchd" : platform === "linux" ? "systemd" : null;
|
|
646
|
-
}
|
|
647
|
-
function servicePaths(env = process.env) {
|
|
648
|
-
const paths = localPaths(env);
|
|
649
|
-
const directory = path.join(paths.dataDir, "service");
|
|
650
|
-
return {
|
|
651
|
-
directory,
|
|
652
|
-
recordPath: path.join(directory, "service.json"),
|
|
653
|
-
runnerPath: path.join(directory, "run"),
|
|
654
|
-
requestsDirectory: path.join(directory, "requests"),
|
|
655
|
-
stagedDefinitionPath: path.join(directory, "treeport.plist")
|
|
656
|
-
};
|
|
657
|
-
}
|
|
658
|
-
async function readJson(filePath, schema) {
|
|
659
|
-
return fs.readFile(filePath, "utf8").then((value) => schema.parse(JSON.parse(value))).catch(() => null);
|
|
660
|
-
}
|
|
661
|
-
async function writeJson(filePath, value) {
|
|
662
|
-
await fs.mkdir(path.dirname(filePath), {
|
|
663
|
-
recursive: true,
|
|
664
|
-
mode: 448
|
|
665
|
-
});
|
|
666
|
-
const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
667
|
-
await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
|
|
668
|
-
await fs.rename(temporaryPath, filePath);
|
|
669
|
-
}
|
|
670
|
-
function fingerprint(value) {
|
|
671
|
-
const parsed = z.string().safeParse(value);
|
|
672
|
-
const source = parsed.success ? parsed.data : JSON.stringify(Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))));
|
|
673
|
-
return crypto.createHash("sha256").update(source).digest("hex");
|
|
674
|
-
}
|
|
675
|
-
function xml(value) {
|
|
676
|
-
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
677
|
-
}
|
|
678
|
-
function shellQuote(value) {
|
|
679
|
-
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
680
|
-
}
|
|
681
|
-
function createAdministratorCommand(input) {
|
|
682
|
-
return `sudo ${input.installationMethod === "curl" ? shellQuote(input.cliEntrypoint) : `${shellQuote(input.runtimeExecutable)} ${shellQuote(input.runtimeEntrypoint)}`} service apply --request ${shellQuote(input.requestPath)}`;
|
|
683
|
-
}
|
|
684
|
-
function systemdValue(value) {
|
|
685
|
-
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("%", "%%").replaceAll("\n", "\\n");
|
|
686
|
-
}
|
|
687
|
-
function createLaunchdDefinition(input) {
|
|
688
|
-
return {
|
|
689
|
-
label: input.label,
|
|
690
|
-
programArguments: [input.runnerPath],
|
|
691
|
-
username: input.username,
|
|
692
|
-
group: input.group,
|
|
693
|
-
environment: input.environment,
|
|
694
|
-
workingDirectory: input.home,
|
|
695
|
-
standardOutPath: input.logPath,
|
|
696
|
-
standardErrorPath: input.logPath,
|
|
697
|
-
keepAlive: true,
|
|
698
|
-
processType: "Background",
|
|
699
|
-
throttleInterval: 10,
|
|
700
|
-
exitTimeOut: 10,
|
|
701
|
-
abandonProcessGroup: true,
|
|
702
|
-
umask: 63
|
|
703
|
-
};
|
|
704
|
-
}
|
|
705
|
-
function serializeLaunchdDefinition(definition) {
|
|
706
|
-
const environment = Object.entries(definition.environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, value]) => ` <key>${xml(name)}</key>\n <string>${xml(value)}</string>`).join("\n");
|
|
707
|
-
const argumentsXml = definition.programArguments.map((argument) => ` <string>${xml(argument)}</string>`).join("\n");
|
|
708
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
709
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
710
|
-
<plist version="1.0">
|
|
711
|
-
<dict>
|
|
712
|
-
<key>Label</key>
|
|
713
|
-
<string>${xml(definition.label)}</string>
|
|
714
|
-
<key>ProgramArguments</key>
|
|
715
|
-
<array>
|
|
716
|
-
${argumentsXml}
|
|
717
|
-
</array>
|
|
718
|
-
<key>UserName</key>
|
|
719
|
-
<string>${xml(definition.username)}</string>
|
|
720
|
-
<key>GroupName</key>
|
|
721
|
-
<string>${xml(definition.group)}</string>
|
|
722
|
-
<key>EnvironmentVariables</key>
|
|
723
|
-
<dict>
|
|
724
|
-
${environment}
|
|
725
|
-
</dict>
|
|
726
|
-
<key>WorkingDirectory</key>
|
|
727
|
-
<string>${xml(definition.workingDirectory)}</string>
|
|
728
|
-
<key>StandardOutPath</key>
|
|
729
|
-
<string>${xml(definition.standardOutPath)}</string>
|
|
730
|
-
<key>StandardErrorPath</key>
|
|
731
|
-
<string>${xml(definition.standardErrorPath)}</string>
|
|
732
|
-
<key>KeepAlive</key>
|
|
733
|
-
<true/>
|
|
734
|
-
<key>ProcessType</key>
|
|
735
|
-
<string>${definition.processType}</string>
|
|
736
|
-
<key>ThrottleInterval</key>
|
|
737
|
-
<integer>${definition.throttleInterval}</integer>
|
|
738
|
-
<key>ExitTimeOut</key>
|
|
739
|
-
<integer>${definition.exitTimeOut}</integer>
|
|
740
|
-
<key>AbandonProcessGroup</key>
|
|
741
|
-
<true/>
|
|
742
|
-
<key>Umask</key>
|
|
743
|
-
<integer>${definition.umask}</integer>
|
|
744
|
-
</dict>
|
|
745
|
-
</plist>
|
|
746
|
-
`;
|
|
747
|
-
}
|
|
748
|
-
function createSystemdDefinition(input) {
|
|
749
|
-
return {
|
|
750
|
-
description: "Treeport daemon",
|
|
751
|
-
execStart: input.runnerPath,
|
|
752
|
-
environment: input.environment,
|
|
753
|
-
restart: "always",
|
|
754
|
-
restartSeconds: 5,
|
|
755
|
-
timeoutStopSeconds: 10,
|
|
756
|
-
killMode: "process",
|
|
757
|
-
wantedBy: "default.target"
|
|
758
|
-
};
|
|
759
|
-
}
|
|
760
|
-
function serializeSystemdDefinition(definition) {
|
|
761
|
-
const environment = Object.entries(definition.environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, value]) => `Environment="${systemdValue(name)}=${systemdValue(value)}"`).join("\n");
|
|
762
|
-
return `[Unit]
|
|
763
|
-
Description=${definition.description}
|
|
764
|
-
|
|
765
|
-
[Service]
|
|
766
|
-
Type=simple
|
|
767
|
-
ExecStart="${systemdValue(definition.execStart)}"
|
|
768
|
-
${environment}
|
|
769
|
-
Restart=${definition.restart}
|
|
770
|
-
RestartSec=${definition.restartSeconds}
|
|
771
|
-
TimeoutStopSec=${definition.timeoutStopSeconds}
|
|
772
|
-
KillMode=${definition.killMode}
|
|
773
|
-
|
|
774
|
-
[Install]
|
|
775
|
-
WantedBy=${definition.wantedBy}
|
|
776
|
-
`;
|
|
777
|
-
}
|
|
778
|
-
async function runCommand(executable, args, environment = process.env) {
|
|
779
|
-
return new Promise((resolve) => {
|
|
780
|
-
const child = spawn(executable, args, {
|
|
781
|
-
env: environment,
|
|
782
|
-
stdio: [
|
|
783
|
-
"ignore",
|
|
784
|
-
"pipe",
|
|
785
|
-
"pipe"
|
|
786
|
-
]
|
|
787
|
-
});
|
|
788
|
-
let stdout = "";
|
|
789
|
-
let stderr = "";
|
|
790
|
-
child.stdout.setEncoding("utf8");
|
|
791
|
-
child.stderr.setEncoding("utf8");
|
|
792
|
-
child.stdout.on("data", (value) => {
|
|
793
|
-
stdout += value;
|
|
794
|
-
});
|
|
795
|
-
child.stderr.on("data", (value) => {
|
|
796
|
-
stderr += value;
|
|
797
|
-
});
|
|
798
|
-
child.once("error", (error) => {
|
|
799
|
-
resolve({
|
|
800
|
-
code: 127,
|
|
801
|
-
stdout,
|
|
802
|
-
stderr: error.message
|
|
803
|
-
});
|
|
804
|
-
});
|
|
805
|
-
child.once("close", (code) => {
|
|
806
|
-
resolve({
|
|
807
|
-
code: code ?? 1,
|
|
808
|
-
stdout,
|
|
809
|
-
stderr
|
|
810
|
-
});
|
|
811
|
-
});
|
|
812
|
-
});
|
|
813
|
-
}
|
|
814
|
-
function commandError(command, result) {
|
|
815
|
-
const detail = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n");
|
|
816
|
-
return /* @__PURE__ */ new Error(`${command} failed${detail ? `: ${detail}` : ` with status ${result.code}`}`);
|
|
817
|
-
}
|
|
818
|
-
async function executablePath(name) {
|
|
819
|
-
const candidates = name === "launchctl" ? ["/bin/launchctl", "/usr/bin/launchctl"] : [`/usr/bin/${name}`, `/bin/${name}`];
|
|
820
|
-
for (const candidate of candidates) if (await fs.access(candidate, constants.X_OK).then(() => true).catch(() => false)) return candidate;
|
|
821
|
-
return name;
|
|
822
|
-
}
|
|
823
|
-
async function primaryGroup(username) {
|
|
824
|
-
const result = await runCommand(await executablePath("id"), ["-gn", username]);
|
|
825
|
-
if (result.code !== 0 || !result.stdout.trim()) throw commandError("id -gn", result);
|
|
826
|
-
return result.stdout.trim();
|
|
827
|
-
}
|
|
828
|
-
function currentEntrypoint() {
|
|
829
|
-
const value = process.env.TREEPORT_CLI_ENTRYPOINT?.trim() || process.argv[1]?.trim();
|
|
830
|
-
return value ? path.resolve(value) : null;
|
|
831
|
-
}
|
|
832
|
-
async function ensureEntrypoint(installationMethod) {
|
|
833
|
-
const entrypoint = currentEntrypoint();
|
|
834
|
-
if (!entrypoint) throw new Error("Treeport could not identify a stable CLI entrypoint. Install Treeport with npm or the curl installer, then retry.");
|
|
835
|
-
await fs.access(entrypoint, constants.X_OK).catch(() => {
|
|
836
|
-
throw new Error(`Treeport cannot execute its stable CLI entrypoint at ${entrypoint}. Reinstall Treeport, then retry.`);
|
|
837
|
-
});
|
|
838
|
-
if (installationMethod === "npm") {
|
|
839
|
-
const [actual, expected] = await Promise.all([fs.realpath(entrypoint), fs.realpath(await resolvePackagePath("bin", "treeport.mjs"))]);
|
|
840
|
-
if (actual !== expected) throw new Error(`The current CLI entrypoint is not the installed Treeport npm bin: ${entrypoint}`);
|
|
841
|
-
}
|
|
842
|
-
return entrypoint;
|
|
843
|
-
}
|
|
844
|
-
async function currentAdministratorRuntime() {
|
|
845
|
-
const invokedEntrypoint = process.argv[1]?.trim();
|
|
846
|
-
if (!invokedEntrypoint) throw new Error("Treeport could not identify its Node entrypoint.");
|
|
847
|
-
const runtimeEntrypoint = path.resolve(invokedEntrypoint);
|
|
848
|
-
const [runtimeExecutable, actualEntrypoint, packageBinEntrypoint, packageCliEntrypoint] = await Promise.all([
|
|
849
|
-
fs.realpath(process.execPath),
|
|
850
|
-
fs.realpath(runtimeEntrypoint),
|
|
851
|
-
fs.realpath(await resolvePackagePath("bin", "treeport.mjs")),
|
|
852
|
-
fs.realpath(await resolvePackagePath("dist", "node", "cli", "index.js"))
|
|
853
|
-
]);
|
|
854
|
-
if (actualEntrypoint !== packageBinEntrypoint && actualEntrypoint !== packageCliEntrypoint) throw new Error(`Treeport cannot use an unrecognized package entrypoint for administrator commands: ${runtimeEntrypoint}`);
|
|
855
|
-
await Promise.all([fs.access(runtimeExecutable, constants.X_OK), fs.access(runtimeEntrypoint, constants.R_OK)]);
|
|
856
|
-
return {
|
|
857
|
-
runtimeExecutable,
|
|
858
|
-
runtimeEntrypoint
|
|
859
|
-
};
|
|
860
|
-
}
|
|
861
|
-
function cacheDirectory(home, env) {
|
|
862
|
-
const configured = env.TREEPORT_CACHE_DIR?.trim();
|
|
863
|
-
if (configured) return path.resolve(configured.replace(/^~(?=\/|$)/, home));
|
|
864
|
-
if (env.XDG_CACHE_HOME?.trim()) return path.join(path.resolve(env.XDG_CACHE_HOME.replace(/^~(?=\/|$)/, home)), "treeport");
|
|
865
|
-
return process.platform === "darwin" ? path.join(home, "Library", "Caches", "treeport") : path.join(home, ".cache", "treeport");
|
|
866
|
-
}
|
|
867
|
-
function createServiceEnvironment(input) {
|
|
868
|
-
const env = input.env ?? process.env;
|
|
869
|
-
const url = new URL(input.apiUrl);
|
|
870
|
-
assertLoopbackHost(url.hostname);
|
|
871
|
-
const result = {
|
|
872
|
-
HOME: input.user.homedir,
|
|
873
|
-
USER: input.user.username,
|
|
874
|
-
LOGNAME: input.user.username,
|
|
875
|
-
PATH: env.PATH?.trim() || "/usr/local/bin:/usr/bin:/bin",
|
|
876
|
-
TREEPORT_HOST: url.hostname,
|
|
877
|
-
TREEPORT_PORT: url.port || "80",
|
|
878
|
-
TREEPORT_API_URL: input.apiUrl,
|
|
879
|
-
TREEPORT_DATA_DIR: input.paths.dataDir,
|
|
880
|
-
TREEPORT_RUNTIME_DIR: input.paths.runtimeDir,
|
|
881
|
-
TREEPORT_CACHE_DIR: cacheDirectory(input.user.homedir, env),
|
|
882
|
-
TREEPORT_DATABASE_PATH: env.TREEPORT_DATABASE_PATH?.trim() || path.join(input.paths.dataDir, "treeport.db"),
|
|
883
|
-
TREEPORT_SHELL: env.TREEPORT_SHELL?.trim() || env.SHELL?.trim() || "/bin/sh",
|
|
884
|
-
TREEPORT_TMUX_PATH: env.TREEPORT_TMUX_PATH?.trim() || "tmux",
|
|
885
|
-
TREEPORT_GIT_PATH: env.TREEPORT_GIT_PATH?.trim() || "git",
|
|
886
|
-
TREEPORT_GH_PATH: env.TREEPORT_GH_PATH?.trim() || "gh",
|
|
887
|
-
TREEPORT_DAEMON_LIFECYCLE: "service",
|
|
888
|
-
TREEPORT_INSTALLATION_METHOD: input.installationMethod,
|
|
889
|
-
TREEPORT_SERVICE_RECORD: input.recordPath
|
|
890
|
-
};
|
|
891
|
-
for (const [name, value] of Object.entries(env)) if (value !== void 0 && (name === "LANG" || name === "LC_ALL" || name.startsWith("LC_"))) result[name] = value;
|
|
892
|
-
return result;
|
|
893
|
-
}
|
|
894
|
-
function definitionForRecord(record) {
|
|
895
|
-
if (record.manager === "launchd") return serializeLaunchdDefinition(createLaunchdDefinition({
|
|
896
|
-
label: record.definitionName,
|
|
897
|
-
runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath,
|
|
898
|
-
username: record.username,
|
|
899
|
-
group: record.group,
|
|
900
|
-
environment: record.environment,
|
|
901
|
-
home: record.home,
|
|
902
|
-
logPath: record.logPath
|
|
903
|
-
}));
|
|
904
|
-
return serializeSystemdDefinition(createSystemdDefinition({
|
|
905
|
-
runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath,
|
|
906
|
-
environment: record.environment
|
|
907
|
-
}));
|
|
908
|
-
}
|
|
909
|
-
function runnerSource(record) {
|
|
910
|
-
return `#!/bin/sh
|
|
911
|
-
set -u
|
|
912
|
-
entrypoint=${shellQuote(record.cliEntrypoint)}
|
|
913
|
-
record=${shellQuote(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).recordPath)}
|
|
914
|
-
log=${shellQuote(record.logPath)}
|
|
915
|
-
reported=0
|
|
916
|
-
while [ ! -x "$entrypoint" ]; do
|
|
917
|
-
if [ "$reported" -eq 0 ]; then
|
|
918
|
-
mkdir -p "$(dirname "$log")"
|
|
919
|
-
printf '%s Treeport service cannot start because %s is missing. Reinstall Treeport, then run treeport service enable or treeport service disable.\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$entrypoint" >> "$log"
|
|
920
|
-
reported=1
|
|
921
|
-
fi
|
|
922
|
-
sleep 60
|
|
923
|
-
done
|
|
924
|
-
export TREEPORT_SERVICE_RECORD="$record"
|
|
925
|
-
exec "$entrypoint" service run
|
|
926
|
-
`;
|
|
927
|
-
}
|
|
928
|
-
async function currentRecord() {
|
|
929
|
-
return readJson(servicePaths().recordPath, serviceRecordSchema);
|
|
930
|
-
}
|
|
931
|
-
async function saveRecord(record) {
|
|
932
|
-
await writeJson(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).recordPath, record);
|
|
933
|
-
}
|
|
934
|
-
async function managerState(record) {
|
|
935
|
-
if (record.manager === "launchd") {
|
|
936
|
-
const launchctl = await executablePath("launchctl");
|
|
937
|
-
const [active, disabled, definitionExists] = await Promise.all([
|
|
938
|
-
runCommand(launchctl, ["print", `system/${record.definitionName}`]),
|
|
939
|
-
runCommand(launchctl, ["print-disabled", "system"]),
|
|
940
|
-
fs.access(record.definitionPath).then(() => true).catch(() => false)
|
|
941
|
-
]);
|
|
942
|
-
return {
|
|
943
|
-
active: active.code === 0,
|
|
944
|
-
enabled: definitionExists && !disabled.stdout.includes(`"${record.definitionName}" => true`),
|
|
945
|
-
lingering: true,
|
|
946
|
-
managerIssue: null
|
|
947
|
-
};
|
|
948
|
-
}
|
|
949
|
-
const systemctl = await executablePath("systemctl");
|
|
950
|
-
const [active, enabled, linger] = await Promise.all([
|
|
951
|
-
runCommand(systemctl, [
|
|
952
|
-
"--user",
|
|
953
|
-
"is-active",
|
|
954
|
-
record.definitionName
|
|
955
|
-
]),
|
|
956
|
-
runCommand(systemctl, [
|
|
957
|
-
"--user",
|
|
958
|
-
"is-enabled",
|
|
959
|
-
record.definitionName
|
|
960
|
-
]),
|
|
961
|
-
runCommand(await executablePath("loginctl"), [
|
|
962
|
-
"show-user",
|
|
963
|
-
record.username,
|
|
964
|
-
"-p",
|
|
965
|
-
"Linger",
|
|
966
|
-
"--value"
|
|
967
|
-
])
|
|
968
|
-
]);
|
|
969
|
-
return {
|
|
970
|
-
active: active.code === 0 && active.stdout.trim() === "active",
|
|
971
|
-
enabled: enabled.code === 0 && enabled.stdout.trim() === "enabled",
|
|
972
|
-
lingering: linger.code === 0 && linger.stdout.trim() === "yes",
|
|
973
|
-
managerIssue: active.code === 127 || active.stderr.includes("Failed to connect to bus") || active.stderr.includes("No medium found") ? "The systemd user manager is not available." : linger.code === 127 ? "loginctl is not available." : null
|
|
974
|
-
};
|
|
975
|
-
}
|
|
976
|
-
function administratorCommand(record) {
|
|
977
|
-
const requestId = record.pendingAdministratorRequestId;
|
|
978
|
-
if (!requestId || !record.runtimeExecutable || !record.runtimeEntrypoint) return null;
|
|
979
|
-
const requestPath = path.join(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).requestsDirectory, `${requestId}.json`);
|
|
980
|
-
return createAdministratorCommand({
|
|
981
|
-
installationMethod: record.installationMethod,
|
|
982
|
-
cliEntrypoint: record.cliEntrypoint,
|
|
983
|
-
runtimeExecutable: record.runtimeExecutable,
|
|
984
|
-
runtimeEntrypoint: record.runtimeEntrypoint,
|
|
985
|
-
requestPath
|
|
986
|
-
});
|
|
987
|
-
}
|
|
988
|
-
async function untrackedDefinition() {
|
|
989
|
-
const manager = managerForPlatform();
|
|
990
|
-
if (!manager) return null;
|
|
991
|
-
const user = os.userInfo();
|
|
992
|
-
const name = manager === "launchd" ? `app.treeport.daemon.${user.uid}` : "treeport.service";
|
|
993
|
-
const definitionPath = manager === "launchd" ? `/Library/LaunchDaemons/${name}.plist` : path.join(process.env.XDG_CONFIG_HOME?.trim() || path.join(user.homedir, ".config"), "systemd", "user", name);
|
|
994
|
-
return await fs.access(definitionPath).then(() => true).catch(() => false) ? {
|
|
995
|
-
manager,
|
|
996
|
-
name,
|
|
997
|
-
path: definitionPath
|
|
998
|
-
} : null;
|
|
999
|
-
}
|
|
1000
|
-
async function serviceInstalled() {
|
|
1001
|
-
return await currentRecord() !== null || await untrackedDefinition() !== null;
|
|
1002
|
-
}
|
|
1003
|
-
async function serviceStatus() {
|
|
1004
|
-
const manager = managerForPlatform();
|
|
1005
|
-
const record = await currentRecord();
|
|
1006
|
-
if (!manager) return {
|
|
1007
|
-
supported: false,
|
|
1008
|
-
manager: null,
|
|
1009
|
-
state: "disabled",
|
|
1010
|
-
installed: false,
|
|
1011
|
-
enabledAtBoot: false,
|
|
1012
|
-
active: false,
|
|
1013
|
-
healthy: false,
|
|
1014
|
-
rebootReady: false,
|
|
1015
|
-
definitionMatches: false,
|
|
1016
|
-
environmentMatches: false,
|
|
1017
|
-
entrypointMatches: false,
|
|
1018
|
-
requestedState: null,
|
|
1019
|
-
definitionPath: null,
|
|
1020
|
-
entrypoint: null,
|
|
1021
|
-
daemon: null,
|
|
1022
|
-
issues: [`Treeport service mode does not support ${process.platform}.`],
|
|
1023
|
-
recoveryCommands: [],
|
|
1024
|
-
administratorCommand: null
|
|
1025
|
-
};
|
|
1026
|
-
if (!record) {
|
|
1027
|
-
const untracked = await untrackedDefinition();
|
|
1028
|
-
if (!untracked) return {
|
|
1029
|
-
supported: true,
|
|
1030
|
-
manager,
|
|
1031
|
-
state: "disabled",
|
|
1032
|
-
installed: false,
|
|
1033
|
-
enabledAtBoot: false,
|
|
1034
|
-
active: false,
|
|
1035
|
-
healthy: false,
|
|
1036
|
-
rebootReady: false,
|
|
1037
|
-
definitionMatches: false,
|
|
1038
|
-
environmentMatches: false,
|
|
1039
|
-
entrypointMatches: false,
|
|
1040
|
-
requestedState: null,
|
|
1041
|
-
definitionPath: null,
|
|
1042
|
-
entrypoint: null,
|
|
1043
|
-
daemon: null,
|
|
1044
|
-
issues: [],
|
|
1045
|
-
recoveryCommands: ["treeport service enable"],
|
|
1046
|
-
administratorCommand: null
|
|
1047
|
-
};
|
|
1048
|
-
return {
|
|
1049
|
-
supported: true,
|
|
1050
|
-
manager,
|
|
1051
|
-
state: "stale",
|
|
1052
|
-
installed: true,
|
|
1053
|
-
enabledAtBoot: true,
|
|
1054
|
-
active: (untracked.manager === "launchd" ? await runCommand(await executablePath("launchctl"), ["print", `system/${untracked.name}`]) : await runCommand(await executablePath("systemctl"), [
|
|
1055
|
-
"--user",
|
|
1056
|
-
"is-active",
|
|
1057
|
-
untracked.name
|
|
1058
|
-
])).code === 0,
|
|
1059
|
-
healthy: false,
|
|
1060
|
-
rebootReady: false,
|
|
1061
|
-
definitionMatches: false,
|
|
1062
|
-
environmentMatches: false,
|
|
1063
|
-
entrypointMatches: false,
|
|
1064
|
-
requestedState: null,
|
|
1065
|
-
definitionPath: untracked.path,
|
|
1066
|
-
entrypoint: null,
|
|
1067
|
-
daemon: null,
|
|
1068
|
-
issues: [`A Treeport service definition exists at ${untracked.path}, but its service record is missing. Restore the original Treeport data directory or ask an administrator to inspect and remove the definition.`],
|
|
1069
|
-
recoveryCommands: [],
|
|
1070
|
-
administratorCommand: null
|
|
1071
|
-
};
|
|
1072
|
-
}
|
|
1073
|
-
const paths = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
|
|
1074
|
-
const [managerStatus, definitionContent, entrypointExists, runtimeExecutableExists, runtimeEntrypointExists, currentRuntime, daemon] = await Promise.all([
|
|
1075
|
-
managerState(record),
|
|
1076
|
-
fs.readFile(record.definitionPath, "utf8").catch(() => ""),
|
|
1077
|
-
fs.access(record.cliEntrypoint, constants.X_OK).then(() => true).catch(() => false),
|
|
1078
|
-
record.runtimeExecutable ? fs.access(record.runtimeExecutable, constants.X_OK).then(() => true).catch(() => false) : Promise.resolve(false),
|
|
1079
|
-
record.runtimeEntrypoint ? fs.access(record.runtimeEntrypoint, constants.R_OK).then(() => true).catch(() => false) : Promise.resolve(false),
|
|
1080
|
-
currentAdministratorRuntime().catch(() => null),
|
|
1081
|
-
daemonStatus()
|
|
1082
|
-
]);
|
|
1083
|
-
const definitionPresent = definitionContent !== "";
|
|
1084
|
-
const definitionMatches = definitionPresent && fingerprint(definitionContent) === record.definitionHash;
|
|
1085
|
-
const invokedEntrypoint = currentEntrypoint();
|
|
1086
|
-
const entrypointMatches = Boolean(entrypointExists && runtimeExecutableExists && runtimeEntrypointExists && currentRuntime && record.runtimeExecutable === currentRuntime.runtimeExecutable && record.runtimeEntrypoint === currentRuntime.runtimeEntrypoint && (invokedEntrypoint === null || path.resolve(invokedEntrypoint) === path.resolve(record.cliEntrypoint)));
|
|
1087
|
-
const environmentMatches = fingerprint(createServiceEnvironment({
|
|
1088
|
-
user: {
|
|
1089
|
-
uid: record.uid,
|
|
1090
|
-
gid: record.gid,
|
|
1091
|
-
username: record.username,
|
|
1092
|
-
homedir: record.home,
|
|
1093
|
-
shell: record.environment.TREEPORT_SHELL ?? null
|
|
1094
|
-
},
|
|
1095
|
-
paths: localPaths({
|
|
1096
|
-
TREEPORT_DATA_DIR: record.dataDir,
|
|
1097
|
-
TREEPORT_RUNTIME_DIR: record.runtimeDir
|
|
1098
|
-
}),
|
|
1099
|
-
apiUrl: record.apiUrl,
|
|
1100
|
-
recordPath: paths.recordPath,
|
|
1101
|
-
installationMethod: record.installationMethod
|
|
1102
|
-
})) === record.environmentHash;
|
|
1103
|
-
const healthy = Boolean(daemon.verified && daemon.health?.daemonLifecycle === "service" && daemon.state?.daemonLifecycle === "service" && path.resolve(daemon.state.dataDir) === path.resolve(record.dataDir));
|
|
1104
|
-
const installed = managerStatus.enabled;
|
|
1105
|
-
const rebootReady = installed && (record.manager === "launchd" || managerStatus.lingering);
|
|
1106
|
-
const pendingCommand = administratorCommand(record) ?? (record.manager === "systemd" && managerStatus.enabled && !managerStatus.lingering ? `sudo loginctl enable-linger ${record.username}` : null);
|
|
1107
|
-
const issues = [];
|
|
1108
|
-
const recoveryCommands = [];
|
|
1109
|
-
if (record.manager !== manager) issues.push(`The service record uses ${record.manager}, but this host requires ${manager}.`);
|
|
1110
|
-
if (!definitionMatches && !record.pendingAdministratorRequestId) {
|
|
1111
|
-
issues.push(definitionPresent ? `The service definition at ${record.definitionPath} was changed.` : `The service definition is missing at ${record.definitionPath}.`);
|
|
1112
|
-
recoveryCommands.push("treeport service enable");
|
|
1113
|
-
}
|
|
1114
|
-
if (definitionMatches && !installed && !record.pendingAdministratorRequestId) {
|
|
1115
|
-
issues.push("The service definition is not enabled for startup after reboot.");
|
|
1116
|
-
recoveryCommands.push("treeport service enable");
|
|
1117
|
-
}
|
|
1118
|
-
if (!entrypointMatches) {
|
|
1119
|
-
issues.push(`The service CLI entrypoint or Node runtime is unavailable or moved: ${record.cliEntrypoint}`);
|
|
1120
|
-
recoveryCommands.push("treeport service enable");
|
|
1121
|
-
}
|
|
1122
|
-
if (!environmentMatches) {
|
|
1123
|
-
issues.push("The service environment differs from the current Treeport environment.");
|
|
1124
|
-
recoveryCommands.push("treeport service enable");
|
|
1125
|
-
}
|
|
1126
|
-
if (record.manager === "systemd" && installed && !managerStatus.lingering) {
|
|
1127
|
-
issues.push(`User lingering is disabled for ${record.username}.`);
|
|
1128
|
-
recoveryCommands.push(`sudo loginctl enable-linger ${record.username}`);
|
|
1129
|
-
}
|
|
1130
|
-
if (managerStatus.managerIssue) issues.push(managerStatus.managerIssue);
|
|
1131
|
-
if (installed && record.requestedState === "running" && !healthy && !record.pendingAdministratorRequestId) {
|
|
1132
|
-
issues.push("The supervised Treeport daemon is not healthy.");
|
|
1133
|
-
recoveryCommands.push("treeport start");
|
|
1134
|
-
}
|
|
1135
|
-
const stale = record.manager !== manager || !definitionMatches || !environmentMatches || !entrypointMatches || definitionPresent && !installed || managerStatus.managerIssue !== null;
|
|
1136
|
-
return {
|
|
1137
|
-
supported: true,
|
|
1138
|
-
manager,
|
|
1139
|
-
state: record.pendingAdministratorRequestId || record.manager === "systemd" && installed && !managerStatus.lingering ? "action_required" : stale ? "stale" : healthy ? "healthy" : installed && record.requestedState === "stopped" ? "stopped" : installed && managerStatus.active ? "starting" : installed ? "unhealthy" : "disabled",
|
|
1140
|
-
installed,
|
|
1141
|
-
enabledAtBoot: installed,
|
|
1142
|
-
active: managerStatus.active,
|
|
1143
|
-
healthy,
|
|
1144
|
-
rebootReady,
|
|
1145
|
-
definitionMatches,
|
|
1146
|
-
environmentMatches,
|
|
1147
|
-
entrypointMatches,
|
|
1148
|
-
requestedState: record.requestedState,
|
|
1149
|
-
definitionPath: record.definitionPath,
|
|
1150
|
-
entrypoint: record.cliEntrypoint,
|
|
1151
|
-
daemon,
|
|
1152
|
-
issues,
|
|
1153
|
-
recoveryCommands: [...new Set(recoveryCommands)],
|
|
1154
|
-
administratorCommand: pendingCommand
|
|
1155
|
-
};
|
|
1156
|
-
}
|
|
1157
|
-
async function prepareRecord() {
|
|
1158
|
-
if (process.getuid?.() === 0) throw new Error("Run `treeport service enable` as the user who will run Treeport, not as root.");
|
|
1159
|
-
const manager = managerForPlatform();
|
|
1160
|
-
if (!manager) throw new Error(`Treeport service mode supports macOS launchd and Linux systemd; found ${process.platform}.`);
|
|
1161
|
-
const explicitApiUrl = process.env.TREEPORT_API_URL?.trim();
|
|
1162
|
-
if (explicitApiUrl) assertLoopbackHost(new URL(explicitApiUrl).hostname);
|
|
1163
|
-
const user = os.userInfo();
|
|
1164
|
-
const paths = localPaths();
|
|
1165
|
-
const locations = servicePaths();
|
|
1166
|
-
const apiUrl = await resolveLocalApiUrl();
|
|
1167
|
-
const listener = new URL(apiUrl);
|
|
1168
|
-
if (listener.protocol !== "http:") throw new Error("Treeport service mode requires a local HTTP loopback URL.");
|
|
1169
|
-
assertLoopbackHost(listener.hostname);
|
|
1170
|
-
const installationMethod = process.env.TREEPORT_INSTALLATION_METHOD?.trim() === "curl" ? "curl" : "npm";
|
|
1171
|
-
const [cliEntrypoint, administratorRuntime] = await Promise.all([ensureEntrypoint(installationMethod), currentAdministratorRuntime()]);
|
|
1172
|
-
const group = await primaryGroup(user.username);
|
|
1173
|
-
const definitionName = manager === "launchd" ? `app.treeport.daemon.${user.uid}` : "treeport.service";
|
|
1174
|
-
const definitionPath = manager === "launchd" ? `/Library/LaunchDaemons/${definitionName}.plist` : path.join(process.env.XDG_CONFIG_HOME?.trim() || path.join(user.homedir, ".config"), "systemd", "user", definitionName);
|
|
1175
|
-
const environment = createServiceEnvironment({
|
|
1176
|
-
user,
|
|
1177
|
-
paths,
|
|
1178
|
-
apiUrl,
|
|
1179
|
-
recordPath: locations.recordPath,
|
|
1180
|
-
installationMethod
|
|
1181
|
-
});
|
|
1182
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1183
|
-
const previous = await currentRecord();
|
|
1184
|
-
if (await fs.access(definitionPath).then(() => true).catch(() => false) && !previous) throw new Error(`A Treeport service definition already exists at ${definitionPath}. Disable it from its original data directory before enabling another service.`);
|
|
1185
|
-
if (previous && path.resolve(previous.dataDir) !== paths.dataDir) throw new Error(`Treeport service mode already uses ${previous.dataDir}. Disable it before enabling ${paths.dataDir}.`);
|
|
1186
|
-
const base = {
|
|
1187
|
-
schemaVersion: 1,
|
|
1188
|
-
manager,
|
|
1189
|
-
platform: process.platform,
|
|
1190
|
-
uid: user.uid,
|
|
1191
|
-
gid: user.gid,
|
|
1192
|
-
username: user.username,
|
|
1193
|
-
group,
|
|
1194
|
-
home: user.homedir,
|
|
1195
|
-
dataDir: paths.dataDir,
|
|
1196
|
-
runtimeDir: paths.runtimeDir,
|
|
1197
|
-
logPath: paths.logPath,
|
|
1198
|
-
apiUrl,
|
|
1199
|
-
cliEntrypoint,
|
|
1200
|
-
runtimeExecutable: administratorRuntime.runtimeExecutable,
|
|
1201
|
-
runtimeEntrypoint: administratorRuntime.runtimeEntrypoint,
|
|
1202
|
-
installationMethod,
|
|
1203
|
-
definitionName,
|
|
1204
|
-
definitionPath,
|
|
1205
|
-
definitionHash: "0".repeat(64),
|
|
1206
|
-
environmentHash: fingerprint(environment),
|
|
1207
|
-
environment,
|
|
1208
|
-
requestedState: "running",
|
|
1209
|
-
pendingAdministratorRequestId: null,
|
|
1210
|
-
createdAt: previous?.createdAt ?? now,
|
|
1211
|
-
updatedAt: now
|
|
1212
|
-
};
|
|
1213
|
-
const definition = definitionForRecord(base);
|
|
1214
|
-
return {
|
|
1215
|
-
record: {
|
|
1216
|
-
...base,
|
|
1217
|
-
definitionHash: fingerprint(definition)
|
|
1218
|
-
},
|
|
1219
|
-
definition
|
|
1220
|
-
};
|
|
1221
|
-
}
|
|
1222
|
-
async function writeServiceFiles(record, definition) {
|
|
1223
|
-
const locations = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
|
|
1224
|
-
await Promise.all([fs.mkdir(path.dirname(record.logPath), {
|
|
1225
|
-
recursive: true,
|
|
1226
|
-
mode: 448
|
|
1227
|
-
}), fs.mkdir(locations.requestsDirectory, {
|
|
1228
|
-
recursive: true,
|
|
1229
|
-
mode: 448
|
|
1230
|
-
})]);
|
|
1231
|
-
await fs.writeFile(locations.runnerPath, runnerSource(record), { mode: 448 });
|
|
1232
|
-
await fs.chmod(locations.runnerPath, 448);
|
|
1233
|
-
if (record.manager === "launchd") await fs.writeFile(locations.stagedDefinitionPath, definition, { mode: 384 });
|
|
1234
|
-
else {
|
|
1235
|
-
await fs.mkdir(path.dirname(record.definitionPath), {
|
|
1236
|
-
recursive: true,
|
|
1237
|
-
mode: 448
|
|
1238
|
-
});
|
|
1239
|
-
const temporaryPath = `${record.definitionPath}.${process.pid}.tmp`;
|
|
1240
|
-
await fs.writeFile(temporaryPath, definition, { mode: 384 });
|
|
1241
|
-
await fs.rename(temporaryPath, record.definitionPath);
|
|
1242
|
-
}
|
|
1243
|
-
await saveRecord(record);
|
|
1244
|
-
}
|
|
1245
|
-
async function prepareAdministratorRequest(record, operation) {
|
|
1246
|
-
const runtime = record.runtimeExecutable && record.runtimeEntrypoint ? {
|
|
1247
|
-
runtimeExecutable: record.runtimeExecutable,
|
|
1248
|
-
runtimeEntrypoint: record.runtimeEntrypoint
|
|
1249
|
-
} : await currentAdministratorRuntime();
|
|
1250
|
-
const requestRecord = {
|
|
1251
|
-
...record,
|
|
1252
|
-
...runtime
|
|
1253
|
-
};
|
|
1254
|
-
const locations = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
|
|
1255
|
-
const id = crypto.randomUUID();
|
|
1256
|
-
const now = /* @__PURE__ */ new Date();
|
|
1257
|
-
const request = {
|
|
1258
|
-
schemaVersion: 1,
|
|
1259
|
-
id,
|
|
1260
|
-
operation,
|
|
1261
|
-
createdAt: now.toISOString(),
|
|
1262
|
-
expiresAt: new Date(now.getTime() + 15 * 6e4).toISOString(),
|
|
1263
|
-
uid: record.uid,
|
|
1264
|
-
gid: record.gid,
|
|
1265
|
-
username: record.username,
|
|
1266
|
-
group: record.group,
|
|
1267
|
-
home: record.home,
|
|
1268
|
-
serviceRecordPath: locations.recordPath,
|
|
1269
|
-
runnerPath: locations.runnerPath,
|
|
1270
|
-
definitionName: record.definitionName,
|
|
1271
|
-
definitionPath: record.definitionPath,
|
|
1272
|
-
stagedDefinitionPath: locations.stagedDefinitionPath,
|
|
1273
|
-
definitionHash: record.definitionHash,
|
|
1274
|
-
apiUrl: record.apiUrl,
|
|
1275
|
-
cliEntrypoint: record.cliEntrypoint,
|
|
1276
|
-
runtimeExecutable: requestRecord.runtimeExecutable,
|
|
1277
|
-
runtimeEntrypoint: requestRecord.runtimeEntrypoint
|
|
1278
|
-
};
|
|
1279
|
-
await writeJson(path.join(locations.requestsDirectory, `${id}.json`), request);
|
|
1280
|
-
const next = {
|
|
1281
|
-
...requestRecord,
|
|
1282
|
-
pendingAdministratorRequestId: id,
|
|
1283
|
-
updatedAt: now.toISOString()
|
|
1284
|
-
};
|
|
1285
|
-
await saveRecord(next);
|
|
1286
|
-
return {
|
|
1287
|
-
record: next,
|
|
1288
|
-
command: administratorCommand(next)
|
|
1289
|
-
};
|
|
1290
|
-
}
|
|
1291
|
-
async function waitForService(record) {
|
|
1292
|
-
const deadline = Date.now() + 15e3;
|
|
1293
|
-
const version = await treeportVersion();
|
|
1294
|
-
while (Date.now() < deadline) {
|
|
1295
|
-
const observed = await daemonHealth(record.apiUrl, 500);
|
|
1296
|
-
if (observed?.daemonLifecycle === "service" && observed.instanceId && observed.version === version) return;
|
|
1297
|
-
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
1298
|
-
}
|
|
1299
|
-
throw new Error(`Treeport service did not become ready at ${record.apiUrl}. See ${record.logPath}.`);
|
|
1300
|
-
}
|
|
1301
|
-
async function serviceEnable() {
|
|
1302
|
-
const existing = await serviceStatus();
|
|
1303
|
-
if (existing.state === "healthy" && existing.definitionMatches && existing.environmentMatches && existing.entrypointMatches) return {
|
|
1304
|
-
status: existing,
|
|
1305
|
-
changed: false,
|
|
1306
|
-
administratorCommand: null
|
|
1307
|
-
};
|
|
1308
|
-
const { record, definition } = await prepareRecord();
|
|
1309
|
-
const failedChecks = (await runDoctor()).filter((check) => !check.ok);
|
|
1310
|
-
if (failedChecks.length) throw new Error(failedChecks.map((check) => `${check.name}: ${check.detail}`).join("\n"));
|
|
1311
|
-
const systemctl = record.manager === "systemd" ? await executablePath("systemctl") : null;
|
|
1312
|
-
if (systemctl) {
|
|
1313
|
-
const managerAvailable = await runCommand(systemctl, ["--user", "show-environment"]);
|
|
1314
|
-
if (managerAvailable.code !== 0) throw commandError("systemctl --user", managerAvailable);
|
|
1315
|
-
}
|
|
1316
|
-
await writeServiceFiles(record, definition);
|
|
1317
|
-
if (record.manager === "launchd") {
|
|
1318
|
-
await daemonDown();
|
|
1319
|
-
const prepared = await prepareAdministratorRequest(record, "enable");
|
|
1320
|
-
return {
|
|
1321
|
-
status: await serviceStatus(),
|
|
1322
|
-
changed: true,
|
|
1323
|
-
administratorCommand: prepared.command
|
|
1324
|
-
};
|
|
1325
|
-
}
|
|
1326
|
-
if (!systemctl) throw new Error("Treeport could not resolve the systemd command.");
|
|
1327
|
-
await daemonDown();
|
|
1328
|
-
const reload = await runCommand(systemctl, ["--user", "daemon-reload"]);
|
|
1329
|
-
if (reload.code !== 0) {
|
|
1330
|
-
await Promise.all([fs.rm(record.definitionPath, { force: true }), fs.rm(servicePaths().directory, {
|
|
1331
|
-
recursive: true,
|
|
1332
|
-
force: true
|
|
1333
|
-
})]);
|
|
1334
|
-
await daemonUp({});
|
|
1335
|
-
throw commandError("systemctl --user daemon-reload", reload);
|
|
1336
|
-
}
|
|
1337
|
-
const enabled = await runCommand(systemctl, [
|
|
1338
|
-
"--user",
|
|
1339
|
-
"enable",
|
|
1340
|
-
"--now",
|
|
1341
|
-
record.definitionName
|
|
1342
|
-
]);
|
|
1343
|
-
if (enabled.code !== 0) {
|
|
1344
|
-
await fs.rm(record.definitionPath, { force: true });
|
|
1345
|
-
await runCommand(systemctl, ["--user", "daemon-reload"]);
|
|
1346
|
-
await fs.rm(servicePaths().directory, {
|
|
1347
|
-
recursive: true,
|
|
1348
|
-
force: true
|
|
1349
|
-
});
|
|
1350
|
-
await daemonUp({});
|
|
1351
|
-
throw commandError("systemctl --user enable --now", enabled);
|
|
1352
|
-
}
|
|
1353
|
-
const startupError = await waitForService(record).then(() => null, (error) => error);
|
|
1354
|
-
if (startupError) {
|
|
1355
|
-
await runCommand(systemctl, [
|
|
1356
|
-
"--user",
|
|
1357
|
-
"disable",
|
|
1358
|
-
"--now",
|
|
1359
|
-
record.definitionName
|
|
1360
|
-
]);
|
|
1361
|
-
await fs.rm(record.definitionPath, { force: true });
|
|
1362
|
-
await runCommand(systemctl, ["--user", "daemon-reload"]);
|
|
1363
|
-
await fs.rm(servicePaths().directory, {
|
|
1364
|
-
recursive: true,
|
|
1365
|
-
force: true
|
|
1366
|
-
});
|
|
1367
|
-
await daemonUp({});
|
|
1368
|
-
throw startupError;
|
|
1369
|
-
}
|
|
1370
|
-
const status = await serviceStatus();
|
|
1371
|
-
return {
|
|
1372
|
-
status,
|
|
1373
|
-
changed: true,
|
|
1374
|
-
administratorCommand: status.administratorCommand
|
|
1375
|
-
};
|
|
1376
|
-
}
|
|
1377
|
-
async function serviceStart() {
|
|
1378
|
-
const record = await currentRecord();
|
|
1379
|
-
if (!record) throw new Error("Treeport service mode is disabled. Run `treeport service enable` first.");
|
|
1380
|
-
const current = await serviceStatus();
|
|
1381
|
-
if (current.state === "healthy") return {
|
|
1382
|
-
status: current,
|
|
1383
|
-
changed: false,
|
|
1384
|
-
administratorCommand: null
|
|
1385
|
-
};
|
|
1386
|
-
if (current.administratorCommand) return {
|
|
1387
|
-
status: current,
|
|
1388
|
-
changed: false,
|
|
1389
|
-
administratorCommand: current.administratorCommand
|
|
1390
|
-
};
|
|
1391
|
-
if (!current.definitionMatches || !current.entrypointMatches) throw new Error("The Treeport service definition is stale. Run `treeport service enable` to repair it.");
|
|
1392
|
-
const next = {
|
|
1393
|
-
...record,
|
|
1394
|
-
requestedState: "running",
|
|
1395
|
-
pendingAdministratorRequestId: null,
|
|
1396
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1397
|
-
};
|
|
1398
|
-
await saveRecord(next);
|
|
1399
|
-
if (record.manager === "launchd") {
|
|
1400
|
-
const prepared = await prepareAdministratorRequest(next, "start");
|
|
1401
|
-
return {
|
|
1402
|
-
status: await serviceStatus(),
|
|
1403
|
-
changed: true,
|
|
1404
|
-
administratorCommand: prepared.command
|
|
1405
|
-
};
|
|
1406
|
-
}
|
|
1407
|
-
const result = await runCommand(await executablePath("systemctl"), [
|
|
1408
|
-
"--user",
|
|
1409
|
-
"start",
|
|
1410
|
-
record.definitionName
|
|
1411
|
-
]);
|
|
1412
|
-
if (result.code !== 0) throw commandError("systemctl --user start", result);
|
|
1413
|
-
await waitForService(next);
|
|
1414
|
-
return {
|
|
1415
|
-
status: await serviceStatus(),
|
|
1416
|
-
changed: true,
|
|
1417
|
-
administratorCommand: null
|
|
1418
|
-
};
|
|
1419
|
-
}
|
|
1420
|
-
async function serviceStop() {
|
|
1421
|
-
const record = await currentRecord();
|
|
1422
|
-
if (!record) throw new Error("Treeport service mode is disabled.");
|
|
1423
|
-
const current = await serviceStatus();
|
|
1424
|
-
if (current.state === "stopped") return {
|
|
1425
|
-
status: current,
|
|
1426
|
-
changed: false,
|
|
1427
|
-
administratorCommand: null
|
|
1428
|
-
};
|
|
1429
|
-
const next = {
|
|
1430
|
-
...record,
|
|
1431
|
-
requestedState: "stopped",
|
|
1432
|
-
pendingAdministratorRequestId: null,
|
|
1433
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1434
|
-
};
|
|
1435
|
-
await saveRecord(next);
|
|
1436
|
-
if (record.manager === "launchd") {
|
|
1437
|
-
const prepared = await prepareAdministratorRequest(next, "stop");
|
|
1438
|
-
return {
|
|
1439
|
-
status: await serviceStatus(),
|
|
1440
|
-
changed: true,
|
|
1441
|
-
administratorCommand: prepared.command
|
|
1442
|
-
};
|
|
1443
|
-
}
|
|
1444
|
-
const result = await runCommand(await executablePath("systemctl"), [
|
|
1445
|
-
"--user",
|
|
1446
|
-
"stop",
|
|
1447
|
-
record.definitionName
|
|
1448
|
-
]);
|
|
1449
|
-
if (result.code !== 0) {
|
|
1450
|
-
await saveRecord(record);
|
|
1451
|
-
throw commandError("systemctl --user stop", result);
|
|
1452
|
-
}
|
|
1453
|
-
return {
|
|
1454
|
-
status: await serviceStatus(),
|
|
1455
|
-
changed: true,
|
|
1456
|
-
administratorCommand: null
|
|
1457
|
-
};
|
|
1458
|
-
}
|
|
1459
|
-
async function serviceDisable() {
|
|
1460
|
-
const record = await currentRecord();
|
|
1461
|
-
if (!record) return {
|
|
1462
|
-
status: await serviceStatus(),
|
|
1463
|
-
changed: false,
|
|
1464
|
-
administratorCommand: null
|
|
1465
|
-
};
|
|
1466
|
-
if (record.manager === "launchd") {
|
|
1467
|
-
const prepared = await prepareAdministratorRequest({
|
|
1468
|
-
...record,
|
|
1469
|
-
pendingAdministratorRequestId: null
|
|
1470
|
-
}, "disable");
|
|
1471
|
-
return {
|
|
1472
|
-
status: await serviceStatus(),
|
|
1473
|
-
changed: true,
|
|
1474
|
-
administratorCommand: prepared.command
|
|
1475
|
-
};
|
|
1476
|
-
}
|
|
1477
|
-
const systemctl = await executablePath("systemctl");
|
|
1478
|
-
const disabled = await runCommand(systemctl, [
|
|
1479
|
-
"--user",
|
|
1480
|
-
"disable",
|
|
1481
|
-
"--now",
|
|
1482
|
-
record.definitionName
|
|
1483
|
-
]);
|
|
1484
|
-
if (disabled.code !== 0 && !disabled.stderr.includes("does not exist")) throw commandError("systemctl --user disable --now", disabled);
|
|
1485
|
-
await fs.rm(record.definitionPath, { force: true });
|
|
1486
|
-
await runCommand(systemctl, ["--user", "daemon-reload"]);
|
|
1487
|
-
await fs.rm(servicePaths().directory, {
|
|
1488
|
-
recursive: true,
|
|
1489
|
-
force: true
|
|
1490
|
-
});
|
|
1491
|
-
return {
|
|
1492
|
-
status: await serviceStatus(),
|
|
1493
|
-
changed: true,
|
|
1494
|
-
administratorCommand: null
|
|
1495
|
-
};
|
|
1496
|
-
}
|
|
1497
|
-
async function serviceApply(requestPath) {
|
|
1498
|
-
if (process.platform !== "darwin") throw new Error("Treeport service apply is only available for macOS LaunchDaemons.");
|
|
1499
|
-
if (process.getuid?.() !== 0) throw new Error("Run the printed service apply command with sudo or as root.");
|
|
1500
|
-
if (!path.isAbsolute(requestPath)) throw new Error("The service apply request path must be absolute.");
|
|
1501
|
-
const metadata = await fs.lstat(requestPath);
|
|
1502
|
-
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("The service apply request must be a regular file, not a symlink.");
|
|
1503
|
-
if ((metadata.mode & 63) !== 0) throw new Error("The service apply request must not be readable or writable by other users.");
|
|
1504
|
-
const request = await readJson(requestPath, administratorRequestSchema);
|
|
1505
|
-
if (!request) throw new Error("The service apply request is invalid.");
|
|
1506
|
-
if (metadata.uid !== request.uid) throw new Error("The service apply request owner does not match its target user.");
|
|
1507
|
-
if (Date.parse(request.expiresAt) <= Date.now()) throw new Error("The service apply request expired. Run the original Treeport command again.");
|
|
1508
|
-
const currentRuntime = await currentAdministratorRuntime().catch(() => null);
|
|
1509
|
-
const invokedRuntimeEntrypoint = process.argv[1] ? path.resolve(process.argv[1]) : null;
|
|
1510
|
-
if (!currentRuntime || currentRuntime.runtimeExecutable !== request.runtimeExecutable || currentRuntime.runtimeEntrypoint !== request.runtimeEntrypoint || invokedRuntimeEntrypoint !== request.runtimeEntrypoint) throw new Error("The service apply command did not use the approved Treeport Node runtime and package entrypoint.");
|
|
1511
|
-
const usedPath = `${requestPath}.used`;
|
|
1512
|
-
if (await fs.access(usedPath).then(() => true).catch(() => false)) throw new Error("The service apply request was already used.");
|
|
1513
|
-
const account = os.userInfo({ encoding: "utf8" });
|
|
1514
|
-
const idResult = await runCommand(await executablePath("id"), ["-u", request.username]);
|
|
1515
|
-
if (idResult.code !== 0 || Number(idResult.stdout.trim()) !== request.uid) throw new Error("The service apply target user no longer matches the host account.");
|
|
1516
|
-
const record = await readJson(request.serviceRecordPath, serviceRecordSchema);
|
|
1517
|
-
if (!record || record.uid !== request.uid || record.username !== request.username || record.manager !== "launchd" || record.definitionName !== request.definitionName || record.definitionPath !== request.definitionPath || record.cliEntrypoint !== request.cliEntrypoint || record.runtimeExecutable !== request.runtimeExecutable || record.runtimeEntrypoint !== request.runtimeEntrypoint || record.definitionHash !== request.definitionHash || record.pendingAdministratorRequestId !== request.id) throw new Error("The service apply request does not match the current Treeport service record.");
|
|
1518
|
-
if (account.uid !== 0) throw new Error("Treeport service apply lost root privileges.");
|
|
1519
|
-
const launchctl = await executablePath("launchctl");
|
|
1520
|
-
const target = `system/${request.definitionName}`;
|
|
1521
|
-
if (request.operation === "enable") {
|
|
1522
|
-
const staged = await fs.readFile(request.stagedDefinitionPath, "utf8");
|
|
1523
|
-
if (fingerprint(staged) !== request.definitionHash || !staged.includes(`<string>${xml(request.username)}</string>`) || !staged.includes(`<string>${xml(request.runnerPath)}</string>`)) throw new Error("The staged LaunchDaemon definition does not match the approved request.");
|
|
1524
|
-
const temporaryPath = `${request.definitionPath}.${process.pid}.tmp`;
|
|
1525
|
-
await fs.copyFile(request.stagedDefinitionPath, temporaryPath);
|
|
1526
|
-
await fs.chown(temporaryPath, 0, 0);
|
|
1527
|
-
await fs.chmod(temporaryPath, 420);
|
|
1528
|
-
await fs.rename(temporaryPath, request.definitionPath);
|
|
1529
|
-
await runCommand(launchctl, ["bootout", target]);
|
|
1530
|
-
const enabled = await runCommand(launchctl, ["enable", target]);
|
|
1531
|
-
if (enabled.code !== 0) throw commandError("launchctl enable", enabled);
|
|
1532
|
-
const bootstrapped = await runCommand(launchctl, [
|
|
1533
|
-
"bootstrap",
|
|
1534
|
-
"system",
|
|
1535
|
-
request.definitionPath
|
|
1536
|
-
]);
|
|
1537
|
-
if (bootstrapped.code !== 0) throw commandError("launchctl bootstrap", bootstrapped);
|
|
1538
|
-
} else if (request.operation === "start") {
|
|
1539
|
-
const enabled = await runCommand(launchctl, ["enable", target]);
|
|
1540
|
-
if (enabled.code !== 0) throw commandError("launchctl enable", enabled);
|
|
1541
|
-
const started = (await runCommand(launchctl, ["print", target])).code === 0 ? await runCommand(launchctl, ["kickstart", target]) : await runCommand(launchctl, [
|
|
1542
|
-
"bootstrap",
|
|
1543
|
-
"system",
|
|
1544
|
-
request.definitionPath
|
|
1545
|
-
]);
|
|
1546
|
-
if (started.code !== 0) throw commandError("launchctl start", started);
|
|
1547
|
-
} else if (request.operation === "stop") {
|
|
1548
|
-
const stopped = await runCommand(launchctl, ["bootout", target]);
|
|
1549
|
-
if (stopped.code !== 0 && !stopped.stderr.includes("No such process")) throw commandError("launchctl bootout", stopped);
|
|
1550
|
-
} else {
|
|
1551
|
-
const installed = await fs.readFile(request.definitionPath, "utf8").catch(() => "");
|
|
1552
|
-
if (installed && fingerprint(installed) !== request.definitionHash) throw new Error("Refusing to remove a LaunchDaemon definition that Treeport did not create.");
|
|
1553
|
-
await runCommand(launchctl, ["bootout", target]);
|
|
1554
|
-
await fs.rm(request.definitionPath, { force: true });
|
|
1555
|
-
}
|
|
1556
|
-
if (request.operation === "enable" || request.operation === "start") await waitForService(record);
|
|
1557
|
-
await fs.rename(requestPath, usedPath);
|
|
1558
|
-
if (request.operation === "disable") await fs.rm(path.dirname(request.serviceRecordPath), {
|
|
1559
|
-
recursive: true,
|
|
1560
|
-
force: true
|
|
1561
|
-
});
|
|
1562
|
-
else {
|
|
1563
|
-
await writeJson(request.serviceRecordPath, {
|
|
1564
|
-
...record,
|
|
1565
|
-
requestedState: request.operation === "stop" ? "stopped" : "running",
|
|
1566
|
-
pendingAdministratorRequestId: null,
|
|
1567
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1568
|
-
});
|
|
1569
|
-
await fs.chown(request.serviceRecordPath, request.uid, request.gid);
|
|
1570
|
-
}
|
|
1571
|
-
return {
|
|
1572
|
-
operation: request.operation,
|
|
1573
|
-
applied: true
|
|
1574
|
-
};
|
|
1575
|
-
}
|
|
1576
|
-
async function serviceRun() {
|
|
1577
|
-
const recordPath = process.env.TREEPORT_SERVICE_RECORD?.trim();
|
|
1578
|
-
if (!recordPath || !path.isAbsolute(recordPath)) throw new Error("Treeport service run requires a valid service record.");
|
|
1579
|
-
const record = await readJson(recordPath, serviceRecordSchema);
|
|
1580
|
-
if (!record) throw new Error(`Treeport service record is invalid: ${recordPath}`);
|
|
1581
|
-
if (process.getuid?.() === 0 || process.getuid?.() !== record.uid) throw new Error(`Treeport service must run as ${record.username} (UID ${record.uid}), never as root.`);
|
|
1582
|
-
await writeJson(recordPath, {
|
|
1583
|
-
...record,
|
|
1584
|
-
requestedState: "running",
|
|
1585
|
-
pendingAdministratorRequestId: null,
|
|
1586
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1587
|
-
});
|
|
1588
|
-
const [version, serverEntry, webDist] = await Promise.all([
|
|
1589
|
-
treeportVersion(),
|
|
1590
|
-
resolvePackagePath("dist", "node", "server", "index.js"),
|
|
1591
|
-
resolvePackagePath("dist", "web")
|
|
1592
|
-
]);
|
|
1593
|
-
Object.assign(process.env, record.environment, {
|
|
1594
|
-
TREEPORT_APP_VERSION: version,
|
|
1595
|
-
TREEPORT_INSTANCE_ID: crypto.randomUUID(),
|
|
1596
|
-
TREEPORT_WEB_DIST: webDist,
|
|
1597
|
-
TREEPORT_DAEMON_LIFECYCLE: "service"
|
|
1598
|
-
});
|
|
1599
|
-
await import(pathToFileURL(serverEntry).href);
|
|
1600
|
-
}
|
|
1601
|
-
async function readServiceLogs(lines) {
|
|
1602
|
-
const record = await currentRecord();
|
|
1603
|
-
if (!record || record.manager === "launchd") return (await fs.readFile(record?.logPath ?? localPaths().logPath, "utf8").catch((error) => {
|
|
1604
|
-
if (error.code === "ENOENT") return "";
|
|
1605
|
-
throw error;
|
|
1606
|
-
})).split("\n").slice(-lines - 1).join("\n");
|
|
1607
|
-
const result = await runCommand(await executablePath("journalctl"), [
|
|
1608
|
-
"--user",
|
|
1609
|
-
"--unit",
|
|
1610
|
-
record.definitionName,
|
|
1611
|
-
"--no-pager",
|
|
1612
|
-
"--lines",
|
|
1613
|
-
String(lines)
|
|
1614
|
-
]);
|
|
1615
|
-
if (result.code !== 0) throw commandError("journalctl --user", result);
|
|
1616
|
-
return result.stdout;
|
|
1617
|
-
}
|
|
1618
|
-
async function serviceDoctorCheck() {
|
|
1619
|
-
const status = await serviceStatus();
|
|
1620
|
-
if (!status.supported) return {
|
|
1621
|
-
name: "Service supervision",
|
|
1622
|
-
ok: false,
|
|
1623
|
-
detail: status.issues.join(" ")
|
|
1624
|
-
};
|
|
1625
|
-
if (status.state === "disabled") return {
|
|
1626
|
-
name: "Service supervision",
|
|
1627
|
-
ok: true,
|
|
1628
|
-
detail: "disabled (opt in with `treeport service enable`)"
|
|
1629
|
-
};
|
|
1630
|
-
if (status.state === "healthy") return {
|
|
1631
|
-
name: "Service supervision",
|
|
1632
|
-
ok: true,
|
|
1633
|
-
detail: `${status.manager}; enabled at boot and healthy`
|
|
1634
|
-
};
|
|
1635
|
-
if (status.state === "stopped") return {
|
|
1636
|
-
name: "Service supervision",
|
|
1637
|
-
ok: true,
|
|
1638
|
-
detail: `${status.manager}; intentionally stopped and enabled for next boot`
|
|
1639
|
-
};
|
|
1640
|
-
return {
|
|
1641
|
-
name: "Service supervision",
|
|
1642
|
-
ok: false,
|
|
1643
|
-
detail: status.issues.join(" ") || `state: ${status.state}`
|
|
1644
|
-
};
|
|
1645
|
-
}
|
|
1646
|
-
//#endregion
|
|
1647
80
|
//#region src/cli/application.ts
|
|
1648
81
|
const contextPrefix = "TREEPORT";
|
|
1649
82
|
let configuredApiUrl;
|
|
@@ -1661,6 +94,7 @@ let writeStderr = (value) => {
|
|
|
1661
94
|
process.stderr.write(value);
|
|
1662
95
|
};
|
|
1663
96
|
let requestedExitCode = 0;
|
|
97
|
+
let cliEnvironment = process.env;
|
|
1664
98
|
var CliError = class extends Error {
|
|
1665
99
|
exitCode;
|
|
1666
100
|
code;
|
|
@@ -1682,10 +116,12 @@ async function resolveDaemonLifecycle() {
|
|
|
1682
116
|
return await serviceInstalled() ? "service" : "treeport";
|
|
1683
117
|
}
|
|
1684
118
|
function formatServiceStatus(status) {
|
|
119
|
+
const mode = status.mode === "headless" ? "advanced headless (starts before login)" : status.mode === "user" && status.manager === "launchd" ? "user/login (starts after login)" : status.mode === "user" ? "user service" : "not installed";
|
|
1685
120
|
const lines = [
|
|
1686
121
|
`Treeport service: ${status.state}`,
|
|
122
|
+
`Mode: ${mode}`,
|
|
1687
123
|
`Manager: ${status.manager ?? "unsupported"}`,
|
|
1688
|
-
`Starts
|
|
124
|
+
`Starts before login: ${status.enabledAtBoot ? "yes" : "no"}`,
|
|
1689
125
|
`Active: ${status.active ? "yes" : "no"}`,
|
|
1690
126
|
`Definition: ${status.definitionPath ?? "not installed"}`
|
|
1691
127
|
];
|
|
@@ -1789,9 +225,12 @@ async function resolveProject(identifier) {
|
|
|
1789
225
|
const environmentMatch = list.find((project) => project.id === contextProjectId);
|
|
1790
226
|
if (environmentMatch) return environmentMatch;
|
|
1791
227
|
}
|
|
1792
|
-
const candidate = await canonical(identifier);
|
|
1793
|
-
const match = list.
|
|
1794
|
-
|
|
228
|
+
const candidate = await canonical(identifier ?? ".");
|
|
229
|
+
const match = list.flatMap((project) => [project.rootPath, ...project.worktrees.map((item) => item.path)].map((root) => ({
|
|
230
|
+
project,
|
|
231
|
+
root
|
|
232
|
+
}))).filter(({ root }) => pathContains(candidate, root)).sort((left, right) => right.root.length - left.root.length)[0]?.project;
|
|
233
|
+
if (!match) throw new CliError(identifier === void 0 ? `No registered project contains ${candidate}. Specify --project <id-or-path>.` : `No registered project matches ${identifier}`, 5);
|
|
1795
234
|
return match;
|
|
1796
235
|
}
|
|
1797
236
|
async function packageSource(value) {
|
|
@@ -2008,7 +447,7 @@ const agentGuidance = `AI agents:
|
|
|
2008
447
|
async function main(args) {
|
|
2009
448
|
const argv = args[0] === "spawn" || args[0] === "terminal" && args[1] === "create" ? commandArgv(args) : void 0;
|
|
2010
449
|
let parserError = "";
|
|
2011
|
-
const program = new Command().name("treeport").usage("[options] [folder] [command]").description("Manage Treeport projects, trees, and terminals.").argument("[folder]", "folder inside a Git repository to open").option("--json", "emit machine-readable JSON").addHelpText("beforeAll", agentGuidance).configureOutput({
|
|
450
|
+
const program = new Command().name("treeport").usage("[options] [folder] [command]").description("Manage Treeport projects, trees, and terminals.").argument("[folder]", "folder or folder inside a Git repository to open").option("--json", "emit machine-readable JSON").addHelpText("beforeAll", agentGuidance).configureOutput({
|
|
2012
451
|
writeOut: writeStdout,
|
|
2013
452
|
writeErr: (value) => {
|
|
2014
453
|
parserError += value;
|
|
@@ -2035,12 +474,9 @@ async function main(args) {
|
|
|
2035
474
|
const registered = await request("/api/projects", {
|
|
2036
475
|
method: "POST",
|
|
2037
476
|
body: JSON.stringify({ path: canonicalFolder })
|
|
2038
|
-
}).catch((error) => {
|
|
2039
|
-
if (error instanceof CliError && error.code === "NOT_A_GIT_REPOSITORY") throw new CliError(`No Git repository contains ${canonicalFolder}.`, error.exitCode, error.code, error.details);
|
|
2040
|
-
throw error;
|
|
2041
477
|
});
|
|
2042
478
|
const targetWorktree = registered.project.worktrees.filter((worktree) => !worktree.prunable && pathContains(canonicalFolder, worktree.path)).sort((left, right) => right.path.length - left.path.length)[0];
|
|
2043
|
-
if (!targetWorktree) throw new CliError(`
|
|
479
|
+
if (!targetWorktree) throw new CliError(`Treeport did not find a workspace containing ${canonicalFolder}.`, 5, "WORKTREE_NOT_FOUND", {
|
|
2044
480
|
path: canonicalFolder,
|
|
2045
481
|
projectId: registered.project.id
|
|
2046
482
|
});
|
|
@@ -2048,7 +484,10 @@ async function main(args) {
|
|
|
2048
484
|
target.pathname = `/projects/${encodeURIComponent(registered.project.id)}/worktrees/${encodeURIComponent(targetWorktree.id)}`;
|
|
2049
485
|
target.search = "";
|
|
2050
486
|
target.hash = "";
|
|
2051
|
-
const opened = await
|
|
487
|
+
const opened = contextTerminalId ? await request(`/api/worktrees/${encodeURIComponent(targetWorktree.id)}/open`, {
|
|
488
|
+
method: "POST",
|
|
489
|
+
body: JSON.stringify({ sourceTerminalId: contextTerminalId })
|
|
490
|
+
}).then(() => ({ client: "current" })) : await openWorkspace(target.href).catch((error) => {
|
|
2052
491
|
if (error instanceof OpenWorkspaceError) throw new CliError(error.message, 1, "OPEN_FAILED", { url: target.href });
|
|
2053
492
|
throw error;
|
|
2054
493
|
});
|
|
@@ -2056,9 +495,10 @@ async function main(args) {
|
|
|
2056
495
|
projectId: registered.project.id,
|
|
2057
496
|
worktreeId: targetWorktree.id,
|
|
2058
497
|
path: canonicalFolder,
|
|
498
|
+
projectKind: registered.project.kind,
|
|
2059
499
|
url: target.href,
|
|
2060
500
|
client: opened.client
|
|
2061
|
-
}, () => `Opened ${registered.project.name} / ${targetWorktree.name} in the ${opened.client === "desktop" ? "Treeport desktop app" : "browser"}\n${target.href}`);
|
|
501
|
+
}, () => `Opened ${registered.project.name} / ${targetWorktree.name} in the ${opened.client === "desktop" ? "Treeport desktop app" : opened.client === "current" ? "current Treeport client" : "browser"}\n${target.href}`);
|
|
2062
502
|
});
|
|
2063
503
|
const startCommand = program.command("start").description("Ensure the local Treeport daemon is running").option("--host <address>", "loopback listener address").option("--port <port>", "listener port").option("--foreground", "run in the foreground").option("--json", "emit machine-readable JSON");
|
|
2064
504
|
startCommand.action(async () => {
|
|
@@ -2101,8 +541,9 @@ async function main(args) {
|
|
|
2101
541
|
serviceCommand.action(() => {
|
|
2102
542
|
writeStdout(serviceCommand.helpInformation());
|
|
2103
543
|
});
|
|
2104
|
-
serviceCommand.command("enable").description("Enable startup
|
|
2105
|
-
|
|
544
|
+
const serviceEnableCommand = serviceCommand.command("enable").description("Enable automatic startup and unexpected-exit restarts").option("--headless", "use advanced macOS startup before login (requires an administrator)").option("--json", "emit machine-readable JSON");
|
|
545
|
+
serviceEnableCommand.action(async () => {
|
|
546
|
+
const result = await serviceEnable(serviceEnableCommand.opts().headless ? "headless" : "user");
|
|
2106
547
|
print(result, () => formatServiceStatus(result.status));
|
|
2107
548
|
if (result.status.state === "action_required") requestedExitCode = 1;
|
|
2108
549
|
});
|
|
@@ -2239,6 +680,8 @@ async function main(args) {
|
|
|
2239
680
|
project: {
|
|
2240
681
|
id: project.id,
|
|
2241
682
|
name: project.name,
|
|
683
|
+
kind: project.kind,
|
|
684
|
+
rootPath: project.rootPath,
|
|
2242
685
|
repositoryPath: project.repositoryPath,
|
|
2243
686
|
mainWorktreePath: project.mainWorktreePath,
|
|
2244
687
|
defaultBranch: project.defaultBranch,
|
|
@@ -2296,10 +739,24 @@ async function main(args) {
|
|
|
2296
739
|
return lines.join("\n");
|
|
2297
740
|
});
|
|
2298
741
|
});
|
|
2299
|
-
const updatePackagesCommand = program.command("update").description("
|
|
742
|
+
const updatePackagesCommand = program.command("update").description("Update Treeport or explicitly update configured packages").argument("[source]", "one configured npm: source").option("--packages", "update every eligible configured package").option("--json", "emit machine-readable JSON");
|
|
2300
743
|
updatePackagesCommand.action(async (source) => {
|
|
2301
744
|
const options = updatePackagesCommand.opts();
|
|
2302
|
-
if (
|
|
745
|
+
if (source && options.packages) throw new CliError("Specify a package source or --packages, not both.", 2);
|
|
746
|
+
if (!source && !options.packages) {
|
|
747
|
+
if (await resolveDaemonLifecycle() === "external") throw new CliError("Cannot update Treeport because this daemon lifecycle is externally managed.", 5, "UPDATE_EXTERNAL_REFUSED");
|
|
748
|
+
const selfUpdateOptions = { environment: cliEnvironment };
|
|
749
|
+
if (!jsonOutput) selfUpdateOptions.progress = (message) => writeStderr(`${message}\n`);
|
|
750
|
+
const result = await runLocalUpdate(selfUpdateOptions).catch((error) => {
|
|
751
|
+
if (error instanceof LocalUpdateError) throw new CliError(error.message, error.exitCode, error.code, error.details);
|
|
752
|
+
throw error;
|
|
753
|
+
});
|
|
754
|
+
print(result, () => {
|
|
755
|
+
if (result.status === "current") return `Treeport ${result.toVersion} is current`;
|
|
756
|
+
return result.daemon.wasRunning ? `Updated Treeport from ${result.fromVersion} to ${result.toVersion} and restarted the ${result.daemon.lifecycle === "service" ? "service" : "daemon"}` : `Updated Treeport from ${result.fromVersion} to ${result.toVersion}; Treeport remains stopped`;
|
|
757
|
+
});
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
2303
760
|
const results = (await request("/api/packages/update", {
|
|
2304
761
|
method: "POST",
|
|
2305
762
|
body: JSON.stringify(source ? { source: await packageSource(source) } : {})
|
|
@@ -2321,16 +778,16 @@ async function main(args) {
|
|
|
2321
778
|
projectCommand.action(() => {
|
|
2322
779
|
throw new CliError(projectCommand.helpInformation(), 2);
|
|
2323
780
|
});
|
|
2324
|
-
projectCommand.command("add").description("Register a Git repository").argument("<path>", "
|
|
781
|
+
projectCommand.command("add").description("Register a folder or Git repository").argument("<path>", "folder path").option("--json", "emit machine-readable JSON").action(async (repository) => {
|
|
2325
782
|
const body = await request("/api/projects", {
|
|
2326
783
|
method: "POST",
|
|
2327
784
|
body: JSON.stringify({ path: await canonical(repository) })
|
|
2328
785
|
});
|
|
2329
|
-
print(body.project, () => `Registered ${body.project.name} (${body.project.id})\n${body.project.
|
|
786
|
+
print(body.project, () => `Registered ${body.project.name} (${body.project.id})\n${body.project.rootPath}`);
|
|
2330
787
|
});
|
|
2331
788
|
projectCommand.command("list").description("List registered projects").option("--json", "emit machine-readable JSON").action(async () => {
|
|
2332
789
|
const list = await projects();
|
|
2333
|
-
print(list, () => list.map((project) => `${project.id}\t${project.name}\t${project.
|
|
790
|
+
print(list, () => list.map((project) => `${project.id}\t${project.name}\t${project.kind}\t${project.rootPath}`).join("\n"));
|
|
2334
791
|
});
|
|
2335
792
|
const worktreeCommand = program.command("worktree").description("List, create, and remove trees");
|
|
2336
793
|
worktreeCommand.action(() => {
|
|
@@ -2340,9 +797,9 @@ async function main(args) {
|
|
|
2340
797
|
worktreeListCommand.action(async () => {
|
|
2341
798
|
const { project: projectIdentifier } = worktreeListCommand.opts();
|
|
2342
799
|
const list = projectIdentifier ? (await resolveProject(projectIdentifier)).worktrees : (await projects()).flatMap((project) => project.worktrees);
|
|
2343
|
-
print(list, () => list.map((worktree) => `${worktree.id}\t${worktree.name}\t${worktree.branch ?? `detached@${worktree.head.slice(0, 8)}`}\t${worktree.path}`).join("\n"));
|
|
800
|
+
print(list, () => list.map((worktree) => `${worktree.id}\t${worktree.name}\t${worktree.kind === "folder" ? "folder" : worktree.branch ?? `detached@${worktree.head.slice(0, 8)}`}\t${worktree.path}`).join("\n"));
|
|
2344
801
|
});
|
|
2345
|
-
const worktreeCreateCommand = worktreeCommand.command("create").description("Create a linked tree").
|
|
802
|
+
const worktreeCreateCommand = worktreeCommand.command("create").description("Create a linked tree").option("--project <id-or-path>", "project to create from (default: current folder)").requiredOption("--name <name>", "Tree name").option("--from-current", "base the tree on the current tree").option("--json", "emit machine-readable JSON");
|
|
2346
803
|
worktreeCreateCommand.action(async () => {
|
|
2347
804
|
const options = worktreeCreateCommand.opts();
|
|
2348
805
|
const project = await resolveProject(options.project);
|
|
@@ -2467,7 +924,7 @@ async function main(args) {
|
|
|
2467
924
|
terminalId
|
|
2468
925
|
}, () => `Deleted ${terminalId}`);
|
|
2469
926
|
});
|
|
2470
|
-
const spawnCommand = program.command("spawn").description("Create a tree and its first terminal").usage("[options] [-- <command> args...]").
|
|
927
|
+
const spawnCommand = program.command("spawn").description("Create a tree and its first terminal").usage("[options] [-- <command> args...]").option("--project <id-or-path-or-dot>", "project to create from (default: current folder)").requiredOption("--worktree-name <name>", "Tree name").requiredOption("--name <terminal-name>", "terminal name").option("--from-current", "base the tree on the current tree").option("--json", "emit machine-readable JSON").addHelpText("after", "\nCommand arguments may be passed after --.\n");
|
|
2471
928
|
spawnCommand.action(async () => {
|
|
2472
929
|
const options = spawnCommand.opts();
|
|
2473
930
|
const project = await resolveProject(options.project);
|
|
@@ -2495,6 +952,7 @@ async function main(args) {
|
|
|
2495
952
|
}
|
|
2496
953
|
async function runCliApplication(options) {
|
|
2497
954
|
const environment = options.environment ?? process.env;
|
|
955
|
+
cliEnvironment = environment;
|
|
2498
956
|
configuredApiUrl = environment.TREEPORT_API_URL?.trim();
|
|
2499
957
|
apiUrl = (await resolveLocalApiUrl(environment)).replace(/\/$/, "");
|
|
2500
958
|
contextProjectId = environment.TREEPORT_PROJECT_ID?.trim() || void 0;
|