create-projx 1.6.5 → 1.7.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/README.md +88 -37
- package/dist/{baseline-PZM4KJJW.js → baseline-FKCXQFRD.js} +2 -2
- package/dist/{chunk-XQ7FE4U3.js → chunk-N4WD4VN3.js} +158 -19
- package/dist/{chunk-6YRBHJ2V.js → chunk-OLPF7FAN.js} +26 -9
- package/dist/index.js +1607 -603
- package/dist/{utils-AVKSTHIF.js → utils-4G3HNHES.js} +5 -1
- package/package.json +11 -6
- package/src/templates/README.md.ejs +21 -4
- package/src/templates/ci.yml.ejs +167 -37
- package/src/templates/docker-compose.yml.ejs +72 -5
- package/src/templates/pre-commit.ejs +28 -4
- package/src/templates/setup.sh.ejs +75 -1
- package/src/templates/docker-compose.dev.yml.ejs +0 -189
package/README.md
CHANGED
|
@@ -51,6 +51,12 @@ npx create-projx vision-api --components fastapi -y
|
|
|
51
51
|
# Node API + React frontend
|
|
52
52
|
npx create-projx saas --components fastify,frontend -y
|
|
53
53
|
|
|
54
|
+
# Minimal Express API + React frontend
|
|
55
|
+
npx create-projx api-app --components express,frontend -y
|
|
56
|
+
|
|
57
|
+
# Drizzle-backed Node API + React frontend
|
|
58
|
+
npx create-projx ledger --components express,frontend --orm drizzle -y
|
|
59
|
+
|
|
54
60
|
# Mobile app with backend
|
|
55
61
|
npx create-projx field-app --components fastapi,mobile -y
|
|
56
62
|
|
|
@@ -74,18 +80,20 @@ If this saves you even one hour, it's already paid for itself. (It's free.)
|
|
|
74
80
|
- **No lock-in.** Projx generates files and walks away. Delete the `.projx` config and it's just a normal repo.
|
|
75
81
|
- **Adopt incrementally.** Already have a project? `projx init` adds CI, hooks, and Docker without touching your code.
|
|
76
82
|
- **Pick your package manager.** npm, pnpm, yarn, or bun. The choice propagates everywhere — scripts, Docker, CI, docs.
|
|
83
|
+
- **Pick your Node ORM.** Prisma is the auto-CRUD default. Drizzle, Sequelize, and TypeORM ship as first-class addons with identical runtime surface (auto-routes, pagination, filtering, search, lifecycle hooks).
|
|
77
84
|
- **AI-agent friendly.** Ships with [SKILL.md](SKILL.md) so Claude, Cursor, and other agents call Projx instead of hand-writing broken scaffolds.
|
|
78
85
|
|
|
79
86
|
## What you get
|
|
80
87
|
|
|
81
|
-
| Component | Stack
|
|
82
|
-
| ---------- |
|
|
83
|
-
| `fastapi` | Python, SQLAlchemy, Alembic
|
|
84
|
-
| `fastify` | Node.js, Prisma, TypeBox
|
|
85
|
-
| `
|
|
86
|
-
| `
|
|
87
|
-
| `
|
|
88
|
-
| `
|
|
88
|
+
| Component | Stack | What it gives you |
|
|
89
|
+
| ---------- | ------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
|
90
|
+
| `fastapi` | Python, SQLAlchemy, Alembic | Auto-entity CRUD, JWT auth, migrations, OpenAPI docs |
|
|
91
|
+
| `fastify` | Node.js, Prisma / Drizzle / Sequelize / TypeORM, TypeBox | Auto-entity CRUD, JWT auth, typed schemas, OpenAPI docs |
|
|
92
|
+
| `express` | Express 5, TypeScript, Prisma / Drizzle / Sequelize / TypeORM | Auto-entity CRUD, JWT auth, validation, security middleware, health checks |
|
|
93
|
+
| `frontend` | React 19, TypeScript, Vite | Auth, theming, design tokens, light/dark mode |
|
|
94
|
+
| `mobile` | Flutter, Riverpod, GoRouter | Auth, biometric, theming, GoRouter shell |
|
|
95
|
+
| `e2e` | Playwright | Page object model, auth fixtures, accessibility scans |
|
|
96
|
+
| `infra` | Terraform, AWS | EKS, RDS, VPC, ALB, CodePipeline, multi-environment |
|
|
89
97
|
|
|
90
98
|
Plus, in every project: Docker Compose for dev + prod, GitHub Actions CI per component (path-filtered), pre-commit hooks, secret detection, VS Code settings, and 80% test coverage enforced.
|
|
91
99
|
|
|
@@ -110,6 +118,9 @@ npx create-projx my-app
|
|
|
110
118
|
# Non-interactive — specify components
|
|
111
119
|
npx create-projx my-app --components fastify,frontend,e2e
|
|
112
120
|
|
|
121
|
+
# Use Drizzle for Node backends instead of Prisma
|
|
122
|
+
npx create-projx my-app --components express,frontend --orm drizzle
|
|
123
|
+
|
|
113
124
|
# Accept defaults (Fastify + Frontend + E2E)
|
|
114
125
|
npx create-projx my-app -y
|
|
115
126
|
```
|
|
@@ -189,12 +200,12 @@ Your custom files (controllers, pages, middleware) are never deleted. Files you
|
|
|
189
200
|
|
|
190
201
|
Common user-owned files are **default-skipped** automatically — template updates won't touch them:
|
|
191
202
|
|
|
192
|
-
| Scope
|
|
193
|
-
|
|
194
|
-
| Root (`.projx`)
|
|
195
|
-
| fastapi
|
|
196
|
-
| fastify / frontend / e2e | `package.json`
|
|
197
|
-
| mobile
|
|
203
|
+
| Scope | Default skips |
|
|
204
|
+
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
205
|
+
| Root (`.projx`) | `docker-compose.yml`, `README.md`, `.githooks/pre-commit`, `.github/workflows/ci.yml`, `scripts/setup.sh`, `scripts/setup-docker.sh`, `scripts/setup-ssl.sh` |
|
|
206
|
+
| fastapi | `pyproject.toml` |
|
|
207
|
+
| fastify / frontend / e2e | `package.json` |
|
|
208
|
+
| mobile | `pubspec.yaml` |
|
|
198
209
|
|
|
199
210
|
Defaults are applied once on first `update` and saved to the `skip` array. To skip additional files, add them to `skip` in `.projx` (root-level) or `.projx-component` (per-component):
|
|
200
211
|
|
|
@@ -231,9 +242,8 @@ npx create-projx unpin <patterns...>
|
|
|
231
242
|
npx create-projx pin --list
|
|
232
243
|
npx create-projx doctor [--fix]
|
|
233
244
|
npx create-projx gen entity <name> [--ai | --backend]
|
|
234
|
-
npx create-projx sync [--url <url>]
|
|
235
245
|
|
|
236
|
-
--components <list> Comma-separated: fastapi,fastify,frontend,mobile,e2e,infra
|
|
246
|
+
--components <list> Comma-separated: fastapi,fastify,express,frontend,mobile,e2e,infra
|
|
237
247
|
--name <dir> Custom directory for `add <type>` (multi-instance)
|
|
238
248
|
--ai Target fastapi (AI/ML) for gen entity
|
|
239
249
|
--backend Target fastify (API backend) for gen entity
|
|
@@ -286,8 +296,11 @@ Scaffold a new entity in your primary backend + typed models for frontend/mobile
|
|
|
286
296
|
npx create-projx gen entity invoice # interactive
|
|
287
297
|
npx create-projx gen entity invoice --fields "name:string,amount:number" # non-interactive
|
|
288
298
|
npx create-projx gen entity embedding --ai --fields "name:string,vector:json" # target AI backend
|
|
299
|
+
npx create-projx gen entity invite --fields "email:string,code:string:unique:generated" # server-fills `code`
|
|
289
300
|
```
|
|
290
301
|
|
|
302
|
+
**Field modifiers** (Fastify/Express only, after the type): `unique` adds a Prisma `@unique` constraint; `generated` (alias: `server`, `server-generated`) marks the field as server-populated — it's omitted from the create-schema and a `beforeCreate` hook stub is emitted that fills it before persist (override the body of `generateXxx()` with your slug/code/UUID logic). Use both together for server-issued unique identifiers (invite codes, slugs, short URLs).
|
|
303
|
+
|
|
291
304
|
When both `fastapi` and `fastify` exist, the entity generates in the **primary backend** only (not both). First run prompts you to choose and saves to `.projx`:
|
|
292
305
|
|
|
293
306
|
```json
|
|
@@ -301,31 +314,25 @@ Override with `--ai` (fastapi) or `--backend` (fastify).
|
|
|
301
314
|
| Primary backend (fastapi) | `src/entities/<name>/_model.py` + `tests/test_<name>_entity.py` — model + 11 CRUD/auth tests |
|
|
302
315
|
| Primary backend (fastify) | `src/modules/<name>/schemas.ts` + `index.ts` + Prisma model + `tests/modules/<name>.test.ts` |
|
|
303
316
|
| `frontend` | `src/types/<name>.ts` — TypeScript interface + Create/Update variants |
|
|
304
|
-
| `mobile` | `lib/entities/<name>/model.dart` — Dart class with fromJson/toJson/copyWith |
|
|
305
317
|
|
|
306
318
|
**Tests included**: every `gen entity` writes a working integration test file alongside the model — 11 tests for FastAPI (extending `BaseEntityApiTest`), 11 tests for Fastify (via `describeCrudEntity`). Both run against a real database (Postgres). New entities ship green from day one — no scrambling to bolt on tests at go-live.
|
|
307
319
|
|
|
308
320
|
No migrations — run `alembic revision --autogenerate` or `prisma migrate dev` (via your package manager) when ready.
|
|
309
321
|
|
|
310
|
-
###
|
|
322
|
+
### ORM choice (Node backends)
|
|
311
323
|
|
|
312
|
-
|
|
324
|
+
Pick an ORM at create-time with `--orm <provider>`. Default: **prisma**. Supported: `prisma`, `drizzle`, `sequelize`, `typeorm`.
|
|
313
325
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
326
|
+
| ORM | Schema home | `gen entity` produces | Sync schema to DB |
|
|
327
|
+
| ----------- | ------------------------ | ----------------------------------------------------------------------------------- | ---------------------------------------------- |
|
|
328
|
+
| `prisma` | `prisma/schema.prisma` | Prisma model + Fastify/Express module (`schemas.ts`, `index.ts`) + integration test | `prisma migrate dev` |
|
|
329
|
+
| `drizzle` | `src/db/schema.ts` | Typed `pgTable` + Fastify/Express router wired via `_base/` + CRUD test | `drizzle-kit push --force` |
|
|
330
|
+
| `sequelize` | `src/models/<name>.ts` | Sequelize `Model` class + aggregator entry + Fastify/Express router + CRUD test | `pnpm db:sync` (uses `sequelize.sync`) |
|
|
331
|
+
| `typeorm` | `src/entities/<name>.ts` | Decorated `@Entity` class + aggregator entry + Fastify/Express router + CRUD test | `pnpm db:sync` (uses `dataSource.synchronize`) |
|
|
318
332
|
|
|
319
|
-
|
|
333
|
+
All four ORMs scaffold equivalent runtime behavior: `_base/auto-routes.ts` wires POST/GET/PATCH/DELETE/bulk routes with pagination, equality filters, `ILIKE` search, and the lifecycle hook contract (`beforeCreate`, `afterCreate`, `beforeUpdate`, `afterUpdate`, `beforeDelete`).
|
|
320
334
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
```tsx
|
|
324
|
-
import type { Invoice } from '../types/invoice';
|
|
325
|
-
|
|
326
|
-
const { data } = await api.list<Invoice>('/invoices'); // data: Invoice[]
|
|
327
|
-
const item = await api.get<Invoice>('/invoices', id); // item: Invoice
|
|
328
|
-
```
|
|
335
|
+
ORM-specific scaffolding lives in [cli/src/addons/orms/](cli/src/addons/orms/) — each ORM is a self-contained folder with a `manifest.json` (deps, file removals, scripts), `shared/` files, per-framework overlays, and `gen-entity/` templates. Adding a new ORM means adding a new folder there; no CLI core changes.
|
|
329
336
|
|
|
330
337
|
## Rename Component Directories
|
|
331
338
|
|
|
@@ -344,12 +351,11 @@ CI, `scripts/setup.sh`, pre-commit hooks, and docker-compose are all regenerated
|
|
|
344
351
|
my-app/
|
|
345
352
|
├── fastapi/ # Auto-entity CRUD backend
|
|
346
353
|
│ └── .projx-component # Identifies this as the fastapi component
|
|
347
|
-
├── frontend/ #
|
|
354
|
+
├── frontend/ # React + Vite shell
|
|
348
355
|
│ └── .projx-component
|
|
349
356
|
├── e2e/ # Playwright E2E tests
|
|
350
357
|
│ └── .projx-component
|
|
351
358
|
├── docker-compose.yml # Production (backend + frontend + SSL)
|
|
352
|
-
├── docker-compose.dev.yml # Development (PostgreSQL + hot reload)
|
|
353
359
|
├── .github/workflows/ # CI per component (runs only on changes)
|
|
354
360
|
├── .githooks/pre-commit # Format + lint on commit
|
|
355
361
|
├── .vscode/ # Editor settings + recommended extensions
|
|
@@ -365,11 +371,56 @@ The core idea: define a data model, get everything else for free.
|
|
|
365
371
|
|
|
366
372
|
**Backend** — Drop a model file. The registry auto-discovers it and generates CRUD routes, schemas, pagination, filtering, sorting, search, FK expansion, and OpenAPI docs.
|
|
367
373
|
|
|
368
|
-
**Field privacy** — Sensitive columns (`password_hash`, `secret`, `api_key`, `mfa_secret`, etc.) are automatically stripped from API responses
|
|
374
|
+
**Field privacy** — Sensitive columns (`password_hash`, `secret`, `api_key`, `mfa_secret`, etc.) are automatically stripped from API responses via a built-in baseline. Add project-specific hidden fields per entity (`__hidden_fields__` in FastAPI, `hiddenFields` in Fastify). Mark entire entities as `__private__` / `private: true` to hide them from the API entirely — no routes registered.
|
|
375
|
+
|
|
376
|
+
**Frontend** — Ships a React shell with auth, theming, and design tokens. Build your own pages using the generated types from `gen entity`.
|
|
377
|
+
|
|
378
|
+
**Mobile** — Ships a Flutter shell with auth, biometric, and GoRouter scaffolding. Build screens using the generated Dart models from `gen entity`.
|
|
369
379
|
|
|
370
|
-
|
|
380
|
+
## Encrypted Service Config
|
|
381
|
+
|
|
382
|
+
Both backends ship with a `service_configs` table for storing third-party credentials (SMTP, OAuth, S3, etc.) encrypted at rest with AES-256-GCM. The `purpose` column is a free-form string — pick whatever taxonomy fits your project.
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
// Fastify
|
|
386
|
+
import { setServiceConfig, getServiceConfig } from "./lib/service-config.js";
|
|
387
|
+
await setServiceConfig(prisma, "smtp", { host, port, user, password });
|
|
388
|
+
const cfg = await getServiceConfig<{ host: string }>(prisma, "smtp");
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
```python
|
|
392
|
+
# FastAPI
|
|
393
|
+
from src.entities.service_config._repository import ServiceConfigRepository
|
|
394
|
+
repo = ServiceConfigRepository(session)
|
|
395
|
+
await repo.set_config('smtp', {'host': ..., 'port': 587})
|
|
396
|
+
cfg = await repo.get_config('smtp')
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Encryption key resolves from `CRED_ENCRYPTION_KEY` (32-byte base64). If absent, derived from `JWT_SECRET` for development. Set an explicit key in production. Reads are cached in-memory for 10 minutes; `invalidate(purpose)` clears.
|
|
400
|
+
|
|
401
|
+
## Rate Limiting
|
|
402
|
+
|
|
403
|
+
The Fastify backend ships with `@fastify/rate-limit` registered globally. Defaults: **200 requests per minute per user** (or per IP for unauthenticated requests). Tune via `RATE_LIMIT_MAX` and `RATE_LIMIT_WINDOW` in `.env`.
|
|
404
|
+
|
|
405
|
+
Override per route for sensitive endpoints:
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
fastify.post(
|
|
409
|
+
"/auth/resend-verification",
|
|
410
|
+
{
|
|
411
|
+
config: {
|
|
412
|
+
rateLimit: {
|
|
413
|
+
max: 5,
|
|
414
|
+
timeWindow: "1 hour",
|
|
415
|
+
keyGenerator: (req) => (req.body?.email ?? req.ip).toLowerCase(),
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
handler,
|
|
420
|
+
);
|
|
421
|
+
```
|
|
371
422
|
|
|
372
|
-
|
|
423
|
+
For the FastAPI service, edge rate limits are enforced by the frontend nginx (`auth_limit` and `api_limit` zones in [frontend/nginx.conf](frontend/nginx.conf)) — no application-level limiter is wired by default since the service is internal.
|
|
373
424
|
|
|
374
425
|
## Development
|
|
375
426
|
|
|
@@ -13,12 +13,14 @@ import {
|
|
|
13
13
|
toSnake,
|
|
14
14
|
upsertComponentMarker,
|
|
15
15
|
writeProjxConfig
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-OLPF7FAN.js";
|
|
17
17
|
|
|
18
18
|
// src/baseline.ts
|
|
19
19
|
import { existsSync, writeFileSync, unlinkSync } from "fs";
|
|
20
20
|
import {
|
|
21
21
|
chmod,
|
|
22
|
+
cp,
|
|
23
|
+
mkdtemp,
|
|
22
24
|
mkdir,
|
|
23
25
|
writeFile,
|
|
24
26
|
rm,
|
|
@@ -38,6 +40,7 @@ function shellSafeUpper(s) {
|
|
|
38
40
|
var CANONICAL_DISPLAY = {
|
|
39
41
|
fastapi: "FastAPI",
|
|
40
42
|
fastify: "Fastify",
|
|
43
|
+
express: "Express",
|
|
41
44
|
frontend: "Frontend",
|
|
42
45
|
mobile: "Flutter",
|
|
43
46
|
e2e: "E2E",
|
|
@@ -59,6 +62,7 @@ function withInstances(vars) {
|
|
|
59
62
|
instances: enriched,
|
|
60
63
|
fastapiInstances: byType("fastapi"),
|
|
61
64
|
fastifyInstances: byType("fastify"),
|
|
65
|
+
expressInstances: byType("express"),
|
|
62
66
|
frontendInstances: byType("frontend"),
|
|
63
67
|
mobileInstances: byType("mobile"),
|
|
64
68
|
e2eInstances: byType("e2e"),
|
|
@@ -72,9 +76,6 @@ async function renderShared(filename, vars) {
|
|
|
72
76
|
async function generateDockerCompose(vars) {
|
|
73
77
|
return renderShared("docker-compose.yml.ejs", withInstances(vars));
|
|
74
78
|
}
|
|
75
|
-
async function generateDockerComposeDev(vars) {
|
|
76
|
-
return renderShared("docker-compose.dev.yml.ejs", withInstances(vars));
|
|
77
|
-
}
|
|
78
79
|
async function generatePreCommit(vars) {
|
|
79
80
|
return renderShared("pre-commit.ejs", withInstances(vars));
|
|
80
81
|
}
|
|
@@ -110,9 +111,7 @@ function generateVscodeSettings(vars) {
|
|
|
110
111
|
settings["editor.formatOnSave"] = true;
|
|
111
112
|
settings["editor.codeActionsOnSave"] = { "source.fixAll.eslint": "explicit" };
|
|
112
113
|
settings["eslint.useFlatConfig"] = true;
|
|
113
|
-
const prettierComponent = ["frontend", "fastify", "e2e"].find(
|
|
114
|
-
(c) => vars.components.includes(c)
|
|
115
|
-
);
|
|
114
|
+
const prettierComponent = ["frontend", "fastify", "express", "e2e"].find((c) => vars.components.includes(c));
|
|
116
115
|
if (prettierComponent) {
|
|
117
116
|
settings["prettier.configPath"] = `${vars.paths[prettierComponent]}/.prettierrc`;
|
|
118
117
|
}
|
|
@@ -127,7 +126,7 @@ function generateVscodeSettings(vars) {
|
|
|
127
126
|
// src/baseline.ts
|
|
128
127
|
var BASELINE_REF = "refs/projx/baseline";
|
|
129
128
|
async function migrateComponentMarkers(cwd, components, componentPaths, applyDefaults) {
|
|
130
|
-
const { readComponentMarker: readComponentMarker2, writeComponentMarker } = await import("./utils-
|
|
129
|
+
const { readComponentMarker: readComponentMarker2, writeComponentMarker } = await import("./utils-4G3HNHES.js");
|
|
131
130
|
for (const component of components) {
|
|
132
131
|
const dir = componentPaths[component];
|
|
133
132
|
const markerDir = join2(cwd, dir);
|
|
@@ -155,6 +154,9 @@ async function writeManagedProjx(cwd, version, vars, applyDefaults) {
|
|
|
155
154
|
if (pmObj?.name && !merged.packageManager) {
|
|
156
155
|
merged.packageManager = pmObj.name;
|
|
157
156
|
}
|
|
157
|
+
if (typeof vars.orm === "string" && !merged.orm) {
|
|
158
|
+
merged.orm = vars.orm;
|
|
159
|
+
}
|
|
158
160
|
if (applyDefaults && !merged.defaultsApplied) {
|
|
159
161
|
const userSkip = Array.isArray(merged.skip) ? merged.skip : [];
|
|
160
162
|
merged.skip = [.../* @__PURE__ */ new Set([...userSkip, ...DEFAULT_ROOT_SKIP_PATTERNS])];
|
|
@@ -413,7 +415,7 @@ async function writeTemplateToDir(dest, repoDir, components, componentPaths, var
|
|
|
413
415
|
nameSnake
|
|
414
416
|
});
|
|
415
417
|
}
|
|
416
|
-
const hasBackend = components.includes("fastapi") || components.includes("fastify");
|
|
418
|
+
const hasBackend = components.includes("fastapi") || components.includes("fastify") || components.includes("express");
|
|
417
419
|
const userSkip = rootSkip ?? [];
|
|
418
420
|
const defaultRootSkip = applyDefaults ? DEFAULT_ROOT_SKIP_PATTERNS : [];
|
|
419
421
|
const effectiveSkip = [.../* @__PURE__ */ new Set([...userSkip, ...defaultRootSkip])];
|
|
@@ -427,11 +429,6 @@ async function writeTemplateToDir(dest, repoDir, components, componentPaths, var
|
|
|
427
429
|
join2(dest, "docker-compose.yml"),
|
|
428
430
|
await generateDockerCompose(vars)
|
|
429
431
|
);
|
|
430
|
-
if (shouldWrite("docker-compose.dev.yml"))
|
|
431
|
-
await writeFile(
|
|
432
|
-
join2(dest, "docker-compose.dev.yml"),
|
|
433
|
-
await generateDockerComposeDev(vars)
|
|
434
|
-
);
|
|
435
432
|
}
|
|
436
433
|
if (shouldWrite("README.md"))
|
|
437
434
|
await writeFile(join2(dest, "README.md"), await generateReadme(vars));
|
|
@@ -497,9 +494,9 @@ async function writeOneInstance(inst, opts) {
|
|
|
497
494
|
}
|
|
498
495
|
const outDir = join2(dest, targetDir);
|
|
499
496
|
await mkdir(outDir, { recursive: true });
|
|
500
|
-
const { cp } = await import("fs/promises");
|
|
497
|
+
const { cp: cp2 } = await import("fs/promises");
|
|
501
498
|
if (existsSync(srcDir)) {
|
|
502
|
-
await
|
|
499
|
+
await cp2(srcDir, outDir, { recursive: true, force: true });
|
|
503
500
|
}
|
|
504
501
|
await rm(tmpDir, { recursive: true, force: true });
|
|
505
502
|
const instancePaths = {
|
|
@@ -507,6 +504,7 @@ async function writeOneInstance(inst, opts) {
|
|
|
507
504
|
[type]: targetDir
|
|
508
505
|
};
|
|
509
506
|
await renderEjsInDir(outDir, { ...vars, paths: instancePaths });
|
|
507
|
+
await applyOrmProviderToInstance(repoDir, outDir, type, vars);
|
|
510
508
|
await upsertComponentMarker(
|
|
511
509
|
join2(dest, targetDir),
|
|
512
510
|
type,
|
|
@@ -520,6 +518,141 @@ async function writeOneInstance(inst, opts) {
|
|
|
520
518
|
vars.nameOverrides
|
|
521
519
|
);
|
|
522
520
|
}
|
|
521
|
+
async function applyOrmProviderToInstance(repoDir, dir, component, vars) {
|
|
522
|
+
const orm = typeof vars.orm === "string" ? vars.orm : void 0;
|
|
523
|
+
if (!orm || orm === "prisma") return;
|
|
524
|
+
if (component !== "fastify" && component !== "express") return;
|
|
525
|
+
await applyOrmAddon(repoDir, orm, component, dir, vars);
|
|
526
|
+
}
|
|
527
|
+
async function loadOrmManifest(repoDir, orm) {
|
|
528
|
+
const path = join2(repoDir, "addons", "orms", orm, "manifest.json");
|
|
529
|
+
if (!existsSync(path)) {
|
|
530
|
+
throw new Error(
|
|
531
|
+
`ORM "${orm}" is not yet supported. No manifest found at ${path}.`
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
return JSON.parse(await readFile2(path, "utf-8"));
|
|
535
|
+
}
|
|
536
|
+
function applyPackageOverrides(pkg, overrides) {
|
|
537
|
+
if (overrides.descriptionReplace && typeof pkg.description === "string") {
|
|
538
|
+
pkg.description = pkg.description.replaceAll(
|
|
539
|
+
overrides.descriptionReplace.from,
|
|
540
|
+
overrides.descriptionReplace.to
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
const scripts = pkg.scripts ?? {};
|
|
544
|
+
for (const prefix of overrides.removeScriptPrefixes ?? []) {
|
|
545
|
+
for (const key of Object.keys(scripts)) {
|
|
546
|
+
if (key.startsWith(prefix)) delete scripts[key];
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
Object.assign(scripts, overrides.addScripts ?? {});
|
|
550
|
+
pkg.scripts = scripts;
|
|
551
|
+
const dependencies = pkg.dependencies ?? {};
|
|
552
|
+
for (const dep of overrides.removeDependencies ?? []) {
|
|
553
|
+
delete dependencies[dep];
|
|
554
|
+
}
|
|
555
|
+
Object.assign(dependencies, overrides.addDependencies ?? {});
|
|
556
|
+
pkg.dependencies = dependencies;
|
|
557
|
+
const devDependencies = pkg.devDependencies ?? {};
|
|
558
|
+
for (const dep of overrides.removeDevDependencies ?? []) {
|
|
559
|
+
delete devDependencies[dep];
|
|
560
|
+
}
|
|
561
|
+
Object.assign(devDependencies, overrides.addDevDependencies ?? {});
|
|
562
|
+
pkg.devDependencies = devDependencies;
|
|
563
|
+
}
|
|
564
|
+
async function applyOrmAddon(repoDir, orm, framework, dir, vars) {
|
|
565
|
+
const manifest = await loadOrmManifest(repoDir, orm);
|
|
566
|
+
if (!manifest.frameworks.includes(framework)) {
|
|
567
|
+
throw new Error(
|
|
568
|
+
`ORM "${orm}" does not support framework "${framework}". Supported: ${manifest.frameworks.join(", ")}`
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
for (const relPath of manifest.removeFromBase) {
|
|
572
|
+
await rm(join2(dir, relPath), { recursive: true, force: true });
|
|
573
|
+
}
|
|
574
|
+
const pkgPath = join2(dir, "package.json");
|
|
575
|
+
if (existsSync(pkgPath)) {
|
|
576
|
+
const pkg = await readJsonObject(pkgPath);
|
|
577
|
+
applyPackageOverrides(pkg, manifest.packageOverrides);
|
|
578
|
+
await writeJsonObject(pkgPath, pkg);
|
|
579
|
+
}
|
|
580
|
+
const addonRoot = join2(repoDir, "addons", "orms", orm);
|
|
581
|
+
const sharedSrc = join2(addonRoot, "shared");
|
|
582
|
+
const frameworkSrc = join2(addonRoot, framework);
|
|
583
|
+
if (existsSync(sharedSrc)) {
|
|
584
|
+
await cp(sharedSrc, dir, { recursive: true, force: true });
|
|
585
|
+
}
|
|
586
|
+
if (existsSync(frameworkSrc)) {
|
|
587
|
+
await cp(frameworkSrc, dir, { recursive: true, force: true });
|
|
588
|
+
}
|
|
589
|
+
await writeFile(
|
|
590
|
+
join2(dir, "Dockerfile"),
|
|
591
|
+
ormNodeDockerfileSource(manifest, vars)
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
async function readJsonObject(path) {
|
|
595
|
+
return JSON.parse(await readFile2(path, "utf-8"));
|
|
596
|
+
}
|
|
597
|
+
async function writeJsonObject(path, data) {
|
|
598
|
+
await writeFile(path, JSON.stringify(data, null, 2) + "\n");
|
|
599
|
+
}
|
|
600
|
+
function ormNodeDockerfileSource(manifest, vars) {
|
|
601
|
+
const pm = vars.pm;
|
|
602
|
+
const pmName = pm.name ?? "npm";
|
|
603
|
+
const lockfile = pm.lockfile ?? "package-lock.json";
|
|
604
|
+
const install = pm.ci ?? "npm ci";
|
|
605
|
+
const exec = pm.exec ?? "npx";
|
|
606
|
+
const run = pm.run ?? "npm run";
|
|
607
|
+
const dockerCfg = manifest.dockerfile ?? {};
|
|
608
|
+
const extraConfigCopy = (dockerCfg.extraConfigFiles ?? []).join(" ");
|
|
609
|
+
const migrateCmd = dockerCfg.migrateCommand ?? "";
|
|
610
|
+
const setup = pmName === "pnpm" ? `ENV PNPM_HOME="/pnpm"
|
|
611
|
+
ENV PATH="$PNPM_HOME:$PATH"
|
|
612
|
+
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
|
613
|
+
` : pmName === "yarn" ? "RUN corepack enable\n" : pmName === "bun" ? "RUN npm install -g bun\n" : "";
|
|
614
|
+
const buildCopy = extraConfigCopy ? `COPY package.json tsconfig.json ${extraConfigCopy} ./` : `COPY package.json tsconfig.json ./`;
|
|
615
|
+
const migrateStage = migrateCmd ? `FROM build AS migrate
|
|
616
|
+
CMD ["sh", "-c", "${exec} ${migrateCmd}"]
|
|
617
|
+
|
|
618
|
+
` : "";
|
|
619
|
+
return `FROM node:22-bookworm-slim AS base
|
|
620
|
+
|
|
621
|
+
RUN apt-get update \\
|
|
622
|
+
&& apt-get install -y --no-install-recommends ca-certificates \\
|
|
623
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
624
|
+
|
|
625
|
+
${setup}WORKDIR /app
|
|
626
|
+
|
|
627
|
+
FROM base AS deps
|
|
628
|
+
COPY package.json ${lockfile} ./
|
|
629
|
+
RUN ${install}
|
|
630
|
+
|
|
631
|
+
FROM base AS build
|
|
632
|
+
ENV NODE_OPTIONS="--max-old-space-size=768"
|
|
633
|
+
COPY --from=deps /app/node_modules ./node_modules
|
|
634
|
+
${buildCopy}
|
|
635
|
+
COPY src ./src
|
|
636
|
+
RUN ${run} build
|
|
637
|
+
|
|
638
|
+
${migrateStage}FROM base AS runtime
|
|
639
|
+
ENV NODE_ENV=production
|
|
640
|
+
RUN npm install -g pm2@5.4.3
|
|
641
|
+
RUN chown -R node /app
|
|
642
|
+
USER node
|
|
643
|
+
|
|
644
|
+
COPY --from=build --chown=node:node /app/node_modules ./node_modules
|
|
645
|
+
COPY --from=build --chown=node:node /app/dist ./dist
|
|
646
|
+
COPY --chown=node:node package.json ecosystem.config.cjs* ./
|
|
647
|
+
|
|
648
|
+
EXPOSE 3000
|
|
649
|
+
|
|
650
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \\
|
|
651
|
+
CMD ["node", "-e", "require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
|
|
652
|
+
|
|
653
|
+
CMD ["sh", "-c", "if [ -f ecosystem.config.cjs ]; then pm2-runtime ecosystem.config.cjs; else node dist/server.js; fi"]
|
|
654
|
+
`;
|
|
655
|
+
}
|
|
523
656
|
async function substituteNamesForInstance(inst, dest, name, nameSnake, overrides) {
|
|
524
657
|
const { type, path } = inst;
|
|
525
658
|
const isCanonical = path === type;
|
|
@@ -537,6 +670,13 @@ async function substituteNamesForInstance(inst, dest, name, nameSnake, overrides
|
|
|
537
670
|
"projx-fastify",
|
|
538
671
|
target
|
|
539
672
|
);
|
|
673
|
+
} else if (type === "express") {
|
|
674
|
+
const target = isCanonical ? overrides?.express ?? `${name}-express` : `${name}-${path}`;
|
|
675
|
+
await replaceInFile(
|
|
676
|
+
join2(dest, `${path}/package.json`),
|
|
677
|
+
"projx-express",
|
|
678
|
+
target
|
|
679
|
+
);
|
|
540
680
|
} else if (type === "frontend") {
|
|
541
681
|
const target = isCanonical ? overrides?.frontend ?? `${name}-frontend` : `${name}-${path}`;
|
|
542
682
|
await replaceInFile(
|
|
@@ -573,7 +713,7 @@ async function detectPackageNameOverrides(cwd, components, componentPaths) {
|
|
|
573
713
|
const name = await readTomlProjectName(file);
|
|
574
714
|
if (name) overrides.fastapi = name;
|
|
575
715
|
}
|
|
576
|
-
for (const c of ["fastify", "frontend", "e2e"]) {
|
|
716
|
+
for (const c of ["fastify", "express", "frontend", "e2e"]) {
|
|
577
717
|
if (!components.includes(c)) continue;
|
|
578
718
|
const file = join2(cwd, componentPaths[c], "package.json");
|
|
579
719
|
const name = await readJsonName(file);
|
|
@@ -715,8 +855,7 @@ async function applyTemplate(cwd, repoDir, components, componentPaths, vars, ver
|
|
|
715
855
|
}
|
|
716
856
|
const baselineRef = getBaselineRef(cwd);
|
|
717
857
|
if (baselineRef) {
|
|
718
|
-
const tmpTemplate = join2(tmpdir(),
|
|
719
|
-
await mkdir(tmpTemplate, { recursive: true });
|
|
858
|
+
const tmpTemplate = await mkdtemp(join2(tmpdir(), "projx-tpl-"));
|
|
720
859
|
await writeTemplateToDir(
|
|
721
860
|
tmpTemplate,
|
|
722
861
|
repoDir,
|
|
@@ -4,6 +4,7 @@ import { existsSync, readFileSync } from "fs";
|
|
|
4
4
|
import {
|
|
5
5
|
chmod,
|
|
6
6
|
cp,
|
|
7
|
+
mkdtemp,
|
|
7
8
|
mkdir,
|
|
8
9
|
readdir,
|
|
9
10
|
readFile,
|
|
@@ -18,12 +19,19 @@ var REPO_URL = `https://github.com/${REPO}`;
|
|
|
18
19
|
var COMPONENTS = [
|
|
19
20
|
"fastapi",
|
|
20
21
|
"fastify",
|
|
22
|
+
"express",
|
|
21
23
|
"frontend",
|
|
22
24
|
"mobile",
|
|
23
25
|
"e2e",
|
|
24
26
|
"infra"
|
|
25
27
|
];
|
|
26
28
|
var PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
|
|
29
|
+
var ORM_PROVIDERS = [
|
|
30
|
+
"prisma",
|
|
31
|
+
"drizzle",
|
|
32
|
+
"sequelize",
|
|
33
|
+
"typeorm"
|
|
34
|
+
];
|
|
27
35
|
function pmCommands(pm) {
|
|
28
36
|
switch (pm) {
|
|
29
37
|
case "npm":
|
|
@@ -88,7 +96,7 @@ function detectPackageManager(cwd) {
|
|
|
88
96
|
return null;
|
|
89
97
|
}
|
|
90
98
|
function detectPackageManagerFromComponents(cwd, componentPaths) {
|
|
91
|
-
const jsComponents = ["fastify", "frontend", "e2e"];
|
|
99
|
+
const jsComponents = ["fastify", "express", "frontend", "e2e"];
|
|
92
100
|
for (const component of jsComponents) {
|
|
93
101
|
const dir = componentPaths[component];
|
|
94
102
|
if (!dir) continue;
|
|
@@ -99,6 +107,7 @@ function detectPackageManagerFromComponents(cwd, componentPaths) {
|
|
|
99
107
|
}
|
|
100
108
|
return detectPackageManager(cwd);
|
|
101
109
|
}
|
|
110
|
+
var KNOWN_FEATURES = ["auth"];
|
|
102
111
|
function toKebab(s) {
|
|
103
112
|
return s.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
|
|
104
113
|
}
|
|
@@ -127,8 +136,7 @@ async function downloadRepo(localPath) {
|
|
|
127
136
|
if (localPath) {
|
|
128
137
|
return localPath;
|
|
129
138
|
}
|
|
130
|
-
const dest = join(tmpdir(),
|
|
131
|
-
await mkdir(dest, { recursive: true });
|
|
139
|
+
const dest = await mkdtemp(join(tmpdir(), "projx-"));
|
|
132
140
|
if (hasCommand("git")) {
|
|
133
141
|
execSync(`git clone --depth 1 ${REPO_URL}.git "${dest}/repo"`, {
|
|
134
142
|
stdio: "pipe"
|
|
@@ -213,7 +221,12 @@ async function copyStaticFiles(repoDir, dest) {
|
|
|
213
221
|
await cp(extensionsJson, join(dest, ".vscode/extensions.json"));
|
|
214
222
|
manifest.push(".vscode/extensions.json");
|
|
215
223
|
}
|
|
216
|
-
const staticScripts = [
|
|
224
|
+
const staticScripts = [
|
|
225
|
+
"ci-local.sh",
|
|
226
|
+
"setup-docker.sh",
|
|
227
|
+
"setup-ssl.sh",
|
|
228
|
+
"style-check.py"
|
|
229
|
+
];
|
|
217
230
|
const scriptsSrc = join(tpl, "scripts");
|
|
218
231
|
if (existsSync(scriptsSrc)) {
|
|
219
232
|
await mkdir(join(dest, "scripts"), { recursive: true });
|
|
@@ -326,10 +339,10 @@ async function writeProjxConfig(cwd, data) {
|
|
|
326
339
|
}
|
|
327
340
|
var DEFAULT_ROOT_SKIP_PATTERNS = [
|
|
328
341
|
"docker-compose.yml",
|
|
329
|
-
"docker-compose.dev.yml",
|
|
330
342
|
"README.md",
|
|
331
343
|
".githooks/pre-commit",
|
|
332
344
|
".github/workflows/ci.yml",
|
|
345
|
+
"scripts/ci-local.sh",
|
|
333
346
|
"scripts/setup.sh",
|
|
334
347
|
"scripts/setup-docker.sh",
|
|
335
348
|
"scripts/setup-ssl.sh"
|
|
@@ -337,6 +350,7 @@ var DEFAULT_ROOT_SKIP_PATTERNS = [
|
|
|
337
350
|
var DEFAULT_COMPONENT_SKIP_PATTERNS = {
|
|
338
351
|
fastapi: ["pyproject.toml"],
|
|
339
352
|
fastify: ["package.json"],
|
|
353
|
+
express: ["package.json"],
|
|
340
354
|
frontend: ["package.json"],
|
|
341
355
|
e2e: ["package.json"],
|
|
342
356
|
mobile: ["pubspec.yaml"]
|
|
@@ -375,16 +389,17 @@ async function discoverComponentsFromMarkers(cwd) {
|
|
|
375
389
|
}
|
|
376
390
|
function render(template, vars) {
|
|
377
391
|
const lines = template.split("\n");
|
|
378
|
-
return renderLines(lines, vars).replace(/\n{
|
|
392
|
+
return renderLines(lines, vars).replace(/\n{4,}/g, "\n\n\n");
|
|
379
393
|
}
|
|
380
394
|
function evalExpr(expr, vars) {
|
|
381
395
|
const components = vars.components;
|
|
382
396
|
const projectName = vars.projectName;
|
|
383
397
|
const pmName = vars.pm?.name ?? "npm";
|
|
384
|
-
const
|
|
385
|
-
const
|
|
398
|
+
const orm = vars.orm ?? "prisma";
|
|
399
|
+
const argNames = ["components", "projectName", "pm", "orm"];
|
|
400
|
+
const argValues = [components, projectName, pmName, orm];
|
|
386
401
|
for (const [k, v] of Object.entries(vars)) {
|
|
387
|
-
if (
|
|
402
|
+
if (["components", "projectName", "pm", "orm"].includes(k)) continue;
|
|
388
403
|
if (!/^[a-zA-Z_$][\w$]*$/.test(k)) continue;
|
|
389
404
|
argNames.push(k);
|
|
390
405
|
argValues.push(v);
|
|
@@ -521,9 +536,11 @@ export {
|
|
|
521
536
|
REPO_URL,
|
|
522
537
|
COMPONENTS,
|
|
523
538
|
PACKAGE_MANAGERS,
|
|
539
|
+
ORM_PROVIDERS,
|
|
524
540
|
pmCommands,
|
|
525
541
|
detectPackageManager,
|
|
526
542
|
detectPackageManagerFromComponents,
|
|
543
|
+
KNOWN_FEATURES,
|
|
527
544
|
toKebab,
|
|
528
545
|
toSnake,
|
|
529
546
|
toTitle,
|