@stjoseph/prumo 0.0.1 → 0.0.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.
- package/.prumo-templates/mobile/routing.md +23 -0
- package/.prumo-templates/monorepo/tasks.md +25 -10
- package/.prumo-templates/web/routing.md +49 -2
- package/README.md +8 -3
- package/dist/assets.js +16 -0
- package/dist/cli.js +3 -11
- package/dist/compose.js +2 -0
- package/package.json +2 -1
- package/templates/api/README.md +1 -1
- package/templates/api/package.json +1 -0
- package/templates/mobile/README.md +1 -1
- package/templates/mobile/package.json +1 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +6 -0
- package/templates/mobile/src/app/(app)/index.tsx +5 -1
- package/templates/mobile/src/features/auth/authenticated-layout.spec.tsx +69 -0
- package/templates/mobile/src/features/auth/sign-out-button.spec.tsx +55 -0
- package/templates/mobile/src/features/auth/sign-out-button.tsx +33 -0
- package/templates/site/README.md +1 -1
- package/templates/site/package.json +2 -2
- package/templates/web/README.md +4 -0
- package/templates/web/src/app.spec.tsx +130 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +3 -1
- package/templates/web/src/features/auth/sign-out-button.tsx +28 -0
- package/templates/web/src/features/auth/sign-up-form.tsx +3 -1
- package/templates/web/src/routes/_authenticated/index.tsx +3 -1
- package/templates/workspace/README.md +8 -1
|
@@ -7,6 +7,9 @@ router render the authenticated group or redirect to login.
|
|
|
7
7
|
|
|
8
8
|
Express protection with an authenticated layout group. Do not check for a session inside a screen.
|
|
9
9
|
|
|
10
|
+
**In that layout, render nothing while the session is pending.** Redirect to sign in only once it has arrived
|
|
11
|
+
empty or failed to load.
|
|
12
|
+
|
|
10
13
|
When a deep link targets a protected route without a session, keep the intended destination **in memory**
|
|
11
14
|
and navigate to it after a successful login. Do not persist it.
|
|
12
15
|
|
|
@@ -27,6 +30,13 @@ moment) while the alternative flash reads as a defect and can show somebody else
|
|
|
27
30
|
|
|
28
31
|
Checking inside each screen fails differently: the screen written tomorrow does not check.
|
|
29
32
|
|
|
33
|
+
**A pending session is not a missing one.** The natural check, redirect when there is no session data, works
|
|
34
|
+
at a cold start only because the splash holds rendering until the session arrives, so the layout never sees
|
|
35
|
+
it pending. Signing out empties the query cache, and the next sign-in mounts the layout with the session still
|
|
36
|
+
on its way: the check reads no data, and sends the person who just signed in straight back to sign in. Nothing
|
|
37
|
+
in normal use shows it until somebody signs out. Waiting while pending must not become a way in either, so a
|
|
38
|
+
session that failed to load redirects just like an empty one.
|
|
39
|
+
|
|
30
40
|
A dropped deep-link destination is the most common reason a notification link "does not work": the person
|
|
31
41
|
taps a specific order, signs in, and lands on the home screen with the order to find by hand. Keeping the
|
|
32
42
|
destination in memory rather than on disk matters because `orders/123` describes intent: it says the order
|
|
@@ -51,6 +61,15 @@ Protection:
|
|
|
51
61
|
❌ app/orders.tsx → if (!session) router.replace('/login')
|
|
52
62
|
```
|
|
53
63
|
|
|
64
|
+
Deciding in the layout:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
✅ if (session.isPending) return null
|
|
68
|
+
if (session.data === null || session.data === undefined) return <Redirect href="/sign-in" />
|
|
69
|
+
❌ if (!session.data) return <Redirect href="/sign-in" />
|
|
70
|
+
// bounces the next sign-in back once the cache has been cleared
|
|
71
|
+
```
|
|
72
|
+
|
|
54
73
|
Navigation:
|
|
55
74
|
|
|
56
75
|
```
|
|
@@ -65,6 +84,10 @@ before it renders.
|
|
|
65
84
|
|
|
66
85
|
**Compiler.** Typed routes reject a misspelled path and a missing or wrongly typed param.
|
|
67
86
|
|
|
87
|
+
**Tests.** Render the router with an empty cache and three session answers: one that arrives late expects
|
|
88
|
+
the protected screen, an empty one and a failed one expect sign in. Rendering with a session already cached
|
|
89
|
+
passes with the defect in place.
|
|
90
|
+
|
|
68
91
|
**Review only.** That the splash is released only after the session resolves, and that a deep-link
|
|
69
92
|
destination is not written to storage.
|
|
70
93
|
|
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## Rule
|
|
4
4
|
|
|
5
|
-
Give the root `package.json`
|
|
6
|
-
`pnpm
|
|
5
|
+
Give the root `package.json` a `dev` script that starts every app with `pnpm -r --parallel dev`, and one script
|
|
6
|
+
per app, named after it, that starts only that app with `pnpm --filter <app> dev`. Give it `lint`, `typecheck`
|
|
7
|
+
and `test` as well, each running through `pnpm -r`.
|
|
7
8
|
|
|
8
|
-
**
|
|
9
|
+
**Every app answers to `dev`.** When a framework names its development command differently, keep its name and
|
|
10
|
+
alias it: `"dev": "pnpm start:dev"` in the API, `"dev": "pnpm start"` in mobile.
|
|
11
|
+
|
|
12
|
+
**Give every app its own port.** The API holds 3000, so the site runs on 3200.
|
|
9
13
|
|
|
10
14
|
Keep compiler options in `tsconfig.base.json` at the workspace root. Extend it by relative path from each
|
|
11
15
|
app and package, overriding only what differs.
|
|
@@ -21,9 +25,17 @@ its own `biome.jsonc` with `"extends": "//"`, holding only what differs.
|
|
|
21
25
|
What does not exist is caching, and caching pays once a build hurts, which a freshly generated workspace
|
|
22
26
|
does not.
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
`pnpm dev` is safe because the one-app command is just as short. Without `pnpm web` beside it, typing
|
|
29
|
+
`pnpm dev` while expecting the app you work on would start four processes; with it, starting everything is a
|
|
30
|
+
choice rather than an accident. The root scripts call `dev` and nothing else, which is why every app answers to
|
|
31
|
+
that name: the root never needs to know how each framework spells its development command.
|
|
32
|
+
|
|
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
|
|
35
|
+
them, run `pnpm mobile` in a terminal of its own.
|
|
36
|
+
|
|
37
|
+
Ports are fixed per app because `pnpm dev` starts them together, and a collision there fails only when two apps
|
|
38
|
+
run at once: `next dev` defaults to 3000, which the API already holds.
|
|
27
39
|
|
|
28
40
|
The other three belong at the root because that is what the pre-push hook calls. Leaving them out would
|
|
29
41
|
mean each person assembling their own `--filter`, and several versions of one command.
|
|
@@ -52,8 +64,10 @@ The workspace root, and every `tsconfig.json` beneath it.
|
|
|
52
64
|
Starting work:
|
|
53
65
|
|
|
54
66
|
```
|
|
55
|
-
✅ pnpm
|
|
56
|
-
|
|
67
|
+
✅ pnpm web // the app you are working on
|
|
68
|
+
✅ pnpm dev // every app; mobile without its QR code
|
|
69
|
+
✅ pnpm mobile // mobile with its QR code and shortcuts
|
|
70
|
+
❌ "dev" missing from an app // pnpm dev silently skips it
|
|
57
71
|
```
|
|
58
72
|
|
|
59
73
|
Extending the base:
|
|
@@ -67,8 +81,9 @@ Extending the base:
|
|
|
67
81
|
|
|
68
82
|
**The hook.** Pre-push runs the root `typecheck`, so a type error in any package stops the push.
|
|
69
83
|
|
|
70
|
-
**Review only.** That a new app extends the base rather than copying it,
|
|
71
|
-
|
|
84
|
+
**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.
|
|
72
87
|
|
|
73
88
|
**The reversal condition, stated so it is measurable rather than a feeling:** without caching,
|
|
74
89
|
`pnpm -r typecheck` runs `tsc` in every package from scratch every time. Turborepo belongs on the table the
|
|
@@ -14,6 +14,12 @@ types the router derives from the file name.
|
|
|
14
14
|
Let TanStack Query own the cache. Use a route loader only to call `ensureQueryData`, and read the data in
|
|
15
15
|
the component through the Query hook. Never pass loader data down as props.
|
|
16
16
|
|
|
17
|
+
**After signing in or signing up, `await` a `refetchQueries` of the session before navigating.** Never
|
|
18
|
+
`invalidateQueries` for it.
|
|
19
|
+
|
|
20
|
+
**On signing out, `clear()` the whole query cache**, whether or not the sign-out request succeeded, then navigate
|
|
21
|
+
to sign in.
|
|
22
|
+
|
|
17
23
|
## Rationale
|
|
18
24
|
|
|
19
25
|
The generated route tree is committed because application code imports it, so without it `tsc` fails on a
|
|
@@ -36,6 +42,21 @@ The loader prefetches rather than fetches because two caches holding the same da
|
|
|
36
42
|
screen not update* becomes unanswerable. Data handed down as props sits in no cache, so invalidating
|
|
37
43
|
after a mutation never reaches it.
|
|
38
44
|
|
|
45
|
+
**The session is the one query where invalidating fails silently.** The authenticated layout reads it
|
|
46
|
+
with `ensureQueryData`, which fetches only when the cache is empty, and a visitor without a session leaves
|
|
47
|
+
`null` there, which is not empty. `invalidateQueries` only marks it stale and refetches what a component
|
|
48
|
+
observes, and nothing observes the session. So the sign-in succeeds, the server holds a session, and the
|
|
49
|
+
layout reads the cached `null` and sends the visitor back to sign in. `refetchQueries` includes queries
|
|
50
|
+
nobody observes and resolves once the new value is cached. Following the loader rule above and the
|
|
51
|
+
invalidation rule in `data.md` to the letter is exactly how this defect is written.
|
|
52
|
+
|
|
53
|
+
**Signing out needs more than the session.** A query about *me* is keyed by what it means, not by who asked:
|
|
54
|
+
`['profile', 'me']` holds whoever was signed in. Refetching only the session lets the next person on the same tab
|
|
55
|
+
sign in and read the previous person's profile, because the loader's `ensureQueryData` finds it cached. Clearing
|
|
56
|
+
the cache is the only choice that stays correct when somebody adds the next query without thinking about signing
|
|
57
|
+
out; listing the user's queries one by one fails the day one is forgotten. It clears even when the request fails,
|
|
58
|
+
because a server session left alive is a smaller harm than a previous user's data left readable.
|
|
59
|
+
|
|
39
60
|
## Applies to
|
|
40
61
|
|
|
41
62
|
Every file under `routes/`.
|
|
@@ -66,6 +87,26 @@ Loading data:
|
|
|
66
87
|
// component: props.orders
|
|
67
88
|
```
|
|
68
89
|
|
|
90
|
+
After the session changes:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
✅ await queryClient.refetchQueries({ queryKey: sessionQuery(auth).queryKey })
|
|
94
|
+
await navigate({ to: redirect ?? '/' })
|
|
95
|
+
❌ await queryClient.invalidateQueries({ queryKey: sessionQuery(auth).queryKey })
|
|
96
|
+
// the layout still reads the cached null
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Signing out:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
✅ await auth.signOut()
|
|
103
|
+
queryClient.clear()
|
|
104
|
+
await navigate({ to: '/sign-in' })
|
|
105
|
+
❌ await auth.signOut()
|
|
106
|
+
await queryClient.refetchQueries({ queryKey: sessionQuery(auth).queryKey })
|
|
107
|
+
// the next person signs in and reads the previous profile
|
|
108
|
+
```
|
|
109
|
+
|
|
69
110
|
## Enforcement
|
|
70
111
|
|
|
71
112
|
**Structural.** A route inside the authenticated layout cannot skip the redirect, because the layout runs
|
|
@@ -74,8 +115,14 @@ first.
|
|
|
74
115
|
**Compiler.** `validateSearch` makes the search params' types reflect what survived validation, so a
|
|
75
116
|
component cannot read a field the schema does not produce.
|
|
76
117
|
|
|
77
|
-
**
|
|
78
|
-
route
|
|
118
|
+
**Tests.** A test that renders the app at a protected route, signs in, and expects the protected page. It
|
|
119
|
+
must start from the protected route: starting on the sign-in page never caches the `null`, and passes with
|
|
120
|
+
the defect in place. A second test signs one person out, signs another in on the same app, and expects the
|
|
121
|
+
second person's data rather than the first's.
|
|
122
|
+
|
|
123
|
+
**Review only.** That the loader prefetches rather than returning data the component consumes, that a
|
|
124
|
+
route needing a session was not placed outside the layout, that every form signing in refetches the session,
|
|
125
|
+
and that signing out clears the cache.
|
|
79
126
|
|
|
80
127
|
**A tie worth knowing:** the query key is shared between the loader and the component. If they differ, the
|
|
81
128
|
prefetch fills one cache entry and the component reads another, paying for the request twice and gaining
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
6
|
<a href="https://www.npmjs.com/package/@stjoseph/prumo"><img src="https://img.shields.io/npm/v/%40stjoseph%2Fprumo?color=1e3a5f&label=npm" alt="npm"></a>
|
|
7
|
-
<a href="https://github.com/stjosephworks/prumo/blob/main/LICENSE"><img src="https://img.shields.io/
|
|
7
|
+
<a href="https://github.com/stjosephworks/prumo/blob/main/LICENSE"><img src="https://img.shields.io/github/license/stjosephworks/prumo?color=1e3a5f" alt="MIT"></a>
|
|
8
8
|
</p>
|
|
9
9
|
|
|
10
10
|
Prumo is a set of choices a team has already made, packaged so that starting a new TypeScript
|
|
@@ -36,9 +36,14 @@ Node 22.17 or later, pnpm, and Docker for anything with an API.
|
|
|
36
36
|
## Running it
|
|
37
37
|
|
|
38
38
|
```sh
|
|
39
|
-
|
|
39
|
+
npx @stjoseph/prumo new my-app # npm
|
|
40
|
+
pnpm dlx @stjoseph/prumo new my-app # pnpm
|
|
40
41
|
```
|
|
41
42
|
|
|
43
|
+
Either runner starts it, and the generated project installs with pnpm, which it needs anyway. The
|
|
44
|
+
published package is [`@stjoseph/prumo`](https://www.npmjs.com/package/@stjoseph/prumo), and the
|
|
45
|
+
command it installs is `prumo`.
|
|
46
|
+
|
|
42
47
|
It asks what to build, writes the project into `my-app/`, runs `git init`, and installs. Nothing is
|
|
43
48
|
committed, so the first commit is yours and the diff shows everything Prumo wrote.
|
|
44
49
|
|
|
@@ -47,7 +52,7 @@ terminal a missing answer is an error rather than a default, so nothing importan
|
|
|
47
52
|
silence:
|
|
48
53
|
|
|
49
54
|
```sh
|
|
50
|
-
|
|
55
|
+
npx @stjoseph/prumo new my-app --types api,web --single-tenant
|
|
51
56
|
```
|
|
52
57
|
|
|
53
58
|
| Flag | What it answers |
|
package/dist/assets.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
// Decided by where the CLI runs from, never by what exists on disk: after a build, `cli/templates` is a stale copy
|
|
4
|
+
// sitting right beside the source, and probing for it first made the source CLI generate from yesterday's templates.
|
|
5
|
+
export function assetsFor(here) {
|
|
6
|
+
const root = basename(here) === 'dist' ? join(here, '..') : join(here, '..', '..');
|
|
7
|
+
return { templates: join(root, 'templates'), knowledge: join(root, '.prumo-templates') };
|
|
8
|
+
}
|
|
9
|
+
export function requireAssets(assets) {
|
|
10
|
+
for (const path of Object.values(assets)) {
|
|
11
|
+
if (!existsSync(path)) {
|
|
12
|
+
throw new Error(`Cannot find ${path}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return assets;
|
|
16
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,21 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { join, resolve } from 'node:path';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
4
3
|
import { parseArgs } from 'node:util';
|
|
5
4
|
import * as prompt from '@clack/prompts';
|
|
6
5
|
import { terminalAsker } from "./asker.js";
|
|
6
|
+
import { assetsFor, requireAssets } from "./assets.js";
|
|
7
7
|
import { generate } from "./generate.js";
|
|
8
8
|
import { validateProjectName } from "./names.js";
|
|
9
9
|
import { resolveAnswers } from "./questions.js";
|
|
10
10
|
const USAGE = `Usage: prumo new [name] [--types api,web,mobile,site] [--alone | --monorepo]
|
|
11
11
|
[--multi-tenant | --single-tenant] [--skip-install]`;
|
|
12
|
-
function firstExisting(...candidates) {
|
|
13
|
-
const found = candidates.find((candidate) => existsSync(candidate));
|
|
14
|
-
if (found === undefined) {
|
|
15
|
-
throw new Error(`Cannot find ${candidates.join(' or ')}`);
|
|
16
|
-
}
|
|
17
|
-
return found;
|
|
18
|
-
}
|
|
19
12
|
async function main() {
|
|
20
13
|
const { values, positionals } = parseArgs({
|
|
21
14
|
allowPositionals: true,
|
|
@@ -48,8 +41,7 @@ async function main() {
|
|
|
48
41
|
}
|
|
49
42
|
const here = import.meta.dirname;
|
|
50
43
|
await generate({
|
|
51
|
-
|
|
52
|
-
knowledge: firstExisting(join(here, '../.prumo-templates'), join(here, '../../.prumo-templates')),
|
|
44
|
+
...requireAssets(assetsFor(here)),
|
|
53
45
|
target: resolve(answers.name),
|
|
54
46
|
answers,
|
|
55
47
|
install: !values['skip-install'],
|
package/dist/compose.js
CHANGED
|
@@ -203,6 +203,8 @@ export async function composeWorkspace({ templates, target, types, mobileScheme
|
|
|
203
203
|
return text.replace('# MOBILE_APP_SCHEME=app\n', `MOBILE_APP_SCHEME=${mobileScheme}\n`);
|
|
204
204
|
});
|
|
205
205
|
}
|
|
206
|
+
// One command for everything and one per app. Each app answers to `dev`, so the root never needs to know how.
|
|
207
|
+
await rewriteText(join(target, 'package.json'), (text) => types.reduce((pkg, type) => setJsonc(pkg, ['scripts', type], `pnpm --filter ${type} dev`), setJsonc(text, ['scripts', 'dev'], 'pnpm -r --parallel dev')));
|
|
206
208
|
const manifests = await Promise.all(apps.map(async (app) => readJsonc(await readFile(join(app, 'package.json'), 'utf8'))));
|
|
207
209
|
const catalogs = collectCatalog(manifests);
|
|
208
210
|
if (clients.length > 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stjoseph/prumo",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.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",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"license": "MIT",
|
|
24
24
|
"author": "Leonardo Freitas",
|
|
25
25
|
"type": "module",
|
|
26
|
+
"packageManager": "pnpm@12.3.4",
|
|
26
27
|
"bin": {
|
|
27
28
|
"prumo": "dist/cli.js"
|
|
28
29
|
},
|
package/templates/api/README.md
CHANGED
|
@@ -11,7 +11,7 @@ pnpm install
|
|
|
11
11
|
cp .env.example .env # then replace BETTER_AUTH_SECRET with 32 or more random characters
|
|
12
12
|
docker compose up -d --wait
|
|
13
13
|
pnpm db:migrate
|
|
14
|
-
pnpm
|
|
14
|
+
pnpm dev
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
The API listens on `http://localhost:3000`. Outside production, its documentation is at `/api/docs`.
|
|
@@ -9,6 +9,12 @@ export default function AuthenticatedLayout() {
|
|
|
9
9
|
const session = useQuery(sessionQuery(auth))
|
|
10
10
|
const pathname = usePathname()
|
|
11
11
|
|
|
12
|
+
// Pending is not signed out: after the cache is cleared, the session takes a request to arrive, and redirecting on
|
|
13
|
+
// that would send whoever just signed in straight back to sign in. A failed request still redirects.
|
|
14
|
+
if (session.isPending) {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
12
18
|
if (session.data === null || session.data === undefined) {
|
|
13
19
|
rememberIntendedRoute(pathname)
|
|
14
20
|
return <Redirect href="/sign-in" />
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
import { useRouter } from 'expo-router'
|
|
2
3
|
import { Text } from 'react-native'
|
|
3
4
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
4
5
|
import { profileQuery } from '@/api-contract'
|
|
6
|
+
import { SignOutButton } from '@/features/auth/sign-out-button'
|
|
5
7
|
import { useClients } from '@/features/clients/clients-context'
|
|
6
8
|
import { ProfileForm } from '@/features/profile/profile-form'
|
|
7
9
|
|
|
8
10
|
export default function ProfileScreen() {
|
|
9
|
-
const { api } = useClients()
|
|
11
|
+
const { api, auth } = useClients()
|
|
12
|
+
const router = useRouter()
|
|
10
13
|
const profile = useQuery(profileQuery(api))
|
|
11
14
|
|
|
12
15
|
return (
|
|
13
16
|
<SafeAreaView className="flex-1 justify-center gap-6 p-6">
|
|
14
17
|
<Text className="text-2xl font-semibold text-neutral-900">Your profile</Text>
|
|
15
18
|
{profile.data === undefined ? null : <ProfileForm api={api} profile={profile.data} />}
|
|
19
|
+
<SignOutButton auth={auth} onSignedOut={() => router.replace('/sign-in')} />
|
|
16
20
|
</SafeAreaView>
|
|
17
21
|
)
|
|
18
22
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { createAuthClient } from 'better-auth/react'
|
|
3
|
+
import { renderRouter, screen } from 'expo-router/testing-library'
|
|
4
|
+
import type { ReactNode } from 'react'
|
|
5
|
+
import { Text } from 'react-native'
|
|
6
|
+
import { createClient } from '@/api-contract'
|
|
7
|
+
import AuthenticatedLayout from '@/app/(app)/_layout'
|
|
8
|
+
import type { AuthClient } from '@/features/auth/auth-client'
|
|
9
|
+
import { ClientsProvider } from '@/features/clients/clients-context'
|
|
10
|
+
import { API_URL, fakeTransport, json } from '../../../test/fake-transport'
|
|
11
|
+
|
|
12
|
+
const user = { id: 'user-1', email: 'ana@example.com', name: 'Ana' }
|
|
13
|
+
|
|
14
|
+
async function renderAt(getSession: () => Response | Promise<Response>) {
|
|
15
|
+
const transport = fakeTransport({ 'GET /api/auth/get-session': getSession })
|
|
16
|
+
// jest-expo leaves the app manifest empty, and the Expo plugin needs it to build an origin; the layout depends only on
|
|
17
|
+
// what getSession returns, which the plugin does not change.
|
|
18
|
+
const auth = createAuthClient({
|
|
19
|
+
baseURL: API_URL,
|
|
20
|
+
basePath: '/api/auth',
|
|
21
|
+
fetchOptions: { customFetchImpl: transport },
|
|
22
|
+
}) as unknown as AuthClient
|
|
23
|
+
const clients = { auth, api: createClient({ baseUrl: API_URL, fetch: transport }) }
|
|
24
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
25
|
+
|
|
26
|
+
function Providers({ children }: { children: ReactNode }) {
|
|
27
|
+
return (
|
|
28
|
+
<QueryClientProvider client={queryClient}>
|
|
29
|
+
<ClientsProvider clients={clients}>{children}</ClientsProvider>
|
|
30
|
+
</QueryClientProvider>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await renderRouter(
|
|
35
|
+
{
|
|
36
|
+
'(app)/_layout': AuthenticatedLayout,
|
|
37
|
+
'(app)/index': () => <Text>Your profile</Text>,
|
|
38
|
+
'sign-in': () => <Text>Sign in</Text>,
|
|
39
|
+
},
|
|
40
|
+
{ initialUrl: '/', wrapper: Providers },
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('AuthenticatedLayout', () => {
|
|
45
|
+
// An empty cache is what signing out leaves behind, and the session then takes a request to arrive.
|
|
46
|
+
it('waits for a session still on its way instead of sending a signed-in visitor to sign in', async () => {
|
|
47
|
+
await renderAt(async () => {
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
49
|
+
return json(200, { session: { id: 'session-1', userId: user.id }, user })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
expect(await screen.findByText('Your profile')).toBeOnTheScreen()
|
|
53
|
+
expect(screen.queryByText('Sign in')).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('sends a visitor without a session to sign in', async () => {
|
|
57
|
+
await renderAt(() => json(200, null))
|
|
58
|
+
|
|
59
|
+
expect(await screen.findByText('Sign in')).toBeOnTheScreen()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// Waiting on a pending session must not become a way in: a session that cannot be read is not a session.
|
|
63
|
+
it('sends a visitor to sign in when the session cannot be read', async () => {
|
|
64
|
+
await renderAt(() => json(500, { message: 'Internal server error' }))
|
|
65
|
+
|
|
66
|
+
expect(await screen.findByText('Sign in')).toBeOnTheScreen()
|
|
67
|
+
expect(screen.queryByText('Your profile')).toBeNull()
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { render, screen, userEvent, waitFor } from '@testing-library/react-native'
|
|
3
|
+
import { createAuthClient } from 'better-auth/react'
|
|
4
|
+
import { clearUserData } from '@/features/storage/storage'
|
|
5
|
+
import { API_URL, fakeTransport, json } from '../../../test/fake-transport'
|
|
6
|
+
import { SignOutButton } from './sign-out-button'
|
|
7
|
+
|
|
8
|
+
// MMKV needs its native module, which Jest does not have. What clearUserData removes is the persister's own key.
|
|
9
|
+
jest.mock('@/features/storage/storage', () => ({ clearUserData: jest.fn() }))
|
|
10
|
+
|
|
11
|
+
async function signOutWith(signOut: () => Response | Promise<Response>) {
|
|
12
|
+
const queryClient = new QueryClient()
|
|
13
|
+
const onSignedOut = jest.fn()
|
|
14
|
+
// jest-expo leaves the app manifest empty, and the Expo plugin needs it to build an origin; the button depends only on
|
|
15
|
+
// signOut resolving or failing, which the plugin does not change.
|
|
16
|
+
const auth = createAuthClient({
|
|
17
|
+
baseURL: API_URL,
|
|
18
|
+
basePath: '/api/auth',
|
|
19
|
+
fetchOptions: { customFetchImpl: fakeTransport({ 'POST /api/auth/sign-out': signOut }) },
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
queryClient.setQueryData(['session'], { session: { id: 'session-1' }, user: { id: 'user-1' } })
|
|
23
|
+
queryClient.setQueryData(['profile', 'me'], { id: 'profile-1', displayName: 'Ana' })
|
|
24
|
+
|
|
25
|
+
await render(
|
|
26
|
+
<QueryClientProvider client={queryClient}>
|
|
27
|
+
<SignOutButton auth={auth} onSignedOut={onSignedOut} />
|
|
28
|
+
</QueryClientProvider>,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
await userEvent.setup().press(screen.getByRole('button', { name: 'Sign out' }))
|
|
32
|
+
await waitFor(() => expect(onSignedOut).toHaveBeenCalled())
|
|
33
|
+
|
|
34
|
+
return queryClient
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('SignOutButton', () => {
|
|
38
|
+
beforeEach(() => jest.mocked(clearUserData).mockClear())
|
|
39
|
+
|
|
40
|
+
it('leaves nothing of the user behind for the next person on the device', async () => {
|
|
41
|
+
const queryClient = await signOutWith(() => json(200, { success: true }))
|
|
42
|
+
|
|
43
|
+
expect(queryClient.getQueryCache().getAll()).toHaveLength(0)
|
|
44
|
+
expect(clearUserData).toHaveBeenCalledTimes(1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('still clears the device when the sign-out request never arrives', async () => {
|
|
48
|
+
const queryClient = await signOutWith(() => {
|
|
49
|
+
throw new TypeError('Network request failed')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
expect(queryClient.getQueryCache().getAll()).toHaveLength(0)
|
|
53
|
+
expect(clearUserData).toHaveBeenCalledTimes(1)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { Button } from '@/components/ui/button'
|
|
3
|
+
import { clearUserData } from '@/features/storage/storage'
|
|
4
|
+
import type { AuthClient } from './auth-client'
|
|
5
|
+
|
|
6
|
+
export type SignOutAuth = Pick<AuthClient, 'signOut'>
|
|
7
|
+
|
|
8
|
+
export function SignOutButton({
|
|
9
|
+
auth,
|
|
10
|
+
onSignedOut,
|
|
11
|
+
}: {
|
|
12
|
+
auth: SignOutAuth
|
|
13
|
+
onSignedOut: () => void
|
|
14
|
+
}) {
|
|
15
|
+
const queryClient = useQueryClient()
|
|
16
|
+
|
|
17
|
+
async function signOut() {
|
|
18
|
+
try {
|
|
19
|
+
await auth.signOut()
|
|
20
|
+
} catch {
|
|
21
|
+
// A failed request leaves nothing on the device to act on: the Expo plugin deletes the stored cookie before it
|
|
22
|
+
// sends. The server's session outlives it until it expires, and signing out locally is still what was asked.
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// What the plugin cannot know is the rest of this user's data: the query cache and every user-scoped key, or the
|
|
26
|
+
// next person on this device reads them.
|
|
27
|
+
queryClient.clear()
|
|
28
|
+
clearUserData()
|
|
29
|
+
onSignedOut()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return <Button label="Sign out" variant="outline" onPress={signOut} />
|
|
33
|
+
}
|
package/templates/site/README.md
CHANGED
|
@@ -11,7 +11,7 @@ pnpm install
|
|
|
11
11
|
pnpm dev
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
The site runs on `http://localhost:3000
|
|
14
|
+
The site runs on `http://localhost:3200`, leaving 3000 to the API. It calls no API yet; when a page needs data, it fetches it on the server.
|
|
15
15
|
|
|
16
16
|
## Everyday commands
|
|
17
17
|
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"dev": "next dev",
|
|
7
|
+
"dev": "next dev --port 3200",
|
|
8
8
|
"build": "next build",
|
|
9
|
-
"start": "next start",
|
|
9
|
+
"start": "next start --port 3200",
|
|
10
10
|
"lint": "biome check",
|
|
11
11
|
"typecheck": "next typegen && tsc --noEmit",
|
|
12
12
|
"test": "vitest run",
|
package/templates/web/README.md
CHANGED
|
@@ -14,6 +14,10 @@ pnpm dev
|
|
|
14
14
|
|
|
15
15
|
The app runs on `http://localhost:5173`, which is the origin the API trusts by default (`WEB_ORIGIN`).
|
|
16
16
|
|
|
17
|
+
Open it and you are sent to sign in, because everything but the sign-in and sign-up pages needs a session. Follow
|
|
18
|
+
**Sign up** to create an account; you land on your profile, which you can edit. The API must be running and
|
|
19
|
+
migrated first, or the session check has nothing to answer it.
|
|
20
|
+
|
|
17
21
|
## Everyday commands
|
|
18
22
|
|
|
19
23
|
| Command | What it does |
|
|
@@ -1,12 +1,142 @@
|
|
|
1
1
|
import { screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
2
3
|
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import type { Profile } from '@/api-contract'
|
|
3
5
|
import { fakeTransport, json } from '../test/fake-transport'
|
|
4
6
|
import { renderApp } from '../test/render-app'
|
|
5
7
|
|
|
8
|
+
const user = { id: 'user-1', email: 'ana@example.com', name: 'Ana' }
|
|
9
|
+
|
|
10
|
+
const session = { session: { id: 'session-1', userId: user.id }, user }
|
|
11
|
+
|
|
12
|
+
const profile: Profile = {
|
|
13
|
+
id: 'profile-1',
|
|
14
|
+
userId: user.id,
|
|
15
|
+
displayName: 'Ana',
|
|
16
|
+
locale: 'en',
|
|
17
|
+
timezone: 'UTC',
|
|
18
|
+
createdAt: '2026-09-14T12:57:24.057Z',
|
|
19
|
+
updatedAt: '2026-09-14T12:57:24.057Z',
|
|
20
|
+
}
|
|
21
|
+
|
|
6
22
|
describe('App', () => {
|
|
7
23
|
it('sends a visitor without a session to sign in', async () => {
|
|
8
24
|
renderApp('/', fakeTransport({ 'GET /api/auth/get-session': () => json(200, null) }))
|
|
9
25
|
|
|
10
26
|
expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
|
|
11
27
|
})
|
|
28
|
+
|
|
29
|
+
// The protected route reads the session through ensureQueryData, which keeps a cached null. Found in a real browser:
|
|
30
|
+
// signing in succeeded, a session existed, and the visitor was sent straight back to sign in.
|
|
31
|
+
it('takes a visitor who signs in to the page they were turned away from', async () => {
|
|
32
|
+
const visitor = userEvent.setup()
|
|
33
|
+
let signedIn = false
|
|
34
|
+
|
|
35
|
+
renderApp(
|
|
36
|
+
'/',
|
|
37
|
+
fakeTransport({
|
|
38
|
+
'GET /api/auth/get-session': () => json(200, signedIn ? session : null),
|
|
39
|
+
'POST /api/auth/sign-in/email': () => {
|
|
40
|
+
signedIn = true
|
|
41
|
+
return json(200, { redirect: false, token: 'token-1', user })
|
|
42
|
+
},
|
|
43
|
+
'GET /api/v1/users/me': () => json(200, profile),
|
|
44
|
+
}),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
await visitor.type(await screen.findByLabelText('Email'), user.email)
|
|
48
|
+
await visitor.type(screen.getByLabelText('Password'), 'correct-horse-battery')
|
|
49
|
+
await visitor.click(screen.getByRole('button', { name: 'Sign in' }))
|
|
50
|
+
|
|
51
|
+
expect(await screen.findByRole('heading', { name: 'Your profile' })).toBeInTheDocument()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// Starting from sign-up would hide the defect: only a protected route caches the null session first.
|
|
55
|
+
it('takes a visitor turned away to sign in, who then creates an account, into the app', async () => {
|
|
56
|
+
const visitor = userEvent.setup()
|
|
57
|
+
let registered = false
|
|
58
|
+
|
|
59
|
+
renderApp(
|
|
60
|
+
'/',
|
|
61
|
+
fakeTransport({
|
|
62
|
+
'GET /api/auth/get-session': () => json(200, registered ? session : null),
|
|
63
|
+
'POST /api/auth/sign-up/email': () => {
|
|
64
|
+
registered = true
|
|
65
|
+
return json(200, { token: 'token-1', user })
|
|
66
|
+
},
|
|
67
|
+
'GET /api/v1/users/me': () => json(200, profile),
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
await visitor.click(await screen.findByRole('link', { name: 'Sign up' }))
|
|
72
|
+
await visitor.type(await screen.findByLabelText('Name'), user.name)
|
|
73
|
+
await visitor.type(screen.getByLabelText('Email'), user.email)
|
|
74
|
+
await visitor.type(screen.getByLabelText('Password'), 'correct-horse-battery')
|
|
75
|
+
await visitor.click(screen.getByRole('button', { name: 'Create account' }))
|
|
76
|
+
|
|
77
|
+
expect(await screen.findByRole('heading', { name: 'Your profile' })).toBeInTheDocument()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('shows the next person on the tab their own profile, not the previous one', async () => {
|
|
81
|
+
const visitor = userEvent.setup()
|
|
82
|
+
const people = {
|
|
83
|
+
ana: { user, profile },
|
|
84
|
+
bruno: {
|
|
85
|
+
user: { id: 'user-2', email: 'bruno@example.com', name: 'Bruno' },
|
|
86
|
+
profile: { ...profile, id: 'profile-2', userId: 'user-2', displayName: 'Bruno' },
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
let current: keyof typeof people | null = 'ana'
|
|
90
|
+
|
|
91
|
+
renderApp(
|
|
92
|
+
'/',
|
|
93
|
+
fakeTransport({
|
|
94
|
+
'GET /api/auth/get-session': () =>
|
|
95
|
+
json(
|
|
96
|
+
200,
|
|
97
|
+
current === null
|
|
98
|
+
? null
|
|
99
|
+
: { session: { id: `session-${current}` }, user: people[current].user },
|
|
100
|
+
),
|
|
101
|
+
'POST /api/auth/sign-out': () => {
|
|
102
|
+
current = null
|
|
103
|
+
return json(200, { success: true })
|
|
104
|
+
},
|
|
105
|
+
'POST /api/auth/sign-in/email': () => {
|
|
106
|
+
current = 'bruno'
|
|
107
|
+
return json(200, { redirect: false, token: 'token-2', user: people.bruno.user })
|
|
108
|
+
},
|
|
109
|
+
'GET /api/v1/users/me': () => json(200, current === null ? null : people[current].profile),
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
expect(await screen.findByLabelText('Display name')).toHaveValue('Ana')
|
|
114
|
+
|
|
115
|
+
await visitor.click(screen.getByRole('button', { name: 'Sign out' }))
|
|
116
|
+
await visitor.type(await screen.findByLabelText('Email'), people.bruno.user.email)
|
|
117
|
+
await visitor.type(screen.getByLabelText('Password'), 'correct-horse-battery')
|
|
118
|
+
await visitor.click(screen.getByRole('button', { name: 'Sign in' }))
|
|
119
|
+
|
|
120
|
+
await screen.findByRole('heading', { name: 'Your profile' })
|
|
121
|
+
expect(screen.getByLabelText('Display name')).toHaveValue('Bruno')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('signs a visitor out of this tab even when the sign-out request never arrives', async () => {
|
|
125
|
+
const visitor = userEvent.setup()
|
|
126
|
+
|
|
127
|
+
renderApp(
|
|
128
|
+
'/',
|
|
129
|
+
fakeTransport({
|
|
130
|
+
'GET /api/auth/get-session': () => json(200, session),
|
|
131
|
+
'GET /api/v1/users/me': () => json(200, profile),
|
|
132
|
+
'POST /api/auth/sign-out': () => {
|
|
133
|
+
throw new TypeError('Failed to fetch')
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
await visitor.click(await screen.findByRole('button', { name: 'Sign out' }))
|
|
139
|
+
|
|
140
|
+
expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
|
|
141
|
+
})
|
|
12
142
|
})
|
|
@@ -38,7 +38,9 @@ export function SignInForm({ auth, redirect }: { auth: AuthClient; redirect: str
|
|
|
38
38
|
return
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
// Invalidating is not enough: nothing observes the session, and the protected route's ensureQueryData
|
|
42
|
+
// would keep returning the cached null. Refetching replaces it before the route reads it.
|
|
43
|
+
await queryClient.refetchQueries({ queryKey: sessionQuery(auth).queryKey })
|
|
42
44
|
await navigate({ to: redirect ?? '/' })
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { useNavigate } from '@tanstack/react-router'
|
|
3
|
+
import { Button } from '@/components/ui/button'
|
|
4
|
+
import type { AuthClient } from './auth-client'
|
|
5
|
+
|
|
6
|
+
export function SignOutButton({ auth }: { auth: AuthClient }) {
|
|
7
|
+
const queryClient = useQueryClient()
|
|
8
|
+
const navigate = useNavigate()
|
|
9
|
+
|
|
10
|
+
async function signOut() {
|
|
11
|
+
try {
|
|
12
|
+
await auth.signOut()
|
|
13
|
+
} catch {
|
|
14
|
+
// Better Auth throws when the request never arrives. The cache is cleared regardless, because data left behind
|
|
15
|
+
// for the next person on this tab is the worse error; the cookie survives until the server's session expires.
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The whole cache, not only the session: a query about "me" belongs to whoever was signed in.
|
|
19
|
+
queryClient.clear()
|
|
20
|
+
await navigate({ to: '/sign-in' })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Button type="button" variant="outline" onClick={signOut}>
|
|
25
|
+
Sign out
|
|
26
|
+
</Button>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -39,7 +39,9 @@ export function SignUpForm({ auth }: { auth: AuthClient }) {
|
|
|
39
39
|
return
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
// Invalidating is not enough: nothing observes the session, and the protected route's ensureQueryData
|
|
43
|
+
// would keep returning the cached null. Refetching replaces it before the route reads it.
|
|
44
|
+
await queryClient.refetchQueries({ queryKey: sessionQuery(auth).queryKey })
|
|
43
45
|
await navigate({ to: '/' })
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useSuspenseQuery } from '@tanstack/react-query'
|
|
2
2
|
import { createFileRoute } from '@tanstack/react-router'
|
|
3
3
|
import { profileQuery } from '@/api-contract'
|
|
4
|
+
import { SignOutButton } from '@/features/auth/sign-out-button'
|
|
4
5
|
import { ProfileForm } from '@/features/profile/profile-form'
|
|
5
6
|
|
|
6
7
|
export const Route = createFileRoute('/_authenticated/')({
|
|
@@ -9,13 +10,14 @@ export const Route = createFileRoute('/_authenticated/')({
|
|
|
9
10
|
})
|
|
10
11
|
|
|
11
12
|
function ProfilePage() {
|
|
12
|
-
const { api } = Route.useRouteContext()
|
|
13
|
+
const { api, auth } = Route.useRouteContext()
|
|
13
14
|
const { data: profile } = useSuspenseQuery(profileQuery(api))
|
|
14
15
|
|
|
15
16
|
return (
|
|
16
17
|
<>
|
|
17
18
|
<h1 className="text-2xl font-semibold">Your profile</h1>
|
|
18
19
|
<ProfileForm api={api} profile={profile} />
|
|
20
|
+
<SignOutButton auth={auth} />
|
|
19
21
|
</>
|
|
20
22
|
)
|
|
21
23
|
}
|
|
@@ -16,9 +16,16 @@ Node 22.17 or later, pnpm, and Docker for the API.
|
|
|
16
16
|
| Command | What it does |
|
|
17
17
|
|---|---|
|
|
18
18
|
| `pnpm install` | Installs every app and package |
|
|
19
|
-
| `pnpm
|
|
19
|
+
| `pnpm dev` | Starts every app at once, their output prefixed by app. Mobile runs without its QR code there; use `pnpm mobile` for that |
|
|
20
|
+
| `pnpm api` · `pnpm web` · `pnpm mobile` · `pnpm site` | Starts one app; the workspace has a script for each app it holds |
|
|
20
21
|
| `pnpm lint` · `pnpm typecheck` · `pnpm test` | Runs across every app and package |
|
|
21
22
|
|
|
23
|
+
## Trying it
|
|
24
|
+
|
|
25
|
+
With the API migrated, run `pnpm dev`. If the workspace holds `web`, open `http://localhost:5173`: you are sent to
|
|
26
|
+
sign in, and **Sign up** creates an account and takes you to your profile. Each app's README says what it needs
|
|
27
|
+
before it starts.
|
|
28
|
+
|
|
22
29
|
## Conventions
|
|
23
30
|
|
|
24
31
|
The rules this project follows live in `.prumo/`, and `AGENTS.md` points to them. Read those before
|