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/README.md +6 -5
- package/docs/DESIGN.md +46 -8
- package/lib/client.js +6 -6
- package/lib/index.js +56 -26
- package/lib/index.js.map +2 -2
- package/lib/types/home-hosted/resolve.d.ts +1 -1
- package/lib/types/home-hosted/token.d.ts +5 -6
- package/lib/types/service.d.ts +21 -4
- package/lib/types/shared/contracts.d.ts +1 -1
- package/package.json +2 -2
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.
|
|
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: `
|
|
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
|
|
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
|
|
3432
|
-
*
|
|
3433
|
-
*
|
|
3434
|
-
*
|
|
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
|
|
3430
|
+
if (runtime === null) {
|
|
3439
3431
|
throw new HomeHostedError(
|
|
3440
|
-
"the panel
|
|
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
|
-
|
|
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
|
-
|
|
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,
|