dsh-home-hosted 0.9.3 → 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 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 needs
46
- `home-hosted migrate` before this plugin will write it; see [PANEL](PANEL.md#upgrading-an-existing-state-root).
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. Run the panel's own migration once, before letting
91
- 0.9 — or this plugin — write it:
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
- home-hosted migrate --dry-run # print the steps, write nothing
95
- home-hosted migrate --yes # keep a .bak beside each rewritten file
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
- Both sides are then on schema 2, and every write stays readable by the release that pinned it.
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.4";
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 = `function launchChild(program, argv, useShell) {
3421
- const child = spawn(program, argv, { stdio: 'inherit', shell: useShell })
3422
- for (const signal of ['SIGTERM', 'SIGINT', 'SIGQUIT', 'SIGHUP'])
3423
- process.on(signal, () => { try { child.kill(signal) } catch {} })
3424
- child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
3425
- child.on('error', (error) => {
3426
- console.error('[dsh-home-hosted] could not start ' + program + ': ' + error.message)
3427
- process.exit(1)
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
- if (isScript)
3635
- launchChild(process.execPath, [entry, ...args], false)
3636
- else
3637
- launchChild(entry, args, process.platform === 'win32' && /\\.(cmd|bat)$/i.test(entry))
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
- if (/\\.(mjs|cjs|js)$/.test(entry))
3846
- launchChild(process.execPath, [entry, ...args], false)
3847
- else
3848
- launchChild(entry, args, process.platform === 'win32' && /\\.(cmd|bat)$/i.test(entry))
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, command) {
4478
- const args = [...deps.launch?.args ?? [], command, "--home", deps.home];
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 runCli(deps, command) {
4484
- const args = cliArgs(deps, command);
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 runCli(deps, "up");
4497
- if (result.code !== 0) {
4498
- const detail = result.stderr.trim() || result.stdout.trim() || `the CLI exited ${String(result.code)}`;
4499
- return { ok: false, detail };
4500
- }
4501
- const match = /https?:\/\/[^\s]+/.exec(result.stdout);
4502
- return { ok: true, detail: "the panel is answering", url: match?.[0] ?? null };
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 runCli(deps, "down");
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 runCli2(exec, args, env) {
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 runCli2(options.exec, ["--home", options.home, "set-token"], { HHOSTED_TOKEN: token });
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 runCli2(options.exec, ["--home", options.home, "set-token"], { HHOSTED_TOKEN: token });
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",