create-vexcms 0.0.20 → 0.1.0-alpha.11
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/LICENSE +202 -0
- package/README.md +118 -85
- package/dist/index.js +1007 -639
- package/package.json +35 -15
- package/templates/base-nextjs/README.md +63 -203
- package/templates/base-nextjs/_env.example +7 -9
- package/templates/base-nextjs/_gitignore +41 -1
- package/templates/base-nextjs/convex/_generated/api.d.ts +6 -62
- package/templates/base-nextjs/convex/_generated/server.d.ts +19 -0
- package/templates/base-nextjs/convex/_generated/server.js +8 -0
- package/templates/base-nextjs/convex/auth/api.ts +49 -3
- package/templates/base-nextjs/convex/auth/db.ts +42 -300
- package/templates/base-nextjs/convex/auth/index.ts +10 -6
- package/templates/base-nextjs/convex/auth/options.ts +5 -29
- package/templates/base-nextjs/convex/auth/plugins/index.ts +15 -5
- package/templates/base-nextjs/convex/auth/sessions.ts +9 -15
- package/templates/base-nextjs/convex/http.ts +18 -3
- package/templates/base-nextjs/convex/schema.ts +104 -2
- package/templates/base-nextjs/convex/tsconfig.json +2 -2
- package/templates/base-nextjs/convex/vex/firstUser.ts +12 -86
- package/templates/base-nextjs/convex/vex/globals.ts +19 -82
- package/templates/base-nextjs/convex/vex/media.ts +19 -246
- package/templates/base-nextjs/convex/vex.schema.ts +138 -0
- package/templates/base-nextjs/convex/vex.ts +19 -0
- package/templates/base-nextjs/convex.json +6 -0
- package/templates/base-nextjs/eslint.config.mjs +1 -21
- package/templates/base-nextjs/next.config.ts +57 -1
- package/templates/base-nextjs/package.json +46 -48
- package/templates/base-nextjs/public/favicons/favicon.ico +0 -0
- package/templates/base-nextjs/src/app/(frontend)/@auth/(...)auth/[pathname]/view.tsx +17 -5
- package/templates/base-nextjs/src/app/(frontend)/auth/[pathname]/view.tsx +2 -4
- package/templates/base-nextjs/src/app/(frontend)/layout.tsx +12 -2
- package/templates/base-nextjs/src/app/(frontend)/page.tsx +2 -113
- package/templates/base-nextjs/src/app/(vexcms)/admin/[[...path]]/page.tsx +40 -0
- package/templates/base-nextjs/src/app/(vexcms)/admin/clientProviders.tsx +22 -0
- package/templates/base-nextjs/src/app/(vexcms)/admin/layout.tsx +19 -0
- package/templates/base-nextjs/src/app/globals.css +241 -104
- package/templates/base-nextjs/src/app/layout.tsx +29 -13
- package/templates/base-nextjs/src/app/unauthorized/page.tsx +24 -0
- package/templates/base-nextjs/src/auth/access.ts +46 -0
- package/templates/base-nextjs/src/auth/client.tsx +17 -5
- package/templates/base-nextjs/src/auth/hasPermission.ts +39 -0
- package/templates/base-nextjs/src/auth/serverUtils.ts +29 -25
- package/templates/base-nextjs/src/components/WelcomePage.tsx +127 -0
- package/templates/base-nextjs/src/components/component-example.tsx +56 -464
- package/templates/base-nextjs/src/components/providers/auth.tsx +13 -0
- package/templates/base-nextjs/src/components/providers/client.tsx +6 -1
- package/templates/base-nextjs/src/components/providers/convex.tsx +53 -21
- package/templates/base-nextjs/src/components/providers/server.tsx +10 -7
- package/templates/{marketing-site → base-nextjs}/src/components/ui/accordion.tsx +30 -32
- package/templates/base-nextjs/src/components/ui/badge.tsx +10 -13
- package/templates/base-nextjs/src/components/ui/input.tsx +35 -5
- package/templates/base-nextjs/src/context/AuthContext.tsx +29 -0
- package/templates/base-nextjs/src/db/constants/auth.ts +0 -1
- package/templates/base-nextjs/src/db/constants/index.ts +22 -35
- package/templates/base-nextjs/src/lib/utils.ts +0 -12
- package/templates/base-nextjs/src/proxy.ts +97 -12
- package/templates/base-nextjs/src/vex.config.ts +35 -0
- package/templates/base-nextjs/src/vex.types.ts +293 -0
- package/templates/base-nextjs/src/vexcms/collections/images.ts +15 -0
- package/templates/base-nextjs/src/vexcms/collections/index.ts +2 -1
- package/templates/base-nextjs/src/vexcms/collections/users.ts +8 -34
- package/templates/base-nextjs/tsconfig.json +3 -0
- package/templates/marketing-site/convex/_generated/api.d.ts +81 -0
- package/templates/marketing-site/convex/_generated/api.js +23 -0
- package/templates/marketing-site/convex/_generated/dataModel.d.ts +60 -0
- package/templates/marketing-site/convex/_generated/server.d.ts +162 -0
- package/templates/marketing-site/convex/_generated/server.js +101 -0
- package/templates/marketing-site/convex/footers.ts +11 -7
- package/templates/marketing-site/convex/headers.ts +15 -5
- package/templates/marketing-site/convex/pages.ts +20 -35
- package/templates/marketing-site/convex/schema.ts +119 -0
- package/templates/marketing-site/convex/seed.ts +1039 -39
- package/templates/marketing-site/convex/siteSettings.ts +19 -5
- package/templates/marketing-site/convex/theme.ts +55 -61
- package/templates/marketing-site/convex/vex.schema.ts +387 -0
- package/templates/marketing-site/public/favicon.svg +14 -0
- package/templates/marketing-site/public/favicons/apple-touch-icon.png +0 -0
- package/templates/marketing-site/public/favicons/favicon.ico +0 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/PageContent.tsx +52 -0
- package/templates/marketing-site/src/app/(frontend)/{[slug] → (site)/[slug]}/page.tsx +7 -8
- package/templates/marketing-site/src/app/(frontend)/(site)/layout.tsx +47 -0
- package/templates/marketing-site/src/app/(frontend)/{page.tsx → (site)/page.tsx} +5 -6
- package/templates/marketing-site/src/app/(frontend)/layout.tsx +13 -25
- package/templates/marketing-site/src/app/(vexcms)/admin/layout.tsx +32 -0
- package/templates/marketing-site/src/app/globals.css +374 -0
- package/templates/marketing-site/src/app/layout.tsx +88 -0
- package/templates/marketing-site/src/components/BrandMark.tsx +27 -0
- package/templates/marketing-site/src/components/CodePane.tsx +86 -0
- package/templates/marketing-site/src/components/Container.tsx +25 -0
- package/templates/marketing-site/src/components/CopyButton.tsx +80 -0
- package/templates/marketing-site/src/components/FirstAdminBootstrap.tsx +54 -0
- package/templates/marketing-site/src/components/InstallCommand.tsx +29 -0
- package/templates/marketing-site/src/components/MediaImage.tsx +59 -0
- package/templates/marketing-site/src/components/SectionHeader.tsx +51 -0
- package/templates/marketing-site/src/components/SiteFooter.tsx +11 -6
- package/templates/marketing-site/src/components/SiteHeader.tsx +11 -6
- package/templates/marketing-site/src/components/ThemeLive.tsx +58 -0
- package/templates/marketing-site/src/components/ThemeStyle.tsx +45 -90
- package/templates/marketing-site/src/components/motion-primitives/animated-group.tsx +44 -120
- package/templates/marketing-site/src/components/motion-primitives/reveal.module.css +32 -0
- package/templates/marketing-site/src/components/motion-primitives/text-effect.tsx +47 -266
- package/templates/marketing-site/src/lib/highlight.ts +71 -0
- package/templates/marketing-site/src/lib/metadata.ts +51 -74
- package/templates/marketing-site/src/lib/shikiStarkEmber.json +57 -0
- package/templates/marketing-site/src/proxy.ts +123 -0
- package/templates/marketing-site/src/vex.config.ts +34 -0
- package/templates/marketing-site/src/vex.types.ts +651 -0
- package/templates/marketing-site/src/vexcms/api.ts +23 -0
- package/templates/marketing-site/src/vexcms/blocks/CTA/config.ts +6 -5
- package/templates/marketing-site/src/vexcms/blocks/CTA/index.tsx +41 -25
- package/templates/marketing-site/src/vexcms/blocks/CodeShowcase/config.ts +62 -0
- package/templates/marketing-site/src/vexcms/blocks/CodeShowcase/index.tsx +106 -0
- package/templates/marketing-site/src/vexcms/blocks/FAQ/config.ts +7 -6
- package/templates/marketing-site/src/vexcms/blocks/FAQ/index.tsx +65 -52
- package/templates/marketing-site/src/vexcms/blocks/Features/config.ts +5 -8
- package/templates/marketing-site/src/vexcms/blocks/Features/index.tsx +44 -59
- package/templates/marketing-site/src/vexcms/blocks/Footer/config.ts +13 -14
- package/templates/marketing-site/src/vexcms/blocks/Footer/index.tsx +91 -54
- package/templates/marketing-site/src/vexcms/blocks/Header/config.ts +19 -12
- package/templates/marketing-site/src/vexcms/blocks/Header/index.tsx +206 -140
- package/templates/marketing-site/src/vexcms/blocks/Hero/config.ts +28 -14
- package/templates/marketing-site/src/vexcms/blocks/Hero/index.tsx +176 -130
- package/templates/marketing-site/src/vexcms/blocks/HowItWorks/config.ts +6 -10
- package/templates/marketing-site/src/vexcms/blocks/HowItWorks/index.tsx +60 -55
- package/templates/marketing-site/src/vexcms/blocks/Roadmap/config.ts +38 -57
- package/templates/marketing-site/src/vexcms/blocks/Roadmap/index.tsx +247 -95
- package/templates/marketing-site/src/vexcms/blocks/Split/config.ts +62 -0
- package/templates/marketing-site/src/vexcms/blocks/Split/index.tsx +186 -0
- package/templates/marketing-site/src/vexcms/blocks/Stats/config.ts +35 -0
- package/templates/marketing-site/src/vexcms/blocks/Stats/index.tsx +77 -0
- package/templates/marketing-site/src/vexcms/blocks/config.ts +18 -17
- package/templates/marketing-site/src/vexcms/blocks/constants.ts +5 -3
- package/templates/marketing-site/src/vexcms/blocks/index.ts +18 -8
- package/templates/marketing-site/src/vexcms/collections/footers.ts +2 -1
- package/templates/marketing-site/src/vexcms/collections/headers.ts +2 -1
- package/templates/marketing-site/src/vexcms/collections/index.ts +2 -1
- package/templates/marketing-site/src/vexcms/collections/pages.ts +16 -21
- package/templates/marketing-site/src/vexcms/collections/themeColors.ts +118 -0
- package/templates/marketing-site/src/vexcms/collections/themes.ts +29 -111
- package/templates/marketing-site/src/vexcms/collections/users.ts +20 -0
- package/templates/marketing-site/src/vexcms/globals/index.ts +1 -1
- package/templates/marketing-site/src/vexcms/globals/siteSettings.ts +43 -27
- package/templates/base-nextjs/convex/auth/adapter/index.ts +0 -230
- package/templates/base-nextjs/convex/auth/adapter/utils.ts +0 -547
- package/templates/base-nextjs/convex/auth/config.ts +0 -10
- package/templates/base-nextjs/convex/vex/auth.ts +0 -38
- package/templates/base-nextjs/convex/vex/collections.ts +0 -333
- package/templates/base-nextjs/convex/vex/helpers.ts +0 -33
- package/templates/base-nextjs/convex/vex/impersonation.ts +0 -51
- package/templates/base-nextjs/convex/vex/migrate.ts +0 -74
- package/templates/base-nextjs/convex/vex/model/collections.ts +0 -196
- package/templates/base-nextjs/convex/vex/model/media.ts +0 -87
- package/templates/base-nextjs/convex/vex/model/versions.ts +0 -254
- package/templates/base-nextjs/convex/vex/previewSnapshot.ts +0 -53
- package/templates/base-nextjs/convex/vex/versions.ts +0 -648
- package/templates/base-nextjs/src/app/admin/AdminLayoutWrapper.tsx +0 -33
- package/templates/base-nextjs/src/app/admin/AdminPageWrapper.tsx +0 -38
- package/templates/base-nextjs/src/app/admin/RichTextFieldWithMedia.tsx +0 -101
- package/templates/base-nextjs/src/app/admin/[[...path]]/page.tsx +0 -15
- package/templates/base-nextjs/src/app/admin/layout.tsx +0 -51
- package/templates/base-nextjs/src/app/favicon.ico +0 -0
- package/templates/base-nextjs/src/components/providers/theme.tsx +0 -11
- package/templates/base-nextjs/src/components/ui/theme-toggle.tsx +0 -67
- package/templates/base-nextjs/src/db/types.ts +0 -6
- package/templates/base-nextjs/src/vexcms/access.ts +0 -44
- package/templates/base-nextjs/src/vexcms/auth.ts +0 -4
- package/templates/base-nextjs/src/vexcms/collections/media.ts +0 -15
- package/templates/base-nextjs/vex.config.ts +0 -25
- package/templates/marketing-site/src/app/(frontend)/PageContent.tsx +0 -186
- package/templates/marketing-site/src/app/(frontend)/preview/[slug]/PreviewPageContent.tsx +0 -55
- package/templates/marketing-site/src/app/(frontend)/preview/[slug]/page.tsx +0 -27
- package/templates/marketing-site/src/app/(frontend)/preview/layout.tsx +0 -18
- package/templates/marketing-site/src/app/admin/AdminLayoutWrapper.tsx +0 -71
- package/templates/marketing-site/src/components/ThemeInjector.tsx +0 -128
- package/templates/marketing-site/src/components/admin/IconPickerField.tsx +0 -134
- package/templates/marketing-site/src/components/admin/ThemeImport.tsx +0 -174
- package/templates/marketing-site/src/components/admin/ThemeImportField.tsx +0 -33
- package/templates/marketing-site/src/db/constants/index.ts +0 -52
- package/templates/marketing-site/src/lib/colorConvert.ts +0 -171
- package/templates/marketing-site/src/vexcms/access.ts +0 -36
- 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
|
|
8
|
-
import
|
|
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
|
|
35
|
+
} catch {
|
|
36
36
|
return true;
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
-
function resolveProjectName(input2,
|
|
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
|
|
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
|
|
128
|
+
import path2 from "path";
|
|
84
129
|
|
|
85
130
|
// src/helpers/fileOperations.ts
|
|
86
|
-
import
|
|
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 (!
|
|
139
|
+
if (!fs3.existsSync(templatePath)) {
|
|
95
140
|
templatePath = join(__dirname, "../templates", templateDir);
|
|
96
141
|
}
|
|
97
|
-
await
|
|
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
|
|
109
|
-
await
|
|
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
|
|
123
|
-
await
|
|
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
|
|
173
|
+
await fs3.copy(overlayDir, targetDir, { overwrite: true });
|
|
129
174
|
}
|
|
130
175
|
|
|
131
|
-
// src/installers/
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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://
|
|
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: "
|
|
500
|
-
content: `##
|
|
310
|
+
title: "Discord OAuth Setup",
|
|
311
|
+
content: `## Discord OAuth Setup
|
|
501
312
|
|
|
502
|
-
1. Create
|
|
503
|
-
2.
|
|
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
|
-
|
|
510
|
-
id: "
|
|
511
|
-
name: "
|
|
512
|
-
envPrefix: "
|
|
513
|
-
clientIdVar: "
|
|
514
|
-
clientSecretVar: "
|
|
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: '"
|
|
519
|
-
socialProvider: `
|
|
520
|
-
clientId: process.env.
|
|
521
|
-
clientSecret: process.env.
|
|
329
|
+
clientSideProvider: '"apple"',
|
|
330
|
+
socialProvider: `apple({
|
|
331
|
+
clientId: process.env.APPLE_CLIENT_ID!,
|
|
332
|
+
clientSecret: process.env.APPLE_CLIENT_SECRET!,
|
|
522
333
|
})`,
|
|
523
|
-
scopes: [
|
|
334
|
+
scopes: []
|
|
524
335
|
},
|
|
525
336
|
env: [
|
|
526
337
|
{
|
|
527
|
-
name: "
|
|
338
|
+
name: "APPLE_CLIENT_ID",
|
|
528
339
|
type: "server",
|
|
529
|
-
description: "
|
|
340
|
+
description: "Apple Sign In Service ID"
|
|
530
341
|
},
|
|
531
342
|
{
|
|
532
|
-
name: "
|
|
343
|
+
name: "APPLE_CLIENT_SECRET",
|
|
533
344
|
type: "server",
|
|
534
|
-
description: "
|
|
345
|
+
description: "Apple Sign In Client Secret (JWT token)"
|
|
535
346
|
}
|
|
536
347
|
],
|
|
537
348
|
docs: {
|
|
538
|
-
provider: "https://
|
|
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: "
|
|
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: "
|
|
545
|
-
content: `##
|
|
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,745 @@ 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 MARKETING_DEPENDENCIES = {
|
|
2072
|
+
/** Render-time syntax highlighting for the CodeShowcase and Split blocks. */
|
|
2073
|
+
shiki: "^4.4.3"
|
|
2074
|
+
};
|
|
2075
|
+
var VexFrameworkInstaller = class {
|
|
2076
|
+
/**
|
|
2077
|
+
* Binds this installer to the destination directory and package name used
|
|
2078
|
+
* throughout scaffolding (template copy target, package.json, README, etc).
|
|
2079
|
+
*
|
|
2080
|
+
* @param targetPath - Absolute path of the directory the project is created in.
|
|
2081
|
+
* @param projectName - npm package name recorded for subclasses; not read directly by the base class.
|
|
2082
|
+
*/
|
|
2083
|
+
constructor(targetPath, projectName) {
|
|
2084
|
+
this.targetPath = targetPath;
|
|
2085
|
+
this.projectName = projectName;
|
|
2086
|
+
}
|
|
2087
|
+
targetPath;
|
|
2088
|
+
projectName;
|
|
2089
|
+
/**
|
|
2090
|
+
* Copy base template files to target directory
|
|
2091
|
+
*/
|
|
2092
|
+
async copyBaseFiles() {
|
|
2093
|
+
await copyTemplate(this.frameworkName, this.targetPath);
|
|
2094
|
+
}
|
|
2095
|
+
/**
|
|
2096
|
+
* Apply a template overlay on top of the base template.
|
|
2097
|
+
* Merges overlay files onto the already-copied base — existing files
|
|
2098
|
+
* not in the overlay are left untouched.
|
|
2099
|
+
*
|
|
2100
|
+
* @param overlay - Name of the overlay template directory under `templates/` (e.g. `"marketing-site"`).
|
|
2101
|
+
*/
|
|
2102
|
+
async applyTemplateOverlay(overlay) {
|
|
2103
|
+
const { dirname: dirname2 } = await import("path");
|
|
2104
|
+
const { fileURLToPath: fileURLToPath2 } = await import("url");
|
|
2105
|
+
const __filename = fileURLToPath2(import.meta.url);
|
|
2106
|
+
const __dirname = dirname2(__filename);
|
|
2107
|
+
let overlayDir = path2.join(__dirname, "../../templates", overlay);
|
|
2108
|
+
if (!await fs5.pathExists(overlayDir)) {
|
|
2109
|
+
overlayDir = path2.join(__dirname, "../templates", overlay);
|
|
2110
|
+
}
|
|
2111
|
+
await overlayTemplate({ overlayDir, targetDir: this.targetPath });
|
|
2112
|
+
}
|
|
2113
|
+
/**
|
|
2114
|
+
* Apply the parts of the marketing overlay that copying files cannot express.
|
|
2115
|
+
*
|
|
2116
|
+
* `overlayTemplate` only adds and overwrites — it has no way to remove a
|
|
2117
|
+
* base file, and it does not touch `package.json`. Two things therefore
|
|
2118
|
+
* have to happen here:
|
|
2119
|
+
*
|
|
2120
|
+
* 1. **Delete base's `(frontend)/page.tsx`.** It and the overlay's
|
|
2121
|
+
* `(frontend)/(site)/page.tsx` both resolve to `/`. Next picks base's,
|
|
2122
|
+
* so the marketing home page is unreachable and every scaffold shows the
|
|
2123
|
+
* bare bootstrap `WelcomePage` instead of the seeded site. The `(site)`
|
|
2124
|
+
* route group owns `/` in a marketing scaffold.
|
|
2125
|
+
* 2. **Add `shiki`.** The CodeShowcase and Split blocks highlight at render
|
|
2126
|
+
* time in a server component. It is a marketing-only dependency, so it
|
|
2127
|
+
* is added here rather than carried in base's `package.json` where a
|
|
2128
|
+
* `--bare` scaffold would install it for nothing.
|
|
2129
|
+
*/
|
|
2130
|
+
async finalizeMarketingOverlay() {
|
|
2131
|
+
const homeRoute = path2.join(this.targetPath, "src/app/(frontend)/page.tsx");
|
|
2132
|
+
await fs5.remove(homeRoute);
|
|
2133
|
+
const pkgPath = path2.join(this.targetPath, "package.json");
|
|
2134
|
+
const pkg = await fs5.readJson(pkgPath);
|
|
2135
|
+
pkg.dependencies = { ...pkg.dependencies, shiki: MARKETING_DEPENDENCIES.shiki };
|
|
2136
|
+
pkg.dependencies = Object.fromEntries(
|
|
2137
|
+
Object.entries(pkg.dependencies).sort(([a], [b]) => a.localeCompare(b))
|
|
2138
|
+
);
|
|
2139
|
+
await fs5.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
2140
|
+
}
|
|
2141
|
+
/**
|
|
2142
|
+
* Update the package.json name field, the vex:update script (to match the
|
|
2143
|
+
* detected package manager), and README.md's `{{PROJECT_NAME}}` heading.
|
|
2144
|
+
*
|
|
2145
|
+
* @param name - npm package name to write into `package.json`'s `name` field
|
|
2146
|
+
* and to substitute for `{{PROJECT_NAME}}` in `README.md`.
|
|
2147
|
+
*/
|
|
2148
|
+
async updatePackageName(name) {
|
|
2149
|
+
const pkgPath = path2.join(this.targetPath, "package.json");
|
|
2150
|
+
const pkg = await fs5.readJson(pkgPath);
|
|
2151
|
+
pkg.name = name;
|
|
2152
|
+
const pm = this.detectPackageManager();
|
|
2153
|
+
if (pkg.scripts?.["vex:update"] && pm !== "pnpm") {
|
|
2154
|
+
const addCmd = pm === "yarn" ? "yarn add" : pm === "bun" ? "bun add" : "npm install";
|
|
2155
|
+
pkg.scripts["vex:update"] = pkg.scripts["vex:update"].replace(/^pnpm add/, addCmd);
|
|
2156
|
+
}
|
|
2157
|
+
await fs5.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
2158
|
+
const readmePath = path2.join(this.targetPath, "README.md");
|
|
2159
|
+
if (await fs5.pathExists(readmePath)) {
|
|
2160
|
+
const readme = await fs5.readFile(readmePath, "utf-8");
|
|
2161
|
+
await fs5.writeFile(readmePath, readme.replace("{{PROJECT_NAME}}", name));
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* Configure the dev server port.
|
|
2166
|
+
* Updates package.json dev script and creates .env.local with NEXT_PUBLIC_SITE_URL.
|
|
2167
|
+
*
|
|
2168
|
+
* @param port - Dev server port number to write into the `dev` script and `.env.local`.
|
|
2169
|
+
*/
|
|
2170
|
+
async configurePort(port) {
|
|
2171
|
+
const pkgPath = path2.join(this.targetPath, "package.json");
|
|
2172
|
+
const pkg = await fs5.readJson(pkgPath);
|
|
2173
|
+
if (pkg.scripts?.dev) {
|
|
2174
|
+
if (pkg.scripts.dev.includes("--port")) {
|
|
2175
|
+
pkg.scripts.dev = pkg.scripts.dev.replace(/--port[= ]\d+/, `--port=${port}`);
|
|
2176
|
+
} else {
|
|
2177
|
+
pkg.scripts.dev = `${pkg.scripts.dev} --port=${port}`;
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
await fs5.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
2181
|
+
const siteUrl = `http://localhost:${port}`;
|
|
2182
|
+
const envLocalPath = path2.join(this.targetPath, ".env.local");
|
|
2183
|
+
const envContent = [
|
|
2184
|
+
`NEXT_PUBLIC_SITE_URL=${siteUrl}`,
|
|
2185
|
+
`SITE_URL=${siteUrl}`,
|
|
2186
|
+
`BETTER_AUTH_SECRET=${this.generateAuthSecret()}`,
|
|
2187
|
+
`NEXT_PUBLIC_CONVEX_URL=https://placeholder.convex.cloud`,
|
|
2188
|
+
`NEXT_PUBLIC_CONVEX_SITE_URL=https://placeholder.convex.site`,
|
|
2189
|
+
`CONVEX_DEPLOYMENT=`,
|
|
2190
|
+
""
|
|
2191
|
+
].join("\n");
|
|
2192
|
+
await fs5.writeFile(envLocalPath, envContent);
|
|
2193
|
+
}
|
|
2194
|
+
/**
|
|
2195
|
+
* Rewrites the scaffolded `package.json` for life inside a host pnpm
|
|
2196
|
+
* workspace (`--monorepo`). `@vexcms/*` dependencies become
|
|
2197
|
+
* `workspace:*`; any other dependency the host's `pnpm-workspace.yaml`
|
|
2198
|
+
* catalog also pins becomes `catalog:`; everything else keeps its
|
|
2199
|
+
* literal, catalog-resolved version. No-op when `options.monorepo` is
|
|
2200
|
+
* false or `options.workspaceRoot` is unset.
|
|
2201
|
+
*
|
|
2202
|
+
* @param options - Resolved project options; only `monorepo`/`workspaceRoot` are consulted.
|
|
2203
|
+
*/
|
|
2204
|
+
async applyMonorepoRewrite(options) {
|
|
2205
|
+
if (!options.monorepo || !options.workspaceRoot) return;
|
|
2206
|
+
const pkgPath = path2.join(this.targetPath, "package.json");
|
|
2207
|
+
const manifest = await fs5.readJson(pkgPath);
|
|
2208
|
+
const catalog = await readWorkspaceCatalog({ workspaceRoot: options.workspaceRoot });
|
|
2209
|
+
const rewritten = rewriteManifestForMonorepo({ manifest, catalog });
|
|
2210
|
+
await fs5.writeJson(pkgPath, rewritten, { spaces: 2 });
|
|
2211
|
+
}
|
|
2212
|
+
/**
|
|
2213
|
+
* Detect the package manager used to invoke the CLI
|
|
2214
|
+
*
|
|
2215
|
+
* @returns The package manager inferred from `npm_config_user_agent`, defaulting to `"npm"` when it can't be determined.
|
|
2216
|
+
*/
|
|
2217
|
+
detectPackageManager() {
|
|
2218
|
+
const userAgent = process.env.npm_config_user_agent;
|
|
2219
|
+
if (userAgent) {
|
|
2220
|
+
if (userAgent.includes("pnpm")) return "pnpm";
|
|
2221
|
+
if (userAgent.includes("yarn")) return "yarn";
|
|
2222
|
+
if (userAgent.includes("bun")) return "bun";
|
|
2223
|
+
}
|
|
2224
|
+
return "npm";
|
|
2225
|
+
}
|
|
2226
|
+
/**
|
|
2227
|
+
* Install project dependencies using detected package manager
|
|
2228
|
+
*/
|
|
2229
|
+
async installDependencies() {
|
|
2230
|
+
const packageManager = this.detectPackageManager();
|
|
2231
|
+
const spinner = ora(`Installing dependencies with ${packageManager}...`).start();
|
|
2232
|
+
try {
|
|
2233
|
+
await execa(packageManager, ["install"], {
|
|
2234
|
+
cwd: this.targetPath,
|
|
2235
|
+
stdio: "pipe"
|
|
2236
|
+
});
|
|
2237
|
+
spinner.succeed("Dependencies installed successfully");
|
|
2238
|
+
} catch (error) {
|
|
2239
|
+
spinner.fail("Failed to install dependencies");
|
|
2240
|
+
throw new Error(
|
|
2241
|
+
`Dependency installation failed: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
2242
|
+
);
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
2246
|
+
* Format code using project's formatter
|
|
2247
|
+
*/
|
|
2248
|
+
async formatCode() {
|
|
2249
|
+
const packageManager = this.detectPackageManager();
|
|
2250
|
+
const spinner = ora("Formatting code...").start();
|
|
2251
|
+
try {
|
|
2252
|
+
const result = await execa(packageManager, ["run", "format"], {
|
|
2253
|
+
cwd: this.targetPath,
|
|
2254
|
+
stdio: "pipe",
|
|
2255
|
+
reject: false
|
|
2256
|
+
});
|
|
2257
|
+
if (result.exitCode === 0) {
|
|
2258
|
+
spinner.succeed("Code formatted successfully");
|
|
2259
|
+
} else {
|
|
2260
|
+
spinner.warn("Formatting completed with warnings");
|
|
2261
|
+
}
|
|
2262
|
+
} catch (error) {
|
|
2263
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
2264
|
+
spinner.warn(`Failed to format code: ${errorMessage}`);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
/**
|
|
2268
|
+
* Lint and fix code using project's ESLint configuration
|
|
2269
|
+
*/
|
|
2270
|
+
async lintCode() {
|
|
2271
|
+
const packageManager = this.detectPackageManager();
|
|
2272
|
+
const spinner = ora("Linting and fixing code...").start();
|
|
2273
|
+
try {
|
|
2274
|
+
const args2 = packageManager === "pnpm" ? ["lint", "--fix"] : ["run", "lint", "--", "--fix"];
|
|
2275
|
+
const result = await execa(packageManager, args2, {
|
|
2276
|
+
cwd: this.targetPath,
|
|
2277
|
+
stdio: "pipe",
|
|
2278
|
+
reject: false
|
|
2279
|
+
});
|
|
2280
|
+
if (result.exitCode === 0) {
|
|
2281
|
+
spinner.succeed("Code linted and fixed successfully");
|
|
2282
|
+
} else {
|
|
2283
|
+
spinner.warn("Linting completed with warnings");
|
|
2284
|
+
}
|
|
2285
|
+
} catch (error) {
|
|
2286
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
2287
|
+
spinner.warn(`Failed to lint code: ${errorMessage}`);
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
/**
|
|
2291
|
+
* Initialize Git repository in target directory
|
|
2292
|
+
*/
|
|
2293
|
+
async initGitRepo() {
|
|
2294
|
+
const spinner = ora("Initializing Git repository...").start();
|
|
2295
|
+
try {
|
|
2296
|
+
try {
|
|
2297
|
+
await execa("git", ["--version"], { stdio: "pipe" });
|
|
2298
|
+
} catch {
|
|
2299
|
+
spinner.fail("Git is not installed");
|
|
2300
|
+
throw new Error("Git is not installed. Please install Git to initialize a repository.");
|
|
2301
|
+
}
|
|
2302
|
+
await execa("git", ["init"], { cwd: this.targetPath, stdio: "pipe" });
|
|
2303
|
+
await execa("git", ["add", "."], { cwd: this.targetPath, stdio: "pipe" });
|
|
2304
|
+
await execa(
|
|
2305
|
+
"git",
|
|
2306
|
+
["commit", "-m", "Initial commit from create-vexcms"],
|
|
2307
|
+
{ cwd: this.targetPath, stdio: "pipe" }
|
|
2308
|
+
);
|
|
2309
|
+
spinner.succeed("Git repository initialized");
|
|
2310
|
+
} catch (error) {
|
|
2311
|
+
spinner.fail("Failed to initialize Git repository");
|
|
2312
|
+
throw new Error(
|
|
2313
|
+
`Git initialization failed: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
2314
|
+
);
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
/**
|
|
2318
|
+
* Generate a secure random secret for Better Auth
|
|
2319
|
+
*
|
|
2320
|
+
* @returns A 64-character hex string (32 random bytes) suitable for `BETTER_AUTH_SECRET`.
|
|
2321
|
+
*/
|
|
2322
|
+
generateAuthSecret() {
|
|
2323
|
+
return crypto.randomBytes(32).toString("hex");
|
|
2324
|
+
}
|
|
2325
|
+
/**
|
|
2326
|
+
* Write the generated auth secret to .env.example
|
|
2327
|
+
*/
|
|
2328
|
+
async writeAuthSecret() {
|
|
2329
|
+
const envPath = path2.join(this.targetPath, ".env.example");
|
|
2330
|
+
const secret = this.generateAuthSecret();
|
|
2331
|
+
if (await fs5.pathExists(envPath)) {
|
|
2332
|
+
let content = await fs5.readFile(envPath, "utf-8");
|
|
2333
|
+
content = content.replace(
|
|
2334
|
+
/BETTER_AUTH_SECRET=.*/,
|
|
2335
|
+
`BETTER_AUTH_SECRET=${secret}`
|
|
2336
|
+
);
|
|
2337
|
+
await fs5.writeFile(envPath, content);
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Configure the Better Auth organizations plugin.
|
|
2342
|
+
* When enabled, replaces placeholders with organization import and plugin,
|
|
2343
|
+
* and leaves every org-aware file (`createGetAuth` call sites, `access.ts`,
|
|
2344
|
+
* `AuthContext.tsx`, `hasPermission.ts`, `convex/auth/api.ts`,
|
|
2345
|
+
* `db/constants/index.ts`) untouched.
|
|
2346
|
+
* When disabled, removes the placeholder lines AND strips organization
|
|
2347
|
+
* wiring from those same files down to the org-free shape — the
|
|
2348
|
+
* templates ship org-enabled by default (so `--orgs` needs no surgery),
|
|
2349
|
+
* so the default (orgs-declined) path has to actively cut it.
|
|
2350
|
+
*
|
|
2351
|
+
* @param enabled - Whether the organizations (multi-tenant) plugin should stay wired.
|
|
2352
|
+
*/
|
|
2353
|
+
async configureOrganizations(enabled) {
|
|
2354
|
+
const pluginsPath = path2.join(this.targetPath, "convex/auth/plugins/index.ts");
|
|
2355
|
+
if (await fs5.pathExists(pluginsPath)) {
|
|
2356
|
+
let content = await fs5.readFile(pluginsPath, "utf-8");
|
|
2357
|
+
if (enabled) {
|
|
2358
|
+
content = content.replace(
|
|
2359
|
+
"// {{ORGANIZATIONS_IMPORT}}",
|
|
2360
|
+
'import { organization } from "better-auth/plugins"'
|
|
2361
|
+
);
|
|
2362
|
+
content = content.replace(
|
|
2363
|
+
" // {{ORGANIZATIONS_PLUGIN}}",
|
|
2364
|
+
" organization(),"
|
|
2365
|
+
);
|
|
2366
|
+
} else {
|
|
2367
|
+
content = content.replace(/.*\/\/ \{\{ORGANIZATIONS_IMPORT\}\}\n?/, "");
|
|
2368
|
+
content = content.replace(/.*\/\/ \{\{ORGANIZATIONS_PLUGIN\}\}\n?/, "");
|
|
2118
2369
|
}
|
|
2119
|
-
|
|
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}`);
|
|
2370
|
+
await fs5.writeFile(pluginsPath, content);
|
|
2137
2371
|
}
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
lines.push(`${provider.envPrefix}_CLIENT_SECRET: z.string(),`);
|
|
2141
|
-
if (providerId === "figma") {
|
|
2142
|
-
lines.push(`FIGMA_CLIENT_KEY: z.string(),`);
|
|
2372
|
+
if (!enabled) {
|
|
2373
|
+
await this.stripOrganizationWiring();
|
|
2143
2374
|
}
|
|
2144
|
-
return lines.join("\n ");
|
|
2145
|
-
}).join("\n ");
|
|
2146
|
-
return schemas;
|
|
2147
|
-
}
|
|
2148
|
-
function generateEnvTsRuntimeMapping(providers) {
|
|
2149
|
-
if (providers.length === 0) {
|
|
2150
|
-
return "";
|
|
2151
2375
|
}
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2376
|
+
/**
|
|
2377
|
+
* Rewrites every scaffolded file that references organizations down to
|
|
2378
|
+
* its org-free shape. Called by `configureOrganizations(false)` — the
|
|
2379
|
+
* `--orgs`-declined default. Templates ship org-ENABLED (so `--orgs`
|
|
2380
|
+
* requires no surgery); this is what makes the default scaffold buildable
|
|
2381
|
+
* once the generated registry drops org tables on the first `vex dev`
|
|
2382
|
+
* regen. Every transform throws loudly when its expected pattern is
|
|
2383
|
+
* missing (template drift), rather than silently no-opping.
|
|
2384
|
+
*
|
|
2385
|
+
* `src/vexcms/api.ts` only exists in full (marketing-site overlay)
|
|
2386
|
+
* scaffolds — skipped, not required, for `--bare`.
|
|
2387
|
+
*/
|
|
2388
|
+
async stripOrganizationWiring() {
|
|
2389
|
+
const requiredFiles = [
|
|
2390
|
+
{ rel: "convex/vex.ts", transform: stripOrgFromGetAuthCall },
|
|
2391
|
+
{ rel: "convex/vex/globals.ts", transform: stripOrgFromGetAuthCall },
|
|
2392
|
+
{ rel: "convex/vex/media.ts", transform: stripOrgFromGetAuthCall },
|
|
2393
|
+
{ rel: "convex/auth/api.ts", transform: stripOrgFromAuthApi },
|
|
2394
|
+
{ rel: "src/auth/access.ts", transform: stripOrgFromAccess },
|
|
2395
|
+
{ rel: "src/context/AuthContext.tsx", transform: stripOrgFromAuthContext },
|
|
2396
|
+
{ rel: "src/auth/hasPermission.ts", transform: stripOrgFromHasPermission },
|
|
2397
|
+
{ rel: "src/db/constants/index.ts", transform: stripOrgFromDbConstants }
|
|
2398
|
+
];
|
|
2399
|
+
for (const file of requiredFiles) {
|
|
2400
|
+
const filePath = path2.join(this.targetPath, file.rel);
|
|
2401
|
+
if (!await fs5.pathExists(filePath)) {
|
|
2402
|
+
throw new Error(
|
|
2403
|
+
`configureOrganizations: required file "${file.rel}" is missing from the scaffold \u2014 template drift?`
|
|
2404
|
+
);
|
|
2405
|
+
}
|
|
2406
|
+
const content = await fs5.readFile(filePath, "utf-8");
|
|
2407
|
+
await fs5.writeFile(filePath, file.transform(filePath, content));
|
|
2156
2408
|
}
|
|
2157
|
-
const
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
lines.push(`FIGMA_CLIENT_KEY: process.env.FIGMA_CLIENT_KEY,`);
|
|
2409
|
+
const apiPath = path2.join(this.targetPath, "src/vexcms/api.ts");
|
|
2410
|
+
if (await fs5.pathExists(apiPath)) {
|
|
2411
|
+
const content = await fs5.readFile(apiPath, "utf-8");
|
|
2412
|
+
await fs5.writeFile(apiPath, stripOrgFromGetAuthCall(apiPath, content));
|
|
2162
2413
|
}
|
|
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
2414
|
}
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2415
|
+
/**
|
|
2416
|
+
* Main orchestration method for project initialization
|
|
2417
|
+
*
|
|
2418
|
+
* @param options - Resolved project options from CLI flags and prompts, driving every scaffolding step (template copy, overlay, port, auth, README, git, install).
|
|
2419
|
+
*/
|
|
2420
|
+
async initProject(options) {
|
|
2421
|
+
const copySpinner = ora("Copying template files...").start();
|
|
2422
|
+
try {
|
|
2423
|
+
await this.copyBaseFiles();
|
|
2424
|
+
copySpinner.succeed("Template files copied");
|
|
2425
|
+
} catch (error) {
|
|
2426
|
+
copySpinner.fail("Failed to copy template files");
|
|
2427
|
+
throw error;
|
|
2184
2428
|
}
|
|
2185
|
-
if (
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
return [
|
|
2196
|
-
`${provider.clientIdVar}=`,
|
|
2197
|
-
`${provider.clientSecretVar}=`
|
|
2198
|
-
];
|
|
2429
|
+
if (!options.bare) {
|
|
2430
|
+
const overlaySpinner = ora("Applying marketing site template...").start();
|
|
2431
|
+
try {
|
|
2432
|
+
await this.applyTemplateOverlay("marketing-site");
|
|
2433
|
+
await this.finalizeMarketingOverlay();
|
|
2434
|
+
overlaySpinner.succeed("Marketing site template applied");
|
|
2435
|
+
} catch (error) {
|
|
2436
|
+
overlaySpinner.fail("Failed to apply template overlay");
|
|
2437
|
+
throw error;
|
|
2438
|
+
}
|
|
2199
2439
|
}
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
const provider = getProvider(providerId);
|
|
2209
|
-
if (!provider) {
|
|
2210
|
-
throw new Error(`Unknown OAuth provider: ${providerId}`);
|
|
2440
|
+
const nameSpinner = ora("Configuring project...").start();
|
|
2441
|
+
try {
|
|
2442
|
+
await this.updatePackageName(options.projectName);
|
|
2443
|
+
await this.configurePort(options.port);
|
|
2444
|
+
nameSpinner.succeed("Project configured");
|
|
2445
|
+
} catch (error) {
|
|
2446
|
+
nameSpinner.fail("Failed to configure project");
|
|
2447
|
+
throw error;
|
|
2211
2448
|
}
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2449
|
+
const authSpinner = ora("Configuring authentication...").start();
|
|
2450
|
+
try {
|
|
2451
|
+
await this.updateOAuthConfig(options.oauthProviders, options.emailPasswordAuth);
|
|
2452
|
+
authSpinner.succeed(
|
|
2453
|
+
options.emailPasswordAuth || options.oauthProviders.length > 0 ? "Authentication configuration updated" : "Authentication placeholders cleaned up"
|
|
2215
2454
|
);
|
|
2455
|
+
} catch (error) {
|
|
2456
|
+
authSpinner.fail("Failed to configure authentication");
|
|
2457
|
+
throw error;
|
|
2458
|
+
}
|
|
2459
|
+
const oauthUISpinner = ora("Configuring OAuth UI...").start();
|
|
2460
|
+
try {
|
|
2461
|
+
await this.updateOAuthUIConfig(options.oauthProviders, options.emailPasswordAuth);
|
|
2462
|
+
oauthUISpinner.succeed(
|
|
2463
|
+
options.oauthProviders.length > 0 ? "OAuth UI configuration updated" : "OAuth UI placeholders cleaned up"
|
|
2464
|
+
);
|
|
2465
|
+
} catch (error) {
|
|
2466
|
+
oauthUISpinner.fail("Failed to configure OAuth UI");
|
|
2467
|
+
throw error;
|
|
2468
|
+
}
|
|
2469
|
+
const envSpinner = ora("Updating .env.example...").start();
|
|
2470
|
+
try {
|
|
2471
|
+
await this.updateEnvExample(options.oauthProviders);
|
|
2472
|
+
envSpinner.succeed(
|
|
2473
|
+
options.oauthProviders.length > 0 ? ".env.example updated" : ".env.example placeholders cleaned up"
|
|
2474
|
+
);
|
|
2475
|
+
} catch (error) {
|
|
2476
|
+
envSpinner.fail("Failed to update .env.example");
|
|
2477
|
+
throw error;
|
|
2478
|
+
}
|
|
2479
|
+
const envTsSpinner = ora("Updating typed env configuration...").start();
|
|
2480
|
+
try {
|
|
2481
|
+
await this.updateEnvTs(options.oauthProviders);
|
|
2482
|
+
envTsSpinner.succeed(
|
|
2483
|
+
options.oauthProviders.length > 0 ? "Typed env configuration updated" : "Typed env placeholders cleaned up"
|
|
2484
|
+
);
|
|
2485
|
+
} catch (error) {
|
|
2486
|
+
envTsSpinner.fail("Failed to update typed env configuration");
|
|
2487
|
+
throw error;
|
|
2488
|
+
}
|
|
2489
|
+
const readmeSpinner = ora("Updating README...").start();
|
|
2490
|
+
try {
|
|
2491
|
+
await this.updateReadme(options.oauthProviders);
|
|
2492
|
+
readmeSpinner.succeed(
|
|
2493
|
+
options.oauthProviders.length > 0 ? "README updated" : "README placeholders cleaned up"
|
|
2494
|
+
);
|
|
2495
|
+
} catch (error) {
|
|
2496
|
+
readmeSpinner.fail("Failed to update README");
|
|
2497
|
+
throw error;
|
|
2498
|
+
}
|
|
2499
|
+
const orgsSpinner = ora("Configuring organizations...").start();
|
|
2500
|
+
try {
|
|
2501
|
+
await this.configureOrganizations(options.orgs);
|
|
2502
|
+
orgsSpinner.succeed(
|
|
2503
|
+
options.orgs ? "Organizations plugin enabled" : "Organizations placeholders cleaned up"
|
|
2504
|
+
);
|
|
2505
|
+
} catch (error) {
|
|
2506
|
+
orgsSpinner.fail("Failed to configure organizations");
|
|
2507
|
+
throw error;
|
|
2508
|
+
}
|
|
2509
|
+
const secretSpinner = ora("Generating auth secret...").start();
|
|
2510
|
+
try {
|
|
2511
|
+
await this.writeAuthSecret();
|
|
2512
|
+
secretSpinner.succeed("Auth secret generated");
|
|
2513
|
+
} catch (error) {
|
|
2514
|
+
secretSpinner.fail("Failed to generate auth secret");
|
|
2515
|
+
throw error;
|
|
2516
|
+
}
|
|
2517
|
+
if (options.monorepo) {
|
|
2518
|
+
const monorepoSpinner = ora("Rewriting dependencies for the host workspace...").start();
|
|
2519
|
+
try {
|
|
2520
|
+
await this.applyMonorepoRewrite(options);
|
|
2521
|
+
monorepoSpinner.succeed("Dependencies rewritten for the host workspace");
|
|
2522
|
+
} catch (error) {
|
|
2523
|
+
monorepoSpinner.fail("Failed to rewrite dependencies for the host workspace");
|
|
2524
|
+
throw error;
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
if (options.initGit && !options.monorepo) {
|
|
2528
|
+
await this.initGitRepo();
|
|
2529
|
+
}
|
|
2530
|
+
if (options.installDependencies && !options.monorepo) {
|
|
2531
|
+
await this.installDependencies();
|
|
2532
|
+
await this.lintCode();
|
|
2533
|
+
await this.formatCode();
|
|
2216
2534
|
}
|
|
2217
|
-
return provider.readme.content;
|
|
2218
|
-
}).filter(Boolean);
|
|
2219
|
-
if (sections.length === 0) {
|
|
2220
|
-
return "";
|
|
2221
2535
|
}
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
${sections.join("\n\n---\n\n")}`;
|
|
2225
|
-
}
|
|
2536
|
+
};
|
|
2226
2537
|
|
|
2227
2538
|
// src/installers/nextjs.ts
|
|
2539
|
+
import { join as join2 } from "path";
|
|
2228
2540
|
var VexNextJSInstaller = class extends VexFrameworkInstaller {
|
|
2541
|
+
/**
|
|
2542
|
+
* Framework identifier used for template selection and logging.
|
|
2543
|
+
*
|
|
2544
|
+
* @returns The literal string `"nextjs"`.
|
|
2545
|
+
*/
|
|
2229
2546
|
get frameworkName() {
|
|
2230
2547
|
return "nextjs";
|
|
2231
2548
|
}
|
|
2232
2549
|
/**
|
|
2233
2550
|
* Update OAuth configuration in Convex auth options file
|
|
2234
2551
|
* Target file: convex/auth/options.ts
|
|
2552
|
+
*
|
|
2553
|
+
* @param selectedProviders - IDs of the OAuth providers chosen for the project (e.g. `["google", "github"]`).
|
|
2554
|
+
* @param emailPasswordEnabled - Whether email/password authentication is also enabled.
|
|
2235
2555
|
*/
|
|
2236
2556
|
async updateOAuthConfig(selectedProviders, emailPasswordEnabled) {
|
|
2237
2557
|
const authFilePath = join2(this.targetPath, "convex/auth/options.ts");
|
|
@@ -2254,6 +2574,9 @@ var VexNextJSInstaller = class extends VexFrameworkInstaller {
|
|
|
2254
2574
|
/**
|
|
2255
2575
|
* Update OAuth UI configuration in auth client file
|
|
2256
2576
|
* Target file: src/auth/client.tsx
|
|
2577
|
+
*
|
|
2578
|
+
* @param selectedProviders - IDs of the OAuth providers chosen for the project (e.g. `["google", "github"]`).
|
|
2579
|
+
* @param emailPasswordEnabled - Whether email/password authentication is also enabled.
|
|
2257
2580
|
*/
|
|
2258
2581
|
async updateOAuthUIConfig(selectedProviders, emailPasswordEnabled) {
|
|
2259
2582
|
const providersFilePath = join2(this.targetPath, "src/auth/client.tsx");
|
|
@@ -2273,6 +2596,8 @@ var VexNextJSInstaller = class extends VexFrameworkInstaller {
|
|
|
2273
2596
|
/**
|
|
2274
2597
|
* Update .env.example with OAuth environment variables
|
|
2275
2598
|
* Target file: .env.example
|
|
2599
|
+
*
|
|
2600
|
+
* @param selectedProviders - IDs of the OAuth providers chosen for the project (e.g. `["google", "github"]`).
|
|
2276
2601
|
*/
|
|
2277
2602
|
async updateEnvExample(selectedProviders) {
|
|
2278
2603
|
const envFilePath = join2(this.targetPath, ".env.example");
|
|
@@ -2286,6 +2611,8 @@ var VexNextJSInstaller = class extends VexFrameworkInstaller {
|
|
|
2286
2611
|
/**
|
|
2287
2612
|
* Update README with OAuth provider setup guides
|
|
2288
2613
|
* Target file: README.md
|
|
2614
|
+
*
|
|
2615
|
+
* @param selectedProviders - IDs of the OAuth providers chosen for the project (e.g. `["google", "github"]`).
|
|
2289
2616
|
*/
|
|
2290
2617
|
async updateReadme(selectedProviders) {
|
|
2291
2618
|
const readmeFilePath = join2(this.targetPath, "README.md");
|
|
@@ -2300,6 +2627,8 @@ var VexNextJSInstaller = class extends VexFrameworkInstaller {
|
|
|
2300
2627
|
/**
|
|
2301
2628
|
* Update env.mjs with OAuth provider environment variables
|
|
2302
2629
|
* Target file: src/env.mjs
|
|
2630
|
+
*
|
|
2631
|
+
* @param selectedProviders - IDs of the OAuth providers chosen for the project (e.g. `["google", "github"]`).
|
|
2303
2632
|
*/
|
|
2304
2633
|
async updateEnvTs(selectedProviders) {
|
|
2305
2634
|
const envFilePath = join2(this.targetPath, "src/env.mjs");
|
|
@@ -2331,7 +2660,7 @@ function createInstaller(props) {
|
|
|
2331
2660
|
}
|
|
2332
2661
|
|
|
2333
2662
|
// 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();
|
|
2663
|
+
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
2664
|
var args = program.args;
|
|
2336
2665
|
var opts = program.opts();
|
|
2337
2666
|
async function main() {
|
|
@@ -2339,15 +2668,19 @@ async function main() {
|
|
|
2339
2668
|
console.log(chalk2.bold(" create-vexcms"));
|
|
2340
2669
|
console.log();
|
|
2341
2670
|
const bare = opts.bare ?? false;
|
|
2671
|
+
const yes = opts.yes ?? false;
|
|
2672
|
+
const monorepo = opts.monorepo ?? false;
|
|
2342
2673
|
let inputArg;
|
|
2343
2674
|
if (args[0]) {
|
|
2344
2675
|
inputArg = args[0];
|
|
2676
|
+
} else if (yes) {
|
|
2677
|
+
inputArg = "my-vexcms-app";
|
|
2345
2678
|
} else {
|
|
2346
2679
|
inputArg = await input({
|
|
2347
2680
|
message: "(1/8) What is your project named?",
|
|
2348
2681
|
default: "my-vexcms-app",
|
|
2349
2682
|
validate: (value) => {
|
|
2350
|
-
const name = value.includes("/") ?
|
|
2683
|
+
const name = value.includes("/") ? path3.basename(value) : value;
|
|
2351
2684
|
if (name === ".") return true;
|
|
2352
2685
|
const result = validateProjectName(name);
|
|
2353
2686
|
if (result.valid) return true;
|
|
@@ -2356,52 +2689,80 @@ async function main() {
|
|
|
2356
2689
|
});
|
|
2357
2690
|
}
|
|
2358
2691
|
inputArg = resolveProjectName(inputArg, process.cwd());
|
|
2359
|
-
const projectName = inputArg === "." ?
|
|
2692
|
+
const projectName = inputArg === "." ? path3.basename(process.cwd()) : path3.basename(inputArg);
|
|
2360
2693
|
const validation = validateProjectName(projectName);
|
|
2361
2694
|
if (!validation.valid) {
|
|
2362
2695
|
displayInvalidNameError(projectName, validation.errors);
|
|
2363
2696
|
process.exit(1);
|
|
2364
2697
|
}
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2698
|
+
let targetDir;
|
|
2699
|
+
let workspaceRoot = null;
|
|
2700
|
+
if (monorepo) {
|
|
2701
|
+
workspaceRoot = await findWorkspaceRoot({ cwd: process.cwd() });
|
|
2702
|
+
if (!workspaceRoot) {
|
|
2703
|
+
console.error(chalk2.red("\nError: --monorepo requires a pnpm workspace."));
|
|
2704
|
+
console.error(chalk2.yellow(`No pnpm-workspace.yaml was found walking up from ${process.cwd()}.`));
|
|
2705
|
+
console.error(chalk2.yellow("Run this command from inside a pnpm workspace, or drop --monorepo."));
|
|
2369
2706
|
process.exit(1);
|
|
2370
2707
|
}
|
|
2708
|
+
targetDir = path3.join(workspaceRoot, "apps", projectName);
|
|
2709
|
+
if (await checkDirectoryExists(targetDir)) {
|
|
2710
|
+
console.error(chalk2.red(`
|
|
2711
|
+
Error: 'apps/${projectName}' already exists under ${workspaceRoot}.`));
|
|
2712
|
+
console.error(chalk2.yellow("--monorepo refuses to scaffold over an existing directory."));
|
|
2713
|
+
process.exit(1);
|
|
2714
|
+
}
|
|
2715
|
+
} else {
|
|
2716
|
+
targetDir = inputArg === "." ? process.cwd() : path3.resolve(process.cwd(), inputArg);
|
|
2717
|
+
if (await checkDirectoryExists(targetDir)) {
|
|
2718
|
+
if (!await isDirectoryEmpty(targetDir)) {
|
|
2719
|
+
displayDirectoryNotEmptyError();
|
|
2720
|
+
process.exit(1);
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2371
2723
|
}
|
|
2372
2724
|
let framework;
|
|
2373
|
-
|
|
2374
|
-
framework =
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2725
|
+
if (yes) {
|
|
2726
|
+
framework = "nextjs";
|
|
2727
|
+
} else {
|
|
2728
|
+
while (true) {
|
|
2729
|
+
framework = await select({
|
|
2730
|
+
message: "(2/8) Select a framework:",
|
|
2731
|
+
choices: [
|
|
2732
|
+
{ name: "Next.js (Recommended)", value: "nextjs" },
|
|
2733
|
+
{ name: "TanStack Start (Coming Soon)", value: "tanstack" }
|
|
2734
|
+
]
|
|
2735
|
+
});
|
|
2736
|
+
if (framework === "tanstack") {
|
|
2737
|
+
console.log(chalk2.yellow("\n TanStack Start support is coming soon! Please select Next.js for now.\n"));
|
|
2738
|
+
continue;
|
|
2739
|
+
}
|
|
2740
|
+
break;
|
|
2384
2741
|
}
|
|
2385
|
-
break;
|
|
2386
2742
|
}
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2743
|
+
let port;
|
|
2744
|
+
if (yes) {
|
|
2745
|
+
port = 3010;
|
|
2746
|
+
} else {
|
|
2747
|
+
const portInput = await input({
|
|
2748
|
+
message: "(3/8) Dev server port:",
|
|
2749
|
+
default: "3010",
|
|
2750
|
+
validate: (value) => {
|
|
2751
|
+
const num = parseInt(value, 10);
|
|
2752
|
+
if (isNaN(num) || num < 1 || num > 65535) return "Must be a valid port number (1-65535)";
|
|
2753
|
+
return true;
|
|
2754
|
+
}
|
|
2755
|
+
});
|
|
2756
|
+
port = parseInt(portInput, 10);
|
|
2757
|
+
}
|
|
2758
|
+
const emailPasswordAuth = yes ? true : await confirm({
|
|
2398
2759
|
message: "(4/8) Enable email/password authentication?",
|
|
2399
2760
|
default: true
|
|
2400
2761
|
});
|
|
2401
|
-
const orgs = opts.orgs ?? await confirm({
|
|
2762
|
+
const orgs = opts.orgs ?? (yes ? false : await confirm({
|
|
2402
2763
|
message: "(5/8) Enable multi-tenant (organizations)?",
|
|
2403
2764
|
default: false
|
|
2404
|
-
});
|
|
2765
|
+
}));
|
|
2405
2766
|
const popularProviders = getPopularProviders();
|
|
2406
2767
|
const additionalProviders = getAdditionalProviders();
|
|
2407
2768
|
const allProviderChoices = [
|
|
@@ -2415,15 +2776,15 @@ async function main() {
|
|
|
2415
2776
|
value: p.id
|
|
2416
2777
|
}))
|
|
2417
2778
|
];
|
|
2418
|
-
const oauthProviders = await checkbox({
|
|
2779
|
+
const oauthProviders = yes ? [] : await checkbox({
|
|
2419
2780
|
message: "(6/8) Select OAuth providers (space to toggle, enter to confirm):",
|
|
2420
2781
|
choices: allProviderChoices
|
|
2421
2782
|
});
|
|
2422
|
-
const initGit = await confirm({
|
|
2783
|
+
const initGit = yes ? true : await confirm({
|
|
2423
2784
|
message: "(7/8) Initialize a Git repository?",
|
|
2424
2785
|
default: true
|
|
2425
2786
|
});
|
|
2426
|
-
const installDependencies = await confirm({
|
|
2787
|
+
const installDependencies = yes ? false : await confirm({
|
|
2427
2788
|
message: "(8/8) Install dependencies?",
|
|
2428
2789
|
default: false
|
|
2429
2790
|
});
|
|
@@ -2437,9 +2798,12 @@ async function main() {
|
|
|
2437
2798
|
emailPasswordAuth,
|
|
2438
2799
|
oauthProviders,
|
|
2439
2800
|
initGit,
|
|
2440
|
-
installDependencies
|
|
2801
|
+
installDependencies,
|
|
2802
|
+
monorepo,
|
|
2803
|
+
workspaceRoot,
|
|
2804
|
+
yes
|
|
2441
2805
|
};
|
|
2442
|
-
await
|
|
2806
|
+
await fs6.ensureDir(targetDir);
|
|
2443
2807
|
const installer = createInstaller({
|
|
2444
2808
|
framework,
|
|
2445
2809
|
projectDir: targetDir,
|
|
@@ -2449,6 +2813,10 @@ async function main() {
|
|
|
2449
2813
|
await installer.initProject(options);
|
|
2450
2814
|
console.log();
|
|
2451
2815
|
displaySuccessMessage(options.projectName, targetDir, inputArg === ".");
|
|
2816
|
+
if (monorepo && workspaceRoot) {
|
|
2817
|
+
console.log(chalk2.cyan(` Run 'pnpm install' from ${workspaceRoot} to link the new workspace member.`));
|
|
2818
|
+
console.log();
|
|
2819
|
+
}
|
|
2452
2820
|
}
|
|
2453
2821
|
main().catch((error) => {
|
|
2454
2822
|
console.error(chalk2.red("\nAn error occurred:"), error.message);
|