@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/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 |
|
|
@@ -105,11 +120,17 @@ Once written, those documents belong to the team. Prumo does not come back and r
|
|
|
105
120
|
| [`docs/DECISIONS.md`](https://github.com/stjosephworks/prumo/blob/main/docs/DECISIONS.md) | Every decision, why it won, and what it costs |
|
|
106
121
|
| [`docs/OPEN-QUESTIONS.md`](https://github.com/stjosephworks/prumo/blob/main/docs/OPEN-QUESTIONS.md) | What has been raised and is still unresolved |
|
|
107
122
|
|
|
123
|
+
## Releasing
|
|
124
|
+
|
|
125
|
+
`main` publishes itself. A push to `main` runs the whole CI, and when `cli/package.json` carries a version the
|
|
126
|
+
registry does not have yet, the `publish` job sends it to npm through npm's trusted publishing, with provenance and
|
|
127
|
+
without a token in the repository. Any other push to `main` publishes nothing.
|
|
128
|
+
|
|
108
129
|
## Status
|
|
109
130
|
|
|
110
131
|
Prumo is in beta. `0.0.1` is the first published version: every template runs and every generated
|
|
111
132
|
project is verified in CI, and what a generated project looks like can still change before 1.0.
|
|
112
133
|
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
|
-
|
|
134
|
+
updating a project's `.prumo/` in place comes after 1.0.
|
|
114
135
|
|
|
115
136
|
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, `pnpm dev` offers to create the database first.\n\n';
|
|
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 the database'));
|
|
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 {
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
42
|
+
json: { type: 'boolean', default: false },
|
|
23
43
|
},
|
|
24
44
|
});
|
|
25
|
-
const [
|
|
26
|
-
if (
|
|
27
|
-
|
|
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
|
|
61
|
+
const target = resolve(answers.name);
|
|
62
|
+
const install = !values['skip-install'];
|
|
43
63
|
await generate({
|
|
44
|
-
...requireAssets(assetsFor(
|
|
45
|
-
target
|
|
64
|
+
...requireAssets(assetsFor(import.meta.dirname)),
|
|
65
|
+
target,
|
|
46
66
|
answers,
|
|
47
|
-
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
|
-
|
|
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(
|
|
52
|
-
console.error(error instanceof Error ? error.message : error);
|
|
53
|
-
process.exit(1);
|
|
54
|
-
});
|
|
174
|
+
await main(process.argv.slice(2));
|
package/dist/commands.js
ADDED
|
@@ -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
|
-
|
|
207
|
+
// The database and the ports are the exception: both may ask a question, which the interleaved output of a
|
|
208
|
+
// parallel run would bury, so the root asks first and each app's own check then finds nothing to do.
|
|
209
|
+
// `pnpm -r --parallel` starts each app in its own process group, so Ctrl+C reaches pnpm and leaves the apps
|
|
210
|
+
// running. The ports script wraps the run, remembers what it started, and stops all of it on the way out.
|
|
211
|
+
const database = types.includes('api') ? 'node apps/api/scripts/database.mjs --check && ' : '';
|
|
212
|
+
const dev = `${database}node scripts/ports.mjs --all -- pnpm -r --parallel dev`;
|
|
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) {
|
package/dist/database.js
ADDED
|
@@ -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
|
@@ -7,6 +7,7 @@ import { composeWorkspace, copyTemplate } from "./compose.js";
|
|
|
7
7
|
import { writeContext } from "./context.js";
|
|
8
8
|
import { setJsonc } from "./jsonc.js";
|
|
9
9
|
import { schemeFor } from "./names.js";
|
|
10
|
+
import { CliError } from "./output.js";
|
|
10
11
|
async function rewrite(path, change) {
|
|
11
12
|
await writeFile(path, change(await readFile(path, 'utf8')));
|
|
12
13
|
}
|
|
@@ -38,21 +39,38 @@ async function writeLocalEnv(app, type) {
|
|
|
38
39
|
}
|
|
39
40
|
await writeFile(join(app, '.env'), env);
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
});
|
|
43
58
|
if (result.status !== 0) {
|
|
44
59
|
throw new Error(`${command} ${args.join(' ')} failed in ${cwd}`);
|
|
45
60
|
}
|
|
46
61
|
}
|
|
47
|
-
export async function generate({ templates, knowledge, target, answers, install, }) {
|
|
62
|
+
export async function generate({ templates, knowledge, target, answers, install, childOutput = 'inherit', }) {
|
|
48
63
|
if (existsSync(target) && (await readdir(target)).length > 0) {
|
|
49
|
-
throw new
|
|
64
|
+
throw new CliError('target_not_empty', `${target} already exists and is not empty.`);
|
|
50
65
|
}
|
|
51
66
|
const [only] = answers.types;
|
|
52
67
|
if (answers.architecture === 'alone' && only !== undefined) {
|
|
53
68
|
await copyTemplate(join(templates, only), target);
|
|
54
69
|
await nameProject(target, answers.name);
|
|
55
70
|
await writeLocalEnv(target, only);
|
|
71
|
+
if (only === 'api') {
|
|
72
|
+
await nameCompose(target, answers.name);
|
|
73
|
+
}
|
|
56
74
|
if (only === 'mobile') {
|
|
57
75
|
await nameMobileApp(target, answers.name);
|
|
58
76
|
}
|
|
@@ -68,15 +86,18 @@ export async function generate({ templates, knowledge, target, answers, install,
|
|
|
68
86
|
for (const type of answers.types) {
|
|
69
87
|
await writeLocalEnv(join(target, 'apps', type), type);
|
|
70
88
|
}
|
|
89
|
+
if (answers.types.includes('api')) {
|
|
90
|
+
await nameCompose(join(target, 'apps', 'api'), answers.name);
|
|
91
|
+
}
|
|
71
92
|
if (answers.types.includes('mobile')) {
|
|
72
93
|
await nameMobileApp(join(target, 'apps', 'mobile'), answers.name);
|
|
73
94
|
}
|
|
74
95
|
}
|
|
75
96
|
await writeContext(knowledge, target, answers);
|
|
76
|
-
run('git', ['init', '--quiet'], target);
|
|
97
|
+
run('git', ['init', '--quiet'], target, childOutput);
|
|
77
98
|
if (install) {
|
|
78
|
-
run('pnpm', ['install'], target);
|
|
99
|
+
run('pnpm', ['install'], target, childOutput);
|
|
79
100
|
// Rewriting the contract import changes import grouping and line length; only the formatter can settle both.
|
|
80
|
-
run('pnpm', ['exec', 'biome', 'check', '--write'], target);
|
|
101
|
+
run('pnpm', ['exec', 'biome', 'check', '--write'], target, childOutput);
|
|
81
102
|
}
|
|
82
103
|
}
|