@strav/cli 0.4.31 → 1.0.0-alpha.5

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
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `bun strav key:generate` — generate APP_KEY and write it to `.env`.
3
+ *
4
+ * Generates 32 cryptographically-random bytes and hex-encodes them (64
5
+ * chars). This format matches `parseEncryptionKey()` in `@strav/kernel/encryption`
6
+ * so the value can be used directly as `config.encryption.key`.
7
+ *
8
+ * Behaviour:
9
+ * - If `.env` doesn't exist, creates it with `APP_KEY=<key>`.
10
+ * - If `.env` exists and already has `APP_KEY=`, updates the line.
11
+ * - If `.env` exists without `APP_KEY=`, appends the line.
12
+ * - `--show` prints the key to stdout instead of writing to disk
13
+ * (useful when your secret manager reads from stdout).
14
+ * - `--force` regenerates and overwrites even when APP_KEY is already set.
15
+ */
16
+
17
+ import { existsSync } from 'node:fs'
18
+ import { readFile, writeFile } from 'node:fs/promises'
19
+ import { join } from 'node:path'
20
+ import { Command, type ExecuteArgs } from './command.ts'
21
+ import { ExitCode } from './exit_codes.ts'
22
+
23
+ export class KeyGenerate extends Command {
24
+ static signature = 'key:generate {--show} {--force}'
25
+ static description = 'Generate APP_KEY and write it to .env.'
26
+ static providers: string[] = []
27
+
28
+ override async execute({ flags }: ExecuteArgs): Promise<number> {
29
+ const envPath = join(process.cwd(), '.env')
30
+ const show = flags.show === true
31
+ const force = flags.force === true
32
+
33
+ // Read existing .env content (may not exist)
34
+ let existing = existsSync(envPath) ? await readFile(envPath, 'utf8') : ''
35
+
36
+ // Guard: don't overwrite unless --force
37
+ if (!force && existing.match(/^APP_KEY=.+/m)) {
38
+ this.warn('APP_KEY already set in .env. Use --force to overwrite.')
39
+ return ExitCode.Success
40
+ }
41
+
42
+ // Generate 32 random bytes → 64-char hex string
43
+ const bytes = new Uint8Array(32)
44
+ crypto.getRandomValues(bytes)
45
+ const key = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
46
+
47
+ if (show) {
48
+ this.line(`APP_KEY=${key}`)
49
+ return ExitCode.Success
50
+ }
51
+
52
+ if (existing.match(/^APP_KEY=/m)) {
53
+ // Replace the existing APP_KEY line
54
+ existing = existing.replace(/^APP_KEY=.*$/m, `APP_KEY=${key}`)
55
+ } else {
56
+ // Append (with a trailing newline)
57
+ existing = existing.trimEnd()
58
+ existing = existing ? `${existing}\nAPP_KEY=${key}\n` : `APP_KEY=${key}\n`
59
+ }
60
+
61
+ await writeFile(envPath, existing, 'utf8')
62
+ this.success(`APP_KEY written to ${envPath}`)
63
+ this.info('Add it to your shell with: source .env (or use dotenv)')
64
+ return ExitCode.Success
65
+ }
66
+ }
@@ -0,0 +1,17 @@
1
+ // make:* scaffold commands — one class per target file type.
2
+
3
+ export { MakeCommandFile } from './make_command_file.ts'
4
+ export { MakeController } from './make_controller.ts'
5
+ export { MakeFactory } from './make_factory.ts'
6
+ export { MakeJob } from './make_job.ts'
7
+ export { MakeMail } from './make_mail.ts'
8
+ export { MakeMiddleware } from './make_middleware.ts'
9
+ export { MakeMigration } from './make_migration.ts'
10
+ export { MakeModel } from './make_model.ts'
11
+ export { MakeNotification } from './make_notification.ts'
12
+ export { MakePolicy } from './make_policy.ts'
13
+ export { MakeProvider } from './make_provider.ts'
14
+ export { MakeRepository } from './make_repository.ts'
15
+ export { MakeRequest } from './make_request.ts'
16
+ export { MakeSeeder } from './make_seeder.ts'
17
+ export { MakeTest } from './make_test.ts'
@@ -0,0 +1,27 @@
1
+ import { MakeCommand, pascal, snake } from '../make_command.ts'
2
+
3
+ export class MakeCommandFile extends MakeCommand {
4
+ static signature = 'make:command {name}'
5
+ static description = 'Create a console Command stub.'
6
+ static providers: string[] = []
7
+
8
+ protected filePath(name: string): string {
9
+ return `app/console/commands/${snake(name)}.ts`
10
+ }
11
+
12
+ protected stub(name: string): string {
13
+ const cls = pascal(name)
14
+ return `import { Command, type ExecuteArgs, ExitCode } from '@strav/cli'
15
+
16
+ export class ${cls} extends Command {
17
+ static signature = '${snake(name).replace(/_/g, ':')}'
18
+ static description = 'Describe what this command does.'
19
+
20
+ override async execute({ args, flags }: ExecuteArgs): Promise<number> {
21
+ // implement the command here
22
+ return ExitCode.Success
23
+ }
24
+ }
25
+ `
26
+ }
27
+ }
@@ -0,0 +1,24 @@
1
+ import { MakeCommand, pascal, snake } from '../make_command.ts'
2
+
3
+ export class MakeController extends MakeCommand {
4
+ static signature = 'make:controller {name}'
5
+ static description = 'Create a new HTTP controller stub.'
6
+ static providers: string[] = []
7
+
8
+ protected filePath(name: string): string {
9
+ const fileName = name.toLowerCase().endsWith('controller') ? snake(name) : `${snake(name)}_controller`
10
+ return `app/http/controllers/${fileName}.ts`
11
+ }
12
+
13
+ protected stub(name: string): string {
14
+ const cls = pascal(name).endsWith('Controller') ? pascal(name) : `${pascal(name)}Controller`
15
+ return `import type { HttpContext } from '@strav/http'
16
+
17
+ export class ${cls} {
18
+ async handle(ctx: HttpContext): Promise<Response> {
19
+ return ctx.response.ok('Hello from ${cls}')
20
+ }
21
+ }
22
+ `
23
+ }
24
+ }
@@ -0,0 +1,25 @@
1
+ import { camel, MakeCommand, pascal, snake } from '../make_command.ts'
2
+
3
+ export class MakeFactory extends MakeCommand {
4
+ static signature = 'make:factory {name}'
5
+ static description = 'Create a factory stub for a model.'
6
+ static providers: string[] = []
7
+
8
+ protected filePath(name: string): string {
9
+ const base = snake(name).replace(/_factory$/, '')
10
+ return `database/factories/${base}_factory.ts`
11
+ }
12
+
13
+ protected stub(name: string): string {
14
+ const base = pascal(name).replace(/Factory$/, '')
15
+ return `import type { ${base} } from '../../app/models/${snake(base)}.ts'
16
+
17
+ export function ${camel(base)}Factory(overrides: Partial<${base}> = {}): ${base} {
18
+ return {
19
+ // define default attribute values here
20
+ ...overrides,
21
+ } as ${base}
22
+ }
23
+ `
24
+ }
25
+ }
@@ -0,0 +1,25 @@
1
+ import { MakeCommand, pascal, snake } from '../make_command.ts'
2
+
3
+ export class MakeJob extends MakeCommand {
4
+ static signature = 'make:job {name}'
5
+ static description = 'Create a queue job stub.'
6
+ static providers: string[] = []
7
+
8
+ protected filePath(name: string): string {
9
+ return `app/jobs/${snake(name)}.ts`
10
+ }
11
+
12
+ protected stub(name: string): string {
13
+ const cls = pascal(name)
14
+ return `import { Job, type JobContext } from '@strav/queue'
15
+
16
+ export class ${cls} extends Job<unknown> {
17
+ static override readonly jobName = '${snake(name)}'
18
+
19
+ async handle(ctx: JobContext<unknown>): Promise<void> {
20
+ // handle the job
21
+ }
22
+ }
23
+ `
24
+ }
25
+ }
@@ -0,0 +1,27 @@
1
+ import { MakeCommand, pascal, snake } from '../make_command.ts'
2
+
3
+ export class MakeMail extends MakeCommand {
4
+ static signature = 'make:mail {name}'
5
+ static description = 'Create a Mailable stub.'
6
+ static providers: string[] = []
7
+
8
+ protected filePath(name: string): string {
9
+ return `app/mail/${snake(name)}.ts`
10
+ }
11
+
12
+ protected stub(name: string): string {
13
+ const cls = pascal(name)
14
+ return `import { Mailable, type Message } from '@strav/signal'
15
+
16
+ export class ${cls} extends Mailable<unknown> {
17
+ build(payload: unknown): Message {
18
+ return {
19
+ to: [{ address: 'recipient@example.com' }],
20
+ subject: '${pascal(name)}',
21
+ html: '<p>Hello</p>',
22
+ }
23
+ }
24
+ }
25
+ `
26
+ }
27
+ }
@@ -0,0 +1,23 @@
1
+ import { MakeCommand, pascal, snake } from '../make_command.ts'
2
+
3
+ export class MakeMiddleware extends MakeCommand {
4
+ static signature = 'make:middleware {name}'
5
+ static description = 'Create a new HTTP middleware stub.'
6
+ static providers: string[] = []
7
+
8
+ protected filePath(name: string): string {
9
+ return `app/http/middleware/${snake(name)}.ts`
10
+ }
11
+
12
+ protected stub(name: string): string {
13
+ const cls = pascal(name)
14
+ return `import type { HttpContext, MiddlewareNext } from '@strav/http'
15
+
16
+ export class ${cls} {
17
+ async handle(ctx: HttpContext, next: MiddlewareNext): Promise<Response> {
18
+ return next(ctx)
19
+ }
20
+ }
21
+ `
22
+ }
23
+ }
@@ -0,0 +1,48 @@
1
+ import { UsageError } from '../binder.ts'
2
+ import type { ExecuteArgs } from '../command.ts'
3
+ import { MakeCommand } from '../make_command.ts'
4
+
5
+ export class MakeMigration extends MakeCommand {
6
+ static signature = 'make:migration {--message=}'
7
+ static description = 'Create a migration file stub.'
8
+ static providers: string[] = []
9
+
10
+ protected filePath(name: string): string {
11
+ return `database/migrations/${name}.ts`
12
+ }
13
+
14
+ protected stub(name: string): string {
15
+ return `import type { Migration } from '@strav/database'
16
+
17
+ export const migration: Migration = {
18
+ name: '${name}',
19
+ async up(db) {
20
+ // write your migration here
21
+ },
22
+ async down(db) {
23
+ // write your rollback here
24
+ },
25
+ }
26
+ `
27
+ }
28
+
29
+ override async execute({ flags }: ExecuteArgs): Promise<number> {
30
+ const msg = (flags.message as string | undefined)?.trim()
31
+ if (!msg) {
32
+ throw new UsageError(
33
+ '--message (or -m) is required: bun strav make:migration -m "create users"',
34
+ )
35
+ }
36
+ const slug = msg
37
+ .toLowerCase()
38
+ .replace(/[^a-z0-9]+/g, '_')
39
+ .replace(/^_+|_+$/g, '')
40
+ if (!slug)
41
+ throw new UsageError(`--message "${msg}" produced an empty slug — use letters / digits`)
42
+ const now = new Date()
43
+ const pad = (n: number) => String(n).padStart(2, '0')
44
+ const ts = `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}`
45
+ const name = `${ts}_${slug}`
46
+ return super.execute({ args: { name }, flags })
47
+ }
48
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `make:model <Name>` — the model_generator.
3
+ *
4
+ * Writes three files (each skipped if it already exists):
5
+ * - `app/models/<name>.ts` — Model class
6
+ * - `app/repositories/<name>_repository.ts` — Repository<Model>
7
+ * - `database/factories/<name>_factory.ts` — Factory stub
8
+ */
9
+
10
+ import { existsSync } from 'node:fs'
11
+ import { mkdir, writeFile } from 'node:fs/promises'
12
+ import { dirname, join } from 'node:path'
13
+ import { Command, type ExecuteArgs } from '../command.ts'
14
+ import { ExitCode } from '../exit_codes.ts'
15
+ import { camel, pascal, snake } from '../make_command.ts'
16
+
17
+ export class MakeModel extends Command {
18
+ static signature = 'make:model {name}'
19
+ static description = 'Create Model + Repository + Factory stubs (model_generator).'
20
+ static providers: string[] = []
21
+
22
+ override async execute({ args }: ExecuteArgs): Promise<number> {
23
+ const raw = (args.name ?? '').trim()
24
+ if (!raw) {
25
+ this.error('A model name is required.')
26
+ return ExitCode.UsageError
27
+ }
28
+
29
+ const name = pascal(raw)
30
+ const files: Array<{ path: string; content: string }> = [
31
+ { path: `app/models/${snake(raw)}.ts`, content: modelStub(name) },
32
+ {
33
+ path: `app/repositories/${snake(raw)}_repository.ts`,
34
+ content: repositoryStub(name),
35
+ },
36
+ {
37
+ path: `database/factories/${snake(raw)}_factory.ts`,
38
+ content: factoryStub(name),
39
+ },
40
+ ]
41
+
42
+ for (const { path, content } of files) {
43
+ const dest = join(process.cwd(), path)
44
+ if (existsSync(dest)) {
45
+ this.warn(`${dest} already exists — skipping.`)
46
+ continue
47
+ }
48
+ await mkdir(dirname(dest), { recursive: true })
49
+ await writeFile(dest, content, 'utf8')
50
+ this.success(`Created ${dest}`)
51
+ }
52
+ return ExitCode.Success
53
+ }
54
+ }
55
+
56
+ function modelStub(name: string): string {
57
+ return `import { Model } from '@strav/database'
58
+
59
+ export class ${name} extends Model {
60
+ declare id: string
61
+ // add your properties here
62
+ }
63
+ `
64
+ }
65
+
66
+ function repositoryStub(name: string): string {
67
+ return `import { inject } from '@strav/kernel'
68
+ import { PostgresDatabase, Repository } from '@strav/database'
69
+ import { ${name} } from '../models/${snake(name)}.ts'
70
+ import { ${camel(name)}Schema } from '../../database/schemas/${snake(name)}_schema.ts'
71
+
72
+ @inject()
73
+ export class ${name}Repository extends Repository<${name}> {
74
+ constructor(db: PostgresDatabase) {
75
+ super(db, ${camel(name)}Schema)
76
+ }
77
+ }
78
+ `
79
+ }
80
+
81
+ function factoryStub(name: string): string {
82
+ return `import type { ${name} } from '../../app/models/${snake(name)}.ts'
83
+
84
+ export function ${camel(name)}Factory(overrides: Partial<${name}> = {}): ${name} {
85
+ return {
86
+ // define default attribute values here
87
+ ...overrides,
88
+ } as ${name}
89
+ }
90
+ `
91
+ }
@@ -0,0 +1,23 @@
1
+ import { MakeCommand, pascal, snake } from '../make_command.ts'
2
+
3
+ export class MakeNotification extends MakeCommand {
4
+ static signature = 'make:notification {name}'
5
+ static description = 'Create a Notification stub.'
6
+ static providers: string[] = []
7
+
8
+ protected filePath(name: string): string {
9
+ return `app/notifications/${snake(name)}.ts`
10
+ }
11
+
12
+ protected stub(name: string): string {
13
+ const cls = pascal(name)
14
+ return `// ${cls} notification
15
+ // Implement channels (mail, database, broadcast) once @strav/signal notifications land.
16
+ export class ${cls} {
17
+ via(): string[] {
18
+ return ['mail']
19
+ }
20
+ }
21
+ `
22
+ }
23
+ }
@@ -0,0 +1,24 @@
1
+ import { MakeCommand, pascal, snake } from '../make_command.ts'
2
+
3
+ export class MakePolicy extends MakeCommand {
4
+ static signature = 'make:policy {name}'
5
+ static description = 'Create an authorization policy stub.'
6
+ static providers: string[] = []
7
+
8
+ protected filePath(name: string): string {
9
+ const base = snake(name).replace(/_policy$/, '')
10
+ return `app/policies/${base}_policy.ts`
11
+ }
12
+
13
+ protected stub(name: string): string {
14
+ const base = pascal(name).replace(/Policy$/, '')
15
+ const cls = `${base}Policy`
16
+ return `export class ${cls} {
17
+ // async view(user: User, model: ${base}): Promise<boolean> { return true }
18
+ // async create(user: User): Promise<boolean> { return true }
19
+ // async update(user: User, model: ${base}): Promise<boolean> { return true }
20
+ // async delete(user: User, model: ${base}): Promise<boolean> { return true }
21
+ }
22
+ `
23
+ }
24
+ }
@@ -0,0 +1,29 @@
1
+ import { MakeCommand, pascal, snake } from '../make_command.ts'
2
+
3
+ export class MakeProvider extends MakeCommand {
4
+ static signature = 'make:provider {name}'
5
+ static description = 'Create a ServiceProvider stub.'
6
+ static providers: string[] = []
7
+
8
+ protected filePath(name: string): string {
9
+ return `app/providers/${snake(name)}.ts`
10
+ }
11
+
12
+ protected stub(name: string): string {
13
+ const cls = pascal(name).endsWith('Provider') ? pascal(name) : `${pascal(name)}Provider`
14
+ return `import { type Application, ServiceProvider } from '@strav/kernel'
15
+
16
+ export class ${cls} extends ServiceProvider {
17
+ override readonly name = '${snake(cls).replace(/_provider$/, '')}'
18
+
19
+ override register(app: Application): void {
20
+ // bind services into the container
21
+ }
22
+
23
+ override async boot(app: Application): Promise<void> {
24
+ // run initialization after all providers are registered
25
+ }
26
+ }
27
+ `
28
+ }
29
+ }
@@ -0,0 +1,30 @@
1
+ import { MakeCommand, pascal, snake } from '../make_command.ts'
2
+
3
+ export class MakeRepository extends MakeCommand {
4
+ static signature = 'make:repository {name}'
5
+ static description = 'Create a Repository stub.'
6
+ static providers: string[] = []
7
+
8
+ protected filePath(name: string): string {
9
+ const base = snake(name).replace(/_repository$/, '')
10
+ return `app/repositories/${base}_repository.ts`
11
+ }
12
+
13
+ protected stub(name: string): string {
14
+ const base = pascal(name).replace(/Repository$/, '')
15
+ const cls = `${base}Repository`
16
+ const model = base
17
+ return `import { inject } from '@strav/kernel'
18
+ import { type Database, PostgresDatabase, Repository } from '@strav/database'
19
+ import { ${model} } from '../models/${snake(model)}.ts'
20
+ import { ${snake(model)}Schema } from '../../database/schemas/${snake(model)}_schema.ts'
21
+
22
+ @inject()
23
+ export class ${cls} extends Repository<${model}> {
24
+ constructor(db: PostgresDatabase) {
25
+ super(db, ${snake(model)}Schema)
26
+ }
27
+ }
28
+ `
29
+ }
30
+ }
@@ -0,0 +1,24 @@
1
+ import { MakeCommand, pascal, snake } from '../make_command.ts'
2
+
3
+ export class MakeRequest extends MakeCommand {
4
+ static signature = 'make:request {name}'
5
+ static description = 'Create a new FormRequest stub.'
6
+ static providers: string[] = []
7
+
8
+ protected filePath(name: string): string {
9
+ return `app/http/requests/${snake(name)}.ts`
10
+ }
11
+
12
+ protected stub(name: string): string {
13
+ const cls = pascal(name).endsWith('Request') ? pascal(name) : `${pascal(name)}Request`
14
+ return `import { FormRequest } from '@strav/http'
15
+ import { z } from 'zod'
16
+
17
+ export class ${cls} extends FormRequest {
18
+ schema = z.object({
19
+ // define your fields here
20
+ })
21
+ }
22
+ `
23
+ }
24
+ }
@@ -0,0 +1,23 @@
1
+ import { MakeCommand, pascal, snake } from '../make_command.ts'
2
+
3
+ export class MakeSeeder extends MakeCommand {
4
+ static signature = 'make:seeder {name}'
5
+ static description = 'Create a database seeder stub.'
6
+ static providers: string[] = []
7
+
8
+ protected filePath(name: string): string {
9
+ return `database/seeders/${snake(name)}.ts`
10
+ }
11
+
12
+ protected stub(name: string): string {
13
+ const cls = pascal(name).endsWith('Seeder') ? pascal(name) : `${pascal(name)}Seeder`
14
+ return `import type { Database } from '@strav/database'
15
+
16
+ export class ${cls} {
17
+ async run(db: Database): Promise<void> {
18
+ // seed your data here
19
+ }
20
+ }
21
+ `
22
+ }
23
+ }
@@ -0,0 +1,22 @@
1
+ import { MakeCommand, snake } from '../make_command.ts'
2
+
3
+ export class MakeTest extends MakeCommand {
4
+ static signature = 'make:test {name}'
5
+ static description = 'Create a feature test stub.'
6
+ static providers: string[] = []
7
+
8
+ protected filePath(name: string): string {
9
+ return `tests/feature/${snake(name)}.test.ts`
10
+ }
11
+
12
+ protected stub(name: string): string {
13
+ return `import { describe, expect, test } from 'bun:test'
14
+
15
+ describe('${snake(name).replace(/_/g, ' ')}', () => {
16
+ test('placeholder', () => {
17
+ expect(true).toBe(true)
18
+ })
19
+ })
20
+ `
21
+ }
22
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `MakeCommand` — base for every `make:*` scaffold command.
3
+ *
4
+ * Subclasses implement `filePath(name)` and `stub(name)` and get the
5
+ * filesystem write, exists-check, and directory creation for free.
6
+ *
7
+ * Naming helpers (`pascal`, `snake`, `camel`) convert the user's input
8
+ * into the conventional forms each stub needs.
9
+ *
10
+ * Re-running against an existing file is a no-op with a warning —
11
+ * there's no overwrite mode. Stubs can't safely re-derive what the user
12
+ * has already edited.
13
+ */
14
+
15
+ import { existsSync } from 'node:fs'
16
+ import { mkdir, writeFile } from 'node:fs/promises'
17
+ import { dirname, join } from 'node:path'
18
+ import { Command, type ExecuteArgs } from './command.ts'
19
+ import { ExitCode } from './exit_codes.ts'
20
+
21
+ export abstract class MakeCommand extends Command {
22
+ /** Destination path relative to `process.cwd()`. */
23
+ protected abstract filePath(name: string): string
24
+ /** File content to write. */
25
+ protected abstract stub(name: string): string
26
+
27
+ override async execute({ args }: ExecuteArgs): Promise<number> {
28
+ const raw = (args.name ?? '').trim()
29
+ if (!raw) {
30
+ this.error('A name is required.')
31
+ return ExitCode.UsageError
32
+ }
33
+
34
+ const dest = join(process.cwd(), this.filePath(raw))
35
+ if (existsSync(dest)) {
36
+ this.warn(`${dest} already exists — skipping. Delete it first to regenerate.`)
37
+ return ExitCode.Success
38
+ }
39
+
40
+ await mkdir(dirname(dest), { recursive: true })
41
+ await writeFile(dest, this.stub(raw), 'utf8')
42
+ this.success(`Created ${dest}`)
43
+ return ExitCode.Success
44
+ }
45
+ }
46
+
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+ // Naming helpers
49
+ // ─────────────────────────────────────────────────────────────────────────────
50
+
51
+ /** MyFoo / my_foo / my-foo → MyFoo */
52
+ export function pascal(name: string): string {
53
+ return name
54
+ .replace(/[-_](.)/g, (_, c: string) => c.toUpperCase())
55
+ .replace(/^(.)/, (_, c: string) => c.toUpperCase())
56
+ }
57
+
58
+ /** MyFoo / myFoo → my_foo */
59
+ export function snake(name: string): string {
60
+ return pascal(name)
61
+ .replace(/([A-Z])/g, (c) => `_${c.toLowerCase()}`)
62
+ .replace(/^_/, '')
63
+ }
64
+
65
+ /** MyFoo → myFoo */
66
+ export function camel(name: string): string {
67
+ const p = pascal(name)
68
+ return p.charAt(0).toLowerCase() + p.slice(1)
69
+ }