@fonlan/dsh-quick-commands 0.1.0
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/LICENSE +21 -0
- package/README.en.md +149 -0
- package/README.md +108 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +1036 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +904 -0
- package/lib/types/index.d.ts +19 -0
- package/lib/types/server/remote.d.ts +117 -0
- package/lib/types/server/rpc.d.ts +4 -0
- package/lib/types/server/runner.d.ts +114 -0
- package/lib/types/server/settings.d.ts +24 -0
- package/lib/types/shared/contract.d.ts +87 -0
- package/package.json +108 -0
- package/src/client/api.ts +76 -0
- package/src/client/header.tsx +534 -0
- package/src/client/index.tsx +50 -0
- package/src/client/locales.ts +106 -0
- package/src/client/quick-commands.css +580 -0
- package/src/client/run-state.ts +125 -0
- package/src/client/settings-card.tsx +209 -0
- package/src/client/slot-augment.d.ts +14 -0
- package/src/index.ts +61 -0
- package/src/server/remote.ts +304 -0
- package/src/server/rpc.ts +286 -0
- package/src/server/runner.ts +471 -0
- package/src/server/settings.ts +106 -0
- package/src/shared/contract.ts +92 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quick-commands settings card (设置 → 插件配置): an expandable plugin card —
|
|
3
|
+
* header (title + description + chevron) collapsed by default, body reveals
|
|
4
|
+
* the workspace × command-set editor and the popup anchor preference. Body
|
|
5
|
+
* edits instant-save per change; partial rows (empty name/command) persist
|
|
6
|
+
* while the user types.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors the official PluginCard disclosure pattern (header button with
|
|
9
|
+
* aria-expanded, rotate-180 chevron, body separated by a top border).
|
|
10
|
+
*/
|
|
11
|
+
import { useEffect, useRef, useState } from 'react'
|
|
12
|
+
import {
|
|
13
|
+
IconChevronDownOutline14,
|
|
14
|
+
IconPlusOutline16,
|
|
15
|
+
IconTrashOutline16,
|
|
16
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
17
|
+
import { quickApi } from './api'
|
|
18
|
+
import type { QuickRosterEntry, QuickCommandsSettings } from '../shared/contract'
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
t: (key: string) => string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface RosterState {
|
|
25
|
+
loaded: boolean
|
|
26
|
+
entries: QuickRosterEntry[]
|
|
27
|
+
anchor: QuickCommandsSettings['popupAnchor']
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type CommandDraft = { name: string; command: string }
|
|
31
|
+
|
|
32
|
+
export function QuickCommandsSettingsCard({ t }: Props): JSX.Element {
|
|
33
|
+
// Card-local disclosure state (a reading gesture, like the official card).
|
|
34
|
+
const [open, setOpen] = useState(false)
|
|
35
|
+
const [state, setState] = useState<RosterState>({ loaded: false, entries: [], anchor: 'corner' })
|
|
36
|
+
const [error, setError] = useState<string | null>(null)
|
|
37
|
+
// Guard concurrent saves: the last write wins via a monotonically bumped token.
|
|
38
|
+
const saveToken = useRef(0)
|
|
39
|
+
|
|
40
|
+
const load = async (): Promise<void> => {
|
|
41
|
+
try {
|
|
42
|
+
const [entries, settings] = await Promise.all([quickApi.workspacesList(), quickApi.settingsGet()])
|
|
43
|
+
setState({ loaded: true, entries, anchor: settings.popupAnchor })
|
|
44
|
+
setError(null)
|
|
45
|
+
} catch (e) {
|
|
46
|
+
setState((s) => ({ ...s, loaded: true }))
|
|
47
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
void load()
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
/** Instant-save the workspace's whole command set; optimistic UI. */
|
|
56
|
+
const saveCommands = async (workspaceId: string, commands: CommandDraft[]): Promise<void> => {
|
|
57
|
+
const token = ++saveToken.current
|
|
58
|
+
try {
|
|
59
|
+
const next = await quickApi.settingsSetCommands(workspaceId, commands)
|
|
60
|
+
if (token !== saveToken.current) return
|
|
61
|
+
setState((s) => ({
|
|
62
|
+
...s,
|
|
63
|
+
anchor: next.popupAnchor,
|
|
64
|
+
entries: s.entries.map((e) => (e.workspaceId === workspaceId ? { ...e, commands } : e)),
|
|
65
|
+
}))
|
|
66
|
+
setError(null)
|
|
67
|
+
} catch (e) {
|
|
68
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
69
|
+
// Re-sync to server truth on failure.
|
|
70
|
+
void load()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Compute the next command list for a workspace, THEN save + update state. */
|
|
75
|
+
const mutateCommands = (workspaceId: string, next: (current: CommandDraft[]) => CommandDraft[]): void => {
|
|
76
|
+
const entry = state.entries.find((e) => e.workspaceId === workspaceId)
|
|
77
|
+
if (entry === undefined) return
|
|
78
|
+
const commands = next(entry.commands)
|
|
79
|
+
// Optimistic update first (row stays visible immediately), then persist.
|
|
80
|
+
setState((s) => ({
|
|
81
|
+
...s,
|
|
82
|
+
entries: s.entries.map((e) => (e.workspaceId === workspaceId ? { ...e, commands } : e)),
|
|
83
|
+
}))
|
|
84
|
+
void saveCommands(workspaceId, commands)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const patchEntry = (workspaceId: string, index: number, field: 'name' | 'command', value: string): void => {
|
|
88
|
+
mutateCommands(workspaceId, (current) =>
|
|
89
|
+
current.map((c, i) => (i === index ? { ...c, [field]: value } : c)),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const addCommand = (workspaceId: string): void => {
|
|
94
|
+
mutateCommands(workspaceId, (current) => [...current, { name: '', command: '' }])
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const removeCommand = (workspaceId: string, index: number): void => {
|
|
98
|
+
mutateCommands(workspaceId, (current) => current.filter((_, i) => i !== index))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const setAnchor = async (anchor: QuickCommandsSettings['popupAnchor']): Promise<void> => {
|
|
102
|
+
setState((s) => ({ ...s, anchor }))
|
|
103
|
+
try {
|
|
104
|
+
const next = await quickApi.settingsSetAnchor(anchor)
|
|
105
|
+
setState((s) => ({ ...s, anchor: next.popupAnchor }))
|
|
106
|
+
} catch (e) {
|
|
107
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
108
|
+
void load()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const title = t('cardTitle')
|
|
113
|
+
const description = t('cardDescription')
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<li className={'qc-settings-card' + (open ? ' qc-settings-card-open' : '')}>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
className="qc-settings-header"
|
|
120
|
+
aria-expanded={open}
|
|
121
|
+
aria-label={`${t('collapseOrExpand')}: ${title}`}
|
|
122
|
+
onClick={() => setOpen((v) => !v)}
|
|
123
|
+
>
|
|
124
|
+
<span className="qc-settings-head-text">
|
|
125
|
+
<span className="qc-settings-name">{title}</span>
|
|
126
|
+
<span className="qc-settings-description">{description}</span>
|
|
127
|
+
</span>
|
|
128
|
+
<IconChevronDownOutline14 className={'qc-settings-chevron' + (open ? ' qc-settings-chevron-open' : '')} />
|
|
129
|
+
</button>
|
|
130
|
+
|
|
131
|
+
{open && (
|
|
132
|
+
<div className="qc-settings-body">
|
|
133
|
+
<p className="qc-settings-intro">{t('cardIntro')}</p>
|
|
134
|
+
|
|
135
|
+
<div className="qc-settings-anchor">
|
|
136
|
+
<span className="qc-settings-anchor-label">{t('cardAnchor')}</span>
|
|
137
|
+
<label className="qc-settings-radio">
|
|
138
|
+
<input
|
|
139
|
+
type="radio"
|
|
140
|
+
name="qc-anchor"
|
|
141
|
+
checked={state.anchor === 'corner'}
|
|
142
|
+
onChange={() => void setAnchor('corner')}
|
|
143
|
+
/>
|
|
144
|
+
{t('cardAnchorCorner')}
|
|
145
|
+
</label>
|
|
146
|
+
<label className="qc-settings-radio">
|
|
147
|
+
<input
|
|
148
|
+
type="radio"
|
|
149
|
+
name="qc-anchor"
|
|
150
|
+
checked={state.anchor === 'button'}
|
|
151
|
+
onChange={() => void setAnchor('button')}
|
|
152
|
+
/>
|
|
153
|
+
{t('cardAnchorButton')}
|
|
154
|
+
</label>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{error !== null && <div className="qc-settings-error">{t('cardSaveError')}: {error}</div>}
|
|
158
|
+
|
|
159
|
+
{state.loaded && state.entries.length === 0 && (
|
|
160
|
+
<p className="qc-settings-empty">{t('cardNoWorkspaces')}</p>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
{state.entries.map((entry) => (
|
|
164
|
+
<section key={entry.workspaceId} className="qc-settings-workspace">
|
|
165
|
+
<header className="qc-settings-workspace-head">
|
|
166
|
+
<span className="qc-settings-workspace-title">{entry.title || entry.path || entry.workspaceId}</span>
|
|
167
|
+
<span className="qc-settings-workspace-path">{entry.path}</span>
|
|
168
|
+
</header>
|
|
169
|
+
{entry.commands.length === 0 && (
|
|
170
|
+
<p className="qc-settings-no-commands">{t('cardNoCommands')}</p>
|
|
171
|
+
)}
|
|
172
|
+
{entry.commands.map((command, index) => (
|
|
173
|
+
<div key={`${entry.workspaceId}-${index}`} className="qc-settings-command">
|
|
174
|
+
<input
|
|
175
|
+
className="qc-settings-field qc-settings-field-name"
|
|
176
|
+
placeholder={t('cardName')}
|
|
177
|
+
value={command.name}
|
|
178
|
+
aria-label={t('cardName')}
|
|
179
|
+
onChange={(e) => patchEntry(entry.workspaceId, index, 'name', e.target.value)}
|
|
180
|
+
/>
|
|
181
|
+
<input
|
|
182
|
+
className="qc-settings-field qc-settings-field-command"
|
|
183
|
+
placeholder={t('cardCommand')}
|
|
184
|
+
value={command.command}
|
|
185
|
+
aria-label={t('cardCommand')}
|
|
186
|
+
onChange={(e) => patchEntry(entry.workspaceId, index, 'command', e.target.value)}
|
|
187
|
+
/>
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
className="qc-settings-icon-btn"
|
|
191
|
+
aria-label={t('cardRemove')}
|
|
192
|
+
title={t('cardRemove')}
|
|
193
|
+
onClick={() => removeCommand(entry.workspaceId, index)}
|
|
194
|
+
>
|
|
195
|
+
<IconTrashOutline16 size={14} />
|
|
196
|
+
</button>
|
|
197
|
+
</div>
|
|
198
|
+
))}
|
|
199
|
+
<button type="button" className="qc-settings-add-btn" onClick={() => addCommand(entry.workspaceId)}>
|
|
200
|
+
<IconPlusOutline16 size={14} />
|
|
201
|
+
{t('cardAddCommand')}
|
|
202
|
+
</button>
|
|
203
|
+
</section>
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</li>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declare this plugin's locale namespace so slot registrations can type-check
|
|
3
|
+
* the `locale` seat. zh is the key source of truth; en must carry the exact
|
|
4
|
+
* same key set (checked at runtime by the locale registry).
|
|
5
|
+
*/
|
|
6
|
+
import type { zh } from './locales'
|
|
7
|
+
|
|
8
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
9
|
+
interface LocaleNamespaceMap {
|
|
10
|
+
'quick-commands': keyof typeof zh
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export {}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fonlan/dsh-quick-commands host half: per-workspace quick commands.
|
|
3
|
+
*
|
|
4
|
+
* Registers the `quick-commands` settings namespace (workspaceId → command
|
|
5
|
+
* sets + popup anchor), owns the subprocess runner that executes commands with
|
|
6
|
+
* a fixed workspace cwd, and serves the fenced JSON API the client calls for
|
|
7
|
+
* config CRUD and live command output.
|
|
8
|
+
*
|
|
9
|
+
* Remote workspaces: when the workspace path is a @dsh-ssh/dsh-ssh placeholder
|
|
10
|
+
* directory and the `sshPool` service is present, commands stream over SSH on
|
|
11
|
+
* the remote host (same tail/offset semantics as local runs). The SSH backend
|
|
12
|
+
* is resolved lazily per run, so plugin load order is irrelevant and the
|
|
13
|
+
* absence of dsh-ssh degrades cleanly to local-only behaviour.
|
|
14
|
+
*/
|
|
15
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
16
|
+
import { QuickCommandRunner, type RemoteBackend } from './server/runner.js'
|
|
17
|
+
import { readHostConfig } from './server/remote.js'
|
|
18
|
+
import { registerSettings } from './server/settings.js'
|
|
19
|
+
import { registerApiRoutes } from './server/rpc.js'
|
|
20
|
+
|
|
21
|
+
export const name = '@fonlan/dsh-quick-commands'
|
|
22
|
+
|
|
23
|
+
export const inject = ['settings']
|
|
24
|
+
|
|
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)
|
|
59
|
+
}
|
|
60
|
+
}, 'quick-commands: process cleanup')
|
|
61
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote (SSH) execution support: detect that a workspace path is a dsh-ssh
|
|
3
|
+
* placeholder directory, and stream a quick command over an acquired SSH
|
|
4
|
+
* connection.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately dependency-free: the @dsh-ssh/dsh-ssh host plugin is consumed
|
|
7
|
+
* only through duck-typed services (`sshPool` service + the `dsh-ssh-hosts`
|
|
8
|
+
* settings namespace). When that plugin is absent everything degrades to the
|
|
9
|
+
* local path (the runner keeps its subprocess backend untouched).
|
|
10
|
+
*
|
|
11
|
+
* The placeholder-path contract mirrors dsh-ssh/src/router.js exactly (it is
|
|
12
|
+
* that plugin's public wire format):
|
|
13
|
+
* root = $DSH_SSH_REMOTE_ROOT | $DSH_HOME/remote | ~/.dsh/remote
|
|
14
|
+
* layout = <root>/<hostId>/<base64url(remote absolute path)>
|
|
15
|
+
* The encoded segment must decode to an absolute POSIX path (starts with
|
|
16
|
+
* '/'); anything else is never misdetected as remote.
|
|
17
|
+
*/
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
|
|
21
|
+
/** Remote host id + remote working directory decoded from a placeholder path. */
|
|
22
|
+
export interface RemoteWorkspaceRef {
|
|
23
|
+
hostId: string
|
|
24
|
+
/** Remote absolute path of the workspace root. */
|
|
25
|
+
remotePath: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── placeholder path detection (mirrors dsh-ssh/src/router.js) ──────────────
|
|
29
|
+
|
|
30
|
+
export function remoteRoot(env: NodeJS.ProcessEnv = process.env): string {
|
|
31
|
+
if (env.DSH_SSH_REMOTE_ROOT !== undefined && env.DSH_SSH_REMOTE_ROOT !== '') {
|
|
32
|
+
return path.resolve(String(env.DSH_SSH_REMOTE_ROOT))
|
|
33
|
+
}
|
|
34
|
+
const dshHome =
|
|
35
|
+
env.DSH_HOME !== undefined && env.DSH_HOME !== ''
|
|
36
|
+
? String(env.DSH_HOME)
|
|
37
|
+
: path.join(os.homedir(), '.dsh')
|
|
38
|
+
return path.join(dshHome, 'remote')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isValidHostId(hostId: string): boolean {
|
|
42
|
+
if (hostId.length === 0) return false
|
|
43
|
+
if (hostId === '.' || hostId === '..') return false
|
|
44
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(hostId)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function encodeRemotePath(remotePath: string): string {
|
|
48
|
+
return Buffer.from(String(remotePath), 'utf8').toString('base64url')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function decodeRemotePath(encoded: string): string | null {
|
|
52
|
+
if (typeof encoded !== 'string' || encoded.length === 0) return null
|
|
53
|
+
if (!/^[A-Za-z0-9_-]+$/.test(encoded)) return null
|
|
54
|
+
let text: string
|
|
55
|
+
try {
|
|
56
|
+
text = Buffer.from(encoded, 'base64url').toString('utf8')
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
if (encodeRemotePath(text) !== encoded) return null
|
|
61
|
+
return text
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Local absolute path → { hostId, remotePath } when it is a dsh-ssh
|
|
66
|
+
* placeholder (`<root>/<hostId>/<encoded>`), otherwise null. A coincidentally
|
|
67
|
+
* matching real directory is never misdetected: the encoded segment must
|
|
68
|
+
* round-trip to an absolute POSIX path.
|
|
69
|
+
*/
|
|
70
|
+
export function localPathToRemoteRef(localPath: string, env: NodeJS.ProcessEnv = process.env): RemoteWorkspaceRef | null {
|
|
71
|
+
if (typeof localPath !== 'string' || localPath.length === 0) return null
|
|
72
|
+
const root = remoteRoot(env)
|
|
73
|
+
const rel = path.relative(root, localPath)
|
|
74
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null
|
|
75
|
+
const segments = rel.split(path.sep)
|
|
76
|
+
if (segments.length !== 2) return null
|
|
77
|
+
const hostId = segments[0]
|
|
78
|
+
const encoded = segments[1]
|
|
79
|
+
if (!isValidHostId(hostId)) return null
|
|
80
|
+
const remotePath = decodeRemotePath(encoded)
|
|
81
|
+
if (remotePath === null) return null
|
|
82
|
+
if (!remotePath.startsWith('/')) return null
|
|
83
|
+
return { hostId, remotePath }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Map a session cwd (which may be inside the placeholder workspace, e.g. a
|
|
88
|
+
* subdirectory of the workspace root) onto the remote filesystem. Only paths
|
|
89
|
+
* strictly inside the placeholder are re-anchored; anything else falls back to
|
|
90
|
+
* the workspace's remote root.
|
|
91
|
+
*/
|
|
92
|
+
export function resolveRemoteCwd(sessionCwd: string, workspaceLocalPath: string, remotePath: string): string {
|
|
93
|
+
const rel = path.relative(workspaceLocalPath, sessionCwd)
|
|
94
|
+
if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) {
|
|
95
|
+
return path.posix.resolve(remotePath, rel.split(path.sep).join('/'))
|
|
96
|
+
}
|
|
97
|
+
return remotePath
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── duck-typed SSH surface (provided by @dsh-ssh/dsh-ssh at runtime) ─────────
|
|
101
|
+
|
|
102
|
+
/** Streaming chunk the remote exec yields before the final exit-code item. */
|
|
103
|
+
export interface RemoteExecChunk {
|
|
104
|
+
stream: 'stdout' | 'stderr'
|
|
105
|
+
chunk: Buffer
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The terminal item yielded by execStream: remote channel exit code. */
|
|
109
|
+
export interface RemoteExecExit {
|
|
110
|
+
exitCode: number
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type RemoteExecEvent = RemoteExecChunk | RemoteExecExit
|
|
114
|
+
|
|
115
|
+
/** SshConn minimal surface (see dsh-ssh ssh-core SshConn). */
|
|
116
|
+
export interface SshConnLike {
|
|
117
|
+
execStream(cmd: string, opts?: { cwd?: string; timeoutMs?: number }): AsyncGenerator<RemoteExecEvent>
|
|
118
|
+
exec(cmd: string, opts?: { cwd?: string; timeoutMs?: number }): Promise<{ code: number; signal: string | null; stdout: string; stderr: string }>
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** SshPool minimal surface (see dsh-ssh ssh-core SshPool). */
|
|
122
|
+
export interface SshPoolLike {
|
|
123
|
+
acquire(cfg: { id: string; [key: string]: unknown }): Promise<SshConnLike>
|
|
124
|
+
release(): void
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Error carrying a SSH-stage tag (mirrors dsh-ssh SshError.stage). */
|
|
128
|
+
export interface SshStageError {
|
|
129
|
+
stage?: string
|
|
130
|
+
message?: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read one host's connection config from the `dsh-ssh-hosts` settings
|
|
135
|
+
* namespace (id → HostConfig dict, as written by the @dsh-ssh/dsh-ssh
|
|
136
|
+
* settings page). Returns `{ ...host, id }` for pool.acquire, or undefined
|
|
137
|
+
* when the host is not configured. `get` is the settings.get(ns) reader.
|
|
138
|
+
*/
|
|
139
|
+
export function readHostConfig(
|
|
140
|
+
get: (ns: string) => unknown,
|
|
141
|
+
hostId: string,
|
|
142
|
+
): { id: string; [key: string]: unknown } | undefined {
|
|
143
|
+
let doc: unknown
|
|
144
|
+
try {
|
|
145
|
+
doc = get('dsh-ssh-hosts')
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined
|
|
148
|
+
}
|
|
149
|
+
if (typeof doc !== 'object' || doc === null) return undefined
|
|
150
|
+
const hosts = (doc as { hosts?: Record<string, unknown> }).hosts
|
|
151
|
+
if (typeof hosts !== 'object' || hosts === null) return undefined
|
|
152
|
+
const host = hosts[hostId]
|
|
153
|
+
if (typeof host !== 'object' || host === null) return undefined
|
|
154
|
+
return { ...(host as Record<string, unknown>), id: hostId }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isChunk(item: RemoteExecEvent): item is RemoteExecChunk {
|
|
158
|
+
return typeof item === 'object' && item !== null && 'stream' in item
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Single-quote one string for a POSIX shell (' → '\''). */
|
|
162
|
+
function shellQuoteSingle(s: string): string {
|
|
163
|
+
return "'" + String(s).replace(/'/g, "'\\''") + "'"
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** stderr marker line: the remote process-group leader's pid ($$ of the sshd shell). */
|
|
167
|
+
const PID_MARKER = '__QC_PID__'
|
|
168
|
+
|
|
169
|
+
// ── remote quick-command executor ────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* One remote execution stream. Consumes conn.execStream in the background,
|
|
173
|
+
* appends decoded chunks into the caller-provided tail buffers, and records the
|
|
174
|
+
* terminal outcome.
|
|
175
|
+
*
|
|
176
|
+
* The command runs on the remote shell that sshd spawns for the channel (that
|
|
177
|
+
* shell is the session/process-group leader; `$$` is its pid AND its pgid). The
|
|
178
|
+
* leader pid is probed by prefixing `echo <marker> $$ >&2` to the command, so
|
|
179
|
+
* `kill` can terminate the WHOLE process group (`kill -TERM -- -<pid>`), which
|
|
180
|
+
* `pkill -f` alone cannot do for a child like `sleep 30` whose cmdline does not
|
|
181
|
+
* match the original command text. The probe line is filtered out of the stderr
|
|
182
|
+
* stream the user sees (a short buffer handles chunk-cut lines).
|
|
183
|
+
*/
|
|
184
|
+
export class RemoteStreamExecutor {
|
|
185
|
+
private cmd = ''
|
|
186
|
+
private cwd = ''
|
|
187
|
+
private started = false
|
|
188
|
+
/** Remote process-group leader pid (parse the marker line; null until seen). */
|
|
189
|
+
private leaderPid: number | null = null
|
|
190
|
+
/** Raw stderr head buffer for the (possibly chunk-cut) marker line. */
|
|
191
|
+
private markerBuf = ''
|
|
192
|
+
|
|
193
|
+
constructor(
|
|
194
|
+
private readonly conn: SshConnLike,
|
|
195
|
+
private readonly pool: SshPoolLike,
|
|
196
|
+
) {}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Start consuming the remote stream, settling the returned promise when the
|
|
200
|
+
* stream ends. `onData` sees decoded text chunks, `onExit` the terminal
|
|
201
|
+
* outcome (called exactly once, also on connection failure).
|
|
202
|
+
*/
|
|
203
|
+
async start(
|
|
204
|
+
cmd: string,
|
|
205
|
+
cwd: string,
|
|
206
|
+
onData: (stream: 'stdout' | 'stderr', text: string) => void,
|
|
207
|
+
onExit: (exit: { exitCode: number | null; signal: string | null; error?: string }) => void,
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
this.cmd = cmd
|
|
210
|
+
this.cwd = cwd
|
|
211
|
+
this.started = true
|
|
212
|
+
// Probe the group leader pid on stderr, then run the user command in a
|
|
213
|
+
// subshell so a stray `exit` cannot end our session shell early.
|
|
214
|
+
const wrapped = `echo ${PID_MARKER} $$ >&2; cd ${shellQuoteSingle(cwd)} && ( ${cmd} )`
|
|
215
|
+
let exitCode: number | null = null
|
|
216
|
+
let signal: string | null = null
|
|
217
|
+
try {
|
|
218
|
+
for await (const raw of this.conn.execStream(wrapped)) {
|
|
219
|
+
const item = raw as RemoteExecEvent
|
|
220
|
+
if (isChunk(item)) {
|
|
221
|
+
if (item.stream === 'stdout') {
|
|
222
|
+
onData('stdout', item.chunk.toString('utf8'))
|
|
223
|
+
} else {
|
|
224
|
+
this.emitStderr(item.chunk.toString('utf8'), onData)
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
exitCode = item.exitCode
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
const e = error as SshStageError
|
|
232
|
+
onExit({
|
|
233
|
+
exitCode: null,
|
|
234
|
+
signal: null,
|
|
235
|
+
error: (e?.message ?? String(error)) + (e?.stage ? ` (stage: ${e.stage})` : ''),
|
|
236
|
+
})
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
onExit({ exitCode, signal })
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Route stderr text, swallowing the (possibly split) marker line. */
|
|
243
|
+
private emitStderr(text: string, onData: (stream: 'stdout' | 'stderr', text: string) => void): void {
|
|
244
|
+
if (this.leaderPid !== null) {
|
|
245
|
+
onData('stderr', text)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
this.markerBuf += text
|
|
249
|
+
const nl = this.markerBuf.indexOf('\n')
|
|
250
|
+
if (nl === -1) {
|
|
251
|
+
// No newline yet: still potentially the marker line. Flush only when the
|
|
252
|
+
// buffer grows absurdly (marker lines are tiny).
|
|
253
|
+
if (this.markerBuf.length > 4096) {
|
|
254
|
+
onData('stderr', this.markerBuf)
|
|
255
|
+
this.markerBuf = ''
|
|
256
|
+
}
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
const head = this.markerBuf.slice(0, nl)
|
|
260
|
+
this.markerBuf = this.markerBuf.slice(nl + 1)
|
|
261
|
+
const m = /^__QC_PID__\s+(\d+)\s*$/.exec(head)
|
|
262
|
+
if (m !== null) {
|
|
263
|
+
this.leaderPid = Number(m[1])
|
|
264
|
+
} else {
|
|
265
|
+
onData('stderr', head + '\n')
|
|
266
|
+
}
|
|
267
|
+
if (this.markerBuf !== '') {
|
|
268
|
+
const rest = this.markerBuf
|
|
269
|
+
this.markerBuf = ''
|
|
270
|
+
onData('stderr', rest)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The probed group-leader pid, if the marker line has been seen. */
|
|
275
|
+
get pid(): number | null {
|
|
276
|
+
return this.leaderPid
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Terminate the remote command's whole process group (best-effort). With the
|
|
281
|
+
* probed leader pid this is a true group kill (covers children like sleep);
|
|
282
|
+
* before the probe arrives (or when it never does) it falls back to a
|
|
283
|
+
* `pkill -f` on the exact `cd <cwd> && <cmd>` channel command line. The
|
|
284
|
+
* original stream then closes; the caller records signal 'TERM'.
|
|
285
|
+
*/
|
|
286
|
+
async kill(): Promise<void> {
|
|
287
|
+
if (!this.started) return
|
|
288
|
+
if (this.leaderPid !== null) {
|
|
289
|
+
try {
|
|
290
|
+
await this.conn.exec(`kill -TERM -- -${this.leaderPid} 2>/dev/null; pkill -TERM -P ${this.leaderPid} 2>/dev/null; true`, { timeoutMs: 10_000 })
|
|
291
|
+
return
|
|
292
|
+
} catch {
|
|
293
|
+
// Fall through to pkill.
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const pattern = 'cd ' + (this.cwd ? this.cwd + ' && ' : '') + this.cmd
|
|
297
|
+
const escaped = pattern.replace(/[.*+?^$[\]{}()|/\\]/g, '\\$&').replace(/'/g, "'\\''")
|
|
298
|
+
try {
|
|
299
|
+
await this.conn.exec(`pkill -TERM -f '${escaped}' 2>/dev/null; true`, { timeoutMs: 10_000 })
|
|
300
|
+
} catch {
|
|
301
|
+
// Best-effort: the stream may already have settled.
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|