@kaynooo/cli-docker 0.2.0 → 0.4.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,8 +1,8 @@
1
1
  {
2
2
  "name": "@kaynooo/cli-docker",
3
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.",
4
+ "version": "0.4.0",
5
+ "description": "dc, dx, dlog and sf: docker compose on the stack found here, including one only its containers tell of, a compose service's shell, logs, restart, stop or rebuild, every service's health and recent errors in one call, and a Symfony console command picked and run.",
6
6
  "author": "Kaynooo <contact@kaynooo.fr>",
7
7
  "license": "MIT",
8
8
  "homepage": "https://git.kaynooo.fr/kaynooo/cli/src/branch/main/packages/docker#readme",
@@ -18,7 +18,9 @@
18
18
  },
19
19
  "bin": {
20
20
  "dc": "./dist/dc-cli.js",
21
- "dx": "./dist/dx-cli.js"
21
+ "dlog": "./dist/dlog-cli.js",
22
+ "dx": "./dist/dx-cli.js",
23
+ "sf": "./dist/sf-cli.js"
22
24
  },
23
25
  "files": ["dist", "src"],
24
26
  "engines": {
@@ -28,11 +30,14 @@
28
30
  "registry": "https://git.kaynooo.fr/api/packages/kaynooo/npm/"
29
31
  },
30
32
  "scripts": {
31
- "build": "$npm_execpath build src/dc-cli.ts src/dx-cli.ts --target bun --outdir dist",
33
+ "build": "$npm_execpath build src/dc-cli.ts src/dlog-cli.ts src/dx-cli.ts src/sf-cli.ts --target bun --outdir dist",
32
34
  "dc": "$npm_execpath src/dc-cli.ts",
33
- "dx": "$npm_execpath src/dx-cli.ts"
35
+ "dlog": "$npm_execpath src/dlog-cli.ts",
36
+ "dx": "$npm_execpath src/dx-cli.ts",
37
+ "sf": "$npm_execpath src/sf-cli.ts"
34
38
  },
35
39
  "devDependencies": {
40
+ "@kaynooo/cli-env": "0.8.1",
36
41
  "@kaynooo/cli-kit": "0.20.0"
37
42
  }
38
43
  }
@@ -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 { dlog } from './dlog'
7
+
8
+ process.exitCode = await runMain({ name: 'dlog', version, run: dlog })
package/src/dlog.ts ADDED
@@ -0,0 +1,179 @@
1
+ import type { Container, Service } from './stack'
2
+ import process from 'node:process'
3
+ import { maskLine, PEM_BEGIN, PEM_END } from '@kaynooo/cli-env/mask'
4
+ import { fail, jsonOption, paint, parse, printJson, stripAnsi } from '@kaynooo/cli-kit'
5
+ import { allServices, attempt, containers, key, stateColor } from './stack'
6
+
7
+ const usage = `dlog [service...]
8
+
9
+ One line per compose service with its state and health, then the last distinct error lines of the services that log some or are not healthy, repeats collapsed to one line with a count and secrets masked as redact does.
10
+ With service names, only those are read. The stacks are the ones dx lists: this folder's, its child folders' and the ones only their containers tell of.
11
+
12
+ Exits 0 when every service is up and quiet, 1 when one is down, unhealthy or still starting, logged an error or has logs compose could not read, so it can gate a script, 1 too when there is no compose service here. A service that exited with code 0 (a migration, an init) is not down.`
13
+
14
+ const options = {
15
+ lines: { type: 'string', short: 'n', default: '5', description: 'error lines kept per service' },
16
+ since: { type: 'string', default: '1h', description: 'how far back the logs are read (compose --since: 30m, 2h, a timestamp)' },
17
+ all: { type: 'boolean', short: 'a', description: 'the last log lines of a service with no error too' },
18
+ json: jsonOption('[{ key, state, health, status, errors: [{ line, count }], tail }]'),
19
+ } as const
20
+
21
+ const ERROR = /\b(?:error|err|fatal|exception|panic|critical|crit|traceback|failed|failure|refused|denied|timeout|timed out|oom|killed)\b/i
22
+ const TAIL = 500
23
+ const MAX_WIDTH = 220
24
+ const TIMESTAMP = /\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?(?:Z|[+-]\d{2}:?\d{2})?|\d{2}[/-][A-Za-z]{3}[/-]\d{4}:\d{2}:\d{2}:\d{2}(?: [+-]\d{4})?|\d{2}:\d{2}:\d{2}(?:[.,]\d+)?/g
25
+ const IDENTIFIER = /\b(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|0x[0-9a-f]+|[0-9a-f]{12,})\b/gi
26
+ const NUMBER = /\d+/g
27
+ const STATUS_HEALTH = /\((healthy|unhealthy|health: starting)\)/
28
+ const LEADING_BRACKETS = /^(?:\[\s*\]|\s)+/
29
+
30
+ export interface ErrorLine {
31
+ line: string
32
+ count: number
33
+ }
34
+
35
+ /** The line two occurrences of the same message share: no timestamp, id or number. */
36
+ function shape(line: string): string {
37
+ return line.replace(TIMESTAMP, '').replace(IDENTIFIER, '#').replace(NUMBER, '#').replace(LEADING_BRACKETS, '').trim()
38
+ }
39
+
40
+ function clip(line: string): string {
41
+ return line.length > MAX_WIDTH ? `${line.slice(0, MAX_WIDTH - 1)}…` : line
42
+ }
43
+
44
+ /** The last `limit` distinct error lines of a log, oldest first, each as its latest occurrence with how often its message came up, secrets masked. */
45
+ export function errorLines(log: string, limit: number): ErrorLine[] {
46
+ const seen = new Map<string, ErrorLine>()
47
+
48
+ for (const raw of log.split('\n')) {
49
+ const line = stripAnsi(raw).trim()
50
+
51
+ if (line === '' || !ERROR.test(line))
52
+ continue
53
+
54
+ const shared = shape(line)
55
+ const before = seen.get(shared)
56
+
57
+ // Re-inserted so the Map's order is the order of the latest occurrences.
58
+ seen.delete(shared)
59
+ seen.set(shared, { line: clip(maskLine(line)), count: (before?.count ?? 0) + 1 })
60
+ }
61
+
62
+ return [...seen.values()].slice(-limit)
63
+ }
64
+
65
+ export interface Health {
66
+ /** `healthy`, `unhealthy`, `starting`, or empty when the container has no healthcheck. */
67
+ health: string
68
+ /** Whether the service is up and, when it has a healthcheck, healthy. */
69
+ fine: boolean
70
+ }
71
+
72
+ export function healthOf(state: string, container: Container | undefined): Health {
73
+ const health = (container?.Health ?? '') || (STATUS_HEALTH.exec(container?.Status ?? '')?.[1]?.replace('health: ', '') ?? '')
74
+ const finished = state === 'exited' && container?.ExitCode === 0
75
+
76
+ return { health, fine: (state === 'running' || finished) && (health === '' || health === 'healthy') }
77
+ }
78
+
79
+ export interface Report {
80
+ key: string
81
+ state: string
82
+ health: string
83
+ status: string
84
+ errors: ErrorLine[]
85
+ tail: string[]
86
+ /** Why compose could not list the service or read its logs. */
87
+ unreadable: string | undefined
88
+ fine: boolean
89
+ }
90
+
91
+ async function read(service: Service, since: string): Promise<{ log: string, container: Container | undefined, unreadable: string | undefined }> {
92
+ const [ps, logs] = await Promise.all([
93
+ attempt(service.stack, ['ps', '--all', '--format', 'json', service.name]),
94
+ attempt(service.stack, ['logs', '--no-color', '--no-log-prefix', `--tail=${TAIL}`, `--since=${since}`, service.name]),
95
+ ])
96
+
97
+ return { log: logs.text, container: containers(ps.text)[0], unreadable: ps.error ?? logs.error }
98
+ }
99
+
100
+ /** The lines of a log without the body of a private key. */
101
+ export function withoutKeys(lines: string[]): string[] {
102
+ let inKey = false
103
+
104
+ return lines.filter((text) => {
105
+ const opens = PEM_BEGIN.test(text)
106
+ const hidden = inKey || opens
107
+
108
+ inKey = (inKey || opens) && !PEM_END.test(text)
109
+
110
+ return !hidden
111
+ })
112
+ }
113
+
114
+ function line(report: Report): string {
115
+ const colors = paint(process.stdout)
116
+ const status = report.status.replace(STATUS_HEALTH, '').trim()
117
+ const detail = [report.health, status].filter(Boolean).join(' · ')
118
+
119
+ return `${report.key.padEnd(24)} ${colors[stateColor(report.state)](report.state.padEnd(9))} ${colors.dim(detail)}`.trimEnd()
120
+ }
121
+
122
+ function show(reports: Report[]): void {
123
+ const colors = paint(process.stdout)
124
+
125
+ for (const report of reports) {
126
+ console.log(line(report))
127
+
128
+ if (report.unreadable !== undefined)
129
+ console.log(` ${colors.red(`logs unavailable: ${report.unreadable}`)}`)
130
+
131
+ for (const { line: text, count } of report.errors)
132
+ console.log(` ${colors.red(text)}${count > 1 ? colors.dim(` ×${count}`) : ''}`)
133
+
134
+ for (const text of report.tail)
135
+ console.log(` ${colors.dim(text)}`)
136
+ }
137
+ }
138
+
139
+ export async function dlog(args: string[]): Promise<number> {
140
+ const parsed = parse(args, usage, options, { replaces: 'docker compose ps, logs --tail | grep -i error and docker inspect for health', examples: ['dlog', ['dlog php db', 'only those services'], ['dlog -n 10 --since 15m', 'ten error lines of the last quarter of an hour'], ['dlog --json', 'the report as JSON']] })
141
+ const limit = Number(parsed.values.lines)
142
+
143
+ if (!Number.isInteger(limit) || limit < 1)
144
+ fail('--lines takes a positive whole number')
145
+
146
+ const services = await allServices()
147
+
148
+ if (services.length === 0)
149
+ fail('no compose service here', 1)
150
+
151
+ const wanted = parsed.positionals
152
+ const unknown = wanted.filter(name => !services.some(service => key(service) === name || service.name === name))
153
+
154
+ if (unknown.length > 0)
155
+ fail(`no service named ${unknown.join(', ')}`, 1)
156
+
157
+ const chosen = wanted.length === 0 ? services : services.filter(service => wanted.includes(key(service)) || wanted.includes(service.name))
158
+ const reports: Report[] = await Promise.all(chosen.map(async (service) => {
159
+ const { log, container, unreadable } = await read(service, parsed.values.since)
160
+ const { health, fine } = healthOf(service.state, container)
161
+ const lines = withoutKeys(log.split('\n'))
162
+ const errors = errorLines(lines.join('\n'), limit)
163
+ const tail = parsed.values.all === true && errors.length === 0 ? lines.map(text => clip(maskLine(stripAnsi(text).trim()))).filter(Boolean).slice(-limit) : []
164
+
165
+ return { key: key(service), state: service.state, health, status: service.status, errors, tail, unreadable, fine }
166
+ }))
167
+
168
+ if (parsed.values.json === true)
169
+ printJson(reports)
170
+ else
171
+ show(reports)
172
+
173
+ const down = reports.filter(report => !report.fine || report.unreadable !== undefined || report.errors.length > 0)
174
+
175
+ if (parsed.values.json !== true && down.length > 0)
176
+ console.error(paint(process.stderr).dim(`${down.length} of ${reports.length} services need a look: ${down.map(report => report.key).join(', ')}`))
177
+
178
+ return down.length > 0 ? 1 : 0
179
+ }
package/src/dx.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Action, Service } from './stack'
2
2
  import process from 'node:process'
3
3
  import { closest, fail, jsonOption, paint, parse, printJson } from '@kaynooo/cli-kit'
4
- import { byRecency, record, runs } from './history'
4
+ import { byRecency, record, runs } from '@kaynooo/cli-kit/fzf'
5
5
  import { KEY_LABELS, KEYS, pickService } from './picker'
6
6
  import { ACTIONS, allServices, commandFor, defaultAction, key, logsOf, serviceNamed, stateColor } from './stack'
7
7
 
@@ -46,7 +46,7 @@ function find(services: Service[], named: string): Service {
46
46
 
47
47
  // The rows term lists in place of the picker: what it types back for enter and for each key.
48
48
  function listed(services: Service[]): unknown[] {
49
- return byRecency(services, runs(), key).map(service => ({
49
+ return byRecency(services, runs('dx'), key).map(service => ({
50
50
  label: key(service),
51
51
  hint: [service.state, service.ports, service.image].filter(Boolean).join(' · '),
52
52
  typed: key(service),
@@ -130,7 +130,7 @@ export async function dx(args: string[]): Promise<number> {
130
130
  return 0
131
131
  }
132
132
 
133
- const picked = pickService(byRecency(services, runs(), key))
133
+ const picked = pickService(byRecency(services, runs('dx'), key))
134
134
 
135
135
  if (picked === undefined)
136
136
  return 1
@@ -139,7 +139,7 @@ export async function dx(args: string[]): Promise<number> {
139
139
  action = KEYS[picked.key]
140
140
  }
141
141
  else if (named === '-') {
142
- const last = runs()[0]
142
+ const last = runs('dx')[0]
143
143
 
144
144
  service = last && services.find(candidate => key(candidate) === last.id)
145
145
 
@@ -160,7 +160,7 @@ export async function dx(args: string[]): Promise<number> {
160
160
  const chosen = action ?? defaultAction(service)
161
161
  const command = await commandFor(service, chosen)
162
162
 
163
- record({ id: key(service), args: [chosen] })
163
+ record('dx', { id: key(service), args: [chosen] })
164
164
 
165
165
  if (emit) {
166
166
  console.log(['run', action === undefined ? key(service) : `${key(service)} ${action}`, '', ...command.map(word => `=${word}`)].join('\n'))
package/src/picker.ts CHANGED
@@ -1,13 +1,15 @@
1
+ import type { Row } from '@kaynooo/cli-kit/fzf'
1
2
  import type { Action, Service } from './stack'
2
3
  import process from 'node:process'
3
4
  import { paint } from '@kaynooo/cli-kit'
5
+ import { fzf, heightFor } from '@kaynooo/cli-kit/fzf'
4
6
  import { key, stateColor } from './stack'
5
7
 
6
8
  export const KEYS: Record<string, Action> = { 'ctrl-l': 'logs', 'ctrl-r': 'restart', 'ctrl-x': 'stop', 'ctrl-b': 'build' }
7
9
  export const KEY_LABELS: Record<string, string> = { 'ctrl-l': 'Logs', 'ctrl-r': 'Restart', 'ctrl-x': 'Stop', 'ctrl-b': 'Rebuild' }
8
10
 
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
+ // The hidden fields come back with the choice: folder, name, key, compose project.
12
+ function row(service: Service): Row {
11
13
  const colors = paint(process.stderr)
12
14
  const dir = service.stack.dir
13
15
  const project = service.stack.project
@@ -19,49 +21,17 @@ function row(service: Service): { hidden: string[], cells: string[] } {
19
21
  }
20
22
  }
21
23
 
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
24
  // The service picked with fzf and the key that picked it, enter's being empty; undefined on escape.
33
25
  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 }
26
+ const picked = fzf(services.map(row), {
27
+ prompt: 'dx> ',
28
+ header: 'enter shell or up, ctrl-l logs, ctrl-r restart, ctrl-x stop, ctrl-b rebuild',
29
+ expect: Object.keys(KEYS),
30
+ searched: 1,
31
+ height: heightFor(services.length),
32
+ preview: `${process.execPath} ${process.argv[1]} --show {1} {2} {4}`,
33
+ })
34
+ const service = services.find(candidate => key(candidate) === picked?.hidden[2])
35
+
36
+ return picked === undefined || service === undefined ? undefined : { service, key: picked.key }
67
37
  }
package/src/sf-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 { sf } from './sf'
7
+
8
+ process.exitCode = await runMain({ name: 'sf', version, run: sf })