@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 +2 -2
- package/lib/index.js +158 -29
- package/lib/types/server/shell-path.d.ts +69 -0
- package/package.json +15 -13
- package/src/index.ts +40 -32
- package/src/server/rpc.ts +8 -3
- package/src/server/runner.ts +7 -3
- package/src/server/shell-path.ts +160 -0
package/lib/client.js
CHANGED
|
@@ -825,7 +825,7 @@ window.__ModuleLoader__.load({
|
|
|
825
825
|
});
|
|
826
826
|
}
|
|
827
827
|
//#endregion
|
|
828
|
-
//#region \0dsh-css:/
|
|
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:/
|
|
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
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
}
|
|
916
|
-
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
}
|
|
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.
|
|
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
|
-
"
|
|
95
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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> => {
|
package/src/server/runner.ts
CHANGED
|
@@ -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
|
-
|
|
207
|
-
|
|
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
|
+
}
|