@kaynooo/cli-docker 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/stack.ts ADDED
@@ -0,0 +1,265 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { dirname, join, relative, resolve } from 'node:path'
3
+
4
+ /** How to reach one compose project: the command a user runs, and the one this package lists it with. */
5
+ export interface Stack {
6
+ /** Relative to the folder it runs from, `.` for that one. */
7
+ dir: string
8
+ /** Typed as is, `docker compose` from the folder itself, `env -C <dir> docker compose ...` from another. */
9
+ run: string[]
10
+ list: { argv: string[], cwd: string, env: Record<string, string | undefined> }
11
+ /** The compose project, for a stack found through its containers rather than a compose file compose finds on its own. */
12
+ project?: string
13
+ }
14
+
15
+ /** What compose recorded on a container of the stack it brought up. */
16
+ export interface Labels {
17
+ project: string
18
+ files: string[]
19
+ envFiles: string[]
20
+ workdir: string
21
+ }
22
+
23
+ export interface Service {
24
+ stack: Stack
25
+ /** The compose project the stack resolves to. */
26
+ project: string
27
+ name: string
28
+ state: string
29
+ status: string
30
+ ports: string
31
+ image: string
32
+ }
33
+
34
+ export type Action = 'shell' | 'logs' | 'restart' | 'up' | 'stop' | 'build'
35
+
36
+ interface Config {
37
+ name?: string
38
+ services?: Record<string, { image?: string, ports?: { target?: number, published?: string }[] }>
39
+ }
40
+
41
+ export interface Container {
42
+ Service: string
43
+ State: string
44
+ Status: string
45
+ }
46
+
47
+ const LABEL_FORMAT = ['project', 'project.config_files', 'project.environment_file', 'project.working_dir'].map(label => `{{.Label "com.docker.compose.${label}"}}`).join('\t')
48
+ const COMPOSE_FILE_VAR = /^\s*(?:export\s+)?COMPOSE_FILE\s*=\s*\S/m
49
+ const COMPOSE_FILES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']
50
+ const SKIPPED = new Set(['node_modules', 'vendor', 'dist', 'build', 'target', 'venv', '__pycache__'])
51
+
52
+ export const ACTIONS: Action[] = ['shell', 'logs', 'restart', 'up', 'stop', 'build']
53
+
54
+ /** The command behind `env -C` when it runs from another folder than this one. */
55
+ function from(dir: string, command: string[]): string[] {
56
+ return dir === '.' ? command : ['env', '-C', dir, ...command]
57
+ }
58
+
59
+ /** The direct child folders, hidden and built ones aside. */
60
+ function children(dir = '.'): string[] {
61
+ return readdirSync(dir, { withFileTypes: true })
62
+ .filter(child => child.isDirectory() && !child.name.startsWith('.') && !SKIPPED.has(child.name))
63
+ .map(child => child.name)
64
+ .sort()
65
+ }
66
+
67
+ /** The folder and its parents, relative to it, up to the repository root. */
68
+ function up(dir: string): string[] {
69
+ const dirs: string[] = []
70
+
71
+ for (let current = dir; ; current = join(current, '..')) {
72
+ dirs.push(current)
73
+
74
+ const absolute = resolve(current)
75
+
76
+ if (existsSync(join(current, '.git')) || dirname(absolute) === absolute)
77
+ return dirs
78
+ }
79
+ }
80
+
81
+ export function stackAt(dir: string): Stack {
82
+ return { dir, run: from(dir, ['docker', 'compose']), list: { argv: ['docker', 'compose'], cwd: resolve(dir), env: process.env } }
83
+ }
84
+
85
+ /** A compose file compose finds on its own, or a .env naming the ones to use, which compose reads too. */
86
+ export function holdsStack(dir: string): boolean {
87
+ if (COMPOSE_FILES.some(name => existsSync(join(dir, name))))
88
+ return true
89
+
90
+ try {
91
+ return COMPOSE_FILE_VAR.test(readFileSync(join(dir, '.env'), 'utf-8'))
92
+ }
93
+ catch {
94
+ return false
95
+ }
96
+ }
97
+
98
+ /** The stack of `dir` or of its nearest parent, up to the repository root. */
99
+ export function nearestStack(dir: string): Stack | undefined {
100
+ const found = up(dir).find(holdsStack)
101
+
102
+ return found === undefined ? undefined : stackAt(found)
103
+ }
104
+
105
+ /** The stack of this folder or its nearest parent holding a compose file, then each child folder holding one. */
106
+ export function stacks(): Stack[] {
107
+ const here = nearestStack('.')
108
+ const below = children().filter(holdsStack)
109
+
110
+ return [...(here ? [here] : []), ...below.filter(dir => dir !== here?.dir).map(stackAt)]
111
+ }
112
+
113
+ /** The compose projects with containers, one entry each, from the labels compose put on them. */
114
+ export function labelled(text: string): Labels[] {
115
+ const projects = new Map<string, Labels>()
116
+
117
+ for (const line of text.split('\n')) {
118
+ const [project, files, envFiles = '', workdir = ''] = line.split('\t')
119
+
120
+ if (project !== undefined && project !== '' && files !== undefined && files !== '' && workdir !== '' && !projects.has(project))
121
+ projects.set(project, { project, files: files.split(','), envFiles: envFiles.split(',').filter(Boolean), workdir })
122
+ }
123
+
124
+ return [...projects.values()]
125
+ }
126
+
127
+ /** A stack whose files were renamed or deleted since it came up is left out, there being nothing to run it with. */
128
+ export async function labels(project?: string): Promise<Labels[]> {
129
+ const filter = project === undefined ? [] : ['--filter', `label=com.docker.compose.project=${project}`]
130
+ const proc = Bun.spawn(['docker', 'ps', '--all', ...filter, '--format', LABEL_FORMAT], { stdout: 'pipe', stderr: 'ignore' })
131
+
132
+ if ((await proc.exited) !== 0)
133
+ return []
134
+
135
+ return labelled(await new Response(proc.stdout).text()).filter(stack => [...stack.files, ...stack.envFiles].every(file => existsSync(file)))
136
+ }
137
+
138
+ /** Run from the folder it came up in, with the files it came up with, paths kept short from there. */
139
+ export function labelledStack(dir: string, { project, files, envFiles, workdir }: Labels): Stack {
140
+ const options = (paths: (file: string) => string) => ['-p', project, ...files.flatMap(file => ['-f', paths(file)]), ...envFiles.flatMap(file => ['--env-file', paths(file)])]
141
+
142
+ return {
143
+ dir,
144
+ project,
145
+ run: from(dir, ['docker', 'compose', ...options(file => relative(workdir, file))]),
146
+ list: { argv: ['docker', 'compose', ...options(file => file)], cwd: workdir, env: process.env },
147
+ }
148
+ }
149
+
150
+ export function stackOf(recorded: Labels): Stack {
151
+ return labelledStack(relative('.', recorded.workdir) || '.', recorded)
152
+ }
153
+
154
+ /** The stacks with containers that came up from this folder, a parent up to the repository root, or a child folder. */
155
+ export async function recordedNearby(): Promise<Labels[]> {
156
+ const dirs = new Set([...up('.'), ...children()].map(dir => resolve(dir)))
157
+
158
+ return (await labels()).filter(stack => dirs.has(stack.workdir))
159
+ }
160
+
161
+ async function output(stack: Stack, args: string[]): Promise<string> {
162
+ const proc = Bun.spawn([...stack.list.argv, ...args], { cwd: stack.list.cwd, env: stack.list.env, stdout: 'pipe', stderr: 'pipe' })
163
+
164
+ return (await proc.exited) === 0 ? new Response(proc.stdout).text() : ''
165
+ }
166
+
167
+ /** Compose prints one container per line, where older versions printed a JSON array. */
168
+ export function containers(text: string): Container[] {
169
+ const trimmed = text.trim()
170
+
171
+ if (!trimmed)
172
+ return []
173
+
174
+ return trimmed.startsWith('[') ? JSON.parse(trimmed) as Container[] : trimmed.split('\n').map(line => JSON.parse(line) as Container)
175
+ }
176
+
177
+ export async function services(stack: Stack): Promise<Service[]> {
178
+ const [config, ps] = await Promise.all([output(stack, ['config', '--format', 'json']), output(stack, ['ps', '--all', '--format', 'json'])])
179
+ const parsed = config ? JSON.parse(config) as Config : {}
180
+ const declared = parsed.services ?? {}
181
+ const project = stack.project ?? parsed.name ?? ''
182
+ const running = new Map(containers(ps).map(container => [container.Service, container]))
183
+
184
+ return Object.entries(declared).map(([name, service]) => ({
185
+ stack,
186
+ project,
187
+ name,
188
+ state: running.get(name)?.State ?? 'none',
189
+ status: running.get(name)?.Status ?? '',
190
+ ports: (service.ports ?? []).filter(port => (port.published ?? '') !== '').map(port => `${port.published}->${port.target}`).join(' '),
191
+ image: service.image ?? 'built',
192
+ }))
193
+ }
194
+
195
+ /** The services of the stacks compose finds on its own, then of the ones only their containers tell of. */
196
+ export async function allServices(): Promise<Service[]> {
197
+ const [listed, recorded] = await Promise.all([Promise.all(stacks().map(services)), recordedNearby()])
198
+ const found = listed.flat()
199
+ const known = new Set(found.map(service => service.project))
200
+ const others = recorded.filter(stack => !known.has(stack.project))
201
+
202
+ return [...found, ...(await Promise.all(others.map(async stack => services(stackOf(stack))))).flat()]
203
+ }
204
+
205
+ function named(service: Service): string {
206
+ return service.stack.project === undefined ? service.name : `${service.stack.project}:${service.name}`
207
+ }
208
+
209
+ /** What names a service: `php` in this folder's stack, `api/php` in a child's, `<project>:php` in one found through its containers. */
210
+ export function key(service: Service): string {
211
+ return service.stack.dir === '.' ? named(service) : `${service.stack.dir}/${named(service)}`
212
+ }
213
+
214
+ /** Bash when the container has it, the images built on alpine only having sh. */
215
+ async function shellOf(service: Service): Promise<string> {
216
+ return (await output(service.stack, ['exec', '-T', service.name, 'sh', '-c', 'command -v bash'])) ? 'bash' : 'sh'
217
+ }
218
+
219
+ export async function commandFor(service: Service, action: Action): Promise<string[]> {
220
+ const run = service.stack.run
221
+
222
+ switch (action) {
223
+ case 'shell':
224
+ return [...run, 'exec', service.name, await shellOf(service)]
225
+ case 'logs':
226
+ return [...run, 'logs', '-f', '--tail=200', service.name]
227
+ case 'restart':
228
+ return [...run, 'restart', service.name]
229
+ case 'up':
230
+ return [...run, 'up', '-d', service.name]
231
+ case 'stop':
232
+ return [...run, 'stop', service.name]
233
+ case 'build':
234
+ return [...run, 'up', '-d', '--build', service.name]
235
+ }
236
+ }
237
+
238
+ /** A stopped service has no shell to open, so it is brought up instead. */
239
+ export function defaultAction(service: Service): Action {
240
+ return service.state === 'running' ? 'shell' : 'up'
241
+ }
242
+
243
+ // Up in green, down in red, never created dimmed, anything on its way, restarting or paused, in yellow.
244
+ export function stateColor(state: string): 'green' | 'red' | 'dim' | 'yellow' {
245
+ if (state === 'running')
246
+ return 'green'
247
+
248
+ if (state === 'exited' || state === 'dead')
249
+ return 'red'
250
+
251
+ return state === 'none' ? 'dim' : 'yellow'
252
+ }
253
+
254
+ // A service looked up again from the folder, project and name the picker's preview was given.
255
+ export async function serviceNamed(dir: string, name: string, project: string): Promise<Service | undefined> {
256
+ const [recorded] = project === '' ? [] : await labels(project)
257
+ const stack = recorded === undefined ? stackAt(dir) : labelledStack(dir, recorded)
258
+
259
+ return (await services(stack)).find(candidate => candidate.name === name)
260
+ }
261
+
262
+ // Compose's own words for its output of one service, the last lines of its logs.
263
+ export async function logsOf(stack: Stack, name: string): Promise<string> {
264
+ return output(stack, ['logs', '--tail=30', '--no-log-prefix', name])
265
+ }