dsh-home-hosted 0.2.2 → 0.3.1

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/lib/index.js CHANGED
@@ -40,13 +40,14 @@ var DEFAULT_SETTINGS = {
40
40
  agentTools: { enabled: true, allow: [...AGENT_TOOL_NAMES] },
41
41
  panel: { port: null },
42
42
  authNotice: true,
43
+ reclaimToken: true,
43
44
  uiStyle: "detailed",
44
45
  cli: { prefer: "pinned" }
45
46
  };
46
47
 
47
48
  // src/service.ts
48
49
  import { Service } from "@deepseek-ai/cordis";
49
- import fs12 from "node:fs";
50
+ import fs13 from "node:fs";
50
51
  import path13 from "node:path";
51
52
  import process10 from "node:process";
52
53
  import { fileURLToPath } from "node:url";
@@ -169,6 +170,12 @@ function writeJsonAtomic(file, value, mode) {
169
170
  writeFileAtomic(file, `${JSON.stringify(value, null, 2)}
170
171
  `, mode);
171
172
  }
173
+ function removeFile(file) {
174
+ try {
175
+ fs.rmSync(file, { force: true });
176
+ } catch {
177
+ }
178
+ }
172
179
 
173
180
  // src/boot/common.ts
174
181
  function errorMessage(error) {
@@ -2032,8 +2039,9 @@ function removeEntry(raw, id) {
2032
2039
  }
2033
2040
 
2034
2041
  // src/home-hosted/dsh-entry.ts
2035
- import path8 from "node:path";
2036
- import process5 from "node:process";
2042
+ import fs11 from "node:fs";
2043
+ import path10 from "node:path";
2044
+ import process7 from "node:process";
2037
2045
 
2038
2046
  // src/home-hosted/launch.ts
2039
2047
  import fs8 from "node:fs";
@@ -2101,143 +2109,11 @@ function buildHomeHostedBootSpec(facts, launch, options) {
2101
2109
  };
2102
2110
  }
2103
2111
 
2104
- // src/home-hosted/dsh-entry.ts
2105
- function detectProfile(argv, env = {}) {
2106
- for (let index = 0; index < argv.length; index += 1) {
2107
- const arg = argv[index];
2108
- if (arg === "--profile" && typeof argv[index + 1] === "string" && !argv[index + 1].startsWith("-"))
2109
- return argv[index + 1];
2110
- if (arg?.startsWith("--profile="))
2111
- return arg.slice("--profile=".length);
2112
- }
2113
- const dir = env.DSH_PROFILE_DIR;
2114
- if (typeof dir === "string" && dir.length > 0) {
2115
- const base = path8.basename(dir);
2116
- if (base.length > 0 && base !== "." && base !== path8.sep)
2117
- return base;
2118
- }
2119
- const app = argv.slice(2).find((candidate) => candidate !== void 0 && !candidate.startsWith("-"));
2120
- if (app !== void 0 && app.length > 0)
2121
- return app;
2122
- const fromEnv = env.DSH_PROFILE;
2123
- return typeof fromEnv === "string" && fromEnv.length > 0 ? fromEnv : "web";
2124
- }
2125
- async function resolveDshLaunch() {
2126
- const fromArgv = process5.argv[1];
2127
- if (typeof fromArgv === "string" && /\.[cm]?js$/.test(fromArgv) && path8.isAbsolute(fromArgv))
2128
- return { program: process5.execPath, args: [fromArgv], cliEntry: fromArgv, shimPath: null, source: "entry" };
2129
- const found = await which("dsh");
2130
- return found === null ? null : resolveShimmedCli(found, process5.execPath);
2131
- }
2132
- function buildDshEntry(facts) {
2133
- const launch = facts.launch;
2134
- const entryArgs = launch?.args ?? [];
2135
- const profile = facts.profile === null || facts.profile === void 0 ? "web" : facts.profile;
2136
- const appArgs = [];
2137
- if (facts.port !== null) {
2138
- appArgs.push(
2139
- "--port",
2140
- "{port}",
2141
- "--host",
2142
- "{host}",
2143
- "--no-open",
2144
- "--trusted-host",
2145
- `localhost:{port}`
2146
- );
2147
- if (facts.host === "0.0.0.0")
2148
- appArgs.push("--trusted-host", "{lanIp}:{port}");
2149
- }
2150
- const launcherArgs = profile === "web" ? ["web"] : ["--profile", profile];
2151
- const args = [...entryArgs, ...launcherArgs, ...appArgs];
2152
- return {
2153
- id: facts.id,
2154
- label: "DSH web",
2155
- enabled: true,
2156
- autostart: true,
2157
- command: launch?.program ?? "dsh",
2158
- args,
2159
- bind: facts.host === "0.0.0.0" ? "lan" : "local",
2160
- port: facts.port,
2161
- cwd: process5.cwd(),
2162
- env: {},
2163
- dataEnvs: { DSH_HOME: facts.dshHome },
2164
- onPortConflict: "kill",
2165
- stop: { killPortHolders: true },
2166
- health: {
2167
- enabled: true,
2168
- mode: "http",
2169
- // 401 is normal here: the panel answers the login route when auth is on.
2170
- http: { path: "/", method: "GET", expectStatusBelow: 500, expectBody: "" }
2171
- }
2172
- };
2173
- }
2174
-
2175
- // src/home-hosted/entries.ts
2176
- function defaultOnPortConflict(platform = process.platform) {
2177
- return platform === "win32" ? "kill" : "follow";
2178
- }
2179
- function defaultIntent(id, platform = process.platform) {
2180
- return {
2181
- id,
2182
- autostart: true,
2183
- onPortConflict: defaultOnPortConflict(platform),
2184
- stopKillPortHolders: true,
2185
- persistent: true
2186
- };
2187
- }
2188
- function ownedPatch(intent, live, options = {}) {
2189
- const stop = live?.stop ?? {};
2190
- return {
2191
- autostart: intent.autostart,
2192
- onPortConflict: intent.onPortConflict,
2193
- ...options.persistent === false ? {} : { persistent: intent.persistent },
2194
- stop: { ...stop, killPortHolders: intent.stopKillPortHolders }
2195
- };
2196
- }
2197
- function ownedDrift(live, intent, options = {}) {
2198
- if (live === null)
2199
- return ["missing entry"];
2200
- const drift = [];
2201
- if (live.autostart !== intent.autostart)
2202
- drift.push("autostart");
2203
- if (live.onPortConflict !== intent.onPortConflict)
2204
- drift.push("onPortConflict");
2205
- if (options.persistent !== false && (live.persistent ?? false) !== intent.persistent)
2206
- drift.push("persistent");
2207
- const killPortHolders = live.stop?.killPortHolders;
2208
- if (killPortHolders !== intent.stopKillPortHolders)
2209
- drift.push("stop.killPortHolders");
2210
- return drift;
2211
- }
2212
- function snapshotOwned(live) {
2213
- const killPortHolders = live.stop?.killPortHolders;
2214
- return {
2215
- id: live.id,
2216
- autostart: typeof live.autostart === "boolean" ? live.autostart : false,
2217
- onPortConflict: isOnPortConflict(live.onPortConflict) ? live.onPortConflict : "block",
2218
- persistent: live.persistent === true,
2219
- stop: { killPortHolders: typeof killPortHolders === "boolean" ? killPortHolders : false }
2220
- };
2221
- }
2222
- function restorePatch(live, snapshot) {
2223
- const stop = live.stop ?? {};
2224
- const previous = snapshot?.stop ?? {};
2225
- return {
2226
- autostart: snapshot?.autostart ?? false,
2227
- onPortConflict: snapshot?.onPortConflict ?? "block",
2228
- persistent: snapshot?.persistent === true,
2229
- stop: {
2230
- ...stop,
2231
- killPortHolders: typeof previous.killPortHolders === "boolean" ? previous.killPortHolders : false
2232
- }
2233
- };
2234
- }
2235
-
2236
2112
  // src/home-hosted/resolve.ts
2237
2113
  import { createRequire } from "node:module";
2238
2114
  import fs9 from "node:fs";
2239
- import path9 from "node:path";
2240
- import process6 from "node:process";
2115
+ import path8 from "node:path";
2116
+ import process5 from "node:process";
2241
2117
  var EXPECTED_RANGE = "^0.6.4";
2242
2118
  var MIN_SUPPORTED_VERSION = "0.4.1";
2243
2119
  var MIN_KILL_VERSION = "0.6.0";
@@ -2253,7 +2129,7 @@ function binEntryFromManifest(manifestPath) {
2253
2129
  try {
2254
2130
  const manifest = JSON.parse(fs9.readFileSync(manifestPath, "utf8"));
2255
2131
  const declared = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.["home-hosted"] ?? Object.values(manifest.bin ?? {})[0];
2256
- return typeof declared === "string" && declared.length > 0 ? path9.resolve(path9.dirname(manifestPath), declared) : null;
2132
+ return typeof declared === "string" && declared.length > 0 ? path8.resolve(path8.dirname(manifestPath), declared) : null;
2257
2133
  } catch {
2258
2134
  return null;
2259
2135
  }
@@ -2309,7 +2185,7 @@ async function resolveCli(options = {}) {
2309
2185
  const override = options.override?.trim();
2310
2186
  let unusableOverride = null;
2311
2187
  if (override !== void 0 && override.length > 0) {
2312
- const launch = resolveShimmedCli(path9.resolve(override));
2188
+ const launch = resolveShimmedCli(path8.resolve(override));
2313
2189
  if (launch === null) {
2314
2190
  unusableOverride = override;
2315
2191
  } else {
@@ -2343,13 +2219,13 @@ async function resolveCli(options = {}) {
2343
2219
  }
2344
2220
  dependency = {
2345
2221
  candidate: { source: "dependency", path: dependencyEntry, version: version2 },
2346
- launch: { program: process6.execPath, args: [dependencyEntry], cliEntry: dependencyEntry, shimPath: null, source: "entry" }
2222
+ launch: { program: process5.execPath, args: [dependencyEntry], cliEntry: dependencyEntry, shimPath: null, source: "entry" }
2347
2223
  };
2348
2224
  }
2349
2225
  let global = null;
2350
2226
  const found = await locate("home-hosted") ?? await locate("hh");
2351
2227
  if (found !== null) {
2352
- const launch = resolveShimmedCli(path9.resolve(found));
2228
+ const launch = resolveShimmedCli(path8.resolve(found));
2353
2229
  if (launch !== null) {
2354
2230
  global = {
2355
2231
  candidate: { source: "path", path: launch.cliEntry ?? launch.shimPath ?? launch.program, version: await readVersion(launch) },
@@ -2413,16 +2289,16 @@ async function resolveCli(options = {}) {
2413
2289
 
2414
2290
  // src/home-hosted/launcher.ts
2415
2291
  import fs10 from "node:fs";
2416
- import path10 from "node:path";
2417
- import process7 from "node:process";
2292
+ import path9 from "node:path";
2293
+ import process6 from "node:process";
2418
2294
  function launcherDir(stateDir) {
2419
- return path10.join(stateDir, "bin");
2295
+ return path9.join(stateDir, "bin");
2420
2296
  }
2421
2297
  function launcherPath(stateDir) {
2422
- return path10.join(launcherDir(stateDir), "home-hosted.mjs");
2298
+ return path9.join(launcherDir(stateDir), "home-hosted.mjs");
2423
2299
  }
2424
2300
  function launcherRecordPath(stateDir) {
2425
- return path10.join(launcherDir(stateDir), "resolved.json");
2301
+ return path9.join(launcherDir(stateDir), "resolved.json");
2426
2302
  }
2427
2303
  function buildLauncherSource(options) {
2428
2304
  const marker = options.marker ?? "managed by dsh-home-hosted";
@@ -2547,13 +2423,444 @@ async function preflightLauncher(stateDir, timeoutMs = 1e4) {
2547
2423
  const file = launcherPath(stateDir);
2548
2424
  if (!fs10.existsSync(file))
2549
2425
  return null;
2550
- const result = process7.platform === "win32" ? await run(process7.execPath, [file, "--version"], { timeoutMs }) : await run(file, ["--version"], { timeoutMs });
2426
+ const result = process6.platform === "win32" ? await run(process6.execPath, [file, "--version"], { timeoutMs }) : await run(file, ["--version"], { timeoutMs });
2551
2427
  return parseVersion(result.stdout) ?? parseVersion(result.stderr);
2552
2428
  }
2429
+ function dshLauncherRecordPath(stateDir) {
2430
+ return path9.join(launcherDir(stateDir), "dsh-resolved.json");
2431
+ }
2432
+ function dshLauncherPath(stateDir, entryExtension) {
2433
+ const extension = (entryExtension ?? "").toLowerCase() === ".cjs" ? ".cjs" : ".mjs";
2434
+ return path9.join(launcherDir(stateDir), `dsh${extension}`);
2435
+ }
2436
+ function readDshLauncherRecord(stateDir) {
2437
+ return readJson(dshLauncherRecordPath(stateDir));
2438
+ }
2439
+ function buildDshLauncherSource(options) {
2440
+ const esm = (options.entryExtension ?? "").toLowerCase() !== ".cjs";
2441
+ const prelude = esm ? `import fs from 'node:fs'
2442
+ import path from 'node:path'
2443
+ import process from 'node:process'
2444
+ import { spawnSync } from 'node:child_process'` : `const fs = require('node:fs')
2445
+ const path = require('node:path')
2446
+ const process = require('node:process')
2447
+ const { spawnSync } = require('node:child_process')`;
2448
+ const roots = [options.resolvedEntry === null ? null : dshSearchRoot(options.resolvedEntry), ...options.searchRoots ?? []].filter((root) => typeof root === "string" && root.length > 0);
2449
+ return `#!/usr/bin/env node
2450
+ // managed by dsh-home-hosted \u2014 rewritten on every plugin start; do not edit.
2451
+ ${prelude}
2452
+
2453
+ const DSH_HOME = ${JSON.stringify(options.dshHome)}
2454
+ const RECORD = ${JSON.stringify(dshLauncherRecordPath(options.stateDir))}
2455
+ const SEARCH_ROOTS = ${JSON.stringify([...new Set(roots)])}
2456
+
2457
+ function readRecord() {
2458
+ try { return JSON.parse(fs.readFileSync(RECORD, 'utf8')) } catch { return null }
2459
+ }
2460
+
2461
+ function isEntry(file) {
2462
+ let stat = null
2463
+ try { stat = fs.statSync(file) } catch { return false }
2464
+ return stat.isFile() && /\\.(?:mjs|cjs|js)$/.test(file)
2465
+ }
2466
+
2467
+ function collect(out, entry) {
2468
+ if (typeof entry === 'string' && isEntry(entry) && !out.includes(entry)) out.push(entry)
2469
+ }
2470
+
2471
+ function collectStore(root, out) {
2472
+ const store = path.join(root, 'node_modules', '.pnpm')
2473
+ let names = []
2474
+ try { names = fs.readdirSync(store) } catch { return }
2475
+ for (const name of names) {
2476
+ if (!name.startsWith('dsh@') && !name.startsWith('@deepseek-ai+dsh@')) continue
2477
+ collect(out, path.join(store, name, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'))
2478
+ collect(out, path.join(store, name, 'node_modules', 'dsh', 'bin', 'dsh.js'))
2479
+ }
2480
+ }
2481
+
2482
+ function collectRoot(root, out) {
2483
+ collect(out, path.join(root, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'))
2484
+ collect(out, path.join(root, 'node_modules', 'dsh', 'bin', 'dsh.js'))
2485
+ collectStore(root, out)
2486
+ }
2487
+
2488
+ function candidates() {
2489
+ const out = []
2490
+ const record = readRecord()
2491
+ if (record && typeof record.entry === 'string') collect(out, record.entry)
2492
+ for (const root of [...(Array.isArray(record?.roots) ? record.roots : []), ...SEARCH_ROOTS]) collectRoot(root, out)
2493
+ collectRoot(DSH_HOME, out)
2494
+ let profiles = []
2495
+ try { profiles = fs.readdirSync(path.join(DSH_HOME, 'profiles')) } catch {}
2496
+ for (const name of profiles) collectRoot(path.join(DSH_HOME, 'profiles', name), out)
2497
+ // Last resort: a dsh on the entry's own PATH.
2498
+ for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
2499
+ collect(out, path.join(dir, 'dsh'))
2500
+ collect(out, path.join(dir, 'dsh.cmd'))
2501
+ }
2502
+ return out
2503
+ }
2504
+
2505
+ function versionOf(entry) {
2506
+ let dir = path.dirname(entry)
2507
+ for (let hop = 0; hop < 4; hop += 1) {
2508
+ try {
2509
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
2510
+ if (typeof manifest.version === 'string') return manifest.version
2511
+ } catch {}
2512
+ dir = path.dirname(dir)
2513
+ }
2514
+ return null
2515
+ }
2516
+
2517
+ function compare(a, b) {
2518
+ const split = value => {
2519
+ const [core, pre = null] = String(value).split('-', 2)
2520
+ return { parts: core.split('.').map(part => Number.parseInt(part, 10) || 0), pre }
2521
+ }
2522
+ const left = split(a); const right = split(b)
2523
+ for (let i = 0; i < 3; i += 1) {
2524
+ const diff = (left.parts[i] ?? 0) - (right.parts[i] ?? 0)
2525
+ if (diff !== 0) return diff < 0 ? -1 : 1
2526
+ }
2527
+ if (left.pre === right.pre) return 0
2528
+ if (left.pre === null) return 1
2529
+ if (right.pre === null) return -1
2530
+ return left.pre < right.pre ? -1 : 1
2531
+ }
2532
+
2533
+ // A recorded path that still exists is usually the copy to boot, but never the
2534
+ // older one: a rebuild or upgrade that landed elsewhere must win on version.
2535
+ function choose() {
2536
+ const recorded = readRecord()?.entry ?? null
2537
+ const found = candidates().map(entry => ({ entry, recorded: entry === recorded, version: versionOf(entry) }))
2538
+ const known = found.filter(item => item.version !== null)
2539
+ if (known.length > 0) {
2540
+ known.sort((a, b) => (compare(b.version, a.version) || (Number(b.recorded) - Number(a.recorded))))
2541
+ return known[0].entry
2542
+ }
2543
+ return (found.find(item => item.recorded) ?? found[0])?.entry ?? null
2544
+ }
2545
+
2546
+ const entry = choose()
2547
+ if (entry === null) {
2548
+ 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')
2549
+ process.exit(1)
2550
+ }
2551
+
2552
+ const args = process.argv.slice(2)
2553
+ const result = /\\.(mjs|cjs|js)$/.test(entry)
2554
+ ? spawnSync(process.execPath, [entry, ...args], { stdio: 'inherit' })
2555
+ : spawnSync(entry, args, { stdio: 'inherit', shell: process.platform === 'win32' && /\\.(cmd|bat)$/i.test(entry) })
2556
+ process.exit(typeof result.status === 'number' ? result.status : 1)
2557
+ `;
2558
+ }
2559
+ function writeDshLauncher(options) {
2560
+ const file = dshLauncherPath(options.stateDir, options.entryExtension);
2561
+ const source = buildDshLauncherSource(options);
2562
+ const previous = fs10.existsSync(file) ? fs10.readFileSync(file, "utf8") : null;
2563
+ const changed = previous !== source;
2564
+ if (changed) {
2565
+ writeFileAtomic(file, source, 493);
2566
+ fs10.chmodSync(file, 493);
2567
+ }
2568
+ const raw = [dshSearchRoot(options.resolvedEntry), ...options.searchRoots ?? [], ...readDshLauncherRecord(options.stateDir)?.roots ?? []];
2569
+ const known = raw.filter((root) => typeof root === "string" && root.length > 0 && dshRootUsable(root));
2570
+ const roots = [...new Set(known)];
2571
+ writeJsonAtomic(dshLauncherRecordPath(options.stateDir), {
2572
+ entry: options.resolvedEntry,
2573
+ roots,
2574
+ writtenAt: Date.now()
2575
+ }, 384);
2576
+ return { path: file, changed };
2577
+ }
2578
+ function dshCandidatesUnder(root) {
2579
+ const out = [];
2580
+ for (const entry of [
2581
+ path9.join(root, "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"),
2582
+ path9.join(root, "node_modules", "dsh", "bin", "dsh.js")
2583
+ ]) {
2584
+ if (fs10.existsSync(entry))
2585
+ out.push(entry);
2586
+ }
2587
+ const store = path9.join(root, "node_modules", ".pnpm");
2588
+ if (fs10.existsSync(store)) {
2589
+ for (const name2 of fs10.readdirSync(store)) {
2590
+ if (!name2.startsWith("dsh@") && !name2.startsWith("@deepseek-ai+dsh@"))
2591
+ continue;
2592
+ for (const entry of [
2593
+ path9.join(store, name2, "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"),
2594
+ path9.join(store, name2, "node_modules", "dsh", "bin", "dsh.js")
2595
+ ]) {
2596
+ if (fs10.existsSync(entry))
2597
+ out.push(entry);
2598
+ }
2599
+ }
2600
+ }
2601
+ return out;
2602
+ }
2603
+ function dshSearchRoot(entry) {
2604
+ if (typeof entry !== "string" || entry.length === 0)
2605
+ return null;
2606
+ const parts = entry.split(path9.sep);
2607
+ const store = parts.indexOf(".pnpm");
2608
+ const modules = store > 0 ? store - 1 : parts.lastIndexOf("node_modules");
2609
+ return modules > 0 ? parts.slice(0, modules).join(path9.sep) : null;
2610
+ }
2611
+ function dshRootUsable(root) {
2612
+ return dshCandidatesUnder(root).length > 0 || fs10.existsSync(path9.join(root, "node_modules", ".pnpm"));
2613
+ }
2614
+
2615
+ // src/home-hosted/dsh-entry.ts
2616
+ function detectProfile(argv, env = {}) {
2617
+ for (let index = 0; index < argv.length; index += 1) {
2618
+ const arg = argv[index];
2619
+ if (arg === "--profile" && typeof argv[index + 1] === "string" && !argv[index + 1].startsWith("-"))
2620
+ return argv[index + 1];
2621
+ if (arg?.startsWith("--profile="))
2622
+ return arg.slice("--profile=".length);
2623
+ }
2624
+ const dir = env.DSH_PROFILE_DIR;
2625
+ if (typeof dir === "string" && dir.length > 0) {
2626
+ const base = path10.basename(dir);
2627
+ if (base.length > 0 && base !== "." && base !== path10.sep)
2628
+ return base;
2629
+ }
2630
+ const app = argv.slice(2).find((candidate) => candidate !== void 0 && !candidate.startsWith("-"));
2631
+ if (app !== void 0 && app.length > 0)
2632
+ return app;
2633
+ const fromEnv = env.DSH_PROFILE;
2634
+ return typeof fromEnv === "string" && fromEnv.length > 0 ? fromEnv : "web";
2635
+ }
2636
+ function versionOf(entry) {
2637
+ let dir = path10.dirname(entry);
2638
+ for (let hop = 0; hop < 4; hop += 1) {
2639
+ const manifest = readJson(path10.join(dir, "package.json"));
2640
+ if (typeof manifest?.version === "string")
2641
+ return manifest.version;
2642
+ dir = path10.dirname(dir);
2643
+ }
2644
+ return null;
2645
+ }
2646
+ function isEntry(file) {
2647
+ return /\.[cm]?js$/.test(file) && fs11.existsSync(file) && !fs11.statSync(file).isDirectory();
2648
+ }
2649
+ function realPath(file) {
2650
+ try {
2651
+ return fs11.realpathSync(file);
2652
+ } catch {
2653
+ return file;
2654
+ }
2655
+ }
2656
+ function cloneEntryFrom(argv1) {
2657
+ if (!isEntry(argv1))
2658
+ return null;
2659
+ const root = path10.dirname(path10.dirname(argv1));
2660
+ for (const candidate of [
2661
+ path10.join(root, "lib", "bin.js"),
2662
+ path10.join(root, "lib", "bin.mjs"),
2663
+ path10.join(root, "lib", "bin.cjs"),
2664
+ path10.join(root, "dist", "bin.js"),
2665
+ path10.join(root, "bin", "dsh.js"),
2666
+ path10.join(root, "bin", "dsh.mjs")
2667
+ ]) {
2668
+ if (isEntry(candidate))
2669
+ return candidate;
2670
+ }
2671
+ return argv1;
2672
+ }
2673
+ function launchedEntry(argv1) {
2674
+ const real = realPath(argv1);
2675
+ const direct = cloneEntryFrom(real);
2676
+ if (direct !== null)
2677
+ return direct;
2678
+ const shimmed = resolveShimmedCli(real, process7.execPath);
2679
+ return shimmed?.cliEntry == null ? null : cloneEntryFrom(shimmed.cliEntry);
2680
+ }
2681
+ function findDshEntry(dshHome2) {
2682
+ const found = [];
2683
+ found.push(...dshCandidatesUnder(dshHome2));
2684
+ for (const name2 of readdirNames(path10.join(dshHome2, "profiles")))
2685
+ found.push(...dshCandidatesUnder(path10.join(dshHome2, "profiles", name2)));
2686
+ const withVersion = found.map((entry) => ({ entry, version: versionOf(entry) }));
2687
+ withVersion.sort((a, b) => compareVersions(b.version ?? "0.0.0", a.version ?? "0.0.0"));
2688
+ return withVersion[0]?.entry ?? null;
2689
+ }
2690
+ function readdirNames(dir) {
2691
+ try {
2692
+ return fs11.readdirSync(dir);
2693
+ } catch {
2694
+ return [];
2695
+ }
2696
+ }
2697
+ async function resolveDshLaunch(options = {}) {
2698
+ const argv1 = options.argv1 === void 0 ? process7.argv[1] : options.argv1;
2699
+ const dshHome2 = options.dshHome ?? dshHome();
2700
+ const stateDir = options.stateDir ?? pluginStateDir();
2701
+ const local = typeof argv1 === "string" && argv1.length > 0 ? launchedEntry(argv1) : null;
2702
+ if (local !== null && realPath(local) !== realPath(dshLauncherPath(stateDir, path10.extname(local)))) {
2703
+ const launcher = writeDshLauncher({
2704
+ stateDir,
2705
+ dshHome: dshHome2,
2706
+ resolvedEntry: local,
2707
+ entryExtension: path10.extname(local),
2708
+ // Where this install sits, so a rebuilt/moved copy is found next boot.
2709
+ searchRoots: [dshSearchRoot(local)].filter((root) => root !== null)
2710
+ });
2711
+ return { program: process7.execPath, args: [local], cliEntry: local, shimPath: null, source: "entry", launcherPath: launcher.path };
2712
+ }
2713
+ const installed = findDshEntry(dshHome2);
2714
+ if (installed !== null) {
2715
+ const launcher = writeDshLauncher({ stateDir, dshHome: dshHome2, resolvedEntry: installed, entryExtension: path10.extname(installed) });
2716
+ return { program: process7.execPath, args: [installed], cliEntry: installed, shimPath: null, source: "entry", launcherPath: launcher.path };
2717
+ }
2718
+ const found = await (options.findOnPath ?? which)("dsh");
2719
+ if (found === null)
2720
+ return null;
2721
+ const launch = resolveShimmedCli(found, process7.execPath);
2722
+ return launch === null ? null : { ...launch, launcherPath: null };
2723
+ }
2724
+ function buildCommand(launch, launcherPath2) {
2725
+ if (launcherPath2 !== null && launcherPath2.length > 0)
2726
+ return { command: process7.execPath, entryArgs: [launcherPath2] };
2727
+ return { command: launch.program, entryArgs: launch.args };
2728
+ }
2729
+ function entryScript(config) {
2730
+ const program = config.command;
2731
+ if (typeof program !== "string" || program.length === 0)
2732
+ return null;
2733
+ if (!path10.isAbsolute(program))
2734
+ return program;
2735
+ const rest = config.args ?? [];
2736
+ return rest.find((arg) => path10.isAbsolute(arg)) ?? program;
2737
+ }
2738
+ function needsLauncherRepair(config) {
2739
+ const program = config.command;
2740
+ if (typeof program !== "string" || program.length === 0)
2741
+ return false;
2742
+ if (path10.isAbsolute(program))
2743
+ return true;
2744
+ return (config.args ?? []).some((arg, index) => index === 0 && path10.isAbsolute(arg));
2745
+ }
2746
+ function launcherRepair(config, launch) {
2747
+ if (launch?.launcherPath == null || launch.launcherPath.length === 0)
2748
+ return null;
2749
+ const launcher = launch.launcherPath;
2750
+ const stored = entryScript(config);
2751
+ if (stored === null || stored === launcher)
2752
+ return null;
2753
+ if (stored !== launch.cliEntry)
2754
+ return null;
2755
+ const rest = (config.args ?? []).filter((arg, index) => !(index === 0 && path10.isAbsolute(arg)));
2756
+ return { command: process7.execPath, args: [launcher, ...rest] };
2757
+ }
2758
+ function buildDshEntry(facts) {
2759
+ const launch = facts.launch;
2760
+ const profile = facts.profile === null || facts.profile === void 0 ? "web" : facts.profile;
2761
+ const appArgs = [];
2762
+ if (facts.port !== null) {
2763
+ appArgs.push(
2764
+ "--port",
2765
+ "{port}",
2766
+ "--host",
2767
+ "{host}",
2768
+ "--no-open",
2769
+ "--trusted-host",
2770
+ `localhost:{port}`
2771
+ );
2772
+ if (facts.host === "0.0.0.0")
2773
+ appArgs.push("--trusted-host", "{lanIp}:{port}");
2774
+ }
2775
+ const launcherArgs = profile === "web" ? ["web"] : ["--profile", profile];
2776
+ const generated = launch === null ? { command: "dsh", entryArgs: [] } : buildCommand(launch, facts.launcherPath ?? null);
2777
+ return {
2778
+ id: facts.id,
2779
+ label: "DSH web",
2780
+ enabled: true,
2781
+ autostart: true,
2782
+ command: generated.command,
2783
+ args: [...generated.entryArgs, ...launcherArgs, ...appArgs],
2784
+ bind: facts.host === "0.0.0.0" ? "lan" : "local",
2785
+ port: facts.port,
2786
+ cwd: process7.cwd(),
2787
+ env: {},
2788
+ dataEnvs: { DSH_HOME: facts.dshHome },
2789
+ onPortConflict: "kill",
2790
+ stop: { killPortHolders: true },
2791
+ health: {
2792
+ enabled: true,
2793
+ mode: "http",
2794
+ // 401 is normal here: the panel answers the login route when auth is on.
2795
+ http: { path: "/", method: "GET", expectStatusBelow: 500, expectBody: "" }
2796
+ }
2797
+ };
2798
+ }
2799
+
2800
+ // src/home-hosted/entries.ts
2801
+ function defaultOnPortConflict(platform = process.platform) {
2802
+ return platform === "win32" ? "kill" : "follow";
2803
+ }
2804
+ function defaultIntent(id, platform = process.platform) {
2805
+ return {
2806
+ id,
2807
+ autostart: true,
2808
+ onPortConflict: defaultOnPortConflict(platform),
2809
+ stopKillPortHolders: true,
2810
+ persistent: true
2811
+ };
2812
+ }
2813
+ function ownedPatch(intent, live, options = {}) {
2814
+ const stop = live?.stop ?? {};
2815
+ return {
2816
+ autostart: intent.autostart,
2817
+ onPortConflict: intent.onPortConflict,
2818
+ ...options.persistent === false ? {} : { persistent: intent.persistent },
2819
+ stop: { ...stop, killPortHolders: intent.stopKillPortHolders }
2820
+ };
2821
+ }
2822
+ function ownedDrift(live, intent, options = {}) {
2823
+ if (live === null)
2824
+ return ["missing entry"];
2825
+ const drift = [];
2826
+ if (live.autostart !== intent.autostart)
2827
+ drift.push("autostart");
2828
+ if (live.onPortConflict !== intent.onPortConflict)
2829
+ drift.push("onPortConflict");
2830
+ if (options.persistent !== false && (live.persistent ?? false) !== intent.persistent)
2831
+ drift.push("persistent");
2832
+ const killPortHolders = live.stop?.killPortHolders;
2833
+ if (killPortHolders !== intent.stopKillPortHolders)
2834
+ drift.push("stop.killPortHolders");
2835
+ return drift;
2836
+ }
2837
+ function snapshotOwned(live) {
2838
+ const killPortHolders = live.stop?.killPortHolders;
2839
+ return {
2840
+ id: live.id,
2841
+ autostart: typeof live.autostart === "boolean" ? live.autostart : false,
2842
+ onPortConflict: isOnPortConflict(live.onPortConflict) ? live.onPortConflict : "block",
2843
+ persistent: live.persistent === true,
2844
+ stop: { killPortHolders: typeof killPortHolders === "boolean" ? killPortHolders : false }
2845
+ };
2846
+ }
2847
+ function restorePatch(live, snapshot) {
2848
+ const stop = live.stop ?? {};
2849
+ const previous = snapshot?.stop ?? {};
2850
+ return {
2851
+ autostart: snapshot?.autostart ?? false,
2852
+ onPortConflict: snapshot?.onPortConflict ?? "block",
2853
+ persistent: snapshot?.persistent === true,
2854
+ stop: {
2855
+ ...stop,
2856
+ killPortHolders: typeof previous.killPortHolders === "boolean" ? previous.killPortHolders : false
2857
+ }
2858
+ };
2859
+ }
2553
2860
 
2554
2861
  // src/home-hosted/panel-control.ts
2555
2862
  import { spawn as spawn2 } from "node:child_process";
2556
- import fs11 from "node:fs";
2863
+ import fs12 from "node:fs";
2557
2864
  import path11 from "node:path";
2558
2865
  import process8 from "node:process";
2559
2866
  function cliArgs(deps, command) {
@@ -2642,7 +2949,7 @@ log('started with exit ' + String(result.status))
2642
2949
  function writeTakeoverHelper(deps, oldPid) {
2643
2950
  const file = takeoverHelperPath(deps.stateDir);
2644
2951
  writeFileAtomic(file, buildTakeoverSource(deps, oldPid), 493);
2645
- fs11.chmodSync(file, 493);
2952
+ fs12.chmodSync(file, 493);
2646
2953
  return file;
2647
2954
  }
2648
2955
  function spawnDetached(program, args) {
@@ -2760,14 +3067,19 @@ var PanelClient = class {
2760
3067
  return await this.request("POST", `/api/servers/${encodeURIComponent(id)}/free-port`);
2761
3068
  }
2762
3069
  };
2763
- async function verifyToken(baseUrl, token, timeoutMs = 5e3) {
3070
+ async function probeToken(baseUrl, token, timeoutMs = 5e3) {
2764
3071
  try {
2765
3072
  await new PanelClient({ baseUrl, token, timeoutMs }).listServers();
2766
- return true;
2767
- } catch {
2768
- return false;
3073
+ return "ok";
3074
+ } catch (error) {
3075
+ if (error instanceof PanelError && (error.code === "AUTH_REQUIRED" || error.status === 401 || error.status === 403))
3076
+ return "refused";
3077
+ return "unreachable";
2769
3078
  }
2770
3079
  }
3080
+ async function verifyToken(baseUrl, token, timeoutMs = 5e3) {
3081
+ return await probeToken(baseUrl, token, timeoutMs) === "ok";
3082
+ }
2771
3083
 
2772
3084
  // src/home-hosted/runtime.ts
2773
3085
  import http from "node:http";
@@ -2863,18 +3175,35 @@ function apiTokenEnrolled(home) {
2863
3175
  const secrets = readJson(secretsFile(home));
2864
3176
  return secrets !== null && secrets.apiToken !== null && secrets.apiToken !== void 0;
2865
3177
  }
2866
- var inFlight = /* @__PURE__ */ new Map();
3178
+ var queue = /* @__PURE__ */ new Map();
3179
+ function serialised(stateDir, work) {
3180
+ const key = path12.resolve(stateDir);
3181
+ const previous = queue.get(key) ?? Promise.resolve();
3182
+ const next = previous.then(work, work);
3183
+ const settled = next.then(() => void 0, () => void 0);
3184
+ queue.set(key, settled);
3185
+ void settled.then(() => {
3186
+ if (queue.get(key) === settled) queue.delete(key);
3187
+ });
3188
+ return next;
3189
+ }
2867
3190
  async function ensureToken(options) {
2868
- const key = path12.resolve(options.stateDir);
2869
- const pending = inFlight.get(key);
2870
- if (pending !== void 0)
2871
- return await pending;
2872
- const attempt = enrollToken(options);
2873
- inFlight.set(key, attempt);
3191
+ return await serialised(options.stateDir, async () => await enrollToken(options));
3192
+ }
3193
+ async function reclaimToken(options) {
3194
+ return await serialised(options.stateDir, async () => await enrollFreshToken(options));
3195
+ }
3196
+ async function runCli2(exec, args, env) {
2874
3197
  try {
2875
- return await attempt;
2876
- } finally {
2877
- inFlight.delete(key);
3198
+ const result = await exec(args, env);
3199
+ if (result.code !== 0) {
3200
+ return {
3201
+ failure: result.error?.trim() || result.stderr.trim() || result.stdout.trim() || `exit ${String(result.code)}`
3202
+ };
3203
+ }
3204
+ return { result };
3205
+ } catch (error) {
3206
+ return { failure: error instanceof Error ? error.message : String(error) };
2878
3207
  }
2879
3208
  }
2880
3209
  async function enrollToken(options) {
@@ -2889,20 +3218,41 @@ async function enrollToken(options) {
2889
3218
  };
2890
3219
  }
2891
3220
  const token = generateToken();
2892
- const result = await options.exec(["--home", options.home, "set-token"], { HHOSTED_TOKEN: token });
2893
- if (result.code !== 0) {
3221
+ const enrolled = await runCli2(options.exec, ["--home", options.home, "set-token"], { HHOSTED_TOKEN: token });
3222
+ if (enrolled.result === void 0) {
2894
3223
  return {
2895
3224
  token: null,
2896
3225
  enrolled: false,
2897
- detail: `could not enrol an API token: ${result.stderr.trim() || result.stdout.trim() || `exit ${String(result.code)}`}`
3226
+ detail: `could not enrol an API token: ${enrolled.failure ?? "the CLI did not answer"}`
2898
3227
  };
2899
3228
  }
2900
3229
  storeToken(options.stateDir, token);
2901
3230
  return { token, enrolled: true, detail: "enrolled a new panel API token" };
2902
3231
  }
3232
+ async function enrollFreshToken(options) {
3233
+ const cleared = await runCli2(options.exec, ["--home", options.home, "set-token", "--clear"], {});
3234
+ if (cleared.result === void 0) {
3235
+ return {
3236
+ token: null,
3237
+ detail: `could not clear the old API token: ${cleared.failure ?? "the CLI did not answer"}`
3238
+ };
3239
+ }
3240
+ const token = generateToken();
3241
+ const enrolled = await runCli2(options.exec, ["--home", options.home, "set-token"], { HHOSTED_TOKEN: token });
3242
+ if (enrolled.result === void 0) {
3243
+ removeFile(storedTokenPath(options.stateDir));
3244
+ return {
3245
+ token: null,
3246
+ detail: `the old API token was cleared, but a new one could not be enrolled: ${enrolled.failure ?? "the CLI did not answer"}; retrying is safe`
3247
+ };
3248
+ }
3249
+ storeToken(options.stateDir, token);
3250
+ return { token, detail: "enrolled a fresh panel API token" };
3251
+ }
2903
3252
 
2904
3253
  // src/service.ts
2905
3254
  var ENTRY_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
3255
+ var TOKEN_PROBE_TIMEOUT_MS = 3e3;
2906
3256
  function pluginRoot() {
2907
3257
  try {
2908
3258
  return path13.dirname(path13.dirname(fileURLToPath(import.meta.url)));
@@ -2924,8 +3274,16 @@ var HomeHostedService = class extends Service {
2924
3274
  this.snapshotsFile = path13.join(options.stateDir, "snapshots.json");
2925
3275
  }
2926
3276
  clientCache = null;
3277
+ /** The last token this service proved against a panel, so a poll does not re-probe it. */
3278
+ tokenProof = null;
2927
3279
  tokenDetail = "";
2928
3280
  snapshotsFile;
3281
+ async resolveDsh(dshHome2) {
3282
+ return await (this.options.resolveDsh ?? ((options) => resolveDshLaunch(options)))({
3283
+ dshHome: dshHome2,
3284
+ stateDir: this.options.stateDir
3285
+ });
3286
+ }
2929
3287
  // -------------------------------------------------------------------------
2930
3288
  // Facts
2931
3289
  // -------------------------------------------------------------------------
@@ -3019,14 +3377,40 @@ var HomeHostedService = class extends Service {
3019
3377
  throw new HomeHostedError(read.error, "CONFIG_UNREADABLE");
3020
3378
  writeConfig(this.options.home, patchControl(read.raw ?? {}, { port }), this.writtenBy());
3021
3379
  }
3380
+ /** Whether this exact token was already proved against this exact panel. */
3381
+ tokenProven(url, token) {
3382
+ return this.tokenProof !== null && this.tokenProof.until > Date.now() && this.tokenProof.url === url && this.tokenProof.token === token;
3383
+ }
3384
+ proveToken(url, token) {
3385
+ this.tokenProof = { url, token, until: Date.now() + 3e4 };
3386
+ }
3022
3387
  async panelStatus() {
3023
3388
  const runtime = this.runtime();
3024
3389
  const stored = readStoredToken(this.options.stateDir);
3025
3390
  const enrolledOnDisk = apiTokenEnrolled(this.options.home);
3026
3391
  const reachable = runtime !== null && (pidAlive(runtime.pid) || await probePanel(runtime.url));
3027
3392
  const answered = runtime !== null && await probePanel(runtime.url);
3028
- const token = stored !== null ? "enrolled" : enrolledOnDisk ? "present" : "absent";
3029
- const writeVia = answered && stored !== null ? "api" : "file";
3393
+ let token = "unknown";
3394
+ let tokenVerified = false;
3395
+ if (answered) {
3396
+ if (stored === null) {
3397
+ token = enrolledOnDisk ? "present" : "absent";
3398
+ } else if (this.tokenProven(runtime.url, stored)) {
3399
+ token = "enrolled";
3400
+ tokenVerified = true;
3401
+ } else {
3402
+ const probe = await probeToken(runtime.url, stored, TOKEN_PROBE_TIMEOUT_MS);
3403
+ if (probe === "ok") {
3404
+ token = "enrolled";
3405
+ tokenVerified = true;
3406
+ this.proveToken(runtime.url, stored);
3407
+ } else if (probe === "refused") {
3408
+ token = "stale";
3409
+ tokenVerified = true;
3410
+ }
3411
+ }
3412
+ }
3413
+ const writeVia = token === "enrolled" ? "api" : "file";
3030
3414
  return {
3031
3415
  home: this.options.home,
3032
3416
  reachable: answered,
@@ -3036,9 +3420,47 @@ var HomeHostedService = class extends Service {
3036
3420
  pid: runtime?.pid ?? null,
3037
3421
  writeVia,
3038
3422
  token,
3039
- detail: answered ? stored !== null ? this.tokenDetail || "the panel is answering and this plugin holds a token" : enrolledOnDisk ? "the panel is answering, but home-hosted already holds an API token this plugin does not have" : "the panel is answering; a token will be enrolled on the first write" : runtime === null ? "no run.json: the panel is not running, so entries are written straight to servers.config.json" : reachable ? "the panel process is alive but is not answering" : "the panel is not running"
3423
+ tokenVerified,
3424
+ detail: answered ? token === "enrolled" ? this.tokenDetail || "the panel is answering and this plugin holds a token" : token === "stale" ? "the panel refused this plugin's API token, so writes go straight to servers.config.json; regenerate the token" : token === "present" ? "the panel is answering, but home-hosted already holds an API token this plugin does not have" : token === "absent" ? "the panel is answering; a token will be enrolled on the first write" : "the panel answered, but the plugin's token could not be checked" : runtime === null ? "no run.json: the panel is not running, so entries are written straight to servers.config.json" : reachable ? "the panel process is alive but is not answering" : "the panel is not running"
3040
3425
  };
3041
3426
  }
3427
+ /**
3428
+ * Replace the panel's API token with a fresh one and prove it.
3429
+ *
3430
+ * home-hosted keeps only a hash, so a token this plugin does not hold cannot
3431
+ * be recovered: the old hash is cleared, a new token is enrolled through the
3432
+ * CLI, and it is proved against the answering panel before being reported.
3433
+ * Nothing is written when the CLI cannot run, and a panel that is not
3434
+ * answering is refused up front rather than left with an unverifiable token.
3435
+ */
3436
+ async reclaimPanelToken() {
3437
+ const runtime = this.runtime();
3438
+ if (runtime === null || !await probePanel(runtime.url)) {
3439
+ throw new HomeHostedError(
3440
+ "the panel is not answering, so a regenerated token could not be verified; start the panel first",
3441
+ "PANEL_UNAVAILABLE"
3442
+ );
3443
+ }
3444
+ const result = await reclaimToken({
3445
+ home: this.options.home,
3446
+ stateDir: this.options.stateDir,
3447
+ exec: (args, env) => this.cliExec(args, env)
3448
+ });
3449
+ this.clientCache = null;
3450
+ this.tokenProof = null;
3451
+ this.tokenDetail = result.detail;
3452
+ if (result.token === null)
3453
+ throw new HomeHostedError(result.detail, "TOKEN_RECLAIM_FAILED");
3454
+ const probe = await probeToken(runtime.url, result.token, TOKEN_PROBE_TIMEOUT_MS);
3455
+ if (probe !== "ok") {
3456
+ throw new HomeHostedError(
3457
+ probe === "refused" ? "a fresh token was enrolled, but the panel refused it; check the panel log before trying again" : "a fresh token was enrolled, but the panel stopped answering before it could be verified",
3458
+ "TOKEN_RECLAIM_UNVERIFIED"
3459
+ );
3460
+ }
3461
+ this.proveToken(runtime.url, result.token);
3462
+ return await this.status();
3463
+ }
3042
3464
  async tryClient() {
3043
3465
  if (this.clientCache !== null && this.clientCache.until > Date.now())
3044
3466
  return this.clientCache.client;
@@ -3057,6 +3479,7 @@ var HomeHostedService = class extends Service {
3057
3479
  return null;
3058
3480
  const client = new PanelClient({ baseUrl: runtime.url, token: ensured.token });
3059
3481
  this.clientCache = { client, until: Date.now() + 3e4 };
3482
+ this.proveToken(runtime.url, ensured.token);
3060
3483
  return client;
3061
3484
  }
3062
3485
  async requireClient() {
@@ -3095,20 +3518,35 @@ var HomeHostedService = class extends Service {
3095
3518
  map.set(entry.id, { view: { id: entry.id, status: "unknown", pid: null, url: null, config: entry }, config: entry });
3096
3519
  return map;
3097
3520
  }
3098
- async createEntry(intent, patch, live) {
3521
+ async createEntry(intent, patch) {
3099
3522
  if (intent.id !== this.options.defaultEntryId)
3100
3523
  return null;
3101
- const dsh = await resolveDshLaunch();
3524
+ const harnessHome = process10.env.DSH_HOME ?? dshHome();
3525
+ const dsh = await this.resolveDsh(harnessHome);
3102
3526
  const { port, host } = this.webServer();
3103
3527
  const generated = buildDshEntry({
3104
3528
  id: intent.id,
3105
3529
  port,
3106
3530
  host,
3107
3531
  profile: detectProfile(process10.argv, process10.env),
3108
- dshHome: process10.env.DSH_HOME ?? dshHome(),
3109
- launch: dsh
3532
+ dshHome: harnessHome,
3533
+ launch: dsh,
3534
+ launcherPath: dsh?.launcherPath ?? null
3110
3535
  });
3111
- return { ...live ?? generated, ...generated, ...patch };
3536
+ return { ...generated, ...patch };
3537
+ }
3538
+ /**
3539
+ * An entry written before the launcher existed still runs a clone's build
3540
+ * output directly. Point it at the stable launcher, or the fix would only
3541
+ * ever apply to freshly created entries — the plugin never rewrites an
3542
+ * existing entry's command.
3543
+ */
3544
+ async dshCommandRepair(live) {
3545
+ if (live === null || !needsLauncherRepair(live))
3546
+ return null;
3547
+ const harnessHome = process10.env.DSH_HOME ?? dshHome();
3548
+ const dsh = await this.resolveDsh(harnessHome);
3549
+ return launcherRepair(live, dsh);
3112
3550
  }
3113
3551
  async writeOwned(intent) {
3114
3552
  const live = (await this.liveEntries()).get(intent.id) ?? null;
@@ -3118,13 +3556,14 @@ var HomeHostedService = class extends Service {
3118
3556
  this.saveSnapshots(snapshots);
3119
3557
  }
3120
3558
  const patch = ownedPatch(intent, live?.config ?? null, { persistent: await this.supportsPersistent() });
3559
+ const repair = intent.id === this.options.defaultEntryId ? await this.dshCommandRepair(live?.config ?? null) : null;
3121
3560
  const client = await this.tryClient();
3122
3561
  if (client !== null) {
3123
3562
  if (live !== null) {
3124
- await client.updateServer(intent.id, patch);
3563
+ await client.updateServer(intent.id, { ...patch, ...repair ?? {} });
3125
3564
  return;
3126
3565
  }
3127
- const entry2 = await this.createEntry(intent, patch, null);
3566
+ const entry2 = await this.createEntry(intent, patch);
3128
3567
  if (entry2 === null) {
3129
3568
  throw new HomeHostedError(
3130
3569
  `no server "${intent.id}" exists; create it first, then this plugin can adopt its autostart and conflict policy`,
@@ -3139,10 +3578,10 @@ var HomeHostedService = class extends Service {
3139
3578
  throw new HomeHostedError(read.error, "CONFIG_UNREADABLE");
3140
3579
  let raw = read.raw ?? {};
3141
3580
  if (findEntry(raw, intent.id) !== null) {
3142
- writeConfig(this.options.home, patchEntry(raw, intent.id, patch), this.writtenBy());
3581
+ writeConfig(this.options.home, patchEntry(raw, intent.id, { ...patch, ...repair ?? {} }), this.writtenBy());
3143
3582
  return;
3144
3583
  }
3145
- const entry = await this.createEntry(intent, patch, null);
3584
+ const entry = await this.createEntry(intent, patch);
3146
3585
  if (entry === null)
3147
3586
  throw new HomeHostedError(`no server "${intent.id}" exists in ${this.options.home}/servers.config.json`, "ENTRY_MISSING");
3148
3587
  if (await this.panelRunning()) {
@@ -3301,7 +3740,7 @@ var HomeHostedService = class extends Service {
3301
3740
  case "switch": {
3302
3741
  if (file === void 0 || file.trim().length === 0)
3303
3742
  throw new HomeHostedError("switching the UI needs the zip to install", "UI_FILE_REQUIRED");
3304
- if (!fs12.existsSync(file))
3743
+ if (!fs13.existsSync(file))
3305
3744
  throw new HomeHostedError(`no file at ${file}`, "UI_FILE_MISSING");
3306
3745
  const result = await runUi(["ui-switch", "--file", file, "--yes"]);
3307
3746
  return { ok: result.code === 0, detail: result.code === 0 ? "the UI was replaced" : "the switch did not run", ui: active(), output: result.output };
@@ -3525,7 +3964,7 @@ var HomeHostedService = class extends Service {
3525
3964
  if (status.state !== "enabled-failing" && status.state !== "installed-disabled")
3526
3965
  return;
3527
3966
  const fileBacked = status.mechanism === "systemd-user" || status.mechanism === "systemd-system" || status.mechanism === "xdg-autostart" || status.mechanism === "launchd-agent" || status.mechanism === "launchd-daemon";
3528
- if (fileBacked && (status.unitPath === null || !fs12.existsSync(status.unitPath)))
3967
+ if (fileBacked && (status.unitPath === null || !fs13.existsSync(status.unitPath)))
3529
3968
  return;
3530
3969
  try {
3531
3970
  await this.installBoot();
@@ -3638,6 +4077,8 @@ var HomeHostedService = class extends Service {
3638
4077
  return await this.startPanelNow();
3639
4078
  case "panel.takeover":
3640
4079
  return await this.takeoverPanel(input.force === true);
4080
+ case "panel.reclaimToken":
4081
+ return await this.reclaimPanelToken();
3641
4082
  case "cli.installGlobal":
3642
4083
  return await this.installGlobalCli();
3643
4084
  default:
@@ -3831,6 +4272,8 @@ function normalize(raw, fallbackEntryId) {
3831
4272
  port: typeof raw?.panel?.port === "number" && Number.isInteger(raw.panel.port) && raw.panel.port >= 1 && raw.panel.port <= 65535 ? raw.panel.port : null
3832
4273
  },
3833
4274
  authNotice: raw?.authNotice === void 0 ? DEFAULT_SETTINGS.authNotice : raw.authNotice === true,
4275
+ // Absent means on: a stale token is a fault to repair, not a setting to opt into.
4276
+ reclaimToken: raw?.reclaimToken === void 0 ? DEFAULT_SETTINGS.reclaimToken : raw.reclaimToken === true,
3834
4277
  uiStyle: raw?.uiStyle === "compact" ? "compact" : "detailed",
3835
4278
  agentTools: {
3836
4279
  // Absent means the default (on), and so does the pair the previous
@@ -3868,6 +4311,7 @@ var SettingsStore = class {
3868
4311
  entries: patch.entries ?? this.current.entries,
3869
4312
  panel: { ...this.current.panel, ...patch.panel ?? {} },
3870
4313
  authNotice: patch.authNotice ?? this.current.authNotice,
4314
+ reclaimToken: patch.reclaimToken ?? this.current.reclaimToken,
3871
4315
  uiStyle: patch.uiStyle ?? this.current.uiStyle,
3872
4316
  agentTools: { ...this.current.agentTools, ...patch.agentTools ?? {} },
3873
4317
  cli: { ...this.current.cli, ...patch.cli ?? {} }
@@ -4001,7 +4445,49 @@ function toolNameFor(name2) {
4001
4445
  var READ_ONLY_ACTIONS = {
4002
4446
  ui_manage: (input) => stringArg(input, "action") === "status"
4003
4447
  };
4004
- function registerOne(ctx, service, name2) {
4448
+ var TOKEN_REFUSAL_CODES = /* @__PURE__ */ new Set([
4449
+ "AUTH_REQUIRED",
4450
+ "AUTH_UNARMED",
4451
+ "UNAUTHORIZED",
4452
+ "FORBIDDEN",
4453
+ "INVALID_TOKEN",
4454
+ "TOKEN_STALE",
4455
+ "TOKEN_REFUSED"
4456
+ ]);
4457
+ function errorCode(error) {
4458
+ const code = error?.code;
4459
+ return typeof code === "string" ? code : null;
4460
+ }
4461
+ function errorStatus(error) {
4462
+ const status = error?.status;
4463
+ return typeof status === "number" ? status : null;
4464
+ }
4465
+ function refusedByPanel(error) {
4466
+ const status = errorStatus(error);
4467
+ if (status === 401 || status === 403)
4468
+ return true;
4469
+ const code = errorCode(error);
4470
+ return code !== null && TOKEN_REFUSAL_CODES.has(code);
4471
+ }
4472
+ async function tokenRefused(service, error) {
4473
+ if (refusedByPanel(error))
4474
+ return true;
4475
+ if (errorCode(error) !== "PANEL_UNAVAILABLE")
4476
+ return false;
4477
+ try {
4478
+ const status = await service.call("status", {});
4479
+ return status?.panel.token === "stale";
4480
+ } catch {
4481
+ return false;
4482
+ }
4483
+ }
4484
+ function messageOf(error) {
4485
+ return error instanceof Error ? error.message : String(error);
4486
+ }
4487
+ function failureText(error) {
4488
+ return `failed: ${messageOf(error)}`;
4489
+ }
4490
+ function registerOne(ctx, service, settings, name2) {
4005
4491
  const spec = TOOL_SPECS[name2];
4006
4492
  const toolName = toolNameFor(name2);
4007
4493
  const mutatingTool = MUTATING_AGENT_TOOLS.includes(name2);
@@ -4030,11 +4516,27 @@ function registerOne(ctx, service, name2) {
4030
4516
  if (outcome !== "allowed-once")
4031
4517
  return `refused: approval answered "${outcome}" (session sandbox: ${mode ?? "unknown"}). ${remedy}`;
4032
4518
  }
4519
+ let request;
4033
4520
  try {
4034
- const { endpoint, payload } = spec.run(input);
4035
- return JSON.stringify(await service.call(endpoint, payload), null, 2);
4521
+ request = spec.run(input);
4036
4522
  } catch (error) {
4037
- return `failed: ${error instanceof Error ? error.message : String(error)}`;
4523
+ return failureText(error);
4524
+ }
4525
+ try {
4526
+ return JSON.stringify(await service.call(request.endpoint, request.payload), null, 2);
4527
+ } catch (error) {
4528
+ if (!settings.get().reclaimToken || !await tokenRefused(service, error))
4529
+ return failureText(error);
4530
+ try {
4531
+ await service.call("panel.reclaimToken", {});
4532
+ } catch (reclaimError) {
4533
+ return `${failureText(error)} (token reclaim failed: ${messageOf(reclaimError)})`;
4534
+ }
4535
+ try {
4536
+ return JSON.stringify(await service.call(request.endpoint, request.payload), null, 2);
4537
+ } catch (retryError) {
4538
+ return failureText(retryError);
4539
+ }
4038
4540
  }
4039
4541
  }
4040
4542
  }));
@@ -4050,7 +4552,7 @@ function registerAgentTools(ctx, service, settings) {
4050
4552
  return;
4051
4553
  for (const name2 of current.agentTools.allow) {
4052
4554
  try {
4053
- disposers.push(registerOne(scoped, service, name2));
4555
+ disposers.push(registerOne(scoped, service, settings, name2));
4054
4556
  } catch {
4055
4557
  }
4056
4558
  }