@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/README.md +7 -1
- package/dist/dc-cli.js +14 -3
- package/dist/dlog-cli.js +1164 -0
- package/dist/dx-cli.js +103 -57
- package/dist/sf-cli.js +1339 -0
- package/package.json +10 -5
- package/src/dlog-cli.ts +8 -0
- package/src/dlog.ts +179 -0
- package/src/dx.ts +5 -5
- package/src/picker.ts +15 -45
- package/src/sf-cli.ts +8 -0
- package/src/sf.ts +368 -0
- package/src/stack.ts +19 -5
- package/src/history.ts +0 -48
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaynooo/cli-docker",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "dc and
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
}
|
package/src/dlog-cli.ts
ADDED
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 '
|
|
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
|
|
10
|
-
function row(service: Service):
|
|
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
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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