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.
Files changed (2) hide show
  1. package/index.js +262 -18
  2. 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 { spawnSync } from 'node:child_process'
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 ✖ ${msg}\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 main() {
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 Scaffolding project in ${targetDir} ...`)
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
- console.log(' Installing dependencies with npm ...\n')
56
- const install = spawnSync('npm', ['install'], { cwd: targetDir, stdio: 'inherit' })
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
- if (install.status !== 0) {
59
- fail('Dependency installation failed. You can cd into the folder and run "npm install" manually.')
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
- ✔ Done! Project created.
64
-
65
- Next steps:
66
- cd ${projectName}
67
- # set DATABASE_URL in .env
68
- npm run migrate:up
69
- npm run seed
70
- npm run dev
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.0.0",
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": {