dsh-home-hosted 0.4.1 → 0.4.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/docs/DESIGN.md CHANGED
@@ -62,10 +62,27 @@ merely offers `lib/bin.js` (a clone), which beats a `dsh` on PATH. A clone has
62
62
  no manifest, so it is found by that build-file name — a guess that must never
63
63
  outrank a real install.
64
64
 
65
- The row's stored command is repaired to that launcher while the plugin runs
66
- (`launcherRepair`), including a row that names a bare `dsh`: PATH is exactly what
67
- pointed at the copy that stopped working. An entry that runs a different absolute
68
- dsh is somebody's deliberate choice and is never rewritten.
65
+ The row's stored command is repaired while the plugin runs (`launcherRepair`),
66
+ including a row that names a bare `dsh`: PATH is exactly what pointed at the copy
67
+ that stopped working. A row this plugin created has its command replaced; an
68
+ adopted row that deliberately runs a different absolute dsh is left alone.
69
+
70
+ ### A project that depends on dsh keeps it, by name
71
+
72
+ The launcher is the fallback, not the common case. `home-hosted` resolves a bare
73
+ command itself (`resolveCommand(command, cwd, projectDir)` in its process
74
+ provider) by checking `node_modules/.bin/<command>` under the row's cwd and then
75
+ its own project directory, and only then PATH. So a row whose project installed
76
+ dsh as a dependency is written as a bare `dsh` with that project as its cwd: it
77
+ runs the copy the project's own lockfile chose, through the shim its own package
78
+ manager wrote, with nothing absolute to move when the project or the store does.
79
+
80
+ That is also why this plugin never shells out to a package manager for it:
81
+ `npx dsh` falls back to the registry and to a global when the local bin is
82
+ missing, `pnpm dsh` needs pnpm on the boot PATH, and `nlx --local` would add a
83
+ global tool to a boot path for a lookup the panel already performs. The launcher
84
+ is still what a row gets when a bare name would *not* reach the pinned image —
85
+ a clone run without its own `.bin`, or a global-only machine.
69
86
 
70
87
  ## One panel root and one boot artifact per state root
71
88
 
@@ -78,7 +95,10 @@ explicit `$HHOSTED_HOME` still wins, and a panel already living at
78
95
  every server look like it vanished), but otherwise the root is the instance's own
79
96
  `<stateDir>/panel`. Only a root with a `servers.config.json` counts, or a
80
97
  `run.json` whose pid is alive — a killed panel leaves a runtime file behind, and
81
- adopting on that would point a fresh install at somebody's dead root.
98
+ adopting on that would point a fresh install at somebody's dead root. Adoption is
99
+ also limited to an instance that already exists: the machine's own harness home,
100
+ or a state dir that already holds settings. A scratch `DSH_HOME` or a test
101
+ project must not reach over and drive the panel some other install owns.
82
102
 
83
103
  The boot artifact name follows the state root too, but keyed on the *machine*
84
104
  default: `home-hosted` only for `~/.dsh/dsh-home-hosted`, `home-hosted-<hash>`
package/lib/index.js CHANGED
@@ -1989,10 +1989,20 @@ function resolveHomeHostedHome(stateDir, env = process.env, homeDir = os3.homedi
1989
1989
  const configured = env.HHOSTED_HOME?.trim();
1990
1990
  if (configured)
1991
1991
  return { home: resolveUnder(configured, homeDir), source: "env" };
1992
+ const state = resolveUnder(stateDir, homeDir);
1992
1993
  const legacy = path6.join(homeDir, ".home-hosted");
1993
- if (hasLivePanel(legacy))
1994
+ if (isEstablishedInstance(state, homeDir) && hasLivePanel(legacy))
1994
1995
  return { home: legacy, source: "legacy" };
1995
- return { home: path6.join(resolveUnder(stateDir, homeDir), "panel"), source: "instance" };
1996
+ return { home: path6.join(state, "panel"), source: "instance" };
1997
+ }
1998
+ function isEstablishedInstance(stateDir, homeDir) {
1999
+ if (stateDir === path6.join(homeDir, ".dsh", "dsh-home-hosted"))
2000
+ return true;
2001
+ try {
2002
+ return fs7.statSync(path6.join(stateDir, "settings.json")).isFile();
2003
+ } catch {
2004
+ return false;
2005
+ }
1996
2006
  }
1997
2007
  function hasLivePanel(home) {
1998
2008
  try {
@@ -2851,10 +2861,48 @@ function readdirNames(dir) {
2851
2861
  return [];
2852
2862
  }
2853
2863
  }
2864
+ function projectDshEntry(dir, depth = 4) {
2865
+ let current = resolveDir(dir);
2866
+ for (let hop = 0; hop <= depth; hop += 1) {
2867
+ const shim = path10.join(current, "node_modules", ".bin", "dsh");
2868
+ const shimmed = resolveShimmedCli(shim, process7.execPath);
2869
+ if (shimmed?.cliEntry != null && isEntry(shimmed.cliEntry))
2870
+ return shimmed.cliEntry;
2871
+ const found = dshCandidatesUnder(current).map((entry) => ({ entry, version: versionOf(entry) })).sort((a, b) => compareVersions(b.version ?? "0.0.0", a.version ?? "0.0.0"));
2872
+ if (found[0] !== void 0)
2873
+ return found[0].entry;
2874
+ const parent = path10.dirname(current);
2875
+ if (parent === current)
2876
+ break;
2877
+ current = parent;
2878
+ }
2879
+ return null;
2880
+ }
2881
+ function resolveDir(dir) {
2882
+ try {
2883
+ return fs12.realpathSync(dir);
2884
+ } catch {
2885
+ return path10.resolve(dir);
2886
+ }
2887
+ }
2854
2888
  async function resolveDshLaunch(options = {}) {
2855
2889
  const argv1 = options.argv1 === void 0 ? process7.argv[1] : options.argv1;
2856
2890
  const dshHome2 = options.dshHome ?? dshHome();
2857
2891
  const stateDir = options.stateDir ?? pluginStateDir();
2892
+ const projectDir = options.projectDir === void 0 ? process7.cwd() : options.projectDir;
2893
+ const declared = projectDir === null || projectDir.trim().length === 0 ? null : projectDshEntry(projectDir);
2894
+ if (declared !== null && realPath(declared) !== realPath(launcherFor(stateDir, declared))) {
2895
+ const launcher = writeDshLauncher({
2896
+ stateDir,
2897
+ dshHome: dshHome2,
2898
+ resolvedEntry: declared,
2899
+ pinnedEntry: declared,
2900
+ repin: true,
2901
+ entryExtension: path10.extname(declared),
2902
+ searchRoots: [dshSearchRoot(declared), projectDir].filter((root) => typeof root === "string" && root.length > 0)
2903
+ });
2904
+ return { program: process7.execPath, args: [declared], cliEntry: declared, shimPath: null, source: "entry", launcherPath: launcher.path };
2905
+ }
2858
2906
  const local = typeof argv1 === "string" && argv1.length > 0 ? launchedEntry(argv1) : null;
2859
2907
  if (local !== null && realPath(local) !== realPath(dshLauncherPath(stateDir, path10.extname(local)))) {
2860
2908
  const launcher = writeDshLauncher({
@@ -2895,6 +2943,9 @@ async function resolveDshLaunch(options = {}) {
2895
2943
  const launch = resolveShimmedCli(found, process7.execPath);
2896
2944
  return launch === null ? null : { ...launch, launcherPath: null };
2897
2945
  }
2946
+ function launcherFor(stateDir, entry) {
2947
+ return dshLauncherPath(stateDir, path10.extname(entry));
2948
+ }
2898
2949
  function buildCommand(launch, launcherPath2) {
2899
2950
  if (launcherPath2 !== null && launcherPath2.length > 0)
2900
2951
  return { command: process7.execPath, entryArgs: [launcherPath2] };
@@ -2909,13 +2960,24 @@ function entryScript(config) {
2909
2960
  const rest = config.args ?? [];
2910
2961
  return rest.find((arg) => path10.isAbsolute(arg)) ?? program;
2911
2962
  }
2912
- function pinnedScriptFor(launch) {
2963
+ function localDshCommand(projectDir, entry) {
2964
+ if (typeof projectDir !== "string" || projectDir.trim().length === 0 || entry === null)
2965
+ return null;
2966
+ const shim = resolveShimmedCli(path10.join(projectDir, "node_modules", ".bin", "dsh"))?.cliEntry ?? null;
2967
+ if (shim === null)
2968
+ return null;
2969
+ return realPath(shim) === realPath(entry) ? "dsh" : null;
2970
+ }
2971
+ function pinnedScriptFor(launch, projectDir) {
2913
2972
  if (launch === null)
2914
2973
  return null;
2974
+ const entry = launch.cliEntry ?? launch.args.find((arg) => path10.isAbsolute(arg)) ?? null;
2975
+ if (localDshCommand(projectDir, entry) === "dsh")
2976
+ return "dsh";
2915
2977
  const launcher = launch.launcherPath;
2916
2978
  if (launcher !== null && launcher.length > 0)
2917
2979
  return launcher;
2918
- return launch.cliEntry ?? launch.args.find((arg) => path10.isAbsolute(arg)) ?? null;
2980
+ return entry;
2919
2981
  }
2920
2982
  function isBareCommand(stored) {
2921
2983
  return !/[/\\]/.test(stored) && (stored === "dsh" || /^dsh\.(?:cmd|exe)$/i.test(stored));
@@ -2930,23 +2992,24 @@ function needsLauncherRepair(config) {
2930
2992
  return true;
2931
2993
  return (config.args ?? []).some((arg) => path10.isAbsolute(arg));
2932
2994
  }
2933
- function launcherRepair(config, launch) {
2934
- const pinned = pinnedScriptFor(launch);
2995
+ function launcherRepair(config, launch, options = {}) {
2996
+ const pinned = pinnedScriptFor(launch, options.projectDir);
2935
2997
  if (pinned === null || launch === null)
2936
2998
  return null;
2999
+ const targetBare = pinned === "dsh";
3000
+ const storedBare = isBareCommand(entryScript(config) ?? "");
2937
3001
  const stored = entryScript(config);
2938
3002
  if (stored === null || stored === pinned)
2939
3003
  return null;
2940
- const bare = isBareCommand(stored);
2941
- if (!bare) {
3004
+ if (!storedBare && options.ownedCommand !== true) {
2942
3005
  const target = resolveShimmedCli(stored, process7.execPath)?.cliEntry ?? stored;
2943
- if (target !== pinned && target !== launch.cliEntry)
3006
+ if (target !== launch.cliEntry && target !== pinned)
2944
3007
  return null;
2945
3008
  }
2946
3009
  const args = config.args ?? [];
2947
- const inline = bare ? -1 : args.findIndex((arg) => path10.isAbsolute(arg));
2948
- const rest = inline === -1 ? args : args.filter((_, index) => index !== inline);
2949
- return { command: process7.execPath, args: [pinned, ...rest] };
3010
+ const script = storedBare ? -1 : args.findIndex((arg) => path10.isAbsolute(arg) || arg === pinned);
3011
+ const rest = script === -1 ? args : args.filter((_, index) => index !== script);
3012
+ return targetBare ? { command: "dsh", args: rest, ...typeof options.projectDir === "string" && options.projectDir.length > 0 ? { cwd: options.projectDir } : {} } : { command: process7.execPath, args: [pinned, ...rest] };
2950
3013
  }
2951
3014
  function buildDshEntry(facts) {
2952
3015
  const launch = facts.launch;
@@ -2964,7 +3027,9 @@ function buildDshEntry(facts) {
2964
3027
  );
2965
3028
  }
2966
3029
  const launcherArgs = profile === "web" ? ["web"] : ["--profile", profile];
2967
- const generated = launch === null ? { command: "dsh", entryArgs: [] } : buildCommand(launch, facts.launcherPath ?? null);
3030
+ const projectDir = facts.projectDir === void 0 || facts.projectDir === null ? process7.cwd() : facts.projectDir;
3031
+ const local = launch === null ? null : localDshCommand(projectDir, launch.cliEntry);
3032
+ const generated = launch === null || local === "dsh" ? { command: "dsh", entryArgs: [] } : buildCommand(launch, facts.launcherPath ?? null);
2968
3033
  return {
2969
3034
  id: facts.id,
2970
3035
  label: "DSH web",
@@ -2974,7 +3039,7 @@ function buildDshEntry(facts) {
2974
3039
  args: [...generated.entryArgs, ...launcherArgs, ...appArgs],
2975
3040
  bind: "local",
2976
3041
  port: facts.port,
2977
- cwd: process7.cwd(),
3042
+ cwd: projectDir,
2978
3043
  env: {},
2979
3044
  dataEnvs: { DSH_HOME: facts.dshHome },
2980
3045
  onPortConflict: "kill",
@@ -3205,6 +3270,11 @@ function ownedDrift(live, intent) {
3205
3270
  drift.push("stop.killPortHolders");
3206
3271
  return drift;
3207
3272
  }
3273
+ function isCreatedEntry(snapshot) {
3274
+ if (snapshot === void 0)
3275
+ return false;
3276
+ return Object.keys(snapshot).length === 1 && typeof snapshot.id === "string";
3277
+ }
3208
3278
  function snapshotOwned(live) {
3209
3279
  const killPortHolders = live.stop?.killPortHolders;
3210
3280
  return {
@@ -3588,13 +3658,19 @@ var HomeHostedService = class extends Service {
3588
3658
  tokenDetail = "";
3589
3659
  /** When a missing managed entry was last put back, so a poll cannot become a write loop. */
3590
3660
  entryRecoveryAt = 0;
3661
+ /** When a managed row was last re-pointed at the pinned dsh, for the same reason. */
3662
+ commandRepairAt = 0;
3591
3663
  /** The last panel inventory, so a model-step assembly never reads the disk. */
3592
3664
  instanceCache = null;
3593
3665
  snapshotsFile;
3594
- async resolveDsh(dshHome2) {
3666
+ projectDir() {
3667
+ return this.options.projectDir ?? process10.cwd();
3668
+ }
3669
+ async resolveDsh(dshHome2, projectDir = this.projectDir()) {
3595
3670
  return await (this.options.resolveDsh ?? ((options) => resolveDshLaunch(options)))({
3596
3671
  dshHome: dshHome2,
3597
- stateDir: this.options.stateDir
3672
+ stateDir: this.options.stateDir,
3673
+ projectDir
3598
3674
  });
3599
3675
  }
3600
3676
  // -------------------------------------------------------------------------
@@ -4030,7 +4106,8 @@ var HomeHostedService = class extends Service {
4030
4106
  profile: detectProfile(process10.argv, process10.env),
4031
4107
  dshHome: harnessHome,
4032
4108
  launch: dsh,
4033
- launcherPath: dsh?.launcherPath ?? null
4109
+ launcherPath: dsh?.launcherPath ?? null,
4110
+ projectDir: this.projectDir()
4034
4111
  });
4035
4112
  return { ...generated, ...patch };
4036
4113
  }
@@ -4045,7 +4122,8 @@ var HomeHostedService = class extends Service {
4045
4122
  return null;
4046
4123
  const harnessHome = process10.env.DSH_HOME ?? dshHome();
4047
4124
  const dsh = await this.resolveDsh(harnessHome);
4048
- return launcherRepair(live, dsh);
4125
+ const owned = isCreatedEntry(this.snapshots()[live.id]);
4126
+ return launcherRepair(live, dsh, { ownedCommand: owned, projectDir: this.projectDir() });
4049
4127
  }
4050
4128
  async writeOwned(intent) {
4051
4129
  const live = (await this.liveEntries()).get(intent.id) ?? null;
@@ -4477,6 +4555,7 @@ var HomeHostedService = class extends Service {
4477
4555
  */
4478
4556
  async reconcile() {
4479
4557
  await this.ensureManagedEntry();
4558
+ await this.repairManagedCommand();
4480
4559
  await this.repairBootEntry();
4481
4560
  await this.migrateBootEntryName();
4482
4561
  }
@@ -4525,6 +4604,34 @@ var HomeHostedService = class extends Service {
4525
4604
  } catch {
4526
4605
  }
4527
4606
  }
4607
+ /**
4608
+ * Re-point a managed `dsh` row that runs the wrong dsh.
4609
+ *
4610
+ * A row created before the launcher existed, or by an older release that took
4611
+ * whatever PATH answered, keeps starting that copy — and no click would fix
4612
+ * it, because the plugin only writes the row when the toggle is applied. So
4613
+ * the drift is repaired where the entry's existence is already checked, under
4614
+ * the same throttle, and only for a row this plugin owns.
4615
+ */
4616
+ async repairManagedCommand() {
4617
+ const id = this.options.defaultEntryId;
4618
+ if (!this.options.settings.get().manageDsh || !this.options.settings.intentFor(id).autostart)
4619
+ return;
4620
+ const now = Date.now();
4621
+ if (now - this.commandRepairAt < ENTRY_RECOVERY_INTERVAL_MS)
4622
+ return;
4623
+ const live = (await this.liveEntries()).get(id)?.config ?? null;
4624
+ if (live === null || this.snapshots()[id] === void 0 || !needsLauncherRepair(live))
4625
+ return;
4626
+ const repair = await this.dshCommandRepair(live);
4627
+ if (repair === null)
4628
+ return;
4629
+ this.commandRepairAt = now;
4630
+ try {
4631
+ await this.writeOwned({ ...this.options.settings.intentFor(id), autostart: true });
4632
+ } catch {
4633
+ }
4634
+ }
4528
4635
  /** Re-assert the boot entry when the OS still has it but it stopped working. */
4529
4636
  async repairBootEntry() {
4530
4637
  if (!this.options.settings.get().autostart.enabled)
@@ -4578,7 +4685,7 @@ var HomeHostedService = class extends Service {
4578
4685
  boot: await this.bootStatus(),
4579
4686
  // A deleted managed entry is put back here as well as at startup: whoever
4580
4687
  // deleted it is looking at the page that reports it missing.
4581
- entries: await this.ensureManagedEntry().then(async () => await this.entriesStatus()),
4688
+ entries: await this.ensureManagedEntry().then(async () => await this.repairManagedCommand()).then(async () => await this.entriesStatus()),
4582
4689
  servers,
4583
4690
  settings: this.options.settings.get(),
4584
4691
  cli,