bunigniter 0.2.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +229 -0
  3. package/dist/LICENSE +21 -0
  4. package/dist/README.md +229 -0
  5. package/dist/base/controller.ts +324 -0
  6. package/dist/base/index.ts +5 -0
  7. package/dist/base/service.ts +21 -0
  8. package/dist/cli/index.ts +318 -0
  9. package/dist/cli/list-routes.ts +72 -0
  10. package/dist/cli/repl.ts +461 -0
  11. package/dist/cli/templates.ts +283 -0
  12. package/dist/client/index.ts +159 -0
  13. package/dist/db/drizzle.ts +550 -0
  14. package/dist/db/validators.ts +229 -0
  15. package/dist/edge-builder.ts +120 -0
  16. package/dist/edge.ts +69 -0
  17. package/dist/helpers/cache.ts +173 -0
  18. package/dist/helpers/cors.ts +103 -0
  19. package/dist/helpers/csrf.ts +155 -0
  20. package/dist/helpers/debug.ts +158 -0
  21. package/dist/helpers/env.ts +147 -0
  22. package/dist/helpers/handler.ts +158 -0
  23. package/dist/helpers/http.ts +194 -0
  24. package/dist/helpers/image.ts +217 -0
  25. package/dist/helpers/jwt.ts +147 -0
  26. package/dist/helpers/logger.ts +96 -0
  27. package/dist/helpers/mail.ts +272 -0
  28. package/dist/helpers/middleware-loader.ts +116 -0
  29. package/dist/helpers/middleware.ts +57 -0
  30. package/dist/helpers/modules.ts +115 -0
  31. package/dist/helpers/openapi.ts +140 -0
  32. package/dist/helpers/pagination.ts +159 -0
  33. package/dist/helpers/queue.ts +186 -0
  34. package/dist/helpers/request-context.ts +13 -0
  35. package/dist/helpers/request.ts +376 -0
  36. package/dist/helpers/schedule.ts +173 -0
  37. package/dist/helpers/session-middleware.ts +89 -0
  38. package/dist/helpers/session.ts +286 -0
  39. package/dist/helpers/sse.ts +90 -0
  40. package/dist/helpers/throttle.ts +156 -0
  41. package/dist/helpers/upload.ts +417 -0
  42. package/dist/helpers/validator.ts +287 -0
  43. package/dist/helpers/ws.ts +123 -0
  44. package/dist/index.ts +221 -0
  45. package/dist/package.json +70 -0
  46. package/dist/router/file-router.ts +541 -0
  47. package/dist/router/server-router.ts +103 -0
  48. package/dist/view/page.ts +96 -0
  49. package/dist/view/renderer.tsx +390 -0
  50. package/dist/view/view-response.ts +10 -0
  51. package/package.json +70 -0
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Controller — CodeIgniter-style base class.
3
+ *
4
+ * Extend this to get `this.db`, `this.request`, `this.json()`, `this.view()`, `this.redirect()`.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * // pages/users.ts
9
+ * export class Users extends Controller {
10
+ * async index() {
11
+ * const users = await this.db.query('SELECT * FROM users')
12
+ * return this.json(users)
13
+ * }
14
+ * }
15
+ * ```
16
+ */
17
+ import type { Context } from "elysia";
18
+ import type { DbClient } from "../db/drizzle";
19
+ import {
20
+ validate,
21
+ validateStringRules,
22
+ validateZod,
23
+ type ValidationResult,
24
+ type ValidationErrors,
25
+ type rules,
26
+ } from "../helpers/validator";
27
+ import type { z } from "zod";
28
+ import type { Session } from "../helpers/session";
29
+ import type { Cache } from "../helpers/cache";
30
+ import type { Queue } from "../helpers/queue";
31
+ import type { Upload } from "../helpers/upload";
32
+ import type { Mail } from "../helpers/mail";
33
+ import { PageResponse, type PageOptions } from "../view/page";
34
+ import { ViewResponse } from "../view/view-response";
35
+ import { type HttpClient, createHttp } from "../helpers/http";
36
+ import { Image } from "../helpers/image";
37
+ import {
38
+ paginate as paginateFn,
39
+ addPaginationToDb,
40
+ type PaginateResult,
41
+ type PaginateOptions,
42
+ } from "../helpers/pagination";
43
+ import { RequestProxy } from "../helpers/request";
44
+
45
+ export class Controller {
46
+ /** Active request context — set by the router before each handler call. */
47
+ declare ctx: Context;
48
+
49
+ /** Database client. Configured via `app.use(DrizzleModule)`. */
50
+ declare db: DbClient;
51
+
52
+ /** Named databases (multi-database support). */
53
+ declare dbs: Record<string, DbClient>;
54
+
55
+ /** Session — cookie-based, `this.session.get/set/delete/clear`. */
56
+ declare session: Session;
57
+
58
+ /** Auth — `this.auth.user()`, `this.auth.login()`, `this.auth.logout()`. */
59
+ declare auth: {
60
+ user: () => any;
61
+ login: (user: any) => void;
62
+ logout: () => void;
63
+ check: () => boolean;
64
+ };
65
+
66
+ /** Cache — `this.cache.get/set/delete/remember`. */
67
+ declare cache: Cache;
68
+
69
+ /** Queue — `this.queue.dispatch/process`. */
70
+ declare queue: Queue;
71
+
72
+ /** Upload — `this.upload.file/files/store`. */
73
+ declare upload: Upload;
74
+
75
+ /** Mail — `this.mail.send()`. */
76
+ declare mail: Mail;
77
+
78
+ /** HTTP Client — `this.http.get/post/put/delete`. */
79
+ declare http: HttpClient;
80
+
81
+ /** Image — `this.image.open(file).resize(w,h).save(path)`. */
82
+ declare image: typeof Image;
83
+
84
+ /** Pagination — `this.paginate(data, total, options)`. */
85
+ declare paginate: typeof paginateFn;
86
+
87
+ /** Shared props for page rendering. */
88
+ protected _sharedProps: Record<string, any> = {};
89
+
90
+ /** Default HTTP client instance. */
91
+ private _http: HttpClient = createHttp();
92
+
93
+ /** Paginate helper bound to this controller. */
94
+ protected paginate = paginateFn;
95
+
96
+ // ─── Request Shortcuts ───────────────────────────────────────
97
+
98
+ /** URL query parameter. */
99
+ protected get query(): Record<string, string | string[]> {
100
+ return this.ctx.query ?? {};
101
+ }
102
+
103
+ /** Path parameter. */
104
+ protected param(name: string): string | undefined {
105
+ return (this.ctx.params as Record<string, string | undefined>)?.[name];
106
+ }
107
+
108
+ /** Request body (parsed JSON by Elysia). */
109
+ protected get body(): any {
110
+ return (this.ctx as any).body ?? {};
111
+ }
112
+
113
+ /** Request headers. */
114
+ protected get headers(): Record<string, string | string[]> {
115
+ return this.ctx.headers ?? {};
116
+ }
117
+
118
+ /** Request proxy — CodeIgniter-style input API (input, get, post, only, has, etc.). */
119
+ protected get request(): RequestProxy {
120
+ return new RequestProxy(this.ctx);
121
+ }
122
+
123
+ /** Raw Elysia context — escape hatch. */
124
+ protected get ctx_raw(): Context {
125
+ return this.ctx;
126
+ }
127
+
128
+ // ─── Response Shortcuts ──────────────────────────────────────
129
+
130
+ /** Return JSON response. */
131
+ protected json(data: any, status = 200): Response {
132
+ return new Response(JSON.stringify(data), {
133
+ status,
134
+ headers: { "content-type": "application/json" },
135
+ });
136
+ }
137
+
138
+ /** Return text response. */
139
+ protected text(data: string, status = 200): Response {
140
+ return new Response(data, {
141
+ status,
142
+ headers: { "content-type": "text/plain" },
143
+ });
144
+ }
145
+
146
+ /** Return HTML response. */
147
+ protected html(data: string, status = 200): Response {
148
+ return new Response(data, {
149
+ status,
150
+ headers: { "content-type": "text/html" },
151
+ });
152
+ }
153
+
154
+ /** Redirect to a URL. */
155
+ protected redirect(url: string, status: 301 | 302 = 302): Response {
156
+ return new Response(null, {
157
+ status,
158
+ headers: { location: url },
159
+ });
160
+ }
161
+
162
+ /** Return 404. */
163
+ protected notFound(message = "Not Found"): Response {
164
+ return new Response(message, { status: 404 });
165
+ }
166
+
167
+ /** Return 401. */
168
+ protected unauthorized(message = "Unauthorized"): Response {
169
+ return new Response(message, { status: 401 });
170
+ }
171
+
172
+ /** Return 400 with validation errors. */
173
+ protected badRequest(errors?: any): Response {
174
+ return this.json({ error: "Bad Request", details: errors ?? null }, 400);
175
+ }
176
+
177
+ // ─── Lifecycle Hooks ────────────────────────────────────────
178
+
179
+ /**
180
+ * Called before every controller method.
181
+ * Return a Response to short-circuit (e.g. redirect unauthenticated users).
182
+ * Return undefined to continue normally.
183
+ */
184
+ protected _before(): Response | undefined {
185
+ return undefined;
186
+ }
187
+
188
+ // ─── Validation Shortcuts ────────────────────────────────────
189
+
190
+ // ─── HTTP Client Shortcut ─────────────────────────────────
191
+
192
+ /** Make an HTTP GET request. */
193
+ protected async httpGet<T = any>(url: string, options?: any): Promise<T> {
194
+ const res = await this._http.get<T>(url, options);
195
+ return res.data;
196
+ }
197
+
198
+ /** Make an HTTP POST request. */
199
+ protected async httpPost<T = any>(
200
+ url: string,
201
+ body?: any,
202
+ options?: any,
203
+ ): Promise<T> {
204
+ const res = await this._http.post<T>(url, body, options);
205
+ return res.data;
206
+ }
207
+
208
+ /** Make an HTTP PUT request. */
209
+ protected async httpPut<T = any>(
210
+ url: string,
211
+ body?: any,
212
+ options?: any,
213
+ ): Promise<T> {
214
+ const res = await this._http.put<T>(url, body, options);
215
+ return res.data;
216
+ }
217
+
218
+ /** Make an HTTP DELETE request. */
219
+ protected async httpDelete<T = any>(url: string, options?: any): Promise<T> {
220
+ const res = await this._http.delete<T>(url, options);
221
+ return res.data;
222
+ }
223
+
224
+ // ─── Image Manipulation ────────────────────────────────────
225
+
226
+ /** Open an image for manipulation. */
227
+ protected imageOpen(path: string): Image {
228
+ return Image.open(path);
229
+ }
230
+
231
+ // ─── View (SSR React) ─────────────────────────────────────
232
+
233
+ /**
234
+ * Render a React view component to HTML (server-side).
235
+ *
236
+ * The component is loaded from views/ directory and SSR-rendered.
237
+ *
238
+ * @param name - View file name (e.g. 'TodoList' → views/TodoList.tsx)
239
+ * @param props - Props passed to the React component
240
+ * @param options - Options (title, scripts)
241
+ *
242
+ * @example
243
+ * ```ts
244
+ * const todos = await this.db.query('SELECT * FROM users')
245
+ * return this.view('TodoList', { todos: todos.rows }, { title: 'My Todos' })
246
+ * ```
247
+ */
248
+ protected view(
249
+ name: string,
250
+ props: Record<string, any> = {},
251
+ options: { title?: string; scripts?: string[] } = {},
252
+ ): ViewResponse {
253
+ return new ViewResponse(name, props, options);
254
+ }
255
+
256
+ // ─── Page Rendering ────────────────────────────────────────
257
+
258
+ /**
259
+ * Render a page with Inertia-style protocol.
260
+ *
261
+ * First request returns full HTML. Subsequent Inertia navigation
262
+ * returns JSON with component name + props.
263
+ *
264
+ * @param component - Component name (e.g. 'Users/Index')
265
+ * @param props - Component props (data from loader)
266
+ * @param options - Page options (status, title, layout, flash)
267
+ *
268
+ * @example
269
+ * ```ts
270
+ * async index() {
271
+ * const users = await this.db.query('SELECT * FROM users')
272
+ * return this.page('Users/Index', { users }, {
273
+ * title: 'Users List',
274
+ * flash: { type: 'success', message: 'Loaded!' },
275
+ * })
276
+ * }
277
+ * ```
278
+ */
279
+ protected page(
280
+ component: string,
281
+ props: Record<string, any> = {},
282
+ options: PageOptions = {},
283
+ ): PageResponse {
284
+ return new PageResponse(component, props, options);
285
+ }
286
+
287
+ /** Share a prop across all pages (like Inertia.share). */
288
+ protected share(key: string, value: any): void;
289
+ protected share(props: Record<string, any>): void;
290
+ protected share(keyOrProps: string | Record<string, any>, value?: any): void {
291
+ if (typeof keyOrProps === "string") {
292
+ this._sharedProps[keyOrProps] = value;
293
+ } else {
294
+ Object.assign(this._sharedProps, keyOrProps);
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Validate request body against rules or a Zod schema.
300
+ *
301
+ * @example
302
+ * ```ts
303
+ * // String rules (CodeIgniter style)
304
+ * const v = this.validate(this.body, {
305
+ * name: 'required|min:2',
306
+ * email: 'required|email',
307
+ * })
308
+ * if (v.fails()) return this.badRequest(v.errors())
309
+ *
310
+ * // Zod schema (TypeScript style)
311
+ * import { z } from 'zod'
312
+ * const v = this.validate(this.body, z.object({
313
+ * name: z.string().min(2),
314
+ * email: z.string().email(),
315
+ * }))
316
+ * ```
317
+ */
318
+ protected validate<T extends Record<string, any>>(
319
+ data: unknown,
320
+ schemaOrRules: z.ZodSchema<T> | Record<string, string>,
321
+ ): ValidationResult<T> {
322
+ return validate(data as any, schemaOrRules as any);
323
+ }
324
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Base barrel.
3
+ */
4
+ export { Controller } from './controller'
5
+ export { Service } from './service'
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Service — CodeIgniter-style service class.
3
+ *
4
+ * Extend this to get `this.db` for database access.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * // services/user.service.ts
9
+ * export class UserService extends Service {
10
+ * async findById(id: number) {
11
+ * return this.db.query('SELECT * FROM users WHERE id = ?', [id])
12
+ * }
13
+ * }
14
+ * ```
15
+ */
16
+ import type { DbClient } from '../db/drizzle'
17
+
18
+ export class Service {
19
+ /** Database client. */
20
+ declare db: DbClient
21
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Bunigniter CLI — scaffolding and utility commands.
3
+ *
4
+ * Usage: `bun run bi <command> [args]`
5
+ *
6
+ * All templates live in src/cli/templates.ts — single source of truth.
7
+ */
8
+ import { join } from 'node:path'
9
+ import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'node:fs'
10
+
11
+ const CWD = process.cwd()
12
+ const ROUTES_DIR = join(CWD, 'routes')
13
+ const DB_DIR = join(CWD, 'db')
14
+ const MIGRATIONS_DIR = join(CWD, 'db/migrations')
15
+ const SEEDS_DIR = join(CWD, 'db/seeds')
16
+ const MIDDLEWARE_DIR = join(CWD, 'middleware')
17
+ const TESTS_DIR = join(CWD, 'tests')
18
+
19
+ // ─── Command registry ──────────────────────────────────────────
20
+
21
+ const commands: Record<string, { desc: string; run: (args: string[]) => Promise<void> }> = {}
22
+
23
+ export function register(name: string, desc: string, fn: (args: string[]) => Promise<void>): void {
24
+ commands[name] = { desc, run: fn }
25
+ }
26
+
27
+ // ─── Shared helpers ────────────────────────────────────────────
28
+
29
+ function ensureDir(dir: string): void {
30
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
31
+ }
32
+
33
+ function write(name: string, filePath: string, content: string): void {
34
+ ensureDir(filePath.substring(0, filePath.lastIndexOf('/')))
35
+ if (existsSync(filePath)) throw new Error(`Already exists: ${filePath}`)
36
+ writeFileSync(filePath, content, 'utf-8')
37
+ console.log(`[nx] Created: ${filePath}`)
38
+ }
39
+
40
+ function argValue(args: string[], key: string, fallback = ''): string {
41
+ const idx = args.indexOf(key)
42
+ if (idx >= 0 && idx + 1 < args.length) return args[idx + 1]
43
+ const eq = args.find(a => a.startsWith(`${key}=`))
44
+ if (eq) return eq.split('=').slice(1).join('=')
45
+ return fallback
46
+ }
47
+
48
+ // ─── Register commands ─────────────────────────────────────────
49
+
50
+ register('make:controller', 'Scaffold a route controller', async (args) => {
51
+ const name = args[0]
52
+ if (!name) throw new Error('Usage: bi make:controller <name>')
53
+ const { controller } = await import('./templates')
54
+ write(name, join(ROUTES_DIR, `${name}.ts`), controller(name, process.env.ROUTER_PREFIX || '/api'))
55
+ })
56
+
57
+ register('make:model', 'Create DB schema', async (args) => {
58
+ const name = args[0]
59
+ if (!name) throw new Error('Usage: bi make:model <name> --columns "name:string,email:string"')
60
+ const cols = argValue(args, '--columns', 'name:string')
61
+ const { model } = await import('./templates')
62
+ const schemaDir = join(DB_DIR, 'schema')
63
+ write(name, join(schemaDir, `${name}.ts`), model(name, cols))
64
+ })
65
+
66
+ register('make:migration', 'Create a migration file', async (args) => {
67
+ const name = args[0]
68
+ if (!name) throw new Error('Usage: bi make:migration <name>')
69
+ const { migration } = await import('./templates')
70
+ write(name, join(MIGRATIONS_DIR, `${Date.now()}_${name}.sql`), migration(name))
71
+ })
72
+
73
+ register('db:migrate', 'Run pending migrations', async () => {
74
+ ensureDir(MIGRATIONS_DIR)
75
+ const files = readdirSync(MIGRATIONS_DIR).filter(f => f.endsWith('.sql')).sort()
76
+ if (files.length === 0) { console.log('[nx] No pending migrations.'); return }
77
+ for (const file of files) {
78
+ const sql = readFileSync(join(MIGRATIONS_DIR, file), 'utf-8')
79
+ console.log(`[nx] Running: ${file}`)
80
+ // Execute SQL via bun:sqlite
81
+ try {
82
+ const { Database } = await import('bun:sqlite')
83
+ const dbPath = arg(['DB_FILENAME'], 'app.db')
84
+ const db = new Database(dbPath)
85
+ for (const stmt of sql.split(';').filter(Boolean)) {
86
+ db.run(stmt.trim())
87
+ }
88
+ db.close()
89
+ console.log(`[nx] ✓ ${file}`)
90
+ } catch (e: any) {
91
+ console.error(`[nx] ✗ ${e.message}`)
92
+ }
93
+ }
94
+ })
95
+
96
+ register('db:rollback', 'Rollback last migration', async () => {
97
+ ensureDir(MIGRATIONS_DIR)
98
+ const files = readdirSync(MIGRATIONS_DIR).filter(f => f.endsWith('.sql')).sort()
99
+ if (files.length === 0) { console.log('[nx] No migrations to rollback.'); return }
100
+ const last = files[files.length - 1]
101
+ try {
102
+ const fs = await import('node:fs')
103
+ fs.unlinkSync(join(MIGRATIONS_DIR, last))
104
+ console.log(`[nx] Rolled back: ${last}`)
105
+ } catch (e: any) {
106
+ console.error(`[nx] Error: ${e.message}`)
107
+ }
108
+ })
109
+
110
+ register('db:seed', 'Run database seeders', async (args) => {
111
+ const specific = argValue(args, '--file', '')
112
+ const dir = specific ? join(SEEDS_DIR, specific) : SEEDS_DIR
113
+ if (!existsSync(dir)) { console.log('[nx] No seeders directory found.'); return }
114
+ if (specific && !existsSync(dir)) throw new Error(`Seeder not found: ${specific}`)
115
+
116
+ const files = specific ? [specific] : readdirSync(SEEDS_DIR).filter(f => f.endsWith('.ts')).sort()
117
+ for (const file of files) {
118
+ const mod = await import(join(SEEDS_DIR, file))
119
+ const fn = mod.default || mod.seed
120
+ if (typeof fn === 'function') {
121
+ console.log(`[nx] Seeding: ${file}`)
122
+ await fn({ db: null, dialect: 'sqlite' })
123
+ }
124
+ }
125
+ })
126
+
127
+ register('make:seeder', 'Scaffold a seeder file', async (args) => {
128
+ const name = args[0]
129
+ if (!name) throw new Error('Usage: bi make:seeder <name>')
130
+ const { seeder } = await import('./templates')
131
+ write(name, join(SEEDS_DIR, `${name}.ts`), seeder(name))
132
+ })
133
+
134
+ register('db:wipe', 'Drop all tables (DESTRUCTIVE)', async () => {
135
+ console.log('[nx] WARNING: This will drop ALL tables!')
136
+ try {
137
+ const { Database } = await import('bun:sqlite')
138
+ const dbPath = arg(['DB_FILENAME'], 'app.db')
139
+ const db = new Database(dbPath)
140
+ const tables = db.query("SELECT name FROM sqlite_master WHERE type='table'").all() as any[]
141
+ for (const t of tables) {
142
+ if (t.name === 'sqlite_sequence') continue
143
+ db.run(`DROP TABLE IF EXISTS "${t.name}"`)
144
+ console.log(`[nx] Dropped: ${t.name}`)
145
+ }
146
+ db.close()
147
+ console.log(`[nx] Done. ${tables.length - 1} tables dropped.`)
148
+ } catch (e: any) {
149
+ console.error(`[nx] Error: ${e.message}`)
150
+ }
151
+ })
152
+
153
+ register('key:generate', 'Generate APP_KEY', async () => {
154
+ const key = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64')
155
+ console.log(`\n APP_KEY=${key}\n`)
156
+ console.log(' Add to your .env file:')
157
+ console.log(` APP_KEY=${key}\n`)
158
+ })
159
+
160
+ register('make:middleware', 'Scaffold a middleware', async (args) => {
161
+ const name = args[0]
162
+ if (!name) throw new Error('Usage: bi make:middleware <name>')
163
+ const { middleware } = await import('./templates')
164
+ write(name, join(MIDDLEWARE_DIR, `${name}.ts`), middleware(name))
165
+ })
166
+
167
+ register('make:test', 'Scaffold a test file', async (args) => {
168
+ const name = args[0]
169
+ if (!name) throw new Error('Usage: bi make:test <name>')
170
+ const { test } = await import('./templates')
171
+ write(name, join(TESTS_DIR, `${name}.test.ts`), test(name))
172
+ })
173
+
174
+ register('make:command', 'Scaffold a CLI command', async (args) => {
175
+ const name = args[0]
176
+ if (!name) throw new Error('Usage: bi make:command <name>')
177
+ const { command } = await import('./templates')
178
+ write(name, join(CWD, 'commands', `${name}.ts`), command(name))
179
+ })
180
+
181
+ register('make:job', 'Scaffold a queue job', async (args) => {
182
+ const name = args[0]
183
+ if (!name) throw new Error('Usage: bi make:job <name>')
184
+ const { job } = await import('./templates')
185
+ write(name, join(CWD, 'jobs', `${name}.ts`), job(name))
186
+ })
187
+
188
+ register('make:mail', 'Scaffold a mail class', async (args) => {
189
+ const name = args[0]
190
+ if (!name) throw new Error('Usage: bi make:mail <name>')
191
+ const { mail } = await import('./templates')
192
+ write(name, join(CWD, 'mails', `${name}.ts`), mail(name))
193
+ })
194
+
195
+ register('make:event', 'Scaffold an event class', async (args) => {
196
+ const name = args[0]
197
+ if (!name) throw new Error('Usage: bi make:event <name>')
198
+ const { eventTemplate } = await import('./templates')
199
+ write(name, join(CWD, 'events', `${name}.ts`), eventTemplate(name))
200
+ })
201
+
202
+ register('make:listener', 'Scaffold an event listener', async (args) => {
203
+ const name = args[0]
204
+ if (!name) throw new Error('Usage: bi make:listener <name>')
205
+ const { listener } = await import('./templates')
206
+ write(name, join(CWD, 'listeners', `${name}.ts`), listener(name))
207
+ })
208
+
209
+ register('make:provider', 'Scaffold a service provider', async (args) => {
210
+ const name = args[0]
211
+ if (!name) throw new Error('Usage: bi make:provider <name>')
212
+ const { provider } = await import('./templates')
213
+ write(name, join(CWD, 'providers', `${name}.ts`), provider(name))
214
+ })
215
+
216
+ register('make:policy', 'Scaffold an authorization policy', async (args) => {
217
+ const name = args[0]
218
+ if (!name) throw new Error('Usage: bi make:policy <name>')
219
+ const { policy } = await import('./templates')
220
+ write(name, join(CWD, 'policies', `${name}.ts`), policy(name))
221
+ })
222
+
223
+ register('make:request', 'Scaffold a form request (validation)', async (args) => {
224
+ const name = args[0]
225
+ if (!name) throw new Error('Usage: bi make:request <name>')
226
+ const { formRequest } = await import('./templates')
227
+ write(name, join(CWD, 'requests', `${name}.ts`), formRequest(name))
228
+ })
229
+
230
+ register('make:resource', 'Scaffold an API resource', async (args) => {
231
+ const name = args[0]
232
+ if (!name) throw new Error('Usage: bi make:resource <name>')
233
+ const { resource } = await import('./templates')
234
+ write(name, join(CWD, 'resources', `${name}.ts`), resource(name))
235
+ })
236
+
237
+ register('make:rule', 'Scaffold a validation rule', async (args) => {
238
+ const name = args[0]
239
+ if (!name) throw new Error('Usage: bi make:rule <name>')
240
+ const { rule } = await import('./templates')
241
+ write(name, join(CWD, 'rules', `${name}.ts`), rule(name))
242
+ })
243
+
244
+ register('storage:link', 'Create storage symlink', async () => {
245
+ const target = join(CWD, 'storage', 'app')
246
+ const link = join(CWD, 'public', 'storage')
247
+ if (existsSync(link)) { console.log('[nx] Storage link already exists.'); return }
248
+ ensureDir(join(CWD, 'storage'))
249
+ ensureDir(join(CWD, 'public'))
250
+ try {
251
+ const fs = await import('node:fs')
252
+ fs.symlinkSync(target, link, 'dir')
253
+ console.log(`[nx] Linked: ${link} → ${target}`)
254
+ } catch (e: any) {
255
+ console.error(`[nx] Error: ${e.message}`)
256
+ }
257
+ })
258
+
259
+ register('build:edge', 'Build pre-compiled edge routes', async () => {
260
+ const { buildEdgeRoutes } = await import('../edge-builder')
261
+ await buildEdgeRoutes()
262
+ })
263
+
264
+ register('edge:dev', 'Run edge app locally', async () => {
265
+ const { createEdgeApp, register } = await import('../edge')
266
+ const app = createEdgeApp()
267
+ register(app, 'GET', '/api/hello', () => new Response(JSON.stringify({ message: 'Hello from Edge!' }), { headers: { 'content-type': 'application/json' } }))
268
+ app.listen(3001, () => console.log('Edge app on :3001'))
269
+ })
270
+
271
+ register('list', 'Show all registered routes', async () => {
272
+ const { listRoutes } = await import('./list-routes')
273
+ await listRoutes()
274
+ })
275
+
276
+ register('repl', 'Start interactive console', async () => {
277
+ const { startRepl } = await import('./repl')
278
+ await startRepl()
279
+ })
280
+
281
+ register('help', 'Show this help', async () => {
282
+ console.log('\n Bunigniter CLI')
283
+ console.log(' ─────────────────────────────────')
284
+ for (const [name, cmd] of Object.entries(commands)) {
285
+ console.log(` ${name.padEnd(25)} ${cmd.desc}`)
286
+ }
287
+ console.log()
288
+ })
289
+
290
+ // ─── Boot ──────────────────────────────────────────────────────
291
+
292
+ async function main() {
293
+ const args = process.argv.slice(2)
294
+ const cmd = args[0]
295
+
296
+ if (!cmd || cmd === 'help' || !commands[cmd]) {
297
+ await commands.help.run()
298
+ process.exit(cmd && cmd !== 'help' ? 1 : 0)
299
+ }
300
+
301
+ try {
302
+ await commands[cmd].run(args.slice(1))
303
+ } catch (err) {
304
+ console.error(`[nx] Error: ${(err as Error).message}`)
305
+ process.exit(1)
306
+ }
307
+ }
308
+
309
+ main()
310
+
311
+ /** Read first matching env var or return default. */
312
+ function arg(keys: string[], fallback: string): string {
313
+ for (const k of keys) {
314
+ const v = process.env[k] ?? ''
315
+ if (v) return v
316
+ }
317
+ return fallback
318
+ }