@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.
@@ -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
+ }