@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,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fenced JSON API under /plugins/@fonlan/dsh-quick-commands/api/<method>.
|
|
3
|
+
* Same browser-trust fence as task-kanban's /api gateway: loopback Host-header
|
|
4
|
+
* or configured trustedHosts, same-origin browser markers.
|
|
5
|
+
*
|
|
6
|
+
* Methods (POST):
|
|
7
|
+
* settings.get → QuickCommandsSettings
|
|
8
|
+
* settings.setCommands → { workspaceId, commands } → QuickCommandsSettings
|
|
9
|
+
* settings.setAnchor → { anchor } → QuickCommandsSettings
|
|
10
|
+
* settings.setPopupSize → { width, height } → QuickCommandsSettings
|
|
11
|
+
* workspaces.list → QuickRosterEntry[] (DSH workspaces × command sets)
|
|
12
|
+
* run.start → { workspaceId, commandName, sessionCwd } → QuickRunView
|
|
13
|
+
* run.poll → { runId, stdoutFrom, stderrFrom } → RunPollResult
|
|
14
|
+
* run.kill → { runId } → { killed }
|
|
15
|
+
* run.list → { workspaceId } → live runs
|
|
16
|
+
*/
|
|
17
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
18
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
19
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
20
|
+
import type { QuickCommandsSettingsFace } from './settings.js'
|
|
21
|
+
import type { QuickRunner } from './runner.js'
|
|
22
|
+
import type { ApiEnvelope, QuickRosterEntry, QuickRunView } from '../shared/contract.js'
|
|
23
|
+
import { localPathToRemoteRef } from './remote.js'
|
|
24
|
+
|
|
25
|
+
const API_PREFIX = '/plugins/@fonlan/dsh-quick-commands/api'
|
|
26
|
+
|
|
27
|
+
// ── browser-trust fence (mirrors dsh-client-connection's api-request-trust) ──
|
|
28
|
+
|
|
29
|
+
function header(headers: IncomingMessage['headers'], name: string): string | undefined {
|
|
30
|
+
const value = headers[name]
|
|
31
|
+
return typeof value === 'string' ? value : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
35
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
36
|
+
const parts = hostname.split('.')
|
|
37
|
+
return parts.length === 4 && parts[0] === '127' && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isTrustedApiRequest(req: IncomingMessage, trustedHosts: readonly string[]): boolean {
|
|
41
|
+
const host = header(req.headers, 'host')
|
|
42
|
+
if (host === undefined) return false
|
|
43
|
+
let hostUrl: URL
|
|
44
|
+
try {
|
|
45
|
+
hostUrl = new URL(`http://${host}`)
|
|
46
|
+
} catch {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !trustedHosts.includes(hostUrl.host) && !trustedHosts.includes(hostUrl.hostname)) {
|
|
50
|
+
if (!trustedHosts.some((entry) => entry === hostUrl.hostname || entry === hostUrl.host)) return false
|
|
51
|
+
}
|
|
52
|
+
if (header(req.headers, 'sec-fetch-site') === 'cross-site') return false
|
|
53
|
+
const origin = header(req.headers, 'origin')
|
|
54
|
+
if (origin === undefined) return true
|
|
55
|
+
try {
|
|
56
|
+
return new URL(origin).host === hostUrl.host
|
|
57
|
+
} catch {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function trustedHostsOf(ctx: Context): string[] {
|
|
63
|
+
for (const entry of ctx.get('loader')?.entries?.() ?? []) {
|
|
64
|
+
if (entry.options?.name === 'connection') {
|
|
65
|
+
const config = entry.options.config as { trustedHosts?: string[] } | undefined
|
|
66
|
+
return config?.trustedHosts ?? []
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return []
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── wire helpers ────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
function writeJson(res: ServerResponse, body: unknown): void {
|
|
75
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
76
|
+
res.end(JSON.stringify(body))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function writeError(res: ServerResponse, code: string, message: string, status = 400): void {
|
|
80
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
81
|
+
res.end(JSON.stringify({ ok: false, error: { code, message } }))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function requireString(value: unknown, name: string): string {
|
|
85
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
86
|
+
throw new Error(`${name} 不能为空`)
|
|
87
|
+
}
|
|
88
|
+
return value
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
92
|
+
const chunks: Buffer[] = []
|
|
93
|
+
for await (const chunk of req) chunks.push(chunk as Buffer)
|
|
94
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
95
|
+
if (text.trim() === '') return {}
|
|
96
|
+
try {
|
|
97
|
+
const parsed: unknown = JSON.parse(text)
|
|
98
|
+
return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : {}
|
|
99
|
+
} catch {
|
|
100
|
+
return {}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── request dispatch ────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
interface WorkspaceServiceLike {
|
|
107
|
+
get(id: string): { path: string; title: string; id: string } | undefined
|
|
108
|
+
list(): Array<{ path: string; title: string; id: string }>
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function registerApiRoutes(ctx: Context, runner: QuickRunner, settings: QuickCommandsSettingsFace): () => void {
|
|
112
|
+
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHostsOf(ctx))
|
|
113
|
+
const workspaceService = (): WorkspaceServiceLike | undefined =>
|
|
114
|
+
(ctx.get('workspaceRegistry') ?? ctx.get('workspace')) as WorkspaceServiceLike | undefined
|
|
115
|
+
|
|
116
|
+
/** Roster: every DSH workspace joined with its command set (for the card). */
|
|
117
|
+
function roster(): QuickRosterEntry[] {
|
|
118
|
+
const svc = workspaceService()
|
|
119
|
+
const config = settings.get()
|
|
120
|
+
const entries: QuickRosterEntry[] = []
|
|
121
|
+
if (svc !== undefined) {
|
|
122
|
+
for (const ws of svc.list()) {
|
|
123
|
+
const set = config.workspaces.find((w) => w.workspaceId === ws.id)
|
|
124
|
+
const ref = localPathToRemoteRef(ws.path)
|
|
125
|
+
entries.push({
|
|
126
|
+
workspaceId: ws.id,
|
|
127
|
+
path: ws.path,
|
|
128
|
+
title: ws.title,
|
|
129
|
+
...(ref !== null ? { remoteHost: ref.hostId } : {}),
|
|
130
|
+
commands: set === undefined ? [] : set.commands,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
// Registry unavailable; fall back to configured sets only.
|
|
135
|
+
for (const set of config.workspaces) {
|
|
136
|
+
entries.push({ workspaceId: set.workspaceId, path: '', title: set.workspaceId, commands: set.commands })
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return entries
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function dispatch(method: string, body: Record<string, unknown>): Promise<unknown> {
|
|
143
|
+
switch (method) {
|
|
144
|
+
case 'settings.get': {
|
|
145
|
+
return settings.get()
|
|
146
|
+
}
|
|
147
|
+
case 'settings.setCommands': {
|
|
148
|
+
const workspaceId = requireString(body.workspaceId, 'workspaceId')
|
|
149
|
+
const commandsRaw = body.commands
|
|
150
|
+
if (!Array.isArray(commandsRaw)) throw new Error('commands must be an array')
|
|
151
|
+
// Empty name/command are allowed: the settings card persists partial
|
|
152
|
+
// rows while the user types (instant save per edit). Runnable
|
|
153
|
+
// validation happens in run.start, not here.
|
|
154
|
+
const commands = commandsRaw.map((entry) => {
|
|
155
|
+
if (typeof entry !== 'object' || entry === null) throw new Error('invalid command entry')
|
|
156
|
+
const e = entry as Record<string, unknown>
|
|
157
|
+
if (typeof e.name !== 'string') throw new Error('command name must be a string')
|
|
158
|
+
if (typeof e.command !== 'string') throw new Error('command body must be a string')
|
|
159
|
+
return { name: e.name, command: e.command }
|
|
160
|
+
})
|
|
161
|
+
await settings.setCommands(workspaceId, commands)
|
|
162
|
+
return settings.get()
|
|
163
|
+
}
|
|
164
|
+
case 'settings.setAnchor': {
|
|
165
|
+
const anchor = body.anchor
|
|
166
|
+
if (anchor !== 'corner' && anchor !== 'button') throw new Error('anchor must be corner or button')
|
|
167
|
+
await settings.setAnchor(anchor)
|
|
168
|
+
return settings.get()
|
|
169
|
+
}
|
|
170
|
+
case 'settings.setPopupSize': {
|
|
171
|
+
const width = body.width
|
|
172
|
+
const height = body.height
|
|
173
|
+
// Number.isFinite guards NaN, which slips past schemastery's range checks.
|
|
174
|
+
if (typeof width !== 'number' || !Number.isFinite(width) || width < 320 || width > 8192) {
|
|
175
|
+
throw new Error('width must be a number between 320 and 8192')
|
|
176
|
+
}
|
|
177
|
+
if (typeof height !== 'number' || !Number.isFinite(height) || height < 200 || height > 8192) {
|
|
178
|
+
throw new Error('height must be a number between 200 and 8192')
|
|
179
|
+
}
|
|
180
|
+
await settings.setPopupSize({ width: Math.round(width), height: Math.round(height) })
|
|
181
|
+
return settings.get()
|
|
182
|
+
}
|
|
183
|
+
case 'workspaces.list': {
|
|
184
|
+
return roster()
|
|
185
|
+
}
|
|
186
|
+
case 'run.start': {
|
|
187
|
+
const workspaceId = requireString(body.workspaceId, 'workspaceId')
|
|
188
|
+
const commandName = requireString(body.commandName, 'commandName')
|
|
189
|
+
const sessionCwd = requireString(body.sessionCwd, 'sessionCwd')
|
|
190
|
+
const svc = workspaceService()
|
|
191
|
+
const ws = svc?.get(workspaceId)
|
|
192
|
+
if (ws === undefined) {
|
|
193
|
+
throw { code: 'workspace-not-found', message: `workspace ${workspaceId} is not registered`, status: 404 }
|
|
194
|
+
}
|
|
195
|
+
const entry = settings.commandsOf(workspaceId).find((c) => c.name === commandName)
|
|
196
|
+
if (entry === undefined) {
|
|
197
|
+
throw { code: 'command-not-found', message: `command ${commandName} is not configured for this workspace`, status: 404 }
|
|
198
|
+
}
|
|
199
|
+
if (entry.name.trim() === '' || entry.command.trim() === '') {
|
|
200
|
+
throw { code: 'command-empty', message: `command ${commandName} has an empty name or body`, status: 400 }
|
|
201
|
+
}
|
|
202
|
+
const result = await runner.start({
|
|
203
|
+
workspaceId,
|
|
204
|
+
workspacePath: ws.path,
|
|
205
|
+
workspaceTitle: ws.title,
|
|
206
|
+
commandName: entry.name,
|
|
207
|
+
command: entry.command,
|
|
208
|
+
sessionCwd,
|
|
209
|
+
popupAnchor: settings.get().popupAnchor,
|
|
210
|
+
})
|
|
211
|
+
if (!result.ok) {
|
|
212
|
+
throw { code: 'run-busy', message: result.error, status: 409 }
|
|
213
|
+
}
|
|
214
|
+
const view = runner.poll(result.runId, 0, 0)
|
|
215
|
+
if (view === null) throw { code: 'internal', message: 'run not found after start', status: 500 }
|
|
216
|
+
const value: QuickRunView = {
|
|
217
|
+
runId: result.runId,
|
|
218
|
+
workspaceId,
|
|
219
|
+
commandName: entry.name,
|
|
220
|
+
resolvedCommand: result.resolvedCommand,
|
|
221
|
+
...(result.remoteHost !== undefined ? { remoteHost: result.remoteHost } : {}),
|
|
222
|
+
status: view.status,
|
|
223
|
+
exitCode: view.exitCode,
|
|
224
|
+
signal: view.signal,
|
|
225
|
+
startedAt: Date.now(),
|
|
226
|
+
stdoutEnd: view.stdoutEnd,
|
|
227
|
+
stderrEnd: view.stderrEnd,
|
|
228
|
+
stdoutDelta: view.stdoutDelta,
|
|
229
|
+
stderrDelta: view.stderrDelta,
|
|
230
|
+
stdoutLossy: view.stdoutLossy,
|
|
231
|
+
stderrLossy: view.stderrLossy,
|
|
232
|
+
}
|
|
233
|
+
return value
|
|
234
|
+
}
|
|
235
|
+
case 'run.poll': {
|
|
236
|
+
const runId = requireString(body.runId, 'runId')
|
|
237
|
+
const stdoutFrom = typeof body.stdoutFrom === 'number' ? body.stdoutFrom : 0
|
|
238
|
+
const stderrFrom = typeof body.stderrFrom === 'number' ? body.stderrFrom : 0
|
|
239
|
+
const view = runner.poll(runId, stdoutFrom, stderrFrom)
|
|
240
|
+
if (view === null) throw { code: 'run-not-found', message: 'run not found (expired or unknown)', status: 404 }
|
|
241
|
+
return view
|
|
242
|
+
}
|
|
243
|
+
case 'run.kill': {
|
|
244
|
+
const runId = requireString(body.runId, 'runId')
|
|
245
|
+
const result = runner.kill(runId)
|
|
246
|
+
if (result === 'not-found') throw { code: 'run-not-found', message: 'run not found', status: 404 }
|
|
247
|
+
return { killed: result === 'ok' }
|
|
248
|
+
}
|
|
249
|
+
case 'run.list': {
|
|
250
|
+
const workspaceId = requireString(body.workspaceId, 'workspaceId')
|
|
251
|
+
return runner.forWorkspace(workspaceId)
|
|
252
|
+
}
|
|
253
|
+
default:
|
|
254
|
+
throw { code: 'method-not-found', message: `unknown method ${method}`, status: 404 }
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const webServer = ctx.get('webServer')
|
|
259
|
+
if (webServer === undefined) return () => undefined
|
|
260
|
+
return ctx.effect(() => {
|
|
261
|
+
return webServer.register({
|
|
262
|
+
kind: 'prefix',
|
|
263
|
+
path: API_PREFIX,
|
|
264
|
+
handler: async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
265
|
+
if (!fence(req)) {
|
|
266
|
+
writeError(res, 'forbidden', 'forbidden', 403)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
if (req.method !== 'POST') {
|
|
270
|
+
writeError(res, 'method', 'method not allowed', 405)
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
const url = new URL(req.url ?? '', 'http://placeholder')
|
|
274
|
+
const method = url.pathname.slice(API_PREFIX.length + 1)
|
|
275
|
+
const payload = await readJsonBody(req)
|
|
276
|
+
try {
|
|
277
|
+
const value = await dispatch(method, payload)
|
|
278
|
+
writeJson(res, { ok: true, value })
|
|
279
|
+
} catch (error) {
|
|
280
|
+
const e = error as { code?: string; message?: string; status?: number }
|
|
281
|
+
writeError(res, e.code ?? 'error', e.message ?? String(error), e.status ?? 400)
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
})
|
|
285
|
+
}, 'quick-commands: api routes')
|
|
286
|
+
}
|