create-projx 1.5.4 → 1.5.6

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/dist/index.js CHANGED
@@ -1,380 +1,43 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ BASELINE_REF,
4
+ applyTemplate,
5
+ collectAllFiles,
6
+ detectPackageNameOverrides,
7
+ getBaselineRef,
8
+ getFileAtRef,
9
+ matchesSkip,
10
+ saveBaselineRef,
11
+ writeTemplateToDir
12
+ } from "./chunk-G74HYIE4.js";
13
+ import {
14
+ COMPONENTS,
15
+ COMPONENT_MARKER,
16
+ EXCLUDE,
17
+ PACKAGE_MANAGERS,
18
+ cleanupRepo,
19
+ detectPackageManager,
20
+ detectPackageManagerFromComponents,
21
+ detectProjectName,
22
+ discoverComponentPaths,
23
+ discoverComponentsFromMarkers,
24
+ downloadRepo,
25
+ exec,
26
+ hasCommand,
27
+ pmCommands,
28
+ readComponentMarker,
29
+ readFileOrNull,
30
+ readProjxConfig,
31
+ toKebab,
32
+ toSnake,
33
+ toTitle,
34
+ writeComponentMarker,
35
+ writeProjxConfig
36
+ } from "./chunk-FTHX7ILT.js";
2
37
 
3
38
  // src/index.ts
4
- import { existsSync as existsSync13 } from "fs";
5
- import { resolve as resolve2 } from "path";
6
-
7
- // src/utils.ts
8
- import { execSync } from "child_process";
9
- import { existsSync, readFileSync } from "fs";
10
- import { cp, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
11
- import { join, resolve } from "path";
12
- import { tmpdir } from "os";
13
- import { fileURLToPath } from "url";
14
- var REPO = "ukanhaupa/projx";
15
- var REPO_URL = `https://github.com/${REPO}`;
16
- var COMPONENTS = [
17
- "fastapi",
18
- "fastify",
19
- "frontend",
20
- "mobile",
21
- "e2e",
22
- "infra"
23
- ];
24
- var PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
25
- function pmCommands(pm) {
26
- switch (pm) {
27
- case "npm":
28
- 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" };
29
- case "pnpm":
30
- 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" };
31
- case "yarn":
32
- 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" };
33
- case "bun":
34
- 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" };
35
- }
36
- }
37
- function detectPackageManager(cwd) {
38
- if (existsSync(join(cwd, "bun.lockb"))) return "bun";
39
- if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
40
- if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
41
- if (existsSync(join(cwd, "package-lock.json"))) return "npm";
42
- return null;
43
- }
44
- function toKebab(s) {
45
- return s.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
46
- }
47
- function toSnake(s) {
48
- return toKebab(s).replace(/-/g, "_");
49
- }
50
- function toTitle(s) {
51
- return s.split(/[-_\s]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
52
- }
53
- function hasCommand(cmd) {
54
- try {
55
- execSync(`command -v ${cmd}`, { stdio: "ignore" });
56
- return true;
57
- } catch {
58
- return false;
59
- }
60
- }
61
- function exec(cmd, cwd) {
62
- execSync(cmd, { cwd, stdio: "pipe" });
63
- }
64
- function sharedTemplateDir() {
65
- const thisFile = fileURLToPath(import.meta.url);
66
- return join(thisFile, "../../src/templates");
67
- }
68
- async function downloadRepo(localPath) {
69
- if (localPath) {
70
- return localPath;
71
- }
72
- const dest = join(tmpdir(), `projx-${Date.now()}`);
73
- await mkdir(dest, { recursive: true });
74
- if (hasCommand("git")) {
75
- execSync(
76
- `git clone --depth 1 ${REPO_URL}.git "${dest}/repo"`,
77
- { stdio: "pipe" }
78
- );
79
- return join(dest, "repo");
80
- }
81
- const tarUrl = `${REPO_URL}/archive/refs/heads/main.tar.gz`;
82
- execSync(
83
- `curl -sL "${tarUrl}" | tar xz -C "${dest}"`,
84
- { stdio: "pipe" }
85
- );
86
- const entries = await readdir(dest);
87
- const extracted = entries.find((e) => e.startsWith("projx-"));
88
- if (!extracted) throw new Error("Failed to extract repo archive.");
89
- return join(dest, extracted);
90
- }
91
- async function cleanupRepo(repoDir, isLocal) {
92
- if (isLocal) return;
93
- const parent = resolve(repoDir, "..");
94
- if (parent.startsWith(tmpdir())) {
95
- await rm(parent, { recursive: true, force: true });
96
- }
97
- }
98
- var EXCLUDE = /* @__PURE__ */ new Set([
99
- "node_modules",
100
- "dist",
101
- "build",
102
- "coverage",
103
- "__pycache__",
104
- ".dart_tool",
105
- ".flutter-plugins",
106
- ".flutter-plugins-dependencies",
107
- ".venv",
108
- ".pytest_cache",
109
- ".ruff_cache",
110
- ".mypy_cache",
111
- "playwright-report",
112
- "test-results",
113
- ".terraform",
114
- "cli"
115
- ]);
116
- var EXCLUDE_FILES = /* @__PURE__ */ new Set([
117
- "uv.lock",
118
- "pnpm-lock.yaml",
119
- "package-lock.json",
120
- "pubspec.lock",
121
- ".env",
122
- ".env.dev",
123
- ".env.staging",
124
- ".env.prod",
125
- "dev.tfplan",
126
- ".coverage"
127
- ]);
128
- async function copyComponent(repoDir, component, dest) {
129
- const src = join(repoDir, component);
130
- const out = join(dest, component);
131
- const files = [];
132
- await cp(src, out, {
133
- recursive: true,
134
- filter: (source) => {
135
- const base = source.split("/").pop();
136
- if (EXCLUDE.has(base)) return false;
137
- if (EXCLUDE_FILES.has(base)) return false;
138
- if (base.endsWith(".pyc")) return false;
139
- return true;
140
- }
141
- });
142
- await collectFiles(out, out, files);
143
- return files;
144
- }
145
- async function copyStaticFiles(repoDir, dest) {
146
- const manifest = [];
147
- const tpl = repoDir;
148
- const statics = [".editorconfig"];
149
- for (const file of statics) {
150
- const src = join(tpl, file);
151
- if (existsSync(src)) {
152
- await cp(src, join(dest, file));
153
- manifest.push(file);
154
- }
155
- }
156
- const extensionsJson = join(tpl, ".vscode/extensions.json");
157
- if (existsSync(extensionsJson)) {
158
- await mkdir(join(dest, ".vscode"), { recursive: true });
159
- await cp(extensionsJson, join(dest, ".vscode/extensions.json"));
160
- manifest.push(".vscode/extensions.json");
161
- }
162
- const scripts = join(tpl, "scripts");
163
- if (existsSync(scripts)) {
164
- await cp(scripts, join(dest, "scripts"), { recursive: true });
165
- manifest.push("scripts/setup-ssl.sh");
166
- }
167
- return manifest;
168
- }
169
- async function collectFiles(dir, root, files) {
170
- const entries = await readdir(dir, { withFileTypes: true });
171
- for (const entry of entries) {
172
- const full = join(dir, entry.name);
173
- if (entry.isDirectory()) {
174
- await collectFiles(full, root, files);
175
- } else {
176
- files.push(full.slice(root.length + 1));
177
- }
178
- }
179
- }
180
- async function replaceInFile(filePath, find, replace) {
181
- if (!existsSync(filePath)) return;
182
- const content = await readFile(filePath, "utf-8");
183
- if (!content.includes(find)) return;
184
- await writeFile(filePath, content.replaceAll(find, replace));
185
- }
186
- async function replaceInDir(dir, find, replace, ext) {
187
- if (!existsSync(dir)) return;
188
- const entries = await readdir(dir, { withFileTypes: true });
189
- for (const entry of entries) {
190
- const full = join(dir, entry.name);
191
- if (entry.isDirectory()) {
192
- await replaceInDir(full, find, replace, ext);
193
- } else if (entry.name.endsWith(ext)) {
194
- await replaceInFile(full, find, replace);
195
- }
196
- }
197
- }
198
- var COMPONENT_MARKER = ".projx-component";
199
- async function readFileOrNull(path) {
200
- try {
201
- return await readFile(path, "utf-8");
202
- } catch {
203
- return null;
204
- }
205
- }
206
- async function readComponentMarker(dir) {
207
- const raw = await readFileOrNull(join(dir, COMPONENT_MARKER));
208
- if (!raw) return null;
209
- try {
210
- const data = JSON.parse(raw);
211
- return {
212
- components: data.components ?? (data.component ? [data.component] : []),
213
- origin: data.origin,
214
- skip: data.skip
215
- };
216
- } catch {
217
- return null;
218
- }
219
- }
220
- async function writeComponentMarker(dir, component, origin = "scaffold", skip) {
221
- const markerPath = join(dir, COMPONENT_MARKER);
222
- let components = [component];
223
- let existingOrigin = origin;
224
- let existingSkip = skip;
225
- const existing = await readFileOrNull(markerPath);
226
- if (existing) {
227
- try {
228
- const data = JSON.parse(existing);
229
- const prev = data.components ?? (data.component ? [data.component] : []);
230
- existingOrigin = origin ?? data.origin ?? "scaffold";
231
- existingSkip = skip ?? data.skip;
232
- if (!prev.includes(component)) {
233
- components = [...prev, component];
234
- } else {
235
- components = prev;
236
- }
237
- } catch {
238
- }
239
- }
240
- const marker = { components, origin: existingOrigin };
241
- if (existingSkip && existingSkip.length > 0) marker.skip = existingSkip;
242
- await writeFile(markerPath, JSON.stringify(marker, null, 2) + "\n");
243
- }
244
- async function discoverComponentPaths(cwd, components) {
245
- const paths = {};
246
- const scan = async (dir) => {
247
- const entries = await readdir(dir, { withFileTypes: true });
248
- for (const entry of entries) {
249
- if (!entry.isDirectory()) continue;
250
- if (EXCLUDE.has(entry.name)) continue;
251
- if (entry.name.startsWith(".")) continue;
252
- const full = join(dir, entry.name);
253
- const marker = join(full, COMPONENT_MARKER);
254
- if (existsSync(marker)) {
255
- try {
256
- const data = JSON.parse(await readFile(marker, "utf-8"));
257
- const markerComponents = data.components ?? (data.component ? [data.component] : []);
258
- for (const mc of markerComponents) {
259
- if (components.includes(mc)) {
260
- paths[mc] = entry.name;
261
- }
262
- }
263
- } catch {
264
- }
265
- }
266
- }
267
- };
268
- await scan(cwd);
269
- for (const c of components) {
270
- if (!paths[c]) paths[c] = c;
271
- }
272
- return paths;
273
- }
274
- async function discoverComponentsFromMarkers(cwd) {
275
- const components = [];
276
- const paths = {};
277
- const entries = await readdir(cwd, { withFileTypes: true });
278
- for (const entry of entries) {
279
- if (!entry.isDirectory()) continue;
280
- if (EXCLUDE.has(entry.name)) continue;
281
- if (entry.name.startsWith(".")) continue;
282
- const full = join(cwd, entry.name);
283
- const marker = join(full, COMPONENT_MARKER);
284
- if (existsSync(marker)) {
285
- try {
286
- const data = JSON.parse(await readFile(marker, "utf-8"));
287
- const markerComponents = data.components ?? (data.component ? [data.component] : []);
288
- for (const mc of markerComponents) {
289
- if (COMPONENTS.includes(mc) && !components.includes(mc)) {
290
- components.push(mc);
291
- paths[mc] = entry.name;
292
- }
293
- }
294
- } catch {
295
- }
296
- }
297
- }
298
- for (const c of components) {
299
- if (!paths[c]) paths[c] = c;
300
- }
301
- return { components, paths };
302
- }
303
- function render(template, vars) {
304
- const components = vars.components;
305
- const projectName = vars.projectName;
306
- const lines = template.split("\n");
307
- const output = [];
308
- const stack = [];
309
- for (const line of lines) {
310
- const ifMatch = line.match(/^<%\s*if\s*\((.+?)\)\s*\{?\s*%>$/);
311
- if (ifMatch) {
312
- const pmName = vars.pm?.name ?? "npm";
313
- const fn = new Function("components", "projectName", "pm", `return ${ifMatch[1]}`);
314
- const result = fn(components, projectName, pmName);
315
- stack.push({ active: result, matched: result });
316
- continue;
317
- }
318
- const elseIfMatch = line.match(/^<%\s*\}\s*else\s+if\s*\((.+?)\)\s*\{?\s*%>$/);
319
- if (elseIfMatch) {
320
- if (stack.length > 0) {
321
- const top = stack[stack.length - 1];
322
- if (top.matched) {
323
- top.active = false;
324
- } else {
325
- const pmN = vars.pm?.name ?? "npm";
326
- const fn = new Function("components", "projectName", "pm", `return ${elseIfMatch[1]}`);
327
- const result = fn(components, projectName, pmN);
328
- top.active = result;
329
- if (result) top.matched = true;
330
- }
331
- }
332
- continue;
333
- }
334
- if (/^<%\s*\}\s*else\s*\{?\s*%>$/.test(line)) {
335
- if (stack.length > 0) {
336
- const top = stack[stack.length - 1];
337
- top.active = !top.matched;
338
- }
339
- continue;
340
- }
341
- if (/^<%\s*\}?\s*%>$/.test(line)) {
342
- stack.pop();
343
- continue;
344
- }
345
- if (stack.length > 0 && stack.some((v) => !v.active)) continue;
346
- const replaced = line.replace(
347
- /<%=\s*([\w.]+)\s*%>/g,
348
- (_, expr) => {
349
- const parts = expr.split(".");
350
- let val = vars;
351
- for (const p11 of parts) {
352
- val = val?.[p11];
353
- }
354
- return String(val ?? "");
355
- }
356
- );
357
- output.push(replaced);
358
- }
359
- return output.join("\n").replace(/\n{3,}/g, "\n\n");
360
- }
361
- function detectProjectName(cwd, components, componentPaths) {
362
- for (const component of components) {
363
- const dir = componentPaths[component] ?? component;
364
- const pkgPath = join(cwd, dir, "package.json");
365
- if (existsSync(pkgPath)) {
366
- try {
367
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
368
- const n = pkg.name;
369
- if (n && n.includes("-")) {
370
- return n.substring(0, n.lastIndexOf("-"));
371
- }
372
- } catch {
373
- }
374
- }
375
- }
376
- return toKebab(cwd.split("/").pop());
377
- }
39
+ import { existsSync as existsSync11 } from "fs";
40
+ import { resolve } from "path";
378
41
 
379
42
  // src/prompts.ts
380
43
  import * as p from "@clack/prompts";
@@ -428,430 +91,10 @@ async function runPrompts(nameArg) {
428
91
  }
429
92
 
430
93
  // src/scaffold.ts
431
- import { copyFileSync, existsSync as existsSync3 } from "fs";
432
- import { mkdir as mkdir3, readFile as readFile4 } from "fs/promises";
433
- import { join as join4 } from "path";
94
+ import { copyFileSync, existsSync } from "fs";
95
+ import { mkdir, readFile } from "fs/promises";
96
+ import { join } from "path";
434
97
  import * as p2 from "@clack/prompts";
435
-
436
- // src/baseline.ts
437
- import { existsSync as existsSync2, writeFileSync, unlinkSync } from "fs";
438
- import { chmod, mkdir as mkdir2, writeFile as writeFile2, rm as rm2, readFile as readFile3 } from "fs/promises";
439
- import { execSync as execSync2 } from "child_process";
440
- import { join as join3 } from "path";
441
- import { tmpdir as tmpdir2 } from "os";
442
-
443
- // src/generators/index.ts
444
- import { readFile as readFile2 } from "fs/promises";
445
- import { join as join2 } from "path";
446
- async function renderShared(filename, vars) {
447
- const tpl = await readFile2(
448
- join2(sharedTemplateDir(), filename),
449
- "utf-8"
450
- );
451
- return render(tpl, vars);
452
- }
453
- async function generateDockerCompose(vars) {
454
- return renderShared("docker-compose.yml.ejs", vars);
455
- }
456
- async function generateDockerComposeDev(vars) {
457
- return renderShared("docker-compose.dev.yml.ejs", vars);
458
- }
459
- async function generatePreCommit(vars) {
460
- return renderShared("pre-commit.ejs", vars);
461
- }
462
- async function generateSetupSh(vars) {
463
- return renderShared("setup.sh.ejs", vars);
464
- }
465
- async function generateCiYml(vars) {
466
- return renderShared("ci.yml.ejs", vars);
467
- }
468
- async function generateReadme(vars) {
469
- return renderShared("README.md.ejs", vars);
470
- }
471
- function generateVscodeSettings(vars) {
472
- const settings = {};
473
- if (vars.components.includes("fastapi")) {
474
- settings["[python]"] = {
475
- "editor.defaultFormatter": "charliermarsh.ruff",
476
- "editor.codeActionsOnSave": { "source.fixAll.ruff": "explicit" }
477
- };
478
- }
479
- settings["[typescript]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
480
- settings["[typescriptreact]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
481
- settings["[javascript]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
482
- settings["[json]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
483
- settings["[css]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
484
- settings["[yaml]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
485
- settings["editor.formatOnSave"] = true;
486
- settings["editor.codeActionsOnSave"] = { "source.fixAll.eslint": "explicit" };
487
- settings["eslint.useFlatConfig"] = true;
488
- const prettierComponent = ["frontend", "fastify", "e2e"].find(
489
- (c) => vars.components.includes(c)
490
- );
491
- if (prettierComponent) {
492
- settings["prettier.configPath"] = `${vars.paths[prettierComponent]}/.prettierrc`;
493
- }
494
- if (vars.components.includes("fastapi")) {
495
- settings["ruff.lineLength"] = 120;
496
- settings["python.analysis.extraPaths"] = [`${vars.paths.fastapi}/src`];
497
- settings["python.analysis.importFormat"] = "absolute";
498
- }
499
- return JSON.stringify(settings, null, 2) + "\n";
500
- }
501
-
502
- // src/baseline.ts
503
- var BASELINE_REF = "refs/projx/baseline";
504
- function matchesSkip(filePath, patterns) {
505
- for (const pattern of patterns) {
506
- if (pattern === "**") return true;
507
- if (pattern.endsWith("/**")) {
508
- const prefix = pattern.slice(0, -3);
509
- if (filePath.startsWith(prefix + "/") || filePath === prefix) return true;
510
- }
511
- if (pattern.startsWith("**/")) {
512
- const suffix = pattern.slice(3);
513
- if (suffix.startsWith("*.")) {
514
- const ext = suffix.slice(1);
515
- if (filePath.endsWith(ext)) return true;
516
- } else if (filePath.endsWith(suffix) || filePath.includes("/" + suffix)) {
517
- return true;
518
- }
519
- }
520
- if (pattern.startsWith("*.")) {
521
- const ext = pattern.slice(1);
522
- if (filePath.endsWith(ext)) return true;
523
- }
524
- if (filePath === pattern) return true;
525
- }
526
- return false;
527
- }
528
- function saveBaselineRef(cwd) {
529
- try {
530
- const head = execSync2("git rev-parse HEAD", { cwd, stdio: "pipe" }).toString().trim();
531
- execSync2(`git update-ref ${BASELINE_REF} ${head}`, { cwd, stdio: "pipe" });
532
- } catch {
533
- }
534
- }
535
- function getBaselineRef(cwd) {
536
- try {
537
- return execSync2(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" }).toString().trim();
538
- } catch {
539
- }
540
- try {
541
- const sha = execSync2("git log -1 --format=%H -- .projx", { cwd, stdio: "pipe" }).toString().trim();
542
- if (sha) return sha;
543
- } catch {
544
- }
545
- return null;
546
- }
547
- function getFileAtRef(cwd, ref, filePath) {
548
- try {
549
- return execSync2(`git show ${ref}:"${filePath}"`, { cwd, stdio: "pipe" }).toString();
550
- } catch {
551
- return null;
552
- }
553
- }
554
- function mergeFileThreeWay(oursPath, baseContent, theirsContent) {
555
- const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
556
- const baseTmp = join3(tmpdir2(), `projx-base-${id}`);
557
- const theirsTmp = join3(tmpdir2(), `projx-theirs-${id}`);
558
- try {
559
- writeFileSync(baseTmp, baseContent);
560
- writeFileSync(theirsTmp, theirsContent);
561
- execSync2(`git merge-file "${oursPath}" "${baseTmp}" "${theirsTmp}"`, { stdio: "pipe" });
562
- return true;
563
- } catch {
564
- return false;
565
- } finally {
566
- try {
567
- unlinkSync(baseTmp);
568
- } catch {
569
- }
570
- try {
571
- unlinkSync(theirsTmp);
572
- } catch {
573
- }
574
- }
575
- }
576
- async function collectAllFiles(dir, base) {
577
- const { readdir: readdir4 } = await import("fs/promises");
578
- const results = [];
579
- const walk = async (current) => {
580
- const entries = await readdir4(current, { withFileTypes: true });
581
- for (const entry of entries) {
582
- const full = join3(current, entry.name);
583
- if (entry.isDirectory()) {
584
- await walk(full);
585
- } else {
586
- results.push(full.slice(base.length + 1));
587
- }
588
- }
589
- };
590
- await walk(dir);
591
- return results;
592
- }
593
- async function tryThreeWayMerge(cwd, templateDir, baselineRef) {
594
- const templateFiles = await collectAllFiles(templateDir, templateDir);
595
- const merged = [];
596
- const conflicted = [];
597
- for (const file of templateFiles) {
598
- const oursPath = join3(cwd, file);
599
- if (!existsSync2(oursPath)) continue;
600
- const baseContent = getFileAtRef(cwd, baselineRef, file);
601
- if (baseContent === null) continue;
602
- let theirsContent;
603
- try {
604
- theirsContent = await readFile3(join3(templateDir, file), "utf-8");
605
- } catch {
606
- continue;
607
- }
608
- const oursContent = await readFile3(oursPath, "utf-8");
609
- if (oursContent === baseContent) continue;
610
- if (theirsContent === baseContent) continue;
611
- const clean = mergeFileThreeWay(oursPath, baseContent, theirsContent);
612
- if (clean) {
613
- merged.push(file);
614
- } else {
615
- conflicted.push(file);
616
- }
617
- }
618
- return { merged, conflicted };
619
- }
620
- function createOrphanWorktree(cwd) {
621
- const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
622
- const branch = `projx/tmp-${id}`;
623
- const worktree = join3(tmpdir2(), `projx-wt-${id}`);
624
- try {
625
- execSync2("git worktree prune", { cwd, stdio: "pipe" });
626
- } catch {
627
- }
628
- execSync2(`git worktree add --orphan -b ${branch} "${worktree}"`, {
629
- cwd,
630
- stdio: "pipe"
631
- });
632
- return { worktree, branch };
633
- }
634
- function cleanupWorktree(cwd, worktree, branch) {
635
- try {
636
- execSync2(`git worktree remove "${worktree}" --force`, { cwd, stdio: "pipe" });
637
- } catch {
638
- try {
639
- rm2(worktree, { recursive: true, force: true });
640
- execSync2("git worktree prune", { cwd, stdio: "pipe" });
641
- } catch {
642
- }
643
- }
644
- try {
645
- execSync2(`git branch -D ${branch}`, { cwd, stdio: "pipe" });
646
- } catch {
647
- }
648
- }
649
- async function removeSkippedFiles(dir, skipPatterns) {
650
- if (skipPatterns.length === 0) return;
651
- const { readdir: readdir4, unlink: unlink2 } = await import("fs/promises");
652
- const walk = async (current, base) => {
653
- const entries = await readdir4(current, { withFileTypes: true });
654
- for (const entry of entries) {
655
- const full = join3(current, entry.name);
656
- const rel = full.slice(base.length + 1);
657
- if (entry.isDirectory()) {
658
- await walk(full, base);
659
- } else if (entry.name !== ".projx-component" && matchesSkip(rel, skipPatterns)) {
660
- await unlink2(full);
661
- }
662
- }
663
- };
664
- await walk(dir, dir);
665
- }
666
- async function writeTemplateToDir(dest, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip) {
667
- const name = vars.projectName;
668
- const nameSnake = toSnake(name);
669
- for (const component of components) {
670
- const targetDir = componentPaths[component];
671
- const skipPatterns = componentSkips?.[component] ?? [];
672
- const tmpDir = join3(dest, "__cptmp__");
673
- await copyComponent(repoDir, component, tmpDir);
674
- const srcDir = join3(tmpDir, component);
675
- if (skipPatterns.length > 0) {
676
- await removeSkippedFiles(srcDir, skipPatterns);
677
- }
678
- const outDir = join3(dest, targetDir);
679
- await mkdir2(outDir, { recursive: true });
680
- const { cp: cp2 } = await import("fs/promises");
681
- if (existsSync2(srcDir)) {
682
- await cp2(srcDir, outDir, { recursive: true, force: true });
683
- }
684
- await rm2(tmpDir, { recursive: true, force: true });
685
- await writeComponentMarker(join3(dest, targetDir), component, origin, skipPatterns.length > 0 ? skipPatterns : void 0);
686
- }
687
- await substituteNames(dest, components, componentPaths, name, nameSnake);
688
- const hasBackend = components.includes("fastapi") || components.includes("fastify");
689
- const skip = rootSkip ?? [];
690
- const shouldWrite = (file) => !matchesSkip(file, skip);
691
- if (hasBackend || components.includes("frontend")) {
692
- if (shouldWrite("docker-compose.yml"))
693
- await writeFile2(join3(dest, "docker-compose.yml"), await generateDockerCompose(vars));
694
- if (shouldWrite("docker-compose.dev.yml"))
695
- await writeFile2(join3(dest, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
696
- }
697
- if (shouldWrite("README.md"))
698
- await writeFile2(join3(dest, "README.md"), await generateReadme(vars));
699
- if (shouldWrite(".githooks/pre-commit")) {
700
- await mkdir2(join3(dest, ".githooks"), { recursive: true });
701
- await writeFile2(join3(dest, ".githooks/pre-commit"), await generatePreCommit(vars));
702
- await chmod(join3(dest, ".githooks/pre-commit"), 493);
703
- }
704
- if (shouldWrite(".github/workflows/ci.yml")) {
705
- await mkdir2(join3(dest, ".github/workflows"), { recursive: true });
706
- await writeFile2(join3(dest, ".github/workflows/ci.yml"), await generateCiYml(vars));
707
- }
708
- if (shouldWrite("setup.sh")) {
709
- await writeFile2(join3(dest, "setup.sh"), await generateSetupSh(vars));
710
- await chmod(join3(dest, "setup.sh"), 493);
711
- }
712
- await copyStaticFiles(repoDir, dest);
713
- if (shouldWrite(".vscode/settings.json")) {
714
- await mkdir2(join3(dest, ".vscode"), { recursive: true });
715
- await writeFile2(join3(dest, ".vscode/settings.json"), generateVscodeSettings(vars));
716
- }
717
- const projxConfig = {
718
- version,
719
- components,
720
- createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
721
- };
722
- const pmObj = vars.pm;
723
- if (pmObj?.name) projxConfig.packageManager = pmObj.name;
724
- await writeFile2(join3(dest, ".projx"), JSON.stringify(projxConfig, null, 2) + "\n");
725
- }
726
- async function substituteNames(dest, components, paths, name, nameSnake) {
727
- if (components.includes("fastapi")) {
728
- await replaceInFile(join3(dest, `${paths.fastapi}/pyproject.toml`), "projx-fastapi", `${name}-fastapi`);
729
- }
730
- if (components.includes("fastify")) {
731
- await replaceInFile(join3(dest, `${paths.fastify}/package.json`), "projx-fastify", `${name}-fastify`);
732
- }
733
- if (components.includes("frontend")) {
734
- await replaceInFile(join3(dest, `${paths.frontend}/package.json`), "projx-frontend", `${name}-frontend`);
735
- }
736
- if (components.includes("e2e")) {
737
- await replaceInFile(join3(dest, `${paths.e2e}/package.json`), "projx-e2e", `${name}-e2e`);
738
- }
739
- if (components.includes("mobile")) {
740
- await replaceInFile(join3(dest, `${paths.mobile}/pubspec.yaml`), "projx_mobile", `${nameSnake}_mobile`);
741
- await replaceInDir(join3(dest, `${paths.mobile}`), "package:projx_mobile/", `package:${nameSnake}_mobile/`, ".dart");
742
- }
743
- }
744
- async function applyTemplate(cwd, repoDir, components, componentPaths, vars, version, origin = "scaffold", componentSkips, rootSkip) {
745
- const hasHead = (() => {
746
- try {
747
- execSync2("git rev-parse HEAD", { cwd, stdio: "pipe" });
748
- return true;
749
- } catch {
750
- return false;
751
- }
752
- })();
753
- if (!hasHead) {
754
- await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
755
- return { status: "clean" };
756
- }
757
- const { worktree, branch } = createOrphanWorktree(cwd);
758
- try {
759
- await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
760
- execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
761
- const diff2 = execSync2("git diff --cached --stat", { cwd: worktree, stdio: "pipe" }).toString().trim();
762
- if (!diff2) {
763
- cleanupWorktree(cwd, worktree, branch);
764
- return { status: "clean" };
765
- }
766
- execSync2(
767
- `git commit --no-verify -m "projx: template v${version} [${components.join(", ")}]"`,
768
- { cwd: worktree, stdio: "pipe" }
769
- );
770
- try {
771
- execSync2(`git worktree remove "${worktree}" --force`, { cwd, stdio: "pipe" });
772
- } catch {
773
- try {
774
- await rm2(worktree, { recursive: true, force: true });
775
- execSync2("git worktree prune", { cwd, stdio: "pipe" });
776
- } catch {
777
- }
778
- }
779
- let mergeClean = false;
780
- try {
781
- execSync2(
782
- `git merge ${branch} --allow-unrelated-histories -m "projx: update to template v${version}"`,
783
- { cwd, stdio: "pipe" }
784
- );
785
- mergeClean = true;
786
- } catch {
787
- try {
788
- execSync2("git merge --abort", { cwd, stdio: "pipe" });
789
- } catch {
790
- }
791
- }
792
- try {
793
- execSync2(`git branch -D ${branch}`, { cwd, stdio: "pipe" });
794
- } catch {
795
- }
796
- if (mergeClean) {
797
- saveBaselineRef(cwd);
798
- return { status: "clean" };
799
- }
800
- const baselineRef = getBaselineRef(cwd);
801
- if (baselineRef) {
802
- const tmpTemplate = join3(tmpdir2(), `projx-tpl-${Date.now()}`);
803
- await mkdir2(tmpTemplate, { recursive: true });
804
- await writeTemplateToDir(tmpTemplate, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
805
- const result = await tryThreeWayMerge(cwd, tmpTemplate, baselineRef);
806
- await rm2(tmpTemplate, { recursive: true, force: true });
807
- const projxConfig = {
808
- version,
809
- components,
810
- createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
811
- };
812
- const pmObj = vars.pm;
813
- if (pmObj?.name) projxConfig.packageManager = pmObj.name;
814
- await writeFile2(join3(cwd, ".projx"), JSON.stringify(projxConfig, null, 2) + "\n");
815
- if (result.conflicted.length === 0) {
816
- execSync2("git add -A", { cwd, stdio: "pipe" });
817
- const staged = execSync2("git diff --cached --stat", { cwd, stdio: "pipe" }).toString().trim();
818
- if (staged) {
819
- execSync2(
820
- `git commit --no-verify -m "projx: update to template v${version} (3-way merge)"`,
821
- { cwd, stdio: "pipe" }
822
- );
823
- }
824
- saveBaselineRef(cwd);
825
- return result.merged.length > 0 ? { status: "merged", mergedFiles: result.merged } : { status: "clean" };
826
- }
827
- for (const f of result.conflicted) {
828
- try {
829
- execSync2(`git checkout -- "${f}"`, { cwd, stdio: "pipe" });
830
- } catch {
831
- }
832
- }
833
- for (const f of result.merged) {
834
- try {
835
- execSync2(`git add "${f}"`, { cwd, stdio: "pipe" });
836
- } catch {
837
- }
838
- }
839
- execSync2("git add .projx", { cwd, stdio: "pipe" });
840
- return {
841
- status: "conflicts",
842
- mergedFiles: result.merged,
843
- conflictedFiles: result.conflicted
844
- };
845
- }
846
- await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
847
- return { status: "conflicts" };
848
- } catch (err) {
849
- cleanupWorktree(cwd, worktree, branch);
850
- throw err;
851
- }
852
- }
853
-
854
- // src/scaffold.ts
855
98
  async function scaffold(opts, dest, localRepo) {
856
99
  const name = toKebab(opts.name);
857
100
  const pm = opts.packageManager ?? "npm";
@@ -860,7 +103,7 @@ async function scaffold(opts, dest, localRepo) {
860
103
  );
861
104
  const vars = { projectName: name, components: opts.components, paths, pm: pmCommands(pm) };
862
105
  const isLocal = !!localRepo;
863
- await mkdir3(dest, { recursive: true });
106
+ await mkdir(dest, { recursive: true });
864
107
  const dlSpinner = p2.spinner();
865
108
  dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
866
109
  const repoDir = await downloadRepo(localRepo).catch((err) => {
@@ -870,16 +113,15 @@ async function scaffold(opts, dest, localRepo) {
870
113
  });
871
114
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
872
115
  try {
873
- const pkg = JSON.parse(await readFile4(join4(repoDir, "cli/package.json"), "utf-8"));
116
+ const pkg = JSON.parse(await readFile(join(repoDir, "cli/package.json"), "utf-8"));
874
117
  const version = pkg.version;
875
118
  p2.log.info(`Scaffolding project in ${dest}`);
876
119
  if (opts.git) {
877
120
  exec("git init", dest);
878
- exec("git config core.hooksPath .githooks", dest);
879
121
  }
880
122
  const spinner7 = p2.spinner();
881
123
  spinner7.start("Scaffolding project");
882
- await applyTemplate(dest, repoDir, opts.components, paths, vars, version);
124
+ await applyTemplate(dest, repoDir, opts.components, paths, vars, version, void 0, void 0, true);
883
125
  spinner7.stop("Scaffold complete.");
884
126
  if (opts.install) {
885
127
  await installDeps(dest, opts.components, pm);
@@ -888,7 +130,8 @@ async function scaffold(opts, dest, localRepo) {
888
130
  if (opts.git) {
889
131
  try {
890
132
  exec("git add -A", dest);
891
- exec('git commit --no-verify -m "Initial scaffold from projx"', dest);
133
+ exec('git commit -m "Initial scaffold from projx"', dest);
134
+ exec("git config core.hooksPath .githooks", dest);
892
135
  saveBaselineRef(dest);
893
136
  } catch {
894
137
  }
@@ -913,7 +156,7 @@ async function installDeps(dest, components, pm) {
913
156
  case "fastapi":
914
157
  if (hasCommand("uv")) {
915
158
  spinner7.start("Installing FastAPI dependencies (uv sync)");
916
- exec("uv sync --all-extras", join4(dest, "fastapi"));
159
+ exec("uv sync --all-extras", join(dest, "fastapi"));
917
160
  spinner7.stop("FastAPI dependencies installed.");
918
161
  } else {
919
162
  p2.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
@@ -922,7 +165,7 @@ async function installDeps(dest, components, pm) {
922
165
  case "fastify":
923
166
  if (hasCommand(pmBin)) {
924
167
  spinner7.start(`Installing Fastify dependencies (${cmds.install})`);
925
- exec(cmds.install, join4(dest, "fastify"));
168
+ exec(cmds.install, join(dest, "fastify"));
926
169
  spinner7.stop("Fastify dependencies installed.");
927
170
  } else {
928
171
  p2.log.warn(`${pm} not found \u2014 run 'cd fastify && ${cmds.install}' manually.`);
@@ -931,7 +174,7 @@ async function installDeps(dest, components, pm) {
931
174
  case "frontend":
932
175
  if (hasCommand(pmBin)) {
933
176
  spinner7.start(`Installing Frontend dependencies (${cmds.install})`);
934
- exec(cmds.install, join4(dest, "frontend"));
177
+ exec(cmds.install, join(dest, "frontend"));
935
178
  spinner7.stop("Frontend dependencies installed.");
936
179
  } else {
937
180
  p2.log.warn(`${pm} not found \u2014 run 'cd frontend && ${cmds.install}' manually.`);
@@ -940,7 +183,7 @@ async function installDeps(dest, components, pm) {
940
183
  case "e2e":
941
184
  if (hasCommand(pmBin)) {
942
185
  spinner7.start(`Installing E2E dependencies (${cmds.install})`);
943
- exec(cmds.install, join4(dest, "e2e"));
186
+ exec(cmds.install, join(dest, "e2e"));
944
187
  spinner7.stop("E2E dependencies installed.");
945
188
  } else {
946
189
  p2.log.warn(`${pm} not found \u2014 run 'cd e2e && ${cmds.install}' manually.`);
@@ -949,7 +192,7 @@ async function installDeps(dest, components, pm) {
949
192
  case "mobile":
950
193
  if (hasCommand("flutter")) {
951
194
  spinner7.start("Installing Flutter dependencies");
952
- exec("flutter pub get", join4(dest, "mobile"));
195
+ exec("flutter pub get", join(dest, "mobile"));
953
196
  spinner7.stop("Flutter dependencies installed.");
954
197
  } else {
955
198
  p2.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
@@ -965,9 +208,9 @@ async function installDeps(dest, components, pm) {
965
208
  }
966
209
  function copyEnvExamples(dest, components) {
967
210
  for (const component of components) {
968
- const example = join4(dest, component, ".env.example");
969
- const env = join4(dest, component, ".env");
970
- if (existsSync3(example) && !existsSync3(env)) {
211
+ const example = join(dest, component, ".env.example");
212
+ const env = join(dest, component, ".env");
213
+ if (existsSync(example) && !existsSync(env)) {
971
214
  try {
972
215
  copyFileSync(example, env);
973
216
  } catch {
@@ -977,10 +220,10 @@ function copyEnvExamples(dest, components) {
977
220
  }
978
221
 
979
222
  // src/update.ts
980
- import { existsSync as existsSync4 } from "fs";
981
- import { readFile as readFile5, writeFile as writeFile3, unlink } from "fs/promises";
982
- import { execSync as execSync3 } from "child_process";
983
- import { join as join5 } from "path";
223
+ import { existsSync as existsSync2 } from "fs";
224
+ import { readFile as readFile2, unlink } from "fs/promises";
225
+ import { execSync } from "child_process";
226
+ import { join as join2 } from "path";
984
227
  import * as p3 from "@clack/prompts";
985
228
  async function update(cwd, localRepo) {
986
229
  p3.intro("projx update");
@@ -990,39 +233,47 @@ async function update(cwd, localRepo) {
990
233
  process.exit(1);
991
234
  }
992
235
  try {
993
- execSync3("git worktree prune", { cwd, stdio: "pipe" });
236
+ execSync("git worktree prune", { cwd, stdio: "pipe" });
994
237
  } catch {
995
238
  }
239
+ const raw = await readProjxConfig(cwd);
240
+ const { components, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
241
+ const pendingConflicts = findFilesWithConflictMarkers(cwd);
242
+ if (pendingConflicts.length > 0) {
243
+ p3.log.warn(`Found ${pendingConflicts.length} file(s) with unresolved conflict markers from a prior update:`);
244
+ for (const f of pendingConflicts) p3.log.info(` ${f}`);
245
+ p3.log.info("");
246
+ const resumeVersion = String(raw.version ?? "unknown");
247
+ const handled = await promptSkipLearning(cwd, componentPaths, resumeVersion, pendingConflicts);
248
+ if (!handled) {
249
+ p3.log.info("");
250
+ p3.log.info("Resolve manually with `git diff` then `git add` / `git checkout --`,");
251
+ p3.log.info("or re-run `npx create-projx update` to resume the prompt.");
252
+ }
253
+ return;
254
+ }
996
255
  if (hasUncommittedChanges(cwd)) {
997
256
  p3.log.error("You have uncommitted changes. Commit or stash them first.");
998
257
  process.exit(1);
999
258
  }
1000
- const configPath = join5(cwd, ".projx");
1001
- let config;
1002
- if (existsSync4(configPath)) {
1003
- const raw = JSON.parse(await readFile5(configPath, "utf-8"));
1004
- const { components: discovered } = await discoverComponentsFromMarkers(cwd);
1005
- config = { ...raw, components: discovered.length > 0 ? discovered : raw.components };
1006
- p3.log.info(`Found .projx (v${config.version}, components: ${config.components.join(", ")})`);
259
+ if (components.length === 0) {
260
+ p3.log.error("No projx components found. Run 'projx init' first.");
261
+ process.exit(1);
262
+ }
263
+ if (Object.keys(raw).length > 0) {
264
+ p3.log.info(`Found .projx (v${raw.version ?? "unknown"}, components: ${components.join(", ")})`);
1007
265
  } else {
1008
- p3.log.warn("No .projx file found. Detecting components from directories.");
1009
- const { components: discovered } = await discoverComponentsFromMarkers(cwd);
1010
- if (discovered.length === 0) {
1011
- p3.log.error("No projx components found. Run 'projx init' first.");
1012
- process.exit(1);
1013
- }
1014
- config = { version: "0.0.0", components: discovered, createdAt: "unknown" };
1015
- p3.log.info(`Detected: ${discovered.join(", ")}`);
266
+ p3.log.warn("No .projx file found. Detected components from markers.");
267
+ p3.log.info(`Detected: ${components.join(", ")}`);
1016
268
  }
1017
- const componentPaths = await discoverComponentPaths(cwd, config.components);
1018
- for (const c of config.components) {
269
+ for (const c of components) {
1019
270
  const dir = componentPaths[c];
1020
271
  p3.log.info(dir !== c ? `${c} \u2192 ${dir}/` : `${c}/`);
1021
272
  }
1022
273
  const componentSkips = {};
1023
- for (const component of config.components) {
274
+ for (const component of components) {
1024
275
  const dir = componentPaths[component];
1025
- const marker = await readComponentMarker(join5(cwd, dir));
276
+ const marker = await readComponentMarker(join2(cwd, dir));
1026
277
  if (marker?.skip && marker.skip.length > 0) {
1027
278
  componentSkips[component] = marker.skip;
1028
279
  }
@@ -1036,17 +287,36 @@ async function update(cwd, localRepo) {
1036
287
  });
1037
288
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1038
289
  try {
1039
- const pkg = JSON.parse(await readFile5(join5(repoDir, "cli/package.json"), "utf-8"));
290
+ const pkg = JSON.parse(await readFile2(join2(repoDir, "cli/package.json"), "utf-8"));
1040
291
  const version = pkg.version;
1041
- const name = detectProjectName(cwd, config.components, componentPaths);
1042
- const raw = existsSync4(configPath) ? JSON.parse(await readFile5(configPath, "utf-8")) : {};
1043
- const pm = raw.packageManager ?? "npm";
1044
- const vars = { projectName: name, components: config.components, paths: componentPaths, pm: pmCommands(pm) };
292
+ const name = detectProjectName(cwd, components, componentPaths);
293
+ const recordedPm = raw.packageManager;
294
+ const detectedPm = detectPackageManagerFromComponents(cwd, componentPaths);
295
+ const pm = detectedPm ?? recordedPm ?? "npm";
296
+ if (detectedPm && recordedPm && detectedPm !== recordedPm) {
297
+ p3.log.warn(`packageManager mismatch: .projx says "${recordedPm}" but lockfile is "${detectedPm}". Using "${detectedPm}".`);
298
+ await writeProjxConfig(cwd, { ...raw, packageManager: detectedPm });
299
+ } else if (detectedPm && !recordedPm) {
300
+ await writeProjxConfig(cwd, { ...raw, packageManager: detectedPm });
301
+ }
302
+ const nameOverrides = await detectPackageNameOverrides(cwd, components, componentPaths);
303
+ const vars = { projectName: name, components, paths: componentPaths, pm: pmCommands(pm), nameOverrides };
1045
304
  const spinner7 = p3.spinner();
1046
305
  spinner7.start("Applying template update");
1047
- const rootSkip = config.skip ?? [];
1048
- const result = await applyTemplate(cwd, repoDir, config.components, componentPaths, vars, version, "scaffold", componentSkips, rootSkip);
306
+ const rootSkip = Array.isArray(raw.skip) ? raw.skip : [];
307
+ const isLegacyMigration = !raw.defaultsApplied;
308
+ if (isLegacyMigration) {
309
+ p3.log.info("Legacy project detected \u2014 applying default skip patterns for user-owned files.");
310
+ }
311
+ const result = await applyTemplate(cwd, repoDir, components, componentPaths, vars, version, componentSkips, rootSkip, isLegacyMigration);
1049
312
  spinner7.stop("Template applied.");
313
+ const pinnedUpdates = await findPinnedFilesWithUpdates(cwd, repoDir, components, componentPaths, vars, version, componentSkips, rootSkip);
314
+ if (pinnedUpdates.length > 0) {
315
+ p3.log.info("");
316
+ p3.log.info(`${pinnedUpdates.length} pinned file(s) have template updates available:`);
317
+ for (const f of pinnedUpdates) p3.log.info(` ${f}`);
318
+ p3.log.info("Run `npx create-projx unpin <file> && npx create-projx update` to opt in.");
319
+ }
1050
320
  if (result.status === "merged") {
1051
321
  saveBaselineRef(cwd);
1052
322
  p3.log.success(`${result.mergedFiles?.length ?? 0} file(s) merged cleanly.`);
@@ -1062,7 +332,7 @@ async function update(cwd, localRepo) {
1062
332
  p3.log.info(` ${f}`);
1063
333
  }
1064
334
  }
1065
- const handled = await promptSkipLearning(cwd, componentPaths, version);
335
+ const handled = await promptSkipLearning(cwd, componentPaths, version, result.conflictedFiles ?? []);
1066
336
  if (!handled) {
1067
337
  p3.log.info("");
1068
338
  p3.log.info("Review: git diff");
@@ -1084,7 +354,7 @@ async function update(cwd, localRepo) {
1084
354
  }
1085
355
  function isGitRepo(cwd) {
1086
356
  try {
1087
- execSync3("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
357
+ execSync("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
1088
358
  return true;
1089
359
  } catch {
1090
360
  return false;
@@ -1092,33 +362,108 @@ function isGitRepo(cwd) {
1092
362
  }
1093
363
  function hasUncommittedChanges(cwd) {
1094
364
  try {
1095
- const status = execSync3("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
365
+ const status = execSync("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
1096
366
  return status.length > 0;
1097
367
  } catch {
1098
368
  return false;
1099
369
  }
1100
370
  }
1101
- async function promptSkipLearning(cwd, componentPaths, version) {
1102
- if (!process.stdin.isTTY) return false;
1103
- const statusOutput = execSync3("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
1104
- if (!statusOutput) return false;
1105
- const entries = statusOutput.split("\n").filter(Boolean).map((line) => ({
1106
- status: line.slice(0, 2).trim(),
1107
- file: line.slice(3).trim()
1108
- }));
1109
- const changedFiles = entries.map((e) => e.file).filter((f) => {
371
+ async function findPinnedFilesWithUpdates(cwd, repoDir, components, componentPaths, vars, version, componentSkips, rootSkip) {
372
+ const { mkdir: mkdir5, rm: rm2, readFile: readFile7 } = await import("fs/promises");
373
+ const { tmpdir: tmpdir2 } = await import("os");
374
+ const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-72Z7TC2E.js");
375
+ const config = await readProjxConfig(cwd);
376
+ const rootPinned = Array.isArray(config.skip) ? config.skip : [];
377
+ const componentPinned = [];
378
+ for (const component of components) {
379
+ const dir = componentPaths[component];
380
+ const marker = await readComponentMarker(join2(cwd, dir));
381
+ if (marker?.skip && marker.skip.length > 0) {
382
+ componentPinned.push({ component, dir, patterns: marker.skip });
383
+ }
384
+ }
385
+ if (rootPinned.length === 0 && componentPinned.length === 0) return [];
386
+ const tmpTemplate = join2(tmpdir2(), `projx-pinned-${Date.now()}`);
387
+ await mkdir5(tmpTemplate, { recursive: true });
388
+ void componentSkips;
389
+ void rootSkip;
390
+ try {
391
+ await writeTemplateToDir2(tmpTemplate, repoDir, components, componentPaths, vars, version, {
392
+ componentSkips: {},
393
+ rootSkip: [],
394
+ realCwd: tmpTemplate
395
+ });
396
+ const updates = [];
397
+ for (const file of rootPinned) {
398
+ const tmplPath = join2(tmpTemplate, file);
399
+ const userPath = join2(cwd, file);
400
+ if (!existsSync2(tmplPath) || !existsSync2(userPath)) continue;
401
+ const tmplContent = await readFile7(tmplPath, "utf-8");
402
+ const userContent = await readFile7(userPath, "utf-8");
403
+ if (tmplContent !== userContent) updates.push(file);
404
+ }
405
+ for (const { dir, patterns } of componentPinned) {
406
+ for (const pattern of patterns) {
407
+ if (pattern.includes("*")) continue;
408
+ const rel = `${dir}/${pattern}`;
409
+ const tmplPath = join2(tmpTemplate, rel);
410
+ const userPath = join2(cwd, rel);
411
+ if (!existsSync2(tmplPath) || !existsSync2(userPath)) continue;
412
+ const tmplContent = await readFile7(tmplPath, "utf-8");
413
+ const userContent = await readFile7(userPath, "utf-8");
414
+ if (tmplContent !== userContent) updates.push(rel);
415
+ }
416
+ }
417
+ return updates;
418
+ } finally {
419
+ await rm2(tmpTemplate, { recursive: true, force: true });
420
+ }
421
+ }
422
+ function findFilesWithConflictMarkers(cwd) {
423
+ try {
424
+ const out = execSync(
425
+ `git -c core.quotepath=off grep -lE '^<<<<<<< (your changes|HEAD)'`,
426
+ { cwd, stdio: "pipe" }
427
+ ).toString().trim();
428
+ if (!out) return [];
429
+ return out.split("\n").filter(Boolean);
430
+ } catch {
431
+ return [];
432
+ }
433
+ }
434
+ async function promptSkipLearning(cwd, componentPaths, version, conflictedFiles) {
435
+ const changedFiles = conflictedFiles.filter((f) => {
1110
436
  const base = f.split("/").pop();
1111
437
  if (base === ".projx" || base === COMPONENT_MARKER) return false;
1112
438
  return true;
1113
439
  });
1114
440
  if (changedFiles.length === 0) return false;
1115
- p3.log.warn(`${changedFiles.length} template file(s) differ from your code.`);
441
+ if (!process.stdin.isTTY) {
442
+ p3.log.info("Non-interactive: skipping prompt. Resolve conflicts manually with `git diff` then `git add`.");
443
+ p3.log.info("Re-run `npx create-projx update` later to interactively decide which files to keep.");
444
+ return false;
445
+ }
446
+ const statusOutput = execSync("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
447
+ const entries = statusOutput.split("\n").filter(Boolean).map((line) => ({
448
+ status: line.slice(0, 2).trim(),
449
+ file: line.slice(3).trim()
450
+ }));
451
+ p3.log.warn(`${changedFiles.length} file(s) have conflicts to resolve.`);
452
+ p3.log.info("Each file is currently in your working tree with conflict markers.");
453
+ p3.log.info("");
454
+ p3.log.info("CHECKED = keep your version, resolve markers manually, commit when ready");
455
+ p3.log.info("UNCHECKED = discard template's changes AND skip this file on future updates");
456
+ p3.log.info("");
1116
457
  const selected = await p3.multiselect({
1117
- message: "Select files to KEEP (unselected will be discarded and skipped on future updates)",
458
+ message: "Which files do you want to KEEP?",
1118
459
  options: changedFiles.map((f) => ({ value: f, label: f })),
1119
460
  required: false
1120
461
  });
1121
- if (p3.isCancel(selected)) return false;
462
+ if (p3.isCancel(selected)) {
463
+ p3.log.warn("Cancelled. Conflict markers remain in the working tree.");
464
+ p3.log.info("Re-run `npx create-projx update` later to resume the prompt.");
465
+ return false;
466
+ }
1122
467
  const kept = new Set(selected);
1123
468
  const discarded = changedFiles.filter((f) => !kept.has(f));
1124
469
  if (discarded.length > 0) {
@@ -1126,9 +471,9 @@ async function promptSkipLearning(cwd, componentPaths, version) {
1126
471
  const entry = entries.find((e) => e.file === file);
1127
472
  try {
1128
473
  if (entry?.status === "??") {
1129
- await unlink(join5(cwd, file));
474
+ await unlink(join2(cwd, file));
1130
475
  } else {
1131
- execSync3(`git checkout -- "${file}"`, { cwd, stdio: "pipe" });
476
+ execSync(`git checkout -- "${file}"`, { cwd, stdio: "pipe" });
1132
477
  }
1133
478
  } catch {
1134
479
  }
@@ -1139,7 +484,7 @@ async function promptSkipLearning(cwd, componentPaths, version) {
1139
484
  );
1140
485
  }
1141
486
  if (kept.size > 0) {
1142
- p3.log.info(`${kept.size} file(s) kept \u2014 commit when ready:`);
487
+ p3.log.info(`${kept.size} file(s) kept with conflict markers \u2014 resolve and commit:`);
1143
488
  p3.log.info(
1144
489
  ` git add . && git commit -m "projx: update to v${version}"`
1145
490
  );
@@ -1173,42 +518,33 @@ async function learnSkips(cwd, files, componentPaths) {
1173
518
  }
1174
519
  for (const [component, additions] of Object.entries(componentSkipAdds)) {
1175
520
  const dir = componentPaths[component];
1176
- const markerPath = join5(cwd, dir, COMPONENT_MARKER);
1177
- try {
1178
- const data = JSON.parse(await readFile5(markerPath, "utf-8"));
1179
- const existing = data.skip ?? [];
1180
- data.skip = [.../* @__PURE__ */ new Set([...existing, ...additions])];
1181
- await writeFile3(markerPath, JSON.stringify(data, null, 2) + "\n");
1182
- } catch {
1183
- }
521
+ const marker = await readComponentMarker(join2(cwd, dir));
522
+ if (!marker) continue;
523
+ const merged = [.../* @__PURE__ */ new Set([...marker.skip, ...additions])];
524
+ await writeComponentMarker(join2(cwd, dir), { ...marker, skip: merged });
1184
525
  }
1185
526
  if (rootSkipAdds.length > 0) {
1186
- const configPath = join5(cwd, ".projx");
1187
- try {
1188
- const data = JSON.parse(await readFile5(configPath, "utf-8"));
1189
- const existing = data.skip ?? [];
1190
- data.skip = [.../* @__PURE__ */ new Set([...existing, ...rootSkipAdds])];
1191
- await writeFile3(configPath, JSON.stringify(data, null, 2) + "\n");
1192
- } catch {
1193
- }
527
+ const config = await readProjxConfig(cwd);
528
+ const existing = Array.isArray(config.skip) ? config.skip : [];
529
+ const merged = [.../* @__PURE__ */ new Set([...existing, ...rootSkipAdds])];
530
+ await writeProjxConfig(cwd, { ...config, skip: merged });
1194
531
  }
1195
532
  }
1196
533
 
1197
534
  // src/add.ts
1198
- import { copyFileSync as copyFileSync2, existsSync as existsSync5 } from "fs";
1199
- import { readFile as readFile6 } from "fs/promises";
1200
- import { join as join6 } from "path";
535
+ import { copyFileSync as copyFileSync2, existsSync as existsSync3 } from "fs";
536
+ import { readFile as readFile3 } from "fs/promises";
537
+ import { join as join3 } from "path";
1201
538
  import * as p4 from "@clack/prompts";
1202
539
  async function add(cwd, newComponents, localRepo, skipInstall = false) {
1203
540
  p4.intro("projx add");
1204
541
  const isLocal = !!localRepo;
1205
- const configPath = join6(cwd, ".projx");
1206
- if (!existsSync5(configPath)) {
542
+ if (!existsSync3(join3(cwd, ".projx"))) {
1207
543
  p4.log.error("No .projx file found. Run 'npx create-projx <name>' to create a project first.");
1208
544
  process.exit(1);
1209
545
  }
1210
- const config = JSON.parse(await readFile6(configPath, "utf-8"));
1211
- const existing = config.components;
546
+ const config = await readProjxConfig(cwd);
547
+ const { components: existing } = await discoverComponentsFromMarkers(cwd);
1212
548
  const alreadyExists = newComponents.filter((c) => existing.includes(c));
1213
549
  if (alreadyExists.length > 0) {
1214
550
  p4.log.warn(`Already present: ${alreadyExists.join(", ")}. Skipping those.`);
@@ -1235,19 +571,19 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
1235
571
  const pm = config.packageManager ?? "npm";
1236
572
  const name = detectProjectName(cwd, existing, paths);
1237
573
  const vars = { projectName: name, components: allComponents, paths, pm: pmCommands(pm) };
1238
- const pkg = JSON.parse(await readFile6(join6(repoDir, "cli/package.json"), "utf-8"));
574
+ const pkg = JSON.parse(await readFile3(join3(repoDir, "cli/package.json"), "utf-8"));
1239
575
  const version = pkg.version;
1240
576
  const spinner7 = p4.spinner();
1241
577
  spinner7.start("Adding components");
1242
- await writeTemplateToDir(cwd, repoDir, allComponents, paths, vars, version, "scaffold");
578
+ await writeTemplateToDir(cwd, repoDir, allComponents, paths, vars, version, { realCwd: cwd });
1243
579
  spinner7.stop("Components added.");
1244
580
  if (!skipInstall) {
1245
581
  await installDeps2(cwd, toAdd, pm);
1246
582
  }
1247
583
  for (const component of toAdd) {
1248
- const example = join6(cwd, component, ".env.example");
1249
- const env = join6(cwd, component, ".env");
1250
- if (existsSync5(example) && !existsSync5(env)) {
584
+ const example = join3(cwd, component, ".env.example");
585
+ const env = join3(cwd, component, ".env");
586
+ if (existsSync3(example) && !existsSync3(env)) {
1251
587
  try {
1252
588
  copyFileSync2(example, env);
1253
589
  } catch {
@@ -1271,7 +607,7 @@ async function installDeps2(dest, components, pm) {
1271
607
  case "fastapi":
1272
608
  if (hasCommand("uv")) {
1273
609
  spinner7.start("Installing FastAPI dependencies");
1274
- exec("uv sync --all-extras", join6(dest, "fastapi"));
610
+ exec("uv sync --all-extras", join3(dest, "fastapi"));
1275
611
  spinner7.stop("FastAPI dependencies installed.");
1276
612
  } else {
1277
613
  p4.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
@@ -1280,7 +616,7 @@ async function installDeps2(dest, components, pm) {
1280
616
  case "fastify":
1281
617
  if (hasCommand(pmBin)) {
1282
618
  spinner7.start(`Installing Fastify dependencies (${cmds.install})`);
1283
- exec(cmds.install, join6(dest, "fastify"));
619
+ exec(cmds.install, join3(dest, "fastify"));
1284
620
  spinner7.stop("Fastify dependencies installed.");
1285
621
  } else {
1286
622
  p4.log.warn(`${pm} not found \u2014 run 'cd fastify && ${cmds.install}' manually.`);
@@ -1289,7 +625,7 @@ async function installDeps2(dest, components, pm) {
1289
625
  case "frontend":
1290
626
  if (hasCommand(pmBin)) {
1291
627
  spinner7.start(`Installing Frontend dependencies (${cmds.install})`);
1292
- exec(cmds.install, join6(dest, "frontend"));
628
+ exec(cmds.install, join3(dest, "frontend"));
1293
629
  spinner7.stop("Frontend dependencies installed.");
1294
630
  } else {
1295
631
  p4.log.warn(`${pm} not found \u2014 run 'cd frontend && ${cmds.install}' manually.`);
@@ -1298,7 +634,7 @@ async function installDeps2(dest, components, pm) {
1298
634
  case "e2e":
1299
635
  if (hasCommand(pmBin)) {
1300
636
  spinner7.start(`Installing E2E dependencies (${cmds.install})`);
1301
- exec(cmds.install, join6(dest, "e2e"));
637
+ exec(cmds.install, join3(dest, "e2e"));
1302
638
  spinner7.stop("E2E dependencies installed.");
1303
639
  } else {
1304
640
  p4.log.warn(`${pm} not found \u2014 run 'cd e2e && ${cmds.install}' manually.`);
@@ -1307,7 +643,7 @@ async function installDeps2(dest, components, pm) {
1307
643
  case "mobile":
1308
644
  if (hasCommand("flutter")) {
1309
645
  spinner7.start("Installing Flutter dependencies");
1310
- exec("flutter pub get", join6(dest, "mobile"));
646
+ exec("flutter pub get", join3(dest, "mobile"));
1311
647
  spinner7.stop("Flutter dependencies installed.");
1312
648
  } else {
1313
649
  p4.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
@@ -1323,22 +659,22 @@ async function installDeps2(dest, components, pm) {
1323
659
  }
1324
660
 
1325
661
  // src/init.ts
1326
- import { existsSync as existsSync7 } from "fs";
1327
- import { readFile as readFile7 } from "fs/promises";
1328
- import { execSync as execSync4 } from "child_process";
1329
- import { join as join8 } from "path";
662
+ import { existsSync as existsSync5 } from "fs";
663
+ import { readFile as readFile4 } from "fs/promises";
664
+ import { execSync as execSync2 } from "child_process";
665
+ import { join as join5 } from "path";
1330
666
  import * as p5 from "@clack/prompts";
1331
667
 
1332
668
  // src/detect.ts
1333
- import { existsSync as existsSync6 } from "fs";
1334
- import { readdir as readdir2 } from "fs/promises";
1335
- import { join as join7 } from "path";
669
+ import { existsSync as existsSync4 } from "fs";
670
+ import { readdir } from "fs/promises";
671
+ import { join as join4 } from "path";
1336
672
  async function detectComponents(cwd) {
1337
673
  const results = [];
1338
- const entries = await readdir2(cwd, { withFileTypes: true });
674
+ const entries = await readdir(cwd, { withFileTypes: true });
1339
675
  const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !EXCLUDE.has(e.name)).map((e) => e.name);
1340
676
  for (const dir of dirs) {
1341
- const full = join7(cwd, dir);
677
+ const full = join4(cwd, dir);
1342
678
  const detections = await scanDirectory(full, dir);
1343
679
  results.push(...detections);
1344
680
  }
@@ -1346,7 +682,7 @@ async function detectComponents(cwd) {
1346
682
  }
1347
683
  async function scanDirectory(dir, relPath) {
1348
684
  const results = [];
1349
- const pyproject = await readFileOrNull(join7(dir, "pyproject.toml"));
685
+ const pyproject = await readFileOrNull(join4(dir, "pyproject.toml"));
1350
686
  if (pyproject && /fastapi/i.test(pyproject)) {
1351
687
  results.push({
1352
688
  component: "fastapi",
@@ -1383,7 +719,7 @@ async function scanDirectory(dir, relPath) {
1383
719
  });
1384
720
  }
1385
721
  }
1386
- const pubspec = await readFileOrNull(join7(dir, "pubspec.yaml"));
722
+ const pubspec = await readFileOrNull(join4(dir, "pubspec.yaml"));
1387
723
  if (pubspec && /flutter:/i.test(pubspec)) {
1388
724
  results.push({
1389
725
  component: "mobile",
@@ -1392,7 +728,7 @@ async function scanDirectory(dir, relPath) {
1392
728
  evidence: "pubspec.yaml has flutter dependency"
1393
729
  });
1394
730
  }
1395
- const hasTf = existsSync6(join7(dir, "main.tf")) || existsSync6(join7(dir, "variables.tf")) || existsSync6(join7(dir, "stack/main.tf")) || existsSync6(join7(dir, "versions.tf"));
731
+ const hasTf = existsSync4(join4(dir, "main.tf")) || existsSync4(join4(dir, "variables.tf")) || existsSync4(join4(dir, "stack/main.tf")) || existsSync4(join4(dir, "versions.tf"));
1396
732
  if (hasTf) {
1397
733
  results.push({
1398
734
  component: "infra",
@@ -1404,7 +740,7 @@ async function scanDirectory(dir, relPath) {
1404
740
  return results;
1405
741
  }
1406
742
  async function readPkg(dir) {
1407
- const content = await readFileOrNull(join7(dir, "package.json"));
743
+ const content = await readFileOrNull(join4(dir, "package.json"));
1408
744
  if (!content) return null;
1409
745
  try {
1410
746
  return JSON.parse(content);
@@ -1417,7 +753,7 @@ async function readPkg(dir) {
1417
753
  async function init(cwd, localRepo) {
1418
754
  p5.intro("projx init");
1419
755
  const isLocal = !!localRepo;
1420
- if (existsSync7(join8(cwd, ".projx"))) {
756
+ if (existsSync5(join5(cwd, ".projx"))) {
1421
757
  p5.log.error("This project is already initialized. Use 'npx create-projx update' or 'npx create-projx add' instead.");
1422
758
  process.exit(1);
1423
759
  }
@@ -1477,15 +813,15 @@ async function init(cwd, localRepo) {
1477
813
  });
1478
814
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1479
815
  try {
1480
- const pkg = JSON.parse(await readFile7(join8(repoDir, "cli/package.json"), "utf-8"));
816
+ const pkg = JSON.parse(await readFile4(join5(repoDir, "cli/package.json"), "utf-8"));
1481
817
  const version = pkg.version;
1482
818
  const applySpinner = p5.spinner();
1483
819
  applySpinner.start("Applying template");
1484
- const result = await applyTemplate(cwd, repoDir, components, paths, vars, version, "init");
820
+ const result = await applyTemplate(cwd, repoDir, components, paths, vars, version, void 0, void 0, true);
1485
821
  applySpinner.stop("Template applied.");
1486
- if (existsSync7(join8(cwd, ".githooks"))) {
822
+ if (existsSync5(join5(cwd, ".githooks"))) {
1487
823
  try {
1488
- execSync4("git config core.hooksPath .githooks", { cwd, stdio: "pipe" });
824
+ execSync2("git config core.hooksPath .githooks", { cwd, stdio: "pipe" });
1489
825
  } catch {
1490
826
  }
1491
827
  }
@@ -1544,7 +880,7 @@ async function manualSelect(cwd) {
1544
880
  defaultValue: component
1545
881
  });
1546
882
  if (p5.isCancel(dir)) process.exit(0);
1547
- if (!existsSync7(join8(cwd, dir))) {
883
+ if (!existsSync5(join5(cwd, dir))) {
1548
884
  p5.log.warn(`${dir}/ does not exist \u2014 skipping.`);
1549
885
  continue;
1550
886
  }
@@ -1554,7 +890,7 @@ async function manualSelect(cwd) {
1554
890
  }
1555
891
  function isGitRepo2(cwd) {
1556
892
  try {
1557
- execSync4("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
893
+ execSync2("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
1558
894
  return true;
1559
895
  } catch {
1560
896
  return false;
@@ -1562,7 +898,7 @@ function isGitRepo2(cwd) {
1562
898
  }
1563
899
  function hasUncommittedChanges2(cwd) {
1564
900
  try {
1565
- const status = execSync4("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
901
+ const status = execSync2("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
1566
902
  return status.length > 0;
1567
903
  } catch {
1568
904
  return false;
@@ -1570,9 +906,8 @@ function hasUncommittedChanges2(cwd) {
1570
906
  }
1571
907
 
1572
908
  // src/pin.ts
1573
- import { existsSync as existsSync8 } from "fs";
1574
- import { readFile as readFile8, writeFile as writeFile4 } from "fs/promises";
1575
- import { join as join9 } from "path";
909
+ import { existsSync as existsSync6 } from "fs";
910
+ import { join as join6 } from "path";
1576
911
  import * as p6 from "@clack/prompts";
1577
912
  function classifyPattern(pattern, componentPaths) {
1578
913
  const dirToComponent = {};
@@ -1592,12 +927,11 @@ function classifyPattern(pattern, componentPaths) {
1592
927
  }
1593
928
  async function pin(cwd, patterns) {
1594
929
  p6.intro("projx pin");
1595
- const configPath = join9(cwd, ".projx");
1596
- if (!existsSync8(configPath)) {
930
+ if (!existsSync6(join6(cwd, ".projx"))) {
1597
931
  p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
1598
932
  process.exit(1);
1599
933
  }
1600
- const config = JSON.parse(await readFile8(configPath, "utf-8"));
934
+ const config = await readProjxConfig(cwd);
1601
935
  const componentPaths = (await discoverComponentsFromMarkers(cwd)).paths;
1602
936
  const rootAdds = [];
1603
937
  const componentAdds = {};
@@ -1616,30 +950,27 @@ async function pin(cwd, patterns) {
1616
950
  }
1617
951
  for (const [component, additions] of Object.entries(componentAdds)) {
1618
952
  const dir = componentPaths[component];
1619
- const markerPath = join9(cwd, dir, COMPONENT_MARKER);
1620
- try {
1621
- const data = JSON.parse(await readFile8(markerPath, "utf-8"));
1622
- const existing = data.skip ?? [];
1623
- const merged = [.../* @__PURE__ */ new Set([...existing, ...additions])];
1624
- const added = merged.length - existing.length;
1625
- if (added > 0) {
1626
- data.skip = merged;
1627
- await writeFile4(markerPath, JSON.stringify(data, null, 2) + "\n");
1628
- p6.log.success(`${component}: pinned ${additions.join(", ")}`);
1629
- } else {
1630
- p6.log.info(`${component}: already pinned.`);
1631
- }
1632
- } catch {
953
+ const marker = await readComponentMarker(join6(cwd, dir));
954
+ if (!marker) {
1633
955
  p6.log.error(`Could not read marker for ${component}.`);
956
+ continue;
957
+ }
958
+ const merged = [.../* @__PURE__ */ new Set([...marker.skip, ...additions])];
959
+ const added = merged.length - marker.skip.length;
960
+ if (added > 0) {
961
+ const next = { ...marker, skip: merged };
962
+ await writeComponentMarker(join6(cwd, dir), next);
963
+ p6.log.success(`${component}: pinned ${additions.join(", ")}`);
964
+ } else {
965
+ p6.log.info(`${component}: already pinned.`);
1634
966
  }
1635
967
  }
1636
968
  if (rootAdds.length > 0) {
1637
- const existing = config.skip ?? [];
969
+ const existing = Array.isArray(config.skip) ? config.skip : [];
1638
970
  const merged = [.../* @__PURE__ */ new Set([...existing, ...rootAdds])];
1639
971
  const added = merged.length - existing.length;
1640
972
  if (added > 0) {
1641
- config.skip = merged;
1642
- await writeFile4(configPath, JSON.stringify(config, null, 2) + "\n");
973
+ await writeProjxConfig(cwd, { ...config, skip: merged });
1643
974
  p6.log.success(`root: pinned ${rootAdds.join(", ")}`);
1644
975
  } else {
1645
976
  p6.log.info("root: already pinned.");
@@ -1649,12 +980,11 @@ async function pin(cwd, patterns) {
1649
980
  }
1650
981
  async function unpin(cwd, patterns) {
1651
982
  p6.intro("projx unpin");
1652
- const configPath = join9(cwd, ".projx");
1653
- if (!existsSync8(configPath)) {
983
+ if (!existsSync6(join6(cwd, ".projx"))) {
1654
984
  p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
1655
985
  process.exit(1);
1656
986
  }
1657
- const config = JSON.parse(await readFile8(configPath, "utf-8"));
987
+ const config = await readProjxConfig(cwd);
1658
988
  const componentPaths = (await discoverComponentsFromMarkers(cwd)).paths;
1659
989
  const rootRemoves = [];
1660
990
  const componentRemoves = {};
@@ -1669,38 +999,27 @@ async function unpin(cwd, patterns) {
1669
999
  }
1670
1000
  for (const [component, removals] of Object.entries(componentRemoves)) {
1671
1001
  const dir = componentPaths[component];
1672
- const markerPath = join9(cwd, dir, COMPONENT_MARKER);
1673
- try {
1674
- const data = JSON.parse(await readFile8(markerPath, "utf-8"));
1675
- const existing = data.skip ?? [];
1676
- const filtered = existing.filter((s) => !removals.includes(s));
1677
- const removed = existing.length - filtered.length;
1678
- if (removed > 0) {
1679
- if (filtered.length > 0) {
1680
- data.skip = filtered;
1681
- } else {
1682
- delete data.skip;
1683
- }
1684
- await writeFile4(markerPath, JSON.stringify(data, null, 2) + "\n");
1685
- p6.log.success(`${component}: unpinned ${removals.join(", ")}`);
1686
- } else {
1687
- p6.log.info(`${component}: not found in skip list.`);
1688
- }
1689
- } catch {
1002
+ const marker = await readComponentMarker(join6(cwd, dir));
1003
+ if (!marker) {
1690
1004
  p6.log.error(`Could not read marker for ${component}.`);
1005
+ continue;
1006
+ }
1007
+ const filtered = marker.skip.filter((s) => !removals.includes(s));
1008
+ const removed = marker.skip.length - filtered.length;
1009
+ if (removed > 0) {
1010
+ const next = { ...marker, skip: filtered };
1011
+ await writeComponentMarker(join6(cwd, dir), next);
1012
+ p6.log.success(`${component}: unpinned ${removals.join(", ")}`);
1013
+ } else {
1014
+ p6.log.info(`${component}: not found in skip list.`);
1691
1015
  }
1692
1016
  }
1693
1017
  if (rootRemoves.length > 0) {
1694
- const existing = config.skip ?? [];
1018
+ const existing = Array.isArray(config.skip) ? config.skip : [];
1695
1019
  const filtered = existing.filter((s) => !rootRemoves.includes(s));
1696
1020
  const removed = existing.length - filtered.length;
1697
1021
  if (removed > 0) {
1698
- if (filtered.length > 0) {
1699
- config.skip = filtered;
1700
- } else {
1701
- delete config.skip;
1702
- }
1703
- await writeFile4(configPath, JSON.stringify(config, null, 2) + "\n");
1022
+ await writeProjxConfig(cwd, { ...config, skip: filtered });
1704
1023
  p6.log.success(`root: unpinned ${rootRemoves.join(", ")}`);
1705
1024
  } else {
1706
1025
  p6.log.info("root: not found in skip list.");
@@ -1710,24 +1029,24 @@ async function unpin(cwd, patterns) {
1710
1029
  }
1711
1030
  async function listPins(cwd) {
1712
1031
  p6.intro("projx pin --list");
1713
- const configPath = join9(cwd, ".projx");
1714
- if (!existsSync8(configPath)) {
1032
+ if (!existsSync6(join6(cwd, ".projx"))) {
1715
1033
  p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
1716
1034
  process.exit(1);
1717
1035
  }
1718
- const config = JSON.parse(await readFile8(configPath, "utf-8"));
1036
+ const config = await readProjxConfig(cwd);
1719
1037
  const { components: discovered, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
1720
1038
  let hasAny = false;
1721
- if (config.skip && config.skip.length > 0) {
1039
+ const rootSkip = Array.isArray(config.skip) ? config.skip : [];
1040
+ if (rootSkip.length > 0) {
1722
1041
  hasAny = true;
1723
1042
  p6.log.info("root:");
1724
- for (const s of config.skip) {
1043
+ for (const s of rootSkip) {
1725
1044
  p6.log.info(` ${s}`);
1726
1045
  }
1727
1046
  }
1728
1047
  for (const component of discovered) {
1729
1048
  const dir = componentPaths[component];
1730
- const marker = await readComponentMarker(join9(cwd, dir));
1049
+ const marker = await readComponentMarker(join6(cwd, dir));
1731
1050
  if (marker?.skip && marker.skip.length > 0) {
1732
1051
  hasAny = true;
1733
1052
  const label = dir !== component ? `${component} (${dir}/)` : `${component}`;
@@ -1744,15 +1063,15 @@ async function listPins(cwd) {
1744
1063
  }
1745
1064
 
1746
1065
  // src/doctor.ts
1747
- import { existsSync as existsSync9 } from "fs";
1748
- import { readFile as readFile9, readdir as readdir3 } from "fs/promises";
1749
- import { execSync as execSync5 } from "child_process";
1750
- import { join as join10 } from "path";
1066
+ import { existsSync as existsSync7 } from "fs";
1067
+ import { readdir as readdir2 } from "fs/promises";
1068
+ import { execSync as execSync3 } from "child_process";
1069
+ import { join as join7 } from "path";
1751
1070
  import * as p7 from "@clack/prompts";
1752
1071
  async function checkConfig(cwd) {
1753
1072
  const results = [];
1754
- const configPath = join10(cwd, ".projx");
1755
- if (!existsSync9(configPath)) {
1073
+ const configPath = join7(cwd, ".projx");
1074
+ if (!existsSync7(configPath)) {
1756
1075
  results.push({
1757
1076
  name: ".projx exists",
1758
1077
  status: "fail",
@@ -1761,44 +1080,41 @@ async function checkConfig(cwd) {
1761
1080
  });
1762
1081
  return { results };
1763
1082
  }
1764
- let config;
1765
- try {
1766
- config = JSON.parse(await readFile9(configPath, "utf-8"));
1767
- } catch {
1083
+ const rootConfig = await readProjxConfig(cwd);
1084
+ if (Object.keys(rootConfig).length === 0) {
1768
1085
  results.push({
1769
1086
  name: ".projx valid JSON",
1770
1087
  status: "fail",
1771
- message: ".projx contains invalid JSON."
1088
+ message: ".projx contains invalid JSON or is empty."
1772
1089
  });
1773
1090
  return { results };
1774
1091
  }
1775
- results.push({ name: ".projx exists", status: "pass", message: `v${config.version}` });
1776
- if (!config.version || !config.components || !Array.isArray(config.components)) {
1092
+ results.push({ name: ".projx exists", status: "pass", message: `v${rootConfig.version ?? "unknown"}` });
1093
+ if (!rootConfig.version) {
1777
1094
  results.push({
1778
1095
  name: ".projx fields",
1779
- status: "fail",
1780
- message: "Missing required fields (version, components)."
1781
- });
1782
- return { results };
1783
- }
1784
- const invalid = config.components.filter((c) => !COMPONENTS.includes(c));
1785
- if (invalid.length > 0) {
1786
- results.push({
1787
- name: "component names",
1788
1096
  status: "warn",
1789
- message: `Unknown components: ${invalid.join(", ")}`
1097
+ message: "Missing version field."
1790
1098
  });
1791
- } else {
1792
- results.push({ name: "component names", status: "pass", message: `${config.components.length} valid` });
1793
1099
  }
1794
- return { results, config };
1100
+ return { results, rootConfig };
1795
1101
  }
1796
- async function checkComponents(cwd, config, componentPaths) {
1102
+ async function checkComponents(cwd, components, componentPaths) {
1797
1103
  const results = [];
1798
- for (const component of config.components) {
1104
+ if (components.length === 0) {
1105
+ results.push({
1106
+ name: "components",
1107
+ status: "fail",
1108
+ message: `No ${COMPONENT_MARKER} files found in any directory.`,
1109
+ fix: "Run 'npx create-projx init' to detect and mark components."
1110
+ });
1111
+ return results;
1112
+ }
1113
+ results.push({ name: "components", status: "pass", message: `${components.length} discovered from markers` });
1114
+ for (const component of components) {
1799
1115
  const dir = componentPaths[component];
1800
- const fullDir = join10(cwd, dir);
1801
- if (!existsSync9(fullDir)) {
1116
+ const fullDir = join7(cwd, dir);
1117
+ if (!existsSync7(fullDir)) {
1802
1118
  results.push({
1803
1119
  name: `${component} directory`,
1804
1120
  status: "fail",
@@ -1816,53 +1132,28 @@ async function checkComponents(cwd, config, componentPaths) {
1816
1132
  });
1817
1133
  continue;
1818
1134
  }
1819
- if (!marker.components.includes(component)) {
1820
- results.push({
1821
- name: `${component} marker`,
1822
- status: "warn",
1823
- message: `Marker in ${dir}/ does not list "${component}".`
1824
- });
1825
- } else {
1826
- const label = dir !== component ? `${dir}/ (${component})` : `${component}/`;
1827
- results.push({ name: `${component} marker`, status: "pass", message: label });
1828
- }
1829
- }
1830
- try {
1831
- const entries = await readdir3(cwd, { withFileTypes: true });
1832
- for (const entry of entries) {
1833
- if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
1834
- const markerPath = join10(cwd, entry.name, COMPONENT_MARKER);
1835
- if (!existsSync9(markerPath)) continue;
1836
- const isKnown = Object.values(componentPaths).includes(entry.name);
1837
- if (!isKnown) {
1838
- results.push({
1839
- name: `orphan marker`,
1840
- status: "warn",
1841
- message: `${entry.name}/ has a ${COMPONENT_MARKER} but is not in .projx components.`
1842
- });
1843
- }
1844
- }
1845
- } catch {
1135
+ const label = dir !== component ? `${dir}/ (${component})` : `${component}/`;
1136
+ results.push({ name: `${component} marker`, status: "pass", message: label });
1846
1137
  }
1847
1138
  return results;
1848
1139
  }
1849
1140
  function checkGit(cwd, fix) {
1850
1141
  const results = [];
1851
1142
  try {
1852
- execSync5("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
1143
+ execSync3("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
1853
1144
  results.push({ name: "git repo", status: "pass", message: "OK" });
1854
1145
  } catch {
1855
1146
  results.push({ name: "git repo", status: "fail", message: "Not a git repository." });
1856
1147
  return results;
1857
1148
  }
1858
1149
  try {
1859
- const ref = execSync5(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" }).toString().trim();
1150
+ const ref = execSync3(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" }).toString().trim();
1860
1151
  results.push({ name: "baseline ref", status: "pass", message: ref.slice(0, 8) });
1861
1152
  } catch {
1862
1153
  if (fix) {
1863
1154
  saveBaselineRef(cwd);
1864
1155
  try {
1865
- execSync5(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" });
1156
+ execSync3(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" });
1866
1157
  results.push({ name: "baseline ref", status: "pass", message: "Created from git history." });
1867
1158
  } catch {
1868
1159
  results.push({
@@ -1882,11 +1173,11 @@ function checkGit(cwd, fix) {
1882
1173
  }
1883
1174
  }
1884
1175
  try {
1885
- const worktrees = execSync5("git worktree list --porcelain", { cwd, stdio: "pipe" }).toString();
1176
+ const worktrees = execSync3("git worktree list --porcelain", { cwd, stdio: "pipe" }).toString();
1886
1177
  const stale = worktrees.split("\n").filter((l) => l.includes("projx-wt-") || l.includes("projx/tmp-"));
1887
1178
  if (stale.length > 0) {
1888
1179
  if (fix) {
1889
- execSync5("git worktree prune", { cwd, stdio: "pipe" });
1180
+ execSync3("git worktree prune", { cwd, stdio: "pipe" });
1890
1181
  results.push({ name: "worktrees", status: "pass", message: "Pruned stale worktrees." });
1891
1182
  } else {
1892
1183
  results.push({
@@ -1904,7 +1195,7 @@ function checkGit(cwd, fix) {
1904
1195
  results.push({ name: "worktrees", status: "pass", message: "OK" });
1905
1196
  }
1906
1197
  try {
1907
- const status = execSync5("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
1198
+ const status = execSync3("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
1908
1199
  if (status) {
1909
1200
  const count = status.split("\n").length;
1910
1201
  results.push({ name: "working tree", status: "warn", message: `${count} uncommitted change(s).` });
@@ -1915,26 +1206,25 @@ function checkGit(cwd, fix) {
1915
1206
  }
1916
1207
  return results;
1917
1208
  }
1918
- async function checkSkipPatterns(cwd, config, componentPaths) {
1209
+ async function checkSkipPatterns(cwd, rootConfig, components, componentPaths) {
1919
1210
  const results = [];
1920
- if (config.skip && config.skip.length > 0) {
1921
- for (const pattern of config.skip) {
1922
- const matches = await patternMatchesAnything(cwd, pattern);
1923
- if (!matches) {
1924
- results.push({
1925
- name: "root skip",
1926
- status: "warn",
1927
- message: `"${pattern}" matches no files \u2014 stale?`
1928
- });
1929
- }
1211
+ const rootSkip = Array.isArray(rootConfig.skip) ? rootConfig.skip : [];
1212
+ for (const pattern of rootSkip) {
1213
+ const matches = await patternMatchesAnything(cwd, pattern);
1214
+ if (!matches) {
1215
+ results.push({
1216
+ name: "root skip",
1217
+ status: "warn",
1218
+ message: `"${pattern}" matches no files \u2014 stale?`
1219
+ });
1930
1220
  }
1931
1221
  }
1932
- for (const component of config.components) {
1222
+ for (const component of components) {
1933
1223
  const dir = componentPaths[component];
1934
- const marker = await readComponentMarker(join10(cwd, dir));
1224
+ const marker = await readComponentMarker(join7(cwd, dir));
1935
1225
  if (marker?.skip && marker.skip.length > 0) {
1936
1226
  for (const pattern of marker.skip) {
1937
- const matches = await patternMatchesAnything(join10(cwd, dir), pattern);
1227
+ const matches = await patternMatchesAnything(join7(cwd, dir), pattern);
1938
1228
  if (!matches) {
1939
1229
  results.push({
1940
1230
  name: `${component} skip`,
@@ -1945,23 +1235,23 @@ async function checkSkipPatterns(cwd, config, componentPaths) {
1945
1235
  }
1946
1236
  }
1947
1237
  }
1948
- if (results.length === 0 && (config.skip?.length || config.components.some(() => true))) {
1238
+ if (results.length === 0 && (rootSkip.length > 0 || components.length > 0)) {
1949
1239
  results.push({ name: "skip patterns", status: "pass", message: "All patterns match files." });
1950
1240
  }
1951
1241
  return results;
1952
1242
  }
1953
1243
  async function patternMatchesAnything(dir, pattern) {
1954
1244
  if (pattern === "**") return true;
1955
- if (!existsSync9(dir)) return false;
1245
+ if (!existsSync7(dir)) return false;
1956
1246
  const walk = async (current, base) => {
1957
1247
  let entries;
1958
1248
  try {
1959
- entries = await readdir3(current, { withFileTypes: true });
1249
+ entries = await readdir2(current, { withFileTypes: true });
1960
1250
  } catch {
1961
1251
  return false;
1962
1252
  }
1963
1253
  for (const entry of entries) {
1964
- const full = join10(current, entry.name);
1254
+ const full = join7(current, entry.name);
1965
1255
  const rel = full.slice(base.length + 1);
1966
1256
  if (entry.isDirectory()) {
1967
1257
  if (await walk(full, base)) return true;
@@ -1976,17 +1266,16 @@ async function patternMatchesAnything(dir, pattern) {
1976
1266
  async function doctor(cwd, fix = false) {
1977
1267
  p7.intro("projx doctor");
1978
1268
  const allResults = [];
1979
- const { results: configResults, config } = await checkConfig(cwd);
1269
+ const { results: configResults, rootConfig } = await checkConfig(cwd);
1980
1270
  allResults.push(...configResults);
1981
- if (!config) {
1271
+ if (!rootConfig) {
1982
1272
  printReport(allResults);
1983
1273
  process.exit(1);
1984
1274
  }
1985
- const { components: discovered, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
1986
- const resolvedConfig = { ...config, components: discovered.length > 0 ? discovered : config.components };
1987
- allResults.push(...await checkComponents(cwd, resolvedConfig, componentPaths));
1275
+ const { components, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
1276
+ allResults.push(...await checkComponents(cwd, components, componentPaths));
1988
1277
  allResults.push(...checkGit(cwd, fix));
1989
- allResults.push(...await checkSkipPatterns(cwd, resolvedConfig, componentPaths));
1278
+ allResults.push(...await checkSkipPatterns(cwd, rootConfig, components, componentPaths));
1990
1279
  printReport(allResults);
1991
1280
  const passed = allResults.filter((r) => r.status === "pass").length;
1992
1281
  const warns = allResults.filter((r) => r.status === "warn").length;
@@ -2010,10 +1299,10 @@ function printReport(results) {
2010
1299
  }
2011
1300
 
2012
1301
  // src/diff.ts
2013
- import { existsSync as existsSync10 } from "fs";
2014
- import { readFile as readFile10, mkdir as mkdir4, rm as rm3 } from "fs/promises";
2015
- import { join as join11 } from "path";
2016
- import { tmpdir as tmpdir3 } from "os";
1302
+ import { existsSync as existsSync8 } from "fs";
1303
+ import { readFile as readFile5, mkdir as mkdir2, rm } from "fs/promises";
1304
+ import { join as join8 } from "path";
1305
+ import { tmpdir } from "os";
2017
1306
  import * as p8 from "@clack/prompts";
2018
1307
  function isSkipped(file, componentPaths, componentSkips, rootSkip) {
2019
1308
  for (const [component, dir] of Object.entries(componentPaths)) {
@@ -2036,23 +1325,21 @@ function fileComponent(file, componentPaths) {
2036
1325
  async function diff(cwd, localRepo) {
2037
1326
  p8.intro("projx diff");
2038
1327
  const isLocal = !!localRepo;
2039
- const configPath = join11(cwd, ".projx");
2040
- if (!existsSync10(configPath)) {
1328
+ if (!existsSync8(join8(cwd, ".projx"))) {
2041
1329
  p8.log.error("No .projx file found. Run 'npx create-projx init' first.");
2042
1330
  process.exit(1);
2043
1331
  }
2044
- const raw = JSON.parse(await readFile10(configPath, "utf-8"));
2045
- const { components: discovered, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
2046
- const config = { ...raw, components: discovered.length > 0 ? discovered : raw.components };
1332
+ const raw = await readProjxConfig(cwd);
1333
+ const { components, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
2047
1334
  const componentSkips = {};
2048
- for (const component of config.components) {
1335
+ for (const component of components) {
2049
1336
  const dir = componentPaths[component];
2050
- const marker = await readComponentMarker(join11(cwd, dir));
1337
+ const marker = await readComponentMarker(join8(cwd, dir));
2051
1338
  if (marker?.skip && marker.skip.length > 0) {
2052
1339
  componentSkips[component] = marker.skip;
2053
1340
  }
2054
1341
  }
2055
- const rootSkip = config.skip ?? [];
1342
+ const rootSkip = Array.isArray(raw.skip) ? raw.skip : [];
2056
1343
  const dlSpinner = p8.spinner();
2057
1344
  dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
2058
1345
  const repoDir = await downloadRepo(localRepo).catch((err) => {
@@ -2062,16 +1349,20 @@ async function diff(cwd, localRepo) {
2062
1349
  });
2063
1350
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
2064
1351
  try {
2065
- const pkg = JSON.parse(await readFile10(join11(repoDir, "cli/package.json"), "utf-8"));
1352
+ const pkg = JSON.parse(await readFile5(join8(repoDir, "cli/package.json"), "utf-8"));
2066
1353
  const version = pkg.version;
2067
- p8.log.info(`Current: v${config.version} \u2192 Template: v${version}`);
2068
- const name = detectProjectName(cwd, config.components, componentPaths);
2069
- const vars = { projectName: name, components: config.components, paths: componentPaths, pm: pmCommands(raw.packageManager ?? "npm") };
1354
+ p8.log.info(`Current: v${raw.version ?? "unknown"} \u2192 Template: v${version}`);
1355
+ const name = detectProjectName(cwd, components, componentPaths);
1356
+ const vars = { projectName: name, components, paths: componentPaths, pm: pmCommands(raw.packageManager ?? "npm") };
2070
1357
  const spinner7 = p8.spinner();
2071
1358
  spinner7.start("Analyzing changes");
2072
- const tmpTemplate = join11(tmpdir3(), `projx-diff-${Date.now()}`);
2073
- await mkdir4(tmpTemplate, { recursive: true });
2074
- await writeTemplateToDir(tmpTemplate, repoDir, config.components, componentPaths, vars, version, "scaffold", componentSkips, rootSkip);
1359
+ const tmpTemplate = join8(tmpdir(), `projx-diff-${Date.now()}`);
1360
+ await mkdir2(tmpTemplate, { recursive: true });
1361
+ await writeTemplateToDir(tmpTemplate, repoDir, components, componentPaths, vars, version, {
1362
+ componentSkips,
1363
+ rootSkip,
1364
+ realCwd: cwd
1365
+ });
2075
1366
  const baselineRef = getBaselineRef(cwd);
2076
1367
  const templateFiles = await collectAllFiles(tmpTemplate, tmpTemplate);
2077
1368
  const analyses = [];
@@ -2081,16 +1372,16 @@ async function diff(cwd, localRepo) {
2081
1372
  analyses.push({ file, status: "skipped", component });
2082
1373
  continue;
2083
1374
  }
2084
- const oursPath = join11(cwd, file);
2085
- if (!existsSync10(oursPath)) {
1375
+ const oursPath = join8(cwd, file);
1376
+ if (!existsSync8(oursPath)) {
2086
1377
  analyses.push({ file, status: "new", component });
2087
1378
  continue;
2088
1379
  }
2089
1380
  let oursContent;
2090
1381
  let theirsContent;
2091
1382
  try {
2092
- oursContent = await readFile10(oursPath, "utf-8");
2093
- theirsContent = await readFile10(join11(tmpTemplate, file), "utf-8");
1383
+ oursContent = await readFile5(oursPath, "utf-8");
1384
+ theirsContent = await readFile5(join8(tmpTemplate, file), "utf-8");
2094
1385
  } catch {
2095
1386
  continue;
2096
1387
  }
@@ -2115,7 +1406,7 @@ async function diff(cwd, localRepo) {
2115
1406
  analyses.push({ file, status: "needs-merge", component });
2116
1407
  }
2117
1408
  }
2118
- await rm3(tmpTemplate, { recursive: true, force: true });
1409
+ await rm(tmpTemplate, { recursive: true, force: true });
2119
1410
  spinner7.stop("Analysis complete.");
2120
1411
  const groups = {
2121
1412
  "new": [],
@@ -2164,9 +1455,9 @@ async function diff(cwd, localRepo) {
2164
1455
  }
2165
1456
 
2166
1457
  // src/gen.ts
2167
- import { existsSync as existsSync11 } from "fs";
2168
- import { readFile as readFile11, writeFile as writeFile5, mkdir as mkdir5 } from "fs/promises";
2169
- import { join as join12 } from "path";
1458
+ import { existsSync as existsSync9 } from "fs";
1459
+ import { readFile as readFile6, writeFile, mkdir as mkdir3 } from "fs/promises";
1460
+ import { join as join9 } from "path";
2170
1461
  import * as p9 from "@clack/prompts";
2171
1462
  var FIELD_TYPES = ["string", "number", "boolean", "date", "datetime", "text", "json"];
2172
1463
  function toPascal(s) {
@@ -2823,15 +2114,9 @@ async function resolvePrimaryBackend(cwd, hasFastapi, hasFastify, backendFlag) {
2823
2114
  if (backendFlag) return backendFlag;
2824
2115
  if (hasFastapi && !hasFastify) return "fastapi";
2825
2116
  if (hasFastify && !hasFastapi) return "fastify";
2826
- const configPath = join12(cwd, ".projx");
2827
- if (existsSync11(configPath)) {
2828
- try {
2829
- const data = JSON.parse(await readFile11(configPath, "utf-8"));
2830
- if (data.primaryBackend === "fastapi" || data.primaryBackend === "fastify") {
2831
- return data.primaryBackend;
2832
- }
2833
- } catch {
2834
- }
2117
+ const config = await readProjxConfig(cwd);
2118
+ if (config.primaryBackend === "fastapi" || config.primaryBackend === "fastify") {
2119
+ return config.primaryBackend;
2835
2120
  }
2836
2121
  if (!process.stdin.isTTY) return "fastify";
2837
2122
  const choice = await p9.select({
@@ -2843,23 +2128,17 @@ async function resolvePrimaryBackend(cwd, hasFastapi, hasFastify, backendFlag) {
2843
2128
  initialValue: "fastify"
2844
2129
  });
2845
2130
  if (p9.isCancel(choice)) process.exit(0);
2846
- try {
2847
- const data = JSON.parse(await readFile11(configPath, "utf-8"));
2848
- data.primaryBackend = choice;
2849
- await writeFile5(configPath, JSON.stringify(data, null, 2) + "\n");
2850
- p9.log.success(`Saved primaryBackend: ${choice} to .projx`);
2851
- } catch {
2852
- }
2131
+ await writeProjxConfig(cwd, { ...config, primaryBackend: choice });
2132
+ p9.log.success(`Saved primaryBackend: ${choice} to .projx`);
2853
2133
  return choice;
2854
2134
  }
2855
2135
  async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2856
2136
  p9.intro(`projx gen entity ${entityName}`);
2857
- const configPath = join12(cwd, ".projx");
2858
- if (!existsSync11(configPath)) {
2137
+ if (!existsSync9(join9(cwd, ".projx"))) {
2859
2138
  p9.log.error("No .projx file found. Run 'npx create-projx init' first.");
2860
2139
  process.exit(1);
2861
2140
  }
2862
- const projxData = JSON.parse(await readFile11(configPath, "utf-8"));
2141
+ const projxData = await readProjxConfig(cwd);
2863
2142
  const pmName = projxData.packageManager ?? "npm";
2864
2143
  const pm = pmCommands(pmName);
2865
2144
  const { components: discovered, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
@@ -2896,37 +2175,37 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2896
2175
  const generated = [];
2897
2176
  if (genFastapi) {
2898
2177
  const dir = componentPaths.fastapi;
2899
- const entityDir = join12(cwd, dir, "src/entities", toSnake(config.name));
2900
- if (existsSync11(entityDir)) {
2178
+ const entityDir = join9(cwd, dir, "src/entities", toSnake(config.name));
2179
+ if (existsSync9(entityDir)) {
2901
2180
  p9.log.warn(`${dir}/src/entities/${toSnake(config.name)}/ already exists. Skipping FastAPI.`);
2902
2181
  } else {
2903
- await mkdir5(entityDir, { recursive: true });
2904
- await writeFile5(join12(entityDir, "_model.py"), generateFastAPIModel(config));
2905
- await writeFile5(join12(entityDir, "__init__.py"), "from ._model import *\n");
2182
+ await mkdir3(entityDir, { recursive: true });
2183
+ await writeFile(join9(entityDir, "_model.py"), generateFastAPIModel(config));
2184
+ await writeFile(join9(entityDir, "__init__.py"), "from ._model import *\n");
2906
2185
  generated.push(`${dir}/src/entities/${toSnake(config.name)}/_model.py`);
2907
2186
  generated.push(`${dir}/src/entities/${toSnake(config.name)}/__init__.py`);
2908
- const testsDir = join12(cwd, dir, "tests");
2909
- const testFile = join12(testsDir, `test_${toSnake(config.name)}_entity.py`);
2910
- if (existsSync11(testsDir) && !existsSync11(testFile)) {
2911
- await writeFile5(testFile, generateFastapiTest(config));
2187
+ const testsDir = join9(cwd, dir, "tests");
2188
+ const testFile = join9(testsDir, `test_${toSnake(config.name)}_entity.py`);
2189
+ if (existsSync9(testsDir) && !existsSync9(testFile)) {
2190
+ await writeFile(testFile, generateFastapiTest(config));
2912
2191
  generated.push(`${dir}/tests/test_${toSnake(config.name)}_entity.py`);
2913
2192
  }
2914
2193
  }
2915
2194
  }
2916
2195
  if (genFastify) {
2917
2196
  const dir = componentPaths.fastify;
2918
- const moduleDir = join12(cwd, dir, "src/modules", toKebab(config.name));
2919
- if (existsSync11(moduleDir)) {
2197
+ const moduleDir = join9(cwd, dir, "src/modules", toKebab(config.name));
2198
+ if (existsSync9(moduleDir)) {
2920
2199
  p9.log.warn(`${dir}/src/modules/${toKebab(config.name)}/ already exists. Skipping Fastify.`);
2921
2200
  } else {
2922
- await mkdir5(moduleDir, { recursive: true });
2923
- await writeFile5(join12(moduleDir, "schemas.ts"), generateFastifySchemas(config));
2924
- await writeFile5(join12(moduleDir, "index.ts"), generateFastifyIndex(config));
2201
+ await mkdir3(moduleDir, { recursive: true });
2202
+ await writeFile(join9(moduleDir, "schemas.ts"), generateFastifySchemas(config));
2203
+ await writeFile(join9(moduleDir, "index.ts"), generateFastifyIndex(config));
2925
2204
  generated.push(`${dir}/src/modules/${toKebab(config.name)}/schemas.ts`);
2926
2205
  generated.push(`${dir}/src/modules/${toKebab(config.name)}/index.ts`);
2927
- const appPath = join12(cwd, dir, "src/app.ts");
2928
- if (existsSync11(appPath)) {
2929
- const appContent = await readFile11(appPath, "utf-8");
2206
+ const appPath = join9(cwd, dir, "src/app.ts");
2207
+ if (existsSync9(appPath)) {
2208
+ const appContent = await readFile6(appPath, "utf-8");
2930
2209
  const importLine = `import './modules/${toKebab(config.name)}/index.js';`;
2931
2210
  if (!appContent.includes(importLine)) {
2932
2211
  const updated = appContent.replace(
@@ -2935,62 +2214,62 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2935
2214
  `
2936
2215
  );
2937
2216
  if (updated !== appContent) {
2938
- await writeFile5(appPath, updated);
2217
+ await writeFile(appPath, updated);
2939
2218
  generated.push(`${dir}/src/app.ts (import added)`);
2940
2219
  }
2941
2220
  }
2942
2221
  }
2943
- const prismaPath = join12(cwd, dir, "prisma/schema.prisma");
2944
- if (existsSync11(prismaPath)) {
2945
- const prismaContent = await readFile11(prismaPath, "utf-8");
2222
+ const prismaPath = join9(cwd, dir, "prisma/schema.prisma");
2223
+ if (existsSync9(prismaPath)) {
2224
+ const prismaContent = await readFile6(prismaPath, "utf-8");
2946
2225
  const modelName = `model ${toPascal(config.name)}`;
2947
2226
  if (!prismaContent.includes(modelName)) {
2948
2227
  const prismaModel = generatePrismaModel(config);
2949
- await writeFile5(prismaPath, prismaContent.trimEnd() + "\n\n" + prismaModel + "\n");
2228
+ await writeFile(prismaPath, prismaContent.trimEnd() + "\n\n" + prismaModel + "\n");
2950
2229
  generated.push(`${dir}/prisma/schema.prisma (model added)`);
2951
2230
  }
2952
2231
  }
2953
- const testsModulesDir = join12(cwd, dir, "tests/modules");
2954
- const fastifyTestFile = join12(testsModulesDir, `${toKebab(config.name)}.test.ts`);
2955
- if (existsSync11(testsModulesDir) && !existsSync11(fastifyTestFile)) {
2956
- await writeFile5(fastifyTestFile, generateFastifyTest(config));
2232
+ const testsModulesDir = join9(cwd, dir, "tests/modules");
2233
+ const fastifyTestFile = join9(testsModulesDir, `${toKebab(config.name)}.test.ts`);
2234
+ if (existsSync9(testsModulesDir) && !existsSync9(fastifyTestFile)) {
2235
+ await writeFile(fastifyTestFile, generateFastifyTest(config));
2957
2236
  generated.push(`${dir}/tests/modules/${toKebab(config.name)}.test.ts`);
2958
2237
  }
2959
2238
  }
2960
2239
  }
2961
2240
  if (hasFrontend) {
2962
2241
  const dir = componentPaths.frontend;
2963
- const typesDir = join12(cwd, dir, "src/types");
2242
+ const typesDir = join9(cwd, dir, "src/types");
2964
2243
  const fileName = toKebab(config.name) + ".ts";
2965
- const filePath = join12(typesDir, fileName);
2966
- if (existsSync11(filePath)) {
2244
+ const filePath = join9(typesDir, fileName);
2245
+ if (existsSync9(filePath)) {
2967
2246
  p9.log.warn(`${dir}/src/types/${fileName} already exists. Skipping frontend types.`);
2968
2247
  } else {
2969
- await mkdir5(typesDir, { recursive: true });
2970
- await writeFile5(filePath, generateFrontendInterface(config));
2248
+ await mkdir3(typesDir, { recursive: true });
2249
+ await writeFile(filePath, generateFrontendInterface(config));
2971
2250
  generated.push(`${dir}/src/types/${fileName}`);
2972
- const barrelPath = join12(typesDir, "index.ts");
2251
+ const barrelPath = join9(typesDir, "index.ts");
2973
2252
  const exportLine = `export * from './${toKebab(config.name)}';`;
2974
- if (existsSync11(barrelPath)) {
2975
- const content = await readFile11(barrelPath, "utf-8");
2253
+ if (existsSync9(barrelPath)) {
2254
+ const content = await readFile6(barrelPath, "utf-8");
2976
2255
  if (!content.includes(exportLine)) {
2977
- await writeFile5(barrelPath, content.trimEnd() + "\n" + exportLine + "\n");
2256
+ await writeFile(barrelPath, content.trimEnd() + "\n" + exportLine + "\n");
2978
2257
  }
2979
2258
  } else {
2980
- await writeFile5(barrelPath, exportLine + "\n");
2259
+ await writeFile(barrelPath, exportLine + "\n");
2981
2260
  }
2982
2261
  generated.push(`${dir}/src/types/index.ts`);
2983
2262
  }
2984
2263
  }
2985
2264
  if (hasMobile) {
2986
2265
  const dir = componentPaths.mobile;
2987
- const entityDir = join12(cwd, dir, "lib/entities", toSnake(config.name));
2988
- const modelPath = join12(entityDir, "model.dart");
2989
- if (existsSync11(modelPath)) {
2266
+ const entityDir = join9(cwd, dir, "lib/entities", toSnake(config.name));
2267
+ const modelPath = join9(entityDir, "model.dart");
2268
+ if (existsSync9(modelPath)) {
2990
2269
  p9.log.warn(`${dir}/lib/entities/${toSnake(config.name)}/model.dart already exists. Skipping mobile model.`);
2991
2270
  } else {
2992
- await mkdir5(entityDir, { recursive: true });
2993
- await writeFile5(modelPath, generateDartModel(config));
2271
+ await mkdir3(entityDir, { recursive: true });
2272
+ await writeFile(modelPath, generateDartModel(config));
2994
2273
  generated.push(`${dir}/lib/entities/${toSnake(config.name)}/model.dart`);
2995
2274
  }
2996
2275
  }
@@ -3030,9 +2309,9 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
3030
2309
  }
3031
2310
 
3032
2311
  // src/sync.ts
3033
- import { existsSync as existsSync12, readFileSync as readFileSync2 } from "fs";
3034
- import { writeFile as writeFile6, mkdir as mkdir6 } from "fs/promises";
3035
- import { join as join13 } from "path";
2312
+ import { existsSync as existsSync10, readFileSync } from "fs";
2313
+ import { writeFile as writeFile2, mkdir as mkdir4 } from "fs/promises";
2314
+ import { join as join10 } from "path";
3036
2315
  import * as p10 from "@clack/prompts";
3037
2316
  function toPascal2(s) {
3038
2317
  return s.replace(/(?:^|[_\-\s])([a-zA-Z])/g, (_, c) => c.toUpperCase());
@@ -3203,8 +2482,8 @@ function generateDartModel2(entity) {
3203
2482
  }
3204
2483
  async function sync(cwd, url) {
3205
2484
  p10.intro("projx sync");
3206
- const configPath = join13(cwd, ".projx");
3207
- if (!existsSync12(configPath)) {
2485
+ const configPath = join10(cwd, ".projx");
2486
+ if (!existsSync10(configPath)) {
3208
2487
  p10.log.error("No .projx file found. Run 'npx create-projx init' first.");
3209
2488
  process.exit(1);
3210
2489
  }
@@ -3236,18 +2515,18 @@ async function sync(cwd, url) {
3236
2515
  const generated = [];
3237
2516
  if (hasFrontend) {
3238
2517
  const dir = componentPaths.frontend;
3239
- const typesDir = join13(cwd, dir, "src/types");
3240
- await mkdir6(typesDir, { recursive: true });
2518
+ const typesDir = join10(cwd, dir, "src/types");
2519
+ await mkdir4(typesDir, { recursive: true });
3241
2520
  const barrelExports = [];
3242
2521
  for (const entity of meta.entities) {
3243
2522
  const fileName = toKebab(toSnake(entity.name)) + ".ts";
3244
- const filePath = join13(typesDir, fileName);
3245
- await writeFile6(filePath, generateTsInterface(entity));
2523
+ const filePath = join10(typesDir, fileName);
2524
+ await writeFile2(filePath, generateTsInterface(entity));
3246
2525
  generated.push(`${dir}/src/types/${fileName}`);
3247
2526
  barrelExports.push(`export * from './${toKebab(toSnake(entity.name))}';`);
3248
2527
  }
3249
- await writeFile6(
3250
- join13(typesDir, "index.ts"),
2528
+ await writeFile2(
2529
+ join10(typesDir, "index.ts"),
3251
2530
  barrelExports.join("\n") + "\n"
3252
2531
  );
3253
2532
  generated.push(`${dir}/src/types/index.ts`);
@@ -3255,10 +2534,10 @@ async function sync(cwd, url) {
3255
2534
  if (hasMobile) {
3256
2535
  const dir = componentPaths.mobile;
3257
2536
  for (const entity of meta.entities) {
3258
- const entityDir = join13(cwd, dir, "lib/entities", toSnake(entity.name));
3259
- await mkdir6(entityDir, { recursive: true });
3260
- const modelPath = join13(entityDir, "model.dart");
3261
- await writeFile6(modelPath, generateDartModel2(entity));
2537
+ const entityDir = join10(cwd, dir, "lib/entities", toSnake(entity.name));
2538
+ await mkdir4(entityDir, { recursive: true });
2539
+ const modelPath = join10(entityDir, "model.dart");
2540
+ await writeFile2(modelPath, generateDartModel2(entity));
3262
2541
  generated.push(`${dir}/lib/entities/${toSnake(entity.name)}/model.dart`);
3263
2542
  }
3264
2543
  }
@@ -3281,10 +2560,10 @@ async function sync(cwd, url) {
3281
2560
  function detectMetaUrl(cwd) {
3282
2561
  const envFiles = [".env", ".env.dev", ".env.local"];
3283
2562
  for (const envFile of envFiles) {
3284
- const envPath = join13(cwd, envFile);
3285
- if (existsSync12(envPath)) {
2563
+ const envPath = join10(cwd, envFile);
2564
+ if (existsSync10(envPath)) {
3286
2565
  try {
3287
- const content = readFileSync2(envPath, "utf-8");
2566
+ const content = readFileSync(envPath, "utf-8");
3288
2567
  const match = content.match(/VITE_API_URL\s*=\s*(.+)/);
3289
2568
  if (match) {
3290
2569
  const base = match[1].trim().replace(/["']/g, "");
@@ -3300,10 +2579,10 @@ function detectMetaUrl(cwd) {
3300
2579
  "frontend/.env.dev"
3301
2580
  ];
3302
2581
  for (const envFile of frontendEnvFiles) {
3303
- const envPath = join13(cwd, envFile);
3304
- if (existsSync12(envPath)) {
2582
+ const envPath = join10(cwd, envFile);
2583
+ if (existsSync10(envPath)) {
3305
2584
  try {
3306
- const content = readFileSync2(envPath, "utf-8");
2585
+ const content = readFileSync(envPath, "utf-8");
3307
2586
  const match = content.match(/VITE_API_URL\s*=\s*(.+)/);
3308
2587
  if (match) {
3309
2588
  const base = match[1].trim().replace(/["']/g, "");
@@ -3373,7 +2652,7 @@ function parseArgs() {
3373
2652
  continue;
3374
2653
  }
3375
2654
  if (arg === "--local") {
3376
- localRepo = resolve2(args[++i] || ".");
2655
+ localRepo = resolve(args[++i] || ".");
3377
2656
  continue;
3378
2657
  }
3379
2658
  if (arg === "--no-git") {
@@ -3545,8 +2824,8 @@ async function main() {
3545
2824
  opts.git = options.git ?? opts.git;
3546
2825
  opts.install = options.install ?? opts.install;
3547
2826
  }
3548
- const dest = resolve2(process.cwd(), opts.name);
3549
- if (existsSync13(dest)) {
2827
+ const dest = resolve(process.cwd(), opts.name);
2828
+ if (existsSync11(dest)) {
3550
2829
  console.error(`Error: ${dest} already exists.`);
3551
2830
  process.exit(1);
3552
2831
  }