zuro-cli 0.0.2-beta.1 → 0.0.2-beta.10

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.mjs CHANGED
@@ -166,18 +166,36 @@ async function fetchFile(filePath, options) {
166
166
  import fs from "fs-extra";
167
167
  import path from "path";
168
168
  import { execa } from "execa";
169
- async function initPackageJson(cwd, force = false) {
169
+ function normalizePackageName(name) {
170
+ const normalized = name.trim().toLowerCase().replace(/[^a-z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^[._-]+|[._-]+$/g, "");
171
+ return normalized || "zuro-app";
172
+ }
173
+ async function ensurePackageManagerAvailable(pm) {
174
+ try {
175
+ await execa(pm, ["--version"], { stdio: "ignore" });
176
+ } catch {
177
+ throw new Error(
178
+ `Package manager '${pm}' is not installed or not available in PATH. Install it or choose npm.`
179
+ );
180
+ }
181
+ }
182
+ async function initPackageJson(cwd, force = false, packageName = "zuro-app", srcDir = "src", options = {}) {
170
183
  const pkgPath = path.join(cwd, "package.json");
171
184
  if (force || !await fs.pathExists(pkgPath)) {
185
+ const scripts = {
186
+ "dev": `tsx watch ${srcDir}/server.ts`,
187
+ "build": "tsc",
188
+ "start": "node dist/server.js"
189
+ };
190
+ if (options.enablePrettier) {
191
+ scripts["format"] = "prettier --write .";
192
+ scripts["format:check"] = "prettier --check .";
193
+ }
172
194
  await fs.writeJson(pkgPath, {
173
- name: "zuro-app",
195
+ name: normalizePackageName(packageName),
174
196
  version: "0.0.1",
175
197
  private: true,
176
- scripts: {
177
- "dev": "tsx watch src/server.ts",
178
- "build": "tsc",
179
- "start": "node dist/server.js"
180
- }
198
+ scripts
181
199
  }, { spaces: 2 });
182
200
  }
183
201
  }
@@ -214,8 +232,9 @@ async function installDependencies(pm, deps, cwd, options = {}) {
214
232
  import fs2 from "fs-extra";
215
233
  import path2 from "path";
216
234
  import os from "os";
217
- var updateEnvFile = async (cwd, variables, createIfMissing = true) => {
235
+ var updateEnvFile = async (cwd, variables, createIfMissing = true, options = {}) => {
218
236
  const envPath = path2.join(cwd, ".env");
237
+ const overwriteExisting = options.overwriteExisting ?? false;
219
238
  let content = "";
220
239
  if (fs2.existsSync(envPath)) {
221
240
  content = await fs2.readFile(envPath, "utf-8");
@@ -224,14 +243,25 @@ var updateEnvFile = async (cwd, variables, createIfMissing = true) => {
224
243
  }
225
244
  let modified = false;
226
245
  for (const [key, value] of Object.entries(variables)) {
227
- const regex = new RegExp(`^${key}=`, "m");
228
- if (!regex.test(content)) {
229
- if (content && !content.endsWith("\n")) {
230
- content += os.EOL;
246
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
247
+ const regex = new RegExp(`^${escapedKey}=.*$`, "m");
248
+ if (regex.test(content)) {
249
+ if (!overwriteExisting) {
250
+ continue;
231
251
  }
232
- content += `${key}=${value}${os.EOL}`;
233
- modified = true;
252
+ const nextLine = `${key}=${value}`;
253
+ const updated = content.replace(regex, nextLine);
254
+ if (updated !== content) {
255
+ content = updated;
256
+ modified = true;
257
+ }
258
+ continue;
234
259
  }
260
+ if (content && !content.endsWith("\n")) {
261
+ content += os.EOL;
262
+ }
263
+ content += `${key}=${value}${os.EOL}`;
264
+ modified = true;
235
265
  }
236
266
  if (modified || !fs2.existsSync(envPath)) {
237
267
  await fs2.writeFile(envPath, content);
@@ -354,6 +384,13 @@ function showNonZuroProjectMessage() {
354
384
  console.log("- a fresh/empty directory, or");
355
385
  console.log("- an existing project already managed by Zuro CLI.");
356
386
  }
387
+ function showInitFirstMessage() {
388
+ console.log(chalk.yellow("No Zuro project found in this directory."));
389
+ console.log("");
390
+ console.log(chalk.yellow("Run init first, then add modules."));
391
+ console.log("");
392
+ console.log(chalk.cyan("npx zuro-cli init"));
393
+ }
357
394
 
358
395
  // src/commands/init.ts
359
396
  function resolveSafeTargetPath(projectRoot, srcDir, file) {
@@ -368,6 +405,48 @@ function resolveSafeTargetPath(projectRoot, srcDir, file) {
368
405
  targetPath
369
406
  };
370
407
  }
408
+ async function ensureSafeTargetDirectory(targetDir, cwd, projectName) {
409
+ await fs4.ensureDir(targetDir);
410
+ const entries = await fs4.readdir(targetDir);
411
+ if (entries.length === 0) {
412
+ return true;
413
+ }
414
+ const isCurrentFolder = targetDir === cwd;
415
+ const response = await prompts({
416
+ type: "confirm",
417
+ name: "proceed",
418
+ message: isCurrentFolder ? `Current folder '${projectName}' is not empty. Continue anyway?` : `Target folder '${projectName}' already exists and is not empty. Continue anyway?`,
419
+ initial: false
420
+ });
421
+ return response.proceed === true;
422
+ }
423
+ async function setupPrettier(targetDir) {
424
+ const prettierConfigPath = path4.join(targetDir, ".prettierrc");
425
+ const prettierIgnorePath = path4.join(targetDir, ".prettierignore");
426
+ if (!await fs4.pathExists(prettierConfigPath)) {
427
+ const prettierConfig = {
428
+ semi: true,
429
+ singleQuote: false,
430
+ trailingComma: "es5",
431
+ printWidth: 100,
432
+ tabWidth: 2
433
+ };
434
+ await fs4.writeJson(prettierConfigPath, prettierConfig, { spaces: 2 });
435
+ }
436
+ if (!await fs4.pathExists(prettierIgnorePath)) {
437
+ const ignoreContent = `node_modules
438
+ dist
439
+ build
440
+ coverage
441
+ .next
442
+ pnpm-lock.yaml
443
+ package-lock.json
444
+ bun.lock
445
+ bun.lockb
446
+ `;
447
+ await fs4.writeFile(prettierIgnorePath, ignoreContent);
448
+ }
449
+ }
371
450
  async function init() {
372
451
  const cwd = process.cwd();
373
452
  const isExistingProject = await fs4.pathExists(path4.join(cwd, "package.json"));
@@ -380,6 +459,7 @@ async function init() {
380
459
  let pm = "npm";
381
460
  let srcDir = "src";
382
461
  let projectName = path4.basename(cwd);
462
+ let enablePrettier = false;
383
463
  if (isExistingProject) {
384
464
  console.log(chalk2.blue("\u2139 Existing project detected."));
385
465
  projectName = path4.basename(cwd);
@@ -417,6 +497,12 @@ async function init() {
417
497
  { title: "bun", value: "bun" }
418
498
  ],
419
499
  initial: 0
500
+ },
501
+ {
502
+ type: "confirm",
503
+ name: "prettier",
504
+ message: "Setup Prettier?",
505
+ initial: true
420
506
  }
421
507
  ]);
422
508
  if (response.pm === void 0) {
@@ -424,6 +510,7 @@ async function init() {
424
510
  return;
425
511
  }
426
512
  pm = response.pm;
513
+ enablePrettier = response.prettier === true;
427
514
  srcDir = "src";
428
515
  if (!response.path || response.path.trim() === "") {
429
516
  projectName = path4.basename(cwd);
@@ -432,7 +519,11 @@ async function init() {
432
519
  } else {
433
520
  projectName = response.path.trim();
434
521
  targetDir = path4.resolve(cwd, projectName);
435
- await fs4.ensureDir(targetDir);
522
+ }
523
+ const isSafeTarget = await ensureSafeTargetDirectory(targetDir, cwd, projectName);
524
+ if (!isSafeTarget) {
525
+ console.log(chalk2.red("Operation cancelled."));
526
+ return;
436
527
  }
437
528
  }
438
529
  const existingConfig = targetDir === cwd ? existingZuroConfig : await readZuroConfig(targetDir);
@@ -441,20 +532,26 @@ async function init() {
441
532
  pm,
442
533
  srcDir: srcDir || existingConfig?.srcDir || "src"
443
534
  };
444
- await writeZuroConfig(targetDir, zuroConfig);
445
535
  const spinner = ora("Connecting to Zuro Registry...").start();
536
+ let currentStep = "package manager preflight";
446
537
  try {
538
+ spinner.text = `Checking ${pm} availability...`;
539
+ await ensurePackageManagerAvailable(pm);
540
+ currentStep = "registry fetch";
541
+ spinner.text = "Connecting to Zuro Registry...";
447
542
  const registryContext = await fetchRegistry();
448
543
  const coreModule = registryContext.manifest.modules.core;
449
544
  if (!coreModule) {
450
545
  spinner.fail("Core module not found in registry.");
451
546
  return;
452
547
  }
548
+ currentStep = "project initialization";
453
549
  spinner.text = "Initializing project...";
454
550
  const hasPackageJson = await fs4.pathExists(path4.join(targetDir, "package.json"));
455
551
  if (!hasPackageJson) {
456
- await initPackageJson(targetDir, true);
552
+ await initPackageJson(targetDir, true, projectName, srcDir, { enablePrettier });
457
553
  }
554
+ currentStep = "dependency installation";
458
555
  spinner.text = `Installing dependencies using ${pm}...`;
459
556
  let runtimeDeps = [];
460
557
  let devDeps = [];
@@ -469,6 +566,10 @@ async function init() {
469
566
  }
470
567
  await installDependencies(pm, runtimeDeps, targetDir);
471
568
  await installDependencies(pm, devDeps, targetDir, { dev: true });
569
+ if (enablePrettier) {
570
+ await installDependencies(pm, ["prettier"], targetDir, { dev: true });
571
+ }
572
+ currentStep = "module file generation";
472
573
  spinner.text = "Fetching core module files...";
473
574
  for (const file of coreModule.files) {
474
575
  const { relativeTargetPath, targetPath } = resolveSafeTargetPath(targetDir, srcDir, file);
@@ -491,7 +592,14 @@ async function init() {
491
592
  await fs4.ensureDir(path4.dirname(targetPath));
492
593
  await fs4.writeFile(targetPath, content);
493
594
  }
595
+ currentStep = "environment file setup";
494
596
  await createInitialEnv(targetDir);
597
+ if (enablePrettier) {
598
+ currentStep = "prettier setup";
599
+ await setupPrettier(targetDir);
600
+ }
601
+ currentStep = "config write";
602
+ await writeZuroConfig(targetDir, zuroConfig);
495
603
  spinner.succeed(chalk2.green("Project initialized successfully!"));
496
604
  console.log(`
497
605
  ${chalk2.bold("Next steps:")}`);
@@ -502,8 +610,15 @@ ${chalk2.bold("Next steps:")}`);
502
610
  console.log(`
503
611
  ${chalk2.dim("Add modules: zuro-cli add database, zuro-cli add auth")}`);
504
612
  } catch (error) {
505
- spinner.fail(chalk2.red("Failed to initialize project."));
506
- console.error(error);
613
+ spinner.fail(chalk2.red(`Failed during ${currentStep}.`));
614
+ const errorMessage = error instanceof Error ? error.message : String(error);
615
+ console.error(chalk2.red(errorMessage));
616
+ console.log(`
617
+ ${chalk2.bold("Retry:")}`);
618
+ if (targetDir !== cwd) {
619
+ console.log(chalk2.cyan(` cd ${projectName}`));
620
+ }
621
+ console.log(chalk2.cyan(" npx zuro-cli init"));
507
622
  }
508
623
  }
509
624
 
@@ -512,6 +627,7 @@ import prompts2 from "prompts";
512
627
  import ora2 from "ora";
513
628
  import path6 from "path";
514
629
  import fs6 from "fs-extra";
630
+ import { randomBytes } from "crypto";
515
631
 
516
632
  // src/utils/dependency.ts
517
633
  import fs5 from "fs-extra";
@@ -557,6 +673,10 @@ var resolveDependencies = async (moduleDependencies, cwd) => {
557
673
 
558
674
  // src/commands/add.ts
559
675
  import chalk4 from "chalk";
676
+ var DEFAULT_DATABASE_URLS = {
677
+ "database-pg": "postgresql://postgres@localhost:5432/mydb",
678
+ "database-mysql": "mysql://root@localhost:3306/mydb"
679
+ };
560
680
  function resolveSafeTargetPath2(projectRoot, srcDir, file) {
561
681
  const targetPath = path6.resolve(projectRoot, srcDir, file.target);
562
682
  const normalizedRoot = path6.resolve(projectRoot);
@@ -565,86 +685,338 @@ function resolveSafeTargetPath2(projectRoot, srcDir, file) {
565
685
  }
566
686
  return targetPath;
567
687
  }
688
+ function resolvePackageManager(projectRoot) {
689
+ if (fs6.existsSync(path6.join(projectRoot, "pnpm-lock.yaml"))) {
690
+ return "pnpm";
691
+ }
692
+ if (fs6.existsSync(path6.join(projectRoot, "bun.lockb")) || fs6.existsSync(path6.join(projectRoot, "bun.lock"))) {
693
+ return "bun";
694
+ }
695
+ if (fs6.existsSync(path6.join(projectRoot, "yarn.lock"))) {
696
+ return "yarn";
697
+ }
698
+ return "npm";
699
+ }
700
+ function parseDatabaseDialect(value) {
701
+ const normalized = value?.trim().toLowerCase();
702
+ if (!normalized) {
703
+ return null;
704
+ }
705
+ if (normalized === "pg" || normalized === "postgres" || normalized === "postgresql" || normalized === "database-pg") {
706
+ return "database-pg";
707
+ }
708
+ if (normalized === "mysql" || normalized === "database-mysql") {
709
+ return "database-mysql";
710
+ }
711
+ return null;
712
+ }
713
+ function isDatabaseModule(moduleName) {
714
+ return moduleName === "database-pg" || moduleName === "database-mysql";
715
+ }
716
+ function validateDatabaseUrl(rawUrl, moduleName) {
717
+ const dbUrl = rawUrl.trim();
718
+ if (!dbUrl) {
719
+ throw new Error("Database URL cannot be empty.");
720
+ }
721
+ let parsed;
722
+ try {
723
+ parsed = new URL(dbUrl);
724
+ } catch {
725
+ throw new Error(`Invalid database URL: '${dbUrl}'.`);
726
+ }
727
+ const protocol = parsed.protocol.toLowerCase();
728
+ if (moduleName === "database-pg" && protocol !== "postgresql:" && protocol !== "postgres:") {
729
+ throw new Error("PostgreSQL URL must start with postgres:// or postgresql://");
730
+ }
731
+ if (moduleName === "database-mysql" && protocol !== "mysql:") {
732
+ throw new Error("MySQL URL must start with mysql://");
733
+ }
734
+ return dbUrl;
735
+ }
736
+ async function detectInstalledDatabaseDialect(projectRoot, srcDir) {
737
+ const dbIndexPath = path6.join(projectRoot, srcDir, "db", "index.ts");
738
+ if (!fs6.existsSync(dbIndexPath)) {
739
+ return null;
740
+ }
741
+ const content = await fs6.readFile(dbIndexPath, "utf-8");
742
+ if (content.includes("drizzle-orm/node-postgres") || content.includes(`from "pg"`)) {
743
+ return "database-pg";
744
+ }
745
+ if (content.includes("drizzle-orm/mysql2") || content.includes(`from "mysql2`)) {
746
+ return "database-mysql";
747
+ }
748
+ return null;
749
+ }
750
+ async function backupDatabaseFiles(projectRoot, srcDir) {
751
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
752
+ const backupRoot = path6.join(projectRoot, ".zuro", "backups", `database-${timestamp}`);
753
+ const candidates = [
754
+ path6.join(projectRoot, srcDir, "db", "index.ts"),
755
+ path6.join(projectRoot, "drizzle.config.ts")
756
+ ];
757
+ let copied = false;
758
+ for (const filePath of candidates) {
759
+ if (!fs6.existsSync(filePath)) {
760
+ continue;
761
+ }
762
+ const relativePath = path6.relative(projectRoot, filePath);
763
+ const backupPath = path6.join(backupRoot, relativePath);
764
+ await fs6.ensureDir(path6.dirname(backupPath));
765
+ await fs6.copyFile(filePath, backupPath);
766
+ copied = true;
767
+ }
768
+ return copied ? backupRoot : null;
769
+ }
770
+ function databaseLabel(moduleName) {
771
+ return moduleName === "database-pg" ? "PostgreSQL" : "MySQL";
772
+ }
773
+ function getDatabaseSetupHint(moduleName, dbUrl) {
774
+ try {
775
+ const parsed = new URL(dbUrl);
776
+ const dbName = parsed.pathname.replace(/^\/+/, "") || "mydb";
777
+ if (moduleName === "database-pg") {
778
+ return `createdb ${dbName}`;
779
+ }
780
+ return `mysql -e "CREATE DATABASE IF NOT EXISTS ${dbName};"`;
781
+ } catch {
782
+ return moduleName === "database-pg" ? "createdb <database_name>" : `mysql -e "CREATE DATABASE IF NOT EXISTS <database_name>;"`;
783
+ }
784
+ }
785
+ function getModuleDocsPath(moduleName) {
786
+ if (isDatabaseModule(moduleName)) {
787
+ return "database";
788
+ }
789
+ return moduleName;
790
+ }
791
+ function escapeRegex(value) {
792
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
793
+ }
794
+ async function hasEnvVariable(projectRoot, key) {
795
+ const envPath = path6.join(projectRoot, ".env");
796
+ if (!await fs6.pathExists(envPath)) {
797
+ return false;
798
+ }
799
+ const content = await fs6.readFile(envPath, "utf-8");
800
+ const pattern = new RegExp(`^${escapeRegex(key)}=`, "m");
801
+ return pattern.test(content);
802
+ }
803
+ async function isLikelyEmptyDirectory(cwd) {
804
+ const entries = await fs6.readdir(cwd);
805
+ const ignored = /* @__PURE__ */ new Set([".ds_store", "thumbs.db"]);
806
+ return entries.filter((entry) => !ignored.has(entry.toLowerCase())).length === 0;
807
+ }
808
+ async function ensureSchemaExport(projectRoot, srcDir, schemaFileName) {
809
+ const schemaIndexPath = path6.join(projectRoot, srcDir, "db", "schema", "index.ts");
810
+ if (!await fs6.pathExists(schemaIndexPath)) {
811
+ return;
812
+ }
813
+ const exportLine = `export * from "./${schemaFileName}";`;
814
+ const content = await fs6.readFile(schemaIndexPath, "utf-8");
815
+ const normalized = content.replace(/\r\n/g, "\n");
816
+ const exportPattern = new RegExp(
817
+ `^\\s*export\\s*\\*\\s*from\\s*["']\\./${escapeRegex(schemaFileName)}["'];?\\s*$`,
818
+ "m"
819
+ );
820
+ if (exportPattern.test(normalized)) {
821
+ return;
822
+ }
823
+ let next = normalized.replace(/^\s*export\s*\{\s*\};?\s*$/m, "").trimEnd();
824
+ if (next.length > 0) {
825
+ next += "\n\n";
826
+ }
827
+ next += `${exportLine}
828
+ `;
829
+ await fs6.writeFile(schemaIndexPath, next);
830
+ }
568
831
  async function injectErrorHandler(projectRoot, srcDir) {
569
832
  const appPath = path6.join(projectRoot, srcDir, "app.ts");
570
833
  if (!fs6.existsSync(appPath)) {
571
834
  return false;
572
835
  }
573
836
  let content = await fs6.readFile(appPath, "utf-8");
574
- if (content.includes("errorHandler")) {
575
- return true;
576
- }
577
837
  const errorImport = `import { errorHandler, notFoundHandler } from "./middleware/error-handler";`;
578
- const importRegex = /^import .+ from .+;?\s*$/gm;
579
- let lastImportIndex = 0;
580
- let match;
581
- while ((match = importRegex.exec(content)) !== null) {
582
- lastImportIndex = match.index + match[0].length;
583
- }
584
- if (lastImportIndex > 0) {
585
- content = content.slice(0, lastImportIndex) + `
838
+ const hasErrorImport = content.includes(errorImport);
839
+ const hasNotFoundUse = /app\.use\(\s*notFoundHandler\s*\)/.test(content);
840
+ const hasErrorUse = /app\.use\(\s*errorHandler\s*\)/.test(content);
841
+ let modified = false;
842
+ let importInserted = hasErrorImport;
843
+ if (!hasErrorImport) {
844
+ const importRegex = /^import .+ from .+;?\s*$/gm;
845
+ let lastImportIndex = 0;
846
+ let match;
847
+ while ((match = importRegex.exec(content)) !== null) {
848
+ lastImportIndex = match.index + match[0].length;
849
+ }
850
+ if (lastImportIndex > 0) {
851
+ content = content.slice(0, lastImportIndex) + `
586
852
  ${errorImport}` + content.slice(lastImportIndex);
853
+ modified = true;
854
+ importInserted = true;
855
+ }
587
856
  }
588
- const errorSetup = `
857
+ let setupInserted = hasNotFoundUse && hasErrorUse;
858
+ if (!setupInserted) {
859
+ const setupLines = [];
860
+ if (!hasNotFoundUse) {
861
+ setupLines.push("app.use(notFoundHandler);");
862
+ }
863
+ if (!hasErrorUse) {
864
+ setupLines.push("app.use(errorHandler);");
865
+ }
866
+ const errorSetup = `
589
867
  // Error handling (must be last)
590
- app.use(notFoundHandler);
591
- app.use(errorHandler);
868
+ ${setupLines.join("\n")}
592
869
  `;
593
- const exportMatch = content.match(/export default app;?\s*$/m);
594
- if (exportMatch && exportMatch.index !== void 0) {
595
- content = content.slice(0, exportMatch.index) + errorSetup + "\n" + content.slice(exportMatch.index);
870
+ const exportMatch = content.match(/export default app;?\s*$/m);
871
+ if (exportMatch && exportMatch.index !== void 0) {
872
+ content = content.slice(0, exportMatch.index) + errorSetup + "\n" + content.slice(exportMatch.index);
873
+ modified = true;
874
+ setupInserted = true;
875
+ }
596
876
  }
597
- await fs6.writeFile(appPath, content);
598
- return true;
877
+ if (modified) {
878
+ await fs6.writeFile(appPath, content);
879
+ }
880
+ return importInserted && setupInserted;
599
881
  }
600
882
  async function injectAuthRoutes(projectRoot, srcDir) {
601
883
  const appPath = path6.join(projectRoot, srcDir, "app.ts");
602
- if (!fs6.existsSync(appPath)) {
884
+ if (!await fs6.pathExists(appPath)) {
603
885
  return false;
604
886
  }
605
- let content = await fs6.readFile(appPath, "utf-8");
606
- if (content.includes("routes/auth.routes")) {
607
- return true;
887
+ let appContent = await fs6.readFile(appPath, "utf-8");
888
+ const authHandlerImport = `import { toNodeHandler } from "better-auth/node";`;
889
+ const authImport = `import { auth } from "./lib/auth";`;
890
+ const routeIndexUserImport = `import userRoutes from "./user.routes";`;
891
+ const appUserImport = `import userRoutes from "./routes/user.routes";`;
892
+ let appModified = false;
893
+ const appendImport = (source, line) => {
894
+ if (source.includes(line)) {
895
+ return { source, inserted: true };
896
+ }
897
+ const importRegex = /^import .+ from .+;?\s*$/gm;
898
+ let lastImportIndex = 0;
899
+ let match;
900
+ while ((match = importRegex.exec(source)) !== null) {
901
+ lastImportIndex = match.index + match[0].length;
902
+ }
903
+ if (lastImportIndex <= 0) {
904
+ return { source, inserted: false };
905
+ }
906
+ return {
907
+ source: source.slice(0, lastImportIndex) + `
908
+ ${line}` + source.slice(lastImportIndex),
909
+ inserted: true
910
+ };
911
+ };
912
+ for (const importLine of [authHandlerImport, authImport]) {
913
+ const next = appendImport(appContent, importLine);
914
+ if (!next.inserted) {
915
+ return false;
916
+ }
917
+ if (next.source !== appContent) {
918
+ appContent = next.source;
919
+ appModified = true;
920
+ }
921
+ }
922
+ const hasAuthMount = /toNodeHandler\(\s*auth\s*\)/.test(appContent) && /\/api\/auth/.test(appContent);
923
+ if (!hasAuthMount) {
924
+ const authMountLine = "app.all(/^\\/api\\/auth(?:\\/.*)?$/, toNodeHandler(auth));\n";
925
+ const jsonIndex = appContent.search(/^\s*app\.use\(\s*express\.json\(\)\s*\);\s*$/m);
926
+ let insertionIndex = jsonIndex;
927
+ if (insertionIndex < 0) {
928
+ const healthIndex = appContent.search(/^\s*app\.get\(\s*["']\/health["']\s*,/m);
929
+ insertionIndex = healthIndex;
930
+ }
931
+ if (insertionIndex < 0) {
932
+ const exportMatch = appContent.match(/export default app;?\s*$/m);
933
+ insertionIndex = exportMatch?.index ?? -1;
934
+ }
935
+ if (insertionIndex < 0) {
936
+ return false;
937
+ }
938
+ appContent = appContent.slice(0, insertionIndex) + authMountLine + appContent.slice(insertionIndex);
939
+ appModified = true;
608
940
  }
609
- const authImport = `import authRoutes from "./routes/auth.routes";`;
610
- const userImport = `import userRoutes from "./routes/user.routes";`;
611
- const routeSetup = `
612
- // Auth routes
613
- app.use(authRoutes);
941
+ const routeIndexPath = path6.join(projectRoot, srcDir, "routes", "index.ts");
942
+ if (await fs6.pathExists(routeIndexPath)) {
943
+ let routeContent = await fs6.readFile(routeIndexPath, "utf-8");
944
+ let routeModified = false;
945
+ const userImportResult = appendImport(routeContent, routeIndexUserImport);
946
+ if (!userImportResult.inserted) {
947
+ return false;
948
+ }
949
+ if (userImportResult.source !== routeContent) {
950
+ routeContent = userImportResult.source;
951
+ routeModified = true;
952
+ }
953
+ const hasUserRoute = /rootRouter\.use\(\s*["']\/users["']\s*,\s*userRoutes\s*\)/.test(routeContent);
954
+ if (!hasUserRoute) {
955
+ const routeSetup = `
956
+ // User routes
957
+ rootRouter.use("/users", userRoutes);
958
+ `;
959
+ const exportMatch = routeContent.match(/export default rootRouter;?\s*$/m);
960
+ if (!exportMatch || exportMatch.index === void 0) {
961
+ return false;
962
+ }
963
+ routeContent = routeContent.slice(0, exportMatch.index) + routeSetup + "\n" + routeContent.slice(exportMatch.index);
964
+ routeModified = true;
965
+ }
966
+ if (routeModified) {
967
+ await fs6.writeFile(routeIndexPath, routeContent);
968
+ }
969
+ } else {
970
+ const hasUserRoute = /app\.use\(\s*["']\/api\/users["']\s*,\s*userRoutes\s*\)/.test(appContent);
971
+ if (!hasUserRoute) {
972
+ const exportMatch = appContent.match(/export default app;?\s*$/m);
973
+ if (!exportMatch || exportMatch.index === void 0) {
974
+ return false;
975
+ }
976
+ const routeSetup = `
977
+ // User routes
614
978
  app.use("/api/users", userRoutes);
615
979
  `;
616
- const importRegex = /^import .+ from .+;?\s*$/gm;
617
- let lastImportIndex = 0;
618
- let match;
619
- while ((match = importRegex.exec(content)) !== null) {
620
- lastImportIndex = match.index + match[0].length;
621
- }
622
- if (lastImportIndex > 0) {
623
- content = content.slice(0, lastImportIndex) + `
624
- ${authImport}
625
- ${userImport}` + content.slice(lastImportIndex);
626
- }
627
- const exportMatch = content.match(/export default app;?\s*$/m);
628
- if (exportMatch && exportMatch.index !== void 0) {
629
- content = content.slice(0, exportMatch.index) + routeSetup + "\n" + content.slice(exportMatch.index);
630
- }
631
- await fs6.writeFile(appPath, content);
980
+ appContent = appContent.slice(0, exportMatch.index) + routeSetup + "\n" + appContent.slice(exportMatch.index);
981
+ appModified = true;
982
+ }
983
+ const userImportResult = appendImport(appContent, appUserImport);
984
+ if (!userImportResult.inserted) {
985
+ return false;
986
+ }
987
+ if (userImportResult.source !== appContent) {
988
+ appContent = userImportResult.source;
989
+ appModified = true;
990
+ }
991
+ }
992
+ if (appModified) {
993
+ await fs6.writeFile(appPath, appContent);
994
+ }
632
995
  return true;
633
996
  }
634
997
  var add = async (moduleName) => {
635
998
  const projectRoot = process.cwd();
636
999
  const projectConfig = await readZuroConfig(projectRoot);
637
1000
  if (!projectConfig) {
1001
+ if (await isLikelyEmptyDirectory(projectRoot)) {
1002
+ showInitFirstMessage();
1003
+ return;
1004
+ }
638
1005
  showNonZuroProjectMessage();
639
1006
  return;
640
1007
  }
641
- let srcDir = projectConfig.srcDir || "src";
1008
+ const srcDir = projectConfig.srcDir || "src";
1009
+ let resolvedModuleName = moduleName;
1010
+ const parsedDialect = parseDatabaseDialect(moduleName);
1011
+ if (parsedDialect) {
1012
+ resolvedModuleName = parsedDialect;
1013
+ }
642
1014
  let customDbUrl;
643
- const DEFAULT_URLS = {
644
- "database-pg": "postgresql://root@localhost:5432/mydb",
645
- "database-mysql": "mysql://root@localhost:3306/mydb"
646
- };
647
- if (moduleName === "database") {
1015
+ let usedDefaultDbUrl = false;
1016
+ let databaseBackupPath = null;
1017
+ let generatedAuthSecret = false;
1018
+ let authDatabaseDialect = null;
1019
+ if (resolvedModuleName === "database") {
648
1020
  const variantResponse = await prompts2({
649
1021
  type: "select",
650
1022
  name: "variant",
@@ -655,22 +1027,39 @@ var add = async (moduleName) => {
655
1027
  ]
656
1028
  });
657
1029
  if (!variantResponse.variant) {
658
- process.exit(0);
1030
+ console.log(chalk4.yellow("Operation cancelled."));
1031
+ return;
659
1032
  }
660
- moduleName = variantResponse.variant;
661
- const defaultUrl = DEFAULT_URLS[moduleName];
662
- console.log(chalk4.dim(` Tip: Leave blank to use ${defaultUrl}
663
- `));
664
- const urlResponse = await prompts2({
665
- type: "text",
666
- name: "dbUrl",
667
- message: "Database URL",
668
- initial: ""
669
- });
670
- customDbUrl = urlResponse.dbUrl?.trim() || defaultUrl;
1033
+ resolvedModuleName = variantResponse.variant;
671
1034
  }
672
- if ((moduleName === "database-pg" || moduleName === "database-mysql") && customDbUrl === void 0) {
673
- const defaultUrl = DEFAULT_URLS[moduleName];
1035
+ if (isDatabaseModule(resolvedModuleName)) {
1036
+ const installedDialect = await detectInstalledDatabaseDialect(projectRoot, srcDir);
1037
+ if (installedDialect && installedDialect !== resolvedModuleName) {
1038
+ console.log(
1039
+ chalk4.yellow(
1040
+ `
1041
+ \u26A0 Existing database setup detected: ${databaseLabel(installedDialect)}.`
1042
+ )
1043
+ );
1044
+ console.log(
1045
+ chalk4.yellow(
1046
+ ` Switching to ${databaseLabel(resolvedModuleName)} will overwrite db files and drizzle config.
1047
+ `
1048
+ )
1049
+ );
1050
+ const switchResponse = await prompts2({
1051
+ type: "confirm",
1052
+ name: "proceed",
1053
+ message: "Continue and switch database dialect?",
1054
+ initial: false
1055
+ });
1056
+ if (!switchResponse.proceed) {
1057
+ console.log(chalk4.yellow("Operation cancelled."));
1058
+ return;
1059
+ }
1060
+ databaseBackupPath = await backupDatabaseFiles(projectRoot, srcDir);
1061
+ }
1062
+ const defaultUrl = DEFAULT_DATABASE_URLS[resolvedModuleName];
674
1063
  console.log(chalk4.dim(` Tip: Leave blank to use ${defaultUrl}
675
1064
  `));
676
1065
  const response = await prompts2({
@@ -679,53 +1068,82 @@ var add = async (moduleName) => {
679
1068
  message: "Database URL",
680
1069
  initial: ""
681
1070
  });
682
- customDbUrl = response.dbUrl?.trim() || defaultUrl;
1071
+ if (response.dbUrl === void 0) {
1072
+ console.log(chalk4.yellow("Operation cancelled."));
1073
+ return;
1074
+ }
1075
+ const enteredUrl = response.dbUrl?.trim() || "";
1076
+ usedDefaultDbUrl = enteredUrl.length === 0;
1077
+ customDbUrl = validateDatabaseUrl(enteredUrl || defaultUrl, resolvedModuleName);
683
1078
  }
684
- const spinner = ora2(`Checking registry for ${moduleName}...`).start();
1079
+ const pm = resolvePackageManager(projectRoot);
1080
+ const spinner = ora2(`Checking registry for ${resolvedModuleName}...`).start();
1081
+ let currentStep = "package manager preflight";
685
1082
  try {
1083
+ spinner.text = `Checking ${pm} availability...`;
1084
+ await ensurePackageManagerAvailable(pm);
1085
+ currentStep = "registry fetch";
1086
+ spinner.text = `Checking registry for ${resolvedModuleName}...`;
686
1087
  const registryContext = await fetchRegistry();
687
- const module = registryContext.manifest.modules[moduleName];
1088
+ const module = registryContext.manifest.modules[resolvedModuleName];
688
1089
  if (!module) {
689
- spinner.fail(`Module '${moduleName}' not found.`);
1090
+ spinner.fail(`Module '${resolvedModuleName}' not found.`);
690
1091
  return;
691
1092
  }
692
- spinner.succeed(`Found module: ${chalk4.cyan(moduleName)}`);
1093
+ spinner.succeed(`Found module: ${chalk4.cyan(resolvedModuleName)}`);
693
1094
  const moduleDeps = module.moduleDependencies || [];
1095
+ currentStep = "module dependency resolution";
694
1096
  await resolveDependencies(moduleDeps, projectRoot);
1097
+ currentStep = "dependency installation";
695
1098
  spinner.start("Installing dependencies...");
696
- let pm = "npm";
697
- if (fs6.existsSync(path6.join(projectRoot, "pnpm-lock.yaml"))) {
698
- pm = "pnpm";
699
- } else if (fs6.existsSync(path6.join(projectRoot, "bun.lockb"))) {
700
- pm = "bun";
701
- } else if (fs6.existsSync(path6.join(projectRoot, "yarn.lock"))) {
702
- pm = "yarn";
703
- }
704
1099
  await installDependencies(pm, module.dependencies || [], projectRoot);
705
1100
  await installDependencies(pm, module.devDependencies || [], projectRoot, { dev: true });
706
1101
  spinner.succeed("Dependencies installed");
1102
+ currentStep = "module scaffolding";
707
1103
  spinner.start("Scaffolding files...");
1104
+ if (resolvedModuleName === "auth") {
1105
+ authDatabaseDialect = await detectInstalledDatabaseDialect(projectRoot, srcDir);
1106
+ }
708
1107
  for (const file of module.files) {
709
- const content = await fetchFile(file.path, {
1108
+ let fetchPath = file.path;
1109
+ let expectedSha256 = file.sha256;
1110
+ let expectedSize = file.size;
1111
+ if (resolvedModuleName === "auth" && file.target === "db/schema/auth.ts" && authDatabaseDialect === "database-mysql") {
1112
+ fetchPath = "express/db/schema/auth.mysql.ts";
1113
+ expectedSha256 = void 0;
1114
+ expectedSize = void 0;
1115
+ }
1116
+ let content = await fetchFile(fetchPath, {
710
1117
  baseUrl: registryContext.fileBaseUrl,
711
- expectedSha256: file.sha256,
712
- expectedSize: file.size
1118
+ expectedSha256,
1119
+ expectedSize
713
1120
  });
1121
+ if (isDatabaseModule(resolvedModuleName) && file.target === "../drizzle.config.ts") {
1122
+ const normalizedSrcDir = srcDir.replace(/\\/g, "/");
1123
+ content = content.replace(
1124
+ /schema:\s*["'][^"']+["']/,
1125
+ `schema: "./${normalizedSrcDir}/db/schema/*"`
1126
+ );
1127
+ }
714
1128
  const targetPath = resolveSafeTargetPath2(projectRoot, srcDir, file);
715
1129
  await fs6.ensureDir(path6.dirname(targetPath));
716
1130
  await fs6.writeFile(targetPath, content);
717
1131
  }
1132
+ const schemaExports = module.files.map((file) => file.target.replace(/\\/g, "/")).filter((target) => /^db\/schema\/[^/]+\.ts$/.test(target)).map((target) => path6.posix.basename(target, ".ts")).filter((name) => name !== "index");
1133
+ for (const schemaFileName of schemaExports) {
1134
+ await ensureSchemaExport(projectRoot, srcDir, schemaFileName);
1135
+ }
718
1136
  spinner.succeed("Files generated");
719
- if (moduleName === "auth") {
720
- spinner.start("Configuring routes in app.ts...");
1137
+ if (resolvedModuleName === "auth") {
1138
+ spinner.start("Configuring routes...");
721
1139
  const injected = await injectAuthRoutes(projectRoot, srcDir);
722
1140
  if (injected) {
723
- spinner.succeed("Routes configured in app.ts");
1141
+ spinner.succeed("Routes configured");
724
1142
  } else {
725
- spinner.warn("Could not find app.ts - routes need manual setup");
1143
+ spinner.warn("Could not configure routes automatically");
726
1144
  }
727
1145
  }
728
- if (moduleName === "error-handler") {
1146
+ if (resolvedModuleName === "error-handler") {
729
1147
  spinner.start("Configuring error handler in app.ts...");
730
1148
  const injected = await injectErrorHandler(projectRoot, srcDir);
731
1149
  if (injected) {
@@ -734,78 +1152,63 @@ var add = async (moduleName) => {
734
1152
  spinner.warn("Could not find app.ts - error handler needs manual setup");
735
1153
  }
736
1154
  }
737
- const envConfig = ENV_CONFIGS[moduleName];
1155
+ const envConfig = ENV_CONFIGS[resolvedModuleName];
738
1156
  if (envConfig) {
1157
+ currentStep = "environment configuration";
739
1158
  spinner.start("Updating environment configuration...");
740
1159
  const envVars = { ...envConfig.envVars };
741
- if (customDbUrl && (moduleName === "database-pg" || moduleName === "database-mysql")) {
1160
+ if (customDbUrl && isDatabaseModule(resolvedModuleName)) {
742
1161
  envVars.DATABASE_URL = customDbUrl;
743
1162
  }
744
- await updateEnvFile(projectRoot, envVars);
1163
+ if (resolvedModuleName === "auth") {
1164
+ const hasExistingSecret = await hasEnvVariable(projectRoot, "BETTER_AUTH_SECRET");
1165
+ if (!hasExistingSecret) {
1166
+ envVars.BETTER_AUTH_SECRET = randomBytes(32).toString("hex");
1167
+ generatedAuthSecret = true;
1168
+ }
1169
+ }
1170
+ await updateEnvFile(projectRoot, envVars, true, {
1171
+ overwriteExisting: isDatabaseModule(resolvedModuleName)
1172
+ });
745
1173
  await updateEnvSchema(projectRoot, srcDir, envConfig.schemaFields);
746
1174
  spinner.succeed("Environment configured");
747
1175
  }
748
1176
  console.log(chalk4.green(`
749
- \u2714 ${moduleName} added successfully!
1177
+ \u2714 ${resolvedModuleName} added successfully!
750
1178
  `));
751
- if (moduleName === "auth") {
752
- console.log(chalk4.bold("\u{1F4CB} Next Steps:\n"));
753
- console.log(chalk4.yellow("1. Update your .env file:"));
754
- console.log(
755
- chalk4.dim(" We added placeholder values. Update BETTER_AUTH_SECRET with a secure key.\n")
1179
+ if (databaseBackupPath) {
1180
+ console.log(chalk4.blue(`\u2139 Backup created at: ${databaseBackupPath}
1181
+ `));
1182
+ }
1183
+ const docsPath = getModuleDocsPath(resolvedModuleName);
1184
+ const docsUrl = `https://zuro-cli.devbybriyan.com/docs/${docsPath}`;
1185
+ console.log(chalk4.blue(`\u2139 Docs: ${docsUrl}`));
1186
+ if (isDatabaseModule(resolvedModuleName)) {
1187
+ if (usedDefaultDbUrl) {
1188
+ console.log(chalk4.yellow("\u2139 Review DATABASE_URL in .env if your local DB config differs."));
1189
+ }
1190
+ const setupHint = getDatabaseSetupHint(
1191
+ resolvedModuleName,
1192
+ customDbUrl || DEFAULT_DATABASE_URLS[resolvedModuleName]
756
1193
  );
757
- console.log(chalk4.yellow("2. Run database migrations:"));
758
- console.log(chalk4.cyan(" npx drizzle-kit generate"));
759
- console.log(chalk4.cyan(" npx drizzle-kit migrate\n"));
760
- console.log(chalk4.yellow("3. Available endpoints:"));
761
- console.log(chalk4.dim(" POST /auth/sign-up/email - Register"));
762
- console.log(chalk4.dim(" POST /auth/sign-in/email - Login"));
763
- console.log(chalk4.dim(" POST /auth/sign-out - Logout"));
764
- console.log(chalk4.dim(" GET /api/users/me - Current user\n"));
765
- } else if (moduleName === "error-handler") {
766
- console.log(chalk4.bold("\u{1F4CB} Usage:\n"));
767
- console.log(chalk4.yellow("Throw errors in your controllers:"));
768
- console.log(chalk4.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
769
- console.log(chalk4.white(` import { UnauthorizedError, NotFoundError } from "./lib/errors";`));
770
- console.log("");
771
- console.log(chalk4.white(` throw new UnauthorizedError("Invalid credentials");`));
772
- console.log(chalk4.white(` throw new NotFoundError("User not found");`));
773
- console.log(chalk4.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
774
- console.log(chalk4.yellow("Available error classes:"));
775
- console.log(chalk4.dim(" BadRequestError (400)"));
776
- console.log(chalk4.dim(" UnauthorizedError (401)"));
777
- console.log(chalk4.dim(" ForbiddenError (403)"));
778
- console.log(chalk4.dim(" NotFoundError (404)"));
779
- console.log(chalk4.dim(" ConflictError (409)"));
780
- console.log(chalk4.dim(" ValidationError (422)"));
781
- console.log(chalk4.dim(" InternalServerError (500)\n"));
782
- console.log(chalk4.yellow("Wrap async handlers:"));
783
- console.log(chalk4.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
784
- console.log(chalk4.white(` import { asyncHandler } from "./middleware/error-handler";`));
785
- console.log("");
786
- console.log(chalk4.white(` router.get("/users", asyncHandler(async (req, res) => {`));
787
- console.log(chalk4.white(" // errors auto-caught"));
788
- console.log(chalk4.white(" }));"));
789
- console.log(chalk4.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
790
- } else if (moduleName.includes("database")) {
791
- console.log(chalk4.bold("\u{1F4CB} Next Steps:\n"));
792
- let stepNum = 1;
793
- if (!customDbUrl) {
794
- console.log(chalk4.yellow(`${stepNum}. Update DATABASE_URL in .env:`));
795
- console.log(
796
- chalk4.dim(" We added a placeholder. Update with your actual database credentials.\n")
797
- );
798
- stepNum++;
1194
+ console.log(chalk4.yellow(`\u2139 Ensure DB exists: ${setupHint}`));
1195
+ console.log(chalk4.yellow("\u2139 Run migrations: npx drizzle-kit generate && npx drizzle-kit migrate"));
1196
+ }
1197
+ if (resolvedModuleName === "auth") {
1198
+ if (generatedAuthSecret) {
1199
+ console.log(chalk4.yellow("\u2139 BETTER_AUTH_SECRET was generated automatically."));
1200
+ } else {
1201
+ console.log(chalk4.yellow("\u2139 Review BETTER_AUTH_SECRET and BETTER_AUTH_URL in .env."));
799
1202
  }
800
- console.log(chalk4.yellow(`${stepNum}. Create schemas in src/db/schema/:`));
801
- console.log(chalk4.dim(" Add table files and export from index.ts\n"));
802
- stepNum++;
803
- console.log(chalk4.yellow(`${stepNum}. Run migrations:`));
804
- console.log(chalk4.cyan(" npx drizzle-kit generate"));
805
- console.log(chalk4.cyan(" npx drizzle-kit migrate\n"));
1203
+ console.log(chalk4.yellow("\u2139 Run migrations: npx drizzle-kit generate && npx drizzle-kit migrate"));
806
1204
  }
807
1205
  } catch (error) {
808
- spinner.fail(`Failed to add module: ${error.message}`);
1206
+ spinner.fail(chalk4.red(`Failed during ${currentStep}.`));
1207
+ const errorMessage = error instanceof Error ? error.message : String(error);
1208
+ console.error(chalk4.red(errorMessage));
1209
+ console.log(`
1210
+ ${chalk4.bold("Retry:")}`);
1211
+ console.log(chalk4.cyan(` npx zuro-cli add ${resolvedModuleName}`));
809
1212
  }
810
1213
  };
811
1214