@treeport/treeport 0.4.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/treeport.mjs +26 -1
- package/dist/dist-Crk_Xr82.js +735 -0
- package/dist/node/cli/index.js +184 -1596
- package/dist/node/server/core/launcher.js +45 -0
- package/dist/node/server/index.js +3530 -446
- package/dist/update-qVp7yL5D.js +2716 -0
- package/dist/web/assets/index-2LLiNn3-.js +146 -0
- package/dist/web/assets/index-DmDs47YU.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/0010_browser_panels_and_permissions.sql +21 -0
- package/drizzle/0011_free_magdalene.sql +1 -0
- package/drizzle/meta/0008_snapshot.json +792 -0
- package/drizzle/meta/0009_snapshot.json +804 -0
- package/drizzle/meta/0010_snapshot.json +923 -0
- package/drizzle/meta/0011_snapshot.json +931 -0
- package/drizzle/meta/_journal.json +28 -0
- package/package.json +6 -3
- package/skills/treeport/SKILL.md +71 -6
- 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,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { B as parseEventsSnapshot, H as SOCKET_IO_PATH, R as webPanelInputSchema, V as parseProductEvent, t as TERMINAL_CAPTURE_MAX_LINES } from "../../dist-Crk_Xr82.js";
|
|
3
|
+
import { A as treeportVersion, C as disableTailscaleRemote, D as resolvePackagePath, E as resolveLocalApiUrl, M as parseDurationMs, O as runDoctor, S as daemonUp, T as readDaemonLogs, _ as serviceStatus, b as daemonHealth, d as serviceDisable, f as serviceDoctorCheck, g as serviceStart, h as serviceRun, k as tailscaleRemoteStatus, l as readServiceLogs, m as serviceInstalled, p as serviceEnable, s as runLocalUpdate, t as LocalUpdateError, u as serviceApply, v as serviceStop, w as enableTailscaleRemote, x as daemonStatus, y as daemonDown } from "../../update-qVp7yL5D.js";
|
|
3
4
|
import fs from "node:fs/promises";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { Command, CommanderError } from "commander";
|
|
6
7
|
import { io } from "socket.io-client";
|
|
7
|
-
import { z } from "zod";
|
|
8
8
|
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
9
|
//#region src/cli/args.ts
|
|
14
10
|
function extractJsonOutput(args) {
|
|
15
11
|
const separator = args.indexOf("--");
|
|
@@ -82,1568 +78,6 @@ async function openWorkspace(workspaceUrl, options = {}) {
|
|
|
82
78
|
throw new OpenWorkspaceError(`Treeport registered the folder, but could not open it automatically.${browser.stderr ? ` ${browser.stderr}` : ""}\nOpen this URL manually: ${workspaceUrl}`);
|
|
83
79
|
}
|
|
84
80
|
//#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
81
|
//#region src/cli/application.ts
|
|
1648
82
|
const contextPrefix = "TREEPORT";
|
|
1649
83
|
let configuredApiUrl;
|
|
@@ -1661,6 +95,7 @@ let writeStderr = (value) => {
|
|
|
1661
95
|
process.stderr.write(value);
|
|
1662
96
|
};
|
|
1663
97
|
let requestedExitCode = 0;
|
|
98
|
+
let cliEnvironment = process.env;
|
|
1664
99
|
var CliError = class extends Error {
|
|
1665
100
|
exitCode;
|
|
1666
101
|
code;
|
|
@@ -1682,10 +117,12 @@ async function resolveDaemonLifecycle() {
|
|
|
1682
117
|
return await serviceInstalled() ? "service" : "treeport";
|
|
1683
118
|
}
|
|
1684
119
|
function formatServiceStatus(status) {
|
|
120
|
+
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
121
|
const lines = [
|
|
1686
122
|
`Treeport service: ${status.state}`,
|
|
123
|
+
`Mode: ${mode}`,
|
|
1687
124
|
`Manager: ${status.manager ?? "unsupported"}`,
|
|
1688
|
-
`Starts
|
|
125
|
+
`Starts before login: ${status.enabledAtBoot ? "yes" : "no"}`,
|
|
1689
126
|
`Active: ${status.active ? "yes" : "no"}`,
|
|
1690
127
|
`Definition: ${status.definitionPath ?? "not installed"}`
|
|
1691
128
|
];
|
|
@@ -1789,9 +226,12 @@ async function resolveProject(identifier) {
|
|
|
1789
226
|
const environmentMatch = list.find((project) => project.id === contextProjectId);
|
|
1790
227
|
if (environmentMatch) return environmentMatch;
|
|
1791
228
|
}
|
|
1792
|
-
const candidate = await canonical(identifier);
|
|
1793
|
-
const match = list.
|
|
1794
|
-
|
|
229
|
+
const candidate = await canonical(identifier ?? ".");
|
|
230
|
+
const match = list.flatMap((project) => [project.rootPath, ...project.worktrees.map((item) => item.path)].map((root) => ({
|
|
231
|
+
project,
|
|
232
|
+
root
|
|
233
|
+
}))).filter(({ root }) => pathContains(candidate, root)).sort((left, right) => right.root.length - left.root.length)[0]?.project;
|
|
234
|
+
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
235
|
return match;
|
|
1796
236
|
}
|
|
1797
237
|
async function packageSource(value) {
|
|
@@ -1846,7 +286,37 @@ async function webPanelLaunchCwd(worktree) {
|
|
|
1846
286
|
});
|
|
1847
287
|
return path.relative(worktreeRoot, cwd) || ".";
|
|
1848
288
|
}
|
|
1849
|
-
function
|
|
289
|
+
async function resolveBrowserPanel(panelId) {
|
|
290
|
+
const candidates = (await projects()).flatMap((project) => project.worktrees.flatMap((worktree) => worktree.panels.filter((panel) => panel.kind === "browser").map((panel) => ({
|
|
291
|
+
panel,
|
|
292
|
+
worktree
|
|
293
|
+
}))));
|
|
294
|
+
if (panelId) {
|
|
295
|
+
const match = candidates.find((candidate) => candidate.panel.id === panelId);
|
|
296
|
+
if (!match) throw new CliError(`Browser ${panelId} was not found`, 5);
|
|
297
|
+
return match;
|
|
298
|
+
}
|
|
299
|
+
const worktree = await resolveWorktree(".");
|
|
300
|
+
const matches = candidates.filter((candidate) => candidate.worktree.id === worktree.id);
|
|
301
|
+
if (matches.length === 1) return matches[0];
|
|
302
|
+
if (matches.length === 0) throw new CliError(`No Browser is open in worktree ${worktree.name}`, 5, "BROWSER_PANEL_NOT_FOUND");
|
|
303
|
+
throw new CliError(`More than one Browser is open in worktree ${worktree.name}; specify --panel`, 5, "BROWSER_PANEL_AMBIGUOUS", { panelIds: matches.map((candidate) => candidate.panel.id) });
|
|
304
|
+
}
|
|
305
|
+
async function runBrowserAgentCommand(command, args, panelId) {
|
|
306
|
+
const { panel } = await resolveBrowserPanel(panelId);
|
|
307
|
+
const result = await request(`/api/panels/${encodeURIComponent(panel.id)}/browser-agent`, {
|
|
308
|
+
method: "POST",
|
|
309
|
+
body: JSON.stringify({
|
|
310
|
+
command,
|
|
311
|
+
args
|
|
312
|
+
})
|
|
313
|
+
});
|
|
314
|
+
return {
|
|
315
|
+
panelId: panel.id,
|
|
316
|
+
output: result.output
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function panelUrl(worktree, panelId) {
|
|
1850
320
|
const target = new URL(apiUrl);
|
|
1851
321
|
target.pathname = `/projects/${encodeURIComponent(worktree.projectId)}/worktrees/${encodeURIComponent(worktree.id)}/panels/${encodeURIComponent(panelId)}`;
|
|
1852
322
|
target.search = "";
|
|
@@ -2002,13 +472,10 @@ async function waitForTerminal(terminalId, condition, timeoutMs) {
|
|
|
2002
472
|
function print(value, human) {
|
|
2003
473
|
writeStdout(`${jsonOutput ? JSON.stringify(value) : human ? human() : JSON.stringify(value, null, 2)}\n`);
|
|
2004
474
|
}
|
|
2005
|
-
const agentGuidance = `AI agents:
|
|
2006
|
-
If you're an AI agent, use \`treeport skills\` to see the usage guide.
|
|
2007
|
-
`;
|
|
2008
475
|
async function main(args) {
|
|
2009
476
|
const argv = args[0] === "spawn" || args[0] === "terminal" && args[1] === "create" ? commandArgv(args) : void 0;
|
|
2010
477
|
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").
|
|
478
|
+
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").configureOutput({
|
|
2012
479
|
writeOut: writeStdout,
|
|
2013
480
|
writeErr: (value) => {
|
|
2014
481
|
parserError += value;
|
|
@@ -2035,12 +502,9 @@ async function main(args) {
|
|
|
2035
502
|
const registered = await request("/api/projects", {
|
|
2036
503
|
method: "POST",
|
|
2037
504
|
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
505
|
});
|
|
2042
506
|
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(`
|
|
507
|
+
if (!targetWorktree) throw new CliError(`Treeport did not find a workspace containing ${canonicalFolder}.`, 5, "WORKTREE_NOT_FOUND", {
|
|
2044
508
|
path: canonicalFolder,
|
|
2045
509
|
projectId: registered.project.id
|
|
2046
510
|
});
|
|
@@ -2048,7 +512,10 @@ async function main(args) {
|
|
|
2048
512
|
target.pathname = `/projects/${encodeURIComponent(registered.project.id)}/worktrees/${encodeURIComponent(targetWorktree.id)}`;
|
|
2049
513
|
target.search = "";
|
|
2050
514
|
target.hash = "";
|
|
2051
|
-
const opened = await
|
|
515
|
+
const opened = contextTerminalId ? await request(`/api/worktrees/${encodeURIComponent(targetWorktree.id)}/open`, {
|
|
516
|
+
method: "POST",
|
|
517
|
+
body: JSON.stringify({ sourceTerminalId: contextTerminalId })
|
|
518
|
+
}).then(() => ({ client: "current" })) : await openWorkspace(target.href).catch((error) => {
|
|
2052
519
|
if (error instanceof OpenWorkspaceError) throw new CliError(error.message, 1, "OPEN_FAILED", { url: target.href });
|
|
2053
520
|
throw error;
|
|
2054
521
|
});
|
|
@@ -2056,9 +523,10 @@ async function main(args) {
|
|
|
2056
523
|
projectId: registered.project.id,
|
|
2057
524
|
worktreeId: targetWorktree.id,
|
|
2058
525
|
path: canonicalFolder,
|
|
526
|
+
projectKind: registered.project.kind,
|
|
2059
527
|
url: target.href,
|
|
2060
528
|
client: opened.client
|
|
2061
|
-
}, () => `Opened ${registered.project.name} / ${targetWorktree.name} in the ${opened.client === "desktop" ? "Treeport desktop app" : "browser"}\n${target.href}`);
|
|
529
|
+
}, () => `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
530
|
});
|
|
2063
531
|
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
532
|
startCommand.action(async () => {
|
|
@@ -2101,8 +569,9 @@ async function main(args) {
|
|
|
2101
569
|
serviceCommand.action(() => {
|
|
2102
570
|
writeStdout(serviceCommand.helpInformation());
|
|
2103
571
|
});
|
|
2104
|
-
serviceCommand.command("enable").description("Enable startup
|
|
2105
|
-
|
|
572
|
+
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");
|
|
573
|
+
serviceEnableCommand.action(async () => {
|
|
574
|
+
const result = await serviceEnable(serviceEnableCommand.opts().headless ? "headless" : "user");
|
|
2106
575
|
print(result, () => formatServiceStatus(result.status));
|
|
2107
576
|
if (result.status.state === "action_required") requestedExitCode = 1;
|
|
2108
577
|
});
|
|
@@ -2232,6 +701,7 @@ async function main(args) {
|
|
|
2232
701
|
worktreeId,
|
|
2233
702
|
terminalId
|
|
2234
703
|
});
|
|
704
|
+
const treeContext = (await request(`/api/worktrees/${encodeURIComponent(worktree.id)}/context`)).context;
|
|
2235
705
|
const context = {
|
|
2236
706
|
managed: true,
|
|
2237
707
|
apiUrl,
|
|
@@ -2239,6 +709,8 @@ async function main(args) {
|
|
|
2239
709
|
project: {
|
|
2240
710
|
id: project.id,
|
|
2241
711
|
name: project.name,
|
|
712
|
+
kind: project.kind,
|
|
713
|
+
rootPath: project.rootPath,
|
|
2242
714
|
repositoryPath: project.repositoryPath,
|
|
2243
715
|
mainWorktreePath: project.mainWorktreePath,
|
|
2244
716
|
defaultBranch: project.defaultBranch,
|
|
@@ -2252,7 +724,8 @@ async function main(args) {
|
|
|
2252
724
|
head: worktree.head,
|
|
2253
725
|
branch: worktree.branch,
|
|
2254
726
|
detached: worktree.detached,
|
|
2255
|
-
kind: worktree.kind
|
|
727
|
+
kind: worktree.kind,
|
|
728
|
+
context: treeContext
|
|
2256
729
|
},
|
|
2257
730
|
terminal: {
|
|
2258
731
|
id: terminal.id,
|
|
@@ -2262,8 +735,108 @@ async function main(args) {
|
|
|
2262
735
|
exitCode: terminal.exitCode
|
|
2263
736
|
}
|
|
2264
737
|
};
|
|
2265
|
-
print(context, () =>
|
|
738
|
+
print(context, () => {
|
|
739
|
+
const entries = Object.entries(context.worktree.context);
|
|
740
|
+
const treeContextText = entries.length === 0 ? "—" : entries.map(([key, value]) => {
|
|
741
|
+
const [first = "", ...rest] = [...value].map((character) => {
|
|
742
|
+
const code = character.charCodeAt(0);
|
|
743
|
+
return code !== 10 && (code <= 31 || code >= 127 && code <= 159) ? `\\u${code.toString(16).padStart(4, "0")}` : character;
|
|
744
|
+
}).join("").split("\n");
|
|
745
|
+
return ` ${key}: ${first}${rest.length > 0 ? `\n${rest.map((line) => ` ${line}`).join("\n")}` : ""}`;
|
|
746
|
+
}).join("\n");
|
|
747
|
+
return `Treeport context\n\nProject: ${context.project.name} (${context.project.id})\nTree: ${context.worktree.name} (${context.worktree.id})\nPath: ${context.worktree.path}\nContext:\n${treeContextText}\nTerminal: ${context.terminal.name} (${context.terminal.id}) — ${context.terminal.status}\nAPI: ${context.apiUrl}\nLifecycle: ${context.daemonLifecycle === "external" ? "externally managed" : context.daemonLifecycle === "service" ? "managed by the OS service" : "managed by Treeport"}`;
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
const browserCommand = program.command("browser").description("Manage Browser and its hosted Chromium");
|
|
751
|
+
browserCommand.action(() => {
|
|
752
|
+
writeStdout(browserCommand.helpInformation());
|
|
753
|
+
});
|
|
754
|
+
const browserOpenCommand = browserCommand.command("open").description("Open Browser and request client navigation").argument("[url]", "absolute HTTP or HTTPS URL").requiredOption("--worktree <id-or-path-or-dot>", "owning tree").option("--json", "emit machine-readable JSON");
|
|
755
|
+
browserOpenCommand.action(async (url) => {
|
|
756
|
+
const worktree = await resolveWorktree(browserOpenCommand.opts().worktree);
|
|
757
|
+
const body = {
|
|
758
|
+
url,
|
|
759
|
+
sourceTerminalId: contextWorktreeId === worktree.id ? contextTerminalId ?? null : null
|
|
760
|
+
};
|
|
761
|
+
const result = await request(`/api/worktrees/${encodeURIComponent(worktree.id)}/browser-panels`, {
|
|
762
|
+
method: "POST",
|
|
763
|
+
body: JSON.stringify(body)
|
|
764
|
+
});
|
|
765
|
+
const output = {
|
|
766
|
+
...result,
|
|
767
|
+
url: panelUrl(worktree, result.panel.id)
|
|
768
|
+
};
|
|
769
|
+
print(output, () => `Opened ${result.panel.title} (${result.panel.id})\n${output.url}`);
|
|
770
|
+
});
|
|
771
|
+
browserCommand.command("install").description("Install the Chromium build used by Browser").option("--json", "emit machine-readable JSON").action(async () => {
|
|
772
|
+
const result = await request("/api/browser/install", { method: "POST" });
|
|
773
|
+
print(result, () => result.message);
|
|
774
|
+
});
|
|
775
|
+
browserCommand.command("status").description("Show hosted browser installation status").option("--json", "emit machine-readable JSON").action(async () => {
|
|
776
|
+
const result = await request("/api/browser/status");
|
|
777
|
+
print(result, () => `${result.installed ? "Chromium is installed" : "Chromium is not installed"}\nLaunch ready: ${result.launchReady ? "yes" : "no"}\nPlaywright: ${result.playwrightVersion}\nBrowser: ${result.channel} ${result.browserRevision}\nExecutable: ${result.executablePath}${result.launchError ? `\nLaunch error: ${result.launchError}` : ""}`);
|
|
2266
778
|
});
|
|
779
|
+
browserCommand.command("remove").description("Remove Treeport's hosted Chromium build").option("--json", "emit machine-readable JSON").action(async () => {
|
|
780
|
+
await request("/api/browser/install", { method: "DELETE" });
|
|
781
|
+
print({ removed: true }, () => "Removed Treeport hosted Chromium");
|
|
782
|
+
});
|
|
783
|
+
browserCommand.command("list").description("List open Browser sessions").option("--json", "emit machine-readable JSON").action(async () => {
|
|
784
|
+
const panels = (await projects()).flatMap((project) => project.worktrees.flatMap((worktree) => worktree.panels.filter((panel) => panel.kind === "browser").map((panel) => ({
|
|
785
|
+
panelId: panel.id,
|
|
786
|
+
title: panel.title,
|
|
787
|
+
worktreeId: worktree.id,
|
|
788
|
+
worktree: worktree.name,
|
|
789
|
+
projectId: project.id,
|
|
790
|
+
project: project.name
|
|
791
|
+
}))));
|
|
792
|
+
print(panels, () => panels.length ? panels.map((panel) => `${panel.panelId}\t${panel.project} / ${panel.worktree}\t${panel.title}`).join("\n") : "Browser is not open.");
|
|
793
|
+
});
|
|
794
|
+
const printAgentResult = async (command, args, panelId) => {
|
|
795
|
+
const result = await runBrowserAgentCommand(command, args, panelId);
|
|
796
|
+
print(result, () => result.output);
|
|
797
|
+
};
|
|
798
|
+
const browserSnapshotCommand = browserCommand.command("snapshot").description("Capture an accessibility snapshot of the hosted page").option("--panel <panel-id>", "Browser ID").option("--json", "emit machine-readable JSON");
|
|
799
|
+
browserSnapshotCommand.action(async () => printAgentResult("snapshot", [], browserSnapshotCommand.opts().panel));
|
|
800
|
+
const browserClickCommand = browserCommand.command("click").description("Click an element from the latest browser snapshot").argument("<target>", "Playwright element reference or selector").option("--panel <panel-id>", "Browser ID").option("--json", "emit machine-readable JSON");
|
|
801
|
+
browserClickCommand.action(async (target) => printAgentResult("click", [target], browserClickCommand.opts().panel));
|
|
802
|
+
const browserFillCommand = browserCommand.command("fill").description("Fill an editable element from the latest browser snapshot").argument("<target>", "Playwright element reference or selector").argument("<text>", "text to enter").option("--panel <panel-id>", "Browser ID").option("--json", "emit machine-readable JSON");
|
|
803
|
+
browserFillCommand.action(async (target, text) => printAgentResult("fill", [target, text], browserFillCommand.opts().panel));
|
|
804
|
+
const browserPressCommand = browserCommand.command("press").description("Press a key in the hosted page").argument("<key>", "Playwright key name").option("--panel <panel-id>", "Browser ID").option("--json", "emit machine-readable JSON");
|
|
805
|
+
browserPressCommand.action(async (key) => printAgentResult("press", [key], browserPressCommand.opts().panel));
|
|
806
|
+
const browserGotoCommand = browserCommand.command("goto").description("Navigate the hosted page").argument("<url>", "absolute HTTP or HTTPS URL").option("--panel <panel-id>", "Browser ID").option("--json", "emit machine-readable JSON");
|
|
807
|
+
browserGotoCommand.action(async (url) => printAgentResult("goto", [url], browserGotoCommand.opts().panel));
|
|
808
|
+
const browserConsoleCommand = browserCommand.command("console").description("List page console messages").argument("[level]", "minimum console level").option("--panel <panel-id>", "Browser ID").option("--json", "emit machine-readable JSON");
|
|
809
|
+
browserConsoleCommand.action(async (level) => printAgentResult("console", level ? [level] : [], browserConsoleCommand.opts().panel));
|
|
810
|
+
for (const [name, description, agentName] of [
|
|
811
|
+
[
|
|
812
|
+
"back",
|
|
813
|
+
"Go back in the hosted page",
|
|
814
|
+
"go-back"
|
|
815
|
+
],
|
|
816
|
+
[
|
|
817
|
+
"forward",
|
|
818
|
+
"Go forward in the hosted page",
|
|
819
|
+
"go-forward"
|
|
820
|
+
],
|
|
821
|
+
[
|
|
822
|
+
"reload",
|
|
823
|
+
"Reload the hosted page",
|
|
824
|
+
"reload"
|
|
825
|
+
],
|
|
826
|
+
[
|
|
827
|
+
"network",
|
|
828
|
+
"List page network requests",
|
|
829
|
+
"requests"
|
|
830
|
+
],
|
|
831
|
+
[
|
|
832
|
+
"screenshot",
|
|
833
|
+
"Capture a screenshot of the hosted page",
|
|
834
|
+
"screenshot"
|
|
835
|
+
]
|
|
836
|
+
]) {
|
|
837
|
+
const command = browserCommand.command(name).description(description).option("--panel <panel-id>", "Browser ID").option("--json", "emit machine-readable JSON");
|
|
838
|
+
command.action(async () => printAgentResult(agentName, [], command.opts().panel));
|
|
839
|
+
}
|
|
2267
840
|
const installCommand = program.command("install").description("Install and configure a Treeport package").argument("<source>", "npm: source or local directory").option("-l, --local", "configure the registered project containing the current directory").option("--json", "emit machine-readable JSON");
|
|
2268
841
|
installCommand.action(async (source) => {
|
|
2269
842
|
const options = installCommand.opts();
|
|
@@ -2296,10 +869,24 @@ async function main(args) {
|
|
|
2296
869
|
return lines.join("\n");
|
|
2297
870
|
});
|
|
2298
871
|
});
|
|
2299
|
-
const updatePackagesCommand = program.command("update").description("
|
|
872
|
+
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
873
|
updatePackagesCommand.action(async (source) => {
|
|
2301
874
|
const options = updatePackagesCommand.opts();
|
|
2302
|
-
if (
|
|
875
|
+
if (source && options.packages) throw new CliError("Specify a package source or --packages, not both.", 2);
|
|
876
|
+
if (!source && !options.packages) {
|
|
877
|
+
if (await resolveDaemonLifecycle() === "external") throw new CliError("Cannot update Treeport because this daemon lifecycle is externally managed.", 5, "UPDATE_EXTERNAL_REFUSED");
|
|
878
|
+
const selfUpdateOptions = { environment: cliEnvironment };
|
|
879
|
+
if (!jsonOutput) selfUpdateOptions.progress = (message) => writeStderr(`${message}\n`);
|
|
880
|
+
const result = await runLocalUpdate(selfUpdateOptions).catch((error) => {
|
|
881
|
+
if (error instanceof LocalUpdateError) throw new CliError(error.message, error.exitCode, error.code, error.details);
|
|
882
|
+
throw error;
|
|
883
|
+
});
|
|
884
|
+
print(result, () => {
|
|
885
|
+
if (result.status === "current") return `Treeport ${result.toVersion} is current`;
|
|
886
|
+
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`;
|
|
887
|
+
});
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
2303
890
|
const results = (await request("/api/packages/update", {
|
|
2304
891
|
method: "POST",
|
|
2305
892
|
body: JSON.stringify(source ? { source: await packageSource(source) } : {})
|
|
@@ -2321,16 +908,16 @@ async function main(args) {
|
|
|
2321
908
|
projectCommand.action(() => {
|
|
2322
909
|
throw new CliError(projectCommand.helpInformation(), 2);
|
|
2323
910
|
});
|
|
2324
|
-
projectCommand.command("add").description("Register a Git repository").argument("<path>", "
|
|
911
|
+
projectCommand.command("add").description("Register a folder or Git repository").argument("<path>", "folder path").option("--json", "emit machine-readable JSON").action(async (repository) => {
|
|
2325
912
|
const body = await request("/api/projects", {
|
|
2326
913
|
method: "POST",
|
|
2327
914
|
body: JSON.stringify({ path: await canonical(repository) })
|
|
2328
915
|
});
|
|
2329
|
-
print(body.project, () => `Registered ${body.project.name} (${body.project.id})\n${body.project.
|
|
916
|
+
print(body.project, () => `Registered ${body.project.name} (${body.project.id})\n${body.project.rootPath}`);
|
|
2330
917
|
});
|
|
2331
918
|
projectCommand.command("list").description("List registered projects").option("--json", "emit machine-readable JSON").action(async () => {
|
|
2332
919
|
const list = await projects();
|
|
2333
|
-
print(list, () => list.map((project) => `${project.id}\t${project.name}\t${project.
|
|
920
|
+
print(list, () => list.map((project) => `${project.id}\t${project.name}\t${project.kind}\t${project.rootPath}`).join("\n"));
|
|
2334
921
|
});
|
|
2335
922
|
const worktreeCommand = program.command("worktree").description("List, create, and remove trees");
|
|
2336
923
|
worktreeCommand.action(() => {
|
|
@@ -2340,9 +927,9 @@ async function main(args) {
|
|
|
2340
927
|
worktreeListCommand.action(async () => {
|
|
2341
928
|
const { project: projectIdentifier } = worktreeListCommand.opts();
|
|
2342
929
|
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"));
|
|
930
|
+
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
931
|
});
|
|
2345
|
-
const worktreeCreateCommand = worktreeCommand.command("create").description("Create a linked tree").
|
|
932
|
+
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
933
|
worktreeCreateCommand.action(async () => {
|
|
2347
934
|
const options = worktreeCreateCommand.opts();
|
|
2348
935
|
const project = await resolveProject(options.project);
|
|
@@ -2401,7 +988,7 @@ async function main(args) {
|
|
|
2401
988
|
});
|
|
2402
989
|
const output = {
|
|
2403
990
|
...result,
|
|
2404
|
-
url:
|
|
991
|
+
url: panelUrl(worktree, result.panel.id)
|
|
2405
992
|
};
|
|
2406
993
|
print(output, () => `${result.reused ? "Reused" : "Opened"} ${result.panel.title} (${result.panel.id})\n${output.url}`);
|
|
2407
994
|
});
|
|
@@ -2467,7 +1054,7 @@ async function main(args) {
|
|
|
2467
1054
|
terminalId
|
|
2468
1055
|
}, () => `Deleted ${terminalId}`);
|
|
2469
1056
|
});
|
|
2470
|
-
const spawnCommand = program.command("spawn").description("Create a tree and its first terminal").usage("[options] [-- <command> args...]").
|
|
1057
|
+
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
1058
|
spawnCommand.action(async () => {
|
|
2472
1059
|
const options = spawnCommand.opts();
|
|
2473
1060
|
const project = await resolveProject(options.project);
|
|
@@ -2495,6 +1082,7 @@ async function main(args) {
|
|
|
2495
1082
|
}
|
|
2496
1083
|
async function runCliApplication(options) {
|
|
2497
1084
|
const environment = options.environment ?? process.env;
|
|
1085
|
+
cliEnvironment = environment;
|
|
2498
1086
|
configuredApiUrl = environment.TREEPORT_API_URL?.trim();
|
|
2499
1087
|
apiUrl = (await resolveLocalApiUrl(environment)).replace(/\/$/, "");
|
|
2500
1088
|
contextProjectId = environment.TREEPORT_PROJECT_ID?.trim() || void 0;
|