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.
- package/README.md +6 -4
- package/README.zh-CN.md +6 -4
- package/lib/client.js +1043 -682
- package/package.json +3 -2
- package/src/client/build-image.jsx +161 -56
- package/src/client/components.jsx +5 -0
- package/src/client/i18n.js +100 -22
- package/src/client/icons.jsx +12 -14
- package/src/client/index.jsx +15 -1
- package/src/client/page.jsx +73 -1
- package/src/client/platforms.js +2 -0
- package/src/client/push-image.jsx +113 -0
- package/src/client/styles.js +12 -1
- package/src/client/tabs/images.jsx +26 -20
- package/src/host/apple.js +13 -13
- package/src/host/images.js +143 -0
- package/src/host/service.js +44 -17
- package/src/host/tools.js +37 -48
|
@@ -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
|
+
}
|
package/src/client/styles.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 [
|
|
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
|
|
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
|
-
|
|
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('
|
|
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={
|
|
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('
|
|
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
|
-
|
|
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
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
if (
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
+
}
|
package/src/host/service.js
CHANGED
|
@@ -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:
|
|
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
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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:
|
|
754
|
-
timeoutMs:
|
|
755
|
-
title: payload.title ??
|
|
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')
|
|
768
|
-
|
|
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]
|