@fonlan/dsh-quick-commands 0.1.7 → 0.1.9

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/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,
@@ -555,6 +688,28 @@ const WorkspaceCommandsSchema = z.object({
555
688
  workspaceId: z.string().min(1),
556
689
  commands: z.array(CommandSchema).max(200)
557
690
  });
691
+ /**
692
+ * Entry config schema, and the schema dsh derives the plugin's settings page
693
+ * from. The `z<...>` annotation names the RESOLVED (live-handle) shape on
694
+ * purpose:
695
+ *
696
+ * - schemastery >= 3.18.3 carries a third `Mode` type parameter, so a
697
+ * `.volatile()` field's OUTPUT type is `Volatile<T>` (a live `{ get() }`
698
+ * cell), never `T` — the old `z<QuickCommandsSettings>` annotation stopped
699
+ * compiling;
700
+ * - dropping the annotation and letting the type be inferred does not build
701
+ * either: declaration emit then cannot portably name the inferred type
702
+ * (TS2742), because `z.array(...)` leaks `@deepseek-ai/cosmokit`'s `Dict`
703
+ * into the schema type while the program holds two schemastery copies
704
+ * (this plugin's 3.18.3, and the one `@deepseek-ai/dsh-host-webserver`
705
+ * resolves — see pnpm-workspace.yaml's minimumReleaseAgeExclude, which pins
706
+ * the former), and cosmokit itself is not nameable from this package.
707
+ *
708
+ * The annotation is type-only. At runtime all three fields are still declared
709
+ * `volatile()` — that is what makes them writable by the settings plane (its
710
+ * `write()` refuses an entry whose form has no volatile field) and what keeps
711
+ * the auto-generated page working.
712
+ */
558
713
  const QuickCommandsSettingsSchema = z.object({
559
714
  workspaces: z.array(WorkspaceCommandsSchema).max(500).volatile(),
560
715
  popupAnchor: z.union([z.const("corner"), z.const("button")]).default("corner").volatile(),
@@ -564,25 +719,30 @@ const QuickCommandsSettingsSchema = z.object({
564
719
  }).volatile()
565
720
  });
566
721
  /**
567
- * Read one config field. A volatile field arrives as a handle (dsh >= 0.1.7)
568
- * and is read through `.get()` on EVERY access, so edits made through the
569
- * settings plane are visible immediately; hosts that hand the plain value keep
570
- * working unchanged.
722
+ * Read a config node that may be a plain value or a live (`.volatile()`) node.
723
+ *
724
+ * A plugin whose Config declares volatile fields receives a reactive config
725
+ * whose fields expose `get()`, while hand-written config objects and tests pass
726
+ * plain values. Both shapes are supported here so the same code path serves the
727
+ * settings page, `cordis.patch.yml`, and the unit tests.
571
728
  */
572
- function readField(field, fallback) {
573
- if (field === void 0 || field === null) return fallback;
574
- const handle = field;
575
- const value = typeof handle.get === "function" ? handle.get() : field;
576
- return value === void 0 || value === null ? fallback : value;
729
+ function liveValue(node, fallback) {
730
+ if (node === void 0 || node === null) return fallback;
731
+ const getter = node.get;
732
+ if (typeof getter === "function") {
733
+ const value = getter.call(node);
734
+ return value === void 0 ? fallback : value;
735
+ }
736
+ return node;
577
737
  }
578
738
  function registerSettings(ctx, config) {
579
739
  const readWorkspaces = () => {
580
- const value = readField(config?.workspaces, []);
740
+ const value = liveValue(config?.workspaces, []);
581
741
  return Array.isArray(value) ? value : [];
582
742
  };
583
- const readAnchor = () => readField(config?.popupAnchor, "corner") === "button" ? "button" : "corner";
743
+ const readAnchor = () => liveValue(config?.popupAnchor, "corner") === "button" ? "button" : "corner";
584
744
  const readPopupSize = () => {
585
- const value = readField(config?.popupSize, void 0);
745
+ const value = liveValue(config?.popupSize, void 0);
586
746
  if (typeof value !== "object" || value === null) return void 0;
587
747
  const { width, height } = value;
588
748
  if (typeof width !== "number" || typeof height !== "number") return void 0;
@@ -861,10 +1021,8 @@ function registerApiRoutes(ctx, runner, settings) {
861
1021
  };
862
1022
  }
863
1023
  }
864
- const webServer = ctx.get("webServer");
865
- if (webServer === void 0) return () => void 0;
866
1024
  return ctx.effect(() => {
867
- return webServer.register({
1025
+ return ctx.webServer.register({
868
1026
  kind: "prefix",
869
1027
  path: API_PREFIX,
870
1028
  handler: async (req, res) => {
@@ -899,30 +1057,28 @@ const Config = QuickCommandsSettingsSchema;
899
1057
  /** Configuration rides the entry config; edits persist through the settings service. */
900
1058
  function apply(ctx, config) {
901
1059
  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)
1060
+ ctx.inject(["subprocess", "webServer"], (sctx) => {
1061
+ const subprocess = sctx.get("subprocess");
1062
+ const resolveRemote = () => {
1063
+ const sshPool = sctx.get("sshPool");
1064
+ if (sshPool === void 0 || typeof sshPool.acquire !== "function" || typeof sshPool.release !== "function") return;
1065
+ return {
1066
+ pool: sshPool,
1067
+ resolveHost: (hostId) => readHostConfig((ns) => {
1068
+ try {
1069
+ return sctx.get("settings")?.get(ns);
1070
+ } catch {
1071
+ return;
1072
+ }
1073
+ }, hostId)
1074
+ };
919
1075
  };
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");
1076
+ const runner = new QuickCommandRunner(subprocess, () => resolveRemote());
1077
+ sctx.effect(() => registerApiRoutes(sctx, runner, settings), "quick-commands: api routes");
1078
+ sctx.effect(() => () => {
1079
+ for (const id of runner.listAll()) runner.kill(id);
1080
+ }, "quick-commands: process cleanup");
1081
+ });
926
1082
  }
927
1083
  //#endregion
928
1084
  export { Config, apply, inject, name };
@@ -13,9 +13,9 @@
13
13
  * absence of dsh-ssh degrades cleanly to local-only behaviour.
14
14
  */
15
15
  import type { Context } from '@deepseek-ai/cordis';
16
- import type { QuickCommandsSettings } from './shared/contract.js';
16
+ import { type QuickCommandsConfig } from './server/settings.js';
17
17
  export declare const name = "@fonlan/dsh-quick-commands";
18
18
  export declare const inject: string[];
19
- export declare const Config: import("@deepseek-ai/schemastery").default<QuickCommandsSettings>;
19
+ export declare const Config: import("@deepseek-ai/schemastery").default<import("./shared/contract.js").QuickCommandsSettings, import("./server/settings.js").QuickCommandsLiveSettings>;
20
20
  /** Configuration rides the entry config; edits persist through the settings service. */
21
- export declare function apply(ctx: Context, config: QuickCommandsSettings): void;
21
+ export declare function apply(ctx: Context, config: QuickCommandsConfig): void;
@@ -10,7 +10,7 @@
10
10
  * field therefore yields the handle object, which silently destroys the whole
11
11
  * document: `Array.isArray(handle)` is false, JSON renders `{}`, and a
12
12
  * `.default('corner')` union arrives as the handle rather than a literal.
13
- * Every read goes through {@link readField} instead, which unwraps handles and
13
+ * Every read goes through {@link liveValue} instead, which unwraps handles and
14
14
  * still accepts plain values (hosts that resolve the config without the
15
15
  * volatile wrapper).
16
16
  */
@@ -18,7 +18,49 @@ import type { Context } from '@deepseek-ai/cordis';
18
18
  import z from '@deepseek-ai/schemastery';
19
19
  import type { QuickCommandsSettings, QuickCommandEntry, QuickPopupSize } from '../shared/contract.js';
20
20
  export declare const QUICK_COMMANDS_NS = "quick-commands";
21
- export declare const QuickCommandsSettingsSchema: z<QuickCommandsSettings>;
21
+ /** Live handle schemastery wraps around one `volatile()` config field. */
22
+ interface VolatileField {
23
+ get(): unknown;
24
+ }
25
+ /**
26
+ * The entry config in its RESOLVED form: schemastery hands every `.volatile()`
27
+ * field to the loader (and so to `apply()`) as a live handle instead of the
28
+ * plain value. This is the schema's real output type.
29
+ */
30
+ export interface QuickCommandsLiveSettings {
31
+ workspaces: VolatileField;
32
+ popupAnchor: VolatileField;
33
+ popupSize: VolatileField;
34
+ }
35
+ /**
36
+ * The entry config as `apply()` receives it: the resolved (live) document, or
37
+ * the plain document in hand-built contexts and hosts that resolve the config
38
+ * without the volatile wrapper. Both load through the same `apply`.
39
+ */
40
+ export type QuickCommandsConfig = QuickCommandsSettings | QuickCommandsLiveSettings;
41
+ /**
42
+ * Entry config schema, and the schema dsh derives the plugin's settings page
43
+ * from. The `z<...>` annotation names the RESOLVED (live-handle) shape on
44
+ * purpose:
45
+ *
46
+ * - schemastery >= 3.18.3 carries a third `Mode` type parameter, so a
47
+ * `.volatile()` field's OUTPUT type is `Volatile<T>` (a live `{ get() }`
48
+ * cell), never `T` — the old `z<QuickCommandsSettings>` annotation stopped
49
+ * compiling;
50
+ * - dropping the annotation and letting the type be inferred does not build
51
+ * either: declaration emit then cannot portably name the inferred type
52
+ * (TS2742), because `z.array(...)` leaks `@deepseek-ai/cosmokit`'s `Dict`
53
+ * into the schema type while the program holds two schemastery copies
54
+ * (this plugin's 3.18.3, and the one `@deepseek-ai/dsh-host-webserver`
55
+ * resolves — see pnpm-workspace.yaml's minimumReleaseAgeExclude, which pins
56
+ * the former), and cosmokit itself is not nameable from this package.
57
+ *
58
+ * The annotation is type-only. At runtime all three fields are still declared
59
+ * `volatile()` — that is what makes them writable by the settings plane (its
60
+ * `write()` refuses an entry whose form has no volatile field) and what keeps
61
+ * the auto-generated page working.
62
+ */
63
+ export declare const QuickCommandsSettingsSchema: z<QuickCommandsSettings, QuickCommandsLiveSettings>;
22
64
  /** Settings service face for the quick-commands engine. */
23
65
  export interface QuickCommandsSettingsFace {
24
66
  get(): QuickCommandsSettings;
@@ -31,4 +73,5 @@ export interface QuickCommandsSettingsFace {
31
73
  /** Persist the output popup size (drag-resize). */
32
74
  setPopupSize(size: QuickPopupSize): Promise<void>;
33
75
  }
34
- export declare function registerSettings(ctx: Context, config: QuickCommandsSettings): QuickCommandsSettingsFace;
76
+ export declare function registerSettings(ctx: Context, config: QuickCommandsConfig): QuickCommandsSettingsFace;
77
+ export {};
@@ -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.9",
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": {
@@ -63,27 +63,27 @@
63
63
  },
64
64
  "peerDependencies": {
65
65
  "@deepseek-ai/cordis": "^4.0.1",
66
- "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
67
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
68
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
69
- "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
70
- "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
71
- "@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2",
72
- "@deepseek-ai/dsh-workspace": "^0.1.2-alpha.2",
66
+ "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
67
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
68
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
69
+ "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
70
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
71
+ "@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
72
+ "@deepseek-ai/dsh-workspace": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
73
73
  "@deepseek-ai/schemastery": "^3.18.2",
74
74
  "react": "^18.2.0",
75
75
  "react-dom": "^18.2.0"
76
76
  },
77
77
  "devDependencies": {
78
- "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
79
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-alpha.2",
80
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
81
- "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.2-alpha.2",
82
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
83
- "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
84
- "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
85
- "@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2",
86
- "@deepseek-ai/dsh-workspace": "^0.1.2-alpha.2",
78
+ "@deepseek-ai/dsh-client-locale": "0.2.0-rc.1",
79
+ "@deepseek-ai/dsh-client-ui-conversation": "0.2.0-rc.1",
80
+ "@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.1",
81
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "0.2.0-rc.1",
82
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.1",
83
+ "@deepseek-ai/dsh-host-webserver": "0.2.0-rc.1",
84
+ "@deepseek-ai/dsh-settings": "0.2.0-rc.1",
85
+ "@deepseek-ai/dsh-subprocess": "0.2.0-rc.1",
86
+ "@deepseek-ai/dsh-workspace": "0.2.0-rc.1",
87
87
  "@deepseek-ai/schemastery": "^3.18.3",
88
88
  "@types/node": "^22.0.0",
89
89
  "@types/react": "~18.3.1",
@@ -95,7 +95,7 @@
95
95
  "build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
96
96
  "build:client": "tsdown",
97
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",
98
+ "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",
99
99
  "bundle": "tsdown",
100
100
  "watch": "tsdown --watch",
101
101
  "release:patch": "npm version patch && git push && git push --tags",
package/src/index.ts CHANGED
@@ -15,9 +15,8 @@
15
15
  import type { Context } from '@deepseek-ai/cordis'
16
16
  import { QuickCommandRunner, type RemoteBackend } from './server/runner.js'
17
17
  import { readHostConfig } from './server/remote.js'
18
- import { QuickCommandsSettingsSchema, registerSettings } from './server/settings.js'
18
+ import { QuickCommandsSettingsSchema, registerSettings, type QuickCommandsConfig } from './server/settings.js'
19
19
  import { registerApiRoutes } from './server/rpc.js'
20
- import type { QuickCommandsSettings } from './shared/contract.js'
21
20
 
22
21
  export const name = '@fonlan/dsh-quick-commands'
23
22
 
@@ -30,39 +29,47 @@ export const inject = ['settings']
30
29
  export const Config = QuickCommandsSettingsSchema
31
30
 
32
31
  /** Configuration rides the entry config; edits persist through the settings service. */
33
- export function apply(ctx: Context, config: QuickCommandsSettings): void {
32
+ export function apply(ctx: Context, config: QuickCommandsConfig): void {
34
33
  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
34
+ // dsh >= 0.1.7 activates entries asynchronously: a fiber is only readable as a
35
+ // service once its own init settled, and the webserver's init waits for its
36
+ // listen. Resolving services with a one-shot `ctx.get()` during apply could
37
+ // therefore observe undefined with nothing to retry — the header menu's
38
+ // "HTTP 405": `subprocess` (and/or the route helper's own `webServer` guard)
39
+ // came back missing, the API prefix route was never registered, and every
40
+ // client POST fell through to the /plugins client-modules bundle route, which
41
+ // answers any non-GET with 405.
42
+ // ctx.inject is the retrying form: the body runs once both services are live,
43
+ // so the pairing also covers whichever one arrives after this entry.
44
+ ctx.inject(['subprocess', 'webServer'], (sctx) => {
45
+ const subprocess = sctx.get('subprocess')
46
+ // Remote backend (duck-typed over @dsh-ssh/dsh-ssh): resolved lazily so the
47
+ // sshPool service is picked up whenever the SSH plugin loads.
48
+ const resolveRemote = (): RemoteBackend | undefined => {
49
+ const sshPool = sctx.get('sshPool') as
50
+ | { acquire(cfg: { id: string; [key: string]: unknown }): Promise<unknown>; release(): void }
51
+ | undefined
52
+ if (sshPool === undefined || typeof sshPool.acquire !== 'function' || typeof sshPool.release !== 'function') {
53
+ return undefined
54
+ }
55
+ return {
56
+ pool: sshPool as never,
57
+ resolveHost: (hostId) => readHostConfig((ns) => {
58
+ try {
59
+ return (sctx.get('settings') as { get(ns: string): unknown } | undefined)?.get(ns)
60
+ } catch {
61
+ return undefined
62
+ }
63
+ }, hostId),
64
+ }
48
65
  }
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')
66
+ const runner = new QuickCommandRunner(subprocess as never, () => resolveRemote())
67
+ sctx.effect(() => registerApiRoutes(sctx, runner, settings), 'quick-commands: api routes')
68
+ sctx.effect(() => () => {
69
+ // Terminate every live process when the plugin fiber stops.
70
+ for (const id of runner.listAll()) {
71
+ runner.kill(id)
72
+ }
73
+ }, 'quick-commands: process cleanup')
74
+ })
68
75
  }
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 = {
@@ -10,7 +10,7 @@
10
10
  * field therefore yields the handle object, which silently destroys the whole
11
11
  * document: `Array.isArray(handle)` is false, JSON renders `{}`, and a
12
12
  * `.default('corner')` union arrives as the handle rather than a literal.
13
- * Every read goes through {@link readField} instead, which unwraps handles and
13
+ * Every read goes through {@link liveValue} instead, which unwraps handles and
14
14
  * still accepts plain values (hosts that resolve the config without the
15
15
  * volatile wrapper).
16
16
  */
@@ -36,7 +36,52 @@ const WorkspaceCommandsSchema = z.object({
36
36
  commands: z.array(CommandSchema).max(200),
37
37
  })
38
38
 
39
- export const QuickCommandsSettingsSchema: z<QuickCommandsSettings> = z.object({
39
+ /** Live handle schemastery wraps around one `volatile()` config field. */
40
+ interface VolatileField {
41
+ get(): unknown
42
+ }
43
+
44
+ /**
45
+ * The entry config in its RESOLVED form: schemastery hands every `.volatile()`
46
+ * field to the loader (and so to `apply()`) as a live handle instead of the
47
+ * plain value. This is the schema's real output type.
48
+ */
49
+ export interface QuickCommandsLiveSettings {
50
+ workspaces: VolatileField
51
+ popupAnchor: VolatileField
52
+ popupSize: VolatileField
53
+ }
54
+
55
+ /**
56
+ * The entry config as `apply()` receives it: the resolved (live) document, or
57
+ * the plain document in hand-built contexts and hosts that resolve the config
58
+ * without the volatile wrapper. Both load through the same `apply`.
59
+ */
60
+ export type QuickCommandsConfig = QuickCommandsSettings | QuickCommandsLiveSettings
61
+
62
+ /**
63
+ * Entry config schema, and the schema dsh derives the plugin's settings page
64
+ * from. The `z<...>` annotation names the RESOLVED (live-handle) shape on
65
+ * purpose:
66
+ *
67
+ * - schemastery >= 3.18.3 carries a third `Mode` type parameter, so a
68
+ * `.volatile()` field's OUTPUT type is `Volatile<T>` (a live `{ get() }`
69
+ * cell), never `T` — the old `z<QuickCommandsSettings>` annotation stopped
70
+ * compiling;
71
+ * - dropping the annotation and letting the type be inferred does not build
72
+ * either: declaration emit then cannot portably name the inferred type
73
+ * (TS2742), because `z.array(...)` leaks `@deepseek-ai/cosmokit`'s `Dict`
74
+ * into the schema type while the program holds two schemastery copies
75
+ * (this plugin's 3.18.3, and the one `@deepseek-ai/dsh-host-webserver`
76
+ * resolves — see pnpm-workspace.yaml's minimumReleaseAgeExclude, which pins
77
+ * the former), and cosmokit itself is not nameable from this package.
78
+ *
79
+ * The annotation is type-only. At runtime all three fields are still declared
80
+ * `volatile()` — that is what makes them writable by the settings plane (its
81
+ * `write()` refuses an entry whose form has no volatile field) and what keeps
82
+ * the auto-generated page working.
83
+ */
84
+ export const QuickCommandsSettingsSchema: z<QuickCommandsSettings, QuickCommandsLiveSettings> = z.object({
40
85
  // Values are addressed by index (array edits), so the whole array is the
41
86
  // volatile handle the settings plane swaps in place.
42
87
  workspaces: z.array(WorkspaceCommandsSchema).max(500).volatile(),
@@ -51,22 +96,22 @@ export const QuickCommandsSettingsSchema: z<QuickCommandsSettings> = z.object({
51
96
  }).volatile(),
52
97
  })
53
98
 
54
- /** Live handle schemastery wraps around one `volatile()` config field. */
55
- interface VolatileField<T> {
56
- get(): T
57
- }
58
-
59
99
  /**
60
- * Read one config field. A volatile field arrives as a handle (dsh >= 0.1.7)
61
- * and is read through `.get()` on EVERY access, so edits made through the
62
- * settings plane are visible immediately; hosts that hand the plain value keep
63
- * working unchanged.
100
+ * Read a config node that may be a plain value or a live (`.volatile()`) node.
101
+ *
102
+ * A plugin whose Config declares volatile fields receives a reactive config
103
+ * whose fields expose `get()`, while hand-written config objects and tests pass
104
+ * plain values. Both shapes are supported here so the same code path serves the
105
+ * settings page, `cordis.patch.yml`, and the unit tests.
64
106
  */
65
- function readField<T>(field: unknown, fallback: T): T {
66
- if (field === undefined || field === null) return fallback
67
- const handle = field as Partial<VolatileField<T>>
68
- const value = typeof handle.get === 'function' ? handle.get() : (field as T)
69
- return value === undefined || value === null ? fallback : value
107
+ function liveValue<T>(node: unknown, fallback: T): T {
108
+ if (node === undefined || node === null) return fallback
109
+ const getter = (node as { get?: unknown }).get
110
+ if (typeof getter === 'function') {
111
+ const value = (getter as () => unknown).call(node)
112
+ return value === undefined ? fallback : (value as T)
113
+ }
114
+ return node as T
70
115
  }
71
116
 
72
117
  /** Structural settings-service subset used for persistence. */
@@ -87,15 +132,15 @@ export interface QuickCommandsSettingsFace {
87
132
  setPopupSize(size: QuickPopupSize): Promise<void>
88
133
  }
89
134
 
90
- export function registerSettings(ctx: Context, config: QuickCommandsSettings): QuickCommandsSettingsFace {
135
+ export function registerSettings(ctx: Context, config: QuickCommandsConfig): QuickCommandsSettingsFace {
91
136
  const readWorkspaces = (): WorkspaceCommands[] => {
92
- const value = readField<unknown>(config?.workspaces, [])
137
+ const value = liveValue<unknown>(config?.workspaces, [])
93
138
  return Array.isArray(value) ? value as WorkspaceCommands[] : []
94
139
  }
95
140
  const readAnchor = (): QuickCommandsSettings['popupAnchor'] =>
96
- (readField<unknown>(config?.popupAnchor, 'corner') === 'button' ? 'button' : 'corner')
141
+ (liveValue<unknown>(config?.popupAnchor, 'corner') === 'button' ? 'button' : 'corner')
97
142
  const readPopupSize = (): QuickPopupSize | undefined => {
98
- const value = readField<unknown>(config?.popupSize, undefined)
143
+ const value = liveValue<unknown>(config?.popupSize, undefined)
99
144
  if (typeof value !== 'object' || value === null) return undefined
100
145
  const { width, height } = value as { width?: unknown; height?: unknown }
101
146
  // `{}` (never resized) and any partially written size read as absent.
@@ -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
+ }