@fonlan/dsh-quick-commands 0.1.7 → 0.1.8

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/lib/client.js CHANGED
@@ -825,7 +825,7 @@ window.__ModuleLoader__.load({
825
825
  });
826
826
  }
827
827
  //#endregion
828
- //#region \0dsh-css:/home/runner/work/dsh-quick-commands/dsh-quick-commands/src/client/settings-section.css.mjs
828
+ //#region \0dsh-css:/Users/fonlan/Repos/dsh-quick-commands/src/client/settings-section.css.mjs
829
829
  const css$1 = "/**\n * Styles for the quick-commands settings page.\n *\n * The page occupies the settings panel content column: a static header\n * (title plus a one-line summary) over the always-visible command editor.\n * Inner controls follow the built-in settings language.\n */\n\n/* page layout */\n\n.qc-page {\n display: flex;\n flex-direction: column;\n gap: 16px;\n max-width: 720px;\n}\n.qc-page-head {\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n.qc-page-title {\n margin: 0;\n color: var(--dsw-alias-label-primary, #f2f2f2);\n font-size: 15px;\n font-weight: 600;\n line-height: 1.4;\n}\n.qc-page-sub {\n margin: 0;\n color: var(--dsw-alias-label-secondary, #cfd3d6);\n font-size: 13px;\n line-height: 1.5;\n}\n\n/* body: workspace / command-set editor */\n\n.qc-settings-body {\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n.qc-settings-intro {\n font-size: 12px;\n color: var(--dsw-alias-label-secondary, #cfd3d6);\n margin: 0;\n line-height: 1.5;\n}\n.qc-settings-anchor {\n display: flex;\n align-items: center;\n gap: 12px;\n font-size: 12px;\n}\n.qc-settings-anchor-label {\n color: var(--dsw-alias-label-primary, #f2f2f2);\n font-weight: 500;\n}\n.qc-settings-radio {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n color: var(--dsw-alias-label-secondary, #cfd3d6);\n cursor: pointer;\n}\n.qc-settings-error {\n font-size: 12px;\n color: var(--dsw-alias-state-error-primary, #e5484d);\n}\n.qc-settings-empty {\n font-size: 12px;\n color: var(--dsw-alias-label-secondary, #cfd3d6);\n margin: 0;\n}\n.qc-settings-workspace {\n border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));\n border-radius: 10px;\n padding: 10px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n.qc-settings-workspace-head {\n display: flex;\n align-items: baseline;\n gap: 8px;\n}\n.qc-settings-workspace-title {\n font-size: 13px;\n font-weight: 600;\n color: var(--dsw-alias-label-primary, #f2f2f2);\n}\n.qc-settings-workspace-path {\n font-size: 11px;\n font-family: var(--dsw-font-family-mono, monospace);\n color: var(--dsw-alias-label-tertiary, #adb2b8);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.qc-settings-no-commands {\n font-size: 12px;\n color: var(--dsw-alias-label-tertiary, #adb2b8);\n margin: 0;\n}\n.qc-settings-command {\n display: flex;\n gap: 6px;\n align-items: center;\n}\n.qc-settings-field {\n border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));\n border-radius: 8px;\n background: var(--dsw-alias-bg-layer-1, #2a2a2d);\n color: var(--dsw-alias-label-primary, #f2f2f2);\n font-size: 12px;\n padding: 6px 8px;\n outline: none;\n}\n.qc-settings-field::placeholder {\n color: var(--dsw-alias-label-tertiary, #adb2b8);\n}\n.qc-settings-field:focus {\n border-color: var(--dsw-alias-brand-primary, #4a9eff);\n}\n.qc-settings-field-name {\n width: 160px;\n flex: none;\n}\n.qc-settings-field-command {\n flex: 1;\n font-family: var(--dsw-font-family-mono, monospace);\n}\n.qc-settings-icon-btn {\n border: 0;\n background: transparent;\n color: var(--dsw-alias-label-tertiary, #adb2b8);\n cursor: pointer;\n padding: 4px;\n display: inline-flex;\n align-items: center;\n border-radius: 6px;\n}\n.qc-settings-icon-btn:hover {\n background: color-mix(in srgb, var(--dsw-alias-label-primary, #f2f2f2) 10%, transparent);\n color: var(--dsw-alias-state-error-primary, #e5484d);\n}\n.qc-settings-add-btn {\n align-self: flex-start;\n display: inline-flex;\n align-items: center;\n gap: 4px;\n border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));\n border-radius: 8px;\n background: transparent;\n color: var(--dsw-alias-label-primary, #f2f2f2);\n font-size: 12px;\n padding: 4px 10px;\n cursor: pointer;\n}\n.qc-settings-add-btn:hover {\n background: color-mix(in srgb, var(--dsw-alias-label-primary, #f2f2f2) 10%, transparent);\n}\n";
830
830
  const tagId$1 = "@fonlan/dsh-quick-commands/settings-section.css#d7004149";
831
831
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]") === null) {
@@ -1152,7 +1152,7 @@ window.__ModuleLoader__.load({
1152
1152
  cardAnchorButton: "Anchored at button"
1153
1153
  };
1154
1154
  //#endregion
1155
- //#region \0dsh-css:/home/runner/work/dsh-quick-commands/dsh-quick-commands/src/client/quick-commands.css.mjs
1155
+ //#region \0dsh-css:/Users/fonlan/Repos/dsh-quick-commands/src/client/quick-commands.css.mjs
1156
1156
  const css = "/* @fonlan/dsh-quick-commands client styles.\n Uses theme CSS variables (--dsw-alias-*); falls back to neutral literals.\n Constraint: this deployment's theme defines only these text tokens —\n label-primary, label-secondary, label-tertiary, label-dimmed. READABLE\n description copy uses label-secondary / label-tertiary (never label-dimmed,\n which is intentionally near-invisible and reserved for disabled states). */\n\n/* ── header button (icon-only, left of Session log pill) ─────────────────── */\n.qc-head-wrap {\n position: relative;\n display: inline-flex;\n align-items: center;\n}\n/* Sized to DSH built-in header buttons (e.g. the open-in-app split button on\n the left): 26px tall pill, 13px radius, hairline border-l4, hover uses the\n shared --dsw-alias-interactive-bg-hover token. */\n.qc-head-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 26px;\n border: 0.5px solid var(--dsw-alias-border-l4, rgba(128, 128, 128, 0.35));\n border-radius: 13px;\n background: transparent;\n color: var(--dsw-alias-label-primary, #f2f2f2);\n cursor: pointer;\n padding: 0;\n}\n.qc-head-btn:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, var(--dsw-alias-label-primary, #f2f2f2) 10%, transparent));\n}\n.qc-head-btn:disabled {\n color: var(--dsw-alias-label-dimmed, #666);\n cursor: not-allowed;\n}\n.qc-head-btn svg {\n flex: none;\n}\n\n/* ── command menu popover (anchored under the button) ────────────────────── */\n.qc-menu {\n position: absolute;\n top: calc(100% + 6px);\n right: 12px;\n min-width: 280px;\n max-height: 420px;\n overflow: auto;\n background: var(--dsw-alias-bg-overlay, #1b1b1c);\n border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));\n border-radius: 10px;\n box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);\n z-index: 50;\n padding: 6px;\n display: flex;\n flex-direction: column;\n}\n.qc-menu-head {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 4px 8px 6px;\n border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.25));\n}\n.qc-menu-title {\n font-size: 12px;\n font-weight: 600;\n color: var(--dsw-alias-label-primary, #f2f2f2);\n}\n.qc-menu-ws {\n flex: 1;\n font-size: 11px;\n color: var(--dsw-alias-label-tertiary, #adb2b8);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-align: right;\n margin-right: 2px;\n}\n.qc-menu-remote {\n flex: 0 0 auto;\n font-size: 10px;\n font-weight: 600;\n line-height: 1;\n padding: 2px 5px;\n border-radius: 5px;\n color: var(--dsw-alias-fg-on-accent, #fff);\n background: var(--dsw-alias-accent, #4a8dff);\n letter-spacing: 0.04em;\n}\n.qc-menu-close {\n border: 0;\n background: transparent;\n color: var(--dsw-alias-label-tertiary, #adb2b8);\n cursor: pointer;\n padding: 2px;\n display: inline-flex;\n align-items: center;\n}\n.qc-menu-empty {\n font-size: 12px;\n color: var(--dsw-alias-label-tertiary, #adb2b8);\n padding: 10px 8px;\n margin: 0;\n}\n.qc-menu-item {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 2px;\n text-align: left;\n border: 0;\n background: transparent;\n border-radius: 8px;\n padding: 6px 8px;\n cursor: pointer;\n width: 100%;\n}\n.qc-menu-item:hover:not(:disabled) {\n background: color-mix(in srgb, var(--dsw-alias-label-primary, #f2f2f2) 10%, transparent);\n}\n.qc-menu-item:disabled {\n opacity: 0.6;\n cursor: wait;\n}\n.qc-menu-item-name {\n font-size: 13px;\n font-weight: 500;\n color: var(--dsw-alias-label-primary, #f2f2f2);\n}\n.qc-menu-item-cmd {\n font-family: var(--dsw-font-family-mono, monospace);\n font-size: 11px;\n color: var(--dsw-alias-label-secondary, #cfd3d6);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n max-width: 100%;\n}\n\n/* ── live output popup ───────────────────────────────────────────────────── */\n.qc-popup {\n position: relative; /* anchors the absolutely-positioned resize grip */\n display: flex;\n flex-direction: column;\n background: var(--dsw-alias-bg-overlay, #1b1b1c);\n border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));\n border-radius: 12px;\n box-shadow: 0 12px 36px rgba(0, 0, 0, 0.45);\n z-index: 60;\n overflow: hidden;\n}\n.qc-popup-corner {\n position: fixed;\n right: 20px;\n bottom: 20px;\n width: 520px;\n max-width: calc(100vw - 40px);\n height: 340px;\n max-height: calc(100vh - 120px);\n}\n.qc-popup-button {\n position: absolute;\n top: calc(100% + 6px);\n right: 12px;\n width: 440px;\n max-width: calc(100vw - 40px);\n height: 300px;\n max-height: calc(100vh - 120px);\n}\n.qc-popup-head {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 10px;\n border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.25));\n}\n.qc-popup-title {\n flex: 1;\n font-size: 12px;\n font-weight: 600;\n color: var(--dsw-alias-label-primary, #f2f2f2);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.qc-popup-cmd {\n font-family: var(--dsw-font-family-mono, monospace);\n font-size: 11px;\n color: var(--dsw-alias-label-secondary, #cfd3d6);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n max-width: 40%;\n}\n.qc-popup-remote {\n flex: 0 0 auto;\n font-size: 10px;\n font-weight: 600;\n line-height: 1;\n padding: 3px 6px;\n border-radius: 6px;\n color: var(--dsw-alias-fg-on-accent, #fff);\n background: var(--dsw-alias-accent, #4a8dff);\n letter-spacing: 0.04em;\n}\n.qc-popup-icon {\n border: 0;\n background: transparent;\n color: var(--dsw-alias-label-tertiary, #adb2b8);\n cursor: pointer;\n padding: 4px;\n display: inline-flex;\n align-items: center;\n border-radius: 6px;\n}\n.qc-popup-icon:hover:not(:disabled) {\n background: color-mix(in srgb, var(--dsw-alias-label-primary, #f2f2f2) 10%, transparent);\n}\n.qc-popup-icon:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n}\n.qc-popup-kill {\n color: var(--dsw-alias-state-error-primary, #e5484d);\n}\n.qc-popup-confirm {\n display: flex;\n align-items: center;\n gap: 8px;\n font-size: 12px;\n padding: 8px 10px;\n background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f5a623) 12%, transparent);\n color: var(--dsw-alias-label-primary, #f2f2f2);\n border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.25));\n}\n.qc-popup-btn {\n border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));\n border-radius: 8px;\n background: transparent;\n color: var(--dsw-alias-label-primary, #f2f2f2);\n font-size: 12px;\n padding: 4px 10px;\n cursor: pointer;\n}\n.qc-popup-btn:hover {\n background: color-mix(in srgb, var(--dsw-alias-label-primary, #f2f2f2) 10%, transparent);\n}\n.qc-popup-btn-plain {\n color: var(--dsw-alias-label-secondary, #cfd3d6);\n}\n.qc-popup-tabs {\n display: flex;\n gap: 2px;\n padding: 6px 10px 0;\n border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.25));\n}\n.qc-popup-tab {\n border: 0;\n background: transparent;\n color: var(--dsw-alias-label-secondary, #cfd3d6);\n font-size: 12px;\n padding: 5px 10px;\n cursor: pointer;\n border-bottom: 2px solid transparent;\n}\n.qc-popup-tab-active {\n color: var(--dsw-alias-label-primary, #f2f2f2);\n border-bottom-color: var(--dsw-alias-brand-primary, #4a9eff);\n}\n.qc-popup-output {\n flex: 1;\n overflow: auto;\n padding: 8px 10px;\n font-family: var(--dsw-font-family-mono, monospace);\n font-size: 12px;\n line-height: 1.5;\n}\n.qc-popup-pre {\n margin: 0;\n white-space: pre-wrap;\n word-break: break-word;\n color: var(--dsw-alias-label-primary, #f2f2f2);\n}\n.qc-popup-empty {\n margin: 0;\n color: var(--dsw-alias-label-tertiary, #adb2b8);\n font-size: 12px;\n}\n.qc-popup-error {\n margin: 0 0 6px;\n color: var(--dsw-alias-state-error-primary, #e5484d);\n font-size: 12px;\n}\n.qc-popup-foot {\n display: flex;\n align-items: center;\n padding: 6px 10px;\n border-top: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.25));\n font-size: 11px;\n}\n.qc-popup-state {\n color: var(--dsw-alias-label-secondary, #cfd3d6);\n}\n\n/* Resize handles. The popup pins its RIGHT edge (both anchors); width is\n always dragged from the LEFT border. Height is dragged from the TOP border\n (corner anchor, bottom pinned) or the BOTTOM border (button anchor, top\n pinned below the button). The free corner (top-left / bottom-left) carries\n the diagonal grip. Pointer capture lives in header.tsx; touch-action:none\n lets touch drags resize instead of scrolling. */\n.qc-popup-resize {\n position: absolute;\n z-index: 3;\n touch-action: none;\n user-select: none;\n}\n.qc-popup-resize-left {\n left: 0;\n top: 10px;\n bottom: 10px;\n width: 7px;\n cursor: ew-resize;\n}\n.qc-popup-resize-top {\n top: 0;\n left: 10px;\n right: 10px;\n height: 7px;\n cursor: ns-resize;\n}\n.qc-popup-resize-bottom {\n bottom: 0;\n left: 10px;\n right: 10px;\n height: 7px;\n cursor: ns-resize;\n}\n.qc-popup-resize-left:hover,\n.qc-popup-resize-top:hover,\n.qc-popup-resize-bottom:hover {\n background: color-mix(in srgb, var(--dsw-alias-label-primary, #f2f2f2) 14%, transparent);\n}\n.qc-popup-resize-corner {\n width: 22px;\n height: 22px;\n z-index: 4;\n display: flex;\n padding: 3px;\n color: var(--dsw-alias-label-tertiary, #adb2b8);\n opacity: 0.6;\n transition: opacity .15s ease;\n}\n.qc-popup-resize-corner-tl {\n top: 0;\n left: 0;\n cursor: nwse-resize;\n align-items: flex-start;\n justify-content: flex-start;\n}\n.qc-popup-resize-corner-bl {\n bottom: 0;\n left: 0;\n cursor: nesw-resize;\n align-items: flex-end;\n justify-content: flex-start;\n}\n.qc-popup-resize-corner:hover,\n.qc-popup-resize-corner:active {\n opacity: 1;\n}\n\n/* ── mobile fallback (dsh-mobile-nav) ──────────────────────────────────────\n The dsh-mobile-nav plugin (≤1023px) hides the ENTIRE header utilities seat\n with `[data-mobile-nav=\"frame\"] [data-phase] header > :first-child >\n :last-child { display: none !important; }`, because the seat used to hold\n only the session-log-export capsule (which it relocates to the drawer\n footer). Our quick-run ▶ button rides that same seat, so it vanished on\n phones.\n\n Re-show ONLY our button: restore the seat's flex layout (out-ranking the\n host rule via `:has(.qc-head-wrap)` specificity (0,5,1) > (0,4,1)) and keep\n every other seat occupant hidden (the session-log capsule stays relocated\n to the drawer footer). Desktop (no [data-mobile-nav]) is untouched. */\n[data-mobile-nav=\"frame\"] [data-phase] header > :first-child > :last-child:has(.qc-head-wrap) {\n display: flex !important;\n margin-left: 4px !important;\n gap: 0 !important;\n}\n[data-mobile-nav=\"frame\"] [data-phase] header > :first-child > :last-child:has(.qc-head-wrap)\n [data-slot=\"conversation.session.header.utilities\"] > :not(.qc-head-wrap) {\n display: none !important;\n}\n\n/* ── viewport-level command menu ─────────────────────────────────────────\n The phone shell's nav rail creates a sibling stacking context and its\n center column can clip header descendants. The React menu therefore mounts\n in document.body; these styles deliberately detach it from that header\n context. Pixel coordinates (including the 56px rail exclusion) are set by\n positionMenu() in header.tsx. */\n.qc-menu-portal {\n position: fixed;\n right: auto;\n z-index: 1100;\n box-sizing: border-box;\n max-height: min(420px, calc(100dvh - 72px));\n}\n\n@media (max-width: 767px) {\n /* A 44px target lets a fingertip reliably activate both the stop and close\n buttons without changing the desktop header density. */\n body[data-dsh-mobile] .qc-popup-icon,\n [data-mobile-nav=\"frame\"] .qc-popup-icon {\n min-width: 44px;\n min-height: 44px;\n justify-content: center;\n }\n}";
1157
1157
  const tagId = "@fonlan/dsh-quick-commands/quick-commands.css#b65a4ce4";
1158
1158
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
package/lib/index.js CHANGED
@@ -1,7 +1,141 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { execFile } from "node:child_process";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
5
  import z from "@deepseek-ai/schemastery";
6
+ //#region src/server/shell-path.ts
7
+ /**
8
+ * Login-shell PATH resolution for the local quick-command backend.
9
+ *
10
+ * `dsh web` started from a terminal inherits the user's full PATH, so a
11
+ * configured command such as `npm run typecheck` or `docker compose up -d`
12
+ * finds its toolchain. **DSH Desktop does not**: the Electron host is launched
13
+ * by launchd and runs with the minimal system PATH
14
+ * (`/usr/bin:/bin:/usr/sbin:/sbin`), which hides homebrew, `/usr/local/bin`,
15
+ * nvm, corepack and Docker Desktop. Every command that leans on the user's
16
+ * toolchain then fails with "command not found" in Desktop while the very same
17
+ * profile keeps working in a browser-launched host.
18
+ *
19
+ * This module asks the user's interactive login shell for its PATH once
20
+ * (cached for the process lifetime) and prepends it to the inherited PATH for
21
+ * local runs. Resolution is best effort by design: a missing `$SHELL`, a
22
+ * timeout, a non-zero exit or unparseable output all leave the inherited PATH
23
+ * untouched, so a terminal-launched host behaves exactly as before.
24
+ */
25
+ /** Delimiters wrapping the shell's own PATH in the probe's stdout. */
26
+ const PATH_START = "__DSH_QC_PATH_START__";
27
+ const PATH_END = "__DSH_QC_PATH_END__";
28
+ const defaultRunner = (file, args, options) => new Promise((resolve, reject) => {
29
+ execFile(file, args, options, (error, stdout) => {
30
+ if (error) reject(error);
31
+ else resolve({ stdout });
32
+ });
33
+ });
34
+ /**
35
+ * Extract the PATH a shell printed between the markers.
36
+ *
37
+ * Login profiles are free to print their own noise (nvm notices, version
38
+ * managers, MOTDs), so only the marked span counts — never the whole stdout.
39
+ * @param stdout - raw probe stdout.
40
+ * @returns the marked PATH, or '' when the markers are missing.
41
+ */
42
+ function extractShellPath(stdout) {
43
+ if (typeof stdout !== "string" && !(stdout instanceof Buffer)) return "";
44
+ const text = String(stdout);
45
+ const start = text.indexOf(PATH_START);
46
+ const end = text.lastIndexOf(PATH_END);
47
+ if (start < 0 || end < 0 || end < start) return "";
48
+ return text.slice(start + 21, end);
49
+ }
50
+ /**
51
+ * Put the login-shell PATH in front of the inherited one.
52
+ *
53
+ * The login PATH wins (that is where the user's toolchain lives); entries from
54
+ * the inherited PATH stay behind it as a fallback. Empty entries are dropped
55
+ * and duplicates keep their first position.
56
+ * @param loginPath - PATH reported by the login shell ('' when unavailable).
57
+ * @param inheritedPath - the host process's own PATH.
58
+ * @returns the merged PATH (the inherited value when there is no login one).
59
+ */
60
+ function mergePaths(loginPath, inheritedPath) {
61
+ const seen = /* @__PURE__ */ new Set();
62
+ const entries = [];
63
+ for (const part of `${loginPath}:${inheritedPath ?? ""}`.split(":")) {
64
+ const entry = part.trim();
65
+ if (entry === "" || seen.has(entry)) continue;
66
+ seen.add(entry);
67
+ entries.push(entry);
68
+ }
69
+ return entries.join(":");
70
+ }
71
+ /** The shell program whose login PATH describes this user's environment. */
72
+ function loginShell(env = process.env) {
73
+ const shell = env.SHELL?.trim();
74
+ return shell !== void 0 && shell !== "" ? shell : void 0;
75
+ }
76
+ /**
77
+ * Ask `shell` (interactive login) for its PATH.
78
+ *
79
+ * Both flags matter: `-l` sources the profile files that own the system PATH
80
+ * (on macOS `/etc/zprofile` → `path_helper`), `-i` additionally sources the
81
+ * interactive config where nvm/fnm/volta and other version managers install
82
+ * their shims (this user's node lives in `~/.zshrc`). A hung or noisy
83
+ * interactive profile cannot block a run: the probe carries a deadline, and
84
+ * only the marked span of stdout is read.
85
+ * @param options.shell - shell program; defaults to `$SHELL`.
86
+ * @param options.timeoutMs - probe deadline.
87
+ * @param options.run - probe runner, injectable for tests.
88
+ * @param options.env - environment the probe itself runs with.
89
+ * @returns the login PATH, or '' when it cannot be determined.
90
+ */
91
+ async function resolveLoginShellPath(options = {}) {
92
+ if (process.platform === "win32") return "";
93
+ const env = options.env ?? process.env;
94
+ const shell = options.shell ?? loginShell(env);
95
+ if (shell === void 0) return "";
96
+ const run = options.run ?? defaultRunner;
97
+ const command = `printf '%s' "${PATH_START}\${PATH}${PATH_END}"`;
98
+ try {
99
+ const { stdout } = await run(shell, [
100
+ "-i",
101
+ "-l",
102
+ "-c",
103
+ command
104
+ ], {
105
+ timeout: options.timeoutMs ?? 3e3,
106
+ windowsHide: true,
107
+ env
108
+ });
109
+ const path = extractShellPath(stdout).trim();
110
+ return /[\s]/.test(path) ? "" : path;
111
+ } catch {
112
+ return "";
113
+ }
114
+ }
115
+ let cached;
116
+ /** Cached, process-wide login-shell PATH (one probe per host lifetime). */
117
+ function loginShellPath() {
118
+ cached ??= resolveLoginShellPath();
119
+ return cached;
120
+ }
121
+ /**
122
+ * Environment for one local quick command.
123
+ *
124
+ * A copy of the host environment with the login-shell PATH merged in front, so
125
+ * Desktop-launched hosts see the same toolchain a terminal-launched host does.
126
+ * @param overrides - plugin-owned variables (DSH_WORKSPACE*) merged last.
127
+ * @returns the spawn environment.
128
+ */
129
+ async function localCommandEnv(overrides = {}) {
130
+ const env = { ...process.env };
131
+ const loginPath = await loginShellPath();
132
+ if (loginPath !== "") env.PATH = mergePaths(loginPath, process.env.PATH);
133
+ return {
134
+ ...env,
135
+ ...overrides
136
+ };
137
+ }
138
+ //#endregion
5
139
  //#region src/server/remote.ts
6
140
  /**
7
141
  * Remote (SSH) execution support: detect that a workspace path is a dsh-ssh
@@ -312,12 +446,11 @@ var QuickCommandRunner = class {
312
446
  stdout: { maxBytes: TAIL_MAX_BYTES },
313
447
  stderr: { maxBytes: TAIL_MAX_BYTES }
314
448
  },
315
- env: {
316
- ...process.env,
449
+ env: await localCommandEnv({
317
450
  DSH_WORKSPACE: input.workspacePath,
318
451
  DSH_WORKSPACE_ID: input.workspaceId,
319
452
  DSH_WORKSPACE_TITLE: input.workspaceTitle
320
- }
453
+ })
321
454
  });
322
455
  const live = {
323
456
  runId,
@@ -861,10 +994,8 @@ function registerApiRoutes(ctx, runner, settings) {
861
994
  };
862
995
  }
863
996
  }
864
- const webServer = ctx.get("webServer");
865
- if (webServer === void 0) return () => void 0;
866
997
  return ctx.effect(() => {
867
- return webServer.register({
998
+ return ctx.webServer.register({
868
999
  kind: "prefix",
869
1000
  path: API_PREFIX,
870
1001
  handler: async (req, res) => {
@@ -899,30 +1030,28 @@ const Config = QuickCommandsSettingsSchema;
899
1030
  /** Configuration rides the entry config; edits persist through the settings service. */
900
1031
  function apply(ctx, config) {
901
1032
  const settings = registerSettings(ctx, config);
902
- const subprocess = ctx.get("subprocess");
903
- if (subprocess === void 0) {
904
- ctx.logger?.warn?.("@fonlan/dsh-quick-commands: subprocess service unavailable; commands cannot run");
905
- return;
906
- }
907
- const resolveRemote = () => {
908
- const sshPool = ctx.get("sshPool");
909
- if (sshPool === void 0 || typeof sshPool.acquire !== "function" || typeof sshPool.release !== "function") return;
910
- return {
911
- pool: sshPool,
912
- resolveHost: (hostId) => readHostConfig((ns) => {
913
- try {
914
- return ctx.get("settings")?.get(ns);
915
- } catch {
916
- return;
917
- }
918
- }, hostId)
1033
+ ctx.inject(["subprocess", "webServer"], (sctx) => {
1034
+ const subprocess = sctx.get("subprocess");
1035
+ const resolveRemote = () => {
1036
+ const sshPool = sctx.get("sshPool");
1037
+ if (sshPool === void 0 || typeof sshPool.acquire !== "function" || typeof sshPool.release !== "function") return;
1038
+ return {
1039
+ pool: sshPool,
1040
+ resolveHost: (hostId) => readHostConfig((ns) => {
1041
+ try {
1042
+ return sctx.get("settings")?.get(ns);
1043
+ } catch {
1044
+ return;
1045
+ }
1046
+ }, hostId)
1047
+ };
919
1048
  };
920
- };
921
- const runner = new QuickCommandRunner(subprocess, () => resolveRemote());
922
- ctx.effect(() => registerApiRoutes(ctx, runner, settings), "quick-commands: api routes");
923
- ctx.effect(() => () => {
924
- for (const id of runner.listAll()) runner.kill(id);
925
- }, "quick-commands: process cleanup");
1049
+ const runner = new QuickCommandRunner(subprocess, () => resolveRemote());
1050
+ sctx.effect(() => registerApiRoutes(sctx, runner, settings), "quick-commands: api routes");
1051
+ sctx.effect(() => () => {
1052
+ for (const id of runner.listAll()) runner.kill(id);
1053
+ }, "quick-commands: process cleanup");
1054
+ });
926
1055
  }
927
1056
  //#endregion
928
1057
  export { Config, apply, inject, name };
@@ -0,0 +1,69 @@
1
+ /** Delimiters wrapping the shell's own PATH in the probe's stdout. */
2
+ export declare const PATH_START = "__DSH_QC_PATH_START__";
3
+ export declare const PATH_END = "__DSH_QC_PATH_END__";
4
+ /** Default deadline for the login-shell probe (ms). */
5
+ export declare const PROBE_TIMEOUT_MS = 3000;
6
+ /** Minimal probe runner shape: `child_process.execFile`, or a test double. */
7
+ export type ProbeRunner = (file: string, args: string[], options: {
8
+ timeout: number;
9
+ windowsHide: boolean;
10
+ env: NodeJS.ProcessEnv;
11
+ }) => Promise<{
12
+ stdout?: string | Buffer;
13
+ }>;
14
+ /**
15
+ * Extract the PATH a shell printed between the markers.
16
+ *
17
+ * Login profiles are free to print their own noise (nvm notices, version
18
+ * managers, MOTDs), so only the marked span counts — never the whole stdout.
19
+ * @param stdout - raw probe stdout.
20
+ * @returns the marked PATH, or '' when the markers are missing.
21
+ */
22
+ export declare function extractShellPath(stdout: unknown): string;
23
+ /**
24
+ * Put the login-shell PATH in front of the inherited one.
25
+ *
26
+ * The login PATH wins (that is where the user's toolchain lives); entries from
27
+ * the inherited PATH stay behind it as a fallback. Empty entries are dropped
28
+ * and duplicates keep their first position.
29
+ * @param loginPath - PATH reported by the login shell ('' when unavailable).
30
+ * @param inheritedPath - the host process's own PATH.
31
+ * @returns the merged PATH (the inherited value when there is no login one).
32
+ */
33
+ export declare function mergePaths(loginPath: string, inheritedPath: string | undefined): string;
34
+ /** The shell program whose login PATH describes this user's environment. */
35
+ export declare function loginShell(env?: NodeJS.ProcessEnv): string | undefined;
36
+ /**
37
+ * Ask `shell` (interactive login) for its PATH.
38
+ *
39
+ * Both flags matter: `-l` sources the profile files that own the system PATH
40
+ * (on macOS `/etc/zprofile` → `path_helper`), `-i` additionally sources the
41
+ * interactive config where nvm/fnm/volta and other version managers install
42
+ * their shims (this user's node lives in `~/.zshrc`). A hung or noisy
43
+ * interactive profile cannot block a run: the probe carries a deadline, and
44
+ * only the marked span of stdout is read.
45
+ * @param options.shell - shell program; defaults to `$SHELL`.
46
+ * @param options.timeoutMs - probe deadline.
47
+ * @param options.run - probe runner, injectable for tests.
48
+ * @param options.env - environment the probe itself runs with.
49
+ * @returns the login PATH, or '' when it cannot be determined.
50
+ */
51
+ export declare function resolveLoginShellPath(options?: {
52
+ shell?: string | undefined;
53
+ timeoutMs?: number;
54
+ run?: ProbeRunner;
55
+ env?: NodeJS.ProcessEnv;
56
+ }): Promise<string>;
57
+ /** Cached, process-wide login-shell PATH (one probe per host lifetime). */
58
+ export declare function loginShellPath(): Promise<string>;
59
+ /** Drop the cache (tests, and settings reloads that may have changed `$SHELL`). */
60
+ export declare function resetLoginShellPathCache(): void;
61
+ /**
62
+ * Environment for one local quick command.
63
+ *
64
+ * A copy of the host environment with the login-shell PATH merged in front, so
65
+ * Desktop-launched hosts see the same toolchain a terminal-launched host does.
66
+ * @param overrides - plugin-owned variables (DSH_WORKSPACE*) merged last.
67
+ * @returns the spawn environment.
68
+ */
69
+ export declare function localCommandEnv(overrides?: Record<string, string>): Promise<NodeJS.ProcessEnv>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonlan/dsh-quick-commands",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "DSH web plugin: per-workspace quick commands. Configure named shell commands per DSH workspace, run one from the session header with live stdout/stderr in a floating popup.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -44,6 +44,18 @@
44
44
  "publishConfig": {
45
45
  "access": "public"
46
46
  },
47
+ "scripts": {
48
+ "build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
49
+ "build:client": "tsdown",
50
+ "prepare": "npm run build",
51
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json",
52
+ "test": "node --experimental-transform-types --test test/remote.test.ts test/settings-config.test.ts test/host-apply.test.ts test/shell-path.test.ts && node test/header-icons.test.mjs",
53
+ "bundle": "tsdown",
54
+ "watch": "tsdown --watch",
55
+ "release:patch": "npm version patch && git push && git push --tags",
56
+ "release:minor": "npm version minor && git push && git push --tags",
57
+ "release:major": "npm version major && git push && git push --tags"
58
+ },
47
59
  "dsh": {
48
60
  "bundle": {
49
61
  "patch": "./cordis.patch.yml"
@@ -91,15 +103,5 @@
91
103
  "tsdown": "^0.22.14",
92
104
  "typescript": "^5.9.0"
93
105
  },
94
- "scripts": {
95
- "build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
96
- "build:client": "tsdown",
97
- "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json",
98
- "test": "node --experimental-transform-types --test test/remote.test.ts test/settings-config.test.ts && node test/header-icons.test.mjs",
99
- "bundle": "tsdown",
100
- "watch": "tsdown --watch",
101
- "release:patch": "npm version patch && git push && git push --tags",
102
- "release:minor": "npm version minor && git push && git push --tags",
103
- "release:major": "npm version major && git push && git push --tags"
104
- }
105
- }
106
+ "packageManager": "pnpm@11.21.0"
107
+ }
package/src/index.ts CHANGED
@@ -32,37 +32,45 @@ export const Config = QuickCommandsSettingsSchema
32
32
  /** Configuration rides the entry config; edits persist through the settings service. */
33
33
  export function apply(ctx: Context, config: QuickCommandsSettings): void {
34
34
  const settings = registerSettings(ctx, config)
35
- const subprocess = ctx.get('subprocess')
36
- if (subprocess === undefined) {
37
- ctx.logger?.warn?.('@fonlan/dsh-quick-commands: subprocess service unavailable; commands cannot run')
38
- return
39
- }
40
- // Remote backend (duck-typed over @dsh-ssh/dsh-ssh): resolved lazily so the
41
- // sshPool service is picked up whenever the SSH plugin loads.
42
- const resolveRemote = (): RemoteBackend | undefined => {
43
- const sshPool = ctx.get('sshPool') as
44
- | { acquire(cfg: { id: string; [key: string]: unknown }): Promise<unknown>; release(): void }
45
- | undefined
46
- if (sshPool === undefined || typeof sshPool.acquire !== 'function' || typeof sshPool.release !== 'function') {
47
- return undefined
35
+ // dsh >= 0.1.7 activates entries asynchronously: a fiber is only readable as a
36
+ // service once its own init settled, and the webserver's init waits for its
37
+ // listen. Resolving services with a one-shot `ctx.get()` during apply could
38
+ // therefore observe undefined with nothing to retry — the header menu's
39
+ // "HTTP 405": `subprocess` (and/or the route helper's own `webServer` guard)
40
+ // came back missing, the API prefix route was never registered, and every
41
+ // client POST fell through to the /plugins client-modules bundle route, which
42
+ // answers any non-GET with 405.
43
+ // ctx.inject is the retrying form: the body runs once both services are live,
44
+ // so the pairing also covers whichever one arrives after this entry.
45
+ ctx.inject(['subprocess', 'webServer'], (sctx) => {
46
+ const subprocess = sctx.get('subprocess')
47
+ // Remote backend (duck-typed over @dsh-ssh/dsh-ssh): resolved lazily so the
48
+ // sshPool service is picked up whenever the SSH plugin loads.
49
+ const resolveRemote = (): RemoteBackend | undefined => {
50
+ const sshPool = sctx.get('sshPool') as
51
+ | { acquire(cfg: { id: string; [key: string]: unknown }): Promise<unknown>; release(): void }
52
+ | undefined
53
+ if (sshPool === undefined || typeof sshPool.acquire !== 'function' || typeof sshPool.release !== 'function') {
54
+ return undefined
55
+ }
56
+ return {
57
+ pool: sshPool as never,
58
+ resolveHost: (hostId) => readHostConfig((ns) => {
59
+ try {
60
+ return (sctx.get('settings') as { get(ns: string): unknown } | undefined)?.get(ns)
61
+ } catch {
62
+ return undefined
63
+ }
64
+ }, hostId),
65
+ }
48
66
  }
49
- return {
50
- pool: sshPool as never,
51
- resolveHost: (hostId) => readHostConfig((ns) => {
52
- try {
53
- return (ctx.get('settings') as { get(ns: string): unknown } | undefined)?.get(ns)
54
- } catch {
55
- return undefined
56
- }
57
- }, hostId),
58
- }
59
- }
60
- const runner = new QuickCommandRunner(subprocess as never, () => resolveRemote())
61
- ctx.effect(() => registerApiRoutes(ctx, runner, settings), 'quick-commands: api routes')
62
- ctx.effect(() => () => {
63
- // Terminate every live process when the plugin fiber stops.
64
- for (const id of runner.listAll()) {
65
- runner.kill(id)
66
- }
67
- }, 'quick-commands: process cleanup')
67
+ const runner = new QuickCommandRunner(subprocess as never, () => resolveRemote())
68
+ sctx.effect(() => registerApiRoutes(sctx, runner, settings), 'quick-commands: api routes')
69
+ sctx.effect(() => () => {
70
+ // Terminate every live process when the plugin fiber stops.
71
+ for (const id of runner.listAll()) {
72
+ runner.kill(id)
73
+ }
74
+ }, 'quick-commands: process cleanup')
75
+ })
68
76
  }
package/src/server/rpc.ts CHANGED
@@ -255,10 +255,15 @@ export function registerApiRoutes(ctx: Context, runner: QuickRunner, settings: Q
255
255
  }
256
256
  }
257
257
 
258
- const webServer = ctx.get('webServer')
259
- if (webServer === undefined) return () => undefined
258
+ // The caller reaches this function through ctx.inject(['webServer']), so the
259
+ // service is active by construction. A one-shot `ctx.get('webServer')` guard
260
+ // here used to return an empty disposer whenever the webserver fiber had not
261
+ // finished activating yet (dsh >= 0.1.7 activates entries asynchronously and
262
+ // makes a service readable only once its own init settled — for the webserver
263
+ // that means listening): no route was ever registered and every client POST
264
+ // fell through to the /plugins bundle route, which answers non-GET with 405.
260
265
  return ctx.effect(() => {
261
- return webServer.register({
266
+ return ctx.webServer.register({
262
267
  kind: 'prefix',
263
268
  path: API_PREFIX,
264
269
  handler: async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
@@ -17,6 +17,7 @@
17
17
  import { randomUUID } from 'node:crypto'
18
18
  import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
19
19
  import type { QuickCommandsSettings } from '../shared/contract.js'
20
+ import { localCommandEnv } from './shell-path.js'
20
21
  import {
21
22
  localPathToRemoteRef,
22
23
  resolveRemoteCwd,
@@ -203,12 +204,15 @@ export class QuickCommandRunner implements QuickRunner {
203
204
  stdout: { maxBytes: TAIL_MAX_BYTES },
204
205
  stderr: { maxBytes: TAIL_MAX_BYTES },
205
206
  },
206
- env: {
207
- ...process.env,
207
+ // The login-shell PATH rides in front of the host's own: DSH Desktop runs
208
+ // the host with launchd's minimal PATH, so `npm run typecheck` and
209
+ // `docker compose up -d` only resolve once the user's toolchain is
210
+ // visible. See ./shell-path.ts.
211
+ env: await localCommandEnv({
208
212
  DSH_WORKSPACE: input.workspacePath,
209
213
  DSH_WORKSPACE_ID: input.workspaceId,
210
214
  DSH_WORKSPACE_TITLE: input.workspaceTitle,
211
- },
215
+ }),
212
216
  })
213
217
 
214
218
  const live: LiveRun = {
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Login-shell PATH resolution for the local quick-command backend.
3
+ *
4
+ * `dsh web` started from a terminal inherits the user's full PATH, so a
5
+ * configured command such as `npm run typecheck` or `docker compose up -d`
6
+ * finds its toolchain. **DSH Desktop does not**: the Electron host is launched
7
+ * by launchd and runs with the minimal system PATH
8
+ * (`/usr/bin:/bin:/usr/sbin:/sbin`), which hides homebrew, `/usr/local/bin`,
9
+ * nvm, corepack and Docker Desktop. Every command that leans on the user's
10
+ * toolchain then fails with "command not found" in Desktop while the very same
11
+ * profile keeps working in a browser-launched host.
12
+ *
13
+ * This module asks the user's interactive login shell for its PATH once
14
+ * (cached for the process lifetime) and prepends it to the inherited PATH for
15
+ * local runs. Resolution is best effort by design: a missing `$SHELL`, a
16
+ * timeout, a non-zero exit or unparseable output all leave the inherited PATH
17
+ * untouched, so a terminal-launched host behaves exactly as before.
18
+ */
19
+ import { execFile } from 'node:child_process'
20
+
21
+ /** Delimiters wrapping the shell's own PATH in the probe's stdout. */
22
+ export const PATH_START = '__DSH_QC_PATH_START__'
23
+ export const PATH_END = '__DSH_QC_PATH_END__'
24
+
25
+ /** Default deadline for the login-shell probe (ms). */
26
+ export const PROBE_TIMEOUT_MS = 3000
27
+
28
+ /** Minimal probe runner shape: `child_process.execFile`, or a test double. */
29
+ export type ProbeRunner = (
30
+ file: string,
31
+ args: string[],
32
+ options: { timeout: number; windowsHide: boolean; env: NodeJS.ProcessEnv },
33
+ ) => Promise<{ stdout?: string | Buffer }>
34
+
35
+ const defaultRunner: ProbeRunner = (file, args, options) =>
36
+ new Promise((resolve, reject) => {
37
+ execFile(file, args, options, (error, stdout) => {
38
+ if (error) reject(error)
39
+ else resolve({ stdout })
40
+ })
41
+ })
42
+
43
+ /**
44
+ * Extract the PATH a shell printed between the markers.
45
+ *
46
+ * Login profiles are free to print their own noise (nvm notices, version
47
+ * managers, MOTDs), so only the marked span counts — never the whole stdout.
48
+ * @param stdout - raw probe stdout.
49
+ * @returns the marked PATH, or '' when the markers are missing.
50
+ */
51
+ export function extractShellPath(stdout: unknown): string {
52
+ if (typeof stdout !== 'string' && !(stdout instanceof Buffer)) return ''
53
+ const text = String(stdout)
54
+ const start = text.indexOf(PATH_START)
55
+ const end = text.lastIndexOf(PATH_END)
56
+ if (start < 0 || end < 0 || end < start) return ''
57
+ return text.slice(start + PATH_START.length, end)
58
+ }
59
+
60
+ /**
61
+ * Put the login-shell PATH in front of the inherited one.
62
+ *
63
+ * The login PATH wins (that is where the user's toolchain lives); entries from
64
+ * the inherited PATH stay behind it as a fallback. Empty entries are dropped
65
+ * and duplicates keep their first position.
66
+ * @param loginPath - PATH reported by the login shell ('' when unavailable).
67
+ * @param inheritedPath - the host process's own PATH.
68
+ * @returns the merged PATH (the inherited value when there is no login one).
69
+ */
70
+ export function mergePaths(loginPath: string, inheritedPath: string | undefined): string {
71
+ const seen = new Set<string>()
72
+ const entries: string[] = []
73
+ for (const part of `${loginPath}:${inheritedPath ?? ''}`.split(':')) {
74
+ const entry = part.trim()
75
+ if (entry === '' || seen.has(entry)) continue
76
+ seen.add(entry)
77
+ entries.push(entry)
78
+ }
79
+ return entries.join(':')
80
+ }
81
+
82
+ /** The shell program whose login PATH describes this user's environment. */
83
+ export function loginShell(env: NodeJS.ProcessEnv = process.env): string | undefined {
84
+ const shell = env.SHELL?.trim()
85
+ return shell !== undefined && shell !== '' ? shell : undefined
86
+ }
87
+
88
+ /**
89
+ * Ask `shell` (interactive login) for its PATH.
90
+ *
91
+ * Both flags matter: `-l` sources the profile files that own the system PATH
92
+ * (on macOS `/etc/zprofile` → `path_helper`), `-i` additionally sources the
93
+ * interactive config where nvm/fnm/volta and other version managers install
94
+ * their shims (this user's node lives in `~/.zshrc`). A hung or noisy
95
+ * interactive profile cannot block a run: the probe carries a deadline, and
96
+ * only the marked span of stdout is read.
97
+ * @param options.shell - shell program; defaults to `$SHELL`.
98
+ * @param options.timeoutMs - probe deadline.
99
+ * @param options.run - probe runner, injectable for tests.
100
+ * @param options.env - environment the probe itself runs with.
101
+ * @returns the login PATH, or '' when it cannot be determined.
102
+ */
103
+ export async function resolveLoginShellPath(options: {
104
+ shell?: string | undefined
105
+ timeoutMs?: number
106
+ run?: ProbeRunner
107
+ env?: NodeJS.ProcessEnv
108
+ } = {}): Promise<string> {
109
+ if (process.platform === 'win32') return ''
110
+ const env = options.env ?? process.env
111
+ const shell = options.shell ?? loginShell(env)
112
+ if (shell === undefined) return ''
113
+ const run = options.run ?? defaultRunner
114
+ // `${PATH}` must stay braced: an unbraced `$PATH__DSH...` is read as one
115
+ // identifier by POSIX shells and silently expands to nothing.
116
+ const command = `printf '%s' "${PATH_START}\${PATH}${PATH_END}"`
117
+ try {
118
+ const { stdout } = await run(shell, ['-i', '-l', '-c', command], {
119
+ timeout: options.timeoutMs ?? PROBE_TIMEOUT_MS,
120
+ windowsHide: true,
121
+ env,
122
+ })
123
+ const path = extractShellPath(stdout).trim()
124
+ // A list-valued shell (fish) renders "$PATH" as space-separated entries:
125
+ // merging that would inject a single bogus entry, so keep the host PATH.
126
+ return /[\s]/.test(path) ? '' : path
127
+ } catch {
128
+ // Any failure (missing shell, timeout, killed profile) keeps the inherited
129
+ // PATH: a quick command must never be worse than the host's own shell.
130
+ return ''
131
+ }
132
+ }
133
+
134
+ let cached: Promise<string> | undefined
135
+
136
+ /** Cached, process-wide login-shell PATH (one probe per host lifetime). */
137
+ export function loginShellPath(): Promise<string> {
138
+ cached ??= resolveLoginShellPath()
139
+ return cached
140
+ }
141
+
142
+ /** Drop the cache (tests, and settings reloads that may have changed `$SHELL`). */
143
+ export function resetLoginShellPathCache(): void {
144
+ cached = undefined
145
+ }
146
+
147
+ /**
148
+ * Environment for one local quick command.
149
+ *
150
+ * A copy of the host environment with the login-shell PATH merged in front, so
151
+ * Desktop-launched hosts see the same toolchain a terminal-launched host does.
152
+ * @param overrides - plugin-owned variables (DSH_WORKSPACE*) merged last.
153
+ * @returns the spawn environment.
154
+ */
155
+ export async function localCommandEnv(overrides: Record<string, string> = {}): Promise<NodeJS.ProcessEnv> {
156
+ const env: NodeJS.ProcessEnv = { ...process.env }
157
+ const loginPath = await loginShellPath()
158
+ if (loginPath !== '') env.PATH = mergePaths(loginPath, process.env.PATH)
159
+ return { ...env, ...overrides }
160
+ }