create-simo-api 1.0.0 → 1.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/index.js +262 -18
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
3
|
-
import { dirname, join, resolve } from 'node:path'
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
|
-
import {
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
6
|
import { randomBytes } from 'node:crypto'
|
|
7
|
+
import { styleText } from 'node:util'
|
|
8
|
+
import { createInterface } from 'node:readline/promises'
|
|
9
|
+
import { createRequire } from 'node:module'
|
|
7
10
|
|
|
8
11
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
9
12
|
const TEMPLATE_DIR = join(__dirname, 'template')
|
|
@@ -13,12 +16,231 @@ const RENAME = {
|
|
|
13
16
|
'_env.example': '.env.example',
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
20
|
+
|
|
21
|
+
let createdDir = null
|
|
22
|
+
|
|
23
|
+
function removeProject() {
|
|
24
|
+
if (!createdDir) return
|
|
25
|
+
rmSync(createdDir, { recursive: true, force: true })
|
|
26
|
+
console.error(styleText('gray', ` Project folder removed: ${relative(process.cwd(), createdDir)}\n`))
|
|
27
|
+
createdDir = null
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
function fail(msg) {
|
|
17
|
-
console.error(`\n
|
|
31
|
+
console.error(`\n${styleText(['red', 'bold'], '✖')} ${styleText('red', msg)}\n`)
|
|
32
|
+
removeProject()
|
|
18
33
|
process.exit(1)
|
|
19
34
|
}
|
|
20
35
|
|
|
21
|
-
function
|
|
36
|
+
function cancel() {
|
|
37
|
+
console.log(`\n\n${styleText('gray', 'Cancelled.')}\n`)
|
|
38
|
+
removeProject()
|
|
39
|
+
process.exit(130)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
process.on('SIGINT', cancel)
|
|
43
|
+
|
|
44
|
+
function listFiles(dir) {
|
|
45
|
+
return readdirSync(dir, { recursive: true })
|
|
46
|
+
.map((file) => join(dir, file))
|
|
47
|
+
.filter((file) => statSync(file).isFile())
|
|
48
|
+
.sort()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function installDependencies(cwd) {
|
|
52
|
+
return new Promise((resolvePromise) => {
|
|
53
|
+
let frame = 0
|
|
54
|
+
const label = 'Installation in progress... ☕'
|
|
55
|
+
const timer = setInterval(() => {
|
|
56
|
+
process.stdout.write(`\r${styleText('cyan', SPINNER[frame++ % SPINNER.length])} ${label}`)
|
|
57
|
+
}, 80)
|
|
58
|
+
|
|
59
|
+
let output = ''
|
|
60
|
+
const child = spawn('npm', ['install'], { cwd })
|
|
61
|
+
child.stdout.on('data', (data) => { output += data })
|
|
62
|
+
child.stderr.on('data', (data) => { output += data })
|
|
63
|
+
|
|
64
|
+
child.on('close', (code) => {
|
|
65
|
+
clearInterval(timer)
|
|
66
|
+
const icon = code === 0 ? styleText('green', '✔') : styleText('red', '✖')
|
|
67
|
+
process.stdout.write(`\r${icon} ${label}\n`)
|
|
68
|
+
resolvePromise({ code, output })
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function runCommand(cwd, args) {
|
|
74
|
+
console.log(`\n${styleText('gray', '$')} ${styleText('cyan', `npm ${args.join(' ')}`)}`)
|
|
75
|
+
return new Promise((resolvePromise) => {
|
|
76
|
+
const child = spawn('npm', args, { cwd, stdio: 'inherit' })
|
|
77
|
+
child.on('close', (code) => resolvePromise(code))
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function ask(question) {
|
|
82
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
83
|
+
rl.on('SIGINT', cancel)
|
|
84
|
+
try {
|
|
85
|
+
const answer = await rl.question(question)
|
|
86
|
+
return answer.trim()
|
|
87
|
+
} catch {
|
|
88
|
+
cancel()
|
|
89
|
+
} finally {
|
|
90
|
+
rl.close()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const validators = {
|
|
95
|
+
identifier: (value) => /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)
|
|
96
|
+
? null
|
|
97
|
+
: 'Only letters, numbers and _ are allowed, and it cannot start with a number.',
|
|
98
|
+
host: (value) => /^[^\s/:@]+$/.test(value) ? null : 'Enter a valid host, for example localhost.',
|
|
99
|
+
port: (value) => {
|
|
100
|
+
const port = Number(value)
|
|
101
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 ? null : 'Enter a number between 1 and 65535.'
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function askWithDefault(label, defaultValue, validate) {
|
|
106
|
+
const hint = defaultValue ? styleText('gray', ` (${defaultValue})`) : ''
|
|
107
|
+
|
|
108
|
+
while (true) {
|
|
109
|
+
const value = (await ask(`${styleText('bold', label)}${hint}: `)) || defaultValue
|
|
110
|
+
const error = validate?.(value)
|
|
111
|
+
if (!error) return value
|
|
112
|
+
console.log(` ${styleText('red', '✖')} ${styleText('red', error)}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function printRetry(message) {
|
|
117
|
+
console.log(`\n${styleText('red', '✖')} ${styleText('red', message)} ${styleText('gray', 'Try again.')}\n`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function askConnection(previous) {
|
|
121
|
+
return {
|
|
122
|
+
user: await askWithDefault('User', previous.user, validators.identifier),
|
|
123
|
+
password: await askWithDefault('Password', ''),
|
|
124
|
+
host: await askWithDefault('Host', previous.host, validators.host),
|
|
125
|
+
port: Number(await askWithDefault('Port', String(previous.port), validators.port)),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function createDatabase(targetDir, db) {
|
|
130
|
+
const pg = createRequire(join(targetDir, 'package.json'))('pg')
|
|
131
|
+
const client = new pg.Client({ ...db, database: 'postgres' })
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
await client.connect()
|
|
135
|
+
const { rowCount } = await client.query('SELECT 1 FROM pg_database WHERE datname = $1', [db.database])
|
|
136
|
+
if (rowCount > 0) return 'exists'
|
|
137
|
+
await client.query(`CREATE DATABASE "${db.database}"`)
|
|
138
|
+
return 'created'
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error.code === '42P04') return 'exists'
|
|
141
|
+
if (error.code === '42501') return 'denied'
|
|
142
|
+
if (error.code === '28P01' || error.code === '28000') return 'auth'
|
|
143
|
+
if (['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN'].includes(error.code)) return 'unreachable'
|
|
144
|
+
fail(`Could not create the database: ${error.message}`)
|
|
145
|
+
} finally {
|
|
146
|
+
await client.end().catch(() => {})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function createDatabaseAsAdmin(targetDir, db) {
|
|
151
|
+
console.log(`\n${styleText('gray', 'Enter a PostgreSQL admin account (the one created when PostgreSQL was installed).')}`)
|
|
152
|
+
const pg = createRequire(join(targetDir, 'package.json'))('pg')
|
|
153
|
+
let adminUser = 'postgres'
|
|
154
|
+
|
|
155
|
+
while (true) {
|
|
156
|
+
adminUser = await askWithDefault('Admin user', adminUser, validators.identifier)
|
|
157
|
+
const password = await askWithDefault('Admin password', '')
|
|
158
|
+
const client = new pg.Client({ host: db.host, port: db.port, database: 'postgres', user: adminUser, password })
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
await client.connect()
|
|
162
|
+
await client.query(`CREATE DATABASE "${db.database}" OWNER "${db.user}"`)
|
|
163
|
+
return
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error.code === '28P01' || error.code === '28000') printRetry(`Wrong password for PostgreSQL admin "${adminUser}".`)
|
|
166
|
+
else if (error.code === '42501') printRetry(`"${adminUser}" is not allowed to create databases either.`)
|
|
167
|
+
else fail(`Could not create the database: ${error.message}`)
|
|
168
|
+
} finally {
|
|
169
|
+
await client.end().catch(() => {})
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function createDatabaseWithSudo(db) {
|
|
175
|
+
console.log(`\n${styleText('gray', '$')} ${styleText('cyan', `sudo -u postgres psql -c 'CREATE DATABASE "${db.database}" OWNER "${db.user}";'`)}`)
|
|
176
|
+
return new Promise((resolvePromise) => {
|
|
177
|
+
const child = spawn('sudo', ['-u', 'postgres', 'psql', '-c', `CREATE DATABASE "${db.database}" OWNER "${db.user}";`], { stdio: 'inherit' })
|
|
178
|
+
child.on('close', (code) => resolvePromise(code))
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function setupDatabase(targetDir, projectName) {
|
|
183
|
+
console.log(`\n${styleText('magenta', '🐘')} PostgreSQL setup\n`)
|
|
184
|
+
|
|
185
|
+
const defaultName = projectName.toLowerCase().replace(/[^a-z0-9_]/g, '_')
|
|
186
|
+
const database = await askWithDefault('Database name', defaultName, validators.identifier)
|
|
187
|
+
|
|
188
|
+
let db = { database, user: 'postgres', host: 'localhost', port: 5432 }
|
|
189
|
+
let status
|
|
190
|
+
|
|
191
|
+
while (true) {
|
|
192
|
+
db = { database, ...(await askConnection(db)) }
|
|
193
|
+
status = await createDatabase(targetDir, db)
|
|
194
|
+
|
|
195
|
+
if (status === 'auth') printRetry(`Wrong user or password for "${db.user}".`)
|
|
196
|
+
else if (status === 'unreachable') printRetry(`Cannot reach PostgreSQL on ${db.host}:${db.port}. Is it running?`)
|
|
197
|
+
else break
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (status === 'denied') {
|
|
201
|
+
console.log(`\n${styleText('yellow', '⚠')} User "${db.user}" is not allowed to create databases.`)
|
|
202
|
+
|
|
203
|
+
const useSudo = process.platform === 'linux'
|
|
204
|
+
&& ['', 'y', 'yes'].includes((await ask(`${styleText('yellow', '?')} Create it with the "postgres" system user (sudo)? ${styleText('gray', '(Y/n)')} `)).toLowerCase())
|
|
205
|
+
|
|
206
|
+
const createdWithSudo = useSudo && (await createDatabaseWithSudo(db)) === 0
|
|
207
|
+
|
|
208
|
+
if (!createdWithSudo) {
|
|
209
|
+
await createDatabaseAsAdmin(targetDir, db)
|
|
210
|
+
}
|
|
211
|
+
status = 'created'
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const url = `postgres://${encodeURIComponent(db.user)}:${encodeURIComponent(db.password)}@${db.host}:${db.port}/${db.database}`
|
|
215
|
+
const envPath = join(targetDir, '.env')
|
|
216
|
+
writeFileSync(envPath, readFileSync(envPath, 'utf8').replace(/^DATABASE_URL=.*$/m, `DATABASE_URL=${url}`))
|
|
217
|
+
|
|
218
|
+
const maskedUrl = url.replace(`:${encodeURIComponent(db.password)}@`, ':****@')
|
|
219
|
+
console.log(`
|
|
220
|
+
${styleText('green', '✔')} Database ${styleText('cyan', db.database)} ${status === 'exists' ? 'already exists, using it' : 'created'}
|
|
221
|
+
|
|
222
|
+
${styleText('gray', 'Host ')} ${db.host}:${db.port}
|
|
223
|
+
${styleText('gray', 'Database ')} ${db.database}
|
|
224
|
+
${styleText('gray', 'User ')} ${db.user}
|
|
225
|
+
${styleText('gray', 'URL ')} ${maskedUrl}
|
|
226
|
+
${styleText('gray', 'Saved in ')} .env`)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function runSetup(targetDir, projectName) {
|
|
230
|
+
await setupDatabase(targetDir, projectName)
|
|
231
|
+
|
|
232
|
+
for (const args of [['run', 'migrate:up'], ['run', 'seed']]) {
|
|
233
|
+
const code = await runCommand(targetDir, args)
|
|
234
|
+
if (code !== 0) {
|
|
235
|
+
fail(`"npm ${args.join(' ')}" failed. Fix the error above, then create the project again.`)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
createdDir = null
|
|
240
|
+
await runCommand(targetDir, ['run', 'dev'])
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function main() {
|
|
22
244
|
const projectName = process.argv[2]
|
|
23
245
|
|
|
24
246
|
if (!projectName) {
|
|
@@ -31,9 +253,10 @@ function main() {
|
|
|
31
253
|
fail(`Directory "${projectName}" already exists and is not empty.`)
|
|
32
254
|
}
|
|
33
255
|
|
|
34
|
-
console.log(`\n
|
|
256
|
+
console.log(`\n${styleText('yellow', '⚡')} We will scaffold your app in a few seconds..\n`)
|
|
35
257
|
|
|
36
258
|
mkdirSync(targetDir, { recursive: true })
|
|
259
|
+
createdDir = targetDir
|
|
37
260
|
cpSync(TEMPLATE_DIR, targetDir, { recursive: true })
|
|
38
261
|
|
|
39
262
|
for (const [from, to] of Object.entries(RENAME)) {
|
|
@@ -52,23 +275,44 @@ function main() {
|
|
|
52
275
|
.replace(/^ADMIN_PASSWORD=$/m, `ADMIN_PASSWORD=${randomBytes(12).toString('base64url')}`)
|
|
53
276
|
writeFileSync(join(targetDir, '.env'), env)
|
|
54
277
|
|
|
55
|
-
|
|
56
|
-
|
|
278
|
+
for (const file of listFiles(targetDir)) {
|
|
279
|
+
const path = relative(process.cwd(), file)
|
|
280
|
+
console.log(`${styleText('green', 'CREATE')} ${path} ${styleText('gray', `(${statSync(file).size} bytes)`)}`)
|
|
281
|
+
}
|
|
57
282
|
|
|
58
|
-
|
|
59
|
-
|
|
283
|
+
console.log()
|
|
284
|
+
const install = await installDependencies(targetDir)
|
|
285
|
+
|
|
286
|
+
if (install.code !== 0) {
|
|
287
|
+
console.error(styleText('gray', install.output))
|
|
288
|
+
fail('Dependency installation failed. Check your internet connection and npm, then try again.')
|
|
60
289
|
}
|
|
61
290
|
|
|
62
291
|
console.log(`
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
292
|
+
${styleText('magenta', '🚀')} Successfully created project ${styleText('cyan', projectName)}
|
|
293
|
+
${styleText('green', ' Welcome to Simo Express App. Happy coding!')}
|
|
294
|
+
|
|
295
|
+
👉 Get started with the following commands:
|
|
296
|
+
|
|
297
|
+
${styleText('gray', '$')} ${styleText('cyan', `cd ${projectName}`)}
|
|
298
|
+
${styleText('gray', '$ # set DATABASE_URL in .env')}
|
|
299
|
+
${styleText('gray', '$')} ${styleText('cyan', 'npm run migrate:up')}
|
|
300
|
+
${styleText('gray', '$')} ${styleText('cyan', 'npm run seed')}
|
|
301
|
+
${styleText('gray', '$')} ${styleText('cyan', 'npm run dev')}
|
|
71
302
|
`)
|
|
303
|
+
|
|
304
|
+
if (!process.stdin.isTTY) {
|
|
305
|
+
createdDir = null
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const answer = await ask(`${styleText('yellow', '?')} Do you want me to run these commands for you? ${styleText('gray', '(Y/n)')} `)
|
|
310
|
+
|
|
311
|
+
if (answer === '' || answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
|
|
312
|
+
await runSetup(targetDir, projectName)
|
|
313
|
+
} else {
|
|
314
|
+
createdDir = null
|
|
315
|
+
}
|
|
72
316
|
}
|
|
73
317
|
|
|
74
318
|
main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-simo-api",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Scaffold an Express 5 + PostgreSQL API with JWT auth (access + refresh rotation), role permissions, Zod validation, service layer, migrations, seed and tests.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|