@stjoseph/prumo 0.5.0 → 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 (56) hide show
  1. package/.prumo-templates/api/auth.md +1 -1
  2. package/.prumo-templates/core/testing.md +14 -4
  3. package/.prumo-templates/mcp/server.md +1 -1
  4. package/.prumo-templates/monorepo/layout.md +5 -1
  5. package/.prumo-templates/monorepo/tasks.md +5 -3
  6. package/.prumo-templates/social/sign-in.md +2 -1
  7. package/README.md +15 -2
  8. package/dist/add.js +219 -0
  9. package/dist/apps.js +205 -0
  10. package/dist/asker.js +37 -0
  11. package/dist/clean.js +3 -3
  12. package/dist/cli.js +51 -2
  13. package/dist/commands.js +10 -0
  14. package/dist/compose.js +86 -28
  15. package/dist/context.js +32 -5
  16. package/dist/database.js +3 -4
  17. package/dist/generate.js +26 -24
  18. package/dist/names.js +15 -2
  19. package/dist/next-steps.js +4 -3
  20. package/dist/pnpm-extended.js +159 -0
  21. package/dist/questions.js +22 -3
  22. package/dist/workspace-yaml.js +91 -0
  23. package/package.json +4 -1
  24. package/templates/api/.env.example +1 -0
  25. package/templates/api/README.md +2 -1
  26. package/templates/api/scripts/ports.mjs +60 -45
  27. package/templates/api/src/infra/config/env.ts +8 -1
  28. package/templates/api/src/infra/http/controllers/oauth.controller.ts +2 -2
  29. package/templates/api/src/infra/http/controllers/social.controller.ts +29 -13
  30. package/templates/api/src/infra/http/hooks/origin.hook.ts +4 -4
  31. package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +1 -1
  32. package/templates/api/test/infra/config/env.spec.ts +26 -0
  33. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +24 -0
  34. package/templates/api/test/infra/http/controllers/social.controller.spec.ts +23 -1
  35. package/templates/api/test/support/setup.ts +1 -1
  36. package/templates/mobile/scripts/ports.mjs +60 -45
  37. package/templates/mobile/src/api-contract/auth.ts +15 -3
  38. package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +3 -2
  39. package/templates/mobile/test/features/auth/email-flows.spec.tsx +3 -2
  40. package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +3 -2
  41. package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +3 -2
  42. package/templates/mobile/test/features/auth/social-buttons.spec.tsx +3 -2
  43. package/templates/mobile/test/features/profile/profile-form.spec.tsx +3 -4
  44. package/templates/mobile/test/support/query-client.ts +14 -0
  45. package/templates/site/scripts/ports.mjs +60 -45
  46. package/templates/web/scripts/ports.mjs +60 -45
  47. package/templates/web/src/api-contract/auth.ts +15 -3
  48. package/templates/web/src/features/auth/social-buttons.tsx +2 -2
  49. package/templates/web/test/features/auth/social-buttons.spec.tsx +1 -0
  50. package/templates/web/test/features/oauth/consent-form.spec.tsx +3 -2
  51. package/templates/web/test/features/profile/profile-form.spec.tsx +3 -2
  52. package/templates/web/test/support/query-client.ts +14 -0
  53. package/templates/web/test/support/render-app.tsx +2 -2
  54. package/templates/web/vite.config.ts +10 -3
  55. package/templates/workspace/README.md +2 -2
  56. 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
@@ -15,7 +15,8 @@ Do not mock `EntityManager` or any concrete class from a library.
15
15
 
16
16
  **In a client, replace the network through the injected transport**: pass a fake `fetch` to `createClient`.
17
17
  Do not add a request-mocking library. Test a component that shows an error by rendering it and asserting
18
- **where** the error appears.
18
+ **where** the error appears. Build every test's `QueryClient` with `createTestQueryClient()` from
19
+ `@test/support/query-client`, never with `new QueryClient()`: the defaults a test needs live there, once.
19
20
 
20
21
  Measure coverage and report it. Do not block a merge on a coverage threshold.
21
22
 
@@ -59,9 +60,18 @@ written in a mobile test is a mistake an assistant will make.
59
60
  is **asynchronous** and its matchers need no setup. `expo-secure-store` needs the native Keychain or Keystore,
60
61
  which Jest does not have, so a test builds the auth client with an in-memory token store, which is why a form
61
62
  asks only for the auth methods it calls. And `jest-expo` leaves the app manifest empty, so `expo-linking` cannot
62
- know the app's scheme: a test that reaches `Linking.createURL` mocks it with the scheme it expects. A test rendering a `useMutation` builds
63
- its `QueryClient` with `mutations: { gcTime: Infinity }`: once unmounted, the mutation otherwise starts TanStack
64
- Query's five-minute garbage-collection timer, and Jest force-exits the worker still waiting on it.
63
+ know the app's scheme: a test that reaches `Linking.createURL` mocks it with the scheme it expects. And a query
64
+ or mutation left without an observer once a test unmounts starts TanStack Query's five-minute garbage-collection
65
+ timer, which Jest's worker waits on until it is force-exited. `createTestQueryClient()` sets `gcTime: Infinity`
66
+ for both, so no timer starts, and `retry: false` for queries, so a failure shows at once: the two defaults TanStack
67
+ Query's testing guide gives. Vitest does not wait on the timer, and `web` uses the same helper so that one rule
68
+ holds in both.
69
+
70
+ **`web` turns route code splitting off under Vitest**, in `vite.config.ts`. Split, a route's component is a module
71
+ Vite transforms the first time a test renders it, inside the one second a `find*` query waits, and on a loaded CI
72
+ runner the first page of a test file missed it. The wait is not the problem and is not raised: with the transform
73
+ gone the same load passes, and past that load a longer wait fails the same tests anyway. **What it costs:** the
74
+ tests never load a route's lazy chunk; the build and its preview do.
65
75
 
66
76
  A coverage threshold turns a proxy into a target. Whoever is below writes tests to raise the number rather
67
77
  than to check anything; whoever is above stops thinking. With no database mocks a test costs more to write,
@@ -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
@@ -60,7 +60,9 @@ committed, so the first commit is yours and the diff shows everything Prumo wrot
60
60
  writes into the current directory instead, which must be empty, and names the project after it. If
61
61
  anything fails or is interrupted, Prumo removes what it wrote, so the same command can run again.
62
62
  A project cannot be named `api`, `web`, `mobile` or `site`: those are the names its apps take inside
63
- a workspace.
63
+ a workspace. Nor can it take the name of a package that pnpm's compatibility database extends, such as `ws`:
64
+ pnpm applies the extension to the project's own `package.json` and installs what it adds. Prumo names
65
+ the packages and suggests another name.
64
66
 
65
67
  Every question also has a flag, which is how CI runs it without a terminal. Outside an interactive
66
68
  terminal every question that applies needs its flag, the layout of a single type included: a missing
@@ -74,6 +76,7 @@ npx @stjoseph/prumo new my-api --types api --alone --single-tenant --email
74
76
  | Flag | What it answers |
75
77
  |---|---|
76
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 |
77
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` |
78
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 |
79
82
  | `--mcp` or `--no-mcp` | With an api and a web: whether AI assistants reach the API through MCP, signed in as the user |
@@ -86,6 +89,7 @@ npx @stjoseph/prumo new my-api --types api --alone --single-tenant --email
86
89
  | Command | What it does |
87
90
  |---|---|
88
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 |
89
93
  | `prumo db` | Inside a project with an API: creates the development database in Docker and writes its URL into `.env` |
90
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 |
91
95
  | `prumo doctor` | Checks Node, pnpm, git and Docker on this machine, and that Docker runs Linux containers |
@@ -106,7 +110,7 @@ is asked, and the exit code is 0 only when `ok` is true.
106
110
  | `site` | Next on the App Router, static by default, for pages that have to be indexed |
107
111
 
108
112
  One type produces that project on its own. Several produce a pnpm workspace: each application under
109
- `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
110
114
  other at typecheck, and one version per dependency held in a catalog.
111
115
 
112
116
  Every template is committed here in full, and none of them is generated by calling somebody else's
@@ -138,6 +142,7 @@ Once written, those documents belong to the team. Prumo does not come back and r
138
142
  | [`docs/structure.md`](https://github.com/stjosephworks/prumo/blob/main/docs/structure.md) | The `.prumo/` layout, and the rule deciding which areas a project receives |
139
143
  | [`docs/document-template.md`](https://github.com/stjosephworks/prumo/blob/main/docs/document-template.md) | The shape every convention document fills |
140
144
  | [`docs/maintaining-templates.md`](https://github.com/stjosephworks/prumo/blob/main/docs/maintaining-templates.md) | How a template is changed, and how the change is proved |
145
+ | [`docs/upgrading.md`](https://github.com/stjosephworks/prumo/blob/main/docs/upgrading.md) | What each release changed in the files it generates, and how an existing project carries it over |
141
146
 
142
147
  ## Releasing
143
148
 
@@ -145,6 +150,14 @@ Once written, those documents belong to the team. Prumo does not come back and r
145
150
  registry does not have yet, the `publish` job sends it to npm through npm's trusted publishing, with provenance and
146
151
  without a token in the repository. Any other push to `main` publishes nothing.
147
152
 
153
+ ## Upgrading a project
154
+
155
+ Prumo does not update a project it generated, and there is no `prumo upgrade`. For each release that changed
156
+ the generated files, [`docs/upgrading.md`](https://github.com/stjosephworks/prumo/blob/main/docs/upgrading.md)
157
+ lists what to copy or change and why, and gives one method: generate a reference project with the same answers
158
+ at the old and the new version, and diff the files listed. A file your team has edited is merged by hand, never
159
+ overwritten.
160
+
148
161
  ## Status
149
162
 
150
163
  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.
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?',