@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.
@@ -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
+ }