create-projx 1.5.5 → 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,447 +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
- async function writeManagedProjx(cwd, version, vars, components) {
505
- const projxPath = join3(cwd, ".projx");
506
- let existing = {};
507
- if (existsSync2(projxPath)) {
508
- try {
509
- existing = JSON.parse(await readFile3(projxPath, "utf-8"));
510
- } catch (err) {
511
- const msg = err instanceof Error ? err.message : String(err);
512
- console.warn(`projx: existing .projx is unreadable (${msg}); writing fresh.`);
513
- }
514
- }
515
- const merged = {
516
- ...existing,
517
- version
518
- };
519
- if (components !== void 0) {
520
- merged.components = components;
521
- }
522
- if (typeof merged.createdAt !== "string") {
523
- merged.createdAt = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
524
- }
525
- const pmObj = vars.pm;
526
- if (pmObj?.name && !merged.packageManager) {
527
- merged.packageManager = pmObj.name;
528
- }
529
- await writeFile2(projxPath, JSON.stringify(merged, null, 2) + "\n");
530
- }
531
- function matchesSkip(filePath, patterns) {
532
- for (const pattern of patterns) {
533
- if (pattern === "**") return true;
534
- if (pattern.endsWith("/**")) {
535
- const prefix = pattern.slice(0, -3);
536
- if (filePath.startsWith(prefix + "/") || filePath === prefix) return true;
537
- }
538
- if (pattern.startsWith("**/")) {
539
- const suffix = pattern.slice(3);
540
- if (suffix.startsWith("*.")) {
541
- const ext = suffix.slice(1);
542
- if (filePath.endsWith(ext)) return true;
543
- } else if (filePath.endsWith(suffix) || filePath.includes("/" + suffix)) {
544
- return true;
545
- }
546
- }
547
- if (pattern.startsWith("*.")) {
548
- const ext = pattern.slice(1);
549
- if (filePath.endsWith(ext)) return true;
550
- }
551
- if (filePath === pattern) return true;
552
- }
553
- return false;
554
- }
555
- function saveBaselineRef(cwd) {
556
- try {
557
- const head = execSync2("git rev-parse HEAD", { cwd, stdio: "pipe" }).toString().trim();
558
- execSync2(`git update-ref ${BASELINE_REF} ${head}`, { cwd, stdio: "pipe" });
559
- } catch {
560
- }
561
- }
562
- function getBaselineRef(cwd) {
563
- try {
564
- return execSync2(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" }).toString().trim();
565
- } catch {
566
- }
567
- try {
568
- const sha = execSync2("git log -1 --format=%H -- .projx", { cwd, stdio: "pipe" }).toString().trim();
569
- if (sha) return sha;
570
- } catch {
571
- }
572
- return null;
573
- }
574
- function getFileAtRef(cwd, ref, filePath) {
575
- try {
576
- return execSync2(`git show ${ref}:"${filePath}"`, { cwd, stdio: "pipe" }).toString();
577
- } catch {
578
- return null;
579
- }
580
- }
581
- function mergeFileThreeWay(oursPath, baseContent, theirsContent) {
582
- const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
583
- const baseTmp = join3(tmpdir2(), `projx-base-${id}`);
584
- const theirsTmp = join3(tmpdir2(), `projx-theirs-${id}`);
585
- try {
586
- writeFileSync(baseTmp, baseContent);
587
- writeFileSync(theirsTmp, theirsContent);
588
- execSync2(
589
- `git merge-file -L "your changes" -L "previous projx baseline" -L "new projx template" "${oursPath}" "${baseTmp}" "${theirsTmp}"`,
590
- { stdio: "pipe" }
591
- );
592
- return true;
593
- } catch {
594
- return false;
595
- } finally {
596
- try {
597
- unlinkSync(baseTmp);
598
- } catch {
599
- }
600
- try {
601
- unlinkSync(theirsTmp);
602
- } catch {
603
- }
604
- }
605
- }
606
- async function collectAllFiles(dir, base) {
607
- const { readdir: readdir4 } = await import("fs/promises");
608
- const results = [];
609
- const walk = async (current) => {
610
- const entries = await readdir4(current, { withFileTypes: true });
611
- for (const entry of entries) {
612
- const full = join3(current, entry.name);
613
- if (entry.isDirectory()) {
614
- await walk(full);
615
- } else {
616
- results.push(full.slice(base.length + 1));
617
- }
618
- }
619
- };
620
- await walk(dir);
621
- return results;
622
- }
623
- async function tryThreeWayMerge(cwd, templateDir, baselineRef) {
624
- const templateFiles = await collectAllFiles(templateDir, templateDir);
625
- const merged = [];
626
- const conflicted = [];
627
- for (const file of templateFiles) {
628
- if (file === ".projx") continue;
629
- const oursPath = join3(cwd, file);
630
- if (!existsSync2(oursPath)) continue;
631
- const baseContent = getFileAtRef(cwd, baselineRef, file);
632
- if (baseContent === null) continue;
633
- let theirsContent;
634
- try {
635
- theirsContent = await readFile3(join3(templateDir, file), "utf-8");
636
- } catch {
637
- continue;
638
- }
639
- const oursContent = await readFile3(oursPath, "utf-8");
640
- if (theirsContent === baseContent) continue;
641
- if (oursContent === baseContent) {
642
- await writeFile2(oursPath, theirsContent);
643
- merged.push(file);
644
- continue;
645
- }
646
- if (oursContent === theirsContent) continue;
647
- const clean = mergeFileThreeWay(oursPath, baseContent, theirsContent);
648
- if (clean) {
649
- merged.push(file);
650
- } else {
651
- conflicted.push(file);
652
- }
653
- }
654
- return { merged, conflicted };
655
- }
656
- function createOrphanWorktree(cwd) {
657
- const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
658
- const branch = `projx/tmp-${id}`;
659
- const worktree = join3(tmpdir2(), `projx-wt-${id}`);
660
- try {
661
- execSync2("git worktree prune", { cwd, stdio: "pipe" });
662
- } catch {
663
- }
664
- execSync2(`git worktree add --orphan -b ${branch} "${worktree}"`, {
665
- cwd,
666
- stdio: "pipe"
667
- });
668
- return { worktree, branch };
669
- }
670
- function cleanupWorktree(cwd, worktree, branch) {
671
- try {
672
- execSync2(`git worktree remove "${worktree}" --force`, { cwd, stdio: "pipe" });
673
- } catch {
674
- try {
675
- rm2(worktree, { recursive: true, force: true });
676
- execSync2("git worktree prune", { cwd, stdio: "pipe" });
677
- } catch {
678
- }
679
- }
680
- try {
681
- execSync2(`git branch -D ${branch}`, { cwd, stdio: "pipe" });
682
- } catch {
683
- }
684
- }
685
- async function removeSkippedFiles(dir, skipPatterns) {
686
- if (skipPatterns.length === 0) return;
687
- const { readdir: readdir4, unlink: unlink2 } = await import("fs/promises");
688
- const walk = async (current, base) => {
689
- const entries = await readdir4(current, { withFileTypes: true });
690
- for (const entry of entries) {
691
- const full = join3(current, entry.name);
692
- const rel = full.slice(base.length + 1);
693
- if (entry.isDirectory()) {
694
- await walk(full, base);
695
- } else if (entry.name !== ".projx-component" && matchesSkip(rel, skipPatterns)) {
696
- await unlink2(full);
697
- }
698
- }
699
- };
700
- await walk(dir, dir);
701
- }
702
- async function writeTemplateToDir(dest, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip) {
703
- const name = vars.projectName;
704
- const nameSnake = toSnake(name);
705
- for (const component of components) {
706
- const targetDir = componentPaths[component];
707
- const skipPatterns = componentSkips?.[component] ?? [];
708
- const tmpDir = join3(dest, "__cptmp__");
709
- await copyComponent(repoDir, component, tmpDir);
710
- const srcDir = join3(tmpDir, component);
711
- if (skipPatterns.length > 0) {
712
- await removeSkippedFiles(srcDir, skipPatterns);
713
- }
714
- const outDir = join3(dest, targetDir);
715
- await mkdir2(outDir, { recursive: true });
716
- const { cp: cp2 } = await import("fs/promises");
717
- if (existsSync2(srcDir)) {
718
- await cp2(srcDir, outDir, { recursive: true, force: true });
719
- }
720
- await rm2(tmpDir, { recursive: true, force: true });
721
- await writeComponentMarker(join3(dest, targetDir), component, origin, skipPatterns.length > 0 ? skipPatterns : void 0);
722
- }
723
- await substituteNames(dest, components, componentPaths, name, nameSnake);
724
- const hasBackend = components.includes("fastapi") || components.includes("fastify");
725
- const skip = rootSkip ?? [];
726
- const shouldWrite = (file) => !matchesSkip(file, skip);
727
- if (hasBackend || components.includes("frontend")) {
728
- if (shouldWrite("docker-compose.yml"))
729
- await writeFile2(join3(dest, "docker-compose.yml"), await generateDockerCompose(vars));
730
- if (shouldWrite("docker-compose.dev.yml"))
731
- await writeFile2(join3(dest, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
732
- }
733
- if (shouldWrite("README.md"))
734
- await writeFile2(join3(dest, "README.md"), await generateReadme(vars));
735
- if (shouldWrite(".githooks/pre-commit")) {
736
- await mkdir2(join3(dest, ".githooks"), { recursive: true });
737
- await writeFile2(join3(dest, ".githooks/pre-commit"), await generatePreCommit(vars));
738
- await chmod(join3(dest, ".githooks/pre-commit"), 493);
739
- }
740
- if (shouldWrite(".github/workflows/ci.yml")) {
741
- await mkdir2(join3(dest, ".github/workflows"), { recursive: true });
742
- await writeFile2(join3(dest, ".github/workflows/ci.yml"), await generateCiYml(vars));
743
- }
744
- if (shouldWrite("setup.sh")) {
745
- await writeFile2(join3(dest, "setup.sh"), await generateSetupSh(vars));
746
- await chmod(join3(dest, "setup.sh"), 493);
747
- }
748
- await copyStaticFiles(repoDir, dest);
749
- if (shouldWrite(".vscode/settings.json")) {
750
- await mkdir2(join3(dest, ".vscode"), { recursive: true });
751
- await writeFile2(join3(dest, ".vscode/settings.json"), generateVscodeSettings(vars));
752
- }
753
- await writeManagedProjx(dest, version, vars, components);
754
- }
755
- async function substituteNames(dest, components, paths, name, nameSnake) {
756
- if (components.includes("fastapi")) {
757
- await replaceInFile(join3(dest, `${paths.fastapi}/pyproject.toml`), "projx-fastapi", `${name}-fastapi`);
758
- }
759
- if (components.includes("fastify")) {
760
- await replaceInFile(join3(dest, `${paths.fastify}/package.json`), "projx-fastify", `${name}-fastify`);
761
- }
762
- if (components.includes("frontend")) {
763
- await replaceInFile(join3(dest, `${paths.frontend}/package.json`), "projx-frontend", `${name}-frontend`);
764
- }
765
- if (components.includes("e2e")) {
766
- await replaceInFile(join3(dest, `${paths.e2e}/package.json`), "projx-e2e", `${name}-e2e`);
767
- }
768
- if (components.includes("mobile")) {
769
- await replaceInFile(join3(dest, `${paths.mobile}/pubspec.yaml`), "projx_mobile", `${nameSnake}_mobile`);
770
- await replaceInDir(join3(dest, `${paths.mobile}`), "package:projx_mobile/", `package:${nameSnake}_mobile/`, ".dart");
771
- }
772
- }
773
- async function applyTemplate(cwd, repoDir, components, componentPaths, vars, version, origin = "scaffold", componentSkips, rootSkip) {
774
- const hasHead = (() => {
775
- try {
776
- execSync2("git rev-parse HEAD", { cwd, stdio: "pipe" });
777
- return true;
778
- } catch {
779
- return false;
780
- }
781
- })();
782
- if (!hasHead) {
783
- await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
784
- return { status: "clean" };
785
- }
786
- const { worktree, branch } = createOrphanWorktree(cwd);
787
- try {
788
- await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
789
- execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
790
- const diff2 = execSync2("git diff --cached --stat", { cwd: worktree, stdio: "pipe" }).toString().trim();
791
- if (!diff2) {
792
- cleanupWorktree(cwd, worktree, branch);
793
- return { status: "clean" };
794
- }
795
- execSync2(
796
- `git commit --no-verify -m "projx: template v${version} [${components.join(", ")}]"`,
797
- { cwd: worktree, stdio: "pipe" }
798
- );
799
- try {
800
- execSync2(`git worktree remove "${worktree}" --force`, { cwd, stdio: "pipe" });
801
- } catch {
802
- try {
803
- await rm2(worktree, { recursive: true, force: true });
804
- execSync2("git worktree prune", { cwd, stdio: "pipe" });
805
- } catch {
806
- }
807
- }
808
- let mergeClean = false;
809
- try {
810
- execSync2(
811
- `git merge ${branch} --allow-unrelated-histories -m "projx: update to template v${version}"`,
812
- { cwd, stdio: "pipe" }
813
- );
814
- mergeClean = true;
815
- } catch {
816
- try {
817
- execSync2("git merge --abort", { cwd, stdio: "pipe" });
818
- } catch {
819
- }
820
- }
821
- try {
822
- execSync2(`git branch -D ${branch}`, { cwd, stdio: "pipe" });
823
- } catch {
824
- }
825
- if (mergeClean) {
826
- saveBaselineRef(cwd);
827
- return { status: "clean" };
828
- }
829
- const baselineRef = getBaselineRef(cwd);
830
- if (baselineRef) {
831
- const tmpTemplate = join3(tmpdir2(), `projx-tpl-${Date.now()}`);
832
- await mkdir2(tmpTemplate, { recursive: true });
833
- await writeTemplateToDir(tmpTemplate, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
834
- const result = await tryThreeWayMerge(cwd, tmpTemplate, baselineRef);
835
- await rm2(tmpTemplate, { recursive: true, force: true });
836
- if (result.conflicted.length === 0) {
837
- await writeManagedProjx(cwd, version, vars);
838
- execSync2("git add -A", { cwd, stdio: "pipe" });
839
- const staged = execSync2("git diff --cached --stat", { cwd, stdio: "pipe" }).toString().trim();
840
- if (staged) {
841
- execSync2(
842
- `git commit --no-verify -m "projx: update to template v${version} (3-way merge)"`,
843
- { cwd, stdio: "pipe" }
844
- );
845
- }
846
- saveBaselineRef(cwd);
847
- return result.merged.length > 0 ? { status: "merged", mergedFiles: result.merged } : { status: "clean" };
848
- }
849
- await writeManagedProjx(cwd, version, vars);
850
- for (const f of result.merged) {
851
- try {
852
- execSync2(`git add "${f}"`, { cwd, stdio: "pipe" });
853
- } catch {
854
- }
855
- }
856
- execSync2("git add .projx", { cwd, stdio: "pipe" });
857
- return {
858
- status: "conflicts",
859
- mergedFiles: result.merged,
860
- conflictedFiles: result.conflicted
861
- };
862
- }
863
- await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
864
- return { status: "conflicts" };
865
- } catch (err) {
866
- cleanupWorktree(cwd, worktree, branch);
867
- throw err;
868
- }
869
- }
870
-
871
- // src/scaffold.ts
872
98
  async function scaffold(opts, dest, localRepo) {
873
99
  const name = toKebab(opts.name);
874
100
  const pm = opts.packageManager ?? "npm";
@@ -877,7 +103,7 @@ async function scaffold(opts, dest, localRepo) {
877
103
  );
878
104
  const vars = { projectName: name, components: opts.components, paths, pm: pmCommands(pm) };
879
105
  const isLocal = !!localRepo;
880
- await mkdir3(dest, { recursive: true });
106
+ await mkdir(dest, { recursive: true });
881
107
  const dlSpinner = p2.spinner();
882
108
  dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
883
109
  const repoDir = await downloadRepo(localRepo).catch((err) => {
@@ -887,16 +113,15 @@ async function scaffold(opts, dest, localRepo) {
887
113
  });
888
114
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
889
115
  try {
890
- 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"));
891
117
  const version = pkg.version;
892
118
  p2.log.info(`Scaffolding project in ${dest}`);
893
119
  if (opts.git) {
894
120
  exec("git init", dest);
895
- exec("git config core.hooksPath .githooks", dest);
896
121
  }
897
122
  const spinner7 = p2.spinner();
898
123
  spinner7.start("Scaffolding project");
899
- await applyTemplate(dest, repoDir, opts.components, paths, vars, version);
124
+ await applyTemplate(dest, repoDir, opts.components, paths, vars, version, void 0, void 0, true);
900
125
  spinner7.stop("Scaffold complete.");
901
126
  if (opts.install) {
902
127
  await installDeps(dest, opts.components, pm);
@@ -905,7 +130,8 @@ async function scaffold(opts, dest, localRepo) {
905
130
  if (opts.git) {
906
131
  try {
907
132
  exec("git add -A", dest);
908
- 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);
909
135
  saveBaselineRef(dest);
910
136
  } catch {
911
137
  }
@@ -930,7 +156,7 @@ async function installDeps(dest, components, pm) {
930
156
  case "fastapi":
931
157
  if (hasCommand("uv")) {
932
158
  spinner7.start("Installing FastAPI dependencies (uv sync)");
933
- exec("uv sync --all-extras", join4(dest, "fastapi"));
159
+ exec("uv sync --all-extras", join(dest, "fastapi"));
934
160
  spinner7.stop("FastAPI dependencies installed.");
935
161
  } else {
936
162
  p2.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
@@ -939,7 +165,7 @@ async function installDeps(dest, components, pm) {
939
165
  case "fastify":
940
166
  if (hasCommand(pmBin)) {
941
167
  spinner7.start(`Installing Fastify dependencies (${cmds.install})`);
942
- exec(cmds.install, join4(dest, "fastify"));
168
+ exec(cmds.install, join(dest, "fastify"));
943
169
  spinner7.stop("Fastify dependencies installed.");
944
170
  } else {
945
171
  p2.log.warn(`${pm} not found \u2014 run 'cd fastify && ${cmds.install}' manually.`);
@@ -948,7 +174,7 @@ async function installDeps(dest, components, pm) {
948
174
  case "frontend":
949
175
  if (hasCommand(pmBin)) {
950
176
  spinner7.start(`Installing Frontend dependencies (${cmds.install})`);
951
- exec(cmds.install, join4(dest, "frontend"));
177
+ exec(cmds.install, join(dest, "frontend"));
952
178
  spinner7.stop("Frontend dependencies installed.");
953
179
  } else {
954
180
  p2.log.warn(`${pm} not found \u2014 run 'cd frontend && ${cmds.install}' manually.`);
@@ -957,7 +183,7 @@ async function installDeps(dest, components, pm) {
957
183
  case "e2e":
958
184
  if (hasCommand(pmBin)) {
959
185
  spinner7.start(`Installing E2E dependencies (${cmds.install})`);
960
- exec(cmds.install, join4(dest, "e2e"));
186
+ exec(cmds.install, join(dest, "e2e"));
961
187
  spinner7.stop("E2E dependencies installed.");
962
188
  } else {
963
189
  p2.log.warn(`${pm} not found \u2014 run 'cd e2e && ${cmds.install}' manually.`);
@@ -966,7 +192,7 @@ async function installDeps(dest, components, pm) {
966
192
  case "mobile":
967
193
  if (hasCommand("flutter")) {
968
194
  spinner7.start("Installing Flutter dependencies");
969
- exec("flutter pub get", join4(dest, "mobile"));
195
+ exec("flutter pub get", join(dest, "mobile"));
970
196
  spinner7.stop("Flutter dependencies installed.");
971
197
  } else {
972
198
  p2.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
@@ -982,9 +208,9 @@ async function installDeps(dest, components, pm) {
982
208
  }
983
209
  function copyEnvExamples(dest, components) {
984
210
  for (const component of components) {
985
- const example = join4(dest, component, ".env.example");
986
- const env = join4(dest, component, ".env");
987
- 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)) {
988
214
  try {
989
215
  copyFileSync(example, env);
990
216
  } catch {
@@ -994,10 +220,10 @@ function copyEnvExamples(dest, components) {
994
220
  }
995
221
 
996
222
  // src/update.ts
997
- import { existsSync as existsSync4 } from "fs";
998
- import { readFile as readFile5, writeFile as writeFile3, unlink } from "fs/promises";
999
- import { execSync as execSync3 } from "child_process";
1000
- 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";
1001
227
  import * as p3 from "@clack/prompts";
1002
228
  async function update(cwd, localRepo) {
1003
229
  p3.intro("projx update");
@@ -1007,39 +233,47 @@ async function update(cwd, localRepo) {
1007
233
  process.exit(1);
1008
234
  }
1009
235
  try {
1010
- execSync3("git worktree prune", { cwd, stdio: "pipe" });
236
+ execSync("git worktree prune", { cwd, stdio: "pipe" });
1011
237
  } catch {
1012
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
+ }
1013
255
  if (hasUncommittedChanges(cwd)) {
1014
256
  p3.log.error("You have uncommitted changes. Commit or stash them first.");
1015
257
  process.exit(1);
1016
258
  }
1017
- const configPath = join5(cwd, ".projx");
1018
- let config;
1019
- if (existsSync4(configPath)) {
1020
- const raw = JSON.parse(await readFile5(configPath, "utf-8"));
1021
- const { components: discovered } = await discoverComponentsFromMarkers(cwd);
1022
- config = { ...raw, components: discovered.length > 0 ? discovered : raw.components };
1023
- 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(", ")})`);
1024
265
  } else {
1025
- p3.log.warn("No .projx file found. Detecting components from directories.");
1026
- const { components: discovered } = await discoverComponentsFromMarkers(cwd);
1027
- if (discovered.length === 0) {
1028
- p3.log.error("No projx components found. Run 'projx init' first.");
1029
- process.exit(1);
1030
- }
1031
- config = { version: "0.0.0", components: discovered, createdAt: "unknown" };
1032
- 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(", ")}`);
1033
268
  }
1034
- const componentPaths = await discoverComponentPaths(cwd, config.components);
1035
- for (const c of config.components) {
269
+ for (const c of components) {
1036
270
  const dir = componentPaths[c];
1037
271
  p3.log.info(dir !== c ? `${c} \u2192 ${dir}/` : `${c}/`);
1038
272
  }
1039
273
  const componentSkips = {};
1040
- for (const component of config.components) {
274
+ for (const component of components) {
1041
275
  const dir = componentPaths[component];
1042
- const marker = await readComponentMarker(join5(cwd, dir));
276
+ const marker = await readComponentMarker(join2(cwd, dir));
1043
277
  if (marker?.skip && marker.skip.length > 0) {
1044
278
  componentSkips[component] = marker.skip;
1045
279
  }
@@ -1053,17 +287,36 @@ async function update(cwd, localRepo) {
1053
287
  });
1054
288
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1055
289
  try {
1056
- 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"));
1057
291
  const version = pkg.version;
1058
- const name = detectProjectName(cwd, config.components, componentPaths);
1059
- const raw = existsSync4(configPath) ? JSON.parse(await readFile5(configPath, "utf-8")) : {};
1060
- const pm = raw.packageManager ?? "npm";
1061
- 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 };
1062
304
  const spinner7 = p3.spinner();
1063
305
  spinner7.start("Applying template update");
1064
- const rootSkip = config.skip ?? [];
1065
- 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);
1066
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
+ }
1067
320
  if (result.status === "merged") {
1068
321
  saveBaselineRef(cwd);
1069
322
  p3.log.success(`${result.mergedFiles?.length ?? 0} file(s) merged cleanly.`);
@@ -1079,7 +332,7 @@ async function update(cwd, localRepo) {
1079
332
  p3.log.info(` ${f}`);
1080
333
  }
1081
334
  }
1082
- const handled = await promptSkipLearning(cwd, componentPaths, version);
335
+ const handled = await promptSkipLearning(cwd, componentPaths, version, result.conflictedFiles ?? []);
1083
336
  if (!handled) {
1084
337
  p3.log.info("");
1085
338
  p3.log.info("Review: git diff");
@@ -1101,7 +354,7 @@ async function update(cwd, localRepo) {
1101
354
  }
1102
355
  function isGitRepo(cwd) {
1103
356
  try {
1104
- execSync3("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
357
+ execSync("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
1105
358
  return true;
1106
359
  } catch {
1107
360
  return false;
@@ -1109,33 +362,108 @@ function isGitRepo(cwd) {
1109
362
  }
1110
363
  function hasUncommittedChanges(cwd) {
1111
364
  try {
1112
- const status = execSync3("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
365
+ const status = execSync("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
1113
366
  return status.length > 0;
1114
367
  } catch {
1115
368
  return false;
1116
369
  }
1117
370
  }
1118
- async function promptSkipLearning(cwd, componentPaths, version) {
1119
- if (!process.stdin.isTTY) return false;
1120
- const statusOutput = execSync3("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
1121
- if (!statusOutput) return false;
1122
- const entries = statusOutput.split("\n").filter(Boolean).map((line) => ({
1123
- status: line.slice(0, 2).trim(),
1124
- file: line.slice(3).trim()
1125
- }));
1126
- 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) => {
1127
436
  const base = f.split("/").pop();
1128
437
  if (base === ".projx" || base === COMPONENT_MARKER) return false;
1129
438
  return true;
1130
439
  });
1131
440
  if (changedFiles.length === 0) return false;
1132
- 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("");
1133
457
  const selected = await p3.multiselect({
1134
- message: "Select files to KEEP (unselected will be discarded and skipped on future updates)",
458
+ message: "Which files do you want to KEEP?",
1135
459
  options: changedFiles.map((f) => ({ value: f, label: f })),
1136
460
  required: false
1137
461
  });
1138
- 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
+ }
1139
467
  const kept = new Set(selected);
1140
468
  const discarded = changedFiles.filter((f) => !kept.has(f));
1141
469
  if (discarded.length > 0) {
@@ -1143,9 +471,9 @@ async function promptSkipLearning(cwd, componentPaths, version) {
1143
471
  const entry = entries.find((e) => e.file === file);
1144
472
  try {
1145
473
  if (entry?.status === "??") {
1146
- await unlink(join5(cwd, file));
474
+ await unlink(join2(cwd, file));
1147
475
  } else {
1148
- execSync3(`git checkout -- "${file}"`, { cwd, stdio: "pipe" });
476
+ execSync(`git checkout -- "${file}"`, { cwd, stdio: "pipe" });
1149
477
  }
1150
478
  } catch {
1151
479
  }
@@ -1156,7 +484,7 @@ async function promptSkipLearning(cwd, componentPaths, version) {
1156
484
  );
1157
485
  }
1158
486
  if (kept.size > 0) {
1159
- 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:`);
1160
488
  p3.log.info(
1161
489
  ` git add . && git commit -m "projx: update to v${version}"`
1162
490
  );
@@ -1190,42 +518,33 @@ async function learnSkips(cwd, files, componentPaths) {
1190
518
  }
1191
519
  for (const [component, additions] of Object.entries(componentSkipAdds)) {
1192
520
  const dir = componentPaths[component];
1193
- const markerPath = join5(cwd, dir, COMPONENT_MARKER);
1194
- try {
1195
- const data = JSON.parse(await readFile5(markerPath, "utf-8"));
1196
- const existing = data.skip ?? [];
1197
- data.skip = [.../* @__PURE__ */ new Set([...existing, ...additions])];
1198
- await writeFile3(markerPath, JSON.stringify(data, null, 2) + "\n");
1199
- } catch {
1200
- }
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 });
1201
525
  }
1202
526
  if (rootSkipAdds.length > 0) {
1203
- const configPath = join5(cwd, ".projx");
1204
- try {
1205
- const data = JSON.parse(await readFile5(configPath, "utf-8"));
1206
- const existing = data.skip ?? [];
1207
- data.skip = [.../* @__PURE__ */ new Set([...existing, ...rootSkipAdds])];
1208
- await writeFile3(configPath, JSON.stringify(data, null, 2) + "\n");
1209
- } catch {
1210
- }
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 });
1211
531
  }
1212
532
  }
1213
533
 
1214
534
  // src/add.ts
1215
- import { copyFileSync as copyFileSync2, existsSync as existsSync5 } from "fs";
1216
- import { readFile as readFile6 } from "fs/promises";
1217
- 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";
1218
538
  import * as p4 from "@clack/prompts";
1219
539
  async function add(cwd, newComponents, localRepo, skipInstall = false) {
1220
540
  p4.intro("projx add");
1221
541
  const isLocal = !!localRepo;
1222
- const configPath = join6(cwd, ".projx");
1223
- if (!existsSync5(configPath)) {
542
+ if (!existsSync3(join3(cwd, ".projx"))) {
1224
543
  p4.log.error("No .projx file found. Run 'npx create-projx <name>' to create a project first.");
1225
544
  process.exit(1);
1226
545
  }
1227
- const config = JSON.parse(await readFile6(configPath, "utf-8"));
1228
- const existing = config.components;
546
+ const config = await readProjxConfig(cwd);
547
+ const { components: existing } = await discoverComponentsFromMarkers(cwd);
1229
548
  const alreadyExists = newComponents.filter((c) => existing.includes(c));
1230
549
  if (alreadyExists.length > 0) {
1231
550
  p4.log.warn(`Already present: ${alreadyExists.join(", ")}. Skipping those.`);
@@ -1252,19 +571,19 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
1252
571
  const pm = config.packageManager ?? "npm";
1253
572
  const name = detectProjectName(cwd, existing, paths);
1254
573
  const vars = { projectName: name, components: allComponents, paths, pm: pmCommands(pm) };
1255
- 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"));
1256
575
  const version = pkg.version;
1257
576
  const spinner7 = p4.spinner();
1258
577
  spinner7.start("Adding components");
1259
- await writeTemplateToDir(cwd, repoDir, allComponents, paths, vars, version, "scaffold");
578
+ await writeTemplateToDir(cwd, repoDir, allComponents, paths, vars, version, { realCwd: cwd });
1260
579
  spinner7.stop("Components added.");
1261
580
  if (!skipInstall) {
1262
581
  await installDeps2(cwd, toAdd, pm);
1263
582
  }
1264
583
  for (const component of toAdd) {
1265
- const example = join6(cwd, component, ".env.example");
1266
- const env = join6(cwd, component, ".env");
1267
- 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)) {
1268
587
  try {
1269
588
  copyFileSync2(example, env);
1270
589
  } catch {
@@ -1288,7 +607,7 @@ async function installDeps2(dest, components, pm) {
1288
607
  case "fastapi":
1289
608
  if (hasCommand("uv")) {
1290
609
  spinner7.start("Installing FastAPI dependencies");
1291
- exec("uv sync --all-extras", join6(dest, "fastapi"));
610
+ exec("uv sync --all-extras", join3(dest, "fastapi"));
1292
611
  spinner7.stop("FastAPI dependencies installed.");
1293
612
  } else {
1294
613
  p4.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
@@ -1297,7 +616,7 @@ async function installDeps2(dest, components, pm) {
1297
616
  case "fastify":
1298
617
  if (hasCommand(pmBin)) {
1299
618
  spinner7.start(`Installing Fastify dependencies (${cmds.install})`);
1300
- exec(cmds.install, join6(dest, "fastify"));
619
+ exec(cmds.install, join3(dest, "fastify"));
1301
620
  spinner7.stop("Fastify dependencies installed.");
1302
621
  } else {
1303
622
  p4.log.warn(`${pm} not found \u2014 run 'cd fastify && ${cmds.install}' manually.`);
@@ -1306,7 +625,7 @@ async function installDeps2(dest, components, pm) {
1306
625
  case "frontend":
1307
626
  if (hasCommand(pmBin)) {
1308
627
  spinner7.start(`Installing Frontend dependencies (${cmds.install})`);
1309
- exec(cmds.install, join6(dest, "frontend"));
628
+ exec(cmds.install, join3(dest, "frontend"));
1310
629
  spinner7.stop("Frontend dependencies installed.");
1311
630
  } else {
1312
631
  p4.log.warn(`${pm} not found \u2014 run 'cd frontend && ${cmds.install}' manually.`);
@@ -1315,7 +634,7 @@ async function installDeps2(dest, components, pm) {
1315
634
  case "e2e":
1316
635
  if (hasCommand(pmBin)) {
1317
636
  spinner7.start(`Installing E2E dependencies (${cmds.install})`);
1318
- exec(cmds.install, join6(dest, "e2e"));
637
+ exec(cmds.install, join3(dest, "e2e"));
1319
638
  spinner7.stop("E2E dependencies installed.");
1320
639
  } else {
1321
640
  p4.log.warn(`${pm} not found \u2014 run 'cd e2e && ${cmds.install}' manually.`);
@@ -1324,7 +643,7 @@ async function installDeps2(dest, components, pm) {
1324
643
  case "mobile":
1325
644
  if (hasCommand("flutter")) {
1326
645
  spinner7.start("Installing Flutter dependencies");
1327
- exec("flutter pub get", join6(dest, "mobile"));
646
+ exec("flutter pub get", join3(dest, "mobile"));
1328
647
  spinner7.stop("Flutter dependencies installed.");
1329
648
  } else {
1330
649
  p4.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
@@ -1340,22 +659,22 @@ async function installDeps2(dest, components, pm) {
1340
659
  }
1341
660
 
1342
661
  // src/init.ts
1343
- import { existsSync as existsSync7 } from "fs";
1344
- import { readFile as readFile7 } from "fs/promises";
1345
- import { execSync as execSync4 } from "child_process";
1346
- 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";
1347
666
  import * as p5 from "@clack/prompts";
1348
667
 
1349
668
  // src/detect.ts
1350
- import { existsSync as existsSync6 } from "fs";
1351
- import { readdir as readdir2 } from "fs/promises";
1352
- 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";
1353
672
  async function detectComponents(cwd) {
1354
673
  const results = [];
1355
- const entries = await readdir2(cwd, { withFileTypes: true });
674
+ const entries = await readdir(cwd, { withFileTypes: true });
1356
675
  const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !EXCLUDE.has(e.name)).map((e) => e.name);
1357
676
  for (const dir of dirs) {
1358
- const full = join7(cwd, dir);
677
+ const full = join4(cwd, dir);
1359
678
  const detections = await scanDirectory(full, dir);
1360
679
  results.push(...detections);
1361
680
  }
@@ -1363,7 +682,7 @@ async function detectComponents(cwd) {
1363
682
  }
1364
683
  async function scanDirectory(dir, relPath) {
1365
684
  const results = [];
1366
- const pyproject = await readFileOrNull(join7(dir, "pyproject.toml"));
685
+ const pyproject = await readFileOrNull(join4(dir, "pyproject.toml"));
1367
686
  if (pyproject && /fastapi/i.test(pyproject)) {
1368
687
  results.push({
1369
688
  component: "fastapi",
@@ -1400,7 +719,7 @@ async function scanDirectory(dir, relPath) {
1400
719
  });
1401
720
  }
1402
721
  }
1403
- const pubspec = await readFileOrNull(join7(dir, "pubspec.yaml"));
722
+ const pubspec = await readFileOrNull(join4(dir, "pubspec.yaml"));
1404
723
  if (pubspec && /flutter:/i.test(pubspec)) {
1405
724
  results.push({
1406
725
  component: "mobile",
@@ -1409,7 +728,7 @@ async function scanDirectory(dir, relPath) {
1409
728
  evidence: "pubspec.yaml has flutter dependency"
1410
729
  });
1411
730
  }
1412
- 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"));
1413
732
  if (hasTf) {
1414
733
  results.push({
1415
734
  component: "infra",
@@ -1421,7 +740,7 @@ async function scanDirectory(dir, relPath) {
1421
740
  return results;
1422
741
  }
1423
742
  async function readPkg(dir) {
1424
- const content = await readFileOrNull(join7(dir, "package.json"));
743
+ const content = await readFileOrNull(join4(dir, "package.json"));
1425
744
  if (!content) return null;
1426
745
  try {
1427
746
  return JSON.parse(content);
@@ -1434,7 +753,7 @@ async function readPkg(dir) {
1434
753
  async function init(cwd, localRepo) {
1435
754
  p5.intro("projx init");
1436
755
  const isLocal = !!localRepo;
1437
- if (existsSync7(join8(cwd, ".projx"))) {
756
+ if (existsSync5(join5(cwd, ".projx"))) {
1438
757
  p5.log.error("This project is already initialized. Use 'npx create-projx update' or 'npx create-projx add' instead.");
1439
758
  process.exit(1);
1440
759
  }
@@ -1494,15 +813,15 @@ async function init(cwd, localRepo) {
1494
813
  });
1495
814
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1496
815
  try {
1497
- 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"));
1498
817
  const version = pkg.version;
1499
818
  const applySpinner = p5.spinner();
1500
819
  applySpinner.start("Applying template");
1501
- 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);
1502
821
  applySpinner.stop("Template applied.");
1503
- if (existsSync7(join8(cwd, ".githooks"))) {
822
+ if (existsSync5(join5(cwd, ".githooks"))) {
1504
823
  try {
1505
- execSync4("git config core.hooksPath .githooks", { cwd, stdio: "pipe" });
824
+ execSync2("git config core.hooksPath .githooks", { cwd, stdio: "pipe" });
1506
825
  } catch {
1507
826
  }
1508
827
  }
@@ -1561,7 +880,7 @@ async function manualSelect(cwd) {
1561
880
  defaultValue: component
1562
881
  });
1563
882
  if (p5.isCancel(dir)) process.exit(0);
1564
- if (!existsSync7(join8(cwd, dir))) {
883
+ if (!existsSync5(join5(cwd, dir))) {
1565
884
  p5.log.warn(`${dir}/ does not exist \u2014 skipping.`);
1566
885
  continue;
1567
886
  }
@@ -1571,7 +890,7 @@ async function manualSelect(cwd) {
1571
890
  }
1572
891
  function isGitRepo2(cwd) {
1573
892
  try {
1574
- execSync4("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
893
+ execSync2("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
1575
894
  return true;
1576
895
  } catch {
1577
896
  return false;
@@ -1579,7 +898,7 @@ function isGitRepo2(cwd) {
1579
898
  }
1580
899
  function hasUncommittedChanges2(cwd) {
1581
900
  try {
1582
- const status = execSync4("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
901
+ const status = execSync2("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
1583
902
  return status.length > 0;
1584
903
  } catch {
1585
904
  return false;
@@ -1587,9 +906,8 @@ function hasUncommittedChanges2(cwd) {
1587
906
  }
1588
907
 
1589
908
  // src/pin.ts
1590
- import { existsSync as existsSync8 } from "fs";
1591
- import { readFile as readFile8, writeFile as writeFile4 } from "fs/promises";
1592
- import { join as join9 } from "path";
909
+ import { existsSync as existsSync6 } from "fs";
910
+ import { join as join6 } from "path";
1593
911
  import * as p6 from "@clack/prompts";
1594
912
  function classifyPattern(pattern, componentPaths) {
1595
913
  const dirToComponent = {};
@@ -1609,12 +927,11 @@ function classifyPattern(pattern, componentPaths) {
1609
927
  }
1610
928
  async function pin(cwd, patterns) {
1611
929
  p6.intro("projx pin");
1612
- const configPath = join9(cwd, ".projx");
1613
- if (!existsSync8(configPath)) {
930
+ if (!existsSync6(join6(cwd, ".projx"))) {
1614
931
  p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
1615
932
  process.exit(1);
1616
933
  }
1617
- const config = JSON.parse(await readFile8(configPath, "utf-8"));
934
+ const config = await readProjxConfig(cwd);
1618
935
  const componentPaths = (await discoverComponentsFromMarkers(cwd)).paths;
1619
936
  const rootAdds = [];
1620
937
  const componentAdds = {};
@@ -1633,30 +950,27 @@ async function pin(cwd, patterns) {
1633
950
  }
1634
951
  for (const [component, additions] of Object.entries(componentAdds)) {
1635
952
  const dir = componentPaths[component];
1636
- const markerPath = join9(cwd, dir, COMPONENT_MARKER);
1637
- try {
1638
- const data = JSON.parse(await readFile8(markerPath, "utf-8"));
1639
- const existing = data.skip ?? [];
1640
- const merged = [.../* @__PURE__ */ new Set([...existing, ...additions])];
1641
- const added = merged.length - existing.length;
1642
- if (added > 0) {
1643
- data.skip = merged;
1644
- await writeFile4(markerPath, JSON.stringify(data, null, 2) + "\n");
1645
- p6.log.success(`${component}: pinned ${additions.join(", ")}`);
1646
- } else {
1647
- p6.log.info(`${component}: already pinned.`);
1648
- }
1649
- } catch {
953
+ const marker = await readComponentMarker(join6(cwd, dir));
954
+ if (!marker) {
1650
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.`);
1651
966
  }
1652
967
  }
1653
968
  if (rootAdds.length > 0) {
1654
- const existing = config.skip ?? [];
969
+ const existing = Array.isArray(config.skip) ? config.skip : [];
1655
970
  const merged = [.../* @__PURE__ */ new Set([...existing, ...rootAdds])];
1656
971
  const added = merged.length - existing.length;
1657
972
  if (added > 0) {
1658
- config.skip = merged;
1659
- await writeFile4(configPath, JSON.stringify(config, null, 2) + "\n");
973
+ await writeProjxConfig(cwd, { ...config, skip: merged });
1660
974
  p6.log.success(`root: pinned ${rootAdds.join(", ")}`);
1661
975
  } else {
1662
976
  p6.log.info("root: already pinned.");
@@ -1666,12 +980,11 @@ async function pin(cwd, patterns) {
1666
980
  }
1667
981
  async function unpin(cwd, patterns) {
1668
982
  p6.intro("projx unpin");
1669
- const configPath = join9(cwd, ".projx");
1670
- if (!existsSync8(configPath)) {
983
+ if (!existsSync6(join6(cwd, ".projx"))) {
1671
984
  p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
1672
985
  process.exit(1);
1673
986
  }
1674
- const config = JSON.parse(await readFile8(configPath, "utf-8"));
987
+ const config = await readProjxConfig(cwd);
1675
988
  const componentPaths = (await discoverComponentsFromMarkers(cwd)).paths;
1676
989
  const rootRemoves = [];
1677
990
  const componentRemoves = {};
@@ -1686,38 +999,27 @@ async function unpin(cwd, patterns) {
1686
999
  }
1687
1000
  for (const [component, removals] of Object.entries(componentRemoves)) {
1688
1001
  const dir = componentPaths[component];
1689
- const markerPath = join9(cwd, dir, COMPONENT_MARKER);
1690
- try {
1691
- const data = JSON.parse(await readFile8(markerPath, "utf-8"));
1692
- const existing = data.skip ?? [];
1693
- const filtered = existing.filter((s) => !removals.includes(s));
1694
- const removed = existing.length - filtered.length;
1695
- if (removed > 0) {
1696
- if (filtered.length > 0) {
1697
- data.skip = filtered;
1698
- } else {
1699
- delete data.skip;
1700
- }
1701
- await writeFile4(markerPath, JSON.stringify(data, null, 2) + "\n");
1702
- p6.log.success(`${component}: unpinned ${removals.join(", ")}`);
1703
- } else {
1704
- p6.log.info(`${component}: not found in skip list.`);
1705
- }
1706
- } catch {
1002
+ const marker = await readComponentMarker(join6(cwd, dir));
1003
+ if (!marker) {
1707
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.`);
1708
1015
  }
1709
1016
  }
1710
1017
  if (rootRemoves.length > 0) {
1711
- const existing = config.skip ?? [];
1018
+ const existing = Array.isArray(config.skip) ? config.skip : [];
1712
1019
  const filtered = existing.filter((s) => !rootRemoves.includes(s));
1713
1020
  const removed = existing.length - filtered.length;
1714
1021
  if (removed > 0) {
1715
- if (filtered.length > 0) {
1716
- config.skip = filtered;
1717
- } else {
1718
- delete config.skip;
1719
- }
1720
- await writeFile4(configPath, JSON.stringify(config, null, 2) + "\n");
1022
+ await writeProjxConfig(cwd, { ...config, skip: filtered });
1721
1023
  p6.log.success(`root: unpinned ${rootRemoves.join(", ")}`);
1722
1024
  } else {
1723
1025
  p6.log.info("root: not found in skip list.");
@@ -1727,24 +1029,24 @@ async function unpin(cwd, patterns) {
1727
1029
  }
1728
1030
  async function listPins(cwd) {
1729
1031
  p6.intro("projx pin --list");
1730
- const configPath = join9(cwd, ".projx");
1731
- if (!existsSync8(configPath)) {
1032
+ if (!existsSync6(join6(cwd, ".projx"))) {
1732
1033
  p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
1733
1034
  process.exit(1);
1734
1035
  }
1735
- const config = JSON.parse(await readFile8(configPath, "utf-8"));
1036
+ const config = await readProjxConfig(cwd);
1736
1037
  const { components: discovered, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
1737
1038
  let hasAny = false;
1738
- if (config.skip && config.skip.length > 0) {
1039
+ const rootSkip = Array.isArray(config.skip) ? config.skip : [];
1040
+ if (rootSkip.length > 0) {
1739
1041
  hasAny = true;
1740
1042
  p6.log.info("root:");
1741
- for (const s of config.skip) {
1043
+ for (const s of rootSkip) {
1742
1044
  p6.log.info(` ${s}`);
1743
1045
  }
1744
1046
  }
1745
1047
  for (const component of discovered) {
1746
1048
  const dir = componentPaths[component];
1747
- const marker = await readComponentMarker(join9(cwd, dir));
1049
+ const marker = await readComponentMarker(join6(cwd, dir));
1748
1050
  if (marker?.skip && marker.skip.length > 0) {
1749
1051
  hasAny = true;
1750
1052
  const label = dir !== component ? `${component} (${dir}/)` : `${component}`;
@@ -1761,15 +1063,15 @@ async function listPins(cwd) {
1761
1063
  }
1762
1064
 
1763
1065
  // src/doctor.ts
1764
- import { existsSync as existsSync9 } from "fs";
1765
- import { readFile as readFile9, readdir as readdir3 } from "fs/promises";
1766
- import { execSync as execSync5 } from "child_process";
1767
- 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";
1768
1070
  import * as p7 from "@clack/prompts";
1769
1071
  async function checkConfig(cwd) {
1770
1072
  const results = [];
1771
- const configPath = join10(cwd, ".projx");
1772
- if (!existsSync9(configPath)) {
1073
+ const configPath = join7(cwd, ".projx");
1074
+ if (!existsSync7(configPath)) {
1773
1075
  results.push({
1774
1076
  name: ".projx exists",
1775
1077
  status: "fail",
@@ -1778,44 +1080,41 @@ async function checkConfig(cwd) {
1778
1080
  });
1779
1081
  return { results };
1780
1082
  }
1781
- let config;
1782
- try {
1783
- config = JSON.parse(await readFile9(configPath, "utf-8"));
1784
- } catch {
1083
+ const rootConfig = await readProjxConfig(cwd);
1084
+ if (Object.keys(rootConfig).length === 0) {
1785
1085
  results.push({
1786
1086
  name: ".projx valid JSON",
1787
1087
  status: "fail",
1788
- message: ".projx contains invalid JSON."
1088
+ message: ".projx contains invalid JSON or is empty."
1789
1089
  });
1790
1090
  return { results };
1791
1091
  }
1792
- results.push({ name: ".projx exists", status: "pass", message: `v${config.version}` });
1793
- 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) {
1794
1094
  results.push({
1795
1095
  name: ".projx fields",
1796
- status: "fail",
1797
- message: "Missing required fields (version, components)."
1798
- });
1799
- return { results };
1800
- }
1801
- const invalid = config.components.filter((c) => !COMPONENTS.includes(c));
1802
- if (invalid.length > 0) {
1803
- results.push({
1804
- name: "component names",
1805
1096
  status: "warn",
1806
- message: `Unknown components: ${invalid.join(", ")}`
1097
+ message: "Missing version field."
1807
1098
  });
1808
- } else {
1809
- results.push({ name: "component names", status: "pass", message: `${config.components.length} valid` });
1810
1099
  }
1811
- return { results, config };
1100
+ return { results, rootConfig };
1812
1101
  }
1813
- async function checkComponents(cwd, config, componentPaths) {
1102
+ async function checkComponents(cwd, components, componentPaths) {
1814
1103
  const results = [];
1815
- 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) {
1816
1115
  const dir = componentPaths[component];
1817
- const fullDir = join10(cwd, dir);
1818
- if (!existsSync9(fullDir)) {
1116
+ const fullDir = join7(cwd, dir);
1117
+ if (!existsSync7(fullDir)) {
1819
1118
  results.push({
1820
1119
  name: `${component} directory`,
1821
1120
  status: "fail",
@@ -1833,53 +1132,28 @@ async function checkComponents(cwd, config, componentPaths) {
1833
1132
  });
1834
1133
  continue;
1835
1134
  }
1836
- if (!marker.components.includes(component)) {
1837
- results.push({
1838
- name: `${component} marker`,
1839
- status: "warn",
1840
- message: `Marker in ${dir}/ does not list "${component}".`
1841
- });
1842
- } else {
1843
- const label = dir !== component ? `${dir}/ (${component})` : `${component}/`;
1844
- results.push({ name: `${component} marker`, status: "pass", message: label });
1845
- }
1846
- }
1847
- try {
1848
- const entries = await readdir3(cwd, { withFileTypes: true });
1849
- for (const entry of entries) {
1850
- if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
1851
- const markerPath = join10(cwd, entry.name, COMPONENT_MARKER);
1852
- if (!existsSync9(markerPath)) continue;
1853
- const isKnown = Object.values(componentPaths).includes(entry.name);
1854
- if (!isKnown) {
1855
- results.push({
1856
- name: `orphan marker`,
1857
- status: "warn",
1858
- message: `${entry.name}/ has a ${COMPONENT_MARKER} but is not in .projx components.`
1859
- });
1860
- }
1861
- }
1862
- } catch {
1135
+ const label = dir !== component ? `${dir}/ (${component})` : `${component}/`;
1136
+ results.push({ name: `${component} marker`, status: "pass", message: label });
1863
1137
  }
1864
1138
  return results;
1865
1139
  }
1866
1140
  function checkGit(cwd, fix) {
1867
1141
  const results = [];
1868
1142
  try {
1869
- execSync5("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
1143
+ execSync3("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
1870
1144
  results.push({ name: "git repo", status: "pass", message: "OK" });
1871
1145
  } catch {
1872
1146
  results.push({ name: "git repo", status: "fail", message: "Not a git repository." });
1873
1147
  return results;
1874
1148
  }
1875
1149
  try {
1876
- 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();
1877
1151
  results.push({ name: "baseline ref", status: "pass", message: ref.slice(0, 8) });
1878
1152
  } catch {
1879
1153
  if (fix) {
1880
1154
  saveBaselineRef(cwd);
1881
1155
  try {
1882
- execSync5(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" });
1156
+ execSync3(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" });
1883
1157
  results.push({ name: "baseline ref", status: "pass", message: "Created from git history." });
1884
1158
  } catch {
1885
1159
  results.push({
@@ -1899,11 +1173,11 @@ function checkGit(cwd, fix) {
1899
1173
  }
1900
1174
  }
1901
1175
  try {
1902
- const worktrees = execSync5("git worktree list --porcelain", { cwd, stdio: "pipe" }).toString();
1176
+ const worktrees = execSync3("git worktree list --porcelain", { cwd, stdio: "pipe" }).toString();
1903
1177
  const stale = worktrees.split("\n").filter((l) => l.includes("projx-wt-") || l.includes("projx/tmp-"));
1904
1178
  if (stale.length > 0) {
1905
1179
  if (fix) {
1906
- execSync5("git worktree prune", { cwd, stdio: "pipe" });
1180
+ execSync3("git worktree prune", { cwd, stdio: "pipe" });
1907
1181
  results.push({ name: "worktrees", status: "pass", message: "Pruned stale worktrees." });
1908
1182
  } else {
1909
1183
  results.push({
@@ -1921,7 +1195,7 @@ function checkGit(cwd, fix) {
1921
1195
  results.push({ name: "worktrees", status: "pass", message: "OK" });
1922
1196
  }
1923
1197
  try {
1924
- const status = execSync5("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
1198
+ const status = execSync3("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
1925
1199
  if (status) {
1926
1200
  const count = status.split("\n").length;
1927
1201
  results.push({ name: "working tree", status: "warn", message: `${count} uncommitted change(s).` });
@@ -1932,26 +1206,25 @@ function checkGit(cwd, fix) {
1932
1206
  }
1933
1207
  return results;
1934
1208
  }
1935
- async function checkSkipPatterns(cwd, config, componentPaths) {
1209
+ async function checkSkipPatterns(cwd, rootConfig, components, componentPaths) {
1936
1210
  const results = [];
1937
- if (config.skip && config.skip.length > 0) {
1938
- for (const pattern of config.skip) {
1939
- const matches = await patternMatchesAnything(cwd, pattern);
1940
- if (!matches) {
1941
- results.push({
1942
- name: "root skip",
1943
- status: "warn",
1944
- message: `"${pattern}" matches no files \u2014 stale?`
1945
- });
1946
- }
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
+ });
1947
1220
  }
1948
1221
  }
1949
- for (const component of config.components) {
1222
+ for (const component of components) {
1950
1223
  const dir = componentPaths[component];
1951
- const marker = await readComponentMarker(join10(cwd, dir));
1224
+ const marker = await readComponentMarker(join7(cwd, dir));
1952
1225
  if (marker?.skip && marker.skip.length > 0) {
1953
1226
  for (const pattern of marker.skip) {
1954
- const matches = await patternMatchesAnything(join10(cwd, dir), pattern);
1227
+ const matches = await patternMatchesAnything(join7(cwd, dir), pattern);
1955
1228
  if (!matches) {
1956
1229
  results.push({
1957
1230
  name: `${component} skip`,
@@ -1962,23 +1235,23 @@ async function checkSkipPatterns(cwd, config, componentPaths) {
1962
1235
  }
1963
1236
  }
1964
1237
  }
1965
- if (results.length === 0 && (config.skip?.length || config.components.some(() => true))) {
1238
+ if (results.length === 0 && (rootSkip.length > 0 || components.length > 0)) {
1966
1239
  results.push({ name: "skip patterns", status: "pass", message: "All patterns match files." });
1967
1240
  }
1968
1241
  return results;
1969
1242
  }
1970
1243
  async function patternMatchesAnything(dir, pattern) {
1971
1244
  if (pattern === "**") return true;
1972
- if (!existsSync9(dir)) return false;
1245
+ if (!existsSync7(dir)) return false;
1973
1246
  const walk = async (current, base) => {
1974
1247
  let entries;
1975
1248
  try {
1976
- entries = await readdir3(current, { withFileTypes: true });
1249
+ entries = await readdir2(current, { withFileTypes: true });
1977
1250
  } catch {
1978
1251
  return false;
1979
1252
  }
1980
1253
  for (const entry of entries) {
1981
- const full = join10(current, entry.name);
1254
+ const full = join7(current, entry.name);
1982
1255
  const rel = full.slice(base.length + 1);
1983
1256
  if (entry.isDirectory()) {
1984
1257
  if (await walk(full, base)) return true;
@@ -1993,17 +1266,16 @@ async function patternMatchesAnything(dir, pattern) {
1993
1266
  async function doctor(cwd, fix = false) {
1994
1267
  p7.intro("projx doctor");
1995
1268
  const allResults = [];
1996
- const { results: configResults, config } = await checkConfig(cwd);
1269
+ const { results: configResults, rootConfig } = await checkConfig(cwd);
1997
1270
  allResults.push(...configResults);
1998
- if (!config) {
1271
+ if (!rootConfig) {
1999
1272
  printReport(allResults);
2000
1273
  process.exit(1);
2001
1274
  }
2002
- const { components: discovered, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
2003
- const resolvedConfig = { ...config, components: discovered.length > 0 ? discovered : config.components };
2004
- allResults.push(...await checkComponents(cwd, resolvedConfig, componentPaths));
1275
+ const { components, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
1276
+ allResults.push(...await checkComponents(cwd, components, componentPaths));
2005
1277
  allResults.push(...checkGit(cwd, fix));
2006
- allResults.push(...await checkSkipPatterns(cwd, resolvedConfig, componentPaths));
1278
+ allResults.push(...await checkSkipPatterns(cwd, rootConfig, components, componentPaths));
2007
1279
  printReport(allResults);
2008
1280
  const passed = allResults.filter((r) => r.status === "pass").length;
2009
1281
  const warns = allResults.filter((r) => r.status === "warn").length;
@@ -2027,10 +1299,10 @@ function printReport(results) {
2027
1299
  }
2028
1300
 
2029
1301
  // src/diff.ts
2030
- import { existsSync as existsSync10 } from "fs";
2031
- import { readFile as readFile10, mkdir as mkdir4, rm as rm3 } from "fs/promises";
2032
- import { join as join11 } from "path";
2033
- 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";
2034
1306
  import * as p8 from "@clack/prompts";
2035
1307
  function isSkipped(file, componentPaths, componentSkips, rootSkip) {
2036
1308
  for (const [component, dir] of Object.entries(componentPaths)) {
@@ -2053,23 +1325,21 @@ function fileComponent(file, componentPaths) {
2053
1325
  async function diff(cwd, localRepo) {
2054
1326
  p8.intro("projx diff");
2055
1327
  const isLocal = !!localRepo;
2056
- const configPath = join11(cwd, ".projx");
2057
- if (!existsSync10(configPath)) {
1328
+ if (!existsSync8(join8(cwd, ".projx"))) {
2058
1329
  p8.log.error("No .projx file found. Run 'npx create-projx init' first.");
2059
1330
  process.exit(1);
2060
1331
  }
2061
- const raw = JSON.parse(await readFile10(configPath, "utf-8"));
2062
- const { components: discovered, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
2063
- const config = { ...raw, components: discovered.length > 0 ? discovered : raw.components };
1332
+ const raw = await readProjxConfig(cwd);
1333
+ const { components, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
2064
1334
  const componentSkips = {};
2065
- for (const component of config.components) {
1335
+ for (const component of components) {
2066
1336
  const dir = componentPaths[component];
2067
- const marker = await readComponentMarker(join11(cwd, dir));
1337
+ const marker = await readComponentMarker(join8(cwd, dir));
2068
1338
  if (marker?.skip && marker.skip.length > 0) {
2069
1339
  componentSkips[component] = marker.skip;
2070
1340
  }
2071
1341
  }
2072
- const rootSkip = config.skip ?? [];
1342
+ const rootSkip = Array.isArray(raw.skip) ? raw.skip : [];
2073
1343
  const dlSpinner = p8.spinner();
2074
1344
  dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
2075
1345
  const repoDir = await downloadRepo(localRepo).catch((err) => {
@@ -2079,16 +1349,20 @@ async function diff(cwd, localRepo) {
2079
1349
  });
2080
1350
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
2081
1351
  try {
2082
- 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"));
2083
1353
  const version = pkg.version;
2084
- p8.log.info(`Current: v${config.version} \u2192 Template: v${version}`);
2085
- const name = detectProjectName(cwd, config.components, componentPaths);
2086
- 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") };
2087
1357
  const spinner7 = p8.spinner();
2088
1358
  spinner7.start("Analyzing changes");
2089
- const tmpTemplate = join11(tmpdir3(), `projx-diff-${Date.now()}`);
2090
- await mkdir4(tmpTemplate, { recursive: true });
2091
- 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
+ });
2092
1366
  const baselineRef = getBaselineRef(cwd);
2093
1367
  const templateFiles = await collectAllFiles(tmpTemplate, tmpTemplate);
2094
1368
  const analyses = [];
@@ -2098,16 +1372,16 @@ async function diff(cwd, localRepo) {
2098
1372
  analyses.push({ file, status: "skipped", component });
2099
1373
  continue;
2100
1374
  }
2101
- const oursPath = join11(cwd, file);
2102
- if (!existsSync10(oursPath)) {
1375
+ const oursPath = join8(cwd, file);
1376
+ if (!existsSync8(oursPath)) {
2103
1377
  analyses.push({ file, status: "new", component });
2104
1378
  continue;
2105
1379
  }
2106
1380
  let oursContent;
2107
1381
  let theirsContent;
2108
1382
  try {
2109
- oursContent = await readFile10(oursPath, "utf-8");
2110
- theirsContent = await readFile10(join11(tmpTemplate, file), "utf-8");
1383
+ oursContent = await readFile5(oursPath, "utf-8");
1384
+ theirsContent = await readFile5(join8(tmpTemplate, file), "utf-8");
2111
1385
  } catch {
2112
1386
  continue;
2113
1387
  }
@@ -2132,7 +1406,7 @@ async function diff(cwd, localRepo) {
2132
1406
  analyses.push({ file, status: "needs-merge", component });
2133
1407
  }
2134
1408
  }
2135
- await rm3(tmpTemplate, { recursive: true, force: true });
1409
+ await rm(tmpTemplate, { recursive: true, force: true });
2136
1410
  spinner7.stop("Analysis complete.");
2137
1411
  const groups = {
2138
1412
  "new": [],
@@ -2181,9 +1455,9 @@ async function diff(cwd, localRepo) {
2181
1455
  }
2182
1456
 
2183
1457
  // src/gen.ts
2184
- import { existsSync as existsSync11 } from "fs";
2185
- import { readFile as readFile11, writeFile as writeFile5, mkdir as mkdir5 } from "fs/promises";
2186
- 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";
2187
1461
  import * as p9 from "@clack/prompts";
2188
1462
  var FIELD_TYPES = ["string", "number", "boolean", "date", "datetime", "text", "json"];
2189
1463
  function toPascal(s) {
@@ -2840,15 +2114,9 @@ async function resolvePrimaryBackend(cwd, hasFastapi, hasFastify, backendFlag) {
2840
2114
  if (backendFlag) return backendFlag;
2841
2115
  if (hasFastapi && !hasFastify) return "fastapi";
2842
2116
  if (hasFastify && !hasFastapi) return "fastify";
2843
- const configPath = join12(cwd, ".projx");
2844
- if (existsSync11(configPath)) {
2845
- try {
2846
- const data = JSON.parse(await readFile11(configPath, "utf-8"));
2847
- if (data.primaryBackend === "fastapi" || data.primaryBackend === "fastify") {
2848
- return data.primaryBackend;
2849
- }
2850
- } catch {
2851
- }
2117
+ const config = await readProjxConfig(cwd);
2118
+ if (config.primaryBackend === "fastapi" || config.primaryBackend === "fastify") {
2119
+ return config.primaryBackend;
2852
2120
  }
2853
2121
  if (!process.stdin.isTTY) return "fastify";
2854
2122
  const choice = await p9.select({
@@ -2860,23 +2128,17 @@ async function resolvePrimaryBackend(cwd, hasFastapi, hasFastify, backendFlag) {
2860
2128
  initialValue: "fastify"
2861
2129
  });
2862
2130
  if (p9.isCancel(choice)) process.exit(0);
2863
- try {
2864
- const data = JSON.parse(await readFile11(configPath, "utf-8"));
2865
- data.primaryBackend = choice;
2866
- await writeFile5(configPath, JSON.stringify(data, null, 2) + "\n");
2867
- p9.log.success(`Saved primaryBackend: ${choice} to .projx`);
2868
- } catch {
2869
- }
2131
+ await writeProjxConfig(cwd, { ...config, primaryBackend: choice });
2132
+ p9.log.success(`Saved primaryBackend: ${choice} to .projx`);
2870
2133
  return choice;
2871
2134
  }
2872
2135
  async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2873
2136
  p9.intro(`projx gen entity ${entityName}`);
2874
- const configPath = join12(cwd, ".projx");
2875
- if (!existsSync11(configPath)) {
2137
+ if (!existsSync9(join9(cwd, ".projx"))) {
2876
2138
  p9.log.error("No .projx file found. Run 'npx create-projx init' first.");
2877
2139
  process.exit(1);
2878
2140
  }
2879
- const projxData = JSON.parse(await readFile11(configPath, "utf-8"));
2141
+ const projxData = await readProjxConfig(cwd);
2880
2142
  const pmName = projxData.packageManager ?? "npm";
2881
2143
  const pm = pmCommands(pmName);
2882
2144
  const { components: discovered, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
@@ -2913,37 +2175,37 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2913
2175
  const generated = [];
2914
2176
  if (genFastapi) {
2915
2177
  const dir = componentPaths.fastapi;
2916
- const entityDir = join12(cwd, dir, "src/entities", toSnake(config.name));
2917
- if (existsSync11(entityDir)) {
2178
+ const entityDir = join9(cwd, dir, "src/entities", toSnake(config.name));
2179
+ if (existsSync9(entityDir)) {
2918
2180
  p9.log.warn(`${dir}/src/entities/${toSnake(config.name)}/ already exists. Skipping FastAPI.`);
2919
2181
  } else {
2920
- await mkdir5(entityDir, { recursive: true });
2921
- await writeFile5(join12(entityDir, "_model.py"), generateFastAPIModel(config));
2922
- 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");
2923
2185
  generated.push(`${dir}/src/entities/${toSnake(config.name)}/_model.py`);
2924
2186
  generated.push(`${dir}/src/entities/${toSnake(config.name)}/__init__.py`);
2925
- const testsDir = join12(cwd, dir, "tests");
2926
- const testFile = join12(testsDir, `test_${toSnake(config.name)}_entity.py`);
2927
- if (existsSync11(testsDir) && !existsSync11(testFile)) {
2928
- 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));
2929
2191
  generated.push(`${dir}/tests/test_${toSnake(config.name)}_entity.py`);
2930
2192
  }
2931
2193
  }
2932
2194
  }
2933
2195
  if (genFastify) {
2934
2196
  const dir = componentPaths.fastify;
2935
- const moduleDir = join12(cwd, dir, "src/modules", toKebab(config.name));
2936
- if (existsSync11(moduleDir)) {
2197
+ const moduleDir = join9(cwd, dir, "src/modules", toKebab(config.name));
2198
+ if (existsSync9(moduleDir)) {
2937
2199
  p9.log.warn(`${dir}/src/modules/${toKebab(config.name)}/ already exists. Skipping Fastify.`);
2938
2200
  } else {
2939
- await mkdir5(moduleDir, { recursive: true });
2940
- await writeFile5(join12(moduleDir, "schemas.ts"), generateFastifySchemas(config));
2941
- 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));
2942
2204
  generated.push(`${dir}/src/modules/${toKebab(config.name)}/schemas.ts`);
2943
2205
  generated.push(`${dir}/src/modules/${toKebab(config.name)}/index.ts`);
2944
- const appPath = join12(cwd, dir, "src/app.ts");
2945
- if (existsSync11(appPath)) {
2946
- 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");
2947
2209
  const importLine = `import './modules/${toKebab(config.name)}/index.js';`;
2948
2210
  if (!appContent.includes(importLine)) {
2949
2211
  const updated = appContent.replace(
@@ -2952,62 +2214,62 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2952
2214
  `
2953
2215
  );
2954
2216
  if (updated !== appContent) {
2955
- await writeFile5(appPath, updated);
2217
+ await writeFile(appPath, updated);
2956
2218
  generated.push(`${dir}/src/app.ts (import added)`);
2957
2219
  }
2958
2220
  }
2959
2221
  }
2960
- const prismaPath = join12(cwd, dir, "prisma/schema.prisma");
2961
- if (existsSync11(prismaPath)) {
2962
- 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");
2963
2225
  const modelName = `model ${toPascal(config.name)}`;
2964
2226
  if (!prismaContent.includes(modelName)) {
2965
2227
  const prismaModel = generatePrismaModel(config);
2966
- await writeFile5(prismaPath, prismaContent.trimEnd() + "\n\n" + prismaModel + "\n");
2228
+ await writeFile(prismaPath, prismaContent.trimEnd() + "\n\n" + prismaModel + "\n");
2967
2229
  generated.push(`${dir}/prisma/schema.prisma (model added)`);
2968
2230
  }
2969
2231
  }
2970
- const testsModulesDir = join12(cwd, dir, "tests/modules");
2971
- const fastifyTestFile = join12(testsModulesDir, `${toKebab(config.name)}.test.ts`);
2972
- if (existsSync11(testsModulesDir) && !existsSync11(fastifyTestFile)) {
2973
- 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));
2974
2236
  generated.push(`${dir}/tests/modules/${toKebab(config.name)}.test.ts`);
2975
2237
  }
2976
2238
  }
2977
2239
  }
2978
2240
  if (hasFrontend) {
2979
2241
  const dir = componentPaths.frontend;
2980
- const typesDir = join12(cwd, dir, "src/types");
2242
+ const typesDir = join9(cwd, dir, "src/types");
2981
2243
  const fileName = toKebab(config.name) + ".ts";
2982
- const filePath = join12(typesDir, fileName);
2983
- if (existsSync11(filePath)) {
2244
+ const filePath = join9(typesDir, fileName);
2245
+ if (existsSync9(filePath)) {
2984
2246
  p9.log.warn(`${dir}/src/types/${fileName} already exists. Skipping frontend types.`);
2985
2247
  } else {
2986
- await mkdir5(typesDir, { recursive: true });
2987
- await writeFile5(filePath, generateFrontendInterface(config));
2248
+ await mkdir3(typesDir, { recursive: true });
2249
+ await writeFile(filePath, generateFrontendInterface(config));
2988
2250
  generated.push(`${dir}/src/types/${fileName}`);
2989
- const barrelPath = join12(typesDir, "index.ts");
2251
+ const barrelPath = join9(typesDir, "index.ts");
2990
2252
  const exportLine = `export * from './${toKebab(config.name)}';`;
2991
- if (existsSync11(barrelPath)) {
2992
- const content = await readFile11(barrelPath, "utf-8");
2253
+ if (existsSync9(barrelPath)) {
2254
+ const content = await readFile6(barrelPath, "utf-8");
2993
2255
  if (!content.includes(exportLine)) {
2994
- await writeFile5(barrelPath, content.trimEnd() + "\n" + exportLine + "\n");
2256
+ await writeFile(barrelPath, content.trimEnd() + "\n" + exportLine + "\n");
2995
2257
  }
2996
2258
  } else {
2997
- await writeFile5(barrelPath, exportLine + "\n");
2259
+ await writeFile(barrelPath, exportLine + "\n");
2998
2260
  }
2999
2261
  generated.push(`${dir}/src/types/index.ts`);
3000
2262
  }
3001
2263
  }
3002
2264
  if (hasMobile) {
3003
2265
  const dir = componentPaths.mobile;
3004
- const entityDir = join12(cwd, dir, "lib/entities", toSnake(config.name));
3005
- const modelPath = join12(entityDir, "model.dart");
3006
- 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)) {
3007
2269
  p9.log.warn(`${dir}/lib/entities/${toSnake(config.name)}/model.dart already exists. Skipping mobile model.`);
3008
2270
  } else {
3009
- await mkdir5(entityDir, { recursive: true });
3010
- await writeFile5(modelPath, generateDartModel(config));
2271
+ await mkdir3(entityDir, { recursive: true });
2272
+ await writeFile(modelPath, generateDartModel(config));
3011
2273
  generated.push(`${dir}/lib/entities/${toSnake(config.name)}/model.dart`);
3012
2274
  }
3013
2275
  }
@@ -3047,9 +2309,9 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
3047
2309
  }
3048
2310
 
3049
2311
  // src/sync.ts
3050
- import { existsSync as existsSync12, readFileSync as readFileSync2 } from "fs";
3051
- import { writeFile as writeFile6, mkdir as mkdir6 } from "fs/promises";
3052
- 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";
3053
2315
  import * as p10 from "@clack/prompts";
3054
2316
  function toPascal2(s) {
3055
2317
  return s.replace(/(?:^|[_\-\s])([a-zA-Z])/g, (_, c) => c.toUpperCase());
@@ -3220,8 +2482,8 @@ function generateDartModel2(entity) {
3220
2482
  }
3221
2483
  async function sync(cwd, url) {
3222
2484
  p10.intro("projx sync");
3223
- const configPath = join13(cwd, ".projx");
3224
- if (!existsSync12(configPath)) {
2485
+ const configPath = join10(cwd, ".projx");
2486
+ if (!existsSync10(configPath)) {
3225
2487
  p10.log.error("No .projx file found. Run 'npx create-projx init' first.");
3226
2488
  process.exit(1);
3227
2489
  }
@@ -3253,18 +2515,18 @@ async function sync(cwd, url) {
3253
2515
  const generated = [];
3254
2516
  if (hasFrontend) {
3255
2517
  const dir = componentPaths.frontend;
3256
- const typesDir = join13(cwd, dir, "src/types");
3257
- await mkdir6(typesDir, { recursive: true });
2518
+ const typesDir = join10(cwd, dir, "src/types");
2519
+ await mkdir4(typesDir, { recursive: true });
3258
2520
  const barrelExports = [];
3259
2521
  for (const entity of meta.entities) {
3260
2522
  const fileName = toKebab(toSnake(entity.name)) + ".ts";
3261
- const filePath = join13(typesDir, fileName);
3262
- await writeFile6(filePath, generateTsInterface(entity));
2523
+ const filePath = join10(typesDir, fileName);
2524
+ await writeFile2(filePath, generateTsInterface(entity));
3263
2525
  generated.push(`${dir}/src/types/${fileName}`);
3264
2526
  barrelExports.push(`export * from './${toKebab(toSnake(entity.name))}';`);
3265
2527
  }
3266
- await writeFile6(
3267
- join13(typesDir, "index.ts"),
2528
+ await writeFile2(
2529
+ join10(typesDir, "index.ts"),
3268
2530
  barrelExports.join("\n") + "\n"
3269
2531
  );
3270
2532
  generated.push(`${dir}/src/types/index.ts`);
@@ -3272,10 +2534,10 @@ async function sync(cwd, url) {
3272
2534
  if (hasMobile) {
3273
2535
  const dir = componentPaths.mobile;
3274
2536
  for (const entity of meta.entities) {
3275
- const entityDir = join13(cwd, dir, "lib/entities", toSnake(entity.name));
3276
- await mkdir6(entityDir, { recursive: true });
3277
- const modelPath = join13(entityDir, "model.dart");
3278
- 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));
3279
2541
  generated.push(`${dir}/lib/entities/${toSnake(entity.name)}/model.dart`);
3280
2542
  }
3281
2543
  }
@@ -3298,10 +2560,10 @@ async function sync(cwd, url) {
3298
2560
  function detectMetaUrl(cwd) {
3299
2561
  const envFiles = [".env", ".env.dev", ".env.local"];
3300
2562
  for (const envFile of envFiles) {
3301
- const envPath = join13(cwd, envFile);
3302
- if (existsSync12(envPath)) {
2563
+ const envPath = join10(cwd, envFile);
2564
+ if (existsSync10(envPath)) {
3303
2565
  try {
3304
- const content = readFileSync2(envPath, "utf-8");
2566
+ const content = readFileSync(envPath, "utf-8");
3305
2567
  const match = content.match(/VITE_API_URL\s*=\s*(.+)/);
3306
2568
  if (match) {
3307
2569
  const base = match[1].trim().replace(/["']/g, "");
@@ -3317,10 +2579,10 @@ function detectMetaUrl(cwd) {
3317
2579
  "frontend/.env.dev"
3318
2580
  ];
3319
2581
  for (const envFile of frontendEnvFiles) {
3320
- const envPath = join13(cwd, envFile);
3321
- if (existsSync12(envPath)) {
2582
+ const envPath = join10(cwd, envFile);
2583
+ if (existsSync10(envPath)) {
3322
2584
  try {
3323
- const content = readFileSync2(envPath, "utf-8");
2585
+ const content = readFileSync(envPath, "utf-8");
3324
2586
  const match = content.match(/VITE_API_URL\s*=\s*(.+)/);
3325
2587
  if (match) {
3326
2588
  const base = match[1].trim().replace(/["']/g, "");
@@ -3390,7 +2652,7 @@ function parseArgs() {
3390
2652
  continue;
3391
2653
  }
3392
2654
  if (arg === "--local") {
3393
- localRepo = resolve2(args[++i] || ".");
2655
+ localRepo = resolve(args[++i] || ".");
3394
2656
  continue;
3395
2657
  }
3396
2658
  if (arg === "--no-git") {
@@ -3562,8 +2824,8 @@ async function main() {
3562
2824
  opts.git = options.git ?? opts.git;
3563
2825
  opts.install = options.install ?? opts.install;
3564
2826
  }
3565
- const dest = resolve2(process.cwd(), opts.name);
3566
- if (existsSync13(dest)) {
2827
+ const dest = resolve(process.cwd(), opts.name);
2828
+ if (existsSync11(dest)) {
3567
2829
  console.error(`Error: ${dest} already exists.`);
3568
2830
  process.exit(1);
3569
2831
  }