@myvillage/cli 1.6.3 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1654 @@
1
+ import { mkdirSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ // MyVillage brand colors
5
+ const BRAND = {
6
+ gold: '#FFD700',
7
+ brown: '#8B4513',
8
+ green: '#228B22',
9
+ primary: '#B07C00',
10
+ secondary: '#E4DCCB',
11
+ darkBrown: '#302017',
12
+ deepGreen: '#043922',
13
+ teal: '#799C9F',
14
+ };
15
+
16
+ // OAuth constants
17
+ const OAUTH_BASE_URL = 'https://portal.myvillageproject.ai/api/oauth';
18
+ const OAUTH_SCOPES = 'openid profile email villager offline_access';
19
+
20
+ // MCP tool group definitions
21
+ const TOOL_GROUPS = {
22
+ social: ['post_create', 'post_view', 'post_list', 'post_edit', 'post_delete', 'comment_create', 'comment_list', 'vote_cast'],
23
+ communities: ['community_list', 'community_view', 'community_create', 'community_join', 'community_leave', 'community_members', 'community_events_list', 'community_event_register', 'community_event_details', 'community_event_create'],
24
+ villages: ['village_list', 'village_view', 'village_create', 'village_update', 'village_leaders_list', 'my_villages'],
25
+ moments: ['moment_create', 'moment_list', 'moment_view', 'pulse_create', 'pulse_list', 'checkin_submit'],
26
+ villagers: ['my_profile', 'villager_search', 'affiliation_search', 'affiliation_add', 'agent_list_by_villager'],
27
+ meetings: ['recall_send_bot', 'recall_meeting_attendance'],
28
+ };
29
+
30
+ // ─── Agentic App Template ───────────────────────────────────────────────────
31
+
32
+ export function createAgenticAppProject(targetDir, options) {
33
+ const {
34
+ name,
35
+ description,
36
+ authStrategy = 'oauth',
37
+ features = [],
38
+ includeMcp = true,
39
+ includeRestApi = true,
40
+ mcpToolGroups = [],
41
+ oauthCredentials = null,
42
+ } = options;
43
+
44
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
45
+ const hasOAuth = authStrategy === 'oauth' || authStrategy === 'both';
46
+ const hasEmailPassword = authStrategy === 'email-password' || authStrategy === 'both';
47
+ const hasDashboard = features.includes('dashboard');
48
+ const hasSettings = features.includes('settings');
49
+ const hasUsers = features.includes('users');
50
+ const hasNotifications = features.includes('notifications');
51
+
52
+ // Create directory structure
53
+ const dirs = [
54
+ '',
55
+ 'public',
56
+ 'server',
57
+ 'server/agent',
58
+ 'src',
59
+ 'src/components',
60
+ 'src/pages',
61
+ ];
62
+
63
+ if (includeMcp) {
64
+ dirs.push('server/mcp');
65
+ }
66
+ if (hasOAuth || hasEmailPassword) {
67
+ dirs.push('src/auth');
68
+ dirs.push('server/auth');
69
+ }
70
+ if (includeRestApi) {
71
+ dirs.push('server/api');
72
+ }
73
+
74
+ for (const dir of dirs) {
75
+ mkdirSync(join(targetDir, dir), { recursive: true });
76
+ }
77
+
78
+ // Write root files
79
+ writeFileSync(join(targetDir, 'package.json'), generatePackageJson(slug, description, includeMcp));
80
+ writeFileSync(join(targetDir, 'vite.config.js'), generateViteConfig());
81
+ writeFileSync(join(targetDir, '.gitignore'), generateGitignore());
82
+ writeFileSync(join(targetDir, '.env'), generateEnv(oauthCredentials, hasOAuth, includeMcp));
83
+ writeFileSync(join(targetDir, '.env.example'), generateEnvExample(hasOAuth, includeMcp));
84
+ writeFileSync(join(targetDir, 'index.html'), generateIndexHtml(name));
85
+ writeFileSync(join(targetDir, 'README.md'), generateReadme(name, description, hasOAuth, includeMcp, includeRestApi, features));
86
+
87
+ // Backend: Express server with agent + integrations
88
+ writeFileSync(join(targetDir, 'server/index.js'), generateEntrypoint(name, hasOAuth, hasEmailPassword, includeMcp, includeRestApi));
89
+ writeFileSync(join(targetDir, 'server/agent/index.js'), generateAgent(includeMcp));
90
+
91
+ if (includeMcp) {
92
+ writeFileSync(join(targetDir, 'server/mcp/client.js'), generateMcpClient(mcpToolGroups));
93
+ }
94
+ if (hasOAuth) {
95
+ writeFileSync(join(targetDir, 'server/auth/oauth.js'), generateOAuthModule());
96
+ }
97
+ if (includeRestApi) {
98
+ writeFileSync(join(targetDir, 'server/api/routes.js'), generateApiRoutes(hasOAuth));
99
+ }
100
+
101
+ // Frontend: React + Vite
102
+ writeFileSync(join(targetDir, 'src/main.jsx'), generateMainJsx());
103
+ writeFileSync(join(targetDir, 'src/App.jsx'), generateAppJsx(features, hasOAuth));
104
+ writeFileSync(join(targetDir, 'src/App.css'), generateAppCss());
105
+ writeFileSync(join(targetDir, 'src/index.css'), generateIndexCss());
106
+
107
+ // Components
108
+ writeFileSync(join(targetDir, 'src/components/Layout.jsx'), generateLayoutComponent());
109
+ writeFileSync(join(targetDir, 'src/components/Sidebar.jsx'), generateSidebarComponent(features));
110
+ writeFileSync(join(targetDir, 'src/components/Header.jsx'), generateHeaderComponent(hasOAuth));
111
+ writeFileSync(join(targetDir, 'src/components/AgentChat.jsx'), generateAgentChatComponent());
112
+
113
+ if (hasOAuth) {
114
+ writeFileSync(join(targetDir, 'src/components/ProtectedRoute.jsx'), generateProtectedRoute());
115
+ writeFileSync(join(targetDir, 'src/auth/oauth.js'), generateBrowserOAuthModule());
116
+ writeFileSync(join(targetDir, 'src/auth/AuthProvider.jsx'), generateAuthProvider());
117
+ writeFileSync(join(targetDir, 'src/pages/Login.jsx'), generateLoginPage());
118
+ writeFileSync(join(targetDir, 'src/pages/Callback.jsx'), generateCallbackPage());
119
+ }
120
+
121
+ // Pages
122
+ if (hasDashboard) {
123
+ writeFileSync(join(targetDir, 'src/pages/Dashboard.jsx'), generateDashboardPage(includeMcp));
124
+ }
125
+ if (hasSettings) {
126
+ writeFileSync(join(targetDir, 'src/pages/Settings.jsx'), generateSettingsPage());
127
+ }
128
+ if (hasUsers) {
129
+ writeFileSync(join(targetDir, 'src/pages/Users.jsx'), generateUsersPage());
130
+ }
131
+ if (hasNotifications) {
132
+ writeFileSync(join(targetDir, 'src/pages/Notifications.jsx'), generateNotificationsPage());
133
+ }
134
+
135
+ writeFileSync(join(targetDir, 'src/pages/NotFound.jsx'), generateNotFoundPage());
136
+ }
137
+
138
+ // ─── Generator Functions ────────────────────────────────────────────────────
139
+
140
+ function generatePackageJson(slug, description, includeMcp) {
141
+ const deps = {
142
+ 'react': '^18.3.0',
143
+ 'react-dom': '^18.3.0',
144
+ 'react-router-dom': '^6.22.0',
145
+ 'ai': '^4.0.0',
146
+ '@ai-sdk/anthropic': '^1.0.0',
147
+ 'express': '^4.21.0',
148
+ 'cors': '^2.8.5',
149
+ 'dotenv': '^16.4.0',
150
+ };
151
+
152
+ if (includeMcp) {
153
+ deps['@modelcontextprotocol/sdk'] = '^1.0.0';
154
+ }
155
+
156
+ const pkg = {
157
+ name: slug,
158
+ version: '0.1.0',
159
+ description: description || 'MyVillageOS application',
160
+ type: 'module',
161
+ private: true,
162
+ scripts: {
163
+ dev: 'concurrently "vite" "node --watch server/index.js"',
164
+ 'dev:client': 'vite',
165
+ 'dev:server': 'node --watch server/index.js',
166
+ build: 'vite build',
167
+ start: 'node server/index.js',
168
+ preview: 'vite preview',
169
+ },
170
+ dependencies: deps,
171
+ devDependencies: {
172
+ 'vite': '^5.0.0',
173
+ '@vitejs/plugin-react': '^4.2.0',
174
+ 'concurrently': '^8.2.0',
175
+ },
176
+ };
177
+
178
+ return JSON.stringify(pkg, null, 2) + '\n';
179
+ }
180
+
181
+ function generateGitignore() {
182
+ return `node_modules/
183
+ dist/
184
+ .DS_Store
185
+ *.log
186
+ .env
187
+ `;
188
+ }
189
+
190
+ function generateEnv(oauthCredentials, hasOAuth, includeMcp) {
191
+ const lines = [];
192
+
193
+ if (hasOAuth) {
194
+ const clientId = oauthCredentials?.clientId || '';
195
+ const clientSecret = oauthCredentials?.clientSecret || '';
196
+ // Server-side OAuth credentials
197
+ lines.push(`MYVILLAGEOS_CLIENT_ID=${clientId}`);
198
+ lines.push(`MYVILLAGEOS_CLIENT_SECRET=${clientSecret}`);
199
+ // Browser-side OAuth credentials (Vite exposes VITE_ prefixed vars)
200
+ lines.push(`VITE_OAUTH_CLIENT_ID=${clientId}`);
201
+ lines.push(`VITE_OAUTH_CLIENT_SECRET=${clientSecret}`);
202
+ lines.push(`VITE_OAUTH_BASE_URL=${OAUTH_BASE_URL}`);
203
+ lines.push('VITE_OAUTH_REDIRECT_URI=http://localhost:5173/callback');
204
+ }
205
+
206
+ lines.push('MYVILLAGEOS_API_URL=https://portal.myvillageproject.ai');
207
+
208
+ if (includeMcp) {
209
+ lines.push('MYVILLAGEOS_MCP_URL=https://mcp.myvillageproject.ai');
210
+ }
211
+
212
+ lines.push('ANTHROPIC_API_KEY=');
213
+ lines.push('PORT=3000');
214
+
215
+ return lines.join('\n') + '\n';
216
+ }
217
+
218
+ function generateEnvExample(hasOAuth, includeMcp) {
219
+ const lines = [];
220
+
221
+ if (hasOAuth) {
222
+ lines.push('MYVILLAGEOS_CLIENT_ID=');
223
+ lines.push('MYVILLAGEOS_CLIENT_SECRET=');
224
+ lines.push(`VITE_OAUTH_CLIENT_ID=`);
225
+ lines.push(`VITE_OAUTH_CLIENT_SECRET=`);
226
+ lines.push(`VITE_OAUTH_BASE_URL=${OAUTH_BASE_URL}`);
227
+ lines.push('VITE_OAUTH_REDIRECT_URI=http://localhost:5173/callback');
228
+ }
229
+
230
+ lines.push('MYVILLAGEOS_API_URL=https://portal.myvillageproject.ai');
231
+
232
+ if (includeMcp) {
233
+ lines.push('MYVILLAGEOS_MCP_URL=https://mcp.myvillageproject.ai');
234
+ }
235
+
236
+ lines.push('ANTHROPIC_API_KEY=');
237
+ lines.push('PORT=3000');
238
+
239
+ return lines.join('\n') + '\n';
240
+ }
241
+
242
+ function generateReadme(name, description, hasOAuth, includeMcp, includeRestApi, features = []) {
243
+ let readme = `# ${name}
244
+
245
+ ${description || 'A MyVillageOS agentic application.'}
246
+
247
+ ## Getting Started
248
+
249
+ 1. Install dependencies:
250
+
251
+ \`\`\`bash
252
+ npm install
253
+ \`\`\`
254
+
255
+ 2. Copy the environment file and fill in your credentials:
256
+
257
+ \`\`\`bash
258
+ cp .env.example .env
259
+ \`\`\`
260
+
261
+ 3. Set your \`ANTHROPIC_API_KEY\` in \`.env\`.
262
+ `;
263
+
264
+ if (hasOAuth) {
265
+ readme += `
266
+ 4. Register an OAuth client at [portal.myvillageproject.ai](https://portal.myvillageproject.ai) and add your \`MYVILLAGEOS_CLIENT_ID\` and \`MYVILLAGEOS_CLIENT_SECRET\` to \`.env\`.
267
+ `;
268
+ }
269
+
270
+ readme += `
271
+ ## Running
272
+
273
+ \`\`\`bash
274
+ # Development (React frontend + Express backend concurrently)
275
+ npm run dev
276
+
277
+ # Or run separately:
278
+ npm run dev:client # React frontend on http://localhost:5173
279
+ npm run dev:server # Express backend on http://localhost:3000
280
+
281
+ # Production
282
+ npm run build # Build React frontend
283
+ npm start # Start Express server (serves built frontend)
284
+ \`\`\`
285
+
286
+ ## Architecture
287
+
288
+ ### Frontend (React + Vite)
289
+ - **src/App.jsx** - Root component with routing
290
+ - **src/components/** - Layout, Sidebar, Header, AgentChat
291
+ - **src/pages/** - Dashboard, Settings, etc.
292
+ ${hasOAuth ? '- **src/auth/** - Browser-side OAuth PKCE flow\n' : ''}
293
+ ### Backend (Express)
294
+ - **server/index.js** - Express server with agent endpoint
295
+ - **server/agent/index.js** - AI agent powered by Vercel AI SDK + Anthropic
296
+ `;
297
+
298
+ if (includeMcp) {
299
+ readme += `- **server/mcp/client.js** - MCP client for MyVillageOS tools
300
+ `;
301
+ }
302
+
303
+ if (hasOAuth) {
304
+ readme += `- **server/auth/oauth.js** - Server-side OAuth 2.0 + PKCE helpers
305
+ `;
306
+ }
307
+
308
+ if (includeRestApi) {
309
+ readme += `- **server/api/routes.js** - REST API proxy routes for platform integration
310
+ `;
311
+ }
312
+
313
+ readme += `
314
+ ## Brand Colors
315
+
316
+ | Color | Hex |
317
+ |------------|-----------|
318
+ | Gold | ${BRAND.gold} |
319
+ | Brown | ${BRAND.brown} |
320
+ | Green | ${BRAND.green} |
321
+ | Primary | ${BRAND.primary} |
322
+ | Secondary | ${BRAND.secondary} |
323
+ | Dark Brown | ${BRAND.darkBrown} |
324
+ | Deep Green | ${BRAND.deepGreen} |
325
+ | Teal | ${BRAND.teal} |
326
+
327
+ Built with [MyVillageOS](https://myvillageproject.ai)
328
+ `;
329
+
330
+ return readme;
331
+ }
332
+
333
+ function generateEntrypoint(name, hasOAuth, hasEmailPassword, includeMcp, includeRestApi) {
334
+ let imports = `import 'dotenv/config';
335
+ import express from 'express';
336
+ import cors from 'cors';
337
+ import { runAgent } from './agent/index.js';
338
+ `;
339
+
340
+ if (includeRestApi) {
341
+ imports += `import { createApiRouter } from './api/routes.js';
342
+ `;
343
+ }
344
+
345
+ if (hasOAuth) {
346
+ imports += `import { initiateOAuthLogin, handleOAuthCallback, getStoredTokens } from './auth/oauth.js';
347
+ `;
348
+ }
349
+
350
+ let body = `
351
+ const app = express();
352
+ const PORT = process.env.PORT || 3000;
353
+
354
+ app.use(express.json());
355
+ app.use(cors());
356
+
357
+ // Health check
358
+ app.get('/health', (req, res) => {
359
+ res.json({ status: 'ok', name: '${name}', timestamp: new Date().toISOString() });
360
+ });
361
+ `;
362
+
363
+ if (hasOAuth) {
364
+ body += `
365
+ // OAuth login flow
366
+ app.get('/auth/login', (req, res) => {
367
+ const { url, codeVerifier } = initiateOAuthLogin();
368
+ // Store codeVerifier in session or a temporary store for the callback
369
+ app.locals.pendingAuth = { codeVerifier };
370
+ res.redirect(url);
371
+ });
372
+
373
+ app.get('/auth/callback', async (req, res) => {
374
+ try {
375
+ const { code } = req.query;
376
+ const { codeVerifier } = app.locals.pendingAuth || {};
377
+ if (!code || !codeVerifier) {
378
+ return res.status(400).json({ error: 'Missing code or code verifier' });
379
+ }
380
+ const tokens = await handleOAuthCallback(code, codeVerifier);
381
+ app.locals.tokens = tokens;
382
+ delete app.locals.pendingAuth;
383
+ res.json({ message: 'Authenticated successfully' });
384
+ } catch (err) {
385
+ console.error('[Auth] OAuth callback error:', err.message);
386
+ res.status(500).json({ error: 'Authentication failed' });
387
+ }
388
+ });
389
+ `;
390
+ }
391
+
392
+ if (includeRestApi) {
393
+ if (hasOAuth) {
394
+ body += `
395
+ // Mount API routes with token accessor
396
+ app.use('/api', createApiRouter(() => app.locals.tokens));
397
+ `;
398
+ } else {
399
+ body += `
400
+ // Mount API routes
401
+ app.use('/api', createApiRouter());
402
+ `;
403
+ }
404
+ }
405
+
406
+ body += `
407
+ // Agent endpoint - send a message to the AI agent
408
+ app.post('/agent/chat', async (req, res) => {
409
+ try {
410
+ const { message } = req.body;
411
+ if (!message) {
412
+ return res.status(400).json({ error: 'Message is required' });
413
+ }
414
+ `;
415
+
416
+ if (hasOAuth) {
417
+ body += ` const tokens = app.locals.tokens || null;
418
+ const result = await runAgent(message, { tokens });
419
+ `;
420
+ } else {
421
+ body += ` const result = await runAgent(message);
422
+ `;
423
+ }
424
+
425
+ body += ` res.json({ response: result });
426
+ } catch (err) {
427
+ console.error('[Agent] Error:', err.message);
428
+ res.status(500).json({ error: 'Agent processing failed' });
429
+ }
430
+ });
431
+
432
+ app.listen(PORT, () => {
433
+ console.log(\`[Server] ${name} running on http://localhost:\${PORT}\`);
434
+ console.log('[Server] POST /agent/chat - Send messages to the AI agent');
435
+ `;
436
+
437
+ if (hasOAuth) {
438
+ body += ` console.log('[Server] GET /auth/login - Start OAuth login flow');
439
+ `;
440
+ }
441
+
442
+ if (includeRestApi) {
443
+ body += ` console.log('[Server] /api/* - Platform API proxy routes');
444
+ `;
445
+ }
446
+
447
+ body += `});
448
+ `;
449
+
450
+ return imports + body;
451
+ }
452
+
453
+ function generateAgent(includeMcp) {
454
+ let code = `import { generateText } from 'ai';
455
+ import { anthropic } from '@ai-sdk/anthropic';
456
+ `;
457
+
458
+ if (includeMcp) {
459
+ code += `import { getMcpTools } from '../mcp/client.js';
460
+ `;
461
+ }
462
+
463
+ code += `
464
+ /**
465
+ * Run the AI agent with the given user message.
466
+ * Uses Vercel AI SDK with Anthropic's Claude and optional MCP tools.
467
+ *
468
+ * @param {string} message - The user's input message
469
+ * @param {object} context - Optional context (tokens, etc.)
470
+ * @returns {string} The agent's text response
471
+ */
472
+ export async function runAgent(message, context = {}) {
473
+ console.log('[Agent] Processing message:', message.substring(0, 80));
474
+
475
+ `;
476
+
477
+ if (includeMcp) {
478
+ code += ` // Load MCP tools from MyVillageOS
479
+ let tools = {};
480
+ try {
481
+ tools = await getMcpTools();
482
+ console.log(\`[Agent] Loaded \${Object.keys(tools).length} MCP tools\`);
483
+ } catch (err) {
484
+ console.warn('[Agent] Failed to load MCP tools, running without tools:', err.message);
485
+ }
486
+
487
+ `;
488
+ }
489
+
490
+ code += ` const systemPrompt = \`You are a helpful AI agent integrated with the MyVillageOS platform.
491
+ You assist users with community management, social networking, and village coordination.
492
+ Be concise, helpful, and action-oriented. When you have tools available, use them
493
+ to fulfill the user's request rather than just describing what could be done.\`;
494
+
495
+ const result = await generateText({
496
+ model: anthropic('claude-sonnet-4-20250514'),
497
+ system: systemPrompt,
498
+ messages: [{ role: 'user', content: message }],
499
+ `;
500
+
501
+ if (includeMcp) {
502
+ code += ` tools,
503
+ maxSteps: 10,
504
+ `;
505
+ }
506
+
507
+ code += ` });
508
+
509
+ console.log('[Agent] Response generated, length:', result.text.length);
510
+ return result.text;
511
+ }
512
+ `;
513
+
514
+ return code;
515
+ }
516
+
517
+ function generateMcpClient(selectedGroups) {
518
+ // Build the list of allowed tool names from selected groups
519
+ const allowedTools = [];
520
+ for (const group of selectedGroups) {
521
+ if (TOOL_GROUPS[group]) {
522
+ allowedTools.push(...TOOL_GROUPS[group]);
523
+ }
524
+ }
525
+
526
+ const code = `import { Client } from '@modelcontextprotocol/sdk/client/index.js';
527
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
528
+ import { jsonSchema } from 'ai';
529
+
530
+ // ─── MCP Tool Group Definitions ─────────────────────────────────────────────
531
+ // Each group maps to a set of MyVillageOS MCP tool names.
532
+
533
+ const TOOL_GROUPS = {
534
+ social: ['post_create', 'post_view', 'post_list', 'post_edit', 'post_delete', 'comment_create', 'comment_list', 'vote_cast'],
535
+ communities: ['community_list', 'community_view', 'community_create', 'community_join', 'community_leave', 'community_members', 'community_events_list', 'community_event_register', 'community_event_details', 'community_event_create'],
536
+ villages: ['village_list', 'village_view', 'village_create', 'village_update', 'village_leaders_list', 'my_villages'],
537
+ moments: ['moment_create', 'moment_list', 'moment_view', 'pulse_create', 'pulse_list', 'checkin_submit'],
538
+ villagers: ['my_profile', 'villager_search', 'affiliation_search', 'affiliation_add', 'agent_list_by_villager'],
539
+ meetings: ['recall_send_bot', 'recall_meeting_attendance'],
540
+ };
541
+
542
+ // Tool groups selected during project creation
543
+ const SELECTED_GROUPS = ${JSON.stringify(selectedGroups)};
544
+
545
+ // Flatten selected groups into an allowlist of tool names
546
+ const ALLOWED_TOOLS = new Set(
547
+ SELECTED_GROUPS.flatMap(group => TOOL_GROUPS[group] || [])
548
+ );
549
+
550
+ let mcpClient = null;
551
+
552
+ /**
553
+ * Initialize the MCP client by spawning the MyVillageOS MCP server as a child process.
554
+ * Uses stdio transport for local communication.
555
+ */
556
+ async function getClient() {
557
+ if (mcpClient) return mcpClient;
558
+
559
+ const transport = new StdioClientTransport({
560
+ command: 'npx',
561
+ args: ['@myvillage/mcp-server'],
562
+ env: {
563
+ ...process.env,
564
+ MYVILLAGEOS_API_URL: process.env.MYVILLAGEOS_API_URL,
565
+ MYVILLAGEOS_MCP_URL: process.env.MYVILLAGEOS_MCP_URL,
566
+ },
567
+ });
568
+
569
+ mcpClient = new Client({ name: 'myvillage-agentic-app', version: '1.0.0' });
570
+ await mcpClient.connect(transport);
571
+ console.log('[MCP] Connected to MyVillageOS MCP server');
572
+
573
+ return mcpClient;
574
+ }
575
+
576
+ /**
577
+ * Convert MCP tools to Vercel AI SDK tool format.
578
+ * Filters tools based on the selected tool groups.
579
+ *
580
+ * @returns {object} Tools object compatible with Vercel AI SDK generateText/streamText
581
+ */
582
+ export async function getMcpTools() {
583
+ const client = await getClient();
584
+ const { tools: mcpTools } = await client.listTools();
585
+
586
+ const aiTools = {};
587
+
588
+ for (const tool of mcpTools) {
589
+ // Filter to only the tools in selected groups (if groups were specified)
590
+ if (ALLOWED_TOOLS.size > 0 && !ALLOWED_TOOLS.has(tool.name)) {
591
+ continue;
592
+ }
593
+
594
+ aiTools[tool.name] = {
595
+ description: tool.description || tool.name,
596
+ parameters: jsonSchema(tool.inputSchema || { type: 'object', properties: {} }),
597
+ execute: async (args) => {
598
+ console.log(\`[MCP] Calling tool: \${tool.name}\`, JSON.stringify(args).substring(0, 200));
599
+ const result = await client.callTool({ name: tool.name, arguments: args });
600
+ return result.content;
601
+ },
602
+ };
603
+ }
604
+
605
+ return aiTools;
606
+ }
607
+
608
+ /**
609
+ * Get the list of available tool group names and their tool counts.
610
+ */
611
+ export function getToolGroupInfo() {
612
+ return Object.entries(TOOL_GROUPS).map(([group, tools]) => ({
613
+ group,
614
+ tools: tools.length,
615
+ selected: SELECTED_GROUPS.includes(group),
616
+ }));
617
+ }
618
+
619
+ /**
620
+ * Disconnect the MCP client. Call this on shutdown.
621
+ */
622
+ export async function disconnectMcp() {
623
+ if (mcpClient) {
624
+ await mcpClient.close();
625
+ mcpClient = null;
626
+ console.log('[MCP] Disconnected from MCP server');
627
+ }
628
+ }
629
+ `;
630
+
631
+ return code;
632
+ }
633
+
634
+ function generateOAuthModule() {
635
+ return `import crypto from 'crypto';
636
+
637
+ // ─── OAuth 2.0 + PKCE Helpers ───────────────────────────────────────────────
638
+ // Server-side OAuth flow for authenticating with MyVillageOS.
639
+
640
+ const OAUTH_BASE_URL = process.env.MYVILLAGEOS_API_URL
641
+ ? \`\${process.env.MYVILLAGEOS_API_URL}/api/oauth\`
642
+ : '${OAUTH_BASE_URL}';
643
+
644
+ const CLIENT_ID = process.env.MYVILLAGEOS_CLIENT_ID;
645
+ const CLIENT_SECRET = process.env.MYVILLAGEOS_CLIENT_SECRET;
646
+ const REDIRECT_URI = 'http://localhost:' + (process.env.PORT || '3000') + '/auth/callback';
647
+ const SCOPES = '${OAUTH_SCOPES}';
648
+
649
+ // In-memory token store (replace with a database in production)
650
+ let storedTokens = null;
651
+
652
+ /**
653
+ * Generate a cryptographically random code verifier for PKCE.
654
+ */
655
+ function generateCodeVerifier() {
656
+ return crypto.randomBytes(64).toString('base64url');
657
+ }
658
+
659
+ /**
660
+ * Generate the code challenge from a code verifier (S256 method).
661
+ */
662
+ function generateCodeChallenge(verifier) {
663
+ return crypto.createHash('sha256').update(verifier).digest('base64url');
664
+ }
665
+
666
+ /**
667
+ * Initiate the OAuth login flow. Returns the authorization URL and code verifier.
668
+ * The caller should store the codeVerifier and redirect the user to the URL.
669
+ */
670
+ export function initiateOAuthLogin() {
671
+ const codeVerifier = generateCodeVerifier();
672
+ const codeChallenge = generateCodeChallenge(codeVerifier);
673
+ const state = crypto.randomBytes(16).toString('hex');
674
+
675
+ const params = new URLSearchParams({
676
+ response_type: 'code',
677
+ client_id: CLIENT_ID,
678
+ redirect_uri: REDIRECT_URI,
679
+ scope: SCOPES,
680
+ state,
681
+ code_challenge: codeChallenge,
682
+ code_challenge_method: 'S256',
683
+ });
684
+
685
+ const url = \`\${OAUTH_BASE_URL}/authorize?\${params.toString()}\`;
686
+ console.log('[OAuth] Login URL generated');
687
+
688
+ return { url, codeVerifier, state };
689
+ }
690
+
691
+ /**
692
+ * Exchange an authorization code for tokens.
693
+ */
694
+ export async function handleOAuthCallback(code, codeVerifier) {
695
+ const body = new URLSearchParams({
696
+ grant_type: 'authorization_code',
697
+ code,
698
+ redirect_uri: REDIRECT_URI,
699
+ client_id: CLIENT_ID,
700
+ client_secret: CLIENT_SECRET,
701
+ code_verifier: codeVerifier,
702
+ });
703
+
704
+ const response = await fetch(\`\${OAUTH_BASE_URL}/token\`, {
705
+ method: 'POST',
706
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
707
+ body: body.toString(),
708
+ });
709
+
710
+ if (!response.ok) {
711
+ const error = await response.text();
712
+ throw new Error(\`Token exchange failed: \${response.status} \${error}\`);
713
+ }
714
+
715
+ const tokens = await response.json();
716
+ storedTokens = {
717
+ accessToken: tokens.access_token,
718
+ refreshToken: tokens.refresh_token,
719
+ expiresAt: Date.now() + (tokens.expires_in * 1000),
720
+ idToken: tokens.id_token,
721
+ };
722
+
723
+ console.log('[OAuth] Tokens obtained successfully');
724
+ return storedTokens;
725
+ }
726
+
727
+ /**
728
+ * Refresh the access token using the stored refresh token.
729
+ */
730
+ export async function refreshAccessToken() {
731
+ if (!storedTokens?.refreshToken) {
732
+ throw new Error('No refresh token available');
733
+ }
734
+
735
+ const body = new URLSearchParams({
736
+ grant_type: 'refresh_token',
737
+ refresh_token: storedTokens.refreshToken,
738
+ client_id: CLIENT_ID,
739
+ client_secret: CLIENT_SECRET,
740
+ });
741
+
742
+ const response = await fetch(\`\${OAUTH_BASE_URL}/token\`, {
743
+ method: 'POST',
744
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
745
+ body: body.toString(),
746
+ });
747
+
748
+ if (!response.ok) {
749
+ const error = await response.text();
750
+ throw new Error(\`Token refresh failed: \${response.status} \${error}\`);
751
+ }
752
+
753
+ const tokens = await response.json();
754
+ storedTokens = {
755
+ accessToken: tokens.access_token,
756
+ refreshToken: tokens.refresh_token || storedTokens.refreshToken,
757
+ expiresAt: Date.now() + (tokens.expires_in * 1000),
758
+ idToken: tokens.id_token,
759
+ };
760
+
761
+ console.log('[OAuth] Tokens refreshed successfully');
762
+ return storedTokens;
763
+ }
764
+
765
+ /**
766
+ * Get a valid access token, refreshing if needed.
767
+ */
768
+ export async function getValidAccessToken() {
769
+ if (!storedTokens) {
770
+ throw new Error('Not authenticated. Visit /auth/login to authenticate.');
771
+ }
772
+
773
+ // Refresh if token expires within 60 seconds
774
+ if (storedTokens.expiresAt - Date.now() < 60_000) {
775
+ await refreshAccessToken();
776
+ }
777
+
778
+ return storedTokens.accessToken;
779
+ }
780
+
781
+ /**
782
+ * Get the currently stored tokens (may be null if not authenticated).
783
+ */
784
+ export function getStoredTokens() {
785
+ return storedTokens;
786
+ }
787
+ `;
788
+ }
789
+
790
+ function generateApiRoutes(hasOAuth) {
791
+ let code = `import { Router } from 'express';
792
+
793
+ // ─── Platform API Routes ────────────────────────────────────────────────────
794
+ // Express router with helpers for making authenticated API calls to MyVillageOS.
795
+
796
+ const API_BASE_URL = process.env.MYVILLAGEOS_API_URL || 'https://portal.myvillageproject.ai';
797
+
798
+ `;
799
+
800
+ if (hasOAuth) {
801
+ code += `/**
802
+ * Create the API router.
803
+ * @param {Function} getTokens - Function that returns the current OAuth tokens
804
+ */
805
+ export function createApiRouter(getTokens) {
806
+ const router = Router();
807
+
808
+ /**
809
+ * Make an authenticated request to the MyVillageOS platform API.
810
+ */
811
+ async function apiRequest(path, options = {}) {
812
+ const tokens = getTokens?.();
813
+ const headers = {
814
+ 'Content-Type': 'application/json',
815
+ ...options.headers,
816
+ };
817
+
818
+ if (tokens?.accessToken) {
819
+ headers['Authorization'] = \`Bearer \${tokens.accessToken}\`;
820
+ }
821
+
822
+ const url = \`\${API_BASE_URL}\${path}\`;
823
+ console.log(\`[API] \${options.method || 'GET'} \${url}\`);
824
+
825
+ const response = await fetch(url, {
826
+ ...options,
827
+ headers,
828
+ });
829
+
830
+ if (!response.ok) {
831
+ const error = await response.text();
832
+ throw new Error(\`API request failed: \${response.status} \${error}\`);
833
+ }
834
+
835
+ return response.json();
836
+ }
837
+ `;
838
+ } else {
839
+ code += `/**
840
+ * Create the API router.
841
+ */
842
+ export function createApiRouter() {
843
+ const router = Router();
844
+
845
+ /**
846
+ * Make a request to the MyVillageOS platform API.
847
+ */
848
+ async function apiRequest(path, options = {}) {
849
+ const headers = {
850
+ 'Content-Type': 'application/json',
851
+ ...options.headers,
852
+ };
853
+
854
+ const url = \`\${API_BASE_URL}\${path}\`;
855
+ console.log(\`[API] \${options.method || 'GET'} \${url}\`);
856
+
857
+ const response = await fetch(url, {
858
+ ...options,
859
+ headers,
860
+ });
861
+
862
+ if (!response.ok) {
863
+ const error = await response.text();
864
+ throw new Error(\`API request failed: \${response.status} \${error}\`);
865
+ }
866
+
867
+ return response.json();
868
+ }
869
+ `;
870
+ }
871
+
872
+ code += `
873
+ // ─── Network / Social ──────────────────────────────────────────────────────
874
+
875
+ // List posts from the MAN network
876
+ router.get('/network/posts', async (req, res) => {
877
+ try {
878
+ const data = await apiRequest('/api/network/posts');
879
+ res.json(data);
880
+ } catch (err) {
881
+ console.error('[API] Error fetching posts:', err.message);
882
+ res.status(500).json({ error: err.message });
883
+ }
884
+ });
885
+
886
+ // Create a new post
887
+ router.post('/network/posts', async (req, res) => {
888
+ try {
889
+ const data = await apiRequest('/api/network/posts', {
890
+ method: 'POST',
891
+ body: JSON.stringify(req.body),
892
+ });
893
+ res.json(data);
894
+ } catch (err) {
895
+ console.error('[API] Error creating post:', err.message);
896
+ res.status(500).json({ error: err.message });
897
+ }
898
+ });
899
+
900
+ // ─── Communities ────────────────────────────────────────────────────────────
901
+
902
+ // List communities
903
+ router.get('/communities', async (req, res) => {
904
+ try {
905
+ const data = await apiRequest('/api/network/communities');
906
+ res.json(data);
907
+ } catch (err) {
908
+ console.error('[API] Error fetching communities:', err.message);
909
+ res.status(500).json({ error: err.message });
910
+ }
911
+ });
912
+
913
+ // ─── Villages ───────────────────────────────────────────────────────────────
914
+
915
+ // List villages
916
+ router.get('/villages', async (req, res) => {
917
+ try {
918
+ const data = await apiRequest('/api/network/villages');
919
+ res.json(data);
920
+ } catch (err) {
921
+ console.error('[API] Error fetching villages:', err.message);
922
+ res.status(500).json({ error: err.message });
923
+ }
924
+ });
925
+
926
+ // ─── Profile ────────────────────────────────────────────────────────────────
927
+
928
+ // Get current user profile
929
+ router.get('/profile', async (req, res) => {
930
+ try {
931
+ const data = await apiRequest('/api/oauth/userinfo');
932
+ res.json(data);
933
+ } catch (err) {
934
+ console.error('[API] Error fetching profile:', err.message);
935
+ res.status(500).json({ error: err.message });
936
+ }
937
+ });
938
+
939
+ return router;
940
+ }
941
+ `;
942
+
943
+ return code;
944
+ }
945
+
946
+ // ─── Vite & HTML Generators ─────────────────────────────────────────────────
947
+
948
+ function generateViteConfig() {
949
+ return `import { defineConfig } from 'vite';
950
+ import react from '@vitejs/plugin-react';
951
+
952
+ export default defineConfig({
953
+ plugins: [react()],
954
+ server: {
955
+ port: 5173,
956
+ open: true,
957
+ proxy: {
958
+ '/api': 'http://localhost:3000',
959
+ '/agent': 'http://localhost:3000',
960
+ '/auth': 'http://localhost:3000',
961
+ '/health': 'http://localhost:3000',
962
+ },
963
+ },
964
+ build: { outDir: 'dist' },
965
+ });
966
+ `;
967
+ }
968
+
969
+ function generateIndexHtml(name) {
970
+ return `<!DOCTYPE html>
971
+ <html lang="en">
972
+ <head>
973
+ <meta charset="UTF-8" />
974
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
975
+ <title>${name} - MyVillageOS</title>
976
+ </head>
977
+ <body>
978
+ <div id="root"></div>
979
+ <script type="module" src="/src/main.jsx"></script>
980
+ </body>
981
+ </html>
982
+ `;
983
+ }
984
+
985
+ // ─── Frontend Generators ────────────────────────────────────────────────────
986
+ // NOTE: These functions return string literals that will be written to files.
987
+ // Template expressions like ${} are JS interpolation, not output.
988
+ // Backticks, $, and { that appear in output JSX are safe in regular strings.
989
+
990
+ function generateMainJsx() {
991
+ return `import React from 'react';
992
+ import ReactDOM from 'react-dom/client';
993
+ import { BrowserRouter } from 'react-router-dom';
994
+ import App from './App.jsx';
995
+ import './index.css';
996
+
997
+ ReactDOM.createRoot(document.getElementById('root')).render(
998
+ <React.StrictMode>
999
+ <BrowserRouter>
1000
+ <App />
1001
+ </BrowserRouter>
1002
+ </React.StrictMode>
1003
+ );
1004
+ `;
1005
+ }
1006
+
1007
+ function generateAppJsx(features, hasOAuth) {
1008
+ const hasDashboard = features.includes('dashboard');
1009
+ const hasSettings = features.includes('settings');
1010
+ const hasUsers = features.includes('users');
1011
+ const hasNotifications = features.includes('notifications');
1012
+
1013
+ const imports = ["import { Routes, Route } from 'react-router-dom';"];
1014
+ imports.push("import Layout from './components/Layout.jsx';");
1015
+ imports.push("import NotFound from './pages/NotFound.jsx';");
1016
+ imports.push("import './App.css';");
1017
+
1018
+ if (hasOAuth) {
1019
+ imports.push("import { AuthProvider } from './auth/AuthProvider.jsx';");
1020
+ imports.push("import ProtectedRoute from './components/ProtectedRoute.jsx';");
1021
+ imports.push("import Login from './pages/Login.jsx';");
1022
+ imports.push("import Callback from './pages/Callback.jsx';");
1023
+ }
1024
+ if (hasDashboard) imports.push("import Dashboard from './pages/Dashboard.jsx';");
1025
+ if (hasSettings) imports.push("import Settings from './pages/Settings.jsx';");
1026
+ if (hasUsers) imports.push("import Users from './pages/Users.jsx';");
1027
+ if (hasNotifications) imports.push("import Notifications from './pages/Notifications.jsx';");
1028
+
1029
+ const routes = [];
1030
+ const homeElement = hasDashboard ? '<Dashboard />' : '<div className="welcome"><h1>Welcome</h1><p>Select a page from the sidebar to get started.</p></div>';
1031
+
1032
+ if (hasOAuth) {
1033
+ routes.push(' <Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>');
1034
+ } else {
1035
+ routes.push(' <Route path="/" element={<Layout />}>');
1036
+ }
1037
+
1038
+ routes.push(' <Route index element={' + homeElement + '} />');
1039
+ if (hasDashboard) routes.push(' <Route path="dashboard" element={<Dashboard />} />');
1040
+ if (hasSettings) routes.push(' <Route path="settings" element={<Settings />} />');
1041
+ if (hasUsers) routes.push(' <Route path="users" element={<Users />} />');
1042
+ if (hasNotifications) routes.push(' <Route path="notifications" element={<Notifications />} />');
1043
+ routes.push(' <Route path="*" element={<NotFound />} />');
1044
+ routes.push(' </Route>');
1045
+
1046
+ if (hasOAuth) {
1047
+ routes.push(' <Route path="/login" element={<Login />} />');
1048
+ routes.push(' <Route path="/callback" element={<Callback />} />');
1049
+ }
1050
+
1051
+ let body;
1052
+ if (hasOAuth) {
1053
+ body = ' <AuthProvider>\n <Routes>\n' + routes.join('\n') + '\n </Routes>\n </AuthProvider>';
1054
+ } else {
1055
+ body = ' <Routes>\n' + routes.join('\n') + '\n </Routes>';
1056
+ }
1057
+
1058
+ return imports.join('\n') + '\n\nexport default function App() {\n return (\n' + body + '\n );\n}\n';
1059
+ }
1060
+
1061
+ function generateAppCss() {
1062
+ return ':root {\n' +
1063
+ ' --brand-gold: ' + BRAND.gold + ';\n' +
1064
+ ' --brand-brown: ' + BRAND.brown + ';\n' +
1065
+ ' --brand-green: ' + BRAND.green + ';\n' +
1066
+ ' --brand-primary: ' + BRAND.primary + ';\n' +
1067
+ ' --brand-secondary: ' + BRAND.secondary + ';\n' +
1068
+ ' --brand-dark-brown: ' + BRAND.darkBrown + ';\n' +
1069
+ ' --brand-deep-green: ' + BRAND.deepGreen + ';\n' +
1070
+ ' --brand-teal: ' + BRAND.teal + ';\n' +
1071
+ ' --sidebar-width: 240px;\n' +
1072
+ ' --header-height: 56px;\n' +
1073
+ '}\n' +
1074
+ '\n' +
1075
+ '.layout { display: flex; height: 100vh; }\n' +
1076
+ '.layout-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }\n' +
1077
+ '.layout-content { flex: 1; overflow-y: auto; padding: 24px; background: #f5f3ef; }\n' +
1078
+ '\n' +
1079
+ '.sidebar {\n' +
1080
+ ' width: var(--sidebar-width);\n' +
1081
+ ' background: var(--brand-dark-brown);\n' +
1082
+ ' color: var(--brand-secondary);\n' +
1083
+ ' display: flex;\n' +
1084
+ ' flex-direction: column;\n' +
1085
+ '}\n' +
1086
+ '.sidebar-brand { padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,0.1); }\n' +
1087
+ '.sidebar-brand h2 { color: var(--brand-gold); font-size: 18px; margin: 0; }\n' +
1088
+ '.sidebar-nav { flex: 1; padding: 12px 0; list-style: none; }\n' +
1089
+ '.sidebar-nav a {\n' +
1090
+ ' display: block; padding: 10px 20px; color: var(--brand-secondary);\n' +
1091
+ ' text-decoration: none; font-size: 14px; transition: background 0.2s;\n' +
1092
+ '}\n' +
1093
+ '.sidebar-nav a:hover, .sidebar-nav a.active {\n' +
1094
+ ' background: rgba(255,215,0,0.1); color: var(--brand-gold);\n' +
1095
+ '}\n' +
1096
+ '\n' +
1097
+ '.header {\n' +
1098
+ ' height: var(--header-height); background: white; border-bottom: 1px solid #e0ddd7;\n' +
1099
+ ' display: flex; align-items: center; justify-content: space-between; padding: 0 24px;\n' +
1100
+ '}\n' +
1101
+ '.header-title { font-size: 16px; font-weight: 600; color: var(--brand-dark-brown); }\n' +
1102
+ '.header-user { display: flex; align-items: center; gap: 12px; }\n' +
1103
+ '.header-user button {\n' +
1104
+ ' padding: 6px 14px; background: var(--brand-primary); color: white;\n' +
1105
+ ' border: none; border-radius: 6px; font-size: 13px; cursor: pointer;\n' +
1106
+ '}\n' +
1107
+ '\n' +
1108
+ '.login-page {\n' +
1109
+ ' display: flex; justify-content: center; align-items: center; height: 100vh;\n' +
1110
+ ' background: var(--brand-secondary);\n' +
1111
+ '}\n' +
1112
+ '.login-card {\n' +
1113
+ ' text-align: center; padding: 48px; background: white;\n' +
1114
+ ' border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.1);\n' +
1115
+ '}\n' +
1116
+ '.login-btn {\n' +
1117
+ ' margin-top: 20px; padding: 12px 32px; background: var(--brand-primary);\n' +
1118
+ ' color: white; border: none; border-radius: 8px; font-size: 16px; cursor: pointer;\n' +
1119
+ '}\n' +
1120
+ '.login-btn:hover { background: var(--brand-gold); color: var(--brand-dark-brown); }\n' +
1121
+ '\n' +
1122
+ '.stat-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; margin-bottom: 24px; }\n' +
1123
+ '.stat-card {\n' +
1124
+ ' background: white; border-radius: 8px; padding: 20px;\n' +
1125
+ ' box-shadow: 0 1px 3px rgba(0,0,0,0.08);\n' +
1126
+ '}\n' +
1127
+ '.stat-card h3 { font-size: 13px; color: var(--brand-teal); margin-bottom: 8px; }\n' +
1128
+ '.stat-card .value { font-size: 28px; font-weight: 700; color: var(--brand-dark-brown); }\n' +
1129
+ '\n' +
1130
+ '.agent-chat {\n' +
1131
+ ' background: white; border-radius: 8px; padding: 20px;\n' +
1132
+ ' box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-top: 24px;\n' +
1133
+ '}\n' +
1134
+ '.agent-chat h3 { margin-bottom: 12px; color: var(--brand-dark-brown); }\n' +
1135
+ '.agent-chat-messages {\n' +
1136
+ ' max-height: 300px; overflow-y: auto; margin-bottom: 12px;\n' +
1137
+ ' border: 1px solid #e0ddd7; border-radius: 6px; padding: 12px;\n' +
1138
+ '}\n' +
1139
+ '.agent-chat-input { display: flex; gap: 8px; }\n' +
1140
+ '.agent-chat-input input {\n' +
1141
+ ' flex: 1; padding: 10px 14px; border: 1px solid #e0ddd7;\n' +
1142
+ ' border-radius: 6px; font-size: 14px;\n' +
1143
+ '}\n' +
1144
+ '.agent-chat-input button {\n' +
1145
+ ' padding: 10px 20px; background: var(--brand-primary); color: white;\n' +
1146
+ ' border: none; border-radius: 6px; font-size: 14px; cursor: pointer;\n' +
1147
+ '}\n' +
1148
+ '.message { margin-bottom: 8px; }\n' +
1149
+ '.message.user { color: var(--brand-primary); }\n' +
1150
+ '.message.agent { color: var(--brand-deep-green); }\n' +
1151
+ '\n' +
1152
+ '.page-header { margin-bottom: 24px; }\n' +
1153
+ '.page-header h1 { font-size: 24px; color: var(--brand-dark-brown); }\n' +
1154
+ '.page-header p { color: var(--brand-teal); margin-top: 4px; }\n';
1155
+ }
1156
+
1157
+ function generateIndexCss() {
1158
+ return '*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n' +
1159
+ 'html, body {\n' +
1160
+ ' height: 100%;\n' +
1161
+ " font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;\n" +
1162
+ ' background: ' + BRAND.secondary + ';\n' +
1163
+ ' color: ' + BRAND.darkBrown + ';\n' +
1164
+ ' line-height: 1.6;\n' +
1165
+ '}\n' +
1166
+ '#root { height: 100%; }\n' +
1167
+ 'a { color: ' + BRAND.primary + '; text-decoration: none; }\n' +
1168
+ 'a:hover { text-decoration: underline; }\n' +
1169
+ 'button { cursor: pointer; font-family: inherit; }\n';
1170
+ }
1171
+
1172
+ // ─── React Components ───────────────────────────────────────────────────────
1173
+
1174
+ function generateLayoutComponent() {
1175
+ return `import { Outlet } from 'react-router-dom';
1176
+ import Sidebar from './Sidebar.jsx';
1177
+ import Header from './Header.jsx';
1178
+
1179
+ export default function Layout() {
1180
+ return (
1181
+ <div className="layout">
1182
+ <Sidebar />
1183
+ <div className="layout-main">
1184
+ <Header />
1185
+ <main className="layout-content">
1186
+ <Outlet />
1187
+ </main>
1188
+ </div>
1189
+ </div>
1190
+ );
1191
+ }
1192
+ `;
1193
+ }
1194
+
1195
+ function generateSidebarComponent(features) {
1196
+ const links = [' <li><a href="/">Home</a></li>'];
1197
+ if (features.includes('dashboard')) links.push(' <li><a href="/dashboard">Dashboard</a></li>');
1198
+ if (features.includes('settings')) links.push(' <li><a href="/settings">Settings</a></li>');
1199
+ if (features.includes('users')) links.push(' <li><a href="/users">Users</a></li>');
1200
+ if (features.includes('notifications')) links.push(' <li><a href="/notifications">Notifications</a></li>');
1201
+
1202
+ return 'export default function Sidebar() {\n' +
1203
+ ' return (\n' +
1204
+ ' <aside className="sidebar">\n' +
1205
+ ' <div className="sidebar-brand">\n' +
1206
+ ' <h2>MyVillageOS</h2>\n' +
1207
+ ' </div>\n' +
1208
+ ' <ul className="sidebar-nav">\n' +
1209
+ links.join('\n') + '\n' +
1210
+ ' </ul>\n' +
1211
+ ' </aside>\n' +
1212
+ ' );\n' +
1213
+ '}\n';
1214
+ }
1215
+
1216
+ function generateHeaderComponent(hasOAuth) {
1217
+ if (hasOAuth) {
1218
+ return `import { useAuth } from '../auth/AuthProvider.jsx';
1219
+
1220
+ export default function Header() {
1221
+ const { user, logout } = useAuth();
1222
+
1223
+ return (
1224
+ <header className="header">
1225
+ <span className="header-title">MyVillageOS App</span>
1226
+ <div className="header-user">
1227
+ {user && <span>{user.name || user.email}</span>}
1228
+ {user && <button onClick={logout}>Sign out</button>}
1229
+ </div>
1230
+ </header>
1231
+ );
1232
+ }
1233
+ `;
1234
+ }
1235
+
1236
+ return `export default function Header() {
1237
+ return (
1238
+ <header className="header">
1239
+ <span className="header-title">MyVillageOS App</span>
1240
+ </header>
1241
+ );
1242
+ }
1243
+ `;
1244
+ }
1245
+
1246
+ function generateAgentChatComponent() {
1247
+ return "import { useState } from 'react';\n" +
1248
+ '\n' +
1249
+ 'export default function AgentChat() {\n' +
1250
+ ' const [messages, setMessages] = useState([]);\n' +
1251
+ " const [input, setInput] = useState('');\n" +
1252
+ ' const [loading, setLoading] = useState(false);\n' +
1253
+ '\n' +
1254
+ ' const sendMessage = async () => {\n' +
1255
+ ' if (!input.trim() || loading) return;\n' +
1256
+ '\n' +
1257
+ ' const userMsg = input.trim();\n' +
1258
+ " setMessages(prev => [...prev, { role: 'user', text: userMsg }]);\n" +
1259
+ " setInput('');\n" +
1260
+ ' setLoading(true);\n' +
1261
+ '\n' +
1262
+ ' try {\n' +
1263
+ " const res = await fetch('/agent/chat', {\n" +
1264
+ " method: 'POST',\n" +
1265
+ " headers: { 'Content-Type': 'application/json' },\n" +
1266
+ ' body: JSON.stringify({ message: userMsg }),\n' +
1267
+ ' });\n' +
1268
+ ' const data = await res.json();\n' +
1269
+ " setMessages(prev => [...prev, { role: 'agent', text: data.response || data.error }]);\n" +
1270
+ ' } catch (err) {\n' +
1271
+ " setMessages(prev => [...prev, { role: 'agent', text: 'Error: ' + err.message }]);\n" +
1272
+ ' } finally {\n' +
1273
+ ' setLoading(false);\n' +
1274
+ ' }\n' +
1275
+ ' };\n' +
1276
+ '\n' +
1277
+ ' return (\n' +
1278
+ ' <div className="agent-chat">\n' +
1279
+ ' <h3>AI Agent</h3>\n' +
1280
+ ' <div className="agent-chat-messages">\n' +
1281
+ " {messages.length === 0 && <p style={{ color: '#999' }}>Ask the agent anything about your village...</p>}\n" +
1282
+ ' {messages.map((msg, i) => (\n' +
1283
+ ' <div key={i} className={`message ${msg.role}`}>\n' +
1284
+ " <strong>{msg.role === 'user' ? 'You' : 'Agent'}:</strong> {msg.text}\n" +
1285
+ ' </div>\n' +
1286
+ ' ))}\n' +
1287
+ ' {loading && <div className="message agent"><em>Thinking...</em></div>}\n' +
1288
+ ' </div>\n' +
1289
+ ' <div className="agent-chat-input">\n' +
1290
+ ' <input\n' +
1291
+ ' value={input}\n' +
1292
+ ' onChange={e => setInput(e.target.value)}\n' +
1293
+ " onKeyDown={e => e.key === 'Enter' && sendMessage()}\n" +
1294
+ ' placeholder="Ask the AI agent..."\n' +
1295
+ ' disabled={loading}\n' +
1296
+ ' />\n' +
1297
+ ' <button onClick={sendMessage} disabled={loading}>Send</button>\n' +
1298
+ ' </div>\n' +
1299
+ ' </div>\n' +
1300
+ ' );\n' +
1301
+ '}\n';
1302
+ }
1303
+
1304
+ function generateProtectedRoute() {
1305
+ return `import { Navigate } from 'react-router-dom';
1306
+ import { useAuth } from '../auth/AuthProvider.jsx';
1307
+
1308
+ export default function ProtectedRoute({ children }) {
1309
+ const { isAuthenticated, isLoading } = useAuth();
1310
+ if (isLoading) return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>Loading...</div>;
1311
+ if (!isAuthenticated) return <Navigate to="/login" replace />;
1312
+ return children;
1313
+ }
1314
+ `;
1315
+ }
1316
+
1317
+ function generateBrowserOAuthModule() {
1318
+ return '// Browser-side OAuth 2.0 + PKCE implementation\n' +
1319
+ '\n' +
1320
+ 'function base64UrlEncode(buffer) {\n' +
1321
+ ' const bytes = new Uint8Array(buffer);\n' +
1322
+ " let str = '';\n" +
1323
+ " for (const b of bytes) str += String.fromCharCode(b);\n" +
1324
+ " return btoa(str).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n" +
1325
+ '}\n' +
1326
+ '\n' +
1327
+ 'export function generateCodeVerifier() {\n' +
1328
+ ' const array = new Uint8Array(64);\n' +
1329
+ ' crypto.getRandomValues(array);\n' +
1330
+ ' return base64UrlEncode(array);\n' +
1331
+ '}\n' +
1332
+ '\n' +
1333
+ 'export async function generateCodeChallenge(verifier) {\n' +
1334
+ ' const encoder = new TextEncoder();\n' +
1335
+ ' const data = encoder.encode(verifier);\n' +
1336
+ " const digest = await crypto.subtle.digest('SHA-256', data);\n" +
1337
+ ' return base64UrlEncode(digest);\n' +
1338
+ '}\n' +
1339
+ '\n' +
1340
+ 'export async function startLogin() {\n' +
1341
+ ' const verifier = generateCodeVerifier();\n' +
1342
+ ' const challenge = await generateCodeChallenge(verifier);\n' +
1343
+ ' const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));\n' +
1344
+ '\n' +
1345
+ " sessionStorage.setItem('oauth_verifier', verifier);\n" +
1346
+ " sessionStorage.setItem('oauth_state', state);\n" +
1347
+ '\n' +
1348
+ ' const baseUrl = import.meta.env.VITE_OAUTH_BASE_URL;\n' +
1349
+ ' const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID;\n' +
1350
+ ' const redirectUri = import.meta.env.VITE_OAUTH_REDIRECT_URI;\n' +
1351
+ '\n' +
1352
+ ' const params = new URLSearchParams({\n' +
1353
+ ' client_id: clientId,\n' +
1354
+ ' redirect_uri: redirectUri,\n' +
1355
+ " response_type: 'code',\n" +
1356
+ " scope: 'openid profile email villager offline_access',\n" +
1357
+ ' state,\n' +
1358
+ ' code_challenge: challenge,\n' +
1359
+ " code_challenge_method: 'S256',\n" +
1360
+ ' });\n' +
1361
+ '\n' +
1362
+ ' window.location.href = `${baseUrl}/authorize?${params.toString()}`;\n' +
1363
+ '}\n' +
1364
+ '\n' +
1365
+ 'export async function handleCallback(code, state) {\n' +
1366
+ " const savedState = sessionStorage.getItem('oauth_state');\n" +
1367
+ " const verifier = sessionStorage.getItem('oauth_verifier');\n" +
1368
+ " if (state !== savedState) throw new Error('OAuth state mismatch');\n" +
1369
+ '\n' +
1370
+ ' const baseUrl = import.meta.env.VITE_OAUTH_BASE_URL;\n' +
1371
+ ' const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID;\n' +
1372
+ ' const clientSecret = import.meta.env.VITE_OAUTH_CLIENT_SECRET;\n' +
1373
+ ' const redirectUri = import.meta.env.VITE_OAUTH_REDIRECT_URI;\n' +
1374
+ '\n' +
1375
+ ' const body = new URLSearchParams({\n' +
1376
+ " grant_type: 'authorization_code',\n" +
1377
+ ' code,\n' +
1378
+ ' redirect_uri: redirectUri,\n' +
1379
+ ' client_id: clientId,\n' +
1380
+ ' client_secret: clientSecret,\n' +
1381
+ ' code_verifier: verifier,\n' +
1382
+ ' });\n' +
1383
+ '\n' +
1384
+ ' const response = await fetch(`${baseUrl}/token`, {\n' +
1385
+ " method: 'POST',\n" +
1386
+ " headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n" +
1387
+ ' body: body.toString(),\n' +
1388
+ ' });\n' +
1389
+ '\n' +
1390
+ " if (!response.ok) throw new Error('Token exchange failed');\n" +
1391
+ '\n' +
1392
+ ' const tokens = await response.json();\n' +
1393
+ ' storeTokens(tokens);\n' +
1394
+ " sessionStorage.removeItem('oauth_verifier');\n" +
1395
+ " sessionStorage.removeItem('oauth_state');\n" +
1396
+ ' return tokens;\n' +
1397
+ '}\n' +
1398
+ '\n' +
1399
+ 'export async function refreshToken(token) {\n' +
1400
+ ' const baseUrl = import.meta.env.VITE_OAUTH_BASE_URL;\n' +
1401
+ ' const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID;\n' +
1402
+ ' const clientSecret = import.meta.env.VITE_OAUTH_CLIENT_SECRET;\n' +
1403
+ '\n' +
1404
+ ' const body = new URLSearchParams({\n' +
1405
+ " grant_type: 'refresh_token',\n" +
1406
+ ' refresh_token: token,\n' +
1407
+ ' client_id: clientId,\n' +
1408
+ ' client_secret: clientSecret,\n' +
1409
+ ' });\n' +
1410
+ '\n' +
1411
+ ' const response = await fetch(`${baseUrl}/token`, {\n' +
1412
+ " method: 'POST',\n" +
1413
+ " headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n" +
1414
+ ' body: body.toString(),\n' +
1415
+ ' });\n' +
1416
+ '\n' +
1417
+ " if (!response.ok) throw new Error('Token refresh failed');\n" +
1418
+ '\n' +
1419
+ ' const tokens = await response.json();\n' +
1420
+ ' storeTokens(tokens);\n' +
1421
+ ' return tokens;\n' +
1422
+ '}\n' +
1423
+ '\n' +
1424
+ 'export function getStoredTokens() {\n' +
1425
+ " const raw = localStorage.getItem('myvillage_tokens');\n" +
1426
+ ' return raw ? JSON.parse(raw) : null;\n' +
1427
+ '}\n' +
1428
+ '\n' +
1429
+ 'export function storeTokens(tokens) {\n' +
1430
+ " localStorage.setItem('myvillage_tokens', JSON.stringify(tokens));\n" +
1431
+ '}\n' +
1432
+ '\n' +
1433
+ 'export function clearTokens() {\n' +
1434
+ " localStorage.removeItem('myvillage_tokens');\n" +
1435
+ '}\n';
1436
+ }
1437
+
1438
+ function generateAuthProvider() {
1439
+ return "import { createContext, useContext, useState, useEffect, useCallback } from 'react';\n" +
1440
+ "import { useNavigate } from 'react-router-dom';\n" +
1441
+ "import { startLogin, getStoredTokens, clearTokens, refreshToken } from './oauth.js';\n" +
1442
+ '\n' +
1443
+ 'const AuthContext = createContext(null);\n' +
1444
+ '\n' +
1445
+ 'export function useAuth() {\n' +
1446
+ ' const ctx = useContext(AuthContext);\n' +
1447
+ " if (!ctx) throw new Error('useAuth must be used within AuthProvider');\n" +
1448
+ ' return ctx;\n' +
1449
+ '}\n' +
1450
+ '\n' +
1451
+ 'export function AuthProvider({ children }) {\n' +
1452
+ ' const [user, setUser] = useState(null);\n' +
1453
+ ' const [isLoading, setIsLoading] = useState(true);\n' +
1454
+ ' const navigate = useNavigate();\n' +
1455
+ '\n' +
1456
+ ' const fetchUser = useCallback(async (accessToken) => {\n' +
1457
+ ' const baseUrl = import.meta.env.VITE_OAUTH_BASE_URL;\n' +
1458
+ ' const res = await fetch(`${baseUrl}/userinfo`, {\n' +
1459
+ ' headers: { Authorization: `Bearer ${accessToken}` },\n' +
1460
+ ' });\n' +
1461
+ ' if (res.ok) {\n' +
1462
+ ' setUser(await res.json());\n' +
1463
+ ' } else {\n' +
1464
+ ' clearTokens();\n' +
1465
+ ' setUser(null);\n' +
1466
+ ' }\n' +
1467
+ ' }, []);\n' +
1468
+ '\n' +
1469
+ ' useEffect(() => {\n' +
1470
+ ' const tokens = getStoredTokens();\n' +
1471
+ ' if (tokens?.access_token) {\n' +
1472
+ ' fetchUser(tokens.access_token).finally(() => setIsLoading(false));\n' +
1473
+ ' } else {\n' +
1474
+ ' setIsLoading(false);\n' +
1475
+ ' }\n' +
1476
+ ' }, [fetchUser]);\n' +
1477
+ '\n' +
1478
+ ' useEffect(() => {\n' +
1479
+ ' const tokens = getStoredTokens();\n' +
1480
+ ' if (!tokens?.expires_in || !tokens?.refresh_token) return;\n' +
1481
+ ' const refreshMs = (tokens.expires_in - 60) * 1000;\n' +
1482
+ ' if (refreshMs <= 0) return;\n' +
1483
+ ' const timer = setTimeout(async () => {\n' +
1484
+ ' try {\n' +
1485
+ ' const newTokens = await refreshToken(tokens.refresh_token);\n' +
1486
+ ' await fetchUser(newTokens.access_token);\n' +
1487
+ ' } catch {\n' +
1488
+ ' clearTokens();\n' +
1489
+ ' setUser(null);\n' +
1490
+ ' }\n' +
1491
+ ' }, refreshMs);\n' +
1492
+ ' return () => clearTimeout(timer);\n' +
1493
+ ' }, [user, fetchUser]);\n' +
1494
+ '\n' +
1495
+ ' const login = () => startLogin();\n' +
1496
+ " const logout = () => { clearTokens(); setUser(null); navigate('/login'); };\n" +
1497
+ ' const isAuthenticated = !!user;\n' +
1498
+ '\n' +
1499
+ ' return (\n' +
1500
+ ' <AuthContext.Provider value={{ user, login, logout, isAuthenticated, isLoading }}>\n' +
1501
+ ' {children}\n' +
1502
+ ' </AuthContext.Provider>\n' +
1503
+ ' );\n' +
1504
+ '}\n';
1505
+ }
1506
+
1507
+ function generateLoginPage() {
1508
+ return `import { useAuth } from '../auth/AuthProvider.jsx';
1509
+
1510
+ export default function Login() {
1511
+ const { login } = useAuth();
1512
+ return (
1513
+ <div className="login-page">
1514
+ <div className="login-card">
1515
+ <h1>MyVillageOS</h1>
1516
+ <p>Sign in to access your application</p>
1517
+ <button className="login-btn" onClick={login}>Sign in with MyVillageOS</button>
1518
+ </div>
1519
+ </div>
1520
+ );
1521
+ }
1522
+ `;
1523
+ }
1524
+
1525
+ function generateCallbackPage() {
1526
+ return `import { useEffect, useState } from 'react';
1527
+ import { useNavigate, useSearchParams } from 'react-router-dom';
1528
+ import { handleCallback } from '../auth/oauth.js';
1529
+
1530
+ export default function Callback() {
1531
+ const [searchParams] = useSearchParams();
1532
+ const navigate = useNavigate();
1533
+ const [error, setError] = useState(null);
1534
+
1535
+ useEffect(() => {
1536
+ const code = searchParams.get('code');
1537
+ const state = searchParams.get('state');
1538
+ if (!code || !state) { setError('Missing authorization code or state'); return; }
1539
+ handleCallback(code, state)
1540
+ .then(() => navigate('/', { replace: true }))
1541
+ .catch((err) => setError(err.message));
1542
+ }, [searchParams, navigate]);
1543
+
1544
+ if (error) {
1545
+ return (
1546
+ <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', flexDirection: 'column' }}>
1547
+ <h2>Authentication Error</h2>
1548
+ <p>{error}</p>
1549
+ <a href="/login">Try again</a>
1550
+ </div>
1551
+ );
1552
+ }
1553
+
1554
+ return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>Completing sign in...</div>;
1555
+ }
1556
+ `;
1557
+ }
1558
+
1559
+ // ─── Page Generators ────────────────────────────────────────────────────────
1560
+
1561
+ function generateDashboardPage(includeMcp) {
1562
+ return `import AgentChat from '../components/AgentChat.jsx';
1563
+
1564
+ export default function Dashboard() {
1565
+ return (
1566
+ <div>
1567
+ <div className="page-header">
1568
+ <h1>Dashboard</h1>
1569
+ <p>Your application overview</p>
1570
+ </div>
1571
+ <div className="stat-cards">
1572
+ <div className="stat-card">
1573
+ <h3>Communities</h3>
1574
+ <div className="value">--</div>
1575
+ </div>
1576
+ <div className="stat-card">
1577
+ <h3>Posts</h3>
1578
+ <div className="value">--</div>
1579
+ </div>
1580
+ <div className="stat-card">
1581
+ <h3>Members</h3>
1582
+ <div className="value">--</div>
1583
+ </div>
1584
+ </div>
1585
+ <AgentChat />
1586
+ </div>
1587
+ );
1588
+ }
1589
+ `;
1590
+ }
1591
+
1592
+ function generateSettingsPage() {
1593
+ return `export default function Settings() {
1594
+ return (
1595
+ <div>
1596
+ <div className="page-header">
1597
+ <h1>Settings</h1>
1598
+ <p>Configure your application</p>
1599
+ </div>
1600
+ <div style={{ background: 'white', borderRadius: 8, padding: 24 }}>
1601
+ <p>Settings page - customize as needed.</p>
1602
+ </div>
1603
+ </div>
1604
+ );
1605
+ }
1606
+ `;
1607
+ }
1608
+
1609
+ function generateUsersPage() {
1610
+ return `export default function Users() {
1611
+ return (
1612
+ <div>
1613
+ <div className="page-header">
1614
+ <h1>Users</h1>
1615
+ <p>Manage users and permissions</p>
1616
+ </div>
1617
+ <div style={{ background: 'white', borderRadius: 8, padding: 24 }}>
1618
+ <p>User management - customize as needed.</p>
1619
+ </div>
1620
+ </div>
1621
+ );
1622
+ }
1623
+ `;
1624
+ }
1625
+
1626
+ function generateNotificationsPage() {
1627
+ return `export default function Notifications() {
1628
+ return (
1629
+ <div>
1630
+ <div className="page-header">
1631
+ <h1>Notifications</h1>
1632
+ <p>Stay updated on activity</p>
1633
+ </div>
1634
+ <div style={{ background: 'white', borderRadius: 8, padding: 24 }}>
1635
+ <p>No new notifications.</p>
1636
+ </div>
1637
+ </div>
1638
+ );
1639
+ }
1640
+ `;
1641
+ }
1642
+
1643
+ function generateNotFoundPage() {
1644
+ return `export default function NotFound() {
1645
+ return (
1646
+ <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '50vh', flexDirection: 'column' }}>
1647
+ <h1>404</h1>
1648
+ <p>Page not found</p>
1649
+ <a href="/">Go home</a>
1650
+ </div>
1651
+ );
1652
+ }
1653
+ `;
1654
+ }