dsh-home-hosted 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +48 -0
- package/cordis.patch.yml +6 -0
- package/docs/DESIGN.md +114 -0
- package/lib/client.js +9 -0
- package/lib/index.js +3694 -0
- package/lib/index.js.map +7 -0
- package/lib/types/boot/common.d.ts +40 -0
- package/lib/types/boot/escape.d.ts +59 -0
- package/lib/types/boot/fallback.d.ts +10 -0
- package/lib/types/boot/index.d.ts +12 -0
- package/lib/types/boot/ladder.d.ts +4 -0
- package/lib/types/boot/launchd.d.ts +8 -0
- package/lib/types/boot/systemd.d.ts +10 -0
- package/lib/types/boot/types.d.ts +94 -0
- package/lib/types/boot/windows.d.ts +16 -0
- package/lib/types/boot/xdg.d.ts +3 -0
- package/lib/types/config.d.ts +15 -0
- package/lib/types/home-hosted/config-file.d.ts +23 -0
- package/lib/types/home-hosted/dsh-entry.d.ts +22 -0
- package/lib/types/home-hosted/entries.d.ts +20 -0
- package/lib/types/home-hosted/launch.d.ts +25 -0
- package/lib/types/home-hosted/launcher.d.ts +38 -0
- package/lib/types/home-hosted/panel-control.d.ts +34 -0
- package/lib/types/home-hosted/panel.d.ts +44 -0
- package/lib/types/home-hosted/resolve.d.ts +34 -0
- package/lib/types/home-hosted/runtime.d.ts +20 -0
- package/lib/types/home-hosted/token.d.ts +20 -0
- package/lib/types/index.d.ts +15 -0
- package/lib/types/rpc.d.ts +11 -0
- package/lib/types/service.d.ts +125 -0
- package/lib/types/settings.d.ts +13 -0
- package/lib/types/shared/contracts.d.ts +245 -0
- package/lib/types/tools.d.ts +14 -0
- package/lib/types/util/exec.d.ts +29 -0
- package/lib/types/util/fsx.d.ts +9 -0
- package/lib/types/util/paths.d.ts +12 -0
- package/package.json +108 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,3694 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import path14 from "node:path";
|
|
3
|
+
|
|
4
|
+
// src/config.ts
|
|
5
|
+
import z from "@deepseek-ai/schemastery";
|
|
6
|
+
var Config = z.object({
|
|
7
|
+
stateDir: z.string().description("Plugin state directory; defaults to $DSH_HOME/dsh-home-hosted"),
|
|
8
|
+
homeHostedCommand: z.string().description("home-hosted executable to put in a generated autostart entry"),
|
|
9
|
+
defaultEntryId: z.string().default("dsh").description("Server entry id used for the running harness")
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// src/shared/contracts.ts
|
|
13
|
+
var RPC_PATH = "/home-hosted";
|
|
14
|
+
var RPC_VERSION = 1;
|
|
15
|
+
var AGENT_TOOL_NAMES = [
|
|
16
|
+
"status",
|
|
17
|
+
"servers_list",
|
|
18
|
+
"servers_start",
|
|
19
|
+
"servers_stop",
|
|
20
|
+
"servers_restart",
|
|
21
|
+
"servers_create",
|
|
22
|
+
"servers_update",
|
|
23
|
+
"servers_delete",
|
|
24
|
+
"autostart_install",
|
|
25
|
+
"autostart_uninstall"
|
|
26
|
+
];
|
|
27
|
+
var MUTATING_AGENT_TOOLS = [
|
|
28
|
+
"servers_start",
|
|
29
|
+
"servers_stop",
|
|
30
|
+
"servers_restart",
|
|
31
|
+
"servers_create",
|
|
32
|
+
"servers_update",
|
|
33
|
+
"servers_delete",
|
|
34
|
+
"autostart_install",
|
|
35
|
+
"autostart_uninstall"
|
|
36
|
+
];
|
|
37
|
+
var DEFAULT_SETTINGS = {
|
|
38
|
+
autostart: { enabled: false, mechanism: "auto" },
|
|
39
|
+
entries: [],
|
|
40
|
+
agentTools: { enabled: false, allow: ["status", "servers_list"] },
|
|
41
|
+
cli: { prefer: "pinned" }
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// src/service.ts
|
|
45
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
46
|
+
import fs12 from "node:fs";
|
|
47
|
+
import path13 from "node:path";
|
|
48
|
+
import process10 from "node:process";
|
|
49
|
+
import { fileURLToPath } from "node:url";
|
|
50
|
+
|
|
51
|
+
// src/boot/ladder.ts
|
|
52
|
+
import os2 from "node:os";
|
|
53
|
+
import process3 from "node:process";
|
|
54
|
+
|
|
55
|
+
// src/util/exec.ts
|
|
56
|
+
import { spawn } from "node:child_process";
|
|
57
|
+
async function run(command, args = [], options = {}) {
|
|
58
|
+
const maxBytes = options.maxBytes ?? 256 * 1024;
|
|
59
|
+
const timeoutMs = options.timeoutMs ?? 2e4;
|
|
60
|
+
return await new Promise((resolve) => {
|
|
61
|
+
let settled = false;
|
|
62
|
+
let stdout = "";
|
|
63
|
+
let stderr = "";
|
|
64
|
+
let timedOut = false;
|
|
65
|
+
const finish = (result) => {
|
|
66
|
+
if (settled)
|
|
67
|
+
return;
|
|
68
|
+
settled = true;
|
|
69
|
+
resolve({
|
|
70
|
+
command,
|
|
71
|
+
args,
|
|
72
|
+
code: result.code ?? null,
|
|
73
|
+
signal: result.signal ?? null,
|
|
74
|
+
stdout: result.stdout ?? stdout,
|
|
75
|
+
stderr: result.stderr ?? stderr,
|
|
76
|
+
timedOut,
|
|
77
|
+
error: result.error ?? null
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
let child;
|
|
81
|
+
try {
|
|
82
|
+
child = spawn(command, args, {
|
|
83
|
+
cwd: options.cwd,
|
|
84
|
+
env: { ...process.env, ...options.env },
|
|
85
|
+
shell: false,
|
|
86
|
+
windowsHide: true,
|
|
87
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
88
|
+
});
|
|
89
|
+
} catch (error) {
|
|
90
|
+
finish({ error: error instanceof Error ? error.message : String(error) });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
timedOut = true;
|
|
95
|
+
child.kill("SIGKILL");
|
|
96
|
+
}, timeoutMs);
|
|
97
|
+
child.stdout?.on("data", (chunk) => {
|
|
98
|
+
if (stdout.length < maxBytes)
|
|
99
|
+
stdout += chunk.toString("utf8").slice(0, maxBytes - stdout.length);
|
|
100
|
+
});
|
|
101
|
+
child.stderr?.on("data", (chunk) => {
|
|
102
|
+
if (stderr.length < maxBytes)
|
|
103
|
+
stderr += chunk.toString("utf8").slice(0, maxBytes - stderr.length);
|
|
104
|
+
});
|
|
105
|
+
child.on("error", (error) => {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
finish({ error: error.message });
|
|
108
|
+
});
|
|
109
|
+
child.on("close", (code, signal) => {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
finish({ code, signal });
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async function sudoAvailable() {
|
|
116
|
+
if (process.getuid?.() === 0)
|
|
117
|
+
return true;
|
|
118
|
+
const result = await run("sudo", ["-n", "true"], { timeoutMs: 5e3 });
|
|
119
|
+
return result.code === 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/boot/common.ts
|
|
123
|
+
import fs2 from "node:fs";
|
|
124
|
+
import os from "node:os";
|
|
125
|
+
import path2 from "node:path";
|
|
126
|
+
|
|
127
|
+
// src/util/fsx.ts
|
|
128
|
+
import fs from "node:fs";
|
|
129
|
+
import path from "node:path";
|
|
130
|
+
import process2 from "node:process";
|
|
131
|
+
function readText(file) {
|
|
132
|
+
try {
|
|
133
|
+
return fs.readFileSync(file, "utf8");
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function readJson(file) {
|
|
139
|
+
const text = readText(file);
|
|
140
|
+
if (text === null)
|
|
141
|
+
return null;
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(text);
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function ensureDir(dir, mode = 448) {
|
|
149
|
+
fs.mkdirSync(dir, { recursive: true, mode });
|
|
150
|
+
}
|
|
151
|
+
function writeFileAtomic(file, data, mode) {
|
|
152
|
+
ensureDir(path.dirname(file));
|
|
153
|
+
const temp = path.join(path.dirname(file), `.${path.basename(file)}.${process2.pid}.${Date.now()}.tmp`);
|
|
154
|
+
const fd = fs.openSync(temp, "w", mode ?? 420);
|
|
155
|
+
try {
|
|
156
|
+
fs.writeFileSync(fd, data, "utf8");
|
|
157
|
+
fs.fsyncSync(fd);
|
|
158
|
+
} finally {
|
|
159
|
+
fs.closeSync(fd);
|
|
160
|
+
}
|
|
161
|
+
if (mode !== void 0)
|
|
162
|
+
fs.chmodSync(temp, mode);
|
|
163
|
+
fs.renameSync(temp, file);
|
|
164
|
+
}
|
|
165
|
+
function writeJsonAtomic(file, value, mode) {
|
|
166
|
+
writeFileAtomic(file, `${JSON.stringify(value, null, 2)}
|
|
167
|
+
`, mode);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/boot/common.ts
|
|
171
|
+
function errorMessage(error) {
|
|
172
|
+
return error instanceof Error ? error.message : String(error);
|
|
173
|
+
}
|
|
174
|
+
function existsOf(ctx, file) {
|
|
175
|
+
return ctx.exists ? ctx.exists(file) : fs2.existsSync(file);
|
|
176
|
+
}
|
|
177
|
+
function posixJoin(...parts) {
|
|
178
|
+
return path2.posix.join(...parts);
|
|
179
|
+
}
|
|
180
|
+
function configHome(ctx) {
|
|
181
|
+
const xdg = ctx.env.XDG_CONFIG_HOME?.trim();
|
|
182
|
+
return xdg ? path2.posix.resolve(xdg) : posixJoin(ctx.home, ".config");
|
|
183
|
+
}
|
|
184
|
+
function localAppData(ctx) {
|
|
185
|
+
const value = ctx.env.LOCALAPPDATA?.trim();
|
|
186
|
+
return value ? value : path2.join(ctx.home, "AppData", "Local");
|
|
187
|
+
}
|
|
188
|
+
function tempDir(ctx) {
|
|
189
|
+
const value = ctx.env.TMPDIR?.trim() || ctx.env.TEMP?.trim() || ctx.env.TMP?.trim();
|
|
190
|
+
return value || os.tmpdir();
|
|
191
|
+
}
|
|
192
|
+
function currentUser(ctx) {
|
|
193
|
+
const value = ctx.env.USER?.trim() || ctx.env.LOGNAME?.trim() || ctx.env.USERNAME?.trim();
|
|
194
|
+
if (value)
|
|
195
|
+
return value;
|
|
196
|
+
try {
|
|
197
|
+
return os.userInfo().username;
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function uidOf(ctx) {
|
|
203
|
+
const configured = ctx.env.UID?.trim();
|
|
204
|
+
if (configured)
|
|
205
|
+
return configured;
|
|
206
|
+
const uid = process.getuid?.();
|
|
207
|
+
return uid === void 0 ? null : String(uid);
|
|
208
|
+
}
|
|
209
|
+
function inspectOwned(file, marker) {
|
|
210
|
+
if (!fs2.existsSync(file))
|
|
211
|
+
return { exists: false, owned: false, text: null, reason: null };
|
|
212
|
+
const text = readText(file);
|
|
213
|
+
if (text === null)
|
|
214
|
+
return { exists: true, owned: false, text: null, reason: `${file} exists but is unreadable, so it cannot be proven to be ours` };
|
|
215
|
+
if (!text.includes(marker))
|
|
216
|
+
return { exists: true, owned: false, text, reason: `${file} exists and does not carry this plugin's marker (${marker}); refusing to touch an artifact we did not write` };
|
|
217
|
+
return { exists: true, owned: true, text, reason: null };
|
|
218
|
+
}
|
|
219
|
+
function writeOwned(file, marker, content, mode = 420) {
|
|
220
|
+
const own = inspectOwned(file, marker);
|
|
221
|
+
if (own.exists && !own.owned)
|
|
222
|
+
return { changed: false, refusal: own.reason };
|
|
223
|
+
if (own.exists && own.text === content)
|
|
224
|
+
return { changed: false, refusal: null };
|
|
225
|
+
writeFileAtomic(file, content, mode);
|
|
226
|
+
return { changed: true, refusal: null };
|
|
227
|
+
}
|
|
228
|
+
function removeOwned(file, marker) {
|
|
229
|
+
const own = inspectOwned(file, marker);
|
|
230
|
+
if (!own.exists)
|
|
231
|
+
return { removed: false, refusal: null };
|
|
232
|
+
if (!own.owned)
|
|
233
|
+
return { removed: false, refusal: own.reason };
|
|
234
|
+
try {
|
|
235
|
+
fs2.rmSync(file, { force: true });
|
|
236
|
+
return { removed: true, refusal: null };
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return { removed: false, refusal: `could not delete ${file}: ${errorMessage(error)}` };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function failed(detail, extra = {}) {
|
|
242
|
+
return { ok: false, changed: false, detail, commands: [], needsPrivilege: false, ...extra };
|
|
243
|
+
}
|
|
244
|
+
function bootState(installed, enabled, failing) {
|
|
245
|
+
if (!installed)
|
|
246
|
+
return "not-installed";
|
|
247
|
+
if (!enabled)
|
|
248
|
+
return "installed-disabled";
|
|
249
|
+
return failing ? "enabled-failing" : "enabled-running";
|
|
250
|
+
}
|
|
251
|
+
function isInstalledState(state) {
|
|
252
|
+
return state === "installed-disabled" || state === "enabled-running" || state === "enabled-failing";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/boot/systemd.ts
|
|
256
|
+
import fs3 from "node:fs";
|
|
257
|
+
|
|
258
|
+
// src/boot/escape.ts
|
|
259
|
+
var CONTROL = /[\u0000-\u001F\u007F]/;
|
|
260
|
+
function assertNoControl(value, what) {
|
|
261
|
+
if (CONTROL.test(value))
|
|
262
|
+
throw new Error(`${what} must not contain control characters or newlines`);
|
|
263
|
+
return value;
|
|
264
|
+
}
|
|
265
|
+
function assertUnitName(name2) {
|
|
266
|
+
if (!/^[A-Za-z0-9._@-]+$/.test(name2))
|
|
267
|
+
throw new Error(`unit name ${JSON.stringify(name2)} must match ^[A-Za-z0-9._@-]+$`);
|
|
268
|
+
if (name2.startsWith("-") || name2.startsWith(".") || name2.includes(".."))
|
|
269
|
+
throw new Error(`unit name ${JSON.stringify(name2)} must not be option-like or traverse paths`);
|
|
270
|
+
return name2;
|
|
271
|
+
}
|
|
272
|
+
function assertMarker(marker) {
|
|
273
|
+
if (marker.trim() === "")
|
|
274
|
+
throw new Error("marker must not be empty");
|
|
275
|
+
return assertNoControl(marker, "marker");
|
|
276
|
+
}
|
|
277
|
+
function assertEnvKey(key) {
|
|
278
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
279
|
+
throw new Error(`environment key ${JSON.stringify(key)} is not a valid name`);
|
|
280
|
+
return key;
|
|
281
|
+
}
|
|
282
|
+
function isAbsolutePath(value) {
|
|
283
|
+
return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
|
|
284
|
+
}
|
|
285
|
+
function assertAbsolute(value, what) {
|
|
286
|
+
if (!isAbsolutePath(value))
|
|
287
|
+
throw new Error(`${what} must be absolute, got ${JSON.stringify(value)}`);
|
|
288
|
+
return assertNoControl(value, what);
|
|
289
|
+
}
|
|
290
|
+
function assertLabel(value) {
|
|
291
|
+
if (value.trim() === "")
|
|
292
|
+
throw new Error("label must not be empty");
|
|
293
|
+
return assertNoControl(value, "label");
|
|
294
|
+
}
|
|
295
|
+
function assertArg(value, what = "argument") {
|
|
296
|
+
return assertNoControl(value, what);
|
|
297
|
+
}
|
|
298
|
+
function replaceAll(value, pairs) {
|
|
299
|
+
let out = value;
|
|
300
|
+
for (const [from, to] of pairs)
|
|
301
|
+
out = out.split(from).join(to);
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
function shellQuote(value) {
|
|
305
|
+
if (value !== "" && /^[A-Za-z0-9._/:=@%+,-]+$/.test(value))
|
|
306
|
+
return value;
|
|
307
|
+
return `'${value.split("'").join("'\\''")}'`;
|
|
308
|
+
}
|
|
309
|
+
function shellCommand(program, args) {
|
|
310
|
+
return [program, ...args].map(shellQuote).join(" ");
|
|
311
|
+
}
|
|
312
|
+
function systemdExecWord(value) {
|
|
313
|
+
assertNoControl(value, "ExecStart word");
|
|
314
|
+
const escaped = replaceAll(value, [["\\", "\\\\"], ['"', '\\"'], ["$", "$$"], ["%", "%%"]]);
|
|
315
|
+
return escaped !== value || /[\s"';]/.test(value) ? `"${escaped}"` : value;
|
|
316
|
+
}
|
|
317
|
+
function systemdText(value) {
|
|
318
|
+
assertNoControl(value, "unit setting");
|
|
319
|
+
return replaceAll(value, [["\\", "\\\\"], ["%", "%%"]]);
|
|
320
|
+
}
|
|
321
|
+
function systemdPath(value, what = "path") {
|
|
322
|
+
assertAbsolute(value, what);
|
|
323
|
+
if (value.includes('"'))
|
|
324
|
+
throw new Error(`${what} must not contain a double quote: ${JSON.stringify(value)}`);
|
|
325
|
+
return value;
|
|
326
|
+
}
|
|
327
|
+
function systemdEnvLine(key, value) {
|
|
328
|
+
assertEnvKey(key);
|
|
329
|
+
assertNoControl(value, `environment value for ${key}`);
|
|
330
|
+
const escaped = replaceAll(value, [["\\", "\\\\"], ['"', '\\"'], ["%", "%%"]]);
|
|
331
|
+
return escaped !== value || /[\s"']/.test(value) ? `"${key}=${escaped}"` : `${key}=${value}`;
|
|
332
|
+
}
|
|
333
|
+
function xmlEscape(value) {
|
|
334
|
+
assertNoControl(value, "xml value");
|
|
335
|
+
return replaceAll(value, [["&", "&"], ["<", "<"], [">", ">"], ['"', """], ["'", "'"]]);
|
|
336
|
+
}
|
|
337
|
+
function xmlUnescape(value) {
|
|
338
|
+
return replaceAll(value, [["<", "<"], [">", ">"], [""", '"'], ["'", "'"], ["&", "&"]]);
|
|
339
|
+
}
|
|
340
|
+
function assertXmlCommentSafe(value) {
|
|
341
|
+
if (value.includes("--"))
|
|
342
|
+
throw new Error("marker must not contain `--` when it is written inside an XML comment");
|
|
343
|
+
return value;
|
|
344
|
+
}
|
|
345
|
+
var DESKTOP_RESERVED = /[\s"'\\><~|&;$*?#()`]/;
|
|
346
|
+
function desktopWord(value) {
|
|
347
|
+
assertNoControl(value, "desktop Exec word");
|
|
348
|
+
const escaped = replaceAll(value, [["\\", "\\\\"], ['"', '\\"'], ["`", "\\`"], ["$", "\\$"], ["%", "%%"]]);
|
|
349
|
+
return DESKTOP_RESERVED.test(value) || escaped !== value ? `"${escaped}"` : value;
|
|
350
|
+
}
|
|
351
|
+
function desktopExec(program, args) {
|
|
352
|
+
return [program, ...args].map(desktopWord).join(" ");
|
|
353
|
+
}
|
|
354
|
+
function windowsArg(value) {
|
|
355
|
+
assertNoControl(value, "windows argument");
|
|
356
|
+
if (value !== "" && !/[\s"]/.test(value))
|
|
357
|
+
return value;
|
|
358
|
+
let out = '"';
|
|
359
|
+
let backslashes = 0;
|
|
360
|
+
for (const char of value) {
|
|
361
|
+
if (char === "\\") {
|
|
362
|
+
backslashes += 1;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (char === '"') {
|
|
366
|
+
out += `${"\\".repeat(backslashes * 2 + 1)}"`;
|
|
367
|
+
backslashes = 0;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
out += `${"\\".repeat(backslashes)}${char}`;
|
|
371
|
+
backslashes = 0;
|
|
372
|
+
}
|
|
373
|
+
out += `${"\\".repeat(backslashes * 2)}"`;
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
function windowsCommandLine(program, args) {
|
|
377
|
+
return [program, ...args].map(windowsArg).join(" ");
|
|
378
|
+
}
|
|
379
|
+
function batchCommandLine(program, args) {
|
|
380
|
+
return batchEscape(windowsCommandLine(program, args));
|
|
381
|
+
}
|
|
382
|
+
function batchEscape(line) {
|
|
383
|
+
assertNoControl(line, "batch command line");
|
|
384
|
+
return replaceAll(line, [
|
|
385
|
+
["^", "^^"],
|
|
386
|
+
["&", "^&"],
|
|
387
|
+
["|", "^|"],
|
|
388
|
+
["<", "^<"],
|
|
389
|
+
[">", "^>"],
|
|
390
|
+
["(", "^("],
|
|
391
|
+
[")", "^)"],
|
|
392
|
+
["%", "%%"]
|
|
393
|
+
]);
|
|
394
|
+
}
|
|
395
|
+
function powershellLiteral(value) {
|
|
396
|
+
assertNoControl(value, "powershell value");
|
|
397
|
+
return `'${value.split("'").join("''")}'`;
|
|
398
|
+
}
|
|
399
|
+
function cmdQuote(value) {
|
|
400
|
+
if (value !== "" && !/[\s"&|<>^]/.test(value))
|
|
401
|
+
return value;
|
|
402
|
+
return `"${value.split('"').join('""')}"`;
|
|
403
|
+
}
|
|
404
|
+
function windowsDisplayCommand(program, args) {
|
|
405
|
+
return [program, ...args].map(cmdQuote).join(" ");
|
|
406
|
+
}
|
|
407
|
+
function assertRegistryValueName(name2) {
|
|
408
|
+
if (name2.trim() === "")
|
|
409
|
+
throw new Error("registry value name must not be empty");
|
|
410
|
+
return assertNoControl(name2, "registry value name");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// src/boot/systemd.ts
|
|
414
|
+
var ENABLED_UNIT_STATES = /* @__PURE__ */ new Set(["enabled", "enabled-runtime", "alias", "static", "indirect", "generated", "transient"]);
|
|
415
|
+
var FAILED_RESULTS = /* @__PURE__ */ new Set(["failed", "exit-code", "signal", "timeout", "core-dump", "watchdog", "start-limit-hit", "oom-kill"]);
|
|
416
|
+
function validate(spec) {
|
|
417
|
+
assertUnitName(spec.unitName);
|
|
418
|
+
assertMarker(spec.marker);
|
|
419
|
+
assertLabel(spec.label);
|
|
420
|
+
assertAbsolute(spec.command, "spec.command");
|
|
421
|
+
assertAbsolute(spec.cwd, "spec.cwd");
|
|
422
|
+
assertAbsolute(spec.logDir, "spec.logDir");
|
|
423
|
+
for (const arg of spec.args)
|
|
424
|
+
assertArg(arg);
|
|
425
|
+
for (const key of Object.keys(spec.env))
|
|
426
|
+
assertEnvKey(key);
|
|
427
|
+
}
|
|
428
|
+
function envLines(spec) {
|
|
429
|
+
return Object.entries(spec.env).map(([key, value]) => `Environment=${systemdEnvLine(key, value)}`);
|
|
430
|
+
}
|
|
431
|
+
var MANAGED = (spec) => `# Managed by ${spec.marker}`;
|
|
432
|
+
function systemdUserUnit(spec) {
|
|
433
|
+
validate(spec);
|
|
434
|
+
return `${[
|
|
435
|
+
MANAGED(spec),
|
|
436
|
+
"[Unit]",
|
|
437
|
+
`Description=${systemdText(spec.label)}`,
|
|
438
|
+
"After=network-online.target",
|
|
439
|
+
"",
|
|
440
|
+
"[Service]",
|
|
441
|
+
"Type=exec",
|
|
442
|
+
`ExecStart=${[spec.command, ...spec.args].map(systemdExecWord).join(" ")}`,
|
|
443
|
+
`WorkingDirectory=${systemdPath(spec.cwd, "spec.cwd")}`,
|
|
444
|
+
"Restart=always",
|
|
445
|
+
"RestartSec=5",
|
|
446
|
+
...envLines(spec),
|
|
447
|
+
"",
|
|
448
|
+
"[Install]",
|
|
449
|
+
"WantedBy=default.target",
|
|
450
|
+
""
|
|
451
|
+
].join("\n")}`;
|
|
452
|
+
}
|
|
453
|
+
function systemdSystemUnit(spec, user = null) {
|
|
454
|
+
validate(spec);
|
|
455
|
+
return `${[
|
|
456
|
+
MANAGED(spec),
|
|
457
|
+
"[Unit]",
|
|
458
|
+
`Description=${systemdText(spec.label)}`,
|
|
459
|
+
"After=network-online.target",
|
|
460
|
+
"StartLimitIntervalSec=60",
|
|
461
|
+
"StartLimitBurst=5",
|
|
462
|
+
"",
|
|
463
|
+
"[Service]",
|
|
464
|
+
...user ? [`User=${user}`] : [],
|
|
465
|
+
"Type=exec",
|
|
466
|
+
`ExecStart=${[spec.command, ...spec.args].map(systemdExecWord).join(" ")}`,
|
|
467
|
+
`WorkingDirectory=${systemdPath(spec.cwd, "spec.cwd")}`,
|
|
468
|
+
"Restart=always",
|
|
469
|
+
"RestartSec=5",
|
|
470
|
+
...envLines(spec),
|
|
471
|
+
"",
|
|
472
|
+
"[Install]",
|
|
473
|
+
"WantedBy=multi-user.target",
|
|
474
|
+
""
|
|
475
|
+
].join("\n")}`;
|
|
476
|
+
}
|
|
477
|
+
function codeOf(result) {
|
|
478
|
+
return result.error ? null : result.code;
|
|
479
|
+
}
|
|
480
|
+
function parseKeyValues(stdout) {
|
|
481
|
+
const out = {};
|
|
482
|
+
for (const line of stdout.split("\n")) {
|
|
483
|
+
const eq = line.indexOf("=");
|
|
484
|
+
if (eq > 0)
|
|
485
|
+
out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
|
486
|
+
}
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
async function readUnit(ctx, unit, scope) {
|
|
490
|
+
const scopeArgs = scope === "user" ? ["--user"] : [];
|
|
491
|
+
const show = await ctx.run("systemctl", [...scopeArgs, "show", "-p", "UnitFileState", "-p", "ActiveState", "-p", "Result", "-p", "NRestarts", unit]);
|
|
492
|
+
const props = parseKeyValues(show.stdout);
|
|
493
|
+
const isEnabled = await ctx.run("systemctl", [...scopeArgs, "is-enabled", unit]);
|
|
494
|
+
const enabledCode = codeOf(isEnabled);
|
|
495
|
+
const unitFileState = props.UnitFileState ?? "";
|
|
496
|
+
const knownState = unitFileState !== "" && unitFileState !== "not-found";
|
|
497
|
+
const enabled = enabledCode === 0 || ENABLED_UNIT_STATES.has(unitFileState);
|
|
498
|
+
const installed = enabled || knownState;
|
|
499
|
+
const result = props.Result ?? "";
|
|
500
|
+
return {
|
|
501
|
+
unitFileState,
|
|
502
|
+
activeState: props.ActiveState ?? "",
|
|
503
|
+
result,
|
|
504
|
+
nRestarts: props.NRestarts ?? "",
|
|
505
|
+
enabled,
|
|
506
|
+
installed,
|
|
507
|
+
reachable: show.error === void 0 || show.error === null,
|
|
508
|
+
detail: `UnitFileState=${unitFileState || "(none)"} ActiveState=${props.ActiveState || "(none)"} Result=${result || "(none)"} NRestarts=${props.NRestarts ?? "0"}`
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
async function userScopeReachable(ctx) {
|
|
512
|
+
const probe = await ctx.run("systemctl", ["--user", "is-system-running"]);
|
|
513
|
+
if (probe.error)
|
|
514
|
+
return { reachable: false, reason: `systemctl --user is not available here (${probe.error})` };
|
|
515
|
+
if (`${probe.stdout}
|
|
516
|
+
${probe.stderr}`.includes("Failed to connect to user scope bus"))
|
|
517
|
+
return { reachable: false, reason: "systemctl --user cannot reach a user manager: Failed to connect to user scope bus" };
|
|
518
|
+
return { reachable: true, reason: "" };
|
|
519
|
+
}
|
|
520
|
+
async function lingerEnabled(ctx) {
|
|
521
|
+
const user = currentUser(ctx);
|
|
522
|
+
if (!user)
|
|
523
|
+
return false;
|
|
524
|
+
const probe = await ctx.run("loginctl", ["show-user", user, "--property=Linger"]);
|
|
525
|
+
if (codeOf(probe) !== 0)
|
|
526
|
+
return false;
|
|
527
|
+
return /(^|\n)Linger=yes(\n|$)/.test(probe.stdout);
|
|
528
|
+
}
|
|
529
|
+
function problem(run2, command) {
|
|
530
|
+
const code = codeOf(run2);
|
|
531
|
+
if (code === 0)
|
|
532
|
+
return null;
|
|
533
|
+
if (code === null)
|
|
534
|
+
return run2.error ? `${command}: ${run2.error}` : `${command}: no exit code`;
|
|
535
|
+
return `${command} exited ${code}: ${(run2.stderr || run2.stdout).trim() || "(no output)"}`;
|
|
536
|
+
}
|
|
537
|
+
function createSystemdUserProvider(ctx) {
|
|
538
|
+
const mechanism = "systemd-user";
|
|
539
|
+
const unitOf = (spec) => `${assertUnitName(spec.unitName)}.service`;
|
|
540
|
+
const pathOf = (spec) => posixJoin(configHome(ctx), "systemd", "user", unitOf(spec));
|
|
541
|
+
return {
|
|
542
|
+
mechanism,
|
|
543
|
+
async detect() {
|
|
544
|
+
if (ctx.platform !== "linux")
|
|
545
|
+
return { mechanism, available: false, bootCapable: false, privileged: false, reason: "systemd is Linux-only" };
|
|
546
|
+
const bus = await userScopeReachable(ctx);
|
|
547
|
+
if (!bus.reachable)
|
|
548
|
+
return { mechanism, available: false, bootCapable: false, privileged: false, reason: bus.reason };
|
|
549
|
+
const linger = await lingerEnabled(ctx);
|
|
550
|
+
const privileged = linger || await ctx.sudo();
|
|
551
|
+
return {
|
|
552
|
+
mechanism,
|
|
553
|
+
available: true,
|
|
554
|
+
bootCapable: linger,
|
|
555
|
+
privileged,
|
|
556
|
+
reason: linger ? "a systemd user manager is reachable and linger is on, so the unit starts at boot" : "a systemd user manager is reachable, but linger is off: the unit starts at login until `loginctl enable-linger` succeeds"
|
|
557
|
+
};
|
|
558
|
+
},
|
|
559
|
+
async status(spec) {
|
|
560
|
+
try {
|
|
561
|
+
const unit = unitOf(spec);
|
|
562
|
+
const file = pathOf(spec);
|
|
563
|
+
const own = inspectOwned(file, assertMarker(spec.marker));
|
|
564
|
+
if (own.exists && !own.owned)
|
|
565
|
+
return { state: "not-installed", unitPath: file, detail: own.reason ?? "foreign file", commands: [] };
|
|
566
|
+
const probe = await readUnit(ctx, unit, "user");
|
|
567
|
+
const installed = own.owned || probe.installed;
|
|
568
|
+
const state = bootState(installed, probe.enabled, FAILED_RESULTS.has(probe.result));
|
|
569
|
+
const user = currentUser(ctx);
|
|
570
|
+
const linger = await lingerEnabled(ctx);
|
|
571
|
+
const commands = [];
|
|
572
|
+
if (!linger && user)
|
|
573
|
+
commands.push(shellCommand("sudo", ["loginctl", "enable-linger", user]));
|
|
574
|
+
const detail = `${probe.detail}; ${linger ? "linger is on (boot-capable)" : "linger is off (login-scoped)"}`;
|
|
575
|
+
return { state, unitPath: file, detail, commands };
|
|
576
|
+
} catch (error) {
|
|
577
|
+
return { state: "not-installed", unitPath: null, detail: errorMessage(error), commands: [] };
|
|
578
|
+
}
|
|
579
|
+
},
|
|
580
|
+
async install(spec) {
|
|
581
|
+
try {
|
|
582
|
+
validate(spec);
|
|
583
|
+
const unit = unitOf(spec);
|
|
584
|
+
const file = pathOf(spec);
|
|
585
|
+
const write = writeOwned(file, spec.marker, systemdUserUnit(spec), 420);
|
|
586
|
+
if (write.refusal)
|
|
587
|
+
return failed(write.refusal);
|
|
588
|
+
const before = await readUnit(ctx, unit, "user");
|
|
589
|
+
let changed = write.changed;
|
|
590
|
+
if (write.changed || !before.enabled) {
|
|
591
|
+
const reloadCommand = shellCommand("systemctl", ["--user", "daemon-reload"]);
|
|
592
|
+
const reload = await ctx.run("systemctl", ["--user", "daemon-reload"]);
|
|
593
|
+
const reloadProblem = problem(reload, reloadCommand);
|
|
594
|
+
if (reloadProblem)
|
|
595
|
+
return failed(reloadProblem, { changed, commands: [reloadCommand] });
|
|
596
|
+
const enableCommand = shellCommand("systemctl", ["--user", "enable", "--now", unit]);
|
|
597
|
+
const enable = await ctx.run("systemctl", ["--user", "enable", "--now", unit]);
|
|
598
|
+
const enableProblem = problem(enable, enableCommand);
|
|
599
|
+
if (enableProblem)
|
|
600
|
+
return failed(enableProblem, { changed: true, commands: [enableCommand] });
|
|
601
|
+
changed = true;
|
|
602
|
+
}
|
|
603
|
+
const after = await readUnit(ctx, unit, "user");
|
|
604
|
+
if (!after.enabled)
|
|
605
|
+
return failed(`unit ${unit} is not enabled after install (${after.detail})`, { changed });
|
|
606
|
+
let linger = await lingerEnabled(ctx);
|
|
607
|
+
const user = currentUser(ctx);
|
|
608
|
+
const commands = [];
|
|
609
|
+
if (!linger && user) {
|
|
610
|
+
const res = await ctx.run("loginctl", ["enable-linger", user]);
|
|
611
|
+
if (codeOf(res) === 0) {
|
|
612
|
+
linger = true;
|
|
613
|
+
changed = true;
|
|
614
|
+
} else {
|
|
615
|
+
commands.push(shellCommand("sudo", ["loginctl", "enable-linger", user]));
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
const detail = linger ? `${file} installed and enabled; linger is on, so ${unit} starts at boot` : `${file} installed and enabled, but linger is off: ${unit} starts at login only. ${commands.length ? `Run \`${commands[0]}\` to make it boot-capable.` : "No target user could be determined to enable linger for."}`;
|
|
619
|
+
return { ok: true, changed, detail, commands, needsPrivilege: !linger };
|
|
620
|
+
} catch (error) {
|
|
621
|
+
return failed(errorMessage(error));
|
|
622
|
+
}
|
|
623
|
+
},
|
|
624
|
+
async uninstall(spec) {
|
|
625
|
+
try {
|
|
626
|
+
validate(spec);
|
|
627
|
+
const unit = unitOf(spec);
|
|
628
|
+
const file = pathOf(spec);
|
|
629
|
+
const own = inspectOwned(file, spec.marker);
|
|
630
|
+
if (own.exists && !own.owned)
|
|
631
|
+
return failed(own.reason ?? "foreign file");
|
|
632
|
+
const before = await readUnit(ctx, unit, "user");
|
|
633
|
+
if (!before.reachable && !own.exists) {
|
|
634
|
+
return { ok: true, changed: false, detail: `no unit file at ${file} and systemctl is unavailable; nothing to remove`, commands: [], needsPrivilege: false };
|
|
635
|
+
}
|
|
636
|
+
if (!own.owned && !own.exists && before.installed)
|
|
637
|
+
return failed(`a unit named ${unit} exists but no unit file of ours is at ${file}; refusing to disable a unit this plugin did not install`);
|
|
638
|
+
if (own.owned && before.reachable) {
|
|
639
|
+
const disableCommand = shellCommand("systemctl", ["--user", "disable", "--now", unit]);
|
|
640
|
+
const disable = await ctx.run("systemctl", ["--user", "disable", "--now", unit]);
|
|
641
|
+
const disableCode = codeOf(disable);
|
|
642
|
+
if (disableCode !== 0 && disableCode !== 1 && disableCode !== 4)
|
|
643
|
+
return failed(problem(disable, disableCommand) ?? "disable failed", { commands: [disableCommand] });
|
|
644
|
+
}
|
|
645
|
+
const remove = removeOwned(file, spec.marker);
|
|
646
|
+
if (remove.refusal)
|
|
647
|
+
return failed(remove.refusal);
|
|
648
|
+
let changed = remove.removed;
|
|
649
|
+
if (before.installed || remove.removed) {
|
|
650
|
+
await ctx.run("systemctl", ["--user", "daemon-reload"]);
|
|
651
|
+
changed = true;
|
|
652
|
+
}
|
|
653
|
+
const after = await readUnit(ctx, unit, "user");
|
|
654
|
+
if (after.installed)
|
|
655
|
+
return failed(`unit ${unit} is still known to systemd after removal (${after.detail})`, { changed });
|
|
656
|
+
return {
|
|
657
|
+
ok: true,
|
|
658
|
+
changed,
|
|
659
|
+
detail: remove.removed ? `disabled and removed ${file}` : `${file} was already gone`,
|
|
660
|
+
commands: [],
|
|
661
|
+
needsPrivilege: false
|
|
662
|
+
};
|
|
663
|
+
} catch (error) {
|
|
664
|
+
return failed(errorMessage(error));
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
function systemUserName(ctx) {
|
|
670
|
+
const user = currentUser(ctx);
|
|
671
|
+
return user && /^[A-Za-z_][A-Za-z0-9._-]*$/.test(user) ? user : null;
|
|
672
|
+
}
|
|
673
|
+
function stageUnit(ctx, unit, content) {
|
|
674
|
+
const staged = posixJoin(tempDir(ctx), `home-hosted-${unit}`);
|
|
675
|
+
fs3.writeFileSync(staged, content, { mode: 420 });
|
|
676
|
+
return staged;
|
|
677
|
+
}
|
|
678
|
+
function createSystemdSystemProvider(ctx) {
|
|
679
|
+
const mechanism = "systemd-system";
|
|
680
|
+
const unitOf = (spec) => `${assertUnitName(spec.unitName)}.service`;
|
|
681
|
+
const pathOf = (spec) => posixJoin("/etc", "systemd", "system", unitOf(spec));
|
|
682
|
+
const contentOf = (spec) => systemdSystemUnit(spec, ctx.isRoot ? systemUserName(ctx) : null);
|
|
683
|
+
const installCommands = (staged, file, unit) => [
|
|
684
|
+
shellCommand("sudo", ["install", "-m", "0644", staged, file]),
|
|
685
|
+
shellCommand("sudo", ["systemctl", "daemon-reload"]),
|
|
686
|
+
shellCommand("sudo", ["systemctl", "enable", "--now", unit])
|
|
687
|
+
];
|
|
688
|
+
return {
|
|
689
|
+
mechanism,
|
|
690
|
+
async detect() {
|
|
691
|
+
if (ctx.platform !== "linux")
|
|
692
|
+
return { mechanism, available: false, bootCapable: false, privileged: false, reason: "systemd is Linux-only" };
|
|
693
|
+
if (!existsOf(ctx, "/run/systemd/system"))
|
|
694
|
+
return { mechanism, available: false, bootCapable: false, privileged: false, reason: "systemd is not the init system here (no /run/systemd/system)" };
|
|
695
|
+
const privileged = ctx.isRoot || await ctx.sudo();
|
|
696
|
+
return {
|
|
697
|
+
mechanism,
|
|
698
|
+
available: privileged,
|
|
699
|
+
bootCapable: privileged,
|
|
700
|
+
privileged,
|
|
701
|
+
reason: privileged ? "systemd is PID 1 and this process can use root, so a system unit starts at boot" : "systemd is PID 1, but installing a system unit needs root or passwordless sudo"
|
|
702
|
+
};
|
|
703
|
+
},
|
|
704
|
+
async status(spec) {
|
|
705
|
+
try {
|
|
706
|
+
const unit = unitOf(spec);
|
|
707
|
+
const file = pathOf(spec);
|
|
708
|
+
const own = inspectOwned(file, assertMarker(spec.marker));
|
|
709
|
+
if (own.exists && !own.owned)
|
|
710
|
+
return { state: "not-installed", unitPath: file, detail: own.reason ?? "foreign file", commands: [] };
|
|
711
|
+
const probe = await readUnit(ctx, unit, "system");
|
|
712
|
+
const installed = own.owned || probe.installed;
|
|
713
|
+
const state = bootState(installed, probe.enabled, FAILED_RESULTS.has(probe.result));
|
|
714
|
+
const privileged = ctx.isRoot || await ctx.sudo();
|
|
715
|
+
const commands = [];
|
|
716
|
+
if (installed && !probe.enabled && !privileged)
|
|
717
|
+
commands.push(shellCommand("sudo", ["systemctl", "enable", "--now", unit]));
|
|
718
|
+
return {
|
|
719
|
+
state,
|
|
720
|
+
unitPath: file,
|
|
721
|
+
detail: `${probe.detail}${privileged ? "" : "; needs root to change"}`,
|
|
722
|
+
commands
|
|
723
|
+
};
|
|
724
|
+
} catch (error) {
|
|
725
|
+
return { state: "not-installed", unitPath: null, detail: errorMessage(error), commands: [] };
|
|
726
|
+
}
|
|
727
|
+
},
|
|
728
|
+
async install(spec) {
|
|
729
|
+
try {
|
|
730
|
+
validate(spec);
|
|
731
|
+
const unit = unitOf(spec);
|
|
732
|
+
const file = pathOf(spec);
|
|
733
|
+
const content = contentOf(spec);
|
|
734
|
+
const own = inspectOwned(file, spec.marker);
|
|
735
|
+
if (own.exists && !own.owned)
|
|
736
|
+
return failed(own.reason ?? "foreign file");
|
|
737
|
+
const before = await readUnit(ctx, unit, "system");
|
|
738
|
+
const changed = !own.exists || own.text !== content;
|
|
739
|
+
if (!ctx.isRoot && !await ctx.sudo()) {
|
|
740
|
+
const staged = stageUnit(ctx, unit, content);
|
|
741
|
+
return failed(`installing a system unit needs root; the unit is staged at ${staged}`, {
|
|
742
|
+
changed: false,
|
|
743
|
+
needsPrivilege: true,
|
|
744
|
+
commands: installCommands(staged, file, unit)
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
if (ctx.isRoot) {
|
|
748
|
+
const write = writeOwned(file, spec.marker, content, 420);
|
|
749
|
+
if (write.refusal)
|
|
750
|
+
return failed(write.refusal);
|
|
751
|
+
} else {
|
|
752
|
+
const staged = stageUnit(ctx, unit, content);
|
|
753
|
+
const installCommand = shellCommand("sudo", ["install", "-m", "0644", staged, file]);
|
|
754
|
+
const install = await ctx.run("sudo", ["-n", "install", "-m", "0644", staged, file]);
|
|
755
|
+
const installProblem = problem(install, installCommand);
|
|
756
|
+
if (installProblem)
|
|
757
|
+
return failed(installProblem, { needsPrivilege: true, commands: installCommands(staged, file, unit) });
|
|
758
|
+
fs3.rmSync(staged, { force: true });
|
|
759
|
+
}
|
|
760
|
+
if (changed || !before.enabled) {
|
|
761
|
+
const sudoPrefix = ctx.isRoot ? [] : ["-n"];
|
|
762
|
+
const reloadCommand = shellCommand("sudo", ["systemctl", "daemon-reload"]);
|
|
763
|
+
const reload = await ctx.run("sudo", [...sudoPrefix, "systemctl", "daemon-reload"]);
|
|
764
|
+
const reloadProblem = problem(reload, reloadCommand);
|
|
765
|
+
if (reloadProblem)
|
|
766
|
+
return failed(reloadProblem, { changed, needsPrivilege: !ctx.isRoot, commands: [reloadCommand] });
|
|
767
|
+
const enableCommand = shellCommand("sudo", ["systemctl", "enable", "--now", unit]);
|
|
768
|
+
const enable = await ctx.run("sudo", [...sudoPrefix, "systemctl", "enable", "--now", unit]);
|
|
769
|
+
const enableProblem = problem(enable, enableCommand);
|
|
770
|
+
if (enableProblem)
|
|
771
|
+
return failed(enableProblem, { changed: true, needsPrivilege: !ctx.isRoot, commands: [enableCommand] });
|
|
772
|
+
}
|
|
773
|
+
const after = await readUnit(ctx, unit, "system");
|
|
774
|
+
if (!after.enabled)
|
|
775
|
+
return failed(`unit ${unit} is not enabled after install (${after.detail})`, { changed });
|
|
776
|
+
return {
|
|
777
|
+
ok: true,
|
|
778
|
+
changed: changed || !before.enabled,
|
|
779
|
+
detail: `${file} installed and enabled as a system unit (${after.detail})`,
|
|
780
|
+
commands: [],
|
|
781
|
+
needsPrivilege: !ctx.isRoot
|
|
782
|
+
};
|
|
783
|
+
} catch (error) {
|
|
784
|
+
return failed(errorMessage(error));
|
|
785
|
+
}
|
|
786
|
+
},
|
|
787
|
+
async uninstall(spec) {
|
|
788
|
+
try {
|
|
789
|
+
const unit = unitOf(spec);
|
|
790
|
+
const file = pathOf(spec);
|
|
791
|
+
const own = inspectOwned(file, spec.marker);
|
|
792
|
+
if (own.exists && !own.owned)
|
|
793
|
+
return failed(own.reason ?? "foreign file");
|
|
794
|
+
const privileged = ctx.isRoot || await ctx.sudo();
|
|
795
|
+
if (!privileged) {
|
|
796
|
+
if (!own.exists)
|
|
797
|
+
return { ok: true, changed: false, detail: `no unit file at ${file} and no privilege to change systemd; nothing to remove`, commands: [], needsPrivilege: false };
|
|
798
|
+
return failed("removing a system unit needs root", {
|
|
799
|
+
needsPrivilege: true,
|
|
800
|
+
commands: [
|
|
801
|
+
shellCommand("sudo", ["systemctl", "disable", "--now", unit]),
|
|
802
|
+
shellCommand("sudo", ["rm", "-f", file]),
|
|
803
|
+
shellCommand("sudo", ["systemctl", "daemon-reload"])
|
|
804
|
+
]
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
const prefix = ctx.isRoot ? [] : ["-n"];
|
|
808
|
+
if (!own.exists && !own.owned)
|
|
809
|
+
return failed(`no unit file of ours at ${file}; refusing to disable a unit this plugin did not install`);
|
|
810
|
+
const disableCommand = shellCommand("sudo", ["systemctl", "disable", "--now", unit]);
|
|
811
|
+
const disable = await ctx.run("sudo", [...prefix, "systemctl", "disable", "--now", unit]);
|
|
812
|
+
const disableCode = codeOf(disable);
|
|
813
|
+
if (disableCode !== 0 && disableCode !== 1 && disableCode !== 4)
|
|
814
|
+
return failed(problem(disable, disableCommand) ?? "disable failed", { needsPrivilege: !ctx.isRoot, commands: [disableCommand] });
|
|
815
|
+
if (own.exists) {
|
|
816
|
+
if (ctx.isRoot) {
|
|
817
|
+
const remove = removeOwned(file, spec.marker);
|
|
818
|
+
if (remove.refusal)
|
|
819
|
+
return failed(remove.refusal);
|
|
820
|
+
} else {
|
|
821
|
+
if (!own.owned)
|
|
822
|
+
return failed(own.reason ?? "foreign file");
|
|
823
|
+
const rmCommand = shellCommand("sudo", ["rm", "-f", file]);
|
|
824
|
+
const rm = await ctx.run("sudo", ["-n", "rm", "-f", file]);
|
|
825
|
+
const rmProblem = problem(rm, rmCommand);
|
|
826
|
+
if (rmProblem)
|
|
827
|
+
return failed(rmProblem, { needsPrivilege: true, commands: [rmCommand] });
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
await ctx.run("sudo", [...prefix, "systemctl", "daemon-reload"]);
|
|
831
|
+
const after = await readUnit(ctx, unit, "system");
|
|
832
|
+
if (after.installed)
|
|
833
|
+
return failed(`unit ${unit} is still known to systemd after removal (${after.detail})`, { changed: true, needsPrivilege: !ctx.isRoot });
|
|
834
|
+
return {
|
|
835
|
+
ok: true,
|
|
836
|
+
changed: own.exists || disableCode === 0,
|
|
837
|
+
detail: own.exists ? `disabled and removed ${file}` : `${file} was already gone`,
|
|
838
|
+
commands: [],
|
|
839
|
+
needsPrivilege: !ctx.isRoot
|
|
840
|
+
};
|
|
841
|
+
} catch (error) {
|
|
842
|
+
return failed(errorMessage(error));
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// src/boot/launchd.ts
|
|
849
|
+
import fs4 from "node:fs";
|
|
850
|
+
import path3 from "node:path";
|
|
851
|
+
var LAUNCHD_LABEL_PREFIX = "dev.home-hosted.";
|
|
852
|
+
var LAUNCHCTL_ALREADY_LOADED = 5;
|
|
853
|
+
var LAUNCHCTL_NOT_LOADED = 113;
|
|
854
|
+
var LAUNCHCTL_NO_SUCH_PROCESS = 3;
|
|
855
|
+
function validate2(spec) {
|
|
856
|
+
assertUnitName(spec.unitName);
|
|
857
|
+
assertMarker(spec.marker);
|
|
858
|
+
assertLabel(spec.label);
|
|
859
|
+
assertAbsolute(spec.command, "spec.command");
|
|
860
|
+
assertAbsolute(spec.cwd, "spec.cwd");
|
|
861
|
+
assertAbsolute(spec.logDir, "spec.logDir");
|
|
862
|
+
for (const arg of spec.args)
|
|
863
|
+
assertArg(arg);
|
|
864
|
+
for (const key of Object.keys(spec.env))
|
|
865
|
+
assertEnvKey(key);
|
|
866
|
+
}
|
|
867
|
+
function launchdLabel(spec) {
|
|
868
|
+
return `${LAUNCHD_LABEL_PREFIX}${assertUnitName(spec.unitName)}`;
|
|
869
|
+
}
|
|
870
|
+
function launchdPlist(spec) {
|
|
871
|
+
validate2(spec);
|
|
872
|
+
const label = launchdLabel(spec);
|
|
873
|
+
const out = path3.posix.join(spec.logDir, `${label}.out.log`);
|
|
874
|
+
const err = path3.posix.join(spec.logDir, `${label}.err.log`);
|
|
875
|
+
const envKeys = Object.keys(spec.env);
|
|
876
|
+
const lines = [
|
|
877
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
878
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
879
|
+
'<plist version="1.0">',
|
|
880
|
+
"<dict>",
|
|
881
|
+
" <key>Label</key>",
|
|
882
|
+
` <string>${xmlEscape(label)}</string>`,
|
|
883
|
+
" <key>ProgramArguments</key>",
|
|
884
|
+
" <array>",
|
|
885
|
+
...[spec.command, ...spec.args].map((arg) => ` <string>${xmlEscape(arg)}</string>`),
|
|
886
|
+
" </array>",
|
|
887
|
+
" <key>RunAtLoad</key>",
|
|
888
|
+
" <true/>",
|
|
889
|
+
" <key>KeepAlive</key>",
|
|
890
|
+
" <dict>",
|
|
891
|
+
" <key>SuccessfulExit</key>",
|
|
892
|
+
" <false/>",
|
|
893
|
+
" </dict>",
|
|
894
|
+
" <key>ThrottleInterval</key>",
|
|
895
|
+
" <integer>10</integer>",
|
|
896
|
+
" <key>WorkingDirectory</key>",
|
|
897
|
+
` <string>${xmlEscape(spec.cwd)}</string>`,
|
|
898
|
+
...envKeys.length ? [
|
|
899
|
+
" <key>EnvironmentVariables</key>",
|
|
900
|
+
" <dict>",
|
|
901
|
+
...envKeys.flatMap((key) => [` <key>${xmlEscape(key)}</key>`, ` <string>${xmlEscape(spec.env[key] ?? "")}</string>`]),
|
|
902
|
+
" </dict>"
|
|
903
|
+
] : [],
|
|
904
|
+
" <key>StandardOutPath</key>",
|
|
905
|
+
` <string>${xmlEscape(out)}</string>`,
|
|
906
|
+
" <key>StandardErrorPath</key>",
|
|
907
|
+
` <string>${xmlEscape(err)}</string>`,
|
|
908
|
+
` <!-- Managed by ${assertXmlCommentSafe(spec.marker)} -->`,
|
|
909
|
+
"</dict>",
|
|
910
|
+
"</plist>",
|
|
911
|
+
""
|
|
912
|
+
];
|
|
913
|
+
return lines.join("\n");
|
|
914
|
+
}
|
|
915
|
+
function codeOf2(result) {
|
|
916
|
+
return result.error ? null : result.code;
|
|
917
|
+
}
|
|
918
|
+
function problem2(run2, command) {
|
|
919
|
+
const code = codeOf2(run2);
|
|
920
|
+
if (code === 0)
|
|
921
|
+
return null;
|
|
922
|
+
if (code === null)
|
|
923
|
+
return run2.error ? `${command}: ${run2.error}` : `${command}: no exit code`;
|
|
924
|
+
return `${command} exited ${code}: ${(run2.stderr || run2.stdout).trim() || "(no output)"}`;
|
|
925
|
+
}
|
|
926
|
+
function ensureDirQuiet(dir) {
|
|
927
|
+
try {
|
|
928
|
+
fs4.mkdirSync(dir, { recursive: true });
|
|
929
|
+
} catch {
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
function createLaunchdProvider(ctx, mode) {
|
|
933
|
+
const mechanism = mode === "agent" ? "launchd-agent" : "launchd-daemon";
|
|
934
|
+
const pathOf = (spec) => mode === "agent" ? posixJoin(ctx.home, "Library", "LaunchAgents", `${launchdLabel(spec)}.plist`) : posixJoin("/Library", "LaunchDaemons", `${launchdLabel(spec)}.plist`);
|
|
935
|
+
const domainOf = async () => {
|
|
936
|
+
if (mode === "daemon")
|
|
937
|
+
return "system";
|
|
938
|
+
const uid = uidOf(ctx);
|
|
939
|
+
if (!uid)
|
|
940
|
+
return null;
|
|
941
|
+
const gui = `gui/${uid}`;
|
|
942
|
+
if (codeOf2(await ctx.run("launchctl", ["print", gui])) === 0)
|
|
943
|
+
return gui;
|
|
944
|
+
const user = `user/${uid}`;
|
|
945
|
+
if (codeOf2(await ctx.run("launchctl", ["print", user])) === 0)
|
|
946
|
+
return user;
|
|
947
|
+
return null;
|
|
948
|
+
};
|
|
949
|
+
const privileged = async () => mode === "agent" ? true : ctx.isRoot || await ctx.sudo();
|
|
950
|
+
const runPrivileged = async (program, args) => mode === "agent" || ctx.isRoot ? await ctx.run(program, args) : await ctx.run("sudo", ["-n", program, ...args]);
|
|
951
|
+
const commands = (file, label) => mode === "agent" ? [] : [
|
|
952
|
+
shellCommand("sudo", ["launchctl", "bootstrap", "system", file]),
|
|
953
|
+
shellCommand("sudo", ["launchctl", "enable", `system/${label}`])
|
|
954
|
+
];
|
|
955
|
+
return {
|
|
956
|
+
mechanism,
|
|
957
|
+
async detect() {
|
|
958
|
+
if (ctx.platform !== "darwin")
|
|
959
|
+
return { mechanism, available: false, bootCapable: false, privileged: false, reason: "launchd is macOS-only" };
|
|
960
|
+
if (mode === "agent") {
|
|
961
|
+
const domain = await domainOf();
|
|
962
|
+
return {
|
|
963
|
+
mechanism,
|
|
964
|
+
available: domain !== null,
|
|
965
|
+
bootCapable: false,
|
|
966
|
+
privileged: true,
|
|
967
|
+
reason: domain === null ? `no launchd gui/$UID or user/$UID domain is reachable for uid ${uidOf(ctx) ?? "?"}` : `launchd ${domain} domain is reachable; a LaunchAgent loads at login, not at boot`
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
const canElevate = ctx.isRoot || await ctx.sudo();
|
|
971
|
+
return {
|
|
972
|
+
mechanism,
|
|
973
|
+
available: canElevate,
|
|
974
|
+
bootCapable: canElevate,
|
|
975
|
+
privileged: canElevate,
|
|
976
|
+
reason: canElevate ? "this process can write /Library/LaunchDaemons, so a LaunchDaemon starts at boot" : "a LaunchDaemon needs root or passwordless sudo"
|
|
977
|
+
};
|
|
978
|
+
},
|
|
979
|
+
async status(spec) {
|
|
980
|
+
try {
|
|
981
|
+
const file = pathOf(spec);
|
|
982
|
+
const label = launchdLabel(spec);
|
|
983
|
+
const own = inspectOwned(file, assertMarker(spec.marker));
|
|
984
|
+
if (own.exists && !own.owned)
|
|
985
|
+
return { state: "not-installed", unitPath: file, detail: own.reason ?? "foreign file", commands: [] };
|
|
986
|
+
const domain = await domainOf();
|
|
987
|
+
if (!own.owned)
|
|
988
|
+
return { state: "not-installed", unitPath: file, detail: `no plist at ${file}`, commands: [] };
|
|
989
|
+
if (domain === null)
|
|
990
|
+
return { state: "enabled-running", unitPath: file, detail: `${file} is present; launchd has no reachable domain for uid ${uidOf(ctx) ?? "?"}, so it loads at the next login`, commands: [] };
|
|
991
|
+
const probe = await ctx.run("launchctl", ["print", `${domain}/${label}`]);
|
|
992
|
+
const loaded = codeOf2(probe) === 0;
|
|
993
|
+
const state = bootState(true, true, false);
|
|
994
|
+
return {
|
|
995
|
+
state,
|
|
996
|
+
unitPath: file,
|
|
997
|
+
detail: `${file} is present; ${loaded ? `loaded in ${domain}` : `not loaded right now (RunAtLoad loads it at the next login)`}`,
|
|
998
|
+
commands: []
|
|
999
|
+
};
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
return { state: "not-installed", unitPath: null, detail: errorMessage(error), commands: [] };
|
|
1002
|
+
}
|
|
1003
|
+
},
|
|
1004
|
+
async install(spec) {
|
|
1005
|
+
try {
|
|
1006
|
+
validate2(spec);
|
|
1007
|
+
const file = pathOf(spec);
|
|
1008
|
+
const label = launchdLabel(spec);
|
|
1009
|
+
const content = launchdPlist(spec);
|
|
1010
|
+
const own = inspectOwned(file, spec.marker);
|
|
1011
|
+
if (own.exists && !own.owned)
|
|
1012
|
+
return failed(own.reason ?? "foreign file");
|
|
1013
|
+
const staged = posixJoin(tempDir(ctx), `${label}.plist`);
|
|
1014
|
+
if (!await privileged()) {
|
|
1015
|
+
ensureDirQuiet(path3.posix.dirname(staged));
|
|
1016
|
+
fs4.writeFileSync(staged, content, { mode: 420 });
|
|
1017
|
+
return failed("writing /Library/LaunchDaemons needs root; the plist is staged for you to install", {
|
|
1018
|
+
needsPrivilege: true,
|
|
1019
|
+
commands: [
|
|
1020
|
+
shellCommand("sudo", ["install", "-m", "0644", staged, file]),
|
|
1021
|
+
shellCommand("sudo", ["launchctl", "bootstrap", "system", file]),
|
|
1022
|
+
shellCommand("sudo", ["launchctl", "enable", `system/${label}`])
|
|
1023
|
+
]
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
const domain = await domainOf();
|
|
1027
|
+
if (domain === null)
|
|
1028
|
+
return failed(`launchd has no reachable domain for uid ${uidOf(ctx) ?? "?"}; refusing to write an agent that could never load`);
|
|
1029
|
+
ensureDirQuiet(path3.posix.dirname(file));
|
|
1030
|
+
ensureDirQuiet(spec.logDir);
|
|
1031
|
+
const loaded = codeOf2(await ctx.run("launchctl", ["print", `${domain}/${label}`])) === 0;
|
|
1032
|
+
const before = { text: own.text, exists: own.exists };
|
|
1033
|
+
let changed = false;
|
|
1034
|
+
if (mode === "agent" || ctx.isRoot) {
|
|
1035
|
+
const write = writeOwned(file, spec.marker, content, 420);
|
|
1036
|
+
if (write.refusal)
|
|
1037
|
+
return failed(write.refusal);
|
|
1038
|
+
changed = write.changed;
|
|
1039
|
+
} else {
|
|
1040
|
+
ensureDirQuiet(path3.dirname(staged));
|
|
1041
|
+
fs4.writeFileSync(staged, content, { mode: 420 });
|
|
1042
|
+
changed = !before.exists || before.text !== content;
|
|
1043
|
+
if (!changed) {
|
|
1044
|
+
fs4.rmSync(staged, { force: true });
|
|
1045
|
+
} else {
|
|
1046
|
+
const installCommand = shellCommand("sudo", ["install", "-m", "0644", staged, file]);
|
|
1047
|
+
const install = await runPrivileged("install", ["-m", "0644", staged, file]);
|
|
1048
|
+
if (codeOf2(install) !== 0)
|
|
1049
|
+
return failed(problem2(install, installCommand) ?? "launchctl failed", { needsPrivilege: true, commands: [installCommand] });
|
|
1050
|
+
fs4.rmSync(staged, { force: true });
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
const lint = await runPrivileged("plutil", ["-lint", file]);
|
|
1054
|
+
if (codeOf2(lint) !== 0)
|
|
1055
|
+
return failed(`plutil -lint rejected the generated plist: ${(lint.stderr || lint.stdout).trim() || "no output"}`, { changed, commands: commands(file, label) });
|
|
1056
|
+
if (loaded && changed) {
|
|
1057
|
+
const bootout = await runPrivileged("launchctl", ["bootout", `${domain}/${label}`]);
|
|
1058
|
+
const bootoutCode = codeOf2(bootout);
|
|
1059
|
+
if (bootoutCode !== 0 && bootoutCode !== LAUNCHCTL_NO_SUCH_PROCESS && bootoutCode !== LAUNCHCTL_NOT_LOADED)
|
|
1060
|
+
return failed(problem2(bootout, shellCommand("launchctl", ["bootout", `${domain}/${label}`])) ?? "launchctl failed", { changed, commands: commands(file, label) });
|
|
1061
|
+
}
|
|
1062
|
+
if (changed || !loaded) {
|
|
1063
|
+
const boot = await runPrivileged("launchctl", ["bootstrap", domain, file]);
|
|
1064
|
+
const bootCode = codeOf2(boot);
|
|
1065
|
+
if (bootCode !== 0 && bootCode !== LAUNCHCTL_ALREADY_LOADED)
|
|
1066
|
+
return failed(problem2(boot, shellCommand("launchctl", ["bootstrap", domain, file])) ?? "launchctl failed", { changed, commands: commands(file, label) });
|
|
1067
|
+
if (bootCode === 0)
|
|
1068
|
+
changed = true;
|
|
1069
|
+
const enable = await runPrivileged("launchctl", ["enable", `${domain}/${label}`]);
|
|
1070
|
+
if (codeOf2(enable) !== 0)
|
|
1071
|
+
return failed(problem2(enable, shellCommand("launchctl", ["enable", `${domain}/${label}`])) ?? "launchctl failed", { changed, commands: commands(file, label) });
|
|
1072
|
+
}
|
|
1073
|
+
const verify = await runPrivileged("launchctl", ["print", `${domain}/${label}`]);
|
|
1074
|
+
if (codeOf2(verify) !== 0)
|
|
1075
|
+
return failed(`launchctl print ${domain}/${label} did not confirm the job (exit ${String(codeOf2(verify))})`, { changed, commands: commands(file, label) });
|
|
1076
|
+
return {
|
|
1077
|
+
ok: true,
|
|
1078
|
+
changed,
|
|
1079
|
+
detail: `${file} installed and loaded in ${domain}`,
|
|
1080
|
+
commands: [],
|
|
1081
|
+
needsPrivilege: mode === "daemon" && !ctx.isRoot
|
|
1082
|
+
};
|
|
1083
|
+
} catch (error) {
|
|
1084
|
+
return failed(errorMessage(error));
|
|
1085
|
+
}
|
|
1086
|
+
},
|
|
1087
|
+
async uninstall(spec) {
|
|
1088
|
+
try {
|
|
1089
|
+
const file = pathOf(spec);
|
|
1090
|
+
const label = launchdLabel(spec);
|
|
1091
|
+
const own = inspectOwned(file, spec.marker);
|
|
1092
|
+
if (own.exists && !own.owned)
|
|
1093
|
+
return failed(own.reason ?? "foreign file");
|
|
1094
|
+
if (mode === "daemon" && !ctx.isRoot && !await ctx.sudo()) {
|
|
1095
|
+
if (!own.exists)
|
|
1096
|
+
return { ok: true, changed: false, detail: `no plist at ${file} and no privilege to change launchd; nothing to remove`, commands: [], needsPrivilege: false };
|
|
1097
|
+
return failed("removing a LaunchDaemon needs root", {
|
|
1098
|
+
needsPrivilege: true,
|
|
1099
|
+
commands: [
|
|
1100
|
+
shellCommand("sudo", ["launchctl", "bootout", `system/${label}`]),
|
|
1101
|
+
shellCommand("sudo", ["rm", "-f", file])
|
|
1102
|
+
]
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
const domain = await domainOf();
|
|
1106
|
+
if (!own.exists && !own.owned)
|
|
1107
|
+
return failed(`no plist of ours at ${file}; refusing to unload a job this plugin did not install`);
|
|
1108
|
+
let changed = false;
|
|
1109
|
+
if (domain !== null) {
|
|
1110
|
+
const bootout = await runPrivileged("launchctl", ["bootout", `${domain}/${label}`]);
|
|
1111
|
+
const bootoutCode = codeOf2(bootout);
|
|
1112
|
+
if (bootoutCode === 0)
|
|
1113
|
+
changed = true;
|
|
1114
|
+
else if (bootoutCode !== LAUNCHCTL_NO_SUCH_PROCESS && bootoutCode !== LAUNCHCTL_NOT_LOADED)
|
|
1115
|
+
return failed(problem2(bootout, shellCommand("launchctl", ["bootout", `${domain}/${label}`])) ?? "launchctl failed", { commands: commands(file, label) });
|
|
1116
|
+
}
|
|
1117
|
+
if (own.exists) {
|
|
1118
|
+
if (mode === "agent" || ctx.isRoot) {
|
|
1119
|
+
const remove = removeOwned(file, spec.marker);
|
|
1120
|
+
if (remove.refusal)
|
|
1121
|
+
return failed(remove.refusal);
|
|
1122
|
+
if (remove.removed)
|
|
1123
|
+
changed = true;
|
|
1124
|
+
} else {
|
|
1125
|
+
if (!own.owned)
|
|
1126
|
+
return failed(own.reason ?? "foreign file");
|
|
1127
|
+
const rmCommand = shellCommand("sudo", ["rm", "-f", file]);
|
|
1128
|
+
const rm = await runPrivileged("rm", ["-f", file]);
|
|
1129
|
+
if (codeOf2(rm) !== 0)
|
|
1130
|
+
return failed(problem2(rm, rmCommand) ?? "launchctl failed", { needsPrivilege: true, commands: [rmCommand] });
|
|
1131
|
+
changed = true;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
if (domain !== null) {
|
|
1135
|
+
const verify = await runPrivileged("launchctl", ["print", `${domain}/${label}`]);
|
|
1136
|
+
if (codeOf2(verify) === 0)
|
|
1137
|
+
return failed(`${label} is still loaded in ${domain} after bootout`, { changed });
|
|
1138
|
+
}
|
|
1139
|
+
return {
|
|
1140
|
+
ok: true,
|
|
1141
|
+
changed,
|
|
1142
|
+
detail: own.exists ? `unloaded and removed ${file}` : `no plist at ${file} (already gone)`,
|
|
1143
|
+
commands: [],
|
|
1144
|
+
needsPrivilege: mode === "daemon" && !ctx.isRoot
|
|
1145
|
+
};
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
return failed(errorMessage(error));
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
function createLaunchdAgentProvider(ctx) {
|
|
1153
|
+
return createLaunchdProvider(ctx, "agent");
|
|
1154
|
+
}
|
|
1155
|
+
function createLaunchdDaemonProvider(ctx) {
|
|
1156
|
+
return createLaunchdProvider(ctx, "daemon");
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// src/boot/xdg.ts
|
|
1160
|
+
import fs5 from "node:fs";
|
|
1161
|
+
import path4 from "node:path";
|
|
1162
|
+
function validate3(spec) {
|
|
1163
|
+
assertUnitName(spec.unitName);
|
|
1164
|
+
assertMarker(spec.marker);
|
|
1165
|
+
assertLabel(spec.label);
|
|
1166
|
+
assertAbsolute(spec.command, "spec.command");
|
|
1167
|
+
assertAbsolute(spec.cwd, "spec.cwd");
|
|
1168
|
+
for (const arg of spec.args)
|
|
1169
|
+
assertArg(arg);
|
|
1170
|
+
for (const key of Object.keys(spec.env))
|
|
1171
|
+
assertEnvKey(key);
|
|
1172
|
+
}
|
|
1173
|
+
function xdgDesktopEntry(spec) {
|
|
1174
|
+
validate3(spec);
|
|
1175
|
+
return `${[
|
|
1176
|
+
"[Desktop Entry]",
|
|
1177
|
+
"Type=Application",
|
|
1178
|
+
`Name=${spec.label}`,
|
|
1179
|
+
`Comment=home-hosted daemon, managed by ${spec.marker}`,
|
|
1180
|
+
`Exec=${desktopExec(spec.command, spec.args)}`,
|
|
1181
|
+
`Path=${spec.cwd}`,
|
|
1182
|
+
"Terminal=false",
|
|
1183
|
+
"X-GNOME-Autostart-enabled=true",
|
|
1184
|
+
`X-HomeHosted-Marker=${spec.marker}`,
|
|
1185
|
+
""
|
|
1186
|
+
].join("\n")}`;
|
|
1187
|
+
}
|
|
1188
|
+
function desktopFlag(text, key) {
|
|
1189
|
+
for (const line of text.split("\n")) {
|
|
1190
|
+
const trimmed = line.trim();
|
|
1191
|
+
if (trimmed.startsWith(`${key}=`))
|
|
1192
|
+
return trimmed.slice(key.length + 1).trim();
|
|
1193
|
+
}
|
|
1194
|
+
return null;
|
|
1195
|
+
}
|
|
1196
|
+
function createXdgAutostartProvider(ctx) {
|
|
1197
|
+
const mechanism = "xdg-autostart";
|
|
1198
|
+
const unitOf = (spec) => `${assertUnitName(spec.unitName)}.desktop`;
|
|
1199
|
+
const pathOf = (spec) => posixJoin(configHome(ctx), "autostart", unitOf(spec));
|
|
1200
|
+
return {
|
|
1201
|
+
mechanism,
|
|
1202
|
+
async detect() {
|
|
1203
|
+
if (ctx.platform !== "linux")
|
|
1204
|
+
return { mechanism, available: false, bootCapable: false, privileged: false, reason: "XDG autostart is Linux-only" };
|
|
1205
|
+
return {
|
|
1206
|
+
mechanism,
|
|
1207
|
+
available: true,
|
|
1208
|
+
bootCapable: false,
|
|
1209
|
+
privileged: true,
|
|
1210
|
+
reason: `a .desktop entry under ${posixJoin(configHome(ctx), "autostart")} needs no privilege, but it starts at login rather than at boot`
|
|
1211
|
+
};
|
|
1212
|
+
},
|
|
1213
|
+
async status(spec) {
|
|
1214
|
+
try {
|
|
1215
|
+
const file = pathOf(spec);
|
|
1216
|
+
const own = inspectOwned(file, assertMarker(spec.marker));
|
|
1217
|
+
if (own.exists && !own.owned)
|
|
1218
|
+
return { state: "not-installed", unitPath: file, detail: own.reason ?? "foreign file", commands: [] };
|
|
1219
|
+
if (!own.owned || own.text === null)
|
|
1220
|
+
return { state: "not-installed", unitPath: file, detail: `no autostart entry at ${file}`, commands: [] };
|
|
1221
|
+
const enabled = desktopFlag(own.text, "X-GNOME-Autostart-enabled") !== "false" && desktopFlag(own.text, "Hidden") !== "true";
|
|
1222
|
+
const state = bootState(true, enabled, false);
|
|
1223
|
+
return {
|
|
1224
|
+
state,
|
|
1225
|
+
unitPath: file,
|
|
1226
|
+
detail: `${file} is present; ${enabled ? "it starts at login" : "it is disabled"} (login scope, not boot)`,
|
|
1227
|
+
commands: []
|
|
1228
|
+
};
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
return { state: "not-installed", unitPath: null, detail: errorMessage(error), commands: [] };
|
|
1231
|
+
}
|
|
1232
|
+
},
|
|
1233
|
+
async install(spec) {
|
|
1234
|
+
try {
|
|
1235
|
+
validate3(spec);
|
|
1236
|
+
const file = pathOf(spec);
|
|
1237
|
+
fs5.mkdirSync(path4.dirname(file), { recursive: true });
|
|
1238
|
+
const write = writeOwned(file, spec.marker, xdgDesktopEntry(spec), 420);
|
|
1239
|
+
if (write.refusal)
|
|
1240
|
+
return failed(write.refusal);
|
|
1241
|
+
const text = inspectOwned(file, spec.marker).text;
|
|
1242
|
+
const enabled = text !== null && desktopFlag(text, "X-GNOME-Autostart-enabled") !== "false" && desktopFlag(text, "Hidden") !== "true";
|
|
1243
|
+
if (!enabled)
|
|
1244
|
+
return failed(`${file} was written but does not read back as enabled`, { changed: write.changed });
|
|
1245
|
+
return {
|
|
1246
|
+
ok: true,
|
|
1247
|
+
changed: write.changed,
|
|
1248
|
+
detail: write.changed ? `${file} installed; it starts at login (not at boot)` : `${file} was already installed and enabled`,
|
|
1249
|
+
commands: [],
|
|
1250
|
+
needsPrivilege: false
|
|
1251
|
+
};
|
|
1252
|
+
} catch (error) {
|
|
1253
|
+
return failed(errorMessage(error));
|
|
1254
|
+
}
|
|
1255
|
+
},
|
|
1256
|
+
async uninstall(spec) {
|
|
1257
|
+
try {
|
|
1258
|
+
const file = pathOf(spec);
|
|
1259
|
+
const own = inspectOwned(file, spec.marker);
|
|
1260
|
+
if (own.exists && !own.owned)
|
|
1261
|
+
return failed(own.reason ?? "foreign file");
|
|
1262
|
+
const remove = removeOwned(file, spec.marker);
|
|
1263
|
+
if (remove.refusal)
|
|
1264
|
+
return failed(remove.refusal);
|
|
1265
|
+
return {
|
|
1266
|
+
ok: true,
|
|
1267
|
+
changed: remove.removed,
|
|
1268
|
+
detail: remove.removed ? `removed ${file}` : `${file} was already gone`,
|
|
1269
|
+
commands: [],
|
|
1270
|
+
needsPrivilege: false
|
|
1271
|
+
};
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
return failed(errorMessage(error));
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// src/boot/windows.ts
|
|
1280
|
+
import fs6 from "node:fs";
|
|
1281
|
+
import path5 from "node:path";
|
|
1282
|
+
var RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
|
1283
|
+
var MARKER_KEY = "HKCU\\Software\\home-hosted";
|
|
1284
|
+
var RUN_VALUE_LIMIT = 260;
|
|
1285
|
+
function validate4(spec) {
|
|
1286
|
+
assertUnitName(spec.unitName);
|
|
1287
|
+
assertMarker(spec.marker);
|
|
1288
|
+
assertLabel(spec.label);
|
|
1289
|
+
assertAbsolute(spec.command, "spec.command");
|
|
1290
|
+
assertAbsolute(spec.cwd, "spec.cwd");
|
|
1291
|
+
for (const arg of spec.args)
|
|
1292
|
+
assertArg(arg);
|
|
1293
|
+
for (const key of Object.keys(spec.env))
|
|
1294
|
+
assertEnvKey(key);
|
|
1295
|
+
}
|
|
1296
|
+
function codeOf3(result) {
|
|
1297
|
+
return result.error ? null : result.code;
|
|
1298
|
+
}
|
|
1299
|
+
function problem3(run2, command) {
|
|
1300
|
+
const code = codeOf3(run2);
|
|
1301
|
+
if (code === 0)
|
|
1302
|
+
return null;
|
|
1303
|
+
if (code === null)
|
|
1304
|
+
return run2.error ? `${command}: ${run2.error}` : `${command}: no exit code`;
|
|
1305
|
+
return `${command} exited ${code}: ${(run2.stderr || run2.stdout).trim() || "(no output)"}`;
|
|
1306
|
+
}
|
|
1307
|
+
async function queryReg(ctx, key, name2) {
|
|
1308
|
+
const res = await ctx.run("reg.exe", ["query", key, "/v", name2]);
|
|
1309
|
+
if (res.error || res.code !== 0)
|
|
1310
|
+
return { exists: false, data: null };
|
|
1311
|
+
for (const line of res.stdout.split(/\r?\n/)) {
|
|
1312
|
+
const match = /^\s{2,}(.+?)\s{2,}(REG_[A-Z_]+)\s{2,}(.*)$/.exec(line);
|
|
1313
|
+
if (match && match[1]?.trim() === name2)
|
|
1314
|
+
return { exists: true, data: (match[3] ?? "").trim() };
|
|
1315
|
+
}
|
|
1316
|
+
return { exists: false, data: null };
|
|
1317
|
+
}
|
|
1318
|
+
function windowsRunPayload(ctx, spec) {
|
|
1319
|
+
const direct = windowsCommandLine(spec.command, spec.args);
|
|
1320
|
+
if (direct.length <= RUN_VALUE_LIMIT)
|
|
1321
|
+
return { data: direct, wrapperPath: null, wrapperContent: null };
|
|
1322
|
+
const wrapperPath = path5.join(localAppData(ctx), "home-hosted", `${spec.unitName}.cmd`);
|
|
1323
|
+
return {
|
|
1324
|
+
data: `cmd.exe /c ${windowsArg(wrapperPath)}`,
|
|
1325
|
+
wrapperPath,
|
|
1326
|
+
wrapperContent: `@echo off\r
|
|
1327
|
+
rem ${spec.marker}\r
|
|
1328
|
+
${batchCommandLine(spec.command, spec.args)}\r
|
|
1329
|
+
`
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
function createWindowsRunProviderImpl(ctx) {
|
|
1333
|
+
const mechanism = "windows-run";
|
|
1334
|
+
const nameOf = (spec) => assertRegistryValueName(assertUnitName(spec.unitName));
|
|
1335
|
+
const addValue = (key, name2, data) => ctx.run("reg.exe", ["add", key, "/v", name2, "/t", "REG_SZ", "/d", data, "/f"]);
|
|
1336
|
+
return {
|
|
1337
|
+
mechanism,
|
|
1338
|
+
async detect() {
|
|
1339
|
+
if (ctx.platform !== "win32")
|
|
1340
|
+
return { mechanism, available: false, bootCapable: false, privileged: false, reason: "the Run key is Windows-only" };
|
|
1341
|
+
return {
|
|
1342
|
+
mechanism,
|
|
1343
|
+
available: true,
|
|
1344
|
+
bootCapable: false,
|
|
1345
|
+
privileged: true,
|
|
1346
|
+
reason: `HKCU\\\u2026\\Run needs no admin, but it starts at login rather than at boot`
|
|
1347
|
+
};
|
|
1348
|
+
},
|
|
1349
|
+
async status(spec) {
|
|
1350
|
+
try {
|
|
1351
|
+
const name2 = nameOf(spec);
|
|
1352
|
+
const marker = assertMarker(spec.marker);
|
|
1353
|
+
const value = await queryReg(ctx, RUN_KEY, name2);
|
|
1354
|
+
if (!value.exists)
|
|
1355
|
+
return { state: "not-installed", unitPath: RUN_KEY, detail: `no ${RUN_KEY}\\${name2} value`, commands: [] };
|
|
1356
|
+
const markerValue = await queryReg(ctx, MARKER_KEY, name2);
|
|
1357
|
+
const owned = markerValue.data?.includes(marker) === true;
|
|
1358
|
+
if (!owned)
|
|
1359
|
+
return { state: "not-installed", unitPath: RUN_KEY, detail: `${name2} exists under ${RUN_KEY} but is not marked as ours; refusing to treat it as this plugin's entry`, commands: [] };
|
|
1360
|
+
const state = bootState(true, true, false);
|
|
1361
|
+
return { state, unitPath: RUN_KEY, detail: `${RUN_KEY}\\${name2} starts at login (not at boot)`, commands: [] };
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
return { state: "not-installed", unitPath: RUN_KEY, detail: errorMessage(error), commands: [] };
|
|
1364
|
+
}
|
|
1365
|
+
},
|
|
1366
|
+
async install(spec) {
|
|
1367
|
+
try {
|
|
1368
|
+
validate4(spec);
|
|
1369
|
+
const name2 = nameOf(spec);
|
|
1370
|
+
const marker = assertMarker(spec.marker);
|
|
1371
|
+
const payload = windowsRunPayload(ctx, spec);
|
|
1372
|
+
const wrapperExists = payload.wrapperPath !== null && fs6.existsSync(payload.wrapperPath);
|
|
1373
|
+
const wrapperOwn = payload.wrapperPath ? inspectOwned(payload.wrapperPath, marker) : null;
|
|
1374
|
+
if (wrapperOwn?.exists && !wrapperOwn.owned)
|
|
1375
|
+
return failed(wrapperOwn.reason ?? "foreign wrapper");
|
|
1376
|
+
const markerValue = await queryReg(ctx, MARKER_KEY, name2);
|
|
1377
|
+
const owned = markerValue.data?.includes(marker) === true;
|
|
1378
|
+
const current = await queryReg(ctx, RUN_KEY, name2);
|
|
1379
|
+
if (current.exists && !owned)
|
|
1380
|
+
return failed(`${RUN_KEY}\\${name2} already exists and was not written by this plugin; refusing to overwrite it`);
|
|
1381
|
+
const commands = [
|
|
1382
|
+
windowsDisplayCommand("reg.exe", ["add", RUN_KEY, "/v", name2, "/t", "REG_SZ", "/d", payload.data, "/f"])
|
|
1383
|
+
];
|
|
1384
|
+
let changed = false;
|
|
1385
|
+
if (payload.wrapperPath && payload.wrapperContent) {
|
|
1386
|
+
const write = writeOwned(payload.wrapperPath, marker, payload.wrapperContent, 420);
|
|
1387
|
+
if (write.refusal)
|
|
1388
|
+
return failed(write.refusal);
|
|
1389
|
+
changed = write.changed || changed;
|
|
1390
|
+
}
|
|
1391
|
+
if (!current.exists || current.data !== payload.data) {
|
|
1392
|
+
const add = await addValue(RUN_KEY, name2, payload.data);
|
|
1393
|
+
const addProblem = problem3(add, commands[0] ?? "reg.exe add");
|
|
1394
|
+
if (addProblem)
|
|
1395
|
+
return failed(addProblem, { changed, commands });
|
|
1396
|
+
changed = true;
|
|
1397
|
+
}
|
|
1398
|
+
if (!owned) {
|
|
1399
|
+
const mark = await addValue(MARKER_KEY, name2, marker);
|
|
1400
|
+
const markProblem = problem3(mark, windowsDisplayCommand("reg.exe", ["add", MARKER_KEY, "/v", name2, "/t", "REG_SZ", "/d", marker, "/f"]));
|
|
1401
|
+
if (markProblem)
|
|
1402
|
+
return failed(markProblem, { changed, commands });
|
|
1403
|
+
changed = true;
|
|
1404
|
+
}
|
|
1405
|
+
const after = await queryReg(ctx, RUN_KEY, name2);
|
|
1406
|
+
if (after.data !== payload.data)
|
|
1407
|
+
return failed(`${RUN_KEY}\\${name2} does not read back as the value that was written`, { changed, commands });
|
|
1408
|
+
if (!payload.wrapperPath && wrapperExists && wrapperOwn?.owned) {
|
|
1409
|
+
removeOwned(path5.join(localAppData(ctx), "home-hosted", `${spec.unitName}.cmd`), marker);
|
|
1410
|
+
}
|
|
1411
|
+
return {
|
|
1412
|
+
ok: true,
|
|
1413
|
+
changed,
|
|
1414
|
+
detail: changed ? `${RUN_KEY}\\${name2} installed; it starts at login (not at boot)` : `${RUN_KEY}\\${name2} was already installed`,
|
|
1415
|
+
commands: [],
|
|
1416
|
+
needsPrivilege: false
|
|
1417
|
+
};
|
|
1418
|
+
} catch (error) {
|
|
1419
|
+
return failed(errorMessage(error));
|
|
1420
|
+
}
|
|
1421
|
+
},
|
|
1422
|
+
async uninstall(spec) {
|
|
1423
|
+
try {
|
|
1424
|
+
const name2 = nameOf(spec);
|
|
1425
|
+
const marker = assertMarker(spec.marker);
|
|
1426
|
+
const current = await queryReg(ctx, RUN_KEY, name2);
|
|
1427
|
+
const markerValue = await queryReg(ctx, MARKER_KEY, name2);
|
|
1428
|
+
const owned = markerValue.data?.includes(marker) === true;
|
|
1429
|
+
if (current.exists && !owned) {
|
|
1430
|
+
const payload2 = windowsRunPayload(ctx, spec);
|
|
1431
|
+
const wrapperOwned = payload2.wrapperPath !== null ? inspectOwned(payload2.wrapperPath, marker).owned : false;
|
|
1432
|
+
if (current.data !== payload2.data && !wrapperOwned)
|
|
1433
|
+
return failed(`${RUN_KEY}\\${name2} is not marked as ours; refusing to delete a foreign Run value`);
|
|
1434
|
+
}
|
|
1435
|
+
let changed = false;
|
|
1436
|
+
if (current.exists) {
|
|
1437
|
+
const del = await ctx.run("reg.exe", ["delete", RUN_KEY, "/v", name2, "/f"]);
|
|
1438
|
+
const delCode = codeOf3(del);
|
|
1439
|
+
if (delCode !== 0 && delCode !== 1)
|
|
1440
|
+
return failed(problem3(del, windowsDisplayCommand("reg.exe", ["delete", RUN_KEY, "/v", name2, "/f"])) ?? "reg.exe delete failed");
|
|
1441
|
+
changed = delCode === 0;
|
|
1442
|
+
}
|
|
1443
|
+
if (owned) {
|
|
1444
|
+
await ctx.run("reg.exe", ["delete", MARKER_KEY, "/v", name2, "/f"]);
|
|
1445
|
+
changed = true;
|
|
1446
|
+
}
|
|
1447
|
+
const payload = windowsRunPayload(ctx, spec);
|
|
1448
|
+
if (payload.wrapperPath) {
|
|
1449
|
+
const remove = removeOwned(payload.wrapperPath, marker);
|
|
1450
|
+
if (remove.refusal)
|
|
1451
|
+
return failed(remove.refusal);
|
|
1452
|
+
changed = remove.removed || changed;
|
|
1453
|
+
}
|
|
1454
|
+
const stale = path5.join(localAppData(ctx), "home-hosted", `${spec.unitName}.cmd`);
|
|
1455
|
+
const staleRemove = removeOwned(stale, marker);
|
|
1456
|
+
if (staleRemove.refusal)
|
|
1457
|
+
return failed(staleRemove.refusal);
|
|
1458
|
+
changed = staleRemove.removed || changed;
|
|
1459
|
+
const after = await queryReg(ctx, RUN_KEY, name2);
|
|
1460
|
+
if (after.exists)
|
|
1461
|
+
return failed(`${RUN_KEY}\\${name2} still exists after removal`, { changed });
|
|
1462
|
+
return {
|
|
1463
|
+
ok: true,
|
|
1464
|
+
changed,
|
|
1465
|
+
detail: changed ? `removed ${RUN_KEY}\\${name2}` : `${RUN_KEY}\\${name2} was already gone`,
|
|
1466
|
+
commands: [],
|
|
1467
|
+
needsPrivilege: false
|
|
1468
|
+
};
|
|
1469
|
+
} catch (error) {
|
|
1470
|
+
return failed(errorMessage(error));
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
function scheduledTaskXml(spec) {
|
|
1476
|
+
validate4(spec);
|
|
1477
|
+
const args = spec.args.map(windowsArg).join(" ");
|
|
1478
|
+
return `${[
|
|
1479
|
+
'<?xml version="1.0" encoding="UTF-16"?>',
|
|
1480
|
+
'<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
|
|
1481
|
+
"<RegistrationInfo>",
|
|
1482
|
+
`<Description>${xmlEscape(spec.marker)}</Description>`,
|
|
1483
|
+
"</RegistrationInfo>",
|
|
1484
|
+
"<Triggers>",
|
|
1485
|
+
"<LogonTrigger>",
|
|
1486
|
+
"<Enabled>true</Enabled>",
|
|
1487
|
+
"</LogonTrigger>",
|
|
1488
|
+
"</Triggers>",
|
|
1489
|
+
"<Principals>",
|
|
1490
|
+
'<Principal id="Author">',
|
|
1491
|
+
"<LogonType>InteractiveToken</LogonType>",
|
|
1492
|
+
"<RunLevel>LeastPrivilege</RunLevel>",
|
|
1493
|
+
"</Principal>",
|
|
1494
|
+
"</Principals>",
|
|
1495
|
+
"<Settings>",
|
|
1496
|
+
"<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>",
|
|
1497
|
+
"<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>",
|
|
1498
|
+
"<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>",
|
|
1499
|
+
"<StartWhenAvailable>true</StartWhenAvailable>",
|
|
1500
|
+
"<RestartOnFailure>",
|
|
1501
|
+
"<Interval>PT1M</Interval>",
|
|
1502
|
+
"<Count>3</Count>",
|
|
1503
|
+
"</RestartOnFailure>",
|
|
1504
|
+
"<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>",
|
|
1505
|
+
"</Settings>",
|
|
1506
|
+
'<Actions Context="Author">',
|
|
1507
|
+
"<Exec>",
|
|
1508
|
+
`<Command>${xmlEscape(spec.command)}</Command>`,
|
|
1509
|
+
`<Arguments>${xmlEscape(args)}</Arguments>`,
|
|
1510
|
+
`<WorkingDirectory>${xmlEscape(spec.cwd)}</WorkingDirectory>`,
|
|
1511
|
+
"</Exec>",
|
|
1512
|
+
"</Actions>",
|
|
1513
|
+
"</Task>",
|
|
1514
|
+
""
|
|
1515
|
+
].join("\r\n")}`;
|
|
1516
|
+
}
|
|
1517
|
+
function registerTaskScript(spec) {
|
|
1518
|
+
validate4(spec);
|
|
1519
|
+
const args = spec.args.map(windowsArg).join(" ");
|
|
1520
|
+
return [
|
|
1521
|
+
`$action = New-ScheduledTaskAction -Execute ${powershellLiteral(spec.command)} -Argument ${powershellLiteral(args)} -WorkingDirectory ${powershellLiteral(spec.cwd)}`,
|
|
1522
|
+
"$trigger = New-ScheduledTaskTrigger -AtLogOn",
|
|
1523
|
+
"$settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries",
|
|
1524
|
+
`Register-ScheduledTask -TaskName ${powershellLiteral(spec.unitName)} -Action $action -Trigger $trigger -Settings $settings -Description ${powershellLiteral(spec.marker)} -Force | Out-Null`
|
|
1525
|
+
].join("; ");
|
|
1526
|
+
}
|
|
1527
|
+
function unregisterTaskScript(name2) {
|
|
1528
|
+
return `Unregister-ScheduledTask -TaskName ${powershellLiteral(assertUnitName(name2))} -Confirm:$false`;
|
|
1529
|
+
}
|
|
1530
|
+
function xmlTag(xml, tag) {
|
|
1531
|
+
const match = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(xml);
|
|
1532
|
+
return match?.[1] === void 0 ? null : xmlUnescape(match[1].trim());
|
|
1533
|
+
}
|
|
1534
|
+
function createWindowsTaskProvider(ctx) {
|
|
1535
|
+
const mechanism = "windows-task";
|
|
1536
|
+
const nameOf = (spec) => assertUnitName(spec.unitName);
|
|
1537
|
+
const query = async (name2) => {
|
|
1538
|
+
const res = await ctx.run("schtasks.exe", ["/Query", "/TN", name2, "/XML"]);
|
|
1539
|
+
if (res.error || res.code !== 0)
|
|
1540
|
+
return { exists: false, xml: null };
|
|
1541
|
+
return { exists: true, xml: res.stdout };
|
|
1542
|
+
};
|
|
1543
|
+
const inspect = async (spec) => {
|
|
1544
|
+
const found = await query(nameOf(spec));
|
|
1545
|
+
if (!found.exists || found.xml === null)
|
|
1546
|
+
return { exists: false, xml: null, marked: false, matches: false };
|
|
1547
|
+
const marked = xmlUnescape(found.xml).includes(spec.marker);
|
|
1548
|
+
const args = spec.args.map(windowsArg).join(" ");
|
|
1549
|
+
const matches = marked && xmlTag(found.xml, "Command") === spec.command && xmlTag(found.xml, "Arguments") === args && (xmlTag(found.xml, "WorkingDirectory") ?? spec.cwd) === spec.cwd;
|
|
1550
|
+
return { exists: true, xml: found.xml, marked, matches };
|
|
1551
|
+
};
|
|
1552
|
+
const hasRegisterCmdlet = async () => {
|
|
1553
|
+
const probe = await ctx.run("powershell.exe", ["-NoProfile", "-Command", "Get-Command Register-ScheduledTask"]);
|
|
1554
|
+
return probe.error === void 0 || probe.error === null ? /Register-ScheduledTask/.test(probe.stdout) : false;
|
|
1555
|
+
};
|
|
1556
|
+
const isElevated = async () => {
|
|
1557
|
+
const script = "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)";
|
|
1558
|
+
const probe = await ctx.run("powershell.exe", ["-NoProfile", "-Command", script]);
|
|
1559
|
+
return probe.error === void 0 || probe.error === null ? /^true$/im.test(probe.stdout.trim()) : false;
|
|
1560
|
+
};
|
|
1561
|
+
return {
|
|
1562
|
+
mechanism,
|
|
1563
|
+
async detect() {
|
|
1564
|
+
if (ctx.platform !== "win32")
|
|
1565
|
+
return { mechanism, available: false, bootCapable: false, privileged: false, reason: "scheduled tasks are Windows-only" };
|
|
1566
|
+
const elevated = await isElevated();
|
|
1567
|
+
return {
|
|
1568
|
+
mechanism,
|
|
1569
|
+
available: elevated,
|
|
1570
|
+
bootCapable: false,
|
|
1571
|
+
privileged: elevated,
|
|
1572
|
+
reason: elevated ? "administrator rights are available; the task is registered for logon (not for boot)" : "registering a scheduled task needs an elevated process"
|
|
1573
|
+
};
|
|
1574
|
+
},
|
|
1575
|
+
async status(spec) {
|
|
1576
|
+
try {
|
|
1577
|
+
const name2 = nameOf(spec);
|
|
1578
|
+
const info = await inspect(spec);
|
|
1579
|
+
if (!info.exists)
|
|
1580
|
+
return { state: "not-installed", unitPath: null, detail: `no scheduled task named ${name2}`, commands: [] };
|
|
1581
|
+
if (!info.marked)
|
|
1582
|
+
return { state: "not-installed", unitPath: null, detail: `task ${name2} exists but does not carry this plugin's marker; refusing to treat it as this plugin's entry`, commands: [] };
|
|
1583
|
+
const state = bootState(true, true, false);
|
|
1584
|
+
return { state, unitPath: null, detail: `scheduled task ${name2} is registered (runs at logon, not at boot)`, commands: [] };
|
|
1585
|
+
} catch (error) {
|
|
1586
|
+
return { state: "not-installed", unitPath: null, detail: errorMessage(error), commands: [] };
|
|
1587
|
+
}
|
|
1588
|
+
},
|
|
1589
|
+
async install(spec) {
|
|
1590
|
+
try {
|
|
1591
|
+
validate4(spec);
|
|
1592
|
+
const name2 = nameOf(spec);
|
|
1593
|
+
const info = await inspect(spec);
|
|
1594
|
+
if (info.exists && !info.marked)
|
|
1595
|
+
return failed(`scheduled task ${name2} already exists and was not written by this plugin; refusing to overwrite it`);
|
|
1596
|
+
if (!await isElevated())
|
|
1597
|
+
return failed("registering a scheduled task needs an elevated process", { needsPrivilege: true });
|
|
1598
|
+
const elevatedCommands = [
|
|
1599
|
+
windowsDisplayCommand("powershell.exe", ["-NoProfile", "-Command", unregisterTaskScript(name2)]),
|
|
1600
|
+
windowsDisplayCommand("schtasks.exe", ["/Query", "/TN", name2, "/XML"])
|
|
1601
|
+
];
|
|
1602
|
+
if (info.exists && info.matches) {
|
|
1603
|
+
const verify = await query(name2);
|
|
1604
|
+
if (verify.exists)
|
|
1605
|
+
return { ok: true, changed: false, detail: `scheduled task ${name2} is already registered`, commands: [], needsPrivilege: true };
|
|
1606
|
+
}
|
|
1607
|
+
const cmdlet = await hasRegisterCmdlet();
|
|
1608
|
+
if (cmdlet) {
|
|
1609
|
+
const script = registerTaskScript(spec);
|
|
1610
|
+
const res = await ctx.run("powershell.exe", ["-NoProfile", "-Command", script]);
|
|
1611
|
+
if (codeOf3(res) !== 0)
|
|
1612
|
+
return failed(problem3(res, windowsDisplayCommand("powershell.exe", ["-NoProfile", "-Command", script])) ?? "Register-ScheduledTask failed", { needsPrivilege: true, commands: elevatedCommands });
|
|
1613
|
+
} else {
|
|
1614
|
+
const xmlFile = path5.join(tempDir(ctx), `${name2}.xml`);
|
|
1615
|
+
fs6.mkdirSync(path5.dirname(xmlFile), { recursive: true });
|
|
1616
|
+
fs6.writeFileSync(xmlFile, Buffer.from(`\uFEFF${scheduledTaskXml(spec)}`, "utf16le"));
|
|
1617
|
+
const createCommand = windowsDisplayCommand("schtasks.exe", ["/Create", "/TN", name2, "/XML", xmlFile, "/F"]);
|
|
1618
|
+
const create = await ctx.run("schtasks.exe", ["/Create", "/TN", name2, "/XML", xmlFile, "/F"]);
|
|
1619
|
+
if (codeOf3(create) !== 0)
|
|
1620
|
+
return failed(problem3(create, createCommand) ?? "schtasks /Create failed", { needsPrivilege: true, commands: [createCommand] });
|
|
1621
|
+
fs6.rmSync(xmlFile, { force: true });
|
|
1622
|
+
}
|
|
1623
|
+
const after = await query(name2);
|
|
1624
|
+
if (!after.exists)
|
|
1625
|
+
return failed(`scheduled task ${name2} was not found after registration`, { changed: true, needsPrivilege: true, commands: elevatedCommands });
|
|
1626
|
+
return {
|
|
1627
|
+
ok: true,
|
|
1628
|
+
changed: true,
|
|
1629
|
+
detail: `scheduled task ${name2} registered (runs at logon, not at boot)`,
|
|
1630
|
+
commands: [],
|
|
1631
|
+
needsPrivilege: true
|
|
1632
|
+
};
|
|
1633
|
+
} catch (error) {
|
|
1634
|
+
return failed(errorMessage(error));
|
|
1635
|
+
}
|
|
1636
|
+
},
|
|
1637
|
+
async uninstall(spec) {
|
|
1638
|
+
try {
|
|
1639
|
+
const name2 = nameOf(spec);
|
|
1640
|
+
const info = await inspect(spec);
|
|
1641
|
+
if (!info.exists)
|
|
1642
|
+
return { ok: true, changed: false, detail: `no scheduled task named ${name2}; already gone`, commands: [], needsPrivilege: false };
|
|
1643
|
+
if (!info.marked)
|
|
1644
|
+
return failed(`scheduled task ${name2} is not marked as ours; refusing to delete a foreign task`);
|
|
1645
|
+
if (!await isElevated())
|
|
1646
|
+
return failed("deleting a scheduled task needs an elevated process", { needsPrivilege: true });
|
|
1647
|
+
const cmdlet = await hasRegisterCmdlet();
|
|
1648
|
+
const res = cmdlet ? await ctx.run("powershell.exe", ["-NoProfile", "-Command", unregisterTaskScript(name2)]) : await ctx.run("schtasks.exe", ["/Delete", "/TN", name2, "/F"]);
|
|
1649
|
+
const code = codeOf3(res);
|
|
1650
|
+
if (code !== 0 && code !== 1)
|
|
1651
|
+
return failed(problem3(res, cmdlet ? "powershell.exe Unregister-ScheduledTask" : windowsDisplayCommand("schtasks.exe", ["/Delete", "/TN", name2, "/F"])) ?? "unregistering the task failed", { needsPrivilege: true });
|
|
1652
|
+
const after = await query(name2);
|
|
1653
|
+
if (after.exists)
|
|
1654
|
+
return failed(`scheduled task ${name2} still exists after removal`, { changed: true, needsPrivilege: true });
|
|
1655
|
+
return {
|
|
1656
|
+
ok: true,
|
|
1657
|
+
changed: true,
|
|
1658
|
+
detail: `unregistered scheduled task ${name2}`,
|
|
1659
|
+
commands: [],
|
|
1660
|
+
needsPrivilege: true
|
|
1661
|
+
};
|
|
1662
|
+
} catch (error) {
|
|
1663
|
+
return failed(errorMessage(error));
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
function createWindowsRunProvider(ctx) {
|
|
1669
|
+
return createWindowsRunProviderImpl(ctx);
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// src/boot/fallback.ts
|
|
1673
|
+
var RESTART_POLICY = "use the container restart policy instead: `docker run --restart unless-stopped`, or `restart: unless-stopped` in a compose file";
|
|
1674
|
+
function detectContainer(ctx) {
|
|
1675
|
+
if (existsOf(ctx, "/.dockerenv"))
|
|
1676
|
+
return "Docker (/.dockerenv)";
|
|
1677
|
+
if (existsOf(ctx, "/run/.containerenv"))
|
|
1678
|
+
return "Podman (/run/.containerenv)";
|
|
1679
|
+
if (ctx.env.container?.trim())
|
|
1680
|
+
return `container environment (container=${ctx.env.container.trim()})`;
|
|
1681
|
+
const cgroup = existsOf(ctx, "/proc/1/cgroup") ? readText("/proc/1/cgroup") : null;
|
|
1682
|
+
if (cgroup) {
|
|
1683
|
+
if (cgroup.includes("kubepods"))
|
|
1684
|
+
return "Kubernetes (cgroup kubepods)";
|
|
1685
|
+
if (cgroup.includes("docker"))
|
|
1686
|
+
return "Docker (cgroup docker)";
|
|
1687
|
+
if (cgroup.includes("lxc"))
|
|
1688
|
+
return "LXC (cgroup lxc)";
|
|
1689
|
+
if (cgroup.includes("containerd"))
|
|
1690
|
+
return "containerd (cgroup)";
|
|
1691
|
+
}
|
|
1692
|
+
return null;
|
|
1693
|
+
}
|
|
1694
|
+
function createContainerProvider(ctx) {
|
|
1695
|
+
const mechanism = "container";
|
|
1696
|
+
return {
|
|
1697
|
+
mechanism,
|
|
1698
|
+
async detect() {
|
|
1699
|
+
const container = detectContainer(ctx);
|
|
1700
|
+
return {
|
|
1701
|
+
mechanism,
|
|
1702
|
+
available: container !== null,
|
|
1703
|
+
bootCapable: false,
|
|
1704
|
+
privileged: false,
|
|
1705
|
+
reason: container === null ? "this process does not look like it is inside a container" : `this process runs inside ${container}; an OS boot entry would live on the host, not here`
|
|
1706
|
+
};
|
|
1707
|
+
},
|
|
1708
|
+
async status() {
|
|
1709
|
+
const container = detectContainer(ctx);
|
|
1710
|
+
return {
|
|
1711
|
+
state: "unsupported",
|
|
1712
|
+
unitPath: null,
|
|
1713
|
+
detail: container === null ? "no container detected" : `running inside ${container}; ${RESTART_POLICY}`,
|
|
1714
|
+
commands: []
|
|
1715
|
+
};
|
|
1716
|
+
},
|
|
1717
|
+
async install() {
|
|
1718
|
+
const container = detectContainer(ctx);
|
|
1719
|
+
return failed(
|
|
1720
|
+
container === null ? `no container detected; ${RESTART_POLICY}` : `this process runs inside ${container}; an OS boot entry would be installed outside the container \u2014 ${RESTART_POLICY}`
|
|
1721
|
+
);
|
|
1722
|
+
},
|
|
1723
|
+
async uninstall() {
|
|
1724
|
+
return failed("nothing to uninstall: this plugin installed no OS boot entry for a container; the container restart policy is what starts it");
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
function createUnsupportedProvider(platform) {
|
|
1729
|
+
const mechanism = "unsupported";
|
|
1730
|
+
const reason = `no boot mechanism is known for ${platform}`;
|
|
1731
|
+
return {
|
|
1732
|
+
mechanism,
|
|
1733
|
+
async detect() {
|
|
1734
|
+
return { mechanism, available: true, bootCapable: false, privileged: false, reason };
|
|
1735
|
+
},
|
|
1736
|
+
async status(_spec) {
|
|
1737
|
+
return { state: "unsupported", unitPath: null, detail: reason, commands: [] };
|
|
1738
|
+
},
|
|
1739
|
+
async install(_spec) {
|
|
1740
|
+
return failed(`this plugin has no boot mechanism for ${platform}; start home-hosted from your platform's own service manager`);
|
|
1741
|
+
},
|
|
1742
|
+
async uninstall(_spec) {
|
|
1743
|
+
return failed(`this plugin has no boot mechanism for ${platform}, so it installed nothing to remove`);
|
|
1744
|
+
}
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// src/boot/ladder.ts
|
|
1749
|
+
function platformKind(platform) {
|
|
1750
|
+
if (platform === "linux" || platform === "darwin" || platform === "win32")
|
|
1751
|
+
return platform;
|
|
1752
|
+
return "other";
|
|
1753
|
+
}
|
|
1754
|
+
function bootProviders(ctx, platform = ctx.platform) {
|
|
1755
|
+
const container = createContainerProvider(ctx);
|
|
1756
|
+
const unsupported = createUnsupportedProvider(platform);
|
|
1757
|
+
switch (platform) {
|
|
1758
|
+
case "linux":
|
|
1759
|
+
return [createSystemdUserProvider(ctx), createSystemdSystemProvider(ctx), createXdgAutostartProvider(ctx), container, unsupported];
|
|
1760
|
+
case "darwin":
|
|
1761
|
+
return [createLaunchdAgentProvider(ctx), createLaunchdDaemonProvider(ctx), unsupported];
|
|
1762
|
+
case "win32":
|
|
1763
|
+
return [createWindowsRunProvider(ctx), createWindowsTaskProvider(ctx), unsupported];
|
|
1764
|
+
default:
|
|
1765
|
+
return [container, unsupported];
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
function createBootLadder(options = {}) {
|
|
1769
|
+
const platform = options.platform ?? process3.platform;
|
|
1770
|
+
const isRoot = process3.getuid?.() === 0;
|
|
1771
|
+
const ctx = {
|
|
1772
|
+
platform,
|
|
1773
|
+
home: options.home ?? os2.homedir(),
|
|
1774
|
+
env: options.env ?? process3.env,
|
|
1775
|
+
run: options.run ?? run,
|
|
1776
|
+
sudo: options.sudo ?? (async () => isRoot ? true : await sudoAvailable()),
|
|
1777
|
+
isRoot,
|
|
1778
|
+
exists: options.exists
|
|
1779
|
+
};
|
|
1780
|
+
const providers = bootProviders(ctx, platform);
|
|
1781
|
+
const kind = platformKind(platform);
|
|
1782
|
+
async function detectAll() {
|
|
1783
|
+
const candidates = await Promise.all(providers.map(async (provider) => {
|
|
1784
|
+
try {
|
|
1785
|
+
return await provider.detect();
|
|
1786
|
+
} catch (error) {
|
|
1787
|
+
return {
|
|
1788
|
+
mechanism: provider.mechanism,
|
|
1789
|
+
available: false,
|
|
1790
|
+
bootCapable: false,
|
|
1791
|
+
privileged: false,
|
|
1792
|
+
reason: `detection failed: ${errorMessage(error)}`
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
}));
|
|
1796
|
+
return { platform: kind, candidates };
|
|
1797
|
+
}
|
|
1798
|
+
async function safeStatus(provider, spec) {
|
|
1799
|
+
try {
|
|
1800
|
+
return await provider.status(spec);
|
|
1801
|
+
} catch (error) {
|
|
1802
|
+
return { state: "not-installed", unitPath: null, detail: errorMessage(error), commands: [] };
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
function recommend(candidates) {
|
|
1806
|
+
const byMechanism = new Map(candidates.map((candidate) => [candidate.mechanism, candidate]));
|
|
1807
|
+
const available = providers.map((provider) => provider.mechanism).filter((mechanism) => byMechanism.get(mechanism)?.available === true);
|
|
1808
|
+
const bootCapable = available.find((mechanism) => mechanism !== "container" && byMechanism.get(mechanism)?.bootCapable === true);
|
|
1809
|
+
if (bootCapable)
|
|
1810
|
+
return bootCapable;
|
|
1811
|
+
if (available.includes("container"))
|
|
1812
|
+
return "container";
|
|
1813
|
+
return available.find((mechanism) => mechanism !== "unsupported") ?? available[0] ?? null;
|
|
1814
|
+
}
|
|
1815
|
+
async function buildStatus(spec, mechanism) {
|
|
1816
|
+
const { candidates } = await detectAll();
|
|
1817
|
+
const byMechanism = new Map(candidates.map((candidate2) => [candidate2.mechanism, candidate2]));
|
|
1818
|
+
const statuses = /* @__PURE__ */ new Map();
|
|
1819
|
+
for (const [provider, status2] of await Promise.all(providers.map(async (provider2) => [provider2, await safeStatus(provider2, spec)])))
|
|
1820
|
+
statuses.set(provider.mechanism, status2);
|
|
1821
|
+
const explicit = mechanism ? providers.find((provider) => provider.mechanism === mechanism) : void 0;
|
|
1822
|
+
const installedProvider = providers.find((provider) => {
|
|
1823
|
+
const status2 = statuses.get(provider.mechanism);
|
|
1824
|
+
return status2 !== void 0 && isInstalledState(status2.state);
|
|
1825
|
+
});
|
|
1826
|
+
const recommended = recommend(candidates);
|
|
1827
|
+
const target = explicit ?? installedProvider ?? (recommended ? providers.find((provider) => provider.mechanism === recommended) : void 0);
|
|
1828
|
+
const explicitStatus = explicit ? statuses.get(explicit.mechanism) : void 0;
|
|
1829
|
+
const installedMechanism = explicit && explicitStatus && isInstalledState(explicitStatus.state) ? explicit.mechanism : installedProvider?.mechanism ?? null;
|
|
1830
|
+
const common = { platform: kind, mechanism: installedMechanism, recommended, candidates };
|
|
1831
|
+
if (!target) {
|
|
1832
|
+
return {
|
|
1833
|
+
...common,
|
|
1834
|
+
state: "unsupported",
|
|
1835
|
+
bootCapable: false,
|
|
1836
|
+
privileged: false,
|
|
1837
|
+
unitPath: null,
|
|
1838
|
+
commands: [],
|
|
1839
|
+
detail: `no boot mechanism is available on ${platform}`
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
const status = statuses.get(target.mechanism);
|
|
1843
|
+
const candidate = byMechanism.get(target.mechanism);
|
|
1844
|
+
const alsoInstalled = providers.filter((provider) => provider.mechanism !== target.mechanism && isInstalledState(statuses.get(provider.mechanism)?.state ?? "unsupported")).map((provider) => provider.mechanism);
|
|
1845
|
+
return {
|
|
1846
|
+
...common,
|
|
1847
|
+
state: status?.state ?? "unsupported",
|
|
1848
|
+
bootCapable: candidate?.bootCapable ?? false,
|
|
1849
|
+
privileged: candidate?.privileged ?? false,
|
|
1850
|
+
unitPath: status?.unitPath ?? null,
|
|
1851
|
+
commands: status?.commands ?? [],
|
|
1852
|
+
detail: `${status?.detail ?? "no detail"}${alsoInstalled.length ? `; also installed: ${alsoInstalled.join(", ")}` : ""}`
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
async function safeInstall(provider, spec) {
|
|
1856
|
+
try {
|
|
1857
|
+
return await provider.install(spec);
|
|
1858
|
+
} catch (error) {
|
|
1859
|
+
return failed(errorMessage(error));
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
async function safeUninstall(provider, spec) {
|
|
1863
|
+
try {
|
|
1864
|
+
return await provider.uninstall(spec);
|
|
1865
|
+
} catch (error) {
|
|
1866
|
+
return failed(errorMessage(error));
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
return {
|
|
1870
|
+
providers,
|
|
1871
|
+
detect: detectAll,
|
|
1872
|
+
status: buildStatus,
|
|
1873
|
+
async install(spec, mechanism) {
|
|
1874
|
+
const before = await buildStatus(spec, mechanism);
|
|
1875
|
+
const chosen = mechanism ?? before.mechanism ?? before.recommended;
|
|
1876
|
+
const target = chosen ? providers.find((provider) => provider.mechanism === chosen) : void 0;
|
|
1877
|
+
if (!target) {
|
|
1878
|
+
return {
|
|
1879
|
+
...failed(`no boot mechanism is available on ${platform}`),
|
|
1880
|
+
mechanism: null,
|
|
1881
|
+
status: before
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
const result = await safeInstall(target, spec);
|
|
1885
|
+
const status = await buildStatus(spec, mechanism);
|
|
1886
|
+
return {
|
|
1887
|
+
...result,
|
|
1888
|
+
mechanism: result.ok ? target.mechanism : status.mechanism,
|
|
1889
|
+
status
|
|
1890
|
+
};
|
|
1891
|
+
},
|
|
1892
|
+
async uninstall(spec, mechanism) {
|
|
1893
|
+
const before = await buildStatus(spec, mechanism);
|
|
1894
|
+
const chosen = mechanism ?? before.mechanism;
|
|
1895
|
+
const target = chosen ? providers.find((provider) => provider.mechanism === chosen) : void 0;
|
|
1896
|
+
if (!target) {
|
|
1897
|
+
return {
|
|
1898
|
+
ok: true,
|
|
1899
|
+
changed: false,
|
|
1900
|
+
detail: "no OS boot entry is installed for this plugin, so there is nothing to uninstall",
|
|
1901
|
+
commands: [],
|
|
1902
|
+
needsPrivilege: false,
|
|
1903
|
+
status: before
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
const result = await safeUninstall(target, spec);
|
|
1907
|
+
const status = await buildStatus(spec, mechanism);
|
|
1908
|
+
return { ...result, status };
|
|
1909
|
+
}
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// src/home-hosted/config-file.ts
|
|
1914
|
+
import fs7 from "node:fs";
|
|
1915
|
+
|
|
1916
|
+
// src/util/paths.ts
|
|
1917
|
+
import os3 from "node:os";
|
|
1918
|
+
import path6 from "node:path";
|
|
1919
|
+
function expandHome(value) {
|
|
1920
|
+
if (value === "~")
|
|
1921
|
+
return os3.homedir();
|
|
1922
|
+
if (value.startsWith("~/") || value.startsWith("~\\"))
|
|
1923
|
+
return path6.join(os3.homedir(), value.slice(2));
|
|
1924
|
+
return value;
|
|
1925
|
+
}
|
|
1926
|
+
function dshHome() {
|
|
1927
|
+
const configured = process.env.DSH_HOME?.trim();
|
|
1928
|
+
return configured ? path6.resolve(expandHome(configured)) : path6.join(os3.homedir(), ".dsh");
|
|
1929
|
+
}
|
|
1930
|
+
function homeHostedHome() {
|
|
1931
|
+
const configured = process.env.HHOSTED_HOME?.trim();
|
|
1932
|
+
return configured ? path6.resolve(expandHome(configured)) : path6.join(os3.homedir(), ".home-hosted");
|
|
1933
|
+
}
|
|
1934
|
+
function pluginStateDir(override) {
|
|
1935
|
+
const configured = override?.trim();
|
|
1936
|
+
if (configured)
|
|
1937
|
+
return path6.resolve(expandHome(configured));
|
|
1938
|
+
return path6.join(dshHome(), "dsh-home-hosted");
|
|
1939
|
+
}
|
|
1940
|
+
function runtimeFile(home = homeHostedHome()) {
|
|
1941
|
+
return path6.join(home, "run.json");
|
|
1942
|
+
}
|
|
1943
|
+
function secretsFile(home = homeHostedHome()) {
|
|
1944
|
+
return path6.join(home, ".control-secrets.json");
|
|
1945
|
+
}
|
|
1946
|
+
function configFile(home = homeHostedHome()) {
|
|
1947
|
+
return path6.join(home, "servers.config.json");
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
// src/home-hosted/config-file.ts
|
|
1951
|
+
function readConfig(home) {
|
|
1952
|
+
const file = configFile(home);
|
|
1953
|
+
const raw = readJson(file);
|
|
1954
|
+
if (raw === null) {
|
|
1955
|
+
const exists = fs7.existsSync(file);
|
|
1956
|
+
return { raw: null, exists, error: exists ? "the config file could not be parsed" : null };
|
|
1957
|
+
}
|
|
1958
|
+
if (!Array.isArray(raw.servers) && raw.servers !== void 0)
|
|
1959
|
+
return { raw: null, exists: true, error: "the config file has a servers field that is not an array" };
|
|
1960
|
+
return { raw, exists: true, error: null };
|
|
1961
|
+
}
|
|
1962
|
+
function writeConfig(home, raw, writtenBy = "dsh-home-hosted") {
|
|
1963
|
+
const meta = { schema: 1, ...raw.meta ?? {}, writtenBy };
|
|
1964
|
+
const next = { ...raw, meta };
|
|
1965
|
+
writeFileAtomic(configFile(home), `${JSON.stringify(next, null, 2)}
|
|
1966
|
+
`);
|
|
1967
|
+
}
|
|
1968
|
+
function findEntry(raw, id) {
|
|
1969
|
+
return (raw.servers ?? []).find((entry) => entry.id === id) ?? null;
|
|
1970
|
+
}
|
|
1971
|
+
function upsertEntry(raw, entry) {
|
|
1972
|
+
const servers = [...raw.servers ?? []];
|
|
1973
|
+
const index = servers.findIndex((candidate) => candidate.id === entry.id);
|
|
1974
|
+
if (index >= 0)
|
|
1975
|
+
servers[index] = entry;
|
|
1976
|
+
else
|
|
1977
|
+
servers.push(entry);
|
|
1978
|
+
return { ...raw, servers };
|
|
1979
|
+
}
|
|
1980
|
+
function patchEntry(raw, id, patch) {
|
|
1981
|
+
const servers = (raw.servers ?? []).map((entry) => {
|
|
1982
|
+
if (entry.id !== id)
|
|
1983
|
+
return entry;
|
|
1984
|
+
const merged = { ...entry, ...patch };
|
|
1985
|
+
if (patch.stop !== void 0 && typeof entry.stop === "object" && entry.stop !== null)
|
|
1986
|
+
merged.stop = { ...entry.stop, ...patch.stop };
|
|
1987
|
+
return merged;
|
|
1988
|
+
});
|
|
1989
|
+
return { ...raw, servers };
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
// src/home-hosted/dsh-entry.ts
|
|
1993
|
+
import path8 from "node:path";
|
|
1994
|
+
import process5 from "node:process";
|
|
1995
|
+
|
|
1996
|
+
// src/home-hosted/launch.ts
|
|
1997
|
+
import fs8 from "node:fs";
|
|
1998
|
+
import path7 from "node:path";
|
|
1999
|
+
import process4 from "node:process";
|
|
2000
|
+
async function which(command) {
|
|
2001
|
+
const probe = process4.platform === "win32" ? "where.exe" : "which";
|
|
2002
|
+
const result = await run(probe, [command], { timeoutMs: 5e3 });
|
|
2003
|
+
if (result.code !== 0)
|
|
2004
|
+
return null;
|
|
2005
|
+
const first = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)[0];
|
|
2006
|
+
return first ?? null;
|
|
2007
|
+
}
|
|
2008
|
+
function resolveShimmedCli(absolute, nodePath = process4.execPath) {
|
|
2009
|
+
if (!fs8.existsSync(absolute))
|
|
2010
|
+
return null;
|
|
2011
|
+
if (/\.(?:mjs|cjs|js)$/.test(absolute))
|
|
2012
|
+
return { program: nodePath, args: [absolute], cliEntry: absolute, shimPath: null, source: "entry" };
|
|
2013
|
+
const shimTarget = /^#\s*cmd-shim-target=(.+)$/m.exec(readText(absolute) ?? "")?.[1]?.trim();
|
|
2014
|
+
if (shimTarget !== void 0 && fs8.existsSync(shimTarget))
|
|
2015
|
+
return { program: nodePath, args: [shimTarget], cliEntry: shimTarget, shimPath: absolute, source: "shim-target" };
|
|
2016
|
+
return { program: absolute, args: [], cliEntry: null, shimPath: absolute, source: "shim" };
|
|
2017
|
+
}
|
|
2018
|
+
function homeHostedEnv(facts, launch, extra = {}) {
|
|
2019
|
+
const dirs = [
|
|
2020
|
+
launch.shimPath === null ? null : path7.dirname(launch.shimPath),
|
|
2021
|
+
path7.dirname(process4.execPath),
|
|
2022
|
+
...(process4.env.PATH ?? "").split(path7.delimiter),
|
|
2023
|
+
"/usr/local/bin",
|
|
2024
|
+
"/usr/bin",
|
|
2025
|
+
"/bin"
|
|
2026
|
+
].filter((dir) => typeof dir === "string" && dir.length > 0);
|
|
2027
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2028
|
+
const pathValue = dirs.filter((dir) => {
|
|
2029
|
+
if (seen.has(dir))
|
|
2030
|
+
return false;
|
|
2031
|
+
seen.add(dir);
|
|
2032
|
+
return true;
|
|
2033
|
+
}).join(path7.delimiter);
|
|
2034
|
+
const env = {
|
|
2035
|
+
PATH: pathValue,
|
|
2036
|
+
HOME: process4.env.HOME ?? process4.env.USERPROFILE ?? "",
|
|
2037
|
+
HHOSTED_HOME: facts.home,
|
|
2038
|
+
...extra
|
|
2039
|
+
};
|
|
2040
|
+
if (facts.projectDir)
|
|
2041
|
+
env.HHOSTED_PROJECT = facts.projectDir;
|
|
2042
|
+
return env;
|
|
2043
|
+
}
|
|
2044
|
+
function buildHomeHostedBootSpec(facts, launch, options) {
|
|
2045
|
+
const logDir = path7.join(facts.home, ".logs");
|
|
2046
|
+
fs8.mkdirSync(logDir, { recursive: true });
|
|
2047
|
+
const args = [...launch.args, "up", "--foreground", "--home", facts.home];
|
|
2048
|
+
if (facts.projectDir)
|
|
2049
|
+
args.push("--project", facts.projectDir);
|
|
2050
|
+
return {
|
|
2051
|
+
command: launch.program,
|
|
2052
|
+
args,
|
|
2053
|
+
cwd: facts.projectDir ?? facts.home,
|
|
2054
|
+
env: homeHostedEnv(facts, launch),
|
|
2055
|
+
marker: options.marker ?? "managed by dsh-home-hosted",
|
|
2056
|
+
unitName: "home-hosted",
|
|
2057
|
+
label: "home-hosted control panel",
|
|
2058
|
+
logDir
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
// src/home-hosted/dsh-entry.ts
|
|
2063
|
+
function detectProfile(argv, env = {}) {
|
|
2064
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
2065
|
+
const arg = argv[index];
|
|
2066
|
+
if (arg === "--profile" && typeof argv[index + 1] === "string" && !argv[index + 1].startsWith("-"))
|
|
2067
|
+
return argv[index + 1];
|
|
2068
|
+
if (arg?.startsWith("--profile="))
|
|
2069
|
+
return arg.slice("--profile=".length);
|
|
2070
|
+
}
|
|
2071
|
+
const dir = env.DSH_PROFILE_DIR;
|
|
2072
|
+
if (typeof dir === "string" && dir.length > 0) {
|
|
2073
|
+
const base = path8.basename(dir);
|
|
2074
|
+
if (base.length > 0 && base !== "." && base !== path8.sep)
|
|
2075
|
+
return base;
|
|
2076
|
+
}
|
|
2077
|
+
const app = argv.slice(2).find((candidate) => candidate !== void 0 && !candidate.startsWith("-"));
|
|
2078
|
+
if (app !== void 0 && app.length > 0)
|
|
2079
|
+
return app;
|
|
2080
|
+
const fromEnv = env.DSH_PROFILE;
|
|
2081
|
+
return typeof fromEnv === "string" && fromEnv.length > 0 ? fromEnv : "web";
|
|
2082
|
+
}
|
|
2083
|
+
async function resolveDshLaunch() {
|
|
2084
|
+
const fromArgv = process5.argv[1];
|
|
2085
|
+
if (typeof fromArgv === "string" && /\.[cm]?js$/.test(fromArgv) && path8.isAbsolute(fromArgv))
|
|
2086
|
+
return { program: process5.execPath, args: [fromArgv], cliEntry: fromArgv, shimPath: null, source: "entry" };
|
|
2087
|
+
const found = await which("dsh");
|
|
2088
|
+
return found === null ? null : resolveShimmedCli(found, process5.execPath);
|
|
2089
|
+
}
|
|
2090
|
+
function buildDshEntry(facts) {
|
|
2091
|
+
const launch = facts.launch;
|
|
2092
|
+
const entryArgs = launch?.args ?? [];
|
|
2093
|
+
const profile = facts.profile === null || facts.profile === void 0 ? "web" : facts.profile;
|
|
2094
|
+
const appArgs = [];
|
|
2095
|
+
if (facts.port !== null) {
|
|
2096
|
+
appArgs.push(
|
|
2097
|
+
"--port",
|
|
2098
|
+
"{port}",
|
|
2099
|
+
"--host",
|
|
2100
|
+
"{host}",
|
|
2101
|
+
"--no-open",
|
|
2102
|
+
"--trusted-host",
|
|
2103
|
+
`localhost:{port}`
|
|
2104
|
+
);
|
|
2105
|
+
if (facts.host === "0.0.0.0")
|
|
2106
|
+
appArgs.push("--trusted-host", "{lanIp}:{port}");
|
|
2107
|
+
}
|
|
2108
|
+
const launcherArgs = profile === "web" ? ["web"] : ["--profile", profile];
|
|
2109
|
+
const args = [...entryArgs, ...launcherArgs, ...appArgs];
|
|
2110
|
+
return {
|
|
2111
|
+
id: facts.id,
|
|
2112
|
+
label: "DSH web",
|
|
2113
|
+
enabled: true,
|
|
2114
|
+
autostart: true,
|
|
2115
|
+
command: launch?.program ?? "dsh",
|
|
2116
|
+
args,
|
|
2117
|
+
bind: facts.host === "0.0.0.0" ? "lan" : "local",
|
|
2118
|
+
port: facts.port,
|
|
2119
|
+
cwd: process5.cwd(),
|
|
2120
|
+
env: {},
|
|
2121
|
+
dataEnvs: { DSH_HOME: facts.dshHome },
|
|
2122
|
+
onPortConflict: "kill",
|
|
2123
|
+
stop: { killPortHolders: true },
|
|
2124
|
+
health: {
|
|
2125
|
+
enabled: true,
|
|
2126
|
+
mode: "http",
|
|
2127
|
+
http: { path: "/", method: "GET", expectStatusBelow: 400, expectBody: "" }
|
|
2128
|
+
}
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// src/home-hosted/entries.ts
|
|
2133
|
+
var DEFAULT_ON_PORT_CONFLICT = "kill";
|
|
2134
|
+
function defaultIntent(id) {
|
|
2135
|
+
return {
|
|
2136
|
+
id,
|
|
2137
|
+
autostart: true,
|
|
2138
|
+
onPortConflict: DEFAULT_ON_PORT_CONFLICT,
|
|
2139
|
+
stopKillPortHolders: true
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
function ownedPatch(intent, live) {
|
|
2143
|
+
const stop = live?.stop ?? {};
|
|
2144
|
+
return {
|
|
2145
|
+
autostart: intent.autostart,
|
|
2146
|
+
onPortConflict: intent.onPortConflict,
|
|
2147
|
+
stop: { ...stop, killPortHolders: intent.stopKillPortHolders }
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
function ownedDrift(live, intent) {
|
|
2151
|
+
if (live === null)
|
|
2152
|
+
return ["missing entry"];
|
|
2153
|
+
const drift = [];
|
|
2154
|
+
if (live.autostart !== intent.autostart)
|
|
2155
|
+
drift.push("autostart");
|
|
2156
|
+
if (live.onPortConflict !== intent.onPortConflict)
|
|
2157
|
+
drift.push("onPortConflict");
|
|
2158
|
+
const killPortHolders = live.stop?.killPortHolders;
|
|
2159
|
+
if (killPortHolders !== intent.stopKillPortHolders)
|
|
2160
|
+
drift.push("stop.killPortHolders");
|
|
2161
|
+
return drift;
|
|
2162
|
+
}
|
|
2163
|
+
function snapshotOwned(live) {
|
|
2164
|
+
const snapshot = { id: live.id };
|
|
2165
|
+
if (live.autostart !== void 0)
|
|
2166
|
+
snapshot.autostart = live.autostart;
|
|
2167
|
+
if (live.onPortConflict !== void 0)
|
|
2168
|
+
snapshot.onPortConflict = live.onPortConflict;
|
|
2169
|
+
if (typeof live.stop === "object" && live.stop !== null) {
|
|
2170
|
+
const killPortHolders = live.stop.killPortHolders;
|
|
2171
|
+
if (typeof killPortHolders === "boolean")
|
|
2172
|
+
snapshot.stop = { killPortHolders };
|
|
2173
|
+
}
|
|
2174
|
+
return snapshot;
|
|
2175
|
+
}
|
|
2176
|
+
function restorePatch(live, snapshot) {
|
|
2177
|
+
const stop = live.stop ?? {};
|
|
2178
|
+
const previous = snapshot?.stop ?? {};
|
|
2179
|
+
return {
|
|
2180
|
+
autostart: snapshot?.autostart ?? false,
|
|
2181
|
+
onPortConflict: snapshot?.onPortConflict ?? "block",
|
|
2182
|
+
stop: {
|
|
2183
|
+
...stop,
|
|
2184
|
+
killPortHolders: typeof previous.killPortHolders === "boolean" ? previous.killPortHolders : false
|
|
2185
|
+
}
|
|
2186
|
+
};
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
// src/home-hosted/resolve.ts
|
|
2190
|
+
import { createRequire } from "node:module";
|
|
2191
|
+
import fs9 from "node:fs";
|
|
2192
|
+
import path9 from "node:path";
|
|
2193
|
+
import process6 from "node:process";
|
|
2194
|
+
var EXPECTED_RANGE = "^0.6.1";
|
|
2195
|
+
var MIN_SUPPORTED_VERSION = "0.4.1";
|
|
2196
|
+
var MIN_KILL_VERSION = "0.6.0";
|
|
2197
|
+
function pinnedManifestPath() {
|
|
2198
|
+
try {
|
|
2199
|
+
return createRequire(import.meta.url).resolve("home-hosted/package.json");
|
|
2200
|
+
} catch {
|
|
2201
|
+
return null;
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
function binEntryFromManifest(manifestPath) {
|
|
2205
|
+
try {
|
|
2206
|
+
const manifest = JSON.parse(fs9.readFileSync(manifestPath, "utf8"));
|
|
2207
|
+
const declared = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.["home-hosted"] ?? Object.values(manifest.bin ?? {})[0];
|
|
2208
|
+
return typeof declared === "string" && declared.length > 0 ? path9.resolve(path9.dirname(manifestPath), declared) : null;
|
|
2209
|
+
} catch {
|
|
2210
|
+
return null;
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
function parseVersion(output) {
|
|
2214
|
+
const match = /(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(output ?? "");
|
|
2215
|
+
return match === null ? null : `${match[1]}.${match[2]}.${match[3]}${match[4] === void 0 ? "" : `-${match[4]}`}`;
|
|
2216
|
+
}
|
|
2217
|
+
function compareVersions(a, b) {
|
|
2218
|
+
const split = (value) => {
|
|
2219
|
+
const [core, pre = null] = value.split("-", 2);
|
|
2220
|
+
return { parts: (core ?? "").split(".").map((part) => Number.parseInt(part, 10) || 0), pre };
|
|
2221
|
+
};
|
|
2222
|
+
const left = split(a);
|
|
2223
|
+
const right = split(b);
|
|
2224
|
+
for (let index = 0; index < 3; index += 1) {
|
|
2225
|
+
const diff = (left.parts[index] ?? 0) - (right.parts[index] ?? 0);
|
|
2226
|
+
if (diff !== 0)
|
|
2227
|
+
return diff < 0 ? -1 : 1;
|
|
2228
|
+
}
|
|
2229
|
+
if (left.pre === right.pre)
|
|
2230
|
+
return 0;
|
|
2231
|
+
if (left.pre === null)
|
|
2232
|
+
return 1;
|
|
2233
|
+
if (right.pre === null)
|
|
2234
|
+
return -1;
|
|
2235
|
+
return left.pre < right.pre ? -1 : 1;
|
|
2236
|
+
}
|
|
2237
|
+
function detailFor(source, version, supported, prefer, unusableOverride = null) {
|
|
2238
|
+
const shown = version ?? "unknown version";
|
|
2239
|
+
switch (source) {
|
|
2240
|
+
case "config":
|
|
2241
|
+
return unusableOverride === null ? `from the configured command (${shown})` : `the configured command could not be used: ${unusableOverride} does not exist (or is not a file this plugin can run); fix homeHostedCommand, or clear it to let the plugin choose`;
|
|
2242
|
+
case "dependency":
|
|
2243
|
+
return supported ? `the pinned dependency (${shown})` : `the pinned dependency is below the oldest supported release (${MIN_SUPPORTED_VERSION})`;
|
|
2244
|
+
case "path":
|
|
2245
|
+
if (!supported)
|
|
2246
|
+
return `the global install on PATH (${shown}) is below the oldest supported release (${MIN_SUPPORTED_VERSION})`;
|
|
2247
|
+
return prefer === "global" ? `the global install on PATH (${shown})` : `a global install on PATH (${shown}), not the pinned dependency ${EXPECTED_RANGE}`;
|
|
2248
|
+
default:
|
|
2249
|
+
return "no home-hosted CLI found: install the plugin with its dependencies, or put home-hosted on PATH";
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
async function defaultReadVersion(launch, timeoutMs) {
|
|
2253
|
+
const result = await run(launch.program, [...launch.args, "--version"], { timeoutMs });
|
|
2254
|
+
return parseVersion(result.stdout) ?? parseVersion(result.stderr);
|
|
2255
|
+
}
|
|
2256
|
+
async function resolveCli(options = {}) {
|
|
2257
|
+
const timeoutMs = options.timeoutMs ?? 5e3;
|
|
2258
|
+
const prefer = options.prefer ?? "pinned";
|
|
2259
|
+
const readVersion = options.readVersion ?? ((launch) => defaultReadVersion(launch, timeoutMs));
|
|
2260
|
+
const locate = options.findOnPath ?? which;
|
|
2261
|
+
const override = options.override?.trim();
|
|
2262
|
+
let unusableOverride = null;
|
|
2263
|
+
if (override !== void 0 && override.length > 0) {
|
|
2264
|
+
const launch = resolveShimmedCli(path9.resolve(override));
|
|
2265
|
+
if (launch === null) {
|
|
2266
|
+
unusableOverride = override;
|
|
2267
|
+
} else {
|
|
2268
|
+
const version2 = await readVersion(launch);
|
|
2269
|
+
const supported2 = version2 === null || compareVersions(version2, MIN_SUPPORTED_VERSION) >= 0;
|
|
2270
|
+
return {
|
|
2271
|
+
launch,
|
|
2272
|
+
status: {
|
|
2273
|
+
source: "config",
|
|
2274
|
+
path: launch.cliEntry ?? launch.shimPath ?? launch.program,
|
|
2275
|
+
version: version2,
|
|
2276
|
+
expectedRange: EXPECTED_RANGE,
|
|
2277
|
+
supported: supported2,
|
|
2278
|
+
prefer,
|
|
2279
|
+
dependency: null,
|
|
2280
|
+
global: null,
|
|
2281
|
+
detail: detailFor("config", version2, supported2, prefer)
|
|
2282
|
+
}
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
const manifest = (options.packageManifest ?? pinnedManifestPath)();
|
|
2287
|
+
const dependencyEntry = manifest === null ? null : binEntryFromManifest(manifest);
|
|
2288
|
+
let dependency = null;
|
|
2289
|
+
if (dependencyEntry !== null && fs9.existsSync(dependencyEntry)) {
|
|
2290
|
+
let version2 = null;
|
|
2291
|
+
try {
|
|
2292
|
+
version2 = JSON.parse(fs9.readFileSync(manifest, "utf8")).version ?? null;
|
|
2293
|
+
} catch {
|
|
2294
|
+
version2 = null;
|
|
2295
|
+
}
|
|
2296
|
+
dependency = {
|
|
2297
|
+
candidate: { source: "dependency", path: dependencyEntry, version: version2 },
|
|
2298
|
+
launch: { program: process6.execPath, args: [dependencyEntry], cliEntry: dependencyEntry, shimPath: null, source: "entry" }
|
|
2299
|
+
};
|
|
2300
|
+
}
|
|
2301
|
+
let global = null;
|
|
2302
|
+
const found = await locate("home-hosted") ?? await locate("hh");
|
|
2303
|
+
if (found !== null) {
|
|
2304
|
+
const launch = resolveShimmedCli(path9.resolve(found));
|
|
2305
|
+
if (launch !== null) {
|
|
2306
|
+
global = {
|
|
2307
|
+
candidate: { source: "path", path: launch.cliEntry ?? launch.shimPath ?? launch.program, version: await readVersion(launch) },
|
|
2308
|
+
launch
|
|
2309
|
+
};
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
const order = prefer === "global" ? [global, dependency] : [dependency, global];
|
|
2313
|
+
const chosen = order.find((item) => item !== null) ?? null;
|
|
2314
|
+
const dependencySummary = dependency?.candidate ?? null;
|
|
2315
|
+
const globalSummary = global?.candidate ?? null;
|
|
2316
|
+
if (unusableOverride !== null) {
|
|
2317
|
+
return {
|
|
2318
|
+
launch: null,
|
|
2319
|
+
status: {
|
|
2320
|
+
source: "config",
|
|
2321
|
+
path: unusableOverride,
|
|
2322
|
+
version: null,
|
|
2323
|
+
expectedRange: EXPECTED_RANGE,
|
|
2324
|
+
supported: false,
|
|
2325
|
+
prefer,
|
|
2326
|
+
dependency: dependencySummary,
|
|
2327
|
+
global: globalSummary,
|
|
2328
|
+
detail: detailFor("config", null, false, prefer, unusableOverride)
|
|
2329
|
+
}
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
if (chosen === null) {
|
|
2333
|
+
return {
|
|
2334
|
+
launch: null,
|
|
2335
|
+
status: {
|
|
2336
|
+
source: "none",
|
|
2337
|
+
path: null,
|
|
2338
|
+
version: null,
|
|
2339
|
+
expectedRange: EXPECTED_RANGE,
|
|
2340
|
+
supported: false,
|
|
2341
|
+
prefer,
|
|
2342
|
+
dependency: dependencySummary,
|
|
2343
|
+
global: globalSummary,
|
|
2344
|
+
detail: detailFor("none", null, false, prefer)
|
|
2345
|
+
}
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
const version = chosen.candidate.version;
|
|
2349
|
+
const supported = version === null || compareVersions(version, MIN_SUPPORTED_VERSION) >= 0;
|
|
2350
|
+
return {
|
|
2351
|
+
launch: chosen.launch,
|
|
2352
|
+
status: {
|
|
2353
|
+
source: chosen.candidate.source,
|
|
2354
|
+
path: chosen.candidate.path,
|
|
2355
|
+
version,
|
|
2356
|
+
expectedRange: EXPECTED_RANGE,
|
|
2357
|
+
supported,
|
|
2358
|
+
prefer,
|
|
2359
|
+
dependency: dependencySummary,
|
|
2360
|
+
global: globalSummary,
|
|
2361
|
+
detail: detailFor(chosen.candidate.source, version, supported, prefer)
|
|
2362
|
+
}
|
|
2363
|
+
};
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
// src/home-hosted/launcher.ts
|
|
2367
|
+
import fs10 from "node:fs";
|
|
2368
|
+
import path10 from "node:path";
|
|
2369
|
+
import process7 from "node:process";
|
|
2370
|
+
function launcherDir(stateDir) {
|
|
2371
|
+
return path10.join(stateDir, "bin");
|
|
2372
|
+
}
|
|
2373
|
+
function launcherPath(stateDir) {
|
|
2374
|
+
return path10.join(launcherDir(stateDir), "home-hosted.mjs");
|
|
2375
|
+
}
|
|
2376
|
+
function launcherRecordPath(stateDir) {
|
|
2377
|
+
return path10.join(launcherDir(stateDir), "resolved.json");
|
|
2378
|
+
}
|
|
2379
|
+
function buildLauncherSource(options) {
|
|
2380
|
+
const marker = options.marker ?? "managed by dsh-home-hosted";
|
|
2381
|
+
const record = launcherRecordPath(options.stateDir);
|
|
2382
|
+
return `#!/usr/bin/env node
|
|
2383
|
+
// ${marker} \u2014 rewritten on every plugin start; do not edit.
|
|
2384
|
+
import fs from 'node:fs'
|
|
2385
|
+
import path from 'node:path'
|
|
2386
|
+
import process from 'node:process'
|
|
2387
|
+
import { spawnSync } from 'node:child_process'
|
|
2388
|
+
|
|
2389
|
+
const DSH_HOME = ${JSON.stringify(options.dshHome)}
|
|
2390
|
+
const RECORD = ${JSON.stringify(record)}
|
|
2391
|
+
const MIN_VERSION = ${JSON.stringify(options.minVersion)}
|
|
2392
|
+
const PLUGIN_ROOT = ${JSON.stringify(options.pluginRoot ?? null)}
|
|
2393
|
+
|
|
2394
|
+
function readRecord() {
|
|
2395
|
+
try { return JSON.parse(fs.readFileSync(RECORD, 'utf8')) } catch { return null }
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
function versionOf(entry) {
|
|
2399
|
+
let dir = path.dirname(entry)
|
|
2400
|
+
for (let hop = 0; hop < 4; hop += 1) {
|
|
2401
|
+
try {
|
|
2402
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
|
|
2403
|
+
if (typeof manifest.version === 'string') return manifest.version
|
|
2404
|
+
} catch {}
|
|
2405
|
+
dir = path.dirname(dir)
|
|
2406
|
+
}
|
|
2407
|
+
return null
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
function compare(a, b) {
|
|
2411
|
+
const split = value => {
|
|
2412
|
+
const [core, pre = null] = String(value).split('-', 2)
|
|
2413
|
+
return { parts: core.split('.').map(part => Number.parseInt(part, 10) || 0), pre }
|
|
2414
|
+
}
|
|
2415
|
+
const left = split(a); const right = split(b)
|
|
2416
|
+
for (let i = 0; i < 3; i += 1) {
|
|
2417
|
+
const diff = (left.parts[i] ?? 0) - (right.parts[i] ?? 0)
|
|
2418
|
+
if (diff !== 0) return diff < 0 ? -1 : 1
|
|
2419
|
+
}
|
|
2420
|
+
if (left.pre === right.pre) return 0
|
|
2421
|
+
if (left.pre === null) return 1
|
|
2422
|
+
if (right.pre === null) return -1
|
|
2423
|
+
return left.pre < right.pre ? -1 : 1
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
function collect(out, entry) {
|
|
2427
|
+
if (typeof entry === 'string' && entry.length > 0 && fs.existsSync(entry)) out.push(entry)
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
function collectRoot(root, out) {
|
|
2431
|
+
collect(out, path.join(root, 'node_modules', 'home-hosted', 'bin', 'home-hosted.mjs'))
|
|
2432
|
+
const store = path.join(root, 'node_modules', '.pnpm')
|
|
2433
|
+
let names = []
|
|
2434
|
+
try { names = fs.readdirSync(store) } catch { return }
|
|
2435
|
+
for (const name of names) {
|
|
2436
|
+
if (name.startsWith('home-hosted@') || name.startsWith('dsh-home-hosted@'))
|
|
2437
|
+
collect(out, path.join(store, name, 'node_modules', 'home-hosted', 'bin', 'home-hosted.mjs'))
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
function candidates() {
|
|
2442
|
+
const out = []
|
|
2443
|
+
const record = readRecord()
|
|
2444
|
+
if (record && typeof record.entry === 'string') collect(out, record.entry)
|
|
2445
|
+
if (PLUGIN_ROOT !== null) collectRoot(PLUGIN_ROOT, out)
|
|
2446
|
+
try {
|
|
2447
|
+
for (const name of fs.readdirSync(path.join(DSH_HOME, 'profiles')))
|
|
2448
|
+
collectRoot(path.join(DSH_HOME, 'profiles', name), out)
|
|
2449
|
+
} catch {}
|
|
2450
|
+
collectRoot(path.join(DSH_HOME), out)
|
|
2451
|
+
// Last resort: a global install on the entry's own PATH.
|
|
2452
|
+
for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
|
|
2453
|
+
if (dir.length === 0) continue
|
|
2454
|
+
collect(out, path.join(dir, 'home-hosted'))
|
|
2455
|
+
collect(out, path.join(dir, 'home-hosted.cmd'))
|
|
2456
|
+
}
|
|
2457
|
+
return out
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
function choose() {
|
|
2461
|
+
const found = candidates().map(entry => ({ entry, version: versionOf(entry) }))
|
|
2462
|
+
const supported = found.filter(item => item.version !== null && compare(item.version, MIN_VERSION) >= 0)
|
|
2463
|
+
supported.sort((a, b) => compare(b.version, a.version))
|
|
2464
|
+
return (supported[0] ?? found[0] ?? null)?.entry ?? null
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
const entry = choose()
|
|
2468
|
+
if (entry === null) {
|
|
2469
|
+
console.error('[dsh-home-hosted] no home-hosted CLI found (looked in ' + DSH_HOME + ' and PATH). Reinstall the plugin with its dependencies, or set homeHostedCommand in the plugin row.')
|
|
2470
|
+
process.exit(1)
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
const args = process.argv.slice(2)
|
|
2474
|
+
const isScript = /\\.(mjs|cjs|js)$/.test(entry)
|
|
2475
|
+
const result = isScript
|
|
2476
|
+
? spawnSync(process.execPath, [entry, ...args], { stdio: 'inherit' })
|
|
2477
|
+
: spawnSync(entry, args, { stdio: 'inherit', shell: process.platform === 'win32' && /\\.(cmd|bat)$/i.test(entry) })
|
|
2478
|
+
process.exit(typeof result.status === 'number' ? result.status : 1)
|
|
2479
|
+
`;
|
|
2480
|
+
}
|
|
2481
|
+
function writeLauncher(options) {
|
|
2482
|
+
const file = launcherPath(options.stateDir);
|
|
2483
|
+
const source = buildLauncherSource(options);
|
|
2484
|
+
const previous = fs10.existsSync(file) ? fs10.readFileSync(file, "utf8") : null;
|
|
2485
|
+
const changed = previous !== source;
|
|
2486
|
+
if (changed) {
|
|
2487
|
+
writeFileAtomic(file, source, 493);
|
|
2488
|
+
fs10.chmodSync(file, 493);
|
|
2489
|
+
}
|
|
2490
|
+
writeJsonAtomic(launcherRecordPath(options.stateDir), {
|
|
2491
|
+
entry: options.resolvedEntry,
|
|
2492
|
+
version: options.resolvedVersion ?? null,
|
|
2493
|
+
pluginRoot: options.pluginRoot ?? null,
|
|
2494
|
+
writtenAt: Date.now()
|
|
2495
|
+
}, 384);
|
|
2496
|
+
return { path: file, changed };
|
|
2497
|
+
}
|
|
2498
|
+
async function preflightLauncher(stateDir, timeoutMs = 1e4) {
|
|
2499
|
+
const file = launcherPath(stateDir);
|
|
2500
|
+
if (!fs10.existsSync(file))
|
|
2501
|
+
return null;
|
|
2502
|
+
const result = process7.platform === "win32" ? await run(process7.execPath, [file, "--version"], { timeoutMs }) : await run(file, ["--version"], { timeoutMs });
|
|
2503
|
+
return parseVersion(result.stdout) ?? parseVersion(result.stderr);
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
// src/home-hosted/panel-control.ts
|
|
2507
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
2508
|
+
import fs11 from "node:fs";
|
|
2509
|
+
import path11 from "node:path";
|
|
2510
|
+
import process8 from "node:process";
|
|
2511
|
+
function cliArgs(deps, command) {
|
|
2512
|
+
const args = [...deps.launch?.args ?? [], command, "--home", deps.home];
|
|
2513
|
+
if (deps.projectDir)
|
|
2514
|
+
args.push("--project", deps.projectDir);
|
|
2515
|
+
return args;
|
|
2516
|
+
}
|
|
2517
|
+
async function runCli(deps, command) {
|
|
2518
|
+
const launch = deps.launch;
|
|
2519
|
+
if (launch === null)
|
|
2520
|
+
return { code: null, stdout: "", stderr: "no home-hosted CLI is available" };
|
|
2521
|
+
return await run(launch.program, cliArgs(deps, command), {
|
|
2522
|
+
env: deps.env,
|
|
2523
|
+
timeoutMs: deps.timeoutMs ?? 9e4
|
|
2524
|
+
});
|
|
2525
|
+
}
|
|
2526
|
+
async function startPanel(deps) {
|
|
2527
|
+
const result = await runCli(deps, "up");
|
|
2528
|
+
if (result.code !== 0) {
|
|
2529
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `the CLI exited ${String(result.code)}`;
|
|
2530
|
+
return { ok: false, detail };
|
|
2531
|
+
}
|
|
2532
|
+
const match = /https?:\/\/[^\s]+/.exec(result.stdout);
|
|
2533
|
+
return { ok: true, detail: "the panel is answering", url: match?.[0] ?? null };
|
|
2534
|
+
}
|
|
2535
|
+
function takeoverHelperPath(stateDir) {
|
|
2536
|
+
return path11.join(launcherDir(stateDir), "panel-takeover.mjs");
|
|
2537
|
+
}
|
|
2538
|
+
function takeoverLogPath(stateDir) {
|
|
2539
|
+
return path11.join(launcherDir(stateDir), "panel-takeover.log");
|
|
2540
|
+
}
|
|
2541
|
+
function buildTakeoverSource(deps, oldPid, marker = "managed by dsh-home-hosted") {
|
|
2542
|
+
const program = deps.launch?.program ?? process8.execPath;
|
|
2543
|
+
const args = deps.launch === null ? [] : [...deps.launch.args, "up", "--home", deps.home, ...deps.projectDir ? ["--project", deps.projectDir] : []];
|
|
2544
|
+
const downArgs = [...deps.launch?.args ?? [], "down", "--home", deps.home];
|
|
2545
|
+
return `#!/usr/bin/env node
|
|
2546
|
+
// ${marker} \u2014 replaces the panel; do not edit.
|
|
2547
|
+
import fs from 'node:fs'
|
|
2548
|
+
import { spawnSync } from 'node:child_process'
|
|
2549
|
+
|
|
2550
|
+
const OLD_PID = ${JSON.stringify(oldPid)}
|
|
2551
|
+
const DOWN_ARGS = ${JSON.stringify(downArgs)}
|
|
2552
|
+
const PROGRAM = ${JSON.stringify(program)}
|
|
2553
|
+
const ARGS = ${JSON.stringify(args)}
|
|
2554
|
+
const ENV = ${JSON.stringify(deps.env)}
|
|
2555
|
+
const LOG = ${JSON.stringify(takeoverLogPath(deps.stateDir))}
|
|
2556
|
+
|
|
2557
|
+
function log(line) {
|
|
2558
|
+
try { fs.appendFileSync(LOG, new Date().toISOString() + ' ' + line + '\\n') } catch {}
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
function alive(pid) {
|
|
2562
|
+
if (typeof pid !== 'number') return false
|
|
2563
|
+
try { process.kill(pid, 0); return true } catch { return false }
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
2567
|
+
|
|
2568
|
+
log('stopping the answering panel: ' + PROGRAM + ' ' + DOWN_ARGS.join(' '))
|
|
2569
|
+
const down = spawnSync(PROGRAM, DOWN_ARGS, { env: { ...process.env, ...ENV }, stdio: 'ignore' })
|
|
2570
|
+
log('down exited ' + String(down.status))
|
|
2571
|
+
|
|
2572
|
+
for (let i = 0; i < 40 && alive(OLD_PID); i += 1)
|
|
2573
|
+
await sleep(250)
|
|
2574
|
+
|
|
2575
|
+
if (alive(OLD_PID)) {
|
|
2576
|
+
log('still alive; sending SIGTERM')
|
|
2577
|
+
try { process.kill(OLD_PID, 'SIGTERM') } catch {}
|
|
2578
|
+
for (let i = 0; i < 40 && alive(OLD_PID); i += 1)
|
|
2579
|
+
await sleep(250)
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
if (alive(OLD_PID)) {
|
|
2583
|
+
log('still alive; sending SIGKILL')
|
|
2584
|
+
try { process.kill(OLD_PID, 'SIGKILL') } catch {}
|
|
2585
|
+
for (let i = 0; i < 20 && alive(OLD_PID); i += 1)
|
|
2586
|
+
await sleep(250)
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
log('starting ' + PROGRAM + ' ' + ARGS.join(' '))
|
|
2590
|
+
const result = spawnSync(PROGRAM, ARGS, { env: { ...process.env, ...ENV }, stdio: 'ignore' })
|
|
2591
|
+
log('started with exit ' + String(result.status))
|
|
2592
|
+
`;
|
|
2593
|
+
}
|
|
2594
|
+
function writeTakeoverHelper(deps, oldPid) {
|
|
2595
|
+
const file = takeoverHelperPath(deps.stateDir);
|
|
2596
|
+
writeFileAtomic(file, buildTakeoverSource(deps, oldPid), 493);
|
|
2597
|
+
fs11.chmodSync(file, 493);
|
|
2598
|
+
return file;
|
|
2599
|
+
}
|
|
2600
|
+
function spawnTakeover(deps, oldPid) {
|
|
2601
|
+
const helper = writeTakeoverHelper(deps, oldPid);
|
|
2602
|
+
const child = spawn2(process8.execPath, [helper], { detached: true, stdio: "ignore" });
|
|
2603
|
+
child.unref();
|
|
2604
|
+
return {
|
|
2605
|
+
ok: true,
|
|
2606
|
+
detail: "replacing the panel now; this page will disconnect and come back under the preferred copy"
|
|
2607
|
+
};
|
|
2608
|
+
}
|
|
2609
|
+
async function installGlobal(range, options = {}) {
|
|
2610
|
+
const execute = options.run ?? run;
|
|
2611
|
+
const usePnpm = options.pnpm ?? true;
|
|
2612
|
+
const result = usePnpm ? await execute("pnpm", ["add", "-g", `home-hosted@${range}`], { timeoutMs: 3e5 }) : await execute("npm", ["install", "-g", `home-hosted@${range}`], { timeoutMs: 3e5 });
|
|
2613
|
+
const output = `${result.stdout}
|
|
2614
|
+
${result.stderr}`.trim();
|
|
2615
|
+
if (result.code !== 0)
|
|
2616
|
+
return { ok: false, detail: result.error ?? `the installer exited ${String(result.code)}`, output };
|
|
2617
|
+
return { ok: true, detail: `installed home-hosted@${range} globally`, output };
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
// src/home-hosted/panel.ts
|
|
2621
|
+
var PanelError = class extends Error {
|
|
2622
|
+
constructor(message, code, status = null, detail = void 0) {
|
|
2623
|
+
super(message);
|
|
2624
|
+
this.code = code;
|
|
2625
|
+
this.status = status;
|
|
2626
|
+
this.detail = detail;
|
|
2627
|
+
this.name = "PanelError";
|
|
2628
|
+
}
|
|
2629
|
+
};
|
|
2630
|
+
var PanelClient = class {
|
|
2631
|
+
baseUrl;
|
|
2632
|
+
token;
|
|
2633
|
+
timeoutMs;
|
|
2634
|
+
constructor(options) {
|
|
2635
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
2636
|
+
this.token = options.token;
|
|
2637
|
+
this.timeoutMs = options.timeoutMs ?? 1e4;
|
|
2638
|
+
}
|
|
2639
|
+
async request(method, path15, body) {
|
|
2640
|
+
let response;
|
|
2641
|
+
try {
|
|
2642
|
+
response = await fetch(`${this.baseUrl}${path15}`, {
|
|
2643
|
+
method,
|
|
2644
|
+
headers: {
|
|
2645
|
+
authorization: `Bearer ${this.token}`,
|
|
2646
|
+
...body === void 0 ? {} : { "content-type": "application/json" }
|
|
2647
|
+
},
|
|
2648
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
2649
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
2650
|
+
});
|
|
2651
|
+
} catch (error) {
|
|
2652
|
+
throw new PanelError(
|
|
2653
|
+
error instanceof Error ? error.message : String(error),
|
|
2654
|
+
"PANEL_UNREACHABLE"
|
|
2655
|
+
);
|
|
2656
|
+
}
|
|
2657
|
+
const text = await response.text();
|
|
2658
|
+
let parsed = null;
|
|
2659
|
+
try {
|
|
2660
|
+
parsed = text.length > 0 ? JSON.parse(text) : null;
|
|
2661
|
+
} catch {
|
|
2662
|
+
throw new PanelError(`the panel answered ${response.status} with a non-JSON body`, "PANEL_BAD_RESPONSE", response.status);
|
|
2663
|
+
}
|
|
2664
|
+
if (!response.ok) {
|
|
2665
|
+
const record = parsed;
|
|
2666
|
+
const message = typeof record?.message === "string" ? record.message : `the panel answered ${response.status}`;
|
|
2667
|
+
const code = typeof record?.code === "string" ? record.code : "PANEL_ERROR";
|
|
2668
|
+
throw new PanelError(message, code, response.status, record?.detail);
|
|
2669
|
+
}
|
|
2670
|
+
return parsed;
|
|
2671
|
+
}
|
|
2672
|
+
async listServers() {
|
|
2673
|
+
const answer2 = await this.request("GET", "/api/servers");
|
|
2674
|
+
return answer2.servers;
|
|
2675
|
+
}
|
|
2676
|
+
async getServer(id) {
|
|
2677
|
+
const answer2 = await this.request("GET", `/api/servers/${encodeURIComponent(id)}`);
|
|
2678
|
+
return answer2.server;
|
|
2679
|
+
}
|
|
2680
|
+
async createServer(entry) {
|
|
2681
|
+
const answer2 = await this.request("POST", "/api/servers", entry);
|
|
2682
|
+
return answer2.server;
|
|
2683
|
+
}
|
|
2684
|
+
async updateServer(id, patch) {
|
|
2685
|
+
const answer2 = await this.request("PATCH", `/api/servers/${encodeURIComponent(id)}`, patch);
|
|
2686
|
+
return answer2.server;
|
|
2687
|
+
}
|
|
2688
|
+
async deleteServer(id) {
|
|
2689
|
+
await this.request("DELETE", `/api/servers/${encodeURIComponent(id)}`);
|
|
2690
|
+
}
|
|
2691
|
+
async startServer(id) {
|
|
2692
|
+
await this.request("POST", `/api/servers/${encodeURIComponent(id)}/start`);
|
|
2693
|
+
}
|
|
2694
|
+
async stopServer(id) {
|
|
2695
|
+
await this.request("POST", `/api/servers/${encodeURIComponent(id)}/stop`);
|
|
2696
|
+
}
|
|
2697
|
+
async restartServer(id) {
|
|
2698
|
+
await this.request("POST", `/api/servers/${encodeURIComponent(id)}/restart`);
|
|
2699
|
+
}
|
|
2700
|
+
/** Re-list the port's listeners and stop what is not the panel's own tree. */
|
|
2701
|
+
async freePort(id) {
|
|
2702
|
+
return await this.request("POST", `/api/servers/${encodeURIComponent(id)}/free-port`);
|
|
2703
|
+
}
|
|
2704
|
+
};
|
|
2705
|
+
async function verifyToken(baseUrl, token, timeoutMs = 5e3) {
|
|
2706
|
+
try {
|
|
2707
|
+
await new PanelClient({ baseUrl, token, timeoutMs }).listServers();
|
|
2708
|
+
return true;
|
|
2709
|
+
} catch {
|
|
2710
|
+
return false;
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
// src/home-hosted/runtime.ts
|
|
2715
|
+
import http from "node:http";
|
|
2716
|
+
import https from "node:https";
|
|
2717
|
+
import process9 from "node:process";
|
|
2718
|
+
function readRuntime(home) {
|
|
2719
|
+
const raw = readJson(runtimeFile(home));
|
|
2720
|
+
if (raw === null)
|
|
2721
|
+
return null;
|
|
2722
|
+
const url = typeof raw.url === "string" ? raw.url : null;
|
|
2723
|
+
const pid = typeof raw.pid === "number" ? raw.pid : null;
|
|
2724
|
+
if (url === null || pid === null)
|
|
2725
|
+
return null;
|
|
2726
|
+
return {
|
|
2727
|
+
version: typeof raw.version === "string" ? raw.version : "unknown",
|
|
2728
|
+
pid,
|
|
2729
|
+
url: url.replace(/\/+$/, ""),
|
|
2730
|
+
probeUrl: typeof raw.probeUrl === "string" ? raw.probeUrl : void 0,
|
|
2731
|
+
protocol: typeof raw.protocol === "string" ? raw.protocol : void 0,
|
|
2732
|
+
port: typeof raw.port === "number" ? raw.port : 0,
|
|
2733
|
+
bindHost: typeof raw.bindHost === "string" ? raw.bindHost : void 0,
|
|
2734
|
+
projectDir: typeof raw.projectDir === "string" ? raw.projectDir : void 0,
|
|
2735
|
+
dataRoot: typeof raw.dataRoot === "string" ? raw.dataRoot : void 0,
|
|
2736
|
+
configPath: typeof raw.configPath === "string" ? raw.configPath : void 0,
|
|
2737
|
+
logFile: typeof raw.logFile === "string" ? raw.logFile : void 0
|
|
2738
|
+
};
|
|
2739
|
+
}
|
|
2740
|
+
function pidAlive(pid) {
|
|
2741
|
+
try {
|
|
2742
|
+
process9.kill(pid, 0);
|
|
2743
|
+
return true;
|
|
2744
|
+
} catch {
|
|
2745
|
+
return false;
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
async function probePanel(url, timeoutMs = 2e3) {
|
|
2749
|
+
const target = `${url.replace(/\/+$/, "")}/healthz`;
|
|
2750
|
+
return await new Promise((resolve) => {
|
|
2751
|
+
let settled = false;
|
|
2752
|
+
const done = (value) => {
|
|
2753
|
+
if (settled)
|
|
2754
|
+
return;
|
|
2755
|
+
settled = true;
|
|
2756
|
+
resolve(value);
|
|
2757
|
+
};
|
|
2758
|
+
let parsed;
|
|
2759
|
+
try {
|
|
2760
|
+
parsed = new URL(target);
|
|
2761
|
+
} catch {
|
|
2762
|
+
done(false);
|
|
2763
|
+
return;
|
|
2764
|
+
}
|
|
2765
|
+
const transport = parsed.protocol === "https:" ? https : http;
|
|
2766
|
+
const request = transport.get(
|
|
2767
|
+
{
|
|
2768
|
+
hostname: parsed.hostname,
|
|
2769
|
+
port: parsed.port,
|
|
2770
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
2771
|
+
timeout: timeoutMs,
|
|
2772
|
+
rejectUnauthorized: false
|
|
2773
|
+
},
|
|
2774
|
+
(response) => {
|
|
2775
|
+
response.resume();
|
|
2776
|
+
done(typeof response.statusCode === "number");
|
|
2777
|
+
}
|
|
2778
|
+
);
|
|
2779
|
+
request.on("timeout", () => {
|
|
2780
|
+
request.destroy();
|
|
2781
|
+
done(false);
|
|
2782
|
+
});
|
|
2783
|
+
request.on("error", () => done(false));
|
|
2784
|
+
});
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
// src/home-hosted/token.ts
|
|
2788
|
+
import { randomBytes } from "node:crypto";
|
|
2789
|
+
import path12 from "node:path";
|
|
2790
|
+
function storedTokenPath(stateDir) {
|
|
2791
|
+
return path12.join(stateDir, "panel-token");
|
|
2792
|
+
}
|
|
2793
|
+
function readStoredToken(stateDir) {
|
|
2794
|
+
const text = readText(storedTokenPath(stateDir))?.trim();
|
|
2795
|
+
return text !== void 0 && text.length > 0 ? text : null;
|
|
2796
|
+
}
|
|
2797
|
+
function storeToken(stateDir, token) {
|
|
2798
|
+
writeFileAtomic(storedTokenPath(stateDir), `${token}
|
|
2799
|
+
`, 384);
|
|
2800
|
+
}
|
|
2801
|
+
function generateToken() {
|
|
2802
|
+
return randomBytes(32).toString("base64url");
|
|
2803
|
+
}
|
|
2804
|
+
function apiTokenEnrolled(home) {
|
|
2805
|
+
const secrets = readJson(secretsFile(home));
|
|
2806
|
+
return secrets !== null && secrets.apiToken !== null && secrets.apiToken !== void 0;
|
|
2807
|
+
}
|
|
2808
|
+
async function ensureToken(options) {
|
|
2809
|
+
const stored = readStoredToken(options.stateDir);
|
|
2810
|
+
if (stored !== null)
|
|
2811
|
+
return { token: stored, enrolled: false, detail: "using the stored panel token" };
|
|
2812
|
+
if (apiTokenEnrolled(options.home)) {
|
|
2813
|
+
return {
|
|
2814
|
+
token: null,
|
|
2815
|
+
enrolled: false,
|
|
2816
|
+
detail: "home-hosted already has an API token, and this plugin does not have it; run `home-hosted set-token --generate` and store that token, or clear it and re-enable"
|
|
2817
|
+
};
|
|
2818
|
+
}
|
|
2819
|
+
const token = generateToken();
|
|
2820
|
+
const result = await options.exec(["--home", options.home, "set-token"], { HHOSTED_TOKEN: token });
|
|
2821
|
+
if (result.code !== 0) {
|
|
2822
|
+
return {
|
|
2823
|
+
token: null,
|
|
2824
|
+
enrolled: false,
|
|
2825
|
+
detail: `could not enrol an API token: ${result.stderr.trim() || result.stdout.trim() || `exit ${String(result.code)}`}`
|
|
2826
|
+
};
|
|
2827
|
+
}
|
|
2828
|
+
storeToken(options.stateDir, token);
|
|
2829
|
+
return { token, enrolled: true, detail: "enrolled a new panel API token" };
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
// src/service.ts
|
|
2833
|
+
var ENTRY_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
|
2834
|
+
function pluginRoot() {
|
|
2835
|
+
try {
|
|
2836
|
+
return path13.dirname(path13.dirname(fileURLToPath(import.meta.url)));
|
|
2837
|
+
} catch {
|
|
2838
|
+
return null;
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
var HomeHostedError = class extends Error {
|
|
2842
|
+
constructor(message, code) {
|
|
2843
|
+
super(message);
|
|
2844
|
+
this.code = code;
|
|
2845
|
+
this.name = "HomeHostedError";
|
|
2846
|
+
}
|
|
2847
|
+
};
|
|
2848
|
+
var HomeHostedService = class extends Service {
|
|
2849
|
+
constructor(ctx, options) {
|
|
2850
|
+
super(ctx, "homeHosted");
|
|
2851
|
+
this.options = options;
|
|
2852
|
+
this.snapshotsFile = path13.join(options.stateDir, "snapshots.json");
|
|
2853
|
+
}
|
|
2854
|
+
clientCache = null;
|
|
2855
|
+
tokenDetail = "";
|
|
2856
|
+
snapshotsFile;
|
|
2857
|
+
// -------------------------------------------------------------------------
|
|
2858
|
+
// Facts
|
|
2859
|
+
// -------------------------------------------------------------------------
|
|
2860
|
+
runtime() {
|
|
2861
|
+
return readRuntime(this.options.home);
|
|
2862
|
+
}
|
|
2863
|
+
/** The entry id this very process was started as, when the panel supervises us. */
|
|
2864
|
+
selfEntryId() {
|
|
2865
|
+
const id = process10.env.HHOSTED_SERVER_ID;
|
|
2866
|
+
return id !== void 0 && id.length > 0 ? id : null;
|
|
2867
|
+
}
|
|
2868
|
+
/** What the config's `meta.writtenBy` names: the panel release when we can read it. */
|
|
2869
|
+
writtenBy() {
|
|
2870
|
+
return this.runtime()?.version ?? "dsh-home-hosted";
|
|
2871
|
+
}
|
|
2872
|
+
webServer() {
|
|
2873
|
+
const service = this.ctx.get("webServer");
|
|
2874
|
+
const port = typeof service?.port === "number" ? service.port : null;
|
|
2875
|
+
const host = typeof service?.host === "string" ? service.host : "127.0.0.1";
|
|
2876
|
+
return { port, host };
|
|
2877
|
+
}
|
|
2878
|
+
cliCache = null;
|
|
2879
|
+
/**
|
|
2880
|
+
* Resolve the preferred CLI, refresh the stable launcher a boot entry runs, and
|
|
2881
|
+
* preflight that launcher the same way the entry will invoke it.
|
|
2882
|
+
*/
|
|
2883
|
+
async cli() {
|
|
2884
|
+
const prefer = this.options.settings.get().cli.prefer;
|
|
2885
|
+
if (this.cliCache !== null && this.cliCache.until > Date.now() && this.cliCache.prefer === prefer)
|
|
2886
|
+
return this.cliCache;
|
|
2887
|
+
const resolution = await resolveCli({ override: this.options.homeHostedCommand, prefer });
|
|
2888
|
+
let launcher = null;
|
|
2889
|
+
let launcherVersion = null;
|
|
2890
|
+
if (resolution.launch !== null) {
|
|
2891
|
+
launcher = writeLauncher({
|
|
2892
|
+
stateDir: this.options.stateDir,
|
|
2893
|
+
dshHome: dshHome(),
|
|
2894
|
+
resolvedEntry: resolution.launch.cliEntry ?? resolution.launch.shimPath ?? null,
|
|
2895
|
+
resolvedVersion: resolution.status.version,
|
|
2896
|
+
pluginRoot: pluginRoot(),
|
|
2897
|
+
minVersion: MIN_SUPPORTED_VERSION
|
|
2898
|
+
}).path;
|
|
2899
|
+
launcherVersion = await preflightLauncher(this.options.stateDir);
|
|
2900
|
+
}
|
|
2901
|
+
this.cliCache = { prefer, resolution, launcher, launcherVersion, until: Date.now() + 6e4 };
|
|
2902
|
+
return this.cliCache;
|
|
2903
|
+
}
|
|
2904
|
+
async panelControlDeps() {
|
|
2905
|
+
const { resolution } = await this.cli();
|
|
2906
|
+
const launch = resolution.launch;
|
|
2907
|
+
const projectDir = process10.env.HHOSTED_PROJECT ?? this.runtime()?.projectDir ?? null;
|
|
2908
|
+
return {
|
|
2909
|
+
launch,
|
|
2910
|
+
home: this.options.home,
|
|
2911
|
+
projectDir,
|
|
2912
|
+
stateDir: this.options.stateDir,
|
|
2913
|
+
env: launch === null ? {} : homeHostedEnv({ home: this.options.home, projectDir }, launch)
|
|
2914
|
+
};
|
|
2915
|
+
}
|
|
2916
|
+
async cliExec(args, env) {
|
|
2917
|
+
if (this.options.execCli !== void 0)
|
|
2918
|
+
return await this.options.execCli(args, env);
|
|
2919
|
+
const { resolution } = await this.cli();
|
|
2920
|
+
const launch = resolution.launch;
|
|
2921
|
+
if (launch === null)
|
|
2922
|
+
throw new HomeHostedError(resolution.status.detail, "CLI_NOT_FOUND");
|
|
2923
|
+
return await run(launch.program, [...launch.args, ...args], { env, timeoutMs: 3e4 });
|
|
2924
|
+
}
|
|
2925
|
+
// -------------------------------------------------------------------------
|
|
2926
|
+
// Panel
|
|
2927
|
+
// -------------------------------------------------------------------------
|
|
2928
|
+
async panelStatus() {
|
|
2929
|
+
const runtime = this.runtime();
|
|
2930
|
+
const stored = readStoredToken(this.options.stateDir);
|
|
2931
|
+
const enrolledOnDisk = apiTokenEnrolled(this.options.home);
|
|
2932
|
+
const reachable = runtime !== null && (pidAlive(runtime.pid) || await probePanel(runtime.url));
|
|
2933
|
+
const answered = runtime !== null && await probePanel(runtime.url);
|
|
2934
|
+
const token = stored !== null ? "enrolled" : enrolledOnDisk ? "present" : "absent";
|
|
2935
|
+
const writeVia = answered && stored !== null ? "api" : "file";
|
|
2936
|
+
return {
|
|
2937
|
+
home: this.options.home,
|
|
2938
|
+
reachable: answered,
|
|
2939
|
+
url: runtime?.url ?? null,
|
|
2940
|
+
version: runtime?.version ?? null,
|
|
2941
|
+
pid: runtime?.pid ?? null,
|
|
2942
|
+
writeVia,
|
|
2943
|
+
token,
|
|
2944
|
+
detail: answered ? stored !== null ? this.tokenDetail || "the panel is answering and this plugin holds a token" : enrolledOnDisk ? "the panel is answering, but home-hosted already holds an API token this plugin does not have" : "the panel is answering; a token will be enrolled on the first write" : runtime === null ? "no run.json: the panel is not running, so entries are written straight to servers.config.json" : reachable ? "the panel process is alive but is not answering" : "the panel is not running"
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
async tryClient() {
|
|
2948
|
+
if (this.clientCache !== null && this.clientCache.until > Date.now())
|
|
2949
|
+
return this.clientCache.client;
|
|
2950
|
+
const runtime = this.runtime();
|
|
2951
|
+
if (runtime === null || !await probePanel(runtime.url))
|
|
2952
|
+
return null;
|
|
2953
|
+
const ensured = await ensureToken({
|
|
2954
|
+
home: this.options.home,
|
|
2955
|
+
stateDir: this.options.stateDir,
|
|
2956
|
+
exec: (args, env) => this.cliExec(args, env)
|
|
2957
|
+
});
|
|
2958
|
+
this.tokenDetail = ensured.detail;
|
|
2959
|
+
if (ensured.token === null)
|
|
2960
|
+
return null;
|
|
2961
|
+
if (!await verifyToken(runtime.url, ensured.token))
|
|
2962
|
+
return null;
|
|
2963
|
+
const client = new PanelClient({ baseUrl: runtime.url, token: ensured.token });
|
|
2964
|
+
this.clientCache = { client, until: Date.now() + 3e4 };
|
|
2965
|
+
return client;
|
|
2966
|
+
}
|
|
2967
|
+
async requireClient() {
|
|
2968
|
+
const client = await this.tryClient();
|
|
2969
|
+
if (client === null) {
|
|
2970
|
+
const status = await this.panelStatus();
|
|
2971
|
+
throw new HomeHostedError(status.detail, "PANEL_UNAVAILABLE");
|
|
2972
|
+
}
|
|
2973
|
+
return client;
|
|
2974
|
+
}
|
|
2975
|
+
// -------------------------------------------------------------------------
|
|
2976
|
+
// Snapshot bookkeeping
|
|
2977
|
+
// -------------------------------------------------------------------------
|
|
2978
|
+
snapshots() {
|
|
2979
|
+
return readJson(this.snapshotsFile) ?? {};
|
|
2980
|
+
}
|
|
2981
|
+
saveSnapshots(snapshots) {
|
|
2982
|
+
writeJsonAtomic(this.snapshotsFile, snapshots, 384);
|
|
2983
|
+
}
|
|
2984
|
+
// -------------------------------------------------------------------------
|
|
2985
|
+
// Entries
|
|
2986
|
+
// -------------------------------------------------------------------------
|
|
2987
|
+
async liveEntries() {
|
|
2988
|
+
const map = /* @__PURE__ */ new Map();
|
|
2989
|
+
const client = await this.tryClient();
|
|
2990
|
+
if (client !== null) {
|
|
2991
|
+
try {
|
|
2992
|
+
for (const view of await client.listServers())
|
|
2993
|
+
map.set(view.id, { view, config: view.config });
|
|
2994
|
+
return map;
|
|
2995
|
+
} catch {
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
const read = readConfig(this.options.home);
|
|
2999
|
+
for (const entry of read.raw?.servers ?? [])
|
|
3000
|
+
map.set(entry.id, { view: { id: entry.id, status: "unknown", pid: null, url: null, config: entry }, config: entry });
|
|
3001
|
+
return map;
|
|
3002
|
+
}
|
|
3003
|
+
async createEntry(intent, patch, live) {
|
|
3004
|
+
if (intent.id !== this.options.defaultEntryId)
|
|
3005
|
+
return null;
|
|
3006
|
+
const dsh = await resolveDshLaunch();
|
|
3007
|
+
const { port, host } = this.webServer();
|
|
3008
|
+
const generated = buildDshEntry({
|
|
3009
|
+
id: intent.id,
|
|
3010
|
+
port,
|
|
3011
|
+
host,
|
|
3012
|
+
profile: detectProfile(process10.argv, process10.env),
|
|
3013
|
+
dshHome: process10.env.DSH_HOME ?? dshHome(),
|
|
3014
|
+
launch: dsh
|
|
3015
|
+
});
|
|
3016
|
+
return { ...live ?? generated, ...generated, ...patch };
|
|
3017
|
+
}
|
|
3018
|
+
async writeOwned(intent) {
|
|
3019
|
+
const live = (await this.liveEntries()).get(intent.id) ?? null;
|
|
3020
|
+
const snapshots = this.snapshots();
|
|
3021
|
+
if (snapshots[intent.id] === void 0) {
|
|
3022
|
+
snapshots[intent.id] = live === null ? { id: intent.id } : snapshotOwned(live.config);
|
|
3023
|
+
this.saveSnapshots(snapshots);
|
|
3024
|
+
}
|
|
3025
|
+
const patch = ownedPatch(intent, live?.config ?? null);
|
|
3026
|
+
const client = await this.tryClient();
|
|
3027
|
+
if (client !== null) {
|
|
3028
|
+
if (live !== null) {
|
|
3029
|
+
await client.updateServer(intent.id, patch);
|
|
3030
|
+
return;
|
|
3031
|
+
}
|
|
3032
|
+
const entry2 = await this.createEntry(intent, patch, null);
|
|
3033
|
+
if (entry2 === null) {
|
|
3034
|
+
throw new HomeHostedError(
|
|
3035
|
+
`no server "${intent.id}" exists; create it first, then this plugin can adopt its autostart and conflict policy`,
|
|
3036
|
+
"ENTRY_MISSING"
|
|
3037
|
+
);
|
|
3038
|
+
}
|
|
3039
|
+
await client.createServer(entry2);
|
|
3040
|
+
return;
|
|
3041
|
+
}
|
|
3042
|
+
const read = readConfig(this.options.home);
|
|
3043
|
+
if (read.error !== null)
|
|
3044
|
+
throw new HomeHostedError(read.error, "CONFIG_UNREADABLE");
|
|
3045
|
+
let raw = read.raw ?? {};
|
|
3046
|
+
if (findEntry(raw, intent.id) !== null) {
|
|
3047
|
+
writeConfig(this.options.home, patchEntry(raw, intent.id, patch), this.writtenBy());
|
|
3048
|
+
return;
|
|
3049
|
+
}
|
|
3050
|
+
const entry = await this.createEntry(intent, patch, null);
|
|
3051
|
+
if (entry === null)
|
|
3052
|
+
throw new HomeHostedError(`no server "${intent.id}" exists in ${this.options.home}/servers.config.json`, "ENTRY_MISSING");
|
|
3053
|
+
if (await this.panelRunning()) {
|
|
3054
|
+
writeConfig(this.options.home, upsertEntry(raw, { ...entry, autostart: false }), this.writtenBy());
|
|
3055
|
+
raw = readConfig(this.options.home).raw ?? raw;
|
|
3056
|
+
writeConfig(this.options.home, patchEntry(raw, intent.id, { autostart: intent.autostart }), this.writtenBy());
|
|
3057
|
+
return;
|
|
3058
|
+
}
|
|
3059
|
+
writeConfig(this.options.home, upsertEntry(raw, entry), this.writtenBy());
|
|
3060
|
+
}
|
|
3061
|
+
async panelRunning() {
|
|
3062
|
+
const runtime = this.runtime();
|
|
3063
|
+
return runtime !== null && (pidAlive(runtime.pid) || await probePanel(runtime.url));
|
|
3064
|
+
}
|
|
3065
|
+
/**
|
|
3066
|
+
* The version whose schema will parse what we write: the answering panel, or
|
|
3067
|
+
* the CLI that will parse it next. `kill` did not exist before 0.6.0, and an
|
|
3068
|
+
* older panel refuses to *boot* with it — so this is checked before any write.
|
|
3069
|
+
*/
|
|
3070
|
+
async configVersion() {
|
|
3071
|
+
const runtime = this.runtime();
|
|
3072
|
+
if (runtime !== null && runtime.version !== null && (pidAlive(runtime.pid) || await probePanel(runtime.url)))
|
|
3073
|
+
return runtime.version;
|
|
3074
|
+
const { resolution } = await this.cli();
|
|
3075
|
+
return resolution.status.version;
|
|
3076
|
+
}
|
|
3077
|
+
async assertPolicySupported(intent) {
|
|
3078
|
+
if (intent.onPortConflict !== "kill")
|
|
3079
|
+
return;
|
|
3080
|
+
const version = await this.configVersion();
|
|
3081
|
+
if (version === null || compareVersions(version, MIN_KILL_VERSION) >= 0)
|
|
3082
|
+
return;
|
|
3083
|
+
const { resolution } = await this.cli();
|
|
3084
|
+
throw new HomeHostedError(
|
|
3085
|
+
`the home-hosted that would parse this config is ${version}, which has no "kill" policy (added in ${MIN_KILL_VERSION}); replace it with ${resolution.status.version ?? "the pinned copy"} from this page, or set the entry's on-port-conflict policy to block`,
|
|
3086
|
+
"KILL_UNSUPPORTED"
|
|
3087
|
+
);
|
|
3088
|
+
}
|
|
3089
|
+
async applyIntents(intents) {
|
|
3090
|
+
for (const raw of intents) {
|
|
3091
|
+
if (!ENTRY_ID_PATTERN.test(raw.id))
|
|
3092
|
+
throw new HomeHostedError(`"${raw.id}" is not a valid server id`, "INVALID_ID");
|
|
3093
|
+
const intent = {
|
|
3094
|
+
id: raw.id,
|
|
3095
|
+
autostart: raw.autostart === true,
|
|
3096
|
+
onPortConflict: raw.onPortConflict ?? "kill",
|
|
3097
|
+
stopKillPortHolders: raw.stopKillPortHolders !== false
|
|
3098
|
+
};
|
|
3099
|
+
await this.assertPolicySupported(intent);
|
|
3100
|
+
await this.writeOwned(intent);
|
|
3101
|
+
const current = this.options.settings.get();
|
|
3102
|
+
const entries = current.entries.filter((entry) => entry.id !== intent.id);
|
|
3103
|
+
entries.push(intent);
|
|
3104
|
+
this.options.settings.update({ entries });
|
|
3105
|
+
}
|
|
3106
|
+
return await this.entriesStatus();
|
|
3107
|
+
}
|
|
3108
|
+
async restoreEntry(id) {
|
|
3109
|
+
const live = (await this.liveEntries()).get(id) ?? null;
|
|
3110
|
+
if (live === null)
|
|
3111
|
+
throw new HomeHostedError(`no server "${id}" exists`, "ENTRY_MISSING");
|
|
3112
|
+
const snapshots = this.snapshots();
|
|
3113
|
+
if (snapshots[id] === void 0)
|
|
3114
|
+
throw new HomeHostedError(`"${id}" was never adopted by this plugin, so there is nothing to restore; adopt it first`, "NOT_ADOPTED");
|
|
3115
|
+
const patch = restorePatch(live.config, snapshots[id] ?? null);
|
|
3116
|
+
const client = await this.tryClient();
|
|
3117
|
+
if (client !== null)
|
|
3118
|
+
await client.updateServer(id, patch);
|
|
3119
|
+
else {
|
|
3120
|
+
const read = readConfig(this.options.home);
|
|
3121
|
+
if (read.error !== null || read.raw === null)
|
|
3122
|
+
throw new HomeHostedError(read.error ?? "the config file is unreadable", "CONFIG_UNREADABLE");
|
|
3123
|
+
writeConfig(this.options.home, patchEntry(read.raw, id, patch), this.writtenBy());
|
|
3124
|
+
}
|
|
3125
|
+
delete snapshots[id];
|
|
3126
|
+
this.saveSnapshots(snapshots);
|
|
3127
|
+
const current = this.options.settings.get();
|
|
3128
|
+
this.options.settings.update({ entries: current.entries.filter((entry) => entry.id !== id) });
|
|
3129
|
+
return await this.entriesStatus();
|
|
3130
|
+
}
|
|
3131
|
+
async entriesStatus() {
|
|
3132
|
+
const settings = this.options.settings.get();
|
|
3133
|
+
const live = await this.liveEntries();
|
|
3134
|
+
const snapshots = this.snapshots();
|
|
3135
|
+
const ids = /* @__PURE__ */ new Set([this.options.defaultEntryId, ...settings.entries.map((entry) => entry.id)]);
|
|
3136
|
+
return [...ids].map((id) => {
|
|
3137
|
+
const intent = this.options.settings.intentFor(id);
|
|
3138
|
+
const entry = live.get(id) ?? null;
|
|
3139
|
+
return {
|
|
3140
|
+
intent,
|
|
3141
|
+
exists: entry !== null,
|
|
3142
|
+
managed: snapshots[id] !== void 0,
|
|
3143
|
+
drift: entry === null ? ["missing entry"] : ownedDrift(entry.config, intent),
|
|
3144
|
+
live: entry?.view ?? null,
|
|
3145
|
+
snapshot: snapshots[id] ?? null
|
|
3146
|
+
};
|
|
3147
|
+
});
|
|
3148
|
+
}
|
|
3149
|
+
// -------------------------------------------------------------------------
|
|
3150
|
+
// Boot autostart
|
|
3151
|
+
// -------------------------------------------------------------------------
|
|
3152
|
+
ladder() {
|
|
3153
|
+
if (this.options.createLadder !== void 0)
|
|
3154
|
+
return this.options.createLadder();
|
|
3155
|
+
return createBootLadder({ env: process10.env });
|
|
3156
|
+
}
|
|
3157
|
+
async bootSpec() {
|
|
3158
|
+
const { resolution, launcher } = await this.cli();
|
|
3159
|
+
const launch = resolution.launch;
|
|
3160
|
+
if (launch === null)
|
|
3161
|
+
return null;
|
|
3162
|
+
const runtime = this.runtime();
|
|
3163
|
+
const spec = buildHomeHostedBootSpec(
|
|
3164
|
+
{
|
|
3165
|
+
home: this.options.home,
|
|
3166
|
+
projectDir: process10.env.HHOSTED_PROJECT ?? runtime?.projectDir ?? null,
|
|
3167
|
+
version: runtime?.version ?? null
|
|
3168
|
+
},
|
|
3169
|
+
launch,
|
|
3170
|
+
{ stateDir: this.options.stateDir }
|
|
3171
|
+
);
|
|
3172
|
+
if (launcher !== null) {
|
|
3173
|
+
const cliArgs2 = spec.args.slice(launch.args.length);
|
|
3174
|
+
if (process10.platform === "win32") {
|
|
3175
|
+
spec.command = process10.execPath;
|
|
3176
|
+
spec.args = [launcher, ...cliArgs2];
|
|
3177
|
+
} else {
|
|
3178
|
+
spec.command = launcher;
|
|
3179
|
+
spec.args = cliArgs2;
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
return spec;
|
|
3183
|
+
}
|
|
3184
|
+
async bootStatus(mechanism) {
|
|
3185
|
+
const platform = process10.platform === "linux" ? "linux" : process10.platform === "darwin" ? "darwin" : process10.platform === "win32" ? "win32" : "other";
|
|
3186
|
+
const spec = await this.bootSpec();
|
|
3187
|
+
if (spec === null) {
|
|
3188
|
+
return {
|
|
3189
|
+
platform,
|
|
3190
|
+
mechanism: null,
|
|
3191
|
+
recommended: null,
|
|
3192
|
+
state: "unsupported",
|
|
3193
|
+
bootCapable: false,
|
|
3194
|
+
privileged: false,
|
|
3195
|
+
unitPath: null,
|
|
3196
|
+
commands: [],
|
|
3197
|
+
detail: "no home-hosted CLI is available, so no boot entry can be generated; install the plugin with its dependencies",
|
|
3198
|
+
candidates: []
|
|
3199
|
+
};
|
|
3200
|
+
}
|
|
3201
|
+
const settings = this.options.settings.get();
|
|
3202
|
+
const requested = mechanism ?? (settings.autostart.mechanism === "auto" ? void 0 : settings.autostart.mechanism);
|
|
3203
|
+
return await this.ladder().status(spec, requested);
|
|
3204
|
+
}
|
|
3205
|
+
async installBoot(mechanism) {
|
|
3206
|
+
const spec = await this.bootSpec();
|
|
3207
|
+
if (spec === null)
|
|
3208
|
+
throw new HomeHostedError((await this.cli()).resolution.status.detail, "CLI_NOT_FOUND");
|
|
3209
|
+
const result = await this.ladder().install(spec, mechanism);
|
|
3210
|
+
if (result.ok) {
|
|
3211
|
+
this.options.settings.update({
|
|
3212
|
+
autostart: {
|
|
3213
|
+
enabled: true,
|
|
3214
|
+
mechanism: mechanism ?? result.mechanism ?? this.options.settings.get().autostart.mechanism
|
|
3215
|
+
}
|
|
3216
|
+
});
|
|
3217
|
+
}
|
|
3218
|
+
return { result, status: await this.bootStatus(mechanism) };
|
|
3219
|
+
}
|
|
3220
|
+
async uninstallBoot(mechanism) {
|
|
3221
|
+
const spec = await this.bootSpec();
|
|
3222
|
+
if (spec === null)
|
|
3223
|
+
throw new HomeHostedError((await this.cli()).resolution.status.detail, "CLI_NOT_FOUND");
|
|
3224
|
+
const result = await this.ladder().uninstall(spec, mechanism);
|
|
3225
|
+
if (result.ok)
|
|
3226
|
+
this.options.settings.update({ autostart: { ...this.options.settings.get().autostart, enabled: false } });
|
|
3227
|
+
return { result, status: await this.bootStatus(mechanism) };
|
|
3228
|
+
}
|
|
3229
|
+
// -------------------------------------------------------------------------
|
|
3230
|
+
// Panel lifecycle
|
|
3231
|
+
// -------------------------------------------------------------------------
|
|
3232
|
+
/** Out-of-the-box start: run the preferred CLI's `up`, which detaches itself. */
|
|
3233
|
+
async startPanelNow() {
|
|
3234
|
+
const { resolution } = await this.cli();
|
|
3235
|
+
const deps = await this.panelControlDeps();
|
|
3236
|
+
if (deps.launch === null)
|
|
3237
|
+
throw new HomeHostedError(resolution.status.detail, "CLI_NOT_FOUND");
|
|
3238
|
+
const runtime = this.runtime();
|
|
3239
|
+
if (runtime !== null && await probePanel(runtime.url)) {
|
|
3240
|
+
return { ok: true, detail: "a panel is already answering", url: runtime.url, version: runtime.version };
|
|
3241
|
+
}
|
|
3242
|
+
const result = await startPanel(deps);
|
|
3243
|
+
this.clientCache = null;
|
|
3244
|
+
return result;
|
|
3245
|
+
}
|
|
3246
|
+
/**
|
|
3247
|
+
* Replace an answering panel with the preferred copy.
|
|
3248
|
+
*
|
|
3249
|
+
* That stops the servers the old panel supervises — this process included — so
|
|
3250
|
+
* the work is handed to a detached helper and the guard demands that this
|
|
3251
|
+
* session is an adopted, autostarting entry the new panel will bring back.
|
|
3252
|
+
*/
|
|
3253
|
+
async takeoverPanel(force = false) {
|
|
3254
|
+
const { resolution } = await this.cli();
|
|
3255
|
+
const deps = await this.panelControlDeps();
|
|
3256
|
+
if (deps.launch === null)
|
|
3257
|
+
throw new HomeHostedError(resolution.status.detail, "CLI_NOT_FOUND");
|
|
3258
|
+
const runtime = this.runtime();
|
|
3259
|
+
if (runtime === null || !await probePanel(runtime.url))
|
|
3260
|
+
return await this.startPanelNow();
|
|
3261
|
+
if (runtime.version !== null && resolution.status.version !== null && runtime.version === resolution.status.version) {
|
|
3262
|
+
return { ok: true, detail: `the answering panel is already ${runtime.version}`, url: runtime.url, version: runtime.version };
|
|
3263
|
+
}
|
|
3264
|
+
if (!force) {
|
|
3265
|
+
const id = this.selfEntryId();
|
|
3266
|
+
const live = id === null ? null : (await this.liveEntries()).get(id) ?? null;
|
|
3267
|
+
if (id === null || live === null || live.config.autostart !== true) {
|
|
3268
|
+
throw new HomeHostedError(
|
|
3269
|
+
"replacing the panel stops every server it supervises, including this session, and nothing would start it again: adopt this entry with autostart first, or pass force",
|
|
3270
|
+
"TAKEOVER_UNSAFE"
|
|
3271
|
+
);
|
|
3272
|
+
}
|
|
3273
|
+
}
|
|
3274
|
+
return spawnTakeover(deps, runtime.pid);
|
|
3275
|
+
}
|
|
3276
|
+
/** Install the pinned range globally, so the `global` preference has a copy to run. */
|
|
3277
|
+
async installGlobalCli() {
|
|
3278
|
+
const result = await installGlobal(EXPECTED_RANGE);
|
|
3279
|
+
this.cliCache = null;
|
|
3280
|
+
return { ok: result.ok, detail: result.detail, output: result.output };
|
|
3281
|
+
}
|
|
3282
|
+
/** Re-assert an entry that is already installed: a node or CLI upgrade moves the
|
|
3283
|
+
* paths a unit was written with, and the fix is to rewrite it.
|
|
3284
|
+
*
|
|
3285
|
+
* Never installs one that is not there. Installing is a deliberate act — it can
|
|
3286
|
+
* need root and it makes this machine start something at boot — so it happens on
|
|
3287
|
+
* an explicit click or an approved tool call, not as a side effect of startup.
|
|
3288
|
+
*/
|
|
3289
|
+
async reconcile() {
|
|
3290
|
+
if (!this.options.settings.get().autostart.enabled)
|
|
3291
|
+
return;
|
|
3292
|
+
const status = await this.bootStatus();
|
|
3293
|
+
if (status.state !== "enabled-failing" && status.state !== "installed-disabled")
|
|
3294
|
+
return;
|
|
3295
|
+
const fileBacked = status.mechanism === "systemd-user" || status.mechanism === "systemd-system" || status.mechanism === "xdg-autostart" || status.mechanism === "launchd-agent" || status.mechanism === "launchd-daemon";
|
|
3296
|
+
if (fileBacked && (status.unitPath === null || !fs12.existsSync(status.unitPath)))
|
|
3297
|
+
return;
|
|
3298
|
+
try {
|
|
3299
|
+
await this.installBoot();
|
|
3300
|
+
} catch {
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
// -------------------------------------------------------------------------
|
|
3304
|
+
// Status
|
|
3305
|
+
// -------------------------------------------------------------------------
|
|
3306
|
+
async status() {
|
|
3307
|
+
const client = await this.tryClient();
|
|
3308
|
+
const panel = await this.panelStatus();
|
|
3309
|
+
const { resolution, launcher, launcherVersion } = await this.cli();
|
|
3310
|
+
const cliDetail = resolution.status.detail;
|
|
3311
|
+
const panelVersion = this.runtime()?.version ?? null;
|
|
3312
|
+
const cli = {
|
|
3313
|
+
...resolution.status,
|
|
3314
|
+
launcherPath: launcher,
|
|
3315
|
+
launcherVersion,
|
|
3316
|
+
// A panel left running from an older install is the usual reason to see
|
|
3317
|
+
// two versions on this page; say so instead of leaving it ambiguous.
|
|
3318
|
+
detail: panelVersion !== null && resolution.status.version !== null && panelVersion !== resolution.status.version ? `${cliDetail}; the running panel is ${panelVersion}` : cliDetail
|
|
3319
|
+
};
|
|
3320
|
+
let servers = [];
|
|
3321
|
+
let lastError = null;
|
|
3322
|
+
if (client !== null) {
|
|
3323
|
+
try {
|
|
3324
|
+
servers = await client.listServers();
|
|
3325
|
+
} catch (error) {
|
|
3326
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
3327
|
+
}
|
|
3328
|
+
} else if (panel.reachable) {
|
|
3329
|
+
lastError = panel.detail;
|
|
3330
|
+
}
|
|
3331
|
+
return {
|
|
3332
|
+
panel,
|
|
3333
|
+
boot: await this.bootStatus(),
|
|
3334
|
+
entries: await this.entriesStatus(),
|
|
3335
|
+
servers,
|
|
3336
|
+
settings: this.options.settings.get(),
|
|
3337
|
+
cli,
|
|
3338
|
+
lastError
|
|
3339
|
+
};
|
|
3340
|
+
}
|
|
3341
|
+
// -------------------------------------------------------------------------
|
|
3342
|
+
// Endpoint dispatch
|
|
3343
|
+
// -------------------------------------------------------------------------
|
|
3344
|
+
async call(endpoint, payload) {
|
|
3345
|
+
const input = payload ?? {};
|
|
3346
|
+
switch (endpoint) {
|
|
3347
|
+
case "status":
|
|
3348
|
+
return await this.status();
|
|
3349
|
+
case "settings.update": {
|
|
3350
|
+
const patch = input.patch ?? {};
|
|
3351
|
+
this.options.settings.update(patch);
|
|
3352
|
+
return await this.status();
|
|
3353
|
+
}
|
|
3354
|
+
case "servers.list":
|
|
3355
|
+
return await (await this.requireClient()).listServers();
|
|
3356
|
+
case "servers.get":
|
|
3357
|
+
return await (await this.requireClient()).getServer(String(input.id));
|
|
3358
|
+
case "servers.create": {
|
|
3359
|
+
const entry = input.entry;
|
|
3360
|
+
if (typeof entry?.id !== "string" || !ENTRY_ID_PATTERN.test(entry.id))
|
|
3361
|
+
throw new HomeHostedError("a server entry needs an id matching ^[a-z0-9][a-z0-9_-]*$", "INVALID_ID");
|
|
3362
|
+
return await (await this.requireClient()).createServer(entry);
|
|
3363
|
+
}
|
|
3364
|
+
case "servers.update":
|
|
3365
|
+
return await (await this.requireClient()).updateServer(String(input.id), input.patch ?? {});
|
|
3366
|
+
case "servers.delete": {
|
|
3367
|
+
const id = String(input.id);
|
|
3368
|
+
if (id === this.selfEntryId()) {
|
|
3369
|
+
throw new HomeHostedError(
|
|
3370
|
+
`"${id}" is the entry this very process runs as, and deleting it stops this session; pause it (autostart off) or restore it instead`,
|
|
3371
|
+
"SELF_ENTRY"
|
|
3372
|
+
);
|
|
3373
|
+
}
|
|
3374
|
+
await (await this.requireClient()).deleteServer(id);
|
|
3375
|
+
return { id };
|
|
3376
|
+
}
|
|
3377
|
+
case "servers.start":
|
|
3378
|
+
await (await this.requireClient()).startServer(String(input.id));
|
|
3379
|
+
return await this.entriesStatus();
|
|
3380
|
+
case "servers.stop":
|
|
3381
|
+
await (await this.requireClient()).stopServer(String(input.id));
|
|
3382
|
+
return await this.entriesStatus();
|
|
3383
|
+
case "servers.restart":
|
|
3384
|
+
await (await this.requireClient()).restartServer(String(input.id));
|
|
3385
|
+
return await this.entriesStatus();
|
|
3386
|
+
case "servers.freePort":
|
|
3387
|
+
return await (await this.requireClient()).freePort(String(input.id));
|
|
3388
|
+
case "entries.apply": {
|
|
3389
|
+
const intents = Array.isArray(input.intents) ? input.intents : [];
|
|
3390
|
+
return await this.applyIntents(intents);
|
|
3391
|
+
}
|
|
3392
|
+
case "entries.restore":
|
|
3393
|
+
return await this.restoreEntry(String(input.id));
|
|
3394
|
+
case "boot.install":
|
|
3395
|
+
return await this.installBoot(input.mechanism);
|
|
3396
|
+
case "boot.uninstall":
|
|
3397
|
+
return await this.uninstallBoot(input.mechanism);
|
|
3398
|
+
case "boot.verify":
|
|
3399
|
+
return await this.bootStatus();
|
|
3400
|
+
case "panel.start":
|
|
3401
|
+
return await this.startPanelNow();
|
|
3402
|
+
case "panel.takeover":
|
|
3403
|
+
return await this.takeoverPanel(input.force === true);
|
|
3404
|
+
case "cli.installGlobal":
|
|
3405
|
+
return await this.installGlobalCli();
|
|
3406
|
+
default:
|
|
3407
|
+
throw new HomeHostedError(`unknown endpoint "${String(endpoint)}"`, "UNKNOWN_ENDPOINT");
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
};
|
|
3411
|
+
|
|
3412
|
+
// src/rpc.ts
|
|
3413
|
+
function answer(endpoint, result) {
|
|
3414
|
+
return Response.json({ v: RPC_VERSION, endpoint, result });
|
|
3415
|
+
}
|
|
3416
|
+
function registerRpc(ctx, service) {
|
|
3417
|
+
ctx.inject(["connection"], (scoped) => {
|
|
3418
|
+
const connection = scoped.connection;
|
|
3419
|
+
if (connection?.fetch?.register === void 0)
|
|
3420
|
+
return;
|
|
3421
|
+
scoped.effect(() => {
|
|
3422
|
+
let dispose;
|
|
3423
|
+
try {
|
|
3424
|
+
dispose = connection.fetch.register({
|
|
3425
|
+
// The exact-route registry is keyed by the full request path, so the
|
|
3426
|
+
// `/api` prefix belongs here; the page composes the same URL from
|
|
3427
|
+
// API_BASE + RPC_PATH.
|
|
3428
|
+
path: `/api${RPC_PATH}`,
|
|
3429
|
+
methods: ["POST"],
|
|
3430
|
+
requestBody: "buffered",
|
|
3431
|
+
fetch: async (request) => {
|
|
3432
|
+
let body;
|
|
3433
|
+
try {
|
|
3434
|
+
body = await request.json();
|
|
3435
|
+
} catch {
|
|
3436
|
+
return Response.json({ error: "the request body is not JSON" }, { status: 400 });
|
|
3437
|
+
}
|
|
3438
|
+
if (body === null || typeof body !== "object" || body.v !== RPC_VERSION) {
|
|
3439
|
+
return Response.json(
|
|
3440
|
+
{ error: `this page and the host disagree on the protocol version (expected ${String(RPC_VERSION)})` },
|
|
3441
|
+
{ status: 409 }
|
|
3442
|
+
);
|
|
3443
|
+
}
|
|
3444
|
+
try {
|
|
3445
|
+
const value = await service.call(body.endpoint, body.payload);
|
|
3446
|
+
return answer(body.endpoint, { ok: true, value });
|
|
3447
|
+
} catch (error) {
|
|
3448
|
+
const code = error instanceof HomeHostedError ? error.code : "INTERNAL";
|
|
3449
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3450
|
+
const detail = error?.detail;
|
|
3451
|
+
return answer(body.endpoint, { ok: false, error: { code, message, ...detail === void 0 ? {} : { detail } } });
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
});
|
|
3455
|
+
} catch (error) {
|
|
3456
|
+
const logger = scoped.logger;
|
|
3457
|
+
logger?.warn?.(`[dsh-home-hosted] could not register ${RPC_PATH}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3458
|
+
return () => {
|
|
3459
|
+
};
|
|
3460
|
+
}
|
|
3461
|
+
return () => {
|
|
3462
|
+
void dispose();
|
|
3463
|
+
};
|
|
3464
|
+
}, "dsh-home-hosted: rpc route");
|
|
3465
|
+
});
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
// src/settings.ts
|
|
3469
|
+
function knownTool(value) {
|
|
3470
|
+
return typeof value === "string" && AGENT_TOOL_NAMES.includes(value);
|
|
3471
|
+
}
|
|
3472
|
+
function normalizeIntent(value, fallbackId) {
|
|
3473
|
+
const base = defaultIntent(typeof value?.id === "string" && value.id.length > 0 ? value.id : fallbackId);
|
|
3474
|
+
return {
|
|
3475
|
+
id: base.id,
|
|
3476
|
+
autostart: typeof value?.autostart === "boolean" ? value.autostart : base.autostart,
|
|
3477
|
+
onPortConflict: value?.onPortConflict ?? base.onPortConflict,
|
|
3478
|
+
stopKillPortHolders: typeof value?.stopKillPortHolders === "boolean" ? value.stopKillPortHolders : base.stopKillPortHolders
|
|
3479
|
+
};
|
|
3480
|
+
}
|
|
3481
|
+
function normalize(raw, fallbackEntryId) {
|
|
3482
|
+
const entries = Array.isArray(raw?.entries) ? raw.entries.map((intent) => normalizeIntent(intent, fallbackEntryId)) : [];
|
|
3483
|
+
const allow = Array.isArray(raw?.agentTools?.allow) ? raw.agentTools.allow.filter(knownTool) : DEFAULT_SETTINGS.agentTools.allow;
|
|
3484
|
+
return {
|
|
3485
|
+
autostart: {
|
|
3486
|
+
enabled: raw?.autostart?.enabled === true,
|
|
3487
|
+
mechanism: raw?.autostart?.mechanism ?? "auto"
|
|
3488
|
+
},
|
|
3489
|
+
entries,
|
|
3490
|
+
agentTools: {
|
|
3491
|
+
enabled: raw?.agentTools?.enabled === true,
|
|
3492
|
+
allow: allow.length > 0 ? allow : [...DEFAULT_SETTINGS.agentTools.allow]
|
|
3493
|
+
},
|
|
3494
|
+
cli: {
|
|
3495
|
+
prefer: raw?.cli?.prefer === "global" ? "global" : "pinned"
|
|
3496
|
+
}
|
|
3497
|
+
};
|
|
3498
|
+
}
|
|
3499
|
+
var SettingsStore = class {
|
|
3500
|
+
constructor(file, fallbackEntryId) {
|
|
3501
|
+
this.file = file;
|
|
3502
|
+
this.fallbackEntryId = fallbackEntryId;
|
|
3503
|
+
this.current = normalize(readJson(file), fallbackEntryId);
|
|
3504
|
+
}
|
|
3505
|
+
current;
|
|
3506
|
+
listeners = /* @__PURE__ */ new Set();
|
|
3507
|
+
get() {
|
|
3508
|
+
return this.current;
|
|
3509
|
+
}
|
|
3510
|
+
/** The intent for an entry, materialising the default when it has none yet. */
|
|
3511
|
+
intentFor(id) {
|
|
3512
|
+
return this.current.entries.find((entry) => entry.id === id) ?? defaultIntent(id);
|
|
3513
|
+
}
|
|
3514
|
+
update(patch) {
|
|
3515
|
+
const next = normalize({
|
|
3516
|
+
autostart: { ...this.current.autostart, ...patch.autostart ?? {} },
|
|
3517
|
+
entries: patch.entries ?? this.current.entries,
|
|
3518
|
+
agentTools: { ...this.current.agentTools, ...patch.agentTools ?? {} },
|
|
3519
|
+
cli: { ...this.current.cli, ...patch.cli ?? {} }
|
|
3520
|
+
}, this.fallbackEntryId);
|
|
3521
|
+
this.current = next;
|
|
3522
|
+
writeJsonAtomic(this.file, next, 384);
|
|
3523
|
+
for (const listener of this.listeners)
|
|
3524
|
+
listener(next);
|
|
3525
|
+
return next;
|
|
3526
|
+
}
|
|
3527
|
+
onChange(listener) {
|
|
3528
|
+
this.listeners.add(listener);
|
|
3529
|
+
return () => {
|
|
3530
|
+
this.listeners.delete(listener);
|
|
3531
|
+
};
|
|
3532
|
+
}
|
|
3533
|
+
};
|
|
3534
|
+
|
|
3535
|
+
// src/tools.ts
|
|
3536
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
3537
|
+
var TOOL_SPECS = {
|
|
3538
|
+
status: {
|
|
3539
|
+
endpoint: "status",
|
|
3540
|
+
description: "Report the home-hosted panel state, its boot-autostart entry, and the entries this plugin manages.",
|
|
3541
|
+
parameters: {}
|
|
3542
|
+
},
|
|
3543
|
+
servers_list: {
|
|
3544
|
+
endpoint: "servers.list",
|
|
3545
|
+
description: "List every server home-hosted supervises, with status, pid and url.",
|
|
3546
|
+
parameters: {}
|
|
3547
|
+
},
|
|
3548
|
+
servers_start: {
|
|
3549
|
+
endpoint: "servers.start",
|
|
3550
|
+
description: "Start a server supervised by home-hosted.",
|
|
3551
|
+
parameters: { id: { type: "string", required: true, description: "Server entry id" } }
|
|
3552
|
+
},
|
|
3553
|
+
servers_stop: {
|
|
3554
|
+
endpoint: "servers.stop",
|
|
3555
|
+
description: "Stop a server supervised by home-hosted.",
|
|
3556
|
+
parameters: { id: { type: "string", required: true, description: "Server entry id" } }
|
|
3557
|
+
},
|
|
3558
|
+
servers_restart: {
|
|
3559
|
+
endpoint: "servers.restart",
|
|
3560
|
+
description: "Restart a server supervised by home-hosted. Restarting the entry this session runs as will end the session.",
|
|
3561
|
+
parameters: { id: { type: "string", required: true, description: "Server entry id" } }
|
|
3562
|
+
},
|
|
3563
|
+
servers_create: {
|
|
3564
|
+
endpoint: "servers.create",
|
|
3565
|
+
description: "Add a server entry to home-hosted. The entry runs a command on this machine under the panel's supervision.",
|
|
3566
|
+
parameters: { entry: { type: "json", required: true, description: "A full home-hosted server entry, including its id and command" } }
|
|
3567
|
+
},
|
|
3568
|
+
servers_update: {
|
|
3569
|
+
endpoint: "servers.update",
|
|
3570
|
+
description: "Change fields of an existing home-hosted server entry.",
|
|
3571
|
+
parameters: {
|
|
3572
|
+
id: { type: "string", required: true, description: "Server entry id" },
|
|
3573
|
+
patch: { type: "json", required: true, description: "Fields to change" }
|
|
3574
|
+
}
|
|
3575
|
+
},
|
|
3576
|
+
servers_delete: {
|
|
3577
|
+
endpoint: "servers.delete",
|
|
3578
|
+
description: "Stop and remove a home-hosted server entry. Refused for the entry this session runs as.",
|
|
3579
|
+
parameters: { id: { type: "string", required: true, description: "Server entry id" } }
|
|
3580
|
+
},
|
|
3581
|
+
autostart_install: {
|
|
3582
|
+
endpoint: "boot.install",
|
|
3583
|
+
description: "Install the OS entry that starts home-hosted at boot or login.",
|
|
3584
|
+
parameters: { mechanism: { type: "string", description: "Explicit mechanism, e.g. systemd-user; omit to pick the best available one" } }
|
|
3585
|
+
},
|
|
3586
|
+
autostart_uninstall: {
|
|
3587
|
+
endpoint: "boot.uninstall",
|
|
3588
|
+
description: "Remove the OS entry that starts home-hosted at boot or login.",
|
|
3589
|
+
parameters: { mechanism: { type: "string", description: "Explicit mechanism; omit to use the installed one" } }
|
|
3590
|
+
}
|
|
3591
|
+
};
|
|
3592
|
+
function toolNameFor(name2) {
|
|
3593
|
+
return `home_hosted_${name2}`;
|
|
3594
|
+
}
|
|
3595
|
+
function registerOne(ctx, service, name2) {
|
|
3596
|
+
const spec = TOOL_SPECS[name2];
|
|
3597
|
+
const toolName = toolNameFor(name2);
|
|
3598
|
+
const mutating = MUTATING_AGENT_TOOLS.includes(name2);
|
|
3599
|
+
return ctx.tools.register(defineTool({
|
|
3600
|
+
name: toolName,
|
|
3601
|
+
description: spec.description,
|
|
3602
|
+
parameters: spec.parameters,
|
|
3603
|
+
output: {
|
|
3604
|
+
schema: { type: "string" },
|
|
3605
|
+
render: (_args, value) => [{ type: "text", text: value }]
|
|
3606
|
+
},
|
|
3607
|
+
async execute(args, exec) {
|
|
3608
|
+
const input = args ?? {};
|
|
3609
|
+
if (mutating) {
|
|
3610
|
+
const approval = ctx.get("approval");
|
|
3611
|
+
if (approval?.request === void 0)
|
|
3612
|
+
return "refused: this deployment has no approval service, so a mutating home-hosted tool cannot run.";
|
|
3613
|
+
const outcome = await approval.request({
|
|
3614
|
+
agent: exec?.agent,
|
|
3615
|
+
toolName,
|
|
3616
|
+
reason: `${spec.description} (${JSON.stringify(input)})`
|
|
3617
|
+
});
|
|
3618
|
+
if (outcome !== "allowed-once")
|
|
3619
|
+
return `refused: approval answered "${outcome}".`;
|
|
3620
|
+
}
|
|
3621
|
+
try {
|
|
3622
|
+
const payload = name2 === "servers_create" ? { entry: input.entry } : name2 === "servers_update" ? { id: input.id, patch: input.patch } : name2 === "status" || name2 === "servers_list" ? {} : { id: input.id, mechanism: input.mechanism };
|
|
3623
|
+
const value = await service.call(spec.endpoint, payload);
|
|
3624
|
+
return JSON.stringify(value, null, 2);
|
|
3625
|
+
} catch (error) {
|
|
3626
|
+
return `failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
}));
|
|
3630
|
+
}
|
|
3631
|
+
function registerAgentTools(ctx, service, settings) {
|
|
3632
|
+
ctx.inject(["tools"], (scoped) => {
|
|
3633
|
+
const disposers = [];
|
|
3634
|
+
const sync = () => {
|
|
3635
|
+
while (disposers.length > 0)
|
|
3636
|
+
disposers.pop()?.();
|
|
3637
|
+
const current = settings.get();
|
|
3638
|
+
if (!current.agentTools.enabled)
|
|
3639
|
+
return;
|
|
3640
|
+
for (const name2 of current.agentTools.allow) {
|
|
3641
|
+
try {
|
|
3642
|
+
disposers.push(registerOne(scoped, service, name2));
|
|
3643
|
+
} catch {
|
|
3644
|
+
}
|
|
3645
|
+
}
|
|
3646
|
+
};
|
|
3647
|
+
scoped.effect(() => {
|
|
3648
|
+
sync();
|
|
3649
|
+
const off = settings.onChange(sync);
|
|
3650
|
+
return () => {
|
|
3651
|
+
off();
|
|
3652
|
+
while (disposers.length > 0)
|
|
3653
|
+
disposers.pop()?.();
|
|
3654
|
+
};
|
|
3655
|
+
}, "dsh-home-hosted: agent tools");
|
|
3656
|
+
});
|
|
3657
|
+
}
|
|
3658
|
+
|
|
3659
|
+
// src/index.ts
|
|
3660
|
+
var name = "dsh-home-hosted";
|
|
3661
|
+
var inject = [];
|
|
3662
|
+
function apply(ctx, config) {
|
|
3663
|
+
const resolved = {
|
|
3664
|
+
stateDir: config?.stateDir,
|
|
3665
|
+
homeHostedCommand: config?.homeHostedCommand,
|
|
3666
|
+
defaultEntryId: config?.defaultEntryId ?? "dsh"
|
|
3667
|
+
};
|
|
3668
|
+
const stateDir = pluginStateDir(resolved.stateDir);
|
|
3669
|
+
ensureDir(stateDir, 448);
|
|
3670
|
+
const settings = new SettingsStore(path14.join(stateDir, "settings.json"), resolved.defaultEntryId);
|
|
3671
|
+
const service = new HomeHostedService(ctx, {
|
|
3672
|
+
home: homeHostedHome(),
|
|
3673
|
+
stateDir,
|
|
3674
|
+
homeHostedCommand: resolved.homeHostedCommand,
|
|
3675
|
+
defaultEntryId: resolved.defaultEntryId,
|
|
3676
|
+
settings
|
|
3677
|
+
});
|
|
3678
|
+
registerRpc(ctx, service);
|
|
3679
|
+
registerAgentTools(ctx, service, settings);
|
|
3680
|
+
ctx.effect(() => {
|
|
3681
|
+
const timer = setTimeout(() => {
|
|
3682
|
+
void service.reconcile().catch(() => {
|
|
3683
|
+
});
|
|
3684
|
+
}, 5e3);
|
|
3685
|
+
return () => clearTimeout(timer);
|
|
3686
|
+
}, "dsh-home-hosted: startup reconcile");
|
|
3687
|
+
}
|
|
3688
|
+
export {
|
|
3689
|
+
Config,
|
|
3690
|
+
apply,
|
|
3691
|
+
inject,
|
|
3692
|
+
name
|
|
3693
|
+
};
|
|
3694
|
+
//# sourceMappingURL=index.js.map
|