@treeport/treeport 0.1.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/loopback-4XVZbAD1.js +407 -0
- package/dist/node/cli/index.js +616 -105
- package/dist/node/server/index.js +4337 -945
- package/dist/web/assets/index-0q5frbNy.css +2 -0
- package/dist/web/assets/index-CUy0IkGL.js +146 -0
- package/dist/web/favicon.svg +1 -1
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/index.html +2 -2
- package/drizzle/0001_misty_spectrum.sql +11 -0
- package/drizzle/0002_dazzling_princess_powerful.sql +9 -0
- package/drizzle/0003_yummy_whirlwind.sql +3 -0
- package/drizzle/0004_persistent_worktree_creation.sql +26 -0
- package/drizzle/0005_git_authoritative_worktrees.sql +120 -0
- package/drizzle/0006_web_panel_launch_input.sql +3 -0
- package/drizzle/0007_dashing_pestilence.sql +10 -0
- package/drizzle/meta/0001_snapshot.json +647 -0
- package/drizzle/meta/0002_snapshot.json +701 -0
- package/drizzle/meta/0003_snapshot.json +709 -0
- package/drizzle/meta/0004_snapshot.json +714 -0
- package/drizzle/meta/0005_snapshot.json +696 -0
- package/drizzle/meta/0006_snapshot.json +711 -0
- package/drizzle/meta/0007_snapshot.json +775 -0
- package/drizzle/meta/_journal.json +49 -0
- package/package.json +9 -9
- package/skills/treeport/SKILL.md +3 -2
- package/dist/dist-CUkImh2W.js +0 -254
- package/dist/web/assets/index-C2QPZrxe.js +0 -146
- package/dist/web/assets/index-CC-O69aQ.css +0 -2
package/dist/node/cli/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { A as parseProductEvent, j as SOCKET_IO_PATH, k as parseEventsSnapshot, n as parseDurationMs, r as TERMINAL_CAPTURE_MAX_LINES, t as assertLoopbackHost } from "../../loopback-4XVZbAD1.js";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { Command, CommanderError } from "commander";
|
|
6
6
|
import { io } from "socket.io-client";
|
|
7
|
-
import
|
|
7
|
+
import { z } from "zod";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
|
+
import crypto from "node:crypto";
|
|
9
10
|
import fsSync from "node:fs";
|
|
10
11
|
import os from "node:os";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
@@ -18,6 +19,69 @@ function extractJsonOutput(args) {
|
|
|
18
19
|
return true;
|
|
19
20
|
}
|
|
20
21
|
//#endregion
|
|
22
|
+
//#region src/cli/open.ts
|
|
23
|
+
const DESKTOP_BUNDLE_ID = "tech.noice.treeport";
|
|
24
|
+
const LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set([
|
|
25
|
+
"127.0.0.1",
|
|
26
|
+
"localhost",
|
|
27
|
+
"[::1]"
|
|
28
|
+
]);
|
|
29
|
+
var OpenWorkspaceError = class extends Error {};
|
|
30
|
+
function defaultLaunch(executable, args) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const child = spawn(executable, args, { stdio: [
|
|
33
|
+
"ignore",
|
|
34
|
+
"ignore",
|
|
35
|
+
"pipe"
|
|
36
|
+
] });
|
|
37
|
+
let settled = false;
|
|
38
|
+
let stderr = "";
|
|
39
|
+
child.stderr.setEncoding("utf8");
|
|
40
|
+
child.stderr.on("data", (chunk) => {
|
|
41
|
+
stderr = `${stderr}${chunk}`.slice(-4096);
|
|
42
|
+
});
|
|
43
|
+
child.once("error", (error) => {
|
|
44
|
+
if (!settled) {
|
|
45
|
+
settled = true;
|
|
46
|
+
resolve({
|
|
47
|
+
code: null,
|
|
48
|
+
stderr: error.message
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
child.once("close", (code) => {
|
|
53
|
+
if (!settled) {
|
|
54
|
+
settled = true;
|
|
55
|
+
resolve({
|
|
56
|
+
code,
|
|
57
|
+
stderr: stderr.trim()
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function desktopCanOpen(workspaceUrl) {
|
|
64
|
+
if (!URL.canParse(workspaceUrl)) return false;
|
|
65
|
+
const url = new URL(workspaceUrl);
|
|
66
|
+
return !url.username && !url.password && (url.protocol === "https:" || url.protocol === "http:" && LOOPBACK_HOSTNAMES.has(url.hostname.toLowerCase()));
|
|
67
|
+
}
|
|
68
|
+
async function openWorkspace(workspaceUrl, options = {}) {
|
|
69
|
+
const platform = options.platform ?? process.platform;
|
|
70
|
+
const launch = options.launch ?? defaultLaunch;
|
|
71
|
+
if (platform === "darwin" && desktopCanOpen(workspaceUrl)) {
|
|
72
|
+
const deepLink = new URL("treeport://open");
|
|
73
|
+
deepLink.searchParams.set("url", workspaceUrl);
|
|
74
|
+
if ((await launch("open", [
|
|
75
|
+
"-b",
|
|
76
|
+
DESKTOP_BUNDLE_ID,
|
|
77
|
+
deepLink.href
|
|
78
|
+
])).code === 0) return { client: "desktop" };
|
|
79
|
+
}
|
|
80
|
+
const browser = await launch(platform === "darwin" ? "open" : "xdg-open", [workspaceUrl]);
|
|
81
|
+
if (browser.code === 0) return { client: "browser" };
|
|
82
|
+
throw new OpenWorkspaceError(`Treeport registered the folder, but could not open it automatically.${browser.stderr ? ` ${browser.stderr}` : ""}\nOpen this URL manually: ${workspaceUrl}`);
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
21
85
|
//#region src/cli/lifecycle.ts
|
|
22
86
|
const DEFAULT_HOST = "127.0.0.1";
|
|
23
87
|
const DEFAULT_PORT = 8733;
|
|
@@ -40,17 +104,62 @@ function localPaths(env = process.env) {
|
|
|
40
104
|
logPath: path.join(dataDir, "logs", "daemon.log")
|
|
41
105
|
};
|
|
42
106
|
}
|
|
43
|
-
async function readJson(filePath) {
|
|
44
|
-
return fs.readFile(filePath, "utf8").then((value) => JSON.parse(value)).catch(() => null);
|
|
107
|
+
async function readJson(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
|
+
});
|
|
127
|
+
const healthRecordSchema = z.strictObject({
|
|
128
|
+
ok: z.literal(true),
|
|
129
|
+
version: z.string(),
|
|
130
|
+
protocolVersion: z.number(),
|
|
131
|
+
hostname: z.string().optional(),
|
|
132
|
+
pid: z.number(),
|
|
133
|
+
instanceId: z.string().nullable(),
|
|
134
|
+
installationMethod: z.string(),
|
|
135
|
+
daemonLifecycle: z.enum(["treeport", "external"]),
|
|
136
|
+
url: z.string()
|
|
137
|
+
});
|
|
138
|
+
async function preferences(env = process.env) {
|
|
139
|
+
return await readJson(localPaths(env).preferencesPath, preferencesSchema) ?? {};
|
|
45
140
|
}
|
|
46
|
-
async function
|
|
47
|
-
|
|
141
|
+
async function savePreferences(value) {
|
|
142
|
+
const paths = localPaths();
|
|
143
|
+
await fs.mkdir(paths.dataDir, {
|
|
144
|
+
recursive: true,
|
|
145
|
+
mode: 448
|
|
146
|
+
});
|
|
147
|
+
const temporaryPath = `${paths.preferencesPath}.${process.pid}.tmp`;
|
|
148
|
+
await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
|
|
149
|
+
await fs.rename(temporaryPath, paths.preferencesPath);
|
|
48
150
|
}
|
|
49
|
-
async function resolveLocalApiUrl() {
|
|
50
|
-
const explicit =
|
|
151
|
+
async function resolveLocalApiUrl(env = process.env) {
|
|
152
|
+
const explicit = env.TREEPORT_API_URL?.trim();
|
|
153
|
+
const managedApiUrl = env.TREEPORT_MANAGED_API_URL?.trim();
|
|
154
|
+
const daemonRecordPath = env.TREEPORT_DAEMON_RECORD?.trim();
|
|
155
|
+
if (explicit && explicit !== managedApiUrl) return explicit.replace(/\/$/, "");
|
|
156
|
+
if (managedApiUrl && daemonRecordPath) {
|
|
157
|
+
const record = await readJson(path.resolve(expandHome(daemonRecordPath)), daemonRecordSchema);
|
|
158
|
+
if (record) return record.apiUrl.replace(/\/$/, "");
|
|
159
|
+
}
|
|
51
160
|
if (explicit) return explicit.replace(/\/$/, "");
|
|
52
|
-
const saved = await preferences();
|
|
53
|
-
return listenerUrl(
|
|
161
|
+
const saved = await preferences(env);
|
|
162
|
+
return listenerUrl(env.TREEPORT_HOST?.trim() || env.HOST?.trim() || saved.host || DEFAULT_HOST, Number.parseInt(env.TREEPORT_PORT?.trim() || env.PORT?.trim() || String(saved.port ?? DEFAULT_PORT), 10));
|
|
54
163
|
}
|
|
55
164
|
async function resolvePackagePath(...segments) {
|
|
56
165
|
const candidates = [fileURLToPath(new URL("../../../", import.meta.url)), fileURLToPath(new URL("../../", import.meta.url))];
|
|
@@ -58,7 +167,7 @@ async function resolvePackagePath(...segments) {
|
|
|
58
167
|
throw new Error("Could not locate the Treeport package directory");
|
|
59
168
|
}
|
|
60
169
|
async function treeportVersion() {
|
|
61
|
-
return (await readJson(await resolvePackagePath("package.json")))?.version ?? "development";
|
|
170
|
+
return (await readJson(await resolvePackagePath("package.json"), z.looseObject({ version: z.string().optional() })))?.version ?? "development";
|
|
62
171
|
}
|
|
63
172
|
function processExists(pid) {
|
|
64
173
|
try {
|
|
@@ -68,31 +177,30 @@ function processExists(pid) {
|
|
|
68
177
|
return error.code === "EPERM";
|
|
69
178
|
}
|
|
70
179
|
}
|
|
71
|
-
async function
|
|
180
|
+
async function daemonHealth(apiUrl, timeoutMs = 1500) {
|
|
72
181
|
const signal = AbortSignal.timeout(timeoutMs);
|
|
73
182
|
return fetch(`${apiUrl}/api/health`, { signal }).then(async (response) => {
|
|
74
183
|
if (!response.ok) return null;
|
|
75
|
-
const
|
|
76
|
-
return
|
|
184
|
+
const result = healthRecordSchema.safeParse(await response.json());
|
|
185
|
+
return result.success ? result.data : null;
|
|
77
186
|
}).catch(() => null);
|
|
78
187
|
}
|
|
79
188
|
function matchesOwnership(state, observed) {
|
|
80
189
|
return observed.pid === state.pid && observed.instanceId === state.instanceId && path.resolve(state.dataDir) === localPaths().dataDir;
|
|
81
190
|
}
|
|
82
191
|
async function readState() {
|
|
83
|
-
|
|
84
|
-
return value && typeof value.pid === "number" && typeof value.instanceId === "string" && typeof value.apiUrl === "string" && typeof value.dataDir === "string" ? value : null;
|
|
192
|
+
return readJson(localPaths().statePath, daemonRecordSchema);
|
|
85
193
|
}
|
|
86
194
|
async function removeStaleState(state) {
|
|
87
195
|
const paths = localPaths();
|
|
88
|
-
for (const filePath of [paths.statePath, paths.lockPath]) if ((await readJson(filePath))?.instanceId === state.instanceId) await fs.rm(filePath, { force: true });
|
|
196
|
+
for (const filePath of [paths.statePath, paths.lockPath]) if ((await readJson(filePath, z.looseObject({ instanceId: z.string() })))?.instanceId === state.instanceId) await fs.rm(filePath, { force: true });
|
|
89
197
|
}
|
|
90
198
|
async function stopOwned(state) {
|
|
91
199
|
if (!processExists(state.pid)) {
|
|
92
200
|
await removeStaleState(state);
|
|
93
201
|
return;
|
|
94
202
|
}
|
|
95
|
-
const observed = await
|
|
203
|
+
const observed = await daemonHealth(state.apiUrl);
|
|
96
204
|
if (!observed || !matchesOwnership(state, observed)) throw new Error(`Refusing to stop PID ${state.pid}: Treeport could not verify ownership. Check ${localPaths().statePath}.`);
|
|
97
205
|
process.kill(state.pid, "SIGTERM");
|
|
98
206
|
const deadline = Date.now() + 7e3;
|
|
@@ -131,6 +239,168 @@ async function executableCheck(executable, args) {
|
|
|
131
239
|
}));
|
|
132
240
|
});
|
|
133
241
|
}
|
|
242
|
+
const tailscaleStatusResponseSchema = z.looseObject({
|
|
243
|
+
BackendState: z.string().optional(),
|
|
244
|
+
Self: z.looseObject({ DNSName: z.string().optional() }).optional()
|
|
245
|
+
});
|
|
246
|
+
const tailscaleServeConfigurationSchema = z.lazy(() => z.looseObject({
|
|
247
|
+
TCP: z.record(z.string(), z.looseObject({})).optional(),
|
|
248
|
+
Foreground: z.record(z.string(), tailscaleServeConfigurationSchema).optional(),
|
|
249
|
+
Web: z.record(z.string(), z.looseObject({ Handlers: z.record(z.string(), z.looseObject({ Proxy: z.string().optional() })).optional() })).optional()
|
|
250
|
+
}));
|
|
251
|
+
async function tailscale(args) {
|
|
252
|
+
return new Promise((resolve, reject) => {
|
|
253
|
+
const child = spawn("tailscale", args, { stdio: [
|
|
254
|
+
"ignore",
|
|
255
|
+
"pipe",
|
|
256
|
+
"pipe"
|
|
257
|
+
] });
|
|
258
|
+
let stdout = "";
|
|
259
|
+
let stderr = "";
|
|
260
|
+
child.stdout.setEncoding("utf8");
|
|
261
|
+
child.stderr.setEncoding("utf8");
|
|
262
|
+
child.stdout.on("data", (chunk) => {
|
|
263
|
+
stdout += chunk;
|
|
264
|
+
});
|
|
265
|
+
child.stderr.on("data", (chunk) => {
|
|
266
|
+
stderr += chunk;
|
|
267
|
+
});
|
|
268
|
+
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}`)));
|
|
269
|
+
child.once("close", (code) => {
|
|
270
|
+
if (code === 0) {
|
|
271
|
+
resolve(stdout);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
|
275
|
+
reject(/* @__PURE__ */ new Error(`Tailscale ${args[0]} failed${detail ? `: ${detail}` : ` (status ${code ?? 1})`}`));
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
function tailscaleJson(value, command, schema) {
|
|
280
|
+
const result = schema.safeParse(JSON.parse(value));
|
|
281
|
+
if (!result.success) throw new Error(`Tailscale ${command} returned an invalid JSON response`);
|
|
282
|
+
return result.data;
|
|
283
|
+
}
|
|
284
|
+
function remotePreference(value) {
|
|
285
|
+
if (value.remote === void 0) return null;
|
|
286
|
+
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");
|
|
287
|
+
return value.remote;
|
|
288
|
+
}
|
|
289
|
+
function localProxyTarget(apiUrl) {
|
|
290
|
+
if (!URL.canParse(apiUrl)) throw new Error("Treeport remote access requires a loopback daemon URL");
|
|
291
|
+
const url = new URL(apiUrl);
|
|
292
|
+
if (url.protocol !== "http:" || ![
|
|
293
|
+
"127.0.0.1",
|
|
294
|
+
"localhost",
|
|
295
|
+
"::1",
|
|
296
|
+
"[::1]"
|
|
297
|
+
].includes(url.hostname)) throw new Error("Treeport remote access requires a loopback daemon. Run `treeport up --host 127.0.0.1`, then try again.");
|
|
298
|
+
return `http://${url.host}`;
|
|
299
|
+
}
|
|
300
|
+
function portIsServed(config, port) {
|
|
301
|
+
const tcp = config.TCP;
|
|
302
|
+
if (tcp && Object.hasOwn(tcp, String(port))) return true;
|
|
303
|
+
return Object.values(config.Foreground ?? {}).some((value) => portIsServed(value, port));
|
|
304
|
+
}
|
|
305
|
+
function rootProxyForPort(config, port) {
|
|
306
|
+
for (const [hostPort, server] of Object.entries(config.Web ?? {})) {
|
|
307
|
+
if (!hostPort.endsWith(`:${port}`)) continue;
|
|
308
|
+
const proxy = server.Handlers?.["/"]?.Proxy;
|
|
309
|
+
if (proxy !== void 0) return proxy;
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
function proxyMatches(actual, expected) {
|
|
314
|
+
return actual !== null && expected !== void 0 && actual.replace(/\/$/, "") === expected.replace(/\/$/, "");
|
|
315
|
+
}
|
|
316
|
+
async function tailscaleServeConfig() {
|
|
317
|
+
return tailscaleJson(await tailscale([
|
|
318
|
+
"serve",
|
|
319
|
+
"status",
|
|
320
|
+
"--json"
|
|
321
|
+
]), "serve status", tailscaleServeConfigurationSchema);
|
|
322
|
+
}
|
|
323
|
+
async function tailscaleRemoteUrl(port) {
|
|
324
|
+
const status = tailscaleJson(await tailscale(["status", "--json"]), "status", tailscaleStatusResponseSchema);
|
|
325
|
+
if (status.BackendState !== "Running") throw new Error("Tailscale is not connected. Run `tailscale up` then try again.");
|
|
326
|
+
const dnsName = status.Self?.DNSName;
|
|
327
|
+
if (!dnsName?.trim()) throw new Error("Tailscale did not report a DNS name. Enable MagicDNS, then try again.");
|
|
328
|
+
return `https://${dnsName.trim().replace(/\.$/, "")}${port === 443 ? "" : `:${port}`}`;
|
|
329
|
+
}
|
|
330
|
+
async function enableTailscaleRemote(options) {
|
|
331
|
+
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");
|
|
332
|
+
const saved = await preferences();
|
|
333
|
+
const remote = remotePreference(saved);
|
|
334
|
+
const port = options.port ?? remote?.port ?? DEFAULT_PORT;
|
|
335
|
+
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.`);
|
|
336
|
+
const expectedTarget = localProxyTarget((await daemonStatus()).state?.apiUrl ?? await resolveLocalApiUrl());
|
|
337
|
+
const [url, config] = await Promise.all([tailscaleRemoteUrl(port), tailscaleServeConfig()]);
|
|
338
|
+
const existingTarget = rootProxyForPort(config, port);
|
|
339
|
+
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>\`.`);
|
|
340
|
+
const target = localProxyTarget((await daemonUp({})).apiUrl);
|
|
341
|
+
const alreadyEnabled = proxyMatches(existingTarget, target);
|
|
342
|
+
if (!alreadyEnabled) await tailscale([
|
|
343
|
+
"serve",
|
|
344
|
+
"--bg",
|
|
345
|
+
`--https=${port}`,
|
|
346
|
+
target
|
|
347
|
+
]);
|
|
348
|
+
await savePreferences({
|
|
349
|
+
...saved,
|
|
350
|
+
remote: {
|
|
351
|
+
port,
|
|
352
|
+
target
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
return {
|
|
356
|
+
alreadyEnabled,
|
|
357
|
+
port,
|
|
358
|
+
url
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
async function tailscaleRemoteStatus() {
|
|
362
|
+
const remote = remotePreference(await preferences());
|
|
363
|
+
if (!remote) return {
|
|
364
|
+
configured: false,
|
|
365
|
+
active: false,
|
|
366
|
+
port: null,
|
|
367
|
+
url: null
|
|
368
|
+
};
|
|
369
|
+
const [url, config] = await Promise.all([tailscaleRemoteUrl(remote.port), tailscaleServeConfig()]);
|
|
370
|
+
return {
|
|
371
|
+
configured: true,
|
|
372
|
+
active: proxyMatches(rootProxyForPort(config, remote.port), remote.target),
|
|
373
|
+
port: remote.port,
|
|
374
|
+
url
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
async function disableTailscaleRemote() {
|
|
378
|
+
const saved = await preferences();
|
|
379
|
+
const remote = remotePreference(saved);
|
|
380
|
+
if (!remote) return {
|
|
381
|
+
wasEnabled: false,
|
|
382
|
+
changedTailscale: false
|
|
383
|
+
};
|
|
384
|
+
if (proxyMatches(rootProxyForPort(await tailscaleServeConfig(), remote.port), remote.target)) {
|
|
385
|
+
await tailscale([
|
|
386
|
+
"serve",
|
|
387
|
+
`--https=${remote.port}`,
|
|
388
|
+
"off"
|
|
389
|
+
]);
|
|
390
|
+
delete saved.remote;
|
|
391
|
+
await savePreferences(saved);
|
|
392
|
+
return {
|
|
393
|
+
wasEnabled: true,
|
|
394
|
+
changedTailscale: true
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
delete saved.remote;
|
|
398
|
+
await savePreferences(saved);
|
|
399
|
+
return {
|
|
400
|
+
wasEnabled: false,
|
|
401
|
+
changedTailscale: false
|
|
402
|
+
};
|
|
403
|
+
}
|
|
134
404
|
async function runDoctor() {
|
|
135
405
|
const paths = localPaths();
|
|
136
406
|
const gitPath = process.env.TREEPORT_GIT_PATH?.trim() || "git";
|
|
@@ -191,7 +461,7 @@ async function daemonStatus() {
|
|
|
191
461
|
verified: false
|
|
192
462
|
};
|
|
193
463
|
}
|
|
194
|
-
const observed = await
|
|
464
|
+
const observed = await daemonHealth(state.apiUrl);
|
|
195
465
|
return {
|
|
196
466
|
running: Boolean(observed),
|
|
197
467
|
state,
|
|
@@ -204,20 +474,14 @@ async function daemonUp(options) {
|
|
|
204
474
|
const paths = localPaths();
|
|
205
475
|
const saved = await preferences();
|
|
206
476
|
const next = {
|
|
477
|
+
...saved,
|
|
207
478
|
host: options.host?.trim() || saved.host || DEFAULT_HOST,
|
|
208
479
|
port: options.port ?? saved.port ?? DEFAULT_PORT
|
|
209
480
|
};
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
});
|
|
215
|
-
const temporaryPath = `${paths.preferencesPath}.${process.pid}.tmp`;
|
|
216
|
-
await fs.writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 384 });
|
|
217
|
-
await fs.rename(temporaryPath, paths.preferencesPath);
|
|
218
|
-
}
|
|
219
|
-
const host = options.host?.trim() || process.env.TREEPORT_HOST?.trim() || next.host;
|
|
220
|
-
const port = Number.parseInt(options.port === void 0 ? process.env.TREEPORT_PORT?.trim() || String(next.port) : String(options.port), 10);
|
|
481
|
+
const host = options.host?.trim() || process.env.TREEPORT_HOST?.trim() || process.env.HOST?.trim() || next.host;
|
|
482
|
+
assertLoopbackHost(host);
|
|
483
|
+
if (options.host !== void 0 || options.port !== void 0) await savePreferences(next);
|
|
484
|
+
const port = Number.parseInt(options.port === void 0 ? process.env.TREEPORT_PORT?.trim() || process.env.PORT?.trim() || String(next.port) : String(options.port), 10);
|
|
221
485
|
const apiUrl = options.host !== void 0 || options.port !== void 0 ? listenerUrl(host, port) : process.env.TREEPORT_API_URL?.trim() || listenerUrl(host, port);
|
|
222
486
|
const currentVersion = await treeportVersion();
|
|
223
487
|
const existing = await daemonStatus();
|
|
@@ -287,7 +551,7 @@ async function daemonUp(options) {
|
|
|
287
551
|
fsSync.closeSync(log);
|
|
288
552
|
const deadline = Date.now() + 15e3;
|
|
289
553
|
while (Date.now() < deadline) {
|
|
290
|
-
const observed = await
|
|
554
|
+
const observed = await daemonHealth(apiUrl, 500);
|
|
291
555
|
if (observed && observed.pid === child.pid && observed.instanceId === instanceId && observed.version === currentVersion) return {
|
|
292
556
|
alreadyRunning: false,
|
|
293
557
|
apiUrl,
|
|
@@ -312,15 +576,23 @@ async function readDaemonLogs(lines = 100) {
|
|
|
312
576
|
})).split("\n").slice(-lines - 1).join("\n");
|
|
313
577
|
}
|
|
314
578
|
//#endregion
|
|
315
|
-
//#region src/cli/
|
|
316
|
-
const configuredApiUrl = process.env.TREEPORT_API_URL?.trim();
|
|
317
|
-
const apiUrl = (await resolveLocalApiUrl()).replace(/\/$/, "");
|
|
579
|
+
//#region src/cli/application.ts
|
|
318
580
|
const contextPrefix = "TREEPORT";
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
581
|
+
let configuredApiUrl;
|
|
582
|
+
let apiUrl = "";
|
|
583
|
+
let contextProjectId;
|
|
584
|
+
let contextWorktreeId;
|
|
585
|
+
let contextTerminalId;
|
|
586
|
+
let configuredDaemonLifecycle;
|
|
587
|
+
let jsonOutput = false;
|
|
588
|
+
let workingDirectory = process.cwd();
|
|
589
|
+
let writeStdout = (value) => {
|
|
590
|
+
process.stdout.write(value);
|
|
591
|
+
};
|
|
592
|
+
let writeStderr = (value) => {
|
|
593
|
+
process.stderr.write(value);
|
|
594
|
+
};
|
|
595
|
+
let requestedExitCode = 0;
|
|
324
596
|
var CliError = class extends Error {
|
|
325
597
|
exitCode;
|
|
326
598
|
code;
|
|
@@ -332,6 +604,11 @@ var CliError = class extends Error {
|
|
|
332
604
|
this.details = details;
|
|
333
605
|
}
|
|
334
606
|
};
|
|
607
|
+
async function resolveDaemonLifecycle() {
|
|
608
|
+
if (configuredDaemonLifecycle === "external") return "external";
|
|
609
|
+
if (configuredApiUrl) return (await daemonHealth(apiUrl))?.daemonLifecycle ?? "treeport";
|
|
610
|
+
return "treeport";
|
|
611
|
+
}
|
|
335
612
|
async function request(pathname, options = {}) {
|
|
336
613
|
const controller = new AbortController();
|
|
337
614
|
const externalSignal = options.signal;
|
|
@@ -363,6 +640,29 @@ async function request(pathname, options = {}) {
|
|
|
363
640
|
externalSignal?.removeEventListener("abort", abort);
|
|
364
641
|
}
|
|
365
642
|
}
|
|
643
|
+
async function createWorktree(projectId, input) {
|
|
644
|
+
let operation = (await request(`/api/projects/${encodeURIComponent(projectId)}/worktree-operations`, {
|
|
645
|
+
method: "POST",
|
|
646
|
+
body: JSON.stringify(input)
|
|
647
|
+
})).operation;
|
|
648
|
+
while (operation.status === "pending" || operation.status === "running") {
|
|
649
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
650
|
+
operation = (await request(`/api/operations/${encodeURIComponent(operation.id)}`)).operation;
|
|
651
|
+
}
|
|
652
|
+
if (operation.status === "failed") throw new CliError(operation.error ?? "Worktree creation failed", 5, "WORKTREE_CREATION_FAILED");
|
|
653
|
+
if (operation.kind !== "create") throw new CliError("Worktree creation returned an unexpected operation kind", 5, "INVALID_OPERATION_RESULT");
|
|
654
|
+
const worktreeId = typeof operation.result?.worktreeId === "string" ? operation.result.worktreeId : operation.worktreeId;
|
|
655
|
+
if (!worktreeId) throw new CliError("Completed worktree creation did not identify its worktree", 5, "INVALID_OPERATION_RESULT");
|
|
656
|
+
const worktree = (await request(`/api/projects/${encodeURIComponent(projectId)}`)).project.worktrees.find((item) => item.id === worktreeId);
|
|
657
|
+
if (!worktree) throw new CliError(`Created worktree ${worktreeId} was not found`, 5, "INVALID_OPERATION_RESULT");
|
|
658
|
+
const terminalId = typeof operation.result?.terminalId === "string" ? operation.result.terminalId : null;
|
|
659
|
+
return {
|
|
660
|
+
worktree,
|
|
661
|
+
terminal: worktree.terminals.find((item) => item.id === terminalId) ?? null,
|
|
662
|
+
terminalError: typeof operation.result?.terminalError === "string" ? operation.result.terminalError : null,
|
|
663
|
+
setupError: typeof operation.result?.setupError === "string" ? operation.result.setupError : null
|
|
664
|
+
};
|
|
665
|
+
}
|
|
366
666
|
function commandArgv(args) {
|
|
367
667
|
const separator = args.indexOf("--");
|
|
368
668
|
if (separator === -1) return;
|
|
@@ -372,7 +672,8 @@ function commandArgv(args) {
|
|
|
372
672
|
return argv;
|
|
373
673
|
}
|
|
374
674
|
async function canonical(value) {
|
|
375
|
-
|
|
675
|
+
const resolved = path.resolve(workingDirectory, value);
|
|
676
|
+
return fs.realpath(resolved).catch(() => resolved);
|
|
376
677
|
}
|
|
377
678
|
async function projects() {
|
|
378
679
|
return (await request("/api/projects")).projects;
|
|
@@ -394,6 +695,14 @@ async function resolveProject(identifier) {
|
|
|
394
695
|
if (!match) throw new CliError(`No registered project matches ${identifier}`, 5);
|
|
395
696
|
return match;
|
|
396
697
|
}
|
|
698
|
+
async function packageSource(value) {
|
|
699
|
+
if (value.startsWith("npm:")) return value;
|
|
700
|
+
if (path.isAbsolute(value) || value === "." || value === ".." || value.startsWith("./") || value.startsWith("../") || value === "~" || value.startsWith("~/")) return canonical(value);
|
|
701
|
+
return value;
|
|
702
|
+
}
|
|
703
|
+
async function localPackageProjectId() {
|
|
704
|
+
return (await request(`/api/packages/project?${new URLSearchParams({ path: await canonical(workingDirectory) }).toString()}`)).project.id;
|
|
705
|
+
}
|
|
397
706
|
async function resolveWorktree(identifier) {
|
|
398
707
|
const all = (await projects()).flatMap((project) => project.worktrees);
|
|
399
708
|
const direct = all.find((worktree) => worktree.id === identifier);
|
|
@@ -407,6 +716,43 @@ async function resolveWorktree(identifier) {
|
|
|
407
716
|
if (!match) throw new CliError(`No registered worktree matches ${identifier}`, 5);
|
|
408
717
|
return match;
|
|
409
718
|
}
|
|
719
|
+
function parseWebPanelInput(value) {
|
|
720
|
+
if (value === void 0) return null;
|
|
721
|
+
if (Buffer.byteLength(value) > 65536) throw new CliError("Web panel input is limited to 64 KiB", 2);
|
|
722
|
+
let parsed;
|
|
723
|
+
try {
|
|
724
|
+
parsed = JSON.parse(value);
|
|
725
|
+
} catch (error) {
|
|
726
|
+
throw new CliError(`--input must contain valid JSON: ${error instanceof Error ? error.message : String(error)}`, 2);
|
|
727
|
+
}
|
|
728
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new CliError("--input must contain a JSON object", 2);
|
|
729
|
+
return parsed;
|
|
730
|
+
}
|
|
731
|
+
async function webPanelDefinition(worktreeId, identifier) {
|
|
732
|
+
const definitions = (await request(`/api/worktrees/${encodeURIComponent(worktreeId)}/web-panel-definitions`)).definitions;
|
|
733
|
+
const exact = definitions.find((definition) => definition.id === identifier);
|
|
734
|
+
if (exact) return exact;
|
|
735
|
+
const matches = definitions.filter((definition) => decodeURIComponent(definition.id.split(":").at(-1) ?? "") === identifier);
|
|
736
|
+
if (matches.length === 1) return matches[0];
|
|
737
|
+
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) });
|
|
738
|
+
throw new CliError(`Web panel ${identifier} is not available in this worktree`, 5, "WEB_PANEL_DEFINITION_NOT_FOUND");
|
|
739
|
+
}
|
|
740
|
+
async function webPanelLaunchCwd(worktree) {
|
|
741
|
+
const [cwd, worktreeRoot] = await Promise.all([canonical(workingDirectory), canonical(worktree.path)]);
|
|
742
|
+
if (!pathContains(cwd, worktreeRoot)) throw new CliError(`The current directory is outside worktree ${worktree.name}`, 5, "INVALID_WEB_PANEL_LAUNCH_CWD", {
|
|
743
|
+
cwd,
|
|
744
|
+
worktreeId: worktree.id,
|
|
745
|
+
worktreePath: worktree.path
|
|
746
|
+
});
|
|
747
|
+
return path.relative(worktreeRoot, cwd) || ".";
|
|
748
|
+
}
|
|
749
|
+
function webPanelUrl(worktree, panelId) {
|
|
750
|
+
const target = new URL(apiUrl);
|
|
751
|
+
target.pathname = `/projects/${encodeURIComponent(worktree.projectId)}/worktrees/${encodeURIComponent(worktree.id)}/panels/${encodeURIComponent(panelId)}`;
|
|
752
|
+
target.search = "";
|
|
753
|
+
target.hash = "";
|
|
754
|
+
return target.href;
|
|
755
|
+
}
|
|
410
756
|
function resolveTerminalId(identifier) {
|
|
411
757
|
if (identifier !== ".") return identifier;
|
|
412
758
|
const terminalId = contextTerminalId;
|
|
@@ -423,16 +769,11 @@ function parseCaptureLines(value) {
|
|
|
423
769
|
return lines;
|
|
424
770
|
}
|
|
425
771
|
function parseDuration(value) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
m: 6e4,
|
|
432
|
-
h: 36e5
|
|
433
|
-
}[match[2]];
|
|
434
|
-
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2147483647) throw new CliError("Timeout must be between 1ms and 2147483647ms", 2);
|
|
435
|
-
return timeoutMs;
|
|
772
|
+
try {
|
|
773
|
+
return parseDurationMs(value);
|
|
774
|
+
} catch (error) {
|
|
775
|
+
throw new CliError(error instanceof Error ? error.message : String(error), 2);
|
|
776
|
+
}
|
|
436
777
|
}
|
|
437
778
|
async function inspectTerminal(terminalId, signal) {
|
|
438
779
|
return request(`/api/terminals/${encodeURIComponent(terminalId)}`, signal ? { signal } : {});
|
|
@@ -523,8 +864,8 @@ async function waitForTerminal(terminalId, condition, timeoutMs) {
|
|
|
523
864
|
condition
|
|
524
865
|
});
|
|
525
866
|
if (event.type === "terminal.metadata") {
|
|
526
|
-
const metadata =
|
|
527
|
-
if (!
|
|
867
|
+
const { worktreeId: _worktreeId, ...metadata } = event.data;
|
|
868
|
+
if (!observation) throw new CliError("Treeport daemon sent invalid terminal metadata", 3, "DAEMON_PROTOCOL_ERROR");
|
|
528
869
|
observation = {
|
|
529
870
|
...observation,
|
|
530
871
|
metadata
|
|
@@ -559,8 +900,7 @@ async function waitForTerminal(terminalId, condition, timeoutMs) {
|
|
|
559
900
|
}
|
|
560
901
|
}
|
|
561
902
|
function print(value, human) {
|
|
562
|
-
|
|
563
|
-
else console.log(human ? human() : JSON.stringify(value, null, 2));
|
|
903
|
+
writeStdout(`${jsonOutput ? JSON.stringify(value) : human ? human() : JSON.stringify(value, null, 2)}\n`);
|
|
564
904
|
}
|
|
565
905
|
const agentGuidance = `AI agents:
|
|
566
906
|
If you're an AI agent, use \`treeport skills\` to see the usage guide.
|
|
@@ -568,14 +908,59 @@ const agentGuidance = `AI agents:
|
|
|
568
908
|
async function main(args) {
|
|
569
909
|
const argv = args[0] === "spawn" || args[0] === "terminal" && args[1] === "create" ? commandArgv(args) : void 0;
|
|
570
910
|
let parserError = "";
|
|
571
|
-
const program = new Command().name("treeport").description("Manage Treeport projects, worktrees, and terminals.").option("--json", "emit machine-readable JSON").addHelpText("beforeAll", agentGuidance).configureOutput({
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
911
|
+
const program = new Command().name("treeport").usage("[options] [folder] [command]").description("Manage Treeport projects, worktrees, and terminals.").argument("[folder]", "folder inside a Git repository to open").option("--json", "emit machine-readable JSON").addHelpText("beforeAll", agentGuidance).configureOutput({
|
|
912
|
+
writeOut: writeStdout,
|
|
913
|
+
writeErr: (value) => {
|
|
914
|
+
parserError += value;
|
|
915
|
+
}
|
|
916
|
+
}).showHelpAfterError().exitOverride();
|
|
917
|
+
program.action(async (folder) => {
|
|
918
|
+
if (folder === void 0) {
|
|
919
|
+
writeStdout(program.helpInformation());
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
const absoluteFolder = path.resolve(workingDirectory, folder);
|
|
923
|
+
if (!(await fs.stat(absoluteFolder).catch((error) => {
|
|
924
|
+
if ((typeof error === "object" && error !== null && "code" in error ? error.code : void 0) === "ENOENT") throw new CliError(`Folder does not exist: ${absoluteFolder}`, 5, "FOLDER_NOT_FOUND", { path: absoluteFolder });
|
|
925
|
+
throw new CliError(`Cannot access folder ${absoluteFolder}: ${error instanceof Error ? error.message : String(error)}`, 5, "FOLDER_UNREADABLE", { path: absoluteFolder });
|
|
926
|
+
})).isDirectory()) throw new CliError(`Path is not a folder: ${absoluteFolder}`, 5, "FOLDER_NOT_DIRECTORY", { path: absoluteFolder });
|
|
927
|
+
const canonicalFolder = await fs.realpath(absoluteFolder).catch((error) => {
|
|
928
|
+
throw new CliError(`Cannot access folder ${absoluteFolder}: ${error instanceof Error ? error.message : String(error)}`, 5, "FOLDER_UNREADABLE", { path: absoluteFolder });
|
|
929
|
+
});
|
|
930
|
+
if (await resolveDaemonLifecycle() === "external") {
|
|
931
|
+
if (!await daemonHealth(apiUrl)) throw new CliError(`Cannot reach the externally managed Treeport daemon at ${apiUrl}. Start it through the process that owns its lifecycle and retry.`, 3, "DAEMON_UNREACHABLE");
|
|
932
|
+
} else await daemonUp({});
|
|
933
|
+
const registered = await request("/api/projects", {
|
|
934
|
+
method: "POST",
|
|
935
|
+
body: JSON.stringify({ path: canonicalFolder })
|
|
936
|
+
}).catch((error) => {
|
|
937
|
+
if (error instanceof CliError && error.code === "NOT_A_GIT_REPOSITORY") throw new CliError(`No Git repository contains ${canonicalFolder}.`, error.exitCode, error.code, error.details);
|
|
938
|
+
throw error;
|
|
939
|
+
});
|
|
940
|
+
const targetWorktree = registered.project.worktrees.filter((worktree) => !worktree.prunable && pathContains(canonicalFolder, worktree.path)).sort((left, right) => right.path.length - left.path.length)[0];
|
|
941
|
+
if (!targetWorktree) throw new CliError(`Git did not report an active worktree containing ${canonicalFolder}.`, 5, "WORKTREE_NOT_FOUND", {
|
|
942
|
+
path: canonicalFolder,
|
|
943
|
+
projectId: registered.project.id
|
|
944
|
+
});
|
|
945
|
+
const target = new URL(apiUrl);
|
|
946
|
+
target.pathname = `/projects/${encodeURIComponent(registered.project.id)}/worktrees/${encodeURIComponent(targetWorktree.id)}`;
|
|
947
|
+
target.search = "";
|
|
948
|
+
target.hash = "";
|
|
949
|
+
const opened = await openWorkspace(target.href).catch((error) => {
|
|
950
|
+
if (error instanceof OpenWorkspaceError) throw new CliError(error.message, 1, "OPEN_FAILED", { url: target.href });
|
|
951
|
+
throw error;
|
|
952
|
+
});
|
|
953
|
+
print({
|
|
954
|
+
projectId: registered.project.id,
|
|
955
|
+
worktreeId: targetWorktree.id,
|
|
956
|
+
path: canonicalFolder,
|
|
957
|
+
url: target.href,
|
|
958
|
+
client: opened.client
|
|
959
|
+
}, () => `Opened ${registered.project.name} / ${targetWorktree.name} in the ${opened.client === "desktop" ? "Treeport desktop app" : "browser"}\n${target.href}`);
|
|
576
960
|
});
|
|
577
|
-
const upCommand = program.command("up").description("Ensure the local Treeport daemon is running").option("--host <address>", "listener address").option("--port <port>", "listener port").option("--foreground", "run in the foreground").option("--json", "emit machine-readable JSON");
|
|
961
|
+
const upCommand = program.command("up").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");
|
|
578
962
|
upCommand.action(async () => {
|
|
963
|
+
if (await resolveDaemonLifecycle() === "external") throw new CliError("Cannot run `treeport up` because the daemon lifecycle is externally managed. Control the process that started Treeport instead.", 5, "DAEMON_LIFECYCLE_EXTERNAL");
|
|
579
964
|
const options = upCommand.opts();
|
|
580
965
|
const port = options.port === void 0 ? void 0 : Number(options.port);
|
|
581
966
|
const result = await daemonUp({
|
|
@@ -585,22 +970,43 @@ async function main(args) {
|
|
|
585
970
|
});
|
|
586
971
|
if (options.foreground) return;
|
|
587
972
|
print(result, () => `Treeport is up\n${result.apiUrl}`);
|
|
588
|
-
const listenerHost = new URL(result.apiUrl).hostname;
|
|
589
|
-
if (![
|
|
590
|
-
"127.0.0.1",
|
|
591
|
-
"::1",
|
|
592
|
-
"[::1]",
|
|
593
|
-
"localhost"
|
|
594
|
-
].includes(listenerHost)) process.stderr.write("Warning: Treeport has no authentication. Use only a trusted private network.\n");
|
|
595
973
|
});
|
|
596
974
|
const downCommand = program.command("down").description("Stop the local daemon and preserve terminal sessions").option("--terminate-terminals", "terminate every Treeport-owned tmux server").option("--force", "confirm termination of all terminals").option("--json", "emit machine-readable JSON");
|
|
597
975
|
downCommand.action(async () => {
|
|
976
|
+
if (await resolveDaemonLifecycle() === "external") throw new CliError("Cannot run `treeport down` because the daemon lifecycle is externally managed. Control the process that started Treeport instead.", 5, "DAEMON_LIFECYCLE_EXTERNAL");
|
|
598
977
|
const options = downCommand.opts();
|
|
599
978
|
if (options.terminateTerminals && !options.force) throw new CliError("Re-run with --terminate-terminals --force to confirm termination of every terminal.", 2);
|
|
600
979
|
if (options.terminateTerminals) await request("/api/admin/terminate-terminals", { method: "POST" });
|
|
601
980
|
const result = await daemonDown();
|
|
602
981
|
print(result, () => result.wasRunning ? "Treeport is down" : "Treeport is already down");
|
|
603
982
|
});
|
|
983
|
+
const remoteCommand = program.command("remote").description("Expose Treeport privately through Tailscale Serve");
|
|
984
|
+
remoteCommand.action(() => {
|
|
985
|
+
writeStdout(remoteCommand.helpInformation());
|
|
986
|
+
});
|
|
987
|
+
const remoteEnableCommand = remoteCommand.command("enable").description("Enable private HTTPS access through Tailscale").option("--port <port>", "Tailscale HTTPS port (default: 8733)").option("--json", "emit machine-readable JSON");
|
|
988
|
+
remoteEnableCommand.action(async () => {
|
|
989
|
+
if (await resolveDaemonLifecycle() === "external") throw new CliError("Cannot run `treeport remote enable` because the daemon lifecycle is externally managed. Configure remote access through the process that started Treeport instead.", 5, "DAEMON_LIFECYCLE_EXTERNAL");
|
|
990
|
+
const options = remoteEnableCommand.opts();
|
|
991
|
+
const port = options.port === void 0 ? void 0 : Number(options.port);
|
|
992
|
+
if (port !== void 0 && (!Number.isInteger(port) || port < 1 || port > 65535)) throw new CliError("--port must be an integer between 1 and 65535", 2);
|
|
993
|
+
const result = await enableTailscaleRemote(port === void 0 ? {} : { port });
|
|
994
|
+
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.`);
|
|
995
|
+
});
|
|
996
|
+
remoteCommand.command("status").description("Show Tailscale remote access status").option("--json", "emit machine-readable JSON").action(async () => {
|
|
997
|
+
const result = await tailscaleRemoteStatus();
|
|
998
|
+
print(result, () => {
|
|
999
|
+
if (!result.configured) return "Treeport remote access is disabled";
|
|
1000
|
+
return result.active ? `Treeport remote access is enabled\n${result.url}` : `Treeport remote access is unavailable\nExpected: ${result.url}\nThe Tailscale Serve route no longer points to Treeport.`;
|
|
1001
|
+
});
|
|
1002
|
+
});
|
|
1003
|
+
remoteCommand.command("disable").description("Disable Treeport Tailscale remote access").option("--json", "emit machine-readable JSON").action(async () => {
|
|
1004
|
+
const result = await disableTailscaleRemote();
|
|
1005
|
+
print(result, () => {
|
|
1006
|
+
if (result.changedTailscale) return "Treeport remote access is disabled";
|
|
1007
|
+
return result.wasEnabled ? "Treeport remote access is disabled" : "Treeport remote access was already disabled; the current Tailscale route was left unchanged.";
|
|
1008
|
+
});
|
|
1009
|
+
});
|
|
604
1010
|
program.command("status").description("Show local daemon status").option("--json", "emit machine-readable JSON").action(async () => {
|
|
605
1011
|
const status = await daemonStatus();
|
|
606
1012
|
const projectList = status.verified ? await projects() : [];
|
|
@@ -620,12 +1026,12 @@ async function main(args) {
|
|
|
620
1026
|
logsCommand.action(async () => {
|
|
621
1027
|
const lines = Number(logsCommand.opts().lines);
|
|
622
1028
|
if (!Number.isInteger(lines) || lines < 1 || lines > 1e4) throw new CliError("--lines must be an integer between 1 and 10000", 2);
|
|
623
|
-
|
|
1029
|
+
writeStdout(await readDaemonLogs(lines));
|
|
624
1030
|
});
|
|
625
1031
|
program.command("doctor").description("Diagnose local requirements and paths").option("--json", "emit machine-readable JSON").action(async () => {
|
|
626
1032
|
const checks = await runDoctor();
|
|
627
1033
|
print(checks, () => checks.map((check) => `${check.ok ? "ok" : "error"}\t${check.name}\t${check.detail}`).join("\n"));
|
|
628
|
-
if (checks.some((check) => !check.ok))
|
|
1034
|
+
if (checks.some((check) => !check.ok)) requestedExitCode = 1;
|
|
629
1035
|
});
|
|
630
1036
|
program.command("version").description("Show CLI and daemon versions").option("--json", "emit machine-readable JSON").action(async () => {
|
|
631
1037
|
const [cli, status] = await Promise.all([treeportVersion(), daemonStatus()]);
|
|
@@ -636,7 +1042,8 @@ async function main(args) {
|
|
|
636
1042
|
print(result, () => `CLI: ${result.cli}\nDaemon: ${result.daemon ?? "not running"}`);
|
|
637
1043
|
});
|
|
638
1044
|
program.command("skills").description("Print the Treeport usage guide for AI agents").action(async () => {
|
|
639
|
-
|
|
1045
|
+
const skill = await fs.readFile(await resolvePackagePath("skills", "treeport", "SKILL.md"), "utf8");
|
|
1046
|
+
writeStdout(await resolveDaemonLifecycle() === "external" ? skill.replace("\n# Treeport\n", "\n# Treeport\n\n> **Externally managed daemon lifecycle:** Do not run `treeport up`, `treeport down`, or `treeport remote enable`. The process that started Treeport owns startup, shutdown, remote exposure, and logs. Other Treeport commands continue to use the configured daemon normally.\n") : skill);
|
|
640
1047
|
});
|
|
641
1048
|
program.command("context").description("Show the current Treeport-managed terminal context").option("--json", "emit machine-readable JSON").action(async () => {
|
|
642
1049
|
const projectId = contextProjectId;
|
|
@@ -674,6 +1081,7 @@ async function main(args) {
|
|
|
674
1081
|
const context = {
|
|
675
1082
|
managed: true,
|
|
676
1083
|
apiUrl,
|
|
1084
|
+
daemonLifecycle: await resolveDaemonLifecycle(),
|
|
677
1085
|
project: {
|
|
678
1086
|
id: project.id,
|
|
679
1087
|
name: project.name,
|
|
@@ -690,8 +1098,7 @@ async function main(args) {
|
|
|
690
1098
|
head: worktree.head,
|
|
691
1099
|
branch: worktree.branch,
|
|
692
1100
|
detached: worktree.detached,
|
|
693
|
-
kind: worktree.kind
|
|
694
|
-
status: worktree.status
|
|
1101
|
+
kind: worktree.kind
|
|
695
1102
|
},
|
|
696
1103
|
terminal: {
|
|
697
1104
|
id: terminal.id,
|
|
@@ -701,7 +1108,62 @@ async function main(args) {
|
|
|
701
1108
|
exitCode: terminal.exitCode
|
|
702
1109
|
}
|
|
703
1110
|
};
|
|
704
|
-
print(context, () => `Treeport context\n\nProject: ${context.project.name} (${context.project.id})\nWorktree: ${context.worktree.name} (${context.worktree.id})\nPath: ${context.worktree.path}\nTerminal: ${context.terminal.name} (${context.terminal.id}) — ${context.terminal.status}\nAPI: ${context.apiUrl}`);
|
|
1111
|
+
print(context, () => `Treeport context\n\nProject: ${context.project.name} (${context.project.id})\nWorktree: ${context.worktree.name} (${context.worktree.id})\nPath: ${context.worktree.path}\nTerminal: ${context.terminal.name} (${context.terminal.id}) — ${context.terminal.status}\nAPI: ${context.apiUrl}\nLifecycle: ${context.daemonLifecycle === "external" ? "externally managed" : "managed by Treeport"}`);
|
|
1112
|
+
});
|
|
1113
|
+
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");
|
|
1114
|
+
installCommand.action(async (source) => {
|
|
1115
|
+
const options = installCommand.opts();
|
|
1116
|
+
const result = (await request("/api/packages/install", {
|
|
1117
|
+
method: "POST",
|
|
1118
|
+
body: JSON.stringify({
|
|
1119
|
+
source: await packageSource(source),
|
|
1120
|
+
...options.local ? { projectId: await localPackageProjectId() } : {}
|
|
1121
|
+
})
|
|
1122
|
+
})).result;
|
|
1123
|
+
print(result, () => `Installed ${result.source}${result.scope === "project" ? ` for project ${result.projectId}` : " globally"}`);
|
|
1124
|
+
});
|
|
1125
|
+
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");
|
|
1126
|
+
removePackageCommand.action(async (source) => {
|
|
1127
|
+
const options = removePackageCommand.opts();
|
|
1128
|
+
const result = (await request("/api/packages/remove", {
|
|
1129
|
+
method: "POST",
|
|
1130
|
+
body: JSON.stringify({
|
|
1131
|
+
source: await packageSource(source),
|
|
1132
|
+
...options.local ? { projectId: await localPackageProjectId() } : {}
|
|
1133
|
+
})
|
|
1134
|
+
})).result;
|
|
1135
|
+
print(result, () => `Removed ${result.source}`);
|
|
1136
|
+
});
|
|
1137
|
+
program.command("list").description("List configured Treeport packages").option("--json", "emit machine-readable JSON").action(async () => {
|
|
1138
|
+
const result = await request("/api/packages");
|
|
1139
|
+
print(result, () => {
|
|
1140
|
+
const lines = result.packages.map((pkg) => {
|
|
1141
|
+
return `${pkg.scope === "global" ? "global" : `project:${pkg.projectName ?? pkg.projectId}`}\t${pkg.source}\t${pkg.resources.webPanels} web panels, ${pkg.resources.terminalPresets} terminal presets`;
|
|
1142
|
+
});
|
|
1143
|
+
lines.push(...result.diagnostics.map((item) => `error\t${item.scope}\t${item.source ?? item.path ?? "settings"}\t${item.message}`));
|
|
1144
|
+
return lines.join("\n");
|
|
1145
|
+
});
|
|
1146
|
+
});
|
|
1147
|
+
const updatePackagesCommand = program.command("update").description("Explicitly update configured Treeport packages").argument("[source]", "one configured npm: source").option("--packages", "update every eligible configured package").option("--json", "emit machine-readable JSON");
|
|
1148
|
+
updatePackagesCommand.action(async (source) => {
|
|
1149
|
+
const options = updatePackagesCommand.opts();
|
|
1150
|
+
if (!source && !options.packages || source && options.packages) throw new CliError("Specify a package source or --packages. Bare `treeport update` is reserved for a future Treeport self-update.", 2);
|
|
1151
|
+
const results = (await request("/api/packages/update", {
|
|
1152
|
+
method: "POST",
|
|
1153
|
+
body: JSON.stringify(source ? { source: await packageSource(source) } : {})
|
|
1154
|
+
})).results;
|
|
1155
|
+
print(results, () => results.map((result) => `${result.status}\t${result.scope}\t${result.source ?? "packages"}${result.reason ? `\t${result.reason}` : ""}`).join("\n"));
|
|
1156
|
+
});
|
|
1157
|
+
const reloadCommand = program.command("reload").description("Reload package settings and resources without restarting").option("-l, --local", "reload only the registered project containing the current directory").option("--json", "emit machine-readable JSON");
|
|
1158
|
+
reloadCommand.action(async () => {
|
|
1159
|
+
const options = reloadCommand.opts();
|
|
1160
|
+
const result = await request("/api/packages/reload", {
|
|
1161
|
+
method: "POST",
|
|
1162
|
+
body: JSON.stringify(options.local ? { projectId: await localPackageProjectId() } : {})
|
|
1163
|
+
});
|
|
1164
|
+
print(result, () => {
|
|
1165
|
+
return [...result.results.map((item) => `Reloaded ${item.scope === "global" ? "global packages" : `project ${item.projectId}`}`), ...result.diagnostics.map((item) => `Error: ${item.source ?? item.path ?? item.scope}: ${item.message}`)].join("\n");
|
|
1166
|
+
});
|
|
705
1167
|
});
|
|
706
1168
|
const projectCommand = program.command("project").description("Register and list projects");
|
|
707
1169
|
projectCommand.action(() => {
|
|
@@ -726,20 +1188,17 @@ async function main(args) {
|
|
|
726
1188
|
worktreeListCommand.action(async () => {
|
|
727
1189
|
const { project: projectIdentifier } = worktreeListCommand.opts();
|
|
728
1190
|
const list = projectIdentifier ? (await resolveProject(projectIdentifier)).worktrees : (await projects()).flatMap((project) => project.worktrees);
|
|
729
|
-
print(list, () => list.map((worktree) => `${worktree.id}\t${worktree.name}\t${worktree.branch ?? `detached@${worktree.head.slice(0, 8)}`}\t${worktree.
|
|
1191
|
+
print(list, () => list.map((worktree) => `${worktree.id}\t${worktree.name}\t${worktree.branch ?? `detached@${worktree.head.slice(0, 8)}`}\t${worktree.path}`).join("\n"));
|
|
730
1192
|
});
|
|
731
1193
|
const worktreeCreateCommand = worktreeCommand.command("create").description("Create a linked worktree").requiredOption("--project <id-or-path>", "project to create from").requiredOption("--name <name>", "worktree name").option("--from-current", "base the worktree on the current worktree").option("--json", "emit machine-readable JSON");
|
|
732
1194
|
worktreeCreateCommand.action(async () => {
|
|
733
1195
|
const options = worktreeCreateCommand.opts();
|
|
734
1196
|
const project = await resolveProject(options.project);
|
|
735
1197
|
const sourceWorktreeId = options.fromCurrent ? (await resolveWorktree(".")).id : void 0;
|
|
736
|
-
const result = await
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
base: options.fromCurrent ? "current" : "default",
|
|
741
|
-
...sourceWorktreeId ? { sourceWorktreeId } : {}
|
|
742
|
-
})
|
|
1198
|
+
const result = await createWorktree(project.id, {
|
|
1199
|
+
name: options.name,
|
|
1200
|
+
base: options.fromCurrent ? "current" : "default",
|
|
1201
|
+
...sourceWorktreeId ? { sourceWorktreeId } : {}
|
|
743
1202
|
});
|
|
744
1203
|
print(result, () => `Created ${result.worktree.name} (${result.worktree.id})\n${result.worktree.path}${result.setupError ? `\nSetup error: ${result.setupError}` : ""}`);
|
|
745
1204
|
});
|
|
@@ -750,14 +1209,48 @@ async function main(args) {
|
|
|
750
1209
|
const preview = (await request(`/api/worktrees/${worktree.id}/remove-preview`)).preview;
|
|
751
1210
|
if (!preview.eligible) throw new CliError(preview.reasons.join("\n"), 5);
|
|
752
1211
|
if (preview.warnings.length && !confirmed) throw new CliError(`${preview.warnings.join("\n")}\nRe-run with --force to confirm removal.`, 5);
|
|
753
|
-
|
|
1212
|
+
let operation = (await request(`/api/worktrees/${worktree.id}/remove`, {
|
|
754
1213
|
method: "POST",
|
|
755
1214
|
body: JSON.stringify({
|
|
756
1215
|
confirmationToken: preview.confirmationToken,
|
|
757
1216
|
confirmDestructive: preview.warnings.length > 0
|
|
758
1217
|
})
|
|
1218
|
+
})).operation;
|
|
1219
|
+
while (operation.status === "pending" || operation.status === "running") {
|
|
1220
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1221
|
+
operation = (await request(`/api/operations/${encodeURIComponent(operation.id)}`)).operation;
|
|
1222
|
+
}
|
|
1223
|
+
if (operation.status === "failed") throw new CliError(operation.error ?? "Worktree removal failed", 5, "WORKTREE_REMOVAL_FAILED");
|
|
1224
|
+
if (operation.kind !== "remove" || !operation.result) throw new CliError("Worktree removal returned an unexpected operation result", 5, "INVALID_OPERATION_RESULT");
|
|
1225
|
+
print(operation.result, () => {
|
|
1226
|
+
const warning = operation.result?.cleanup.warning;
|
|
1227
|
+
return `Removed ${worktree.name} (${worktree.id})${warning ? `\nWarning: ${warning}` : ""}`;
|
|
1228
|
+
});
|
|
1229
|
+
});
|
|
1230
|
+
const webPanelCommand = program.command("web-panel").description("Open persistent web panels");
|
|
1231
|
+
webPanelCommand.action(() => {
|
|
1232
|
+
throw new CliError(webPanelCommand.helpInformation(), 2);
|
|
1233
|
+
});
|
|
1234
|
+
const webPanelOpenCommand = webPanelCommand.command("open").description("Create or reuse a web panel and request client navigation").argument("<definition>", "definition ID or unique short name").requiredOption("--worktree <id-or-path-or-dot>", "owning worktree").option("--input <json>", "structured panel input as a JSON object").option("--new", "create a separate panel instance").option("--json", "emit machine-readable JSON");
|
|
1235
|
+
webPanelOpenCommand.action(async (identifier) => {
|
|
1236
|
+
const options = webPanelOpenCommand.opts();
|
|
1237
|
+
const worktree = await resolveWorktree(options.worktree);
|
|
1238
|
+
const definition = await webPanelDefinition(worktree.id, identifier);
|
|
1239
|
+
const result = await request(`/api/worktrees/${encodeURIComponent(worktree.id)}/panels/open`, {
|
|
1240
|
+
method: "POST",
|
|
1241
|
+
body: JSON.stringify({
|
|
1242
|
+
definitionId: definition.id,
|
|
1243
|
+
input: parseWebPanelInput(options.input),
|
|
1244
|
+
launchCwd: await webPanelLaunchCwd(worktree),
|
|
1245
|
+
newInstance: options.new ?? false,
|
|
1246
|
+
sourceTerminalId: contextTerminalId ?? null
|
|
1247
|
+
})
|
|
759
1248
|
});
|
|
760
|
-
|
|
1249
|
+
const output = {
|
|
1250
|
+
...result,
|
|
1251
|
+
url: webPanelUrl(worktree, result.panel.id)
|
|
1252
|
+
};
|
|
1253
|
+
print(output, () => `${result.reused ? "Reused" : "Opened"} ${result.panel.title} (${result.panel.id})\n${output.url}`);
|
|
761
1254
|
});
|
|
762
1255
|
const terminalCommand = program.command("terminal").description("Manage persistent worktree terminals");
|
|
763
1256
|
terminalCommand.action(() => {
|
|
@@ -798,8 +1291,8 @@ async function main(args) {
|
|
|
798
1291
|
const capture = await request(`/api/terminals/${encodeURIComponent(terminalId)}/capture?lines=${lines}`);
|
|
799
1292
|
if (jsonOutput) print(capture);
|
|
800
1293
|
else {
|
|
801
|
-
|
|
802
|
-
if (capture.content && !capture.content.endsWith("\n"))
|
|
1294
|
+
writeStdout(capture.content);
|
|
1295
|
+
if (capture.content && !capture.content.endsWith("\n")) writeStdout("\n");
|
|
803
1296
|
}
|
|
804
1297
|
});
|
|
805
1298
|
const terminalWaitCommand = terminalCommand.command("wait").description("Wait for a terminal runtime condition").argument("<terminal-id-or-dot>", "terminal to observe").requiredOption("--until <idle|working|bell|exit>", "condition to wait for").option("--timeout <duration>", "maximum wait, such as 30s or 5m").option("--json", "emit machine-readable JSON");
|
|
@@ -826,16 +1319,14 @@ async function main(args) {
|
|
|
826
1319
|
const options = spawnCommand.opts();
|
|
827
1320
|
const project = await resolveProject(options.project);
|
|
828
1321
|
const sourceWorktreeId = options.fromCurrent ? (await resolveWorktree(".")).id : void 0;
|
|
829
|
-
const result = await
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
worktreeName: options.worktreeName,
|
|
1322
|
+
const result = await createWorktree(project.id, {
|
|
1323
|
+
name: options.worktreeName,
|
|
1324
|
+
base: options.fromCurrent ? "current" : "default",
|
|
1325
|
+
initialTerminal: {
|
|
834
1326
|
name: options.name,
|
|
835
|
-
base: options.fromCurrent ? "current" : "default",
|
|
836
|
-
...sourceWorktreeId ? { sourceWorktreeId } : {},
|
|
837
1327
|
...argv ? { argv } : {}
|
|
838
|
-
}
|
|
1328
|
+
},
|
|
1329
|
+
...sourceWorktreeId ? { sourceWorktreeId } : {}
|
|
839
1330
|
});
|
|
840
1331
|
print(result, () => `Created worktree ${result.worktree.name} (${result.worktree.id})\nPath: ${result.worktree.path}\n${result.terminal ? `Terminal: ${result.terminal.name} (${result.terminal.id}) — ${result.terminal.status}` : "Terminal: not created"}${result.setupError ? `\nSetup error: ${result.setupError}` : ""}${result.terminalError ? `\nTerminal error: ${result.terminalError}` : ""}`);
|
|
841
1332
|
});
|
|
@@ -849,17 +1340,37 @@ async function main(args) {
|
|
|
849
1340
|
throw error;
|
|
850
1341
|
}
|
|
851
1342
|
}
|
|
852
|
-
|
|
853
|
-
const
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
1343
|
+
async function runCliApplication(options) {
|
|
1344
|
+
const environment = options.environment ?? process.env;
|
|
1345
|
+
configuredApiUrl = environment.TREEPORT_API_URL?.trim();
|
|
1346
|
+
apiUrl = (await resolveLocalApiUrl(environment)).replace(/\/$/, "");
|
|
1347
|
+
contextProjectId = environment.TREEPORT_PROJECT_ID?.trim() || void 0;
|
|
1348
|
+
contextWorktreeId = environment.TREEPORT_WORKTREE_ID?.trim() || void 0;
|
|
1349
|
+
contextTerminalId = environment.TREEPORT_TERMINAL_ID?.trim() || void 0;
|
|
1350
|
+
configuredDaemonLifecycle = environment.TREEPORT_DAEMON_LIFECYCLE?.trim();
|
|
1351
|
+
jsonOutput = extractJsonOutput(options.args);
|
|
1352
|
+
workingDirectory = options.cwd ?? process.cwd();
|
|
1353
|
+
writeStdout = options.stdout ?? ((value) => process.stdout.write(value));
|
|
1354
|
+
writeStderr = options.stderr ?? ((value) => process.stderr.write(value));
|
|
1355
|
+
requestedExitCode = 0;
|
|
1356
|
+
try {
|
|
1357
|
+
await main([...options.args]);
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
const cliError = error instanceof CliError ? error : new CliError(error instanceof Error ? error.message : String(error), 1);
|
|
1360
|
+
if (jsonOutput) {
|
|
1361
|
+
const body = { error: {
|
|
1362
|
+
code: cliError.code,
|
|
1363
|
+
message: cliError.message,
|
|
1364
|
+
...cliError.details === void 0 ? {} : { details: cliError.details }
|
|
1365
|
+
} };
|
|
1366
|
+
writeStderr(`${JSON.stringify(body)}\n`);
|
|
1367
|
+
} else writeStderr(`${cliError.message}\n`);
|
|
1368
|
+
requestedExitCode = cliError.exitCode;
|
|
1369
|
+
}
|
|
1370
|
+
return requestedExitCode;
|
|
1371
|
+
}
|
|
1372
|
+
//#endregion
|
|
1373
|
+
//#region src/cli/index.ts
|
|
1374
|
+
process.exitCode = await runCliApplication({ args: process.argv.slice(2) });
|
|
864
1375
|
//#endregion
|
|
865
1376
|
export {};
|