@strav/cli 0.4.31 → 1.0.0-alpha.4
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/package.json +17 -41
- package/src/binder.ts +88 -0
- package/src/command.ts +297 -0
- package/src/config_list.ts +42 -0
- package/src/config_show.ts +50 -0
- package/src/console_provider.ts +46 -0
- package/src/exit_codes.ts +26 -0
- package/src/index.ts +60 -2
- package/src/key_generate.ts +66 -0
- package/src/make/index.ts +17 -0
- package/src/make/make_command_file.ts +27 -0
- package/src/make/make_controller.ts +24 -0
- package/src/make/make_factory.ts +25 -0
- package/src/make/make_job.ts +25 -0
- package/src/make/make_mail.ts +27 -0
- package/src/make/make_middleware.ts +23 -0
- package/src/make/make_migration.ts +48 -0
- package/src/make/make_model.ts +91 -0
- package/src/make/make_notification.ts +23 -0
- package/src/make/make_policy.ts +24 -0
- package/src/make/make_provider.ts +29 -0
- package/src/make/make_repository.ts +30 -0
- package/src/make/make_request.ts +24 -0
- package/src/make/make_seeder.ts +23 -0
- package/src/make/make_test.ts +22 -0
- package/src/make_command.ts +69 -0
- package/src/run_cli.ts +121 -0
- package/src/scaffold_console_provider.ts +45 -0
- package/src/signature.ts +171 -0
- package/src/subset_boot.ts +51 -0
- package/src/util_console_provider.ts +18 -0
- package/src/cli/bootstrap.ts +0 -82
- package/src/cli/command_loader.ts +0 -180
- package/src/cli/index.ts +0 -3
- package/src/cli/strav.ts +0 -13
- package/src/commands/db_seed.ts +0 -77
- package/src/commands/db_setup_roles.ts +0 -101
- package/src/commands/generate_api.ts +0 -93
- package/src/commands/generate_key.ts +0 -47
- package/src/commands/generate_models.ts +0 -49
- package/src/commands/generate_seeder.ts +0 -68
- package/src/commands/migration_compare.ts +0 -167
- package/src/commands/migration_fresh.ts +0 -148
- package/src/commands/migration_generate.ts +0 -84
- package/src/commands/migration_rollback.ts +0 -54
- package/src/commands/migration_run.ts +0 -45
- package/src/commands/package_install.ts +0 -161
- package/src/commands/queue_flush.ts +0 -35
- package/src/commands/queue_retry.ts +0 -34
- package/src/commands/queue_work.ts +0 -101
- package/src/commands/scheduler_work.ts +0 -46
- package/src/commands/tenant_create.ts +0 -35
- package/src/commands/tenant_delete.ts +0 -64
- package/src/commands/tenant_list.ts +0 -39
- package/src/config/loader.ts +0 -50
- package/src/generators/api_generator.ts +0 -1035
- package/src/generators/config.ts +0 -113
- package/src/generators/doc_generator.ts +0 -996
- package/src/generators/index.ts +0 -11
- package/src/generators/model_generator.ts +0 -596
- package/src/generators/route_generator.ts +0 -187
- package/src/generators/test_generator.ts +0 -1667
- package/tsconfig.json +0 -5
package/package.json
CHANGED
|
@@ -1,52 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/cli",
|
|
3
|
-
"version": "0.4
|
|
3
|
+
"version": "1.0.0-alpha.4",
|
|
4
|
+
"description": "Strav CLI layer — Command base, signature parser, ConsoleProvider, interactive prompts on top of @strav/kernel's ConsoleKernel",
|
|
4
5
|
"type": "module",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"framework",
|
|
10
|
-
"typescript",
|
|
11
|
-
"strav",
|
|
12
|
-
"cli"
|
|
13
|
-
],
|
|
14
|
-
"strav": {
|
|
15
|
-
"commands": "src/commands"
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
16
10
|
},
|
|
17
11
|
"files": [
|
|
18
|
-
"src
|
|
19
|
-
"
|
|
20
|
-
"tsconfig.json",
|
|
21
|
-
"CHANGELOG.md"
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
22
14
|
],
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"./cli": "./src/cli/index.ts",
|
|
26
|
-
"./cli/*": "./src/cli/*.ts",
|
|
27
|
-
"./commands": "./src/commands/index.ts",
|
|
28
|
-
"./commands/*": "./src/commands/*.ts",
|
|
29
|
-
"./config/*": "./src/config/*.ts",
|
|
30
|
-
"./generators": "./src/generators/index.ts",
|
|
31
|
-
"./generators/*": "./src/generators/*.ts"
|
|
15
|
+
"engines": {
|
|
16
|
+
"bun": ">=1.3.14"
|
|
32
17
|
},
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
},
|
|
36
|
-
"peerDependencies": {
|
|
37
|
-
"@strav/kernel": "0.4.31",
|
|
38
|
-
"@strav/http": "0.4.31",
|
|
39
|
-
"@strav/database": "0.4.31",
|
|
40
|
-
"@strav/queue": "0.4.31",
|
|
41
|
-
"@strav/signal": "0.4.31"
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
42
20
|
},
|
|
43
21
|
"dependencies": {
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
22
|
+
"@strav/kernel": "1.0.0-alpha.4"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@types/bun": ">=1.3.14"
|
|
47
26
|
},
|
|
48
|
-
"
|
|
49
|
-
"test": "bun test tests/",
|
|
50
|
-
"typecheck": "tsc --noEmit"
|
|
51
|
-
}
|
|
27
|
+
"devDependencies": null
|
|
52
28
|
}
|
package/src/binder.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argv binder — given a parsed `Signature` and the kernel's `ParsedArgv`,
|
|
3
|
+
* produce the `{ args, flags }` shape `Command.execute()` receives.
|
|
4
|
+
*
|
|
5
|
+
* Validation is loud-fail (`UsageError`) so wrong invocations land an exit
|
|
6
|
+
* code 2 with a clear message rather than a `TypeError` from `args.foo`:
|
|
7
|
+
* - Required positional missing → "missing argument: <name>".
|
|
8
|
+
* - Extra positional after all declared → "unexpected argument: <value>".
|
|
9
|
+
* - String flag with no value (bare `--output`) → "flag --output requires a value".
|
|
10
|
+
*
|
|
11
|
+
* Unknown flags (passed but not declared) are *retained* in the output —
|
|
12
|
+
* commands may inspect them via `flags[<unknown>]`, which is the escape
|
|
13
|
+
* hatch for ad-hoc flags without growing the signature. They never error.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { type ParsedArgv, StravError, type StravErrorOptions } from '@strav/kernel'
|
|
17
|
+
import type { Signature } from './signature.ts'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Thrown when argv doesn't match a `Signature` — missing required positional,
|
|
21
|
+
* unexpected extra positional, or a value-flag with no value. Status 2 mirrors
|
|
22
|
+
* the POSIX "usage error" exit-code convention; the CliConsoleKernel surfaces
|
|
23
|
+
* it as exit code 2.
|
|
24
|
+
*/
|
|
25
|
+
export class UsageError extends StravError {
|
|
26
|
+
constructor(message: string, options: StravErrorOptions = {}) {
|
|
27
|
+
super(message, { code: 'cli.usage', status: 2 }, options)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface BoundArgv {
|
|
32
|
+
args: Record<string, string | undefined>
|
|
33
|
+
flags: Record<string, string | boolean>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function bindArgv(signature: Signature, parsed: ParsedArgv): BoundArgv {
|
|
37
|
+
const args: Record<string, string | undefined> = {}
|
|
38
|
+
const flags: Record<string, string | boolean> = {}
|
|
39
|
+
|
|
40
|
+
// ─── positionals ────────────────────────────────────────────────────────────
|
|
41
|
+
for (let i = 0; i < signature.args.length; i++) {
|
|
42
|
+
const spec = signature.args[i]
|
|
43
|
+
if (!spec) continue
|
|
44
|
+
const value = parsed.args[i]
|
|
45
|
+
if (value === undefined) {
|
|
46
|
+
if (!spec.optional) {
|
|
47
|
+
throw new UsageError(`missing argument: <${spec.name}>`)
|
|
48
|
+
}
|
|
49
|
+
args[spec.name] = undefined
|
|
50
|
+
} else {
|
|
51
|
+
args[spec.name] = value
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (parsed.args.length > signature.args.length) {
|
|
56
|
+
const extra = parsed.args[signature.args.length]
|
|
57
|
+
throw new UsageError(`unexpected argument: "${extra}"`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── declared flags ─────────────────────────────────────────────────────────
|
|
61
|
+
const declaredFlagNames = new Set<string>()
|
|
62
|
+
for (const spec of signature.flags) {
|
|
63
|
+
declaredFlagNames.add(spec.name)
|
|
64
|
+
const raw = parsed.flags[spec.name]
|
|
65
|
+
if (raw === undefined) {
|
|
66
|
+
flags[spec.name] = spec.default
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
if (spec.kind === 'boolean') {
|
|
70
|
+
// Bare `--flag` parses as `true`; an explicit `--flag=foo` also flips
|
|
71
|
+
// it to true (the value is meaningless for a boolean — we ignore it
|
|
72
|
+
// rather than error so `--verbose=1` from CI scripts doesn't surprise).
|
|
73
|
+
flags[spec.name] = raw !== false
|
|
74
|
+
} else {
|
|
75
|
+
if (raw === true) {
|
|
76
|
+
throw new UsageError(`flag --${spec.name} requires a value`)
|
|
77
|
+
}
|
|
78
|
+
flags[spec.name] = raw
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── undeclared flags (pass through) ────────────────────────────────────────
|
|
83
|
+
for (const [name, value] of Object.entries(parsed.flags)) {
|
|
84
|
+
if (!declaredFlagNames.has(name)) flags[name] = value
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { args, flags }
|
|
88
|
+
}
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Command` — base class for CLI commands.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `@strav/kernel`'s minimal `Command` (which exposes `handle(ctx)` with
|
|
5
|
+
* positional `args: readonly string[]`) and adds:
|
|
6
|
+
* - The signature DSL (`'cmd {arg} {arg?} {--flag=default}'`), parsed once
|
|
7
|
+
* at registration time. `execute({ args, flags })` receives the bound
|
|
8
|
+
* values keyed by name.
|
|
9
|
+
* - Output helpers as instance methods (`this.info` / `this.warn` / …) so
|
|
10
|
+
* command bodies stay declarative without threading the writer around.
|
|
11
|
+
* - Interactive prompts (`this.confirm`, `this.ask`, `this.choice`,
|
|
12
|
+
* `this.table`) backed by Bun's stdin reader.
|
|
13
|
+
* - `static providers?: string[]` — names of providers to boot. Resolved
|
|
14
|
+
* by the `CliConsoleKernel` against the default list (transitive deps
|
|
15
|
+
* auto-included). Omit for "boot everything"; `[]` for "boot nothing".
|
|
16
|
+
*
|
|
17
|
+
* Subclasses implement `execute(args)` and return a number (exit code) or
|
|
18
|
+
* void (treated as 0). Errors thrown from `execute()` bubble up to the
|
|
19
|
+
* kernel, which surfaces them via stderr + returns exit 1.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
type Application,
|
|
24
|
+
type CommandContext,
|
|
25
|
+
type CommandResult,
|
|
26
|
+
Command as KernelCommand,
|
|
27
|
+
} from '@strav/kernel'
|
|
28
|
+
import { bindArgv, UsageError } from './binder.ts'
|
|
29
|
+
import { ExitCode } from './exit_codes.ts'
|
|
30
|
+
import { parseSignature, type Signature } from './signature.ts'
|
|
31
|
+
|
|
32
|
+
export interface ExecuteArgs {
|
|
33
|
+
/** Positional args, keyed by name (from `{name}` in the signature). */
|
|
34
|
+
args: Record<string, string | undefined>
|
|
35
|
+
/**
|
|
36
|
+
* Parsed flags, keyed by name. Boolean flags resolve to `false` by default;
|
|
37
|
+
* string flags to their declared default. Undeclared flags pass through.
|
|
38
|
+
*/
|
|
39
|
+
flags: Record<string, string | boolean>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Static metadata every CLI command must declare. The richer `signature` is
|
|
44
|
+
* the source of truth for argv binding — the bare command name is derived
|
|
45
|
+
* from its first token. `description` is the one-liner shown by `list`.
|
|
46
|
+
*/
|
|
47
|
+
export interface CliCommandMeta {
|
|
48
|
+
readonly signature: string
|
|
49
|
+
readonly description: string
|
|
50
|
+
/**
|
|
51
|
+
* Subset of provider names to boot for this command. Omitted = full default
|
|
52
|
+
* list. `[]` = none. See `docs/cli/guides/subset-boot.md`.
|
|
53
|
+
*/
|
|
54
|
+
readonly providers?: readonly string[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export abstract class Command extends KernelCommand {
|
|
58
|
+
/** Lazily-built `Signature` from `static signature`. Re-used across handle() calls. */
|
|
59
|
+
private static signatureCache: WeakMap<typeof Command, Signature> = new WeakMap()
|
|
60
|
+
|
|
61
|
+
static parsedSignature(): Signature {
|
|
62
|
+
// `this` here refers to the SUBCLASS that called .parsedSignature(), which
|
|
63
|
+
// is what we want — the cache keys per-subclass so each command parses its
|
|
64
|
+
// signature once. Using the literal `Command` name would collapse all
|
|
65
|
+
// subclasses to the same key.
|
|
66
|
+
// biome-ignore lint/complexity/noThisInStatic: intentional subclass-aware cache
|
|
67
|
+
const Class = this as typeof Command
|
|
68
|
+
const cached = Command.signatureCache.get(Class)
|
|
69
|
+
if (cached) return cached
|
|
70
|
+
const raw = (Class as { signature?: unknown }).signature
|
|
71
|
+
if (typeof raw !== 'string') {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`${Class.name}: missing static \`signature\` string — every Command needs one`,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
const parsed = parseSignature(raw)
|
|
77
|
+
Command.signatureCache.set(Class, parsed)
|
|
78
|
+
return parsed
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Kernel calls this with positional argv; we re-bind via the signature and call `execute()`. */
|
|
82
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: matches kernel's CommandResult — void means "treat as 0"
|
|
83
|
+
override async handle(ctx: CommandContext): Promise<number | void> {
|
|
84
|
+
const Class = this.constructor as typeof Command
|
|
85
|
+
const signature = Class.parsedSignature()
|
|
86
|
+
this.output = ctx.out
|
|
87
|
+
this.app = ctx.app
|
|
88
|
+
|
|
89
|
+
// `<cmd> --help` short-circuits to per-command help.
|
|
90
|
+
if (ctx.flags.help === true || ctx.flags.h === true) {
|
|
91
|
+
this.printHelp()
|
|
92
|
+
return 0
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// UsageError → POSIX exit code 2 + per-command usage line on stderr.
|
|
96
|
+
// The catch covers BOTH the binder (signature mismatch) AND execute()
|
|
97
|
+
// itself — commands often validate flag values after binding (e.g.,
|
|
98
|
+
// `--batch=not-a-number`) and throw UsageError. Other exceptions still
|
|
99
|
+
// get the kernel's generic exit-1 + stack-trace treatment.
|
|
100
|
+
try {
|
|
101
|
+
const bound = bindArgv(signature, {
|
|
102
|
+
command: signature.name,
|
|
103
|
+
args: [...ctx.args],
|
|
104
|
+
flags: { ...ctx.flags },
|
|
105
|
+
})
|
|
106
|
+
return await this.execute(bound)
|
|
107
|
+
} catch (err) {
|
|
108
|
+
if (err instanceof UsageError) {
|
|
109
|
+
this.error(err.message)
|
|
110
|
+
this.error(`Usage: ${formatUsage(signature)}`)
|
|
111
|
+
return ExitCode.UsageError
|
|
112
|
+
}
|
|
113
|
+
throw err
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Print the help message for this command. Composed from `static description`
|
|
119
|
+
* + the parsed signature + the optional `help()` override.
|
|
120
|
+
*/
|
|
121
|
+
protected printHelp(): void {
|
|
122
|
+
const Class = this.constructor as typeof Command
|
|
123
|
+
const signature = Class.parsedSignature()
|
|
124
|
+
const description = (Class as { description?: unknown }).description
|
|
125
|
+
if (typeof description === 'string' && description.length > 0) {
|
|
126
|
+
this.line(description)
|
|
127
|
+
this.line()
|
|
128
|
+
}
|
|
129
|
+
this.line(`Usage: ${formatUsage(signature)}`)
|
|
130
|
+
if (signature.args.length > 0) {
|
|
131
|
+
this.line()
|
|
132
|
+
this.line('Arguments:')
|
|
133
|
+
for (const arg of signature.args) {
|
|
134
|
+
this.line(` ${arg.name}${arg.optional ? '?' : ''}`)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (signature.flags.length > 0) {
|
|
138
|
+
this.line()
|
|
139
|
+
this.line('Flags:')
|
|
140
|
+
for (const flag of signature.flags) {
|
|
141
|
+
const suffix = flag.kind === 'string' ? `=<value> (default: ${flag.default})` : ' (boolean)'
|
|
142
|
+
this.line(` --${flag.name}${suffix}`)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const extra = this.help()
|
|
146
|
+
if (extra) {
|
|
147
|
+
this.line()
|
|
148
|
+
this.line(extra)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Set by `handle()` before calling `execute()`. */
|
|
153
|
+
protected output!: CommandContext['out']
|
|
154
|
+
/**
|
|
155
|
+
* The booted `Application` — set by `handle()` before `execute()` runs.
|
|
156
|
+
* Use it to resolve services that don't fit through constructor injection
|
|
157
|
+
* (e.g., when a command's deps are dynamic, or when subset boot binds a
|
|
158
|
+
* service that the command's static `@inject()` doesn't know about).
|
|
159
|
+
*/
|
|
160
|
+
protected app!: Application
|
|
161
|
+
|
|
162
|
+
/** Implement the command. Return a number for an explicit exit code, void for 0. */
|
|
163
|
+
abstract execute(argv: ExecuteArgs): CommandResult
|
|
164
|
+
|
|
165
|
+
// ─── output helpers — thin wrappers around ConsoleOutput ─────────────────────
|
|
166
|
+
|
|
167
|
+
protected info(msg: string): void {
|
|
168
|
+
this.output.info(msg)
|
|
169
|
+
}
|
|
170
|
+
protected success(msg: string): void {
|
|
171
|
+
this.output.success(msg)
|
|
172
|
+
}
|
|
173
|
+
protected warn(msg: string): void {
|
|
174
|
+
this.output.warn(msg)
|
|
175
|
+
}
|
|
176
|
+
protected error(msg: string): void {
|
|
177
|
+
this.output.error(msg)
|
|
178
|
+
}
|
|
179
|
+
protected line(msg = ''): void {
|
|
180
|
+
this.output.line(msg)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Print a table with aligned columns. Headers + every row are stringified;
|
|
185
|
+
* column widths derived from the widest cell. Apps that need fancier
|
|
186
|
+
* tables write to `this.output` directly.
|
|
187
|
+
*/
|
|
188
|
+
protected table(headers: readonly string[], rows: readonly (readonly string[])[]): void {
|
|
189
|
+
const widths = headers.map((h, i) =>
|
|
190
|
+
Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)),
|
|
191
|
+
)
|
|
192
|
+
const fmt = (cells: readonly string[]) =>
|
|
193
|
+
cells.map((c, i) => (c ?? '').padEnd(widths[i] ?? 0)).join(' ')
|
|
194
|
+
this.line(fmt(headers))
|
|
195
|
+
this.line(widths.map((w) => '-'.repeat(w)).join(' '))
|
|
196
|
+
for (const row of rows) this.line(fmt(row))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Yes/no prompt. Accepts `y`/`yes` (case-insensitive) as `true`; anything
|
|
201
|
+
* else (including empty input) as `false`. Use `defaultYes: true` to flip
|
|
202
|
+
* empty input to `true`.
|
|
203
|
+
*/
|
|
204
|
+
protected async confirm(question: string, opts: { defaultYes?: boolean } = {}): Promise<boolean> {
|
|
205
|
+
const suffix = opts.defaultYes ? ' [Y/n] ' : ' [y/N] '
|
|
206
|
+
this.output.write(`${question}${suffix}`)
|
|
207
|
+
const answer = (await readStdinLine()).trim().toLowerCase()
|
|
208
|
+
if (answer === '') return opts.defaultYes ?? false
|
|
209
|
+
return answer === 'y' || answer === 'yes'
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Free-text prompt. Returns the default when the user just hits enter. */
|
|
213
|
+
protected async ask(question: string, defaultValue?: string): Promise<string> {
|
|
214
|
+
const suffix = defaultValue !== undefined ? ` [${defaultValue}] ` : ' '
|
|
215
|
+
this.output.write(`${question}${suffix}`)
|
|
216
|
+
const answer = (await readStdinLine()).trim()
|
|
217
|
+
if (answer === '') return defaultValue ?? ''
|
|
218
|
+
return answer
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Multiple-choice prompt — prints options as a numbered list, accepts
|
|
223
|
+
* either the number or the option text. Re-prompts on bad input.
|
|
224
|
+
*/
|
|
225
|
+
protected async choice<T extends string>(
|
|
226
|
+
question: string,
|
|
227
|
+
options: readonly T[],
|
|
228
|
+
defaultValue?: T,
|
|
229
|
+
): Promise<T> {
|
|
230
|
+
if (options.length === 0) {
|
|
231
|
+
throw new Error('choice(): options must be non-empty')
|
|
232
|
+
}
|
|
233
|
+
while (true) {
|
|
234
|
+
this.line(question)
|
|
235
|
+
for (let i = 0; i < options.length; i++) {
|
|
236
|
+
const marker = options[i] === defaultValue ? ' (default)' : ''
|
|
237
|
+
this.line(` ${i + 1}. ${options[i]}${marker}`)
|
|
238
|
+
}
|
|
239
|
+
this.output.write('> ')
|
|
240
|
+
const raw = (await readStdinLine()).trim()
|
|
241
|
+
if (raw === '' && defaultValue !== undefined) return defaultValue
|
|
242
|
+
const asNum = Number.parseInt(raw, 10)
|
|
243
|
+
if (Number.isInteger(asNum) && asNum >= 1 && asNum <= options.length) {
|
|
244
|
+
return options[asNum - 1] as T
|
|
245
|
+
}
|
|
246
|
+
const match = options.find((o) => o === raw)
|
|
247
|
+
if (match !== undefined) return match
|
|
248
|
+
this.warn(`Invalid choice: "${raw}"`)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Optional per-command help text. Override to provide examples / extra detail. */
|
|
253
|
+
help(): string | undefined {
|
|
254
|
+
return undefined
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Render `cmd {arg} {arg?} [--flag=…]` from a parsed signature for help output. */
|
|
259
|
+
function formatUsage(signature: Signature): string {
|
|
260
|
+
const parts: string[] = [signature.name]
|
|
261
|
+
for (const arg of signature.args) parts.push(arg.optional ? `[${arg.name}]` : `<${arg.name}>`)
|
|
262
|
+
for (const flag of signature.flags) {
|
|
263
|
+
parts.push(flag.kind === 'string' ? `[--${flag.name}=…]` : `[--${flag.name}]`)
|
|
264
|
+
}
|
|
265
|
+
return parts.join(' ')
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Read one line from stdin. Returns the empty string at EOF. Decoded as UTF-8.
|
|
270
|
+
*
|
|
271
|
+
* Bun's `console.readLine()` blocks the event loop; this uses the `for await`
|
|
272
|
+
* stream interface so other async work (e.g., a spinner) can run alongside.
|
|
273
|
+
*/
|
|
274
|
+
async function readStdinLine(): Promise<string> {
|
|
275
|
+
let buf = ''
|
|
276
|
+
const decoder = new TextDecoder()
|
|
277
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
278
|
+
buf += decoder.decode(chunk, { stream: true })
|
|
279
|
+
const newline = buf.indexOf('\n')
|
|
280
|
+
if (newline !== -1) {
|
|
281
|
+
return buf.slice(0, newline).replace(/\r$/, '')
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
buf += decoder.decode()
|
|
285
|
+
return buf.replace(/\r$/, '')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Constructor type the `CliConsoleKernel` accepts. Combines the runtime
|
|
290
|
+
* constructor (so the container can `make()` it) with the static metadata
|
|
291
|
+
* the kernel reads at registration / dispatch time.
|
|
292
|
+
*/
|
|
293
|
+
export type CliCommandClass<T extends Command = Command> = (new (
|
|
294
|
+
// biome-ignore lint/suspicious/noExplicitAny: structural ctor — Command subclasses vary in injected deps
|
|
295
|
+
...args: any[]
|
|
296
|
+
) => T) &
|
|
297
|
+
CliCommandMeta
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav config:list` — list every top-level config namespace.
|
|
3
|
+
*
|
|
4
|
+
* Useful for sanity-checking what `ConfigProvider` actually loaded —
|
|
5
|
+
* e.g., confirming that `config.auth` is present after wiring a new
|
|
6
|
+
* provider, or seeing which app sections are populated in production
|
|
7
|
+
* vs. development.
|
|
8
|
+
*
|
|
9
|
+
* Pairs with `config:show <key>` for drilling in. Outputs one
|
|
10
|
+
* namespace per line, alphabetically sorted, with a trailing `(empty)`
|
|
11
|
+
* marker for namespaces whose value is `undefined` / `null` / an empty
|
|
12
|
+
* object.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { ConfigRepository } from '@strav/kernel'
|
|
16
|
+
import { Command } from './command.ts'
|
|
17
|
+
import { ExitCode } from './exit_codes.ts'
|
|
18
|
+
|
|
19
|
+
export class ConfigList extends Command {
|
|
20
|
+
static signature = 'config:list'
|
|
21
|
+
static description = 'List top-level config namespaces (app, auth, database, …).'
|
|
22
|
+
static providers = ['config']
|
|
23
|
+
|
|
24
|
+
override execute(): number {
|
|
25
|
+
const all = this.app.resolve(ConfigRepository).all()
|
|
26
|
+
const keys = Object.keys(all).sort()
|
|
27
|
+
|
|
28
|
+
if (keys.length === 0) {
|
|
29
|
+
this.info('No config namespaces are loaded.')
|
|
30
|
+
return ExitCode.Success
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const key of keys) {
|
|
34
|
+
const value = all[key]
|
|
35
|
+
const empty =
|
|
36
|
+
value == null ||
|
|
37
|
+
(typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0)
|
|
38
|
+
this.line(empty ? `${key} (empty)` : key)
|
|
39
|
+
}
|
|
40
|
+
return ExitCode.Success
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav config:show <key>` — print a single config value by dot-path.
|
|
3
|
+
*
|
|
4
|
+
* Resolves the value through `ConfigRepository.get`, which means apps see
|
|
5
|
+
* the same lookup semantics here as in their providers — including any
|
|
6
|
+
* env-var indirection / config decorators applied at boot.
|
|
7
|
+
*
|
|
8
|
+
* `--json` prints a compact JSON encoding (objects, arrays, numbers, …).
|
|
9
|
+
* The default formatter handles scalars and objects sensibly so common
|
|
10
|
+
* lookups (`config:show app.url`) "just work" without `--json`.
|
|
11
|
+
*
|
|
12
|
+
* Secret values: this command does **no** redaction. Apps that want to
|
|
13
|
+
* audit secret access from the CLI should wrap config access in their
|
|
14
|
+
* own provider or rely on the logger's redaction config when piping the
|
|
15
|
+
* output to logs.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { ConfigRepository } from '@strav/kernel'
|
|
19
|
+
import { Command, type ExecuteArgs } from './command.ts'
|
|
20
|
+
import { ExitCode } from './exit_codes.ts'
|
|
21
|
+
|
|
22
|
+
export class ConfigShow extends Command {
|
|
23
|
+
static signature = 'config:show {key} {--json}'
|
|
24
|
+
static description = 'Print a config value by dot-path (e.g. config:show app.url).'
|
|
25
|
+
static providers = ['config']
|
|
26
|
+
|
|
27
|
+
override execute({ args, flags }: ExecuteArgs): number {
|
|
28
|
+
const key = args.key as string
|
|
29
|
+
const value = this.app.resolve(ConfigRepository).get(key)
|
|
30
|
+
|
|
31
|
+
if (value === undefined) {
|
|
32
|
+
this.error(`Config key not set: ${key}`)
|
|
33
|
+
return ExitCode.DataError
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (flags.json === true) {
|
|
37
|
+
this.line(JSON.stringify(value))
|
|
38
|
+
return ExitCode.Success
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (value === null || typeof value !== 'object') {
|
|
42
|
+
this.line(String(value))
|
|
43
|
+
return ExitCode.Success
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Objects / arrays — pretty-print so nested config is readable.
|
|
47
|
+
this.line(JSON.stringify(value, null, 2))
|
|
48
|
+
return ExitCode.Success
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ConsoleProvider` — declares a set of console `Command` classes the app
|
|
3
|
+
* registers with the `CliConsoleKernel`.
|
|
4
|
+
*
|
|
5
|
+
* Apps subclass it once in `app/providers/console_provider.ts`:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { ConsoleProvider } from '@strav/cli'
|
|
9
|
+
* import { TenantBackup } from '../console/commands/tenant_backup.ts'
|
|
10
|
+
*
|
|
11
|
+
* export class AppConsoleProvider extends ConsoleProvider {
|
|
12
|
+
* override readonly name = 'console.app'
|
|
13
|
+
* override readonly commands = [TenantBackup] as const
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* `runCli({ defaultProviders })` walks the provider list once, pulls every
|
|
18
|
+
* `commands` array off subclasses, and hands the union to the
|
|
19
|
+
* `CliConsoleKernel`. Apps don't have to wire commands a second time.
|
|
20
|
+
*
|
|
21
|
+
* The provider doesn't do anything at `register()` — command collection
|
|
22
|
+
* happens before the app boots so subset-boot (`static providers`) can
|
|
23
|
+
* pre-filter the provider list.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { ServiceProvider } from '@strav/kernel'
|
|
27
|
+
import type { CliCommandClass } from './command.ts'
|
|
28
|
+
|
|
29
|
+
export abstract class ConsoleProvider extends ServiceProvider {
|
|
30
|
+
/** Commands this provider contributes. Override in subclasses. */
|
|
31
|
+
readonly commands: readonly CliCommandClass[] = []
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Collect every command across an ordered provider list. Order matches the
|
|
36
|
+
* iteration; duplicate-by-signature is caught later by `CliConsoleKernel`.
|
|
37
|
+
*/
|
|
38
|
+
export function collectCommands(providers: readonly ServiceProvider[]): CliCommandClass[] {
|
|
39
|
+
const out: CliCommandClass[] = []
|
|
40
|
+
for (const provider of providers) {
|
|
41
|
+
if (provider instanceof ConsoleProvider) {
|
|
42
|
+
out.push(...provider.commands)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POSIX-aligned exit-code constants. Commands return one of these (or any
|
|
3
|
+
* number ≥ 100 for command-specific failures); the framework defaults to
|
|
4
|
+
* 0 when `execute()` returns `void` and 1 when an error escapes.
|
|
5
|
+
*
|
|
6
|
+
* Apps reach for these via `import { ExitCode } from '@strav/cli'` so the
|
|
7
|
+
* call site reads `return ExitCode.UsageError` instead of a magic `2`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const ExitCode = {
|
|
11
|
+
/** Success. */
|
|
12
|
+
Success: 0,
|
|
13
|
+
/** Unspecified failure. The kernel uses this when an exception escapes. */
|
|
14
|
+
GenericFailure: 1,
|
|
15
|
+
/**
|
|
16
|
+
* Argv didn't match the command's signature — missing positional, extra
|
|
17
|
+
* positional, value-flag with no value. Thrown by the binder as `UsageError`.
|
|
18
|
+
*/
|
|
19
|
+
UsageError: 2,
|
|
20
|
+
/** Configuration is invalid or missing (e.g., `APP_KEY` unset). */
|
|
21
|
+
ConfigError: 64,
|
|
22
|
+
/** Data dependency unavailable (DB unreachable, file missing). */
|
|
23
|
+
DataError: 65,
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode]
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,60 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// Public API of @strav/cli.
|
|
2
|
+
//
|
|
3
|
+
// Adds the signature DSL (`'cmd {arg} {arg?} {--flag=default}'`), a richer
|
|
4
|
+
// `Command` base with output helpers + interactive prompts, `ConsoleProvider`
|
|
5
|
+
// for command registration, subset boot (`static providers = [...]`), and
|
|
6
|
+
// `runCli` as the entry point for `bin/strav.ts`.
|
|
7
|
+
//
|
|
8
|
+
// Built on @strav/kernel's `Command` / `ConsoleKernel` — those stay as the
|
|
9
|
+
// minimal infrastructure; @strav/cli is the productive developer surface.
|
|
10
|
+
//
|
|
11
|
+
// Deferred to post-M4 slices (each lands when its underlying package does):
|
|
12
|
+
// - `cache:*` — needs `@strav/cache` (not yet a package)
|
|
13
|
+
// - `tenant:*` — needs a generic tenant-CRUD convention or app-side hooks
|
|
14
|
+
// - `plugin:install` — needs `package.json#strav` metadata convention
|
|
15
|
+
|
|
16
|
+
export { type BoundArgv, bindArgv, UsageError } from './binder.ts'
|
|
17
|
+
export {
|
|
18
|
+
type CliCommandClass,
|
|
19
|
+
type CliCommandMeta,
|
|
20
|
+
Command,
|
|
21
|
+
type ExecuteArgs,
|
|
22
|
+
} from './command.ts'
|
|
23
|
+
export { ConsoleProvider, collectCommands } from './console_provider.ts'
|
|
24
|
+
export { ConfigList } from './config_list.ts'
|
|
25
|
+
export { ConfigShow } from './config_show.ts'
|
|
26
|
+
export { ExitCode, type ExitCodeValue } from './exit_codes.ts'
|
|
27
|
+
export { KeyGenerate } from './key_generate.ts'
|
|
28
|
+
export {
|
|
29
|
+
MakeCommandFile,
|
|
30
|
+
MakeController,
|
|
31
|
+
MakeFactory,
|
|
32
|
+
MakeJob,
|
|
33
|
+
MakeMail,
|
|
34
|
+
MakeMiddleware,
|
|
35
|
+
MakeMigration,
|
|
36
|
+
MakeModel,
|
|
37
|
+
MakeNotification,
|
|
38
|
+
MakePolicy,
|
|
39
|
+
MakeProvider,
|
|
40
|
+
MakeRepository,
|
|
41
|
+
MakeRequest,
|
|
42
|
+
MakeSeeder,
|
|
43
|
+
MakeTest,
|
|
44
|
+
} from './make/index.ts'
|
|
45
|
+
export {
|
|
46
|
+
camel,
|
|
47
|
+
MakeCommand as MakeCommandBase,
|
|
48
|
+
pascal,
|
|
49
|
+
snake,
|
|
50
|
+
} from './make_command.ts'
|
|
51
|
+
export { type RunCliOptions, runCli } from './run_cli.ts'
|
|
52
|
+
export { ScaffoldConsoleProvider } from './scaffold_console_provider.ts'
|
|
53
|
+
export {
|
|
54
|
+
type FlagSpec,
|
|
55
|
+
type PositionalArg,
|
|
56
|
+
parseSignature,
|
|
57
|
+
type Signature,
|
|
58
|
+
} from './signature.ts'
|
|
59
|
+
export { selectProviders } from './subset_boot.ts'
|
|
60
|
+
export { UtilConsoleProvider } from './util_console_provider.ts'
|