dsh-home-hosted 0.3.2 → 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 +141 -13
- package/lib/client.js +6 -6
- package/lib/index.js +1105 -270
- 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 -8
- 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;
|
|
@@ -2758,13 +2942,11 @@ function buildDshEntry(facts) {
|
|
|
2758
2942
|
"--port",
|
|
2759
2943
|
"{port}",
|
|
2760
2944
|
"--host",
|
|
2761
|
-
"
|
|
2945
|
+
"127.0.0.1",
|
|
2762
2946
|
"--no-open",
|
|
2763
2947
|
"--trusted-host",
|
|
2764
2948
|
`localhost:{port}`
|
|
2765
2949
|
);
|
|
2766
|
-
if (facts.host === "0.0.0.0")
|
|
2767
|
-
appArgs.push("--trusted-host", "{lanIp}:{port}");
|
|
2768
2950
|
}
|
|
2769
2951
|
const launcherArgs = profile === "web" ? ["web"] : ["--profile", profile];
|
|
2770
2952
|
const generated = launch === null ? { command: "dsh", entryArgs: [] } : buildCommand(launch, facts.launcherPath ?? null);
|
|
@@ -2775,7 +2957,7 @@ function buildDshEntry(facts) {
|
|
|
2775
2957
|
autostart: true,
|
|
2776
2958
|
command: generated.command,
|
|
2777
2959
|
args: [...generated.entryArgs, ...launcherArgs, ...appArgs],
|
|
2778
|
-
bind:
|
|
2960
|
+
bind: "local",
|
|
2779
2961
|
port: facts.port,
|
|
2780
2962
|
cwd: process7.cwd(),
|
|
2781
2963
|
env: {},
|
|
@@ -2791,6 +2973,186 @@ function buildDshEntry(facts) {
|
|
|
2791
2973
|
};
|
|
2792
2974
|
}
|
|
2793
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
|
+
|
|
2794
3156
|
// src/home-hosted/entries.ts
|
|
2795
3157
|
function defaultOnPortConflict(platform = process.platform) {
|
|
2796
3158
|
return platform === "win32" ? "kill" : "follow";
|
|
@@ -2804,16 +3166,16 @@ function defaultIntent(id, platform = process.platform) {
|
|
|
2804
3166
|
persistent: true
|
|
2805
3167
|
};
|
|
2806
3168
|
}
|
|
2807
|
-
function ownedPatch(intent, live
|
|
3169
|
+
function ownedPatch(intent, live) {
|
|
2808
3170
|
const stop = live?.stop ?? {};
|
|
2809
3171
|
return {
|
|
2810
3172
|
autostart: intent.autostart,
|
|
2811
3173
|
onPortConflict: intent.onPortConflict,
|
|
2812
|
-
|
|
3174
|
+
persistent: intent.persistent,
|
|
2813
3175
|
stop: { ...stop, killPortHolders: intent.stopKillPortHolders }
|
|
2814
3176
|
};
|
|
2815
3177
|
}
|
|
2816
|
-
function ownedDrift(live, intent
|
|
3178
|
+
function ownedDrift(live, intent) {
|
|
2817
3179
|
if (live === null)
|
|
2818
3180
|
return ["missing entry"];
|
|
2819
3181
|
const drift = [];
|
|
@@ -2821,7 +3183,7 @@ function ownedDrift(live, intent, options = {}) {
|
|
|
2821
3183
|
drift.push("autostart");
|
|
2822
3184
|
if (live.onPortConflict !== intent.onPortConflict)
|
|
2823
3185
|
drift.push("onPortConflict");
|
|
2824
|
-
if (
|
|
3186
|
+
if ((live.persistent ?? false) !== intent.persistent)
|
|
2825
3187
|
drift.push("persistent");
|
|
2826
3188
|
const killPortHolders = live.stop?.killPortHolders;
|
|
2827
3189
|
if (killPortHolders !== intent.stopKillPortHolders)
|
|
@@ -2854,9 +3216,9 @@ function restorePatch(live, snapshot) {
|
|
|
2854
3216
|
|
|
2855
3217
|
// src/home-hosted/panel-control.ts
|
|
2856
3218
|
import { spawn as spawn2 } from "node:child_process";
|
|
2857
|
-
import
|
|
2858
|
-
import
|
|
2859
|
-
import
|
|
3219
|
+
import fs14 from "node:fs";
|
|
3220
|
+
import path12 from "node:path";
|
|
3221
|
+
import process9 from "node:process";
|
|
2860
3222
|
function cliArgs(deps, command) {
|
|
2861
3223
|
const args = [...deps.launch?.args ?? [], command, "--home", deps.home];
|
|
2862
3224
|
if (deps.projectDir)
|
|
@@ -2864,10 +3226,13 @@ function cliArgs(deps, command) {
|
|
|
2864
3226
|
return args;
|
|
2865
3227
|
}
|
|
2866
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);
|
|
2867
3232
|
const launch = deps.launch;
|
|
2868
3233
|
if (launch === null)
|
|
2869
3234
|
return { code: null, stdout: "", stderr: "no home-hosted CLI is available" };
|
|
2870
|
-
return await run(launch.program,
|
|
3235
|
+
return await run(launch.program, args, {
|
|
2871
3236
|
env: deps.env,
|
|
2872
3237
|
timeoutMs: deps.timeoutMs ?? 9e4
|
|
2873
3238
|
});
|
|
@@ -2881,14 +3246,22 @@ async function startPanel(deps) {
|
|
|
2881
3246
|
const match = /https?:\/\/[^\s]+/.exec(result.stdout);
|
|
2882
3247
|
return { ok: true, detail: "the panel is answering", url: match?.[0] ?? null };
|
|
2883
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
|
+
}
|
|
2884
3257
|
function takeoverHelperPath(stateDir) {
|
|
2885
|
-
return
|
|
3258
|
+
return path12.join(launcherDir(stateDir), "panel-takeover.mjs");
|
|
2886
3259
|
}
|
|
2887
3260
|
function takeoverLogPath(stateDir) {
|
|
2888
|
-
return
|
|
3261
|
+
return path12.join(launcherDir(stateDir), "panel-takeover.log");
|
|
2889
3262
|
}
|
|
2890
3263
|
function buildTakeoverSource(deps, oldPid, marker = "managed by dsh-home-hosted") {
|
|
2891
|
-
const program = deps.launch?.program ??
|
|
3264
|
+
const program = deps.launch?.program ?? process9.execPath;
|
|
2892
3265
|
const args = deps.launch === null ? [] : [...deps.launch.args, "up", "--home", deps.home, ...deps.projectDir ? ["--project", deps.projectDir] : []];
|
|
2893
3266
|
const downArgs = [...deps.launch?.args ?? [], "down", "--home", deps.home];
|
|
2894
3267
|
return `#!/usr/bin/env node
|
|
@@ -2943,7 +3316,7 @@ log('started with exit ' + String(result.status))
|
|
|
2943
3316
|
function writeTakeoverHelper(deps, oldPid) {
|
|
2944
3317
|
const file = takeoverHelperPath(deps.stateDir);
|
|
2945
3318
|
writeFileAtomic(file, buildTakeoverSource(deps, oldPid), 493);
|
|
2946
|
-
|
|
3319
|
+
fs14.chmodSync(file, 493);
|
|
2947
3320
|
return file;
|
|
2948
3321
|
}
|
|
2949
3322
|
function spawnDetached(program, args) {
|
|
@@ -2953,7 +3326,7 @@ function spawnTakeover(deps, oldPid, spawnChild = spawnDetached) {
|
|
|
2953
3326
|
const helper = writeTakeoverHelper(deps, oldPid);
|
|
2954
3327
|
let child;
|
|
2955
3328
|
try {
|
|
2956
|
-
child = spawnChild(
|
|
3329
|
+
child = spawnChild(process9.execPath, [helper]);
|
|
2957
3330
|
} catch (error) {
|
|
2958
3331
|
return { ok: false, detail: `could not start the panel-replacement helper: ${error instanceof Error ? error.message : String(error)}` };
|
|
2959
3332
|
}
|
|
@@ -2995,10 +3368,10 @@ var PanelClient = class {
|
|
|
2995
3368
|
this.token = options.token;
|
|
2996
3369
|
this.timeoutMs = options.timeoutMs ?? 1e4;
|
|
2997
3370
|
}
|
|
2998
|
-
async request(method,
|
|
3371
|
+
async request(method, path16, body) {
|
|
2999
3372
|
let response;
|
|
3000
3373
|
try {
|
|
3001
|
-
response = await fetch(`${this.baseUrl}${
|
|
3374
|
+
response = await fetch(`${this.baseUrl}${path16}`, {
|
|
3002
3375
|
method,
|
|
3003
3376
|
headers: {
|
|
3004
3377
|
authorization: `Bearer ${this.token}`,
|
|
@@ -3075,91 +3448,21 @@ async function verifyToken(baseUrl, token, timeoutMs = 5e3) {
|
|
|
3075
3448
|
return await probeToken(baseUrl, token, timeoutMs) === "ok";
|
|
3076
3449
|
}
|
|
3077
3450
|
|
|
3078
|
-
// src/home-hosted/runtime.ts
|
|
3079
|
-
import http from "node:http";
|
|
3080
|
-
import https from "node:https";
|
|
3081
|
-
import process9 from "node:process";
|
|
3082
|
-
function readRuntime(home) {
|
|
3083
|
-
const raw = readJson(runtimeFile(home));
|
|
3084
|
-
if (raw === null)
|
|
3085
|
-
return null;
|
|
3086
|
-
const url = typeof raw.url === "string" ? raw.url : null;
|
|
3087
|
-
const pid = typeof raw.pid === "number" ? raw.pid : null;
|
|
3088
|
-
if (url === null || pid === null)
|
|
3089
|
-
return null;
|
|
3090
|
-
return {
|
|
3091
|
-
version: typeof raw.version === "string" ? raw.version : "unknown",
|
|
3092
|
-
pid,
|
|
3093
|
-
url: url.replace(/\/+$/, ""),
|
|
3094
|
-
probeUrl: typeof raw.probeUrl === "string" ? raw.probeUrl : void 0,
|
|
3095
|
-
protocol: typeof raw.protocol === "string" ? raw.protocol : void 0,
|
|
3096
|
-
port: typeof raw.port === "number" ? raw.port : 0,
|
|
3097
|
-
bindHost: typeof raw.bindHost === "string" ? raw.bindHost : void 0,
|
|
3098
|
-
projectDir: typeof raw.projectDir === "string" ? raw.projectDir : void 0,
|
|
3099
|
-
dataRoot: typeof raw.dataRoot === "string" ? raw.dataRoot : void 0,
|
|
3100
|
-
configPath: typeof raw.configPath === "string" ? raw.configPath : void 0,
|
|
3101
|
-
logFile: typeof raw.logFile === "string" ? raw.logFile : void 0
|
|
3102
|
-
};
|
|
3103
|
-
}
|
|
3104
|
-
function pidAlive(pid) {
|
|
3105
|
-
try {
|
|
3106
|
-
process9.kill(pid, 0);
|
|
3107
|
-
return true;
|
|
3108
|
-
} catch {
|
|
3109
|
-
return false;
|
|
3110
|
-
}
|
|
3111
|
-
}
|
|
3112
|
-
async function probePanel(url, timeoutMs = 2e3) {
|
|
3113
|
-
const target = `${url.replace(/\/+$/, "")}/healthz`;
|
|
3114
|
-
return await new Promise((resolve) => {
|
|
3115
|
-
let settled = false;
|
|
3116
|
-
const done = (value) => {
|
|
3117
|
-
if (settled)
|
|
3118
|
-
return;
|
|
3119
|
-
settled = true;
|
|
3120
|
-
resolve(value);
|
|
3121
|
-
};
|
|
3122
|
-
let parsed;
|
|
3123
|
-
try {
|
|
3124
|
-
parsed = new URL(target);
|
|
3125
|
-
} catch {
|
|
3126
|
-
done(false);
|
|
3127
|
-
return;
|
|
3128
|
-
}
|
|
3129
|
-
const transport = parsed.protocol === "https:" ? https : http;
|
|
3130
|
-
const request = transport.get(
|
|
3131
|
-
{
|
|
3132
|
-
hostname: parsed.hostname,
|
|
3133
|
-
port: parsed.port,
|
|
3134
|
-
path: `${parsed.pathname}${parsed.search}`,
|
|
3135
|
-
timeout: timeoutMs,
|
|
3136
|
-
rejectUnauthorized: false
|
|
3137
|
-
},
|
|
3138
|
-
(response) => {
|
|
3139
|
-
response.resume();
|
|
3140
|
-
done(typeof response.statusCode === "number");
|
|
3141
|
-
}
|
|
3142
|
-
);
|
|
3143
|
-
request.on("timeout", () => {
|
|
3144
|
-
request.destroy();
|
|
3145
|
-
done(false);
|
|
3146
|
-
});
|
|
3147
|
-
request.on("error", () => done(false));
|
|
3148
|
-
});
|
|
3149
|
-
}
|
|
3150
|
-
|
|
3151
3451
|
// src/home-hosted/token.ts
|
|
3152
|
-
import { randomBytes } from "node:crypto";
|
|
3153
|
-
import
|
|
3154
|
-
function
|
|
3155
|
-
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);
|
|
3156
3456
|
}
|
|
3157
|
-
function
|
|
3158
|
-
|
|
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();
|
|
3159
3462
|
return text !== void 0 && text.length > 0 ? text : null;
|
|
3160
3463
|
}
|
|
3161
|
-
function storeToken(stateDir, token) {
|
|
3162
|
-
writeFileAtomic(storedTokenPath(stateDir), `${token}
|
|
3464
|
+
function storeToken(stateDir, token, slot) {
|
|
3465
|
+
writeFileAtomic(storedTokenPath(stateDir, slot), `${token}
|
|
3163
3466
|
`, 384);
|
|
3164
3467
|
}
|
|
3165
3468
|
function generateToken() {
|
|
@@ -3170,8 +3473,8 @@ function apiTokenEnrolled(home) {
|
|
|
3170
3473
|
return secrets !== null && secrets.apiToken !== null && secrets.apiToken !== void 0;
|
|
3171
3474
|
}
|
|
3172
3475
|
var queue = /* @__PURE__ */ new Map();
|
|
3173
|
-
function serialised(stateDir, work) {
|
|
3174
|
-
const key =
|
|
3476
|
+
function serialised(stateDir, slot, work) {
|
|
3477
|
+
const key = `${path13.resolve(stateDir)}\0${slot ?? ""}`;
|
|
3175
3478
|
const previous = queue.get(key) ?? Promise.resolve();
|
|
3176
3479
|
const next = previous.then(work, work);
|
|
3177
3480
|
const settled = next.then(() => void 0, () => void 0);
|
|
@@ -3182,10 +3485,10 @@ function serialised(stateDir, work) {
|
|
|
3182
3485
|
return next;
|
|
3183
3486
|
}
|
|
3184
3487
|
async function ensureToken(options) {
|
|
3185
|
-
return await serialised(options.stateDir, async () => await enrollToken(options));
|
|
3488
|
+
return await serialised(options.stateDir, options.slot, async () => await enrollToken(options));
|
|
3186
3489
|
}
|
|
3187
3490
|
async function reclaimToken(options) {
|
|
3188
|
-
return await serialised(options.stateDir, async () => await enrollFreshToken(options));
|
|
3491
|
+
return await serialised(options.stateDir, options.slot, async () => await enrollFreshToken(options));
|
|
3189
3492
|
}
|
|
3190
3493
|
async function runCli2(exec, args, env) {
|
|
3191
3494
|
try {
|
|
@@ -3201,7 +3504,7 @@ async function runCli2(exec, args, env) {
|
|
|
3201
3504
|
}
|
|
3202
3505
|
}
|
|
3203
3506
|
async function enrollToken(options) {
|
|
3204
|
-
const stored = readStoredToken(options.stateDir);
|
|
3507
|
+
const stored = readStoredToken(options.stateDir, options.slot);
|
|
3205
3508
|
if (stored !== null)
|
|
3206
3509
|
return { token: stored, enrolled: false, detail: "using the stored panel token" };
|
|
3207
3510
|
if (apiTokenEnrolled(options.home)) {
|
|
@@ -3220,7 +3523,7 @@ async function enrollToken(options) {
|
|
|
3220
3523
|
detail: `could not enrol an API token: ${enrolled.failure ?? "the CLI did not answer"}`
|
|
3221
3524
|
};
|
|
3222
3525
|
}
|
|
3223
|
-
storeToken(options.stateDir, token);
|
|
3526
|
+
storeToken(options.stateDir, token, options.slot);
|
|
3224
3527
|
return { token, enrolled: true, detail: "enrolled a new panel API token" };
|
|
3225
3528
|
}
|
|
3226
3529
|
async function enrollFreshToken(options) {
|
|
@@ -3232,7 +3535,7 @@ async function enrollFreshToken(options) {
|
|
|
3232
3535
|
detail: `could not enrol a fresh API token: ${enrolled.failure ?? "the CLI did not answer"}; the previously stored token was kept`
|
|
3233
3536
|
};
|
|
3234
3537
|
}
|
|
3235
|
-
storeToken(options.stateDir, token);
|
|
3538
|
+
storeToken(options.stateDir, token, options.slot);
|
|
3236
3539
|
return { token, detail: "enrolled a fresh panel API token" };
|
|
3237
3540
|
}
|
|
3238
3541
|
|
|
@@ -3240,9 +3543,10 @@ async function enrollFreshToken(options) {
|
|
|
3240
3543
|
var ENTRY_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
|
3241
3544
|
var TOKEN_PROBE_TIMEOUT_MS = 3e3;
|
|
3242
3545
|
var ENTRY_RECOVERY_INTERVAL_MS = 3e4;
|
|
3546
|
+
var INSTANCES_CACHE_MS = 1e4;
|
|
3243
3547
|
function pluginRoot() {
|
|
3244
3548
|
try {
|
|
3245
|
-
return
|
|
3549
|
+
return path14.dirname(path14.dirname(fileURLToPath(import.meta.url)));
|
|
3246
3550
|
} catch {
|
|
3247
3551
|
return null;
|
|
3248
3552
|
}
|
|
@@ -3254,11 +3558,14 @@ var HomeHostedError = class extends Error {
|
|
|
3254
3558
|
this.name = "HomeHostedError";
|
|
3255
3559
|
}
|
|
3256
3560
|
};
|
|
3561
|
+
function foreignView(entry) {
|
|
3562
|
+
return { id: entry.id, status: "unknown", pid: null, url: null, config: entry };
|
|
3563
|
+
}
|
|
3257
3564
|
var HomeHostedService = class extends Service {
|
|
3258
3565
|
constructor(ctx, options) {
|
|
3259
3566
|
super(ctx, "homeHosted");
|
|
3260
3567
|
this.options = options;
|
|
3261
|
-
this.snapshotsFile =
|
|
3568
|
+
this.snapshotsFile = path14.join(options.stateDir, "snapshots.json");
|
|
3262
3569
|
}
|
|
3263
3570
|
clientCache = null;
|
|
3264
3571
|
/** The last token this service proved against a panel, so a poll does not re-probe it. */
|
|
@@ -3266,6 +3573,8 @@ var HomeHostedService = class extends Service {
|
|
|
3266
3573
|
tokenDetail = "";
|
|
3267
3574
|
/** When a missing managed entry was last put back, so a poll cannot become a write loop. */
|
|
3268
3575
|
entryRecoveryAt = 0;
|
|
3576
|
+
/** The last panel inventory, so a model-step assembly never reads the disk. */
|
|
3577
|
+
instanceCache = null;
|
|
3269
3578
|
snapshotsFile;
|
|
3270
3579
|
async resolveDsh(dshHome2) {
|
|
3271
3580
|
return await (this.options.resolveDsh ?? ((options) => resolveDshLaunch(options)))({
|
|
@@ -3274,6 +3583,185 @@ var HomeHostedService = class extends Service {
|
|
|
3274
3583
|
});
|
|
3275
3584
|
}
|
|
3276
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
|
+
// -------------------------------------------------------------------------
|
|
3277
3765
|
// Facts
|
|
3278
3766
|
// -------------------------------------------------------------------------
|
|
3279
3767
|
runtime() {
|
|
@@ -3285,14 +3773,13 @@ var HomeHostedService = class extends Service {
|
|
|
3285
3773
|
return id !== void 0 && id.length > 0 ? id : null;
|
|
3286
3774
|
}
|
|
3287
3775
|
/** What the config's `meta.writtenBy` names: the panel release when we can read it. */
|
|
3288
|
-
writtenBy() {
|
|
3289
|
-
return
|
|
3776
|
+
writtenBy(home = this.options.home) {
|
|
3777
|
+
return readRuntime(home)?.version ?? "dsh-home-hosted";
|
|
3290
3778
|
}
|
|
3291
3779
|
webServer() {
|
|
3292
3780
|
const service = this.ctx.get("webServer");
|
|
3293
3781
|
const port = typeof service?.port === "number" ? service.port : null;
|
|
3294
|
-
|
|
3295
|
-
return { port, host };
|
|
3782
|
+
return { port };
|
|
3296
3783
|
}
|
|
3297
3784
|
cliCache = null;
|
|
3298
3785
|
/**
|
|
@@ -3329,7 +3816,8 @@ var HomeHostedService = class extends Service {
|
|
|
3329
3816
|
home: this.options.home,
|
|
3330
3817
|
projectDir,
|
|
3331
3818
|
stateDir: this.options.stateDir,
|
|
3332
|
-
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
|
|
3333
3821
|
};
|
|
3334
3822
|
}
|
|
3335
3823
|
async cliExec(args, env) {
|
|
@@ -3520,11 +4008,10 @@ var HomeHostedService = class extends Service {
|
|
|
3520
4008
|
return null;
|
|
3521
4009
|
const harnessHome = process10.env.DSH_HOME ?? dshHome();
|
|
3522
4010
|
const dsh = await this.resolveDsh(harnessHome);
|
|
3523
|
-
const { port
|
|
4011
|
+
const { port } = this.webServer();
|
|
3524
4012
|
const generated = buildDshEntry({
|
|
3525
4013
|
id: intent.id,
|
|
3526
4014
|
port,
|
|
3527
|
-
host,
|
|
3528
4015
|
profile: detectProfile(process10.argv, process10.env),
|
|
3529
4016
|
dshHome: harnessHome,
|
|
3530
4017
|
launch: dsh,
|
|
@@ -3552,7 +4039,7 @@ var HomeHostedService = class extends Service {
|
|
|
3552
4039
|
snapshots[intent.id] = live === null ? { id: intent.id } : snapshotOwned(live.config);
|
|
3553
4040
|
this.saveSnapshots(snapshots);
|
|
3554
4041
|
}
|
|
3555
|
-
const patch = ownedPatch(intent, live?.config ?? null
|
|
4042
|
+
const patch = ownedPatch(intent, live?.config ?? null);
|
|
3556
4043
|
const repair = intent.id === this.options.defaultEntryId ? await this.dshCommandRepair(live?.config ?? null) : null;
|
|
3557
4044
|
const client = await this.tryClient();
|
|
3558
4045
|
if (client !== null) {
|
|
@@ -3593,35 +4080,6 @@ var HomeHostedService = class extends Service {
|
|
|
3593
4080
|
const runtime = this.runtime();
|
|
3594
4081
|
return runtime !== null && (pidAlive(runtime.pid) || await probePanel(runtime.url));
|
|
3595
4082
|
}
|
|
3596
|
-
/**
|
|
3597
|
-
* The version whose schema will parse what we write: the answering panel, or
|
|
3598
|
-
* the CLI that will parse it next. `kill` did not exist before 0.6.0, and an
|
|
3599
|
-
* older panel refuses to *boot* with it — so this is checked before any write.
|
|
3600
|
-
*/
|
|
3601
|
-
async configVersion() {
|
|
3602
|
-
const runtime = this.runtime();
|
|
3603
|
-
if (runtime !== null && runtime.version !== null && (pidAlive(runtime.pid) || await probePanel(runtime.url)))
|
|
3604
|
-
return runtime.version;
|
|
3605
|
-
const { resolution } = await this.cli();
|
|
3606
|
-
return resolution.status.version;
|
|
3607
|
-
}
|
|
3608
|
-
/** Whether the panel whose schema parses our write knows `persistent`. */
|
|
3609
|
-
async supportsPersistent() {
|
|
3610
|
-
const version = await this.configVersion();
|
|
3611
|
-
return version !== null && compareVersions(version, MIN_PERSISTENT_VERSION) >= 0;
|
|
3612
|
-
}
|
|
3613
|
-
async assertPolicySupported(intent) {
|
|
3614
|
-
if (intent.onPortConflict !== "kill")
|
|
3615
|
-
return;
|
|
3616
|
-
const version = await this.configVersion();
|
|
3617
|
-
if (version === null || compareVersions(version, MIN_KILL_VERSION) >= 0)
|
|
3618
|
-
return;
|
|
3619
|
-
const { resolution } = await this.cli();
|
|
3620
|
-
throw new HomeHostedError(
|
|
3621
|
-
`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`,
|
|
3622
|
-
"KILL_UNSUPPORTED"
|
|
3623
|
-
);
|
|
3624
|
-
}
|
|
3625
4083
|
async applyIntents(intents) {
|
|
3626
4084
|
for (const raw of intents) {
|
|
3627
4085
|
if (!ENTRY_ID_PATTERN.test(raw.id))
|
|
@@ -3640,7 +4098,6 @@ var HomeHostedService = class extends Service {
|
|
|
3640
4098
|
stopKillPortHolders: raw.stopKillPortHolders ?? fallback.stopKillPortHolders,
|
|
3641
4099
|
persistent: raw.persistent ?? fallback.persistent
|
|
3642
4100
|
};
|
|
3643
|
-
await this.assertPolicySupported(intent);
|
|
3644
4101
|
await this.writeOwned(intent);
|
|
3645
4102
|
const current = this.options.settings.get();
|
|
3646
4103
|
const entries = current.entries.filter((entry) => entry.id !== intent.id);
|
|
@@ -3705,21 +4162,22 @@ var HomeHostedService = class extends Service {
|
|
|
3705
4162
|
return await this.entriesStatus();
|
|
3706
4163
|
}
|
|
3707
4164
|
/** Drive the panel's own UI through its CLI, and report what is installed. */
|
|
3708
|
-
async uiManage(action, file) {
|
|
4165
|
+
async uiManage(action, file, target) {
|
|
4166
|
+
const home = target ?? this.options.home;
|
|
3709
4167
|
const cli = action === "status" ? null : await this.cli();
|
|
3710
4168
|
const runUi = async (args) => {
|
|
3711
4169
|
const launch = cli?.resolution.launch ?? null;
|
|
3712
4170
|
if (launch === null)
|
|
3713
4171
|
throw new HomeHostedError(cli?.resolution.status.detail ?? "no home-hosted CLI could be resolved", "CLI_NOT_FOUND");
|
|
3714
|
-
const result = await run(launch.program, [...launch.args, ...args, "--home",
|
|
3715
|
-
env: { ...process10.env, HHOSTED_HOME:
|
|
4172
|
+
const result = await run(launch.program, [...launch.args, ...args, "--home", home], {
|
|
4173
|
+
env: { ...process10.env, HHOSTED_HOME: home },
|
|
3716
4174
|
timeoutMs: 3e5
|
|
3717
4175
|
});
|
|
3718
4176
|
const output = [result.error, result.stdout, result.stderr].filter((part) => typeof part === "string" && part.trim().length > 0).join("\n").trim();
|
|
3719
4177
|
return { code: result.code, output };
|
|
3720
4178
|
};
|
|
3721
4179
|
const active = () => {
|
|
3722
|
-
const meta = readJson(
|
|
4180
|
+
const meta = readJson(path14.join(home, ".ui", "ui.json"));
|
|
3723
4181
|
return meta === null ? null : { name: meta.name ?? null, version: meta.version ?? null, repo: meta.repo ?? null, tag: meta.tag ?? null };
|
|
3724
4182
|
};
|
|
3725
4183
|
switch (action) {
|
|
@@ -3738,7 +4196,7 @@ var HomeHostedService = class extends Service {
|
|
|
3738
4196
|
case "switch": {
|
|
3739
4197
|
if (file === void 0 || file.trim().length === 0)
|
|
3740
4198
|
throw new HomeHostedError("switching the UI needs the zip to install", "UI_FILE_REQUIRED");
|
|
3741
|
-
if (!
|
|
4199
|
+
if (!fs15.existsSync(file))
|
|
3742
4200
|
throw new HomeHostedError(`no file at ${file}`, "UI_FILE_MISSING");
|
|
3743
4201
|
const result = await runUi(["ui-switch", "--file", file, "--yes"]);
|
|
3744
4202
|
return { ok: result.code === 0, detail: result.code === 0 ? "the UI was replaced" : "the switch did not run", ui: active(), output: result.output };
|
|
@@ -3777,7 +4235,6 @@ var HomeHostedService = class extends Service {
|
|
|
3777
4235
|
const settings = this.options.settings.get();
|
|
3778
4236
|
const live = await this.liveEntries();
|
|
3779
4237
|
const snapshots = this.snapshots();
|
|
3780
|
-
const persistent = await this.supportsPersistent();
|
|
3781
4238
|
const ids = /* @__PURE__ */ new Set([this.options.defaultEntryId, ...settings.entries.map((entry) => entry.id)]);
|
|
3782
4239
|
return [...ids].map((id) => {
|
|
3783
4240
|
const intent = this.options.settings.intentFor(id);
|
|
@@ -3786,7 +4243,7 @@ var HomeHostedService = class extends Service {
|
|
|
3786
4243
|
intent,
|
|
3787
4244
|
exists: entry !== null,
|
|
3788
4245
|
managed: snapshots[id] !== void 0,
|
|
3789
|
-
drift: entry === null ? ["missing entry"] : ownedDrift(entry.config, intent
|
|
4246
|
+
drift: entry === null ? ["missing entry"] : ownedDrift(entry.config, intent),
|
|
3790
4247
|
live: entry?.view ?? null,
|
|
3791
4248
|
snapshot: snapshots[id] ?? null
|
|
3792
4249
|
};
|
|
@@ -3912,6 +4369,28 @@ var HomeHostedService = class extends Service {
|
|
|
3912
4369
|
this.clientCache = null;
|
|
3913
4370
|
return result;
|
|
3914
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
|
+
}
|
|
3915
4394
|
/**
|
|
3916
4395
|
* Replace an answering panel with the preferred copy.
|
|
3917
4396
|
*
|
|
@@ -3992,7 +4471,7 @@ var HomeHostedService = class extends Service {
|
|
|
3992
4471
|
if (status.state !== "enabled-failing" && status.state !== "installed-disabled")
|
|
3993
4472
|
return;
|
|
3994
4473
|
const fileBacked = status.mechanism === "systemd-user" || status.mechanism === "systemd-system" || status.mechanism === "xdg-autostart" || status.mechanism === "launchd-agent" || status.mechanism === "launchd-daemon";
|
|
3995
|
-
if (fileBacked && (status.unitPath === null || !
|
|
4474
|
+
if (fileBacked && (status.unitPath === null || !fs15.existsSync(status.unitPath)))
|
|
3996
4475
|
return;
|
|
3997
4476
|
try {
|
|
3998
4477
|
await this.installBoot();
|
|
@@ -4002,7 +4481,7 @@ var HomeHostedService = class extends Service {
|
|
|
4002
4481
|
// -------------------------------------------------------------------------
|
|
4003
4482
|
// Status
|
|
4004
4483
|
// -------------------------------------------------------------------------
|
|
4005
|
-
async status() {
|
|
4484
|
+
async status(refresh = false) {
|
|
4006
4485
|
const client = await this.tryClient();
|
|
4007
4486
|
const panel = await this.panelStatus();
|
|
4008
4487
|
const { resolution, launcher, launcherVersion } = await this.cli();
|
|
@@ -4030,6 +4509,10 @@ var HomeHostedService = class extends Service {
|
|
|
4030
4509
|
return {
|
|
4031
4510
|
defaultEntryId: this.options.defaultEntryId,
|
|
4032
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),
|
|
4033
4516
|
boot: await this.bootStatus(),
|
|
4034
4517
|
// A deleted managed entry is put back here as well as at startup: whoever
|
|
4035
4518
|
// deleted it is looking at the page that reports it missing.
|
|
@@ -4045,28 +4528,54 @@ var HomeHostedService = class extends Service {
|
|
|
4045
4528
|
// -------------------------------------------------------------------------
|
|
4046
4529
|
async call(endpoint, payload) {
|
|
4047
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
|
+
}
|
|
4048
4537
|
switch (endpoint) {
|
|
4049
4538
|
case "status":
|
|
4050
|
-
return await this.status();
|
|
4539
|
+
return await this.status(input.refresh === true);
|
|
4051
4540
|
case "settings.update": {
|
|
4052
4541
|
const patch = input.patch ?? {};
|
|
4053
4542
|
this.options.settings.update(patch);
|
|
4054
4543
|
return await this.status();
|
|
4055
4544
|
}
|
|
4056
|
-
case "servers.list":
|
|
4057
|
-
|
|
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
|
+
}
|
|
4058
4554
|
case "servers.get":
|
|
4059
4555
|
return await (await this.requireClient()).getServer(String(input.id));
|
|
4060
4556
|
case "servers.create": {
|
|
4061
4557
|
const entry = input.entry;
|
|
4062
4558
|
if (typeof entry?.id !== "string" || !ENTRY_ID_PATTERN.test(entry.id))
|
|
4063
4559
|
throw new HomeHostedError("a server entry needs an id matching ^[a-z0-9][a-z0-9_-]*$", "INVALID_ID");
|
|
4064
|
-
|
|
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);
|
|
4065
4572
|
}
|
|
4066
|
-
case "servers.update":
|
|
4067
|
-
return await (await this.requireClient()).updateServer(String(input.id), input.patch ?? {});
|
|
4068
4573
|
case "servers.delete": {
|
|
4069
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
|
+
}
|
|
4070
4579
|
if (id === this.selfEntryId()) {
|
|
4071
4580
|
throw new HomeHostedError(
|
|
4072
4581
|
`"${id}" is the entry this very process runs as, and deleting it stops this session; pause it (autostart off) or restore it instead`,
|
|
@@ -4076,15 +4585,45 @@ var HomeHostedService = class extends Service {
|
|
|
4076
4585
|
await (await this.requireClient()).deleteServer(id);
|
|
4077
4586
|
return { id };
|
|
4078
4587
|
}
|
|
4079
|
-
case "servers.start":
|
|
4080
|
-
|
|
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);
|
|
4081
4599
|
return await this.entriesStatus();
|
|
4082
|
-
|
|
4083
|
-
|
|
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);
|
|
4084
4612
|
return await this.entriesStatus();
|
|
4085
|
-
|
|
4086
|
-
|
|
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);
|
|
4087
4625
|
return await this.entriesStatus();
|
|
4626
|
+
}
|
|
4088
4627
|
case "servers.freePort":
|
|
4089
4628
|
return await (await this.requireClient()).freePort(String(input.id));
|
|
4090
4629
|
case "entries.apply": {
|
|
@@ -4095,8 +4634,16 @@ var HomeHostedService = class extends Service {
|
|
|
4095
4634
|
return await this.removeManagedEntry(String(input.id));
|
|
4096
4635
|
case "entries.restore":
|
|
4097
4636
|
return await this.restoreEntry(String(input.id));
|
|
4098
|
-
case "ui.manage":
|
|
4099
|
-
|
|
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
|
+
}
|
|
4100
4647
|
case "boot.install":
|
|
4101
4648
|
return await this.installBoot(input.mechanism);
|
|
4102
4649
|
case "boot.uninstall":
|
|
@@ -4105,6 +4652,8 @@ var HomeHostedService = class extends Service {
|
|
|
4105
4652
|
return await this.bootStatus();
|
|
4106
4653
|
case "panel.start":
|
|
4107
4654
|
return await this.startPanelNow();
|
|
4655
|
+
case "panel.stop":
|
|
4656
|
+
return await this.stopPanelNow();
|
|
4108
4657
|
case "panel.takeover":
|
|
4109
4658
|
return await this.takeoverPanel(input.force === true);
|
|
4110
4659
|
case "panel.reclaimToken":
|
|
@@ -4174,6 +4723,24 @@ function registerRpc(ctx, service) {
|
|
|
4174
4723
|
});
|
|
4175
4724
|
}
|
|
4176
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
|
+
|
|
4177
4744
|
// src/home-hosted/web-notice.ts
|
|
4178
4745
|
var STOCK_UNAUTHORIZED = "dsh web authentication required; reopen the URL printed by dsh web.";
|
|
4179
4746
|
function authNoticeBody(logUrl) {
|
|
@@ -4273,6 +4840,7 @@ var LEGACY_TOOL_NAMES = {
|
|
|
4273
4840
|
function isLegacyDefaultAllowlist(names) {
|
|
4274
4841
|
return names.length === 2 && names.includes("status") && names.includes("servers_list");
|
|
4275
4842
|
}
|
|
4843
|
+
var PRE_MERGE_SETTINGS_VERSION = 2;
|
|
4276
4844
|
var BOOT_MECHANISMS = ["auto", "systemd-user", "systemd-system", "launchd-agent", "launchd-daemon", "xdg-autostart", "windows-run", "windows-task", "container", "unsupported"];
|
|
4277
4845
|
function isBootMechanism(value) {
|
|
4278
4846
|
return typeof value === "string" && BOOT_MECHANISMS.includes(value);
|
|
@@ -4280,7 +4848,7 @@ function isBootMechanism(value) {
|
|
|
4280
4848
|
function normalize(raw, fallbackEntryId) {
|
|
4281
4849
|
const entries = Array.isArray(raw?.entries) ? raw.entries.map((intent) => normalizeIntent(intent, fallbackEntryId)) : [];
|
|
4282
4850
|
const declared = Array.isArray(raw?.agentTools?.allow) ? raw.agentTools.allow : null;
|
|
4283
|
-
const legacyFile = (typeof raw?.version === "number" ? raw.version : 1) <
|
|
4851
|
+
const legacyFile = (typeof raw?.version === "number" ? raw.version : 1) < PRE_MERGE_SETTINGS_VERSION;
|
|
4284
4852
|
const migrated = declared === null ? null : [...new Set(declared.map((name2) => legacyFile ? LEGACY_TOOL_NAMES[name2] ?? name2 : name2).filter(knownTool))];
|
|
4285
4853
|
const choseNothing = legacyFile && declared !== null && migrated !== null && isLegacyDefaultAllowlist(declared);
|
|
4286
4854
|
const allow = migrated === null || choseNothing ? [...DEFAULT_SETTINGS.agentTools.allow] : migrated;
|
|
@@ -4304,6 +4872,8 @@ function normalize(raw, fallbackEntryId) {
|
|
|
4304
4872
|
authNotice: raw?.authNotice === void 0 ? DEFAULT_SETTINGS.authNotice : raw.authNotice === true,
|
|
4305
4873
|
// Absent means on: a stale token is a fault to repair, not a setting to opt into.
|
|
4306
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,
|
|
4307
4877
|
uiStyle: raw?.uiStyle === "compact" ? "compact" : "detailed",
|
|
4308
4878
|
agentTools: {
|
|
4309
4879
|
// Absent means the default (on), and so does the pair the previous
|
|
@@ -4342,6 +4912,7 @@ var SettingsStore = class {
|
|
|
4342
4912
|
panel: { ...this.current.panel, ...patch.panel ?? {} },
|
|
4343
4913
|
authNotice: patch.authNotice ?? this.current.authNotice,
|
|
4344
4914
|
reclaimToken: patch.reclaimToken ?? this.current.reclaimToken,
|
|
4915
|
+
instancesNotice: patch.instancesNotice ?? this.current.instancesNotice,
|
|
4345
4916
|
uiStyle: patch.uiStyle ?? this.current.uiStyle,
|
|
4346
4917
|
agentTools: { ...this.current.agentTools, ...patch.agentTools ?? {} },
|
|
4347
4918
|
cli: { ...this.current.cli, ...patch.cli ?? {} }
|
|
@@ -4373,6 +4944,10 @@ function sandboxMode(ctx, exec) {
|
|
|
4373
4944
|
return null;
|
|
4374
4945
|
}
|
|
4375
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
|
+
}
|
|
4376
4951
|
function stringArg(input, key) {
|
|
4377
4952
|
const value = input[key];
|
|
4378
4953
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
@@ -4381,20 +4956,62 @@ function jsonArg(input, key) {
|
|
|
4381
4956
|
const value = input[key];
|
|
4382
4957
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
4383
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
|
+
};
|
|
4384
5000
|
var TOOL_SPECS = {
|
|
4385
5001
|
status: {
|
|
4386
|
-
description: "Report the home-hosted panel state, its boot-autostart entry, and the entries this plugin manages.",
|
|
4387
|
-
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 },
|
|
4388
5004
|
run: () => ({ endpoint: "status", payload: {} })
|
|
4389
5005
|
},
|
|
4390
5006
|
servers_list: {
|
|
4391
|
-
description: "List every server home-hosted supervises, with status, pid and url.",
|
|
4392
|
-
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 },
|
|
4393
5009
|
run: () => ({ endpoint: "servers.list", payload: {} })
|
|
4394
5010
|
},
|
|
4395
5011
|
servers_lifecycle: {
|
|
4396
|
-
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.",
|
|
4397
5013
|
parameters: {
|
|
5014
|
+
instance: INSTANCE_PARAM,
|
|
4398
5015
|
action: { type: "string", required: true, description: "start, stop or restart" },
|
|
4399
5016
|
id: { type: "string", required: true, description: "Server entry id" }
|
|
4400
5017
|
},
|
|
@@ -4409,27 +5026,31 @@ var TOOL_SPECS = {
|
|
|
4409
5026
|
}
|
|
4410
5027
|
},
|
|
4411
5028
|
servers_edit: {
|
|
4412
|
-
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.",
|
|
4413
5030
|
parameters: {
|
|
5031
|
+
instance: INSTANCE_PARAM,
|
|
4414
5032
|
action: { type: "string", required: true, description: "create, update or delete" },
|
|
4415
5033
|
id: { type: "string", description: "Server entry id (update, delete)" },
|
|
4416
|
-
entry:
|
|
4417
|
-
patch:
|
|
5034
|
+
entry: ENTRY_PARAM,
|
|
5035
|
+
patch: PATCH_PARAM
|
|
4418
5036
|
},
|
|
4419
5037
|
run: (input) => {
|
|
4420
5038
|
const action = stringArg(input, "action");
|
|
4421
5039
|
if (action === "create") {
|
|
4422
5040
|
const entry = jsonArg(input, "entry");
|
|
4423
5041
|
if (entry === null)
|
|
4424
|
-
throw
|
|
5042
|
+
throw jsonArgError(input, "entry", "to create a server");
|
|
4425
5043
|
return { endpoint: "servers.create", payload: { entry } };
|
|
4426
5044
|
}
|
|
4427
5045
|
if (action === "update") {
|
|
4428
5046
|
const id = stringArg(input, "id");
|
|
4429
5047
|
const patch = jsonArg(input, "patch");
|
|
4430
|
-
if (id === null
|
|
4431
|
-
throw new Error("id
|
|
4432
|
-
|
|
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 } };
|
|
4433
5054
|
}
|
|
4434
5055
|
if (action === "delete") {
|
|
4435
5056
|
const id = stringArg(input, "id");
|
|
@@ -4441,8 +5062,9 @@ var TOOL_SPECS = {
|
|
|
4441
5062
|
}
|
|
4442
5063
|
},
|
|
4443
5064
|
autostart_manage: {
|
|
4444
|
-
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.",
|
|
4445
5066
|
parameters: {
|
|
5067
|
+
instance: INSTANCE_PARAM,
|
|
4446
5068
|
action: { type: "string", required: true, description: "install or uninstall" },
|
|
4447
5069
|
mechanism: { type: "string", description: "Explicit mechanism, e.g. launchd-daemon or systemd-system; omit to use the plugin setting" }
|
|
4448
5070
|
},
|
|
@@ -4455,8 +5077,9 @@ var TOOL_SPECS = {
|
|
|
4455
5077
|
}
|
|
4456
5078
|
},
|
|
4457
5079
|
ui_manage: {
|
|
4458
|
-
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.",
|
|
4459
5081
|
parameters: {
|
|
5082
|
+
instance: INSTANCE_PARAM,
|
|
4460
5083
|
action: { type: "string", required: true, description: "status, update, revert or switch" },
|
|
4461
5084
|
file: { type: "string", description: "Absolute path to a UI zip (switch)" }
|
|
4462
5085
|
},
|
|
@@ -4475,6 +5098,198 @@ function toolNameFor(name2) {
|
|
|
4475
5098
|
var READ_ONLY_ACTIONS = {
|
|
4476
5099
|
ui_manage: (input) => stringArg(input, "action") === "status"
|
|
4477
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
|
+
}
|
|
4478
5293
|
var TOKEN_REFUSAL_CODES = /* @__PURE__ */ new Set([
|
|
4479
5294
|
"AUTH_REQUIRED",
|
|
4480
5295
|
"AUTH_UNARMED",
|
|
@@ -4531,8 +5346,17 @@ function registerOne(ctx, service, settings, name2) {
|
|
|
4531
5346
|
},
|
|
4532
5347
|
async execute(args, exec) {
|
|
4533
5348
|
const input = args ?? {};
|
|
5349
|
+
let request;
|
|
5350
|
+
try {
|
|
5351
|
+
request = spec.run(input);
|
|
5352
|
+
} catch (error) {
|
|
5353
|
+
return failureText(error);
|
|
5354
|
+
}
|
|
4534
5355
|
const mode = sandboxMode(ctx, exec);
|
|
4535
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;
|
|
4536
5360
|
if (mutating && mode !== "danger-full-access") {
|
|
4537
5361
|
const remedy = "Set the session to Full access (danger-full-access), or use a session where approvals can be answered.";
|
|
4538
5362
|
const approval = ctx.get("approval");
|
|
@@ -4546,16 +5370,17 @@ function registerOne(ctx, service, settings, name2) {
|
|
|
4546
5370
|
if (outcome !== "allowed-once")
|
|
4547
5371
|
return `refused: approval answered "${outcome}" (session sandbox: ${mode ?? "unknown"}). ${remedy}`;
|
|
4548
5372
|
}
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
4554
|
-
|
|
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}`}.`;
|
|
4555
5380
|
try {
|
|
4556
|
-
return JSON.stringify(await service.call(request.endpoint,
|
|
5381
|
+
return `${JSON.stringify(await service.call(request.endpoint, payload), null, 2)}${landing}`;
|
|
4557
5382
|
} catch (error) {
|
|
4558
|
-
if (!settings.get().reclaimToken || !await tokenRefused(service, error))
|
|
5383
|
+
if (target.home !== null || !settings.get().reclaimToken || !await tokenRefused(service, error))
|
|
4559
5384
|
return failureText(error);
|
|
4560
5385
|
try {
|
|
4561
5386
|
await service.call("panel.reclaimToken", {});
|
|
@@ -4563,7 +5388,7 @@ function registerOne(ctx, service, settings, name2) {
|
|
|
4563
5388
|
return `${failureText(error)} (token reclaim failed: ${messageOf(reclaimError)})`;
|
|
4564
5389
|
}
|
|
4565
5390
|
try {
|
|
4566
|
-
return JSON.stringify(await service.call(request.endpoint,
|
|
5391
|
+
return `${JSON.stringify(await service.call(request.endpoint, payload), null, 2)}${landing}`;
|
|
4567
5392
|
} catch (retryError) {
|
|
4568
5393
|
return failureText(retryError);
|
|
4569
5394
|
}
|
|
@@ -4606,21 +5431,31 @@ function apply(ctx, config) {
|
|
|
4606
5431
|
const resolved = {
|
|
4607
5432
|
stateDir: config?.stateDir,
|
|
4608
5433
|
homeHostedCommand: config?.homeHostedCommand,
|
|
4609
|
-
defaultEntryId: config?.defaultEntryId ?? "dsh"
|
|
5434
|
+
defaultEntryId: config?.defaultEntryId ?? "dsh",
|
|
5435
|
+
instanceRoots: config?.instanceRoots ?? []
|
|
4610
5436
|
};
|
|
4611
5437
|
const stateDir = pluginStateDir(resolved.stateDir);
|
|
4612
5438
|
ensureDir(stateDir, 448);
|
|
4613
|
-
const
|
|
5439
|
+
const panel = resolveHomeHostedHome(stateDir);
|
|
5440
|
+
const settings = new SettingsStore(path15.join(stateDir, "settings.json"), resolved.defaultEntryId);
|
|
4614
5441
|
const service = new HomeHostedService(ctx, {
|
|
4615
|
-
home:
|
|
5442
|
+
home: panel.home,
|
|
5443
|
+
panelHomeSource: panel.source,
|
|
4616
5444
|
stateDir,
|
|
4617
5445
|
homeHostedCommand: resolved.homeHostedCommand,
|
|
4618
5446
|
defaultEntryId: resolved.defaultEntryId,
|
|
5447
|
+
instanceRoots: resolved.instanceRoots,
|
|
4619
5448
|
settings
|
|
4620
5449
|
});
|
|
4621
5450
|
registerRpc(ctx, service);
|
|
4622
5451
|
registerAgentTools(ctx, service, settings);
|
|
5452
|
+
registerInstancesNotice(ctx, {
|
|
5453
|
+
instances: () => service.instancesNow(),
|
|
5454
|
+
enabled: () => settings.get().instancesNotice
|
|
5455
|
+
});
|
|
4623
5456
|
registerAuthNotice(ctx, () => settings.get().authNotice ? service.panelLogUrl() : null);
|
|
5457
|
+
void service.instances().catch(() => {
|
|
5458
|
+
});
|
|
4624
5459
|
ctx.effect(() => {
|
|
4625
5460
|
const timer = setTimeout(() => {
|
|
4626
5461
|
void service.reconcile().catch(() => {
|