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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +341 -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 +3499 -0
  17. package/package.json +62 -4
  18. package/src/client/api.js +94 -0
  19. package/src/client/attach.jsx +133 -0
  20. package/src/client/components.jsx +301 -0
  21. package/src/client/hooks.js +161 -0
  22. package/src/client/i18n.js +496 -0
  23. package/src/client/icons.jsx +219 -0
  24. package/src/client/index.jsx +76 -0
  25. package/src/client/page.jsx +755 -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 +363 -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 +143 -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 +243 -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 +832 -0
  46. package/src/host/tools.js +198 -0
  47. package/src/host/util.js +97 -0
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Apple `container` provider.
3
+ *
4
+ * Read-only discovery uses `container system status/version` and the JSON list
5
+ * commands; mutations are normal jobs through the same JobManager used by the
6
+ * Docker provider. There is no Docker context, image store, or Compose here.
7
+ */
8
+ import { cpus, homedir, totalmem } from 'node:os'
9
+ import { statSync } from 'node:fs'
10
+
11
+ import {
12
+ CommandError,
13
+ describeCommand,
14
+ detectDestructive,
15
+ normalizeSubcommand,
16
+ parseArguments,
17
+ parseCommand,
18
+ resolveCwd,
19
+ } from './commands.js'
20
+ import { AppleCli } from './apple-cli.js'
21
+ import { JobManager } from './jobs.js'
22
+ import {
23
+ firstLine,
24
+ normalizeAppleContainer,
25
+ normalizeAppleImage,
26
+ normalizeAppleNetwork,
27
+ normalizeAppleVolume,
28
+ parseAppleDf,
29
+ parseAppleSystemStatus,
30
+ parseAppleSystemVersion,
31
+ summarizeStderr,
32
+ } from './parsers.js'
33
+ import { messageOf, optionalInteger, requireString } from './util.js'
34
+
35
+ const VALUE_CACHE_MS = 1500
36
+
37
+ /** @param {string} value */
38
+ function safeId(value) {
39
+ const id = requireString(value, 'id', { max: 256 })
40
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.:/@-]{0,255}$/u.test(id)) throw new CommandError('资源 ID 不合法')
41
+ return id
42
+ }
43
+
44
+ /** @param {string} value */
45
+ function safeImageRef(value) {
46
+ const reference = requireString(value, 'reference', { max: 512 })
47
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.:/@-]*$/u.test(reference)) throw new CommandError('镜像名不合法')
48
+ return reference
49
+ }
50
+
51
+ /** @param {unknown} value @param {string} field */
52
+ function stringList(value, field) {
53
+ if (value === undefined || value === null) return []
54
+ if (!Array.isArray(value)) throw new CommandError(`${field} 必须是字符串数组`)
55
+ if (value.length > 64) throw new CommandError(`${field} 数量过多`)
56
+ return value.map((entry, index) => requireString(entry, `${field}[${String(index)}]`, { max: 512 }))
57
+ }
58
+
59
+ /** @param {string} value */
60
+ function safePort(value) {
61
+ 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)) {
62
+ throw new CommandError(`端口格式不合法: ${value}(示例 8080:80)`)
63
+ }
64
+ return value
65
+ }
66
+
67
+ /** @param {string} value */
68
+ function safeEnv(value) {
69
+ if (!/^[A-Za-z_][A-Za-z0-9_]*=[^\r\n]*$/u.test(value)) throw new CommandError(`环境变量格式不合法: ${value}(示例 KEY=VALUE)`)
70
+ return value
71
+ }
72
+
73
+ export class AppleProvider {
74
+ /**
75
+ * @param {{
76
+ * subprocess: any,
77
+ * config?: Record<string, any>,
78
+ * env?: NodeJS.ProcessEnv,
79
+ * home?: string,
80
+ * platform?: NodeJS.Platform,
81
+ * logger?: any,
82
+ * }} options
83
+ */
84
+ constructor(options) {
85
+ this.config = options.config ?? {}
86
+ this.home = options.home ?? homedir()
87
+ this.env = options.env ?? process.env
88
+ this.platform = options.platform ?? process.platform
89
+ this.logger = options.logger
90
+ this.controlEnabled = this.platform === 'darwin' && this.config.enableRuntimeControl !== false
91
+ this.cli = new AppleCli({
92
+ subprocess: options.subprocess,
93
+ config: this.config,
94
+ env: this.env,
95
+ home: this.home,
96
+ platform: this.platform,
97
+ logger: this.logger,
98
+ })
99
+ this.jobs = new JobManager({
100
+ subprocess: options.subprocess,
101
+ cli: this.cli,
102
+ maxOutputBytes: Number(this.config.maxOutputBytes ?? 1024 * 1024),
103
+ maxRuns: Number(this.config.maxConcurrentRuns ?? 4),
104
+ idPrefix: 'apple',
105
+ })
106
+ this.cache = new Map()
107
+ }
108
+
109
+ /** @param {AbortSignal | undefined} signal */
110
+ async info(signal) {
111
+ let path = null
112
+ try {
113
+ path = await this.cli.ensureContainer({ signal, force: true })
114
+ } catch (error) {
115
+ return {
116
+ id: 'apple-container',
117
+ label: 'Apple container',
118
+ available: false,
119
+ running: false,
120
+ version: null,
121
+ detail: messageOf(error),
122
+ }
123
+ }
124
+ const status = parseAppleSystemStatus(await this.cli.systemStatus(signal))
125
+ const probe = status.status === 'running' ? undefined : await this.cli.probe(signal)
126
+ const running = status.status === 'running' || probe !== undefined
127
+ const versions = parseAppleSystemVersion(await this.cli.systemVersion(signal))
128
+ return {
129
+ id: 'apple-container',
130
+ label: 'Apple container',
131
+ available: true,
132
+ running,
133
+ version: versions.containerVersion || null,
134
+ detail: path,
135
+ }
136
+ }
137
+
138
+ invalidate() {
139
+ this.cache.clear()
140
+ }
141
+
142
+ /** @param {string} key @param {() => Promise<any>} produce */
143
+ async cached(key, produce) {
144
+ const now = Date.now()
145
+ const hit = this.cache.get(key)
146
+ if (hit !== undefined && now - hit.at < VALUE_CACHE_MS) return hit.value
147
+ const value = await produce()
148
+ this.cache.set(key, { at: now, value })
149
+ return value
150
+ }
151
+
152
+ /**
153
+ * @param {Record<string, any>} payload
154
+ * @param {AbortSignal | undefined} signal
155
+ */
156
+ async status(payload, signal) {
157
+ let path = null
158
+ let cliError = null
159
+ try {
160
+ path = await this.cli.ensureContainer({ signal })
161
+ } catch (error) {
162
+ cliError = error
163
+ }
164
+ const system = path === null
165
+ ? { status: 'unavailable', error: messageOf(cliError), raw: null }
166
+ : parseAppleSystemStatus(await this.cli.systemStatus(signal))
167
+ if (path !== null && system.status !== 'running') {
168
+ const probe = await this.cli.probe(signal)
169
+ if (probe !== undefined) {
170
+ system.status = 'running'
171
+ system.error = null
172
+ }
173
+ }
174
+ const versions = path === null ? { containerVersion: '', apiServerVersion: '', components: [] } : parseAppleSystemVersion(await this.cli.systemVersion(signal))
175
+ let df = null
176
+ let dfError = null
177
+ if (path !== null && system.status === 'running') {
178
+ try {
179
+ df = await this.cli.df(signal)
180
+ } catch (error) {
181
+ dfError = messageOf(error)
182
+ }
183
+ }
184
+ const running = system.status === 'running'
185
+ const daemonState = running ? 'running' : system.status === 'unavailable' || system.status === 'error' ? 'unreachable' : 'stopped'
186
+ const server = {
187
+ name: 'container-apiserver',
188
+ version: versions.apiServerVersion || system.apiServerVersion || '',
189
+ ncpu: cpus().length,
190
+ memTotal: totalmem(),
191
+ containers: Number(df?.containers?.total) || 0,
192
+ containersRunning: Number(df?.containers?.active) || 0,
193
+ containersStopped: Math.max(0, (Number(df?.containers?.total) || 0) - (Number(df?.containers?.active) || 0)),
194
+ images: Number(df?.images?.total) || 0,
195
+ operatingSystem: 'Apple container',
196
+ architecture: this.platform === 'darwin' ? 'arm64' : this.platform,
197
+ }
198
+ const warnings = []
199
+ if (typeof system.error === 'string' && system.error !== '') warnings.push(firstLine(system.error))
200
+ if (dfError) warnings.push(firstLine(dfError))
201
+ const value = {
202
+ provider: 'apple-container',
203
+ fetchedAt: Date.now(),
204
+ platform: this.platform,
205
+ cli: {
206
+ installed: path !== null,
207
+ path,
208
+ version: versions.containerVersion || null,
209
+ error: cliError === null ? null : messageOf(cliError),
210
+ },
211
+ daemon: {
212
+ state: daemonState,
213
+ error: typeof system.error === 'string' && system.error !== '' ? summarizeStderr(system.error) : null,
214
+ },
215
+ context: {
216
+ selected: null,
217
+ current: null,
218
+ endpoint: '',
219
+ description: 'Apple container',
220
+ contexts: [],
221
+ },
222
+ client: {
223
+ version: versions.containerVersion || null,
224
+ context: null,
225
+ os: 'darwin',
226
+ arch: 'arm64',
227
+ },
228
+ server,
229
+ runtime: {
230
+ kind: 'apple-container',
231
+ label: 'Apple container',
232
+ profile: null,
233
+ installed: path !== null,
234
+ running,
235
+ vmRunning: running,
236
+ canStart: this.controlEnabled && path !== null && !running,
237
+ canStop: this.controlEnabled && path !== null && running,
238
+ canRestart: false,
239
+ endpoint: '',
240
+ details: {
241
+ system: system.raw ?? null,
242
+ status: system.status,
243
+ components: versions.components,
244
+ disk: df,
245
+ },
246
+ error: system.error ?? dfError ?? (cliError === null ? null : messageOf(cliError)),
247
+ },
248
+ env: {
249
+ dockerHost: false,
250
+ dockerContext: null,
251
+ },
252
+ capabilities: {
253
+ enableRuntimeControl: this.controlEnabled,
254
+ maxOutputBytes: Number(this.config.maxOutputBytes ?? 1024 * 1024),
255
+ defaultTimeoutMs: Number(this.config.defaultTimeoutMs ?? 120_000),
256
+ compose: false,
257
+ contexts: false,
258
+ },
259
+ warnings,
260
+ }
261
+ return value
262
+ }
263
+
264
+ /**
265
+ * @param {Record<string, any>} payload
266
+ * @param {AbortSignal | undefined} signal
267
+ */
268
+ async list(payload, signal) {
269
+ const resource = requireString(payload.resource, 'resource')
270
+ const all = payload.all !== false
271
+ const key = `${resource}:${all ? 'all' : 'running'}`
272
+ return this.cached(key, async () => {
273
+ let items
274
+ let format = 'json'
275
+ if (resource === 'containers') {
276
+ const raw = await this.cli.json(['list', ...(all ? ['-a'] : []), '--format', 'json'], { signal })
277
+ items = (Array.isArray(raw) ? raw : []).map(normalizeAppleContainer)
278
+ } else if (resource === 'images') {
279
+ const raw = await this.cli.json(['image', 'list', '--format', 'json'], { signal })
280
+ items = (Array.isArray(raw) ? raw : []).map(normalizeAppleImage)
281
+ } else if (resource === 'volumes') {
282
+ const raw = await this.cli.json(['volume', 'list', '--format', 'json'], { signal })
283
+ items = (Array.isArray(raw) ? raw : []).map(normalizeAppleVolume)
284
+ } else if (resource === 'networks') {
285
+ const raw = await this.cli.json(['network', 'list', '--format', 'json'], { signal })
286
+ items = (Array.isArray(raw) ? raw : []).map(normalizeAppleNetwork)
287
+ } else if (resource === 'disk') {
288
+ const raw = await this.cli.json(['system', 'df', '--format', 'json'], { signal })
289
+ items = parseAppleDf(raw)
290
+ } else if (resource === 'compose') {
291
+ throw new CommandError('Apple container 不支持 Compose;请切换到 Docker provider', 'container/unsupported')
292
+ } else {
293
+ throw new CommandError(`不支持的资源类型: ${resource}`)
294
+ }
295
+ return { fetchedAt: Date.now(), items, format, warnings: [] }
296
+ })
297
+ }
298
+
299
+ /**
300
+ * @param {Record<string, any>} payload
301
+ * @param {AbortSignal | undefined} signal
302
+ */
303
+ async inspect(payload, signal) {
304
+ const kind = requireString(payload.kind, 'kind')
305
+ const command = {
306
+ container: ['inspect', safeId(payload.id)],
307
+ image: ['image', 'inspect', safeId(payload.id)],
308
+ volume: ['volume', 'inspect', safeId(payload.id)],
309
+ network: ['network', 'inspect', safeId(payload.id)],
310
+ }[kind]
311
+ if (command === undefined) throw new CommandError('不支持的 inspect 类型')
312
+ const raw = await this.cli.json(command, { signal, timeoutMs: 20_000, maxBytes: 4 * 1024 * 1024 })
313
+ const value = Array.isArray(raw) ? raw[0] ?? null : raw
314
+ return { value, raw: JSON.stringify(value, null, 2), warnings: [] }
315
+ }
316
+
317
+ /**
318
+ * @param {Record<string, any>} payload
319
+ * @param {AbortSignal | undefined} signal
320
+ */
321
+ async run(payload, signal) {
322
+ const path = await this.cli.ensureContainer({ signal })
323
+ let subargv
324
+ if (payload.argv !== undefined) {
325
+ if (payload.command !== undefined) throw new CommandError('command 和 argv 只能提供一个')
326
+ subargv = normalizeSubcommand(payload.argv, { dockerPath: path, commandName: 'container' })
327
+ } else {
328
+ subargv = parseCommand(requireString(payload.command, 'command'), { dockerPath: path, commandName: 'container' })
329
+ }
330
+ const cwd = resolveCwd(payload.cwd, { fallback: this.home, statSync })
331
+ const displayCommand = describeCommand(subargv, null, 'container')
332
+ const destructive = detectDestructive(subargv)
333
+ if (destructive.dangerous && payload.confirm !== true) {
334
+ throw Object.assign(new Error(`危险操作需要确认: ${displayCommand}`), {
335
+ code: 'docker/confirmation-required',
336
+ details: { command: displayCommand, reason: destructive.reason },
337
+ })
338
+ }
339
+ const timeoutMs = optionalInteger(payload.timeoutMs, 'timeoutMs', { min: 5000, max: 30 * 60_000 }) ?? Number(this.config.defaultTimeoutMs ?? 120_000)
340
+ const run = this.jobs.start({
341
+ argv: [path, ...subargv],
342
+ displayCommand,
343
+ context: null,
344
+ cwd,
345
+ title: typeof payload.title === 'string' ? payload.title : '',
346
+ timeoutMs,
347
+ })
348
+ this.invalidate()
349
+ return { run }
350
+ }
351
+
352
+ /**
353
+ * Semantic quick action mapped to Apple CLI syntax.
354
+ * @param {Record<string, any>} payload
355
+ * @param {AbortSignal | undefined} signal
356
+ */
357
+ async action(payload, signal) {
358
+ const kind = requireString(payload.kind, 'kind')
359
+ const action = requireString(payload.action, 'action')
360
+ if (kind === 'container' && action === 'run') {
361
+ const image = safeImageRef(payload.image)
362
+ const name = payload.name === undefined || payload.name === null ? undefined : safeId(payload.name)
363
+ const args = parseArguments(payload.command === undefined || payload.command === null ? '' : requireString(payload.command, 'command'))
364
+ const ports = stringList(payload.ports, 'ports').map(safePort)
365
+ const env = stringList(payload.env, 'env').map(safeEnv)
366
+ const argv = [
367
+ 'run',
368
+ '-d',
369
+ ...(name === undefined ? [] : ['--name', name]),
370
+ ...ports.flatMap((port) => ['-p', port]),
371
+ ...env.flatMap((entry) => ['-e', entry]),
372
+ image,
373
+ ...args,
374
+ ]
375
+ return this.run({
376
+ argv,
377
+ cwd: payload.cwd,
378
+ title: payload.title ?? `container run ${image}`,
379
+ }, signal)
380
+ }
381
+ if (kind === 'container') {
382
+ const id = safeId(payload.id)
383
+ if (action === 'restart') throw new CommandError('Apple container 不支持一键重启;请先停止再启动', 'container/unsupported')
384
+ const argv = action === 'start'
385
+ ? ['start', id]
386
+ : action === 'stop'
387
+ ? ['stop', id]
388
+ : action === 'delete'
389
+ ? ['delete', id]
390
+ : action === 'logs'
391
+ ? ['logs', ...(payload.follow === true ? ['-f', '-n', '100'] : ['-n', '500']), id]
392
+ : undefined
393
+ if (argv === undefined) throw new CommandError(`不支持的操作: ${action}`)
394
+ return this.run({ argv, confirm: payload.confirm, cwd: payload.cwd, title: payload.title ?? `container ${action} ${id}` }, signal)
395
+ }
396
+ if (kind === 'image') {
397
+ if (action === 'pull') {
398
+ return this.run({ argv: ['image', 'pull', safeImageRef(payload.id ?? payload.reference)], cwd: payload.cwd, title: payload.title ?? 'container image pull' }, signal)
399
+ }
400
+ if (action === 'delete') {
401
+ return this.run({ argv: ['image', 'delete', safeId(payload.id)], confirm: payload.confirm, cwd: payload.cwd, title: payload.title ?? 'container image delete' }, signal)
402
+ }
403
+ throw new CommandError(`不支持的镜像操作: ${action}`)
404
+ }
405
+ if (kind === 'volume') {
406
+ if (action !== 'delete') throw new CommandError(`不支持的卷操作: ${action}`)
407
+ return this.run({ argv: ['volume', 'delete', safeId(payload.id)], confirm: payload.confirm, cwd: payload.cwd, title: payload.title ?? 'container volume delete' }, signal)
408
+ }
409
+ if (kind === 'network') {
410
+ if (action !== 'delete') throw new CommandError(`不支持的网络操作: ${action}`)
411
+ return this.run({ argv: ['network', 'delete', safeId(payload.id)], confirm: payload.confirm, cwd: payload.cwd, title: payload.title ?? 'container network delete' }, signal)
412
+ }
413
+ throw new CommandError(`不支持的资源类型: ${kind}`)
414
+ }
415
+
416
+ /**
417
+ * @param {Record<string, any>} payload
418
+ * @param {AbortSignal | undefined} signal
419
+ */
420
+ async runtimeAction(payload, signal) {
421
+ const action = requireString(payload.action, 'action')
422
+ if (action !== 'start' && action !== 'stop') throw new CommandError('Apple container 只支持 system start/stop', 'container/unsupported')
423
+ if (action === 'stop' && payload.confirm !== true) {
424
+ throw Object.assign(new Error('停止 Apple container 系统服务会中断所有容器'), {
425
+ code: 'docker/confirmation-required',
426
+ details: { command: 'container system stop', reason: 'runtime-stop' },
427
+ })
428
+ }
429
+ const path = await this.cli.ensureContainer({ signal })
430
+ const argv = action === 'start'
431
+ ? [path, 'system', 'start', '--disable-kernel-install', '--timeout', '120']
432
+ : [path, 'system', 'stop']
433
+ const displayCommand = action === 'start'
434
+ ? 'container system start --disable-kernel-install --timeout 120'
435
+ : 'container system stop'
436
+ const run = this.jobs.start({
437
+ argv,
438
+ displayCommand,
439
+ context: null,
440
+ cwd: this.home,
441
+ title: action === 'start' ? 'container system start' : 'container system stop',
442
+ timeoutMs: 10 * 60_000,
443
+ })
444
+ this.invalidate()
445
+ return { run, dispatched: true, message: displayCommand }
446
+ }
447
+
448
+ /** @param {Record<string, any>} payload */
449
+ poll(payload) {
450
+ const runId = requireString(payload.runId, 'runId')
451
+ return this.jobs.poll({
452
+ runId,
453
+ stdoutOffset: optionalInteger(payload.stdoutOffset, 'stdoutOffset', { min: 0 }),
454
+ stderrOffset: optionalInteger(payload.stderrOffset, 'stderrOffset', { min: 0 }),
455
+ })
456
+ }
457
+
458
+ /** @param {Record<string, any>} payload */
459
+ cancel(payload) {
460
+ return this.jobs.cancel(requireString(payload.runId, 'runId'))
461
+ }
462
+
463
+ dispose() {
464
+ this.jobs.dispose()
465
+ this.cache.clear()
466
+ }
467
+ }
468
+
469
+ export function createAppleProvider(options) {
470
+ return new AppleProvider(options)
471
+ }