create-projx 1.6.2 → 1.6.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
@@ -39,7 +39,7 @@ Ask an LLM to "scaffold a full-stack app" and you get 50 files of plausible-look
39
39
  ```bash
40
40
  npx create-projx my-app # interactive — pick exactly what you need
41
41
  cd my-app
42
- ./setup.sh # installs everything you picked
42
+ ./scripts/setup.sh # installs everything you picked
43
43
  ```
44
44
 
45
45
  Pick any combination of components — they're all optional:
@@ -116,7 +116,7 @@ npx create-projx my-app -y
116
116
 
117
117
  ## Package Manager Support
118
118
 
119
- Projx supports **npm**, **pnpm**, **yarn**, and **bun**. During `create`, you're prompted to pick one. The choice is stored in `.projx` and used everywhere — setup.sh, Docker, CI, pre-commit hooks, and README.
119
+ Projx supports **npm**, **pnpm**, **yarn**, and **bun**. During `create`, you're prompted to pick one. The choice is stored in `.projx` and used everywhere — `scripts/setup.sh`, Docker, CI, pre-commit hooks, and README.
120
120
 
121
121
  ```json
122
122
  { "packageManager": "pnpm" }
@@ -158,6 +158,16 @@ npx create-projx add frontend mobile
158
158
 
159
159
  Copies the new component directories, regenerates shared files (docker-compose, CI, pre-commit hooks) to include them, and installs dependencies.
160
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 `scripts/setup.sh`. `update` keeps every instance refreshed on every run.
170
+
161
171
  ### Update Scaffolding
162
172
 
163
173
  When templates improve, update your project:
@@ -181,7 +191,7 @@ Common user-owned files are **default-skipped** automatically — template updat
181
191
 
182
192
  | Scope | Default skips |
183
193
  |-------|---------------|
184
- | Root (`.projx`) | `docker-compose.yml`, `docker-compose.dev.yml`, `README.md`, `.githooks/pre-commit`, `.github/workflows/ci.yml`, `setup.sh` |
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` |
185
195
  | fastapi | `pyproject.toml` |
186
196
  | fastify / frontend / e2e | `package.json` |
187
197
  | mobile | `pubspec.yaml` |
@@ -213,6 +223,7 @@ To opt back in to updates for a skipped file, use `npx create-projx unpin <file>
213
223
  npx create-projx <name> [options]
214
224
  npx create-projx init
215
225
  npx create-projx add <components...>
226
+ npx create-projx add <type> --name <dir>
216
227
  npx create-projx update
217
228
  npx create-projx diff
218
229
  npx create-projx pin <patterns...>
@@ -223,6 +234,7 @@ npx create-projx gen entity <name> [--ai | --backend]
223
234
  npx create-projx sync [--url <url>]
224
235
 
225
236
  --components <list> Comma-separated: fastapi,fastify,frontend,mobile,e2e,infra
237
+ --name <dir> Custom directory for `add <type>` (multi-instance)
226
238
  --ai Target fastapi (AI/ML) for gen entity
227
239
  --backend Target fastify (API backend) for gen entity
228
240
  --no-git Skip git init
@@ -324,7 +336,7 @@ backend/.projx-component → { "components": ["fastapi"] }
324
336
  web/.projx-component → { "components": ["frontend"] }
325
337
  ```
326
338
 
327
- CI, setup.sh, pre-commit hooks, and docker-compose are all regenerated with your custom directory names.
339
+ CI, `scripts/setup.sh`, pre-commit hooks, and docker-compose are all regenerated with your custom directory names.
328
340
 
329
341
  ## What a Scaffolded Project Looks Like
330
342
 
@@ -341,7 +353,7 @@ my-app/
341
353
  ├── .github/workflows/ # CI per component (runs only on changes)
342
354
  ├── .githooks/pre-commit # Format + lint on commit
343
355
  ├── .vscode/ # Editor settings + recommended extensions
344
- ├── setup.sh # Install all deps
356
+ ├── scripts/ # setup.sh, setup-docker.sh, setup-ssl.sh
345
357
  └── .projx # Components list + version
346
358
  ```
347
359
 
@@ -366,7 +378,7 @@ Contributing to Projx itself:
366
378
  ```bash
367
379
  git clone https://github.com/ukanhaupa/projx.git
368
380
  cd projx
369
- ./setup.sh
381
+ ./scripts/setup.sh
370
382
  ```
371
383
 
372
384
  The CLI lives in `cli/`. Templates are the root-level component directories (`fastapi/`, `frontend/`, etc.).
@@ -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-D33FXCNT.js";
14
- import "./chunk-LTIJPVRZ.js";
11
+ } from "./chunk-XQ7FE4U3.js";
12
+ import "./chunk-6YRBHJ2V.js";
15
13
  export {
16
14
  BASELINE_REF,
17
15
  applyTemplate,
18
- buildDisplayNames,
19
- buildPathsUpper,
20
16
  collectAllFiles,
21
17
  detectPackageNameOverrides,
22
18
  getBaselineRef,
@@ -1,7 +1,15 @@
1
1
  // src/utils.ts
2
2
  import { execSync } from "child_process";
3
3
  import { existsSync, readFileSync } from "fs";
4
- import { cp, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
4
+ import {
5
+ chmod,
6
+ cp,
7
+ mkdir,
8
+ readdir,
9
+ readFile,
10
+ rm,
11
+ writeFile
12
+ } from "fs/promises";
5
13
  import { join, resolve } from "path";
6
14
  import { tmpdir } from "os";
7
15
  import { fileURLToPath } from "url";
@@ -19,13 +27,57 @@ var PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
19
27
  function pmCommands(pm) {
20
28
  switch (pm) {
21
29
  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", audit: "npm audit --omit=dev" };
30
+ return {
31
+ name: "npm",
32
+ install: "npm install",
33
+ ci: "npm ci",
34
+ run: "npm run",
35
+ exec: "npx",
36
+ dlx: "npx",
37
+ lockfile: "package-lock.json",
38
+ prismaExec: "npx prisma",
39
+ runDev: "npm run dev",
40
+ audit: "npm audit --omit=dev"
41
+ };
23
42
  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", audit: "pnpm audit --prod" };
43
+ return {
44
+ name: "pnpm",
45
+ install: "pnpm install",
46
+ ci: "pnpm install --frozen-lockfile",
47
+ run: "pnpm",
48
+ exec: "pnpm exec",
49
+ dlx: "pnpm dlx",
50
+ lockfile: "pnpm-lock.yaml",
51
+ prismaExec: "pnpm prisma",
52
+ runDev: "pnpm dev",
53
+ audit: "pnpm audit --prod"
54
+ };
25
55
  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", audit: "yarn npm audit --environment production" };
56
+ return {
57
+ name: "yarn",
58
+ install: "yarn",
59
+ ci: "yarn --frozen-lockfile",
60
+ run: "yarn",
61
+ exec: "yarn",
62
+ dlx: "yarn dlx",
63
+ lockfile: "yarn.lock",
64
+ prismaExec: "yarn prisma",
65
+ runDev: "yarn dev",
66
+ audit: "yarn npm audit --environment production"
67
+ };
27
68
  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", audit: "bun audit --prod" };
69
+ return {
70
+ name: "bun",
71
+ install: "bun install",
72
+ ci: "bun install --frozen-lockfile",
73
+ run: "bun run",
74
+ exec: "bunx",
75
+ dlx: "bunx",
76
+ lockfile: "bun.lockb",
77
+ prismaExec: "bunx prisma",
78
+ runDev: "bun run dev",
79
+ audit: "bun audit --prod"
80
+ };
29
81
  }
30
82
  }
31
83
  function detectPackageManager(cwd) {
@@ -78,17 +130,13 @@ async function downloadRepo(localPath) {
78
130
  const dest = join(tmpdir(), `projx-${Date.now()}`);
79
131
  await mkdir(dest, { recursive: true });
80
132
  if (hasCommand("git")) {
81
- execSync(
82
- `git clone --depth 1 ${REPO_URL}.git "${dest}/repo"`,
83
- { stdio: "pipe" }
84
- );
133
+ execSync(`git clone --depth 1 ${REPO_URL}.git "${dest}/repo"`, {
134
+ stdio: "pipe"
135
+ });
85
136
  return join(dest, "repo");
86
137
  }
87
138
  const tarUrl = `${REPO_URL}/archive/refs/heads/main.tar.gz`;
88
- execSync(
89
- `curl -sL "${tarUrl}" | tar xz -C "${dest}"`,
90
- { stdio: "pipe" }
91
- );
139
+ execSync(`curl -sL "${tarUrl}" | tar xz -C "${dest}"`, { stdio: "pipe" });
92
140
  const entries = await readdir(dest);
93
141
  const extracted = entries.find((e) => e.startsWith("projx-"));
94
142
  if (!extracted) throw new Error("Failed to extract repo archive.");
@@ -165,10 +213,19 @@ async function copyStaticFiles(repoDir, dest) {
165
213
  await cp(extensionsJson, join(dest, ".vscode/extensions.json"));
166
214
  manifest.push(".vscode/extensions.json");
167
215
  }
168
- const scripts = join(tpl, "scripts");
169
- if (existsSync(scripts)) {
170
- await cp(scripts, join(dest, "scripts"), { recursive: true });
171
- manifest.push("scripts/setup-ssl.sh");
216
+ const staticScripts = ["setup-docker.sh", "setup-ssl.sh"];
217
+ const scriptsSrc = join(tpl, "scripts");
218
+ if (existsSync(scriptsSrc)) {
219
+ await mkdir(join(dest, "scripts"), { recursive: true });
220
+ for (const file of staticScripts) {
221
+ const src = join(scriptsSrc, file);
222
+ const dst = join(dest, "scripts", file);
223
+ if (existsSync(src) && !existsSync(dst)) {
224
+ await cp(src, dst);
225
+ await chmod(dst, 493);
226
+ manifest.push(`scripts/${file}`);
227
+ }
228
+ }
172
229
  }
173
230
  return manifest;
174
231
  }
@@ -273,7 +330,9 @@ var DEFAULT_ROOT_SKIP_PATTERNS = [
273
330
  "README.md",
274
331
  ".githooks/pre-commit",
275
332
  ".github/workflows/ci.yml",
276
- "setup.sh"
333
+ "scripts/setup.sh",
334
+ "scripts/setup-docker.sh",
335
+ "scripts/setup-ssl.sh"
277
336
  ];
278
337
  var DEFAULT_COMPONENT_SKIP_PATTERNS = {
279
338
  fastapi: ["pyproject.toml"],
@@ -293,7 +352,9 @@ async function discoverComponentPaths(cwd, components) {
293
352
  async function discoverComponentsFromMarkers(cwd) {
294
353
  const components = [];
295
354
  const paths = {};
296
- if (!existsSync(cwd)) return { components, paths };
355
+ const instances = [];
356
+ if (!existsSync(cwd))
357
+ return { components, paths, instances };
297
358
  const entries = await readdir(cwd, { withFileTypes: true });
298
359
  for (const entry of entries) {
299
360
  if (!entry.isDirectory()) continue;
@@ -301,6 +362,7 @@ async function discoverComponentsFromMarkers(cwd) {
301
362
  if (entry.name.startsWith(".")) continue;
302
363
  const marker = await readComponentMarker(join(cwd, entry.name));
303
364
  if (!marker) continue;
365
+ instances.push({ type: marker.component, path: entry.name });
304
366
  if (!components.includes(marker.component)) {
305
367
  components.push(marker.component);
306
368
  paths[marker.component] = entry.name;
@@ -309,33 +371,82 @@ async function discoverComponentsFromMarkers(cwd) {
309
371
  for (const c of components) {
310
372
  if (!paths[c]) paths[c] = c;
311
373
  }
312
- return { components, paths };
374
+ return { components, paths, instances };
313
375
  }
314
376
  function render(template, vars) {
377
+ const lines = template.split("\n");
378
+ return renderLines(lines, vars).replace(/\n{3,}/g, "\n\n");
379
+ }
380
+ function evalExpr(expr, vars) {
315
381
  const components = vars.components;
316
382
  const projectName = vars.projectName;
317
- const lines = template.split("\n");
383
+ const pmName = vars.pm?.name ?? "npm";
384
+ const argNames = ["components", "projectName", "pm"];
385
+ const argValues = [components, projectName, pmName];
386
+ for (const [k, v] of Object.entries(vars)) {
387
+ if (k === "components" || k === "projectName" || k === "pm") continue;
388
+ if (!/^[a-zA-Z_$][\w$]*$/.test(k)) continue;
389
+ argNames.push(k);
390
+ argValues.push(v);
391
+ }
392
+ const fn = new Function(...argNames, `return ${expr}`);
393
+ return fn(...argValues);
394
+ }
395
+ function findBlockEnd(lines, startIdx) {
396
+ let depth = 1;
397
+ for (let i = startIdx + 1; i < lines.length; i++) {
398
+ const line = lines[i];
399
+ if (/^<%\s*(if|for)\s*\(.+?\)\s*\{?\s*%>$/.test(line)) depth++;
400
+ else if (/^<%\s*\}\s*else\s+if\s*\((.+?)\)\s*\{?\s*%>$/.test(line)) {
401
+ } else if (/^<%\s*\}\s*else\s*\{?\s*%>$/.test(line)) {
402
+ } else if (/^<%\s*\}?\s*%>$/.test(line)) {
403
+ depth--;
404
+ if (depth === 0) return i;
405
+ }
406
+ }
407
+ throw new Error("Unmatched template block");
408
+ }
409
+ function renderLines(lines, vars) {
318
410
  const output = [];
319
411
  const stack = [];
320
- for (const line of lines) {
412
+ for (let i = 0; i < lines.length; i++) {
413
+ const line = lines[i];
414
+ const forMatch = line.match(
415
+ /^<%\s*for\s*\(\s*(?:const|let)\s+(\w+)\s+of\s+(.+?)\s*\)\s*\{?\s*%>$/
416
+ );
417
+ if (forMatch) {
418
+ const varName = forMatch[1];
419
+ const iterExpr = forMatch[2];
420
+ const end = findBlockEnd(lines, i);
421
+ const bodyLines = lines.slice(i + 1, end);
422
+ if (stack.length === 0 || stack.every((v) => v.active)) {
423
+ const iterable = evalExpr(iterExpr, vars);
424
+ if (Array.isArray(iterable)) {
425
+ for (const item of iterable) {
426
+ const sub = renderLines(bodyLines, { ...vars, [varName]: item });
427
+ if (sub) output.push(sub);
428
+ }
429
+ }
430
+ }
431
+ i = end;
432
+ continue;
433
+ }
321
434
  const ifMatch = line.match(/^<%\s*if\s*\((.+?)\)\s*\{?\s*%>$/);
322
435
  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);
436
+ const result = Boolean(evalExpr(ifMatch[1], vars));
326
437
  stack.push({ active: result, matched: result });
327
438
  continue;
328
439
  }
329
- const elseIfMatch = line.match(/^<%\s*\}\s*else\s+if\s*\((.+?)\)\s*\{?\s*%>$/);
440
+ const elseIfMatch = line.match(
441
+ /^<%\s*\}\s*else\s+if\s*\((.+?)\)\s*\{?\s*%>$/
442
+ );
330
443
  if (elseIfMatch) {
331
444
  if (stack.length > 0) {
332
445
  const top = stack[stack.length - 1];
333
446
  if (top.matched) {
334
447
  top.active = false;
335
448
  } 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);
449
+ const result = Boolean(evalExpr(elseIfMatch[1], vars));
339
450
  top.active = result;
340
451
  if (result) top.matched = true;
341
452
  }
@@ -354,20 +465,22 @@ function render(template, vars) {
354
465
  continue;
355
466
  }
356
467
  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;
468
+ const replaced = line.replace(/<%=\s*(.+?)\s*%>/g, (_, expr) => {
469
+ const trimmed = expr.trim();
470
+ if (/^[\w.]+$/.test(trimmed)) {
471
+ const parts = trimmed.split(".");
472
+ let val2 = vars;
362
473
  for (const p of parts) {
363
- val = val?.[p];
474
+ val2 = val2?.[p];
364
475
  }
365
- return String(val ?? "");
476
+ return String(val2 ?? "");
366
477
  }
367
- );
478
+ const val = evalExpr(trimmed, vars);
479
+ return String(val ?? "");
480
+ });
368
481
  output.push(replaced);
369
482
  }
370
- return output.join("\n").replace(/\n{3,}/g, "\n\n");
483
+ return output.join("\n");
371
484
  }
372
485
  async function renderEjsInDir(dir, vars) {
373
486
  if (!existsSync(dir)) return;