dsh-home-hosted 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -170,12 +170,6 @@ function writeJsonAtomic(file, value, mode) {
170
170
  writeFileAtomic(file, `${JSON.stringify(value, null, 2)}
171
171
  `, mode);
172
172
  }
173
- function removeFile(file) {
174
- try {
175
- fs.rmSync(file, { force: true });
176
- } catch {
177
- }
178
- }
179
173
 
180
174
  // src/boot/common.ts
181
175
  function errorMessage(error) {
@@ -2114,7 +2108,7 @@ import { createRequire } from "node:module";
2114
2108
  import fs9 from "node:fs";
2115
2109
  import path8 from "node:path";
2116
2110
  import process5 from "node:process";
2117
- var EXPECTED_RANGE = "^0.6.4";
2111
+ var EXPECTED_RANGE = "^0.6.6";
2118
2112
  var MIN_SUPPORTED_VERSION = "0.4.1";
2119
2113
  var MIN_KILL_VERSION = "0.6.0";
2120
2114
  var MIN_PERSISTENT_VERSION = "0.6.3";
@@ -2764,13 +2758,11 @@ function buildDshEntry(facts) {
2764
2758
  "--port",
2765
2759
  "{port}",
2766
2760
  "--host",
2767
- "{host}",
2761
+ "127.0.0.1",
2768
2762
  "--no-open",
2769
2763
  "--trusted-host",
2770
2764
  `localhost:{port}`
2771
2765
  );
2772
- if (facts.host === "0.0.0.0")
2773
- appArgs.push("--trusted-host", "{lanIp}:{port}");
2774
2766
  }
2775
2767
  const launcherArgs = profile === "web" ? ["web"] : ["--profile", profile];
2776
2768
  const generated = launch === null ? { command: "dsh", entryArgs: [] } : buildCommand(launch, facts.launcherPath ?? null);
@@ -2781,7 +2773,7 @@ function buildDshEntry(facts) {
2781
2773
  autostart: true,
2782
2774
  command: generated.command,
2783
2775
  args: [...generated.entryArgs, ...launcherArgs, ...appArgs],
2784
- bind: facts.host === "0.0.0.0" ? "lan" : "local",
2776
+ bind: "local",
2785
2777
  port: facts.port,
2786
2778
  cwd: process7.cwd(),
2787
2779
  env: {},
@@ -3230,20 +3222,12 @@ async function enrollToken(options) {
3230
3222
  return { token, enrolled: true, detail: "enrolled a new panel API token" };
3231
3223
  }
3232
3224
  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
3225
  const token = generateToken();
3241
3226
  const enrolled = await runCli2(options.exec, ["--home", options.home, "set-token"], { HHOSTED_TOKEN: token });
3242
3227
  if (enrolled.result === void 0) {
3243
- removeFile(storedTokenPath(options.stateDir));
3244
3228
  return {
3245
3229
  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`
3230
+ detail: `could not enrol a fresh API token: ${enrolled.failure ?? "the CLI did not answer"}; the previously stored token was kept`
3247
3231
  };
3248
3232
  }
3249
3233
  storeToken(options.stateDir, token);
@@ -3253,6 +3237,7 @@ async function enrollFreshToken(options) {
3253
3237
  // src/service.ts
3254
3238
  var ENTRY_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
3255
3239
  var TOKEN_PROBE_TIMEOUT_MS = 3e3;
3240
+ var ENTRY_RECOVERY_INTERVAL_MS = 3e4;
3256
3241
  function pluginRoot() {
3257
3242
  try {
3258
3243
  return path13.dirname(path13.dirname(fileURLToPath(import.meta.url)));
@@ -3277,6 +3262,8 @@ var HomeHostedService = class extends Service {
3277
3262
  /** The last token this service proved against a panel, so a poll does not re-probe it. */
3278
3263
  tokenProof = null;
3279
3264
  tokenDetail = "";
3265
+ /** When a missing managed entry was last put back, so a poll cannot become a write loop. */
3266
+ entryRecoveryAt = 0;
3280
3267
  snapshotsFile;
3281
3268
  async resolveDsh(dshHome2) {
3282
3269
  return await (this.options.resolveDsh ?? ((options) => resolveDshLaunch(options)))({
@@ -3302,8 +3289,7 @@ var HomeHostedService = class extends Service {
3302
3289
  webServer() {
3303
3290
  const service = this.ctx.get("webServer");
3304
3291
  const port = typeof service?.port === "number" ? service.port : null;
3305
- const host = typeof service?.host === "string" ? service.host : "127.0.0.1";
3306
- return { port, host };
3292
+ return { port };
3307
3293
  }
3308
3294
  cliCache = null;
3309
3295
  /**
@@ -3421,26 +3407,30 @@ var HomeHostedService = class extends Service {
3421
3407
  writeVia,
3422
3408
  token,
3423
3409
  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"
3410
+ 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 but no API token is enrolled for it yet; writes go to servers.config.json until one is" : "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" : token === "present" || token === "stale" ? "the panel process is up but does not answer with this plugin's API token, so this page cannot reach it" : "the panel process is alive but is not answering"
3425
3411
  };
3426
3412
  }
3427
3413
  /**
3428
3414
  * Replace the panel's API token with a fresh one and prove it.
3429
3415
  *
3430
3416
  * 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.
3417
+ * be recovered: a new token replaces the old hash in one CLI write, and it is
3418
+ * proved against the answering panel before being reported.
3419
+ *
3420
+ * The panel is not required to be *answering*: a missing or refused token is a
3421
+ * common reason it cannot be reached at all, and refusing the repair for that
3422
+ * reason would leave no way out from the page. Only a panel that was never
3423
+ * started is refused — there is nothing to enrol a token against yet.
3435
3424
  */
3436
3425
  async reclaimPanelToken() {
3437
3426
  const runtime = this.runtime();
3438
- if (runtime === null || !await probePanel(runtime.url)) {
3427
+ if (runtime === null) {
3439
3428
  throw new HomeHostedError(
3440
- "the panel is not answering, so a regenerated token could not be verified; start the panel first",
3429
+ "the panel has not been started, so there is nothing to enrol a token against; start it first",
3441
3430
  "PANEL_UNAVAILABLE"
3442
3431
  );
3443
3432
  }
3433
+ const answering = await probePanel(runtime.url);
3444
3434
  const result = await reclaimToken({
3445
3435
  home: this.options.home,
3446
3436
  stateDir: this.options.stateDir,
@@ -3451,6 +3441,10 @@ var HomeHostedService = class extends Service {
3451
3441
  this.tokenDetail = result.detail;
3452
3442
  if (result.token === null)
3453
3443
  throw new HomeHostedError(result.detail, "TOKEN_RECLAIM_FAILED");
3444
+ if (!answering) {
3445
+ this.tokenDetail = `${result.detail}; the panel is not answering, so it could not be proved yet`;
3446
+ return await this.status();
3447
+ }
3454
3448
  const probe = await probeToken(runtime.url, result.token, TOKEN_PROBE_TIMEOUT_MS);
3455
3449
  if (probe !== "ok") {
3456
3450
  throw new HomeHostedError(
@@ -3523,11 +3517,10 @@ var HomeHostedService = class extends Service {
3523
3517
  return null;
3524
3518
  const harnessHome = process10.env.DSH_HOME ?? dshHome();
3525
3519
  const dsh = await this.resolveDsh(harnessHome);
3526
- const { port, host } = this.webServer();
3520
+ const { port } = this.webServer();
3527
3521
  const generated = buildDshEntry({
3528
3522
  id: intent.id,
3529
3523
  port,
3530
- host,
3531
3524
  profile: detectProfile(process10.argv, process10.env),
3532
3525
  dshHome: harnessHome,
3533
3526
  launch: dsh,
@@ -3650,8 +3643,9 @@ var HomeHostedService = class extends Service {
3650
3643
  entries.push(intent);
3651
3644
  this.options.settings.update({
3652
3645
  // The page's toggle reads this flag, so managing the harness has to record
3653
- // itself; restoring or removing that entry clears it again.
3654
- ...intent.id === this.options.defaultEntryId ? { manageDsh: true } : {},
3646
+ // itself; restoring or removing that entry clears it again. A paused intent
3647
+ // (`autostart: false`) is not management, or the toggle could never go off.
3648
+ ...intent.id === this.options.defaultEntryId ? { manageDsh: intent.autostart } : {},
3655
3649
  entries
3656
3650
  });
3657
3651
  }
@@ -3958,6 +3952,36 @@ var HomeHostedService = class extends Service {
3958
3952
  * an explicit click or an approved tool call, not as a side effect of startup.
3959
3953
  */
3960
3954
  async reconcile() {
3955
+ await this.ensureManagedEntry();
3956
+ await this.repairBootEntry();
3957
+ }
3958
+ /**
3959
+ * Put back a managed entry that no longer exists, while its intent still wants
3960
+ * autostart. A paused intent is a deliberate stop and is left alone.
3961
+ *
3962
+ * Called from startup `reconcile()` as well as the status read, because a person
3963
+ * who deletes the entry from the panel is looking at the page right then — not
3964
+ * at the next plugin start. A failed attempt is not repeated for a while, so a
3965
+ * config that cannot be written does not turn every poll into a write.
3966
+ */
3967
+ async ensureManagedEntry() {
3968
+ const { settings } = this.options;
3969
+ const id = this.options.defaultEntryId;
3970
+ if (!settings.get().manageDsh || !settings.intentFor(id).autostart)
3971
+ return;
3972
+ const now = Date.now();
3973
+ if (now - this.entryRecoveryAt < ENTRY_RECOVERY_INTERVAL_MS)
3974
+ return;
3975
+ this.entryRecoveryAt = now;
3976
+ if ((await this.entriesStatus()).every((entry) => entry.intent.id !== id || entry.exists))
3977
+ return;
3978
+ try {
3979
+ await this.writeOwned({ ...settings.intentFor(id), autostart: true });
3980
+ } catch {
3981
+ }
3982
+ }
3983
+ /** Re-assert the boot entry when the OS still has it but it stopped working. */
3984
+ async repairBootEntry() {
3961
3985
  if (!this.options.settings.get().autostart.enabled)
3962
3986
  return;
3963
3987
  const status = await this.bootStatus();
@@ -4003,7 +4027,9 @@ var HomeHostedService = class extends Service {
4003
4027
  defaultEntryId: this.options.defaultEntryId,
4004
4028
  panel,
4005
4029
  boot: await this.bootStatus(),
4006
- entries: await this.entriesStatus(),
4030
+ // A deleted managed entry is put back here as well as at startup: whoever
4031
+ // deleted it is looking at the page that reports it missing.
4032
+ entries: await this.ensureManagedEntry().then(async () => await this.entriesStatus()),
4007
4033
  servers,
4008
4034
  settings: this.options.settings.get(),
4009
4035
  cli,