@stjoseph/prumo 0.0.3 → 0.0.6
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 +23 -2
- package/dist/clean.js +135 -0
- package/dist/cli.js +140 -20
- package/dist/commands.js +80 -0
- package/dist/compose.js +7 -1
- package/dist/database.js +46 -0
- package/dist/doctor.js +70 -0
- package/dist/generate.js +28 -7
- package/dist/output.js +24 -0
- package/dist/questions.js +7 -6
- package/package.json +1 -1
- package/templates/api/.env.example +5 -2
- package/templates/api/README.md +18 -4
- package/templates/api/docker-compose.yml +4 -1
- package/templates/api/package.json +2 -1
- package/templates/api/scripts/database.mjs +307 -0
- package/templates/api/scripts/ports.mjs +508 -0
- package/templates/mobile/.env.example +3 -0
- package/templates/mobile/README.md +10 -0
- package/templates/mobile/jest.config.js +4 -0
- package/templates/mobile/package.json +1 -1
- package/templates/mobile/scripts/ports.mjs +508 -0
- package/templates/site/.env.example +2 -0
- package/templates/site/README.md +12 -1
- package/templates/site/gitignore +1 -0
- package/templates/site/package.json +1 -1
- package/templates/site/scripts/ports.mjs +508 -0
- package/templates/web/.env.example +3 -0
- package/templates/web/README.md +10 -1
- package/templates/web/package.json +1 -1
- package/templates/web/scripts/ports.mjs +508 -0
- package/templates/workspace/README.md +8 -4
- package/templates/workspace/scripts/ports.mjs +508 -0
package/dist/output.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export class CliError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
data;
|
|
4
|
+
constructor(code, message, data) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.data = data;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function errorEnvelope(command, error) {
|
|
11
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
12
|
+
const code = error instanceof CliError ? error.code : isParseArgsError(error) ? 'usage' : 'failed';
|
|
13
|
+
const data = error instanceof CliError ? error.data : undefined;
|
|
14
|
+
return data === undefined
|
|
15
|
+
? { ok: false, command, error: { code, message } }
|
|
16
|
+
: { ok: false, command, error: { code, message }, data };
|
|
17
|
+
}
|
|
18
|
+
function isParseArgsError(error) {
|
|
19
|
+
const code = error?.code;
|
|
20
|
+
return typeof code === 'string' && code.startsWith('ERR_PARSE_ARGS_');
|
|
21
|
+
}
|
|
22
|
+
export function writeEnvelope(envelope) {
|
|
23
|
+
process.stdout.write(`${JSON.stringify(envelope)}\n`);
|
|
24
|
+
}
|
package/dist/questions.js
CHANGED
|
@@ -1,28 +1,29 @@
|
|
|
1
1
|
import { validateProjectName } from "./names.js";
|
|
2
|
+
import { CliError } from "./output.js";
|
|
2
3
|
const TYPES = ['api', 'web', 'mobile', 'site'];
|
|
3
4
|
const TENANT_AWARE = ['api', 'web', 'mobile'];
|
|
4
5
|
function missing(flag) {
|
|
5
|
-
throw new
|
|
6
|
+
throw new CliError('needs_input', `Missing ${flag}. Outside an interactive terminal every answer must come from a flag.`);
|
|
6
7
|
}
|
|
7
8
|
function parseTypes(value) {
|
|
8
9
|
const types = value.split(',').map((type) => type.trim());
|
|
9
10
|
const unknown = types.filter((type) => !TYPES.includes(type));
|
|
10
11
|
if (unknown.length > 0 || types.length === 0) {
|
|
11
|
-
throw new
|
|
12
|
+
throw new CliError('invalid_input', `Unknown type: ${unknown.join(', ')}. Choose from ${TYPES.join(', ')}.`);
|
|
12
13
|
}
|
|
13
14
|
return [...new Set(types)];
|
|
14
15
|
}
|
|
15
16
|
export async function resolveAnswers(flags, asker) {
|
|
16
17
|
if (flags.alone && flags.monorepo) {
|
|
17
|
-
throw new
|
|
18
|
+
throw new CliError('invalid_input', 'Choose --alone or --monorepo, not both.');
|
|
18
19
|
}
|
|
19
20
|
if (flags.multiTenant && flags.singleTenant) {
|
|
20
|
-
throw new
|
|
21
|
+
throw new CliError('invalid_input', 'Choose --multi-tenant or --single-tenant, not both.');
|
|
21
22
|
}
|
|
22
23
|
const name = flags.name ?? (asker === undefined ? missing('the project name') : await asker.name());
|
|
23
24
|
const invalid = validateProjectName(name);
|
|
24
25
|
if (invalid !== undefined) {
|
|
25
|
-
throw new
|
|
26
|
+
throw new CliError('invalid_input', `Invalid project name "${name}". ${invalid}`);
|
|
26
27
|
}
|
|
27
28
|
let types;
|
|
28
29
|
if (flags.types !== undefined) {
|
|
@@ -37,7 +38,7 @@ export async function resolveAnswers(flags, asker) {
|
|
|
37
38
|
let architecture = 'alone';
|
|
38
39
|
if (types.length > 1) {
|
|
39
40
|
if (flags.alone) {
|
|
40
|
-
throw new
|
|
41
|
+
throw new CliError('invalid_input', '--alone holds a single type; several types make a workspace.');
|
|
41
42
|
}
|
|
42
43
|
architecture = 'monorepo';
|
|
43
44
|
}
|
package/package.json
CHANGED
|
@@ -2,8 +2,11 @@ NODE_ENV=development
|
|
|
2
2
|
PORT=3000
|
|
3
3
|
LOG_LEVEL=info
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
# MISSING until `pnpm db:setup` creates a database; `pnpm dev` offers to run it.
|
|
6
|
+
DATABASE_URL=MISSING
|
|
7
|
+
AUTH_DATABASE_URL=MISSING
|
|
8
|
+
# Read by docker-compose.yml, not by the API: the port Postgres is published on.
|
|
9
|
+
POSTGRES_PORT=5432
|
|
7
10
|
|
|
8
11
|
BETTER_AUTH_SECRET=replace-this-with-at-least-32-characters
|
|
9
12
|
BETTER_AUTH_URL=http://localhost:3000
|
package/templates/api/README.md
CHANGED
|
@@ -2,21 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
## Requirements
|
|
4
4
|
|
|
5
|
-
Node 22.17 or later, pnpm, and Docker.
|
|
5
|
+
Node 22.17 or later, pnpm, and Docker Desktop running: the database and the tests both run in it.
|
|
6
6
|
|
|
7
7
|
## Running it
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
pnpm install
|
|
11
|
-
docker compose up -d --wait
|
|
12
|
-
pnpm db:migrate
|
|
13
11
|
pnpm dev
|
|
14
12
|
```
|
|
15
13
|
|
|
16
14
|
`.env` comes with the generated project, copied from `.env.example` with a random `BETTER_AUTH_SECRET`. It is not
|
|
17
15
|
committed, so on a fresh clone run `cp .env.example .env` and replace the secret with 32 or more random characters.
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
`DATABASE_URL` starts as `MISSING`. While it is, `pnpm dev` offers to create the database first: it asks for a name,
|
|
18
|
+
starts Postgres from `docker-compose.yml` on port 5432 or the next free one (kept in `POSTGRES_PORT`), writes the URL
|
|
19
|
+
into `.env`, and migrates. `pnpm db:setup` does the same at any time. Without a terminal every answer comes from a
|
|
20
|
+
flag: `--name`, `--port`, `--skip-migrate`, and `--json` for one JSON result on stdout.
|
|
21
|
+
|
|
22
|
+
The API listens on the port in `PORT`, `http://localhost:3000` by default. Outside production, its documentation is
|
|
23
|
+
at `/api/docs`.
|
|
24
|
+
|
|
25
|
+
Before it starts, `pnpm dev` checks that the port is free. When something else holds it, it says what does, by
|
|
26
|
+
name and pid, and asks whether to stop that process or to move this app to the next free port. Moving it writes the
|
|
27
|
+
new port to `.env`, along with every URL that pointed at the old one. Without a terminal, pass `--kill` or
|
|
28
|
+
`--change` to `scripts/ports.mjs`, or the command stops rather than choosing for you.
|
|
29
|
+
|
|
30
|
+
Ctrl+C stops everything `pnpm dev` started, not only what the terminal signals: the script remembers the processes
|
|
31
|
+
it spawned, stops them, and frees the port. Anything it did not start is named and left alone.
|
|
20
32
|
|
|
21
33
|
`pnpm db:migrate` builds first, then runs Better Auth's migrations and then the application's. Before
|
|
22
34
|
migrating, Better Auth logs `Database schema mismatch` because its tables do not exist yet; the migration
|
|
@@ -29,6 +41,8 @@ still completes.
|
|
|
29
41
|
| `pnpm test` | Runs the suite against a throwaway Postgres; Docker must be running |
|
|
30
42
|
| `pnpm lint` | Biome |
|
|
31
43
|
| `pnpm typecheck` | `tsc --noEmit`, also run before every push |
|
|
44
|
+
| `pnpm db:setup` | Creates a development database in Docker, writes its URL into `.env`, and migrates |
|
|
45
|
+
| `pnpm db:migrate` | Builds, then runs Better Auth's migrations and the application's |
|
|
32
46
|
| `pnpm db:migration:create` | Generates a migration from the entity diff, to be read and edited before committing |
|
|
33
47
|
|
|
34
48
|
## Conventions
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
# The Compose project, and so the names of the container and its volume. `prumo new` sets it to the project's name.
|
|
2
|
+
name: api
|
|
3
|
+
|
|
1
4
|
services:
|
|
2
5
|
postgres:
|
|
3
6
|
image: postgres:18-alpine
|
|
@@ -6,7 +9,7 @@ services:
|
|
|
6
9
|
POSTGRES_PASSWORD: app
|
|
7
10
|
POSTGRES_DB: app
|
|
8
11
|
ports:
|
|
9
|
-
- '5432:5432'
|
|
12
|
+
- '${POSTGRES_PORT:-5432}:5432'
|
|
10
13
|
volumes:
|
|
11
14
|
- postgres:/var/lib/postgresql
|
|
12
15
|
- ./docker/init:/docker-entrypoint-initdb.d:ro
|
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
"build": "nest build",
|
|
7
7
|
"start": "nest start",
|
|
8
8
|
"start:dev": "nest start --watch",
|
|
9
|
-
"dev": "pnpm start:dev",
|
|
9
|
+
"dev": "node scripts/database.mjs --check && node scripts/ports.mjs -- pnpm start:dev",
|
|
10
10
|
"start:prod": "node dist/main",
|
|
11
11
|
"lint": "biome check",
|
|
12
12
|
"typecheck": "tsc --noEmit",
|
|
13
13
|
"test": "vitest run",
|
|
14
14
|
"test:watch": "vitest",
|
|
15
15
|
"test:cov": "vitest run --coverage",
|
|
16
|
+
"db:setup": "node scripts/database.mjs",
|
|
16
17
|
"db:migrate": "nest build && auth migrate --config dist/auth/auth.js --yes && mikro-orm migration:up",
|
|
17
18
|
"db:migration:create": "mikro-orm migration:create",
|
|
18
19
|
"prepare": "node .githooks/install.mjs"
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Creates the development database in Docker and writes its URL into .env.
|
|
2
|
+
//
|
|
3
|
+
// node scripts/database.mjs creates a database, whatever .env holds
|
|
4
|
+
// node scripts/database.mjs --check does nothing unless DATABASE_URL is still MISSING; `pnpm dev` runs this
|
|
5
|
+
//
|
|
6
|
+
// The server is always the one in docker-compose.yml, published on 5432 or, when that port is taken, the next free
|
|
7
|
+
// one, kept in POSTGRES_PORT. It needs no dependency, so it runs before `pnpm install` has installed anything.
|
|
8
|
+
import { spawnSync } from 'node:child_process'
|
|
9
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { connect } from 'node:net'
|
|
11
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
12
|
+
import { createInterface } from 'node:readline/promises'
|
|
13
|
+
import { parseArgs } from 'node:util'
|
|
14
|
+
|
|
15
|
+
const MISSING = 'MISSING'
|
|
16
|
+
const USER = 'app'
|
|
17
|
+
const PASSWORD = 'app'
|
|
18
|
+
const DEFAULT_PORT = 5432
|
|
19
|
+
const PORTS_TRIED = 20
|
|
20
|
+
const NAME = /^[a-z_][a-z0-9_]{0,62}$/
|
|
21
|
+
|
|
22
|
+
const api = resolve(import.meta.dirname, '..')
|
|
23
|
+
const envPath = join(api, '.env')
|
|
24
|
+
|
|
25
|
+
const { values } = parseArgs({
|
|
26
|
+
options: {
|
|
27
|
+
check: { type: 'boolean', default: false },
|
|
28
|
+
name: { type: 'string' },
|
|
29
|
+
port: { type: 'string' },
|
|
30
|
+
'skip-migrate': { type: 'boolean', default: false },
|
|
31
|
+
json: { type: 'boolean', default: false },
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const json = values.json
|
|
36
|
+
const interactive = !json && process.stdin.isTTY === true && process.stdout.isTTY === true
|
|
37
|
+
// Under --json, stdout belongs to the result document.
|
|
38
|
+
const childStdio = ['ignore', json ? 2 : 'inherit', 'inherit']
|
|
39
|
+
|
|
40
|
+
class Failure extends Error {
|
|
41
|
+
constructor(code, message) {
|
|
42
|
+
super(message)
|
|
43
|
+
this.code = code
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function say(message) {
|
|
48
|
+
;(json ? process.stderr : process.stdout).write(`${message}\n`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function ask(question, fallback) {
|
|
52
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout })
|
|
53
|
+
const suffix = fallback === undefined ? '' : ` (${fallback})`
|
|
54
|
+
const answer = (await readline.question(`${question}${suffix}: `)).trim()
|
|
55
|
+
readline.close()
|
|
56
|
+
return answer === '' ? fallback : answer
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function confirm(question) {
|
|
60
|
+
const answer = await ask(`${question} [Y/n]`)
|
|
61
|
+
return answer === undefined || /^y(es)?$/i.test(answer)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readEnv() {
|
|
65
|
+
return existsSync(envPath) ? readFileSync(envPath, 'utf8') : undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function envValue(env, key) {
|
|
69
|
+
return new RegExp(`^${key}=(.*)$`, 'm').exec(env)?.[1]?.trim()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function withEnvValue(env, key, value) {
|
|
73
|
+
const line = new RegExp(`^${key}=.*$`, 'm')
|
|
74
|
+
return line.test(env)
|
|
75
|
+
? env.replace(line, `${key}=${value}`)
|
|
76
|
+
: `${env.replace(/\n?$/, '\n')}${key}=${value}\n`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function writeEnv(entries) {
|
|
80
|
+
let env = readEnv() ?? ''
|
|
81
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
82
|
+
env = withEnvValue(env, key, value)
|
|
83
|
+
}
|
|
84
|
+
writeFileSync(envPath, env)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function defaultName() {
|
|
88
|
+
let directory = api
|
|
89
|
+
|
|
90
|
+
// The project root is where .prumo/ lives: the api itself when alone, two levels up in a workspace.
|
|
91
|
+
while (!existsSync(join(directory, '.prumo')) && dirname(directory) !== directory) {
|
|
92
|
+
directory = dirname(directory)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const root = existsSync(join(directory, '.prumo')) ? directory : api
|
|
96
|
+
const name = basename(root)
|
|
97
|
+
.toLowerCase()
|
|
98
|
+
.replace(/[^a-z0-9_]/g, '_')
|
|
99
|
+
|
|
100
|
+
return NAME.test(name) ? name : `app_${name}`.slice(0, 63)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function docker(args, options = {}) {
|
|
104
|
+
return spawnSync('docker', args, { cwd: api, encoding: 'utf8', ...options })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function listening(host, port) {
|
|
108
|
+
return new Promise((done) => {
|
|
109
|
+
const socket = connect({ host, port, timeout: 1000 })
|
|
110
|
+
const finish = (open) => {
|
|
111
|
+
socket.destroy()
|
|
112
|
+
done(open)
|
|
113
|
+
}
|
|
114
|
+
socket.once('connect', () => finish(true))
|
|
115
|
+
socket.once('timeout', () => finish(false))
|
|
116
|
+
socket.once('error', () => finish(false))
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function portTaken(port) {
|
|
121
|
+
// A server on this machine may listen on only one of the two loopback addresses.
|
|
122
|
+
return (await listening('127.0.0.1', port)) || (await listening('::1', port))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function requireDocker() {
|
|
126
|
+
if (docker(['--version']).error !== undefined) {
|
|
127
|
+
throw new Failure(
|
|
128
|
+
'docker_missing',
|
|
129
|
+
'Docker is not installed. Install Docker Desktop from https://www.docker.com/products/docker-desktop/ and run this again.',
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
if (docker(['info']).status !== 0) {
|
|
133
|
+
throw new Failure(
|
|
134
|
+
'docker_not_running',
|
|
135
|
+
'Docker is installed but not running. Open Docker Desktop, wait until it says it is running, and run this again.',
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The port the service already publishes, when it is up: moving it would only restart it for nothing.
|
|
141
|
+
function publishedPort() {
|
|
142
|
+
const result = docker(['compose', 'port', 'postgres', '5432'])
|
|
143
|
+
const port = /:(\d+)\s*$/.exec(result.stdout ?? '')?.[1]
|
|
144
|
+
|
|
145
|
+
return result.status === 0 && port !== undefined ? Number(port) : undefined
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function choosePort(env) {
|
|
149
|
+
const running = publishedPort()
|
|
150
|
+
|
|
151
|
+
if (running !== undefined) {
|
|
152
|
+
return running
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (values.port !== undefined) {
|
|
156
|
+
const port = Number(values.port)
|
|
157
|
+
if (await portTaken(port)) {
|
|
158
|
+
throw new Failure('port_busy', `Port ${port} is already in use.`)
|
|
159
|
+
}
|
|
160
|
+
return port
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const start = Number(envValue(env, 'POSTGRES_PORT') ?? DEFAULT_PORT)
|
|
164
|
+
|
|
165
|
+
for (let port = start; port < start + PORTS_TRIED; port += 1) {
|
|
166
|
+
if (!(await portTaken(port))) {
|
|
167
|
+
if (port !== start) {
|
|
168
|
+
say(`Port ${start} is in use; Postgres will listen on ${port}.`)
|
|
169
|
+
}
|
|
170
|
+
return port
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
throw new Failure(
|
|
175
|
+
'port_busy',
|
|
176
|
+
`Ports ${start} to ${start + PORTS_TRIED - 1} are all in use. Pass --port with a free one.`,
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function startPostgres(port) {
|
|
181
|
+
// docker-compose.yml reads POSTGRES_PORT from this .env, so the file is written before the service starts.
|
|
182
|
+
writeEnv({ POSTGRES_PORT: port })
|
|
183
|
+
say('Starting Postgres in Docker…')
|
|
184
|
+
|
|
185
|
+
if (docker(['compose', 'up', '-d', '--wait'], { stdio: childStdio }).status !== 0) {
|
|
186
|
+
throw new Failure('docker_failed', '`docker compose up -d --wait` failed; its output is above.')
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Stop at the first error, print bare values, and read the SQL from stdin.
|
|
191
|
+
const PSQL_SCRIPT = ['-v', 'ON_ERROR_STOP=1', '-tAq', '-f', '-']
|
|
192
|
+
|
|
193
|
+
function sql(database, text) {
|
|
194
|
+
const result = docker(
|
|
195
|
+
['compose', 'exec', '-T', 'postgres', 'psql', '-U', USER, '-d', database, ...PSQL_SCRIPT],
|
|
196
|
+
{ input: text },
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if (result.error !== undefined || result.status !== 0) {
|
|
200
|
+
throw new Failure(
|
|
201
|
+
'postgres_error',
|
|
202
|
+
result.stderr?.trim() || result.error?.message || 'psql failed',
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return result.stdout.trim()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function main() {
|
|
210
|
+
const env = readEnv()
|
|
211
|
+
|
|
212
|
+
if (values.check) {
|
|
213
|
+
// No .env, or a URL someone already chose: the API's own validation has the last word.
|
|
214
|
+
if (env === undefined || envValue(env, 'DATABASE_URL') !== MISSING) {
|
|
215
|
+
return undefined
|
|
216
|
+
}
|
|
217
|
+
if (!interactive) {
|
|
218
|
+
throw new Failure(
|
|
219
|
+
'database_missing',
|
|
220
|
+
'DATABASE_URL is MISSING in .env. Run `pnpm db:setup` first.',
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
say('DATABASE_URL in .env is still MISSING.')
|
|
224
|
+
if (!(await confirm('Create a development database in Docker now?'))) {
|
|
225
|
+
throw new Failure('declined', 'DATABASE_URL is MISSING. Run `pnpm db:setup` when ready.')
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (env === undefined) {
|
|
230
|
+
throw new Failure('env_missing', `${envPath} does not exist. Copy .env.example to .env first.`)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const name = values.name ?? (interactive ? await ask('Database name', defaultName()) : undefined)
|
|
234
|
+
|
|
235
|
+
if (name === undefined) {
|
|
236
|
+
throw new Failure(
|
|
237
|
+
'needs_input',
|
|
238
|
+
'The database name is required. Outside an interactive terminal, pass --name.',
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
if (!NAME.test(name)) {
|
|
242
|
+
throw new Failure(
|
|
243
|
+
'invalid_name',
|
|
244
|
+
`"${name}" is not a valid name: lowercase letters, digits and _, not starting with a digit.`,
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
requireDocker()
|
|
249
|
+
|
|
250
|
+
const port = await choosePort(env)
|
|
251
|
+
startPostgres(port)
|
|
252
|
+
|
|
253
|
+
const exists = sql('postgres', `SELECT 1 FROM pg_database WHERE datname = '${name}'`) === '1'
|
|
254
|
+
|
|
255
|
+
if (exists) {
|
|
256
|
+
say(`Database ${name} already exists; using it.`)
|
|
257
|
+
} else {
|
|
258
|
+
sql('postgres', `CREATE DATABASE "${name}"`)
|
|
259
|
+
say(`Created database ${name}.`)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The image runs docker/init only for the database it creates on first start; every other one needs them too.
|
|
263
|
+
const init = join(api, 'docker', 'init')
|
|
264
|
+
for (const file of readdirSync(init)
|
|
265
|
+
.filter((entry) => entry.endsWith('.sql'))
|
|
266
|
+
.sort()) {
|
|
267
|
+
sql(name, readFileSync(join(init, file), 'utf8'))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const url = `postgresql://${USER}:${PASSWORD}@localhost:${port}/${name}`
|
|
271
|
+
writeEnv({ DATABASE_URL: url, AUTH_DATABASE_URL: url })
|
|
272
|
+
say('Wrote DATABASE_URL and AUTH_DATABASE_URL to .env.')
|
|
273
|
+
|
|
274
|
+
let migrated = false
|
|
275
|
+
|
|
276
|
+
if (!values['skip-migrate']) {
|
|
277
|
+
say('Migrating…')
|
|
278
|
+
if (spawnSync('pnpm', ['db:migrate'], { cwd: api, stdio: childStdio }).status !== 0) {
|
|
279
|
+
throw new Failure(
|
|
280
|
+
'migrate_failed',
|
|
281
|
+
'`pnpm db:migrate` failed. The database exists and .env points at it.',
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
migrated = true
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { database: name, port, created: !exists, migrated }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
const data = await main()
|
|
292
|
+
if (json) {
|
|
293
|
+
process.stdout.write(`${JSON.stringify({ ok: true, command: 'db', data: data ?? null })}\n`)
|
|
294
|
+
}
|
|
295
|
+
} catch (error) {
|
|
296
|
+
const code = error instanceof Failure ? error.code : 'unexpected'
|
|
297
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
298
|
+
|
|
299
|
+
if (json) {
|
|
300
|
+
process.stdout.write(
|
|
301
|
+
`${JSON.stringify({ ok: false, command: 'db', error: { code, message } })}\n`,
|
|
302
|
+
)
|
|
303
|
+
} else {
|
|
304
|
+
process.stderr.write(`${message}\n`)
|
|
305
|
+
}
|
|
306
|
+
process.exitCode = 1
|
|
307
|
+
}
|