@vibecms/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/vibe.js ADDED
@@ -0,0 +1,497 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { program } = require('commander');
4
+ const prompts = require('prompts');
5
+ const fs = require('fs-extra');
6
+ const path = require('path');
7
+ const { execSync } = require('child_process');
8
+ const pc = require('picocolors');
9
+
10
+ program
11
+ .version('0.1.0')
12
+ .description('VibeCMS Framework CLI Installer');
13
+
14
+ program
15
+ .command('init')
16
+ .option('--local', 'Use local workspace symlinks instead of NPM registry')
17
+ .option('--minimal', 'Minimal install: only CMS engine, editor overlay, and API routes (no starter blocks/header/footer)')
18
+ .description('Initialize VibeCMS in your Next.js project')
19
+ .action(async (options) => {
20
+ console.log(pc.magenta(pc.bold('\nWelcome to VibeCMS! šŸ‘‹\n')));
21
+
22
+ const frameworkPrompt = await prompts({
23
+ type: 'select',
24
+ name: 'framework',
25
+ message: 'Which framework is this project using?',
26
+ choices: [
27
+ { title: 'Next.js', value: 'nextjs' },
28
+ { title: 'Vite (React)', value: 'vite' },
29
+ { title: 'Astro (React)', value: 'astro' }
30
+ ],
31
+ initial: 0
32
+ });
33
+
34
+ if (!frameworkPrompt.framework) {
35
+ console.log(pc.red('Installation cancelled.'));
36
+ process.exit(0);
37
+ }
38
+
39
+ const response = await prompts({
40
+ type: 'confirm',
41
+ name: 'proceed',
42
+ message: 'Authentication successful. This will install VibeCMS directly into your Next.js project. Proceed?',
43
+ initial: true
44
+ });
45
+
46
+ if (!response.proceed) {
47
+ console.log(pc.red('Installation cancelled.'));
48
+ process.exit(0);
49
+ }
50
+
51
+ // License key prompt
52
+ const licensePrompt = await prompts({
53
+ type: 'text',
54
+ name: 'licenseKey',
55
+ message: 'Enter your VibeCMS license key (get one at https://vibecms.com/pricing):',
56
+ validate: (val) => {
57
+ if (!val || !val.startsWith('vibe_')) return 'License key must start with "vibe_"';
58
+ if (!val.includes('.')) return 'Invalid key format. Expected: vibe_<payload>.<signature>';
59
+ return true;
60
+ }
61
+ });
62
+
63
+ if (!licensePrompt.licenseKey) {
64
+ console.log(pc.yellow('⚠ No license key provided. You can add it later to .env.local as VIBE_LICENSE_KEY=...'));
65
+ }
66
+
67
+ const cwd = process.cwd();
68
+ const isSrc = fs.existsSync(path.join(cwd, 'src'));
69
+ const sourceDir = isSrc ? path.join(cwd, 'src') : cwd;
70
+ const pkgPath = path.join(cwd, 'package.json');
71
+ if (!fs.existsSync(pkgPath)) {
72
+ console.error(pc.red('No package.json found. Please run this command in a valid project.'));
73
+ process.exit(1);
74
+ }
75
+
76
+ // 1. Install Dependencies
77
+ try {
78
+ console.log(pc.cyan('\nšŸ“¦ Injecting core dependencies into package.json...'));
79
+ const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8'));
80
+ pkg.dependencies = pkg.dependencies || {};
81
+ const vibeDeps = {
82
+ "@vibecms/core": options.local ? "file:../../packages/core" : "^0.1.0",
83
+ "@vibecms/next": options.local ? "file:../../packages/next" : "^0.1.0",
84
+ "isomorphic-git": "^1.25.0",
85
+ "@isomorphic-git/lightning-fs": "^4.6.0",
86
+ "nanostores": "latest",
87
+ "@nanostores/react": "latest",
88
+ "zod": "^3.22.4",
89
+ "lucide-react": "latest",
90
+ "sonner": "latest",
91
+ "framer-motion": "latest",
92
+ "@vercel/blob": "latest",
93
+ "@tiptap/react": "latest",
94
+ "@tiptap/starter-kit": "latest",
95
+ ...(frameworkPrompt.framework === 'nextjs' ? { "next-auth": "4.24.5" } : {})
96
+ };
97
+
98
+ let updated = false;
99
+ for (const [dep, version] of Object.entries(vibeDeps)) {
100
+ if (!pkg.dependencies[dep]) {
101
+ pkg.dependencies[dep] = version;
102
+ updated = true;
103
+ }
104
+ }
105
+
106
+ if (updated) {
107
+ await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2));
108
+ console.log(pc.green('āœ” Updated package.json dependencies'));
109
+ } else {
110
+ console.log(pc.gray('āœ” Dependencies already injected.'));
111
+ }
112
+
113
+
114
+ console.log(pc.cyan('\nšŸ“¦ Running npm install...'));
115
+ try {
116
+ execSync('npm install', { stdio: 'inherit', cwd });
117
+ } catch (e) {
118
+ console.log(pc.yellow('\n⚠ Standard install failed (likely React 19 Peer Dependency clash). Falling back to --legacy-peer-deps...'));
119
+ execSync('npm install --legacy-peer-deps', { stdio: 'inherit', cwd });
120
+ }
121
+ } catch (e) {
122
+ console.error(pc.red('Failed to update package.json or install dependencies.'));
123
+ process.exit(1);
124
+ }
125
+
126
+ // 2. Extract Templates Natively
127
+ try {
128
+ console.log(pc.cyan('\n🚚 Unpacking VibeCMS core templates...'));
129
+ const templateDir = path.join(__dirname, '../templates');
130
+
131
+ if (!fs.existsSync(templateDir)) {
132
+ throw new Error('Templates directory missing in CLI package.');
133
+ }
134
+
135
+ if (options.minimal) {
136
+ // Minimal mode: only copy CMS engine essentials, no starter blocks/header/footer
137
+ const minimalFiles = [
138
+ 'components/cms/EditProvider.tsx',
139
+ 'components/cms/Editor.tsx',
140
+ 'components/cms/CmsStage.tsx',
141
+ 'components/cms/VisualWrapper.tsx',
142
+ 'components/cms/FormGenerator.tsx',
143
+ 'components/cms/BlockRenderer.tsx',
144
+ 'components/cms/fields',
145
+ 'lib/cms/schema.ts',
146
+ 'lib/cms/sanitize.ts',
147
+ 'lib/cms/auditor.ts',
148
+ 'lib/cms/engine.ts',
149
+ 'lib/cms/auth-nextauth.ts',
150
+ 'lib/cms/store.ts',
151
+ 'lib/cms/registry.ts',
152
+ ];
153
+ for (const relPath of minimalFiles) {
154
+ const srcPath = path.join(templateDir, relPath);
155
+ const destPath = path.join(sourceDir, relPath);
156
+ if (fs.existsSync(srcPath)) {
157
+ await fs.copy(srcPath, destPath, { overwrite: false, errorOnExist: false });
158
+ }
159
+ }
160
+ console.log(pc.green(`āœ” Unpacked minimal CMS core into ${isSrc ? 'src/' : './'} (no starter blocks, header, or footer)`));
161
+ } else {
162
+ await fs.copy(templateDir, sourceDir, { overwrite: false, errorOnExist: false });
163
+ console.log(pc.green(`āœ” Successfully unpacked core files into ${isSrc ? 'src/' : './'}`));
164
+ }
165
+
166
+ // 3. Generate vibe.config.ts
167
+ let configTemplate = '';
168
+ if (frameworkPrompt.framework === 'nextjs') {
169
+ configTemplate = `import { defineConfig } from '@vibecms/core';\nimport { z } from 'zod';\nimport { vibeField } from '@${isSrc ? '' : '/'}lib/cms/schema';\nimport { NextAuthProvider } from '@${isSrc ? '' : '/'}lib/cms/auth-nextauth';\n\nexport default defineConfig({\n contentDir: '${isSrc ? 'src/content' : 'content'}',\n publicDir: 'public',\n auth: new NextAuthProvider(),\n collections: {\n pages: {\n name: 'Pages',\n schema: z.object({\n _isPublished: vibeField(z.boolean().default(false), 'boolean'),\n title: vibeField(z.string(), 'text')\n })\n }\n },\n singletons: {\n settings: {\n name: 'Global Settings',\n schema: z.object({\n siteName: vibeField(z.string().default('My Next.js Site'), 'text'),\n })\n }\n }\n});\n`;
170
+ } else {
171
+ configTemplate = `import { defineConfig } from '@vibecms/core';\nimport { z } from 'zod';\nimport { vibeField } from './${isSrc ? 'src/' : ''}lib/cms/schema';\n\nexport default defineConfig({\n contentDir: '${isSrc ? 'src/content' : 'content'}',\n publicDir: 'public',\n // auth: new CustomAuthProvider(),\n collections: {\n pages: {\n name: 'Pages',\n schema: z.object({\n _isPublished: vibeField(z.boolean().default(false), 'boolean'),\n title: vibeField(z.string(), 'text')\n })\n }\n },\n singletons: {\n settings: {\n name: 'Global Settings',\n schema: z.object({\n siteName: vibeField(z.string().default('My Site'), 'text'),\n })\n }\n }\n});\n`;
172
+ }
173
+ await fs.writeFile(path.join(cwd, 'vibe.config.ts'), configTemplate);
174
+ console.log(pc.green('āœ” Scaffolded vibe.config.ts'));
175
+
176
+ // 4. Scaffold Local Dev API & NextAuth API for Next.js
177
+ if (frameworkPrompt.framework === 'nextjs') {
178
+ const apiDir = path.join(sourceDir, 'app', 'api', 'vibecms');
179
+ await fs.ensureDir(apiDir);
180
+ const routePath = path.join(apiDir, 'route.ts');
181
+ const routeTemplate = `import { createVibeRouteHandler } from '@vibecms/next/app-router';\n\nexport const POST = createVibeRouteHandler();\n`;
182
+ await fs.writeFile(routePath, routeTemplate);
183
+ console.log(pc.green('āœ” Scaffolded local DevEngine API at src/app/api/vibecms/route.ts'));
184
+
185
+ // Scaffold NextAuth backend specifically
186
+ const authDir = path.join(sourceDir, 'app', 'api', 'auth', '[...nextauth]');
187
+ await fs.ensureDir(authDir);
188
+ const authRoutePath = path.join(authDir, 'route.ts');
189
+ const authTemplate = `import NextAuth from "next-auth"\nimport GithubProvider from "next-auth/providers/github"\n\n// SECURITY: Add your Github Emails to this list to grant workspace access!\nconst ALLOWED_EMAILS = ["your-email@example.com"];\n\nconst handler = NextAuth({\n providers: [\n GithubProvider({\n clientId: process.env.GITHUB_ID as string,\n clientSecret: process.env.GITHUB_SECRET as string,\n }),\n ],\n callbacks: {\n async signIn({ user }) {\n if (user.email && ALLOWED_EMAILS.includes(user.email)) {\n return true;\n }\n console.log('Unauthorized VibeCMS login attempt blocked from:', user.email);\n return false;\n }\n }\n})\n\nexport { handler as GET, handler as POST }\n`;
190
+ await fs.writeFile(authRoutePath, authTemplate);
191
+ console.log(pc.green('āœ” Scaffolded NextAuth API at src/app/api/auth/[...nextauth]/route.ts'));
192
+
193
+ // Scaffold /admin login redirect page
194
+ const adminDir = path.join(sourceDir, 'app', 'admin');
195
+ const adminLoginDir = path.join(adminDir, 'login');
196
+ await fs.ensureDir(adminLoginDir);
197
+ const adminLoginPath = path.join(adminLoginDir, 'page.tsx');
198
+ const adminLoginTemplate = `'use client';\nimport { signIn, useSession } from 'next-auth/react';\nimport { useRouter } from 'next/navigation';\nimport { useEffect } from 'react';\n\nexport default function AdminLoginPage() {\n const { status } = useSession();\n const router = useRouter();\n\n useEffect(() => {\n if (status === 'authenticated') router.replace('/admin');\n }, [status, router]);\n\n if (status === 'loading') {\n return <div className="flex min-h-screen items-center justify-center"><div className="animate-pulse text-neutral-400 text-sm">Laden...</div></div>;\n }\n\n return (\n <div className="flex min-h-screen items-center justify-center bg-neutral-50">\n <div className="w-full max-w-sm mx-auto">\n <div className="bg-white rounded-2xl shadow-lg border border-neutral-200 p-8 text-center">\n <div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-indigo-600 text-white text-2xl font-bold mb-4">V</div>\n <h1 className="text-xl font-semibold text-neutral-900">VibeCMS</h1>\n <p className="text-sm text-neutral-500 mt-1 mb-6">Melden Sie sich an, um Inhalte zu verwalten.</p>\n <button onClick={() => signIn('github', { callbackUrl: '/admin' })} className="w-full flex items-center justify-center gap-3 rounded-lg bg-neutral-900 px-4 py-3 text-sm font-medium text-white hover:bg-neutral-800">\n <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" /></svg>\n Anmelden mit GitHub\n </button>\n </div>\n </div>\n </div>\n );\n}\n`;
199
+ await fs.writeFile(adminLoginPath, adminLoginTemplate);
200
+
201
+ // Scaffold admin dashboard page
202
+ const adminPagePath = path.join(adminDir, 'page.tsx');
203
+ const adminDashTemplate = `import Link from 'next/link';\nimport vibeConfig from '${isSrc ? '../../../' : '../../'}vibe.config';\n\nexport default function AdminDashboard() {\n const collections = vibeConfig.collections || {};\n return (\n <div className="max-w-5xl mx-auto p-6">\n <h1 className="text-2xl font-bold text-neutral-900 mb-6">Dashboard</h1>\n <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">\n {Object.entries(collections).map(([key, col]) => (\n <Link key={key} href={\\'/admin/' + key + '\\'} className="bg-white rounded-xl border border-neutral-200 p-5 hover:border-indigo-300 hover:shadow-md transition-all">\n <h2 className="text-sm font-semibold text-neutral-900">{(col as any).name}</h2>\n </Link>\n ))}\n </div>\n </div>\n );\n}\n`;
204
+ await fs.writeFile(adminPagePath, adminDashTemplate);
205
+ console.log(pc.green('āœ” Scaffolded admin dashboard at src/app/admin/'));
206
+ }
207
+
208
+ const envPath = path.join(cwd, '.env.local');
209
+ let envInjection = `\n# --- VibeCMS ---\nVIBE_LICENSE_KEY=${licensePrompt.licenseKey || ''}\nBLOB_READ_WRITE_TOKEN=\n`;
210
+ if (frameworkPrompt.framework === 'nextjs') {
211
+ envInjection += `GITHUB_ID=\nGITHUB_SECRET=\nNEXTAUTH_SECRET=\nNEXTAUTH_URL=http://localhost:3000\n`;
212
+ }
213
+
214
+ if (fs.existsSync(envPath)) {
215
+ await fs.appendFile(envPath, envInjection);
216
+ } else {
217
+ await fs.writeFile(envPath, envInjection);
218
+ }
219
+ console.log(pc.green('āœ” Injected VibeCMS secure variables into .env.local'));
220
+
221
+ } catch (e) {
222
+ console.error(pc.red('Failed to scaffold directory geometry:'), e);
223
+ }
224
+
225
+ console.log(pc.magenta(pc.bold('\n✨ VibeCMS initialized successfully!')));
226
+ if (options.minimal) {
227
+ console.log(pc.cyan(' Mode: Minimal (no starter blocks — bring your own components)\n'));
228
+ }
229
+ console.log(pc.white('\nNext Steps:'));
230
+ console.log(pc.gray('1. Open .env.local and populate the empty tokens.'));
231
+ console.log(pc.gray(`2. Wrap your layout in EditProvider: import { EditProvider } from '@${isSrc ? '' : '/'}components/cms/EditProvider'`));
232
+ console.log(pc.gray('3. Replace the registry.ts schema with your custom vibe.config.ts'));
233
+ if (options.minimal) {
234
+ console.log(pc.gray('4. Register your own block components in vibe.config.ts: blocks: [{ type: "hero", component: YourHero }]'));
235
+ console.log(pc.gray('5. Or use inline primitives: import { VibeText, VibeImage } from "@vibecms/core"'));
236
+ }
237
+ if (frameworkPrompt.framework === 'nextjs') {
238
+ console.log(pc.yellow('\nšŸ” Create a GitHub OAuth App (Developer Settings):'));
239
+ console.log(pc.gray(' - Homepage URL: http://localhost:3000'));
240
+ console.log(pc.gray(' - Callback URL: http://localhost:3000/api/auth/callback/github'));
241
+ console.log(pc.gray(' - Paste Client ID and Secret into .env.local!'));
242
+ console.log(pc.magenta('\n To edit your website, strictly navigate to: http://localhost:3000/admin'));
243
+ }
244
+ console.log('');
245
+ });
246
+
247
+ program
248
+ .command('add <preset>')
249
+ .description('Add a pre-built standard collection to your VibeCMS project (e.g., blog, team, testimonials)')
250
+ .action(async (preset) => {
251
+ const cwd = process.cwd();
252
+ const isSrc = fs.existsSync(path.join(cwd, 'src'));
253
+ const sourceDir = isSrc ? path.join(cwd, 'src') : cwd;
254
+
255
+ const validPresets = ['blog', 'team', 'testimonials'];
256
+ if (!validPresets.includes(preset)) {
257
+ console.log(pc.red(`Invalid preset "${preset}". Valid templates are: ${validPresets.join(', ')}`));
258
+ return;
259
+ }
260
+
261
+ console.log(pc.cyan(`\nšŸ“¦ Scaffolding collection: ${preset}...\n`));
262
+
263
+ const pkgPath = path.join(cwd, 'package.json');
264
+ let framework = 'nextjs';
265
+ if (fs.existsSync(pkgPath)) {
266
+ const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8'));
267
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
268
+ if (deps['vite']) framework = 'vite';
269
+ if (deps['astro']) framework = 'astro';
270
+ }
271
+
272
+ const contentDir = path.join(sourceDir, 'content', preset);
273
+ await fs.ensureDir(contentDir);
274
+
275
+ let schemaContent = '';
276
+
277
+ if (preset === 'team') {
278
+ schemaContent = `import { z } from 'zod';\nimport { vibeField } from '@vibecms/core';\n\nexport const teamSchema = z.object({\n name: vibeField(z.string(), 'text'),\n role: vibeField(z.string(), 'text'),\n bio: vibeField(z.string().optional(), 'rich-text'),\n headshot: vibeField(z.string().optional(), 'image'),\n socialLinks: vibeField(\n z.array(\n z.object({\n platform: vibeField(z.string(), 'text'),\n url: vibeField(z.string(), 'text'),\n })\n ).default([]),\n 'array'\n ),\n});\n`;
279
+ } else if (preset === 'blog') {
280
+ schemaContent = `import { z } from 'zod';\nimport { vibeField, seoSchema, publishingSchema, blockSchema } from '@vibecms/core';\n\nexport const blogSchema = z.object({\n title: vibeField(z.string(), 'text'),\n excerpt: vibeField(z.string().optional(), 'rich-text'),\n coverImage: vibeField(z.string().optional(), 'image'),\n author: vibeField(z.string(), 'reference', { collection: 'team' }),\n content: vibeField(z.array(blockSchema).default([]), 'array'),\n})\n.merge(seoSchema)\n.merge(publishingSchema);\n`;
281
+ } else if (preset === 'testimonials') {
282
+ schemaContent = `import { z } from 'zod';\nimport { vibeField } from '@vibecms/core';\n\nexport const testimonialSchema = z.object({\n quote: vibeField(z.string(), 'rich-text'),\n authorName: vibeField(z.string(), 'text'),\n authorRole: vibeField(z.string().optional(), 'text'),\n authorAvatar: vibeField(z.string().optional(), 'image'),\n companyLogo: vibeField(z.string().optional(), 'image'),\n rating: vibeField(z.number().min(1).max(5).default(5), 'number'),\n});\n`;
283
+ }
284
+
285
+ const schemaPath = path.join(contentDir, 'schema.ts');
286
+ await fs.writeFile(schemaPath, schemaContent, 'utf8');
287
+ console.log(pc.green(`āœ” Created ${isSrc ? 'src/' : ''}content/${preset}/schema.ts`));
288
+
289
+ if (preset === 'blog') {
290
+ if (framework === 'nextjs') {
291
+ const appDir = path.join(sourceDir, 'app', 'blog', '[slug]');
292
+ await fs.ensureDir(appDir);
293
+ const pagePath = path.join(appDir, 'page.tsx');
294
+ if (!fs.existsSync(pagePath)) {
295
+ await fs.writeFile(pagePath, `import { VibeEngine } from '@vibecms/core';\nimport { notFound } from 'next/navigation';\nimport { SeoHead } from '@vibecms/core/ui';\n\nexport default async function BlogPostPage({ params }: { params: { slug: string } }) {\n const post = await VibeEngine.read('blog', params.slug);\n if (!post) notFound();\n return (\n <main className="max-w-3xl mx-auto py-12 px-6">\n <SeoHead data={post} />\n <header className="mb-8">\n <h1 className="text-4xl font-bold mb-4">{post.title}</h1>\n {post.author && <p className="text-neutral-500">By {post.author}</p>}\n </header>\n <article className="mt-8">\n {post.content?.map((block: any, i: number) => (\n <div key={i} className="my-4 p-4 bg-neutral-50 rounded-lg">{block._type} Block Placeholder</div>\n ))}\n </article>\n </main>\n );\n}\n`);
296
+ console.log(pc.green(`āœ” Created Next.js sample frontend route at ${isSrc ? 'src/' : ''}app/blog/[slug]/page.tsx`));
297
+ }
298
+ } else if (framework === 'vite') {
299
+ const pagesDir = path.join(sourceDir, 'pages');
300
+ await fs.ensureDir(pagesDir);
301
+ const pagePath = path.join(pagesDir, 'BlogPost.tsx');
302
+ if (!fs.existsSync(pagePath)) {
303
+ await fs.writeFile(pagePath, `import { useEffect, useState } from 'react';\nimport { useParams } from 'react-router-dom';\nimport { VibeEngine } from '@vibecms/core';\nimport { SeoHead } from '@vibecms/core/ui';\n\nexport default function BlogPostPage() {\n const { slug } = useParams();\n const [post, setPost] = useState<any>(null);\n \n useEffect(() => {\n if (slug) VibeEngine.read('blog', slug).then(setPost);\n }, [slug]);\n\n if (!post) return <div>Loading...</div>;\n\n return (\n <main className="max-w-3xl mx-auto py-12 px-6">\n <SeoHead data={post} />\n <header className="mb-8">\n <h1 className="text-4xl font-bold mb-4">{post.title}</h1>\n {post.author && <p className="text-neutral-500">By {post.author}</p>}\n </header>\n <article className="mt-8">\n {post.content?.map((block: any, i: number) => (\n <div key={i} className="my-4 p-4 bg-neutral-50 rounded-lg">{block._type} Block Placeholder</div>\n ))}\n </article>\n </main>\n );\n}\n`);
304
+ console.log(pc.green(`āœ” Created Vite React sample component at ${isSrc ? 'src/' : ''}pages/BlogPost.tsx`));
305
+ }
306
+ } else if (framework === 'astro') {
307
+ const pagesDir = path.join(sourceDir, 'pages', 'blog');
308
+ await fs.ensureDir(pagesDir);
309
+ const pagePath = path.join(pagesDir, '[slug].astro');
310
+ if (!fs.existsSync(pagePath)) {
311
+ await fs.writeFile(pagePath, `---\nimport { VibeEngine } from '@vibecms/core';\nimport { SeoHead } from '@vibecms/core/ui';\n\nexport async function getStaticPaths() {\n const posts = await VibeEngine.list('blog');\n return posts.map((slug: string) => ({ params: { slug } }));\n}\n\nconst { slug } = Astro.params;\nconst post = await VibeEngine.read('blog', slug as string);\nif (!post) return Astro.redirect('/404');\n---\n<html>\n <head>\n <SeoHead data={post} />\n </head>\n <body>\n <main class="max-w-3xl mx-auto py-12 px-6">\n <header class="mb-8">\n <h1 class="text-4xl font-bold mb-4">{post.title}</h1>\n {post.author && <p class="text-neutral-500">By {post.author}</p>}\n </header>\n <article class="mt-8">\n {post.content?.map((block: any) => (\n <div class="my-4 p-4 bg-neutral-50 rounded-lg">{block._type} Block Placeholder</div>\n ))}\n </article>\n </main>\n </body>\n</html>\n`);
312
+ console.log(pc.green(`āœ” Created Astro sample route at ${isSrc ? 'src/' : ''}pages/blog/[slug].astro`));
313
+ }
314
+ }
315
+ }
316
+
317
+ console.log(pc.magenta(`\n✨ Successfully added ${preset} schema!`));
318
+ console.log(pc.white('\nNext Steps:'));
319
+ console.log(pc.gray(`1. Open vibe.config.ts`));
320
+ console.log(pc.gray(`2. Import the schema: import { ${preset}Schema } from '@/content/${preset}/schema'`));
321
+ console.log(pc.gray(`3. Add it to your collections object under the key '${preset}'`));
322
+ console.log('');
323
+ });
324
+
325
+ program
326
+ .command('validate')
327
+ .description('Validate all JSON content against your vibe.config.ts')
328
+ .action(async () => {
329
+ console.log(pc.cyan('\nšŸ” Validating VibeCMS Content...'));
330
+ try {
331
+ const script = `
332
+ import config from './vibe.config.ts';
333
+ import fs from 'fs';
334
+ import path from 'path';
335
+
336
+ async function run() {
337
+ const collections = config.collections;
338
+ let hasErrors = false;
339
+ let checkedCount = 0;
340
+
341
+ for (const [key, coll] of Object.entries(collections)) {
342
+ const dir = path.join(process.cwd(), config.contentDir, key);
343
+ if (!fs.existsSync(dir)) continue;
344
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
345
+ for (const file of files) {
346
+ try {
347
+ const content = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8'));
348
+ coll.schema.parse(content);
349
+ checkedCount++;
350
+ } catch (e) {
351
+ console.log('\\nāŒ Validation Failed: /' + key + '/' + file);
352
+ if (e.errors) {
353
+ e.errors.forEach(err => console.log(' - ' + err.path.join('.') + ': ' + err.message));
354
+ } else {
355
+ console.log(' - JSON Parse Error');
356
+ }
357
+ hasErrors = true;
358
+ }
359
+ }
360
+ }
361
+ if (!hasErrors) {
362
+ console.log('\\nāœ… All ' + checkedCount + ' documents are valid!');
363
+ } else {
364
+ process.exit(1);
365
+ }
366
+ }
367
+ run().catch(() => process.exit(1));
368
+ `;
369
+ // Write a temporary ts runner
370
+ const tmpPath = path.join(process.cwd(), '.vibe-validate.ts');
371
+ await fs.writeFile(tmpPath, script);
372
+ execSync('npx tsx ' + tmpPath, { stdio: 'inherit', cwd: process.cwd() });
373
+ await fs.unlink(tmpPath);
374
+ } catch (e) {
375
+ const tmpPath = path.join(process.cwd(), '.vibe-validate.ts');
376
+ if (fs.existsSync(tmpPath)) await fs.unlink(tmpPath);
377
+ console.error(pc.red('\\nšŸ’„ Validation process failed. Make sure your vibe.config.ts is valid and has no circular imports.'));
378
+ process.exit(1);
379
+ }
380
+ });
381
+
382
+ program
383
+ .command('export')
384
+ .description('Export all CMS content as a single JSON file (DSGVO data export)')
385
+ .option('-o, --output <path>', 'Output file path', 'vibecms-export.json')
386
+ .action(async (options) => {
387
+ console.log(pc.cyan('\nšŸ“¦ Exporting all VibeCMS content...\n'));
388
+ const cwd = process.cwd();
389
+ const isSrc = fs.existsSync(path.join(cwd, 'src'));
390
+ const contentDir = path.join(cwd, isSrc ? 'src/content' : 'content');
391
+
392
+ if (!fs.existsSync(contentDir)) {
393
+ console.log(pc.red('No content directory found.'));
394
+ process.exit(1);
395
+ }
396
+
397
+ const exportData = { exportedAt: new Date().toISOString(), collections: {} };
398
+
399
+ const collections = fs.readdirSync(contentDir, { withFileTypes: true })
400
+ .filter(d => d.isDirectory())
401
+ .map(d => d.name);
402
+
403
+ for (const col of collections) {
404
+ const colDir = path.join(contentDir, col);
405
+ const files = fs.readdirSync(colDir).filter(f => f.endsWith('.json'));
406
+ exportData.collections[col] = {};
407
+ for (const file of files) {
408
+ try {
409
+ const content = JSON.parse(fs.readFileSync(path.join(colDir, file), 'utf-8'));
410
+ exportData.collections[col][file.replace('.json', '')] = content;
411
+ } catch (e) {
412
+ console.log(pc.yellow(`⚠ Failed to parse ${col}/${file}`));
413
+ }
414
+ }
415
+ console.log(pc.green(`āœ” Exported ${files.length} documents from ${col}`));
416
+ }
417
+
418
+ const outPath = path.resolve(options.output);
419
+ fs.writeFileSync(outPath, JSON.stringify(exportData, null, 2));
420
+ console.log(pc.magenta(`\n✨ Export complete: ${outPath}`));
421
+ console.log(pc.gray(` ${Object.keys(exportData.collections).length} collections, ${Object.values(exportData.collections).reduce((sum, col) => sum + Object.keys(col).length, 0)} documents`));
422
+ console.log('');
423
+ });
424
+
425
+ program
426
+ .command('audit')
427
+ .description('Audit content for missing SEO fields, empty required fields, and orphaned references')
428
+ .action(async () => {
429
+ console.log(pc.cyan('\nšŸ” Auditing VibeCMS content...\n'));
430
+ const cwd = process.cwd();
431
+ const isSrc = fs.existsSync(path.join(cwd, 'src'));
432
+ const contentDir = path.join(cwd, isSrc ? 'src/content' : 'content');
433
+
434
+ if (!fs.existsSync(contentDir)) {
435
+ console.log(pc.red('No content directory found.'));
436
+ process.exit(1);
437
+ }
438
+
439
+ let warnings = 0;
440
+ let checked = 0;
441
+ const allSlugs = new Set();
442
+ const referencedSlugs = new Set();
443
+
444
+ const collections = fs.readdirSync(contentDir, { withFileTypes: true })
445
+ .filter(d => d.isDirectory())
446
+ .map(d => d.name);
447
+
448
+ for (const col of collections) {
449
+ const colDir = path.join(contentDir, col);
450
+ const files = fs.readdirSync(colDir).filter(f => f.endsWith('.json'));
451
+
452
+ for (const file of files) {
453
+ checked++;
454
+ const slug = file.replace('.json', '');
455
+ allSlugs.add(slug);
456
+
457
+ try {
458
+ const raw = fs.readFileSync(path.join(colDir, file), 'utf-8');
459
+ const content = JSON.parse(raw);
460
+
461
+ // Check for empty title
462
+ if (!content.title && !content.name) {
463
+ console.log(pc.yellow(` ⚠ ${col}/${slug}: Missing title or name field`));
464
+ warnings++;
465
+ }
466
+
467
+ // Check SEO fields
468
+ if (content.seoTitle !== undefined && !content.seoTitle) {
469
+ console.log(pc.yellow(` ⚠ ${col}/${slug}: Empty seoTitle`));
470
+ warnings++;
471
+ }
472
+ if (content.seoDescription !== undefined && !content.seoDescription) {
473
+ console.log(pc.yellow(` ⚠ ${col}/${slug}: Empty seoDescription`));
474
+ warnings++;
475
+ }
476
+
477
+ // Collect referenced slugs
478
+ const refMatches = raw.match(/"([a-z0-9_-]+)"/gi) || [];
479
+ refMatches.forEach(m => referencedSlugs.add(m.replace(/"/g, '')));
480
+
481
+ } catch (e) {
482
+ console.log(pc.red(` āœ— ${col}/${slug}: Invalid JSON`));
483
+ warnings++;
484
+ }
485
+ }
486
+ }
487
+
488
+ console.log('');
489
+ if (warnings === 0) {
490
+ console.log(pc.green(`āœ… All ${checked} documents passed audit!`));
491
+ } else {
492
+ console.log(pc.yellow(`⚠ ${warnings} warnings found across ${checked} documents.`));
493
+ }
494
+ console.log('');
495
+ });
496
+
497
+ program.parse(process.argv);
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@vibecms/cli",
3
+ "version": "0.1.0",
4
+ "license": "LicenseRef-VibeCMS",
5
+ "publishConfig": {
6
+ "registry": "https://registry.npmjs.org",
7
+ "access": "public"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "templates",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/lassewehlmanngit/vibecms.git"
18
+ },
19
+ "homepage": "https://vibecms.com",
20
+ "bin": {
21
+ "vibe": "bin/vibe.js",
22
+ "create-vibe-app": "bin/vibe.js"
23
+ },
24
+ "scripts": {
25
+ "build": "echo 'CLI is plain JS — no build needed'"
26
+ },
27
+ "dependencies": {
28
+ "commander": "^12.0.0",
29
+ "prompts": "^2.4.2",
30
+ "picocolors": "^1.0.0",
31
+ "fs-extra": "^11.2.0"
32
+ },
33
+ "devDependencies": {
34
+ "typescript": "^5.0.0",
35
+ "@types/node": "^20.0.0",
36
+ "@types/prompts": "^2.4.9",
37
+ "@types/fs-extra": "^11.0.4"
38
+ }
39
+ }
@@ -0,0 +1,42 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Server Component that securely reads GA4 ID from site.json and injects the Google Analytics script ONLY in production.
6
+ */
7
+ export async function VibeAnalytics() {
8
+ if (process.env.NODE_ENV !== 'production') return null;
9
+
10
+ const settingsPath = path.join(process.cwd(), 'src/content/settings/site.json');
11
+ let ga4Id = '';
12
+
13
+ try {
14
+ const raw = await fs.readFile(settingsPath, 'utf8');
15
+ const settings = JSON.parse(raw);
16
+ if (settings.integrations && settings.integrations.ga4Id) {
17
+ ga4Id = settings.integrations.ga4Id;
18
+ }
19
+ } catch (e) {
20
+ return null;
21
+ }
22
+
23
+ if (!ga4Id) return null;
24
+
25
+ return (
26
+ <>
27
+ <script async src={`https://www.googletagmanager.com/gtag/js?id=${ga4Id}`} />
28
+ <script
29
+ dangerouslySetInnerHTML={{
30
+ __html: `
31
+ window.dataLayer = window.dataLayer || [];
32
+ function gtag(){dataLayer.push(arguments);}
33
+ gtag('js', new Date());
34
+ gtag('config', '${ga4Id}', {
35
+ page_path: window.location.pathname,
36
+ });
37
+ `,
38
+ }}
39
+ />
40
+ </>
41
+ );
42
+ }
@@ -0,0 +1,37 @@
1
+ 'use client';
2
+ import React from 'react';
3
+ import { getCustomBlockComponent } from '@vibecms/core';
4
+
5
+ /**
6
+ * Clean block dispatcher. Resolves block `_type` to a registered component
7
+ * from the custom block registry (configured in vibe.config.ts).
8
+ *
9
+ * This renderer contains NO built-in UI — all block components must be
10
+ * registered via `blocks: [...]` in your vibe.config.ts, or placed in
11
+ * src/components/blocks/ and imported here.
12
+ *
13
+ * Example vibe.config.ts:
14
+ * blocks: [
15
+ * { type: 'hero', component: MyHero },
16
+ * { type: 'features', component: MyFeatures },
17
+ * ]
18
+ */
19
+ export function BlockRenderer({ block, index }: { block: any; index: string | number }) {
20
+ if (!block) return null;
21
+
22
+ const Component = getCustomBlockComponent(block._type);
23
+ if (Component) {
24
+ return <Component data={block} index={index} />;
25
+ }
26
+
27
+ return (
28
+ <div className="p-8 text-center text-amber-700 border border-amber-300 rounded-xl bg-amber-50 max-w-2xl mx-auto w-full my-8 text-sm">
29
+ <p className="font-semibold mb-1">No renderer registered for block type: &quot;{block._type}&quot;</p>
30
+ <p className="text-amber-600">
31
+ Register a component in your <code className="bg-amber-100 px-1 rounded">vibe.config.ts</code>:
32
+ {' '}
33
+ <code className="bg-amber-100 px-1 rounded">{'blocks: [{ type: \'' + block._type + '\', component: My' + block._type.charAt(0).toUpperCase() + block._type.slice(1) + ' }]'}</code>
34
+ </p>
35
+ </div>
36
+ );
37
+ }