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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +341 -2
  3. package/cordis.patch.yml +3 -0
  4. package/docs/ai-chat.png +0 -0
  5. package/docs/ai-new-session.png +0 -0
  6. package/docs/apple-container.png +0 -0
  7. package/docs/attach.png +0 -0
  8. package/docs/docker-console.png +0 -0
  9. package/docs/docker-overview.png +0 -0
  10. package/docs/docker-volumes.png +0 -0
  11. package/docs/image-architecture.png +0 -0
  12. package/docs/package-install.png +0 -0
  13. package/docs/provider-switch.png +0 -0
  14. package/docs/run-container.png +0 -0
  15. package/index.js +138 -0
  16. package/lib/client.js +3499 -0
  17. package/package.json +62 -4
  18. package/src/client/api.js +94 -0
  19. package/src/client/attach.jsx +133 -0
  20. package/src/client/components.jsx +301 -0
  21. package/src/client/hooks.js +161 -0
  22. package/src/client/i18n.js +496 -0
  23. package/src/client/icons.jsx +219 -0
  24. package/src/client/index.jsx +76 -0
  25. package/src/client/page.jsx +755 -0
  26. package/src/client/run-container.jsx +187 -0
  27. package/src/client/runs.js +182 -0
  28. package/src/client/store.js +91 -0
  29. package/src/client/styles.js +363 -0
  30. package/src/client/tabs/compose.jsx +94 -0
  31. package/src/client/tabs/console.jsx +211 -0
  32. package/src/client/tabs/containers.jsx +143 -0
  33. package/src/client/tabs/images.jsx +127 -0
  34. package/src/client/tabs/networks.jsx +91 -0
  35. package/src/client/tabs/overview.jsx +266 -0
  36. package/src/client/tabs/volumes.jsx +76 -0
  37. package/src/host/apple-cli.js +307 -0
  38. package/src/host/apple.js +471 -0
  39. package/src/host/attach.js +243 -0
  40. package/src/host/commands.js +235 -0
  41. package/src/host/docker-cli.js +405 -0
  42. package/src/host/jobs.js +245 -0
  43. package/src/host/parsers.js +484 -0
  44. package/src/host/runtime.js +285 -0
  45. package/src/host/service.js +832 -0
  46. package/src/host/tools.js +198 -0
  47. package/src/host/util.js +97 -0
@@ -0,0 +1,266 @@
1
+ /** Docker overview: runtime, counts, recent containers, disk, quick commands. */
2
+ import { useMemo } from 'react'
3
+
4
+ import {
5
+ Button,
6
+ Chips,
7
+ EmptyState,
8
+ ErrorState,
9
+ LoadingSkeleton,
10
+ Spinner,
11
+ StateLabel,
12
+ displayValue,
13
+ formatBytes,
14
+ formatTime,
15
+ } from '../components.jsx'
16
+ import { useDockerList } from '../hooks.js'
17
+ import { IconBox, IconImage, IconPlay, IconRestart, IconStop, IconTerminal } from '../icons.jsx'
18
+
19
+ function Stat({ label, value, detail }) {
20
+ return (
21
+ <div className="dsh-docker-stat">
22
+ <div className="dsh-docker-stat-label">{label}</div>
23
+ <div className="dsh-docker-stat-value">{value}</div>
24
+ {detail !== undefined && <div className="dsh-docker-stat-detail">{detail}</div>}
25
+ </div>
26
+ )
27
+ }
28
+
29
+ function RuntimeCard({ t, provider, status, imageCount, volumeCount, networkCount, onRuntime, busy }) {
30
+ const runtime = status.runtime
31
+ const details = runtime?.details ?? {}
32
+ const isApple = provider === 'apple-container'
33
+ const rows = (isApple ? [
34
+ [t('providerLabel'), t('providerApple')],
35
+ [t('systemService'), details.status],
36
+ [t('client'), status.client?.version],
37
+ [t('server'), status.server?.version],
38
+ [t('containers'), status.server?.containers],
39
+ [t('images'), imageCount],
40
+ [t('volumes'), volumeCount],
41
+ [t('networks'), networkCount],
42
+ [t('cpus'), status.server?.ncpu !== undefined ? String(status.server.ncpu) : undefined],
43
+ [t('memory'), status.server?.memTotal !== undefined ? formatBytes(status.server.memTotal) : undefined],
44
+ ] : [
45
+ [t('endpoint'), status.context?.endpoint],
46
+ [t('profile'), runtime?.profile],
47
+ [t('driver'), runtime?.driver ?? details.driver],
48
+ [t('arch'), runtime?.arch ?? details.arch],
49
+ [t('mountType'), runtime?.mountType ?? details.mountType],
50
+ [t('kubernetes'), runtime?.kubernetes === undefined ? undefined : runtime.kubernetes ? t('yes') : t('no')],
51
+ [t('cpus'), runtime?.cpus !== undefined ? String(runtime.cpus) : undefined],
52
+ [t('memory'), runtime?.memory !== undefined ? formatBytes(runtime.memory) : undefined],
53
+ [t('disk'), runtime?.disk !== undefined ? formatBytes(runtime.disk) : undefined],
54
+ ]).filter(([, value]) => value !== undefined && value !== null && value !== '')
55
+ return (
56
+ <section className="dsh-docker-card">
57
+ <div className="dsh-docker-card-head">
58
+ <div>
59
+ <h2 className="dsh-docker-card-title">{t('runtime')}</h2>
60
+ <p className="dsh-docker-card-sub">{runtime?.kind ?? ''}</p>
61
+ </div>
62
+ <span className="dsh-docker-status" data-state={runtime?.running ? 'running' : 'stopped'}>
63
+ <span className="dsh-docker-dot" />
64
+ {runtime?.running ? t('statusRunning') : t('statusStopped')}
65
+ </span>
66
+ </div>
67
+ <div className="dsh-docker-card-body">
68
+ <div className="dsh-docker-runtime-head">
69
+ <span className="dsh-docker-mark" style={{ width: 34, height: 34 }}><IconBox size={19} /></span>
70
+ <div>
71
+ <div className="dsh-docker-runtime-name">{runtime?.label ?? t('unknownRuntime')}</div>
72
+ <div className="dsh-docker-muted">{isApple ? 'container · per-container VM' : runtime?.kind === 'colima' ? `colima · profile ${runtime.profile ?? 'default'}` : (runtime?.kind ?? '')}</div>
73
+ </div>
74
+ </div>
75
+ <div className="dsh-docker-kv">
76
+ {rows.map(([key, value]) => (
77
+ <div key={key} className="dsh-docker-kv-row" style={{ display: 'contents' }}>
78
+ <div className="dsh-docker-kv-key">{key}</div>
79
+ <div className="dsh-docker-kv-value">{displayValue(value)}</div>
80
+ </div>
81
+ ))}
82
+ </div>
83
+ <div className="dsh-docker-middle" style={{ marginTop: 14 }}>
84
+ {runtime?.canStart === true && (
85
+ <Button size="sm" variant="primary" onClick={() => onRuntime('start')} disabled={busy}>
86
+ {busy ? <Spinner size={13} /> : <IconPlay size={13} />} {t('start')}
87
+ </Button>
88
+ )}
89
+ {runtime?.canStop === true && (
90
+ <Button size="sm" onClick={() => onRuntime('stop')} disabled={busy}>
91
+ <IconStop size={13} /> {t('stop')}
92
+ </Button>
93
+ )}
94
+ {runtime?.canRestart === true && (
95
+ <Button size="sm" onClick={() => onRuntime('restart')} disabled={busy}>
96
+ <IconRestart size={13} /> {t('restart')}
97
+ </Button>
98
+ )}
99
+ {runtime?.canStart !== true && runtime?.canStop !== true && runtime?.canRestart !== true && (
100
+ <span className="dsh-docker-muted">{status.capabilities?.enableRuntimeControl === false ? t('runtimeControlDisabled') : runtime?.error ?? t('remoteHint')}</span>
101
+ )}
102
+ </div>
103
+ </div>
104
+ </section>
105
+ )
106
+ }
107
+
108
+ function RecentContainers({ t, list, onOpenTab, onInspect }) {
109
+ if (list.loading && list.data === null) return <LoadingSkeleton rows={4} />
110
+ if (list.error !== null && list.data === null) {
111
+ return <ErrorState title={t('listFailed')} text={list.error.message} retryLabel={t('retry')} onRetry={list.reload} />
112
+ }
113
+ const items = (list.data?.items ?? []).slice(0, 5)
114
+ if (items.length === 0) {
115
+ return <EmptyState icon={<IconBox size={19} />} title={t('emptyContainers')} />
116
+ }
117
+ return (
118
+ <div className="dsh-docker-table-wrap">
119
+ <table className="dsh-docker-table">
120
+ <thead>
121
+ <tr>
122
+ <th>{t('name')}</th>
123
+ <th>{t('image')}</th>
124
+ <th>{t('status')}</th>
125
+ <th>{t('ports')}</th>
126
+ </tr>
127
+ </thead>
128
+ <tbody>
129
+ {items.map((item) => (
130
+ <tr key={item.id} onClick={() => onInspect('container', item.id)} style={{ cursor: 'pointer' }}>
131
+ <td><span className="dsh-docker-row-title">{item.name || item.id}</span></td>
132
+ <td className="dsh-docker-mono dsh-docker-truncate">{item.image}</td>
133
+ <td><StateLabel state={item.state} t={t} /></td>
134
+ <td><Chips values={item.ports.slice(0, 2)} mono /></td>
135
+ </tr>
136
+ ))}
137
+ </tbody>
138
+ </table>
139
+ <div style={{ display: 'flex', justifyContent: 'flex-end', padding: '8px 12px' }}>
140
+ <Button size="sm" variant="ghost" onClick={() => onOpenTab('containers')}>{t('viewAll')}</Button>
141
+ </div>
142
+ </div>
143
+ )
144
+ }
145
+
146
+ function DiskCard({ t, list }) {
147
+ if (list.loading && list.data === null) return <LoadingSkeleton rows={3} />
148
+ if (list.error !== null && list.data === null) return <div className="dsh-docker-muted" style={{ padding: 16 }}>{list.error.message}</div>
149
+ const items = list.data?.items ?? []
150
+ if (items.length === 0) return <div className="dsh-docker-muted" style={{ padding: 16 }}>{t('noDiskData')}</div>
151
+ return (
152
+ <div className="dsh-docker-table-wrap">
153
+ <table className="dsh-docker-table">
154
+ <thead>
155
+ <tr>
156
+ <th>{t('status')}</th>
157
+ <th className="dsh-docker-col-num">{t('size')}</th>
158
+ <th className="dsh-docker-col-num">Reclaimable</th>
159
+ </tr>
160
+ </thead>
161
+ <tbody>
162
+ {items.map((item) => (
163
+ <tr key={item.type}>
164
+ <td>{item.type}</td>
165
+ <td className="dsh-docker-col-num">{displayValue(item.size)}</td>
166
+ <td className="dsh-docker-col-num">{displayValue(item.reclaimable)}</td>
167
+ </tr>
168
+ ))}
169
+ </tbody>
170
+ </table>
171
+ </div>
172
+ )
173
+ }
174
+
175
+ export default function OverviewTab({ t, api, provider, context, status, refreshToken, onRuntime, onOpenTab, onInspect, onQuickCommand, runState }) {
176
+ const enabled = status?.daemon?.state === 'running'
177
+ const containers = useDockerList(api, provider, 'containers', context, { all: true, refreshToken, enabled })
178
+ const images = useDockerList(api, provider, 'images', context, { refreshToken, enabled })
179
+ const volumes = useDockerList(api, provider, 'volumes', context, { refreshToken, enabled })
180
+ const networks = useDockerList(api, provider, 'networks', context, { refreshToken, enabled })
181
+ const disk = useDockerList(api, provider, 'disk', context, { refreshToken, enabled })
182
+ const busy = runState?.run !== null && runState?.run !== undefined && (runState.run.status === 'running' || runState.run.status === 'cancelling')
183
+ const server = status?.server
184
+ const isApple = provider === 'apple-container'
185
+ const quick = isApple ? ['ls -a', 'image ls', 'volume ls', 'network ls', 'system df', 'system version'] : ['ps -a', 'images', 'volume ls', 'network ls', 'compose ls', 'system df', 'info', 'version']
186
+ const commandPrefix = isApple ? 'container' : 'docker'
187
+ const recent = useMemo(() => (containers.data?.items ?? []).slice(0, 5), [containers.data])
188
+
189
+ const volumeCount = volumes.data?.items?.length
190
+ const networkCount = networks.data?.items?.length
191
+ const imageCount = images.data?.items?.length ?? server?.images
192
+
193
+ return (
194
+ <div className="dsh-docker-stack">
195
+ <div className="dsh-docker-stats">
196
+ <Stat label={t('runningContainers')} value={server?.containersRunning ?? '—'} detail={isApple ? t('providerApple') : context ?? t('noContext')} />
197
+ <Stat label={t('stoppedContainers')} value={server?.containersStopped ?? '—'} />
198
+ <Stat label={t('imageCount')} value={imageCount ?? '—'} />
199
+ <Stat label={t('volumeCount')} value={volumeCount ?? '—'} />
200
+ <Stat label={t('networkCount')} value={networkCount ?? '—'} />
201
+ <Stat
202
+ label={t('server')}
203
+ value={provider === 'apple-container' ? status?.client?.version ?? server?.version ?? '—' : server?.version ?? status?.client?.version ?? '—'}
204
+ detail={server?.ncpu !== undefined ? `${server.ncpu} CPU · ${formatBytes(server.memTotal)}` : undefined}
205
+ />
206
+ </div>
207
+
208
+ <div className="dsh-docker-grid-2">
209
+ <RuntimeCard t={t} provider={provider} status={status} imageCount={imageCount} volumeCount={volumeCount} networkCount={networkCount} onRuntime={onRuntime} busy={busy} />
210
+ <section className="dsh-docker-card">
211
+ <div className="dsh-docker-card-head">
212
+ <div>
213
+ <h2 className="dsh-docker-card-title">{t('quickCommands')}</h2>
214
+ <p className="dsh-docker-card-sub">{t('terminal')}</p>
215
+ </div>
216
+ <IconTerminal size={16} className="dsh-docker-muted" />
217
+ </div>
218
+ <div className="dsh-docker-card-body">
219
+ <div className="dsh-docker-chip-row">
220
+ {quick.map((command) => (
221
+ <button key={command} type="button" className="dsh-docker-quick" onClick={() => onQuickCommand(`${commandPrefix} ${command}`)}>
222
+ {command}
223
+ </button>
224
+ ))}
225
+ </div>
226
+ <div className="dsh-docker-divider" />
227
+ <div className="dsh-docker-kv">
228
+ <div className="dsh-docker-kv-key">{t('client')}</div>
229
+ <div className="dsh-docker-kv-value">{status?.client?.version ?? '—'} {status?.cli?.path ? <span className="dsh-docker-mono dsh-docker-muted"> · {status.cli.path}</span> : null}</div>
230
+ <div className="dsh-docker-kv-key">{isApple ? t('providerLabel') : t('context')}</div>
231
+ <div className="dsh-docker-kv-value">{isApple ? t('providerApple') : context ?? status?.context?.selected ?? status?.context?.current ?? t('noContext')}</div>
232
+ <div className="dsh-docker-kv-key">{t('lastUpdated')}</div>
233
+ <div className="dsh-docker-kv-value">{formatTime(status?.fetchedAt)}</div>
234
+ </div>
235
+ </div>
236
+ </section>
237
+ </div>
238
+
239
+ <div className="dsh-docker-grid-2">
240
+ <section className="dsh-docker-card">
241
+ <div className="dsh-docker-card-head">
242
+ <div>
243
+ <h2 className="dsh-docker-card-title">{t('recentContainers')}</h2>
244
+ </div>
245
+ <Button size="sm" variant="ghost" onClick={() => onOpenTab('containers')}>{t('viewAll')}</Button>
246
+ </div>
247
+ <div className="dsh-docker-card-body dsh-docker-flush" style={{ paddingTop: 10 }}>
248
+ <RecentContainers t={t} list={{ ...containers, data: containers.data ?? { items: recent } }} onOpenTab={onOpenTab} onInspect={onInspect} />
249
+ </div>
250
+ </section>
251
+ <section className="dsh-docker-card">
252
+ <div className="dsh-docker-card-head">
253
+ <div>
254
+ <h2 className="dsh-docker-card-title">{t('disk')}</h2>
255
+ <p className="dsh-docker-card-sub">{commandPrefix} system df</p>
256
+ </div>
257
+ <IconImage size={16} className="dsh-docker-muted" />
258
+ </div>
259
+ <div className="dsh-docker-card-body dsh-docker-flush" style={{ paddingTop: 10 }}>
260
+ <DiskCard t={t} list={disk} />
261
+ </div>
262
+ </section>
263
+ </div>
264
+ </div>
265
+ )
266
+ }
@@ -0,0 +1,76 @@
1
+ /** Volumes tab. */
2
+ import { useMemo, useState } from 'react'
3
+
4
+ import { Button, EmptyState, ErrorState, IconButton, LoadingSkeleton, SearchInput, displayValue } from '../components.jsx'
5
+ import { useDockerList } from '../hooks.js'
6
+ import { IconAlert, IconInfo, IconTrash, IconVolume } from '../icons.jsx'
7
+
8
+ export default function VolumesTab({ t, api, provider, context, status, refreshToken, onAction, onInspect }) {
9
+ const [search, setSearch] = useState('')
10
+ const enabled = status?.daemon?.state === 'running'
11
+ const list = useDockerList(api, provider, 'volumes', context, { refreshToken, enabled })
12
+ const items = list.data?.items ?? []
13
+ const filtered = useMemo(() => {
14
+ const needle = search.trim().toLowerCase()
15
+ return needle === '' ? items : items.filter((item) => `${item.name} ${item.driver} ${item.mountpoint}`.toLowerCase().includes(needle))
16
+ }, [items, search])
17
+
18
+ return (
19
+ <div className="dsh-docker-stack">
20
+ <div className="dsh-docker-toolbar">
21
+ <div className="dsh-docker-toolbar-left">
22
+ <SearchInput value={search} onChange={setSearch} placeholder={t('search')} label={t('search')} />
23
+ </div>
24
+ <div className="dsh-docker-toolbar-right">
25
+ <span className="dsh-docker-muted">{filtered.length} / {items.length}</span>
26
+ <Button size="sm" onClick={list.reload} disabled={list.loading}>{t('refresh')}</Button>
27
+ </div>
28
+ </div>
29
+ {!enabled && items.length === 0 ? (
30
+ <EmptyState icon={<IconAlert size={20} />} title={provider === 'apple-container' ? t('appleSystemStopped') : t('daemonStopped')} text={provider === 'apple-container' ? t('appleSystemStoppedBody') : t('daemonStoppedBody')} />
31
+ ) : list.loading && list.data === null ? (
32
+ <LoadingSkeleton rows={5} />
33
+ ) : list.error !== null && list.data === null ? (
34
+ <ErrorState title={t('listFailed')} text={list.error.message} retryLabel={t('retry')} onRetry={list.reload} />
35
+ ) : filtered.length === 0 ? (
36
+ <EmptyState icon={<IconVolume size={20} />} title={search === '' ? t('emptyVolumes') : t('emptySearch')} text={search === '' ? undefined : search} />
37
+ ) : (
38
+ <section className="dsh-docker-card">
39
+ <div className="dsh-docker-table-wrap">
40
+ <table className="dsh-docker-table">
41
+ <thead>
42
+ <tr>
43
+ <th>{t('name')}</th>
44
+ <th>{t('driver')}</th>
45
+ <th>{t('scope')}</th>
46
+ <th>{t('mountpoint')}</th>
47
+ <th className="dsh-docker-col-actions">{t('actions')}</th>
48
+ </tr>
49
+ </thead>
50
+ <tbody>
51
+ {filtered.map((item) => (
52
+ <tr key={item.name}>
53
+ <td className="dsh-docker-mono dsh-docker-row-title">{item.name}</td>
54
+ <td>{displayValue(item.driver)}</td>
55
+ <td>{displayValue(item.scope)}</td>
56
+ <td className="dsh-docker-mono dsh-docker-muted dsh-docker-truncate" title={item.mountpoint}>{displayValue(item.mountpoint)}</td>
57
+ <td className="dsh-docker-col-actions">
58
+ <span className="dsh-docker-row-actions">
59
+ <IconButton size="sm" label={t('inspect')} onClick={() => onInspect('volume', item.name)}>
60
+ <IconInfo size={14} />
61
+ </IconButton>
62
+ <IconButton size="sm" label={t('removeVolume')} onClick={() => onAction({ kind: 'volume', action: 'delete', id: item.name })}>
63
+ <IconTrash size={14} />
64
+ </IconButton>
65
+ </span>
66
+ </td>
67
+ </tr>
68
+ ))}
69
+ </tbody>
70
+ </table>
71
+ </div>
72
+ </section>
73
+ )}
74
+ </div>
75
+ )
76
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Apple `container` CLI discovery and process execution.
3
+ *
4
+ * Apple container is not Docker: there is no daemon socket, no Docker context,
5
+ * and every container runs in its own lightweight VM. The CLI is invoked
6
+ * directly and its `--format json` output is parsed by the provider.
7
+ */
8
+ import { accessSync, constants, statSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
11
+
12
+ import { summarizeStderr } from './parsers.js'
13
+ import { codeOf, messageOf } from './util.js'
14
+
15
+ const DEFAULT_MAX_OUTPUT = 1024 * 1024
16
+ const DEFAULT_TIMEOUT = 20_000
17
+ const RESOLVE_RETRY_MS = 10_000
18
+
19
+ export class AppleCliError extends Error {
20
+ /** @param {string} message @param {string} code @param {Record<string, unknown>} details */
21
+ constructor(message, code = 'container/cli-missing', details = {}) {
22
+ super(message)
23
+ this.name = 'AppleCliError'
24
+ this.code = code
25
+ this.details = details
26
+ }
27
+ }
28
+
29
+ export class AppleCommandError extends Error {
30
+ /** @param {string} message @param {{code?: string, stderr?: string, details?: Record<string, unknown>}} [options] */
31
+ constructor(message, options = {}) {
32
+ super(message)
33
+ this.name = 'AppleCommandError'
34
+ this.code = options.code ?? 'container/exec-failed'
35
+ this.stderr = options.stderr ?? ''
36
+ this.details = options.details ?? {}
37
+ }
38
+ }
39
+
40
+ /** @param {string} path */
41
+ function executable(path) {
42
+ try {
43
+ accessSync(path, constants.X_OK)
44
+ return statSync(path).isFile()
45
+ } catch {
46
+ return false
47
+ }
48
+ }
49
+
50
+ /** @param {number} value @param {number} fallback */
51
+ function clampBytes(value, fallback) {
52
+ return Number.isFinite(value) ? Math.min(Math.max(Math.trunc(value), 64 * 1024), 8 * 1024 * 1024) : fallback
53
+ }
54
+
55
+ export class AppleCli {
56
+ /**
57
+ * @param {{
58
+ * subprocess: any,
59
+ * config?: Record<string, any>,
60
+ * env?: NodeJS.ProcessEnv,
61
+ * home?: string,
62
+ * platform?: NodeJS.Platform,
63
+ * logger?: any,
64
+ * }} options
65
+ */
66
+ constructor(options) {
67
+ this.subprocess = options.subprocess
68
+ this.config = options.config ?? {}
69
+ this.env = options.env ?? process.env
70
+ this.home = options.home ?? homedir()
71
+ this.platform = options.platform ?? process.platform
72
+ this.logger = options.logger
73
+ this.maxOutput = clampBytes(Number(this.config.maxOutputBytes), DEFAULT_MAX_OUTPUT)
74
+ this.containerPath = undefined
75
+ this.lastAttempt = 0
76
+ this.error = undefined
77
+ }
78
+
79
+ /** @returns {string[]} */
80
+ candidates() {
81
+ const configured = typeof this.config.containerPath === 'string' && this.config.containerPath.trim() !== '' ? this.config.containerPath.trim() : null
82
+ const envPath = typeof this.env.CONTAINER_PATH === 'string' && this.env.CONTAINER_PATH.trim() !== '' ? this.env.CONTAINER_PATH.trim() : null
83
+ return [
84
+ configured,
85
+ envPath,
86
+ '/usr/local/bin/container',
87
+ '/opt/homebrew/bin/container',
88
+ join(this.home, '.local/bin/container'),
89
+ ].filter((value, index, list) => typeof value === 'string' && value !== '' && list.indexOf(value) === index)
90
+ }
91
+
92
+ /** @param {AbortSignal | undefined} signal */
93
+ async resolveOnPath(signal) {
94
+ if (typeof this.subprocess.resolveExecutable !== 'function') return undefined
95
+ try {
96
+ return await this.subprocess.resolveExecutable('container', undefined, signal)
97
+ } catch (error) {
98
+ if (error?.name === 'SubprocessExecutableNotFoundError' || codeOf(error) === 'ENOENT') return undefined
99
+ throw error
100
+ }
101
+ }
102
+
103
+ /**
104
+ * @param {{signal?: AbortSignal, force?: boolean}} [options]
105
+ * @returns {Promise<string>}
106
+ */
107
+ async ensureContainer(options = {}) {
108
+ if (this.containerPath !== undefined) return this.containerPath
109
+ const now = Date.now()
110
+ if (!options.force && now - this.lastAttempt < RESOLVE_RETRY_MS && this.error !== undefined) throw this.error
111
+ this.lastAttempt = now
112
+ const configured = typeof this.config.containerPath === 'string' && this.config.containerPath.trim() !== '' ? this.config.containerPath.trim() : null
113
+ if (configured !== null) {
114
+ const path = isAbsolute(configured) ? configured : resolve(this.home, configured)
115
+ if (executable(path)) {
116
+ this.containerPath = path
117
+ this.error = undefined
118
+ return path
119
+ }
120
+ const error = new AppleCliError(`配置的 containerPath 不可执行: ${configured}`, 'container/cli-missing', { candidates: [configured] })
121
+ this.error = error
122
+ throw error
123
+ }
124
+ for (const candidate of this.candidates()) {
125
+ const path = isAbsolute(candidate) ? candidate : resolve(this.home, candidate)
126
+ if (executable(path)) {
127
+ this.containerPath = path
128
+ this.error = undefined
129
+ return path
130
+ }
131
+ }
132
+ const onPath = await this.resolveOnPath(options.signal)
133
+ if (onPath !== undefined && executable(onPath)) {
134
+ this.containerPath = onPath
135
+ this.error = undefined
136
+ return onPath
137
+ }
138
+ const error = new AppleCliError(
139
+ '未找到 Apple container 命令。请安装 container(macOS 26 / Apple silicon)并执行 container system start。',
140
+ 'container/cli-missing',
141
+ { candidates: this.candidates() },
142
+ )
143
+ this.error = error
144
+ throw error
145
+ }
146
+
147
+ /** @returns {NodeJS.ProcessEnv} */
148
+ childEnv() {
149
+ const pathParts = []
150
+ if (typeof this.containerPath === 'string') pathParts.push(dirname(this.containerPath))
151
+ pathParts.push('/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin')
152
+ if (typeof this.env.PATH === 'string') pathParts.push(...this.env.PATH.split(':'))
153
+ return {
154
+ PATH: [...new Set(pathParts.filter(Boolean))].join(':'),
155
+ NO_COLOR: '1',
156
+ CLICOLOR: '0',
157
+ TERM: 'dumb',
158
+ CONTAINER_DEBUG: '0',
159
+ }
160
+ }
161
+
162
+ /** @param {string} path */
163
+ markMissing(path) {
164
+ if (this.containerPath === path) {
165
+ this.containerPath = undefined
166
+ this.error = undefined
167
+ this.lastAttempt = 0
168
+ }
169
+ }
170
+
171
+ /**
172
+ * @param {string[]} subargv
173
+ * @param {{cwd?: string, timeoutMs?: number, signal?: AbortSignal, maxBytes?: number}} [options]
174
+ */
175
+ async exec(subargv, options = {}) {
176
+ const path = await this.ensureContainer({ signal: options.signal })
177
+ const maxBytes = clampBytes(Number(options.maxBytes ?? this.maxOutput), this.maxOutput)
178
+ const timeoutMs = Math.min(Math.max(Number(options.timeoutMs ?? DEFAULT_TIMEOUT), 1000), 10 * 60_000)
179
+ const abort = new AbortController()
180
+ let timedOut = false
181
+ const onAbort = () => abort.abort(options.signal?.reason)
182
+ options.signal?.addEventListener('abort', onAbort, { once: true })
183
+ if (options.signal?.aborted === true) abort.abort(options.signal.reason)
184
+ const timeout = setTimeout(() => {
185
+ timedOut = true
186
+ abort.abort(new Error('container command timed out'))
187
+ }, timeoutMs)
188
+ timeout.unref?.()
189
+
190
+ let handle
191
+ try {
192
+ handle = this.subprocess.spawn({
193
+ argv: [path, ...subargv],
194
+ cwd: options.cwd ?? this.home,
195
+ stdio: { stdin: 'ignore', stdout: { maxBytes }, stderr: { maxBytes } },
196
+ graceMs: 2000,
197
+ env: this.childEnv(),
198
+ signal: abort.signal,
199
+ })
200
+ } catch (error) {
201
+ clearTimeout(timeout)
202
+ options.signal?.removeEventListener('abort', onAbort)
203
+ throw new AppleCommandError(`无法启动 container: ${messageOf(error)}`, { code: 'container/exec-failed' })
204
+ }
205
+
206
+ let outcome
207
+ try {
208
+ outcome = await handle.done
209
+ } catch (error) {
210
+ clearTimeout(timeout)
211
+ options.signal?.removeEventListener('abort', onAbort)
212
+ this.markMissing(path)
213
+ throw new AppleCommandError(`container 进程失败: ${messageOf(error)}`, { code: 'container/exec-failed' })
214
+ }
215
+ clearTimeout(timeout)
216
+ options.signal?.removeEventListener('abort', onAbort)
217
+ const stdout = handle.collected?.stdout?.readFrom(0)?.text ?? ''
218
+ const stderr = handle.collected?.stderr?.readFrom(0)?.text ?? ''
219
+ return { exitCode: outcome.exitCode, signal: outcome.signal, stdout, stderr, timedOut, argv: [path, ...subargv] }
220
+ }
221
+
222
+ /**
223
+ * Execute and require a zero exit code.
224
+ * @param {string[]} subargv
225
+ * @param {{cwd?: string, timeoutMs?: number, signal?: AbortSignal, maxBytes?: number}} [options]
226
+ */
227
+ async ok(subargv, options = {}) {
228
+ const result = await this.exec(subargv, options)
229
+ if (result.timedOut || result.exitCode !== 0) {
230
+ const detail = summarizeStderr(result.stderr)
231
+ throw new AppleCommandError(
232
+ result.timedOut ? `container ${subargv[0] ?? ''} 超时` : detail.trim() === '' ? `container ${subargv[0] ?? ''} 退出码 ${String(result.exitCode)}` : detail,
233
+ {
234
+ code: result.timedOut ? 'container/timeout' : 'container/exec-failed',
235
+ stderr: result.stderr,
236
+ details: { argv: result.argv, exitCode: result.exitCode, timedOut: result.timedOut },
237
+ },
238
+ )
239
+ }
240
+ return result
241
+ }
242
+
243
+ /**
244
+ * @param {string[]} subargv
245
+ * @param {{cwd?: string, timeoutMs?: number, signal?: AbortSignal, maxBytes?: number}} [options]
246
+ */
247
+ async json(subargv, options = {}) {
248
+ const result = await this.ok(subargv, options)
249
+ try {
250
+ return JSON.parse(result.stdout)
251
+ } catch (error) {
252
+ throw new AppleCommandError(`无法解析 container JSON: ${messageOf(error)}`, {
253
+ code: 'container/protocol',
254
+ stderr: result.stderr,
255
+ details: { stdout: result.stdout.slice(0, 2000) },
256
+ })
257
+ }
258
+ }
259
+
260
+ /** @param {AbortSignal | undefined} signal */
261
+ async systemStatus(signal) {
262
+ try {
263
+ const result = await this.exec(['system', 'status', '--format', 'json'], { signal, timeoutMs: 10_000 })
264
+ if (result.exitCode !== 0) {
265
+ return { status: 'unavailable', error: summarizeStderr(result.stderr) || `exit ${String(result.exitCode)}`, raw: null }
266
+ }
267
+ return JSON.parse(result.stdout)
268
+ } catch (error) {
269
+ return { status: 'unavailable', error: messageOf(error), raw: null }
270
+ }
271
+ }
272
+
273
+ /** @param {AbortSignal | undefined} signal */
274
+ async systemVersion(signal) {
275
+ try {
276
+ const raw = await this.json(['system', 'version', '--format', 'json'], { signal, timeoutMs: 10_000 })
277
+ return Array.isArray(raw) ? raw : [raw]
278
+ } catch {
279
+ return []
280
+ }
281
+ }
282
+
283
+ /** @param {AbortSignal | undefined} signal */
284
+ async df(signal) {
285
+ return this.json(['system', 'df', '--format', 'json'], { signal, timeoutMs: 30_000 })
286
+ }
287
+
288
+ /**
289
+ * `container system status` has reported `unregistered` on installs where the
290
+ * apiserver is still reachable; a lightweight list is the authoritative
291
+ * health probe. Returns undefined when the service is not reachable.
292
+ * @param {AbortSignal | undefined} signal
293
+ */
294
+ async probe(signal) {
295
+ try {
296
+ const raw = await this.json(['list', '-a', '--format', 'json'], { signal, timeoutMs: 10_000 })
297
+ return Array.isArray(raw) ? raw : []
298
+ } catch {
299
+ return undefined
300
+ }
301
+ }
302
+
303
+ /** @param {string} path */
304
+ static commandName(path) {
305
+ return basename(path)
306
+ }
307
+ }