@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.
@@ -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
- **Review only.** That each `public: true` is deliberate: the one mistake that is invisible at runtime, because
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, so there are no
19
- ports, no adapters, and no in house architecture to learn before writing the first endpoint.
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.17 or later, pnpm, and Docker for anything with an API. `prumo doctor` checks all of it.
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 nine areas. Only the areas the answers call for are copied |
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 nine areas are `core`, `api`, `database`, `client`, `web`, `mobile`, `site`, `monorepo` and
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. `0.0.1` is the first published version: every template runs and every generated
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 { validateProjectName } from "./names.js";
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(validateProjectName) : undefined);
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 = resolve(answers.name);
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: { ...answers, target, installed: install }, text: '' };
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.filter((arg) => !arg.startsWith('-'));
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
- { flag: '--multi-tenant', description: 'Tenant-aware api, web and mobile' },
15
- { flag: '--single-tenant', description: 'No tenancy' },
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, recorded in DECISIONS.md: stable NativeWind needs Tailwind 3.
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, 17, 0];
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.17.0 or later is required` }),
58
+ : { status: 'fail', detail: `${probe.nodeVersion}; 22.18.0 or later is required` }),
32
59
  });
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
- }
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
- throw new Error(`${command} ${args.join(' ')} failed in ${cwd}`);
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
- if (existsSync(target) && (await readdir(target)).length > 0) {
65
- throw new CliError('target_not_empty', `${target} already exists and is not empty.`);
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.split(',').map((type) => type.trim());
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 (unknown.length > 0 || types.length === 0) {
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 name = flags.name ?? (asker === undefined ? missing('the project name') : await asker.name());
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
- throw new CliError('invalid_input', `Invalid project name "${name}". ${invalid}`);
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 || flags.singleTenant) {
57
- multiTenant = flags.multiTenant;
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.1.1",
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.17.0"
55
+ "node": ">=22.18.0"
56
56
  }
57
57
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Requirements
4
4
 
5
- Node 22.17 or later, pnpm, and Docker Desktop running: the database and the tests both run in it.
5
+ Node 22.18 or later, pnpm 10.26 or later, and Docker Desktop running: the database and the tests both run in it.
6
6
 
7
7
  ## Running it
8
8
 
@@ -18,9 +18,10 @@
18
18
  "prepare": "node .githooks/install.mjs"
19
19
  },
20
20
  "dependencies": {
21
- "@better-auth/cimd": "1.7.6",
22
- "@better-auth/expo": "1.7.6",
23
- "@better-auth/mcp": "1.7.6",
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.6",
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.6",
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.17.0"
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.17 or later, pnpm, and the API running with `MOBILE_APP_SCHEME=app`. A simulator or a device with Expo Go
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/expo": "1.7.6",
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.6",
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.17.0"
63
+ "node": ">=22.18.0"
63
64
  }
64
65
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Requirements
4
4
 
5
- Node 22.17 or later, and pnpm. The build downloads its font from Google Fonts, so it needs network access.
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
 
@@ -35,6 +35,6 @@
35
35
  "vitest": "4.1.11"
36
36
  },
37
37
  "engines": {
38
- "node": ">=22.17.0"
38
+ "node": ">=22.18.0"
39
39
  }
40
40
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Requirements
4
4
 
5
- Node 22.17 or later, pnpm, and the API running.
5
+ Node 22.18 or later, pnpm 10.26 or later, and the API running.
6
6
 
7
7
  ## Running it
8
8
 
@@ -16,12 +16,13 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@base-ui/react": "1.8.0",
19
- "@better-auth/oauth-provider": "1.7.6",
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.6",
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.17.0"
56
+ "node": ">=22.18.0"
56
57
  }
57
58
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Requirements
4
4
 
5
- Node 22.17 or later and pnpm. For the API, Docker Desktop running: its database and tests run in it.
5
+ Node 22.18 or later and pnpm 10.26 or later. For the API, Docker Desktop running: its database and tests run in it.
6
6
 
7
7
  ## Layout
8
8
 
@@ -12,6 +12,6 @@
12
12
  "@biomejs/biome": "catalog:"
13
13
  },
14
14
  "engines": {
15
- "node": ">=22.17.0"
15
+ "node": ">=22.18.0"
16
16
  }
17
17
  }