create-projx 1.6.1 → 1.6.3

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
@@ -7,6 +7,8 @@
7
7
 
8
8
  **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
9
 
10
+ ![projx demo](.github/demo.gif)
11
+
10
12
  ```bash
11
13
  npx create-projx my-app
12
14
  ```
@@ -156,6 +158,16 @@ npx create-projx add frontend mobile
156
158
 
157
159
  Copies the new component directories, regenerates shared files (docker-compose, CI, pre-commit hooks) to include them, and installs dependencies.
158
160
 
161
+ #### Multiple instances of the same type
162
+
163
+ Need a second backend service alongside an existing one (e.g. an SMTP listener next to your CRUD API)? Use `--name <dir>`:
164
+
165
+ ```bash
166
+ npx create-projx add fastify --name email-ingestor
167
+ ```
168
+
169
+ Creates `email-ingestor/` with the fastify scaffold and a `.projx-component` marker. Each instance gets its own job in `.github/workflows/ci.yml`, its own section in `.githooks/pre-commit`, and its own install step in `setup.sh`. `update` keeps every instance refreshed on every run.
170
+
159
171
  ### Update Scaffolding
160
172
 
161
173
  When templates improve, update your project:
@@ -211,6 +223,7 @@ To opt back in to updates for a skipped file, use `npx create-projx unpin <file>
211
223
  npx create-projx <name> [options]
212
224
  npx create-projx init
213
225
  npx create-projx add <components...>
226
+ npx create-projx add <type> --name <dir>
214
227
  npx create-projx update
215
228
  npx create-projx diff
216
229
  npx create-projx pin <patterns...>
@@ -221,6 +234,7 @@ npx create-projx gen entity <name> [--ai | --backend]
221
234
  npx create-projx sync [--url <url>]
222
235
 
223
236
  --components <list> Comma-separated: fastapi,fastify,frontend,mobile,e2e,infra
237
+ --name <dir> Custom directory for `add <type>` (multi-instance)
224
238
  --ai Target fastapi (AI/ML) for gen entity
225
239
  --backend Target fastify (API backend) for gen entity
226
240
  --no-git Skip git init
@@ -289,7 +303,7 @@ Override with `--ai` (fastapi) or `--backend` (fastify).
289
303
  | `frontend` | `src/types/<name>.ts` — TypeScript interface + Create/Update variants |
290
304
  | `mobile` | `lib/entities/<name>/model.dart` — Dart class with fromJson/toJson/copyWith |
291
305
 
292
- **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 for Fastify, SQLite-in-memory for FastAPI today). New entities ship green from day one — no scrambling to bolt on tests at go-live.
306
+ **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.
293
307
 
294
308
  No migrations — run `alembic revision --autogenerate` or `prisma migrate dev` (via your package manager) when ready.
295
309
 
@@ -371,8 +385,8 @@ The CLI lives in `cli/`. Templates are the root-level component directories (`fa
371
385
 
372
386
  ```bash
373
387
  cd cli
374
- npm test # run tests
375
- npm run build # build CLI
388
+ pnpm test # run tests
389
+ pnpm build # build CLI
376
390
  ```
377
391
 
378
392
  ## Try it now
@@ -1,8 +1,6 @@
1
1
  import {
2
2
  BASELINE_REF,
3
3
  applyTemplate,
4
- buildDisplayNames,
5
- buildPathsUpper,
6
4
  collectAllFiles,
7
5
  detectPackageNameOverrides,
8
6
  getBaselineRef,
@@ -10,13 +8,11 @@ import {
10
8
  matchesSkip,
11
9
  saveBaselineRef,
12
10
  writeTemplateToDir
13
- } from "./chunk-TNI4XBVS.js";
14
- import "./chunk-FTHX7ILT.js";
11
+ } from "./chunk-OBYYB6PR.js";
12
+ import "./chunk-LYPPFXGK.js";
15
13
  export {
16
14
  BASELINE_REF,
17
15
  applyTemplate,
18
- buildDisplayNames,
19
- buildPathsUpper,
20
16
  collectAllFiles,
21
17
  detectPackageNameOverrides,
22
18
  getBaselineRef,
@@ -19,13 +19,57 @@ var PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
19
19
  function pmCommands(pm) {
20
20
  switch (pm) {
21
21
  case "npm":
22
- return { name: "npm", install: "npm install", ci: "npm ci", run: "npm run", exec: "npx", dlx: "npx", lockfile: "package-lock.json", prismaExec: "npx prisma", runDev: "npm run dev" };
22
+ return {
23
+ name: "npm",
24
+ install: "npm install",
25
+ ci: "npm ci",
26
+ run: "npm run",
27
+ exec: "npx",
28
+ dlx: "npx",
29
+ lockfile: "package-lock.json",
30
+ prismaExec: "npx prisma",
31
+ runDev: "npm run dev",
32
+ audit: "npm audit --omit=dev"
33
+ };
23
34
  case "pnpm":
24
- return { name: "pnpm", install: "pnpm install", ci: "pnpm install --frozen-lockfile", run: "pnpm", exec: "pnpm exec", dlx: "pnpm dlx", lockfile: "pnpm-lock.yaml", prismaExec: "pnpm prisma", runDev: "pnpm dev" };
35
+ return {
36
+ name: "pnpm",
37
+ install: "pnpm install",
38
+ ci: "pnpm install --frozen-lockfile",
39
+ run: "pnpm",
40
+ exec: "pnpm exec",
41
+ dlx: "pnpm dlx",
42
+ lockfile: "pnpm-lock.yaml",
43
+ prismaExec: "pnpm prisma",
44
+ runDev: "pnpm dev",
45
+ audit: "pnpm audit --prod"
46
+ };
25
47
  case "yarn":
26
- return { name: "yarn", install: "yarn", ci: "yarn --frozen-lockfile", run: "yarn", exec: "yarn", dlx: "yarn dlx", lockfile: "yarn.lock", prismaExec: "yarn prisma", runDev: "yarn dev" };
48
+ return {
49
+ name: "yarn",
50
+ install: "yarn",
51
+ ci: "yarn --frozen-lockfile",
52
+ run: "yarn",
53
+ exec: "yarn",
54
+ dlx: "yarn dlx",
55
+ lockfile: "yarn.lock",
56
+ prismaExec: "yarn prisma",
57
+ runDev: "yarn dev",
58
+ audit: "yarn npm audit --environment production"
59
+ };
27
60
  case "bun":
28
- return { name: "bun", install: "bun install", ci: "bun install --frozen-lockfile", run: "bun run", exec: "bunx", dlx: "bunx", lockfile: "bun.lockb", prismaExec: "bunx prisma", runDev: "bun run dev" };
61
+ return {
62
+ name: "bun",
63
+ install: "bun install",
64
+ ci: "bun install --frozen-lockfile",
65
+ run: "bun run",
66
+ exec: "bunx",
67
+ dlx: "bunx",
68
+ lockfile: "bun.lockb",
69
+ prismaExec: "bunx prisma",
70
+ runDev: "bun run dev",
71
+ audit: "bun audit --prod"
72
+ };
29
73
  }
30
74
  }
31
75
  function detectPackageManager(cwd) {
@@ -78,17 +122,13 @@ async function downloadRepo(localPath) {
78
122
  const dest = join(tmpdir(), `projx-${Date.now()}`);
79
123
  await mkdir(dest, { recursive: true });
80
124
  if (hasCommand("git")) {
81
- execSync(
82
- `git clone --depth 1 ${REPO_URL}.git "${dest}/repo"`,
83
- { stdio: "pipe" }
84
- );
125
+ execSync(`git clone --depth 1 ${REPO_URL}.git "${dest}/repo"`, {
126
+ stdio: "pipe"
127
+ });
85
128
  return join(dest, "repo");
86
129
  }
87
130
  const tarUrl = `${REPO_URL}/archive/refs/heads/main.tar.gz`;
88
- execSync(
89
- `curl -sL "${tarUrl}" | tar xz -C "${dest}"`,
90
- { stdio: "pipe" }
91
- );
131
+ execSync(`curl -sL "${tarUrl}" | tar xz -C "${dest}"`, { stdio: "pipe" });
92
132
  const entries = await readdir(dest);
93
133
  const extracted = entries.find((e) => e.startsWith("projx-"));
94
134
  if (!extracted) throw new Error("Failed to extract repo archive.");
@@ -293,7 +333,9 @@ async function discoverComponentPaths(cwd, components) {
293
333
  async function discoverComponentsFromMarkers(cwd) {
294
334
  const components = [];
295
335
  const paths = {};
296
- if (!existsSync(cwd)) return { components, paths };
336
+ const instances = [];
337
+ if (!existsSync(cwd))
338
+ return { components, paths, instances };
297
339
  const entries = await readdir(cwd, { withFileTypes: true });
298
340
  for (const entry of entries) {
299
341
  if (!entry.isDirectory()) continue;
@@ -301,6 +343,7 @@ async function discoverComponentsFromMarkers(cwd) {
301
343
  if (entry.name.startsWith(".")) continue;
302
344
  const marker = await readComponentMarker(join(cwd, entry.name));
303
345
  if (!marker) continue;
346
+ instances.push({ type: marker.component, path: entry.name });
304
347
  if (!components.includes(marker.component)) {
305
348
  components.push(marker.component);
306
349
  paths[marker.component] = entry.name;
@@ -309,33 +352,82 @@ async function discoverComponentsFromMarkers(cwd) {
309
352
  for (const c of components) {
310
353
  if (!paths[c]) paths[c] = c;
311
354
  }
312
- return { components, paths };
355
+ return { components, paths, instances };
313
356
  }
314
357
  function render(template, vars) {
358
+ const lines = template.split("\n");
359
+ return renderLines(lines, vars).replace(/\n{3,}/g, "\n\n");
360
+ }
361
+ function evalExpr(expr, vars) {
315
362
  const components = vars.components;
316
363
  const projectName = vars.projectName;
317
- const lines = template.split("\n");
364
+ const pmName = vars.pm?.name ?? "npm";
365
+ const argNames = ["components", "projectName", "pm"];
366
+ const argValues = [components, projectName, pmName];
367
+ for (const [k, v] of Object.entries(vars)) {
368
+ if (k === "components" || k === "projectName" || k === "pm") continue;
369
+ if (!/^[a-zA-Z_$][\w$]*$/.test(k)) continue;
370
+ argNames.push(k);
371
+ argValues.push(v);
372
+ }
373
+ const fn = new Function(...argNames, `return ${expr}`);
374
+ return fn(...argValues);
375
+ }
376
+ function findBlockEnd(lines, startIdx) {
377
+ let depth = 1;
378
+ for (let i = startIdx + 1; i < lines.length; i++) {
379
+ const line = lines[i];
380
+ if (/^<%\s*(if|for)\s*\(.+?\)\s*\{?\s*%>$/.test(line)) depth++;
381
+ else if (/^<%\s*\}\s*else\s+if\s*\((.+?)\)\s*\{?\s*%>$/.test(line)) {
382
+ } else if (/^<%\s*\}\s*else\s*\{?\s*%>$/.test(line)) {
383
+ } else if (/^<%\s*\}?\s*%>$/.test(line)) {
384
+ depth--;
385
+ if (depth === 0) return i;
386
+ }
387
+ }
388
+ throw new Error("Unmatched template block");
389
+ }
390
+ function renderLines(lines, vars) {
318
391
  const output = [];
319
392
  const stack = [];
320
- for (const line of lines) {
393
+ for (let i = 0; i < lines.length; i++) {
394
+ const line = lines[i];
395
+ const forMatch = line.match(
396
+ /^<%\s*for\s*\(\s*(?:const|let)\s+(\w+)\s+of\s+(.+?)\s*\)\s*\{?\s*%>$/
397
+ );
398
+ if (forMatch) {
399
+ const varName = forMatch[1];
400
+ const iterExpr = forMatch[2];
401
+ const end = findBlockEnd(lines, i);
402
+ const bodyLines = lines.slice(i + 1, end);
403
+ if (stack.length === 0 || stack.every((v) => v.active)) {
404
+ const iterable = evalExpr(iterExpr, vars);
405
+ if (Array.isArray(iterable)) {
406
+ for (const item of iterable) {
407
+ const sub = renderLines(bodyLines, { ...vars, [varName]: item });
408
+ if (sub) output.push(sub);
409
+ }
410
+ }
411
+ }
412
+ i = end;
413
+ continue;
414
+ }
321
415
  const ifMatch = line.match(/^<%\s*if\s*\((.+?)\)\s*\{?\s*%>$/);
322
416
  if (ifMatch) {
323
- const pmName = vars.pm?.name ?? "npm";
324
- const fn = new Function("components", "projectName", "pm", `return ${ifMatch[1]}`);
325
- const result = fn(components, projectName, pmName);
417
+ const result = Boolean(evalExpr(ifMatch[1], vars));
326
418
  stack.push({ active: result, matched: result });
327
419
  continue;
328
420
  }
329
- const elseIfMatch = line.match(/^<%\s*\}\s*else\s+if\s*\((.+?)\)\s*\{?\s*%>$/);
421
+ const elseIfMatch = line.match(
422
+ /^<%\s*\}\s*else\s+if\s*\((.+?)\)\s*\{?\s*%>$/
423
+ );
330
424
  if (elseIfMatch) {
331
425
  if (stack.length > 0) {
332
426
  const top = stack[stack.length - 1];
333
427
  if (top.matched) {
334
428
  top.active = false;
335
429
  } else {
336
- const pmN = vars.pm?.name ?? "npm";
337
- const fn = new Function("components", "projectName", "pm", `return ${elseIfMatch[1]}`);
338
- const result = fn(components, projectName, pmN);
430
+ const result = Boolean(evalExpr(elseIfMatch[1], vars));
339
431
  top.active = result;
340
432
  if (result) top.matched = true;
341
433
  }
@@ -354,20 +446,33 @@ function render(template, vars) {
354
446
  continue;
355
447
  }
356
448
  if (stack.length > 0 && stack.some((v) => !v.active)) continue;
357
- const replaced = line.replace(
358
- /<%=\s*([\w.]+)\s*%>/g,
359
- (_, expr) => {
360
- const parts = expr.split(".");
361
- let val = vars;
362
- for (const p of parts) {
363
- val = val?.[p];
364
- }
365
- return String(val ?? "");
449
+ const replaced = line.replace(/<%=\s*([\w.]+)\s*%>/g, (_, expr) => {
450
+ const parts = expr.split(".");
451
+ let val = vars;
452
+ for (const p of parts) {
453
+ val = val?.[p];
366
454
  }
367
- );
455
+ return String(val ?? "");
456
+ });
368
457
  output.push(replaced);
369
458
  }
370
- return output.join("\n").replace(/\n{3,}/g, "\n\n");
459
+ return output.join("\n");
460
+ }
461
+ async function renderEjsInDir(dir, vars) {
462
+ if (!existsSync(dir)) return;
463
+ const entries = await readdir(dir, { withFileTypes: true });
464
+ for (const entry of entries) {
465
+ const full = join(dir, entry.name);
466
+ if (entry.isDirectory()) {
467
+ await renderEjsInDir(full, vars);
468
+ } else if (entry.name.endsWith(".ejs")) {
469
+ const content = await readFile(full, "utf-8");
470
+ const rendered = render(content, vars);
471
+ const out = full.slice(0, -".ejs".length);
472
+ await writeFile(out, rendered);
473
+ await rm(full);
474
+ }
475
+ }
371
476
  }
372
477
  function detectProjectName(cwd, components, componentPaths) {
373
478
  for (const component of components) {
@@ -420,5 +525,6 @@ export {
420
525
  discoverComponentPaths,
421
526
  discoverComponentsFromMarkers,
422
527
  render,
528
+ renderEjsInDir,
423
529
  detectProjectName
424
530
  };