@stjoseph/prumo 0.0.2 → 0.0.5

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 CHANGED
@@ -31,7 +31,7 @@ workspace holding four applications, without either one carrying rules it has no
31
31
 
32
32
  ## Requirements
33
33
 
34
- Node 22.17 or later, pnpm, and Docker for anything with an API.
34
+ Node 22.17 or later, pnpm, and Docker for anything with an API. `prumo doctor` checks all of it.
35
35
 
36
36
  ## Running it
37
37
 
@@ -62,6 +62,21 @@ npx @stjoseph/prumo new my-app --types api,web --single-tenant
62
62
  | `--multi-tenant` or `--single-tenant` | Whether the application serves several tenants |
63
63
  | `--skip-install` | Stops after writing the files |
64
64
 
65
+ ## Commands
66
+
67
+ | Command | What it does |
68
+ |---|---|
69
+ | `prumo new [name]` | Generates a project |
70
+ | `prumo db` | Inside a project with an API: creates the development database in Docker and writes its URL into `.env` |
71
+ | `prumo clean` | Removes what the project needed only once, today the database setup, once the database exists |
72
+ | `prumo doctor` | Checks Node, pnpm, git and Docker on this machine |
73
+ | `prumo version` · `--version` · `-v` | Prints the CLI version |
74
+ | `prumo help [command]` · `--help` | Lists the commands, or describes one |
75
+
76
+ Every command takes `--json`: stdout then holds exactly one document, `{ "ok": true, "command", "data" }` or
77
+ `{ "ok": false, "command", "error": { "code", "message" } }`, anything written for a person goes to stderr, nothing
78
+ is asked, and the exit code is 0 only when `ok` is true.
79
+
65
80
  ## What it generates
66
81
 
67
82
  | Type | What you get |
@@ -110,6 +125,6 @@ Once written, those documents belong to the team. Prumo does not come back and r
110
125
  Prumo is in beta. `0.0.1` is the first published version: every template runs and every generated
111
126
  project is verified in CI, and what a generated project looks like can still change before 1.0.
112
127
  What is known to be missing is listed in [`docs/OPEN-QUESTIONS.md`](https://github.com/stjosephworks/prumo/blob/main/docs/OPEN-QUESTIONS.md), and
113
- `prumo new` is the only command. Updating a project's `.prumo/` in place comes after 1.0.
128
+ updating a project's `.prumo/` in place comes after 1.0.
114
129
 
115
130
  MIT licensed. See [`LICENSE`](https://github.com/stjosephworks/prumo/blob/main/LICENSE).
package/dist/clean.js ADDED
@@ -0,0 +1,135 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readdir, readFile, rm, rmdir, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, relative } from 'node:path';
4
+ import { apiPath, findProjectRoot } from "./database.js";
5
+ import { readJsonc, setJsonc } from "./jsonc.js";
6
+ import { CliError } from "./output.js";
7
+ const SAMPLE_URL = 'postgresql://app:app@localhost:5432/app';
8
+ export const API_DEV_PREFIX = 'node scripts/database.mjs --check && ';
9
+ export const ROOT_DEV_PREFIX = 'node apps/api/scripts/database.mjs --check && ';
10
+ export const SETUP_SCRIPT = 'node scripts/database.mjs';
11
+ export const EXAMPLE_BLOCK = '# MISSING until `pnpm db:setup` creates a database; `pnpm dev` offers to run it.\n' +
12
+ 'DATABASE_URL=MISSING\nAUTH_DATABASE_URL=MISSING\n';
13
+ export const README_PARAGRAPH = '`DATABASE_URL` starts as `MISSING`. While it is, `pnpm dev` offers to create the database first: it asks for a name,\n' +
14
+ 'starts Postgres from `docker-compose.yml` on port 5432 or the next free one (kept in `POSTGRES_PORT`), writes the URL\n' +
15
+ 'into `.env`, and migrates. `pnpm db:setup` does the same at any time. Without a terminal every answer comes from a\n' +
16
+ 'flag: `--name`, `--port`, `--skip-migrate`, and `--json` for one JSON result on stdout.\n';
17
+ const README_REPLACEMENT = 'Before the first `pnpm dev`, start Postgres with `docker compose up -d --wait` and run `pnpm db:migrate`.\n' +
18
+ '`.env.example` points at that service on port 5432; when `POSTGRES_PORT` changes, change both URLs with it.\n';
19
+ export const README_ROW = '| `pnpm db:setup` | Creates a development database in Docker, writes its URL into `.env`, and migrates |\n';
20
+ export const WORKSPACE_SENTENCE = 'If the workspace holds `api` and its database is not set up yet, it offers to create one first. ';
21
+ function textRule(id, file, description, from, to, marker) {
22
+ return {
23
+ id,
24
+ file,
25
+ description,
26
+ inspect: (text) => text.includes(from) ? 'pending' : text.includes(marker) ? 'modified' : 'absent',
27
+ change: (text) => text.replace(from, to),
28
+ };
29
+ }
30
+ function scriptRule(id, file, description, key, decide) {
31
+ const scriptOf = (text) => readJsonc(text).scripts?.[key];
32
+ return {
33
+ id,
34
+ file,
35
+ description,
36
+ inspect: (text) => {
37
+ const value = scriptOf(text);
38
+ return value === undefined ? 'absent' : decide(value).status;
39
+ },
40
+ change: (text) => setJsonc(text, ['scripts', key], decide(scriptOf(text) ?? '').next),
41
+ };
42
+ }
43
+ function devRule(id, file, prefix) {
44
+ return scriptRule(id, file, `Stop checking the database before \`dev\``, 'dev', (value) => value.startsWith(prefix)
45
+ ? { status: 'pending', next: value.slice(prefix.length) }
46
+ : { status: value.includes('database.mjs') ? 'modified' : 'absent', next: value });
47
+ }
48
+ function rulesFor(root, api, templates) {
49
+ const at = (file) => relative(root, join(api, file));
50
+ const rules = [
51
+ devRule('api-dev-check', at('package.json'), API_DEV_PREFIX),
52
+ scriptRule('api-db-setup', at('package.json'), 'Remove the `db:setup` script', 'db:setup', (value) => value === SETUP_SCRIPT ? { status: 'pending' } : { status: 'modified', next: value }),
53
+ textRule('api-env-example', at('.env.example'), 'Put a sample database URL back in `.env.example`', EXAMPLE_BLOCK, `DATABASE_URL=${SAMPLE_URL}\nAUTH_DATABASE_URL=${SAMPLE_URL}\n`, '=MISSING'),
54
+ textRule('api-readme-setup', at('README.md'), 'Replace the README paragraph about `MISSING`', README_PARAGRAPH, README_REPLACEMENT, 'db:setup'),
55
+ textRule('api-readme-row', at('README.md'), 'Remove `db:setup` from the README', README_ROW, '', '`pnpm db:setup`'),
56
+ ];
57
+ if (api !== root) {
58
+ rules.push(devRule('root-dev-check', 'package.json', ROOT_DEV_PREFIX), textRule('root-readme', 'README.md', 'Remove the database offer from the README', WORKSPACE_SENTENCE, '', 'offers to create one'));
59
+ }
60
+ return { rules, script: join(templates, 'api', 'scripts', 'database.mjs') };
61
+ }
62
+ export async function planClean({ cwd, templates, force, }) {
63
+ const root = findProjectRoot(cwd);
64
+ const api = apiPath(root);
65
+ if (api === undefined) {
66
+ return { root, items: [], apply: async () => { } };
67
+ }
68
+ const env = await readFile(join(api, '.env'), 'utf8').catch(() => undefined);
69
+ if (!force && env !== undefined && /^DATABASE_URL=MISSING$/m.test(env)) {
70
+ throw new CliError('database_missing', 'DATABASE_URL is still MISSING: set the database up before removing what sets it up, or pass --force.');
71
+ }
72
+ const { rules, script } = rulesFor(root, api, templates);
73
+ const items = [];
74
+ const pending = [];
75
+ for (const rule of rules) {
76
+ const text = await readFile(join(root, rule.file), 'utf8').catch(() => undefined);
77
+ const status = text === undefined ? 'absent' : rule.inspect(text);
78
+ items.push({ id: rule.id, path: rule.file, description: rule.description, status });
79
+ if (status === 'pending') {
80
+ pending.push(rule);
81
+ }
82
+ }
83
+ const scriptPath = join(api, 'scripts', 'database.mjs');
84
+ const shipped = await readFile(script, 'utf8');
85
+ const current = await readFile(scriptPath, 'utf8').catch(() => undefined);
86
+ const scriptItem = {
87
+ id: 'api-database-script',
88
+ path: relative(root, scriptPath),
89
+ description: 'Delete the database setup script',
90
+ status: current === undefined ? 'absent' : current === shipped ? 'pending' : 'modified',
91
+ };
92
+ items.push(scriptItem);
93
+ return {
94
+ root,
95
+ items,
96
+ apply: async () => {
97
+ for (const rule of pending) {
98
+ const path = join(root, rule.file);
99
+ await writeFile(path, rule.change(await readFile(path, 'utf8')));
100
+ }
101
+ if (scriptItem.status === 'pending') {
102
+ await rm(scriptPath);
103
+ if (existsSync(dirname(scriptPath)) && (await readdir(dirname(scriptPath))).length === 0) {
104
+ await rmdir(dirname(scriptPath));
105
+ }
106
+ }
107
+ for (const item of items) {
108
+ if (item.status === 'pending') {
109
+ item.status = 'removed';
110
+ }
111
+ }
112
+ },
113
+ };
114
+ }
115
+ const SYMBOL = {
116
+ pending: '-',
117
+ removed: '✓',
118
+ modified: '!',
119
+ absent: '·',
120
+ };
121
+ export function cleanText(items) {
122
+ if (items.length === 0) {
123
+ return 'Nothing to clean.';
124
+ }
125
+ return items
126
+ .map((item) => {
127
+ const note = item.status === 'modified'
128
+ ? ' (changed since generated; kept)'
129
+ : item.status === 'absent'
130
+ ? ' (already gone)'
131
+ : '';
132
+ return `${SYMBOL[item.status]} ${item.description}: ${item.path}${note}`;
133
+ })
134
+ .join('\n');
135
+ }
package/dist/cli.js CHANGED
@@ -1,16 +1,36 @@
1
1
  #!/usr/bin/env node
2
- import { resolve } from 'node:path';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
3
4
  import { parseArgs } from 'node:util';
4
5
  import * as prompt from '@clack/prompts';
5
6
  import { terminalAsker } from "./asker.js";
6
7
  import { assetsFor, requireAssets } from "./assets.js";
8
+ import { cleanText, planClean } from "./clean.js";
9
+ import { COMMANDS, findCommand, helpText } from "./commands.js";
10
+ import { runDatabase } from "./database.js";
11
+ import { doctor, doctorText } from "./doctor.js";
7
12
  import { generate } from "./generate.js";
8
13
  import { validateProjectName } from "./names.js";
14
+ import { CliError, errorEnvelope, writeEnvelope } from "./output.js";
9
15
  import { resolveAnswers } from "./questions.js";
10
- const USAGE = `Usage: prumo new [name] [--types api,web,mobile,site] [--alone | --monorepo]
11
- [--multi-tenant | --single-tenant] [--skip-install]`;
12
- async function main() {
16
+ function version() {
17
+ // src/cli.ts and dist/cli.js both sit one level below package.json.
18
+ const pkg = JSON.parse(readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8'));
19
+ return pkg.version;
20
+ }
21
+ function help(name) {
22
+ if (name === undefined) {
23
+ return { data: { commands: COMMANDS }, text: helpText() };
24
+ }
25
+ const command = findCommand(name);
26
+ if (command === undefined) {
27
+ throw new CliError('unknown_command', `Unknown command "${name}". Run \`prumo help\`.`);
28
+ }
29
+ return { data: { commands: [command] }, text: helpText(command) };
30
+ }
31
+ async function runNew(args, json) {
13
32
  const { values, positionals } = parseArgs({
33
+ args,
14
34
  allowPositionals: true,
15
35
  options: {
16
36
  types: { type: 'string' },
@@ -19,15 +39,14 @@ async function main() {
19
39
  'multi-tenant': { type: 'boolean', default: false },
20
40
  'single-tenant': { type: 'boolean', default: false },
21
41
  'skip-install': { type: 'boolean', default: false },
22
- help: { type: 'boolean', default: false },
42
+ json: { type: 'boolean', default: false },
23
43
  },
24
44
  });
25
- const [command, name] = positionals;
26
- if (values.help || command !== 'new') {
27
- console.log(USAGE);
28
- process.exit(values.help ? 0 : 1);
45
+ const [name, ...extra] = positionals;
46
+ if (extra.length > 0) {
47
+ throw new CliError('usage', `Unexpected argument: ${extra.join(' ')}`);
29
48
  }
30
- const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
49
+ const interactive = !json && process.stdin.isTTY === true && process.stdout.isTTY === true;
31
50
  const answers = await resolveAnswers({
32
51
  name,
33
52
  types: values.types,
@@ -36,19 +55,120 @@ async function main() {
36
55
  multiTenant: values['multi-tenant'],
37
56
  singleTenant: values['single-tenant'],
38
57
  }, interactive ? terminalAsker(validateProjectName) : undefined);
39
- if (answers.types.length > 1) {
58
+ if (!json && answers.types.length > 1) {
40
59
  prompt.log.info(`${answers.types.length} types make a workspace: apps/ and packages/ under one root.`);
41
60
  }
42
- const here = import.meta.dirname;
61
+ const target = resolve(answers.name);
62
+ const install = !values['skip-install'];
43
63
  await generate({
44
- ...requireAssets(assetsFor(here)),
45
- target: resolve(answers.name),
64
+ ...requireAssets(assetsFor(import.meta.dirname)),
65
+ target,
46
66
  answers,
47
- install: !values['skip-install'],
67
+ install,
68
+ childOutput: json ? 'stderr' : 'inherit',
69
+ });
70
+ if (!json) {
71
+ prompt.outro(`Created ${answers.name}.`);
72
+ }
73
+ return { data: { ...answers, target, installed: install }, text: '' };
74
+ }
75
+ async function runClean(args, json) {
76
+ const { values } = parseArgs({
77
+ args,
78
+ options: {
79
+ 'dry-run': { type: 'boolean', default: false },
80
+ yes: { type: 'boolean', default: false },
81
+ force: { type: 'boolean', default: false },
82
+ json: { type: 'boolean', default: false },
83
+ },
84
+ });
85
+ const plan = await planClean({
86
+ cwd: process.cwd(),
87
+ templates: requireAssets(assetsFor(import.meta.dirname)).templates,
88
+ force: values.force,
48
89
  });
49
- prompt.outro(`Created ${answers.name}.`);
90
+ const pending = plan.items.some((item) => item.status === 'pending');
91
+ const result = (applied) => ({
92
+ data: { root: plan.root, dryRun: values['dry-run'], applied, items: plan.items },
93
+ text: cleanText(plan.items),
94
+ });
95
+ if (!pending || values['dry-run']) {
96
+ return result(false);
97
+ }
98
+ if (!values.yes) {
99
+ const interactive = !json && process.stdin.isTTY === true && process.stdout.isTTY === true;
100
+ if (!interactive) {
101
+ throw new CliError('needs_input', 'Outside an interactive terminal, pass --yes to clean.');
102
+ }
103
+ console.log(cleanText(plan.items));
104
+ const confirmed = await prompt.confirm({ message: 'Remove these?', initialValue: false });
105
+ if (prompt.isCancel(confirmed) || !confirmed) {
106
+ throw new CliError('declined', 'Nothing was removed.');
107
+ }
108
+ }
109
+ await plan.apply();
110
+ return result(true);
111
+ }
112
+ async function main(argv) {
113
+ const json = argv.includes('--json');
114
+ const index = argv.findIndex((arg) => !arg.startsWith('-'));
115
+ const command = index === -1 ? undefined : argv[index];
116
+ const args = index === -1 ? [] : argv.slice(index + 1);
117
+ const wantsHelp = argv.includes('--help') || argv.includes('-h');
118
+ const name = command ?? (argv.includes('--version') || argv.includes('-v') ? 'version' : 'help');
119
+ try {
120
+ let result;
121
+ if (wantsHelp && command !== undefined && command !== 'help') {
122
+ result = help(command);
123
+ }
124
+ else if (name === 'help') {
125
+ const [topic] = args.filter((arg) => !arg.startsWith('-'));
126
+ result = help(topic);
127
+ }
128
+ else if (name === 'version') {
129
+ const current = version();
130
+ result = { data: { version: current }, text: current };
131
+ }
132
+ else if (name === 'doctor') {
133
+ const report = await doctor();
134
+ if (!report.ready) {
135
+ throw new CliError('not_ready', 'This machine is missing something Prumo needs.', report);
136
+ }
137
+ result = { data: report, text: doctorText(report) };
138
+ }
139
+ else if (name === 'db') {
140
+ // The script writes its own output, --json included.
141
+ process.exitCode = runDatabase(args, process.cwd());
142
+ return;
143
+ }
144
+ else if (name === 'clean') {
145
+ result = await runClean(args, json);
146
+ }
147
+ else if (name === 'new') {
148
+ result = await runNew(args, json);
149
+ }
150
+ else {
151
+ throw new CliError('unknown_command', `Unknown command "${name}". Run \`prumo help\`.`);
152
+ }
153
+ if (json) {
154
+ writeEnvelope({ ok: true, command: name, data: result.data });
155
+ }
156
+ else if (result.text !== '') {
157
+ console.log(result.text);
158
+ }
159
+ }
160
+ catch (error) {
161
+ const envelope = errorEnvelope(name, error);
162
+ if (json) {
163
+ writeEnvelope(envelope);
164
+ }
165
+ else if (error instanceof CliError && error.code === 'not_ready') {
166
+ console.error(doctorText(error.data));
167
+ }
168
+ else {
169
+ console.error(envelope.ok ? '' : envelope.error.message);
170
+ }
171
+ process.exitCode = 1;
172
+ }
50
173
  }
51
- main().catch((error) => {
52
- console.error(error instanceof Error ? error.message : error);
53
- process.exit(1);
54
- });
174
+ await main(process.argv.slice(2));
@@ -0,0 +1,80 @@
1
+ const JSON_OPTION = {
2
+ flag: '--json',
3
+ description: 'Print one JSON document on stdout and never prompt',
4
+ };
5
+ export const COMMANDS = [
6
+ {
7
+ name: 'new',
8
+ usage: 'prumo new [name] [options]',
9
+ summary: 'Generate a project and the .prumo/ context it follows',
10
+ options: [
11
+ { flag: '--types <list>', description: 'Comma-separated: api, web, mobile, site' },
12
+ { flag: '--alone', description: 'One project of one type' },
13
+ { flag: '--monorepo', description: 'A workspace, even for a single type' },
14
+ { flag: '--multi-tenant', description: 'Tenant-aware api, web and mobile' },
15
+ { flag: '--single-tenant', description: 'No tenancy' },
16
+ { flag: '--skip-install', description: 'Do not run pnpm install' },
17
+ JSON_OPTION,
18
+ ],
19
+ },
20
+ {
21
+ name: 'db',
22
+ usage: 'prumo db [options]',
23
+ summary: "Create the development database in Docker and write its URL into the API's .env",
24
+ options: [
25
+ { flag: '--name <name>', description: 'Database name; asked when omitted' },
26
+ {
27
+ flag: '--port <port>',
28
+ description: 'Host port for Postgres; the first free from 5432 by default',
29
+ },
30
+ { flag: '--skip-migrate', description: 'Do not run pnpm db:migrate afterwards' },
31
+ JSON_OPTION,
32
+ ],
33
+ },
34
+ {
35
+ name: 'clean',
36
+ usage: 'prumo clean [options]',
37
+ summary: 'Remove what a project needed only once, such as the database setup',
38
+ options: [
39
+ { flag: '--dry-run', description: 'List what would be removed, and change nothing' },
40
+ { flag: '--yes', description: 'Do not ask for confirmation; required without a terminal' },
41
+ { flag: '--force', description: 'Clean even while DATABASE_URL is still MISSING' },
42
+ JSON_OPTION,
43
+ ],
44
+ },
45
+ {
46
+ name: 'doctor',
47
+ usage: 'prumo doctor',
48
+ summary: 'Check that this machine has what a Prumo project needs',
49
+ options: [JSON_OPTION],
50
+ },
51
+ {
52
+ name: 'version',
53
+ usage: 'prumo version',
54
+ summary: 'Print the CLI version',
55
+ options: [JSON_OPTION],
56
+ },
57
+ {
58
+ name: 'help',
59
+ usage: 'prumo help [command]',
60
+ summary: 'List the commands, or describe one',
61
+ options: [JSON_OPTION],
62
+ },
63
+ ];
64
+ export function findCommand(name) {
65
+ return COMMANDS.find((command) => command.name === name);
66
+ }
67
+ function columns(rows) {
68
+ const width = Math.max(...rows.map(([left]) => left.length));
69
+ return rows.map(([left, right]) => ` ${left.padEnd(width)} ${right}`).join('\n');
70
+ }
71
+ export function helpText(command) {
72
+ if (command !== undefined) {
73
+ return `Usage: ${command.usage}\n\n${command.summary}\n\nOptions:\n${columns(command.options.map((option) => [option.flag, option.description]))}`;
74
+ }
75
+ return `Usage: prumo <command> [options]\n\nCommands:\n${columns(COMMANDS.map((entry) => [entry.name, entry.summary]))}\n\nOptions:\n${columns([
76
+ ['-h, --help', 'Show help; `prumo <command> --help` for one command'],
77
+ ['-v, --version', 'Print the CLI version'],
78
+ [JSON_OPTION.flag, JSON_OPTION.description],
79
+ ])}`;
80
+ }
package/dist/compose.js CHANGED
@@ -204,7 +204,13 @@ export async function composeWorkspace({ templates, target, types, mobileScheme
204
204
  });
205
205
  }
206
206
  // One command for everything and one per app. Each app answers to `dev`, so the root never needs to know how.
207
- await rewriteText(join(target, 'package.json'), (text) => types.reduce((pkg, type) => setJsonc(pkg, ['scripts', type], `pnpm --filter ${type} dev`), setJsonc(text, ['scripts', 'dev'], 'pnpm -r --parallel dev')));
207
+ // The API's database check is the exception: it may ask a question, which the interleaved output of a parallel
208
+ // run would bury, so the root asks it first and the API's own check then finds nothing to do.
209
+ const parallelDev = 'pnpm -r --parallel dev';
210
+ const dev = types.includes('api')
211
+ ? `node apps/api/scripts/database.mjs --check && ${parallelDev}`
212
+ : parallelDev;
213
+ await rewriteText(join(target, 'package.json'), (text) => types.reduce((pkg, type) => setJsonc(pkg, ['scripts', type], `pnpm --filter ${type} dev`), setJsonc(text, ['scripts', 'dev'], dev)));
208
214
  const manifests = await Promise.all(apps.map(async (app) => readJsonc(await readFile(join(app, 'package.json'), 'utf8'))));
209
215
  const catalogs = collectCatalog(manifests);
210
216
  if (clients.length > 0) {
@@ -0,0 +1,46 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { CliError } from "./output.js";
5
+ const SCRIPT = join('scripts', 'database.mjs');
6
+ export function findProjectRoot(from) {
7
+ let directory = resolve(from);
8
+ while (!existsSync(join(directory, '.prumo', 'config.json'))) {
9
+ if (dirname(directory) === directory) {
10
+ throw new CliError('not_a_project', 'Not inside a Prumo project: no .prumo/config.json here or above.');
11
+ }
12
+ directory = dirname(directory);
13
+ }
14
+ return directory;
15
+ }
16
+ export function readConfig(root) {
17
+ return JSON.parse(readFileSync(join(root, '.prumo', 'config.json'), 'utf8'));
18
+ }
19
+ // Where the API sits, or undefined when the project has none.
20
+ export function apiPath(root) {
21
+ const config = readConfig(root);
22
+ if (!config.types.includes('api')) {
23
+ return undefined;
24
+ }
25
+ return config.architecture === 'alone' ? root : join(root, 'apps', 'api');
26
+ }
27
+ export function apiDirectory(root) {
28
+ const api = apiPath(root);
29
+ if (api === undefined) {
30
+ throw new CliError('no_api', 'This project has no api, so it has no database to create.');
31
+ }
32
+ if (!existsSync(join(api, SCRIPT))) {
33
+ throw new CliError('script_missing', `${join(api, SCRIPT)} does not exist; the project predates \`prumo db\` or was cleaned.`);
34
+ }
35
+ return api;
36
+ }
37
+ // The logic lives in the project, so it runs without Prumo installed; this command only finds it and runs it.
38
+ // The script answers --json itself, so its stdout is passed through untouched.
39
+ export function runDatabase(args, cwd) {
40
+ const api = apiDirectory(findProjectRoot(cwd));
41
+ const result = spawnSync(process.execPath, [SCRIPT, ...args], { cwd: api, stdio: 'inherit' });
42
+ if (result.error !== undefined) {
43
+ throw result.error;
44
+ }
45
+ return result.status ?? 1;
46
+ }
package/dist/doctor.js ADDED
@@ -0,0 +1,70 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ const NODE_FLOOR = [22, 17, 0];
3
+ export const systemProbe = {
4
+ nodeVersion: process.versions.node,
5
+ run: (command, args) => {
6
+ const result = spawnSync(command, args, { encoding: 'utf8', timeout: 10_000 });
7
+ return { ok: result.error === undefined && result.status === 0, stdout: result.stdout ?? '' };
8
+ },
9
+ };
10
+ function atLeast(version, floor) {
11
+ const parts = version.split('.').map(Number);
12
+ for (const [index, minimum] of floor.entries()) {
13
+ const part = parts[index] ?? 0;
14
+ if (part !== minimum) {
15
+ return part > minimum;
16
+ }
17
+ }
18
+ return true;
19
+ }
20
+ function firstLine(text) {
21
+ return text.trim().split('\n')[0] ?? '';
22
+ }
23
+ export async function doctor(probe = systemProbe) {
24
+ const checks = [];
25
+ checks.push({
26
+ id: 'node',
27
+ label: 'Node',
28
+ required: true,
29
+ ...(atLeast(probe.nodeVersion, NODE_FLOOR)
30
+ ? { status: 'ok', detail: probe.nodeVersion }
31
+ : { status: 'fail', detail: `${probe.nodeVersion}; 22.17.0 or later is required` }),
32
+ });
33
+ for (const [id, label, why] of [
34
+ ['pnpm', 'pnpm', 'installs and runs every generated project'],
35
+ ['git', 'git', 'every generated project starts as a repository'],
36
+ ]) {
37
+ const result = probe.run(id, ['--version']);
38
+ checks.push({
39
+ id,
40
+ label,
41
+ required: true,
42
+ ...(result.ok
43
+ ? { status: 'ok', detail: firstLine(result.stdout) }
44
+ : { status: 'fail', detail: `not found; it ${why}` }),
45
+ });
46
+ }
47
+ const docker = probe.run('docker', ['--version']);
48
+ const dockerRunning = docker.ok && probe.run('docker', ['info']).ok;
49
+ checks.push({
50
+ id: 'docker',
51
+ label: 'Docker',
52
+ required: false,
53
+ ...(dockerRunning
54
+ ? { status: 'ok', detail: firstLine(docker.stdout) }
55
+ : {
56
+ status: 'warn',
57
+ detail: docker.ok
58
+ ? 'installed but not running; open Docker Desktop. The API runs its database and tests in it'
59
+ : 'not found; install Docker Desktop. The API runs its database and tests in it',
60
+ }),
61
+ });
62
+ return { ready: checks.every((check) => !check.required || check.status === 'ok'), checks };
63
+ }
64
+ const SYMBOL = { ok: '✓', warn: '!', fail: '✗' };
65
+ export function doctorText(report) {
66
+ const width = Math.max(...report.checks.map((check) => check.label.length));
67
+ const lines = report.checks.map((check) => `${SYMBOL[check.status]} ${check.label.padEnd(width)} ${check.detail}`);
68
+ lines.push('', report.ready ? 'Ready.' : 'Not ready: fix what is marked ✗ before generating a project.');
69
+ return lines.join('\n');
70
+ }
package/dist/generate.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
2
3
  import { existsSync } from 'node:fs';
3
4
  import { readdir, readFile, writeFile } from 'node:fs/promises';
4
5
  import { join } from 'node:path';
@@ -6,6 +7,7 @@ import { composeWorkspace, copyTemplate } from "./compose.js";
6
7
  import { writeContext } from "./context.js";
7
8
  import { setJsonc } from "./jsonc.js";
8
9
  import { schemeFor } from "./names.js";
10
+ import { CliError } from "./output.js";
9
11
  async function rewrite(path, change) {
10
12
  await writeFile(path, change(await readFile(path, 'utf8')));
11
13
  }
@@ -20,20 +22,55 @@ async function nameMobileApp(app, name) {
20
22
  return setJsonc(result, ['expo', 'scheme'], schemeFor(name));
21
23
  });
22
24
  }
23
- function run(command, args, cwd) {
24
- const result = spawnSync(command, args, { cwd, stdio: 'inherit' });
25
+ const SECRET_LINE = /^BETTER_AUTH_SECRET=.*$/m;
26
+ // `.env` stays out of git, so the example is the committed truth and a fresh project gets a copy it can start with.
27
+ // Only the secret differs: a sample value is public, so every generated API draws its own.
28
+ async function writeLocalEnv(app, type) {
29
+ const example = join(app, '.env.example');
30
+ if (!existsSync(example)) {
31
+ return;
32
+ }
33
+ let env = await readFile(example, 'utf8');
34
+ if (type === 'api') {
35
+ if (!SECRET_LINE.test(env)) {
36
+ throw new Error(`${example} no longer carries a BETTER_AUTH_SECRET line`);
37
+ }
38
+ env = env.replace(SECRET_LINE, `BETTER_AUTH_SECRET=${randomBytes(32).toString('base64url')}`);
39
+ }
40
+ await writeFile(join(app, '.env'), env);
41
+ }
42
+ // Compose names the container and volume after the project; without this, every workspace's apps/api is `api`
43
+ // and a second project's database replaces the first's.
44
+ async function nameCompose(api, name) {
45
+ await rewrite(join(api, 'docker-compose.yml'), (text) => {
46
+ if (!/^name: .*$/m.test(text)) {
47
+ throw new Error(`${join(api, 'docker-compose.yml')} no longer carries a top-level name`);
48
+ }
49
+ return text.replace(/^name: .*$/m, `name: ${name}`);
50
+ });
51
+ }
52
+ function run(command, args, cwd, output) {
53
+ // Under --json stdout belongs to the result document, so a child's output goes to stderr instead.
54
+ const result = spawnSync(command, args, {
55
+ cwd,
56
+ stdio: output === 'inherit' ? 'inherit' : ['ignore', 2, 2],
57
+ });
25
58
  if (result.status !== 0) {
26
59
  throw new Error(`${command} ${args.join(' ')} failed in ${cwd}`);
27
60
  }
28
61
  }
29
- export async function generate({ templates, knowledge, target, answers, install, }) {
62
+ export async function generate({ templates, knowledge, target, answers, install, childOutput = 'inherit', }) {
30
63
  if (existsSync(target) && (await readdir(target)).length > 0) {
31
- throw new Error(`${target} already exists and is not empty.`);
64
+ throw new CliError('target_not_empty', `${target} already exists and is not empty.`);
32
65
  }
33
66
  const [only] = answers.types;
34
67
  if (answers.architecture === 'alone' && only !== undefined) {
35
68
  await copyTemplate(join(templates, only), target);
36
69
  await nameProject(target, answers.name);
70
+ await writeLocalEnv(target, only);
71
+ if (only === 'api') {
72
+ await nameCompose(target, answers.name);
73
+ }
37
74
  if (only === 'mobile') {
38
75
  await nameMobileApp(target, answers.name);
39
76
  }
@@ -46,15 +83,21 @@ export async function generate({ templates, knowledge, target, answers, install,
46
83
  mobileScheme: schemeFor(answers.name),
47
84
  });
48
85
  await nameProject(target, answers.name);
86
+ for (const type of answers.types) {
87
+ await writeLocalEnv(join(target, 'apps', type), type);
88
+ }
89
+ if (answers.types.includes('api')) {
90
+ await nameCompose(join(target, 'apps', 'api'), answers.name);
91
+ }
49
92
  if (answers.types.includes('mobile')) {
50
93
  await nameMobileApp(join(target, 'apps', 'mobile'), answers.name);
51
94
  }
52
95
  }
53
96
  await writeContext(knowledge, target, answers);
54
- run('git', ['init', '--quiet'], target);
97
+ run('git', ['init', '--quiet'], target, childOutput);
55
98
  if (install) {
56
- run('pnpm', ['install'], target);
99
+ run('pnpm', ['install'], target, childOutput);
57
100
  // Rewriting the contract import changes import grouping and line length; only the formatter can settle both.
58
- run('pnpm', ['exec', 'biome', 'check', '--write'], target);
101
+ run('pnpm', ['exec', 'biome', 'check', '--write'], target, childOutput);
59
102
  }
60
103
  }
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 Error(`Missing ${flag}. Outside an interactive terminal every answer must come from a flag.`);
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 Error(`Unknown type: ${unknown.join(', ')}. Choose from ${TYPES.join(', ')}.`);
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 Error('Choose --alone or --monorepo, not both.');
18
+ throw new CliError('invalid_input', 'Choose --alone or --monorepo, not both.');
18
19
  }
19
20
  if (flags.multiTenant && flags.singleTenant) {
20
- throw new Error('Choose --multi-tenant or --single-tenant, not both.');
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 Error(`Invalid project name "${name}". ${invalid}`);
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 Error('--alone holds a single type; several types make a workspace.');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stjoseph/prumo",
3
- "version": "0.0.2",
3
+ "version": "0.0.5",
4
4
  "description": "Scaffolds a TypeScript project and ships it the conventions it follows, in a .prumo/ folder any AI assistant reads.",
5
5
  "keywords": [
6
6
  "scaffolding",
@@ -2,8 +2,11 @@ NODE_ENV=development
2
2
  PORT=3000
3
3
  LOG_LEVEL=info
4
4
 
5
- DATABASE_URL=postgresql://app:app@localhost:5432/app
6
- AUTH_DATABASE_URL=postgresql://app:app@localhost:5432/app
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
@@ -2,18 +2,23 @@
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
- cp .env.example .env # then replace BETTER_AUTH_SECRET with 32 or more random characters
12
- docker compose up -d --wait
13
- pnpm db:migrate
14
11
  pnpm dev
15
12
  ```
16
13
 
14
+ `.env` comes with the generated project, copied from `.env.example` with a random `BETTER_AUTH_SECRET`. It is not
15
+ committed, so on a fresh clone run `cp .env.example .env` and replace the secret with 32 or more random characters.
16
+
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
+
17
22
  The API listens on `http://localhost:3000`. Outside production, its documentation is at `/api/docs`.
18
23
 
19
24
  `pnpm db:migrate` builds first, then runs Better Auth's migrations and then the application's. Before
@@ -27,6 +32,8 @@ still completes.
27
32
  | `pnpm test` | Runs the suite against a throwaway Postgres; Docker must be running |
28
33
  | `pnpm lint` | Biome |
29
34
  | `pnpm typecheck` | `tsc --noEmit`, also run before every push |
35
+ | `pnpm db:setup` | Creates a development database in Docker, writes its URL into `.env`, and migrates |
36
+ | `pnpm db:migrate` | Builds, then runs Better Auth's migrations and the application's |
30
37
  | `pnpm db:migration:create` | Generates a migration from the entity diff, to be read and edited before committing |
31
38
 
32
39
  ## 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 && 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
+ }
@@ -9,10 +9,12 @@ to open the app.
9
9
 
10
10
  ```sh
11
11
  pnpm install
12
- cp .env.example .env # EXPO_PUBLIC_API_URL points at the API
13
12
  pnpm dev
14
13
  ```
15
14
 
15
+ `.env` comes with the generated project, copied from `.env.example`; `EXPO_PUBLIC_API_URL` points at the API. It is
16
+ not committed, so on a fresh clone run `cp .env.example .env`.
17
+
16
18
  On an Android emulator `localhost` is the emulator itself; point `EXPO_PUBLIC_API_URL` at your machine's address.
17
19
 
18
20
  ## Everyday commands
@@ -8,10 +8,12 @@ Node 22.17 or later, pnpm, and the API running.
8
8
 
9
9
  ```sh
10
10
  pnpm install
11
- cp .env.example .env # VITE_API_URL points at the API
12
11
  pnpm dev
13
12
  ```
14
13
 
14
+ `.env` comes with the generated project, copied from `.env.example`; `VITE_API_URL` points at the API. It is not
15
+ committed, so on a fresh clone run `cp .env.example .env`.
16
+
15
17
  The app runs on `http://localhost:5173`, which is the origin the API trusts by default (`WEB_ORIGIN`).
16
18
 
17
19
  Open it and you are sent to sign in, because everything but the sign-in and sign-up pages needs a session. Follow
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Requirements
4
4
 
5
- Node 22.17 or later, pnpm, and Docker for the API.
5
+ Node 22.17 or later and pnpm. For the API, Docker Desktop running: its database and tests run in it.
6
6
 
7
7
  ## Layout
8
8
 
@@ -22,7 +22,7 @@ Node 22.17 or later, pnpm, and Docker for the API.
22
22
 
23
23
  ## Trying it
24
24
 
25
- With the API migrated, run `pnpm dev`. If the workspace holds `web`, open `http://localhost:5173`: you are sent to
25
+ Run `pnpm dev`. If the workspace holds `api` and its database is not set up yet, it offers to create one first. If the workspace holds `web`, open `http://localhost:5173`: you are sent to
26
26
  sign in, and **Sign up** creates an account and takes you to your profile. Each app's README says what it needs
27
27
  before it starts.
28
28