@stravigor/create 0.1.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 +42 -0
- package/package.json +12 -0
- package/src/index.ts +156 -0
- package/src/prompts.ts +134 -0
- package/src/scaffold.ts +20 -0
- package/src/templates/api.ts +80 -0
- package/src/templates/shared.ts +152 -0
- package/src/templates/web.ts +191 -0
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# @stravigor/create
|
|
2
|
+
|
|
3
|
+
Scaffold a new [Strav](https://www.npmjs.com/package/@stravigor/core) application.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bunx @stravigor/create my-app
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Templates
|
|
12
|
+
|
|
13
|
+
- **api** — Headless REST API with CORS enabled
|
|
14
|
+
- **web** — Full-stack with `.strav` views, sessions, and static files
|
|
15
|
+
|
|
16
|
+
## Options
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
bunx @stravigor/create <project-name> [options]
|
|
20
|
+
|
|
21
|
+
--template, -t api|web Template to use (default: prompt)
|
|
22
|
+
--db <name> Database name (default: project name)
|
|
23
|
+
-h, --help Show help
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## What's scaffolded
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
my-app/
|
|
30
|
+
├── index.ts Server entry point
|
|
31
|
+
├── strav.ts CLI (migrations, generators)
|
|
32
|
+
├── config/ Configuration files
|
|
33
|
+
├── database/schemas/ Schema definitions
|
|
34
|
+
├── start/routes.ts Route registration
|
|
35
|
+
├── tests/ Test files
|
|
36
|
+
├── .env Environment variables
|
|
37
|
+
└── package.json
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## License
|
|
41
|
+
|
|
42
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stravigor/create",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Scaffold a new Strav application",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": ["strav", "stravigor", "bun", "framework", "scaffold", "create"],
|
|
8
|
+
"bin": {
|
|
9
|
+
"@stravigor/create": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": ["src/", "package.json", "README.md"]
|
|
12
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
import { select, input } from './prompts.ts'
|
|
5
|
+
import { scaffold } from './scaffold.ts'
|
|
6
|
+
import type { ScaffoldOptions } from './templates/shared.ts'
|
|
7
|
+
|
|
8
|
+
const VERSION = '0.1.0'
|
|
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 === '--template' || arg === '-t') {
|
|
37
|
+
const val = args[++i]
|
|
38
|
+
if (val === 'api' || val === 'web') {
|
|
39
|
+
result.template = val
|
|
40
|
+
} else {
|
|
41
|
+
console.error(red(` Invalid template: ${val}. Use "api" or "web".`))
|
|
42
|
+
process.exit(1)
|
|
43
|
+
}
|
|
44
|
+
} else if (arg === '--db') {
|
|
45
|
+
result.db = args[++i]
|
|
46
|
+
} else if (!arg.startsWith('-') && !result.projectName) {
|
|
47
|
+
result.projectName = arg
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return result
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function printUsage(): void {
|
|
55
|
+
console.log(`
|
|
56
|
+
${bold('@stravigor/create')} ${dim(`v${VERSION}`)}
|
|
57
|
+
|
|
58
|
+
${bold('Usage:')}
|
|
59
|
+
bunx @stravigor/create ${cyan('<project-name>')} [options]
|
|
60
|
+
|
|
61
|
+
${bold('Options:')}
|
|
62
|
+
--template, -t ${dim('api|web')} Template to use (default: prompt)
|
|
63
|
+
--db ${dim('<name>')} Database name (default: project name)
|
|
64
|
+
-h, --help Show this help message
|
|
65
|
+
`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toSnakeCase(name: string): string {
|
|
69
|
+
return name.replace(/-/g, '_')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Main ────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
async function main(): Promise<void> {
|
|
75
|
+
const args = parseArgs()
|
|
76
|
+
|
|
77
|
+
if (args.help) {
|
|
78
|
+
printUsage()
|
|
79
|
+
process.exit(0)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log()
|
|
83
|
+
console.log(` ${bold('@stravigor/create')} ${dim(`v${VERSION}`)}`)
|
|
84
|
+
console.log()
|
|
85
|
+
|
|
86
|
+
// Project name
|
|
87
|
+
if (!args.projectName) {
|
|
88
|
+
printUsage()
|
|
89
|
+
process.exit(1)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const projectName = args.projectName
|
|
93
|
+
const root = resolve(projectName)
|
|
94
|
+
|
|
95
|
+
// Validate
|
|
96
|
+
if (existsSync(root)) {
|
|
97
|
+
console.error(red(` Directory "${projectName}" already exists.`))
|
|
98
|
+
process.exit(1)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(projectName)) {
|
|
102
|
+
console.error(red(` Invalid project name. Use only letters, numbers, hyphens, and underscores.`))
|
|
103
|
+
process.exit(1)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Template
|
|
107
|
+
let template = args.template
|
|
108
|
+
if (!template) {
|
|
109
|
+
template = await select('Which template?', [
|
|
110
|
+
{ label: 'api', value: 'api', description: 'Headless REST API' },
|
|
111
|
+
{ label: 'web', value: 'web', description: 'Full-stack with views and static files' },
|
|
112
|
+
]) as 'api' | 'web'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Database name
|
|
116
|
+
const defaultDb = toSnakeCase(projectName)
|
|
117
|
+
const dbName = args.db ?? await input('Database name:', defaultDb)
|
|
118
|
+
|
|
119
|
+
console.log()
|
|
120
|
+
|
|
121
|
+
// Scaffold
|
|
122
|
+
const opts: ScaffoldOptions = { projectName, template, dbName }
|
|
123
|
+
await scaffold(root, opts)
|
|
124
|
+
console.log(` ${green('+')} Scaffolded project files`)
|
|
125
|
+
|
|
126
|
+
// Install dependencies
|
|
127
|
+
console.log(` ${dim('...')} Installing dependencies`)
|
|
128
|
+
const install = Bun.spawn(['bun', 'install'], { cwd: root, stdout: 'ignore', stderr: 'pipe' })
|
|
129
|
+
const exitCode = await install.exited
|
|
130
|
+
|
|
131
|
+
if (exitCode !== 0) {
|
|
132
|
+
const stderr = await new Response(install.stderr).text()
|
|
133
|
+
console.error(red(` Failed to install dependencies:`))
|
|
134
|
+
console.error(dim(` ${stderr}`))
|
|
135
|
+
process.exit(1)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
console.log(` ${green('+')} Installed dependencies`)
|
|
139
|
+
|
|
140
|
+
// Done
|
|
141
|
+
console.log()
|
|
142
|
+
console.log(` ${green('Project created successfully!')}`)
|
|
143
|
+
console.log()
|
|
144
|
+
console.log(` Next steps:`)
|
|
145
|
+
console.log()
|
|
146
|
+
console.log(` ${dim('$')} cd ${projectName}`)
|
|
147
|
+
console.log(` ${dim('$')} bun --hot index.ts`)
|
|
148
|
+
console.log()
|
|
149
|
+
console.log(` ${dim('Then open http://localhost:3000')}`)
|
|
150
|
+
console.log()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
main().catch((err) => {
|
|
154
|
+
console.error(red(` Error: ${err instanceof Error ? err.message : err}`))
|
|
155
|
+
process.exit(1)
|
|
156
|
+
})
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
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 label = i === selected ? `\x1b[1m${choices[i].label}\x1b[0m` : choices[i].label
|
|
21
|
+
const desc = `\x1b[2m${choices[i].description}\x1b[0m`
|
|
22
|
+
process.stdout.write(`\x1b[2K ${prefix} ${label} ${desc}\n`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
process.stdout.write(` \x1b[1m${message}\x1b[0m\n`)
|
|
27
|
+
// Print initial lines so render() can overwrite them
|
|
28
|
+
for (const choice of choices) {
|
|
29
|
+
process.stdout.write('\n')
|
|
30
|
+
}
|
|
31
|
+
render()
|
|
32
|
+
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const stdin = process.stdin
|
|
35
|
+
stdin.setRawMode(true)
|
|
36
|
+
stdin.resume()
|
|
37
|
+
stdin.setEncoding('utf8')
|
|
38
|
+
|
|
39
|
+
let buffer = ''
|
|
40
|
+
|
|
41
|
+
const onData = (data: string) => {
|
|
42
|
+
buffer += data
|
|
43
|
+
|
|
44
|
+
// Check for Ctrl+C
|
|
45
|
+
if (buffer.includes('\x03')) {
|
|
46
|
+
stdin.setRawMode(false)
|
|
47
|
+
stdin.pause()
|
|
48
|
+
stdin.removeListener('data', onData)
|
|
49
|
+
process.stdout.write('\n')
|
|
50
|
+
process.exit(0)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Process escape sequences
|
|
54
|
+
while (buffer.length > 0) {
|
|
55
|
+
if (buffer.startsWith(ARROW_UP)) {
|
|
56
|
+
selected = (selected - 1 + choices.length) % choices.length
|
|
57
|
+
render()
|
|
58
|
+
buffer = buffer.slice(ARROW_UP.length)
|
|
59
|
+
} else if (buffer.startsWith(ARROW_DOWN)) {
|
|
60
|
+
selected = (selected + 1) % choices.length
|
|
61
|
+
render()
|
|
62
|
+
buffer = buffer.slice(ARROW_DOWN.length)
|
|
63
|
+
} else if (buffer.startsWith(ENTER)) {
|
|
64
|
+
stdin.setRawMode(false)
|
|
65
|
+
stdin.pause()
|
|
66
|
+
stdin.removeListener('data', onData)
|
|
67
|
+
resolve(choices[selected].value)
|
|
68
|
+
buffer = ''
|
|
69
|
+
return
|
|
70
|
+
} else if (buffer.startsWith(ESC)) {
|
|
71
|
+
// Incomplete escape sequence, wait for more data
|
|
72
|
+
break
|
|
73
|
+
} else {
|
|
74
|
+
// Discard unrecognized input
|
|
75
|
+
buffer = buffer.slice(1)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
stdin.on('data', onData)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function input(message: string, defaultValue: string): Promise<string> {
|
|
85
|
+
process.stdout.write(` \x1b[1m${message}\x1b[0m \x1b[2m(${defaultValue})\x1b[0m `)
|
|
86
|
+
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
const stdin = process.stdin
|
|
89
|
+
stdin.setRawMode(true)
|
|
90
|
+
stdin.resume()
|
|
91
|
+
stdin.setEncoding('utf8')
|
|
92
|
+
|
|
93
|
+
let value = ''
|
|
94
|
+
|
|
95
|
+
const onData = (data: string) => {
|
|
96
|
+
for (const char of data) {
|
|
97
|
+
if (char === '\x03') {
|
|
98
|
+
// Ctrl+C
|
|
99
|
+
stdin.setRawMode(false)
|
|
100
|
+
stdin.pause()
|
|
101
|
+
stdin.removeListener('data', onData)
|
|
102
|
+
process.stdout.write('\n')
|
|
103
|
+
process.exit(0)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (char === '\r' || char === '\n') {
|
|
107
|
+
stdin.setRawMode(false)
|
|
108
|
+
stdin.pause()
|
|
109
|
+
stdin.removeListener('data', onData)
|
|
110
|
+
process.stdout.write('\n')
|
|
111
|
+
resolve(value || defaultValue)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (char === '\x7f' || char === '\b') {
|
|
116
|
+
// Backspace
|
|
117
|
+
if (value.length > 0) {
|
|
118
|
+
value = value.slice(0, -1)
|
|
119
|
+
process.stdout.write('\b \b')
|
|
120
|
+
}
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Printable characters
|
|
125
|
+
if (char >= ' ') {
|
|
126
|
+
value += char
|
|
127
|
+
process.stdout.write(char)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
stdin.on('data', onData)
|
|
133
|
+
})
|
|
134
|
+
}
|
package/src/scaffold.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs'
|
|
2
|
+
import { join, dirname } from 'node:path'
|
|
3
|
+
import { getSharedFiles, type ScaffoldOptions } from './templates/shared.ts'
|
|
4
|
+
import { getApiFiles } from './templates/api.ts'
|
|
5
|
+
import { getWebFiles } from './templates/web.ts'
|
|
6
|
+
|
|
7
|
+
export async function scaffold(root: string, opts: ScaffoldOptions): Promise<void> {
|
|
8
|
+
// Collect all files
|
|
9
|
+
const files = [
|
|
10
|
+
...getSharedFiles(opts),
|
|
11
|
+
...(opts.template === 'web' ? getWebFiles(opts) : getApiFiles(opts)),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
// Create directories and write files
|
|
15
|
+
for (const file of files) {
|
|
16
|
+
const fullPath = join(root, file.path)
|
|
17
|
+
mkdirSync(dirname(fullPath), { recursive: true })
|
|
18
|
+
await Bun.write(fullPath, file.content)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { TemplateFile, ScaffoldOptions } from './shared.ts'
|
|
2
|
+
|
|
3
|
+
export function getApiFiles(opts: ScaffoldOptions): TemplateFile[] {
|
|
4
|
+
return [
|
|
5
|
+
{ path: 'index.ts', content: indexTs(opts) },
|
|
6
|
+
{ path: 'config/http.ts', content: configHttp() },
|
|
7
|
+
{ path: 'start/routes.ts', content: routes(opts) },
|
|
8
|
+
]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function indexTs(opts: ScaffoldOptions): string {
|
|
12
|
+
return `import 'reflect-metadata'
|
|
13
|
+
import { app } from '@stravigor/core/core'
|
|
14
|
+
import Configuration from '@stravigor/core/config/configuration'
|
|
15
|
+
import Database from '@stravigor/core/database/database'
|
|
16
|
+
import BaseModel from '@stravigor/core/orm/base_model'
|
|
17
|
+
import Router from '@stravigor/core/http/router'
|
|
18
|
+
import Server from '@stravigor/core/http/server'
|
|
19
|
+
import { ExceptionHandler } from '@stravigor/core/exceptions'
|
|
20
|
+
import EncryptionManager from '@stravigor/core/encryption/encryption_manager'
|
|
21
|
+
|
|
22
|
+
async function boot() {
|
|
23
|
+
const config = new Configuration('./config')
|
|
24
|
+
await config.load()
|
|
25
|
+
app.singleton(Configuration, () => config)
|
|
26
|
+
|
|
27
|
+
app.singleton(Database)
|
|
28
|
+
const db = app.resolve(Database)
|
|
29
|
+
new BaseModel(db)
|
|
30
|
+
|
|
31
|
+
app.singleton(EncryptionManager)
|
|
32
|
+
app.resolve(EncryptionManager)
|
|
33
|
+
|
|
34
|
+
app.singleton(Router)
|
|
35
|
+
const router = app.resolve(Router)
|
|
36
|
+
|
|
37
|
+
const handler = new ExceptionHandler(config.get('app.env') === 'local')
|
|
38
|
+
router.useExceptionHandler(handler)
|
|
39
|
+
router.cors()
|
|
40
|
+
|
|
41
|
+
await import('./start/routes')
|
|
42
|
+
|
|
43
|
+
app.singleton(Server)
|
|
44
|
+
const server = app.resolve(Server)
|
|
45
|
+
server.start(router)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
boot().catch((err) => {
|
|
49
|
+
console.error('Failed to boot:', err)
|
|
50
|
+
process.exit(1)
|
|
51
|
+
})
|
|
52
|
+
`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function configHttp(): string {
|
|
56
|
+
return `import { env } from '@stravigor/core/helpers/env'
|
|
57
|
+
|
|
58
|
+
export default {
|
|
59
|
+
host: env('HOST', '0.0.0.0'),
|
|
60
|
+
port: env.int('PORT', 3000),
|
|
61
|
+
domain: env('DOMAIN', 'localhost'),
|
|
62
|
+
}
|
|
63
|
+
`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function routes(opts: ScaffoldOptions): string {
|
|
67
|
+
return `import { router } from '@stravigor/core/http'
|
|
68
|
+
|
|
69
|
+
router.get('/', () => {
|
|
70
|
+
return Response.json({
|
|
71
|
+
name: '${opts.projectName}',
|
|
72
|
+
status: 'running',
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
router.get('/health', () => {
|
|
77
|
+
return Response.json({ status: 'ok' })
|
|
78
|
+
})
|
|
79
|
+
`
|
|
80
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
export interface TemplateFile {
|
|
2
|
+
path: string
|
|
3
|
+
content: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface ScaffoldOptions {
|
|
7
|
+
projectName: string
|
|
8
|
+
template: 'api' | 'web'
|
|
9
|
+
dbName: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getSharedFiles(opts: ScaffoldOptions): TemplateFile[] {
|
|
13
|
+
const appKey = crypto.randomUUID()
|
|
14
|
+
|
|
15
|
+
return [
|
|
16
|
+
{ path: 'package.json', content: packageJson(opts) },
|
|
17
|
+
{ path: 'tsconfig.json', content: tsconfig() },
|
|
18
|
+
{ path: '.env', content: dotEnv(opts, appKey) },
|
|
19
|
+
{ path: '.gitignore', content: gitignore() },
|
|
20
|
+
{ path: 'strav.ts', content: stravTs() },
|
|
21
|
+
{ path: 'config/app.ts', content: configApp() },
|
|
22
|
+
{ path: 'config/database.ts', content: configDatabase(opts) },
|
|
23
|
+
{ path: 'config/encryption.ts', content: configEncryption() },
|
|
24
|
+
{ path: 'database/schemas/.gitkeep', content: '' },
|
|
25
|
+
{ path: 'tests/health.test.ts', content: healthTest(opts) },
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function packageJson(opts: ScaffoldOptions): string {
|
|
30
|
+
return JSON.stringify({
|
|
31
|
+
name: opts.projectName,
|
|
32
|
+
version: '0.0.1',
|
|
33
|
+
type: 'module',
|
|
34
|
+
private: true,
|
|
35
|
+
scripts: {
|
|
36
|
+
dev: 'bun --hot index.ts',
|
|
37
|
+
start: 'bun index.ts',
|
|
38
|
+
test: 'bun test tests/',
|
|
39
|
+
},
|
|
40
|
+
dependencies: {
|
|
41
|
+
'@stravigor/core': '^0.1.0',
|
|
42
|
+
'luxon': '^3.7.2',
|
|
43
|
+
'reflect-metadata': '^0.2.2',
|
|
44
|
+
},
|
|
45
|
+
devDependencies: {
|
|
46
|
+
'@types/bun': 'latest',
|
|
47
|
+
'@types/luxon': '^3.7.1',
|
|
48
|
+
'@stravigor/testing': '^0.1.0',
|
|
49
|
+
},
|
|
50
|
+
}, null, 2) + '\n'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function tsconfig(): string {
|
|
54
|
+
return JSON.stringify({
|
|
55
|
+
compilerOptions: {
|
|
56
|
+
lib: ['ESNext'],
|
|
57
|
+
target: 'ESNext',
|
|
58
|
+
module: 'ESNext',
|
|
59
|
+
moduleDetection: 'force',
|
|
60
|
+
allowJs: true,
|
|
61
|
+
moduleResolution: 'bundler',
|
|
62
|
+
allowImportingTsExtensions: true,
|
|
63
|
+
noEmit: true,
|
|
64
|
+
experimentalDecorators: true,
|
|
65
|
+
emitDecoratorMetadata: true,
|
|
66
|
+
strict: true,
|
|
67
|
+
skipLibCheck: true,
|
|
68
|
+
noFallthroughCasesInSwitch: true,
|
|
69
|
+
noUnusedLocals: false,
|
|
70
|
+
noUnusedParameters: false,
|
|
71
|
+
},
|
|
72
|
+
include: ['**/*.ts'],
|
|
73
|
+
}, null, 2) + '\n'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function dotEnv(opts: ScaffoldOptions, appKey: string): string {
|
|
77
|
+
return `APP_ENV=local
|
|
78
|
+
APP_DEBUG=true
|
|
79
|
+
APP_KEY=${appKey}
|
|
80
|
+
|
|
81
|
+
HOST=0.0.0.0
|
|
82
|
+
PORT=3000
|
|
83
|
+
DOMAIN=localhost
|
|
84
|
+
|
|
85
|
+
DB_HOST=127.0.0.1
|
|
86
|
+
DB_PORT=5432
|
|
87
|
+
DB_USERNAME=postgres
|
|
88
|
+
DB_PASSWORD=
|
|
89
|
+
DB_DATABASE=${opts.dbName}
|
|
90
|
+
`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function gitignore(): string {
|
|
94
|
+
return `node_modules/
|
|
95
|
+
.env
|
|
96
|
+
app/
|
|
97
|
+
database/migrations/
|
|
98
|
+
*.log
|
|
99
|
+
`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function stravTs(): string {
|
|
103
|
+
return `#!/usr/bin/env bun
|
|
104
|
+
import '@stravigor/core/cli/strav'
|
|
105
|
+
`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function configApp(): string {
|
|
109
|
+
return `import { env } from '@stravigor/core/helpers/env'
|
|
110
|
+
|
|
111
|
+
export default {
|
|
112
|
+
env: env('APP_ENV', 'local'),
|
|
113
|
+
debug: env.bool('APP_DEBUG', true),
|
|
114
|
+
key: env('APP_KEY'),
|
|
115
|
+
}
|
|
116
|
+
`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function configDatabase(opts: ScaffoldOptions): string {
|
|
120
|
+
return `import { env } from '@stravigor/core/helpers/env'
|
|
121
|
+
|
|
122
|
+
export default {
|
|
123
|
+
host: env('DB_HOST', '127.0.0.1'),
|
|
124
|
+
port: env.int('DB_PORT', 5432),
|
|
125
|
+
username: env('DB_USERNAME', 'postgres'),
|
|
126
|
+
password: env('DB_PASSWORD', ''),
|
|
127
|
+
database: env('DB_DATABASE', '${opts.dbName}'),
|
|
128
|
+
}
|
|
129
|
+
`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function configEncryption(): string {
|
|
133
|
+
return `import { env } from '@stravigor/core/helpers/env'
|
|
134
|
+
|
|
135
|
+
export default {
|
|
136
|
+
key: env('APP_KEY'),
|
|
137
|
+
previousKeys: [],
|
|
138
|
+
}
|
|
139
|
+
`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function healthTest(opts: ScaffoldOptions): string {
|
|
143
|
+
const path = opts.template === 'web' ? '/api/health' : '/health'
|
|
144
|
+
return `import { test, expect } from 'bun:test'
|
|
145
|
+
|
|
146
|
+
test('health check returns ok', async () => {
|
|
147
|
+
const res = await fetch('http://localhost:3000${path}')
|
|
148
|
+
const json = await res.json()
|
|
149
|
+
expect(json.status).toBe('ok')
|
|
150
|
+
})
|
|
151
|
+
`
|
|
152
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { TemplateFile, ScaffoldOptions } from './shared.ts'
|
|
2
|
+
|
|
3
|
+
export function getWebFiles(opts: ScaffoldOptions): TemplateFile[] {
|
|
4
|
+
return [
|
|
5
|
+
{ path: 'index.ts', content: indexTs(opts) },
|
|
6
|
+
{ path: 'config/http.ts', content: configHttp() },
|
|
7
|
+
{ path: 'config/session.ts', content: configSession() },
|
|
8
|
+
{ path: 'config/view.ts', content: configView() },
|
|
9
|
+
{ path: 'start/routes.ts', content: routes(opts) },
|
|
10
|
+
{ path: 'views/welcome.strav', content: welcomeView() },
|
|
11
|
+
{ path: 'public/styles.css', content: stylesCss() },
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function indexTs(opts: ScaffoldOptions): string {
|
|
16
|
+
return `import 'reflect-metadata'
|
|
17
|
+
import { app } from '@stravigor/core/core'
|
|
18
|
+
import Configuration from '@stravigor/core/config/configuration'
|
|
19
|
+
import Database from '@stravigor/core/database/database'
|
|
20
|
+
import BaseModel from '@stravigor/core/orm/base_model'
|
|
21
|
+
import Router from '@stravigor/core/http/router'
|
|
22
|
+
import Server from '@stravigor/core/http/server'
|
|
23
|
+
import { ExceptionHandler } from '@stravigor/core/exceptions'
|
|
24
|
+
import EncryptionManager from '@stravigor/core/encryption/encryption_manager'
|
|
25
|
+
import SessionManager from '@stravigor/core/session/session_manager'
|
|
26
|
+
import { ViewEngine } from '@stravigor/core/view'
|
|
27
|
+
|
|
28
|
+
async function boot() {
|
|
29
|
+
const config = new Configuration('./config')
|
|
30
|
+
await config.load()
|
|
31
|
+
app.singleton(Configuration, () => config)
|
|
32
|
+
|
|
33
|
+
app.singleton(Database)
|
|
34
|
+
const db = app.resolve(Database)
|
|
35
|
+
new BaseModel(db)
|
|
36
|
+
|
|
37
|
+
app.singleton(EncryptionManager)
|
|
38
|
+
app.resolve(EncryptionManager)
|
|
39
|
+
|
|
40
|
+
app.singleton(SessionManager)
|
|
41
|
+
app.resolve(SessionManager)
|
|
42
|
+
|
|
43
|
+
app.singleton(ViewEngine)
|
|
44
|
+
app.resolve(ViewEngine)
|
|
45
|
+
|
|
46
|
+
app.singleton(Router)
|
|
47
|
+
const router = app.resolve(Router)
|
|
48
|
+
|
|
49
|
+
const handler = new ExceptionHandler(config.get('app.env') === 'local')
|
|
50
|
+
router.useExceptionHandler(handler)
|
|
51
|
+
|
|
52
|
+
await import('./start/routes')
|
|
53
|
+
|
|
54
|
+
app.singleton(Server)
|
|
55
|
+
const server = app.resolve(Server)
|
|
56
|
+
server.start(router)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
boot().catch((err) => {
|
|
60
|
+
console.error('Failed to boot:', err)
|
|
61
|
+
process.exit(1)
|
|
62
|
+
})
|
|
63
|
+
`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function configHttp(): string {
|
|
67
|
+
return `import { env } from '@stravigor/core/helpers/env'
|
|
68
|
+
|
|
69
|
+
export default {
|
|
70
|
+
host: env('HOST', '0.0.0.0'),
|
|
71
|
+
port: env.int('PORT', 3000),
|
|
72
|
+
domain: env('DOMAIN', 'localhost'),
|
|
73
|
+
public: './public',
|
|
74
|
+
}
|
|
75
|
+
`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function configSession(): string {
|
|
79
|
+
return `export default {
|
|
80
|
+
cookie: 'strav_session',
|
|
81
|
+
lifetime: 120,
|
|
82
|
+
httpOnly: true,
|
|
83
|
+
secure: false,
|
|
84
|
+
sameSite: 'lax' as const,
|
|
85
|
+
}
|
|
86
|
+
`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function configView(): string {
|
|
90
|
+
return `export default {
|
|
91
|
+
directory: 'views',
|
|
92
|
+
cache: false,
|
|
93
|
+
}
|
|
94
|
+
`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function routes(opts: ScaffoldOptions): string {
|
|
98
|
+
return `import { router } from '@stravigor/core/http'
|
|
99
|
+
import { view } from '@stravigor/core/view'
|
|
100
|
+
|
|
101
|
+
router.get('/', async () => {
|
|
102
|
+
return view('welcome', { name: '${opts.projectName}' })
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
router.get('/api/health', () => {
|
|
106
|
+
return Response.json({ status: 'ok' })
|
|
107
|
+
})
|
|
108
|
+
`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function welcomeView(): string {
|
|
112
|
+
return `<!DOCTYPE html>
|
|
113
|
+
<html lang="en">
|
|
114
|
+
<head>
|
|
115
|
+
<meta charset="UTF-8">
|
|
116
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
117
|
+
<title>{{ name }} — Strav</title>
|
|
118
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
119
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
120
|
+
<link href="https://fonts.googleapis.com/css2?family=Barlow+Semi+Condensed:wght@400;600;700&display=swap" rel="stylesheet">
|
|
121
|
+
<link rel="stylesheet" href="/styles.css">
|
|
122
|
+
</head>
|
|
123
|
+
<body>
|
|
124
|
+
<div class="container">
|
|
125
|
+
<h1>Welcome to Strav</h1>
|
|
126
|
+
<p>Your application <strong>{{ name }}</strong> is up and running.</p>
|
|
127
|
+
<div class="links">
|
|
128
|
+
<a href="https://github.com/nicoyambura/stravigor" target="_blank">Documentation</a>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</body>
|
|
132
|
+
</html>
|
|
133
|
+
`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function stylesCss(): string {
|
|
137
|
+
return `* {
|
|
138
|
+
margin: 0;
|
|
139
|
+
padding: 0;
|
|
140
|
+
box-sizing: border-box;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
body {
|
|
144
|
+
font-family: 'Barlow Semi Condensed', sans-serif;
|
|
145
|
+
background: #f8fafc;
|
|
146
|
+
color: #1e293b;
|
|
147
|
+
min-height: 100vh;
|
|
148
|
+
display: flex;
|
|
149
|
+
align-items: center;
|
|
150
|
+
justify-content: center;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.container {
|
|
154
|
+
text-align: center;
|
|
155
|
+
padding: 2rem;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
h1 {
|
|
159
|
+
font-size: 2.5rem;
|
|
160
|
+
font-weight: 700;
|
|
161
|
+
margin-bottom: 1rem;
|
|
162
|
+
color: oklch(57.7% 0.245 27.325);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
p {
|
|
166
|
+
font-size: 1.125rem;
|
|
167
|
+
color: #64748b;
|
|
168
|
+
margin-bottom: 2rem;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
p strong {
|
|
172
|
+
color: #1e293b;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.links a {
|
|
176
|
+
color: #2563eb;
|
|
177
|
+
text-decoration: none;
|
|
178
|
+
font-size: 0.875rem;
|
|
179
|
+
font-weight: 600;
|
|
180
|
+
border: 1px solid #e2e8f0;
|
|
181
|
+
padding: 0.5rem 1rem;
|
|
182
|
+
border-radius: 0.375rem;
|
|
183
|
+
transition: all 0.2s;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.links a:hover {
|
|
187
|
+
background: #eff6ff;
|
|
188
|
+
border-color: #2563eb;
|
|
189
|
+
}
|
|
190
|
+
`
|
|
191
|
+
}
|