dsh-plugin-container 0.0.0-stage → 0.3.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/cordis.patch.yml +3 -0
  4. package/docs/ai-chat.png +0 -0
  5. package/docs/ai-new-session.png +0 -0
  6. package/docs/apple-container.png +0 -0
  7. package/docs/attach.png +0 -0
  8. package/docs/docker-console.png +0 -0
  9. package/docs/docker-overview.png +0 -0
  10. package/docs/docker-volumes.png +0 -0
  11. package/docs/image-architecture.png +0 -0
  12. package/docs/package-install.png +0 -0
  13. package/docs/provider-switch.png +0 -0
  14. package/docs/run-container.png +0 -0
  15. package/index.js +138 -0
  16. package/lib/client.js +3560 -0
  17. package/package.json +62 -4
  18. package/src/client/api.js +98 -0
  19. package/src/client/attach.jsx +134 -0
  20. package/src/client/components.jsx +320 -0
  21. package/src/client/hooks.js +161 -0
  22. package/src/client/i18n.js +502 -0
  23. package/src/client/icons.jsx +227 -0
  24. package/src/client/index.jsx +76 -0
  25. package/src/client/page.jsx +782 -0
  26. package/src/client/run-container.jsx +187 -0
  27. package/src/client/runs.js +182 -0
  28. package/src/client/store.js +91 -0
  29. package/src/client/styles.js +371 -0
  30. package/src/client/tabs/compose.jsx +94 -0
  31. package/src/client/tabs/console.jsx +211 -0
  32. package/src/client/tabs/containers.jsx +148 -0
  33. package/src/client/tabs/images.jsx +127 -0
  34. package/src/client/tabs/networks.jsx +91 -0
  35. package/src/client/tabs/overview.jsx +266 -0
  36. package/src/client/tabs/volumes.jsx +76 -0
  37. package/src/host/apple-cli.js +307 -0
  38. package/src/host/apple.js +471 -0
  39. package/src/host/attach.js +246 -0
  40. package/src/host/commands.js +235 -0
  41. package/src/host/docker-cli.js +405 -0
  42. package/src/host/jobs.js +245 -0
  43. package/src/host/parsers.js +484 -0
  44. package/src/host/runtime.js +285 -0
  45. package/src/host/service.js +836 -0
  46. package/src/host/tools.js +198 -0
  47. package/src/host/util.js +97 -0
@@ -0,0 +1,836 @@
1
+ /**
2
+ * Host-side Docker service exposed to the browser over the Connection generic
3
+ * RPC channel `/docker`.
4
+ *
5
+ * Every method returns `{ok:true,value}` or `{ok:false,error}`. There is no
6
+ * shell, no model-facing tool, and no persistence: query caches and command
7
+ * records live in this plugin's memory and disappear on unload.
8
+ */
9
+ import { homedir } from 'node:os'
10
+ import { statSync } from 'node:fs'
11
+
12
+ import {
13
+ CommandError,
14
+ describeCommand,
15
+ detectDestructive,
16
+ normalizeContext,
17
+ normalizeSubcommand,
18
+ parseArguments,
19
+ parseCommand,
20
+ resolveCwd,
21
+ } from './commands.js'
22
+ import { AppleProvider } from './apple.js'
23
+ import { AttachManager } from './attach.js'
24
+ import { AppleCliError, AppleCommandError } from './apple-cli.js'
25
+ import { DockerCli, DockerCliError, DockerCommandError } from './docker-cli.js'
26
+ import { JobManager } from './jobs.js'
27
+ import {
28
+ firstLine,
29
+ mapDockerInfo,
30
+ mapDockerVersion,
31
+ normalizeContainer,
32
+ normalizeImage,
33
+ normalizeNetwork,
34
+ normalizeVolume,
35
+ parseComposeList,
36
+ parseContexts,
37
+ parseNdjson,
38
+ parseSystemDf,
39
+ summarizeStderr,
40
+ } from './parsers.js'
41
+ import { createRuntimeResolver } from './runtime.js'
42
+ import { codeOf, isRecord, messageOf, optionalInteger, requireString } from './util.js'
43
+
44
+ const STATUS_CACHE_MS = 1500
45
+ const CONTEXT_CACHE_MS = 10_000
46
+ const VALUE_CACHE_MS = 1500
47
+
48
+ /** @param {unknown} value */
49
+ function ok(value) {
50
+ return { ok: true, value }
51
+ }
52
+
53
+ /** @param {string} code @param {string} message @param {Record<string, unknown>} [details] */
54
+ function fail(code, message, details = {}) {
55
+ return { ok: false, error: { code, message, details } }
56
+ }
57
+
58
+ /** @param {unknown} error */
59
+ function failureFrom(error) {
60
+ if (error instanceof DockerCliError || error instanceof DockerCommandError || error instanceof AppleCliError || error instanceof AppleCommandError || error instanceof CommandError) {
61
+ return fail(error.code ?? 'docker/internal', error.message, {
62
+ ...(error.details ?? {}),
63
+ ...(error.stderr !== undefined ? { stderr: summarizeStderr(error.stderr) } : {}),
64
+ })
65
+ }
66
+ if (codeOf(error) !== undefined) return fail(codeOf(error), messageOf(error), error?.details ?? {})
67
+ return fail('docker/internal', messageOf(error))
68
+ }
69
+
70
+ /** @param {unknown} value */
71
+ function payloadOf(value) {
72
+ if (value === undefined || value === null) return {}
73
+ if (!isRecord(value)) throw new CommandError('请求参数必须是对象')
74
+ return value
75
+ }
76
+
77
+ /** @param {unknown} value @param {string} field */
78
+ function optionalString(value, field) {
79
+ if (value === undefined || value === null) return undefined
80
+ return requireString(value, field)
81
+ }
82
+
83
+ /** @param {unknown} value */
84
+ function optionalBoolean(value) {
85
+ return value === true
86
+ }
87
+
88
+ /** @param {string} value */
89
+ function safeIdentifier(value) {
90
+ const id = requireString(value, 'id', { max: 256 })
91
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.:/@-]{0,255}$/u.test(id)) throw new CommandError('资源 ID 不合法')
92
+ return id
93
+ }
94
+
95
+ /** @param {unknown} value */
96
+ function safeImageRef(value) {
97
+ const reference = requireString(value, 'image', { max: 512 })
98
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.:/@-]*$/u.test(reference)) throw new CommandError('镜像名不合法')
99
+ return reference
100
+ }
101
+
102
+ /** @param {unknown} value @param {string} field */
103
+ function stringList(value, field) {
104
+ if (value === undefined || value === null) return []
105
+ if (!Array.isArray(value)) throw new CommandError(`${field} 必须是字符串数组`)
106
+ if (value.length > 64) throw new CommandError(`${field} 数量过多`)
107
+ return value.map((entry, index) => requireString(entry, `${field}[${String(index)}]`, { max: 512 }))
108
+ }
109
+
110
+ /** @param {string} value */
111
+ function safePort(value) {
112
+ if (!/^(?:(?:\d{1,3}\.){3}\d{1,3}:)?\d{1,5}:\d{1,5}(?:\/(?:tcp|udp))?$/u.test(value) && !/^\d{1,5}(?:\/(?:tcp|udp))?$/u.test(value)) {
113
+ throw new CommandError(`端口格式不合法: ${value}(示例 8080:80)`)
114
+ }
115
+ return value
116
+ }
117
+
118
+ /** @param {string} value */
119
+ function safeEnv(value) {
120
+ if (!/^[A-Za-z_][A-Za-z0-9_]*=[^\r\n]*$/u.test(value)) throw new CommandError(`环境变量格式不合法: ${value}(示例 KEY=VALUE)`)
121
+ return value
122
+ }
123
+
124
+ export class DockerService {
125
+ /**
126
+ * @param {{
127
+ * subprocess: any,
128
+ * logger?: any,
129
+ * config?: Record<string, any>,
130
+ * env?: NodeJS.ProcessEnv,
131
+ * home?: string,
132
+ * platform?: NodeJS.Platform,
133
+ * }} options
134
+ */
135
+ constructor(options) {
136
+ this.logger = options.logger
137
+ this.config = options.config ?? {}
138
+ this.home = options.home ?? homedir()
139
+ this.env = options.env ?? process.env
140
+ this.cli = new DockerCli({
141
+ subprocess: options.subprocess,
142
+ config: this.config,
143
+ env: this.env,
144
+ home: this.home,
145
+ platform: options.platform,
146
+ logger: this.logger,
147
+ })
148
+ this.jobs = new JobManager({
149
+ subprocess: options.subprocess,
150
+ cli: this.cli,
151
+ maxOutputBytes: Number(this.config.maxOutputBytes ?? 1024 * 1024),
152
+ maxRuns: Number(this.config.maxConcurrentRuns ?? 4),
153
+ idPrefix: 'docker',
154
+ })
155
+ this.runtime = createRuntimeResolver({
156
+ cli: this.cli,
157
+ platform: options.platform,
158
+ env: this.env,
159
+ config: this.config,
160
+ })
161
+ this.attach = new AttachManager({
162
+ subprocess: options.subprocess,
163
+ maxBytes: Number(this.config.maxOutputBytes ?? 1024 * 1024),
164
+ maxSessions: Number(this.config.maxAttachSessions ?? 2),
165
+ })
166
+ this.apple = new AppleProvider({
167
+ subprocess: options.subprocess,
168
+ config: this.config,
169
+ env: this.env,
170
+ home: this.home,
171
+ platform: options.platform,
172
+ logger: this.logger,
173
+ })
174
+ this.contextsCache = undefined
175
+ this.toolsEnabled = false
176
+ this.imageArchCache = new Map()
177
+ this.statusCache = new Map()
178
+ this.valueCache = new Map()
179
+ }
180
+
181
+ /** @param {string} method @param {unknown} rawPayload @param {AbortSignal | undefined} signal @param {any} peer */
182
+ async handle(method, rawPayload, signal, peer) {
183
+ try {
184
+ if (peer?.principal?.kind !== 'owner') return fail('docker/forbidden', '只有 DSH owner 会话可以访问本机 Docker')
185
+ const payload = payloadOf(rawPayload)
186
+ const provider = this.providerOf(payload)
187
+ switch (method) {
188
+ case 'ping':
189
+ return ok({
190
+ ok: true,
191
+ plugin: 'dsh-plugin-container',
192
+ version: '0.2.0',
193
+ dockerPath: this.cli.dockerPath ?? null,
194
+ containerPath: this.apple.cli.containerPath ?? null,
195
+ })
196
+ case 'providers':
197
+ return ok(await this.providers(signal))
198
+ case 'status':
199
+ case 'list':
200
+ case 'inspect':
201
+ case 'run':
202
+ case 'action':
203
+ case 'runtimeAction':
204
+ if (provider === 'apple-container') return ok(await this.apple[method](payload, signal))
205
+ if (method === 'action') return ok(await this.action(payload, signal))
206
+ return ok(await this[method](payload, signal))
207
+ case 'attach':
208
+ return ok(await this.attachStart(provider, payload, signal))
209
+ case 'attachPoll':
210
+ return ok(this.attach.poll(payload))
211
+ case 'attachInput':
212
+ return ok(await this.attach.write(payload))
213
+ case 'attachResize':
214
+ return ok(await this.attach.resize(payload))
215
+ case 'attachClose':
216
+ return ok(await this.attach.close(payload))
217
+ case 'poll':
218
+ case 'cancel': {
219
+ const runProvider = this.providerForRun(payload)
220
+ if (runProvider === 'apple-container') return ok(this.apple[method](payload))
221
+ return ok(method === 'poll' ? this.jobs.poll(this.pollInput(payload)) : this.jobs.cancel(requireString(payload.runId, 'runId')))
222
+ }
223
+ default:
224
+ return fail('docker/unknown-method', `未知的 RPC 方法: ${String(method)}`)
225
+ }
226
+ } catch (error) {
227
+ return failureFrom(error)
228
+ }
229
+ }
230
+
231
+ /** @param {Record<string, any>} payload */
232
+ providerOf(payload) {
233
+ return payload.provider === 'apple-container' || payload.provider === 'apple' ? 'apple-container' : 'docker'
234
+ }
235
+
236
+ /** @param {Record<string, any>} payload */
237
+ providerForRun(payload) {
238
+ const runId = typeof payload.runId === 'string' ? payload.runId : ''
239
+ if (runId.startsWith('apple_')) return 'apple-container'
240
+ if (runId.startsWith('docker_')) return 'docker'
241
+ return this.providerOf(payload)
242
+ }
243
+
244
+ enableTools() {
245
+ this.toolsEnabled = true
246
+ }
247
+
248
+ /** @param {AbortSignal | undefined} signal */
249
+ async providers(signal) {
250
+ const providers = []
251
+ try {
252
+ const path = await this.cli.ensureDocker({ signal })
253
+ providers.push({ id: 'docker', label: 'Docker', available: true, runtime: 'docker', detail: path })
254
+ } catch (error) {
255
+ providers.push({ id: 'docker', label: 'Docker', available: false, runtime: 'docker', detail: messageOf(error) })
256
+ }
257
+ providers.push(await this.apple.info(signal))
258
+ const defaultProvider = providers.find((provider) => provider.id === 'docker' && provider.available)?.id ?? providers.find((provider) => provider.available)?.id ?? 'docker'
259
+ const features = ['attach', 'exec', 'run', 'image-architecture']
260
+ if (this.toolsEnabled) features.push('ai-tools')
261
+ return { providers, defaultProvider, features }
262
+ }
263
+
264
+ /** @param {Record<string, any>} payload */
265
+ pollInput(payload) {
266
+ return {
267
+ runId: requireString(payload.runId, 'runId'),
268
+ stdoutOffset: optionalInteger(payload.stdoutOffset, 'stdoutOffset', { min: 0 }),
269
+ stderrOffset: optionalInteger(payload.stderrOffset, 'stderrOffset', { min: 0 }),
270
+ }
271
+ }
272
+
273
+ /** @param {AbortSignal | undefined} signal */
274
+ async loadContexts(signal) {
275
+ const now = Date.now()
276
+ if (this.contextsCache !== undefined && now - this.contextsCache.at < CONTEXT_CACHE_MS) return this.contextsCache.value
277
+ let value = []
278
+ try {
279
+ await this.cli.ensureDocker({ signal })
280
+ const result = await this.cli.docker(['context', 'ls', '--format', 'json'], { context: null, signal, timeoutMs: 10_000 })
281
+ value = parseContexts(result.stdout).contexts
282
+ } catch (error) {
283
+ if (!(error instanceof DockerCliError)) {
284
+ this.logger?.warn?.(`docker status: context discovery failed: ${messageOf(error)}`)
285
+ }
286
+ value = []
287
+ }
288
+ this.contextsCache = { at: now, value }
289
+ return value
290
+ }
291
+
292
+ /**
293
+ * @param {string | undefined} requested
294
+ * @param {AbortSignal | undefined} signal
295
+ * @param {{strict?: boolean}} [options]
296
+ */
297
+ async resolveContext(requested, signal, options = {}) {
298
+ const contexts = await this.loadContexts(signal)
299
+ const known = new Set(contexts.map((context) => context.name))
300
+ let name
301
+ try {
302
+ name = normalizeContext(requested, { known })
303
+ } catch (error) {
304
+ if (options.strict === false) name = null
305
+ else throw error
306
+ }
307
+ const selected = name === null ? contexts.find((context) => context.current) ?? contexts[0] ?? null : contexts.find((context) => context.name === name) ?? null
308
+ if (name !== null && selected === null) throw new CommandError(`未知的 Docker context: ${name}`, 'docker/context-unknown')
309
+ return {
310
+ contexts,
311
+ current: contexts.find((context) => context.current) ?? null,
312
+ selected,
313
+ name: selected?.name ?? null,
314
+ }
315
+ }
316
+
317
+ /** @param {unknown} value @param {string} field */
318
+ cacheKey(parts) {
319
+ return parts.map((part) => (part === undefined || part === null ? '' : String(part))).join('\u0000')
320
+ }
321
+
322
+ /** @param {string} key @param {() => Promise<any>} produce @param {number} ttl */
323
+ async cached(key, produce, ttl = VALUE_CACHE_MS) {
324
+ const now = Date.now()
325
+ const hit = this.valueCache.get(key)
326
+ if (hit !== undefined && now - hit.at < ttl) return hit.value
327
+ const value = await produce()
328
+ this.valueCache.set(key, { at: now, value })
329
+ return value
330
+ }
331
+
332
+ invalidate() {
333
+ this.statusCache.clear()
334
+ this.valueCache.clear()
335
+ }
336
+
337
+ /**
338
+ * @param {Record<string, any>} payload
339
+ * @param {AbortSignal | undefined} signal
340
+ */
341
+ async status(payload, signal) {
342
+ const requested = optionalString(payload.context, 'context')
343
+ const cacheKey = this.cacheKey(['status', requested ?? ''])
344
+ const now = Date.now()
345
+ const hit = this.statusCache.get(cacheKey)
346
+ if (hit !== undefined && now - hit.at < STATUS_CACHE_MS) return hit.value
347
+
348
+ const contexts = await this.loadContexts(signal)
349
+ const known = new Set(contexts.map((context) => context.name))
350
+ let selectedName = requested ?? null
351
+ if (selectedName !== null && !known.has(selectedName)) selectedName = null
352
+ const current = contexts.find((context) => context.current) ?? null
353
+ const selected = selectedName === null ? current ?? contexts[0] ?? null : contexts.find((context) => context.name === selectedName) ?? null
354
+
355
+ let dockerPath = null
356
+ let cliError = null
357
+ try {
358
+ dockerPath = await this.cli.ensureDocker({ signal })
359
+ } catch (error) {
360
+ cliError = error
361
+ }
362
+
363
+ let version = null
364
+ let info = null
365
+ let daemonError = null
366
+ let daemonState = 'unknown'
367
+ let versionError = null
368
+ if (dockerPath !== null) {
369
+ try {
370
+ const result = await this.cli.docker(['version', '--format', '{{json .}}'], {
371
+ context: selected?.name ?? null,
372
+ signal,
373
+ timeoutMs: 12_000,
374
+ })
375
+ version = mapDockerVersion(JSON.parse(result.stdout))
376
+ if (version.server !== null) daemonState = 'running'
377
+ } catch (error) {
378
+ versionError = error
379
+ if (error instanceof DockerCommandError) daemonError = error.stderr || error.message
380
+ }
381
+ try {
382
+ const result = await this.cli.docker(['info', '--format', '{{json .}}'], {
383
+ context: selected?.name ?? null,
384
+ signal,
385
+ timeoutMs: 15_000,
386
+ })
387
+ info = mapDockerInfo(JSON.parse(result.stdout))
388
+ daemonState = 'running'
389
+ daemonError = null
390
+ } catch (error) {
391
+ if (daemonError === null) daemonError = error instanceof DockerCommandError ? error.stderr || error.message : messageOf(error)
392
+ if (daemonState !== 'running') {
393
+ daemonState = /cannot connect|is the docker daemon running|no such host|connection refused|permission denied/iu.test(daemonError)
394
+ ? 'stopped'
395
+ : 'unreachable'
396
+ }
397
+ }
398
+ }
399
+
400
+ let runtime
401
+ try {
402
+ const resolved = await this.runtime.resolve(contexts, selected?.name ?? requested ?? null, {
403
+ running: daemonState === 'running',
404
+ error: daemonError,
405
+ }, signal)
406
+ runtime = resolved.runtime
407
+ } catch (error) {
408
+ runtime = {
409
+ kind: 'unknown',
410
+ label: 'Docker',
411
+ profile: null,
412
+ installed: null,
413
+ running: daemonState === 'running',
414
+ vmRunning: daemonState === 'running',
415
+ canStart: false,
416
+ canStop: false,
417
+ canRestart: false,
418
+ endpoint: selected?.endpoint ?? '',
419
+ details: null,
420
+ error: messageOf(error),
421
+ }
422
+ }
423
+
424
+ const warnings = []
425
+ if (selected?.error) warnings.push(selected.error)
426
+ if (daemonError) warnings.push(firstLine(daemonError))
427
+ if (versionError !== null && daemonState !== 'running') warnings.push(firstLine(messageOf(versionError)))
428
+
429
+ const value = {
430
+ provider: 'docker',
431
+ fetchedAt: Date.now(),
432
+ platform: process.platform,
433
+ cli: {
434
+ installed: dockerPath !== null,
435
+ path: dockerPath,
436
+ version: version?.client?.version ?? null,
437
+ error: cliError === null ? null : messageOf(cliError),
438
+ },
439
+ daemon: {
440
+ state: daemonState,
441
+ error: daemonError === null ? null : summarizeStderr(daemonError),
442
+ },
443
+ context: {
444
+ selected: selected?.name ?? null,
445
+ current: current?.name ?? null,
446
+ endpoint: selected?.endpoint ?? '',
447
+ description: selected?.description ?? '',
448
+ contexts,
449
+ },
450
+ client: version?.client ?? info?.client ?? null,
451
+ server: info?.server ?? version?.server ?? null,
452
+ runtime,
453
+ env: {
454
+ dockerHost: typeof this.env.DOCKER_HOST === 'string' && this.env.DOCKER_HOST !== '',
455
+ dockerContext: typeof this.env.DOCKER_CONTEXT === 'string' ? this.env.DOCKER_CONTEXT : null,
456
+ },
457
+ capabilities: {
458
+ enableRuntimeControl: this.runtime.controlEnabled,
459
+ maxOutputBytes: Number(this.config.maxOutputBytes ?? 1024 * 1024),
460
+ defaultTimeoutMs: Number(this.config.defaultTimeoutMs ?? 120_000),
461
+ compose: true,
462
+ contexts: true,
463
+ canCompose: selected !== null,
464
+ },
465
+ warnings,
466
+ }
467
+ this.statusCache.set(cacheKey, { at: now, value })
468
+ return value
469
+ }
470
+
471
+ /**
472
+ * @param {Record<string, any>} payload
473
+ * @param {AbortSignal | undefined} signal
474
+ */
475
+ async list(payload, signal) {
476
+ const resource = requireString(payload.resource, 'resource')
477
+ const resolved = await this.resolveContext(optionalString(payload.context, 'context'), signal)
478
+ const context = resolved.name
479
+ const all = payload.all !== false
480
+ const key = this.cacheKey(['list', resource, context ?? '', all ? 'all' : 'running'])
481
+ return this.cached(key, async () => {
482
+ const command = {
483
+ containers: ['ps', ...(all ? ['-a'] : []), '--format', '{{json .}}'],
484
+ images: ['images', '--format', '{{json .}}'],
485
+ volumes: ['volume', 'ls', '--format', '{{json .}}'],
486
+ networks: ['network', 'ls', '--format', '{{json .}}'],
487
+ compose: ['compose', 'ls', '-a', '--format', 'json'],
488
+ disk: ['system', 'df', '--format', '{{json .}}'],
489
+ }[resource]
490
+ if (command === undefined) throw new CommandError(`不支持的资源类型: ${resource}`)
491
+ let result
492
+ try {
493
+ result = await this.cli.docker(command, { context, signal, timeoutMs: resource === 'disk' ? 30_000 : 20_000 })
494
+ } catch (error) {
495
+ if (resource !== 'compose') throw error
496
+ // Older Compose releases reject --format json; retry with the table.
497
+ result = await this.cli.docker(['compose', 'ls', '-a'], { context, signal, timeoutMs: 20_000 })
498
+ const parsed = parseComposeList(result.stdout)
499
+ return { fetchedAt: Date.now(), items: parsed.items, format: parsed.format, warnings: this.warningsOf(result.stderr) }
500
+ }
501
+ let items
502
+ let format = 'json'
503
+ if (resource === 'containers') items = parseNdjson(result.stdout).map(normalizeContainer)
504
+ else if (resource === 'images') {
505
+ items = parseNdjson(result.stdout).map(normalizeImage)
506
+ items = await this.withImageArchitectures(items, context, signal)
507
+ }
508
+ else if (resource === 'volumes') items = parseNdjson(result.stdout).map(normalizeVolume)
509
+ else if (resource === 'networks') items = parseNdjson(result.stdout).map(normalizeNetwork)
510
+ else if (resource === 'disk') items = parseSystemDf(result.stdout)
511
+ else {
512
+ const parsed = parseComposeList(result.stdout)
513
+ items = parsed.items
514
+ format = parsed.format
515
+ }
516
+ return { fetchedAt: Date.now(), items, format, warnings: this.warningsOf(result.stderr) }
517
+ })
518
+ }
519
+
520
+ /**
521
+ * `docker images` does not expose image architecture; inspect the listed IDs
522
+ * once and cache the stable platform result.
523
+ * @param {any[]} items
524
+ * @param {string | null} context
525
+ * @param {AbortSignal | undefined} signal
526
+ */
527
+ async withImageArchitectures(items, context, signal) {
528
+ if (items.length === 0) return items
529
+ const now = Date.now()
530
+ const keyFor = (id) => `${context ?? ''}\u0000${id}`
531
+ const missing = []
532
+ for (const item of items) {
533
+ const cached = this.imageArchCache.get(keyFor(item.id))
534
+ if (cached === undefined || now - cached.at > 5 * 60_000) missing.push(item.id)
535
+ }
536
+ if (missing.length > 0) {
537
+ try {
538
+ const result = await this.cli.docker(
539
+ ['image', 'inspect', '--format', '{{json .}}', ...missing.slice(0, 64)],
540
+ { context, signal, timeoutMs: 20_000, maxBytes: 4 * 1024 * 1024 },
541
+ )
542
+ const byId = new Map()
543
+ for (const line of result.stdout.split(/\r?\n/u)) {
544
+ if (line.trim() === '') continue
545
+ try {
546
+ const value = JSON.parse(line)
547
+ if (typeof value?.Id !== 'string') continue
548
+ const architecture = [value.Os, value.Architecture, value.Variant].filter(Boolean).join('/')
549
+ byId.set(value.Id, architecture)
550
+ byId.set(value.Id.replace(/^sha256:/u, ''), architecture)
551
+ } catch {
552
+ // Ignore malformed inspect output; architecture is optional metadata.
553
+ }
554
+ }
555
+ for (const id of missing) {
556
+ const full = [...byId.keys()].find((candidate) => candidate === id || candidate.startsWith(id) || id.startsWith(candidate))
557
+ const value = full === undefined ? '' : byId.get(full)
558
+ this.imageArchCache.set(keyFor(id), { at: now, value: value ?? '' })
559
+ }
560
+ } catch {
561
+ for (const id of missing) this.imageArchCache.set(keyFor(id), { at: 0, value: '' })
562
+ }
563
+ }
564
+ return items.map((item) => {
565
+ const cached = this.imageArchCache.get(keyFor(item.id))
566
+ return { ...item, architecture: cached?.value ?? '' }
567
+ })
568
+ }
569
+
570
+ /** @param {string} stderr */
571
+ warningsOf(stderr) {
572
+ const line = firstLine(stderr)
573
+ return line === '' ? [] : [line]
574
+ }
575
+
576
+ /**
577
+ * @param {Record<string, any>} payload
578
+ * @param {AbortSignal | undefined} signal
579
+ */
580
+ async inspect(payload, signal) {
581
+ const kind = requireString(payload.kind, 'kind')
582
+ if (!['container', 'image', 'volume', 'network'].includes(kind)) throw new CommandError('不支持的 inspect 类型')
583
+ const id = safeIdentifier(payload.id)
584
+ const resolved = await this.resolveContext(optionalString(payload.context, 'context'), signal)
585
+ const result = await this.cli.docker([kind, 'inspect', id], {
586
+ context: resolved.name,
587
+ signal,
588
+ timeoutMs: 20_000,
589
+ maxBytes: 4 * 1024 * 1024,
590
+ })
591
+ try {
592
+ const parsed = JSON.parse(result.stdout)
593
+ const value = Array.isArray(parsed) ? parsed[0] ?? null : parsed
594
+ return { value, raw: result.stdout, warnings: this.warningsOf(result.stderr) }
595
+ } catch (error) {
596
+ return { value: null, raw: result.stdout, parseError: messageOf(error), warnings: this.warningsOf(result.stderr) }
597
+ }
598
+ }
599
+
600
+ /**
601
+ * @param {Record<string, any>} payload
602
+ * @param {AbortSignal | undefined} signal
603
+ */
604
+ async run(payload, signal) {
605
+ const dockerPath = await this.cli.ensureDocker({ signal })
606
+ const resolved = await this.resolveContext(optionalString(payload.context, 'context'), signal)
607
+ let subargv
608
+ if (payload.argv !== undefined) {
609
+ if (payload.command !== undefined) throw new CommandError('command 和 argv 只能提供一个')
610
+ subargv = normalizeSubcommand(payload.argv, { dockerPath })
611
+ } else {
612
+ subargv = parseCommand(requireString(payload.command, 'command'), { dockerPath })
613
+ }
614
+ const context = resolved.name
615
+ const cwd = resolveCwd(payload.cwd, { fallback: this.home, statSync })
616
+ const displayCommand = describeCommand(subargv, context)
617
+ const destructive = detectDestructive(subargv)
618
+ if (destructive.dangerous && payload.confirm !== true) {
619
+ throw Object.assign(new Error(`危险操作需要确认: ${displayCommand}`), {
620
+ code: 'docker/confirmation-required',
621
+ details: { command: displayCommand, reason: destructive.reason },
622
+ })
623
+ }
624
+ const timeoutMs = optionalInteger(payload.timeoutMs, 'timeoutMs', { min: 5000, max: 30 * 60_000 }) ?? Number(this.config.defaultTimeoutMs ?? 120_000)
625
+ const argv = this.cli.argvFor(subargv, { context })
626
+ const run = this.jobs.start({
627
+ argv,
628
+ displayCommand,
629
+ context,
630
+ cwd,
631
+ title: optionalString(payload.title, 'title') ?? '',
632
+ timeoutMs,
633
+ })
634
+ this.invalidate()
635
+ return { run }
636
+ }
637
+
638
+ /**
639
+ * Start an interactive attach PTY. Docker attaches PID 1; Apple container has
640
+ * no attach verb, so it opens an exec shell in the running container.
641
+ * @param {'docker' | 'apple-container'} provider
642
+ * @param {Record<string, any>} payload
643
+ * @param {AbortSignal | undefined} signal
644
+ */
645
+ async attachStart(provider, payload, signal) {
646
+ const id = safeIdentifier(payload.id)
647
+ const name = optionalString(payload.name, 'name') ?? id
648
+ const mode = payload.mode === 'exec' ? 'exec' : 'attach'
649
+ const cols = optionalInteger(payload.cols, 'cols', { min: 20, max: 300 }) ?? 120
650
+ const rows = optionalInteger(payload.rows, 'rows', { min: 5, max: 120 }) ?? 30
651
+ if (provider === 'apple-container') {
652
+ const path = await this.apple.cli.ensureContainer({ signal })
653
+ let running = false
654
+ try {
655
+ const raw = await this.apple.cli.json(['list', '-a', '--format', 'json'], { signal, timeoutMs: 15_000 })
656
+ const found = (Array.isArray(raw) ? raw : []).find((entry) => entry?.id === id || entry?.configuration?.id === id)
657
+ running = found?.status?.state === 'running'
658
+ } catch (error) {
659
+ throw new CommandError(`无法读取 Apple container 状态: ${messageOf(error)}`, 'container/exec-failed')
660
+ }
661
+ if (!running) throw new CommandError(`容器未运行,无法 ${mode}`, 'container/not-running')
662
+ const argv = [path, 'exec', '-i', '-t', id, '/bin/sh']
663
+ return {
664
+ session: await this.attach.start({
665
+ argv,
666
+ cwd: this.home,
667
+ env: this.apple.cli.childEnv(),
668
+ provider,
669
+ containerId: id,
670
+ containerName: name,
671
+ command: `container exec -i -t ${id} /bin/sh`,
672
+ mode,
673
+ cols,
674
+ rows,
675
+ signal,
676
+ }),
677
+ }
678
+ }
679
+ await this.cli.ensureDocker({ signal })
680
+ const resolved = await this.resolveContext(optionalString(payload.context, 'context'), signal)
681
+ const context = resolved.name
682
+ const running = await this.cli.docker(['inspect', '--format', '{{.State.Running}}', id], {
683
+ context,
684
+ signal,
685
+ timeoutMs: 15_000,
686
+ })
687
+ if (running.stdout.trim() !== 'true') throw new CommandError(`容器未运行,无法 ${mode}`, 'docker/not-running')
688
+ const subargv = mode === 'exec' ? ['exec', '-i', '-t', id, '/bin/sh'] : ['attach', id]
689
+ return {
690
+ session: await this.attach.start({
691
+ argv: this.cli.argvFor(subargv, { context }),
692
+ cwd: this.home,
693
+ env: this.cli.childEnv(),
694
+ provider: 'docker',
695
+ containerId: id,
696
+ containerName: name,
697
+ command: describeCommand(subargv, context),
698
+ mode,
699
+ cols,
700
+ rows,
701
+ signal,
702
+ }),
703
+ }
704
+ }
705
+
706
+ /**
707
+ * Semantic quick action for Docker; provider-specific syntax is centralized here.
708
+ * @param {Record<string, any>} payload
709
+ * @param {AbortSignal | undefined} signal
710
+ */
711
+ async action(payload, signal) {
712
+ const kind = requireString(payload.kind, 'kind')
713
+ const action = requireString(payload.action, 'action')
714
+ if (kind === 'container' && action === 'run') {
715
+ const image = safeImageRef(payload.image)
716
+ const name = optionalString(payload.name, 'name')
717
+ if (name !== undefined) safeIdentifier(name)
718
+ const args = parseArguments(optionalString(payload.command, 'command') ?? '')
719
+ const ports = stringList(payload.ports, 'ports').map(safePort)
720
+ const env = stringList(payload.env, 'env').map(safeEnv)
721
+ const argv = [
722
+ 'run',
723
+ '-d',
724
+ '-i',
725
+ '-t',
726
+ ...(name === undefined ? [] : ['--name', name]),
727
+ ...ports.flatMap((port) => ['-p', port]),
728
+ ...env.flatMap((entry) => ['-e', entry]),
729
+ image,
730
+ ...args,
731
+ ]
732
+ return this.run({
733
+ argv,
734
+ context: payload.context,
735
+ cwd: payload.cwd,
736
+ title: payload.title ?? `启动容器 ${image}`,
737
+ }, signal)
738
+ }
739
+ const id = safeIdentifier(payload.id ?? payload.reference)
740
+ let argv
741
+ if (kind === 'container') {
742
+ if (action === 'start') argv = ['start', id]
743
+ else if (action === 'stop') argv = ['stop', id]
744
+ else if (action === 'restart') argv = ['restart', id]
745
+ else if (action === 'delete') argv = ['rm', '-f', id]
746
+ else if (action === 'logs') argv = ['logs', '--tail', payload.follow === true ? '100' : '500', ...(payload.follow === true ? ['-f'] : []), id]
747
+ } else if (kind === 'image') {
748
+ if (action === 'pull') argv = ['pull', id]
749
+ else if (action === 'delete') argv = ['rmi', id]
750
+ } else if (kind === 'volume' && action === 'delete') {
751
+ argv = ['volume', 'rm', id]
752
+ } else if (kind === 'network' && action === 'delete') {
753
+ argv = ['network', 'rm', id]
754
+ }
755
+ if (argv === undefined) throw new CommandError(`不支持的操作: ${kind}/${action}`)
756
+ return this.run({
757
+ argv,
758
+ context: payload.context,
759
+ cwd: payload.cwd,
760
+ confirm: payload.confirm,
761
+ timeoutMs: payload.timeoutMs,
762
+ title: payload.title ?? `${kind} ${action} ${id}`,
763
+ }, signal)
764
+ }
765
+
766
+ /**
767
+ * @param {Record<string, any>} payload
768
+ * @param {AbortSignal | undefined} signal
769
+ */
770
+ async runtimeAction(payload, signal) {
771
+ const action = requireString(payload.action, 'action')
772
+ if (!['start', 'stop', 'restart'].includes(action)) throw new CommandError('不支持的运行时操作')
773
+ if ((action === 'stop' || action === 'restart') && payload.confirm !== true) {
774
+ throw Object.assign(new Error(`停止/重启 Docker 运行时需要确认`), {
775
+ code: 'docker/confirmation-required',
776
+ details: { command: action === 'stop' ? '停止运行时' : '重启运行时', reason: 'runtime-stop' },
777
+ })
778
+ }
779
+ const status = await this.status({ context: payload.context }, signal)
780
+ const spec = this.runtime.actionSpec(action, status.runtime)
781
+ const run = this.jobs.start({
782
+ argv: spec.argv,
783
+ displayCommand: spec.displayCommand,
784
+ context: null,
785
+ cwd: this.home,
786
+ title: spec.title,
787
+ timeoutMs: spec.timeoutMs,
788
+ })
789
+ this.invalidate()
790
+ return { run, dispatched: true, message: spec.title }
791
+ }
792
+
793
+ /**
794
+ * Read container logs for a tool (not exposed as a UI RPC).
795
+ * @param {Record<string, any>} payload
796
+ * @param {AbortSignal | undefined} signal
797
+ */
798
+ async logs(payload, signal) {
799
+ const provider = payload.provider === 'apple-container' ? 'apple-container' : 'docker'
800
+ const id = safeIdentifier(payload.id)
801
+ const tail = optionalInteger(payload.tail ?? 200, 'tail', { min: 1, max: 500 })
802
+ const maxBytes = 256 * 1024
803
+ if (provider === 'apple-container') {
804
+ const path = await this.apple.cli.ensureContainer({ signal })
805
+ const result = await this.apple.cli.exec(['logs', '-n', String(tail), id], { signal, cwd: this.home, timeoutMs: 20_000, maxBytes })
806
+ const text = `${result.stdout}${result.stderr}`
807
+ if (result.timedOut) throw new CommandError('读取 Apple container 日志超时', 'container/timeout')
808
+ if (result.exitCode !== 0) {
809
+ throw Object.assign(new CommandError(text.trim() || `container logs 退出码 ${String(result.exitCode)}`, 'container/exec-failed'), { stderr: result.stderr })
810
+ }
811
+ return { provider, id, tail, text }
812
+ }
813
+ await this.cli.ensureDocker({ signal })
814
+ const resolved = await this.resolveContext(optionalString(payload.context, 'context'), signal)
815
+ const result = await this.cli.docker(['logs', '--tail', String(tail), id], {
816
+ context: resolved.name,
817
+ signal,
818
+ timeoutMs: 20_000,
819
+ maxBytes,
820
+ })
821
+ return { provider, id, tail, text: `${result.stdout}${result.stderr}` }
822
+ }
823
+
824
+ dispose() {
825
+ this.jobs.dispose()
826
+ this.attach.dispose().catch(() => {})
827
+ this.apple.dispose()
828
+ this.statusCache.clear()
829
+ this.valueCache.clear()
830
+ this.imageArchCache.clear()
831
+ }
832
+ }
833
+
834
+ export function createDockerService(options) {
835
+ return new DockerService(options)
836
+ }