dsh-plugin-container 0.4.5 → 0.5.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.
@@ -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, 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,140 @@
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 contextDir = requireString(payload.contextDir, 'contextDir', { max: 4096 })
81
+ if (!isAbsolute(contextDir) || contextDir.includes('\0')) throw new CommandError('构建目录必须是绝对路径')
82
+ const dirStat = statOrNull(contextDir)
83
+ if (dirStat === null) throw new CommandError(`构建目录不存在: ${contextDir}`)
84
+ if (!dirStat.isDirectory()) throw new CommandError(`不是目录: ${contextDir}`)
85
+
86
+ let file
87
+ if (payload.file !== undefined && payload.file !== null && String(payload.file).trim() !== '') {
88
+ const name = requireString(String(payload.file).trim(), 'file', { max: 1024 })
89
+ const resolved = resolve(contextDir, name)
90
+ if (resolved !== contextDir && !resolved.startsWith(`${contextDir}${sep}`)) throw new CommandError('Dockerfile 必须位于构建目录内')
91
+ const fileStat = statOrNull(resolved)
92
+ if (fileStat === null || !fileStat.isFile()) throw new CommandError(`找不到 Dockerfile: ${name}`)
93
+ file = resolved
94
+ } else if (statOrNull(join(contextDir, 'Dockerfile')) === null && statOrNull(join(contextDir, 'Containerfile')) === null) {
95
+ throw new CommandError('构建目录里没有 Dockerfile 或 Containerfile,请选择其他目录或指定文件')
96
+ }
97
+
98
+ const platform = platformOf(payload.platform)
99
+ const buildArgs = list(payload.buildArgs, 'buildArgs').map((entry) => {
100
+ if (!BUILD_ARG.test(entry)) throw new CommandError(`构建参数格式不合法: ${entry}(示例 KEY=VALUE)`)
101
+ return entry
102
+ })
103
+ return { tag, contextDir, file, platform, noCache: payload.noCache === true, buildArgs }
104
+ }
105
+
106
+ /** Both `docker build` and `container build` accept this flag set. */
107
+ export function buildArgv(spec) {
108
+ return [
109
+ 'build',
110
+ '-t',
111
+ spec.tag,
112
+ ...(spec.file === undefined ? [] : ['-f', spec.file]),
113
+ ...(spec.platform === undefined ? [] : ['--platform', spec.platform]),
114
+ ...(spec.noCache ? ['--no-cache'] : []),
115
+ ...spec.buildArgs.flatMap((entry) => ['--build-arg', entry]),
116
+ spec.contextDir,
117
+ ]
118
+ }
119
+
120
+ /**
121
+ * Describe a candidate build directory so the form can validate before running.
122
+ * @param {unknown} value
123
+ */
124
+ export function inspectBuildContext(value) {
125
+ const path = typeof value === 'string' ? value.trim() : ''
126
+ const empty = { path, exists: false, isDirectory: false, dockerfiles: [], suggestedTag: '' }
127
+ if (path === '' || !isAbsolute(path) || path.includes('\0') || path.length > 4096) return empty
128
+ const stat = statOrNull(path)
129
+ if (stat === null) return empty
130
+ if (!stat.isDirectory()) return { ...empty, exists: true }
131
+ let names = []
132
+ try {
133
+ names = readdirSync(path).filter((name) => DOCKERFILE_NAME.test(name) && statOrNull(join(path, name))?.isFile())
134
+ } catch {
135
+ names = []
136
+ }
137
+ names.sort((a, b) => Number(b === 'Dockerfile') - Number(a === 'Dockerfile') || a.localeCompare(b))
138
+ const slug = basename(path).toLowerCase().replace(/[^a-z0-9._-]+/gu, '-').replace(/^[^a-z0-9]+|[^a-z0-9]+$/gu, '')
139
+ return { path, exists: true, isDirectory: true, dockerfiles: names.slice(0, 20), suggestedTag: slug === '' ? '' : `${slug}:latest` }
140
+ }
@@ -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,
@@ -195,6 +196,8 @@ export class DockerService {
195
196
  })
196
197
  case 'providers':
197
198
  return ok(await this.providers(signal))
199
+ case 'buildContext':
200
+ return ok(inspectBuildContext(payload.path))
198
201
  case 'status':
199
202
  case 'list':
200
203
  case 'inspect':
@@ -737,22 +740,28 @@ export class DockerService {
737
740
  }, signal)
738
741
  }
739
742
  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 路径不合法')
743
+ const spec = buildSpec(payload)
744
+ return this.run({
745
+ argv: buildArgv(spec),
746
+ context: payload.context,
747
+ cwd: spec.contextDir,
748
+ timeoutMs: LONG_JOB_MS,
749
+ title: payload.title ?? `构建镜像 ${spec.tag}`,
750
+ }, signal)
751
+ }
752
+ if (kind === 'image' && action === 'push') {
753
+ const { source, target } = pushSpec(payload)
754
+ if (target !== source) {
755
+ await this.cli.ensureDocker({ signal })
756
+ const resolved = await this.resolveContext(optionalString(payload.context, 'context'), signal)
757
+ await this.cli.docker(['tag', source, target], { context: resolved.name, signal, timeoutMs: 20_000 })
745
758
  }
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
759
  return this.run({
751
- argv,
760
+ argv: ['push', target],
752
761
  context: payload.context,
753
- cwd: contextPath,
754
- timeoutMs: payload.timeoutMs ?? 15 * 60_000,
755
- title: payload.title ?? `构建镜像 ${tag}`,
762
+ cwd: payload.cwd,
763
+ timeoutMs: LONG_JOB_MS,
764
+ title: payload.title ?? `推送镜像 ${target}`,
756
765
  }, signal)
757
766
  }
758
767
  const id = safeIdentifier(payload.id ?? payload.reference)
@@ -764,8 +773,10 @@ export class DockerService {
764
773
  else if (action === 'delete') argv = ['rm', '-f', id]
765
774
  else if (action === 'logs') argv = ['logs', '--tail', payload.follow === true ? '100' : '500', ...(payload.follow === true ? ['-f'] : []), id]
766
775
  } else if (kind === 'image') {
767
- if (action === 'pull') argv = ['pull', id]
768
- else if (action === 'push') argv = ['push', id]
776
+ if (action === 'pull') {
777
+ const platform = platformOf(payload.platform)
778
+ argv = ['pull', ...(platform === undefined ? [] : ['--platform', platform]), id]
779
+ }
769
780
  else if (action === 'delete') argv = ['rmi', id]
770
781
  } else if (kind === 'volume' && action === 'delete') {
771
782
  argv = ['volume', 'rm', id]