@kaynooo/cli-docker 0.4.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,7 +1,7 @@
1
1
  {
2
2
  "name": "@kaynooo/cli-docker",
3
3
  "type": "module",
4
- "version": "0.4.0",
4
+ "version": "0.5.0",
5
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",
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 })
package/src/dlog-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 { dlog } from './dlog'
7
6
 
8
- process.exitCode = await runMain({ name: 'dlog', version, run: dlog })
7
+ process.exitCode = await runMain({ name: 'dlog', run: dlog })
package/src/dlog.ts CHANGED
@@ -1,24 +1,37 @@
1
+ import type { KnownSecrets } from '@kaynooo/cli-env/mask'
2
+ import type { ExitRow } from '@kaynooo/cli-kit'
1
3
  import type { Container, Service } from './stack'
2
4
  import process from 'node:process'
5
+ import { loadKnown } from '@kaynooo/cli-env/files'
3
6
  import { maskLine, PEM_BEGIN, PEM_END } from '@kaynooo/cli-env/mask'
4
- import { fail, jsonOption, paint, parse, printJson, stripAnsi } from '@kaynooo/cli-kit'
7
+ import { badUsage, fail, jsonOption, paint, parse, printJson, stripAnsi, wholeNumber } from '@kaynooo/cli-kit'
5
8
  import { allServices, attempt, containers, key, stateColor } from './stack'
6
9
 
7
10
  const usage = `dlog [service...]
8
11
 
9
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.
10
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.`
11
15
 
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.`
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]
13
21
 
14
22
  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 }]'),
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 }]'),
19
30
  } as const
20
31
 
21
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') }
22
35
  const TAIL = 500
23
36
  const MAX_WIDTH = 220
24
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
@@ -41,14 +54,14 @@ function clip(line: string): string {
41
54
  return line.length > MAX_WIDTH ? `${line.slice(0, MAX_WIDTH - 1)}…` : line
42
55
  }
43
56
 
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[] {
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[] {
46
59
  const seen = new Map<string, ErrorLine>()
47
60
 
48
61
  for (const raw of log.split('\n')) {
49
62
  const line = stripAnsi(raw).trim()
50
63
 
51
- if (line === '' || !ERROR.test(line))
64
+ if (line === '' || !matches.test(line))
52
65
  continue
53
66
 
54
67
  const shared = shape(line)
@@ -56,7 +69,7 @@ export function errorLines(log: string, limit: number): ErrorLine[] {
56
69
 
57
70
  // Re-inserted so the Map's order is the order of the latest occurrences.
58
71
  seen.delete(shared)
59
- seen.set(shared, { line: clip(maskLine(line)), count: (before?.count ?? 0) + 1 })
72
+ seen.set(shared, { line: clip(maskLine(line, known)), count: (before?.count ?? 0) + 1 })
60
73
  }
61
74
 
62
75
  return [...seen.values()].slice(-limit)
@@ -69,11 +82,15 @@ export interface Health {
69
82
  fine: boolean
70
83
  }
71
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
+
72
90
  export function healthOf(state: string, container: Container | undefined): Health {
73
91
  const health = (container?.Health ?? '') || (STATUS_HEALTH.exec(container?.Status ?? '')?.[1]?.replace('health: ', '') ?? '')
74
- const finished = state === 'exited' && container?.ExitCode === 0
75
92
 
76
- return { health, fine: (state === 'running' || finished) && (health === '' || health === 'healthy') }
93
+ return { health, fine: isUp(state, container) && (health === '' || health === 'healthy') }
77
94
  }
78
95
 
79
96
  export interface Report {
@@ -86,6 +103,29 @@ export interface Report {
86
103
  /** Why compose could not list the service or read its logs. */
87
104
  unreadable: string | undefined
88
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
+ }
89
129
  }
90
130
 
91
131
  async function read(service: Service, since: string): Promise<{ log: string, container: Container | undefined, unreadable: string | undefined }> {
@@ -137,11 +177,11 @@ function show(reports: Report[]): void {
137
177
  }
138
178
 
139
179
  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')
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()
145
185
 
146
186
  const services = await allServices()
147
187
 
@@ -159,10 +199,10 @@ export async function dlog(args: string[]): Promise<number> {
159
199
  const { log, container, unreadable } = await read(service, parsed.values.since)
160
200
  const { health, fine } = healthOf(service.state, container)
161
201
  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) : []
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) : []
164
204
 
165
- return { key: key(service), state: service.state, health, status: service.status, errors, tail, unreadable, fine }
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 }
166
206
  }))
167
207
 
168
208
  if (parsed.values.json === true)
@@ -170,7 +210,7 @@ export async function dlog(args: string[]): Promise<number> {
170
210
  else
171
211
  show(reports)
172
212
 
173
- const down = reports.filter(report => !report.fine || report.unreadable !== undefined || report.errors.length > 0)
213
+ const down = reports.filter(report => needsLook(report, failOn))
174
214
 
175
215
  if (parsed.values.json !== true && down.length > 0)
176
216
  console.error(paint(process.stderr).dim(`${down.length} of ${reports.length} services need a look: ${down.map(report => report.key).join(', ')}`))
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 {
@@ -163,15 +167,9 @@ export async function recordedNearby(): Promise<Labels[]> {
163
167
 
164
168
  /** What compose printed, and its stderr when it exited non-zero. */
165
169
  export async function attempt(stack: Stack, args: string[]): Promise<{ text: string, error: string | undefined }> {
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])
170
+ const result = await captureOutput([...stack.list.argv, ...args], { cwd: stack.list.cwd, env: stack.list.env })
168
171
 
169
- if (code === 0)
170
- return { text, error: undefined }
171
-
172
- const last = stderr.trim().split('\n').at(-1) ?? ''
173
-
174
- return { text: '', error: last === '' ? `exit ${code}` : last }
172
+ return result.code === 0 ? { text: result.stdout, error: undefined } : { text: '', error: composeError(result) }
175
173
  }
176
174
 
177
175
  async function output(stack: Stack, args: string[]): Promise<string> {
@@ -230,14 +228,21 @@ async function shellOf(service: Service): Promise<string> {
230
228
  return (await output(service.stack, ['exec', '-T', service.name, 'sh', '-c', 'command -v bash'])) ? 'bash' : 'sh'
231
229
  }
232
230
 
233
- 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[]> {
234
239
  const run = service.stack.run
235
240
 
236
241
  switch (action) {
237
242
  case 'shell':
238
243
  return [...run, 'exec', service.name, await shellOf(service)]
239
244
  case 'logs':
240
- return [...run, 'logs', '-f', '--tail=200', service.name]
245
+ return [...run, 'logs', ...logs.follow === false ? [] : ['-f'], `--tail=${logs.tail ?? LOGS_TAIL}`, service.name]
241
246
  case 'restart':
242
247
  return [...run, 'restart', service.name]
243
248
  case 'up':
@@ -273,7 +278,14 @@ export async function serviceNamed(dir: string, name: string, project: string):
273
278
  return (await services(stack)).find(candidate => candidate.name === name)
274
279
  }
275
280
 
276
- // 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.
277
289
  export async function logsOf(stack: Stack, name: string): Promise<string> {
278
- 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())
279
291
  }