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