@strav/spring 0.4.31 → 1.0.0-alpha.29

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 (88) hide show
  1. package/README.md +11 -52
  2. package/package.json +19 -20
  3. package/src/args.ts +119 -0
  4. package/src/cli.ts +134 -0
  5. package/src/index.ts +10 -176
  6. package/src/prompts.ts +49 -127
  7. package/src/scaffold.ts +115 -36
  8. package/src/spring_error.ts +11 -0
  9. package/src/templates/shared/README.md.tt +37 -0
  10. package/src/templates/shared/_dot_env.example.tt +12 -0
  11. package/src/templates/shared/_dot_env.tt +12 -0
  12. package/src/templates/shared/_dot_gitignore +12 -0
  13. package/src/templates/shared/app/console/_dot_gitkeep +0 -0
  14. package/src/templates/shared/app/exceptions/_dot_gitkeep +0 -0
  15. package/src/templates/shared/app/http/controllers/_dot_gitkeep +0 -0
  16. package/src/templates/shared/app/http/middleware/_dot_gitkeep +0 -0
  17. package/src/templates/shared/app/http/requests/_dot_gitkeep +0 -0
  18. package/src/templates/shared/app/jobs/_dot_gitkeep +0 -0
  19. package/src/templates/shared/app/mail/_dot_gitkeep +0 -0
  20. package/src/templates/shared/app/models/_dot_gitkeep +0 -0
  21. package/src/templates/shared/app/notifications/_dot_gitkeep +0 -0
  22. package/src/templates/shared/app/policies/_dot_gitkeep +0 -0
  23. package/src/templates/shared/app/providers/app_provider.ts +18 -0
  24. package/src/templates/shared/app/repositories/_dot_gitkeep +0 -0
  25. package/src/templates/shared/bin/strav.ts +21 -0
  26. package/src/templates/shared/bootstrap/app.ts +13 -0
  27. package/src/templates/shared/bootstrap/providers.ts +24 -0
  28. package/src/templates/shared/config/app.ts.tt +13 -0
  29. package/src/templates/shared/config/http.ts +9 -0
  30. package/src/templates/shared/config/logger.ts +9 -0
  31. package/src/templates/shared/database/factories/_dot_gitkeep +0 -0
  32. package/src/templates/shared/database/migrations/_dot_gitkeep +0 -0
  33. package/src/templates/shared/database/schemas/_dot_gitkeep +0 -0
  34. package/src/templates/shared/database/seeders/_dot_gitkeep +0 -0
  35. package/src/templates/shared/package.json.tt +22 -0
  36. package/src/templates/shared/routes/api.ts +11 -0
  37. package/src/templates/shared/routes/console.ts +10 -0
  38. package/src/templates/shared/storage/cache/_dot_gitkeep +0 -0
  39. package/src/templates/shared/storage/logs/_dot_gitkeep +0 -0
  40. package/src/templates/shared/storage/uploads/_dot_gitkeep +0 -0
  41. package/src/templates/shared/tests/feature/healthz.test.ts.tt +19 -0
  42. package/src/templates/shared/tests/unit/_dot_gitkeep +0 -0
  43. package/src/templates/shared/tsconfig.json +21 -11
  44. package/src/templates/web/README.md.tt +42 -0
  45. package/src/templates/web/_dot_gitignore +13 -0
  46. package/src/templates/web/app/providers/app_provider.ts +21 -0
  47. package/src/templates/web/bootstrap/providers.ts +31 -0
  48. package/src/templates/web/config/http.ts +17 -8
  49. package/src/templates/web/config/view.ts +26 -7
  50. package/src/templates/web/package.json.tt +26 -0
  51. package/src/templates/web/public/assets/_dot_gitkeep +0 -0
  52. package/src/templates/web/resources/css/app.css +32 -0
  53. package/src/templates/web/resources/views/components/_dot_gitkeep +0 -0
  54. package/src/templates/web/resources/views/errors/404.strav +8 -0
  55. package/src/templates/web/resources/views/errors/500.strav +8 -0
  56. package/src/templates/web/resources/views/layouts/app.strav.tt +46 -0
  57. package/src/templates/web/resources/views/pages/index.strav.tt +47 -0
  58. package/src/templates/web/routes/broadcast.ts +9 -0
  59. package/src/templates/web/routes/web.ts +19 -0
  60. package/src/templates/web/tests/browser/_dot_gitkeep +0 -0
  61. package/src/version.ts +9 -0
  62. package/src/templates/api/app/controllers/controller.ts +0 -15
  63. package/src/templates/api/app/controllers/user_controller.ts +0 -69
  64. package/src/templates/api/config/database.ts +0 -9
  65. package/src/templates/api/config/http.ts +0 -17
  66. package/src/templates/api/database/factories/user_factory.ts +0 -11
  67. package/src/templates/api/database/schemas/user.ts +0 -13
  68. package/src/templates/api/database/seeders/database_seeder.ts +0 -8
  69. package/src/templates/api/database/seeders/user_seeder.ts +0 -15
  70. package/src/templates/api/index.ts +0 -11
  71. package/src/templates/api/package.json +0 -24
  72. package/src/templates/api/start/providers.ts +0 -10
  73. package/src/templates/api/start/routes.ts +0 -22
  74. package/src/templates/shared/config/app.ts +0 -7
  75. package/src/templates/shared/config/encryption.ts +0 -5
  76. package/src/templates/shared/package.json +0 -24
  77. package/src/templates/shared/storage/uploads/.gitkeep +0 -1
  78. package/src/templates/shared/strav.ts +0 -2
  79. package/src/templates/shared/tests/example.test.ts +0 -11
  80. package/src/templates/web/index.ts +0 -28
  81. package/src/templates/web/package.json +0 -26
  82. package/src/templates/web/public/builds/.gitkeep +0 -1
  83. package/src/templates/web/public/css/.gitkeep +0 -1
  84. package/src/templates/web/resources/css/app.scss +0 -77
  85. package/src/templates/web/resources/islands/counter.vue +0 -31
  86. package/src/templates/web/resources/views/layouts/app.strav +0 -18
  87. package/src/templates/web/resources/views/pages/index.strav +0 -32
  88. package/src/templates/web/start/providers.ts +0 -11
package/src/prompts.ts CHANGED
@@ -1,135 +1,57 @@
1
- const ESC = '\x1b'
2
- const ARROW_UP = `${ESC}[A`
3
- const ARROW_DOWN = `${ESC}[B`
4
- const ENTER = '\r'
5
-
6
- interface Choice {
1
+ /**
2
+ * Minimal interactive prompts for `bunx @strav/spring`. Two functions:
3
+ * `select` for picking from a small fixed list, `input` for a free string.
4
+ *
5
+ * No external dependency: spring's whole point is to bootstrap a project
6
+ * with `bunx` and a pinned framework version — we don't want a chain of
7
+ * peer deps to install before the user has typed their first thing.
8
+ *
9
+ * Tested through the bin (not unit-tested) because the value here is the
10
+ * interactive I/O surface, not the parsing.
11
+ */
12
+
13
+ import * as readline from 'node:readline/promises'
14
+
15
+ export interface SelectOption<T extends string> {
16
+ value: T
7
17
  label: string
8
- value: string
9
- description: string
18
+ description?: string
10
19
  }
11
20
 
12
- export async function select(message: string, choices: Choice[]): Promise<string> {
13
- let selected = 0
14
-
15
- const render = () => {
16
- // Move cursor up to overwrite previous render (except first time)
17
- process.stdout.write(`\x1b[${choices.length}A`)
18
- for (let i = 0; i < choices.length; i++) {
19
- const prefix = i === selected ? '\x1b[36m>\x1b[0m' : ' '
20
- const choice = choices[i]!
21
- const label = i === selected ? `\x1b[1m${choice.label}\x1b[0m` : choice.label
22
- const desc = `\x1b[2m${choice.description}\x1b[0m`
23
- process.stdout.write(`\x1b[2K ${prefix} ${label} ${desc}\n`)
21
+ export async function select<T extends string>(
22
+ question: string,
23
+ options: readonly SelectOption<T>[],
24
+ ): Promise<T> {
25
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
26
+ try {
27
+ process.stdout.write(`\n ${question}\n`)
28
+ for (let i = 0; i < options.length; i++) {
29
+ const opt = options[i] as SelectOption<T>
30
+ const tail = opt.description ? ` \x1b[2m— ${opt.description}\x1b[0m` : ''
31
+ process.stdout.write(` \x1b[2m${i + 1})\x1b[0m ${opt.label}${tail}\n`)
24
32
  }
25
- }
26
-
27
- process.stdout.write(` \x1b[1m${message}\x1b[0m\n`)
28
- // Print initial lines so render() can overwrite them
29
- for (const choice of choices) {
30
- process.stdout.write('\n')
31
- }
32
- render()
33
-
34
- return new Promise(resolve => {
35
- const stdin = process.stdin
36
- stdin.setRawMode(true)
37
- stdin.resume()
38
- stdin.setEncoding('utf8')
39
-
40
- let buffer = ''
41
-
42
- const onData = (data: string) => {
43
- buffer += data
44
-
45
- // Check for Ctrl+C
46
- if (buffer.includes('\x03')) {
47
- stdin.setRawMode(false)
48
- stdin.pause()
49
- stdin.removeListener('data', onData)
50
- process.stdout.write('\n')
51
- process.exit(0)
52
- }
53
-
54
- // Process escape sequences
55
- while (buffer.length > 0) {
56
- if (buffer.startsWith(ARROW_UP)) {
57
- selected = (selected - 1 + choices.length) % choices.length
58
- render()
59
- buffer = buffer.slice(ARROW_UP.length)
60
- } else if (buffer.startsWith(ARROW_DOWN)) {
61
- selected = (selected + 1) % choices.length
62
- render()
63
- buffer = buffer.slice(ARROW_DOWN.length)
64
- } else if (buffer.startsWith(ENTER)) {
65
- stdin.setRawMode(false)
66
- stdin.pause()
67
- stdin.removeListener('data', onData)
68
- resolve(choices[selected]!.value)
69
- buffer = ''
70
- return
71
- } else if (buffer.startsWith(ESC)) {
72
- // Incomplete escape sequence, wait for more data
73
- break
74
- } else {
75
- // Discard unrecognized input
76
- buffer = buffer.slice(1)
77
- }
33
+ while (true) {
34
+ const answer = (await rl.question(`\n > `)).trim()
35
+ const numeric = Number.parseInt(answer, 10)
36
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= options.length) {
37
+ return (options[numeric - 1] as SelectOption<T>).value
78
38
  }
39
+ const match = options.find((o) => o.value === answer || o.label === answer)
40
+ if (match) return match.value
41
+ process.stdout.write(` \x1b[31m✗\x1b[0m enter a number 1–${options.length} or the label\n`)
79
42
  }
80
-
81
- stdin.on('data', onData)
82
- })
43
+ } finally {
44
+ rl.close()
45
+ }
83
46
  }
84
47
 
85
- export async function input(message: string, defaultValue: string): Promise<string> {
86
- process.stdout.write(` \x1b[1m${message}\x1b[0m \x1b[2m(${defaultValue})\x1b[0m `)
87
-
88
- return new Promise(resolve => {
89
- const stdin = process.stdin
90
- stdin.setRawMode(true)
91
- stdin.resume()
92
- stdin.setEncoding('utf8')
93
-
94
- let value = ''
95
-
96
- const onData = (data: string) => {
97
- for (const char of data) {
98
- if (char === '\x03') {
99
- // Ctrl+C
100
- stdin.setRawMode(false)
101
- stdin.pause()
102
- stdin.removeListener('data', onData)
103
- process.stdout.write('\n')
104
- process.exit(0)
105
- }
106
-
107
- if (char === '\r' || char === '\n') {
108
- stdin.setRawMode(false)
109
- stdin.pause()
110
- stdin.removeListener('data', onData)
111
- process.stdout.write('\n')
112
- resolve(value || defaultValue)
113
- return
114
- }
115
-
116
- if (char === '\x7f' || char === '\b') {
117
- // Backspace
118
- if (value.length > 0) {
119
- value = value.slice(0, -1)
120
- process.stdout.write('\b \b')
121
- }
122
- continue
123
- }
124
-
125
- // Printable characters
126
- if (char >= ' ') {
127
- value += char
128
- process.stdout.write(char)
129
- }
130
- }
131
- }
132
-
133
- stdin.on('data', onData)
134
- })
135
- }
48
+ export async function input(question: string, fallback?: string): Promise<string> {
49
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
50
+ try {
51
+ const suffix = fallback !== undefined ? ` \x1b[2m(${fallback})\x1b[0m` : ''
52
+ const answer = (await rl.question(` ${question}${suffix}: `)).trim()
53
+ return answer === '' && fallback !== undefined ? fallback : answer
54
+ } finally {
55
+ rl.close()
56
+ }
57
+ }
package/src/scaffold.ts CHANGED
@@ -1,54 +1,133 @@
1
- import { readdirSync, mkdirSync, statSync } from 'node:fs'
2
- import { join, dirname } from 'node:path'
3
- import pkg from '../package.json'
1
+ /**
2
+ * Scaffolder. Walks the template tree under `src/templates/{shared,<template>}/`,
3
+ * copies each file to `dest`, and applies a tiny `{{token}}` pass to `.tt`
4
+ * files. Plain files are copied byte-for-byte.
5
+ *
6
+ * Conventions baked in:
7
+ * - Files named with a leading `_dot_` are written with a leading `.`. This
8
+ * avoids npm-publish edge cases where dotfiles inside `src/` can be ignored
9
+ * by registries' implicit ignore lists.
10
+ * - The `.tt` suffix marks a file with `{{token}}` interpolation. The suffix
11
+ * is stripped on write. `{{token}}` is a literal replace — no conditionals,
12
+ * no loops.
13
+ * - Overlay order: `shared/` is copied first, then `<template>/` is copied
14
+ * on top, with overlay files overwriting matching paths.
15
+ */
16
+
17
+ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
18
+ import { dirname, join, relative } from 'node:path'
19
+ import { SpringError } from './spring_error.ts'
20
+
21
+ const TEMPLATES_ROOT = join(import.meta.dir, 'templates')
4
22
 
5
23
  export interface ScaffoldOptions {
6
24
  projectName: string
7
25
  template: 'api' | 'web'
8
26
  dbName: string
27
+ /** Absolute path the project is written to. */
28
+ dest: string
29
+ /**
30
+ * Override the framework version string injected into the generated
31
+ * `package.json`. Tests pass `'workspace:*'` so the scaffolded app can
32
+ * boot inside this monorepo without `bun install`. Defaults to the
33
+ * pinned constant from `version.ts`.
34
+ */
35
+ stravVersion?: string
9
36
  }
10
37
 
11
- export async function scaffold(root: string, opts: ScaffoldOptions): Promise<void> {
12
- const templatesDir = join(import.meta.dir, 'templates')
13
- const appKey = crypto.randomUUID()
38
+ export interface ScaffoldResult {
39
+ /** Project-root-relative paths of every file written. */
40
+ files: readonly string[]
41
+ }
14
42
 
15
- const replacements: Record<string, string> = {
16
- __PROJECT_NAME__: opts.projectName,
17
- __DB_NAME__: opts.dbName,
18
- __APP_KEY__: appKey,
19
- __STRAV_VERSION__: `^${pkg.version}`,
43
+ export async function scaffold(opts: ScaffoldOptions): Promise<ScaffoldResult> {
44
+ const tokens = {
45
+ projectName: opts.projectName,
46
+ dbName: opts.dbName,
47
+ stravVersion: opts.stravVersion ?? (await defaultStravVersion()),
20
48
  }
21
49
 
22
- // Copy shared files first, then template-specific (may override shared)
23
- await copyDir(join(templatesDir, 'shared'), root, replacements)
24
- await copyDir(join(templatesDir, opts.template), root, replacements)
25
- }
50
+ const written: string[] = []
51
+ const overlayLayers = [join(TEMPLATES_ROOT, 'shared'), join(TEMPLATES_ROOT, opts.template)]
26
52
 
27
- async function copyDir(
28
- srcDir: string,
29
- destDir: string,
30
- replacements: Record<string, string>
31
- ): Promise<void> {
32
- const entries = readdirSync(srcDir)
53
+ for (const layer of overlayLayers) {
54
+ if (!(await pathExists(layer))) {
55
+ // The overlay for a given template may be absent (e.g., --api has no
56
+ // overlay in slice A — everything ships in shared/). Skip silently.
57
+ continue
58
+ }
59
+ for await (const sourceFile of walk(layer)) {
60
+ const rel = relative(layer, sourceFile)
61
+ const targetRel = applyDotPrefix(stripTemplateSuffix(rel))
62
+ const targetAbs = join(opts.dest, targetRel)
63
+ await mkdir(dirname(targetAbs), { recursive: true })
33
64
 
65
+ if (sourceFile.endsWith('.tt')) {
66
+ const raw = await readFile(sourceFile, 'utf8')
67
+ await writeFile(targetAbs, interpolate(raw, tokens), 'utf8')
68
+ } else {
69
+ const buf = await readFile(sourceFile)
70
+ await writeFile(targetAbs, buf)
71
+ }
72
+ written.push(targetRel)
73
+ }
74
+ }
75
+
76
+ // .gitkeep files don't have a real purpose in the generated tree — they
77
+ // exist only so empty template directories survive git/npm. Strip from
78
+ // the result manifest so tests see a clean list, but leave on disk so
79
+ // `make:*` commands can target the directories.
80
+ return { files: written.sort() }
81
+ }
82
+
83
+ async function* walk(dir: string): AsyncIterable<string> {
84
+ const entries = await readdir(dir, { withFileTypes: true })
34
85
  for (const entry of entries) {
35
- const srcPath = join(srcDir, entry)
36
- const destPath = join(destDir, entry.replace(/\.tpl$/, ''))
37
-
38
- if (statSync(srcPath).isDirectory()) {
39
- await copyDir(srcPath, destPath, replacements)
40
- } else {
41
- mkdirSync(dirname(destPath), { recursive: true })
42
- const content = await Bun.file(srcPath).text()
43
- await Bun.write(destPath, applyReplacements(content, replacements))
86
+ const full = join(dir, entry.name)
87
+ if (entry.isDirectory()) {
88
+ yield* walk(full)
89
+ } else if (entry.isFile()) {
90
+ yield full
44
91
  }
45
92
  }
46
93
  }
47
94
 
48
- function applyReplacements(content: string, replacements: Record<string, string>): string {
49
- let result = content
50
- for (const [placeholder, value] of Object.entries(replacements)) {
51
- result = result.replaceAll(placeholder, value)
95
+ async function pathExists(p: string): Promise<boolean> {
96
+ try {
97
+ await stat(p)
98
+ return true
99
+ } catch {
100
+ return false
52
101
  }
53
- return result
54
- }
102
+ }
103
+
104
+ function stripTemplateSuffix(path: string): string {
105
+ return path.endsWith('.tt') ? path.slice(0, -3) : path
106
+ }
107
+
108
+ const DOT_PREFIX = '_dot_'
109
+
110
+ function applyDotPrefix(path: string): string {
111
+ // Apply per-segment so nested files like `_dot_github/workflows/x.yml` work.
112
+ return path
113
+ .split('/')
114
+ .map((seg) => (seg.startsWith(DOT_PREFIX) ? `.${seg.slice(DOT_PREFIX.length)}` : seg))
115
+ .join('/')
116
+ }
117
+
118
+ const TOKEN_RE = /\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}/g
119
+
120
+ function interpolate(source: string, tokens: Record<string, string>): string {
121
+ return source.replace(TOKEN_RE, (_, name: string) => {
122
+ const value = tokens[name]
123
+ if (value === undefined) {
124
+ throw new SpringError(`template references unknown token {{${name}}}`)
125
+ }
126
+ return value
127
+ })
128
+ }
129
+
130
+ async function defaultStravVersion(): Promise<string> {
131
+ const { STRAV_VERSION } = await import('./version.ts')
132
+ return STRAV_VERSION
133
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `SpringError` — error class local to `@strav/spring`.
3
+ *
4
+ * Spring has zero `@strav/*` runtime dependencies (see the template-strategy
5
+ * ADR), so it cannot use `StravError` from `@strav/kernel`. This is a small
6
+ * local equivalent: a typed error so the CLI top-level can distinguish
7
+ * expected user-facing errors from unexpected bugs.
8
+ */
9
+ export class SpringError extends Error {
10
+ override readonly name = 'SpringError'
11
+ }
@@ -0,0 +1,37 @@
1
+ # {{projectName}}
2
+
3
+ A Strav 1.0 application scaffolded by `@strav/spring`.
4
+
5
+ ## Run
6
+
7
+ ```bash
8
+ bun install
9
+ bun strav serve # HTTP server on http://localhost:3000
10
+ bun strav # list every available command
11
+ bun strav key:generate # generate a real APP_KEY
12
+ ```
13
+
14
+ ## Layout
15
+
16
+ This project follows the layout in `spec/directory-structure.md`:
17
+
18
+ - `app/` — your application code (`http/`, `models/`, `providers/`, …)
19
+ - `bin/strav.ts` — single dispatcher; `bun strav <command>` invokes it
20
+ - `bootstrap/` — `app.ts` (Application factory) + `providers.ts` (provider list)
21
+ - `config/` — typed config files; one per package, each exports `default`
22
+ - `routes/` — route declarations
23
+ - `tests/feature/` — HTTP-level integration tests
24
+ - `tests/unit/` — pure unit tests
25
+
26
+ ## Adding more packages
27
+
28
+ The scaffolded app starts with the minimum stack (`@strav/{kernel,http,cli}`).
29
+ Add `@strav/database`, `@strav/auth`, `@strav/queue`, … as you need them:
30
+
31
+ ```bash
32
+ bun add @strav/database
33
+ ```
34
+
35
+ Then create the matching `config/<name>.ts` file, add the provider to
36
+ `bootstrap/providers.ts`, and (for `@strav/database`) wire `DB_*` env vars
37
+ in `.env`.
@@ -0,0 +1,12 @@
1
+ APP_NAME={{projectName}}
2
+ APP_ENV=local
3
+ APP_KEY=
4
+ APP_URL=http://localhost:3000
5
+ LOG_LEVEL=info
6
+
7
+ # Database — uncomment + fill in when you add @strav/database
8
+ # DB_HOST=127.0.0.1
9
+ # DB_PORT=5432
10
+ # DB_USER=
11
+ # DB_PASSWORD=
12
+ # DB_DATABASE={{dbName}}
@@ -0,0 +1,12 @@
1
+ APP_NAME={{projectName}}
2
+ APP_ENV=local
3
+ APP_KEY=change-me-with-key-generate
4
+ APP_URL=http://localhost:3000
5
+ LOG_LEVEL=info
6
+
7
+ # Database — uncomment + fill in when you add @strav/database
8
+ # DB_HOST=127.0.0.1
9
+ # DB_PORT=5432
10
+ # DB_USER=strav
11
+ # DB_PASSWORD=strav
12
+ # DB_DATABASE={{dbName}}
@@ -0,0 +1,12 @@
1
+ node_modules
2
+ .env
3
+ .env.local
4
+ storage/cache/*
5
+ !storage/cache/.gitkeep
6
+ storage/logs/*
7
+ !storage/logs/.gitkeep
8
+ storage/uploads/*
9
+ !storage/uploads/.gitkeep
10
+ public/assets
11
+ *.log
12
+ .DS_Store
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -0,0 +1,18 @@
1
+ import { Router } from '@strav/http'
2
+ import { type Application, ServiceProvider } from '@strav/kernel'
3
+ import { registerApiRoutes } from '../../routes/api.ts'
4
+
5
+ /**
6
+ * Application-level wiring: registers routes, app-owned bindings, custom
7
+ * middleware. Runs in `register()` (not `boot()`) so the router still
8
+ * accepts route additions — `HttpProvider.boot()` compiles the trie and
9
+ * locks the registry.
10
+ */
11
+ export class AppProvider extends ServiceProvider {
12
+ override readonly name = 'app'
13
+ override readonly dependencies = ['http']
14
+
15
+ override register(app: Application): void {
16
+ registerApiRoutes(app.resolve(Router))
17
+ }
18
+ }
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Single entry point for every role. `ConsoleKernel` (via `runCli`) parses
4
+ * the subcommand and resolves the right kernel — `serve` for `HttpKernel`,
5
+ * `make:*` for one-shot scaffolders, etc. Add new commands by listing a
6
+ * `ConsoleProvider` subclass in `bootstrap/providers.ts`; `runCli`
7
+ * auto-collects their `commands` arrays.
8
+ */
9
+
10
+ // `@strav/kernel` imports `reflect-metadata` internally; consumers don't need to.
11
+ import { runCli } from '@strav/cli'
12
+ import { createApp } from '../bootstrap/app.ts'
13
+ import { providers } from '../bootstrap/providers.ts'
14
+
15
+ const exitCode = await runCli({
16
+ argv: process.argv.slice(2),
17
+ defaultProviders: await providers(),
18
+ app: createApp(),
19
+ })
20
+
21
+ process.exit(exitCode)
@@ -0,0 +1,13 @@
1
+ import { Application } from '@strav/kernel'
2
+
3
+ /**
4
+ * Build the `Application` instance. Providers are NOT attached here —
5
+ * `runCli` (in `bin/strav.ts`) picks the right subset for the requested
6
+ * command from `bootstrap/providers.ts` and registers them on this app.
7
+ *
8
+ * App-level hooks (env detection, custom singletons that don't belong in
9
+ * a provider) can be added here later without touching every command.
10
+ */
11
+ export function createApp(): Application {
12
+ return new Application()
13
+ }
@@ -0,0 +1,24 @@
1
+ import { HttpConsoleProvider, HttpProvider } from '@strav/http'
2
+ import { ConfigProvider, LoggerProvider, type ServiceProvider } from '@strav/kernel'
3
+ import { AppProvider } from '../app/providers/app_provider.ts'
4
+
5
+ /**
6
+ * Default provider list. Order is not load-bearing — the container does a
7
+ * dependency-aware topo sort at boot. Keep providers grouped by package
8
+ * for readability.
9
+ *
10
+ * `ConfigProvider.fromDirectory('config')` auto-discovers every
11
+ * `config/*.ts` file and keys them by basename — `config/app.ts` →
12
+ * `config('app.*')`, `config/http.ts` → `config('http.*')`, etc. To add
13
+ * a new config slot, drop a file with a `default export` into
14
+ * `config/`. No edits to this list required.
15
+ */
16
+ export async function providers(): Promise<ServiceProvider[]> {
17
+ return [
18
+ await ConfigProvider.fromDirectory('config'),
19
+ new LoggerProvider(),
20
+ new HttpProvider(),
21
+ new HttpConsoleProvider(),
22
+ new AppProvider(),
23
+ ]
24
+ }
@@ -0,0 +1,13 @@
1
+ import { env } from '@strav/kernel'
2
+
3
+ export default {
4
+ name: env('APP_NAME', '{{projectName}}'),
5
+ env: env('APP_ENV', 'local'),
6
+ /**
7
+ * Encryption + signing key. Generate one with `bun strav key:generate`
8
+ * and copy the value into `.env`. The placeholder below is fine for
9
+ * `bun strav serve` smoke-tests but MUST be replaced before deploying.
10
+ */
11
+ key: env('APP_KEY', 'change-me-with-key-generate'),
12
+ url: env('APP_URL', 'http://localhost:3000'),
13
+ }
@@ -0,0 +1,9 @@
1
+ import { env } from '@strav/kernel'
2
+
3
+ export default {
4
+ /**
5
+ * Expose stack traces in error responses. Convenient locally; never
6
+ * leave this on in production.
7
+ */
8
+ exposeStackTrace: env('APP_ENV', 'local') !== 'production',
9
+ }
@@ -0,0 +1,9 @@
1
+ import { env } from '@strav/kernel'
2
+
3
+ export default {
4
+ default: 'stderr',
5
+ level: env('LOG_LEVEL', 'info'),
6
+ channels: {
7
+ stderr: { driver: 'stderr' },
8
+ },
9
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "{{projectName}}",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "strav": "bun bin/strav.ts",
8
+ "dev": "bun --hot bin/strav.ts serve",
9
+ "test": "bun test"
10
+ },
11
+ "dependencies": {
12
+ "@strav/cli": "{{stravVersion}}",
13
+ "@strav/http": "{{stravVersion}}",
14
+ "@strav/kernel": "{{stravVersion}}"
15
+ },
16
+ "devDependencies": {
17
+ "@types/bun": "latest"
18
+ },
19
+ "engines": {
20
+ "bun": ">=1.3.14"
21
+ }
22
+ }
@@ -0,0 +1,11 @@
1
+ import type { Router } from '@strav/http'
2
+
3
+ /**
4
+ * Wire JSON routes. Imported and called from `app/providers/app_provider.ts`.
5
+ *
6
+ * The router is a registry until boot, so this function only declares routes;
7
+ * the actual trie compile happens inside `HttpProvider.boot()`.
8
+ */
9
+ export function registerApiRoutes(router: Router): void {
10
+ router.get('/healthz', () => Response.json({ ok: true }))
11
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Custom console commands and scheduled tasks. Register them by adding a
3
+ * `ConsoleProvider` subclass to `bootstrap/providers.ts` whose `commands`
4
+ * array lists your `Command` classes.
5
+ *
6
+ * This file is intentionally empty in the scaffold — it's a documentation
7
+ * anchor so the layout matches `spec/directory-structure.md`. Add wiring
8
+ * here once you create your first custom command (`bun strav make:command`).
9
+ */
10
+ export {}
File without changes