@stjoseph/prumo 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prumo-templates/api/auth.md +13 -1
- package/README.md +17 -12
- package/dist/clean.js +11 -1
- package/dist/cli.js +38 -9
- package/dist/commands.js +7 -4
- package/dist/compose.js +3 -3
- package/dist/doctor.js +31 -17
- package/dist/generate.js +53 -5
- package/dist/names.js +42 -0
- package/dist/questions.js +32 -16
- package/package.json +2 -2
- package/templates/api/README.md +1 -1
- package/templates/api/package.json +7 -6
- package/templates/mobile/README.md +1 -1
- package/templates/mobile/package.json +4 -3
- package/templates/site/README.md +1 -1
- package/templates/site/package.json +1 -1
- package/templates/web/README.md +1 -1
- package/templates/web/package.json +4 -3
- package/templates/workspace/README.md +1 -1
- package/templates/workspace/package.json +1 -1
|
@@ -27,6 +27,10 @@ not `database/entities.md`.
|
|
|
27
27
|
Create what the application keeps about a user in `databaseHooks.user.create.after`, through a use case:
|
|
28
28
|
`src/infra/di/index.ts` passes the hook into `createAuth`.
|
|
29
29
|
|
|
30
|
+
Pin every Better Auth package to one version, and declare `@better-auth/core` as a direct dependency at that
|
|
31
|
+
version: `better-auth`, `auth` and every `@better-auth/*` plugin, in the api and in every client. Upgrade them in
|
|
32
|
+
one change, never one package at a time.
|
|
33
|
+
|
|
30
34
|
## Rationale
|
|
31
35
|
|
|
32
36
|
The route sits inside Fastify, so the request id and the logs cover the authentication flow. **The error
|
|
@@ -51,6 +55,11 @@ Auth's tables as leftovers and every generated migration drops them, with every
|
|
|
51
55
|
key is declared rather than written by hand in a migration because the ORM would otherwise drop that too,
|
|
52
56
|
every time. `AuthUser` exists only as that anchor.
|
|
53
57
|
|
|
58
|
+
`better-auth` pins its core exactly, but each plugin only asks for a compatible core as a peer. Without a
|
|
59
|
+
direct `@better-auth/core`, pnpm satisfies that peer with the newest core on the registry, so a core release
|
|
60
|
+
installs a second core beside the pinned one, and the plugins stop typechecking against it. One version and
|
|
61
|
+
a declared core leave pnpm a single core to install.
|
|
62
|
+
|
|
54
63
|
**The sign-up hook is not atomic:** the user and the application's row are written over two connections, so
|
|
55
64
|
a failure between them leaves a user without a profile. The result is a visible 404, not corrupted data. And
|
|
56
65
|
the foreign key means deleting a user fails while rows reference it, so the application removes them first.
|
|
@@ -82,5 +91,8 @@ so a configuration that would drop Better Auth's schema, or the foreign key, fai
|
|
|
82
91
|
|
|
83
92
|
**The database.** A row referencing a user that does not exist is refused.
|
|
84
93
|
|
|
85
|
-
**
|
|
94
|
+
**Typecheck.** Two cores in one project usually fail `pnpm typecheck` in `src/infra/auth/`.
|
|
95
|
+
|
|
96
|
+
**Review only.** That an upgrade moves the whole Better Auth family, `@better-auth/core` included. And that
|
|
97
|
+
each `public: true` is deliberate: the one mistake that is invisible at runtime, because
|
|
86
98
|
the route works, for everyone.
|
package/README.md
CHANGED
|
@@ -15,8 +15,9 @@ turned into something that actually runs.
|
|
|
15
15
|
|
|
16
16
|
Put briefly, Prumo is the validation of a well built project without heavy architectural
|
|
17
17
|
bureaucracy, done up front instead of discovered three months in. Well built here does not mean
|
|
18
|
-
heavily layered. Every choice follows what the framework itself already indicates,
|
|
19
|
-
|
|
18
|
+
heavily layered. Every choice follows what the framework itself already indicates, and the one
|
|
19
|
+
structure of Prumo's own is the API's: ports and adapters, so the domain imports neither the
|
|
20
|
+
database nor the HTTP framework.
|
|
20
21
|
|
|
21
22
|
A new project receives two things. The first is code that already works: installed, wired together,
|
|
22
23
|
and proven by starting rather than by compiling. The second is a `.prumo/` folder holding the
|
|
@@ -31,7 +32,8 @@ workspace holding four applications, without either one carrying rules it has no
|
|
|
31
32
|
|
|
32
33
|
## Requirements
|
|
33
34
|
|
|
34
|
-
Node 22.
|
|
35
|
+
Node 22.18 or later, pnpm 10.26 or later, and Docker for anything with an API. `prumo doctor` checks
|
|
36
|
+
all of it. Prumo runs on macOS and Linux; Windows is not supported yet.
|
|
35
37
|
|
|
36
38
|
## Running it
|
|
37
39
|
|
|
@@ -45,7 +47,11 @@ published package is [`@stjoseph/prumo`](https://www.npmjs.com/package/@stjoseph
|
|
|
45
47
|
command it installs is `prumo`.
|
|
46
48
|
|
|
47
49
|
It asks what to build, writes the project into `my-app/`, runs `git init`, and installs. Nothing is
|
|
48
|
-
committed, so the first commit is yours and the diff shows everything Prumo wrote.
|
|
50
|
+
committed, so the first commit is yours and the diff shows everything Prumo wrote. `prumo new .`
|
|
51
|
+
writes into the current directory instead, which must be empty, and names the project after it. If
|
|
52
|
+
anything fails or is interrupted, Prumo removes what it wrote, so the same command can run again.
|
|
53
|
+
A project cannot be named `api`, `web`, `mobile` or `site`: those are the names its apps take inside
|
|
54
|
+
a workspace.
|
|
49
55
|
|
|
50
56
|
Every question also has a flag, which is how CI runs it without a terminal. Outside an interactive
|
|
51
57
|
terminal a missing answer is an error rather than a default, so nothing important gets decided by
|
|
@@ -59,7 +65,7 @@ npx @stjoseph/prumo new my-app --types api,web --single-tenant --no-mcp
|
|
|
59
65
|
|---|---|
|
|
60
66
|
| `--types api,web,mobile,site` | What the project contains. More than one type makes a workspace |
|
|
61
67
|
| `--alone` or `--monorepo` | How a single type is laid out |
|
|
62
|
-
| `--multi-tenant` or `--single-tenant` | Whether the application serves several tenants |
|
|
68
|
+
| `--multi-tenant` or `--single-tenant` | Whether the application serves several tenants. Multi-tenant ships the `multi-tenancy` conventions; the generated code itself is not tenant-aware yet |
|
|
63
69
|
| `--mcp` or `--no-mcp` | With an api and a web: whether AI assistants reach the API through MCP, signed in as the user |
|
|
64
70
|
| `--skip-install` | Stops after writing the files |
|
|
65
71
|
|
|
@@ -67,9 +73,9 @@ npx @stjoseph/prumo new my-app --types api,web --single-tenant --no-mcp
|
|
|
67
73
|
|
|
68
74
|
| Command | What it does |
|
|
69
75
|
|---|---|
|
|
70
|
-
| `prumo new [name]` | Generates a project |
|
|
76
|
+
| `prumo new [name \| .]` | Generates a project |
|
|
71
77
|
| `prumo db` | Inside a project with an API: creates the development database in Docker and writes its URL into `.env` |
|
|
72
|
-
| `prumo clean` | Removes what the project needed only once, today the database setup, once the database exists |
|
|
78
|
+
| `prumo clean` | Removes what the project needed only once, today the database setup, once the database exists. Only the Prumo version that generated the project cleans it |
|
|
73
79
|
| `prumo doctor` | Checks Node, pnpm, git and Docker on this machine |
|
|
74
80
|
| `prumo version` · `--version` · `-v` | Prints the CLI version |
|
|
75
81
|
| `prumo help [command]` · `--help` | Lists the commands, or describes one |
|
|
@@ -100,13 +106,13 @@ a month apart are the same project.
|
|
|
100
106
|
|
|
101
107
|
| File | What it is |
|
|
102
108
|
|---|---|
|
|
103
|
-
| `.prumo/` | The conventions, in
|
|
109
|
+
| `.prumo/` | The conventions, in ten areas. Only the areas the answers call for are copied |
|
|
104
110
|
| `.prumo/INDEX.md` | One line per document. Generated by the tool, and safe to regenerate |
|
|
105
111
|
| `AGENTS.md` | The pointer into `.prumo/`, read by Codex, Cursor, Copilot, Gemini CLI and others |
|
|
106
112
|
| `CLAUDE.md` | An `@AGENTS.md` import, for Claude Code |
|
|
107
113
|
|
|
108
|
-
The
|
|
109
|
-
`multi-tenancy`. Only `core` ships unconditionally, because everything in it is true of every type;
|
|
114
|
+
The ten areas are `core`, `api`, `database`, `client`, `web`, `mobile`, `site`, `monorepo`,
|
|
115
|
+
`multi-tenancy` and `mcp`. Only `core` ships unconditionally, because everything in it is true of every type;
|
|
110
116
|
the rest follow the answers, since advice about a platform the project does not have is worse than
|
|
111
117
|
no advice at all.
|
|
112
118
|
|
|
@@ -129,8 +135,7 @@ without a token in the repository. Any other push to `main` publishes nothing.
|
|
|
129
135
|
|
|
130
136
|
## Status
|
|
131
137
|
|
|
132
|
-
Prumo is in beta
|
|
133
|
-
project is verified in CI, and what a generated project looks like can still change before 1.0.
|
|
138
|
+
Prumo is in beta: every template runs and every generated project is verified in CI, and what a generated project looks like can still change before 1.0.
|
|
134
139
|
Updating a project's `.prumo/` in place comes after 1.0.
|
|
135
140
|
|
|
136
141
|
MIT licensed. See [`LICENSE`](https://github.com/stjosephworks/prumo/blob/main/LICENSE).
|
package/dist/clean.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { readdir, readFile, rm, rmdir, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname, join, relative } from 'node:path';
|
|
4
|
-
import { apiPath, findProjectRoot } from "./database.js";
|
|
4
|
+
import { apiPath, findProjectRoot, readConfig } from "./database.js";
|
|
5
5
|
import { readJsonc, setJsonc } from "./jsonc.js";
|
|
6
6
|
import { CliError } from "./output.js";
|
|
7
|
+
import { cliVersion } from "./version.js";
|
|
7
8
|
const SAMPLE_URL = 'postgresql://app:app@localhost:5432/app';
|
|
8
9
|
export const API_DEV_PREFIX = 'node scripts/database.mjs --check && ';
|
|
9
10
|
export const ROOT_DEV_PREFIX = 'node apps/api/scripts/database.mjs --check && ';
|
|
@@ -65,6 +66,15 @@ export async function planClean({ cwd, templates, force, }) {
|
|
|
65
66
|
if (api === undefined) {
|
|
66
67
|
return { root, items: [], apply: async () => { } };
|
|
67
68
|
}
|
|
69
|
+
// "Exactly what was generated" is judged against this CLI's templates. Another version shipped other text, and
|
|
70
|
+
// every untouched file would read as changed by the team, so only the version that wrote the project cleans it.
|
|
71
|
+
const wrote = readConfig(root).prumo;
|
|
72
|
+
const running = cliVersion();
|
|
73
|
+
if (wrote !== running) {
|
|
74
|
+
throw new CliError('version_mismatch', wrote === undefined
|
|
75
|
+
? `This project predates Prumo 0.1.1, which began recording its version. Run \`prumo clean\` from the version that generated it, such as \`npx @stjoseph/prumo@0.1.0 clean\`.`
|
|
76
|
+
: `This project was generated by Prumo ${wrote}, and this is ${running}. Run \`npx @stjoseph/prumo@${wrote} clean\`.`);
|
|
77
|
+
}
|
|
68
78
|
const env = await readFile(join(api, '.env'), 'utf8').catch(() => undefined);
|
|
69
79
|
if (!force && env !== undefined && /^DATABASE_URL=MISSING$/m.test(env)) {
|
|
70
80
|
throw new CliError('database_missing', 'DATABASE_URL is still MISSING: set the database up before removing what sets it up, or pass --force.');
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { resolve } from 'node:path';
|
|
3
2
|
import { parseArgs } from 'node:util';
|
|
4
3
|
import * as prompt from '@clack/prompts';
|
|
5
4
|
import { terminalAsker } from "./asker.js";
|
|
@@ -7,9 +6,9 @@ import { assetsFor, requireAssets } from "./assets.js";
|
|
|
7
6
|
import { cleanText, planClean } from "./clean.js";
|
|
8
7
|
import { COMMANDS, findCommand, helpText } from "./commands.js";
|
|
9
8
|
import { runDatabase } from "./database.js";
|
|
10
|
-
import { doctor, doctorText } from "./doctor.js";
|
|
9
|
+
import { doctor, doctorText, systemProbe, toolChecks } from "./doctor.js";
|
|
11
10
|
import { generate } from "./generate.js";
|
|
12
|
-
import {
|
|
11
|
+
import { projectProblem } from "./names.js";
|
|
13
12
|
import { CliError, errorEnvelope, writeEnvelope } from "./output.js";
|
|
14
13
|
import { resolveAnswers } from "./questions.js";
|
|
15
14
|
import { cliVersion } from "./version.js";
|
|
@@ -43,6 +42,16 @@ async function runNew(args, json) {
|
|
|
43
42
|
if (extra.length > 0) {
|
|
44
43
|
throw new CliError('usage', `Unexpected argument: ${extra.join(' ')}`);
|
|
45
44
|
}
|
|
45
|
+
const install = !values['skip-install'];
|
|
46
|
+
// Before the first question and the first file: a missing tool would otherwise surface halfway through.
|
|
47
|
+
const tools = toolChecks(systemProbe, install ? ['git', 'pnpm'] : ['git']);
|
|
48
|
+
if (tools.some((check) => check.status !== 'ok')) {
|
|
49
|
+
throw new CliError('not_ready', 'This machine is missing something Prumo needs.', {
|
|
50
|
+
ready: false,
|
|
51
|
+
checks: tools,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const cwd = process.cwd();
|
|
46
55
|
const interactive = !json && process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
47
56
|
const answers = await resolveAnswers({
|
|
48
57
|
name,
|
|
@@ -53,23 +62,22 @@ async function runNew(args, json) {
|
|
|
53
62
|
singleTenant: values['single-tenant'],
|
|
54
63
|
mcp: values.mcp,
|
|
55
64
|
noMcp: values['no-mcp'],
|
|
56
|
-
}, interactive ? terminalAsker(
|
|
65
|
+
}, interactive ? terminalAsker((input) => projectProblem(input, cwd)) : undefined, cwd);
|
|
57
66
|
if (!json && answers.types.length > 1) {
|
|
58
67
|
prompt.log.info(`${answers.types.length} types make a workspace: apps/ and packages/ under one root.`);
|
|
59
68
|
}
|
|
60
|
-
const target =
|
|
61
|
-
const install = !values['skip-install'];
|
|
69
|
+
const { target, ...chosen } = answers;
|
|
62
70
|
await generate({
|
|
63
71
|
...requireAssets(assetsFor(import.meta.dirname)),
|
|
64
72
|
target,
|
|
65
|
-
answers,
|
|
73
|
+
answers: chosen,
|
|
66
74
|
install,
|
|
67
75
|
childOutput: json ? 'stderr' : 'inherit',
|
|
68
76
|
});
|
|
69
77
|
if (!json) {
|
|
70
78
|
prompt.outro(`Created ${answers.name}.`);
|
|
71
79
|
}
|
|
72
|
-
return { data: { ...
|
|
80
|
+
return { data: { ...chosen, target, installed: install }, text: '' };
|
|
73
81
|
}
|
|
74
82
|
async function runClean(args, json) {
|
|
75
83
|
const { values } = parseArgs({
|
|
@@ -108,6 +116,19 @@ async function runClean(args, json) {
|
|
|
108
116
|
await plan.apply();
|
|
109
117
|
return result(true);
|
|
110
118
|
}
|
|
119
|
+
// A command that takes no options of its own still refuses one it does not know.
|
|
120
|
+
function noOptions(args, positionals = 0) {
|
|
121
|
+
const parsed = parseArgs({
|
|
122
|
+
args,
|
|
123
|
+
allowPositionals: true,
|
|
124
|
+
options: { json: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } },
|
|
125
|
+
});
|
|
126
|
+
if (parsed.positionals.length > positionals) {
|
|
127
|
+
throw new CliError('usage', `Unexpected argument: ${parsed.positionals.slice(positionals).join(' ')}`);
|
|
128
|
+
}
|
|
129
|
+
return parsed.positionals;
|
|
130
|
+
}
|
|
131
|
+
const GLOBAL_OPTIONS = ['--json', '--help', '-h', '--version', '-v'];
|
|
111
132
|
async function main(argv) {
|
|
112
133
|
const json = argv.includes('--json');
|
|
113
134
|
const index = argv.findIndex((arg) => !arg.startsWith('-'));
|
|
@@ -116,19 +137,27 @@ async function main(argv) {
|
|
|
116
137
|
const wantsHelp = argv.includes('--help') || argv.includes('-h');
|
|
117
138
|
const name = command ?? (argv.includes('--version') || argv.includes('-v') ? 'version' : 'help');
|
|
118
139
|
try {
|
|
140
|
+
const unknown = argv
|
|
141
|
+
.slice(0, index === -1 ? argv.length : index)
|
|
142
|
+
.find((arg) => !GLOBAL_OPTIONS.includes(arg));
|
|
143
|
+
if (unknown !== undefined) {
|
|
144
|
+
throw new CliError('usage', `Unknown option "${unknown}". Run \`prumo help\`.`);
|
|
145
|
+
}
|
|
119
146
|
let result;
|
|
120
147
|
if (wantsHelp && command !== undefined && command !== 'help') {
|
|
121
148
|
result = help(command);
|
|
122
149
|
}
|
|
123
150
|
else if (name === 'help') {
|
|
124
|
-
const [topic] = args
|
|
151
|
+
const [topic] = noOptions(args, 1);
|
|
125
152
|
result = help(topic);
|
|
126
153
|
}
|
|
127
154
|
else if (name === 'version') {
|
|
155
|
+
noOptions(args);
|
|
128
156
|
const current = cliVersion();
|
|
129
157
|
result = { data: { version: current }, text: current };
|
|
130
158
|
}
|
|
131
159
|
else if (name === 'doctor') {
|
|
160
|
+
noOptions(args);
|
|
132
161
|
const report = await doctor();
|
|
133
162
|
if (!report.ready) {
|
|
134
163
|
throw new CliError('not_ready', 'This machine is missing something Prumo needs.', report);
|
package/dist/commands.js
CHANGED
|
@@ -5,14 +5,17 @@ const JSON_OPTION = {
|
|
|
5
5
|
export const COMMANDS = [
|
|
6
6
|
{
|
|
7
7
|
name: 'new',
|
|
8
|
-
usage: 'prumo new [name] [options]',
|
|
9
|
-
summary: 'Generate a project and the .prumo/ context it follows',
|
|
8
|
+
usage: 'prumo new [name | .] [options]',
|
|
9
|
+
summary: 'Generate a project and the .prumo/ context it follows; `.` generates into this empty directory',
|
|
10
10
|
options: [
|
|
11
11
|
{ flag: '--types <list>', description: 'Comma-separated: api, web, mobile, site' },
|
|
12
12
|
{ flag: '--alone', description: 'One project of one type' },
|
|
13
13
|
{ flag: '--monorepo', description: 'A workspace, even for a single type' },
|
|
14
|
-
{
|
|
15
|
-
|
|
14
|
+
{
|
|
15
|
+
flag: '--multi-tenant',
|
|
16
|
+
description: 'Ship the multi-tenancy conventions; the code itself is not tenant-aware',
|
|
17
|
+
},
|
|
18
|
+
{ flag: '--single-tenant', description: 'No multi-tenancy conventions' },
|
|
16
19
|
{ flag: '--mcp', description: 'An MCP server on the api, authorized through the web' },
|
|
17
20
|
{ flag: '--no-mcp', description: 'No MCP server' },
|
|
18
21
|
{ flag: '--skip-install', description: 'Do not run pnpm install' },
|
package/dist/compose.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { join, relative } from 'node:path';
|
|
3
|
+
import { join, relative, sep } from 'node:path';
|
|
4
4
|
import { prependProperty, readJsonc, removeWhatBaseDeclares, setJsonc } from "./jsonc.js";
|
|
5
5
|
import { applyMcp } from "./mcp.js";
|
|
6
6
|
const CLIENTS = ['web', 'mobile'];
|
|
7
|
-
// One exception to one-version-per-dependency
|
|
7
|
+
// One exception to one-version-per-dependency: stable NativeWind needs Tailwind 3.
|
|
8
8
|
const NAMED_CATALOGS = [{ name: 'tailwind3', dependency: 'tailwindcss', major: '3' }];
|
|
9
9
|
const NOT_COPIED = new Set([
|
|
10
10
|
'node_modules',
|
|
@@ -36,7 +36,7 @@ export async function copyTemplate(source, destination) {
|
|
|
36
36
|
await cp(source, destination, {
|
|
37
37
|
recursive: true,
|
|
38
38
|
filter: (path) => !relative(source, path)
|
|
39
|
-
.split(
|
|
39
|
+
.split(sep)
|
|
40
40
|
.some((part) => NOT_COPIED.has(part) || NOT_COPIED_SUFFIXES.some((suffix) => part.endsWith(suffix))),
|
|
41
41
|
});
|
|
42
42
|
// Published, a template's .gitignore is `gitignore`, because npm deletes the dotted name from a tarball.
|
package/dist/doctor.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
const NODE_FLOOR = [22,
|
|
2
|
+
const NODE_FLOOR = [22, 18, 0];
|
|
3
|
+
// Every generated workspace approves dependency builds with `allowBuilds`, which pnpm added in 10.26.0.
|
|
4
|
+
const PNPM_FLOOR = [10, 26, 0];
|
|
5
|
+
// On Windows pnpm is a .cmd shim, and Node starts one only through a shell.
|
|
6
|
+
export const SHELL = process.platform === 'win32';
|
|
3
7
|
export const systemProbe = {
|
|
4
8
|
nodeVersion: process.versions.node,
|
|
5
9
|
run: (command, args) => {
|
|
6
|
-
const result = spawnSync(command, args, { encoding: 'utf8', timeout: 10_000 });
|
|
10
|
+
const result = spawnSync(command, args, { encoding: 'utf8', timeout: 10_000, shell: SHELL });
|
|
7
11
|
return { ok: result.error === undefined && result.status === 0, stdout: result.stdout ?? '' };
|
|
8
12
|
},
|
|
9
13
|
};
|
|
@@ -20,6 +24,29 @@ function atLeast(version, floor) {
|
|
|
20
24
|
function firstLine(text) {
|
|
21
25
|
return text.trim().split('\n')[0] ?? '';
|
|
22
26
|
}
|
|
27
|
+
const TOOLS = {
|
|
28
|
+
pnpm: { why: 'installs and runs every generated project', floor: PNPM_FLOOR },
|
|
29
|
+
git: { why: 'every generated project starts as a repository', floor: undefined },
|
|
30
|
+
};
|
|
31
|
+
export function toolChecks(probe, tools) {
|
|
32
|
+
return tools.map((id) => {
|
|
33
|
+
const { why, floor } = TOOLS[id];
|
|
34
|
+
const result = probe.run(id, ['--version']);
|
|
35
|
+
const version = firstLine(result.stdout);
|
|
36
|
+
const base = { id, label: id, required: true };
|
|
37
|
+
if (!result.ok) {
|
|
38
|
+
return { ...base, status: 'fail', detail: `not found; it ${why}` };
|
|
39
|
+
}
|
|
40
|
+
if (floor !== undefined && !atLeast(version, floor)) {
|
|
41
|
+
return {
|
|
42
|
+
...base,
|
|
43
|
+
status: 'fail',
|
|
44
|
+
detail: `${version}; ${floor.join('.')} or later is required`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return { ...base, status: 'ok', detail: version };
|
|
48
|
+
});
|
|
49
|
+
}
|
|
23
50
|
export async function doctor(probe = systemProbe) {
|
|
24
51
|
const checks = [];
|
|
25
52
|
checks.push({
|
|
@@ -28,22 +55,9 @@ export async function doctor(probe = systemProbe) {
|
|
|
28
55
|
required: true,
|
|
29
56
|
...(atLeast(probe.nodeVersion, NODE_FLOOR)
|
|
30
57
|
? { status: 'ok', detail: probe.nodeVersion }
|
|
31
|
-
: { status: 'fail', detail: `${probe.nodeVersion}; 22.
|
|
58
|
+
: { status: 'fail', detail: `${probe.nodeVersion}; 22.18.0 or later is required` }),
|
|
32
59
|
});
|
|
33
|
-
|
|
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
|
-
}
|
|
60
|
+
checks.push(...toolChecks(probe, ['pnpm', 'git']));
|
|
47
61
|
const docker = probe.run('docker', ['--version']);
|
|
48
62
|
const dockerRunning = docker.ok && probe.run('docker', ['info']).ok;
|
|
49
63
|
checks.push({
|
package/dist/generate.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
|
-
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { composeWorkspace, copyTemplate } from "./compose.js";
|
|
7
7
|
import { writeContext } from "./context.js";
|
|
8
|
+
import { SHELL } from "./doctor.js";
|
|
8
9
|
import { setJsonc } from "./jsonc.js";
|
|
9
10
|
import { applyMcp } from "./mcp.js";
|
|
10
|
-
import { schemeFor } from "./names.js";
|
|
11
|
+
import { schemeFor, targetProblem } from "./names.js";
|
|
11
12
|
import { CliError } from "./output.js";
|
|
12
13
|
async function rewrite(path, change) {
|
|
13
14
|
await writeFile(path, change(await readFile(path, 'utf8')));
|
|
@@ -50,20 +51,67 @@ async function nameCompose(api, name) {
|
|
|
50
51
|
return text.replace(/^name: .*$/m, `name: ${name}`);
|
|
51
52
|
});
|
|
52
53
|
}
|
|
54
|
+
// Ctrl+C reaches the child and Prumo alike. While a listener exists Prumo is not killed outright, so the run that
|
|
55
|
+
// was stopped returns, its failure removes the half-written project, and the next run starts from nothing.
|
|
56
|
+
let interrupted = false;
|
|
57
|
+
function interrupt() {
|
|
58
|
+
interrupted = true;
|
|
59
|
+
}
|
|
60
|
+
function stopIfInterrupted() {
|
|
61
|
+
if (interrupted) {
|
|
62
|
+
throw new CliError('interrupted', 'Interrupted. Nothing was kept.');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
53
65
|
function run(command, args, cwd, output) {
|
|
66
|
+
stopIfInterrupted();
|
|
54
67
|
// Under --json stdout belongs to the result document, so a child's output goes to stderr instead.
|
|
55
68
|
const result = spawnSync(command, args, {
|
|
56
69
|
cwd,
|
|
57
70
|
stdio: output === 'inherit' ? 'inherit' : ['ignore', 2, 2],
|
|
71
|
+
shell: SHELL,
|
|
58
72
|
});
|
|
73
|
+
// spawnSync holds the event loop, so the listener has not run yet; the child says it instead, either by dying of
|
|
74
|
+
// the signal or, as pnpm does, by catching it and exiting with 130.
|
|
75
|
+
if (result.signal === 'SIGINT' || result.status === 130) {
|
|
76
|
+
interrupted = true;
|
|
77
|
+
}
|
|
78
|
+
stopIfInterrupted();
|
|
59
79
|
if (result.status !== 0) {
|
|
60
|
-
|
|
80
|
+
const why = result.error?.message ??
|
|
81
|
+
(result.signal !== null ? `stopped by ${result.signal}` : `exit code ${result.status}`);
|
|
82
|
+
throw new Error(`${command} ${args.join(' ')} failed in ${cwd}: ${why}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Only what this run wrote goes: a directory it created, or the contents of the empty one it was given.
|
|
86
|
+
async function discard(target, created) {
|
|
87
|
+
if (created) {
|
|
88
|
+
await rm(target, { recursive: true, force: true });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const entry of await readdir(target).catch(() => [])) {
|
|
92
|
+
await rm(join(target, entry), { recursive: true, force: true });
|
|
61
93
|
}
|
|
62
94
|
}
|
|
63
95
|
export async function generate({ templates, knowledge, target, answers, install, childOutput = 'inherit', }) {
|
|
64
|
-
|
|
65
|
-
|
|
96
|
+
const occupied = targetProblem(target);
|
|
97
|
+
if (occupied !== undefined) {
|
|
98
|
+
throw new CliError('target_not_empty', occupied);
|
|
99
|
+
}
|
|
100
|
+
const created = !existsSync(target);
|
|
101
|
+
interrupted = false;
|
|
102
|
+
process.on('SIGINT', interrupt);
|
|
103
|
+
try {
|
|
104
|
+
await write({ templates, knowledge, target, answers, install, childOutput });
|
|
66
105
|
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
await discard(target, created);
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
process.off('SIGINT', interrupt);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function write({ templates, knowledge, target, answers, install, childOutput, }) {
|
|
67
115
|
const [only] = answers.types;
|
|
68
116
|
if (answers.architecture === 'alone' && only !== undefined) {
|
|
69
117
|
await copyTemplate(join(templates, only), target);
|
package/dist/names.js
CHANGED
|
@@ -1,10 +1,52 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, resolve } from 'node:path';
|
|
1
3
|
const PROJECT_NAME = /^[a-z][a-z0-9-]*$/;
|
|
4
|
+
// npm refuses a longer package name.
|
|
5
|
+
const MAX_LENGTH = 214;
|
|
6
|
+
// In a workspace each app's package is named after its type, and `pnpm --filter <type>` would match the root too.
|
|
7
|
+
const RESERVED = ['api', 'web', 'mobile', 'site'];
|
|
2
8
|
export function validateProjectName(name) {
|
|
3
9
|
if (!PROJECT_NAME.test(name)) {
|
|
4
10
|
return 'Use lowercase letters, digits and hyphens, starting with a letter.';
|
|
5
11
|
}
|
|
12
|
+
if (name.length > MAX_LENGTH) {
|
|
13
|
+
return `Use at most ${MAX_LENGTH} characters.`;
|
|
14
|
+
}
|
|
15
|
+
if (RESERVED.includes(name)) {
|
|
16
|
+
return 'That is the name an app takes inside a workspace; choose another.';
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
// Why a directory cannot receive a project, or undefined when it can: it does not exist yet, or it is empty.
|
|
21
|
+
export function targetProblem(target) {
|
|
22
|
+
const stats = statSync(target, { throwIfNoEntry: false });
|
|
23
|
+
if (stats === undefined) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
if (!stats.isDirectory()) {
|
|
27
|
+
return `${target} already exists and is not a directory.`;
|
|
28
|
+
}
|
|
29
|
+
if (readdirSync(target).length > 0) {
|
|
30
|
+
return `${target} already exists and is not empty.`;
|
|
31
|
+
}
|
|
6
32
|
return undefined;
|
|
7
33
|
}
|
|
34
|
+
// `.` is the current directory, and the project takes its name; anything else is a new directory inside it.
|
|
35
|
+
export function projectAt(input, cwd) {
|
|
36
|
+
return input === '.'
|
|
37
|
+
? { name: basename(cwd), target: cwd }
|
|
38
|
+
: { name: input, target: resolve(cwd, input) };
|
|
39
|
+
}
|
|
40
|
+
export function projectProblem(input, cwd) {
|
|
41
|
+
const { name, target } = projectAt(input, cwd);
|
|
42
|
+
const invalid = validateProjectName(name);
|
|
43
|
+
if (invalid !== undefined) {
|
|
44
|
+
return input === '.'
|
|
45
|
+
? `This directory's name, "${name}", cannot name a project. ${invalid}`
|
|
46
|
+
: invalid;
|
|
47
|
+
}
|
|
48
|
+
return targetProblem(target);
|
|
49
|
+
}
|
|
8
50
|
export function schemeFor(name) {
|
|
9
51
|
return name.replaceAll('-', '');
|
|
10
52
|
}
|
package/dist/questions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { validateProjectName } from "./names.js";
|
|
1
|
+
import { projectAt, targetProblem, validateProjectName } from "./names.js";
|
|
2
2
|
import { CliError } from "./output.js";
|
|
3
3
|
const TYPES = ['api', 'web', 'mobile', 'site'];
|
|
4
4
|
const TENANT_AWARE = ['api', 'web', 'mobile'];
|
|
@@ -6,14 +6,20 @@ function missing(flag) {
|
|
|
6
6
|
throw new CliError('needs_input', `Missing ${flag}. Outside an interactive terminal every answer must come from a flag.`);
|
|
7
7
|
}
|
|
8
8
|
function parseTypes(value) {
|
|
9
|
-
const types = value
|
|
9
|
+
const types = value
|
|
10
|
+
.split(',')
|
|
11
|
+
.map((type) => type.trim())
|
|
12
|
+
.filter((type) => type !== '');
|
|
10
13
|
const unknown = types.filter((type) => !TYPES.includes(type));
|
|
11
|
-
if (
|
|
14
|
+
if (types.length === 0) {
|
|
15
|
+
throw new CliError('invalid_input', `--types is empty. Choose from ${TYPES.join(', ')}.`);
|
|
16
|
+
}
|
|
17
|
+
if (unknown.length > 0) {
|
|
12
18
|
throw new CliError('invalid_input', `Unknown type: ${unknown.join(', ')}. Choose from ${TYPES.join(', ')}.`);
|
|
13
19
|
}
|
|
14
20
|
return [...new Set(types)];
|
|
15
21
|
}
|
|
16
|
-
export async function resolveAnswers(flags, asker) {
|
|
22
|
+
export async function resolveAnswers(flags, asker, cwd = process.cwd()) {
|
|
17
23
|
if (flags.alone && flags.monorepo) {
|
|
18
24
|
throw new CliError('invalid_input', 'Choose --alone or --monorepo, not both.');
|
|
19
25
|
}
|
|
@@ -23,10 +29,17 @@ export async function resolveAnswers(flags, asker) {
|
|
|
23
29
|
if (flags.multiTenant && flags.singleTenant) {
|
|
24
30
|
throw new CliError('invalid_input', 'Choose --multi-tenant or --single-tenant, not both.');
|
|
25
31
|
}
|
|
26
|
-
const
|
|
32
|
+
const input = flags.name ?? (asker === undefined ? missing('the project name') : await asker.name());
|
|
33
|
+
const { name, target } = projectAt(input, cwd);
|
|
27
34
|
const invalid = validateProjectName(name);
|
|
28
35
|
if (invalid !== undefined) {
|
|
29
|
-
|
|
36
|
+
const which = input === '.' ? ', taken from this directory' : '';
|
|
37
|
+
throw new CliError('invalid_input', `Invalid project name "${name}"${which}. ${invalid}`);
|
|
38
|
+
}
|
|
39
|
+
// Before the other questions, so nobody answers them all for a directory that cannot take the project.
|
|
40
|
+
const occupied = targetProblem(target);
|
|
41
|
+
if (occupied !== undefined) {
|
|
42
|
+
throw new CliError('target_not_empty', occupied);
|
|
30
43
|
}
|
|
31
44
|
let types;
|
|
32
45
|
if (flags.types !== undefined) {
|
|
@@ -52,17 +65,20 @@ export async function resolveAnswers(flags, asker) {
|
|
|
52
65
|
architecture = await asker.architecture();
|
|
53
66
|
}
|
|
54
67
|
let multiTenant = false;
|
|
55
|
-
if (types.some((type) => TENANT_AWARE.includes(type))) {
|
|
56
|
-
if (flags.multiTenant
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
else if (asker !== undefined) {
|
|
60
|
-
multiTenant = await asker.multiTenant();
|
|
61
|
-
}
|
|
62
|
-
else {
|
|
63
|
-
missing('--multi-tenant or --single-tenant');
|
|
68
|
+
if (!types.some((type) => TENANT_AWARE.includes(type))) {
|
|
69
|
+
if (flags.multiTenant) {
|
|
70
|
+
throw new CliError('invalid_input', `--multi-tenant needs one of ${TENANT_AWARE.join(', ')} in --types.`);
|
|
64
71
|
}
|
|
65
72
|
}
|
|
73
|
+
else if (flags.multiTenant || flags.singleTenant) {
|
|
74
|
+
multiTenant = flags.multiTenant;
|
|
75
|
+
}
|
|
76
|
+
else if (asker !== undefined) {
|
|
77
|
+
multiTenant = await asker.multiTenant();
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
missing('--multi-tenant or --single-tenant');
|
|
81
|
+
}
|
|
66
82
|
// MCP authorizes through a sign-in and a consent page, which only a web app can serve.
|
|
67
83
|
const mcpCapable = types.includes('api') && types.includes('web');
|
|
68
84
|
let mcp = false;
|
|
@@ -80,5 +96,5 @@ export async function resolveAnswers(flags, asker) {
|
|
|
80
96
|
else {
|
|
81
97
|
missing('--mcp or --no-mcp');
|
|
82
98
|
}
|
|
83
|
-
return { name, types, architecture, multiTenant, mcp };
|
|
99
|
+
return { name, types, architecture, multiTenant, mcp, target };
|
|
84
100
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stjoseph/prumo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -52,6 +52,6 @@
|
|
|
52
52
|
"vitest": "4.1.11"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
|
55
|
-
"node": ">=22.
|
|
55
|
+
"node": ">=22.18.0"
|
|
56
56
|
}
|
|
57
57
|
}
|
package/templates/api/README.md
CHANGED
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
"prepare": "node .githooks/install.mjs"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@better-auth/cimd": "1.7.
|
|
22
|
-
"@better-auth/
|
|
23
|
-
"@better-auth/
|
|
21
|
+
"@better-auth/cimd": "1.7.7",
|
|
22
|
+
"@better-auth/core": "1.7.7",
|
|
23
|
+
"@better-auth/expo": "1.7.7",
|
|
24
|
+
"@better-auth/mcp": "1.7.7",
|
|
24
25
|
"@fastify/cors": "11.3.0",
|
|
25
26
|
"@fastify/swagger": "9.9.0",
|
|
26
27
|
"@fastify/swagger-ui": "6.1.1",
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
"@mikro-orm/migrations": "7.2.0",
|
|
29
30
|
"@mikro-orm/postgresql": "7.2.0",
|
|
30
31
|
"@modelcontextprotocol/server": "2.2.0",
|
|
31
|
-
"better-auth": "1.7.
|
|
32
|
+
"better-auth": "1.7.7",
|
|
32
33
|
"fastify": "5.12.5",
|
|
33
34
|
"fastify-type-provider-zod": "7.0.0",
|
|
34
35
|
"pg": "8.23.0",
|
|
@@ -47,14 +48,14 @@
|
|
|
47
48
|
"@types/node": "24.13.4",
|
|
48
49
|
"@types/pg": "8.23.1",
|
|
49
50
|
"@vitest/coverage-v8": "4.1.11",
|
|
50
|
-
"auth": "1.7.
|
|
51
|
+
"auth": "1.7.7",
|
|
51
52
|
"pino-pretty": "13.1.3",
|
|
52
53
|
"typescript": "6.0.3",
|
|
53
54
|
"unplugin-swc": "1.6.0",
|
|
54
55
|
"vitest": "4.1.11"
|
|
55
56
|
},
|
|
56
57
|
"engines": {
|
|
57
|
-
"node": ">=22.
|
|
58
|
+
"node": ">=22.18.0"
|
|
58
59
|
},
|
|
59
60
|
"mikro-orm": {
|
|
60
61
|
"configPaths": [
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Requirements
|
|
4
4
|
|
|
5
|
-
Node 22.
|
|
5
|
+
Node 22.18 or later, pnpm 10.26 or later, and the API running with `MOBILE_APP_SCHEME=app`. A simulator or a device with Expo Go
|
|
6
6
|
to open the app.
|
|
7
7
|
|
|
8
8
|
## Running it
|
|
@@ -15,12 +15,13 @@
|
|
|
15
15
|
"prepare": "node .githooks/install.mjs"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@better-auth/
|
|
18
|
+
"@better-auth/core": "1.7.7",
|
|
19
|
+
"@better-auth/expo": "1.7.7",
|
|
19
20
|
"@hookform/resolvers": "5.9.1",
|
|
20
21
|
"@tanstack/query-sync-storage-persister": "5.102.8",
|
|
21
22
|
"@tanstack/react-query": "5.102.8",
|
|
22
23
|
"@tanstack/react-query-persist-client": "5.102.8",
|
|
23
|
-
"better-auth": "1.7.
|
|
24
|
+
"better-auth": "1.7.7",
|
|
24
25
|
"class-variance-authority": "0.7.1",
|
|
25
26
|
"expo": "57.0.22",
|
|
26
27
|
"expo-constants": "57.0.18",
|
|
@@ -59,6 +60,6 @@
|
|
|
59
60
|
"typescript": "6.0.3"
|
|
60
61
|
},
|
|
61
62
|
"engines": {
|
|
62
|
-
"node": ">=22.
|
|
63
|
+
"node": ">=22.18.0"
|
|
63
64
|
}
|
|
64
65
|
}
|
package/templates/site/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Requirements
|
|
4
4
|
|
|
5
|
-
Node 22.
|
|
5
|
+
Node 22.18 or later, and pnpm 10.26 or later. The build downloads its font from Google Fonts, so it needs network access.
|
|
6
6
|
|
|
7
7
|
## Running it
|
|
8
8
|
|
package/templates/web/README.md
CHANGED
|
@@ -16,12 +16,13 @@
|
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@base-ui/react": "1.8.0",
|
|
19
|
-
"@better-auth/
|
|
19
|
+
"@better-auth/core": "1.7.7",
|
|
20
|
+
"@better-auth/oauth-provider": "1.7.7",
|
|
20
21
|
"@fontsource-variable/geist": "5.3.0",
|
|
21
22
|
"@hookform/resolvers": "5.9.1",
|
|
22
23
|
"@tanstack/react-query": "5.102.8",
|
|
23
24
|
"@tanstack/react-router": "1.170.36",
|
|
24
|
-
"better-auth": "1.7.
|
|
25
|
+
"better-auth": "1.7.7",
|
|
25
26
|
"class-variance-authority": "0.7.1",
|
|
26
27
|
"cn": "0.3.0",
|
|
27
28
|
"lucide-react": "1.45.0",
|
|
@@ -52,6 +53,6 @@
|
|
|
52
53
|
"vitest": "4.1.11"
|
|
53
54
|
},
|
|
54
55
|
"engines": {
|
|
55
|
-
"node": ">=22.
|
|
56
|
+
"node": ">=22.18.0"
|
|
56
57
|
}
|
|
57
58
|
}
|