@kaynooo/cli-docker 0.1.0 → 0.3.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 +8 -2
- package/dist/dc-cli.js +16 -1
- package/dist/dx-cli.js +236 -30
- package/dist/sf-cli.js +1328 -0
- package/package.json +7 -5
- package/src/dx.ts +117 -24
- package/src/picker.ts +37 -0
- package/src/sf-cli.ts +8 -0
- package/src/sf.ts +368 -0
- package/src/stack.ts +27 -3
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 and
|
|
4
|
+
"version": "0.3.0",
|
|
5
|
+
"description": "dc, dx and sf: docker compose on the stack found here, including one only its containers tell of, a compose service's shell, logs, restart, stop or rebuild, and a Symfony console command picked and run.",
|
|
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,7 +18,8 @@
|
|
|
18
18
|
},
|
|
19
19
|
"bin": {
|
|
20
20
|
"dc": "./dist/dc-cli.js",
|
|
21
|
-
"dx": "./dist/dx-cli.js"
|
|
21
|
+
"dx": "./dist/dx-cli.js",
|
|
22
|
+
"sf": "./dist/sf-cli.js"
|
|
22
23
|
},
|
|
23
24
|
"files": ["dist", "src"],
|
|
24
25
|
"engines": {
|
|
@@ -28,9 +29,10 @@
|
|
|
28
29
|
"registry": "https://git.kaynooo.fr/api/packages/kaynooo/npm/"
|
|
29
30
|
},
|
|
30
31
|
"scripts": {
|
|
31
|
-
"build": "$npm_execpath build src/dc-cli.ts src/dx-cli.ts --target bun --outdir dist",
|
|
32
|
+
"build": "$npm_execpath build src/dc-cli.ts src/dx-cli.ts src/sf-cli.ts --target bun --outdir dist",
|
|
32
33
|
"dc": "$npm_execpath src/dc-cli.ts",
|
|
33
|
-
"dx": "$npm_execpath src/dx-cli.ts"
|
|
34
|
+
"dx": "$npm_execpath src/dx-cli.ts",
|
|
35
|
+
"sf": "$npm_execpath src/sf-cli.ts"
|
|
34
36
|
},
|
|
35
37
|
"devDependencies": {
|
|
36
38
|
"@kaynooo/cli-kit": "0.20.0"
|
package/src/dx.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import type { Action, Service } from './stack'
|
|
2
2
|
import process from 'node:process'
|
|
3
3
|
import { closest, fail, jsonOption, paint, parse, printJson } from '@kaynooo/cli-kit'
|
|
4
|
-
import {
|
|
4
|
+
import { byRecency, record, runs } from '@kaynooo/cli-kit/fzf'
|
|
5
|
+
import { KEY_LABELS, KEYS, pickService } from './picker'
|
|
6
|
+
import { ACTIONS, allServices, commandFor, defaultAction, key, logsOf, serviceNamed, stateColor } from './stack'
|
|
5
7
|
|
|
6
8
|
const usage = `dx [service] [action]
|
|
9
|
+
dx -
|
|
7
10
|
|
|
8
|
-
Without a service,
|
|
9
|
-
|
|
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\`).
|
|
10
14
|
|
|
11
15
|
Exits with the code of the command it ran, 1 when there is no compose service here or none by that name.`
|
|
12
16
|
|
|
@@ -14,17 +18,6 @@ const options = {
|
|
|
14
18
|
json: jsonOption('[{ key, project, name, state, status, ports, image }]'),
|
|
15
19
|
} as const
|
|
16
20
|
|
|
17
|
-
// Up in green, down in red, never created dimmed, anything on its way, restarting or paused, in yellow.
|
|
18
|
-
function stateColor(state: string): 'green' | 'red' | 'dim' | 'yellow' {
|
|
19
|
-
if (state === 'running')
|
|
20
|
-
return 'green'
|
|
21
|
-
|
|
22
|
-
if (state === 'exited' || state === 'dead')
|
|
23
|
-
return 'red'
|
|
24
|
-
|
|
25
|
-
return state === 'none' ? 'dim' : 'yellow'
|
|
26
|
-
}
|
|
27
|
-
|
|
28
21
|
function table(services: Service[]): string {
|
|
29
22
|
const colors = paint(process.stdout)
|
|
30
23
|
const keys = services.map(key)
|
|
@@ -51,29 +44,129 @@ function find(services: Service[], named: string): Service {
|
|
|
51
44
|
return fail(`no service named ${named}${nearest === undefined ? '' : `, did you mean ${nearest}?`}`, 1)
|
|
52
45
|
}
|
|
53
46
|
|
|
47
|
+
// The rows term lists in place of the picker: what it types back for enter and for each key.
|
|
48
|
+
function listed(services: Service[]): unknown[] {
|
|
49
|
+
return byRecency(services, runs('dx'), key).map(service => ({
|
|
50
|
+
label: key(service),
|
|
51
|
+
hint: [service.state, service.ports, service.image].filter(Boolean).join(' · '),
|
|
52
|
+
typed: key(service),
|
|
53
|
+
preview: ['--show', service.stack.dir, service.name, service.stack.project ?? ''],
|
|
54
|
+
actions: Object.entries(KEYS).map(([pressed, action]) => ({ label: KEY_LABELS[pressed] ?? action, key: pressed, typed: `${key(service)} ${action}` })),
|
|
55
|
+
}))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The hidden flags term and the shell completions call, each printing and nothing more.
|
|
59
|
+
async function hidden(flag: string, rest: string[], services: Service[]): Promise<number | undefined> {
|
|
60
|
+
if (flag === '--list') {
|
|
61
|
+
console.log(JSON.stringify(listed(services)))
|
|
62
|
+
|
|
63
|
+
return 0
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// With the state as the description fish shows beside each name.
|
|
67
|
+
if (flag === '--names') {
|
|
68
|
+
console.log(services.map(service => `${key(service)}\t${service.state}`).join('\n'))
|
|
69
|
+
|
|
70
|
+
return 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (flag === '--show') {
|
|
74
|
+
const [dir = '.', name = '', project = ''] = rest
|
|
75
|
+
const service = await serviceNamed(dir, name, project)
|
|
76
|
+
|
|
77
|
+
if (service === undefined)
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
const colors = paint(process.stdout)
|
|
81
|
+
|
|
82
|
+
console.log(`${colors[stateColor(service.state)](service.status || service.state)} ${colors.dim(service.image)}${service.ports ? ` ${colors.cyan(service.ports)}` : ''}\n`)
|
|
83
|
+
console.log(await logsOf(service.stack, name))
|
|
84
|
+
|
|
85
|
+
return 0
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
54
91
|
export async function dx(args: string[]): Promise<number> {
|
|
55
|
-
const
|
|
92
|
+
const [flag = ''] = args
|
|
93
|
+
|
|
94
|
+
if (flag === '--show' || flag === '--list' || flag === '--names') {
|
|
95
|
+
const services = flag === '--show' ? [] : await allServices()
|
|
96
|
+
|
|
97
|
+
if (services.length === 0 && flag !== '--show')
|
|
98
|
+
fail('no compose service here', 1)
|
|
99
|
+
|
|
100
|
+
return await hidden(flag, args.slice(1), services) ?? 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The protocol the tmux picker reads instead of running the command: its mode, the words typed back, and the command's words.
|
|
104
|
+
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']] })
|
|
56
106
|
const [named, asked] = parsed.positionals
|
|
57
107
|
const services = await allServices()
|
|
58
108
|
|
|
59
109
|
if (services.length === 0)
|
|
60
110
|
fail('no compose service here', 1)
|
|
61
111
|
|
|
112
|
+
let service: Service | undefined
|
|
113
|
+
let action: Action | undefined
|
|
114
|
+
|
|
62
115
|
if (named === undefined) {
|
|
63
|
-
|
|
64
|
-
printJson(services.map(service => ({ key: key(service), project: service.project, name: service.name, state: service.state, status: service.status, ports: service.ports, image: service.image })))
|
|
65
|
-
else
|
|
66
|
-
console.log(table(services))
|
|
116
|
+
const interactive = process.stdin.isTTY && process.stderr.isTTY && parsed.values.json !== true
|
|
67
117
|
|
|
68
|
-
|
|
118
|
+
if (!interactive || Bun.which('fzf') === null) {
|
|
119
|
+
if (emit)
|
|
120
|
+
fail('the picker needs a terminal and fzf', 1)
|
|
121
|
+
|
|
122
|
+
if (interactive)
|
|
123
|
+
console.error('fzf is not installed, listing the services instead')
|
|
124
|
+
|
|
125
|
+
if (parsed.values.json === true)
|
|
126
|
+
printJson(services.map(each => ({ key: key(each), project: each.project, name: each.name, state: each.state, status: each.status, ports: each.ports, image: each.image })))
|
|
127
|
+
else
|
|
128
|
+
console.log(table(services))
|
|
129
|
+
|
|
130
|
+
return 0
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const picked = pickService(byRecency(services, runs('dx'), key))
|
|
134
|
+
|
|
135
|
+
if (picked === undefined)
|
|
136
|
+
return 1
|
|
137
|
+
|
|
138
|
+
service = picked.service
|
|
139
|
+
action = KEYS[picked.key]
|
|
140
|
+
}
|
|
141
|
+
else if (named === '-') {
|
|
142
|
+
const last = runs('dx')[0]
|
|
143
|
+
|
|
144
|
+
service = last && services.find(candidate => key(candidate) === last.id)
|
|
145
|
+
|
|
146
|
+
if (service === undefined)
|
|
147
|
+
fail(last ? `${last.id} is gone` : 'nothing done here yet', 1)
|
|
148
|
+
|
|
149
|
+
action = last!.args[0] as Action | undefined
|
|
69
150
|
}
|
|
151
|
+
else {
|
|
152
|
+
service = find(services, named)
|
|
70
153
|
|
|
71
|
-
|
|
154
|
+
if (asked !== undefined && !ACTIONS.includes(asked as Action))
|
|
155
|
+
fail(`no action ${asked}, one of ${ACTIONS.join(', ')}`)
|
|
72
156
|
|
|
73
|
-
|
|
74
|
-
|
|
157
|
+
action = asked as Action | undefined
|
|
158
|
+
}
|
|
75
159
|
|
|
76
|
-
const
|
|
160
|
+
const chosen = action ?? defaultAction(service)
|
|
161
|
+
const command = await commandFor(service, chosen)
|
|
162
|
+
|
|
163
|
+
record('dx', { id: key(service), args: [chosen] })
|
|
164
|
+
|
|
165
|
+
if (emit) {
|
|
166
|
+
console.log(['run', action === undefined ? key(service) : `${key(service)} ${action}`, '', ...command.map(word => `=${word}`)].join('\n'))
|
|
167
|
+
|
|
168
|
+
return 0
|
|
169
|
+
}
|
|
77
170
|
|
|
78
171
|
return Bun.spawn(command, { stdio: ['inherit', 'inherit', 'inherit'] }).exited
|
|
79
172
|
}
|
package/src/picker.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Row } from '@kaynooo/cli-kit/fzf'
|
|
2
|
+
import type { Action, Service } from './stack'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
import { paint } from '@kaynooo/cli-kit'
|
|
5
|
+
import { fzf, heightFor } from '@kaynooo/cli-kit/fzf'
|
|
6
|
+
import { key, stateColor } from './stack'
|
|
7
|
+
|
|
8
|
+
export const KEYS: Record<string, Action> = { 'ctrl-l': 'logs', 'ctrl-r': 'restart', 'ctrl-x': 'stop', 'ctrl-b': 'build' }
|
|
9
|
+
export const KEY_LABELS: Record<string, string> = { 'ctrl-l': 'Logs', 'ctrl-r': 'Restart', 'ctrl-x': 'Stop', 'ctrl-b': 'Rebuild' }
|
|
10
|
+
|
|
11
|
+
// The hidden fields come back with the choice: folder, name, key, compose project.
|
|
12
|
+
function row(service: Service): Row {
|
|
13
|
+
const colors = paint(process.stderr)
|
|
14
|
+
const dir = service.stack.dir
|
|
15
|
+
const project = service.stack.project
|
|
16
|
+
const name = project === undefined ? service.name : `${colors.dim(`${project}:`)}${service.name}`
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
hidden: [dir, service.name, key(service), project ?? ''],
|
|
20
|
+
cells: [dir === '.' ? name : `${colors.dim(`${dir}/`)}${name}`, colors[stateColor(service.state)](service.state), colors.cyan(service.ports), colors.dim(service.image)],
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The service picked with fzf and the key that picked it, enter's being empty; undefined on escape.
|
|
25
|
+
export function pickService(services: Service[]): { service: Service, key: string } | undefined {
|
|
26
|
+
const picked = fzf(services.map(row), {
|
|
27
|
+
prompt: 'dx> ',
|
|
28
|
+
header: 'enter shell or up, ctrl-l logs, ctrl-r restart, ctrl-x stop, ctrl-b rebuild',
|
|
29
|
+
expect: Object.keys(KEYS),
|
|
30
|
+
searched: 1,
|
|
31
|
+
height: heightFor(services.length),
|
|
32
|
+
preview: `${process.execPath} ${process.argv[1]} --show {1} {2} {4}`,
|
|
33
|
+
})
|
|
34
|
+
const service = services.find(candidate => key(candidate) === picked?.hidden[2])
|
|
35
|
+
|
|
36
|
+
return picked === undefined || service === undefined ? undefined : { service, key: picked.key }
|
|
37
|
+
}
|
package/src/sf-cli.ts
ADDED
package/src/sf.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import type { Arg, Row } from '@kaynooo/cli-kit/fzf'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { dirname, join, resolve } from 'node:path'
|
|
5
|
+
import process from 'node:process'
|
|
6
|
+
import { fail, paint, parse } from '@kaynooo/cli-kit'
|
|
7
|
+
import { byRecency, emit, fzf, list, record, runs, table } from '@kaynooo/cli-kit/fzf'
|
|
8
|
+
import { input } from '@kaynooo/cli-kit/prompt'
|
|
9
|
+
import { children, from, nearestStack, services, up } from './stack'
|
|
10
|
+
|
|
11
|
+
interface Argument {
|
|
12
|
+
name: string
|
|
13
|
+
is_required: boolean
|
|
14
|
+
is_array: boolean
|
|
15
|
+
description: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Option {
|
|
19
|
+
name: string
|
|
20
|
+
shortcut: string
|
|
21
|
+
accept_value: boolean
|
|
22
|
+
description: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A command as `bin/console list --format=json` describes it, a definition without arguments or options holding an empty array instead of an object. */
|
|
26
|
+
export interface Command {
|
|
27
|
+
name: string
|
|
28
|
+
description: string
|
|
29
|
+
help: string
|
|
30
|
+
usage: string[]
|
|
31
|
+
hidden: boolean
|
|
32
|
+
definition: { arguments: Record<string, Argument> | [], options: Record<string, Option> | [] }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One console, and how its commands are run and listed. */
|
|
36
|
+
export interface Console {
|
|
37
|
+
dir: string
|
|
38
|
+
run: string[]
|
|
39
|
+
list: { argv: string[], cwd: string, env: Record<string, string | undefined> }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Entry {
|
|
43
|
+
app: Console
|
|
44
|
+
command: Command
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const usage = `sf [command] [args...]
|
|
48
|
+
sf -
|
|
49
|
+
|
|
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
|
+
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.
|
|
54
|
+
|
|
55
|
+
Exits with the code of the console, 1 when there is no bin/console here.`
|
|
56
|
+
|
|
57
|
+
/** Fzf paints its rows and previews itself, so these keep their colors whatever stream they are written to. */
|
|
58
|
+
const colors = paint({ isTTY: true })
|
|
59
|
+
const MARKUP = /<(info|comment|question|error)>(.*?)<\/\1>/gs
|
|
60
|
+
const TAGS = { info: 'green', comment: 'yellow', question: 'cyan', error: 'red' } as const
|
|
61
|
+
const LINKS = /<\/?href[^>]*>/g
|
|
62
|
+
|
|
63
|
+
/** Symfony's console markup, in the colors the console itself gives it. */
|
|
64
|
+
export function marked(text: string): string {
|
|
65
|
+
return text.replace(MARKUP, (_, tag: keyof typeof TAGS, inner: string) => colors[TAGS[tag]](inner)).replace(LINKS, '')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function argumentsOf(command: Command): Argument[] {
|
|
69
|
+
return Array.isArray(command.definition.arguments) ? [] : Object.values(command.definition.arguments)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function optionsOf(command: Command): Option[] {
|
|
73
|
+
return Array.isArray(command.definition.options) ? [] : Object.values(command.definition.options)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The php service by name, `php` itself before `booki-php` or `dorad_php`. */
|
|
77
|
+
export function phpService(names: string[]): string | undefined {
|
|
78
|
+
return names.find(name => name === 'php') ?? names.find(name => name.includes('php'))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function consoleAt(dir: string): Promise<Console> {
|
|
82
|
+
const stack = nearestStack(dir)
|
|
83
|
+
const php = stack === undefined ? undefined : phpService((await services(stack)).map(service => service.name))
|
|
84
|
+
|
|
85
|
+
if (stack !== undefined && php !== undefined)
|
|
86
|
+
return { dir, run: [...stack.run, 'exec', php, 'bin/console'], list: { ...stack.list, argv: [...stack.list.argv, 'exec', '-T', php, 'bin/console'] } }
|
|
87
|
+
|
|
88
|
+
return { dir, run: from(dir, ['php', 'bin/console']), list: { argv: ['php', 'bin/console'], cwd: resolve(dir), env: process.env } }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The console of this folder or its nearest parent, then those of the child folders. */
|
|
92
|
+
async function consoles(): Promise<Console[]> {
|
|
93
|
+
const here = up('.').find(dir => existsSync(join(dir, 'bin/console')))
|
|
94
|
+
const dirs = [...(here === undefined ? [] : [here]), ...children().filter(dir => existsSync(join(dir, 'bin/console')))]
|
|
95
|
+
|
|
96
|
+
return Promise.all([...new Set(dirs)].map(consoleAt))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function cached(dir: string): string {
|
|
100
|
+
return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'sf', `${resolve(dir).replaceAll('/', '%')}.json`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fromCache(dir: string): Command[] | undefined {
|
|
104
|
+
try {
|
|
105
|
+
return existsSync(cached(dir)) ? JSON.parse(readFileSync(cached(dir), 'utf-8')) as Command[] : undefined
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function live(app: Console): Promise<Command[]> {
|
|
113
|
+
const proc = Bun.spawn([...app.list.argv, 'list', '--format=json'], { cwd: app.list.cwd, env: app.list.env, stdout: 'pipe', stderr: 'pipe' })
|
|
114
|
+
const text = await new Response(proc.stdout).text()
|
|
115
|
+
|
|
116
|
+
if ((await proc.exited) !== 0)
|
|
117
|
+
return fromCache(app.dir) ?? []
|
|
118
|
+
|
|
119
|
+
const commands = (JSON.parse(text) as { commands: Command[] }).commands.filter(command => !command.hidden)
|
|
120
|
+
|
|
121
|
+
mkdirSync(dirname(cached(app.dir)), { recursive: true })
|
|
122
|
+
writeFileSync(cached(app.dir), JSON.stringify(commands))
|
|
123
|
+
|
|
124
|
+
return commands
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function entries(fresh: boolean): Promise<Entry[]> {
|
|
128
|
+
const found = await consoles()
|
|
129
|
+
const lists = await Promise.all(found.map(async app => (fresh ? live(app) : fromCache(app.dir) ?? live(app))))
|
|
130
|
+
|
|
131
|
+
return found.flatMap((app, index) => lists[index]!.map(command => ({ app, command })))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What names a command: `cache:clear` in this folder's console, `api/cache:clear` in a child's. */
|
|
135
|
+
export function key(entry: Entry): string {
|
|
136
|
+
return entry.app.dir === '.' ? entry.command.name : `${entry.app.dir}/${entry.command.name}`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The namespace dimmed, the command standing out: `cache:` in `cache:clear`. */
|
|
140
|
+
function named(name: string): string {
|
|
141
|
+
const colon = name.lastIndexOf(':')
|
|
142
|
+
|
|
143
|
+
return colon === -1 ? name : `${colors.dim(name.slice(0, colon + 1))}${name.slice(colon + 1)}`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Required arguments read as `<name>`, the others as `[name]`, a list as `name...`. */
|
|
147
|
+
export function signature(command: Command): string {
|
|
148
|
+
return argumentsOf(command)
|
|
149
|
+
.map(argument => `${argument.is_required ? '<' : '['}${argument.name}${argument.is_array ? '...' : ''}${argument.is_required ? '>' : ']'}`)
|
|
150
|
+
.join(' ')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function rows(found: Entry[]): Row[] {
|
|
154
|
+
return byRecency(found, runs('sf'), key).map(entry => ({
|
|
155
|
+
hidden: [entry.app.dir, entry.command.name],
|
|
156
|
+
cells: [entry.app.dir === '.' ? named(entry.command.name) : `${colors.dim(`${entry.app.dir}/`)}${named(entry.command.name)}`, colors.cyan(signature(entry.command)), colors.dim(entry.command.description)],
|
|
157
|
+
}))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** From the cache alone, the preview being called at every move. */
|
|
161
|
+
function preview(dir: string, name: string): void {
|
|
162
|
+
const command = (fromCache(dir) ?? []).find(candidate => candidate.name === name)
|
|
163
|
+
|
|
164
|
+
if (!command)
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
// Laid out as `bin/console help` has it.
|
|
168
|
+
const lines = [
|
|
169
|
+
colors.bold(command.description),
|
|
170
|
+
'',
|
|
171
|
+
colors.yellow('Usage:'),
|
|
172
|
+
...command.usage.map(line => ` ${line}`),
|
|
173
|
+
...(argumentsOf(command).length ? ['', colors.yellow('Arguments:'), ...argumentsOf(command).map(argument => ` ${colors.green(argument.name.padEnd(20))} ${argument.description}`)] : []),
|
|
174
|
+
...(optionsOf(command).length ? ['', colors.yellow('Options:'), ...optionsOf(command).map(option => ` ${colors.green(`--${option.name}${option.accept_value ? '=' : ''}`.padEnd(20))} ${option.description}`)] : []),
|
|
175
|
+
...(command.help ? ['', colors.yellow('Help:'), command.help] : []),
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
console.log(marked(lines.join('\n')))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The arguments given, then a prompt for each required one past them. */
|
|
182
|
+
export function commandFor(entry: Entry, given: string[]): Arg[] {
|
|
183
|
+
const prompts = argumentsOf(entry.command).filter(argument => argument.is_required).slice(given.length)
|
|
184
|
+
|
|
185
|
+
return [...entry.app.run, entry.command.name, ...given.map(value => ({ given: value })), ...prompts.map(argument => ({ ask: argument.name }))]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The words of a command with every prompt answered, and the arguments a run is remembered with; undefined once a prompt is cancelled. */
|
|
189
|
+
export async function answered(command: Arg[], ask: (name: string) => Promise<string | undefined>): Promise<{ argv: string[], given: string[] } | undefined> {
|
|
190
|
+
const argv: string[] = []
|
|
191
|
+
const given: string[] = []
|
|
192
|
+
|
|
193
|
+
for (const word of command) {
|
|
194
|
+
if (typeof word === 'string') {
|
|
195
|
+
argv.push(word)
|
|
196
|
+
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const value = 'given' in word ? word.given : await ask(word.ask)
|
|
201
|
+
|
|
202
|
+
if (value === undefined)
|
|
203
|
+
return undefined
|
|
204
|
+
|
|
205
|
+
argv.push(value)
|
|
206
|
+
given.push(value)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { argv, given }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function interactive(): boolean {
|
|
213
|
+
return process.stdin.isTTY && process.stderr.isTTY
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** What `--emit` prints for tmux, or, standalone, the command run once its prompts are answered. */
|
|
217
|
+
async function execute(emitting: boolean, typed: string, recorded: string, command: Arg[]): Promise<number> {
|
|
218
|
+
if (emitting) {
|
|
219
|
+
emit('run', typed, recorded, command)
|
|
220
|
+
|
|
221
|
+
return 0
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!interactive() && command.some(word => typeof word === 'object' && 'ask' in word))
|
|
225
|
+
fail('a required argument is missing and there is no terminal to ask for it', 1)
|
|
226
|
+
|
|
227
|
+
const done = await answered(command, async name => input(`${name}:`))
|
|
228
|
+
|
|
229
|
+
if (done === undefined)
|
|
230
|
+
return 1
|
|
231
|
+
|
|
232
|
+
if (recorded !== '')
|
|
233
|
+
record('sf', { id: recorded, args: done.given })
|
|
234
|
+
|
|
235
|
+
return Bun.spawn(done.argv, { stdio: ['inherit', 'inherit', 'inherit'] }).exited
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function listing(found: Entry[]): string {
|
|
239
|
+
const sorted = byRecency(found, runs('sf'), key)
|
|
240
|
+
const labels = sorted.map(entry => [key(entry), signature(entry.command)].filter(Boolean).join(' '))
|
|
241
|
+
const width = Math.max(...labels.map(label => label.length))
|
|
242
|
+
|
|
243
|
+
return sorted.map((entry, index) => `${labels[index]!.padEnd(width)} ${entry.command.description}`.trimEnd()).join('\n')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The hidden flags term, tmux and the shell completions call, each printing and nothing more. */
|
|
247
|
+
async function hidden(flag: string, rest: string[]): Promise<number | undefined> {
|
|
248
|
+
if (flag === '--show') {
|
|
249
|
+
preview(rest[0] ?? '.', rest[1] ?? '')
|
|
250
|
+
|
|
251
|
+
return 0
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (flag === '--record') {
|
|
255
|
+
record('sf', { id: rest[0] ?? '', args: rest.slice(1) })
|
|
256
|
+
|
|
257
|
+
return 0
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Called back by fzf once the picker is open on the cached list.
|
|
261
|
+
if (flag === '--rows') {
|
|
262
|
+
console.log(table(rows(await entries(true))).join('\n'))
|
|
263
|
+
|
|
264
|
+
return 0
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (flag !== '--list' && flag !== '--names')
|
|
268
|
+
return undefined
|
|
269
|
+
|
|
270
|
+
const refreshed = flag === '--list' && (await consoles()).some(found => fromCache(found.dir) !== undefined)
|
|
271
|
+
const found = await entries(false)
|
|
272
|
+
|
|
273
|
+
if (flag === '--names') {
|
|
274
|
+
// With the description as the one fish shows beside each name.
|
|
275
|
+
console.log(found.map(entry => `${key(entry)}\t${entry.command.description}`).join('\n'))
|
|
276
|
+
|
|
277
|
+
return 0
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// From the cache, which a list run in the background brings up to date for the next time, as fzf's reload does.
|
|
281
|
+
if (refreshed)
|
|
282
|
+
Bun.spawn([process.execPath, process.argv[1]!, '--rows'], { stdio: ['ignore', 'ignore', 'ignore'] }).unref()
|
|
283
|
+
|
|
284
|
+
list(byRecency(found, runs('sf'), key).map(entry => ({
|
|
285
|
+
label: [key(entry), signature(entry.command)].filter(Boolean).join(' '),
|
|
286
|
+
hint: entry.command.description,
|
|
287
|
+
typed: key(entry),
|
|
288
|
+
preview: ['--show', entry.app.dir, entry.command.name],
|
|
289
|
+
})))
|
|
290
|
+
|
|
291
|
+
return 0
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function sf(args: string[]): Promise<number> {
|
|
295
|
+
const [flag = ''] = args
|
|
296
|
+
const flagged = await hidden(flag, args.slice(1))
|
|
297
|
+
|
|
298
|
+
if (flagged !== undefined)
|
|
299
|
+
return flagged
|
|
300
|
+
|
|
301
|
+
// The protocol the tmux picker reads instead of running the command: its mode, the words typed back, and the command's words.
|
|
302
|
+
const emitting = flag === '--emit'
|
|
303
|
+
const words = emitting ? args.slice(1) : args
|
|
304
|
+
const last = words[0] === '-'
|
|
305
|
+
const parsed = parse(last ? [] : words, usage, {}, {
|
|
306
|
+
positionals: ['[command]'],
|
|
307
|
+
trailing: true,
|
|
308
|
+
replaces: 'bin/console typed in the php container, with the command names and arguments looked up by hand',
|
|
309
|
+
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
|
+
})
|
|
311
|
+
const [command] = parsed.positionals
|
|
312
|
+
const given = last ? words.slice(1) : parsed.rest ?? []
|
|
313
|
+
const refreshed = command === undefined && !last && (await consoles()).some(found => fromCache(found.dir) !== undefined)
|
|
314
|
+
const found = await entries(false)
|
|
315
|
+
|
|
316
|
+
if (last) {
|
|
317
|
+
const previous = runs('sf')[0]
|
|
318
|
+
const entry = previous && found.find(candidate => key(candidate) === previous.id)
|
|
319
|
+
|
|
320
|
+
if (entry === undefined)
|
|
321
|
+
fail(previous ? `${previous.id} is gone` : 'nothing run here yet', 1)
|
|
322
|
+
|
|
323
|
+
return execute(emitting, key(entry), key(entry), commandFor(entry, [...previous!.args, ...given]))
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (command !== undefined) {
|
|
327
|
+
const entry = found.find(candidate => key(candidate) === command) ?? found.find(candidate => candidate.command.name === command)
|
|
328
|
+
|
|
329
|
+
if (entry !== undefined)
|
|
330
|
+
return execute(emitting, key(entry), key(entry), commandFor(entry, given))
|
|
331
|
+
|
|
332
|
+
// Left to the console, which knows its abbreviations and suggests the nearest command itself.
|
|
333
|
+
const [first] = await consoles()
|
|
334
|
+
|
|
335
|
+
if (first === undefined)
|
|
336
|
+
fail('no bin/console here', 1)
|
|
337
|
+
|
|
338
|
+
return execute(emitting, command, '', [...first.run, command, ...given.map(value => ({ given: value }))])
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (found.length === 0)
|
|
342
|
+
fail('no bin/console here, or it listed no command', 1)
|
|
343
|
+
|
|
344
|
+
if (!interactive() || Bun.which('fzf') === null) {
|
|
345
|
+
if (emitting)
|
|
346
|
+
fail('the picker needs a terminal and fzf', 1)
|
|
347
|
+
|
|
348
|
+
if (interactive())
|
|
349
|
+
console.error('fzf is not installed, listing the commands instead')
|
|
350
|
+
|
|
351
|
+
console.log(listing(found))
|
|
352
|
+
|
|
353
|
+
return 0
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const self = `${process.execPath} ${process.argv[1]}`
|
|
357
|
+
const picked = fzf(rows(found), {
|
|
358
|
+
prompt: 'sf> ',
|
|
359
|
+
searched: 1,
|
|
360
|
+
preview: `${self} --show {1} {2}`,
|
|
361
|
+
// Once the cached list is in, and only once, the reload firing load again.
|
|
362
|
+
bind: refreshed ? [`load:unbind(load)+reload-sync(${self} --rows)`] : [],
|
|
363
|
+
})
|
|
364
|
+
// Read again, the reload having maybe brought a command the cached list did not hold.
|
|
365
|
+
const entry = picked && (await entries(false)).find(candidate => candidate.app.dir === picked.hidden[0] && candidate.command.name === picked.hidden[1])
|
|
366
|
+
|
|
367
|
+
return entry === undefined ? 1 : execute(emitting, key(entry), key(entry), commandFor(entry, []))
|
|
368
|
+
}
|
package/src/stack.ts
CHANGED
|
@@ -52,12 +52,12 @@ const SKIPPED = new Set(['node_modules', 'vendor', 'dist', 'build', 'target', 'v
|
|
|
52
52
|
export const ACTIONS: Action[] = ['shell', 'logs', 'restart', 'up', 'stop', 'build']
|
|
53
53
|
|
|
54
54
|
/** The command behind `env -C` when it runs from another folder than this one. */
|
|
55
|
-
function from(dir: string, command: string[]): string[] {
|
|
55
|
+
export function from(dir: string, command: string[]): string[] {
|
|
56
56
|
return dir === '.' ? command : ['env', '-C', dir, ...command]
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/** The direct child folders, hidden and built ones aside. */
|
|
60
|
-
function children(dir = '.'): string[] {
|
|
60
|
+
export function children(dir = '.'): string[] {
|
|
61
61
|
return readdirSync(dir, { withFileTypes: true })
|
|
62
62
|
.filter(child => child.isDirectory() && !child.name.startsWith('.') && !SKIPPED.has(child.name))
|
|
63
63
|
.map(child => child.name)
|
|
@@ -65,7 +65,7 @@ function children(dir = '.'): string[] {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** The folder and its parents, relative to it, up to the repository root. */
|
|
68
|
-
function up(dir: string): string[] {
|
|
68
|
+
export function up(dir: string): string[] {
|
|
69
69
|
const dirs: string[] = []
|
|
70
70
|
|
|
71
71
|
for (let current = dir; ; current = join(current, '..')) {
|
|
@@ -239,3 +239,27 @@ export async function commandFor(service: Service, action: Action): Promise<stri
|
|
|
239
239
|
export function defaultAction(service: Service): Action {
|
|
240
240
|
return service.state === 'running' ? 'shell' : 'up'
|
|
241
241
|
}
|
|
242
|
+
|
|
243
|
+
// Up in green, down in red, never created dimmed, anything on its way, restarting or paused, in yellow.
|
|
244
|
+
export function stateColor(state: string): 'green' | 'red' | 'dim' | 'yellow' {
|
|
245
|
+
if (state === 'running')
|
|
246
|
+
return 'green'
|
|
247
|
+
|
|
248
|
+
if (state === 'exited' || state === 'dead')
|
|
249
|
+
return 'red'
|
|
250
|
+
|
|
251
|
+
return state === 'none' ? 'dim' : 'yellow'
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// A service looked up again from the folder, project and name the picker's preview was given.
|
|
255
|
+
export async function serviceNamed(dir: string, name: string, project: string): Promise<Service | undefined> {
|
|
256
|
+
const [recorded] = project === '' ? [] : await labels(project)
|
|
257
|
+
const stack = recorded === undefined ? stackAt(dir) : labelledStack(dir, recorded)
|
|
258
|
+
|
|
259
|
+
return (await services(stack)).find(candidate => candidate.name === name)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Compose's own words for its output of one service, the last lines of its logs.
|
|
263
|
+
export async function logsOf(stack: Stack, name: string): Promise<string> {
|
|
264
|
+
return output(stack, ['logs', '--tail=30', '--no-log-prefix', name])
|
|
265
|
+
}
|