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,243 @@
|
|
|
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
|
+
* cols?: number,
|
|
57
|
+
* rows?: number,
|
|
58
|
+
* signal?: AbortSignal,
|
|
59
|
+
* }} input
|
|
60
|
+
*/
|
|
61
|
+
async start(input) {
|
|
62
|
+
if (typeof this.subprocess.spawnTerminal !== 'function') {
|
|
63
|
+
throw attachError('当前 DSH 版本不支持 PTY 终端,无法 attach', 'docker/attach-unsupported')
|
|
64
|
+
}
|
|
65
|
+
if (this.sessions.size >= this.maxSessions) {
|
|
66
|
+
throw attachError(`最多同时打开 ${String(this.maxSessions)} 个 attach 终端,请先关闭一个`, 'docker/attach-limit')
|
|
67
|
+
}
|
|
68
|
+
const cols = dimension(input.cols, MIN_COLS, MAX_COLS, 120)
|
|
69
|
+
const rows = dimension(input.rows, MIN_ROWS, MAX_ROWS, 30)
|
|
70
|
+
let handle
|
|
71
|
+
try {
|
|
72
|
+
handle = await this.subprocess.spawnTerminal({
|
|
73
|
+
argv: input.argv,
|
|
74
|
+
cwd: input.cwd,
|
|
75
|
+
cols,
|
|
76
|
+
rows,
|
|
77
|
+
terminalType: 'xterm-256color',
|
|
78
|
+
env: input.env ?? {},
|
|
79
|
+
graceMs: 2000,
|
|
80
|
+
signal: input.signal,
|
|
81
|
+
})
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw attachError(`无法启动 attach 终端: ${messageOf(error)}`, 'docker/attach-failed')
|
|
84
|
+
}
|
|
85
|
+
if (handle === null || typeof handle !== 'object' || handle.output === undefined || typeof handle.write !== 'function') {
|
|
86
|
+
try { await handle?.terminate?.() } catch { /* best effort */ }
|
|
87
|
+
throw attachError('attach 终端句柄不可用', 'docker/attach-failed')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const id = `attach_${randomUUID()}`
|
|
91
|
+
/** @type {any} */
|
|
92
|
+
const session = {
|
|
93
|
+
id,
|
|
94
|
+
provider: input.provider,
|
|
95
|
+
containerId: input.containerId,
|
|
96
|
+
containerName: input.containerName ?? input.containerId,
|
|
97
|
+
command: input.command,
|
|
98
|
+
cols,
|
|
99
|
+
rows,
|
|
100
|
+
status: 'running',
|
|
101
|
+
exitCode: null,
|
|
102
|
+
startedAt: Date.now(),
|
|
103
|
+
endedAt: null,
|
|
104
|
+
error: null,
|
|
105
|
+
output: '',
|
|
106
|
+
/** Absolute offset of output[0] within the session's full output. */
|
|
107
|
+
baseOffset: 0,
|
|
108
|
+
handle,
|
|
109
|
+
closed: false,
|
|
110
|
+
}
|
|
111
|
+
this.sessions.set(id, session)
|
|
112
|
+
handle.output.on('data', (chunk) => this.append(session, String(chunk)))
|
|
113
|
+
handle.done.then(
|
|
114
|
+
(outcome) => {
|
|
115
|
+
if (session.closed) return
|
|
116
|
+
session.status = 'exited'
|
|
117
|
+
session.exitCode = outcome?.exitCode ?? null
|
|
118
|
+
session.endedAt = Date.now()
|
|
119
|
+
},
|
|
120
|
+
(error) => {
|
|
121
|
+
if (session.closed) return
|
|
122
|
+
session.status = 'failed'
|
|
123
|
+
session.endedAt = Date.now()
|
|
124
|
+
session.error = messageOf(error)
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
return this.info(session)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @param {any} session @param {string} text */
|
|
131
|
+
append(session, text) {
|
|
132
|
+
if (text === '') return
|
|
133
|
+
session.output += text
|
|
134
|
+
if (session.output.length > this.maxBytes) {
|
|
135
|
+
const remove = session.output.length - this.maxBytes
|
|
136
|
+
session.output = session.output.slice(remove)
|
|
137
|
+
session.baseOffset += remove
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** @param {any} session */
|
|
142
|
+
info(session) {
|
|
143
|
+
return {
|
|
144
|
+
id: session.id,
|
|
145
|
+
provider: session.provider,
|
|
146
|
+
containerId: session.containerId,
|
|
147
|
+
containerName: session.containerName,
|
|
148
|
+
command: session.command,
|
|
149
|
+
cols: session.cols,
|
|
150
|
+
rows: session.rows,
|
|
151
|
+
status: session.status,
|
|
152
|
+
exitCode: session.exitCode,
|
|
153
|
+
startedAt: session.startedAt,
|
|
154
|
+
endedAt: session.endedAt,
|
|
155
|
+
error: session.error,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @param {string} id */
|
|
160
|
+
require(id) {
|
|
161
|
+
if (typeof id !== 'string' || id === '') throw attachError('缺少 attachId', 'docker/attach-unknown')
|
|
162
|
+
const session = this.sessions.get(id)
|
|
163
|
+
if (session === undefined) throw attachError('attach 会话不存在或已关闭', 'docker/attach-unknown')
|
|
164
|
+
return session
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** @param {Record<string, any>} input */
|
|
168
|
+
poll(input) {
|
|
169
|
+
const session = this.require(input.attachId)
|
|
170
|
+
const requested = Number(input.offset)
|
|
171
|
+
const offset = Number.isFinite(requested) && requested >= 0 ? Math.trunc(requested) : 0
|
|
172
|
+
const lossy = offset < session.baseOffset
|
|
173
|
+
const start = Math.max(offset, session.baseOffset) - session.baseOffset
|
|
174
|
+
return {
|
|
175
|
+
session: this.info(session),
|
|
176
|
+
output: {
|
|
177
|
+
text: session.output.slice(start),
|
|
178
|
+
nextOffset: session.baseOffset + session.output.length,
|
|
179
|
+
lossy,
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** @param {Record<string, any>} input */
|
|
185
|
+
async write(input) {
|
|
186
|
+
const session = this.require(input.attachId)
|
|
187
|
+
if (session.status !== 'running') throw attachError('attach 终端已结束', 'docker/attach-closed')
|
|
188
|
+
const data = input.data
|
|
189
|
+
if (typeof data !== 'string' || data === '') throw attachError('缺少输入内容', 'docker/attach-input')
|
|
190
|
+
if (Buffer.byteLength(data, 'utf8') > MAX_INPUT_BYTES) throw attachError('单次输入过长', 'docker/attach-input')
|
|
191
|
+
try {
|
|
192
|
+
await session.handle.write(data)
|
|
193
|
+
} catch (error) {
|
|
194
|
+
throw attachError(`写入 attach 终端失败: ${messageOf(error)}`, 'docker/attach-input')
|
|
195
|
+
}
|
|
196
|
+
return this.info(session)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** @param {Record<string, any>} input */
|
|
200
|
+
async resize(input) {
|
|
201
|
+
const session = this.require(input.attachId)
|
|
202
|
+
if (session.status !== 'running') return this.info(session)
|
|
203
|
+
const cols = dimension(input.cols, MIN_COLS, MAX_COLS, session.cols)
|
|
204
|
+
const rows = dimension(input.rows, MIN_ROWS, MAX_ROWS, session.rows)
|
|
205
|
+
try {
|
|
206
|
+
await session.handle.resize(cols, rows)
|
|
207
|
+
session.cols = cols
|
|
208
|
+
session.rows = rows
|
|
209
|
+
} catch (error) {
|
|
210
|
+
throw attachError(`调整 attach 终端大小失败: ${messageOf(error)}`, 'docker/attach-resize')
|
|
211
|
+
}
|
|
212
|
+
return this.info(session)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** @param {Record<string, any>} input */
|
|
216
|
+
async close(input) {
|
|
217
|
+
const session = this.require(input.attachId)
|
|
218
|
+
session.closed = true
|
|
219
|
+
session.status = 'closed'
|
|
220
|
+
session.endedAt = Date.now()
|
|
221
|
+
try {
|
|
222
|
+
await session.handle.terminate()
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (codeOf(error) !== 'ESRCH') session.error = messageOf(error)
|
|
225
|
+
} finally {
|
|
226
|
+
this.sessions.delete(session.id)
|
|
227
|
+
}
|
|
228
|
+
return this.info(session)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async dispose() {
|
|
232
|
+
const sessions = [...this.sessions.values()]
|
|
233
|
+
this.sessions.clear()
|
|
234
|
+
await Promise.allSettled(sessions.map(async (session) => {
|
|
235
|
+
session.closed = true
|
|
236
|
+
await session.handle.terminate()
|
|
237
|
+
}))
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function createAttachManager(options) {
|
|
242
|
+
return new AttachManager(options)
|
|
243
|
+
}
|
|
@@ -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
|
+
}
|