@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
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 };
|