@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.
Files changed (63) hide show
  1. package/package.json +17 -41
  2. package/src/binder.ts +88 -0
  3. package/src/command.ts +297 -0
  4. package/src/config_list.ts +42 -0
  5. package/src/config_show.ts +50 -0
  6. package/src/console_provider.ts +46 -0
  7. package/src/exit_codes.ts +26 -0
  8. package/src/index.ts +60 -2
  9. package/src/key_generate.ts +66 -0
  10. package/src/make/index.ts +17 -0
  11. package/src/make/make_command_file.ts +27 -0
  12. package/src/make/make_controller.ts +24 -0
  13. package/src/make/make_factory.ts +25 -0
  14. package/src/make/make_job.ts +25 -0
  15. package/src/make/make_mail.ts +27 -0
  16. package/src/make/make_middleware.ts +23 -0
  17. package/src/make/make_migration.ts +48 -0
  18. package/src/make/make_model.ts +91 -0
  19. package/src/make/make_notification.ts +23 -0
  20. package/src/make/make_policy.ts +24 -0
  21. package/src/make/make_provider.ts +29 -0
  22. package/src/make/make_repository.ts +30 -0
  23. package/src/make/make_request.ts +24 -0
  24. package/src/make/make_seeder.ts +23 -0
  25. package/src/make/make_test.ts +22 -0
  26. package/src/make_command.ts +69 -0
  27. package/src/run_cli.ts +121 -0
  28. package/src/scaffold_console_provider.ts +45 -0
  29. package/src/signature.ts +171 -0
  30. package/src/subset_boot.ts +51 -0
  31. package/src/util_console_provider.ts +18 -0
  32. package/src/cli/bootstrap.ts +0 -82
  33. package/src/cli/command_loader.ts +0 -180
  34. package/src/cli/index.ts +0 -3
  35. package/src/cli/strav.ts +0 -13
  36. package/src/commands/db_seed.ts +0 -77
  37. package/src/commands/db_setup_roles.ts +0 -101
  38. package/src/commands/generate_api.ts +0 -93
  39. package/src/commands/generate_key.ts +0 -47
  40. package/src/commands/generate_models.ts +0 -49
  41. package/src/commands/generate_seeder.ts +0 -68
  42. package/src/commands/migration_compare.ts +0 -167
  43. package/src/commands/migration_fresh.ts +0 -148
  44. package/src/commands/migration_generate.ts +0 -84
  45. package/src/commands/migration_rollback.ts +0 -54
  46. package/src/commands/migration_run.ts +0 -45
  47. package/src/commands/package_install.ts +0 -161
  48. package/src/commands/queue_flush.ts +0 -35
  49. package/src/commands/queue_retry.ts +0 -34
  50. package/src/commands/queue_work.ts +0 -101
  51. package/src/commands/scheduler_work.ts +0 -46
  52. package/src/commands/tenant_create.ts +0 -35
  53. package/src/commands/tenant_delete.ts +0 -64
  54. package/src/commands/tenant_list.ts +0 -39
  55. package/src/config/loader.ts +0 -50
  56. package/src/generators/api_generator.ts +0 -1035
  57. package/src/generators/config.ts +0 -113
  58. package/src/generators/doc_generator.ts +0 -996
  59. package/src/generators/index.ts +0 -11
  60. package/src/generators/model_generator.ts +0 -596
  61. package/src/generators/route_generator.ts +0 -187
  62. package/src/generators/test_generator.ts +0 -1667
  63. package/tsconfig.json +0 -5
package/src/run_cli.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `runCli` — entry point for `bin/strav.ts`.
3
+ *
4
+ * Flow:
5
+ * 1. Parse argv to find the command name (no app yet).
6
+ * 2. Look up the matching `CliCommandClass` by signature first-token.
7
+ * 3. Read its `static providers` (if any) → filter `defaultProviders`
8
+ * with transitive deps auto-included.
9
+ * 4. Build (or accept) the Application. Register the filtered providers.
10
+ * 5. Hand off to `@strav/kernel`'s `ConsoleKernel.run(...)`, which boots,
11
+ * dispatches, and shuts down. Per-command argv binding happens inside
12
+ * `Command.handle()` (the kernel calls `handle`, our base re-binds).
13
+ *
14
+ * Return: the dispatch's exit code. Callers in `bin/strav.ts` are expected
15
+ * to forward it via `process.exit(code)`.
16
+ *
17
+ * Args:
18
+ * - `argv` — typically `process.argv.slice(2)`.
19
+ * - `defaultProviders` — the full provider list from `bootstrap/providers.ts`.
20
+ * - `commands` — optional explicit command list. When omitted, every
21
+ * `ConsoleProvider` subclass found in `defaultProviders` contributes its
22
+ * `commands` array.
23
+ * - `app` — pre-built Application (tests). When omitted, one is constructed.
24
+ */
25
+
26
+ import {
27
+ type Application,
28
+ ConfigError,
29
+ ConsoleKernel,
30
+ type ConsoleOutputOptions,
31
+ parseArgv,
32
+ type ServiceProvider,
33
+ } from '@strav/kernel'
34
+ import type { CliCommandClass } from './command.ts'
35
+ import { collectCommands } from './console_provider.ts'
36
+ import { selectProviders } from './subset_boot.ts'
37
+
38
+ export interface RunCliOptions {
39
+ argv: readonly string[]
40
+ /** Full default provider list — typically from `bootstrap/providers.ts`. */
41
+ defaultProviders: readonly ServiceProvider[]
42
+ /**
43
+ * Explicit command list. Optional — when omitted, commands are collected
44
+ * from every `ConsoleProvider` subclass in `defaultProviders`.
45
+ */
46
+ commands?: readonly CliCommandClass[]
47
+ /** Pre-built application (tests). */
48
+ app?: Application
49
+ output?: ConsoleOutputOptions
50
+ /** Install SIGINT/SIGTERM handlers. Default `false` for console. */
51
+ signalHandlers?: boolean
52
+ }
53
+
54
+ export async function runCli(options: RunCliOptions): Promise<number> {
55
+ const commands = options.commands ?? collectCommands(options.defaultProviders)
56
+ assertUniqueCommands(commands)
57
+ const byName = indexByName(commands)
58
+ const { command: commandName } = parseArgv(options.argv)
59
+
60
+ // ─── pick the subset of providers to boot ──────────────────────────────────
61
+ // No command (or `list` / `--help`) → boot nothing; the kernel just prints
62
+ // the registered list. Apps that wire a custom listing command can override.
63
+ let providers: ServiceProvider[]
64
+ if (
65
+ commandName === undefined ||
66
+ commandName === 'list' ||
67
+ commandName === '--help' ||
68
+ commandName === '-h'
69
+ ) {
70
+ providers = []
71
+ } else {
72
+ const Class = byName.get(commandName)
73
+ const requested = (Class as { providers?: readonly string[] } | undefined)?.providers
74
+ providers = selectProviders(options.defaultProviders, requested, commandName)
75
+ }
76
+
77
+ // ─── delegate to kernel's ConsoleKernel.run ────────────────────────────────
78
+ const runArgs: {
79
+ argv: readonly string[]
80
+ providers: ServiceProvider[]
81
+ commands: readonly CliCommandClass[]
82
+ signalHandlers: boolean
83
+ app?: Application
84
+ output?: ConsoleOutputOptions
85
+ } = {
86
+ argv: options.argv,
87
+ providers,
88
+ commands,
89
+ signalHandlers: options.signalHandlers ?? false,
90
+ }
91
+ if (options.app !== undefined) runArgs.app = options.app
92
+ if (options.output !== undefined) runArgs.output = options.output
93
+ return ConsoleKernel.run(runArgs)
94
+ }
95
+
96
+ function assertUniqueCommands(commands: readonly CliCommandClass[]): void {
97
+ const seen = new Map<string, CliCommandClass>()
98
+ for (const Class of commands) {
99
+ const name = firstToken(Class.signature)
100
+ const existing = seen.get(name)
101
+ if (existing) {
102
+ throw new ConfigError(
103
+ `runCli: command "${name}" declared twice (${existing.name} and ${Class.name})`,
104
+ )
105
+ }
106
+ seen.set(name, Class)
107
+ }
108
+ }
109
+
110
+ function indexByName(commands: readonly CliCommandClass[]): Map<string, CliCommandClass> {
111
+ const out = new Map<string, CliCommandClass>()
112
+ for (const Class of commands) out.set(firstToken(Class.signature), Class)
113
+ return out
114
+ }
115
+
116
+ /** Pull the command name (first whitespace-delimited token) out of a signature. */
117
+ function firstToken(signature: string): string {
118
+ const trimmed = signature.trimStart()
119
+ const space = trimmed.search(/\s/)
120
+ return space === -1 ? trimmed : trimmed.slice(0, space)
121
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `ScaffoldConsoleProvider` — all `make:*` commands.
3
+ *
4
+ * Apps add it to `bootstrap/providers.ts`. Because all make:* commands
5
+ * set `static providers = []` (boot nothing), the startup cost is zero
6
+ * when a scaffolding command runs.
7
+ */
8
+
9
+ import { ConsoleProvider } from './console_provider.ts'
10
+ import { MakeCommandFile } from './make/make_command_file.ts'
11
+ import { MakeController } from './make/make_controller.ts'
12
+ import { MakeFactory } from './make/make_factory.ts'
13
+ import { MakeJob } from './make/make_job.ts'
14
+ import { MakeMail } from './make/make_mail.ts'
15
+ import { MakeMiddleware } from './make/make_middleware.ts'
16
+ import { MakeMigration } from './make/make_migration.ts'
17
+ import { MakeModel } from './make/make_model.ts'
18
+ import { MakeNotification } from './make/make_notification.ts'
19
+ import { MakePolicy } from './make/make_policy.ts'
20
+ import { MakeProvider } from './make/make_provider.ts'
21
+ import { MakeRepository } from './make/make_repository.ts'
22
+ import { MakeRequest } from './make/make_request.ts'
23
+ import { MakeSeeder } from './make/make_seeder.ts'
24
+ import { MakeTest } from './make/make_test.ts'
25
+
26
+ export class ScaffoldConsoleProvider extends ConsoleProvider {
27
+ override readonly name = 'console.scaffold'
28
+ override readonly commands = [
29
+ MakeController,
30
+ MakeMiddleware,
31
+ MakeRequest,
32
+ MakeModel,
33
+ MakeRepository,
34
+ MakeMigration,
35
+ MakeSeeder,
36
+ MakeFactory,
37
+ MakeJob,
38
+ MakeMail,
39
+ MakeNotification,
40
+ MakePolicy,
41
+ MakeProvider,
42
+ MakeCommandFile,
43
+ MakeTest,
44
+ ] as const
45
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Signature DSL — parses `'cmd {arg} {arg?} {--flag=default} {--bool}'` into
3
+ * a structured `Signature` the `Command` base + `CliConsoleKernel` use to
4
+ * dispatch argv onto `execute({ args, flags })`.
5
+ *
6
+ * Grammar (each piece separated by whitespace):
7
+ * - `name` → command name (first token, no curlies).
8
+ * - `{slug}` → required positional named `slug`.
9
+ * - `{target?}` → optional positional named `target` (undefined when missing).
10
+ * - `{--output}` → boolean flag named `output` (default `false`).
11
+ * - `{--output=val}` → string flag named `output` (default `'val'`).
12
+ *
13
+ * Constraints (enforced by `parseSignature`):
14
+ * - First token IS the command name and never carries `{}`.
15
+ * - All required positionals come before optionals.
16
+ * - Positional + flag names are unique within a signature.
17
+ *
18
+ * Unknown shapes throw `ConfigError` at parse time — typos surface at command
19
+ * registration, not at the user's first attempt to run.
20
+ */
21
+
22
+ import { ConfigError } from '@strav/kernel'
23
+
24
+ export interface PositionalArg {
25
+ /** Identifier accessible as `args.<name>` in execute(). */
26
+ name: string
27
+ optional: boolean
28
+ }
29
+
30
+ export type FlagSpec =
31
+ | {
32
+ kind: 'boolean'
33
+ name: string
34
+ /** Always `false` — bare `--flag` flips it to `true`. */
35
+ default: false
36
+ }
37
+ | {
38
+ kind: 'string'
39
+ name: string
40
+ /** Default returned when the flag is absent. */
41
+ default: string
42
+ }
43
+
44
+ export interface Signature {
45
+ /** Command name — what users type as the first argv token. */
46
+ name: string
47
+ args: PositionalArg[]
48
+ flags: FlagSpec[]
49
+ }
50
+
51
+ /**
52
+ * Parse a signature string into a structured `Signature`. Throws `ConfigError`
53
+ * with a clear message on any malformed token.
54
+ */
55
+ export function parseSignature(signature: string): Signature {
56
+ const tokens = tokenize(signature)
57
+ if (tokens.length === 0) {
58
+ throw new ConfigError(`parseSignature: empty signature`)
59
+ }
60
+ const first = tokens[0] ?? ''
61
+ if (first.startsWith('{')) {
62
+ throw new ConfigError(
63
+ `parseSignature("${signature}"): first token must be the command name, not "${first}"`,
64
+ )
65
+ }
66
+ const name = first
67
+
68
+ const args: PositionalArg[] = []
69
+ const flags: FlagSpec[] = []
70
+ const seenArgNames = new Set<string>()
71
+ const seenFlagNames = new Set<string>()
72
+ let sawOptional = false
73
+
74
+ for (let i = 1; i < tokens.length; i++) {
75
+ const tok = tokens[i] ?? ''
76
+ if (!tok.startsWith('{') || !tok.endsWith('}')) {
77
+ throw new ConfigError(`parseSignature("${signature}"): token "${tok}" must be wrapped in {…}`)
78
+ }
79
+ const inner = tok.slice(1, -1)
80
+ if (inner.startsWith('--')) {
81
+ const flag = parseFlag(inner.slice(2), signature)
82
+ if (seenFlagNames.has(flag.name)) {
83
+ throw new ConfigError(
84
+ `parseSignature("${signature}"): flag "--${flag.name}" declared twice`,
85
+ )
86
+ }
87
+ seenFlagNames.add(flag.name)
88
+ flags.push(flag)
89
+ } else {
90
+ const arg = parsePositional(inner, signature)
91
+ if (seenArgNames.has(arg.name)) {
92
+ throw new ConfigError(
93
+ `parseSignature("${signature}"): positional "${arg.name}" declared twice`,
94
+ )
95
+ }
96
+ if (sawOptional && !arg.optional) {
97
+ throw new ConfigError(
98
+ `parseSignature("${signature}"): required positional "${arg.name}" cannot follow an optional one — argv parsing would be ambiguous`,
99
+ )
100
+ }
101
+ if (arg.optional) sawOptional = true
102
+ seenArgNames.add(arg.name)
103
+ args.push(arg)
104
+ }
105
+ }
106
+
107
+ return { name, args, flags }
108
+ }
109
+
110
+ function parsePositional(inner: string, signature: string): PositionalArg {
111
+ if (inner.endsWith('?')) {
112
+ const name = inner.slice(0, -1)
113
+ validateIdentifier(name, signature)
114
+ return { name, optional: true }
115
+ }
116
+ validateIdentifier(inner, signature)
117
+ return { name: inner, optional: false }
118
+ }
119
+
120
+ function parseFlag(inner: string, signature: string): FlagSpec {
121
+ const eq = inner.indexOf('=')
122
+ if (eq === -1) {
123
+ validateIdentifier(inner, signature)
124
+ return { kind: 'boolean', name: inner, default: false }
125
+ }
126
+ const name = inner.slice(0, eq)
127
+ const value = inner.slice(eq + 1)
128
+ validateIdentifier(name, signature)
129
+ return { kind: 'string', name, default: value }
130
+ }
131
+
132
+ function validateIdentifier(name: string, signature: string): void {
133
+ if (name.length === 0) {
134
+ throw new ConfigError(`parseSignature("${signature}"): empty identifier`)
135
+ }
136
+ // Allow letters, digits, dash, underscore. CLI flag/arg names mirror what
137
+ // users would type — kebab-case is fine.
138
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) {
139
+ throw new ConfigError(
140
+ `parseSignature("${signature}"): "${name}" is not a valid identifier (letters, digits, dash, underscore; must start with a letter)`,
141
+ )
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Tokenize on whitespace, but keep braced groups intact even if they contain
147
+ * spaces (`{--header=Authorization: Bearer}` works). Throws on unterminated
148
+ * braces.
149
+ */
150
+ function tokenize(signature: string): string[] {
151
+ const tokens: string[] = []
152
+ let i = 0
153
+ while (i < signature.length) {
154
+ while (i < signature.length && /\s/.test(signature[i] ?? '')) i++
155
+ if (i >= signature.length) break
156
+ if (signature[i] === '{') {
157
+ const end = signature.indexOf('}', i)
158
+ if (end === -1) {
159
+ throw new ConfigError(`parseSignature("${signature}"): unterminated "{"`)
160
+ }
161
+ tokens.push(signature.slice(i, end + 1))
162
+ i = end + 1
163
+ } else {
164
+ let j = i
165
+ while (j < signature.length && !/\s/.test(signature[j] ?? '')) j++
166
+ tokens.push(signature.slice(i, j))
167
+ i = j
168
+ }
169
+ }
170
+ return tokens
171
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Subset boot — given the default provider list and a list of provider names
3
+ * requested by a command's `static providers`, return the filtered list with
4
+ * transitive `dependencies` auto-included.
5
+ *
6
+ * Spec:
7
+ * - `undefined` → return the full default list (no filtering).
8
+ * - `[]` → return an empty array.
9
+ * - `[names...]` → look each name up in the default list; pull in every
10
+ * transitive `dependencies = [...]` entry; topo-order preserved by the
11
+ * application's own sort.
12
+ * - Unknown name → `ConfigError` with a clear message.
13
+ */
14
+
15
+ import { ConfigError, type ServiceProvider } from '@strav/kernel'
16
+
17
+ export function selectProviders(
18
+ defaults: readonly ServiceProvider[],
19
+ requested: readonly string[] | undefined,
20
+ commandName: string,
21
+ ): ServiceProvider[] {
22
+ if (requested === undefined) return [...defaults]
23
+ if (requested.length === 0) return []
24
+
25
+ const byName = new Map<string, ServiceProvider>()
26
+ for (const p of defaults) byName.set(p.name, p)
27
+
28
+ const selected = new Map<string, ServiceProvider>()
29
+
30
+ const visit = (name: string, trail: readonly string[]): void => {
31
+ if (selected.has(name)) return
32
+ if (trail.includes(name)) {
33
+ throw new ConfigError(
34
+ `ConsoleProvider: circular provider dependency while resolving '${commandName}': ${[...trail, name].join(' → ')}`,
35
+ )
36
+ }
37
+ const provider = byName.get(name)
38
+ if (!provider) {
39
+ throw new ConfigError(
40
+ `Command '${commandName}' declared provider '${name}' which is not in the default providers list`,
41
+ )
42
+ }
43
+ for (const dep of provider.dependencies) {
44
+ visit(dep, [...trail, name])
45
+ }
46
+ selected.set(name, provider)
47
+ }
48
+
49
+ for (const name of requested) visit(name, [])
50
+ return [...selected.values()]
51
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `UtilConsoleProvider` — utility commands that don't belong to a specific
3
+ * package (key generation, config introspection).
4
+ *
5
+ * `KeyGenerate` sets `static providers = []` (writes to disk, no app services).
6
+ * `ConfigShow` / `ConfigList` set `static providers = ['config']` to read
7
+ * from the booted `ConfigRepository`.
8
+ */
9
+
10
+ import { ConfigList } from './config_list.ts'
11
+ import { ConfigShow } from './config_show.ts'
12
+ import { ConsoleProvider } from './console_provider.ts'
13
+ import { KeyGenerate } from './key_generate.ts'
14
+
15
+ export class UtilConsoleProvider extends ConsoleProvider {
16
+ override readonly name = 'console.util'
17
+ override readonly commands = [KeyGenerate, ConfigShow, ConfigList] as const
18
+ }
@@ -1,82 +0,0 @@
1
- import Configuration from '@strav/kernel/config/configuration'
2
- import Database from '@strav/database/database/database'
3
- import SchemaRegistry from '@strav/database/schema/registry'
4
- import DatabaseIntrospector from '@strav/database/database/introspector'
5
- import Application, { app } from '@strav/kernel/core/application'
6
- import type ServiceProvider from '@strav/kernel/core/service_provider'
7
- import { getDatabasePaths } from '../config/loader.ts'
8
-
9
- export interface BootstrapResult {
10
- config: Configuration
11
- db: Database
12
- registry: SchemaRegistry
13
- introspector: DatabaseIntrospector
14
- }
15
-
16
- /**
17
- * Bootstrap the core framework services needed by CLI commands.
18
- *
19
- * Loads configuration, connects to the database, discovers and validates
20
- * schemas, and creates an introspector instance.
21
- */
22
- export async function bootstrap(): Promise<BootstrapResult> {
23
- const config = new Configuration('./config')
24
- await config.load()
25
-
26
- const db = new Database(config)
27
- await db.init()
28
-
29
- const registry = new SchemaRegistry()
30
- const dbPaths = await getDatabasePaths()
31
- await registry.discover(dbPaths.schemas)
32
- registry.validate()
33
-
34
- const introspector = new DatabaseIntrospector(db)
35
-
36
- return { config, db, registry, introspector }
37
- }
38
-
39
- /** Cleanly close the database connection. */
40
- export async function shutdown(db: Database): Promise<void> {
41
- await db.close()
42
- }
43
-
44
- export interface WithProvidersOptions {
45
- /**
46
- * Install SIGINT/SIGTERM handlers for graceful shutdown. Default: `true`.
47
- * Set to `false` when the caller owns signal handling and must control
48
- * shutdown ordering — e.g. `queue:work`, which lets the worker drain its
49
- * in-flight job before `app.shutdown()` tears down providers.
50
- */
51
- signalHandlers?: boolean
52
- }
53
-
54
- /**
55
- * Bootstrap the global Application with the given service providers.
56
- *
57
- * Registers all providers on the global `app` singleton from `@strav/kernel`,
58
- * boots them in dependency order, and returns it. Signal handlers for
59
- * graceful shutdown are installed automatically unless `options.signalHandlers`
60
- * is `false`.
61
- *
62
- * The global singleton is intentional: handler code (queue handlers, scheduled
63
- * tasks, route handlers, etc.) commonly imports `app` from `@strav/kernel` to
64
- * resolve services. A separate Application instance here would leave that
65
- * singleton's container empty and cause `app.resolve(X)` to throw at runtime —
66
- * a silent failure mode for queue workers in particular.
67
- *
68
- * @example
69
- * const app = await withProviders([
70
- * new ConfigProvider(),
71
- * new DatabaseProvider(),
72
- * new AuthProvider({ resolver: (id) => User.find(id) }),
73
- * ])
74
- */
75
- export async function withProviders(
76
- providers: ServiceProvider[],
77
- options?: WithProvidersOptions,
78
- ): Promise<Application> {
79
- for (const provider of providers) app.use(provider)
80
- await app.start(options)
81
- return app
82
- }
@@ -1,180 +0,0 @@
1
- import { readdirSync, existsSync, realpathSync } from 'node:fs'
2
- import { join } from 'node:path'
3
- import type { Command } from 'commander'
4
- import chalk from 'chalk'
5
-
6
- /**
7
- * Discovers and registers CLI commands from two sources:
8
- *
9
- * 1. **Package commands** — installed `@strav/*` packages that declare
10
- * `"strav": { "commands": "src/commands" }` in their `package.json`.
11
- * 2. **User commands** — `.ts` files in a `./commands/` directory at the
12
- * project root.
13
- *
14
- * Every discovered file must export a `register(program: Command): void`
15
- * function.
16
- *
17
- * @example
18
- * // In strav.ts:
19
- * await CommandLoader.discover(program)
20
- *
21
- * @example
22
- * // In a package (e.g. @strav/search):
23
- * // package.json: { "strav": { "commands": "src/commands" } }
24
- * // src/commands/search_import.ts:
25
- * export function register(program: Command): void {
26
- * program.command('search:import <model>').action(async () => { ... })
27
- * }
28
- *
29
- * @example
30
- * // User-defined command:
31
- * // commands/deploy.ts:
32
- * export function register(program: Command): void {
33
- * program.command('deploy').action(async () => { ... })
34
- * }
35
- */
36
- export default class CommandLoader {
37
- /**
38
- * Discover and register commands from packages and the user's commands directory.
39
- *
40
- * @param baseDir - Project root directory. Defaults to `process.cwd()`.
41
- */
42
- static async discover(program: Command, baseDir?: string): Promise<void> {
43
- const root = baseDir ?? process.cwd()
44
- await this.loadPackageCommands(program, root)
45
- await this.loadUserCommands(program, root)
46
- }
47
-
48
- // ── Package commands ───────────────────────────────────────────────────
49
-
50
- private static async loadPackageCommands(program: Command, root: string): Promise<void> {
51
- const packages = await this.resolvePackages(root)
52
-
53
- for (const { root: pkgRoot, commandsDir } of packages) {
54
- try {
55
- const dir = join(pkgRoot, commandsDir)
56
- if (!dirExists(dir)) continue
57
-
58
- const files = readdirSync(dir).filter(f => f.endsWith('.ts'))
59
- for (const file of files) {
60
- const filePath = join(dir, file)
61
- try {
62
- const module = await import(filePath)
63
- if (typeof module.register === 'function') {
64
- module.register(program)
65
- }
66
- } catch (err) {
67
- console.error(
68
- chalk.yellow(
69
- `Warning: Failed to load command "${file}": ${err instanceof Error ? err.message : err}`
70
- )
71
- )
72
- }
73
- }
74
- } catch {
75
- // Skip packages whose commands directory can't be read
76
- }
77
- }
78
- }
79
-
80
- // ── User commands ──────────────────────────────────────────────────────
81
-
82
- private static async loadUserCommands(program: Command, root: string): Promise<void> {
83
- const userDir = join(root, 'commands')
84
- if (!dirExists(userDir)) return
85
-
86
- const files = readdirSync(userDir).filter(f => f.endsWith('.ts'))
87
-
88
- for (const file of files) {
89
- const filePath = join(userDir, file)
90
- try {
91
- const module = await import(filePath)
92
- if (typeof module.register === 'function') {
93
- module.register(program)
94
- }
95
- } catch (err) {
96
- console.error(
97
- chalk.yellow(
98
- `Warning: Failed to load command "${file}": ${err instanceof Error ? err.message : err}`
99
- )
100
- )
101
- }
102
- }
103
- }
104
-
105
- // ── Package resolution ─────────────────────────────────────────────────
106
-
107
- private static async resolvePackages(
108
- root: string
109
- ): Promise<Array<{ root: string; commandsDir: string }>> {
110
- const results: Array<{ root: string; commandsDir: string }> = []
111
- const seen = new Set<string>()
112
-
113
- // 1. Check node_modules/@strav/*
114
- const nodeModulesDir = join(root, 'node_modules', '@strav')
115
- if (dirExists(nodeModulesDir)) {
116
- const dirs = readdirSync(nodeModulesDir)
117
- for (const dir of dirs) {
118
- const pkgRoot = join(nodeModulesDir, dir)
119
- const realRoot = realPath(pkgRoot)
120
- if (seen.has(realRoot)) continue
121
- seen.add(realRoot)
122
- const commandsDir = await readStravCommands(pkgRoot)
123
- if (commandsDir) results.push({ root: pkgRoot, commandsDir })
124
- }
125
- }
126
-
127
- // 2. Check Bun workspace packages
128
- try {
129
- const rootPkgPath = join(root, 'package.json')
130
- if (existsSync(rootPkgPath)) {
131
- const rootPkg = await Bun.file(rootPkgPath).json()
132
- const workspaces: string[] = rootPkg.workspaces ?? []
133
- for (const ws of workspaces) {
134
- const wsPath = join(root, ws)
135
- const realRoot = realPath(wsPath)
136
- if (seen.has(realRoot)) continue
137
- seen.add(realRoot)
138
- const commandsDir = await readStravCommands(wsPath)
139
- if (commandsDir) results.push({ root: wsPath, commandsDir })
140
- }
141
- }
142
- } catch {
143
- // No package.json or not in a workspace
144
- }
145
-
146
- return results
147
- }
148
- }
149
-
150
- // ── Helpers ────────────────────────────────────────────────────────────────
151
-
152
- /** Resolve symlinks to avoid double-loading workspace packages. */
153
- function realPath(p: string): string {
154
- try {
155
- return realpathSync(p)
156
- } catch {
157
- return p
158
- }
159
- }
160
-
161
- function dirExists(path: string): boolean {
162
- try {
163
- readdirSync(path)
164
- return true
165
- } catch {
166
- return false
167
- }
168
- }
169
-
170
- /** Read the `strav.commands` field from a package's `package.json`. */
171
- async function readStravCommands(packageRoot: string): Promise<string | null> {
172
- try {
173
- const pkgPath = join(packageRoot, 'package.json')
174
- if (!existsSync(pkgPath)) return null
175
- const pkg = await Bun.file(pkgPath).json()
176
- return pkg?.strav?.commands ?? null
177
- } catch {
178
- return null
179
- }
180
- }
package/src/cli/index.ts DELETED
@@ -1,3 +0,0 @@
1
- export { bootstrap, shutdown, withProviders } from './bootstrap'
2
- export type { BootstrapResult, WithProvidersOptions } from './bootstrap'
3
- export { default as CommandLoader } from './command_loader'
package/src/cli/strav.ts DELETED
@@ -1,13 +0,0 @@
1
- import 'reflect-metadata'
2
- import { Command } from 'commander'
3
- import { join } from 'node:path'
4
- import CommandLoader from './command_loader.ts'
5
-
6
- const pkg = await Bun.file(join(import.meta.dir, '../../package.json')).json()
7
- const program = new Command()
8
-
9
- program.name('strav').description('Strav CLI').version(pkg.version)
10
-
11
- await CommandLoader.discover(program)
12
-
13
- program.parse()