dsh-home-hosted 0.9.4 → 0.9.5
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/docs/DESIGN.md +3 -2
- package/docs/PANEL.md +18 -5
- package/lib/index.js +161 -34
- package/lib/index.js.map +4 -4
- package/lib/types/home-hosted/panel-control.d.ts +9 -1
- package/lib/types/home-hosted/panel-migrate.d.ts +80 -0
- package/lib/types/home-hosted/resolve.d.ts +1 -1
- package/package.json +2 -2
package/docs/DESIGN.md
CHANGED
|
@@ -42,8 +42,9 @@ stop-then-start pair. Both are gone: it pins `home-hosted@^0.9.x` — 0.9.0 at t
|
|
|
42
42
|
which refuses an older panel outright, so neither check could ever fire again.
|
|
43
43
|
|
|
44
44
|
An old state root is upgraded by running the latest 0.7.x **once, before installing 0.8** — its first
|
|
45
|
-
run moves the state into `.hh/`. A root a 0.8-era panel left on config schema 1
|
|
46
|
-
|
|
45
|
+
run moves the state into `.hh/`. A root a 0.8-era panel left on config schema 1 is lifted
|
|
46
|
+
automatically: a failed start makes the plugin run the panel's own `migrate` and retry, at boot and from
|
|
47
|
+
the page alike. See [PANEL](PANEL.md#upgrading-an-existing-state-root).
|
|
47
48
|
|
|
48
49
|
## Tests
|
|
49
50
|
|
package/docs/PANEL.md
CHANGED
|
@@ -87,15 +87,28 @@ path. A 0.8.x panel is not a lesser-supported copy — it is one this plugin can
|
|
|
87
87
|
|
|
88
88
|
### Upgrading an existing state root
|
|
89
89
|
|
|
90
|
-
A root a 0.8-era panel wrote is on config schema 1
|
|
91
|
-
|
|
90
|
+
A root a 0.8-era panel wrote is on config schema 1, and 0.9 refuses to boot it.
|
|
91
|
+
|
|
92
|
+
**This plugin lifts the panel it manages for you.** Both start paths — the page's Start button and the
|
|
93
|
+
generated launcher a boot entry runs — ask the panel's own `migrate --dry-run` after a failed start, and
|
|
94
|
+
when it reports a pending migration they apply `migrate --yes` and retry once. So an upgrade that raised
|
|
95
|
+
the schema heals itself: at boot, with no page open, and without you running anything.
|
|
92
96
|
|
|
93
97
|
```sh
|
|
94
|
-
|
|
95
|
-
home-hosted migrate --
|
|
98
|
+
# what runs for you, in order
|
|
99
|
+
home-hosted migrate --dry-run # the question: is a migration pending?
|
|
100
|
+
home-hosted migrate --yes # only if it is; keeps a .bak beside each rewritten file
|
|
96
101
|
```
|
|
97
102
|
|
|
98
|
-
|
|
103
|
+
The decision is the panel's own printed plan, never the failure text, so a start that failed for another
|
|
104
|
+
reason is left alone and a reworded refusal cannot disable the repair. Nothing here implements a
|
|
105
|
+
migration step: `migrate` keeps the backups, validates its output, and refuses a config it cannot lift —
|
|
106
|
+
that refusal is reported verbatim.
|
|
107
|
+
|
|
108
|
+
**A root that is not the one this plugin manages is not touched.** Run the two commands by hand against
|
|
109
|
+
it, exactly as above. The same applies when the panel refuses for a reason `migrate` cannot fix (a file
|
|
110
|
+
from a *newer* release, an unreadable config): the start's own error is what you get, with the migration's
|
|
111
|
+
refusal appended.
|
|
99
112
|
|
|
100
113
|
**0.7.21 widens that nullable set by one field, and it closed a bug here.** Clearing a server's label
|
|
101
114
|
sent an empty string, which the panel accepts and saves — so the entry kept a blank label instead of
|
package/lib/index.js
CHANGED
|
@@ -3017,7 +3017,7 @@ function buildHomeHostedBootSpec(facts, launch, options) {
|
|
|
3017
3017
|
}
|
|
3018
3018
|
|
|
3019
3019
|
// src/home-hosted/resolve.ts
|
|
3020
|
-
var EXPECTED_RANGE = "^0.9.
|
|
3020
|
+
var EXPECTED_RANGE = "^0.9.7";
|
|
3021
3021
|
var MIN_SUPPORTED_VERSION = "0.9.0";
|
|
3022
3022
|
function pinnedManifestPath() {
|
|
3023
3023
|
try {
|
|
@@ -3411,20 +3411,113 @@ import process7 from "node:process";
|
|
|
3411
3411
|
import fs13 from "node:fs";
|
|
3412
3412
|
import path12 from "node:path";
|
|
3413
3413
|
import process6 from "node:process";
|
|
3414
|
+
|
|
3415
|
+
// src/home-hosted/panel-migrate.ts
|
|
3416
|
+
var MIGRATION_STEP_ANCHOR = /^migrating .+: config schema \d+ → \d+$/m;
|
|
3417
|
+
var MIGRATION_STEP_SOURCE = MIGRATION_STEP_ANCHOR.source;
|
|
3418
|
+
function cliText(answer2) {
|
|
3419
|
+
return [answer2.stdout, answer2.stderr].filter((part) => typeof part === "string" && part.trim().length > 0).join("\n").trim();
|
|
3420
|
+
}
|
|
3421
|
+
function migrationSteps(answer2) {
|
|
3422
|
+
const text = [answer2.stdout, answer2.stderr].join("\n");
|
|
3423
|
+
return text.split("\n").filter((line) => MIGRATION_STEP_ANCHOR.test(line));
|
|
3424
|
+
}
|
|
3425
|
+
function migrationTransitions(steps) {
|
|
3426
|
+
const jumps = steps.map((step) => /config schema (\d+) → (\d+)/.exec(step)).filter((match) => match !== null).map((match) => `${match[1]} \u2192 ${match[2]}`);
|
|
3427
|
+
return [...new Set(jumps)];
|
|
3428
|
+
}
|
|
3429
|
+
function migrationNote(transitions) {
|
|
3430
|
+
const from = transitions.length === 0 ? "the config schema" : `config schema ${transitions.join(", ")}`;
|
|
3431
|
+
return `${from} made this panel refuse to start, so it was lifted with \`home-hosted migrate --yes\` (a .bak sits beside each rewritten file)`;
|
|
3432
|
+
}
|
|
3433
|
+
async function repairPanelMigration(run2) {
|
|
3434
|
+
const plan = await run2(["migrate", "--dry-run"]);
|
|
3435
|
+
const steps = migrationSteps(plan);
|
|
3436
|
+
if (steps.length === 0)
|
|
3437
|
+
return { note: null, failure: null };
|
|
3438
|
+
const applied = await run2(["migrate", "--yes"]);
|
|
3439
|
+
if (applied.code !== 0)
|
|
3440
|
+
return { note: null, failure: cliText(applied) || `home-hosted migrate exited ${String(applied.code)}` };
|
|
3441
|
+
return { note: migrationNote(migrationTransitions(steps)), failure: null };
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
// src/home-hosted/launcher.ts
|
|
3414
3445
|
function launcherDir(stateDir) {
|
|
3415
3446
|
return path12.join(stateDir, "bin");
|
|
3416
3447
|
}
|
|
3417
3448
|
function launcherPath(stateDir) {
|
|
3418
3449
|
return path12.join(launcherDir(stateDir), "home-hosted.mjs");
|
|
3419
3450
|
}
|
|
3420
|
-
var LAUNCH_CHILD = `
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3451
|
+
var LAUNCH_CHILD = `let current = null
|
|
3452
|
+
let stopping = false
|
|
3453
|
+
for (const signal of ['SIGTERM', 'SIGINT', 'SIGQUIT', 'SIGHUP'])
|
|
3454
|
+
process.on(signal, () => { stopping = true; try { current.kill(signal) } catch {} })
|
|
3455
|
+
|
|
3456
|
+
/** Run a child with this process's own stdio; resolves with how it ended. */
|
|
3457
|
+
function launchChild(program, argv, useShell) {
|
|
3458
|
+
return new Promise((resolve) => {
|
|
3459
|
+
const child = spawn(program, argv, { stdio: 'inherit', shell: useShell })
|
|
3460
|
+
current = child
|
|
3461
|
+
child.on('exit', (code, signal) => resolve({ code, signal }))
|
|
3462
|
+
child.on('error', (error) => {
|
|
3463
|
+
console.error('[dsh-home-hosted] could not start ' + program + ': ' + error.message)
|
|
3464
|
+
// A child that never started is a failure, and exitWith reads a null code with no signal as
|
|
3465
|
+
// success \u2014 which would report a launcher that started nothing as having exited cleanly.
|
|
3466
|
+
resolve({ code: 1, signal: null })
|
|
3467
|
+
})
|
|
3468
|
+
})
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
/** The exit code a launcher reports for a child's outcome, as before. */
|
|
3472
|
+
function exitWith(result) {
|
|
3473
|
+
process.exit(result.code ?? (result.signal ? 1 : 0))
|
|
3474
|
+
}`;
|
|
3475
|
+
var MIGRATION_REPAIR = `/**
|
|
3476
|
+
* Ask the panel's own migrate whether this root needs lifting, apply it if so, and say
|
|
3477
|
+
* whether a retry is worthwhile (null means "no, the original failure stands").
|
|
3478
|
+
*
|
|
3479
|
+
* Only this script's own '--foreground' daemon run is repaired. The boot entry passes it, so a
|
|
3480
|
+
* failure there is the one nothing else can heal; a 'down' or a one-off command that failed is
|
|
3481
|
+
* left alone, because lifting a config is not what a person asked those for.
|
|
3482
|
+
*/
|
|
3483
|
+
async function repairMigration(program, prefix, args) {
|
|
3484
|
+
if (!args.includes('--foreground')) return null
|
|
3485
|
+
const dirs = []
|
|
3486
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
3487
|
+
if (args[index] === '--home' && args[index + 1] !== undefined) dirs.push('--home', args[index + 1])
|
|
3488
|
+
if (args[index] === '--project' && args[index + 1] !== undefined) dirs.push('--project', args[index + 1])
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
// The decision is the panel's answer, never its failure text: one 'migrating ...' line per
|
|
3492
|
+
// file it would lift, at column 0. A failed start for any other reason prints none.
|
|
3493
|
+
const plan = await runQuiet(program, [...prefix, 'migrate', '--dry-run', ...dirs])
|
|
3494
|
+
const anchor = new RegExp(${JSON.stringify(MIGRATION_STEP_SOURCE)}, 'm')
|
|
3495
|
+
if (!anchor.test(plan.output))
|
|
3496
|
+
return null
|
|
3497
|
+
|
|
3498
|
+
console.error('[dsh-home-hosted] the panel config is on an older schema, which this panel refuses to boot; lifting it with \`home-hosted migrate --yes\` (a .bak is kept beside each file)')
|
|
3499
|
+
// Not through launchChild: a stop signal must not kill the process that is rewriting the
|
|
3500
|
+
// person's config, so 'current' stays null and a pending stop waits for this to finish.
|
|
3501
|
+
const applied = await runQuiet(program, [...prefix, 'migrate', '--yes', ...dirs])
|
|
3502
|
+
if (applied.code !== 0) {
|
|
3503
|
+
console.error('[dsh-home-hosted] the migration did not succeed: ' + applied.output)
|
|
3504
|
+
return null
|
|
3505
|
+
}
|
|
3506
|
+
console.error('[dsh-home-hosted] migrated; starting the panel again')
|
|
3507
|
+
return true
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
/** Run the CLI with its output captured (both streams), for a question rather than a daemon. */
|
|
3511
|
+
function runQuiet(program, argv) {
|
|
3512
|
+
return new Promise((resolve) => {
|
|
3513
|
+
const child = spawn(program, argv, { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
3514
|
+
let output = ''
|
|
3515
|
+
const collect = (chunk) => { output = (output + chunk).slice(-65536) }
|
|
3516
|
+
child.stdout?.on('data', collect)
|
|
3517
|
+
child.stderr?.on('data', collect)
|
|
3518
|
+
child.on('exit', code => resolve({ code, output }))
|
|
3519
|
+
// A child that never started cannot have applied anything, and a null code reads as success.
|
|
3520
|
+
child.on('error', error => resolve({ code: 1, output: String(error && error.message) }))
|
|
3428
3521
|
})
|
|
3429
3522
|
}`;
|
|
3430
3523
|
function launcherRecordPath(stateDir) {
|
|
@@ -3629,12 +3722,30 @@ if (entry === null) {
|
|
|
3629
3722
|
process.exit(1)
|
|
3630
3723
|
}
|
|
3631
3724
|
|
|
3725
|
+
${MIGRATION_REPAIR}
|
|
3726
|
+
|
|
3632
3727
|
const args = process.argv.slice(2)
|
|
3633
3728
|
const isScript = /\\.(mjs|cjs|js)$/.test(entry)
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3729
|
+
const program = isScript ? process.execPath : entry
|
|
3730
|
+
const useShell = !isScript && process.platform === 'win32' && /\\.(cmd|bat)$/i.test(entry)
|
|
3731
|
+
|
|
3732
|
+
// What precedes the command word: node runs an entry script, and a shim or global executable is
|
|
3733
|
+
// itself the program.
|
|
3734
|
+
const prefix = isScript ? [entry] : []
|
|
3735
|
+
const argv = [...prefix, ...args]
|
|
3736
|
+
|
|
3737
|
+
const first = await launchChild(program, argv, useShell)
|
|
3738
|
+
if (first.code === 0 || stopping) exitWith(first)
|
|
3739
|
+
|
|
3740
|
+
// The panel refused to start, and at boot nothing else can repair it: this launcher is
|
|
3741
|
+
// what the entry runs, and a config on an older schema fails here forever. Ask the
|
|
3742
|
+
// panel's own migrate whether that is the reason, lift it if so, and retry once.
|
|
3743
|
+
const repaired = await repairMigration(program, prefix, args)
|
|
3744
|
+
// A stop that arrived while the migration ran has already reported its outcome: starting the
|
|
3745
|
+
// panel now would undo it, and a unit stopping while its launcher starts a daemon is the
|
|
3746
|
+
// orphaned-panel case this launcher exists to prevent.
|
|
3747
|
+
if (repaired === null || stopping) exitWith(first)
|
|
3748
|
+
exitWith(await launchChild(program, argv, useShell))
|
|
3638
3749
|
`;
|
|
3639
3750
|
}
|
|
3640
3751
|
function writeLauncher(options) {
|
|
@@ -3842,10 +3953,13 @@ if (entry === null) {
|
|
|
3842
3953
|
}
|
|
3843
3954
|
|
|
3844
3955
|
const args = process.argv.slice(2)
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3956
|
+
const isScript = /\\.(mjs|cjs|js)$/.test(entry)
|
|
3957
|
+
const program = isScript ? process.execPath : entry
|
|
3958
|
+
const argv = isScript ? [entry, ...args] : args
|
|
3959
|
+
// .then rather than top-level await: this launcher ships as CommonJS too (a .cjs entry gets a
|
|
3960
|
+
// .cjs launcher), where top-level await is a syntax error \u2014 and a launcher that cannot parse is a
|
|
3961
|
+
// boot entry that starts nothing.
|
|
3962
|
+
launchChild(program, argv, !isScript && process.platform === 'win32' && /\\.(cmd|bat)$/i.test(entry)).then(exitWith)
|
|
3849
3963
|
`;
|
|
3850
3964
|
}
|
|
3851
3965
|
function writeDshLauncher(options) {
|
|
@@ -4474,35 +4588,46 @@ import { spawn as spawn2 } from "node:child_process";
|
|
|
4474
4588
|
import fs16 from "node:fs";
|
|
4475
4589
|
import path15 from "node:path";
|
|
4476
4590
|
import process9 from "node:process";
|
|
4477
|
-
function cliArgs(deps,
|
|
4478
|
-
const args = [...deps.launch?.args ?? [],
|
|
4591
|
+
function cliArgs(deps, words) {
|
|
4592
|
+
const args = [...deps.launch?.args ?? [], ...words, "--home", deps.home];
|
|
4479
4593
|
if (deps.projectDir)
|
|
4480
4594
|
args.push("--project", deps.projectDir);
|
|
4481
4595
|
return args;
|
|
4482
4596
|
}
|
|
4483
|
-
async function
|
|
4484
|
-
const args = cliArgs(deps,
|
|
4597
|
+
async function runCliArgs(deps, words) {
|
|
4598
|
+
const args = cliArgs(deps, words);
|
|
4485
4599
|
if (deps.exec !== void 0)
|
|
4486
4600
|
return await deps.exec(args, deps.env);
|
|
4487
4601
|
const launch = deps.launch;
|
|
4488
4602
|
if (launch === null)
|
|
4489
|
-
return { code: null, stdout: "", stderr: "no home-hosted CLI is available", error: null };
|
|
4603
|
+
return { command: "", args, code: null, signal: null, stdout: "", stderr: "no home-hosted CLI is available", timedOut: false, error: null };
|
|
4490
4604
|
return await run(launch.program, args, {
|
|
4491
4605
|
env: deps.env,
|
|
4492
4606
|
timeoutMs: deps.timeoutMs ?? 9e4
|
|
4493
4607
|
});
|
|
4494
4608
|
}
|
|
4495
4609
|
async function startPanel(deps) {
|
|
4496
|
-
const result = await
|
|
4497
|
-
if (result.code
|
|
4498
|
-
const
|
|
4499
|
-
return { ok:
|
|
4500
|
-
}
|
|
4501
|
-
const
|
|
4502
|
-
|
|
4610
|
+
const result = await runCliArgs(deps, ["up"]);
|
|
4611
|
+
if (result.code === 0) {
|
|
4612
|
+
const match2 = /https?:\/\/[^\s]+/.exec(result.stdout);
|
|
4613
|
+
return { ok: true, detail: "the panel is answering", url: match2?.[0] ?? null };
|
|
4614
|
+
}
|
|
4615
|
+
const failed2 = result.stderr.trim() || result.stdout.trim() || `the CLI exited ${String(result.code)}`;
|
|
4616
|
+
const migration = await repairPanelMigration((words) => runCliArgs(deps, words));
|
|
4617
|
+
if (migration.failure !== null)
|
|
4618
|
+
return { ok: false, detail: `${failed2}; the panel config needs a migration and it did not apply: ${migration.failure}` };
|
|
4619
|
+
if (migration.note === null)
|
|
4620
|
+
return { ok: false, detail: failed2 };
|
|
4621
|
+
const retried = await runCliArgs(deps, ["up"]);
|
|
4622
|
+
if (retried.code !== 0) {
|
|
4623
|
+
const again = retried.stderr.trim() || retried.stdout.trim() || `the CLI exited ${String(retried.code)}`;
|
|
4624
|
+
return { ok: false, detail: `${migration.note}, but the panel still did not start: ${again}` };
|
|
4625
|
+
}
|
|
4626
|
+
const match = /https?:\/\/[^\s]+/.exec(retried.stdout);
|
|
4627
|
+
return { ok: true, detail: `${migration.note}; the panel is answering`, url: match?.[0] ?? null };
|
|
4503
4628
|
}
|
|
4504
4629
|
async function stopPanel(deps) {
|
|
4505
|
-
const result = await
|
|
4630
|
+
const result = await runCliArgs(deps, ["down"]);
|
|
4506
4631
|
if (result.code !== 0) {
|
|
4507
4632
|
const detail = result.error ?? (result.stderr.trim() || result.stdout.trim() || `the CLI exited ${String(result.code)}`);
|
|
4508
4633
|
return { ok: false, detail: `the panel did not stop: ${detail}` };
|
|
@@ -5028,7 +5153,7 @@ async function ensureToken(options) {
|
|
|
5028
5153
|
async function reclaimToken(options) {
|
|
5029
5154
|
return await serialised(options.stateDir, options.slot, async () => await enrollFreshToken(options));
|
|
5030
5155
|
}
|
|
5031
|
-
async function
|
|
5156
|
+
async function runCli(exec, args, env) {
|
|
5032
5157
|
try {
|
|
5033
5158
|
const result = await exec(args, env);
|
|
5034
5159
|
if (result.code !== 0) {
|
|
@@ -5053,7 +5178,7 @@ async function enrollToken(options) {
|
|
|
5053
5178
|
};
|
|
5054
5179
|
}
|
|
5055
5180
|
const token = generateToken();
|
|
5056
|
-
const enrolled = await
|
|
5181
|
+
const enrolled = await runCli(options.exec, ["--home", options.home, "set-token"], { HHOSTED_TOKEN: token });
|
|
5057
5182
|
if (enrolled.result === void 0) {
|
|
5058
5183
|
return {
|
|
5059
5184
|
token: null,
|
|
@@ -5066,7 +5191,7 @@ async function enrollToken(options) {
|
|
|
5066
5191
|
}
|
|
5067
5192
|
async function enrollFreshToken(options) {
|
|
5068
5193
|
const token = generateToken();
|
|
5069
|
-
const enrolled = await
|
|
5194
|
+
const enrolled = await runCli(options.exec, ["--home", options.home, "set-token"], { HHOSTED_TOKEN: token });
|
|
5070
5195
|
if (enrolled.result === void 0) {
|
|
5071
5196
|
return {
|
|
5072
5197
|
token: null,
|
|
@@ -7049,7 +7174,9 @@ var ENTRY_FIELDS = {
|
|
|
7049
7174
|
resources: { type: "object", additionalProperties: true, description: "Resource policy: maxRssBytes \u2014 restart when the tree exceeds it; 0 disables" },
|
|
7050
7175
|
backupPaths: { type: "array", items: { type: "string" }, description: "Paths included in backups for this entry (templates allowed)" },
|
|
7051
7176
|
logBufferLines: { type: "number", description: "Live log lines kept for this entry, 50..100000" },
|
|
7052
|
-
backupIgnoreGenerated: { type: "boolean", description: "Skip build output and dependency directories (node_modules, dist, caches) inside the declared backup paths" }
|
|
7177
|
+
backupIgnoreGenerated: { type: "boolean", description: "Skip build output and dependency directories (node_modules, dist, caches) inside the declared backup paths" },
|
|
7178
|
+
dump: { oneOf: [{ type: "object", additionalProperties: true }, { type: "null" }], description: "Backup hook run before this entry's data is captured: command, args, env, timeoutMs. Write into {dumpDir}; a failed dump refuses the backup \u2014 or null" },
|
|
7179
|
+
load: { oneOf: [{ type: "object", additionalProperties: true }, { type: "null" }], description: "Restore hook run on this entry's next start, once its port answers: command, args, env, timeoutMs, waitMs. Read from {dumpDir} \u2014 or null" }
|
|
7053
7180
|
};
|
|
7054
7181
|
var ENTRY_PARAM = {
|
|
7055
7182
|
type: "object",
|