create-projx 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1064 -0
- package/package.json +44 -0
- package/src/templates/Makefile.ejs +286 -0
- package/src/templates/README.md.ejs +87 -0
- package/src/templates/ci.yml.ejs +117 -0
- package/src/templates/docker-compose.dev.yml.ejs +168 -0
- package/src/templates/docker-compose.yml.ejs +146 -0
- package/src/templates/pre-commit.ejs +122 -0
- package/src/templates/setup.sh.ejs +37 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { existsSync as existsSync5 } from "fs";
|
|
5
|
+
import { resolve as resolve2 } from "path";
|
|
6
|
+
|
|
7
|
+
// src/utils.ts
|
|
8
|
+
import { execSync } from "child_process";
|
|
9
|
+
import { existsSync } from "fs";
|
|
10
|
+
import { cp, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
|
|
11
|
+
import { join, resolve } from "path";
|
|
12
|
+
import { tmpdir } from "os";
|
|
13
|
+
import { fileURLToPath } from "url";
|
|
14
|
+
var REPO = "ukanhaupa/projx";
|
|
15
|
+
var REPO_URL = `https://github.com/${REPO}`;
|
|
16
|
+
var COMPONENTS = [
|
|
17
|
+
"fastapi",
|
|
18
|
+
"fastify",
|
|
19
|
+
"frontend",
|
|
20
|
+
"mobile",
|
|
21
|
+
"e2e",
|
|
22
|
+
"infra"
|
|
23
|
+
];
|
|
24
|
+
function toKebab(s) {
|
|
25
|
+
return s.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
function toSnake(s) {
|
|
28
|
+
return toKebab(s).replace(/-/g, "_");
|
|
29
|
+
}
|
|
30
|
+
function hasCommand(cmd) {
|
|
31
|
+
try {
|
|
32
|
+
execSync(`command -v ${cmd}`, { stdio: "ignore" });
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function exec(cmd, cwd) {
|
|
39
|
+
execSync(cmd, { cwd, stdio: "pipe" });
|
|
40
|
+
}
|
|
41
|
+
function sharedTemplateDir() {
|
|
42
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
43
|
+
return join(thisFile, "../../src/templates");
|
|
44
|
+
}
|
|
45
|
+
async function downloadRepo(localPath) {
|
|
46
|
+
if (localPath) {
|
|
47
|
+
return localPath;
|
|
48
|
+
}
|
|
49
|
+
const dest = join(tmpdir(), `projx-${Date.now()}`);
|
|
50
|
+
await mkdir(dest, { recursive: true });
|
|
51
|
+
if (hasCommand("git")) {
|
|
52
|
+
execSync(
|
|
53
|
+
`git clone --depth 1 ${REPO_URL}.git "${dest}/repo"`,
|
|
54
|
+
{ stdio: "pipe" }
|
|
55
|
+
);
|
|
56
|
+
return join(dest, "repo");
|
|
57
|
+
}
|
|
58
|
+
const tarUrl = `${REPO_URL}/archive/refs/heads/main.tar.gz`;
|
|
59
|
+
execSync(
|
|
60
|
+
`curl -sL "${tarUrl}" | tar xz -C "${dest}"`,
|
|
61
|
+
{ stdio: "pipe" }
|
|
62
|
+
);
|
|
63
|
+
const entries = await readdir(dest);
|
|
64
|
+
const extracted = entries.find((e) => e.startsWith("projx-"));
|
|
65
|
+
if (!extracted) throw new Error("Failed to extract repo archive.");
|
|
66
|
+
return join(dest, extracted);
|
|
67
|
+
}
|
|
68
|
+
async function cleanupRepo(repoDir, isLocal) {
|
|
69
|
+
if (isLocal) return;
|
|
70
|
+
const parent = resolve(repoDir, "..");
|
|
71
|
+
if (parent.startsWith(tmpdir())) {
|
|
72
|
+
await rm(parent, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
var EXCLUDE = /* @__PURE__ */ new Set([
|
|
76
|
+
"node_modules",
|
|
77
|
+
"dist",
|
|
78
|
+
"build",
|
|
79
|
+
"coverage",
|
|
80
|
+
"__pycache__",
|
|
81
|
+
".dart_tool",
|
|
82
|
+
".flutter-plugins",
|
|
83
|
+
".flutter-plugins-dependencies",
|
|
84
|
+
".venv",
|
|
85
|
+
".pytest_cache",
|
|
86
|
+
".ruff_cache",
|
|
87
|
+
".mypy_cache",
|
|
88
|
+
"playwright-report",
|
|
89
|
+
"test-results",
|
|
90
|
+
".terraform",
|
|
91
|
+
"cli"
|
|
92
|
+
]);
|
|
93
|
+
var EXCLUDE_FILES = /* @__PURE__ */ new Set([
|
|
94
|
+
"uv.lock",
|
|
95
|
+
"pnpm-lock.yaml",
|
|
96
|
+
"package-lock.json",
|
|
97
|
+
"pubspec.lock",
|
|
98
|
+
".env",
|
|
99
|
+
".env.dev",
|
|
100
|
+
".env.staging",
|
|
101
|
+
".env.prod",
|
|
102
|
+
"dev.tfplan",
|
|
103
|
+
".coverage"
|
|
104
|
+
]);
|
|
105
|
+
async function copyComponent(repoDir, component, dest) {
|
|
106
|
+
const src = join(repoDir, component);
|
|
107
|
+
const out = join(dest, component);
|
|
108
|
+
const files = [];
|
|
109
|
+
await cp(src, out, {
|
|
110
|
+
recursive: true,
|
|
111
|
+
filter: (source) => {
|
|
112
|
+
const base = source.split("/").pop();
|
|
113
|
+
if (EXCLUDE.has(base)) return false;
|
|
114
|
+
if (EXCLUDE_FILES.has(base)) return false;
|
|
115
|
+
if (base.endsWith(".pyc")) return false;
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
await collectFiles(out, out, files);
|
|
120
|
+
return files;
|
|
121
|
+
}
|
|
122
|
+
async function copyStaticFiles(repoDir, dest) {
|
|
123
|
+
const manifest = [];
|
|
124
|
+
const tpl = repoDir;
|
|
125
|
+
const statics = [".editorconfig", "LICENSE"];
|
|
126
|
+
for (const file of statics) {
|
|
127
|
+
const src = join(tpl, file);
|
|
128
|
+
if (existsSync(src)) {
|
|
129
|
+
await cp(src, join(dest, file));
|
|
130
|
+
manifest.push(file);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const gitignore = join(tpl, ".gitignore");
|
|
134
|
+
if (existsSync(gitignore)) {
|
|
135
|
+
await cp(gitignore, join(dest, ".gitignore"));
|
|
136
|
+
manifest.push(".gitignore");
|
|
137
|
+
}
|
|
138
|
+
const vscode = join(tpl, ".vscode");
|
|
139
|
+
if (existsSync(vscode)) {
|
|
140
|
+
await cp(vscode, join(dest, ".vscode"), { recursive: true });
|
|
141
|
+
manifest.push(".vscode/settings.json", ".vscode/extensions.json");
|
|
142
|
+
}
|
|
143
|
+
const scripts = join(tpl, "scripts");
|
|
144
|
+
if (existsSync(scripts)) {
|
|
145
|
+
await cp(scripts, join(dest, "scripts"), { recursive: true });
|
|
146
|
+
manifest.push("scripts/setup-ssl.sh");
|
|
147
|
+
}
|
|
148
|
+
return manifest;
|
|
149
|
+
}
|
|
150
|
+
async function collectFiles(dir, root, files) {
|
|
151
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
const full = join(dir, entry.name);
|
|
154
|
+
if (entry.isDirectory()) {
|
|
155
|
+
await collectFiles(full, root, files);
|
|
156
|
+
} else {
|
|
157
|
+
files.push(full.slice(root.length + 1));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async function replaceInFile(filePath, find, replace) {
|
|
162
|
+
if (!existsSync(filePath)) return;
|
|
163
|
+
const content = await readFile(filePath, "utf-8");
|
|
164
|
+
if (!content.includes(find)) return;
|
|
165
|
+
await writeFile(filePath, content.replaceAll(find, replace));
|
|
166
|
+
}
|
|
167
|
+
async function replaceInDir(dir, find, replace, ext) {
|
|
168
|
+
if (!existsSync(dir)) return;
|
|
169
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
170
|
+
for (const entry of entries) {
|
|
171
|
+
const full = join(dir, entry.name);
|
|
172
|
+
if (entry.isDirectory()) {
|
|
173
|
+
await replaceInDir(full, find, replace, ext);
|
|
174
|
+
} else if (entry.name.endsWith(ext)) {
|
|
175
|
+
await replaceInFile(full, find, replace);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function render(template, vars) {
|
|
180
|
+
const components = vars.components;
|
|
181
|
+
const projectName = vars.projectName;
|
|
182
|
+
const lines = template.split("\n");
|
|
183
|
+
const output = [];
|
|
184
|
+
const stack = [];
|
|
185
|
+
for (const line of lines) {
|
|
186
|
+
const ifMatch = line.match(/^<%\s*if\s*\((.+?)\)\s*\{?\s*%>$/);
|
|
187
|
+
if (ifMatch) {
|
|
188
|
+
const fn = new Function("components", "projectName", `return ${ifMatch[1]}`);
|
|
189
|
+
stack.push(fn(components, projectName));
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (/^<%\s*\}\s*else\s*\{?\s*%>$/.test(line)) {
|
|
193
|
+
if (stack.length > 0) {
|
|
194
|
+
stack[stack.length - 1] = !stack[stack.length - 1];
|
|
195
|
+
}
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (/^<%\s*\}?\s*%>$/.test(line)) {
|
|
199
|
+
stack.pop();
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (stack.length > 0 && stack.some((v) => !v)) continue;
|
|
203
|
+
const replaced = line.replace(
|
|
204
|
+
/<%=\s*(\w+)\s*%>/g,
|
|
205
|
+
(_, key) => String(vars[key] ?? "")
|
|
206
|
+
);
|
|
207
|
+
output.push(replaced);
|
|
208
|
+
}
|
|
209
|
+
return output.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/prompts.ts
|
|
213
|
+
import * as p from "@clack/prompts";
|
|
214
|
+
var LABELS = {
|
|
215
|
+
fastapi: { label: "FastAPI", hint: "Python \u2014 SQLAlchemy, Alembic, uvicorn" },
|
|
216
|
+
fastify: { label: "Fastify", hint: "Node.js \u2014 Prisma, TypeBox, TypeScript" },
|
|
217
|
+
frontend: { label: "Frontend", hint: "React 19 + Vite + React Router" },
|
|
218
|
+
mobile: { label: "Mobile", hint: "Flutter + Riverpod + GoRouter" },
|
|
219
|
+
e2e: { label: "E2E Tests", hint: "Playwright" },
|
|
220
|
+
infra: { label: "Infrastructure", hint: "Terraform + AWS" }
|
|
221
|
+
};
|
|
222
|
+
var DEFAULTS = ["fastify", "frontend", "e2e"];
|
|
223
|
+
async function runPrompts(nameArg) {
|
|
224
|
+
p.intro("projx");
|
|
225
|
+
const name = nameArg ?? await p.text({
|
|
226
|
+
message: "Project name",
|
|
227
|
+
placeholder: "my-app",
|
|
228
|
+
validate: (v) => {
|
|
229
|
+
if (!v) return "Required";
|
|
230
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(v))
|
|
231
|
+
return "Lowercase, hyphens, no spaces";
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
if (p.isCancel(name)) process.exit(0);
|
|
235
|
+
const components = await p.multiselect({
|
|
236
|
+
message: "Which components?",
|
|
237
|
+
options: COMPONENTS.map((c) => ({
|
|
238
|
+
value: c,
|
|
239
|
+
label: LABELS[c].label,
|
|
240
|
+
hint: LABELS[c].hint
|
|
241
|
+
})),
|
|
242
|
+
initialValues: DEFAULTS,
|
|
243
|
+
required: false
|
|
244
|
+
});
|
|
245
|
+
if (p.isCancel(components)) process.exit(0);
|
|
246
|
+
if (components.length === 0) {
|
|
247
|
+
p.log.warn("No components selected. Creating an empty project.");
|
|
248
|
+
}
|
|
249
|
+
return { name, components, git: true, install: true };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/scaffold.ts
|
|
253
|
+
import { copyFileSync, existsSync as existsSync2 } from "fs";
|
|
254
|
+
import { chmod, mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
|
|
255
|
+
import { join as join3 } from "path";
|
|
256
|
+
import * as p2 from "@clack/prompts";
|
|
257
|
+
|
|
258
|
+
// src/generators/index.ts
|
|
259
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
260
|
+
import { join as join2 } from "path";
|
|
261
|
+
async function renderShared(filename, vars) {
|
|
262
|
+
const tpl = await readFile2(
|
|
263
|
+
join2(sharedTemplateDir(), filename),
|
|
264
|
+
"utf-8"
|
|
265
|
+
);
|
|
266
|
+
return render(tpl, vars);
|
|
267
|
+
}
|
|
268
|
+
async function generateDockerCompose(vars) {
|
|
269
|
+
return renderShared("docker-compose.yml.ejs", vars);
|
|
270
|
+
}
|
|
271
|
+
async function generateDockerComposeDev(vars) {
|
|
272
|
+
return renderShared("docker-compose.dev.yml.ejs", vars);
|
|
273
|
+
}
|
|
274
|
+
async function generateMakefile(vars) {
|
|
275
|
+
return renderShared("Makefile.ejs", vars);
|
|
276
|
+
}
|
|
277
|
+
async function generatePreCommit(vars) {
|
|
278
|
+
return renderShared("pre-commit.ejs", vars);
|
|
279
|
+
}
|
|
280
|
+
async function generateSetupSh(vars) {
|
|
281
|
+
return renderShared("setup.sh.ejs", vars);
|
|
282
|
+
}
|
|
283
|
+
async function generateCiYml(vars) {
|
|
284
|
+
return renderShared("ci.yml.ejs", vars);
|
|
285
|
+
}
|
|
286
|
+
async function generateReadme(vars) {
|
|
287
|
+
return renderShared("README.md.ejs", vars);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// src/scaffold.ts
|
|
291
|
+
async function scaffold(opts, dest, localRepo) {
|
|
292
|
+
const name = toKebab(opts.name);
|
|
293
|
+
const nameSnake = toSnake(opts.name);
|
|
294
|
+
const vars = { projectName: name, components: opts.components };
|
|
295
|
+
const isLocal = !!localRepo;
|
|
296
|
+
await mkdir2(dest, { recursive: true });
|
|
297
|
+
const dlSpinner = p2.spinner();
|
|
298
|
+
dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
|
|
299
|
+
const repoDir = await downloadRepo(localRepo).catch((err) => {
|
|
300
|
+
dlSpinner.stop("Failed.");
|
|
301
|
+
p2.log.error(String(err));
|
|
302
|
+
process.exit(1);
|
|
303
|
+
});
|
|
304
|
+
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
305
|
+
try {
|
|
306
|
+
await doScaffold(opts, dest, repoDir, name, nameSnake, vars);
|
|
307
|
+
} finally {
|
|
308
|
+
await cleanupRepo(repoDir, isLocal);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async function doScaffold(opts, dest, repoDir, name, nameSnake, vars) {
|
|
312
|
+
p2.log.info(`Scaffolding project in ${dest}`);
|
|
313
|
+
const manifest = [];
|
|
314
|
+
for (const component of opts.components) {
|
|
315
|
+
const spinner4 = p2.spinner();
|
|
316
|
+
spinner4.start(`Copying ${component}/`);
|
|
317
|
+
const files = await copyComponent(repoDir, component, dest);
|
|
318
|
+
manifest.push(...files.map((f) => `${component}/${f}`));
|
|
319
|
+
spinner4.stop(`${component}/`);
|
|
320
|
+
}
|
|
321
|
+
await substituteNames(dest, opts.components, name, nameSnake);
|
|
322
|
+
const hasBackend = opts.components.includes("fastapi") || opts.components.includes("fastify");
|
|
323
|
+
if (hasBackend || opts.components.includes("frontend")) {
|
|
324
|
+
const dc = await generateDockerCompose(vars);
|
|
325
|
+
await writeFile2(join3(dest, "docker-compose.yml"), dc);
|
|
326
|
+
manifest.push("docker-compose.yml");
|
|
327
|
+
const dcDev = await generateDockerComposeDev(vars);
|
|
328
|
+
await writeFile2(join3(dest, "docker-compose.dev.yml"), dcDev);
|
|
329
|
+
manifest.push("docker-compose.dev.yml");
|
|
330
|
+
}
|
|
331
|
+
const makefile = await generateMakefile(vars);
|
|
332
|
+
await writeFile2(join3(dest, "Makefile"), makefile);
|
|
333
|
+
manifest.push("Makefile");
|
|
334
|
+
const readme = await generateReadme(vars);
|
|
335
|
+
await writeFile2(join3(dest, "README.md"), readme);
|
|
336
|
+
manifest.push("README.md");
|
|
337
|
+
await mkdir2(join3(dest, ".githooks"), { recursive: true });
|
|
338
|
+
const preCommit = await generatePreCommit(vars);
|
|
339
|
+
await writeFile2(join3(dest, ".githooks/pre-commit"), preCommit);
|
|
340
|
+
await chmod(join3(dest, ".githooks/pre-commit"), 493);
|
|
341
|
+
manifest.push(".githooks/pre-commit");
|
|
342
|
+
await mkdir2(join3(dest, ".github/workflows"), { recursive: true });
|
|
343
|
+
const lintYml = await generateCiYml(vars);
|
|
344
|
+
await writeFile2(join3(dest, ".github/workflows/ci.yml"), lintYml);
|
|
345
|
+
manifest.push(".github/workflows/ci.yml");
|
|
346
|
+
const setupSh = await generateSetupSh(vars);
|
|
347
|
+
await writeFile2(join3(dest, "setup.sh"), setupSh);
|
|
348
|
+
await chmod(join3(dest, "setup.sh"), 493);
|
|
349
|
+
manifest.push("setup.sh");
|
|
350
|
+
const staticFiles = await copyStaticFiles(repoDir, dest);
|
|
351
|
+
manifest.push(...staticFiles);
|
|
352
|
+
const pkg = JSON.parse(
|
|
353
|
+
await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
|
|
354
|
+
);
|
|
355
|
+
const projxConfig = {
|
|
356
|
+
version: pkg.version,
|
|
357
|
+
components: opts.components,
|
|
358
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
|
|
359
|
+
files: manifest.sort()
|
|
360
|
+
};
|
|
361
|
+
await writeFile2(join3(dest, ".projx"), JSON.stringify(projxConfig, null, 2));
|
|
362
|
+
if (opts.git) {
|
|
363
|
+
try {
|
|
364
|
+
exec("git init", dest);
|
|
365
|
+
exec("git config core.hooksPath .githooks", dest);
|
|
366
|
+
p2.log.success("Git initialized with hooks.");
|
|
367
|
+
} catch {
|
|
368
|
+
p2.log.warn("Failed to initialize git.");
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (opts.install) {
|
|
372
|
+
await installDeps(dest, opts.components);
|
|
373
|
+
}
|
|
374
|
+
copyEnvExamples(dest, opts.components);
|
|
375
|
+
if (opts.git) {
|
|
376
|
+
try {
|
|
377
|
+
exec("git add -A", dest);
|
|
378
|
+
exec('git commit -m "Initial scaffold from projx"', dest);
|
|
379
|
+
p2.log.success("Initial commit created.");
|
|
380
|
+
} catch {
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
p2.outro(`Done! Next steps:
|
|
384
|
+
|
|
385
|
+
cd ${name}
|
|
386
|
+
make run-dev`);
|
|
387
|
+
}
|
|
388
|
+
async function substituteNames(dest, components, name, nameSnake) {
|
|
389
|
+
if (components.includes("fastapi")) {
|
|
390
|
+
await replaceInFile(
|
|
391
|
+
join3(dest, "fastapi/pyproject.toml"),
|
|
392
|
+
"projx-fastapi",
|
|
393
|
+
`${name}-fastapi`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
if (components.includes("fastify")) {
|
|
397
|
+
await replaceInFile(
|
|
398
|
+
join3(dest, "fastify/package.json"),
|
|
399
|
+
"projx-fastify",
|
|
400
|
+
`${name}-fastify`
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
if (components.includes("frontend")) {
|
|
404
|
+
await replaceInFile(
|
|
405
|
+
join3(dest, "frontend/package.json"),
|
|
406
|
+
"projx-frontend",
|
|
407
|
+
`${name}-frontend`
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
if (components.includes("e2e")) {
|
|
411
|
+
await replaceInFile(
|
|
412
|
+
join3(dest, "e2e/package.json"),
|
|
413
|
+
"projx-e2e",
|
|
414
|
+
`${name}-e2e`
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (components.includes("mobile")) {
|
|
418
|
+
await replaceInFile(
|
|
419
|
+
join3(dest, "mobile/pubspec.yaml"),
|
|
420
|
+
"projx_mobile",
|
|
421
|
+
`${nameSnake}_mobile`
|
|
422
|
+
);
|
|
423
|
+
await replaceInDir(
|
|
424
|
+
join3(dest, "mobile"),
|
|
425
|
+
"package:projx_mobile/",
|
|
426
|
+
`package:${nameSnake}_mobile/`,
|
|
427
|
+
".dart"
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
async function installDeps(dest, components) {
|
|
432
|
+
for (const component of components) {
|
|
433
|
+
const spinner4 = p2.spinner();
|
|
434
|
+
try {
|
|
435
|
+
switch (component) {
|
|
436
|
+
case "fastapi":
|
|
437
|
+
if (hasCommand("uv")) {
|
|
438
|
+
spinner4.start("Installing FastAPI dependencies (uv sync)");
|
|
439
|
+
exec("uv sync --all-extras", join3(dest, "fastapi"));
|
|
440
|
+
spinner4.stop("FastAPI dependencies installed.");
|
|
441
|
+
} else {
|
|
442
|
+
p2.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
|
|
443
|
+
}
|
|
444
|
+
break;
|
|
445
|
+
case "fastify":
|
|
446
|
+
if (hasCommand("pnpm")) {
|
|
447
|
+
spinner4.start("Installing Fastify dependencies (pnpm install)");
|
|
448
|
+
exec("pnpm install", join3(dest, "fastify"));
|
|
449
|
+
spinner4.stop("Fastify dependencies installed.");
|
|
450
|
+
} else {
|
|
451
|
+
spinner4.start("Installing Fastify dependencies (npm install)");
|
|
452
|
+
exec("npm install", join3(dest, "fastify"));
|
|
453
|
+
spinner4.stop("Fastify dependencies installed.");
|
|
454
|
+
}
|
|
455
|
+
break;
|
|
456
|
+
case "frontend":
|
|
457
|
+
spinner4.start("Installing Frontend dependencies (npm install)");
|
|
458
|
+
exec("npm install", join3(dest, "frontend"));
|
|
459
|
+
spinner4.stop("Frontend dependencies installed.");
|
|
460
|
+
break;
|
|
461
|
+
case "e2e":
|
|
462
|
+
spinner4.start("Installing E2E dependencies (npm install)");
|
|
463
|
+
exec("npm install", join3(dest, "e2e"));
|
|
464
|
+
spinner4.stop("E2E dependencies installed.");
|
|
465
|
+
break;
|
|
466
|
+
case "mobile":
|
|
467
|
+
if (hasCommand("flutter")) {
|
|
468
|
+
spinner4.start("Installing Flutter dependencies");
|
|
469
|
+
exec("flutter pub get", join3(dest, "mobile"));
|
|
470
|
+
spinner4.stop("Flutter dependencies installed.");
|
|
471
|
+
} else {
|
|
472
|
+
p2.log.warn(
|
|
473
|
+
"Flutter not found \u2014 run 'cd mobile && flutter pub get' manually."
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
break;
|
|
477
|
+
case "infra":
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
} catch {
|
|
481
|
+
spinner4.stop(`Failed to install ${component} dependencies.`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function copyEnvExamples(dest, components) {
|
|
486
|
+
for (const component of components) {
|
|
487
|
+
const example = join3(dest, component, ".env.example");
|
|
488
|
+
const env = join3(dest, component, ".env");
|
|
489
|
+
if (existsSync2(example) && !existsSync2(env)) {
|
|
490
|
+
try {
|
|
491
|
+
copyFileSync(example, env);
|
|
492
|
+
} catch {
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// src/update.ts
|
|
499
|
+
import { existsSync as existsSync3, readFileSync } from "fs";
|
|
500
|
+
import { readFile as readFile4, writeFile as writeFile3, mkdir as mkdir3, chmod as chmod2, cp as cp2, rm as rm2 } from "fs/promises";
|
|
501
|
+
import { execSync as execSync2 } from "child_process";
|
|
502
|
+
import { join as join4 } from "path";
|
|
503
|
+
import * as p3 from "@clack/prompts";
|
|
504
|
+
var NEVER_OVERWRITE = [
|
|
505
|
+
/\.env$/,
|
|
506
|
+
/\.env\.(dev|staging|prod)$/,
|
|
507
|
+
/prisma\/migrations\//,
|
|
508
|
+
/src\/migrations\/versions\//
|
|
509
|
+
];
|
|
510
|
+
function isGitRepo(cwd) {
|
|
511
|
+
try {
|
|
512
|
+
execSync2("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
|
|
513
|
+
return true;
|
|
514
|
+
} catch {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function hasUncommittedChanges(cwd) {
|
|
519
|
+
try {
|
|
520
|
+
const status = execSync2("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
|
|
521
|
+
return status.length > 0;
|
|
522
|
+
} catch {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function branchExists(cwd, branch) {
|
|
527
|
+
try {
|
|
528
|
+
execSync2(`git show-ref --verify --quiet refs/heads/${branch}`, { cwd, stdio: "pipe" });
|
|
529
|
+
return true;
|
|
530
|
+
} catch {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
function getCurrentBranch(cwd) {
|
|
535
|
+
return execSync2("git branch --show-current", { cwd, stdio: "pipe" }).toString().trim();
|
|
536
|
+
}
|
|
537
|
+
async function update(cwd, localRepo) {
|
|
538
|
+
p3.intro("projx update");
|
|
539
|
+
const isLocal = !!localRepo;
|
|
540
|
+
const configPath = join4(cwd, ".projx");
|
|
541
|
+
let config;
|
|
542
|
+
if (existsSync3(configPath)) {
|
|
543
|
+
config = JSON.parse(await readFile4(configPath, "utf-8"));
|
|
544
|
+
p3.log.info(
|
|
545
|
+
`Found .projx (v${config.version}, components: ${config.components.join(", ")})`
|
|
546
|
+
);
|
|
547
|
+
} else {
|
|
548
|
+
p3.log.warn("No .projx file found. Detecting components from directories.");
|
|
549
|
+
const detected = COMPONENTS.filter(
|
|
550
|
+
(c) => existsSync3(join4(cwd, c))
|
|
551
|
+
);
|
|
552
|
+
if (detected.length === 0) {
|
|
553
|
+
p3.log.error("No projx components found in this directory.");
|
|
554
|
+
process.exit(1);
|
|
555
|
+
}
|
|
556
|
+
config = {
|
|
557
|
+
version: "0.0.0",
|
|
558
|
+
components: detected,
|
|
559
|
+
createdAt: "unknown",
|
|
560
|
+
files: []
|
|
561
|
+
};
|
|
562
|
+
p3.log.info(`Detected: ${detected.join(", ")}`);
|
|
563
|
+
}
|
|
564
|
+
const useGitBranch = isGitRepo(cwd);
|
|
565
|
+
let branchName;
|
|
566
|
+
let originalBranch;
|
|
567
|
+
if (useGitBranch) {
|
|
568
|
+
if (hasUncommittedChanges(cwd)) {
|
|
569
|
+
p3.log.error("You have uncommitted changes. Commit or stash them first.");
|
|
570
|
+
process.exit(1);
|
|
571
|
+
}
|
|
572
|
+
originalBranch = getCurrentBranch(cwd);
|
|
573
|
+
const dlSpinner = p3.spinner();
|
|
574
|
+
dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
|
|
575
|
+
const repoDir = await downloadRepo(localRepo).catch((err) => {
|
|
576
|
+
dlSpinner.stop("Failed.");
|
|
577
|
+
p3.log.error(String(err));
|
|
578
|
+
process.exit(1);
|
|
579
|
+
});
|
|
580
|
+
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
581
|
+
const pkg = JSON.parse(
|
|
582
|
+
await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
|
|
583
|
+
);
|
|
584
|
+
branchName = `projx/update-v${pkg.version}`;
|
|
585
|
+
if (branchExists(cwd, branchName)) {
|
|
586
|
+
let suffix = 1;
|
|
587
|
+
while (branchExists(cwd, `${branchName}-${suffix}`)) suffix++;
|
|
588
|
+
branchName = `${branchName}-${suffix}`;
|
|
589
|
+
}
|
|
590
|
+
execSync2(`git checkout -b ${branchName}`, { cwd, stdio: "pipe" });
|
|
591
|
+
p3.log.info(`Created branch: ${branchName}`);
|
|
592
|
+
try {
|
|
593
|
+
await doUpdate(cwd, config, repoDir, pkg.version);
|
|
594
|
+
} finally {
|
|
595
|
+
await cleanupRepo(repoDir, isLocal);
|
|
596
|
+
}
|
|
597
|
+
execSync2(`git checkout ${originalBranch}`, { cwd, stdio: "pipe" });
|
|
598
|
+
p3.outro(
|
|
599
|
+
`Updates on branch: ${branchName}
|
|
600
|
+
|
|
601
|
+
Review changes:
|
|
602
|
+
git diff ${originalBranch}...${branchName}
|
|
603
|
+
|
|
604
|
+
Merge when ready:
|
|
605
|
+
git merge ${branchName}`
|
|
606
|
+
);
|
|
607
|
+
} else {
|
|
608
|
+
const dlSpinner = p3.spinner();
|
|
609
|
+
dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
|
|
610
|
+
const repoDir = await downloadRepo(localRepo).catch((err) => {
|
|
611
|
+
dlSpinner.stop("Failed.");
|
|
612
|
+
p3.log.error(String(err));
|
|
613
|
+
process.exit(1);
|
|
614
|
+
});
|
|
615
|
+
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
616
|
+
const pkg = JSON.parse(
|
|
617
|
+
await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
|
|
618
|
+
);
|
|
619
|
+
try {
|
|
620
|
+
await doUpdate(cwd, config, repoDir, pkg.version);
|
|
621
|
+
} finally {
|
|
622
|
+
await cleanupRepo(repoDir, isLocal);
|
|
623
|
+
}
|
|
624
|
+
p3.outro(`Updated to v${pkg.version}. Review changes before committing.`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
async function doUpdate(cwd, config, repoDir, version) {
|
|
628
|
+
const name = detectProjectName(cwd, config.components);
|
|
629
|
+
const nameSnake = toSnake(name);
|
|
630
|
+
const vars = { projectName: name, components: config.components };
|
|
631
|
+
const newManifest = [];
|
|
632
|
+
for (const component of config.components) {
|
|
633
|
+
const spinner5 = p3.spinner();
|
|
634
|
+
spinner5.start(`Updating ${component}/ template files`);
|
|
635
|
+
const componentSrc = join4(repoDir, component);
|
|
636
|
+
if (!existsSync3(componentSrc)) {
|
|
637
|
+
spinner5.stop(`${component}/ template not found, skipping.`);
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
const tmpDest = join4(cwd, `.projx-tmp`);
|
|
641
|
+
const files = await copyComponent(repoDir, component, tmpDest);
|
|
642
|
+
for (const file of files) {
|
|
643
|
+
const rel = `${component}/${file}`;
|
|
644
|
+
const src = join4(tmpDest, component, file);
|
|
645
|
+
const dest = join4(cwd, rel);
|
|
646
|
+
if (NEVER_OVERWRITE.some((re) => re.test(rel))) continue;
|
|
647
|
+
if (config.files.length > 0 && !config.files.includes(rel)) continue;
|
|
648
|
+
const dir = dest.substring(0, dest.lastIndexOf("/"));
|
|
649
|
+
await mkdir3(dir, { recursive: true });
|
|
650
|
+
await cp2(src, dest, { force: true });
|
|
651
|
+
newManifest.push(rel);
|
|
652
|
+
}
|
|
653
|
+
await rm2(tmpDest, { recursive: true, force: true });
|
|
654
|
+
spinner5.stop(`${component}/ updated.`);
|
|
655
|
+
}
|
|
656
|
+
const spinner4 = p3.spinner();
|
|
657
|
+
spinner4.start("Updating shared files");
|
|
658
|
+
const hasBackend = config.components.includes("fastapi") || config.components.includes("fastify");
|
|
659
|
+
if (hasBackend || config.components.includes("frontend")) {
|
|
660
|
+
await writeFile3(
|
|
661
|
+
join4(cwd, "docker-compose.yml"),
|
|
662
|
+
await generateDockerCompose(vars)
|
|
663
|
+
);
|
|
664
|
+
newManifest.push("docker-compose.yml");
|
|
665
|
+
await writeFile3(
|
|
666
|
+
join4(cwd, "docker-compose.dev.yml"),
|
|
667
|
+
await generateDockerComposeDev(vars)
|
|
668
|
+
);
|
|
669
|
+
newManifest.push("docker-compose.dev.yml");
|
|
670
|
+
}
|
|
671
|
+
await mkdir3(join4(cwd, ".githooks"), { recursive: true });
|
|
672
|
+
const preCommit = await generatePreCommit(vars);
|
|
673
|
+
await writeFile3(join4(cwd, ".githooks/pre-commit"), preCommit);
|
|
674
|
+
await chmod2(join4(cwd, ".githooks/pre-commit"), 493);
|
|
675
|
+
newManifest.push(".githooks/pre-commit");
|
|
676
|
+
await mkdir3(join4(cwd, ".github/workflows"), { recursive: true });
|
|
677
|
+
await writeFile3(
|
|
678
|
+
join4(cwd, ".github/workflows/ci.yml"),
|
|
679
|
+
await generateCiYml(vars)
|
|
680
|
+
);
|
|
681
|
+
newManifest.push(".github/workflows/ci.yml");
|
|
682
|
+
const setupSh = await generateSetupSh(vars);
|
|
683
|
+
await writeFile3(join4(cwd, "setup.sh"), setupSh);
|
|
684
|
+
await chmod2(join4(cwd, "setup.sh"), 493);
|
|
685
|
+
newManifest.push("setup.sh");
|
|
686
|
+
spinner4.stop("Shared files updated.");
|
|
687
|
+
if (config.components.includes("mobile")) {
|
|
688
|
+
await replaceInDir(
|
|
689
|
+
join4(cwd, "mobile"),
|
|
690
|
+
"package:projx_mobile/",
|
|
691
|
+
`package:${nameSnake}_mobile/`,
|
|
692
|
+
".dart"
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
const updatedConfig = {
|
|
696
|
+
version,
|
|
697
|
+
components: config.components,
|
|
698
|
+
createdAt: config.createdAt,
|
|
699
|
+
files: [.../* @__PURE__ */ new Set([...config.files, ...newManifest])].sort()
|
|
700
|
+
};
|
|
701
|
+
await writeFile3(join4(cwd, ".projx"), JSON.stringify(updatedConfig, null, 2));
|
|
702
|
+
}
|
|
703
|
+
function detectProjectName(cwd, components) {
|
|
704
|
+
for (const component of components) {
|
|
705
|
+
const pkgPath = join4(cwd, component, "package.json");
|
|
706
|
+
if (existsSync3(pkgPath)) {
|
|
707
|
+
try {
|
|
708
|
+
const pkg = JSON.parse(
|
|
709
|
+
readFileSync(pkgPath, "utf-8")
|
|
710
|
+
);
|
|
711
|
+
const n = pkg.name;
|
|
712
|
+
if (n && n.includes("-")) {
|
|
713
|
+
return n.substring(0, n.lastIndexOf("-"));
|
|
714
|
+
}
|
|
715
|
+
} catch {
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return toKebab(cwd.split("/").pop());
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// src/add.ts
|
|
723
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
|
|
724
|
+
import { chmod as chmod3, mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
|
|
725
|
+
import { join as join5 } from "path";
|
|
726
|
+
import * as p4 from "@clack/prompts";
|
|
727
|
+
async function add(cwd, newComponents, localRepo, skipInstall = false) {
|
|
728
|
+
p4.intro("projx add");
|
|
729
|
+
const isLocal = !!localRepo;
|
|
730
|
+
const configPath = join5(cwd, ".projx");
|
|
731
|
+
if (!existsSync4(configPath)) {
|
|
732
|
+
p4.log.error("No .projx file found. Run 'projx <name>' to create a project first.");
|
|
733
|
+
process.exit(1);
|
|
734
|
+
}
|
|
735
|
+
const config = JSON.parse(await readFile5(configPath, "utf-8"));
|
|
736
|
+
const existing = config.components;
|
|
737
|
+
const alreadyExists = newComponents.filter((c) => existing.includes(c));
|
|
738
|
+
if (alreadyExists.length > 0) {
|
|
739
|
+
p4.log.warn(`Already present: ${alreadyExists.join(", ")}. Skipping those.`);
|
|
740
|
+
}
|
|
741
|
+
const toAdd = newComponents.filter((c) => !existing.includes(c));
|
|
742
|
+
if (toAdd.length === 0) {
|
|
743
|
+
p4.log.info("Nothing new to add.");
|
|
744
|
+
process.exit(0);
|
|
745
|
+
}
|
|
746
|
+
p4.log.info(`Adding: ${toAdd.join(", ")}`);
|
|
747
|
+
const dlSpinner = p4.spinner();
|
|
748
|
+
dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
|
|
749
|
+
const repoDir = await downloadRepo(localRepo).catch((err) => {
|
|
750
|
+
dlSpinner.stop("Failed.");
|
|
751
|
+
p4.log.error(String(err));
|
|
752
|
+
process.exit(1);
|
|
753
|
+
});
|
|
754
|
+
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
755
|
+
try {
|
|
756
|
+
await doAdd(cwd, config, toAdd, repoDir, skipInstall);
|
|
757
|
+
} finally {
|
|
758
|
+
await cleanupRepo(repoDir, isLocal);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
async function doAdd(cwd, config, toAdd, repoDir, skipInstall) {
|
|
762
|
+
const name = detectProjectName2(cwd, config.components);
|
|
763
|
+
const nameSnake = toSnake(name);
|
|
764
|
+
const allComponents = [...config.components, ...toAdd];
|
|
765
|
+
const vars = { projectName: name, components: allComponents };
|
|
766
|
+
const newFiles = [];
|
|
767
|
+
for (const component of toAdd) {
|
|
768
|
+
const spinner5 = p4.spinner();
|
|
769
|
+
spinner5.start(`Adding ${component}/`);
|
|
770
|
+
const files = await copyComponent(repoDir, component, cwd);
|
|
771
|
+
newFiles.push(...files.map((f) => `${component}/${f}`));
|
|
772
|
+
spinner5.stop(`${component}/`);
|
|
773
|
+
}
|
|
774
|
+
await substituteNames2(cwd, toAdd, name, nameSnake);
|
|
775
|
+
const spinner4 = p4.spinner();
|
|
776
|
+
spinner4.start("Regenerating shared files");
|
|
777
|
+
const hasBackend = allComponents.includes("fastapi") || allComponents.includes("fastify");
|
|
778
|
+
if (hasBackend || allComponents.includes("frontend")) {
|
|
779
|
+
await writeFile4(
|
|
780
|
+
join5(cwd, "docker-compose.yml"),
|
|
781
|
+
await generateDockerCompose(vars)
|
|
782
|
+
);
|
|
783
|
+
await writeFile4(
|
|
784
|
+
join5(cwd, "docker-compose.dev.yml"),
|
|
785
|
+
await generateDockerComposeDev(vars)
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
await writeFile4(join5(cwd, "Makefile"), await generateMakefile(vars));
|
|
789
|
+
await writeFile4(join5(cwd, "README.md"), await generateReadme(vars));
|
|
790
|
+
await mkdir4(join5(cwd, ".githooks"), { recursive: true });
|
|
791
|
+
await writeFile4(join5(cwd, ".githooks/pre-commit"), await generatePreCommit(vars));
|
|
792
|
+
await chmod3(join5(cwd, ".githooks/pre-commit"), 493);
|
|
793
|
+
await mkdir4(join5(cwd, ".github/workflows"), { recursive: true });
|
|
794
|
+
await writeFile4(
|
|
795
|
+
join5(cwd, ".github/workflows/ci.yml"),
|
|
796
|
+
await generateCiYml(vars)
|
|
797
|
+
);
|
|
798
|
+
await writeFile4(join5(cwd, "setup.sh"), await generateSetupSh(vars));
|
|
799
|
+
await chmod3(join5(cwd, "setup.sh"), 493);
|
|
800
|
+
spinner4.stop("Shared files regenerated.");
|
|
801
|
+
if (!skipInstall) {
|
|
802
|
+
await installDeps2(cwd, toAdd);
|
|
803
|
+
}
|
|
804
|
+
for (const component of toAdd) {
|
|
805
|
+
const example = join5(cwd, component, ".env.example");
|
|
806
|
+
const env = join5(cwd, component, ".env");
|
|
807
|
+
if (existsSync4(example) && !existsSync4(env)) {
|
|
808
|
+
try {
|
|
809
|
+
copyFileSync2(example, env);
|
|
810
|
+
} catch {
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
const pkg = JSON.parse(
|
|
815
|
+
await readFile5(join5(repoDir, "cli/package.json"), "utf-8")
|
|
816
|
+
);
|
|
817
|
+
const updatedConfig = {
|
|
818
|
+
version: pkg.version,
|
|
819
|
+
components: allComponents,
|
|
820
|
+
createdAt: config.createdAt,
|
|
821
|
+
files: [.../* @__PURE__ */ new Set([...config.files, ...newFiles])].sort()
|
|
822
|
+
};
|
|
823
|
+
await writeFile4(join5(cwd, ".projx"), JSON.stringify(updatedConfig, null, 2));
|
|
824
|
+
p4.outro(`Added ${toAdd.join(", ")}. Shared files updated for all ${allComponents.length} components.`);
|
|
825
|
+
}
|
|
826
|
+
async function substituteNames2(dest, components, name, nameSnake) {
|
|
827
|
+
if (components.includes("fastapi")) {
|
|
828
|
+
await replaceInFile(
|
|
829
|
+
join5(dest, "fastapi/pyproject.toml"),
|
|
830
|
+
"projx-fastapi",
|
|
831
|
+
`${name}-fastapi`
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
if (components.includes("fastify")) {
|
|
835
|
+
await replaceInFile(
|
|
836
|
+
join5(dest, "fastify/package.json"),
|
|
837
|
+
"projx-fastify",
|
|
838
|
+
`${name}-fastify`
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
if (components.includes("frontend")) {
|
|
842
|
+
await replaceInFile(
|
|
843
|
+
join5(dest, "frontend/package.json"),
|
|
844
|
+
"projx-frontend",
|
|
845
|
+
`${name}-frontend`
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
if (components.includes("e2e")) {
|
|
849
|
+
await replaceInFile(
|
|
850
|
+
join5(dest, "e2e/package.json"),
|
|
851
|
+
"projx-e2e",
|
|
852
|
+
`${name}-e2e`
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
if (components.includes("mobile")) {
|
|
856
|
+
await replaceInFile(
|
|
857
|
+
join5(dest, "mobile/pubspec.yaml"),
|
|
858
|
+
"projx_mobile",
|
|
859
|
+
`${nameSnake}_mobile`
|
|
860
|
+
);
|
|
861
|
+
await replaceInDir(
|
|
862
|
+
join5(dest, "mobile"),
|
|
863
|
+
"package:projx_mobile/",
|
|
864
|
+
`package:${nameSnake}_mobile/`,
|
|
865
|
+
".dart"
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
async function installDeps2(dest, components) {
|
|
870
|
+
for (const component of components) {
|
|
871
|
+
const spinner4 = p4.spinner();
|
|
872
|
+
try {
|
|
873
|
+
switch (component) {
|
|
874
|
+
case "fastapi":
|
|
875
|
+
if (hasCommand("uv")) {
|
|
876
|
+
spinner4.start("Installing FastAPI dependencies");
|
|
877
|
+
exec("uv sync --all-extras", join5(dest, "fastapi"));
|
|
878
|
+
spinner4.stop("FastAPI dependencies installed.");
|
|
879
|
+
} else {
|
|
880
|
+
p4.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
|
|
881
|
+
}
|
|
882
|
+
break;
|
|
883
|
+
case "fastify":
|
|
884
|
+
if (hasCommand("pnpm")) {
|
|
885
|
+
spinner4.start("Installing Fastify dependencies");
|
|
886
|
+
exec("pnpm install", join5(dest, "fastify"));
|
|
887
|
+
spinner4.stop("Fastify dependencies installed.");
|
|
888
|
+
} else {
|
|
889
|
+
spinner4.start("Installing Fastify dependencies");
|
|
890
|
+
exec("npm install", join5(dest, "fastify"));
|
|
891
|
+
spinner4.stop("Fastify dependencies installed.");
|
|
892
|
+
}
|
|
893
|
+
break;
|
|
894
|
+
case "frontend":
|
|
895
|
+
spinner4.start("Installing Frontend dependencies");
|
|
896
|
+
exec("npm install", join5(dest, "frontend"));
|
|
897
|
+
spinner4.stop("Frontend dependencies installed.");
|
|
898
|
+
break;
|
|
899
|
+
case "e2e":
|
|
900
|
+
spinner4.start("Installing E2E dependencies");
|
|
901
|
+
exec("npm install", join5(dest, "e2e"));
|
|
902
|
+
spinner4.stop("E2E dependencies installed.");
|
|
903
|
+
break;
|
|
904
|
+
case "mobile":
|
|
905
|
+
if (hasCommand("flutter")) {
|
|
906
|
+
spinner4.start("Installing Flutter dependencies");
|
|
907
|
+
exec("flutter pub get", join5(dest, "mobile"));
|
|
908
|
+
spinner4.stop("Flutter dependencies installed.");
|
|
909
|
+
} else {
|
|
910
|
+
p4.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
|
|
911
|
+
}
|
|
912
|
+
break;
|
|
913
|
+
case "infra":
|
|
914
|
+
break;
|
|
915
|
+
}
|
|
916
|
+
} catch {
|
|
917
|
+
spinner4.stop(`Failed to install ${component} dependencies.`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
function detectProjectName2(cwd, components) {
|
|
922
|
+
for (const component of components) {
|
|
923
|
+
const pkgPath = join5(cwd, component, "package.json");
|
|
924
|
+
if (existsSync4(pkgPath)) {
|
|
925
|
+
try {
|
|
926
|
+
const pkg = JSON.parse(
|
|
927
|
+
readFileSync2(pkgPath, "utf-8")
|
|
928
|
+
);
|
|
929
|
+
const n = pkg.name;
|
|
930
|
+
if (n && n.includes("-")) {
|
|
931
|
+
return n.substring(0, n.lastIndexOf("-"));
|
|
932
|
+
}
|
|
933
|
+
} catch {
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return toKebab(cwd.split("/").pop());
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// src/index.ts
|
|
941
|
+
var args = process.argv.slice(2);
|
|
942
|
+
function parseArgs() {
|
|
943
|
+
let command = "create";
|
|
944
|
+
let name;
|
|
945
|
+
let localRepo;
|
|
946
|
+
const options = {};
|
|
947
|
+
const extraArgs = [];
|
|
948
|
+
for (let i = 0; i < args.length; i++) {
|
|
949
|
+
const arg = args[i];
|
|
950
|
+
if (arg === "update" && !name) {
|
|
951
|
+
command = "update";
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
if (arg === "add" && !name) {
|
|
955
|
+
command = "add";
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
if (arg === "--components") {
|
|
959
|
+
const val = args[++i];
|
|
960
|
+
if (val) {
|
|
961
|
+
options.components = val.split(",").filter(
|
|
962
|
+
(c) => COMPONENTS.includes(c)
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
if (arg === "--local") {
|
|
968
|
+
localRepo = resolve2(args[++i] || ".");
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
if (arg === "--no-git") {
|
|
972
|
+
options.git = false;
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
if (arg === "--no-install") {
|
|
976
|
+
options.install = false;
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
if (arg === "-y" || arg === "--yes") {
|
|
980
|
+
options.components = options.components ?? ["fastify", "frontend", "e2e"];
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
if (arg === "--help" || arg === "-h") {
|
|
984
|
+
printHelp();
|
|
985
|
+
process.exit(0);
|
|
986
|
+
}
|
|
987
|
+
if (!arg.startsWith("-")) {
|
|
988
|
+
if (command === "add") {
|
|
989
|
+
extraArgs.push(arg);
|
|
990
|
+
} else if (!name) {
|
|
991
|
+
name = arg;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return { command, name, options, localRepo, extraArgs };
|
|
996
|
+
}
|
|
997
|
+
function printHelp() {
|
|
998
|
+
console.log(`
|
|
999
|
+
Usage:
|
|
1000
|
+
projx <name> [options] Create a new project
|
|
1001
|
+
projx add <components...> Add components to existing project
|
|
1002
|
+
projx update Update scaffolding to latest
|
|
1003
|
+
|
|
1004
|
+
Options:
|
|
1005
|
+
--components <list> Comma-separated: fastapi,fastify,frontend,mobile,e2e,infra
|
|
1006
|
+
--no-git Skip git init
|
|
1007
|
+
--no-install Skip dependency installation
|
|
1008
|
+
-y, --yes Accept defaults (fastify + frontend + e2e)
|
|
1009
|
+
--local <path> Use local repo instead of downloading (dev only)
|
|
1010
|
+
-h, --help Show this help
|
|
1011
|
+
|
|
1012
|
+
Examples:
|
|
1013
|
+
npx create-projx my-app
|
|
1014
|
+
npx create-projx my-app --components fastapi,frontend,e2e
|
|
1015
|
+
npx create-projx my-app -y
|
|
1016
|
+
npx create-projx add frontend mobile
|
|
1017
|
+
npx create-projx@latest update
|
|
1018
|
+
`);
|
|
1019
|
+
}
|
|
1020
|
+
async function main() {
|
|
1021
|
+
const { command, name, options, localRepo, extraArgs } = parseArgs();
|
|
1022
|
+
if (command === "update") {
|
|
1023
|
+
await update(process.cwd(), localRepo);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
if (command === "add") {
|
|
1027
|
+
const components = extraArgs.filter(
|
|
1028
|
+
(c) => COMPONENTS.includes(c)
|
|
1029
|
+
);
|
|
1030
|
+
if (components.length === 0) {
|
|
1031
|
+
console.error(`Error: specify components to add. Available: ${COMPONENTS.join(", ")}`);
|
|
1032
|
+
process.exit(1);
|
|
1033
|
+
}
|
|
1034
|
+
await add(process.cwd(), components, localRepo, options.install === false);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
let opts;
|
|
1038
|
+
if (options.components) {
|
|
1039
|
+
if (!name) {
|
|
1040
|
+
console.error("Error: project name required. Usage: projx <name>");
|
|
1041
|
+
return process.exit(1);
|
|
1042
|
+
}
|
|
1043
|
+
opts = {
|
|
1044
|
+
name,
|
|
1045
|
+
components: options.components,
|
|
1046
|
+
git: options.git ?? true,
|
|
1047
|
+
install: options.install ?? true
|
|
1048
|
+
};
|
|
1049
|
+
} else {
|
|
1050
|
+
opts = await runPrompts(name);
|
|
1051
|
+
opts.git = options.git ?? opts.git;
|
|
1052
|
+
opts.install = options.install ?? opts.install;
|
|
1053
|
+
}
|
|
1054
|
+
const dest = resolve2(process.cwd(), opts.name);
|
|
1055
|
+
if (existsSync5(dest)) {
|
|
1056
|
+
console.error(`Error: ${dest} already exists.`);
|
|
1057
|
+
process.exit(1);
|
|
1058
|
+
}
|
|
1059
|
+
await scaffold(opts, dest, localRepo);
|
|
1060
|
+
}
|
|
1061
|
+
main().catch((err) => {
|
|
1062
|
+
console.error(err);
|
|
1063
|
+
process.exit(1);
|
|
1064
|
+
});
|