@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/README.md +6 -3
- package/dist/dc-cli.js +448 -27
- package/dist/dlog-cli.js +1465 -0
- package/dist/dx-cli.js +479 -42
- package/dist/sf-cli.js +494 -50
- package/package.json +6 -3
- package/src/dc-cli.ts +1 -2
- package/src/dlog-cli.ts +7 -0
- package/src/dlog.ts +219 -0
- 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 +33 -7
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaynooo/cli-docker",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "dc, dx and sf: docker compose on the stack found here, including one only its containers tell of, a compose service's shell, logs, restart, stop or rebuild, and a Symfony console command picked and run.",
|
|
4
|
+
"version": "0.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',
|
|
26
|
+
process.exitCode = await runMain({ name: 'dc', run: main })
|
package/src/dlog-cli.ts
ADDED
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',
|
|
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 {
|
|
@@ -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
|
-
|
|
162
|
-
|
|
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
|
|
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
|
|
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',
|
|
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
|
-
|
|
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
|
}
|