@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/lib/index.js ADDED
@@ -0,0 +1,904 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import z from "@deepseek-ai/schemastery";
5
+ import { settingsNamespace } from "@deepseek-ai/dsh-settings";
6
+ //#region src/server/remote.ts
7
+ /**
8
+ * Remote (SSH) execution support: detect that a workspace path is a dsh-ssh
9
+ * placeholder directory, and stream a quick command over an acquired SSH
10
+ * connection.
11
+ *
12
+ * Deliberately dependency-free: the @dsh-ssh/dsh-ssh host plugin is consumed
13
+ * only through duck-typed services (`sshPool` service + the `dsh-ssh-hosts`
14
+ * settings namespace). When that plugin is absent everything degrades to the
15
+ * local path (the runner keeps its subprocess backend untouched).
16
+ *
17
+ * The placeholder-path contract mirrors dsh-ssh/src/router.js exactly (it is
18
+ * that plugin's public wire format):
19
+ * root = $DSH_SSH_REMOTE_ROOT | $DSH_HOME/remote | ~/.dsh/remote
20
+ * layout = <root>/<hostId>/<base64url(remote absolute path)>
21
+ * The encoded segment must decode to an absolute POSIX path (starts with
22
+ * '/'); anything else is never misdetected as remote.
23
+ */
24
+ function remoteRoot(env = process.env) {
25
+ if (env.DSH_SSH_REMOTE_ROOT !== void 0 && env.DSH_SSH_REMOTE_ROOT !== "") return path.resolve(String(env.DSH_SSH_REMOTE_ROOT));
26
+ const dshHome = env.DSH_HOME !== void 0 && env.DSH_HOME !== "" ? String(env.DSH_HOME) : path.join(os.homedir(), ".dsh");
27
+ return path.join(dshHome, "remote");
28
+ }
29
+ function isValidHostId(hostId) {
30
+ if (hostId.length === 0) return false;
31
+ if (hostId === "." || hostId === "..") return false;
32
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(hostId);
33
+ }
34
+ function encodeRemotePath(remotePath) {
35
+ return Buffer.from(String(remotePath), "utf8").toString("base64url");
36
+ }
37
+ function decodeRemotePath(encoded) {
38
+ if (typeof encoded !== "string" || encoded.length === 0) return null;
39
+ if (!/^[A-Za-z0-9_-]+$/.test(encoded)) return null;
40
+ let text;
41
+ try {
42
+ text = Buffer.from(encoded, "base64url").toString("utf8");
43
+ } catch {
44
+ return null;
45
+ }
46
+ if (encodeRemotePath(text) !== encoded) return null;
47
+ return text;
48
+ }
49
+ /**
50
+ * Local absolute path → { hostId, remotePath } when it is a dsh-ssh
51
+ * placeholder (`<root>/<hostId>/<encoded>`), otherwise null. A coincidentally
52
+ * matching real directory is never misdetected: the encoded segment must
53
+ * round-trip to an absolute POSIX path.
54
+ */
55
+ function localPathToRemoteRef(localPath, env = process.env) {
56
+ if (typeof localPath !== "string" || localPath.length === 0) return null;
57
+ const root = remoteRoot(env);
58
+ const rel = path.relative(root, localPath);
59
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return null;
60
+ const segments = rel.split(path.sep);
61
+ if (segments.length !== 2) return null;
62
+ const hostId = segments[0];
63
+ const encoded = segments[1];
64
+ if (!isValidHostId(hostId)) return null;
65
+ const remotePath = decodeRemotePath(encoded);
66
+ if (remotePath === null) return null;
67
+ if (!remotePath.startsWith("/")) return null;
68
+ return {
69
+ hostId,
70
+ remotePath
71
+ };
72
+ }
73
+ /**
74
+ * Map a session cwd (which may be inside the placeholder workspace, e.g. a
75
+ * subdirectory of the workspace root) onto the remote filesystem. Only paths
76
+ * strictly inside the placeholder are re-anchored; anything else falls back to
77
+ * the workspace's remote root.
78
+ */
79
+ function resolveRemoteCwd(sessionCwd, workspaceLocalPath, remotePath) {
80
+ const rel = path.relative(workspaceLocalPath, sessionCwd);
81
+ if (rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel)) return path.posix.resolve(remotePath, rel.split(path.sep).join("/"));
82
+ return remotePath;
83
+ }
84
+ /**
85
+ * Read one host's connection config from the `dsh-ssh-hosts` settings
86
+ * namespace (id → HostConfig dict, as written by the @dsh-ssh/dsh-ssh
87
+ * settings page). Returns `{ ...host, id }` for pool.acquire, or undefined
88
+ * when the host is not configured. `get` is the settings.get(ns) reader.
89
+ */
90
+ function readHostConfig(get, hostId) {
91
+ let doc;
92
+ try {
93
+ doc = get("dsh-ssh-hosts");
94
+ } catch {
95
+ return;
96
+ }
97
+ if (typeof doc !== "object" || doc === null) return void 0;
98
+ const hosts = doc.hosts;
99
+ if (typeof hosts !== "object" || hosts === null) return void 0;
100
+ const host = hosts[hostId];
101
+ if (typeof host !== "object" || host === null) return void 0;
102
+ return {
103
+ ...host,
104
+ id: hostId
105
+ };
106
+ }
107
+ function isChunk(item) {
108
+ return typeof item === "object" && item !== null && "stream" in item;
109
+ }
110
+ /** Single-quote one string for a POSIX shell (' → '\''). */
111
+ function shellQuoteSingle(s) {
112
+ return "'" + String(s).replace(/'/g, "'\\''") + "'";
113
+ }
114
+ /** stderr marker line: the remote process-group leader's pid ($$ of the sshd shell). */
115
+ const PID_MARKER = "__QC_PID__";
116
+ /**
117
+ * One remote execution stream. Consumes conn.execStream in the background,
118
+ * appends decoded chunks into the caller-provided tail buffers, and records the
119
+ * terminal outcome.
120
+ *
121
+ * The command runs on the remote shell that sshd spawns for the channel (that
122
+ * shell is the session/process-group leader; `$$` is its pid AND its pgid). The
123
+ * leader pid is probed by prefixing `echo <marker> $$ >&2` to the command, so
124
+ * `kill` can terminate the WHOLE process group (`kill -TERM -- -<pid>`), which
125
+ * `pkill -f` alone cannot do for a child like `sleep 30` whose cmdline does not
126
+ * match the original command text. The probe line is filtered out of the stderr
127
+ * stream the user sees (a short buffer handles chunk-cut lines).
128
+ */
129
+ var RemoteStreamExecutor = class {
130
+ conn;
131
+ pool;
132
+ cmd = "";
133
+ cwd = "";
134
+ started = false;
135
+ /** Remote process-group leader pid (parse the marker line; null until seen). */
136
+ leaderPid = null;
137
+ /** Raw stderr head buffer for the (possibly chunk-cut) marker line. */
138
+ markerBuf = "";
139
+ constructor(conn, pool) {
140
+ this.conn = conn;
141
+ this.pool = pool;
142
+ }
143
+ /**
144
+ * Start consuming the remote stream, settling the returned promise when the
145
+ * stream ends. `onData` sees decoded text chunks, `onExit` the terminal
146
+ * outcome (called exactly once, also on connection failure).
147
+ */
148
+ async start(cmd, cwd, onData, onExit) {
149
+ this.cmd = cmd;
150
+ this.cwd = cwd;
151
+ this.started = true;
152
+ const wrapped = `echo ${PID_MARKER} $$ >&2; cd ${shellQuoteSingle(cwd)} && ( ${cmd} )`;
153
+ let exitCode = null;
154
+ let signal = null;
155
+ try {
156
+ for await (const raw of this.conn.execStream(wrapped)) {
157
+ const item = raw;
158
+ if (isChunk(item)) {
159
+ if (item.stream === "stdout") onData("stdout", item.chunk.toString("utf8"));
160
+ else this.emitStderr(item.chunk.toString("utf8"), onData);
161
+ } else exitCode = item.exitCode;
162
+ }
163
+ } catch (error) {
164
+ const e = error;
165
+ onExit({
166
+ exitCode: null,
167
+ signal: null,
168
+ error: (e?.message ?? String(error)) + (e?.stage ? ` (stage: ${e.stage})` : "")
169
+ });
170
+ return;
171
+ }
172
+ onExit({
173
+ exitCode,
174
+ signal
175
+ });
176
+ }
177
+ /** Route stderr text, swallowing the (possibly split) marker line. */
178
+ emitStderr(text, onData) {
179
+ if (this.leaderPid !== null) {
180
+ onData("stderr", text);
181
+ return;
182
+ }
183
+ this.markerBuf += text;
184
+ const nl = this.markerBuf.indexOf("\n");
185
+ if (nl === -1) {
186
+ if (this.markerBuf.length > 4096) {
187
+ onData("stderr", this.markerBuf);
188
+ this.markerBuf = "";
189
+ }
190
+ return;
191
+ }
192
+ const head = this.markerBuf.slice(0, nl);
193
+ this.markerBuf = this.markerBuf.slice(nl + 1);
194
+ const m = /^__QC_PID__\s+(\d+)\s*$/.exec(head);
195
+ if (m !== null) this.leaderPid = Number(m[1]);
196
+ else onData("stderr", head + "\n");
197
+ if (this.markerBuf !== "") {
198
+ const rest = this.markerBuf;
199
+ this.markerBuf = "";
200
+ onData("stderr", rest);
201
+ }
202
+ }
203
+ /** The probed group-leader pid, if the marker line has been seen. */
204
+ get pid() {
205
+ return this.leaderPid;
206
+ }
207
+ /**
208
+ * Terminate the remote command's whole process group (best-effort). With the
209
+ * probed leader pid this is a true group kill (covers children like sleep);
210
+ * before the probe arrives (or when it never does) it falls back to a
211
+ * `pkill -f` on the exact `cd <cwd> && <cmd>` channel command line. The
212
+ * original stream then closes; the caller records signal 'TERM'.
213
+ */
214
+ async kill() {
215
+ if (!this.started) return;
216
+ if (this.leaderPid !== null) try {
217
+ await this.conn.exec(`kill -TERM -- -${this.leaderPid} 2>/dev/null; pkill -TERM -P ${this.leaderPid} 2>/dev/null; true`, { timeoutMs: 1e4 });
218
+ return;
219
+ } catch {}
220
+ const escaped = ("cd " + (this.cwd ? this.cwd + " && " : "") + this.cmd).replace(/[.*+?^$[\]{}()|/\\]/g, "\\$&").replace(/'/g, "'\\''");
221
+ try {
222
+ await this.conn.exec(`pkill -TERM -f '${escaped}' 2>/dev/null; true`, { timeoutMs: 1e4 });
223
+ } catch {}
224
+ }
225
+ };
226
+ //#endregion
227
+ //#region src/server/runner.ts
228
+ /**
229
+ * Quick-command execution engine.
230
+ *
231
+ * Owns one subprocess per run (serial per workspace id), keeps bounded
232
+ * in-memory output tails with offset-based readers for incremental client
233
+ * polls, resolves the {workspace}/{cwd}/{title} placeholders against the
234
+ * session-bound execution context, and terminates the whole process tree on
235
+ * kill / popup close.
236
+ *
237
+ * Two backends:
238
+ * - local: DSH's `subprocess` service (`bash -c`/`pwsh -c`, workspace cwd).
239
+ * - remote: when the workspace path is a dsh-ssh placeholder directory and
240
+ * the `sshPool` service is present, the command streams over SSH with the
241
+ * workspace's remote path as cwd. The client-visible behaviour (tail +
242
+ * offset polls) is identical for both backends.
243
+ */
244
+ /** Bounded in-memory tail window per output stream (bytes). */
245
+ const TAIL_MAX_BYTES = 262144;
246
+ /** Substitute supported placeholders in the command string. */
247
+ function resolvePlaceholders(command, vars) {
248
+ return command.replaceAll("{workspace}", vars.workspace).replaceAll("{cwd}", vars.cwd).replaceAll("{title}", vars.title);
249
+ }
250
+ /** Pick the shell executable for the current platform. */
251
+ function shellCmd() {
252
+ return process.platform === "win32" ? "pwsh" : "bash";
253
+ }
254
+ /** Pick the `-c` inclusive switch for the current platform. */
255
+ function shellFlag() {
256
+ return "-c";
257
+ }
258
+ var QuickCommandRunner = class {
259
+ subprocess;
260
+ resolveRemote;
261
+ runs = /* @__PURE__ */ new Map();
262
+ liveByWorkspace = /* @__PURE__ */ new Map();
263
+ constructor(subprocess, resolveRemote) {
264
+ this.subprocess = subprocess;
265
+ this.resolveRemote = resolveRemote;
266
+ }
267
+ /** Resolve the remote reference lazily so SSH availability is re-checked per run. */
268
+ resolveRemoteBackend() {
269
+ try {
270
+ return this.resolveRemote?.();
271
+ } catch {
272
+ return;
273
+ }
274
+ }
275
+ async start(input) {
276
+ const liveId = this.liveByWorkspace.get(input.workspaceId);
277
+ if (liveId !== void 0) {
278
+ const live = this.runs.get(liveId);
279
+ if (live !== void 0 && live.outcome === null) return {
280
+ ok: false,
281
+ error: `a command is already running for this workspace (${live.commandName})`
282
+ };
283
+ this.liveByWorkspace.delete(input.workspaceId);
284
+ }
285
+ const ref = localPathToRemoteRef(input.workspacePath);
286
+ if (ref !== null) {
287
+ const backend = this.resolveRemoteBackend();
288
+ if (backend === void 0) return {
289
+ ok: false,
290
+ error: `workspace ${input.workspaceId} is an SSH remote workspace (host ${ref.hostId}), but the @dsh-ssh/dsh-ssh plugin (sshPool service) is not loaded`
291
+ };
292
+ return this.startRemote(input, ref.hostId, ref.remotePath, backend);
293
+ }
294
+ return this.startLocal(input);
295
+ }
296
+ async startLocal(input) {
297
+ const resolved = resolvePlaceholders(input.command, {
298
+ workspace: input.workspacePath,
299
+ cwd: input.sessionCwd,
300
+ title: input.workspaceTitle
301
+ });
302
+ const runId = randomUUID();
303
+ const handle = this.subprocess.spawn({
304
+ argv: [
305
+ shellCmd(),
306
+ shellFlag(),
307
+ resolved
308
+ ],
309
+ cwd: input.workspacePath,
310
+ graceMs: 3e3,
311
+ stdio: {
312
+ stdin: "ignore",
313
+ stdout: { maxBytes: TAIL_MAX_BYTES },
314
+ stderr: { maxBytes: TAIL_MAX_BYTES }
315
+ },
316
+ env: {
317
+ ...process.env,
318
+ DSH_WORKSPACE: input.workspacePath,
319
+ DSH_WORKSPACE_ID: input.workspaceId,
320
+ DSH_WORKSPACE_TITLE: input.workspaceTitle
321
+ }
322
+ });
323
+ const live = {
324
+ runId,
325
+ workspaceId: input.workspaceId,
326
+ commandName: input.commandName,
327
+ resolvedCommand: resolved,
328
+ kind: "local",
329
+ handle,
330
+ stdoutBytes: 0,
331
+ stderrBytes: 0,
332
+ stdoutTail: "",
333
+ stderrTail: "",
334
+ stdoutClipped: false,
335
+ stderrClipped: false,
336
+ outcome: null,
337
+ killRequested: false,
338
+ startedAt: Date.now()
339
+ };
340
+ this.runs.set(runId, live);
341
+ this.liveByWorkspace.set(input.workspaceId, runId);
342
+ const stdoutReader = handle.collected?.stdout;
343
+ const stderrReader = handle.collected?.stderr;
344
+ const drain = () => {
345
+ if (stdoutReader !== void 0) {
346
+ const read = stdoutReader.readFrom(live.stdoutBytes);
347
+ live.stdoutBytes = read.nextOffset;
348
+ live.stdoutTail += read.text;
349
+ if (live.stdoutTail.length > TAIL_MAX_BYTES) {
350
+ live.stdoutTail = live.stdoutTail.slice(-262144);
351
+ live.stdoutClipped = true;
352
+ }
353
+ }
354
+ if (stderrReader !== void 0) {
355
+ const read = stderrReader.readFrom(live.stderrBytes);
356
+ live.stderrBytes = read.nextOffset;
357
+ live.stderrTail += read.text;
358
+ if (live.stderrTail.length > TAIL_MAX_BYTES) {
359
+ live.stderrTail = live.stderrTail.slice(-262144);
360
+ live.stderrClipped = true;
361
+ }
362
+ }
363
+ };
364
+ const interval = setInterval(drain, 150);
365
+ handle.done.then((outcome) => {
366
+ clearInterval(interval);
367
+ drain();
368
+ live.outcome = {
369
+ exitCode: outcome.exitCode,
370
+ signal: outcome.signal
371
+ };
372
+ }).catch(() => {
373
+ clearInterval(interval);
374
+ live.outcome = {
375
+ exitCode: null,
376
+ signal: null
377
+ };
378
+ });
379
+ return {
380
+ ok: true,
381
+ runId,
382
+ resolvedCommand: resolved
383
+ };
384
+ }
385
+ async startRemote(input, hostId, remotePath, backend) {
386
+ const cfg = backend.resolveHost(hostId);
387
+ if (cfg === void 0) return {
388
+ ok: false,
389
+ error: `remote host ${hostId} is not configured in dsh-ssh-hosts — add it in Settings → SSH hosts`
390
+ };
391
+ let conn;
392
+ try {
393
+ conn = await backend.pool.acquire(cfg);
394
+ } catch (error) {
395
+ const e = error;
396
+ return {
397
+ ok: false,
398
+ error: `ssh connect to host ${hostId} failed: ${e?.message ?? String(error)}` + (e?.stage ? ` (stage: ${e.stage})` : "")
399
+ };
400
+ }
401
+ const remoteCwd = resolveRemoteCwd(input.sessionCwd, input.workspacePath, remotePath);
402
+ const resolved = resolvePlaceholders(input.command, {
403
+ workspace: remotePath,
404
+ cwd: remoteCwd,
405
+ title: input.workspaceTitle
406
+ });
407
+ const runId = randomUUID();
408
+ const executor = new RemoteStreamExecutor(conn, backend.pool);
409
+ const live = {
410
+ runId,
411
+ workspaceId: input.workspaceId,
412
+ commandName: input.commandName,
413
+ resolvedCommand: resolved,
414
+ kind: "remote",
415
+ handle: executor,
416
+ stdoutBytes: 0,
417
+ stderrBytes: 0,
418
+ stdoutTail: "",
419
+ stderrTail: "",
420
+ stdoutClipped: false,
421
+ stderrClipped: false,
422
+ outcome: null,
423
+ killRequested: false,
424
+ startedAt: Date.now()
425
+ };
426
+ this.runs.set(runId, live);
427
+ this.liveByWorkspace.set(input.workspaceId, runId);
428
+ const append = (stream, text) => {
429
+ if (stream === "stdout") {
430
+ live.stdoutBytes += Buffer.byteLength(text, "utf8");
431
+ live.stdoutTail += text;
432
+ if (live.stdoutTail.length > TAIL_MAX_BYTES) {
433
+ live.stdoutTail = live.stdoutTail.slice(-262144);
434
+ live.stdoutClipped = true;
435
+ }
436
+ } else {
437
+ live.stderrBytes += Buffer.byteLength(text, "utf8");
438
+ live.stderrTail += text;
439
+ if (live.stderrTail.length > TAIL_MAX_BYTES) {
440
+ live.stderrTail = live.stderrTail.slice(-262144);
441
+ live.stderrClipped = true;
442
+ }
443
+ }
444
+ };
445
+ executor.start(resolved, remoteCwd, append, (exit) => {
446
+ if (exit.error !== void 0 && exit.error !== "") append("stderr", `\n[ssh] ${exit.error}\n`);
447
+ live.outcome = {
448
+ exitCode: exit.exitCode,
449
+ signal: live.killRequested ? "TERM" : exit.signal
450
+ };
451
+ backend.pool.release();
452
+ });
453
+ return {
454
+ ok: true,
455
+ runId,
456
+ remoteHost: hostId,
457
+ resolvedCommand: resolved
458
+ };
459
+ }
460
+ poll(runId, stdoutFrom, stderrFrom) {
461
+ const live = this.runs.get(runId);
462
+ if (live === void 0) return null;
463
+ if (live.kind === "local") {
464
+ const handle = live.handle;
465
+ const stdoutReader = handle.collected?.stdout;
466
+ const stderrReader = handle.collected?.stderr;
467
+ if (stdoutReader !== void 0) {
468
+ const read = stdoutReader.readFrom(live.stdoutBytes);
469
+ live.stdoutBytes = read.nextOffset;
470
+ live.stdoutTail += read.text;
471
+ if (live.stdoutTail.length > TAIL_MAX_BYTES) {
472
+ live.stdoutTail = live.stdoutTail.slice(-262144);
473
+ live.stdoutClipped = true;
474
+ }
475
+ }
476
+ if (stderrReader !== void 0) {
477
+ const read = stderrReader.readFrom(live.stderrBytes);
478
+ live.stderrBytes = read.nextOffset;
479
+ live.stderrTail += read.text;
480
+ if (live.stderrTail.length > TAIL_MAX_BYTES) {
481
+ live.stderrTail = live.stderrTail.slice(-262144);
482
+ live.stderrClipped = true;
483
+ }
484
+ }
485
+ }
486
+ let stdoutDelta = live.stdoutTail;
487
+ let stdoutLossy = live.stdoutClipped;
488
+ if (stdoutFrom > 0 && stdoutFrom <= live.stdoutBytes) {
489
+ const tailStart = live.stdoutBytes - live.stdoutTail.length;
490
+ if (stdoutFrom < tailStart) stdoutLossy = true;
491
+ else stdoutDelta = live.stdoutTail.slice(stdoutFrom - tailStart);
492
+ }
493
+ let stderrDelta = live.stderrTail;
494
+ let stderrLossy = live.stderrClipped;
495
+ if (stderrFrom > 0 && stderrFrom <= live.stderrBytes) {
496
+ const tailStart = live.stderrBytes - live.stderrTail.length;
497
+ if (stderrFrom < tailStart) stderrLossy = true;
498
+ else stderrDelta = live.stderrTail.slice(stderrFrom - tailStart);
499
+ }
500
+ const exited = live.outcome !== null;
501
+ return {
502
+ kind: "poll",
503
+ status: exited ? "exited" : "running",
504
+ exitCode: exited ? live.outcome.exitCode : null,
505
+ signal: exited ? live.outcome.signal : null,
506
+ stdoutEnd: live.stdoutBytes,
507
+ stderrEnd: live.stderrBytes,
508
+ stdoutDelta,
509
+ stderrDelta,
510
+ stdoutLossy,
511
+ stderrLossy
512
+ };
513
+ }
514
+ kill(runId) {
515
+ const live = this.runs.get(runId);
516
+ if (live === void 0) return "not-found";
517
+ if (live.outcome !== null) return "already-exited";
518
+ if (live.kind === "local") live.handle.terminate();
519
+ else {
520
+ live.killRequested = true;
521
+ live.handle.kill();
522
+ }
523
+ return "ok";
524
+ }
525
+ forWorkspace(workspaceId) {
526
+ const ids = this.liveByWorkspace.get(workspaceId);
527
+ if (ids === void 0) return [];
528
+ const live = this.runs.get(ids);
529
+ if (live === void 0) return [];
530
+ return [{
531
+ runId: live.runId,
532
+ commandName: live.commandName,
533
+ startedAt: live.startedAt,
534
+ status: live.outcome === null ? "running" : "exited"
535
+ }];
536
+ }
537
+ /** Every live run id (terminal and running) for fiber cleanup. */
538
+ listAll() {
539
+ return [...this.runs.keys()];
540
+ }
541
+ };
542
+ //#endregion
543
+ //#region src/server/settings.ts
544
+ const QUICK_COMMANDS_NS = settingsNamespace("quick-commands");
545
+ /**
546
+ * One command entry schema. name/command may be EMPTY: the settings card edits
547
+ * rows field-by-field (instant save per keystroke), so a partially-typed row
548
+ * is a legitimate persisted state. Runnable validation happens at run.start.
549
+ */
550
+ const CommandSchema = z.object({
551
+ name: z.string(),
552
+ command: z.string()
553
+ });
554
+ /** One workspace command set. */
555
+ const WorkspaceCommandsSchema = z.object({
556
+ workspaceId: z.string().min(1),
557
+ commands: z.array(CommandSchema).max(200)
558
+ });
559
+ const QuickCommandsSettingsSchema = z.object({
560
+ workspaces: z.array(WorkspaceCommandsSchema).max(500),
561
+ popupAnchor: z.union([z.const("corner"), z.const("button")]).default("corner"),
562
+ popupSize: z.object({
563
+ width: z.number().min(320).max(8192),
564
+ height: z.number().min(200).max(8192)
565
+ })
566
+ });
567
+ const DEFAULT_SETTINGS = {
568
+ workspaces: [],
569
+ popupAnchor: "corner"
570
+ };
571
+ function registerSettings(ctx) {
572
+ let current = {
573
+ ...DEFAULT_SETTINGS,
574
+ workspaces: []
575
+ };
576
+ let service;
577
+ ctx.inject(["settings"], (sctx) => {
578
+ service = sctx.settings;
579
+ const scope = service.register(QUICK_COMMANDS_NS, QuickCommandsSettingsSchema);
580
+ current = scope.get();
581
+ scope.watch((next) => {
582
+ current = next;
583
+ });
584
+ });
585
+ async function persist(patch) {
586
+ if (service === void 0) throw new Error("@fonlan/dsh-quick-commands: settings service is not available in this profile");
587
+ await service.update(QUICK_COMMANDS_NS, patch);
588
+ }
589
+ return {
590
+ get: () => current,
591
+ commandsOf: (workspaceId) => {
592
+ const set = current.workspaces.find((w) => w.workspaceId === workspaceId);
593
+ return set === void 0 ? [] : set.commands;
594
+ },
595
+ setCommands: async (workspaceId, commands) => {
596
+ const next = current.workspaces.filter((w) => w.workspaceId !== workspaceId);
597
+ if (commands.length > 0) next.push({
598
+ workspaceId,
599
+ commands
600
+ });
601
+ await persist({ workspaces: next });
602
+ },
603
+ setAnchor: async (anchor) => {
604
+ await persist({ popupAnchor: anchor });
605
+ },
606
+ setPopupSize: async (size) => {
607
+ await persist({ popupSize: size });
608
+ }
609
+ };
610
+ }
611
+ //#endregion
612
+ //#region src/server/rpc.ts
613
+ const API_PREFIX = "/plugins/@fonlan/dsh-quick-commands/api";
614
+ function header(headers, name) {
615
+ const value = headers[name];
616
+ return typeof value === "string" ? value : void 0;
617
+ }
618
+ function isLoopbackHostname(hostname) {
619
+ if (hostname === "localhost" || hostname === "[::1]") return true;
620
+ const parts = hostname.split(".");
621
+ return parts.length === 4 && parts[0] === "127" && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255);
622
+ }
623
+ function isTrustedApiRequest(req, trustedHosts) {
624
+ const host = header(req.headers, "host");
625
+ if (host === void 0) return false;
626
+ let hostUrl;
627
+ try {
628
+ hostUrl = new URL(`http://${host}`);
629
+ } catch {
630
+ return false;
631
+ }
632
+ if (!isLoopbackHostname(hostUrl.hostname) && !trustedHosts.includes(hostUrl.host) && !trustedHosts.includes(hostUrl.hostname)) {
633
+ if (!trustedHosts.some((entry) => entry === hostUrl.hostname || entry === hostUrl.host)) return false;
634
+ }
635
+ if (header(req.headers, "sec-fetch-site") === "cross-site") return false;
636
+ const origin = header(req.headers, "origin");
637
+ if (origin === void 0) return true;
638
+ try {
639
+ return new URL(origin).host === hostUrl.host;
640
+ } catch {
641
+ return false;
642
+ }
643
+ }
644
+ function trustedHostsOf(ctx) {
645
+ for (const entry of ctx.get("loader")?.entries?.() ?? []) if (entry.options?.name === "connection") return entry.options.config?.trustedHosts ?? [];
646
+ return [];
647
+ }
648
+ function writeJson(res, body) {
649
+ res.writeHead(200, {
650
+ "content-type": "application/json; charset=utf-8",
651
+ "cache-control": "no-store"
652
+ });
653
+ res.end(JSON.stringify(body));
654
+ }
655
+ function writeError(res, code, message, status = 400) {
656
+ res.writeHead(status, {
657
+ "content-type": "application/json; charset=utf-8",
658
+ "cache-control": "no-store"
659
+ });
660
+ res.end(JSON.stringify({
661
+ ok: false,
662
+ error: {
663
+ code,
664
+ message
665
+ }
666
+ }));
667
+ }
668
+ function requireString(value, name) {
669
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`${name} 不能为空`);
670
+ return value;
671
+ }
672
+ async function readJsonBody(req) {
673
+ const chunks = [];
674
+ for await (const chunk of req) chunks.push(chunk);
675
+ const text = Buffer.concat(chunks).toString("utf8");
676
+ if (text.trim() === "") return {};
677
+ try {
678
+ const parsed = JSON.parse(text);
679
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
680
+ } catch {
681
+ return {};
682
+ }
683
+ }
684
+ function registerApiRoutes(ctx, runner, settings) {
685
+ const fence = (req) => isTrustedApiRequest(req, trustedHostsOf(ctx));
686
+ const workspaceService = () => ctx.get("workspaceRegistry") ?? ctx.get("workspace");
687
+ /** Roster: every DSH workspace joined with its command set (for the card). */
688
+ function roster() {
689
+ const svc = workspaceService();
690
+ const config = settings.get();
691
+ const entries = [];
692
+ if (svc !== void 0) for (const ws of svc.list()) {
693
+ const set = config.workspaces.find((w) => w.workspaceId === ws.id);
694
+ const ref = localPathToRemoteRef(ws.path);
695
+ entries.push({
696
+ workspaceId: ws.id,
697
+ path: ws.path,
698
+ title: ws.title,
699
+ ...ref !== null ? { remoteHost: ref.hostId } : {},
700
+ commands: set === void 0 ? [] : set.commands
701
+ });
702
+ }
703
+ else for (const set of config.workspaces) entries.push({
704
+ workspaceId: set.workspaceId,
705
+ path: "",
706
+ title: set.workspaceId,
707
+ commands: set.commands
708
+ });
709
+ return entries;
710
+ }
711
+ async function dispatch(method, body) {
712
+ switch (method) {
713
+ case "settings.get": return settings.get();
714
+ case "settings.setCommands": {
715
+ const workspaceId = requireString(body.workspaceId, "workspaceId");
716
+ const commandsRaw = body.commands;
717
+ if (!Array.isArray(commandsRaw)) throw new Error("commands must be an array");
718
+ const commands = commandsRaw.map((entry) => {
719
+ if (typeof entry !== "object" || entry === null) throw new Error("invalid command entry");
720
+ const e = entry;
721
+ if (typeof e.name !== "string") throw new Error("command name must be a string");
722
+ if (typeof e.command !== "string") throw new Error("command body must be a string");
723
+ return {
724
+ name: e.name,
725
+ command: e.command
726
+ };
727
+ });
728
+ await settings.setCommands(workspaceId, commands);
729
+ return settings.get();
730
+ }
731
+ case "settings.setAnchor": {
732
+ const anchor = body.anchor;
733
+ if (anchor !== "corner" && anchor !== "button") throw new Error("anchor must be corner or button");
734
+ await settings.setAnchor(anchor);
735
+ return settings.get();
736
+ }
737
+ case "settings.setPopupSize": {
738
+ const width = body.width;
739
+ const height = body.height;
740
+ if (typeof width !== "number" || !Number.isFinite(width) || width < 320 || width > 8192) throw new Error("width must be a number between 320 and 8192");
741
+ if (typeof height !== "number" || !Number.isFinite(height) || height < 200 || height > 8192) throw new Error("height must be a number between 200 and 8192");
742
+ await settings.setPopupSize({
743
+ width: Math.round(width),
744
+ height: Math.round(height)
745
+ });
746
+ return settings.get();
747
+ }
748
+ case "workspaces.list": return roster();
749
+ case "run.start": {
750
+ const workspaceId = requireString(body.workspaceId, "workspaceId");
751
+ const commandName = requireString(body.commandName, "commandName");
752
+ const sessionCwd = requireString(body.sessionCwd, "sessionCwd");
753
+ const ws = workspaceService()?.get(workspaceId);
754
+ if (ws === void 0) throw {
755
+ code: "workspace-not-found",
756
+ message: `workspace ${workspaceId} is not registered`,
757
+ status: 404
758
+ };
759
+ const entry = settings.commandsOf(workspaceId).find((c) => c.name === commandName);
760
+ if (entry === void 0) throw {
761
+ code: "command-not-found",
762
+ message: `command ${commandName} is not configured for this workspace`,
763
+ status: 404
764
+ };
765
+ if (entry.name.trim() === "" || entry.command.trim() === "") throw {
766
+ code: "command-empty",
767
+ message: `command ${commandName} has an empty name or body`,
768
+ status: 400
769
+ };
770
+ const result = await runner.start({
771
+ workspaceId,
772
+ workspacePath: ws.path,
773
+ workspaceTitle: ws.title,
774
+ commandName: entry.name,
775
+ command: entry.command,
776
+ sessionCwd,
777
+ popupAnchor: settings.get().popupAnchor
778
+ });
779
+ if (!result.ok) throw {
780
+ code: "run-busy",
781
+ message: result.error,
782
+ status: 409
783
+ };
784
+ const view = runner.poll(result.runId, 0, 0);
785
+ if (view === null) throw {
786
+ code: "internal",
787
+ message: "run not found after start",
788
+ status: 500
789
+ };
790
+ return {
791
+ runId: result.runId,
792
+ workspaceId,
793
+ commandName: entry.name,
794
+ resolvedCommand: result.resolvedCommand,
795
+ ...result.remoteHost !== void 0 ? { remoteHost: result.remoteHost } : {},
796
+ status: view.status,
797
+ exitCode: view.exitCode,
798
+ signal: view.signal,
799
+ startedAt: Date.now(),
800
+ stdoutEnd: view.stdoutEnd,
801
+ stderrEnd: view.stderrEnd,
802
+ stdoutDelta: view.stdoutDelta,
803
+ stderrDelta: view.stderrDelta,
804
+ stdoutLossy: view.stdoutLossy,
805
+ stderrLossy: view.stderrLossy
806
+ };
807
+ }
808
+ case "run.poll": {
809
+ const runId = requireString(body.runId, "runId");
810
+ const stdoutFrom = typeof body.stdoutFrom === "number" ? body.stdoutFrom : 0;
811
+ const stderrFrom = typeof body.stderrFrom === "number" ? body.stderrFrom : 0;
812
+ const view = runner.poll(runId, stdoutFrom, stderrFrom);
813
+ if (view === null) throw {
814
+ code: "run-not-found",
815
+ message: "run not found (expired or unknown)",
816
+ status: 404
817
+ };
818
+ return view;
819
+ }
820
+ case "run.kill": {
821
+ const runId = requireString(body.runId, "runId");
822
+ const result = runner.kill(runId);
823
+ if (result === "not-found") throw {
824
+ code: "run-not-found",
825
+ message: "run not found",
826
+ status: 404
827
+ };
828
+ return { killed: result === "ok" };
829
+ }
830
+ case "run.list": {
831
+ const workspaceId = requireString(body.workspaceId, "workspaceId");
832
+ return runner.forWorkspace(workspaceId);
833
+ }
834
+ default: throw {
835
+ code: "method-not-found",
836
+ message: `unknown method ${method}`,
837
+ status: 404
838
+ };
839
+ }
840
+ }
841
+ const webServer = ctx.get("webServer");
842
+ if (webServer === void 0) return () => void 0;
843
+ return ctx.effect(() => {
844
+ return webServer.register({
845
+ kind: "prefix",
846
+ path: API_PREFIX,
847
+ handler: async (req, res) => {
848
+ if (!fence(req)) {
849
+ writeError(res, "forbidden", "forbidden", 403);
850
+ return;
851
+ }
852
+ if (req.method !== "POST") {
853
+ writeError(res, "method", "method not allowed", 405);
854
+ return;
855
+ }
856
+ const method = new URL(req.url ?? "", "http://placeholder").pathname.slice(40);
857
+ const payload = await readJsonBody(req);
858
+ try {
859
+ writeJson(res, {
860
+ ok: true,
861
+ value: await dispatch(method, payload)
862
+ });
863
+ } catch (error) {
864
+ const e = error;
865
+ writeError(res, e.code ?? "error", e.message ?? String(error), e.status ?? 400);
866
+ }
867
+ }
868
+ });
869
+ }, "quick-commands: api routes");
870
+ }
871
+ //#endregion
872
+ //#region src/index.ts
873
+ const name = "@fonlan/dsh-quick-commands";
874
+ const inject = ["settings"];
875
+ /** No plugin-level config; all configuration lives in the settings namespace. */
876
+ function apply(ctx) {
877
+ const settings = registerSettings(ctx);
878
+ const subprocess = ctx.get("subprocess");
879
+ if (subprocess === void 0) {
880
+ ctx.logger?.warn?.("@fonlan/dsh-quick-commands: subprocess service unavailable; commands cannot run");
881
+ return;
882
+ }
883
+ const resolveRemote = () => {
884
+ const sshPool = ctx.get("sshPool");
885
+ if (sshPool === void 0 || typeof sshPool.acquire !== "function" || typeof sshPool.release !== "function") return;
886
+ return {
887
+ pool: sshPool,
888
+ resolveHost: (hostId) => readHostConfig((ns) => {
889
+ try {
890
+ return ctx.get("settings")?.get(ns);
891
+ } catch {
892
+ return;
893
+ }
894
+ }, hostId)
895
+ };
896
+ };
897
+ const runner = new QuickCommandRunner(subprocess, () => resolveRemote());
898
+ ctx.effect(() => registerApiRoutes(ctx, runner, settings), "quick-commands: api routes");
899
+ ctx.effect(() => () => {
900
+ for (const id of runner.listAll()) runner.kill(id);
901
+ }, "quick-commands: process cleanup");
902
+ }
903
+ //#endregion
904
+ export { apply, inject, name };