dsh-home-hosted 0.3.1 → 0.3.2

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";
@@ -3230,20 +3224,12 @@ async function enrollToken(options) {
3230
3224
  return { token, enrolled: true, detail: "enrolled a new panel API token" };
3231
3225
  }
3232
3226
  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
3227
  const token = generateToken();
3241
3228
  const enrolled = await runCli2(options.exec, ["--home", options.home, "set-token"], { HHOSTED_TOKEN: token });
3242
3229
  if (enrolled.result === void 0) {
3243
- removeFile(storedTokenPath(options.stateDir));
3244
3230
  return {
3245
3231
  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`
3232
+ detail: `could not enrol a fresh API token: ${enrolled.failure ?? "the CLI did not answer"}; the previously stored token was kept`
3247
3233
  };
3248
3234
  }
3249
3235
  storeToken(options.stateDir, token);
@@ -3253,6 +3239,7 @@ async function enrollFreshToken(options) {
3253
3239
  // src/service.ts
3254
3240
  var ENTRY_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
3255
3241
  var TOKEN_PROBE_TIMEOUT_MS = 3e3;
3242
+ var ENTRY_RECOVERY_INTERVAL_MS = 3e4;
3256
3243
  function pluginRoot() {
3257
3244
  try {
3258
3245
  return path13.dirname(path13.dirname(fileURLToPath(import.meta.url)));
@@ -3277,6 +3264,8 @@ var HomeHostedService = class extends Service {
3277
3264
  /** The last token this service proved against a panel, so a poll does not re-probe it. */
3278
3265
  tokenProof = null;
3279
3266
  tokenDetail = "";
3267
+ /** When a missing managed entry was last put back, so a poll cannot become a write loop. */
3268
+ entryRecoveryAt = 0;
3280
3269
  snapshotsFile;
3281
3270
  async resolveDsh(dshHome2) {
3282
3271
  return await (this.options.resolveDsh ?? ((options) => resolveDshLaunch(options)))({
@@ -3421,26 +3410,30 @@ var HomeHostedService = class extends Service {
3421
3410
  writeVia,
3422
3411
  token,
3423
3412
  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"
3413
+ 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
3414
  };
3426
3415
  }
3427
3416
  /**
3428
3417
  * Replace the panel's API token with a fresh one and prove it.
3429
3418
  *
3430
3419
  * 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.
3420
+ * be recovered: a new token replaces the old hash in one CLI write, and it is
3421
+ * proved against the answering panel before being reported.
3422
+ *
3423
+ * The panel is not required to be *answering*: a missing or refused token is a
3424
+ * common reason it cannot be reached at all, and refusing the repair for that
3425
+ * reason would leave no way out from the page. Only a panel that was never
3426
+ * started is refused — there is nothing to enrol a token against yet.
3435
3427
  */
3436
3428
  async reclaimPanelToken() {
3437
3429
  const runtime = this.runtime();
3438
- if (runtime === null || !await probePanel(runtime.url)) {
3430
+ if (runtime === null) {
3439
3431
  throw new HomeHostedError(
3440
- "the panel is not answering, so a regenerated token could not be verified; start the panel first",
3432
+ "the panel has not been started, so there is nothing to enrol a token against; start it first",
3441
3433
  "PANEL_UNAVAILABLE"
3442
3434
  );
3443
3435
  }
3436
+ const answering = await probePanel(runtime.url);
3444
3437
  const result = await reclaimToken({
3445
3438
  home: this.options.home,
3446
3439
  stateDir: this.options.stateDir,
@@ -3451,6 +3444,10 @@ var HomeHostedService = class extends Service {
3451
3444
  this.tokenDetail = result.detail;
3452
3445
  if (result.token === null)
3453
3446
  throw new HomeHostedError(result.detail, "TOKEN_RECLAIM_FAILED");
3447
+ if (!answering) {
3448
+ this.tokenDetail = `${result.detail}; the panel is not answering, so it could not be proved yet`;
3449
+ return await this.status();
3450
+ }
3454
3451
  const probe = await probeToken(runtime.url, result.token, TOKEN_PROBE_TIMEOUT_MS);
3455
3452
  if (probe !== "ok") {
3456
3453
  throw new HomeHostedError(
@@ -3650,8 +3647,9 @@ var HomeHostedService = class extends Service {
3650
3647
  entries.push(intent);
3651
3648
  this.options.settings.update({
3652
3649
  // 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 } : {},
3650
+ // itself; restoring or removing that entry clears it again. A paused intent
3651
+ // (`autostart: false`) is not management, or the toggle could never go off.
3652
+ ...intent.id === this.options.defaultEntryId ? { manageDsh: intent.autostart } : {},
3655
3653
  entries
3656
3654
  });
3657
3655
  }
@@ -3958,6 +3956,36 @@ var HomeHostedService = class extends Service {
3958
3956
  * an explicit click or an approved tool call, not as a side effect of startup.
3959
3957
  */
3960
3958
  async reconcile() {
3959
+ await this.ensureManagedEntry();
3960
+ await this.repairBootEntry();
3961
+ }
3962
+ /**
3963
+ * Put back a managed entry that no longer exists, while its intent still wants
3964
+ * autostart. A paused intent is a deliberate stop and is left alone.
3965
+ *
3966
+ * Called from startup `reconcile()` as well as the status read, because a person
3967
+ * who deletes the entry from the panel is looking at the page right then — not
3968
+ * at the next plugin start. A failed attempt is not repeated for a while, so a
3969
+ * config that cannot be written does not turn every poll into a write.
3970
+ */
3971
+ async ensureManagedEntry() {
3972
+ const { settings } = this.options;
3973
+ const id = this.options.defaultEntryId;
3974
+ if (!settings.get().manageDsh || !settings.intentFor(id).autostart)
3975
+ return;
3976
+ const now = Date.now();
3977
+ if (now - this.entryRecoveryAt < ENTRY_RECOVERY_INTERVAL_MS)
3978
+ return;
3979
+ this.entryRecoveryAt = now;
3980
+ if ((await this.entriesStatus()).every((entry) => entry.intent.id !== id || entry.exists))
3981
+ return;
3982
+ try {
3983
+ await this.writeOwned({ ...settings.intentFor(id), autostart: true });
3984
+ } catch {
3985
+ }
3986
+ }
3987
+ /** Re-assert the boot entry when the OS still has it but it stopped working. */
3988
+ async repairBootEntry() {
3961
3989
  if (!this.options.settings.get().autostart.enabled)
3962
3990
  return;
3963
3991
  const status = await this.bootStatus();
@@ -4003,7 +4031,9 @@ var HomeHostedService = class extends Service {
4003
4031
  defaultEntryId: this.options.defaultEntryId,
4004
4032
  panel,
4005
4033
  boot: await this.bootStatus(),
4006
- entries: await this.entriesStatus(),
4034
+ // A deleted managed entry is put back here as well as at startup: whoever
4035
+ // deleted it is looking at the page that reports it missing.
4036
+ entries: await this.ensureManagedEntry().then(async () => await this.entriesStatus()),
4007
4037
  servers,
4008
4038
  settings: this.options.settings.get(),
4009
4039
  cli,