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/LICENSE +21 -0
- package/README.en-US.md +444 -0
- package/README.md +226 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +1222 -0
- package/lib/index.js +753 -0
- package/package.json +40 -0
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
|
+
}
|