create-projx 1.7.2 → 1.7.4

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
@@ -4,6 +4,7 @@
4
4
  [![CI](https://github.com/ukanhaupa/projx/actions/workflows/ci.yml/badge.svg)](https://github.com/ukanhaupa/projx/actions/workflows/ci.yml)
5
5
  [![GitHub stars](https://img.shields.io/github/stars/ukanhaupa/projx)](https://github.com/ukanhaupa/projx)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
+ [![Sponsor](https://img.shields.io/github/sponsors/ukanhaupa?label=Sponsor&logo=GitHub)](https://github.com/sponsors/ukanhaupa)
7
8
 
8
9
  **Go from blank folder to production-ready project in 30 seconds.** Backend-only API, AI/ML app, mobile, full-stack, infra setup — pick what you need and get it wired with auth, database, Docker, CI/CD, hooks, and tests. All optional. All yours.
9
10
 
@@ -85,20 +86,76 @@ If this saves you even one hour, it's already paid for itself. (It's free.)
85
86
 
86
87
  ## What you get
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
+ | Component | Stack | What it gives you |
90
+ | ------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------- |
91
+ | `fastapi` | Python, SQLAlchemy, Alembic | Auto-entity CRUD, JWT auth, migrations, OpenAPI docs |
92
+ | `fastify` | Node.js, Prisma / Drizzle / Sequelize / TypeORM, TypeBox | Auto-entity CRUD, JWT auth, typed schemas, OpenAPI docs |
93
+ | `express` | Express 5, TypeScript, Prisma / Drizzle / Sequelize / TypeORM | Auto-entity CRUD, JWT auth, validation, security middleware, health checks |
94
+ | `frontend` | React 19, TypeScript, Vite | Auth, theming, design tokens, light/dark mode |
95
+ | `mobile` | Flutter, Riverpod, GoRouter | Auth, biometric, theming, GoRouter shell |
96
+ | `e2e` | Playwright | Page object model, auth fixtures, accessibility scans |
97
+ | `infra` | Terraform, AWS | EKS, RDS, VPC, ALB, CodePipeline, multi-environment |
98
+ | `admin-panel` | Directus (Docker), Postgres | Instant admin UI + REST/GraphQL over your DB, internal-only behind nginx |
97
99
 
98
100
  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.
99
101
 
100
102
  All optional. Pick any combination.
101
103
 
104
+ ## Optional features
105
+
106
+ Components are the floor. Features are the opt-in modules that ride on top — same standard, same CI, same skip-list discipline. Today there's one, and it's the one everyone rewrites: **auth**.
107
+
108
+ ```bash
109
+ # Fastify + Prisma (default ORM)
110
+ npx create-projx my-app --components fastify --auth fastify
111
+
112
+ # Express + Drizzle
113
+ npx create-projx my-app --components express --orm drizzle --auth express
114
+
115
+ # FastAPI + SQLAlchemy
116
+ npx create-projx my-app --components fastapi --auth fastapi
117
+
118
+ # Multiple backends, one flag — comma-separable targets
119
+ npx create-projx my-app --components fastify,express --auth fastify,express
120
+
121
+ # Add auth to an existing project
122
+ npx create-projx add frontend --auth fastify
123
+ ```
124
+
125
+ `update` re-applies any feature recorded on a component, so template upgrades never strip your auth wiring.
126
+
127
+ ### What `--auth` ships
128
+
129
+ - Email + password signup — first user auto-promoted to admin
130
+ - Login with JWT access token and refresh-token rotation, with replay detection
131
+ - Account lockout after 5 failed logins (15-minute cooldown)
132
+ - MFA via TOTP authenticator app — enroll via otpauth URL (client renders the QR), verify on challenge
133
+ - MFA recovery codes — generate, single-use consume, regenerate
134
+ - MFA lockout on repeated bad codes with time-based unlock
135
+ - Password reset via emailed single-use token (30-minute TTL)
136
+ - Email verification with resend endpoint (24-hour TTL token)
137
+ - Authenticated password change — revokes all other sessions
138
+ - Active session listing
139
+ - Current-user lookup via `/me`
140
+ - Role-based permissions baked into the JWT payload
141
+ - SMTP mailer for verification and reset emails — falls back to logging the link when SMTP is unset
142
+ - Cron-driven cleanup of expired tokens (toggle via `AUTH_BACKGROUND_JOBS`)
143
+ - Centralized error responses with `request_id` propagation
144
+
145
+ Sixteen endpoints across signup, login, MFA challenge/enroll/disable, recovery-code regen, refresh, logout, change-password, sessions, forgot/reset password, verify/resend email, me. Same surface on every backend — mounted at `/auth/*` on fastify and express, `/api/v1/auth/*` on fastapi.
146
+
147
+ ### Backend × ORM compatibility
148
+
149
+ | Backend | Prisma | Drizzle | Sequelize | TypeORM | SQLAlchemy |
150
+ | --------- | ------ | ------- | --------- | ------- | ---------- |
151
+ | `fastify` | yes | yes | yes | yes | — |
152
+ | `express` | yes | yes | yes | yes | — |
153
+ | `fastapi` | — | — | — | — | yes |
154
+
155
+ Nine combinations, one external contract. ORM-specific bits live under [features/auth/](features/auth/) (per-stack, per-ORM subdirectories); the shared surface per stack lives under each stack's `common/` subdirectory. Env vars the feature reads: `JWT_SECRET`, `FRONTEND_URL`, `AUTH_BACKGROUND_JOBS` (all backends); `JWT_ALGORITHMS`, `MFA_ISSUER`, `AUTH_CLEANUP_INTERVAL_SECONDS` (fastapi).
156
+
157
+ Full feature-template spec — manifests, patches, anchors, idempotency — in [docs/feature-templates.md](docs/feature-templates.md).
158
+
102
159
  ## Built for humans and AI agents
103
160
 
104
161
  Projx is a shared operating system for teams that ship with both:
@@ -462,6 +519,12 @@ Add this to your project's README:
462
519
 
463
520
  ---
464
521
 
522
+ ## Sponsor
523
+
524
+ Projx is free and MIT-licensed. If it saves you time, consider [sponsoring its development](https://github.com/sponsors/ukanhaupa) — it keeps the templates current and the roadmap moving.
525
+
526
+ ---
527
+
465
528
  ## License
466
529
 
467
530
  MIT
@@ -8,8 +8,8 @@ import {
8
8
  matchesSkip,
9
9
  saveBaselineRef,
10
10
  writeTemplateToDir
11
- } from "./chunk-XAYCVTHL.js";
12
- import "./chunk-FQPOK3QZ.js";
11
+ } from "./chunk-RFHLWYJ4.js";
12
+ import "./chunk-B7PW6QO7.js";
13
13
  export {
14
14
  BASELINE_REF,
15
15
  applyTemplate,
@@ -23,7 +23,8 @@ var COMPONENTS = [
23
23
  "frontend",
24
24
  "mobile",
25
25
  "e2e",
26
- "infra"
26
+ "infra",
27
+ "admin-panel"
27
28
  ];
28
29
  var PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
29
30
  var ORM_PROVIDERS = [
@@ -223,6 +224,7 @@ async function copyStaticFiles(repoDir, dest) {
223
224
  }
224
225
  const staticScripts = [
225
226
  "ci-local.sh",
227
+ "ci-runner-gc.sh",
226
228
  "check-bundle-size.sh",
227
229
  "setup-docker.sh",
228
230
  "setup-ssl.sh",
@@ -295,7 +297,10 @@ function parseMarker(raw) {
295
297
  if (!component) return null;
296
298
  return {
297
299
  component,
298
- skip: Array.isArray(data.skip) ? data.skip : []
300
+ skip: Array.isArray(data.skip) ? data.skip : [],
301
+ features: Array.isArray(data.features) ? data.features.filter(
302
+ (f) => typeof f === "string"
303
+ ) : void 0
299
304
  };
300
305
  } catch {
301
306
  return null;
@@ -308,9 +313,25 @@ async function readComponentMarker(dir) {
308
313
  }
309
314
  async function writeComponentMarker(dir, data) {
310
315
  const markerPath = join(dir, COMPONENT_MARKER);
316
+ const existing = await readFileOrNull(markerPath);
317
+ let preservedFeatures;
318
+ if (existing) {
319
+ try {
320
+ const prev = JSON.parse(existing);
321
+ if (Array.isArray(prev.features)) {
322
+ preservedFeatures = prev.features.filter(
323
+ (f) => typeof f === "string"
324
+ );
325
+ }
326
+ } catch {
327
+ preservedFeatures = void 0;
328
+ }
329
+ }
330
+ const features = data.features ?? preservedFeatures;
311
331
  const out = {
312
332
  component: data.component,
313
- skip: Array.isArray(data.skip) ? data.skip : []
333
+ skip: Array.isArray(data.skip) ? data.skip : [],
334
+ ...features && features.length > 0 ? { features } : {}
314
335
  };
315
336
  await writeFile(markerPath, JSON.stringify(out, null, 2) + "\n");
316
337
  }
@@ -344,6 +365,7 @@ var DEFAULT_ROOT_SKIP_PATTERNS = [
344
365
  ".githooks/pre-commit",
345
366
  ".github/workflows/ci.yml",
346
367
  "scripts/ci-local.sh",
368
+ "scripts/ci-runner-gc.sh",
347
369
  "scripts/check-bundle-size.sh",
348
370
  "scripts/setup.sh",
349
371
  "scripts/setup-docker.sh",
@@ -13,7 +13,7 @@ import {
13
13
  toSnake,
14
14
  upsertComponentMarker,
15
15
  writeProjxConfig
16
- } from "./chunk-FQPOK3QZ.js";
16
+ } from "./chunk-B7PW6QO7.js";
17
17
 
18
18
  // src/baseline.ts
19
19
  import { existsSync, writeFileSync, unlinkSync } from "fs";
@@ -44,7 +44,8 @@ var CANONICAL_DISPLAY = {
44
44
  frontend: "Frontend",
45
45
  mobile: "Flutter",
46
46
  e2e: "E2E",
47
- infra: "Terraform"
47
+ infra: "Terraform",
48
+ "admin-panel": "Admin Panel"
48
49
  };
49
50
  function withInstances(vars) {
50
51
  const base = vars.instances && vars.instances.length > 0 ? vars.instances : vars.components.map((type) => ({
@@ -66,7 +67,8 @@ function withInstances(vars) {
66
67
  frontendInstances: byType("frontend"),
67
68
  mobileInstances: byType("mobile"),
68
69
  e2eInstances: byType("e2e"),
69
- infraInstances: byType("infra")
70
+ infraInstances: byType("infra"),
71
+ adminPanelInstances: byType("admin-panel")
70
72
  };
71
73
  }
72
74
  async function renderShared(filename, vars) {
@@ -126,7 +128,7 @@ function generateVscodeSettings(vars) {
126
128
  // src/baseline.ts
127
129
  var BASELINE_REF = "refs/projx/baseline";
128
130
  async function migrateComponentMarkers(cwd, components, componentPaths, applyDefaults) {
129
- const { readComponentMarker: readComponentMarker2, writeComponentMarker } = await import("./utils-MC7VKL2U.js");
131
+ const { readComponentMarker: readComponentMarker2, writeComponentMarker } = await import("./utils-X2P47QNN.js");
130
132
  for (const component of components) {
131
133
  const dir = componentPaths[component];
132
134
  const markerDir = join2(cwd, dir);
@@ -423,7 +425,7 @@ async function writeTemplateToDir(dest, repoDir, components, componentPaths, var
423
425
  if (!matchesSkip(file, effectiveSkip)) return true;
424
426
  return !existsSync(join2(realCwd, file));
425
427
  };
426
- if (hasBackend || components.includes("frontend")) {
428
+ if (hasBackend || components.includes("frontend") || components.includes("admin-panel")) {
427
429
  if (shouldWrite("docker-compose.yml"))
428
430
  await writeFile(
429
431
  join2(dest, "docker-compose.yml"),
@@ -658,25 +660,35 @@ async function substituteNamesForInstance(inst, dest, name, nameSnake, overrides
658
660
  const isCanonical = path === type;
659
661
  if (type === "fastapi") {
660
662
  const target = isCanonical ? overrides?.fastapi ?? `${name}-fastapi` : `${name}-${path}`;
661
- await replaceInFile(
662
- join2(dest, `${path}/pyproject.toml`),
663
- "projx-fastapi",
664
- target
665
- );
663
+ for (const file of [
664
+ "pyproject.toml",
665
+ "src/configs/_database.py",
666
+ "tests/test_app.py"
667
+ ]) {
668
+ await replaceInFile(
669
+ join2(dest, `${path}/${file}`),
670
+ "projx-fastapi",
671
+ target
672
+ );
673
+ }
666
674
  } else if (type === "fastify") {
667
675
  const target = isCanonical ? overrides?.fastify ?? `${name}-fastify` : `${name}-${path}`;
668
- await replaceInFile(
669
- join2(dest, `${path}/package.json`),
670
- "projx-fastify",
671
- target
672
- );
676
+ for (const file of ["package.json", ".env.example", ".env.test"]) {
677
+ await replaceInFile(
678
+ join2(dest, `${path}/${file}`),
679
+ "projx-fastify",
680
+ target
681
+ );
682
+ }
673
683
  } else if (type === "express") {
674
684
  const target = isCanonical ? overrides?.express ?? `${name}-express` : `${name}-${path}`;
675
- await replaceInFile(
676
- join2(dest, `${path}/package.json`),
677
- "projx-express",
678
- target
679
- );
685
+ for (const file of ["package.json", ".env.example", ".env.test"]) {
686
+ await replaceInFile(
687
+ join2(dest, `${path}/${file}`),
688
+ "projx-express",
689
+ target
690
+ );
691
+ }
680
692
  } else if (type === "frontend") {
681
693
  const target = isCanonical ? overrides?.frontend ?? `${name}-frontend` : `${name}-${path}`;
682
694
  await replaceInFile(
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  matchesSkip,
10
10
  saveBaselineRef,
11
11
  writeTemplateToDir
12
- } from "./chunk-XAYCVTHL.js";
12
+ } from "./chunk-RFHLWYJ4.js";
13
13
  import {
14
14
  COMPONENTS,
15
15
  COMPONENT_MARKER,
@@ -36,11 +36,12 @@ import {
36
36
  toSnake,
37
37
  writeComponentMarker,
38
38
  writeProjxConfig
39
- } from "./chunk-FQPOK3QZ.js";
39
+ } from "./chunk-B7PW6QO7.js";
40
40
 
41
41
  // src/index.ts
42
42
  import { existsSync as existsSync11 } from "fs";
43
43
  import { resolve } from "path";
44
+ import { pathToFileURL } from "url";
44
45
 
45
46
  // src/features.ts
46
47
  import { existsSync } from "fs";
@@ -186,16 +187,16 @@ async function readManifest(featureDir, feature) {
186
187
  }
187
188
  return manifest;
188
189
  }
189
- async function applyTarget(args2) {
190
- const stackDir = join(args2.featureDir, args2.target.component);
190
+ async function applyTarget(args) {
191
+ const stackDir = join(args.featureDir, args.target.component);
191
192
  if (!existsSync(stackDir)) return;
192
- const targetPath = join(args2.dest, args2.target.path);
193
+ const targetPath = join(args.dest, args.target.path);
193
194
  if (!existsSync(targetPath)) {
194
195
  throw new Error(
195
- `Target instance path ${args2.target.path} not found in ${args2.dest}.`
196
+ `Target instance path ${args.target.path} not found in ${args.dest}.`
196
197
  );
197
198
  }
198
- const orm = typeof args2.vars.orm === "string" ? args2.vars.orm : void 0;
199
+ const orm = typeof args.vars.orm === "string" ? args.vars.orm : void 0;
199
200
  const ormDir = orm ? join(stackDir, orm) : void 0;
200
201
  const hasOrmDir = ormDir !== void 0 && existsSync(ormDir);
201
202
  const ormPatchNames = /* @__PURE__ */ new Set();
@@ -210,7 +211,7 @@ async function applyTarget(args2) {
210
211
  for (const sub of ["files", join("common", "files")]) {
211
212
  const filesDir = join(stackDir, sub);
212
213
  if (existsSync(filesDir)) {
213
- await renderFilesInto(filesDir, targetPath, args2.vars);
214
+ await renderFilesInto(filesDir, targetPath, args.vars);
214
215
  }
215
216
  }
216
217
  for (const sub of ["patches", join("common", "patches")]) {
@@ -219,7 +220,7 @@ async function applyTarget(args2) {
219
220
  await applyPatches(
220
221
  patchesDir,
221
222
  targetPath,
222
- args2.featureName,
223
+ args.featureName,
223
224
  ormPatchNames,
224
225
  hasOrmDir
225
226
  );
@@ -228,18 +229,18 @@ async function applyTarget(args2) {
228
229
  if (hasOrmDir) {
229
230
  const ormFilesDir = join(ormDir, "files");
230
231
  if (existsSync(ormFilesDir)) {
231
- await renderFilesInto(ormFilesDir, targetPath, args2.vars);
232
+ await renderFilesInto(ormFilesDir, targetPath, args.vars);
232
233
  }
233
234
  const ormPatchesDir = join(ormDir, "patches");
234
235
  if (existsSync(ormPatchesDir)) {
235
- await applyPatches(ormPatchesDir, targetPath, args2.featureName);
236
+ await applyPatches(ormPatchesDir, targetPath, args.featureName);
236
237
  }
237
238
  }
238
- const envKeys = args2.manifest.env?.[args2.target.component] ?? [];
239
+ const envKeys = args.manifest.env?.[args.target.component] ?? [];
239
240
  if (envKeys.length > 0) {
240
- await appendEnvExample(targetPath, args2.featureName, envKeys);
241
+ await appendEnvExample(targetPath, args.featureName, envKeys);
241
242
  }
242
- await recordFeatureInMarker(targetPath, args2.featureName);
243
+ await recordFeatureInMarker(targetPath, args.featureName);
243
244
  }
244
245
  async function renderFilesInto(filesDir, targetPath, vars) {
245
246
  const entries = await collectFiles(filesDir);
@@ -382,7 +383,11 @@ var LABELS = {
382
383
  frontend: { label: "Frontend", hint: "React 19 + Vite + React Router" },
383
384
  mobile: { label: "Mobile", hint: "Flutter + Riverpod + GoRouter" },
384
385
  e2e: { label: "E2E Tests", hint: "Playwright" },
385
- infra: { label: "Infrastructure", hint: "Terraform + AWS" }
386
+ infra: { label: "Infrastructure", hint: "Terraform + AWS" },
387
+ "admin-panel": {
388
+ label: "Admin Panel",
389
+ hint: "Directus \u2014 instant admin over Postgres (Docker)"
390
+ }
386
391
  };
387
392
  var DEFAULTS = ["fastify", "frontend", "e2e"];
388
393
  async function runPrompts(nameArg) {
@@ -607,6 +612,8 @@ async function installDeps(dest, components, pm) {
607
612
  break;
608
613
  case "infra":
609
614
  break;
615
+ case "admin-panel":
616
+ break;
610
617
  }
611
618
  } catch {
612
619
  spinner6.stop(`Failed to install ${component} dependencies.`);
@@ -632,6 +639,32 @@ import { readFile as readFile3, unlink } from "fs/promises";
632
639
  import { execSync } from "child_process";
633
640
  import { join as join3 } from "path";
634
641
  import * as p3 from "@clack/prompts";
642
+ async function collectRecordedFeatures(cwd, components, componentPaths) {
643
+ const byFeature = /* @__PURE__ */ new Map();
644
+ for (const component of components) {
645
+ const dir = componentPaths[component] ?? component;
646
+ const raw = await readFile3(join3(cwd, dir, COMPONENT_MARKER), "utf-8").catch(
647
+ () => null
648
+ );
649
+ if (!raw) continue;
650
+ let features;
651
+ try {
652
+ features = JSON.parse(raw).features;
653
+ } catch {
654
+ continue;
655
+ }
656
+ if (!Array.isArray(features)) continue;
657
+ for (const name of features) {
658
+ if (typeof name !== "string") continue;
659
+ const list = byFeature.get(name) ?? [];
660
+ list.push(dir);
661
+ byFeature.set(name, list);
662
+ }
663
+ }
664
+ const out = {};
665
+ for (const [feature, targets] of byFeature) out[feature] = targets.join(",");
666
+ return out;
667
+ }
635
668
  async function update(cwd, localRepo) {
636
669
  p3.intro("projx update");
637
670
  const isLocal = !!localRepo;
@@ -766,6 +799,24 @@ async function update(cwd, localRepo) {
766
799
  extraInstances
767
800
  );
768
801
  spinner6.stop("Template applied.");
802
+ const recordedFeatures = await collectRecordedFeatures(
803
+ cwd,
804
+ components,
805
+ componentPaths
806
+ );
807
+ if (Object.keys(recordedFeatures).length > 0) {
808
+ const featSpinner = p3.spinner();
809
+ featSpinner.start("Re-applying recorded features");
810
+ await applyFeatures({
811
+ features: recordedFeatures,
812
+ repoDir,
813
+ components,
814
+ instances,
815
+ dest: cwd,
816
+ vars
817
+ });
818
+ featSpinner.stop("Features re-applied.");
819
+ }
769
820
  const pinnedUpdates = await findPinnedFilesWithUpdates(
770
821
  cwd,
771
822
  repoDir,
@@ -851,7 +902,7 @@ function hasUncommittedChanges(cwd) {
851
902
  async function findPinnedFilesWithUpdates(cwd, repoDir, components, componentPaths, vars, version, componentSkips, rootSkip) {
852
903
  const { mkdtemp: mkdtemp2, rm: rm2, readFile: readFile8 } = await import("fs/promises");
853
904
  const { tmpdir: tmpdir2 } = await import("os");
854
- const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-ZPPJKHBN.js");
905
+ const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-O25CAKIL.js");
855
906
  const config = await readProjxConfig(cwd);
856
907
  const rootPinned = Array.isArray(config.skip) ? config.skip : [];
857
908
  const componentPinned = [];
@@ -1036,7 +1087,7 @@ import { copyFileSync as copyFileSync2, existsSync as existsSync4 } from "fs";
1036
1087
  import { readFile as readFile4 } from "fs/promises";
1037
1088
  import { join as join4 } from "path";
1038
1089
  import * as p4 from "@clack/prompts";
1039
- async function add(cwd, newComponents, localRepo, skipInstall = false, customName) {
1090
+ async function add(cwd, newComponents, localRepo, skipInstall = false, customName, features) {
1040
1091
  p4.intro("projx add");
1041
1092
  const isLocal = !!localRepo;
1042
1093
  if (!existsSync4(join4(cwd, ".projx"))) {
@@ -1124,6 +1175,19 @@ async function add(cwd, newComponents, localRepo, skipInstall = false, customNam
1124
1175
  { realCwd: cwd }
1125
1176
  );
1126
1177
  spinner6.stop("Components added.");
1178
+ if (features && Object.keys(features).length > 0) {
1179
+ const featSpinner = p4.spinner();
1180
+ featSpinner.start("Applying features");
1181
+ await applyFeatures({
1182
+ features,
1183
+ repoDir,
1184
+ components: allComponents,
1185
+ instances,
1186
+ dest: cwd,
1187
+ vars
1188
+ });
1189
+ featSpinner.stop("Features applied.");
1190
+ }
1127
1191
  if (!skipInstall) {
1128
1192
  await installDeps2(
1129
1193
  cwd,
@@ -1322,6 +1386,8 @@ async function installDeps2(dest, instances, pm) {
1322
1386
  break;
1323
1387
  case "infra":
1324
1388
  break;
1389
+ case "admin-panel":
1390
+ break;
1325
1391
  }
1326
1392
  } catch {
1327
1393
  spinner6.stop(`Failed to install ${type} dependencies (${path}/).`);
@@ -1418,6 +1484,15 @@ async function scanDirectory(dir, relPath) {
1418
1484
  evidence: "Terraform .tf files found"
1419
1485
  });
1420
1486
  }
1487
+ const dockerfile = await readFileOrNull(join5(dir, "Dockerfile"));
1488
+ if (dockerfile && /^FROM\s+directus\/directus/m.test(dockerfile)) {
1489
+ results.push({
1490
+ component: "admin-panel",
1491
+ directory: relPath,
1492
+ confidence: "high",
1493
+ evidence: "Dockerfile builds from directus/directus image"
1494
+ });
1495
+ }
1421
1496
  return results;
1422
1497
  }
1423
1498
  async function readPkg(dir) {
@@ -4086,7 +4161,6 @@ async function runGen(opts) {
4086
4161
  }
4087
4162
 
4088
4163
  // src/index.ts
4089
- var args = process.argv.slice(2);
4090
4164
  function matchFeatureFlag(arg, argv, i) {
4091
4165
  for (const feat of KNOWN_FEATURES) {
4092
4166
  const eq = `--${feat}=`;
@@ -4109,7 +4183,8 @@ function matchFeatureFlag(arg, argv, i) {
4109
4183
  }
4110
4184
  return null;
4111
4185
  }
4112
- function parseArgs() {
4186
+ function parseArgs(argv = process.argv.slice(2)) {
4187
+ const args = argv;
4113
4188
  let command = "create";
4114
4189
  let name;
4115
4190
  let localRepo;
@@ -4249,7 +4324,7 @@ function printHelp() {
4249
4324
  projx gen entity <name> Generate a new entity
4250
4325
 
4251
4326
  Options:
4252
- --components <list> Comma-separated: fastapi,fastify,express,frontend,mobile,e2e,infra
4327
+ --components <list> Comma-separated: fastapi,fastify,express,frontend,mobile,e2e,infra,admin-panel
4253
4328
  --orm <provider> Node backend ORM: prisma (default), drizzle, sequelize, typeorm
4254
4329
  --auth <targets> Add auth feature. Targets: <component>[:<instance>] (comma-separated)
4255
4330
  --no-git Skip git init
@@ -4263,6 +4338,7 @@ function printHelp() {
4263
4338
  npx create-projx my-app --components fastapi,frontend,e2e
4264
4339
  npx create-projx my-app --components express,frontend,e2e --orm drizzle
4265
4340
  npx create-projx my-app --components fastify,frontend,mobile --auth fastify
4341
+ npx create-projx my-app --components fastify,frontend,admin-panel
4266
4342
  npx create-projx my-app -y
4267
4343
  npx create-projx add frontend mobile
4268
4344
  npx create-projx add fastify --name email-ingestor
@@ -4306,7 +4382,8 @@ async function main() {
4306
4382
  components,
4307
4383
  localRepo,
4308
4384
  options.install === false,
4309
- customName
4385
+ customName,
4386
+ options.features
4310
4387
  );
4311
4388
  return;
4312
4389
  }
@@ -4379,7 +4456,14 @@ async function main() {
4379
4456
  }
4380
4457
  await scaffold(opts, dest, localRepo);
4381
4458
  }
4382
- main().catch((err) => {
4383
- console.error(err);
4384
- process.exit(1);
4385
- });
4459
+ var isEntrypoint = process.argv[1] !== void 0 && import.meta.url === pathToFileURL(process.argv[1]).href;
4460
+ if (isEntrypoint) {
4461
+ main().catch((err) => {
4462
+ console.error(err);
4463
+ process.exit(1);
4464
+ });
4465
+ }
4466
+ export {
4467
+ matchFeatureFlag,
4468
+ parseArgs
4469
+ };
@@ -35,7 +35,7 @@ import {
35
35
  upsertComponentMarker,
36
36
  writeComponentMarker,
37
37
  writeProjxConfig
38
- } from "./chunk-FQPOK3QZ.js";
38
+ } from "./chunk-B7PW6QO7.js";
39
39
  export {
40
40
  COMPONENTS,
41
41
  COMPONENT_MARKER,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-projx",
3
- "version": "1.7.2",
3
+ "version": "1.7.4",
4
4
  "description": "Scaffold production-grade fullstack projects in seconds. FastAPI, Fastify, Express, React, Flutter, Terraform — with auth, database, CI/CD, E2E tests, and Docker. One command, ready to deploy.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,9 @@ Scaffolded with [Projx](https://github.com/ukanhaupa/projx).
27
27
  <% if (components.includes('infra')) { %>
28
28
  | **<%= paths.infra %>/** | Terraform, AWS (EKS, RDS, VPC, CodePipeline) |
29
29
  <% } %>
30
+ <% if (components.includes('admin-panel')) { %>
31
+ | **<%= paths['admin-panel'] %>/** | Directus admin panel over Postgres |
32
+ <% } %>
30
33
  | **Identity** | OIDC / JWT |
31
34
  | **Containers** | Docker, Docker Compose |
32
35
 
@@ -83,6 +86,20 @@ cd <%= paths.frontend %> && cp .env.example .env && <%= pm.install %> && <%= pm.
83
86
  cd <%= paths.mobile %> && cp .env.example .env && flutter pub get && flutter run
84
87
  ```
85
88
  <% } %>
89
+ <% if (components.includes('admin-panel')) { %>
90
+
91
+ ### <%= paths['admin-panel'] %>/
92
+
93
+ ```bash
94
+ cd <%= paths['admin-panel'] %> && cp .env.example .env # set KEY, SECRET, ADMIN_PASSWORD, DB_*
95
+ docker compose up --build <%= paths['admin-panel'] %>
96
+ ```
97
+ <% if (components.includes('frontend')) { %>
98
+ Internal-only — reach the admin UI at `https://localhost/admin/` through the frontend proxy.
99
+ <% } else { %>
100
+ Internal-only — publish the port for local admin work: `docker compose run --service-ports --rm <%= paths['admin-panel'] %>`.
101
+ <% } %>
102
+ <% } %>
86
103
 
87
104
  ## Testing
88
105
 
@@ -11,6 +11,9 @@ permissions:
11
11
  contents: read
12
12
  pull-requests: read
13
13
 
14
+ env:
15
+ WS: run-${{ github.run_id }}-${{ github.run_attempt }}
16
+
14
17
  jobs:
15
18
  changes:
16
19
  name: Detect changes
@@ -36,12 +39,18 @@ jobs:
36
39
  <% } %>
37
40
  <% for (const inst of infraInstances) { %>
38
41
  <%= inst.path %>: ${{ steps.filter.outputs.<%= inst.path %> }}
42
+ <% } %>
43
+ <% for (const inst of adminPanelInstances) { %>
44
+ <%= inst.path %>: ${{ steps.filter.outputs.<%= inst.path %> }}
39
45
  <% } %>
40
46
  steps:
41
47
  - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
48
+ with:
49
+ path: ${{ env.WS }}
42
50
  - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
43
51
  id: filter
44
52
  with:
53
+ working-directory: ${{ env.WS }}
45
54
  filters: |
46
55
  <% for (const inst of fastapiInstances) { %>
47
56
  <%= inst.path %>:
@@ -71,6 +80,10 @@ jobs:
71
80
  <%= inst.path %>:
72
81
  - '<%= inst.path %>/**'
73
82
  <% } %>
83
+ <% for (const inst of adminPanelInstances) { %>
84
+ <%= inst.path %>:
85
+ - '<%= inst.path %>/**'
86
+ <% } %>
74
87
 
75
88
  secrets:
76
89
  name: Secret scan
@@ -79,10 +92,15 @@ jobs:
79
92
  - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
80
93
  with:
81
94
  fetch-depth: 0
82
- - uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2
83
- env:
84
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
85
- - run: python3 scripts/style-check.py frontend/src
95
+ path: ${{ env.WS }}
96
+ - name: Install gitleaks
97
+ run: |
98
+ curl -sSL -o /tmp/gitleaks.tar.gz https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz
99
+ tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
100
+ sudo mv /tmp/gitleaks /usr/local/bin/gitleaks
101
+ - name: Run gitleaks
102
+ run: gitleaks detect --source ${{ env.WS }} --no-git --no-banner --redact --exit-code 1
103
+ - run: python3 ${{ env.WS }}/scripts/style-check.py ${{ env.WS }}/frontend/src
86
104
  <% for (const inst of fastapiInstances) { %>
87
105
 
88
106
  <%= inst.path %>:
@@ -106,7 +124,7 @@ jobs:
106
124
  --health-retries 5
107
125
  defaults:
108
126
  run:
109
- working-directory: <%= inst.path %>
127
+ working-directory: ${{ env.WS }}/<%= inst.path %>
110
128
  env:
111
129
  SQLALCHEMY_DATABASE_URI: postgresql+asyncpg://postgres:postgres@localhost:5432/ci_test
112
130
  JWT_PROVIDER: shared_secret
@@ -114,6 +132,8 @@ jobs:
114
132
  JWT_ALGORITHMS: HS256
115
133
  steps:
116
134
  - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
135
+ with:
136
+ path: ${{ env.WS }}
117
137
  - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
118
138
  - run: uv sync --group dev
119
139
  - run: uv run ruff format --check src tests
@@ -123,7 +143,7 @@ jobs:
123
143
  run: |
124
144
  run_pip_audit() {
125
145
  for attempt in 1 2 3; do
126
- if uv run pip-audit --ignore-vuln CVE-2026-3219 --ignore-vuln PYSEC-2025-183; then
146
+ if bash audit.sh; then
127
147
  return 0
128
148
  fi
129
149
  if [ "$attempt" -eq 3 ]; then
@@ -157,12 +177,14 @@ jobs:
157
177
  --health-retries 5
158
178
  defaults:
159
179
  run:
160
- working-directory: <%= inst.path %>
180
+ working-directory: ${{ env.WS }}/<%= inst.path %>
161
181
  env:
162
182
  DATABASE_URL: postgresql://postgres:postgres@localhost:5432/ci_test
163
183
  JWT_SECRET: ci-test-secret # gitleaks:allow
164
184
  steps:
165
185
  - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
186
+ with:
187
+ path: ${{ env.WS }}
166
188
  <% if (pm.name === 'pnpm') { %>
167
189
  - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
168
190
  with:
@@ -176,7 +198,7 @@ jobs:
176
198
  with:
177
199
  node-version: 22
178
200
  cache: <%= pm.name %>
179
- cache-dependency-path: <%= inst.path %>/<%= pm.lockfile %>
201
+ cache-dependency-path: ${{ env.WS }}/<%= inst.path %>/<%= pm.lockfile %>
180
202
  <% } %>
181
203
  - run: <%= pm.ci %>
182
204
  <% if (orm === 'drizzle') { %>
@@ -216,11 +238,13 @@ jobs:
216
238
  --health-retries 5
217
239
  defaults:
218
240
  run:
219
- working-directory: <%= inst.path %>
241
+ working-directory: ${{ env.WS }}/<%= inst.path %>
220
242
  env:
221
243
  DATABASE_URL: postgresql://postgres:postgres@localhost:5432/ci_test
222
244
  steps:
223
245
  - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
246
+ with:
247
+ path: ${{ env.WS }}
224
248
  <% if (pm.name === 'pnpm') { %>
225
249
  - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
226
250
  with:
@@ -234,7 +258,7 @@ jobs:
234
258
  with:
235
259
  node-version: 22
236
260
  cache: <%= pm.name %>
237
- cache-dependency-path: <%= inst.path %>/<%= pm.lockfile %>
261
+ cache-dependency-path: ${{ env.WS }}/<%= inst.path %>/<%= pm.lockfile %>
238
262
  <% } %>
239
263
  - run: <%= pm.ci %>
240
264
  <% if (orm === 'drizzle') { %>
@@ -260,9 +284,11 @@ jobs:
260
284
  runs-on: ubuntu-latest
261
285
  defaults:
262
286
  run:
263
- working-directory: <%= inst.path %>
287
+ working-directory: ${{ env.WS }}/<%= inst.path %>
264
288
  steps:
265
289
  - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
290
+ with:
291
+ path: ${{ env.WS }}
266
292
  <% if (pm.name === 'pnpm') { %>
267
293
  - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
268
294
  with:
@@ -276,7 +302,7 @@ jobs:
276
302
  with:
277
303
  node-version: 22
278
304
  cache: <%= pm.name %>
279
- cache-dependency-path: <%= inst.path %>/<%= pm.lockfile %>
305
+ cache-dependency-path: ${{ env.WS }}/<%= inst.path %>/<%= pm.lockfile %>
280
306
  <% } %>
281
307
  - run: <%= pm.ci %>
282
308
  - run: <%= pm.exec %> prettier --check .
@@ -295,9 +321,11 @@ jobs:
295
321
  runs-on: ubuntu-latest
296
322
  defaults:
297
323
  run:
298
- working-directory: <%= inst.path %>
324
+ working-directory: ${{ env.WS }}/<%= inst.path %>
299
325
  steps:
300
326
  - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
327
+ with:
328
+ path: ${{ env.WS }}
301
329
  - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
302
330
  with:
303
331
  channel: stable
@@ -317,9 +345,11 @@ jobs:
317
345
  runs-on: ubuntu-latest
318
346
  defaults:
319
347
  run:
320
- working-directory: <%= inst.path %>
348
+ working-directory: ${{ env.WS }}/<%= inst.path %>
321
349
  steps:
322
350
  - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
351
+ with:
352
+ path: ${{ env.WS }}
323
353
  <% if (pm.name === 'pnpm') { %>
324
354
  - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
325
355
  with:
@@ -333,7 +363,7 @@ jobs:
333
363
  with:
334
364
  node-version: 22
335
365
  cache: <%= pm.name %>
336
- cache-dependency-path: <%= inst.path %>/<%= pm.lockfile %>
366
+ cache-dependency-path: ${{ env.WS }}/<%= inst.path %>/<%= pm.lockfile %>
337
367
  <% } %>
338
368
  - run: <%= pm.ci %>
339
369
  - run: <%= pm.exec %> prettier --check .
@@ -350,9 +380,11 @@ jobs:
350
380
  runs-on: ubuntu-latest
351
381
  defaults:
352
382
  run:
353
- working-directory: <%= inst.path %>/stack
383
+ working-directory: ${{ env.WS }}/<%= inst.path %>/stack
354
384
  steps:
355
385
  - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
386
+ with:
387
+ path: ${{ env.WS }}
356
388
  - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3
357
389
  with:
358
390
  terraform_version: '1.11'
@@ -360,3 +392,19 @@ jobs:
360
392
  - run: terraform init -backend=false
361
393
  - run: terraform validate
362
394
  <% } %>
395
+ <% for (const inst of adminPanelInstances) { %>
396
+
397
+ <%= inst.path %>:
398
+ name: <%= inst.display %> (docker build)
399
+ needs: changes
400
+ if: github.event_name == 'workflow_dispatch' || needs.changes.outputs.<%= inst.path %> == 'true'
401
+ runs-on: ubuntu-latest
402
+ defaults:
403
+ run:
404
+ working-directory: ${{ env.WS }}/<%= inst.path %>
405
+ steps:
406
+ - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
407
+ with:
408
+ path: ${{ env.WS }}
409
+ - run: docker build -t <%= inst.path %>:ci .
410
+ <% } %>
@@ -157,6 +157,32 @@ services:
157
157
  networks:
158
158
  - app-network
159
159
  <% } %>
160
+ <% for (const inst of adminPanelInstances) { %>
161
+ <%= inst.path %>:
162
+ build: ./<%= inst.path %>
163
+ expose:
164
+ - "8055"
165
+ env_file:
166
+ - ./<%= inst.path %>/.env
167
+ volumes:
168
+ - ./<%= inst.path %>/uploads:/directus/uploads
169
+ - ./<%= inst.path %>/extensions:/directus/extensions
170
+ restart: unless-stopped
171
+ healthcheck:
172
+ test:
173
+ [
174
+ "CMD",
175
+ "node",
176
+ "-e",
177
+ "require('http').get('http://localhost:8055/server/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
178
+ ]
179
+ interval: 30s
180
+ timeout: 10s
181
+ retries: 3
182
+ start_period: 20s
183
+ networks:
184
+ - app-network
185
+ <% } %>
160
186
  <% if (frontendInstances.length > 0) { %>
161
187
  certbot:
162
188
  image: certbot/certbot:latest
@@ -39,8 +39,8 @@ if [ -n "$<%= inst.upper %>_PY" ]; then
39
39
  echo "$<%= inst.upper %>_PY" | sed 's|^<%= inst.path %>/||' | xargs uv run ruff format
40
40
  echo "$<%= inst.upper %>_PY" | sed 's|^<%= inst.path %>/||' | xargs uv run ruff check --fix
41
41
  uv run mypy
42
- if grep -rEn 'from src\.[a-z_]+(\.[a-z_]+)?\._[a-z_]+ import' src/; then
43
- echo "ERROR: src/ files cannot import from another module's _-prefixed file. Import from the package."
42
+ if grep -rEn 'from src\.[a-z_]+(\.[a-z_]+)?\._[a-z_]+ import' src/ | grep -v 'pragma:.*allow-private-import'; then
43
+ echo "ERROR: src/ files cannot import from another module's _-prefixed file. Import from the package, or add '# pragma: allow-private-import' on the line if the cycle makes a package import impossible."
44
44
  exit 1
45
45
  fi
46
46
  uv run lint-imports
@@ -121,6 +121,14 @@ else
121
121
  echo "<%= inst.display %> skipped (SDK not installed)."
122
122
  fi
123
123
  <% } %>
124
+ <% for (const inst of adminPanelInstances) { %>
125
+
126
+ if [ ! -f <%= inst.path %>/.env ] && [ -f <%= inst.path %>/.env.example ]; then
127
+ cp <%= inst.path %>/.env.example <%= inst.path %>/.env
128
+ echo "<%= inst.path %>/.env created from .env.example."
129
+ fi
130
+ echo "<%= inst.display %> configured (Docker — run 'docker compose up <%= inst.path %>')."
131
+ <% } %>
124
132
 
125
133
  echo ""
126
134
  echo "Done. Ensure PostgreSQL is running locally, then run '<%= pm.runDev %>' (or 'uv run main.py') in each service."