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,246 @@
1
+ /**
2
+ * Interactive PTY sessions for `docker attach` and Apple `container exec`.
3
+ *
4
+ * The DSH subprocess service exposes spawnTerminal, whose handle owns a PTY,
5
+ * raw output stream, write(), resize(), and terminate(). This manager keeps the
6
+ * PTY process out of the one-shot JobManager: attach sessions are long-lived,
7
+ * browser-driven, and have their own bounded output buffer.
8
+ */
9
+ import { randomUUID } from 'node:crypto'
10
+
11
+ import { codeOf, messageOf } from './util.js'
12
+
13
+ const MAX_SESSIONS = 2
14
+ const MAX_INPUT_BYTES = 8192
15
+ const MAX_COLS = 300
16
+ const MIN_COLS = 20
17
+ const MAX_ROWS = 120
18
+ const MIN_ROWS = 5
19
+
20
+ /** @param {unknown} value @param {number} min @param {number} max @param {number} fallback */
21
+ function dimension(value, min, max, fallback) {
22
+ const number = Number(value)
23
+ if (!Number.isFinite(number)) return fallback
24
+ return Math.min(Math.max(Math.trunc(number), min), max)
25
+ }
26
+
27
+ /** @param {unknown} value @param {string} message @param {string} code */
28
+ function attachError(message, code) {
29
+ return Object.assign(new Error(message), { code })
30
+ }
31
+
32
+ export class AttachManager {
33
+ /**
34
+ * @param {{
35
+ * subprocess: any,
36
+ * maxBytes?: number,
37
+ * maxSessions?: number,
38
+ * }} options
39
+ */
40
+ constructor(options) {
41
+ this.subprocess = options.subprocess
42
+ this.maxBytes = Math.min(Math.max(Number(options.maxBytes ?? 1_500_000), 64 * 1024), 8 * 1024 * 1024)
43
+ this.maxSessions = Math.min(Math.max(Number(options.maxSessions ?? MAX_SESSIONS), 1), 8)
44
+ this.sessions = new Map()
45
+ }
46
+
47
+ /**
48
+ * @param {{
49
+ * argv: string[],
50
+ * cwd: string,
51
+ * env?: NodeJS.ProcessEnv,
52
+ * provider: string,
53
+ * containerId: string,
54
+ * containerName?: string,
55
+ * command: string,
56
+ * mode?: 'attach' | 'exec',
57
+ * cols?: number,
58
+ * rows?: number,
59
+ * signal?: AbortSignal,
60
+ * }} input
61
+ */
62
+ async start(input) {
63
+ if (typeof this.subprocess.spawnTerminal !== 'function') {
64
+ throw attachError('当前 DSH 版本不支持 PTY 终端,无法 attach', 'docker/attach-unsupported')
65
+ }
66
+ if (this.sessions.size >= this.maxSessions) {
67
+ throw attachError(`最多同时打开 ${String(this.maxSessions)} 个 attach 终端,请先关闭一个`, 'docker/attach-limit')
68
+ }
69
+ const cols = dimension(input.cols, MIN_COLS, MAX_COLS, 120)
70
+ const rows = dimension(input.rows, MIN_ROWS, MAX_ROWS, 30)
71
+ let handle
72
+ try {
73
+ handle = await this.subprocess.spawnTerminal({
74
+ argv: input.argv,
75
+ cwd: input.cwd,
76
+ cols,
77
+ rows,
78
+ terminalType: 'xterm-256color',
79
+ env: input.env ?? {},
80
+ graceMs: 2000,
81
+ signal: input.signal,
82
+ })
83
+ } catch (error) {
84
+ throw attachError(`无法启动 attach 终端: ${messageOf(error)}`, 'docker/attach-failed')
85
+ }
86
+ if (handle === null || typeof handle !== 'object' || handle.output === undefined || typeof handle.write !== 'function') {
87
+ try { await handle?.terminate?.() } catch { /* best effort */ }
88
+ throw attachError('attach 终端句柄不可用', 'docker/attach-failed')
89
+ }
90
+
91
+ const id = `attach_${randomUUID()}`
92
+ /** @type {any} */
93
+ const session = {
94
+ id,
95
+ provider: input.provider,
96
+ containerId: input.containerId,
97
+ containerName: input.containerName ?? input.containerId,
98
+ command: input.command,
99
+ mode: input.mode === 'exec' ? 'exec' : 'attach',
100
+ cols,
101
+ rows,
102
+ status: 'running',
103
+ exitCode: null,
104
+ startedAt: Date.now(),
105
+ endedAt: null,
106
+ error: null,
107
+ output: '',
108
+ /** Absolute offset of output[0] within the session's full output. */
109
+ baseOffset: 0,
110
+ handle,
111
+ closed: false,
112
+ }
113
+ this.sessions.set(id, session)
114
+ handle.output.on('data', (chunk) => this.append(session, String(chunk)))
115
+ handle.done.then(
116
+ (outcome) => {
117
+ if (session.closed) return
118
+ session.status = 'exited'
119
+ session.exitCode = outcome?.exitCode ?? null
120
+ session.endedAt = Date.now()
121
+ },
122
+ (error) => {
123
+ if (session.closed) return
124
+ session.status = 'failed'
125
+ session.endedAt = Date.now()
126
+ session.error = messageOf(error)
127
+ },
128
+ )
129
+ return this.info(session)
130
+ }
131
+
132
+ /** @param {any} session @param {string} text */
133
+ append(session, text) {
134
+ if (text === '') return
135
+ session.output += text
136
+ if (session.output.length > this.maxBytes) {
137
+ const remove = session.output.length - this.maxBytes
138
+ session.output = session.output.slice(remove)
139
+ session.baseOffset += remove
140
+ }
141
+ }
142
+
143
+ /** @param {any} session */
144
+ info(session) {
145
+ return {
146
+ id: session.id,
147
+ provider: session.provider,
148
+ containerId: session.containerId,
149
+ containerName: session.containerName,
150
+ command: session.command,
151
+ mode: session.mode,
152
+ cols: session.cols,
153
+ rows: session.rows,
154
+ status: session.status,
155
+ exitCode: session.exitCode,
156
+ startedAt: session.startedAt,
157
+ endedAt: session.endedAt,
158
+ error: session.error,
159
+ }
160
+ }
161
+
162
+ /** @param {string} id */
163
+ require(id) {
164
+ if (typeof id !== 'string' || id === '') throw attachError('缺少 attachId', 'docker/attach-unknown')
165
+ const session = this.sessions.get(id)
166
+ if (session === undefined) throw attachError('attach 会话不存在或已关闭', 'docker/attach-unknown')
167
+ return session
168
+ }
169
+
170
+ /** @param {Record<string, any>} input */
171
+ poll(input) {
172
+ const session = this.require(input.attachId)
173
+ const requested = Number(input.offset)
174
+ const offset = Number.isFinite(requested) && requested >= 0 ? Math.trunc(requested) : 0
175
+ const lossy = offset < session.baseOffset
176
+ const start = Math.max(offset, session.baseOffset) - session.baseOffset
177
+ return {
178
+ session: this.info(session),
179
+ output: {
180
+ text: session.output.slice(start),
181
+ nextOffset: session.baseOffset + session.output.length,
182
+ lossy,
183
+ },
184
+ }
185
+ }
186
+
187
+ /** @param {Record<string, any>} input */
188
+ async write(input) {
189
+ const session = this.require(input.attachId)
190
+ if (session.status !== 'running') throw attachError('attach 终端已结束', 'docker/attach-closed')
191
+ const data = input.data
192
+ if (typeof data !== 'string' || data === '') throw attachError('缺少输入内容', 'docker/attach-input')
193
+ if (Buffer.byteLength(data, 'utf8') > MAX_INPUT_BYTES) throw attachError('单次输入过长', 'docker/attach-input')
194
+ try {
195
+ await session.handle.write(data)
196
+ } catch (error) {
197
+ throw attachError(`写入 attach 终端失败: ${messageOf(error)}`, 'docker/attach-input')
198
+ }
199
+ return this.info(session)
200
+ }
201
+
202
+ /** @param {Record<string, any>} input */
203
+ async resize(input) {
204
+ const session = this.require(input.attachId)
205
+ if (session.status !== 'running') return this.info(session)
206
+ const cols = dimension(input.cols, MIN_COLS, MAX_COLS, session.cols)
207
+ const rows = dimension(input.rows, MIN_ROWS, MAX_ROWS, session.rows)
208
+ try {
209
+ await session.handle.resize(cols, rows)
210
+ session.cols = cols
211
+ session.rows = rows
212
+ } catch (error) {
213
+ throw attachError(`调整 attach 终端大小失败: ${messageOf(error)}`, 'docker/attach-resize')
214
+ }
215
+ return this.info(session)
216
+ }
217
+
218
+ /** @param {Record<string, any>} input */
219
+ async close(input) {
220
+ const session = this.require(input.attachId)
221
+ session.closed = true
222
+ session.status = 'closed'
223
+ session.endedAt = Date.now()
224
+ try {
225
+ await session.handle.terminate()
226
+ } catch (error) {
227
+ if (codeOf(error) !== 'ESRCH') session.error = messageOf(error)
228
+ } finally {
229
+ this.sessions.delete(session.id)
230
+ }
231
+ return this.info(session)
232
+ }
233
+
234
+ async dispose() {
235
+ const sessions = [...this.sessions.values()]
236
+ this.sessions.clear()
237
+ await Promise.allSettled(sessions.map(async (session) => {
238
+ session.closed = true
239
+ await session.handle.terminate()
240
+ }))
241
+ }
242
+ }
243
+
244
+ export function createAttachManager(options) {
245
+ return new AttachManager(options)
246
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Docker command parsing and safety policy.
3
+ *
4
+ * Commands are never handed to a shell. The tokenizer only splits argv, and
5
+ * shell operators are rejected so a pasted `docker ps | grep x` fails loudly
6
+ * instead of silently becoming arguments to Docker.
7
+ */
8
+ import { basename, isAbsolute } from 'node:path'
9
+
10
+ import {
11
+ MAX_ARG_BYTES,
12
+ MAX_ARGV,
13
+ MAX_COMMAND_BYTES,
14
+ displayCommand,
15
+ messageOf,
16
+ requireString,
17
+ } from './util.js'
18
+
19
+ const SHELL_OPERATORS = new Set(['|', '&', ';', '<', '>', '(', ')', '`', '$'])
20
+ const FORBIDDEN_GLOBAL_FLAGS = new Set(['--context', '--host', '--config'])
21
+ const FORBIDDEN_GLOBAL_PREFIXES = ['--tls', '-H']
22
+ const DESTRUCTIVE_FIRST = new Set(['rm', 'rmi', 'prune', 'kill', 'stop', 'delete'])
23
+ const DESTRUCTIVE_GROUPS = new Set(['volume', 'network', 'container', 'image', 'builder'])
24
+ const SENSITIVE_MARKERS = new Set(['login', 'logout', '--password', '--password-stdin', '--token', '--registry-token'])
25
+
26
+ export class CommandError extends Error {
27
+ /** @param {string} message @param {string} [code] */
28
+ constructor(message, code = 'docker/invalid-command') {
29
+ super(message)
30
+ this.name = 'CommandError'
31
+ this.code = code
32
+ }
33
+ }
34
+
35
+ /** @param {string} input */
36
+ function tokenize(input) {
37
+ if (Buffer.byteLength(input, 'utf8') > MAX_COMMAND_BYTES) throw new CommandError('命令太长(上限 8 KiB)')
38
+ if (input.includes('\0')) throw new CommandError('命令包含非法 NUL 字符')
39
+
40
+ const tokens = []
41
+ let current = ''
42
+ let quote = null
43
+ let hasToken = false
44
+ let index = 0
45
+
46
+ const push = () => {
47
+ if (!hasToken) return
48
+ tokens.push(current)
49
+ current = ''
50
+ hasToken = false
51
+ if (tokens.length > MAX_ARGV) throw new CommandError(`参数过多(上限 ${MAX_ARGV} 项)`)
52
+ }
53
+
54
+ while (index < input.length) {
55
+ const char = input[index]
56
+ if (quote === "'") {
57
+ if (char === "'") quote = null
58
+ else current += char
59
+ hasToken = true
60
+ index += 1
61
+ continue
62
+ }
63
+ if (quote === '"') {
64
+ if (char === '"') {
65
+ quote = null
66
+ index += 1
67
+ continue
68
+ }
69
+ if (char === '\\') {
70
+ const next = input[index + 1]
71
+ if (next === undefined) throw new CommandError('命令以未完成的转义结尾')
72
+ current += next
73
+ hasToken = true
74
+ index += 2
75
+ continue
76
+ }
77
+ current += char
78
+ hasToken = true
79
+ index += 1
80
+ continue
81
+ }
82
+ if (char === "'" || char === '"') {
83
+ quote = char
84
+ hasToken = true
85
+ index += 1
86
+ continue
87
+ }
88
+ if (char === '\\') {
89
+ const next = input[index + 1]
90
+ if (next === undefined) throw new CommandError('命令以未完成的转义结尾')
91
+ current += next
92
+ hasToken = true
93
+ index += 2
94
+ continue
95
+ }
96
+ if (SHELL_OPERATORS.has(char)) {
97
+ throw new CommandError('不支持管道、重定向、命令替换等 shell 语法;请分开执行,或改用快捷操作')
98
+ }
99
+ if (/\s/u.test(char)) {
100
+ push()
101
+ index += 1
102
+ continue
103
+ }
104
+ current += char
105
+ hasToken = true
106
+ index += 1
107
+ }
108
+ if (quote !== null) throw new CommandError('引号没有闭合')
109
+ push()
110
+ for (const token of tokens) {
111
+ if (Buffer.byteLength(token, 'utf8') > MAX_ARG_BYTES) throw new CommandError('单个参数太长(上限 4 KiB)')
112
+ }
113
+ return tokens
114
+ }
115
+
116
+ /**
117
+ * Parse free-form command text into a Docker subcommand argv.
118
+ * @param {string} input
119
+ * @param {{dockerPath?: string}} [options]
120
+ */
121
+ export function parseCommand(input, options = {}) {
122
+ const text = requireString(input, 'command', { max: MAX_COMMAND_BYTES, allowEmpty: true })
123
+ const tokens = tokenize(text.trim())
124
+ return normalizeSubcommand(tokens, options)
125
+ }
126
+
127
+ /**
128
+ * Parse shell-free argument text without stripping a leading command name.
129
+ * Used by structured actions such as `run <image> <args>`.
130
+ * @param {string} input
131
+ * @returns {string[]}
132
+ */
133
+ export function parseArguments(input) {
134
+ const text = requireString(input, 'arguments', { max: MAX_COMMAND_BYTES, allowEmpty: true })
135
+ return tokenize(text.trim())
136
+ }
137
+
138
+ /**
139
+ * Normalize both free-form and structured input to the argv after `docker`.
140
+ * @param {unknown} value
141
+ * @param {{dockerPath?: string}} [options]
142
+ */
143
+ export function normalizeSubcommand(value, options = {}) {
144
+ const commandName = options.commandName ?? 'docker'
145
+ if (!Array.isArray(value)) throw new CommandError('argv 必须是字符串数组')
146
+ if (value.length === 0) throw new CommandError(`请输入 ${commandName} 命令`)
147
+ if (value.length > MAX_ARGV) throw new CommandError(`参数过多(上限 ${MAX_ARGV} 项)`)
148
+ const tokens = value.map((token, index) => requireString(token, `argv[${index}]`))
149
+ let argv = tokens
150
+ const binaryName = options.dockerPath ? basename(options.dockerPath) : commandName
151
+ if (argv[0] === commandName || argv[0] === binaryName || argv[0] === options.dockerPath) argv = argv.slice(1)
152
+ if (argv.length === 0) throw new CommandError(`请输入 ${commandName} 子命令`)
153
+ if (argv[0] === 'sudo' || argv[0] === 'env') throw new CommandError(`只能执行 ${commandName} 子命令,不能执行其他程序`)
154
+ if (commandName === 'container' && argv[0] === 'docker') throw new CommandError('当前 provider 是 Apple container,请使用 container 命令')
155
+ for (const token of argv) {
156
+ if (FORBIDDEN_GLOBAL_FLAGS.has(token) || token.startsWith('--context=') || token.startsWith('--host=') || token.startsWith('--config=')) {
157
+ throw new CommandError(`不允许在命令中覆盖 ${token};请使用面板的上下文选择器`)
158
+ }
159
+ if (FORBIDDEN_GLOBAL_PREFIXES.some((prefix) => token === prefix || token.startsWith(`${prefix}=`) || (prefix === '-H' && token.startsWith('-H') && token.length > 2))) {
160
+ throw new CommandError(`不允许在命令中覆盖 ${token};连接目标由上下文决定`)
161
+ }
162
+ }
163
+ return argv
164
+ }
165
+
166
+ /**
167
+ * @param {string | undefined | null} context
168
+ * @param {{known?: Set<string>}} [options]
169
+ * @returns {string | null}
170
+ */
171
+ export function normalizeContext(context, options = {}) {
172
+ if (context === undefined || context === null || context === '') return null
173
+ if (typeof context !== 'string') throw new CommandError('context 必须是字符串')
174
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.@-]{0,127}$/u.test(context)) throw new CommandError('context 名称不合法')
175
+ if (options.known !== undefined && !options.known.has(context)) throw new CommandError(`未知的 Docker context: ${context}`, 'docker/context-unknown')
176
+ return context
177
+ }
178
+
179
+ /**
180
+ * @param {string[]} argv
181
+ * @returns {{dangerous: boolean, reason: string | null}}
182
+ */
183
+ export function detectDestructive(argv) {
184
+ const first = argv[0]
185
+ const second = argv[1]
186
+ if (DESTRUCTIVE_FIRST.has(first)) {
187
+ if (first === 'stop' || first === 'kill') return { dangerous: true, reason: 'stop' }
188
+ if (first === 'rm' || first === 'rmi' || first === 'delete') return { dangerous: true, reason: 'delete' }
189
+ return { dangerous: true, reason: 'prune' }
190
+ }
191
+ if (first === 'compose' && (second === 'down' || second === 'rm')) return { dangerous: true, reason: 'compose-down' }
192
+ if (first === 'system' && second === 'prune') return { dangerous: true, reason: 'prune' }
193
+ if (DESTRUCTIVE_GROUPS.has(first) && (second === 'rm' || second === 'delete' || second === 'prune')) {
194
+ return { dangerous: true, reason: second === 'prune' ? 'prune' : 'delete' }
195
+ }
196
+ if (first === 'swarm' && second === 'leave') return { dangerous: true, reason: 'delete' }
197
+ return { dangerous: false, reason: null }
198
+ }
199
+
200
+ /**
201
+ * @param {string[]} argv
202
+ * @returns {boolean}
203
+ */
204
+ export function isSensitiveCommand(argv) {
205
+ return argv.some((token) => SENSITIVE_MARKERS.has(token) || token.startsWith('--password=') || token.startsWith('--token='))
206
+ }
207
+
208
+ /**
209
+ * Resolve a client-provided cwd to an absolute existing directory.
210
+ * @param {unknown} value
211
+ * @param {{fallback: string, statSync: (path: string) => {isDirectory: () => boolean}}} options
212
+ */
213
+ export function resolveCwd(value, options) {
214
+ if (value === undefined || value === null || value === '') return options.fallback
215
+ if (typeof value !== 'string') throw new CommandError('cwd 必须是字符串', 'docker/invalid-cwd')
216
+ if (!isAbsolute(value)) throw new CommandError('cwd 必须是绝对路径', 'docker/invalid-cwd')
217
+ if (value.includes('\0') || value.length > 4096) throw new CommandError('cwd 不合法', 'docker/invalid-cwd')
218
+ let stat
219
+ try {
220
+ stat = options.statSync(value)
221
+ } catch (error) {
222
+ throw new CommandError(`cwd 不存在或不可访问: ${messageOf(error)}`, 'docker/invalid-cwd')
223
+ }
224
+ if (!stat.isDirectory()) throw new CommandError('cwd 不是目录', 'docker/invalid-cwd')
225
+ return value
226
+ }
227
+
228
+ /**
229
+ * Build the client-facing display form without resolving the binary path.
230
+ * @param {string[]} argv
231
+ * @param {string | null} context
232
+ */
233
+ export function describeCommand(argv, context, commandName = 'docker') {
234
+ return displayCommand(argv, context, commandName)
235
+ }