@kaynooo/cli-docker 0.3.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 +2 -1
- package/dist/dc-cli.js +14 -3
- package/dist/dlog-cli.js +1164 -0
- package/dist/dx-cli.js +14 -3
- package/dist/sf-cli.js +14 -3
- package/package.json +6 -3
- package/src/dlog-cli.ts +8 -0
- package/src/dlog.ts +179 -0
- package/src/stack.ts +16 -2
package/dist/dx-cli.js
CHANGED
|
@@ -349,6 +349,9 @@ function optionRows(options, colors = paint(process3.stdout)) {
|
|
|
349
349
|
|
|
350
350
|
// ../kit/src/text.ts
|
|
351
351
|
var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, "gi");
|
|
352
|
+
function stripAnsi(text) {
|
|
353
|
+
return text.replace(ANSI, "");
|
|
354
|
+
}
|
|
352
355
|
function distance(a, b) {
|
|
353
356
|
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
354
357
|
for (const [i, left] of [...a].entries()) {
|
|
@@ -745,7 +748,7 @@ var {Glob } = globalThis.Bun;
|
|
|
745
748
|
var SESSION_FILES = new Glob("*.json");
|
|
746
749
|
var ACCOUNT_DIRS = new Glob("*/sessions");
|
|
747
750
|
// package.json
|
|
748
|
-
var version = "0.
|
|
751
|
+
var version = "0.4.0";
|
|
749
752
|
|
|
750
753
|
// src/dx.ts
|
|
751
754
|
import process10 from "process";
|
|
@@ -928,9 +931,17 @@ async function recordedNearby() {
|
|
|
928
931
|
const dirs = new Set([...up("."), ...children()].map((dir) => resolve(dir)));
|
|
929
932
|
return (await labels()).filter((stack) => dirs.has(stack.workdir));
|
|
930
933
|
}
|
|
931
|
-
async function
|
|
934
|
+
async function attempt(stack, args) {
|
|
932
935
|
const proc = Bun.spawn([...stack.list.argv, ...args], { cwd: stack.list.cwd, env: stack.list.env, stdout: "pipe", stderr: "pipe" });
|
|
933
|
-
|
|
936
|
+
const [text, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
|
|
937
|
+
if (code === 0)
|
|
938
|
+
return { text, error: undefined };
|
|
939
|
+
const last = stderr.trim().split(`
|
|
940
|
+
`).at(-1) ?? "";
|
|
941
|
+
return { text: "", error: last === "" ? `exit ${code}` : last };
|
|
942
|
+
}
|
|
943
|
+
async function output(stack, args) {
|
|
944
|
+
return (await attempt(stack, args)).text;
|
|
934
945
|
}
|
|
935
946
|
function containers(text) {
|
|
936
947
|
const trimmed = text.trim();
|
package/dist/sf-cli.js
CHANGED
|
@@ -349,6 +349,9 @@ function optionRows(options, colors = paint(process3.stdout)) {
|
|
|
349
349
|
|
|
350
350
|
// ../kit/src/text.ts
|
|
351
351
|
var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, "gi");
|
|
352
|
+
function stripAnsi(text) {
|
|
353
|
+
return text.replace(ANSI, "");
|
|
354
|
+
}
|
|
352
355
|
function distance(a, b) {
|
|
353
356
|
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
354
357
|
for (const [i, left] of [...a].entries()) {
|
|
@@ -745,7 +748,7 @@ var {Glob } = globalThis.Bun;
|
|
|
745
748
|
var SESSION_FILES = new Glob("*.json");
|
|
746
749
|
var ACCOUNT_DIRS = new Glob("*/sessions");
|
|
747
750
|
// package.json
|
|
748
|
-
var version = "0.
|
|
751
|
+
var version = "0.4.0";
|
|
749
752
|
|
|
750
753
|
// src/sf.ts
|
|
751
754
|
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
@@ -1018,9 +1021,17 @@ async function recordedNearby() {
|
|
|
1018
1021
|
const dirs = new Set([...up("."), ...children()].map((dir) => resolve(dir)));
|
|
1019
1022
|
return (await labels()).filter((stack) => dirs.has(stack.workdir));
|
|
1020
1023
|
}
|
|
1021
|
-
async function
|
|
1024
|
+
async function attempt(stack, args) {
|
|
1022
1025
|
const proc = Bun.spawn([...stack.list.argv, ...args], { cwd: stack.list.cwd, env: stack.list.env, stdout: "pipe", stderr: "pipe" });
|
|
1023
|
-
|
|
1026
|
+
const [text, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
|
|
1027
|
+
if (code === 0)
|
|
1028
|
+
return { text, error: undefined };
|
|
1029
|
+
const last = stderr.trim().split(`
|
|
1030
|
+
`).at(-1) ?? "";
|
|
1031
|
+
return { text: "", error: last === "" ? `exit ${code}` : last };
|
|
1032
|
+
}
|
|
1033
|
+
async function output(stack, args) {
|
|
1034
|
+
return (await attempt(stack, args)).text;
|
|
1024
1035
|
}
|
|
1025
1036
|
function containers(text) {
|
|
1026
1037
|
const trimmed = text.trim();
|
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, dx 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, and a Symfony console command picked and run.",
|
|
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,6 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"bin": {
|
|
20
20
|
"dc": "./dist/dc-cli.js",
|
|
21
|
+
"dlog": "./dist/dlog-cli.js",
|
|
21
22
|
"dx": "./dist/dx-cli.js",
|
|
22
23
|
"sf": "./dist/sf-cli.js"
|
|
23
24
|
},
|
|
@@ -29,12 +30,14 @@
|
|
|
29
30
|
"registry": "https://git.kaynooo.fr/api/packages/kaynooo/npm/"
|
|
30
31
|
},
|
|
31
32
|
"scripts": {
|
|
32
|
-
"build": "$npm_execpath build src/dc-cli.ts src/dx-cli.ts src/sf-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",
|
|
33
34
|
"dc": "$npm_execpath src/dc-cli.ts",
|
|
35
|
+
"dlog": "$npm_execpath src/dlog-cli.ts",
|
|
34
36
|
"dx": "$npm_execpath src/dx-cli.ts",
|
|
35
37
|
"sf": "$npm_execpath src/sf-cli.ts"
|
|
36
38
|
},
|
|
37
39
|
"devDependencies": {
|
|
40
|
+
"@kaynooo/cli-env": "0.8.1",
|
|
38
41
|
"@kaynooo/cli-kit": "0.20.0"
|
|
39
42
|
}
|
|
40
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/stack.ts
CHANGED
|
@@ -42,6 +42,9 @@ export interface Container {
|
|
|
42
42
|
Service: string
|
|
43
43
|
State: string
|
|
44
44
|
Status: string
|
|
45
|
+
/** Empty for a container with no healthcheck. */
|
|
46
|
+
Health?: string
|
|
47
|
+
ExitCode?: number
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
const LABEL_FORMAT = ['project', 'project.config_files', 'project.environment_file', 'project.working_dir'].map(label => `{{.Label "com.docker.compose.${label}"}}`).join('\t')
|
|
@@ -158,10 +161,21 @@ export async function recordedNearby(): Promise<Labels[]> {
|
|
|
158
161
|
return (await labels()).filter(stack => dirs.has(stack.workdir))
|
|
159
162
|
}
|
|
160
163
|
|
|
161
|
-
|
|
164
|
+
/** What compose printed, and its stderr when it exited non-zero. */
|
|
165
|
+
export async function attempt(stack: Stack, args: string[]): Promise<{ text: string, error: string | undefined }> {
|
|
162
166
|
const proc = Bun.spawn([...stack.list.argv, ...args], { cwd: stack.list.cwd, env: stack.list.env, stdout: 'pipe', stderr: 'pipe' })
|
|
167
|
+
const [text, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
|
|
168
|
+
|
|
169
|
+
if (code === 0)
|
|
170
|
+
return { text, error: undefined }
|
|
171
|
+
|
|
172
|
+
const last = stderr.trim().split('\n').at(-1) ?? ''
|
|
163
173
|
|
|
164
|
-
return
|
|
174
|
+
return { text: '', error: last === '' ? `exit ${code}` : last }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function output(stack: Stack, args: string[]): Promise<string> {
|
|
178
|
+
return (await attempt(stack, args)).text
|
|
165
179
|
}
|
|
166
180
|
|
|
167
181
|
/** Compose prints one container per line, where older versions printed a JSON array. */
|