dsh-plugin-container 0.4.6 → 0.5.1

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.
@@ -0,0 +1,113 @@
1
+ /** Push a local image to a registry, retagging it first when the target differs. */
2
+ import { useEffect, useMemo, useState } from 'react'
3
+
4
+ import { Button, Modal, fill } from './components.jsx'
5
+
6
+ const IMAGE_REF = /^[A-Za-z0-9][A-Za-z0-9_.:/@-]*$/u
7
+ const PRESETS = [
8
+ { label: 'Docker Hub', prefix: 'myname/' },
9
+ { label: 'GHCR', prefix: 'ghcr.io/myname/' },
10
+ { label: 'Quay', prefix: 'quay.io/myname/' },
11
+ ]
12
+
13
+ /** `ghcr.io/me/app:1` -> `app:1` */
14
+ function lastSegment(reference) {
15
+ const withoutDigest = reference.split('@')[0]
16
+ return withoutDigest.slice(withoutDigest.lastIndexOf('/') + 1)
17
+ }
18
+
19
+ export default function PushImageDialog({ open, t, provider, image, onClose, onSubmit }) {
20
+ const [target, setTarget] = useState('')
21
+ const [error, setError] = useState(null)
22
+ const [busy, setBusy] = useState(false)
23
+
24
+ useEffect(() => {
25
+ if (!open) return
26
+ setTarget(image)
27
+ setError(null)
28
+ setBusy(false)
29
+ }, [open, image])
30
+
31
+ const trimmed = target.trim()
32
+ const valid = IMAGE_REF.test(trimmed)
33
+ const needsNamespace = valid && !trimmed.split('@')[0].includes('/')
34
+ const retag = valid && trimmed !== image
35
+ const command = useMemo(() => {
36
+ const cli = provider === 'apple-container' ? 'container image' : 'docker'
37
+ const lines = []
38
+ if (retag) lines.push(`${cli} tag ${image} ${trimmed}`)
39
+ lines.push(`${cli} push ${valid ? trimmed : '…'}`)
40
+ return lines.join('\n')
41
+ }, [image, provider, retag, trimmed, valid])
42
+
43
+ const submit = async (event) => {
44
+ event?.preventDefault()
45
+ if (!valid) {
46
+ setError(t('pullInvalid'))
47
+ return
48
+ }
49
+ setBusy(true)
50
+ setError(null)
51
+ try {
52
+ await onSubmit({ kind: 'image', action: 'push', id: image, target: trimmed, name: trimmed })
53
+ onClose()
54
+ } catch (submitError) {
55
+ setError(submitError instanceof Error ? submitError.message : String(submitError))
56
+ setBusy(false)
57
+ }
58
+ }
59
+
60
+ return (
61
+ <Modal
62
+ open={open}
63
+ title={t('imgPushTitle')}
64
+ subtitle={provider === 'apple-container' ? t('providerApple') : t('providerDocker')}
65
+ onClose={onClose}
66
+ closeLabel={t('close')}
67
+ footer={
68
+ <>
69
+ <Button size="sm" variant="ghost" onClick={onClose} disabled={busy}>{t('modalCancel')}</Button>
70
+ <Button size="sm" variant="primary" onClick={submit} disabled={busy || !valid}>
71
+ {busy ? t('imgPushing') : t('imgPushStart')}
72
+ </Button>
73
+ </>
74
+ }
75
+ >
76
+ <form className="dsh-docker-run-form" onSubmit={submit}>
77
+ <label className="dsh-docker-field">
78
+ <span>{t('imgPushSource')}</span>
79
+ <input className="dsh-docker-input dsh-docker-mono" value={image} readOnly />
80
+ </label>
81
+ <label className="dsh-docker-field">
82
+ <span>{t('imgPushTarget')}</span>
83
+ <input
84
+ className="dsh-docker-input dsh-docker-mono"
85
+ value={target}
86
+ onChange={(event) => setTarget(event.target.value)}
87
+ spellCheck="false"
88
+ autoFocus
89
+ />
90
+ <span className="dsh-docker-hint">{t('imgPushTargetHint')}</span>
91
+ </label>
92
+ <div className="dsh-docker-chip-row">
93
+ <span className="dsh-docker-hint">{t('imgPushPresets')}</span>
94
+ {PRESETS.map((preset) => (
95
+ <Button key={preset.label} size="sm" variant="ghost" onClick={() => setTarget(`${preset.prefix}${lastSegment(image)}`)}>
96
+ {preset.label}
97
+ </Button>
98
+ ))}
99
+ </div>
100
+ {needsNamespace && (
101
+ <div className="dsh-docker-hint dsh-docker-hint-warn">{fill(t('imgPushNeedsNamespace'), { name: lastSegment(trimmed) })}</div>
102
+ )}
103
+ {retag && <div className="dsh-docker-hint">{t('imgPushRetag')}</div>}
104
+ <label className="dsh-docker-field">
105
+ <span>{t('imgCommandPreview')}</span>
106
+ <pre className="dsh-docker-preview">{command}</pre>
107
+ </label>
108
+ <div className="dsh-docker-hint">{provider === 'apple-container' ? t('imgPushLoginApple') : t('imgPushLoginDocker')}</div>
109
+ {error !== null && <div className="dsh-docker-form-error">{error}</div>}
110
+ </form>
111
+ </Modal>
112
+ )
113
+ }
@@ -220,7 +220,6 @@ const CSS = `
220
220
  .dsh-docker-field > span { font-size: 11.5px; color: var(--dsw-alias-label-secondary, #60636c); }
221
221
  .dsh-docker-textarea { height: auto; min-height: 72px; padding: 8px 10px; resize: vertical; font-family: var(--dsw-font-markdown-code-font-family, ui-monospace, Menlo, monospace); line-height: 1.5; }
222
222
  .dsh-docker-form-error { color: var(--dsw-alias-label-error, #d33c3c); font-size: 12px; }
223
- .dsh-docker-check { display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--dsw-alias-label-secondary, #60636c); }
224
223
 
225
224
 
226
225
  .dsh-docker-table-wrap { overflow: auto; border-radius: 0 0 11px 11px; }
@@ -359,6 +358,18 @@ const CSS = `
359
358
  @media (prefers-reduced-motion: reduce) {
360
359
  .dsh-docker-spinner, .dsh-docker-skel-line, .dsh-docker-toast { animation: none; }
361
360
  }
361
+ .dsh-docker-hint { font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-tertiary, #8a8d96); }
362
+ .dsh-docker-hint-warn { color: var(--dsw-alias-label-warning, #b7791f); }
363
+ .dsh-docker-line-ok { font-size: 12px; color: var(--dsw-alias-label-success, #2f855a); }
364
+ .dsh-docker-line-err { font-size: 12px; color: var(--dsw-alias-label-error, #d33c3c); }
365
+ .dsh-docker-preview { margin: 0; padding: 8px 10px; border-radius: 8px; white-space: pre-wrap; word-break: break-all; font-family: var(--dsw-font-markdown-code-font-family, ui-monospace, Menlo, monospace); font-size: 11.5px; line-height: 1.55; background: var(--dsw-alias-markdown-code-block, #f6f7f9); border: .5px solid var(--dsw-alias-border-l3, rgba(0,0,0,.08)); }
366
+ .dsh-docker-chip-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
367
+ .dsh-docker-advanced { border-top: .5px solid var(--dsw-alias-border-l3, rgba(0,0,0,.08)); padding-top: 10px; }
368
+ .dsh-docker-advanced > summary { cursor: pointer; font-size: 12px; color: var(--dsw-alias-label-secondary, #60636c); user-select: none; }
369
+ .dsh-docker-advanced[open] > summary { margin-bottom: 10px; }
370
+ .dsh-docker-advanced-body { display: flex; flex-direction: column; gap: 12px; }
371
+ .dsh-docker-check { display: flex; align-items: center; gap: 8px; font-size: 12.5px; }
372
+
362
373
  `
363
374
 
364
375
  export function injectStyles() {
@@ -11,17 +11,22 @@ import {
11
11
  displayValue,
12
12
  shortId,
13
13
  } from '../components.jsx'
14
- import BuildImageDialog from '../build-image.jsx'
15
14
  import { useDockerList } from '../hooks.js'
16
- import { IconAlert, IconBuild, IconDownload, IconImage, IconInfo, IconPlay, IconTrash, IconUpload } from '../icons.jsx'
15
+ import { PLATFORMS } from '../platforms.js'
16
+ import { IconAlert, IconDownload, IconHammer, IconImage, IconInfo, IconPlay, IconTrash, IconUpload } from '../icons.jsx'
17
17
 
18
18
  const IMAGE_REF = /^[A-Za-z0-9][A-Za-z0-9_.:/@-]*$/u
19
19
 
20
- export default function ImagesTab({ t, api, provider, context, status, refreshToken, features = [], cwd, onAction, onRunContainer, onInspect, onNotify }) {
20
+ /** Accept a pasted `docker pull nginx` as well as a bare image name. */
21
+ function normalizePullInput(value) {
22
+ return value.trim().replace(/^(?:docker\s+(?:image\s+)?|container\s+image\s+)pull\s+/iu, '').trim()
23
+ }
24
+
25
+ export default function ImagesTab({ t, api, provider, context, status, refreshToken, features = [], onAction, onRunContainer, onInspect, onNotify, onPullImage, onPushImage, onBuildImage }) {
21
26
  const showArchitecture = features.includes('image-architecture')
22
27
  const [search, setSearch] = useState('')
23
28
  const [pull, setPull] = useState('')
24
- const [buildOpen, setBuildOpen] = useState(false)
29
+ const [pullPlatform, setPullPlatform] = useState('')
25
30
  const enabled = status?.daemon?.state === 'running'
26
31
  const list = useDockerList(api, provider, 'images', context, { refreshToken, enabled })
27
32
  const items = list.data?.items ?? []
@@ -33,13 +38,13 @@ export default function ImagesTab({ t, api, provider, context, status, refreshTo
33
38
 
34
39
  const submitPull = (event) => {
35
40
  event.preventDefault()
36
- const reference = pull.trim()
41
+ const reference = normalizePullInput(pull)
37
42
  if (!IMAGE_REF.test(reference)) {
38
43
  onNotify('error', t('pullInvalid'))
39
44
  return
40
45
  }
41
46
  setPull('')
42
- onAction({ kind: 'image', action: 'pull', id: reference }, { focusConsole: true })
47
+ onPullImage(reference, pullPlatform === '' ? undefined : pullPlatform)
43
48
  }
44
49
 
45
50
  return (
@@ -54,9 +59,21 @@ export default function ImagesTab({ t, api, provider, context, status, refreshTo
54
59
  value={pull}
55
60
  onChange={(event) => setPull(event.target.value)}
56
61
  placeholder={t('pullPlaceholder')}
57
- aria-label={t('pullPlaceholder')}
62
+ aria-label={t('imgPullHint')}
63
+ title={t('imgPullHint')}
58
64
  spellCheck="false"
59
65
  />
66
+ <select
67
+ className="dsh-docker-select"
68
+ style={{ width: 150 }}
69
+ value={pullPlatform}
70
+ onChange={(event) => setPullPlatform(event.target.value)}
71
+ aria-label={t('imgPullPlatform')}
72
+ title={t('imgPullPlatform')}
73
+ >
74
+ <option value="">{t('imgBuildPlatformAuto')}</option>
75
+ {PLATFORMS.map((name) => <option key={name} value={name}>{name}</option>)}
76
+ </select>
60
77
  <Button size="sm" type="submit" disabled={pull.trim() === ''}><IconDownload size={13} /> {t('pull')}</Button>
61
78
  </form>
62
79
  </div>
@@ -64,7 +81,7 @@ export default function ImagesTab({ t, api, provider, context, status, refreshTo
64
81
  <span className="dsh-docker-muted">{filtered.length} / {items.length}</span>
65
82
  {!showArchitecture && <span className="dsh-docker-muted">{t('restartForArchitecture')}</span>}
66
83
  {features.includes('image-build') && (
67
- <Button size="sm" onClick={() => setBuildOpen(true)}><IconBuild size={13} /> {t('buildImage')}</Button>
84
+ <Button size="sm" onClick={onBuildImage}><IconHammer size={13} /> {t('imgBuild')}</Button>
68
85
  )}
69
86
  <Button size="sm" onClick={list.reload} disabled={list.loading}>{t('refresh')}</Button>
70
87
  </div>
@@ -112,7 +129,7 @@ export default function ImagesTab({ t, api, provider, context, status, refreshTo
112
129
  </IconButton>
113
130
  )}
114
131
  {features.includes('image-push') && item.name !== '<none>' && (
115
- <IconButton size="sm" label={t('pushImage')} onClick={() => onAction({ kind: 'image', action: 'push', id: item.name }, { focusConsole: true })}>
132
+ <IconButton size="sm" label={t('imgPush')} onClick={() => onPushImage(item.name)}>
116
133
  <IconUpload size={14} />
117
134
  </IconButton>
118
135
  )}
@@ -132,17 +149,6 @@ export default function ImagesTab({ t, api, provider, context, status, refreshTo
132
149
  </div>
133
150
  </section>
134
151
  )}
135
-
136
- <BuildImageDialog
137
- open={buildOpen}
138
- t={t}
139
- provider={provider}
140
- cwd={cwd}
141
- onClose={() => setBuildOpen(false)}
142
- onSubmit={async (input) => {
143
- await onAction(input, { focusConsole: true })
144
- }}
145
- />
146
152
  </div>
147
153
  )
148
154
  }
package/src/host/apple.js CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  resolveCwd,
19
19
  } from './commands.js'
20
20
  import { AppleCli } from './apple-cli.js'
21
+ import { LONG_JOB_MS, buildArgv, buildSpec, platformOf, pushSpec } from './images.js'
21
22
  import { JobManager } from './jobs.js'
22
23
  import {
23
24
  firstLine,
@@ -395,21 +396,20 @@ export class AppleProvider {
395
396
  }
396
397
  if (kind === 'image') {
397
398
  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 === 'push') {
401
- return this.run({ argv: ['image', 'push', safeImageRef(payload.id ?? payload.reference)], cwd: payload.cwd, title: payload.title ?? 'container image push' }, signal)
399
+ const platform = platformOf(payload.platform)
400
+ return this.run({ argv: ['image', 'pull', ...(platform === undefined ? [] : ['--platform', platform]), safeImageRef(payload.id ?? payload.reference)], cwd: payload.cwd, timeoutMs: LONG_JOB_MS, title: payload.title ?? 'container image pull' }, signal)
402
401
  }
403
402
  if (action === 'build') {
404
- const tag = safeImageRef(payload.tag)
405
- const contextPath = resolveCwd(payload.contextPath === undefined ? this.home : requireString(payload.contextPath, 'contextPath'), { fallback: this.home, statSync })
406
- const dockerfile = payload.dockerfile === undefined || payload.dockerfile === null ? undefined : requireString(payload.dockerfile, 'dockerfile')
407
- if (dockerfile !== undefined && (dockerfile.includes('\0') || dockerfile.startsWith('-') || dockerfile.length > 4096)) throw new CommandError('Dockerfile 路径不合法')
408
- const argv = ['build', '--progress', 'plain', '-t', tag]
409
- if (dockerfile !== undefined && dockerfile !== '') argv.push('-f', dockerfile)
410
- if (payload.noCache === true) argv.push('--no-cache')
411
- argv.push(contextPath)
412
- return this.run({ argv, cwd: contextPath, timeoutMs: payload.timeoutMs ?? 15 * 60_000, title: payload.title ?? `container build ${tag}` }, signal)
403
+ const spec = buildSpec(payload)
404
+ return this.run({ argv: buildArgv(spec), cwd: spec.contextDir, timeoutMs: LONG_JOB_MS, title: payload.title ?? `container build ${spec.tag}` }, signal)
405
+ }
406
+ if (action === 'push') {
407
+ const { source, target } = pushSpec(payload)
408
+ if (target !== source) {
409
+ const tagged = await this.cli.exec(['image', 'tag', source, target], { signal, cwd: this.home, timeoutMs: 20_000 })
410
+ if (tagged.exitCode !== 0) throw new CommandError(`${tagged.stderr}${tagged.stdout}`.trim() || `container image tag 退出码 ${String(tagged.exitCode)}`, 'container/exec-failed')
411
+ }
412
+ return this.run({ argv: ['image', 'push', target], cwd: payload.cwd, timeoutMs: LONG_JOB_MS, title: payload.title ?? `container image push ${target}` }, signal)
413
413
  }
414
414
  if (action === 'delete') {
415
415
  return this.run({ argv: ['image', 'delete', safeId(payload.id)], confirm: payload.confirm, cwd: payload.cwd, title: payload.title ?? 'container image delete' }, signal)
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Shared validation for image push and build, used by the Docker and Apple
3
+ * providers. Everything here produces argv arrays; nothing touches a shell.
4
+ */
5
+ import { readdirSync, statSync } from 'node:fs'
6
+ import { basename, isAbsolute, join, resolve, sep } from 'node:path'
7
+
8
+ import { CommandError } from './commands.js'
9
+ import { requireString } from './util.js'
10
+
11
+ const IMAGE_REF = /^[A-Za-z0-9][A-Za-z0-9_.:/@-]*$/u
12
+ const PLATFORM = /^[a-z0-9]+\/[a-z0-9]+(?:\/[a-z0-9]+)?$/u
13
+ const BUILD_ARG = /^[A-Za-z_][A-Za-z0-9_]*=[^\r\n]*$/u
14
+ const DOCKERFILE_NAME = /^(?:Dockerfile|Containerfile)(?:\..+)?$|\.(?:Dockerfile|Containerfile)$/u
15
+
16
+ export const LONG_JOB_MS = 30 * 60_000
17
+
18
+ /** @param {unknown} value @param {string} field */
19
+ export function imageRef(value, field) {
20
+ const reference = requireString(value, field, { max: 512 })
21
+ if (!IMAGE_REF.test(reference)) throw new CommandError(`镜像名不合法: ${reference}`)
22
+ return reference
23
+ }
24
+
25
+ /**
26
+ * Docker rejects uppercase in the repository part; say so before running.
27
+ * @param {string} reference
28
+ */
29
+ function assertLowercaseRepository(reference) {
30
+ const withoutDigest = reference.split('@')[0]
31
+ const lastSlash = withoutDigest.lastIndexOf('/')
32
+ const lastColon = withoutDigest.lastIndexOf(':')
33
+ const repository = lastColon > lastSlash ? withoutDigest.slice(0, lastColon) : withoutDigest
34
+ if (/[A-Z]/u.test(repository)) throw new CommandError(`镜像名的仓库部分必须是小写: ${repository}`)
35
+ }
36
+
37
+ /**
38
+ * Optional `--platform` value shared by pull and build.
39
+ * @param {unknown} value
40
+ * @returns {string | undefined}
41
+ */
42
+ export function platformOf(value) {
43
+ if (value === undefined || value === null || String(value).trim() === '') return undefined
44
+ const platform = String(value).trim()
45
+ if (!PLATFORM.test(platform)) throw new CommandError(`平台格式不合法: ${platform}(示例 linux/arm64)`)
46
+ return platform
47
+ }
48
+
49
+ /** @param {Record<string, any>} payload */
50
+ export function pushSpec(payload) {
51
+ const source = imageRef(payload.id ?? payload.reference, 'id')
52
+ const target = payload.target === undefined || payload.target === null || String(payload.target).trim() === ''
53
+ ? source
54
+ : imageRef(String(payload.target).trim(), 'target')
55
+ assertLowercaseRepository(target)
56
+ return { source, target }
57
+ }
58
+
59
+ /** @param {unknown} value @param {string} field */
60
+ function list(value, field) {
61
+ if (value === undefined || value === null) return []
62
+ if (!Array.isArray(value)) throw new CommandError(`${field} 必须是字符串数组`)
63
+ if (value.length > 64) throw new CommandError(`${field} 数量过多`)
64
+ return value.map((entry, index) => requireString(entry, `${field}[${String(index)}]`, { max: 1024 }))
65
+ }
66
+
67
+ /** @param {unknown} value */
68
+ function statOrNull(value) {
69
+ try {
70
+ return statSync(value)
71
+ } catch {
72
+ return null
73
+ }
74
+ }
75
+
76
+ /** @param {Record<string, any>} payload */
77
+ export function buildSpec(payload) {
78
+ const tag = imageRef(payload.tag, 'tag')
79
+ assertLowercaseRepository(tag)
80
+ const rawDir = requireString(payload.contextDir, 'contextDir', { max: 4096 })
81
+ if (!isAbsolute(rawDir) || rawDir.includes('\0')) throw new CommandError('构建目录必须是绝对路径')
82
+ // Normalise so `/a/b/` and `/a/b/./` compare equal to the resolved Dockerfile path below.
83
+ const contextDir = resolve(rawDir)
84
+ const dirStat = statOrNull(contextDir)
85
+ if (dirStat === null) throw new CommandError(`构建目录不存在: ${contextDir}`)
86
+ if (!dirStat.isDirectory()) throw new CommandError(`不是目录: ${contextDir}`)
87
+
88
+ let file
89
+ if (payload.file !== undefined && payload.file !== null && String(payload.file).trim() !== '') {
90
+ const name = requireString(String(payload.file).trim(), 'file', { max: 1024 })
91
+ const resolved = resolve(contextDir, name)
92
+ const prefix = contextDir.endsWith(sep) ? contextDir : `${contextDir}${sep}`
93
+ if (resolved !== contextDir && !resolved.startsWith(prefix)) throw new CommandError('Dockerfile 必须位于构建目录内')
94
+ const fileStat = statOrNull(resolved)
95
+ if (fileStat === null || !fileStat.isFile()) throw new CommandError(`找不到 Dockerfile: ${name}`)
96
+ file = resolved
97
+ } else if (statOrNull(join(contextDir, 'Dockerfile')) === null && statOrNull(join(contextDir, 'Containerfile')) === null) {
98
+ throw new CommandError('构建目录里没有 Dockerfile 或 Containerfile,请选择其他目录或指定文件')
99
+ }
100
+
101
+ const platform = platformOf(payload.platform)
102
+ const buildArgs = list(payload.buildArgs, 'buildArgs').map((entry) => {
103
+ if (!BUILD_ARG.test(entry)) throw new CommandError(`构建参数格式不合法: ${entry}(示例 KEY=VALUE)`)
104
+ return entry
105
+ })
106
+ return { tag, contextDir, file, platform, noCache: payload.noCache === true, buildArgs }
107
+ }
108
+
109
+ /** Both `docker build` and `container build` accept this flag set. */
110
+ export function buildArgv(spec) {
111
+ return [
112
+ 'build',
113
+ '-t',
114
+ spec.tag,
115
+ ...(spec.file === undefined ? [] : ['-f', spec.file]),
116
+ ...(spec.platform === undefined ? [] : ['--platform', spec.platform]),
117
+ ...(spec.noCache ? ['--no-cache'] : []),
118
+ ...spec.buildArgs.flatMap((entry) => ['--build-arg', entry]),
119
+ spec.contextDir,
120
+ ]
121
+ }
122
+
123
+ /**
124
+ * Describe a candidate build directory so the form can validate before running.
125
+ * @param {unknown} value
126
+ */
127
+ export function inspectBuildContext(value) {
128
+ const path = typeof value === 'string' ? value.trim() : ''
129
+ const empty = { path, exists: false, isDirectory: false, dockerfiles: [], suggestedTag: '' }
130
+ if (path === '' || !isAbsolute(path) || path.includes('\0') || path.length > 4096) return empty
131
+ const stat = statOrNull(path)
132
+ if (stat === null) return empty
133
+ if (!stat.isDirectory()) return { ...empty, exists: true }
134
+ let names = []
135
+ try {
136
+ names = readdirSync(path).filter((name) => DOCKERFILE_NAME.test(name) && statOrNull(join(path, name))?.isFile())
137
+ } catch {
138
+ names = []
139
+ }
140
+ names.sort((a, b) => Number(b === 'Dockerfile') - Number(a === 'Dockerfile') || a.localeCompare(b))
141
+ const slug = basename(path).toLowerCase().replace(/[^a-z0-9._-]+/gu, '-').replace(/^[^a-z0-9]+|[^a-z0-9]+$/gu, '')
142
+ return { path, exists: true, isDirectory: true, dockerfiles: names.slice(0, 20), suggestedTag: slug === '' ? '' : `${slug}:latest` }
143
+ }
@@ -7,7 +7,7 @@
7
7
  * records live in this plugin's memory and disappear on unload.
8
8
  */
9
9
  import { homedir } from 'node:os'
10
- import { statSync } from 'node:fs'
10
+ import { readFileSync, statSync } from 'node:fs'
11
11
 
12
12
  import {
13
13
  CommandError,
@@ -23,6 +23,7 @@ import { AppleProvider } from './apple.js'
23
23
  import { AttachManager } from './attach.js'
24
24
  import { AppleCliError, AppleCommandError } from './apple-cli.js'
25
25
  import { DockerCli, DockerCliError, DockerCommandError } from './docker-cli.js'
26
+ import { LONG_JOB_MS, buildArgv, buildSpec, inspectBuildContext, platformOf, pushSpec } from './images.js'
26
27
  import { JobManager } from './jobs.js'
27
28
  import {
28
29
  firstLine,
@@ -41,6 +42,15 @@ import {
41
42
  import { createRuntimeResolver } from './runtime.js'
42
43
  import { codeOf, isRecord, messageOf, optionalInteger, requireString } from './util.js'
43
44
 
45
+ /** Reported by `ping`; read once so it always matches the published package. */
46
+ const PLUGIN_VERSION = (() => {
47
+ try {
48
+ return String(JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version)
49
+ } catch {
50
+ return 'unknown'
51
+ }
52
+ })()
53
+
44
54
  const STATUS_CACHE_MS = 1500
45
55
  const CONTEXT_CACHE_MS = 10_000
46
56
  const VALUE_CACHE_MS = 1500
@@ -189,12 +199,14 @@ export class DockerService {
189
199
  return ok({
190
200
  ok: true,
191
201
  plugin: 'dsh-plugin-container',
192
- version: '0.2.0',
202
+ version: PLUGIN_VERSION,
193
203
  dockerPath: this.cli.dockerPath ?? null,
194
204
  containerPath: this.apple.cli.containerPath ?? null,
195
205
  })
196
206
  case 'providers':
197
207
  return ok(await this.providers(signal))
208
+ case 'buildContext':
209
+ return ok(inspectBuildContext(payload.path))
198
210
  case 'status':
199
211
  case 'list':
200
212
  case 'inspect':
@@ -737,22 +749,28 @@ export class DockerService {
737
749
  }, signal)
738
750
  }
739
751
  if (kind === 'image' && action === 'build') {
740
- const tag = safeImageRef(payload.tag)
741
- const contextPath = resolveCwd(optionalString(payload.contextPath, 'contextPath') ?? this.home, { fallback: this.home, statSync })
742
- const dockerfile = optionalString(payload.dockerfile, 'dockerfile')
743
- if (dockerfile !== undefined && (dockerfile.includes('\0') || dockerfile.startsWith('-') || dockerfile.length > 4096)) {
744
- throw new CommandError('Dockerfile 路径不合法')
752
+ const spec = buildSpec(payload)
753
+ return this.run({
754
+ argv: buildArgv(spec),
755
+ context: payload.context,
756
+ cwd: spec.contextDir,
757
+ timeoutMs: LONG_JOB_MS,
758
+ title: payload.title ?? `构建镜像 ${spec.tag}`,
759
+ }, signal)
760
+ }
761
+ if (kind === 'image' && action === 'push') {
762
+ const { source, target } = pushSpec(payload)
763
+ if (target !== source) {
764
+ await this.cli.ensureDocker({ signal })
765
+ const resolved = await this.resolveContext(optionalString(payload.context, 'context'), signal)
766
+ await this.cli.docker(['tag', source, target], { context: resolved.name, signal, timeoutMs: 20_000 })
745
767
  }
746
- const argv = ['build', '--progress', 'plain', '-t', tag]
747
- if (dockerfile !== undefined && dockerfile !== '') argv.push('-f', dockerfile)
748
- if (payload.noCache === true) argv.push('--no-cache')
749
- argv.push(contextPath)
750
768
  return this.run({
751
- argv,
769
+ argv: ['push', target],
752
770
  context: payload.context,
753
- cwd: contextPath,
754
- timeoutMs: payload.timeoutMs ?? 15 * 60_000,
755
- title: payload.title ?? `构建镜像 ${tag}`,
771
+ cwd: payload.cwd,
772
+ timeoutMs: LONG_JOB_MS,
773
+ title: payload.title ?? `推送镜像 ${target}`,
756
774
  }, signal)
757
775
  }
758
776
  const id = safeIdentifier(payload.id ?? payload.reference)
@@ -764,8 +782,17 @@ export class DockerService {
764
782
  else if (action === 'delete') argv = ['rm', '-f', id]
765
783
  else if (action === 'logs') argv = ['logs', '--tail', payload.follow === true ? '100' : '500', ...(payload.follow === true ? ['-f'] : []), id]
766
784
  } else if (kind === 'image') {
767
- if (action === 'pull') argv = ['pull', id]
768
- else if (action === 'push') argv = ['push', id]
785
+ if (action === 'pull') {
786
+ const platform = platformOf(payload.platform)
787
+ argv = ['pull', ...(platform === undefined ? [] : ['--platform', platform]), id]
788
+ return this.run({
789
+ argv,
790
+ context: payload.context,
791
+ cwd: payload.cwd,
792
+ timeoutMs: LONG_JOB_MS,
793
+ title: payload.title ?? `拉取镜像 ${id}`,
794
+ }, signal)
795
+ }
769
796
  else if (action === 'delete') argv = ['rmi', id]
770
797
  } else if (kind === 'volume' && action === 'delete') {
771
798
  argv = ['volume', 'rm', id]