@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.
- package/lib/client.js +138 -13
- package/lib/client.js.map +1 -1
- package/lib/index.js +206 -52
- package/lib/types/index.d.ts +4 -2
- package/lib/types/server/settings.d.ts +14 -4
- package/lib/types/server/shell-path.d.ts +69 -0
- package/lib/types/shared/contract.d.ts +7 -0
- package/package.json +30 -27
- package/src/client/header.tsx +15 -12
- package/src/client/icons.tsx +106 -0
- package/src/client/settings-section.tsx +6 -9
- package/src/index.ts +51 -36
- package/src/server/rpc.ts +8 -3
- package/src/server/runner.ts +7 -3
- package/src/server/settings.ts +74 -29
- package/src/server/shell-path.ts +160 -0
- package/src/shared/contract.ts +10 -0
|
@@ -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
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
package/src/server/settings.ts
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* User-facing plugin settings (per-workspace command sets + popup anchor),
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
/** Live handle schemastery wraps around one `volatile()` config field. */
|
|
55
|
+
interface VolatileField<T> {
|
|
56
|
+
get(): T
|
|
45
57
|
}
|
|
46
58
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
|
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:
|
|
134
|
+
get: readDocument,
|
|
90
135
|
commandsOf: (workspaceId) => {
|
|
91
|
-
const set =
|
|
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
|
|
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
|
+
}
|
package/src/shared/contract.ts
CHANGED
|
@@ -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). */
|