create-projx 1.6.5 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -37
- package/dist/{baseline-PZM4KJJW.js → baseline-FKCXQFRD.js} +2 -2
- package/dist/{chunk-XQ7FE4U3.js → chunk-N4WD4VN3.js} +158 -19
- package/dist/{chunk-6YRBHJ2V.js → chunk-OLPF7FAN.js} +26 -9
- package/dist/index.js +1607 -603
- package/dist/{utils-AVKSTHIF.js → utils-4G3HNHES.js} +5 -1
- package/package.json +11 -6
- package/src/templates/README.md.ejs +21 -4
- package/src/templates/ci.yml.ejs +167 -37
- package/src/templates/docker-compose.yml.ejs +72 -5
- package/src/templates/pre-commit.ejs +28 -4
- package/src/templates/setup.sh.ejs +75 -1
- package/src/templates/docker-compose.dev.yml.ejs +0 -189
package/dist/index.js
CHANGED
|
@@ -9,12 +9,14 @@ import {
|
|
|
9
9
|
matchesSkip,
|
|
10
10
|
saveBaselineRef,
|
|
11
11
|
writeTemplateToDir
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-N4WD4VN3.js";
|
|
13
13
|
import {
|
|
14
14
|
COMPONENTS,
|
|
15
15
|
COMPONENT_MARKER,
|
|
16
16
|
DEFAULT_ROOT_SKIP_PATTERNS,
|
|
17
17
|
EXCLUDE,
|
|
18
|
+
KNOWN_FEATURES,
|
|
19
|
+
ORM_PROVIDERS,
|
|
18
20
|
PACKAGE_MANAGERS,
|
|
19
21
|
cleanupRepo,
|
|
20
22
|
detectPackageManager,
|
|
@@ -29,22 +31,354 @@ import {
|
|
|
29
31
|
readComponentMarker,
|
|
30
32
|
readFileOrNull,
|
|
31
33
|
readProjxConfig,
|
|
34
|
+
render,
|
|
32
35
|
toKebab,
|
|
33
36
|
toSnake,
|
|
34
|
-
toTitle,
|
|
35
37
|
writeComponentMarker,
|
|
36
38
|
writeProjxConfig
|
|
37
|
-
} from "./chunk-
|
|
39
|
+
} from "./chunk-OLPF7FAN.js";
|
|
38
40
|
|
|
39
41
|
// src/index.ts
|
|
40
42
|
import { existsSync as existsSync11 } from "fs";
|
|
41
43
|
import { resolve } from "path";
|
|
42
44
|
|
|
45
|
+
// src/features.ts
|
|
46
|
+
import { existsSync } from "fs";
|
|
47
|
+
import { cp, mkdir, readFile, readdir, writeFile } from "fs/promises";
|
|
48
|
+
import { dirname, join, relative } from "path";
|
|
49
|
+
function parseFeatureFlag(input) {
|
|
50
|
+
const trimmed = input.trim();
|
|
51
|
+
if (!trimmed) return [];
|
|
52
|
+
const pieces = trimmed.split(",");
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const raw of pieces) {
|
|
55
|
+
const piece = raw.trim();
|
|
56
|
+
if (!piece) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Invalid feature flag: empty target in "${input}". Use "component[:instance],..." form.`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const parts = piece.split(":").map((p10) => p10.trim());
|
|
62
|
+
if (parts.length > 2 || parts.some((p10) => !p10)) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Invalid feature flag target "${piece}". Expected "component" or "component:instance".`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const [component, instance] = parts;
|
|
68
|
+
if (!COMPONENTS.includes(component)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Unknown component "${component}". Valid: ${COMPONENTS.join(", ")}.`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
out.push(
|
|
74
|
+
instance ? { component, instance } : { component }
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
function validateFeatureTargets(targets, instances, _components, supports) {
|
|
80
|
+
const resolved = [];
|
|
81
|
+
for (const t of targets) {
|
|
82
|
+
if (supports && !supports.includes(t.component)) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Feature does not support ${t.component}. Supported: ${supports.join(", ")}.`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const candidates = instances.filter((i) => i.type === t.component).sort((a, b) => a.path.localeCompare(b.path));
|
|
88
|
+
if (candidates.length === 0) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`No ${t.component} instance found. Add it to --components first.`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (t.instance) {
|
|
94
|
+
const match = candidates.find((c) => c.path === t.instance);
|
|
95
|
+
if (!match) {
|
|
96
|
+
const known = candidates.map((c) => c.path).join(", ");
|
|
97
|
+
throw new Error(
|
|
98
|
+
`No ${t.component} instance named "${t.instance}". Known: ${known}.`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
resolved.push({
|
|
102
|
+
component: t.component,
|
|
103
|
+
instance: t.instance,
|
|
104
|
+
path: match.path
|
|
105
|
+
});
|
|
106
|
+
} else {
|
|
107
|
+
resolved.push({
|
|
108
|
+
component: t.component,
|
|
109
|
+
instance: candidates[0].path,
|
|
110
|
+
path: candidates[0].path
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return resolved;
|
|
115
|
+
}
|
|
116
|
+
async function applyFeatures(opts) {
|
|
117
|
+
const featureRoot = join(opts.repoDir, "features");
|
|
118
|
+
for (const [name, raw] of Object.entries(opts.features)) {
|
|
119
|
+
if (!raw) continue;
|
|
120
|
+
const targets = parseFeatureFlag(raw);
|
|
121
|
+
const featureDir = join(featureRoot, name);
|
|
122
|
+
if (!existsSync(featureDir)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Feature "${name}" not found at ${featureDir}. Pin or update the repo to a version that ships this feature.`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const manifest = JSON.parse(
|
|
128
|
+
await readFile(join(featureDir, "feature.json"), "utf-8")
|
|
129
|
+
);
|
|
130
|
+
if (manifest.requiresOrm && manifest.requiresOrm.length > 0) {
|
|
131
|
+
const orm = opts.vars.orm ?? "prisma";
|
|
132
|
+
if (!manifest.requiresOrm.includes(orm)) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Feature "${name}" requires --orm ${manifest.requiresOrm.join(" or ")} (got "${orm}").`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const resolved = validateFeatureTargets(
|
|
139
|
+
targets,
|
|
140
|
+
opts.instances,
|
|
141
|
+
opts.components,
|
|
142
|
+
manifest.supports
|
|
143
|
+
);
|
|
144
|
+
await applyFeature({
|
|
145
|
+
feature: name,
|
|
146
|
+
featureRoot,
|
|
147
|
+
targets: resolved,
|
|
148
|
+
dest: opts.dest,
|
|
149
|
+
vars: opts.vars
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async function applyFeature(opts) {
|
|
154
|
+
const featureDir = join(opts.featureRoot, opts.feature);
|
|
155
|
+
if (!existsSync(featureDir)) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Feature "${opts.feature}" not found at ${featureDir}. Check feature name and repo version.`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
const manifest = await readManifest(featureDir, opts.feature);
|
|
161
|
+
for (const target of opts.targets) {
|
|
162
|
+
if (!manifest.supports.includes(target.component)) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Feature "${opts.feature}" does not support ${target.component}. Supported: ${manifest.supports.join(", ")}.`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
await applyTarget({
|
|
168
|
+
featureDir,
|
|
169
|
+
featureName: opts.feature,
|
|
170
|
+
manifest,
|
|
171
|
+
target,
|
|
172
|
+
dest: opts.dest,
|
|
173
|
+
vars: { ...opts.vars, inst: target }
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function readManifest(featureDir, feature) {
|
|
178
|
+
const path = join(featureDir, "feature.json");
|
|
179
|
+
if (!existsSync(path)) {
|
|
180
|
+
throw new Error(`Feature "${feature}" missing feature.json at ${path}.`);
|
|
181
|
+
}
|
|
182
|
+
const raw = await readFile(path, "utf-8");
|
|
183
|
+
const manifest = JSON.parse(raw);
|
|
184
|
+
if (!manifest.name || !Array.isArray(manifest.supports)) {
|
|
185
|
+
throw new Error(`Feature "${feature}" manifest is malformed.`);
|
|
186
|
+
}
|
|
187
|
+
return manifest;
|
|
188
|
+
}
|
|
189
|
+
async function applyTarget(args2) {
|
|
190
|
+
const stackDir = join(args2.featureDir, args2.target.component);
|
|
191
|
+
if (!existsSync(stackDir)) return;
|
|
192
|
+
const targetPath = join(args2.dest, args2.target.path);
|
|
193
|
+
if (!existsSync(targetPath)) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Target instance path ${args2.target.path} not found in ${args2.dest}.`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const orm = typeof args2.vars.orm === "string" ? args2.vars.orm : void 0;
|
|
199
|
+
const ormDir = orm ? join(stackDir, orm) : void 0;
|
|
200
|
+
const hasOrmDir = ormDir !== void 0 && existsSync(ormDir);
|
|
201
|
+
const ormPatchNames = /* @__PURE__ */ new Set();
|
|
202
|
+
if (hasOrmDir) {
|
|
203
|
+
const ormPatchesDir = join(ormDir, "patches");
|
|
204
|
+
if (existsSync(ormPatchesDir)) {
|
|
205
|
+
for (const f of await readdir(ormPatchesDir)) {
|
|
206
|
+
if (f.endsWith(".json")) ormPatchNames.add(f);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const sub of ["files", join("common", "files")]) {
|
|
211
|
+
const filesDir = join(stackDir, sub);
|
|
212
|
+
if (existsSync(filesDir)) {
|
|
213
|
+
await renderFilesInto(filesDir, targetPath, args2.vars);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const sub of ["patches", join("common", "patches")]) {
|
|
217
|
+
const patchesDir = join(stackDir, sub);
|
|
218
|
+
if (existsSync(patchesDir)) {
|
|
219
|
+
await applyPatches(
|
|
220
|
+
patchesDir,
|
|
221
|
+
targetPath,
|
|
222
|
+
args2.featureName,
|
|
223
|
+
ormPatchNames,
|
|
224
|
+
hasOrmDir
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (hasOrmDir) {
|
|
229
|
+
const ormFilesDir = join(ormDir, "files");
|
|
230
|
+
if (existsSync(ormFilesDir)) {
|
|
231
|
+
await renderFilesInto(ormFilesDir, targetPath, args2.vars);
|
|
232
|
+
}
|
|
233
|
+
const ormPatchesDir = join(ormDir, "patches");
|
|
234
|
+
if (existsSync(ormPatchesDir)) {
|
|
235
|
+
await applyPatches(ormPatchesDir, targetPath, args2.featureName);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const envKeys = args2.manifest.env?.[args2.target.component] ?? [];
|
|
239
|
+
if (envKeys.length > 0) {
|
|
240
|
+
await appendEnvExample(targetPath, args2.featureName, envKeys);
|
|
241
|
+
}
|
|
242
|
+
await recordFeatureInMarker(targetPath, args2.featureName);
|
|
243
|
+
}
|
|
244
|
+
async function renderFilesInto(filesDir, targetPath, vars) {
|
|
245
|
+
const entries = await collectFiles(filesDir);
|
|
246
|
+
for (const rel of entries) {
|
|
247
|
+
const src = join(filesDir, rel);
|
|
248
|
+
const isEjs = rel.endsWith(".ejs");
|
|
249
|
+
const outRel = isEjs ? rel.slice(0, -4) : rel;
|
|
250
|
+
const dst = join(targetPath, outRel);
|
|
251
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
252
|
+
if (isEjs || /\.(ts|tsx|js|jsx|py|dart|md|json|yml|yaml|html)$/.test(rel)) {
|
|
253
|
+
const raw = await readFile(src, "utf-8");
|
|
254
|
+
await writeFile(dst, render(raw, vars));
|
|
255
|
+
} else {
|
|
256
|
+
await cp(src, dst);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async function collectFiles(root) {
|
|
261
|
+
const out = [];
|
|
262
|
+
async function walk(dir) {
|
|
263
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
264
|
+
for (const e of entries) {
|
|
265
|
+
const full = join(dir, e.name);
|
|
266
|
+
if (e.isDirectory()) await walk(full);
|
|
267
|
+
else out.push(relative(root, full));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
await walk(root);
|
|
271
|
+
return out.sort();
|
|
272
|
+
}
|
|
273
|
+
async function applyPatches(patchesDir, targetPath, featureName, skipNames = /* @__PURE__ */ new Set(), tolerateMissingTarget = false) {
|
|
274
|
+
const files = (await readdir(patchesDir)).filter((f) => f.endsWith(".json")).filter((f) => !skipNames.has(f)).sort();
|
|
275
|
+
for (const file of files) {
|
|
276
|
+
const raw = await readFile(join(patchesDir, file), "utf-8");
|
|
277
|
+
const patch = JSON.parse(raw);
|
|
278
|
+
if (patch.type === "package-json") {
|
|
279
|
+
await applyPackageJsonPatch(targetPath, patch);
|
|
280
|
+
} else if (patch.type === "text") {
|
|
281
|
+
await applyTextPatch(
|
|
282
|
+
targetPath,
|
|
283
|
+
patch,
|
|
284
|
+
featureName,
|
|
285
|
+
tolerateMissingTarget
|
|
286
|
+
);
|
|
287
|
+
} else {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`Unknown patch type in ${file}: ${patch.type}.`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async function applyPackageJsonPatch(targetPath, patch) {
|
|
295
|
+
const pkgPath = join(targetPath, "package.json");
|
|
296
|
+
if (!existsSync(pkgPath)) {
|
|
297
|
+
throw new Error(`package-json patch failed: ${pkgPath} not found.`);
|
|
298
|
+
}
|
|
299
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
|
|
300
|
+
const merge = patch.merge;
|
|
301
|
+
for (const key of ["dependencies", "devDependencies", "scripts"]) {
|
|
302
|
+
const incoming = merge[key];
|
|
303
|
+
if (!incoming) continue;
|
|
304
|
+
pkg[key] = { ...pkg[key] ?? {}, ...incoming };
|
|
305
|
+
}
|
|
306
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
307
|
+
}
|
|
308
|
+
async function applyTextPatch(targetPath, patch, featureName, tolerateMissingTarget = false) {
|
|
309
|
+
const filePath = join(targetPath, patch.file);
|
|
310
|
+
if (!existsSync(filePath)) {
|
|
311
|
+
if (tolerateMissingTarget) return;
|
|
312
|
+
throw new Error(
|
|
313
|
+
`text patch failed: ${patch.file} not found in ${targetPath}.`
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const content = await readFile(filePath, "utf-8");
|
|
317
|
+
const idx = content.indexOf(patch.anchor);
|
|
318
|
+
if (idx === -1) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`text patch anchor "${patch.anchor}" not found in ${patch.file}.`
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
const lineStart = content.lastIndexOf("\n", idx) + 1;
|
|
324
|
+
const indent = content.slice(lineStart, idx).match(/^\s*/)?.[0] ?? "";
|
|
325
|
+
const sentinel = sentinelFor(featureName, patch.anchor, patch.insert, indent);
|
|
326
|
+
if (content.includes(sentinel)) return;
|
|
327
|
+
const insertWithSentinel = patch.insert + sentinel;
|
|
328
|
+
let next;
|
|
329
|
+
if (patch.position === "before") {
|
|
330
|
+
next = content.slice(0, idx) + insertWithSentinel + content.slice(idx);
|
|
331
|
+
} else {
|
|
332
|
+
const end = idx + patch.anchor.length;
|
|
333
|
+
const after = content.slice(end);
|
|
334
|
+
const newline = after.startsWith("\n") ? "\n" : "\n";
|
|
335
|
+
next = content.slice(0, end) + newline + insertWithSentinel + (after.startsWith("\n") ? after.slice(1) : after);
|
|
336
|
+
}
|
|
337
|
+
await writeFile(filePath, next);
|
|
338
|
+
}
|
|
339
|
+
function sentinelFor(feature, anchor, insert, indent = "") {
|
|
340
|
+
const hash = simpleHash(anchor + "|" + insert);
|
|
341
|
+
const isHash = anchor.includes("#");
|
|
342
|
+
const open = isHash ? "# " : "// ";
|
|
343
|
+
return `${indent}${open}projx-feature: ${feature} ${hash}
|
|
344
|
+
`;
|
|
345
|
+
}
|
|
346
|
+
function simpleHash(s) {
|
|
347
|
+
let h = 0;
|
|
348
|
+
for (let i = 0; i < s.length; i++) h = h * 31 + s.charCodeAt(i) | 0;
|
|
349
|
+
return Math.abs(h).toString(36);
|
|
350
|
+
}
|
|
351
|
+
async function appendEnvExample(targetPath, featureName, keys) {
|
|
352
|
+
const envPath = join(targetPath, ".env.example");
|
|
353
|
+
let content = existsSync(envPath) ? await readFile(envPath, "utf-8") : "";
|
|
354
|
+
const header = `# Added by feature: ${featureName}`;
|
|
355
|
+
if (content.includes(header)) return;
|
|
356
|
+
if (content && !content.endsWith("\n")) content += "\n";
|
|
357
|
+
content += `
|
|
358
|
+
${header}
|
|
359
|
+
`;
|
|
360
|
+
for (const key of keys) content += `# ${key}=
|
|
361
|
+
`;
|
|
362
|
+
await writeFile(envPath, content);
|
|
363
|
+
}
|
|
364
|
+
async function recordFeatureInMarker(targetPath, featureName) {
|
|
365
|
+
const markerPath = join(targetPath, ".projx-component");
|
|
366
|
+
if (!existsSync(markerPath)) return;
|
|
367
|
+
const raw = await readFile(markerPath, "utf-8");
|
|
368
|
+
const marker = JSON.parse(raw);
|
|
369
|
+
marker.features = marker.features ?? [];
|
|
370
|
+
if (!marker.features.includes(featureName)) {
|
|
371
|
+
marker.features.push(featureName);
|
|
372
|
+
await writeFile(markerPath, JSON.stringify(marker, null, 2) + "\n");
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
43
376
|
// src/prompts.ts
|
|
44
377
|
import * as p from "@clack/prompts";
|
|
45
378
|
var LABELS = {
|
|
46
379
|
fastapi: { label: "FastAPI", hint: "Python \u2014 SQLAlchemy, Alembic, uvicorn" },
|
|
47
380
|
fastify: { label: "Fastify", hint: "Node.js \u2014 Prisma, TypeBox, TypeScript" },
|
|
381
|
+
express: { label: "Express", hint: "Node.js \u2014 Express 5, TypeScript" },
|
|
48
382
|
frontend: { label: "Frontend", hint: "React 19 + Vite + React Router" },
|
|
49
383
|
mobile: { label: "Mobile", hint: "Flutter + Riverpod + GoRouter" },
|
|
50
384
|
e2e: { label: "E2E Tests", hint: "Playwright" },
|
|
@@ -78,9 +412,25 @@ async function runPrompts(nameArg) {
|
|
|
78
412
|
p.log.warn("No components selected. Creating an empty project.");
|
|
79
413
|
}
|
|
80
414
|
const hasJs = components.some(
|
|
81
|
-
(c) => ["fastify", "frontend", "e2e"].includes(c)
|
|
415
|
+
(c) => ["fastify", "express", "frontend", "e2e"].includes(c)
|
|
416
|
+
);
|
|
417
|
+
const hasNodeBackend = components.some(
|
|
418
|
+
(c) => ["fastify", "express"].includes(c)
|
|
82
419
|
);
|
|
420
|
+
let orm = "prisma";
|
|
83
421
|
let packageManager = "npm";
|
|
422
|
+
if (hasNodeBackend) {
|
|
423
|
+
const choice = await p.select({
|
|
424
|
+
message: "Node backend ORM",
|
|
425
|
+
options: ORM_PROVIDERS.map((provider) => ({
|
|
426
|
+
value: provider,
|
|
427
|
+
label: provider === "prisma" ? "Prisma" : "Drizzle"
|
|
428
|
+
})),
|
|
429
|
+
initialValue: "prisma"
|
|
430
|
+
});
|
|
431
|
+
if (p.isCancel(choice)) process.exit(0);
|
|
432
|
+
orm = choice;
|
|
433
|
+
}
|
|
84
434
|
if (hasJs) {
|
|
85
435
|
const pm = await p.select({
|
|
86
436
|
message: "Package manager",
|
|
@@ -90,13 +440,13 @@ async function runPrompts(nameArg) {
|
|
|
90
440
|
if (p.isCancel(pm)) process.exit(0);
|
|
91
441
|
packageManager = pm;
|
|
92
442
|
}
|
|
93
|
-
return { name, components, git: true, install: true, packageManager };
|
|
443
|
+
return { name, components, git: true, install: true, packageManager, orm };
|
|
94
444
|
}
|
|
95
445
|
|
|
96
446
|
// src/scaffold.ts
|
|
97
|
-
import { copyFileSync, existsSync } from "fs";
|
|
98
|
-
import { mkdir, readFile } from "fs/promises";
|
|
99
|
-
import { join } from "path";
|
|
447
|
+
import { copyFileSync, existsSync as existsSync2 } from "fs";
|
|
448
|
+
import { mkdir as mkdir2, readFile as readFile2 } from "fs/promises";
|
|
449
|
+
import { join as join2 } from "path";
|
|
100
450
|
import * as p2 from "@clack/prompts";
|
|
101
451
|
async function scaffold(opts, dest, localRepo) {
|
|
102
452
|
const name = toKebab(opts.name);
|
|
@@ -108,10 +458,11 @@ async function scaffold(opts, dest, localRepo) {
|
|
|
108
458
|
projectName: name,
|
|
109
459
|
components: opts.components,
|
|
110
460
|
paths,
|
|
111
|
-
pm: pmCommands(pm)
|
|
461
|
+
pm: pmCommands(pm),
|
|
462
|
+
orm: opts.orm ?? "prisma"
|
|
112
463
|
};
|
|
113
464
|
const isLocal = !!localRepo;
|
|
114
|
-
await
|
|
465
|
+
await mkdir2(dest, { recursive: true });
|
|
115
466
|
const dlSpinner = p2.spinner();
|
|
116
467
|
dlSpinner.start(
|
|
117
468
|
isLocal ? "Using local templates" : "Downloading latest templates"
|
|
@@ -124,15 +475,15 @@ async function scaffold(opts, dest, localRepo) {
|
|
|
124
475
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
125
476
|
try {
|
|
126
477
|
const pkg = JSON.parse(
|
|
127
|
-
await
|
|
478
|
+
await readFile2(join2(repoDir, "cli/package.json"), "utf-8")
|
|
128
479
|
);
|
|
129
480
|
const version = pkg.version;
|
|
130
481
|
p2.log.info(`Scaffolding project in ${dest}`);
|
|
131
482
|
if (opts.git) {
|
|
132
483
|
exec("git init", dest);
|
|
133
484
|
}
|
|
134
|
-
const
|
|
135
|
-
|
|
485
|
+
const spinner6 = p2.spinner();
|
|
486
|
+
spinner6.start("Scaffolding project");
|
|
136
487
|
await applyTemplate(
|
|
137
488
|
dest,
|
|
138
489
|
repoDir,
|
|
@@ -144,7 +495,20 @@ async function scaffold(opts, dest, localRepo) {
|
|
|
144
495
|
void 0,
|
|
145
496
|
true
|
|
146
497
|
);
|
|
147
|
-
|
|
498
|
+
spinner6.stop("Scaffold complete.");
|
|
499
|
+
if (opts.features && Object.keys(opts.features).length > 0) {
|
|
500
|
+
const featSpinner = p2.spinner();
|
|
501
|
+
featSpinner.start("Applying features");
|
|
502
|
+
await applyFeatures({
|
|
503
|
+
features: opts.features,
|
|
504
|
+
repoDir,
|
|
505
|
+
components: opts.components,
|
|
506
|
+
instances: opts.components.map((type) => ({ type, path: type })),
|
|
507
|
+
dest,
|
|
508
|
+
vars
|
|
509
|
+
});
|
|
510
|
+
featSpinner.stop("Features applied.");
|
|
511
|
+
}
|
|
148
512
|
if (opts.install) {
|
|
149
513
|
await installDeps(dest, opts.components, pm);
|
|
150
514
|
}
|
|
@@ -174,34 +538,45 @@ async function installDeps(dest, components, pm) {
|
|
|
174
538
|
const cmds = pmCommands(pm);
|
|
175
539
|
const pmBin = pm === "bun" ? "bun" : pm;
|
|
176
540
|
for (const component of components) {
|
|
177
|
-
const
|
|
541
|
+
const spinner6 = p2.spinner();
|
|
178
542
|
try {
|
|
179
543
|
switch (component) {
|
|
180
544
|
case "fastapi":
|
|
181
545
|
if (hasCommand("uv")) {
|
|
182
|
-
|
|
183
|
-
exec("uv sync --all-extras",
|
|
184
|
-
|
|
546
|
+
spinner6.start("Installing FastAPI dependencies (uv sync)");
|
|
547
|
+
exec("uv sync --all-extras", join2(dest, "fastapi"));
|
|
548
|
+
spinner6.stop("FastAPI dependencies installed.");
|
|
185
549
|
} else {
|
|
186
550
|
p2.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
|
|
187
551
|
}
|
|
188
552
|
break;
|
|
189
553
|
case "fastify":
|
|
190
554
|
if (hasCommand(pmBin)) {
|
|
191
|
-
|
|
192
|
-
exec(cmds.install,
|
|
193
|
-
|
|
555
|
+
spinner6.start(`Installing Fastify dependencies (${cmds.install})`);
|
|
556
|
+
exec(cmds.install, join2(dest, "fastify"));
|
|
557
|
+
spinner6.stop("Fastify dependencies installed.");
|
|
194
558
|
} else {
|
|
195
559
|
p2.log.warn(
|
|
196
560
|
`${pm} not found \u2014 run 'cd fastify && ${cmds.install}' manually.`
|
|
197
561
|
);
|
|
198
562
|
}
|
|
199
563
|
break;
|
|
564
|
+
case "express":
|
|
565
|
+
if (hasCommand(pmBin)) {
|
|
566
|
+
spinner6.start(`Installing Express dependencies (${cmds.install})`);
|
|
567
|
+
exec(cmds.install, join2(dest, "express"));
|
|
568
|
+
spinner6.stop("Express dependencies installed.");
|
|
569
|
+
} else {
|
|
570
|
+
p2.log.warn(
|
|
571
|
+
`${pm} not found \u2014 run 'cd express && ${cmds.install}' manually.`
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
break;
|
|
200
575
|
case "frontend":
|
|
201
576
|
if (hasCommand(pmBin)) {
|
|
202
|
-
|
|
203
|
-
exec(cmds.install,
|
|
204
|
-
|
|
577
|
+
spinner6.start(`Installing Frontend dependencies (${cmds.install})`);
|
|
578
|
+
exec(cmds.install, join2(dest, "frontend"));
|
|
579
|
+
spinner6.stop("Frontend dependencies installed.");
|
|
205
580
|
} else {
|
|
206
581
|
p2.log.warn(
|
|
207
582
|
`${pm} not found \u2014 run 'cd frontend && ${cmds.install}' manually.`
|
|
@@ -210,9 +585,9 @@ async function installDeps(dest, components, pm) {
|
|
|
210
585
|
break;
|
|
211
586
|
case "e2e":
|
|
212
587
|
if (hasCommand(pmBin)) {
|
|
213
|
-
|
|
214
|
-
exec(cmds.install,
|
|
215
|
-
|
|
588
|
+
spinner6.start(`Installing E2E dependencies (${cmds.install})`);
|
|
589
|
+
exec(cmds.install, join2(dest, "e2e"));
|
|
590
|
+
spinner6.stop("E2E dependencies installed.");
|
|
216
591
|
} else {
|
|
217
592
|
p2.log.warn(
|
|
218
593
|
`${pm} not found \u2014 run 'cd e2e && ${cmds.install}' manually.`
|
|
@@ -221,9 +596,9 @@ async function installDeps(dest, components, pm) {
|
|
|
221
596
|
break;
|
|
222
597
|
case "mobile":
|
|
223
598
|
if (hasCommand("flutter")) {
|
|
224
|
-
|
|
225
|
-
exec("flutter pub get",
|
|
226
|
-
|
|
599
|
+
spinner6.start("Installing Flutter dependencies");
|
|
600
|
+
exec("flutter pub get", join2(dest, "mobile"));
|
|
601
|
+
spinner6.stop("Flutter dependencies installed.");
|
|
227
602
|
} else {
|
|
228
603
|
p2.log.warn(
|
|
229
604
|
"Flutter not found \u2014 run 'cd mobile && flutter pub get' manually."
|
|
@@ -234,15 +609,15 @@ async function installDeps(dest, components, pm) {
|
|
|
234
609
|
break;
|
|
235
610
|
}
|
|
236
611
|
} catch {
|
|
237
|
-
|
|
612
|
+
spinner6.stop(`Failed to install ${component} dependencies.`);
|
|
238
613
|
}
|
|
239
614
|
}
|
|
240
615
|
}
|
|
241
616
|
function copyEnvExamples(dest, components) {
|
|
242
617
|
for (const component of components) {
|
|
243
|
-
const example =
|
|
244
|
-
const env =
|
|
245
|
-
if (
|
|
618
|
+
const example = join2(dest, component, ".env.example");
|
|
619
|
+
const env = join2(dest, component, ".env");
|
|
620
|
+
if (existsSync2(example) && !existsSync2(env)) {
|
|
246
621
|
try {
|
|
247
622
|
copyFileSync(example, env);
|
|
248
623
|
} catch {
|
|
@@ -252,10 +627,10 @@ function copyEnvExamples(dest, components) {
|
|
|
252
627
|
}
|
|
253
628
|
|
|
254
629
|
// src/update.ts
|
|
255
|
-
import { existsSync as
|
|
256
|
-
import { readFile as
|
|
630
|
+
import { existsSync as existsSync3 } from "fs";
|
|
631
|
+
import { readFile as readFile3, unlink } from "fs/promises";
|
|
257
632
|
import { execSync } from "child_process";
|
|
258
|
-
import { join as
|
|
633
|
+
import { join as join3 } from "path";
|
|
259
634
|
import * as p3 from "@clack/prompts";
|
|
260
635
|
async function update(cwd, localRepo) {
|
|
261
636
|
p3.intro("projx update");
|
|
@@ -323,7 +698,7 @@ async function update(cwd, localRepo) {
|
|
|
323
698
|
const componentSkips = {};
|
|
324
699
|
for (const component of components) {
|
|
325
700
|
const dir = componentPaths[component];
|
|
326
|
-
const marker = await readComponentMarker(
|
|
701
|
+
const marker = await readComponentMarker(join3(cwd, dir));
|
|
327
702
|
if (marker?.skip && marker.skip.length > 0) {
|
|
328
703
|
componentSkips[component] = marker.skip;
|
|
329
704
|
}
|
|
@@ -340,7 +715,7 @@ async function update(cwd, localRepo) {
|
|
|
340
715
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
341
716
|
try {
|
|
342
717
|
const pkg = JSON.parse(
|
|
343
|
-
await
|
|
718
|
+
await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
|
|
344
719
|
);
|
|
345
720
|
const version = pkg.version;
|
|
346
721
|
const name = detectProjectName(cwd, components, componentPaths);
|
|
@@ -366,10 +741,11 @@ async function update(cwd, localRepo) {
|
|
|
366
741
|
paths: componentPaths,
|
|
367
742
|
instances,
|
|
368
743
|
pm: pmCommands(pm),
|
|
369
|
-
nameOverrides
|
|
744
|
+
nameOverrides,
|
|
745
|
+
orm: raw.orm ?? "prisma"
|
|
370
746
|
};
|
|
371
|
-
const
|
|
372
|
-
|
|
747
|
+
const spinner6 = p3.spinner();
|
|
748
|
+
spinner6.start("Applying template update");
|
|
373
749
|
const rootSkip = Array.isArray(raw.skip) ? raw.skip : [];
|
|
374
750
|
const isLegacyMigration = !raw.defaultsApplied;
|
|
375
751
|
if (isLegacyMigration) {
|
|
@@ -389,7 +765,7 @@ async function update(cwd, localRepo) {
|
|
|
389
765
|
isLegacyMigration,
|
|
390
766
|
extraInstances
|
|
391
767
|
);
|
|
392
|
-
|
|
768
|
+
spinner6.stop("Template applied.");
|
|
393
769
|
const pinnedUpdates = await findPinnedFilesWithUpdates(
|
|
394
770
|
cwd,
|
|
395
771
|
repoDir,
|
|
@@ -473,22 +849,21 @@ function hasUncommittedChanges(cwd) {
|
|
|
473
849
|
}
|
|
474
850
|
}
|
|
475
851
|
async function findPinnedFilesWithUpdates(cwd, repoDir, components, componentPaths, vars, version, componentSkips, rootSkip) {
|
|
476
|
-
const {
|
|
852
|
+
const { mkdtemp: mkdtemp2, rm: rm2, readFile: readFile8 } = await import("fs/promises");
|
|
477
853
|
const { tmpdir: tmpdir2 } = await import("os");
|
|
478
|
-
const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-
|
|
854
|
+
const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-FKCXQFRD.js");
|
|
479
855
|
const config = await readProjxConfig(cwd);
|
|
480
856
|
const rootPinned = Array.isArray(config.skip) ? config.skip : [];
|
|
481
857
|
const componentPinned = [];
|
|
482
858
|
for (const component of components) {
|
|
483
859
|
const dir = componentPaths[component];
|
|
484
|
-
const marker = await readComponentMarker(
|
|
860
|
+
const marker = await readComponentMarker(join3(cwd, dir));
|
|
485
861
|
if (marker?.skip && marker.skip.length > 0) {
|
|
486
862
|
componentPinned.push({ component, dir, patterns: marker.skip });
|
|
487
863
|
}
|
|
488
864
|
}
|
|
489
865
|
if (rootPinned.length === 0 && componentPinned.length === 0) return [];
|
|
490
|
-
const tmpTemplate =
|
|
491
|
-
await mkdir5(tmpTemplate, { recursive: true });
|
|
866
|
+
const tmpTemplate = await mkdtemp2(join3(tmpdir2(), "projx-pinned-"));
|
|
492
867
|
void componentSkips;
|
|
493
868
|
void rootSkip;
|
|
494
869
|
try {
|
|
@@ -507,22 +882,22 @@ async function findPinnedFilesWithUpdates(cwd, repoDir, components, componentPat
|
|
|
507
882
|
);
|
|
508
883
|
const updates = [];
|
|
509
884
|
for (const file of rootPinned) {
|
|
510
|
-
const tmplPath =
|
|
511
|
-
const userPath =
|
|
512
|
-
if (!
|
|
513
|
-
const tmplContent = await
|
|
514
|
-
const userContent = await
|
|
885
|
+
const tmplPath = join3(tmpTemplate, file);
|
|
886
|
+
const userPath = join3(cwd, file);
|
|
887
|
+
if (!existsSync3(tmplPath) || !existsSync3(userPath)) continue;
|
|
888
|
+
const tmplContent = await readFile8(tmplPath, "utf-8");
|
|
889
|
+
const userContent = await readFile8(userPath, "utf-8");
|
|
515
890
|
if (tmplContent !== userContent) updates.push(file);
|
|
516
891
|
}
|
|
517
892
|
for (const { dir, patterns } of componentPinned) {
|
|
518
893
|
for (const pattern of patterns) {
|
|
519
894
|
if (pattern.includes("*")) continue;
|
|
520
895
|
const rel = `${dir}/${pattern}`;
|
|
521
|
-
const tmplPath =
|
|
522
|
-
const userPath =
|
|
523
|
-
if (!
|
|
524
|
-
const tmplContent = await
|
|
525
|
-
const userContent = await
|
|
896
|
+
const tmplPath = join3(tmpTemplate, rel);
|
|
897
|
+
const userPath = join3(cwd, rel);
|
|
898
|
+
if (!existsSync3(tmplPath) || !existsSync3(userPath)) continue;
|
|
899
|
+
const tmplContent = await readFile8(tmplPath, "utf-8");
|
|
900
|
+
const userContent = await readFile8(userPath, "utf-8");
|
|
526
901
|
if (tmplContent !== userContent) updates.push(rel);
|
|
527
902
|
}
|
|
528
903
|
}
|
|
@@ -596,7 +971,7 @@ async function promptSkipLearning(cwd, componentPaths, version, conflictedFiles)
|
|
|
596
971
|
const entry = entries.find((e) => e.file === file);
|
|
597
972
|
try {
|
|
598
973
|
if (entry?.status === "??") {
|
|
599
|
-
await unlink(
|
|
974
|
+
await unlink(join3(cwd, file));
|
|
600
975
|
} else {
|
|
601
976
|
execSync(`git checkout -- "${file}"`, { cwd, stdio: "pipe" });
|
|
602
977
|
}
|
|
@@ -630,9 +1005,9 @@ async function learnSkips(cwd, files, componentPaths) {
|
|
|
630
1005
|
let matched = false;
|
|
631
1006
|
for (const [dir, component] of Object.entries(dirToComponent)) {
|
|
632
1007
|
if (file.startsWith(dir + "/")) {
|
|
633
|
-
const
|
|
1008
|
+
const relative2 = file.slice(dir.length + 1);
|
|
634
1009
|
if (!componentSkipAdds[component]) componentSkipAdds[component] = [];
|
|
635
|
-
componentSkipAdds[component].push(
|
|
1010
|
+
componentSkipAdds[component].push(relative2);
|
|
636
1011
|
matched = true;
|
|
637
1012
|
break;
|
|
638
1013
|
}
|
|
@@ -643,10 +1018,10 @@ async function learnSkips(cwd, files, componentPaths) {
|
|
|
643
1018
|
}
|
|
644
1019
|
for (const [component, additions] of Object.entries(componentSkipAdds)) {
|
|
645
1020
|
const dir = componentPaths[component];
|
|
646
|
-
const marker = await readComponentMarker(
|
|
1021
|
+
const marker = await readComponentMarker(join3(cwd, dir));
|
|
647
1022
|
if (!marker) continue;
|
|
648
1023
|
const merged = [.../* @__PURE__ */ new Set([...marker.skip, ...additions])];
|
|
649
|
-
await writeComponentMarker(
|
|
1024
|
+
await writeComponentMarker(join3(cwd, dir), { ...marker, skip: merged });
|
|
650
1025
|
}
|
|
651
1026
|
if (rootSkipAdds.length > 0) {
|
|
652
1027
|
const config = await readProjxConfig(cwd);
|
|
@@ -657,14 +1032,14 @@ async function learnSkips(cwd, files, componentPaths) {
|
|
|
657
1032
|
}
|
|
658
1033
|
|
|
659
1034
|
// src/add.ts
|
|
660
|
-
import { copyFileSync as copyFileSync2, existsSync as
|
|
661
|
-
import { readFile as
|
|
662
|
-
import { join as
|
|
1035
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync4 } from "fs";
|
|
1036
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1037
|
+
import { join as join4 } from "path";
|
|
663
1038
|
import * as p4 from "@clack/prompts";
|
|
664
1039
|
async function add(cwd, newComponents, localRepo, skipInstall = false, customName) {
|
|
665
1040
|
p4.intro("projx add");
|
|
666
1041
|
const isLocal = !!localRepo;
|
|
667
|
-
if (!
|
|
1042
|
+
if (!existsSync4(join4(cwd, ".projx"))) {
|
|
668
1043
|
p4.log.error(
|
|
669
1044
|
"No .projx file found. Run 'npx create-projx <name>' to create a project first."
|
|
670
1045
|
);
|
|
@@ -678,8 +1053,8 @@ async function add(cwd, newComponents, localRepo, skipInstall = false, customNam
|
|
|
678
1053
|
"--name can only be used when adding a single component type."
|
|
679
1054
|
);
|
|
680
1055
|
}
|
|
681
|
-
const targetDir =
|
|
682
|
-
if (
|
|
1056
|
+
const targetDir = join4(cwd, customName);
|
|
1057
|
+
if (existsSync4(targetDir)) {
|
|
683
1058
|
throw new Error(`Directory '${customName}' already exists.`);
|
|
684
1059
|
}
|
|
685
1060
|
return await addInstance(
|
|
@@ -730,14 +1105,15 @@ async function add(cwd, newComponents, localRepo, skipInstall = false, customNam
|
|
|
730
1105
|
components: allComponents,
|
|
731
1106
|
paths,
|
|
732
1107
|
instances,
|
|
733
|
-
pm: pmCommands(pm)
|
|
1108
|
+
pm: pmCommands(pm),
|
|
1109
|
+
orm: config.orm ?? "prisma"
|
|
734
1110
|
};
|
|
735
1111
|
const pkg = JSON.parse(
|
|
736
|
-
await
|
|
1112
|
+
await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
|
|
737
1113
|
);
|
|
738
1114
|
const version = pkg.version;
|
|
739
|
-
const
|
|
740
|
-
|
|
1115
|
+
const spinner6 = p4.spinner();
|
|
1116
|
+
spinner6.start("Adding components");
|
|
741
1117
|
await writeTemplateToDir(
|
|
742
1118
|
cwd,
|
|
743
1119
|
repoDir,
|
|
@@ -747,7 +1123,7 @@ async function add(cwd, newComponents, localRepo, skipInstall = false, customNam
|
|
|
747
1123
|
version,
|
|
748
1124
|
{ realCwd: cwd }
|
|
749
1125
|
);
|
|
750
|
-
|
|
1126
|
+
spinner6.stop("Components added.");
|
|
751
1127
|
if (!skipInstall) {
|
|
752
1128
|
await installDeps2(
|
|
753
1129
|
cwd,
|
|
@@ -756,9 +1132,9 @@ async function add(cwd, newComponents, localRepo, skipInstall = false, customNam
|
|
|
756
1132
|
);
|
|
757
1133
|
}
|
|
758
1134
|
for (const component of toAdd) {
|
|
759
|
-
const example =
|
|
760
|
-
const env =
|
|
761
|
-
if (
|
|
1135
|
+
const example = join4(cwd, component, ".env.example");
|
|
1136
|
+
const env = join4(cwd, component, ".env");
|
|
1137
|
+
if (existsSync4(example) && !existsSync4(env)) {
|
|
762
1138
|
try {
|
|
763
1139
|
copyFileSync2(example, env);
|
|
764
1140
|
} catch {
|
|
@@ -798,28 +1174,28 @@ async function addInstance(cwd, type, customName, config, existing, localRepo, s
|
|
|
798
1174
|
components: existing,
|
|
799
1175
|
paths,
|
|
800
1176
|
instances,
|
|
801
|
-
pm: pmCommands(pm)
|
|
1177
|
+
pm: pmCommands(pm),
|
|
1178
|
+
orm: config.orm ?? "prisma"
|
|
802
1179
|
};
|
|
803
1180
|
const pkg = JSON.parse(
|
|
804
|
-
await
|
|
1181
|
+
await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
|
|
805
1182
|
);
|
|
806
1183
|
const version = pkg.version;
|
|
807
1184
|
const INSTANCE_AWARE_ROOT = /* @__PURE__ */ new Set([
|
|
808
1185
|
".github/workflows/ci.yml",
|
|
809
1186
|
".githooks/pre-commit",
|
|
810
1187
|
"scripts/setup.sh",
|
|
811
|
-
"docker-compose.yml"
|
|
812
|
-
"docker-compose.dev.yml"
|
|
1188
|
+
"docker-compose.yml"
|
|
813
1189
|
]);
|
|
814
1190
|
const rawSkip = Array.isArray(config.skip) ? config.skip : [];
|
|
815
|
-
const rootSkip = rawSkip.filter((
|
|
1191
|
+
const rootSkip = rawSkip.filter((p10) => !INSTANCE_AWARE_ROOT.has(p10));
|
|
816
1192
|
const componentSkips = {};
|
|
817
1193
|
for (const inst of existingInstances) {
|
|
818
|
-
const m = await readComponentMarker(
|
|
1194
|
+
const m = await readComponentMarker(join4(cwd, inst.path));
|
|
819
1195
|
if (m?.skip && m.skip.length > 0) componentSkips[inst.type] = m.skip;
|
|
820
1196
|
}
|
|
821
|
-
const
|
|
822
|
-
|
|
1197
|
+
const spinner6 = p4.spinner();
|
|
1198
|
+
spinner6.start(`Scaffolding ${customName}/`);
|
|
823
1199
|
const result = await applyTemplate(
|
|
824
1200
|
cwd,
|
|
825
1201
|
repoDir,
|
|
@@ -833,7 +1209,7 @@ async function addInstance(cwd, type, customName, config, existing, localRepo, s
|
|
|
833
1209
|
[newInstance],
|
|
834
1210
|
[newInstance]
|
|
835
1211
|
);
|
|
836
|
-
|
|
1212
|
+
spinner6.stop(`Scaffolded ${customName}/.`);
|
|
837
1213
|
if (result.status === "merged") {
|
|
838
1214
|
p4.log.success(
|
|
839
1215
|
`${result.mergedFiles?.length ?? 0} root file(s) merged cleanly.`
|
|
@@ -851,9 +1227,9 @@ async function addInstance(cwd, type, customName, config, existing, localRepo, s
|
|
|
851
1227
|
if (!skipInstall) {
|
|
852
1228
|
await installDeps2(cwd, [{ type, path: customName }], pm);
|
|
853
1229
|
}
|
|
854
|
-
const example =
|
|
855
|
-
const env =
|
|
856
|
-
if (
|
|
1230
|
+
const example = join4(cwd, customName, ".env.example");
|
|
1231
|
+
const env = join4(cwd, customName, ".env");
|
|
1232
|
+
if (existsSync4(example) && !existsSync4(env)) {
|
|
857
1233
|
try {
|
|
858
1234
|
copyFileSync2(example, env);
|
|
859
1235
|
} catch {
|
|
@@ -868,26 +1244,39 @@ async function installDeps2(dest, instances, pm) {
|
|
|
868
1244
|
const cmds = pmCommands(pm);
|
|
869
1245
|
const pmBin = pm === "bun" ? "bun" : pm;
|
|
870
1246
|
for (const { type, path } of instances) {
|
|
871
|
-
const dir =
|
|
872
|
-
const
|
|
1247
|
+
const dir = join4(dest, path);
|
|
1248
|
+
const spinner6 = p4.spinner();
|
|
873
1249
|
try {
|
|
874
1250
|
switch (type) {
|
|
875
1251
|
case "fastapi":
|
|
876
1252
|
if (hasCommand("uv")) {
|
|
877
|
-
|
|
1253
|
+
spinner6.start(`Installing FastAPI dependencies (${path}/)`);
|
|
878
1254
|
exec("uv sync --all-extras", dir);
|
|
879
|
-
|
|
1255
|
+
spinner6.stop(`FastAPI dependencies installed (${path}/).`);
|
|
880
1256
|
} else {
|
|
881
1257
|
p4.log.warn(`uv not found \u2014 run 'cd ${path} && uv sync' manually.`);
|
|
882
1258
|
}
|
|
883
1259
|
break;
|
|
884
1260
|
case "fastify":
|
|
885
1261
|
if (hasCommand(pmBin)) {
|
|
886
|
-
|
|
1262
|
+
spinner6.start(
|
|
887
1263
|
`Installing Fastify dependencies (${path}/, ${cmds.install})`
|
|
888
1264
|
);
|
|
889
1265
|
exec(cmds.install, dir);
|
|
890
|
-
|
|
1266
|
+
spinner6.stop(`Fastify dependencies installed (${path}/).`);
|
|
1267
|
+
} else {
|
|
1268
|
+
p4.log.warn(
|
|
1269
|
+
`${pm} not found \u2014 run 'cd ${path} && ${cmds.install}' manually.`
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
break;
|
|
1273
|
+
case "express":
|
|
1274
|
+
if (hasCommand(pmBin)) {
|
|
1275
|
+
spinner6.start(
|
|
1276
|
+
`Installing Express dependencies (${path}/, ${cmds.install})`
|
|
1277
|
+
);
|
|
1278
|
+
exec(cmds.install, dir);
|
|
1279
|
+
spinner6.stop(`Express dependencies installed (${path}/).`);
|
|
891
1280
|
} else {
|
|
892
1281
|
p4.log.warn(
|
|
893
1282
|
`${pm} not found \u2014 run 'cd ${path} && ${cmds.install}' manually.`
|
|
@@ -896,11 +1285,11 @@ async function installDeps2(dest, instances, pm) {
|
|
|
896
1285
|
break;
|
|
897
1286
|
case "frontend":
|
|
898
1287
|
if (hasCommand(pmBin)) {
|
|
899
|
-
|
|
1288
|
+
spinner6.start(
|
|
900
1289
|
`Installing Frontend dependencies (${path}/, ${cmds.install})`
|
|
901
1290
|
);
|
|
902
1291
|
exec(cmds.install, dir);
|
|
903
|
-
|
|
1292
|
+
spinner6.stop(`Frontend dependencies installed (${path}/).`);
|
|
904
1293
|
} else {
|
|
905
1294
|
p4.log.warn(
|
|
906
1295
|
`${pm} not found \u2014 run 'cd ${path} && ${cmds.install}' manually.`
|
|
@@ -909,11 +1298,11 @@ async function installDeps2(dest, instances, pm) {
|
|
|
909
1298
|
break;
|
|
910
1299
|
case "e2e":
|
|
911
1300
|
if (hasCommand(pmBin)) {
|
|
912
|
-
|
|
1301
|
+
spinner6.start(
|
|
913
1302
|
`Installing E2E dependencies (${path}/, ${cmds.install})`
|
|
914
1303
|
);
|
|
915
1304
|
exec(cmds.install, dir);
|
|
916
|
-
|
|
1305
|
+
spinner6.stop(`E2E dependencies installed (${path}/).`);
|
|
917
1306
|
} else {
|
|
918
1307
|
p4.log.warn(
|
|
919
1308
|
`${pm} not found \u2014 run 'cd ${path} && ${cmds.install}' manually.`
|
|
@@ -922,9 +1311,9 @@ async function installDeps2(dest, instances, pm) {
|
|
|
922
1311
|
break;
|
|
923
1312
|
case "mobile":
|
|
924
1313
|
if (hasCommand("flutter")) {
|
|
925
|
-
|
|
1314
|
+
spinner6.start(`Installing Flutter dependencies (${path}/)`);
|
|
926
1315
|
exec("flutter pub get", dir);
|
|
927
|
-
|
|
1316
|
+
spinner6.stop(`Flutter dependencies installed (${path}/).`);
|
|
928
1317
|
} else {
|
|
929
1318
|
p4.log.warn(
|
|
930
1319
|
`Flutter not found \u2014 run 'cd ${path} && flutter pub get' manually.`
|
|
@@ -935,30 +1324,30 @@ async function installDeps2(dest, instances, pm) {
|
|
|
935
1324
|
break;
|
|
936
1325
|
}
|
|
937
1326
|
} catch {
|
|
938
|
-
|
|
1327
|
+
spinner6.stop(`Failed to install ${type} dependencies (${path}/).`);
|
|
939
1328
|
}
|
|
940
1329
|
}
|
|
941
1330
|
}
|
|
942
1331
|
|
|
943
1332
|
// src/init.ts
|
|
944
|
-
import { existsSync as
|
|
945
|
-
import { readFile as
|
|
1333
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1334
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
946
1335
|
import { execSync as execSync2 } from "child_process";
|
|
947
|
-
import { join as
|
|
1336
|
+
import { join as join6 } from "path";
|
|
948
1337
|
import * as p5 from "@clack/prompts";
|
|
949
1338
|
|
|
950
1339
|
// src/detect.ts
|
|
951
|
-
import { existsSync as
|
|
952
|
-
import { readdir } from "fs/promises";
|
|
953
|
-
import { join as
|
|
1340
|
+
import { existsSync as existsSync5 } from "fs";
|
|
1341
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
1342
|
+
import { join as join5 } from "path";
|
|
954
1343
|
async function detectComponents(cwd) {
|
|
955
1344
|
const results = [];
|
|
956
|
-
const entries = await
|
|
1345
|
+
const entries = await readdir2(cwd, { withFileTypes: true });
|
|
957
1346
|
const dirs = entries.filter(
|
|
958
1347
|
(e) => e.isDirectory() && !e.name.startsWith(".") && !EXCLUDE.has(e.name)
|
|
959
1348
|
).map((e) => e.name);
|
|
960
1349
|
for (const dir of dirs) {
|
|
961
|
-
const full =
|
|
1350
|
+
const full = join5(cwd, dir);
|
|
962
1351
|
const detections = await scanDirectory(full, dir);
|
|
963
1352
|
results.push(...detections);
|
|
964
1353
|
}
|
|
@@ -966,7 +1355,7 @@ async function detectComponents(cwd) {
|
|
|
966
1355
|
}
|
|
967
1356
|
async function scanDirectory(dir, relPath) {
|
|
968
1357
|
const results = [];
|
|
969
|
-
const pyproject = await readFileOrNull(
|
|
1358
|
+
const pyproject = await readFileOrNull(join5(dir, "pyproject.toml"));
|
|
970
1359
|
if (pyproject && /fastapi/i.test(pyproject)) {
|
|
971
1360
|
results.push({
|
|
972
1361
|
component: "fastapi",
|
|
@@ -986,6 +1375,14 @@ async function scanDirectory(dir, relPath) {
|
|
|
986
1375
|
evidence: "package.json has fastify dependency"
|
|
987
1376
|
});
|
|
988
1377
|
}
|
|
1378
|
+
if (allDeps.express) {
|
|
1379
|
+
results.push({
|
|
1380
|
+
component: "express",
|
|
1381
|
+
directory: relPath,
|
|
1382
|
+
confidence: "high",
|
|
1383
|
+
evidence: "package.json has express dependency"
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
989
1386
|
if (allDeps.react || allDeps["react-dom"]) {
|
|
990
1387
|
results.push({
|
|
991
1388
|
component: "frontend",
|
|
@@ -1003,7 +1400,7 @@ async function scanDirectory(dir, relPath) {
|
|
|
1003
1400
|
});
|
|
1004
1401
|
}
|
|
1005
1402
|
}
|
|
1006
|
-
const pubspec = await readFileOrNull(
|
|
1403
|
+
const pubspec = await readFileOrNull(join5(dir, "pubspec.yaml"));
|
|
1007
1404
|
if (pubspec && /flutter:/i.test(pubspec)) {
|
|
1008
1405
|
results.push({
|
|
1009
1406
|
component: "mobile",
|
|
@@ -1012,7 +1409,7 @@ async function scanDirectory(dir, relPath) {
|
|
|
1012
1409
|
evidence: "pubspec.yaml has flutter dependency"
|
|
1013
1410
|
});
|
|
1014
1411
|
}
|
|
1015
|
-
const hasTf =
|
|
1412
|
+
const hasTf = existsSync5(join5(dir, "main.tf")) || existsSync5(join5(dir, "variables.tf")) || existsSync5(join5(dir, "stack/main.tf")) || existsSync5(join5(dir, "versions.tf"));
|
|
1016
1413
|
if (hasTf) {
|
|
1017
1414
|
results.push({
|
|
1018
1415
|
component: "infra",
|
|
@@ -1024,7 +1421,7 @@ async function scanDirectory(dir, relPath) {
|
|
|
1024
1421
|
return results;
|
|
1025
1422
|
}
|
|
1026
1423
|
async function readPkg(dir) {
|
|
1027
|
-
const content = await readFileOrNull(
|
|
1424
|
+
const content = await readFileOrNull(join5(dir, "package.json"));
|
|
1028
1425
|
if (!content) return null;
|
|
1029
1426
|
try {
|
|
1030
1427
|
return JSON.parse(content);
|
|
@@ -1037,7 +1434,7 @@ async function readPkg(dir) {
|
|
|
1037
1434
|
async function init(cwd, localRepo) {
|
|
1038
1435
|
p5.intro("projx init");
|
|
1039
1436
|
const isLocal = !!localRepo;
|
|
1040
|
-
if (
|
|
1437
|
+
if (existsSync6(join6(cwd, ".projx"))) {
|
|
1041
1438
|
p5.log.error(
|
|
1042
1439
|
"This project is already initialized. Use 'npx create-projx update' or 'npx create-projx add' instead."
|
|
1043
1440
|
);
|
|
@@ -1053,10 +1450,10 @@ async function init(cwd, localRepo) {
|
|
|
1053
1450
|
p5.log.error("You have uncommitted changes. Commit or stash them first.");
|
|
1054
1451
|
process.exit(1);
|
|
1055
1452
|
}
|
|
1056
|
-
const
|
|
1057
|
-
|
|
1453
|
+
const spinner6 = p5.spinner();
|
|
1454
|
+
spinner6.start("Scanning for components");
|
|
1058
1455
|
const detected = await detectComponents(cwd);
|
|
1059
|
-
|
|
1456
|
+
spinner6.stop(
|
|
1060
1457
|
detected.length > 0 ? `Found ${detected.length} component(s).` : "No components detected."
|
|
1061
1458
|
);
|
|
1062
1459
|
if (detected.length === 0) {
|
|
@@ -1076,7 +1473,7 @@ async function init(cwd, localRepo) {
|
|
|
1076
1473
|
confirmed.map((c) => [c.component, c.directory])
|
|
1077
1474
|
);
|
|
1078
1475
|
const hasJs = components.some(
|
|
1079
|
-
(c) => ["fastify", "frontend", "e2e"].includes(c)
|
|
1476
|
+
(c) => ["fastify", "express", "frontend", "e2e"].includes(c)
|
|
1080
1477
|
);
|
|
1081
1478
|
let pm = "npm";
|
|
1082
1479
|
if (hasJs) {
|
|
@@ -1099,7 +1496,8 @@ async function init(cwd, localRepo) {
|
|
|
1099
1496
|
projectName,
|
|
1100
1497
|
components,
|
|
1101
1498
|
paths,
|
|
1102
|
-
pm: pmCommands(pm)
|
|
1499
|
+
pm: pmCommands(pm),
|
|
1500
|
+
orm: "prisma"
|
|
1103
1501
|
};
|
|
1104
1502
|
const dlSpinner = p5.spinner();
|
|
1105
1503
|
dlSpinner.start(
|
|
@@ -1113,7 +1511,7 @@ async function init(cwd, localRepo) {
|
|
|
1113
1511
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
1114
1512
|
try {
|
|
1115
1513
|
const pkg = JSON.parse(
|
|
1116
|
-
await
|
|
1514
|
+
await readFile5(join6(repoDir, "cli/package.json"), "utf-8")
|
|
1117
1515
|
);
|
|
1118
1516
|
const version = pkg.version;
|
|
1119
1517
|
const applySpinner = p5.spinner();
|
|
@@ -1130,7 +1528,7 @@ async function init(cwd, localRepo) {
|
|
|
1130
1528
|
true
|
|
1131
1529
|
);
|
|
1132
1530
|
applySpinner.stop("Template applied.");
|
|
1133
|
-
if (
|
|
1531
|
+
if (existsSync6(join6(cwd, ".githooks"))) {
|
|
1134
1532
|
try {
|
|
1135
1533
|
execSync2("git config core.hooksPath .githooks", { cwd, stdio: "pipe" });
|
|
1136
1534
|
} catch {
|
|
@@ -1179,7 +1577,7 @@ async function writeBareProjx(cwd, localRepo, isLocal, pm) {
|
|
|
1179
1577
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
1180
1578
|
try {
|
|
1181
1579
|
const pkg = JSON.parse(
|
|
1182
|
-
await
|
|
1580
|
+
await readFile5(join6(repoDir, "cli/package.json"), "utf-8")
|
|
1183
1581
|
);
|
|
1184
1582
|
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1185
1583
|
const config = {
|
|
@@ -1228,8 +1626,8 @@ function hasUncommittedChanges2(cwd) {
|
|
|
1228
1626
|
}
|
|
1229
1627
|
|
|
1230
1628
|
// src/pin.ts
|
|
1231
|
-
import { existsSync as
|
|
1232
|
-
import { join as
|
|
1629
|
+
import { existsSync as existsSync7 } from "fs";
|
|
1630
|
+
import { join as join7 } from "path";
|
|
1233
1631
|
import * as p6 from "@clack/prompts";
|
|
1234
1632
|
function classifyPattern(pattern, componentPaths) {
|
|
1235
1633
|
const dirToComponent = {};
|
|
@@ -1249,7 +1647,7 @@ function classifyPattern(pattern, componentPaths) {
|
|
|
1249
1647
|
}
|
|
1250
1648
|
async function pin(cwd, patterns) {
|
|
1251
1649
|
p6.intro("projx pin");
|
|
1252
|
-
if (!
|
|
1650
|
+
if (!existsSync7(join7(cwd, ".projx"))) {
|
|
1253
1651
|
p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
|
|
1254
1652
|
process.exit(1);
|
|
1255
1653
|
}
|
|
@@ -1262,20 +1660,20 @@ async function pin(cwd, patterns) {
|
|
|
1262
1660
|
p6.log.warn(`Cannot pin ${pattern} \u2014 config files are managed by projx.`);
|
|
1263
1661
|
continue;
|
|
1264
1662
|
}
|
|
1265
|
-
const { scope, component, relative } = classifyPattern(
|
|
1663
|
+
const { scope, component, relative: relative2 } = classifyPattern(
|
|
1266
1664
|
pattern,
|
|
1267
1665
|
componentPaths
|
|
1268
1666
|
);
|
|
1269
1667
|
if (scope === "component" && component) {
|
|
1270
1668
|
if (!componentAdds[component]) componentAdds[component] = [];
|
|
1271
|
-
componentAdds[component].push(
|
|
1669
|
+
componentAdds[component].push(relative2);
|
|
1272
1670
|
} else {
|
|
1273
|
-
rootAdds.push(
|
|
1671
|
+
rootAdds.push(relative2);
|
|
1274
1672
|
}
|
|
1275
1673
|
}
|
|
1276
1674
|
for (const [component, additions] of Object.entries(componentAdds)) {
|
|
1277
1675
|
const dir = componentPaths[component];
|
|
1278
|
-
const marker = await readComponentMarker(
|
|
1676
|
+
const marker = await readComponentMarker(join7(cwd, dir));
|
|
1279
1677
|
if (!marker) {
|
|
1280
1678
|
p6.log.error(`Could not read marker for ${component}.`);
|
|
1281
1679
|
continue;
|
|
@@ -1284,7 +1682,7 @@ async function pin(cwd, patterns) {
|
|
|
1284
1682
|
const added = merged.length - marker.skip.length;
|
|
1285
1683
|
if (added > 0) {
|
|
1286
1684
|
const next = { ...marker, skip: merged };
|
|
1287
|
-
await writeComponentMarker(
|
|
1685
|
+
await writeComponentMarker(join7(cwd, dir), next);
|
|
1288
1686
|
p6.log.success(`${component}: pinned ${additions.join(", ")}`);
|
|
1289
1687
|
} else {
|
|
1290
1688
|
p6.log.info(`${component}: already pinned.`);
|
|
@@ -1305,7 +1703,7 @@ async function pin(cwd, patterns) {
|
|
|
1305
1703
|
}
|
|
1306
1704
|
async function unpin(cwd, patterns) {
|
|
1307
1705
|
p6.intro("projx unpin");
|
|
1308
|
-
if (!
|
|
1706
|
+
if (!existsSync7(join7(cwd, ".projx"))) {
|
|
1309
1707
|
p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
|
|
1310
1708
|
process.exit(1);
|
|
1311
1709
|
}
|
|
@@ -1314,20 +1712,20 @@ async function unpin(cwd, patterns) {
|
|
|
1314
1712
|
const rootRemoves = [];
|
|
1315
1713
|
const componentRemoves = {};
|
|
1316
1714
|
for (const pattern of patterns) {
|
|
1317
|
-
const { scope, component, relative } = classifyPattern(
|
|
1715
|
+
const { scope, component, relative: relative2 } = classifyPattern(
|
|
1318
1716
|
pattern,
|
|
1319
1717
|
componentPaths
|
|
1320
1718
|
);
|
|
1321
1719
|
if (scope === "component" && component) {
|
|
1322
1720
|
if (!componentRemoves[component]) componentRemoves[component] = [];
|
|
1323
|
-
componentRemoves[component].push(
|
|
1721
|
+
componentRemoves[component].push(relative2);
|
|
1324
1722
|
} else {
|
|
1325
|
-
rootRemoves.push(
|
|
1723
|
+
rootRemoves.push(relative2);
|
|
1326
1724
|
}
|
|
1327
1725
|
}
|
|
1328
1726
|
for (const [component, removals] of Object.entries(componentRemoves)) {
|
|
1329
1727
|
const dir = componentPaths[component];
|
|
1330
|
-
const marker = await readComponentMarker(
|
|
1728
|
+
const marker = await readComponentMarker(join7(cwd, dir));
|
|
1331
1729
|
if (!marker) {
|
|
1332
1730
|
p6.log.error(`Could not read marker for ${component}.`);
|
|
1333
1731
|
continue;
|
|
@@ -1336,7 +1734,7 @@ async function unpin(cwd, patterns) {
|
|
|
1336
1734
|
const removed = marker.skip.length - filtered.length;
|
|
1337
1735
|
if (removed > 0) {
|
|
1338
1736
|
const next = { ...marker, skip: filtered };
|
|
1339
|
-
await writeComponentMarker(
|
|
1737
|
+
await writeComponentMarker(join7(cwd, dir), next);
|
|
1340
1738
|
p6.log.success(`${component}: unpinned ${removals.join(", ")}`);
|
|
1341
1739
|
} else {
|
|
1342
1740
|
p6.log.info(`${component}: not found in skip list.`);
|
|
@@ -1357,7 +1755,7 @@ async function unpin(cwd, patterns) {
|
|
|
1357
1755
|
}
|
|
1358
1756
|
async function listPins(cwd) {
|
|
1359
1757
|
p6.intro("projx pin --list");
|
|
1360
|
-
if (!
|
|
1758
|
+
if (!existsSync7(join7(cwd, ".projx"))) {
|
|
1361
1759
|
p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
|
|
1362
1760
|
process.exit(1);
|
|
1363
1761
|
}
|
|
@@ -1374,7 +1772,7 @@ async function listPins(cwd) {
|
|
|
1374
1772
|
}
|
|
1375
1773
|
for (const component of discovered) {
|
|
1376
1774
|
const dir = componentPaths[component];
|
|
1377
|
-
const marker = await readComponentMarker(
|
|
1775
|
+
const marker = await readComponentMarker(join7(cwd, dir));
|
|
1378
1776
|
if (marker?.skip && marker.skip.length > 0) {
|
|
1379
1777
|
hasAny = true;
|
|
1380
1778
|
const label = dir !== component ? `${component} (${dir}/)` : `${component}`;
|
|
@@ -1391,15 +1789,15 @@ async function listPins(cwd) {
|
|
|
1391
1789
|
}
|
|
1392
1790
|
|
|
1393
1791
|
// src/doctor.ts
|
|
1394
|
-
import { existsSync as
|
|
1395
|
-
import { readdir as
|
|
1792
|
+
import { existsSync as existsSync8 } from "fs";
|
|
1793
|
+
import { readdir as readdir3 } from "fs/promises";
|
|
1396
1794
|
import { execSync as execSync3 } from "child_process";
|
|
1397
|
-
import { join as
|
|
1795
|
+
import { join as join8 } from "path";
|
|
1398
1796
|
import * as p7 from "@clack/prompts";
|
|
1399
1797
|
async function checkConfig(cwd) {
|
|
1400
1798
|
const results = [];
|
|
1401
|
-
const configPath =
|
|
1402
|
-
if (!
|
|
1799
|
+
const configPath = join8(cwd, ".projx");
|
|
1800
|
+
if (!existsSync8(configPath)) {
|
|
1403
1801
|
results.push({
|
|
1404
1802
|
name: ".projx exists",
|
|
1405
1803
|
status: "fail",
|
|
@@ -1449,8 +1847,8 @@ async function checkComponents(cwd, components, componentPaths) {
|
|
|
1449
1847
|
});
|
|
1450
1848
|
for (const component of components) {
|
|
1451
1849
|
const dir = componentPaths[component];
|
|
1452
|
-
const fullDir =
|
|
1453
|
-
if (!
|
|
1850
|
+
const fullDir = join8(cwd, dir);
|
|
1851
|
+
if (!existsSync8(fullDir)) {
|
|
1454
1852
|
results.push({
|
|
1455
1853
|
name: `${component} directory`,
|
|
1456
1854
|
status: "fail",
|
|
@@ -1590,10 +1988,10 @@ async function checkSkipPatterns(cwd, rootConfig, components, componentPaths) {
|
|
|
1590
1988
|
}
|
|
1591
1989
|
for (const component of components) {
|
|
1592
1990
|
const dir = componentPaths[component];
|
|
1593
|
-
const marker = await readComponentMarker(
|
|
1991
|
+
const marker = await readComponentMarker(join8(cwd, dir));
|
|
1594
1992
|
if (marker?.skip && marker.skip.length > 0) {
|
|
1595
1993
|
for (const pattern of marker.skip) {
|
|
1596
|
-
const matches = await patternMatchesAnything(
|
|
1994
|
+
const matches = await patternMatchesAnything(join8(cwd, dir), pattern);
|
|
1597
1995
|
if (!matches) {
|
|
1598
1996
|
results.push({
|
|
1599
1997
|
name: `${component} skip`,
|
|
@@ -1615,16 +2013,16 @@ async function checkSkipPatterns(cwd, rootConfig, components, componentPaths) {
|
|
|
1615
2013
|
}
|
|
1616
2014
|
async function patternMatchesAnything(dir, pattern) {
|
|
1617
2015
|
if (pattern === "**") return true;
|
|
1618
|
-
if (!
|
|
2016
|
+
if (!existsSync8(dir)) return false;
|
|
1619
2017
|
const walk = async (current, base) => {
|
|
1620
2018
|
let entries;
|
|
1621
2019
|
try {
|
|
1622
|
-
entries = await
|
|
2020
|
+
entries = await readdir3(current, { withFileTypes: true });
|
|
1623
2021
|
} catch {
|
|
1624
2022
|
return false;
|
|
1625
2023
|
}
|
|
1626
2024
|
for (const entry of entries) {
|
|
1627
|
-
const full =
|
|
2025
|
+
const full = join8(current, entry.name);
|
|
1628
2026
|
const rel = full.slice(base.length + 1);
|
|
1629
2027
|
if (entry.isDirectory()) {
|
|
1630
2028
|
if (await walk(full, base)) return true;
|
|
@@ -1674,17 +2072,17 @@ function printReport(results) {
|
|
|
1674
2072
|
}
|
|
1675
2073
|
|
|
1676
2074
|
// src/diff.ts
|
|
1677
|
-
import { existsSync as
|
|
1678
|
-
import { readFile as
|
|
1679
|
-
import { join as
|
|
2075
|
+
import { existsSync as existsSync9 } from "fs";
|
|
2076
|
+
import { readFile as readFile6, mkdtemp, rm } from "fs/promises";
|
|
2077
|
+
import { join as join9 } from "path";
|
|
1680
2078
|
import { tmpdir } from "os";
|
|
1681
2079
|
import * as p8 from "@clack/prompts";
|
|
1682
2080
|
function isSkipped(file, componentPaths, componentSkips, rootSkip) {
|
|
1683
2081
|
for (const [component, dir] of Object.entries(componentPaths)) {
|
|
1684
2082
|
if (file.startsWith(dir + "/")) {
|
|
1685
|
-
const
|
|
2083
|
+
const relative2 = file.slice(dir.length + 1);
|
|
1686
2084
|
const skips = componentSkips[component] ?? [];
|
|
1687
|
-
if (matchesSkip(
|
|
2085
|
+
if (matchesSkip(relative2, skips)) return true;
|
|
1688
2086
|
}
|
|
1689
2087
|
}
|
|
1690
2088
|
const base = file.split("/").pop();
|
|
@@ -1700,7 +2098,7 @@ function fileComponent(file, componentPaths) {
|
|
|
1700
2098
|
async function diff(cwd, localRepo) {
|
|
1701
2099
|
p8.intro("projx diff");
|
|
1702
2100
|
const isLocal = !!localRepo;
|
|
1703
|
-
if (!
|
|
2101
|
+
if (!existsSync9(join9(cwd, ".projx"))) {
|
|
1704
2102
|
p8.log.error("No .projx file found. Run 'npx create-projx init' first.");
|
|
1705
2103
|
process.exit(1);
|
|
1706
2104
|
}
|
|
@@ -1709,7 +2107,7 @@ async function diff(cwd, localRepo) {
|
|
|
1709
2107
|
const componentSkips = {};
|
|
1710
2108
|
for (const component of components) {
|
|
1711
2109
|
const dir = componentPaths[component];
|
|
1712
|
-
const marker = await readComponentMarker(
|
|
2110
|
+
const marker = await readComponentMarker(join9(cwd, dir));
|
|
1713
2111
|
if (marker?.skip && marker.skip.length > 0) {
|
|
1714
2112
|
componentSkips[component] = marker.skip;
|
|
1715
2113
|
}
|
|
@@ -1727,7 +2125,7 @@ async function diff(cwd, localRepo) {
|
|
|
1727
2125
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
1728
2126
|
try {
|
|
1729
2127
|
const pkg = JSON.parse(
|
|
1730
|
-
await
|
|
2128
|
+
await readFile6(join9(repoDir, "cli/package.json"), "utf-8")
|
|
1731
2129
|
);
|
|
1732
2130
|
const version = pkg.version;
|
|
1733
2131
|
p8.log.info(`Current: v${raw.version ?? "unknown"} \u2192 Template: v${version}`);
|
|
@@ -1736,12 +2134,12 @@ async function diff(cwd, localRepo) {
|
|
|
1736
2134
|
projectName: name,
|
|
1737
2135
|
components,
|
|
1738
2136
|
paths: componentPaths,
|
|
1739
|
-
pm: pmCommands(raw.packageManager ?? "npm")
|
|
2137
|
+
pm: pmCommands(raw.packageManager ?? "npm"),
|
|
2138
|
+
orm: raw.orm ?? "prisma"
|
|
1740
2139
|
};
|
|
1741
|
-
const
|
|
1742
|
-
|
|
1743
|
-
const tmpTemplate =
|
|
1744
|
-
await mkdir2(tmpTemplate, { recursive: true });
|
|
2140
|
+
const spinner6 = p8.spinner();
|
|
2141
|
+
spinner6.start("Analyzing changes");
|
|
2142
|
+
const tmpTemplate = await mkdtemp(join9(tmpdir(), "projx-diff-"));
|
|
1745
2143
|
await writeTemplateToDir(
|
|
1746
2144
|
tmpTemplate,
|
|
1747
2145
|
repoDir,
|
|
@@ -1764,16 +2162,16 @@ async function diff(cwd, localRepo) {
|
|
|
1764
2162
|
analyses.push({ file, status: "skipped", component });
|
|
1765
2163
|
continue;
|
|
1766
2164
|
}
|
|
1767
|
-
const oursPath =
|
|
1768
|
-
if (!
|
|
2165
|
+
const oursPath = join9(cwd, file);
|
|
2166
|
+
if (!existsSync9(oursPath)) {
|
|
1769
2167
|
analyses.push({ file, status: "new", component });
|
|
1770
2168
|
continue;
|
|
1771
2169
|
}
|
|
1772
2170
|
let oursContent;
|
|
1773
2171
|
let theirsContent;
|
|
1774
2172
|
try {
|
|
1775
|
-
oursContent = await
|
|
1776
|
-
theirsContent = await
|
|
2173
|
+
oursContent = await readFile6(oursPath, "utf-8");
|
|
2174
|
+
theirsContent = await readFile6(join9(tmpTemplate, file), "utf-8");
|
|
1777
2175
|
} catch {
|
|
1778
2176
|
continue;
|
|
1779
2177
|
}
|
|
@@ -1799,7 +2197,7 @@ async function diff(cwd, localRepo) {
|
|
|
1799
2197
|
}
|
|
1800
2198
|
}
|
|
1801
2199
|
await rm(tmpTemplate, { recursive: true, force: true });
|
|
1802
|
-
|
|
2200
|
+
spinner6.stop("Analysis complete.");
|
|
1803
2201
|
const groups = {
|
|
1804
2202
|
new: [],
|
|
1805
2203
|
"clean-update": [],
|
|
@@ -1853,9 +2251,9 @@ async function diff(cwd, localRepo) {
|
|
|
1853
2251
|
}
|
|
1854
2252
|
|
|
1855
2253
|
// src/gen.ts
|
|
1856
|
-
import { existsSync as
|
|
1857
|
-
import { readFile as
|
|
1858
|
-
import { join as
|
|
2254
|
+
import { existsSync as existsSync10 } from "fs";
|
|
2255
|
+
import { readFile as readFile7, writeFile as writeFile2, mkdir as mkdir3 } from "fs/promises";
|
|
2256
|
+
import { join as join10 } from "path";
|
|
1859
2257
|
import * as p9 from "@clack/prompts";
|
|
1860
2258
|
var FIELD_TYPES = [
|
|
1861
2259
|
"string",
|
|
@@ -1928,11 +2326,23 @@ async function promptEntityConfig(name) {
|
|
|
1928
2326
|
initialValue: true
|
|
1929
2327
|
});
|
|
1930
2328
|
if (p9.isCancel(required)) process.exit(0);
|
|
1931
|
-
fields.push({
|
|
2329
|
+
fields.push({
|
|
2330
|
+
name: toSnake(fieldName),
|
|
2331
|
+
type: fieldType,
|
|
2332
|
+
required,
|
|
2333
|
+
unique: false,
|
|
2334
|
+
generated: false
|
|
2335
|
+
});
|
|
1932
2336
|
}
|
|
1933
2337
|
if (fields.length === 0) {
|
|
1934
2338
|
p9.log.warn("No fields defined. Adding a default 'name' field.");
|
|
1935
|
-
fields.push({
|
|
2339
|
+
fields.push({
|
|
2340
|
+
name: "name",
|
|
2341
|
+
type: "string",
|
|
2342
|
+
required: true,
|
|
2343
|
+
unique: false,
|
|
2344
|
+
generated: false
|
|
2345
|
+
});
|
|
1936
2346
|
}
|
|
1937
2347
|
const stringFields = fields.filter(
|
|
1938
2348
|
(f) => f.type === "string" || f.type === "text"
|
|
@@ -1965,7 +2375,14 @@ function parseFieldsFlag(raw) {
|
|
|
1965
2375
|
const required = nameType.endsWith("!");
|
|
1966
2376
|
const name = toSnake(required ? nameType.slice(0, -1) : nameType);
|
|
1967
2377
|
const type = rest[0] || "string";
|
|
1968
|
-
|
|
2378
|
+
const modifiers = new Set(rest.slice(1).map((item) => item.toLowerCase()));
|
|
2379
|
+
return {
|
|
2380
|
+
name,
|
|
2381
|
+
type,
|
|
2382
|
+
required: required || true,
|
|
2383
|
+
unique: modifiers.has("unique") || modifiers.has("@unique"),
|
|
2384
|
+
generated: modifiers.has("generated") || modifiers.has("server") || modifiers.has("server-generated")
|
|
2385
|
+
};
|
|
1969
2386
|
});
|
|
1970
2387
|
}
|
|
1971
2388
|
function sqlalchemyType(type) {
|
|
@@ -2088,24 +2505,6 @@ function typeboxOptional(type) {
|
|
|
2088
2505
|
return "Type.Optional(Type.Any())";
|
|
2089
2506
|
}
|
|
2090
2507
|
}
|
|
2091
|
-
function fieldMetaType(type) {
|
|
2092
|
-
switch (type) {
|
|
2093
|
-
case "string":
|
|
2094
|
-
return { type: "str", fieldType: "text" };
|
|
2095
|
-
case "number":
|
|
2096
|
-
return { type: "int", fieldType: "number" };
|
|
2097
|
-
case "boolean":
|
|
2098
|
-
return { type: "bool", fieldType: "boolean" };
|
|
2099
|
-
case "date":
|
|
2100
|
-
return { type: "date", fieldType: "date" };
|
|
2101
|
-
case "datetime":
|
|
2102
|
-
return { type: "datetime", fieldType: "datetime" };
|
|
2103
|
-
case "text":
|
|
2104
|
-
return { type: "str", fieldType: "textarea" };
|
|
2105
|
-
case "json":
|
|
2106
|
-
return { type: "dict", fieldType: "textarea" };
|
|
2107
|
-
}
|
|
2108
|
-
}
|
|
2109
2508
|
function prismaType(type, required) {
|
|
2110
2509
|
const nullable = required ? "" : "?";
|
|
2111
2510
|
switch (type) {
|
|
@@ -2125,6 +2524,10 @@ function prismaType(type, required) {
|
|
|
2125
2524
|
return `Json${nullable}`;
|
|
2126
2525
|
}
|
|
2127
2526
|
}
|
|
2527
|
+
function prismaFieldType(field) {
|
|
2528
|
+
const base = prismaType(field.type, field.required);
|
|
2529
|
+
return field.unique ? `${base} @unique` : base;
|
|
2530
|
+
}
|
|
2128
2531
|
function generateFastifySchemas(config) {
|
|
2129
2532
|
const className = toPascal(config.name);
|
|
2130
2533
|
const lines = [];
|
|
@@ -2146,7 +2549,7 @@ function generateFastifySchemas(config) {
|
|
|
2146
2549
|
lines.push(`export type ${className} = Static<typeof ${className}Schema>;`);
|
|
2147
2550
|
lines.push("");
|
|
2148
2551
|
lines.push(`export const Create${className}Schema = Type.Object({`);
|
|
2149
|
-
for (const f of config.fields) {
|
|
2552
|
+
for (const f of config.fields.filter((field) => !field.generated)) {
|
|
2150
2553
|
if (f.required) {
|
|
2151
2554
|
lines.push(` ${f.name}: ${typeboxType(f.type, true)},`);
|
|
2152
2555
|
} else {
|
|
@@ -2160,7 +2563,7 @@ function generateFastifySchemas(config) {
|
|
|
2160
2563
|
);
|
|
2161
2564
|
lines.push("");
|
|
2162
2565
|
lines.push(`export const Update${className}Schema = Type.Object({`);
|
|
2163
|
-
for (const f of config.fields) {
|
|
2566
|
+
for (const f of config.fields.filter((field) => !field.generated)) {
|
|
2164
2567
|
lines.push(` ${f.name}: ${typeboxOptional(f.type)},`);
|
|
2165
2568
|
}
|
|
2166
2569
|
lines.push(`});`);
|
|
@@ -2174,44 +2577,26 @@ function generateFastifySchemas(config) {
|
|
|
2174
2577
|
function generateFastifyIndex(config) {
|
|
2175
2578
|
const className = toPascal(config.name);
|
|
2176
2579
|
const camelConfig = className.charAt(0).toLowerCase() + className.slice(1) + "Config";
|
|
2177
|
-
const
|
|
2178
|
-
"id",
|
|
2179
|
-
...config.fields.map((f) => f.name),
|
|
2180
|
-
"created_at",
|
|
2181
|
-
"updated_at"
|
|
2182
|
-
];
|
|
2183
|
-
if (config.softDelete) allColumns.push("deleted_at");
|
|
2580
|
+
const generatedFields = config.fields.filter((field) => field.generated);
|
|
2184
2581
|
const lines = [];
|
|
2185
2582
|
lines.push(
|
|
2186
|
-
`import { EntityRegistry, type EntityConfig
|
|
2583
|
+
`import { EntityRegistry, type EntityConfig } from '../_base/index.js';`
|
|
2187
2584
|
);
|
|
2585
|
+
if (generatedFields.length > 0) {
|
|
2586
|
+
lines.push(`import { randomBytes } from 'node:crypto';`);
|
|
2587
|
+
}
|
|
2188
2588
|
lines.push(
|
|
2189
2589
|
`import { ${className}Schema, Create${className}Schema, Update${className}Schema } from './schemas.js';`
|
|
2190
2590
|
);
|
|
2191
2591
|
lines.push("");
|
|
2192
|
-
|
|
2193
|
-
lines.push(
|
|
2194
|
-
` { key: 'id', label: 'Id', type: 'str', nullable: false, is_auto: true, is_primary_key: true, filterable: true, has_foreign_key: false, field_type: 'text' },`
|
|
2195
|
-
);
|
|
2196
|
-
for (const f of config.fields) {
|
|
2197
|
-
const meta = fieldMetaType(f.type);
|
|
2198
|
-
lines.push(
|
|
2199
|
-
` { key: '${f.name}', label: '${toTitle(f.name)}', type: '${meta.type}', nullable: ${!f.required}, is_auto: false, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: '${meta.fieldType}' },`
|
|
2200
|
-
);
|
|
2201
|
-
}
|
|
2202
|
-
lines.push(
|
|
2203
|
-
` { key: 'created_at', label: 'Created At', type: 'datetime', nullable: false, is_auto: true, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: 'datetime' },`
|
|
2204
|
-
);
|
|
2205
|
-
lines.push(
|
|
2206
|
-
` { key: 'updated_at', label: 'Updated At', type: 'datetime', nullable: false, is_auto: true, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: 'datetime' },`
|
|
2207
|
-
);
|
|
2208
|
-
if (config.softDelete) {
|
|
2592
|
+
for (const field of generatedFields) {
|
|
2209
2593
|
lines.push(
|
|
2210
|
-
`
|
|
2594
|
+
`function generate${className}${toPascal(field.name)}(): string {`
|
|
2211
2595
|
);
|
|
2596
|
+
lines.push(` return randomBytes(8).toString('hex').toUpperCase();`);
|
|
2597
|
+
lines.push(`}`);
|
|
2598
|
+
lines.push("");
|
|
2212
2599
|
}
|
|
2213
|
-
lines.push(`];`);
|
|
2214
|
-
lines.push("");
|
|
2215
2600
|
const tags = config.apiPrefix.replace(/^\//, "");
|
|
2216
2601
|
lines.push(`export const ${camelConfig}: EntityConfig = {`);
|
|
2217
2602
|
lines.push(` name: '${className}',`);
|
|
@@ -2222,7 +2607,6 @@ function generateFastifyIndex(config) {
|
|
|
2222
2607
|
lines.push(` readonly: ${config.readonly},`);
|
|
2223
2608
|
lines.push(` softDelete: ${config.softDelete},`);
|
|
2224
2609
|
lines.push(` bulkOperations: ${config.bulkOperations},`);
|
|
2225
|
-
lines.push(` columnNames: [${allColumns.map((c) => `'${c}'`).join(", ")}],`);
|
|
2226
2610
|
if (config.searchableFields.length > 0) {
|
|
2227
2611
|
lines.push(
|
|
2228
2612
|
` searchableFields: [${config.searchableFields.map((f) => `'${f}'`).join(", ")}],`
|
|
@@ -2230,10 +2614,25 @@ function generateFastifyIndex(config) {
|
|
|
2230
2614
|
} else {
|
|
2231
2615
|
lines.push(` searchableFields: [],`);
|
|
2232
2616
|
}
|
|
2233
|
-
lines.push(` fields,`);
|
|
2234
2617
|
lines.push(` schema: ${className}Schema,`);
|
|
2235
2618
|
lines.push(` createSchema: Create${className}Schema,`);
|
|
2236
2619
|
lines.push(` updateSchema: Update${className}Schema,`);
|
|
2620
|
+
if (generatedFields.length > 0) {
|
|
2621
|
+
lines.push(
|
|
2622
|
+
` beforeCreateFields: [${generatedFields.map((field) => `'${field.name}'`).join(", ")}],`
|
|
2623
|
+
);
|
|
2624
|
+
lines.push(` beforeCreate: (_request, data) => {`);
|
|
2625
|
+
for (const field of generatedFields) {
|
|
2626
|
+
lines.push(
|
|
2627
|
+
` if (!('${field.name}' in data) || data.${field.name} == null) {`
|
|
2628
|
+
);
|
|
2629
|
+
lines.push(
|
|
2630
|
+
` data.${field.name} = generate${className}${toPascal(field.name)}();`
|
|
2631
|
+
);
|
|
2632
|
+
lines.push(` }`);
|
|
2633
|
+
}
|
|
2634
|
+
lines.push(` },`);
|
|
2635
|
+
}
|
|
2237
2636
|
lines.push(`};`);
|
|
2238
2637
|
lines.push("");
|
|
2239
2638
|
lines.push(`EntityRegistry.register(${camelConfig});`);
|
|
@@ -2247,7 +2646,7 @@ function generatePrismaModel(config) {
|
|
|
2247
2646
|
lines.push(` id String @id @default(uuid())`);
|
|
2248
2647
|
for (const f of config.fields) {
|
|
2249
2648
|
const padded = f.name.padEnd(10);
|
|
2250
|
-
lines.push(` ${padded} ${
|
|
2649
|
+
lines.push(` ${padded} ${prismaFieldType(f)}`);
|
|
2251
2650
|
}
|
|
2252
2651
|
if (config.softDelete) {
|
|
2253
2652
|
lines.push(` deleted_at DateTime?`);
|
|
@@ -2262,28 +2661,248 @@ function generatePrismaModel(config) {
|
|
|
2262
2661
|
lines.push(`}`);
|
|
2263
2662
|
return lines.join("\n");
|
|
2264
2663
|
}
|
|
2265
|
-
function
|
|
2266
|
-
|
|
2664
|
+
function drizzleColumn(field) {
|
|
2665
|
+
let expr;
|
|
2666
|
+
switch (field.type) {
|
|
2667
|
+
case "number":
|
|
2668
|
+
expr = `integer('${field.name}')`;
|
|
2669
|
+
break;
|
|
2670
|
+
case "boolean":
|
|
2671
|
+
expr = `boolean('${field.name}')`;
|
|
2672
|
+
break;
|
|
2673
|
+
case "date":
|
|
2674
|
+
expr = `date('${field.name}')`;
|
|
2675
|
+
break;
|
|
2676
|
+
case "datetime":
|
|
2677
|
+
expr = `timestamp('${field.name}', { withTimezone: true })`;
|
|
2678
|
+
break;
|
|
2679
|
+
case "json":
|
|
2680
|
+
expr = `jsonb('${field.name}')`;
|
|
2681
|
+
break;
|
|
2682
|
+
case "text":
|
|
2683
|
+
case "string":
|
|
2684
|
+
expr = `text('${field.name}')`;
|
|
2685
|
+
break;
|
|
2686
|
+
}
|
|
2687
|
+
if (field.required) expr += ".notNull()";
|
|
2688
|
+
if (field.unique) expr += ".unique()";
|
|
2689
|
+
return expr;
|
|
2690
|
+
}
|
|
2691
|
+
function generateDrizzleTable(config) {
|
|
2692
|
+
const lines = [];
|
|
2693
|
+
const tableConst = toCamel(pluralize(toPascal(config.name)));
|
|
2694
|
+
lines.push(`export const ${tableConst} = pgTable('${config.tableName}', {`);
|
|
2695
|
+
lines.push(` id: uuid('id').primaryKey().defaultRandom(),`);
|
|
2696
|
+
lines.push(
|
|
2697
|
+
` createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),`
|
|
2698
|
+
);
|
|
2699
|
+
lines.push(
|
|
2700
|
+
` updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),`
|
|
2701
|
+
);
|
|
2702
|
+
if (config.softDelete) {
|
|
2703
|
+
lines.push(` deletedAt: timestamp('deleted_at', { withTimezone: true }),`);
|
|
2704
|
+
}
|
|
2705
|
+
for (const field of config.fields) {
|
|
2706
|
+
lines.push(` ${toCamel(field.name)}: ${drizzleColumn(field)},`);
|
|
2707
|
+
}
|
|
2708
|
+
lines.push(`});`);
|
|
2709
|
+
return lines.join("\n");
|
|
2710
|
+
}
|
|
2711
|
+
function drizzleImports(config) {
|
|
2712
|
+
const used = /* @__PURE__ */ new Set(["pgTable", "uuid", "timestamp"]);
|
|
2713
|
+
for (const field of config.fields) {
|
|
2714
|
+
switch (field.type) {
|
|
2715
|
+
case "number":
|
|
2716
|
+
used.add("integer");
|
|
2717
|
+
break;
|
|
2718
|
+
case "boolean":
|
|
2719
|
+
used.add("boolean");
|
|
2720
|
+
break;
|
|
2721
|
+
case "date":
|
|
2722
|
+
used.add("date");
|
|
2723
|
+
break;
|
|
2724
|
+
case "datetime":
|
|
2725
|
+
used.add("timestamp");
|
|
2726
|
+
break;
|
|
2727
|
+
case "json":
|
|
2728
|
+
used.add("jsonb");
|
|
2729
|
+
break;
|
|
2730
|
+
case "text":
|
|
2731
|
+
case "string":
|
|
2732
|
+
used.add("text");
|
|
2733
|
+
break;
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
return [...used].sort();
|
|
2737
|
+
}
|
|
2738
|
+
function zodType(type, required) {
|
|
2739
|
+
const inner = (() => {
|
|
2267
2740
|
switch (type) {
|
|
2268
2741
|
case "string":
|
|
2269
2742
|
case "text":
|
|
2270
|
-
|
|
2271
|
-
case "datetime":
|
|
2272
|
-
return "string";
|
|
2743
|
+
return "z.string()";
|
|
2273
2744
|
case "number":
|
|
2274
|
-
return "number";
|
|
2745
|
+
return "z.number()";
|
|
2275
2746
|
case "boolean":
|
|
2276
|
-
return "boolean";
|
|
2747
|
+
return "z.boolean()";
|
|
2748
|
+
case "date":
|
|
2749
|
+
return "z.string().date()";
|
|
2750
|
+
case "datetime":
|
|
2751
|
+
return "z.string().datetime()";
|
|
2277
2752
|
case "json":
|
|
2278
|
-
return "
|
|
2753
|
+
return "z.unknown()";
|
|
2279
2754
|
}
|
|
2280
2755
|
})();
|
|
2281
|
-
return required ?
|
|
2756
|
+
return required ? inner : `${inner}.nullable()`;
|
|
2282
2757
|
}
|
|
2283
|
-
function
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2758
|
+
function zodOptional(type) {
|
|
2759
|
+
switch (type) {
|
|
2760
|
+
case "string":
|
|
2761
|
+
case "text":
|
|
2762
|
+
return "z.string().optional()";
|
|
2763
|
+
case "number":
|
|
2764
|
+
return "z.number().optional()";
|
|
2765
|
+
case "boolean":
|
|
2766
|
+
return "z.boolean().optional()";
|
|
2767
|
+
case "date":
|
|
2768
|
+
return "z.string().date().optional()";
|
|
2769
|
+
case "datetime":
|
|
2770
|
+
return "z.string().datetime().optional()";
|
|
2771
|
+
case "json":
|
|
2772
|
+
return "z.unknown().optional()";
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
function generateExpressSchemas(config) {
|
|
2776
|
+
const className = toPascal(config.name);
|
|
2777
|
+
const lines = [];
|
|
2778
|
+
lines.push(`import { z } from 'zod';`);
|
|
2779
|
+
lines.push("");
|
|
2780
|
+
lines.push(`export const ${className}Schema = z.object({`);
|
|
2781
|
+
lines.push(` id: z.string().uuid(),`);
|
|
2782
|
+
for (const f of config.fields) {
|
|
2783
|
+
lines.push(` ${f.name}: ${zodType(f.type, f.required)},`);
|
|
2784
|
+
}
|
|
2785
|
+
lines.push(` created_at: z.string().datetime(),`);
|
|
2786
|
+
lines.push(` updated_at: z.string().datetime(),`);
|
|
2787
|
+
if (config.softDelete)
|
|
2788
|
+
lines.push(` deleted_at: z.string().datetime().nullable(),`);
|
|
2789
|
+
lines.push(`});`);
|
|
2790
|
+
lines.push("");
|
|
2791
|
+
lines.push(`export type ${className} = z.infer<typeof ${className}Schema>;`);
|
|
2792
|
+
lines.push("");
|
|
2793
|
+
lines.push(`export const Create${className}Schema = z.object({`);
|
|
2794
|
+
for (const f of config.fields.filter((field) => !field.generated)) {
|
|
2795
|
+
if (f.required) {
|
|
2796
|
+
lines.push(` ${f.name}: ${zodType(f.type, true)},`);
|
|
2797
|
+
} else {
|
|
2798
|
+
lines.push(` ${f.name}: ${zodOptional(f.type)},`);
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
lines.push(`});`);
|
|
2802
|
+
lines.push("");
|
|
2803
|
+
lines.push(
|
|
2804
|
+
`export type Create${className} = z.infer<typeof Create${className}Schema>;`
|
|
2805
|
+
);
|
|
2806
|
+
lines.push("");
|
|
2807
|
+
lines.push(`export const Update${className}Schema = z.object({`);
|
|
2808
|
+
for (const f of config.fields.filter((field) => !field.generated)) {
|
|
2809
|
+
lines.push(` ${f.name}: ${zodOptional(f.type)},`);
|
|
2810
|
+
}
|
|
2811
|
+
lines.push(`});`);
|
|
2812
|
+
lines.push("");
|
|
2813
|
+
lines.push(
|
|
2814
|
+
`export type Update${className} = z.infer<typeof Update${className}Schema>;`
|
|
2815
|
+
);
|
|
2816
|
+
lines.push("");
|
|
2817
|
+
return lines.join("\n");
|
|
2818
|
+
}
|
|
2819
|
+
function generateExpressIndex(config) {
|
|
2820
|
+
const className = toPascal(config.name);
|
|
2821
|
+
const camelConfig = className.charAt(0).toLowerCase() + className.slice(1) + "Config";
|
|
2822
|
+
const generatedFields = config.fields.filter((field) => field.generated);
|
|
2823
|
+
const tags = config.apiPrefix.replace(/^\//, "");
|
|
2824
|
+
const lines = [];
|
|
2825
|
+
lines.push(
|
|
2826
|
+
`import { EntityRegistry, type EntityConfig } from '../_base/index.js';`
|
|
2827
|
+
);
|
|
2828
|
+
if (generatedFields.length > 0) {
|
|
2829
|
+
lines.push(`import { randomBytes } from 'node:crypto';`);
|
|
2830
|
+
}
|
|
2831
|
+
lines.push(
|
|
2832
|
+
`import { ${className}Schema, Create${className}Schema, Update${className}Schema } from './schemas.js';`
|
|
2833
|
+
);
|
|
2834
|
+
lines.push("");
|
|
2835
|
+
for (const field of generatedFields) {
|
|
2836
|
+
lines.push(
|
|
2837
|
+
`function generate${className}${toPascal(field.name)}(): string {`
|
|
2838
|
+
);
|
|
2839
|
+
lines.push(` return randomBytes(8).toString('hex').toUpperCase();`);
|
|
2840
|
+
lines.push(`}`);
|
|
2841
|
+
lines.push("");
|
|
2842
|
+
}
|
|
2843
|
+
lines.push(`export const ${camelConfig}: EntityConfig = {`);
|
|
2844
|
+
lines.push(` name: '${className}',`);
|
|
2845
|
+
lines.push(` tableName: '${config.tableName}',`);
|
|
2846
|
+
lines.push(` prismaModel: '${className}',`);
|
|
2847
|
+
lines.push(` apiPrefix: '${config.apiPrefix}',`);
|
|
2848
|
+
lines.push(` tags: ['${tags}'],`);
|
|
2849
|
+
lines.push(` readonly: ${config.readonly},`);
|
|
2850
|
+
lines.push(` softDelete: ${config.softDelete},`);
|
|
2851
|
+
lines.push(` bulkOperations: ${config.bulkOperations},`);
|
|
2852
|
+
if (config.searchableFields.length > 0) {
|
|
2853
|
+
lines.push(
|
|
2854
|
+
` searchableFields: [${config.searchableFields.map((f) => `'${f}'`).join(", ")}],`
|
|
2855
|
+
);
|
|
2856
|
+
} else {
|
|
2857
|
+
lines.push(` searchableFields: [],`);
|
|
2858
|
+
}
|
|
2859
|
+
lines.push(` schema: ${className}Schema,`);
|
|
2860
|
+
lines.push(` createSchema: Create${className}Schema,`);
|
|
2861
|
+
lines.push(` updateSchema: Update${className}Schema,`);
|
|
2862
|
+
if (generatedFields.length > 0) {
|
|
2863
|
+
lines.push(
|
|
2864
|
+
` beforeCreateFields: [${generatedFields.map((field) => `'${field.name}'`).join(", ")}],`
|
|
2865
|
+
);
|
|
2866
|
+
lines.push(` beforeCreate: (_request, data) => {`);
|
|
2867
|
+
for (const field of generatedFields) {
|
|
2868
|
+
lines.push(
|
|
2869
|
+
` if (!('${field.name}' in data) || data.${field.name} == null) {`
|
|
2870
|
+
);
|
|
2871
|
+
lines.push(
|
|
2872
|
+
` data.${field.name} = generate${className}${toPascal(field.name)}();`
|
|
2873
|
+
);
|
|
2874
|
+
lines.push(` }`);
|
|
2875
|
+
}
|
|
2876
|
+
lines.push(` },`);
|
|
2877
|
+
}
|
|
2878
|
+
lines.push(`};`);
|
|
2879
|
+
lines.push("");
|
|
2880
|
+
lines.push(`EntityRegistry.register(${camelConfig});`);
|
|
2881
|
+
lines.push("");
|
|
2882
|
+
return lines.join("\n");
|
|
2883
|
+
}
|
|
2884
|
+
function tsType(type, required) {
|
|
2885
|
+
const base = (() => {
|
|
2886
|
+
switch (type) {
|
|
2887
|
+
case "string":
|
|
2888
|
+
case "text":
|
|
2889
|
+
case "date":
|
|
2890
|
+
case "datetime":
|
|
2891
|
+
return "string";
|
|
2892
|
+
case "number":
|
|
2893
|
+
return "number";
|
|
2894
|
+
case "boolean":
|
|
2895
|
+
return "boolean";
|
|
2896
|
+
case "json":
|
|
2897
|
+
return "Record<string, unknown>";
|
|
2898
|
+
}
|
|
2899
|
+
})();
|
|
2900
|
+
return required ? base : `${base} | null`;
|
|
2901
|
+
}
|
|
2902
|
+
function generateFrontendInterface(config) {
|
|
2903
|
+
const className = toPascal(config.name);
|
|
2904
|
+
const lines = [];
|
|
2905
|
+
lines.push(`export interface ${className} {`);
|
|
2287
2906
|
lines.push(` id: string;`);
|
|
2288
2907
|
for (const f of config.fields) {
|
|
2289
2908
|
lines.push(` ${f.name}: ${tsType(f.type, f.required)};`);
|
|
@@ -2331,7 +2950,8 @@ function dartType(type, required) {
|
|
|
2331
2950
|
return required ? base : `${base}?`;
|
|
2332
2951
|
}
|
|
2333
2952
|
function toCamel(s) {
|
|
2334
|
-
|
|
2953
|
+
const pascal = toPascal(s);
|
|
2954
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
2335
2955
|
}
|
|
2336
2956
|
function dartFromJson(fieldName, type, required) {
|
|
2337
2957
|
const key = `json['${fieldName}']`;
|
|
@@ -2565,77 +3185,579 @@ function generateFastifyTest(config) {
|
|
|
2565
3185
|
const className = toPascal(config.name);
|
|
2566
3186
|
const basePath = `/api/v1${config.apiPrefix}`;
|
|
2567
3187
|
const updateField = config.fields[0];
|
|
3188
|
+
const uniqueFields = config.fields.filter((field) => field.unique);
|
|
2568
3189
|
const lines = [];
|
|
2569
3190
|
lines.push(
|
|
2570
3191
|
`import { describeCrudEntity } from '../helpers/crud-test-base.js';`
|
|
2571
3192
|
);
|
|
3193
|
+
lines.push(
|
|
3194
|
+
`import { Create${className}Schema } from '../../src/modules/${toKebab(config.name)}/schemas.js';`
|
|
3195
|
+
);
|
|
2572
3196
|
lines.push("");
|
|
2573
3197
|
lines.push(`describeCrudEntity({`);
|
|
2574
3198
|
lines.push(` entityName: '${className}',`);
|
|
2575
3199
|
lines.push(` basePath: '${basePath}',`);
|
|
2576
3200
|
lines.push(` prismaModel: '${className}',`);
|
|
2577
|
-
lines.push(`
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
3201
|
+
lines.push(` createSchema: Create${className}Schema,`);
|
|
3202
|
+
lines.push(` updatePayload: {`);
|
|
3203
|
+
lines.push(
|
|
3204
|
+
` ${updateField.name}: ${tsLiteral(updateField.type, "update")},`
|
|
3205
|
+
);
|
|
2581
3206
|
lines.push(` },`);
|
|
3207
|
+
if (uniqueFields.length > 0) {
|
|
3208
|
+
lines.push(
|
|
3209
|
+
` uniqueFields: [${uniqueFields.map((field) => `'${field.name}'`).join(", ")}],`
|
|
3210
|
+
);
|
|
3211
|
+
}
|
|
3212
|
+
lines.push(`});`);
|
|
3213
|
+
lines.push("");
|
|
3214
|
+
return lines.join("\n");
|
|
3215
|
+
}
|
|
3216
|
+
function generateExpressTest(config) {
|
|
3217
|
+
const className = toPascal(config.name);
|
|
3218
|
+
const basePath = `/api/v1${config.apiPrefix}`;
|
|
3219
|
+
const updateField = config.fields[0];
|
|
3220
|
+
const uniqueFields = config.fields.filter((field) => field.unique);
|
|
3221
|
+
const lines = [];
|
|
3222
|
+
lines.push(
|
|
3223
|
+
`import { describeCrudEntity } from '../helpers/crud-test-base.js';`
|
|
3224
|
+
);
|
|
3225
|
+
lines.push(
|
|
3226
|
+
`import { Create${className}Schema } from '../../src/modules/${toKebab(config.name)}/schemas.js';`
|
|
3227
|
+
);
|
|
3228
|
+
lines.push("");
|
|
3229
|
+
lines.push(`describeCrudEntity({`);
|
|
3230
|
+
lines.push(` entityName: '${className}',`);
|
|
3231
|
+
lines.push(` basePath: '${basePath}',`);
|
|
3232
|
+
lines.push(` prismaModel: '${className}',`);
|
|
3233
|
+
lines.push(` createSchema: Create${className}Schema,`);
|
|
2582
3234
|
lines.push(` updatePayload: {`);
|
|
2583
3235
|
lines.push(
|
|
2584
3236
|
` ${updateField.name}: ${tsLiteral(updateField.type, "update")},`
|
|
2585
3237
|
);
|
|
2586
3238
|
lines.push(` },`);
|
|
3239
|
+
if (uniqueFields.length > 0) {
|
|
3240
|
+
lines.push(
|
|
3241
|
+
` uniqueFields: [${uniqueFields.map((field) => `'${field.name}'`).join(", ")}],`
|
|
3242
|
+
);
|
|
3243
|
+
}
|
|
2587
3244
|
lines.push(`});`);
|
|
2588
3245
|
lines.push("");
|
|
2589
3246
|
return lines.join("\n");
|
|
2590
3247
|
}
|
|
2591
|
-
|
|
3248
|
+
function addonGenEntityPath(repoDir, orm, fileName) {
|
|
3249
|
+
return join10(repoDir, "addons", "orms", orm, "gen-entity", fileName);
|
|
3250
|
+
}
|
|
3251
|
+
function sampleValue(field) {
|
|
3252
|
+
switch (field.type) {
|
|
3253
|
+
case "string":
|
|
3254
|
+
case "text":
|
|
3255
|
+
return `'sample-${field.name}'`;
|
|
3256
|
+
case "number":
|
|
3257
|
+
return "1";
|
|
3258
|
+
case "boolean":
|
|
3259
|
+
return "true";
|
|
3260
|
+
case "date":
|
|
3261
|
+
return "'2025-01-01'";
|
|
3262
|
+
case "datetime":
|
|
3263
|
+
return "'2025-01-01T00:00:00Z'";
|
|
3264
|
+
case "json":
|
|
3265
|
+
return "{}";
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
function sampleJsonPayload(fields) {
|
|
3269
|
+
const props = fields.filter((f) => !f.generated).map((f) => `${f.name}: ${sampleValue(f)}`);
|
|
3270
|
+
return `{ ${props.join(", ")} }`;
|
|
3271
|
+
}
|
|
3272
|
+
function updateJsonPayload(fields) {
|
|
3273
|
+
const editable = fields.find(
|
|
3274
|
+
(f) => !f.generated && (f.type === "string" || f.type === "text")
|
|
3275
|
+
);
|
|
3276
|
+
if (editable) return `{ ${editable.name}: 'updated-${editable.name}' }`;
|
|
3277
|
+
const numeric = fields.find((f) => !f.generated && f.type === "number");
|
|
3278
|
+
if (numeric) return `{ ${numeric.name}: 2 }`;
|
|
3279
|
+
return sampleJsonPayload(fields);
|
|
3280
|
+
}
|
|
3281
|
+
function insertAtAnchor(content, anchor, insertion) {
|
|
3282
|
+
if (content.includes(insertion)) return content;
|
|
3283
|
+
const lines = content.split("\n");
|
|
3284
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3285
|
+
if (lines[i].includes(anchor)) {
|
|
3286
|
+
lines.splice(i + 1, 0, insertion);
|
|
3287
|
+
return lines.join("\n");
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
return content;
|
|
3291
|
+
}
|
|
3292
|
+
async function fillTemplate(repoDir, orm, fileName, vars) {
|
|
3293
|
+
const path = addonGenEntityPath(repoDir, orm, fileName);
|
|
3294
|
+
if (!existsSync10(path)) {
|
|
3295
|
+
throw new Error(`Addon template not found: ${path}`);
|
|
3296
|
+
}
|
|
3297
|
+
let content = await readFile7(path, "utf-8");
|
|
3298
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
3299
|
+
content = content.replaceAll(`__${key}__`, value);
|
|
3300
|
+
}
|
|
3301
|
+
return content;
|
|
3302
|
+
}
|
|
3303
|
+
function buildDrizzleEntityVars(config) {
|
|
3304
|
+
const pascal = toPascal(config.name);
|
|
3305
|
+
return {
|
|
3306
|
+
ENTITY_PASCAL: pascal,
|
|
3307
|
+
TABLE_CAMEL: toCamel(pluralize(pascal)),
|
|
3308
|
+
API_PREFIX: config.apiPrefix,
|
|
3309
|
+
TAG: config.apiPrefix.replace(/^\//, ""),
|
|
3310
|
+
SEARCHABLE_FIELDS_ARRAY: config.searchableFields.map((f) => `'${f}'`).join(", "),
|
|
3311
|
+
BULK_OPERATIONS: String(config.bulkOperations),
|
|
3312
|
+
SAMPLE_PAYLOAD: sampleJsonPayload(config.fields),
|
|
3313
|
+
UPDATE_PAYLOAD: updateJsonPayload(config.fields)
|
|
3314
|
+
};
|
|
3315
|
+
}
|
|
3316
|
+
function sequelizeFieldType(field) {
|
|
3317
|
+
switch (field.type) {
|
|
3318
|
+
case "string":
|
|
3319
|
+
return { dataType: "STRING", tsType: "string" };
|
|
3320
|
+
case "text":
|
|
3321
|
+
return { dataType: "TEXT", tsType: "string" };
|
|
3322
|
+
case "number":
|
|
3323
|
+
return { dataType: "INTEGER", tsType: "number" };
|
|
3324
|
+
case "boolean":
|
|
3325
|
+
return { dataType: "BOOLEAN", tsType: "boolean" };
|
|
3326
|
+
case "date":
|
|
3327
|
+
return { dataType: "DATEONLY", tsType: "Date" };
|
|
3328
|
+
case "datetime":
|
|
3329
|
+
return { dataType: "DATE", tsType: "Date" };
|
|
3330
|
+
case "json":
|
|
3331
|
+
return { dataType: "JSONB", tsType: "unknown" };
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
function sequelizeFieldDeclarations(fields) {
|
|
3335
|
+
return fields.map((f) => {
|
|
3336
|
+
const { tsType: tsType2 } = sequelizeFieldType(f);
|
|
3337
|
+
const nullable = f.required ? "" : " | null";
|
|
3338
|
+
return ` declare ${toCamel(f.name)}: ${tsType2}${nullable};`;
|
|
3339
|
+
}).join("\n");
|
|
3340
|
+
}
|
|
3341
|
+
function sequelizeFieldDefinitions(fields) {
|
|
3342
|
+
return fields.map((f) => {
|
|
3343
|
+
const { dataType } = sequelizeFieldType(f);
|
|
3344
|
+
const parts = [`type: DataTypes.${dataType}`];
|
|
3345
|
+
if (f.required) parts.push("allowNull: false");
|
|
3346
|
+
else parts.push("allowNull: true");
|
|
3347
|
+
if (f.unique) parts.push("unique: true");
|
|
3348
|
+
return ` ${toCamel(f.name)}: { ${parts.join(", ")} },`;
|
|
3349
|
+
}).join("\n");
|
|
3350
|
+
}
|
|
3351
|
+
function buildSequelizeEntityVars(config) {
|
|
3352
|
+
const pascal = toPascal(config.name);
|
|
3353
|
+
const kebab = toKebab(config.name);
|
|
3354
|
+
return {
|
|
3355
|
+
ENTITY_PASCAL: pascal,
|
|
3356
|
+
ENTITY_KEBAB: kebab,
|
|
3357
|
+
TABLE_NAME: config.tableName,
|
|
3358
|
+
API_PREFIX: config.apiPrefix,
|
|
3359
|
+
TAG: config.apiPrefix.replace(/^\//, ""),
|
|
3360
|
+
SEARCHABLE_FIELDS_ARRAY: config.searchableFields.map((f) => `'${f}'`).join(", "),
|
|
3361
|
+
BULK_OPERATIONS: String(config.bulkOperations),
|
|
3362
|
+
SAMPLE_PAYLOAD: sampleJsonPayload(config.fields),
|
|
3363
|
+
UPDATE_PAYLOAD: updateJsonPayload(config.fields),
|
|
3364
|
+
FIELD_DECLARATIONS: sequelizeFieldDeclarations(config.fields),
|
|
3365
|
+
FIELD_DEFINITIONS: sequelizeFieldDefinitions(config.fields)
|
|
3366
|
+
};
|
|
3367
|
+
}
|
|
3368
|
+
function typeormColumnType(field) {
|
|
3369
|
+
switch (field.type) {
|
|
3370
|
+
case "string":
|
|
3371
|
+
return "varchar";
|
|
3372
|
+
case "text":
|
|
3373
|
+
return "text";
|
|
3374
|
+
case "number":
|
|
3375
|
+
return "integer";
|
|
3376
|
+
case "boolean":
|
|
3377
|
+
return "boolean";
|
|
3378
|
+
case "date":
|
|
3379
|
+
return "date";
|
|
3380
|
+
case "datetime":
|
|
3381
|
+
return "timestamptz";
|
|
3382
|
+
case "json":
|
|
3383
|
+
return "jsonb";
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
3386
|
+
function typeormColumnTsType(field) {
|
|
3387
|
+
switch (field.type) {
|
|
3388
|
+
case "string":
|
|
3389
|
+
case "text":
|
|
3390
|
+
return "string";
|
|
3391
|
+
case "number":
|
|
3392
|
+
return "number";
|
|
3393
|
+
case "boolean":
|
|
3394
|
+
return "boolean";
|
|
3395
|
+
case "date":
|
|
3396
|
+
case "datetime":
|
|
3397
|
+
return "Date";
|
|
3398
|
+
case "json":
|
|
3399
|
+
return "unknown";
|
|
3400
|
+
}
|
|
3401
|
+
}
|
|
3402
|
+
function typeormColumnDecorators(fields) {
|
|
3403
|
+
return fields.map((f) => {
|
|
3404
|
+
const dbName = f.name;
|
|
3405
|
+
const propName = toCamel(f.name);
|
|
3406
|
+
const opts = [
|
|
3407
|
+
`type: '${typeormColumnType(f)}'`,
|
|
3408
|
+
`name: '${dbName}'`
|
|
3409
|
+
];
|
|
3410
|
+
if (!f.required) opts.push("nullable: true");
|
|
3411
|
+
if (f.unique) opts.push("unique: true");
|
|
3412
|
+
const tsType2 = typeormColumnTsType(f);
|
|
3413
|
+
const nullable = f.required ? "!" : "?";
|
|
3414
|
+
return ` @Column({ ${opts.join(", ")} })
|
|
3415
|
+
${propName}${nullable}: ${tsType2}${f.required ? "" : " | null"};`;
|
|
3416
|
+
}).join("\n\n");
|
|
3417
|
+
}
|
|
3418
|
+
function buildTypeormEntityVars(config) {
|
|
3419
|
+
const pascal = toPascal(config.name);
|
|
3420
|
+
const kebab = toKebab(config.name);
|
|
3421
|
+
return {
|
|
3422
|
+
ENTITY_PASCAL: pascal,
|
|
3423
|
+
ENTITY_KEBAB: kebab,
|
|
3424
|
+
TABLE_NAME: config.tableName,
|
|
3425
|
+
API_PREFIX: config.apiPrefix,
|
|
3426
|
+
TAG: config.apiPrefix.replace(/^\//, ""),
|
|
3427
|
+
SEARCHABLE_FIELDS_ARRAY: config.searchableFields.map((f) => `'${f}'`).join(", "),
|
|
3428
|
+
BULK_OPERATIONS: String(config.bulkOperations),
|
|
3429
|
+
SAMPLE_PAYLOAD: sampleJsonPayload(config.fields),
|
|
3430
|
+
UPDATE_PAYLOAD: updateJsonPayload(config.fields),
|
|
3431
|
+
COLUMN_DECORATORS: typeormColumnDecorators(config.fields)
|
|
3432
|
+
};
|
|
3433
|
+
}
|
|
3434
|
+
async function appendTypeormEntity(repoDir, cwd, dir, framework, config, generated) {
|
|
3435
|
+
const vars = buildTypeormEntityVars(config);
|
|
3436
|
+
const kebab = vars.ENTITY_KEBAB;
|
|
3437
|
+
const entitiesDir = join10(cwd, dir, "src/entities");
|
|
3438
|
+
await mkdir3(entitiesDir, { recursive: true });
|
|
3439
|
+
const entityPath = join10(entitiesDir, `${kebab}.ts`);
|
|
3440
|
+
if (!existsSync10(entityPath)) {
|
|
3441
|
+
const entitySource = await fillTemplate(
|
|
3442
|
+
repoDir,
|
|
3443
|
+
"typeorm",
|
|
3444
|
+
"entity.ts",
|
|
3445
|
+
vars
|
|
3446
|
+
);
|
|
3447
|
+
await writeFile2(entityPath, entitySource);
|
|
3448
|
+
generated.push(`${dir}/src/entities/${kebab}.ts`);
|
|
3449
|
+
}
|
|
3450
|
+
const entitiesIndexPath = join10(entitiesDir, "index.ts");
|
|
3451
|
+
if (existsSync10(entitiesIndexPath)) {
|
|
3452
|
+
let content = await readFile7(entitiesIndexPath, "utf-8");
|
|
3453
|
+
const importLine = `import { ${vars.ENTITY_PASCAL} } from './${kebab}.js';`;
|
|
3454
|
+
const exportLine = ` ${vars.ENTITY_PASCAL},`;
|
|
3455
|
+
const updated = insertAtAnchor(
|
|
3456
|
+
insertAtAnchor(content, "projx-anchor: model-imports", importLine),
|
|
3457
|
+
"projx-anchor: model-exports",
|
|
3458
|
+
exportLine
|
|
3459
|
+
);
|
|
3460
|
+
if (updated !== content) {
|
|
3461
|
+
content = updated;
|
|
3462
|
+
await writeFile2(entitiesIndexPath, content);
|
|
3463
|
+
generated.push(`${dir}/src/entities/index.ts (entity wired)`);
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
const moduleDir = join10(cwd, dir, "src/modules", kebab);
|
|
3467
|
+
if (!existsSync10(moduleDir)) {
|
|
3468
|
+
await mkdir3(moduleDir, { recursive: true });
|
|
3469
|
+
const routerSource = await fillTemplate(
|
|
3470
|
+
repoDir,
|
|
3471
|
+
"typeorm",
|
|
3472
|
+
framework === "fastify" ? "fastify-router.ts" : "express-router.ts",
|
|
3473
|
+
vars
|
|
3474
|
+
);
|
|
3475
|
+
await writeFile2(join10(moduleDir, "index.ts"), routerSource);
|
|
3476
|
+
generated.push(`${dir}/src/modules/${kebab}/index.ts`);
|
|
3477
|
+
}
|
|
3478
|
+
const appPath = join10(cwd, dir, "src/app.ts");
|
|
3479
|
+
if (existsSync10(appPath)) {
|
|
3480
|
+
let appContent = await readFile7(appPath, "utf-8");
|
|
3481
|
+
const importLine = `import { register${vars.ENTITY_PASCAL}Entity } from './modules/${kebab}/index.js';`;
|
|
3482
|
+
const registrationLine = framework === "fastify" ? ` await register${vars.ENTITY_PASCAL}Entity(app);` : ` register${vars.ENTITY_PASCAL}Entity(app);`;
|
|
3483
|
+
const updated = insertAtAnchor(
|
|
3484
|
+
insertAtAnchor(appContent, "projx-anchor: entity-imports", importLine),
|
|
3485
|
+
"projx-anchor: entity-registrations",
|
|
3486
|
+
registrationLine
|
|
3487
|
+
);
|
|
3488
|
+
if (updated !== appContent) {
|
|
3489
|
+
appContent = updated;
|
|
3490
|
+
await writeFile2(appPath, appContent);
|
|
3491
|
+
generated.push(`${dir}/src/app.ts (entity wired)`);
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
const testsDir = framework === "fastify" ? join10(cwd, dir, "tests/modules") : join10(cwd, dir, "tests");
|
|
3495
|
+
await mkdir3(testsDir, { recursive: true });
|
|
3496
|
+
const testFile = join10(testsDir, `${kebab}.test.ts`);
|
|
3497
|
+
if (!existsSync10(testFile)) {
|
|
3498
|
+
const testSource = await fillTemplate(
|
|
3499
|
+
repoDir,
|
|
3500
|
+
"typeorm",
|
|
3501
|
+
framework === "fastify" ? "fastify-test.ts" : "express-test.ts",
|
|
3502
|
+
vars
|
|
3503
|
+
);
|
|
3504
|
+
await writeFile2(testFile, testSource);
|
|
3505
|
+
const testRel = framework === "fastify" ? `tests/modules/${kebab}.test.ts` : `tests/${kebab}.test.ts`;
|
|
3506
|
+
generated.push(`${dir}/${testRel}`);
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
async function appendSequelizeEntity(repoDir, cwd, dir, framework, config, generated) {
|
|
3510
|
+
const vars = buildSequelizeEntityVars(config);
|
|
3511
|
+
const kebab = vars.ENTITY_KEBAB;
|
|
3512
|
+
const modelsDir = join10(cwd, dir, "src/models");
|
|
3513
|
+
await mkdir3(modelsDir, { recursive: true });
|
|
3514
|
+
const modelPath = join10(modelsDir, `${kebab}.ts`);
|
|
3515
|
+
if (!existsSync10(modelPath)) {
|
|
3516
|
+
const modelSource = await fillTemplate(
|
|
3517
|
+
repoDir,
|
|
3518
|
+
"sequelize",
|
|
3519
|
+
"model.ts",
|
|
3520
|
+
vars
|
|
3521
|
+
);
|
|
3522
|
+
await writeFile2(modelPath, modelSource);
|
|
3523
|
+
generated.push(`${dir}/src/models/${kebab}.ts`);
|
|
3524
|
+
}
|
|
3525
|
+
const modelsIndexPath = join10(modelsDir, "index.ts");
|
|
3526
|
+
if (existsSync10(modelsIndexPath)) {
|
|
3527
|
+
let content = await readFile7(modelsIndexPath, "utf-8");
|
|
3528
|
+
const importLine = `import { ${vars.ENTITY_PASCAL} } from './${kebab}.js';`;
|
|
3529
|
+
const exportLine = ` ${vars.ENTITY_PASCAL},`;
|
|
3530
|
+
const updated = insertAtAnchor(
|
|
3531
|
+
insertAtAnchor(content, "projx-anchor: model-imports", importLine),
|
|
3532
|
+
"projx-anchor: model-exports",
|
|
3533
|
+
exportLine
|
|
3534
|
+
);
|
|
3535
|
+
if (updated !== content) {
|
|
3536
|
+
content = updated;
|
|
3537
|
+
await writeFile2(modelsIndexPath, content);
|
|
3538
|
+
generated.push(`${dir}/src/models/index.ts (model wired)`);
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
const moduleDir = join10(cwd, dir, "src/modules", kebab);
|
|
3542
|
+
if (!existsSync10(moduleDir)) {
|
|
3543
|
+
await mkdir3(moduleDir, { recursive: true });
|
|
3544
|
+
const routerSource = await fillTemplate(
|
|
3545
|
+
repoDir,
|
|
3546
|
+
"sequelize",
|
|
3547
|
+
framework === "fastify" ? "fastify-router.ts" : "express-router.ts",
|
|
3548
|
+
vars
|
|
3549
|
+
);
|
|
3550
|
+
await writeFile2(join10(moduleDir, "index.ts"), routerSource);
|
|
3551
|
+
generated.push(`${dir}/src/modules/${kebab}/index.ts`);
|
|
3552
|
+
}
|
|
3553
|
+
const appPath = join10(cwd, dir, "src/app.ts");
|
|
3554
|
+
if (existsSync10(appPath)) {
|
|
3555
|
+
let appContent = await readFile7(appPath, "utf-8");
|
|
3556
|
+
const importLine = `import { register${vars.ENTITY_PASCAL}Entity } from './modules/${kebab}/index.js';`;
|
|
3557
|
+
const registrationLine = framework === "fastify" ? ` await register${vars.ENTITY_PASCAL}Entity(app);` : ` register${vars.ENTITY_PASCAL}Entity(app);`;
|
|
3558
|
+
const updated = insertAtAnchor(
|
|
3559
|
+
insertAtAnchor(appContent, "projx-anchor: entity-imports", importLine),
|
|
3560
|
+
"projx-anchor: entity-registrations",
|
|
3561
|
+
registrationLine
|
|
3562
|
+
);
|
|
3563
|
+
if (updated !== appContent) {
|
|
3564
|
+
appContent = updated;
|
|
3565
|
+
await writeFile2(appPath, appContent);
|
|
3566
|
+
generated.push(`${dir}/src/app.ts (entity wired)`);
|
|
3567
|
+
}
|
|
3568
|
+
}
|
|
3569
|
+
const testsDir = framework === "fastify" ? join10(cwd, dir, "tests/modules") : join10(cwd, dir, "tests");
|
|
3570
|
+
await mkdir3(testsDir, { recursive: true });
|
|
3571
|
+
const testFile = join10(testsDir, `${kebab}.test.ts`);
|
|
3572
|
+
if (!existsSync10(testFile)) {
|
|
3573
|
+
const testSource = await fillTemplate(
|
|
3574
|
+
repoDir,
|
|
3575
|
+
"sequelize",
|
|
3576
|
+
framework === "fastify" ? "fastify-test.ts" : "express-test.ts",
|
|
3577
|
+
vars
|
|
3578
|
+
);
|
|
3579
|
+
await writeFile2(testFile, testSource);
|
|
3580
|
+
const testRel = framework === "fastify" ? `tests/modules/${kebab}.test.ts` : `tests/${kebab}.test.ts`;
|
|
3581
|
+
generated.push(`${dir}/${testRel}`);
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
3584
|
+
async function appendDrizzleEntity(repoDir, cwd, dir, framework, config, generated) {
|
|
3585
|
+
const schemaDir = join10(cwd, dir, "src/db");
|
|
3586
|
+
const schemaPath = join10(schemaDir, "schema.ts");
|
|
3587
|
+
const tableConst = toCamel(pluralize(toPascal(config.name)));
|
|
3588
|
+
const tableSource = generateDrizzleTable(config);
|
|
3589
|
+
await mkdir3(schemaDir, { recursive: true });
|
|
3590
|
+
const usedImports = drizzleImports(config);
|
|
3591
|
+
if (!existsSync10(schemaPath)) {
|
|
3592
|
+
await writeFile2(
|
|
3593
|
+
schemaPath,
|
|
3594
|
+
`import { ${usedImports.join(", ")} } from 'drizzle-orm/pg-core';
|
|
3595
|
+
|
|
3596
|
+
${tableSource}
|
|
3597
|
+
`
|
|
3598
|
+
);
|
|
3599
|
+
generated.push(`${dir}/src/db/schema.ts`);
|
|
3600
|
+
} else {
|
|
3601
|
+
const content = await readFile7(schemaPath, "utf-8");
|
|
3602
|
+
if (!content.includes(`export const ${tableConst} = pgTable(`)) {
|
|
3603
|
+
let updated = content;
|
|
3604
|
+
const importLine = `import { ${usedImports.join(", ")} } from 'drizzle-orm/pg-core';`;
|
|
3605
|
+
if (!updated.includes("drizzle-orm/pg-core")) {
|
|
3606
|
+
updated = importLine + "\n\n" + updated;
|
|
3607
|
+
} else {
|
|
3608
|
+
updated = updated.replace(
|
|
3609
|
+
/import\s+\{([^}]+)\}\s+from\s+'drizzle-orm\/pg-core';/,
|
|
3610
|
+
(_match, imports) => {
|
|
3611
|
+
const names = new Set(
|
|
3612
|
+
String(imports).split(",").map((item) => item.trim()).filter(Boolean)
|
|
3613
|
+
);
|
|
3614
|
+
for (const name of usedImports) {
|
|
3615
|
+
names.add(name);
|
|
3616
|
+
}
|
|
3617
|
+
return `import { ${[...names].sort().join(", ")} } from 'drizzle-orm/pg-core';`;
|
|
3618
|
+
}
|
|
3619
|
+
);
|
|
3620
|
+
}
|
|
3621
|
+
await writeFile2(
|
|
3622
|
+
schemaPath,
|
|
3623
|
+
updated.trimEnd() + "\n\n" + tableSource + "\n"
|
|
3624
|
+
);
|
|
3625
|
+
generated.push(`${dir}/src/db/schema.ts (table added)`);
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
const vars = buildDrizzleEntityVars(config);
|
|
3629
|
+
const kebab = toKebab(config.name);
|
|
3630
|
+
const moduleDir = join10(cwd, dir, "src/modules", kebab);
|
|
3631
|
+
if (!existsSync10(moduleDir)) {
|
|
3632
|
+
await mkdir3(moduleDir, { recursive: true });
|
|
3633
|
+
const routerSource = await fillTemplate(
|
|
3634
|
+
repoDir,
|
|
3635
|
+
"drizzle",
|
|
3636
|
+
framework === "fastify" ? "fastify-router.ts" : "express-router.ts",
|
|
3637
|
+
vars
|
|
3638
|
+
);
|
|
3639
|
+
await writeFile2(join10(moduleDir, "index.ts"), routerSource);
|
|
3640
|
+
generated.push(`${dir}/src/modules/${kebab}/index.ts`);
|
|
3641
|
+
}
|
|
3642
|
+
const appPath = join10(cwd, dir, "src/app.ts");
|
|
3643
|
+
if (existsSync10(appPath)) {
|
|
3644
|
+
let appContent = await readFile7(appPath, "utf-8");
|
|
3645
|
+
const importLine = `import { register${vars.ENTITY_PASCAL}Entity } from './modules/${kebab}/index.js';`;
|
|
3646
|
+
const registrationLine = framework === "fastify" ? ` await register${vars.ENTITY_PASCAL}Entity(app);` : ` register${vars.ENTITY_PASCAL}Entity(app, db);`;
|
|
3647
|
+
const updated = insertAtAnchor(
|
|
3648
|
+
insertAtAnchor(appContent, "projx-anchor: entity-imports", importLine),
|
|
3649
|
+
"projx-anchor: entity-registrations",
|
|
3650
|
+
registrationLine
|
|
3651
|
+
);
|
|
3652
|
+
if (updated !== appContent) {
|
|
3653
|
+
appContent = updated;
|
|
3654
|
+
await writeFile2(appPath, appContent);
|
|
3655
|
+
generated.push(`${dir}/src/app.ts (entity wired)`);
|
|
3656
|
+
}
|
|
3657
|
+
}
|
|
3658
|
+
const testsDir = framework === "fastify" ? join10(cwd, dir, "tests/modules") : join10(cwd, dir, "tests");
|
|
3659
|
+
await mkdir3(testsDir, { recursive: true });
|
|
3660
|
+
const testFile = join10(testsDir, `${kebab}.test.ts`);
|
|
3661
|
+
if (!existsSync10(testFile)) {
|
|
3662
|
+
const testSource = await fillTemplate(
|
|
3663
|
+
repoDir,
|
|
3664
|
+
"drizzle",
|
|
3665
|
+
framework === "fastify" ? "fastify-test.ts" : "express-test.ts",
|
|
3666
|
+
vars
|
|
3667
|
+
);
|
|
3668
|
+
await writeFile2(testFile, testSource);
|
|
3669
|
+
const testRel = framework === "fastify" ? `tests/modules/${kebab}.test.ts` : `tests/${kebab}.test.ts`;
|
|
3670
|
+
generated.push(`${dir}/${testRel}`);
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
async function resolvePrimaryBackend(cwd, hasFastapi, hasFastify, hasExpress, backendFlag) {
|
|
2592
3674
|
if (backendFlag) return backendFlag;
|
|
2593
|
-
|
|
2594
|
-
|
|
3675
|
+
const backends = [
|
|
3676
|
+
hasFastapi ? "fastapi" : void 0,
|
|
3677
|
+
hasFastify ? "fastify" : void 0,
|
|
3678
|
+
hasExpress ? "express" : void 0
|
|
3679
|
+
].filter((item) => item !== void 0);
|
|
3680
|
+
if (backends.length === 1) return backends[0];
|
|
2595
3681
|
const config = await readProjxConfig(cwd);
|
|
2596
|
-
if (config.primaryBackend === "fastapi" || config.primaryBackend === "fastify") {
|
|
3682
|
+
if (config.primaryBackend === "fastapi" || config.primaryBackend === "fastify" || config.primaryBackend === "express") {
|
|
2597
3683
|
return config.primaryBackend;
|
|
2598
3684
|
}
|
|
2599
|
-
if (!process.stdin.isTTY)
|
|
3685
|
+
if (!process.stdin.isTTY) {
|
|
3686
|
+
return hasFastify ? "fastify" : hasExpress ? "express" : "fastapi";
|
|
3687
|
+
}
|
|
2600
3688
|
const choice = await p9.select({
|
|
2601
|
-
message: "
|
|
3689
|
+
message: "Multiple backends detected. Which is your primary?",
|
|
2602
3690
|
options: [
|
|
2603
|
-
{ value: "fastify", label: "fastify (API backend)" },
|
|
2604
|
-
{ value: "
|
|
3691
|
+
...hasFastify ? [{ value: "fastify", label: "fastify (API backend)" }] : [],
|
|
3692
|
+
...hasExpress ? [{ value: "express", label: "express (API backend)" }] : [],
|
|
3693
|
+
...hasFastapi ? [{ value: "fastapi", label: "fastapi (AI/ML engine)" }] : []
|
|
2605
3694
|
],
|
|
2606
|
-
initialValue: "fastify"
|
|
3695
|
+
initialValue: hasFastify ? "fastify" : hasExpress ? "express" : "fastapi"
|
|
2607
3696
|
});
|
|
2608
3697
|
if (p9.isCancel(choice)) process.exit(0);
|
|
2609
3698
|
await writeProjxConfig(cwd, { ...config, primaryBackend: choice });
|
|
2610
3699
|
p9.log.success(`Saved primaryBackend: ${choice} to .projx`);
|
|
2611
3700
|
return choice;
|
|
2612
3701
|
}
|
|
2613
|
-
async function gen(cwd, entityName, fieldsFlag, backendFlag) {
|
|
3702
|
+
async function gen(cwd, entityName, fieldsFlag, backendFlag, localRepo) {
|
|
2614
3703
|
p9.intro(`projx gen entity ${entityName}`);
|
|
2615
|
-
if (!
|
|
3704
|
+
if (!existsSync10(join10(cwd, ".projx"))) {
|
|
2616
3705
|
p9.log.error("No .projx file found. Run 'npx create-projx init' first.");
|
|
2617
3706
|
process.exit(1);
|
|
2618
3707
|
}
|
|
2619
3708
|
const projxData = await readProjxConfig(cwd);
|
|
2620
3709
|
const pmName = projxData.packageManager ?? "npm";
|
|
2621
3710
|
const pm = pmCommands(pmName);
|
|
3711
|
+
const orm = projxData.orm ?? "prisma";
|
|
3712
|
+
const needsAddon = orm === "drizzle" || orm === "sequelize" || orm === "typeorm";
|
|
3713
|
+
const repoDir = needsAddon ? await downloadRepo(localRepo).catch((err) => {
|
|
3714
|
+
p9.cancel(`Failed to fetch templates: ${err.message}`);
|
|
3715
|
+
process.exit(1);
|
|
3716
|
+
}) : "";
|
|
3717
|
+
const isLocal = Boolean(localRepo);
|
|
3718
|
+
try {
|
|
3719
|
+
return await runGen({
|
|
3720
|
+
cwd,
|
|
3721
|
+
entityName,
|
|
3722
|
+
fieldsFlag,
|
|
3723
|
+
backendFlag,
|
|
3724
|
+
pm,
|
|
3725
|
+
orm,
|
|
3726
|
+
repoDir
|
|
3727
|
+
});
|
|
3728
|
+
} finally {
|
|
3729
|
+
if (needsAddon && repoDir) {
|
|
3730
|
+
await cleanupRepo(repoDir, isLocal);
|
|
3731
|
+
}
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
async function runGen(opts) {
|
|
3735
|
+
const { cwd, entityName, fieldsFlag, backendFlag, pm, orm, repoDir } = opts;
|
|
2622
3736
|
const { components: discovered, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
|
|
2623
3737
|
const hasFastapi = discovered.includes("fastapi");
|
|
2624
3738
|
const hasFastify = discovered.includes("fastify");
|
|
3739
|
+
const hasExpress = discovered.includes("express");
|
|
2625
3740
|
const hasFrontend = discovered.includes("frontend");
|
|
2626
3741
|
const hasMobile = discovered.includes("mobile");
|
|
2627
|
-
if (!hasFastapi && !hasFastify) {
|
|
2628
|
-
p9.log.error(
|
|
3742
|
+
if (!hasFastapi && !hasFastify && !hasExpress) {
|
|
3743
|
+
p9.log.error(
|
|
3744
|
+
"No backend component found. Need fastapi, fastify, or express."
|
|
3745
|
+
);
|
|
2629
3746
|
process.exit(1);
|
|
2630
3747
|
}
|
|
2631
3748
|
const targetBackend = await resolvePrimaryBackend(
|
|
2632
3749
|
cwd,
|
|
2633
3750
|
hasFastapi,
|
|
2634
3751
|
hasFastify,
|
|
3752
|
+
hasExpress,
|
|
2635
3753
|
backendFlag
|
|
2636
3754
|
);
|
|
2637
3755
|
const genFastapi = targetBackend === "fastapi" && hasFastapi;
|
|
2638
3756
|
const genFastify = targetBackend === "fastify" && hasFastify;
|
|
3757
|
+
const genExpress = targetBackend === "express" && hasExpress;
|
|
3758
|
+
const genDrizzle = orm === "drizzle" && (genFastify || genExpress);
|
|
3759
|
+
const genSequelize = orm === "sequelize" && (genFastify || genExpress);
|
|
3760
|
+
const genTypeorm = orm === "typeorm" && (genFastify || genExpress);
|
|
2639
3761
|
let config;
|
|
2640
3762
|
if (fieldsFlag) {
|
|
2641
3763
|
const fields = parseFieldsFlag(fieldsFlag);
|
|
@@ -2658,53 +3780,140 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
|
|
|
2658
3780
|
const generated = [];
|
|
2659
3781
|
if (genFastapi) {
|
|
2660
3782
|
const dir = componentPaths.fastapi;
|
|
2661
|
-
const entityDir =
|
|
2662
|
-
if (
|
|
3783
|
+
const entityDir = join10(cwd, dir, "src/entities", toSnake(config.name));
|
|
3784
|
+
if (existsSync10(entityDir)) {
|
|
2663
3785
|
p9.log.warn(
|
|
2664
3786
|
`${dir}/src/entities/${toSnake(config.name)}/ already exists. Skipping FastAPI.`
|
|
2665
3787
|
);
|
|
2666
3788
|
} else {
|
|
2667
3789
|
await mkdir3(entityDir, { recursive: true });
|
|
2668
|
-
await
|
|
2669
|
-
|
|
3790
|
+
await writeFile2(
|
|
3791
|
+
join10(entityDir, "_model.py"),
|
|
2670
3792
|
generateFastAPIModel(config)
|
|
2671
3793
|
);
|
|
2672
|
-
await
|
|
2673
|
-
|
|
3794
|
+
await writeFile2(
|
|
3795
|
+
join10(entityDir, "__init__.py"),
|
|
2674
3796
|
"from ._model import *\n"
|
|
2675
3797
|
);
|
|
2676
3798
|
generated.push(`${dir}/src/entities/${toSnake(config.name)}/_model.py`);
|
|
2677
3799
|
generated.push(`${dir}/src/entities/${toSnake(config.name)}/__init__.py`);
|
|
2678
|
-
const testsDir =
|
|
2679
|
-
const testFile =
|
|
2680
|
-
if (
|
|
2681
|
-
await
|
|
3800
|
+
const testsDir = join10(cwd, dir, "tests");
|
|
3801
|
+
const testFile = join10(testsDir, `test_${toSnake(config.name)}_entity.py`);
|
|
3802
|
+
if (existsSync10(testsDir) && !existsSync10(testFile)) {
|
|
3803
|
+
await writeFile2(testFile, generateFastapiTest(config));
|
|
2682
3804
|
generated.push(`${dir}/tests/test_${toSnake(config.name)}_entity.py`);
|
|
2683
3805
|
}
|
|
2684
3806
|
}
|
|
2685
3807
|
}
|
|
2686
|
-
if (genFastify) {
|
|
3808
|
+
if (genFastify && orm !== "drizzle" && orm !== "sequelize" && orm !== "typeorm") {
|
|
2687
3809
|
const dir = componentPaths.fastify;
|
|
2688
|
-
const moduleDir =
|
|
2689
|
-
if (
|
|
3810
|
+
const moduleDir = join10(cwd, dir, "src/modules", toKebab(config.name));
|
|
3811
|
+
if (existsSync10(moduleDir)) {
|
|
2690
3812
|
p9.log.warn(
|
|
2691
3813
|
`${dir}/src/modules/${toKebab(config.name)}/ already exists. Skipping Fastify.`
|
|
2692
3814
|
);
|
|
2693
3815
|
} else {
|
|
2694
3816
|
await mkdir3(moduleDir, { recursive: true });
|
|
2695
|
-
await
|
|
2696
|
-
|
|
3817
|
+
await writeFile2(
|
|
3818
|
+
join10(moduleDir, "schemas.ts"),
|
|
2697
3819
|
generateFastifySchemas(config)
|
|
2698
3820
|
);
|
|
2699
|
-
await
|
|
2700
|
-
|
|
3821
|
+
await writeFile2(
|
|
3822
|
+
join10(moduleDir, "index.ts"),
|
|
2701
3823
|
generateFastifyIndex(config)
|
|
2702
3824
|
);
|
|
2703
3825
|
generated.push(`${dir}/src/modules/${toKebab(config.name)}/schemas.ts`);
|
|
2704
3826
|
generated.push(`${dir}/src/modules/${toKebab(config.name)}/index.ts`);
|
|
2705
|
-
const appPath =
|
|
2706
|
-
if (
|
|
2707
|
-
const appContent = await
|
|
3827
|
+
const appPath = join10(cwd, dir, "src/app.ts");
|
|
3828
|
+
if (existsSync10(appPath)) {
|
|
3829
|
+
const appContent = await readFile7(appPath, "utf-8");
|
|
3830
|
+
const importLine = `import './modules/${toKebab(config.name)}/index.js';`;
|
|
3831
|
+
if (!appContent.includes(importLine)) {
|
|
3832
|
+
const updated = appContent.replace(
|
|
3833
|
+
/^(import\s+'\.\/modules\/.*?';?\s*\n)/m,
|
|
3834
|
+
`$1${importLine}
|
|
3835
|
+
`
|
|
3836
|
+
);
|
|
3837
|
+
if (updated !== appContent) {
|
|
3838
|
+
await writeFile2(appPath, updated);
|
|
3839
|
+
generated.push(`${dir}/src/app.ts (import added)`);
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
const prismaPath = join10(cwd, dir, "prisma/schema.prisma");
|
|
3844
|
+
if (existsSync10(prismaPath)) {
|
|
3845
|
+
const prismaContent = await readFile7(prismaPath, "utf-8");
|
|
3846
|
+
const modelName = `model ${toPascal(config.name)}`;
|
|
3847
|
+
if (!prismaContent.includes(modelName)) {
|
|
3848
|
+
const prismaModel = generatePrismaModel(config);
|
|
3849
|
+
await writeFile2(
|
|
3850
|
+
prismaPath,
|
|
3851
|
+
prismaContent.trimEnd() + "\n\n" + prismaModel + "\n"
|
|
3852
|
+
);
|
|
3853
|
+
generated.push(`${dir}/prisma/schema.prisma (model added)`);
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
3856
|
+
const testsModulesDir = join10(cwd, dir, "tests/modules");
|
|
3857
|
+
const fastifyTestFile = join10(
|
|
3858
|
+
testsModulesDir,
|
|
3859
|
+
`${toKebab(config.name)}.test.ts`
|
|
3860
|
+
);
|
|
3861
|
+
if (existsSync10(testsModulesDir) && !existsSync10(fastifyTestFile)) {
|
|
3862
|
+
await writeFile2(fastifyTestFile, generateFastifyTest(config));
|
|
3863
|
+
generated.push(`${dir}/tests/modules/${toKebab(config.name)}.test.ts`);
|
|
3864
|
+
}
|
|
3865
|
+
}
|
|
3866
|
+
}
|
|
3867
|
+
if (genFastify && orm === "drizzle") {
|
|
3868
|
+
await appendDrizzleEntity(
|
|
3869
|
+
repoDir,
|
|
3870
|
+
cwd,
|
|
3871
|
+
componentPaths.fastify,
|
|
3872
|
+
"fastify",
|
|
3873
|
+
config,
|
|
3874
|
+
generated
|
|
3875
|
+
);
|
|
3876
|
+
} else if (genFastify && orm === "sequelize") {
|
|
3877
|
+
await appendSequelizeEntity(
|
|
3878
|
+
repoDir,
|
|
3879
|
+
cwd,
|
|
3880
|
+
componentPaths.fastify,
|
|
3881
|
+
"fastify",
|
|
3882
|
+
config,
|
|
3883
|
+
generated
|
|
3884
|
+
);
|
|
3885
|
+
} else if (genFastify && orm === "typeorm") {
|
|
3886
|
+
await appendTypeormEntity(
|
|
3887
|
+
repoDir,
|
|
3888
|
+
cwd,
|
|
3889
|
+
componentPaths.fastify,
|
|
3890
|
+
"fastify",
|
|
3891
|
+
config,
|
|
3892
|
+
generated
|
|
3893
|
+
);
|
|
3894
|
+
}
|
|
3895
|
+
if (genExpress && orm !== "drizzle" && orm !== "sequelize" && orm !== "typeorm") {
|
|
3896
|
+
const dir = componentPaths.express;
|
|
3897
|
+
const moduleDir = join10(cwd, dir, "src/modules", toKebab(config.name));
|
|
3898
|
+
if (existsSync10(moduleDir)) {
|
|
3899
|
+
p9.log.warn(
|
|
3900
|
+
`${dir}/src/modules/${toKebab(config.name)}/ already exists. Skipping Express.`
|
|
3901
|
+
);
|
|
3902
|
+
} else {
|
|
3903
|
+
await mkdir3(moduleDir, { recursive: true });
|
|
3904
|
+
await writeFile2(
|
|
3905
|
+
join10(moduleDir, "schemas.ts"),
|
|
3906
|
+
generateExpressSchemas(config)
|
|
3907
|
+
);
|
|
3908
|
+
await writeFile2(
|
|
3909
|
+
join10(moduleDir, "index.ts"),
|
|
3910
|
+
generateExpressIndex(config)
|
|
3911
|
+
);
|
|
3912
|
+
generated.push(`${dir}/src/modules/${toKebab(config.name)}/schemas.ts`);
|
|
3913
|
+
generated.push(`${dir}/src/modules/${toKebab(config.name)}/index.ts`);
|
|
3914
|
+
const appPath = join10(cwd, dir, "src/app.ts");
|
|
3915
|
+
if (existsSync10(appPath)) {
|
|
3916
|
+
const appContent = await readFile7(appPath, "utf-8");
|
|
2708
3917
|
const importLine = `import './modules/${toKebab(config.name)}/index.js';`;
|
|
2709
3918
|
if (!appContent.includes(importLine)) {
|
|
2710
3919
|
const updated = appContent.replace(
|
|
@@ -2713,75 +3922,103 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
|
|
|
2713
3922
|
`
|
|
2714
3923
|
);
|
|
2715
3924
|
if (updated !== appContent) {
|
|
2716
|
-
await
|
|
3925
|
+
await writeFile2(appPath, updated);
|
|
2717
3926
|
generated.push(`${dir}/src/app.ts (import added)`);
|
|
2718
3927
|
}
|
|
2719
3928
|
}
|
|
2720
3929
|
}
|
|
2721
|
-
const prismaPath =
|
|
2722
|
-
if (
|
|
2723
|
-
const prismaContent = await
|
|
3930
|
+
const prismaPath = join10(cwd, dir, "prisma/schema.prisma");
|
|
3931
|
+
if (existsSync10(prismaPath)) {
|
|
3932
|
+
const prismaContent = await readFile7(prismaPath, "utf-8");
|
|
2724
3933
|
const modelName = `model ${toPascal(config.name)}`;
|
|
2725
3934
|
if (!prismaContent.includes(modelName)) {
|
|
2726
3935
|
const prismaModel = generatePrismaModel(config);
|
|
2727
|
-
await
|
|
3936
|
+
await writeFile2(
|
|
2728
3937
|
prismaPath,
|
|
2729
3938
|
prismaContent.trimEnd() + "\n\n" + prismaModel + "\n"
|
|
2730
3939
|
);
|
|
2731
3940
|
generated.push(`${dir}/prisma/schema.prisma (model added)`);
|
|
2732
3941
|
}
|
|
2733
3942
|
}
|
|
2734
|
-
const testsModulesDir =
|
|
2735
|
-
const
|
|
3943
|
+
const testsModulesDir = join10(cwd, dir, "tests/modules");
|
|
3944
|
+
const expressTestFile = join10(
|
|
2736
3945
|
testsModulesDir,
|
|
2737
3946
|
`${toKebab(config.name)}.test.ts`
|
|
2738
3947
|
);
|
|
2739
|
-
if (
|
|
2740
|
-
await
|
|
3948
|
+
if (existsSync10(testsModulesDir) && !existsSync10(expressTestFile)) {
|
|
3949
|
+
await writeFile2(expressTestFile, generateExpressTest(config));
|
|
2741
3950
|
generated.push(`${dir}/tests/modules/${toKebab(config.name)}.test.ts`);
|
|
2742
3951
|
}
|
|
2743
3952
|
}
|
|
2744
3953
|
}
|
|
3954
|
+
if (genExpress && orm === "drizzle") {
|
|
3955
|
+
await appendDrizzleEntity(
|
|
3956
|
+
repoDir,
|
|
3957
|
+
cwd,
|
|
3958
|
+
componentPaths.express,
|
|
3959
|
+
"express",
|
|
3960
|
+
config,
|
|
3961
|
+
generated
|
|
3962
|
+
);
|
|
3963
|
+
} else if (genExpress && orm === "sequelize") {
|
|
3964
|
+
await appendSequelizeEntity(
|
|
3965
|
+
repoDir,
|
|
3966
|
+
cwd,
|
|
3967
|
+
componentPaths.express,
|
|
3968
|
+
"express",
|
|
3969
|
+
config,
|
|
3970
|
+
generated
|
|
3971
|
+
);
|
|
3972
|
+
} else if (genExpress && orm === "typeorm") {
|
|
3973
|
+
await appendTypeormEntity(
|
|
3974
|
+
repoDir,
|
|
3975
|
+
cwd,
|
|
3976
|
+
componentPaths.express,
|
|
3977
|
+
"express",
|
|
3978
|
+
config,
|
|
3979
|
+
generated
|
|
3980
|
+
);
|
|
3981
|
+
}
|
|
2745
3982
|
if (hasFrontend) {
|
|
2746
3983
|
const dir = componentPaths.frontend;
|
|
2747
|
-
const typesDir =
|
|
3984
|
+
const typesDir = join10(cwd, dir, "src/types");
|
|
2748
3985
|
const fileName = toKebab(config.name) + ".ts";
|
|
2749
|
-
const filePath =
|
|
2750
|
-
if (
|
|
3986
|
+
const filePath = join10(typesDir, fileName);
|
|
3987
|
+
if (existsSync10(filePath)) {
|
|
2751
3988
|
p9.log.warn(
|
|
2752
3989
|
`${dir}/src/types/${fileName} already exists. Skipping frontend types.`
|
|
2753
3990
|
);
|
|
2754
3991
|
} else {
|
|
2755
3992
|
await mkdir3(typesDir, { recursive: true });
|
|
2756
|
-
await
|
|
3993
|
+
await writeFile2(filePath, generateFrontendInterface(config));
|
|
2757
3994
|
generated.push(`${dir}/src/types/${fileName}`);
|
|
2758
|
-
const barrelPath =
|
|
3995
|
+
const barrelPath = join10(typesDir, "index.ts");
|
|
2759
3996
|
const exportLine = `export * from './${toKebab(config.name)}';`;
|
|
2760
|
-
if (
|
|
2761
|
-
const content = await
|
|
3997
|
+
if (existsSync10(barrelPath)) {
|
|
3998
|
+
const content = await readFile7(barrelPath, "utf-8");
|
|
2762
3999
|
if (!content.includes(exportLine)) {
|
|
2763
|
-
await
|
|
4000
|
+
await writeFile2(
|
|
2764
4001
|
barrelPath,
|
|
2765
4002
|
content.trimEnd() + "\n" + exportLine + "\n"
|
|
2766
4003
|
);
|
|
2767
4004
|
}
|
|
2768
4005
|
} else {
|
|
2769
|
-
await
|
|
4006
|
+
await writeFile2(barrelPath, exportLine + "\n");
|
|
2770
4007
|
}
|
|
2771
4008
|
generated.push(`${dir}/src/types/index.ts`);
|
|
2772
4009
|
}
|
|
2773
4010
|
}
|
|
2774
4011
|
if (hasMobile) {
|
|
2775
4012
|
const dir = componentPaths.mobile;
|
|
2776
|
-
const entityDir =
|
|
2777
|
-
const modelPath =
|
|
2778
|
-
if (
|
|
4013
|
+
const entityDir = join10(cwd, dir, "lib/entities", toSnake(config.name));
|
|
4014
|
+
const modelPath = join10(entityDir, "model.dart");
|
|
4015
|
+
if (existsSync10(modelPath)) {
|
|
2779
4016
|
p9.log.warn(
|
|
2780
4017
|
`${dir}/lib/entities/${toSnake(config.name)}/model.dart already exists. Skipping mobile model.`
|
|
2781
4018
|
);
|
|
2782
4019
|
} else {
|
|
2783
4020
|
await mkdir3(entityDir, { recursive: true });
|
|
2784
|
-
await
|
|
4021
|
+
await writeFile2(modelPath, generateDartModel(config));
|
|
2785
4022
|
generated.push(`${dir}/lib/entities/${toSnake(config.name)}/model.dart`);
|
|
2786
4023
|
}
|
|
2787
4024
|
}
|
|
@@ -2803,13 +4040,33 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
|
|
|
2803
4040
|
);
|
|
2804
4041
|
p9.log.info(" alembic upgrade head");
|
|
2805
4042
|
}
|
|
2806
|
-
if (genFastify) {
|
|
4043
|
+
if (genFastify && orm === "prisma") {
|
|
2807
4044
|
p9.log.info("");
|
|
2808
4045
|
p9.log.info("Fastify next steps:");
|
|
2809
4046
|
p9.log.info(
|
|
2810
4047
|
` ${pm.prismaExec} migrate dev --name add_${toSnake(config.name)}`
|
|
2811
4048
|
);
|
|
2812
4049
|
}
|
|
4050
|
+
if (genDrizzle) {
|
|
4051
|
+
p9.log.info("");
|
|
4052
|
+
p9.log.info("Drizzle next steps:");
|
|
4053
|
+
p9.log.info(` ${pm.exec} drizzle-kit generate`);
|
|
4054
|
+
p9.log.info(` ${pm.exec} drizzle-kit migrate`);
|
|
4055
|
+
}
|
|
4056
|
+
if (genSequelize) {
|
|
4057
|
+
p9.log.info("");
|
|
4058
|
+
p9.log.info("Sequelize next steps:");
|
|
4059
|
+
p9.log.info(
|
|
4060
|
+
` ${pm.run} db:sync # syncs the schema against $DATABASE_URL`
|
|
4061
|
+
);
|
|
4062
|
+
}
|
|
4063
|
+
if (genTypeorm) {
|
|
4064
|
+
p9.log.info("");
|
|
4065
|
+
p9.log.info("TypeORM next steps:");
|
|
4066
|
+
p9.log.info(
|
|
4067
|
+
` ${pm.run} db:sync # syncs the schema against $DATABASE_URL`
|
|
4068
|
+
);
|
|
4069
|
+
}
|
|
2813
4070
|
if (hasFrontend) {
|
|
2814
4071
|
p9.log.info("");
|
|
2815
4072
|
p9.log.info("Frontend usage:");
|
|
@@ -2828,295 +4085,30 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
|
|
|
2828
4085
|
p9.outro(`Entity ${className} created.`);
|
|
2829
4086
|
}
|
|
2830
4087
|
|
|
2831
|
-
// src/
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
}
|
|
2842
|
-
function metaTypeToTs(type, fieldType, nullable) {
|
|
2843
|
-
const base = (() => {
|
|
2844
|
-
switch (type) {
|
|
2845
|
-
case "str":
|
|
2846
|
-
return "string";
|
|
2847
|
-
case "int":
|
|
2848
|
-
case "float":
|
|
2849
|
-
return "number";
|
|
2850
|
-
case "bool":
|
|
2851
|
-
return "boolean";
|
|
2852
|
-
case "datetime":
|
|
2853
|
-
case "date":
|
|
2854
|
-
return "string";
|
|
2855
|
-
case "dict":
|
|
2856
|
-
return "Record<string, unknown>";
|
|
2857
|
-
default:
|
|
2858
|
-
return "unknown";
|
|
2859
|
-
}
|
|
2860
|
-
})();
|
|
2861
|
-
return nullable ? `${base} | null` : base;
|
|
2862
|
-
}
|
|
2863
|
-
function metaTypeToDart(type, nullable) {
|
|
2864
|
-
const base = (() => {
|
|
2865
|
-
switch (type) {
|
|
2866
|
-
case "str":
|
|
2867
|
-
return "String";
|
|
2868
|
-
case "int":
|
|
2869
|
-
return "int";
|
|
2870
|
-
case "float":
|
|
2871
|
-
return "double";
|
|
2872
|
-
case "bool":
|
|
2873
|
-
return "bool";
|
|
2874
|
-
case "datetime":
|
|
2875
|
-
case "date":
|
|
2876
|
-
return "DateTime";
|
|
2877
|
-
case "dict":
|
|
2878
|
-
return "Map<String, dynamic>";
|
|
2879
|
-
default:
|
|
2880
|
-
return "dynamic";
|
|
2881
|
-
}
|
|
2882
|
-
})();
|
|
2883
|
-
return nullable ? `${base}?` : base;
|
|
2884
|
-
}
|
|
2885
|
-
function dartFromJsonExpr(key, type, nullable) {
|
|
2886
|
-
const accessor = `json['${key}']`;
|
|
2887
|
-
const isDate = type === "datetime" || type === "date";
|
|
2888
|
-
if (isDate && nullable)
|
|
2889
|
-
return `${accessor} != null ? DateTime.parse(${accessor} as String) : null`;
|
|
2890
|
-
if (isDate) return `DateTime.parse(${accessor} as String)`;
|
|
2891
|
-
if (type === "dict" && nullable)
|
|
2892
|
-
return `${accessor} as Map<String, dynamic>?`;
|
|
2893
|
-
if (type === "dict") return `${accessor} as Map<String, dynamic>`;
|
|
2894
|
-
const dartT = (() => {
|
|
2895
|
-
switch (type) {
|
|
2896
|
-
case "str":
|
|
2897
|
-
return "String";
|
|
2898
|
-
case "int":
|
|
2899
|
-
return "int";
|
|
2900
|
-
case "float":
|
|
2901
|
-
return "double";
|
|
2902
|
-
case "bool":
|
|
2903
|
-
return "bool";
|
|
2904
|
-
default:
|
|
2905
|
-
return "dynamic";
|
|
2906
|
-
}
|
|
2907
|
-
})();
|
|
2908
|
-
return nullable ? `${accessor} as ${dartT}?` : `${accessor} as ${dartT}`;
|
|
2909
|
-
}
|
|
2910
|
-
function dartToJsonExpr(key, camel, type) {
|
|
2911
|
-
const isDate = type === "datetime" || type === "date";
|
|
2912
|
-
if (isDate) return `'${key}': ${camel}?.toIso8601String()`;
|
|
2913
|
-
return `'${key}': ${camel}`;
|
|
2914
|
-
}
|
|
2915
|
-
function generateTsInterface(entity) {
|
|
2916
|
-
const className = toPascal2(entity.name);
|
|
2917
|
-
const lines = [];
|
|
2918
|
-
lines.push(`export interface ${className} {`);
|
|
2919
|
-
for (const f of entity.fields) {
|
|
2920
|
-
lines.push(
|
|
2921
|
-
` ${f.key}: ${metaTypeToTs(f.type, f.field_type, f.nullable)};`
|
|
2922
|
-
);
|
|
2923
|
-
}
|
|
2924
|
-
lines.push(`}`);
|
|
2925
|
-
lines.push("");
|
|
2926
|
-
const createFields = entity.fields.filter((f) => f.in_create);
|
|
2927
|
-
lines.push(`export interface Create${className} {`);
|
|
2928
|
-
for (const f of createFields) {
|
|
2929
|
-
const optional = f.nullable ? "?" : "";
|
|
2930
|
-
lines.push(
|
|
2931
|
-
` ${f.key}${optional}: ${metaTypeToTs(f.type, f.field_type, f.nullable)};`
|
|
2932
|
-
);
|
|
2933
|
-
}
|
|
2934
|
-
lines.push(`}`);
|
|
2935
|
-
lines.push("");
|
|
2936
|
-
const updateFields = entity.fields.filter((f) => f.in_update);
|
|
2937
|
-
lines.push(`export interface Update${className} {`);
|
|
2938
|
-
for (const f of updateFields) {
|
|
2939
|
-
lines.push(` ${f.key}?: ${metaTypeToTs(f.type, f.field_type, true)};`);
|
|
2940
|
-
}
|
|
2941
|
-
lines.push(`}`);
|
|
2942
|
-
lines.push("");
|
|
2943
|
-
return lines.join("\n");
|
|
2944
|
-
}
|
|
2945
|
-
function generateDartModel2(entity) {
|
|
2946
|
-
const className = toPascal2(entity.name);
|
|
2947
|
-
const lines = [];
|
|
2948
|
-
const fields = entity.fields.map((f) => ({
|
|
2949
|
-
snake: f.key,
|
|
2950
|
-
camel: toCamel2(f.key),
|
|
2951
|
-
type: metaTypeToDart(f.type, f.nullable),
|
|
2952
|
-
nullable: f.nullable,
|
|
2953
|
-
metaType: f.type
|
|
2954
|
-
}));
|
|
2955
|
-
lines.push(`class ${className} {`);
|
|
2956
|
-
for (const f of fields) {
|
|
2957
|
-
lines.push(` final ${f.type} ${f.camel};`);
|
|
2958
|
-
}
|
|
2959
|
-
lines.push("");
|
|
2960
|
-
lines.push(` const ${className}({`);
|
|
2961
|
-
for (const f of fields) {
|
|
2962
|
-
if (f.nullable) {
|
|
2963
|
-
lines.push(` this.${f.camel},`);
|
|
2964
|
-
} else {
|
|
2965
|
-
lines.push(` required this.${f.camel},`);
|
|
2966
|
-
}
|
|
2967
|
-
}
|
|
2968
|
-
lines.push(` });`);
|
|
2969
|
-
lines.push("");
|
|
2970
|
-
lines.push(` factory ${className}.fromJson(Map<String, dynamic> json) {`);
|
|
2971
|
-
lines.push(` return ${className}(`);
|
|
2972
|
-
for (const f of fields) {
|
|
2973
|
-
lines.push(
|
|
2974
|
-
` ${f.camel}: ${dartFromJsonExpr(f.snake, f.metaType, f.nullable)},`
|
|
2975
|
-
);
|
|
2976
|
-
}
|
|
2977
|
-
lines.push(` );`);
|
|
2978
|
-
lines.push(` }`);
|
|
2979
|
-
lines.push("");
|
|
2980
|
-
lines.push(` Map<String, dynamic> toJson() {`);
|
|
2981
|
-
lines.push(` return {`);
|
|
2982
|
-
for (const f of fields) {
|
|
2983
|
-
lines.push(` ${dartToJsonExpr(f.snake, f.camel, f.metaType)},`);
|
|
2984
|
-
}
|
|
2985
|
-
lines.push(` };`);
|
|
2986
|
-
lines.push(` }`);
|
|
2987
|
-
lines.push("");
|
|
2988
|
-
lines.push(` ${className} copyWith({`);
|
|
2989
|
-
for (const f of fields) {
|
|
2990
|
-
lines.push(` ${f.type.replace("?", "")}? ${f.camel},`);
|
|
2991
|
-
}
|
|
2992
|
-
lines.push(` }) {`);
|
|
2993
|
-
lines.push(` return ${className}(`);
|
|
2994
|
-
for (const f of fields) {
|
|
2995
|
-
lines.push(` ${f.camel}: ${f.camel} ?? this.${f.camel},`);
|
|
2996
|
-
}
|
|
2997
|
-
lines.push(` );`);
|
|
2998
|
-
lines.push(` }`);
|
|
2999
|
-
lines.push(`}`);
|
|
3000
|
-
lines.push("");
|
|
3001
|
-
return lines.join("\n");
|
|
3002
|
-
}
|
|
3003
|
-
async function sync(cwd, url) {
|
|
3004
|
-
p10.intro("projx sync");
|
|
3005
|
-
const configPath = join10(cwd, ".projx");
|
|
3006
|
-
if (!existsSync10(configPath)) {
|
|
3007
|
-
p10.log.error("No .projx file found. Run 'npx create-projx init' first.");
|
|
3008
|
-
process.exit(1);
|
|
3009
|
-
}
|
|
3010
|
-
const { components: discovered, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
|
|
3011
|
-
const hasFrontend = discovered.includes("frontend");
|
|
3012
|
-
const hasMobile = discovered.includes("mobile");
|
|
3013
|
-
if (!hasFrontend && !hasMobile) {
|
|
3014
|
-
p10.log.error("No frontend or mobile component found. Nothing to sync.");
|
|
3015
|
-
process.exit(1);
|
|
3016
|
-
}
|
|
3017
|
-
const metaUrl = url || detectMetaUrl(cwd);
|
|
3018
|
-
const spinner7 = p10.spinner();
|
|
3019
|
-
spinner7.start(`Fetching metadata from ${metaUrl}`);
|
|
3020
|
-
let meta;
|
|
3021
|
-
try {
|
|
3022
|
-
const res = await fetch(metaUrl);
|
|
3023
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
3024
|
-
meta = await res.json();
|
|
3025
|
-
} catch (err) {
|
|
3026
|
-
spinner7.stop("Failed.");
|
|
3027
|
-
p10.log.error(`Could not fetch ${metaUrl}: ${err}`);
|
|
3028
|
-
p10.log.info("Make sure your backend is running.");
|
|
3029
|
-
p10.log.info(
|
|
3030
|
-
"Or specify URL: projx sync --url http://localhost:8000/api/v1/_meta"
|
|
3031
|
-
);
|
|
3032
|
-
process.exit(1);
|
|
3033
|
-
}
|
|
3034
|
-
spinner7.stop(`Fetched ${meta.entities.length} entity(s).`);
|
|
3035
|
-
const generated = [];
|
|
3036
|
-
if (hasFrontend) {
|
|
3037
|
-
const dir = componentPaths.frontend;
|
|
3038
|
-
const typesDir = join10(cwd, dir, "src/types");
|
|
3039
|
-
await mkdir4(typesDir, { recursive: true });
|
|
3040
|
-
const barrelExports = [];
|
|
3041
|
-
for (const entity of meta.entities) {
|
|
3042
|
-
const fileName = toKebab(toSnake(entity.name)) + ".ts";
|
|
3043
|
-
const filePath = join10(typesDir, fileName);
|
|
3044
|
-
await writeFile2(filePath, generateTsInterface(entity));
|
|
3045
|
-
generated.push(`${dir}/src/types/${fileName}`);
|
|
3046
|
-
barrelExports.push(`export * from './${toKebab(toSnake(entity.name))}';`);
|
|
3047
|
-
}
|
|
3048
|
-
await writeFile2(
|
|
3049
|
-
join10(typesDir, "index.ts"),
|
|
3050
|
-
barrelExports.join("\n") + "\n"
|
|
3051
|
-
);
|
|
3052
|
-
generated.push(`${dir}/src/types/index.ts`);
|
|
3053
|
-
}
|
|
3054
|
-
if (hasMobile) {
|
|
3055
|
-
const dir = componentPaths.mobile;
|
|
3056
|
-
for (const entity of meta.entities) {
|
|
3057
|
-
const entityDir = join10(cwd, dir, "lib/entities", toSnake(entity.name));
|
|
3058
|
-
await mkdir4(entityDir, { recursive: true });
|
|
3059
|
-
const modelPath = join10(entityDir, "model.dart");
|
|
3060
|
-
await writeFile2(modelPath, generateDartModel2(entity));
|
|
3061
|
-
generated.push(`${dir}/lib/entities/${toSnake(entity.name)}/model.dart`);
|
|
3062
|
-
}
|
|
3063
|
-
}
|
|
3064
|
-
p10.log.success(`Synced ${meta.entities.length} entity(s):`);
|
|
3065
|
-
for (const f of generated) {
|
|
3066
|
-
p10.log.info(` ${f}`);
|
|
3067
|
-
}
|
|
3068
|
-
if (hasFrontend) {
|
|
3069
|
-
p10.log.info("");
|
|
3070
|
-
p10.log.info("Frontend usage:");
|
|
3071
|
-
for (const entity of meta.entities) {
|
|
3072
|
-
const className = toPascal2(entity.name);
|
|
3073
|
-
p10.log.info(
|
|
3074
|
-
` import type { ${className} } from '../types/${toKebab(toSnake(entity.name))}';`
|
|
3075
|
-
);
|
|
3076
|
-
}
|
|
3077
|
-
}
|
|
3078
|
-
p10.outro("Types are up to date.");
|
|
3079
|
-
}
|
|
3080
|
-
function detectMetaUrl(cwd) {
|
|
3081
|
-
const envFiles = [".env", ".env.dev", ".env.local"];
|
|
3082
|
-
for (const envFile of envFiles) {
|
|
3083
|
-
const envPath = join10(cwd, envFile);
|
|
3084
|
-
if (existsSync10(envPath)) {
|
|
3085
|
-
try {
|
|
3086
|
-
const content = readFileSync(envPath, "utf-8");
|
|
3087
|
-
const match = content.match(/VITE_API_URL\s*=\s*(.+)/);
|
|
3088
|
-
if (match) {
|
|
3089
|
-
const base = match[1].trim().replace(/["']/g, "");
|
|
3090
|
-
return `${base}/api/v1/_meta`;
|
|
3091
|
-
}
|
|
3092
|
-
} catch {
|
|
3093
|
-
}
|
|
4088
|
+
// src/index.ts
|
|
4089
|
+
var args = process.argv.slice(2);
|
|
4090
|
+
function matchFeatureFlag(arg, argv, i) {
|
|
4091
|
+
for (const feat of KNOWN_FEATURES) {
|
|
4092
|
+
const eq = `--${feat}=`;
|
|
4093
|
+
if (arg.startsWith(eq)) {
|
|
4094
|
+
return {
|
|
4095
|
+
feature: feat,
|
|
4096
|
+
value: arg.slice(eq.length),
|
|
4097
|
+
consumedNext: false
|
|
4098
|
+
};
|
|
3094
4099
|
}
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
for (const envFile of frontendEnvFiles) {
|
|
3102
|
-
const envPath = join10(cwd, envFile);
|
|
3103
|
-
if (existsSync10(envPath)) {
|
|
3104
|
-
try {
|
|
3105
|
-
const content = readFileSync(envPath, "utf-8");
|
|
3106
|
-
const match = content.match(/VITE_API_URL\s*=\s*(.+)/);
|
|
3107
|
-
if (match) {
|
|
3108
|
-
const base = match[1].trim().replace(/["']/g, "");
|
|
3109
|
-
return `${base}/api/v1/_meta`;
|
|
3110
|
-
}
|
|
3111
|
-
} catch {
|
|
4100
|
+
if (arg === `--${feat}`) {
|
|
4101
|
+
const next = argv[i + 1];
|
|
4102
|
+
if (!next || next.startsWith("-")) {
|
|
4103
|
+
throw new Error(
|
|
4104
|
+
`Flag --${feat} requires a value. Use --${feat}=<targets> or --${feat} <targets>.`
|
|
4105
|
+
);
|
|
3112
4106
|
}
|
|
4107
|
+
return { feature: feat, value: next, consumedNext: true };
|
|
3113
4108
|
}
|
|
3114
4109
|
}
|
|
3115
|
-
return
|
|
4110
|
+
return null;
|
|
3116
4111
|
}
|
|
3117
|
-
|
|
3118
|
-
// src/index.ts
|
|
3119
|
-
var args = process.argv.slice(2);
|
|
3120
4112
|
function parseArgs() {
|
|
3121
4113
|
let command = "create";
|
|
3122
4114
|
let name;
|
|
@@ -3158,10 +4150,6 @@ function parseArgs() {
|
|
|
3158
4150
|
command = "gen";
|
|
3159
4151
|
continue;
|
|
3160
4152
|
}
|
|
3161
|
-
if (arg === "sync" && !name) {
|
|
3162
|
-
command = "sync";
|
|
3163
|
-
continue;
|
|
3164
|
-
}
|
|
3165
4153
|
if (arg === "--components") {
|
|
3166
4154
|
const val = args[++i];
|
|
3167
4155
|
if (val) {
|
|
@@ -3169,6 +4157,16 @@ function parseArgs() {
|
|
|
3169
4157
|
}
|
|
3170
4158
|
continue;
|
|
3171
4159
|
}
|
|
4160
|
+
if (arg === "--orm") {
|
|
4161
|
+
const val = args[++i];
|
|
4162
|
+
if (!val || !ORM_PROVIDERS.includes(val)) {
|
|
4163
|
+
throw new Error(
|
|
4164
|
+
`Invalid --orm. Use one of: ${ORM_PROVIDERS.join(", ")}`
|
|
4165
|
+
);
|
|
4166
|
+
}
|
|
4167
|
+
options.orm = val;
|
|
4168
|
+
continue;
|
|
4169
|
+
}
|
|
3172
4170
|
if (arg === "--local") {
|
|
3173
4171
|
localRepo = resolve(args[++i] || ".");
|
|
3174
4172
|
continue;
|
|
@@ -3201,11 +4199,6 @@ function parseArgs() {
|
|
|
3201
4199
|
flags.backend = true;
|
|
3202
4200
|
continue;
|
|
3203
4201
|
}
|
|
3204
|
-
if (arg === "--url") {
|
|
3205
|
-
const val = args[++i];
|
|
3206
|
-
if (val) extraArgs.push(`--url=${val}`);
|
|
3207
|
-
continue;
|
|
3208
|
-
}
|
|
3209
4202
|
if (arg === "--help" || arg === "-h") {
|
|
3210
4203
|
printHelp();
|
|
3211
4204
|
process.exit(0);
|
|
@@ -3220,6 +4213,16 @@ function parseArgs() {
|
|
|
3220
4213
|
if (val) extraArgs.push(`--name=${val}`);
|
|
3221
4214
|
continue;
|
|
3222
4215
|
}
|
|
4216
|
+
{
|
|
4217
|
+
const featureMatch = matchFeatureFlag(arg, args, i);
|
|
4218
|
+
if (featureMatch) {
|
|
4219
|
+
const { feature, value, consumedNext } = featureMatch;
|
|
4220
|
+
parseFeatureFlag(value);
|
|
4221
|
+
options.features = { ...options.features ?? {}, [feature]: value };
|
|
4222
|
+
if (consumedNext) i++;
|
|
4223
|
+
continue;
|
|
4224
|
+
}
|
|
4225
|
+
}
|
|
3223
4226
|
if (!arg.startsWith("-")) {
|
|
3224
4227
|
if (command === "add" || command === "pin" || command === "unpin" || command === "gen") {
|
|
3225
4228
|
extraArgs.push(arg);
|
|
@@ -3244,10 +4247,11 @@ function printHelp() {
|
|
|
3244
4247
|
projx pin --list Show all skip patterns
|
|
3245
4248
|
projx doctor [--fix] Health check for projx project
|
|
3246
4249
|
projx gen entity <name> Generate a new entity
|
|
3247
|
-
projx sync [--url <url>] Sync types from running backend
|
|
3248
4250
|
|
|
3249
4251
|
Options:
|
|
3250
|
-
--components <list> Comma-separated: fastapi,fastify,frontend,mobile,e2e,infra
|
|
4252
|
+
--components <list> Comma-separated: fastapi,fastify,express,frontend,mobile,e2e,infra
|
|
4253
|
+
--orm <provider> Node backend ORM: prisma (default), drizzle, sequelize, typeorm
|
|
4254
|
+
--auth <targets> Add auth feature. Targets: <component>[:<instance>] (comma-separated)
|
|
3251
4255
|
--no-git Skip git init
|
|
3252
4256
|
--no-install Skip dependency installation
|
|
3253
4257
|
-y, --yes Accept defaults (fastify + frontend + e2e)
|
|
@@ -3257,6 +4261,8 @@ function printHelp() {
|
|
|
3257
4261
|
Examples:
|
|
3258
4262
|
npx create-projx my-app
|
|
3259
4263
|
npx create-projx my-app --components fastapi,frontend,e2e
|
|
4264
|
+
npx create-projx my-app --components express,frontend,e2e --orm drizzle
|
|
4265
|
+
npx create-projx my-app --components fastify,frontend,mobile --auth fastify
|
|
3260
4266
|
npx create-projx my-app -y
|
|
3261
4267
|
npx create-projx add frontend mobile
|
|
3262
4268
|
npx create-projx add fastify --name email-ingestor
|
|
@@ -3330,12 +4336,6 @@ async function main() {
|
|
|
3330
4336
|
await doctor(process.cwd(), flags.fix);
|
|
3331
4337
|
return;
|
|
3332
4338
|
}
|
|
3333
|
-
if (command === "sync") {
|
|
3334
|
-
const urlArg = extraArgs.find((a) => a.startsWith("--url="));
|
|
3335
|
-
const url = urlArg ? urlArg.split("=").slice(1).join("=") : void 0;
|
|
3336
|
-
await sync(process.cwd(), url);
|
|
3337
|
-
return;
|
|
3338
|
-
}
|
|
3339
4339
|
if (command === "gen") {
|
|
3340
4340
|
const subcommand = extraArgs[0];
|
|
3341
4341
|
if (subcommand !== "entity" || !extraArgs[1]) {
|
|
@@ -3348,7 +4348,7 @@ async function main() {
|
|
|
3348
4348
|
const fieldsArg = extraArgs.find((a) => a.startsWith("--fields="));
|
|
3349
4349
|
const fieldsFlag = fieldsArg ? fieldsArg.split("=").slice(1).join("=") : void 0;
|
|
3350
4350
|
const backendFlag = flags.ai ? "fastapi" : flags.backend ? "fastify" : void 0;
|
|
3351
|
-
await gen(process.cwd(), entityName, fieldsFlag, backendFlag);
|
|
4351
|
+
await gen(process.cwd(), entityName, fieldsFlag, backendFlag, localRepo);
|
|
3352
4352
|
return;
|
|
3353
4353
|
}
|
|
3354
4354
|
let opts;
|
|
@@ -3361,12 +4361,16 @@ async function main() {
|
|
|
3361
4361
|
name,
|
|
3362
4362
|
components: options.components,
|
|
3363
4363
|
git: options.git ?? true,
|
|
3364
|
-
install: options.install ?? true
|
|
4364
|
+
install: options.install ?? true,
|
|
4365
|
+
orm: options.orm ?? "prisma",
|
|
4366
|
+
features: options.features
|
|
3365
4367
|
};
|
|
3366
4368
|
} else {
|
|
3367
4369
|
opts = await runPrompts(name);
|
|
3368
4370
|
opts.git = options.git ?? opts.git;
|
|
3369
4371
|
opts.install = options.install ?? opts.install;
|
|
4372
|
+
opts.orm = options.orm ?? opts.orm ?? "prisma";
|
|
4373
|
+
opts.features = options.features ?? opts.features;
|
|
3370
4374
|
}
|
|
3371
4375
|
const dest = resolve(process.cwd(), opts.name);
|
|
3372
4376
|
if (existsSync11(dest)) {
|