dsh-plugin-file-actions 0.1.7-alpha.2

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,753 @@
1
+ /**
2
+ * dsh-plugin-file-actions — Host half.
3
+ *
4
+ * File-level complements to the official `open-in-app` routes (which only
5
+ * accept existing directories), on every platform the official catalog covers:
6
+ *
7
+ * - GET /api/file-actions/info → editor/terminal capability and the
8
+ * configurable run-command extension map, so the browser menu can grey the
9
+ * "run this file" item for unknown types.
10
+ * - POST /api/file-actions/launch → open one existing file (or directory) in
11
+ * a whitelisted editor/IDE. The app is resolved by the official
12
+ * `@deepseek-ai/dsh-host-open-in-app` resolver — the same locators the
13
+ * official routes use (macOS `.app` bundles, Windows `App Paths` registry /
14
+ * Uninstall records / `%ProgramFiles%` scans, Linux PATH names and desktop
15
+ * entries) — and launched with the file path appended.
16
+ * - POST /api/file-actions/run → run one file inside a whitelisted
17
+ * terminal (Terminal.app via AppleScript, Ghostty, Windows Terminal, Git
18
+ * Bash via mintty, GNOME Terminal, Konsole).
19
+ * - POST /api/file-actions/clone → clone one git URL (or check out one svn
20
+ * URL) into a directory chosen in the browser through the official
21
+ * directory picker. The URL is re-validated against the strict VCS shapes
22
+ * and spawned as argv — never a shell — so chat-authored text cannot reach
23
+ * the command line as options.
24
+ *
25
+ * Security: every route asks the composition's `connection` service for a
26
+ * rejection first (Host/Origin fence + browser authentication, the same model
27
+ * as the official open-in-app host), bodies are bounded JSON, app ids are
28
+ * whitelist-checked against the editor set, the resolver only resolves catalog
29
+ * entries, and paths must be absolute and exist on disk. The run command is
30
+ * built from the configured extension map plus the file path — a shell string
31
+ * on POSIX, argv words on Windows, where the command line reaches the
32
+ * terminal's shell through an environment variable, never through a re-quoted
33
+ * argument, and executability for the fallback derives from the file
34
+ * extension. Launches spawn detached through the official launcher with a
35
+ * credential-scrubbed environment.
36
+ */
37
+
38
+ import { execFile } from 'node:child_process'
39
+ import { createRequire } from 'node:module'
40
+ import { stat } from 'node:fs/promises'
41
+ import path from 'node:path'
42
+ import { pathToFileURL } from 'node:url'
43
+ import { promisify } from 'node:util'
44
+ import { launchEnvironmentOf, launchedThroughSsh } from '@deepseek-ai/dsh-launch-environment'
45
+ import z from '@deepseek-ai/schemastery'
46
+
47
+ const execFileAsync = promisify(execFile)
48
+
49
+ /** Cordis function-plugin name. */
50
+ export const name = 'file-actions'
51
+
52
+ /**
53
+ * The route carrier, the trust fence guarding every route, and the subprocess
54
+ * capability the official resolver uses for PATH lookups (the same service the
55
+ * official open-in-app host injects).
56
+ */
57
+ export const inject = ['webServer', 'connection', 'subprocess']
58
+
59
+ /** Default extension → command map for "run this file" on POSIX hosts. */
60
+ const DEFAULT_RUN_COMMANDS_POSIX = {
61
+ py: 'python3',
62
+ pyw: 'python3',
63
+ sh: 'bash',
64
+ bash: 'bash',
65
+ zsh: 'zsh',
66
+ js: 'node',
67
+ mjs: 'node',
68
+ cjs: 'node',
69
+ ts: 'tsx',
70
+ tsx: 'tsx',
71
+ rb: 'ruby',
72
+ pl: 'perl',
73
+ php: 'php',
74
+ lua: 'lua',
75
+ }
76
+
77
+ /** Default extension → command map for "run this file" on Windows. */
78
+ const DEFAULT_RUN_COMMANDS_WIN32 = {
79
+ py: 'python',
80
+ pyw: 'python',
81
+ bat: 'cmd /c',
82
+ cmd: 'cmd /c',
83
+ ps1: 'powershell -File',
84
+ sh: 'bash',
85
+ bash: 'bash',
86
+ js: 'node',
87
+ mjs: 'node',
88
+ cjs: 'node',
89
+ ts: 'tsx',
90
+ tsx: 'tsx',
91
+ rb: 'ruby',
92
+ pl: 'perl',
93
+ php: 'php',
94
+ lua: 'lua',
95
+ }
96
+
97
+ /**
98
+ * The platform's default extension → command map.
99
+ * @param platform - host platform; defaults to the running one.
100
+ */
101
+ export function defaultRunCommands(platform = process.platform) {
102
+ return { ...(platform === 'win32' ? DEFAULT_RUN_COMMANDS_WIN32 : DEFAULT_RUN_COMMANDS_POSIX) }
103
+ }
104
+
105
+ /** POSIX default table, kept as a named export for existing consumers. */
106
+ export const DEFAULT_RUN_COMMANDS = DEFAULT_RUN_COMMANDS_POSIX
107
+
108
+ /** Host configuration. */
109
+ export const Config = z.object({
110
+ /** Extension (lowercase, no dot) → command run before the quoted file path. Defaults are platform-aware. */
111
+ runCommands: z.dict(z.string(), z.string()).default(defaultRunCommands()),
112
+ /** Permit running files whose extension is unmapped but that carry an execute bit; Windows derives executability from the extension (exe/bat/cmd/com). */
113
+ allowExecutableBit: z.boolean().default(true),
114
+ /** Deadline in milliseconds: bounded host commands, and the detached-launch watch window. */
115
+ launchTimeoutMs: z.number().step(1).min(100).max(120_000).default(10_000),
116
+ /** Deadline in milliseconds for one git clone / svn checkout (network-bound, so the ceiling is far above the launch watch). */
117
+ cloneTimeoutMs: z.number().step(1).min(1000).max(600_000).default(120_000),
118
+ })
119
+
120
+ /**
121
+ * Official open-in-app catalog ids this plugin launches at file level, with
122
+ * labels on the browser side. File managers (finder/explorer/filemanager) are
123
+ * excluded: the client half offers them from the official probe alone and
124
+ * launches them through the official POST /open-in-app/open route with the
125
+ * file's directory — the exact call the session-header menu makes — because a
126
+ * file-manager shell-open with the file path would open the file in its
127
+ * default app instead. Terminals are handled by the run route, not this set.
128
+ */
129
+ const EDITOR_IDS = [
130
+ 'cursor', 'vscode', 'vscodeinsiders', 'windsurf', 'zed', 'sublimetext',
131
+ 'androidstudio', 'intellij', 'pycharm', 'webstorm', 'phpstorm',
132
+ 'goland', 'rider', 'rustrover',
133
+ ]
134
+
135
+ /** Terminals the run route knows, keyed by official catalog ids. */
136
+ const TERMINALS = ['ghostty', 'terminal', 'gitbash', 'windowsterminal', 'gnometerminal', 'konsole']
137
+
138
+ /** Module layouts of the official resolver/catalog across published versions. */
139
+ const RESOLVER_LAYOUTS = ['lib/types/resolver.js', 'lib/resolver.js']
140
+ const CATALOG_LAYOUTS = ['lib/types/catalog.js', 'lib/catalog.js']
141
+
142
+ /**
143
+ * The installed official package's root, reached through its exported
144
+ * `./package.json` subpath (the exports map blocks deep specifier imports,
145
+ * but a resolved file URL inside the package is a plain module import).
146
+ */
147
+ function officialPackageRoot() {
148
+ const require = createRequire(import.meta.url)
149
+ try {
150
+ return path.dirname(require.resolve('@deepseek-ai/dsh-host-open-in-app/package.json'))
151
+ } catch (error) {
152
+ throw new Error(
153
+ 'file-actions: the official dependency @deepseek-ai/dsh-host-open-in-app is not resolvable from this plugin'
154
+ + ' (for link: installs run `npm install` inside the plugin checkout; for npm/git installs reinstall with'
155
+ + ' `dsh plugin --profile web update dsh-plugin-file-actions -w`)',
156
+ { cause: error },
157
+ )
158
+ }
159
+ }
160
+
161
+ /** Import the first module layout that exists among the candidates. */
162
+ async function importFirstLayout(root, candidates) {
163
+ let lastError
164
+ for (const candidate of candidates) {
165
+ try {
166
+ return await import(pathToFileURL(path.join(root, candidate)).href)
167
+ } catch (error) {
168
+ lastError = error
169
+ }
170
+ }
171
+ throw new Error(
172
+ `file-actions: none of the official module layouts exist under ${root}: ${candidates.join(', ')}`,
173
+ { cause: lastError },
174
+ )
175
+ }
176
+
177
+ /**
178
+ * Load the official open-in-app resolver and catalog from the installed
179
+ * dependency — the exact detection and launch layer the official host routes
180
+ * run, tried against every layout the published versions shipped.
181
+ */
182
+ export async function loadOfficialOpenInApp() {
183
+ const root = officialPackageRoot()
184
+ const [resolver, catalog] = await Promise.all([
185
+ importFirstLayout(root, RESOLVER_LAYOUTS),
186
+ importFirstLayout(root, CATALOG_LAYOUTS),
187
+ ])
188
+ if (
189
+ typeof resolver.resolveOpenInAppApps !== 'function'
190
+ || typeof resolver.resolveLaunch !== 'function'
191
+ || typeof resolver.launchResolved !== 'function'
192
+ || typeof resolver.launchDetachedApp !== 'function'
193
+ || !Array.isArray(catalog.OPEN_IN_APP_CATALOG)
194
+ ) {
195
+ throw new Error('file-actions: the official open-in-app package loaded but does not expose the expected resolver API')
196
+ }
197
+ return { resolver, catalog: catalog.OPEN_IN_APP_CATALOG }
198
+ }
199
+
200
+ /** Trust surface consumed here; the browser-side connection package owns the full type. */
201
+ /** Answer an untrusted/unauthenticated request; true when it was rejected. */
202
+ function rejected(connection, req, res) {
203
+ const rejection = connection.requestRejection(req)
204
+ if (rejection === undefined) return false
205
+ res.statusCode = rejection
206
+ res.end()
207
+ return true
208
+ }
209
+
210
+ /** Open/run-route request bodies are tiny JSON objects; anything larger is hostile. */
211
+ const MAX_BODY_BYTES = 64 * 1024
212
+
213
+ /** JSON response (no-store: outcomes are live facts). */
214
+ function sendJson(res, status, payload) {
215
+ res.statusCode = status
216
+ res.setHeader('content-type', 'application/json; charset=utf-8')
217
+ res.setHeader('cache-control', 'no-store')
218
+ res.end(JSON.stringify(payload))
219
+ }
220
+
221
+ /** 405 with the route's one supported method. */
222
+ function sendMethodNotAllowed(res, allow) {
223
+ res.statusCode = 405
224
+ res.setHeader('allow', allow)
225
+ res.end()
226
+ }
227
+
228
+ /** Collect a bounded request body as UTF-8 text; null past the ceiling (stream drained). */
229
+ async function readBoundedBody(req) {
230
+ const chunks = []
231
+ let size = 0
232
+ for await (const chunk of req) {
233
+ size += chunk.byteLength
234
+ if (size > MAX_BODY_BYTES) {
235
+ req.resume()
236
+ return null
237
+ }
238
+ chunks.push(chunk)
239
+ }
240
+ return Buffer.concat(chunks, size).toString('utf8')
241
+ }
242
+
243
+ /** Validate one POST body at the wire: JSON object with string app/path. */
244
+ function parseBody(text) {
245
+ let body
246
+ try {
247
+ body = JSON.parse(text)
248
+ } catch {
249
+ // Swallows the parse error: a non-JSON body is exactly the null case.
250
+ return null
251
+ }
252
+ if (typeof body !== 'object' || body === null) return null
253
+ const { app, path: bodyPath } = body
254
+ return typeof app === 'string' && typeof bodyPath === 'string' ? { app, path: bodyPath } : null
255
+ }
256
+
257
+ /** Validate one clone body at the wire: JSON object with string url/parent and the vcs literal. */
258
+ function parseCloneBody(text) {
259
+ let body
260
+ try {
261
+ body = JSON.parse(text)
262
+ } catch {
263
+ return null
264
+ }
265
+ if (typeof body !== 'object' || body === null) return null
266
+ const { url, vcs, parent } = body
267
+ return typeof url === 'string' && (vcs === 'git' || vcs === 'svn') && typeof parent === 'string'
268
+ ? { url, vcs, parent }
269
+ : null
270
+ }
271
+
272
+ /**
273
+ * Repository URL classes the clone route accepts, mirroring the client
274
+ * classifier: git over the https/git/ssh schemes or the SCP spelling, and svn
275
+ * over its scheme family. The official message sanitizer strips every other
276
+ * scheme, so nothing else can arrive as a rendered link.
277
+ */
278
+ const GIT_URL_RES = [
279
+ /^(?:git|ssh):\/\/\S+$/i,
280
+ /^git@[A-Za-z0-9._-]+[:/]\S+$/i,
281
+ /^https?:\/\/\S+\.git$/i,
282
+ ]
283
+ const SVN_URL_RE = /^(?:svn|svn\+ssh|svn\+https?):\/\/\S+$/i
284
+
285
+ /**
286
+ * The VCS behind one repository URL, or null. The URL comes from chat text the
287
+ * agent authored, and argv spawning leaves the URL as the one injection
288
+ * surface: anything that could parse as a command-line option (a leading
289
+ * dash), holds whitespace, or exceeds a sane length is refused before git/svn
290
+ * ever sees it.
291
+ */
292
+ function vcsKindOf(url) {
293
+ if (typeof url !== 'string' || url.length === 0 || url.length > 2048) return null
294
+ if (url.startsWith('-') || /\s/.test(url)) return null
295
+ if (GIT_URL_RES.some((pattern) => pattern.test(url))) return 'git'
296
+ if (SVN_URL_RE.test(url)) return 'svn'
297
+ return null
298
+ }
299
+
300
+ /**
301
+ * The directory name one repository URL clones into, or null when the URL
302
+ * yields no safe single segment: the name is always the last path segment
303
+ * (with the .git suffix and any SCP host part stripped), it must be one clean
304
+ * path component, and it must never start with a dash — the target rides the
305
+ * argv after the URL, and a dash-leading argument would parse as an option.
306
+ */
307
+ function repoNameOf(url) {
308
+ const withoutScheme = url.replace(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//, '')
309
+ const tail = withoutScheme.split(/[\\/]/).pop() ?? ''
310
+ const segment = tail.includes(':') ? (tail.split(':').pop() ?? '') : tail
311
+ const name = segment.replace(/\.git$/i, '')
312
+ return /^[A-Za-z0-9._][A-Za-z0-9._-]*$/.test(name) && name !== '.' && name !== '..' ? name : null
313
+ }
314
+
315
+ /** Whether the path names an existing file or directory on disk. */
316
+ async function pathExists(absolute) {
317
+ try {
318
+ await stat(absolute)
319
+ return true
320
+ } catch {
321
+ return false
322
+ }
323
+ }
324
+
325
+ /** Quote one string as a POSIX single-quoted shell word. */
326
+ function shellQuote(value) {
327
+ return `'${value.replaceAll("'", `'\\''`)}'`
328
+ }
329
+
330
+ /** Quote one word for a cmd.exe command line: double-quote only when it holds whitespace. */
331
+ function cmdQuote(word) {
332
+ return /\s/.test(word) ? `"${word}"` : word
333
+ }
334
+
335
+ /**
336
+ * The environment variable carrying the run command line to the cmd instance
337
+ * inside Windows Terminal. The token holds no whitespace, so it survives
338
+ * wt.exe's command-line reconstruction untouched and cmd.exe expands it into
339
+ * the full command at execution time.
340
+ */
341
+ const RUN_COMMAND_ENV = 'FILE_ACTIONS_RUN_CMD'
342
+ const RUN_COMMAND_TOKEN = '%FILE_ACTIONS_RUN_CMD%'
343
+
344
+ /** File extensions Windows runs directly through CreateProcess. */
345
+ const WIN32_EXECUTABLE_EXTENSIONS = new Set(['exe', 'bat', 'cmd', 'com'])
346
+
347
+ /**
348
+ * Build the run invocation for one file: the configured command words ahead of
349
+ * the file path. POSIX hosts get a shell string (`{ script }`) handed to a
350
+ * shell; Windows hosts get argv words (`{ tokens }`) handed to the terminal's
351
+ * shell opener, so the file path never crosses a second quoting layer.
352
+ * POSIX executability comes from the execute bit; on Windows, where chmod has
353
+ * no effect, it is derived from the file extension instead.
354
+ * @returns null when no run spelling applies (unmapped extension, not executable).
355
+ */
356
+ function runInvocationFor(file, config, fileStat, platform) {
357
+ const extension = path.extname(file).slice(1).toLowerCase()
358
+ const command = config.runCommands[extension]
359
+ if (command !== undefined) {
360
+ if (platform === 'win32') {
361
+ const words = command.trim().split(/\s+/).filter((word) => word !== '')
362
+ return words.length === 0 ? null : { tokens: [...words, file] }
363
+ }
364
+ return { script: `${command} ${shellQuote(file)}` }
365
+ }
366
+ const executable = platform === 'win32'
367
+ ? WIN32_EXECUTABLE_EXTENSIONS.has(extension)
368
+ : (fileStat.mode & 0o111) !== 0
369
+ if (config.allowExecutableBit && executable) {
370
+ return platform === 'win32' ? { tokens: [file] } : { script: shellQuote(file) }
371
+ }
372
+ return null
373
+ }
374
+
375
+ /**
376
+ * Run one command inside a whitelisted terminal, opened at the file's
377
+ * directory. macOS keeps the historical spellings (Terminal.app AppleScript,
378
+ * Ghostty via `open … --args -e`); every other adapter spawns detached through
379
+ * the official launcher, so the terminal outlives dsh and never inherits the
380
+ * harness's credential variables. On POSIX the terminal keeps an interactive
381
+ * shell after the command ends (`exec bash …`), matching Terminal.app's
382
+ * behavior.
383
+ * @returns true when an adapter ran; false when the terminal has no adapter on
384
+ * this platform (the route answers 400 unsupported-terminal).
385
+ */
386
+ async function runInTerminal(app, file, invocation, context) {
387
+ const { platform, resolved, timeoutMs, launch, runCommand } = context
388
+ // The route guarantees a resolution; the kind guard is defensive (a terminal
389
+ // resolved through a non-argv launch would have no command to spawn).
390
+ const launcher = resolved === undefined ? undefined : resolved.launch
391
+ if (launcher === undefined || launcher.kind !== 'argv') return false
392
+ const executable = launcher.command
393
+ const directory = path.dirname(file)
394
+ const windowsCommand = invocation.tokens === undefined
395
+ ? null
396
+ : invocation.tokens.map(cmdQuote).join(' ')
397
+ const posixCommand = invocation.tokens === undefined
398
+ ? invocation.script
399
+ : invocation.tokens.map(shellQuote).join(' ')
400
+
401
+ if (platform === 'darwin') {
402
+ const script = `cd ${shellQuote(directory)} && ${posixCommand}`
403
+ if (app === 'terminal') {
404
+ // AppleScript string literal: escape backslashes first, then double quotes.
405
+ const doScript = script.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
406
+ await runCommand('osascript', ['-e', `tell application "Terminal" to do script "${doScript}"`], { timeout: timeoutMs })
407
+ return true
408
+ }
409
+ if (app === 'ghostty') {
410
+ await runCommand('open', ['-na', 'Ghostty', '--args', '-e', script], { timeout: timeoutMs })
411
+ return true
412
+ }
413
+ return false
414
+ }
415
+
416
+ if (platform === 'win32') {
417
+ if (app === 'windowsterminal') {
418
+ await launch(executable, ['-d', directory, 'cmd', '/k', RUN_COMMAND_TOKEN], {
419
+ watchMs: timeoutMs,
420
+ env: { [RUN_COMMAND_ENV]: windowsCommand },
421
+ })
422
+ return true
423
+ }
424
+ if (app === 'gitbash') {
425
+ // The gitbash resolution proves …/Git/git-bash.exe; the full Git for
426
+ // Windows layout ships mintty and bash beside it. Both shells run as
427
+ // absolute paths: a bare `exec bash` would hit WSL's
428
+ // C:\Windows\system32\bash.exe, because the inherited Windows PATH does
429
+ // not contain Git's /usr/bin. CHERE_INVOKING keeps the login shell from
430
+ // cd-ing home after our cd.
431
+ const gitRoot = path.dirname(executable)
432
+ const bash = path.join(gitRoot, 'usr', 'bin', 'bash.exe')
433
+ await launch(path.join(gitRoot, 'usr', 'bin', 'mintty.exe'), [
434
+ '-e', bash, '-l', '-c',
435
+ `cd ${shellQuote(directory)} && ${posixCommand}; exec ${shellQuote(bash)} -l -i`,
436
+ ], { watchMs: timeoutMs, env: { CHERE_INVOKING: '1' } })
437
+ return true
438
+ }
439
+ return false
440
+ }
441
+
442
+ const script = `${posixCommand}; exec bash -i`
443
+ if (app === 'ghostty') {
444
+ await launch(executable, [`--working-directory=${directory}`, '-e', 'bash', '-c', script], { watchMs: timeoutMs })
445
+ return true
446
+ }
447
+ if (app === 'gnometerminal') {
448
+ await launch(executable, [`--working-directory=${directory}`, '--', 'bash', '-c', script], { watchMs: timeoutMs })
449
+ return true
450
+ }
451
+ if (app === 'konsole') {
452
+ await launch(executable, ['--workdir', directory, '-e', 'bash', '-c', script], { watchMs: timeoutMs })
453
+ return true
454
+ }
455
+ return false
456
+ }
457
+
458
+ /**
459
+ * Register the info, launch, and run routes behind the connection trust fence.
460
+ * @param ctx - Cordis context; `webServer`, `connection`, and `subprocess` are injected.
461
+ * @param config - validated Config values.
462
+ * @param seam - test seams: resolver/catalog/launch/runCommand/stat/platform/env/home/ssh/resolveExecutable.
463
+ */
464
+ export async function apply(ctx, config, seam = {}) {
465
+ const platform = seam.platform ?? process.platform
466
+ const statOf = seam.stat ?? stat
467
+ const effective = {
468
+ runCommands: { ...defaultRunCommands(platform), ...(config.runCommands ?? {}) },
469
+ allowExecutableBit: config.allowExecutableBit ?? true,
470
+ launchTimeoutMs: config.launchTimeoutMs ?? 10_000,
471
+ cloneTimeoutMs: config.cloneTimeoutMs ?? 120_000,
472
+ }
473
+ // SSH detection asks the Cordis context getter; a host without one simply
474
+ // is not SSH (the official /apps probe stays the real visibility gate).
475
+ const ssh = seam.ssh ?? (() => {
476
+ try {
477
+ return launchedThroughSsh(launchEnvironmentOf(ctx))
478
+ } catch {
479
+ return false
480
+ }
481
+ })()
482
+
483
+ /** The composition's PATH resolver, completed like the official host's. */
484
+ const resolveExecutableOf = seam.resolveExecutable ?? (async (command) => {
485
+ try {
486
+ return await ctx.subprocess.resolveExecutable(command)
487
+ } catch {
488
+ return null
489
+ }
490
+ })
491
+
492
+ /** Platform facts per route call; the resolver fills its own launcher default. */
493
+ const internalsOf = () => ({
494
+ ssh,
495
+ platform: seam.platform,
496
+ env: seam.env,
497
+ home: seam.home,
498
+ launch: seam.launch,
499
+ resolveExecutable: resolveExecutableOf,
500
+ })
501
+
502
+ /** The official resolver bundle: seam-injected, or loaded from the installed package. */
503
+ let bundleTask
504
+ const loadBundle = () => bundleTask ??= (seam.resolver !== undefined
505
+ ? Promise.resolve({ resolver: seam.resolver, catalog: seam.catalog ?? [] })
506
+ : loadOfficialOpenInApp())
507
+ // Fail loud at activation: a broken official dependency must not become a zombie menu.
508
+ await loadBundle()
509
+
510
+ /** Lazy once-per-plugin-life resolution; the map is the mutable authority. */
511
+ let resolutionsTask
512
+ const availability = () => resolutionsTask ??= loadBundle().then((bundle) =>
513
+ bundle.resolver.resolveOpenInAppApps(effective.launchTimeoutMs, internalsOf()))
514
+
515
+ /**
516
+ * Replace one stale resolution after a missing-executable launch, exactly
517
+ * like the official host: re-resolve the entry once, or drop it from the map.
518
+ */
519
+ const refreshResolution = async (app) => {
520
+ const bundle = await loadBundle()
521
+ const map = await availability()
522
+ const fresh = await bundle.resolver.resolveLaunch(app, effective.launchTimeoutMs, internalsOf())
523
+ if (fresh === null) {
524
+ map.delete(app.id)
525
+ return undefined
526
+ }
527
+ map.set(app.id, fresh)
528
+ return fresh
529
+ }
530
+
531
+ // Warm the detection pass at activation so the first info/launch after a
532
+ // dsh web restart answers from the memoized map instead of a cold registry
533
+ // sweep (fire-and-forget: every locator failure is swallowed inside the
534
+ // resolver as "unavailable").
535
+ availability().catch(() => {})
536
+
537
+ /** Read + validate one JSON POST body, answering failures; null when invalid. */
538
+ const readPost = async (req, res, parse = parseBody) => {
539
+ const essence = String(req.headers['content-type']).split(';', 1)[0]?.trim().toLowerCase()
540
+ if (essence !== 'application/json') {
541
+ sendJson(res, 415, { code: 'unsupported-media-type', message: 'content-type must be application/json' })
542
+ return null
543
+ }
544
+ let text
545
+ try {
546
+ text = await readBoundedBody(req)
547
+ } catch {
548
+ sendJson(res, 400, { code: 'bad-request', message: 'request body unreadable' })
549
+ return null
550
+ }
551
+ if (text === null) {
552
+ sendJson(res, 413, { code: 'payload-too-large', message: 'request body is too large' })
553
+ return null
554
+ }
555
+ const parsed = parse(text)
556
+ if (parsed === null) {
557
+ sendJson(res, 400, { code: 'bad-request', message: 'request body does not match the route schema' })
558
+ return null
559
+ }
560
+ return parsed
561
+ }
562
+
563
+ ctx.effect(() => ctx.webServer.register({
564
+ kind: 'exact',
565
+ path: '/api/file-actions/info',
566
+ handler: async (req, res) => {
567
+ if (rejected(ctx.connection, req, res)) return
568
+ if (req.method !== 'GET') {
569
+ sendMethodNotAllowed(res, 'GET')
570
+ return
571
+ }
572
+ // The ids this plugin's own resolver pass verified. The browser
573
+ // intersects them with the official probe result, so a version skew
574
+ // between the two resolver copies can never show an item that would
575
+ // answer 400.
576
+ const available = await availability()
577
+ .then((map) => [...map.keys()])
578
+ .catch(() => [])
579
+ sendJson(res, 200, {
580
+ editors: [...EDITOR_IDS],
581
+ terminals: [...TERMINALS],
582
+ runExtensions: Object.keys(effective.runCommands),
583
+ allowExecutableBit: effective.allowExecutableBit,
584
+ available,
585
+ })
586
+ },
587
+ }), 'file-actions: GET /api/file-actions/info')
588
+
589
+ ctx.effect(() => ctx.webServer.register({
590
+ kind: 'exact',
591
+ path: '/api/file-actions/launch',
592
+ handler: async (req, res) => {
593
+ if (rejected(ctx.connection, req, res)) return
594
+ if (req.method !== 'POST') {
595
+ sendMethodNotAllowed(res, 'POST')
596
+ return
597
+ }
598
+ const parsed = await readPost(req, res)
599
+ if (parsed === null) return
600
+ if (!path.isAbsolute(parsed.path)) {
601
+ sendJson(res, 400, { code: 'bad-request', message: 'path must be absolute' })
602
+ return
603
+ }
604
+ if (!await pathExists(parsed.path)) {
605
+ sendJson(res, 404, { code: 'not-found', message: `path does not exist: ${parsed.path}` })
606
+ return
607
+ }
608
+ if (ssh || !EDITOR_IDS.includes(parsed.app)) {
609
+ sendJson(res, 400, { code: 'unavailable-app', message: `unknown or unavailable app: ${parsed.app}` })
610
+ return
611
+ }
612
+ const bundle = await loadBundle()
613
+ const app = bundle.catalog.find((entry) => entry.id === parsed.app)
614
+ const resolved = app === undefined ? undefined : (await availability()).get(app.id)
615
+ if (app === undefined || resolved === undefined) {
616
+ sendJson(res, 400, { code: 'unavailable-app', message: `unknown or unavailable app: ${parsed.app}` })
617
+ return
618
+ }
619
+ let outcome = await bundle.resolver.launchResolved(resolved, parsed.path, effective.launchTimeoutMs, internalsOf())
620
+ if (outcome === 'missing') {
621
+ const fresh = await refreshResolution(app)
622
+ outcome = fresh === undefined
623
+ ? 'failed'
624
+ : await bundle.resolver.launchResolved(fresh, parsed.path, effective.launchTimeoutMs, internalsOf())
625
+ }
626
+ if (outcome === 'launched') sendJson(res, 200, { ok: true })
627
+ else sendJson(res, 502, { code: 'launch-failed', message: `failed to launch ${parsed.app}` })
628
+ },
629
+ }), 'file-actions: POST /api/file-actions/launch')
630
+
631
+ ctx.effect(() => ctx.webServer.register({
632
+ kind: 'exact',
633
+ path: '/api/file-actions/run',
634
+ handler: async (req, res) => {
635
+ if (rejected(ctx.connection, req, res)) return
636
+ if (req.method !== 'POST') {
637
+ sendMethodNotAllowed(res, 'POST')
638
+ return
639
+ }
640
+ const parsed = await readPost(req, res)
641
+ if (parsed === null) return
642
+ if (ssh || !TERMINALS.includes(parsed.app)) {
643
+ sendJson(res, 400, { code: 'unsupported-terminal', message: `unsupported terminal: ${parsed.app}` })
644
+ return
645
+ }
646
+ if (!path.isAbsolute(parsed.path)) {
647
+ sendJson(res, 400, { code: 'bad-request', message: 'path must be absolute' })
648
+ return
649
+ }
650
+ let fileStat
651
+ try {
652
+ fileStat = await statOf(parsed.path)
653
+ } catch {
654
+ sendJson(res, 404, { code: 'not-found', message: `path does not exist: ${parsed.path}` })
655
+ return
656
+ }
657
+ if (!fileStat.isFile()) {
658
+ sendJson(res, 422, { code: 'not-a-file', message: `not a regular file: ${parsed.path}` })
659
+ return
660
+ }
661
+ const invocation = runInvocationFor(parsed.path, effective, fileStat, platform)
662
+ if (invocation === null) {
663
+ sendJson(res, 422, { code: 'no-command', message: `no run command for extension: ${path.extname(parsed.path)}` })
664
+ return
665
+ }
666
+ const bundle = await loadBundle()
667
+ const resolved = (await availability()).get(parsed.app)
668
+ if (resolved === undefined) {
669
+ // The browser's triple intersection makes this unreachable from the
670
+ // menu; a direct POST names a terminal this host never resolved —
671
+ // distinct from "unsupported", which the client cannot act on.
672
+ sendJson(res, 400, { code: 'unavailable-terminal', message: `terminal is not available on this host: ${parsed.app}` })
673
+ return
674
+ }
675
+ let launched
676
+ try {
677
+ launched = await runInTerminal(parsed.app, parsed.path, invocation, {
678
+ platform,
679
+ resolved,
680
+ timeoutMs: effective.launchTimeoutMs,
681
+ launch: seam.launch ?? bundle.resolver.launchDetachedApp,
682
+ runCommand: seam.runCommand ?? execFileAsync,
683
+ })
684
+ } catch {
685
+ sendJson(res, 502, { code: 'launch-failed', message: `failed to run in ${parsed.app}` })
686
+ return
687
+ }
688
+ if (launched) sendJson(res, 200, { ok: true })
689
+ else sendJson(res, 400, { code: 'unsupported-terminal', message: `unsupported terminal: ${parsed.app}` })
690
+ },
691
+ }), 'file-actions: POST /api/file-actions/run')
692
+
693
+ ctx.effect(() => ctx.webServer.register({
694
+ kind: 'exact',
695
+ path: '/api/file-actions/clone',
696
+ handler: async (req, res) => {
697
+ if (rejected(ctx.connection, req, res)) return
698
+ if (req.method !== 'POST') {
699
+ sendMethodNotAllowed(res, 'POST')
700
+ return
701
+ }
702
+ const parsed = await readPost(req, res, parseCloneBody)
703
+ if (parsed === null) return
704
+ if (vcsKindOf(parsed.url) !== parsed.vcs) {
705
+ sendJson(res, 400, { code: 'bad-url', message: `not a ${parsed.vcs} repository URL: ${parsed.url}` })
706
+ return
707
+ }
708
+ if (!path.isAbsolute(parsed.parent)) {
709
+ sendJson(res, 400, { code: 'bad-request', message: 'parent must be absolute' })
710
+ return
711
+ }
712
+ let parentStat
713
+ try {
714
+ parentStat = await statOf(parsed.parent)
715
+ } catch {
716
+ sendJson(res, 404, { code: 'not-found', message: `parent does not exist: ${parsed.parent}` })
717
+ return
718
+ }
719
+ if (!parentStat.isDirectory()) {
720
+ sendJson(res, 400, { code: 'bad-request', message: `parent is not a directory: ${parsed.parent}` })
721
+ return
722
+ }
723
+ const name = repoNameOf(parsed.url)
724
+ if (name === null) {
725
+ sendJson(res, 400, { code: 'bad-url', message: `the URL yields no safe directory name: ${parsed.url}` })
726
+ return
727
+ }
728
+ const target = path.join(parsed.parent, name)
729
+ if (await pathExists(target)) {
730
+ sendJson(res, 409, { code: 'target-exists', message: `target already exists: ${target}` })
731
+ return
732
+ }
733
+ const args = parsed.vcs === 'git'
734
+ ? ['clone', parsed.url, target]
735
+ : ['checkout', parsed.url, target, '--non-interactive']
736
+ try {
737
+ // argv straight to the VCS — no shell — with the URL already
738
+ // option-injection screened, and interactive credential prompts off so
739
+ // a private repo fails fast instead of hanging the bounded command
740
+ // (cached credential helpers and agent keys keep working).
741
+ await (seam.runCommand ?? execFileAsync)(parsed.vcs === 'git' ? 'git' : 'svn', args, {
742
+ timeout: effective.cloneTimeoutMs,
743
+ maxBuffer: 10 * 1024 * 1024,
744
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
745
+ })
746
+ } catch {
747
+ sendJson(res, 502, { code: 'clone-failed', message: `failed to ${parsed.vcs === 'git' ? 'clone' : 'check out'} ${parsed.url}` })
748
+ return
749
+ }
750
+ sendJson(res, 200, { ok: true })
751
+ },
752
+ }), 'file-actions: POST /api/file-actions/clone')
753
+ }