@treeport/treeport 0.2.2 → 0.4.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 +2 -2
- package/bin/treeport.mjs +3 -1
- package/dist/{loopback-4XVZbAD1.js → loopback-D7k_J_Wl.js} +16 -11
- package/dist/node/cli/index.js +1257 -104
- package/dist/node/server/core/launcher.js +19 -2
- package/dist/node/server/index.js +469 -377
- package/dist/{shell-integration-7aBNr-p0.js → shell-integration-Be_c91lw.js} +17 -15
- package/dist/web/assets/{index-CUy0IkGL.js → index-DCtptjcH.js} +5 -5
- package/dist/web/assets/index-Wj0w0nWP.css +2 -0
- package/dist/web/index.html +2 -2
- package/dist/web/manifest.webmanifest +2 -2
- package/package.json +4 -4
- package/skills/treeport/SKILL.md +20 -20
- package/dist/web/assets/index-0q5frbNy.css +0 -2
package/dist/node/cli/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { A as
|
|
2
|
+
import { A as parseEventsSnapshot, M as SOCKET_IO_PATH, j as parseProductEvent, k as webPanelInputSchema, n as parseDurationMs, r as TERMINAL_CAPTURE_MAX_LINES, t as assertLoopbackHost } from "../../loopback-D7k_J_Wl.js";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { Command, CommanderError } from "commander";
|
|
@@ -7,9 +7,9 @@ import { io } from "socket.io-client";
|
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import crypto from "node:crypto";
|
|
10
|
-
import fsSync from "node:fs";
|
|
10
|
+
import fsSync, { constants } from "node:fs";
|
|
11
11
|
import os from "node:os";
|
|
12
|
-
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
13
|
//#region src/cli/args.ts
|
|
14
14
|
function extractJsonOutput(args) {
|
|
15
15
|
const separator = args.indexOf("--");
|
|
@@ -104,7 +104,7 @@ function localPaths(env = process.env) {
|
|
|
104
104
|
logPath: path.join(dataDir, "logs", "daemon.log")
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
|
-
async function readJson(filePath, schema) {
|
|
107
|
+
async function readJson$1(filePath, schema) {
|
|
108
108
|
return fs.readFile(filePath, "utf8").then((value) => schema.parse(JSON.parse(value))).catch(() => null);
|
|
109
109
|
}
|
|
110
110
|
const preferencesSchema = z.looseObject({
|
|
@@ -122,7 +122,12 @@ const daemonRecordSchema = z.strictObject({
|
|
|
122
122
|
apiUrl: z.string(),
|
|
123
123
|
dataDir: z.string(),
|
|
124
124
|
startedAt: z.string(),
|
|
125
|
-
installationMethod: z.string()
|
|
125
|
+
installationMethod: z.string(),
|
|
126
|
+
daemonLifecycle: z.enum([
|
|
127
|
+
"treeport",
|
|
128
|
+
"service",
|
|
129
|
+
"external"
|
|
130
|
+
])
|
|
126
131
|
});
|
|
127
132
|
const healthRecordSchema = z.strictObject({
|
|
128
133
|
ok: z.literal(true),
|
|
@@ -132,11 +137,15 @@ const healthRecordSchema = z.strictObject({
|
|
|
132
137
|
pid: z.number(),
|
|
133
138
|
instanceId: z.string().nullable(),
|
|
134
139
|
installationMethod: z.string(),
|
|
135
|
-
daemonLifecycle: z.enum([
|
|
140
|
+
daemonLifecycle: z.enum([
|
|
141
|
+
"treeport",
|
|
142
|
+
"service",
|
|
143
|
+
"external"
|
|
144
|
+
]),
|
|
136
145
|
url: z.string()
|
|
137
146
|
});
|
|
138
147
|
async function preferences(env = process.env) {
|
|
139
|
-
return await readJson(localPaths(env).preferencesPath, preferencesSchema) ?? {};
|
|
148
|
+
return await readJson$1(localPaths(env).preferencesPath, preferencesSchema) ?? {};
|
|
140
149
|
}
|
|
141
150
|
async function savePreferences(value) {
|
|
142
151
|
const paths = localPaths();
|
|
@@ -154,7 +163,7 @@ async function resolveLocalApiUrl(env = process.env) {
|
|
|
154
163
|
const daemonRecordPath = env.TREEPORT_DAEMON_RECORD?.trim();
|
|
155
164
|
if (explicit && explicit !== managedApiUrl) return explicit.replace(/\/$/, "");
|
|
156
165
|
if (managedApiUrl && daemonRecordPath) {
|
|
157
|
-
const record = await readJson(path.resolve(expandHome(daemonRecordPath)), daemonRecordSchema);
|
|
166
|
+
const record = await readJson$1(path.resolve(expandHome(daemonRecordPath)), daemonRecordSchema);
|
|
158
167
|
if (record) return record.apiUrl.replace(/\/$/, "");
|
|
159
168
|
}
|
|
160
169
|
if (explicit) return explicit.replace(/\/$/, "");
|
|
@@ -167,7 +176,7 @@ async function resolvePackagePath(...segments) {
|
|
|
167
176
|
throw new Error("Could not locate the Treeport package directory");
|
|
168
177
|
}
|
|
169
178
|
async function treeportVersion() {
|
|
170
|
-
return (await readJson(await resolvePackagePath("package.json"), z.looseObject({ version: z.string().optional() })))?.version ?? "development";
|
|
179
|
+
return (await readJson$1(await resolvePackagePath("package.json"), z.looseObject({ version: z.string().optional() })))?.version ?? "development";
|
|
171
180
|
}
|
|
172
181
|
function processExists(pid) {
|
|
173
182
|
try {
|
|
@@ -189,11 +198,11 @@ function matchesOwnership(state, observed) {
|
|
|
189
198
|
return observed.pid === state.pid && observed.instanceId === state.instanceId && path.resolve(state.dataDir) === localPaths().dataDir;
|
|
190
199
|
}
|
|
191
200
|
async function readState() {
|
|
192
|
-
return readJson(localPaths().statePath, daemonRecordSchema);
|
|
201
|
+
return readJson$1(localPaths().statePath, daemonRecordSchema);
|
|
193
202
|
}
|
|
194
203
|
async function removeStaleState(state) {
|
|
195
204
|
const paths = localPaths();
|
|
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 });
|
|
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 });
|
|
197
206
|
}
|
|
198
207
|
async function stopOwned(state) {
|
|
199
208
|
if (!processExists(state.pid)) {
|
|
@@ -294,7 +303,7 @@ function localProxyTarget(apiUrl) {
|
|
|
294
303
|
"localhost",
|
|
295
304
|
"::1",
|
|
296
305
|
"[::1]"
|
|
297
|
-
].includes(url.hostname)) throw new Error("Treeport remote access requires a loopback daemon. Run `treeport
|
|
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.");
|
|
298
307
|
return `http://${url.host}`;
|
|
299
308
|
}
|
|
300
309
|
function portIsServed(config, port) {
|
|
@@ -337,7 +346,7 @@ async function enableTailscaleRemote(options) {
|
|
|
337
346
|
const [url, config] = await Promise.all([tailscaleRemoteUrl(port), tailscaleServeConfig()]);
|
|
338
347
|
const existingTarget = rootProxyForPort(config, port);
|
|
339
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>\`.`);
|
|
340
|
-
const target = localProxyTarget((await daemonUp({})).apiUrl);
|
|
349
|
+
const target = localProxyTarget((options.daemon ?? await daemonUp({})).apiUrl);
|
|
341
350
|
const alreadyEnabled = proxyMatches(existingTarget, target);
|
|
342
351
|
if (!alreadyEnabled) await tailscale([
|
|
343
352
|
"serve",
|
|
@@ -518,6 +527,7 @@ async function daemonUp(options) {
|
|
|
518
527
|
TREEPORT_APP_VERSION: currentVersion,
|
|
519
528
|
TREEPORT_INSTANCE_ID: instanceId,
|
|
520
529
|
TREEPORT_INSTALLATION_METHOD: process.env.TREEPORT_INSTALLATION_METHOD?.trim() || "npm",
|
|
530
|
+
TREEPORT_DAEMON_LIFECYCLE: "treeport",
|
|
521
531
|
TREEPORT_WEB_DIST: webDist
|
|
522
532
|
};
|
|
523
533
|
if (options.foreground) {
|
|
@@ -576,6 +586,1064 @@ async function readDaemonLogs(lines = 100) {
|
|
|
576
586
|
})).split("\n").slice(-lines - 1).join("\n");
|
|
577
587
|
}
|
|
578
588
|
//#endregion
|
|
589
|
+
//#region src/cli/service.ts
|
|
590
|
+
const serviceRecordSchema = z.strictObject({
|
|
591
|
+
schemaVersion: z.literal(1),
|
|
592
|
+
manager: z.enum(["launchd", "systemd"]),
|
|
593
|
+
platform: z.string(),
|
|
594
|
+
uid: z.number().int().nonnegative(),
|
|
595
|
+
gid: z.number().int().nonnegative(),
|
|
596
|
+
username: z.string().min(1),
|
|
597
|
+
group: z.string().min(1),
|
|
598
|
+
home: z.string().min(1),
|
|
599
|
+
dataDir: z.string().min(1),
|
|
600
|
+
runtimeDir: z.string().min(1),
|
|
601
|
+
logPath: z.string().min(1),
|
|
602
|
+
apiUrl: z.string().min(1),
|
|
603
|
+
cliEntrypoint: z.string().min(1),
|
|
604
|
+
runtimeExecutable: z.string().min(1).nullable().default(null),
|
|
605
|
+
runtimeEntrypoint: z.string().min(1).nullable().default(null),
|
|
606
|
+
installationMethod: z.enum(["curl", "npm"]),
|
|
607
|
+
definitionName: z.string().min(1),
|
|
608
|
+
definitionPath: z.string().min(1),
|
|
609
|
+
definitionHash: z.string().length(64),
|
|
610
|
+
environmentHash: z.string().length(64),
|
|
611
|
+
environment: z.record(z.string(), z.string()),
|
|
612
|
+
requestedState: z.enum(["running", "stopped"]),
|
|
613
|
+
pendingAdministratorRequestId: z.string().nullable(),
|
|
614
|
+
createdAt: z.string(),
|
|
615
|
+
updatedAt: z.string()
|
|
616
|
+
});
|
|
617
|
+
const administratorRequestSchema = z.strictObject({
|
|
618
|
+
schemaVersion: z.literal(1),
|
|
619
|
+
id: z.string().uuid(),
|
|
620
|
+
operation: z.enum([
|
|
621
|
+
"enable",
|
|
622
|
+
"start",
|
|
623
|
+
"stop",
|
|
624
|
+
"disable"
|
|
625
|
+
]),
|
|
626
|
+
createdAt: z.string(),
|
|
627
|
+
expiresAt: z.string(),
|
|
628
|
+
uid: z.number().int().nonnegative(),
|
|
629
|
+
gid: z.number().int().nonnegative(),
|
|
630
|
+
username: z.string().min(1),
|
|
631
|
+
group: z.string().min(1),
|
|
632
|
+
home: z.string().min(1),
|
|
633
|
+
serviceRecordPath: z.string().min(1),
|
|
634
|
+
runnerPath: z.string().min(1),
|
|
635
|
+
definitionName: z.string().min(1),
|
|
636
|
+
definitionPath: z.string().min(1),
|
|
637
|
+
stagedDefinitionPath: z.string().min(1),
|
|
638
|
+
definitionHash: z.string().length(64),
|
|
639
|
+
apiUrl: z.string().min(1),
|
|
640
|
+
cliEntrypoint: z.string().min(1),
|
|
641
|
+
runtimeExecutable: z.string().min(1),
|
|
642
|
+
runtimeEntrypoint: z.string().min(1)
|
|
643
|
+
});
|
|
644
|
+
function managerForPlatform(platform = process.platform) {
|
|
645
|
+
return platform === "darwin" ? "launchd" : platform === "linux" ? "systemd" : null;
|
|
646
|
+
}
|
|
647
|
+
function servicePaths(env = process.env) {
|
|
648
|
+
const paths = localPaths(env);
|
|
649
|
+
const directory = path.join(paths.dataDir, "service");
|
|
650
|
+
return {
|
|
651
|
+
directory,
|
|
652
|
+
recordPath: path.join(directory, "service.json"),
|
|
653
|
+
runnerPath: path.join(directory, "run"),
|
|
654
|
+
requestsDirectory: path.join(directory, "requests"),
|
|
655
|
+
stagedDefinitionPath: path.join(directory, "treeport.plist")
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
async function readJson(filePath, schema) {
|
|
659
|
+
return fs.readFile(filePath, "utf8").then((value) => schema.parse(JSON.parse(value))).catch(() => null);
|
|
660
|
+
}
|
|
661
|
+
async function writeJson(filePath, value) {
|
|
662
|
+
await fs.mkdir(path.dirname(filePath), {
|
|
663
|
+
recursive: true,
|
|
664
|
+
mode: 448
|
|
665
|
+
});
|
|
666
|
+
const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
667
|
+
await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
|
|
668
|
+
await fs.rename(temporaryPath, filePath);
|
|
669
|
+
}
|
|
670
|
+
function fingerprint(value) {
|
|
671
|
+
const parsed = z.string().safeParse(value);
|
|
672
|
+
const source = parsed.success ? parsed.data : JSON.stringify(Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))));
|
|
673
|
+
return crypto.createHash("sha256").update(source).digest("hex");
|
|
674
|
+
}
|
|
675
|
+
function xml(value) {
|
|
676
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
677
|
+
}
|
|
678
|
+
function shellQuote(value) {
|
|
679
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
680
|
+
}
|
|
681
|
+
function createAdministratorCommand(input) {
|
|
682
|
+
return `sudo ${input.installationMethod === "curl" ? shellQuote(input.cliEntrypoint) : `${shellQuote(input.runtimeExecutable)} ${shellQuote(input.runtimeEntrypoint)}`} service apply --request ${shellQuote(input.requestPath)}`;
|
|
683
|
+
}
|
|
684
|
+
function systemdValue(value) {
|
|
685
|
+
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("%", "%%").replaceAll("\n", "\\n");
|
|
686
|
+
}
|
|
687
|
+
function createLaunchdDefinition(input) {
|
|
688
|
+
return {
|
|
689
|
+
label: input.label,
|
|
690
|
+
programArguments: [input.runnerPath],
|
|
691
|
+
username: input.username,
|
|
692
|
+
group: input.group,
|
|
693
|
+
environment: input.environment,
|
|
694
|
+
workingDirectory: input.home,
|
|
695
|
+
standardOutPath: input.logPath,
|
|
696
|
+
standardErrorPath: input.logPath,
|
|
697
|
+
keepAlive: true,
|
|
698
|
+
processType: "Background",
|
|
699
|
+
throttleInterval: 10,
|
|
700
|
+
exitTimeOut: 10,
|
|
701
|
+
abandonProcessGroup: true,
|
|
702
|
+
umask: 63
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
function serializeLaunchdDefinition(definition) {
|
|
706
|
+
const environment = Object.entries(definition.environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, value]) => ` <key>${xml(name)}</key>\n <string>${xml(value)}</string>`).join("\n");
|
|
707
|
+
const argumentsXml = definition.programArguments.map((argument) => ` <string>${xml(argument)}</string>`).join("\n");
|
|
708
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
709
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
710
|
+
<plist version="1.0">
|
|
711
|
+
<dict>
|
|
712
|
+
<key>Label</key>
|
|
713
|
+
<string>${xml(definition.label)}</string>
|
|
714
|
+
<key>ProgramArguments</key>
|
|
715
|
+
<array>
|
|
716
|
+
${argumentsXml}
|
|
717
|
+
</array>
|
|
718
|
+
<key>UserName</key>
|
|
719
|
+
<string>${xml(definition.username)}</string>
|
|
720
|
+
<key>GroupName</key>
|
|
721
|
+
<string>${xml(definition.group)}</string>
|
|
722
|
+
<key>EnvironmentVariables</key>
|
|
723
|
+
<dict>
|
|
724
|
+
${environment}
|
|
725
|
+
</dict>
|
|
726
|
+
<key>WorkingDirectory</key>
|
|
727
|
+
<string>${xml(definition.workingDirectory)}</string>
|
|
728
|
+
<key>StandardOutPath</key>
|
|
729
|
+
<string>${xml(definition.standardOutPath)}</string>
|
|
730
|
+
<key>StandardErrorPath</key>
|
|
731
|
+
<string>${xml(definition.standardErrorPath)}</string>
|
|
732
|
+
<key>KeepAlive</key>
|
|
733
|
+
<true/>
|
|
734
|
+
<key>ProcessType</key>
|
|
735
|
+
<string>${definition.processType}</string>
|
|
736
|
+
<key>ThrottleInterval</key>
|
|
737
|
+
<integer>${definition.throttleInterval}</integer>
|
|
738
|
+
<key>ExitTimeOut</key>
|
|
739
|
+
<integer>${definition.exitTimeOut}</integer>
|
|
740
|
+
<key>AbandonProcessGroup</key>
|
|
741
|
+
<true/>
|
|
742
|
+
<key>Umask</key>
|
|
743
|
+
<integer>${definition.umask}</integer>
|
|
744
|
+
</dict>
|
|
745
|
+
</plist>
|
|
746
|
+
`;
|
|
747
|
+
}
|
|
748
|
+
function createSystemdDefinition(input) {
|
|
749
|
+
return {
|
|
750
|
+
description: "Treeport daemon",
|
|
751
|
+
execStart: input.runnerPath,
|
|
752
|
+
environment: input.environment,
|
|
753
|
+
restart: "always",
|
|
754
|
+
restartSeconds: 5,
|
|
755
|
+
timeoutStopSeconds: 10,
|
|
756
|
+
killMode: "process",
|
|
757
|
+
wantedBy: "default.target"
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
function serializeSystemdDefinition(definition) {
|
|
761
|
+
const environment = Object.entries(definition.environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, value]) => `Environment="${systemdValue(name)}=${systemdValue(value)}"`).join("\n");
|
|
762
|
+
return `[Unit]
|
|
763
|
+
Description=${definition.description}
|
|
764
|
+
|
|
765
|
+
[Service]
|
|
766
|
+
Type=simple
|
|
767
|
+
ExecStart="${systemdValue(definition.execStart)}"
|
|
768
|
+
${environment}
|
|
769
|
+
Restart=${definition.restart}
|
|
770
|
+
RestartSec=${definition.restartSeconds}
|
|
771
|
+
TimeoutStopSec=${definition.timeoutStopSeconds}
|
|
772
|
+
KillMode=${definition.killMode}
|
|
773
|
+
|
|
774
|
+
[Install]
|
|
775
|
+
WantedBy=${definition.wantedBy}
|
|
776
|
+
`;
|
|
777
|
+
}
|
|
778
|
+
async function runCommand(executable, args, environment = process.env) {
|
|
779
|
+
return new Promise((resolve) => {
|
|
780
|
+
const child = spawn(executable, args, {
|
|
781
|
+
env: environment,
|
|
782
|
+
stdio: [
|
|
783
|
+
"ignore",
|
|
784
|
+
"pipe",
|
|
785
|
+
"pipe"
|
|
786
|
+
]
|
|
787
|
+
});
|
|
788
|
+
let stdout = "";
|
|
789
|
+
let stderr = "";
|
|
790
|
+
child.stdout.setEncoding("utf8");
|
|
791
|
+
child.stderr.setEncoding("utf8");
|
|
792
|
+
child.stdout.on("data", (value) => {
|
|
793
|
+
stdout += value;
|
|
794
|
+
});
|
|
795
|
+
child.stderr.on("data", (value) => {
|
|
796
|
+
stderr += value;
|
|
797
|
+
});
|
|
798
|
+
child.once("error", (error) => {
|
|
799
|
+
resolve({
|
|
800
|
+
code: 127,
|
|
801
|
+
stdout,
|
|
802
|
+
stderr: error.message
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
child.once("close", (code) => {
|
|
806
|
+
resolve({
|
|
807
|
+
code: code ?? 1,
|
|
808
|
+
stdout,
|
|
809
|
+
stderr
|
|
810
|
+
});
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
function commandError(command, result) {
|
|
815
|
+
const detail = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n");
|
|
816
|
+
return /* @__PURE__ */ new Error(`${command} failed${detail ? `: ${detail}` : ` with status ${result.code}`}`);
|
|
817
|
+
}
|
|
818
|
+
async function executablePath(name) {
|
|
819
|
+
const candidates = name === "launchctl" ? ["/bin/launchctl", "/usr/bin/launchctl"] : [`/usr/bin/${name}`, `/bin/${name}`];
|
|
820
|
+
for (const candidate of candidates) if (await fs.access(candidate, constants.X_OK).then(() => true).catch(() => false)) return candidate;
|
|
821
|
+
return name;
|
|
822
|
+
}
|
|
823
|
+
async function primaryGroup(username) {
|
|
824
|
+
const result = await runCommand(await executablePath("id"), ["-gn", username]);
|
|
825
|
+
if (result.code !== 0 || !result.stdout.trim()) throw commandError("id -gn", result);
|
|
826
|
+
return result.stdout.trim();
|
|
827
|
+
}
|
|
828
|
+
function currentEntrypoint() {
|
|
829
|
+
const value = process.env.TREEPORT_CLI_ENTRYPOINT?.trim() || process.argv[1]?.trim();
|
|
830
|
+
return value ? path.resolve(value) : null;
|
|
831
|
+
}
|
|
832
|
+
async function ensureEntrypoint(installationMethod) {
|
|
833
|
+
const entrypoint = currentEntrypoint();
|
|
834
|
+
if (!entrypoint) throw new Error("Treeport could not identify a stable CLI entrypoint. Install Treeport with npm or the curl installer, then retry.");
|
|
835
|
+
await fs.access(entrypoint, constants.X_OK).catch(() => {
|
|
836
|
+
throw new Error(`Treeport cannot execute its stable CLI entrypoint at ${entrypoint}. Reinstall Treeport, then retry.`);
|
|
837
|
+
});
|
|
838
|
+
if (installationMethod === "npm") {
|
|
839
|
+
const [actual, expected] = await Promise.all([fs.realpath(entrypoint), fs.realpath(await resolvePackagePath("bin", "treeport.mjs"))]);
|
|
840
|
+
if (actual !== expected) throw new Error(`The current CLI entrypoint is not the installed Treeport npm bin: ${entrypoint}`);
|
|
841
|
+
}
|
|
842
|
+
return entrypoint;
|
|
843
|
+
}
|
|
844
|
+
async function currentAdministratorRuntime() {
|
|
845
|
+
const invokedEntrypoint = process.argv[1]?.trim();
|
|
846
|
+
if (!invokedEntrypoint) throw new Error("Treeport could not identify its Node entrypoint.");
|
|
847
|
+
const runtimeEntrypoint = path.resolve(invokedEntrypoint);
|
|
848
|
+
const [runtimeExecutable, actualEntrypoint, packageBinEntrypoint, packageCliEntrypoint] = await Promise.all([
|
|
849
|
+
fs.realpath(process.execPath),
|
|
850
|
+
fs.realpath(runtimeEntrypoint),
|
|
851
|
+
fs.realpath(await resolvePackagePath("bin", "treeport.mjs")),
|
|
852
|
+
fs.realpath(await resolvePackagePath("dist", "node", "cli", "index.js"))
|
|
853
|
+
]);
|
|
854
|
+
if (actualEntrypoint !== packageBinEntrypoint && actualEntrypoint !== packageCliEntrypoint) throw new Error(`Treeport cannot use an unrecognized package entrypoint for administrator commands: ${runtimeEntrypoint}`);
|
|
855
|
+
await Promise.all([fs.access(runtimeExecutable, constants.X_OK), fs.access(runtimeEntrypoint, constants.R_OK)]);
|
|
856
|
+
return {
|
|
857
|
+
runtimeExecutable,
|
|
858
|
+
runtimeEntrypoint
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
function cacheDirectory(home, env) {
|
|
862
|
+
const configured = env.TREEPORT_CACHE_DIR?.trim();
|
|
863
|
+
if (configured) return path.resolve(configured.replace(/^~(?=\/|$)/, home));
|
|
864
|
+
if (env.XDG_CACHE_HOME?.trim()) return path.join(path.resolve(env.XDG_CACHE_HOME.replace(/^~(?=\/|$)/, home)), "treeport");
|
|
865
|
+
return process.platform === "darwin" ? path.join(home, "Library", "Caches", "treeport") : path.join(home, ".cache", "treeport");
|
|
866
|
+
}
|
|
867
|
+
function createServiceEnvironment(input) {
|
|
868
|
+
const env = input.env ?? process.env;
|
|
869
|
+
const url = new URL(input.apiUrl);
|
|
870
|
+
assertLoopbackHost(url.hostname);
|
|
871
|
+
const result = {
|
|
872
|
+
HOME: input.user.homedir,
|
|
873
|
+
USER: input.user.username,
|
|
874
|
+
LOGNAME: input.user.username,
|
|
875
|
+
PATH: env.PATH?.trim() || "/usr/local/bin:/usr/bin:/bin",
|
|
876
|
+
TREEPORT_HOST: url.hostname,
|
|
877
|
+
TREEPORT_PORT: url.port || "80",
|
|
878
|
+
TREEPORT_API_URL: input.apiUrl,
|
|
879
|
+
TREEPORT_DATA_DIR: input.paths.dataDir,
|
|
880
|
+
TREEPORT_RUNTIME_DIR: input.paths.runtimeDir,
|
|
881
|
+
TREEPORT_CACHE_DIR: cacheDirectory(input.user.homedir, env),
|
|
882
|
+
TREEPORT_DATABASE_PATH: env.TREEPORT_DATABASE_PATH?.trim() || path.join(input.paths.dataDir, "treeport.db"),
|
|
883
|
+
TREEPORT_SHELL: env.TREEPORT_SHELL?.trim() || env.SHELL?.trim() || "/bin/sh",
|
|
884
|
+
TREEPORT_TMUX_PATH: env.TREEPORT_TMUX_PATH?.trim() || "tmux",
|
|
885
|
+
TREEPORT_GIT_PATH: env.TREEPORT_GIT_PATH?.trim() || "git",
|
|
886
|
+
TREEPORT_GH_PATH: env.TREEPORT_GH_PATH?.trim() || "gh",
|
|
887
|
+
TREEPORT_DAEMON_LIFECYCLE: "service",
|
|
888
|
+
TREEPORT_INSTALLATION_METHOD: input.installationMethod,
|
|
889
|
+
TREEPORT_SERVICE_RECORD: input.recordPath
|
|
890
|
+
};
|
|
891
|
+
for (const [name, value] of Object.entries(env)) if (value !== void 0 && (name === "LANG" || name === "LC_ALL" || name.startsWith("LC_"))) result[name] = value;
|
|
892
|
+
return result;
|
|
893
|
+
}
|
|
894
|
+
function definitionForRecord(record) {
|
|
895
|
+
if (record.manager === "launchd") return serializeLaunchdDefinition(createLaunchdDefinition({
|
|
896
|
+
label: record.definitionName,
|
|
897
|
+
runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath,
|
|
898
|
+
username: record.username,
|
|
899
|
+
group: record.group,
|
|
900
|
+
environment: record.environment,
|
|
901
|
+
home: record.home,
|
|
902
|
+
logPath: record.logPath
|
|
903
|
+
}));
|
|
904
|
+
return serializeSystemdDefinition(createSystemdDefinition({
|
|
905
|
+
runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath,
|
|
906
|
+
environment: record.environment
|
|
907
|
+
}));
|
|
908
|
+
}
|
|
909
|
+
function runnerSource(record) {
|
|
910
|
+
return `#!/bin/sh
|
|
911
|
+
set -u
|
|
912
|
+
entrypoint=${shellQuote(record.cliEntrypoint)}
|
|
913
|
+
record=${shellQuote(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).recordPath)}
|
|
914
|
+
log=${shellQuote(record.logPath)}
|
|
915
|
+
reported=0
|
|
916
|
+
while [ ! -x "$entrypoint" ]; do
|
|
917
|
+
if [ "$reported" -eq 0 ]; then
|
|
918
|
+
mkdir -p "$(dirname "$log")"
|
|
919
|
+
printf '%s Treeport service cannot start because %s is missing. Reinstall Treeport, then run treeport service enable or treeport service disable.\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$entrypoint" >> "$log"
|
|
920
|
+
reported=1
|
|
921
|
+
fi
|
|
922
|
+
sleep 60
|
|
923
|
+
done
|
|
924
|
+
export TREEPORT_SERVICE_RECORD="$record"
|
|
925
|
+
exec "$entrypoint" service run
|
|
926
|
+
`;
|
|
927
|
+
}
|
|
928
|
+
async function currentRecord() {
|
|
929
|
+
return readJson(servicePaths().recordPath, serviceRecordSchema);
|
|
930
|
+
}
|
|
931
|
+
async function saveRecord(record) {
|
|
932
|
+
await writeJson(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).recordPath, record);
|
|
933
|
+
}
|
|
934
|
+
async function managerState(record) {
|
|
935
|
+
if (record.manager === "launchd") {
|
|
936
|
+
const launchctl = await executablePath("launchctl");
|
|
937
|
+
const [active, disabled, definitionExists] = await Promise.all([
|
|
938
|
+
runCommand(launchctl, ["print", `system/${record.definitionName}`]),
|
|
939
|
+
runCommand(launchctl, ["print-disabled", "system"]),
|
|
940
|
+
fs.access(record.definitionPath).then(() => true).catch(() => false)
|
|
941
|
+
]);
|
|
942
|
+
return {
|
|
943
|
+
active: active.code === 0,
|
|
944
|
+
enabled: definitionExists && !disabled.stdout.includes(`"${record.definitionName}" => true`),
|
|
945
|
+
lingering: true,
|
|
946
|
+
managerIssue: null
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
const systemctl = await executablePath("systemctl");
|
|
950
|
+
const [active, enabled, linger] = await Promise.all([
|
|
951
|
+
runCommand(systemctl, [
|
|
952
|
+
"--user",
|
|
953
|
+
"is-active",
|
|
954
|
+
record.definitionName
|
|
955
|
+
]),
|
|
956
|
+
runCommand(systemctl, [
|
|
957
|
+
"--user",
|
|
958
|
+
"is-enabled",
|
|
959
|
+
record.definitionName
|
|
960
|
+
]),
|
|
961
|
+
runCommand(await executablePath("loginctl"), [
|
|
962
|
+
"show-user",
|
|
963
|
+
record.username,
|
|
964
|
+
"-p",
|
|
965
|
+
"Linger",
|
|
966
|
+
"--value"
|
|
967
|
+
])
|
|
968
|
+
]);
|
|
969
|
+
return {
|
|
970
|
+
active: active.code === 0 && active.stdout.trim() === "active",
|
|
971
|
+
enabled: enabled.code === 0 && enabled.stdout.trim() === "enabled",
|
|
972
|
+
lingering: linger.code === 0 && linger.stdout.trim() === "yes",
|
|
973
|
+
managerIssue: active.code === 127 || active.stderr.includes("Failed to connect to bus") || active.stderr.includes("No medium found") ? "The systemd user manager is not available." : linger.code === 127 ? "loginctl is not available." : null
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
function administratorCommand(record) {
|
|
977
|
+
const requestId = record.pendingAdministratorRequestId;
|
|
978
|
+
if (!requestId || !record.runtimeExecutable || !record.runtimeEntrypoint) return null;
|
|
979
|
+
const requestPath = path.join(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).requestsDirectory, `${requestId}.json`);
|
|
980
|
+
return createAdministratorCommand({
|
|
981
|
+
installationMethod: record.installationMethod,
|
|
982
|
+
cliEntrypoint: record.cliEntrypoint,
|
|
983
|
+
runtimeExecutable: record.runtimeExecutable,
|
|
984
|
+
runtimeEntrypoint: record.runtimeEntrypoint,
|
|
985
|
+
requestPath
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
async function untrackedDefinition() {
|
|
989
|
+
const manager = managerForPlatform();
|
|
990
|
+
if (!manager) return null;
|
|
991
|
+
const user = os.userInfo();
|
|
992
|
+
const name = manager === "launchd" ? `app.treeport.daemon.${user.uid}` : "treeport.service";
|
|
993
|
+
const definitionPath = manager === "launchd" ? `/Library/LaunchDaemons/${name}.plist` : path.join(process.env.XDG_CONFIG_HOME?.trim() || path.join(user.homedir, ".config"), "systemd", "user", name);
|
|
994
|
+
return await fs.access(definitionPath).then(() => true).catch(() => false) ? {
|
|
995
|
+
manager,
|
|
996
|
+
name,
|
|
997
|
+
path: definitionPath
|
|
998
|
+
} : null;
|
|
999
|
+
}
|
|
1000
|
+
async function serviceInstalled() {
|
|
1001
|
+
return await currentRecord() !== null || await untrackedDefinition() !== null;
|
|
1002
|
+
}
|
|
1003
|
+
async function serviceStatus() {
|
|
1004
|
+
const manager = managerForPlatform();
|
|
1005
|
+
const record = await currentRecord();
|
|
1006
|
+
if (!manager) return {
|
|
1007
|
+
supported: false,
|
|
1008
|
+
manager: null,
|
|
1009
|
+
state: "disabled",
|
|
1010
|
+
installed: false,
|
|
1011
|
+
enabledAtBoot: false,
|
|
1012
|
+
active: false,
|
|
1013
|
+
healthy: false,
|
|
1014
|
+
rebootReady: false,
|
|
1015
|
+
definitionMatches: false,
|
|
1016
|
+
environmentMatches: false,
|
|
1017
|
+
entrypointMatches: false,
|
|
1018
|
+
requestedState: null,
|
|
1019
|
+
definitionPath: null,
|
|
1020
|
+
entrypoint: null,
|
|
1021
|
+
daemon: null,
|
|
1022
|
+
issues: [`Treeport service mode does not support ${process.platform}.`],
|
|
1023
|
+
recoveryCommands: [],
|
|
1024
|
+
administratorCommand: null
|
|
1025
|
+
};
|
|
1026
|
+
if (!record) {
|
|
1027
|
+
const untracked = await untrackedDefinition();
|
|
1028
|
+
if (!untracked) return {
|
|
1029
|
+
supported: true,
|
|
1030
|
+
manager,
|
|
1031
|
+
state: "disabled",
|
|
1032
|
+
installed: false,
|
|
1033
|
+
enabledAtBoot: false,
|
|
1034
|
+
active: false,
|
|
1035
|
+
healthy: false,
|
|
1036
|
+
rebootReady: false,
|
|
1037
|
+
definitionMatches: false,
|
|
1038
|
+
environmentMatches: false,
|
|
1039
|
+
entrypointMatches: false,
|
|
1040
|
+
requestedState: null,
|
|
1041
|
+
definitionPath: null,
|
|
1042
|
+
entrypoint: null,
|
|
1043
|
+
daemon: null,
|
|
1044
|
+
issues: [],
|
|
1045
|
+
recoveryCommands: ["treeport service enable"],
|
|
1046
|
+
administratorCommand: null
|
|
1047
|
+
};
|
|
1048
|
+
return {
|
|
1049
|
+
supported: true,
|
|
1050
|
+
manager,
|
|
1051
|
+
state: "stale",
|
|
1052
|
+
installed: true,
|
|
1053
|
+
enabledAtBoot: true,
|
|
1054
|
+
active: (untracked.manager === "launchd" ? await runCommand(await executablePath("launchctl"), ["print", `system/${untracked.name}`]) : await runCommand(await executablePath("systemctl"), [
|
|
1055
|
+
"--user",
|
|
1056
|
+
"is-active",
|
|
1057
|
+
untracked.name
|
|
1058
|
+
])).code === 0,
|
|
1059
|
+
healthy: false,
|
|
1060
|
+
rebootReady: false,
|
|
1061
|
+
definitionMatches: false,
|
|
1062
|
+
environmentMatches: false,
|
|
1063
|
+
entrypointMatches: false,
|
|
1064
|
+
requestedState: null,
|
|
1065
|
+
definitionPath: untracked.path,
|
|
1066
|
+
entrypoint: null,
|
|
1067
|
+
daemon: null,
|
|
1068
|
+
issues: [`A Treeport service definition exists at ${untracked.path}, but its service record is missing. Restore the original Treeport data directory or ask an administrator to inspect and remove the definition.`],
|
|
1069
|
+
recoveryCommands: [],
|
|
1070
|
+
administratorCommand: null
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
const paths = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
|
|
1074
|
+
const [managerStatus, definitionContent, entrypointExists, runtimeExecutableExists, runtimeEntrypointExists, currentRuntime, daemon] = await Promise.all([
|
|
1075
|
+
managerState(record),
|
|
1076
|
+
fs.readFile(record.definitionPath, "utf8").catch(() => ""),
|
|
1077
|
+
fs.access(record.cliEntrypoint, constants.X_OK).then(() => true).catch(() => false),
|
|
1078
|
+
record.runtimeExecutable ? fs.access(record.runtimeExecutable, constants.X_OK).then(() => true).catch(() => false) : Promise.resolve(false),
|
|
1079
|
+
record.runtimeEntrypoint ? fs.access(record.runtimeEntrypoint, constants.R_OK).then(() => true).catch(() => false) : Promise.resolve(false),
|
|
1080
|
+
currentAdministratorRuntime().catch(() => null),
|
|
1081
|
+
daemonStatus()
|
|
1082
|
+
]);
|
|
1083
|
+
const definitionPresent = definitionContent !== "";
|
|
1084
|
+
const definitionMatches = definitionPresent && fingerprint(definitionContent) === record.definitionHash;
|
|
1085
|
+
const invokedEntrypoint = currentEntrypoint();
|
|
1086
|
+
const entrypointMatches = Boolean(entrypointExists && runtimeExecutableExists && runtimeEntrypointExists && currentRuntime && record.runtimeExecutable === currentRuntime.runtimeExecutable && record.runtimeEntrypoint === currentRuntime.runtimeEntrypoint && (invokedEntrypoint === null || path.resolve(invokedEntrypoint) === path.resolve(record.cliEntrypoint)));
|
|
1087
|
+
const environmentMatches = fingerprint(createServiceEnvironment({
|
|
1088
|
+
user: {
|
|
1089
|
+
uid: record.uid,
|
|
1090
|
+
gid: record.gid,
|
|
1091
|
+
username: record.username,
|
|
1092
|
+
homedir: record.home,
|
|
1093
|
+
shell: record.environment.TREEPORT_SHELL ?? null
|
|
1094
|
+
},
|
|
1095
|
+
paths: localPaths({
|
|
1096
|
+
TREEPORT_DATA_DIR: record.dataDir,
|
|
1097
|
+
TREEPORT_RUNTIME_DIR: record.runtimeDir
|
|
1098
|
+
}),
|
|
1099
|
+
apiUrl: record.apiUrl,
|
|
1100
|
+
recordPath: paths.recordPath,
|
|
1101
|
+
installationMethod: record.installationMethod
|
|
1102
|
+
})) === record.environmentHash;
|
|
1103
|
+
const healthy = Boolean(daemon.verified && daemon.health?.daemonLifecycle === "service" && daemon.state?.daemonLifecycle === "service" && path.resolve(daemon.state.dataDir) === path.resolve(record.dataDir));
|
|
1104
|
+
const installed = managerStatus.enabled;
|
|
1105
|
+
const rebootReady = installed && (record.manager === "launchd" || managerStatus.lingering);
|
|
1106
|
+
const pendingCommand = administratorCommand(record) ?? (record.manager === "systemd" && managerStatus.enabled && !managerStatus.lingering ? `sudo loginctl enable-linger ${record.username}` : null);
|
|
1107
|
+
const issues = [];
|
|
1108
|
+
const recoveryCommands = [];
|
|
1109
|
+
if (record.manager !== manager) issues.push(`The service record uses ${record.manager}, but this host requires ${manager}.`);
|
|
1110
|
+
if (!definitionMatches && !record.pendingAdministratorRequestId) {
|
|
1111
|
+
issues.push(definitionPresent ? `The service definition at ${record.definitionPath} was changed.` : `The service definition is missing at ${record.definitionPath}.`);
|
|
1112
|
+
recoveryCommands.push("treeport service enable");
|
|
1113
|
+
}
|
|
1114
|
+
if (definitionMatches && !installed && !record.pendingAdministratorRequestId) {
|
|
1115
|
+
issues.push("The service definition is not enabled for startup after reboot.");
|
|
1116
|
+
recoveryCommands.push("treeport service enable");
|
|
1117
|
+
}
|
|
1118
|
+
if (!entrypointMatches) {
|
|
1119
|
+
issues.push(`The service CLI entrypoint or Node runtime is unavailable or moved: ${record.cliEntrypoint}`);
|
|
1120
|
+
recoveryCommands.push("treeport service enable");
|
|
1121
|
+
}
|
|
1122
|
+
if (!environmentMatches) {
|
|
1123
|
+
issues.push("The service environment differs from the current Treeport environment.");
|
|
1124
|
+
recoveryCommands.push("treeport service enable");
|
|
1125
|
+
}
|
|
1126
|
+
if (record.manager === "systemd" && installed && !managerStatus.lingering) {
|
|
1127
|
+
issues.push(`User lingering is disabled for ${record.username}.`);
|
|
1128
|
+
recoveryCommands.push(`sudo loginctl enable-linger ${record.username}`);
|
|
1129
|
+
}
|
|
1130
|
+
if (managerStatus.managerIssue) issues.push(managerStatus.managerIssue);
|
|
1131
|
+
if (installed && record.requestedState === "running" && !healthy && !record.pendingAdministratorRequestId) {
|
|
1132
|
+
issues.push("The supervised Treeport daemon is not healthy.");
|
|
1133
|
+
recoveryCommands.push("treeport start");
|
|
1134
|
+
}
|
|
1135
|
+
const stale = record.manager !== manager || !definitionMatches || !environmentMatches || !entrypointMatches || definitionPresent && !installed || managerStatus.managerIssue !== null;
|
|
1136
|
+
return {
|
|
1137
|
+
supported: true,
|
|
1138
|
+
manager,
|
|
1139
|
+
state: record.pendingAdministratorRequestId || record.manager === "systemd" && installed && !managerStatus.lingering ? "action_required" : stale ? "stale" : healthy ? "healthy" : installed && record.requestedState === "stopped" ? "stopped" : installed && managerStatus.active ? "starting" : installed ? "unhealthy" : "disabled",
|
|
1140
|
+
installed,
|
|
1141
|
+
enabledAtBoot: installed,
|
|
1142
|
+
active: managerStatus.active,
|
|
1143
|
+
healthy,
|
|
1144
|
+
rebootReady,
|
|
1145
|
+
definitionMatches,
|
|
1146
|
+
environmentMatches,
|
|
1147
|
+
entrypointMatches,
|
|
1148
|
+
requestedState: record.requestedState,
|
|
1149
|
+
definitionPath: record.definitionPath,
|
|
1150
|
+
entrypoint: record.cliEntrypoint,
|
|
1151
|
+
daemon,
|
|
1152
|
+
issues,
|
|
1153
|
+
recoveryCommands: [...new Set(recoveryCommands)],
|
|
1154
|
+
administratorCommand: pendingCommand
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
async function prepareRecord() {
|
|
1158
|
+
if (process.getuid?.() === 0) throw new Error("Run `treeport service enable` as the user who will run Treeport, not as root.");
|
|
1159
|
+
const manager = managerForPlatform();
|
|
1160
|
+
if (!manager) throw new Error(`Treeport service mode supports macOS launchd and Linux systemd; found ${process.platform}.`);
|
|
1161
|
+
const explicitApiUrl = process.env.TREEPORT_API_URL?.trim();
|
|
1162
|
+
if (explicitApiUrl) assertLoopbackHost(new URL(explicitApiUrl).hostname);
|
|
1163
|
+
const user = os.userInfo();
|
|
1164
|
+
const paths = localPaths();
|
|
1165
|
+
const locations = servicePaths();
|
|
1166
|
+
const apiUrl = await resolveLocalApiUrl();
|
|
1167
|
+
const listener = new URL(apiUrl);
|
|
1168
|
+
if (listener.protocol !== "http:") throw new Error("Treeport service mode requires a local HTTP loopback URL.");
|
|
1169
|
+
assertLoopbackHost(listener.hostname);
|
|
1170
|
+
const installationMethod = process.env.TREEPORT_INSTALLATION_METHOD?.trim() === "curl" ? "curl" : "npm";
|
|
1171
|
+
const [cliEntrypoint, administratorRuntime] = await Promise.all([ensureEntrypoint(installationMethod), currentAdministratorRuntime()]);
|
|
1172
|
+
const group = await primaryGroup(user.username);
|
|
1173
|
+
const definitionName = manager === "launchd" ? `app.treeport.daemon.${user.uid}` : "treeport.service";
|
|
1174
|
+
const definitionPath = manager === "launchd" ? `/Library/LaunchDaemons/${definitionName}.plist` : path.join(process.env.XDG_CONFIG_HOME?.trim() || path.join(user.homedir, ".config"), "systemd", "user", definitionName);
|
|
1175
|
+
const environment = createServiceEnvironment({
|
|
1176
|
+
user,
|
|
1177
|
+
paths,
|
|
1178
|
+
apiUrl,
|
|
1179
|
+
recordPath: locations.recordPath,
|
|
1180
|
+
installationMethod
|
|
1181
|
+
});
|
|
1182
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1183
|
+
const previous = await currentRecord();
|
|
1184
|
+
if (await fs.access(definitionPath).then(() => true).catch(() => false) && !previous) throw new Error(`A Treeport service definition already exists at ${definitionPath}. Disable it from its original data directory before enabling another service.`);
|
|
1185
|
+
if (previous && path.resolve(previous.dataDir) !== paths.dataDir) throw new Error(`Treeport service mode already uses ${previous.dataDir}. Disable it before enabling ${paths.dataDir}.`);
|
|
1186
|
+
const base = {
|
|
1187
|
+
schemaVersion: 1,
|
|
1188
|
+
manager,
|
|
1189
|
+
platform: process.platform,
|
|
1190
|
+
uid: user.uid,
|
|
1191
|
+
gid: user.gid,
|
|
1192
|
+
username: user.username,
|
|
1193
|
+
group,
|
|
1194
|
+
home: user.homedir,
|
|
1195
|
+
dataDir: paths.dataDir,
|
|
1196
|
+
runtimeDir: paths.runtimeDir,
|
|
1197
|
+
logPath: paths.logPath,
|
|
1198
|
+
apiUrl,
|
|
1199
|
+
cliEntrypoint,
|
|
1200
|
+
runtimeExecutable: administratorRuntime.runtimeExecutable,
|
|
1201
|
+
runtimeEntrypoint: administratorRuntime.runtimeEntrypoint,
|
|
1202
|
+
installationMethod,
|
|
1203
|
+
definitionName,
|
|
1204
|
+
definitionPath,
|
|
1205
|
+
definitionHash: "0".repeat(64),
|
|
1206
|
+
environmentHash: fingerprint(environment),
|
|
1207
|
+
environment,
|
|
1208
|
+
requestedState: "running",
|
|
1209
|
+
pendingAdministratorRequestId: null,
|
|
1210
|
+
createdAt: previous?.createdAt ?? now,
|
|
1211
|
+
updatedAt: now
|
|
1212
|
+
};
|
|
1213
|
+
const definition = definitionForRecord(base);
|
|
1214
|
+
return {
|
|
1215
|
+
record: {
|
|
1216
|
+
...base,
|
|
1217
|
+
definitionHash: fingerprint(definition)
|
|
1218
|
+
},
|
|
1219
|
+
definition
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
async function writeServiceFiles(record, definition) {
|
|
1223
|
+
const locations = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
|
|
1224
|
+
await Promise.all([fs.mkdir(path.dirname(record.logPath), {
|
|
1225
|
+
recursive: true,
|
|
1226
|
+
mode: 448
|
|
1227
|
+
}), fs.mkdir(locations.requestsDirectory, {
|
|
1228
|
+
recursive: true,
|
|
1229
|
+
mode: 448
|
|
1230
|
+
})]);
|
|
1231
|
+
await fs.writeFile(locations.runnerPath, runnerSource(record), { mode: 448 });
|
|
1232
|
+
await fs.chmod(locations.runnerPath, 448);
|
|
1233
|
+
if (record.manager === "launchd") await fs.writeFile(locations.stagedDefinitionPath, definition, { mode: 384 });
|
|
1234
|
+
else {
|
|
1235
|
+
await fs.mkdir(path.dirname(record.definitionPath), {
|
|
1236
|
+
recursive: true,
|
|
1237
|
+
mode: 448
|
|
1238
|
+
});
|
|
1239
|
+
const temporaryPath = `${record.definitionPath}.${process.pid}.tmp`;
|
|
1240
|
+
await fs.writeFile(temporaryPath, definition, { mode: 384 });
|
|
1241
|
+
await fs.rename(temporaryPath, record.definitionPath);
|
|
1242
|
+
}
|
|
1243
|
+
await saveRecord(record);
|
|
1244
|
+
}
|
|
1245
|
+
async function prepareAdministratorRequest(record, operation) {
|
|
1246
|
+
const runtime = record.runtimeExecutable && record.runtimeEntrypoint ? {
|
|
1247
|
+
runtimeExecutable: record.runtimeExecutable,
|
|
1248
|
+
runtimeEntrypoint: record.runtimeEntrypoint
|
|
1249
|
+
} : await currentAdministratorRuntime();
|
|
1250
|
+
const requestRecord = {
|
|
1251
|
+
...record,
|
|
1252
|
+
...runtime
|
|
1253
|
+
};
|
|
1254
|
+
const locations = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
|
|
1255
|
+
const id = crypto.randomUUID();
|
|
1256
|
+
const now = /* @__PURE__ */ new Date();
|
|
1257
|
+
const request = {
|
|
1258
|
+
schemaVersion: 1,
|
|
1259
|
+
id,
|
|
1260
|
+
operation,
|
|
1261
|
+
createdAt: now.toISOString(),
|
|
1262
|
+
expiresAt: new Date(now.getTime() + 15 * 6e4).toISOString(),
|
|
1263
|
+
uid: record.uid,
|
|
1264
|
+
gid: record.gid,
|
|
1265
|
+
username: record.username,
|
|
1266
|
+
group: record.group,
|
|
1267
|
+
home: record.home,
|
|
1268
|
+
serviceRecordPath: locations.recordPath,
|
|
1269
|
+
runnerPath: locations.runnerPath,
|
|
1270
|
+
definitionName: record.definitionName,
|
|
1271
|
+
definitionPath: record.definitionPath,
|
|
1272
|
+
stagedDefinitionPath: locations.stagedDefinitionPath,
|
|
1273
|
+
definitionHash: record.definitionHash,
|
|
1274
|
+
apiUrl: record.apiUrl,
|
|
1275
|
+
cliEntrypoint: record.cliEntrypoint,
|
|
1276
|
+
runtimeExecutable: requestRecord.runtimeExecutable,
|
|
1277
|
+
runtimeEntrypoint: requestRecord.runtimeEntrypoint
|
|
1278
|
+
};
|
|
1279
|
+
await writeJson(path.join(locations.requestsDirectory, `${id}.json`), request);
|
|
1280
|
+
const next = {
|
|
1281
|
+
...requestRecord,
|
|
1282
|
+
pendingAdministratorRequestId: id,
|
|
1283
|
+
updatedAt: now.toISOString()
|
|
1284
|
+
};
|
|
1285
|
+
await saveRecord(next);
|
|
1286
|
+
return {
|
|
1287
|
+
record: next,
|
|
1288
|
+
command: administratorCommand(next)
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
async function waitForService(record) {
|
|
1292
|
+
const deadline = Date.now() + 15e3;
|
|
1293
|
+
const version = await treeportVersion();
|
|
1294
|
+
while (Date.now() < deadline) {
|
|
1295
|
+
const observed = await daemonHealth(record.apiUrl, 500);
|
|
1296
|
+
if (observed?.daemonLifecycle === "service" && observed.instanceId && observed.version === version) return;
|
|
1297
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
1298
|
+
}
|
|
1299
|
+
throw new Error(`Treeport service did not become ready at ${record.apiUrl}. See ${record.logPath}.`);
|
|
1300
|
+
}
|
|
1301
|
+
async function serviceEnable() {
|
|
1302
|
+
const existing = await serviceStatus();
|
|
1303
|
+
if (existing.state === "healthy" && existing.definitionMatches && existing.environmentMatches && existing.entrypointMatches) return {
|
|
1304
|
+
status: existing,
|
|
1305
|
+
changed: false,
|
|
1306
|
+
administratorCommand: null
|
|
1307
|
+
};
|
|
1308
|
+
const { record, definition } = await prepareRecord();
|
|
1309
|
+
const failedChecks = (await runDoctor()).filter((check) => !check.ok);
|
|
1310
|
+
if (failedChecks.length) throw new Error(failedChecks.map((check) => `${check.name}: ${check.detail}`).join("\n"));
|
|
1311
|
+
const systemctl = record.manager === "systemd" ? await executablePath("systemctl") : null;
|
|
1312
|
+
if (systemctl) {
|
|
1313
|
+
const managerAvailable = await runCommand(systemctl, ["--user", "show-environment"]);
|
|
1314
|
+
if (managerAvailable.code !== 0) throw commandError("systemctl --user", managerAvailable);
|
|
1315
|
+
}
|
|
1316
|
+
await writeServiceFiles(record, definition);
|
|
1317
|
+
if (record.manager === "launchd") {
|
|
1318
|
+
await daemonDown();
|
|
1319
|
+
const prepared = await prepareAdministratorRequest(record, "enable");
|
|
1320
|
+
return {
|
|
1321
|
+
status: await serviceStatus(),
|
|
1322
|
+
changed: true,
|
|
1323
|
+
administratorCommand: prepared.command
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
if (!systemctl) throw new Error("Treeport could not resolve the systemd command.");
|
|
1327
|
+
await daemonDown();
|
|
1328
|
+
const reload = await runCommand(systemctl, ["--user", "daemon-reload"]);
|
|
1329
|
+
if (reload.code !== 0) {
|
|
1330
|
+
await Promise.all([fs.rm(record.definitionPath, { force: true }), fs.rm(servicePaths().directory, {
|
|
1331
|
+
recursive: true,
|
|
1332
|
+
force: true
|
|
1333
|
+
})]);
|
|
1334
|
+
await daemonUp({});
|
|
1335
|
+
throw commandError("systemctl --user daemon-reload", reload);
|
|
1336
|
+
}
|
|
1337
|
+
const enabled = await runCommand(systemctl, [
|
|
1338
|
+
"--user",
|
|
1339
|
+
"enable",
|
|
1340
|
+
"--now",
|
|
1341
|
+
record.definitionName
|
|
1342
|
+
]);
|
|
1343
|
+
if (enabled.code !== 0) {
|
|
1344
|
+
await fs.rm(record.definitionPath, { force: true });
|
|
1345
|
+
await runCommand(systemctl, ["--user", "daemon-reload"]);
|
|
1346
|
+
await fs.rm(servicePaths().directory, {
|
|
1347
|
+
recursive: true,
|
|
1348
|
+
force: true
|
|
1349
|
+
});
|
|
1350
|
+
await daemonUp({});
|
|
1351
|
+
throw commandError("systemctl --user enable --now", enabled);
|
|
1352
|
+
}
|
|
1353
|
+
const startupError = await waitForService(record).then(() => null, (error) => error);
|
|
1354
|
+
if (startupError) {
|
|
1355
|
+
await runCommand(systemctl, [
|
|
1356
|
+
"--user",
|
|
1357
|
+
"disable",
|
|
1358
|
+
"--now",
|
|
1359
|
+
record.definitionName
|
|
1360
|
+
]);
|
|
1361
|
+
await fs.rm(record.definitionPath, { force: true });
|
|
1362
|
+
await runCommand(systemctl, ["--user", "daemon-reload"]);
|
|
1363
|
+
await fs.rm(servicePaths().directory, {
|
|
1364
|
+
recursive: true,
|
|
1365
|
+
force: true
|
|
1366
|
+
});
|
|
1367
|
+
await daemonUp({});
|
|
1368
|
+
throw startupError;
|
|
1369
|
+
}
|
|
1370
|
+
const status = await serviceStatus();
|
|
1371
|
+
return {
|
|
1372
|
+
status,
|
|
1373
|
+
changed: true,
|
|
1374
|
+
administratorCommand: status.administratorCommand
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
async function serviceStart() {
|
|
1378
|
+
const record = await currentRecord();
|
|
1379
|
+
if (!record) throw new Error("Treeport service mode is disabled. Run `treeport service enable` first.");
|
|
1380
|
+
const current = await serviceStatus();
|
|
1381
|
+
if (current.state === "healthy") return {
|
|
1382
|
+
status: current,
|
|
1383
|
+
changed: false,
|
|
1384
|
+
administratorCommand: null
|
|
1385
|
+
};
|
|
1386
|
+
if (current.administratorCommand) return {
|
|
1387
|
+
status: current,
|
|
1388
|
+
changed: false,
|
|
1389
|
+
administratorCommand: current.administratorCommand
|
|
1390
|
+
};
|
|
1391
|
+
if (!current.definitionMatches || !current.entrypointMatches) throw new Error("The Treeport service definition is stale. Run `treeport service enable` to repair it.");
|
|
1392
|
+
const next = {
|
|
1393
|
+
...record,
|
|
1394
|
+
requestedState: "running",
|
|
1395
|
+
pendingAdministratorRequestId: null,
|
|
1396
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1397
|
+
};
|
|
1398
|
+
await saveRecord(next);
|
|
1399
|
+
if (record.manager === "launchd") {
|
|
1400
|
+
const prepared = await prepareAdministratorRequest(next, "start");
|
|
1401
|
+
return {
|
|
1402
|
+
status: await serviceStatus(),
|
|
1403
|
+
changed: true,
|
|
1404
|
+
administratorCommand: prepared.command
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
const result = await runCommand(await executablePath("systemctl"), [
|
|
1408
|
+
"--user",
|
|
1409
|
+
"start",
|
|
1410
|
+
record.definitionName
|
|
1411
|
+
]);
|
|
1412
|
+
if (result.code !== 0) throw commandError("systemctl --user start", result);
|
|
1413
|
+
await waitForService(next);
|
|
1414
|
+
return {
|
|
1415
|
+
status: await serviceStatus(),
|
|
1416
|
+
changed: true,
|
|
1417
|
+
administratorCommand: null
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
async function serviceStop() {
|
|
1421
|
+
const record = await currentRecord();
|
|
1422
|
+
if (!record) throw new Error("Treeport service mode is disabled.");
|
|
1423
|
+
const current = await serviceStatus();
|
|
1424
|
+
if (current.state === "stopped") return {
|
|
1425
|
+
status: current,
|
|
1426
|
+
changed: false,
|
|
1427
|
+
administratorCommand: null
|
|
1428
|
+
};
|
|
1429
|
+
const next = {
|
|
1430
|
+
...record,
|
|
1431
|
+
requestedState: "stopped",
|
|
1432
|
+
pendingAdministratorRequestId: null,
|
|
1433
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1434
|
+
};
|
|
1435
|
+
await saveRecord(next);
|
|
1436
|
+
if (record.manager === "launchd") {
|
|
1437
|
+
const prepared = await prepareAdministratorRequest(next, "stop");
|
|
1438
|
+
return {
|
|
1439
|
+
status: await serviceStatus(),
|
|
1440
|
+
changed: true,
|
|
1441
|
+
administratorCommand: prepared.command
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
const result = await runCommand(await executablePath("systemctl"), [
|
|
1445
|
+
"--user",
|
|
1446
|
+
"stop",
|
|
1447
|
+
record.definitionName
|
|
1448
|
+
]);
|
|
1449
|
+
if (result.code !== 0) {
|
|
1450
|
+
await saveRecord(record);
|
|
1451
|
+
throw commandError("systemctl --user stop", result);
|
|
1452
|
+
}
|
|
1453
|
+
return {
|
|
1454
|
+
status: await serviceStatus(),
|
|
1455
|
+
changed: true,
|
|
1456
|
+
administratorCommand: null
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
async function serviceDisable() {
|
|
1460
|
+
const record = await currentRecord();
|
|
1461
|
+
if (!record) return {
|
|
1462
|
+
status: await serviceStatus(),
|
|
1463
|
+
changed: false,
|
|
1464
|
+
administratorCommand: null
|
|
1465
|
+
};
|
|
1466
|
+
if (record.manager === "launchd") {
|
|
1467
|
+
const prepared = await prepareAdministratorRequest({
|
|
1468
|
+
...record,
|
|
1469
|
+
pendingAdministratorRequestId: null
|
|
1470
|
+
}, "disable");
|
|
1471
|
+
return {
|
|
1472
|
+
status: await serviceStatus(),
|
|
1473
|
+
changed: true,
|
|
1474
|
+
administratorCommand: prepared.command
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
const systemctl = await executablePath("systemctl");
|
|
1478
|
+
const disabled = await runCommand(systemctl, [
|
|
1479
|
+
"--user",
|
|
1480
|
+
"disable",
|
|
1481
|
+
"--now",
|
|
1482
|
+
record.definitionName
|
|
1483
|
+
]);
|
|
1484
|
+
if (disabled.code !== 0 && !disabled.stderr.includes("does not exist")) throw commandError("systemctl --user disable --now", disabled);
|
|
1485
|
+
await fs.rm(record.definitionPath, { force: true });
|
|
1486
|
+
await runCommand(systemctl, ["--user", "daemon-reload"]);
|
|
1487
|
+
await fs.rm(servicePaths().directory, {
|
|
1488
|
+
recursive: true,
|
|
1489
|
+
force: true
|
|
1490
|
+
});
|
|
1491
|
+
return {
|
|
1492
|
+
status: await serviceStatus(),
|
|
1493
|
+
changed: true,
|
|
1494
|
+
administratorCommand: null
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
async function serviceApply(requestPath) {
|
|
1498
|
+
if (process.platform !== "darwin") throw new Error("Treeport service apply is only available for macOS LaunchDaemons.");
|
|
1499
|
+
if (process.getuid?.() !== 0) throw new Error("Run the printed service apply command with sudo or as root.");
|
|
1500
|
+
if (!path.isAbsolute(requestPath)) throw new Error("The service apply request path must be absolute.");
|
|
1501
|
+
const metadata = await fs.lstat(requestPath);
|
|
1502
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("The service apply request must be a regular file, not a symlink.");
|
|
1503
|
+
if ((metadata.mode & 63) !== 0) throw new Error("The service apply request must not be readable or writable by other users.");
|
|
1504
|
+
const request = await readJson(requestPath, administratorRequestSchema);
|
|
1505
|
+
if (!request) throw new Error("The service apply request is invalid.");
|
|
1506
|
+
if (metadata.uid !== request.uid) throw new Error("The service apply request owner does not match its target user.");
|
|
1507
|
+
if (Date.parse(request.expiresAt) <= Date.now()) throw new Error("The service apply request expired. Run the original Treeport command again.");
|
|
1508
|
+
const currentRuntime = await currentAdministratorRuntime().catch(() => null);
|
|
1509
|
+
const invokedRuntimeEntrypoint = process.argv[1] ? path.resolve(process.argv[1]) : null;
|
|
1510
|
+
if (!currentRuntime || currentRuntime.runtimeExecutable !== request.runtimeExecutable || currentRuntime.runtimeEntrypoint !== request.runtimeEntrypoint || invokedRuntimeEntrypoint !== request.runtimeEntrypoint) throw new Error("The service apply command did not use the approved Treeport Node runtime and package entrypoint.");
|
|
1511
|
+
const usedPath = `${requestPath}.used`;
|
|
1512
|
+
if (await fs.access(usedPath).then(() => true).catch(() => false)) throw new Error("The service apply request was already used.");
|
|
1513
|
+
const account = os.userInfo({ encoding: "utf8" });
|
|
1514
|
+
const idResult = await runCommand(await executablePath("id"), ["-u", request.username]);
|
|
1515
|
+
if (idResult.code !== 0 || Number(idResult.stdout.trim()) !== request.uid) throw new Error("The service apply target user no longer matches the host account.");
|
|
1516
|
+
const record = await readJson(request.serviceRecordPath, serviceRecordSchema);
|
|
1517
|
+
if (!record || record.uid !== request.uid || record.username !== request.username || record.manager !== "launchd" || record.definitionName !== request.definitionName || record.definitionPath !== request.definitionPath || record.cliEntrypoint !== request.cliEntrypoint || record.runtimeExecutable !== request.runtimeExecutable || record.runtimeEntrypoint !== request.runtimeEntrypoint || record.definitionHash !== request.definitionHash || record.pendingAdministratorRequestId !== request.id) throw new Error("The service apply request does not match the current Treeport service record.");
|
|
1518
|
+
if (account.uid !== 0) throw new Error("Treeport service apply lost root privileges.");
|
|
1519
|
+
const launchctl = await executablePath("launchctl");
|
|
1520
|
+
const target = `system/${request.definitionName}`;
|
|
1521
|
+
if (request.operation === "enable") {
|
|
1522
|
+
const staged = await fs.readFile(request.stagedDefinitionPath, "utf8");
|
|
1523
|
+
if (fingerprint(staged) !== request.definitionHash || !staged.includes(`<string>${xml(request.username)}</string>`) || !staged.includes(`<string>${xml(request.runnerPath)}</string>`)) throw new Error("The staged LaunchDaemon definition does not match the approved request.");
|
|
1524
|
+
const temporaryPath = `${request.definitionPath}.${process.pid}.tmp`;
|
|
1525
|
+
await fs.copyFile(request.stagedDefinitionPath, temporaryPath);
|
|
1526
|
+
await fs.chown(temporaryPath, 0, 0);
|
|
1527
|
+
await fs.chmod(temporaryPath, 420);
|
|
1528
|
+
await fs.rename(temporaryPath, request.definitionPath);
|
|
1529
|
+
await runCommand(launchctl, ["bootout", target]);
|
|
1530
|
+
const enabled = await runCommand(launchctl, ["enable", target]);
|
|
1531
|
+
if (enabled.code !== 0) throw commandError("launchctl enable", enabled);
|
|
1532
|
+
const bootstrapped = await runCommand(launchctl, [
|
|
1533
|
+
"bootstrap",
|
|
1534
|
+
"system",
|
|
1535
|
+
request.definitionPath
|
|
1536
|
+
]);
|
|
1537
|
+
if (bootstrapped.code !== 0) throw commandError("launchctl bootstrap", bootstrapped);
|
|
1538
|
+
} else if (request.operation === "start") {
|
|
1539
|
+
const enabled = await runCommand(launchctl, ["enable", target]);
|
|
1540
|
+
if (enabled.code !== 0) throw commandError("launchctl enable", enabled);
|
|
1541
|
+
const started = (await runCommand(launchctl, ["print", target])).code === 0 ? await runCommand(launchctl, ["kickstart", target]) : await runCommand(launchctl, [
|
|
1542
|
+
"bootstrap",
|
|
1543
|
+
"system",
|
|
1544
|
+
request.definitionPath
|
|
1545
|
+
]);
|
|
1546
|
+
if (started.code !== 0) throw commandError("launchctl start", started);
|
|
1547
|
+
} else if (request.operation === "stop") {
|
|
1548
|
+
const stopped = await runCommand(launchctl, ["bootout", target]);
|
|
1549
|
+
if (stopped.code !== 0 && !stopped.stderr.includes("No such process")) throw commandError("launchctl bootout", stopped);
|
|
1550
|
+
} else {
|
|
1551
|
+
const installed = await fs.readFile(request.definitionPath, "utf8").catch(() => "");
|
|
1552
|
+
if (installed && fingerprint(installed) !== request.definitionHash) throw new Error("Refusing to remove a LaunchDaemon definition that Treeport did not create.");
|
|
1553
|
+
await runCommand(launchctl, ["bootout", target]);
|
|
1554
|
+
await fs.rm(request.definitionPath, { force: true });
|
|
1555
|
+
}
|
|
1556
|
+
if (request.operation === "enable" || request.operation === "start") await waitForService(record);
|
|
1557
|
+
await fs.rename(requestPath, usedPath);
|
|
1558
|
+
if (request.operation === "disable") await fs.rm(path.dirname(request.serviceRecordPath), {
|
|
1559
|
+
recursive: true,
|
|
1560
|
+
force: true
|
|
1561
|
+
});
|
|
1562
|
+
else {
|
|
1563
|
+
await writeJson(request.serviceRecordPath, {
|
|
1564
|
+
...record,
|
|
1565
|
+
requestedState: request.operation === "stop" ? "stopped" : "running",
|
|
1566
|
+
pendingAdministratorRequestId: null,
|
|
1567
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1568
|
+
});
|
|
1569
|
+
await fs.chown(request.serviceRecordPath, request.uid, request.gid);
|
|
1570
|
+
}
|
|
1571
|
+
return {
|
|
1572
|
+
operation: request.operation,
|
|
1573
|
+
applied: true
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
async function serviceRun() {
|
|
1577
|
+
const recordPath = process.env.TREEPORT_SERVICE_RECORD?.trim();
|
|
1578
|
+
if (!recordPath || !path.isAbsolute(recordPath)) throw new Error("Treeport service run requires a valid service record.");
|
|
1579
|
+
const record = await readJson(recordPath, serviceRecordSchema);
|
|
1580
|
+
if (!record) throw new Error(`Treeport service record is invalid: ${recordPath}`);
|
|
1581
|
+
if (process.getuid?.() === 0 || process.getuid?.() !== record.uid) throw new Error(`Treeport service must run as ${record.username} (UID ${record.uid}), never as root.`);
|
|
1582
|
+
await writeJson(recordPath, {
|
|
1583
|
+
...record,
|
|
1584
|
+
requestedState: "running",
|
|
1585
|
+
pendingAdministratorRequestId: null,
|
|
1586
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1587
|
+
});
|
|
1588
|
+
const [version, serverEntry, webDist] = await Promise.all([
|
|
1589
|
+
treeportVersion(),
|
|
1590
|
+
resolvePackagePath("dist", "node", "server", "index.js"),
|
|
1591
|
+
resolvePackagePath("dist", "web")
|
|
1592
|
+
]);
|
|
1593
|
+
Object.assign(process.env, record.environment, {
|
|
1594
|
+
TREEPORT_APP_VERSION: version,
|
|
1595
|
+
TREEPORT_INSTANCE_ID: crypto.randomUUID(),
|
|
1596
|
+
TREEPORT_WEB_DIST: webDist,
|
|
1597
|
+
TREEPORT_DAEMON_LIFECYCLE: "service"
|
|
1598
|
+
});
|
|
1599
|
+
await import(pathToFileURL(serverEntry).href);
|
|
1600
|
+
}
|
|
1601
|
+
async function readServiceLogs(lines) {
|
|
1602
|
+
const record = await currentRecord();
|
|
1603
|
+
if (!record || record.manager === "launchd") return (await fs.readFile(record?.logPath ?? localPaths().logPath, "utf8").catch((error) => {
|
|
1604
|
+
if (error.code === "ENOENT") return "";
|
|
1605
|
+
throw error;
|
|
1606
|
+
})).split("\n").slice(-lines - 1).join("\n");
|
|
1607
|
+
const result = await runCommand(await executablePath("journalctl"), [
|
|
1608
|
+
"--user",
|
|
1609
|
+
"--unit",
|
|
1610
|
+
record.definitionName,
|
|
1611
|
+
"--no-pager",
|
|
1612
|
+
"--lines",
|
|
1613
|
+
String(lines)
|
|
1614
|
+
]);
|
|
1615
|
+
if (result.code !== 0) throw commandError("journalctl --user", result);
|
|
1616
|
+
return result.stdout;
|
|
1617
|
+
}
|
|
1618
|
+
async function serviceDoctorCheck() {
|
|
1619
|
+
const status = await serviceStatus();
|
|
1620
|
+
if (!status.supported) return {
|
|
1621
|
+
name: "Service supervision",
|
|
1622
|
+
ok: false,
|
|
1623
|
+
detail: status.issues.join(" ")
|
|
1624
|
+
};
|
|
1625
|
+
if (status.state === "disabled") return {
|
|
1626
|
+
name: "Service supervision",
|
|
1627
|
+
ok: true,
|
|
1628
|
+
detail: "disabled (opt in with `treeport service enable`)"
|
|
1629
|
+
};
|
|
1630
|
+
if (status.state === "healthy") return {
|
|
1631
|
+
name: "Service supervision",
|
|
1632
|
+
ok: true,
|
|
1633
|
+
detail: `${status.manager}; enabled at boot and healthy`
|
|
1634
|
+
};
|
|
1635
|
+
if (status.state === "stopped") return {
|
|
1636
|
+
name: "Service supervision",
|
|
1637
|
+
ok: true,
|
|
1638
|
+
detail: `${status.manager}; intentionally stopped and enabled for next boot`
|
|
1639
|
+
};
|
|
1640
|
+
return {
|
|
1641
|
+
name: "Service supervision",
|
|
1642
|
+
ok: false,
|
|
1643
|
+
detail: status.issues.join(" ") || `state: ${status.state}`
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
//#endregion
|
|
579
1647
|
//#region src/cli/application.ts
|
|
580
1648
|
const contextPrefix = "TREEPORT";
|
|
581
1649
|
let configuredApiUrl;
|
|
@@ -606,8 +1674,40 @@ var CliError = class extends Error {
|
|
|
606
1674
|
};
|
|
607
1675
|
async function resolveDaemonLifecycle() {
|
|
608
1676
|
if (configuredDaemonLifecycle === "external") return "external";
|
|
609
|
-
if (
|
|
610
|
-
|
|
1677
|
+
if (configuredDaemonLifecycle === "service") return "service";
|
|
1678
|
+
if (configuredApiUrl) {
|
|
1679
|
+
const observed = await daemonHealth(apiUrl);
|
|
1680
|
+
if (observed) return observed.daemonLifecycle;
|
|
1681
|
+
}
|
|
1682
|
+
return await serviceInstalled() ? "service" : "treeport";
|
|
1683
|
+
}
|
|
1684
|
+
function formatServiceStatus(status) {
|
|
1685
|
+
const lines = [
|
|
1686
|
+
`Treeport service: ${status.state}`,
|
|
1687
|
+
`Manager: ${status.manager ?? "unsupported"}`,
|
|
1688
|
+
`Starts at boot: ${status.enabledAtBoot ? "yes" : "no"}`,
|
|
1689
|
+
`Active: ${status.active ? "yes" : "no"}`,
|
|
1690
|
+
`Definition: ${status.definitionPath ?? "not installed"}`
|
|
1691
|
+
];
|
|
1692
|
+
if (status.daemon?.state) lines.push(`PID: ${status.daemon.state.pid}`);
|
|
1693
|
+
if (status.issues.length) lines.push(...status.issues.map((issue) => `Issue: ${issue}`));
|
|
1694
|
+
if (status.administratorCommand) lines.push("Administrator action required:", status.administratorCommand, "Then run: treeport service status");
|
|
1695
|
+
else if (status.recoveryCommands.length) lines.push(`Next: ${status.recoveryCommands[0]}`);
|
|
1696
|
+
return lines.join("\n");
|
|
1697
|
+
}
|
|
1698
|
+
async function ensureServiceDaemon() {
|
|
1699
|
+
const result = await serviceStart();
|
|
1700
|
+
const state = result.status.daemon?.state;
|
|
1701
|
+
if (state && result.status.healthy) return {
|
|
1702
|
+
apiUrl: state.apiUrl,
|
|
1703
|
+
pid: state.pid
|
|
1704
|
+
};
|
|
1705
|
+
if (result.administratorCommand) throw new CliError(`An administrator must start the Treeport service:\n${result.administratorCommand}`, 5, "SERVICE_ADMINISTRATOR_ACTION_REQUIRED", result);
|
|
1706
|
+
if (!state || !result.status.healthy) throw new CliError("The Treeport service did not become healthy. Run `treeport service status`.", 3, "DAEMON_UNREACHABLE", result.status);
|
|
1707
|
+
return {
|
|
1708
|
+
apiUrl: state.apiUrl,
|
|
1709
|
+
pid: state.pid
|
|
1710
|
+
};
|
|
611
1711
|
}
|
|
612
1712
|
async function request(pathname, options = {}) {
|
|
613
1713
|
const controller = new AbortController();
|
|
@@ -617,14 +1717,13 @@ async function request(pathname, options = {}) {
|
|
|
617
1717
|
else externalSignal?.addEventListener("abort", abort, { once: true });
|
|
618
1718
|
const timeout = setTimeout(abort, 9e4);
|
|
619
1719
|
try {
|
|
1720
|
+
const headers = new Headers({ accept: "application/json" });
|
|
1721
|
+
if (options.body) headers.set("content-type", "application/json");
|
|
1722
|
+
new Headers(options.headers).forEach((value, key) => headers.set(key, value));
|
|
620
1723
|
const response = await fetch(`${apiUrl}${pathname}`, {
|
|
621
1724
|
...options,
|
|
622
1725
|
signal: controller.signal,
|
|
623
|
-
headers
|
|
624
|
-
accept: "application/json",
|
|
625
|
-
...options.body ? { "content-type": "application/json" } : {},
|
|
626
|
-
...options.headers
|
|
627
|
-
}
|
|
1726
|
+
headers
|
|
628
1727
|
});
|
|
629
1728
|
const body = await response.json().catch(() => ({}));
|
|
630
1729
|
if (!response.ok) {
|
|
@@ -649,18 +1748,18 @@ async function createWorktree(projectId, input) {
|
|
|
649
1748
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
650
1749
|
operation = (await request(`/api/operations/${encodeURIComponent(operation.id)}`)).operation;
|
|
651
1750
|
}
|
|
652
|
-
if (operation.status === "failed") throw new CliError(operation.error ?? "
|
|
653
|
-
if (operation.kind !== "create") throw new CliError("
|
|
654
|
-
const worktreeId =
|
|
655
|
-
if (!worktreeId) throw new CliError("Completed
|
|
1751
|
+
if (operation.status === "failed") throw new CliError(operation.error ?? "Tree creation failed", 5, "WORKTREE_CREATION_FAILED");
|
|
1752
|
+
if (operation.kind !== "create") throw new CliError("Tree creation returned an unexpected operation kind", 5, "INVALID_OPERATION_RESULT");
|
|
1753
|
+
const worktreeId = operation.result?.worktreeId ?? operation.worktreeId;
|
|
1754
|
+
if (!worktreeId) throw new CliError("Completed tree creation did not identify its tree", 5, "INVALID_OPERATION_RESULT");
|
|
656
1755
|
const worktree = (await request(`/api/projects/${encodeURIComponent(projectId)}`)).project.worktrees.find((item) => item.id === worktreeId);
|
|
657
|
-
if (!worktree) throw new CliError(`Created
|
|
658
|
-
const terminalId =
|
|
1756
|
+
if (!worktree) throw new CliError(`Created tree ${worktreeId} was not found`, 5, "INVALID_OPERATION_RESULT");
|
|
1757
|
+
const terminalId = operation.result?.terminalId ?? null;
|
|
659
1758
|
return {
|
|
660
1759
|
worktree,
|
|
661
1760
|
terminal: worktree.terminals.find((item) => item.id === terminalId) ?? null,
|
|
662
|
-
terminalError:
|
|
663
|
-
setupError:
|
|
1761
|
+
terminalError: operation.result?.terminalError ?? null,
|
|
1762
|
+
setupError: operation.result?.setupError ?? null
|
|
664
1763
|
};
|
|
665
1764
|
}
|
|
666
1765
|
function commandArgv(args) {
|
|
@@ -713,7 +1812,7 @@ async function resolveWorktree(identifier) {
|
|
|
713
1812
|
}
|
|
714
1813
|
const candidate = await canonical(identifier);
|
|
715
1814
|
const match = all.filter((worktree) => pathContains(candidate, worktree.path)).sort((a, b) => b.path.length - a.path.length)[0];
|
|
716
|
-
if (!match) throw new CliError(`No registered
|
|
1815
|
+
if (!match) throw new CliError(`No registered tree matches ${identifier}`, 5);
|
|
717
1816
|
return match;
|
|
718
1817
|
}
|
|
719
1818
|
function parseWebPanelInput(value) {
|
|
@@ -725,8 +1824,9 @@ function parseWebPanelInput(value) {
|
|
|
725
1824
|
} catch (error) {
|
|
726
1825
|
throw new CliError(`--input must contain valid JSON: ${error instanceof Error ? error.message : String(error)}`, 2);
|
|
727
1826
|
}
|
|
728
|
-
|
|
729
|
-
|
|
1827
|
+
const validated = webPanelInputSchema.safeParse(parsed);
|
|
1828
|
+
if (!validated.success) throw new CliError("--input must contain a JSON object", 2);
|
|
1829
|
+
return validated.data;
|
|
730
1830
|
}
|
|
731
1831
|
async function webPanelDefinition(worktreeId, identifier) {
|
|
732
1832
|
const definitions = (await request(`/api/worktrees/${encodeURIComponent(worktreeId)}/web-panel-definitions`)).definitions;
|
|
@@ -735,11 +1835,11 @@ async function webPanelDefinition(worktreeId, identifier) {
|
|
|
735
1835
|
const matches = definitions.filter((definition) => decodeURIComponent(definition.id.split(":").at(-1) ?? "") === identifier);
|
|
736
1836
|
if (matches.length === 1) return matches[0];
|
|
737
1837
|
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
|
|
1838
|
+
throw new CliError(`Web panel ${identifier} is not available in this tree`, 5, "WEB_PANEL_DEFINITION_NOT_FOUND");
|
|
739
1839
|
}
|
|
740
1840
|
async function webPanelLaunchCwd(worktree) {
|
|
741
1841
|
const [cwd, worktreeRoot] = await Promise.all([canonical(workingDirectory), canonical(worktree.path)]);
|
|
742
|
-
if (!pathContains(cwd, worktreeRoot)) throw new CliError(`The current directory is outside
|
|
1842
|
+
if (!pathContains(cwd, worktreeRoot)) throw new CliError(`The current directory is outside tree ${worktree.name}`, 5, "INVALID_WEB_PANEL_LAUNCH_CWD", {
|
|
743
1843
|
cwd,
|
|
744
1844
|
worktreeId: worktree.id,
|
|
745
1845
|
worktreePath: worktree.path
|
|
@@ -825,10 +1925,10 @@ async function waitForTerminal(terminalId, condition, timeoutMs) {
|
|
|
825
1925
|
resolve(result);
|
|
826
1926
|
}
|
|
827
1927
|
};
|
|
828
|
-
const fail = (
|
|
1928
|
+
const fail = (cause) => {
|
|
829
1929
|
if (!settled) {
|
|
830
1930
|
settled = true;
|
|
831
|
-
reject(
|
|
1931
|
+
reject(cause);
|
|
832
1932
|
}
|
|
833
1933
|
};
|
|
834
1934
|
const enqueue = (task) => {
|
|
@@ -908,7 +2008,7 @@ const agentGuidance = `AI agents:
|
|
|
908
2008
|
async function main(args) {
|
|
909
2009
|
const argv = args[0] === "spawn" || args[0] === "terminal" && args[1] === "create" ? commandArgv(args) : void 0;
|
|
910
2010
|
let parserError = "";
|
|
911
|
-
const program = new Command().name("treeport").usage("[options] [folder] [command]").description("Manage Treeport projects,
|
|
2011
|
+
const program = new Command().name("treeport").usage("[options] [folder] [command]").description("Manage Treeport projects, trees, and terminals.").argument("[folder]", "folder inside a Git repository to open").option("--json", "emit machine-readable JSON").addHelpText("beforeAll", agentGuidance).configureOutput({
|
|
912
2012
|
writeOut: writeStdout,
|
|
913
2013
|
writeErr: (value) => {
|
|
914
2014
|
parserError += value;
|
|
@@ -921,15 +2021,17 @@ async function main(args) {
|
|
|
921
2021
|
}
|
|
922
2022
|
const absoluteFolder = path.resolve(workingDirectory, folder);
|
|
923
2023
|
if (!(await fs.stat(absoluteFolder).catch((error) => {
|
|
924
|
-
if (
|
|
2024
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") throw new CliError(`Folder does not exist: ${absoluteFolder}`, 5, "FOLDER_NOT_FOUND", { path: absoluteFolder });
|
|
925
2025
|
throw new CliError(`Cannot access folder ${absoluteFolder}: ${error instanceof Error ? error.message : String(error)}`, 5, "FOLDER_UNREADABLE", { path: absoluteFolder });
|
|
926
2026
|
})).isDirectory()) throw new CliError(`Path is not a folder: ${absoluteFolder}`, 5, "FOLDER_NOT_DIRECTORY", { path: absoluteFolder });
|
|
927
2027
|
const canonicalFolder = await fs.realpath(absoluteFolder).catch((error) => {
|
|
928
2028
|
throw new CliError(`Cannot access folder ${absoluteFolder}: ${error instanceof Error ? error.message : String(error)}`, 5, "FOLDER_UNREADABLE", { path: absoluteFolder });
|
|
929
2029
|
});
|
|
930
|
-
|
|
2030
|
+
const lifecycle = await resolveDaemonLifecycle();
|
|
2031
|
+
if (lifecycle === "external") {
|
|
931
2032
|
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
|
|
2033
|
+
} else if (lifecycle === "service") await ensureServiceDaemon();
|
|
2034
|
+
else await daemonUp({});
|
|
933
2035
|
const registered = await request("/api/projects", {
|
|
934
2036
|
method: "POST",
|
|
935
2037
|
body: JSON.stringify({ path: canonicalFolder })
|
|
@@ -958,27 +2060,72 @@ async function main(args) {
|
|
|
958
2060
|
client: opened.client
|
|
959
2061
|
}, () => `Opened ${registered.project.name} / ${targetWorktree.name} in the ${opened.client === "desktop" ? "Treeport desktop app" : "browser"}\n${target.href}`);
|
|
960
2062
|
});
|
|
961
|
-
const
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
2063
|
+
const startCommand = program.command("start").description("Ensure the local Treeport daemon is running").option("--host <address>", "loopback listener address").option("--port <port>", "listener port").option("--foreground", "run in the foreground").option("--json", "emit machine-readable JSON");
|
|
2064
|
+
startCommand.action(async () => {
|
|
2065
|
+
const lifecycle = await resolveDaemonLifecycle();
|
|
2066
|
+
if (lifecycle === "external") throw new CliError("Cannot run `treeport start` because the daemon lifecycle is externally managed. Control the process that started Treeport instead.", 5, "DAEMON_LIFECYCLE_EXTERNAL");
|
|
2067
|
+
const options = startCommand.opts();
|
|
2068
|
+
if (lifecycle === "service") {
|
|
2069
|
+
if (options.foreground || options.host || options.port) throw new CliError("An installed service owns the listener and process mode. Run `treeport service enable` to refresh its configuration, or `treeport service disable` to return to local background mode.", 5, "DAEMON_LIFECYCLE_SERVICE");
|
|
2070
|
+
const result = await serviceStart();
|
|
2071
|
+
print(result, () => formatServiceStatus(result.status));
|
|
2072
|
+
if (result.administratorCommand || !result.status.healthy) requestedExitCode = 1;
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
965
2075
|
const port = options.port === void 0 ? void 0 : Number(options.port);
|
|
966
|
-
const
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
2076
|
+
const daemonOptions = {};
|
|
2077
|
+
if (options.host !== void 0) daemonOptions.host = options.host;
|
|
2078
|
+
if (port !== void 0) daemonOptions.port = port;
|
|
2079
|
+
if (options.foreground !== void 0) daemonOptions.foreground = options.foreground;
|
|
2080
|
+
const result = await daemonUp(daemonOptions);
|
|
971
2081
|
if (options.foreground) return;
|
|
972
|
-
print(result, () => `Treeport is
|
|
2082
|
+
print(result, () => `Treeport is running\n${result.apiUrl}`);
|
|
973
2083
|
});
|
|
974
|
-
const
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
2084
|
+
const stopCommand = program.command("stop").description("Stop the local daemon and preserve terminal sessions").option("--terminate-terminals", "terminate every Treeport-owned tmux server").option("--force", "confirm termination of all terminals").option("--json", "emit machine-readable JSON");
|
|
2085
|
+
stopCommand.action(async () => {
|
|
2086
|
+
const lifecycle = await resolveDaemonLifecycle();
|
|
2087
|
+
if (lifecycle === "external") throw new CliError("Cannot run `treeport stop` because the daemon lifecycle is externally managed. Control the process that started Treeport instead.", 5, "DAEMON_LIFECYCLE_EXTERNAL");
|
|
2088
|
+
const options = stopCommand.opts();
|
|
978
2089
|
if (options.terminateTerminals && !options.force) throw new CliError("Re-run with --terminate-terminals --force to confirm termination of every terminal.", 2);
|
|
979
2090
|
if (options.terminateTerminals) await request("/api/admin/terminate-terminals", { method: "POST" });
|
|
2091
|
+
if (lifecycle === "service") {
|
|
2092
|
+
const result = await serviceStop();
|
|
2093
|
+
print(result, () => formatServiceStatus(result.status));
|
|
2094
|
+
if (result.administratorCommand) requestedExitCode = 1;
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
980
2097
|
const result = await daemonDown();
|
|
981
|
-
print(result, () => result.wasRunning ? "Treeport is
|
|
2098
|
+
print(result, () => result.wasRunning ? "Treeport is stopped" : "Treeport is already stopped");
|
|
2099
|
+
});
|
|
2100
|
+
const serviceCommand = program.command("service").description("Manage opt-in OS service supervision");
|
|
2101
|
+
serviceCommand.action(() => {
|
|
2102
|
+
writeStdout(serviceCommand.helpInformation());
|
|
2103
|
+
});
|
|
2104
|
+
serviceCommand.command("enable").description("Enable startup after reboot and unexpected-exit restarts").option("--json", "emit machine-readable JSON").action(async () => {
|
|
2105
|
+
const result = await serviceEnable();
|
|
2106
|
+
print(result, () => formatServiceStatus(result.status));
|
|
2107
|
+
if (result.status.state === "action_required") requestedExitCode = 1;
|
|
2108
|
+
});
|
|
2109
|
+
serviceCommand.command("status").description("Show OS service supervision status").option("--json", "emit machine-readable JSON").action(async () => {
|
|
2110
|
+
const result = await serviceStatus();
|
|
2111
|
+
print(result, () => formatServiceStatus(result));
|
|
2112
|
+
if (![
|
|
2113
|
+
"disabled",
|
|
2114
|
+
"healthy",
|
|
2115
|
+
"stopped"
|
|
2116
|
+
].includes(result.state) || !result.supported) requestedExitCode = 1;
|
|
2117
|
+
});
|
|
2118
|
+
serviceCommand.command("disable").description("Stop and unregister OS service supervision").option("--json", "emit machine-readable JSON").action(async () => {
|
|
2119
|
+
const result = await serviceDisable();
|
|
2120
|
+
print(result, () => formatServiceStatus(result.status));
|
|
2121
|
+
if (result.administratorCommand || result.status.state !== "disabled") requestedExitCode = 1;
|
|
2122
|
+
});
|
|
2123
|
+
serviceCommand.command("run", { hidden: true }).action(async () => serviceRun());
|
|
2124
|
+
const serviceApplyCommand = serviceCommand.command("apply", { hidden: true }).requiredOption("--request <absolute-path>", "prepared request");
|
|
2125
|
+
serviceApplyCommand.action(async () => {
|
|
2126
|
+
const { request: requestPath } = serviceApplyCommand.opts();
|
|
2127
|
+
const result = await serviceApply(requestPath);
|
|
2128
|
+
print(result, () => `Applied Treeport service ${result.operation} request.`);
|
|
982
2129
|
});
|
|
983
2130
|
const remoteCommand = program.command("remote").description("Expose Treeport privately through Tailscale Serve");
|
|
984
2131
|
remoteCommand.action(() => {
|
|
@@ -986,11 +2133,16 @@ async function main(args) {
|
|
|
986
2133
|
});
|
|
987
2134
|
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
2135
|
remoteEnableCommand.action(async () => {
|
|
989
|
-
|
|
2136
|
+
const lifecycle = await resolveDaemonLifecycle();
|
|
2137
|
+
if (lifecycle === "external") throw new CliError("Cannot run `treeport remote enable` because the daemon lifecycle is externally managed. Configure remote access through the process that started Treeport instead.", 5, "DAEMON_LIFECYCLE_EXTERNAL");
|
|
990
2138
|
const options = remoteEnableCommand.opts();
|
|
991
2139
|
const port = options.port === void 0 ? void 0 : Number(options.port);
|
|
992
2140
|
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
|
|
2141
|
+
const serviceDaemon = lifecycle === "service" ? await ensureServiceDaemon() : void 0;
|
|
2142
|
+
const remoteOptions = {};
|
|
2143
|
+
if (port !== void 0) remoteOptions.port = port;
|
|
2144
|
+
if (serviceDaemon !== void 0) remoteOptions.daemon = serviceDaemon;
|
|
2145
|
+
const result = await enableTailscaleRemote(remoteOptions);
|
|
994
2146
|
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
2147
|
});
|
|
996
2148
|
remoteCommand.command("status").description("Show Tailscale remote access status").option("--json", "emit machine-readable JSON").action(async () => {
|
|
@@ -1009,27 +2161,29 @@ async function main(args) {
|
|
|
1009
2161
|
});
|
|
1010
2162
|
program.command("status").description("Show local daemon status").option("--json", "emit machine-readable JSON").action(async () => {
|
|
1011
2163
|
const status = await daemonStatus();
|
|
2164
|
+
const supervision = await serviceInstalled() ? await serviceStatus() : null;
|
|
1012
2165
|
const projectList = status.verified ? await projects() : [];
|
|
1013
2166
|
const result = {
|
|
1014
2167
|
...status,
|
|
2168
|
+
service: supervision,
|
|
1015
2169
|
projects: projectList.length,
|
|
1016
2170
|
worktrees: projectList.reduce((count, project) => count + project.worktrees.length, 0),
|
|
1017
2171
|
terminals: projectList.reduce((count, project) => count + project.worktrees.reduce((worktreeCount, worktree) => worktreeCount + worktree.terminals.length, 0), 0)
|
|
1018
2172
|
};
|
|
1019
2173
|
print(result, () => {
|
|
1020
|
-
if (!status.state) return "Treeport is
|
|
2174
|
+
if (!status.state) return supervision ? formatServiceStatus(supervision) : "Treeport is stopped";
|
|
1021
2175
|
if (!status.running || !status.verified) return `Treeport is unhealthy (PID ${status.state.pid})\nLogs: ${path.join(status.state.dataDir, "logs", "daemon.log")}`;
|
|
1022
|
-
return `Treeport is
|
|
2176
|
+
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}`;
|
|
1023
2177
|
});
|
|
1024
2178
|
});
|
|
1025
2179
|
const logsCommand = program.command("logs").description("Show recent daemon logs").option("--lines <count>", "number of lines", "100");
|
|
1026
2180
|
logsCommand.action(async () => {
|
|
1027
2181
|
const lines = Number(logsCommand.opts().lines);
|
|
1028
2182
|
if (!Number.isInteger(lines) || lines < 1 || lines > 1e4) throw new CliError("--lines must be an integer between 1 and 10000", 2);
|
|
1029
|
-
writeStdout(await readDaemonLogs(lines));
|
|
2183
|
+
writeStdout(await serviceInstalled() ? await readServiceLogs(lines) : await readDaemonLogs(lines));
|
|
1030
2184
|
});
|
|
1031
2185
|
program.command("doctor").description("Diagnose local requirements and paths").option("--json", "emit machine-readable JSON").action(async () => {
|
|
1032
|
-
const checks = await runDoctor();
|
|
2186
|
+
const checks = [...await runDoctor(), await serviceDoctorCheck()];
|
|
1033
2187
|
print(checks, () => checks.map((check) => `${check.ok ? "ok" : "error"}\t${check.name}\t${check.detail}`).join("\n"));
|
|
1034
2188
|
if (checks.some((check) => !check.ok)) requestedExitCode = 1;
|
|
1035
2189
|
});
|
|
@@ -1043,7 +2197,7 @@ async function main(args) {
|
|
|
1043
2197
|
});
|
|
1044
2198
|
program.command("skills").description("Print the Treeport usage guide for AI agents").action(async () => {
|
|
1045
2199
|
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
|
|
2200
|
+
writeStdout(await resolveDaemonLifecycle() === "external" ? skill.replace("\n# Treeport\n", "\n# Treeport\n\n> **Externally managed daemon lifecycle:** Do not run `treeport start`, `treeport stop`, or `treeport remote enable`. The process that started Treeport owns startup, shutdown, remote exposure, and logs. Other Treeport commands continue to use the configured daemon normally.\n") : skill);
|
|
1047
2201
|
});
|
|
1048
2202
|
program.command("context").description("Show the current Treeport-managed terminal context").option("--json", "emit machine-readable JSON").action(async () => {
|
|
1049
2203
|
const projectId = contextProjectId;
|
|
@@ -1069,12 +2223,12 @@ async function main(args) {
|
|
|
1069
2223
|
if (missing.length) throw new CliError(`Incomplete Treeport context; missing ${missing.join(", ")}`, 5, "TREEPORT_CONTEXT_INCOMPLETE", { missing });
|
|
1070
2224
|
const project = (await request(`/api/projects/${encodeURIComponent(projectId)}`)).project;
|
|
1071
2225
|
const worktree = project.worktrees.find((candidate) => candidate.id === worktreeId);
|
|
1072
|
-
if (!worktree) throw new CliError("Treeport context
|
|
2226
|
+
if (!worktree) throw new CliError("Treeport context tree does not belong to the current project", 5, "TREEPORT_CONTEXT_INVALID", {
|
|
1073
2227
|
projectId,
|
|
1074
2228
|
worktreeId
|
|
1075
2229
|
});
|
|
1076
2230
|
const terminal = worktree.terminals.find((candidate) => candidate.id === terminalId);
|
|
1077
|
-
if (!terminal) throw new CliError("Treeport context terminal does not belong to the current
|
|
2231
|
+
if (!terminal) throw new CliError("Treeport context terminal does not belong to the current tree", 5, "TREEPORT_CONTEXT_INVALID", {
|
|
1078
2232
|
worktreeId,
|
|
1079
2233
|
terminalId
|
|
1080
2234
|
});
|
|
@@ -1108,29 +2262,27 @@ async function main(args) {
|
|
|
1108
2262
|
exitCode: terminal.exitCode
|
|
1109
2263
|
}
|
|
1110
2264
|
};
|
|
1111
|
-
print(context, () => `Treeport context\n\nProject: ${context.project.name} (${context.project.id})\
|
|
2265
|
+
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"}`);
|
|
1112
2266
|
});
|
|
1113
2267
|
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
2268
|
installCommand.action(async (source) => {
|
|
1115
2269
|
const options = installCommand.opts();
|
|
2270
|
+
const body = { source: await packageSource(source) };
|
|
2271
|
+
if (options.local) body.projectId = await localPackageProjectId();
|
|
1116
2272
|
const result = (await request("/api/packages/install", {
|
|
1117
2273
|
method: "POST",
|
|
1118
|
-
body: JSON.stringify(
|
|
1119
|
-
source: await packageSource(source),
|
|
1120
|
-
...options.local ? { projectId: await localPackageProjectId() } : {}
|
|
1121
|
-
})
|
|
2274
|
+
body: JSON.stringify(body)
|
|
1122
2275
|
})).result;
|
|
1123
2276
|
print(result, () => `Installed ${result.source}${result.scope === "project" ? ` for project ${result.projectId}` : " globally"}`);
|
|
1124
2277
|
});
|
|
1125
2278
|
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
2279
|
removePackageCommand.action(async (source) => {
|
|
1127
2280
|
const options = removePackageCommand.opts();
|
|
2281
|
+
const body = { source: await packageSource(source) };
|
|
2282
|
+
if (options.local) body.projectId = await localPackageProjectId();
|
|
1128
2283
|
const result = (await request("/api/packages/remove", {
|
|
1129
2284
|
method: "POST",
|
|
1130
|
-
body: JSON.stringify(
|
|
1131
|
-
source: await packageSource(source),
|
|
1132
|
-
...options.local ? { projectId: await localPackageProjectId() } : {}
|
|
1133
|
-
})
|
|
2285
|
+
body: JSON.stringify(body)
|
|
1134
2286
|
})).result;
|
|
1135
2287
|
print(result, () => `Removed ${result.source}`);
|
|
1136
2288
|
});
|
|
@@ -1180,29 +2332,30 @@ async function main(args) {
|
|
|
1180
2332
|
const list = await projects();
|
|
1181
2333
|
print(list, () => list.map((project) => `${project.id}\t${project.name}\t${project.repositoryPath}`).join("\n"));
|
|
1182
2334
|
});
|
|
1183
|
-
const worktreeCommand = program.command("worktree").description("List, create, and remove
|
|
2335
|
+
const worktreeCommand = program.command("worktree").description("List, create, and remove trees");
|
|
1184
2336
|
worktreeCommand.action(() => {
|
|
1185
2337
|
throw new CliError(worktreeCommand.helpInformation(), 2);
|
|
1186
2338
|
});
|
|
1187
|
-
const worktreeListCommand = worktreeCommand.command("list").description("List discovered
|
|
2339
|
+
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");
|
|
1188
2340
|
worktreeListCommand.action(async () => {
|
|
1189
2341
|
const { project: projectIdentifier } = worktreeListCommand.opts();
|
|
1190
2342
|
const list = projectIdentifier ? (await resolveProject(projectIdentifier)).worktrees : (await projects()).flatMap((project) => project.worktrees);
|
|
1191
2343
|
print(list, () => list.map((worktree) => `${worktree.id}\t${worktree.name}\t${worktree.branch ?? `detached@${worktree.head.slice(0, 8)}`}\t${worktree.path}`).join("\n"));
|
|
1192
2344
|
});
|
|
1193
|
-
const worktreeCreateCommand = worktreeCommand.command("create").description("Create a linked
|
|
2345
|
+
const worktreeCreateCommand = worktreeCommand.command("create").description("Create a linked tree").requiredOption("--project <id-or-path>", "project to create from").requiredOption("--name <name>", "Tree name").option("--from-current", "base the tree on the current tree").option("--json", "emit machine-readable JSON");
|
|
1194
2346
|
worktreeCreateCommand.action(async () => {
|
|
1195
2347
|
const options = worktreeCreateCommand.opts();
|
|
1196
2348
|
const project = await resolveProject(options.project);
|
|
1197
2349
|
const sourceWorktreeId = options.fromCurrent ? (await resolveWorktree(".")).id : void 0;
|
|
1198
|
-
const
|
|
2350
|
+
const request = {
|
|
1199
2351
|
name: options.name,
|
|
1200
|
-
base: options.fromCurrent ? "current" : "default"
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
2352
|
+
base: options.fromCurrent ? "current" : "default"
|
|
2353
|
+
};
|
|
2354
|
+
if (sourceWorktreeId) request.sourceWorktreeId = sourceWorktreeId;
|
|
2355
|
+
const result = await createWorktree(project.id, request);
|
|
2356
|
+
print(result, () => `Created tree ${result.worktree.name} (${result.worktree.id})\n${result.worktree.path}${result.setupError ? `\nSetup error: ${result.setupError}` : ""}`);
|
|
1204
2357
|
});
|
|
1205
|
-
const worktreeRemoveCommand = worktreeCommand.command("remove").description("Remove a linked
|
|
2358
|
+
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");
|
|
1206
2359
|
worktreeRemoveCommand.action(async (identifier) => {
|
|
1207
2360
|
const { force: confirmed } = worktreeRemoveCommand.opts();
|
|
1208
2361
|
const worktree = await resolveWorktree(identifier);
|
|
@@ -1220,18 +2373,18 @@ async function main(args) {
|
|
|
1220
2373
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1221
2374
|
operation = (await request(`/api/operations/${encodeURIComponent(operation.id)}`)).operation;
|
|
1222
2375
|
}
|
|
1223
|
-
if (operation.status === "failed") throw new CliError(operation.error ?? "
|
|
1224
|
-
if (operation.kind !== "remove" || !operation.result) throw new CliError("
|
|
2376
|
+
if (operation.status === "failed") throw new CliError(operation.error ?? "Tree removal failed", 5, "WORKTREE_REMOVAL_FAILED");
|
|
2377
|
+
if (operation.kind !== "remove" || !operation.result) throw new CliError("Tree removal returned an unexpected operation result", 5, "INVALID_OPERATION_RESULT");
|
|
1225
2378
|
print(operation.result, () => {
|
|
1226
2379
|
const warning = operation.result?.cleanup.warning;
|
|
1227
|
-
return `Removed ${worktree.name} (${worktree.id})${warning ? `\nWarning: ${warning}` : ""}`;
|
|
2380
|
+
return `Removed tree ${worktree.name} (${worktree.id})${warning ? `\nWarning: ${warning}` : ""}`;
|
|
1228
2381
|
});
|
|
1229
2382
|
});
|
|
1230
2383
|
const webPanelCommand = program.command("web-panel").description("Open persistent web panels");
|
|
1231
2384
|
webPanelCommand.action(() => {
|
|
1232
2385
|
throw new CliError(webPanelCommand.helpInformation(), 2);
|
|
1233
2386
|
});
|
|
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
|
|
2387
|
+
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");
|
|
1235
2388
|
webPanelOpenCommand.action(async (identifier) => {
|
|
1236
2389
|
const options = webPanelOpenCommand.opts();
|
|
1237
2390
|
const worktree = await resolveWorktree(options.worktree);
|
|
@@ -1252,25 +2405,25 @@ async function main(args) {
|
|
|
1252
2405
|
};
|
|
1253
2406
|
print(output, () => `${result.reused ? "Reused" : "Opened"} ${result.panel.title} (${result.panel.id})\n${output.url}`);
|
|
1254
2407
|
});
|
|
1255
|
-
const terminalCommand = program.command("terminal").description("Manage persistent
|
|
2408
|
+
const terminalCommand = program.command("terminal").description("Manage persistent tree terminals");
|
|
1256
2409
|
terminalCommand.action(() => {
|
|
1257
2410
|
throw new CliError(terminalCommand.helpInformation(), 2);
|
|
1258
2411
|
});
|
|
1259
|
-
const terminalListCommand = terminalCommand.command("list").description("List terminals").option("--worktree <id-or-path>", "limit results to a
|
|
2412
|
+
const terminalListCommand = terminalCommand.command("list").description("List terminals").option("--worktree <id-or-path>", "limit results to a tree").option("--json", "emit machine-readable JSON");
|
|
1260
2413
|
terminalListCommand.action(async () => {
|
|
1261
2414
|
const { worktree: identifier } = terminalListCommand.opts();
|
|
1262
2415
|
const list = identifier ? (await resolveWorktree(identifier)).terminals : (await projects()).flatMap((project) => project.worktrees.flatMap((worktree) => worktree.terminals));
|
|
1263
2416
|
print(list, () => list.map((terminal) => `${terminal.id}\t${terminal.name}\t${terminal.status}\t${JSON.stringify(terminal.argv)}`).join("\n"));
|
|
1264
2417
|
});
|
|
1265
|
-
const terminalCreateCommand = terminalCommand.command("create").description("Create a persistent terminal").usage("[options] [-- <command> args...]").requiredOption("--worktree <id-or-path-or-dot>", "owning
|
|
2418
|
+
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");
|
|
1266
2419
|
terminalCreateCommand.action(async () => {
|
|
1267
2420
|
const options = terminalCreateCommand.opts();
|
|
1268
|
-
const
|
|
2421
|
+
const worktree = await resolveWorktree(options.worktree);
|
|
2422
|
+
const body = { name: options.name };
|
|
2423
|
+
if (argv) body.argv = argv;
|
|
2424
|
+
const result = await request(`/api/worktrees/${worktree.id}/terminals`, {
|
|
1269
2425
|
method: "POST",
|
|
1270
|
-
body: JSON.stringify(
|
|
1271
|
-
name: options.name,
|
|
1272
|
-
...argv ? { argv } : {}
|
|
1273
|
-
})
|
|
2426
|
+
body: JSON.stringify(body)
|
|
1274
2427
|
});
|
|
1275
2428
|
print(result.terminal, () => `Created ${result.terminal.name} (${result.terminal.id})`);
|
|
1276
2429
|
});
|
|
@@ -1314,21 +2467,21 @@ async function main(args) {
|
|
|
1314
2467
|
terminalId
|
|
1315
2468
|
}, () => `Deleted ${terminalId}`);
|
|
1316
2469
|
});
|
|
1317
|
-
const spawnCommand = program.command("spawn").description("Create a
|
|
2470
|
+
const spawnCommand = program.command("spawn").description("Create a tree and its first terminal").usage("[options] [-- <command> args...]").requiredOption("--project <id-or-path-or-dot>", "project to create from").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");
|
|
1318
2471
|
spawnCommand.action(async () => {
|
|
1319
2472
|
const options = spawnCommand.opts();
|
|
1320
2473
|
const project = await resolveProject(options.project);
|
|
1321
2474
|
const sourceWorktreeId = options.fromCurrent ? (await resolveWorktree(".")).id : void 0;
|
|
1322
|
-
const
|
|
2475
|
+
const initialTerminal = { name: options.name };
|
|
2476
|
+
if (argv) initialTerminal.argv = argv;
|
|
2477
|
+
const request = {
|
|
1323
2478
|
name: options.worktreeName,
|
|
1324
2479
|
base: options.fromCurrent ? "current" : "default",
|
|
1325
|
-
initialTerminal
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
});
|
|
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}` : ""}`);
|
|
2480
|
+
initialTerminal
|
|
2481
|
+
};
|
|
2482
|
+
if (sourceWorktreeId) request.sourceWorktreeId = sourceWorktreeId;
|
|
2483
|
+
const result = await createWorktree(project.id, request);
|
|
2484
|
+
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}` : ""}`);
|
|
1332
2485
|
});
|
|
1333
2486
|
try {
|
|
1334
2487
|
await program.parseAsync(args, { from: "user" });
|
|
@@ -1360,9 +2513,9 @@ async function runCliApplication(options) {
|
|
|
1360
2513
|
if (jsonOutput) {
|
|
1361
2514
|
const body = { error: {
|
|
1362
2515
|
code: cliError.code,
|
|
1363
|
-
message: cliError.message
|
|
1364
|
-
...cliError.details === void 0 ? {} : { details: cliError.details }
|
|
2516
|
+
message: cliError.message
|
|
1365
2517
|
} };
|
|
2518
|
+
if (cliError.details !== void 0) body.error.details = cliError.details;
|
|
1366
2519
|
writeStderr(`${JSON.stringify(body)}\n`);
|
|
1367
2520
|
} else writeStderr(`${cliError.message}\n`);
|
|
1368
2521
|
requestedExitCode = cliError.exitCode;
|