@treeport/treeport 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { A as parseProductEvent, j as SOCKET_IO_PATH, k as parseEventsSnapshot, n as parseDurationMs, r as TERMINAL_CAPTURE_MAX_LINES, t as assertLoopbackHost } from "../../loopback-4XVZbAD1.js";
2
+ import { A as parseProductEvent, j as SOCKET_IO_PATH, k as parseEventsSnapshot, n as parseDurationMs, r as TERMINAL_CAPTURE_MAX_LINES, t as assertLoopbackHost } from "../../loopback-Dyv_owrb.js";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { Command, CommanderError } from "commander";
@@ -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(["treeport", "external"]),
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 up --host 127.0.0.1`, then try again.");
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,1015 @@ 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
+ installationMethod: z.enum(["curl", "npm"]),
605
+ definitionName: z.string().min(1),
606
+ definitionPath: z.string().min(1),
607
+ definitionHash: z.string().length(64),
608
+ environmentHash: z.string().length(64),
609
+ environment: z.record(z.string(), z.string()),
610
+ requestedState: z.enum(["running", "stopped"]),
611
+ pendingAdministratorRequestId: z.string().nullable(),
612
+ createdAt: z.string(),
613
+ updatedAt: z.string()
614
+ });
615
+ const administratorRequestSchema = z.strictObject({
616
+ schemaVersion: z.literal(1),
617
+ id: z.string().uuid(),
618
+ operation: z.enum([
619
+ "enable",
620
+ "start",
621
+ "stop",
622
+ "disable"
623
+ ]),
624
+ createdAt: z.string(),
625
+ expiresAt: z.string(),
626
+ uid: z.number().int().nonnegative(),
627
+ gid: z.number().int().nonnegative(),
628
+ username: z.string().min(1),
629
+ group: z.string().min(1),
630
+ home: z.string().min(1),
631
+ serviceRecordPath: z.string().min(1),
632
+ runnerPath: z.string().min(1),
633
+ definitionName: z.string().min(1),
634
+ definitionPath: z.string().min(1),
635
+ stagedDefinitionPath: z.string().min(1),
636
+ definitionHash: z.string().length(64),
637
+ apiUrl: z.string().min(1),
638
+ cliEntrypoint: z.string().min(1)
639
+ });
640
+ function managerForPlatform(platform = process.platform) {
641
+ return platform === "darwin" ? "launchd" : platform === "linux" ? "systemd" : null;
642
+ }
643
+ function servicePaths(env = process.env) {
644
+ const paths = localPaths(env);
645
+ const directory = path.join(paths.dataDir, "service");
646
+ return {
647
+ directory,
648
+ recordPath: path.join(directory, "service.json"),
649
+ runnerPath: path.join(directory, "run"),
650
+ requestsDirectory: path.join(directory, "requests"),
651
+ stagedDefinitionPath: path.join(directory, "treeport.plist")
652
+ };
653
+ }
654
+ async function readJson(filePath, schema) {
655
+ return fs.readFile(filePath, "utf8").then((value) => schema.parse(JSON.parse(value))).catch(() => null);
656
+ }
657
+ async function writeJson(filePath, value) {
658
+ await fs.mkdir(path.dirname(filePath), {
659
+ recursive: true,
660
+ mode: 448
661
+ });
662
+ const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
663
+ await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
664
+ await fs.rename(temporaryPath, filePath);
665
+ }
666
+ function fingerprint(value) {
667
+ const source = typeof value === "string" ? value : JSON.stringify(Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))));
668
+ return crypto.createHash("sha256").update(source).digest("hex");
669
+ }
670
+ function xml(value) {
671
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
672
+ }
673
+ function shellQuote(value) {
674
+ return `'${value.replaceAll("'", `'\\''`)}'`;
675
+ }
676
+ function systemdValue(value) {
677
+ return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("%", "%%").replaceAll("\n", "\\n");
678
+ }
679
+ function createLaunchdDefinition(input) {
680
+ return {
681
+ label: input.label,
682
+ programArguments: [input.runnerPath],
683
+ username: input.username,
684
+ group: input.group,
685
+ environment: input.environment,
686
+ workingDirectory: input.home,
687
+ standardOutPath: input.logPath,
688
+ standardErrorPath: input.logPath,
689
+ keepAlive: true,
690
+ processType: "Background",
691
+ throttleInterval: 10,
692
+ exitTimeOut: 10,
693
+ abandonProcessGroup: true,
694
+ umask: 63
695
+ };
696
+ }
697
+ function serializeLaunchdDefinition(definition) {
698
+ const environment = Object.entries(definition.environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, value]) => ` <key>${xml(name)}</key>\n <string>${xml(value)}</string>`).join("\n");
699
+ const argumentsXml = definition.programArguments.map((argument) => ` <string>${xml(argument)}</string>`).join("\n");
700
+ return `<?xml version="1.0" encoding="UTF-8"?>
701
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
702
+ <plist version="1.0">
703
+ <dict>
704
+ <key>Label</key>
705
+ <string>${xml(definition.label)}</string>
706
+ <key>ProgramArguments</key>
707
+ <array>
708
+ ${argumentsXml}
709
+ </array>
710
+ <key>UserName</key>
711
+ <string>${xml(definition.username)}</string>
712
+ <key>GroupName</key>
713
+ <string>${xml(definition.group)}</string>
714
+ <key>EnvironmentVariables</key>
715
+ <dict>
716
+ ${environment}
717
+ </dict>
718
+ <key>WorkingDirectory</key>
719
+ <string>${xml(definition.workingDirectory)}</string>
720
+ <key>StandardOutPath</key>
721
+ <string>${xml(definition.standardOutPath)}</string>
722
+ <key>StandardErrorPath</key>
723
+ <string>${xml(definition.standardErrorPath)}</string>
724
+ <key>KeepAlive</key>
725
+ <true/>
726
+ <key>ProcessType</key>
727
+ <string>${definition.processType}</string>
728
+ <key>ThrottleInterval</key>
729
+ <integer>${definition.throttleInterval}</integer>
730
+ <key>ExitTimeOut</key>
731
+ <integer>${definition.exitTimeOut}</integer>
732
+ <key>AbandonProcessGroup</key>
733
+ <true/>
734
+ <key>Umask</key>
735
+ <integer>${definition.umask}</integer>
736
+ </dict>
737
+ </plist>
738
+ `;
739
+ }
740
+ function createSystemdDefinition(input) {
741
+ return {
742
+ description: "Treeport daemon",
743
+ execStart: input.runnerPath,
744
+ environment: input.environment,
745
+ restart: "always",
746
+ restartSeconds: 5,
747
+ timeoutStopSeconds: 10,
748
+ killMode: "process",
749
+ wantedBy: "default.target"
750
+ };
751
+ }
752
+ function serializeSystemdDefinition(definition) {
753
+ const environment = Object.entries(definition.environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, value]) => `Environment="${systemdValue(name)}=${systemdValue(value)}"`).join("\n");
754
+ return `[Unit]
755
+ Description=${definition.description}
756
+
757
+ [Service]
758
+ Type=simple
759
+ ExecStart="${systemdValue(definition.execStart)}"
760
+ ${environment}
761
+ Restart=${definition.restart}
762
+ RestartSec=${definition.restartSeconds}
763
+ TimeoutStopSec=${definition.timeoutStopSeconds}
764
+ KillMode=${definition.killMode}
765
+
766
+ [Install]
767
+ WantedBy=${definition.wantedBy}
768
+ `;
769
+ }
770
+ async function runCommand(executable, args, environment = process.env) {
771
+ return new Promise((resolve) => {
772
+ const child = spawn(executable, args, {
773
+ env: environment,
774
+ stdio: [
775
+ "ignore",
776
+ "pipe",
777
+ "pipe"
778
+ ]
779
+ });
780
+ let stdout = "";
781
+ let stderr = "";
782
+ child.stdout.setEncoding("utf8");
783
+ child.stderr.setEncoding("utf8");
784
+ child.stdout.on("data", (value) => {
785
+ stdout += value;
786
+ });
787
+ child.stderr.on("data", (value) => {
788
+ stderr += value;
789
+ });
790
+ child.once("error", (error) => {
791
+ resolve({
792
+ code: 127,
793
+ stdout,
794
+ stderr: error.message
795
+ });
796
+ });
797
+ child.once("close", (code) => {
798
+ resolve({
799
+ code: code ?? 1,
800
+ stdout,
801
+ stderr
802
+ });
803
+ });
804
+ });
805
+ }
806
+ function commandError(command, result) {
807
+ const detail = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n");
808
+ return /* @__PURE__ */ new Error(`${command} failed${detail ? `: ${detail}` : ` with status ${result.code}`}`);
809
+ }
810
+ async function executablePath(name) {
811
+ const candidates = name === "launchctl" ? ["/bin/launchctl", "/usr/bin/launchctl"] : [`/usr/bin/${name}`, `/bin/${name}`];
812
+ for (const candidate of candidates) if (await fs.access(candidate, constants.X_OK).then(() => true).catch(() => false)) return candidate;
813
+ return name;
814
+ }
815
+ async function primaryGroup(username) {
816
+ const result = await runCommand(await executablePath("id"), ["-gn", username]);
817
+ if (result.code !== 0 || !result.stdout.trim()) throw commandError("id -gn", result);
818
+ return result.stdout.trim();
819
+ }
820
+ function currentEntrypoint() {
821
+ const value = process.env.TREEPORT_CLI_ENTRYPOINT?.trim() || process.argv[1]?.trim();
822
+ return value ? path.resolve(value) : null;
823
+ }
824
+ async function ensureEntrypoint(installationMethod) {
825
+ const entrypoint = currentEntrypoint();
826
+ if (!entrypoint) throw new Error("Treeport could not identify a stable CLI entrypoint. Install Treeport with npm or the curl installer, then retry.");
827
+ await fs.access(entrypoint, constants.X_OK).catch(() => {
828
+ throw new Error(`Treeport cannot execute its stable CLI entrypoint at ${entrypoint}. Reinstall Treeport, then retry.`);
829
+ });
830
+ if (installationMethod === "npm") {
831
+ const [actual, expected] = await Promise.all([fs.realpath(entrypoint), fs.realpath(await resolvePackagePath("bin", "treeport.mjs"))]);
832
+ if (actual !== expected) throw new Error(`The current CLI entrypoint is not the installed Treeport npm bin: ${entrypoint}`);
833
+ }
834
+ return entrypoint;
835
+ }
836
+ function cacheDirectory(home, env) {
837
+ const configured = env.TREEPORT_CACHE_DIR?.trim();
838
+ if (configured) return path.resolve(configured.replace(/^~(?=\/|$)/, home));
839
+ if (env.XDG_CACHE_HOME?.trim()) return path.join(path.resolve(env.XDG_CACHE_HOME.replace(/^~(?=\/|$)/, home)), "treeport");
840
+ return process.platform === "darwin" ? path.join(home, "Library", "Caches", "treeport") : path.join(home, ".cache", "treeport");
841
+ }
842
+ function createServiceEnvironment(input) {
843
+ const env = input.env ?? process.env;
844
+ const url = new URL(input.apiUrl);
845
+ assertLoopbackHost(url.hostname);
846
+ const result = {
847
+ HOME: input.user.homedir,
848
+ USER: input.user.username,
849
+ LOGNAME: input.user.username,
850
+ PATH: env.PATH?.trim() || "/usr/local/bin:/usr/bin:/bin",
851
+ TREEPORT_HOST: url.hostname,
852
+ TREEPORT_PORT: url.port || "80",
853
+ TREEPORT_API_URL: input.apiUrl,
854
+ TREEPORT_DATA_DIR: input.paths.dataDir,
855
+ TREEPORT_RUNTIME_DIR: input.paths.runtimeDir,
856
+ TREEPORT_CACHE_DIR: cacheDirectory(input.user.homedir, env),
857
+ TREEPORT_DATABASE_PATH: env.TREEPORT_DATABASE_PATH?.trim() || path.join(input.paths.dataDir, "treeport.db"),
858
+ TREEPORT_SHELL: env.TREEPORT_SHELL?.trim() || env.SHELL?.trim() || "/bin/sh",
859
+ TREEPORT_TMUX_PATH: env.TREEPORT_TMUX_PATH?.trim() || "tmux",
860
+ TREEPORT_GIT_PATH: env.TREEPORT_GIT_PATH?.trim() || "git",
861
+ TREEPORT_GH_PATH: env.TREEPORT_GH_PATH?.trim() || "gh",
862
+ TREEPORT_DAEMON_LIFECYCLE: "service",
863
+ TREEPORT_INSTALLATION_METHOD: input.installationMethod,
864
+ TREEPORT_SERVICE_RECORD: input.recordPath
865
+ };
866
+ for (const [name, value] of Object.entries(env)) if (value !== void 0 && (name === "LANG" || name === "LC_ALL" || name.startsWith("LC_"))) result[name] = value;
867
+ return result;
868
+ }
869
+ function definitionForRecord(record) {
870
+ if (record.manager === "launchd") return serializeLaunchdDefinition(createLaunchdDefinition({
871
+ label: record.definitionName,
872
+ runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath,
873
+ username: record.username,
874
+ group: record.group,
875
+ environment: record.environment,
876
+ home: record.home,
877
+ logPath: record.logPath
878
+ }));
879
+ return serializeSystemdDefinition(createSystemdDefinition({
880
+ runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath,
881
+ environment: record.environment
882
+ }));
883
+ }
884
+ function runnerSource(record) {
885
+ return `#!/bin/sh
886
+ set -u
887
+ entrypoint=${shellQuote(record.cliEntrypoint)}
888
+ record=${shellQuote(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).recordPath)}
889
+ log=${shellQuote(record.logPath)}
890
+ reported=0
891
+ while [ ! -x "$entrypoint" ]; do
892
+ if [ "$reported" -eq 0 ]; then
893
+ mkdir -p "$(dirname "$log")"
894
+ printf '%s Treeport service cannot start because %s is missing. Reinstall Treeport, then run treeport service enable or treeport service disable.\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$entrypoint" >> "$log"
895
+ reported=1
896
+ fi
897
+ sleep 60
898
+ done
899
+ export TREEPORT_SERVICE_RECORD="$record"
900
+ exec "$entrypoint" service run
901
+ `;
902
+ }
903
+ async function currentRecord() {
904
+ return readJson(servicePaths().recordPath, serviceRecordSchema);
905
+ }
906
+ async function saveRecord(record) {
907
+ await writeJson(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).recordPath, record);
908
+ }
909
+ async function managerState(record) {
910
+ if (record.manager === "launchd") {
911
+ const launchctl = await executablePath("launchctl");
912
+ const [active, disabled, definitionExists] = await Promise.all([
913
+ runCommand(launchctl, ["print", `system/${record.definitionName}`]),
914
+ runCommand(launchctl, ["print-disabled", "system"]),
915
+ fs.access(record.definitionPath).then(() => true).catch(() => false)
916
+ ]);
917
+ return {
918
+ active: active.code === 0,
919
+ enabled: definitionExists && !disabled.stdout.includes(`"${record.definitionName}" => true`),
920
+ lingering: true,
921
+ managerIssue: null
922
+ };
923
+ }
924
+ const systemctl = await executablePath("systemctl");
925
+ const [active, enabled, linger] = await Promise.all([
926
+ runCommand(systemctl, [
927
+ "--user",
928
+ "is-active",
929
+ record.definitionName
930
+ ]),
931
+ runCommand(systemctl, [
932
+ "--user",
933
+ "is-enabled",
934
+ record.definitionName
935
+ ]),
936
+ runCommand(await executablePath("loginctl"), [
937
+ "show-user",
938
+ record.username,
939
+ "-p",
940
+ "Linger",
941
+ "--value"
942
+ ])
943
+ ]);
944
+ return {
945
+ active: active.code === 0 && active.stdout.trim() === "active",
946
+ enabled: enabled.code === 0 && enabled.stdout.trim() === "enabled",
947
+ lingering: linger.code === 0 && linger.stdout.trim() === "yes",
948
+ managerIssue: active.code === 127 || active.stderr.includes("Failed to connect to bus") || active.stderr.includes("No medium found") ? "The systemd user manager is not available." : linger.code === 127 ? "loginctl is not available." : null
949
+ };
950
+ }
951
+ function administratorCommand(record) {
952
+ const requestId = record.pendingAdministratorRequestId;
953
+ if (!requestId) return null;
954
+ const requestPath = path.join(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).requestsDirectory, `${requestId}.json`);
955
+ return `sudo ${shellQuote(record.cliEntrypoint)} service apply --request ${shellQuote(requestPath)}`;
956
+ }
957
+ async function untrackedDefinition() {
958
+ const manager = managerForPlatform();
959
+ if (!manager) return null;
960
+ const user = os.userInfo();
961
+ const name = manager === "launchd" ? `app.treeport.daemon.${user.uid}` : "treeport.service";
962
+ const definitionPath = manager === "launchd" ? `/Library/LaunchDaemons/${name}.plist` : path.join(process.env.XDG_CONFIG_HOME?.trim() || path.join(user.homedir, ".config"), "systemd", "user", name);
963
+ return await fs.access(definitionPath).then(() => true).catch(() => false) ? {
964
+ manager,
965
+ name,
966
+ path: definitionPath
967
+ } : null;
968
+ }
969
+ async function serviceInstalled() {
970
+ return await currentRecord() !== null || await untrackedDefinition() !== null;
971
+ }
972
+ async function serviceStatus() {
973
+ const manager = managerForPlatform();
974
+ const record = await currentRecord();
975
+ if (!manager) return {
976
+ supported: false,
977
+ manager: null,
978
+ state: "disabled",
979
+ installed: false,
980
+ enabledAtBoot: false,
981
+ active: false,
982
+ healthy: false,
983
+ rebootReady: false,
984
+ definitionMatches: false,
985
+ environmentMatches: false,
986
+ entrypointMatches: false,
987
+ requestedState: null,
988
+ definitionPath: null,
989
+ entrypoint: null,
990
+ daemon: null,
991
+ issues: [`Treeport service mode does not support ${process.platform}.`],
992
+ recoveryCommands: [],
993
+ administratorCommand: null
994
+ };
995
+ if (!record) {
996
+ const untracked = await untrackedDefinition();
997
+ if (!untracked) return {
998
+ supported: true,
999
+ manager,
1000
+ state: "disabled",
1001
+ installed: false,
1002
+ enabledAtBoot: false,
1003
+ active: false,
1004
+ healthy: false,
1005
+ rebootReady: false,
1006
+ definitionMatches: false,
1007
+ environmentMatches: false,
1008
+ entrypointMatches: false,
1009
+ requestedState: null,
1010
+ definitionPath: null,
1011
+ entrypoint: null,
1012
+ daemon: null,
1013
+ issues: [],
1014
+ recoveryCommands: ["treeport service enable"],
1015
+ administratorCommand: null
1016
+ };
1017
+ return {
1018
+ supported: true,
1019
+ manager,
1020
+ state: "stale",
1021
+ installed: true,
1022
+ enabledAtBoot: true,
1023
+ active: (untracked.manager === "launchd" ? await runCommand(await executablePath("launchctl"), ["print", `system/${untracked.name}`]) : await runCommand(await executablePath("systemctl"), [
1024
+ "--user",
1025
+ "is-active",
1026
+ untracked.name
1027
+ ])).code === 0,
1028
+ healthy: false,
1029
+ rebootReady: false,
1030
+ definitionMatches: false,
1031
+ environmentMatches: false,
1032
+ entrypointMatches: false,
1033
+ requestedState: null,
1034
+ definitionPath: untracked.path,
1035
+ entrypoint: null,
1036
+ daemon: null,
1037
+ issues: [`A Treeport service definition exists at ${untracked.path}, but its service record is missing. Restore the original Treeport data directory or ask an administrator to inspect and remove the definition.`],
1038
+ recoveryCommands: [],
1039
+ administratorCommand: null
1040
+ };
1041
+ }
1042
+ const paths = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
1043
+ const [managerStatus, definitionContent, entrypointExists, daemon] = await Promise.all([
1044
+ managerState(record),
1045
+ fs.readFile(record.definitionPath, "utf8").catch(() => ""),
1046
+ fs.access(record.cliEntrypoint, constants.X_OK).then(() => true).catch(() => false),
1047
+ daemonStatus()
1048
+ ]);
1049
+ const definitionPresent = definitionContent !== "";
1050
+ const definitionMatches = definitionPresent && fingerprint(definitionContent) === record.definitionHash;
1051
+ const invokedEntrypoint = currentEntrypoint();
1052
+ const entrypointMatches = entrypointExists && (invokedEntrypoint === null || path.resolve(invokedEntrypoint) === path.resolve(record.cliEntrypoint));
1053
+ const environmentMatches = fingerprint(createServiceEnvironment({
1054
+ user: {
1055
+ uid: record.uid,
1056
+ gid: record.gid,
1057
+ username: record.username,
1058
+ homedir: record.home,
1059
+ shell: record.environment.TREEPORT_SHELL ?? null
1060
+ },
1061
+ paths: localPaths({
1062
+ TREEPORT_DATA_DIR: record.dataDir,
1063
+ TREEPORT_RUNTIME_DIR: record.runtimeDir
1064
+ }),
1065
+ apiUrl: record.apiUrl,
1066
+ recordPath: paths.recordPath,
1067
+ installationMethod: record.installationMethod
1068
+ })) === record.environmentHash;
1069
+ const healthy = Boolean(daemon.verified && daemon.health?.daemonLifecycle === "service" && daemon.state?.daemonLifecycle === "service" && path.resolve(daemon.state.dataDir) === path.resolve(record.dataDir));
1070
+ const installed = managerStatus.enabled;
1071
+ const rebootReady = installed && (record.manager === "launchd" || managerStatus.lingering);
1072
+ const pendingCommand = administratorCommand(record) ?? (record.manager === "systemd" && managerStatus.enabled && !managerStatus.lingering ? `sudo loginctl enable-linger ${record.username}` : null);
1073
+ const issues = [];
1074
+ const recoveryCommands = [];
1075
+ if (record.manager !== manager) issues.push(`The service record uses ${record.manager}, but this host requires ${manager}.`);
1076
+ if (!definitionMatches && !record.pendingAdministratorRequestId) {
1077
+ issues.push(definitionPresent ? `The service definition at ${record.definitionPath} was changed.` : `The service definition is missing at ${record.definitionPath}.`);
1078
+ recoveryCommands.push("treeport service enable");
1079
+ }
1080
+ if (definitionMatches && !installed && !record.pendingAdministratorRequestId) {
1081
+ issues.push("The service definition is not enabled for startup after reboot.");
1082
+ recoveryCommands.push("treeport service enable");
1083
+ }
1084
+ if (!entrypointMatches) {
1085
+ issues.push(`The service CLI entrypoint is unavailable or moved: ${record.cliEntrypoint}`);
1086
+ recoveryCommands.push("treeport service enable");
1087
+ }
1088
+ if (!environmentMatches) {
1089
+ issues.push("The service environment differs from the current Treeport environment.");
1090
+ recoveryCommands.push("treeport service enable");
1091
+ }
1092
+ if (record.manager === "systemd" && installed && !managerStatus.lingering) {
1093
+ issues.push(`User lingering is disabled for ${record.username}.`);
1094
+ recoveryCommands.push(`sudo loginctl enable-linger ${record.username}`);
1095
+ }
1096
+ if (managerStatus.managerIssue) issues.push(managerStatus.managerIssue);
1097
+ if (installed && record.requestedState === "running" && !healthy && !record.pendingAdministratorRequestId) {
1098
+ issues.push("The supervised Treeport daemon is not healthy.");
1099
+ recoveryCommands.push("treeport start");
1100
+ }
1101
+ const stale = record.manager !== manager || !definitionMatches || !environmentMatches || !entrypointMatches || definitionPresent && !installed || managerStatus.managerIssue !== null;
1102
+ return {
1103
+ supported: true,
1104
+ manager,
1105
+ state: record.pendingAdministratorRequestId || record.manager === "systemd" && installed && !managerStatus.lingering ? "action_required" : stale ? "stale" : healthy ? "healthy" : installed && record.requestedState === "stopped" ? "stopped" : installed && managerStatus.active ? "starting" : installed ? "unhealthy" : "disabled",
1106
+ installed,
1107
+ enabledAtBoot: installed,
1108
+ active: managerStatus.active,
1109
+ healthy,
1110
+ rebootReady,
1111
+ definitionMatches,
1112
+ environmentMatches,
1113
+ entrypointMatches,
1114
+ requestedState: record.requestedState,
1115
+ definitionPath: record.definitionPath,
1116
+ entrypoint: record.cliEntrypoint,
1117
+ daemon,
1118
+ issues,
1119
+ recoveryCommands: [...new Set(recoveryCommands)],
1120
+ administratorCommand: pendingCommand
1121
+ };
1122
+ }
1123
+ async function prepareRecord() {
1124
+ if (process.getuid?.() === 0) throw new Error("Run `treeport service enable` as the user who will run Treeport, not as root.");
1125
+ const manager = managerForPlatform();
1126
+ if (!manager) throw new Error(`Treeport service mode supports macOS launchd and Linux systemd; found ${process.platform}.`);
1127
+ const explicitApiUrl = process.env.TREEPORT_API_URL?.trim();
1128
+ if (explicitApiUrl) assertLoopbackHost(new URL(explicitApiUrl).hostname);
1129
+ const user = os.userInfo();
1130
+ const paths = localPaths();
1131
+ const locations = servicePaths();
1132
+ const apiUrl = await resolveLocalApiUrl();
1133
+ const listener = new URL(apiUrl);
1134
+ if (listener.protocol !== "http:") throw new Error("Treeport service mode requires a local HTTP loopback URL.");
1135
+ assertLoopbackHost(listener.hostname);
1136
+ const installationMethod = process.env.TREEPORT_INSTALLATION_METHOD?.trim() === "curl" ? "curl" : "npm";
1137
+ const cliEntrypoint = await ensureEntrypoint(installationMethod);
1138
+ const group = await primaryGroup(user.username);
1139
+ const definitionName = manager === "launchd" ? `app.treeport.daemon.${user.uid}` : "treeport.service";
1140
+ const definitionPath = manager === "launchd" ? `/Library/LaunchDaemons/${definitionName}.plist` : path.join(process.env.XDG_CONFIG_HOME?.trim() || path.join(user.homedir, ".config"), "systemd", "user", definitionName);
1141
+ const environment = createServiceEnvironment({
1142
+ user,
1143
+ paths,
1144
+ apiUrl,
1145
+ recordPath: locations.recordPath,
1146
+ installationMethod
1147
+ });
1148
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1149
+ const previous = await currentRecord();
1150
+ if (await fs.access(definitionPath).then(() => true).catch(() => false) && !previous) throw new Error(`A Treeport service definition already exists at ${definitionPath}. Disable it from its original data directory before enabling another service.`);
1151
+ if (previous && path.resolve(previous.dataDir) !== paths.dataDir) throw new Error(`Treeport service mode already uses ${previous.dataDir}. Disable it before enabling ${paths.dataDir}.`);
1152
+ const base = {
1153
+ schemaVersion: 1,
1154
+ manager,
1155
+ platform: process.platform,
1156
+ uid: user.uid,
1157
+ gid: user.gid,
1158
+ username: user.username,
1159
+ group,
1160
+ home: user.homedir,
1161
+ dataDir: paths.dataDir,
1162
+ runtimeDir: paths.runtimeDir,
1163
+ logPath: paths.logPath,
1164
+ apiUrl,
1165
+ cliEntrypoint,
1166
+ installationMethod,
1167
+ definitionName,
1168
+ definitionPath,
1169
+ definitionHash: "0".repeat(64),
1170
+ environmentHash: fingerprint(environment),
1171
+ environment,
1172
+ requestedState: "running",
1173
+ pendingAdministratorRequestId: null,
1174
+ createdAt: previous?.createdAt ?? now,
1175
+ updatedAt: now
1176
+ };
1177
+ const definition = definitionForRecord(base);
1178
+ return {
1179
+ record: {
1180
+ ...base,
1181
+ definitionHash: fingerprint(definition)
1182
+ },
1183
+ definition
1184
+ };
1185
+ }
1186
+ async function writeServiceFiles(record, definition) {
1187
+ const locations = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
1188
+ await Promise.all([fs.mkdir(path.dirname(record.logPath), {
1189
+ recursive: true,
1190
+ mode: 448
1191
+ }), fs.mkdir(locations.requestsDirectory, {
1192
+ recursive: true,
1193
+ mode: 448
1194
+ })]);
1195
+ await fs.writeFile(locations.runnerPath, runnerSource(record), { mode: 448 });
1196
+ await fs.chmod(locations.runnerPath, 448);
1197
+ if (record.manager === "launchd") await fs.writeFile(locations.stagedDefinitionPath, definition, { mode: 384 });
1198
+ else {
1199
+ await fs.mkdir(path.dirname(record.definitionPath), {
1200
+ recursive: true,
1201
+ mode: 448
1202
+ });
1203
+ const temporaryPath = `${record.definitionPath}.${process.pid}.tmp`;
1204
+ await fs.writeFile(temporaryPath, definition, { mode: 384 });
1205
+ await fs.rename(temporaryPath, record.definitionPath);
1206
+ }
1207
+ await saveRecord(record);
1208
+ }
1209
+ async function prepareAdministratorRequest(record, operation) {
1210
+ const locations = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
1211
+ const id = crypto.randomUUID();
1212
+ const now = /* @__PURE__ */ new Date();
1213
+ const request = {
1214
+ schemaVersion: 1,
1215
+ id,
1216
+ operation,
1217
+ createdAt: now.toISOString(),
1218
+ expiresAt: new Date(now.getTime() + 15 * 6e4).toISOString(),
1219
+ uid: record.uid,
1220
+ gid: record.gid,
1221
+ username: record.username,
1222
+ group: record.group,
1223
+ home: record.home,
1224
+ serviceRecordPath: locations.recordPath,
1225
+ runnerPath: locations.runnerPath,
1226
+ definitionName: record.definitionName,
1227
+ definitionPath: record.definitionPath,
1228
+ stagedDefinitionPath: locations.stagedDefinitionPath,
1229
+ definitionHash: record.definitionHash,
1230
+ apiUrl: record.apiUrl,
1231
+ cliEntrypoint: record.cliEntrypoint
1232
+ };
1233
+ await writeJson(path.join(locations.requestsDirectory, `${id}.json`), request);
1234
+ const next = {
1235
+ ...record,
1236
+ pendingAdministratorRequestId: id,
1237
+ updatedAt: now.toISOString()
1238
+ };
1239
+ await saveRecord(next);
1240
+ return {
1241
+ record: next,
1242
+ command: administratorCommand(next)
1243
+ };
1244
+ }
1245
+ async function waitForService(record) {
1246
+ const deadline = Date.now() + 15e3;
1247
+ const version = await treeportVersion();
1248
+ while (Date.now() < deadline) {
1249
+ const observed = await daemonHealth(record.apiUrl, 500);
1250
+ if (observed?.daemonLifecycle === "service" && observed.instanceId && observed.version === version) return;
1251
+ await new Promise((resolve) => setTimeout(resolve, 150));
1252
+ }
1253
+ throw new Error(`Treeport service did not become ready at ${record.apiUrl}. See ${record.logPath}.`);
1254
+ }
1255
+ async function serviceEnable() {
1256
+ const existing = await serviceStatus();
1257
+ if (existing.state === "healthy" && existing.definitionMatches && existing.environmentMatches && existing.entrypointMatches) return {
1258
+ status: existing,
1259
+ changed: false,
1260
+ administratorCommand: null
1261
+ };
1262
+ const { record, definition } = await prepareRecord();
1263
+ const failedChecks = (await runDoctor()).filter((check) => !check.ok);
1264
+ if (failedChecks.length) throw new Error(failedChecks.map((check) => `${check.name}: ${check.detail}`).join("\n"));
1265
+ const systemctl = record.manager === "systemd" ? await executablePath("systemctl") : null;
1266
+ if (systemctl) {
1267
+ const managerAvailable = await runCommand(systemctl, ["--user", "show-environment"]);
1268
+ if (managerAvailable.code !== 0) throw commandError("systemctl --user", managerAvailable);
1269
+ }
1270
+ await writeServiceFiles(record, definition);
1271
+ if (record.manager === "launchd") {
1272
+ await daemonDown();
1273
+ const prepared = await prepareAdministratorRequest(record, "enable");
1274
+ return {
1275
+ status: await serviceStatus(),
1276
+ changed: true,
1277
+ administratorCommand: prepared.command
1278
+ };
1279
+ }
1280
+ if (!systemctl) throw new Error("Treeport could not resolve the systemd command.");
1281
+ await daemonDown();
1282
+ const reload = await runCommand(systemctl, ["--user", "daemon-reload"]);
1283
+ if (reload.code !== 0) {
1284
+ await Promise.all([fs.rm(record.definitionPath, { force: true }), fs.rm(servicePaths().directory, {
1285
+ recursive: true,
1286
+ force: true
1287
+ })]);
1288
+ await daemonUp({});
1289
+ throw commandError("systemctl --user daemon-reload", reload);
1290
+ }
1291
+ const enabled = await runCommand(systemctl, [
1292
+ "--user",
1293
+ "enable",
1294
+ "--now",
1295
+ record.definitionName
1296
+ ]);
1297
+ if (enabled.code !== 0) {
1298
+ await fs.rm(record.definitionPath, { force: true });
1299
+ await runCommand(systemctl, ["--user", "daemon-reload"]);
1300
+ await fs.rm(servicePaths().directory, {
1301
+ recursive: true,
1302
+ force: true
1303
+ });
1304
+ await daemonUp({});
1305
+ throw commandError("systemctl --user enable --now", enabled);
1306
+ }
1307
+ const startupError = await waitForService(record).then(() => null, (error) => error);
1308
+ if (startupError) {
1309
+ await runCommand(systemctl, [
1310
+ "--user",
1311
+ "disable",
1312
+ "--now",
1313
+ record.definitionName
1314
+ ]);
1315
+ await fs.rm(record.definitionPath, { force: true });
1316
+ await runCommand(systemctl, ["--user", "daemon-reload"]);
1317
+ await fs.rm(servicePaths().directory, {
1318
+ recursive: true,
1319
+ force: true
1320
+ });
1321
+ await daemonUp({});
1322
+ throw startupError;
1323
+ }
1324
+ const status = await serviceStatus();
1325
+ return {
1326
+ status,
1327
+ changed: true,
1328
+ administratorCommand: status.administratorCommand
1329
+ };
1330
+ }
1331
+ async function serviceStart() {
1332
+ const record = await currentRecord();
1333
+ if (!record) throw new Error("Treeport service mode is disabled. Run `treeport service enable` first.");
1334
+ const current = await serviceStatus();
1335
+ if (current.state === "healthy") return {
1336
+ status: current,
1337
+ changed: false,
1338
+ administratorCommand: null
1339
+ };
1340
+ if (current.administratorCommand) return {
1341
+ status: current,
1342
+ changed: false,
1343
+ administratorCommand: current.administratorCommand
1344
+ };
1345
+ if (!current.definitionMatches || !current.entrypointMatches) throw new Error("The Treeport service definition is stale. Run `treeport service enable` to repair it.");
1346
+ const next = {
1347
+ ...record,
1348
+ requestedState: "running",
1349
+ pendingAdministratorRequestId: null,
1350
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1351
+ };
1352
+ await saveRecord(next);
1353
+ if (record.manager === "launchd") {
1354
+ const prepared = await prepareAdministratorRequest(next, "start");
1355
+ return {
1356
+ status: await serviceStatus(),
1357
+ changed: true,
1358
+ administratorCommand: prepared.command
1359
+ };
1360
+ }
1361
+ const result = await runCommand(await executablePath("systemctl"), [
1362
+ "--user",
1363
+ "start",
1364
+ record.definitionName
1365
+ ]);
1366
+ if (result.code !== 0) throw commandError("systemctl --user start", result);
1367
+ await waitForService(next);
1368
+ return {
1369
+ status: await serviceStatus(),
1370
+ changed: true,
1371
+ administratorCommand: null
1372
+ };
1373
+ }
1374
+ async function serviceStop() {
1375
+ const record = await currentRecord();
1376
+ if (!record) throw new Error("Treeport service mode is disabled.");
1377
+ const current = await serviceStatus();
1378
+ if (current.state === "stopped") return {
1379
+ status: current,
1380
+ changed: false,
1381
+ administratorCommand: null
1382
+ };
1383
+ const next = {
1384
+ ...record,
1385
+ requestedState: "stopped",
1386
+ pendingAdministratorRequestId: null,
1387
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1388
+ };
1389
+ await saveRecord(next);
1390
+ if (record.manager === "launchd") {
1391
+ const prepared = await prepareAdministratorRequest(next, "stop");
1392
+ return {
1393
+ status: await serviceStatus(),
1394
+ changed: true,
1395
+ administratorCommand: prepared.command
1396
+ };
1397
+ }
1398
+ const result = await runCommand(await executablePath("systemctl"), [
1399
+ "--user",
1400
+ "stop",
1401
+ record.definitionName
1402
+ ]);
1403
+ if (result.code !== 0) {
1404
+ await saveRecord(record);
1405
+ throw commandError("systemctl --user stop", result);
1406
+ }
1407
+ return {
1408
+ status: await serviceStatus(),
1409
+ changed: true,
1410
+ administratorCommand: null
1411
+ };
1412
+ }
1413
+ async function serviceDisable() {
1414
+ const record = await currentRecord();
1415
+ if (!record) return {
1416
+ status: await serviceStatus(),
1417
+ changed: false,
1418
+ administratorCommand: null
1419
+ };
1420
+ if (record.manager === "launchd") {
1421
+ const prepared = await prepareAdministratorRequest({
1422
+ ...record,
1423
+ pendingAdministratorRequestId: null
1424
+ }, "disable");
1425
+ return {
1426
+ status: await serviceStatus(),
1427
+ changed: true,
1428
+ administratorCommand: prepared.command
1429
+ };
1430
+ }
1431
+ const systemctl = await executablePath("systemctl");
1432
+ const disabled = await runCommand(systemctl, [
1433
+ "--user",
1434
+ "disable",
1435
+ "--now",
1436
+ record.definitionName
1437
+ ]);
1438
+ if (disabled.code !== 0 && !disabled.stderr.includes("does not exist")) throw commandError("systemctl --user disable --now", disabled);
1439
+ await fs.rm(record.definitionPath, { force: true });
1440
+ await runCommand(systemctl, ["--user", "daemon-reload"]);
1441
+ await fs.rm(servicePaths().directory, {
1442
+ recursive: true,
1443
+ force: true
1444
+ });
1445
+ return {
1446
+ status: await serviceStatus(),
1447
+ changed: true,
1448
+ administratorCommand: null
1449
+ };
1450
+ }
1451
+ async function serviceApply(requestPath) {
1452
+ if (process.platform !== "darwin") throw new Error("Treeport service apply is only available for macOS LaunchDaemons.");
1453
+ if (process.getuid?.() !== 0) throw new Error("Run the printed service apply command with sudo or as root.");
1454
+ if (!path.isAbsolute(requestPath)) throw new Error("The service apply request path must be absolute.");
1455
+ const metadata = await fs.lstat(requestPath);
1456
+ if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("The service apply request must be a regular file, not a symlink.");
1457
+ if ((metadata.mode & 63) !== 0) throw new Error("The service apply request must not be readable or writable by other users.");
1458
+ const request = await readJson(requestPath, administratorRequestSchema);
1459
+ if (!request) throw new Error("The service apply request is invalid.");
1460
+ if (metadata.uid !== request.uid) throw new Error("The service apply request owner does not match its target user.");
1461
+ if (Date.parse(request.expiresAt) <= Date.now()) throw new Error("The service apply request expired. Run the original Treeport command again.");
1462
+ const usedPath = `${requestPath}.used`;
1463
+ if (await fs.access(usedPath).then(() => true).catch(() => false)) throw new Error("The service apply request was already used.");
1464
+ const account = os.userInfo({ encoding: "utf8" });
1465
+ const idResult = await runCommand(await executablePath("id"), ["-u", request.username]);
1466
+ if (idResult.code !== 0 || Number(idResult.stdout.trim()) !== request.uid) throw new Error("The service apply target user no longer matches the host account.");
1467
+ const record = await readJson(request.serviceRecordPath, serviceRecordSchema);
1468
+ if (!record || record.uid !== request.uid || record.username !== request.username || record.manager !== "launchd" || record.definitionName !== request.definitionName || record.definitionPath !== request.definitionPath || record.cliEntrypoint !== request.cliEntrypoint || record.definitionHash !== request.definitionHash || record.pendingAdministratorRequestId !== request.id) throw new Error("The service apply request does not match the current Treeport service record.");
1469
+ if (account.uid !== 0) throw new Error("Treeport service apply lost root privileges.");
1470
+ const launchctl = await executablePath("launchctl");
1471
+ const target = `system/${request.definitionName}`;
1472
+ if (request.operation === "enable") {
1473
+ const staged = await fs.readFile(request.stagedDefinitionPath, "utf8");
1474
+ if (fingerprint(staged) !== request.definitionHash || !staged.includes(`<string>${xml(request.username)}</string>`) || !staged.includes(`<string>${xml(request.runnerPath)}</string>`)) throw new Error("The staged LaunchDaemon definition does not match the approved request.");
1475
+ const temporaryPath = `${request.definitionPath}.${process.pid}.tmp`;
1476
+ await fs.copyFile(request.stagedDefinitionPath, temporaryPath);
1477
+ await fs.chown(temporaryPath, 0, 0);
1478
+ await fs.chmod(temporaryPath, 420);
1479
+ await fs.rename(temporaryPath, request.definitionPath);
1480
+ await runCommand(launchctl, ["bootout", target]);
1481
+ const enabled = await runCommand(launchctl, ["enable", target]);
1482
+ if (enabled.code !== 0) throw commandError("launchctl enable", enabled);
1483
+ const bootstrapped = await runCommand(launchctl, [
1484
+ "bootstrap",
1485
+ "system",
1486
+ request.definitionPath
1487
+ ]);
1488
+ if (bootstrapped.code !== 0) throw commandError("launchctl bootstrap", bootstrapped);
1489
+ } else if (request.operation === "start") {
1490
+ const enabled = await runCommand(launchctl, ["enable", target]);
1491
+ if (enabled.code !== 0) throw commandError("launchctl enable", enabled);
1492
+ const started = (await runCommand(launchctl, ["print", target])).code === 0 ? await runCommand(launchctl, ["kickstart", target]) : await runCommand(launchctl, [
1493
+ "bootstrap",
1494
+ "system",
1495
+ request.definitionPath
1496
+ ]);
1497
+ if (started.code !== 0) throw commandError("launchctl start", started);
1498
+ } else if (request.operation === "stop") {
1499
+ const stopped = await runCommand(launchctl, ["bootout", target]);
1500
+ if (stopped.code !== 0 && !stopped.stderr.includes("No such process")) throw commandError("launchctl bootout", stopped);
1501
+ } else {
1502
+ const installed = await fs.readFile(request.definitionPath, "utf8").catch(() => "");
1503
+ if (installed && fingerprint(installed) !== request.definitionHash) throw new Error("Refusing to remove a LaunchDaemon definition that Treeport did not create.");
1504
+ await runCommand(launchctl, ["bootout", target]);
1505
+ await fs.rm(request.definitionPath, { force: true });
1506
+ }
1507
+ if (request.operation === "enable" || request.operation === "start") await waitForService(record);
1508
+ await fs.rename(requestPath, usedPath);
1509
+ if (request.operation === "disable") await fs.rm(path.dirname(request.serviceRecordPath), {
1510
+ recursive: true,
1511
+ force: true
1512
+ });
1513
+ else {
1514
+ await writeJson(request.serviceRecordPath, {
1515
+ ...record,
1516
+ requestedState: request.operation === "stop" ? "stopped" : "running",
1517
+ pendingAdministratorRequestId: null,
1518
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1519
+ });
1520
+ await fs.chown(request.serviceRecordPath, request.uid, request.gid);
1521
+ }
1522
+ return {
1523
+ operation: request.operation,
1524
+ applied: true
1525
+ };
1526
+ }
1527
+ async function serviceRun() {
1528
+ const recordPath = process.env.TREEPORT_SERVICE_RECORD?.trim();
1529
+ if (!recordPath || !path.isAbsolute(recordPath)) throw new Error("Treeport service run requires a valid service record.");
1530
+ const record = await readJson(recordPath, serviceRecordSchema);
1531
+ if (!record) throw new Error(`Treeport service record is invalid: ${recordPath}`);
1532
+ if (process.getuid?.() === 0 || process.getuid?.() !== record.uid) throw new Error(`Treeport service must run as ${record.username} (UID ${record.uid}), never as root.`);
1533
+ await writeJson(recordPath, {
1534
+ ...record,
1535
+ requestedState: "running",
1536
+ pendingAdministratorRequestId: null,
1537
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1538
+ });
1539
+ const [version, serverEntry, webDist] = await Promise.all([
1540
+ treeportVersion(),
1541
+ resolvePackagePath("dist", "node", "server", "index.js"),
1542
+ resolvePackagePath("dist", "web")
1543
+ ]);
1544
+ Object.assign(process.env, record.environment, {
1545
+ TREEPORT_APP_VERSION: version,
1546
+ TREEPORT_INSTANCE_ID: crypto.randomUUID(),
1547
+ TREEPORT_WEB_DIST: webDist,
1548
+ TREEPORT_DAEMON_LIFECYCLE: "service"
1549
+ });
1550
+ await import(pathToFileURL(serverEntry).href);
1551
+ }
1552
+ async function readServiceLogs(lines) {
1553
+ const record = await currentRecord();
1554
+ if (!record || record.manager === "launchd") return (await fs.readFile(record?.logPath ?? localPaths().logPath, "utf8").catch((error) => {
1555
+ if (error.code === "ENOENT") return "";
1556
+ throw error;
1557
+ })).split("\n").slice(-lines - 1).join("\n");
1558
+ const result = await runCommand(await executablePath("journalctl"), [
1559
+ "--user",
1560
+ "--unit",
1561
+ record.definitionName,
1562
+ "--no-pager",
1563
+ "--lines",
1564
+ String(lines)
1565
+ ]);
1566
+ if (result.code !== 0) throw commandError("journalctl --user", result);
1567
+ return result.stdout;
1568
+ }
1569
+ async function serviceDoctorCheck() {
1570
+ const status = await serviceStatus();
1571
+ if (!status.supported) return {
1572
+ name: "Service supervision",
1573
+ ok: false,
1574
+ detail: status.issues.join(" ")
1575
+ };
1576
+ if (status.state === "disabled") return {
1577
+ name: "Service supervision",
1578
+ ok: true,
1579
+ detail: "disabled (opt in with `treeport service enable`)"
1580
+ };
1581
+ if (status.state === "healthy") return {
1582
+ name: "Service supervision",
1583
+ ok: true,
1584
+ detail: `${status.manager}; enabled at boot and healthy`
1585
+ };
1586
+ if (status.state === "stopped") return {
1587
+ name: "Service supervision",
1588
+ ok: true,
1589
+ detail: `${status.manager}; intentionally stopped and enabled for next boot`
1590
+ };
1591
+ return {
1592
+ name: "Service supervision",
1593
+ ok: false,
1594
+ detail: status.issues.join(" ") || `state: ${status.state}`
1595
+ };
1596
+ }
1597
+ //#endregion
579
1598
  //#region src/cli/application.ts
580
1599
  const contextPrefix = "TREEPORT";
581
1600
  let configuredApiUrl;
@@ -606,8 +1625,40 @@ var CliError = class extends Error {
606
1625
  };
607
1626
  async function resolveDaemonLifecycle() {
608
1627
  if (configuredDaemonLifecycle === "external") return "external";
609
- if (configuredApiUrl) return (await daemonHealth(apiUrl))?.daemonLifecycle ?? "treeport";
610
- return "treeport";
1628
+ if (configuredDaemonLifecycle === "service") return "service";
1629
+ if (configuredApiUrl) {
1630
+ const observed = await daemonHealth(apiUrl);
1631
+ if (observed) return observed.daemonLifecycle;
1632
+ }
1633
+ return await serviceInstalled() ? "service" : "treeport";
1634
+ }
1635
+ function formatServiceStatus(status) {
1636
+ const lines = [
1637
+ `Treeport service: ${status.state}`,
1638
+ `Manager: ${status.manager ?? "unsupported"}`,
1639
+ `Starts at boot: ${status.enabledAtBoot ? "yes" : "no"}`,
1640
+ `Active: ${status.active ? "yes" : "no"}`,
1641
+ `Definition: ${status.definitionPath ?? "not installed"}`
1642
+ ];
1643
+ if (status.daemon?.state) lines.push(`PID: ${status.daemon.state.pid}`);
1644
+ if (status.issues.length) lines.push(...status.issues.map((issue) => `Issue: ${issue}`));
1645
+ if (status.administratorCommand) lines.push("Administrator action required:", status.administratorCommand, "Then run: treeport service status");
1646
+ else if (status.recoveryCommands.length) lines.push(`Next: ${status.recoveryCommands[0]}`);
1647
+ return lines.join("\n");
1648
+ }
1649
+ async function ensureServiceDaemon() {
1650
+ const result = await serviceStart();
1651
+ const state = result.status.daemon?.state;
1652
+ if (state && result.status.healthy) return {
1653
+ apiUrl: state.apiUrl,
1654
+ pid: state.pid
1655
+ };
1656
+ if (result.administratorCommand) throw new CliError(`An administrator must start the Treeport service:\n${result.administratorCommand}`, 5, "SERVICE_ADMINISTRATOR_ACTION_REQUIRED", result);
1657
+ if (!state || !result.status.healthy) throw new CliError("The Treeport service did not become healthy. Run `treeport service status`.", 3, "DAEMON_UNREACHABLE", result.status);
1658
+ return {
1659
+ apiUrl: state.apiUrl,
1660
+ pid: state.pid
1661
+ };
611
1662
  }
612
1663
  async function request(pathname, options = {}) {
613
1664
  const controller = new AbortController();
@@ -927,9 +1978,11 @@ async function main(args) {
927
1978
  const canonicalFolder = await fs.realpath(absoluteFolder).catch((error) => {
928
1979
  throw new CliError(`Cannot access folder ${absoluteFolder}: ${error instanceof Error ? error.message : String(error)}`, 5, "FOLDER_UNREADABLE", { path: absoluteFolder });
929
1980
  });
930
- if (await resolveDaemonLifecycle() === "external") {
1981
+ const lifecycle = await resolveDaemonLifecycle();
1982
+ if (lifecycle === "external") {
931
1983
  if (!await daemonHealth(apiUrl)) throw new CliError(`Cannot reach the externally managed Treeport daemon at ${apiUrl}. Start it through the process that owns its lifecycle and retry.`, 3, "DAEMON_UNREACHABLE");
932
- } else await daemonUp({});
1984
+ } else if (lifecycle === "service") await ensureServiceDaemon();
1985
+ else await daemonUp({});
933
1986
  const registered = await request("/api/projects", {
934
1987
  method: "POST",
935
1988
  body: JSON.stringify({ path: canonicalFolder })
@@ -958,10 +2011,18 @@ async function main(args) {
958
2011
  client: opened.client
959
2012
  }, () => `Opened ${registered.project.name} / ${targetWorktree.name} in the ${opened.client === "desktop" ? "Treeport desktop app" : "browser"}\n${target.href}`);
960
2013
  });
961
- const upCommand = program.command("up").description("Ensure the local Treeport daemon is running").option("--host <address>", "loopback listener address").option("--port <port>", "listener port").option("--foreground", "run in the foreground").option("--json", "emit machine-readable JSON");
962
- upCommand.action(async () => {
963
- if (await resolveDaemonLifecycle() === "external") throw new CliError("Cannot run `treeport up` because the daemon lifecycle is externally managed. Control the process that started Treeport instead.", 5, "DAEMON_LIFECYCLE_EXTERNAL");
964
- const options = upCommand.opts();
2014
+ const startCommand = program.command("start").description("Ensure the local Treeport daemon is running").option("--host <address>", "loopback listener address").option("--port <port>", "listener port").option("--foreground", "run in the foreground").option("--json", "emit machine-readable JSON");
2015
+ startCommand.action(async () => {
2016
+ const lifecycle = await resolveDaemonLifecycle();
2017
+ if (lifecycle === "external") throw new CliError("Cannot run `treeport start` because the daemon lifecycle is externally managed. Control the process that started Treeport instead.", 5, "DAEMON_LIFECYCLE_EXTERNAL");
2018
+ const options = startCommand.opts();
2019
+ if (lifecycle === "service") {
2020
+ if (options.foreground || options.host || options.port) throw new CliError("An installed service owns the listener and process mode. Run `treeport service enable` to refresh its configuration, or `treeport service disable` to return to local background mode.", 5, "DAEMON_LIFECYCLE_SERVICE");
2021
+ const result = await serviceStart();
2022
+ print(result, () => formatServiceStatus(result.status));
2023
+ if (result.administratorCommand || !result.status.healthy) requestedExitCode = 1;
2024
+ return;
2025
+ }
965
2026
  const port = options.port === void 0 ? void 0 : Number(options.port);
966
2027
  const result = await daemonUp({
967
2028
  ...options.host === void 0 ? {} : { host: options.host },
@@ -969,16 +2030,53 @@ async function main(args) {
969
2030
  ...options.foreground === void 0 ? {} : { foreground: options.foreground }
970
2031
  });
971
2032
  if (options.foreground) return;
972
- print(result, () => `Treeport is up\n${result.apiUrl}`);
2033
+ print(result, () => `Treeport is running\n${result.apiUrl}`);
973
2034
  });
974
- const downCommand = program.command("down").description("Stop the local daemon and preserve terminal sessions").option("--terminate-terminals", "terminate every Treeport-owned tmux server").option("--force", "confirm termination of all terminals").option("--json", "emit machine-readable JSON");
975
- downCommand.action(async () => {
976
- if (await resolveDaemonLifecycle() === "external") throw new CliError("Cannot run `treeport down` because the daemon lifecycle is externally managed. Control the process that started Treeport instead.", 5, "DAEMON_LIFECYCLE_EXTERNAL");
977
- const options = downCommand.opts();
2035
+ const stopCommand = program.command("stop").description("Stop the local daemon and preserve terminal sessions").option("--terminate-terminals", "terminate every Treeport-owned tmux server").option("--force", "confirm termination of all terminals").option("--json", "emit machine-readable JSON");
2036
+ stopCommand.action(async () => {
2037
+ const lifecycle = await resolveDaemonLifecycle();
2038
+ if (lifecycle === "external") throw new CliError("Cannot run `treeport stop` because the daemon lifecycle is externally managed. Control the process that started Treeport instead.", 5, "DAEMON_LIFECYCLE_EXTERNAL");
2039
+ const options = stopCommand.opts();
978
2040
  if (options.terminateTerminals && !options.force) throw new CliError("Re-run with --terminate-terminals --force to confirm termination of every terminal.", 2);
979
2041
  if (options.terminateTerminals) await request("/api/admin/terminate-terminals", { method: "POST" });
2042
+ if (lifecycle === "service") {
2043
+ const result = await serviceStop();
2044
+ print(result, () => formatServiceStatus(result.status));
2045
+ if (result.administratorCommand) requestedExitCode = 1;
2046
+ return;
2047
+ }
980
2048
  const result = await daemonDown();
981
- print(result, () => result.wasRunning ? "Treeport is down" : "Treeport is already down");
2049
+ print(result, () => result.wasRunning ? "Treeport is stopped" : "Treeport is already stopped");
2050
+ });
2051
+ const serviceCommand = program.command("service").description("Manage opt-in OS service supervision");
2052
+ serviceCommand.action(() => {
2053
+ writeStdout(serviceCommand.helpInformation());
2054
+ });
2055
+ serviceCommand.command("enable").description("Enable startup after reboot and unexpected-exit restarts").option("--json", "emit machine-readable JSON").action(async () => {
2056
+ const result = await serviceEnable();
2057
+ print(result, () => formatServiceStatus(result.status));
2058
+ if (result.status.state === "action_required") requestedExitCode = 1;
2059
+ });
2060
+ serviceCommand.command("status").description("Show OS service supervision status").option("--json", "emit machine-readable JSON").action(async () => {
2061
+ const result = await serviceStatus();
2062
+ print(result, () => formatServiceStatus(result));
2063
+ if (![
2064
+ "disabled",
2065
+ "healthy",
2066
+ "stopped"
2067
+ ].includes(result.state) || !result.supported) requestedExitCode = 1;
2068
+ });
2069
+ serviceCommand.command("disable").description("Stop and unregister OS service supervision").option("--json", "emit machine-readable JSON").action(async () => {
2070
+ const result = await serviceDisable();
2071
+ print(result, () => formatServiceStatus(result.status));
2072
+ if (result.administratorCommand || result.status.state !== "disabled") requestedExitCode = 1;
2073
+ });
2074
+ serviceCommand.command("run", { hidden: true }).action(async () => serviceRun());
2075
+ const serviceApplyCommand = serviceCommand.command("apply", { hidden: true }).requiredOption("--request <absolute-path>", "prepared request");
2076
+ serviceApplyCommand.action(async () => {
2077
+ const { request: requestPath } = serviceApplyCommand.opts();
2078
+ const result = await serviceApply(requestPath);
2079
+ print(result, () => `Applied Treeport service ${result.operation} request.`);
982
2080
  });
983
2081
  const remoteCommand = program.command("remote").description("Expose Treeport privately through Tailscale Serve");
984
2082
  remoteCommand.action(() => {
@@ -986,11 +2084,16 @@ async function main(args) {
986
2084
  });
987
2085
  const remoteEnableCommand = remoteCommand.command("enable").description("Enable private HTTPS access through Tailscale").option("--port <port>", "Tailscale HTTPS port (default: 8733)").option("--json", "emit machine-readable JSON");
988
2086
  remoteEnableCommand.action(async () => {
989
- if (await resolveDaemonLifecycle() === "external") throw new CliError("Cannot run `treeport remote enable` because the daemon lifecycle is externally managed. Configure remote access through the process that started Treeport instead.", 5, "DAEMON_LIFECYCLE_EXTERNAL");
2087
+ const lifecycle = await resolveDaemonLifecycle();
2088
+ if (lifecycle === "external") throw new CliError("Cannot run `treeport remote enable` because the daemon lifecycle is externally managed. Configure remote access through the process that started Treeport instead.", 5, "DAEMON_LIFECYCLE_EXTERNAL");
990
2089
  const options = remoteEnableCommand.opts();
991
2090
  const port = options.port === void 0 ? void 0 : Number(options.port);
992
2091
  if (port !== void 0 && (!Number.isInteger(port) || port < 1 || port > 65535)) throw new CliError("--port must be an integer between 1 and 65535", 2);
993
- const result = await enableTailscaleRemote(port === void 0 ? {} : { port });
2092
+ const serviceDaemon = lifecycle === "service" ? await ensureServiceDaemon() : void 0;
2093
+ const result = await enableTailscaleRemote({
2094
+ ...port === void 0 ? {} : { port },
2095
+ ...serviceDaemon === void 0 ? {} : { daemon: serviceDaemon }
2096
+ });
994
2097
  print(result, () => `Treeport remote access is ${result.alreadyEnabled ? "already enabled" : "enabled"}\n${result.url}\nTailscale authenticates each remote user. Access is limited by your Tailscale policy.`);
995
2098
  });
996
2099
  remoteCommand.command("status").description("Show Tailscale remote access status").option("--json", "emit machine-readable JSON").action(async () => {
@@ -1009,27 +2112,29 @@ async function main(args) {
1009
2112
  });
1010
2113
  program.command("status").description("Show local daemon status").option("--json", "emit machine-readable JSON").action(async () => {
1011
2114
  const status = await daemonStatus();
2115
+ const supervision = await serviceInstalled() ? await serviceStatus() : null;
1012
2116
  const projectList = status.verified ? await projects() : [];
1013
2117
  const result = {
1014
2118
  ...status,
2119
+ service: supervision,
1015
2120
  projects: projectList.length,
1016
2121
  worktrees: projectList.reduce((count, project) => count + project.worktrees.length, 0),
1017
2122
  terminals: projectList.reduce((count, project) => count + project.worktrees.reduce((worktreeCount, worktree) => worktreeCount + worktree.terminals.length, 0), 0)
1018
2123
  };
1019
2124
  print(result, () => {
1020
- if (!status.state) return "Treeport is down";
2125
+ if (!status.state) return supervision ? formatServiceStatus(supervision) : "Treeport is stopped";
1021
2126
  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 up\n${status.state.apiUrl}\nVersion: ${status.health?.version}\nPID: ${status.state.pid}\nProjects: ${result.projects}\nWorktrees: ${result.worktrees}\nTerminals: ${result.terminals}`;
2127
+ return `Treeport is running\n${status.state.apiUrl}\nLifecycle: ${status.health?.daemonLifecycle}\nVersion: ${status.health?.version}\nPID: ${status.state.pid}\nProjects: ${result.projects}\nWorktrees: ${result.worktrees}\nTerminals: ${result.terminals}`;
1023
2128
  });
1024
2129
  });
1025
2130
  const logsCommand = program.command("logs").description("Show recent daemon logs").option("--lines <count>", "number of lines", "100");
1026
2131
  logsCommand.action(async () => {
1027
2132
  const lines = Number(logsCommand.opts().lines);
1028
2133
  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));
2134
+ writeStdout(await serviceInstalled() ? await readServiceLogs(lines) : await readDaemonLogs(lines));
1030
2135
  });
1031
2136
  program.command("doctor").description("Diagnose local requirements and paths").option("--json", "emit machine-readable JSON").action(async () => {
1032
- const checks = await runDoctor();
2137
+ const checks = [...await runDoctor(), await serviceDoctorCheck()];
1033
2138
  print(checks, () => checks.map((check) => `${check.ok ? "ok" : "error"}\t${check.name}\t${check.detail}`).join("\n"));
1034
2139
  if (checks.some((check) => !check.ok)) requestedExitCode = 1;
1035
2140
  });
@@ -1043,7 +2148,7 @@ async function main(args) {
1043
2148
  });
1044
2149
  program.command("skills").description("Print the Treeport usage guide for AI agents").action(async () => {
1045
2150
  const skill = await fs.readFile(await resolvePackagePath("skills", "treeport", "SKILL.md"), "utf8");
1046
- writeStdout(await resolveDaemonLifecycle() === "external" ? skill.replace("\n# Treeport\n", "\n# Treeport\n\n> **Externally managed daemon lifecycle:** Do not run `treeport up`, `treeport down`, or `treeport remote enable`. The process that started Treeport owns startup, shutdown, remote exposure, and logs. Other Treeport commands continue to use the configured daemon normally.\n") : skill);
2151
+ writeStdout(await resolveDaemonLifecycle() === "external" ? skill.replace("\n# Treeport\n", "\n# Treeport\n\n> **Externally managed daemon lifecycle:** Do not run `treeport start`, `treeport stop`, or `treeport remote enable`. The process that started Treeport owns startup, shutdown, remote exposure, and logs. Other Treeport commands continue to use the configured daemon normally.\n") : skill);
1047
2152
  });
1048
2153
  program.command("context").description("Show the current Treeport-managed terminal context").option("--json", "emit machine-readable JSON").action(async () => {
1049
2154
  const projectId = contextProjectId;
@@ -1108,7 +2213,7 @@ async function main(args) {
1108
2213
  exitCode: terminal.exitCode
1109
2214
  }
1110
2215
  };
1111
- print(context, () => `Treeport context\n\nProject: ${context.project.name} (${context.project.id})\nWorktree: ${context.worktree.name} (${context.worktree.id})\nPath: ${context.worktree.path}\nTerminal: ${context.terminal.name} (${context.terminal.id}) — ${context.terminal.status}\nAPI: ${context.apiUrl}\nLifecycle: ${context.daemonLifecycle === "external" ? "externally managed" : "managed by Treeport"}`);
2216
+ print(context, () => `Treeport context\n\nProject: ${context.project.name} (${context.project.id})\nWorktree: ${context.worktree.name} (${context.worktree.id})\nPath: ${context.worktree.path}\nTerminal: ${context.terminal.name} (${context.terminal.id}) — ${context.terminal.status}\nAPI: ${context.apiUrl}\nLifecycle: ${context.daemonLifecycle === "external" ? "externally managed" : context.daemonLifecycle === "service" ? "managed by the OS service" : "managed by Treeport"}`);
1112
2217
  });
1113
2218
  const installCommand = program.command("install").description("Install and configure a Treeport package").argument("<source>", "npm: source or local directory").option("-l, --local", "configure the registered project containing the current directory").option("--json", "emit machine-readable JSON");
1114
2219
  installCommand.action(async (source) => {