@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/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.3.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 output(stack, args) {
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
- return await proc.exited === 0 ? new Response(proc.stdout).text() : "";
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.3.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 output(stack, args) {
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
- return await proc.exited === 0 ? new Response(proc.stdout).text() : "";
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.3.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
  }
@@ -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/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
- async function output(stack: Stack, args: string[]): Promise<string> {
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 (await proc.exited) === 0 ? new Response(proc.stdout).text() : ''
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. */