@fonlan/dsh-quick-commands 0.1.6 → 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.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Icon compatibility layer over `@deepseek-ai/dsh-client-ui-primitives`.
3
+ *
4
+ * DSH renamed this package's icon exports in 0.1.7: the size-suffixed names
5
+ * (`IconPlayOutline16`) became weight-suffixed ones (`IconPlayOutlineRegular` /
6
+ * `IconPlayOutlineMedium`, matching what the official header plugins use).
7
+ *
8
+ * A client bundle that statically imports a name the host no longer exports
9
+ * receives `undefined`; rendering that element throws, the slot's per-entry
10
+ * error boundary swallows the whole entry, and the surface silently vanishes —
11
+ * exactly how the header ▶ button disappeared on 0.1.7.
12
+ *
13
+ * Resolving by name at runtime (new scheme → legacy scheme → bundled artwork)
14
+ * keeps every surface rendering across DSH alphas. The bundled artwork is only
15
+ * a last resort, so a future rename can never blank a control again.
16
+ */
17
+ import type { ReactElement, ReactNode } from 'react'
18
+ import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
19
+
20
+ /** Props both naming schemes accept (the legacy exports ignore extra fields). */
21
+ export interface IconProps {
22
+ /** Rendered square size in CSS pixels. */
23
+ size?: number
24
+ className?: string
25
+ }
26
+
27
+ type IconComponent = (props: IconProps) => ReactElement
28
+
29
+ const ICON_EXPORTS = primitives as unknown as Record<string, IconComponent | undefined>
30
+
31
+ /** First export name that exists on the running host, else the bundled artwork. */
32
+ function pickIcon(exportNames: readonly string[], fallback: IconComponent): IconComponent {
33
+ for (const name of exportNames) {
34
+ const candidate = ICON_EXPORTS[name]
35
+ if (typeof candidate === 'function') return candidate
36
+ }
37
+ return fallback
38
+ }
39
+
40
+ /** Wrap one locally drawn 16×16 artwork as an icon component. */
41
+ function localIcon(children: ReactNode): IconComponent {
42
+ return function LocalIcon({ size = 16, className }: IconProps): ReactElement {
43
+ return (
44
+ <svg
45
+ width={size}
46
+ height={size}
47
+ viewBox="0 0 16 16"
48
+ fill="none"
49
+ className={className}
50
+ aria-hidden="true"
51
+ >
52
+ {children}
53
+ </svg>
54
+ )
55
+ }
56
+ }
57
+
58
+ /** Run/play glyph for the header quick-run button. */
59
+ export const PlayIcon = pickIcon(
60
+ ['IconPlayOutlineRegular', 'IconPlayOutlineMedium', 'IconPlayOutline16'],
61
+ localIcon(
62
+ <>
63
+ <circle cx="8" cy="8" r="6.5" stroke="currentColor" />
64
+ <path d="M6.6 5.9 10.4 8l-3.8 2.1Z" fill="currentColor" />
65
+ </>,
66
+ ),
67
+ )
68
+
69
+ /** Dismiss glyph (menu header and popup close). */
70
+ export const CloseIcon = pickIcon(
71
+ ['IconCloseOutlineRegular', 'IconCloseOutlineMedium', 'IconCloseOutline16'],
72
+ localIcon(
73
+ <path d="M4.2 4.2 11.8 11.8M11.8 4.2 4.2 11.8" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />,
74
+ ),
75
+ )
76
+
77
+ /** Stop/kill glyph for a running command. */
78
+ export const StopIcon = pickIcon(
79
+ ['IconStopFillRegular', 'IconStopFillMedium', 'IconStopFill16'],
80
+ localIcon(<rect x="4.6" y="4.6" width="6.8" height="6.8" rx="1.6" fill="currentColor" />),
81
+ )
82
+
83
+ /** Add-row glyph in the settings page. */
84
+ export const PlusIcon = pickIcon(
85
+ ['IconPlusOutlineRegular', 'IconPlusOutlineMedium', 'IconPlusOutline16'],
86
+ localIcon(
87
+ <path d="M8 3.4v9.2M3.4 8h9.2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />,
88
+ ),
89
+ )
90
+
91
+ /** Remove-command glyph in the settings page. */
92
+ export const TrashIcon = pickIcon(
93
+ ['IconTrashOutlineRegular', 'IconTrashOutlineMedium', 'IconTrashOutline16'],
94
+ localIcon(
95
+ <>
96
+ <path d="M3.4 4.4h9.2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
97
+ <path
98
+ d="M6.4 4.4V3.1h3.2v1.3M4.9 4.4l.6 8.5h5l.6-8.5"
99
+ stroke="currentColor"
100
+ strokeWidth="1.2"
101
+ strokeLinecap="round"
102
+ strokeLinejoin="round"
103
+ />
104
+ </>,
105
+ ),
106
+ )
@@ -9,12 +9,9 @@
9
9
  * persist while the user types.
10
10
  */
11
11
  import { useEffect, useRef, useState } from 'react'
12
- import {
13
- IconPlusOutline16,
14
- IconTrashOutline16,
15
- } from '@deepseek-ai/dsh-client-ui-primitives'
12
+ import { PlusIcon, TrashIcon } from './icons'
16
13
  import { quickApi } from './api'
17
- import type { QuickRosterEntry, QuickCommandsSettings } from '../shared/contract'
14
+ import { anchorOf, type QuickRosterEntry, type QuickCommandsSettings } from '../shared/contract'
18
15
  import './settings-section.css'
19
16
 
20
17
  interface Props {
@@ -38,7 +35,7 @@ export function QuickCommandsSettingsSection({ t }: Props): JSX.Element {
38
35
  const load = async (): Promise<void> => {
39
36
  try {
40
37
  const [entries, settings] = await Promise.all([quickApi.workspacesList(), quickApi.settingsGet()])
41
- setState({ loaded: true, entries, anchor: settings.popupAnchor })
38
+ setState({ loaded: true, entries, anchor: anchorOf(settings.popupAnchor) })
42
39
  setError(null)
43
40
  } catch (e) {
44
41
  setState((s) => ({ ...s, loaded: true }))
@@ -100,7 +97,7 @@ export function QuickCommandsSettingsSection({ t }: Props): JSX.Element {
100
97
  setState((s) => ({ ...s, anchor }))
101
98
  try {
102
99
  const next = await quickApi.settingsSetAnchor(anchor)
103
- setState((s) => ({ ...s, anchor: next.popupAnchor }))
100
+ setState((s) => ({ ...s, anchor: anchorOf(next.popupAnchor) }))
104
101
  } catch (e) {
105
102
  setError(e instanceof Error ? e.message : String(e))
106
103
  void load()
@@ -179,12 +176,12 @@ export function QuickCommandsSettingsSection({ t }: Props): JSX.Element {
179
176
  title={t('cardRemove')}
180
177
  onClick={() => removeCommand(entry.workspaceId, index)}
181
178
  >
182
- <IconTrashOutline16 size={14} />
179
+ <TrashIcon size={14} />
183
180
  </button>
184
181
  </div>
185
182
  ))}
186
183
  <button type="button" className="qc-settings-add-btn" onClick={() => addCommand(entry.workspaceId)}>
187
- <IconPlusOutline16 size={14} />
184
+ <PlusIcon size={14} />
188
185
  {t('cardAddCommand')}
189
186
  </button>
190
187
  </section>
package/src/index.ts CHANGED
@@ -15,47 +15,62 @@
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 { registerSettings } from './server/settings.js'
18
+ import { QuickCommandsSettingsSchema, registerSettings } from './server/settings.js'
19
19
  import { registerApiRoutes } from './server/rpc.js'
20
+ import type { QuickCommandsSettings } from './shared/contract.js'
20
21
 
21
22
  export const name = '@fonlan/dsh-quick-commands'
22
23
 
23
24
  export const inject = ['settings']
24
25
 
25
- /** No plugin-level config; all configuration lives in the settings namespace. */
26
- export function apply(ctx: Context): void {
27
- const settings = registerSettings(ctx)
28
- const subprocess = ctx.get('subprocess')
29
- if (subprocess === undefined) {
30
- ctx.logger?.warn?.('@fonlan/dsh-quick-commands: subprocess service unavailable; commands cannot run')
31
- return
32
- }
33
- // Remote backend (duck-typed over @dsh-ssh/dsh-ssh): resolved lazily so the
34
- // sshPool service is picked up whenever the SSH plugin loads.
35
- const resolveRemote = (): RemoteBackend | undefined => {
36
- const sshPool = ctx.get('sshPool') as
37
- | { acquire(cfg: { id: string; [key: string]: unknown }): Promise<unknown>; release(): void }
38
- | undefined
39
- if (sshPool === undefined || typeof sshPool.acquire !== 'function' || typeof sshPool.release !== 'function') {
40
- return undefined
41
- }
42
- return {
43
- pool: sshPool as never,
44
- resolveHost: (hostId) => readHostConfig((ns) => {
45
- try {
46
- return (ctx.get('settings') as { get(ns: string): unknown } | undefined)?.get(ns)
47
- } catch {
48
- return undefined
49
- }
50
- }, hostId),
51
- }
52
- }
53
- const runner = new QuickCommandRunner(subprocess as never, () => resolveRemote())
54
- ctx.effect(() => registerApiRoutes(ctx, runner, settings), 'quick-commands: api routes')
55
- ctx.effect(() => () => {
56
- // Terminate every live process when the plugin fiber stops.
57
- for (const id of runner.listAll()) {
58
- runner.kill(id)
26
+ // The entry config IS this plugin's settings document (workspace command sets
27
+ // + popup preferences). Its fields are declared volatile, so the settings
28
+ // plane edits them without remounting the entry and `apply` receives live
29
+ // handles rather than snapshots — see server/settings.ts for the reads.
30
+ export const Config = QuickCommandsSettingsSchema
31
+
32
+ /** Configuration rides the entry config; edits persist through the settings service. */
33
+ export function apply(ctx: Context, config: QuickCommandsSettings): void {
34
+ const settings = registerSettings(ctx, config)
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
+ }
59
66
  }
60
- }, '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
+ })
61
76
  }
package/src/server/rpc.ts CHANGED
@@ -255,10 +255,15 @@ export function registerApiRoutes(ctx: Context, runner: QuickRunner, settings: Q
255
255
  }
256
256
  }
257
257
 
258
- const webServer = ctx.get('webServer')
259
- if (webServer === undefined) return () => undefined
258
+ // The caller reaches this function through ctx.inject(['webServer']), so the
259
+ // service is active by construction. A one-shot `ctx.get('webServer')` guard
260
+ // here used to return an empty disposer whenever the webserver fiber had not
261
+ // finished activating yet (dsh >= 0.1.7 activates entries asynchronously and
262
+ // makes a service readable only once its own init settled — for the webserver
263
+ // that means listening): no route was ever registered and every client POST
264
+ // fell through to the /plugins bundle route, which answers non-GET with 405.
260
265
  return ctx.effect(() => {
261
- return webServer.register({
266
+ return ctx.webServer.register({
262
267
  kind: 'prefix',
263
268
  path: API_PREFIX,
264
269
  handler: async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
@@ -17,6 +17,7 @@
17
17
  import { randomUUID } from 'node:crypto'
18
18
  import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
19
19
  import type { QuickCommandsSettings } from '../shared/contract.js'
20
+ import { localCommandEnv } from './shell-path.js'
20
21
  import {
21
22
  localPathToRemoteRef,
22
23
  resolveRemoteCwd,
@@ -203,12 +204,15 @@ export class QuickCommandRunner implements QuickRunner {
203
204
  stdout: { maxBytes: TAIL_MAX_BYTES },
204
205
  stderr: { maxBytes: TAIL_MAX_BYTES },
205
206
  },
206
- env: {
207
- ...process.env,
207
+ // The login-shell PATH rides in front of the host's own: DSH Desktop runs
208
+ // the host with launchd's minimal PATH, so `npm run typecheck` and
209
+ // `docker compose up -d` only resolve once the user's toolchain is
210
+ // visible. See ./shell-path.ts.
211
+ env: await localCommandEnv({
208
212
  DSH_WORKSPACE: input.workspacePath,
209
213
  DSH_WORKSPACE_ID: input.workspaceId,
210
214
  DSH_WORKSPACE_TITLE: input.workspaceTitle,
211
- },
215
+ }),
212
216
  })
213
217
 
214
218
  const live: LiveRun = {
@@ -1,13 +1,22 @@
1
1
  /**
2
2
  * User-facing plugin settings (per-workspace command sets + popup anchor),
3
- * persisted through the settings service namespace `quick-commands`.
4
- * The DSH settings service requires a lowercase kebab-case namespace
5
- * (/^[a-z][a-z0-9-]*$/), so the scoped package name cannot be used here.
3
+ * carried by this bundle's entry config: dsh >= 0.1.7 owns plugin settings as
4
+ * entry config (the plugin's `Config` schema) and persists edits through
5
+ * `settings.update`.
6
+ *
7
+ * All three fields are declared `volatile()` — that is what makes them
8
+ * writable by the settings plane — and schemastery hands a volatile field to
9
+ * `apply()` as a LIVE HANDLE (`{ get() }`), not as the value. Reading the raw
10
+ * field therefore yields the handle object, which silently destroys the whole
11
+ * document: `Array.isArray(handle)` is false, JSON renders `{}`, and a
12
+ * `.default('corner')` union arrives as the handle rather than a literal.
13
+ * Every read goes through {@link readField} instead, which unwraps handles and
14
+ * still accepts plain values (hosts that resolve the config without the
15
+ * volatile wrapper).
6
16
  */
7
17
  import type { Context } from '@deepseek-ai/cordis'
8
18
  import z from '@deepseek-ai/schemastery'
9
19
  import type { QuickCommandsSettings, QuickCommandEntry, QuickPopupSize, WorkspaceCommands } from '../shared/contract.js'
10
- import type {} from '@deepseek-ai/dsh-settings'
11
20
 
12
21
  export const QUICK_COMMANDS_NS = 'quick-commands'
13
22
 
@@ -28,27 +37,41 @@ const WorkspaceCommandsSchema = z.object({
28
37
  })
29
38
 
30
39
  export const QuickCommandsSettingsSchema: z<QuickCommandsSettings> = z.object({
31
- workspaces: z.array(WorkspaceCommandsSchema).max(500),
32
- popupAnchor: z.union([z.const('corner'), z.const('button')]).default('corner'),
40
+ // Values are addressed by index (array edits), so the whole array is the
41
+ // volatile handle the settings plane swaps in place.
42
+ workspaces: z.array(WorkspaceCommandsSchema).max(500).volatile(),
43
+ popupAnchor: z.union([z.const('corner'), z.const('button')]).default('corner').volatile(),
33
44
  // No default on purpose: a missing popupSize stays ABSENT in the resolved
34
45
  // document, so the client can distinguish "never resized" (per-anchor
35
- // default size) from a user-chosen size.
46
+ // default size) from a user-chosen size. Semantics match the runtime value:
47
+ // never resized resolves to `{}` (schemastery's implicit object default).
36
48
  popupSize: z.object({
37
49
  width: z.number().min(320).max(8192),
38
50
  height: z.number().min(200).max(8192),
39
- }),
51
+ }).volatile(),
40
52
  })
41
53
 
42
- const DEFAULT_SETTINGS: QuickCommandsSettings = {
43
- workspaces: [],
44
- popupAnchor: 'corner',
54
+ /** Live handle schemastery wraps around one `volatile()` config field. */
55
+ interface VolatileField<T> {
56
+ get(): T
45
57
  }
46
58
 
47
- /** Structural settings-service subset; the real service resolves the schema. */
48
- interface SettingsServiceLike {
49
- register(ns: unknown, schema: unknown): unknown
50
- update(ns: unknown, patch: Record<string, unknown>, expectedRevision?: number): Promise<unknown>
51
- mutate?(ns: unknown, ops: readonly unknown[], expectedRevision?: number): Promise<unknown>
59
+ /**
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.
64
+ */
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
70
+ }
71
+
72
+ /** Structural settings-service subset used for persistence. */
73
+ interface SettingsUpdateFace {
74
+ update(ns: string, patch: Record<string, unknown>, expectedRevision?: number): Promise<unknown>
52
75
  }
53
76
 
54
77
  /** Settings service face for the quick-commands engine. */
@@ -64,35 +87,57 @@ export interface QuickCommandsSettingsFace {
64
87
  setPopupSize(size: QuickPopupSize): Promise<void>
65
88
  }
66
89
 
67
- export function registerSettings(ctx: Context): QuickCommandsSettingsFace {
68
- let current: QuickCommandsSettings = { ...DEFAULT_SETTINGS, workspaces: [] }
69
- let service: SettingsServiceLike | undefined
90
+ export function registerSettings(ctx: Context, config: QuickCommandsSettings): QuickCommandsSettingsFace {
91
+ const readWorkspaces = (): WorkspaceCommands[] => {
92
+ const value = readField<unknown>(config?.workspaces, [])
93
+ return Array.isArray(value) ? value as WorkspaceCommands[] : []
94
+ }
95
+ const readAnchor = (): QuickCommandsSettings['popupAnchor'] =>
96
+ (readField<unknown>(config?.popupAnchor, 'corner') === 'button' ? 'button' : 'corner')
97
+ const readPopupSize = (): QuickPopupSize | undefined => {
98
+ const value = readField<unknown>(config?.popupSize, undefined)
99
+ if (typeof value !== 'object' || value === null) return undefined
100
+ const { width, height } = value as { width?: unknown; height?: unknown }
101
+ // `{}` (never resized) and any partially written size read as absent.
102
+ if (typeof width !== 'number' || typeof height !== 'number') return undefined
103
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return undefined
104
+ return { width: Math.round(width), height: Math.round(height) }
105
+ }
106
+ /** Current document, rebuilt from the live config fields on every read. */
107
+ const readDocument = (): QuickCommandsSettings => {
108
+ const size = readPopupSize()
109
+ return {
110
+ workspaces: readWorkspaces(),
111
+ popupAnchor: readAnchor(),
112
+ ...(size === undefined ? {} : { popupSize: size }),
113
+ }
114
+ }
115
+ let service: SettingsUpdateFace | undefined
70
116
 
117
+ // Optional settings service: without one the commands still run from the
118
+ // entry config; they just cannot persist edits.
71
119
  ctx.inject(['settings'], (sctx) => {
72
- service = sctx.settings as unknown as SettingsServiceLike
73
- const scope = service.register(QUICK_COMMANDS_NS, QuickCommandsSettingsSchema) as {
74
- get(): QuickCommandsSettings
75
- watch(callback: (next: QuickCommandsSettings) => void): () => void
76
- }
77
- current = scope.get()
78
- scope.watch((next) => { current = next })
120
+ service = (sctx as unknown as { settings?: SettingsUpdateFace }).settings
79
121
  })
80
122
 
81
123
  async function persist(patch: Record<string, unknown>): Promise<void> {
82
124
  if (service === undefined) {
83
125
  throw new Error('@fonlan/dsh-quick-commands: settings service is not available in this profile')
84
126
  }
127
+ // The write lands in this entry's config: the settings plane either swaps
128
+ // the volatile handles in place or re-applies the entry — both paths are
129
+ // picked up by the next read, which is why no local mirror is kept here.
85
130
  await service.update(QUICK_COMMANDS_NS, patch)
86
131
  }
87
132
 
88
133
  return {
89
- get: () => current,
134
+ get: readDocument,
90
135
  commandsOf: (workspaceId) => {
91
- const set = current.workspaces.find((w) => w.workspaceId === workspaceId)
136
+ const set = readWorkspaces().find((w) => w.workspaceId === workspaceId)
92
137
  return set === undefined ? [] : set.commands
93
138
  },
94
139
  setCommands: async (workspaceId, commands) => {
95
- const next: WorkspaceCommands[] = current.workspaces.filter((w) => w.workspaceId !== workspaceId)
140
+ const next = readWorkspaces().filter((w) => w.workspaceId !== workspaceId)
96
141
  if (commands.length > 0) next.push({ workspaceId, commands })
97
142
  await persist({ workspaces: next })
98
143
  },
@@ -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
+ }
@@ -38,6 +38,16 @@ export interface QuickCommandsSettings {
38
38
  popupSize?: QuickPopupSize
39
39
  }
40
40
 
41
+ /**
42
+ * The document's anchor as a usable literal. Wire data is defensive by
43
+ * contract: an older host can still answer with schemastery's empty-object
44
+ * resolution (`{}`) for this field, which must never reach a CSS class name or
45
+ * a radio `checked` comparison.
46
+ */
47
+ export function anchorOf(value: unknown): 'corner' | 'button' {
48
+ return value === 'button' ? 'button' : 'corner'
49
+ }
50
+
41
51
  /** Placeholder substitution values resolved for one execution. */
42
52
  export interface QuickRunPlaceholders {
43
53
  /** Workspace root path (also the execution cwd). */