@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.
- package/README.md +11 -52
- package/package.json +19 -20
- package/src/args.ts +119 -0
- package/src/cli.ts +134 -0
- package/src/index.ts +10 -176
- package/src/prompts.ts +49 -127
- package/src/scaffold.ts +115 -36
- package/src/spring_error.ts +11 -0
- package/src/templates/shared/README.md.tt +37 -0
- package/src/templates/shared/_dot_env.example.tt +12 -0
- package/src/templates/shared/_dot_env.tt +12 -0
- package/src/templates/shared/_dot_gitignore +12 -0
- package/src/templates/shared/app/console/_dot_gitkeep +0 -0
- package/src/templates/shared/app/exceptions/_dot_gitkeep +0 -0
- package/src/templates/shared/app/http/controllers/_dot_gitkeep +0 -0
- package/src/templates/shared/app/http/middleware/_dot_gitkeep +0 -0
- package/src/templates/shared/app/http/requests/_dot_gitkeep +0 -0
- package/src/templates/shared/app/jobs/_dot_gitkeep +0 -0
- package/src/templates/shared/app/mail/_dot_gitkeep +0 -0
- package/src/templates/shared/app/models/_dot_gitkeep +0 -0
- package/src/templates/shared/app/notifications/_dot_gitkeep +0 -0
- package/src/templates/shared/app/policies/_dot_gitkeep +0 -0
- package/src/templates/shared/app/providers/app_provider.ts +18 -0
- package/src/templates/shared/app/repositories/_dot_gitkeep +0 -0
- package/src/templates/shared/bin/strav.ts +21 -0
- package/src/templates/shared/bootstrap/app.ts +13 -0
- package/src/templates/shared/bootstrap/providers.ts +24 -0
- package/src/templates/shared/config/app.ts.tt +13 -0
- package/src/templates/shared/config/http.ts +9 -0
- package/src/templates/shared/config/logger.ts +9 -0
- package/src/templates/shared/database/factories/_dot_gitkeep +0 -0
- package/src/templates/shared/database/migrations/_dot_gitkeep +0 -0
- package/src/templates/shared/database/schemas/_dot_gitkeep +0 -0
- package/src/templates/shared/database/seeders/_dot_gitkeep +0 -0
- package/src/templates/shared/package.json.tt +22 -0
- package/src/templates/shared/routes/api.ts +11 -0
- package/src/templates/shared/routes/console.ts +10 -0
- package/src/templates/shared/storage/cache/_dot_gitkeep +0 -0
- package/src/templates/shared/storage/logs/_dot_gitkeep +0 -0
- package/src/templates/shared/storage/uploads/_dot_gitkeep +0 -0
- package/src/templates/shared/tests/feature/healthz.test.ts.tt +19 -0
- package/src/templates/shared/tests/unit/_dot_gitkeep +0 -0
- package/src/templates/shared/tsconfig.json +21 -11
- package/src/templates/web/README.md.tt +42 -0
- package/src/templates/web/_dot_gitignore +13 -0
- package/src/templates/web/app/providers/app_provider.ts +21 -0
- package/src/templates/web/bootstrap/providers.ts +31 -0
- package/src/templates/web/config/http.ts +17 -8
- package/src/templates/web/config/view.ts +26 -7
- package/src/templates/web/package.json.tt +26 -0
- package/src/templates/web/public/assets/_dot_gitkeep +0 -0
- package/src/templates/web/resources/css/app.css +32 -0
- package/src/templates/web/resources/views/components/_dot_gitkeep +0 -0
- package/src/templates/web/resources/views/errors/404.strav +8 -0
- package/src/templates/web/resources/views/errors/500.strav +8 -0
- package/src/templates/web/resources/views/layouts/app.strav.tt +46 -0
- package/src/templates/web/resources/views/pages/index.strav.tt +47 -0
- package/src/templates/web/routes/broadcast.ts +9 -0
- package/src/templates/web/routes/web.ts +19 -0
- package/src/templates/web/tests/browser/_dot_gitkeep +0 -0
- package/src/version.ts +9 -0
- package/src/templates/api/app/controllers/controller.ts +0 -15
- package/src/templates/api/app/controllers/user_controller.ts +0 -69
- package/src/templates/api/config/database.ts +0 -9
- package/src/templates/api/config/http.ts +0 -17
- package/src/templates/api/database/factories/user_factory.ts +0 -11
- package/src/templates/api/database/schemas/user.ts +0 -13
- package/src/templates/api/database/seeders/database_seeder.ts +0 -8
- package/src/templates/api/database/seeders/user_seeder.ts +0 -15
- package/src/templates/api/index.ts +0 -11
- package/src/templates/api/package.json +0 -24
- package/src/templates/api/start/providers.ts +0 -10
- package/src/templates/api/start/routes.ts +0 -22
- package/src/templates/shared/config/app.ts +0 -7
- package/src/templates/shared/config/encryption.ts +0 -5
- package/src/templates/shared/package.json +0 -24
- package/src/templates/shared/storage/uploads/.gitkeep +0 -1
- package/src/templates/shared/strav.ts +0 -2
- package/src/templates/shared/tests/example.test.ts +0 -11
- package/src/templates/web/index.ts +0 -28
- package/src/templates/web/package.json +0 -26
- package/src/templates/web/public/builds/.gitkeep +0 -1
- package/src/templates/web/public/css/.gitkeep +0 -1
- package/src/templates/web/resources/css/app.scss +0 -77
- package/src/templates/web/resources/islands/counter.vue +0 -31
- package/src/templates/web/resources/views/layouts/app.strav +0 -18
- package/src/templates/web/resources/views/pages/index.strav +0 -32
- package/src/templates/web/start/providers.ts +0 -11
package/src/prompts.ts
CHANGED
|
@@ -1,135 +1,57 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
9
|
-
description: string
|
|
18
|
+
description?: string
|
|
10
19
|
}
|
|
11
20
|
|
|
12
|
-
export async function select
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
82
|
-
}
|
|
43
|
+
} finally {
|
|
44
|
+
rl.close()
|
|
45
|
+
}
|
|
83
46
|
}
|
|
84
47
|
|
|
85
|
-
export async function input(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
38
|
+
export interface ScaffoldResult {
|
|
39
|
+
/** Project-root-relative paths of every file written. */
|
|
40
|
+
files: readonly string[]
|
|
41
|
+
}
|
|
14
42
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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}}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|
+
}
|
|
File without changes
|
|
@@ -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
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|