seo-intel 1.0.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.
Files changed (46) hide show
  1. package/.env.example +41 -0
  2. package/LICENSE +75 -0
  3. package/README.md +243 -0
  4. package/Start SEO Intel.bat +9 -0
  5. package/Start SEO Intel.command +8 -0
  6. package/cli.js +3727 -0
  7. package/config/example.json +29 -0
  8. package/config/setup-wizard.js +522 -0
  9. package/crawler/index.js +566 -0
  10. package/crawler/robots.js +103 -0
  11. package/crawler/sanitize.js +124 -0
  12. package/crawler/schema-parser.js +168 -0
  13. package/crawler/sitemap.js +103 -0
  14. package/crawler/stealth.js +393 -0
  15. package/crawler/subdomain-discovery.js +341 -0
  16. package/db/db.js +213 -0
  17. package/db/schema.sql +120 -0
  18. package/exports/competitive.js +186 -0
  19. package/exports/heuristics.js +67 -0
  20. package/exports/queries.js +197 -0
  21. package/exports/suggestive.js +230 -0
  22. package/exports/technical.js +180 -0
  23. package/exports/templates.js +77 -0
  24. package/lib/gate.js +204 -0
  25. package/lib/license.js +369 -0
  26. package/lib/oauth.js +432 -0
  27. package/lib/updater.js +324 -0
  28. package/package.json +68 -0
  29. package/reports/generate-html.js +6194 -0
  30. package/reports/generate-site-graph.js +949 -0
  31. package/reports/gsc-loader.js +190 -0
  32. package/scheduler.js +142 -0
  33. package/seo-audit.js +619 -0
  34. package/seo-intel.png +0 -0
  35. package/server.js +602 -0
  36. package/setup/ROADMAP.md +109 -0
  37. package/setup/checks.js +483 -0
  38. package/setup/config-builder.js +227 -0
  39. package/setup/engine.js +65 -0
  40. package/setup/installers.js +197 -0
  41. package/setup/models.js +328 -0
  42. package/setup/openclaw-bridge.js +329 -0
  43. package/setup/validator.js +395 -0
  44. package/setup/web-routes.js +688 -0
  45. package/setup/wizard.html +2920 -0
  46. package/start-seo-intel.sh +8 -0
@@ -0,0 +1,688 @@
1
+ /**
2
+ * SEO Intel — Web Setup Routes
3
+ *
4
+ * HTTP API endpoints for the web-based setup wizard.
5
+ * Uses raw http (no Express) to match the existing server.js pattern.
6
+ * Long-running operations (install, validate) use SSE for streaming.
7
+ */
8
+
9
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync } from 'fs';
10
+ import { join, dirname } from 'path';
11
+ import { fileURLToPath } from 'url';
12
+
13
+ import {
14
+ fullSystemCheck,
15
+ checkGscData,
16
+ getModelRecommendations,
17
+ EXTRACTION_MODELS,
18
+ ANALYSIS_MODELS,
19
+ installNpmDeps,
20
+ installPlaywright,
21
+ pullOllamaModel,
22
+ createEnvFile,
23
+ runFullValidation,
24
+ buildProjectConfig,
25
+ writeProjectConfig,
26
+ updateEnvForSetup,
27
+ writeEnvKey,
28
+ validateConfig,
29
+ } from './engine.js';
30
+
31
+ import { getCurrentVersion, getUpdateInfo, checkForUpdates } from '../lib/updater.js';
32
+
33
+ const __dirname = dirname(fileURLToPath(import.meta.url));
34
+ const ROOT = join(__dirname, '..');
35
+ const WIZARD_HTML = join(__dirname, 'wizard.html');
36
+
37
+ // ── CORS / JSON helpers ─────────────────────────────────────────────────────
38
+
39
+ function jsonResponse(res, data, status = 200) {
40
+ res.writeHead(status, {
41
+ 'Content-Type': 'application/json',
42
+ 'Access-Control-Allow-Origin': '*',
43
+ });
44
+ res.end(JSON.stringify(data));
45
+ }
46
+
47
+ function sseHeaders(res) {
48
+ res.writeHead(200, {
49
+ 'Content-Type': 'text/event-stream',
50
+ 'Cache-Control': 'no-cache',
51
+ 'Connection': 'keep-alive',
52
+ 'Access-Control-Allow-Origin': '*',
53
+ });
54
+ }
55
+
56
+ function sseWrite(res, data) {
57
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
58
+ }
59
+
60
+ async function readBody(req) {
61
+ return new Promise((resolve, reject) => {
62
+ let body = '';
63
+ req.on('data', chunk => body += chunk);
64
+ req.on('end', () => {
65
+ try {
66
+ resolve(body ? JSON.parse(body) : {});
67
+ } catch (e) {
68
+ reject(new Error('Invalid JSON body'));
69
+ }
70
+ });
71
+ req.on('error', reject);
72
+ });
73
+ }
74
+
75
+ // ── Route Handler ───────────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Handle setup-related HTTP requests.
79
+ * Returns true if the request was handled, false to pass through.
80
+ *
81
+ * @param {http.IncomingMessage} req
82
+ * @param {http.ServerResponse} res
83
+ * @param {URL} url
84
+ * @returns {boolean}
85
+ */
86
+ export function handleSetupRequest(req, res, url) {
87
+ const path = url.pathname;
88
+ const method = req.method;
89
+
90
+ // Handle CORS preflight
91
+ if (method === 'OPTIONS' && path.startsWith('/api/setup/')) {
92
+ res.writeHead(204, {
93
+ 'Access-Control-Allow-Origin': '*',
94
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
95
+ 'Access-Control-Allow-Headers': 'Content-Type',
96
+ });
97
+ res.end();
98
+ return true;
99
+ }
100
+
101
+ // GET /setup — serve wizard HTML
102
+ if ((path === '/setup' || path === '/setup/') && method === 'GET') {
103
+ serveWizardHtml(res);
104
+ return true;
105
+ }
106
+
107
+ // GET /api/setup/status — full system check
108
+ if (path === '/api/setup/status' && method === 'GET') {
109
+ handleStatus(req, res);
110
+ return true;
111
+ }
112
+
113
+ // GET /api/setup/models — model recommendations
114
+ if (path === '/api/setup/models' && method === 'GET') {
115
+ handleModels(req, res);
116
+ return true;
117
+ }
118
+
119
+ // POST /api/setup/install — install dependencies (SSE)
120
+ if (path === '/api/setup/install' && method === 'POST') {
121
+ handleInstall(req, res);
122
+ return true;
123
+ }
124
+
125
+ // POST /api/setup/env — update .env keys
126
+ if (path === '/api/setup/env' && method === 'POST') {
127
+ handleEnv(req, res);
128
+ return true;
129
+ }
130
+
131
+ // POST /api/setup/config — create project config
132
+ if (path === '/api/setup/config' && method === 'POST') {
133
+ handleConfig(req, res);
134
+ return true;
135
+ }
136
+
137
+ // POST /api/setup/test-pipeline — run validation (SSE)
138
+ if (path === '/api/setup/test-pipeline' && method === 'POST') {
139
+ handleTestPipeline(req, res);
140
+ return true;
141
+ }
142
+
143
+ // GET /api/setup/gsc — check GSC data status
144
+ if (path === '/api/setup/gsc' && method === 'GET') {
145
+ handleGscStatus(req, res, url);
146
+ return true;
147
+ }
148
+
149
+ // POST /api/setup/gsc/upload — upload GSC CSV files
150
+ if (path === '/api/setup/gsc/upload' && method === 'POST') {
151
+ handleGscUpload(req, res);
152
+ return true;
153
+ }
154
+
155
+ // GET /api/setup/version — current version + update info
156
+ if (path === '/api/setup/version' && method === 'GET') {
157
+ handleVersion(req, res);
158
+ return true;
159
+ }
160
+
161
+ // GET /api/setup/projects — list all projects
162
+ if (path === '/api/setup/projects' && method === 'GET') {
163
+ handleListProjects(res);
164
+ return true;
165
+ }
166
+
167
+ // GET /api/setup/projects/:project — get project config
168
+ if (path.match(/^\/api\/setup\/projects\/[^/]+$/) && method === 'GET') {
169
+ const project = path.split('/').pop();
170
+ handleGetProject(res, project);
171
+ return true;
172
+ }
173
+
174
+ // PATCH /api/setup/projects/:project/competitors — add/remove competitors
175
+ if (path.match(/^\/api\/setup\/projects\/[^/]+\/competitors$/) && method === 'PATCH') {
176
+ const project = path.split('/')[4];
177
+ handleUpdateCompetitors(req, res, project);
178
+ return true;
179
+ }
180
+
181
+ // GET /api/setup/auth/status — all OAuth connection statuses
182
+ if (path === '/api/setup/auth/status' && method === 'GET') {
183
+ handleAuthStatus(res);
184
+ return true;
185
+ }
186
+
187
+ // GET /api/setup/auth/:provider/url — get OAuth authorization URL
188
+ if (path.match(/^\/api\/setup\/auth\/[^/]+\/url$/) && method === 'GET') {
189
+ const provider = path.split('/')[4];
190
+ handleAuthUrl(res, provider);
191
+ return true;
192
+ }
193
+
194
+ // POST /api/setup/auth/:provider/callback — exchange OAuth code for tokens
195
+ if (path.match(/^\/api\/setup\/auth\/[^/]+\/callback$/) && method === 'POST') {
196
+ const provider = path.split('/')[4];
197
+ handleAuthCallback(req, res, provider);
198
+ return true;
199
+ }
200
+
201
+ // DELETE /api/setup/auth/:provider — disconnect provider
202
+ if (path.match(/^\/api\/setup\/auth\/[^/]+$/) && method === 'DELETE') {
203
+ const provider = path.split('/')[4];
204
+ handleAuthDisconnect(res, provider);
205
+ return true;
206
+ }
207
+
208
+ // POST /api/setup/agent/chat — agent-powered setup chat
209
+ if (path === '/api/setup/agent/chat' && method === 'POST') {
210
+ handleAgentChat(req, res);
211
+ return true;
212
+ }
213
+
214
+ return false;
215
+ }
216
+
217
+ // ── Route Implementations ───────────────────────────────────────────────────
218
+
219
+ function serveWizardHtml(res) {
220
+ if (!existsSync(WIZARD_HTML)) {
221
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
222
+ res.end('Setup wizard not found');
223
+ return;
224
+ }
225
+ const html = readFileSync(WIZARD_HTML, 'utf8');
226
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
227
+ res.end(html);
228
+ }
229
+
230
+ async function handleStatus(req, res) {
231
+ try {
232
+ const status = await fullSystemCheck();
233
+ jsonResponse(res, status);
234
+ } catch (err) {
235
+ jsonResponse(res, { error: err.message }, 500);
236
+ }
237
+ }
238
+
239
+ async function handleModels(req, res) {
240
+ try {
241
+ const status = await fullSystemCheck();
242
+ const models = getModelRecommendations(
243
+ status.ollama.models,
244
+ status.env.keys,
245
+ status.vram.vramMB
246
+ );
247
+ jsonResponse(res, {
248
+ ...models,
249
+ gpu: status.vram,
250
+ ollama: status.ollama,
251
+ });
252
+ } catch (err) {
253
+ jsonResponse(res, { error: err.message }, 500);
254
+ }
255
+ }
256
+
257
+ async function handleInstall(req, res) {
258
+ try {
259
+ const body = await readBody(req);
260
+ const { action, model, host } = body;
261
+
262
+ sseHeaders(res);
263
+
264
+ let generator;
265
+ switch (action) {
266
+ case 'npm':
267
+ generator = installNpmDeps();
268
+ break;
269
+ case 'playwright':
270
+ generator = installPlaywright();
271
+ break;
272
+ case 'ollama-pull':
273
+ if (!model) {
274
+ sseWrite(res, { phase: 'error', status: 'error', message: 'Missing model parameter' });
275
+ res.end();
276
+ return;
277
+ }
278
+ generator = pullOllamaModel(model, host || 'http://localhost:11434');
279
+ break;
280
+ case 'env':
281
+ generator = createEnvFile();
282
+ break;
283
+ default:
284
+ sseWrite(res, { phase: 'error', status: 'error', message: `Unknown action: ${action}` });
285
+ res.end();
286
+ return;
287
+ }
288
+
289
+ let hadError = false;
290
+ for await (const ev of generator) {
291
+ if (ev.status === 'error') hadError = true;
292
+ sseWrite(res, ev);
293
+ }
294
+
295
+ if (hadError) {
296
+ sseWrite(res, { phase: 'complete', status: 'error', message: 'Installation finished with errors' });
297
+ } else {
298
+ sseWrite(res, { phase: 'complete', status: 'done', message: 'Installation complete' });
299
+ }
300
+ res.end();
301
+ } catch (err) {
302
+ try {
303
+ sseWrite(res, { phase: 'error', status: 'error', message: err.message });
304
+ res.end();
305
+ } catch {
306
+ // Response already ended
307
+ }
308
+ }
309
+ }
310
+
311
+ async function handleEnv(req, res) {
312
+ try {
313
+ const body = await readBody(req);
314
+ const { keys } = body;
315
+
316
+ if (!keys || typeof keys !== 'object') {
317
+ jsonResponse(res, { error: 'Missing keys object' }, 400);
318
+ return;
319
+ }
320
+
321
+ const result = updateEnvForSetup(keys);
322
+ jsonResponse(res, { success: true, path: result.path });
323
+ } catch (err) {
324
+ jsonResponse(res, { error: err.message }, 500);
325
+ }
326
+ }
327
+
328
+ async function handleConfig(req, res) {
329
+ try {
330
+ const body = await readBody(req);
331
+ const config = buildProjectConfig(body);
332
+ const validation = validateConfig(config);
333
+
334
+ if (!validation.valid) {
335
+ jsonResponse(res, { success: false, errors: validation.errors }, 400);
336
+ return;
337
+ }
338
+
339
+ const result = writeProjectConfig(config);
340
+ jsonResponse(res, {
341
+ success: true,
342
+ path: result.path,
343
+ overwritten: result.overwritten,
344
+ config,
345
+ });
346
+ } catch (err) {
347
+ jsonResponse(res, { error: err.message }, 500);
348
+ }
349
+ }
350
+
351
+ async function handleTestPipeline(req, res) {
352
+ try {
353
+ const body = await readBody(req);
354
+ sseHeaders(res);
355
+
356
+ for await (const step of runFullValidation(body)) {
357
+ sseWrite(res, step);
358
+ }
359
+
360
+ res.end();
361
+ } catch (err) {
362
+ try {
363
+ sseWrite(res, { step: 'error', status: 'fail', detail: err.message });
364
+ res.end();
365
+ } catch {
366
+ // Response already ended
367
+ }
368
+ }
369
+ }
370
+
371
+ // ── GSC Data Handlers ──────────────────────────────────────────────────────
372
+
373
+ async function handleGscStatus(req, res, url) {
374
+ try {
375
+ const project = url.searchParams.get('project') || '';
376
+ const gsc = checkGscData(project);
377
+ jsonResponse(res, gsc);
378
+ } catch (err) {
379
+ jsonResponse(res, { error: err.message }, 500);
380
+ }
381
+ }
382
+
383
+ async function handleGscUpload(req, res) {
384
+ try {
385
+ // Read multipart form data (simplified — expects JSON with base64 files)
386
+ const body = await readBody(req);
387
+ const { project, files } = body;
388
+
389
+ if (!project || !files || !Array.isArray(files) || files.length === 0) {
390
+ jsonResponse(res, { error: 'Missing project name or files array' }, 400);
391
+ return;
392
+ }
393
+
394
+ // Create GSC directory: gsc/<project>/
395
+ const gscDir = join(ROOT, 'gsc');
396
+ const projectDir = join(gscDir, project);
397
+
398
+ if (!existsSync(gscDir)) mkdirSync(gscDir, { recursive: true });
399
+ if (!existsSync(projectDir)) mkdirSync(projectDir, { recursive: true });
400
+
401
+ const saved = [];
402
+ for (const file of files) {
403
+ if (!file.name || !file.content) continue;
404
+
405
+ // Decode base64 content and write CSV
406
+ const content = Buffer.from(file.content, 'base64').toString('utf8');
407
+ const filePath = join(projectDir, file.name);
408
+ writeFileSync(filePath, content, 'utf8');
409
+ saved.push(file.name);
410
+ }
411
+
412
+ // Re-check GSC data status
413
+ const gsc = checkGscData(project);
414
+
415
+ jsonResponse(res, {
416
+ success: true,
417
+ saved,
418
+ folder: project,
419
+ gsc,
420
+ });
421
+ } catch (err) {
422
+ jsonResponse(res, { error: err.message }, 500);
423
+ }
424
+ }
425
+
426
+ // ── Version / Update Handler ──────────────────────────────────────────────
427
+
428
+ async function handleVersion(req, res) {
429
+ try {
430
+ checkForUpdates(); // trigger background check if not already
431
+ const info = await getUpdateInfo();
432
+ jsonResponse(res, info);
433
+ } catch (err) {
434
+ jsonResponse(res, {
435
+ current: getCurrentVersion(),
436
+ hasUpdate: false,
437
+ error: err.message,
438
+ });
439
+ }
440
+ }
441
+
442
+ // ── Project / Competitors Handlers ────────────────────────────────────────
443
+
444
+ import { domainFromUrl } from './config-builder.js';
445
+
446
+ function loadProjectConfig(project) {
447
+ const configPath = join(ROOT, 'config', `${project}.json`);
448
+ if (!existsSync(configPath)) return null;
449
+ return JSON.parse(readFileSync(configPath, 'utf8'));
450
+ }
451
+
452
+ function saveProjectConfig(project, config) {
453
+ const configPath = join(ROOT, 'config', `${project}.json`);
454
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
455
+ }
456
+
457
+ function handleListProjects(res) {
458
+ try {
459
+ const configDir = join(ROOT, 'config');
460
+ const configs = readdirSync(configDir)
461
+ .filter(f => f.endsWith('.json') && !f.startsWith('_'))
462
+ .map(f => {
463
+ try {
464
+ const config = JSON.parse(readFileSync(join(configDir, f), 'utf8'));
465
+ return {
466
+ project: config.project,
467
+ target: config.target?.domain,
468
+ competitors: (config.competitors || []).map(c => c.domain),
469
+ owned: (config.owned || []).map(o => o.domain),
470
+ };
471
+ } catch { return null; }
472
+ })
473
+ .filter(Boolean);
474
+
475
+ jsonResponse(res, { projects: configs });
476
+ } catch (err) {
477
+ jsonResponse(res, { error: err.message }, 500);
478
+ }
479
+ }
480
+
481
+ function handleGetProject(res, project) {
482
+ try {
483
+ const config = loadProjectConfig(project);
484
+ if (!config) {
485
+ jsonResponse(res, { error: `Project '${project}' not found` }, 404);
486
+ return;
487
+ }
488
+ jsonResponse(res, config);
489
+ } catch (err) {
490
+ jsonResponse(res, { error: err.message }, 500);
491
+ }
492
+ }
493
+
494
+ async function handleUpdateCompetitors(req, res, project) {
495
+ try {
496
+ const config = loadProjectConfig(project);
497
+ if (!config) {
498
+ jsonResponse(res, { error: `Project '${project}' not found` }, 404);
499
+ return;
500
+ }
501
+
502
+ const body = await readBody(req);
503
+ const changes = [];
504
+
505
+ // Add competitors
506
+ if (body.add && Array.isArray(body.add)) {
507
+ for (const entry of body.add) {
508
+ const domain = domainFromUrl(entry);
509
+ const url = entry.startsWith('http') ? entry : `https://${entry}`;
510
+ if (!config.competitors.some(c => c.domain === domain)) {
511
+ config.competitors.push({ url, domain, role: 'competitor' });
512
+ changes.push({ action: 'added', type: 'competitor', domain });
513
+ }
514
+ }
515
+ }
516
+
517
+ // Remove competitors
518
+ if (body.remove && Array.isArray(body.remove)) {
519
+ for (const entry of body.remove) {
520
+ const domain = domainFromUrl(entry);
521
+ const before = config.competitors.length;
522
+ config.competitors = config.competitors.filter(c => c.domain !== domain);
523
+ if (config.competitors.length < before) {
524
+ changes.push({ action: 'removed', type: 'competitor', domain });
525
+ }
526
+ }
527
+ }
528
+
529
+ // Add owned
530
+ if (body.addOwned && Array.isArray(body.addOwned)) {
531
+ if (!config.owned) config.owned = [];
532
+ for (const entry of body.addOwned) {
533
+ const domain = domainFromUrl(entry);
534
+ const url = entry.startsWith('http') ? entry : `https://${entry}`;
535
+ if (!config.owned.some(o => o.domain === domain)) {
536
+ config.owned.push({ url, domain, role: 'owned' });
537
+ changes.push({ action: 'added', type: 'owned', domain });
538
+ }
539
+ }
540
+ }
541
+
542
+ // Remove owned
543
+ if (body.removeOwned && Array.isArray(body.removeOwned)) {
544
+ if (!config.owned) config.owned = [];
545
+ for (const entry of body.removeOwned) {
546
+ const domain = domainFromUrl(entry);
547
+ const before = config.owned.length;
548
+ config.owned = config.owned.filter(o => o.domain !== domain);
549
+ if (config.owned.length < before) {
550
+ changes.push({ action: 'removed', type: 'owned', domain });
551
+ }
552
+ }
553
+ }
554
+
555
+ if (changes.length > 0) {
556
+ saveProjectConfig(project, config);
557
+ }
558
+
559
+ jsonResponse(res, {
560
+ success: true,
561
+ changes,
562
+ competitors: config.competitors.map(c => c.domain),
563
+ owned: (config.owned || []).map(o => o.domain),
564
+ });
565
+ } catch (err) {
566
+ jsonResponse(res, { error: err.message }, 500);
567
+ }
568
+ }
569
+
570
+ // ── OAuth Handlers ────────────────────────────────────────────────────────
571
+
572
+ import {
573
+ getAllConnectionStatus,
574
+ getAuthUrl,
575
+ getProviderRequirements,
576
+ clearTokens,
577
+ } from '../lib/oauth.js';
578
+
579
+ function handleAuthStatus(res) {
580
+ try {
581
+ const statuses = getAllConnectionStatus();
582
+ const requirements = getProviderRequirements();
583
+
584
+ // Also include API key status
585
+ const envPath = join(ROOT, '.env');
586
+ const apiKeys = {};
587
+ if (existsSync(envPath)) {
588
+ const env = readFileSync(envPath, 'utf8');
589
+ for (const key of ['GEMINI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'DEEPSEEK_API_KEY']) {
590
+ const match = env.match(new RegExp(`^${key}=(.+)$`, 'm'));
591
+ apiKeys[key] = !!(match && match[1]?.trim());
592
+ }
593
+ // Check OAuth credentials too
594
+ for (const key of ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET']) {
595
+ const match = env.match(new RegExp(`^${key}=(.+)$`, 'm'));
596
+ apiKeys[key] = !!(match && match[1]?.trim());
597
+ }
598
+ }
599
+
600
+ jsonResponse(res, {
601
+ oauth: statuses,
602
+ providers: requirements,
603
+ apiKeys,
604
+ });
605
+ } catch (err) {
606
+ jsonResponse(res, { error: err.message }, 500);
607
+ }
608
+ }
609
+
610
+ function handleAuthUrl(res, provider) {
611
+ try {
612
+ const { url, state } = getAuthUrl(provider, { port: 9876 });
613
+ jsonResponse(res, { url, state, provider });
614
+ } catch (err) {
615
+ // Likely missing credentials
616
+ jsonResponse(res, {
617
+ error: err.message,
618
+ needsSetup: true,
619
+ provider,
620
+ setupUrl: provider === 'google' ? 'https://console.cloud.google.com/apis/credentials' : null,
621
+ envVars: provider === 'google' ? ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'] : [],
622
+ }, 400);
623
+ }
624
+ }
625
+
626
+ async function handleAuthCallback(req, res, provider) {
627
+ try {
628
+ const body = await readBody(req);
629
+ const { code, redirectUri } = body;
630
+
631
+ if (!code) {
632
+ jsonResponse(res, { error: 'Missing authorization code' }, 400);
633
+ return;
634
+ }
635
+
636
+ // Dynamically import the exchange function
637
+ const { default: _, ...oauth } = await import('../lib/oauth.js');
638
+
639
+ // We need to call exchangeCode — but it's not exported directly.
640
+ // Instead, the web wizard will use the popup window flow:
641
+ // 1. Open auth URL in popup
642
+ // 2. Google redirects to localhost:9876/oauth/callback
643
+ // 3. The callback server (started by startOAuthFlow) handles the exchange
644
+ //
645
+ // For web-initiated flows, we provide the auth URL and let the CLI
646
+ // callback server handle token exchange.
647
+
648
+ jsonResponse(res, {
649
+ success: true,
650
+ message: 'Use the auth URL flow — tokens are exchanged via the local callback server',
651
+ });
652
+ } catch (err) {
653
+ jsonResponse(res, { error: err.message }, 500);
654
+ }
655
+ }
656
+
657
+ function handleAuthDisconnect(res, provider) {
658
+ try {
659
+ clearTokens(provider);
660
+ jsonResponse(res, { success: true, provider, disconnected: true });
661
+ } catch (err) {
662
+ jsonResponse(res, { error: err.message }, 500);
663
+ }
664
+ }
665
+
666
+ // ── Agent Chat Handler ────────────────────────────────────────────────────
667
+
668
+ async function handleAgentChat(req, res) {
669
+ try {
670
+ const { isGatewayReady, handleAgentChat: agentChat } = await import('./openclaw-bridge.js');
671
+
672
+ const ready = await isGatewayReady();
673
+ if (!ready) {
674
+ jsonResponse(res, {
675
+ error: 'OpenClaw gateway not running',
676
+ available: false,
677
+ hint: 'Start OpenClaw with: openclaw gateway',
678
+ }, 503);
679
+ return;
680
+ }
681
+
682
+ const body = await readBody(req);
683
+ const result = await agentChat(body);
684
+ jsonResponse(res, { ...result, available: true });
685
+ } catch (err) {
686
+ jsonResponse(res, { error: err.message, available: false }, 500);
687
+ }
688
+ }