@treeport/treeport 0.6.1 → 0.8.3

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,11 +1,14 @@
1
+ import { Ct as projectsResponseSchema, U as decodeUnknownOrNull, ct as healthResponseSchema } from "./dist-BsLn2Gbc.js";
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
- import { z } from "zod";
4
4
  import { spawn } from "node:child_process";
5
5
  import crypto from "node:crypto";
6
6
  import fsSync, { constants } from "node:fs";
7
7
  import os from "node:os";
8
+ import { createInterface } from "node:readline";
9
+ import { z } from "zod";
8
10
  import { fileURLToPath, pathToFileURL } from "node:url";
11
+ import kleur from "kleur";
9
12
  //#region src/duration.ts
10
13
  const DURATION_UNITS = /* @__PURE__ */ new Map([
11
14
  ["ms", 1],
@@ -39,6 +42,90 @@ function assertLoopbackHost(host) {
39
42
  throw new Error("Treeport supports only loopback listeners. Run `treeport start --host 127.0.0.1`, then use `treeport remote enable` for private remote access.");
40
43
  }
41
44
  //#endregion
45
+ //#region src/cli/output.ts
46
+ /** Human output only. JSON and raw streams must bypass this formatter. */
47
+ function humanOutput(environment = process.env, isTTY = false, json = false) {
48
+ const force = environment.FORCE_COLOR;
49
+ const enabled = !json && environment.NO_COLOR === void 0 && !environment.NODE_DISABLE_COLORS && (force !== void 0 ? force !== "0" && force !== "false" : isTTY && environment.TERM !== "dumb");
50
+ const style = (format) => (text) => {
51
+ if (!enabled || !text) return text;
52
+ const previous = kleur.enabled;
53
+ kleur.enabled = true;
54
+ try {
55
+ return format(text);
56
+ } finally {
57
+ kleur.enabled = previous;
58
+ }
59
+ };
60
+ const heading = style(kleur.bold);
61
+ const detail = style(kleur.dim);
62
+ const action = style(kleur.cyan);
63
+ const tones = {
64
+ success: style(kleur.green),
65
+ warning: style(kleur.yellow),
66
+ failure: style(kleur.red),
67
+ neutral: (text) => text
68
+ };
69
+ const indent = (text) => text ? text.split("\n").map((line) => ` ${line}`).join("\n") : "";
70
+ const summary = (text, tone = "neutral") => tones[tone](`${{
71
+ success: "✓ ",
72
+ warning: "! ",
73
+ failure: "✖ ",
74
+ neutral: ""
75
+ }[tone]}${text}`);
76
+ const section = (title, text) => `${heading(title)}\n${indent(text)}`;
77
+ const rows = (values) => {
78
+ const entries = values.filter((entry) => entry !== null);
79
+ const width = Math.max(0, ...entries.map(([label]) => label.length));
80
+ return entries.map(([label, value]) => ` ${label.padEnd(width)} ${String(value).replaceAll("\n", "\n" + " ".repeat(width + 4))}`).join("\n");
81
+ };
82
+ const next = (commands) => commands.length ? section("Next", commands.map((command, index) => index === 0 ? action(command) : command).join("\n")) : "";
83
+ const blocks = (...parts) => parts.filter(Boolean).join("\n\n");
84
+ return {
85
+ enabled,
86
+ heading,
87
+ detail,
88
+ action,
89
+ summary,
90
+ section,
91
+ rows,
92
+ next,
93
+ blocks,
94
+ indent
95
+ };
96
+ }
97
+ function stateName(state) {
98
+ const words = state.replaceAll("_", " ");
99
+ return words.charAt(0).toUpperCase() + words.slice(1);
100
+ }
101
+ function formatServiceStatus(status, output) {
102
+ const tone = !status.supported || status.state === "unhealthy" ? "failure" : status.state === "healthy" || status.state === "stopped" || status.state === "disabled" ? "success" : "warning";
103
+ return output.blocks(output.heading("Treeport service"), output.summary(status.administratorCommand ? "Administrator action required" : status.state === "stale" ? "Needs repair" : status.state === "disabled" ? "Not installed" : stateName(status.state), tone), output.rows([["Mode", status.mode === "headless" ? "Headless service" : status.mode === "user" ? "User service" : "Not installed"], ["Startup", !status.installed ? "Not enabled" : status.enabledAtBoot ? "Before login" : status.mode === "headless" ? "Not enabled before login" : "After login"]]), output.detail(output.rows([
104
+ ["Manager", status.manager ?? "Unsupported"],
105
+ status.definitionPath && status.issues.length ? ["Definition", status.definitionPath] : null,
106
+ status.daemon?.state ? ["PID", status.daemon.state.pid] : null
107
+ ])), status.issues.length > 0 && output.section("Attention", [...new Set(status.issues)].map((issue) => issue.replace("Run `treeport service enable --headless`; routine start and stop then need no administrator.", "After migration, routine start and stop need no administrator.")).join("\n")), status.administratorCommand ? output.next([status.administratorCommand, "Then run: treeport service status"]) : output.next([...new Set(status.recoveryCommands)]));
108
+ }
109
+ function formatLocalUpdateError$1(message, details, output, cancelled = false) {
110
+ const reason = details.rollback?.succeeded && message === "The update failed. Treeport restored the previous version." ? details.cause ?? "The update could not be completed." : details.recovery && message.endsWith(details.recovery) ? message.slice(0, -details.recovery.length).trim() : message;
111
+ const reasons = [...new Set([details.recovery === "Re-run `treeport update --yes` to approve the update." ? reason.replace(/ Re-run with --yes\.$/, "") : cancelled ? reason.replace(/^Treeport update cancelled\. /, "") : reason, details.cause].filter((value) => Boolean(value)))];
112
+ const recovery = [...new Set([
113
+ details.rollback?.succeeded || details.recovery === "The previous Treeport version is active again." ? "Treeport restored the previous version." : null,
114
+ details.recovery === "The previous Treeport version is active again." ? null : details.recovery,
115
+ details.administratorCommand
116
+ ].filter((value) => Boolean(value)))];
117
+ return output.blocks(output.summary(cancelled ? "Update cancelled" : "Update failed", cancelled ? "warning" : "failure"), output.indent(reasons.join("\n")), details.rollback?.attempted && !details.rollback.succeeded && output.summary("Rollback did not succeed", "failure"), output.next(recovery), output.detail(output.rows([
118
+ details.phase ? ["Phase", stateName(details.phase)] : null,
119
+ details.migrationState ? ["Migration", stateName(details.migrationState)] : null,
120
+ details.logPath ? ["Daemon log", details.logPath] : null,
121
+ ...(details.snapshotPaths ?? []).map((snapshot) => ["Pre-migration snapshot", snapshot])
122
+ ])));
123
+ }
124
+ function formatLocalUpdateResult(result, output) {
125
+ if (result.status === "current") return output.summary(`Treeport ${result.toVersion} is current`, "success");
126
+ return output.blocks(output.summary(`Updated Treeport ${result.fromVersion} → ${result.toVersion}`, "success"), result.daemon.restarted ? output.summary(`Treeport ${result.daemon.wasRunning ? "restarted" : "started"} — ${result.daemon.healthy ? "Healthy" : "Health not verified"}`, result.daemon.healthy ? "success" : "warning") : "Treeport remains stopped", result.daemon.restarted && output.rows([["Daemon version", result.daemon.version ?? "Unavailable"], ["Terminals", result.terminals.preserved ? `${result.terminals.before} preserved · ${result.terminals.after} available` : `${result.terminals.after} of ${result.terminals.before} found`]]), !result.daemon.restarted && output.next(["treeport start"]));
127
+ }
128
+ //#endregion
42
129
  //#region src/cli/lifecycle.ts
43
130
  const DEFAULT_HOST = "127.0.0.1";
44
131
  const DEFAULT_PORT = 8733;
@@ -86,21 +173,6 @@ const daemonRecordSchema = z.strictObject({
86
173
  "external"
87
174
  ])
88
175
  });
89
- const healthRecordSchema = z.strictObject({
90
- ok: z.literal(true),
91
- version: z.string(),
92
- protocolVersion: z.number(),
93
- hostname: z.string().optional(),
94
- pid: z.number(),
95
- instanceId: z.string().nullable(),
96
- installationMethod: z.string(),
97
- daemonLifecycle: z.enum([
98
- "treeport",
99
- "service",
100
- "external"
101
- ]),
102
- url: z.string()
103
- });
104
176
  async function preferences(env = process.env) {
105
177
  return await readJson$1(localPaths(env).preferencesPath, preferencesSchema) ?? {};
106
178
  }
@@ -151,15 +223,18 @@ async function daemonHealth(apiUrl, timeoutMs = 1500) {
151
223
  const signal = AbortSignal.timeout(timeoutMs);
152
224
  return fetch(`${apiUrl}/api/health`, { signal }).then(async (response) => {
153
225
  if (!response.ok) return null;
154
- const result = healthRecordSchema.safeParse(await response.json());
155
- return result.success ? result.data : null;
226
+ return decodeUnknownOrNull(healthResponseSchema, await response.json());
156
227
  }).catch(() => null);
157
228
  }
158
229
  function matchesOwnership(state, observed) {
159
230
  return observed.pid === state.pid && observed.instanceId === state.instanceId && path.resolve(state.dataDir) === localPaths().dataDir;
160
231
  }
161
232
  async function readState() {
162
- return readJson$1(localPaths().statePath, daemonRecordSchema);
233
+ const paths = localPaths();
234
+ return await fs.readFile(paths.lockPath, "utf8").then((value) => daemonRecordSchema.parse(JSON.parse(value))).catch((error) => {
235
+ if (error.code === "ENOENT") return null;
236
+ throw new Error(`Cannot verify daemon ownership at ${paths.lockPath}. Inspect the daemon log before starting or stopping Treeport.`, { cause: error });
237
+ }) ?? readJson$1(paths.statePath, daemonRecordSchema);
163
238
  }
164
239
  async function removeStaleState(state) {
165
240
  const paths = localPaths();
@@ -373,11 +448,7 @@ async function disableTailscaleRemote() {
373
448
  }
374
449
  async function runDoctor() {
375
450
  const paths = localPaths();
376
- const gitPath = process.env.TREEPORT_GIT_PATH?.trim() || "git";
377
- const tmuxPath = process.env.TREEPORT_TMUX_PATH?.trim() || "tmux";
378
- const [git, tmux] = await Promise.all([executableCheck(gitPath, ["--version"]), executableCheck(tmuxPath, ["-V"])]);
379
- const tmuxMatch = /tmux\s+(\d+)\.(\d+)/i.exec(tmux.detail);
380
- const tmuxSupported = Boolean(tmux.ok && tmuxMatch && (Number(tmuxMatch[1]) > 3 || Number(tmuxMatch[1]) === 3 && Number(tmuxMatch[2]) >= 2));
451
+ const git = await executableCheck(process.env.TREEPORT_GIT_PATH?.trim() || "git", ["--version"]);
381
452
  const checkDirectory = (directoryPath) => fs.mkdir(directoryPath, {
382
453
  recursive: true,
383
454
  mode: 448
@@ -399,11 +470,6 @@ async function runDoctor() {
399
470
  name: "Git",
400
471
  ...git
401
472
  },
402
- {
403
- name: "tmux",
404
- ok: tmuxSupported,
405
- detail: tmuxSupported ? tmux.detail : `${tmux.detail}. Treeport requires tmux 3.2 or newer.`
406
- },
407
473
  {
408
474
  name: "Data directory",
409
475
  ...dataDirectory
@@ -492,7 +558,8 @@ async function daemonUp(options) {
492
558
  TREEPORT_WEB_DIST: webDist
493
559
  };
494
560
  if (options.foreground) {
495
- console.log(`Treeport will listen on ${apiUrl}`);
561
+ const output = options.output ?? humanOutput(process.env, Boolean(process.stdout.isTTY));
562
+ console.log(output.blocks(output.summary("Treeport is starting", "warning"), output.rows([["URL", apiUrl]])));
496
563
  const child = spawn(process.execPath, [serverEntry], {
497
564
  env: childEnvironment,
498
565
  stdio: "inherit"
@@ -547,9 +614,109 @@ async function readDaemonLogs(lines = 100) {
547
614
  })).split("\n").slice(-lines - 1).join("\n");
548
615
  }
549
616
  //#endregion
617
+ //#region src/cli/service-supervisor.ts
618
+ function serviceSupervisorSource() {
619
+ return `import fs from 'node:fs/promises'
620
+ import { constants } from 'node:fs'
621
+ import path from 'node:path'
622
+ import { spawn } from 'node:child_process'
623
+
624
+ const [recordPath, owner] = process.argv.slice(2)
625
+ const uid = Number(owner)
626
+ if (!Number.isInteger(uid) || uid <= 0 || process.getuid?.() !== uid || !path.isAbsolute(recordPath)) {
627
+ throw new Error('Treeport supervisor must run as its non-root owner')
628
+ }
629
+ const directory = path.dirname(recordPath)
630
+ const statePath = path.join(directory, 'supervisor.json')
631
+ let child = null
632
+ let stopping = null
633
+ let lastState = ''
634
+ let exiting = false
635
+ let retryAt = 0
636
+ let reported = ''
637
+ process.on('SIGTERM', () => { exiting = true })
638
+ process.on('SIGINT', () => { exiting = true })
639
+
640
+ async function readRecord() {
641
+ // Reject symlinks and paths writable by another account, including ancestors.
642
+ for (let current = directory; ; current = path.dirname(current)) {
643
+ const stat = await fs.lstat(current)
644
+ if (!stat.isDirectory() || (stat.uid !== uid && stat.uid !== 0) ||
645
+ ((stat.mode & 0o022) !== 0 && !(stat.uid === 0 && (stat.mode & 0o1000)))) {
646
+ throw new Error('Unsafe Treeport service directory: ' + current)
647
+ }
648
+ if (current === path.dirname(current)) break
649
+ }
650
+ const handle = await fs.open(recordPath, constants.O_RDONLY | constants.O_NOFOLLOW)
651
+ try {
652
+ const stat = await handle.stat()
653
+ if (!stat.isFile() || stat.uid !== uid || (stat.mode & 0o077) !== 0) {
654
+ throw new Error('Unsafe Treeport service record')
655
+ }
656
+ const record = JSON.parse(await handle.readFile('utf8'))
657
+ if (record.uid !== uid || record.supervisorVersion !== 1 ||
658
+ !['running', 'stopped'].includes(record.requestedState) ||
659
+ typeof record.updatedAt !== 'string' || !path.isAbsolute(record.cliEntrypoint) ||
660
+ record.environment?.TREEPORT_SERVICE_RECORD !== recordPath) {
661
+ throw new Error('Invalid Treeport supervisor record')
662
+ }
663
+ return record
664
+ } finally {
665
+ await handle.close()
666
+ }
667
+ }
668
+
669
+ while (true) {
670
+ const record = await readRecord().catch(error => {
671
+ if (reported !== error.message) console.error(error.message)
672
+ reported = error.message
673
+ return null
674
+ })
675
+ if (exiting || !record || record.requestedState === 'stopped') {
676
+ if (child && stopping !== child) {
677
+ stopping = child
678
+ child.kill('SIGTERM')
679
+ }
680
+ } else if (!child && Date.now() >= retryAt) {
681
+ // Resolve the stable CLI path on every spawn, after the updater's atomic switch.
682
+ const log = await fs.open(record.logPath, 'a', 0o600)
683
+ const launched = spawn(record.cliEntrypoint, ['service', 'run'], {
684
+ env: { ...record.environment, PATH: path.dirname(process.execPath) + ':' + record.environment.PATH },
685
+ stdio: ['ignore', log.fd, log.fd]
686
+ })
687
+ child = launched
688
+ const finished = () => {
689
+ if (child === launched) child = null
690
+ retryAt = Date.now() + 1000
691
+ }
692
+ launched.once('error', error => { console.error(error.message); finished() })
693
+ launched.once('exit', finished)
694
+ await log.close()
695
+ }
696
+ if (record) {
697
+ const state = JSON.stringify({
698
+ pid: process.pid, childPid: child?.pid ?? null,
699
+ requestedState: record.requestedState, updatedAt: record.updatedAt,
700
+ requestId: record.supervisorRequestId ?? null
701
+ })
702
+ if (state !== lastState) {
703
+ const temporary = statePath + '.' + process.pid + '.' + Date.now() + '.tmp'
704
+ await fs.writeFile(temporary, state, { mode: 0o600, flag: 'wx' })
705
+ await fs.rename(temporary, statePath)
706
+ lastState = state
707
+ }
708
+ }
709
+ if (exiting && !child) break
710
+ await new Promise(resolve => setTimeout(resolve, 100))
711
+ }
712
+ `;
713
+ }
714
+ //#endregion
550
715
  //#region src/cli/service.ts
551
716
  const serviceRecordSchema = z.strictObject({
552
717
  schemaVersion: z.literal(1),
718
+ supervisorVersion: z.literal(1).nullable().default(null),
719
+ supervisorRequestId: z.string().uuid().nullable().default(null),
553
720
  manager: z.enum(["launchd", "systemd"]),
554
721
  mode: z.enum(["user", "headless"]).optional(),
555
722
  platform: z.string(),
@@ -883,7 +1050,6 @@ function createServiceEnvironment(input) {
883
1050
  TREEPORT_CACHE_DIR: cacheDirectory(input.user.homedir, env),
884
1051
  TREEPORT_DATABASE_PATH: env.TREEPORT_DATABASE_PATH?.trim() || path.join(input.paths.dataDir, "treeport.db"),
885
1052
  TREEPORT_SHELL: env.TREEPORT_SHELL?.trim() || env.SHELL?.trim() || "/bin/sh",
886
- TREEPORT_TMUX_PATH: env.TREEPORT_TMUX_PATH?.trim() || "tmux",
887
1053
  TREEPORT_GIT_PATH: env.TREEPORT_GIT_PATH?.trim() || "git",
888
1054
  TREEPORT_GH_PATH: env.TREEPORT_GH_PATH?.trim() || "gh",
889
1055
  TREEPORT_DAEMON_LIFECYCLE: "service",
@@ -897,12 +1063,12 @@ function definitionForRecord(record) {
897
1063
  if (record.manager === "launchd") return serializeLaunchdDefinition(createLaunchdDefinition({
898
1064
  label: record.definitionName,
899
1065
  mode: record.mode,
900
- runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath,
1066
+ runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath + (record.supervisorVersion ? "-supervised" : ""),
901
1067
  username: record.username,
902
1068
  group: record.group,
903
- environment: record.environment,
1069
+ environment: record.supervisorVersion ? { HOME: record.home } : record.environment,
904
1070
  home: record.home,
905
- logPath: record.logPath
1071
+ logPath: record.supervisorVersion ? "/dev/null" : record.logPath
906
1072
  }));
907
1073
  return serializeSystemdDefinition(createSystemdDefinition({
908
1074
  runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath,
@@ -910,6 +1076,10 @@ function definitionForRecord(record) {
910
1076
  }));
911
1077
  }
912
1078
  function runnerSource(record) {
1079
+ if (record.supervisorVersion) {
1080
+ const locations = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
1081
+ return `#!/bin/sh\nexec ${shellQuote$1(record.runtimeExecutable)} ${shellQuote$1(path.join(locations.directory, "supervisor.mjs"))} ${shellQuote$1(locations.recordPath)} ${record.uid} >> ${shellQuote$1(record.logPath)} 2>&1\n`;
1082
+ }
913
1083
  return `#!/bin/sh
914
1084
  set -u
915
1085
  entrypoint=${shellQuote$1(record.cliEntrypoint)}
@@ -931,8 +1101,23 @@ exec "$entrypoint" service run
931
1101
  function storedServiceMode(input) {
932
1102
  return input.mode ?? (input.manager === "launchd" ? "headless" : "user");
933
1103
  }
1104
+ async function assertServiceDirectory(directory, uid) {
1105
+ for (let current = directory;; current = path.dirname(current)) {
1106
+ const metadata = await fs.lstat(current);
1107
+ if (!metadata.isDirectory() || metadata.uid !== uid && metadata.uid !== 0 || (metadata.mode & 18) !== 0 && !(metadata.uid === 0 && metadata.mode & 512)) throw new Error(`Unsafe Treeport service directory: ${current}`);
1108
+ if (current === path.dirname(current)) break;
1109
+ }
1110
+ }
934
1111
  async function readServiceRecord(recordPath) {
1112
+ const metadata = await fs.lstat(recordPath).catch((error) => {
1113
+ if (error.code === "ENOENT") return null;
1114
+ throw error;
1115
+ });
1116
+ if (!metadata) return null;
1117
+ if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 63) !== 0) throw new Error("Refusing an unsafe Treeport service record.");
1118
+ await assertServiceDirectory(path.dirname(recordPath), metadata.uid);
935
1119
  const record = await readJson(recordPath, serviceRecordSchema);
1120
+ if (record && (!metadata.isFile() || metadata.isSymbolicLink() || metadata.uid !== record.uid || (metadata.mode & 63) !== 0 || process.getuid?.() !== 0 && process.getuid?.() !== record.uid)) throw new Error("Refusing an unsafe or foreign-owned Treeport service record.");
936
1121
  if (!record) return null;
937
1122
  return {
938
1123
  ...record,
@@ -943,7 +1128,14 @@ async function currentRecord() {
943
1128
  return readServiceRecord(servicePaths().recordPath);
944
1129
  }
945
1130
  async function saveRecord(record) {
946
- await writeJson$2(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).recordPath, record);
1131
+ if (process.getuid?.() !== record.uid || record.uid === 0) throw new Error("Run service lifecycle commands as the non-root Treeport data owner.");
1132
+ const locations = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
1133
+ await assertServiceDirectory(locations.directory, record.uid);
1134
+ await writeJson$2(locations.recordPath, {
1135
+ ...record,
1136
+ supervisorVersion: record.supervisorVersion ?? void 0,
1137
+ supervisorRequestId: record.supervisorRequestId ?? void 0
1138
+ });
947
1139
  }
948
1140
  async function managerState(record) {
949
1141
  if (record.manager === "launchd") {
@@ -1034,6 +1226,14 @@ async function untrackedDefinition() {
1034
1226
  async function serviceInstalled() {
1035
1227
  return await currentRecord() !== null || await untrackedDefinition() !== null;
1036
1228
  }
1229
+ function serviceHealthState(input) {
1230
+ if (input.actionRequired) return "action_required";
1231
+ if (input.stale) return "stale";
1232
+ if (input.installed && input.requestedState === "stopped") return input.daemonRunning ? "unhealthy" : "stopped";
1233
+ if (input.healthy) return "healthy";
1234
+ if (input.installed && input.managerActive && !input.supervised) return "starting";
1235
+ return input.installed ? "unhealthy" : "disabled";
1236
+ }
1037
1237
  async function serviceStatus() {
1038
1238
  const manager = managerForPlatform();
1039
1239
  const record = await currentRecord();
@@ -1118,8 +1318,8 @@ async function serviceStatus() {
1118
1318
  const definitionPresent = definitionContent !== "";
1119
1319
  const definitionMatches = definitionPresent && fingerprint(definitionContent) === record.definitionHash;
1120
1320
  const invokedEntrypoint = currentEntrypoint();
1121
- const entrypointMatches = Boolean(entrypointExists && (invokedEntrypoint === null || path.resolve(invokedEntrypoint) === path.resolve(record.cliEntrypoint)));
1122
- const environmentMatches = fingerprint(createServiceEnvironment({
1321
+ const entrypointMatches = Boolean(entrypointExists && (record.supervisorVersion !== null || invokedEntrypoint === null || path.resolve(invokedEntrypoint) === path.resolve(record.cliEntrypoint)));
1322
+ const currentEnvironment = createServiceEnvironment({
1123
1323
  user: {
1124
1324
  uid: record.uid,
1125
1325
  gid: record.gid,
@@ -1134,7 +1334,8 @@ async function serviceStatus() {
1134
1334
  apiUrl: record.apiUrl,
1135
1335
  recordPath: paths.recordPath,
1136
1336
  installationMethod: record.installationMethod
1137
- })) === record.environmentHash;
1337
+ });
1338
+ const environmentMatches = fingerprint(record.supervisorVersion ? record.environment : currentEnvironment) === record.environmentHash;
1138
1339
  const healthy = Boolean(daemon.verified && daemon.health?.daemonLifecycle === "service" && daemon.state?.daemonLifecycle === "service" && path.resolve(daemon.state.dataDir) === path.resolve(record.dataDir));
1139
1340
  const installed = managerStatus.enabled;
1140
1341
  const enabledAtBoot = installed && (record.manager === "launchd" ? record.mode === "headless" : managerStatus.lingering);
@@ -1143,6 +1344,11 @@ async function serviceStatus() {
1143
1344
  const issues = [];
1144
1345
  const recoveryCommands = [];
1145
1346
  const repairCommand = record.manager === "launchd" && record.mode === "headless" ? "treeport service enable --headless" : "treeport service enable";
1347
+ const needsMigration = record.manager === "launchd" && record.mode === "headless" && !record.supervisorVersion;
1348
+ if (needsMigration) {
1349
+ issues.push("This headless installation needs a one-time administrator migration. Run `treeport service enable --headless`; routine start and stop then need no administrator.");
1350
+ recoveryCommands.push(repairCommand);
1351
+ }
1146
1352
  if (record.manager !== manager) issues.push(`The service record uses ${record.manager}, but this host requires ${manager}.`);
1147
1353
  if (!definitionMatches && !record.pendingAdministratorRequestId) {
1148
1354
  issues.push(definitionPresent ? `The service definition at ${record.definitionPath} was changed.` : `The service definition is missing at ${record.definitionPath}.`);
@@ -1169,8 +1375,17 @@ async function serviceStatus() {
1169
1375
  issues.push("The supervised Treeport daemon is not healthy.");
1170
1376
  recoveryCommands.push("treeport start");
1171
1377
  }
1172
- const stale = record.manager !== manager || !definitionMatches || !environmentMatches || !entrypointMatches || definitionPresent && !installed || managerStatus.managerIssue !== null;
1173
- const 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";
1378
+ const stale = needsMigration || record.manager !== manager || !definitionMatches || !environmentMatches || !entrypointMatches || definitionPresent && !installed || managerStatus.managerIssue !== null;
1379
+ const state = serviceHealthState({
1380
+ actionRequired: Boolean(record.pendingAdministratorRequestId) || record.manager === "systemd" && installed && !managerStatus.lingering,
1381
+ stale,
1382
+ healthy,
1383
+ installed,
1384
+ requestedState: record.requestedState,
1385
+ daemonRunning: daemon.running,
1386
+ managerActive: managerStatus.active,
1387
+ supervised: record.supervisorVersion !== null
1388
+ });
1174
1389
  return {
1175
1390
  supported: true,
1176
1391
  manager,
@@ -1233,6 +1448,8 @@ async function prepareRecord(requestedMode) {
1233
1448
  if (previous?.manager === "launchd" && path.resolve(previous.definitionPath) !== path.resolve(definitionPath)) throw new Error(`The service record points to an unexpected definition at ${previous.definitionPath}. Refusing to create another definition.`);
1234
1449
  const base = {
1235
1450
  schemaVersion: 1,
1451
+ supervisorVersion: manager === "launchd" && mode === "headless" ? 1 : null,
1452
+ supervisorRequestId: null,
1236
1453
  manager,
1237
1454
  mode,
1238
1455
  platform: process.platform,
@@ -1270,6 +1487,11 @@ async function prepareRecord(requestedMode) {
1270
1487
  }
1271
1488
  async function writeServiceFiles(record, definition) {
1272
1489
  const locations = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
1490
+ await fs.mkdir(locations.directory, {
1491
+ recursive: true,
1492
+ mode: 448
1493
+ });
1494
+ await assertServiceDirectory(locations.directory, record.uid);
1273
1495
  await fs.mkdir(path.dirname(record.logPath), {
1274
1496
  recursive: true,
1275
1497
  mode: 448
@@ -1278,8 +1500,10 @@ async function writeServiceFiles(record, definition) {
1278
1500
  recursive: true,
1279
1501
  mode: 448
1280
1502
  });
1281
- await fs.writeFile(locations.runnerPath, runnerSource(record), { mode: 448 });
1282
- await fs.chmod(locations.runnerPath, 448);
1503
+ if (record.supervisorVersion) await fs.writeFile(path.join(locations.directory, "supervisor.mjs"), serviceSupervisorSource(), { mode: 384 });
1504
+ const runnerPath = locations.runnerPath + (record.supervisorVersion ? "-supervised" : "");
1505
+ await fs.writeFile(runnerPath, runnerSource(record), { mode: 448 });
1506
+ await fs.chmod(runnerPath, 448);
1283
1507
  if (record.manager === "launchd" && record.mode === "headless") await fs.writeFile(locations.stagedDefinitionPath, definition, { mode: 384 });
1284
1508
  else {
1285
1509
  await fs.mkdir(path.dirname(record.definitionPath), {
@@ -1314,7 +1538,7 @@ async function prepareAdministratorRequest(record, operation) {
1314
1538
  group: record.group,
1315
1539
  home: record.home,
1316
1540
  serviceRecordPath: locations.recordPath,
1317
- runnerPath: locations.runnerPath,
1541
+ runnerPath: locations.runnerPath + (record.supervisorVersion ? "-supervised" : ""),
1318
1542
  definitionName: record.definitionName,
1319
1543
  definitionPath: record.definitionPath,
1320
1544
  stagedDefinitionPath: locations.stagedDefinitionPath,
@@ -1474,11 +1698,13 @@ async function serviceStart() {
1474
1698
  const record = await currentRecord();
1475
1699
  if (!record) throw new Error("Treeport service mode is disabled. Run `treeport service enable` first.");
1476
1700
  const current = await serviceStatus();
1477
- if (current.state === "healthy") return {
1701
+ if (current.state === "healthy" && record.requestedState === "running") return {
1478
1702
  status: current,
1479
1703
  changed: false,
1480
1704
  administratorCommand: null
1481
1705
  };
1706
+ if (record.manager === "launchd" && record.mode === "headless" && !record.supervisorVersion) throw new Error("This headless service needs a one-time administrator migration. Run `treeport service enable --headless` first.");
1707
+ if (record.supervisorVersion && !current.active) throw new Error("The headless supervisor is not loaded. Run `treeport service enable --headless` to repair the startup integration with administrator approval.");
1482
1708
  if (current.administratorCommand) return {
1483
1709
  status: current,
1484
1710
  changed: false,
@@ -1488,17 +1714,18 @@ async function serviceStart() {
1488
1714
  const next = {
1489
1715
  ...record,
1490
1716
  requestedState: "running",
1717
+ supervisorRequestId: record.supervisorVersion ? crypto.randomUUID() : null,
1491
1718
  pendingAdministratorRequestId: null,
1492
1719
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1493
1720
  };
1494
1721
  await saveRecord(next);
1495
1722
  if (record.manager === "launchd") {
1496
1723
  if (record.mode === "headless") {
1497
- const prepared = await prepareAdministratorRequest(next, "start");
1724
+ await waitForService(next);
1498
1725
  return {
1499
1726
  status: await serviceStatus(),
1500
1727
  changed: true,
1501
- administratorCommand: prepared.command
1728
+ administratorCommand: null
1502
1729
  };
1503
1730
  }
1504
1731
  const launchctl = await executablePath("launchctl");
@@ -1548,7 +1775,8 @@ async function serviceStop() {
1548
1775
  const record = await currentRecord();
1549
1776
  if (!record) throw new Error("Treeport service mode is disabled.");
1550
1777
  const current = await serviceStatus();
1551
- if (current.state === "stopped") return {
1778
+ if (record.manager === "launchd" && record.mode === "headless" && !record.supervisorVersion) throw new Error("This headless service needs a one-time administrator migration. Run `treeport service enable --headless` first, or `treeport service disable` for administrator-approved removal.");
1779
+ if (current.state === "stopped" && !record.supervisorVersion) return {
1552
1780
  status: current,
1553
1781
  changed: false,
1554
1782
  administratorCommand: null
@@ -1556,17 +1784,36 @@ async function serviceStop() {
1556
1784
  const next = {
1557
1785
  ...record,
1558
1786
  requestedState: "stopped",
1787
+ supervisorRequestId: record.supervisorVersion ? crypto.randomUUID() : null,
1559
1788
  pendingAdministratorRequestId: null,
1560
1789
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1561
1790
  };
1562
1791
  await saveRecord(next);
1563
1792
  if (record.manager === "launchd") {
1564
1793
  if (record.mode === "headless") {
1565
- const prepared = await prepareAdministratorRequest(next, "stop");
1794
+ if (current.active) {
1795
+ const statePath = path.join(servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).directory, "supervisor.json");
1796
+ const deadline = Date.now() + 15e3;
1797
+ let stopped = false;
1798
+ while (Date.now() < deadline) {
1799
+ const acknowledgement = await readJson(statePath, z.object({
1800
+ requestedState: z.enum(["running", "stopped"]),
1801
+ requestId: z.string().nullable(),
1802
+ childPid: z.number().nullable()
1803
+ }));
1804
+ if (acknowledgement?.requestedState === "stopped" && acknowledgement.requestId === next.supervisorRequestId && acknowledgement.childPid === null) {
1805
+ stopped = true;
1806
+ break;
1807
+ }
1808
+ await new Promise((resolve) => setTimeout(resolve, 100));
1809
+ }
1810
+ if (!stopped) throw new Error(`Treeport supervisor did not confirm shutdown. Stopped intent is preserved; inspect ${record.logPath} before updating.`);
1811
+ }
1812
+ await daemonDown();
1566
1813
  return {
1567
1814
  status: await serviceStatus(),
1568
1815
  changed: true,
1569
- administratorCommand: prepared.command
1816
+ administratorCommand: null
1570
1817
  };
1571
1818
  }
1572
1819
  const commands = userLaunchdCommands({
@@ -1679,7 +1926,7 @@ async function serviceApply(requestPath) {
1679
1926
  if ((metadata.mode & 63) !== 0) throw new Error("The service apply request must not be readable or writable by other users.");
1680
1927
  const request = await readJson(requestPath, administratorRequestSchema);
1681
1928
  if (!request) throw new Error("The service apply request is invalid.");
1682
- if (metadata.uid !== request.uid) throw new Error("The service apply request owner does not match its target user.");
1929
+ if (request.uid <= 0 || metadata.uid !== request.uid) throw new Error("The service apply request owner does not match its target user.");
1683
1930
  if (Date.parse(request.expiresAt) <= Date.now()) throw new Error("The service apply request expired. Run the original Treeport command again.");
1684
1931
  const currentRuntime = await currentAdministratorRuntime().catch(() => null);
1685
1932
  const invokedRuntimeEntrypoint = process.argv[1] ? path.resolve(process.argv[1]) : null;
@@ -1692,13 +1939,27 @@ async function serviceApply(requestPath) {
1692
1939
  const record = await readServiceRecord(request.serviceRecordPath);
1693
1940
  if (!record || record.uid !== request.uid || record.username !== request.username || record.manager !== "launchd" || record.mode !== "headless" || 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.");
1694
1941
  if (account.uid !== 0) throw new Error("Treeport service apply lost root privileges.");
1942
+ const locations = servicePaths({ TREEPORT_DATA_DIR: record.dataDir });
1943
+ const location = launchdLocation({
1944
+ uid: record.uid,
1945
+ home: record.home,
1946
+ mode: "headless"
1947
+ });
1948
+ const groupId = await runCommand$1(await executablePath("id"), ["-g", record.username]);
1949
+ const groupName = await primaryGroup(record.username);
1950
+ if (groupId.code !== 0 || Number(groupId.stdout.trim()) !== record.gid || record.gid !== request.gid || groupName !== record.group || record.group !== request.group || request.definitionName !== location.name || request.definitionPath !== location.path || request.serviceRecordPath !== locations.recordPath || request.runnerPath !== locations.runnerPath + (record.supervisorVersion ? "-supervised" : "") || request.stagedDefinitionPath !== locations.stagedDefinitionPath || requestPath !== path.join(locations.requestsDirectory, `${request.id}.json`)) throw new Error("The administrator request has an unsafe account or installation path.");
1951
+ await assertServiceDirectory(locations.requestsDirectory, record.uid);
1952
+ if (request.operation === "start" || request.operation === "stop") throw new Error("Legacy administrator start/stop requests are no longer supported. Run `treeport service enable --headless` for the one-time startup integration migration.");
1695
1953
  const launchctl = await executablePath("launchctl");
1696
1954
  const target = `system/${request.definitionName}`;
1697
1955
  if (request.operation === "enable") {
1698
- const staged = await fs.readFile(request.stagedDefinitionPath, "utf8");
1699
- 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.");
1956
+ const definition = definitionForRecord(record);
1957
+ if (!record.supervisorVersion || fingerprint(definition) !== request.definitionHash) throw new Error("The staged LaunchDaemon definition does not match the approved request.");
1700
1958
  const temporaryPath = `${request.definitionPath}.${process.pid}.tmp`;
1701
- await fs.copyFile(request.stagedDefinitionPath, temporaryPath);
1959
+ await fs.writeFile(temporaryPath, definition, {
1960
+ mode: 420,
1961
+ flag: "wx"
1962
+ });
1702
1963
  await fs.chown(temporaryPath, 0, 0);
1703
1964
  await fs.chmod(temporaryPath, 420);
1704
1965
  await fs.rename(temporaryPath, request.definitionPath);
@@ -1711,38 +1972,28 @@ async function serviceApply(requestPath) {
1711
1972
  request.definitionPath
1712
1973
  ]);
1713
1974
  if (bootstrapped.code !== 0) throw commandError("launchctl bootstrap", bootstrapped);
1714
- } else if (request.operation === "start") {
1715
- const enabled = await runCommand$1(launchctl, ["enable", target]);
1716
- if (enabled.code !== 0) throw commandError("launchctl enable", enabled);
1717
- const started = (await runCommand$1(launchctl, ["print", target])).code === 0 ? await runCommand$1(launchctl, ["kickstart", target]) : await runCommand$1(launchctl, [
1718
- "bootstrap",
1719
- "system",
1720
- request.definitionPath
1721
- ]);
1722
- if (started.code !== 0) throw commandError("launchctl start", started);
1723
- } else if (request.operation === "stop") {
1724
- const stopped = await runCommand$1(launchctl, ["bootout", target]);
1725
- if (stopped.code !== 0 && !stopped.stderr.includes("No such process")) throw commandError("launchctl bootout", stopped);
1726
1975
  } else {
1727
1976
  const installed = await fs.readFile(request.definitionPath, "utf8").catch(() => "");
1728
1977
  if (installed && fingerprint(installed) !== request.definitionHash) throw new Error("Refusing to remove a LaunchDaemon definition that Treeport did not create.");
1729
1978
  await runCommand$1(launchctl, ["bootout", target]);
1730
1979
  await fs.rm(request.definitionPath, { force: true });
1731
1980
  }
1732
- if (request.operation === "enable" || request.operation === "start") await waitForService(record);
1981
+ process.setgroups([]);
1982
+ process.setgid(request.gid);
1983
+ process.setuid(request.uid);
1984
+ if (request.operation === "enable") await waitForService(record);
1733
1985
  await fs.rename(requestPath, usedPath);
1734
1986
  if (request.operation === "disable") await fs.rm(path.dirname(request.serviceRecordPath), {
1735
1987
  recursive: true,
1736
1988
  force: true
1737
1989
  });
1738
1990
  else {
1739
- await writeJson$2(request.serviceRecordPath, {
1740
- ...record,
1741
- requestedState: request.operation === "stop" ? "stopped" : "running",
1991
+ const latest = await readServiceRecord(request.serviceRecordPath);
1992
+ if (latest?.pendingAdministratorRequestId === request.id) await writeJson$2(request.serviceRecordPath, {
1993
+ ...latest,
1742
1994
  pendingAdministratorRequestId: null,
1743
1995
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1744
1996
  });
1745
- await fs.chown(request.serviceRecordPath, request.uid, request.gid);
1746
1997
  }
1747
1998
  return {
1748
1999
  operation: request.operation,
@@ -1755,8 +2006,12 @@ async function serviceRun() {
1755
2006
  const record = await readServiceRecord(recordPath);
1756
2007
  if (!record) throw new Error(`Treeport service record is invalid: ${recordPath}`);
1757
2008
  if (process.getuid?.() === 0 || process.getuid?.() !== record.uid) throw new Error(`Treeport service must run as ${record.username} (UID ${record.uid}), never as root.`);
1758
- await writeJson$2(recordPath, {
2009
+ if (record.supervisorVersion) {
2010
+ if (record.requestedState === "stopped") return;
2011
+ } else await writeJson$2(recordPath, {
1759
2012
  ...record,
2013
+ supervisorVersion: void 0,
2014
+ supervisorRequestId: void 0,
1760
2015
  requestedState: "running",
1761
2016
  pendingAdministratorRequestId: null,
1762
2017
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -1847,16 +2102,18 @@ async function createUpdateStartupReporter(config) {
1847
2102
  const paths = updatePaths(config.dataDir);
1848
2103
  const pending = await fs.readFile(paths.pending, "utf8").then((value) => pendingSchema.safeParse(JSON.parse(value))).then((result) => result.success ? result.data : null).catch(() => null);
1849
2104
  const active = pending && pending.targetVersion === config.appVersion ? pending : null;
2105
+ const previous = await readUpdateStartupReport(config.dataDir);
2106
+ const previousState = active && previous?.operationId === active.operationId && previous.targetVersion === active.targetVersion ? previous.migrationState : "unknown";
1850
2107
  const report = active ? {
1851
2108
  schemaVersion: 1,
1852
2109
  operationId: active.operationId,
1853
2110
  targetVersion: active.targetVersion,
1854
2111
  instanceId: config.instanceId ?? null,
1855
- migrationState: "not_started",
2112
+ migrationState: previousState,
1856
2113
  ready: false,
1857
2114
  error: null,
1858
2115
  logPath: path.join(config.dataDir, "logs", "daemon.log"),
1859
- snapshotPaths: [],
2116
+ snapshotPaths: previous?.operationId === active.operationId && previous.targetVersion === active.targetVersion ? previous.snapshotPaths : [],
1860
2117
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1861
2118
  } : null;
1862
2119
  const save = async () => {
@@ -1868,14 +2125,20 @@ async function createUpdateStartupReporter(config) {
1868
2125
  return {
1869
2126
  async databaseOpening() {
1870
2127
  if (report) {
1871
- report.migrationState = "unknown";
2128
+ report.migrationState = previousState === "advanced" ? "advanced" : "unknown";
2129
+ await save();
2130
+ }
2131
+ },
2132
+ async snapshotCreated(snapshotPath) {
2133
+ if (report) {
2134
+ report.snapshotPaths = [.../* @__PURE__ */ new Set([...report.snapshotPaths, snapshotPath])];
1872
2135
  await save();
1873
2136
  }
1874
2137
  },
1875
2138
  async databaseOpened(input) {
1876
2139
  if (report) {
1877
- report.migrationState = input.migrationState;
1878
- report.snapshotPaths = input.snapshotPaths;
2140
+ report.migrationState = previousState === "advanced" || input.migrationState === "advanced" ? "advanced" : previousState === "unknown" ? "unknown" : "unchanged";
2141
+ report.snapshotPaths = [.../* @__PURE__ */ new Set([...report.snapshotPaths, ...input.snapshotPaths])];
1879
2142
  await save();
1880
2143
  }
1881
2144
  },
@@ -1950,6 +2213,7 @@ const operationSchema = z.strictObject({
1950
2213
  stagedTarget: z.string().nullable(),
1951
2214
  previousTarget: z.string().nullable(),
1952
2215
  daemonWasRunning: z.boolean(),
2216
+ startRequested: z.boolean().default(false),
1953
2217
  daemonLifecycle: z.enum(["treeport", "service"]).nullable(),
1954
2218
  serviceMode: z.enum(["user", "headless"]).nullable(),
1955
2219
  terminalIds: z.array(z.string()),
@@ -1987,6 +2251,35 @@ const packedReleaseSchema = z.tuple([z.looseObject({
1987
2251
  filename: z.string().min(1),
1988
2252
  integrity: z.string().min(1)
1989
2253
  })]);
2254
+ function formatLocalUpdateError(message, details = {}) {
2255
+ return [...new Set([
2256
+ message,
2257
+ details.cause,
2258
+ details.recovery,
2259
+ details.logPath ? `Daemon log: ${details.logPath}` : null,
2260
+ ...(details.snapshotPaths ?? []).map((snapshot) => `Pre-migration snapshot: ${snapshot}`)
2261
+ ].filter(Boolean))].join("\n");
2262
+ }
2263
+ async function confirmLocalUpdate(preview, signal, input = process.stdin, output = process.stderr, style = humanOutput(process.env, output === process.stderr && Boolean(process.stderr.isTTY))) {
2264
+ if (signal.aborted) return false;
2265
+ return new Promise((resolve) => {
2266
+ const prompt = createInterface({
2267
+ input,
2268
+ output
2269
+ });
2270
+ const cancel = () => prompt.close();
2271
+ signal.addEventListener("abort", cancel, { once: true });
2272
+ prompt.once("SIGINT", cancel);
2273
+ prompt.once("close", () => {
2274
+ signal.removeEventListener("abort", cancel);
2275
+ resolve(false);
2276
+ });
2277
+ prompt.question(style.blocks(style.heading(`Update Treeport ${preview.fromVersion} -> ${preview.toVersion}?`), style.indent(["Clients can briefly disconnect. Terminal sessions are preserved.", preview.recovery ? "This also repairs an interrupted update." : preview.daemonWasRunning ? "Treeport will stop, update, and restart." : preview.startRequested ? "Treeport will start after the update." : "Treeport will remain stopped."].join("\n")), style.action("Continue? [y/N] ")), (answer) => {
2278
+ resolve(/^(y|yes)$/i.test(answer.trim()) && !signal.aborted);
2279
+ prompt.close();
2280
+ });
2281
+ });
2282
+ }
1990
2283
  var LocalUpdateError = class extends Error {
1991
2284
  code;
1992
2285
  details;
@@ -2003,7 +2296,9 @@ var LocalUpdateError = class extends Error {
2003
2296
  "UPDATE_IN_PROGRESS",
2004
2297
  "UPDATE_DOWNGRADE_REFUSED",
2005
2298
  "UPDATE_DAEMON_OWNERSHIP_FAILED",
2006
- "UPDATE_SERVICE_ADMINISTRATOR_ACTION_REQUIRED"
2299
+ "UPDATE_SERVICE_ADMINISTRATOR_ACTION_REQUIRED",
2300
+ "UPDATE_CONFIRMATION_REQUIRED",
2301
+ "UPDATE_SERVICE_NOT_READY"
2007
2302
  ].includes(code) ? 5 : 1);
2008
2303
  }
2009
2304
  };
@@ -2107,15 +2402,45 @@ async function replaceSymlink(linkPath, target) {
2107
2402
  await fs.rename(temporaryPath, linkPath);
2108
2403
  }
2109
2404
  async function terminalIds(apiUrl) {
2110
- const result = await fetch(`${apiUrl}/api/projects`).then(async (response) => response.ok ? response.json() : null).catch(() => null);
2111
- const parsed = z.looseObject({ projects: z.array(z.looseObject({ worktrees: z.array(z.looseObject({ terminals: z.array(z.looseObject({ id: z.string() })) })) })) }).safeParse(result);
2112
- if (!parsed.success) throw new Error("Treeport could not read the terminal inventory.");
2113
- return parsed.data.projects.flatMap((project) => project.worktrees).flatMap((worktree) => worktree.terminals).map((terminal) => terminal.id).sort();
2405
+ const parsed = decodeUnknownOrNull(projectsResponseSchema, await fetch(`${apiUrl}/api/projects`).then(async (response) => response.ok ? response.json() : null).catch(() => null));
2406
+ if (!parsed) throw new Error("Treeport could not read the terminal inventory.");
2407
+ return parsed.projects.flatMap((project) => project.worktrees).flatMap((worktree) => worktree.terminals).map((terminal) => terminal.id).sort();
2408
+ }
2409
+ function updateMigrationState(operation, report) {
2410
+ if (operation.migrationState === "advanced") return "advanced";
2411
+ if (report?.operationId === operation.operationId && report.targetVersion === operation.toVersion) return report.migrationState;
2412
+ return ["stop", "activate"].includes(operation.phase) && operation.migrationState === "not_started" ? "not_started" : "unknown";
2413
+ }
2414
+ async function stopUpdateDaemon(lifecycle) {
2415
+ if (lifecycle === "service") {
2416
+ const stopped = await serviceStop();
2417
+ if (stopped.administratorCommand) throw new LocalUpdateError("UPDATE_SERVICE_ADMINISTRATOR_ACTION_REQUIRED", "The service requires administrator action and was not stopped.", {
2418
+ phase: "stop",
2419
+ administratorCommand: stopped.administratorCommand,
2420
+ recovery: stopped.administratorCommand
2421
+ });
2422
+ const deadline = Date.now() + 7e3;
2423
+ while ((await daemonStatus()).state) {
2424
+ if (Date.now() >= deadline) throw new Error("Treeport could not verify that the service daemon stopped. Inspect the daemon log before changing the installed version.");
2425
+ await new Promise((resolve) => setTimeout(resolve, 100));
2426
+ }
2427
+ } else await daemonDown();
2114
2428
  }
2115
2429
  async function startThroughStableEntrypoint(entrypoint, environment) {
2116
2430
  const result = await runCommand(entrypoint, ["start", "--json"], environment);
2117
2431
  if (result.code !== 0) throw new Error(commandFailure("treeport start", result));
2118
2432
  }
2433
+ async function verifyRestoredDaemon(operation, dataDir) {
2434
+ const deadline = Date.now() + 1e4;
2435
+ let daemon = await daemonStatus();
2436
+ while (Date.now() < deadline && !daemon.verified) {
2437
+ await new Promise((resolve) => setTimeout(resolve, 100));
2438
+ daemon = await daemonStatus();
2439
+ }
2440
+ if (!daemon.verified || daemon.health?.version !== operation.fromVersion || daemon.health?.daemonLifecycle !== operation.daemonLifecycle || path.resolve(daemon.state.dataDir) !== dataDir) throw new Error("The previous Treeport daemon did not pass recovery verification.");
2441
+ const recovered = await terminalIds(daemon.state.apiUrl);
2442
+ if (operation.terminalIds.some((id) => !recovered.includes(id))) throw new Error("The previous Treeport daemon did not recover every terminal.");
2443
+ }
2119
2444
  async function inspectLocalUpdateInstallation(environment = process.env) {
2120
2445
  const entrypointValue = environment.TREEPORT_CLI_ENTRYPOINT?.trim();
2121
2446
  if (!entrypointValue || !path.isAbsolute(entrypointValue)) throw new LocalUpdateError("UPDATE_INSTALLATION_UNSUPPORTED", "Treeport could not identify a stable npm CLI entrypoint. Reinstall Treeport globally with npm, then retry.", { phase: "inspect" });
@@ -2255,8 +2580,10 @@ async function runLocalUpdate(options = {}) {
2255
2580
  return true;
2256
2581
  })) throw new LocalUpdateError("UPDATE_IN_PROGRESS", "Another Treeport update is already running.", { phase: "inspect" });
2257
2582
  let interrupted = false;
2583
+ const cancellation = new AbortController();
2258
2584
  const interrupt = () => {
2259
2585
  interrupted = true;
2586
+ cancellation.abort();
2260
2587
  };
2261
2588
  process.on("SIGINT", interrupt);
2262
2589
  process.on("SIGTERM", interrupt);
@@ -2271,6 +2598,7 @@ async function runLocalUpdate(options = {}) {
2271
2598
  stagedTarget: null,
2272
2599
  previousTarget: null,
2273
2600
  daemonWasRunning: false,
2601
+ startRequested: options.start ?? false,
2274
2602
  daemonLifecycle: null,
2275
2603
  serviceMode: null,
2276
2604
  terminalIds: [],
@@ -2282,6 +2610,8 @@ async function runLocalUpdate(options = {}) {
2282
2610
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2283
2611
  };
2284
2612
  let recoveryOperation = null;
2613
+ let recoveryReport = null;
2614
+ let recoveringPrevious = false;
2285
2615
  const save = async (phase) => {
2286
2616
  operation = {
2287
2617
  ...operation,
@@ -2300,49 +2630,21 @@ async function runLocalUpdate(options = {}) {
2300
2630
  npmPrefix: installation.prefix,
2301
2631
  activeTarget: installation.managed ? await fs.realpath(installation.currentLink).catch(() => installation.prefix) : installation.prefix
2302
2632
  };
2303
- if (staleOperation && staleOperation.daemonWasRunning && DESTRUCTIVE_PHASES.has(staleOperation.phase) && !(await daemonStatus()).running) {
2633
+ if (staleOperation && (staleOperation.daemonWasRunning || staleOperation.startRequested || staleOperation.activated) && DESTRUCTIVE_PHASES.has(staleOperation.phase) && !staleOperation.rollbackSucceeded && !(await daemonStatus()).running) {
2304
2634
  const staleReport = await readUpdateStartupReport(paths.dataDir);
2305
- if (Boolean(staleReport?.operationId === staleOperation.operationId && ["advanced", "unknown"].includes(staleReport.migrationState))) {
2635
+ staleOperation.migrationState = updateMigrationState(staleOperation, staleReport);
2636
+ if (["advanced", "unknown"].includes(staleOperation.migrationState)) {
2637
+ recoveryReport = staleReport?.operationId === staleOperation.operationId && staleReport.targetVersion === staleOperation.toVersion ? staleReport : null;
2306
2638
  if (staleOperation.previousTarget && operation.activeTarget === staleOperation.previousTarget) throw new LocalUpdateError("UPDATE_RECOVERY_REQUIRED", "The older Treeport version is active after a database migration may have started. Treeport will not start it.", {
2307
2639
  phase: "recovery_required",
2308
2640
  operationId: staleOperation.operationId,
2309
- migrationState: staleReport?.migrationState ?? "unknown",
2641
+ migrationState: staleOperation.migrationState,
2642
+ logPath: recoveryReport?.logPath ?? paths.logPath,
2643
+ snapshotPaths: recoveryReport?.snapshotPaths ?? [],
2310
2644
  recovery: "Install the same or a newer Treeport release and inspect the daemon log."
2311
2645
  });
2312
- recoveryOperation = staleOperation;
2313
- } else {
2314
- if (staleOperation.previousTarget) await replaceSymlink(installation.currentLink, staleOperation.previousTarget);
2315
- await fs.rm(path.join(updateDirectory, "pending-startup.json"), { force: true });
2316
- await fs.rm(path.join(updateDirectory, "startup-report.json"), { force: true });
2317
- await startThroughStableEntrypoint(installation.entrypoint, environment).catch((error) => {
2318
- throw new LocalUpdateError("UPDATE_RECOVERY_REQUIRED", "Treeport restored the previous version but could not restart its daemon.", {
2319
- phase: "recovery_required",
2320
- operationId: staleOperation.operationId,
2321
- cause: error instanceof Error ? error.message : String(error),
2322
- recovery: "Inspect the daemon log, then run `treeport start`."
2323
- });
2324
- });
2325
- await writeJson(operationPath, {
2326
- ...staleOperation,
2327
- phase: "complete",
2328
- activated: false,
2329
- rollbackAttempted: true,
2330
- rollbackSucceeded: true,
2331
- recoveryAction: "Run `treeport update` again.",
2332
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2333
- });
2334
- throw new LocalUpdateError("UPDATE_ROLLED_BACK", "Treeport recovered the interrupted update and restored the previous running version. Run `treeport update` again.", {
2335
- phase: "rollback",
2336
- operationId: staleOperation.operationId,
2337
- migrationState: staleReport?.migrationState ?? "not_started",
2338
- rollback: {
2339
- attempted: true,
2340
- safe: true,
2341
- succeeded: true
2342
- },
2343
- recovery: "Run `treeport update` again."
2344
- });
2345
2646
  }
2647
+ recoveryOperation = staleOperation;
2346
2648
  }
2347
2649
  await save("inspect");
2348
2650
  const initialDaemon = await daemonStatus();
@@ -2366,11 +2668,6 @@ async function runLocalUpdate(options = {}) {
2366
2668
  }
2367
2669
  const installedService = await serviceInstalled();
2368
2670
  const serviceBefore = installedService ? await serviceStatus() : null;
2369
- if (serviceBefore?.mode === "headless" && (serviceBefore.active || initialDaemon.running)) throw new LocalUpdateError("UPDATE_SERVICE_ADMINISTRATOR_ACTION_REQUIRED", "Stop the advanced headless service with its administrator action, then run `treeport update` again.", {
2370
- phase: "inspect",
2371
- operationId,
2372
- mode: "headless"
2373
- });
2374
2671
  if (installedService && initialDaemon.running && initialDaemon.health?.daemonLifecycle !== "service") throw new LocalUpdateError("UPDATE_DAEMON_OWNERSHIP_FAILED", "The running daemon does not belong to the installed Treeport service lifecycle.", {
2375
2672
  phase: "inspect",
2376
2673
  operationId
@@ -2384,13 +2681,15 @@ async function runLocalUpdate(options = {}) {
2384
2681
  phase: "resolve",
2385
2682
  operationId
2386
2683
  });
2387
- if (comparison === 0 && recoveryOperation) throw new LocalUpdateError("UPDATE_RECOVERY_REQUIRED", "Treeport needs a newer release to recover after the interrupted database migration.", {
2684
+ if (comparison === 0 && recoveryOperation && ["advanced", "unknown"].includes(recoveryOperation.migrationState)) throw new LocalUpdateError("UPDATE_RECOVERY_REQUIRED", "Treeport needs a newer release to recover after the interrupted database migration.", {
2388
2685
  phase: "recovery_required",
2389
2686
  operationId: recoveryOperation.operationId,
2390
2687
  migrationState: recoveryOperation.migrationState,
2688
+ logPath: recoveryReport?.logPath ?? paths.logPath,
2689
+ snapshotPaths: recoveryReport?.snapshotPaths ?? [],
2391
2690
  recovery: "Install the next Treeport release when it is available and run `treeport update` again."
2392
2691
  });
2393
- if (comparison === 0) {
2692
+ if (comparison === 0 && !recoveryOperation) {
2394
2693
  const currentTerminals = initialDaemon.verified ? await terminalIds(initialDaemon.state.apiUrl) : [];
2395
2694
  const currentLifecycle = initialDaemon.verified ? initialDaemon.health.daemonLifecycle === "service" ? "service" : initialDaemon.health.daemonLifecycle === "treeport" ? "treeport" : null : installedService ? "service" : "treeport";
2396
2695
  await save("complete");
@@ -2421,6 +2720,99 @@ async function runLocalUpdate(options = {}) {
2421
2720
  }
2422
2721
  };
2423
2722
  }
2723
+ const intendedRunning = initialDaemon.verified || serviceBefore?.requestedState === "running";
2724
+ const shouldRun = intendedRunning || Boolean(options.start) || Boolean(recoveryOperation?.daemonWasRunning) || Boolean(recoveryOperation?.startRequested);
2725
+ if (serviceBefore) {
2726
+ if (serviceBefore.administratorCommand) throw new LocalUpdateError("UPDATE_SERVICE_ADMINISTRATOR_ACTION_REQUIRED", "Complete the service action before updating Treeport.", {
2727
+ phase: "inspect",
2728
+ operationId,
2729
+ administratorCommand: serviceBefore.administratorCommand,
2730
+ recovery: serviceBefore.administratorCommand
2731
+ });
2732
+ if (!serviceBefore.installed || !serviceBefore.definitionMatches || !serviceBefore.entrypointMatches || !serviceBefore.environmentMatches || serviceBefore.state === "stale" || serviceBefore.state === "action_required") throw new LocalUpdateError("UPDATE_SERVICE_NOT_READY", "Repair the Treeport service before updating.", {
2733
+ phase: "inspect",
2734
+ operationId,
2735
+ cause: serviceBefore.issues.join("\n"),
2736
+ recovery: serviceBefore.recoveryCommands.join("\n")
2737
+ });
2738
+ }
2739
+ const preview = {
2740
+ fromVersion: installation.version,
2741
+ toVersion: release.version,
2742
+ daemonWasRunning: intendedRunning,
2743
+ startRequested: Boolean(options.start),
2744
+ recovery: recoveryOperation !== null
2745
+ };
2746
+ if (!options.yes && !interrupted) {
2747
+ if (options.confirm) {
2748
+ if (!await options.confirm(preview, cancellation.signal)) throw new LocalUpdateError("UPDATE_CANCELLED", "Treeport update cancelled. The installed version and daemon are unchanged.", {
2749
+ phase: "resolve",
2750
+ operationId,
2751
+ fromVersion: preview.fromVersion,
2752
+ toVersion: preview.toVersion
2753
+ }, 130);
2754
+ } else if (shouldRun || recoveryOperation) throw new LocalUpdateError("UPDATE_CONFIRMATION_REQUIRED", `Updating Treeport ${installation.version} -> ${release.version} requires consent. Clients can briefly disconnect; terminals are preserved. Re-run with --yes.`, {
2755
+ phase: "resolve",
2756
+ operationId,
2757
+ fromVersion: preview.fromVersion,
2758
+ toVersion: preview.toVersion,
2759
+ recovery: "Re-run `treeport update --yes` to approve the update."
2760
+ });
2761
+ }
2762
+ if (interrupted) throw new LocalUpdateError("UPDATE_CANCELLED", "Treeport update cancelled. The installed version and daemon are unchanged.", {
2763
+ phase: "resolve",
2764
+ operationId
2765
+ }, 130);
2766
+ if (recoveryOperation && ["not_started", "unchanged"].includes(recoveryOperation.migrationState)) {
2767
+ recoveringPrevious = true;
2768
+ if (recoveryOperation.daemonLifecycle === "service" || recoveryOperation.daemonWasRunning || recoveryOperation.startRequested) await stopUpdateDaemon(recoveryOperation.daemonLifecycle);
2769
+ recoveryOperation.migrationState = updateMigrationState(recoveryOperation, await readUpdateStartupReport(paths.dataDir));
2770
+ if (["advanced", "unknown"].includes(recoveryOperation.migrationState)) {
2771
+ recoveryOperation.recoveryAction = "Keep the installed version. Inspect the daemon log and retry with the same or a newer release.";
2772
+ await writeJson(operationPath, {
2773
+ ...recoveryOperation,
2774
+ phase: "recovery_required",
2775
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2776
+ });
2777
+ throw new LocalUpdateError("UPDATE_RECOVERY_REQUIRED", "Startup evidence changed during recovery. Treeport will not start the older version.", {
2778
+ phase: "recovery_required",
2779
+ operationId: recoveryOperation.operationId,
2780
+ migrationState: recoveryOperation.migrationState,
2781
+ recovery: recoveryOperation.recoveryAction
2782
+ });
2783
+ }
2784
+ if (recoveryOperation.previousTarget) await replaceSymlink(installation.currentLink, recoveryOperation.previousTarget);
2785
+ await fs.rm(path.join(updateDirectory, "pending-startup.json"), { force: true });
2786
+ await fs.rm(path.join(updateDirectory, "startup-report.json"), { force: true });
2787
+ if (recoveryOperation.daemonWasRunning) await startThroughStableEntrypoint(installation.entrypoint, environment).then(() => verifyRestoredDaemon(recoveryOperation, paths.dataDir)).catch((error) => {
2788
+ throw new LocalUpdateError("UPDATE_RECOVERY_REQUIRED", "Treeport restored the previous version but could not verify daemon and terminal recovery.", {
2789
+ phase: "recovery_required",
2790
+ operationId: recoveryOperation.operationId,
2791
+ cause: error instanceof Error ? error.message : String(error),
2792
+ recovery: "Inspect the daemon log, then run `treeport start`."
2793
+ });
2794
+ });
2795
+ await writeJson(operationPath, {
2796
+ ...recoveryOperation,
2797
+ phase: "complete",
2798
+ activated: false,
2799
+ rollbackAttempted: true,
2800
+ rollbackSucceeded: true,
2801
+ recoveryAction: "Run `treeport update` again.",
2802
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2803
+ });
2804
+ throw new LocalUpdateError("UPDATE_ROLLED_BACK", "Treeport recovered the interrupted update and restored the previous state. Run `treeport update` again.", {
2805
+ phase: "rollback",
2806
+ operationId: recoveryOperation.operationId,
2807
+ migrationState: recoveryOperation.migrationState,
2808
+ rollback: {
2809
+ attempted: true,
2810
+ safe: true,
2811
+ succeeded: true
2812
+ },
2813
+ recovery: "Run `treeport update` again."
2814
+ });
2815
+ }
2424
2816
  const stagingPath = path.join(installation.managedRoot, `.staging-${release.version}-${operationId}`);
2425
2817
  const targetPath = path.join(installation.versionsDirectory, release.version);
2426
2818
  operation.stagedTarget = stagingPath;
@@ -2529,22 +2921,26 @@ async function runLocalUpdate(options = {}) {
2529
2921
  phase: "verify",
2530
2922
  operationId
2531
2923
  });
2532
- operation.daemonWasRunning = daemonBefore.running && daemonBefore.verified || recoveryOperation !== null;
2533
- operation.daemonLifecycle = recoveryOperation ? recoveryOperation.daemonLifecycle : operation.daemonWasRunning ? daemonBefore.health?.daemonLifecycle === "service" ? "service" : "treeport" : installedService ? "service" : "treeport";
2924
+ operation.migrationState = recoveryOperation?.migrationState ?? "not_started";
2925
+ const serviceReady = installedService ? await serviceStatus() : null;
2926
+ if (serviceReady?.installed !== serviceBefore?.installed || serviceReady?.requestedState !== serviceBefore?.requestedState || serviceReady?.definitionPath !== serviceBefore?.definitionPath || serviceReady?.mode !== serviceBefore?.mode || serviceReady && (serviceReady.state === "stale" || serviceReady.state === "action_required" || !serviceReady.definitionMatches || !serviceReady.environmentMatches || !serviceReady.entrypointMatches || serviceReady.administratorCommand)) throw new LocalUpdateError("UPDATE_SERVICE_NOT_READY", "Treeport service state changed while the update was staged. Retry the update.", {
2927
+ phase: "verify",
2928
+ operationId
2929
+ });
2930
+ operation.daemonWasRunning = intendedRunning || Boolean(recoveryOperation?.daemonWasRunning);
2931
+ operation.startRequested = Boolean(options.start) || Boolean(recoveryOperation?.startRequested);
2932
+ operation.daemonLifecycle = recoveryOperation ? recoveryOperation.daemonLifecycle : daemonBefore.verified ? daemonBefore.health?.daemonLifecycle === "service" ? "service" : "treeport" : installedService ? "service" : "treeport";
2534
2933
  operation.serviceMode = recoveryOperation?.serviceMode ?? serviceBefore?.mode ?? null;
2535
- operation.terminalIds = recoveryOperation ? recoveryOperation.terminalIds : operation.daemonWasRunning ? await terminalIds(daemonBefore.state.apiUrl) : [];
2934
+ operation.terminalIds = recoveryOperation ? recoveryOperation.terminalIds : daemonBefore.verified ? await terminalIds(daemonBefore.state.apiUrl) : [];
2536
2935
  if (interrupted) throw new LocalUpdateError("UPDATE_INTERRUPTED", "Treeport update was interrupted before activation. The installed version and daemon are unchanged.", {
2537
2936
  phase: "verify",
2538
2937
  operationId
2539
2938
  });
2540
2939
  await save("stop");
2541
- progress("Stopping the Treeport daemon and preserving terminals…");
2542
- if (operation.daemonWasRunning) if (operation.daemonLifecycle === "service") {
2543
- if ((await serviceStop()).administratorCommand) throw new LocalUpdateError("UPDATE_SERVICE_ADMINISTRATOR_ACTION_REQUIRED", "The service requires administrator action and was not stopped.", {
2544
- phase: "stop",
2545
- operationId
2546
- });
2547
- } else await daemonDown();
2940
+ if (operation.daemonLifecycle === "service" || operation.daemonWasRunning || recoveryOperation) {
2941
+ progress("Stopping the Treeport daemon and preserving terminals…");
2942
+ await stopUpdateDaemon(operation.daemonLifecycle);
2943
+ }
2548
2944
  await save("activate");
2549
2945
  progress(`Activating Treeport ${release.version}…`);
2550
2946
  await fs.rm(targetPath, {
@@ -2568,14 +2964,26 @@ async function runLocalUpdate(options = {}) {
2568
2964
  await save("activate");
2569
2965
  let daemonAfter = null;
2570
2966
  let terminalsAfter = [];
2571
- if (operation.daemonWasRunning) {
2967
+ if (shouldRun) {
2572
2968
  await writeJson(path.join(updateDirectory, "pending-startup.json"), {
2573
2969
  schemaVersion: 1,
2574
2970
  operationId,
2575
2971
  targetVersion: release.version,
2576
2972
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
2577
2973
  });
2578
- await fs.rm(path.join(updateDirectory, "startup-report.json"), { force: true });
2974
+ await writeJson(path.join(updateDirectory, "startup-report.json"), {
2975
+ schemaVersion: 1,
2976
+ operationId,
2977
+ targetVersion: release.version,
2978
+ instanceId: null,
2979
+ migrationState: operation.migrationState,
2980
+ ready: false,
2981
+ error: null,
2982
+ logPath: paths.logPath,
2983
+ snapshotPaths: recoveryReport?.snapshotPaths ?? [],
2984
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2985
+ });
2986
+ operation.migrationState = operation.migrationState === "advanced" ? "advanced" : "unknown";
2579
2987
  await save("restart");
2580
2988
  progress(`Restarting the ${operation.daemonLifecycle === "service" ? "Treeport service" : "Treeport daemon"}…`);
2581
2989
  await startThroughStableEntrypoint(installation.entrypoint, environment);
@@ -2588,7 +2996,7 @@ async function runLocalUpdate(options = {}) {
2588
2996
  daemonAfter = await daemonStatus();
2589
2997
  report = await readUpdateStartupReport(paths.dataDir);
2590
2998
  }
2591
- operation.migrationState = report?.operationId === operationId ? report.migrationState : "unknown";
2999
+ operation.migrationState = updateMigrationState(operation, report);
2592
3000
  if (!daemonAfter.running || !daemonAfter.verified || daemonAfter.health?.version !== release.version || daemonAfter.health.daemonLifecycle !== operation.daemonLifecycle || path.resolve(daemonAfter.state.dataDir) !== paths.dataDir || report?.operationId !== operationId || !report.ready) throw new LocalUpdateError("UPDATE_HEALTH_VERIFICATION_FAILED", `Treeport ${release.version} did not pass startup verification.`, {
2593
3001
  phase: "health_check",
2594
3002
  operationId
@@ -2625,8 +3033,8 @@ async function runLocalUpdate(options = {}) {
2625
3033
  daemon: {
2626
3034
  wasRunning: operation.daemonWasRunning,
2627
3035
  lifecycle: operation.daemonLifecycle,
2628
- restarted: operation.daemonWasRunning,
2629
- healthy: operation.daemonWasRunning ? Boolean(daemonAfter?.verified) : false,
3036
+ restarted: shouldRun,
3037
+ healthy: Boolean(daemonAfter?.verified),
2630
3038
  version: daemonAfter?.health?.version ?? null
2631
3039
  },
2632
3040
  terminals: {
@@ -2644,6 +3052,12 @@ async function runLocalUpdate(options = {}) {
2644
3052
  const failedPhase = operation.phase;
2645
3053
  if (!DESTRUCTIVE_PHASES.has(operation.phase)) {
2646
3054
  if (error instanceof LocalUpdateError) throw error;
3055
+ if (recoveringPrevious && recoveryOperation) throw new LocalUpdateError("UPDATE_RECOVERY_REQUIRED", "Treeport could not restore the interrupted update. Recovery is still required.", {
3056
+ phase: "recovery_required",
3057
+ operationId: recoveryOperation.operationId,
3058
+ cause: error instanceof Error ? error.message : String(error),
3059
+ recovery: "Inspect the active version and daemon log before retrying."
3060
+ });
2647
3061
  throw new LocalUpdateError(operation.phase === "resolve" ? "UPDATE_RELEASE_RESOLUTION_FAILED" : operation.phase === "stage" ? "UPDATE_STAGING_FAILED" : operation.phase === "verify" ? "UPDATE_VERIFICATION_FAILED" : "UPDATE_INSTALLATION_UNSUPPORTED", error instanceof Error ? error.message : String(error), {
2648
3062
  phase: operation.phase,
2649
3063
  operationId,
@@ -2651,13 +3065,14 @@ async function runLocalUpdate(options = {}) {
2651
3065
  toVersion: operation.toVersion
2652
3066
  });
2653
3067
  }
2654
- const startupReport = await readUpdateStartupReport(paths.dataDir);
2655
- if (startupReport?.operationId === operationId) operation.migrationState = startupReport.migrationState;
2656
- if (!["not_started", "unchanged"].includes(operation.migrationState)) {
2657
- const serviceStopError = operation.daemonLifecycle === "service" ? await serviceStop().then(() => null, (cause) => cause instanceof Error ? cause.message : String(cause)) : null;
2658
- operation.recoveryAction = serviceStopError ? `Keep the new version installed. Stop the service, then inspect the daemon log. Service stop failed: ${serviceStopError}` : "Keep the new version installed. Inspect the daemon log and repair with the same or a newer Treeport release.";
3068
+ const stopError = operation.daemonLifecycle === "service" || operation.daemonWasRunning || operation.startRequested ? await stopUpdateDaemon(operation.daemonLifecycle).then(() => null, (cause) => cause instanceof Error ? cause.message : String(cause)) : null;
3069
+ const observedReport = await readUpdateStartupReport(paths.dataDir);
3070
+ const startupReport = observedReport?.operationId === operationId && observedReport.targetVersion === operation.toVersion ? observedReport : null;
3071
+ operation.migrationState = updateMigrationState(operation, startupReport);
3072
+ if (!(!stopError && ["not_started", "unchanged"].includes(operation.migrationState))) {
3073
+ operation.recoveryAction = stopError ? `Keep the active version installed. Stop the daemon, then inspect the daemon log. Stop failed: ${stopError}` : "Keep the new version installed. Inspect the daemon log and repair with the same or a newer Treeport release.";
2659
3074
  await save("recovery_required");
2660
- throw new LocalUpdateError("UPDATE_RECOVERY_REQUIRED", "The updated daemon did not become healthy after database migration began. Treeport did not start the older daemon.", {
3075
+ throw new LocalUpdateError("UPDATE_RECOVERY_REQUIRED", "Treeport could not prove that rollback is safe. Treeport did not start the older daemon.", {
2661
3076
  operationId,
2662
3077
  phase: failedPhase,
2663
3078
  fromVersion: operation.fromVersion,
@@ -2668,6 +3083,7 @@ async function runLocalUpdate(options = {}) {
2668
3083
  safe: false,
2669
3084
  succeeded: false
2670
3085
  },
3086
+ cause: startupReport?.error ?? (error instanceof Error ? error.message : String(error)),
2671
3087
  logPath: startupReport?.logPath ?? paths.logPath,
2672
3088
  snapshotPaths: startupReport?.snapshotPaths ?? [],
2673
3089
  recovery: operation.recoveryAction
@@ -2679,7 +3095,10 @@ async function runLocalUpdate(options = {}) {
2679
3095
  const rollbackError = await (async () => {
2680
3096
  if (operation.previousTarget) await replaceSymlink(installation.currentLink, operation.previousTarget);
2681
3097
  await fs.rm(path.join(updateDirectory, "pending-startup.json"), { force: true });
2682
- if (operation.daemonWasRunning) await startThroughStableEntrypoint(installation.entrypoint, environment);
3098
+ if (operation.daemonWasRunning) {
3099
+ await startThroughStableEntrypoint(installation.entrypoint, environment);
3100
+ await verifyRestoredDaemon(operation, paths.dataDir);
3101
+ }
2683
3102
  })().then(() => null, (cause) => cause);
2684
3103
  operation.rollbackSucceeded = rollbackError === null;
2685
3104
  operation.recoveryAction = rollbackError ? "Inspect the active version and daemon log before starting Treeport." : "The previous Treeport version is active again.";
@@ -2696,6 +3115,8 @@ async function runLocalUpdate(options = {}) {
2696
3115
  succeeded: rollbackError === null
2697
3116
  },
2698
3117
  cause: error instanceof Error ? error.message : String(error),
3118
+ logPath: startupReport?.logPath ?? paths.logPath,
3119
+ snapshotPaths: startupReport?.snapshotPaths ?? [],
2699
3120
  recovery: operation.recoveryAction
2700
3121
  });
2701
3122
  } finally {
@@ -2713,4 +3134,4 @@ async function runLocalUpdate(options = {}) {
2713
3134
  }
2714
3135
  }
2715
3136
  //#endregion
2716
- export { treeportVersion as A, disableTailscaleRemote as C, resolvePackagePath as D, resolveLocalApiUrl as E, parseDurationMs as M, runDoctor as O, daemonUp as S, readDaemonLogs as T, serviceStatus as _, readLocalUpdateProgress as a, daemonHealth as b, createUpdateStartupReporter as c, serviceDisable as d, serviceDoctorCheck as f, serviceStart as g, serviceRun as h, isCanonicalTreeportVersion as i, assertLoopbackHost as j, tailscaleRemoteStatus as k, readServiceLogs as l, serviceInstalled as m, compareTreeportVersions as n, resolveLatestTreeportRelease as o, serviceEnable as p, inspectLocalUpdateInstallation as r, runLocalUpdate as s, LocalUpdateError as t, serviceApply as u, serviceStop as v, enableTailscaleRemote as w, daemonStatus as x, daemonDown as y };
3137
+ export { runDoctor as A, daemonStatus as C, readDaemonLogs as D, enableTailscaleRemote as E, formatServiceStatus as F, humanOutput as I, stateName as L, treeportVersion as M, formatLocalUpdateError$1 as N, resolveLocalApiUrl as O, formatLocalUpdateResult as P, assertLoopbackHost as R, daemonHealth as S, disableTailscaleRemote as T, serviceRun as _, inspectLocalUpdateInstallation as a, serviceStop as b, resolveLatestTreeportRelease as c, readServiceLogs as d, serviceApply as f, serviceInstalled as g, serviceEnable as h, formatLocalUpdateError as i, tailscaleRemoteStatus as j, resolvePackagePath as k, runLocalUpdate as l, serviceDoctorCheck as m, compareTreeportVersions as n, isCanonicalTreeportVersion as o, serviceDisable as p, confirmLocalUpdate as r, readLocalUpdateProgress as s, LocalUpdateError as t, createUpdateStartupReporter as u, serviceStart as v, daemonUp as w, daemonDown as x, serviceStatus as y, parseDurationMs as z };