@kaynooo/cli-docker 0.3.0 → 0.5.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.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.5.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/dc-cli.ts CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  import process from 'node:process'
4
4
  import { completion, parse, runMain } from '@kaynooo/cli-kit'
5
- import { version } from '../package.json'
6
5
  import { dc } from './dc'
7
6
 
8
7
  const usage = `dc [project] <compose args>
@@ -24,4 +23,4 @@ async function main(args: string[]): Promise<number> {
24
23
  return dc(args)
25
24
  }
26
25
 
27
- process.exitCode = await runMain({ name: 'dc', version, run: main })
26
+ process.exitCode = await runMain({ name: 'dc', run: main })
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import process from 'node:process'
4
+ import { runMain } from '@kaynooo/cli-kit'
5
+ import { dlog } from './dlog'
6
+
7
+ process.exitCode = await runMain({ name: 'dlog', run: dlog })
package/src/dlog.ts ADDED
@@ -0,0 +1,219 @@
1
+ import type { KnownSecrets } from '@kaynooo/cli-env/mask'
2
+ import type { ExitRow } from '@kaynooo/cli-kit'
3
+ import type { Container, Service } from './stack'
4
+ import process from 'node:process'
5
+ import { loadKnown } from '@kaynooo/cli-env/files'
6
+ import { maskLine, PEM_BEGIN, PEM_END } from '@kaynooo/cli-env/mask'
7
+ import { badUsage, fail, jsonOption, paint, parse, printJson, stripAnsi, wholeNumber } from '@kaynooo/cli-kit'
8
+ import { allServices, attempt, containers, key, stateColor } from './stack'
9
+
10
+ const usage = `dlog [service...]
11
+
12
+ 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.
13
+ 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.
14
+ \`--level warn\` keeps warnings too, \`--grep <regex>\` keeps the lines matching a case insensitive regex instead of the error words. What fails the exit is \`--fail-on\`: by default a service down, unhealthy or still starting, an error line or logs compose could not read, so it can gate a script; \`--fail-on down\` leaves out an old error line that \`--since\` has not aged out yet. A service that exited with code 0 (a migration, an init) is not down.`
15
+
16
+ const EXITS: ExitRow[] = [[0, 'nothing the --fail-on list names: every service is up and quiet'], [1, 'a service is down, unhealthy or still starting, logged a matching line or has logs compose could not read (as --fail-on keeps them), or there is no compose service here'], [2, 'a usage error']]
17
+
18
+ const FAIL_ON = ['down', 'unhealthy', 'errors'] as const
19
+
20
+ type FailOn = typeof FAIL_ON[number]
21
+
22
+ const options = {
23
+ 'lines': { type: 'string', short: 'n', default: '5', description: 'error lines kept per service' },
24
+ 'since': { type: 'string', default: '1h', description: 'how far back the logs are read (compose --since: 30m, 2h, a timestamp)' },
25
+ 'level': { type: 'string', choices: ['error', 'warn'], default: 'error', description: 'lowest level of the lines kept: error (the default) or warn, warnings too' },
26
+ 'grep': { type: 'string', description: 'keep the lines matching this regex, case insensitive, instead of the --level words' },
27
+ 'fail-on': { type: 'string', multiple: true, choices: FAIL_ON, description: 'what makes the exit 1, repeatable: down, unhealthy or errors (all three)' },
28
+ 'all': { type: 'boolean', short: 'a', description: 'the last log lines of a service with no error too' },
29
+ 'json': jsonOption('[{ key, state, health, status, errors: [{ line, count }], tail, down, unhealthy }]'),
30
+ } as const
31
+
32
+ const ERROR = /\b(?:error|err|fatal|exception|panic|critical|crit|traceback|failed|failure|refused|denied|timeout|timed out|oom|killed)\b/i
33
+ const WARNING = /\b(?:warn|warning|deprecated|deprecation)\b/i
34
+ const LEVELS = { error: ERROR, warn: new RegExp(`${ERROR.source}|${WARNING.source}`, 'i') }
35
+ const TAIL = 500
36
+ const MAX_WIDTH = 220
37
+ 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
38
+ 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
39
+ const NUMBER = /\d+/g
40
+ const STATUS_HEALTH = /\((healthy|unhealthy|health: starting)\)/
41
+ const LEADING_BRACKETS = /^(?:\[\s*\]|\s)+/
42
+
43
+ export interface ErrorLine {
44
+ line: string
45
+ count: number
46
+ }
47
+
48
+ /** The line two occurrences of the same message share: no timestamp, id or number. */
49
+ function shape(line: string): string {
50
+ return line.replace(TIMESTAMP, '').replace(IDENTIFIER, '#').replace(NUMBER, '#').replace(LEADING_BRACKETS, '').trim()
51
+ }
52
+
53
+ function clip(line: string): string {
54
+ return line.length > MAX_WIDTH ? `${line.slice(0, MAX_WIDTH - 1)}…` : line
55
+ }
56
+
57
+ /** The last `limit` distinct lines of a log matching `matches`, oldest first, each as its latest occurrence with how often its message came up, secrets masked. */
58
+ export function errorLines(log: string, limit: number, known?: KnownSecrets, matches: RegExp = ERROR): ErrorLine[] {
59
+ const seen = new Map<string, ErrorLine>()
60
+
61
+ for (const raw of log.split('\n')) {
62
+ const line = stripAnsi(raw).trim()
63
+
64
+ if (line === '' || !matches.test(line))
65
+ continue
66
+
67
+ const shared = shape(line)
68
+ const before = seen.get(shared)
69
+
70
+ // Re-inserted so the Map's order is the order of the latest occurrences.
71
+ seen.delete(shared)
72
+ seen.set(shared, { line: clip(maskLine(line, known)), count: (before?.count ?? 0) + 1 })
73
+ }
74
+
75
+ return [...seen.values()].slice(-limit)
76
+ }
77
+
78
+ export interface Health {
79
+ /** `healthy`, `unhealthy`, `starting`, or empty when the container has no healthcheck. */
80
+ health: string
81
+ /** Whether the service is up and, when it has a healthcheck, healthy. */
82
+ fine: boolean
83
+ }
84
+
85
+ /** Whether the service runs, or ended cleanly (exit code 0) as a migration does. */
86
+ export function isUp(state: string, container: Container | undefined): boolean {
87
+ return state === 'running' || (state === 'exited' && container?.ExitCode === 0)
88
+ }
89
+
90
+ export function healthOf(state: string, container: Container | undefined): Health {
91
+ const health = (container?.Health ?? '') || (STATUS_HEALTH.exec(container?.Status ?? '')?.[1]?.replace('health: ', '') ?? '')
92
+
93
+ return { health, fine: isUp(state, container) && (health === '' || health === 'healthy') }
94
+ }
95
+
96
+ export interface Report {
97
+ key: string
98
+ state: string
99
+ health: string
100
+ status: string
101
+ errors: ErrorLine[]
102
+ tail: string[]
103
+ /** Why compose could not list the service or read its logs. */
104
+ unreadable: string | undefined
105
+ fine: boolean
106
+ /** Not running, and not a clean exit. */
107
+ down: boolean
108
+ /** Up, but unhealthy or still starting. */
109
+ unhealthy: boolean
110
+ }
111
+
112
+ /** Whether the report fails the exit for what `failOn` names; logs compose could not read count as down and as errors alike. */
113
+ export function needsLook(report: Report, failOn: ReadonlySet<FailOn>): boolean {
114
+ return (failOn.has('down') && (report.down || report.unreadable !== undefined))
115
+ || (failOn.has('unhealthy') && report.unhealthy)
116
+ || (failOn.has('errors') && (report.errors.length > 0 || report.unreadable !== undefined))
117
+ }
118
+
119
+ function matcherOf(level: keyof typeof LEVELS, grep: string | undefined): RegExp {
120
+ if (grep === undefined)
121
+ return LEVELS[level]
122
+
123
+ try {
124
+ return new RegExp(grep, 'i')
125
+ }
126
+ catch {
127
+ return badUsage(`--grep takes a regex, ${grep} is not one`)
128
+ }
129
+ }
130
+
131
+ async function read(service: Service, since: string): Promise<{ log: string, container: Container | undefined, unreadable: string | undefined }> {
132
+ const [ps, logs] = await Promise.all([
133
+ attempt(service.stack, ['ps', '--all', '--format', 'json', service.name]),
134
+ attempt(service.stack, ['logs', '--no-color', '--no-log-prefix', `--tail=${TAIL}`, `--since=${since}`, service.name]),
135
+ ])
136
+
137
+ return { log: logs.text, container: containers(ps.text)[0], unreadable: ps.error ?? logs.error }
138
+ }
139
+
140
+ /** The lines of a log without the body of a private key. */
141
+ export function withoutKeys(lines: string[]): string[] {
142
+ let inKey = false
143
+
144
+ return lines.filter((text) => {
145
+ const opens = PEM_BEGIN.test(text)
146
+ const hidden = inKey || opens
147
+
148
+ inKey = (inKey || opens) && !PEM_END.test(text)
149
+
150
+ return !hidden
151
+ })
152
+ }
153
+
154
+ function line(report: Report): string {
155
+ const colors = paint(process.stdout)
156
+ const status = report.status.replace(STATUS_HEALTH, '').trim()
157
+ const detail = [report.health, status].filter(Boolean).join(' · ')
158
+
159
+ return `${report.key.padEnd(24)} ${colors[stateColor(report.state)](report.state.padEnd(9))} ${colors.dim(detail)}`.trimEnd()
160
+ }
161
+
162
+ function show(reports: Report[]): void {
163
+ const colors = paint(process.stdout)
164
+
165
+ for (const report of reports) {
166
+ console.log(line(report))
167
+
168
+ if (report.unreadable !== undefined)
169
+ console.log(` ${colors.red(`logs unavailable: ${report.unreadable}`)}`)
170
+
171
+ for (const { line: text, count } of report.errors)
172
+ console.log(` ${colors.red(text)}${count > 1 ? colors.dim(` ×${count}`) : ''}`)
173
+
174
+ for (const text of report.tail)
175
+ console.log(` ${colors.dim(text)}`)
176
+ }
177
+ }
178
+
179
+ export async function dlog(args: string[]): Promise<number> {
180
+ const parsed = parse(args, usage, options, { exits: EXITS, 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 --level warn', 'warnings too'], ['dlog --grep "timeout|5\\d\\d"', 'the lines matching a regex'], ['dlog --fail-on down --fail-on unhealthy', 'a gate that ignores old errors'], ['dlog --json', 'the report as JSON']] })
181
+ const limit = wholeNumber(parsed.values.lines, 'lines', 5)
182
+ const matches = matcherOf(parsed.values.level === 'warn' ? 'warn' : 'error', parsed.values.grep)
183
+ const failOn = new Set<FailOn>((parsed.values['fail-on'] ?? []).length > 0 ? parsed.values['fail-on'] ?? FAIL_ON : FAIL_ON)
184
+ const known = await loadKnown()
185
+
186
+ const services = await allServices()
187
+
188
+ if (services.length === 0)
189
+ fail('no compose service here', 1)
190
+
191
+ const wanted = parsed.positionals
192
+ const unknown = wanted.filter(name => !services.some(service => key(service) === name || service.name === name))
193
+
194
+ if (unknown.length > 0)
195
+ fail(`no service named ${unknown.join(', ')}`, 1)
196
+
197
+ const chosen = wanted.length === 0 ? services : services.filter(service => wanted.includes(key(service)) || wanted.includes(service.name))
198
+ const reports: Report[] = await Promise.all(chosen.map(async (service) => {
199
+ const { log, container, unreadable } = await read(service, parsed.values.since)
200
+ const { health, fine } = healthOf(service.state, container)
201
+ const lines = withoutKeys(log.split('\n'))
202
+ const errors = errorLines(lines.join('\n'), limit, known, matches)
203
+ const tail = parsed.values.all === true && errors.length === 0 ? lines.map(text => clip(maskLine(stripAnsi(text).trim(), known))).filter(Boolean).slice(-limit) : []
204
+
205
+ return { key: key(service), state: service.state, health, status: service.status, errors, tail, unreadable, fine, down: !isUp(service.state, container), unhealthy: isUp(service.state, container) && !fine }
206
+ }))
207
+
208
+ if (parsed.values.json === true)
209
+ printJson(reports)
210
+ else
211
+ show(reports)
212
+
213
+ const down = reports.filter(report => needsLook(report, failOn))
214
+
215
+ if (parsed.values.json !== true && down.length > 0)
216
+ console.error(paint(process.stderr).dim(`${down.length} of ${reports.length} services need a look: ${down.map(report => report.key).join(', ')}`))
217
+
218
+ return down.length > 0 ? 1 : 0
219
+ }
package/src/dx-cli.ts CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  import process from 'node:process'
4
4
  import { runMain } from '@kaynooo/cli-kit'
5
- import { version } from '../package.json'
6
5
  import { dx } from './dx'
7
6
 
8
- process.exitCode = await runMain({ name: 'dx', version, run: dx })
7
+ process.exitCode = await runMain({ name: 'dx', run: dx })
package/src/dx.ts CHANGED
@@ -1,21 +1,26 @@
1
+ import type { ExitRow } from '@kaynooo/cli-kit'
1
2
  import type { Action, Service } from './stack'
2
3
  import process from 'node:process'
3
- import { closest, fail, jsonOption, paint, parse, printJson } from '@kaynooo/cli-kit'
4
+ import { loadKnown } from '@kaynooo/cli-env/files'
5
+ import { Redactor } from '@kaynooo/cli-env/redactor'
6
+ import { closest, fail, jsonOption, paint, parse, printJson, wholeNumber } from '@kaynooo/cli-kit'
4
7
  import { byRecency, record, runs } from '@kaynooo/cli-kit/fzf'
5
8
  import { KEY_LABELS, KEYS, pickService } from './picker'
6
- import { ACTIONS, allServices, commandFor, defaultAction, key, logsOf, serviceNamed, stateColor } from './stack'
9
+ import { ACTIONS, allServices, commandFor, defaultAction, key, LOGS_TAIL, logsOf, serviceNamed, stateColor } from './stack'
7
10
 
8
11
  const usage = `dx [service] [action]
9
12
  dx -
10
13
 
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
+ Without a terminal or without fzf (a script, a pipe, an agent) \`dx\` lists the services with their state, ports and image: that listing is the form to use there, \`--json\` giving the full record. On a terminal with fzf it picks one instead: 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.
15
+ \`dx -\` does the last action again, \`dx <service> [action]\` acts without the picker: \`shell\` (bash when the container has it), \`logs\` (follows the last ${LOGS_TAIL} lines; \`--tail N\` changes the count and \`--no-follow\` prints them once and exits, for a script), \`restart\`, \`up\`, \`stop\` or \`build\` (up with a rebuild).
16
+ 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
17
 
15
- Exits with the code of the command it ran, 1 when there is no compose service here or none by that name.`
18
+ const EXITS: ExitRow[] = [['the code of the command it ran', 'a listing, a shell, logs, restart, up, stop or build'], [0, 'it listed the services'], [1, 'no compose service here or none by that name'], [2, 'a usage error']]
16
19
 
17
20
  const options = {
18
21
  json: jsonOption('[{ key, project, name, state, status, ports, image }]'),
22
+ tail: { type: 'string', description: `log lines shown by the logs action (${LOGS_TAIL})` },
23
+ follow: { type: 'boolean', negative: 'print the logs once and exit instead of following them' },
19
24
  } as const
20
25
 
21
26
  function table(services: Service[]): string {
@@ -88,6 +93,27 @@ async function hidden(flag: string, rest: string[], services: Service[]): Promis
88
93
  return undefined
89
94
  }
90
95
 
96
+ /** The logs command with its output piped through `redact`, for the picker that runs the words itself; refused when `redact` is missing rather than leaking the logs. */
97
+ export function maskedLogs(command: string[], redactPath: string | null = Bun.which('redact')): string[] {
98
+ if (redactPath === null)
99
+ fail('redact is not installed, so the logs would not be masked', 1)
100
+
101
+ return ['sh', '-c', '"$@" | redact', 'sh', ...command]
102
+ }
103
+
104
+ export async function followMasked(command: string[]): Promise<number> {
105
+ const redactor = new Redactor(await loadKnown())
106
+ const decoder = new TextDecoder()
107
+ const proc = Bun.spawn(command, { stdio: ['inherit', 'pipe', 'inherit'] })
108
+
109
+ for await (const chunk of proc.stdout)
110
+ process.stdout.write(redactor.push(decoder.decode(chunk, { stream: true })))
111
+
112
+ process.stdout.write(redactor.end())
113
+
114
+ return proc.exited
115
+ }
116
+
91
117
  export async function dx(args: string[]): Promise<number> {
92
118
  const [flag = ''] = args
93
119
 
@@ -102,7 +128,7 @@ export async function dx(args: string[]): Promise<number> {
102
128
 
103
129
  // The protocol the tmux picker reads instead of running the command: its mode, the words typed back, and the command's words.
104
130
  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']] })
131
+ const parsed = parse(emit ? args.slice(1) : args, usage, options, { allowNegative: true, exits: EXITS, 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 php logs --tail 50 --no-follow', 'the last 50 lines, then exit'], ['dx -', 'the last action again']] })
106
132
  const [named, asked] = parsed.positionals
107
133
  const services = await allServices()
108
134
 
@@ -158,15 +184,20 @@ export async function dx(args: string[]): Promise<number> {
158
184
  }
159
185
 
160
186
  const chosen = action ?? defaultAction(service)
161
- const command = await commandFor(service, chosen)
187
+ const { tail, follow } = parsed.values
188
+
189
+ if ((tail !== undefined || follow !== undefined) && chosen !== 'logs')
190
+ fail('--tail and --no-follow apply to the logs action: dx <service> logs')
191
+
192
+ const command = await commandFor(service, chosen, { tail: tail === undefined ? undefined : wholeNumber(tail, 'tail', LOGS_TAIL, 0), follow })
162
193
 
163
194
  record('dx', { id: key(service), args: [chosen] })
164
195
 
165
196
  if (emit) {
166
- console.log(['run', action === undefined ? key(service) : `${key(service)} ${action}`, '', ...command.map(word => `=${word}`)].join('\n'))
197
+ console.log(['run', action === undefined ? key(service) : `${key(service)} ${action}`, '', ...(chosen === 'logs' ? maskedLogs(command) : command).map(word => `=${word}`)].join('\n'))
167
198
 
168
199
  return 0
169
200
  }
170
201
 
171
- return Bun.spawn(command, { stdio: ['inherit', 'inherit', 'inherit'] }).exited
202
+ return chosen === 'logs' ? followMasked(command) : Bun.spawn(command, { stdio: ['inherit', 'inherit', 'inherit'] }).exited
172
203
  }
package/src/sf-cli.ts CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  import process from 'node:process'
4
4
  import { runMain } from '@kaynooo/cli-kit'
5
- import { version } from '../package.json'
6
5
  import { sf } from './sf'
7
6
 
8
- process.exitCode = await runMain({ name: 'sf', version, run: sf })
7
+ process.exitCode = await runMain({ name: 'sf', run: sf })
package/src/sf.ts CHANGED
@@ -1,9 +1,10 @@
1
+ import type { ExitRow } from '@kaynooo/cli-kit'
1
2
  import type { Arg, Row } from '@kaynooo/cli-kit/fzf'
2
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
4
  import { homedir } from 'node:os'
4
5
  import { dirname, join, resolve } from 'node:path'
5
6
  import process from 'node:process'
6
- import { fail, paint, parse } from '@kaynooo/cli-kit'
7
+ import { fail, paint, parse, printJson } from '@kaynooo/cli-kit'
7
8
  import { byRecency, emit, fzf, list, record, runs, table } from '@kaynooo/cli-kit/fzf'
8
9
  import { input } from '@kaynooo/cli-kit/prompt'
9
10
  import { children, from, nearestStack, services, up } from './stack'
@@ -47,12 +48,13 @@ export interface Entry {
47
48
  const usage = `sf [command] [args...]
48
49
  sf -
49
50
 
50
- Picks a Symfony console command with fzf and runs it, asking for each required argument it needs. The commands last run come first, per folder, and the picker opens on the list cached from the last run while the console answers.
51
+ Without a terminal or without fzf (a script, a pipe, an agent) \`sf\` lists the commands, one per line with its description: that listing is the form to use there, and \`sf --list --json\` gives each with its arguments and options. On a terminal with fzf it picks a Symfony console command instead and runs it, asking for each required argument it needs. The commands last run come first, per folder, and the picker opens on the list cached from the last run while the console answers.
51
52
  The console is the nearest bin/console up to the repository root, then those of the child folders (keyed \`api/cache:clear\`). It runs in the php service of the nearest compose stack when there is one, else with the local php; the container's working directory is taken to be the app.
52
- \`sf -\` runs the last command again with the arguments it ran with, \`sf <command> [args...]\` runs without the picker: a name the list does not hold goes to the console as is, which resolves \`c:c\` to \`cache:clear\` itself. Without fzf or a terminal it lists the commands instead.
53
- The hidden flags --list, --names, --rows, --show, --record and --emit are for term, tmux and the shell completions.
53
+ \`sf -\` runs the last command again with the arguments it ran with, \`sf <command> [args...]\` runs without the picker: a name the list does not hold goes to the console as is, which resolves \`c:c\` to \`cache:clear\` itself.
54
+ \`sf --list --json\` (or \`sf --json\`) prints every command as JSON, each with its console folder, description, signature, arguments and options.
55
+ The hidden flags --list, --names, --rows, --show, --record and --emit are for term, tmux and the shell completions.`
54
56
 
55
- Exits with the code of the console, 1 when there is no bin/console here.`
57
+ const EXITS: ExitRow[] = [['the code of the console', 'a command it ran'], [0, 'it listed the commands'], [1, 'no bin/console here, or it listed no command'], [2, 'a usage error']]
56
58
 
57
59
  /** Fzf paints its rows and previews itself, so these keep their colors whatever stream they are written to. */
58
60
  const colors = paint({ isTTY: true })
@@ -109,14 +111,21 @@ function fromCache(dir: string): Command[] | undefined {
109
111
  }
110
112
  }
111
113
 
112
- async function live(app: Console): Promise<Command[]> {
114
+ export async function live(app: Console): Promise<Command[]> {
113
115
  const proc = Bun.spawn([...app.list.argv, 'list', '--format=json'], { cwd: app.list.cwd, env: app.list.env, stdout: 'pipe', stderr: 'pipe' })
114
116
  const text = await new Response(proc.stdout).text()
115
117
 
116
118
  if ((await proc.exited) !== 0)
117
119
  return fromCache(app.dir) ?? []
118
120
 
119
- const commands = (JSON.parse(text) as { commands: Command[] }).commands.filter(command => !command.hidden)
121
+ let commands: Command[]
122
+
123
+ try {
124
+ commands = (JSON.parse(text) as { commands: Command[] }).commands.filter(command => !command.hidden)
125
+ }
126
+ catch {
127
+ return fromCache(app.dir) ?? []
128
+ }
120
129
 
121
130
  mkdirSync(dirname(cached(app.dir)), { recursive: true })
122
131
  writeFileSync(cached(app.dir), JSON.stringify(commands))
@@ -150,6 +159,21 @@ export function signature(command: Command): string {
150
159
  .join(' ')
151
160
  }
152
161
 
162
+ /** A command as `sf --json` prints it, the arguments and options of the console's own definition. */
163
+ export function entryJson(entry: Entry) {
164
+ const { command } = entry
165
+
166
+ return {
167
+ name: key(entry),
168
+ command: command.name,
169
+ dir: entry.app.dir,
170
+ description: command.description,
171
+ signature: signature(command),
172
+ arguments: argumentsOf(command).map(argument => ({ name: argument.name, required: argument.is_required, array: argument.is_array, description: argument.description })),
173
+ options: optionsOf(command).map(option => ({ name: option.name, shortcut: option.shortcut, acceptsValue: option.accept_value, description: option.description })),
174
+ }
175
+ }
176
+
153
177
  function rows(found: Entry[]): Row[] {
154
178
  return byRecency(found, runs('sf'), key).map(entry => ({
155
179
  hidden: [entry.app.dir, entry.command.name],
@@ -293,6 +317,15 @@ async function hidden(flag: string, rest: string[]): Promise<number | undefined>
293
317
 
294
318
  export async function sf(args: string[]): Promise<number> {
295
319
  const [flag = ''] = args
320
+
321
+ if (args.length <= 2 && args.includes('--json') && (flag === '--json' || flag === '--list')) {
322
+ const found = await entries(false)
323
+
324
+ printJson(byRecency(found, runs('sf'), key).map(entryJson))
325
+
326
+ return 0
327
+ }
328
+
296
329
  const flagged = await hidden(flag, args.slice(1))
297
330
 
298
331
  if (flagged !== undefined)
@@ -305,6 +338,7 @@ export async function sf(args: string[]): Promise<number> {
305
338
  const parsed = parse(last ? [] : words, usage, {}, {
306
339
  positionals: ['[command]'],
307
340
  trailing: true,
341
+ exits: EXITS,
308
342
  replaces: 'bin/console typed in the php container, with the command names and arguments looked up by hand',
309
343
  examples: ['sf', ['sf cache:clear', 'run it without the picker'], ['sf c:c --env=prod', 'the console resolves abbreviations itself'], ['sf -', 'the last command again, with its arguments']],
310
344
  })
package/src/stack.ts CHANGED
@@ -1,5 +1,9 @@
1
+ import type { KnownSecrets } from '@kaynooo/cli-env/mask'
1
2
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
3
  import { dirname, join, relative, resolve } from 'node:path'
4
+ import { loadKnown } from '@kaynooo/cli-env/files'
5
+ import { Redactor } from '@kaynooo/cli-env/redactor'
6
+ import { captureOutput, composeError } from '@kaynooo/cli-kit'
3
7
 
4
8
  /** How to reach one compose project: the command a user runs, and the one this package lists it with. */
5
9
  export interface Stack {
@@ -42,6 +46,9 @@ export interface Container {
42
46
  Service: string
43
47
  State: string
44
48
  Status: string
49
+ /** Empty for a container with no healthcheck. */
50
+ Health?: string
51
+ ExitCode?: number
45
52
  }
46
53
 
47
54
  const LABEL_FORMAT = ['project', 'project.config_files', 'project.environment_file', 'project.working_dir'].map(label => `{{.Label "com.docker.compose.${label}"}}`).join('\t')
@@ -158,10 +165,15 @@ export async function recordedNearby(): Promise<Labels[]> {
158
165
  return (await labels()).filter(stack => dirs.has(stack.workdir))
159
166
  }
160
167
 
161
- async function output(stack: Stack, args: string[]): Promise<string> {
162
- const proc = Bun.spawn([...stack.list.argv, ...args], { cwd: stack.list.cwd, env: stack.list.env, stdout: 'pipe', stderr: 'pipe' })
168
+ /** What compose printed, and its stderr when it exited non-zero. */
169
+ export async function attempt(stack: Stack, args: string[]): Promise<{ text: string, error: string | undefined }> {
170
+ const result = await captureOutput([...stack.list.argv, ...args], { cwd: stack.list.cwd, env: stack.list.env })
163
171
 
164
- return (await proc.exited) === 0 ? new Response(proc.stdout).text() : ''
172
+ return result.code === 0 ? { text: result.stdout, error: undefined } : { text: '', error: composeError(result) }
173
+ }
174
+
175
+ async function output(stack: Stack, args: string[]): Promise<string> {
176
+ return (await attempt(stack, args)).text
165
177
  }
166
178
 
167
179
  /** Compose prints one container per line, where older versions printed a JSON array. */
@@ -216,14 +228,21 @@ async function shellOf(service: Service): Promise<string> {
216
228
  return (await output(service.stack, ['exec', '-T', service.name, 'sh', '-c', 'command -v bash'])) ? 'bash' : 'sh'
217
229
  }
218
230
 
219
- export async function commandFor(service: Service, action: Action): Promise<string[]> {
231
+ export const LOGS_TAIL = 200
232
+
233
+ export interface LogsOptions {
234
+ tail?: number | undefined
235
+ follow?: boolean | undefined
236
+ }
237
+
238
+ export async function commandFor(service: Service, action: Action, logs: LogsOptions = {}): Promise<string[]> {
220
239
  const run = service.stack.run
221
240
 
222
241
  switch (action) {
223
242
  case 'shell':
224
243
  return [...run, 'exec', service.name, await shellOf(service)]
225
244
  case 'logs':
226
- return [...run, 'logs', '-f', '--tail=200', service.name]
245
+ return [...run, 'logs', ...logs.follow === false ? [] : ['-f'], `--tail=${logs.tail ?? LOGS_TAIL}`, service.name]
227
246
  case 'restart':
228
247
  return [...run, 'restart', service.name]
229
248
  case 'up':
@@ -259,7 +278,14 @@ export async function serviceNamed(dir: string, name: string, project: string):
259
278
  return (await services(stack)).find(candidate => candidate.name === name)
260
279
  }
261
280
 
262
- // Compose's own words for its output of one service, the last lines of its logs.
281
+ /** A whole log with its secrets masked as `redact` does. */
282
+ export function maskLog(text: string, known?: KnownSecrets): string {
283
+ const redactor = new Redactor(known)
284
+
285
+ return redactor.push(text) + redactor.end()
286
+ }
287
+
288
+ // Compose's own words for its output of one service, the last lines of its logs, secrets masked.
263
289
  export async function logsOf(stack: Stack, name: string): Promise<string> {
264
- return output(stack, ['logs', '--tail=30', '--no-log-prefix', name])
290
+ return maskLog(await output(stack, ['logs', '--tail=30', '--no-log-prefix', name]), await loadKnown())
265
291
  }