create-projx 1.7.3 → 1.7.5
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 +72 -9
- package/dist/{baseline-HNSDAHQ4.js → baseline-O25CAKIL.js} +2 -2
- package/dist/{chunk-3NL6OTAP.js → chunk-B7PW6QO7.js} +23 -3
- package/dist/{chunk-RA6FWWUM.js → chunk-RFHLWYJ4.js} +7 -5
- package/dist/index.js +109 -25
- package/dist/{utils-W4CWICA7.js → utils-X2P47QNN.js} +1 -1
- package/package.json +1 -1
- package/src/templates/README.md.ejs +17 -0
- package/src/templates/ci.yml.ejs +30 -0
- package/src/templates/docker-compose.yml.ejs +17 -0
- package/src/templates/pre-commit.ejs +14 -0
- package/src/templates/setup.sh.ejs +8 -0
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
[](https://github.com/ukanhaupa/projx/actions/workflows/ci.yml)
|
|
5
5
|
[](https://github.com/ukanhaupa/projx)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](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
|
|
89
|
-
|
|
|
90
|
-
| `fastapi`
|
|
91
|
-
| `fastify`
|
|
92
|
-
| `express`
|
|
93
|
-
| `frontend`
|
|
94
|
-
| `mobile`
|
|
95
|
-
| `e2e`
|
|
96
|
-
| `infra`
|
|
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` | Go, HTMX (Docker), Postgres | Auth-gated table browser over any Postgres, read-only by default, 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
|
|
@@ -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 = [
|
|
@@ -296,7 +297,10 @@ function parseMarker(raw) {
|
|
|
296
297
|
if (!component) return null;
|
|
297
298
|
return {
|
|
298
299
|
component,
|
|
299
|
-
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
|
|
300
304
|
};
|
|
301
305
|
} catch {
|
|
302
306
|
return null;
|
|
@@ -309,9 +313,25 @@ async function readComponentMarker(dir) {
|
|
|
309
313
|
}
|
|
310
314
|
async function writeComponentMarker(dir, data) {
|
|
311
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;
|
|
312
331
|
const out = {
|
|
313
332
|
component: data.component,
|
|
314
|
-
skip: Array.isArray(data.skip) ? data.skip : []
|
|
333
|
+
skip: Array.isArray(data.skip) ? data.skip : [],
|
|
334
|
+
...features && features.length > 0 ? { features } : {}
|
|
315
335
|
};
|
|
316
336
|
await writeFile(markerPath, JSON.stringify(out, null, 2) + "\n");
|
|
317
337
|
}
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
toSnake,
|
|
14
14
|
upsertComponentMarker,
|
|
15
15
|
writeProjxConfig
|
|
16
|
-
} from "./chunk-
|
|
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-
|
|
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"),
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
matchesSkip,
|
|
10
10
|
saveBaselineRef,
|
|
11
11
|
writeTemplateToDir
|
|
12
|
-
} from "./chunk-
|
|
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-
|
|
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(
|
|
190
|
-
const stackDir = join(
|
|
190
|
+
async function applyTarget(args) {
|
|
191
|
+
const stackDir = join(args.featureDir, args.target.component);
|
|
191
192
|
if (!existsSync(stackDir)) return;
|
|
192
|
-
const targetPath = join(
|
|
193
|
+
const targetPath = join(args.dest, args.target.path);
|
|
193
194
|
if (!existsSync(targetPath)) {
|
|
194
195
|
throw new Error(
|
|
195
|
-
`Target instance path ${
|
|
196
|
+
`Target instance path ${args.target.path} not found in ${args.dest}.`
|
|
196
197
|
);
|
|
197
198
|
}
|
|
198
|
-
const orm = typeof
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
236
|
+
await applyPatches(ormPatchesDir, targetPath, args.featureName);
|
|
236
237
|
}
|
|
237
238
|
}
|
|
238
|
-
const envKeys =
|
|
239
|
+
const envKeys = args.manifest.env?.[args.target.component] ?? [];
|
|
239
240
|
if (envKeys.length > 0) {
|
|
240
|
-
await appendEnvExample(targetPath,
|
|
241
|
+
await appendEnvExample(targetPath, args.featureName, envKeys);
|
|
241
242
|
}
|
|
242
|
-
await recordFeatureInMarker(targetPath,
|
|
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: "Go + HTMX \u2014 auth-gated table browser over any Postgres"
|
|
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-
|
|
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 goMod = await readFileOrNull(join5(dir, "go.mod"));
|
|
1488
|
+
if (goMod && /^module\s+adminpanel\b/m.test(goMod)) {
|
|
1489
|
+
results.push({
|
|
1490
|
+
component: "admin-panel",
|
|
1491
|
+
directory: relPath,
|
|
1492
|
+
confidence: "high",
|
|
1493
|
+
evidence: 'Go module "adminpanel" found'
|
|
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
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
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
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-projx",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.5",
|
|
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'] %>/** | Go + HTMX admin panel — auth-gated table browser 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 DATABASE_URL, SESSION_SECRET, ADMIN_EMAIL, ADMIN_PASSWORD
|
|
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
|
|
package/src/templates/ci.yml.ejs
CHANGED
|
@@ -39,6 +39,9 @@ jobs:
|
|
|
39
39
|
<% } %>
|
|
40
40
|
<% for (const inst of infraInstances) { %>
|
|
41
41
|
<%= inst.path %>: ${{ steps.filter.outputs.<%= inst.path %> }}
|
|
42
|
+
<% } %>
|
|
43
|
+
<% for (const inst of adminPanelInstances) { %>
|
|
44
|
+
<%= inst.path %>: ${{ steps.filter.outputs.<%= inst.path %> }}
|
|
42
45
|
<% } %>
|
|
43
46
|
steps:
|
|
44
47
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
@@ -77,6 +80,10 @@ jobs:
|
|
|
77
80
|
<%= inst.path %>:
|
|
78
81
|
- '<%= inst.path %>/**'
|
|
79
82
|
<% } %>
|
|
83
|
+
<% for (const inst of adminPanelInstances) { %>
|
|
84
|
+
<%= inst.path %>:
|
|
85
|
+
- '<%= inst.path %>/**'
|
|
86
|
+
<% } %>
|
|
80
87
|
|
|
81
88
|
secrets:
|
|
82
89
|
name: Secret scan
|
|
@@ -385,3 +392,26 @@ jobs:
|
|
|
385
392
|
- run: terraform init -backend=false
|
|
386
393
|
- run: terraform validate
|
|
387
394
|
<% } %>
|
|
395
|
+
<% for (const inst of adminPanelInstances) { %>
|
|
396
|
+
|
|
397
|
+
<%= inst.path %>:
|
|
398
|
+
name: <%= inst.display %>
|
|
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
|
+
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5
|
|
410
|
+
with:
|
|
411
|
+
go-version: '1.26'
|
|
412
|
+
cache-dependency-path: ${{ env.WS }}/<%= inst.path %>/go.sum
|
|
413
|
+
- run: test -z "$(gofmt -l .)"
|
|
414
|
+
- run: go vet ./...
|
|
415
|
+
- run: go build ./...
|
|
416
|
+
- run: docker build -t <%= inst.path %>:ci .
|
|
417
|
+
<% } %>
|
|
@@ -157,6 +157,23 @@ 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
|
+
restart: unless-stopped
|
|
168
|
+
healthcheck:
|
|
169
|
+
test: ["CMD", "/admin", "healthcheck"]
|
|
170
|
+
interval: 30s
|
|
171
|
+
timeout: 5s
|
|
172
|
+
retries: 3
|
|
173
|
+
start_period: 10s
|
|
174
|
+
networks:
|
|
175
|
+
- app-network
|
|
176
|
+
<% } %>
|
|
160
177
|
<% if (frontendInstances.length > 0) { %>
|
|
161
178
|
certbot:
|
|
162
179
|
image: certbot/certbot:latest
|
|
@@ -157,3 +157,17 @@ if [ -n "$<%= inst.upper %>_TF" ]; then
|
|
|
157
157
|
fi
|
|
158
158
|
fi
|
|
159
159
|
<% } %>
|
|
160
|
+
<% for (const inst of adminPanelInstances) { %>
|
|
161
|
+
|
|
162
|
+
<%= inst.upper %>_GO=$(echo "$STAGED_FILES" | grep '^<%= inst.path %>/.*\.go$' || true)
|
|
163
|
+
if [ -n "$<%= inst.upper %>_GO" ]; then
|
|
164
|
+
if command -v go &> /dev/null; then
|
|
165
|
+
echo "Checking <%= inst.path %>..."
|
|
166
|
+
echo "$<%= inst.upper %>_GO" | sed 's|^<%= inst.path %>/||' | (cd <%= inst.path %> && xargs gofmt -w)
|
|
167
|
+
(cd <%= inst.path %> && go vet ./...)
|
|
168
|
+
echo "$<%= inst.upper %>_GO" | xargs git add
|
|
169
|
+
else
|
|
170
|
+
echo "Skipping <%= inst.path %> checks (go not installed)"
|
|
171
|
+
fi
|
|
172
|
+
fi
|
|
173
|
+
<% } %>
|
|
@@ -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."
|