create-projx 1.6.5 → 1.7.0
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 +92 -19
- package/dist/{baseline-PZM4KJJW.js → baseline-FHOZNS4D.js} +2 -2
- package/dist/{chunk-6YRBHJ2V.js → chunk-HAT7D4G2.js} +25 -8
- package/dist/{chunk-XQ7FE4U3.js → chunk-IMZKHDIL.js} +161 -19
- package/dist/index.js +1499 -276
- package/dist/{utils-AVKSTHIF.js → utils-BZGSJ7XZ.js} +5 -1
- package/package.json +13 -7
- package/src/addons/orms/drizzle/express/src/app.ts +81 -0
- package/src/addons/orms/drizzle/express/src/modules/_base/auto-routes.ts +278 -0
- package/src/addons/orms/drizzle/express/src/modules/_base/index.ts +20 -0
- package/src/addons/orms/drizzle/express/src/server.ts +32 -0
- package/src/addons/orms/drizzle/express/tests/app.test.ts +24 -0
- package/src/addons/orms/drizzle/express/vitest.config.ts +20 -0
- package/src/addons/orms/drizzle/fastify/src/app.ts +90 -0
- package/src/addons/orms/drizzle/fastify/src/modules/_base/auto-routes.ts +268 -0
- package/src/addons/orms/drizzle/fastify/src/modules/_base/index.ts +20 -0
- package/src/addons/orms/drizzle/fastify/tests/modules/app.test.ts +20 -0
- package/src/addons/orms/drizzle/fastify/vitest.config.ts +31 -0
- package/src/addons/orms/drizzle/gen-entity/express-router.ts +21 -0
- package/src/addons/orms/drizzle/gen-entity/express-test.ts +61 -0
- package/src/addons/orms/drizzle/gen-entity/fastify-router.ts +19 -0
- package/src/addons/orms/drizzle/gen-entity/fastify-test.ts +87 -0
- package/src/addons/orms/drizzle/manifest.json +52 -0
- package/src/addons/orms/drizzle/shared/drizzle.config.ts +12 -0
- package/src/addons/orms/drizzle/shared/src/db/client.ts +17 -0
- package/src/addons/orms/drizzle/shared/src/db/schema.ts +14 -0
- package/src/addons/orms/drizzle/shared/src/modules/_base/query-engine.ts +115 -0
- package/src/addons/orms/drizzle/shared/src/modules/_base/registry.ts +15 -0
- package/src/addons/orms/sequelize/express/src/app.ts +82 -0
- package/src/addons/orms/sequelize/express/src/modules/_base/auto-routes.ts +226 -0
- package/src/addons/orms/sequelize/express/src/modules/_base/index.ts +20 -0
- package/src/addons/orms/sequelize/express/src/server.ts +32 -0
- package/src/addons/orms/sequelize/express/tests/app.test.ts +24 -0
- package/src/addons/orms/sequelize/express/vitest.config.ts +20 -0
- package/src/addons/orms/sequelize/fastify/src/app.ts +83 -0
- package/src/addons/orms/sequelize/fastify/src/modules/_base/auto-routes.ts +216 -0
- package/src/addons/orms/sequelize/fastify/src/modules/_base/index.ts +20 -0
- package/src/addons/orms/sequelize/fastify/tests/modules/app.test.ts +20 -0
- package/src/addons/orms/sequelize/fastify/vitest.config.ts +31 -0
- package/src/addons/orms/sequelize/gen-entity/express-router.ts +17 -0
- package/src/addons/orms/sequelize/gen-entity/express-test.ts +65 -0
- package/src/addons/orms/sequelize/gen-entity/fastify-router.ts +19 -0
- package/src/addons/orms/sequelize/gen-entity/fastify-test.ts +89 -0
- package/src/addons/orms/sequelize/gen-entity/model.ts +21 -0
- package/src/addons/orms/sequelize/manifest.json +53 -0
- package/src/addons/orms/sequelize/shared/scripts/db-sync.ts +14 -0
- package/src/addons/orms/sequelize/shared/src/db/client.ts +19 -0
- package/src/addons/orms/sequelize/shared/src/models/index.ts +9 -0
- package/src/addons/orms/sequelize/shared/src/modules/_base/query-engine.ts +101 -0
- package/src/addons/orms/sequelize/shared/src/modules/_base/registry.ts +15 -0
- package/src/addons/orms/typeorm/express/src/app.ts +82 -0
- package/src/addons/orms/typeorm/express/src/modules/_base/auto-routes.ts +249 -0
- package/src/addons/orms/typeorm/express/src/modules/_base/index.ts +19 -0
- package/src/addons/orms/typeorm/express/src/server.ts +43 -0
- package/src/addons/orms/typeorm/express/tests/app.test.ts +24 -0
- package/src/addons/orms/typeorm/express/vitest.config.ts +20 -0
- package/src/addons/orms/typeorm/fastify/src/app.ts +86 -0
- package/src/addons/orms/typeorm/fastify/src/modules/_base/auto-routes.ts +239 -0
- package/src/addons/orms/typeorm/fastify/src/modules/_base/index.ts +19 -0
- package/src/addons/orms/typeorm/fastify/tests/modules/app.test.ts +20 -0
- package/src/addons/orms/typeorm/fastify/vitest.config.ts +31 -0
- package/src/addons/orms/typeorm/gen-entity/entity.ts +21 -0
- package/src/addons/orms/typeorm/gen-entity/express-router.ts +17 -0
- package/src/addons/orms/typeorm/gen-entity/express-test.ts +66 -0
- package/src/addons/orms/typeorm/gen-entity/fastify-router.ts +19 -0
- package/src/addons/orms/typeorm/gen-entity/fastify-test.ts +89 -0
- package/src/addons/orms/typeorm/manifest.json +53 -0
- package/src/addons/orms/typeorm/shared/scripts/db-sync.ts +14 -0
- package/src/addons/orms/typeorm/shared/src/db/data-source.ts +21 -0
- package/src/addons/orms/typeorm/shared/src/entities/index.ts +8 -0
- package/src/addons/orms/typeorm/shared/src/modules/_base/query-engine.ts +94 -0
- package/src/addons/orms/typeorm/shared/src/modules/_base/registry.ts +15 -0
- package/src/addons/orms/typeorm/shared/tsconfig.json +16 -0
- 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-IMZKHDIL.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,314 @@ 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-HAT7D4G2.js";
|
|
38
40
|
|
|
39
41
|
// src/index.ts
|
|
40
|
-
import { existsSync as
|
|
42
|
+
import { existsSync as existsSync12 } 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((p11) => p11.trim());
|
|
62
|
+
if (parts.length > 2 || parts.some((p11) => !p11)) {
|
|
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 filesDir = join(stackDir, "files");
|
|
199
|
+
if (existsSync(filesDir)) {
|
|
200
|
+
await renderFilesInto(filesDir, targetPath, args2.vars);
|
|
201
|
+
}
|
|
202
|
+
const patchesDir = join(stackDir, "patches");
|
|
203
|
+
if (existsSync(patchesDir)) {
|
|
204
|
+
await applyPatches(patchesDir, targetPath, args2.featureName);
|
|
205
|
+
}
|
|
206
|
+
const envKeys = args2.manifest.env?.[args2.target.component] ?? [];
|
|
207
|
+
if (envKeys.length > 0) {
|
|
208
|
+
await appendEnvExample(targetPath, args2.featureName, envKeys);
|
|
209
|
+
}
|
|
210
|
+
await recordFeatureInMarker(targetPath, args2.featureName);
|
|
211
|
+
}
|
|
212
|
+
async function renderFilesInto(filesDir, targetPath, vars) {
|
|
213
|
+
const entries = await collectFiles(filesDir);
|
|
214
|
+
for (const rel of entries) {
|
|
215
|
+
const src = join(filesDir, rel);
|
|
216
|
+
const isEjs = rel.endsWith(".ejs");
|
|
217
|
+
const outRel = isEjs ? rel.slice(0, -4) : rel;
|
|
218
|
+
const dst = join(targetPath, outRel);
|
|
219
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
220
|
+
if (isEjs || /\.(ts|tsx|js|jsx|py|dart|md|json|yml|yaml|html)$/.test(rel)) {
|
|
221
|
+
const raw = await readFile(src, "utf-8");
|
|
222
|
+
await writeFile(dst, render(raw, vars));
|
|
223
|
+
} else {
|
|
224
|
+
await cp(src, dst);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async function collectFiles(root) {
|
|
229
|
+
const out = [];
|
|
230
|
+
async function walk(dir) {
|
|
231
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
232
|
+
for (const e of entries) {
|
|
233
|
+
const full = join(dir, e.name);
|
|
234
|
+
if (e.isDirectory()) await walk(full);
|
|
235
|
+
else out.push(relative(root, full));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
await walk(root);
|
|
239
|
+
return out.sort();
|
|
240
|
+
}
|
|
241
|
+
async function applyPatches(patchesDir, targetPath, featureName) {
|
|
242
|
+
const files = (await readdir(patchesDir)).filter((f) => f.endsWith(".json")).sort();
|
|
243
|
+
for (const file of files) {
|
|
244
|
+
const raw = await readFile(join(patchesDir, file), "utf-8");
|
|
245
|
+
const patch = JSON.parse(raw);
|
|
246
|
+
if (patch.type === "package-json") {
|
|
247
|
+
await applyPackageJsonPatch(targetPath, patch);
|
|
248
|
+
} else if (patch.type === "text") {
|
|
249
|
+
await applyTextPatch(targetPath, patch, featureName);
|
|
250
|
+
} else {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`Unknown patch type in ${file}: ${patch.type}.`
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
async function applyPackageJsonPatch(targetPath, patch) {
|
|
258
|
+
const pkgPath = join(targetPath, "package.json");
|
|
259
|
+
if (!existsSync(pkgPath)) {
|
|
260
|
+
throw new Error(`package-json patch failed: ${pkgPath} not found.`);
|
|
261
|
+
}
|
|
262
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
|
|
263
|
+
const merge = patch.merge;
|
|
264
|
+
for (const key of ["dependencies", "devDependencies", "scripts"]) {
|
|
265
|
+
const incoming = merge[key];
|
|
266
|
+
if (!incoming) continue;
|
|
267
|
+
pkg[key] = { ...pkg[key] ?? {}, ...incoming };
|
|
268
|
+
}
|
|
269
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
270
|
+
}
|
|
271
|
+
async function applyTextPatch(targetPath, patch, featureName) {
|
|
272
|
+
const filePath = join(targetPath, patch.file);
|
|
273
|
+
if (!existsSync(filePath)) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`text patch failed: ${patch.file} not found in ${targetPath}.`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
const content = await readFile(filePath, "utf-8");
|
|
279
|
+
const sentinel = sentinelFor(featureName, patch.anchor, patch.insert);
|
|
280
|
+
if (content.includes(sentinel)) return;
|
|
281
|
+
const idx = content.indexOf(patch.anchor);
|
|
282
|
+
if (idx === -1) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`text patch anchor "${patch.anchor}" not found in ${patch.file}.`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const insertWithSentinel = patch.insert + sentinel;
|
|
288
|
+
let next;
|
|
289
|
+
if (patch.position === "before") {
|
|
290
|
+
next = content.slice(0, idx) + insertWithSentinel + content.slice(idx);
|
|
291
|
+
} else {
|
|
292
|
+
const end = idx + patch.anchor.length;
|
|
293
|
+
const after = content.slice(end);
|
|
294
|
+
const newline = after.startsWith("\n") ? "\n" : "\n";
|
|
295
|
+
next = content.slice(0, end) + newline + insertWithSentinel + (after.startsWith("\n") ? after.slice(1) : after);
|
|
296
|
+
}
|
|
297
|
+
await writeFile(filePath, next);
|
|
298
|
+
}
|
|
299
|
+
function sentinelFor(feature, anchor, insert) {
|
|
300
|
+
const hash = simpleHash(anchor + "|" + insert);
|
|
301
|
+
const isHash = anchor.includes("#");
|
|
302
|
+
const open = isHash ? "# " : "// ";
|
|
303
|
+
return `${open}projx-feature: ${feature} ${hash}
|
|
304
|
+
`;
|
|
305
|
+
}
|
|
306
|
+
function simpleHash(s) {
|
|
307
|
+
let h = 0;
|
|
308
|
+
for (let i = 0; i < s.length; i++) h = h * 31 + s.charCodeAt(i) | 0;
|
|
309
|
+
return Math.abs(h).toString(36);
|
|
310
|
+
}
|
|
311
|
+
async function appendEnvExample(targetPath, featureName, keys) {
|
|
312
|
+
const envPath = join(targetPath, ".env.example");
|
|
313
|
+
let content = existsSync(envPath) ? await readFile(envPath, "utf-8") : "";
|
|
314
|
+
const header = `# Added by feature: ${featureName}`;
|
|
315
|
+
if (content.includes(header)) return;
|
|
316
|
+
if (content && !content.endsWith("\n")) content += "\n";
|
|
317
|
+
content += `
|
|
318
|
+
${header}
|
|
319
|
+
`;
|
|
320
|
+
for (const key of keys) content += `# ${key}=
|
|
321
|
+
`;
|
|
322
|
+
await writeFile(envPath, content);
|
|
323
|
+
}
|
|
324
|
+
async function recordFeatureInMarker(targetPath, featureName) {
|
|
325
|
+
const markerPath = join(targetPath, ".projx-component");
|
|
326
|
+
if (!existsSync(markerPath)) return;
|
|
327
|
+
const raw = await readFile(markerPath, "utf-8");
|
|
328
|
+
const marker = JSON.parse(raw);
|
|
329
|
+
marker.features = marker.features ?? [];
|
|
330
|
+
if (!marker.features.includes(featureName)) {
|
|
331
|
+
marker.features.push(featureName);
|
|
332
|
+
await writeFile(markerPath, JSON.stringify(marker, null, 2) + "\n");
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
43
336
|
// src/prompts.ts
|
|
44
337
|
import * as p from "@clack/prompts";
|
|
45
338
|
var LABELS = {
|
|
46
339
|
fastapi: { label: "FastAPI", hint: "Python \u2014 SQLAlchemy, Alembic, uvicorn" },
|
|
47
340
|
fastify: { label: "Fastify", hint: "Node.js \u2014 Prisma, TypeBox, TypeScript" },
|
|
341
|
+
express: { label: "Express", hint: "Node.js \u2014 Express 5, TypeScript" },
|
|
48
342
|
frontend: { label: "Frontend", hint: "React 19 + Vite + React Router" },
|
|
49
343
|
mobile: { label: "Mobile", hint: "Flutter + Riverpod + GoRouter" },
|
|
50
344
|
e2e: { label: "E2E Tests", hint: "Playwright" },
|
|
@@ -78,9 +372,25 @@ async function runPrompts(nameArg) {
|
|
|
78
372
|
p.log.warn("No components selected. Creating an empty project.");
|
|
79
373
|
}
|
|
80
374
|
const hasJs = components.some(
|
|
81
|
-
(c) => ["fastify", "frontend", "e2e"].includes(c)
|
|
375
|
+
(c) => ["fastify", "express", "frontend", "e2e"].includes(c)
|
|
376
|
+
);
|
|
377
|
+
const hasNodeBackend = components.some(
|
|
378
|
+
(c) => ["fastify", "express"].includes(c)
|
|
82
379
|
);
|
|
380
|
+
let orm = "prisma";
|
|
83
381
|
let packageManager = "npm";
|
|
382
|
+
if (hasNodeBackend) {
|
|
383
|
+
const choice = await p.select({
|
|
384
|
+
message: "Node backend ORM",
|
|
385
|
+
options: ORM_PROVIDERS.map((provider) => ({
|
|
386
|
+
value: provider,
|
|
387
|
+
label: provider === "prisma" ? "Prisma" : "Drizzle"
|
|
388
|
+
})),
|
|
389
|
+
initialValue: "prisma"
|
|
390
|
+
});
|
|
391
|
+
if (p.isCancel(choice)) process.exit(0);
|
|
392
|
+
orm = choice;
|
|
393
|
+
}
|
|
84
394
|
if (hasJs) {
|
|
85
395
|
const pm = await p.select({
|
|
86
396
|
message: "Package manager",
|
|
@@ -90,13 +400,13 @@ async function runPrompts(nameArg) {
|
|
|
90
400
|
if (p.isCancel(pm)) process.exit(0);
|
|
91
401
|
packageManager = pm;
|
|
92
402
|
}
|
|
93
|
-
return { name, components, git: true, install: true, packageManager };
|
|
403
|
+
return { name, components, git: true, install: true, packageManager, orm };
|
|
94
404
|
}
|
|
95
405
|
|
|
96
406
|
// src/scaffold.ts
|
|
97
|
-
import { copyFileSync, existsSync } from "fs";
|
|
98
|
-
import { mkdir, readFile } from "fs/promises";
|
|
99
|
-
import { join } from "path";
|
|
407
|
+
import { copyFileSync, existsSync as existsSync2 } from "fs";
|
|
408
|
+
import { mkdir as mkdir2, readFile as readFile2 } from "fs/promises";
|
|
409
|
+
import { join as join2 } from "path";
|
|
100
410
|
import * as p2 from "@clack/prompts";
|
|
101
411
|
async function scaffold(opts, dest, localRepo) {
|
|
102
412
|
const name = toKebab(opts.name);
|
|
@@ -108,10 +418,11 @@ async function scaffold(opts, dest, localRepo) {
|
|
|
108
418
|
projectName: name,
|
|
109
419
|
components: opts.components,
|
|
110
420
|
paths,
|
|
111
|
-
pm: pmCommands(pm)
|
|
421
|
+
pm: pmCommands(pm),
|
|
422
|
+
orm: opts.orm ?? "prisma"
|
|
112
423
|
};
|
|
113
424
|
const isLocal = !!localRepo;
|
|
114
|
-
await
|
|
425
|
+
await mkdir2(dest, { recursive: true });
|
|
115
426
|
const dlSpinner = p2.spinner();
|
|
116
427
|
dlSpinner.start(
|
|
117
428
|
isLocal ? "Using local templates" : "Downloading latest templates"
|
|
@@ -124,7 +435,7 @@ async function scaffold(opts, dest, localRepo) {
|
|
|
124
435
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
125
436
|
try {
|
|
126
437
|
const pkg = JSON.parse(
|
|
127
|
-
await
|
|
438
|
+
await readFile2(join2(repoDir, "cli/package.json"), "utf-8")
|
|
128
439
|
);
|
|
129
440
|
const version = pkg.version;
|
|
130
441
|
p2.log.info(`Scaffolding project in ${dest}`);
|
|
@@ -145,6 +456,19 @@ async function scaffold(opts, dest, localRepo) {
|
|
|
145
456
|
true
|
|
146
457
|
);
|
|
147
458
|
spinner7.stop("Scaffold complete.");
|
|
459
|
+
if (opts.features && Object.keys(opts.features).length > 0) {
|
|
460
|
+
const featSpinner = p2.spinner();
|
|
461
|
+
featSpinner.start("Applying features");
|
|
462
|
+
await applyFeatures({
|
|
463
|
+
features: opts.features,
|
|
464
|
+
repoDir,
|
|
465
|
+
components: opts.components,
|
|
466
|
+
instances: opts.components.map((type) => ({ type, path: type })),
|
|
467
|
+
dest,
|
|
468
|
+
vars
|
|
469
|
+
});
|
|
470
|
+
featSpinner.stop("Features applied.");
|
|
471
|
+
}
|
|
148
472
|
if (opts.install) {
|
|
149
473
|
await installDeps(dest, opts.components, pm);
|
|
150
474
|
}
|
|
@@ -180,7 +504,7 @@ async function installDeps(dest, components, pm) {
|
|
|
180
504
|
case "fastapi":
|
|
181
505
|
if (hasCommand("uv")) {
|
|
182
506
|
spinner7.start("Installing FastAPI dependencies (uv sync)");
|
|
183
|
-
exec("uv sync --all-extras",
|
|
507
|
+
exec("uv sync --all-extras", join2(dest, "fastapi"));
|
|
184
508
|
spinner7.stop("FastAPI dependencies installed.");
|
|
185
509
|
} else {
|
|
186
510
|
p2.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
|
|
@@ -189,7 +513,7 @@ async function installDeps(dest, components, pm) {
|
|
|
189
513
|
case "fastify":
|
|
190
514
|
if (hasCommand(pmBin)) {
|
|
191
515
|
spinner7.start(`Installing Fastify dependencies (${cmds.install})`);
|
|
192
|
-
exec(cmds.install,
|
|
516
|
+
exec(cmds.install, join2(dest, "fastify"));
|
|
193
517
|
spinner7.stop("Fastify dependencies installed.");
|
|
194
518
|
} else {
|
|
195
519
|
p2.log.warn(
|
|
@@ -197,10 +521,21 @@ async function installDeps(dest, components, pm) {
|
|
|
197
521
|
);
|
|
198
522
|
}
|
|
199
523
|
break;
|
|
524
|
+
case "express":
|
|
525
|
+
if (hasCommand(pmBin)) {
|
|
526
|
+
spinner7.start(`Installing Express dependencies (${cmds.install})`);
|
|
527
|
+
exec(cmds.install, join2(dest, "express"));
|
|
528
|
+
spinner7.stop("Express dependencies installed.");
|
|
529
|
+
} else {
|
|
530
|
+
p2.log.warn(
|
|
531
|
+
`${pm} not found \u2014 run 'cd express && ${cmds.install}' manually.`
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
break;
|
|
200
535
|
case "frontend":
|
|
201
536
|
if (hasCommand(pmBin)) {
|
|
202
537
|
spinner7.start(`Installing Frontend dependencies (${cmds.install})`);
|
|
203
|
-
exec(cmds.install,
|
|
538
|
+
exec(cmds.install, join2(dest, "frontend"));
|
|
204
539
|
spinner7.stop("Frontend dependencies installed.");
|
|
205
540
|
} else {
|
|
206
541
|
p2.log.warn(
|
|
@@ -211,7 +546,7 @@ async function installDeps(dest, components, pm) {
|
|
|
211
546
|
case "e2e":
|
|
212
547
|
if (hasCommand(pmBin)) {
|
|
213
548
|
spinner7.start(`Installing E2E dependencies (${cmds.install})`);
|
|
214
|
-
exec(cmds.install,
|
|
549
|
+
exec(cmds.install, join2(dest, "e2e"));
|
|
215
550
|
spinner7.stop("E2E dependencies installed.");
|
|
216
551
|
} else {
|
|
217
552
|
p2.log.warn(
|
|
@@ -222,7 +557,7 @@ async function installDeps(dest, components, pm) {
|
|
|
222
557
|
case "mobile":
|
|
223
558
|
if (hasCommand("flutter")) {
|
|
224
559
|
spinner7.start("Installing Flutter dependencies");
|
|
225
|
-
exec("flutter pub get",
|
|
560
|
+
exec("flutter pub get", join2(dest, "mobile"));
|
|
226
561
|
spinner7.stop("Flutter dependencies installed.");
|
|
227
562
|
} else {
|
|
228
563
|
p2.log.warn(
|
|
@@ -240,9 +575,9 @@ async function installDeps(dest, components, pm) {
|
|
|
240
575
|
}
|
|
241
576
|
function copyEnvExamples(dest, components) {
|
|
242
577
|
for (const component of components) {
|
|
243
|
-
const example =
|
|
244
|
-
const env =
|
|
245
|
-
if (
|
|
578
|
+
const example = join2(dest, component, ".env.example");
|
|
579
|
+
const env = join2(dest, component, ".env");
|
|
580
|
+
if (existsSync2(example) && !existsSync2(env)) {
|
|
246
581
|
try {
|
|
247
582
|
copyFileSync(example, env);
|
|
248
583
|
} catch {
|
|
@@ -252,10 +587,10 @@ function copyEnvExamples(dest, components) {
|
|
|
252
587
|
}
|
|
253
588
|
|
|
254
589
|
// src/update.ts
|
|
255
|
-
import { existsSync as
|
|
256
|
-
import { readFile as
|
|
590
|
+
import { existsSync as existsSync3 } from "fs";
|
|
591
|
+
import { readFile as readFile3, unlink } from "fs/promises";
|
|
257
592
|
import { execSync } from "child_process";
|
|
258
|
-
import { join as
|
|
593
|
+
import { join as join3 } from "path";
|
|
259
594
|
import * as p3 from "@clack/prompts";
|
|
260
595
|
async function update(cwd, localRepo) {
|
|
261
596
|
p3.intro("projx update");
|
|
@@ -323,7 +658,7 @@ async function update(cwd, localRepo) {
|
|
|
323
658
|
const componentSkips = {};
|
|
324
659
|
for (const component of components) {
|
|
325
660
|
const dir = componentPaths[component];
|
|
326
|
-
const marker = await readComponentMarker(
|
|
661
|
+
const marker = await readComponentMarker(join3(cwd, dir));
|
|
327
662
|
if (marker?.skip && marker.skip.length > 0) {
|
|
328
663
|
componentSkips[component] = marker.skip;
|
|
329
664
|
}
|
|
@@ -340,7 +675,7 @@ async function update(cwd, localRepo) {
|
|
|
340
675
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
341
676
|
try {
|
|
342
677
|
const pkg = JSON.parse(
|
|
343
|
-
await
|
|
678
|
+
await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
|
|
344
679
|
);
|
|
345
680
|
const version = pkg.version;
|
|
346
681
|
const name = detectProjectName(cwd, components, componentPaths);
|
|
@@ -366,7 +701,8 @@ async function update(cwd, localRepo) {
|
|
|
366
701
|
paths: componentPaths,
|
|
367
702
|
instances,
|
|
368
703
|
pm: pmCommands(pm),
|
|
369
|
-
nameOverrides
|
|
704
|
+
nameOverrides,
|
|
705
|
+
orm: raw.orm ?? "prisma"
|
|
370
706
|
};
|
|
371
707
|
const spinner7 = p3.spinner();
|
|
372
708
|
spinner7.start("Applying template update");
|
|
@@ -473,22 +809,21 @@ function hasUncommittedChanges(cwd) {
|
|
|
473
809
|
}
|
|
474
810
|
}
|
|
475
811
|
async function findPinnedFilesWithUpdates(cwd, repoDir, components, componentPaths, vars, version, componentSkips, rootSkip) {
|
|
476
|
-
const {
|
|
812
|
+
const { mkdtemp: mkdtemp2, rm: rm2, readFile: readFile8 } = await import("fs/promises");
|
|
477
813
|
const { tmpdir: tmpdir2 } = await import("os");
|
|
478
|
-
const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-
|
|
814
|
+
const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-FHOZNS4D.js");
|
|
479
815
|
const config = await readProjxConfig(cwd);
|
|
480
816
|
const rootPinned = Array.isArray(config.skip) ? config.skip : [];
|
|
481
817
|
const componentPinned = [];
|
|
482
818
|
for (const component of components) {
|
|
483
819
|
const dir = componentPaths[component];
|
|
484
|
-
const marker = await readComponentMarker(
|
|
820
|
+
const marker = await readComponentMarker(join3(cwd, dir));
|
|
485
821
|
if (marker?.skip && marker.skip.length > 0) {
|
|
486
822
|
componentPinned.push({ component, dir, patterns: marker.skip });
|
|
487
823
|
}
|
|
488
824
|
}
|
|
489
825
|
if (rootPinned.length === 0 && componentPinned.length === 0) return [];
|
|
490
|
-
const tmpTemplate =
|
|
491
|
-
await mkdir5(tmpTemplate, { recursive: true });
|
|
826
|
+
const tmpTemplate = await mkdtemp2(join3(tmpdir2(), "projx-pinned-"));
|
|
492
827
|
void componentSkips;
|
|
493
828
|
void rootSkip;
|
|
494
829
|
try {
|
|
@@ -507,22 +842,22 @@ async function findPinnedFilesWithUpdates(cwd, repoDir, components, componentPat
|
|
|
507
842
|
);
|
|
508
843
|
const updates = [];
|
|
509
844
|
for (const file of rootPinned) {
|
|
510
|
-
const tmplPath =
|
|
511
|
-
const userPath =
|
|
512
|
-
if (!
|
|
513
|
-
const tmplContent = await
|
|
514
|
-
const userContent = await
|
|
845
|
+
const tmplPath = join3(tmpTemplate, file);
|
|
846
|
+
const userPath = join3(cwd, file);
|
|
847
|
+
if (!existsSync3(tmplPath) || !existsSync3(userPath)) continue;
|
|
848
|
+
const tmplContent = await readFile8(tmplPath, "utf-8");
|
|
849
|
+
const userContent = await readFile8(userPath, "utf-8");
|
|
515
850
|
if (tmplContent !== userContent) updates.push(file);
|
|
516
851
|
}
|
|
517
852
|
for (const { dir, patterns } of componentPinned) {
|
|
518
853
|
for (const pattern of patterns) {
|
|
519
854
|
if (pattern.includes("*")) continue;
|
|
520
855
|
const rel = `${dir}/${pattern}`;
|
|
521
|
-
const tmplPath =
|
|
522
|
-
const userPath =
|
|
523
|
-
if (!
|
|
524
|
-
const tmplContent = await
|
|
525
|
-
const userContent = await
|
|
856
|
+
const tmplPath = join3(tmpTemplate, rel);
|
|
857
|
+
const userPath = join3(cwd, rel);
|
|
858
|
+
if (!existsSync3(tmplPath) || !existsSync3(userPath)) continue;
|
|
859
|
+
const tmplContent = await readFile8(tmplPath, "utf-8");
|
|
860
|
+
const userContent = await readFile8(userPath, "utf-8");
|
|
526
861
|
if (tmplContent !== userContent) updates.push(rel);
|
|
527
862
|
}
|
|
528
863
|
}
|
|
@@ -596,7 +931,7 @@ async function promptSkipLearning(cwd, componentPaths, version, conflictedFiles)
|
|
|
596
931
|
const entry = entries.find((e) => e.file === file);
|
|
597
932
|
try {
|
|
598
933
|
if (entry?.status === "??") {
|
|
599
|
-
await unlink(
|
|
934
|
+
await unlink(join3(cwd, file));
|
|
600
935
|
} else {
|
|
601
936
|
execSync(`git checkout -- "${file}"`, { cwd, stdio: "pipe" });
|
|
602
937
|
}
|
|
@@ -630,9 +965,9 @@ async function learnSkips(cwd, files, componentPaths) {
|
|
|
630
965
|
let matched = false;
|
|
631
966
|
for (const [dir, component] of Object.entries(dirToComponent)) {
|
|
632
967
|
if (file.startsWith(dir + "/")) {
|
|
633
|
-
const
|
|
968
|
+
const relative2 = file.slice(dir.length + 1);
|
|
634
969
|
if (!componentSkipAdds[component]) componentSkipAdds[component] = [];
|
|
635
|
-
componentSkipAdds[component].push(
|
|
970
|
+
componentSkipAdds[component].push(relative2);
|
|
636
971
|
matched = true;
|
|
637
972
|
break;
|
|
638
973
|
}
|
|
@@ -643,10 +978,10 @@ async function learnSkips(cwd, files, componentPaths) {
|
|
|
643
978
|
}
|
|
644
979
|
for (const [component, additions] of Object.entries(componentSkipAdds)) {
|
|
645
980
|
const dir = componentPaths[component];
|
|
646
|
-
const marker = await readComponentMarker(
|
|
981
|
+
const marker = await readComponentMarker(join3(cwd, dir));
|
|
647
982
|
if (!marker) continue;
|
|
648
983
|
const merged = [.../* @__PURE__ */ new Set([...marker.skip, ...additions])];
|
|
649
|
-
await writeComponentMarker(
|
|
984
|
+
await writeComponentMarker(join3(cwd, dir), { ...marker, skip: merged });
|
|
650
985
|
}
|
|
651
986
|
if (rootSkipAdds.length > 0) {
|
|
652
987
|
const config = await readProjxConfig(cwd);
|
|
@@ -657,14 +992,14 @@ async function learnSkips(cwd, files, componentPaths) {
|
|
|
657
992
|
}
|
|
658
993
|
|
|
659
994
|
// src/add.ts
|
|
660
|
-
import { copyFileSync as copyFileSync2, existsSync as
|
|
661
|
-
import { readFile as
|
|
662
|
-
import { join as
|
|
995
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync4 } from "fs";
|
|
996
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
997
|
+
import { join as join4 } from "path";
|
|
663
998
|
import * as p4 from "@clack/prompts";
|
|
664
999
|
async function add(cwd, newComponents, localRepo, skipInstall = false, customName) {
|
|
665
1000
|
p4.intro("projx add");
|
|
666
1001
|
const isLocal = !!localRepo;
|
|
667
|
-
if (!
|
|
1002
|
+
if (!existsSync4(join4(cwd, ".projx"))) {
|
|
668
1003
|
p4.log.error(
|
|
669
1004
|
"No .projx file found. Run 'npx create-projx <name>' to create a project first."
|
|
670
1005
|
);
|
|
@@ -678,8 +1013,8 @@ async function add(cwd, newComponents, localRepo, skipInstall = false, customNam
|
|
|
678
1013
|
"--name can only be used when adding a single component type."
|
|
679
1014
|
);
|
|
680
1015
|
}
|
|
681
|
-
const targetDir =
|
|
682
|
-
if (
|
|
1016
|
+
const targetDir = join4(cwd, customName);
|
|
1017
|
+
if (existsSync4(targetDir)) {
|
|
683
1018
|
throw new Error(`Directory '${customName}' already exists.`);
|
|
684
1019
|
}
|
|
685
1020
|
return await addInstance(
|
|
@@ -730,10 +1065,11 @@ async function add(cwd, newComponents, localRepo, skipInstall = false, customNam
|
|
|
730
1065
|
components: allComponents,
|
|
731
1066
|
paths,
|
|
732
1067
|
instances,
|
|
733
|
-
pm: pmCommands(pm)
|
|
1068
|
+
pm: pmCommands(pm),
|
|
1069
|
+
orm: config.orm ?? "prisma"
|
|
734
1070
|
};
|
|
735
1071
|
const pkg = JSON.parse(
|
|
736
|
-
await
|
|
1072
|
+
await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
|
|
737
1073
|
);
|
|
738
1074
|
const version = pkg.version;
|
|
739
1075
|
const spinner7 = p4.spinner();
|
|
@@ -756,9 +1092,9 @@ async function add(cwd, newComponents, localRepo, skipInstall = false, customNam
|
|
|
756
1092
|
);
|
|
757
1093
|
}
|
|
758
1094
|
for (const component of toAdd) {
|
|
759
|
-
const example =
|
|
760
|
-
const env =
|
|
761
|
-
if (
|
|
1095
|
+
const example = join4(cwd, component, ".env.example");
|
|
1096
|
+
const env = join4(cwd, component, ".env");
|
|
1097
|
+
if (existsSync4(example) && !existsSync4(env)) {
|
|
762
1098
|
try {
|
|
763
1099
|
copyFileSync2(example, env);
|
|
764
1100
|
} catch {
|
|
@@ -798,24 +1134,24 @@ async function addInstance(cwd, type, customName, config, existing, localRepo, s
|
|
|
798
1134
|
components: existing,
|
|
799
1135
|
paths,
|
|
800
1136
|
instances,
|
|
801
|
-
pm: pmCommands(pm)
|
|
1137
|
+
pm: pmCommands(pm),
|
|
1138
|
+
orm: config.orm ?? "prisma"
|
|
802
1139
|
};
|
|
803
1140
|
const pkg = JSON.parse(
|
|
804
|
-
await
|
|
1141
|
+
await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
|
|
805
1142
|
);
|
|
806
1143
|
const version = pkg.version;
|
|
807
1144
|
const INSTANCE_AWARE_ROOT = /* @__PURE__ */ new Set([
|
|
808
1145
|
".github/workflows/ci.yml",
|
|
809
1146
|
".githooks/pre-commit",
|
|
810
1147
|
"scripts/setup.sh",
|
|
811
|
-
"docker-compose.yml"
|
|
812
|
-
"docker-compose.dev.yml"
|
|
1148
|
+
"docker-compose.yml"
|
|
813
1149
|
]);
|
|
814
1150
|
const rawSkip = Array.isArray(config.skip) ? config.skip : [];
|
|
815
1151
|
const rootSkip = rawSkip.filter((p11) => !INSTANCE_AWARE_ROOT.has(p11));
|
|
816
1152
|
const componentSkips = {};
|
|
817
1153
|
for (const inst of existingInstances) {
|
|
818
|
-
const m = await readComponentMarker(
|
|
1154
|
+
const m = await readComponentMarker(join4(cwd, inst.path));
|
|
819
1155
|
if (m?.skip && m.skip.length > 0) componentSkips[inst.type] = m.skip;
|
|
820
1156
|
}
|
|
821
1157
|
const spinner7 = p4.spinner();
|
|
@@ -851,9 +1187,9 @@ async function addInstance(cwd, type, customName, config, existing, localRepo, s
|
|
|
851
1187
|
if (!skipInstall) {
|
|
852
1188
|
await installDeps2(cwd, [{ type, path: customName }], pm);
|
|
853
1189
|
}
|
|
854
|
-
const example =
|
|
855
|
-
const env =
|
|
856
|
-
if (
|
|
1190
|
+
const example = join4(cwd, customName, ".env.example");
|
|
1191
|
+
const env = join4(cwd, customName, ".env");
|
|
1192
|
+
if (existsSync4(example) && !existsSync4(env)) {
|
|
857
1193
|
try {
|
|
858
1194
|
copyFileSync2(example, env);
|
|
859
1195
|
} catch {
|
|
@@ -868,7 +1204,7 @@ async function installDeps2(dest, instances, pm) {
|
|
|
868
1204
|
const cmds = pmCommands(pm);
|
|
869
1205
|
const pmBin = pm === "bun" ? "bun" : pm;
|
|
870
1206
|
for (const { type, path } of instances) {
|
|
871
|
-
const dir =
|
|
1207
|
+
const dir = join4(dest, path);
|
|
872
1208
|
const spinner7 = p4.spinner();
|
|
873
1209
|
try {
|
|
874
1210
|
switch (type) {
|
|
@@ -894,6 +1230,19 @@ async function installDeps2(dest, instances, pm) {
|
|
|
894
1230
|
);
|
|
895
1231
|
}
|
|
896
1232
|
break;
|
|
1233
|
+
case "express":
|
|
1234
|
+
if (hasCommand(pmBin)) {
|
|
1235
|
+
spinner7.start(
|
|
1236
|
+
`Installing Express dependencies (${path}/, ${cmds.install})`
|
|
1237
|
+
);
|
|
1238
|
+
exec(cmds.install, dir);
|
|
1239
|
+
spinner7.stop(`Express dependencies installed (${path}/).`);
|
|
1240
|
+
} else {
|
|
1241
|
+
p4.log.warn(
|
|
1242
|
+
`${pm} not found \u2014 run 'cd ${path} && ${cmds.install}' manually.`
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
break;
|
|
897
1246
|
case "frontend":
|
|
898
1247
|
if (hasCommand(pmBin)) {
|
|
899
1248
|
spinner7.start(
|
|
@@ -941,24 +1290,24 @@ async function installDeps2(dest, instances, pm) {
|
|
|
941
1290
|
}
|
|
942
1291
|
|
|
943
1292
|
// src/init.ts
|
|
944
|
-
import { existsSync as
|
|
945
|
-
import { readFile as
|
|
1293
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1294
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
946
1295
|
import { execSync as execSync2 } from "child_process";
|
|
947
|
-
import { join as
|
|
1296
|
+
import { join as join6 } from "path";
|
|
948
1297
|
import * as p5 from "@clack/prompts";
|
|
949
1298
|
|
|
950
1299
|
// src/detect.ts
|
|
951
|
-
import { existsSync as
|
|
952
|
-
import { readdir } from "fs/promises";
|
|
953
|
-
import { join as
|
|
1300
|
+
import { existsSync as existsSync5 } from "fs";
|
|
1301
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
1302
|
+
import { join as join5 } from "path";
|
|
954
1303
|
async function detectComponents(cwd) {
|
|
955
1304
|
const results = [];
|
|
956
|
-
const entries = await
|
|
1305
|
+
const entries = await readdir2(cwd, { withFileTypes: true });
|
|
957
1306
|
const dirs = entries.filter(
|
|
958
1307
|
(e) => e.isDirectory() && !e.name.startsWith(".") && !EXCLUDE.has(e.name)
|
|
959
1308
|
).map((e) => e.name);
|
|
960
1309
|
for (const dir of dirs) {
|
|
961
|
-
const full =
|
|
1310
|
+
const full = join5(cwd, dir);
|
|
962
1311
|
const detections = await scanDirectory(full, dir);
|
|
963
1312
|
results.push(...detections);
|
|
964
1313
|
}
|
|
@@ -966,7 +1315,7 @@ async function detectComponents(cwd) {
|
|
|
966
1315
|
}
|
|
967
1316
|
async function scanDirectory(dir, relPath) {
|
|
968
1317
|
const results = [];
|
|
969
|
-
const pyproject = await readFileOrNull(
|
|
1318
|
+
const pyproject = await readFileOrNull(join5(dir, "pyproject.toml"));
|
|
970
1319
|
if (pyproject && /fastapi/i.test(pyproject)) {
|
|
971
1320
|
results.push({
|
|
972
1321
|
component: "fastapi",
|
|
@@ -986,6 +1335,14 @@ async function scanDirectory(dir, relPath) {
|
|
|
986
1335
|
evidence: "package.json has fastify dependency"
|
|
987
1336
|
});
|
|
988
1337
|
}
|
|
1338
|
+
if (allDeps.express) {
|
|
1339
|
+
results.push({
|
|
1340
|
+
component: "express",
|
|
1341
|
+
directory: relPath,
|
|
1342
|
+
confidence: "high",
|
|
1343
|
+
evidence: "package.json has express dependency"
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
989
1346
|
if (allDeps.react || allDeps["react-dom"]) {
|
|
990
1347
|
results.push({
|
|
991
1348
|
component: "frontend",
|
|
@@ -1003,7 +1360,7 @@ async function scanDirectory(dir, relPath) {
|
|
|
1003
1360
|
});
|
|
1004
1361
|
}
|
|
1005
1362
|
}
|
|
1006
|
-
const pubspec = await readFileOrNull(
|
|
1363
|
+
const pubspec = await readFileOrNull(join5(dir, "pubspec.yaml"));
|
|
1007
1364
|
if (pubspec && /flutter:/i.test(pubspec)) {
|
|
1008
1365
|
results.push({
|
|
1009
1366
|
component: "mobile",
|
|
@@ -1012,7 +1369,7 @@ async function scanDirectory(dir, relPath) {
|
|
|
1012
1369
|
evidence: "pubspec.yaml has flutter dependency"
|
|
1013
1370
|
});
|
|
1014
1371
|
}
|
|
1015
|
-
const hasTf =
|
|
1372
|
+
const hasTf = existsSync5(join5(dir, "main.tf")) || existsSync5(join5(dir, "variables.tf")) || existsSync5(join5(dir, "stack/main.tf")) || existsSync5(join5(dir, "versions.tf"));
|
|
1016
1373
|
if (hasTf) {
|
|
1017
1374
|
results.push({
|
|
1018
1375
|
component: "infra",
|
|
@@ -1024,7 +1381,7 @@ async function scanDirectory(dir, relPath) {
|
|
|
1024
1381
|
return results;
|
|
1025
1382
|
}
|
|
1026
1383
|
async function readPkg(dir) {
|
|
1027
|
-
const content = await readFileOrNull(
|
|
1384
|
+
const content = await readFileOrNull(join5(dir, "package.json"));
|
|
1028
1385
|
if (!content) return null;
|
|
1029
1386
|
try {
|
|
1030
1387
|
return JSON.parse(content);
|
|
@@ -1037,7 +1394,7 @@ async function readPkg(dir) {
|
|
|
1037
1394
|
async function init(cwd, localRepo) {
|
|
1038
1395
|
p5.intro("projx init");
|
|
1039
1396
|
const isLocal = !!localRepo;
|
|
1040
|
-
if (
|
|
1397
|
+
if (existsSync6(join6(cwd, ".projx"))) {
|
|
1041
1398
|
p5.log.error(
|
|
1042
1399
|
"This project is already initialized. Use 'npx create-projx update' or 'npx create-projx add' instead."
|
|
1043
1400
|
);
|
|
@@ -1076,7 +1433,7 @@ async function init(cwd, localRepo) {
|
|
|
1076
1433
|
confirmed.map((c) => [c.component, c.directory])
|
|
1077
1434
|
);
|
|
1078
1435
|
const hasJs = components.some(
|
|
1079
|
-
(c) => ["fastify", "frontend", "e2e"].includes(c)
|
|
1436
|
+
(c) => ["fastify", "express", "frontend", "e2e"].includes(c)
|
|
1080
1437
|
);
|
|
1081
1438
|
let pm = "npm";
|
|
1082
1439
|
if (hasJs) {
|
|
@@ -1099,7 +1456,8 @@ async function init(cwd, localRepo) {
|
|
|
1099
1456
|
projectName,
|
|
1100
1457
|
components,
|
|
1101
1458
|
paths,
|
|
1102
|
-
pm: pmCommands(pm)
|
|
1459
|
+
pm: pmCommands(pm),
|
|
1460
|
+
orm: "prisma"
|
|
1103
1461
|
};
|
|
1104
1462
|
const dlSpinner = p5.spinner();
|
|
1105
1463
|
dlSpinner.start(
|
|
@@ -1113,7 +1471,7 @@ async function init(cwd, localRepo) {
|
|
|
1113
1471
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
1114
1472
|
try {
|
|
1115
1473
|
const pkg = JSON.parse(
|
|
1116
|
-
await
|
|
1474
|
+
await readFile5(join6(repoDir, "cli/package.json"), "utf-8")
|
|
1117
1475
|
);
|
|
1118
1476
|
const version = pkg.version;
|
|
1119
1477
|
const applySpinner = p5.spinner();
|
|
@@ -1130,7 +1488,7 @@ async function init(cwd, localRepo) {
|
|
|
1130
1488
|
true
|
|
1131
1489
|
);
|
|
1132
1490
|
applySpinner.stop("Template applied.");
|
|
1133
|
-
if (
|
|
1491
|
+
if (existsSync6(join6(cwd, ".githooks"))) {
|
|
1134
1492
|
try {
|
|
1135
1493
|
execSync2("git config core.hooksPath .githooks", { cwd, stdio: "pipe" });
|
|
1136
1494
|
} catch {
|
|
@@ -1179,7 +1537,7 @@ async function writeBareProjx(cwd, localRepo, isLocal, pm) {
|
|
|
1179
1537
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
1180
1538
|
try {
|
|
1181
1539
|
const pkg = JSON.parse(
|
|
1182
|
-
await
|
|
1540
|
+
await readFile5(join6(repoDir, "cli/package.json"), "utf-8")
|
|
1183
1541
|
);
|
|
1184
1542
|
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1185
1543
|
const config = {
|
|
@@ -1228,8 +1586,8 @@ function hasUncommittedChanges2(cwd) {
|
|
|
1228
1586
|
}
|
|
1229
1587
|
|
|
1230
1588
|
// src/pin.ts
|
|
1231
|
-
import { existsSync as
|
|
1232
|
-
import { join as
|
|
1589
|
+
import { existsSync as existsSync7 } from "fs";
|
|
1590
|
+
import { join as join7 } from "path";
|
|
1233
1591
|
import * as p6 from "@clack/prompts";
|
|
1234
1592
|
function classifyPattern(pattern, componentPaths) {
|
|
1235
1593
|
const dirToComponent = {};
|
|
@@ -1249,7 +1607,7 @@ function classifyPattern(pattern, componentPaths) {
|
|
|
1249
1607
|
}
|
|
1250
1608
|
async function pin(cwd, patterns) {
|
|
1251
1609
|
p6.intro("projx pin");
|
|
1252
|
-
if (!
|
|
1610
|
+
if (!existsSync7(join7(cwd, ".projx"))) {
|
|
1253
1611
|
p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
|
|
1254
1612
|
process.exit(1);
|
|
1255
1613
|
}
|
|
@@ -1262,20 +1620,20 @@ async function pin(cwd, patterns) {
|
|
|
1262
1620
|
p6.log.warn(`Cannot pin ${pattern} \u2014 config files are managed by projx.`);
|
|
1263
1621
|
continue;
|
|
1264
1622
|
}
|
|
1265
|
-
const { scope, component, relative } = classifyPattern(
|
|
1623
|
+
const { scope, component, relative: relative2 } = classifyPattern(
|
|
1266
1624
|
pattern,
|
|
1267
1625
|
componentPaths
|
|
1268
1626
|
);
|
|
1269
1627
|
if (scope === "component" && component) {
|
|
1270
1628
|
if (!componentAdds[component]) componentAdds[component] = [];
|
|
1271
|
-
componentAdds[component].push(
|
|
1629
|
+
componentAdds[component].push(relative2);
|
|
1272
1630
|
} else {
|
|
1273
|
-
rootAdds.push(
|
|
1631
|
+
rootAdds.push(relative2);
|
|
1274
1632
|
}
|
|
1275
1633
|
}
|
|
1276
1634
|
for (const [component, additions] of Object.entries(componentAdds)) {
|
|
1277
1635
|
const dir = componentPaths[component];
|
|
1278
|
-
const marker = await readComponentMarker(
|
|
1636
|
+
const marker = await readComponentMarker(join7(cwd, dir));
|
|
1279
1637
|
if (!marker) {
|
|
1280
1638
|
p6.log.error(`Could not read marker for ${component}.`);
|
|
1281
1639
|
continue;
|
|
@@ -1284,7 +1642,7 @@ async function pin(cwd, patterns) {
|
|
|
1284
1642
|
const added = merged.length - marker.skip.length;
|
|
1285
1643
|
if (added > 0) {
|
|
1286
1644
|
const next = { ...marker, skip: merged };
|
|
1287
|
-
await writeComponentMarker(
|
|
1645
|
+
await writeComponentMarker(join7(cwd, dir), next);
|
|
1288
1646
|
p6.log.success(`${component}: pinned ${additions.join(", ")}`);
|
|
1289
1647
|
} else {
|
|
1290
1648
|
p6.log.info(`${component}: already pinned.`);
|
|
@@ -1305,7 +1663,7 @@ async function pin(cwd, patterns) {
|
|
|
1305
1663
|
}
|
|
1306
1664
|
async function unpin(cwd, patterns) {
|
|
1307
1665
|
p6.intro("projx unpin");
|
|
1308
|
-
if (!
|
|
1666
|
+
if (!existsSync7(join7(cwd, ".projx"))) {
|
|
1309
1667
|
p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
|
|
1310
1668
|
process.exit(1);
|
|
1311
1669
|
}
|
|
@@ -1314,20 +1672,20 @@ async function unpin(cwd, patterns) {
|
|
|
1314
1672
|
const rootRemoves = [];
|
|
1315
1673
|
const componentRemoves = {};
|
|
1316
1674
|
for (const pattern of patterns) {
|
|
1317
|
-
const { scope, component, relative } = classifyPattern(
|
|
1675
|
+
const { scope, component, relative: relative2 } = classifyPattern(
|
|
1318
1676
|
pattern,
|
|
1319
1677
|
componentPaths
|
|
1320
1678
|
);
|
|
1321
1679
|
if (scope === "component" && component) {
|
|
1322
1680
|
if (!componentRemoves[component]) componentRemoves[component] = [];
|
|
1323
|
-
componentRemoves[component].push(
|
|
1681
|
+
componentRemoves[component].push(relative2);
|
|
1324
1682
|
} else {
|
|
1325
|
-
rootRemoves.push(
|
|
1683
|
+
rootRemoves.push(relative2);
|
|
1326
1684
|
}
|
|
1327
1685
|
}
|
|
1328
1686
|
for (const [component, removals] of Object.entries(componentRemoves)) {
|
|
1329
1687
|
const dir = componentPaths[component];
|
|
1330
|
-
const marker = await readComponentMarker(
|
|
1688
|
+
const marker = await readComponentMarker(join7(cwd, dir));
|
|
1331
1689
|
if (!marker) {
|
|
1332
1690
|
p6.log.error(`Could not read marker for ${component}.`);
|
|
1333
1691
|
continue;
|
|
@@ -1336,7 +1694,7 @@ async function unpin(cwd, patterns) {
|
|
|
1336
1694
|
const removed = marker.skip.length - filtered.length;
|
|
1337
1695
|
if (removed > 0) {
|
|
1338
1696
|
const next = { ...marker, skip: filtered };
|
|
1339
|
-
await writeComponentMarker(
|
|
1697
|
+
await writeComponentMarker(join7(cwd, dir), next);
|
|
1340
1698
|
p6.log.success(`${component}: unpinned ${removals.join(", ")}`);
|
|
1341
1699
|
} else {
|
|
1342
1700
|
p6.log.info(`${component}: not found in skip list.`);
|
|
@@ -1357,7 +1715,7 @@ async function unpin(cwd, patterns) {
|
|
|
1357
1715
|
}
|
|
1358
1716
|
async function listPins(cwd) {
|
|
1359
1717
|
p6.intro("projx pin --list");
|
|
1360
|
-
if (!
|
|
1718
|
+
if (!existsSync7(join7(cwd, ".projx"))) {
|
|
1361
1719
|
p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
|
|
1362
1720
|
process.exit(1);
|
|
1363
1721
|
}
|
|
@@ -1374,7 +1732,7 @@ async function listPins(cwd) {
|
|
|
1374
1732
|
}
|
|
1375
1733
|
for (const component of discovered) {
|
|
1376
1734
|
const dir = componentPaths[component];
|
|
1377
|
-
const marker = await readComponentMarker(
|
|
1735
|
+
const marker = await readComponentMarker(join7(cwd, dir));
|
|
1378
1736
|
if (marker?.skip && marker.skip.length > 0) {
|
|
1379
1737
|
hasAny = true;
|
|
1380
1738
|
const label = dir !== component ? `${component} (${dir}/)` : `${component}`;
|
|
@@ -1391,15 +1749,15 @@ async function listPins(cwd) {
|
|
|
1391
1749
|
}
|
|
1392
1750
|
|
|
1393
1751
|
// src/doctor.ts
|
|
1394
|
-
import { existsSync as
|
|
1395
|
-
import { readdir as
|
|
1752
|
+
import { existsSync as existsSync8 } from "fs";
|
|
1753
|
+
import { readdir as readdir3 } from "fs/promises";
|
|
1396
1754
|
import { execSync as execSync3 } from "child_process";
|
|
1397
|
-
import { join as
|
|
1755
|
+
import { join as join8 } from "path";
|
|
1398
1756
|
import * as p7 from "@clack/prompts";
|
|
1399
1757
|
async function checkConfig(cwd) {
|
|
1400
1758
|
const results = [];
|
|
1401
|
-
const configPath =
|
|
1402
|
-
if (!
|
|
1759
|
+
const configPath = join8(cwd, ".projx");
|
|
1760
|
+
if (!existsSync8(configPath)) {
|
|
1403
1761
|
results.push({
|
|
1404
1762
|
name: ".projx exists",
|
|
1405
1763
|
status: "fail",
|
|
@@ -1449,8 +1807,8 @@ async function checkComponents(cwd, components, componentPaths) {
|
|
|
1449
1807
|
});
|
|
1450
1808
|
for (const component of components) {
|
|
1451
1809
|
const dir = componentPaths[component];
|
|
1452
|
-
const fullDir =
|
|
1453
|
-
if (!
|
|
1810
|
+
const fullDir = join8(cwd, dir);
|
|
1811
|
+
if (!existsSync8(fullDir)) {
|
|
1454
1812
|
results.push({
|
|
1455
1813
|
name: `${component} directory`,
|
|
1456
1814
|
status: "fail",
|
|
@@ -1590,10 +1948,10 @@ async function checkSkipPatterns(cwd, rootConfig, components, componentPaths) {
|
|
|
1590
1948
|
}
|
|
1591
1949
|
for (const component of components) {
|
|
1592
1950
|
const dir = componentPaths[component];
|
|
1593
|
-
const marker = await readComponentMarker(
|
|
1951
|
+
const marker = await readComponentMarker(join8(cwd, dir));
|
|
1594
1952
|
if (marker?.skip && marker.skip.length > 0) {
|
|
1595
1953
|
for (const pattern of marker.skip) {
|
|
1596
|
-
const matches = await patternMatchesAnything(
|
|
1954
|
+
const matches = await patternMatchesAnything(join8(cwd, dir), pattern);
|
|
1597
1955
|
if (!matches) {
|
|
1598
1956
|
results.push({
|
|
1599
1957
|
name: `${component} skip`,
|
|
@@ -1615,16 +1973,16 @@ async function checkSkipPatterns(cwd, rootConfig, components, componentPaths) {
|
|
|
1615
1973
|
}
|
|
1616
1974
|
async function patternMatchesAnything(dir, pattern) {
|
|
1617
1975
|
if (pattern === "**") return true;
|
|
1618
|
-
if (!
|
|
1976
|
+
if (!existsSync8(dir)) return false;
|
|
1619
1977
|
const walk = async (current, base) => {
|
|
1620
1978
|
let entries;
|
|
1621
1979
|
try {
|
|
1622
|
-
entries = await
|
|
1980
|
+
entries = await readdir3(current, { withFileTypes: true });
|
|
1623
1981
|
} catch {
|
|
1624
1982
|
return false;
|
|
1625
1983
|
}
|
|
1626
1984
|
for (const entry of entries) {
|
|
1627
|
-
const full =
|
|
1985
|
+
const full = join8(current, entry.name);
|
|
1628
1986
|
const rel = full.slice(base.length + 1);
|
|
1629
1987
|
if (entry.isDirectory()) {
|
|
1630
1988
|
if (await walk(full, base)) return true;
|
|
@@ -1674,17 +2032,17 @@ function printReport(results) {
|
|
|
1674
2032
|
}
|
|
1675
2033
|
|
|
1676
2034
|
// src/diff.ts
|
|
1677
|
-
import { existsSync as
|
|
1678
|
-
import { readFile as
|
|
1679
|
-
import { join as
|
|
2035
|
+
import { existsSync as existsSync9 } from "fs";
|
|
2036
|
+
import { readFile as readFile6, mkdtemp, rm } from "fs/promises";
|
|
2037
|
+
import { join as join9 } from "path";
|
|
1680
2038
|
import { tmpdir } from "os";
|
|
1681
2039
|
import * as p8 from "@clack/prompts";
|
|
1682
2040
|
function isSkipped(file, componentPaths, componentSkips, rootSkip) {
|
|
1683
2041
|
for (const [component, dir] of Object.entries(componentPaths)) {
|
|
1684
2042
|
if (file.startsWith(dir + "/")) {
|
|
1685
|
-
const
|
|
2043
|
+
const relative2 = file.slice(dir.length + 1);
|
|
1686
2044
|
const skips = componentSkips[component] ?? [];
|
|
1687
|
-
if (matchesSkip(
|
|
2045
|
+
if (matchesSkip(relative2, skips)) return true;
|
|
1688
2046
|
}
|
|
1689
2047
|
}
|
|
1690
2048
|
const base = file.split("/").pop();
|
|
@@ -1700,7 +2058,7 @@ function fileComponent(file, componentPaths) {
|
|
|
1700
2058
|
async function diff(cwd, localRepo) {
|
|
1701
2059
|
p8.intro("projx diff");
|
|
1702
2060
|
const isLocal = !!localRepo;
|
|
1703
|
-
if (!
|
|
2061
|
+
if (!existsSync9(join9(cwd, ".projx"))) {
|
|
1704
2062
|
p8.log.error("No .projx file found. Run 'npx create-projx init' first.");
|
|
1705
2063
|
process.exit(1);
|
|
1706
2064
|
}
|
|
@@ -1709,7 +2067,7 @@ async function diff(cwd, localRepo) {
|
|
|
1709
2067
|
const componentSkips = {};
|
|
1710
2068
|
for (const component of components) {
|
|
1711
2069
|
const dir = componentPaths[component];
|
|
1712
|
-
const marker = await readComponentMarker(
|
|
2070
|
+
const marker = await readComponentMarker(join9(cwd, dir));
|
|
1713
2071
|
if (marker?.skip && marker.skip.length > 0) {
|
|
1714
2072
|
componentSkips[component] = marker.skip;
|
|
1715
2073
|
}
|
|
@@ -1727,7 +2085,7 @@ async function diff(cwd, localRepo) {
|
|
|
1727
2085
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
1728
2086
|
try {
|
|
1729
2087
|
const pkg = JSON.parse(
|
|
1730
|
-
await
|
|
2088
|
+
await readFile6(join9(repoDir, "cli/package.json"), "utf-8")
|
|
1731
2089
|
);
|
|
1732
2090
|
const version = pkg.version;
|
|
1733
2091
|
p8.log.info(`Current: v${raw.version ?? "unknown"} \u2192 Template: v${version}`);
|
|
@@ -1736,12 +2094,12 @@ async function diff(cwd, localRepo) {
|
|
|
1736
2094
|
projectName: name,
|
|
1737
2095
|
components,
|
|
1738
2096
|
paths: componentPaths,
|
|
1739
|
-
pm: pmCommands(raw.packageManager ?? "npm")
|
|
2097
|
+
pm: pmCommands(raw.packageManager ?? "npm"),
|
|
2098
|
+
orm: raw.orm ?? "prisma"
|
|
1740
2099
|
};
|
|
1741
2100
|
const spinner7 = p8.spinner();
|
|
1742
2101
|
spinner7.start("Analyzing changes");
|
|
1743
|
-
const tmpTemplate =
|
|
1744
|
-
await mkdir2(tmpTemplate, { recursive: true });
|
|
2102
|
+
const tmpTemplate = await mkdtemp(join9(tmpdir(), "projx-diff-"));
|
|
1745
2103
|
await writeTemplateToDir(
|
|
1746
2104
|
tmpTemplate,
|
|
1747
2105
|
repoDir,
|
|
@@ -1764,16 +2122,16 @@ async function diff(cwd, localRepo) {
|
|
|
1764
2122
|
analyses.push({ file, status: "skipped", component });
|
|
1765
2123
|
continue;
|
|
1766
2124
|
}
|
|
1767
|
-
const oursPath =
|
|
1768
|
-
if (!
|
|
2125
|
+
const oursPath = join9(cwd, file);
|
|
2126
|
+
if (!existsSync9(oursPath)) {
|
|
1769
2127
|
analyses.push({ file, status: "new", component });
|
|
1770
2128
|
continue;
|
|
1771
2129
|
}
|
|
1772
2130
|
let oursContent;
|
|
1773
2131
|
let theirsContent;
|
|
1774
2132
|
try {
|
|
1775
|
-
oursContent = await
|
|
1776
|
-
theirsContent = await
|
|
2133
|
+
oursContent = await readFile6(oursPath, "utf-8");
|
|
2134
|
+
theirsContent = await readFile6(join9(tmpTemplate, file), "utf-8");
|
|
1777
2135
|
} catch {
|
|
1778
2136
|
continue;
|
|
1779
2137
|
}
|
|
@@ -1853,9 +2211,10 @@ async function diff(cwd, localRepo) {
|
|
|
1853
2211
|
}
|
|
1854
2212
|
|
|
1855
2213
|
// src/gen.ts
|
|
1856
|
-
import { existsSync as
|
|
1857
|
-
import { readFile as
|
|
1858
|
-
import { join as
|
|
2214
|
+
import { existsSync as existsSync10 } from "fs";
|
|
2215
|
+
import { readFile as readFile7, writeFile as writeFile2, mkdir as mkdir3 } from "fs/promises";
|
|
2216
|
+
import { join as join10 } from "path";
|
|
2217
|
+
import { fileURLToPath } from "url";
|
|
1859
2218
|
import * as p9 from "@clack/prompts";
|
|
1860
2219
|
var FIELD_TYPES = [
|
|
1861
2220
|
"string",
|
|
@@ -1928,11 +2287,23 @@ async function promptEntityConfig(name) {
|
|
|
1928
2287
|
initialValue: true
|
|
1929
2288
|
});
|
|
1930
2289
|
if (p9.isCancel(required)) process.exit(0);
|
|
1931
|
-
fields.push({
|
|
2290
|
+
fields.push({
|
|
2291
|
+
name: toSnake(fieldName),
|
|
2292
|
+
type: fieldType,
|
|
2293
|
+
required,
|
|
2294
|
+
unique: false,
|
|
2295
|
+
generated: false
|
|
2296
|
+
});
|
|
1932
2297
|
}
|
|
1933
2298
|
if (fields.length === 0) {
|
|
1934
2299
|
p9.log.warn("No fields defined. Adding a default 'name' field.");
|
|
1935
|
-
fields.push({
|
|
2300
|
+
fields.push({
|
|
2301
|
+
name: "name",
|
|
2302
|
+
type: "string",
|
|
2303
|
+
required: true,
|
|
2304
|
+
unique: false,
|
|
2305
|
+
generated: false
|
|
2306
|
+
});
|
|
1936
2307
|
}
|
|
1937
2308
|
const stringFields = fields.filter(
|
|
1938
2309
|
(f) => f.type === "string" || f.type === "text"
|
|
@@ -1965,7 +2336,14 @@ function parseFieldsFlag(raw) {
|
|
|
1965
2336
|
const required = nameType.endsWith("!");
|
|
1966
2337
|
const name = toSnake(required ? nameType.slice(0, -1) : nameType);
|
|
1967
2338
|
const type = rest[0] || "string";
|
|
1968
|
-
|
|
2339
|
+
const modifiers = new Set(rest.slice(1).map((item) => item.toLowerCase()));
|
|
2340
|
+
return {
|
|
2341
|
+
name,
|
|
2342
|
+
type,
|
|
2343
|
+
required: required || true,
|
|
2344
|
+
unique: modifiers.has("unique") || modifiers.has("@unique"),
|
|
2345
|
+
generated: modifiers.has("generated") || modifiers.has("server") || modifiers.has("server-generated")
|
|
2346
|
+
};
|
|
1969
2347
|
});
|
|
1970
2348
|
}
|
|
1971
2349
|
function sqlalchemyType(type) {
|
|
@@ -2088,24 +2466,6 @@ function typeboxOptional(type) {
|
|
|
2088
2466
|
return "Type.Optional(Type.Any())";
|
|
2089
2467
|
}
|
|
2090
2468
|
}
|
|
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
2469
|
function prismaType(type, required) {
|
|
2110
2470
|
const nullable = required ? "" : "?";
|
|
2111
2471
|
switch (type) {
|
|
@@ -2125,6 +2485,10 @@ function prismaType(type, required) {
|
|
|
2125
2485
|
return `Json${nullable}`;
|
|
2126
2486
|
}
|
|
2127
2487
|
}
|
|
2488
|
+
function prismaFieldType(field) {
|
|
2489
|
+
const base = prismaType(field.type, field.required);
|
|
2490
|
+
return field.unique ? `${base} @unique` : base;
|
|
2491
|
+
}
|
|
2128
2492
|
function generateFastifySchemas(config) {
|
|
2129
2493
|
const className = toPascal(config.name);
|
|
2130
2494
|
const lines = [];
|
|
@@ -2146,7 +2510,7 @@ function generateFastifySchemas(config) {
|
|
|
2146
2510
|
lines.push(`export type ${className} = Static<typeof ${className}Schema>;`);
|
|
2147
2511
|
lines.push("");
|
|
2148
2512
|
lines.push(`export const Create${className}Schema = Type.Object({`);
|
|
2149
|
-
for (const f of config.fields) {
|
|
2513
|
+
for (const f of config.fields.filter((field) => !field.generated)) {
|
|
2150
2514
|
if (f.required) {
|
|
2151
2515
|
lines.push(` ${f.name}: ${typeboxType(f.type, true)},`);
|
|
2152
2516
|
} else {
|
|
@@ -2160,7 +2524,7 @@ function generateFastifySchemas(config) {
|
|
|
2160
2524
|
);
|
|
2161
2525
|
lines.push("");
|
|
2162
2526
|
lines.push(`export const Update${className}Schema = Type.Object({`);
|
|
2163
|
-
for (const f of config.fields) {
|
|
2527
|
+
for (const f of config.fields.filter((field) => !field.generated)) {
|
|
2164
2528
|
lines.push(` ${f.name}: ${typeboxOptional(f.type)},`);
|
|
2165
2529
|
}
|
|
2166
2530
|
lines.push(`});`);
|
|
@@ -2174,44 +2538,26 @@ function generateFastifySchemas(config) {
|
|
|
2174
2538
|
function generateFastifyIndex(config) {
|
|
2175
2539
|
const className = toPascal(config.name);
|
|
2176
2540
|
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");
|
|
2541
|
+
const generatedFields = config.fields.filter((field) => field.generated);
|
|
2184
2542
|
const lines = [];
|
|
2185
2543
|
lines.push(
|
|
2186
|
-
`import { EntityRegistry, type EntityConfig
|
|
2544
|
+
`import { EntityRegistry, type EntityConfig } from '../_base/index.js';`
|
|
2187
2545
|
);
|
|
2546
|
+
if (generatedFields.length > 0) {
|
|
2547
|
+
lines.push(`import { randomBytes } from 'node:crypto';`);
|
|
2548
|
+
}
|
|
2188
2549
|
lines.push(
|
|
2189
2550
|
`import { ${className}Schema, Create${className}Schema, Update${className}Schema } from './schemas.js';`
|
|
2190
2551
|
);
|
|
2191
2552
|
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) {
|
|
2553
|
+
for (const field of generatedFields) {
|
|
2209
2554
|
lines.push(
|
|
2210
|
-
`
|
|
2555
|
+
`function generate${className}${toPascal(field.name)}(): string {`
|
|
2211
2556
|
);
|
|
2557
|
+
lines.push(` return randomBytes(8).toString('hex').toUpperCase();`);
|
|
2558
|
+
lines.push(`}`);
|
|
2559
|
+
lines.push("");
|
|
2212
2560
|
}
|
|
2213
|
-
lines.push(`];`);
|
|
2214
|
-
lines.push("");
|
|
2215
2561
|
const tags = config.apiPrefix.replace(/^\//, "");
|
|
2216
2562
|
lines.push(`export const ${camelConfig}: EntityConfig = {`);
|
|
2217
2563
|
lines.push(` name: '${className}',`);
|
|
@@ -2222,7 +2568,6 @@ function generateFastifyIndex(config) {
|
|
|
2222
2568
|
lines.push(` readonly: ${config.readonly},`);
|
|
2223
2569
|
lines.push(` softDelete: ${config.softDelete},`);
|
|
2224
2570
|
lines.push(` bulkOperations: ${config.bulkOperations},`);
|
|
2225
|
-
lines.push(` columnNames: [${allColumns.map((c) => `'${c}'`).join(", ")}],`);
|
|
2226
2571
|
if (config.searchableFields.length > 0) {
|
|
2227
2572
|
lines.push(
|
|
2228
2573
|
` searchableFields: [${config.searchableFields.map((f) => `'${f}'`).join(", ")}],`
|
|
@@ -2230,10 +2575,25 @@ function generateFastifyIndex(config) {
|
|
|
2230
2575
|
} else {
|
|
2231
2576
|
lines.push(` searchableFields: [],`);
|
|
2232
2577
|
}
|
|
2233
|
-
lines.push(` fields,`);
|
|
2234
2578
|
lines.push(` schema: ${className}Schema,`);
|
|
2235
2579
|
lines.push(` createSchema: Create${className}Schema,`);
|
|
2236
2580
|
lines.push(` updateSchema: Update${className}Schema,`);
|
|
2581
|
+
if (generatedFields.length > 0) {
|
|
2582
|
+
lines.push(
|
|
2583
|
+
` beforeCreateFields: [${generatedFields.map((field) => `'${field.name}'`).join(", ")}],`
|
|
2584
|
+
);
|
|
2585
|
+
lines.push(` beforeCreate: (_request, data) => {`);
|
|
2586
|
+
for (const field of generatedFields) {
|
|
2587
|
+
lines.push(
|
|
2588
|
+
` if (!('${field.name}' in data) || data.${field.name} == null) {`
|
|
2589
|
+
);
|
|
2590
|
+
lines.push(
|
|
2591
|
+
` data.${field.name} = generate${className}${toPascal(field.name)}();`
|
|
2592
|
+
);
|
|
2593
|
+
lines.push(` }`);
|
|
2594
|
+
}
|
|
2595
|
+
lines.push(` },`);
|
|
2596
|
+
}
|
|
2237
2597
|
lines.push(`};`);
|
|
2238
2598
|
lines.push("");
|
|
2239
2599
|
lines.push(`EntityRegistry.register(${camelConfig});`);
|
|
@@ -2247,7 +2607,7 @@ function generatePrismaModel(config) {
|
|
|
2247
2607
|
lines.push(` id String @id @default(uuid())`);
|
|
2248
2608
|
for (const f of config.fields) {
|
|
2249
2609
|
const padded = f.name.padEnd(10);
|
|
2250
|
-
lines.push(` ${padded} ${
|
|
2610
|
+
lines.push(` ${padded} ${prismaFieldType(f)}`);
|
|
2251
2611
|
}
|
|
2252
2612
|
if (config.softDelete) {
|
|
2253
2613
|
lines.push(` deleted_at DateTime?`);
|
|
@@ -2262,29 +2622,249 @@ function generatePrismaModel(config) {
|
|
|
2262
2622
|
lines.push(`}`);
|
|
2263
2623
|
return lines.join("\n");
|
|
2264
2624
|
}
|
|
2265
|
-
function
|
|
2266
|
-
|
|
2625
|
+
function drizzleColumn(field) {
|
|
2626
|
+
let expr;
|
|
2627
|
+
switch (field.type) {
|
|
2628
|
+
case "number":
|
|
2629
|
+
expr = `integer('${field.name}')`;
|
|
2630
|
+
break;
|
|
2631
|
+
case "boolean":
|
|
2632
|
+
expr = `boolean('${field.name}')`;
|
|
2633
|
+
break;
|
|
2634
|
+
case "date":
|
|
2635
|
+
expr = `date('${field.name}')`;
|
|
2636
|
+
break;
|
|
2637
|
+
case "datetime":
|
|
2638
|
+
expr = `timestamp('${field.name}', { withTimezone: true })`;
|
|
2639
|
+
break;
|
|
2640
|
+
case "json":
|
|
2641
|
+
expr = `jsonb('${field.name}')`;
|
|
2642
|
+
break;
|
|
2643
|
+
case "text":
|
|
2644
|
+
case "string":
|
|
2645
|
+
expr = `text('${field.name}')`;
|
|
2646
|
+
break;
|
|
2647
|
+
}
|
|
2648
|
+
if (field.required) expr += ".notNull()";
|
|
2649
|
+
if (field.unique) expr += ".unique()";
|
|
2650
|
+
return expr;
|
|
2651
|
+
}
|
|
2652
|
+
function generateDrizzleTable(config) {
|
|
2653
|
+
const lines = [];
|
|
2654
|
+
const tableConst = toCamel(pluralize(toPascal(config.name)));
|
|
2655
|
+
lines.push(`export const ${tableConst} = pgTable('${config.tableName}', {`);
|
|
2656
|
+
lines.push(` id: uuid('id').primaryKey().defaultRandom(),`);
|
|
2657
|
+
lines.push(
|
|
2658
|
+
` createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),`
|
|
2659
|
+
);
|
|
2660
|
+
lines.push(
|
|
2661
|
+
` updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),`
|
|
2662
|
+
);
|
|
2663
|
+
if (config.softDelete) {
|
|
2664
|
+
lines.push(` deletedAt: timestamp('deleted_at', { withTimezone: true }),`);
|
|
2665
|
+
}
|
|
2666
|
+
for (const field of config.fields) {
|
|
2667
|
+
lines.push(` ${toCamel(field.name)}: ${drizzleColumn(field)},`);
|
|
2668
|
+
}
|
|
2669
|
+
lines.push(`});`);
|
|
2670
|
+
return lines.join("\n");
|
|
2671
|
+
}
|
|
2672
|
+
function drizzleImports(config) {
|
|
2673
|
+
const used = /* @__PURE__ */ new Set(["pgTable", "uuid", "timestamp"]);
|
|
2674
|
+
for (const field of config.fields) {
|
|
2675
|
+
switch (field.type) {
|
|
2676
|
+
case "number":
|
|
2677
|
+
used.add("integer");
|
|
2678
|
+
break;
|
|
2679
|
+
case "boolean":
|
|
2680
|
+
used.add("boolean");
|
|
2681
|
+
break;
|
|
2682
|
+
case "date":
|
|
2683
|
+
used.add("date");
|
|
2684
|
+
break;
|
|
2685
|
+
case "datetime":
|
|
2686
|
+
used.add("timestamp");
|
|
2687
|
+
break;
|
|
2688
|
+
case "json":
|
|
2689
|
+
used.add("jsonb");
|
|
2690
|
+
break;
|
|
2691
|
+
case "text":
|
|
2692
|
+
case "string":
|
|
2693
|
+
used.add("text");
|
|
2694
|
+
break;
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
return [...used].sort();
|
|
2698
|
+
}
|
|
2699
|
+
function zodType(type, required) {
|
|
2700
|
+
const inner = (() => {
|
|
2267
2701
|
switch (type) {
|
|
2268
2702
|
case "string":
|
|
2269
2703
|
case "text":
|
|
2270
|
-
|
|
2271
|
-
case "datetime":
|
|
2272
|
-
return "string";
|
|
2704
|
+
return "z.string()";
|
|
2273
2705
|
case "number":
|
|
2274
|
-
return "number";
|
|
2706
|
+
return "z.number()";
|
|
2275
2707
|
case "boolean":
|
|
2276
|
-
return "boolean";
|
|
2708
|
+
return "z.boolean()";
|
|
2709
|
+
case "date":
|
|
2710
|
+
return "z.string().date()";
|
|
2711
|
+
case "datetime":
|
|
2712
|
+
return "z.string().datetime()";
|
|
2277
2713
|
case "json":
|
|
2278
|
-
return "
|
|
2714
|
+
return "z.unknown()";
|
|
2279
2715
|
}
|
|
2280
2716
|
})();
|
|
2281
|
-
return required ?
|
|
2717
|
+
return required ? inner : `${inner}.nullable()`;
|
|
2282
2718
|
}
|
|
2283
|
-
function
|
|
2719
|
+
function zodOptional(type) {
|
|
2720
|
+
switch (type) {
|
|
2721
|
+
case "string":
|
|
2722
|
+
case "text":
|
|
2723
|
+
return "z.string().optional()";
|
|
2724
|
+
case "number":
|
|
2725
|
+
return "z.number().optional()";
|
|
2726
|
+
case "boolean":
|
|
2727
|
+
return "z.boolean().optional()";
|
|
2728
|
+
case "date":
|
|
2729
|
+
return "z.string().date().optional()";
|
|
2730
|
+
case "datetime":
|
|
2731
|
+
return "z.string().datetime().optional()";
|
|
2732
|
+
case "json":
|
|
2733
|
+
return "z.unknown().optional()";
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
function generateExpressSchemas(config) {
|
|
2284
2737
|
const className = toPascal(config.name);
|
|
2285
2738
|
const lines = [];
|
|
2286
|
-
lines.push(`
|
|
2287
|
-
lines.push(
|
|
2739
|
+
lines.push(`import { z } from 'zod';`);
|
|
2740
|
+
lines.push("");
|
|
2741
|
+
lines.push(`export const ${className}Schema = z.object({`);
|
|
2742
|
+
lines.push(` id: z.string().uuid(),`);
|
|
2743
|
+
for (const f of config.fields) {
|
|
2744
|
+
lines.push(` ${f.name}: ${zodType(f.type, f.required)},`);
|
|
2745
|
+
}
|
|
2746
|
+
lines.push(` created_at: z.string().datetime(),`);
|
|
2747
|
+
lines.push(` updated_at: z.string().datetime(),`);
|
|
2748
|
+
if (config.softDelete)
|
|
2749
|
+
lines.push(` deleted_at: z.string().datetime().nullable(),`);
|
|
2750
|
+
lines.push(`});`);
|
|
2751
|
+
lines.push("");
|
|
2752
|
+
lines.push(`export type ${className} = z.infer<typeof ${className}Schema>;`);
|
|
2753
|
+
lines.push("");
|
|
2754
|
+
lines.push(`export const Create${className}Schema = z.object({`);
|
|
2755
|
+
for (const f of config.fields.filter((field) => !field.generated)) {
|
|
2756
|
+
if (f.required) {
|
|
2757
|
+
lines.push(` ${f.name}: ${zodType(f.type, true)},`);
|
|
2758
|
+
} else {
|
|
2759
|
+
lines.push(` ${f.name}: ${zodOptional(f.type)},`);
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
lines.push(`});`);
|
|
2763
|
+
lines.push("");
|
|
2764
|
+
lines.push(
|
|
2765
|
+
`export type Create${className} = z.infer<typeof Create${className}Schema>;`
|
|
2766
|
+
);
|
|
2767
|
+
lines.push("");
|
|
2768
|
+
lines.push(`export const Update${className}Schema = z.object({`);
|
|
2769
|
+
for (const f of config.fields.filter((field) => !field.generated)) {
|
|
2770
|
+
lines.push(` ${f.name}: ${zodOptional(f.type)},`);
|
|
2771
|
+
}
|
|
2772
|
+
lines.push(`});`);
|
|
2773
|
+
lines.push("");
|
|
2774
|
+
lines.push(
|
|
2775
|
+
`export type Update${className} = z.infer<typeof Update${className}Schema>;`
|
|
2776
|
+
);
|
|
2777
|
+
lines.push("");
|
|
2778
|
+
return lines.join("\n");
|
|
2779
|
+
}
|
|
2780
|
+
function generateExpressIndex(config) {
|
|
2781
|
+
const className = toPascal(config.name);
|
|
2782
|
+
const camelConfig = className.charAt(0).toLowerCase() + className.slice(1) + "Config";
|
|
2783
|
+
const generatedFields = config.fields.filter((field) => field.generated);
|
|
2784
|
+
const tags = config.apiPrefix.replace(/^\//, "");
|
|
2785
|
+
const lines = [];
|
|
2786
|
+
lines.push(
|
|
2787
|
+
`import { EntityRegistry, type EntityConfig } from '../_base/index.js';`
|
|
2788
|
+
);
|
|
2789
|
+
if (generatedFields.length > 0) {
|
|
2790
|
+
lines.push(`import { randomBytes } from 'node:crypto';`);
|
|
2791
|
+
}
|
|
2792
|
+
lines.push(
|
|
2793
|
+
`import { ${className}Schema, Create${className}Schema, Update${className}Schema } from './schemas.js';`
|
|
2794
|
+
);
|
|
2795
|
+
lines.push("");
|
|
2796
|
+
for (const field of generatedFields) {
|
|
2797
|
+
lines.push(
|
|
2798
|
+
`function generate${className}${toPascal(field.name)}(): string {`
|
|
2799
|
+
);
|
|
2800
|
+
lines.push(` return randomBytes(8).toString('hex').toUpperCase();`);
|
|
2801
|
+
lines.push(`}`);
|
|
2802
|
+
lines.push("");
|
|
2803
|
+
}
|
|
2804
|
+
lines.push(`export const ${camelConfig}: EntityConfig = {`);
|
|
2805
|
+
lines.push(` name: '${className}',`);
|
|
2806
|
+
lines.push(` tableName: '${config.tableName}',`);
|
|
2807
|
+
lines.push(` prismaModel: '${className}',`);
|
|
2808
|
+
lines.push(` apiPrefix: '${config.apiPrefix}',`);
|
|
2809
|
+
lines.push(` tags: ['${tags}'],`);
|
|
2810
|
+
lines.push(` readonly: ${config.readonly},`);
|
|
2811
|
+
lines.push(` softDelete: ${config.softDelete},`);
|
|
2812
|
+
lines.push(` bulkOperations: ${config.bulkOperations},`);
|
|
2813
|
+
if (config.searchableFields.length > 0) {
|
|
2814
|
+
lines.push(
|
|
2815
|
+
` searchableFields: [${config.searchableFields.map((f) => `'${f}'`).join(", ")}],`
|
|
2816
|
+
);
|
|
2817
|
+
} else {
|
|
2818
|
+
lines.push(` searchableFields: [],`);
|
|
2819
|
+
}
|
|
2820
|
+
lines.push(` schema: ${className}Schema,`);
|
|
2821
|
+
lines.push(` createSchema: Create${className}Schema,`);
|
|
2822
|
+
lines.push(` updateSchema: Update${className}Schema,`);
|
|
2823
|
+
if (generatedFields.length > 0) {
|
|
2824
|
+
lines.push(
|
|
2825
|
+
` beforeCreateFields: [${generatedFields.map((field) => `'${field.name}'`).join(", ")}],`
|
|
2826
|
+
);
|
|
2827
|
+
lines.push(` beforeCreate: (_request, data) => {`);
|
|
2828
|
+
for (const field of generatedFields) {
|
|
2829
|
+
lines.push(
|
|
2830
|
+
` if (!('${field.name}' in data) || data.${field.name} == null) {`
|
|
2831
|
+
);
|
|
2832
|
+
lines.push(
|
|
2833
|
+
` data.${field.name} = generate${className}${toPascal(field.name)}();`
|
|
2834
|
+
);
|
|
2835
|
+
lines.push(` }`);
|
|
2836
|
+
}
|
|
2837
|
+
lines.push(` },`);
|
|
2838
|
+
}
|
|
2839
|
+
lines.push(`};`);
|
|
2840
|
+
lines.push("");
|
|
2841
|
+
lines.push(`EntityRegistry.register(${camelConfig});`);
|
|
2842
|
+
lines.push("");
|
|
2843
|
+
return lines.join("\n");
|
|
2844
|
+
}
|
|
2845
|
+
function tsType(type, required) {
|
|
2846
|
+
const base = (() => {
|
|
2847
|
+
switch (type) {
|
|
2848
|
+
case "string":
|
|
2849
|
+
case "text":
|
|
2850
|
+
case "date":
|
|
2851
|
+
case "datetime":
|
|
2852
|
+
return "string";
|
|
2853
|
+
case "number":
|
|
2854
|
+
return "number";
|
|
2855
|
+
case "boolean":
|
|
2856
|
+
return "boolean";
|
|
2857
|
+
case "json":
|
|
2858
|
+
return "Record<string, unknown>";
|
|
2859
|
+
}
|
|
2860
|
+
})();
|
|
2861
|
+
return required ? base : `${base} | null`;
|
|
2862
|
+
}
|
|
2863
|
+
function generateFrontendInterface(config) {
|
|
2864
|
+
const className = toPascal(config.name);
|
|
2865
|
+
const lines = [];
|
|
2866
|
+
lines.push(`export interface ${className} {`);
|
|
2867
|
+
lines.push(` id: string;`);
|
|
2288
2868
|
for (const f of config.fields) {
|
|
2289
2869
|
lines.push(` ${f.name}: ${tsType(f.type, f.required)};`);
|
|
2290
2870
|
}
|
|
@@ -2331,7 +2911,8 @@ function dartType(type, required) {
|
|
|
2331
2911
|
return required ? base : `${base}?`;
|
|
2332
2912
|
}
|
|
2333
2913
|
function toCamel(s) {
|
|
2334
|
-
|
|
2914
|
+
const pascal = toPascal(s);
|
|
2915
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
2335
2916
|
}
|
|
2336
2917
|
function dartFromJson(fieldName, type, required) {
|
|
2337
2918
|
const key = `json['${fieldName}']`;
|
|
@@ -2565,45 +3146,499 @@ function generateFastifyTest(config) {
|
|
|
2565
3146
|
const className = toPascal(config.name);
|
|
2566
3147
|
const basePath = `/api/v1${config.apiPrefix}`;
|
|
2567
3148
|
const updateField = config.fields[0];
|
|
3149
|
+
const uniqueFields = config.fields.filter((field) => field.unique);
|
|
2568
3150
|
const lines = [];
|
|
2569
3151
|
lines.push(
|
|
2570
3152
|
`import { describeCrudEntity } from '../helpers/crud-test-base.js';`
|
|
2571
3153
|
);
|
|
3154
|
+
lines.push(
|
|
3155
|
+
`import { Create${className}Schema } from '../../src/modules/${toKebab(config.name)}/schemas.js';`
|
|
3156
|
+
);
|
|
2572
3157
|
lines.push("");
|
|
2573
3158
|
lines.push(`describeCrudEntity({`);
|
|
2574
3159
|
lines.push(` entityName: '${className}',`);
|
|
2575
3160
|
lines.push(` basePath: '${basePath}',`);
|
|
2576
3161
|
lines.push(` prismaModel: '${className}',`);
|
|
2577
|
-
lines.push(`
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
3162
|
+
lines.push(` createSchema: Create${className}Schema,`);
|
|
3163
|
+
lines.push(` updatePayload: {`);
|
|
3164
|
+
lines.push(
|
|
3165
|
+
` ${updateField.name}: ${tsLiteral(updateField.type, "update")},`
|
|
3166
|
+
);
|
|
2581
3167
|
lines.push(` },`);
|
|
3168
|
+
if (uniqueFields.length > 0) {
|
|
3169
|
+
lines.push(
|
|
3170
|
+
` uniqueFields: [${uniqueFields.map((field) => `'${field.name}'`).join(", ")}],`
|
|
3171
|
+
);
|
|
3172
|
+
}
|
|
3173
|
+
lines.push(`});`);
|
|
3174
|
+
lines.push("");
|
|
3175
|
+
return lines.join("\n");
|
|
3176
|
+
}
|
|
3177
|
+
function generateExpressTest(config) {
|
|
3178
|
+
const className = toPascal(config.name);
|
|
3179
|
+
const basePath = `/api/v1${config.apiPrefix}`;
|
|
3180
|
+
const updateField = config.fields[0];
|
|
3181
|
+
const uniqueFields = config.fields.filter((field) => field.unique);
|
|
3182
|
+
const lines = [];
|
|
3183
|
+
lines.push(
|
|
3184
|
+
`import { describeCrudEntity } from '../helpers/crud-test-base.js';`
|
|
3185
|
+
);
|
|
3186
|
+
lines.push(
|
|
3187
|
+
`import { Create${className}Schema } from '../../src/modules/${toKebab(config.name)}/schemas.js';`
|
|
3188
|
+
);
|
|
3189
|
+
lines.push("");
|
|
3190
|
+
lines.push(`describeCrudEntity({`);
|
|
3191
|
+
lines.push(` entityName: '${className}',`);
|
|
3192
|
+
lines.push(` basePath: '${basePath}',`);
|
|
3193
|
+
lines.push(` prismaModel: '${className}',`);
|
|
3194
|
+
lines.push(` createSchema: Create${className}Schema,`);
|
|
2582
3195
|
lines.push(` updatePayload: {`);
|
|
2583
3196
|
lines.push(
|
|
2584
3197
|
` ${updateField.name}: ${tsLiteral(updateField.type, "update")},`
|
|
2585
3198
|
);
|
|
2586
3199
|
lines.push(` },`);
|
|
3200
|
+
if (uniqueFields.length > 0) {
|
|
3201
|
+
lines.push(
|
|
3202
|
+
` uniqueFields: [${uniqueFields.map((field) => `'${field.name}'`).join(", ")}],`
|
|
3203
|
+
);
|
|
3204
|
+
}
|
|
2587
3205
|
lines.push(`});`);
|
|
2588
3206
|
lines.push("");
|
|
2589
3207
|
return lines.join("\n");
|
|
2590
3208
|
}
|
|
2591
|
-
|
|
3209
|
+
function addonGenEntityPath(orm, fileName) {
|
|
3210
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
3211
|
+
return join10(thisFile, "../../src/addons/orms", orm, "gen-entity", fileName);
|
|
3212
|
+
}
|
|
3213
|
+
function sampleValue(field) {
|
|
3214
|
+
switch (field.type) {
|
|
3215
|
+
case "string":
|
|
3216
|
+
case "text":
|
|
3217
|
+
return `'sample-${field.name}'`;
|
|
3218
|
+
case "number":
|
|
3219
|
+
return "1";
|
|
3220
|
+
case "boolean":
|
|
3221
|
+
return "true";
|
|
3222
|
+
case "date":
|
|
3223
|
+
return "'2025-01-01'";
|
|
3224
|
+
case "datetime":
|
|
3225
|
+
return "'2025-01-01T00:00:00Z'";
|
|
3226
|
+
case "json":
|
|
3227
|
+
return "{}";
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
function sampleJsonPayload(fields) {
|
|
3231
|
+
const props = fields.filter((f) => !f.generated).map((f) => `${f.name}: ${sampleValue(f)}`);
|
|
3232
|
+
return `{ ${props.join(", ")} }`;
|
|
3233
|
+
}
|
|
3234
|
+
function updateJsonPayload(fields) {
|
|
3235
|
+
const editable = fields.find(
|
|
3236
|
+
(f) => !f.generated && (f.type === "string" || f.type === "text")
|
|
3237
|
+
);
|
|
3238
|
+
if (editable) return `{ ${editable.name}: 'updated-${editable.name}' }`;
|
|
3239
|
+
const numeric = fields.find((f) => !f.generated && f.type === "number");
|
|
3240
|
+
if (numeric) return `{ ${numeric.name}: 2 }`;
|
|
3241
|
+
return sampleJsonPayload(fields);
|
|
3242
|
+
}
|
|
3243
|
+
function insertAtAnchor(content, anchor, insertion) {
|
|
3244
|
+
if (content.includes(insertion)) return content;
|
|
3245
|
+
const lines = content.split("\n");
|
|
3246
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3247
|
+
if (lines[i].includes(anchor)) {
|
|
3248
|
+
lines.splice(i + 1, 0, insertion);
|
|
3249
|
+
return lines.join("\n");
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
return content;
|
|
3253
|
+
}
|
|
3254
|
+
async function fillTemplate(orm, fileName, vars) {
|
|
3255
|
+
const path = addonGenEntityPath(orm, fileName);
|
|
3256
|
+
if (!existsSync10(path)) {
|
|
3257
|
+
throw new Error(`Addon template not found: ${path}`);
|
|
3258
|
+
}
|
|
3259
|
+
let content = await readFile7(path, "utf-8");
|
|
3260
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
3261
|
+
content = content.replaceAll(`__${key}__`, value);
|
|
3262
|
+
}
|
|
3263
|
+
return content;
|
|
3264
|
+
}
|
|
3265
|
+
function buildDrizzleEntityVars(config) {
|
|
3266
|
+
const pascal = toPascal(config.name);
|
|
3267
|
+
return {
|
|
3268
|
+
ENTITY_PASCAL: pascal,
|
|
3269
|
+
TABLE_CAMEL: toCamel(pluralize(pascal)),
|
|
3270
|
+
API_PREFIX: config.apiPrefix,
|
|
3271
|
+
TAG: config.apiPrefix.replace(/^\//, ""),
|
|
3272
|
+
SEARCHABLE_FIELDS_ARRAY: config.searchableFields.map((f) => `'${f}'`).join(", "),
|
|
3273
|
+
BULK_OPERATIONS: String(config.bulkOperations),
|
|
3274
|
+
SAMPLE_PAYLOAD: sampleJsonPayload(config.fields),
|
|
3275
|
+
UPDATE_PAYLOAD: updateJsonPayload(config.fields)
|
|
3276
|
+
};
|
|
3277
|
+
}
|
|
3278
|
+
function sequelizeFieldType(field) {
|
|
3279
|
+
switch (field.type) {
|
|
3280
|
+
case "string":
|
|
3281
|
+
return { dataType: "STRING", tsType: "string" };
|
|
3282
|
+
case "text":
|
|
3283
|
+
return { dataType: "TEXT", tsType: "string" };
|
|
3284
|
+
case "number":
|
|
3285
|
+
return { dataType: "INTEGER", tsType: "number" };
|
|
3286
|
+
case "boolean":
|
|
3287
|
+
return { dataType: "BOOLEAN", tsType: "boolean" };
|
|
3288
|
+
case "date":
|
|
3289
|
+
return { dataType: "DATEONLY", tsType: "Date" };
|
|
3290
|
+
case "datetime":
|
|
3291
|
+
return { dataType: "DATE", tsType: "Date" };
|
|
3292
|
+
case "json":
|
|
3293
|
+
return { dataType: "JSONB", tsType: "unknown" };
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
function sequelizeFieldDeclarations(fields) {
|
|
3297
|
+
return fields.map((f) => {
|
|
3298
|
+
const { tsType: tsType2 } = sequelizeFieldType(f);
|
|
3299
|
+
const nullable = f.required ? "" : " | null";
|
|
3300
|
+
return ` declare ${toCamel(f.name)}: ${tsType2}${nullable};`;
|
|
3301
|
+
}).join("\n");
|
|
3302
|
+
}
|
|
3303
|
+
function sequelizeFieldDefinitions(fields) {
|
|
3304
|
+
return fields.map((f) => {
|
|
3305
|
+
const { dataType } = sequelizeFieldType(f);
|
|
3306
|
+
const parts = [`type: DataTypes.${dataType}`];
|
|
3307
|
+
if (f.required) parts.push("allowNull: false");
|
|
3308
|
+
else parts.push("allowNull: true");
|
|
3309
|
+
if (f.unique) parts.push("unique: true");
|
|
3310
|
+
return ` ${toCamel(f.name)}: { ${parts.join(", ")} },`;
|
|
3311
|
+
}).join("\n");
|
|
3312
|
+
}
|
|
3313
|
+
function buildSequelizeEntityVars(config) {
|
|
3314
|
+
const pascal = toPascal(config.name);
|
|
3315
|
+
const kebab = toKebab(config.name);
|
|
3316
|
+
return {
|
|
3317
|
+
ENTITY_PASCAL: pascal,
|
|
3318
|
+
ENTITY_KEBAB: kebab,
|
|
3319
|
+
TABLE_NAME: config.tableName,
|
|
3320
|
+
API_PREFIX: config.apiPrefix,
|
|
3321
|
+
TAG: config.apiPrefix.replace(/^\//, ""),
|
|
3322
|
+
SEARCHABLE_FIELDS_ARRAY: config.searchableFields.map((f) => `'${f}'`).join(", "),
|
|
3323
|
+
BULK_OPERATIONS: String(config.bulkOperations),
|
|
3324
|
+
SAMPLE_PAYLOAD: sampleJsonPayload(config.fields),
|
|
3325
|
+
UPDATE_PAYLOAD: updateJsonPayload(config.fields),
|
|
3326
|
+
FIELD_DECLARATIONS: sequelizeFieldDeclarations(config.fields),
|
|
3327
|
+
FIELD_DEFINITIONS: sequelizeFieldDefinitions(config.fields)
|
|
3328
|
+
};
|
|
3329
|
+
}
|
|
3330
|
+
function typeormColumnType(field) {
|
|
3331
|
+
switch (field.type) {
|
|
3332
|
+
case "string":
|
|
3333
|
+
return "varchar";
|
|
3334
|
+
case "text":
|
|
3335
|
+
return "text";
|
|
3336
|
+
case "number":
|
|
3337
|
+
return "integer";
|
|
3338
|
+
case "boolean":
|
|
3339
|
+
return "boolean";
|
|
3340
|
+
case "date":
|
|
3341
|
+
return "date";
|
|
3342
|
+
case "datetime":
|
|
3343
|
+
return "timestamptz";
|
|
3344
|
+
case "json":
|
|
3345
|
+
return "jsonb";
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
function typeormColumnTsType(field) {
|
|
3349
|
+
switch (field.type) {
|
|
3350
|
+
case "string":
|
|
3351
|
+
case "text":
|
|
3352
|
+
return "string";
|
|
3353
|
+
case "number":
|
|
3354
|
+
return "number";
|
|
3355
|
+
case "boolean":
|
|
3356
|
+
return "boolean";
|
|
3357
|
+
case "date":
|
|
3358
|
+
case "datetime":
|
|
3359
|
+
return "Date";
|
|
3360
|
+
case "json":
|
|
3361
|
+
return "unknown";
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
function typeormColumnDecorators(fields) {
|
|
3365
|
+
return fields.map((f) => {
|
|
3366
|
+
const dbName = f.name;
|
|
3367
|
+
const propName = toCamel(f.name);
|
|
3368
|
+
const opts = [
|
|
3369
|
+
`type: '${typeormColumnType(f)}'`,
|
|
3370
|
+
`name: '${dbName}'`
|
|
3371
|
+
];
|
|
3372
|
+
if (!f.required) opts.push("nullable: true");
|
|
3373
|
+
if (f.unique) opts.push("unique: true");
|
|
3374
|
+
const tsType2 = typeormColumnTsType(f);
|
|
3375
|
+
const nullable = f.required ? "!" : "?";
|
|
3376
|
+
return ` @Column({ ${opts.join(", ")} })
|
|
3377
|
+
${propName}${nullable}: ${tsType2}${f.required ? "" : " | null"};`;
|
|
3378
|
+
}).join("\n\n");
|
|
3379
|
+
}
|
|
3380
|
+
function buildTypeormEntityVars(config) {
|
|
3381
|
+
const pascal = toPascal(config.name);
|
|
3382
|
+
const kebab = toKebab(config.name);
|
|
3383
|
+
return {
|
|
3384
|
+
ENTITY_PASCAL: pascal,
|
|
3385
|
+
ENTITY_KEBAB: kebab,
|
|
3386
|
+
TABLE_NAME: config.tableName,
|
|
3387
|
+
API_PREFIX: config.apiPrefix,
|
|
3388
|
+
TAG: config.apiPrefix.replace(/^\//, ""),
|
|
3389
|
+
SEARCHABLE_FIELDS_ARRAY: config.searchableFields.map((f) => `'${f}'`).join(", "),
|
|
3390
|
+
BULK_OPERATIONS: String(config.bulkOperations),
|
|
3391
|
+
SAMPLE_PAYLOAD: sampleJsonPayload(config.fields),
|
|
3392
|
+
UPDATE_PAYLOAD: updateJsonPayload(config.fields),
|
|
3393
|
+
COLUMN_DECORATORS: typeormColumnDecorators(config.fields)
|
|
3394
|
+
};
|
|
3395
|
+
}
|
|
3396
|
+
async function appendTypeormEntity(cwd, dir, framework, config, generated) {
|
|
3397
|
+
const vars = buildTypeormEntityVars(config);
|
|
3398
|
+
const kebab = vars.ENTITY_KEBAB;
|
|
3399
|
+
const entitiesDir = join10(cwd, dir, "src/entities");
|
|
3400
|
+
await mkdir3(entitiesDir, { recursive: true });
|
|
3401
|
+
const entityPath = join10(entitiesDir, `${kebab}.ts`);
|
|
3402
|
+
if (!existsSync10(entityPath)) {
|
|
3403
|
+
const entitySource = await fillTemplate("typeorm", "entity.ts", vars);
|
|
3404
|
+
await writeFile2(entityPath, entitySource);
|
|
3405
|
+
generated.push(`${dir}/src/entities/${kebab}.ts`);
|
|
3406
|
+
}
|
|
3407
|
+
const entitiesIndexPath = join10(entitiesDir, "index.ts");
|
|
3408
|
+
if (existsSync10(entitiesIndexPath)) {
|
|
3409
|
+
let content = await readFile7(entitiesIndexPath, "utf-8");
|
|
3410
|
+
const importLine = `import { ${vars.ENTITY_PASCAL} } from './${kebab}.js';`;
|
|
3411
|
+
const exportLine = ` ${vars.ENTITY_PASCAL},`;
|
|
3412
|
+
const updated = insertAtAnchor(
|
|
3413
|
+
insertAtAnchor(content, "projx-anchor: model-imports", importLine),
|
|
3414
|
+
"projx-anchor: model-exports",
|
|
3415
|
+
exportLine
|
|
3416
|
+
);
|
|
3417
|
+
if (updated !== content) {
|
|
3418
|
+
content = updated;
|
|
3419
|
+
await writeFile2(entitiesIndexPath, content);
|
|
3420
|
+
generated.push(`${dir}/src/entities/index.ts (entity wired)`);
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
3423
|
+
const moduleDir = join10(cwd, dir, "src/modules", kebab);
|
|
3424
|
+
if (!existsSync10(moduleDir)) {
|
|
3425
|
+
await mkdir3(moduleDir, { recursive: true });
|
|
3426
|
+
const routerSource = await fillTemplate(
|
|
3427
|
+
"typeorm",
|
|
3428
|
+
framework === "fastify" ? "fastify-router.ts" : "express-router.ts",
|
|
3429
|
+
vars
|
|
3430
|
+
);
|
|
3431
|
+
await writeFile2(join10(moduleDir, "index.ts"), routerSource);
|
|
3432
|
+
generated.push(`${dir}/src/modules/${kebab}/index.ts`);
|
|
3433
|
+
}
|
|
3434
|
+
const appPath = join10(cwd, dir, "src/app.ts");
|
|
3435
|
+
if (existsSync10(appPath)) {
|
|
3436
|
+
let appContent = await readFile7(appPath, "utf-8");
|
|
3437
|
+
const importLine = `import { register${vars.ENTITY_PASCAL}Entity } from './modules/${kebab}/index.js';`;
|
|
3438
|
+
const registrationLine = framework === "fastify" ? ` await register${vars.ENTITY_PASCAL}Entity(app);` : ` register${vars.ENTITY_PASCAL}Entity(app);`;
|
|
3439
|
+
const updated = insertAtAnchor(
|
|
3440
|
+
insertAtAnchor(appContent, "projx-anchor: entity-imports", importLine),
|
|
3441
|
+
"projx-anchor: entity-registrations",
|
|
3442
|
+
registrationLine
|
|
3443
|
+
);
|
|
3444
|
+
if (updated !== appContent) {
|
|
3445
|
+
appContent = updated;
|
|
3446
|
+
await writeFile2(appPath, appContent);
|
|
3447
|
+
generated.push(`${dir}/src/app.ts (entity wired)`);
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
const testsDir = framework === "fastify" ? join10(cwd, dir, "tests/modules") : join10(cwd, dir, "tests");
|
|
3451
|
+
await mkdir3(testsDir, { recursive: true });
|
|
3452
|
+
const testFile = join10(testsDir, `${kebab}.test.ts`);
|
|
3453
|
+
if (!existsSync10(testFile)) {
|
|
3454
|
+
const testSource = await fillTemplate(
|
|
3455
|
+
"typeorm",
|
|
3456
|
+
framework === "fastify" ? "fastify-test.ts" : "express-test.ts",
|
|
3457
|
+
vars
|
|
3458
|
+
);
|
|
3459
|
+
await writeFile2(testFile, testSource);
|
|
3460
|
+
const testRel = framework === "fastify" ? `tests/modules/${kebab}.test.ts` : `tests/${kebab}.test.ts`;
|
|
3461
|
+
generated.push(`${dir}/${testRel}`);
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
async function appendSequelizeEntity(cwd, dir, framework, config, generated) {
|
|
3465
|
+
const vars = buildSequelizeEntityVars(config);
|
|
3466
|
+
const kebab = vars.ENTITY_KEBAB;
|
|
3467
|
+
const modelsDir = join10(cwd, dir, "src/models");
|
|
3468
|
+
await mkdir3(modelsDir, { recursive: true });
|
|
3469
|
+
const modelPath = join10(modelsDir, `${kebab}.ts`);
|
|
3470
|
+
if (!existsSync10(modelPath)) {
|
|
3471
|
+
const modelSource = await fillTemplate("sequelize", "model.ts", vars);
|
|
3472
|
+
await writeFile2(modelPath, modelSource);
|
|
3473
|
+
generated.push(`${dir}/src/models/${kebab}.ts`);
|
|
3474
|
+
}
|
|
3475
|
+
const modelsIndexPath = join10(modelsDir, "index.ts");
|
|
3476
|
+
if (existsSync10(modelsIndexPath)) {
|
|
3477
|
+
let content = await readFile7(modelsIndexPath, "utf-8");
|
|
3478
|
+
const importLine = `import { ${vars.ENTITY_PASCAL} } from './${kebab}.js';`;
|
|
3479
|
+
const exportLine = ` ${vars.ENTITY_PASCAL},`;
|
|
3480
|
+
const updated = insertAtAnchor(
|
|
3481
|
+
insertAtAnchor(content, "projx-anchor: model-imports", importLine),
|
|
3482
|
+
"projx-anchor: model-exports",
|
|
3483
|
+
exportLine
|
|
3484
|
+
);
|
|
3485
|
+
if (updated !== content) {
|
|
3486
|
+
content = updated;
|
|
3487
|
+
await writeFile2(modelsIndexPath, content);
|
|
3488
|
+
generated.push(`${dir}/src/models/index.ts (model wired)`);
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
3491
|
+
const moduleDir = join10(cwd, dir, "src/modules", kebab);
|
|
3492
|
+
if (!existsSync10(moduleDir)) {
|
|
3493
|
+
await mkdir3(moduleDir, { recursive: true });
|
|
3494
|
+
const routerSource = await fillTemplate(
|
|
3495
|
+
"sequelize",
|
|
3496
|
+
framework === "fastify" ? "fastify-router.ts" : "express-router.ts",
|
|
3497
|
+
vars
|
|
3498
|
+
);
|
|
3499
|
+
await writeFile2(join10(moduleDir, "index.ts"), routerSource);
|
|
3500
|
+
generated.push(`${dir}/src/modules/${kebab}/index.ts`);
|
|
3501
|
+
}
|
|
3502
|
+
const appPath = join10(cwd, dir, "src/app.ts");
|
|
3503
|
+
if (existsSync10(appPath)) {
|
|
3504
|
+
let appContent = await readFile7(appPath, "utf-8");
|
|
3505
|
+
const importLine = `import { register${vars.ENTITY_PASCAL}Entity } from './modules/${kebab}/index.js';`;
|
|
3506
|
+
const registrationLine = framework === "fastify" ? ` await register${vars.ENTITY_PASCAL}Entity(app);` : ` register${vars.ENTITY_PASCAL}Entity(app);`;
|
|
3507
|
+
const updated = insertAtAnchor(
|
|
3508
|
+
insertAtAnchor(appContent, "projx-anchor: entity-imports", importLine),
|
|
3509
|
+
"projx-anchor: entity-registrations",
|
|
3510
|
+
registrationLine
|
|
3511
|
+
);
|
|
3512
|
+
if (updated !== appContent) {
|
|
3513
|
+
appContent = updated;
|
|
3514
|
+
await writeFile2(appPath, appContent);
|
|
3515
|
+
generated.push(`${dir}/src/app.ts (entity wired)`);
|
|
3516
|
+
}
|
|
3517
|
+
}
|
|
3518
|
+
const testsDir = framework === "fastify" ? join10(cwd, dir, "tests/modules") : join10(cwd, dir, "tests");
|
|
3519
|
+
await mkdir3(testsDir, { recursive: true });
|
|
3520
|
+
const testFile = join10(testsDir, `${kebab}.test.ts`);
|
|
3521
|
+
if (!existsSync10(testFile)) {
|
|
3522
|
+
const testSource = await fillTemplate(
|
|
3523
|
+
"sequelize",
|
|
3524
|
+
framework === "fastify" ? "fastify-test.ts" : "express-test.ts",
|
|
3525
|
+
vars
|
|
3526
|
+
);
|
|
3527
|
+
await writeFile2(testFile, testSource);
|
|
3528
|
+
const testRel = framework === "fastify" ? `tests/modules/${kebab}.test.ts` : `tests/${kebab}.test.ts`;
|
|
3529
|
+
generated.push(`${dir}/${testRel}`);
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
async function appendDrizzleEntity(cwd, dir, framework, config, generated) {
|
|
3533
|
+
const schemaDir = join10(cwd, dir, "src/db");
|
|
3534
|
+
const schemaPath = join10(schemaDir, "schema.ts");
|
|
3535
|
+
const tableConst = toCamel(pluralize(toPascal(config.name)));
|
|
3536
|
+
const tableSource = generateDrizzleTable(config);
|
|
3537
|
+
await mkdir3(schemaDir, { recursive: true });
|
|
3538
|
+
const usedImports = drizzleImports(config);
|
|
3539
|
+
if (!existsSync10(schemaPath)) {
|
|
3540
|
+
await writeFile2(
|
|
3541
|
+
schemaPath,
|
|
3542
|
+
`import { ${usedImports.join(", ")} } from 'drizzle-orm/pg-core';
|
|
3543
|
+
|
|
3544
|
+
${tableSource}
|
|
3545
|
+
`
|
|
3546
|
+
);
|
|
3547
|
+
generated.push(`${dir}/src/db/schema.ts`);
|
|
3548
|
+
} else {
|
|
3549
|
+
const content = await readFile7(schemaPath, "utf-8");
|
|
3550
|
+
if (!content.includes(`export const ${tableConst} = pgTable(`)) {
|
|
3551
|
+
let updated = content;
|
|
3552
|
+
const importLine = `import { ${usedImports.join(", ")} } from 'drizzle-orm/pg-core';`;
|
|
3553
|
+
if (!updated.includes("drizzle-orm/pg-core")) {
|
|
3554
|
+
updated = importLine + "\n\n" + updated;
|
|
3555
|
+
} else {
|
|
3556
|
+
updated = updated.replace(
|
|
3557
|
+
/import\s+\{([^}]+)\}\s+from\s+'drizzle-orm\/pg-core';/,
|
|
3558
|
+
(_match, imports) => {
|
|
3559
|
+
const names = new Set(
|
|
3560
|
+
String(imports).split(",").map((item) => item.trim()).filter(Boolean)
|
|
3561
|
+
);
|
|
3562
|
+
for (const name of usedImports) {
|
|
3563
|
+
names.add(name);
|
|
3564
|
+
}
|
|
3565
|
+
return `import { ${[...names].sort().join(", ")} } from 'drizzle-orm/pg-core';`;
|
|
3566
|
+
}
|
|
3567
|
+
);
|
|
3568
|
+
}
|
|
3569
|
+
await writeFile2(
|
|
3570
|
+
schemaPath,
|
|
3571
|
+
updated.trimEnd() + "\n\n" + tableSource + "\n"
|
|
3572
|
+
);
|
|
3573
|
+
generated.push(`${dir}/src/db/schema.ts (table added)`);
|
|
3574
|
+
}
|
|
3575
|
+
}
|
|
3576
|
+
const vars = buildDrizzleEntityVars(config);
|
|
3577
|
+
const kebab = toKebab(config.name);
|
|
3578
|
+
const moduleDir = join10(cwd, dir, "src/modules", kebab);
|
|
3579
|
+
if (!existsSync10(moduleDir)) {
|
|
3580
|
+
await mkdir3(moduleDir, { recursive: true });
|
|
3581
|
+
const routerSource = await fillTemplate(
|
|
3582
|
+
"drizzle",
|
|
3583
|
+
framework === "fastify" ? "fastify-router.ts" : "express-router.ts",
|
|
3584
|
+
vars
|
|
3585
|
+
);
|
|
3586
|
+
await writeFile2(join10(moduleDir, "index.ts"), routerSource);
|
|
3587
|
+
generated.push(`${dir}/src/modules/${kebab}/index.ts`);
|
|
3588
|
+
}
|
|
3589
|
+
const appPath = join10(cwd, dir, "src/app.ts");
|
|
3590
|
+
if (existsSync10(appPath)) {
|
|
3591
|
+
let appContent = await readFile7(appPath, "utf-8");
|
|
3592
|
+
const importLine = `import { register${vars.ENTITY_PASCAL}Entity } from './modules/${kebab}/index.js';`;
|
|
3593
|
+
const registrationLine = framework === "fastify" ? ` await register${vars.ENTITY_PASCAL}Entity(app);` : ` register${vars.ENTITY_PASCAL}Entity(app, db);`;
|
|
3594
|
+
const updated = insertAtAnchor(
|
|
3595
|
+
insertAtAnchor(appContent, "projx-anchor: entity-imports", importLine),
|
|
3596
|
+
"projx-anchor: entity-registrations",
|
|
3597
|
+
registrationLine
|
|
3598
|
+
);
|
|
3599
|
+
if (updated !== appContent) {
|
|
3600
|
+
appContent = updated;
|
|
3601
|
+
await writeFile2(appPath, appContent);
|
|
3602
|
+
generated.push(`${dir}/src/app.ts (entity wired)`);
|
|
3603
|
+
}
|
|
3604
|
+
}
|
|
3605
|
+
const testsDir = framework === "fastify" ? join10(cwd, dir, "tests/modules") : join10(cwd, dir, "tests");
|
|
3606
|
+
await mkdir3(testsDir, { recursive: true });
|
|
3607
|
+
const testFile = join10(testsDir, `${kebab}.test.ts`);
|
|
3608
|
+
if (!existsSync10(testFile)) {
|
|
3609
|
+
const testSource = await fillTemplate(
|
|
3610
|
+
"drizzle",
|
|
3611
|
+
framework === "fastify" ? "fastify-test.ts" : "express-test.ts",
|
|
3612
|
+
vars
|
|
3613
|
+
);
|
|
3614
|
+
await writeFile2(testFile, testSource);
|
|
3615
|
+
const testRel = framework === "fastify" ? `tests/modules/${kebab}.test.ts` : `tests/${kebab}.test.ts`;
|
|
3616
|
+
generated.push(`${dir}/${testRel}`);
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
async function resolvePrimaryBackend(cwd, hasFastapi, hasFastify, hasExpress, backendFlag) {
|
|
2592
3620
|
if (backendFlag) return backendFlag;
|
|
2593
|
-
|
|
2594
|
-
|
|
3621
|
+
const backends = [
|
|
3622
|
+
hasFastapi ? "fastapi" : void 0,
|
|
3623
|
+
hasFastify ? "fastify" : void 0,
|
|
3624
|
+
hasExpress ? "express" : void 0
|
|
3625
|
+
].filter((item) => item !== void 0);
|
|
3626
|
+
if (backends.length === 1) return backends[0];
|
|
2595
3627
|
const config = await readProjxConfig(cwd);
|
|
2596
|
-
if (config.primaryBackend === "fastapi" || config.primaryBackend === "fastify") {
|
|
3628
|
+
if (config.primaryBackend === "fastapi" || config.primaryBackend === "fastify" || config.primaryBackend === "express") {
|
|
2597
3629
|
return config.primaryBackend;
|
|
2598
3630
|
}
|
|
2599
|
-
if (!process.stdin.isTTY)
|
|
3631
|
+
if (!process.stdin.isTTY) {
|
|
3632
|
+
return hasFastify ? "fastify" : hasExpress ? "express" : "fastapi";
|
|
3633
|
+
}
|
|
2600
3634
|
const choice = await p9.select({
|
|
2601
|
-
message: "
|
|
3635
|
+
message: "Multiple backends detected. Which is your primary?",
|
|
2602
3636
|
options: [
|
|
2603
|
-
{ value: "fastify", label: "fastify (API backend)" },
|
|
2604
|
-
{ value: "
|
|
3637
|
+
...hasFastify ? [{ value: "fastify", label: "fastify (API backend)" }] : [],
|
|
3638
|
+
...hasExpress ? [{ value: "express", label: "express (API backend)" }] : [],
|
|
3639
|
+
...hasFastapi ? [{ value: "fastapi", label: "fastapi (AI/ML engine)" }] : []
|
|
2605
3640
|
],
|
|
2606
|
-
initialValue: "fastify"
|
|
3641
|
+
initialValue: hasFastify ? "fastify" : hasExpress ? "express" : "fastapi"
|
|
2607
3642
|
});
|
|
2608
3643
|
if (p9.isCancel(choice)) process.exit(0);
|
|
2609
3644
|
await writeProjxConfig(cwd, { ...config, primaryBackend: choice });
|
|
@@ -2612,30 +3647,39 @@ async function resolvePrimaryBackend(cwd, hasFastapi, hasFastify, backendFlag) {
|
|
|
2612
3647
|
}
|
|
2613
3648
|
async function gen(cwd, entityName, fieldsFlag, backendFlag) {
|
|
2614
3649
|
p9.intro(`projx gen entity ${entityName}`);
|
|
2615
|
-
if (!
|
|
3650
|
+
if (!existsSync10(join10(cwd, ".projx"))) {
|
|
2616
3651
|
p9.log.error("No .projx file found. Run 'npx create-projx init' first.");
|
|
2617
3652
|
process.exit(1);
|
|
2618
3653
|
}
|
|
2619
3654
|
const projxData = await readProjxConfig(cwd);
|
|
2620
3655
|
const pmName = projxData.packageManager ?? "npm";
|
|
2621
3656
|
const pm = pmCommands(pmName);
|
|
3657
|
+
const orm = projxData.orm ?? "prisma";
|
|
2622
3658
|
const { components: discovered, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
|
|
2623
3659
|
const hasFastapi = discovered.includes("fastapi");
|
|
2624
3660
|
const hasFastify = discovered.includes("fastify");
|
|
3661
|
+
const hasExpress = discovered.includes("express");
|
|
2625
3662
|
const hasFrontend = discovered.includes("frontend");
|
|
2626
3663
|
const hasMobile = discovered.includes("mobile");
|
|
2627
|
-
if (!hasFastapi && !hasFastify) {
|
|
2628
|
-
p9.log.error(
|
|
3664
|
+
if (!hasFastapi && !hasFastify && !hasExpress) {
|
|
3665
|
+
p9.log.error(
|
|
3666
|
+
"No backend component found. Need fastapi, fastify, or express."
|
|
3667
|
+
);
|
|
2629
3668
|
process.exit(1);
|
|
2630
3669
|
}
|
|
2631
3670
|
const targetBackend = await resolvePrimaryBackend(
|
|
2632
3671
|
cwd,
|
|
2633
3672
|
hasFastapi,
|
|
2634
3673
|
hasFastify,
|
|
3674
|
+
hasExpress,
|
|
2635
3675
|
backendFlag
|
|
2636
3676
|
);
|
|
2637
3677
|
const genFastapi = targetBackend === "fastapi" && hasFastapi;
|
|
2638
3678
|
const genFastify = targetBackend === "fastify" && hasFastify;
|
|
3679
|
+
const genExpress = targetBackend === "express" && hasExpress;
|
|
3680
|
+
const genDrizzle = orm === "drizzle" && (genFastify || genExpress);
|
|
3681
|
+
const genSequelize = orm === "sequelize" && (genFastify || genExpress);
|
|
3682
|
+
const genTypeorm = orm === "typeorm" && (genFastify || genExpress);
|
|
2639
3683
|
let config;
|
|
2640
3684
|
if (fieldsFlag) {
|
|
2641
3685
|
const fields = parseFieldsFlag(fieldsFlag);
|
|
@@ -2658,53 +3702,137 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
|
|
|
2658
3702
|
const generated = [];
|
|
2659
3703
|
if (genFastapi) {
|
|
2660
3704
|
const dir = componentPaths.fastapi;
|
|
2661
|
-
const entityDir =
|
|
2662
|
-
if (
|
|
3705
|
+
const entityDir = join10(cwd, dir, "src/entities", toSnake(config.name));
|
|
3706
|
+
if (existsSync10(entityDir)) {
|
|
2663
3707
|
p9.log.warn(
|
|
2664
3708
|
`${dir}/src/entities/${toSnake(config.name)}/ already exists. Skipping FastAPI.`
|
|
2665
3709
|
);
|
|
2666
3710
|
} else {
|
|
2667
3711
|
await mkdir3(entityDir, { recursive: true });
|
|
2668
|
-
await
|
|
2669
|
-
|
|
3712
|
+
await writeFile2(
|
|
3713
|
+
join10(entityDir, "_model.py"),
|
|
2670
3714
|
generateFastAPIModel(config)
|
|
2671
3715
|
);
|
|
2672
|
-
await
|
|
2673
|
-
|
|
3716
|
+
await writeFile2(
|
|
3717
|
+
join10(entityDir, "__init__.py"),
|
|
2674
3718
|
"from ._model import *\n"
|
|
2675
3719
|
);
|
|
2676
3720
|
generated.push(`${dir}/src/entities/${toSnake(config.name)}/_model.py`);
|
|
2677
3721
|
generated.push(`${dir}/src/entities/${toSnake(config.name)}/__init__.py`);
|
|
2678
|
-
const testsDir =
|
|
2679
|
-
const testFile =
|
|
2680
|
-
if (
|
|
2681
|
-
await
|
|
3722
|
+
const testsDir = join10(cwd, dir, "tests");
|
|
3723
|
+
const testFile = join10(testsDir, `test_${toSnake(config.name)}_entity.py`);
|
|
3724
|
+
if (existsSync10(testsDir) && !existsSync10(testFile)) {
|
|
3725
|
+
await writeFile2(testFile, generateFastapiTest(config));
|
|
2682
3726
|
generated.push(`${dir}/tests/test_${toSnake(config.name)}_entity.py`);
|
|
2683
3727
|
}
|
|
2684
3728
|
}
|
|
2685
3729
|
}
|
|
2686
|
-
if (genFastify) {
|
|
3730
|
+
if (genFastify && orm !== "drizzle" && orm !== "sequelize" && orm !== "typeorm") {
|
|
2687
3731
|
const dir = componentPaths.fastify;
|
|
2688
|
-
const moduleDir =
|
|
2689
|
-
if (
|
|
3732
|
+
const moduleDir = join10(cwd, dir, "src/modules", toKebab(config.name));
|
|
3733
|
+
if (existsSync10(moduleDir)) {
|
|
2690
3734
|
p9.log.warn(
|
|
2691
3735
|
`${dir}/src/modules/${toKebab(config.name)}/ already exists. Skipping Fastify.`
|
|
2692
3736
|
);
|
|
2693
3737
|
} else {
|
|
2694
3738
|
await mkdir3(moduleDir, { recursive: true });
|
|
2695
|
-
await
|
|
2696
|
-
|
|
3739
|
+
await writeFile2(
|
|
3740
|
+
join10(moduleDir, "schemas.ts"),
|
|
2697
3741
|
generateFastifySchemas(config)
|
|
2698
3742
|
);
|
|
2699
|
-
await
|
|
2700
|
-
|
|
3743
|
+
await writeFile2(
|
|
3744
|
+
join10(moduleDir, "index.ts"),
|
|
2701
3745
|
generateFastifyIndex(config)
|
|
2702
3746
|
);
|
|
2703
3747
|
generated.push(`${dir}/src/modules/${toKebab(config.name)}/schemas.ts`);
|
|
2704
3748
|
generated.push(`${dir}/src/modules/${toKebab(config.name)}/index.ts`);
|
|
2705
|
-
const appPath =
|
|
2706
|
-
if (
|
|
2707
|
-
const appContent = await
|
|
3749
|
+
const appPath = join10(cwd, dir, "src/app.ts");
|
|
3750
|
+
if (existsSync10(appPath)) {
|
|
3751
|
+
const appContent = await readFile7(appPath, "utf-8");
|
|
3752
|
+
const importLine = `import './modules/${toKebab(config.name)}/index.js';`;
|
|
3753
|
+
if (!appContent.includes(importLine)) {
|
|
3754
|
+
const updated = appContent.replace(
|
|
3755
|
+
/^(import\s+'\.\/modules\/.*?';?\s*\n)/m,
|
|
3756
|
+
`$1${importLine}
|
|
3757
|
+
`
|
|
3758
|
+
);
|
|
3759
|
+
if (updated !== appContent) {
|
|
3760
|
+
await writeFile2(appPath, updated);
|
|
3761
|
+
generated.push(`${dir}/src/app.ts (import added)`);
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
}
|
|
3765
|
+
const prismaPath = join10(cwd, dir, "prisma/schema.prisma");
|
|
3766
|
+
if (existsSync10(prismaPath)) {
|
|
3767
|
+
const prismaContent = await readFile7(prismaPath, "utf-8");
|
|
3768
|
+
const modelName = `model ${toPascal(config.name)}`;
|
|
3769
|
+
if (!prismaContent.includes(modelName)) {
|
|
3770
|
+
const prismaModel = generatePrismaModel(config);
|
|
3771
|
+
await writeFile2(
|
|
3772
|
+
prismaPath,
|
|
3773
|
+
prismaContent.trimEnd() + "\n\n" + prismaModel + "\n"
|
|
3774
|
+
);
|
|
3775
|
+
generated.push(`${dir}/prisma/schema.prisma (model added)`);
|
|
3776
|
+
}
|
|
3777
|
+
}
|
|
3778
|
+
const testsModulesDir = join10(cwd, dir, "tests/modules");
|
|
3779
|
+
const fastifyTestFile = join10(
|
|
3780
|
+
testsModulesDir,
|
|
3781
|
+
`${toKebab(config.name)}.test.ts`
|
|
3782
|
+
);
|
|
3783
|
+
if (existsSync10(testsModulesDir) && !existsSync10(fastifyTestFile)) {
|
|
3784
|
+
await writeFile2(fastifyTestFile, generateFastifyTest(config));
|
|
3785
|
+
generated.push(`${dir}/tests/modules/${toKebab(config.name)}.test.ts`);
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3789
|
+
if (genFastify && orm === "drizzle") {
|
|
3790
|
+
await appendDrizzleEntity(
|
|
3791
|
+
cwd,
|
|
3792
|
+
componentPaths.fastify,
|
|
3793
|
+
"fastify",
|
|
3794
|
+
config,
|
|
3795
|
+
generated
|
|
3796
|
+
);
|
|
3797
|
+
} else if (genFastify && orm === "sequelize") {
|
|
3798
|
+
await appendSequelizeEntity(
|
|
3799
|
+
cwd,
|
|
3800
|
+
componentPaths.fastify,
|
|
3801
|
+
"fastify",
|
|
3802
|
+
config,
|
|
3803
|
+
generated
|
|
3804
|
+
);
|
|
3805
|
+
} else if (genFastify && orm === "typeorm") {
|
|
3806
|
+
await appendTypeormEntity(
|
|
3807
|
+
cwd,
|
|
3808
|
+
componentPaths.fastify,
|
|
3809
|
+
"fastify",
|
|
3810
|
+
config,
|
|
3811
|
+
generated
|
|
3812
|
+
);
|
|
3813
|
+
}
|
|
3814
|
+
if (genExpress && orm !== "drizzle" && orm !== "sequelize" && orm !== "typeorm") {
|
|
3815
|
+
const dir = componentPaths.express;
|
|
3816
|
+
const moduleDir = join10(cwd, dir, "src/modules", toKebab(config.name));
|
|
3817
|
+
if (existsSync10(moduleDir)) {
|
|
3818
|
+
p9.log.warn(
|
|
3819
|
+
`${dir}/src/modules/${toKebab(config.name)}/ already exists. Skipping Express.`
|
|
3820
|
+
);
|
|
3821
|
+
} else {
|
|
3822
|
+
await mkdir3(moduleDir, { recursive: true });
|
|
3823
|
+
await writeFile2(
|
|
3824
|
+
join10(moduleDir, "schemas.ts"),
|
|
3825
|
+
generateExpressSchemas(config)
|
|
3826
|
+
);
|
|
3827
|
+
await writeFile2(
|
|
3828
|
+
join10(moduleDir, "index.ts"),
|
|
3829
|
+
generateExpressIndex(config)
|
|
3830
|
+
);
|
|
3831
|
+
generated.push(`${dir}/src/modules/${toKebab(config.name)}/schemas.ts`);
|
|
3832
|
+
generated.push(`${dir}/src/modules/${toKebab(config.name)}/index.ts`);
|
|
3833
|
+
const appPath = join10(cwd, dir, "src/app.ts");
|
|
3834
|
+
if (existsSync10(appPath)) {
|
|
3835
|
+
const appContent = await readFile7(appPath, "utf-8");
|
|
2708
3836
|
const importLine = `import './modules/${toKebab(config.name)}/index.js';`;
|
|
2709
3837
|
if (!appContent.includes(importLine)) {
|
|
2710
3838
|
const updated = appContent.replace(
|
|
@@ -2713,75 +3841,100 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
|
|
|
2713
3841
|
`
|
|
2714
3842
|
);
|
|
2715
3843
|
if (updated !== appContent) {
|
|
2716
|
-
await
|
|
3844
|
+
await writeFile2(appPath, updated);
|
|
2717
3845
|
generated.push(`${dir}/src/app.ts (import added)`);
|
|
2718
3846
|
}
|
|
2719
3847
|
}
|
|
2720
3848
|
}
|
|
2721
|
-
const prismaPath =
|
|
2722
|
-
if (
|
|
2723
|
-
const prismaContent = await
|
|
3849
|
+
const prismaPath = join10(cwd, dir, "prisma/schema.prisma");
|
|
3850
|
+
if (existsSync10(prismaPath)) {
|
|
3851
|
+
const prismaContent = await readFile7(prismaPath, "utf-8");
|
|
2724
3852
|
const modelName = `model ${toPascal(config.name)}`;
|
|
2725
3853
|
if (!prismaContent.includes(modelName)) {
|
|
2726
3854
|
const prismaModel = generatePrismaModel(config);
|
|
2727
|
-
await
|
|
3855
|
+
await writeFile2(
|
|
2728
3856
|
prismaPath,
|
|
2729
3857
|
prismaContent.trimEnd() + "\n\n" + prismaModel + "\n"
|
|
2730
3858
|
);
|
|
2731
3859
|
generated.push(`${dir}/prisma/schema.prisma (model added)`);
|
|
2732
3860
|
}
|
|
2733
3861
|
}
|
|
2734
|
-
const testsModulesDir =
|
|
2735
|
-
const
|
|
3862
|
+
const testsModulesDir = join10(cwd, dir, "tests/modules");
|
|
3863
|
+
const expressTestFile = join10(
|
|
2736
3864
|
testsModulesDir,
|
|
2737
3865
|
`${toKebab(config.name)}.test.ts`
|
|
2738
3866
|
);
|
|
2739
|
-
if (
|
|
2740
|
-
await
|
|
3867
|
+
if (existsSync10(testsModulesDir) && !existsSync10(expressTestFile)) {
|
|
3868
|
+
await writeFile2(expressTestFile, generateExpressTest(config));
|
|
2741
3869
|
generated.push(`${dir}/tests/modules/${toKebab(config.name)}.test.ts`);
|
|
2742
3870
|
}
|
|
2743
3871
|
}
|
|
2744
3872
|
}
|
|
3873
|
+
if (genExpress && orm === "drizzle") {
|
|
3874
|
+
await appendDrizzleEntity(
|
|
3875
|
+
cwd,
|
|
3876
|
+
componentPaths.express,
|
|
3877
|
+
"express",
|
|
3878
|
+
config,
|
|
3879
|
+
generated
|
|
3880
|
+
);
|
|
3881
|
+
} else if (genExpress && orm === "sequelize") {
|
|
3882
|
+
await appendSequelizeEntity(
|
|
3883
|
+
cwd,
|
|
3884
|
+
componentPaths.express,
|
|
3885
|
+
"express",
|
|
3886
|
+
config,
|
|
3887
|
+
generated
|
|
3888
|
+
);
|
|
3889
|
+
} else if (genExpress && orm === "typeorm") {
|
|
3890
|
+
await appendTypeormEntity(
|
|
3891
|
+
cwd,
|
|
3892
|
+
componentPaths.express,
|
|
3893
|
+
"express",
|
|
3894
|
+
config,
|
|
3895
|
+
generated
|
|
3896
|
+
);
|
|
3897
|
+
}
|
|
2745
3898
|
if (hasFrontend) {
|
|
2746
3899
|
const dir = componentPaths.frontend;
|
|
2747
|
-
const typesDir =
|
|
3900
|
+
const typesDir = join10(cwd, dir, "src/types");
|
|
2748
3901
|
const fileName = toKebab(config.name) + ".ts";
|
|
2749
|
-
const filePath =
|
|
2750
|
-
if (
|
|
3902
|
+
const filePath = join10(typesDir, fileName);
|
|
3903
|
+
if (existsSync10(filePath)) {
|
|
2751
3904
|
p9.log.warn(
|
|
2752
3905
|
`${dir}/src/types/${fileName} already exists. Skipping frontend types.`
|
|
2753
3906
|
);
|
|
2754
3907
|
} else {
|
|
2755
3908
|
await mkdir3(typesDir, { recursive: true });
|
|
2756
|
-
await
|
|
3909
|
+
await writeFile2(filePath, generateFrontendInterface(config));
|
|
2757
3910
|
generated.push(`${dir}/src/types/${fileName}`);
|
|
2758
|
-
const barrelPath =
|
|
3911
|
+
const barrelPath = join10(typesDir, "index.ts");
|
|
2759
3912
|
const exportLine = `export * from './${toKebab(config.name)}';`;
|
|
2760
|
-
if (
|
|
2761
|
-
const content = await
|
|
3913
|
+
if (existsSync10(barrelPath)) {
|
|
3914
|
+
const content = await readFile7(barrelPath, "utf-8");
|
|
2762
3915
|
if (!content.includes(exportLine)) {
|
|
2763
|
-
await
|
|
3916
|
+
await writeFile2(
|
|
2764
3917
|
barrelPath,
|
|
2765
3918
|
content.trimEnd() + "\n" + exportLine + "\n"
|
|
2766
3919
|
);
|
|
2767
3920
|
}
|
|
2768
3921
|
} else {
|
|
2769
|
-
await
|
|
3922
|
+
await writeFile2(barrelPath, exportLine + "\n");
|
|
2770
3923
|
}
|
|
2771
3924
|
generated.push(`${dir}/src/types/index.ts`);
|
|
2772
3925
|
}
|
|
2773
3926
|
}
|
|
2774
3927
|
if (hasMobile) {
|
|
2775
3928
|
const dir = componentPaths.mobile;
|
|
2776
|
-
const entityDir =
|
|
2777
|
-
const modelPath =
|
|
2778
|
-
if (
|
|
3929
|
+
const entityDir = join10(cwd, dir, "lib/entities", toSnake(config.name));
|
|
3930
|
+
const modelPath = join10(entityDir, "model.dart");
|
|
3931
|
+
if (existsSync10(modelPath)) {
|
|
2779
3932
|
p9.log.warn(
|
|
2780
3933
|
`${dir}/lib/entities/${toSnake(config.name)}/model.dart already exists. Skipping mobile model.`
|
|
2781
3934
|
);
|
|
2782
3935
|
} else {
|
|
2783
3936
|
await mkdir3(entityDir, { recursive: true });
|
|
2784
|
-
await
|
|
3937
|
+
await writeFile2(modelPath, generateDartModel(config));
|
|
2785
3938
|
generated.push(`${dir}/lib/entities/${toSnake(config.name)}/model.dart`);
|
|
2786
3939
|
}
|
|
2787
3940
|
}
|
|
@@ -2803,13 +3956,33 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
|
|
|
2803
3956
|
);
|
|
2804
3957
|
p9.log.info(" alembic upgrade head");
|
|
2805
3958
|
}
|
|
2806
|
-
if (genFastify) {
|
|
3959
|
+
if (genFastify && orm === "prisma") {
|
|
2807
3960
|
p9.log.info("");
|
|
2808
3961
|
p9.log.info("Fastify next steps:");
|
|
2809
3962
|
p9.log.info(
|
|
2810
3963
|
` ${pm.prismaExec} migrate dev --name add_${toSnake(config.name)}`
|
|
2811
3964
|
);
|
|
2812
3965
|
}
|
|
3966
|
+
if (genDrizzle) {
|
|
3967
|
+
p9.log.info("");
|
|
3968
|
+
p9.log.info("Drizzle next steps:");
|
|
3969
|
+
p9.log.info(` ${pm.exec} drizzle-kit generate`);
|
|
3970
|
+
p9.log.info(` ${pm.exec} drizzle-kit migrate`);
|
|
3971
|
+
}
|
|
3972
|
+
if (genSequelize) {
|
|
3973
|
+
p9.log.info("");
|
|
3974
|
+
p9.log.info("Sequelize next steps:");
|
|
3975
|
+
p9.log.info(
|
|
3976
|
+
` ${pm.run} db:sync # syncs the schema against $DATABASE_URL`
|
|
3977
|
+
);
|
|
3978
|
+
}
|
|
3979
|
+
if (genTypeorm) {
|
|
3980
|
+
p9.log.info("");
|
|
3981
|
+
p9.log.info("TypeORM next steps:");
|
|
3982
|
+
p9.log.info(
|
|
3983
|
+
` ${pm.run} db:sync # syncs the schema against $DATABASE_URL`
|
|
3984
|
+
);
|
|
3985
|
+
}
|
|
2813
3986
|
if (hasFrontend) {
|
|
2814
3987
|
p9.log.info("");
|
|
2815
3988
|
p9.log.info("Frontend usage:");
|
|
@@ -2829,9 +4002,9 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
|
|
|
2829
4002
|
}
|
|
2830
4003
|
|
|
2831
4004
|
// src/sync.ts
|
|
2832
|
-
import { existsSync as
|
|
2833
|
-
import { writeFile as
|
|
2834
|
-
import { join as
|
|
4005
|
+
import { existsSync as existsSync11, readFileSync } from "fs";
|
|
4006
|
+
import { writeFile as writeFile3, mkdir as mkdir4 } from "fs/promises";
|
|
4007
|
+
import { join as join11 } from "path";
|
|
2835
4008
|
import * as p10 from "@clack/prompts";
|
|
2836
4009
|
function toPascal2(s) {
|
|
2837
4010
|
return s.replace(/(?:^|[_\-\s])([a-zA-Z])/g, (_, c) => c.toUpperCase());
|
|
@@ -3002,8 +4175,8 @@ function generateDartModel2(entity) {
|
|
|
3002
4175
|
}
|
|
3003
4176
|
async function sync(cwd, url) {
|
|
3004
4177
|
p10.intro("projx sync");
|
|
3005
|
-
const configPath =
|
|
3006
|
-
if (!
|
|
4178
|
+
const configPath = join11(cwd, ".projx");
|
|
4179
|
+
if (!existsSync11(configPath)) {
|
|
3007
4180
|
p10.log.error("No .projx file found. Run 'npx create-projx init' first.");
|
|
3008
4181
|
process.exit(1);
|
|
3009
4182
|
}
|
|
@@ -3035,18 +4208,18 @@ async function sync(cwd, url) {
|
|
|
3035
4208
|
const generated = [];
|
|
3036
4209
|
if (hasFrontend) {
|
|
3037
4210
|
const dir = componentPaths.frontend;
|
|
3038
|
-
const typesDir =
|
|
4211
|
+
const typesDir = join11(cwd, dir, "src/types");
|
|
3039
4212
|
await mkdir4(typesDir, { recursive: true });
|
|
3040
4213
|
const barrelExports = [];
|
|
3041
4214
|
for (const entity of meta.entities) {
|
|
3042
4215
|
const fileName = toKebab(toSnake(entity.name)) + ".ts";
|
|
3043
|
-
const filePath =
|
|
3044
|
-
await
|
|
4216
|
+
const filePath = join11(typesDir, fileName);
|
|
4217
|
+
await writeFile3(filePath, generateTsInterface(entity));
|
|
3045
4218
|
generated.push(`${dir}/src/types/${fileName}`);
|
|
3046
4219
|
barrelExports.push(`export * from './${toKebab(toSnake(entity.name))}';`);
|
|
3047
4220
|
}
|
|
3048
|
-
await
|
|
3049
|
-
|
|
4221
|
+
await writeFile3(
|
|
4222
|
+
join11(typesDir, "index.ts"),
|
|
3050
4223
|
barrelExports.join("\n") + "\n"
|
|
3051
4224
|
);
|
|
3052
4225
|
generated.push(`${dir}/src/types/index.ts`);
|
|
@@ -3054,10 +4227,10 @@ async function sync(cwd, url) {
|
|
|
3054
4227
|
if (hasMobile) {
|
|
3055
4228
|
const dir = componentPaths.mobile;
|
|
3056
4229
|
for (const entity of meta.entities) {
|
|
3057
|
-
const entityDir =
|
|
4230
|
+
const entityDir = join11(cwd, dir, "lib/entities", toSnake(entity.name));
|
|
3058
4231
|
await mkdir4(entityDir, { recursive: true });
|
|
3059
|
-
const modelPath =
|
|
3060
|
-
await
|
|
4232
|
+
const modelPath = join11(entityDir, "model.dart");
|
|
4233
|
+
await writeFile3(modelPath, generateDartModel2(entity));
|
|
3061
4234
|
generated.push(`${dir}/lib/entities/${toSnake(entity.name)}/model.dart`);
|
|
3062
4235
|
}
|
|
3063
4236
|
}
|
|
@@ -3080,8 +4253,8 @@ async function sync(cwd, url) {
|
|
|
3080
4253
|
function detectMetaUrl(cwd) {
|
|
3081
4254
|
const envFiles = [".env", ".env.dev", ".env.local"];
|
|
3082
4255
|
for (const envFile of envFiles) {
|
|
3083
|
-
const envPath =
|
|
3084
|
-
if (
|
|
4256
|
+
const envPath = join11(cwd, envFile);
|
|
4257
|
+
if (existsSync11(envPath)) {
|
|
3085
4258
|
try {
|
|
3086
4259
|
const content = readFileSync(envPath, "utf-8");
|
|
3087
4260
|
const match = content.match(/VITE_API_URL\s*=\s*(.+)/);
|
|
@@ -3099,8 +4272,8 @@ function detectMetaUrl(cwd) {
|
|
|
3099
4272
|
"frontend/.env.dev"
|
|
3100
4273
|
];
|
|
3101
4274
|
for (const envFile of frontendEnvFiles) {
|
|
3102
|
-
const envPath =
|
|
3103
|
-
if (
|
|
4275
|
+
const envPath = join11(cwd, envFile);
|
|
4276
|
+
if (existsSync11(envPath)) {
|
|
3104
4277
|
try {
|
|
3105
4278
|
const content = readFileSync(envPath, "utf-8");
|
|
3106
4279
|
const match = content.match(/VITE_API_URL\s*=\s*(.+)/);
|
|
@@ -3117,6 +4290,28 @@ function detectMetaUrl(cwd) {
|
|
|
3117
4290
|
|
|
3118
4291
|
// src/index.ts
|
|
3119
4292
|
var args = process.argv.slice(2);
|
|
4293
|
+
function matchFeatureFlag(arg, argv, i) {
|
|
4294
|
+
for (const feat of KNOWN_FEATURES) {
|
|
4295
|
+
const eq = `--${feat}=`;
|
|
4296
|
+
if (arg.startsWith(eq)) {
|
|
4297
|
+
return {
|
|
4298
|
+
feature: feat,
|
|
4299
|
+
value: arg.slice(eq.length),
|
|
4300
|
+
consumedNext: false
|
|
4301
|
+
};
|
|
4302
|
+
}
|
|
4303
|
+
if (arg === `--${feat}`) {
|
|
4304
|
+
const next = argv[i + 1];
|
|
4305
|
+
if (!next || next.startsWith("-")) {
|
|
4306
|
+
throw new Error(
|
|
4307
|
+
`Flag --${feat} requires a value. Use --${feat}=<targets> or --${feat} <targets>.`
|
|
4308
|
+
);
|
|
4309
|
+
}
|
|
4310
|
+
return { feature: feat, value: next, consumedNext: true };
|
|
4311
|
+
}
|
|
4312
|
+
}
|
|
4313
|
+
return null;
|
|
4314
|
+
}
|
|
3120
4315
|
function parseArgs() {
|
|
3121
4316
|
let command = "create";
|
|
3122
4317
|
let name;
|
|
@@ -3169,6 +4364,16 @@ function parseArgs() {
|
|
|
3169
4364
|
}
|
|
3170
4365
|
continue;
|
|
3171
4366
|
}
|
|
4367
|
+
if (arg === "--orm") {
|
|
4368
|
+
const val = args[++i];
|
|
4369
|
+
if (!val || !ORM_PROVIDERS.includes(val)) {
|
|
4370
|
+
throw new Error(
|
|
4371
|
+
`Invalid --orm. Use one of: ${ORM_PROVIDERS.join(", ")}`
|
|
4372
|
+
);
|
|
4373
|
+
}
|
|
4374
|
+
options.orm = val;
|
|
4375
|
+
continue;
|
|
4376
|
+
}
|
|
3172
4377
|
if (arg === "--local") {
|
|
3173
4378
|
localRepo = resolve(args[++i] || ".");
|
|
3174
4379
|
continue;
|
|
@@ -3220,6 +4425,16 @@ function parseArgs() {
|
|
|
3220
4425
|
if (val) extraArgs.push(`--name=${val}`);
|
|
3221
4426
|
continue;
|
|
3222
4427
|
}
|
|
4428
|
+
{
|
|
4429
|
+
const featureMatch = matchFeatureFlag(arg, args, i);
|
|
4430
|
+
if (featureMatch) {
|
|
4431
|
+
const { feature, value, consumedNext } = featureMatch;
|
|
4432
|
+
parseFeatureFlag(value);
|
|
4433
|
+
options.features = { ...options.features ?? {}, [feature]: value };
|
|
4434
|
+
if (consumedNext) i++;
|
|
4435
|
+
continue;
|
|
4436
|
+
}
|
|
4437
|
+
}
|
|
3223
4438
|
if (!arg.startsWith("-")) {
|
|
3224
4439
|
if (command === "add" || command === "pin" || command === "unpin" || command === "gen") {
|
|
3225
4440
|
extraArgs.push(arg);
|
|
@@ -3247,7 +4462,9 @@ function printHelp() {
|
|
|
3247
4462
|
projx sync [--url <url>] Sync types from running backend
|
|
3248
4463
|
|
|
3249
4464
|
Options:
|
|
3250
|
-
--components <list> Comma-separated: fastapi,fastify,frontend,mobile,e2e,infra
|
|
4465
|
+
--components <list> Comma-separated: fastapi,fastify,express,frontend,mobile,e2e,infra
|
|
4466
|
+
--orm <provider> Node backend ORM: prisma (default), drizzle, sequelize, typeorm
|
|
4467
|
+
--auth <targets> Add auth feature. Targets: <component>[:<instance>] (comma-separated)
|
|
3251
4468
|
--no-git Skip git init
|
|
3252
4469
|
--no-install Skip dependency installation
|
|
3253
4470
|
-y, --yes Accept defaults (fastify + frontend + e2e)
|
|
@@ -3257,6 +4474,8 @@ function printHelp() {
|
|
|
3257
4474
|
Examples:
|
|
3258
4475
|
npx create-projx my-app
|
|
3259
4476
|
npx create-projx my-app --components fastapi,frontend,e2e
|
|
4477
|
+
npx create-projx my-app --components express,frontend,e2e --orm drizzle
|
|
4478
|
+
npx create-projx my-app --components fastify,frontend,mobile --auth fastify,frontend,mobile
|
|
3260
4479
|
npx create-projx my-app -y
|
|
3261
4480
|
npx create-projx add frontend mobile
|
|
3262
4481
|
npx create-projx add fastify --name email-ingestor
|
|
@@ -3361,15 +4580,19 @@ async function main() {
|
|
|
3361
4580
|
name,
|
|
3362
4581
|
components: options.components,
|
|
3363
4582
|
git: options.git ?? true,
|
|
3364
|
-
install: options.install ?? true
|
|
4583
|
+
install: options.install ?? true,
|
|
4584
|
+
orm: options.orm ?? "prisma",
|
|
4585
|
+
features: options.features
|
|
3365
4586
|
};
|
|
3366
4587
|
} else {
|
|
3367
4588
|
opts = await runPrompts(name);
|
|
3368
4589
|
opts.git = options.git ?? opts.git;
|
|
3369
4590
|
opts.install = options.install ?? opts.install;
|
|
4591
|
+
opts.orm = options.orm ?? opts.orm ?? "prisma";
|
|
4592
|
+
opts.features = options.features ?? opts.features;
|
|
3370
4593
|
}
|
|
3371
4594
|
const dest = resolve(process.cwd(), opts.name);
|
|
3372
|
-
if (
|
|
4595
|
+
if (existsSync12(dest)) {
|
|
3373
4596
|
console.error(`Error: ${dest} already exists.`);
|
|
3374
4597
|
process.exit(1);
|
|
3375
4598
|
}
|