@treeport/treeport 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/bin/treeport.mjs +3 -1
- package/dist/loopback-Dyv_owrb.js +407 -0
- package/dist/node/cli/index.js +1735 -119
- package/dist/node/server/index.js +4339 -946
- package/dist/web/assets/index-0q5frbNy.css +2 -0
- package/dist/web/assets/index-CUy0IkGL.js +146 -0
- package/dist/web/favicon.svg +1 -1
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/index.html +2 -2
- package/drizzle/0001_misty_spectrum.sql +11 -0
- package/drizzle/0002_dazzling_princess_powerful.sql +9 -0
- package/drizzle/0003_yummy_whirlwind.sql +3 -0
- package/drizzle/0004_persistent_worktree_creation.sql +26 -0
- package/drizzle/0005_git_authoritative_worktrees.sql +120 -0
- package/drizzle/0006_web_panel_launch_input.sql +3 -0
- package/drizzle/0007_dashing_pestilence.sql +10 -0
- package/drizzle/meta/0001_snapshot.json +647 -0
- package/drizzle/meta/0002_snapshot.json +701 -0
- package/drizzle/meta/0003_snapshot.json +709 -0
- package/drizzle/meta/0004_snapshot.json +714 -0
- package/drizzle/meta/0005_snapshot.json +696 -0
- package/drizzle/meta/0006_snapshot.json +711 -0
- package/drizzle/meta/0007_snapshot.json +775 -0
- package/drizzle/meta/_journal.json +49 -0
- package/package.json +9 -9
- package/skills/treeport/SKILL.md +3 -2
- package/dist/dist-CUkImh2W.js +0 -254
- package/dist/web/assets/index-C2QPZrxe.js +0 -146
- package/dist/web/assets/index-CC-O69aQ.css +0 -2
package/dist/node/cli/index.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { A as parseProductEvent, j as SOCKET_IO_PATH, k as parseEventsSnapshot, n as parseDurationMs, r as TERMINAL_CAPTURE_MAX_LINES, t as assertLoopbackHost } from "../../loopback-Dyv_owrb.js";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { Command, CommanderError } from "commander";
|
|
6
6
|
import { io } from "socket.io-client";
|
|
7
|
-
import
|
|
7
|
+
import { z } from "zod";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
|
-
import
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
import fsSync, { constants } from "node:fs";
|
|
10
11
|
import os from "node:os";
|
|
11
|
-
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
13
|
//#region src/cli/args.ts
|
|
13
14
|
function extractJsonOutput(args) {
|
|
14
15
|
const separator = args.indexOf("--");
|
|
@@ -18,6 +19,69 @@ function extractJsonOutput(args) {
|
|
|
18
19
|
return true;
|
|
19
20
|
}
|
|
20
21
|
//#endregion
|
|
22
|
+
//#region src/cli/open.ts
|
|
23
|
+
const DESKTOP_BUNDLE_ID = "tech.noice.treeport";
|
|
24
|
+
const LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set([
|
|
25
|
+
"127.0.0.1",
|
|
26
|
+
"localhost",
|
|
27
|
+
"[::1]"
|
|
28
|
+
]);
|
|
29
|
+
var OpenWorkspaceError = class extends Error {};
|
|
30
|
+
function defaultLaunch(executable, args) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const child = spawn(executable, args, { stdio: [
|
|
33
|
+
"ignore",
|
|
34
|
+
"ignore",
|
|
35
|
+
"pipe"
|
|
36
|
+
] });
|
|
37
|
+
let settled = false;
|
|
38
|
+
let stderr = "";
|
|
39
|
+
child.stderr.setEncoding("utf8");
|
|
40
|
+
child.stderr.on("data", (chunk) => {
|
|
41
|
+
stderr = `${stderr}${chunk}`.slice(-4096);
|
|
42
|
+
});
|
|
43
|
+
child.once("error", (error) => {
|
|
44
|
+
if (!settled) {
|
|
45
|
+
settled = true;
|
|
46
|
+
resolve({
|
|
47
|
+
code: null,
|
|
48
|
+
stderr: error.message
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
child.once("close", (code) => {
|
|
53
|
+
if (!settled) {
|
|
54
|
+
settled = true;
|
|
55
|
+
resolve({
|
|
56
|
+
code,
|
|
57
|
+
stderr: stderr.trim()
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function desktopCanOpen(workspaceUrl) {
|
|
64
|
+
if (!URL.canParse(workspaceUrl)) return false;
|
|
65
|
+
const url = new URL(workspaceUrl);
|
|
66
|
+
return !url.username && !url.password && (url.protocol === "https:" || url.protocol === "http:" && LOOPBACK_HOSTNAMES.has(url.hostname.toLowerCase()));
|
|
67
|
+
}
|
|
68
|
+
async function openWorkspace(workspaceUrl, options = {}) {
|
|
69
|
+
const platform = options.platform ?? process.platform;
|
|
70
|
+
const launch = options.launch ?? defaultLaunch;
|
|
71
|
+
if (platform === "darwin" && desktopCanOpen(workspaceUrl)) {
|
|
72
|
+
const deepLink = new URL("treeport://open");
|
|
73
|
+
deepLink.searchParams.set("url", workspaceUrl);
|
|
74
|
+
if ((await launch("open", [
|
|
75
|
+
"-b",
|
|
76
|
+
DESKTOP_BUNDLE_ID,
|
|
77
|
+
deepLink.href
|
|
78
|
+
])).code === 0) return { client: "desktop" };
|
|
79
|
+
}
|
|
80
|
+
const browser = await launch(platform === "darwin" ? "open" : "xdg-open", [workspaceUrl]);
|
|
81
|
+
if (browser.code === 0) return { client: "browser" };
|
|
82
|
+
throw new OpenWorkspaceError(`Treeport registered the folder, but could not open it automatically.${browser.stderr ? ` ${browser.stderr}` : ""}\nOpen this URL manually: ${workspaceUrl}`);
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
21
85
|
//#region src/cli/lifecycle.ts
|
|
22
86
|
const DEFAULT_HOST = "127.0.0.1";
|
|
23
87
|
const DEFAULT_PORT = 8733;
|
|
@@ -40,17 +104,71 @@ function localPaths(env = process.env) {
|
|
|
40
104
|
logPath: path.join(dataDir, "logs", "daemon.log")
|
|
41
105
|
};
|
|
42
106
|
}
|
|
43
|
-
async function readJson(filePath) {
|
|
44
|
-
return fs.readFile(filePath, "utf8").then((value) => JSON.parse(value)).catch(() => null);
|
|
107
|
+
async function readJson$1(filePath, schema) {
|
|
108
|
+
return fs.readFile(filePath, "utf8").then((value) => schema.parse(JSON.parse(value))).catch(() => null);
|
|
45
109
|
}
|
|
46
|
-
|
|
47
|
-
|
|
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) ?? {};
|
|
48
149
|
}
|
|
49
|
-
async function
|
|
50
|
-
const
|
|
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
|
+
}
|
|
51
169
|
if (explicit) return explicit.replace(/\/$/, "");
|
|
52
|
-
const saved = await preferences();
|
|
53
|
-
return listenerUrl(
|
|
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));
|
|
54
172
|
}
|
|
55
173
|
async function resolvePackagePath(...segments) {
|
|
56
174
|
const candidates = [fileURLToPath(new URL("../../../", import.meta.url)), fileURLToPath(new URL("../../", import.meta.url))];
|
|
@@ -58,7 +176,7 @@ async function resolvePackagePath(...segments) {
|
|
|
58
176
|
throw new Error("Could not locate the Treeport package directory");
|
|
59
177
|
}
|
|
60
178
|
async function treeportVersion() {
|
|
61
|
-
return (await readJson(await resolvePackagePath("package.json")))?.version ?? "development";
|
|
179
|
+
return (await readJson$1(await resolvePackagePath("package.json"), z.looseObject({ version: z.string().optional() })))?.version ?? "development";
|
|
62
180
|
}
|
|
63
181
|
function processExists(pid) {
|
|
64
182
|
try {
|
|
@@ -68,31 +186,30 @@ function processExists(pid) {
|
|
|
68
186
|
return error.code === "EPERM";
|
|
69
187
|
}
|
|
70
188
|
}
|
|
71
|
-
async function
|
|
189
|
+
async function daemonHealth(apiUrl, timeoutMs = 1500) {
|
|
72
190
|
const signal = AbortSignal.timeout(timeoutMs);
|
|
73
191
|
return fetch(`${apiUrl}/api/health`, { signal }).then(async (response) => {
|
|
74
192
|
if (!response.ok) return null;
|
|
75
|
-
const
|
|
76
|
-
return
|
|
193
|
+
const result = healthRecordSchema.safeParse(await response.json());
|
|
194
|
+
return result.success ? result.data : null;
|
|
77
195
|
}).catch(() => null);
|
|
78
196
|
}
|
|
79
197
|
function matchesOwnership(state, observed) {
|
|
80
198
|
return observed.pid === state.pid && observed.instanceId === state.instanceId && path.resolve(state.dataDir) === localPaths().dataDir;
|
|
81
199
|
}
|
|
82
200
|
async function readState() {
|
|
83
|
-
|
|
84
|
-
return value && typeof value.pid === "number" && typeof value.instanceId === "string" && typeof value.apiUrl === "string" && typeof value.dataDir === "string" ? value : null;
|
|
201
|
+
return readJson$1(localPaths().statePath, daemonRecordSchema);
|
|
85
202
|
}
|
|
86
203
|
async function removeStaleState(state) {
|
|
87
204
|
const paths = localPaths();
|
|
88
|
-
for (const filePath of [paths.statePath, paths.lockPath]) if ((await readJson(filePath))?.instanceId === state.instanceId) await fs.rm(filePath, { force: true });
|
|
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 });
|
|
89
206
|
}
|
|
90
207
|
async function stopOwned(state) {
|
|
91
208
|
if (!processExists(state.pid)) {
|
|
92
209
|
await removeStaleState(state);
|
|
93
210
|
return;
|
|
94
211
|
}
|
|
95
|
-
const observed = await
|
|
212
|
+
const observed = await daemonHealth(state.apiUrl);
|
|
96
213
|
if (!observed || !matchesOwnership(state, observed)) throw new Error(`Refusing to stop PID ${state.pid}: Treeport could not verify ownership. Check ${localPaths().statePath}.`);
|
|
97
214
|
process.kill(state.pid, "SIGTERM");
|
|
98
215
|
const deadline = Date.now() + 7e3;
|
|
@@ -131,6 +248,168 @@ async function executableCheck(executable, args) {
|
|
|
131
248
|
}));
|
|
132
249
|
});
|
|
133
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
|
+
}
|
|
134
413
|
async function runDoctor() {
|
|
135
414
|
const paths = localPaths();
|
|
136
415
|
const gitPath = process.env.TREEPORT_GIT_PATH?.trim() || "git";
|
|
@@ -191,7 +470,7 @@ async function daemonStatus() {
|
|
|
191
470
|
verified: false
|
|
192
471
|
};
|
|
193
472
|
}
|
|
194
|
-
const observed = await
|
|
473
|
+
const observed = await daemonHealth(state.apiUrl);
|
|
195
474
|
return {
|
|
196
475
|
running: Boolean(observed),
|
|
197
476
|
state,
|
|
@@ -204,20 +483,14 @@ async function daemonUp(options) {
|
|
|
204
483
|
const paths = localPaths();
|
|
205
484
|
const saved = await preferences();
|
|
206
485
|
const next = {
|
|
486
|
+
...saved,
|
|
207
487
|
host: options.host?.trim() || saved.host || DEFAULT_HOST,
|
|
208
488
|
port: options.port ?? saved.port ?? DEFAULT_PORT
|
|
209
489
|
};
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
});
|
|
215
|
-
const temporaryPath = `${paths.preferencesPath}.${process.pid}.tmp`;
|
|
216
|
-
await fs.writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 384 });
|
|
217
|
-
await fs.rename(temporaryPath, paths.preferencesPath);
|
|
218
|
-
}
|
|
219
|
-
const host = options.host?.trim() || process.env.TREEPORT_HOST?.trim() || next.host;
|
|
220
|
-
const port = Number.parseInt(options.port === void 0 ? process.env.TREEPORT_PORT?.trim() || String(next.port) : String(options.port), 10);
|
|
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);
|
|
221
494
|
const apiUrl = options.host !== void 0 || options.port !== void 0 ? listenerUrl(host, port) : process.env.TREEPORT_API_URL?.trim() || listenerUrl(host, port);
|
|
222
495
|
const currentVersion = await treeportVersion();
|
|
223
496
|
const existing = await daemonStatus();
|
|
@@ -254,6 +527,7 @@ async function daemonUp(options) {
|
|
|
254
527
|
TREEPORT_APP_VERSION: currentVersion,
|
|
255
528
|
TREEPORT_INSTANCE_ID: instanceId,
|
|
256
529
|
TREEPORT_INSTALLATION_METHOD: process.env.TREEPORT_INSTALLATION_METHOD?.trim() || "npm",
|
|
530
|
+
TREEPORT_DAEMON_LIFECYCLE: "treeport",
|
|
257
531
|
TREEPORT_WEB_DIST: webDist
|
|
258
532
|
};
|
|
259
533
|
if (options.foreground) {
|
|
@@ -287,7 +561,7 @@ async function daemonUp(options) {
|
|
|
287
561
|
fsSync.closeSync(log);
|
|
288
562
|
const deadline = Date.now() + 15e3;
|
|
289
563
|
while (Date.now() < deadline) {
|
|
290
|
-
const observed = await
|
|
564
|
+
const observed = await daemonHealth(apiUrl, 500);
|
|
291
565
|
if (observed && observed.pid === child.pid && observed.instanceId === instanceId && observed.version === currentVersion) return {
|
|
292
566
|
alreadyRunning: false,
|
|
293
567
|
apiUrl,
|
|
@@ -312,15 +586,1032 @@ async function readDaemonLogs(lines = 100) {
|
|
|
312
586
|
})).split("\n").slice(-lines - 1).join("\n");
|
|
313
587
|
}
|
|
314
588
|
//#endregion
|
|
315
|
-
//#region src/cli/
|
|
316
|
-
const
|
|
317
|
-
|
|
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
|
+
installationMethod: z.enum(["curl", "npm"]),
|
|
605
|
+
definitionName: z.string().min(1),
|
|
606
|
+
definitionPath: z.string().min(1),
|
|
607
|
+
definitionHash: z.string().length(64),
|
|
608
|
+
environmentHash: z.string().length(64),
|
|
609
|
+
environment: z.record(z.string(), z.string()),
|
|
610
|
+
requestedState: z.enum(["running", "stopped"]),
|
|
611
|
+
pendingAdministratorRequestId: z.string().nullable(),
|
|
612
|
+
createdAt: z.string(),
|
|
613
|
+
updatedAt: z.string()
|
|
614
|
+
});
|
|
615
|
+
const administratorRequestSchema = z.strictObject({
|
|
616
|
+
schemaVersion: z.literal(1),
|
|
617
|
+
id: z.string().uuid(),
|
|
618
|
+
operation: z.enum([
|
|
619
|
+
"enable",
|
|
620
|
+
"start",
|
|
621
|
+
"stop",
|
|
622
|
+
"disable"
|
|
623
|
+
]),
|
|
624
|
+
createdAt: z.string(),
|
|
625
|
+
expiresAt: z.string(),
|
|
626
|
+
uid: z.number().int().nonnegative(),
|
|
627
|
+
gid: z.number().int().nonnegative(),
|
|
628
|
+
username: z.string().min(1),
|
|
629
|
+
group: z.string().min(1),
|
|
630
|
+
home: z.string().min(1),
|
|
631
|
+
serviceRecordPath: z.string().min(1),
|
|
632
|
+
runnerPath: z.string().min(1),
|
|
633
|
+
definitionName: z.string().min(1),
|
|
634
|
+
definitionPath: z.string().min(1),
|
|
635
|
+
stagedDefinitionPath: z.string().min(1),
|
|
636
|
+
definitionHash: z.string().length(64),
|
|
637
|
+
apiUrl: z.string().min(1),
|
|
638
|
+
cliEntrypoint: z.string().min(1)
|
|
639
|
+
});
|
|
640
|
+
function managerForPlatform(platform = process.platform) {
|
|
641
|
+
return platform === "darwin" ? "launchd" : platform === "linux" ? "systemd" : null;
|
|
642
|
+
}
|
|
643
|
+
function servicePaths(env = process.env) {
|
|
644
|
+
const paths = localPaths(env);
|
|
645
|
+
const directory = path.join(paths.dataDir, "service");
|
|
646
|
+
return {
|
|
647
|
+
directory,
|
|
648
|
+
recordPath: path.join(directory, "service.json"),
|
|
649
|
+
runnerPath: path.join(directory, "run"),
|
|
650
|
+
requestsDirectory: path.join(directory, "requests"),
|
|
651
|
+
stagedDefinitionPath: path.join(directory, "treeport.plist")
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
async function readJson(filePath, schema) {
|
|
655
|
+
return fs.readFile(filePath, "utf8").then((value) => schema.parse(JSON.parse(value))).catch(() => null);
|
|
656
|
+
}
|
|
657
|
+
async function writeJson(filePath, value) {
|
|
658
|
+
await fs.mkdir(path.dirname(filePath), {
|
|
659
|
+
recursive: true,
|
|
660
|
+
mode: 448
|
|
661
|
+
});
|
|
662
|
+
const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
663
|
+
await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
|
|
664
|
+
await fs.rename(temporaryPath, filePath);
|
|
665
|
+
}
|
|
666
|
+
function fingerprint(value) {
|
|
667
|
+
const source = typeof value === "string" ? value : JSON.stringify(Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))));
|
|
668
|
+
return crypto.createHash("sha256").update(source).digest("hex");
|
|
669
|
+
}
|
|
670
|
+
function xml(value) {
|
|
671
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
672
|
+
}
|
|
673
|
+
function shellQuote(value) {
|
|
674
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
675
|
+
}
|
|
676
|
+
function systemdValue(value) {
|
|
677
|
+
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("%", "%%").replaceAll("\n", "\\n");
|
|
678
|
+
}
|
|
679
|
+
function createLaunchdDefinition(input) {
|
|
680
|
+
return {
|
|
681
|
+
label: input.label,
|
|
682
|
+
programArguments: [input.runnerPath],
|
|
683
|
+
username: input.username,
|
|
684
|
+
group: input.group,
|
|
685
|
+
environment: input.environment,
|
|
686
|
+
workingDirectory: input.home,
|
|
687
|
+
standardOutPath: input.logPath,
|
|
688
|
+
standardErrorPath: input.logPath,
|
|
689
|
+
keepAlive: true,
|
|
690
|
+
processType: "Background",
|
|
691
|
+
throttleInterval: 10,
|
|
692
|
+
exitTimeOut: 10,
|
|
693
|
+
abandonProcessGroup: true,
|
|
694
|
+
umask: 63
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
function serializeLaunchdDefinition(definition) {
|
|
698
|
+
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");
|
|
699
|
+
const argumentsXml = definition.programArguments.map((argument) => ` <string>${xml(argument)}</string>`).join("\n");
|
|
700
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
701
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
702
|
+
<plist version="1.0">
|
|
703
|
+
<dict>
|
|
704
|
+
<key>Label</key>
|
|
705
|
+
<string>${xml(definition.label)}</string>
|
|
706
|
+
<key>ProgramArguments</key>
|
|
707
|
+
<array>
|
|
708
|
+
${argumentsXml}
|
|
709
|
+
</array>
|
|
710
|
+
<key>UserName</key>
|
|
711
|
+
<string>${xml(definition.username)}</string>
|
|
712
|
+
<key>GroupName</key>
|
|
713
|
+
<string>${xml(definition.group)}</string>
|
|
714
|
+
<key>EnvironmentVariables</key>
|
|
715
|
+
<dict>
|
|
716
|
+
${environment}
|
|
717
|
+
</dict>
|
|
718
|
+
<key>WorkingDirectory</key>
|
|
719
|
+
<string>${xml(definition.workingDirectory)}</string>
|
|
720
|
+
<key>StandardOutPath</key>
|
|
721
|
+
<string>${xml(definition.standardOutPath)}</string>
|
|
722
|
+
<key>StandardErrorPath</key>
|
|
723
|
+
<string>${xml(definition.standardErrorPath)}</string>
|
|
724
|
+
<key>KeepAlive</key>
|
|
725
|
+
<true/>
|
|
726
|
+
<key>ProcessType</key>
|
|
727
|
+
<string>${definition.processType}</string>
|
|
728
|
+
<key>ThrottleInterval</key>
|
|
729
|
+
<integer>${definition.throttleInterval}</integer>
|
|
730
|
+
<key>ExitTimeOut</key>
|
|
731
|
+
<integer>${definition.exitTimeOut}</integer>
|
|
732
|
+
<key>AbandonProcessGroup</key>
|
|
733
|
+
<true/>
|
|
734
|
+
<key>Umask</key>
|
|
735
|
+
<integer>${definition.umask}</integer>
|
|
736
|
+
</dict>
|
|
737
|
+
</plist>
|
|
738
|
+
`;
|
|
739
|
+
}
|
|
740
|
+
function createSystemdDefinition(input) {
|
|
741
|
+
return {
|
|
742
|
+
description: "Treeport daemon",
|
|
743
|
+
execStart: input.runnerPath,
|
|
744
|
+
environment: input.environment,
|
|
745
|
+
restart: "always",
|
|
746
|
+
restartSeconds: 5,
|
|
747
|
+
timeoutStopSeconds: 10,
|
|
748
|
+
killMode: "process",
|
|
749
|
+
wantedBy: "default.target"
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
function serializeSystemdDefinition(definition) {
|
|
753
|
+
const environment = Object.entries(definition.environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, value]) => `Environment="${systemdValue(name)}=${systemdValue(value)}"`).join("\n");
|
|
754
|
+
return `[Unit]
|
|
755
|
+
Description=${definition.description}
|
|
756
|
+
|
|
757
|
+
[Service]
|
|
758
|
+
Type=simple
|
|
759
|
+
ExecStart="${systemdValue(definition.execStart)}"
|
|
760
|
+
${environment}
|
|
761
|
+
Restart=${definition.restart}
|
|
762
|
+
RestartSec=${definition.restartSeconds}
|
|
763
|
+
TimeoutStopSec=${definition.timeoutStopSeconds}
|
|
764
|
+
KillMode=${definition.killMode}
|
|
765
|
+
|
|
766
|
+
[Install]
|
|
767
|
+
WantedBy=${definition.wantedBy}
|
|
768
|
+
`;
|
|
769
|
+
}
|
|
770
|
+
async function runCommand(executable, args, environment = process.env) {
|
|
771
|
+
return new Promise((resolve) => {
|
|
772
|
+
const child = spawn(executable, args, {
|
|
773
|
+
env: environment,
|
|
774
|
+
stdio: [
|
|
775
|
+
"ignore",
|
|
776
|
+
"pipe",
|
|
777
|
+
"pipe"
|
|
778
|
+
]
|
|
779
|
+
});
|
|
780
|
+
let stdout = "";
|
|
781
|
+
let stderr = "";
|
|
782
|
+
child.stdout.setEncoding("utf8");
|
|
783
|
+
child.stderr.setEncoding("utf8");
|
|
784
|
+
child.stdout.on("data", (value) => {
|
|
785
|
+
stdout += value;
|
|
786
|
+
});
|
|
787
|
+
child.stderr.on("data", (value) => {
|
|
788
|
+
stderr += value;
|
|
789
|
+
});
|
|
790
|
+
child.once("error", (error) => {
|
|
791
|
+
resolve({
|
|
792
|
+
code: 127,
|
|
793
|
+
stdout,
|
|
794
|
+
stderr: error.message
|
|
795
|
+
});
|
|
796
|
+
});
|
|
797
|
+
child.once("close", (code) => {
|
|
798
|
+
resolve({
|
|
799
|
+
code: code ?? 1,
|
|
800
|
+
stdout,
|
|
801
|
+
stderr
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
function commandError(command, result) {
|
|
807
|
+
const detail = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n");
|
|
808
|
+
return /* @__PURE__ */ new Error(`${command} failed${detail ? `: ${detail}` : ` with status ${result.code}`}`);
|
|
809
|
+
}
|
|
810
|
+
async function executablePath(name) {
|
|
811
|
+
const candidates = name === "launchctl" ? ["/bin/launchctl", "/usr/bin/launchctl"] : [`/usr/bin/${name}`, `/bin/${name}`];
|
|
812
|
+
for (const candidate of candidates) if (await fs.access(candidate, constants.X_OK).then(() => true).catch(() => false)) return candidate;
|
|
813
|
+
return name;
|
|
814
|
+
}
|
|
815
|
+
async function primaryGroup(username) {
|
|
816
|
+
const result = await runCommand(await executablePath("id"), ["-gn", username]);
|
|
817
|
+
if (result.code !== 0 || !result.stdout.trim()) throw commandError("id -gn", result);
|
|
818
|
+
return result.stdout.trim();
|
|
819
|
+
}
|
|
820
|
+
function currentEntrypoint() {
|
|
821
|
+
const value = process.env.TREEPORT_CLI_ENTRYPOINT?.trim() || process.argv[1]?.trim();
|
|
822
|
+
return value ? path.resolve(value) : null;
|
|
823
|
+
}
|
|
824
|
+
async function ensureEntrypoint(installationMethod) {
|
|
825
|
+
const entrypoint = currentEntrypoint();
|
|
826
|
+
if (!entrypoint) throw new Error("Treeport could not identify a stable CLI entrypoint. Install Treeport with npm or the curl installer, then retry.");
|
|
827
|
+
await fs.access(entrypoint, constants.X_OK).catch(() => {
|
|
828
|
+
throw new Error(`Treeport cannot execute its stable CLI entrypoint at ${entrypoint}. Reinstall Treeport, then retry.`);
|
|
829
|
+
});
|
|
830
|
+
if (installationMethod === "npm") {
|
|
831
|
+
const [actual, expected] = await Promise.all([fs.realpath(entrypoint), fs.realpath(await resolvePackagePath("bin", "treeport.mjs"))]);
|
|
832
|
+
if (actual !== expected) throw new Error(`The current CLI entrypoint is not the installed Treeport npm bin: ${entrypoint}`);
|
|
833
|
+
}
|
|
834
|
+
return entrypoint;
|
|
835
|
+
}
|
|
836
|
+
function cacheDirectory(home, env) {
|
|
837
|
+
const configured = env.TREEPORT_CACHE_DIR?.trim();
|
|
838
|
+
if (configured) return path.resolve(configured.replace(/^~(?=\/|$)/, home));
|
|
839
|
+
if (env.XDG_CACHE_HOME?.trim()) return path.join(path.resolve(env.XDG_CACHE_HOME.replace(/^~(?=\/|$)/, home)), "treeport");
|
|
840
|
+
return process.platform === "darwin" ? path.join(home, "Library", "Caches", "treeport") : path.join(home, ".cache", "treeport");
|
|
841
|
+
}
|
|
842
|
+
function createServiceEnvironment(input) {
|
|
843
|
+
const env = input.env ?? process.env;
|
|
844
|
+
const url = new URL(input.apiUrl);
|
|
845
|
+
assertLoopbackHost(url.hostname);
|
|
846
|
+
const result = {
|
|
847
|
+
HOME: input.user.homedir,
|
|
848
|
+
USER: input.user.username,
|
|
849
|
+
LOGNAME: input.user.username,
|
|
850
|
+
PATH: env.PATH?.trim() || "/usr/local/bin:/usr/bin:/bin",
|
|
851
|
+
TREEPORT_HOST: url.hostname,
|
|
852
|
+
TREEPORT_PORT: url.port || "80",
|
|
853
|
+
TREEPORT_API_URL: input.apiUrl,
|
|
854
|
+
TREEPORT_DATA_DIR: input.paths.dataDir,
|
|
855
|
+
TREEPORT_RUNTIME_DIR: input.paths.runtimeDir,
|
|
856
|
+
TREEPORT_CACHE_DIR: cacheDirectory(input.user.homedir, env),
|
|
857
|
+
TREEPORT_DATABASE_PATH: env.TREEPORT_DATABASE_PATH?.trim() || path.join(input.paths.dataDir, "treeport.db"),
|
|
858
|
+
TREEPORT_SHELL: env.TREEPORT_SHELL?.trim() || env.SHELL?.trim() || "/bin/sh",
|
|
859
|
+
TREEPORT_TMUX_PATH: env.TREEPORT_TMUX_PATH?.trim() || "tmux",
|
|
860
|
+
TREEPORT_GIT_PATH: env.TREEPORT_GIT_PATH?.trim() || "git",
|
|
861
|
+
TREEPORT_GH_PATH: env.TREEPORT_GH_PATH?.trim() || "gh",
|
|
862
|
+
TREEPORT_DAEMON_LIFECYCLE: "service",
|
|
863
|
+
TREEPORT_INSTALLATION_METHOD: input.installationMethod,
|
|
864
|
+
TREEPORT_SERVICE_RECORD: input.recordPath
|
|
865
|
+
};
|
|
866
|
+
for (const [name, value] of Object.entries(env)) if (value !== void 0 && (name === "LANG" || name === "LC_ALL" || name.startsWith("LC_"))) result[name] = value;
|
|
867
|
+
return result;
|
|
868
|
+
}
|
|
869
|
+
function definitionForRecord(record) {
|
|
870
|
+
if (record.manager === "launchd") return serializeLaunchdDefinition(createLaunchdDefinition({
|
|
871
|
+
label: record.definitionName,
|
|
872
|
+
runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath,
|
|
873
|
+
username: record.username,
|
|
874
|
+
group: record.group,
|
|
875
|
+
environment: record.environment,
|
|
876
|
+
home: record.home,
|
|
877
|
+
logPath: record.logPath
|
|
878
|
+
}));
|
|
879
|
+
return serializeSystemdDefinition(createSystemdDefinition({
|
|
880
|
+
runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath,
|
|
881
|
+
environment: record.environment
|
|
882
|
+
}));
|
|
883
|
+
}
|
|
884
|
+
function runnerSource(record) {
|
|
885
|
+
return `#!/bin/sh
|
|
886
|
+
set -u
|
|
887
|
+
entrypoint=${shellQuote(record.cliEntrypoint)}
|
|
888
|
+
record=${shellQuote(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).recordPath)}
|
|
889
|
+
log=${shellQuote(record.logPath)}
|
|
890
|
+
reported=0
|
|
891
|
+
while [ ! -x "$entrypoint" ]; do
|
|
892
|
+
if [ "$reported" -eq 0 ]; then
|
|
893
|
+
mkdir -p "$(dirname "$log")"
|
|
894
|
+
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"
|
|
895
|
+
reported=1
|
|
896
|
+
fi
|
|
897
|
+
sleep 60
|
|
898
|
+
done
|
|
899
|
+
export TREEPORT_SERVICE_RECORD="$record"
|
|
900
|
+
exec "$entrypoint" service run
|
|
901
|
+
`;
|
|
902
|
+
}
|
|
903
|
+
async function currentRecord() {
|
|
904
|
+
return readJson(servicePaths().recordPath, serviceRecordSchema);
|
|
905
|
+
}
|
|
906
|
+
async function saveRecord(record) {
|
|
907
|
+
await writeJson(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).recordPath, record);
|
|
908
|
+
}
|
|
909
|
+
async function managerState(record) {
|
|
910
|
+
if (record.manager === "launchd") {
|
|
911
|
+
const launchctl = await executablePath("launchctl");
|
|
912
|
+
const [active, disabled, definitionExists] = await Promise.all([
|
|
913
|
+
runCommand(launchctl, ["print", `system/${record.definitionName}`]),
|
|
914
|
+
runCommand(launchctl, ["print-disabled", "system"]),
|
|
915
|
+
fs.access(record.definitionPath).then(() => true).catch(() => false)
|
|
916
|
+
]);
|
|
917
|
+
return {
|
|
918
|
+
active: active.code === 0,
|
|
919
|
+
enabled: definitionExists && !disabled.stdout.includes(`"${record.definitionName}" => true`),
|
|
920
|
+
lingering: true,
|
|
921
|
+
managerIssue: null
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
const systemctl = await executablePath("systemctl");
|
|
925
|
+
const [active, enabled, linger] = await Promise.all([
|
|
926
|
+
runCommand(systemctl, [
|
|
927
|
+
"--user",
|
|
928
|
+
"is-active",
|
|
929
|
+
record.definitionName
|
|
930
|
+
]),
|
|
931
|
+
runCommand(systemctl, [
|
|
932
|
+
"--user",
|
|
933
|
+
"is-enabled",
|
|
934
|
+
record.definitionName
|
|
935
|
+
]),
|
|
936
|
+
runCommand(await executablePath("loginctl"), [
|
|
937
|
+
"show-user",
|
|
938
|
+
record.username,
|
|
939
|
+
"-p",
|
|
940
|
+
"Linger",
|
|
941
|
+
"--value"
|
|
942
|
+
])
|
|
943
|
+
]);
|
|
944
|
+
return {
|
|
945
|
+
active: active.code === 0 && active.stdout.trim() === "active",
|
|
946
|
+
enabled: enabled.code === 0 && enabled.stdout.trim() === "enabled",
|
|
947
|
+
lingering: linger.code === 0 && linger.stdout.trim() === "yes",
|
|
948
|
+
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
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
function administratorCommand(record) {
|
|
952
|
+
const requestId = record.pendingAdministratorRequestId;
|
|
953
|
+
if (!requestId) return null;
|
|
954
|
+
const requestPath = path.join(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).requestsDirectory, `${requestId}.json`);
|
|
955
|
+
return `sudo ${shellQuote(record.cliEntrypoint)} service apply --request ${shellQuote(requestPath)}`;
|
|
956
|
+
}
|
|
957
|
+
async function untrackedDefinition() {
|
|
958
|
+
const manager = managerForPlatform();
|
|
959
|
+
if (!manager) return null;
|
|
960
|
+
const user = os.userInfo();
|
|
961
|
+
const name = manager === "launchd" ? `app.treeport.daemon.${user.uid}` : "treeport.service";
|
|
962
|
+
const definitionPath = manager === "launchd" ? `/Library/LaunchDaemons/${name}.plist` : path.join(process.env.XDG_CONFIG_HOME?.trim() || path.join(user.homedir, ".config"), "systemd", "user", name);
|
|
963
|
+
return await fs.access(definitionPath).then(() => true).catch(() => false) ? {
|
|
964
|
+
manager,
|
|
965
|
+
name,
|
|
966
|
+
path: definitionPath
|
|
967
|
+
} : null;
|
|
968
|
+
}
|
|
969
|
+
async function serviceInstalled() {
|
|
970
|
+
return await currentRecord() !== null || await untrackedDefinition() !== null;
|
|
971
|
+
}
|
|
972
|
+
async function serviceStatus() {
|
|
973
|
+
const manager = managerForPlatform();
|
|
974
|
+
const record = await currentRecord();
|
|
975
|
+
if (!manager) return {
|
|
976
|
+
supported: false,
|
|
977
|
+
manager: null,
|
|
978
|
+
state: "disabled",
|
|
979
|
+
installed: false,
|
|
980
|
+
enabledAtBoot: false,
|
|
981
|
+
active: false,
|
|
982
|
+
healthy: false,
|
|
983
|
+
rebootReady: false,
|
|
984
|
+
definitionMatches: false,
|
|
985
|
+
environmentMatches: false,
|
|
986
|
+
entrypointMatches: false,
|
|
987
|
+
requestedState: null,
|
|
988
|
+
definitionPath: null,
|
|
989
|
+
entrypoint: null,
|
|
990
|
+
daemon: null,
|
|
991
|
+
issues: [`Treeport service mode does not support ${process.platform}.`],
|
|
992
|
+
recoveryCommands: [],
|
|
993
|
+
administratorCommand: null
|
|
994
|
+
};
|
|
995
|
+
if (!record) {
|
|
996
|
+
const untracked = await untrackedDefinition();
|
|
997
|
+
if (!untracked) return {
|
|
998
|
+
supported: true,
|
|
999
|
+
manager,
|
|
1000
|
+
state: "disabled",
|
|
1001
|
+
installed: false,
|
|
1002
|
+
enabledAtBoot: false,
|
|
1003
|
+
active: false,
|
|
1004
|
+
healthy: false,
|
|
1005
|
+
rebootReady: false,
|
|
1006
|
+
definitionMatches: false,
|
|
1007
|
+
environmentMatches: false,
|
|
1008
|
+
entrypointMatches: false,
|
|
1009
|
+
requestedState: null,
|
|
1010
|
+
definitionPath: null,
|
|
1011
|
+
entrypoint: null,
|
|
1012
|
+
daemon: null,
|
|
1013
|
+
issues: [],
|
|
1014
|
+
recoveryCommands: ["treeport service enable"],
|
|
1015
|
+
administratorCommand: null
|
|
1016
|
+
};
|
|
1017
|
+
return {
|
|
1018
|
+
supported: true,
|
|
1019
|
+
manager,
|
|
1020
|
+
state: "stale",
|
|
1021
|
+
installed: true,
|
|
1022
|
+
enabledAtBoot: true,
|
|
1023
|
+
active: (untracked.manager === "launchd" ? await runCommand(await executablePath("launchctl"), ["print", `system/${untracked.name}`]) : await runCommand(await executablePath("systemctl"), [
|
|
1024
|
+
"--user",
|
|
1025
|
+
"is-active",
|
|
1026
|
+
untracked.name
|
|
1027
|
+
])).code === 0,
|
|
1028
|
+
healthy: false,
|
|
1029
|
+
rebootReady: false,
|
|
1030
|
+
definitionMatches: false,
|
|
1031
|
+
environmentMatches: false,
|
|
1032
|
+
entrypointMatches: false,
|
|
1033
|
+
requestedState: null,
|
|
1034
|
+
definitionPath: untracked.path,
|
|
1035
|
+
entrypoint: null,
|
|
1036
|
+
daemon: null,
|
|
1037
|
+
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.`],
|
|
1038
|
+
recoveryCommands: [],
|
|
1039
|
+
administratorCommand: null
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
const paths = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
|
|
1043
|
+
const [managerStatus, definitionContent, entrypointExists, daemon] = await Promise.all([
|
|
1044
|
+
managerState(record),
|
|
1045
|
+
fs.readFile(record.definitionPath, "utf8").catch(() => ""),
|
|
1046
|
+
fs.access(record.cliEntrypoint, constants.X_OK).then(() => true).catch(() => false),
|
|
1047
|
+
daemonStatus()
|
|
1048
|
+
]);
|
|
1049
|
+
const definitionPresent = definitionContent !== "";
|
|
1050
|
+
const definitionMatches = definitionPresent && fingerprint(definitionContent) === record.definitionHash;
|
|
1051
|
+
const invokedEntrypoint = currentEntrypoint();
|
|
1052
|
+
const entrypointMatches = entrypointExists && (invokedEntrypoint === null || path.resolve(invokedEntrypoint) === path.resolve(record.cliEntrypoint));
|
|
1053
|
+
const environmentMatches = fingerprint(createServiceEnvironment({
|
|
1054
|
+
user: {
|
|
1055
|
+
uid: record.uid,
|
|
1056
|
+
gid: record.gid,
|
|
1057
|
+
username: record.username,
|
|
1058
|
+
homedir: record.home,
|
|
1059
|
+
shell: record.environment.TREEPORT_SHELL ?? null
|
|
1060
|
+
},
|
|
1061
|
+
paths: localPaths({
|
|
1062
|
+
TREEPORT_DATA_DIR: record.dataDir,
|
|
1063
|
+
TREEPORT_RUNTIME_DIR: record.runtimeDir
|
|
1064
|
+
}),
|
|
1065
|
+
apiUrl: record.apiUrl,
|
|
1066
|
+
recordPath: paths.recordPath,
|
|
1067
|
+
installationMethod: record.installationMethod
|
|
1068
|
+
})) === record.environmentHash;
|
|
1069
|
+
const healthy = Boolean(daemon.verified && daemon.health?.daemonLifecycle === "service" && daemon.state?.daemonLifecycle === "service" && path.resolve(daemon.state.dataDir) === path.resolve(record.dataDir));
|
|
1070
|
+
const installed = managerStatus.enabled;
|
|
1071
|
+
const rebootReady = installed && (record.manager === "launchd" || managerStatus.lingering);
|
|
1072
|
+
const pendingCommand = administratorCommand(record) ?? (record.manager === "systemd" && managerStatus.enabled && !managerStatus.lingering ? `sudo loginctl enable-linger ${record.username}` : null);
|
|
1073
|
+
const issues = [];
|
|
1074
|
+
const recoveryCommands = [];
|
|
1075
|
+
if (record.manager !== manager) issues.push(`The service record uses ${record.manager}, but this host requires ${manager}.`);
|
|
1076
|
+
if (!definitionMatches && !record.pendingAdministratorRequestId) {
|
|
1077
|
+
issues.push(definitionPresent ? `The service definition at ${record.definitionPath} was changed.` : `The service definition is missing at ${record.definitionPath}.`);
|
|
1078
|
+
recoveryCommands.push("treeport service enable");
|
|
1079
|
+
}
|
|
1080
|
+
if (definitionMatches && !installed && !record.pendingAdministratorRequestId) {
|
|
1081
|
+
issues.push("The service definition is not enabled for startup after reboot.");
|
|
1082
|
+
recoveryCommands.push("treeport service enable");
|
|
1083
|
+
}
|
|
1084
|
+
if (!entrypointMatches) {
|
|
1085
|
+
issues.push(`The service CLI entrypoint is unavailable or moved: ${record.cliEntrypoint}`);
|
|
1086
|
+
recoveryCommands.push("treeport service enable");
|
|
1087
|
+
}
|
|
1088
|
+
if (!environmentMatches) {
|
|
1089
|
+
issues.push("The service environment differs from the current Treeport environment.");
|
|
1090
|
+
recoveryCommands.push("treeport service enable");
|
|
1091
|
+
}
|
|
1092
|
+
if (record.manager === "systemd" && installed && !managerStatus.lingering) {
|
|
1093
|
+
issues.push(`User lingering is disabled for ${record.username}.`);
|
|
1094
|
+
recoveryCommands.push(`sudo loginctl enable-linger ${record.username}`);
|
|
1095
|
+
}
|
|
1096
|
+
if (managerStatus.managerIssue) issues.push(managerStatus.managerIssue);
|
|
1097
|
+
if (installed && record.requestedState === "running" && !healthy && !record.pendingAdministratorRequestId) {
|
|
1098
|
+
issues.push("The supervised Treeport daemon is not healthy.");
|
|
1099
|
+
recoveryCommands.push("treeport start");
|
|
1100
|
+
}
|
|
1101
|
+
const stale = record.manager !== manager || !definitionMatches || !environmentMatches || !entrypointMatches || definitionPresent && !installed || managerStatus.managerIssue !== null;
|
|
1102
|
+
return {
|
|
1103
|
+
supported: true,
|
|
1104
|
+
manager,
|
|
1105
|
+
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",
|
|
1106
|
+
installed,
|
|
1107
|
+
enabledAtBoot: installed,
|
|
1108
|
+
active: managerStatus.active,
|
|
1109
|
+
healthy,
|
|
1110
|
+
rebootReady,
|
|
1111
|
+
definitionMatches,
|
|
1112
|
+
environmentMatches,
|
|
1113
|
+
entrypointMatches,
|
|
1114
|
+
requestedState: record.requestedState,
|
|
1115
|
+
definitionPath: record.definitionPath,
|
|
1116
|
+
entrypoint: record.cliEntrypoint,
|
|
1117
|
+
daemon,
|
|
1118
|
+
issues,
|
|
1119
|
+
recoveryCommands: [...new Set(recoveryCommands)],
|
|
1120
|
+
administratorCommand: pendingCommand
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
async function prepareRecord() {
|
|
1124
|
+
if (process.getuid?.() === 0) throw new Error("Run `treeport service enable` as the user who will run Treeport, not as root.");
|
|
1125
|
+
const manager = managerForPlatform();
|
|
1126
|
+
if (!manager) throw new Error(`Treeport service mode supports macOS launchd and Linux systemd; found ${process.platform}.`);
|
|
1127
|
+
const explicitApiUrl = process.env.TREEPORT_API_URL?.trim();
|
|
1128
|
+
if (explicitApiUrl) assertLoopbackHost(new URL(explicitApiUrl).hostname);
|
|
1129
|
+
const user = os.userInfo();
|
|
1130
|
+
const paths = localPaths();
|
|
1131
|
+
const locations = servicePaths();
|
|
1132
|
+
const apiUrl = await resolveLocalApiUrl();
|
|
1133
|
+
const listener = new URL(apiUrl);
|
|
1134
|
+
if (listener.protocol !== "http:") throw new Error("Treeport service mode requires a local HTTP loopback URL.");
|
|
1135
|
+
assertLoopbackHost(listener.hostname);
|
|
1136
|
+
const installationMethod = process.env.TREEPORT_INSTALLATION_METHOD?.trim() === "curl" ? "curl" : "npm";
|
|
1137
|
+
const cliEntrypoint = await ensureEntrypoint(installationMethod);
|
|
1138
|
+
const group = await primaryGroup(user.username);
|
|
1139
|
+
const definitionName = manager === "launchd" ? `app.treeport.daemon.${user.uid}` : "treeport.service";
|
|
1140
|
+
const definitionPath = manager === "launchd" ? `/Library/LaunchDaemons/${definitionName}.plist` : path.join(process.env.XDG_CONFIG_HOME?.trim() || path.join(user.homedir, ".config"), "systemd", "user", definitionName);
|
|
1141
|
+
const environment = createServiceEnvironment({
|
|
1142
|
+
user,
|
|
1143
|
+
paths,
|
|
1144
|
+
apiUrl,
|
|
1145
|
+
recordPath: locations.recordPath,
|
|
1146
|
+
installationMethod
|
|
1147
|
+
});
|
|
1148
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1149
|
+
const previous = await currentRecord();
|
|
1150
|
+
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.`);
|
|
1151
|
+
if (previous && path.resolve(previous.dataDir) !== paths.dataDir) throw new Error(`Treeport service mode already uses ${previous.dataDir}. Disable it before enabling ${paths.dataDir}.`);
|
|
1152
|
+
const base = {
|
|
1153
|
+
schemaVersion: 1,
|
|
1154
|
+
manager,
|
|
1155
|
+
platform: process.platform,
|
|
1156
|
+
uid: user.uid,
|
|
1157
|
+
gid: user.gid,
|
|
1158
|
+
username: user.username,
|
|
1159
|
+
group,
|
|
1160
|
+
home: user.homedir,
|
|
1161
|
+
dataDir: paths.dataDir,
|
|
1162
|
+
runtimeDir: paths.runtimeDir,
|
|
1163
|
+
logPath: paths.logPath,
|
|
1164
|
+
apiUrl,
|
|
1165
|
+
cliEntrypoint,
|
|
1166
|
+
installationMethod,
|
|
1167
|
+
definitionName,
|
|
1168
|
+
definitionPath,
|
|
1169
|
+
definitionHash: "0".repeat(64),
|
|
1170
|
+
environmentHash: fingerprint(environment),
|
|
1171
|
+
environment,
|
|
1172
|
+
requestedState: "running",
|
|
1173
|
+
pendingAdministratorRequestId: null,
|
|
1174
|
+
createdAt: previous?.createdAt ?? now,
|
|
1175
|
+
updatedAt: now
|
|
1176
|
+
};
|
|
1177
|
+
const definition = definitionForRecord(base);
|
|
1178
|
+
return {
|
|
1179
|
+
record: {
|
|
1180
|
+
...base,
|
|
1181
|
+
definitionHash: fingerprint(definition)
|
|
1182
|
+
},
|
|
1183
|
+
definition
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
async function writeServiceFiles(record, definition) {
|
|
1187
|
+
const locations = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
|
|
1188
|
+
await Promise.all([fs.mkdir(path.dirname(record.logPath), {
|
|
1189
|
+
recursive: true,
|
|
1190
|
+
mode: 448
|
|
1191
|
+
}), fs.mkdir(locations.requestsDirectory, {
|
|
1192
|
+
recursive: true,
|
|
1193
|
+
mode: 448
|
|
1194
|
+
})]);
|
|
1195
|
+
await fs.writeFile(locations.runnerPath, runnerSource(record), { mode: 448 });
|
|
1196
|
+
await fs.chmod(locations.runnerPath, 448);
|
|
1197
|
+
if (record.manager === "launchd") await fs.writeFile(locations.stagedDefinitionPath, definition, { mode: 384 });
|
|
1198
|
+
else {
|
|
1199
|
+
await fs.mkdir(path.dirname(record.definitionPath), {
|
|
1200
|
+
recursive: true,
|
|
1201
|
+
mode: 448
|
|
1202
|
+
});
|
|
1203
|
+
const temporaryPath = `${record.definitionPath}.${process.pid}.tmp`;
|
|
1204
|
+
await fs.writeFile(temporaryPath, definition, { mode: 384 });
|
|
1205
|
+
await fs.rename(temporaryPath, record.definitionPath);
|
|
1206
|
+
}
|
|
1207
|
+
await saveRecord(record);
|
|
1208
|
+
}
|
|
1209
|
+
async function prepareAdministratorRequest(record, operation) {
|
|
1210
|
+
const locations = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
|
|
1211
|
+
const id = crypto.randomUUID();
|
|
1212
|
+
const now = /* @__PURE__ */ new Date();
|
|
1213
|
+
const request = {
|
|
1214
|
+
schemaVersion: 1,
|
|
1215
|
+
id,
|
|
1216
|
+
operation,
|
|
1217
|
+
createdAt: now.toISOString(),
|
|
1218
|
+
expiresAt: new Date(now.getTime() + 15 * 6e4).toISOString(),
|
|
1219
|
+
uid: record.uid,
|
|
1220
|
+
gid: record.gid,
|
|
1221
|
+
username: record.username,
|
|
1222
|
+
group: record.group,
|
|
1223
|
+
home: record.home,
|
|
1224
|
+
serviceRecordPath: locations.recordPath,
|
|
1225
|
+
runnerPath: locations.runnerPath,
|
|
1226
|
+
definitionName: record.definitionName,
|
|
1227
|
+
definitionPath: record.definitionPath,
|
|
1228
|
+
stagedDefinitionPath: locations.stagedDefinitionPath,
|
|
1229
|
+
definitionHash: record.definitionHash,
|
|
1230
|
+
apiUrl: record.apiUrl,
|
|
1231
|
+
cliEntrypoint: record.cliEntrypoint
|
|
1232
|
+
};
|
|
1233
|
+
await writeJson(path.join(locations.requestsDirectory, `${id}.json`), request);
|
|
1234
|
+
const next = {
|
|
1235
|
+
...record,
|
|
1236
|
+
pendingAdministratorRequestId: id,
|
|
1237
|
+
updatedAt: now.toISOString()
|
|
1238
|
+
};
|
|
1239
|
+
await saveRecord(next);
|
|
1240
|
+
return {
|
|
1241
|
+
record: next,
|
|
1242
|
+
command: administratorCommand(next)
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
async function waitForService(record) {
|
|
1246
|
+
const deadline = Date.now() + 15e3;
|
|
1247
|
+
const version = await treeportVersion();
|
|
1248
|
+
while (Date.now() < deadline) {
|
|
1249
|
+
const observed = await daemonHealth(record.apiUrl, 500);
|
|
1250
|
+
if (observed?.daemonLifecycle === "service" && observed.instanceId && observed.version === version) return;
|
|
1251
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
1252
|
+
}
|
|
1253
|
+
throw new Error(`Treeport service did not become ready at ${record.apiUrl}. See ${record.logPath}.`);
|
|
1254
|
+
}
|
|
1255
|
+
async function serviceEnable() {
|
|
1256
|
+
const existing = await serviceStatus();
|
|
1257
|
+
if (existing.state === "healthy" && existing.definitionMatches && existing.environmentMatches && existing.entrypointMatches) return {
|
|
1258
|
+
status: existing,
|
|
1259
|
+
changed: false,
|
|
1260
|
+
administratorCommand: null
|
|
1261
|
+
};
|
|
1262
|
+
const { record, definition } = await prepareRecord();
|
|
1263
|
+
const failedChecks = (await runDoctor()).filter((check) => !check.ok);
|
|
1264
|
+
if (failedChecks.length) throw new Error(failedChecks.map((check) => `${check.name}: ${check.detail}`).join("\n"));
|
|
1265
|
+
const systemctl = record.manager === "systemd" ? await executablePath("systemctl") : null;
|
|
1266
|
+
if (systemctl) {
|
|
1267
|
+
const managerAvailable = await runCommand(systemctl, ["--user", "show-environment"]);
|
|
1268
|
+
if (managerAvailable.code !== 0) throw commandError("systemctl --user", managerAvailable);
|
|
1269
|
+
}
|
|
1270
|
+
await writeServiceFiles(record, definition);
|
|
1271
|
+
if (record.manager === "launchd") {
|
|
1272
|
+
await daemonDown();
|
|
1273
|
+
const prepared = await prepareAdministratorRequest(record, "enable");
|
|
1274
|
+
return {
|
|
1275
|
+
status: await serviceStatus(),
|
|
1276
|
+
changed: true,
|
|
1277
|
+
administratorCommand: prepared.command
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
if (!systemctl) throw new Error("Treeport could not resolve the systemd command.");
|
|
1281
|
+
await daemonDown();
|
|
1282
|
+
const reload = await runCommand(systemctl, ["--user", "daemon-reload"]);
|
|
1283
|
+
if (reload.code !== 0) {
|
|
1284
|
+
await Promise.all([fs.rm(record.definitionPath, { force: true }), fs.rm(servicePaths().directory, {
|
|
1285
|
+
recursive: true,
|
|
1286
|
+
force: true
|
|
1287
|
+
})]);
|
|
1288
|
+
await daemonUp({});
|
|
1289
|
+
throw commandError("systemctl --user daemon-reload", reload);
|
|
1290
|
+
}
|
|
1291
|
+
const enabled = await runCommand(systemctl, [
|
|
1292
|
+
"--user",
|
|
1293
|
+
"enable",
|
|
1294
|
+
"--now",
|
|
1295
|
+
record.definitionName
|
|
1296
|
+
]);
|
|
1297
|
+
if (enabled.code !== 0) {
|
|
1298
|
+
await fs.rm(record.definitionPath, { force: true });
|
|
1299
|
+
await runCommand(systemctl, ["--user", "daemon-reload"]);
|
|
1300
|
+
await fs.rm(servicePaths().directory, {
|
|
1301
|
+
recursive: true,
|
|
1302
|
+
force: true
|
|
1303
|
+
});
|
|
1304
|
+
await daemonUp({});
|
|
1305
|
+
throw commandError("systemctl --user enable --now", enabled);
|
|
1306
|
+
}
|
|
1307
|
+
const startupError = await waitForService(record).then(() => null, (error) => error);
|
|
1308
|
+
if (startupError) {
|
|
1309
|
+
await runCommand(systemctl, [
|
|
1310
|
+
"--user",
|
|
1311
|
+
"disable",
|
|
1312
|
+
"--now",
|
|
1313
|
+
record.definitionName
|
|
1314
|
+
]);
|
|
1315
|
+
await fs.rm(record.definitionPath, { force: true });
|
|
1316
|
+
await runCommand(systemctl, ["--user", "daemon-reload"]);
|
|
1317
|
+
await fs.rm(servicePaths().directory, {
|
|
1318
|
+
recursive: true,
|
|
1319
|
+
force: true
|
|
1320
|
+
});
|
|
1321
|
+
await daemonUp({});
|
|
1322
|
+
throw startupError;
|
|
1323
|
+
}
|
|
1324
|
+
const status = await serviceStatus();
|
|
1325
|
+
return {
|
|
1326
|
+
status,
|
|
1327
|
+
changed: true,
|
|
1328
|
+
administratorCommand: status.administratorCommand
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
async function serviceStart() {
|
|
1332
|
+
const record = await currentRecord();
|
|
1333
|
+
if (!record) throw new Error("Treeport service mode is disabled. Run `treeport service enable` first.");
|
|
1334
|
+
const current = await serviceStatus();
|
|
1335
|
+
if (current.state === "healthy") return {
|
|
1336
|
+
status: current,
|
|
1337
|
+
changed: false,
|
|
1338
|
+
administratorCommand: null
|
|
1339
|
+
};
|
|
1340
|
+
if (current.administratorCommand) return {
|
|
1341
|
+
status: current,
|
|
1342
|
+
changed: false,
|
|
1343
|
+
administratorCommand: current.administratorCommand
|
|
1344
|
+
};
|
|
1345
|
+
if (!current.definitionMatches || !current.entrypointMatches) throw new Error("The Treeport service definition is stale. Run `treeport service enable` to repair it.");
|
|
1346
|
+
const next = {
|
|
1347
|
+
...record,
|
|
1348
|
+
requestedState: "running",
|
|
1349
|
+
pendingAdministratorRequestId: null,
|
|
1350
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1351
|
+
};
|
|
1352
|
+
await saveRecord(next);
|
|
1353
|
+
if (record.manager === "launchd") {
|
|
1354
|
+
const prepared = await prepareAdministratorRequest(next, "start");
|
|
1355
|
+
return {
|
|
1356
|
+
status: await serviceStatus(),
|
|
1357
|
+
changed: true,
|
|
1358
|
+
administratorCommand: prepared.command
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
const result = await runCommand(await executablePath("systemctl"), [
|
|
1362
|
+
"--user",
|
|
1363
|
+
"start",
|
|
1364
|
+
record.definitionName
|
|
1365
|
+
]);
|
|
1366
|
+
if (result.code !== 0) throw commandError("systemctl --user start", result);
|
|
1367
|
+
await waitForService(next);
|
|
1368
|
+
return {
|
|
1369
|
+
status: await serviceStatus(),
|
|
1370
|
+
changed: true,
|
|
1371
|
+
administratorCommand: null
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
async function serviceStop() {
|
|
1375
|
+
const record = await currentRecord();
|
|
1376
|
+
if (!record) throw new Error("Treeport service mode is disabled.");
|
|
1377
|
+
const current = await serviceStatus();
|
|
1378
|
+
if (current.state === "stopped") return {
|
|
1379
|
+
status: current,
|
|
1380
|
+
changed: false,
|
|
1381
|
+
administratorCommand: null
|
|
1382
|
+
};
|
|
1383
|
+
const next = {
|
|
1384
|
+
...record,
|
|
1385
|
+
requestedState: "stopped",
|
|
1386
|
+
pendingAdministratorRequestId: null,
|
|
1387
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1388
|
+
};
|
|
1389
|
+
await saveRecord(next);
|
|
1390
|
+
if (record.manager === "launchd") {
|
|
1391
|
+
const prepared = await prepareAdministratorRequest(next, "stop");
|
|
1392
|
+
return {
|
|
1393
|
+
status: await serviceStatus(),
|
|
1394
|
+
changed: true,
|
|
1395
|
+
administratorCommand: prepared.command
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
const result = await runCommand(await executablePath("systemctl"), [
|
|
1399
|
+
"--user",
|
|
1400
|
+
"stop",
|
|
1401
|
+
record.definitionName
|
|
1402
|
+
]);
|
|
1403
|
+
if (result.code !== 0) {
|
|
1404
|
+
await saveRecord(record);
|
|
1405
|
+
throw commandError("systemctl --user stop", result);
|
|
1406
|
+
}
|
|
1407
|
+
return {
|
|
1408
|
+
status: await serviceStatus(),
|
|
1409
|
+
changed: true,
|
|
1410
|
+
administratorCommand: null
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
async function serviceDisable() {
|
|
1414
|
+
const record = await currentRecord();
|
|
1415
|
+
if (!record) return {
|
|
1416
|
+
status: await serviceStatus(),
|
|
1417
|
+
changed: false,
|
|
1418
|
+
administratorCommand: null
|
|
1419
|
+
};
|
|
1420
|
+
if (record.manager === "launchd") {
|
|
1421
|
+
const prepared = await prepareAdministratorRequest({
|
|
1422
|
+
...record,
|
|
1423
|
+
pendingAdministratorRequestId: null
|
|
1424
|
+
}, "disable");
|
|
1425
|
+
return {
|
|
1426
|
+
status: await serviceStatus(),
|
|
1427
|
+
changed: true,
|
|
1428
|
+
administratorCommand: prepared.command
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
const systemctl = await executablePath("systemctl");
|
|
1432
|
+
const disabled = await runCommand(systemctl, [
|
|
1433
|
+
"--user",
|
|
1434
|
+
"disable",
|
|
1435
|
+
"--now",
|
|
1436
|
+
record.definitionName
|
|
1437
|
+
]);
|
|
1438
|
+
if (disabled.code !== 0 && !disabled.stderr.includes("does not exist")) throw commandError("systemctl --user disable --now", disabled);
|
|
1439
|
+
await fs.rm(record.definitionPath, { force: true });
|
|
1440
|
+
await runCommand(systemctl, ["--user", "daemon-reload"]);
|
|
1441
|
+
await fs.rm(servicePaths().directory, {
|
|
1442
|
+
recursive: true,
|
|
1443
|
+
force: true
|
|
1444
|
+
});
|
|
1445
|
+
return {
|
|
1446
|
+
status: await serviceStatus(),
|
|
1447
|
+
changed: true,
|
|
1448
|
+
administratorCommand: null
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
async function serviceApply(requestPath) {
|
|
1452
|
+
if (process.platform !== "darwin") throw new Error("Treeport service apply is only available for macOS LaunchDaemons.");
|
|
1453
|
+
if (process.getuid?.() !== 0) throw new Error("Run the printed service apply command with sudo or as root.");
|
|
1454
|
+
if (!path.isAbsolute(requestPath)) throw new Error("The service apply request path must be absolute.");
|
|
1455
|
+
const metadata = await fs.lstat(requestPath);
|
|
1456
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("The service apply request must be a regular file, not a symlink.");
|
|
1457
|
+
if ((metadata.mode & 63) !== 0) throw new Error("The service apply request must not be readable or writable by other users.");
|
|
1458
|
+
const request = await readJson(requestPath, administratorRequestSchema);
|
|
1459
|
+
if (!request) throw new Error("The service apply request is invalid.");
|
|
1460
|
+
if (metadata.uid !== request.uid) throw new Error("The service apply request owner does not match its target user.");
|
|
1461
|
+
if (Date.parse(request.expiresAt) <= Date.now()) throw new Error("The service apply request expired. Run the original Treeport command again.");
|
|
1462
|
+
const usedPath = `${requestPath}.used`;
|
|
1463
|
+
if (await fs.access(usedPath).then(() => true).catch(() => false)) throw new Error("The service apply request was already used.");
|
|
1464
|
+
const account = os.userInfo({ encoding: "utf8" });
|
|
1465
|
+
const idResult = await runCommand(await executablePath("id"), ["-u", request.username]);
|
|
1466
|
+
if (idResult.code !== 0 || Number(idResult.stdout.trim()) !== request.uid) throw new Error("The service apply target user no longer matches the host account.");
|
|
1467
|
+
const record = await readJson(request.serviceRecordPath, serviceRecordSchema);
|
|
1468
|
+
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.definitionHash !== request.definitionHash || record.pendingAdministratorRequestId !== request.id) throw new Error("The service apply request does not match the current Treeport service record.");
|
|
1469
|
+
if (account.uid !== 0) throw new Error("Treeport service apply lost root privileges.");
|
|
1470
|
+
const launchctl = await executablePath("launchctl");
|
|
1471
|
+
const target = `system/${request.definitionName}`;
|
|
1472
|
+
if (request.operation === "enable") {
|
|
1473
|
+
const staged = await fs.readFile(request.stagedDefinitionPath, "utf8");
|
|
1474
|
+
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.");
|
|
1475
|
+
const temporaryPath = `${request.definitionPath}.${process.pid}.tmp`;
|
|
1476
|
+
await fs.copyFile(request.stagedDefinitionPath, temporaryPath);
|
|
1477
|
+
await fs.chown(temporaryPath, 0, 0);
|
|
1478
|
+
await fs.chmod(temporaryPath, 420);
|
|
1479
|
+
await fs.rename(temporaryPath, request.definitionPath);
|
|
1480
|
+
await runCommand(launchctl, ["bootout", target]);
|
|
1481
|
+
const enabled = await runCommand(launchctl, ["enable", target]);
|
|
1482
|
+
if (enabled.code !== 0) throw commandError("launchctl enable", enabled);
|
|
1483
|
+
const bootstrapped = await runCommand(launchctl, [
|
|
1484
|
+
"bootstrap",
|
|
1485
|
+
"system",
|
|
1486
|
+
request.definitionPath
|
|
1487
|
+
]);
|
|
1488
|
+
if (bootstrapped.code !== 0) throw commandError("launchctl bootstrap", bootstrapped);
|
|
1489
|
+
} else if (request.operation === "start") {
|
|
1490
|
+
const enabled = await runCommand(launchctl, ["enable", target]);
|
|
1491
|
+
if (enabled.code !== 0) throw commandError("launchctl enable", enabled);
|
|
1492
|
+
const started = (await runCommand(launchctl, ["print", target])).code === 0 ? await runCommand(launchctl, ["kickstart", target]) : await runCommand(launchctl, [
|
|
1493
|
+
"bootstrap",
|
|
1494
|
+
"system",
|
|
1495
|
+
request.definitionPath
|
|
1496
|
+
]);
|
|
1497
|
+
if (started.code !== 0) throw commandError("launchctl start", started);
|
|
1498
|
+
} else if (request.operation === "stop") {
|
|
1499
|
+
const stopped = await runCommand(launchctl, ["bootout", target]);
|
|
1500
|
+
if (stopped.code !== 0 && !stopped.stderr.includes("No such process")) throw commandError("launchctl bootout", stopped);
|
|
1501
|
+
} else {
|
|
1502
|
+
const installed = await fs.readFile(request.definitionPath, "utf8").catch(() => "");
|
|
1503
|
+
if (installed && fingerprint(installed) !== request.definitionHash) throw new Error("Refusing to remove a LaunchDaemon definition that Treeport did not create.");
|
|
1504
|
+
await runCommand(launchctl, ["bootout", target]);
|
|
1505
|
+
await fs.rm(request.definitionPath, { force: true });
|
|
1506
|
+
}
|
|
1507
|
+
if (request.operation === "enable" || request.operation === "start") await waitForService(record);
|
|
1508
|
+
await fs.rename(requestPath, usedPath);
|
|
1509
|
+
if (request.operation === "disable") await fs.rm(path.dirname(request.serviceRecordPath), {
|
|
1510
|
+
recursive: true,
|
|
1511
|
+
force: true
|
|
1512
|
+
});
|
|
1513
|
+
else {
|
|
1514
|
+
await writeJson(request.serviceRecordPath, {
|
|
1515
|
+
...record,
|
|
1516
|
+
requestedState: request.operation === "stop" ? "stopped" : "running",
|
|
1517
|
+
pendingAdministratorRequestId: null,
|
|
1518
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1519
|
+
});
|
|
1520
|
+
await fs.chown(request.serviceRecordPath, request.uid, request.gid);
|
|
1521
|
+
}
|
|
1522
|
+
return {
|
|
1523
|
+
operation: request.operation,
|
|
1524
|
+
applied: true
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
async function serviceRun() {
|
|
1528
|
+
const recordPath = process.env.TREEPORT_SERVICE_RECORD?.trim();
|
|
1529
|
+
if (!recordPath || !path.isAbsolute(recordPath)) throw new Error("Treeport service run requires a valid service record.");
|
|
1530
|
+
const record = await readJson(recordPath, serviceRecordSchema);
|
|
1531
|
+
if (!record) throw new Error(`Treeport service record is invalid: ${recordPath}`);
|
|
1532
|
+
if (process.getuid?.() === 0 || process.getuid?.() !== record.uid) throw new Error(`Treeport service must run as ${record.username} (UID ${record.uid}), never as root.`);
|
|
1533
|
+
await writeJson(recordPath, {
|
|
1534
|
+
...record,
|
|
1535
|
+
requestedState: "running",
|
|
1536
|
+
pendingAdministratorRequestId: null,
|
|
1537
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1538
|
+
});
|
|
1539
|
+
const [version, serverEntry, webDist] = await Promise.all([
|
|
1540
|
+
treeportVersion(),
|
|
1541
|
+
resolvePackagePath("dist", "node", "server", "index.js"),
|
|
1542
|
+
resolvePackagePath("dist", "web")
|
|
1543
|
+
]);
|
|
1544
|
+
Object.assign(process.env, record.environment, {
|
|
1545
|
+
TREEPORT_APP_VERSION: version,
|
|
1546
|
+
TREEPORT_INSTANCE_ID: crypto.randomUUID(),
|
|
1547
|
+
TREEPORT_WEB_DIST: webDist,
|
|
1548
|
+
TREEPORT_DAEMON_LIFECYCLE: "service"
|
|
1549
|
+
});
|
|
1550
|
+
await import(pathToFileURL(serverEntry).href);
|
|
1551
|
+
}
|
|
1552
|
+
async function readServiceLogs(lines) {
|
|
1553
|
+
const record = await currentRecord();
|
|
1554
|
+
if (!record || record.manager === "launchd") return (await fs.readFile(record?.logPath ?? localPaths().logPath, "utf8").catch((error) => {
|
|
1555
|
+
if (error.code === "ENOENT") return "";
|
|
1556
|
+
throw error;
|
|
1557
|
+
})).split("\n").slice(-lines - 1).join("\n");
|
|
1558
|
+
const result = await runCommand(await executablePath("journalctl"), [
|
|
1559
|
+
"--user",
|
|
1560
|
+
"--unit",
|
|
1561
|
+
record.definitionName,
|
|
1562
|
+
"--no-pager",
|
|
1563
|
+
"--lines",
|
|
1564
|
+
String(lines)
|
|
1565
|
+
]);
|
|
1566
|
+
if (result.code !== 0) throw commandError("journalctl --user", result);
|
|
1567
|
+
return result.stdout;
|
|
1568
|
+
}
|
|
1569
|
+
async function serviceDoctorCheck() {
|
|
1570
|
+
const status = await serviceStatus();
|
|
1571
|
+
if (!status.supported) return {
|
|
1572
|
+
name: "Service supervision",
|
|
1573
|
+
ok: false,
|
|
1574
|
+
detail: status.issues.join(" ")
|
|
1575
|
+
};
|
|
1576
|
+
if (status.state === "disabled") return {
|
|
1577
|
+
name: "Service supervision",
|
|
1578
|
+
ok: true,
|
|
1579
|
+
detail: "disabled (opt in with `treeport service enable`)"
|
|
1580
|
+
};
|
|
1581
|
+
if (status.state === "healthy") return {
|
|
1582
|
+
name: "Service supervision",
|
|
1583
|
+
ok: true,
|
|
1584
|
+
detail: `${status.manager}; enabled at boot and healthy`
|
|
1585
|
+
};
|
|
1586
|
+
if (status.state === "stopped") return {
|
|
1587
|
+
name: "Service supervision",
|
|
1588
|
+
ok: true,
|
|
1589
|
+
detail: `${status.manager}; intentionally stopped and enabled for next boot`
|
|
1590
|
+
};
|
|
1591
|
+
return {
|
|
1592
|
+
name: "Service supervision",
|
|
1593
|
+
ok: false,
|
|
1594
|
+
detail: status.issues.join(" ") || `state: ${status.state}`
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
//#endregion
|
|
1598
|
+
//#region src/cli/application.ts
|
|
318
1599
|
const contextPrefix = "TREEPORT";
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
1600
|
+
let configuredApiUrl;
|
|
1601
|
+
let apiUrl = "";
|
|
1602
|
+
let contextProjectId;
|
|
1603
|
+
let contextWorktreeId;
|
|
1604
|
+
let contextTerminalId;
|
|
1605
|
+
let configuredDaemonLifecycle;
|
|
1606
|
+
let jsonOutput = false;
|
|
1607
|
+
let workingDirectory = process.cwd();
|
|
1608
|
+
let writeStdout = (value) => {
|
|
1609
|
+
process.stdout.write(value);
|
|
1610
|
+
};
|
|
1611
|
+
let writeStderr = (value) => {
|
|
1612
|
+
process.stderr.write(value);
|
|
1613
|
+
};
|
|
1614
|
+
let requestedExitCode = 0;
|
|
324
1615
|
var CliError = class extends Error {
|
|
325
1616
|
exitCode;
|
|
326
1617
|
code;
|
|
@@ -332,6 +1623,43 @@ var CliError = class extends Error {
|
|
|
332
1623
|
this.details = details;
|
|
333
1624
|
}
|
|
334
1625
|
};
|
|
1626
|
+
async function resolveDaemonLifecycle() {
|
|
1627
|
+
if (configuredDaemonLifecycle === "external") return "external";
|
|
1628
|
+
if (configuredDaemonLifecycle === "service") return "service";
|
|
1629
|
+
if (configuredApiUrl) {
|
|
1630
|
+
const observed = await daemonHealth(apiUrl);
|
|
1631
|
+
if (observed) return observed.daemonLifecycle;
|
|
1632
|
+
}
|
|
1633
|
+
return await serviceInstalled() ? "service" : "treeport";
|
|
1634
|
+
}
|
|
1635
|
+
function formatServiceStatus(status) {
|
|
1636
|
+
const lines = [
|
|
1637
|
+
`Treeport service: ${status.state}`,
|
|
1638
|
+
`Manager: ${status.manager ?? "unsupported"}`,
|
|
1639
|
+
`Starts at boot: ${status.enabledAtBoot ? "yes" : "no"}`,
|
|
1640
|
+
`Active: ${status.active ? "yes" : "no"}`,
|
|
1641
|
+
`Definition: ${status.definitionPath ?? "not installed"}`
|
|
1642
|
+
];
|
|
1643
|
+
if (status.daemon?.state) lines.push(`PID: ${status.daemon.state.pid}`);
|
|
1644
|
+
if (status.issues.length) lines.push(...status.issues.map((issue) => `Issue: ${issue}`));
|
|
1645
|
+
if (status.administratorCommand) lines.push("Administrator action required:", status.administratorCommand, "Then run: treeport service status");
|
|
1646
|
+
else if (status.recoveryCommands.length) lines.push(`Next: ${status.recoveryCommands[0]}`);
|
|
1647
|
+
return lines.join("\n");
|
|
1648
|
+
}
|
|
1649
|
+
async function ensureServiceDaemon() {
|
|
1650
|
+
const result = await serviceStart();
|
|
1651
|
+
const state = result.status.daemon?.state;
|
|
1652
|
+
if (state && result.status.healthy) return {
|
|
1653
|
+
apiUrl: state.apiUrl,
|
|
1654
|
+
pid: state.pid
|
|
1655
|
+
};
|
|
1656
|
+
if (result.administratorCommand) throw new CliError(`An administrator must start the Treeport service:\n${result.administratorCommand}`, 5, "SERVICE_ADMINISTRATOR_ACTION_REQUIRED", result);
|
|
1657
|
+
if (!state || !result.status.healthy) throw new CliError("The Treeport service did not become healthy. Run `treeport service status`.", 3, "DAEMON_UNREACHABLE", result.status);
|
|
1658
|
+
return {
|
|
1659
|
+
apiUrl: state.apiUrl,
|
|
1660
|
+
pid: state.pid
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
335
1663
|
async function request(pathname, options = {}) {
|
|
336
1664
|
const controller = new AbortController();
|
|
337
1665
|
const externalSignal = options.signal;
|
|
@@ -363,6 +1691,29 @@ async function request(pathname, options = {}) {
|
|
|
363
1691
|
externalSignal?.removeEventListener("abort", abort);
|
|
364
1692
|
}
|
|
365
1693
|
}
|
|
1694
|
+
async function createWorktree(projectId, input) {
|
|
1695
|
+
let operation = (await request(`/api/projects/${encodeURIComponent(projectId)}/worktree-operations`, {
|
|
1696
|
+
method: "POST",
|
|
1697
|
+
body: JSON.stringify(input)
|
|
1698
|
+
})).operation;
|
|
1699
|
+
while (operation.status === "pending" || operation.status === "running") {
|
|
1700
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1701
|
+
operation = (await request(`/api/operations/${encodeURIComponent(operation.id)}`)).operation;
|
|
1702
|
+
}
|
|
1703
|
+
if (operation.status === "failed") throw new CliError(operation.error ?? "Worktree creation failed", 5, "WORKTREE_CREATION_FAILED");
|
|
1704
|
+
if (operation.kind !== "create") throw new CliError("Worktree creation returned an unexpected operation kind", 5, "INVALID_OPERATION_RESULT");
|
|
1705
|
+
const worktreeId = typeof operation.result?.worktreeId === "string" ? operation.result.worktreeId : operation.worktreeId;
|
|
1706
|
+
if (!worktreeId) throw new CliError("Completed worktree creation did not identify its worktree", 5, "INVALID_OPERATION_RESULT");
|
|
1707
|
+
const worktree = (await request(`/api/projects/${encodeURIComponent(projectId)}`)).project.worktrees.find((item) => item.id === worktreeId);
|
|
1708
|
+
if (!worktree) throw new CliError(`Created worktree ${worktreeId} was not found`, 5, "INVALID_OPERATION_RESULT");
|
|
1709
|
+
const terminalId = typeof operation.result?.terminalId === "string" ? operation.result.terminalId : null;
|
|
1710
|
+
return {
|
|
1711
|
+
worktree,
|
|
1712
|
+
terminal: worktree.terminals.find((item) => item.id === terminalId) ?? null,
|
|
1713
|
+
terminalError: typeof operation.result?.terminalError === "string" ? operation.result.terminalError : null,
|
|
1714
|
+
setupError: typeof operation.result?.setupError === "string" ? operation.result.setupError : null
|
|
1715
|
+
};
|
|
1716
|
+
}
|
|
366
1717
|
function commandArgv(args) {
|
|
367
1718
|
const separator = args.indexOf("--");
|
|
368
1719
|
if (separator === -1) return;
|
|
@@ -372,7 +1723,8 @@ function commandArgv(args) {
|
|
|
372
1723
|
return argv;
|
|
373
1724
|
}
|
|
374
1725
|
async function canonical(value) {
|
|
375
|
-
|
|
1726
|
+
const resolved = path.resolve(workingDirectory, value);
|
|
1727
|
+
return fs.realpath(resolved).catch(() => resolved);
|
|
376
1728
|
}
|
|
377
1729
|
async function projects() {
|
|
378
1730
|
return (await request("/api/projects")).projects;
|
|
@@ -394,6 +1746,14 @@ async function resolveProject(identifier) {
|
|
|
394
1746
|
if (!match) throw new CliError(`No registered project matches ${identifier}`, 5);
|
|
395
1747
|
return match;
|
|
396
1748
|
}
|
|
1749
|
+
async function packageSource(value) {
|
|
1750
|
+
if (value.startsWith("npm:")) return value;
|
|
1751
|
+
if (path.isAbsolute(value) || value === "." || value === ".." || value.startsWith("./") || value.startsWith("../") || value === "~" || value.startsWith("~/")) return canonical(value);
|
|
1752
|
+
return value;
|
|
1753
|
+
}
|
|
1754
|
+
async function localPackageProjectId() {
|
|
1755
|
+
return (await request(`/api/packages/project?${new URLSearchParams({ path: await canonical(workingDirectory) }).toString()}`)).project.id;
|
|
1756
|
+
}
|
|
397
1757
|
async function resolveWorktree(identifier) {
|
|
398
1758
|
const all = (await projects()).flatMap((project) => project.worktrees);
|
|
399
1759
|
const direct = all.find((worktree) => worktree.id === identifier);
|
|
@@ -407,6 +1767,43 @@ async function resolveWorktree(identifier) {
|
|
|
407
1767
|
if (!match) throw new CliError(`No registered worktree matches ${identifier}`, 5);
|
|
408
1768
|
return match;
|
|
409
1769
|
}
|
|
1770
|
+
function parseWebPanelInput(value) {
|
|
1771
|
+
if (value === void 0) return null;
|
|
1772
|
+
if (Buffer.byteLength(value) > 65536) throw new CliError("Web panel input is limited to 64 KiB", 2);
|
|
1773
|
+
let parsed;
|
|
1774
|
+
try {
|
|
1775
|
+
parsed = JSON.parse(value);
|
|
1776
|
+
} catch (error) {
|
|
1777
|
+
throw new CliError(`--input must contain valid JSON: ${error instanceof Error ? error.message : String(error)}`, 2);
|
|
1778
|
+
}
|
|
1779
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new CliError("--input must contain a JSON object", 2);
|
|
1780
|
+
return parsed;
|
|
1781
|
+
}
|
|
1782
|
+
async function webPanelDefinition(worktreeId, identifier) {
|
|
1783
|
+
const definitions = (await request(`/api/worktrees/${encodeURIComponent(worktreeId)}/web-panel-definitions`)).definitions;
|
|
1784
|
+
const exact = definitions.find((definition) => definition.id === identifier);
|
|
1785
|
+
if (exact) return exact;
|
|
1786
|
+
const matches = definitions.filter((definition) => decodeURIComponent(definition.id.split(":").at(-1) ?? "") === identifier);
|
|
1787
|
+
if (matches.length === 1) return matches[0];
|
|
1788
|
+
if (matches.length > 1) throw new CliError(`Web panel name ${identifier} is ambiguous: ${matches.map((match) => match.id).join(", ")}`, 5, "WEB_PANEL_DEFINITION_AMBIGUOUS", { definitionIds: matches.map((match) => match.id) });
|
|
1789
|
+
throw new CliError(`Web panel ${identifier} is not available in this worktree`, 5, "WEB_PANEL_DEFINITION_NOT_FOUND");
|
|
1790
|
+
}
|
|
1791
|
+
async function webPanelLaunchCwd(worktree) {
|
|
1792
|
+
const [cwd, worktreeRoot] = await Promise.all([canonical(workingDirectory), canonical(worktree.path)]);
|
|
1793
|
+
if (!pathContains(cwd, worktreeRoot)) throw new CliError(`The current directory is outside worktree ${worktree.name}`, 5, "INVALID_WEB_PANEL_LAUNCH_CWD", {
|
|
1794
|
+
cwd,
|
|
1795
|
+
worktreeId: worktree.id,
|
|
1796
|
+
worktreePath: worktree.path
|
|
1797
|
+
});
|
|
1798
|
+
return path.relative(worktreeRoot, cwd) || ".";
|
|
1799
|
+
}
|
|
1800
|
+
function webPanelUrl(worktree, panelId) {
|
|
1801
|
+
const target = new URL(apiUrl);
|
|
1802
|
+
target.pathname = `/projects/${encodeURIComponent(worktree.projectId)}/worktrees/${encodeURIComponent(worktree.id)}/panels/${encodeURIComponent(panelId)}`;
|
|
1803
|
+
target.search = "";
|
|
1804
|
+
target.hash = "";
|
|
1805
|
+
return target.href;
|
|
1806
|
+
}
|
|
410
1807
|
function resolveTerminalId(identifier) {
|
|
411
1808
|
if (identifier !== ".") return identifier;
|
|
412
1809
|
const terminalId = contextTerminalId;
|
|
@@ -423,16 +1820,11 @@ function parseCaptureLines(value) {
|
|
|
423
1820
|
return lines;
|
|
424
1821
|
}
|
|
425
1822
|
function parseDuration(value) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
m: 6e4,
|
|
432
|
-
h: 36e5
|
|
433
|
-
}[match[2]];
|
|
434
|
-
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2147483647) throw new CliError("Timeout must be between 1ms and 2147483647ms", 2);
|
|
435
|
-
return timeoutMs;
|
|
1823
|
+
try {
|
|
1824
|
+
return parseDurationMs(value);
|
|
1825
|
+
} catch (error) {
|
|
1826
|
+
throw new CliError(error instanceof Error ? error.message : String(error), 2);
|
|
1827
|
+
}
|
|
436
1828
|
}
|
|
437
1829
|
async function inspectTerminal(terminalId, signal) {
|
|
438
1830
|
return request(`/api/terminals/${encodeURIComponent(terminalId)}`, signal ? { signal } : {});
|
|
@@ -523,8 +1915,8 @@ async function waitForTerminal(terminalId, condition, timeoutMs) {
|
|
|
523
1915
|
condition
|
|
524
1916
|
});
|
|
525
1917
|
if (event.type === "terminal.metadata") {
|
|
526
|
-
const metadata =
|
|
527
|
-
if (!
|
|
1918
|
+
const { worktreeId: _worktreeId, ...metadata } = event.data;
|
|
1919
|
+
if (!observation) throw new CliError("Treeport daemon sent invalid terminal metadata", 3, "DAEMON_PROTOCOL_ERROR");
|
|
528
1920
|
observation = {
|
|
529
1921
|
...observation,
|
|
530
1922
|
metadata
|
|
@@ -559,8 +1951,7 @@ async function waitForTerminal(terminalId, condition, timeoutMs) {
|
|
|
559
1951
|
}
|
|
560
1952
|
}
|
|
561
1953
|
function print(value, human) {
|
|
562
|
-
|
|
563
|
-
else console.log(human ? human() : JSON.stringify(value, null, 2));
|
|
1954
|
+
writeStdout(`${jsonOutput ? JSON.stringify(value) : human ? human() : JSON.stringify(value, null, 2)}\n`);
|
|
564
1955
|
}
|
|
565
1956
|
const agentGuidance = `AI agents:
|
|
566
1957
|
If you're an AI agent, use \`treeport skills\` to see the usage guide.
|
|
@@ -568,15 +1959,70 @@ const agentGuidance = `AI agents:
|
|
|
568
1959
|
async function main(args) {
|
|
569
1960
|
const argv = args[0] === "spawn" || args[0] === "terminal" && args[1] === "create" ? commandArgv(args) : void 0;
|
|
570
1961
|
let parserError = "";
|
|
571
|
-
const program = new Command().name("treeport").description("Manage Treeport projects, worktrees, and terminals.").option("--json", "emit machine-readable JSON").addHelpText("beforeAll", agentGuidance).configureOutput({
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
1962
|
+
const program = new Command().name("treeport").usage("[options] [folder] [command]").description("Manage Treeport projects, worktrees, and terminals.").argument("[folder]", "folder inside a Git repository to open").option("--json", "emit machine-readable JSON").addHelpText("beforeAll", agentGuidance).configureOutput({
|
|
1963
|
+
writeOut: writeStdout,
|
|
1964
|
+
writeErr: (value) => {
|
|
1965
|
+
parserError += value;
|
|
1966
|
+
}
|
|
1967
|
+
}).showHelpAfterError().exitOverride();
|
|
1968
|
+
program.action(async (folder) => {
|
|
1969
|
+
if (folder === void 0) {
|
|
1970
|
+
writeStdout(program.helpInformation());
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
const absoluteFolder = path.resolve(workingDirectory, folder);
|
|
1974
|
+
if (!(await fs.stat(absoluteFolder).catch((error) => {
|
|
1975
|
+
if ((typeof error === "object" && error !== null && "code" in error ? error.code : void 0) === "ENOENT") throw new CliError(`Folder does not exist: ${absoluteFolder}`, 5, "FOLDER_NOT_FOUND", { path: absoluteFolder });
|
|
1976
|
+
throw new CliError(`Cannot access folder ${absoluteFolder}: ${error instanceof Error ? error.message : String(error)}`, 5, "FOLDER_UNREADABLE", { path: absoluteFolder });
|
|
1977
|
+
})).isDirectory()) throw new CliError(`Path is not a folder: ${absoluteFolder}`, 5, "FOLDER_NOT_DIRECTORY", { path: absoluteFolder });
|
|
1978
|
+
const canonicalFolder = await fs.realpath(absoluteFolder).catch((error) => {
|
|
1979
|
+
throw new CliError(`Cannot access folder ${absoluteFolder}: ${error instanceof Error ? error.message : String(error)}`, 5, "FOLDER_UNREADABLE", { path: absoluteFolder });
|
|
1980
|
+
});
|
|
1981
|
+
const lifecycle = await resolveDaemonLifecycle();
|
|
1982
|
+
if (lifecycle === "external") {
|
|
1983
|
+
if (!await daemonHealth(apiUrl)) throw new CliError(`Cannot reach the externally managed Treeport daemon at ${apiUrl}. Start it through the process that owns its lifecycle and retry.`, 3, "DAEMON_UNREACHABLE");
|
|
1984
|
+
} else if (lifecycle === "service") await ensureServiceDaemon();
|
|
1985
|
+
else await daemonUp({});
|
|
1986
|
+
const registered = await request("/api/projects", {
|
|
1987
|
+
method: "POST",
|
|
1988
|
+
body: JSON.stringify({ path: canonicalFolder })
|
|
1989
|
+
}).catch((error) => {
|
|
1990
|
+
if (error instanceof CliError && error.code === "NOT_A_GIT_REPOSITORY") throw new CliError(`No Git repository contains ${canonicalFolder}.`, error.exitCode, error.code, error.details);
|
|
1991
|
+
throw error;
|
|
1992
|
+
});
|
|
1993
|
+
const targetWorktree = registered.project.worktrees.filter((worktree) => !worktree.prunable && pathContains(canonicalFolder, worktree.path)).sort((left, right) => right.path.length - left.path.length)[0];
|
|
1994
|
+
if (!targetWorktree) throw new CliError(`Git did not report an active worktree containing ${canonicalFolder}.`, 5, "WORKTREE_NOT_FOUND", {
|
|
1995
|
+
path: canonicalFolder,
|
|
1996
|
+
projectId: registered.project.id
|
|
1997
|
+
});
|
|
1998
|
+
const target = new URL(apiUrl);
|
|
1999
|
+
target.pathname = `/projects/${encodeURIComponent(registered.project.id)}/worktrees/${encodeURIComponent(targetWorktree.id)}`;
|
|
2000
|
+
target.search = "";
|
|
2001
|
+
target.hash = "";
|
|
2002
|
+
const opened = await openWorkspace(target.href).catch((error) => {
|
|
2003
|
+
if (error instanceof OpenWorkspaceError) throw new CliError(error.message, 1, "OPEN_FAILED", { url: target.href });
|
|
2004
|
+
throw error;
|
|
2005
|
+
});
|
|
2006
|
+
print({
|
|
2007
|
+
projectId: registered.project.id,
|
|
2008
|
+
worktreeId: targetWorktree.id,
|
|
2009
|
+
path: canonicalFolder,
|
|
2010
|
+
url: target.href,
|
|
2011
|
+
client: opened.client
|
|
2012
|
+
}, () => `Opened ${registered.project.name} / ${targetWorktree.name} in the ${opened.client === "desktop" ? "Treeport desktop app" : "browser"}\n${target.href}`);
|
|
2013
|
+
});
|
|
2014
|
+
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");
|
|
2015
|
+
startCommand.action(async () => {
|
|
2016
|
+
const lifecycle = await resolveDaemonLifecycle();
|
|
2017
|
+
if (lifecycle === "external") throw new CliError("Cannot run `treeport start` because the daemon lifecycle is externally managed. Control the process that started Treeport instead.", 5, "DAEMON_LIFECYCLE_EXTERNAL");
|
|
2018
|
+
const options = startCommand.opts();
|
|
2019
|
+
if (lifecycle === "service") {
|
|
2020
|
+
if (options.foreground || options.host || options.port) throw new CliError("An installed service owns the listener and process mode. Run `treeport service enable` to refresh its configuration, or `treeport service disable` to return to local background mode.", 5, "DAEMON_LIFECYCLE_SERVICE");
|
|
2021
|
+
const result = await serviceStart();
|
|
2022
|
+
print(result, () => formatServiceStatus(result.status));
|
|
2023
|
+
if (result.administratorCommand || !result.status.healthy) requestedExitCode = 1;
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
580
2026
|
const port = options.port === void 0 ? void 0 : Number(options.port);
|
|
581
2027
|
const result = await daemonUp({
|
|
582
2028
|
...options.host === void 0 ? {} : { host: options.host },
|
|
@@ -584,48 +2030,113 @@ async function main(args) {
|
|
|
584
2030
|
...options.foreground === void 0 ? {} : { foreground: options.foreground }
|
|
585
2031
|
});
|
|
586
2032
|
if (options.foreground) return;
|
|
587
|
-
print(result, () => `Treeport is
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
].includes(listenerHost)) process.stderr.write("Warning: Treeport has no authentication. Use only a trusted private network.\n");
|
|
595
|
-
});
|
|
596
|
-
const downCommand = program.command("down").description("Stop the local daemon and preserve terminal sessions").option("--terminate-terminals", "terminate every Treeport-owned tmux server").option("--force", "confirm termination of all terminals").option("--json", "emit machine-readable JSON");
|
|
597
|
-
downCommand.action(async () => {
|
|
598
|
-
const options = downCommand.opts();
|
|
2033
|
+
print(result, () => `Treeport is running\n${result.apiUrl}`);
|
|
2034
|
+
});
|
|
2035
|
+
const stopCommand = program.command("stop").description("Stop the local daemon and preserve terminal sessions").option("--terminate-terminals", "terminate every Treeport-owned tmux server").option("--force", "confirm termination of all terminals").option("--json", "emit machine-readable JSON");
|
|
2036
|
+
stopCommand.action(async () => {
|
|
2037
|
+
const lifecycle = await resolveDaemonLifecycle();
|
|
2038
|
+
if (lifecycle === "external") throw new CliError("Cannot run `treeport stop` because the daemon lifecycle is externally managed. Control the process that started Treeport instead.", 5, "DAEMON_LIFECYCLE_EXTERNAL");
|
|
2039
|
+
const options = stopCommand.opts();
|
|
599
2040
|
if (options.terminateTerminals && !options.force) throw new CliError("Re-run with --terminate-terminals --force to confirm termination of every terminal.", 2);
|
|
600
2041
|
if (options.terminateTerminals) await request("/api/admin/terminate-terminals", { method: "POST" });
|
|
2042
|
+
if (lifecycle === "service") {
|
|
2043
|
+
const result = await serviceStop();
|
|
2044
|
+
print(result, () => formatServiceStatus(result.status));
|
|
2045
|
+
if (result.administratorCommand) requestedExitCode = 1;
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
601
2048
|
const result = await daemonDown();
|
|
602
|
-
print(result, () => result.wasRunning ? "Treeport is
|
|
2049
|
+
print(result, () => result.wasRunning ? "Treeport is stopped" : "Treeport is already stopped");
|
|
2050
|
+
});
|
|
2051
|
+
const serviceCommand = program.command("service").description("Manage opt-in OS service supervision");
|
|
2052
|
+
serviceCommand.action(() => {
|
|
2053
|
+
writeStdout(serviceCommand.helpInformation());
|
|
2054
|
+
});
|
|
2055
|
+
serviceCommand.command("enable").description("Enable startup after reboot and unexpected-exit restarts").option("--json", "emit machine-readable JSON").action(async () => {
|
|
2056
|
+
const result = await serviceEnable();
|
|
2057
|
+
print(result, () => formatServiceStatus(result.status));
|
|
2058
|
+
if (result.status.state === "action_required") requestedExitCode = 1;
|
|
2059
|
+
});
|
|
2060
|
+
serviceCommand.command("status").description("Show OS service supervision status").option("--json", "emit machine-readable JSON").action(async () => {
|
|
2061
|
+
const result = await serviceStatus();
|
|
2062
|
+
print(result, () => formatServiceStatus(result));
|
|
2063
|
+
if (![
|
|
2064
|
+
"disabled",
|
|
2065
|
+
"healthy",
|
|
2066
|
+
"stopped"
|
|
2067
|
+
].includes(result.state) || !result.supported) requestedExitCode = 1;
|
|
2068
|
+
});
|
|
2069
|
+
serviceCommand.command("disable").description("Stop and unregister OS service supervision").option("--json", "emit machine-readable JSON").action(async () => {
|
|
2070
|
+
const result = await serviceDisable();
|
|
2071
|
+
print(result, () => formatServiceStatus(result.status));
|
|
2072
|
+
if (result.administratorCommand || result.status.state !== "disabled") requestedExitCode = 1;
|
|
2073
|
+
});
|
|
2074
|
+
serviceCommand.command("run", { hidden: true }).action(async () => serviceRun());
|
|
2075
|
+
const serviceApplyCommand = serviceCommand.command("apply", { hidden: true }).requiredOption("--request <absolute-path>", "prepared request");
|
|
2076
|
+
serviceApplyCommand.action(async () => {
|
|
2077
|
+
const { request: requestPath } = serviceApplyCommand.opts();
|
|
2078
|
+
const result = await serviceApply(requestPath);
|
|
2079
|
+
print(result, () => `Applied Treeport service ${result.operation} request.`);
|
|
2080
|
+
});
|
|
2081
|
+
const remoteCommand = program.command("remote").description("Expose Treeport privately through Tailscale Serve");
|
|
2082
|
+
remoteCommand.action(() => {
|
|
2083
|
+
writeStdout(remoteCommand.helpInformation());
|
|
2084
|
+
});
|
|
2085
|
+
const remoteEnableCommand = remoteCommand.command("enable").description("Enable private HTTPS access through Tailscale").option("--port <port>", "Tailscale HTTPS port (default: 8733)").option("--json", "emit machine-readable JSON");
|
|
2086
|
+
remoteEnableCommand.action(async () => {
|
|
2087
|
+
const lifecycle = await resolveDaemonLifecycle();
|
|
2088
|
+
if (lifecycle === "external") throw new CliError("Cannot run `treeport remote enable` because the daemon lifecycle is externally managed. Configure remote access through the process that started Treeport instead.", 5, "DAEMON_LIFECYCLE_EXTERNAL");
|
|
2089
|
+
const options = remoteEnableCommand.opts();
|
|
2090
|
+
const port = options.port === void 0 ? void 0 : Number(options.port);
|
|
2091
|
+
if (port !== void 0 && (!Number.isInteger(port) || port < 1 || port > 65535)) throw new CliError("--port must be an integer between 1 and 65535", 2);
|
|
2092
|
+
const serviceDaemon = lifecycle === "service" ? await ensureServiceDaemon() : void 0;
|
|
2093
|
+
const result = await enableTailscaleRemote({
|
|
2094
|
+
...port === void 0 ? {} : { port },
|
|
2095
|
+
...serviceDaemon === void 0 ? {} : { daemon: serviceDaemon }
|
|
2096
|
+
});
|
|
2097
|
+
print(result, () => `Treeport remote access is ${result.alreadyEnabled ? "already enabled" : "enabled"}\n${result.url}\nTailscale authenticates each remote user. Access is limited by your Tailscale policy.`);
|
|
2098
|
+
});
|
|
2099
|
+
remoteCommand.command("status").description("Show Tailscale remote access status").option("--json", "emit machine-readable JSON").action(async () => {
|
|
2100
|
+
const result = await tailscaleRemoteStatus();
|
|
2101
|
+
print(result, () => {
|
|
2102
|
+
if (!result.configured) return "Treeport remote access is disabled";
|
|
2103
|
+
return result.active ? `Treeport remote access is enabled\n${result.url}` : `Treeport remote access is unavailable\nExpected: ${result.url}\nThe Tailscale Serve route no longer points to Treeport.`;
|
|
2104
|
+
});
|
|
2105
|
+
});
|
|
2106
|
+
remoteCommand.command("disable").description("Disable Treeport Tailscale remote access").option("--json", "emit machine-readable JSON").action(async () => {
|
|
2107
|
+
const result = await disableTailscaleRemote();
|
|
2108
|
+
print(result, () => {
|
|
2109
|
+
if (result.changedTailscale) return "Treeport remote access is disabled";
|
|
2110
|
+
return result.wasEnabled ? "Treeport remote access is disabled" : "Treeport remote access was already disabled; the current Tailscale route was left unchanged.";
|
|
2111
|
+
});
|
|
603
2112
|
});
|
|
604
2113
|
program.command("status").description("Show local daemon status").option("--json", "emit machine-readable JSON").action(async () => {
|
|
605
2114
|
const status = await daemonStatus();
|
|
2115
|
+
const supervision = await serviceInstalled() ? await serviceStatus() : null;
|
|
606
2116
|
const projectList = status.verified ? await projects() : [];
|
|
607
2117
|
const result = {
|
|
608
2118
|
...status,
|
|
2119
|
+
service: supervision,
|
|
609
2120
|
projects: projectList.length,
|
|
610
2121
|
worktrees: projectList.reduce((count, project) => count + project.worktrees.length, 0),
|
|
611
2122
|
terminals: projectList.reduce((count, project) => count + project.worktrees.reduce((worktreeCount, worktree) => worktreeCount + worktree.terminals.length, 0), 0)
|
|
612
2123
|
};
|
|
613
2124
|
print(result, () => {
|
|
614
|
-
if (!status.state) return "Treeport is
|
|
2125
|
+
if (!status.state) return supervision ? formatServiceStatus(supervision) : "Treeport is stopped";
|
|
615
2126
|
if (!status.running || !status.verified) return `Treeport is unhealthy (PID ${status.state.pid})\nLogs: ${path.join(status.state.dataDir, "logs", "daemon.log")}`;
|
|
616
|
-
return `Treeport is
|
|
2127
|
+
return `Treeport is running\n${status.state.apiUrl}\nLifecycle: ${status.health?.daemonLifecycle}\nVersion: ${status.health?.version}\nPID: ${status.state.pid}\nProjects: ${result.projects}\nWorktrees: ${result.worktrees}\nTerminals: ${result.terminals}`;
|
|
617
2128
|
});
|
|
618
2129
|
});
|
|
619
2130
|
const logsCommand = program.command("logs").description("Show recent daemon logs").option("--lines <count>", "number of lines", "100");
|
|
620
2131
|
logsCommand.action(async () => {
|
|
621
2132
|
const lines = Number(logsCommand.opts().lines);
|
|
622
2133
|
if (!Number.isInteger(lines) || lines < 1 || lines > 1e4) throw new CliError("--lines must be an integer between 1 and 10000", 2);
|
|
623
|
-
|
|
2134
|
+
writeStdout(await serviceInstalled() ? await readServiceLogs(lines) : await readDaemonLogs(lines));
|
|
624
2135
|
});
|
|
625
2136
|
program.command("doctor").description("Diagnose local requirements and paths").option("--json", "emit machine-readable JSON").action(async () => {
|
|
626
|
-
const checks = await runDoctor();
|
|
2137
|
+
const checks = [...await runDoctor(), await serviceDoctorCheck()];
|
|
627
2138
|
print(checks, () => checks.map((check) => `${check.ok ? "ok" : "error"}\t${check.name}\t${check.detail}`).join("\n"));
|
|
628
|
-
if (checks.some((check) => !check.ok))
|
|
2139
|
+
if (checks.some((check) => !check.ok)) requestedExitCode = 1;
|
|
629
2140
|
});
|
|
630
2141
|
program.command("version").description("Show CLI and daemon versions").option("--json", "emit machine-readable JSON").action(async () => {
|
|
631
2142
|
const [cli, status] = await Promise.all([treeportVersion(), daemonStatus()]);
|
|
@@ -636,7 +2147,8 @@ async function main(args) {
|
|
|
636
2147
|
print(result, () => `CLI: ${result.cli}\nDaemon: ${result.daemon ?? "not running"}`);
|
|
637
2148
|
});
|
|
638
2149
|
program.command("skills").description("Print the Treeport usage guide for AI agents").action(async () => {
|
|
639
|
-
|
|
2150
|
+
const skill = await fs.readFile(await resolvePackagePath("skills", "treeport", "SKILL.md"), "utf8");
|
|
2151
|
+
writeStdout(await resolveDaemonLifecycle() === "external" ? skill.replace("\n# Treeport\n", "\n# Treeport\n\n> **Externally managed daemon lifecycle:** Do not run `treeport start`, `treeport stop`, or `treeport remote enable`. The process that started Treeport owns startup, shutdown, remote exposure, and logs. Other Treeport commands continue to use the configured daemon normally.\n") : skill);
|
|
640
2152
|
});
|
|
641
2153
|
program.command("context").description("Show the current Treeport-managed terminal context").option("--json", "emit machine-readable JSON").action(async () => {
|
|
642
2154
|
const projectId = contextProjectId;
|
|
@@ -674,6 +2186,7 @@ async function main(args) {
|
|
|
674
2186
|
const context = {
|
|
675
2187
|
managed: true,
|
|
676
2188
|
apiUrl,
|
|
2189
|
+
daemonLifecycle: await resolveDaemonLifecycle(),
|
|
677
2190
|
project: {
|
|
678
2191
|
id: project.id,
|
|
679
2192
|
name: project.name,
|
|
@@ -690,8 +2203,7 @@ async function main(args) {
|
|
|
690
2203
|
head: worktree.head,
|
|
691
2204
|
branch: worktree.branch,
|
|
692
2205
|
detached: worktree.detached,
|
|
693
|
-
kind: worktree.kind
|
|
694
|
-
status: worktree.status
|
|
2206
|
+
kind: worktree.kind
|
|
695
2207
|
},
|
|
696
2208
|
terminal: {
|
|
697
2209
|
id: terminal.id,
|
|
@@ -701,7 +2213,62 @@ async function main(args) {
|
|
|
701
2213
|
exitCode: terminal.exitCode
|
|
702
2214
|
}
|
|
703
2215
|
};
|
|
704
|
-
print(context, () => `Treeport context\n\nProject: ${context.project.name} (${context.project.id})\nWorktree: ${context.worktree.name} (${context.worktree.id})\nPath: ${context.worktree.path}\nTerminal: ${context.terminal.name} (${context.terminal.id}) — ${context.terminal.status}\nAPI: ${context.apiUrl}`);
|
|
2216
|
+
print(context, () => `Treeport context\n\nProject: ${context.project.name} (${context.project.id})\nWorktree: ${context.worktree.name} (${context.worktree.id})\nPath: ${context.worktree.path}\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"}`);
|
|
2217
|
+
});
|
|
2218
|
+
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");
|
|
2219
|
+
installCommand.action(async (source) => {
|
|
2220
|
+
const options = installCommand.opts();
|
|
2221
|
+
const result = (await request("/api/packages/install", {
|
|
2222
|
+
method: "POST",
|
|
2223
|
+
body: JSON.stringify({
|
|
2224
|
+
source: await packageSource(source),
|
|
2225
|
+
...options.local ? { projectId: await localPackageProjectId() } : {}
|
|
2226
|
+
})
|
|
2227
|
+
})).result;
|
|
2228
|
+
print(result, () => `Installed ${result.source}${result.scope === "project" ? ` for project ${result.projectId}` : " globally"}`);
|
|
2229
|
+
});
|
|
2230
|
+
const removePackageCommand = program.command("remove").alias("uninstall").description("Remove a configured Treeport package").argument("<source>", "npm: source or local directory").option("-l, --local", "remove from the registered project containing the current directory").option("--json", "emit machine-readable JSON");
|
|
2231
|
+
removePackageCommand.action(async (source) => {
|
|
2232
|
+
const options = removePackageCommand.opts();
|
|
2233
|
+
const result = (await request("/api/packages/remove", {
|
|
2234
|
+
method: "POST",
|
|
2235
|
+
body: JSON.stringify({
|
|
2236
|
+
source: await packageSource(source),
|
|
2237
|
+
...options.local ? { projectId: await localPackageProjectId() } : {}
|
|
2238
|
+
})
|
|
2239
|
+
})).result;
|
|
2240
|
+
print(result, () => `Removed ${result.source}`);
|
|
2241
|
+
});
|
|
2242
|
+
program.command("list").description("List configured Treeport packages").option("--json", "emit machine-readable JSON").action(async () => {
|
|
2243
|
+
const result = await request("/api/packages");
|
|
2244
|
+
print(result, () => {
|
|
2245
|
+
const lines = result.packages.map((pkg) => {
|
|
2246
|
+
return `${pkg.scope === "global" ? "global" : `project:${pkg.projectName ?? pkg.projectId}`}\t${pkg.source}\t${pkg.resources.webPanels} web panels, ${pkg.resources.terminalPresets} terminal presets`;
|
|
2247
|
+
});
|
|
2248
|
+
lines.push(...result.diagnostics.map((item) => `error\t${item.scope}\t${item.source ?? item.path ?? "settings"}\t${item.message}`));
|
|
2249
|
+
return lines.join("\n");
|
|
2250
|
+
});
|
|
2251
|
+
});
|
|
2252
|
+
const updatePackagesCommand = program.command("update").description("Explicitly update configured Treeport packages").argument("[source]", "one configured npm: source").option("--packages", "update every eligible configured package").option("--json", "emit machine-readable JSON");
|
|
2253
|
+
updatePackagesCommand.action(async (source) => {
|
|
2254
|
+
const options = updatePackagesCommand.opts();
|
|
2255
|
+
if (!source && !options.packages || source && options.packages) throw new CliError("Specify a package source or --packages. Bare `treeport update` is reserved for a future Treeport self-update.", 2);
|
|
2256
|
+
const results = (await request("/api/packages/update", {
|
|
2257
|
+
method: "POST",
|
|
2258
|
+
body: JSON.stringify(source ? { source: await packageSource(source) } : {})
|
|
2259
|
+
})).results;
|
|
2260
|
+
print(results, () => results.map((result) => `${result.status}\t${result.scope}\t${result.source ?? "packages"}${result.reason ? `\t${result.reason}` : ""}`).join("\n"));
|
|
2261
|
+
});
|
|
2262
|
+
const reloadCommand = program.command("reload").description("Reload package settings and resources without restarting").option("-l, --local", "reload only the registered project containing the current directory").option("--json", "emit machine-readable JSON");
|
|
2263
|
+
reloadCommand.action(async () => {
|
|
2264
|
+
const options = reloadCommand.opts();
|
|
2265
|
+
const result = await request("/api/packages/reload", {
|
|
2266
|
+
method: "POST",
|
|
2267
|
+
body: JSON.stringify(options.local ? { projectId: await localPackageProjectId() } : {})
|
|
2268
|
+
});
|
|
2269
|
+
print(result, () => {
|
|
2270
|
+
return [...result.results.map((item) => `Reloaded ${item.scope === "global" ? "global packages" : `project ${item.projectId}`}`), ...result.diagnostics.map((item) => `Error: ${item.source ?? item.path ?? item.scope}: ${item.message}`)].join("\n");
|
|
2271
|
+
});
|
|
705
2272
|
});
|
|
706
2273
|
const projectCommand = program.command("project").description("Register and list projects");
|
|
707
2274
|
projectCommand.action(() => {
|
|
@@ -726,20 +2293,17 @@ async function main(args) {
|
|
|
726
2293
|
worktreeListCommand.action(async () => {
|
|
727
2294
|
const { project: projectIdentifier } = worktreeListCommand.opts();
|
|
728
2295
|
const list = projectIdentifier ? (await resolveProject(projectIdentifier)).worktrees : (await projects()).flatMap((project) => project.worktrees);
|
|
729
|
-
print(list, () => list.map((worktree) => `${worktree.id}\t${worktree.name}\t${worktree.branch ?? `detached@${worktree.head.slice(0, 8)}`}\t${worktree.
|
|
2296
|
+
print(list, () => list.map((worktree) => `${worktree.id}\t${worktree.name}\t${worktree.branch ?? `detached@${worktree.head.slice(0, 8)}`}\t${worktree.path}`).join("\n"));
|
|
730
2297
|
});
|
|
731
2298
|
const worktreeCreateCommand = worktreeCommand.command("create").description("Create a linked worktree").requiredOption("--project <id-or-path>", "project to create from").requiredOption("--name <name>", "worktree name").option("--from-current", "base the worktree on the current worktree").option("--json", "emit machine-readable JSON");
|
|
732
2299
|
worktreeCreateCommand.action(async () => {
|
|
733
2300
|
const options = worktreeCreateCommand.opts();
|
|
734
2301
|
const project = await resolveProject(options.project);
|
|
735
2302
|
const sourceWorktreeId = options.fromCurrent ? (await resolveWorktree(".")).id : void 0;
|
|
736
|
-
const result = await
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
base: options.fromCurrent ? "current" : "default",
|
|
741
|
-
...sourceWorktreeId ? { sourceWorktreeId } : {}
|
|
742
|
-
})
|
|
2303
|
+
const result = await createWorktree(project.id, {
|
|
2304
|
+
name: options.name,
|
|
2305
|
+
base: options.fromCurrent ? "current" : "default",
|
|
2306
|
+
...sourceWorktreeId ? { sourceWorktreeId } : {}
|
|
743
2307
|
});
|
|
744
2308
|
print(result, () => `Created ${result.worktree.name} (${result.worktree.id})\n${result.worktree.path}${result.setupError ? `\nSetup error: ${result.setupError}` : ""}`);
|
|
745
2309
|
});
|
|
@@ -750,14 +2314,48 @@ async function main(args) {
|
|
|
750
2314
|
const preview = (await request(`/api/worktrees/${worktree.id}/remove-preview`)).preview;
|
|
751
2315
|
if (!preview.eligible) throw new CliError(preview.reasons.join("\n"), 5);
|
|
752
2316
|
if (preview.warnings.length && !confirmed) throw new CliError(`${preview.warnings.join("\n")}\nRe-run with --force to confirm removal.`, 5);
|
|
753
|
-
|
|
2317
|
+
let operation = (await request(`/api/worktrees/${worktree.id}/remove`, {
|
|
754
2318
|
method: "POST",
|
|
755
2319
|
body: JSON.stringify({
|
|
756
2320
|
confirmationToken: preview.confirmationToken,
|
|
757
2321
|
confirmDestructive: preview.warnings.length > 0
|
|
758
2322
|
})
|
|
2323
|
+
})).operation;
|
|
2324
|
+
while (operation.status === "pending" || operation.status === "running") {
|
|
2325
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2326
|
+
operation = (await request(`/api/operations/${encodeURIComponent(operation.id)}`)).operation;
|
|
2327
|
+
}
|
|
2328
|
+
if (operation.status === "failed") throw new CliError(operation.error ?? "Worktree removal failed", 5, "WORKTREE_REMOVAL_FAILED");
|
|
2329
|
+
if (operation.kind !== "remove" || !operation.result) throw new CliError("Worktree removal returned an unexpected operation result", 5, "INVALID_OPERATION_RESULT");
|
|
2330
|
+
print(operation.result, () => {
|
|
2331
|
+
const warning = operation.result?.cleanup.warning;
|
|
2332
|
+
return `Removed ${worktree.name} (${worktree.id})${warning ? `\nWarning: ${warning}` : ""}`;
|
|
759
2333
|
});
|
|
760
|
-
|
|
2334
|
+
});
|
|
2335
|
+
const webPanelCommand = program.command("web-panel").description("Open persistent web panels");
|
|
2336
|
+
webPanelCommand.action(() => {
|
|
2337
|
+
throw new CliError(webPanelCommand.helpInformation(), 2);
|
|
2338
|
+
});
|
|
2339
|
+
const webPanelOpenCommand = webPanelCommand.command("open").description("Create or reuse a web panel and request client navigation").argument("<definition>", "definition ID or unique short name").requiredOption("--worktree <id-or-path-or-dot>", "owning worktree").option("--input <json>", "structured panel input as a JSON object").option("--new", "create a separate panel instance").option("--json", "emit machine-readable JSON");
|
|
2340
|
+
webPanelOpenCommand.action(async (identifier) => {
|
|
2341
|
+
const options = webPanelOpenCommand.opts();
|
|
2342
|
+
const worktree = await resolveWorktree(options.worktree);
|
|
2343
|
+
const definition = await webPanelDefinition(worktree.id, identifier);
|
|
2344
|
+
const result = await request(`/api/worktrees/${encodeURIComponent(worktree.id)}/panels/open`, {
|
|
2345
|
+
method: "POST",
|
|
2346
|
+
body: JSON.stringify({
|
|
2347
|
+
definitionId: definition.id,
|
|
2348
|
+
input: parseWebPanelInput(options.input),
|
|
2349
|
+
launchCwd: await webPanelLaunchCwd(worktree),
|
|
2350
|
+
newInstance: options.new ?? false,
|
|
2351
|
+
sourceTerminalId: contextTerminalId ?? null
|
|
2352
|
+
})
|
|
2353
|
+
});
|
|
2354
|
+
const output = {
|
|
2355
|
+
...result,
|
|
2356
|
+
url: webPanelUrl(worktree, result.panel.id)
|
|
2357
|
+
};
|
|
2358
|
+
print(output, () => `${result.reused ? "Reused" : "Opened"} ${result.panel.title} (${result.panel.id})\n${output.url}`);
|
|
761
2359
|
});
|
|
762
2360
|
const terminalCommand = program.command("terminal").description("Manage persistent worktree terminals");
|
|
763
2361
|
terminalCommand.action(() => {
|
|
@@ -798,8 +2396,8 @@ async function main(args) {
|
|
|
798
2396
|
const capture = await request(`/api/terminals/${encodeURIComponent(terminalId)}/capture?lines=${lines}`);
|
|
799
2397
|
if (jsonOutput) print(capture);
|
|
800
2398
|
else {
|
|
801
|
-
|
|
802
|
-
if (capture.content && !capture.content.endsWith("\n"))
|
|
2399
|
+
writeStdout(capture.content);
|
|
2400
|
+
if (capture.content && !capture.content.endsWith("\n")) writeStdout("\n");
|
|
803
2401
|
}
|
|
804
2402
|
});
|
|
805
2403
|
const terminalWaitCommand = terminalCommand.command("wait").description("Wait for a terminal runtime condition").argument("<terminal-id-or-dot>", "terminal to observe").requiredOption("--until <idle|working|bell|exit>", "condition to wait for").option("--timeout <duration>", "maximum wait, such as 30s or 5m").option("--json", "emit machine-readable JSON");
|
|
@@ -826,16 +2424,14 @@ async function main(args) {
|
|
|
826
2424
|
const options = spawnCommand.opts();
|
|
827
2425
|
const project = await resolveProject(options.project);
|
|
828
2426
|
const sourceWorktreeId = options.fromCurrent ? (await resolveWorktree(".")).id : void 0;
|
|
829
|
-
const result = await
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
worktreeName: options.worktreeName,
|
|
2427
|
+
const result = await createWorktree(project.id, {
|
|
2428
|
+
name: options.worktreeName,
|
|
2429
|
+
base: options.fromCurrent ? "current" : "default",
|
|
2430
|
+
initialTerminal: {
|
|
834
2431
|
name: options.name,
|
|
835
|
-
base: options.fromCurrent ? "current" : "default",
|
|
836
|
-
...sourceWorktreeId ? { sourceWorktreeId } : {},
|
|
837
2432
|
...argv ? { argv } : {}
|
|
838
|
-
}
|
|
2433
|
+
},
|
|
2434
|
+
...sourceWorktreeId ? { sourceWorktreeId } : {}
|
|
839
2435
|
});
|
|
840
2436
|
print(result, () => `Created worktree ${result.worktree.name} (${result.worktree.id})\nPath: ${result.worktree.path}\n${result.terminal ? `Terminal: ${result.terminal.name} (${result.terminal.id}) — ${result.terminal.status}` : "Terminal: not created"}${result.setupError ? `\nSetup error: ${result.setupError}` : ""}${result.terminalError ? `\nTerminal error: ${result.terminalError}` : ""}`);
|
|
841
2437
|
});
|
|
@@ -849,17 +2445,37 @@ async function main(args) {
|
|
|
849
2445
|
throw error;
|
|
850
2446
|
}
|
|
851
2447
|
}
|
|
852
|
-
|
|
853
|
-
const
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
2448
|
+
async function runCliApplication(options) {
|
|
2449
|
+
const environment = options.environment ?? process.env;
|
|
2450
|
+
configuredApiUrl = environment.TREEPORT_API_URL?.trim();
|
|
2451
|
+
apiUrl = (await resolveLocalApiUrl(environment)).replace(/\/$/, "");
|
|
2452
|
+
contextProjectId = environment.TREEPORT_PROJECT_ID?.trim() || void 0;
|
|
2453
|
+
contextWorktreeId = environment.TREEPORT_WORKTREE_ID?.trim() || void 0;
|
|
2454
|
+
contextTerminalId = environment.TREEPORT_TERMINAL_ID?.trim() || void 0;
|
|
2455
|
+
configuredDaemonLifecycle = environment.TREEPORT_DAEMON_LIFECYCLE?.trim();
|
|
2456
|
+
jsonOutput = extractJsonOutput(options.args);
|
|
2457
|
+
workingDirectory = options.cwd ?? process.cwd();
|
|
2458
|
+
writeStdout = options.stdout ?? ((value) => process.stdout.write(value));
|
|
2459
|
+
writeStderr = options.stderr ?? ((value) => process.stderr.write(value));
|
|
2460
|
+
requestedExitCode = 0;
|
|
2461
|
+
try {
|
|
2462
|
+
await main([...options.args]);
|
|
2463
|
+
} catch (error) {
|
|
2464
|
+
const cliError = error instanceof CliError ? error : new CliError(error instanceof Error ? error.message : String(error), 1);
|
|
2465
|
+
if (jsonOutput) {
|
|
2466
|
+
const body = { error: {
|
|
2467
|
+
code: cliError.code,
|
|
2468
|
+
message: cliError.message,
|
|
2469
|
+
...cliError.details === void 0 ? {} : { details: cliError.details }
|
|
2470
|
+
} };
|
|
2471
|
+
writeStderr(`${JSON.stringify(body)}\n`);
|
|
2472
|
+
} else writeStderr(`${cliError.message}\n`);
|
|
2473
|
+
requestedExitCode = cliError.exitCode;
|
|
2474
|
+
}
|
|
2475
|
+
return requestedExitCode;
|
|
2476
|
+
}
|
|
2477
|
+
//#endregion
|
|
2478
|
+
//#region src/cli/index.ts
|
|
2479
|
+
process.exitCode = await runCliApplication({ args: process.argv.slice(2) });
|
|
864
2480
|
//#endregion
|
|
865
2481
|
export {};
|