@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.
- package/.prumo-templates/core/testing.md +4 -2
- package/.prumo-templates/database/migrations.md +8 -0
- package/.prumo-templates/monorepo/tasks.md +1 -1
- package/README.md +3 -3
- package/dist/cli.js +9 -0
- package/dist/compose.js +4 -1
- package/dist/features.js +11 -7
- package/dist/next-steps.js +24 -0
- package/package.json +1 -1
- package/templates/api/README.md +19 -0
- package/templates/api/migrations/package.json +3 -0
- package/templates/api/test/support/global-setup.ts +4 -2
- package/templates/mobile/README.md +16 -3
- package/templates/mobile/package.json +3 -2
- package/templates/mobile/test/features/profile/profile-form.spec.tsx +5 -1
- package/templates/web/README.md +5 -0
- package/templates/workspace/README.md +10 -0
|
@@ -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
|
-
**
|
|
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
|
|
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.
|
|
29
|
-
which artefacts the project has, whether they sit alone or in one workspace,
|
|
30
|
-
|
|
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 {/* … */},
|
|
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 =
|
|
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
package/templates/api/README.md
CHANGED
|
@@ -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
|
|
|
@@ -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
|
|
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.
|
|
6
|
-
|
|
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
|
|
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": "
|
|
10
|
-
"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={
|
|
44
|
+
<QueryClientProvider client={queryClient}>
|
|
41
45
|
<ProfileForm api={api} profile={profile} />
|
|
42
46
|
</QueryClientProvider>,
|
|
43
47
|
)
|
package/templates/web/README.md
CHANGED
|
@@ -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
|
|