@stjoseph/prumo 0.4.0 → 0.4.2

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.
@@ -55,11 +55,13 @@ been published since January 2024. Every other rule here (placement, no database
55
55
  behaviour) holds unchanged. **What it costs:** two runners in one workspace with near-identical APIs, so `vi.fn`
56
56
  written in a mobile test is a mistake an assistant will make.
57
57
 
58
- **Three things a mobile test meets, verified with Expo SDK 57:** `render` from `@testing-library/react-native` 14
58
+ **Four things a mobile test meets, verified with Expo SDK 57:** `render` from `@testing-library/react-native` 14
59
59
  is **asynchronous** and its matchers need no setup. `expo-secure-store` needs the native Keychain or Keystore,
60
60
  which Jest does not have, so a test builds the auth client with an in-memory token store, which is why a form
61
61
  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.
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
65
 
64
66
  A coverage threshold turns a proxy into a target. Whoever is below writes tests to raise the number rather
65
67
  than to check anything; whoever is above stops thinking. With no database mocks a test costs more to write,
@@ -11,6 +11,8 @@ boot, and never by hand.
11
11
  Make every migration compatible with the **previous** release. Removing a column is two deployments:
12
12
  one that stops using it, one that removes it.
13
13
 
14
+ Keep the `package.json` in the migrations directory. It holds only `"type": "module"`.
15
+
14
16
  Delete the generated `down`. Undoing a schema change is a new migration going forward.
15
17
 
16
18
  Keep DDL and data apart. A migration performs DDL only. Filling existing rows is a separate task, run
@@ -31,6 +33,12 @@ migration fails. It would also mean **the process serving requests holds DDL per
31
33
  injection or compromise could drop a table. A separate step lets the deploy use a privileged user for
32
34
  thirty seconds while the application runs as one that only reads and writes rows.
33
35
 
36
+ The migrations are TypeScript that Node loads directly, by type stripping; the build never compiles
37
+ them. They use `import`, and without a declared module type Node guesses: it fails to parse them as
38
+ CommonJS, reparses them as ES modules, and warns `MODULE_TYPELESS_PACKAGE_JSON` in every process that
39
+ runs them. The nearest `package.json` decides a file's type, so the one in the migrations directory
40
+ settles it for those files alone and leaves the CommonJS build untouched.
41
+
34
42
  During a rolling deploy the previous version is still running against the new schema, so a migration
35
43
  that removes something the old code reads breaks production before the new version finishes arriving.
36
44
 
@@ -31,7 +31,7 @@ choice rather than an accident. The root scripts call `dev` and nothing else, wh
31
31
  that name: the root never needs to know how each framework spells its development command.
32
32
 
33
33
  **Mobile gives up its terminal under `pnpm dev`.** Metro starts and serves, so an open simulator connects, but
34
- the QR code, the `exp://` URL and the keyboard shortcuts appear only when Expo owns the terminal. When you need
34
+ the QR code, the URL a device opens and the keyboard shortcuts appear only when Expo owns the terminal. When you need
35
35
  them, run `pnpm mobile` in a terminal of its own.
36
36
 
37
37
  Ports are fixed per app because `pnpm dev` starts them together, and a collision there fails only when two apps
package/README.md CHANGED
@@ -25,9 +25,9 @@ conventions that code follows. Any AI assistant working in that repository reads
25
25
  knows the rules without anyone explaining them, which is the whole point: **the conventions live in
26
26
  the project, not in someone's head.**
27
27
 
28
- Applications differ, so the choices are not one fixed bundle. Three answers shape what arrives:
29
- which artefacts the project has, whether they sit alone or in one workspace, and whether the
30
- application serves several tenants. The same body of decisions covers an API on its own and a
28
+ Applications differ, so the choices are not one fixed bundle. The answers shape what arrives:
29
+ which artefacts the project has, whether they sit alone or in one workspace, whether the application
30
+ serves several tenants, and whether it ships MCP, email verification and sign-in with a provider. The same body of decisions covers an API on its own and a
31
31
  workspace holding four applications, without either one carrying rules it has no use for.
32
32
 
33
33
  ## Requirements
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { runDatabase } from "./database.js";
9
9
  import { doctor, doctorText, systemProbe, toolChecks } from "./doctor.js";
10
10
  import { generate } from "./generate.js";
11
11
  import { projectProblem } from "./names.js";
12
+ import { nextSteps } from "./next-steps.js";
12
13
  import { CliError, errorEnvelope, writeEnvelope } from "./output.js";
13
14
  import { resolveAnswers } from "./questions.js";
14
15
  import { cliVersion } from "./version.js";
@@ -90,6 +91,14 @@ async function runNew(args, json) {
90
91
  });
91
92
  if (!json) {
92
93
  prompt.outro(`Created ${answers.name}.`);
94
+ const steps = nextSteps({
95
+ target,
96
+ cwd,
97
+ types: chosen.types,
98
+ architecture: chosen.architecture,
99
+ installed: install,
100
+ });
101
+ process.stdout.write(`Next:\n${steps.map((step) => ` ${step}\n`).join('')}`);
93
102
  }
94
103
  return { data: { ...chosen, target, installed: install, warnings }, text: '' };
95
104
  }
package/dist/compose.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { join, relative, sep } from 'node:path';
4
- import { applyFeatures, NONE } from "./features.js";
4
+ import { applyFeatures, cutFeatures, NONE } from "./features.js";
5
5
  import { prependProperty, readJsonc, removeWhatBaseDeclares, setJsonc } from "./jsonc.js";
6
6
  const CLIENTS = ['web', 'mobile'];
7
7
  // One exception to one-version-per-dependency: stable NativeWind needs Tailwind 3.
@@ -185,6 +185,9 @@ async function assertCatalogCovers(target, catalog) {
185
185
  export async function composeWorkspace({ templates, target, types, features = NONE, }) {
186
186
  await mkdir(target, { recursive: true });
187
187
  await copyTemplate(join(templates, 'workspace'), target);
188
+ // The root README tells a first run what the features change, so it is cut like any app's.
189
+ const readme = join(target, 'README.md');
190
+ await writeFile(readme, cutFeatures(await readFile(readme, 'utf8'), features, readme));
188
191
  const apps = [];
189
192
  for (const type of types) {
190
193
  const app = await copyApp(templates, target, type);
package/dist/features.js CHANGED
@@ -4,7 +4,7 @@ import { join } from 'node:path';
4
4
  import { setJsonc } from "./jsonc.js";
5
5
  // What a project may leave out. The templates ship with every one of them; a project that answered no receives the
6
6
  // template without it: these files deleted, these dependencies removed, and every line or block marked
7
- // `// prumo:<feature>` cut. A feature a type does not list has nothing to cut there.
7
+ // `// prumo:<feature>` cut, READMEs included. A feature a type does not list has nothing to cut there.
8
8
  export const FEATURE_NAMES = ['mcp', 'email', 'social', 'google', 'apple'];
9
9
  export const SOCIAL_PROVIDERS = ['google', 'apple'];
10
10
  export const NONE = {
@@ -145,10 +145,10 @@ export const FEATURES = {
145
145
  apple: { files: {}, dependencies: {} },
146
146
  };
147
147
  export function cutFeature(text, feature, keep, file = 'a file') {
148
- // Inside JSX a comment is written {/* … */}, and in an env file with #, so a block may open and close any of
149
- // these ways.
150
- const start = new RegExp(`^\\s*(// prumo:${feature}|\\{/\\* prumo:${feature} \\*/\\}|# prumo:${feature})$`);
151
- const end = new RegExp(`^\\s*(// prumo:end-${feature}|\\{/\\* prumo:end-${feature} \\*/\\}|# prumo:end-${feature})$`);
148
+ // Inside JSX a comment is written {/* … */}, in an env file with #, and in Markdown as <!-- … -->, so a block may
149
+ // open and close any of these ways.
150
+ const start = new RegExp(`^\\s*(// prumo:${feature}|\\{/\\* prumo:${feature} \\*/\\}|# prumo:${feature}|<!-- prumo:${feature} -->)$`);
151
+ const end = new RegExp(`^\\s*(// prumo:end-${feature}|\\{/\\* prumo:end-${feature} \\*/\\}|# prumo:end-${feature}|<!-- prumo:end-${feature} -->)$`);
152
152
  // A marked import keeps its marker as a trailing comment, which the import sorter carries along.
153
153
  const marked = new RegExp(`\\s*(//|#) prumo:${feature}$`);
154
154
  const kept = [];
@@ -180,6 +180,9 @@ export function cutFeature(text, feature, keep, file = 'a file') {
180
180
  }
181
181
  return kept.join('\n');
182
182
  }
183
+ export function cutFeatures(text, enabled, file = 'a file') {
184
+ return FEATURE_NAMES.reduce((current, feature) => cutFeature(current, feature, enabled[feature], file), text);
185
+ }
183
186
  async function sourceFiles(directory) {
184
187
  if (!existsSync(directory)) {
185
188
  return [];
@@ -193,8 +196,9 @@ export async function applyFeatures(app, type, enabled) {
193
196
  const sources = [
194
197
  ...(await sourceFiles(join(app, 'src'))),
195
198
  ...(await sourceFiles(join(app, 'test'))),
196
- // The settings a feature needs are cut with it.
199
+ // The settings a feature needs are cut with it, and so is what the README says about it.
197
200
  join(app, '.env.example'),
201
+ join(app, 'README.md'),
198
202
  ];
199
203
  const manifest = join(app, 'package.json');
200
204
  let pkg = await readFile(manifest, 'utf8');
@@ -219,7 +223,7 @@ export async function applyFeatures(app, type, enabled) {
219
223
  continue;
220
224
  }
221
225
  const text = await readFile(source, 'utf8');
222
- const cut = FEATURE_NAMES.reduce((current, feature) => cutFeature(current, feature, enabled[feature], source), text);
226
+ const cut = cutFeatures(text, enabled, source);
223
227
  if (dropped.some((dependency) => cut.includes(`'${dependency}`))) {
224
228
  throw new Error(`${source} still imports a package that was removed, outside its marker`);
225
229
  }
@@ -0,0 +1,24 @@
1
+ import { relative } from 'node:path';
2
+ // What to type after `prumo new`, built from the scripts the templates ship rather than written per case. Every app
3
+ // and every workspace root answers to `pnpm dev`; an API's `dev` first offers to create the database, and a mobile
4
+ // app opens only in a development build, which its `ios` and `android` scripts make.
5
+ export function nextSteps({ target, cwd, types, architecture, installed, }) {
6
+ const steps = [];
7
+ const directory = relative(cwd, target);
8
+ if (directory !== '') {
9
+ steps.push([`cd ${/\s/.test(directory) ? `"${directory}"` : directory}`]);
10
+ }
11
+ if (!installed) {
12
+ steps.push(['pnpm install']);
13
+ }
14
+ if (types.includes('mobile')) {
15
+ const run = architecture === 'alone' ? 'pnpm' : 'pnpm --filter mobile';
16
+ steps.push([`${run} ios`, 'or android: the development build the mobile app opens in']);
17
+ }
18
+ steps.push([
19
+ 'pnpm dev',
20
+ types.includes('api') ? 'offers to create the database first' : undefined,
21
+ ]);
22
+ const width = Math.max(...steps.map(([command]) => command.length));
23
+ return steps.map(([command, why]) => why === undefined ? command : `${command.padEnd(width)} # ${why}`);
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stjoseph/prumo",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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",
@@ -32,6 +32,25 @@ it spawned, stops them, and frees the port. Anything it did not start is named a
32
32
 
33
33
  Authentication is the API's own: `/api/auth/sign-up`, `sign-in`, `refresh`, `sign-out` and `session`. The web
34
34
  receives httpOnly cookies; a native client sends `X-Auth-Transport: bearer` and receives the tokens in the body.
35
+ <!-- prumo:email -->
36
+
37
+ An account must confirm its email before it can sign in. `/api/auth/email/verification` and `/api/auth/email/verify`
38
+ send and check the 6-digit code, and `/api/auth/password/forgot` and `/api/auth/password/reset` do the same for a new
39
+ password. No mail is sent yet: the `Mailer` port's only adapter, `LogMailer`, writes each mail to this API's log, in
40
+ a line starting `[mail] to`, so read the code there. Sending real mail is another adapter behind the same port; see
41
+ `.prumo/email/mailer.md`.
42
+ <!-- prumo:end-email -->
43
+ <!-- prumo:social -->
44
+
45
+ Sign-in with a provider starts at `/api/auth/social/<provider>` and comes back to its `callback`. Until a provider's
46
+ settings are filled in `.env`, it answers `provider_not_configured`; `.env.example` lists them and says where each
47
+ one comes from.
48
+ <!-- prumo:end-social -->
49
+ <!-- prumo:mcp -->
50
+
51
+ The MCP server answers at `/api/mcp`. This API is also the OAuth authorization server that issues its tokens: an MCP
52
+ client finds it through `/.well-known/oauth-protected-resource`, and the user signs in and consents on the web app.
53
+ <!-- prumo:end-mcp -->
35
54
 
36
55
  ## Everyday commands
37
56
 
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -1,7 +1,7 @@
1
1
  import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql'
2
2
  import type { TestProject } from 'vitest/node'
3
3
 
4
- let container: StartedPostgreSqlContainer
4
+ let container: StartedPostgreSqlContainer | undefined
5
5
 
6
6
  export async function setup(project: TestProject): Promise<void> {
7
7
  container = await new PostgreSqlContainer('postgres:18-alpine').start()
@@ -9,8 +9,10 @@ export async function setup(project: TestProject): Promise<void> {
9
9
  project.provide('postgresUrl', container.getConnectionUri())
10
10
  }
11
11
 
12
+ // Vitest still tears down when setup threw, as when the image could not be pulled; there is no container then, and
13
+ // failing here would print a second error over the one that matters.
12
14
  export async function teardown(): Promise<void> {
13
- await container.stop()
15
+ await container?.stop()
14
16
  }
15
17
 
16
18
  declare module 'vitest' {
@@ -2,16 +2,23 @@
2
2
 
3
3
  ## Requirements
4
4
 
5
- Node 22.18 or later, pnpm 10.26 or later, and the API running. A simulator or a device with Expo Go
6
- to open the app.
5
+ Node 22.18 or later, pnpm 10.26 or later, and the API running. To open the app, a simulator or a device with a
6
+ development build of it, made on this machine with Xcode for iOS or Android Studio for Android. Expo Go cannot open
7
+ it, because MMKV's native code is not part of Expo Go.
7
8
 
8
9
  ## Running it
9
10
 
10
11
  ```sh
11
12
  pnpm install
12
- pnpm dev
13
+ pnpm ios # or pnpm android: builds the development build, installs it, and serves it
14
+ pnpm dev # afterwards, until a native dependency changes
13
15
  ```
14
16
 
17
+ `pnpm ios` and `pnpm android` run `expo run:ios` and `expo run:android`, which generate the native project the first
18
+ time and compile it, so the first run is the slow one. `pnpm dev` then only serves the JavaScript, and the installed
19
+ build opens it, because `expo-dev-client` makes `expo start` target the development build rather than Expo Go.
20
+ Build again after adding or upgrading a package with native code.
21
+
15
22
  `.env` comes with the generated project, copied from `.env.example`; `EXPO_PUBLIC_API_URL` points at the API. It is
16
23
  not committed, so on a fresh clone run `cp .env.example .env`.
17
24
 
@@ -26,11 +33,17 @@ Ctrl+C stops everything `pnpm dev` started, not only what the terminal signals:
26
33
  it spawned, stops them, and frees the port. Anything it did not start is named and left alone.
27
34
 
28
35
  On an Android emulator `localhost` is the emulator itself; point `EXPO_PUBLIC_API_URL` at your machine's address.
36
+ <!-- prumo:email -->
37
+
38
+ Signing up first asks for the 6-digit code that confirms the email. No mail is sent yet: the API writes it to its
39
+ log, in a line starting `[mail] to`, and a forgotten password works the same way.
40
+ <!-- prumo:end-email -->
29
41
 
30
42
  ## Everyday commands
31
43
 
32
44
  | Command | What it does |
33
45
  |---|---|
46
+ | `pnpm ios` · `pnpm android` | Builds and installs the development build, then serves it. Xcode or Android Studio |
34
47
  | `pnpm test` | Jest with `jest-expo`, rendering components against a fake transport; no API needed |
35
48
  | `pnpm lint` | Biome |
36
49
  | `pnpm typecheck` | `tsc --noEmit`, also run before every push |
@@ -6,8 +6,8 @@
6
6
  "scripts": {
7
7
  "start": "expo start",
8
8
  "dev": "node scripts/ports.mjs -- expo start",
9
- "ios": "expo start --ios",
10
- "android": "expo start --android",
9
+ "ios": "node scripts/ports.mjs -- expo run:ios",
10
+ "android": "node scripts/ports.mjs -- expo run:android",
11
11
  "bundle": "expo export --platform ios --platform android",
12
12
  "lint": "biome check",
13
13
  "typecheck": "tsc --noEmit",
@@ -22,6 +22,7 @@
22
22
  "class-variance-authority": "0.7.1",
23
23
  "expo": "57.0.22",
24
24
  "expo-constants": "57.0.18",
25
+ "expo-dev-client": "57.0.19",
25
26
  "expo-linking": "57.0.10",
26
27
  "expo-router": "57.0.21",
27
28
  "expo-secure-store": "57.0.4",
@@ -36,8 +36,12 @@ describe('ProfileForm', () => {
36
36
  }),
37
37
  })
38
38
 
39
+ // A mutation left without an observer starts a five-minute garbage-collection timer, and Jest's worker would wait
40
+ // it out. Infinity schedules none, as TanStack Query's testing guide advises.
41
+ const queryClient = new QueryClient({ defaultOptions: { mutations: { gcTime: Infinity } } })
42
+
39
43
  await render(
40
- <QueryClientProvider client={new QueryClient()}>
44
+ <QueryClientProvider client={queryClient}>
41
45
  <ProfileForm api={api} profile={profile} />
42
46
  </QueryClientProvider>,
43
47
  )
@@ -28,6 +28,11 @@ it spawned, stops them, and frees the port. Anything it did not start is named a
28
28
  Open it and you are sent to sign in, because everything but the sign-in and sign-up pages needs a session. Follow
29
29
  **Sign up** to create an account; you land on your profile, which you can edit. The API must be running and
30
30
  migrated first, or the session check has nothing to answer it.
31
+ <!-- prumo:email -->
32
+
33
+ Signing up first asks for the 6-digit code that confirms the email. No mail is sent yet: the API writes it to its
34
+ log, in a line starting `[mail] to`, and **Forgot your password?** works the same way.
35
+ <!-- prumo:end-email -->
31
36
 
32
37
  ## Everyday commands
33
38
 
@@ -29,6 +29,16 @@ If the workspace holds `api` and its database is not set up yet, `pnpm dev` offe
29
29
 
30
30
  If the workspace holds `web`, open `http://localhost:5173`: you are sent to sign in, and **Sign up** creates an
31
31
  account and takes you to your profile. Each app's README says what it needs before it starts.
32
+ <!-- prumo:email -->
33
+
34
+ Signing up first asks for the 6-digit code that confirms the email. No mail is sent yet: the API writes it to its
35
+ log, in a line starting `[mail] to`, which `pnpm dev` prints with the rest of the API's output.
36
+ <!-- prumo:end-email -->
37
+ <!-- prumo:social -->
38
+
39
+ A provider's sign-in button answers *That sign-in is not set up yet* until its settings are filled in `apps/api/.env`;
40
+ `.env.example` lists them and says where each one comes from.
41
+ <!-- prumo:end-social -->
32
42
 
33
43
  ## Conventions
34
44