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 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 | What it gives you |
82
- | ---------- | --------------------------- | ------------------------------------------------------------ |
83
- | `fastapi` | Python, SQLAlchemy, Alembic | Auto-entity CRUD, JWT auth, migrations, OpenAPI docs |
84
- | `fastify` | Node.js, Prisma, TypeBox | Auto-entity CRUD, JWT auth, typed schemas, OpenAPI docs |
85
- | `frontend` | React 19, TypeScript, Vite | Auto-entity UI from `/_meta`, design tokens, light/dark mode |
86
- | `mobile` | Flutter, Riverpod, GoRouter | Auto-entity screens, offline-first with Isar, biometric auth |
87
- | `e2e` | Playwright | Page object model, auth fixtures, accessibility scans |
88
- | `infra` | Terraform, AWS | EKS, RDS, VPC, ALB, CodePipeline, multi-environment |
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 | Default skips |
193
- |-------|---------------|
194
- | Root (`.projx`) | `docker-compose.yml`, `docker-compose.dev.yml`, `README.md`, `.githooks/pre-commit`, `.github/workflows/ci.yml`, `scripts/setup.sh`, `scripts/setup-docker.sh`, `scripts/setup-ssl.sh` |
195
- | fastapi | `pyproject.toml` |
196
- | fastify / frontend / e2e | `package.json` |
197
- | mobile | `pubspec.yaml` |
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
- ### Sync Types
322
+ ### ORM choice (Node backends)
311
323
 
312
- Regenerate all frontend/mobile types from a running backend:
324
+ Pick an ORM at create-time with `--orm <provider>`. Default: **prisma**. Supported: `prisma`, `drizzle`, `sequelize`, `typeorm`.
313
325
 
314
- ```bash
315
- npx create-projx sync # auto-detects URL
316
- npx create-projx sync --url http://localhost:8000/api/v1/_meta # explicit URL
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
- Fetches `/_meta` from your backend, generates typed interfaces for every entity. Run after any backend change new field, renamed column, new entity.
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
- The generic `api.ts` client accepts type parameters:
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/ # Auto-entity UI from /_meta
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 and `/_meta` 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, not listed in `/_meta`. The `/_meta` endpoint requires authentication on both backends.
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
- **Frontend** Fetches metadata from `GET /api/v1/_meta`, renders table + form UI automatically. Customize with overrides.
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
- **Mobile** Same metadata endpoint, generates list/detail/form screens. Offline-first with local DB and sync queue.
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
 
@@ -8,8 +8,8 @@ import {
8
8
  matchesSkip,
9
9
  saveBaselineRef,
10
10
  writeTemplateToDir
11
- } from "./chunk-XQ7FE4U3.js";
12
- import "./chunk-6YRBHJ2V.js";
11
+ } from "./chunk-N4WD4VN3.js";
12
+ import "./chunk-OLPF7FAN.js";
13
13
  export {
14
14
  BASELINE_REF,
15
15
  applyTemplate,
@@ -13,12 +13,14 @@ import {
13
13
  toSnake,
14
14
  upsertComponentMarker,
15
15
  writeProjxConfig
16
- } from "./chunk-6YRBHJ2V.js";
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-AVKSTHIF.js");
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 cp(srcDir, outDir, { recursive: true, force: true });
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(), `projx-tpl-${Date.now()}`);
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(), `projx-${Date.now()}`);
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 = ["setup-docker.sh", "setup-ssl.sh"];
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{3,}/g, "\n\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 argNames = ["components", "projectName", "pm"];
385
- const argValues = [components, projectName, pmName];
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 (k === "components" || k === "projectName" || k === "pm") continue;
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,