create-vexcms 0.0.20 → 0.1.0-alpha.3

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.
Files changed (157) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +98 -80
  3. package/dist/index.js +974 -639
  4. package/dist/index.mjs +30976 -0
  5. package/package.json +35 -15
  6. package/templates/base-nextjs/README.md +59 -203
  7. package/templates/base-nextjs/_env.example +7 -9
  8. package/templates/base-nextjs/_gitignore +41 -1
  9. package/templates/base-nextjs/convex/_generated/api.d.ts +6 -62
  10. package/templates/base-nextjs/convex/_generated/server.d.ts +19 -0
  11. package/templates/base-nextjs/convex/_generated/server.js +8 -0
  12. package/templates/base-nextjs/convex/auth/api.ts +49 -3
  13. package/templates/base-nextjs/convex/auth/db.ts +42 -300
  14. package/templates/base-nextjs/convex/auth/index.ts +11 -7
  15. package/templates/base-nextjs/convex/auth/options.ts +5 -29
  16. package/templates/base-nextjs/convex/auth/plugins/index.ts +15 -5
  17. package/templates/base-nextjs/convex/auth/sessions.ts +9 -15
  18. package/templates/base-nextjs/convex/http.ts +18 -3
  19. package/templates/base-nextjs/convex/schema.ts +104 -2
  20. package/templates/base-nextjs/convex/tsconfig.json +2 -2
  21. package/templates/base-nextjs/convex/vex/firstUser.ts +10 -84
  22. package/templates/base-nextjs/convex/vex/globals.ts +19 -82
  23. package/templates/base-nextjs/convex/vex/media.ts +19 -246
  24. package/templates/base-nextjs/convex/vex.schema.ts +138 -0
  25. package/templates/base-nextjs/convex/vex.ts +19 -0
  26. package/templates/base-nextjs/convex.json +6 -0
  27. package/templates/base-nextjs/eslint.config.mjs +1 -21
  28. package/templates/base-nextjs/next.config.ts +10 -0
  29. package/templates/base-nextjs/package.json +46 -48
  30. package/templates/base-nextjs/src/app/(frontend)/@auth/(...)auth/[pathname]/view.tsx +17 -5
  31. package/templates/base-nextjs/src/app/(frontend)/auth/[pathname]/view.tsx +2 -4
  32. package/templates/base-nextjs/src/app/(frontend)/layout.tsx +12 -2
  33. package/templates/base-nextjs/src/app/(frontend)/page.tsx +2 -113
  34. package/templates/base-nextjs/src/app/(vexcms)/admin/[[...path]]/page.tsx +40 -0
  35. package/templates/base-nextjs/src/app/(vexcms)/admin/clientProviders.tsx +22 -0
  36. package/templates/base-nextjs/src/app/(vexcms)/admin/layout.tsx +19 -0
  37. package/templates/base-nextjs/src/app/globals.css +240 -104
  38. package/templates/base-nextjs/src/app/layout.tsx +23 -13
  39. package/templates/base-nextjs/src/app/unauthorized/page.tsx +24 -0
  40. package/templates/base-nextjs/src/auth/access.ts +46 -0
  41. package/templates/base-nextjs/src/auth/client.tsx +17 -5
  42. package/templates/base-nextjs/src/auth/hasPermission.ts +39 -0
  43. package/templates/base-nextjs/src/auth/serverUtils.ts +15 -25
  44. package/templates/base-nextjs/src/components/WelcomePage.tsx +116 -0
  45. package/templates/base-nextjs/src/components/component-example.tsx +56 -464
  46. package/templates/base-nextjs/src/components/providers/auth.tsx +13 -0
  47. package/templates/base-nextjs/src/components/providers/client.tsx +6 -1
  48. package/templates/base-nextjs/src/components/providers/convex.tsx +53 -21
  49. package/templates/base-nextjs/src/components/providers/server.tsx +10 -7
  50. package/templates/{marketing-site → base-nextjs}/src/components/ui/accordion.tsx +30 -32
  51. package/templates/base-nextjs/src/components/ui/badge.tsx +10 -13
  52. package/templates/base-nextjs/src/components/ui/input.tsx +35 -5
  53. package/templates/base-nextjs/src/context/AuthContext.tsx +29 -0
  54. package/templates/base-nextjs/src/db/constants/auth.ts +0 -1
  55. package/templates/base-nextjs/src/db/constants/index.ts +22 -35
  56. package/templates/base-nextjs/src/lib/utils.ts +0 -12
  57. package/templates/base-nextjs/src/proxy.ts +97 -12
  58. package/templates/base-nextjs/src/vex.config.ts +35 -0
  59. package/templates/base-nextjs/src/vex.types.ts +293 -0
  60. package/templates/base-nextjs/src/vexcms/collections/images.ts +15 -0
  61. package/templates/base-nextjs/src/vexcms/collections/index.ts +2 -1
  62. package/templates/base-nextjs/src/vexcms/collections/users.ts +7 -35
  63. package/templates/base-nextjs/tsconfig.json +3 -0
  64. package/templates/marketing-site/convex/_generated/api.d.ts +81 -0
  65. package/templates/marketing-site/convex/_generated/api.js +23 -0
  66. package/templates/marketing-site/convex/_generated/dataModel.d.ts +60 -0
  67. package/templates/marketing-site/convex/_generated/server.d.ts +162 -0
  68. package/templates/marketing-site/convex/_generated/server.js +101 -0
  69. package/templates/marketing-site/convex/footers.ts +11 -7
  70. package/templates/marketing-site/convex/headers.ts +15 -5
  71. package/templates/marketing-site/convex/pages.ts +20 -35
  72. package/templates/marketing-site/convex/schema.ts +119 -0
  73. package/templates/marketing-site/convex/seed.ts +582 -35
  74. package/templates/marketing-site/convex/siteSettings.ts +19 -5
  75. package/templates/marketing-site/convex/theme.ts +55 -61
  76. package/templates/marketing-site/convex/vex.schema.ts +355 -0
  77. package/templates/marketing-site/src/app/(frontend)/PageContent.tsx +27 -160
  78. package/templates/marketing-site/src/app/(frontend)/[slug]/page.tsx +6 -9
  79. package/templates/marketing-site/src/app/(frontend)/layout.tsx +4 -2
  80. package/templates/marketing-site/src/app/(frontend)/page.tsx +2 -5
  81. package/templates/marketing-site/src/components/SiteFooter.tsx +6 -3
  82. package/templates/marketing-site/src/components/SiteHeader.tsx +6 -3
  83. package/templates/marketing-site/src/components/ThemeLive.tsx +59 -0
  84. package/templates/marketing-site/src/components/ThemeStyle.tsx +45 -89
  85. package/templates/marketing-site/src/components/motion-primitives/animated-group.tsx +44 -120
  86. package/templates/marketing-site/src/components/motion-primitives/reveal.module.css +32 -0
  87. package/templates/marketing-site/src/components/motion-primitives/text-effect.tsx +41 -267
  88. package/templates/marketing-site/src/lib/metadata.ts +51 -72
  89. package/templates/marketing-site/src/vex.config.ts +35 -0
  90. package/templates/marketing-site/src/vex.types.ts +634 -0
  91. package/templates/marketing-site/src/vexcms/api.ts +23 -0
  92. package/templates/marketing-site/src/vexcms/blocks/CTA/config.ts +3 -3
  93. package/templates/marketing-site/src/vexcms/blocks/CTA/index.tsx +2 -2
  94. package/templates/marketing-site/src/vexcms/blocks/FAQ/config.ts +4 -4
  95. package/templates/marketing-site/src/vexcms/blocks/FAQ/index.tsx +2 -2
  96. package/templates/marketing-site/src/vexcms/blocks/Features/config.ts +4 -8
  97. package/templates/marketing-site/src/vexcms/blocks/Features/index.tsx +2 -2
  98. package/templates/marketing-site/src/vexcms/blocks/Footer/config.ts +7 -11
  99. package/templates/marketing-site/src/vexcms/blocks/Footer/index.tsx +1 -1
  100. package/templates/marketing-site/src/vexcms/blocks/Header/config.ts +9 -9
  101. package/templates/marketing-site/src/vexcms/blocks/Header/index.tsx +4 -5
  102. package/templates/marketing-site/src/vexcms/blocks/Hero/config.ts +3 -4
  103. package/templates/marketing-site/src/vexcms/blocks/Hero/index.tsx +12 -55
  104. package/templates/marketing-site/src/vexcms/blocks/HowItWorks/config.ts +4 -9
  105. package/templates/marketing-site/src/vexcms/blocks/HowItWorks/index.tsx +3 -6
  106. package/templates/marketing-site/src/vexcms/blocks/Roadmap/config.ts +28 -68
  107. package/templates/marketing-site/src/vexcms/blocks/Roadmap/index.tsx +13 -43
  108. package/templates/marketing-site/src/vexcms/blocks/config.ts +7 -22
  109. package/templates/marketing-site/src/vexcms/blocks/constants.ts +2 -3
  110. package/templates/marketing-site/src/vexcms/blocks/index.ts +9 -8
  111. package/templates/marketing-site/src/vexcms/collections/footers.ts +1 -1
  112. package/templates/marketing-site/src/vexcms/collections/headers.ts +1 -1
  113. package/templates/marketing-site/src/vexcms/collections/index.ts +2 -1
  114. package/templates/marketing-site/src/vexcms/collections/pages.ts +12 -21
  115. package/templates/marketing-site/src/vexcms/collections/themeColors.ts +118 -0
  116. package/templates/marketing-site/src/vexcms/collections/themes.ts +29 -111
  117. package/templates/marketing-site/src/vexcms/globals/index.ts +1 -1
  118. package/templates/marketing-site/src/vexcms/globals/siteSettings.ts +43 -27
  119. package/templates/base-nextjs/convex/auth/adapter/index.ts +0 -230
  120. package/templates/base-nextjs/convex/auth/adapter/utils.ts +0 -547
  121. package/templates/base-nextjs/convex/auth/config.ts +0 -10
  122. package/templates/base-nextjs/convex/vex/auth.ts +0 -38
  123. package/templates/base-nextjs/convex/vex/collections.ts +0 -333
  124. package/templates/base-nextjs/convex/vex/helpers.ts +0 -33
  125. package/templates/base-nextjs/convex/vex/impersonation.ts +0 -51
  126. package/templates/base-nextjs/convex/vex/migrate.ts +0 -74
  127. package/templates/base-nextjs/convex/vex/model/collections.ts +0 -196
  128. package/templates/base-nextjs/convex/vex/model/media.ts +0 -87
  129. package/templates/base-nextjs/convex/vex/model/versions.ts +0 -254
  130. package/templates/base-nextjs/convex/vex/previewSnapshot.ts +0 -53
  131. package/templates/base-nextjs/convex/vex/versions.ts +0 -648
  132. package/templates/base-nextjs/src/app/admin/AdminLayoutWrapper.tsx +0 -33
  133. package/templates/base-nextjs/src/app/admin/AdminPageWrapper.tsx +0 -38
  134. package/templates/base-nextjs/src/app/admin/RichTextFieldWithMedia.tsx +0 -101
  135. package/templates/base-nextjs/src/app/admin/[[...path]]/page.tsx +0 -15
  136. package/templates/base-nextjs/src/app/admin/layout.tsx +0 -51
  137. package/templates/base-nextjs/src/app/favicon.ico +0 -0
  138. package/templates/base-nextjs/src/components/providers/theme.tsx +0 -11
  139. package/templates/base-nextjs/src/components/ui/theme-toggle.tsx +0 -67
  140. package/templates/base-nextjs/src/db/types.ts +0 -6
  141. package/templates/base-nextjs/src/vexcms/access.ts +0 -44
  142. package/templates/base-nextjs/src/vexcms/auth.ts +0 -4
  143. package/templates/base-nextjs/src/vexcms/collections/media.ts +0 -15
  144. package/templates/base-nextjs/vex.config.ts +0 -25
  145. package/templates/marketing-site/public/favicons/favicon.ico +0 -0
  146. package/templates/marketing-site/src/app/(frontend)/preview/[slug]/PreviewPageContent.tsx +0 -55
  147. package/templates/marketing-site/src/app/(frontend)/preview/[slug]/page.tsx +0 -27
  148. package/templates/marketing-site/src/app/(frontend)/preview/layout.tsx +0 -18
  149. package/templates/marketing-site/src/app/admin/AdminLayoutWrapper.tsx +0 -71
  150. package/templates/marketing-site/src/components/ThemeInjector.tsx +0 -128
  151. package/templates/marketing-site/src/components/admin/IconPickerField.tsx +0 -134
  152. package/templates/marketing-site/src/components/admin/ThemeImport.tsx +0 -174
  153. package/templates/marketing-site/src/components/admin/ThemeImportField.tsx +0 -33
  154. package/templates/marketing-site/src/db/constants/index.ts +0 -52
  155. package/templates/marketing-site/src/lib/colorConvert.ts +0 -171
  156. package/templates/marketing-site/src/vexcms/access.ts +0 -36
  157. package/templates/marketing-site/vex.config.ts +0 -32
package/dist/index.js CHANGED
@@ -4,8 +4,8 @@
4
4
  import { Command } from "commander";
5
5
  import chalk2 from "chalk";
6
6
  import { input, select, confirm, checkbox } from "@inquirer/prompts";
7
- import path2 from "path";
8
- import fs5 from "fs-extra";
7
+ import path3 from "path";
8
+ import fs6 from "fs-extra";
9
9
 
10
10
  // src/utils/validation.ts
11
11
  import validateNpmPackageName from "validate-npm-package-name";
@@ -32,11 +32,11 @@ async function isDirectoryEmpty(dirPath) {
32
32
  const files = await fs.readdir(dirPath);
33
33
  const visibleFiles = files.filter((file) => !file.startsWith("."));
34
34
  return visibleFiles.length === 0;
35
- } catch (error) {
35
+ } catch {
36
36
  return true;
37
37
  }
38
38
  }
39
- function resolveProjectName(input2, cwd) {
39
+ function resolveProjectName(input2, _cwd) {
40
40
  return input2;
41
41
  }
42
42
 
@@ -75,15 +75,60 @@ function displaySuccessMessage(projectName, targetPath, isCurrentDir) {
75
75
  console.log();
76
76
  }
77
77
 
78
+ // src/helpers/monorepo.ts
79
+ import fs2 from "fs-extra";
80
+ import path from "path";
81
+ import { parse as parseYaml } from "yaml";
82
+ import sortPackageJson from "sort-package-json";
83
+ async function findWorkspaceRoot(props) {
84
+ const { cwd } = props;
85
+ let dir = path.resolve(cwd);
86
+ while (true) {
87
+ if (await fs2.pathExists(path.join(dir, "pnpm-workspace.yaml"))) {
88
+ return dir;
89
+ }
90
+ const parent = path.dirname(dir);
91
+ if (parent === dir) return null;
92
+ dir = parent;
93
+ }
94
+ }
95
+ async function readWorkspaceCatalog(props) {
96
+ const { workspaceRoot } = props;
97
+ const raw = await fs2.readFile(path.join(workspaceRoot, "pnpm-workspace.yaml"), "utf-8");
98
+ const parsed = parseYaml(raw);
99
+ return parsed.catalog ?? {};
100
+ }
101
+ var MONOREPO_DEPENDENCY_FIELDS = ["dependencies", "devDependencies"];
102
+ function rewriteManifestForMonorepo(props) {
103
+ const { manifest, catalog } = props;
104
+ const rewritten = { ...manifest };
105
+ for (const field of MONOREPO_DEPENDENCY_FIELDS) {
106
+ const deps = manifest[field];
107
+ if (!deps) continue;
108
+ const rewrittenDeps = {};
109
+ for (const [name, version] of Object.entries(deps)) {
110
+ if (name.startsWith("@vexcms/")) {
111
+ rewrittenDeps[name] = "workspace:*";
112
+ } else if (name in catalog) {
113
+ rewrittenDeps[name] = "catalog:";
114
+ } else {
115
+ rewrittenDeps[name] = version;
116
+ }
117
+ }
118
+ rewritten[field] = rewrittenDeps;
119
+ }
120
+ return sortPackageJson(rewritten);
121
+ }
122
+
78
123
  // src/installers/base.ts
79
- import fs3 from "fs-extra";
124
+ import fs5 from "fs-extra";
80
125
  import { execa } from "execa";
81
126
  import ora from "ora";
82
127
  import crypto from "crypto";
83
- import path from "path";
128
+ import path2 from "path";
84
129
 
85
130
  // src/helpers/fileOperations.ts
86
- import fs2 from "fs-extra";
131
+ import fs3 from "fs-extra";
87
132
  import { join, dirname } from "path";
88
133
  import { fileURLToPath } from "url";
89
134
  async function copyTemplate(framework, targetPath) {
@@ -91,10 +136,10 @@ async function copyTemplate(framework, targetPath) {
91
136
  const __dirname = dirname(__filename);
92
137
  const templateDir = framework === "tanstack" ? "base-tanstack" : "base-nextjs";
93
138
  let templatePath = join(__dirname, "../../templates", templateDir);
94
- if (!fs2.existsSync(templatePath)) {
139
+ if (!fs3.existsSync(templatePath)) {
95
140
  templatePath = join(__dirname, "../templates", templateDir);
96
141
  }
97
- await fs2.copy(templatePath, targetPath, {
142
+ await fs3.copy(templatePath, targetPath, {
98
143
  overwrite: false,
99
144
  errorOnExist: false,
100
145
  filter: (src) => {
@@ -105,8 +150,8 @@ async function copyTemplate(framework, targetPath) {
105
150
  });
106
151
  const sourceGitignore = join(templatePath, "_gitignore");
107
152
  const targetGitignore = join(targetPath, ".gitignore");
108
- if (await fs2.pathExists(sourceGitignore)) {
109
- await fs2.copy(sourceGitignore, targetGitignore, {
153
+ if (await fs3.pathExists(sourceGitignore)) {
154
+ await fs3.copy(sourceGitignore, targetGitignore, {
110
155
  overwrite: false,
111
156
  errorOnExist: false
112
157
  });
@@ -119,521 +164,196 @@ async function copyTemplate(framework, targetPath) {
119
164
  for (const [from, to] of dotfileRenames) {
120
165
  const source = join(templatePath, from);
121
166
  const target = join(targetPath, to);
122
- if (await fs2.pathExists(source)) {
123
- await fs2.copy(source, target, { overwrite: false, errorOnExist: false });
167
+ if (await fs3.pathExists(source)) {
168
+ await fs3.copy(source, target, { overwrite: false, errorOnExist: false });
124
169
  }
125
170
  }
126
171
  }
127
172
  async function overlayTemplate({ overlayDir, targetDir }) {
128
- await fs2.copy(overlayDir, targetDir, { overwrite: true });
173
+ await fs3.copy(overlayDir, targetDir, { overwrite: true });
129
174
  }
130
175
 
131
- // src/installers/base.ts
132
- var VexFrameworkInstaller = class {
133
- constructor(targetPath, projectName) {
134
- this.targetPath = targetPath;
135
- this.projectName = projectName;
136
- }
137
- /**
138
- * Copy base template files to target directory
139
- */
140
- async copyBaseFiles() {
141
- await copyTemplate(this.frameworkName, this.targetPath);
142
- }
143
- /**
144
- * Apply a template overlay on top of the base template.
145
- * Merges overlay files onto the already-copied base — existing files
146
- * not in the overlay are left untouched.
147
- */
148
- async applyTemplateOverlay(overlay) {
149
- const { dirname: dirname2 } = await import("path");
150
- const { fileURLToPath: fileURLToPath2 } = await import("url");
151
- const __filename = fileURLToPath2(import.meta.url);
152
- const __dirname = dirname2(__filename);
153
- let overlayDir = path.join(__dirname, "../../templates", overlay);
154
- if (!await fs3.pathExists(overlayDir)) {
155
- overlayDir = path.join(__dirname, "../templates", overlay);
156
- }
157
- await overlayTemplate({ overlayDir, targetDir: this.targetPath });
158
- }
159
- /**
160
- * Update the package.json name field and vex:update script
161
- * to match the detected package manager.
162
- */
163
- async updatePackageName(name) {
164
- const pkgPath = path.join(this.targetPath, "package.json");
165
- const pkg = await fs3.readJson(pkgPath);
166
- pkg.name = name;
167
- const pm = this.detectPackageManager();
168
- if (pkg.scripts?.["vex:update"] && pm !== "pnpm") {
169
- const addCmd = pm === "yarn" ? "yarn add" : pm === "bun" ? "bun add" : "npm install";
170
- pkg.scripts["vex:update"] = pkg.scripts["vex:update"].replace(/^pnpm add/, addCmd);
171
- }
172
- await fs3.writeJson(pkgPath, pkg, { spaces: 2 });
173
- }
174
- /**
175
- * Configure the dev server port.
176
- * Updates package.json dev script and creates .env.local with NEXT_PUBLIC_SITE_URL.
177
- */
178
- async configurePort(port) {
179
- const pkgPath = path.join(this.targetPath, "package.json");
180
- const pkg = await fs3.readJson(pkgPath);
181
- if (pkg.scripts?.dev) {
182
- if (pkg.scripts.dev.includes("--port")) {
183
- pkg.scripts.dev = pkg.scripts.dev.replace(/--port[= ]\d+/, `--port=${port}`);
184
- } else {
185
- pkg.scripts.dev = `${pkg.scripts.dev} --port=${port}`;
176
+ // src/installers/string-utils.ts
177
+ import fs4 from "fs-extra";
178
+
179
+ // src/installers/providers.ts
180
+ var OAUTH_PROVIDERS = {
181
+ // ========================================
182
+ // POPULAR PROVIDERS (10)
183
+ // ========================================
184
+ google: {
185
+ id: "google",
186
+ name: "Google",
187
+ envPrefix: "GOOGLE",
188
+ clientIdVar: "GOOGLE_CLIENT_ID",
189
+ clientSecretVar: "GOOGLE_CLIENT_SECRET",
190
+ popular: true,
191
+ betterAuthConfig: {
192
+ import: "",
193
+ clientSideProvider: '"google"',
194
+ socialProvider: `google({
195
+ clientId: process.env.GOOGLE_CLIENT_ID!,
196
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
197
+ })`,
198
+ scopes: []
199
+ },
200
+ env: [
201
+ {
202
+ name: "GOOGLE_CLIENT_ID",
203
+ type: "server",
204
+ description: "Google OAuth Client ID"
205
+ },
206
+ {
207
+ name: "GOOGLE_CLIENT_SECRET",
208
+ type: "server",
209
+ description: "Google OAuth Client Secret"
186
210
  }
211
+ ],
212
+ docs: {
213
+ provider: "https://console.cloud.google.com/apis/credentials",
214
+ betterAuth: "https://www.better-auth.com/docs/authentication/social"
215
+ },
216
+ requiresExtraConfig: false,
217
+ extraConfigNotes: "",
218
+ readme: {
219
+ title: "Google OAuth Setup",
220
+ content: `## Google OAuth Setup
221
+
222
+ 1. Create OAuth credentials at https://console.cloud.google.com/apis/credentials
223
+ 2. Set the Authorized redirect URI to: \`http://localhost:3000/api/auth/callback/google\` (update for production)
224
+ 3. Copy the Client ID and Client Secret to your \`.env\` file
225
+
226
+ For more details, see the [Better Auth documentation](https://www.better-auth.com/docs/authentication/social).`
187
227
  }
188
- await fs3.writeJson(pkgPath, pkg, { spaces: 2 });
189
- const envLocalPath = path.join(this.targetPath, ".env.local");
190
- const envContent = `NEXT_PUBLIC_SITE_URL=http://localhost:${port}
191
- BETTER_AUTH_SECRET=""
192
- `;
193
- await fs3.writeFile(envLocalPath, envContent);
194
- }
195
- /**
196
- * Detect the package manager used to invoke the CLI
197
- */
198
- detectPackageManager() {
199
- const userAgent = process.env.npm_config_user_agent;
200
- if (userAgent) {
201
- if (userAgent.includes("pnpm")) return "pnpm";
202
- if (userAgent.includes("yarn")) return "yarn";
203
- if (userAgent.includes("bun")) return "bun";
204
- }
205
- return "npm";
206
- }
207
- /**
208
- * Install project dependencies using detected package manager
209
- */
210
- async installDependencies() {
211
- const packageManager = this.detectPackageManager();
212
- const spinner = ora(`Installing dependencies with ${packageManager}...`).start();
213
- try {
214
- await execa(packageManager, ["install"], {
215
- cwd: this.targetPath,
216
- stdio: "pipe"
217
- });
218
- spinner.succeed("Dependencies installed successfully");
219
- } catch (error) {
220
- spinner.fail("Failed to install dependencies");
221
- throw new Error(
222
- `Dependency installation failed: ${error instanceof Error ? error.message : "Unknown error"}`
223
- );
224
- }
225
- }
226
- /**
227
- * Format code using project's formatter
228
- */
229
- async formatCode() {
230
- const packageManager = this.detectPackageManager();
231
- const spinner = ora("Formatting code...").start();
232
- try {
233
- const result = await execa(packageManager, ["run", "format"], {
234
- cwd: this.targetPath,
235
- stdio: "pipe",
236
- reject: false
237
- });
238
- if (result.exitCode === 0) {
239
- spinner.succeed("Code formatted successfully");
240
- } else {
241
- spinner.warn("Formatting completed with warnings");
228
+ },
229
+ github: {
230
+ id: "github",
231
+ name: "GitHub",
232
+ envPrefix: "GITHUB",
233
+ clientIdVar: "GITHUB_CLIENT_ID",
234
+ clientSecretVar: "GITHUB_CLIENT_SECRET",
235
+ popular: true,
236
+ betterAuthConfig: {
237
+ import: "",
238
+ clientSideProvider: '"github"',
239
+ socialProvider: `github({
240
+ clientId: process.env.GITHUB_CLIENT_ID!,
241
+ clientSecret: process.env.GITHUB_CLIENT_SECRET!,
242
+ })`,
243
+ scopes: ["user:email"]
244
+ },
245
+ env: [
246
+ {
247
+ name: "GITHUB_CLIENT_ID",
248
+ type: "server",
249
+ description: "GitHub OAuth App Client ID"
250
+ },
251
+ {
252
+ name: "GITHUB_CLIENT_SECRET",
253
+ type: "server",
254
+ description: "GitHub OAuth App Client Secret"
242
255
  }
243
- } catch (error) {
244
- const errorMessage = error instanceof Error ? error.message : "Unknown error";
245
- spinner.warn(`Failed to format code: ${errorMessage}`);
256
+ ],
257
+ docs: {
258
+ provider: "https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app",
259
+ betterAuth: "https://www.better-auth.com/docs/authentication/social"
260
+ },
261
+ requiresExtraConfig: true,
262
+ extraConfigNotes: "You MUST include the user:email scope in your GitHub app. For GitHub Apps, enable Read-Only access to Email Addresses in Permissions.",
263
+ readme: {
264
+ title: "GitHub OAuth Setup",
265
+ content: `## GitHub OAuth Setup
266
+
267
+ 1. Create a GitHub OAuth App at https://github.com/settings/developers
268
+ 2. Set the Authorization callback URL to: \`http://localhost:3000/api/auth/callback/github\` (update for production)
269
+ 3. Copy the Client ID and Client Secret to your \`.env\` file
270
+ 4. **Important**: Include the \`user:email\` scope in your GitHub app permissions
271
+
272
+ For more details, see the [Better Auth documentation](https://www.better-auth.com/docs/authentication/social).`
246
273
  }
247
- }
248
- /**
249
- * Lint and fix code using project's ESLint configuration
250
- */
251
- async lintCode() {
252
- const packageManager = this.detectPackageManager();
253
- const spinner = ora("Linting and fixing code...").start();
254
- try {
255
- const args2 = packageManager === "pnpm" ? ["lint", "--fix"] : ["run", "lint", "--", "--fix"];
256
- const result = await execa(packageManager, args2, {
257
- cwd: this.targetPath,
258
- stdio: "pipe",
259
- reject: false
260
- });
261
- if (result.exitCode === 0) {
262
- spinner.succeed("Code linted and fixed successfully");
263
- } else {
264
- spinner.warn("Linting completed with warnings");
265
- }
266
- } catch (error) {
267
- const errorMessage = error instanceof Error ? error.message : "Unknown error";
268
- spinner.warn(`Failed to lint code: ${errorMessage}`);
269
- }
270
- }
271
- /**
272
- * Initialize Git repository in target directory
273
- */
274
- async initGitRepo() {
275
- const spinner = ora("Initializing Git repository...").start();
276
- try {
277
- try {
278
- await execa("git", ["--version"], { stdio: "pipe" });
279
- } catch {
280
- spinner.fail("Git is not installed");
281
- throw new Error("Git is not installed. Please install Git to initialize a repository.");
282
- }
283
- await execa("git", ["init"], { cwd: this.targetPath, stdio: "pipe" });
284
- await execa("git", ["add", "."], { cwd: this.targetPath, stdio: "pipe" });
285
- await execa(
286
- "git",
287
- ["commit", "-m", "Initial commit from create-vexcms"],
288
- { cwd: this.targetPath, stdio: "pipe" }
289
- );
290
- spinner.succeed("Git repository initialized");
291
- } catch (error) {
292
- spinner.fail("Failed to initialize Git repository");
293
- throw new Error(
294
- `Git initialization failed: ${error instanceof Error ? error.message : "Unknown error"}`
295
- );
296
- }
297
- }
298
- /**
299
- * Generate a secure random secret for Better Auth
300
- */
301
- generateAuthSecret() {
302
- return crypto.randomBytes(32).toString("hex");
303
- }
304
- /**
305
- * Write the generated auth secret to .env.example
306
- */
307
- async writeAuthSecret() {
308
- const envPath = path.join(this.targetPath, ".env.example");
309
- const secret = this.generateAuthSecret();
310
- if (await fs3.pathExists(envPath)) {
311
- let content = await fs3.readFile(envPath, "utf-8");
312
- content = content.replace(
313
- /BETTER_AUTH_SECRET=.*/,
314
- `BETTER_AUTH_SECRET=${secret}`
315
- );
316
- await fs3.writeFile(envPath, content);
317
- }
318
- }
319
- /**
320
- * Configure the Better Auth organizations plugin.
321
- * When enabled, replaces placeholders with organization import and plugin.
322
- * When disabled, removes the placeholder lines.
323
- */
324
- async configureOrganizations(enabled) {
325
- const pluginsPath = path.join(this.targetPath, "convex/auth/plugins/index.ts");
326
- if (!await fs3.pathExists(pluginsPath)) return;
327
- let content = await fs3.readFile(pluginsPath, "utf-8");
328
- if (enabled) {
329
- content = content.replace(
330
- "// {{ORGANIZATIONS_IMPORT}}",
331
- 'import { organization } from "better-auth/plugins"'
332
- );
333
- content = content.replace(
334
- " // {{ORGANIZATIONS_PLUGIN}}",
335
- " organization(),"
336
- );
337
- } else {
338
- content = content.replace(/.*\/\/ \{\{ORGANIZATIONS_IMPORT\}\}\n?/, "");
339
- content = content.replace(/.*\/\/ \{\{ORGANIZATIONS_PLUGIN\}\}\n?/, "");
340
- }
341
- await fs3.writeFile(pluginsPath, content);
342
- }
343
- /**
344
- * Main orchestration method for project initialization
345
- */
346
- async initProject(options) {
347
- const copySpinner = ora("Copying template files...").start();
348
- try {
349
- await this.copyBaseFiles();
350
- copySpinner.succeed("Template files copied");
351
- } catch (error) {
352
- copySpinner.fail("Failed to copy template files");
353
- throw error;
354
- }
355
- if (!options.bare) {
356
- const overlaySpinner = ora("Applying marketing site template...").start();
357
- try {
358
- await this.applyTemplateOverlay("marketing-site");
359
- overlaySpinner.succeed("Marketing site template applied");
360
- } catch (error) {
361
- overlaySpinner.fail("Failed to apply template overlay");
362
- throw error;
363
- }
364
- }
365
- const nameSpinner = ora("Configuring project...").start();
366
- try {
367
- await this.updatePackageName(options.projectName);
368
- await this.configurePort(options.port);
369
- nameSpinner.succeed("Project configured");
370
- } catch (error) {
371
- nameSpinner.fail("Failed to configure project");
372
- throw error;
373
- }
374
- const authSpinner = ora("Configuring authentication...").start();
375
- try {
376
- await this.updateOAuthConfig(options.oauthProviders, options.emailPasswordAuth);
377
- authSpinner.succeed(
378
- options.emailPasswordAuth || options.oauthProviders.length > 0 ? "Authentication configuration updated" : "Authentication placeholders cleaned up"
379
- );
380
- } catch (error) {
381
- authSpinner.fail("Failed to configure authentication");
382
- throw error;
383
- }
384
- const oauthUISpinner = ora("Configuring OAuth UI...").start();
385
- try {
386
- await this.updateOAuthUIConfig(options.oauthProviders, options.emailPasswordAuth);
387
- oauthUISpinner.succeed(
388
- options.oauthProviders.length > 0 ? "OAuth UI configuration updated" : "OAuth UI placeholders cleaned up"
389
- );
390
- } catch (error) {
391
- oauthUISpinner.fail("Failed to configure OAuth UI");
392
- throw error;
393
- }
394
- const envSpinner = ora("Updating .env.example...").start();
395
- try {
396
- await this.updateEnvExample(options.oauthProviders);
397
- envSpinner.succeed(
398
- options.oauthProviders.length > 0 ? ".env.example updated" : ".env.example placeholders cleaned up"
399
- );
400
- } catch (error) {
401
- envSpinner.fail("Failed to update .env.example");
402
- throw error;
403
- }
404
- const envTsSpinner = ora("Updating typed env configuration...").start();
405
- try {
406
- await this.updateEnvTs(options.oauthProviders);
407
- envTsSpinner.succeed(
408
- options.oauthProviders.length > 0 ? "Typed env configuration updated" : "Typed env placeholders cleaned up"
409
- );
410
- } catch (error) {
411
- envTsSpinner.fail("Failed to update typed env configuration");
412
- throw error;
413
- }
414
- const readmeSpinner = ora("Updating README...").start();
415
- try {
416
- await this.updateReadme(options.oauthProviders);
417
- readmeSpinner.succeed(
418
- options.oauthProviders.length > 0 ? "README updated" : "README placeholders cleaned up"
419
- );
420
- } catch (error) {
421
- readmeSpinner.fail("Failed to update README");
422
- throw error;
423
- }
424
- const orgsSpinner = ora("Configuring organizations...").start();
425
- try {
426
- await this.configureOrganizations(options.orgs);
427
- orgsSpinner.succeed(
428
- options.orgs ? "Organizations plugin enabled" : "Organizations placeholders cleaned up"
429
- );
430
- } catch (error) {
431
- orgsSpinner.fail("Failed to configure organizations");
432
- throw error;
433
- }
434
- const secretSpinner = ora("Generating auth secret...").start();
435
- try {
436
- await this.writeAuthSecret();
437
- secretSpinner.succeed("Auth secret generated");
438
- } catch (error) {
439
- secretSpinner.fail("Failed to generate auth secret");
440
- throw error;
441
- }
442
- if (options.initGit) {
443
- await this.initGitRepo();
444
- }
445
- if (options.installDependencies) {
446
- await this.installDependencies();
447
- await this.lintCode();
448
- await this.formatCode();
449
- }
450
- }
451
- };
452
-
453
- // src/installers/nextjs.ts
454
- import { join as join2 } from "path";
455
-
456
- // src/installers/string-utils.ts
457
- import fs4 from "fs-extra";
458
-
459
- // src/installers/providers.ts
460
- var OAUTH_PROVIDERS = {
461
- // ========================================
462
- // POPULAR PROVIDERS (10)
463
- // ========================================
464
- google: {
465
- id: "google",
466
- name: "Google",
467
- envPrefix: "GOOGLE",
468
- clientIdVar: "GOOGLE_CLIENT_ID",
469
- clientSecretVar: "GOOGLE_CLIENT_SECRET",
470
- popular: true,
471
- betterAuthConfig: {
472
- import: "",
473
- clientSideProvider: '"google"',
474
- socialProvider: `google({
475
- clientId: process.env.GOOGLE_CLIENT_ID!,
476
- clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
477
- })`,
478
- scopes: []
479
- },
480
- env: [
481
- {
482
- name: "GOOGLE_CLIENT_ID",
483
- type: "server",
484
- description: "Google OAuth Client ID"
485
- },
486
- {
487
- name: "GOOGLE_CLIENT_SECRET",
488
- type: "server",
489
- description: "Google OAuth Client Secret"
274
+ },
275
+ discord: {
276
+ id: "discord",
277
+ name: "Discord",
278
+ envPrefix: "DISCORD",
279
+ clientIdVar: "DISCORD_CLIENT_ID",
280
+ clientSecretVar: "DISCORD_CLIENT_SECRET",
281
+ popular: true,
282
+ betterAuthConfig: {
283
+ import: "",
284
+ clientSideProvider: '"discord"',
285
+ socialProvider: `discord({
286
+ clientId: process.env.DISCORD_CLIENT_ID!,
287
+ clientSecret: process.env.DISCORD_CLIENT_SECRET!,
288
+ })`,
289
+ scopes: []
290
+ },
291
+ env: [
292
+ {
293
+ name: "DISCORD_CLIENT_ID",
294
+ type: "server",
295
+ description: "Discord OAuth Application Client ID"
296
+ },
297
+ {
298
+ name: "DISCORD_CLIENT_SECRET",
299
+ type: "server",
300
+ description: "Discord OAuth Application Client Secret"
490
301
  }
491
302
  ],
492
303
  docs: {
493
- provider: "https://console.cloud.google.com/apis/credentials",
304
+ provider: "https://discord.com/developers/applications",
494
305
  betterAuth: "https://www.better-auth.com/docs/authentication/social"
495
306
  },
496
307
  requiresExtraConfig: false,
497
- extraConfigNotes: "",
308
+ extraConfigNotes: "You can optionally add a permissions field to request additional Discord permissions.",
498
309
  readme: {
499
- title: "Google OAuth Setup",
500
- content: `## Google OAuth Setup
310
+ title: "Discord OAuth Setup",
311
+ content: `## Discord OAuth Setup
501
312
 
502
- 1. Create OAuth credentials at https://console.cloud.google.com/apis/credentials
503
- 2. Set the Authorized redirect URI to: \`http://localhost:3000/api/auth/callback/google\` (update for production)
313
+ 1. Create an application at https://discord.com/developers/applications
314
+ 2. Add a redirect URL: \`http://localhost:3000/api/auth/callback/discord\` (update for production)
504
315
  3. Copy the Client ID and Client Secret to your \`.env\` file
505
316
 
506
317
  For more details, see the [Better Auth documentation](https://www.better-auth.com/docs/authentication/social).`
507
318
  }
508
319
  },
509
- github: {
510
- id: "github",
511
- name: "GitHub",
512
- envPrefix: "GITHUB",
513
- clientIdVar: "GITHUB_CLIENT_ID",
514
- clientSecretVar: "GITHUB_CLIENT_SECRET",
320
+ apple: {
321
+ id: "apple",
322
+ name: "Apple",
323
+ envPrefix: "APPLE",
324
+ clientIdVar: "APPLE_CLIENT_ID",
325
+ clientSecretVar: "APPLE_CLIENT_SECRET",
515
326
  popular: true,
516
327
  betterAuthConfig: {
517
328
  import: "",
518
- clientSideProvider: '"github"',
519
- socialProvider: `github({
520
- clientId: process.env.GITHUB_CLIENT_ID!,
521
- clientSecret: process.env.GITHUB_CLIENT_SECRET!,
329
+ clientSideProvider: '"apple"',
330
+ socialProvider: `apple({
331
+ clientId: process.env.APPLE_CLIENT_ID!,
332
+ clientSecret: process.env.APPLE_CLIENT_SECRET!,
522
333
  })`,
523
- scopes: ["user:email"]
334
+ scopes: []
524
335
  },
525
336
  env: [
526
337
  {
527
- name: "GITHUB_CLIENT_ID",
338
+ name: "APPLE_CLIENT_ID",
528
339
  type: "server",
529
- description: "GitHub OAuth App Client ID"
340
+ description: "Apple Sign In Service ID"
530
341
  },
531
342
  {
532
- name: "GITHUB_CLIENT_SECRET",
343
+ name: "APPLE_CLIENT_SECRET",
533
344
  type: "server",
534
- description: "GitHub OAuth App Client Secret"
345
+ description: "Apple Sign In Client Secret (JWT token)"
535
346
  }
536
347
  ],
537
348
  docs: {
538
- provider: "https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app",
349
+ provider: "https://developer.apple.com/sign-in-with-apple/get-started/",
539
350
  betterAuth: "https://www.better-auth.com/docs/authentication/social"
540
351
  },
541
352
  requiresExtraConfig: true,
542
- extraConfigNotes: "You MUST include the user:email scope in your GitHub app. For GitHub Apps, enable Read-Only access to Email Addresses in Permissions.",
353
+ extraConfigNotes: "Apple requires additional configuration: clientId is your Service ID, clientSecret must be a JWT token generated from your Team ID, Key ID, and Private Key. You may also need to provide appBundleIdentifier for iOS apps.",
543
354
  readme: {
544
- title: "GitHub OAuth Setup",
545
- content: `## GitHub OAuth Setup
546
-
547
- 1. Create a GitHub OAuth App at https://github.com/settings/developers
548
- 2. Set the Authorization callback URL to: \`http://localhost:3000/api/auth/callback/github\` (update for production)
549
- 3. Copy the Client ID and Client Secret to your \`.env\` file
550
- 4. **Important**: Include the \`user:email\` scope in your GitHub app permissions
551
-
552
- For more details, see the [Better Auth documentation](https://www.better-auth.com/docs/authentication/social).`
553
- }
554
- },
555
- discord: {
556
- id: "discord",
557
- name: "Discord",
558
- envPrefix: "DISCORD",
559
- clientIdVar: "DISCORD_CLIENT_ID",
560
- clientSecretVar: "DISCORD_CLIENT_SECRET",
561
- popular: true,
562
- betterAuthConfig: {
563
- import: "",
564
- clientSideProvider: '"discord"',
565
- socialProvider: `discord({
566
- clientId: process.env.DISCORD_CLIENT_ID!,
567
- clientSecret: process.env.DISCORD_CLIENT_SECRET!,
568
- })`,
569
- scopes: []
570
- },
571
- env: [
572
- {
573
- name: "DISCORD_CLIENT_ID",
574
- type: "server",
575
- description: "Discord OAuth Application Client ID"
576
- },
577
- {
578
- name: "DISCORD_CLIENT_SECRET",
579
- type: "server",
580
- description: "Discord OAuth Application Client Secret"
581
- }
582
- ],
583
- docs: {
584
- provider: "https://discord.com/developers/applications",
585
- betterAuth: "https://www.better-auth.com/docs/authentication/social"
586
- },
587
- requiresExtraConfig: false,
588
- extraConfigNotes: "You can optionally add a permissions field to request additional Discord permissions.",
589
- readme: {
590
- title: "Discord OAuth Setup",
591
- content: `## Discord OAuth Setup
592
-
593
- 1. Create an application at https://discord.com/developers/applications
594
- 2. Add a redirect URL: \`http://localhost:3000/api/auth/callback/discord\` (update for production)
595
- 3. Copy the Client ID and Client Secret to your \`.env\` file
596
-
597
- For more details, see the [Better Auth documentation](https://www.better-auth.com/docs/authentication/social).`
598
- }
599
- },
600
- apple: {
601
- id: "apple",
602
- name: "Apple",
603
- envPrefix: "APPLE",
604
- clientIdVar: "APPLE_CLIENT_ID",
605
- clientSecretVar: "APPLE_CLIENT_SECRET",
606
- popular: true,
607
- betterAuthConfig: {
608
- import: "",
609
- clientSideProvider: '"apple"',
610
- socialProvider: `apple({
611
- clientId: process.env.APPLE_CLIENT_ID!,
612
- clientSecret: process.env.APPLE_CLIENT_SECRET!,
613
- })`,
614
- scopes: []
615
- },
616
- env: [
617
- {
618
- name: "APPLE_CLIENT_ID",
619
- type: "server",
620
- description: "Apple Sign In Service ID"
621
- },
622
- {
623
- name: "APPLE_CLIENT_SECRET",
624
- type: "server",
625
- description: "Apple Sign In Client Secret (JWT token)"
626
- }
627
- ],
628
- docs: {
629
- provider: "https://developer.apple.com/sign-in-with-apple/get-started/",
630
- betterAuth: "https://www.better-auth.com/docs/authentication/social"
631
- },
632
- requiresExtraConfig: true,
633
- extraConfigNotes: "Apple requires additional configuration: clientId is your Service ID, clientSecret must be a JWT token generated from your Team ID, Key ID, and Private Key. You may also need to provide appBundleIdentifier for iOS apps.",
634
- readme: {
635
- title: "Apple Sign In Setup",
636
- content: `## Apple Sign In Setup
355
+ title: "Apple Sign In Setup",
356
+ content: `## Apple Sign In Setup
637
357
 
638
358
  1. Create a Sign In with Apple service at https://developer.apple.com
639
359
  2. Configure your Service ID and return URLs
@@ -2093,145 +1813,712 @@ async function replacePlaceholder(filePath, placeholder, content, options) {
2093
1813
  updatedLines.push(line);
2094
1814
  }
2095
1815
  }
2096
- await fs4.writeFile(filePath, updatedLines.join("\n"), "utf-8");
2097
- }
2098
- function generateCredentialsValue(enabled) {
2099
- return `credentials={${enabled}}`;
2100
- }
2101
- function generateAuthProvidersBlock(oauthProviders, emailPasswordEnabled) {
2102
- const parts = [];
2103
- parts.push(`emailAndPassword: {
2104
- enabled: ${emailPasswordEnabled}
2105
- },`);
2106
- if (oauthProviders.length > 0) {
2107
- const providersObject = oauthProviders.map((providerId) => {
2108
- const provider = getProvider(providerId);
2109
- if (!provider) {
2110
- throw new Error(`Unknown OAuth provider: ${providerId}`);
2111
- }
2112
- const configLines = [
2113
- `clientId: process.env.${provider.envPrefix}_CLIENT_ID!,`,
2114
- `clientSecret: process.env.${provider.envPrefix}_CLIENT_SECRET!,`
2115
- ];
2116
- if (providerId === "figma") {
2117
- configLines.push(`clientKey: process.env.FIGMA_CLIENT_KEY!,`);
1816
+ await fs4.writeFile(filePath, updatedLines.join("\n"), "utf-8");
1817
+ }
1818
+ function generateCredentialsValue(enabled) {
1819
+ return `credentials={${enabled}}`;
1820
+ }
1821
+ function generateAuthProvidersBlock(oauthProviders, emailPasswordEnabled) {
1822
+ const parts = [];
1823
+ parts.push(`emailAndPassword: {
1824
+ enabled: ${emailPasswordEnabled}
1825
+ },`);
1826
+ if (oauthProviders.length > 0) {
1827
+ const providersObject = oauthProviders.map((providerId) => {
1828
+ const provider = getProvider(providerId);
1829
+ if (!provider) {
1830
+ throw new Error(`Unknown OAuth provider: ${providerId}`);
1831
+ }
1832
+ const configLines = [
1833
+ `clientId: process.env.${provider.envPrefix}_CLIENT_ID!,`,
1834
+ `clientSecret: process.env.${provider.envPrefix}_CLIENT_SECRET!,`
1835
+ ];
1836
+ if (providerId === "figma") {
1837
+ configLines.push(`clientKey: process.env.FIGMA_CLIENT_KEY!,`);
1838
+ }
1839
+ return `${providerId}: {
1840
+ ${configLines.join("\n ")}
1841
+ }`;
1842
+ }).join(",\n ");
1843
+ parts.push(`socialProviders: {
1844
+ ${providersObject}
1845
+ },`);
1846
+ }
1847
+ return parts.join("\n ");
1848
+ }
1849
+ function generateEnvTsServerSchema(providers) {
1850
+ if (providers.length === 0) {
1851
+ return "";
1852
+ }
1853
+ const schemas = providers.map((providerId) => {
1854
+ const provider = getProvider(providerId);
1855
+ if (!provider) {
1856
+ throw new Error(`Unknown OAuth provider: ${providerId}`);
1857
+ }
1858
+ const lines = [];
1859
+ lines.push(`${provider.envPrefix}_CLIENT_ID: z.string(),`);
1860
+ lines.push(`${provider.envPrefix}_CLIENT_SECRET: z.string(),`);
1861
+ if (providerId === "figma") {
1862
+ lines.push(`FIGMA_CLIENT_KEY: z.string(),`);
1863
+ }
1864
+ return lines.join("\n ");
1865
+ }).join("\n ");
1866
+ return schemas;
1867
+ }
1868
+ function generateEnvTsRuntimeMapping(providers) {
1869
+ if (providers.length === 0) {
1870
+ return "";
1871
+ }
1872
+ const mappings = providers.map((providerId) => {
1873
+ const provider = getProvider(providerId);
1874
+ if (!provider) {
1875
+ throw new Error(`Unknown OAuth provider: ${providerId}`);
1876
+ }
1877
+ const lines = [];
1878
+ lines.push(`${provider.envPrefix}_CLIENT_ID: process.env.${provider.envPrefix}_CLIENT_ID,`);
1879
+ lines.push(`${provider.envPrefix}_CLIENT_SECRET: process.env.${provider.envPrefix}_CLIENT_SECRET,`);
1880
+ if (providerId === "figma") {
1881
+ lines.push(`FIGMA_CLIENT_KEY: process.env.FIGMA_CLIENT_KEY,`);
1882
+ }
1883
+ return lines.join("\n ");
1884
+ }).join("\n ");
1885
+ return mappings;
1886
+ }
1887
+ function generateOAuthUIProvidersBlock(providers) {
1888
+ if (providers.length === 0) {
1889
+ return "";
1890
+ }
1891
+ const providerList = providers.map((id) => `"${id}"`).join(", ");
1892
+ return `social={{
1893
+ providers: [${providerList}]
1894
+ }}`;
1895
+ }
1896
+ function generateEnvVarsBlock(providers, framework) {
1897
+ if (providers.length === 0) {
1898
+ return "";
1899
+ }
1900
+ const envVars = providers.flatMap((providerId) => {
1901
+ const provider = getProvider(providerId);
1902
+ if (!provider) {
1903
+ throw new Error(`Unknown OAuth provider: ${providerId}`);
1904
+ }
1905
+ if (provider.env && provider.env.length > 0) {
1906
+ return provider.env.map((envVar) => {
1907
+ let prefix = "";
1908
+ if (envVar.type === "client") {
1909
+ prefix = framework === "nextjs" ? "NEXT_PUBLIC_" : "VITE_";
1910
+ }
1911
+ return `# ${envVar.description}
1912
+ ${prefix}${envVar.name}=`;
1913
+ });
1914
+ } else {
1915
+ return [
1916
+ `${provider.clientIdVar}=`,
1917
+ `${provider.clientSecretVar}=`
1918
+ ];
1919
+ }
1920
+ });
1921
+ return envVars.join("\n");
1922
+ }
1923
+ function generateReadmeSection(providers) {
1924
+ if (providers.length === 0) {
1925
+ return "";
1926
+ }
1927
+ const sections = providers.map((providerId) => {
1928
+ const provider = getProvider(providerId);
1929
+ if (!provider) {
1930
+ throw new Error(`Unknown OAuth provider: ${providerId}`);
1931
+ }
1932
+ if (!provider.readme) {
1933
+ throw new Error(
1934
+ `Provider ${providerId} missing readme metadata`
1935
+ );
1936
+ }
1937
+ return provider.readme.content;
1938
+ }).filter(Boolean);
1939
+ if (sections.length === 0) {
1940
+ return "";
1941
+ }
1942
+ return `# OAuth Provider Setup
1943
+
1944
+ ${sections.join("\n\n---\n\n")}`;
1945
+ }
1946
+ function assertAndReplace(filePath, content, oldBlock, newBlock, label) {
1947
+ if (!content.includes(oldBlock)) {
1948
+ throw new Error(
1949
+ `configureOrganizations: expected pattern "${label}" not found in ${filePath} \u2014 template drift?`
1950
+ );
1951
+ }
1952
+ return content.replace(oldBlock, newBlock);
1953
+ }
1954
+ function stripOrgFromGetAuthCall(filePath, content) {
1955
+ const importPattern = /import \{ TABLE_SLUG_ORGANIZATIONS, TABLE_SLUG_SESSIONS, TABLE_SLUG_USERS \} from "~\/db\/constants";?/;
1956
+ const importMatch = content.match(importPattern);
1957
+ if (!importMatch) {
1958
+ throw new Error(
1959
+ `configureOrganizations: expected the TABLE_SLUG_ORGANIZATIONS db/constants import in ${filePath} \u2014 template drift?`
1960
+ );
1961
+ }
1962
+ let result = assertAndReplace(
1963
+ filePath,
1964
+ content,
1965
+ importMatch[0],
1966
+ importMatch[0].replace("TABLE_SLUG_ORGANIZATIONS, ", ""),
1967
+ "TABLE_SLUG_ORGANIZATIONS db/constants import"
1968
+ );
1969
+ const orgPropPattern = /\n[ \t]*orgCollectionSlug: TABLE_SLUG_ORGANIZATIONS,/;
1970
+ const orgPropMatch = result.match(orgPropPattern);
1971
+ if (!orgPropMatch) {
1972
+ throw new Error(
1973
+ `configureOrganizations: expected an "orgCollectionSlug: TABLE_SLUG_ORGANIZATIONS," line in ${filePath} \u2014 template drift?`
1974
+ );
1975
+ }
1976
+ result = assertAndReplace(filePath, result, orgPropMatch[0], "", "orgCollectionSlug prop");
1977
+ result = assertAndReplace(
1978
+ filePath,
1979
+ result,
1980
+ "resolveOrgs: true,",
1981
+ "resolveOrgs: false,",
1982
+ "resolveOrgs: true"
1983
+ );
1984
+ return result;
1985
+ }
1986
+ function stripOrgFromAuthApi(filePath, content) {
1987
+ let result = assertAndReplace(
1988
+ filePath,
1989
+ content,
1990
+ 'import { get } from "@vexcms/core/server";\nimport { v } from "convex/values";\n\nimport {\n type OrganizationID,\n type SessionID,\n TABLE_SLUG_ORGANIZATIONS,\n TABLE_SLUG_SESSIONS,\n TABLE_SLUG_USERS,\n type UserID,\n} from "~/db/constants";',
1991
+ 'import { get } from "@vexcms/core/server";\n\nimport { type SessionID, TABLE_SLUG_SESSIONS, TABLE_SLUG_USERS, type UserID } from "~/db/constants";',
1992
+ "auth/api.ts imports"
1993
+ );
1994
+ result = assertAndReplace(
1995
+ filePath,
1996
+ result,
1997
+ "export const getUserOrg = query({\n args: {\n noOrgs: v.optional(v.boolean()),\n },\n handler: async (ctx, args) => {\n const empty = { user: null, organization: undefined };\n const identity = await ctx.auth.getUserIdentity();\n if (!identity) {\n return empty;\n }\n const session = await get({\n ctx,\n id: identity.sessionId as SessionID,\n collection: TABLE_SLUG_SESSIONS,\n });\n if (!session) {\n return empty;\n }\n const user = await get({ ctx, id: identity.subject as UserID, collection: TABLE_SLUG_USERS });\n if (!user) {\n return empty;\n }\n if (!session.activeOrganizationId || args.noOrgs) {\n return { user, organization: undefined };\n }\n const organization = await get({\n ctx,\n id: session.activeOrganizationId as OrganizationID,\n collection: TABLE_SLUG_ORGANIZATIONS,\n });\n return { user, organization: organization ?? undefined };\n },\n});",
1998
+ "export const getUserOrg = query({\n args: {},\n handler: async (ctx) => {\n const empty = { user: null, organization: undefined };\n const identity = await ctx.auth.getUserIdentity();\n if (!identity) {\n return empty;\n }\n const session = await get({\n ctx,\n id: identity.sessionId as SessionID,\n collection: TABLE_SLUG_SESSIONS,\n });\n if (!session) {\n return empty;\n }\n const user = await get({ ctx, id: identity.subject as UserID, collection: TABLE_SLUG_USERS });\n if (!user) {\n return empty;\n }\n return { user, organization: undefined };\n },\n});",
1999
+ "auth/api.ts getUserOrg body"
2000
+ );
2001
+ return result;
2002
+ }
2003
+ function stripOrgFromAccess(filePath, content) {
2004
+ let result = assertAndReplace(
2005
+ filePath,
2006
+ content,
2007
+ 'import {\n TABLE_SLUG_ORGANIZATIONS,\n TABLE_SLUG_USERS,\n USER_ROLES,\n} from "~/db/constants"',
2008
+ 'import { TABLE_SLUG_USERS, USER_ROLES } from "~/db/constants"',
2009
+ "access.ts db/constants import"
2010
+ );
2011
+ result = assertAndReplace(
2012
+ filePath,
2013
+ result,
2014
+ "\n orgCollectionSlug: TABLE_SLUG_ORGANIZATIONS,",
2015
+ "",
2016
+ "access.ts orgCollectionSlug prop"
2017
+ );
2018
+ return result;
2019
+ }
2020
+ function stripOrgFromAuthContext(filePath, content) {
2021
+ let result = assertAndReplace(
2022
+ filePath,
2023
+ content,
2024
+ 'import { type OrganizationDoc, type UserDoc } from "~/db/constants";',
2025
+ 'import { type UserDoc } from "~/db/constants";',
2026
+ "AuthContext.tsx db/constants import"
2027
+ );
2028
+ result = assertAndReplace(
2029
+ filePath,
2030
+ result,
2031
+ "export interface CurrentAuth {\n organization?: OrganizationDoc;\n user: null | UserDoc; // null \u2192 unauthenticated \u2192 no roles \u2192 deny\n}",
2032
+ "export interface CurrentAuth {\n user: null | UserDoc; // null \u2192 unauthenticated \u2192 no roles \u2192 deny\n}",
2033
+ "AuthContext.tsx CurrentAuth interface"
2034
+ );
2035
+ result = assertAndReplace(
2036
+ filePath,
2037
+ result,
2038
+ " value: {\n organization?: OrganizationDoc;\n user: null | UserDoc; // null \u2192 unauthenticated \u2192 no roles \u2192 deny\n };",
2039
+ " value: {\n user: null | UserDoc; // null \u2192 unauthenticated \u2192 no roles \u2192 deny\n };",
2040
+ "AuthContext.tsx AuthProvider value prop"
2041
+ );
2042
+ result = assertAndReplace(
2043
+ filePath,
2044
+ result,
2045
+ "/** @returns the current caller `{ user, organization }` from the server layout. */",
2046
+ "/** @returns the current caller `{ user }` from the server layout. */",
2047
+ "AuthContext.tsx useAuth JSDoc"
2048
+ );
2049
+ return result;
2050
+ }
2051
+ function stripOrgFromHasPermission(filePath, content) {
2052
+ return assertAndReplace(
2053
+ filePath,
2054
+ content,
2055
+ " const { user, organization } = useAuth();\n return hasPermissionCore({\n access,\n user,\n organization,\n ...props,\n });",
2056
+ " const { user } = useAuth();\n return hasPermissionCore({\n access,\n user,\n ...props,\n });",
2057
+ "hasPermission.ts useAuth destructure + hasPermissionCore call"
2058
+ );
2059
+ }
2060
+ function stripOrgFromDbConstants(filePath, content) {
2061
+ return assertAndReplace(
2062
+ filePath,
2063
+ content,
2064
+ '\nexport const TABLE_SLUG_ORGANIZATIONS = "organization" as const\nexport type OrganizationDoc = Doc<typeof TABLE_SLUG_ORGANIZATIONS>\nexport type OrganizationID = Id<typeof TABLE_SLUG_ORGANIZATIONS>\n',
2065
+ "",
2066
+ "db/constants/index.ts organization table block"
2067
+ );
2068
+ }
2069
+
2070
+ // src/installers/base.ts
2071
+ var VexFrameworkInstaller = class {
2072
+ /**
2073
+ * Binds this installer to the destination directory and package name used
2074
+ * throughout scaffolding (template copy target, package.json, README, etc).
2075
+ *
2076
+ * @param targetPath - Absolute path of the directory the project is created in.
2077
+ * @param projectName - npm package name recorded for subclasses; not read directly by the base class.
2078
+ */
2079
+ constructor(targetPath, projectName) {
2080
+ this.targetPath = targetPath;
2081
+ this.projectName = projectName;
2082
+ }
2083
+ targetPath;
2084
+ projectName;
2085
+ /**
2086
+ * Copy base template files to target directory
2087
+ */
2088
+ async copyBaseFiles() {
2089
+ await copyTemplate(this.frameworkName, this.targetPath);
2090
+ }
2091
+ /**
2092
+ * Apply a template overlay on top of the base template.
2093
+ * Merges overlay files onto the already-copied base — existing files
2094
+ * not in the overlay are left untouched.
2095
+ *
2096
+ * @param overlay - Name of the overlay template directory under `templates/` (e.g. `"marketing-site"`).
2097
+ */
2098
+ async applyTemplateOverlay(overlay) {
2099
+ const { dirname: dirname2 } = await import("path");
2100
+ const { fileURLToPath: fileURLToPath2 } = await import("url");
2101
+ const __filename = fileURLToPath2(import.meta.url);
2102
+ const __dirname = dirname2(__filename);
2103
+ let overlayDir = path2.join(__dirname, "../../templates", overlay);
2104
+ if (!await fs5.pathExists(overlayDir)) {
2105
+ overlayDir = path2.join(__dirname, "../templates", overlay);
2106
+ }
2107
+ await overlayTemplate({ overlayDir, targetDir: this.targetPath });
2108
+ }
2109
+ /**
2110
+ * Update the package.json name field, the vex:update script (to match the
2111
+ * detected package manager), and README.md's `{{PROJECT_NAME}}` heading.
2112
+ *
2113
+ * @param name - npm package name to write into `package.json`'s `name` field
2114
+ * and to substitute for `{{PROJECT_NAME}}` in `README.md`.
2115
+ */
2116
+ async updatePackageName(name) {
2117
+ const pkgPath = path2.join(this.targetPath, "package.json");
2118
+ const pkg = await fs5.readJson(pkgPath);
2119
+ pkg.name = name;
2120
+ const pm = this.detectPackageManager();
2121
+ if (pkg.scripts?.["vex:update"] && pm !== "pnpm") {
2122
+ const addCmd = pm === "yarn" ? "yarn add" : pm === "bun" ? "bun add" : "npm install";
2123
+ pkg.scripts["vex:update"] = pkg.scripts["vex:update"].replace(/^pnpm add/, addCmd);
2124
+ }
2125
+ await fs5.writeJson(pkgPath, pkg, { spaces: 2 });
2126
+ const readmePath = path2.join(this.targetPath, "README.md");
2127
+ if (await fs5.pathExists(readmePath)) {
2128
+ const readme = await fs5.readFile(readmePath, "utf-8");
2129
+ await fs5.writeFile(readmePath, readme.replace("{{PROJECT_NAME}}", name));
2130
+ }
2131
+ }
2132
+ /**
2133
+ * Configure the dev server port.
2134
+ * Updates package.json dev script and creates .env.local with NEXT_PUBLIC_SITE_URL.
2135
+ *
2136
+ * @param port - Dev server port number to write into the `dev` script and `.env.local`.
2137
+ */
2138
+ async configurePort(port) {
2139
+ const pkgPath = path2.join(this.targetPath, "package.json");
2140
+ const pkg = await fs5.readJson(pkgPath);
2141
+ if (pkg.scripts?.dev) {
2142
+ if (pkg.scripts.dev.includes("--port")) {
2143
+ pkg.scripts.dev = pkg.scripts.dev.replace(/--port[= ]\d+/, `--port=${port}`);
2144
+ } else {
2145
+ pkg.scripts.dev = `${pkg.scripts.dev} --port=${port}`;
2146
+ }
2147
+ }
2148
+ await fs5.writeJson(pkgPath, pkg, { spaces: 2 });
2149
+ const siteUrl = `http://localhost:${port}`;
2150
+ const envLocalPath = path2.join(this.targetPath, ".env.local");
2151
+ const envContent = [
2152
+ `NEXT_PUBLIC_SITE_URL=${siteUrl}`,
2153
+ `SITE_URL=${siteUrl}`,
2154
+ `BETTER_AUTH_SECRET=${this.generateAuthSecret()}`,
2155
+ `NEXT_PUBLIC_CONVEX_URL=https://placeholder.convex.cloud`,
2156
+ `NEXT_PUBLIC_CONVEX_SITE_URL=https://placeholder.convex.site`,
2157
+ `CONVEX_DEPLOYMENT=`,
2158
+ ""
2159
+ ].join("\n");
2160
+ await fs5.writeFile(envLocalPath, envContent);
2161
+ }
2162
+ /**
2163
+ * Rewrites the scaffolded `package.json` for life inside a host pnpm
2164
+ * workspace (`--monorepo`). `@vexcms/*` dependencies become
2165
+ * `workspace:*`; any other dependency the host's `pnpm-workspace.yaml`
2166
+ * catalog also pins becomes `catalog:`; everything else keeps its
2167
+ * literal, catalog-resolved version. No-op when `options.monorepo` is
2168
+ * false or `options.workspaceRoot` is unset.
2169
+ *
2170
+ * @param options - Resolved project options; only `monorepo`/`workspaceRoot` are consulted.
2171
+ */
2172
+ async applyMonorepoRewrite(options) {
2173
+ if (!options.monorepo || !options.workspaceRoot) return;
2174
+ const pkgPath = path2.join(this.targetPath, "package.json");
2175
+ const manifest = await fs5.readJson(pkgPath);
2176
+ const catalog = await readWorkspaceCatalog({ workspaceRoot: options.workspaceRoot });
2177
+ const rewritten = rewriteManifestForMonorepo({ manifest, catalog });
2178
+ await fs5.writeJson(pkgPath, rewritten, { spaces: 2 });
2179
+ }
2180
+ /**
2181
+ * Detect the package manager used to invoke the CLI
2182
+ *
2183
+ * @returns The package manager inferred from `npm_config_user_agent`, defaulting to `"npm"` when it can't be determined.
2184
+ */
2185
+ detectPackageManager() {
2186
+ const userAgent = process.env.npm_config_user_agent;
2187
+ if (userAgent) {
2188
+ if (userAgent.includes("pnpm")) return "pnpm";
2189
+ if (userAgent.includes("yarn")) return "yarn";
2190
+ if (userAgent.includes("bun")) return "bun";
2191
+ }
2192
+ return "npm";
2193
+ }
2194
+ /**
2195
+ * Install project dependencies using detected package manager
2196
+ */
2197
+ async installDependencies() {
2198
+ const packageManager = this.detectPackageManager();
2199
+ const spinner = ora(`Installing dependencies with ${packageManager}...`).start();
2200
+ try {
2201
+ await execa(packageManager, ["install"], {
2202
+ cwd: this.targetPath,
2203
+ stdio: "pipe"
2204
+ });
2205
+ spinner.succeed("Dependencies installed successfully");
2206
+ } catch (error) {
2207
+ spinner.fail("Failed to install dependencies");
2208
+ throw new Error(
2209
+ `Dependency installation failed: ${error instanceof Error ? error.message : "Unknown error"}`
2210
+ );
2211
+ }
2212
+ }
2213
+ /**
2214
+ * Format code using project's formatter
2215
+ */
2216
+ async formatCode() {
2217
+ const packageManager = this.detectPackageManager();
2218
+ const spinner = ora("Formatting code...").start();
2219
+ try {
2220
+ const result = await execa(packageManager, ["run", "format"], {
2221
+ cwd: this.targetPath,
2222
+ stdio: "pipe",
2223
+ reject: false
2224
+ });
2225
+ if (result.exitCode === 0) {
2226
+ spinner.succeed("Code formatted successfully");
2227
+ } else {
2228
+ spinner.warn("Formatting completed with warnings");
2229
+ }
2230
+ } catch (error) {
2231
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
2232
+ spinner.warn(`Failed to format code: ${errorMessage}`);
2233
+ }
2234
+ }
2235
+ /**
2236
+ * Lint and fix code using project's ESLint configuration
2237
+ */
2238
+ async lintCode() {
2239
+ const packageManager = this.detectPackageManager();
2240
+ const spinner = ora("Linting and fixing code...").start();
2241
+ try {
2242
+ const args2 = packageManager === "pnpm" ? ["lint", "--fix"] : ["run", "lint", "--", "--fix"];
2243
+ const result = await execa(packageManager, args2, {
2244
+ cwd: this.targetPath,
2245
+ stdio: "pipe",
2246
+ reject: false
2247
+ });
2248
+ if (result.exitCode === 0) {
2249
+ spinner.succeed("Code linted and fixed successfully");
2250
+ } else {
2251
+ spinner.warn("Linting completed with warnings");
2252
+ }
2253
+ } catch (error) {
2254
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
2255
+ spinner.warn(`Failed to lint code: ${errorMessage}`);
2256
+ }
2257
+ }
2258
+ /**
2259
+ * Initialize Git repository in target directory
2260
+ */
2261
+ async initGitRepo() {
2262
+ const spinner = ora("Initializing Git repository...").start();
2263
+ try {
2264
+ try {
2265
+ await execa("git", ["--version"], { stdio: "pipe" });
2266
+ } catch {
2267
+ spinner.fail("Git is not installed");
2268
+ throw new Error("Git is not installed. Please install Git to initialize a repository.");
2269
+ }
2270
+ await execa("git", ["init"], { cwd: this.targetPath, stdio: "pipe" });
2271
+ await execa("git", ["add", "."], { cwd: this.targetPath, stdio: "pipe" });
2272
+ await execa(
2273
+ "git",
2274
+ ["commit", "-m", "Initial commit from create-vexcms"],
2275
+ { cwd: this.targetPath, stdio: "pipe" }
2276
+ );
2277
+ spinner.succeed("Git repository initialized");
2278
+ } catch (error) {
2279
+ spinner.fail("Failed to initialize Git repository");
2280
+ throw new Error(
2281
+ `Git initialization failed: ${error instanceof Error ? error.message : "Unknown error"}`
2282
+ );
2283
+ }
2284
+ }
2285
+ /**
2286
+ * Generate a secure random secret for Better Auth
2287
+ *
2288
+ * @returns A 64-character hex string (32 random bytes) suitable for `BETTER_AUTH_SECRET`.
2289
+ */
2290
+ generateAuthSecret() {
2291
+ return crypto.randomBytes(32).toString("hex");
2292
+ }
2293
+ /**
2294
+ * Write the generated auth secret to .env.example
2295
+ */
2296
+ async writeAuthSecret() {
2297
+ const envPath = path2.join(this.targetPath, ".env.example");
2298
+ const secret = this.generateAuthSecret();
2299
+ if (await fs5.pathExists(envPath)) {
2300
+ let content = await fs5.readFile(envPath, "utf-8");
2301
+ content = content.replace(
2302
+ /BETTER_AUTH_SECRET=.*/,
2303
+ `BETTER_AUTH_SECRET=${secret}`
2304
+ );
2305
+ await fs5.writeFile(envPath, content);
2306
+ }
2307
+ }
2308
+ /**
2309
+ * Configure the Better Auth organizations plugin.
2310
+ * When enabled, replaces placeholders with organization import and plugin,
2311
+ * and leaves every org-aware file (`createGetAuth` call sites, `access.ts`,
2312
+ * `AuthContext.tsx`, `hasPermission.ts`, `convex/auth/api.ts`,
2313
+ * `db/constants/index.ts`) untouched.
2314
+ * When disabled, removes the placeholder lines AND strips organization
2315
+ * wiring from those same files down to the org-free shape — the
2316
+ * templates ship org-enabled by default (so `--orgs` needs no surgery),
2317
+ * so the default (orgs-declined) path has to actively cut it.
2318
+ *
2319
+ * @param enabled - Whether the organizations (multi-tenant) plugin should stay wired.
2320
+ */
2321
+ async configureOrganizations(enabled) {
2322
+ const pluginsPath = path2.join(this.targetPath, "convex/auth/plugins/index.ts");
2323
+ if (await fs5.pathExists(pluginsPath)) {
2324
+ let content = await fs5.readFile(pluginsPath, "utf-8");
2325
+ if (enabled) {
2326
+ content = content.replace(
2327
+ "// {{ORGANIZATIONS_IMPORT}}",
2328
+ 'import { organization } from "better-auth/plugins"'
2329
+ );
2330
+ content = content.replace(
2331
+ " // {{ORGANIZATIONS_PLUGIN}}",
2332
+ " organization(),"
2333
+ );
2334
+ } else {
2335
+ content = content.replace(/.*\/\/ \{\{ORGANIZATIONS_IMPORT\}\}\n?/, "");
2336
+ content = content.replace(/.*\/\/ \{\{ORGANIZATIONS_PLUGIN\}\}\n?/, "");
2118
2337
  }
2119
- return `${providerId}: {
2120
- ${configLines.join("\n ")}
2121
- }`;
2122
- }).join(",\n ");
2123
- parts.push(`socialProviders: {
2124
- ${providersObject}
2125
- },`);
2126
- }
2127
- return parts.join("\n ");
2128
- }
2129
- function generateEnvTsServerSchema(providers) {
2130
- if (providers.length === 0) {
2131
- return "";
2132
- }
2133
- const schemas = providers.map((providerId) => {
2134
- const provider = getProvider(providerId);
2135
- if (!provider) {
2136
- throw new Error(`Unknown OAuth provider: ${providerId}`);
2338
+ await fs5.writeFile(pluginsPath, content);
2137
2339
  }
2138
- const lines = [];
2139
- lines.push(`${provider.envPrefix}_CLIENT_ID: z.string(),`);
2140
- lines.push(`${provider.envPrefix}_CLIENT_SECRET: z.string(),`);
2141
- if (providerId === "figma") {
2142
- lines.push(`FIGMA_CLIENT_KEY: z.string(),`);
2340
+ if (!enabled) {
2341
+ await this.stripOrganizationWiring();
2143
2342
  }
2144
- return lines.join("\n ");
2145
- }).join("\n ");
2146
- return schemas;
2147
- }
2148
- function generateEnvTsRuntimeMapping(providers) {
2149
- if (providers.length === 0) {
2150
- return "";
2151
2343
  }
2152
- const mappings = providers.map((providerId) => {
2153
- const provider = getProvider(providerId);
2154
- if (!provider) {
2155
- throw new Error(`Unknown OAuth provider: ${providerId}`);
2344
+ /**
2345
+ * Rewrites every scaffolded file that references organizations down to
2346
+ * its org-free shape. Called by `configureOrganizations(false)` — the
2347
+ * `--orgs`-declined default. Templates ship org-ENABLED (so `--orgs`
2348
+ * requires no surgery); this is what makes the default scaffold buildable
2349
+ * once the generated registry drops org tables on the first `vex dev`
2350
+ * regen. Every transform throws loudly when its expected pattern is
2351
+ * missing (template drift), rather than silently no-opping.
2352
+ *
2353
+ * `src/vexcms/api.ts` only exists in full (marketing-site overlay)
2354
+ * scaffolds — skipped, not required, for `--bare`.
2355
+ */
2356
+ async stripOrganizationWiring() {
2357
+ const requiredFiles = [
2358
+ { rel: "convex/vex.ts", transform: stripOrgFromGetAuthCall },
2359
+ { rel: "convex/vex/globals.ts", transform: stripOrgFromGetAuthCall },
2360
+ { rel: "convex/vex/media.ts", transform: stripOrgFromGetAuthCall },
2361
+ { rel: "convex/auth/api.ts", transform: stripOrgFromAuthApi },
2362
+ { rel: "src/auth/access.ts", transform: stripOrgFromAccess },
2363
+ { rel: "src/context/AuthContext.tsx", transform: stripOrgFromAuthContext },
2364
+ { rel: "src/auth/hasPermission.ts", transform: stripOrgFromHasPermission },
2365
+ { rel: "src/db/constants/index.ts", transform: stripOrgFromDbConstants }
2366
+ ];
2367
+ for (const file of requiredFiles) {
2368
+ const filePath = path2.join(this.targetPath, file.rel);
2369
+ if (!await fs5.pathExists(filePath)) {
2370
+ throw new Error(
2371
+ `configureOrganizations: required file "${file.rel}" is missing from the scaffold \u2014 template drift?`
2372
+ );
2373
+ }
2374
+ const content = await fs5.readFile(filePath, "utf-8");
2375
+ await fs5.writeFile(filePath, file.transform(filePath, content));
2156
2376
  }
2157
- const lines = [];
2158
- lines.push(`${provider.envPrefix}_CLIENT_ID: process.env.${provider.envPrefix}_CLIENT_ID,`);
2159
- lines.push(`${provider.envPrefix}_CLIENT_SECRET: process.env.${provider.envPrefix}_CLIENT_SECRET,`);
2160
- if (providerId === "figma") {
2161
- lines.push(`FIGMA_CLIENT_KEY: process.env.FIGMA_CLIENT_KEY,`);
2377
+ const apiPath = path2.join(this.targetPath, "src/vexcms/api.ts");
2378
+ if (await fs5.pathExists(apiPath)) {
2379
+ const content = await fs5.readFile(apiPath, "utf-8");
2380
+ await fs5.writeFile(apiPath, stripOrgFromGetAuthCall(apiPath, content));
2162
2381
  }
2163
- return lines.join("\n ");
2164
- }).join("\n ");
2165
- return mappings;
2166
- }
2167
- function generateOAuthUIProvidersBlock(providers) {
2168
- if (providers.length === 0) {
2169
- return "";
2170
- }
2171
- const providerList = providers.map((id) => `"${id}"`).join(", ");
2172
- return `social={{
2173
- providers: [${providerList}]
2174
- }}`;
2175
- }
2176
- function generateEnvVarsBlock(providers, framework) {
2177
- if (providers.length === 0) {
2178
- return "";
2179
2382
  }
2180
- const envVars = providers.flatMap((providerId) => {
2181
- const provider = getProvider(providerId);
2182
- if (!provider) {
2183
- throw new Error(`Unknown OAuth provider: ${providerId}`);
2383
+ /**
2384
+ * Main orchestration method for project initialization
2385
+ *
2386
+ * @param options - Resolved project options from CLI flags and prompts, driving every scaffolding step (template copy, overlay, port, auth, README, git, install).
2387
+ */
2388
+ async initProject(options) {
2389
+ const copySpinner = ora("Copying template files...").start();
2390
+ try {
2391
+ await this.copyBaseFiles();
2392
+ copySpinner.succeed("Template files copied");
2393
+ } catch (error) {
2394
+ copySpinner.fail("Failed to copy template files");
2395
+ throw error;
2184
2396
  }
2185
- if (provider.env && provider.env.length > 0) {
2186
- return provider.env.map((envVar) => {
2187
- let prefix = "";
2188
- if (envVar.type === "client") {
2189
- prefix = framework === "nextjs" ? "NEXT_PUBLIC_" : "VITE_";
2190
- }
2191
- return `# ${envVar.description}
2192
- ${prefix}${envVar.name}=`;
2193
- });
2194
- } else {
2195
- return [
2196
- `${provider.clientIdVar}=`,
2197
- `${provider.clientSecretVar}=`
2198
- ];
2397
+ if (!options.bare) {
2398
+ const overlaySpinner = ora("Applying marketing site template...").start();
2399
+ try {
2400
+ await this.applyTemplateOverlay("marketing-site");
2401
+ overlaySpinner.succeed("Marketing site template applied");
2402
+ } catch (error) {
2403
+ overlaySpinner.fail("Failed to apply template overlay");
2404
+ throw error;
2405
+ }
2199
2406
  }
2200
- });
2201
- return envVars.join("\n");
2202
- }
2203
- function generateReadmeSection(providers) {
2204
- if (providers.length === 0) {
2205
- return "";
2206
- }
2207
- const sections = providers.map((providerId) => {
2208
- const provider = getProvider(providerId);
2209
- if (!provider) {
2210
- throw new Error(`Unknown OAuth provider: ${providerId}`);
2407
+ const nameSpinner = ora("Configuring project...").start();
2408
+ try {
2409
+ await this.updatePackageName(options.projectName);
2410
+ await this.configurePort(options.port);
2411
+ nameSpinner.succeed("Project configured");
2412
+ } catch (error) {
2413
+ nameSpinner.fail("Failed to configure project");
2414
+ throw error;
2211
2415
  }
2212
- if (!provider.readme) {
2213
- throw new Error(
2214
- `Provider ${providerId} missing readme metadata`
2416
+ const authSpinner = ora("Configuring authentication...").start();
2417
+ try {
2418
+ await this.updateOAuthConfig(options.oauthProviders, options.emailPasswordAuth);
2419
+ authSpinner.succeed(
2420
+ options.emailPasswordAuth || options.oauthProviders.length > 0 ? "Authentication configuration updated" : "Authentication placeholders cleaned up"
2215
2421
  );
2422
+ } catch (error) {
2423
+ authSpinner.fail("Failed to configure authentication");
2424
+ throw error;
2425
+ }
2426
+ const oauthUISpinner = ora("Configuring OAuth UI...").start();
2427
+ try {
2428
+ await this.updateOAuthUIConfig(options.oauthProviders, options.emailPasswordAuth);
2429
+ oauthUISpinner.succeed(
2430
+ options.oauthProviders.length > 0 ? "OAuth UI configuration updated" : "OAuth UI placeholders cleaned up"
2431
+ );
2432
+ } catch (error) {
2433
+ oauthUISpinner.fail("Failed to configure OAuth UI");
2434
+ throw error;
2435
+ }
2436
+ const envSpinner = ora("Updating .env.example...").start();
2437
+ try {
2438
+ await this.updateEnvExample(options.oauthProviders);
2439
+ envSpinner.succeed(
2440
+ options.oauthProviders.length > 0 ? ".env.example updated" : ".env.example placeholders cleaned up"
2441
+ );
2442
+ } catch (error) {
2443
+ envSpinner.fail("Failed to update .env.example");
2444
+ throw error;
2445
+ }
2446
+ const envTsSpinner = ora("Updating typed env configuration...").start();
2447
+ try {
2448
+ await this.updateEnvTs(options.oauthProviders);
2449
+ envTsSpinner.succeed(
2450
+ options.oauthProviders.length > 0 ? "Typed env configuration updated" : "Typed env placeholders cleaned up"
2451
+ );
2452
+ } catch (error) {
2453
+ envTsSpinner.fail("Failed to update typed env configuration");
2454
+ throw error;
2455
+ }
2456
+ const readmeSpinner = ora("Updating README...").start();
2457
+ try {
2458
+ await this.updateReadme(options.oauthProviders);
2459
+ readmeSpinner.succeed(
2460
+ options.oauthProviders.length > 0 ? "README updated" : "README placeholders cleaned up"
2461
+ );
2462
+ } catch (error) {
2463
+ readmeSpinner.fail("Failed to update README");
2464
+ throw error;
2465
+ }
2466
+ const orgsSpinner = ora("Configuring organizations...").start();
2467
+ try {
2468
+ await this.configureOrganizations(options.orgs);
2469
+ orgsSpinner.succeed(
2470
+ options.orgs ? "Organizations plugin enabled" : "Organizations placeholders cleaned up"
2471
+ );
2472
+ } catch (error) {
2473
+ orgsSpinner.fail("Failed to configure organizations");
2474
+ throw error;
2475
+ }
2476
+ const secretSpinner = ora("Generating auth secret...").start();
2477
+ try {
2478
+ await this.writeAuthSecret();
2479
+ secretSpinner.succeed("Auth secret generated");
2480
+ } catch (error) {
2481
+ secretSpinner.fail("Failed to generate auth secret");
2482
+ throw error;
2483
+ }
2484
+ if (options.monorepo) {
2485
+ const monorepoSpinner = ora("Rewriting dependencies for the host workspace...").start();
2486
+ try {
2487
+ await this.applyMonorepoRewrite(options);
2488
+ monorepoSpinner.succeed("Dependencies rewritten for the host workspace");
2489
+ } catch (error) {
2490
+ monorepoSpinner.fail("Failed to rewrite dependencies for the host workspace");
2491
+ throw error;
2492
+ }
2493
+ }
2494
+ if (options.initGit && !options.monorepo) {
2495
+ await this.initGitRepo();
2496
+ }
2497
+ if (options.installDependencies && !options.monorepo) {
2498
+ await this.installDependencies();
2499
+ await this.lintCode();
2500
+ await this.formatCode();
2216
2501
  }
2217
- return provider.readme.content;
2218
- }).filter(Boolean);
2219
- if (sections.length === 0) {
2220
- return "";
2221
2502
  }
2222
- return `# OAuth Provider Setup
2223
-
2224
- ${sections.join("\n\n---\n\n")}`;
2225
- }
2503
+ };
2226
2504
 
2227
2505
  // src/installers/nextjs.ts
2506
+ import { join as join2 } from "path";
2228
2507
  var VexNextJSInstaller = class extends VexFrameworkInstaller {
2508
+ /**
2509
+ * Framework identifier used for template selection and logging.
2510
+ *
2511
+ * @returns The literal string `"nextjs"`.
2512
+ */
2229
2513
  get frameworkName() {
2230
2514
  return "nextjs";
2231
2515
  }
2232
2516
  /**
2233
2517
  * Update OAuth configuration in Convex auth options file
2234
2518
  * Target file: convex/auth/options.ts
2519
+ *
2520
+ * @param selectedProviders - IDs of the OAuth providers chosen for the project (e.g. `["google", "github"]`).
2521
+ * @param emailPasswordEnabled - Whether email/password authentication is also enabled.
2235
2522
  */
2236
2523
  async updateOAuthConfig(selectedProviders, emailPasswordEnabled) {
2237
2524
  const authFilePath = join2(this.targetPath, "convex/auth/options.ts");
@@ -2254,6 +2541,9 @@ var VexNextJSInstaller = class extends VexFrameworkInstaller {
2254
2541
  /**
2255
2542
  * Update OAuth UI configuration in auth client file
2256
2543
  * Target file: src/auth/client.tsx
2544
+ *
2545
+ * @param selectedProviders - IDs of the OAuth providers chosen for the project (e.g. `["google", "github"]`).
2546
+ * @param emailPasswordEnabled - Whether email/password authentication is also enabled.
2257
2547
  */
2258
2548
  async updateOAuthUIConfig(selectedProviders, emailPasswordEnabled) {
2259
2549
  const providersFilePath = join2(this.targetPath, "src/auth/client.tsx");
@@ -2273,6 +2563,8 @@ var VexNextJSInstaller = class extends VexFrameworkInstaller {
2273
2563
  /**
2274
2564
  * Update .env.example with OAuth environment variables
2275
2565
  * Target file: .env.example
2566
+ *
2567
+ * @param selectedProviders - IDs of the OAuth providers chosen for the project (e.g. `["google", "github"]`).
2276
2568
  */
2277
2569
  async updateEnvExample(selectedProviders) {
2278
2570
  const envFilePath = join2(this.targetPath, ".env.example");
@@ -2286,6 +2578,8 @@ var VexNextJSInstaller = class extends VexFrameworkInstaller {
2286
2578
  /**
2287
2579
  * Update README with OAuth provider setup guides
2288
2580
  * Target file: README.md
2581
+ *
2582
+ * @param selectedProviders - IDs of the OAuth providers chosen for the project (e.g. `["google", "github"]`).
2289
2583
  */
2290
2584
  async updateReadme(selectedProviders) {
2291
2585
  const readmeFilePath = join2(this.targetPath, "README.md");
@@ -2300,6 +2594,8 @@ var VexNextJSInstaller = class extends VexFrameworkInstaller {
2300
2594
  /**
2301
2595
  * Update env.mjs with OAuth provider environment variables
2302
2596
  * Target file: src/env.mjs
2597
+ *
2598
+ * @param selectedProviders - IDs of the OAuth providers chosen for the project (e.g. `["google", "github"]`).
2303
2599
  */
2304
2600
  async updateEnvTs(selectedProviders) {
2305
2601
  const envFilePath = join2(this.targetPath, "src/env.mjs");
@@ -2331,7 +2627,7 @@ function createInstaller(props) {
2331
2627
  }
2332
2628
 
2333
2629
  // src/index.ts
2334
- var program = new Command().name("create-vexcms").description("Scaffold a new VEX CMS project").argument("[project-name]", "Project directory name").option("--bare", "Skip marketing site collections, scaffold empty project").option("--orgs", "Enable multi-tenant organizations").version("0.0.2").parse();
2630
+ var program = new Command().name("create-vexcms").description("Scaffold a new VEX CMS project").argument("[project-name]", "Project directory name").option("--bare", "Skip marketing site collections, scaffold empty project").option("--orgs", "Enable multi-tenant organizations").option("--monorepo", "Scaffold into apps/<name> under the detected pnpm workspace root, rewriting dependencies to workspace/catalog protocols").option("--yes", "Accept every prompt default without rendering (for automation)").version("0.0.2").parse();
2335
2631
  var args = program.args;
2336
2632
  var opts = program.opts();
2337
2633
  async function main() {
@@ -2339,15 +2635,19 @@ async function main() {
2339
2635
  console.log(chalk2.bold(" create-vexcms"));
2340
2636
  console.log();
2341
2637
  const bare = opts.bare ?? false;
2638
+ const yes = opts.yes ?? false;
2639
+ const monorepo = opts.monorepo ?? false;
2342
2640
  let inputArg;
2343
2641
  if (args[0]) {
2344
2642
  inputArg = args[0];
2643
+ } else if (yes) {
2644
+ inputArg = "my-vexcms-app";
2345
2645
  } else {
2346
2646
  inputArg = await input({
2347
2647
  message: "(1/8) What is your project named?",
2348
2648
  default: "my-vexcms-app",
2349
2649
  validate: (value) => {
2350
- const name = value.includes("/") ? path2.basename(value) : value;
2650
+ const name = value.includes("/") ? path3.basename(value) : value;
2351
2651
  if (name === ".") return true;
2352
2652
  const result = validateProjectName(name);
2353
2653
  if (result.valid) return true;
@@ -2356,52 +2656,80 @@ async function main() {
2356
2656
  });
2357
2657
  }
2358
2658
  inputArg = resolveProjectName(inputArg, process.cwd());
2359
- const projectName = inputArg === "." ? path2.basename(process.cwd()) : path2.basename(inputArg);
2659
+ const projectName = inputArg === "." ? path3.basename(process.cwd()) : path3.basename(inputArg);
2360
2660
  const validation = validateProjectName(projectName);
2361
2661
  if (!validation.valid) {
2362
2662
  displayInvalidNameError(projectName, validation.errors);
2363
2663
  process.exit(1);
2364
2664
  }
2365
- const targetDir = inputArg === "." ? process.cwd() : path2.resolve(process.cwd(), inputArg);
2366
- if (await checkDirectoryExists(targetDir)) {
2367
- if (!await isDirectoryEmpty(targetDir)) {
2368
- displayDirectoryNotEmptyError();
2665
+ let targetDir;
2666
+ let workspaceRoot = null;
2667
+ if (monorepo) {
2668
+ workspaceRoot = await findWorkspaceRoot({ cwd: process.cwd() });
2669
+ if (!workspaceRoot) {
2670
+ console.error(chalk2.red("\nError: --monorepo requires a pnpm workspace."));
2671
+ console.error(chalk2.yellow(`No pnpm-workspace.yaml was found walking up from ${process.cwd()}.`));
2672
+ console.error(chalk2.yellow("Run this command from inside a pnpm workspace, or drop --monorepo."));
2369
2673
  process.exit(1);
2370
2674
  }
2675
+ targetDir = path3.join(workspaceRoot, "apps", projectName);
2676
+ if (await checkDirectoryExists(targetDir)) {
2677
+ console.error(chalk2.red(`
2678
+ Error: 'apps/${projectName}' already exists under ${workspaceRoot}.`));
2679
+ console.error(chalk2.yellow("--monorepo refuses to scaffold over an existing directory."));
2680
+ process.exit(1);
2681
+ }
2682
+ } else {
2683
+ targetDir = inputArg === "." ? process.cwd() : path3.resolve(process.cwd(), inputArg);
2684
+ if (await checkDirectoryExists(targetDir)) {
2685
+ if (!await isDirectoryEmpty(targetDir)) {
2686
+ displayDirectoryNotEmptyError();
2687
+ process.exit(1);
2688
+ }
2689
+ }
2371
2690
  }
2372
2691
  let framework;
2373
- while (true) {
2374
- framework = await select({
2375
- message: "(2/8) Select a framework:",
2376
- choices: [
2377
- { name: "Next.js (Recommended)", value: "nextjs" },
2378
- { name: "TanStack Start (Coming Soon)", value: "tanstack" }
2379
- ]
2380
- });
2381
- if (framework === "tanstack") {
2382
- console.log(chalk2.yellow("\n TanStack Start support is coming soon! Please select Next.js for now.\n"));
2383
- continue;
2692
+ if (yes) {
2693
+ framework = "nextjs";
2694
+ } else {
2695
+ while (true) {
2696
+ framework = await select({
2697
+ message: "(2/8) Select a framework:",
2698
+ choices: [
2699
+ { name: "Next.js (Recommended)", value: "nextjs" },
2700
+ { name: "TanStack Start (Coming Soon)", value: "tanstack" }
2701
+ ]
2702
+ });
2703
+ if (framework === "tanstack") {
2704
+ console.log(chalk2.yellow("\n TanStack Start support is coming soon! Please select Next.js for now.\n"));
2705
+ continue;
2706
+ }
2707
+ break;
2384
2708
  }
2385
- break;
2386
2709
  }
2387
- const portInput = await input({
2388
- message: "(3/8) Dev server port:",
2389
- default: "3010",
2390
- validate: (value) => {
2391
- const num = parseInt(value, 10);
2392
- if (isNaN(num) || num < 1 || num > 65535) return "Must be a valid port number (1-65535)";
2393
- return true;
2394
- }
2395
- });
2396
- const port = parseInt(portInput, 10);
2397
- const emailPasswordAuth = await confirm({
2710
+ let port;
2711
+ if (yes) {
2712
+ port = 3010;
2713
+ } else {
2714
+ const portInput = await input({
2715
+ message: "(3/8) Dev server port:",
2716
+ default: "3010",
2717
+ validate: (value) => {
2718
+ const num = parseInt(value, 10);
2719
+ if (isNaN(num) || num < 1 || num > 65535) return "Must be a valid port number (1-65535)";
2720
+ return true;
2721
+ }
2722
+ });
2723
+ port = parseInt(portInput, 10);
2724
+ }
2725
+ const emailPasswordAuth = yes ? true : await confirm({
2398
2726
  message: "(4/8) Enable email/password authentication?",
2399
2727
  default: true
2400
2728
  });
2401
- const orgs = opts.orgs ?? await confirm({
2729
+ const orgs = opts.orgs ?? (yes ? false : await confirm({
2402
2730
  message: "(5/8) Enable multi-tenant (organizations)?",
2403
2731
  default: false
2404
- });
2732
+ }));
2405
2733
  const popularProviders = getPopularProviders();
2406
2734
  const additionalProviders = getAdditionalProviders();
2407
2735
  const allProviderChoices = [
@@ -2415,15 +2743,15 @@ async function main() {
2415
2743
  value: p.id
2416
2744
  }))
2417
2745
  ];
2418
- const oauthProviders = await checkbox({
2746
+ const oauthProviders = yes ? [] : await checkbox({
2419
2747
  message: "(6/8) Select OAuth providers (space to toggle, enter to confirm):",
2420
2748
  choices: allProviderChoices
2421
2749
  });
2422
- const initGit = await confirm({
2750
+ const initGit = yes ? true : await confirm({
2423
2751
  message: "(7/8) Initialize a Git repository?",
2424
2752
  default: true
2425
2753
  });
2426
- const installDependencies = await confirm({
2754
+ const installDependencies = yes ? false : await confirm({
2427
2755
  message: "(8/8) Install dependencies?",
2428
2756
  default: false
2429
2757
  });
@@ -2437,9 +2765,12 @@ async function main() {
2437
2765
  emailPasswordAuth,
2438
2766
  oauthProviders,
2439
2767
  initGit,
2440
- installDependencies
2768
+ installDependencies,
2769
+ monorepo,
2770
+ workspaceRoot,
2771
+ yes
2441
2772
  };
2442
- await fs5.ensureDir(targetDir);
2773
+ await fs6.ensureDir(targetDir);
2443
2774
  const installer = createInstaller({
2444
2775
  framework,
2445
2776
  projectDir: targetDir,
@@ -2449,6 +2780,10 @@ async function main() {
2449
2780
  await installer.initProject(options);
2450
2781
  console.log();
2451
2782
  displaySuccessMessage(options.projectName, targetDir, inputArg === ".");
2783
+ if (monorepo && workspaceRoot) {
2784
+ console.log(chalk2.cyan(` Run 'pnpm install' from ${workspaceRoot} to link the new workspace member.`));
2785
+ console.log();
2786
+ }
2452
2787
  }
2453
2788
  main().catch((error) => {
2454
2789
  console.error(chalk2.red("\nAn error occurred:"), error.message);