@treeport/treeport 0.7.0 → 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.
@@ -5,8 +5,10 @@ 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";
8
9
  import { z } from "zod";
9
10
  import { fileURLToPath, pathToFileURL } from "node:url";
11
+ import kleur from "kleur";
10
12
  //#region src/duration.ts
11
13
  const DURATION_UNITS = /* @__PURE__ */ new Map([
12
14
  ["ms", 1],
@@ -40,6 +42,90 @@ function assertLoopbackHost(host) {
40
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.");
41
43
  }
42
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
43
129
  //#region src/cli/lifecycle.ts
44
130
  const DEFAULT_HOST = "127.0.0.1";
45
131
  const DEFAULT_PORT = 8733;
@@ -472,7 +558,8 @@ async function daemonUp(options) {
472
558
  TREEPORT_WEB_DIST: webDist
473
559
  };
474
560
  if (options.foreground) {
475
- 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]])));
476
563
  const child = spawn(process.execPath, [serverEntry], {
477
564
  env: childEnvironment,
478
565
  stdio: "inherit"
@@ -527,9 +614,109 @@ async function readDaemonLogs(lines = 100) {
527
614
  })).split("\n").slice(-lines - 1).join("\n");
528
615
  }
529
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
530
715
  //#region src/cli/service.ts
531
716
  const serviceRecordSchema = z.strictObject({
532
717
  schemaVersion: z.literal(1),
718
+ supervisorVersion: z.literal(1).nullable().default(null),
719
+ supervisorRequestId: z.string().uuid().nullable().default(null),
533
720
  manager: z.enum(["launchd", "systemd"]),
534
721
  mode: z.enum(["user", "headless"]).optional(),
535
722
  platform: z.string(),
@@ -876,12 +1063,12 @@ function definitionForRecord(record) {
876
1063
  if (record.manager === "launchd") return serializeLaunchdDefinition(createLaunchdDefinition({
877
1064
  label: record.definitionName,
878
1065
  mode: record.mode,
879
- runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath,
1066
+ runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath + (record.supervisorVersion ? "-supervised" : ""),
880
1067
  username: record.username,
881
1068
  group: record.group,
882
- environment: record.environment,
1069
+ environment: record.supervisorVersion ? { HOME: record.home } : record.environment,
883
1070
  home: record.home,
884
- logPath: record.logPath
1071
+ logPath: record.supervisorVersion ? "/dev/null" : record.logPath
885
1072
  }));
886
1073
  return serializeSystemdDefinition(createSystemdDefinition({
887
1074
  runnerPath: servicePaths({ TREEPORT_DATA_DIR: record.dataDir }).runnerPath,
@@ -889,6 +1076,10 @@ function definitionForRecord(record) {
889
1076
  }));
890
1077
  }
891
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
+ }
892
1083
  return `#!/bin/sh
893
1084
  set -u
894
1085
  entrypoint=${shellQuote$1(record.cliEntrypoint)}
@@ -910,8 +1101,23 @@ exec "$entrypoint" service run
910
1101
  function storedServiceMode(input) {
911
1102
  return input.mode ?? (input.manager === "launchd" ? "headless" : "user");
912
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
+ }
913
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);
914
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.");
915
1121
  if (!record) return null;
916
1122
  return {
917
1123
  ...record,
@@ -922,7 +1128,14 @@ async function currentRecord() {
922
1128
  return readServiceRecord(servicePaths().recordPath);
923
1129
  }
924
1130
  async function saveRecord(record) {
925
- 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
+ });
926
1139
  }
927
1140
  async function managerState(record) {
928
1141
  if (record.manager === "launchd") {
@@ -1013,6 +1226,14 @@ async function untrackedDefinition() {
1013
1226
  async function serviceInstalled() {
1014
1227
  return await currentRecord() !== null || await untrackedDefinition() !== null;
1015
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
+ }
1016
1237
  async function serviceStatus() {
1017
1238
  const manager = managerForPlatform();
1018
1239
  const record = await currentRecord();
@@ -1097,8 +1318,8 @@ async function serviceStatus() {
1097
1318
  const definitionPresent = definitionContent !== "";
1098
1319
  const definitionMatches = definitionPresent && fingerprint(definitionContent) === record.definitionHash;
1099
1320
  const invokedEntrypoint = currentEntrypoint();
1100
- const entrypointMatches = Boolean(entrypointExists && (invokedEntrypoint === null || path.resolve(invokedEntrypoint) === path.resolve(record.cliEntrypoint)));
1101
- 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({
1102
1323
  user: {
1103
1324
  uid: record.uid,
1104
1325
  gid: record.gid,
@@ -1113,7 +1334,8 @@ async function serviceStatus() {
1113
1334
  apiUrl: record.apiUrl,
1114
1335
  recordPath: paths.recordPath,
1115
1336
  installationMethod: record.installationMethod
1116
- })) === record.environmentHash;
1337
+ });
1338
+ const environmentMatches = fingerprint(record.supervisorVersion ? record.environment : currentEnvironment) === record.environmentHash;
1117
1339
  const healthy = Boolean(daemon.verified && daemon.health?.daemonLifecycle === "service" && daemon.state?.daemonLifecycle === "service" && path.resolve(daemon.state.dataDir) === path.resolve(record.dataDir));
1118
1340
  const installed = managerStatus.enabled;
1119
1341
  const enabledAtBoot = installed && (record.manager === "launchd" ? record.mode === "headless" : managerStatus.lingering);
@@ -1122,6 +1344,11 @@ async function serviceStatus() {
1122
1344
  const issues = [];
1123
1345
  const recoveryCommands = [];
1124
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
+ }
1125
1352
  if (record.manager !== manager) issues.push(`The service record uses ${record.manager}, but this host requires ${manager}.`);
1126
1353
  if (!definitionMatches && !record.pendingAdministratorRequestId) {
1127
1354
  issues.push(definitionPresent ? `The service definition at ${record.definitionPath} was changed.` : `The service definition is missing at ${record.definitionPath}.`);
@@ -1148,8 +1375,17 @@ async function serviceStatus() {
1148
1375
  issues.push("The supervised Treeport daemon is not healthy.");
1149
1376
  recoveryCommands.push("treeport start");
1150
1377
  }
1151
- const stale = record.manager !== manager || !definitionMatches || !environmentMatches || !entrypointMatches || definitionPresent && !installed || managerStatus.managerIssue !== null;
1152
- 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
+ });
1153
1389
  return {
1154
1390
  supported: true,
1155
1391
  manager,
@@ -1212,6 +1448,8 @@ async function prepareRecord(requestedMode) {
1212
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.`);
1213
1449
  const base = {
1214
1450
  schemaVersion: 1,
1451
+ supervisorVersion: manager === "launchd" && mode === "headless" ? 1 : null,
1452
+ supervisorRequestId: null,
1215
1453
  manager,
1216
1454
  mode,
1217
1455
  platform: process.platform,
@@ -1249,6 +1487,11 @@ async function prepareRecord(requestedMode) {
1249
1487
  }
1250
1488
  async function writeServiceFiles(record, definition) {
1251
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);
1252
1495
  await fs.mkdir(path.dirname(record.logPath), {
1253
1496
  recursive: true,
1254
1497
  mode: 448
@@ -1257,8 +1500,10 @@ async function writeServiceFiles(record, definition) {
1257
1500
  recursive: true,
1258
1501
  mode: 448
1259
1502
  });
1260
- await fs.writeFile(locations.runnerPath, runnerSource(record), { mode: 448 });
1261
- 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);
1262
1507
  if (record.manager === "launchd" && record.mode === "headless") await fs.writeFile(locations.stagedDefinitionPath, definition, { mode: 384 });
1263
1508
  else {
1264
1509
  await fs.mkdir(path.dirname(record.definitionPath), {
@@ -1293,7 +1538,7 @@ async function prepareAdministratorRequest(record, operation) {
1293
1538
  group: record.group,
1294
1539
  home: record.home,
1295
1540
  serviceRecordPath: locations.recordPath,
1296
- runnerPath: locations.runnerPath,
1541
+ runnerPath: locations.runnerPath + (record.supervisorVersion ? "-supervised" : ""),
1297
1542
  definitionName: record.definitionName,
1298
1543
  definitionPath: record.definitionPath,
1299
1544
  stagedDefinitionPath: locations.stagedDefinitionPath,
@@ -1453,11 +1698,13 @@ async function serviceStart() {
1453
1698
  const record = await currentRecord();
1454
1699
  if (!record) throw new Error("Treeport service mode is disabled. Run `treeport service enable` first.");
1455
1700
  const current = await serviceStatus();
1456
- if (current.state === "healthy") return {
1701
+ if (current.state === "healthy" && record.requestedState === "running") return {
1457
1702
  status: current,
1458
1703
  changed: false,
1459
1704
  administratorCommand: null
1460
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.");
1461
1708
  if (current.administratorCommand) return {
1462
1709
  status: current,
1463
1710
  changed: false,
@@ -1467,17 +1714,18 @@ async function serviceStart() {
1467
1714
  const next = {
1468
1715
  ...record,
1469
1716
  requestedState: "running",
1717
+ supervisorRequestId: record.supervisorVersion ? crypto.randomUUID() : null,
1470
1718
  pendingAdministratorRequestId: null,
1471
1719
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1472
1720
  };
1473
1721
  await saveRecord(next);
1474
1722
  if (record.manager === "launchd") {
1475
1723
  if (record.mode === "headless") {
1476
- const prepared = await prepareAdministratorRequest(next, "start");
1724
+ await waitForService(next);
1477
1725
  return {
1478
1726
  status: await serviceStatus(),
1479
1727
  changed: true,
1480
- administratorCommand: prepared.command
1728
+ administratorCommand: null
1481
1729
  };
1482
1730
  }
1483
1731
  const launchctl = await executablePath("launchctl");
@@ -1527,7 +1775,8 @@ async function serviceStop() {
1527
1775
  const record = await currentRecord();
1528
1776
  if (!record) throw new Error("Treeport service mode is disabled.");
1529
1777
  const current = await serviceStatus();
1530
- 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 {
1531
1780
  status: current,
1532
1781
  changed: false,
1533
1782
  administratorCommand: null
@@ -1535,17 +1784,36 @@ async function serviceStop() {
1535
1784
  const next = {
1536
1785
  ...record,
1537
1786
  requestedState: "stopped",
1787
+ supervisorRequestId: record.supervisorVersion ? crypto.randomUUID() : null,
1538
1788
  pendingAdministratorRequestId: null,
1539
1789
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1540
1790
  };
1541
1791
  await saveRecord(next);
1542
1792
  if (record.manager === "launchd") {
1543
1793
  if (record.mode === "headless") {
1544
- 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();
1545
1813
  return {
1546
1814
  status: await serviceStatus(),
1547
1815
  changed: true,
1548
- administratorCommand: prepared.command
1816
+ administratorCommand: null
1549
1817
  };
1550
1818
  }
1551
1819
  const commands = userLaunchdCommands({
@@ -1658,7 +1926,7 @@ async function serviceApply(requestPath) {
1658
1926
  if ((metadata.mode & 63) !== 0) throw new Error("The service apply request must not be readable or writable by other users.");
1659
1927
  const request = await readJson(requestPath, administratorRequestSchema);
1660
1928
  if (!request) throw new Error("The service apply request is invalid.");
1661
- 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.");
1662
1930
  if (Date.parse(request.expiresAt) <= Date.now()) throw new Error("The service apply request expired. Run the original Treeport command again.");
1663
1931
  const currentRuntime = await currentAdministratorRuntime().catch(() => null);
1664
1932
  const invokedRuntimeEntrypoint = process.argv[1] ? path.resolve(process.argv[1]) : null;
@@ -1671,13 +1939,27 @@ async function serviceApply(requestPath) {
1671
1939
  const record = await readServiceRecord(request.serviceRecordPath);
1672
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.");
1673
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.");
1674
1953
  const launchctl = await executablePath("launchctl");
1675
1954
  const target = `system/${request.definitionName}`;
1676
1955
  if (request.operation === "enable") {
1677
- const staged = await fs.readFile(request.stagedDefinitionPath, "utf8");
1678
- 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.");
1679
1958
  const temporaryPath = `${request.definitionPath}.${process.pid}.tmp`;
1680
- await fs.copyFile(request.stagedDefinitionPath, temporaryPath);
1959
+ await fs.writeFile(temporaryPath, definition, {
1960
+ mode: 420,
1961
+ flag: "wx"
1962
+ });
1681
1963
  await fs.chown(temporaryPath, 0, 0);
1682
1964
  await fs.chmod(temporaryPath, 420);
1683
1965
  await fs.rename(temporaryPath, request.definitionPath);
@@ -1690,38 +1972,28 @@ async function serviceApply(requestPath) {
1690
1972
  request.definitionPath
1691
1973
  ]);
1692
1974
  if (bootstrapped.code !== 0) throw commandError("launchctl bootstrap", bootstrapped);
1693
- } else if (request.operation === "start") {
1694
- const enabled = await runCommand$1(launchctl, ["enable", target]);
1695
- if (enabled.code !== 0) throw commandError("launchctl enable", enabled);
1696
- const started = (await runCommand$1(launchctl, ["print", target])).code === 0 ? await runCommand$1(launchctl, ["kickstart", target]) : await runCommand$1(launchctl, [
1697
- "bootstrap",
1698
- "system",
1699
- request.definitionPath
1700
- ]);
1701
- if (started.code !== 0) throw commandError("launchctl start", started);
1702
- } else if (request.operation === "stop") {
1703
- const stopped = await runCommand$1(launchctl, ["bootout", target]);
1704
- if (stopped.code !== 0 && !stopped.stderr.includes("No such process")) throw commandError("launchctl bootout", stopped);
1705
1975
  } else {
1706
1976
  const installed = await fs.readFile(request.definitionPath, "utf8").catch(() => "");
1707
1977
  if (installed && fingerprint(installed) !== request.definitionHash) throw new Error("Refusing to remove a LaunchDaemon definition that Treeport did not create.");
1708
1978
  await runCommand$1(launchctl, ["bootout", target]);
1709
1979
  await fs.rm(request.definitionPath, { force: true });
1710
1980
  }
1711
- 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);
1712
1985
  await fs.rename(requestPath, usedPath);
1713
1986
  if (request.operation === "disable") await fs.rm(path.dirname(request.serviceRecordPath), {
1714
1987
  recursive: true,
1715
1988
  force: true
1716
1989
  });
1717
1990
  else {
1718
- await writeJson$2(request.serviceRecordPath, {
1719
- ...record,
1720
- 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,
1721
1994
  pendingAdministratorRequestId: null,
1722
1995
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1723
1996
  });
1724
- await fs.chown(request.serviceRecordPath, request.uid, request.gid);
1725
1997
  }
1726
1998
  return {
1727
1999
  operation: request.operation,
@@ -1734,8 +2006,12 @@ async function serviceRun() {
1734
2006
  const record = await readServiceRecord(recordPath);
1735
2007
  if (!record) throw new Error(`Treeport service record is invalid: ${recordPath}`);
1736
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.`);
1737
- await writeJson$2(recordPath, {
2009
+ if (record.supervisorVersion) {
2010
+ if (record.requestedState === "stopped") return;
2011
+ } else await writeJson$2(recordPath, {
1738
2012
  ...record,
2013
+ supervisorVersion: void 0,
2014
+ supervisorRequestId: void 0,
1739
2015
  requestedState: "running",
1740
2016
  pendingAdministratorRequestId: null,
1741
2017
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -1937,6 +2213,7 @@ const operationSchema = z.strictObject({
1937
2213
  stagedTarget: z.string().nullable(),
1938
2214
  previousTarget: z.string().nullable(),
1939
2215
  daemonWasRunning: z.boolean(),
2216
+ startRequested: z.boolean().default(false),
1940
2217
  daemonLifecycle: z.enum(["treeport", "service"]).nullable(),
1941
2218
  serviceMode: z.enum(["user", "headless"]).nullable(),
1942
2219
  terminalIds: z.array(z.string()),
@@ -1983,6 +2260,26 @@ function formatLocalUpdateError(message, details = {}) {
1983
2260
  ...(details.snapshotPaths ?? []).map((snapshot) => `Pre-migration snapshot: ${snapshot}`)
1984
2261
  ].filter(Boolean))].join("\n");
1985
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
+ }
1986
2283
  var LocalUpdateError = class extends Error {
1987
2284
  code;
1988
2285
  details;
@@ -1999,7 +2296,9 @@ var LocalUpdateError = class extends Error {
1999
2296
  "UPDATE_IN_PROGRESS",
2000
2297
  "UPDATE_DOWNGRADE_REFUSED",
2001
2298
  "UPDATE_DAEMON_OWNERSHIP_FAILED",
2002
- "UPDATE_SERVICE_ADMINISTRATOR_ACTION_REQUIRED"
2299
+ "UPDATE_SERVICE_ADMINISTRATOR_ACTION_REQUIRED",
2300
+ "UPDATE_CONFIRMATION_REQUIRED",
2301
+ "UPDATE_SERVICE_NOT_READY"
2003
2302
  ].includes(code) ? 5 : 1);
2004
2303
  }
2005
2304
  };
@@ -2114,7 +2413,12 @@ function updateMigrationState(operation, report) {
2114
2413
  }
2115
2414
  async function stopUpdateDaemon(lifecycle) {
2116
2415
  if (lifecycle === "service") {
2117
- if ((await serviceStop()).administratorCommand) throw new LocalUpdateError("UPDATE_SERVICE_ADMINISTRATOR_ACTION_REQUIRED", "The service requires administrator action and was not stopped.", { phase: "stop" });
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
+ });
2118
2422
  const deadline = Date.now() + 7e3;
2119
2423
  while ((await daemonStatus()).state) {
2120
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.");
@@ -2126,6 +2430,17 @@ async function startThroughStableEntrypoint(entrypoint, environment) {
2126
2430
  const result = await runCommand(entrypoint, ["start", "--json"], environment);
2127
2431
  if (result.code !== 0) throw new Error(commandFailure("treeport start", result));
2128
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
+ }
2129
2444
  async function inspectLocalUpdateInstallation(environment = process.env) {
2130
2445
  const entrypointValue = environment.TREEPORT_CLI_ENTRYPOINT?.trim();
2131
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" });
@@ -2265,8 +2580,10 @@ async function runLocalUpdate(options = {}) {
2265
2580
  return true;
2266
2581
  })) throw new LocalUpdateError("UPDATE_IN_PROGRESS", "Another Treeport update is already running.", { phase: "inspect" });
2267
2582
  let interrupted = false;
2583
+ const cancellation = new AbortController();
2268
2584
  const interrupt = () => {
2269
2585
  interrupted = true;
2586
+ cancellation.abort();
2270
2587
  };
2271
2588
  process.on("SIGINT", interrupt);
2272
2589
  process.on("SIGTERM", interrupt);
@@ -2281,6 +2598,7 @@ async function runLocalUpdate(options = {}) {
2281
2598
  stagedTarget: null,
2282
2599
  previousTarget: null,
2283
2600
  daemonWasRunning: false,
2601
+ startRequested: options.start ?? false,
2284
2602
  daemonLifecycle: null,
2285
2603
  serviceMode: null,
2286
2604
  terminalIds: [],
@@ -2293,6 +2611,7 @@ async function runLocalUpdate(options = {}) {
2293
2611
  };
2294
2612
  let recoveryOperation = null;
2295
2613
  let recoveryReport = null;
2614
+ let recoveringPrevious = false;
2296
2615
  const save = async (phase) => {
2297
2616
  operation = {
2298
2617
  ...operation,
@@ -2311,8 +2630,7 @@ async function runLocalUpdate(options = {}) {
2311
2630
  npmPrefix: installation.prefix,
2312
2631
  activeTarget: installation.managed ? await fs.realpath(installation.currentLink).catch(() => installation.prefix) : installation.prefix
2313
2632
  };
2314
- if (staleOperation && staleOperation.daemonWasRunning && DESTRUCTIVE_PHASES.has(staleOperation.phase) && !(await daemonStatus()).running) {
2315
- await stopUpdateDaemon(staleOperation.daemonLifecycle);
2633
+ if (staleOperation && (staleOperation.daemonWasRunning || staleOperation.startRequested || staleOperation.activated) && DESTRUCTIVE_PHASES.has(staleOperation.phase) && !staleOperation.rollbackSucceeded && !(await daemonStatus()).running) {
2316
2634
  const staleReport = await readUpdateStartupReport(paths.dataDir);
2317
2635
  staleOperation.migrationState = updateMigrationState(staleOperation, staleReport);
2318
2636
  if (["advanced", "unknown"].includes(staleOperation.migrationState)) {
@@ -2325,40 +2643,8 @@ async function runLocalUpdate(options = {}) {
2325
2643
  snapshotPaths: recoveryReport?.snapshotPaths ?? [],
2326
2644
  recovery: "Install the same or a newer Treeport release and inspect the daemon log."
2327
2645
  });
2328
- recoveryOperation = staleOperation;
2329
- } else {
2330
- if (staleOperation.previousTarget) await replaceSymlink(installation.currentLink, staleOperation.previousTarget);
2331
- await fs.rm(path.join(updateDirectory, "pending-startup.json"), { force: true });
2332
- await fs.rm(path.join(updateDirectory, "startup-report.json"), { force: true });
2333
- await startThroughStableEntrypoint(installation.entrypoint, environment).catch((error) => {
2334
- throw new LocalUpdateError("UPDATE_RECOVERY_REQUIRED", "Treeport restored the previous version but could not restart its daemon.", {
2335
- phase: "recovery_required",
2336
- operationId: staleOperation.operationId,
2337
- cause: error instanceof Error ? error.message : String(error),
2338
- recovery: "Inspect the daemon log, then run `treeport start`."
2339
- });
2340
- });
2341
- await writeJson(operationPath, {
2342
- ...staleOperation,
2343
- phase: "complete",
2344
- activated: false,
2345
- rollbackAttempted: true,
2346
- rollbackSucceeded: true,
2347
- recoveryAction: "Run `treeport update` again.",
2348
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2349
- });
2350
- throw new LocalUpdateError("UPDATE_ROLLED_BACK", "Treeport recovered the interrupted update and restored the previous running version. Run `treeport update` again.", {
2351
- phase: "rollback",
2352
- operationId: staleOperation.operationId,
2353
- migrationState: staleReport?.migrationState ?? "not_started",
2354
- rollback: {
2355
- attempted: true,
2356
- safe: true,
2357
- succeeded: true
2358
- },
2359
- recovery: "Run `treeport update` again."
2360
- });
2361
2646
  }
2647
+ recoveryOperation = staleOperation;
2362
2648
  }
2363
2649
  await save("inspect");
2364
2650
  const initialDaemon = await daemonStatus();
@@ -2382,11 +2668,6 @@ async function runLocalUpdate(options = {}) {
2382
2668
  }
2383
2669
  const installedService = await serviceInstalled();
2384
2670
  const serviceBefore = installedService ? await serviceStatus() : null;
2385
- 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.", {
2386
- phase: "inspect",
2387
- operationId,
2388
- mode: "headless"
2389
- });
2390
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.", {
2391
2672
  phase: "inspect",
2392
2673
  operationId
@@ -2400,7 +2681,7 @@ async function runLocalUpdate(options = {}) {
2400
2681
  phase: "resolve",
2401
2682
  operationId
2402
2683
  });
2403
- 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.", {
2404
2685
  phase: "recovery_required",
2405
2686
  operationId: recoveryOperation.operationId,
2406
2687
  migrationState: recoveryOperation.migrationState,
@@ -2408,7 +2689,7 @@ async function runLocalUpdate(options = {}) {
2408
2689
  snapshotPaths: recoveryReport?.snapshotPaths ?? [],
2409
2690
  recovery: "Install the next Treeport release when it is available and run `treeport update` again."
2410
2691
  });
2411
- if (comparison === 0) {
2692
+ if (comparison === 0 && !recoveryOperation) {
2412
2693
  const currentTerminals = initialDaemon.verified ? await terminalIds(initialDaemon.state.apiUrl) : [];
2413
2694
  const currentLifecycle = initialDaemon.verified ? initialDaemon.health.daemonLifecycle === "service" ? "service" : initialDaemon.health.daemonLifecycle === "treeport" ? "treeport" : null : installedService ? "service" : "treeport";
2414
2695
  await save("complete");
@@ -2439,6 +2720,99 @@ async function runLocalUpdate(options = {}) {
2439
2720
  }
2440
2721
  };
2441
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
+ }
2442
2816
  const stagingPath = path.join(installation.managedRoot, `.staging-${release.version}-${operationId}`);
2443
2817
  const targetPath = path.join(installation.versionsDirectory, release.version);
2444
2818
  operation.stagedTarget = stagingPath;
@@ -2548,17 +2922,25 @@ async function runLocalUpdate(options = {}) {
2548
2922
  operationId
2549
2923
  });
2550
2924
  operation.migrationState = recoveryOperation?.migrationState ?? "not_started";
2551
- operation.daemonWasRunning = daemonBefore.running && daemonBefore.verified || recoveryOperation !== null;
2552
- operation.daemonLifecycle = recoveryOperation ? recoveryOperation.daemonLifecycle : operation.daemonWasRunning ? daemonBefore.health?.daemonLifecycle === "service" ? "service" : "treeport" : installedService ? "service" : "treeport";
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";
2553
2933
  operation.serviceMode = recoveryOperation?.serviceMode ?? serviceBefore?.mode ?? null;
2554
- 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) : [];
2555
2935
  if (interrupted) throw new LocalUpdateError("UPDATE_INTERRUPTED", "Treeport update was interrupted before activation. The installed version and daemon are unchanged.", {
2556
2936
  phase: "verify",
2557
2937
  operationId
2558
2938
  });
2559
2939
  await save("stop");
2560
- progress("Stopping the Treeport daemon and preserving terminals…");
2561
- if (operation.daemonWasRunning) await stopUpdateDaemon(operation.daemonLifecycle);
2940
+ if (operation.daemonLifecycle === "service" || operation.daemonWasRunning || recoveryOperation) {
2941
+ progress("Stopping the Treeport daemon and preserving terminals…");
2942
+ await stopUpdateDaemon(operation.daemonLifecycle);
2943
+ }
2562
2944
  await save("activate");
2563
2945
  progress(`Activating Treeport ${release.version}…`);
2564
2946
  await fs.rm(targetPath, {
@@ -2582,7 +2964,7 @@ async function runLocalUpdate(options = {}) {
2582
2964
  await save("activate");
2583
2965
  let daemonAfter = null;
2584
2966
  let terminalsAfter = [];
2585
- if (operation.daemonWasRunning) {
2967
+ if (shouldRun) {
2586
2968
  await writeJson(path.join(updateDirectory, "pending-startup.json"), {
2587
2969
  schemaVersion: 1,
2588
2970
  operationId,
@@ -2651,8 +3033,8 @@ async function runLocalUpdate(options = {}) {
2651
3033
  daemon: {
2652
3034
  wasRunning: operation.daemonWasRunning,
2653
3035
  lifecycle: operation.daemonLifecycle,
2654
- restarted: operation.daemonWasRunning,
2655
- healthy: operation.daemonWasRunning ? Boolean(daemonAfter?.verified) : false,
3036
+ restarted: shouldRun,
3037
+ healthy: Boolean(daemonAfter?.verified),
2656
3038
  version: daemonAfter?.health?.version ?? null
2657
3039
  },
2658
3040
  terminals: {
@@ -2670,6 +3052,12 @@ async function runLocalUpdate(options = {}) {
2670
3052
  const failedPhase = operation.phase;
2671
3053
  if (!DESTRUCTIVE_PHASES.has(operation.phase)) {
2672
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
+ });
2673
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), {
2674
3062
  phase: operation.phase,
2675
3063
  operationId,
@@ -2677,12 +3065,12 @@ async function runLocalUpdate(options = {}) {
2677
3065
  toVersion: operation.toVersion
2678
3066
  });
2679
3067
  }
2680
- const stopError = operation.daemonWasRunning ? await stopUpdateDaemon(operation.daemonLifecycle).then(() => null, (cause) => cause instanceof Error ? cause.message : String(cause)) : null;
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;
2681
3069
  const observedReport = await readUpdateStartupReport(paths.dataDir);
2682
3070
  const startupReport = observedReport?.operationId === operationId && observedReport.targetVersion === operation.toVersion ? observedReport : null;
2683
3071
  operation.migrationState = updateMigrationState(operation, startupReport);
2684
3072
  if (!(!stopError && ["not_started", "unchanged"].includes(operation.migrationState))) {
2685
- operation.recoveryAction = stopError ? `Keep the new 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.";
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.";
2686
3074
  await save("recovery_required");
2687
3075
  throw new LocalUpdateError("UPDATE_RECOVERY_REQUIRED", "Treeport could not prove that rollback is safe. Treeport did not start the older daemon.", {
2688
3076
  operationId,
@@ -2707,7 +3095,10 @@ async function runLocalUpdate(options = {}) {
2707
3095
  const rollbackError = await (async () => {
2708
3096
  if (operation.previousTarget) await replaceSymlink(installation.currentLink, operation.previousTarget);
2709
3097
  await fs.rm(path.join(updateDirectory, "pending-startup.json"), { force: true });
2710
- 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
+ }
2711
3102
  })().then(() => null, (cause) => cause);
2712
3103
  operation.rollbackSucceeded = rollbackError === null;
2713
3104
  operation.recoveryAction = rollbackError ? "Inspect the active version and daemon log before starting Treeport." : "The previous Treeport version is active again.";
@@ -2743,4 +3134,4 @@ async function runLocalUpdate(options = {}) {
2743
3134
  }
2744
3135
  }
2745
3136
  //#endregion
2746
- export { tailscaleRemoteStatus as A, daemonUp as C, resolveLocalApiUrl as D, readDaemonLogs as E, assertLoopbackHost as M, parseDurationMs as N, resolvePackagePath as O, daemonStatus as S, enableTailscaleRemote as T, serviceStart as _, isCanonicalTreeportVersion as a, daemonDown as b, runLocalUpdate as c, serviceApply as d, serviceDisable as f, serviceRun as g, serviceInstalled as h, inspectLocalUpdateInstallation as i, treeportVersion as j, runDoctor as k, createUpdateStartupReporter as l, serviceEnable as m, compareTreeportVersions as n, readLocalUpdateProgress as o, serviceDoctorCheck as p, formatLocalUpdateError as r, resolveLatestTreeportRelease as s, LocalUpdateError as t, readServiceLogs as u, serviceStatus as v, disableTailscaleRemote as w, daemonHealth as x, serviceStop 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 };