@stjoseph/prumo 0.5.1 → 0.6.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.
Files changed (42) hide show
  1. package/.prumo-templates/api/auth.md +1 -1
  2. package/.prumo-templates/mcp/server.md +1 -1
  3. package/.prumo-templates/monorepo/layout.md +5 -1
  4. package/.prumo-templates/monorepo/tasks.md +5 -3
  5. package/.prumo-templates/social/sign-in.md +2 -1
  6. package/README.md +3 -1
  7. package/dist/add.js +219 -0
  8. package/dist/apps.js +205 -0
  9. package/dist/asker.js +37 -0
  10. package/dist/clean.js +3 -3
  11. package/dist/cli.js +51 -2
  12. package/dist/commands.js +10 -0
  13. package/dist/compose.js +86 -28
  14. package/dist/context.js +32 -5
  15. package/dist/database.js +3 -4
  16. package/dist/generate.js +26 -24
  17. package/dist/names.js +2 -1
  18. package/dist/next-steps.js +4 -3
  19. package/dist/questions.js +22 -3
  20. package/dist/workspace-yaml.js +91 -0
  21. package/package.json +1 -1
  22. package/templates/api/.env.example +1 -0
  23. package/templates/api/README.md +2 -1
  24. package/templates/api/scripts/ports.mjs +60 -45
  25. package/templates/api/src/infra/config/env.ts +8 -1
  26. package/templates/api/src/infra/http/controllers/oauth.controller.ts +2 -2
  27. package/templates/api/src/infra/http/controllers/social.controller.ts +29 -13
  28. package/templates/api/src/infra/http/hooks/origin.hook.ts +4 -4
  29. package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +1 -1
  30. package/templates/api/test/infra/config/env.spec.ts +26 -0
  31. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +24 -0
  32. package/templates/api/test/infra/http/controllers/social.controller.spec.ts +23 -1
  33. package/templates/api/test/support/setup.ts +1 -1
  34. package/templates/mobile/scripts/ports.mjs +60 -45
  35. package/templates/mobile/src/api-contract/auth.ts +15 -3
  36. package/templates/site/scripts/ports.mjs +60 -45
  37. package/templates/web/scripts/ports.mjs +60 -45
  38. package/templates/web/src/api-contract/auth.ts +15 -3
  39. package/templates/web/src/features/auth/social-buttons.tsx +2 -2
  40. package/templates/web/test/features/auth/social-buttons.spec.tsx +1 -0
  41. package/templates/workspace/README.md +2 -2
  42. package/templates/workspace/scripts/ports.mjs +60 -45
@@ -20,7 +20,7 @@ still verify the password against a decoy hash, and answer exactly as for a wron
20
20
  Give the web its tokens in `httpOnly`, `SameSite=Lax` cookies: the access cookie on `/`, the refresh cookie only
21
21
  on `/api/auth/refresh`, both `Secure` when `API_URL` is https. Give a native client its tokens in the body, and
22
22
  only when it sends `X-Auth-Transport: bearer`; it then sends `Authorization: Bearer`. Refuse with 403 any
23
- request that changes state, carries a cookie and no bearer, and does not come from `WEB_ORIGIN`.
23
+ request that changes state, carries a cookie and no bearer, and does not come from an origin in `WEB_ORIGIN`, the comma-separated list of the web apps.
24
24
 
25
25
  Every route is protected by the global `preHandler` hook in `auth.hook.ts`. Mark a route that needs no session
26
26
  with `config: { public: true }`; that means only *do not answer 401*, and the token is still read. Read the
@@ -25,7 +25,7 @@ authorization profile:
25
25
  - Identify a client by its metadata document (CIMD) only. Do not enable dynamic client registration.
26
26
  - Check the client and its redirect URI, by exact match, before any error may be redirected.
27
27
  - Require PKCE with `S256`, and a `resource` equal to `mcpResource(env)`, which becomes the token's audience.
28
- - Send the browser to the web app's `/consent`. Show the client's name, the host of its `client_id` and the
28
+ - Send the browser to `/consent` on the first web app in `WEB_ORIGIN`. Show the client's name, the host of its `client_id` and the
29
29
  host of its redirect URI, and warn when that redirect returns to this device.
30
30
  - Return the code with `iss` (RFC 9207), and spend it once: a code presented twice revokes the session its
31
31
  first exchange started.
@@ -5,7 +5,10 @@
5
5
  Put deployable things in `apps/` and shared code in `packages/`.
6
6
 
7
7
  Name an app directory after its type (`apps/api`, `apps/web`, `apps/mobile`, `apps/site`), never after
8
- the product.
8
+ the product. A second web or site, or an app renamed on purpose, is named for its role (`apps/backoffice`,
9
+ `apps/blog`), still never after the product. The directory, the package and the root script share that name, and
10
+ `.prumo/config.json` lists every app with its type. Only `web` and `site` repeat; a workspace holds one `api` and
11
+ one `mobile`.
9
12
 
10
13
  Keep one package from the start: the integration layer between the clients and the API. It holds URL
11
14
  construction, the `ApiError` parsing, the hand-written contract types and the query factories.
@@ -58,6 +61,7 @@ Layout:
58
61
 
59
62
  ```
60
63
  ✅ apps/api apps/web packages/api-contract
64
+ ✅ apps/api apps/web apps/backoffice packages/api-contract
61
65
  ❌ apps/acme-backend packages/shared
62
66
  ```
63
67
 
@@ -9,7 +9,8 @@ and `test` as well, each running through `pnpm -r`.
9
9
  **Every app answers to `dev`.** When a framework names its development command differently, keep its name and
10
10
  alias it: `"dev": "pnpm start:dev"` in the API, `"dev": "pnpm start"` in mobile.
11
11
 
12
- **Give every app its own port.** The API holds 3000, so the site runs on 3200.
12
+ **Give every app its own port.** The API holds 3000, so the site runs on 3200. A second app of a type takes the
13
+ next port of that type: a second web 5174, a second site 3201.
13
14
 
14
15
  Keep compiler options in `tsconfig.base.json` at the workspace root. Extend it by relative path from each
15
16
  app and package, overriding only what differs.
@@ -82,8 +83,9 @@ Extending the base:
82
83
  **The hook.** Pre-push runs the root `typecheck`, so a type error in any package stops the push.
83
84
 
84
85
  **Review only.** That a new app extends the base rather than copying it, answers to `dev`, runs on a port no
85
- other app uses, and has a root script named after it. A missing `dev` fails nothing: `pnpm -r` skips a package
86
- without the script, so the app simply does not start.
86
+ other app uses, has a root script named after it, and is listed in `.prumo/config.json`, where `scripts/ports.mjs`
87
+ finds it. `prumo add <type> [name]` writes all of it; an app added by hand needs the same. A missing `dev` fails
88
+ nothing: `pnpm -r` skips a package without the script, so the app simply does not start.
87
89
 
88
90
  **The reversal condition, stated so it is measurable rather than a feeling:** without caching,
89
91
  `pnpm -r typecheck` runs `tsc` in every package from scratch every time. Turborepo belongs on the table the
@@ -12,7 +12,8 @@ in the `social_sign_in` cookie that the callback must present. Use PKCE wherever
12
12
  it, and a nonce always. Turn Apple's `form_post` callback into a redirect to the same address, so the cookie
13
13
  comes with it.
14
14
 
15
- Return the web to the path it asked for, only a path on the web app, with session cookies. Return a mobile app
15
+ Return the web to the path it asked for, only a path, with session cookies, on the web app that started: it sends
16
+ its `origin`, which must be in `WEB_ORIGIN`, and the `social_sign_in` cookie carries it to the callback. Return a mobile app
16
17
  to `MOBILE_APP_SCHEME://social` with a one-time code it exchanges at `/api/auth/social/exchange` for its tokens,
17
18
  within two minutes. A mobile app opens the trip with `expo-web-browser`'s `openAuthSessionAsync`.
18
19
 
package/README.md CHANGED
@@ -76,6 +76,7 @@ npx @stjoseph/prumo new my-api --types api --alone --single-tenant --email
76
76
  | Flag | What it answers |
77
77
  |---|---|
78
78
  | `--types api,web,mobile,site` | What the project contains. More than one type makes a workspace |
79
+ | `--app <name>=<type>` | Advanced, repeatable: an app with a name of its own, as `--app backoffice=web`. A type left out of `--types` and given here is renamed. Only `web` and `site` repeat, and an `--app` makes a workspace |
79
80
  | `--alone` or `--monorepo` | How a single type is laid out, so one type needs one of them. Several types always make a workspace and refuse `--alone` |
80
81
  | `--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 |
81
82
  | `--mcp` or `--no-mcp` | With an api and a web: whether AI assistants reach the API through MCP, signed in as the user |
@@ -88,6 +89,7 @@ npx @stjoseph/prumo new my-api --types api --alone --single-tenant --email
88
89
  | Command | What it does |
89
90
  |---|---|
90
91
  | `prumo new [name \| .]` | Generates a project |
92
+ | `prumo add <type> [name]` | Inside a workspace: adds an app, named after its type unless a name is given, as `prumo add web backoffice`. It gets the next free port, the shared contract, the API's trust when it is a web app, and the `.prumo/` areas it needs. Only the Prumo version that generated the project adds to it |
91
93
  | `prumo db` | Inside a project with an API: creates the development database in Docker and writes its URL into `.env` |
92
94
  | `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 |
93
95
  | `prumo doctor` | Checks Node, pnpm, git and Docker on this machine, and that Docker runs Linux containers |
@@ -108,7 +110,7 @@ is asked, and the exit code is 0 only when `ok` is true.
108
110
  | `site` | Next on the App Router, static by default, for pages that have to be indexed |
109
111
 
110
112
  One type produces that project on its own. Several produce a pnpm workspace: each application under
111
- `apps/`, the wire contract shared through `packages/api-contract` so a change on one side breaks the
113
+ `apps/`, named after its type unless it was named on purpose, the wire contract shared through `packages/api-contract` so a change on one side breaks the
112
114
  other at typecheck, and one version per dependency held in a catalog.
113
115
 
114
116
  Every template is committed here in full, and none of them is generated by calling somebody else's
package/dist/add.js ADDED
@@ -0,0 +1,219 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { join, relative } from 'node:path';
4
+ import { appsOf, appsOfType, PORTS, typesOf, validateApps } from "./apps.js";
5
+ import { adaptAppConfig, CLIENTS, collectCatalog, copyApp, copyTemplate, extractContract, joinContract, rootDevPrefix, takeBuildApprovals, } from "./compose.js";
6
+ import { areasFor, growContext } from "./context.js";
7
+ import { findProjectRoot, readConfig } from "./database.js";
8
+ import { applyFeatures, enabledFor } from "./features.js";
9
+ import { nameCompose, nameMobileApp, run, settleMobileScheme, watchInterrupt, writeLocalEnv, } from "./generate.js";
10
+ import { readJsonc, setJsonc } from "./jsonc.js";
11
+ import { schemeFor } from "./names.js";
12
+ import { CliError } from "./output.js";
13
+ import { cliVersion } from "./version.js";
14
+ import { addApprovals, addToCatalogs } from "./workspace-yaml.js";
15
+ // What the run changed, so a failure halfway puts the project back as it found it: each file it rewrote returns
16
+ // to what it held, and each it created goes.
17
+ class Journal {
18
+ before = new Map();
19
+ async keep(path) {
20
+ if (!this.before.has(path)) {
21
+ this.before.set(path, existsSync(path) ? await readFile(path, 'utf8') : null);
22
+ }
23
+ }
24
+ creates(path) {
25
+ if (!this.before.has(path)) {
26
+ this.before.set(path, null);
27
+ }
28
+ }
29
+ async undo() {
30
+ for (const [path, text] of [...this.before].reverse()) {
31
+ if (text === null) {
32
+ await rm(path, { recursive: true, force: true });
33
+ }
34
+ else {
35
+ await writeFile(path, text);
36
+ }
37
+ }
38
+ }
39
+ }
40
+ function envLine(text, key) {
41
+ return new RegExp(`^${key}=(.*)$`, 'm').exec(text)?.[1]?.trim();
42
+ }
43
+ function withEnvLine(text, key, value) {
44
+ return text.replace(new RegExp(`^${key}=.*$`, 'm'), `${key}=${value}`);
45
+ }
46
+ async function readEnv(directory) {
47
+ for (const file of ['.env', '.env.example']) {
48
+ const text = await readFile(join(directory, file), 'utf8').catch(() => undefined);
49
+ if (text !== undefined) {
50
+ return text;
51
+ }
52
+ }
53
+ return '';
54
+ }
55
+ const SCHEME_BLOCK = (scheme) => `# The mobile app's scheme, as in app.json: where it is sent back after signing in with a provider.\nMOBILE_APP_SCHEME=${scheme}\n`;
56
+ export async function addApp({ templates, knowledge, cwd, type, name = type, install, childOutput = 'inherit', }) {
57
+ const root = findProjectRoot(cwd);
58
+ const config = readConfig(root);
59
+ if (config.architecture !== 'monorepo') {
60
+ throw new CliError('not_a_workspace', 'This project is one app, laid out alone. `prumo add` grows a workspace.');
61
+ }
62
+ // The app is copied from this CLI's templates, so only the version that wrote the project gives it the
63
+ // dependencies its catalog holds and an API that trusts several web apps.
64
+ const wrote = config.prumo;
65
+ const running = cliVersion();
66
+ if (wrote !== running) {
67
+ const [major = 0, minor = 0] = (wrote ?? '0.0').split('.').map(Number);
68
+ throw new CliError('version_mismatch', major === 0 && minor < 6
69
+ ? `This project was generated by Prumo ${wrote ?? 'before 0.1.1'}, and \`prumo add\` exists from 0.6.0.`
70
+ : `This project was generated by Prumo ${wrote}, and this is ${running}. Run \`npx @stjoseph/prumo@${wrote} add ${type} ${name}\`.`);
71
+ }
72
+ const project = readJsonc(await readFile(join(root, 'package.json'), 'utf8')).name;
73
+ const apps = appsOf(config);
74
+ const app = { name, type };
75
+ const next = [...apps, app];
76
+ validateApps(next, project);
77
+ const directory = join(root, 'apps', name);
78
+ if (existsSync(directory)) {
79
+ throw new CliError('target_not_empty', `${relative(root, directory)} already exists.`);
80
+ }
81
+ const answers = { ...config, name: project, types: typesOf(next), apps: next };
82
+ const journal = new Journal();
83
+ const unwatch = watchInterrupt();
84
+ try {
85
+ const result = await write({ templates, knowledge, root, project, apps, app, answers, journal });
86
+ if (install) {
87
+ await journal.keep(join(root, 'pnpm-lock.yaml'));
88
+ run('pnpm', ['install'], root, childOutput);
89
+ // Only what this run wrote: the rest of the workspace is the team's, formatted as they left it.
90
+ run('pnpm', [
91
+ 'exec',
92
+ 'biome',
93
+ 'check',
94
+ '--write',
95
+ ...result.written.map((path) => relative(root, path)),
96
+ ], root, childOutput);
97
+ if (type === 'web') {
98
+ run('pnpm', ['exec', 'tsr', 'generate'], directory, childOutput);
99
+ }
100
+ }
101
+ return { app, directory, port: result.port, areas: result.areas, installed: install };
102
+ }
103
+ catch (error) {
104
+ await journal.undo();
105
+ throw error;
106
+ }
107
+ finally {
108
+ unwatch();
109
+ }
110
+ }
111
+ async function write({ templates, knowledge, root, project, apps, app, answers, journal, }) {
112
+ const at = (one) => join(root, 'apps', one.name);
113
+ const directory = at(app);
114
+ const written = [directory];
115
+ const [api] = appsOfType(apps, 'api');
116
+ const rewrite = async (path, change) => {
117
+ await journal.keep(path);
118
+ await writeFile(path, change(await readFile(path, 'utf8')));
119
+ };
120
+ journal.creates(directory);
121
+ await copyApp(templates, root, app);
122
+ await applyFeatures(directory, app.type, enabledFor(answers));
123
+ // The first port of its type that no app of the workspace holds, as each one's .env says today.
124
+ const { key, from } = PORTS[app.type];
125
+ const held = new Set();
126
+ for (const other of apps) {
127
+ const port = envLine(await readEnv(at(other)), PORTS[other.type].key);
128
+ held.add(Number(port ?? PORTS[other.type].from));
129
+ }
130
+ let port = from;
131
+ while (held.has(port)) {
132
+ port += 1;
133
+ }
134
+ await rewrite(join(directory, '.env.example'), (text) => withEnvLine(text, key, String(port)));
135
+ if (app.type === 'site') {
136
+ await rewrite(join(directory, 'package.json'), (text) => {
137
+ const start = readJsonc(text).scripts.start;
138
+ return setJsonc(text, ['scripts', 'start'], start.replace(`--port ${from}`, `--port ${port}`));
139
+ });
140
+ }
141
+ if (CLIENTS.includes(app.type)) {
142
+ const contract = join(root, 'packages', 'api-contract');
143
+ if (existsSync(contract)) {
144
+ await joinContract(directory);
145
+ }
146
+ else {
147
+ journal.creates(contract);
148
+ await copyTemplate(join(templates, 'workspace', 'packages', 'api-contract'), contract);
149
+ await extractContract(root, [app]);
150
+ written.push(contract);
151
+ }
152
+ }
153
+ // Before the app's own manifest becomes `catalog:`, which is what says the versions it needs.
154
+ const manifest = readJsonc(await readFile(join(directory, 'package.json'), 'utf8'));
155
+ const workspaceYaml = join(root, 'pnpm-workspace.yaml');
156
+ await rewrite(workspaceYaml, (text) => addToCatalogs(text, collectCatalog([manifest])));
157
+ await adaptAppConfig(root, directory);
158
+ const approvals = await takeBuildApprovals(directory);
159
+ await rewrite(workspaceYaml, (text) => addApprovals(text, approvals));
160
+ await rewrite(join(root, 'package.json'), (text) => {
161
+ let pkg = setJsonc(text, ['scripts', app.name], `pnpm --filter ${app.name} dev`);
162
+ if (app.type === 'api') {
163
+ const dev = readJsonc(pkg).scripts.dev;
164
+ pkg = setJsonc(pkg, ['scripts', 'dev'], `${rootDevPrefix(app.name)}${dev}`);
165
+ }
166
+ return pkg;
167
+ });
168
+ await writeLocalEnv(directory, app.type);
169
+ const origin = `http://localhost:${port}`;
170
+ const webs = appsOfType(apps, 'web');
171
+ const mobile = appsOfType(apps, 'mobile')[0];
172
+ for (const file of ['.env.example', '.env']) {
173
+ // A new web app is one more origin for the API to trust.
174
+ if (app.type === 'web' && api !== undefined && existsSync(join(at(api), file))) {
175
+ await rewrite(join(at(api), file), (text) => {
176
+ const list = (envLine(text, 'WEB_ORIGIN') ?? '').split(',').filter((entry) => entry !== '');
177
+ return withEnvLine(text, 'WEB_ORIGIN', [...list, origin].join(','));
178
+ });
179
+ }
180
+ // A new API trusts the web apps there already, on the ports they hold.
181
+ if (app.type === 'api' && webs.length > 0) {
182
+ const origins = [];
183
+ for (const web of webs) {
184
+ origins.push(`http://localhost:${envLine(await readEnv(at(web)), 'WEB_PORT') ?? 5173}`);
185
+ }
186
+ await rewrite(join(directory, file), (text) => withEnvLine(text, 'WEB_ORIGIN', origins.join(',')));
187
+ }
188
+ // A mobile app signing in with a provider is sent back by its scheme, which the API was given none of.
189
+ if (app.type === 'mobile' &&
190
+ api !== undefined &&
191
+ answers.social.length > 0 &&
192
+ existsSync(join(at(api), file))) {
193
+ await rewrite(join(at(api), file), (text) => /^MOBILE_APP_SCHEME=/m.test(text)
194
+ ? text
195
+ : text.replace(/^(WEB_ORIGIN=.*\n)/m, `$1${SCHEME_BLOCK(schemeFor(project))}`));
196
+ }
197
+ }
198
+ // A client points at the API where it answers now, which may not be where the template said.
199
+ const apiUrl = api === undefined ? undefined : envLine(await readEnv(at(api)), 'API_URL');
200
+ const clientKey = { web: 'VITE_API_URL', mobile: 'EXPO_PUBLIC_API_URL' }[app.type];
201
+ if (apiUrl !== undefined && clientKey !== undefined) {
202
+ await rewrite(join(directory, '.env'), (text) => withEnvLine(text, clientKey, apiUrl));
203
+ }
204
+ if (app.type === 'api') {
205
+ await nameCompose(directory, project);
206
+ await settleMobileScheme(directory, mobile === undefined ? null : schemeFor(project));
207
+ }
208
+ if (app.type === 'mobile') {
209
+ await nameMobileApp(directory, project);
210
+ }
211
+ const prumo = join(root, '.prumo');
212
+ for (const area of areasFor(answers).filter((each) => !existsSync(join(prumo, each)))) {
213
+ journal.creates(join(prumo, area));
214
+ }
215
+ await journal.keep(join(prumo, 'INDEX.md'));
216
+ await journal.keep(join(prumo, 'config.json'));
217
+ const areas = await growContext(knowledge, root, answers);
218
+ return { port, areas, written: [...written, join(prumo, 'config.json')] };
219
+ }
package/dist/apps.js ADDED
@@ -0,0 +1,205 @@
1
+ import { join } from 'node:path';
2
+ import { CliError } from "./output.js";
3
+ export const TYPES = ['api', 'web', 'mobile', 'site'];
4
+ // A second api would need its own database, contract and clients to point at it, and a second mobile app its own
5
+ // scheme for the API to send it back to; neither is offered.
6
+ export const REPEATABLE = ['web', 'site'];
7
+ const APP_NAME = /^[a-z][a-z0-9-]*$/;
8
+ // The workspace root's own scripts, which an app's script of the same name would overwrite.
9
+ export const ROOT_SCRIPTS = ['dev', 'lint', 'typecheck', 'test', 'prepare'];
10
+ // `pnpm <name>` runs the root script only when pnpm has no command of that name. The commands and aliases of pnpm
11
+ // 10.26, the floor, and of 12.3.
12
+ const PNPM_COMMANDS = [
13
+ 't',
14
+ 'access',
15
+ 'init',
16
+ 'recursive',
17
+ 'multi',
18
+ 'm',
19
+ 'add',
20
+ 'install',
21
+ 'i',
22
+ 'install-test',
23
+ 'it',
24
+ 'update',
25
+ 'up',
26
+ 'upgrade',
27
+ 'outdated',
28
+ 'audit',
29
+ 'change',
30
+ 'version',
31
+ 'lane',
32
+ 'bugs',
33
+ 'issues',
34
+ 'list',
35
+ 'ls',
36
+ 'll',
37
+ 'la',
38
+ 'licenses',
39
+ 'licences',
40
+ 'why',
41
+ 'view',
42
+ 'info',
43
+ 'show',
44
+ 'v',
45
+ 'sbom',
46
+ 'whoami',
47
+ 'deprecate',
48
+ 'undeprecate',
49
+ 'unpublish',
50
+ 'star',
51
+ 'unstar',
52
+ 'stars',
53
+ 'dist-tag',
54
+ 'dist-tags',
55
+ 'ping',
56
+ 'doctor',
57
+ 'search',
58
+ 's',
59
+ 'se',
60
+ 'find',
61
+ 'rebuild',
62
+ 'rb',
63
+ 'pack',
64
+ 'publish',
65
+ 'stage',
66
+ 'remove',
67
+ 'uninstall',
68
+ 'rm',
69
+ 'un',
70
+ 'uni',
71
+ 'patch',
72
+ 'patch-commit',
73
+ 'patch-remove',
74
+ 'peers',
75
+ 'set-script',
76
+ 'ss',
77
+ 'run',
78
+ 'exec',
79
+ 'dlx',
80
+ 'create',
81
+ 'completion',
82
+ 'start',
83
+ 'stop',
84
+ 'restart',
85
+ 'find-hash',
86
+ 'runtime',
87
+ 'rt',
88
+ 'env',
89
+ 'shim',
90
+ 'bin',
91
+ 'clean',
92
+ 'purge',
93
+ 'ci',
94
+ 'clean-install',
95
+ 'ic',
96
+ 'install-clean',
97
+ 'root',
98
+ 'prefix',
99
+ 'config',
100
+ 'c',
101
+ 'get',
102
+ 'set',
103
+ 'pkg',
104
+ 'pack-app',
105
+ 'store',
106
+ 'cache',
107
+ 'cat-file',
108
+ 'cat-index',
109
+ 'ignored-builds',
110
+ 'approve-builds',
111
+ 'link',
112
+ 'ln',
113
+ 'import',
114
+ 'dedupe',
115
+ 'deploy',
116
+ 'prune',
117
+ 'fetch',
118
+ 'unlink',
119
+ 'dislink',
120
+ 'docs',
121
+ 'home',
122
+ 'repo',
123
+ 'self-update',
124
+ 'setup',
125
+ 'login',
126
+ 'adduser',
127
+ 'team',
128
+ 'owner',
129
+ 'owners',
130
+ 'logout',
131
+ 'with',
132
+ 'edit',
133
+ 'profile',
134
+ 'token',
135
+ 'xmas',
136
+ 'help',
137
+ ];
138
+ export const RESERVED_APP_NAMES = [...ROOT_SCRIPTS, ...PNPM_COMMANDS];
139
+ // Where an app of a type keeps its port and the first one it takes; a second app of the type takes the next.
140
+ export const PORTS = {
141
+ api: { key: 'PORT', from: 3000 },
142
+ web: { key: 'WEB_PORT', from: 5173 },
143
+ site: { key: 'SITE_PORT', from: 3200 },
144
+ mobile: { key: 'METRO_PORT', from: 8081 },
145
+ };
146
+ export function defaultApps(types) {
147
+ return types.map((type) => ({ name: type, type }));
148
+ }
149
+ export function typesOf(apps) {
150
+ return [...new Set(apps.map((app) => app.type))];
151
+ }
152
+ export function appsOfType(apps, type) {
153
+ return apps.filter((app) => app.type === type);
154
+ }
155
+ // `name=type`, as `--app backoffice=web` writes it.
156
+ export function parseApp(value) {
157
+ const [name = '', type = '', ...rest] = value.split('=').map((part) => part.trim());
158
+ if (rest.length > 0 || name === '' || type === '') {
159
+ throw new CliError('invalid_app', `--app takes <name>=<type>, as in backoffice=web; got "${value}".`);
160
+ }
161
+ if (!TYPES.includes(type)) {
162
+ throw new CliError('invalid_app', `Unknown type in --app ${value}: ${type}. Choose from ${TYPES.join(', ')}.`);
163
+ }
164
+ return { name, type: type };
165
+ }
166
+ // Why a name cannot name an app beside the others, or undefined when it can.
167
+ export function appNameProblem(name, others, project) {
168
+ if (!APP_NAME.test(name)) {
169
+ return 'Use lowercase letters, digits and hyphens, starting with a letter.';
170
+ }
171
+ if (RESERVED_APP_NAMES.includes(name)) {
172
+ return `"${name}" is a pnpm command or a root script, so \`pnpm ${name}\` would never start the app.`;
173
+ }
174
+ if (name === project) {
175
+ return 'That is the project’s name, which its root package already takes.';
176
+ }
177
+ if (others.some((other) => other.name === name)) {
178
+ return `Another app is already named "${name}".`;
179
+ }
180
+ return undefined;
181
+ }
182
+ export function validateApps(apps, project) {
183
+ apps.forEach((app, index) => {
184
+ const problem = appNameProblem(app.name, apps.slice(0, index), project);
185
+ if (problem !== undefined) {
186
+ throw new CliError('invalid_app', `Invalid app name "${app.name}". ${problem}`);
187
+ }
188
+ });
189
+ for (const type of TYPES.filter((each) => !REPEATABLE.includes(each))) {
190
+ if (appsOfType(apps, type).length > 1) {
191
+ throw new CliError('invalid_app', `A workspace holds one ${type}; only ${REPEATABLE.join(' and ')} may repeat.`);
192
+ }
193
+ }
194
+ }
195
+ // Each app's port: the type's first, then the next for each further app of that type.
196
+ export function portOf(apps, app) {
197
+ return PORTS[app.type].from + appsOfType(apps, app.type).indexOf(app);
198
+ }
199
+ export function appDirectory(root, architecture, app) {
200
+ return architecture === 'alone' ? root : join(root, 'apps', app.name);
201
+ }
202
+ // The apps a project's config describes. One written before apps had names holds one app per type, named after it.
203
+ export function appsOf(config) {
204
+ return config.apps ?? defaultApps(config.types);
205
+ }
package/dist/asker.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import * as prompt from '@clack/prompts';
2
+ import { appNameProblem, REPEATABLE } from "./apps.js";
2
3
  import { SOCIAL_PROVIDERS } from "./features.js";
3
4
  const TYPES = ['api', 'web', 'mobile', 'site'];
5
+ const DONE = 'done';
4
6
  function answered(value) {
5
7
  if (prompt.isCancel(value)) {
6
8
  prompt.cancel('Nothing was generated.');
@@ -24,6 +26,41 @@ export function terminalAsker(validateName) {
24
26
  ],
25
27
  initialValue: 'alone',
26
28
  })),
29
+ apps: async (defaults, project) => {
30
+ const advanced = answered(await prompt.confirm({
31
+ message: 'Advanced: rename the apps, or add another web or site?',
32
+ initialValue: false,
33
+ }));
34
+ if (!advanced) {
35
+ return defaults;
36
+ }
37
+ const apps = [];
38
+ const named = async (type, initialValue) => {
39
+ const name = answered(await prompt.text({
40
+ message: `Name of the ${type} app`,
41
+ initialValue,
42
+ validate: (value) => appNameProblem(value ?? '', apps, project),
43
+ }));
44
+ apps.push({ name, type });
45
+ };
46
+ for (const app of defaults) {
47
+ await named(app.type, app.name);
48
+ }
49
+ while (true) {
50
+ const type = answered(await prompt.select({
51
+ message: 'Add another app?',
52
+ options: [
53
+ { value: DONE, label: 'no, that is all' },
54
+ ...REPEATABLE.map((each) => ({ value: each, label: each })),
55
+ ],
56
+ initialValue: DONE,
57
+ }));
58
+ if (type === DONE) {
59
+ return apps;
60
+ }
61
+ await named(type, '');
62
+ }
63
+ },
27
64
  multiTenant: async () => answered(await prompt.confirm({ message: 'Is it multi-tenant?', initialValue: false })),
28
65
  mcp: async () => answered(await prompt.confirm({
29
66
  message: 'Let AI assistants use the API through MCP, signed in as the user?',
package/dist/clean.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readdir, readFile, rm, rmdir, writeFile } from 'node:fs/promises';
3
- import { dirname, join, relative } from 'node:path';
3
+ import { basename, dirname, join, relative } from 'node:path';
4
+ import { rootDevPrefix } from "./compose.js";
4
5
  import { apiPath, findProjectRoot, readConfig } from "./database.js";
5
6
  import { readJsonc, setJsonc } from "./jsonc.js";
6
7
  import { CliError } from "./output.js";
7
8
  import { cliVersion } from "./version.js";
8
9
  const SAMPLE_URL = 'postgresql://app:app@localhost:5432/app';
9
10
  export const API_DEV_PREFIX = 'node scripts/database.mjs --check && ';
10
- export const ROOT_DEV_PREFIX = 'node apps/api/scripts/database.mjs --check && ';
11
11
  export const SETUP_SCRIPT = 'node scripts/database.mjs';
12
12
  export const EXAMPLE_BLOCK = '# MISSING until `pnpm db:setup` creates a database; `pnpm dev` offers to run it.\n' +
13
13
  'DATABASE_URL=MISSING\n';
@@ -56,7 +56,7 @@ function rulesFor(root, api, templates) {
56
56
  textRule('api-readme-row', at('README.md'), 'Remove `db:setup` from the README', README_ROW, '', '`pnpm db:setup`'),
57
57
  ];
58
58
  if (api !== root) {
59
- 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
+ rules.push(devRule('root-dev-check', 'package.json', rootDevPrefix(basename(api))), textRule('root-readme', 'README.md', 'Remove the database offer from the README', WORKSPACE_SENTENCE, '', 'offers to create the database'));
60
60
  }
61
61
  return { rules, script: join(templates, 'api', 'scripts', 'database.mjs') };
62
62
  }