dsh-home-hosted 0.4.1 → 0.4.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/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
 
package/lib/index.js CHANGED
@@ -2851,10 +2851,48 @@ function readdirNames(dir) {
2851
2851
  return [];
2852
2852
  }
2853
2853
  }
2854
+ function projectDshEntry(dir, depth = 4) {
2855
+ let current = resolveDir(dir);
2856
+ for (let hop = 0; hop <= depth; hop += 1) {
2857
+ const shim = path10.join(current, "node_modules", ".bin", "dsh");
2858
+ const shimmed = resolveShimmedCli(shim, process7.execPath);
2859
+ if (shimmed?.cliEntry != null && isEntry(shimmed.cliEntry))
2860
+ return shimmed.cliEntry;
2861
+ const found = dshCandidatesUnder(current).map((entry) => ({ entry, version: versionOf(entry) })).sort((a, b) => compareVersions(b.version ?? "0.0.0", a.version ?? "0.0.0"));
2862
+ if (found[0] !== void 0)
2863
+ return found[0].entry;
2864
+ const parent = path10.dirname(current);
2865
+ if (parent === current)
2866
+ break;
2867
+ current = parent;
2868
+ }
2869
+ return null;
2870
+ }
2871
+ function resolveDir(dir) {
2872
+ try {
2873
+ return fs12.realpathSync(dir);
2874
+ } catch {
2875
+ return path10.resolve(dir);
2876
+ }
2877
+ }
2854
2878
  async function resolveDshLaunch(options = {}) {
2855
2879
  const argv1 = options.argv1 === void 0 ? process7.argv[1] : options.argv1;
2856
2880
  const dshHome2 = options.dshHome ?? dshHome();
2857
2881
  const stateDir = options.stateDir ?? pluginStateDir();
2882
+ const projectDir = options.projectDir === void 0 ? process7.cwd() : options.projectDir;
2883
+ const declared = projectDir === null || projectDir.trim().length === 0 ? null : projectDshEntry(projectDir);
2884
+ if (declared !== null && realPath(declared) !== realPath(launcherFor(stateDir, declared))) {
2885
+ const launcher = writeDshLauncher({
2886
+ stateDir,
2887
+ dshHome: dshHome2,
2888
+ resolvedEntry: declared,
2889
+ pinnedEntry: declared,
2890
+ repin: true,
2891
+ entryExtension: path10.extname(declared),
2892
+ searchRoots: [dshSearchRoot(declared), projectDir].filter((root) => typeof root === "string" && root.length > 0)
2893
+ });
2894
+ return { program: process7.execPath, args: [declared], cliEntry: declared, shimPath: null, source: "entry", launcherPath: launcher.path };
2895
+ }
2858
2896
  const local = typeof argv1 === "string" && argv1.length > 0 ? launchedEntry(argv1) : null;
2859
2897
  if (local !== null && realPath(local) !== realPath(dshLauncherPath(stateDir, path10.extname(local)))) {
2860
2898
  const launcher = writeDshLauncher({
@@ -2895,6 +2933,9 @@ async function resolveDshLaunch(options = {}) {
2895
2933
  const launch = resolveShimmedCli(found, process7.execPath);
2896
2934
  return launch === null ? null : { ...launch, launcherPath: null };
2897
2935
  }
2936
+ function launcherFor(stateDir, entry) {
2937
+ return dshLauncherPath(stateDir, path10.extname(entry));
2938
+ }
2898
2939
  function buildCommand(launch, launcherPath2) {
2899
2940
  if (launcherPath2 !== null && launcherPath2.length > 0)
2900
2941
  return { command: process7.execPath, entryArgs: [launcherPath2] };
@@ -2909,13 +2950,24 @@ function entryScript(config) {
2909
2950
  const rest = config.args ?? [];
2910
2951
  return rest.find((arg) => path10.isAbsolute(arg)) ?? program;
2911
2952
  }
2912
- function pinnedScriptFor(launch) {
2953
+ function localDshCommand(projectDir, entry) {
2954
+ if (typeof projectDir !== "string" || projectDir.trim().length === 0 || entry === null)
2955
+ return null;
2956
+ const shim = resolveShimmedCli(path10.join(projectDir, "node_modules", ".bin", "dsh"))?.cliEntry ?? null;
2957
+ if (shim === null)
2958
+ return null;
2959
+ return realPath(shim) === realPath(entry) ? "dsh" : null;
2960
+ }
2961
+ function pinnedScriptFor(launch, projectDir) {
2913
2962
  if (launch === null)
2914
2963
  return null;
2964
+ const entry = launch.cliEntry ?? launch.args.find((arg) => path10.isAbsolute(arg)) ?? null;
2965
+ if (localDshCommand(projectDir, entry) === "dsh")
2966
+ return "dsh";
2915
2967
  const launcher = launch.launcherPath;
2916
2968
  if (launcher !== null && launcher.length > 0)
2917
2969
  return launcher;
2918
- return launch.cliEntry ?? launch.args.find((arg) => path10.isAbsolute(arg)) ?? null;
2970
+ return entry;
2919
2971
  }
2920
2972
  function isBareCommand(stored) {
2921
2973
  return !/[/\\]/.test(stored) && (stored === "dsh" || /^dsh\.(?:cmd|exe)$/i.test(stored));
@@ -2930,23 +2982,24 @@ function needsLauncherRepair(config) {
2930
2982
  return true;
2931
2983
  return (config.args ?? []).some((arg) => path10.isAbsolute(arg));
2932
2984
  }
2933
- function launcherRepair(config, launch) {
2934
- const pinned = pinnedScriptFor(launch);
2985
+ function launcherRepair(config, launch, options = {}) {
2986
+ const pinned = pinnedScriptFor(launch, options.projectDir);
2935
2987
  if (pinned === null || launch === null)
2936
2988
  return null;
2989
+ const targetBare = pinned === "dsh";
2990
+ const storedBare = isBareCommand(entryScript(config) ?? "");
2937
2991
  const stored = entryScript(config);
2938
2992
  if (stored === null || stored === pinned)
2939
2993
  return null;
2940
- const bare = isBareCommand(stored);
2941
- if (!bare) {
2994
+ if (!storedBare && options.ownedCommand !== true) {
2942
2995
  const target = resolveShimmedCli(stored, process7.execPath)?.cliEntry ?? stored;
2943
- if (target !== pinned && target !== launch.cliEntry)
2996
+ if (target !== launch.cliEntry && target !== pinned)
2944
2997
  return null;
2945
2998
  }
2946
2999
  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] };
3000
+ const script = storedBare ? -1 : args.findIndex((arg) => path10.isAbsolute(arg) || arg === pinned);
3001
+ const rest = script === -1 ? args : args.filter((_, index) => index !== script);
3002
+ return targetBare ? { command: "dsh", args: rest, ...typeof options.projectDir === "string" && options.projectDir.length > 0 ? { cwd: options.projectDir } : {} } : { command: process7.execPath, args: [pinned, ...rest] };
2950
3003
  }
2951
3004
  function buildDshEntry(facts) {
2952
3005
  const launch = facts.launch;
@@ -2964,7 +3017,9 @@ function buildDshEntry(facts) {
2964
3017
  );
2965
3018
  }
2966
3019
  const launcherArgs = profile === "web" ? ["web"] : ["--profile", profile];
2967
- const generated = launch === null ? { command: "dsh", entryArgs: [] } : buildCommand(launch, facts.launcherPath ?? null);
3020
+ const projectDir = facts.projectDir === void 0 || facts.projectDir === null ? process7.cwd() : facts.projectDir;
3021
+ const local = launch === null ? null : localDshCommand(projectDir, launch.cliEntry);
3022
+ const generated = launch === null || local === "dsh" ? { command: "dsh", entryArgs: [] } : buildCommand(launch, facts.launcherPath ?? null);
2968
3023
  return {
2969
3024
  id: facts.id,
2970
3025
  label: "DSH web",
@@ -2974,7 +3029,7 @@ function buildDshEntry(facts) {
2974
3029
  args: [...generated.entryArgs, ...launcherArgs, ...appArgs],
2975
3030
  bind: "local",
2976
3031
  port: facts.port,
2977
- cwd: process7.cwd(),
3032
+ cwd: projectDir,
2978
3033
  env: {},
2979
3034
  dataEnvs: { DSH_HOME: facts.dshHome },
2980
3035
  onPortConflict: "kill",
@@ -3205,6 +3260,11 @@ function ownedDrift(live, intent) {
3205
3260
  drift.push("stop.killPortHolders");
3206
3261
  return drift;
3207
3262
  }
3263
+ function isCreatedEntry(snapshot) {
3264
+ if (snapshot === void 0)
3265
+ return false;
3266
+ return Object.keys(snapshot).length === 1 && typeof snapshot.id === "string";
3267
+ }
3208
3268
  function snapshotOwned(live) {
3209
3269
  const killPortHolders = live.stop?.killPortHolders;
3210
3270
  return {
@@ -3588,13 +3648,19 @@ var HomeHostedService = class extends Service {
3588
3648
  tokenDetail = "";
3589
3649
  /** When a missing managed entry was last put back, so a poll cannot become a write loop. */
3590
3650
  entryRecoveryAt = 0;
3651
+ /** When a managed row was last re-pointed at the pinned dsh, for the same reason. */
3652
+ commandRepairAt = 0;
3591
3653
  /** The last panel inventory, so a model-step assembly never reads the disk. */
3592
3654
  instanceCache = null;
3593
3655
  snapshotsFile;
3594
- async resolveDsh(dshHome2) {
3656
+ projectDir() {
3657
+ return this.options.projectDir ?? process10.cwd();
3658
+ }
3659
+ async resolveDsh(dshHome2, projectDir = this.projectDir()) {
3595
3660
  return await (this.options.resolveDsh ?? ((options) => resolveDshLaunch(options)))({
3596
3661
  dshHome: dshHome2,
3597
- stateDir: this.options.stateDir
3662
+ stateDir: this.options.stateDir,
3663
+ projectDir
3598
3664
  });
3599
3665
  }
3600
3666
  // -------------------------------------------------------------------------
@@ -4030,7 +4096,8 @@ var HomeHostedService = class extends Service {
4030
4096
  profile: detectProfile(process10.argv, process10.env),
4031
4097
  dshHome: harnessHome,
4032
4098
  launch: dsh,
4033
- launcherPath: dsh?.launcherPath ?? null
4099
+ launcherPath: dsh?.launcherPath ?? null,
4100
+ projectDir: this.projectDir()
4034
4101
  });
4035
4102
  return { ...generated, ...patch };
4036
4103
  }
@@ -4045,7 +4112,8 @@ var HomeHostedService = class extends Service {
4045
4112
  return null;
4046
4113
  const harnessHome = process10.env.DSH_HOME ?? dshHome();
4047
4114
  const dsh = await this.resolveDsh(harnessHome);
4048
- return launcherRepair(live, dsh);
4115
+ const owned = isCreatedEntry(this.snapshots()[live.id]);
4116
+ return launcherRepair(live, dsh, { ownedCommand: owned, projectDir: this.projectDir() });
4049
4117
  }
4050
4118
  async writeOwned(intent) {
4051
4119
  const live = (await this.liveEntries()).get(intent.id) ?? null;
@@ -4477,6 +4545,7 @@ var HomeHostedService = class extends Service {
4477
4545
  */
4478
4546
  async reconcile() {
4479
4547
  await this.ensureManagedEntry();
4548
+ await this.repairManagedCommand();
4480
4549
  await this.repairBootEntry();
4481
4550
  await this.migrateBootEntryName();
4482
4551
  }
@@ -4525,6 +4594,34 @@ var HomeHostedService = class extends Service {
4525
4594
  } catch {
4526
4595
  }
4527
4596
  }
4597
+ /**
4598
+ * Re-point a managed `dsh` row that runs the wrong dsh.
4599
+ *
4600
+ * A row created before the launcher existed, or by an older release that took
4601
+ * whatever PATH answered, keeps starting that copy — and no click would fix
4602
+ * it, because the plugin only writes the row when the toggle is applied. So
4603
+ * the drift is repaired where the entry's existence is already checked, under
4604
+ * the same throttle, and only for a row this plugin owns.
4605
+ */
4606
+ async repairManagedCommand() {
4607
+ const id = this.options.defaultEntryId;
4608
+ if (!this.options.settings.get().manageDsh || !this.options.settings.intentFor(id).autostart)
4609
+ return;
4610
+ const now = Date.now();
4611
+ if (now - this.commandRepairAt < ENTRY_RECOVERY_INTERVAL_MS)
4612
+ return;
4613
+ const live = (await this.liveEntries()).get(id)?.config ?? null;
4614
+ if (live === null || this.snapshots()[id] === void 0 || !needsLauncherRepair(live))
4615
+ return;
4616
+ const repair = await this.dshCommandRepair(live);
4617
+ if (repair === null)
4618
+ return;
4619
+ this.commandRepairAt = now;
4620
+ try {
4621
+ await this.writeOwned({ ...this.options.settings.intentFor(id), autostart: true });
4622
+ } catch {
4623
+ }
4624
+ }
4528
4625
  /** Re-assert the boot entry when the OS still has it but it stopped working. */
4529
4626
  async repairBootEntry() {
4530
4627
  if (!this.options.settings.get().autostart.enabled)
@@ -4578,7 +4675,7 @@ var HomeHostedService = class extends Service {
4578
4675
  boot: await this.bootStatus(),
4579
4676
  // A deleted managed entry is put back here as well as at startup: whoever
4580
4677
  // deleted it is looking at the page that reports it missing.
4581
- entries: await this.ensureManagedEntry().then(async () => await this.entriesStatus()),
4678
+ entries: await this.ensureManagedEntry().then(async () => await this.repairManagedCommand()).then(async () => await this.entriesStatus()),
4582
4679
  servers,
4583
4680
  settings: this.options.settings.get(),
4584
4681
  cli,