@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.
- package/dist/node/cli/index.js +79 -55
- package/dist/node/server/index.js +40 -6
- package/dist/node/server/terminal-host-entry.js +18 -1
- package/dist/{update-UYS2lMdD.js → update-BYHlwpAq.js} +488 -97
- package/dist/web/assets/index-BtAAdn1A.js +84 -0
- package/dist/web/index.html +1 -1
- package/package.json +4 -3
- package/dist/web/assets/index-C5cx0N4G.js +0 -84
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
})
|
|
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 =
|
|
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.
|
|
1261
|
-
|
|
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
|
-
|
|
1724
|
+
await waitForService(next);
|
|
1477
1725
|
return {
|
|
1478
1726
|
status: await serviceStatus(),
|
|
1479
1727
|
changed: true,
|
|
1480
|
-
administratorCommand:
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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
|
|
1678
|
-
if (
|
|
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.
|
|
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
|
-
|
|
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
|
|
1719
|
-
|
|
1720
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2552
|
-
|
|
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 :
|
|
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
|
-
|
|
2561
|
-
|
|
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 (
|
|
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:
|
|
2655
|
-
healthy:
|
|
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
|
|
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)
|
|
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 {
|
|
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 };
|