@treeport/treeport 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/node/cli/index.js +118 -1612
- package/dist/node/server/core/launcher.js +35 -2
- package/dist/node/server/index.js +1193 -572
- package/dist/{shell-integration-7aBNr-p0.js → shell-integration-Be_c91lw.js} +17 -15
- package/dist/update-BW-a6Bd-.js +3107 -0
- package/dist/web/assets/index-Cr4UkmRD.js +146 -0
- package/dist/web/assets/index-he-SubzL.css +2 -0
- package/dist/web/index.html +2 -2
- package/dist/web/manifest.webmanifest +2 -2
- package/drizzle/0008_recent_project_visibility.sql +4 -0
- package/drizzle/0009_open_folders.sql +148 -0
- package/drizzle/meta/0008_snapshot.json +792 -0
- package/drizzle/meta/0009_snapshot.json +804 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +4 -4
- package/skills/treeport/SKILL.md +31 -22
- package/dist/loopback-Dyv_owrb.js +0 -407
- package/dist/web/assets/index-0q5frbNy.css +0 -2
- package/dist/web/assets/index-CUy0IkGL.js +0 -146
package/dist/node/cli/index.js
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { A as
|
|
2
|
+
import { A as treeportVersion, C as disableTailscaleRemote, D as resolvePackagePath, E as resolveLocalApiUrl, M as parseDurationMs, N as TERMINAL_CAPTURE_MAX_LINES, O as runDoctor, S as daemonUp, T as readDaemonLogs, _ as serviceStatus, b as daemonHealth, ct as parseEventsSnapshot, d as serviceDisable, f as serviceDoctorCheck, g as serviceStart, h as serviceRun, k as tailscaleRemoteStatus, l as readServiceLogs, lt as parseProductEvent, m as serviceInstalled, p as serviceEnable, s as runLocalUpdate, st as webPanelInputSchema, t as LocalUpdateError, u as serviceApply, ut as SOCKET_IO_PATH, v as serviceStop, w as enableTailscaleRemote, x as daemonStatus, y as daemonDown } from "../../update-BW-a6Bd-.js";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { Command, CommanderError } from "commander";
|
|
6
6
|
import { io } from "socket.io-client";
|
|
7
|
-
import { z } from "zod";
|
|
8
7
|
import { spawn } from "node:child_process";
|
|
9
|
-
import crypto from "node:crypto";
|
|
10
|
-
import fsSync, { constants } from "node:fs";
|
|
11
|
-
import os from "node:os";
|
|
12
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
8
|
//#region src/cli/args.ts
|
|
14
9
|
function extractJsonOutput(args) {
|
|
15
10
|
const separator = args.indexOf("--");
|
|
@@ -82,1519 +77,6 @@ async function openWorkspace(workspaceUrl, options = {}) {
|
|
|
82
77
|
throw new OpenWorkspaceError(`Treeport registered the folder, but could not open it automatically.${browser.stderr ? ` ${browser.stderr}` : ""}\nOpen this URL manually: ${workspaceUrl}`);
|
|
83
78
|
}
|
|
84
79
|
//#endregion
|
|
85
|
-
//#region src/cli/lifecycle.ts
|
|
86
|
-
const DEFAULT_HOST = "127.0.0.1";
|
|
87
|
-
const DEFAULT_PORT = 8733;
|
|
88
|
-
function listenerUrl(host, port) {
|
|
89
|
-
return `http://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`;
|
|
90
|
-
}
|
|
91
|
-
function expandHome(value) {
|
|
92
|
-
return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
|
|
93
|
-
}
|
|
94
|
-
function localPaths(env = process.env) {
|
|
95
|
-
const defaultDataDir = env.XDG_DATA_HOME ? path.join(expandHome(env.XDG_DATA_HOME), "treeport") : process.platform === "darwin" ? path.join(os.homedir(), "Library", "Application Support", "treeport") : path.join(os.homedir(), ".local", "share", "treeport");
|
|
96
|
-
const dataDir = path.resolve(expandHome(env.TREEPORT_DATA_DIR?.trim() || defaultDataDir));
|
|
97
|
-
const runtimeDir = path.resolve(expandHome(env.TREEPORT_RUNTIME_DIR?.trim() || (env.XDG_RUNTIME_DIR ? path.join(env.XDG_RUNTIME_DIR, "treeport") : path.join(os.tmpdir(), `treeport-${process.getuid?.() ?? "user"}`))));
|
|
98
|
-
return {
|
|
99
|
-
dataDir,
|
|
100
|
-
runtimeDir,
|
|
101
|
-
preferencesPath: path.join(dataDir, "config.json"),
|
|
102
|
-
statePath: path.join(runtimeDir, "daemon.json"),
|
|
103
|
-
lockPath: path.join(dataDir, "daemon.lock"),
|
|
104
|
-
logPath: path.join(dataDir, "logs", "daemon.log")
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
async function readJson$1(filePath, schema) {
|
|
108
|
-
return fs.readFile(filePath, "utf8").then((value) => schema.parse(JSON.parse(value))).catch(() => null);
|
|
109
|
-
}
|
|
110
|
-
const preferencesSchema = z.looseObject({
|
|
111
|
-
host: z.string().optional(),
|
|
112
|
-
port: z.number().optional(),
|
|
113
|
-
remote: z.strictObject({
|
|
114
|
-
port: z.number(),
|
|
115
|
-
target: z.string()
|
|
116
|
-
}).optional()
|
|
117
|
-
});
|
|
118
|
-
const daemonRecordSchema = z.strictObject({
|
|
119
|
-
pid: z.number(),
|
|
120
|
-
instanceId: z.string(),
|
|
121
|
-
version: z.string(),
|
|
122
|
-
apiUrl: z.string(),
|
|
123
|
-
dataDir: z.string(),
|
|
124
|
-
startedAt: z.string(),
|
|
125
|
-
installationMethod: z.string(),
|
|
126
|
-
daemonLifecycle: z.enum([
|
|
127
|
-
"treeport",
|
|
128
|
-
"service",
|
|
129
|
-
"external"
|
|
130
|
-
])
|
|
131
|
-
});
|
|
132
|
-
const healthRecordSchema = z.strictObject({
|
|
133
|
-
ok: z.literal(true),
|
|
134
|
-
version: z.string(),
|
|
135
|
-
protocolVersion: z.number(),
|
|
136
|
-
hostname: z.string().optional(),
|
|
137
|
-
pid: z.number(),
|
|
138
|
-
instanceId: z.string().nullable(),
|
|
139
|
-
installationMethod: z.string(),
|
|
140
|
-
daemonLifecycle: z.enum([
|
|
141
|
-
"treeport",
|
|
142
|
-
"service",
|
|
143
|
-
"external"
|
|
144
|
-
]),
|
|
145
|
-
url: z.string()
|
|
146
|
-
});
|
|
147
|
-
async function preferences(env = process.env) {
|
|
148
|
-
return await readJson$1(localPaths(env).preferencesPath, preferencesSchema) ?? {};
|
|
149
|
-
}
|
|
150
|
-
async function savePreferences(value) {
|
|
151
|
-
const paths = localPaths();
|
|
152
|
-
await fs.mkdir(paths.dataDir, {
|
|
153
|
-
recursive: true,
|
|
154
|
-
mode: 448
|
|
155
|
-
});
|
|
156
|
-
const temporaryPath = `${paths.preferencesPath}.${process.pid}.tmp`;
|
|
157
|
-
await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
|
|
158
|
-
await fs.rename(temporaryPath, paths.preferencesPath);
|
|
159
|
-
}
|
|
160
|
-
async function resolveLocalApiUrl(env = process.env) {
|
|
161
|
-
const explicit = env.TREEPORT_API_URL?.trim();
|
|
162
|
-
const managedApiUrl = env.TREEPORT_MANAGED_API_URL?.trim();
|
|
163
|
-
const daemonRecordPath = env.TREEPORT_DAEMON_RECORD?.trim();
|
|
164
|
-
if (explicit && explicit !== managedApiUrl) return explicit.replace(/\/$/, "");
|
|
165
|
-
if (managedApiUrl && daemonRecordPath) {
|
|
166
|
-
const record = await readJson$1(path.resolve(expandHome(daemonRecordPath)), daemonRecordSchema);
|
|
167
|
-
if (record) return record.apiUrl.replace(/\/$/, "");
|
|
168
|
-
}
|
|
169
|
-
if (explicit) return explicit.replace(/\/$/, "");
|
|
170
|
-
const saved = await preferences(env);
|
|
171
|
-
return listenerUrl(env.TREEPORT_HOST?.trim() || env.HOST?.trim() || saved.host || DEFAULT_HOST, Number.parseInt(env.TREEPORT_PORT?.trim() || env.PORT?.trim() || String(saved.port ?? DEFAULT_PORT), 10));
|
|
172
|
-
}
|
|
173
|
-
async function resolvePackagePath(...segments) {
|
|
174
|
-
const candidates = [fileURLToPath(new URL("../../../", import.meta.url)), fileURLToPath(new URL("../../", import.meta.url))];
|
|
175
|
-
for (const candidate of candidates) if (await fs.access(path.join(candidate, "package.json")).then(() => true).catch(() => false)) return path.join(candidate, ...segments);
|
|
176
|
-
throw new Error("Could not locate the Treeport package directory");
|
|
177
|
-
}
|
|
178
|
-
async function treeportVersion() {
|
|
179
|
-
return (await readJson$1(await resolvePackagePath("package.json"), z.looseObject({ version: z.string().optional() })))?.version ?? "development";
|
|
180
|
-
}
|
|
181
|
-
function processExists(pid) {
|
|
182
|
-
try {
|
|
183
|
-
process.kill(pid, 0);
|
|
184
|
-
return true;
|
|
185
|
-
} catch (error) {
|
|
186
|
-
return error.code === "EPERM";
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
async function daemonHealth(apiUrl, timeoutMs = 1500) {
|
|
190
|
-
const signal = AbortSignal.timeout(timeoutMs);
|
|
191
|
-
return fetch(`${apiUrl}/api/health`, { signal }).then(async (response) => {
|
|
192
|
-
if (!response.ok) return null;
|
|
193
|
-
const result = healthRecordSchema.safeParse(await response.json());
|
|
194
|
-
return result.success ? result.data : null;
|
|
195
|
-
}).catch(() => null);
|
|
196
|
-
}
|
|
197
|
-
function matchesOwnership(state, observed) {
|
|
198
|
-
return observed.pid === state.pid && observed.instanceId === state.instanceId && path.resolve(state.dataDir) === localPaths().dataDir;
|
|
199
|
-
}
|
|
200
|
-
async function readState() {
|
|
201
|
-
return readJson$1(localPaths().statePath, daemonRecordSchema);
|
|
202
|
-
}
|
|
203
|
-
async function removeStaleState(state) {
|
|
204
|
-
const paths = localPaths();
|
|
205
|
-
for (const filePath of [paths.statePath, paths.lockPath]) if ((await readJson$1(filePath, z.looseObject({ instanceId: z.string() })))?.instanceId === state.instanceId) await fs.rm(filePath, { force: true });
|
|
206
|
-
}
|
|
207
|
-
async function stopOwned(state) {
|
|
208
|
-
if (!processExists(state.pid)) {
|
|
209
|
-
await removeStaleState(state);
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
const observed = await daemonHealth(state.apiUrl);
|
|
213
|
-
if (!observed || !matchesOwnership(state, observed)) throw new Error(`Refusing to stop PID ${state.pid}: Treeport could not verify ownership. Check ${localPaths().statePath}.`);
|
|
214
|
-
process.kill(state.pid, "SIGTERM");
|
|
215
|
-
const deadline = Date.now() + 7e3;
|
|
216
|
-
while (Date.now() < deadline) {
|
|
217
|
-
if (!processExists(state.pid)) {
|
|
218
|
-
await removeStaleState(state);
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
222
|
-
}
|
|
223
|
-
throw new Error(`Treeport did not stop within 7 seconds. See ${localPaths().logPath}.`);
|
|
224
|
-
}
|
|
225
|
-
async function executableCheck(executable, args) {
|
|
226
|
-
return new Promise((resolve) => {
|
|
227
|
-
const child = spawn(executable, args, { stdio: [
|
|
228
|
-
"ignore",
|
|
229
|
-
"pipe",
|
|
230
|
-
"pipe"
|
|
231
|
-
] });
|
|
232
|
-
let output = "";
|
|
233
|
-
child.stdout.setEncoding("utf8");
|
|
234
|
-
child.stderr.setEncoding("utf8");
|
|
235
|
-
child.stdout.on("data", (chunk) => {
|
|
236
|
-
output += chunk;
|
|
237
|
-
});
|
|
238
|
-
child.stderr.on("data", (chunk) => {
|
|
239
|
-
output += chunk;
|
|
240
|
-
});
|
|
241
|
-
child.once("error", (error) => resolve({
|
|
242
|
-
ok: false,
|
|
243
|
-
detail: error.message
|
|
244
|
-
}));
|
|
245
|
-
child.once("close", (code) => resolve({
|
|
246
|
-
ok: code === 0,
|
|
247
|
-
detail: output.trim() || `exited with status ${code ?? 1}`
|
|
248
|
-
}));
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
const tailscaleStatusResponseSchema = z.looseObject({
|
|
252
|
-
BackendState: z.string().optional(),
|
|
253
|
-
Self: z.looseObject({ DNSName: z.string().optional() }).optional()
|
|
254
|
-
});
|
|
255
|
-
const tailscaleServeConfigurationSchema = z.lazy(() => z.looseObject({
|
|
256
|
-
TCP: z.record(z.string(), z.looseObject({})).optional(),
|
|
257
|
-
Foreground: z.record(z.string(), tailscaleServeConfigurationSchema).optional(),
|
|
258
|
-
Web: z.record(z.string(), z.looseObject({ Handlers: z.record(z.string(), z.looseObject({ Proxy: z.string().optional() })).optional() })).optional()
|
|
259
|
-
}));
|
|
260
|
-
async function tailscale(args) {
|
|
261
|
-
return new Promise((resolve, reject) => {
|
|
262
|
-
const child = spawn("tailscale", args, { stdio: [
|
|
263
|
-
"ignore",
|
|
264
|
-
"pipe",
|
|
265
|
-
"pipe"
|
|
266
|
-
] });
|
|
267
|
-
let stdout = "";
|
|
268
|
-
let stderr = "";
|
|
269
|
-
child.stdout.setEncoding("utf8");
|
|
270
|
-
child.stderr.setEncoding("utf8");
|
|
271
|
-
child.stdout.on("data", (chunk) => {
|
|
272
|
-
stdout += chunk;
|
|
273
|
-
});
|
|
274
|
-
child.stderr.on("data", (chunk) => {
|
|
275
|
-
stderr += chunk;
|
|
276
|
-
});
|
|
277
|
-
child.once("error", (error) => reject(/* @__PURE__ */ new Error(error.code === "ENOENT" ? "Tailscale is required for remote access. Install it from https://tailscale.com/download, run `tailscale up`, then retry." : `Could not run Tailscale: ${error.message}`)));
|
|
278
|
-
child.once("close", (code) => {
|
|
279
|
-
if (code === 0) {
|
|
280
|
-
resolve(stdout);
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
|
284
|
-
reject(/* @__PURE__ */ new Error(`Tailscale ${args[0]} failed${detail ? `: ${detail}` : ` (status ${code ?? 1})`}`));
|
|
285
|
-
});
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
function tailscaleJson(value, command, schema) {
|
|
289
|
-
const result = schema.safeParse(JSON.parse(value));
|
|
290
|
-
if (!result.success) throw new Error(`Tailscale ${command} returned an invalid JSON response`);
|
|
291
|
-
return result.data;
|
|
292
|
-
}
|
|
293
|
-
function remotePreference(value) {
|
|
294
|
-
if (value.remote === void 0) return null;
|
|
295
|
-
if (!Number.isInteger(value.remote.port) || value.remote.port < 1 || value.remote.port > 65535 || !value.remote.target) throw new Error("Treeport remote access preferences are invalid");
|
|
296
|
-
return value.remote;
|
|
297
|
-
}
|
|
298
|
-
function localProxyTarget(apiUrl) {
|
|
299
|
-
if (!URL.canParse(apiUrl)) throw new Error("Treeport remote access requires a loopback daemon URL");
|
|
300
|
-
const url = new URL(apiUrl);
|
|
301
|
-
if (url.protocol !== "http:" || ![
|
|
302
|
-
"127.0.0.1",
|
|
303
|
-
"localhost",
|
|
304
|
-
"::1",
|
|
305
|
-
"[::1]"
|
|
306
|
-
].includes(url.hostname)) throw new Error("Treeport remote access requires a loopback daemon. Run `treeport start --host 127.0.0.1`, then try again.");
|
|
307
|
-
return `http://${url.host}`;
|
|
308
|
-
}
|
|
309
|
-
function portIsServed(config, port) {
|
|
310
|
-
const tcp = config.TCP;
|
|
311
|
-
if (tcp && Object.hasOwn(tcp, String(port))) return true;
|
|
312
|
-
return Object.values(config.Foreground ?? {}).some((value) => portIsServed(value, port));
|
|
313
|
-
}
|
|
314
|
-
function rootProxyForPort(config, port) {
|
|
315
|
-
for (const [hostPort, server] of Object.entries(config.Web ?? {})) {
|
|
316
|
-
if (!hostPort.endsWith(`:${port}`)) continue;
|
|
317
|
-
const proxy = server.Handlers?.["/"]?.Proxy;
|
|
318
|
-
if (proxy !== void 0) return proxy;
|
|
319
|
-
}
|
|
320
|
-
return null;
|
|
321
|
-
}
|
|
322
|
-
function proxyMatches(actual, expected) {
|
|
323
|
-
return actual !== null && expected !== void 0 && actual.replace(/\/$/, "") === expected.replace(/\/$/, "");
|
|
324
|
-
}
|
|
325
|
-
async function tailscaleServeConfig() {
|
|
326
|
-
return tailscaleJson(await tailscale([
|
|
327
|
-
"serve",
|
|
328
|
-
"status",
|
|
329
|
-
"--json"
|
|
330
|
-
]), "serve status", tailscaleServeConfigurationSchema);
|
|
331
|
-
}
|
|
332
|
-
async function tailscaleRemoteUrl(port) {
|
|
333
|
-
const status = tailscaleJson(await tailscale(["status", "--json"]), "status", tailscaleStatusResponseSchema);
|
|
334
|
-
if (status.BackendState !== "Running") throw new Error("Tailscale is not connected. Run `tailscale up` then try again.");
|
|
335
|
-
const dnsName = status.Self?.DNSName;
|
|
336
|
-
if (!dnsName?.trim()) throw new Error("Tailscale did not report a DNS name. Enable MagicDNS, then try again.");
|
|
337
|
-
return `https://${dnsName.trim().replace(/\.$/, "")}${port === 443 ? "" : `:${port}`}`;
|
|
338
|
-
}
|
|
339
|
-
async function enableTailscaleRemote(options) {
|
|
340
|
-
if (options.port !== void 0 && (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535)) throw new Error("--port must be an integer between 1 and 65535");
|
|
341
|
-
const saved = await preferences();
|
|
342
|
-
const remote = remotePreference(saved);
|
|
343
|
-
const port = options.port ?? remote?.port ?? DEFAULT_PORT;
|
|
344
|
-
if (remote && remote.port !== port) throw new Error(`Treeport remote access is already configured on port ${remote.port}. Run \`treeport remote disable\` before choosing another port.`);
|
|
345
|
-
const expectedTarget = localProxyTarget((await daemonStatus()).state?.apiUrl ?? await resolveLocalApiUrl());
|
|
346
|
-
const [url, config] = await Promise.all([tailscaleRemoteUrl(port), tailscaleServeConfig()]);
|
|
347
|
-
const existingTarget = rootProxyForPort(config, port);
|
|
348
|
-
if ((portIsServed(config, port) || existingTarget !== null) && !proxyMatches(existingTarget, expectedTarget) && !proxyMatches(existingTarget, remote?.target)) throw new Error(`Tailscale Serve already uses port ${port}. Choose another port with \`treeport remote enable --port <port>\`.`);
|
|
349
|
-
const target = localProxyTarget((options.daemon ?? await daemonUp({})).apiUrl);
|
|
350
|
-
const alreadyEnabled = proxyMatches(existingTarget, target);
|
|
351
|
-
if (!alreadyEnabled) await tailscale([
|
|
352
|
-
"serve",
|
|
353
|
-
"--bg",
|
|
354
|
-
`--https=${port}`,
|
|
355
|
-
target
|
|
356
|
-
]);
|
|
357
|
-
await savePreferences({
|
|
358
|
-
...saved,
|
|
359
|
-
remote: {
|
|
360
|
-
port,
|
|
361
|
-
target
|
|
362
|
-
}
|
|
363
|
-
});
|
|
364
|
-
return {
|
|
365
|
-
alreadyEnabled,
|
|
366
|
-
port,
|
|
367
|
-
url
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
async function tailscaleRemoteStatus() {
|
|
371
|
-
const remote = remotePreference(await preferences());
|
|
372
|
-
if (!remote) return {
|
|
373
|
-
configured: false,
|
|
374
|
-
active: false,
|
|
375
|
-
port: null,
|
|
376
|
-
url: null
|
|
377
|
-
};
|
|
378
|
-
const [url, config] = await Promise.all([tailscaleRemoteUrl(remote.port), tailscaleServeConfig()]);
|
|
379
|
-
return {
|
|
380
|
-
configured: true,
|
|
381
|
-
active: proxyMatches(rootProxyForPort(config, remote.port), remote.target),
|
|
382
|
-
port: remote.port,
|
|
383
|
-
url
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
async function disableTailscaleRemote() {
|
|
387
|
-
const saved = await preferences();
|
|
388
|
-
const remote = remotePreference(saved);
|
|
389
|
-
if (!remote) return {
|
|
390
|
-
wasEnabled: false,
|
|
391
|
-
changedTailscale: false
|
|
392
|
-
};
|
|
393
|
-
if (proxyMatches(rootProxyForPort(await tailscaleServeConfig(), remote.port), remote.target)) {
|
|
394
|
-
await tailscale([
|
|
395
|
-
"serve",
|
|
396
|
-
`--https=${remote.port}`,
|
|
397
|
-
"off"
|
|
398
|
-
]);
|
|
399
|
-
delete saved.remote;
|
|
400
|
-
await savePreferences(saved);
|
|
401
|
-
return {
|
|
402
|
-
wasEnabled: true,
|
|
403
|
-
changedTailscale: true
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
delete saved.remote;
|
|
407
|
-
await savePreferences(saved);
|
|
408
|
-
return {
|
|
409
|
-
wasEnabled: false,
|
|
410
|
-
changedTailscale: false
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
async function runDoctor() {
|
|
414
|
-
const paths = localPaths();
|
|
415
|
-
const gitPath = process.env.TREEPORT_GIT_PATH?.trim() || "git";
|
|
416
|
-
const tmuxPath = process.env.TREEPORT_TMUX_PATH?.trim() || "tmux";
|
|
417
|
-
const [git, tmux] = await Promise.all([executableCheck(gitPath, ["--version"]), executableCheck(tmuxPath, ["-V"])]);
|
|
418
|
-
const tmuxMatch = /tmux\s+(\d+)\.(\d+)/i.exec(tmux.detail);
|
|
419
|
-
const tmuxSupported = Boolean(tmux.ok && tmuxMatch && (Number(tmuxMatch[1]) > 3 || Number(tmuxMatch[1]) === 3 && Number(tmuxMatch[2]) >= 2));
|
|
420
|
-
const checkDirectory = (directoryPath) => fs.mkdir(directoryPath, {
|
|
421
|
-
recursive: true,
|
|
422
|
-
mode: 448
|
|
423
|
-
}).then(() => ({
|
|
424
|
-
ok: true,
|
|
425
|
-
detail: directoryPath
|
|
426
|
-
})).catch((error) => ({
|
|
427
|
-
ok: false,
|
|
428
|
-
detail: `${directoryPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
429
|
-
}));
|
|
430
|
-
const [dataDirectory, runtimeDirectory] = await Promise.all([checkDirectory(paths.dataDir), checkDirectory(paths.runtimeDir)]);
|
|
431
|
-
return [
|
|
432
|
-
{
|
|
433
|
-
name: "Node",
|
|
434
|
-
ok: true,
|
|
435
|
-
detail: process.version
|
|
436
|
-
},
|
|
437
|
-
{
|
|
438
|
-
name: "Git",
|
|
439
|
-
...git
|
|
440
|
-
},
|
|
441
|
-
{
|
|
442
|
-
name: "tmux",
|
|
443
|
-
ok: tmuxSupported,
|
|
444
|
-
detail: tmuxSupported ? tmux.detail : `${tmux.detail}. Treeport requires tmux 3.2 or newer.`
|
|
445
|
-
},
|
|
446
|
-
{
|
|
447
|
-
name: "Data directory",
|
|
448
|
-
...dataDirectory
|
|
449
|
-
},
|
|
450
|
-
{
|
|
451
|
-
name: "Runtime directory",
|
|
452
|
-
...runtimeDirectory
|
|
453
|
-
}
|
|
454
|
-
];
|
|
455
|
-
}
|
|
456
|
-
async function daemonStatus() {
|
|
457
|
-
const state = await readState();
|
|
458
|
-
if (!state) return {
|
|
459
|
-
running: false,
|
|
460
|
-
state: null,
|
|
461
|
-
health: null,
|
|
462
|
-
verified: false
|
|
463
|
-
};
|
|
464
|
-
if (!processExists(state.pid)) {
|
|
465
|
-
await removeStaleState(state);
|
|
466
|
-
return {
|
|
467
|
-
running: false,
|
|
468
|
-
state: null,
|
|
469
|
-
health: null,
|
|
470
|
-
verified: false
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
const observed = await daemonHealth(state.apiUrl);
|
|
474
|
-
return {
|
|
475
|
-
running: Boolean(observed),
|
|
476
|
-
state,
|
|
477
|
-
health: observed,
|
|
478
|
-
verified: Boolean(observed && matchesOwnership(state, observed))
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
async function daemonUp(options) {
|
|
482
|
-
if (options.port !== void 0 && (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535)) throw new Error("--port must be an integer between 1 and 65535");
|
|
483
|
-
const paths = localPaths();
|
|
484
|
-
const saved = await preferences();
|
|
485
|
-
const next = {
|
|
486
|
-
...saved,
|
|
487
|
-
host: options.host?.trim() || saved.host || DEFAULT_HOST,
|
|
488
|
-
port: options.port ?? saved.port ?? DEFAULT_PORT
|
|
489
|
-
};
|
|
490
|
-
const host = options.host?.trim() || process.env.TREEPORT_HOST?.trim() || process.env.HOST?.trim() || next.host;
|
|
491
|
-
assertLoopbackHost(host);
|
|
492
|
-
if (options.host !== void 0 || options.port !== void 0) await savePreferences(next);
|
|
493
|
-
const port = Number.parseInt(options.port === void 0 ? process.env.TREEPORT_PORT?.trim() || process.env.PORT?.trim() || String(next.port) : String(options.port), 10);
|
|
494
|
-
const apiUrl = options.host !== void 0 || options.port !== void 0 ? listenerUrl(host, port) : process.env.TREEPORT_API_URL?.trim() || listenerUrl(host, port);
|
|
495
|
-
const currentVersion = await treeportVersion();
|
|
496
|
-
const existing = await daemonStatus();
|
|
497
|
-
if (existing.state) {
|
|
498
|
-
if (!existing.running || !existing.verified) throw new Error(`Treeport PID ${existing.state.pid} is running but ownership or health could not be verified. See ${paths.logPath}.`);
|
|
499
|
-
if (existing.health?.version === currentVersion && existing.state.apiUrl === apiUrl) return {
|
|
500
|
-
alreadyRunning: true,
|
|
501
|
-
apiUrl: existing.state.apiUrl,
|
|
502
|
-
pid: existing.state.pid
|
|
503
|
-
};
|
|
504
|
-
await stopOwned(existing.state);
|
|
505
|
-
}
|
|
506
|
-
const failedChecks = (await runDoctor()).filter((check) => !check.ok);
|
|
507
|
-
if (failedChecks.length) throw new Error(failedChecks.map((check) => `${check.name}: ${check.detail}`).join("\n"));
|
|
508
|
-
const serverEntry = await resolvePackagePath("dist", "node", "server", "index.js");
|
|
509
|
-
const webDist = await resolvePackagePath("dist", "web");
|
|
510
|
-
await fs.access(serverEntry);
|
|
511
|
-
await fs.mkdir(path.dirname(paths.logPath), {
|
|
512
|
-
recursive: true,
|
|
513
|
-
mode: 448
|
|
514
|
-
});
|
|
515
|
-
if (await fs.stat(paths.logPath).then((value) => value.size).catch(() => 0) > 5 * 1024 * 1024) {
|
|
516
|
-
await fs.rm(`${paths.logPath}.1`, { force: true });
|
|
517
|
-
await fs.rename(paths.logPath, `${paths.logPath}.1`);
|
|
518
|
-
}
|
|
519
|
-
const instanceId = crypto.randomUUID();
|
|
520
|
-
const childEnvironment = {
|
|
521
|
-
...process.env,
|
|
522
|
-
TREEPORT_HOST: host,
|
|
523
|
-
TREEPORT_PORT: String(port),
|
|
524
|
-
TREEPORT_API_URL: apiUrl,
|
|
525
|
-
TREEPORT_DATA_DIR: paths.dataDir,
|
|
526
|
-
TREEPORT_RUNTIME_DIR: paths.runtimeDir,
|
|
527
|
-
TREEPORT_APP_VERSION: currentVersion,
|
|
528
|
-
TREEPORT_INSTANCE_ID: instanceId,
|
|
529
|
-
TREEPORT_INSTALLATION_METHOD: process.env.TREEPORT_INSTALLATION_METHOD?.trim() || "npm",
|
|
530
|
-
TREEPORT_DAEMON_LIFECYCLE: "treeport",
|
|
531
|
-
TREEPORT_WEB_DIST: webDist
|
|
532
|
-
};
|
|
533
|
-
if (options.foreground) {
|
|
534
|
-
console.log(`Treeport will listen on ${apiUrl}`);
|
|
535
|
-
const child = spawn(process.execPath, [serverEntry], {
|
|
536
|
-
env: childEnvironment,
|
|
537
|
-
stdio: "inherit"
|
|
538
|
-
});
|
|
539
|
-
const code = await new Promise((resolve, reject) => {
|
|
540
|
-
child.once("error", reject);
|
|
541
|
-
child.once("close", (value) => resolve(value ?? 1));
|
|
542
|
-
});
|
|
543
|
-
if (code !== 0) throw new Error(`Treeport exited with status ${code}`);
|
|
544
|
-
return {
|
|
545
|
-
alreadyRunning: false,
|
|
546
|
-
apiUrl,
|
|
547
|
-
pid: child.pid ?? 0
|
|
548
|
-
};
|
|
549
|
-
}
|
|
550
|
-
const log = fsSync.openSync(paths.logPath, "a", 384);
|
|
551
|
-
const child = spawn(process.execPath, [serverEntry], {
|
|
552
|
-
env: childEnvironment,
|
|
553
|
-
detached: true,
|
|
554
|
-
stdio: [
|
|
555
|
-
"ignore",
|
|
556
|
-
log,
|
|
557
|
-
log
|
|
558
|
-
]
|
|
559
|
-
});
|
|
560
|
-
child.unref();
|
|
561
|
-
fsSync.closeSync(log);
|
|
562
|
-
const deadline = Date.now() + 15e3;
|
|
563
|
-
while (Date.now() < deadline) {
|
|
564
|
-
const observed = await daemonHealth(apiUrl, 500);
|
|
565
|
-
if (observed && observed.pid === child.pid && observed.instanceId === instanceId && observed.version === currentVersion) return {
|
|
566
|
-
alreadyRunning: false,
|
|
567
|
-
apiUrl,
|
|
568
|
-
pid: child.pid ?? observed.pid
|
|
569
|
-
};
|
|
570
|
-
if (child.pid && !processExists(child.pid)) break;
|
|
571
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
572
|
-
}
|
|
573
|
-
const recentLog = await fs.readFile(paths.logPath, "utf8").then((value) => value.split("\n").slice(-20).join("\n").trim()).catch(() => "");
|
|
574
|
-
throw new Error(`Treeport did not become ready at ${apiUrl}. See ${paths.logPath}.${recentLog ? `\n\n${recentLog}` : ""}`);
|
|
575
|
-
}
|
|
576
|
-
async function daemonDown() {
|
|
577
|
-
const state = await readState();
|
|
578
|
-
if (!state) return { wasRunning: false };
|
|
579
|
-
await stopOwned(state);
|
|
580
|
-
return { wasRunning: true };
|
|
581
|
-
}
|
|
582
|
-
async function readDaemonLogs(lines = 100) {
|
|
583
|
-
return (await fs.readFile(localPaths().logPath, "utf8").catch((error) => {
|
|
584
|
-
if (error.code === "ENOENT") return "";
|
|
585
|
-
throw error;
|
|
586
|
-
})).split("\n").slice(-lines - 1).join("\n");
|
|
587
|
-
}
|
|
588
|
-
//#endregion
|
|
589
|
-
//#region src/cli/service.ts
|
|
590
|
-
const serviceRecordSchema = z.strictObject({
|
|
591
|
-
schemaVersion: z.literal(1),
|
|
592
|
-
manager: z.enum(["launchd", "systemd"]),
|
|
593
|
-
platform: z.string(),
|
|
594
|
-
uid: z.number().int().nonnegative(),
|
|
595
|
-
gid: z.number().int().nonnegative(),
|
|
596
|
-
username: z.string().min(1),
|
|
597
|
-
group: z.string().min(1),
|
|
598
|
-
home: z.string().min(1),
|
|
599
|
-
dataDir: z.string().min(1),
|
|
600
|
-
runtimeDir: z.string().min(1),
|
|
601
|
-
logPath: z.string().min(1),
|
|
602
|
-
apiUrl: z.string().min(1),
|
|
603
|
-
cliEntrypoint: z.string().min(1),
|
|
604
|
-
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
80
|
//#region src/cli/application.ts
|
|
1599
81
|
const contextPrefix = "TREEPORT";
|
|
1600
82
|
let configuredApiUrl;
|
|
@@ -1612,6 +94,7 @@ let writeStderr = (value) => {
|
|
|
1612
94
|
process.stderr.write(value);
|
|
1613
95
|
};
|
|
1614
96
|
let requestedExitCode = 0;
|
|
97
|
+
let cliEnvironment = process.env;
|
|
1615
98
|
var CliError = class extends Error {
|
|
1616
99
|
exitCode;
|
|
1617
100
|
code;
|
|
@@ -1633,10 +116,12 @@ async function resolveDaemonLifecycle() {
|
|
|
1633
116
|
return await serviceInstalled() ? "service" : "treeport";
|
|
1634
117
|
}
|
|
1635
118
|
function formatServiceStatus(status) {
|
|
119
|
+
const mode = status.mode === "headless" ? "advanced headless (starts before login)" : status.mode === "user" && status.manager === "launchd" ? "user/login (starts after login)" : status.mode === "user" ? "user service" : "not installed";
|
|
1636
120
|
const lines = [
|
|
1637
121
|
`Treeport service: ${status.state}`,
|
|
122
|
+
`Mode: ${mode}`,
|
|
1638
123
|
`Manager: ${status.manager ?? "unsupported"}`,
|
|
1639
|
-
`Starts
|
|
124
|
+
`Starts before login: ${status.enabledAtBoot ? "yes" : "no"}`,
|
|
1640
125
|
`Active: ${status.active ? "yes" : "no"}`,
|
|
1641
126
|
`Definition: ${status.definitionPath ?? "not installed"}`
|
|
1642
127
|
];
|
|
@@ -1668,14 +153,13 @@ async function request(pathname, options = {}) {
|
|
|
1668
153
|
else externalSignal?.addEventListener("abort", abort, { once: true });
|
|
1669
154
|
const timeout = setTimeout(abort, 9e4);
|
|
1670
155
|
try {
|
|
156
|
+
const headers = new Headers({ accept: "application/json" });
|
|
157
|
+
if (options.body) headers.set("content-type", "application/json");
|
|
158
|
+
new Headers(options.headers).forEach((value, key) => headers.set(key, value));
|
|
1671
159
|
const response = await fetch(`${apiUrl}${pathname}`, {
|
|
1672
160
|
...options,
|
|
1673
161
|
signal: controller.signal,
|
|
1674
|
-
headers
|
|
1675
|
-
accept: "application/json",
|
|
1676
|
-
...options.body ? { "content-type": "application/json" } : {},
|
|
1677
|
-
...options.headers
|
|
1678
|
-
}
|
|
162
|
+
headers
|
|
1679
163
|
});
|
|
1680
164
|
const body = await response.json().catch(() => ({}));
|
|
1681
165
|
if (!response.ok) {
|
|
@@ -1700,18 +184,18 @@ async function createWorktree(projectId, input) {
|
|
|
1700
184
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1701
185
|
operation = (await request(`/api/operations/${encodeURIComponent(operation.id)}`)).operation;
|
|
1702
186
|
}
|
|
1703
|
-
if (operation.status === "failed") throw new CliError(operation.error ?? "
|
|
1704
|
-
if (operation.kind !== "create") throw new CliError("
|
|
1705
|
-
const worktreeId =
|
|
1706
|
-
if (!worktreeId) throw new CliError("Completed
|
|
187
|
+
if (operation.status === "failed") throw new CliError(operation.error ?? "Tree creation failed", 5, "WORKTREE_CREATION_FAILED");
|
|
188
|
+
if (operation.kind !== "create") throw new CliError("Tree creation returned an unexpected operation kind", 5, "INVALID_OPERATION_RESULT");
|
|
189
|
+
const worktreeId = operation.result?.worktreeId ?? operation.worktreeId;
|
|
190
|
+
if (!worktreeId) throw new CliError("Completed tree creation did not identify its tree", 5, "INVALID_OPERATION_RESULT");
|
|
1707
191
|
const worktree = (await request(`/api/projects/${encodeURIComponent(projectId)}`)).project.worktrees.find((item) => item.id === worktreeId);
|
|
1708
|
-
if (!worktree) throw new CliError(`Created
|
|
1709
|
-
const terminalId =
|
|
192
|
+
if (!worktree) throw new CliError(`Created tree ${worktreeId} was not found`, 5, "INVALID_OPERATION_RESULT");
|
|
193
|
+
const terminalId = operation.result?.terminalId ?? null;
|
|
1710
194
|
return {
|
|
1711
195
|
worktree,
|
|
1712
196
|
terminal: worktree.terminals.find((item) => item.id === terminalId) ?? null,
|
|
1713
|
-
terminalError:
|
|
1714
|
-
setupError:
|
|
197
|
+
terminalError: operation.result?.terminalError ?? null,
|
|
198
|
+
setupError: operation.result?.setupError ?? null
|
|
1715
199
|
};
|
|
1716
200
|
}
|
|
1717
201
|
function commandArgv(args) {
|
|
@@ -1741,9 +225,12 @@ async function resolveProject(identifier) {
|
|
|
1741
225
|
const environmentMatch = list.find((project) => project.id === contextProjectId);
|
|
1742
226
|
if (environmentMatch) return environmentMatch;
|
|
1743
227
|
}
|
|
1744
|
-
const candidate = await canonical(identifier);
|
|
1745
|
-
const match = list.
|
|
1746
|
-
|
|
228
|
+
const candidate = await canonical(identifier ?? ".");
|
|
229
|
+
const match = list.flatMap((project) => [project.rootPath, ...project.worktrees.map((item) => item.path)].map((root) => ({
|
|
230
|
+
project,
|
|
231
|
+
root
|
|
232
|
+
}))).filter(({ root }) => pathContains(candidate, root)).sort((left, right) => right.root.length - left.root.length)[0]?.project;
|
|
233
|
+
if (!match) throw new CliError(identifier === void 0 ? `No registered project contains ${candidate}. Specify --project <id-or-path>.` : `No registered project matches ${identifier}`, 5);
|
|
1747
234
|
return match;
|
|
1748
235
|
}
|
|
1749
236
|
async function packageSource(value) {
|
|
@@ -1764,7 +251,7 @@ async function resolveWorktree(identifier) {
|
|
|
1764
251
|
}
|
|
1765
252
|
const candidate = await canonical(identifier);
|
|
1766
253
|
const match = all.filter((worktree) => pathContains(candidate, worktree.path)).sort((a, b) => b.path.length - a.path.length)[0];
|
|
1767
|
-
if (!match) throw new CliError(`No registered
|
|
254
|
+
if (!match) throw new CliError(`No registered tree matches ${identifier}`, 5);
|
|
1768
255
|
return match;
|
|
1769
256
|
}
|
|
1770
257
|
function parseWebPanelInput(value) {
|
|
@@ -1776,8 +263,9 @@ function parseWebPanelInput(value) {
|
|
|
1776
263
|
} catch (error) {
|
|
1777
264
|
throw new CliError(`--input must contain valid JSON: ${error instanceof Error ? error.message : String(error)}`, 2);
|
|
1778
265
|
}
|
|
1779
|
-
|
|
1780
|
-
|
|
266
|
+
const validated = webPanelInputSchema.safeParse(parsed);
|
|
267
|
+
if (!validated.success) throw new CliError("--input must contain a JSON object", 2);
|
|
268
|
+
return validated.data;
|
|
1781
269
|
}
|
|
1782
270
|
async function webPanelDefinition(worktreeId, identifier) {
|
|
1783
271
|
const definitions = (await request(`/api/worktrees/${encodeURIComponent(worktreeId)}/web-panel-definitions`)).definitions;
|
|
@@ -1786,11 +274,11 @@ async function webPanelDefinition(worktreeId, identifier) {
|
|
|
1786
274
|
const matches = definitions.filter((definition) => decodeURIComponent(definition.id.split(":").at(-1) ?? "") === identifier);
|
|
1787
275
|
if (matches.length === 1) return matches[0];
|
|
1788
276
|
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
|
|
277
|
+
throw new CliError(`Web panel ${identifier} is not available in this tree`, 5, "WEB_PANEL_DEFINITION_NOT_FOUND");
|
|
1790
278
|
}
|
|
1791
279
|
async function webPanelLaunchCwd(worktree) {
|
|
1792
280
|
const [cwd, worktreeRoot] = await Promise.all([canonical(workingDirectory), canonical(worktree.path)]);
|
|
1793
|
-
if (!pathContains(cwd, worktreeRoot)) throw new CliError(`The current directory is outside
|
|
281
|
+
if (!pathContains(cwd, worktreeRoot)) throw new CliError(`The current directory is outside tree ${worktree.name}`, 5, "INVALID_WEB_PANEL_LAUNCH_CWD", {
|
|
1794
282
|
cwd,
|
|
1795
283
|
worktreeId: worktree.id,
|
|
1796
284
|
worktreePath: worktree.path
|
|
@@ -1876,10 +364,10 @@ async function waitForTerminal(terminalId, condition, timeoutMs) {
|
|
|
1876
364
|
resolve(result);
|
|
1877
365
|
}
|
|
1878
366
|
};
|
|
1879
|
-
const fail = (
|
|
367
|
+
const fail = (cause) => {
|
|
1880
368
|
if (!settled) {
|
|
1881
369
|
settled = true;
|
|
1882
|
-
reject(
|
|
370
|
+
reject(cause);
|
|
1883
371
|
}
|
|
1884
372
|
};
|
|
1885
373
|
const enqueue = (task) => {
|
|
@@ -1959,7 +447,7 @@ const agentGuidance = `AI agents:
|
|
|
1959
447
|
async function main(args) {
|
|
1960
448
|
const argv = args[0] === "spawn" || args[0] === "terminal" && args[1] === "create" ? commandArgv(args) : void 0;
|
|
1961
449
|
let parserError = "";
|
|
1962
|
-
const program = new Command().name("treeport").usage("[options] [folder] [command]").description("Manage Treeport projects,
|
|
450
|
+
const program = new Command().name("treeport").usage("[options] [folder] [command]").description("Manage Treeport projects, trees, and terminals.").argument("[folder]", "folder or folder inside a Git repository to open").option("--json", "emit machine-readable JSON").addHelpText("beforeAll", agentGuidance).configureOutput({
|
|
1963
451
|
writeOut: writeStdout,
|
|
1964
452
|
writeErr: (value) => {
|
|
1965
453
|
parserError += value;
|
|
@@ -1972,7 +460,7 @@ async function main(args) {
|
|
|
1972
460
|
}
|
|
1973
461
|
const absoluteFolder = path.resolve(workingDirectory, folder);
|
|
1974
462
|
if (!(await fs.stat(absoluteFolder).catch((error) => {
|
|
1975
|
-
if (
|
|
463
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") throw new CliError(`Folder does not exist: ${absoluteFolder}`, 5, "FOLDER_NOT_FOUND", { path: absoluteFolder });
|
|
1976
464
|
throw new CliError(`Cannot access folder ${absoluteFolder}: ${error instanceof Error ? error.message : String(error)}`, 5, "FOLDER_UNREADABLE", { path: absoluteFolder });
|
|
1977
465
|
})).isDirectory()) throw new CliError(`Path is not a folder: ${absoluteFolder}`, 5, "FOLDER_NOT_DIRECTORY", { path: absoluteFolder });
|
|
1978
466
|
const canonicalFolder = await fs.realpath(absoluteFolder).catch((error) => {
|
|
@@ -1986,12 +474,9 @@ async function main(args) {
|
|
|
1986
474
|
const registered = await request("/api/projects", {
|
|
1987
475
|
method: "POST",
|
|
1988
476
|
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
477
|
});
|
|
1993
478
|
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(`
|
|
479
|
+
if (!targetWorktree) throw new CliError(`Treeport did not find a workspace containing ${canonicalFolder}.`, 5, "WORKTREE_NOT_FOUND", {
|
|
1995
480
|
path: canonicalFolder,
|
|
1996
481
|
projectId: registered.project.id
|
|
1997
482
|
});
|
|
@@ -1999,7 +484,10 @@ async function main(args) {
|
|
|
1999
484
|
target.pathname = `/projects/${encodeURIComponent(registered.project.id)}/worktrees/${encodeURIComponent(targetWorktree.id)}`;
|
|
2000
485
|
target.search = "";
|
|
2001
486
|
target.hash = "";
|
|
2002
|
-
const opened = await
|
|
487
|
+
const opened = contextTerminalId ? await request(`/api/worktrees/${encodeURIComponent(targetWorktree.id)}/open`, {
|
|
488
|
+
method: "POST",
|
|
489
|
+
body: JSON.stringify({ sourceTerminalId: contextTerminalId })
|
|
490
|
+
}).then(() => ({ client: "current" })) : await openWorkspace(target.href).catch((error) => {
|
|
2003
491
|
if (error instanceof OpenWorkspaceError) throw new CliError(error.message, 1, "OPEN_FAILED", { url: target.href });
|
|
2004
492
|
throw error;
|
|
2005
493
|
});
|
|
@@ -2007,9 +495,10 @@ async function main(args) {
|
|
|
2007
495
|
projectId: registered.project.id,
|
|
2008
496
|
worktreeId: targetWorktree.id,
|
|
2009
497
|
path: canonicalFolder,
|
|
498
|
+
projectKind: registered.project.kind,
|
|
2010
499
|
url: target.href,
|
|
2011
500
|
client: opened.client
|
|
2012
|
-
}, () => `Opened ${registered.project.name} / ${targetWorktree.name} in the ${opened.client === "desktop" ? "Treeport desktop app" : "browser"}\n${target.href}`);
|
|
501
|
+
}, () => `Opened ${registered.project.name} / ${targetWorktree.name} in the ${opened.client === "desktop" ? "Treeport desktop app" : opened.client === "current" ? "current Treeport client" : "browser"}\n${target.href}`);
|
|
2013
502
|
});
|
|
2014
503
|
const startCommand = program.command("start").description("Ensure the local Treeport daemon is running").option("--host <address>", "loopback listener address").option("--port <port>", "listener port").option("--foreground", "run in the foreground").option("--json", "emit machine-readable JSON");
|
|
2015
504
|
startCommand.action(async () => {
|
|
@@ -2024,11 +513,11 @@ async function main(args) {
|
|
|
2024
513
|
return;
|
|
2025
514
|
}
|
|
2026
515
|
const port = options.port === void 0 ? void 0 : Number(options.port);
|
|
2027
|
-
const
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
516
|
+
const daemonOptions = {};
|
|
517
|
+
if (options.host !== void 0) daemonOptions.host = options.host;
|
|
518
|
+
if (port !== void 0) daemonOptions.port = port;
|
|
519
|
+
if (options.foreground !== void 0) daemonOptions.foreground = options.foreground;
|
|
520
|
+
const result = await daemonUp(daemonOptions);
|
|
2032
521
|
if (options.foreground) return;
|
|
2033
522
|
print(result, () => `Treeport is running\n${result.apiUrl}`);
|
|
2034
523
|
});
|
|
@@ -2052,8 +541,9 @@ async function main(args) {
|
|
|
2052
541
|
serviceCommand.action(() => {
|
|
2053
542
|
writeStdout(serviceCommand.helpInformation());
|
|
2054
543
|
});
|
|
2055
|
-
serviceCommand.command("enable").description("Enable startup
|
|
2056
|
-
|
|
544
|
+
const serviceEnableCommand = serviceCommand.command("enable").description("Enable automatic startup and unexpected-exit restarts").option("--headless", "use advanced macOS startup before login (requires an administrator)").option("--json", "emit machine-readable JSON");
|
|
545
|
+
serviceEnableCommand.action(async () => {
|
|
546
|
+
const result = await serviceEnable(serviceEnableCommand.opts().headless ? "headless" : "user");
|
|
2057
547
|
print(result, () => formatServiceStatus(result.status));
|
|
2058
548
|
if (result.status.state === "action_required") requestedExitCode = 1;
|
|
2059
549
|
});
|
|
@@ -2090,10 +580,10 @@ async function main(args) {
|
|
|
2090
580
|
const port = options.port === void 0 ? void 0 : Number(options.port);
|
|
2091
581
|
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
582
|
const serviceDaemon = lifecycle === "service" ? await ensureServiceDaemon() : void 0;
|
|
2093
|
-
const
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
583
|
+
const remoteOptions = {};
|
|
584
|
+
if (port !== void 0) remoteOptions.port = port;
|
|
585
|
+
if (serviceDaemon !== void 0) remoteOptions.daemon = serviceDaemon;
|
|
586
|
+
const result = await enableTailscaleRemote(remoteOptions);
|
|
2097
587
|
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
588
|
});
|
|
2099
589
|
remoteCommand.command("status").description("Show Tailscale remote access status").option("--json", "emit machine-readable JSON").action(async () => {
|
|
@@ -2124,7 +614,7 @@ async function main(args) {
|
|
|
2124
614
|
print(result, () => {
|
|
2125
615
|
if (!status.state) return supervision ? formatServiceStatus(supervision) : "Treeport is stopped";
|
|
2126
616
|
if (!status.running || !status.verified) return `Treeport is unhealthy (PID ${status.state.pid})\nLogs: ${path.join(status.state.dataDir, "logs", "daemon.log")}`;
|
|
2127
|
-
return `Treeport is running\n${status.state.apiUrl}\nLifecycle: ${status.health?.daemonLifecycle}\nVersion: ${status.health?.version}\nPID: ${status.state.pid}\nProjects: ${result.projects}\
|
|
617
|
+
return `Treeport is running\n${status.state.apiUrl}\nLifecycle: ${status.health?.daemonLifecycle}\nVersion: ${status.health?.version}\nPID: ${status.state.pid}\nProjects: ${result.projects}\nTrees: ${result.worktrees}\nTerminals: ${result.terminals}`;
|
|
2128
618
|
});
|
|
2129
619
|
});
|
|
2130
620
|
const logsCommand = program.command("logs").description("Show recent daemon logs").option("--lines <count>", "number of lines", "100");
|
|
@@ -2174,12 +664,12 @@ async function main(args) {
|
|
|
2174
664
|
if (missing.length) throw new CliError(`Incomplete Treeport context; missing ${missing.join(", ")}`, 5, "TREEPORT_CONTEXT_INCOMPLETE", { missing });
|
|
2175
665
|
const project = (await request(`/api/projects/${encodeURIComponent(projectId)}`)).project;
|
|
2176
666
|
const worktree = project.worktrees.find((candidate) => candidate.id === worktreeId);
|
|
2177
|
-
if (!worktree) throw new CliError("Treeport context
|
|
667
|
+
if (!worktree) throw new CliError("Treeport context tree does not belong to the current project", 5, "TREEPORT_CONTEXT_INVALID", {
|
|
2178
668
|
projectId,
|
|
2179
669
|
worktreeId
|
|
2180
670
|
});
|
|
2181
671
|
const terminal = worktree.terminals.find((candidate) => candidate.id === terminalId);
|
|
2182
|
-
if (!terminal) throw new CliError("Treeport context terminal does not belong to the current
|
|
672
|
+
if (!terminal) throw new CliError("Treeport context terminal does not belong to the current tree", 5, "TREEPORT_CONTEXT_INVALID", {
|
|
2183
673
|
worktreeId,
|
|
2184
674
|
terminalId
|
|
2185
675
|
});
|
|
@@ -2190,6 +680,8 @@ async function main(args) {
|
|
|
2190
680
|
project: {
|
|
2191
681
|
id: project.id,
|
|
2192
682
|
name: project.name,
|
|
683
|
+
kind: project.kind,
|
|
684
|
+
rootPath: project.rootPath,
|
|
2193
685
|
repositoryPath: project.repositoryPath,
|
|
2194
686
|
mainWorktreePath: project.mainWorktreePath,
|
|
2195
687
|
defaultBranch: project.defaultBranch,
|
|
@@ -2213,29 +705,27 @@ async function main(args) {
|
|
|
2213
705
|
exitCode: terminal.exitCode
|
|
2214
706
|
}
|
|
2215
707
|
};
|
|
2216
|
-
print(context, () => `Treeport context\n\nProject: ${context.project.name} (${context.project.id})\
|
|
708
|
+
print(context, () => `Treeport context\n\nProject: ${context.project.name} (${context.project.id})\nTree: ${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
709
|
});
|
|
2218
710
|
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
711
|
installCommand.action(async (source) => {
|
|
2220
712
|
const options = installCommand.opts();
|
|
713
|
+
const body = { source: await packageSource(source) };
|
|
714
|
+
if (options.local) body.projectId = await localPackageProjectId();
|
|
2221
715
|
const result = (await request("/api/packages/install", {
|
|
2222
716
|
method: "POST",
|
|
2223
|
-
body: JSON.stringify(
|
|
2224
|
-
source: await packageSource(source),
|
|
2225
|
-
...options.local ? { projectId: await localPackageProjectId() } : {}
|
|
2226
|
-
})
|
|
717
|
+
body: JSON.stringify(body)
|
|
2227
718
|
})).result;
|
|
2228
719
|
print(result, () => `Installed ${result.source}${result.scope === "project" ? ` for project ${result.projectId}` : " globally"}`);
|
|
2229
720
|
});
|
|
2230
721
|
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
722
|
removePackageCommand.action(async (source) => {
|
|
2232
723
|
const options = removePackageCommand.opts();
|
|
724
|
+
const body = { source: await packageSource(source) };
|
|
725
|
+
if (options.local) body.projectId = await localPackageProjectId();
|
|
2233
726
|
const result = (await request("/api/packages/remove", {
|
|
2234
727
|
method: "POST",
|
|
2235
|
-
body: JSON.stringify(
|
|
2236
|
-
source: await packageSource(source),
|
|
2237
|
-
...options.local ? { projectId: await localPackageProjectId() } : {}
|
|
2238
|
-
})
|
|
728
|
+
body: JSON.stringify(body)
|
|
2239
729
|
})).result;
|
|
2240
730
|
print(result, () => `Removed ${result.source}`);
|
|
2241
731
|
});
|
|
@@ -2249,10 +739,24 @@ async function main(args) {
|
|
|
2249
739
|
return lines.join("\n");
|
|
2250
740
|
});
|
|
2251
741
|
});
|
|
2252
|
-
const updatePackagesCommand = program.command("update").description("
|
|
742
|
+
const updatePackagesCommand = program.command("update").description("Update Treeport or explicitly update configured packages").argument("[source]", "one configured npm: source").option("--packages", "update every eligible configured package").option("--json", "emit machine-readable JSON");
|
|
2253
743
|
updatePackagesCommand.action(async (source) => {
|
|
2254
744
|
const options = updatePackagesCommand.opts();
|
|
2255
|
-
if (
|
|
745
|
+
if (source && options.packages) throw new CliError("Specify a package source or --packages, not both.", 2);
|
|
746
|
+
if (!source && !options.packages) {
|
|
747
|
+
if (await resolveDaemonLifecycle() === "external") throw new CliError("Cannot update Treeport because this daemon lifecycle is externally managed.", 5, "UPDATE_EXTERNAL_REFUSED");
|
|
748
|
+
const selfUpdateOptions = { environment: cliEnvironment };
|
|
749
|
+
if (!jsonOutput) selfUpdateOptions.progress = (message) => writeStderr(`${message}\n`);
|
|
750
|
+
const result = await runLocalUpdate(selfUpdateOptions).catch((error) => {
|
|
751
|
+
if (error instanceof LocalUpdateError) throw new CliError(error.message, error.exitCode, error.code, error.details);
|
|
752
|
+
throw error;
|
|
753
|
+
});
|
|
754
|
+
print(result, () => {
|
|
755
|
+
if (result.status === "current") return `Treeport ${result.toVersion} is current`;
|
|
756
|
+
return result.daemon.wasRunning ? `Updated Treeport from ${result.fromVersion} to ${result.toVersion} and restarted the ${result.daemon.lifecycle === "service" ? "service" : "daemon"}` : `Updated Treeport from ${result.fromVersion} to ${result.toVersion}; Treeport remains stopped`;
|
|
757
|
+
});
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
2256
760
|
const results = (await request("/api/packages/update", {
|
|
2257
761
|
method: "POST",
|
|
2258
762
|
body: JSON.stringify(source ? { source: await packageSource(source) } : {})
|
|
@@ -2274,40 +778,41 @@ async function main(args) {
|
|
|
2274
778
|
projectCommand.action(() => {
|
|
2275
779
|
throw new CliError(projectCommand.helpInformation(), 2);
|
|
2276
780
|
});
|
|
2277
|
-
projectCommand.command("add").description("Register a Git repository").argument("<path>", "
|
|
781
|
+
projectCommand.command("add").description("Register a folder or Git repository").argument("<path>", "folder path").option("--json", "emit machine-readable JSON").action(async (repository) => {
|
|
2278
782
|
const body = await request("/api/projects", {
|
|
2279
783
|
method: "POST",
|
|
2280
784
|
body: JSON.stringify({ path: await canonical(repository) })
|
|
2281
785
|
});
|
|
2282
|
-
print(body.project, () => `Registered ${body.project.name} (${body.project.id})\n${body.project.
|
|
786
|
+
print(body.project, () => `Registered ${body.project.name} (${body.project.id})\n${body.project.rootPath}`);
|
|
2283
787
|
});
|
|
2284
788
|
projectCommand.command("list").description("List registered projects").option("--json", "emit machine-readable JSON").action(async () => {
|
|
2285
789
|
const list = await projects();
|
|
2286
|
-
print(list, () => list.map((project) => `${project.id}\t${project.name}\t${project.
|
|
790
|
+
print(list, () => list.map((project) => `${project.id}\t${project.name}\t${project.kind}\t${project.rootPath}`).join("\n"));
|
|
2287
791
|
});
|
|
2288
|
-
const worktreeCommand = program.command("worktree").description("List, create, and remove
|
|
792
|
+
const worktreeCommand = program.command("worktree").description("List, create, and remove trees");
|
|
2289
793
|
worktreeCommand.action(() => {
|
|
2290
794
|
throw new CliError(worktreeCommand.helpInformation(), 2);
|
|
2291
795
|
});
|
|
2292
|
-
const worktreeListCommand = worktreeCommand.command("list").description("List discovered
|
|
796
|
+
const worktreeListCommand = worktreeCommand.command("list").description("List discovered trees").option("--project <id-or-path>", "limit results to a project").option("--json", "emit machine-readable JSON");
|
|
2293
797
|
worktreeListCommand.action(async () => {
|
|
2294
798
|
const { project: projectIdentifier } = worktreeListCommand.opts();
|
|
2295
799
|
const list = projectIdentifier ? (await resolveProject(projectIdentifier)).worktrees : (await projects()).flatMap((project) => project.worktrees);
|
|
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"));
|
|
800
|
+
print(list, () => list.map((worktree) => `${worktree.id}\t${worktree.name}\t${worktree.kind === "folder" ? "folder" : worktree.branch ?? `detached@${worktree.head.slice(0, 8)}`}\t${worktree.path}`).join("\n"));
|
|
2297
801
|
});
|
|
2298
|
-
const worktreeCreateCommand = worktreeCommand.command("create").description("Create a linked
|
|
802
|
+
const worktreeCreateCommand = worktreeCommand.command("create").description("Create a linked tree").option("--project <id-or-path>", "project to create from (default: current folder)").requiredOption("--name <name>", "Tree name").option("--from-current", "base the tree on the current tree").option("--json", "emit machine-readable JSON");
|
|
2299
803
|
worktreeCreateCommand.action(async () => {
|
|
2300
804
|
const options = worktreeCreateCommand.opts();
|
|
2301
805
|
const project = await resolveProject(options.project);
|
|
2302
806
|
const sourceWorktreeId = options.fromCurrent ? (await resolveWorktree(".")).id : void 0;
|
|
2303
|
-
const
|
|
807
|
+
const request = {
|
|
2304
808
|
name: options.name,
|
|
2305
|
-
base: options.fromCurrent ? "current" : "default"
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
809
|
+
base: options.fromCurrent ? "current" : "default"
|
|
810
|
+
};
|
|
811
|
+
if (sourceWorktreeId) request.sourceWorktreeId = sourceWorktreeId;
|
|
812
|
+
const result = await createWorktree(project.id, request);
|
|
813
|
+
print(result, () => `Created tree ${result.worktree.name} (${result.worktree.id})\n${result.worktree.path}${result.setupError ? `\nSetup error: ${result.setupError}` : ""}`);
|
|
2309
814
|
});
|
|
2310
|
-
const worktreeRemoveCommand = worktreeCommand.command("remove").description("Remove a linked
|
|
815
|
+
const worktreeRemoveCommand = worktreeCommand.command("remove").description("Remove a linked tree").argument("<id-or-path-or-dot>", "Tree to remove").option("--force", "confirm destructive removal warnings").option("--json", "emit machine-readable JSON");
|
|
2311
816
|
worktreeRemoveCommand.action(async (identifier) => {
|
|
2312
817
|
const { force: confirmed } = worktreeRemoveCommand.opts();
|
|
2313
818
|
const worktree = await resolveWorktree(identifier);
|
|
@@ -2325,18 +830,18 @@ async function main(args) {
|
|
|
2325
830
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2326
831
|
operation = (await request(`/api/operations/${encodeURIComponent(operation.id)}`)).operation;
|
|
2327
832
|
}
|
|
2328
|
-
if (operation.status === "failed") throw new CliError(operation.error ?? "
|
|
2329
|
-
if (operation.kind !== "remove" || !operation.result) throw new CliError("
|
|
833
|
+
if (operation.status === "failed") throw new CliError(operation.error ?? "Tree removal failed", 5, "WORKTREE_REMOVAL_FAILED");
|
|
834
|
+
if (operation.kind !== "remove" || !operation.result) throw new CliError("Tree removal returned an unexpected operation result", 5, "INVALID_OPERATION_RESULT");
|
|
2330
835
|
print(operation.result, () => {
|
|
2331
836
|
const warning = operation.result?.cleanup.warning;
|
|
2332
|
-
return `Removed ${worktree.name} (${worktree.id})${warning ? `\nWarning: ${warning}` : ""}`;
|
|
837
|
+
return `Removed tree ${worktree.name} (${worktree.id})${warning ? `\nWarning: ${warning}` : ""}`;
|
|
2333
838
|
});
|
|
2334
839
|
});
|
|
2335
840
|
const webPanelCommand = program.command("web-panel").description("Open persistent web panels");
|
|
2336
841
|
webPanelCommand.action(() => {
|
|
2337
842
|
throw new CliError(webPanelCommand.helpInformation(), 2);
|
|
2338
843
|
});
|
|
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
|
|
844
|
+
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 tree").option("--input <json>", "structured panel input as a JSON object").option("--new", "create a separate panel instance").option("--json", "emit machine-readable JSON");
|
|
2340
845
|
webPanelOpenCommand.action(async (identifier) => {
|
|
2341
846
|
const options = webPanelOpenCommand.opts();
|
|
2342
847
|
const worktree = await resolveWorktree(options.worktree);
|
|
@@ -2357,25 +862,25 @@ async function main(args) {
|
|
|
2357
862
|
};
|
|
2358
863
|
print(output, () => `${result.reused ? "Reused" : "Opened"} ${result.panel.title} (${result.panel.id})\n${output.url}`);
|
|
2359
864
|
});
|
|
2360
|
-
const terminalCommand = program.command("terminal").description("Manage persistent
|
|
865
|
+
const terminalCommand = program.command("terminal").description("Manage persistent tree terminals");
|
|
2361
866
|
terminalCommand.action(() => {
|
|
2362
867
|
throw new CliError(terminalCommand.helpInformation(), 2);
|
|
2363
868
|
});
|
|
2364
|
-
const terminalListCommand = terminalCommand.command("list").description("List terminals").option("--worktree <id-or-path>", "limit results to a
|
|
869
|
+
const terminalListCommand = terminalCommand.command("list").description("List terminals").option("--worktree <id-or-path>", "limit results to a tree").option("--json", "emit machine-readable JSON");
|
|
2365
870
|
terminalListCommand.action(async () => {
|
|
2366
871
|
const { worktree: identifier } = terminalListCommand.opts();
|
|
2367
872
|
const list = identifier ? (await resolveWorktree(identifier)).terminals : (await projects()).flatMap((project) => project.worktrees.flatMap((worktree) => worktree.terminals));
|
|
2368
873
|
print(list, () => list.map((terminal) => `${terminal.id}\t${terminal.name}\t${terminal.status}\t${JSON.stringify(terminal.argv)}`).join("\n"));
|
|
2369
874
|
});
|
|
2370
|
-
const terminalCreateCommand = terminalCommand.command("create").description("Create a persistent terminal").usage("[options] [-- <command> args...]").requiredOption("--worktree <id-or-path-or-dot>", "owning
|
|
875
|
+
const terminalCreateCommand = terminalCommand.command("create").description("Create a persistent terminal").usage("[options] [-- <command> args...]").requiredOption("--worktree <id-or-path-or-dot>", "owning tree").requiredOption("--name <name>", "terminal name").option("--json", "emit machine-readable JSON").addHelpText("after", "\nCommand arguments may be passed after --.\n");
|
|
2371
876
|
terminalCreateCommand.action(async () => {
|
|
2372
877
|
const options = terminalCreateCommand.opts();
|
|
2373
|
-
const
|
|
878
|
+
const worktree = await resolveWorktree(options.worktree);
|
|
879
|
+
const body = { name: options.name };
|
|
880
|
+
if (argv) body.argv = argv;
|
|
881
|
+
const result = await request(`/api/worktrees/${worktree.id}/terminals`, {
|
|
2374
882
|
method: "POST",
|
|
2375
|
-
body: JSON.stringify(
|
|
2376
|
-
name: options.name,
|
|
2377
|
-
...argv ? { argv } : {}
|
|
2378
|
-
})
|
|
883
|
+
body: JSON.stringify(body)
|
|
2379
884
|
});
|
|
2380
885
|
print(result.terminal, () => `Created ${result.terminal.name} (${result.terminal.id})`);
|
|
2381
886
|
});
|
|
@@ -2419,21 +924,21 @@ async function main(args) {
|
|
|
2419
924
|
terminalId
|
|
2420
925
|
}, () => `Deleted ${terminalId}`);
|
|
2421
926
|
});
|
|
2422
|
-
const spawnCommand = program.command("spawn").description("Create a
|
|
927
|
+
const spawnCommand = program.command("spawn").description("Create a tree and its first terminal").usage("[options] [-- <command> args...]").option("--project <id-or-path-or-dot>", "project to create from (default: current folder)").requiredOption("--worktree-name <name>", "Tree name").requiredOption("--name <terminal-name>", "terminal name").option("--from-current", "base the tree on the current tree").option("--json", "emit machine-readable JSON").addHelpText("after", "\nCommand arguments may be passed after --.\n");
|
|
2423
928
|
spawnCommand.action(async () => {
|
|
2424
929
|
const options = spawnCommand.opts();
|
|
2425
930
|
const project = await resolveProject(options.project);
|
|
2426
931
|
const sourceWorktreeId = options.fromCurrent ? (await resolveWorktree(".")).id : void 0;
|
|
2427
|
-
const
|
|
932
|
+
const initialTerminal = { name: options.name };
|
|
933
|
+
if (argv) initialTerminal.argv = argv;
|
|
934
|
+
const request = {
|
|
2428
935
|
name: options.worktreeName,
|
|
2429
936
|
base: options.fromCurrent ? "current" : "default",
|
|
2430
|
-
initialTerminal
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
});
|
|
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}` : ""}`);
|
|
937
|
+
initialTerminal
|
|
938
|
+
};
|
|
939
|
+
if (sourceWorktreeId) request.sourceWorktreeId = sourceWorktreeId;
|
|
940
|
+
const result = await createWorktree(project.id, request);
|
|
941
|
+
print(result, () => `Created tree ${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}` : ""}`);
|
|
2437
942
|
});
|
|
2438
943
|
try {
|
|
2439
944
|
await program.parseAsync(args, { from: "user" });
|
|
@@ -2447,6 +952,7 @@ async function main(args) {
|
|
|
2447
952
|
}
|
|
2448
953
|
async function runCliApplication(options) {
|
|
2449
954
|
const environment = options.environment ?? process.env;
|
|
955
|
+
cliEnvironment = environment;
|
|
2450
956
|
configuredApiUrl = environment.TREEPORT_API_URL?.trim();
|
|
2451
957
|
apiUrl = (await resolveLocalApiUrl(environment)).replace(/\/$/, "");
|
|
2452
958
|
contextProjectId = environment.TREEPORT_PROJECT_ID?.trim() || void 0;
|
|
@@ -2465,9 +971,9 @@ async function runCliApplication(options) {
|
|
|
2465
971
|
if (jsonOutput) {
|
|
2466
972
|
const body = { error: {
|
|
2467
973
|
code: cliError.code,
|
|
2468
|
-
message: cliError.message
|
|
2469
|
-
...cliError.details === void 0 ? {} : { details: cliError.details }
|
|
974
|
+
message: cliError.message
|
|
2470
975
|
} };
|
|
976
|
+
if (cliError.details !== void 0) body.error.details = cliError.details;
|
|
2471
977
|
writeStderr(`${JSON.stringify(body)}\n`);
|
|
2472
978
|
} else writeStderr(`${cliError.message}\n`);
|
|
2473
979
|
requestedExitCode = cliError.exitCode;
|