@avelonjs/cli 0.1.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/LICENSE +21 -0
- package/README.md +159 -0
- package/package.json +58 -0
- package/src/bin.ts +5 -0
- package/src/commands.ts +399 -0
- package/src/docs-check.ts +415 -0
- package/src/driver-scaffold.ts +524 -0
- package/src/generate.ts +306 -0
- package/src/index.ts +42 -0
- package/src/io.ts +65 -0
- package/src/menu.ts +53 -0
- package/src/tui.tsx +77 -0
package/src/generate.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { snake, studly, type ReeveIO, writeln } from './io'
|
|
5
|
+
|
|
6
|
+
export interface GenerateOptions {
|
|
7
|
+
migration?: boolean
|
|
8
|
+
factory?: boolean
|
|
9
|
+
controller?: boolean
|
|
10
|
+
policy?: boolean
|
|
11
|
+
ward?: boolean
|
|
12
|
+
resource?: boolean
|
|
13
|
+
model?: string
|
|
14
|
+
event?: string
|
|
15
|
+
queued?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const builtin: Record<string, string> = {
|
|
19
|
+
'model.ts': `import { Model } from '@avelonjs/orm'
|
|
20
|
+
|
|
21
|
+
/** __NAME__ resource model. */
|
|
22
|
+
export class __NAME__ extends Model {
|
|
23
|
+
static override table = '__TABLE__'
|
|
24
|
+
static override fillable = ['id']
|
|
25
|
+
|
|
26
|
+
declare id: string
|
|
27
|
+
}
|
|
28
|
+
`,
|
|
29
|
+
'controller.ts': `import { view, type HttpRequest } from '@avelonjs/core'
|
|
30
|
+
|
|
31
|
+
/** __NAME__ HTTP controller. */
|
|
32
|
+
export class __NAME__ {
|
|
33
|
+
async index(_request: HttpRequest) {
|
|
34
|
+
return view('__VIEW__.index', {})
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
`,
|
|
38
|
+
'resource-controller.ts': `import { redirect, view, type HttpRequest } from '@avelonjs/core'
|
|
39
|
+
|
|
40
|
+
/** Resource controller for __RESOURCE__. */
|
|
41
|
+
export class __NAME__ {
|
|
42
|
+
async index() {
|
|
43
|
+
return view('__VIEW__.index', {})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async create() {
|
|
47
|
+
return view('__VIEW__.create', {})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async store(_request: HttpRequest) {
|
|
51
|
+
return redirect('/__TABLE__')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async show(_request: HttpRequest, record: { id: string }) {
|
|
55
|
+
return view('__VIEW__.show', { id: record.id })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async edit(_request: HttpRequest, record: { id: string }) {
|
|
59
|
+
return view('__VIEW__.edit', { id: record.id })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async update(_request: HttpRequest, record: { id: string }) {
|
|
63
|
+
return redirect(\`/__TABLE__/\${record.id}\`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async destroy(_request: HttpRequest, _record: { id: string }) {
|
|
67
|
+
return redirect('/__TABLE__')
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
`,
|
|
71
|
+
'request.ts': `import { defineRequest } from '@avelonjs/core'
|
|
72
|
+
|
|
73
|
+
/** Input contract for __NAME__. */
|
|
74
|
+
export const __NAME__ = defineRequest({
|
|
75
|
+
schema: {
|
|
76
|
+
parse(input: unknown) {
|
|
77
|
+
return input as Record<string, unknown>
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
`,
|
|
82
|
+
'policy.ts': `import { definePolicy } from '@avelonjs/core'
|
|
83
|
+
|
|
84
|
+
/** Authorization policy for __MODEL__. */
|
|
85
|
+
export const __NAME__ = definePolicy({
|
|
86
|
+
viewAny: () => true,
|
|
87
|
+
view: () => true,
|
|
88
|
+
create: () => true,
|
|
89
|
+
update: () => false,
|
|
90
|
+
delete: () => false,
|
|
91
|
+
})
|
|
92
|
+
`,
|
|
93
|
+
'ward.ts': `import { defineWard } from '@avelonjs/core'
|
|
94
|
+
|
|
95
|
+
/** Row access rules for __MODEL__. */
|
|
96
|
+
export const __NAME__ = defineWard({
|
|
97
|
+
read: true,
|
|
98
|
+
insert: true,
|
|
99
|
+
update: true,
|
|
100
|
+
delete: true,
|
|
101
|
+
})
|
|
102
|
+
`,
|
|
103
|
+
'event.ts': `import { Event } from '@avelonjs/core'
|
|
104
|
+
|
|
105
|
+
/** Domain event __NAME__. */
|
|
106
|
+
export class __NAME__ extends Event {
|
|
107
|
+
constructor(readonly id: string) {
|
|
108
|
+
super()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
`,
|
|
112
|
+
'listener.ts': `import { defineListener } from '@avelonjs/core'
|
|
113
|
+
|
|
114
|
+
/** Listener for __EVENT__. */
|
|
115
|
+
export const __NAME__ = defineListener({
|
|
116
|
+
delivery: '__DELIVERY__',
|
|
117
|
+
handle() {},
|
|
118
|
+
})
|
|
119
|
+
`,
|
|
120
|
+
'errand.ts': `import { defineErrand } from '@avelonjs/core'
|
|
121
|
+
|
|
122
|
+
/** Background errand __NAME__. */
|
|
123
|
+
export const __NAME__ = defineErrand({
|
|
124
|
+
name: '__SLUG__',
|
|
125
|
+
delivery: 'queued',
|
|
126
|
+
queue: 'default',
|
|
127
|
+
async handle() {},
|
|
128
|
+
})
|
|
129
|
+
`,
|
|
130
|
+
'action.ts': `/** Single-purpose action __NAME__. */
|
|
131
|
+
export async function __NAME__() {}
|
|
132
|
+
`,
|
|
133
|
+
'middleware.ts': `import type { HttpRequest } from '@avelonjs/core'
|
|
134
|
+
|
|
135
|
+
/** HTTP middleware __NAME__. */
|
|
136
|
+
export async function __NAME__(_request: HttpRequest): Promise<void> {}
|
|
137
|
+
`,
|
|
138
|
+
'command.ts': `/** Console command __NAME__. */
|
|
139
|
+
export async function __NAME__(): Promise<void> {
|
|
140
|
+
console.log('__SLUG__')
|
|
141
|
+
}
|
|
142
|
+
`,
|
|
143
|
+
'view.ts': `/** Opaque view reference for __NAME__. */
|
|
144
|
+
export const __NAME__ = '__SLUG__'
|
|
145
|
+
`,
|
|
146
|
+
'migration.ts': `/** Driver-owned migration __ID__. */
|
|
147
|
+
export const __NAME__ = {
|
|
148
|
+
id: '__ID__',
|
|
149
|
+
async up() {},
|
|
150
|
+
async down() {},
|
|
151
|
+
}
|
|
152
|
+
`,
|
|
153
|
+
'factory.ts': `import { defineFactory } from '@avelonjs/assay'
|
|
154
|
+
import { __MODEL__ } from '@/app/Models/__MODEL__'
|
|
155
|
+
|
|
156
|
+
export default defineFactory(__MODEL__, (sequence) => ({
|
|
157
|
+
id: \`__TABLE__-\${sequence}\`,
|
|
158
|
+
}))
|
|
159
|
+
`,
|
|
160
|
+
'package-readme.md': `# @avelonjs/__SLUG__
|
|
161
|
+
|
|
162
|
+
__INTRO__
|
|
163
|
+
|
|
164
|
+
## Installation
|
|
165
|
+
|
|
166
|
+
\`\`\`sh
|
|
167
|
+
bun add @avelonjs/__SLUG__
|
|
168
|
+
\`\`\`
|
|
169
|
+
|
|
170
|
+
## Basic Usage
|
|
171
|
+
|
|
172
|
+
\`\`\`ts
|
|
173
|
+
export function ping(): string {
|
|
174
|
+
return 'pong'
|
|
175
|
+
}
|
|
176
|
+
\`\`\`
|
|
177
|
+
|
|
178
|
+
## Features
|
|
179
|
+
|
|
180
|
+
Document each capability here, simple to advanced.
|
|
181
|
+
|
|
182
|
+
## Method Reference
|
|
183
|
+
|
|
184
|
+
| Method / export | Signature | Description |
|
|
185
|
+
|---|---|---|
|
|
186
|
+
| \`ping\` | \`() => string\` | Returns pong. |
|
|
187
|
+
|
|
188
|
+
## Testing
|
|
189
|
+
|
|
190
|
+
\`\`\`sh
|
|
191
|
+
bun test
|
|
192
|
+
bun run typecheck
|
|
193
|
+
\`\`\`
|
|
194
|
+
`,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function tokens(name: string, extra: Record<string, string> = {}): Record<string, string> {
|
|
198
|
+
const pascal = studly(name)
|
|
199
|
+
const table = snake(pascal)
|
|
200
|
+
.replace(/_controller$/, '')
|
|
201
|
+
.replace(/_request$/, '')
|
|
202
|
+
.replace(/_policy$/, '')
|
|
203
|
+
.replace(/_ward$/, '')
|
|
204
|
+
return {
|
|
205
|
+
__NAME__: pascal,
|
|
206
|
+
__TABLE__: table.endsWith('s') ? table : `${table}s`,
|
|
207
|
+
__VIEW__: table,
|
|
208
|
+
__SLUG__: snake(pascal).replace(/_/g, '-'),
|
|
209
|
+
__RESOURCE__: table,
|
|
210
|
+
__MODEL__: extra.__MODEL__ ?? pascal.replace(/Policy$|Ward$|Controller$|Request$/, ''),
|
|
211
|
+
__EVENT__: extra.__EVENT__ ?? 'Event',
|
|
212
|
+
__DELIVERY__: extra.__DELIVERY__ ?? 'sync',
|
|
213
|
+
__ID__: extra.__ID__ ?? `20260827_${snake(pascal)}`,
|
|
214
|
+
__INTRO__: extra.__INTRO__ ?? `${pascal} is an Avelon package.`,
|
|
215
|
+
...extra,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function apply(template: string, vars: Record<string, string>): string {
|
|
220
|
+
let output = template
|
|
221
|
+
for (const [key, value] of Object.entries(vars)) output = output.replaceAll(key, value)
|
|
222
|
+
return output
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function loadTemplate(io: ReeveIO, name: string): Promise<string> {
|
|
226
|
+
const override = join(io.cwd, 'stubs', name)
|
|
227
|
+
try {
|
|
228
|
+
return await readFile(override, 'utf8')
|
|
229
|
+
} catch {
|
|
230
|
+
const built = builtin[name]
|
|
231
|
+
if (built === undefined) throw new Error(`Unknown stub ${name}`)
|
|
232
|
+
return built
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Writes `relative` from a stub, creating parent directories. */
|
|
237
|
+
export async function writeFromStub(
|
|
238
|
+
io: ReeveIO,
|
|
239
|
+
stub: string,
|
|
240
|
+
relative: string,
|
|
241
|
+
name: string,
|
|
242
|
+
extra: Record<string, string> = {},
|
|
243
|
+
): Promise<string> {
|
|
244
|
+
const template = await loadTemplate(io, stub)
|
|
245
|
+
const file = join(io.cwd, relative)
|
|
246
|
+
await mkdir(dirname(file), { recursive: true })
|
|
247
|
+
await writeFile(file, apply(template, tokens(name, extra)))
|
|
248
|
+
writeln(io, relative)
|
|
249
|
+
return file
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Copies built-in stubs into `stubs/` so a team can change generated shape. */
|
|
253
|
+
export async function publishStubs(io: ReeveIO): Promise<readonly string[]> {
|
|
254
|
+
const written: string[] = []
|
|
255
|
+
for (const [name, contents] of Object.entries(builtin)) {
|
|
256
|
+
const file = join(io.cwd, 'stubs', name)
|
|
257
|
+
await mkdir(dirname(file), { recursive: true })
|
|
258
|
+
await writeFile(file, contents)
|
|
259
|
+
written.push(`stubs/${name}`)
|
|
260
|
+
}
|
|
261
|
+
writeln(io, `Published ${written.length} stubs.`)
|
|
262
|
+
return written
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Generates a model and optional related files from `-mfcpw`. */
|
|
266
|
+
export async function makeModel(
|
|
267
|
+
io: ReeveIO,
|
|
268
|
+
name: string,
|
|
269
|
+
options: GenerateOptions,
|
|
270
|
+
): Promise<void> {
|
|
271
|
+
const pascal = studly(name)
|
|
272
|
+
await writeFromStub(io, 'model.ts', `app/Models/${pascal}.ts`, pascal)
|
|
273
|
+
if (options.migration) await makeMigration(io, `create_${snake(pascal)}s_table`)
|
|
274
|
+
if (options.factory) {
|
|
275
|
+
await writeFromStub(io, 'factory.ts', `database/factories/${pascal}Factory.ts`, pascal, {
|
|
276
|
+
__MODEL__: pascal,
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
if (options.controller) {
|
|
280
|
+
await writeFromStub(
|
|
281
|
+
io,
|
|
282
|
+
options.resource ? 'resource-controller.ts' : 'controller.ts',
|
|
283
|
+
`app/Http/Controllers/${pascal}Controller.ts`,
|
|
284
|
+
`${pascal}Controller`,
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
if (options.policy) {
|
|
288
|
+
await writeFromStub(io, 'policy.ts', `app/Policies/${pascal}Policy.ts`, `${pascal}Policy`, {
|
|
289
|
+
__MODEL__: pascal,
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
if (options.ward) {
|
|
293
|
+
await writeFromStub(io, 'ward.ts', `app/Wards/${pascal}Ward.ts`, `${pascal}Ward`, {
|
|
294
|
+
__MODEL__: pascal,
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Generates a timestamped migration module. */
|
|
300
|
+
export async function makeMigration(io: ReeveIO, name: string): Promise<void> {
|
|
301
|
+
const slug = snake(name)
|
|
302
|
+
const id = `${new Date().toISOString().slice(0, 10).replaceAll('-', '')}_${slug}`
|
|
303
|
+
await writeFromStub(io, 'migration.ts', `database/migrations/${id}.ts`, studly(name), {
|
|
304
|
+
__ID__: id,
|
|
305
|
+
})
|
|
306
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { runCommand } from './commands'
|
|
2
|
+
import { processIO, shouldUseTui, writeln, type ReeveIO } from './io'
|
|
3
|
+
import { COMMANDS } from './menu'
|
|
4
|
+
|
|
5
|
+
export { COMMANDS, MENU } from './menu'
|
|
6
|
+
export { shouldUseTui, processIO, type ReeveIO } from './io'
|
|
7
|
+
export { runCommand } from './commands'
|
|
8
|
+
export {
|
|
9
|
+
checkPackageDocs,
|
|
10
|
+
runDocsCheck,
|
|
11
|
+
type DocsCheckResult,
|
|
12
|
+
type DocsFinding,
|
|
13
|
+
} from './docs-check'
|
|
14
|
+
export { makeDriver, DRIVER_CONTRACTS, type DriverContractSpec } from './driver-scaffold'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Runs `reeve`. Bare invocation opens the TUI when stdout is a TTY. Any subcommand with arguments
|
|
18
|
+
* skips the TUI entirely. `--no-tui` and a non-TTY stdout force plain mode.
|
|
19
|
+
*/
|
|
20
|
+
export async function runReeve(
|
|
21
|
+
argv: readonly string[],
|
|
22
|
+
io: ReeveIO = processIO(),
|
|
23
|
+
): Promise<number> {
|
|
24
|
+
try {
|
|
25
|
+
if (shouldUseTui(argv, io)) {
|
|
26
|
+
const { renderTui } = await import('./tui')
|
|
27
|
+
await renderTui(io)
|
|
28
|
+
return 0
|
|
29
|
+
}
|
|
30
|
+
const args = argv.filter((arg) => arg !== '--no-tui')
|
|
31
|
+
const command = args[0]
|
|
32
|
+
if (command === undefined) {
|
|
33
|
+
writeln(io, 'Usage: reeve <command>. Bare reeve opens the TUI on a TTY.')
|
|
34
|
+
writeln(io, `Commands: ${COMMANDS.join(', ')}`)
|
|
35
|
+
return 1
|
|
36
|
+
}
|
|
37
|
+
return await runCommand(command, args.slice(1), io)
|
|
38
|
+
} catch (error) {
|
|
39
|
+
writeln(io, error instanceof Error ? error.message : String(error))
|
|
40
|
+
return 1
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/io.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { stdout as defaultStdout } from 'node:process'
|
|
2
|
+
|
|
3
|
+
/** IO surface so tests can capture `reeve` without a TTY. */
|
|
4
|
+
export interface ReeveIO {
|
|
5
|
+
cwd: string
|
|
6
|
+
stdout: { write(chunk: string): boolean }
|
|
7
|
+
isTTY: boolean
|
|
8
|
+
env: Record<string, string | undefined>
|
|
9
|
+
confirm?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Builds the default process IO. */
|
|
13
|
+
export function processIO(): ReeveIO {
|
|
14
|
+
return {
|
|
15
|
+
cwd: process.cwd(),
|
|
16
|
+
stdout: defaultStdout,
|
|
17
|
+
isTTY: Boolean(defaultStdout.isTTY),
|
|
18
|
+
env: process.env,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Writes a line to the IO stdout. */
|
|
23
|
+
export function writeln(io: ReeveIO, line = ''): void {
|
|
24
|
+
io.stdout.write(`${line}\n`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Application name, driver, adapter, and environment shown in the TUI header. */
|
|
28
|
+
export function headerBits(io: ReeveIO): {
|
|
29
|
+
app: string
|
|
30
|
+
database: string
|
|
31
|
+
adapter: string
|
|
32
|
+
environment: string
|
|
33
|
+
} {
|
|
34
|
+
return {
|
|
35
|
+
app: io.env.AVELON_APP ?? 'app',
|
|
36
|
+
database: io.env.AVELON_DATABASE ?? 'unconfigured',
|
|
37
|
+
adapter: io.env.AVELON_ADAPTER ?? 'next',
|
|
38
|
+
environment: io.env.AVELON_ENV ?? io.env.NODE_ENV ?? 'local',
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when bare `reeve` should open the TUI. */
|
|
43
|
+
export function shouldUseTui(argv: readonly string[], io: ReeveIO): boolean {
|
|
44
|
+
if (argv.includes('--no-tui')) return false
|
|
45
|
+
if (!io.isTTY) return false
|
|
46
|
+
const rest = argv.filter((arg) => arg !== '--no-tui')
|
|
47
|
+
return rest.length === 0
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** PascalCase identifier from a user-supplied name. */
|
|
51
|
+
export function studly(name: string): string {
|
|
52
|
+
return name
|
|
53
|
+
.split(/[-_\s/]+/)
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
|
56
|
+
.join('')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** snake_case identifier. */
|
|
60
|
+
export function snake(name: string): string {
|
|
61
|
+
return name
|
|
62
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
63
|
+
.replace(/[-\s/]+/g, '_')
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
}
|
package/src/menu.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** One TUI row. Every path has a scriptable equivalent. */
|
|
2
|
+
export interface MenuItem {
|
|
3
|
+
group: string
|
|
4
|
+
label: string
|
|
5
|
+
command: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Groups and commands shown in the TUI and used as the CLI catalog. */
|
|
9
|
+
export const MENU: readonly MenuItem[] = [
|
|
10
|
+
{ group: 'Make', label: 'model', command: 'make:model' },
|
|
11
|
+
{ group: 'Make', label: 'controller', command: 'make:controller' },
|
|
12
|
+
{ group: 'Make', label: 'request', command: 'make:request' },
|
|
13
|
+
{ group: 'Make', label: 'policy', command: 'make:policy' },
|
|
14
|
+
{ group: 'Make', label: 'ward', command: 'make:ward' },
|
|
15
|
+
{ group: 'Make', label: 'event', command: 'make:event' },
|
|
16
|
+
{ group: 'Make', label: 'listener', command: 'make:listener' },
|
|
17
|
+
{ group: 'Make', label: 'errand', command: 'make:errand' },
|
|
18
|
+
{ group: 'Make', label: 'action', command: 'make:action' },
|
|
19
|
+
{ group: 'Make', label: 'command', command: 'make:command' },
|
|
20
|
+
{ group: 'Make', label: 'middleware', command: 'make:middleware' },
|
|
21
|
+
{ group: 'Make', label: 'view', command: 'make:view' },
|
|
22
|
+
{ group: 'Make', label: 'driver', command: 'make:driver' },
|
|
23
|
+
{ group: 'Make', label: 'package', command: 'make:package' },
|
|
24
|
+
{ group: 'Make', label: 'auth', command: 'make:auth' },
|
|
25
|
+
{ group: 'Make', label: 'migration', command: 'make:migration' },
|
|
26
|
+
{ group: 'Database', label: 'migrate', command: 'migrate' },
|
|
27
|
+
{ group: 'Database', label: 'rollback', command: 'migrate:rollback' },
|
|
28
|
+
{ group: 'Database', label: 'fresh', command: 'migrate:fresh' },
|
|
29
|
+
{ group: 'Database', label: 'seed', command: 'db:seed' },
|
|
30
|
+
{ group: 'Database', label: 'schema:pull', command: 'schema:pull' },
|
|
31
|
+
{ group: 'Wards', label: 'list', command: 'ward:list' },
|
|
32
|
+
{ group: 'Wards', label: 'sync', command: 'ward:sync' },
|
|
33
|
+
{ group: 'Wards', label: 'check', command: 'ward:check' },
|
|
34
|
+
{ group: 'Wards', label: 'explain', command: 'ward:explain' },
|
|
35
|
+
{ group: 'Routes', label: 'list', command: 'route:list' },
|
|
36
|
+
{ group: 'Routes', label: 'sync', command: 'route:sync' },
|
|
37
|
+
{ group: 'Errands', label: 'work', command: 'errand:work' },
|
|
38
|
+
{ group: 'Errands', label: 'failed', command: 'errand:failed' },
|
|
39
|
+
{ group: 'Errands', label: 'retry', command: 'errand:retry' },
|
|
40
|
+
{ group: 'Errands', label: 'flush', command: 'errand:flush' },
|
|
41
|
+
{ group: 'Inspect', label: 'event:list', command: 'event:list' },
|
|
42
|
+
{ group: 'Inspect', label: 'config:show', command: 'config:show' },
|
|
43
|
+
{ group: 'Inspect', label: 'capabilities', command: 'capabilities' },
|
|
44
|
+
{ group: 'Inspect', label: 'about', command: 'about' },
|
|
45
|
+
{ group: 'Doctor', label: 'bailiff', command: 'bailiff' },
|
|
46
|
+
{ group: 'Doctor', label: 'docs:check', command: 'docs:check' },
|
|
47
|
+
{ group: 'Doctor', label: 'doctor', command: 'doctor' },
|
|
48
|
+
{ group: 'Inspect', label: 'tinker', command: 'tinker' },
|
|
49
|
+
{ group: 'Make', label: 'stub:publish', command: 'stub:publish' },
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
/** Unique command names the CLI accepts. */
|
|
53
|
+
export const COMMANDS = [...new Set(MENU.map((item) => item.command))]
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Box, Text, render, useApp, useInput } from 'ink'
|
|
2
|
+
import { useMemo, useState, type JSX } from 'react'
|
|
3
|
+
|
|
4
|
+
import { runCommand } from './commands'
|
|
5
|
+
import { headerBits, writeln, type ReeveIO } from './io'
|
|
6
|
+
import { MENU } from './menu'
|
|
7
|
+
|
|
8
|
+
function ReeveTui(props: { io: ReeveIO }): JSX.Element {
|
|
9
|
+
const app = useApp()
|
|
10
|
+
const [cursor, setCursor] = useState(0)
|
|
11
|
+
const [query, setQuery] = useState('')
|
|
12
|
+
const [searching, setSearching] = useState(false)
|
|
13
|
+
const items = useMemo(() => {
|
|
14
|
+
const needle = query.trim().toLowerCase()
|
|
15
|
+
if (needle.length === 0) return MENU
|
|
16
|
+
return MENU.filter(
|
|
17
|
+
(item) =>
|
|
18
|
+
item.command.includes(needle) ||
|
|
19
|
+
item.label.includes(needle) ||
|
|
20
|
+
item.group.toLowerCase().includes(needle),
|
|
21
|
+
)
|
|
22
|
+
}, [query])
|
|
23
|
+
const current = items[cursor]
|
|
24
|
+
|
|
25
|
+
useInput(async (input, key) => {
|
|
26
|
+
if (searching) {
|
|
27
|
+
if (key.return || key.escape) {
|
|
28
|
+
setSearching(false)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
if (key.backspace || key.delete) {
|
|
32
|
+
setQuery((value) => value.slice(0, -1))
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
if (input.length > 0) setQuery((value) => `${value}${input}`)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
if (input === 'q') {
|
|
39
|
+
app.exit()
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
if (input === '/') {
|
|
43
|
+
setSearching(true)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
if (key.upArrow) setCursor((value) => Math.max(0, value - 1))
|
|
47
|
+
if (key.downArrow) setCursor((value) => Math.min(items.length - 1, value + 1))
|
|
48
|
+
if (key.return && current !== undefined) {
|
|
49
|
+
writeln(props.io, `reeve ${current.command}`)
|
|
50
|
+
await runCommand(current.command, [], props.io)
|
|
51
|
+
app.exit()
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const bits = headerBits(props.io)
|
|
56
|
+
return (
|
|
57
|
+
<Box flexDirection="column" borderStyle="single" paddingX={1}>
|
|
58
|
+
<Text>
|
|
59
|
+
avelon {bits.app} · {bits.database} · {bits.adapter} · {bits.environment}
|
|
60
|
+
</Text>
|
|
61
|
+
<Text dimColor>
|
|
62
|
+
{searching ? `search: ${query}` : '↑↓ navigate · ⏎ select · / search · q quit'}
|
|
63
|
+
</Text>
|
|
64
|
+
{items.slice(0, 16).map((item, index) => (
|
|
65
|
+
<Text key={item.command} inverse={index === cursor}>
|
|
66
|
+
{item.group.padEnd(10)} {item.label.padEnd(14)} {item.command}
|
|
67
|
+
</Text>
|
|
68
|
+
))}
|
|
69
|
+
</Box>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Opens the Ink TUI. Every selection prints `reeve <command>` before running it. */
|
|
74
|
+
export async function renderTui(io: ReeveIO): Promise<void> {
|
|
75
|
+
const instance = render(<ReeveTui io={io} />)
|
|
76
|
+
await instance.waitUntilExit()
|
|
77
|
+
}
|