dsh-home-hosted 0.3.3 → 0.4.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/README.md +6 -0
- package/docs/DESIGN.md +130 -13
- package/lib/client.js +6 -6
- package/lib/index.js +1101 -262
- package/lib/index.js.map +4 -4
- package/lib/types/config.d.ts +6 -0
- package/lib/types/home-hosted/dsh-entry.d.ts +14 -7
- package/lib/types/home-hosted/entries.d.ts +4 -18
- package/lib/types/home-hosted/instances-notice.d.ts +35 -0
- package/lib/types/home-hosted/instances.d.ts +34 -0
- package/lib/types/home-hosted/launcher.d.ts +20 -1
- package/lib/types/home-hosted/panel-control.d.ts +14 -0
- package/lib/types/home-hosted/resolve.d.ts +1 -5
- package/lib/types/home-hosted/token.d.ts +8 -3
- package/lib/types/service.d.ts +75 -12
- package/lib/types/shared/contracts.d.ts +92 -11
- package/lib/types/util/paths.d.ts +21 -0
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import
|
|
2
|
+
import path15 from "node:path";
|
|
3
3
|
|
|
4
4
|
// src/config.ts
|
|
5
5
|
import z from "@deepseek-ai/schemastery";
|
|
6
6
|
var Config = z.object({
|
|
7
7
|
stateDir: z.string().description("Plugin state directory; defaults to $DSH_HOME/dsh-home-hosted"),
|
|
8
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")
|
|
9
|
+
defaultEntryId: z.string().default("dsh").description("Server entry id used for the running harness"),
|
|
10
|
+
instanceRoots: z.array(z.string()).default([]).description("Other home-hosted state roots (--home) to report as other panels")
|
|
10
11
|
});
|
|
11
12
|
|
|
12
13
|
// src/shared/contracts.ts
|
|
13
14
|
var RPC_PATH = "/home-hosted";
|
|
14
15
|
var RPC_VERSION = 1;
|
|
16
|
+
var FOREIGN_MECHANISMS = {
|
|
17
|
+
"servers.list": ["file", "api"],
|
|
18
|
+
"servers.create": ["file", "api"],
|
|
19
|
+
"servers.update": ["file", "api"],
|
|
20
|
+
"servers.delete": ["file", "api"],
|
|
21
|
+
"servers.start": ["cli", "api"],
|
|
22
|
+
"servers.stop": ["cli", "api"],
|
|
23
|
+
"servers.restart": ["cli", "api"],
|
|
24
|
+
"ui.manage": ["cli"]
|
|
25
|
+
};
|
|
15
26
|
var ON_PORT_CONFLICT_POLICIES = ["block", "warn", "follow", "reclaim", "kill"];
|
|
16
27
|
function isOnPortConflict(value) {
|
|
17
28
|
return typeof value === "string" && ON_PORT_CONFLICT_POLICIES.includes(value);
|
|
@@ -30,7 +41,7 @@ var MUTATING_AGENT_TOOLS = [
|
|
|
30
41
|
"autostart_manage",
|
|
31
42
|
"ui_manage"
|
|
32
43
|
];
|
|
33
|
-
var SETTINGS_VERSION =
|
|
44
|
+
var SETTINGS_VERSION = 3;
|
|
34
45
|
var DEFAULT_SETTINGS = {
|
|
35
46
|
version: SETTINGS_VERSION,
|
|
36
47
|
autostart: { enabled: false, mechanism: "auto" },
|
|
@@ -41,14 +52,15 @@ var DEFAULT_SETTINGS = {
|
|
|
41
52
|
panel: { port: null },
|
|
42
53
|
authNotice: true,
|
|
43
54
|
reclaimToken: true,
|
|
55
|
+
instancesNotice: true,
|
|
44
56
|
uiStyle: "detailed",
|
|
45
57
|
cli: { prefer: "pinned" }
|
|
46
58
|
};
|
|
47
59
|
|
|
48
60
|
// src/service.ts
|
|
49
61
|
import { Service } from "@deepseek-ai/cordis";
|
|
50
|
-
import
|
|
51
|
-
import
|
|
62
|
+
import fs15 from "node:fs";
|
|
63
|
+
import path14 from "node:path";
|
|
52
64
|
import process10 from "node:process";
|
|
53
65
|
import { fileURLToPath } from "node:url";
|
|
54
66
|
|
|
@@ -1948,9 +1960,11 @@ function createBootLadder(options = {}) {
|
|
|
1948
1960
|
}
|
|
1949
1961
|
|
|
1950
1962
|
// src/home-hosted/config-file.ts
|
|
1951
|
-
import
|
|
1963
|
+
import fs8 from "node:fs";
|
|
1952
1964
|
|
|
1953
1965
|
// src/util/paths.ts
|
|
1966
|
+
import crypto from "node:crypto";
|
|
1967
|
+
import fs7 from "node:fs";
|
|
1954
1968
|
import os3 from "node:os";
|
|
1955
1969
|
import path6 from "node:path";
|
|
1956
1970
|
function expandHome(value) {
|
|
@@ -1968,6 +1982,32 @@ function homeHostedHome() {
|
|
|
1968
1982
|
const configured = process.env.HHOSTED_HOME?.trim();
|
|
1969
1983
|
return configured ? path6.resolve(expandHome(configured)) : path6.join(os3.homedir(), ".home-hosted");
|
|
1970
1984
|
}
|
|
1985
|
+
function resolveHomeHostedHome(stateDir, env = process.env, homeDir = os3.homedir()) {
|
|
1986
|
+
const configured = env.HHOSTED_HOME?.trim();
|
|
1987
|
+
if (configured)
|
|
1988
|
+
return { home: path6.resolve(expandHome(configured)), source: "env" };
|
|
1989
|
+
if (hasPanelState(path6.join(homeDir, ".home-hosted")))
|
|
1990
|
+
return { home: path6.join(homeDir, ".home-hosted"), source: "legacy" };
|
|
1991
|
+
return { home: path6.join(path6.resolve(expandHome(stateDir)), "panel"), source: "instance" };
|
|
1992
|
+
}
|
|
1993
|
+
function hasPanelState(home) {
|
|
1994
|
+
return ["servers.config.json", "run.json"].some((file) => {
|
|
1995
|
+
try {
|
|
1996
|
+
return fs7.statSync(path6.join(home, file)).isFile();
|
|
1997
|
+
} catch {
|
|
1998
|
+
return false;
|
|
1999
|
+
}
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
2002
|
+
function bootUnitName(stateDir) {
|
|
2003
|
+
if (isDefaultStateDir(stateDir))
|
|
2004
|
+
return "home-hosted";
|
|
2005
|
+
return `home-hosted-${crypto.createHash("sha256").update(path6.resolve(stateDir)).digest("hex").slice(0, 8)}`;
|
|
2006
|
+
}
|
|
2007
|
+
function isDefaultStateDir(stateDir) {
|
|
2008
|
+
const bare = stateDir?.trim();
|
|
2009
|
+
return bare === void 0 || bare.length === 0 || path6.resolve(expandHome(bare)) === path6.join(dshHome(), "dsh-home-hosted");
|
|
2010
|
+
}
|
|
1971
2011
|
function pluginStateDir(override) {
|
|
1972
2012
|
const configured = override?.trim();
|
|
1973
2013
|
if (configured)
|
|
@@ -1989,7 +2029,7 @@ function readConfig(home) {
|
|
|
1989
2029
|
const file = configFile(home);
|
|
1990
2030
|
const raw = readJson(file);
|
|
1991
2031
|
if (raw === null) {
|
|
1992
|
-
const exists =
|
|
2032
|
+
const exists = fs8.existsSync(file);
|
|
1993
2033
|
return { raw: null, exists, error: exists ? "the config file could not be parsed" : null };
|
|
1994
2034
|
}
|
|
1995
2035
|
if (!Array.isArray(raw.servers) && raw.servers !== void 0)
|
|
@@ -2033,12 +2073,12 @@ function removeEntry(raw, id) {
|
|
|
2033
2073
|
}
|
|
2034
2074
|
|
|
2035
2075
|
// src/home-hosted/dsh-entry.ts
|
|
2036
|
-
import
|
|
2076
|
+
import fs12 from "node:fs";
|
|
2037
2077
|
import path10 from "node:path";
|
|
2038
2078
|
import process7 from "node:process";
|
|
2039
2079
|
|
|
2040
2080
|
// src/home-hosted/launch.ts
|
|
2041
|
-
import
|
|
2081
|
+
import fs9 from "node:fs";
|
|
2042
2082
|
import path7 from "node:path";
|
|
2043
2083
|
import process4 from "node:process";
|
|
2044
2084
|
async function which(command) {
|
|
@@ -2050,12 +2090,12 @@ async function which(command) {
|
|
|
2050
2090
|
return first ?? null;
|
|
2051
2091
|
}
|
|
2052
2092
|
function resolveShimmedCli(absolute, nodePath = process4.execPath) {
|
|
2053
|
-
if (!
|
|
2093
|
+
if (!fs9.existsSync(absolute))
|
|
2054
2094
|
return null;
|
|
2055
2095
|
if (/\.(?:mjs|cjs|js)$/.test(absolute))
|
|
2056
2096
|
return { program: nodePath, args: [absolute], cliEntry: absolute, shimPath: null, source: "entry" };
|
|
2057
2097
|
const shimTarget = /^#\s*cmd-shim-target=(.+)$/m.exec(readText(absolute) ?? "")?.[1]?.trim();
|
|
2058
|
-
if (shimTarget !== void 0 &&
|
|
2098
|
+
if (shimTarget !== void 0 && fs9.existsSync(shimTarget))
|
|
2059
2099
|
return { program: nodePath, args: [shimTarget], cliEntry: shimTarget, shimPath: absolute, source: "shim-target" };
|
|
2060
2100
|
return { program: absolute, args: [], cliEntry: null, shimPath: absolute, source: "shim" };
|
|
2061
2101
|
}
|
|
@@ -2087,7 +2127,7 @@ function homeHostedEnv(facts, launch, extra = {}) {
|
|
|
2087
2127
|
}
|
|
2088
2128
|
function buildHomeHostedBootSpec(facts, launch, options) {
|
|
2089
2129
|
const logDir = path7.join(facts.home, ".logs");
|
|
2090
|
-
|
|
2130
|
+
fs9.mkdirSync(logDir, { recursive: true });
|
|
2091
2131
|
const args = [...launch.args, "up", "--foreground", "--home", facts.home];
|
|
2092
2132
|
if (facts.projectDir)
|
|
2093
2133
|
args.push("--project", facts.projectDir);
|
|
@@ -2097,7 +2137,9 @@ function buildHomeHostedBootSpec(facts, launch, options) {
|
|
|
2097
2137
|
cwd: facts.projectDir ?? facts.home,
|
|
2098
2138
|
env: homeHostedEnv(facts, launch),
|
|
2099
2139
|
marker: options.marker ?? "managed by dsh-home-hosted",
|
|
2100
|
-
|
|
2140
|
+
// One artifact per state root, so two plugin instances cannot overwrite or
|
|
2141
|
+
// delete each other's entry. The default install keeps the historical name.
|
|
2142
|
+
unitName: bootUnitName(options.stateDir),
|
|
2101
2143
|
label: "home-hosted control panel",
|
|
2102
2144
|
logDir
|
|
2103
2145
|
};
|
|
@@ -2105,13 +2147,11 @@ function buildHomeHostedBootSpec(facts, launch, options) {
|
|
|
2105
2147
|
|
|
2106
2148
|
// src/home-hosted/resolve.ts
|
|
2107
2149
|
import { createRequire } from "node:module";
|
|
2108
|
-
import
|
|
2150
|
+
import fs10 from "node:fs";
|
|
2109
2151
|
import path8 from "node:path";
|
|
2110
2152
|
import process5 from "node:process";
|
|
2111
|
-
var EXPECTED_RANGE = "^0.6.
|
|
2153
|
+
var EXPECTED_RANGE = "^0.6.7";
|
|
2112
2154
|
var MIN_SUPPORTED_VERSION = "0.4.1";
|
|
2113
|
-
var MIN_KILL_VERSION = "0.6.0";
|
|
2114
|
-
var MIN_PERSISTENT_VERSION = "0.6.3";
|
|
2115
2155
|
function pinnedManifestPath() {
|
|
2116
2156
|
try {
|
|
2117
2157
|
return createRequire(import.meta.url).resolve("home-hosted/package.json");
|
|
@@ -2121,7 +2161,7 @@ function pinnedManifestPath() {
|
|
|
2121
2161
|
}
|
|
2122
2162
|
function binEntryFromManifest(manifestPath) {
|
|
2123
2163
|
try {
|
|
2124
|
-
const manifest = JSON.parse(
|
|
2164
|
+
const manifest = JSON.parse(fs10.readFileSync(manifestPath, "utf8"));
|
|
2125
2165
|
const declared = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.["home-hosted"] ?? Object.values(manifest.bin ?? {})[0];
|
|
2126
2166
|
return typeof declared === "string" && declared.length > 0 ? path8.resolve(path8.dirname(manifestPath), declared) : null;
|
|
2127
2167
|
} catch {
|
|
@@ -2204,10 +2244,10 @@ async function resolveCli(options = {}) {
|
|
|
2204
2244
|
const manifest = (options.packageManifest ?? pinnedManifestPath)();
|
|
2205
2245
|
const dependencyEntry = manifest === null ? null : binEntryFromManifest(manifest);
|
|
2206
2246
|
let dependency = null;
|
|
2207
|
-
if (dependencyEntry !== null &&
|
|
2247
|
+
if (dependencyEntry !== null && fs10.existsSync(dependencyEntry)) {
|
|
2208
2248
|
let version2 = null;
|
|
2209
2249
|
try {
|
|
2210
|
-
version2 = JSON.parse(
|
|
2250
|
+
version2 = JSON.parse(fs10.readFileSync(manifest, "utf8")).version ?? null;
|
|
2211
2251
|
} catch {
|
|
2212
2252
|
version2 = null;
|
|
2213
2253
|
}
|
|
@@ -2282,7 +2322,7 @@ async function resolveCli(options = {}) {
|
|
|
2282
2322
|
}
|
|
2283
2323
|
|
|
2284
2324
|
// src/home-hosted/launcher.ts
|
|
2285
|
-
import
|
|
2325
|
+
import fs11 from "node:fs";
|
|
2286
2326
|
import path9 from "node:path";
|
|
2287
2327
|
import process6 from "node:process";
|
|
2288
2328
|
function launcherDir(stateDir) {
|
|
@@ -2399,11 +2439,11 @@ process.exit(typeof result.status === 'number' ? result.status : 1)
|
|
|
2399
2439
|
function writeLauncher(options) {
|
|
2400
2440
|
const file = launcherPath(options.stateDir);
|
|
2401
2441
|
const source = buildLauncherSource(options);
|
|
2402
|
-
const previous =
|
|
2442
|
+
const previous = fs11.existsSync(file) ? fs11.readFileSync(file, "utf8") : null;
|
|
2403
2443
|
const changed = previous !== source;
|
|
2404
2444
|
if (changed) {
|
|
2405
2445
|
writeFileAtomic(file, source, 493);
|
|
2406
|
-
|
|
2446
|
+
fs11.chmodSync(file, 493);
|
|
2407
2447
|
}
|
|
2408
2448
|
writeJsonAtomic(launcherRecordPath(options.stateDir), {
|
|
2409
2449
|
entry: options.resolvedEntry,
|
|
@@ -2415,7 +2455,7 @@ function writeLauncher(options) {
|
|
|
2415
2455
|
}
|
|
2416
2456
|
async function preflightLauncher(stateDir, timeoutMs = 1e4) {
|
|
2417
2457
|
const file = launcherPath(stateDir);
|
|
2418
|
-
if (!
|
|
2458
|
+
if (!fs11.existsSync(file))
|
|
2419
2459
|
return null;
|
|
2420
2460
|
const result = process6.platform === "win32" ? await run(process6.execPath, [file, "--version"], { timeoutMs }) : await run(file, ["--version"], { timeoutMs });
|
|
2421
2461
|
return parseVersion(result.stdout) ?? parseVersion(result.stderr);
|
|
@@ -2439,27 +2479,69 @@ import { spawnSync } from 'node:child_process'` : `const fs = require('node:fs')
|
|
|
2439
2479
|
const path = require('node:path')
|
|
2440
2480
|
const process = require('node:process')
|
|
2441
2481
|
const { spawnSync } = require('node:child_process')`;
|
|
2442
|
-
const
|
|
2482
|
+
const searchRoots = [
|
|
2483
|
+
options.resolvedEntry === null ? null : dshSearchRoot(options.resolvedEntry),
|
|
2484
|
+
options.pinnedEntry === null || options.pinnedEntry === void 0 ? null : dshSearchRoot(options.pinnedEntry),
|
|
2485
|
+
...options.searchRoots ?? []
|
|
2486
|
+
].filter((root) => typeof root === "string" && root.length > 0);
|
|
2443
2487
|
return `#!/usr/bin/env node
|
|
2444
2488
|
// managed by dsh-home-hosted \u2014 rewritten on every plugin start; do not edit.
|
|
2445
2489
|
${prelude}
|
|
2446
2490
|
|
|
2447
2491
|
const DSH_HOME = ${JSON.stringify(options.dshHome)}
|
|
2448
2492
|
const RECORD = ${JSON.stringify(dshLauncherRecordPath(options.stateDir))}
|
|
2449
|
-
const SEARCH_ROOTS = ${JSON.stringify([...new Set(
|
|
2493
|
+
const SEARCH_ROOTS = ${JSON.stringify([...new Set(searchRoots)])}
|
|
2494
|
+
// The image this plugin was installed on, when the record had one to carry.
|
|
2495
|
+
const PINNED = ${JSON.stringify(options.pinnedEntry ?? null)}
|
|
2450
2496
|
|
|
2451
2497
|
function readRecord() {
|
|
2452
2498
|
try { return JSON.parse(fs.readFileSync(RECORD, 'utf8')) } catch { return null }
|
|
2453
2499
|
}
|
|
2454
2500
|
|
|
2455
|
-
function
|
|
2501
|
+
function isFile(file) {
|
|
2456
2502
|
let stat = null
|
|
2457
2503
|
try { stat = fs.statSync(file) } catch { return false }
|
|
2458
|
-
return stat.isFile()
|
|
2504
|
+
return stat.isFile()
|
|
2459
2505
|
}
|
|
2460
2506
|
|
|
2461
|
-
|
|
2462
|
-
|
|
2507
|
+
// One identity per file: a recorded path and a candidate reached through a
|
|
2508
|
+
// symlink must compare equal, or a macOS clone would look like a different copy.
|
|
2509
|
+
function canon(file) {
|
|
2510
|
+
try { return fs.realpathSync(file) } catch { return file }
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
// Keep what can actually boot: a JavaScript entry, or a shim whose recorded
|
|
2514
|
+
// target is one. A bare shim found on PATH is not a script, so requiring the
|
|
2515
|
+
// extension would silently drop the last resort.
|
|
2516
|
+
//
|
|
2517
|
+
// Each candidate carries how it was found, which is what ordering uses:
|
|
2518
|
+
// TIER_REAL a dsh by construction (a manifest, or node_modules/dsh)
|
|
2519
|
+
// TIER_PROBE a root that merely offers a build file \u2014 any package may
|
|
2520
|
+
// TIER_PATH a dsh executable found on PATH
|
|
2521
|
+
const TIER_REAL = 2
|
|
2522
|
+
const TIER_PROBE = 1
|
|
2523
|
+
const TIER_PATH = 0
|
|
2524
|
+
|
|
2525
|
+
function collect(out, entry, source, tier) {
|
|
2526
|
+
const argument = typeof source === 'string' && source.length > 0 ? source : entry
|
|
2527
|
+
const file = typeof entry === 'string' && isFile(entry) ? canon(entry) : null
|
|
2528
|
+
if (file !== null && /\\.(?:mjs|cjs|js)$/.test(file)) {
|
|
2529
|
+
if (!out.has(file) || (out.get(file) ?? 0) < tier) out.set(file, tier)
|
|
2530
|
+
return
|
|
2531
|
+
}
|
|
2532
|
+
// Anything else is a shim: only its recorded target can boot, and a shim
|
|
2533
|
+
// without one is not a candidate at all (never a second pass over itself).
|
|
2534
|
+
if (typeof argument !== 'string' || !isFile(argument)) return
|
|
2535
|
+
let text = ''
|
|
2536
|
+
try { text = fs.readFileSync(argument, 'utf8') } catch { return }
|
|
2537
|
+
const marker = 'cmd-shim-target='
|
|
2538
|
+
const at = text.indexOf(marker)
|
|
2539
|
+
if (at === -1) return
|
|
2540
|
+
const target = text.slice(at + marker.length).split('\\n')[0].trim()
|
|
2541
|
+
if (target.length === 0) return
|
|
2542
|
+
const real = canon(target)
|
|
2543
|
+
if (!/\\.(?:mjs|cjs|js)$/.test(real)) return
|
|
2544
|
+
if (!out.has(real) || (out.get(real) ?? 0) < tier) out.set(real, tier)
|
|
2463
2545
|
}
|
|
2464
2546
|
|
|
2465
2547
|
function collectStore(root, out) {
|
|
@@ -2468,30 +2550,36 @@ function collectStore(root, out) {
|
|
|
2468
2550
|
try { names = fs.readdirSync(store) } catch { return }
|
|
2469
2551
|
for (const name of names) {
|
|
2470
2552
|
if (!name.startsWith('dsh@') && !name.startsWith('@deepseek-ai+dsh@')) continue
|
|
2471
|
-
collect(out, path.join(store, name, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'))
|
|
2472
|
-
collect(out, path.join(store, name, 'node_modules', 'dsh', 'bin', 'dsh.js'))
|
|
2553
|
+
collect(out, path.join(store, name, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'), null, TIER_REAL)
|
|
2554
|
+
collect(out, path.join(store, name, 'node_modules', 'dsh', 'bin', 'dsh.js'), null, TIER_REAL)
|
|
2473
2555
|
}
|
|
2474
2556
|
}
|
|
2475
2557
|
|
|
2476
2558
|
function collectRoot(root, out) {
|
|
2477
|
-
collect(out, path.join(root, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'))
|
|
2478
|
-
collect(out, path.join(root, 'node_modules', 'dsh', 'bin', 'dsh.js'))
|
|
2559
|
+
collect(out, path.join(root, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'), null, TIER_REAL)
|
|
2560
|
+
collect(out, path.join(root, 'node_modules', 'dsh', 'bin', 'dsh.js'), null, TIER_REAL)
|
|
2479
2561
|
collectStore(root, out)
|
|
2562
|
+
// A clone keeps its build output at its own root, not under node_modules.
|
|
2563
|
+
for (const entry of ['lib/bin.js', 'lib/bin.mjs', 'lib/bin.cjs', 'dist/bin.js'])
|
|
2564
|
+
collect(out, path.join(root, entry), null, TIER_PROBE)
|
|
2480
2565
|
}
|
|
2481
2566
|
|
|
2482
|
-
function candidates() {
|
|
2483
|
-
const out =
|
|
2567
|
+
function candidates(skipPath) {
|
|
2568
|
+
const out = new Map()
|
|
2484
2569
|
const record = readRecord()
|
|
2485
|
-
if (record && typeof record.entry === 'string') collect(out, record.entry)
|
|
2570
|
+
if (record && typeof record.entry === 'string') collect(out, record.entry, record.entry, TIER_REAL)
|
|
2486
2571
|
for (const root of [...(Array.isArray(record?.roots) ? record.roots : []), ...SEARCH_ROOTS]) collectRoot(root, out)
|
|
2487
2572
|
collectRoot(DSH_HOME, out)
|
|
2488
2573
|
let profiles = []
|
|
2489
2574
|
try { profiles = fs.readdirSync(path.join(DSH_HOME, 'profiles')) } catch {}
|
|
2490
2575
|
for (const name of profiles) collectRoot(path.join(DSH_HOME, 'profiles', name), out)
|
|
2491
|
-
// Last resort: a dsh on the entry's own PATH.
|
|
2576
|
+
// Last resort: a dsh on the entry's own PATH. It is no longer an equal
|
|
2577
|
+
// candidate: a copy found there is only booted when nothing else answers.
|
|
2578
|
+
if (skipPath) return out
|
|
2492
2579
|
for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
|
|
2493
|
-
|
|
2494
|
-
collect(out,
|
|
2580
|
+
const shim = path.join(dir, 'dsh')
|
|
2581
|
+
collect(out, shim, shim, TIER_PATH)
|
|
2582
|
+
collect(out, path.join(dir, 'dsh.cmd'), path.join(dir, 'dsh.cmd'), TIER_PATH)
|
|
2495
2583
|
}
|
|
2496
2584
|
return out
|
|
2497
2585
|
}
|
|
@@ -2524,22 +2612,43 @@ function compare(a, b) {
|
|
|
2524
2612
|
return left.pre < right.pre ? -1 : 1
|
|
2525
2613
|
}
|
|
2526
2614
|
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2615
|
+
function findPin(pin, found) {
|
|
2616
|
+
if (typeof pin !== 'string' || pin.length === 0) return null
|
|
2617
|
+
const real = canon(pin)
|
|
2618
|
+
return found.find(item => item.entry === real)?.entry ?? null
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
// A root that only offers a build file (lib/bin.js) is a guess: any package may
|
|
2622
|
+
// have one. Those stay usable but never outrank a copy that is dsh by
|
|
2623
|
+
// construction, or a dsh executable from PATH below them.
|
|
2624
|
+
function bestOfTier(found, tier) {
|
|
2625
|
+
const at = found.filter(item => item.tier >= tier)
|
|
2626
|
+
const known = at.filter(item => item.version !== null)
|
|
2533
2627
|
if (known.length > 0) {
|
|
2534
|
-
known.sort((a, b) =>
|
|
2628
|
+
known.sort((a, b) => compare(b.version, a.version))
|
|
2535
2629
|
return known[0].entry
|
|
2536
2630
|
}
|
|
2537
|
-
return
|
|
2631
|
+
return at[0]?.entry ?? null
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
// Choose the image this plugin was installed on, and only then fall back to a
|
|
2635
|
+
// version sort: a higher version elsewhere is not an invitation to boot it.
|
|
2636
|
+
function choose() {
|
|
2637
|
+
const record = readRecord()
|
|
2638
|
+
const pin = typeof record?.pinned === 'string' && record.pinned.length > 0 ? record.pinned : PINNED
|
|
2639
|
+
const found = [...candidates(true)].map(([entry, tier]) => ({ entry, tier, version: versionOf(entry) }))
|
|
2640
|
+
const pinned = findPin(pin, found)
|
|
2641
|
+
if (pinned !== null) return pinned
|
|
2642
|
+
// Real dsh installs first; a bare build-file guess only when nothing else
|
|
2643
|
+
// exists; PATH only when nothing local answered at all.
|
|
2644
|
+
return bestOfTier(found, TIER_REAL)
|
|
2645
|
+
?? bestOfTier(found, TIER_PROBE)
|
|
2646
|
+
?? bestOfTier([...candidates(false)].map(([entry, tier]) => ({ entry, tier, version: versionOf(entry) })), TIER_PATH)
|
|
2538
2647
|
}
|
|
2539
2648
|
|
|
2540
2649
|
const entry = choose()
|
|
2541
2650
|
if (entry === null) {
|
|
2542
|
-
console.error('[dsh-home-hosted] no dsh entry found for ' + DSH_HOME + ' (recorded path, $DSH_HOME node_modules, profiles/*, PATH). Reinstall dsh, or restart: dsh web')
|
|
2651
|
+
console.error('[dsh-home-hosted] no dsh entry found for ' + DSH_HOME + ' (pinned path, recorded path, $DSH_HOME node_modules, profiles/*, PATH). Reinstall dsh, or restart: dsh web')
|
|
2543
2652
|
process.exit(1)
|
|
2544
2653
|
}
|
|
2545
2654
|
|
|
@@ -2552,42 +2661,61 @@ process.exit(typeof result.status === 'number' ? result.status : 1)
|
|
|
2552
2661
|
}
|
|
2553
2662
|
function writeDshLauncher(options) {
|
|
2554
2663
|
const file = dshLauncherPath(options.stateDir, options.entryExtension);
|
|
2555
|
-
const
|
|
2556
|
-
const
|
|
2557
|
-
const
|
|
2664
|
+
const previousRecord = readDshLauncherRecord(options.stateDir);
|
|
2665
|
+
const recordedPin = existingEntry(previousRecord?.pinned);
|
|
2666
|
+
const offered = existingEntry(options.pinnedEntry);
|
|
2667
|
+
const pinned = options.repin === true ? offered ?? recordedPin : recordedPin ?? offered;
|
|
2668
|
+
const source = buildDshLauncherSource({
|
|
2669
|
+
...options,
|
|
2670
|
+
pinnedEntry: pinned === null ? options.pinnedEntry ?? null : pinned
|
|
2671
|
+
});
|
|
2672
|
+
const changed = (fs11.existsSync(file) ? fs11.readFileSync(file, "utf8") : null) !== source;
|
|
2558
2673
|
if (changed) {
|
|
2559
2674
|
writeFileAtomic(file, source, 493);
|
|
2560
|
-
|
|
2675
|
+
fs11.chmodSync(file, 493);
|
|
2561
2676
|
}
|
|
2562
|
-
const raw = [dshSearchRoot(options.resolvedEntry), ...options.searchRoots ?? [], ...
|
|
2677
|
+
const raw = [dshSearchRoot(options.resolvedEntry), dshSearchRoot(pinned), ...options.searchRoots ?? [], ...previousRecord?.roots ?? []];
|
|
2563
2678
|
const known = raw.filter((root) => typeof root === "string" && root.length > 0 && dshRootUsable(root));
|
|
2564
2679
|
const roots = [...new Set(known)];
|
|
2565
2680
|
writeJsonAtomic(dshLauncherRecordPath(options.stateDir), {
|
|
2566
2681
|
entry: options.resolvedEntry,
|
|
2682
|
+
pinned,
|
|
2683
|
+
pinnedAt: pinned === null ? null : pinned === recordedPin ? previousRecord?.pinnedAt ?? Date.now() : Date.now(),
|
|
2567
2684
|
roots,
|
|
2568
2685
|
writtenAt: Date.now()
|
|
2569
2686
|
}, 384);
|
|
2570
2687
|
return { path: file, changed };
|
|
2571
2688
|
}
|
|
2689
|
+
function existingEntry(file) {
|
|
2690
|
+
if (typeof file !== "string" || file.length === 0)
|
|
2691
|
+
return null;
|
|
2692
|
+
if (!fs11.existsSync(file))
|
|
2693
|
+
return null;
|
|
2694
|
+
try {
|
|
2695
|
+
return fs11.realpathSync(file);
|
|
2696
|
+
} catch {
|
|
2697
|
+
return null;
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2572
2700
|
function dshCandidatesUnder(root) {
|
|
2573
2701
|
const out = [];
|
|
2574
2702
|
for (const entry of [
|
|
2575
2703
|
path9.join(root, "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"),
|
|
2576
2704
|
path9.join(root, "node_modules", "dsh", "bin", "dsh.js")
|
|
2577
2705
|
]) {
|
|
2578
|
-
if (
|
|
2706
|
+
if (fs11.existsSync(entry))
|
|
2579
2707
|
out.push(entry);
|
|
2580
2708
|
}
|
|
2581
2709
|
const store = path9.join(root, "node_modules", ".pnpm");
|
|
2582
|
-
if (
|
|
2583
|
-
for (const name2 of
|
|
2710
|
+
if (fs11.existsSync(store)) {
|
|
2711
|
+
for (const name2 of fs11.readdirSync(store)) {
|
|
2584
2712
|
if (!name2.startsWith("dsh@") && !name2.startsWith("@deepseek-ai+dsh@"))
|
|
2585
2713
|
continue;
|
|
2586
2714
|
for (const entry of [
|
|
2587
2715
|
path9.join(store, name2, "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"),
|
|
2588
2716
|
path9.join(store, name2, "node_modules", "dsh", "bin", "dsh.js")
|
|
2589
2717
|
]) {
|
|
2590
|
-
if (
|
|
2718
|
+
if (fs11.existsSync(entry))
|
|
2591
2719
|
out.push(entry);
|
|
2592
2720
|
}
|
|
2593
2721
|
}
|
|
@@ -2598,12 +2726,32 @@ function dshSearchRoot(entry) {
|
|
|
2598
2726
|
if (typeof entry !== "string" || entry.length === 0)
|
|
2599
2727
|
return null;
|
|
2600
2728
|
const parts = entry.split(path9.sep);
|
|
2601
|
-
const store = parts.
|
|
2602
|
-
const modules =
|
|
2603
|
-
|
|
2729
|
+
const store = parts.lastIndexOf(".pnpm");
|
|
2730
|
+
const modules = parts.lastIndexOf("node_modules");
|
|
2731
|
+
const root = store > 0 ? parts.lastIndexOf("node_modules", store) : modules;
|
|
2732
|
+
if (root > 0)
|
|
2733
|
+
return parts.slice(0, root).join(path9.sep);
|
|
2734
|
+
return ownerRoot(entry) ?? (parts.length > 2 ? parts.slice(0, -2).join(path9.sep) : null);
|
|
2735
|
+
}
|
|
2736
|
+
function ownerRoot(entry) {
|
|
2737
|
+
let dir = path9.dirname(entry);
|
|
2738
|
+
for (let hop = 0; hop < 4; hop += 1) {
|
|
2739
|
+
const manifestPath = path9.join(dir, "package.json");
|
|
2740
|
+
const manifest = readJson(manifestPath);
|
|
2741
|
+
if (manifest !== null) {
|
|
2742
|
+
const declared = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.["dsh"] ?? Object.values(manifest.bin ?? {})[0];
|
|
2743
|
+
if (typeof declared === "string" && path9.resolve(path9.dirname(manifestPath), declared) === entry)
|
|
2744
|
+
return dir;
|
|
2745
|
+
}
|
|
2746
|
+
const parent = path9.dirname(dir);
|
|
2747
|
+
if (parent === dir)
|
|
2748
|
+
break;
|
|
2749
|
+
dir = parent;
|
|
2750
|
+
}
|
|
2751
|
+
return null;
|
|
2604
2752
|
}
|
|
2605
2753
|
function dshRootUsable(root) {
|
|
2606
|
-
return dshCandidatesUnder(root).length > 0 ||
|
|
2754
|
+
return dshCandidatesUnder(root).length > 0 || fs11.existsSync(path9.join(root, "node_modules", ".pnpm")) || ["lib/bin.js", "lib/bin.mjs", "lib/bin.cjs", "dist/bin.js"].some((entry) => fs11.existsSync(path9.join(root, entry)));
|
|
2607
2755
|
}
|
|
2608
2756
|
|
|
2609
2757
|
// src/home-hosted/dsh-entry.ts
|
|
@@ -2638,11 +2786,11 @@ function versionOf(entry) {
|
|
|
2638
2786
|
return null;
|
|
2639
2787
|
}
|
|
2640
2788
|
function isEntry(file) {
|
|
2641
|
-
return /\.[cm]?js$/.test(file) &&
|
|
2789
|
+
return /\.[cm]?js$/.test(file) && fs12.existsSync(file) && !fs12.statSync(file).isDirectory();
|
|
2642
2790
|
}
|
|
2643
2791
|
function realPath(file) {
|
|
2644
2792
|
try {
|
|
2645
|
-
return
|
|
2793
|
+
return fs12.realpathSync(file);
|
|
2646
2794
|
} catch {
|
|
2647
2795
|
return file;
|
|
2648
2796
|
}
|
|
@@ -2683,7 +2831,7 @@ function findDshEntry(dshHome2) {
|
|
|
2683
2831
|
}
|
|
2684
2832
|
function readdirNames(dir) {
|
|
2685
2833
|
try {
|
|
2686
|
-
return
|
|
2834
|
+
return fs12.readdirSync(dir);
|
|
2687
2835
|
} catch {
|
|
2688
2836
|
return [];
|
|
2689
2837
|
}
|
|
@@ -2698,12 +2846,27 @@ async function resolveDshLaunch(options = {}) {
|
|
|
2698
2846
|
stateDir,
|
|
2699
2847
|
dshHome: dshHome2,
|
|
2700
2848
|
resolvedEntry: local,
|
|
2849
|
+
// The running image is the one a boot entry must keep running: a global
|
|
2850
|
+
// copy that reports a higher version is not an upgrade of this install.
|
|
2851
|
+
pinnedEntry: local,
|
|
2852
|
+
repin: true,
|
|
2701
2853
|
entryExtension: path10.extname(local),
|
|
2702
2854
|
// Where this install sits, so a rebuilt/moved copy is found next boot.
|
|
2703
2855
|
searchRoots: [dshSearchRoot(local)].filter((root) => root !== null)
|
|
2704
2856
|
});
|
|
2705
2857
|
return { program: process7.execPath, args: [local], cliEntry: local, shimPath: null, source: "entry", launcherPath: launcher.path };
|
|
2706
2858
|
}
|
|
2859
|
+
if (argv1 !== void 0 && argv1 !== null && realPath(argv1) === realPath(dshLauncherPath(stateDir, path10.extname(local ?? argv1)))) {
|
|
2860
|
+
const pinned = readDshLauncherRecord(stateDir)?.pinned ?? null;
|
|
2861
|
+
if (pinned !== null) {
|
|
2862
|
+
const launcher = dshLauncherPath(stateDir, path10.extname(pinned));
|
|
2863
|
+
const launch2 = resolveShimmedCli(pinned, process7.execPath);
|
|
2864
|
+
const cliEntry = launch2?.cliEntry ?? pinned;
|
|
2865
|
+
if (readDshLauncherRecord(stateDir)?.entry !== pinned)
|
|
2866
|
+
writeDshLauncher({ stateDir, dshHome: dshHome2, resolvedEntry: cliEntry, pinnedEntry: pinned, entryExtension: path10.extname(pinned) });
|
|
2867
|
+
return { program: process7.execPath, args: [launcher], cliEntry, shimPath: null, source: "entry", launcherPath: launcher };
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2707
2870
|
const installed = findDshEntry(dshHome2);
|
|
2708
2871
|
if (installed !== null) {
|
|
2709
2872
|
const launcher = writeDshLauncher({ stateDir, dshHome: dshHome2, resolvedEntry: installed, entryExtension: path10.extname(installed) });
|
|
@@ -2712,6 +2875,8 @@ async function resolveDshLaunch(options = {}) {
|
|
|
2712
2875
|
const found = await (options.findOnPath ?? which)("dsh");
|
|
2713
2876
|
if (found === null)
|
|
2714
2877
|
return null;
|
|
2878
|
+
if (readDshLauncherRecord(stateDir)?.pinned != null)
|
|
2879
|
+
return null;
|
|
2715
2880
|
const launch = resolveShimmedCli(found, process7.execPath);
|
|
2716
2881
|
return launch === null ? null : { ...launch, launcherPath: null };
|
|
2717
2882
|
}
|
|
@@ -2729,25 +2894,44 @@ function entryScript(config) {
|
|
|
2729
2894
|
const rest = config.args ?? [];
|
|
2730
2895
|
return rest.find((arg) => path10.isAbsolute(arg)) ?? program;
|
|
2731
2896
|
}
|
|
2897
|
+
function pinnedScriptFor(launch) {
|
|
2898
|
+
if (launch === null)
|
|
2899
|
+
return null;
|
|
2900
|
+
const launcher = launch.launcherPath;
|
|
2901
|
+
if (launcher !== null && launcher.length > 0)
|
|
2902
|
+
return launcher;
|
|
2903
|
+
return launch.cliEntry ?? launch.args.find((arg) => path10.isAbsolute(arg)) ?? null;
|
|
2904
|
+
}
|
|
2905
|
+
function isBareCommand(stored) {
|
|
2906
|
+
return !/[/\\]/.test(stored) && (stored === "dsh" || /^dsh\.(?:cmd|exe)$/i.test(stored));
|
|
2907
|
+
}
|
|
2732
2908
|
function needsLauncherRepair(config) {
|
|
2733
2909
|
const program = config.command;
|
|
2734
2910
|
if (typeof program !== "string" || program.length === 0)
|
|
2735
2911
|
return false;
|
|
2736
2912
|
if (path10.isAbsolute(program))
|
|
2737
2913
|
return true;
|
|
2738
|
-
|
|
2914
|
+
if (isBareCommand(program))
|
|
2915
|
+
return true;
|
|
2916
|
+
return (config.args ?? []).some((arg) => path10.isAbsolute(arg));
|
|
2739
2917
|
}
|
|
2740
2918
|
function launcherRepair(config, launch) {
|
|
2741
|
-
|
|
2919
|
+
const pinned = pinnedScriptFor(launch);
|
|
2920
|
+
if (pinned === null || launch === null)
|
|
2742
2921
|
return null;
|
|
2743
|
-
const launcher = launch.launcherPath;
|
|
2744
2922
|
const stored = entryScript(config);
|
|
2745
|
-
if (stored === null || stored ===
|
|
2923
|
+
if (stored === null || stored === pinned)
|
|
2746
2924
|
return null;
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2925
|
+
const bare = isBareCommand(stored);
|
|
2926
|
+
if (!bare) {
|
|
2927
|
+
const target = resolveShimmedCli(stored, process7.execPath)?.cliEntry ?? stored;
|
|
2928
|
+
if (target !== pinned && target !== launch.cliEntry)
|
|
2929
|
+
return null;
|
|
2930
|
+
}
|
|
2931
|
+
const args = config.args ?? [];
|
|
2932
|
+
const inline = bare ? -1 : args.findIndex((arg) => path10.isAbsolute(arg));
|
|
2933
|
+
const rest = inline === -1 ? args : args.filter((_, index) => index !== inline);
|
|
2934
|
+
return { command: process7.execPath, args: [pinned, ...rest] };
|
|
2751
2935
|
}
|
|
2752
2936
|
function buildDshEntry(facts) {
|
|
2753
2937
|
const launch = facts.launch;
|
|
@@ -2789,6 +2973,186 @@ function buildDshEntry(facts) {
|
|
|
2789
2973
|
};
|
|
2790
2974
|
}
|
|
2791
2975
|
|
|
2976
|
+
// src/home-hosted/instances.ts
|
|
2977
|
+
import fs13 from "node:fs";
|
|
2978
|
+
import os4 from "node:os";
|
|
2979
|
+
import path11 from "node:path";
|
|
2980
|
+
|
|
2981
|
+
// src/home-hosted/runtime.ts
|
|
2982
|
+
import http from "node:http";
|
|
2983
|
+
import https from "node:https";
|
|
2984
|
+
import process8 from "node:process";
|
|
2985
|
+
function readRuntime(home) {
|
|
2986
|
+
const raw = readJson(runtimeFile(home));
|
|
2987
|
+
if (raw === null)
|
|
2988
|
+
return null;
|
|
2989
|
+
const url = typeof raw.url === "string" ? raw.url : null;
|
|
2990
|
+
const pid = typeof raw.pid === "number" ? raw.pid : null;
|
|
2991
|
+
if (url === null || pid === null)
|
|
2992
|
+
return null;
|
|
2993
|
+
return {
|
|
2994
|
+
version: typeof raw.version === "string" ? raw.version : "unknown",
|
|
2995
|
+
pid,
|
|
2996
|
+
url: url.replace(/\/+$/, ""),
|
|
2997
|
+
probeUrl: typeof raw.probeUrl === "string" ? raw.probeUrl : void 0,
|
|
2998
|
+
protocol: typeof raw.protocol === "string" ? raw.protocol : void 0,
|
|
2999
|
+
port: typeof raw.port === "number" ? raw.port : 0,
|
|
3000
|
+
bindHost: typeof raw.bindHost === "string" ? raw.bindHost : void 0,
|
|
3001
|
+
projectDir: typeof raw.projectDir === "string" ? raw.projectDir : void 0,
|
|
3002
|
+
dataRoot: typeof raw.dataRoot === "string" ? raw.dataRoot : void 0,
|
|
3003
|
+
configPath: typeof raw.configPath === "string" ? raw.configPath : void 0,
|
|
3004
|
+
logFile: typeof raw.logFile === "string" ? raw.logFile : void 0
|
|
3005
|
+
};
|
|
3006
|
+
}
|
|
3007
|
+
function pidAlive(pid) {
|
|
3008
|
+
try {
|
|
3009
|
+
process8.kill(pid, 0);
|
|
3010
|
+
return true;
|
|
3011
|
+
} catch {
|
|
3012
|
+
return false;
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
async function probePanel(url, timeoutMs = 2e3) {
|
|
3016
|
+
const target = `${url.replace(/\/+$/, "")}/healthz`;
|
|
3017
|
+
return await new Promise((resolve) => {
|
|
3018
|
+
let settled = false;
|
|
3019
|
+
const done = (value) => {
|
|
3020
|
+
if (settled)
|
|
3021
|
+
return;
|
|
3022
|
+
settled = true;
|
|
3023
|
+
resolve(value);
|
|
3024
|
+
};
|
|
3025
|
+
let parsed;
|
|
3026
|
+
try {
|
|
3027
|
+
parsed = new URL(target);
|
|
3028
|
+
} catch {
|
|
3029
|
+
done(false);
|
|
3030
|
+
return;
|
|
3031
|
+
}
|
|
3032
|
+
const transport = parsed.protocol === "https:" ? https : http;
|
|
3033
|
+
const request = transport.get(
|
|
3034
|
+
{
|
|
3035
|
+
hostname: parsed.hostname,
|
|
3036
|
+
port: parsed.port,
|
|
3037
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
3038
|
+
timeout: timeoutMs,
|
|
3039
|
+
rejectUnauthorized: false
|
|
3040
|
+
},
|
|
3041
|
+
(response) => {
|
|
3042
|
+
response.resume();
|
|
3043
|
+
done(typeof response.statusCode === "number");
|
|
3044
|
+
}
|
|
3045
|
+
);
|
|
3046
|
+
request.on("timeout", () => {
|
|
3047
|
+
request.destroy();
|
|
3048
|
+
done(false);
|
|
3049
|
+
});
|
|
3050
|
+
request.on("error", () => done(false));
|
|
3051
|
+
});
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
// src/home-hosted/instances.ts
|
|
3055
|
+
var SIBLING_PATTERN = /^\.home-hosted(?:[._-].*)?$/;
|
|
3056
|
+
function canonicalPath(value) {
|
|
3057
|
+
const resolved = path11.resolve(expandHome(value));
|
|
3058
|
+
try {
|
|
3059
|
+
return fs13.realpathSync.native(resolved);
|
|
3060
|
+
} catch {
|
|
3061
|
+
return resolved;
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
function defaultListDir(dir) {
|
|
3065
|
+
try {
|
|
3066
|
+
return fs13.readdirSync(dir);
|
|
3067
|
+
} catch {
|
|
3068
|
+
return null;
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
function isStateRoot(home) {
|
|
3072
|
+
try {
|
|
3073
|
+
if (!fs13.statSync(home).isDirectory())
|
|
3074
|
+
return false;
|
|
3075
|
+
} catch {
|
|
3076
|
+
return false;
|
|
3077
|
+
}
|
|
3078
|
+
for (const file of [configFile(home), runtimeFile(home)]) {
|
|
3079
|
+
try {
|
|
3080
|
+
if (fs13.statSync(file).isFile())
|
|
3081
|
+
return true;
|
|
3082
|
+
} catch {
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
return false;
|
|
3086
|
+
}
|
|
3087
|
+
function viewOf(candidate, managed, envHome, hosting, alive) {
|
|
3088
|
+
const runtime = readRuntime(candidate.home);
|
|
3089
|
+
const read = readConfig(candidate.home);
|
|
3090
|
+
return {
|
|
3091
|
+
home: candidate.home,
|
|
3092
|
+
managed: candidate.home === managed,
|
|
3093
|
+
hosting: hosting && candidate.home === envHome,
|
|
3094
|
+
url: runtime?.url ?? null,
|
|
3095
|
+
port: runtime?.port ?? null,
|
|
3096
|
+
pid: runtime?.pid ?? null,
|
|
3097
|
+
version: runtime?.version ?? null,
|
|
3098
|
+
// A `run.json` names the process that wrote it; a recycled pid is not worth
|
|
3099
|
+
// chasing here, because a stale one only mislabels a panel nobody acts on.
|
|
3100
|
+
running: runtime !== null && alive(runtime.pid),
|
|
3101
|
+
projectDir: runtime?.projectDir ?? null,
|
|
3102
|
+
servers: read.error === null ? (read.raw?.servers ?? []).length : null,
|
|
3103
|
+
source: candidate.source
|
|
3104
|
+
};
|
|
3105
|
+
}
|
|
3106
|
+
function discoverInstances(options) {
|
|
3107
|
+
const managed = canonicalPath(options.managedHome);
|
|
3108
|
+
const listDir = options.listDir ?? defaultListDir;
|
|
3109
|
+
const alive = options.alive ?? pidAlive;
|
|
3110
|
+
const envRaw = options.envHome?.trim();
|
|
3111
|
+
const envHome = envRaw !== void 0 && envRaw.length > 0 ? canonicalPath(envRaw) : null;
|
|
3112
|
+
const hostingEntryId = options.hostingEntryId?.trim() ?? "";
|
|
3113
|
+
const candidates = [{ home: managed, source: "managed" }];
|
|
3114
|
+
if (envHome !== null)
|
|
3115
|
+
candidates.push({ home: envHome, source: "env" });
|
|
3116
|
+
for (const root of options.extraRoots ?? []) {
|
|
3117
|
+
if (root.trim().length > 0)
|
|
3118
|
+
candidates.push({ home: canonicalPath(root), source: "configured" });
|
|
3119
|
+
}
|
|
3120
|
+
const homeDir = options.homeDir ?? os4.homedir();
|
|
3121
|
+
for (const name2 of listDir(homeDir) ?? []) {
|
|
3122
|
+
if (SIBLING_PATTERN.test(name2))
|
|
3123
|
+
candidates.push({ home: canonicalPath(path11.join(homeDir, name2)), source: "sibling" });
|
|
3124
|
+
}
|
|
3125
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3126
|
+
const views = [];
|
|
3127
|
+
for (const candidate of candidates) {
|
|
3128
|
+
if (seen.has(candidate.home))
|
|
3129
|
+
continue;
|
|
3130
|
+
seen.add(candidate.home);
|
|
3131
|
+
if (candidate.source !== "managed" && !isStateRoot(candidate.home))
|
|
3132
|
+
continue;
|
|
3133
|
+
views.push(viewOf(candidate, managed, envHome, hostingEntryId.length > 0, alive));
|
|
3134
|
+
}
|
|
3135
|
+
views.sort((left, right) => left.managed === right.managed ? left.home.localeCompare(right.home) : left.managed ? -1 : 1);
|
|
3136
|
+
return views;
|
|
3137
|
+
}
|
|
3138
|
+
function describeInstance(instance) {
|
|
3139
|
+
if (instance.url === null)
|
|
3140
|
+
return `${instance.home} (no runtime)`;
|
|
3141
|
+
return `${instance.home} (${instance.url}${instance.running ? "" : ", stopped"})`;
|
|
3142
|
+
}
|
|
3143
|
+
function instancesNoticeText(instances) {
|
|
3144
|
+
const managed = instances.find((instance) => instance.managed);
|
|
3145
|
+
const others = instances.filter((instance) => !instance.managed);
|
|
3146
|
+
if (managed === void 0 || others.length === 0)
|
|
3147
|
+
return null;
|
|
3148
|
+
return [
|
|
3149
|
+
`home-hosted: ${instances.length} panels were found on this machine, and this plugin manages only ${describeInstance(managed)}.`,
|
|
3150
|
+
`Other panels: ${others.map(describeInstance).join("; ")}.`,
|
|
3151
|
+
'Act on the managed panel unless the user names another; a home_hosted_* call takes "instance" to name one.',
|
|
3152
|
+
"Naming another panel is allowed for methods that can reach it: the plugin asks the user how, then edits that panel's config file, runs the CLI against its state root, or \u2014 only if they choose it \u2014 mints a token for that panel and uses its API."
|
|
3153
|
+
].join(" ");
|
|
3154
|
+
}
|
|
3155
|
+
|
|
2792
3156
|
// src/home-hosted/entries.ts
|
|
2793
3157
|
function defaultOnPortConflict(platform = process.platform) {
|
|
2794
3158
|
return platform === "win32" ? "kill" : "follow";
|
|
@@ -2802,16 +3166,16 @@ function defaultIntent(id, platform = process.platform) {
|
|
|
2802
3166
|
persistent: true
|
|
2803
3167
|
};
|
|
2804
3168
|
}
|
|
2805
|
-
function ownedPatch(intent, live
|
|
3169
|
+
function ownedPatch(intent, live) {
|
|
2806
3170
|
const stop = live?.stop ?? {};
|
|
2807
3171
|
return {
|
|
2808
3172
|
autostart: intent.autostart,
|
|
2809
3173
|
onPortConflict: intent.onPortConflict,
|
|
2810
|
-
|
|
3174
|
+
persistent: intent.persistent,
|
|
2811
3175
|
stop: { ...stop, killPortHolders: intent.stopKillPortHolders }
|
|
2812
3176
|
};
|
|
2813
3177
|
}
|
|
2814
|
-
function ownedDrift(live, intent
|
|
3178
|
+
function ownedDrift(live, intent) {
|
|
2815
3179
|
if (live === null)
|
|
2816
3180
|
return ["missing entry"];
|
|
2817
3181
|
const drift = [];
|
|
@@ -2819,7 +3183,7 @@ function ownedDrift(live, intent, options = {}) {
|
|
|
2819
3183
|
drift.push("autostart");
|
|
2820
3184
|
if (live.onPortConflict !== intent.onPortConflict)
|
|
2821
3185
|
drift.push("onPortConflict");
|
|
2822
|
-
if (
|
|
3186
|
+
if ((live.persistent ?? false) !== intent.persistent)
|
|
2823
3187
|
drift.push("persistent");
|
|
2824
3188
|
const killPortHolders = live.stop?.killPortHolders;
|
|
2825
3189
|
if (killPortHolders !== intent.stopKillPortHolders)
|
|
@@ -2852,9 +3216,9 @@ function restorePatch(live, snapshot) {
|
|
|
2852
3216
|
|
|
2853
3217
|
// src/home-hosted/panel-control.ts
|
|
2854
3218
|
import { spawn as spawn2 } from "node:child_process";
|
|
2855
|
-
import
|
|
2856
|
-
import
|
|
2857
|
-
import
|
|
3219
|
+
import fs14 from "node:fs";
|
|
3220
|
+
import path12 from "node:path";
|
|
3221
|
+
import process9 from "node:process";
|
|
2858
3222
|
function cliArgs(deps, command) {
|
|
2859
3223
|
const args = [...deps.launch?.args ?? [], command, "--home", deps.home];
|
|
2860
3224
|
if (deps.projectDir)
|
|
@@ -2862,10 +3226,13 @@ function cliArgs(deps, command) {
|
|
|
2862
3226
|
return args;
|
|
2863
3227
|
}
|
|
2864
3228
|
async function runCli(deps, command) {
|
|
3229
|
+
const args = cliArgs(deps, command);
|
|
3230
|
+
if (deps.exec !== void 0)
|
|
3231
|
+
return await deps.exec(args, deps.env);
|
|
2865
3232
|
const launch = deps.launch;
|
|
2866
3233
|
if (launch === null)
|
|
2867
3234
|
return { code: null, stdout: "", stderr: "no home-hosted CLI is available" };
|
|
2868
|
-
return await run(launch.program,
|
|
3235
|
+
return await run(launch.program, args, {
|
|
2869
3236
|
env: deps.env,
|
|
2870
3237
|
timeoutMs: deps.timeoutMs ?? 9e4
|
|
2871
3238
|
});
|
|
@@ -2879,14 +3246,22 @@ async function startPanel(deps) {
|
|
|
2879
3246
|
const match = /https?:\/\/[^\s]+/.exec(result.stdout);
|
|
2880
3247
|
return { ok: true, detail: "the panel is answering", url: match?.[0] ?? null };
|
|
2881
3248
|
}
|
|
3249
|
+
async function stopPanel(deps) {
|
|
3250
|
+
const result = await runCli(deps, "down");
|
|
3251
|
+
if (result.code !== 0) {
|
|
3252
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `the CLI exited ${String(result.code)}`;
|
|
3253
|
+
return { ok: false, detail: `the panel did not stop: ${detail}` };
|
|
3254
|
+
}
|
|
3255
|
+
return { ok: true, detail: "the panel stopped; the servers it supervised stopped with it" };
|
|
3256
|
+
}
|
|
2882
3257
|
function takeoverHelperPath(stateDir) {
|
|
2883
|
-
return
|
|
3258
|
+
return path12.join(launcherDir(stateDir), "panel-takeover.mjs");
|
|
2884
3259
|
}
|
|
2885
3260
|
function takeoverLogPath(stateDir) {
|
|
2886
|
-
return
|
|
3261
|
+
return path12.join(launcherDir(stateDir), "panel-takeover.log");
|
|
2887
3262
|
}
|
|
2888
3263
|
function buildTakeoverSource(deps, oldPid, marker = "managed by dsh-home-hosted") {
|
|
2889
|
-
const program = deps.launch?.program ??
|
|
3264
|
+
const program = deps.launch?.program ?? process9.execPath;
|
|
2890
3265
|
const args = deps.launch === null ? [] : [...deps.launch.args, "up", "--home", deps.home, ...deps.projectDir ? ["--project", deps.projectDir] : []];
|
|
2891
3266
|
const downArgs = [...deps.launch?.args ?? [], "down", "--home", deps.home];
|
|
2892
3267
|
return `#!/usr/bin/env node
|
|
@@ -2941,7 +3316,7 @@ log('started with exit ' + String(result.status))
|
|
|
2941
3316
|
function writeTakeoverHelper(deps, oldPid) {
|
|
2942
3317
|
const file = takeoverHelperPath(deps.stateDir);
|
|
2943
3318
|
writeFileAtomic(file, buildTakeoverSource(deps, oldPid), 493);
|
|
2944
|
-
|
|
3319
|
+
fs14.chmodSync(file, 493);
|
|
2945
3320
|
return file;
|
|
2946
3321
|
}
|
|
2947
3322
|
function spawnDetached(program, args) {
|
|
@@ -2951,7 +3326,7 @@ function spawnTakeover(deps, oldPid, spawnChild = spawnDetached) {
|
|
|
2951
3326
|
const helper = writeTakeoverHelper(deps, oldPid);
|
|
2952
3327
|
let child;
|
|
2953
3328
|
try {
|
|
2954
|
-
child = spawnChild(
|
|
3329
|
+
child = spawnChild(process9.execPath, [helper]);
|
|
2955
3330
|
} catch (error) {
|
|
2956
3331
|
return { ok: false, detail: `could not start the panel-replacement helper: ${error instanceof Error ? error.message : String(error)}` };
|
|
2957
3332
|
}
|
|
@@ -2993,10 +3368,10 @@ var PanelClient = class {
|
|
|
2993
3368
|
this.token = options.token;
|
|
2994
3369
|
this.timeoutMs = options.timeoutMs ?? 1e4;
|
|
2995
3370
|
}
|
|
2996
|
-
async request(method,
|
|
3371
|
+
async request(method, path16, body) {
|
|
2997
3372
|
let response;
|
|
2998
3373
|
try {
|
|
2999
|
-
response = await fetch(`${this.baseUrl}${
|
|
3374
|
+
response = await fetch(`${this.baseUrl}${path16}`, {
|
|
3000
3375
|
method,
|
|
3001
3376
|
headers: {
|
|
3002
3377
|
authorization: `Bearer ${this.token}`,
|
|
@@ -3073,91 +3448,21 @@ async function verifyToken(baseUrl, token, timeoutMs = 5e3) {
|
|
|
3073
3448
|
return await probeToken(baseUrl, token, timeoutMs) === "ok";
|
|
3074
3449
|
}
|
|
3075
3450
|
|
|
3076
|
-
// src/home-hosted/runtime.ts
|
|
3077
|
-
import http from "node:http";
|
|
3078
|
-
import https from "node:https";
|
|
3079
|
-
import process9 from "node:process";
|
|
3080
|
-
function readRuntime(home) {
|
|
3081
|
-
const raw = readJson(runtimeFile(home));
|
|
3082
|
-
if (raw === null)
|
|
3083
|
-
return null;
|
|
3084
|
-
const url = typeof raw.url === "string" ? raw.url : null;
|
|
3085
|
-
const pid = typeof raw.pid === "number" ? raw.pid : null;
|
|
3086
|
-
if (url === null || pid === null)
|
|
3087
|
-
return null;
|
|
3088
|
-
return {
|
|
3089
|
-
version: typeof raw.version === "string" ? raw.version : "unknown",
|
|
3090
|
-
pid,
|
|
3091
|
-
url: url.replace(/\/+$/, ""),
|
|
3092
|
-
probeUrl: typeof raw.probeUrl === "string" ? raw.probeUrl : void 0,
|
|
3093
|
-
protocol: typeof raw.protocol === "string" ? raw.protocol : void 0,
|
|
3094
|
-
port: typeof raw.port === "number" ? raw.port : 0,
|
|
3095
|
-
bindHost: typeof raw.bindHost === "string" ? raw.bindHost : void 0,
|
|
3096
|
-
projectDir: typeof raw.projectDir === "string" ? raw.projectDir : void 0,
|
|
3097
|
-
dataRoot: typeof raw.dataRoot === "string" ? raw.dataRoot : void 0,
|
|
3098
|
-
configPath: typeof raw.configPath === "string" ? raw.configPath : void 0,
|
|
3099
|
-
logFile: typeof raw.logFile === "string" ? raw.logFile : void 0
|
|
3100
|
-
};
|
|
3101
|
-
}
|
|
3102
|
-
function pidAlive(pid) {
|
|
3103
|
-
try {
|
|
3104
|
-
process9.kill(pid, 0);
|
|
3105
|
-
return true;
|
|
3106
|
-
} catch {
|
|
3107
|
-
return false;
|
|
3108
|
-
}
|
|
3109
|
-
}
|
|
3110
|
-
async function probePanel(url, timeoutMs = 2e3) {
|
|
3111
|
-
const target = `${url.replace(/\/+$/, "")}/healthz`;
|
|
3112
|
-
return await new Promise((resolve) => {
|
|
3113
|
-
let settled = false;
|
|
3114
|
-
const done = (value) => {
|
|
3115
|
-
if (settled)
|
|
3116
|
-
return;
|
|
3117
|
-
settled = true;
|
|
3118
|
-
resolve(value);
|
|
3119
|
-
};
|
|
3120
|
-
let parsed;
|
|
3121
|
-
try {
|
|
3122
|
-
parsed = new URL(target);
|
|
3123
|
-
} catch {
|
|
3124
|
-
done(false);
|
|
3125
|
-
return;
|
|
3126
|
-
}
|
|
3127
|
-
const transport = parsed.protocol === "https:" ? https : http;
|
|
3128
|
-
const request = transport.get(
|
|
3129
|
-
{
|
|
3130
|
-
hostname: parsed.hostname,
|
|
3131
|
-
port: parsed.port,
|
|
3132
|
-
path: `${parsed.pathname}${parsed.search}`,
|
|
3133
|
-
timeout: timeoutMs,
|
|
3134
|
-
rejectUnauthorized: false
|
|
3135
|
-
},
|
|
3136
|
-
(response) => {
|
|
3137
|
-
response.resume();
|
|
3138
|
-
done(typeof response.statusCode === "number");
|
|
3139
|
-
}
|
|
3140
|
-
);
|
|
3141
|
-
request.on("timeout", () => {
|
|
3142
|
-
request.destroy();
|
|
3143
|
-
done(false);
|
|
3144
|
-
});
|
|
3145
|
-
request.on("error", () => done(false));
|
|
3146
|
-
});
|
|
3147
|
-
}
|
|
3148
|
-
|
|
3149
3451
|
// src/home-hosted/token.ts
|
|
3150
|
-
import { randomBytes } from "node:crypto";
|
|
3151
|
-
import
|
|
3152
|
-
function
|
|
3153
|
-
return
|
|
3452
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3453
|
+
import path13 from "node:path";
|
|
3454
|
+
function tokenSlot(home) {
|
|
3455
|
+
return createHash("sha256").update(path13.resolve(home)).digest("hex").slice(0, 16);
|
|
3154
3456
|
}
|
|
3155
|
-
function
|
|
3156
|
-
|
|
3457
|
+
function storedTokenPath(stateDir, slot) {
|
|
3458
|
+
return slot === void 0 ? path13.join(stateDir, "panel-token") : path13.join(stateDir, "panel-tokens", `${slot}.token`);
|
|
3459
|
+
}
|
|
3460
|
+
function readStoredToken(stateDir, slot) {
|
|
3461
|
+
const text = readText(storedTokenPath(stateDir, slot))?.trim();
|
|
3157
3462
|
return text !== void 0 && text.length > 0 ? text : null;
|
|
3158
3463
|
}
|
|
3159
|
-
function storeToken(stateDir, token) {
|
|
3160
|
-
writeFileAtomic(storedTokenPath(stateDir), `${token}
|
|
3464
|
+
function storeToken(stateDir, token, slot) {
|
|
3465
|
+
writeFileAtomic(storedTokenPath(stateDir, slot), `${token}
|
|
3161
3466
|
`, 384);
|
|
3162
3467
|
}
|
|
3163
3468
|
function generateToken() {
|
|
@@ -3168,8 +3473,8 @@ function apiTokenEnrolled(home) {
|
|
|
3168
3473
|
return secrets !== null && secrets.apiToken !== null && secrets.apiToken !== void 0;
|
|
3169
3474
|
}
|
|
3170
3475
|
var queue = /* @__PURE__ */ new Map();
|
|
3171
|
-
function serialised(stateDir, work) {
|
|
3172
|
-
const key =
|
|
3476
|
+
function serialised(stateDir, slot, work) {
|
|
3477
|
+
const key = `${path13.resolve(stateDir)}\0${slot ?? ""}`;
|
|
3173
3478
|
const previous = queue.get(key) ?? Promise.resolve();
|
|
3174
3479
|
const next = previous.then(work, work);
|
|
3175
3480
|
const settled = next.then(() => void 0, () => void 0);
|
|
@@ -3180,10 +3485,10 @@ function serialised(stateDir, work) {
|
|
|
3180
3485
|
return next;
|
|
3181
3486
|
}
|
|
3182
3487
|
async function ensureToken(options) {
|
|
3183
|
-
return await serialised(options.stateDir, async () => await enrollToken(options));
|
|
3488
|
+
return await serialised(options.stateDir, options.slot, async () => await enrollToken(options));
|
|
3184
3489
|
}
|
|
3185
3490
|
async function reclaimToken(options) {
|
|
3186
|
-
return await serialised(options.stateDir, async () => await enrollFreshToken(options));
|
|
3491
|
+
return await serialised(options.stateDir, options.slot, async () => await enrollFreshToken(options));
|
|
3187
3492
|
}
|
|
3188
3493
|
async function runCli2(exec, args, env) {
|
|
3189
3494
|
try {
|
|
@@ -3199,7 +3504,7 @@ async function runCli2(exec, args, env) {
|
|
|
3199
3504
|
}
|
|
3200
3505
|
}
|
|
3201
3506
|
async function enrollToken(options) {
|
|
3202
|
-
const stored = readStoredToken(options.stateDir);
|
|
3507
|
+
const stored = readStoredToken(options.stateDir, options.slot);
|
|
3203
3508
|
if (stored !== null)
|
|
3204
3509
|
return { token: stored, enrolled: false, detail: "using the stored panel token" };
|
|
3205
3510
|
if (apiTokenEnrolled(options.home)) {
|
|
@@ -3218,7 +3523,7 @@ async function enrollToken(options) {
|
|
|
3218
3523
|
detail: `could not enrol an API token: ${enrolled.failure ?? "the CLI did not answer"}`
|
|
3219
3524
|
};
|
|
3220
3525
|
}
|
|
3221
|
-
storeToken(options.stateDir, token);
|
|
3526
|
+
storeToken(options.stateDir, token, options.slot);
|
|
3222
3527
|
return { token, enrolled: true, detail: "enrolled a new panel API token" };
|
|
3223
3528
|
}
|
|
3224
3529
|
async function enrollFreshToken(options) {
|
|
@@ -3230,7 +3535,7 @@ async function enrollFreshToken(options) {
|
|
|
3230
3535
|
detail: `could not enrol a fresh API token: ${enrolled.failure ?? "the CLI did not answer"}; the previously stored token was kept`
|
|
3231
3536
|
};
|
|
3232
3537
|
}
|
|
3233
|
-
storeToken(options.stateDir, token);
|
|
3538
|
+
storeToken(options.stateDir, token, options.slot);
|
|
3234
3539
|
return { token, detail: "enrolled a fresh panel API token" };
|
|
3235
3540
|
}
|
|
3236
3541
|
|
|
@@ -3238,9 +3543,10 @@ async function enrollFreshToken(options) {
|
|
|
3238
3543
|
var ENTRY_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
|
3239
3544
|
var TOKEN_PROBE_TIMEOUT_MS = 3e3;
|
|
3240
3545
|
var ENTRY_RECOVERY_INTERVAL_MS = 3e4;
|
|
3546
|
+
var INSTANCES_CACHE_MS = 1e4;
|
|
3241
3547
|
function pluginRoot() {
|
|
3242
3548
|
try {
|
|
3243
|
-
return
|
|
3549
|
+
return path14.dirname(path14.dirname(fileURLToPath(import.meta.url)));
|
|
3244
3550
|
} catch {
|
|
3245
3551
|
return null;
|
|
3246
3552
|
}
|
|
@@ -3252,11 +3558,14 @@ var HomeHostedError = class extends Error {
|
|
|
3252
3558
|
this.name = "HomeHostedError";
|
|
3253
3559
|
}
|
|
3254
3560
|
};
|
|
3561
|
+
function foreignView(entry) {
|
|
3562
|
+
return { id: entry.id, status: "unknown", pid: null, url: null, config: entry };
|
|
3563
|
+
}
|
|
3255
3564
|
var HomeHostedService = class extends Service {
|
|
3256
3565
|
constructor(ctx, options) {
|
|
3257
3566
|
super(ctx, "homeHosted");
|
|
3258
3567
|
this.options = options;
|
|
3259
|
-
this.snapshotsFile =
|
|
3568
|
+
this.snapshotsFile = path14.join(options.stateDir, "snapshots.json");
|
|
3260
3569
|
}
|
|
3261
3570
|
clientCache = null;
|
|
3262
3571
|
/** The last token this service proved against a panel, so a poll does not re-probe it. */
|
|
@@ -3264,6 +3573,8 @@ var HomeHostedService = class extends Service {
|
|
|
3264
3573
|
tokenDetail = "";
|
|
3265
3574
|
/** When a missing managed entry was last put back, so a poll cannot become a write loop. */
|
|
3266
3575
|
entryRecoveryAt = 0;
|
|
3576
|
+
/** The last panel inventory, so a model-step assembly never reads the disk. */
|
|
3577
|
+
instanceCache = null;
|
|
3267
3578
|
snapshotsFile;
|
|
3268
3579
|
async resolveDsh(dshHome2) {
|
|
3269
3580
|
return await (this.options.resolveDsh ?? ((options) => resolveDshLaunch(options)))({
|
|
@@ -3272,6 +3583,185 @@ var HomeHostedService = class extends Service {
|
|
|
3272
3583
|
});
|
|
3273
3584
|
}
|
|
3274
3585
|
// -------------------------------------------------------------------------
|
|
3586
|
+
// Panels on this machine
|
|
3587
|
+
// -------------------------------------------------------------------------
|
|
3588
|
+
/**
|
|
3589
|
+
* The panel inventory, cached for a short while: the page polls status and
|
|
3590
|
+
* the prompt provider reads it at every assembly. `refresh` (or an expired
|
|
3591
|
+
* cache) re-reads the disk.
|
|
3592
|
+
*/
|
|
3593
|
+
async instances(refresh = false) {
|
|
3594
|
+
if (refresh || this.instanceCache === null || Date.now() - this.instanceCache.at >= INSTANCES_CACHE_MS)
|
|
3595
|
+
return this.measureInstances();
|
|
3596
|
+
return this.instanceCache.list;
|
|
3597
|
+
}
|
|
3598
|
+
/**
|
|
3599
|
+
* The inventory for a caller that cannot await — a prompt provider runs
|
|
3600
|
+
* synchronously before a model step. Discovery is synchronous too, so this
|
|
3601
|
+
* re-measures a stale cache in place and never blocks the step.
|
|
3602
|
+
*/
|
|
3603
|
+
instancesNow() {
|
|
3604
|
+
if (this.instanceCache !== null && Date.now() - this.instanceCache.at < INSTANCES_CACHE_MS)
|
|
3605
|
+
return this.instanceCache.list;
|
|
3606
|
+
return this.measureInstances();
|
|
3607
|
+
}
|
|
3608
|
+
measureInstances() {
|
|
3609
|
+
const now = Date.now();
|
|
3610
|
+
try {
|
|
3611
|
+
this.instanceCache = {
|
|
3612
|
+
at: now,
|
|
3613
|
+
list: discoverInstances({
|
|
3614
|
+
managedHome: this.options.home,
|
|
3615
|
+
extraRoots: this.options.instanceRoots,
|
|
3616
|
+
envHome: this.options.envHome === void 0 ? process10.env.HHOSTED_HOME ?? null : this.options.envHome,
|
|
3617
|
+
homeDir: this.options.homeDir,
|
|
3618
|
+
hostingEntryId: this.selfEntryId()
|
|
3619
|
+
})
|
|
3620
|
+
};
|
|
3621
|
+
} catch {
|
|
3622
|
+
this.instanceCache = { at: now, list: [] };
|
|
3623
|
+
}
|
|
3624
|
+
return this.instanceCache.list;
|
|
3625
|
+
}
|
|
3626
|
+
/** The inventory as last measured, without touching the disk. */
|
|
3627
|
+
instancesSnapshot() {
|
|
3628
|
+
return this.instanceCache?.list ?? [];
|
|
3629
|
+
}
|
|
3630
|
+
// -------------------------------------------------------------------------
|
|
3631
|
+
// Acting on another panel
|
|
3632
|
+
// -------------------------------------------------------------------------
|
|
3633
|
+
/**
|
|
3634
|
+
* The state root a call targets: the managed one unless it names another panel
|
|
3635
|
+
* discovery reported. A caller-supplied path is never acted on directly, so a
|
|
3636
|
+
* stray string cannot become a way to write anywhere on the disk.
|
|
3637
|
+
*/
|
|
3638
|
+
async targetHome(requested) {
|
|
3639
|
+
if (requested === void 0 || requested === null || requested === "")
|
|
3640
|
+
return { home: this.options.home, foreign: false };
|
|
3641
|
+
if (typeof requested !== "string")
|
|
3642
|
+
throw new HomeHostedError("a panel target must be a state root path", "INSTANCE_INVALID");
|
|
3643
|
+
if (requested.trim().length === 0)
|
|
3644
|
+
return { home: this.options.home, foreign: false };
|
|
3645
|
+
const home = canonicalPath(requested);
|
|
3646
|
+
if (home === canonicalPath(this.options.home))
|
|
3647
|
+
return { home: this.options.home, foreign: false };
|
|
3648
|
+
const instances = await this.instances();
|
|
3649
|
+
if (!instances.some((instance) => instance.home === home && !instance.managed))
|
|
3650
|
+
throw new HomeHostedError(`"${requested}" is not a home-hosted panel this machine reports`, "INSTANCE_UNKNOWN");
|
|
3651
|
+
return { home, foreign: true };
|
|
3652
|
+
}
|
|
3653
|
+
/**
|
|
3654
|
+
* How a call reaches another panel. `via` names it; without one the endpoint's
|
|
3655
|
+
* least invasive mechanism is used, which is the order the shared list keeps.
|
|
3656
|
+
*/
|
|
3657
|
+
mechanismFor(endpoint, requested) {
|
|
3658
|
+
const allowed = FOREIGN_MECHANISMS[endpoint];
|
|
3659
|
+
if (allowed === void 0)
|
|
3660
|
+
throw new HomeHostedError(`"${endpoint}" cannot be aimed at another panel`, "INSTANCE_UNSUPPORTED");
|
|
3661
|
+
if (requested === void 0 || requested === null)
|
|
3662
|
+
return allowed[0];
|
|
3663
|
+
if (typeof requested === "string" && allowed.includes(requested))
|
|
3664
|
+
return requested;
|
|
3665
|
+
throw new HomeHostedError(
|
|
3666
|
+
`"${String(requested)}" is not a way to reach another panel for ${endpoint}; use one of ${allowed.join(", ")}`,
|
|
3667
|
+
"MECHANISM_UNSUPPORTED"
|
|
3668
|
+
);
|
|
3669
|
+
}
|
|
3670
|
+
/** Whether a token this plugin already holds for a panel still works. */
|
|
3671
|
+
async foreignTokenWorks(home) {
|
|
3672
|
+
const runtime = readRuntime(home);
|
|
3673
|
+
if (runtime === null || runtime.url.length === 0)
|
|
3674
|
+
return false;
|
|
3675
|
+
const stored = readStoredToken(this.options.stateDir, tokenSlot(home));
|
|
3676
|
+
return stored !== null && await verifyToken(runtime.url, stored);
|
|
3677
|
+
}
|
|
3678
|
+
/**
|
|
3679
|
+
* A client for another panel: the plugin's own credential for that state root,
|
|
3680
|
+
* minted and enrolled the first time it is needed, proved against the panel,
|
|
3681
|
+
* and replaced when the panel no longer accepts it.
|
|
3682
|
+
*
|
|
3683
|
+
* home-hosted keeps only a hash, so an existing token this plugin does not hold
|
|
3684
|
+
* can only be replaced — which is what choosing this mechanism means.
|
|
3685
|
+
*/
|
|
3686
|
+
async foreignClient(home) {
|
|
3687
|
+
const runtime = readRuntime(home);
|
|
3688
|
+
if (runtime === null || runtime.url.length === 0)
|
|
3689
|
+
throw new HomeHostedError(`no panel is running at ${home}, so its API cannot be used`, "PANEL_UNAVAILABLE");
|
|
3690
|
+
const slot = tokenSlot(home);
|
|
3691
|
+
const exec = async (args, env) => await this.cliExec(args, env);
|
|
3692
|
+
const stored = readStoredToken(this.options.stateDir, slot);
|
|
3693
|
+
if (stored !== null && await verifyToken(runtime.url, stored))
|
|
3694
|
+
return new PanelClient({ baseUrl: runtime.url, token: stored });
|
|
3695
|
+
const enrolled = stored !== null || apiTokenEnrolled(home) ? await reclaimToken({ home, stateDir: this.options.stateDir, exec, slot }) : await ensureToken({ home, stateDir: this.options.stateDir, exec, slot });
|
|
3696
|
+
if (enrolled.token === null)
|
|
3697
|
+
throw new HomeHostedError(enrolled.detail, "TOKEN_UNAVAILABLE");
|
|
3698
|
+
return new PanelClient({ baseUrl: runtime.url, token: enrolled.token });
|
|
3699
|
+
}
|
|
3700
|
+
/** Another panel's own config, refusing anything that is not a readable one. */
|
|
3701
|
+
readForeignConfig(home) {
|
|
3702
|
+
const read = readConfig(home);
|
|
3703
|
+
if (read.error !== null || read.raw === null)
|
|
3704
|
+
throw new HomeHostedError(read.error ?? `no servers config in ${home}`, "CONFIG_UNREADABLE");
|
|
3705
|
+
return read.raw;
|
|
3706
|
+
}
|
|
3707
|
+
/** Another panel's entries as its config holds them: live status needs its API token. */
|
|
3708
|
+
listForeign(home) {
|
|
3709
|
+
return (this.readForeignConfig(home).servers ?? []).map((entry) => foreignView(entry));
|
|
3710
|
+
}
|
|
3711
|
+
createForeign(home, entry) {
|
|
3712
|
+
const raw = this.readForeignConfig(home);
|
|
3713
|
+
if (findEntry(raw, entry.id) !== null)
|
|
3714
|
+
throw new HomeHostedError(`"${entry.id}" already exists in ${home}/servers.config.json`, "DUPLICATE_SERVER");
|
|
3715
|
+
writeConfig(home, upsertEntry(raw, { ...entry, autostart: false }), this.writtenBy(home));
|
|
3716
|
+
const added = this.readForeignConfig(home);
|
|
3717
|
+
writeConfig(home, patchEntry(added, entry.id, { autostart: entry.autostart === true }), this.writtenBy(home));
|
|
3718
|
+
return this.writtenForeignEntry(home, entry.id);
|
|
3719
|
+
}
|
|
3720
|
+
updateForeign(home, id, patch) {
|
|
3721
|
+
const raw = this.readForeignConfig(home);
|
|
3722
|
+
if (findEntry(raw, id) === null)
|
|
3723
|
+
throw new HomeHostedError(`no server "${id}" exists in ${home}/servers.config.json`, "UNKNOWN_SERVER");
|
|
3724
|
+
const next = patchEntry(raw, id, { ...patch, id });
|
|
3725
|
+
writeConfig(home, next, this.writtenBy(home));
|
|
3726
|
+
return this.writtenForeignEntry(home, id);
|
|
3727
|
+
}
|
|
3728
|
+
/** What a write actually left on disk, never what it was asked to write. */
|
|
3729
|
+
writtenForeignEntry(home, id) {
|
|
3730
|
+
const entry = findEntry(this.readForeignConfig(home), id);
|
|
3731
|
+
if (entry === null)
|
|
3732
|
+
throw new HomeHostedError(`the write to ${home}/servers.config.json did not take`, "FOREIGN_WRITE_LOST");
|
|
3733
|
+
return foreignView(entry);
|
|
3734
|
+
}
|
|
3735
|
+
deleteForeign(home, id) {
|
|
3736
|
+
const raw = this.readForeignConfig(home);
|
|
3737
|
+
if (findEntry(raw, id) === null)
|
|
3738
|
+
throw new HomeHostedError(`no server "${id}" exists in ${home}/servers.config.json`, "UNKNOWN_SERVER");
|
|
3739
|
+
writeConfig(home, removeEntry(raw, id), this.writtenBy(home));
|
|
3740
|
+
return { id, home, via: "file" };
|
|
3741
|
+
}
|
|
3742
|
+
/**
|
|
3743
|
+
* Start, stop or restart one entry through that panel's own CLI: this plugin
|
|
3744
|
+
* holds no API token for a panel it does not manage, and a config edit cannot
|
|
3745
|
+
* start anything. The CLI has no server restart, so a restart is a stop
|
|
3746
|
+
* followed by a start — a stop that is not followed by one is reported.
|
|
3747
|
+
*/
|
|
3748
|
+
async lifecycleForeign(home, action, id) {
|
|
3749
|
+
const once = async (command) => {
|
|
3750
|
+
const result = await this.cliExec([command, id, "--home", home], { ...process10.env, HHOSTED_HOME: home });
|
|
3751
|
+
if (result.code !== 0) {
|
|
3752
|
+
const output = [result.stderr, result.stdout, result.error].filter((part) => typeof part === "string" && part.trim().length > 0).join("\n").trim();
|
|
3753
|
+
throw new HomeHostedError(output.length > 0 ? output : `home-hosted ${command} ${id} exited ${String(result.code)}`, "CLI_FAILED");
|
|
3754
|
+
}
|
|
3755
|
+
};
|
|
3756
|
+
if (action === "restart") {
|
|
3757
|
+
await once("stop");
|
|
3758
|
+
await once("start");
|
|
3759
|
+
} else {
|
|
3760
|
+
await once(action);
|
|
3761
|
+
}
|
|
3762
|
+
return { home, id, action, via: "cli" };
|
|
3763
|
+
}
|
|
3764
|
+
// -------------------------------------------------------------------------
|
|
3275
3765
|
// Facts
|
|
3276
3766
|
// -------------------------------------------------------------------------
|
|
3277
3767
|
runtime() {
|
|
@@ -3283,8 +3773,8 @@ var HomeHostedService = class extends Service {
|
|
|
3283
3773
|
return id !== void 0 && id.length > 0 ? id : null;
|
|
3284
3774
|
}
|
|
3285
3775
|
/** What the config's `meta.writtenBy` names: the panel release when we can read it. */
|
|
3286
|
-
writtenBy() {
|
|
3287
|
-
return
|
|
3776
|
+
writtenBy(home = this.options.home) {
|
|
3777
|
+
return readRuntime(home)?.version ?? "dsh-home-hosted";
|
|
3288
3778
|
}
|
|
3289
3779
|
webServer() {
|
|
3290
3780
|
const service = this.ctx.get("webServer");
|
|
@@ -3326,7 +3816,8 @@ var HomeHostedService = class extends Service {
|
|
|
3326
3816
|
home: this.options.home,
|
|
3327
3817
|
projectDir,
|
|
3328
3818
|
stateDir: this.options.stateDir,
|
|
3329
|
-
env: launch === null ? {} : homeHostedEnv({ home: this.options.home, projectDir }, launch)
|
|
3819
|
+
env: launch === null ? {} : homeHostedEnv({ home: this.options.home, projectDir }, launch),
|
|
3820
|
+
exec: this.options.execCli
|
|
3330
3821
|
};
|
|
3331
3822
|
}
|
|
3332
3823
|
async cliExec(args, env) {
|
|
@@ -3548,7 +4039,7 @@ var HomeHostedService = class extends Service {
|
|
|
3548
4039
|
snapshots[intent.id] = live === null ? { id: intent.id } : snapshotOwned(live.config);
|
|
3549
4040
|
this.saveSnapshots(snapshots);
|
|
3550
4041
|
}
|
|
3551
|
-
const patch = ownedPatch(intent, live?.config ?? null
|
|
4042
|
+
const patch = ownedPatch(intent, live?.config ?? null);
|
|
3552
4043
|
const repair = intent.id === this.options.defaultEntryId ? await this.dshCommandRepair(live?.config ?? null) : null;
|
|
3553
4044
|
const client = await this.tryClient();
|
|
3554
4045
|
if (client !== null) {
|
|
@@ -3589,35 +4080,6 @@ var HomeHostedService = class extends Service {
|
|
|
3589
4080
|
const runtime = this.runtime();
|
|
3590
4081
|
return runtime !== null && (pidAlive(runtime.pid) || await probePanel(runtime.url));
|
|
3591
4082
|
}
|
|
3592
|
-
/**
|
|
3593
|
-
* The version whose schema will parse what we write: the answering panel, or
|
|
3594
|
-
* the CLI that will parse it next. `kill` did not exist before 0.6.0, and an
|
|
3595
|
-
* older panel refuses to *boot* with it — so this is checked before any write.
|
|
3596
|
-
*/
|
|
3597
|
-
async configVersion() {
|
|
3598
|
-
const runtime = this.runtime();
|
|
3599
|
-
if (runtime !== null && runtime.version !== null && (pidAlive(runtime.pid) || await probePanel(runtime.url)))
|
|
3600
|
-
return runtime.version;
|
|
3601
|
-
const { resolution } = await this.cli();
|
|
3602
|
-
return resolution.status.version;
|
|
3603
|
-
}
|
|
3604
|
-
/** Whether the panel whose schema parses our write knows `persistent`. */
|
|
3605
|
-
async supportsPersistent() {
|
|
3606
|
-
const version = await this.configVersion();
|
|
3607
|
-
return version !== null && compareVersions(version, MIN_PERSISTENT_VERSION) >= 0;
|
|
3608
|
-
}
|
|
3609
|
-
async assertPolicySupported(intent) {
|
|
3610
|
-
if (intent.onPortConflict !== "kill")
|
|
3611
|
-
return;
|
|
3612
|
-
const version = await this.configVersion();
|
|
3613
|
-
if (version === null || compareVersions(version, MIN_KILL_VERSION) >= 0)
|
|
3614
|
-
return;
|
|
3615
|
-
const { resolution } = await this.cli();
|
|
3616
|
-
throw new HomeHostedError(
|
|
3617
|
-
`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`,
|
|
3618
|
-
"KILL_UNSUPPORTED"
|
|
3619
|
-
);
|
|
3620
|
-
}
|
|
3621
4083
|
async applyIntents(intents) {
|
|
3622
4084
|
for (const raw of intents) {
|
|
3623
4085
|
if (!ENTRY_ID_PATTERN.test(raw.id))
|
|
@@ -3636,7 +4098,6 @@ var HomeHostedService = class extends Service {
|
|
|
3636
4098
|
stopKillPortHolders: raw.stopKillPortHolders ?? fallback.stopKillPortHolders,
|
|
3637
4099
|
persistent: raw.persistent ?? fallback.persistent
|
|
3638
4100
|
};
|
|
3639
|
-
await this.assertPolicySupported(intent);
|
|
3640
4101
|
await this.writeOwned(intent);
|
|
3641
4102
|
const current = this.options.settings.get();
|
|
3642
4103
|
const entries = current.entries.filter((entry) => entry.id !== intent.id);
|
|
@@ -3701,21 +4162,22 @@ var HomeHostedService = class extends Service {
|
|
|
3701
4162
|
return await this.entriesStatus();
|
|
3702
4163
|
}
|
|
3703
4164
|
/** Drive the panel's own UI through its CLI, and report what is installed. */
|
|
3704
|
-
async uiManage(action, file) {
|
|
4165
|
+
async uiManage(action, file, target) {
|
|
4166
|
+
const home = target ?? this.options.home;
|
|
3705
4167
|
const cli = action === "status" ? null : await this.cli();
|
|
3706
4168
|
const runUi = async (args) => {
|
|
3707
4169
|
const launch = cli?.resolution.launch ?? null;
|
|
3708
4170
|
if (launch === null)
|
|
3709
4171
|
throw new HomeHostedError(cli?.resolution.status.detail ?? "no home-hosted CLI could be resolved", "CLI_NOT_FOUND");
|
|
3710
|
-
const result = await run(launch.program, [...launch.args, ...args, "--home",
|
|
3711
|
-
env: { ...process10.env, HHOSTED_HOME:
|
|
4172
|
+
const result = await run(launch.program, [...launch.args, ...args, "--home", home], {
|
|
4173
|
+
env: { ...process10.env, HHOSTED_HOME: home },
|
|
3712
4174
|
timeoutMs: 3e5
|
|
3713
4175
|
});
|
|
3714
4176
|
const output = [result.error, result.stdout, result.stderr].filter((part) => typeof part === "string" && part.trim().length > 0).join("\n").trim();
|
|
3715
4177
|
return { code: result.code, output };
|
|
3716
4178
|
};
|
|
3717
4179
|
const active = () => {
|
|
3718
|
-
const meta = readJson(
|
|
4180
|
+
const meta = readJson(path14.join(home, ".ui", "ui.json"));
|
|
3719
4181
|
return meta === null ? null : { name: meta.name ?? null, version: meta.version ?? null, repo: meta.repo ?? null, tag: meta.tag ?? null };
|
|
3720
4182
|
};
|
|
3721
4183
|
switch (action) {
|
|
@@ -3734,7 +4196,7 @@ var HomeHostedService = class extends Service {
|
|
|
3734
4196
|
case "switch": {
|
|
3735
4197
|
if (file === void 0 || file.trim().length === 0)
|
|
3736
4198
|
throw new HomeHostedError("switching the UI needs the zip to install", "UI_FILE_REQUIRED");
|
|
3737
|
-
if (!
|
|
4199
|
+
if (!fs15.existsSync(file))
|
|
3738
4200
|
throw new HomeHostedError(`no file at ${file}`, "UI_FILE_MISSING");
|
|
3739
4201
|
const result = await runUi(["ui-switch", "--file", file, "--yes"]);
|
|
3740
4202
|
return { ok: result.code === 0, detail: result.code === 0 ? "the UI was replaced" : "the switch did not run", ui: active(), output: result.output };
|
|
@@ -3773,7 +4235,6 @@ var HomeHostedService = class extends Service {
|
|
|
3773
4235
|
const settings = this.options.settings.get();
|
|
3774
4236
|
const live = await this.liveEntries();
|
|
3775
4237
|
const snapshots = this.snapshots();
|
|
3776
|
-
const persistent = await this.supportsPersistent();
|
|
3777
4238
|
const ids = /* @__PURE__ */ new Set([this.options.defaultEntryId, ...settings.entries.map((entry) => entry.id)]);
|
|
3778
4239
|
return [...ids].map((id) => {
|
|
3779
4240
|
const intent = this.options.settings.intentFor(id);
|
|
@@ -3782,7 +4243,7 @@ var HomeHostedService = class extends Service {
|
|
|
3782
4243
|
intent,
|
|
3783
4244
|
exists: entry !== null,
|
|
3784
4245
|
managed: snapshots[id] !== void 0,
|
|
3785
|
-
drift: entry === null ? ["missing entry"] : ownedDrift(entry.config, intent
|
|
4246
|
+
drift: entry === null ? ["missing entry"] : ownedDrift(entry.config, intent),
|
|
3786
4247
|
live: entry?.view ?? null,
|
|
3787
4248
|
snapshot: snapshots[id] ?? null
|
|
3788
4249
|
};
|
|
@@ -3908,6 +4369,28 @@ var HomeHostedService = class extends Service {
|
|
|
3908
4369
|
this.clientCache = null;
|
|
3909
4370
|
return result;
|
|
3910
4371
|
}
|
|
4372
|
+
/**
|
|
4373
|
+
* Stop the panel the plugin drives, and every server it supervises with it.
|
|
4374
|
+
*
|
|
4375
|
+
* Deliberately not a replacement for the takeover guard: stopping is safe to
|
|
4376
|
+
* lose (an `autostart` entry comes back), while replacing is not. If this
|
|
4377
|
+
* process is one of those servers the answer may never arrive, so the page
|
|
4378
|
+
* treats a dropped call as "the panel is stopping" rather than a failure.
|
|
4379
|
+
*/
|
|
4380
|
+
async stopPanelNow() {
|
|
4381
|
+
const { resolution } = await this.cli();
|
|
4382
|
+
const runtime = this.runtime();
|
|
4383
|
+
if (runtime === null || !await probePanel(runtime.url)) {
|
|
4384
|
+
return { ok: true, detail: "no panel is answering, so there was nothing to stop" };
|
|
4385
|
+
}
|
|
4386
|
+
const deps = await this.panelControlDeps();
|
|
4387
|
+
if (deps.launch === null)
|
|
4388
|
+
throw new HomeHostedError(resolution.status.detail, "CLI_NOT_FOUND");
|
|
4389
|
+
const result = await stopPanel(deps);
|
|
4390
|
+
this.clientCache = null;
|
|
4391
|
+
this.tokenProof = null;
|
|
4392
|
+
return result;
|
|
4393
|
+
}
|
|
3911
4394
|
/**
|
|
3912
4395
|
* Replace an answering panel with the preferred copy.
|
|
3913
4396
|
*
|
|
@@ -3988,7 +4471,7 @@ var HomeHostedService = class extends Service {
|
|
|
3988
4471
|
if (status.state !== "enabled-failing" && status.state !== "installed-disabled")
|
|
3989
4472
|
return;
|
|
3990
4473
|
const fileBacked = status.mechanism === "systemd-user" || status.mechanism === "systemd-system" || status.mechanism === "xdg-autostart" || status.mechanism === "launchd-agent" || status.mechanism === "launchd-daemon";
|
|
3991
|
-
if (fileBacked && (status.unitPath === null || !
|
|
4474
|
+
if (fileBacked && (status.unitPath === null || !fs15.existsSync(status.unitPath)))
|
|
3992
4475
|
return;
|
|
3993
4476
|
try {
|
|
3994
4477
|
await this.installBoot();
|
|
@@ -3998,7 +4481,7 @@ var HomeHostedService = class extends Service {
|
|
|
3998
4481
|
// -------------------------------------------------------------------------
|
|
3999
4482
|
// Status
|
|
4000
4483
|
// -------------------------------------------------------------------------
|
|
4001
|
-
async status() {
|
|
4484
|
+
async status(refresh = false) {
|
|
4002
4485
|
const client = await this.tryClient();
|
|
4003
4486
|
const panel = await this.panelStatus();
|
|
4004
4487
|
const { resolution, launcher, launcherVersion } = await this.cli();
|
|
@@ -4026,6 +4509,10 @@ var HomeHostedService = class extends Service {
|
|
|
4026
4509
|
return {
|
|
4027
4510
|
defaultEntryId: this.options.defaultEntryId,
|
|
4028
4511
|
panel,
|
|
4512
|
+
panelRoot: this.options.home,
|
|
4513
|
+
panelRootSource: this.options.panelHomeSource ?? "instance",
|
|
4514
|
+
bootUnitName: bootUnitName(this.options.stateDir),
|
|
4515
|
+
instances: await this.instances(refresh),
|
|
4029
4516
|
boot: await this.bootStatus(),
|
|
4030
4517
|
// A deleted managed entry is put back here as well as at startup: whoever
|
|
4031
4518
|
// deleted it is looking at the page that reports it missing.
|
|
@@ -4041,28 +4528,54 @@ var HomeHostedService = class extends Service {
|
|
|
4041
4528
|
// -------------------------------------------------------------------------
|
|
4042
4529
|
async call(endpoint, payload) {
|
|
4043
4530
|
const input = payload ?? {};
|
|
4531
|
+
if (typeof input.home === "string" && input.home.trim().length > 0 && FOREIGN_MECHANISMS[endpoint] === void 0) {
|
|
4532
|
+
throw new HomeHostedError(
|
|
4533
|
+
`"${endpoint}" is about the panel this plugin manages and cannot be aimed at another one`,
|
|
4534
|
+
"INSTANCE_UNSUPPORTED"
|
|
4535
|
+
);
|
|
4536
|
+
}
|
|
4044
4537
|
switch (endpoint) {
|
|
4045
4538
|
case "status":
|
|
4046
|
-
return await this.status();
|
|
4539
|
+
return await this.status(input.refresh === true);
|
|
4047
4540
|
case "settings.update": {
|
|
4048
4541
|
const patch = input.patch ?? {};
|
|
4049
4542
|
this.options.settings.update(patch);
|
|
4050
4543
|
return await this.status();
|
|
4051
4544
|
}
|
|
4052
|
-
case "servers.list":
|
|
4053
|
-
|
|
4545
|
+
case "servers.list": {
|
|
4546
|
+
const target = await this.targetHome(input.home);
|
|
4547
|
+
if (!target.foreign)
|
|
4548
|
+
return await (await this.requireClient()).listServers();
|
|
4549
|
+
const via = this.mechanismFor("servers.list", input.via);
|
|
4550
|
+
if (via === "api" || input.via === void 0 && await this.foreignTokenWorks(target.home))
|
|
4551
|
+
return await (await this.foreignClient(target.home)).listServers();
|
|
4552
|
+
return this.listForeign(target.home);
|
|
4553
|
+
}
|
|
4054
4554
|
case "servers.get":
|
|
4055
4555
|
return await (await this.requireClient()).getServer(String(input.id));
|
|
4056
4556
|
case "servers.create": {
|
|
4057
4557
|
const entry = input.entry;
|
|
4058
4558
|
if (typeof entry?.id !== "string" || !ENTRY_ID_PATTERN.test(entry.id))
|
|
4059
4559
|
throw new HomeHostedError("a server entry needs an id matching ^[a-z0-9][a-z0-9_-]*$", "INVALID_ID");
|
|
4060
|
-
|
|
4560
|
+
const target = await this.targetHome(input.home);
|
|
4561
|
+
if (!target.foreign)
|
|
4562
|
+
return await (await this.requireClient()).createServer(entry);
|
|
4563
|
+
return this.mechanismFor("servers.create", input.via) === "api" ? await (await this.foreignClient(target.home)).createServer(entry) : this.createForeign(target.home, entry);
|
|
4564
|
+
}
|
|
4565
|
+
case "servers.update": {
|
|
4566
|
+
const target = await this.targetHome(input.home);
|
|
4567
|
+
const id = String(input.id);
|
|
4568
|
+
const patch = input.patch ?? {};
|
|
4569
|
+
if (!target.foreign)
|
|
4570
|
+
return await (await this.requireClient()).updateServer(id, patch);
|
|
4571
|
+
return this.mechanismFor("servers.update", input.via) === "api" ? await (await this.foreignClient(target.home)).updateServer(id, patch) : this.updateForeign(target.home, id, patch);
|
|
4061
4572
|
}
|
|
4062
|
-
case "servers.update":
|
|
4063
|
-
return await (await this.requireClient()).updateServer(String(input.id), input.patch ?? {});
|
|
4064
4573
|
case "servers.delete": {
|
|
4065
4574
|
const id = String(input.id);
|
|
4575
|
+
const target = await this.targetHome(input.home);
|
|
4576
|
+
if (target.foreign) {
|
|
4577
|
+
return this.mechanismFor("servers.delete", input.via) === "api" ? await (await this.foreignClient(target.home)).deleteServer(id).then(() => ({ id, home: target.home, via: "api" })) : this.deleteForeign(target.home, id);
|
|
4578
|
+
}
|
|
4066
4579
|
if (id === this.selfEntryId()) {
|
|
4067
4580
|
throw new HomeHostedError(
|
|
4068
4581
|
`"${id}" is the entry this very process runs as, and deleting it stops this session; pause it (autostart off) or restore it instead`,
|
|
@@ -4072,15 +4585,45 @@ var HomeHostedService = class extends Service {
|
|
|
4072
4585
|
await (await this.requireClient()).deleteServer(id);
|
|
4073
4586
|
return { id };
|
|
4074
4587
|
}
|
|
4075
|
-
case "servers.start":
|
|
4076
|
-
|
|
4588
|
+
case "servers.start": {
|
|
4589
|
+
const target = await this.targetHome(input.home);
|
|
4590
|
+
const id = String(input.id);
|
|
4591
|
+
if (target.foreign) {
|
|
4592
|
+
if (this.mechanismFor("servers.start", input.via) === "api") {
|
|
4593
|
+
await (await this.foreignClient(target.home)).startServer(id);
|
|
4594
|
+
return { home: target.home, id, action: "start", via: "api" };
|
|
4595
|
+
}
|
|
4596
|
+
return await this.lifecycleForeign(target.home, "start", id);
|
|
4597
|
+
}
|
|
4598
|
+
await (await this.requireClient()).startServer(id);
|
|
4077
4599
|
return await this.entriesStatus();
|
|
4078
|
-
|
|
4079
|
-
|
|
4600
|
+
}
|
|
4601
|
+
case "servers.stop": {
|
|
4602
|
+
const target = await this.targetHome(input.home);
|
|
4603
|
+
const id = String(input.id);
|
|
4604
|
+
if (target.foreign) {
|
|
4605
|
+
if (this.mechanismFor("servers.stop", input.via) === "api") {
|
|
4606
|
+
await (await this.foreignClient(target.home)).stopServer(id);
|
|
4607
|
+
return { home: target.home, id, action: "stop", via: "api" };
|
|
4608
|
+
}
|
|
4609
|
+
return await this.lifecycleForeign(target.home, "stop", id);
|
|
4610
|
+
}
|
|
4611
|
+
await (await this.requireClient()).stopServer(id);
|
|
4080
4612
|
return await this.entriesStatus();
|
|
4081
|
-
|
|
4082
|
-
|
|
4613
|
+
}
|
|
4614
|
+
case "servers.restart": {
|
|
4615
|
+
const target = await this.targetHome(input.home);
|
|
4616
|
+
const id = String(input.id);
|
|
4617
|
+
if (target.foreign) {
|
|
4618
|
+
if (this.mechanismFor("servers.restart", input.via) === "api") {
|
|
4619
|
+
await (await this.foreignClient(target.home)).restartServer(id);
|
|
4620
|
+
return { home: target.home, id, action: "restart", via: "api" };
|
|
4621
|
+
}
|
|
4622
|
+
return await this.lifecycleForeign(target.home, "restart", id);
|
|
4623
|
+
}
|
|
4624
|
+
await (await this.requireClient()).restartServer(id);
|
|
4083
4625
|
return await this.entriesStatus();
|
|
4626
|
+
}
|
|
4084
4627
|
case "servers.freePort":
|
|
4085
4628
|
return await (await this.requireClient()).freePort(String(input.id));
|
|
4086
4629
|
case "entries.apply": {
|
|
@@ -4091,8 +4634,16 @@ var HomeHostedService = class extends Service {
|
|
|
4091
4634
|
return await this.removeManagedEntry(String(input.id));
|
|
4092
4635
|
case "entries.restore":
|
|
4093
4636
|
return await this.restoreEntry(String(input.id));
|
|
4094
|
-
case "ui.manage":
|
|
4095
|
-
|
|
4637
|
+
case "ui.manage": {
|
|
4638
|
+
const target = await this.targetHome(input.home);
|
|
4639
|
+
if (target.foreign)
|
|
4640
|
+
this.mechanismFor("ui.manage", input.via);
|
|
4641
|
+
return await this.uiManage(
|
|
4642
|
+
input.action,
|
|
4643
|
+
typeof input.file === "string" ? input.file : void 0,
|
|
4644
|
+
target.foreign ? target.home : void 0
|
|
4645
|
+
);
|
|
4646
|
+
}
|
|
4096
4647
|
case "boot.install":
|
|
4097
4648
|
return await this.installBoot(input.mechanism);
|
|
4098
4649
|
case "boot.uninstall":
|
|
@@ -4101,6 +4652,8 @@ var HomeHostedService = class extends Service {
|
|
|
4101
4652
|
return await this.bootStatus();
|
|
4102
4653
|
case "panel.start":
|
|
4103
4654
|
return await this.startPanelNow();
|
|
4655
|
+
case "panel.stop":
|
|
4656
|
+
return await this.stopPanelNow();
|
|
4104
4657
|
case "panel.takeover":
|
|
4105
4658
|
return await this.takeoverPanel(input.force === true);
|
|
4106
4659
|
case "panel.reclaimToken":
|
|
@@ -4170,6 +4723,24 @@ function registerRpc(ctx, service) {
|
|
|
4170
4723
|
});
|
|
4171
4724
|
}
|
|
4172
4725
|
|
|
4726
|
+
// src/home-hosted/instances-notice.ts
|
|
4727
|
+
var INSTANCES_CONTEXT_ORDER = 130;
|
|
4728
|
+
function instancesContextText(instances, enabled) {
|
|
4729
|
+
return enabled ? instancesNoticeText(instances) ?? "" : "";
|
|
4730
|
+
}
|
|
4731
|
+
function registerInstancesNotice(ctx, deps) {
|
|
4732
|
+
ctx.inject(["systemPrompt"], (scoped) => {
|
|
4733
|
+
const prompt = scoped.systemPrompt;
|
|
4734
|
+
if (typeof prompt?.context !== "function")
|
|
4735
|
+
return;
|
|
4736
|
+
scoped.effect(() => prompt.context({
|
|
4737
|
+
name: "dsh-home-hosted:instances",
|
|
4738
|
+
order: INSTANCES_CONTEXT_ORDER,
|
|
4739
|
+
text: () => instancesContextText(deps.instances(), deps.enabled())
|
|
4740
|
+
}), "dsh-home-hosted: instance notice");
|
|
4741
|
+
});
|
|
4742
|
+
}
|
|
4743
|
+
|
|
4173
4744
|
// src/home-hosted/web-notice.ts
|
|
4174
4745
|
var STOCK_UNAUTHORIZED = "dsh web authentication required; reopen the URL printed by dsh web.";
|
|
4175
4746
|
function authNoticeBody(logUrl) {
|
|
@@ -4269,6 +4840,7 @@ var LEGACY_TOOL_NAMES = {
|
|
|
4269
4840
|
function isLegacyDefaultAllowlist(names) {
|
|
4270
4841
|
return names.length === 2 && names.includes("status") && names.includes("servers_list");
|
|
4271
4842
|
}
|
|
4843
|
+
var PRE_MERGE_SETTINGS_VERSION = 2;
|
|
4272
4844
|
var BOOT_MECHANISMS = ["auto", "systemd-user", "systemd-system", "launchd-agent", "launchd-daemon", "xdg-autostart", "windows-run", "windows-task", "container", "unsupported"];
|
|
4273
4845
|
function isBootMechanism(value) {
|
|
4274
4846
|
return typeof value === "string" && BOOT_MECHANISMS.includes(value);
|
|
@@ -4276,7 +4848,7 @@ function isBootMechanism(value) {
|
|
|
4276
4848
|
function normalize(raw, fallbackEntryId) {
|
|
4277
4849
|
const entries = Array.isArray(raw?.entries) ? raw.entries.map((intent) => normalizeIntent(intent, fallbackEntryId)) : [];
|
|
4278
4850
|
const declared = Array.isArray(raw?.agentTools?.allow) ? raw.agentTools.allow : null;
|
|
4279
|
-
const legacyFile = (typeof raw?.version === "number" ? raw.version : 1) <
|
|
4851
|
+
const legacyFile = (typeof raw?.version === "number" ? raw.version : 1) < PRE_MERGE_SETTINGS_VERSION;
|
|
4280
4852
|
const migrated = declared === null ? null : [...new Set(declared.map((name2) => legacyFile ? LEGACY_TOOL_NAMES[name2] ?? name2 : name2).filter(knownTool))];
|
|
4281
4853
|
const choseNothing = legacyFile && declared !== null && migrated !== null && isLegacyDefaultAllowlist(declared);
|
|
4282
4854
|
const allow = migrated === null || choseNothing ? [...DEFAULT_SETTINGS.agentTools.allow] : migrated;
|
|
@@ -4300,6 +4872,8 @@ function normalize(raw, fallbackEntryId) {
|
|
|
4300
4872
|
authNotice: raw?.authNotice === void 0 ? DEFAULT_SETTINGS.authNotice : raw.authNotice === true,
|
|
4301
4873
|
// Absent means on: a stale token is a fault to repair, not a setting to opt into.
|
|
4302
4874
|
reclaimToken: raw?.reclaimToken === void 0 ? DEFAULT_SETTINGS.reclaimToken : raw.reclaimToken === true,
|
|
4875
|
+
// Absent means on: a plugin that owns one panel should say so by default.
|
|
4876
|
+
instancesNotice: raw?.instancesNotice === void 0 ? DEFAULT_SETTINGS.instancesNotice : raw.instancesNotice === true,
|
|
4303
4877
|
uiStyle: raw?.uiStyle === "compact" ? "compact" : "detailed",
|
|
4304
4878
|
agentTools: {
|
|
4305
4879
|
// Absent means the default (on), and so does the pair the previous
|
|
@@ -4338,6 +4912,7 @@ var SettingsStore = class {
|
|
|
4338
4912
|
panel: { ...this.current.panel, ...patch.panel ?? {} },
|
|
4339
4913
|
authNotice: patch.authNotice ?? this.current.authNotice,
|
|
4340
4914
|
reclaimToken: patch.reclaimToken ?? this.current.reclaimToken,
|
|
4915
|
+
instancesNotice: patch.instancesNotice ?? this.current.instancesNotice,
|
|
4341
4916
|
uiStyle: patch.uiStyle ?? this.current.uiStyle,
|
|
4342
4917
|
agentTools: { ...this.current.agentTools, ...patch.agentTools ?? {} },
|
|
4343
4918
|
cli: { ...this.current.cli, ...patch.cli ?? {} }
|
|
@@ -4369,6 +4944,10 @@ function sandboxMode(ctx, exec) {
|
|
|
4369
4944
|
return null;
|
|
4370
4945
|
}
|
|
4371
4946
|
}
|
|
4947
|
+
function withTarget(payload, home, via) {
|
|
4948
|
+
const target = via === null ? { home } : { home, via };
|
|
4949
|
+
return typeof payload === "object" && payload !== null ? { ...payload, ...target } : target;
|
|
4950
|
+
}
|
|
4372
4951
|
function stringArg(input, key) {
|
|
4373
4952
|
const value = input[key];
|
|
4374
4953
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
@@ -4377,20 +4956,62 @@ function jsonArg(input, key) {
|
|
|
4377
4956
|
const value = input[key];
|
|
4378
4957
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
4379
4958
|
}
|
|
4959
|
+
function jsonArgError(input, key, purpose) {
|
|
4960
|
+
const value = input[key];
|
|
4961
|
+
const received = value === void 0 ? "missing" : Array.isArray(value) ? "array" : typeof value;
|
|
4962
|
+
return new Error(`${key} must be a JSON object ${purpose} (received: ${received})`);
|
|
4963
|
+
}
|
|
4964
|
+
var INSTANCE_PARAM = {
|
|
4965
|
+
type: "string",
|
|
4966
|
+
description: "Home-hosted panel to act on: a state root (--home) or URL reported by home_hosted_status. Omit for the panel this plugin manages; naming another panel asks the user how to reach it before it changes anything."
|
|
4967
|
+
};
|
|
4968
|
+
var ENTRY_FIELDS = {
|
|
4969
|
+
id: { type: "string", required: true, description: "Entry id, ^[a-z0-9][a-z0-9_-]*$" },
|
|
4970
|
+
command: { type: "string", description: "Executable to run" },
|
|
4971
|
+
args: { type: "array", items: { type: "string" }, description: "Arguments" },
|
|
4972
|
+
cwd: { type: "string", description: "Working directory" },
|
|
4973
|
+
label: { type: "string", description: "Human label" },
|
|
4974
|
+
enabled: { type: "boolean", description: "Whether the entry may run" },
|
|
4975
|
+
autostart: { type: "boolean", description: "Start it when the panel starts" },
|
|
4976
|
+
// The panel's schema is `1..65535 | null`, and a patch that clears a port sends it.
|
|
4977
|
+
port: { oneOf: [{ type: "number" }, { type: "null" }], description: "Port the panel tracks for this entry, or null" },
|
|
4978
|
+
bind: { type: "string", description: "Bind host or local/lan" },
|
|
4979
|
+
onPortConflict: { type: "string", enum: [...ON_PORT_CONFLICT_POLICIES], description: "Policy when the port is taken" },
|
|
4980
|
+
stop: { type: "object", additionalProperties: true, description: "Stop policy, e.g. killPortHolders" },
|
|
4981
|
+
health: { type: "object", additionalProperties: true, description: "Health check block" },
|
|
4982
|
+
restart: { type: "object", additionalProperties: true, description: "Restart policy block" },
|
|
4983
|
+
env: { type: "object", additionalProperties: true, description: "Environment variables" },
|
|
4984
|
+
dataEnvs: { type: "object", additionalProperties: true, description: "Environment entries the panel fills in" }
|
|
4985
|
+
};
|
|
4986
|
+
var ENTRY_PARAM = {
|
|
4987
|
+
type: "object",
|
|
4988
|
+
additionalProperties: true,
|
|
4989
|
+
description: "A home-hosted server entry: at least id and command",
|
|
4990
|
+
properties: ENTRY_FIELDS
|
|
4991
|
+
};
|
|
4992
|
+
var PATCH_PARAM = {
|
|
4993
|
+
type: "object",
|
|
4994
|
+
additionalProperties: true,
|
|
4995
|
+
description: "Fields to change on an existing entry; only the given keys are touched",
|
|
4996
|
+
// The same fields, none required and without `id`: the entry patched is the one
|
|
4997
|
+
// the `id` argument names, and the panel rejects a patch that carries an id.
|
|
4998
|
+
properties: Object.fromEntries(Object.entries(ENTRY_FIELDS).filter(([key]) => key !== "id"))
|
|
4999
|
+
};
|
|
4380
5000
|
var TOOL_SPECS = {
|
|
4381
5001
|
status: {
|
|
4382
|
-
description: "Report the home-hosted panel state, its boot-autostart entry, and the entries this plugin manages.",
|
|
4383
|
-
parameters: {},
|
|
5002
|
+
description: "Report the home-hosted panel state, every home-hosted panel found on this machine, its boot-autostart entry, and the entries this plugin manages. This describes the managed panel only; its payload lists the others.",
|
|
5003
|
+
parameters: { instance: INSTANCE_PARAM },
|
|
4384
5004
|
run: () => ({ endpoint: "status", payload: {} })
|
|
4385
5005
|
},
|
|
4386
5006
|
servers_list: {
|
|
4387
|
-
description: "List every server home-hosted supervises, with status, pid and url.",
|
|
4388
|
-
parameters: {},
|
|
5007
|
+
description: "List every server home-hosted supervises, with status, pid and url. A named panel is read from its config file, or through its API when this plugin already holds a token for that panel; naming the API mechanism mints one.",
|
|
5008
|
+
parameters: { instance: INSTANCE_PARAM },
|
|
4389
5009
|
run: () => ({ endpoint: "servers.list", payload: {} })
|
|
4390
5010
|
},
|
|
4391
5011
|
servers_lifecycle: {
|
|
4392
|
-
description: "Start, stop or restart a server supervised by home-hosted. Restarting the entry this session runs as ends the session.",
|
|
5012
|
+
description: "Start, stop or restart a server supervised by home-hosted. Restarting the entry this session runs as ends the session. A named panel is driven through the home-hosted CLI aimed at its state root, or through its API, and a CLI restart is a stop then a start.",
|
|
4393
5013
|
parameters: {
|
|
5014
|
+
instance: INSTANCE_PARAM,
|
|
4394
5015
|
action: { type: "string", required: true, description: "start, stop or restart" },
|
|
4395
5016
|
id: { type: "string", required: true, description: "Server entry id" }
|
|
4396
5017
|
},
|
|
@@ -4405,27 +5026,31 @@ var TOOL_SPECS = {
|
|
|
4405
5026
|
}
|
|
4406
5027
|
},
|
|
4407
5028
|
servers_edit: {
|
|
4408
|
-
description: "Create, update or delete a home-hosted server entry. Delete is refused for the entry this session runs as.",
|
|
5029
|
+
description: "Create, update or delete a home-hosted server entry. Delete is refused for the entry this session runs as. A named panel is edited through its servers.config.json, or through its API with a token this plugin mints.",
|
|
4409
5030
|
parameters: {
|
|
5031
|
+
instance: INSTANCE_PARAM,
|
|
4410
5032
|
action: { type: "string", required: true, description: "create, update or delete" },
|
|
4411
5033
|
id: { type: "string", description: "Server entry id (update, delete)" },
|
|
4412
|
-
entry:
|
|
4413
|
-
patch:
|
|
5034
|
+
entry: ENTRY_PARAM,
|
|
5035
|
+
patch: PATCH_PARAM
|
|
4414
5036
|
},
|
|
4415
5037
|
run: (input) => {
|
|
4416
5038
|
const action = stringArg(input, "action");
|
|
4417
5039
|
if (action === "create") {
|
|
4418
5040
|
const entry = jsonArg(input, "entry");
|
|
4419
5041
|
if (entry === null)
|
|
4420
|
-
throw
|
|
5042
|
+
throw jsonArgError(input, "entry", "to create a server");
|
|
4421
5043
|
return { endpoint: "servers.create", payload: { entry } };
|
|
4422
5044
|
}
|
|
4423
5045
|
if (action === "update") {
|
|
4424
5046
|
const id = stringArg(input, "id");
|
|
4425
5047
|
const patch = jsonArg(input, "patch");
|
|
4426
|
-
if (id === null
|
|
4427
|
-
throw new Error("id
|
|
4428
|
-
|
|
5048
|
+
if (id === null)
|
|
5049
|
+
throw new Error("id is required to update a server");
|
|
5050
|
+
if (patch === null)
|
|
5051
|
+
throw jsonArgError(input, "patch", "to update a server");
|
|
5052
|
+
const { id: _key, ...fields } = patch;
|
|
5053
|
+
return { endpoint: "servers.update", payload: { id, patch: fields } };
|
|
4429
5054
|
}
|
|
4430
5055
|
if (action === "delete") {
|
|
4431
5056
|
const id = stringArg(input, "id");
|
|
@@ -4437,8 +5062,9 @@ var TOOL_SPECS = {
|
|
|
4437
5062
|
}
|
|
4438
5063
|
},
|
|
4439
5064
|
autostart_manage: {
|
|
4440
|
-
description: "Install or remove the OS entry that starts home-hosted at boot or login.",
|
|
5065
|
+
description: "Install or remove the OS entry that starts home-hosted at boot or login. The entry is machine-wide and always starts the panel this plugin manages.",
|
|
4441
5066
|
parameters: {
|
|
5067
|
+
instance: INSTANCE_PARAM,
|
|
4442
5068
|
action: { type: "string", required: true, description: "install or uninstall" },
|
|
4443
5069
|
mechanism: { type: "string", description: "Explicit mechanism, e.g. launchd-daemon or systemd-system; omit to use the plugin setting" }
|
|
4444
5070
|
},
|
|
@@ -4451,8 +5077,9 @@ var TOOL_SPECS = {
|
|
|
4451
5077
|
}
|
|
4452
5078
|
},
|
|
4453
5079
|
ui_manage: {
|
|
4454
|
-
description: "Inspect or change the home-hosted panel's own web UI: status, update, revert to stock, or switch to a local zip.",
|
|
5080
|
+
description: "Inspect or change the home-hosted panel's own web UI: status, update, revert to stock, or switch to a local zip. A named panel's UI is driven through the home-hosted CLI aimed at its state root.",
|
|
4455
5081
|
parameters: {
|
|
5082
|
+
instance: INSTANCE_PARAM,
|
|
4456
5083
|
action: { type: "string", required: true, description: "status, update, revert or switch" },
|
|
4457
5084
|
file: { type: "string", description: "Absolute path to a UI zip (switch)" }
|
|
4458
5085
|
},
|
|
@@ -4471,6 +5098,198 @@ function toolNameFor(name2) {
|
|
|
4471
5098
|
var READ_ONLY_ACTIONS = {
|
|
4472
5099
|
ui_manage: (input) => stringArg(input, "action") === "status"
|
|
4473
5100
|
};
|
|
5101
|
+
var CANCEL_LABEL = "Cancel";
|
|
5102
|
+
var NO_ANSWERER_CODES = /* @__PURE__ */ new Set(["NO_PROVIDER", "DELEGATED_CALLER", "CALLER_NOT_LIVE"]);
|
|
5103
|
+
var MECHANISM_WORDING = {
|
|
5104
|
+
file: { option: "Edit its config file", phrase: "by editing its servers.config.json" },
|
|
5105
|
+
cli: { option: "Run the home-hosted CLI", phrase: "by running the home-hosted CLI against its state root" },
|
|
5106
|
+
api: { option: "Generate a token and use its API", phrase: "by enrolling a token for it and using its API" }
|
|
5107
|
+
};
|
|
5108
|
+
var NO_FOREIGN_REASON = {
|
|
5109
|
+
"status": "home_hosted_status describes the panel this plugin manages, and it already lists every panel found on the machine.",
|
|
5110
|
+
"boot.install": "the OS boot entry starts the panel this plugin manages; it is one machine-wide entry and cannot be aimed at another panel.",
|
|
5111
|
+
"boot.uninstall": "the OS boot entry is one machine-wide entry and belongs to the panel this plugin manages."
|
|
5112
|
+
};
|
|
5113
|
+
async function inventoryOf(service) {
|
|
5114
|
+
const reader = service.instances;
|
|
5115
|
+
if (typeof reader !== "function")
|
|
5116
|
+
return [];
|
|
5117
|
+
try {
|
|
5118
|
+
return await reader.call(service);
|
|
5119
|
+
} catch {
|
|
5120
|
+
return [];
|
|
5121
|
+
}
|
|
5122
|
+
}
|
|
5123
|
+
function matchInstance(instances, wanted) {
|
|
5124
|
+
const target = wanted.trim();
|
|
5125
|
+
if (target.length === 0)
|
|
5126
|
+
return null;
|
|
5127
|
+
const asPath = canonicalPath(target);
|
|
5128
|
+
return instances.find(
|
|
5129
|
+
(instance) => instance.home === asPath || instance.url === target || instance.projectDir !== null && instance.projectDir === target
|
|
5130
|
+
) ?? null;
|
|
5131
|
+
}
|
|
5132
|
+
function inventoryText(instances) {
|
|
5133
|
+
return instances.map((instance) => `${instance.managed ? "managed: " : ""}${describeInstance(instance)}`).join("; ");
|
|
5134
|
+
}
|
|
5135
|
+
function refusalForUnknownPanel(instances, wanted) {
|
|
5136
|
+
return `refused: no home-hosted panel at "${wanted}" was found on this machine. Panels found: ${inventoryText(instances)}. Ask the user which panel they mean, or read home_hosted_status for the inventory.`;
|
|
5137
|
+
}
|
|
5138
|
+
function refusalForUnreachablePanel(instances, target, endpoint) {
|
|
5139
|
+
const reason = NO_FOREIGN_REASON[endpoint] ?? `this plugin has no way to change another panel's state for ${endpoint}`;
|
|
5140
|
+
return `refused: ${describeInstance(target)} is another home-hosted panel, and ${reason} Panels found: ${inventoryText(instances)}.`;
|
|
5141
|
+
}
|
|
5142
|
+
function answerOf(answer2, id) {
|
|
5143
|
+
const item = (Array.isArray(answer2?.answers) ? answer2.answers : []).find((entry) => entry.id === id);
|
|
5144
|
+
return {
|
|
5145
|
+
selected: Array.isArray(item?.selected) ? item.selected : [],
|
|
5146
|
+
custom: typeof item?.custom === "string" && item.custom.trim().length > 0 ? item.custom.trim() : null
|
|
5147
|
+
};
|
|
5148
|
+
}
|
|
5149
|
+
async function planTarget(service, settings, endpoint, requested, mutating) {
|
|
5150
|
+
const instances = await inventoryOf(service);
|
|
5151
|
+
if (instances.length === 0) {
|
|
5152
|
+
return requested === null ? { ok: true, home: null, ask: null } : { ok: false, text: `refused: this host could not list the home-hosted panels on this machine, so "${requested}" cannot be checked; ask the user, or call without instance to act on the panel this plugin manages.` };
|
|
5153
|
+
}
|
|
5154
|
+
const managed = instances.find((instance) => instance.managed);
|
|
5155
|
+
const mechanisms = FOREIGN_MECHANISMS[endpoint];
|
|
5156
|
+
if (requested !== null) {
|
|
5157
|
+
const target = matchInstance(instances, requested);
|
|
5158
|
+
if (target === null)
|
|
5159
|
+
return { ok: false, text: refusalForUnknownPanel(instances, requested) };
|
|
5160
|
+
if (target.managed)
|
|
5161
|
+
return { ok: true, home: null, ask: null };
|
|
5162
|
+
if (mechanisms === void 0)
|
|
5163
|
+
return { ok: false, text: refusalForUnreachablePanel(instances, target, endpoint) };
|
|
5164
|
+
return mutating ? { ok: true, home: target.home, ask: "foreign", mechanisms } : { ok: true, home: target.home, ask: null };
|
|
5165
|
+
}
|
|
5166
|
+
if (!mutating || !settings.get().instancesNotice || managed === void 0 || mechanisms === void 0 || instances.length < 2)
|
|
5167
|
+
return { ok: true, home: null, ask: null };
|
|
5168
|
+
return { ok: true, home: null, ask: "which", mechanisms };
|
|
5169
|
+
}
|
|
5170
|
+
async function ask(ctx, exec, questions, item) {
|
|
5171
|
+
try {
|
|
5172
|
+
const answer2 = await questions.ask({
|
|
5173
|
+
questions: [{ id: item.id, header: "home-hosted", question: item.question, detail: item.detail, options: item.options }],
|
|
5174
|
+
agent: exec?.agent,
|
|
5175
|
+
signal: exec?.signal
|
|
5176
|
+
});
|
|
5177
|
+
const { selected, custom } = answerOf(answer2, item.id);
|
|
5178
|
+
return { ok: true, selected, custom };
|
|
5179
|
+
} catch (error) {
|
|
5180
|
+
return { ok: false, code: errorCode(error), error };
|
|
5181
|
+
}
|
|
5182
|
+
}
|
|
5183
|
+
async function askForeignPanel(ctx, exec, instances, target, mechanisms) {
|
|
5184
|
+
const questions = ctx.get("userQuestions");
|
|
5185
|
+
const first = mechanisms[0];
|
|
5186
|
+
if (typeof questions?.ask !== "function")
|
|
5187
|
+
return { ok: true, home: target.home, via: first };
|
|
5188
|
+
const answer2 = await ask(ctx, exec, questions, {
|
|
5189
|
+
id: "home-hosted-foreign-panel",
|
|
5190
|
+
question: `${target.home} is not a panel this plugin manages. How should it be changed?`,
|
|
5191
|
+
detail: inventoryText(instances),
|
|
5192
|
+
options: [
|
|
5193
|
+
...mechanisms.map((mechanism) => ({
|
|
5194
|
+
label: MECHANISM_WORDING[mechanism].option,
|
|
5195
|
+
description: `${MECHANISM_WORDING[mechanism].phrase}${mechanism === "api" ? "; that mints a token for it, replacing any it had" : "; this plugin holds no API token for that panel"}`
|
|
5196
|
+
})),
|
|
5197
|
+
{ label: CANCEL_LABEL, description: "Change nothing; say which panel you meant instead." }
|
|
5198
|
+
]
|
|
5199
|
+
});
|
|
5200
|
+
if (!answer2.ok) {
|
|
5201
|
+
if (answer2.code === "ASK_ABORTED")
|
|
5202
|
+
return { ok: false, text: "cancelled: the user dismissed the panel question to speak instead; stop and wait for their message." };
|
|
5203
|
+
if (answer2.code !== null && NO_ANSWERER_CODES.has(answer2.code))
|
|
5204
|
+
return { ok: true, home: target.home, via: first };
|
|
5205
|
+
return { ok: false, text: `refused: the panel question could not be asked (${messageOf(answer2.error)}); ask the user before changing another panel.` };
|
|
5206
|
+
}
|
|
5207
|
+
if (answer2.selected.includes(CANCEL_LABEL))
|
|
5208
|
+
return { ok: false, text: `cancelled: the user did not confirm changing ${target.home}; ask which panel they meant.` };
|
|
5209
|
+
const chosen = answer2.selected.find((label) => label !== CANCEL_LABEL);
|
|
5210
|
+
if (chosen !== void 0) {
|
|
5211
|
+
const mechanism = mechanisms.find((candidate) => MECHANISM_WORDING[candidate].option === chosen);
|
|
5212
|
+
if (mechanism !== void 0)
|
|
5213
|
+
return { ok: true, home: target.home, via: mechanism };
|
|
5214
|
+
}
|
|
5215
|
+
if (chosen === void 0 && answer2.custom !== null) {
|
|
5216
|
+
const mechanism = mechanisms.find((candidate) => matchesMechanism(answer2.custom, candidate));
|
|
5217
|
+
if (mechanism !== void 0)
|
|
5218
|
+
return { ok: true, home: target.home, via: mechanism };
|
|
5219
|
+
}
|
|
5220
|
+
return { ok: false, text: `cancelled: the user did not confirm changing ${target.home}; ask which panel they meant.` };
|
|
5221
|
+
}
|
|
5222
|
+
function matchesMechanism(text, mechanism) {
|
|
5223
|
+
const value = text.toLowerCase();
|
|
5224
|
+
if (mechanism === "file")
|
|
5225
|
+
return /file|config/.test(value);
|
|
5226
|
+
if (mechanism === "cli")
|
|
5227
|
+
return /cli|command|terminal/.test(value);
|
|
5228
|
+
return /api|token/.test(value);
|
|
5229
|
+
}
|
|
5230
|
+
async function askWhichPanel(ctx, exec, instances, managed, others, mechanisms) {
|
|
5231
|
+
const questions = ctx.get("userQuestions");
|
|
5232
|
+
if (typeof questions?.ask !== "function")
|
|
5233
|
+
return { ok: true, home: null, via: null };
|
|
5234
|
+
const answer2 = await ask(ctx, exec, questions, {
|
|
5235
|
+
id: "home-hosted-instance",
|
|
5236
|
+
question: `${instances.length} home-hosted panels were found on this machine. Act on the panel this plugin manages?`,
|
|
5237
|
+
detail: inventoryText(instances),
|
|
5238
|
+
options: [
|
|
5239
|
+
{ label: managed.home, description: `managed by this plugin${managed.url === null ? "" : ` \xB7 ${managed.url}`}` },
|
|
5240
|
+
...others.map((instance) => ({
|
|
5241
|
+
label: instance.home,
|
|
5242
|
+
description: `not managed by this plugin${instance.url === null ? "" : ` \xB7 ${instance.url}`} \xB7 ${mechanisms.map((candidate) => MECHANISM_WORDING[candidate].phrase).join(", or ")}`
|
|
5243
|
+
})),
|
|
5244
|
+
{ label: CANCEL_LABEL, description: "Do nothing; say which panel you meant instead." }
|
|
5245
|
+
]
|
|
5246
|
+
});
|
|
5247
|
+
if (!answer2.ok) {
|
|
5248
|
+
if (answer2.code === "ASK_ABORTED")
|
|
5249
|
+
return { ok: false, text: "cancelled: the user dismissed the panel question to speak instead; stop and wait for their message." };
|
|
5250
|
+
if (answer2.code !== null && NO_ANSWERER_CODES.has(answer2.code))
|
|
5251
|
+
return { ok: true, home: null, via: null };
|
|
5252
|
+
return { ok: false, text: `refused: the panel question could not be asked (${messageOf(answer2.error)}); ask the user which panel they mean before acting.` };
|
|
5253
|
+
}
|
|
5254
|
+
if (answer2.selected.includes(CANCEL_LABEL))
|
|
5255
|
+
return { ok: false, text: "cancelled: the user chose not to pick a panel; ask them which one they mean." };
|
|
5256
|
+
const chosen = answer2.selected.find((label) => label !== CANCEL_LABEL);
|
|
5257
|
+
if (chosen !== void 0) {
|
|
5258
|
+
const instance = matchInstance(instances, chosen);
|
|
5259
|
+
if (instance === null)
|
|
5260
|
+
return { ok: false, text: `cancelled: the answer "${chosen}" names no panel; ask the user which panel they mean.` };
|
|
5261
|
+
return instance.managed ? { ok: true, home: null, via: null } : { ok: true, home: instance.home, via: null };
|
|
5262
|
+
}
|
|
5263
|
+
if (answer2.custom !== null) {
|
|
5264
|
+
const instance = matchInstance(instances, answer2.custom);
|
|
5265
|
+
if (instance?.managed === true)
|
|
5266
|
+
return { ok: true, home: null, via: null };
|
|
5267
|
+
if (instance !== null)
|
|
5268
|
+
return { ok: true, home: instance.home, via: null };
|
|
5269
|
+
return { ok: false, text: `cancelled: the user answered "${answer2.custom}", which names no panel; ask them which panel they mean.` };
|
|
5270
|
+
}
|
|
5271
|
+
return { ok: false, text: "cancelled: the panel question was not answered; ask the user which panel they mean before acting." };
|
|
5272
|
+
}
|
|
5273
|
+
async function confirmPlan(ctx, exec, service, plan) {
|
|
5274
|
+
if (plan.ask === null)
|
|
5275
|
+
return { ok: true, home: plan.home, via: null };
|
|
5276
|
+
const instances = await inventoryOf(service);
|
|
5277
|
+
const managed = instances.find((instance) => instance.managed);
|
|
5278
|
+
if (plan.ask === "foreign") {
|
|
5279
|
+
const target = instances.find((instance) => instance.home === plan.home);
|
|
5280
|
+
return target === void 0 ? { ok: false, text: refusalForUnknownPanel(instances, plan.home) } : await askForeignPanel(ctx, exec, instances, target, plan.mechanisms);
|
|
5281
|
+
}
|
|
5282
|
+
if (managed === void 0)
|
|
5283
|
+
return { ok: true, home: null, via: null };
|
|
5284
|
+
const others = instances.filter((instance) => !instance.managed);
|
|
5285
|
+
const picked = await askWhichPanel(ctx, exec, instances, managed, others, plan.mechanisms);
|
|
5286
|
+
if (!picked.ok || picked.home === null)
|
|
5287
|
+
return picked;
|
|
5288
|
+
const chosen = instances.find((instance) => instance.home === picked.home);
|
|
5289
|
+
if (chosen === void 0 || chosen.managed)
|
|
5290
|
+
return picked;
|
|
5291
|
+
return await askForeignPanel(ctx, exec, instances, chosen, plan.mechanisms);
|
|
5292
|
+
}
|
|
4474
5293
|
var TOKEN_REFUSAL_CODES = /* @__PURE__ */ new Set([
|
|
4475
5294
|
"AUTH_REQUIRED",
|
|
4476
5295
|
"AUTH_UNARMED",
|
|
@@ -4527,8 +5346,17 @@ function registerOne(ctx, service, settings, name2) {
|
|
|
4527
5346
|
},
|
|
4528
5347
|
async execute(args, exec) {
|
|
4529
5348
|
const input = args ?? {};
|
|
5349
|
+
let request;
|
|
5350
|
+
try {
|
|
5351
|
+
request = spec.run(input);
|
|
5352
|
+
} catch (error) {
|
|
5353
|
+
return failureText(error);
|
|
5354
|
+
}
|
|
4530
5355
|
const mode = sandboxMode(ctx, exec);
|
|
4531
5356
|
const mutating = mutatingTool && !(READ_ONLY_ACTIONS[name2]?.(input) ?? false);
|
|
5357
|
+
const plan = await planTarget(service, settings, request.endpoint, stringArg(input, "instance"), mutating);
|
|
5358
|
+
if (!plan.ok)
|
|
5359
|
+
return plan.text;
|
|
4532
5360
|
if (mutating && mode !== "danger-full-access") {
|
|
4533
5361
|
const remedy = "Set the session to Full access (danger-full-access), or use a session where approvals can be answered.";
|
|
4534
5362
|
const approval = ctx.get("approval");
|
|
@@ -4542,16 +5370,17 @@ function registerOne(ctx, service, settings, name2) {
|
|
|
4542
5370
|
if (outcome !== "allowed-once")
|
|
4543
5371
|
return `refused: approval answered "${outcome}" (session sandbox: ${mode ?? "unknown"}). ${remedy}`;
|
|
4544
5372
|
}
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
5373
|
+
const target = await confirmPlan(ctx, exec, service, plan);
|
|
5374
|
+
if (!target.ok)
|
|
5375
|
+
return target.text;
|
|
5376
|
+
const payload = target.home === null ? request.payload : withTarget(request.payload, target.home, target.via ?? FOREIGN_MECHANISMS[request.endpoint]?.[0] ?? null);
|
|
5377
|
+
const landing = target.home === null ? "" : `
|
|
5378
|
+
|
|
5379
|
+
home-hosted: ${plan.ask === null ? "read from" : "acted on"} ${target.home}, not the panel this plugin manages${target.via === null ? "" : `, ${MECHANISM_WORDING[target.via].phrase}`}.`;
|
|
4551
5380
|
try {
|
|
4552
|
-
return JSON.stringify(await service.call(request.endpoint,
|
|
5381
|
+
return `${JSON.stringify(await service.call(request.endpoint, payload), null, 2)}${landing}`;
|
|
4553
5382
|
} catch (error) {
|
|
4554
|
-
if (!settings.get().reclaimToken || !await tokenRefused(service, error))
|
|
5383
|
+
if (target.home !== null || !settings.get().reclaimToken || !await tokenRefused(service, error))
|
|
4555
5384
|
return failureText(error);
|
|
4556
5385
|
try {
|
|
4557
5386
|
await service.call("panel.reclaimToken", {});
|
|
@@ -4559,7 +5388,7 @@ function registerOne(ctx, service, settings, name2) {
|
|
|
4559
5388
|
return `${failureText(error)} (token reclaim failed: ${messageOf(reclaimError)})`;
|
|
4560
5389
|
}
|
|
4561
5390
|
try {
|
|
4562
|
-
return JSON.stringify(await service.call(request.endpoint,
|
|
5391
|
+
return `${JSON.stringify(await service.call(request.endpoint, payload), null, 2)}${landing}`;
|
|
4563
5392
|
} catch (retryError) {
|
|
4564
5393
|
return failureText(retryError);
|
|
4565
5394
|
}
|
|
@@ -4602,21 +5431,31 @@ function apply(ctx, config) {
|
|
|
4602
5431
|
const resolved = {
|
|
4603
5432
|
stateDir: config?.stateDir,
|
|
4604
5433
|
homeHostedCommand: config?.homeHostedCommand,
|
|
4605
|
-
defaultEntryId: config?.defaultEntryId ?? "dsh"
|
|
5434
|
+
defaultEntryId: config?.defaultEntryId ?? "dsh",
|
|
5435
|
+
instanceRoots: config?.instanceRoots ?? []
|
|
4606
5436
|
};
|
|
4607
5437
|
const stateDir = pluginStateDir(resolved.stateDir);
|
|
4608
5438
|
ensureDir(stateDir, 448);
|
|
4609
|
-
const
|
|
5439
|
+
const panel = resolveHomeHostedHome(stateDir);
|
|
5440
|
+
const settings = new SettingsStore(path15.join(stateDir, "settings.json"), resolved.defaultEntryId);
|
|
4610
5441
|
const service = new HomeHostedService(ctx, {
|
|
4611
|
-
home:
|
|
5442
|
+
home: panel.home,
|
|
5443
|
+
panelHomeSource: panel.source,
|
|
4612
5444
|
stateDir,
|
|
4613
5445
|
homeHostedCommand: resolved.homeHostedCommand,
|
|
4614
5446
|
defaultEntryId: resolved.defaultEntryId,
|
|
5447
|
+
instanceRoots: resolved.instanceRoots,
|
|
4615
5448
|
settings
|
|
4616
5449
|
});
|
|
4617
5450
|
registerRpc(ctx, service);
|
|
4618
5451
|
registerAgentTools(ctx, service, settings);
|
|
5452
|
+
registerInstancesNotice(ctx, {
|
|
5453
|
+
instances: () => service.instancesNow(),
|
|
5454
|
+
enabled: () => settings.get().instancesNotice
|
|
5455
|
+
});
|
|
4619
5456
|
registerAuthNotice(ctx, () => settings.get().authNotice ? service.panelLogUrl() : null);
|
|
5457
|
+
void service.instances().catch(() => {
|
|
5458
|
+
});
|
|
4620
5459
|
ctx.effect(() => {
|
|
4621
5460
|
const timer = setTimeout(() => {
|
|
4622
5461
|
void service.reconcile().catch(() => {
|