@stjoseph/prumo 0.0.1
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/api/auth.md +130 -0
- package/.prumo-templates/api/config.md +85 -0
- package/.prumo-templates/api/errors.md +82 -0
- package/.prumo-templates/api/logging.md +79 -0
- package/.prumo-templates/api/modules.md +117 -0
- package/.prumo-templates/api/observability.md +91 -0
- package/.prumo-templates/api/pagination.md +71 -0
- package/.prumo-templates/api/routes.md +89 -0
- package/.prumo-templates/client/data.md +124 -0
- package/.prumo-templates/client/forms.md +88 -0
- package/.prumo-templates/core/conventions.md +74 -0
- package/.prumo-templates/core/testing.md +100 -0
- package/.prumo-templates/core/tooling.md +118 -0
- package/.prumo-templates/database/entities.md +97 -0
- package/.prumo-templates/database/migrations.md +74 -0
- package/.prumo-templates/database/queries.md +79 -0
- package/.prumo-templates/database/testing.md +82 -0
- package/.prumo-templates/database/transactions.md +68 -0
- package/.prumo-templates/mobile/components.md +83 -0
- package/.prumo-templates/mobile/config.md +84 -0
- package/.prumo-templates/mobile/routing.md +74 -0
- package/.prumo-templates/mobile/storage.md +84 -0
- package/.prumo-templates/mobile/structure.md +79 -0
- package/.prumo-templates/monorepo/dependencies.md +84 -0
- package/.prumo-templates/monorepo/layout.md +89 -0
- package/.prumo-templates/monorepo/tasks.md +76 -0
- package/.prumo-templates/multi-tenancy/client.md +83 -0
- package/.prumo-templates/multi-tenancy/isolation.md +104 -0
- package/.prumo-templates/site/rendering.md +83 -0
- package/.prumo-templates/site/structure.md +68 -0
- package/.prumo-templates/web/components.md +106 -0
- package/.prumo-templates/web/routing.md +82 -0
- package/.prumo-templates/web/structure.md +92 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/asker.js +28 -0
- package/dist/cli.js +62 -0
- package/dist/compose.js +222 -0
- package/dist/context.js +38 -0
- package/dist/generate.js +60 -0
- package/dist/jsonc.js +76 -0
- package/dist/names.js +10 -0
- package/dist/questions.js +63 -0
- package/package.json +55 -0
- package/templates/api/.env.example +12 -0
- package/templates/api/.githooks/install.mjs +18 -0
- package/templates/api/.githooks/pre-commit +2 -0
- package/templates/api/.githooks/pre-push +2 -0
- package/templates/api/README.md +35 -0
- package/templates/api/biome.jsonc +18 -0
- package/templates/api/docker/init/01-auth-schema.sql +1 -0
- package/templates/api/docker-compose.yml +20 -0
- package/templates/api/gitignore +5 -0
- package/templates/api/migrations/Migration20260914123239_create_profile.ts +13 -0
- package/templates/api/nest-cli.json +10 -0
- package/templates/api/package.json +69 -0
- package/templates/api/pnpm-workspace.yaml +9 -0
- package/templates/api/src/app.module.ts +46 -0
- package/templates/api/src/auth/auth.constants.ts +2 -0
- package/templates/api/src/auth/auth.controller.ts +19 -0
- package/templates/api/src/auth/auth.factory.spec.ts +29 -0
- package/templates/api/src/auth/auth.factory.ts +52 -0
- package/templates/api/src/auth/auth.guard.ts +43 -0
- package/templates/api/src/auth/auth.module.ts +30 -0
- package/templates/api/src/auth/auth.ts +4 -0
- package/templates/api/src/auth/current-user.decorator.ts +9 -0
- package/templates/api/src/auth/express.d.ts +10 -0
- package/templates/api/src/auth/public.decorator.ts +4 -0
- package/templates/api/src/config/env.module.ts +11 -0
- package/templates/api/src/config/env.ts +81 -0
- package/templates/api/src/errors/problem-details.filter.ts +86 -0
- package/templates/api/src/errors/validation-exception.factory.ts +30 -0
- package/templates/api/src/health/health.controller.ts +32 -0
- package/templates/api/src/health/health.module.ts +9 -0
- package/templates/api/src/main.ts +43 -0
- package/templates/api/src/mikro-orm.config.ts +4 -0
- package/templates/api/src/mikro-orm.factory.ts +14 -0
- package/templates/api/src/request-context/request-context.module.ts +9 -0
- package/templates/api/src/request-context/request-context.service.ts +17 -0
- package/templates/api/src/request-context/request-id.middleware.ts +19 -0
- package/templates/api/src/users/dto/update-profile.dto.ts +20 -0
- package/templates/api/src/users/entities/profile.entity.ts +27 -0
- package/templates/api/src/users/users.controller.ts +23 -0
- package/templates/api/src/users/users.module.ts +10 -0
- package/templates/api/src/users/users.service.spec.ts +44 -0
- package/templates/api/src/users/users.service.ts +36 -0
- package/templates/api/test/factories/profile.factory.ts +26 -0
- package/templates/api/test/factories/user.factory.ts +21 -0
- package/templates/api/test/global-setup.ts +20 -0
- package/templates/api/test/setup.ts +80 -0
- package/templates/api/tsconfig.build.json +8 -0
- package/templates/api/tsconfig.json +29 -0
- package/templates/api/vitest.config.mts +17 -0
- package/templates/mobile/.env.example +1 -0
- package/templates/mobile/.githooks/install.mjs +18 -0
- package/templates/mobile/.githooks/pre-commit +2 -0
- package/templates/mobile/.githooks/pre-push +2 -0
- package/templates/mobile/README.md +30 -0
- package/templates/mobile/app.config.ts +13 -0
- package/templates/mobile/app.json +39 -0
- package/templates/mobile/assets/expo.icon/Assets/expo-symbol 2.svg +3 -0
- package/templates/mobile/assets/expo.icon/Assets/grid.png +0 -0
- package/templates/mobile/assets/expo.icon/icon.json +35 -0
- package/templates/mobile/assets/images/android-icon-background.png +0 -0
- package/templates/mobile/assets/images/android-icon-foreground.png +0 -0
- package/templates/mobile/assets/images/android-icon-monochrome.png +0 -0
- package/templates/mobile/assets/images/icon.png +0 -0
- package/templates/mobile/assets/images/splash-icon.png +0 -0
- package/templates/mobile/babel.config.js +6 -0
- package/templates/mobile/biome.jsonc +21 -0
- package/templates/mobile/gitignore +11 -0
- package/templates/mobile/global.d.ts +1 -0
- package/templates/mobile/jest.config.js +21 -0
- package/templates/mobile/metro.config.js +4 -0
- package/templates/mobile/nativewind-env.d.ts +1 -0
- package/templates/mobile/package.json +63 -0
- package/templates/mobile/src/api-contract/api-error.ts +51 -0
- package/templates/mobile/src/api-contract/client.ts +26 -0
- package/templates/mobile/src/api-contract/index.ts +10 -0
- package/templates/mobile/src/api-contract/profile.ts +27 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +18 -0
- package/templates/mobile/src/app/(app)/index.tsx +18 -0
- package/templates/mobile/src/app/(auth)/sign-in.tsx +24 -0
- package/templates/mobile/src/app/(auth)/sign-up.tsx +23 -0
- package/templates/mobile/src/app/_layout.tsx +54 -0
- package/templates/mobile/src/components/ui/button.tsx +36 -0
- package/templates/mobile/src/components/ui/field.tsx +30 -0
- package/templates/mobile/src/config.ts +3 -0
- package/templates/mobile/src/features/auth/auth-client.ts +27 -0
- package/templates/mobile/src/features/auth/intended-route.ts +11 -0
- package/templates/mobile/src/features/auth/session.ts +17 -0
- package/templates/mobile/src/features/auth/sign-in-form.spec.tsx +37 -0
- package/templates/mobile/src/features/auth/sign-in-form.tsx +80 -0
- package/templates/mobile/src/features/auth/sign-up-form.tsx +99 -0
- package/templates/mobile/src/features/clients/clients-context.tsx +21 -0
- package/templates/mobile/src/features/forms/form-error.tsx +16 -0
- package/templates/mobile/src/features/forms/server-errors.ts +33 -0
- package/templates/mobile/src/features/profile/profile-form.spec.tsx +58 -0
- package/templates/mobile/src/features/profile/profile-form.tsx +96 -0
- package/templates/mobile/src/features/storage/query-persister.ts +15 -0
- package/templates/mobile/src/features/storage/storage.ts +11 -0
- package/templates/mobile/src/global.css +3 -0
- package/templates/mobile/tailwind.config.js +9 -0
- package/templates/mobile/test/fake-transport.ts +23 -0
- package/templates/mobile/tsconfig.json +21 -0
- package/templates/site/.githooks/install.mjs +18 -0
- package/templates/site/.githooks/pre-commit +2 -0
- package/templates/site/.githooks/pre-push +2 -0
- package/templates/site/README.md +28 -0
- package/templates/site/biome.jsonc +12 -0
- package/templates/site/components.json +25 -0
- package/templates/site/gitignore +7 -0
- package/templates/site/next.config.ts +7 -0
- package/templates/site/package.json +40 -0
- package/templates/site/pnpm-workspace.yaml +5 -0
- package/templates/site/postcss.config.mjs +7 -0
- package/templates/site/src/app/globals.css +129 -0
- package/templates/site/src/app/layout.tsx +13 -0
- package/templates/site/src/app/page.spec.ts +10 -0
- package/templates/site/src/app/page.tsx +17 -0
- package/templates/site/src/lib/utils.ts +1 -0
- package/templates/site/tsconfig.json +34 -0
- package/templates/site/vitest.config.mts +8 -0
- package/templates/web/.env.example +1 -0
- package/templates/web/.githooks/install.mjs +18 -0
- package/templates/web/.githooks/pre-commit +2 -0
- package/templates/web/.githooks/pre-push +2 -0
- package/templates/web/README.md +29 -0
- package/templates/web/biome.jsonc +24 -0
- package/templates/web/components.json +25 -0
- package/templates/web/gitignore +6 -0
- package/templates/web/index.html +12 -0
- package/templates/web/package.json +55 -0
- package/templates/web/src/api-contract/api-error.ts +51 -0
- package/templates/web/src/api-contract/client.ts +26 -0
- package/templates/web/src/api-contract/index.ts +10 -0
- package/templates/web/src/api-contract/profile.ts +27 -0
- package/templates/web/src/app.spec.tsx +12 -0
- package/templates/web/src/app.tsx +14 -0
- package/templates/web/src/components/ui/button.tsx +57 -0
- package/templates/web/src/components/ui/field.tsx +224 -0
- package/templates/web/src/components/ui/input.tsx +19 -0
- package/templates/web/src/components/ui/label.tsx +17 -0
- package/templates/web/src/components/ui/separator.tsx +20 -0
- package/templates/web/src/config.ts +7 -0
- package/templates/web/src/features/auth/auth-client.ts +12 -0
- package/templates/web/src/features/auth/session.ts +17 -0
- package/templates/web/src/features/auth/sign-in-form.spec.tsx +30 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +73 -0
- package/templates/web/src/features/auth/sign-up-form.tsx +79 -0
- package/templates/web/src/features/forms/form-error.tsx +15 -0
- package/templates/web/src/features/forms/server-errors.ts +33 -0
- package/templates/web/src/features/profile/profile-form.spec.tsx +58 -0
- package/templates/web/src/features/profile/profile-form.tsx +74 -0
- package/templates/web/src/index.css +130 -0
- package/templates/web/src/lib/utils.ts +1 -0
- package/templates/web/src/main.tsx +28 -0
- package/templates/web/src/routeTree.gen.ts +125 -0
- package/templates/web/src/router.tsx +21 -0
- package/templates/web/src/routes/__root.tsx +10 -0
- package/templates/web/src/routes/_authenticated/index.tsx +21 -0
- package/templates/web/src/routes/_authenticated.tsx +15 -0
- package/templates/web/src/routes/sign-in.tsx +20 -0
- package/templates/web/src/routes/sign-up.tsx +17 -0
- package/templates/web/test/fake-transport.ts +23 -0
- package/templates/web/test/render-app.tsx +17 -0
- package/templates/web/test/setup.ts +7 -0
- package/templates/web/tsconfig.json +21 -0
- package/templates/web/vite.config.ts +18 -0
- package/templates/workspace/.githooks/install.mjs +18 -0
- package/templates/workspace/.githooks/pre-commit +2 -0
- package/templates/workspace/.githooks/pre-push +2 -0
- package/templates/workspace/README.md +25 -0
- package/templates/workspace/biome.jsonc +8 -0
- package/templates/workspace/gitignore +5 -0
- package/templates/workspace/package.json +17 -0
- package/templates/workspace/packages/api-contract/package.json +20 -0
- package/templates/workspace/packages/api-contract/tsconfig.json +11 -0
- package/templates/workspace/pnpm-workspace.yaml +3 -0
- package/templates/workspace/tsconfig.base.json +10 -0
package/dist/compose.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
import { prependProperty, readJsonc, removeWhatBaseDeclares, setJsonc } from "./jsonc.js";
|
|
5
|
+
const CLIENTS = ['web', 'mobile'];
|
|
6
|
+
// One exception to one-version-per-dependency, recorded in DECISIONS.md: stable NativeWind needs Tailwind 3.
|
|
7
|
+
const NAMED_CATALOGS = [{ name: 'tailwind3', dependency: 'tailwindcss', major: '3' }];
|
|
8
|
+
const NOT_COPIED = new Set([
|
|
9
|
+
'node_modules',
|
|
10
|
+
'dist',
|
|
11
|
+
'coverage',
|
|
12
|
+
'.env',
|
|
13
|
+
'pnpm-lock.yaml',
|
|
14
|
+
'.expo',
|
|
15
|
+
'.next',
|
|
16
|
+
]);
|
|
17
|
+
const NOT_COPIED_SUFFIXES = ['.tsbuildinfo'];
|
|
18
|
+
// The name inside the package, and the name it is restored to. npm deletes a dotted .gitignore from a tarball.
|
|
19
|
+
const PACKAGED_AS = { gitignore: '.gitignore' };
|
|
20
|
+
const CONTRACT_IMPORT = "'@/api-contract'";
|
|
21
|
+
const CONTRACT_PACKAGE = '@app/api-contract';
|
|
22
|
+
function dependencySections(pkg) {
|
|
23
|
+
return [pkg.dependencies, pkg.devDependencies].filter((section) => section !== undefined);
|
|
24
|
+
}
|
|
25
|
+
async function sourceFiles(directory) {
|
|
26
|
+
const entries = await readdir(directory, { withFileTypes: true, recursive: true });
|
|
27
|
+
return entries
|
|
28
|
+
.filter((entry) => entry.isFile() && /\.tsx?$/.test(entry.name))
|
|
29
|
+
.map((entry) => join(entry.parentPath, entry.name));
|
|
30
|
+
}
|
|
31
|
+
async function rewriteText(path, change) {
|
|
32
|
+
await writeFile(path, change(await readFile(path, 'utf8')));
|
|
33
|
+
}
|
|
34
|
+
export async function copyTemplate(source, destination) {
|
|
35
|
+
await cp(source, destination, {
|
|
36
|
+
recursive: true,
|
|
37
|
+
filter: (path) => !relative(source, path)
|
|
38
|
+
.split('/')
|
|
39
|
+
.some((part) => NOT_COPIED.has(part) || NOT_COPIED_SUFFIXES.some((suffix) => part.endsWith(suffix))),
|
|
40
|
+
});
|
|
41
|
+
// Published, a template's .gitignore is `gitignore`, because npm deletes the dotted name from a tarball.
|
|
42
|
+
for (const [packaged, restored] of Object.entries(PACKAGED_AS)) {
|
|
43
|
+
if (existsSync(join(destination, packaged))) {
|
|
44
|
+
await rename(join(destination, packaged), join(destination, restored));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function copyApp(templates, target, type) {
|
|
49
|
+
const destination = join(target, 'apps', type);
|
|
50
|
+
await copyTemplate(join(templates, type), destination);
|
|
51
|
+
await rm(join(destination, '.githooks'), { recursive: true, force: true });
|
|
52
|
+
return destination;
|
|
53
|
+
}
|
|
54
|
+
async function contractFiles(directory) {
|
|
55
|
+
const files = new Map();
|
|
56
|
+
for (const file of await sourceFiles(directory)) {
|
|
57
|
+
files.set(relative(directory, file), await readFile(file, 'utf8'));
|
|
58
|
+
}
|
|
59
|
+
return files;
|
|
60
|
+
}
|
|
61
|
+
async function extractContract(target, clients) {
|
|
62
|
+
const [first, ...others] = clients.map((client) => join(target, 'apps', client));
|
|
63
|
+
if (first === undefined) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const expected = await contractFiles(join(first, 'src', 'api-contract'));
|
|
67
|
+
for (const other of others) {
|
|
68
|
+
const actual = await contractFiles(join(other, 'src', 'api-contract'));
|
|
69
|
+
const differs = actual.size !== expected.size ||
|
|
70
|
+
[...expected].some(([file, text]) => actual.get(file) !== text);
|
|
71
|
+
if (differs) {
|
|
72
|
+
throw new Error(`${relative(target, other)}/src/api-contract differs from ${relative(target, first)}'s`);
|
|
73
|
+
}
|
|
74
|
+
await rm(join(other, 'src', 'api-contract'), { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
await rename(join(first, 'src', 'api-contract'), join(target, 'packages', 'api-contract', 'src'));
|
|
77
|
+
for (const client of [first, ...others]) {
|
|
78
|
+
for (const directory of ['src', 'test']) {
|
|
79
|
+
for (const file of await sourceFiles(join(client, directory)).catch(() => [])) {
|
|
80
|
+
await rewriteText(file, (text) => text.replaceAll(CONTRACT_IMPORT, `'${CONTRACT_PACKAGE}'`));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
await rewriteText(join(client, 'package.json'), (text) => setJsonc(text, ['dependencies', CONTRACT_PACKAGE], 'workspace:*'));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function namedCatalogFor(dependency, version) {
|
|
87
|
+
return NAMED_CATALOGS.find((entry) => entry.dependency === dependency && version.split('.')[0] === entry.major)?.name;
|
|
88
|
+
}
|
|
89
|
+
export function collectCatalog(packages) {
|
|
90
|
+
const catalogs = { default: {}, named: {} };
|
|
91
|
+
for (const pkg of packages) {
|
|
92
|
+
for (const section of dependencySections(pkg)) {
|
|
93
|
+
for (const [name, version] of Object.entries(section)) {
|
|
94
|
+
if (version.startsWith('workspace:') || version === 'catalog:') {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const named = namedCatalogFor(name, version);
|
|
98
|
+
if (named !== undefined) {
|
|
99
|
+
catalogs.named[named] ??= {};
|
|
100
|
+
}
|
|
101
|
+
const catalog = named === undefined ? catalogs.default : catalogs.named[named];
|
|
102
|
+
const existing = catalog[name];
|
|
103
|
+
if (existing !== undefined && existing !== version) {
|
|
104
|
+
throw new Error(`${name} is pinned to both ${existing} and ${version}; a catalog holds one version`);
|
|
105
|
+
}
|
|
106
|
+
catalog[name] = version;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return catalogs;
|
|
111
|
+
}
|
|
112
|
+
async function adaptAppConfig(target, app) {
|
|
113
|
+
const packagePath = join(app, 'package.json');
|
|
114
|
+
let pkg = await readFile(packagePath, 'utf8');
|
|
115
|
+
const parsed = readJsonc(pkg);
|
|
116
|
+
pkg = setJsonc(pkg, ['scripts', 'prepare'], undefined);
|
|
117
|
+
for (const section of ['dependencies', 'devDependencies']) {
|
|
118
|
+
for (const [name, version] of Object.entries(parsed[section] ?? {})) {
|
|
119
|
+
if (!version.startsWith('workspace:')) {
|
|
120
|
+
const named = namedCatalogFor(name, version);
|
|
121
|
+
pkg = setJsonc(pkg, [section, name], named === undefined ? 'catalog:' : `catalog:${named}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
await writeFile(packagePath, pkg);
|
|
126
|
+
const tsconfigPath = join(app, 'tsconfig.json');
|
|
127
|
+
const base = await readFile(join(target, 'tsconfig.base.json'), 'utf8');
|
|
128
|
+
let tsconfig = removeWhatBaseDeclares(await readFile(tsconfigPath, 'utf8'), base);
|
|
129
|
+
const inherited = readJsonc(tsconfig).extends;
|
|
130
|
+
if (inherited === undefined) {
|
|
131
|
+
tsconfig = prependProperty(tsconfig, 'extends', '../../tsconfig.base.json');
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
tsconfig = setJsonc(tsconfig, ['extends'], ['../../tsconfig.base.json', inherited]);
|
|
135
|
+
}
|
|
136
|
+
await writeFile(tsconfigPath, tsconfig);
|
|
137
|
+
const biomePath = join(app, 'biome.jsonc');
|
|
138
|
+
const rootBiome = await readFile(join(target, 'biome.jsonc'), 'utf8');
|
|
139
|
+
const biome = removeWhatBaseDeclares(await readFile(biomePath, 'utf8'), rootBiome);
|
|
140
|
+
await writeFile(biomePath, prependProperty(biome, 'extends', '//'));
|
|
141
|
+
}
|
|
142
|
+
async function takeBuildApprovals(app) {
|
|
143
|
+
const path = join(app, 'pnpm-workspace.yaml');
|
|
144
|
+
const text = await readFile(path, 'utf8').catch(() => undefined);
|
|
145
|
+
if (text === undefined) {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
await rm(path);
|
|
149
|
+
const lines = text.split('\n');
|
|
150
|
+
const start = lines.indexOf('allowBuilds:');
|
|
151
|
+
if (start === -1) {
|
|
152
|
+
throw new Error(`${path} holds settings other than allowBuilds, which composition does not merge`);
|
|
153
|
+
}
|
|
154
|
+
return lines.slice(start + 1).filter((line) => line.startsWith(' '));
|
|
155
|
+
}
|
|
156
|
+
function entriesYaml(catalog, indent) {
|
|
157
|
+
return Object.keys(catalog)
|
|
158
|
+
.sort()
|
|
159
|
+
.map((name) => `${indent}'${name}': ${catalog[name]}`)
|
|
160
|
+
.join('\n');
|
|
161
|
+
}
|
|
162
|
+
function catalogYaml(catalogs) {
|
|
163
|
+
const named = Object.entries(catalogs.named)
|
|
164
|
+
.map(([name, catalog]) => ` ${name}:\n${entriesYaml(catalog, ' ')}`)
|
|
165
|
+
.join('\n');
|
|
166
|
+
return `\ncatalog:\n${entriesYaml(catalogs.default, ' ')}\n${named === '' ? '' : `\ncatalogs:\n${named}\n`}`;
|
|
167
|
+
}
|
|
168
|
+
async function assertCatalogCovers(target, catalog) {
|
|
169
|
+
const manifests = [
|
|
170
|
+
join(target, 'package.json'),
|
|
171
|
+
join(target, 'packages', 'api-contract', 'package.json'),
|
|
172
|
+
];
|
|
173
|
+
for (const manifest of manifests) {
|
|
174
|
+
const pkg = readJsonc(await readFile(manifest, 'utf8'));
|
|
175
|
+
for (const section of dependencySections(pkg)) {
|
|
176
|
+
for (const [name, version] of Object.entries(section)) {
|
|
177
|
+
if (version === 'catalog:' && catalog[name] === undefined) {
|
|
178
|
+
throw new Error(`${relative(target, manifest)} needs ${name} from the catalog, and no app pins it`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export async function composeWorkspace({ templates, target, types, mobileScheme = 'app', }) {
|
|
185
|
+
await mkdir(target, { recursive: true });
|
|
186
|
+
await copyTemplate(join(templates, 'workspace'), target);
|
|
187
|
+
const apps = [];
|
|
188
|
+
for (const type of types) {
|
|
189
|
+
apps.push(await copyApp(templates, target, type));
|
|
190
|
+
}
|
|
191
|
+
const clients = CLIENTS.filter((client) => types.includes(client));
|
|
192
|
+
if (clients.length > 0) {
|
|
193
|
+
await extractContract(target, clients);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
await rm(join(target, 'packages'), { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
if (types.includes('api') && types.includes('mobile')) {
|
|
199
|
+
await rewriteText(join(target, 'apps', 'api', '.env.example'), (text) => {
|
|
200
|
+
if (!text.includes('# MOBILE_APP_SCHEME=app\n')) {
|
|
201
|
+
throw new Error('apps/api/.env.example no longer carries the commented MOBILE_APP_SCHEME line');
|
|
202
|
+
}
|
|
203
|
+
return text.replace('# MOBILE_APP_SCHEME=app\n', `MOBILE_APP_SCHEME=${mobileScheme}\n`);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
const manifests = await Promise.all(apps.map(async (app) => readJsonc(await readFile(join(app, 'package.json'), 'utf8'))));
|
|
207
|
+
const catalogs = collectCatalog(manifests);
|
|
208
|
+
if (clients.length > 0) {
|
|
209
|
+
await assertCatalogCovers(target, catalogs.default);
|
|
210
|
+
}
|
|
211
|
+
const approvals = [];
|
|
212
|
+
for (const app of apps) {
|
|
213
|
+
await adaptAppConfig(target, app);
|
|
214
|
+
for (const line of await takeBuildApprovals(app)) {
|
|
215
|
+
if (!approvals.includes(line)) {
|
|
216
|
+
approvals.push(line);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const builds = approvals.length > 0 ? `\nallowBuilds:\n${approvals.join('\n')}\n` : '';
|
|
221
|
+
await rewriteText(join(target, 'pnpm-workspace.yaml'), (text) => text + catalogYaml(catalogs) + builds);
|
|
222
|
+
}
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { cp, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const CLIENTS = ['web', 'mobile', 'site'];
|
|
4
|
+
export function areasFor(answers) {
|
|
5
|
+
const has = (type) => answers.types.includes(type);
|
|
6
|
+
return [
|
|
7
|
+
'core',
|
|
8
|
+
...(has('api') ? ['api', 'database'] : []),
|
|
9
|
+
...(CLIENTS.some(has) ? ['client'] : []),
|
|
10
|
+
...CLIENTS.filter(has),
|
|
11
|
+
...(answers.architecture === 'monorepo' ? ['monorepo'] : []),
|
|
12
|
+
...(answers.multiTenant ? ['multi-tenancy'] : []),
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
async function titleOf(path) {
|
|
16
|
+
const firstLine = (await readFile(path, 'utf8')).split('\n', 1)[0] ?? '';
|
|
17
|
+
return firstLine.replace(/^#\s*/, '');
|
|
18
|
+
}
|
|
19
|
+
export async function writeContext(knowledge, root, answers) {
|
|
20
|
+
const prumo = join(root, '.prumo');
|
|
21
|
+
const lines = [];
|
|
22
|
+
await mkdir(prumo, { recursive: true });
|
|
23
|
+
for (const area of areasFor(answers)) {
|
|
24
|
+
await cp(join(knowledge, area), join(prumo, area), { recursive: true });
|
|
25
|
+
for (const file of (await readdir(join(prumo, area))).filter((f) => f.endsWith('.md')).sort()) {
|
|
26
|
+
lines.push(`- [${area}/${file}](${area}/${file}): ${await titleOf(join(prumo, area, file))}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
await writeFile(join(prumo, 'INDEX.md'), `# Conventions\n\n${lines.join('\n')}\n`);
|
|
30
|
+
const config = {
|
|
31
|
+
types: answers.types,
|
|
32
|
+
architecture: answers.architecture,
|
|
33
|
+
multiTenant: answers.multiTenant,
|
|
34
|
+
};
|
|
35
|
+
await writeFile(join(prumo, 'config.json'), `${JSON.stringify(config, null, 2)}\n`);
|
|
36
|
+
await writeFile(join(root, 'AGENTS.md'), `# ${answers.name}\n\nThis project's conventions live in \`.prumo/\`. Read [\`.prumo/INDEX.md\`](.prumo/INDEX.md) before changing how something is done.\n`);
|
|
37
|
+
await writeFile(join(root, 'CLAUDE.md'), '@AGENTS.md\n');
|
|
38
|
+
}
|
package/dist/generate.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { composeWorkspace, copyTemplate } from "./compose.js";
|
|
6
|
+
import { writeContext } from "./context.js";
|
|
7
|
+
import { setJsonc } from "./jsonc.js";
|
|
8
|
+
import { schemeFor } from "./names.js";
|
|
9
|
+
async function rewrite(path, change) {
|
|
10
|
+
await writeFile(path, change(await readFile(path, 'utf8')));
|
|
11
|
+
}
|
|
12
|
+
async function nameProject(root, name) {
|
|
13
|
+
await rewrite(join(root, 'package.json'), (text) => setJsonc(text, ['name'], name));
|
|
14
|
+
await rewrite(join(root, 'README.md'), (text) => text.replace(/^# .*$/m, `# ${name}`));
|
|
15
|
+
}
|
|
16
|
+
async function nameMobileApp(app, name) {
|
|
17
|
+
await rewrite(join(app, 'app.json'), (text) => {
|
|
18
|
+
let result = setJsonc(text, ['expo', 'name'], name);
|
|
19
|
+
result = setJsonc(result, ['expo', 'slug'], name);
|
|
20
|
+
return setJsonc(result, ['expo', 'scheme'], schemeFor(name));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function run(command, args, cwd) {
|
|
24
|
+
const result = spawnSync(command, args, { cwd, stdio: 'inherit' });
|
|
25
|
+
if (result.status !== 0) {
|
|
26
|
+
throw new Error(`${command} ${args.join(' ')} failed in ${cwd}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function generate({ templates, knowledge, target, answers, install, }) {
|
|
30
|
+
if (existsSync(target) && (await readdir(target)).length > 0) {
|
|
31
|
+
throw new Error(`${target} already exists and is not empty.`);
|
|
32
|
+
}
|
|
33
|
+
const [only] = answers.types;
|
|
34
|
+
if (answers.architecture === 'alone' && only !== undefined) {
|
|
35
|
+
await copyTemplate(join(templates, only), target);
|
|
36
|
+
await nameProject(target, answers.name);
|
|
37
|
+
if (only === 'mobile') {
|
|
38
|
+
await nameMobileApp(target, answers.name);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
await composeWorkspace({
|
|
43
|
+
templates,
|
|
44
|
+
target,
|
|
45
|
+
types: answers.types,
|
|
46
|
+
mobileScheme: schemeFor(answers.name),
|
|
47
|
+
});
|
|
48
|
+
await nameProject(target, answers.name);
|
|
49
|
+
if (answers.types.includes('mobile')) {
|
|
50
|
+
await nameMobileApp(join(target, 'apps', 'mobile'), answers.name);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
await writeContext(knowledge, target, answers);
|
|
54
|
+
run('git', ['init', '--quiet'], target);
|
|
55
|
+
if (install) {
|
|
56
|
+
run('pnpm', ['install'], target);
|
|
57
|
+
// Rewriting the contract import changes import grouping and line length; only the formatter can settle both.
|
|
58
|
+
run('pnpm', ['exec', 'biome', 'check', '--write'], target);
|
|
59
|
+
}
|
|
60
|
+
}
|
package/dist/jsonc.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { applyEdits, findNodeAtLocation, modify, parse, parseTree, } from 'jsonc-parser';
|
|
2
|
+
const FORMAT = { formattingOptions: { insertSpaces: true, tabSize: 2 } };
|
|
3
|
+
function isObject(value) {
|
|
4
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
function pathsEqualTo(target, base, path) {
|
|
7
|
+
if (!isObject(target) || !isObject(base)) {
|
|
8
|
+
return JSON.stringify(target) === JSON.stringify(base) ? [path] : [];
|
|
9
|
+
}
|
|
10
|
+
return Object.keys(target).flatMap((key) => key in base ? pathsEqualTo(target[key], base[key], [...path, key]) : []);
|
|
11
|
+
}
|
|
12
|
+
function emptyObjectPath(value, path) {
|
|
13
|
+
if (!isObject(value)) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
for (const [key, child] of Object.entries(value)) {
|
|
17
|
+
const found = emptyObjectPath(child, [...path, key]);
|
|
18
|
+
if (found !== undefined) {
|
|
19
|
+
return found;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return Object.keys(value).length === 0 && path.length > 0 ? path : undefined;
|
|
23
|
+
}
|
|
24
|
+
// jsonc-parser's own removal also deletes the comment written above the next property, which here is the
|
|
25
|
+
// one-line reason a rule change must carry. So a property is cut out by its offsets, and nothing after it moves.
|
|
26
|
+
function removeProperty(text, path) {
|
|
27
|
+
const value = findNodeAtLocation(parseTree(text), path);
|
|
28
|
+
const property = value?.parent;
|
|
29
|
+
if (property === undefined) {
|
|
30
|
+
return text;
|
|
31
|
+
}
|
|
32
|
+
let start = property.offset;
|
|
33
|
+
let end = property.offset + property.length;
|
|
34
|
+
const after = text.slice(end).match(/^\s*,/);
|
|
35
|
+
if (after !== null) {
|
|
36
|
+
end += after[0].length;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
const before = text.slice(0, start).match(/,\s*$/);
|
|
40
|
+
if (before !== null) {
|
|
41
|
+
start -= before[0].length;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
|
45
|
+
if (text.slice(lineStart, start).trim() === '') {
|
|
46
|
+
start = lineStart;
|
|
47
|
+
const lineEnd = text.indexOf('\n', end);
|
|
48
|
+
if (lineEnd !== -1 && text.slice(end, lineEnd).trim() === '') {
|
|
49
|
+
end = lineEnd + 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return text.slice(0, start) + text.slice(end);
|
|
53
|
+
}
|
|
54
|
+
export function readJsonc(text) {
|
|
55
|
+
return parse(text);
|
|
56
|
+
}
|
|
57
|
+
export function setJsonc(text, path, value) {
|
|
58
|
+
return value === undefined
|
|
59
|
+
? removeProperty(text, path)
|
|
60
|
+
: applyEdits(text, modify(text, path, value, FORMAT));
|
|
61
|
+
}
|
|
62
|
+
export function prependProperty(text, key, value) {
|
|
63
|
+
const open = text.indexOf('{');
|
|
64
|
+
return `${text.slice(0, open + 1)}\n ${JSON.stringify(key)}: ${JSON.stringify(value)},${text.slice(open + 1)}`;
|
|
65
|
+
}
|
|
66
|
+
export function removeWhatBaseDeclares(text, baseText) {
|
|
67
|
+
let result = text;
|
|
68
|
+
for (const path of pathsEqualTo(parse(text), parse(baseText), [])) {
|
|
69
|
+
result = removeProperty(result, path);
|
|
70
|
+
}
|
|
71
|
+
for (let empty = emptyObjectPath(parse(result), []); empty !== undefined;) {
|
|
72
|
+
result = removeProperty(result, empty);
|
|
73
|
+
empty = emptyObjectPath(parse(result), []);
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
package/dist/names.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const PROJECT_NAME = /^[a-z][a-z0-9-]*$/;
|
|
2
|
+
export function validateProjectName(name) {
|
|
3
|
+
if (!PROJECT_NAME.test(name)) {
|
|
4
|
+
return 'Use lowercase letters, digits and hyphens, starting with a letter.';
|
|
5
|
+
}
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
export function schemeFor(name) {
|
|
9
|
+
return name.replaceAll('-', '');
|
|
10
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { validateProjectName } from "./names.js";
|
|
2
|
+
const TYPES = ['api', 'web', 'mobile', 'site'];
|
|
3
|
+
const TENANT_AWARE = ['api', 'web', 'mobile'];
|
|
4
|
+
function missing(flag) {
|
|
5
|
+
throw new Error(`Missing ${flag}. Outside an interactive terminal every answer must come from a flag.`);
|
|
6
|
+
}
|
|
7
|
+
function parseTypes(value) {
|
|
8
|
+
const types = value.split(',').map((type) => type.trim());
|
|
9
|
+
const unknown = types.filter((type) => !TYPES.includes(type));
|
|
10
|
+
if (unknown.length > 0 || types.length === 0) {
|
|
11
|
+
throw new Error(`Unknown type: ${unknown.join(', ')}. Choose from ${TYPES.join(', ')}.`);
|
|
12
|
+
}
|
|
13
|
+
return [...new Set(types)];
|
|
14
|
+
}
|
|
15
|
+
export async function resolveAnswers(flags, asker) {
|
|
16
|
+
if (flags.alone && flags.monorepo) {
|
|
17
|
+
throw new Error('Choose --alone or --monorepo, not both.');
|
|
18
|
+
}
|
|
19
|
+
if (flags.multiTenant && flags.singleTenant) {
|
|
20
|
+
throw new Error('Choose --multi-tenant or --single-tenant, not both.');
|
|
21
|
+
}
|
|
22
|
+
const name = flags.name ?? (asker === undefined ? missing('the project name') : await asker.name());
|
|
23
|
+
const invalid = validateProjectName(name);
|
|
24
|
+
if (invalid !== undefined) {
|
|
25
|
+
throw new Error(`Invalid project name "${name}". ${invalid}`);
|
|
26
|
+
}
|
|
27
|
+
let types;
|
|
28
|
+
if (flags.types !== undefined) {
|
|
29
|
+
types = parseTypes(flags.types);
|
|
30
|
+
}
|
|
31
|
+
else if (asker !== undefined) {
|
|
32
|
+
types = await asker.types();
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
missing('--types');
|
|
36
|
+
}
|
|
37
|
+
let architecture = 'alone';
|
|
38
|
+
if (types.length > 1) {
|
|
39
|
+
if (flags.alone) {
|
|
40
|
+
throw new Error('--alone holds a single type; several types make a workspace.');
|
|
41
|
+
}
|
|
42
|
+
architecture = 'monorepo';
|
|
43
|
+
}
|
|
44
|
+
else if (flags.monorepo) {
|
|
45
|
+
architecture = 'monorepo';
|
|
46
|
+
}
|
|
47
|
+
else if (!flags.alone && asker !== undefined) {
|
|
48
|
+
architecture = await asker.architecture();
|
|
49
|
+
}
|
|
50
|
+
let multiTenant = false;
|
|
51
|
+
if (types.some((type) => TENANT_AWARE.includes(type))) {
|
|
52
|
+
if (flags.multiTenant || flags.singleTenant) {
|
|
53
|
+
multiTenant = flags.multiTenant;
|
|
54
|
+
}
|
|
55
|
+
else if (asker !== undefined) {
|
|
56
|
+
multiTenant = await asker.multiTenant();
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
missing('--multi-tenant or --single-tenant');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { name, types, architecture, multiTenant };
|
|
63
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stjoseph/prumo",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Scaffolds a TypeScript project and ships it the conventions it follows, in a .prumo/ folder any AI assistant reads.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"scaffolding",
|
|
7
|
+
"cli",
|
|
8
|
+
"typescript",
|
|
9
|
+
"nestjs",
|
|
10
|
+
"expo",
|
|
11
|
+
"monorepo",
|
|
12
|
+
"conventions",
|
|
13
|
+
"agents",
|
|
14
|
+
"ai"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/stjosephworks/prumo#readme",
|
|
17
|
+
"bugs": "https://github.com/stjosephworks/prumo/issues",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/stjosephworks/prumo.git",
|
|
21
|
+
"directory": "cli"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "Leonardo Freitas",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bin": {
|
|
27
|
+
"prumo": "dist/cli.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"templates",
|
|
32
|
+
".prumo-templates"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"lint": "biome check",
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"test": "vitest run --dir src",
|
|
38
|
+
"prumo": "node src/cli.ts",
|
|
39
|
+
"build": "tsc -p tsconfig.build.json && node scripts/copy-assets.mjs",
|
|
40
|
+
"prepack": "pnpm build"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@clack/prompts": "1.8.1",
|
|
44
|
+
"jsonc-parser": "3.3.1"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@biomejs/biome": "2.5.13",
|
|
48
|
+
"@types/node": "24.13.4",
|
|
49
|
+
"typescript": "6.0.3",
|
|
50
|
+
"vitest": "4.1.11"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=22.17.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
NODE_ENV=development
|
|
2
|
+
PORT=3000
|
|
3
|
+
LOG_LEVEL=info
|
|
4
|
+
|
|
5
|
+
DATABASE_URL=postgresql://app:app@localhost:5432/app
|
|
6
|
+
AUTH_DATABASE_URL=postgresql://app:app@localhost:5432/app
|
|
7
|
+
|
|
8
|
+
BETTER_AUTH_SECRET=replace-this-with-at-least-32-characters
|
|
9
|
+
BETTER_AUTH_URL=http://localhost:3000
|
|
10
|
+
WEB_ORIGIN=http://localhost:5173
|
|
11
|
+
# Set only when a mobile app talks to this API; it enables Better Auth's Expo plugin.
|
|
12
|
+
# MOBILE_APP_SCHEME=app
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
function insideRepository() {
|
|
4
|
+
try {
|
|
5
|
+
execFileSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' })
|
|
6
|
+
return true
|
|
7
|
+
} catch (error) {
|
|
8
|
+
// No git, or no repository: an install from an archive or inside a container, where hooks have no use.
|
|
9
|
+
if (error.code === 'ENOENT' || error.status === 128) {
|
|
10
|
+
return false
|
|
11
|
+
}
|
|
12
|
+
throw error
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (insideRepository()) {
|
|
17
|
+
execFileSync('git', ['config', 'core.hooksPath', '.githooks'], { stdio: 'inherit' })
|
|
18
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# api
|
|
2
|
+
|
|
3
|
+
## Requirements
|
|
4
|
+
|
|
5
|
+
Node 22.17 or later, pnpm, and Docker.
|
|
6
|
+
|
|
7
|
+
## Running it
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm install
|
|
11
|
+
cp .env.example .env # then replace BETTER_AUTH_SECRET with 32 or more random characters
|
|
12
|
+
docker compose up -d --wait
|
|
13
|
+
pnpm db:migrate
|
|
14
|
+
pnpm start:dev
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The API listens on `http://localhost:3000`. Outside production, its documentation is at `/api/docs`.
|
|
18
|
+
|
|
19
|
+
`pnpm db:migrate` builds first, then runs Better Auth's migrations and then the application's. Before
|
|
20
|
+
migrating, Better Auth logs `Database schema mismatch` because its tables do not exist yet; the migration
|
|
21
|
+
still completes.
|
|
22
|
+
|
|
23
|
+
## Everyday commands
|
|
24
|
+
|
|
25
|
+
| Command | What it does |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `pnpm test` | Runs the suite against a throwaway Postgres; Docker must be running |
|
|
28
|
+
| `pnpm lint` | Biome |
|
|
29
|
+
| `pnpm typecheck` | `tsc --noEmit`, also run before every push |
|
|
30
|
+
| `pnpm db:migration:create` | Generates a migration from the entity diff, to be read and edited before committing |
|
|
31
|
+
|
|
32
|
+
## Conventions
|
|
33
|
+
|
|
34
|
+
The rules this project follows live in `.prumo/`, and `AGENTS.md` points to them. Read those before
|
|
35
|
+
changing how something is done.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.5.13/schema.json",
|
|
3
|
+
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
|
4
|
+
"files": { "includes": ["**", "!dist", "!coverage", "!migrations"] },
|
|
5
|
+
"formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 },
|
|
6
|
+
"linter": {
|
|
7
|
+
"rules": {
|
|
8
|
+
"preset": "recommended",
|
|
9
|
+
// Nest injects by the emitted constructor type; `import type` erases it and breaks injection silently.
|
|
10
|
+
"style": { "useImportType": "off" }
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"javascript": {
|
|
14
|
+
"formatter": { "quoteStyle": "single", "semicolons": "asNeeded" },
|
|
15
|
+
// Nest decorates constructor and handler parameters; Biome cannot parse those without this.
|
|
16
|
+
"parser": { "unsafeParameterDecoratorsEnabled": true }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
CREATE SCHEMA IF NOT EXISTS auth;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
services:
|
|
2
|
+
postgres:
|
|
3
|
+
image: postgres:18-alpine
|
|
4
|
+
environment:
|
|
5
|
+
POSTGRES_USER: app
|
|
6
|
+
POSTGRES_PASSWORD: app
|
|
7
|
+
POSTGRES_DB: app
|
|
8
|
+
ports:
|
|
9
|
+
- '5432:5432'
|
|
10
|
+
volumes:
|
|
11
|
+
- postgres:/var/lib/postgresql
|
|
12
|
+
- ./docker/init:/docker-entrypoint-initdb.d:ro
|
|
13
|
+
healthcheck:
|
|
14
|
+
test: ['CMD-SHELL', 'pg_isready -U app']
|
|
15
|
+
interval: 5s
|
|
16
|
+
timeout: 5s
|
|
17
|
+
retries: 10
|
|
18
|
+
|
|
19
|
+
volumes:
|
|
20
|
+
postgres:
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
export class Migration20260914123239_create_profile extends Migration {
|
|
4
|
+
|
|
5
|
+
override name = 'Migration20260914123239_create_profile';
|
|
6
|
+
|
|
7
|
+
override up(): void | Promise<void> {
|
|
8
|
+
this.addSql(`create table "profile" ("id" uuid not null default uuidv7(), "user_id" uuid not null, "display_name" text not null, "locale" text not null default 'en', "timezone" text not null default 'UTC', "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), primary key ("id"));`);
|
|
9
|
+
this.addSql(`alter table "profile" add constraint "profile_user_id_unique" unique ("user_id");`);
|
|
10
|
+
this.addSql(`alter table "profile" add constraint "profile_user_id_foreign" foreign key ("user_id") references "auth"."user" ("id") on delete restrict;`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
}
|