@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/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@kaynooo/cli-docker",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "type": "module",
4
+ "version": "0.2.0",
5
+ "description": "dc and dx: docker compose on the stack found here, including one only its containers tell of, and a compose service's shell, logs, restart, stop or rebuild.",
6
+ "author": "Kaynooo <contact@kaynooo.fr>",
7
+ "license": "MIT",
8
+ "homepage": "https://git.kaynooo.fr/kaynooo/cli/src/branch/main/packages/docker#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://git.kaynooo.fr/kaynooo/cli.git",
12
+ "directory": "packages/docker"
13
+ },
14
+ "bugs": "https://git.kaynooo.fr/kaynooo/cli/issues",
15
+ "keywords": ["docker", "compose", "cli", "bun"],
16
+ "exports": {
17
+ ".": "./src/index.ts"
18
+ },
19
+ "bin": {
20
+ "dc": "./dist/dc-cli.js",
21
+ "dx": "./dist/dx-cli.js"
22
+ },
23
+ "files": ["dist", "src"],
24
+ "engines": {
25
+ "bun": ">=1.4.3"
26
+ },
27
+ "publishConfig": {
28
+ "registry": "https://git.kaynooo.fr/api/packages/kaynooo/npm/"
29
+ },
30
+ "scripts": {
31
+ "build": "$npm_execpath build src/dc-cli.ts src/dx-cli.ts --target bun --outdir dist",
32
+ "dc": "$npm_execpath src/dc-cli.ts",
33
+ "dx": "$npm_execpath src/dx-cli.ts"
34
+ },
35
+ "devDependencies": {
36
+ "@kaynooo/cli-kit": "0.20.0"
37
+ }
38
+ }
package/src/dc-cli.ts ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import process from 'node:process'
4
+ import { completion, parse, runMain } from '@kaynooo/cli-kit'
5
+ import { version } from '../package.json'
6
+ import { dc } from './dc'
7
+
8
+ const usage = `dc [project] <compose args>
9
+
10
+ Runs docker compose on the stack found here: the nearest compose file or .env setting COMPOSE_FILE up to the repository root, else the only stack with containers here.
11
+ A first word naming a compose project with containers here runs on that project, with the files and env file it came up with (\`dc dash logs\`); the command it stands for is shown on stderr.
12
+ Takes no option of its own: every word reaches compose as typed, \`dc --help\` alone excepted.
13
+
14
+ Exits with the code of compose, 1 when several stacks have containers here and none is named.`
15
+
16
+ // A passthrough to compose: only a lone --help or a completion is dc's own, every other word reaches compose as typed.
17
+ async function main(args: string[]): Promise<number> {
18
+ if (completion.active || (args.length === 1 && (args[0] === '--help' || args[0] === '-h'))) {
19
+ parse(args, usage, {}, { replaces: 'docker compose -f docker-compose.prod.yaml --env-file .env.prod, typed again for each stack', examples: ['dc up -d', ['dc dash logs -f', 'a stack that came up from other files']] })
20
+
21
+ return 0
22
+ }
23
+
24
+ return dc(args)
25
+ }
26
+
27
+ process.exitCode = await runMain({ name: 'dc', version, run: main })
package/src/dc.ts ADDED
@@ -0,0 +1,75 @@
1
+ import type { Labels, Stack } from './stack'
2
+ import process from 'node:process'
3
+ import { paint } from '@kaynooo/cli-kit'
4
+ import { nearestStack, recordedNearby, stackAt, stackOf } from './stack'
5
+
6
+ // The words a compose command starts with, which never name a stack, so the containers are not listed to check.
7
+ const COMMANDS = new Set(['alpha', 'attach', 'bridge', 'build', 'commit', 'config', 'cp', 'create', 'down', 'events', 'exec', 'export', 'help', 'images', 'kill', 'logs', 'ls', 'pause', 'port', 'ps', 'publish', 'pull', 'push', 'restart', 'rm', 'run', 'scale', 'start', 'stats', 'stop', 'top', 'unpause', 'up', 'version', 'volumes', 'wait', 'watch'])
8
+
9
+ export type Target
10
+ = | { kind: 'stack', stack: Stack, args: string[], announce: boolean }
11
+ | { kind: 'choose', recorded: Labels[], args: string[] }
12
+
13
+ // Which stack `dc <args>` runs on: one named by a first word, the nearest compose file, or the only one with containers here.
14
+ export async function target(args: string[]): Promise<Target> {
15
+ const [first = '', ...rest] = args
16
+ const recorded = first.startsWith('-') || COMMANDS.has(first) ? undefined : await recordedNearby()
17
+ const chosen = recorded?.find(stack => stack.project === first)
18
+
19
+ if (chosen)
20
+ return { kind: 'stack', stack: stackOf(chosen), args: rest, announce: true }
21
+
22
+ const nearest = nearestStack('.')
23
+
24
+ if (nearest)
25
+ return { kind: 'stack', stack: nearest, args, announce: false }
26
+
27
+ const withContainers = recorded ?? await recordedNearby()
28
+
29
+ if (withContainers.length === 1)
30
+ return { kind: 'stack', stack: stackOf(withContainers[0]!), args, announce: true }
31
+
32
+ if (withContainers.length > 1)
33
+ return { kind: 'choose', recorded: withContainers, args }
34
+
35
+ return { kind: 'stack', stack: stackAt('.'), args, announce: false }
36
+ }
37
+
38
+ // The project picked with `pick` when it is installed, which draws on stderr and prints the choice.
39
+ async function choose(recorded: Labels[]): Promise<Labels | undefined> {
40
+ const picker = Bun.which('pick')
41
+
42
+ if (picker === null || !process.stdin.isTTY)
43
+ return undefined
44
+
45
+ const proc = Bun.spawn([picker, '--prompt', 'dc> '], { stdin: new Blob([recorded.map(stack => stack.project).join('\n')]), stdout: 'pipe', stderr: 'inherit' })
46
+ const picked = (await new Response(proc.stdout).text()).trim()
47
+
48
+ return recorded.find(stack => stack.project === picked)
49
+ }
50
+
51
+ async function run(stack: Stack, args: string[], announce: boolean): Promise<number> {
52
+ // The command it stands for, above its output, since nothing in `dc` says which files it took.
53
+ if (announce && process.stderr.isTTY)
54
+ console.error(paint(process.stderr).dim([...stack.run, ...args].join(' ')))
55
+
56
+ return Bun.spawn([...stack.list.argv, ...args], { cwd: stack.list.cwd, env: stack.list.env, stdio: ['inherit', 'inherit', 'inherit'] }).exited
57
+ }
58
+
59
+ // docker compose on the stack found here, with what it came up with when only its containers tell of it.
60
+ export async function dc(args: string[]): Promise<number> {
61
+ const found = await target(args)
62
+
63
+ if (found.kind === 'stack')
64
+ return run(found.stack, found.args, found.announce)
65
+
66
+ const recorded = await choose(found.recorded)
67
+
68
+ if (!recorded) {
69
+ console.error(`several stacks here, name one first: ${found.recorded.map(stack => stack.project).join(', ')}`)
70
+
71
+ return 1
72
+ }
73
+
74
+ return run(stackOf(recorded), found.args, true)
75
+ }
package/src/dx-cli.ts ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import process from 'node:process'
4
+ import { runMain } from '@kaynooo/cli-kit'
5
+ import { version } from '../package.json'
6
+ import { dx } from './dx'
7
+
8
+ process.exitCode = await runMain({ name: 'dx', version, run: dx })
package/src/dx.ts ADDED
@@ -0,0 +1,172 @@
1
+ import type { Action, Service } from './stack'
2
+ import process from 'node:process'
3
+ import { closest, fail, jsonOption, paint, parse, printJson } from '@kaynooo/cli-kit'
4
+ import { byRecency, record, runs } from './history'
5
+ import { KEY_LABELS, KEYS, pickService } from './picker'
6
+ import { ACTIONS, allServices, commandFor, defaultAction, key, logsOf, serviceNamed, stateColor } from './stack'
7
+
8
+ const usage = `dx [service] [action]
9
+ dx -
10
+
11
+ Without a service, picks one with fzf when it is installed and a terminal is attached: enter opens its shell (or brings a stopped service up), ctrl-l follows its logs, ctrl-r restarts, ctrl-x stops and ctrl-b rebuilds it. The services last acted on come first, per folder. Without fzf or a terminal it lists them instead.
12
+ \`dx -\` does the last action again, \`dx <service> [action]\` acts without the picker: \`shell\` (bash when the container has it), \`logs\` (follows the last 200 lines), \`restart\`, \`up\`, \`stop\` or \`build\` (up with a rebuild).
13
+ The services are the stack of this folder or its nearest parent, the ones of the child folders (keyed \`api/php\`), and stacks found only through their containers (keyed \`<project>:php\`).
14
+
15
+ Exits with the code of the command it ran, 1 when there is no compose service here or none by that name.`
16
+
17
+ const options = {
18
+ json: jsonOption('[{ key, project, name, state, status, ports, image }]'),
19
+ } as const
20
+
21
+ function table(services: Service[]): string {
22
+ const colors = paint(process.stdout)
23
+ const keys = services.map(key)
24
+ const width = Math.max(...keys.map(name => name.length))
25
+ const states = Math.max(...services.map(service => service.state.length))
26
+
27
+ return services.map((service, index) => [
28
+ keys[index]!.padEnd(width),
29
+ colors[stateColor(service.state)](service.state.padEnd(states)),
30
+ colors.cyan(service.ports),
31
+ colors.dim(service.image),
32
+ ].join(' ').trimEnd()).join('\n')
33
+ }
34
+
35
+ // This folder's service first, then the same name in a child folder.
36
+ function find(services: Service[], named: string): Service {
37
+ const found = services.find(service => key(service) === named) ?? services.find(service => service.name === named)
38
+
39
+ if (found !== undefined)
40
+ return found
41
+
42
+ const nearest = closest(named, services.map(key))
43
+
44
+ return fail(`no service named ${named}${nearest === undefined ? '' : `, did you mean ${nearest}?`}`, 1)
45
+ }
46
+
47
+ // The rows term lists in place of the picker: what it types back for enter and for each key.
48
+ function listed(services: Service[]): unknown[] {
49
+ return byRecency(services, runs(), key).map(service => ({
50
+ label: key(service),
51
+ hint: [service.state, service.ports, service.image].filter(Boolean).join(' · '),
52
+ typed: key(service),
53
+ preview: ['--show', service.stack.dir, service.name, service.stack.project ?? ''],
54
+ actions: Object.entries(KEYS).map(([pressed, action]) => ({ label: KEY_LABELS[pressed] ?? action, key: pressed, typed: `${key(service)} ${action}` })),
55
+ }))
56
+ }
57
+
58
+ // The hidden flags term and the shell completions call, each printing and nothing more.
59
+ async function hidden(flag: string, rest: string[], services: Service[]): Promise<number | undefined> {
60
+ if (flag === '--list') {
61
+ console.log(JSON.stringify(listed(services)))
62
+
63
+ return 0
64
+ }
65
+
66
+ // With the state as the description fish shows beside each name.
67
+ if (flag === '--names') {
68
+ console.log(services.map(service => `${key(service)}\t${service.state}`).join('\n'))
69
+
70
+ return 0
71
+ }
72
+
73
+ if (flag === '--show') {
74
+ const [dir = '.', name = '', project = ''] = rest
75
+ const service = await serviceNamed(dir, name, project)
76
+
77
+ if (service === undefined)
78
+ return 0
79
+
80
+ const colors = paint(process.stdout)
81
+
82
+ console.log(`${colors[stateColor(service.state)](service.status || service.state)} ${colors.dim(service.image)}${service.ports ? ` ${colors.cyan(service.ports)}` : ''}\n`)
83
+ console.log(await logsOf(service.stack, name))
84
+
85
+ return 0
86
+ }
87
+
88
+ return undefined
89
+ }
90
+
91
+ export async function dx(args: string[]): Promise<number> {
92
+ const [flag = ''] = args
93
+
94
+ if (flag === '--show' || flag === '--list' || flag === '--names') {
95
+ const services = flag === '--show' ? [] : await allServices()
96
+
97
+ if (services.length === 0 && flag !== '--show')
98
+ fail('no compose service here', 1)
99
+
100
+ return await hidden(flag, args.slice(1), services) ?? 0
101
+ }
102
+
103
+ // The protocol the tmux picker reads instead of running the command: its mode, the words typed back, and the command's words.
104
+ const emit = flag === '--emit'
105
+ const parsed = parse(emit ? args.slice(1) : args, usage, options, { replaces: 'docker compose exec <service> bash, logs -f and restart typed per service', examples: ['dx', ['dx php', 'a shell in php, or php brought up when stopped'], ['dx php logs', 'follow its logs'], ['dx -', 'the last action again']] })
106
+ const [named, asked] = parsed.positionals
107
+ const services = await allServices()
108
+
109
+ if (services.length === 0)
110
+ fail('no compose service here', 1)
111
+
112
+ let service: Service | undefined
113
+ let action: Action | undefined
114
+
115
+ if (named === undefined) {
116
+ const interactive = process.stdin.isTTY && process.stderr.isTTY && parsed.values.json !== true
117
+
118
+ if (!interactive || Bun.which('fzf') === null) {
119
+ if (emit)
120
+ fail('the picker needs a terminal and fzf', 1)
121
+
122
+ if (interactive)
123
+ console.error('fzf is not installed, listing the services instead')
124
+
125
+ if (parsed.values.json === true)
126
+ printJson(services.map(each => ({ key: key(each), project: each.project, name: each.name, state: each.state, status: each.status, ports: each.ports, image: each.image })))
127
+ else
128
+ console.log(table(services))
129
+
130
+ return 0
131
+ }
132
+
133
+ const picked = pickService(byRecency(services, runs(), key))
134
+
135
+ if (picked === undefined)
136
+ return 1
137
+
138
+ service = picked.service
139
+ action = KEYS[picked.key]
140
+ }
141
+ else if (named === '-') {
142
+ const last = runs()[0]
143
+
144
+ service = last && services.find(candidate => key(candidate) === last.id)
145
+
146
+ if (service === undefined)
147
+ fail(last ? `${last.id} is gone` : 'nothing done here yet', 1)
148
+
149
+ action = last!.args[0] as Action | undefined
150
+ }
151
+ else {
152
+ service = find(services, named)
153
+
154
+ if (asked !== undefined && !ACTIONS.includes(asked as Action))
155
+ fail(`no action ${asked}, one of ${ACTIONS.join(', ')}`)
156
+
157
+ action = asked as Action | undefined
158
+ }
159
+
160
+ const chosen = action ?? defaultAction(service)
161
+ const command = await commandFor(service, chosen)
162
+
163
+ record({ id: key(service), args: [chosen] })
164
+
165
+ if (emit) {
166
+ console.log(['run', action === undefined ? key(service) : `${key(service)} ${action}`, '', ...command.map(word => `=${word}`)].join('\n'))
167
+
168
+ return 0
169
+ }
170
+
171
+ return Bun.spawn(command, { stdio: ['inherit', 'inherit', 'inherit'] }).exited
172
+ }
package/src/history.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+ import process from 'node:process'
5
+
6
+ export interface Run {
7
+ id: string
8
+ args: string[]
9
+ }
10
+
11
+ const REMEMBERED = 20
12
+
13
+ function historyFile(): string {
14
+ return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'dx', 'history.json')
15
+ }
16
+
17
+ // Keyed by folder, a broken or missing file reading as nothing run yet.
18
+ function history(): Record<string, Run[]> {
19
+ try {
20
+ const file = historyFile()
21
+
22
+ return existsSync(file) ? JSON.parse(readFileSync(file, 'utf-8')) as Record<string, Run[]> : {}
23
+ }
24
+ catch {
25
+ return {}
26
+ }
27
+ }
28
+
29
+ export function runs(): Run[] {
30
+ return history()[process.cwd()] ?? []
31
+ }
32
+
33
+ export function record(run: Run): void {
34
+ const all = history()
35
+ const file = historyFile()
36
+
37
+ all[process.cwd()] = [run, ...(all[process.cwd()] ?? []).filter(earlier => earlier.id !== run.id)].slice(0, REMEMBERED)
38
+ mkdirSync(dirname(file), { recursive: true })
39
+ writeFileSync(file, JSON.stringify(all))
40
+ }
41
+
42
+ // The ones run last come first, most recent at the top, the rest keeping their order.
43
+ export function byRecency<T>(items: T[], previous: Run[], idOf: (item: T) => string): T[] {
44
+ const rank = new Map(previous.map((run, index) => [run.id, index]))
45
+ const recent = items.filter(item => rank.has(idOf(item))).sort((a, b) => rank.get(idOf(a))! - rank.get(idOf(b))!)
46
+
47
+ return [...recent, ...items.filter(item => !rank.has(idOf(item)))]
48
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './dc'
2
+ export * from './stack'
package/src/picker.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { Action, Service } from './stack'
2
+ import process from 'node:process'
3
+ import { paint } from '@kaynooo/cli-kit'
4
+ import { key, stateColor } from './stack'
5
+
6
+ export const KEYS: Record<string, Action> = { 'ctrl-l': 'logs', 'ctrl-r': 'restart', 'ctrl-x': 'stop', 'ctrl-b': 'build' }
7
+ export const KEY_LABELS: Record<string, string> = { 'ctrl-l': 'Logs', 'ctrl-r': 'Restart', 'ctrl-x': 'Stop', 'ctrl-b': 'Rebuild' }
8
+
9
+ // The fields fzf hands back after the visible ones, tab separated: folder, name, key, compose project.
10
+ function row(service: Service): { hidden: string[], cells: string[] } {
11
+ const colors = paint(process.stderr)
12
+ const dir = service.stack.dir
13
+ const project = service.stack.project
14
+ const name = project === undefined ? service.name : `${colors.dim(`${project}:`)}${service.name}`
15
+
16
+ return {
17
+ hidden: [dir, service.name, key(service), project ?? ''],
18
+ cells: [dir === '.' ? name : `${colors.dim(`${dir}/`)}${name}`, colors[stateColor(service.state)](service.state), colors.cyan(service.ports), colors.dim(service.image)],
19
+ }
20
+ }
21
+
22
+ // Every cell but the last padded to its column, one space short of the two between columns since a tab renders as one.
23
+ function table(rows: { hidden: string[], cells: string[] }[]): string {
24
+ const widths = rows[0]?.cells.map((_, index) => Math.max(...rows.map(each => Bun.stringWidth(each.cells[index]!)))) ?? []
25
+
26
+ return rows.map(each => [
27
+ ...each.hidden,
28
+ ...each.cells.map((cell, index) => (index === each.cells.length - 1 ? cell.trimEnd() : `${cell}${' '.repeat(widths[index]! + 1 - Bun.stringWidth(cell))}`)),
29
+ ].join('\t')).join('\n')
30
+ }
31
+
32
+ // The service picked with fzf and the key that picked it, enter's being empty; undefined on escape.
33
+ export function pickService(services: Service[]): { service: Service, key: string } | undefined {
34
+ const proc = Bun.spawnSync([
35
+ 'fzf',
36
+ '--prompt',
37
+ 'dx> ',
38
+ '--header',
39
+ 'enter shell or up, ctrl-l logs, ctrl-r restart, ctrl-x stop, ctrl-b rebuild',
40
+ '--expect',
41
+ Object.keys(KEYS).join(','),
42
+ '--ansi',
43
+ '--reverse',
44
+ '--height',
45
+ String(Math.max(15, Math.ceil((services.length + 4) / 0.6))),
46
+ '--delimiter',
47
+ '\t',
48
+ '--with-nth',
49
+ '5..',
50
+ '--nth',
51
+ '1',
52
+ '--tabstop',
53
+ '1',
54
+ '--tiebreak',
55
+ 'chunk,index',
56
+ '--preview',
57
+ `${process.execPath} ${process.argv[1]} --show {1} {2} {4}`,
58
+ '--preview-window',
59
+ 'down,40%',
60
+ ], { stdin: new TextEncoder().encode(table(services.map(row))), stdout: 'pipe', stderr: 'inherit' })
61
+
62
+ const [pressed = '', line] = proc.stdout.toString().split('\n')
63
+ const keyed = line?.split('\t')[2]
64
+ const service = services.find(candidate => key(candidate) === keyed)
65
+
66
+ return service === undefined ? undefined : { service, key: pressed }
67
+ }