@strav/spring 0.3.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.
- package/README.md +61 -0
- package/package.json +31 -0
- package/src/index.ts +176 -0
- package/src/prompts.ts +135 -0
- package/src/scaffold.ts +54 -0
- package/src/templates/api/app/controllers/user_controller.ts +69 -0
- package/src/templates/api/config/http.ts +10 -0
- package/src/templates/api/index.ts +33 -0
- package/src/templates/api/routes/routes.ts +24 -0
- package/src/templates/shared/.env +14 -0
- package/src/templates/shared/app/controllers/controller.ts +15 -0
- package/src/templates/shared/app/models/user.ts +30 -0
- package/src/templates/shared/config/app.ts +10 -0
- package/src/templates/shared/config/database.ts +9 -0
- package/src/templates/shared/config/encryption.ts +5 -0
- package/src/templates/shared/database/factories/user_factory.ts +11 -0
- package/src/templates/shared/database/schemas/public/user.ts +13 -0
- package/src/templates/shared/database/seeders/database_seeder.ts +8 -0
- package/src/templates/shared/database/seeders/user_seeder.ts +15 -0
- package/src/templates/shared/package.json +24 -0
- package/src/templates/shared/routes/routes.ts +13 -0
- package/src/templates/shared/storage/cache/.gitkeep +1 -0
- package/src/templates/shared/storage/logs/.gitkeep +1 -0
- package/src/templates/shared/storage/uploads/.gitkeep +1 -0
- package/src/templates/shared/strav.ts +20 -0
- package/src/templates/shared/tests/example.test.ts +11 -0
- package/src/templates/shared/tsconfig.json +20 -0
- package/src/templates/web/app/controllers/home_controller.ts +24 -0
- package/src/templates/web/config/session.ts +10 -0
- package/src/templates/web/config/view.ts +7 -0
- package/src/templates/web/index.ts +48 -0
- package/src/templates/web/package.json +26 -0
- package/src/templates/web/resources/css/app.css +176 -0
- package/src/templates/web/resources/ts/islands/counter.vue +42 -0
- package/src/templates/web/resources/ts/islands/user_manager.vue +127 -0
- package/src/templates/web/resources/ts/islands/user_search.vue +71 -0
- package/src/templates/web/resources/views/layouts/app.strav +32 -0
- package/src/templates/web/resources/views/pages/home.strav +52 -0
- package/src/templates/web/resources/views/pages/users.strav +63 -0
- package/src/templates/web/routes/routes.ts +22 -0
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# @strav/spring
|
|
2
|
+
|
|
3
|
+
Flagship framework scaffolding tool for the Strav ecosystem - the Laravel of the Bun ecosystem.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bunx @strav/spring my-app --web # full-stack with Vue islands
|
|
9
|
+
bunx @strav/spring my-app --api # headless REST API
|
|
10
|
+
bunx @strav/spring my-app # interactive prompt
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Templates
|
|
14
|
+
|
|
15
|
+
- **api** — Headless REST API with CORS enabled
|
|
16
|
+
- **web** — Full-stack with .strav views, Vue islands, and sessions
|
|
17
|
+
|
|
18
|
+
## Options
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
bunx @strav/spring <project-name> [options]
|
|
22
|
+
|
|
23
|
+
--api Headless REST API template
|
|
24
|
+
--web Full-stack template with Vue islands
|
|
25
|
+
--template, -t api|web Alias for --api / --web
|
|
26
|
+
--db <name> Database name (default: project name)
|
|
27
|
+
-h, --help Show help
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## What's scaffolded
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
my-app/
|
|
34
|
+
├── app/
|
|
35
|
+
│ ├── controllers/ # HTTP controllers
|
|
36
|
+
│ ├── models/ # Database models (generated from schemas)
|
|
37
|
+
│ ├── middleware/ # Custom middleware
|
|
38
|
+
│ ├── providers/ # Service providers
|
|
39
|
+
│ ├── policies/ # Authorization policies
|
|
40
|
+
│ ├── jobs/ # Queue jobs
|
|
41
|
+
│ └── services/ # Business logic services
|
|
42
|
+
├── config/ # Configuration files
|
|
43
|
+
├── database/
|
|
44
|
+
│ ├── schemas/public/ # Schema definitions
|
|
45
|
+
│ ├── migrations/public/ # Generated migrations
|
|
46
|
+
│ ├── seeders/ # Database seeders
|
|
47
|
+
│ └── factories/ # Model factories
|
|
48
|
+
├── resources/
|
|
49
|
+
│ ├── views/ # .strav templates
|
|
50
|
+
│ ├── css/ # Stylesheets
|
|
51
|
+
│ └── ts/islands/ # Vue.js islands
|
|
52
|
+
├── routes/ # Route definitions
|
|
53
|
+
├── tests/ # Test files
|
|
54
|
+
├── index.ts # Application entry point
|
|
55
|
+
├── strav.ts # CLI tool
|
|
56
|
+
└── .env # Environment variables
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@strav/spring",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Flagship framework scaffolding tool for the Strav ecosystem.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"strav",
|
|
9
|
+
"bun",
|
|
10
|
+
"framework",
|
|
11
|
+
"scaffold",
|
|
12
|
+
"create",
|
|
13
|
+
"laravel",
|
|
14
|
+
"typescript",
|
|
15
|
+
"vue"
|
|
16
|
+
],
|
|
17
|
+
"bin": {
|
|
18
|
+
"@strav/spring": "./src/index.ts"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src/",
|
|
22
|
+
"package.json",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@strav/kernel": "0.3.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/bun": "latest"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { select, input } from './prompts.ts'
|
|
5
|
+
import { scaffold, type ScaffoldOptions } from './scaffold.ts'
|
|
6
|
+
import pkg from '../package.json'
|
|
7
|
+
|
|
8
|
+
const VERSION = pkg.version
|
|
9
|
+
|
|
10
|
+
// ── Colors ──────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`
|
|
13
|
+
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`
|
|
14
|
+
const green = (s: string) => `\x1b[32m${s}\x1b[0m`
|
|
15
|
+
const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`
|
|
16
|
+
const red = (s: string) => `\x1b[31m${s}\x1b[0m`
|
|
17
|
+
|
|
18
|
+
// ── Arg parsing ─────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
interface ParsedArgs {
|
|
21
|
+
projectName?: string
|
|
22
|
+
template?: 'api' | 'web'
|
|
23
|
+
db?: string
|
|
24
|
+
help?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseArgs(): ParsedArgs {
|
|
28
|
+
const args = process.argv.slice(2)
|
|
29
|
+
const result: ParsedArgs = {}
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
const arg = args[i]
|
|
33
|
+
|
|
34
|
+
if (arg === '--help' || arg === '-h') {
|
|
35
|
+
result.help = true
|
|
36
|
+
} else if (arg === '--api') {
|
|
37
|
+
result.template = 'api'
|
|
38
|
+
} else if (arg === '--web') {
|
|
39
|
+
result.template = 'web'
|
|
40
|
+
} else if (arg === '--template' || arg === '-t') {
|
|
41
|
+
const val = args[++i]
|
|
42
|
+
if (val === 'api' || val === 'web') {
|
|
43
|
+
result.template = val
|
|
44
|
+
} else {
|
|
45
|
+
console.error(red(` Invalid template: ${val}. Use "api" or "web".`))
|
|
46
|
+
process.exit(1)
|
|
47
|
+
}
|
|
48
|
+
} else if (arg === '--db') {
|
|
49
|
+
result.db = args[++i]
|
|
50
|
+
} else if (arg && !arg.startsWith('-') && !result.projectName) {
|
|
51
|
+
result.projectName = arg
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return result
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function printUsage(): void {
|
|
59
|
+
console.log(`
|
|
60
|
+
${bold('@strav/spring')} ${dim(`v${VERSION}`)}
|
|
61
|
+
${dim('The Laravel of the Bun ecosystem')}
|
|
62
|
+
|
|
63
|
+
${bold('Usage:')}
|
|
64
|
+
bunx @strav/spring ${cyan('<project-name>')} [options]
|
|
65
|
+
|
|
66
|
+
${bold('Options:')}
|
|
67
|
+
--api Headless REST API template
|
|
68
|
+
--web Full-stack template with Vue islands and views
|
|
69
|
+
--template, -t ${dim('api|web')} Alias for --api / --web
|
|
70
|
+
--db ${dim('<name>')} Database name (default: project name)
|
|
71
|
+
-h, --help Show this help message
|
|
72
|
+
|
|
73
|
+
${bold('Examples:')}
|
|
74
|
+
bunx @strav/spring my-blog --web
|
|
75
|
+
bunx @strav/spring my-api --api
|
|
76
|
+
bunx @strav/spring my-app ${dim('# interactive prompt')}
|
|
77
|
+
`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function toSnakeCase(name: string): string {
|
|
81
|
+
return name
|
|
82
|
+
.replace(/([A-Z])/g, '_$1')
|
|
83
|
+
.toLowerCase()
|
|
84
|
+
.replace(/[-\s]+/g, '_')
|
|
85
|
+
.replace(/^_+|_+$/g, '')
|
|
86
|
+
.replace(/_+/g, '_')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Main ────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
async function main(): Promise<void> {
|
|
92
|
+
const args = parseArgs()
|
|
93
|
+
|
|
94
|
+
if (args.help) {
|
|
95
|
+
printUsage()
|
|
96
|
+
process.exit(0)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log()
|
|
100
|
+
console.log(` ${bold('@strav/spring')} ${dim(`v${VERSION}`)}`)
|
|
101
|
+
console.log(` ${dim('The Laravel of the Bun ecosystem')}`)
|
|
102
|
+
console.log()
|
|
103
|
+
|
|
104
|
+
// Project name
|
|
105
|
+
if (!args.projectName) {
|
|
106
|
+
printUsage()
|
|
107
|
+
process.exit(1)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const projectName = args.projectName
|
|
111
|
+
const root = resolve(projectName)
|
|
112
|
+
|
|
113
|
+
// Validate
|
|
114
|
+
if (existsSync(root)) {
|
|
115
|
+
console.error(red(` Directory "${projectName}" already exists.`))
|
|
116
|
+
process.exit(1)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(projectName)) {
|
|
120
|
+
console.error(
|
|
121
|
+
red(` Invalid project name. Use only letters, numbers, hyphens, and underscores.`)
|
|
122
|
+
)
|
|
123
|
+
process.exit(1)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Template
|
|
127
|
+
let template = args.template
|
|
128
|
+
if (!template) {
|
|
129
|
+
template = (await select('Which template?', [
|
|
130
|
+
{ label: 'web', value: 'web', description: 'Full-stack with Vue islands, views, and sessions' },
|
|
131
|
+
{ label: 'api', value: 'api', description: 'Headless REST API with CORS enabled' },
|
|
132
|
+
])) as 'api' | 'web'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Database name
|
|
136
|
+
const defaultDb = toSnakeCase(projectName)
|
|
137
|
+
const dbName = args.db ?? defaultDb
|
|
138
|
+
|
|
139
|
+
console.log()
|
|
140
|
+
|
|
141
|
+
// Scaffold
|
|
142
|
+
const opts: ScaffoldOptions = { projectName, template, dbName }
|
|
143
|
+
await scaffold(root, opts)
|
|
144
|
+
console.log(` ${green('+')} Scaffolded project files`)
|
|
145
|
+
|
|
146
|
+
// Install dependencies
|
|
147
|
+
console.log(` ${dim('...')} Installing dependencies`)
|
|
148
|
+
const install = Bun.spawn(['bun', 'install'], { cwd: root, stdout: 'ignore', stderr: 'pipe' })
|
|
149
|
+
const exitCode = await install.exited
|
|
150
|
+
|
|
151
|
+
if (exitCode !== 0) {
|
|
152
|
+
const stderr = await new Response(install.stderr).text()
|
|
153
|
+
console.error(red(` Failed to install dependencies:`))
|
|
154
|
+
console.error(dim(` ${stderr}`))
|
|
155
|
+
process.exit(1)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
console.log(` ${green('+')} Installed dependencies`)
|
|
159
|
+
|
|
160
|
+
// Done
|
|
161
|
+
console.log()
|
|
162
|
+
console.log(` ${green('Project created successfully!')}`)
|
|
163
|
+
console.log()
|
|
164
|
+
console.log(` Next steps:`)
|
|
165
|
+
console.log()
|
|
166
|
+
console.log(` ${dim('$')} cd ${projectName}`)
|
|
167
|
+
console.log(` ${dim('$')} bun --hot index.ts`)
|
|
168
|
+
console.log()
|
|
169
|
+
console.log(` ${dim('Then open http://localhost:3000')}`)
|
|
170
|
+
console.log()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
main().catch(err => {
|
|
174
|
+
console.error(red(` Error: ${err instanceof Error ? err.message : err}`))
|
|
175
|
+
process.exit(1)
|
|
176
|
+
})
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
const ESC = '\x1b'
|
|
2
|
+
const ARROW_UP = `${ESC}[A`
|
|
3
|
+
const ARROW_DOWN = `${ESC}[B`
|
|
4
|
+
const ENTER = '\r'
|
|
5
|
+
|
|
6
|
+
interface Choice {
|
|
7
|
+
label: string
|
|
8
|
+
value: string
|
|
9
|
+
description: string
|
|
10
|
+
}
|
|
11
|
+
|
|
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`)
|
|
24
|
+
}
|
|
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
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
stdin.on('data', onData)
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
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
|
+
}
|
package/src/scaffold.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readdirSync, mkdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, dirname } from 'node:path'
|
|
3
|
+
import pkg from '../package.json'
|
|
4
|
+
|
|
5
|
+
export interface ScaffoldOptions {
|
|
6
|
+
projectName: string
|
|
7
|
+
template: 'api' | 'web'
|
|
8
|
+
dbName: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function scaffold(root: string, opts: ScaffoldOptions): Promise<void> {
|
|
12
|
+
const templatesDir = join(import.meta.dir, 'templates')
|
|
13
|
+
const appKey = crypto.randomUUID()
|
|
14
|
+
|
|
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}`,
|
|
20
|
+
}
|
|
21
|
+
|
|
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
|
+
}
|
|
26
|
+
|
|
27
|
+
async function copyDir(
|
|
28
|
+
srcDir: string,
|
|
29
|
+
destDir: string,
|
|
30
|
+
replacements: Record<string, string>
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
const entries = readdirSync(srcDir)
|
|
33
|
+
|
|
34
|
+
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))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
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)
|
|
52
|
+
}
|
|
53
|
+
return result
|
|
54
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Context } from '@strav/http'
|
|
2
|
+
import { Controller } from './controller.ts'
|
|
3
|
+
import User from '../models/user.ts'
|
|
4
|
+
|
|
5
|
+
export default class UserController extends Controller {
|
|
6
|
+
async index(ctx: Context) {
|
|
7
|
+
const users = await User.all()
|
|
8
|
+
return this.respond(ctx, { users })
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async show(ctx: Context) {
|
|
12
|
+
const { id } = ctx.params
|
|
13
|
+
const user = await User.find(id)
|
|
14
|
+
|
|
15
|
+
if (!user) {
|
|
16
|
+
return this.notFound(ctx, 'User not found')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return this.respond(ctx, { user })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async store(ctx: Context) {
|
|
23
|
+
const { email, name, password } = await ctx.request.json()
|
|
24
|
+
|
|
25
|
+
if (!email || !name || !password) {
|
|
26
|
+
return this.error(ctx, 'Email, name, and password are required')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const user = await User.create({
|
|
30
|
+
id: crypto.randomUUID(),
|
|
31
|
+
email,
|
|
32
|
+
name,
|
|
33
|
+
password_hash: await Bun.password.hash(password),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
return this.respond(ctx, { user }, 201)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async update(ctx: Context) {
|
|
40
|
+
const { id } = ctx.params
|
|
41
|
+
const user = await User.find(id)
|
|
42
|
+
|
|
43
|
+
if (!user) {
|
|
44
|
+
return this.notFound(ctx, 'User not found')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { email, name } = await ctx.request.json()
|
|
48
|
+
|
|
49
|
+
if (email) user.email = email
|
|
50
|
+
if (name) user.name = name
|
|
51
|
+
|
|
52
|
+
await user.save()
|
|
53
|
+
|
|
54
|
+
return this.respond(ctx, { user })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async destroy(ctx: Context) {
|
|
58
|
+
const { id } = ctx.params
|
|
59
|
+
const user = await User.find(id)
|
|
60
|
+
|
|
61
|
+
if (!user) {
|
|
62
|
+
return this.notFound(ctx, 'User not found')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await user.delete()
|
|
66
|
+
|
|
67
|
+
return this.respond(ctx, { message: 'User deleted successfully' })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import 'reflect-metadata'
|
|
2
|
+
import { app } from '@strav/kernel'
|
|
3
|
+
import { router } from '@strav/http'
|
|
4
|
+
import { ConfigProvider, EncryptionProvider } from '@strav/kernel'
|
|
5
|
+
import { DatabaseProvider } from '@strav/database'
|
|
6
|
+
import BaseModel from '@strav/database/orm/base_model'
|
|
7
|
+
import Database from '@strav/database/database/database'
|
|
8
|
+
import Server from '@strav/http/server'
|
|
9
|
+
import { ExceptionHandler } from '@strav/kernel'
|
|
10
|
+
|
|
11
|
+
// Register service providers
|
|
12
|
+
app
|
|
13
|
+
.use(new ConfigProvider())
|
|
14
|
+
.use(new DatabaseProvider())
|
|
15
|
+
.use(new EncryptionProvider())
|
|
16
|
+
|
|
17
|
+
// Boot services (loads config, connects database, derives encryption keys)
|
|
18
|
+
await app.start()
|
|
19
|
+
|
|
20
|
+
// Initialize ORM
|
|
21
|
+
new BaseModel(app.resolve(Database))
|
|
22
|
+
|
|
23
|
+
// Configure router for API
|
|
24
|
+
router.useExceptionHandler(new ExceptionHandler(true))
|
|
25
|
+
router.cors()
|
|
26
|
+
|
|
27
|
+
// Load routes
|
|
28
|
+
await import('./routes/routes')
|
|
29
|
+
|
|
30
|
+
// Start HTTP server
|
|
31
|
+
app.singleton(Server)
|
|
32
|
+
const server = app.resolve(Server)
|
|
33
|
+
server.start(router)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Router } from '@strav/http'
|
|
2
|
+
import UserController from '../app/controllers/user_controller.ts'
|
|
3
|
+
|
|
4
|
+
export default function (router: Router) {
|
|
5
|
+
// Health check endpoint
|
|
6
|
+
router.get('/health', async (ctx) => {
|
|
7
|
+
return ctx.json({
|
|
8
|
+
status: 'ok',
|
|
9
|
+
timestamp: new Date().toISOString(),
|
|
10
|
+
app: '__PROJECT_NAME__',
|
|
11
|
+
version: '0.1.0'
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
// API routes
|
|
16
|
+
router.group('/api/v1', () => {
|
|
17
|
+
// User resource routes
|
|
18
|
+
router.get('/users', [UserController, 'index'])
|
|
19
|
+
router.get('/users/:id', [UserController, 'show'])
|
|
20
|
+
router.post('/users', [UserController, 'store'])
|
|
21
|
+
router.put('/users/:id', [UserController, 'update'])
|
|
22
|
+
router.delete('/users/:id', [UserController, 'destroy'])
|
|
23
|
+
})
|
|
24
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
APP_ENV=local
|
|
2
|
+
APP_KEY=__APP_KEY__
|
|
3
|
+
APP_DEBUG=true
|
|
4
|
+
APP_URL=http://localhost:3000
|
|
5
|
+
APP_PORT=3000
|
|
6
|
+
|
|
7
|
+
DB_HOST=127.0.0.1
|
|
8
|
+
DB_PORT=5432
|
|
9
|
+
DB_USER=liva
|
|
10
|
+
DB_PASSWORD=password1234
|
|
11
|
+
DB_DATABASE=__DB_NAME__
|
|
12
|
+
|
|
13
|
+
SESSION_SECRET=__APP_KEY__
|
|
14
|
+
SESSION_COOKIE_NAME=session
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Context } from '@strav/http'
|
|
2
|
+
|
|
3
|
+
export abstract class Controller {
|
|
4
|
+
protected async respond<T>(ctx: Context, data: T, status = 200) {
|
|
5
|
+
return ctx.json(data, status)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
protected async error(ctx: Context, message: string, status = 400) {
|
|
9
|
+
return ctx.json({ error: message }, status)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
protected async notFound(ctx: Context, message = 'Not found') {
|
|
13
|
+
return ctx.json({ error: message }, 404)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Model, column } from '@strav/database'
|
|
2
|
+
|
|
3
|
+
export default class User extends Model {
|
|
4
|
+
@column({ isPrimary: true })
|
|
5
|
+
declare id: string
|
|
6
|
+
|
|
7
|
+
@column()
|
|
8
|
+
declare email: string
|
|
9
|
+
|
|
10
|
+
@column()
|
|
11
|
+
declare name: string
|
|
12
|
+
|
|
13
|
+
@column()
|
|
14
|
+
declare password_hash: string
|
|
15
|
+
|
|
16
|
+
@column()
|
|
17
|
+
declare email_verified_at: Date | null
|
|
18
|
+
|
|
19
|
+
@column()
|
|
20
|
+
declare remember_token: string | null
|
|
21
|
+
|
|
22
|
+
@column()
|
|
23
|
+
declare created_at: Date
|
|
24
|
+
|
|
25
|
+
@column()
|
|
26
|
+
declare updated_at: Date
|
|
27
|
+
|
|
28
|
+
@column()
|
|
29
|
+
declare deleted_at: Date | null
|
|
30
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { env } from '@strav/kernel'
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: '__PROJECT_NAME__',
|
|
5
|
+
env: env('APP_ENV', 'production'),
|
|
6
|
+
debug: env.bool('APP_DEBUG', false),
|
|
7
|
+
url: env('APP_URL', 'http://localhost:3000'),
|
|
8
|
+
port: env.int('APP_PORT', 3000),
|
|
9
|
+
key: env('APP_KEY'),
|
|
10
|
+
}
|