dsh-plugin-container 0.0.0-stage → 0.2.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.md +341 -2
- package/cordis.patch.yml +3 -0
- package/docs/ai-chat.png +0 -0
- package/docs/ai-new-session.png +0 -0
- package/docs/apple-container.png +0 -0
- package/docs/attach.png +0 -0
- package/docs/docker-console.png +0 -0
- package/docs/docker-overview.png +0 -0
- package/docs/docker-volumes.png +0 -0
- package/docs/image-architecture.png +0 -0
- package/docs/package-install.png +0 -0
- package/docs/provider-switch.png +0 -0
- package/docs/run-container.png +0 -0
- package/index.js +138 -0
- package/lib/client.js +3499 -0
- package/package.json +62 -4
- package/src/client/api.js +94 -0
- package/src/client/attach.jsx +133 -0
- package/src/client/components.jsx +301 -0
- package/src/client/hooks.js +161 -0
- package/src/client/i18n.js +496 -0
- package/src/client/icons.jsx +219 -0
- package/src/client/index.jsx +76 -0
- package/src/client/page.jsx +755 -0
- package/src/client/run-container.jsx +187 -0
- package/src/client/runs.js +182 -0
- package/src/client/store.js +91 -0
- package/src/client/styles.js +363 -0
- package/src/client/tabs/compose.jsx +94 -0
- package/src/client/tabs/console.jsx +211 -0
- package/src/client/tabs/containers.jsx +143 -0
- package/src/client/tabs/images.jsx +127 -0
- package/src/client/tabs/networks.jsx +91 -0
- package/src/client/tabs/overview.jsx +266 -0
- package/src/client/tabs/volumes.jsx +76 -0
- package/src/host/apple-cli.js +307 -0
- package/src/host/apple.js +471 -0
- package/src/host/attach.js +243 -0
- package/src/host/commands.js +235 -0
- package/src/host/docker-cli.js +405 -0
- package/src/host/jobs.js +245 -0
- package/src/host/parsers.js +484 -0
- package/src/host/runtime.js +285 -0
- package/src/host/service.js +832 -0
- package/src/host/tools.js +198 -0
- package/src/host/util.js +97 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker CLI discovery and fully-specified child process execution.
|
|
3
|
+
*
|
|
4
|
+
* No shell is involved: every command is an argv array handed to the
|
|
5
|
+
* subprocess seam. The CLI path is pinned for the lifetime of a process but
|
|
6
|
+
* re-resolved when a status check fails with a missing executable.
|
|
7
|
+
*/
|
|
8
|
+
import { accessSync, constants, statSync } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
11
|
+
|
|
12
|
+
import { codeOf, messageOf } from './util.js'
|
|
13
|
+
import { summarizeStderr } from './parsers.js'
|
|
14
|
+
|
|
15
|
+
const DEFAULT_MAX_OUTPUT = 1024 * 1024
|
|
16
|
+
const DEFAULT_QUERY_TIMEOUT = 20_000
|
|
17
|
+
const RESOLVE_RETRY_MS = 10_000
|
|
18
|
+
|
|
19
|
+
export class DockerCliError extends Error {
|
|
20
|
+
/** @param {string} message @param {string} [code] @param {Record<string, unknown>} [details] */
|
|
21
|
+
constructor(message, code = 'docker/cli-missing', details = {}) {
|
|
22
|
+
super(message)
|
|
23
|
+
this.name = 'DockerCliError'
|
|
24
|
+
this.code = code
|
|
25
|
+
this.details = details
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class DockerCommandError extends Error {
|
|
30
|
+
/** @param {string} message @param {{code?: string, stderr?: string, details?: Record<string, unknown>}} [options] */
|
|
31
|
+
constructor(message, options = {}) {
|
|
32
|
+
super(message)
|
|
33
|
+
this.name = 'DockerCommandError'
|
|
34
|
+
this.code = options.code ?? 'docker/exec-failed'
|
|
35
|
+
this.stderr = options.stderr ?? ''
|
|
36
|
+
this.details = options.details ?? {}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {number} bytes */
|
|
41
|
+
function clampBytes(bytes) {
|
|
42
|
+
if (!Number.isFinite(bytes)) return DEFAULT_MAX_OUTPUT
|
|
43
|
+
return Math.min(Math.max(Math.trunc(bytes), 64 * 1024), 8 * 1024 * 1024)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @param {string} value */
|
|
47
|
+
function executable(path) {
|
|
48
|
+
try {
|
|
49
|
+
accessSync(path, constants.X_OK)
|
|
50
|
+
return statSync(path).isFile()
|
|
51
|
+
} catch {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class DockerCli {
|
|
57
|
+
/**
|
|
58
|
+
* @param {{
|
|
59
|
+
* subprocess: any,
|
|
60
|
+
* config?: Record<string, any>,
|
|
61
|
+
* env?: NodeJS.ProcessEnv,
|
|
62
|
+
* home?: string,
|
|
63
|
+
* platform?: NodeJS.Platform,
|
|
64
|
+
* logger?: any,
|
|
65
|
+
* }} options
|
|
66
|
+
*/
|
|
67
|
+
constructor(options) {
|
|
68
|
+
this.subprocess = options.subprocess
|
|
69
|
+
this.config = options.config ?? {}
|
|
70
|
+
this.env = options.env ?? process.env
|
|
71
|
+
this.home = options.home ?? homedir()
|
|
72
|
+
this.platform = options.platform ?? process.platform
|
|
73
|
+
this.logger = options.logger
|
|
74
|
+
this.maxOutput = clampBytes(Number(this.config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT))
|
|
75
|
+
this.queryTimeoutMs = Number(this.config.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT)
|
|
76
|
+
this.dockerPath = undefined
|
|
77
|
+
this.colimaPath = undefined
|
|
78
|
+
this.dockerError = undefined
|
|
79
|
+
this.lastDockerAttempt = 0
|
|
80
|
+
this.lastColimaAttempt = 0
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @returns {string[]} */
|
|
84
|
+
dockerCandidates() {
|
|
85
|
+
const configured = typeof this.config.dockerPath === 'string' && this.config.dockerPath.trim() !== '' ? this.config.dockerPath.trim() : null
|
|
86
|
+
const envPath = typeof this.env.DOCKER_PATH === 'string' && this.env.DOCKER_PATH.trim() !== '' ? this.env.DOCKER_PATH.trim() : null
|
|
87
|
+
return [
|
|
88
|
+
configured,
|
|
89
|
+
envPath,
|
|
90
|
+
'/opt/homebrew/bin/docker',
|
|
91
|
+
'/usr/local/bin/docker',
|
|
92
|
+
join(this.home, '.docker/bin/docker'),
|
|
93
|
+
'/Applications/Docker.app/Contents/Resources/bin/docker',
|
|
94
|
+
].filter((value, index, list) => typeof value === 'string' && value !== '' && list.indexOf(value) === index)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @returns {string[]} */
|
|
98
|
+
colimaCandidates() {
|
|
99
|
+
const configured = typeof this.config.colimaPath === 'string' && this.config.colimaPath.trim() !== '' ? this.config.colimaPath.trim() : null
|
|
100
|
+
return [
|
|
101
|
+
configured,
|
|
102
|
+
'/opt/homebrew/bin/colima',
|
|
103
|
+
'/usr/local/bin/colima',
|
|
104
|
+
join(this.home, 'go/bin/colima'),
|
|
105
|
+
].filter((value, index, list) => typeof value === 'string' && value !== '' && list.indexOf(value) === index)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @param {string} command @param {AbortSignal | undefined} signal */
|
|
109
|
+
async resolveOnPath(command, signal) {
|
|
110
|
+
if (typeof this.subprocess.resolveExecutable !== 'function') return undefined
|
|
111
|
+
try {
|
|
112
|
+
return await this.subprocess.resolveExecutable(command, undefined, signal)
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error?.name === 'SubprocessExecutableNotFoundError' || codeOf(error) === 'ENOENT') return undefined
|
|
115
|
+
throw error
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {{signal?: AbortSignal, force?: boolean}} [options]
|
|
121
|
+
* @returns {Promise<string>}
|
|
122
|
+
*/
|
|
123
|
+
async ensureDocker(options = {}) {
|
|
124
|
+
if (this.dockerPath !== undefined) return this.dockerPath
|
|
125
|
+
const now = Date.now()
|
|
126
|
+
if (!options.force && now - this.lastDockerAttempt < RESOLVE_RETRY_MS && this.dockerError !== undefined) throw this.dockerError
|
|
127
|
+
this.lastDockerAttempt = now
|
|
128
|
+
const configured = typeof this.config.dockerPath === 'string' && this.config.dockerPath.trim() !== '' ? this.config.dockerPath.trim() : null
|
|
129
|
+
if (configured !== null) {
|
|
130
|
+
const path = isAbsolute(configured) ? configured : resolve(this.home, configured)
|
|
131
|
+
if (executable(path)) {
|
|
132
|
+
this.dockerPath = path
|
|
133
|
+
this.dockerError = undefined
|
|
134
|
+
return path
|
|
135
|
+
}
|
|
136
|
+
const error = new DockerCliError(
|
|
137
|
+
`配置的 dockerPath 不可执行: ${configured}`,
|
|
138
|
+
'docker/cli-missing',
|
|
139
|
+
{ candidates: [configured] },
|
|
140
|
+
)
|
|
141
|
+
this.dockerError = error
|
|
142
|
+
throw error
|
|
143
|
+
}
|
|
144
|
+
for (const candidate of this.dockerCandidates()) {
|
|
145
|
+
if (candidate === configured) continue
|
|
146
|
+
const path = isAbsolute(candidate) ? candidate : resolve(this.home, candidate)
|
|
147
|
+
if (executable(path)) {
|
|
148
|
+
this.dockerPath = path
|
|
149
|
+
this.dockerError = undefined
|
|
150
|
+
return path
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const onPath = await this.resolveOnPath('docker', options.signal)
|
|
154
|
+
if (onPath !== undefined && executable(onPath)) {
|
|
155
|
+
this.dockerPath = onPath
|
|
156
|
+
this.dockerError = undefined
|
|
157
|
+
return onPath
|
|
158
|
+
}
|
|
159
|
+
const error = new DockerCliError(
|
|
160
|
+
'未找到 docker 命令。请安装 Docker Desktop,或安装 colima 并启动运行时。',
|
|
161
|
+
'docker/cli-missing',
|
|
162
|
+
{ candidates: this.dockerCandidates() },
|
|
163
|
+
)
|
|
164
|
+
this.dockerError = error
|
|
165
|
+
throw error
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param {{signal?: AbortSignal, force?: boolean}} [options]
|
|
170
|
+
* @returns {Promise<string | null>}
|
|
171
|
+
*/
|
|
172
|
+
async ensureColima(options = {}) {
|
|
173
|
+
if (this.colimaPath !== undefined) return this.colimaPath
|
|
174
|
+
const now = Date.now()
|
|
175
|
+
if (!options.force && now - this.lastColimaAttempt < RESOLVE_RETRY_MS && this.colimaPath === null) return null
|
|
176
|
+
this.lastColimaAttempt = now
|
|
177
|
+
const configured = typeof this.config.colimaPath === 'string' && this.config.colimaPath.trim() !== '' ? this.config.colimaPath.trim() : null
|
|
178
|
+
if (configured !== null) {
|
|
179
|
+
const path = isAbsolute(configured) ? configured : resolve(this.home, configured)
|
|
180
|
+
if (executable(path)) {
|
|
181
|
+
this.colimaPath = path
|
|
182
|
+
return path
|
|
183
|
+
}
|
|
184
|
+
this.colimaPath = null
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
for (const candidate of this.colimaCandidates()) {
|
|
188
|
+
const path = isAbsolute(candidate) ? candidate : resolve(this.home, candidate)
|
|
189
|
+
if (executable(path)) {
|
|
190
|
+
this.colimaPath = path
|
|
191
|
+
return path
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const onPath = await this.resolveOnPath('colima', options.signal)
|
|
195
|
+
if (onPath !== undefined && executable(onPath)) {
|
|
196
|
+
this.colimaPath = onPath
|
|
197
|
+
return onPath
|
|
198
|
+
}
|
|
199
|
+
this.colimaPath = null
|
|
200
|
+
return null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** @param {string} path */
|
|
204
|
+
markDockerMissing(path) {
|
|
205
|
+
if (this.dockerPath === path) {
|
|
206
|
+
this.dockerPath = undefined
|
|
207
|
+
this.dockerError = undefined
|
|
208
|
+
this.lastDockerAttempt = 0
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** @returns {NodeJS.ProcessEnv} */
|
|
213
|
+
childEnv() {
|
|
214
|
+
const pathParts = []
|
|
215
|
+
if (this.dockerPath !== undefined) pathParts.push(dirname(this.dockerPath))
|
|
216
|
+
if (typeof this.colimaPath === 'string') pathParts.push(dirname(this.colimaPath))
|
|
217
|
+
pathParts.push('/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin')
|
|
218
|
+
if (typeof this.env.PATH === 'string') pathParts.push(...this.env.PATH.split(':'))
|
|
219
|
+
const PATH = [...new Set(pathParts.filter(Boolean))].join(':')
|
|
220
|
+
return {
|
|
221
|
+
PATH,
|
|
222
|
+
NO_COLOR: '1',
|
|
223
|
+
CLICOLOR: '0',
|
|
224
|
+
TERM: 'dumb',
|
|
225
|
+
DOCKER_CLI_HINTS: 'false',
|
|
226
|
+
DOCKER_CLIENT_TIMEOUT: String(this.config.dockerClientTimeoutSec ?? 10),
|
|
227
|
+
COMPOSE_HTTP_TIMEOUT: String(this.config.composeHttpTimeoutSec ?? 20),
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* @param {string[]} subargv
|
|
233
|
+
* @param {{context?: string | null, cwd?: string, timeoutMs?: number, signal?: AbortSignal, maxBytes?: number}} [options]
|
|
234
|
+
*/
|
|
235
|
+
argvFor(subargv, options = {}) {
|
|
236
|
+
const path = this.dockerPath
|
|
237
|
+
if (path === undefined) throw new DockerCliError('docker 尚未解析', 'docker/cli-missing')
|
|
238
|
+
return [path, ...(options.context ? ['--context', options.context] : []), ...subargv]
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Execute one Docker command and collect bounded stdout/stderr.
|
|
243
|
+
* @param {string[]} argv fully-resolved argv including the docker path
|
|
244
|
+
* @param {{cwd?: string, timeoutMs?: number, signal?: AbortSignal, maxBytes?: number}} [options]
|
|
245
|
+
*/
|
|
246
|
+
async exec(argv, options = {}) {
|
|
247
|
+
const maxBytes = clampBytes(options.maxBytes ?? this.maxOutput)
|
|
248
|
+
const timeoutMs = Math.min(Math.max(Number(options.timeoutMs ?? this.queryTimeoutMs), 1000), 10 * 60_000)
|
|
249
|
+
const abort = new AbortController()
|
|
250
|
+
let timedOut = false
|
|
251
|
+
const onAbort = () => abort.abort(options.signal?.reason)
|
|
252
|
+
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
253
|
+
if (options.signal?.aborted === true) abort.abort(options.signal.reason)
|
|
254
|
+
const timeout = setTimeout(() => {
|
|
255
|
+
timedOut = true
|
|
256
|
+
abort.abort(new Error('docker command timed out'))
|
|
257
|
+
}, timeoutMs)
|
|
258
|
+
timeout.unref?.()
|
|
259
|
+
|
|
260
|
+
let handle
|
|
261
|
+
try {
|
|
262
|
+
handle = this.subprocess.spawn({
|
|
263
|
+
argv,
|
|
264
|
+
cwd: options.cwd ?? this.home,
|
|
265
|
+
stdio: { stdin: 'ignore', stdout: { maxBytes }, stderr: { maxBytes } },
|
|
266
|
+
graceMs: 2000,
|
|
267
|
+
env: this.childEnv(),
|
|
268
|
+
signal: abort.signal,
|
|
269
|
+
})
|
|
270
|
+
} catch (error) {
|
|
271
|
+
clearTimeout(timeout)
|
|
272
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
273
|
+
throw new DockerCommandError(`无法启动 docker: ${messageOf(error)}`, { code: 'docker/exec-failed' })
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let outcome
|
|
277
|
+
try {
|
|
278
|
+
outcome = await handle.done
|
|
279
|
+
} catch (error) {
|
|
280
|
+
clearTimeout(timeout)
|
|
281
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
282
|
+
this.markDockerMissing(argv[0])
|
|
283
|
+
throw new DockerCommandError(`docker 进程失败: ${messageOf(error)}`, { code: 'docker/exec-failed' })
|
|
284
|
+
}
|
|
285
|
+
clearTimeout(timeout)
|
|
286
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
287
|
+
|
|
288
|
+
const read = (reader) => {
|
|
289
|
+
if (reader === undefined) return { text: '', truncated: false }
|
|
290
|
+
const value = reader.readFrom(0)
|
|
291
|
+
return { text: value.text, truncated: value.lossy === true, nextOffset: value.nextOffset }
|
|
292
|
+
}
|
|
293
|
+
const stdout = read(handle.collected?.stdout)
|
|
294
|
+
const stderr = read(handle.collected?.stderr)
|
|
295
|
+
return {
|
|
296
|
+
exitCode: outcome.exitCode,
|
|
297
|
+
signal: outcome.signal,
|
|
298
|
+
stdout: stdout.text,
|
|
299
|
+
stderr: stderr.text,
|
|
300
|
+
truncated: stdout.truncated || stderr.truncated,
|
|
301
|
+
timedOut,
|
|
302
|
+
argv,
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Execute a Docker subcommand with the selected context and parse-friendly
|
|
308
|
+
* error reporting.
|
|
309
|
+
* @param {string[]} subargv
|
|
310
|
+
* @param {{context?: string | null, cwd?: string, timeoutMs?: number, signal?: AbortSignal, maxBytes?: number}} [options]
|
|
311
|
+
*/
|
|
312
|
+
async docker(subargv, options = {}) {
|
|
313
|
+
const path = await this.ensureDocker({ signal: options.signal })
|
|
314
|
+
const argv = this.argvFor(subargv, options)
|
|
315
|
+
const result = await this.exec(argv, { ...options, cwd: options.cwd ?? this.home })
|
|
316
|
+
if (result.timedOut || result.exitCode !== 0) {
|
|
317
|
+
const detail = summarizeStderr(result.stderr)
|
|
318
|
+
throw new DockerCommandError(
|
|
319
|
+
result.timedOut
|
|
320
|
+
? `docker 命令超时(${subargv[0] ?? ''})`
|
|
321
|
+
: detail.trim() === ''
|
|
322
|
+
? `docker ${subargv[0] ?? ''} 退出码 ${String(result.exitCode)}`
|
|
323
|
+
: detail,
|
|
324
|
+
{
|
|
325
|
+
code: result.timedOut ? 'docker/timeout' : 'docker/exec-failed',
|
|
326
|
+
stderr: result.stderr,
|
|
327
|
+
details: {
|
|
328
|
+
exitCode: result.exitCode,
|
|
329
|
+
signal: result.signal,
|
|
330
|
+
timedOut: result.timedOut,
|
|
331
|
+
argv,
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
return { ...result, path }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Execute a non-docker helper (colima/open/osascript) and collect output.
|
|
341
|
+
* @param {string} command absolute path or PATH name
|
|
342
|
+
* @param {string[]} args
|
|
343
|
+
* @param {{timeoutMs?: number, signal?: AbortSignal, maxBytes?: number, cwd?: string}} [options]
|
|
344
|
+
*/
|
|
345
|
+
async program(command, args, options = {}) {
|
|
346
|
+
const maxBytes = clampBytes(options.maxBytes ?? this.maxOutput)
|
|
347
|
+
const timeoutMs = Math.min(Math.max(Number(options.timeoutMs ?? this.queryTimeoutMs), 1000), 30 * 60_000)
|
|
348
|
+
const abort = new AbortController()
|
|
349
|
+
let timedOut = false
|
|
350
|
+
const onAbort = () => abort.abort(options.signal?.reason)
|
|
351
|
+
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
352
|
+
if (options.signal?.aborted === true) abort.abort(options.signal.reason)
|
|
353
|
+
const timeout = setTimeout(() => {
|
|
354
|
+
timedOut = true
|
|
355
|
+
abort.abort(new Error('command timed out'))
|
|
356
|
+
}, timeoutMs)
|
|
357
|
+
timeout.unref?.()
|
|
358
|
+
let handle
|
|
359
|
+
try {
|
|
360
|
+
handle = this.subprocess.spawn({
|
|
361
|
+
argv: [command, ...args],
|
|
362
|
+
cwd: options.cwd ?? this.home,
|
|
363
|
+
stdio: { stdin: 'ignore', stdout: { maxBytes }, stderr: { maxBytes } },
|
|
364
|
+
graceMs: 2000,
|
|
365
|
+
env: this.childEnv(),
|
|
366
|
+
signal: abort.signal,
|
|
367
|
+
})
|
|
368
|
+
} catch (error) {
|
|
369
|
+
clearTimeout(timeout)
|
|
370
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
371
|
+
throw new DockerCommandError(`无法启动 ${basename(command)}: ${messageOf(error)}`, { code: 'docker/exec-failed' })
|
|
372
|
+
}
|
|
373
|
+
let outcome
|
|
374
|
+
try {
|
|
375
|
+
outcome = await handle.done
|
|
376
|
+
} catch (error) {
|
|
377
|
+
clearTimeout(timeout)
|
|
378
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
379
|
+
throw new DockerCommandError(`${basename(command)} 进程失败: ${messageOf(error)}`, { code: 'docker/exec-failed' })
|
|
380
|
+
}
|
|
381
|
+
clearTimeout(timeout)
|
|
382
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
383
|
+
const stdout = handle.collected?.stdout?.readFrom(0)?.text ?? ''
|
|
384
|
+
const stderr = handle.collected?.stderr?.readFrom(0)?.text ?? ''
|
|
385
|
+
if (timedOut || outcome.exitCode !== 0) {
|
|
386
|
+
throw new DockerCommandError(
|
|
387
|
+
timedOut ? `${basename(command)} 超时` : summarizeStderr(stderr) || `${basename(command)} 退出码 ${String(outcome.exitCode)}`, {
|
|
388
|
+
code: timedOut ? 'docker/timeout' : 'docker/exec-failed',
|
|
389
|
+
stderr,
|
|
390
|
+
details: { command, args, exitCode: outcome.exitCode, timedOut },
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
return { stdout, stderr, exitCode: outcome.exitCode, signal: outcome.signal, timedOut }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** @param {string} endpoint */
|
|
397
|
+
static isLocalEndpoint(endpoint) {
|
|
398
|
+
return /^(?:unix|npipe):/iu.test(String(endpoint ?? ''))
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** @param {string} path */
|
|
402
|
+
static commandName(path) {
|
|
403
|
+
return basename(path)
|
|
404
|
+
}
|
|
405
|
+
}
|
package/src/host/jobs.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Long-running Docker/CLI jobs with offset polling, cancellation, timeouts,
|
|
3
|
+
* bounded retention, and a concurrency cap.
|
|
4
|
+
*
|
|
5
|
+
* Jobs are independent of a single RPC call: `run` returns immediately and
|
|
6
|
+
* `poll` reads non-consuming collectors. That makes the terminal resilient to
|
|
7
|
+
* tab switches and navigation while the host command keeps running.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from 'node:crypto'
|
|
10
|
+
|
|
11
|
+
import { DockerCommandError } from './docker-cli.js'
|
|
12
|
+
import { codeOf, messageOf } from './util.js'
|
|
13
|
+
|
|
14
|
+
const MAX_RUNS = 4
|
|
15
|
+
const RETENTION_MS = 10 * 60_000
|
|
16
|
+
const MAX_RECORDS = 30
|
|
17
|
+
const MIN_TIMEOUT_MS = 5_000
|
|
18
|
+
const MAX_TIMEOUT_MS = 30 * 60_000
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 120_000
|
|
20
|
+
|
|
21
|
+
/** @param {unknown} value @param {number} fallback */
|
|
22
|
+
function clampOffset(value, fallback = 0) {
|
|
23
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.trunc(value) : fallback
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {unknown} value @param {number} min */
|
|
27
|
+
function clampTimeout(value, min = MIN_TIMEOUT_MS) {
|
|
28
|
+
const requested = typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : DEFAULT_TIMEOUT_MS
|
|
29
|
+
return Math.min(Math.max(requested, min), MAX_TIMEOUT_MS)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class JobManager {
|
|
33
|
+
/**
|
|
34
|
+
* @param {{
|
|
35
|
+
* subprocess: any,
|
|
36
|
+
* cli: import('./docker-cli.js').DockerCli,
|
|
37
|
+
* maxOutputBytes?: number,
|
|
38
|
+
* maxRuns?: number,
|
|
39
|
+
* retentionMs?: number,
|
|
40
|
+
* maxRecords?: number,
|
|
41
|
+
* }} options
|
|
42
|
+
*/
|
|
43
|
+
constructor(options) {
|
|
44
|
+
this.subprocess = options.subprocess
|
|
45
|
+
this.cli = options.cli
|
|
46
|
+
this.maxOutputBytes = options.maxOutputBytes ?? 1024 * 1024
|
|
47
|
+
this.maxRuns = options.maxRuns ?? MAX_RUNS
|
|
48
|
+
this.retentionMs = options.retentionMs ?? RETENTION_MS
|
|
49
|
+
this.maxRecords = options.maxRecords ?? MAX_RECORDS
|
|
50
|
+
this.minTimeoutMs = options.minTimeoutMs ?? MIN_TIMEOUT_MS
|
|
51
|
+
this.idPrefix = options.idPrefix ?? 'run'
|
|
52
|
+
/** @type {Map<string, any>} */
|
|
53
|
+
this.records = new Map()
|
|
54
|
+
this.sweeper = setInterval(() => this.sweep(), 60_000)
|
|
55
|
+
this.sweeper.unref?.()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {string} runId */
|
|
59
|
+
find(runId) {
|
|
60
|
+
const record = this.records.get(runId)
|
|
61
|
+
if (record === undefined) {
|
|
62
|
+
throw Object.assign(new Error(`Docker 运行记录不存在或已过期: ${runId}`), { code: 'docker/run-not-found' })
|
|
63
|
+
}
|
|
64
|
+
return record
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @param {any} record */
|
|
68
|
+
publicView(record) {
|
|
69
|
+
return {
|
|
70
|
+
id: record.id,
|
|
71
|
+
argv: record.argv,
|
|
72
|
+
displayCommand: record.displayCommand,
|
|
73
|
+
context: record.context,
|
|
74
|
+
cwd: record.cwd,
|
|
75
|
+
title: record.title,
|
|
76
|
+
status: record.status,
|
|
77
|
+
exitCode: record.exitCode,
|
|
78
|
+
signal: record.signal,
|
|
79
|
+
startedAt: record.startedAt,
|
|
80
|
+
endedAt: record.endedAt,
|
|
81
|
+
timedOut: record.timedOut,
|
|
82
|
+
error: record.error,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {{
|
|
88
|
+
* argv: string[],
|
|
89
|
+
* displayCommand: string,
|
|
90
|
+
* context?: string | null,
|
|
91
|
+
* cwd: string,
|
|
92
|
+
* title?: string,
|
|
93
|
+
* timeoutMs?: number,
|
|
94
|
+
* maxOutputBytes?: number,
|
|
95
|
+
* }} input
|
|
96
|
+
*/
|
|
97
|
+
start(input) {
|
|
98
|
+
const active = [...this.records.values()].filter((record) => record.status === 'running' || record.status === 'cancelling')
|
|
99
|
+
if (active.length >= this.maxRuns) {
|
|
100
|
+
throw Object.assign(
|
|
101
|
+
new Error(`同时运行的 Docker 命令已达上限(${this.maxRuns} 个)`),
|
|
102
|
+
{ code: 'docker/run-limit', details: { active: active.map((record) => record.id) } },
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
if (!Array.isArray(input.argv) || input.argv.length === 0) throw new DockerCommandError('命令为空', { code: 'docker/invalid-command' })
|
|
106
|
+
const timeoutMs = clampTimeout(input.timeoutMs, this.minTimeoutMs)
|
|
107
|
+
const requestedMax = Number(input.maxOutputBytes ?? this.maxOutputBytes)
|
|
108
|
+
const maxBytes = Number.isFinite(requestedMax) ? Math.min(Math.max(requestedMax, 64 * 1024), 8 * 1024 * 1024) : 1024 * 1024
|
|
109
|
+
const id = `${this.idPrefix}_${randomUUID()}`
|
|
110
|
+
const record = {
|
|
111
|
+
id,
|
|
112
|
+
argv: [...input.argv],
|
|
113
|
+
displayCommand: input.displayCommand,
|
|
114
|
+
context: input.context ?? null,
|
|
115
|
+
cwd: input.cwd,
|
|
116
|
+
title: input.title ?? '',
|
|
117
|
+
status: 'running',
|
|
118
|
+
exitCode: null,
|
|
119
|
+
signal: null,
|
|
120
|
+
startedAt: Date.now(),
|
|
121
|
+
endedAt: null,
|
|
122
|
+
timedOut: false,
|
|
123
|
+
cancelled: false,
|
|
124
|
+
error: null,
|
|
125
|
+
handle: undefined,
|
|
126
|
+
stdout: undefined,
|
|
127
|
+
stderr: undefined,
|
|
128
|
+
timer: undefined,
|
|
129
|
+
abort: undefined,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
record.handle = this.subprocess.spawn({
|
|
134
|
+
argv: record.argv,
|
|
135
|
+
cwd: record.cwd,
|
|
136
|
+
stdio: { stdin: 'ignore', stdout: { maxBytes }, stderr: { maxBytes } },
|
|
137
|
+
graceMs: 2000,
|
|
138
|
+
env: this.cli.childEnv(),
|
|
139
|
+
})
|
|
140
|
+
} catch (error) {
|
|
141
|
+
throw new DockerCommandError(`无法启动命令: ${messageOf(error)}`, {
|
|
142
|
+
code: codeOf(error) === 'ENOENT' ? 'docker/cli-missing' : 'docker/exec-failed',
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
record.stdout = record.handle.collected?.stdout
|
|
147
|
+
record.stderr = record.handle.collected?.stderr
|
|
148
|
+
record.timer = setTimeout(() => {
|
|
149
|
+
if (record.status !== 'running') return
|
|
150
|
+
record.timedOut = true
|
|
151
|
+
try {
|
|
152
|
+
record.handle?.terminate?.()
|
|
153
|
+
} catch {
|
|
154
|
+
// done still settles the record.
|
|
155
|
+
}
|
|
156
|
+
}, timeoutMs)
|
|
157
|
+
record.timer.unref?.()
|
|
158
|
+
|
|
159
|
+
const finish = (outcome) => {
|
|
160
|
+
if (record.status === 'exited' || record.status === 'cancelled' || record.status === 'timed-out' || record.status === 'failed') return
|
|
161
|
+
clearTimeout(record.timer)
|
|
162
|
+
record.endedAt = Date.now()
|
|
163
|
+
record.exitCode = outcome?.exitCode ?? null
|
|
164
|
+
record.signal = outcome?.signal ?? null
|
|
165
|
+
if (record.timedOut) record.status = 'timed-out'
|
|
166
|
+
else if (record.cancelled === true) record.status = 'cancelled'
|
|
167
|
+
else if (record.exitCode === 0) record.status = 'exited'
|
|
168
|
+
else record.status = 'failed'
|
|
169
|
+
this.sweep()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
record.handle.done.then(finish, (error) => {
|
|
173
|
+
if (record.status !== 'running') return
|
|
174
|
+
clearTimeout(record.timer)
|
|
175
|
+
record.endedAt = Date.now()
|
|
176
|
+
record.error = messageOf(error)
|
|
177
|
+
record.status = 'failed'
|
|
178
|
+
this.sweep()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
this.records.set(id, record)
|
|
182
|
+
return this.publicView(record)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {{runId: string, stdoutOffset?: number, stderrOffset?: number}} input
|
|
187
|
+
*/
|
|
188
|
+
poll(input) {
|
|
189
|
+
const record = this.find(input.runId)
|
|
190
|
+
const read = (reader, offset) => {
|
|
191
|
+
if (reader === undefined) return { text: '', nextOffset: offset, lossy: false }
|
|
192
|
+
const value = reader.readFrom(clampOffset(offset))
|
|
193
|
+
return { text: value.text, nextOffset: value.nextOffset, lossy: value.lossy === true }
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
run: this.publicView(record),
|
|
197
|
+
stdout: read(record.stdout, clampOffset(input.stdoutOffset)),
|
|
198
|
+
stderr: read(record.stderr, clampOffset(input.stderrOffset)),
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** @param {string} runId */
|
|
203
|
+
cancel(runId) {
|
|
204
|
+
const record = this.find(runId)
|
|
205
|
+
if (record.status !== 'running' && record.status !== 'cancelling') return this.publicView(record)
|
|
206
|
+
record.cancelled = true
|
|
207
|
+
record.status = 'cancelling'
|
|
208
|
+
try {
|
|
209
|
+
record.handle?.terminate?.()
|
|
210
|
+
} catch (error) {
|
|
211
|
+
record.error = messageOf(error)
|
|
212
|
+
}
|
|
213
|
+
return this.publicView(record)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Remove terminal records beyond retention or count. */
|
|
217
|
+
sweep() {
|
|
218
|
+
const now = Date.now()
|
|
219
|
+
const terminal = [...this.records.values()]
|
|
220
|
+
.filter((record) => record.status !== 'running' && record.status !== 'cancelling')
|
|
221
|
+
.sort((left, right) => (left.endedAt ?? left.startedAt) - (right.endedAt ?? right.startedAt))
|
|
222
|
+
for (const record of terminal) {
|
|
223
|
+
if (now - (record.endedAt ?? record.startedAt) > this.retentionMs) this.records.delete(record.id)
|
|
224
|
+
}
|
|
225
|
+
const remainingTerminal = [...this.records.values()]
|
|
226
|
+
.filter((record) => record.status !== 'running' && record.status !== 'cancelling')
|
|
227
|
+
.sort((left, right) => (right.endedAt ?? right.startedAt) - (left.endedAt ?? left.startedAt))
|
|
228
|
+
for (const record of remainingTerminal.slice(this.maxRecords)) this.records.delete(record.id)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
dispose() {
|
|
232
|
+
clearInterval(this.sweeper)
|
|
233
|
+
for (const record of this.records.values()) {
|
|
234
|
+
clearTimeout(record.timer)
|
|
235
|
+
if (record.status === 'running' || record.status === 'cancelling') {
|
|
236
|
+
try {
|
|
237
|
+
record.handle?.terminate?.()
|
|
238
|
+
} catch {
|
|
239
|
+
// Best effort on plugin unload.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
this.records.clear()
|
|
244
|
+
}
|
|
245
|
+
}
|