@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/README.md +5 -3
- package/dist/dc-cli.js +442 -32
- package/dist/dlog-cli.js +349 -48
- package/dist/dx-cli.js +473 -47
- package/dist/sf-cli.js +488 -55
- package/package.json +1 -1
- package/src/dc-cli.ts +1 -2
- package/src/dlog-cli.ts +1 -2
- package/src/dlog.ts +61 -21
- package/src/dx-cli.ts +1 -2
- package/src/dx.ts +41 -10
- package/src/sf-cli.ts +1 -2
- package/src/sf.ts +41 -7
- package/src/stack.ts +24 -12
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaynooo/cli-docker",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "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',
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
|
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 === '' || !
|
|
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
|
|
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 =
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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 =>
|
|
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',
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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',
|
|
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
|
-
|
|
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.
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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',
|
|
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
|
-
|
|
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
|
}
|