seo-intel 1.4.2 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.3 (2026-04-07)
4
+
5
+ ### Dashboard: Export & Download
6
+ - Per-card download buttons (Markdown, JSON, CSV) on every dashboard card
7
+ - "Download All Reports (ZIP)" in export sidebar
8
+ - New `/api/export/download` endpoint with section filtering
9
+
10
+ ### Improvements
11
+ - GSC data loader picks most recently modified folder (fixes stale folder selection)
12
+ - Report filenames use `YYYY-MM-DD` dates instead of Unix timestamps
13
+ - Setup wizard: multi-host Ollama support (`OLLAMA_HOSTS` env var)
14
+ - Skill file and Agent Guide updated with `watch`, `blog-draft`, and export features
15
+
16
+ ### Cleanup
17
+ - Removed deprecated agentic setup banner from wizard
18
+ - Consolidated Agent Guide into `skill/` directory
19
+
3
20
  ## 1.4.2 (2026-04-05)
4
21
 
5
22
  ### New Feature: Site Watch
package/cli.js CHANGED
@@ -742,9 +742,9 @@ program
742
742
  console.log(chalk.yellow('Sending to Gemini...\n'));
743
743
 
744
744
  // Save prompt for debugging (markdown for Obsidian/agent compatibility)
745
- const promptTs = Date.now();
745
+ const promptTs = new Date().toISOString().slice(0, 10);
746
746
  const promptPath = join(__dirname, `reports/${project}-prompt-${promptTs}.md`);
747
- const promptFrontmatter = `---\nproject: ${project}\ngenerated: ${new Date(promptTs).toISOString()}\ntype: analysis-prompt\nmodel: gemini\n---\n\n`;
747
+ const promptFrontmatter = `---\nproject: ${project}\ngenerated: ${new Date().toISOString()}\ntype: analysis-prompt\nmodel: gemini\n---\n\n`;
748
748
  writeFileSync(promptPath, promptFrontmatter + prompt, 'utf8');
749
749
  console.log(chalk.gray(`Prompt saved: ${promptPath}`));
750
750
 
@@ -764,13 +764,13 @@ program
764
764
  analysis = JSON.parse(jsonMatch[0]);
765
765
  } catch {
766
766
  console.error(chalk.red('Could not parse JSON from response. Saving raw output.'));
767
- const rawPath = join(__dirname, `reports/${project}-raw-${Date.now()}.md`);
767
+ const rawPath = join(__dirname, `reports/${project}-raw-${new Date().toISOString().slice(0, 10)}.md`);
768
768
  writeFileSync(rawPath, result, 'utf8');
769
769
  process.exit(1);
770
770
  }
771
771
 
772
772
  // Save structured analysis to file
773
- const outPath = join(__dirname, `reports/${project}-analysis-${Date.now()}.json`);
773
+ const outPath = join(__dirname, `reports/${project}-analysis-${new Date().toISOString().slice(0, 10)}.json`);
774
774
  writeFileSync(outPath, JSON.stringify(analysis, null, 2), 'utf8');
775
775
 
776
776
  // Save to DB (so HTML dashboard picks it up)
@@ -930,7 +930,7 @@ Respond ONLY with a single valid JSON object matching this exact schema. No expl
930
930
  data = JSON.parse(jsonMatch[0]);
931
931
  } catch {
932
932
  console.error(chalk.red('Could not parse JSON from Gemini response.'));
933
- const rawPath = join(__dirname, `reports/${project}-keywords-raw-${Date.now()}.md`);
933
+ const rawPath = join(__dirname, `reports/${project}-keywords-raw-${new Date().toISOString().slice(0, 10)}.md`);
934
934
  writeFileSync(rawPath, result, 'utf8');
935
935
  console.error(chalk.gray(`Raw output saved: ${rawPath}`));
936
936
  process.exit(1);
@@ -991,7 +991,7 @@ Respond ONLY with a single valid JSON object matching this exact schema. No expl
991
991
  }
992
992
 
993
993
  if (opts.save) {
994
- const outPath = join(__dirname, `reports/${project}-keywords-${Date.now()}.json`);
994
+ const outPath = join(__dirname, `reports/${project}-keywords-${new Date().toISOString().slice(0, 10)}.json`);
995
995
  writeFileSync(outPath, JSON.stringify(data, null, 2), 'utf8');
996
996
  console.log(chalk.bold.green(`✅ Report saved: ${outPath}\n`));
997
997
 
@@ -1759,7 +1759,7 @@ async function runAnalysis(project, db) {
1759
1759
  try {
1760
1760
  const jsonMatch = result.match(/\{[\s\S]*\}/);
1761
1761
  const analysis = JSON.parse(jsonMatch[0]);
1762
- const outPath = join(__dirname, `reports/${project}-analysis-${Date.now()}.json`);
1762
+ const outPath = join(__dirname, `reports/${project}-analysis-${new Date().toISOString().slice(0, 10)}.json`);
1763
1763
  writeFileSync(outPath, JSON.stringify(analysis, null, 2), 'utf8');
1764
1764
 
1765
1765
  // Save to DB
@@ -2410,7 +2410,7 @@ program
2410
2410
  report += `> Analyze this heading structure from ${page.domain}. What H2/H3 sub-topics are logically missing? What would a user expect to find that isn't covered? Be specific.\n\n---\n\n`;
2411
2411
  }
2412
2412
 
2413
- const outPath = join(__dirname, `reports/${project}-headings-audit-${Date.now()}.md`);
2413
+ const outPath = join(__dirname, `reports/${project}-headings-audit-${new Date().toISOString().slice(0, 10)}.md`);
2414
2414
  writeFileSync(outPath, report, 'utf8');
2415
2415
 
2416
2416
  console.log(chalk.bold.green(`\n✅ Full audit saved: ${outPath}`));
@@ -2672,7 +2672,7 @@ program
2672
2672
 
2673
2673
  // ── Save ──
2674
2674
  if (opts.save) {
2675
- const outPath = join(__dirname, `reports/${project}-entities-${Date.now()}.md`);
2675
+ const outPath = join(__dirname, `reports/${project}-entities-${new Date().toISOString().slice(0, 10)}.md`);
2676
2676
  writeFileSync(outPath, mdOutput, 'utf8');
2677
2677
  console.log(chalk.bold.green(` ✅ Entity map saved: ${outPath}\n`));
2678
2678
  }
@@ -3369,7 +3369,7 @@ program
3369
3369
 
3370
3370
  // ── Save ──
3371
3371
  if (opts.save) {
3372
- const outPath = join(__dirname, `reports/${project}-brief-${Date.now()}.md`);
3372
+ const outPath = join(__dirname, `reports/${project}-brief-${new Date().toISOString().slice(0, 10)}.md`);
3373
3373
  writeFileSync(outPath, mdOutput, 'utf8');
3374
3374
  console.log(chalk.bold.green(` ✅ Brief saved: ${outPath}\n`));
3375
3375
  }
@@ -3713,7 +3713,7 @@ program
3713
3713
  }
3714
3714
 
3715
3715
  if (opts.save) {
3716
- const outPath = join(__dirname, `reports/${project}-js-delta-${Date.now()}.md`);
3716
+ const outPath = join(__dirname, `reports/${project}-js-delta-${new Date().toISOString().slice(0, 10)}.md`);
3717
3717
  writeFileSync(outPath, mdOutput, 'utf8');
3718
3718
  console.log(chalk.bold.green(` ✅ Report saved: ${outPath}\n`));
3719
3719
  }
@@ -3859,7 +3859,7 @@ program
3859
3859
  }
3860
3860
 
3861
3861
  // Output
3862
- const timestamp = Date.now();
3862
+ const timestamp = new Date().toISOString().slice(0, 10);
3863
3863
  const defaultPath = join(__dirname, `reports/${project}-export-${timestamp}.${format}`);
3864
3864
  const outPath = opts.output || defaultPath;
3865
3865
 
@@ -4237,7 +4237,7 @@ program
4237
4237
  for (const p of worst) {
4238
4238
  md += `- **${p.url}** — ${p.score}/100 (${p.tier})\n`;
4239
4239
  }
4240
- const outPath = join(__dirname, `reports/${project}-aeo-${Date.now()}.md`);
4240
+ const outPath = join(__dirname, `reports/${project}-aeo-${new Date().toISOString().slice(0, 10)}.md`);
4241
4241
  writeFileSync(outPath, md, 'utf8');
4242
4242
  console.log(chalk.bold.green(` ✅ Report saved: ${outPath}\n`));
4243
4243
  }
@@ -4521,7 +4521,7 @@ program
4521
4521
  const slug = opts.topic
4522
4522
  ? opts.topic.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
4523
4523
  : 'auto';
4524
- const filename = `${project}-blog-draft-${slug}-${Date.now()}.md`;
4524
+ const filename = `${project}-blog-draft-${slug}-${new Date().toISOString().slice(0, 10)}.md`;
4525
4525
  const outPath = join(__dirname, 'reports', filename);
4526
4526
  writeFileSync(outPath, draft, 'utf8');
4527
4527
  console.log(chalk.bold.green(` ✅ Draft saved: ${outPath}`));
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Minimal ZIP archive builder — zero dependencies.
3
+ * Creates valid ZIP files (uncompressed / STORE method) from an array of entries.
4
+ * Sufficient for text-based exports (MD, JSON, CSV) where deflate adds little value.
5
+ *
6
+ * Usage:
7
+ * import { createZip } from './lib/export-zip.js';
8
+ * const buf = createZip([{ name: 'report.md', content: '# Hello' }]);
9
+ * res.end(buf);
10
+ */
11
+
12
+ /**
13
+ * @param {{ name: string, content: string | Buffer }[]} entries
14
+ * @returns {Buffer} Valid ZIP archive
15
+ */
16
+ export function createZip(entries) {
17
+ const localHeaders = [];
18
+ const centralHeaders = [];
19
+ let offset = 0;
20
+
21
+ for (const entry of entries) {
22
+ const nameBytes = Buffer.from(entry.name, 'utf8');
23
+ const data = typeof entry.content === 'string' ? Buffer.from(entry.content, 'utf8') : entry.content;
24
+ const crc = crc32(data);
25
+
26
+ // Local file header (30 + nameLen + dataLen)
27
+ const local = Buffer.alloc(30 + nameBytes.length);
28
+ local.writeUInt32LE(0x04034b50, 0); // signature
29
+ local.writeUInt16LE(20, 4); // version needed (2.0)
30
+ local.writeUInt16LE(0, 6); // flags
31
+ local.writeUInt16LE(0, 8); // compression: STORE
32
+ local.writeUInt16LE(0, 10); // mod time
33
+ local.writeUInt16LE(0, 12); // mod date
34
+ local.writeUInt32LE(crc, 14); // crc-32
35
+ local.writeUInt32LE(data.length, 18); // compressed size
36
+ local.writeUInt32LE(data.length, 22); // uncompressed size
37
+ local.writeUInt16LE(nameBytes.length, 26); // filename length
38
+ local.writeUInt16LE(0, 28); // extra field length
39
+ nameBytes.copy(local, 30);
40
+
41
+ localHeaders.push(Buffer.concat([local, data]));
42
+
43
+ // Central directory header (46 + nameLen)
44
+ const central = Buffer.alloc(46 + nameBytes.length);
45
+ central.writeUInt32LE(0x02014b50, 0); // signature
46
+ central.writeUInt16LE(20, 4); // version made by
47
+ central.writeUInt16LE(20, 6); // version needed
48
+ central.writeUInt16LE(0, 8); // flags
49
+ central.writeUInt16LE(0, 10); // compression: STORE
50
+ central.writeUInt16LE(0, 12); // mod time
51
+ central.writeUInt16LE(0, 14); // mod date
52
+ central.writeUInt32LE(crc, 16); // crc-32
53
+ central.writeUInt32LE(data.length, 20); // compressed size
54
+ central.writeUInt32LE(data.length, 24); // uncompressed size
55
+ central.writeUInt16LE(nameBytes.length, 28); // filename length
56
+ central.writeUInt16LE(0, 30); // extra field length
57
+ central.writeUInt16LE(0, 32); // comment length
58
+ central.writeUInt16LE(0, 34); // disk number start
59
+ central.writeUInt16LE(0, 36); // internal attrs
60
+ central.writeUInt32LE(0, 38); // external attrs
61
+ central.writeUInt32LE(offset, 42); // local header offset
62
+ nameBytes.copy(central, 46);
63
+
64
+ centralHeaders.push(central);
65
+ offset += local.length + data.length;
66
+ }
67
+
68
+ const centralDir = Buffer.concat(centralHeaders);
69
+ const centralDirOffset = offset;
70
+
71
+ // End of central directory record (22 bytes)
72
+ const eocd = Buffer.alloc(22);
73
+ eocd.writeUInt32LE(0x06054b50, 0); // signature
74
+ eocd.writeUInt16LE(0, 4); // disk number
75
+ eocd.writeUInt16LE(0, 6); // disk with central dir
76
+ eocd.writeUInt16LE(entries.length, 8); // entries on this disk
77
+ eocd.writeUInt16LE(entries.length, 10); // total entries
78
+ eocd.writeUInt32LE(centralDir.length, 12); // central dir size
79
+ eocd.writeUInt32LE(centralDirOffset, 16); // central dir offset
80
+ eocd.writeUInt16LE(0, 20); // comment length
81
+
82
+ return Buffer.concat([...localHeaders, centralDir, eocd]);
83
+ }
84
+
85
+ // ─── CRC-32 (IEEE 802.3) ────────────────────────────────────────────────────
86
+
87
+ const CRC_TABLE = new Uint32Array(256);
88
+ for (let n = 0; n < 256; n++) {
89
+ let c = n;
90
+ for (let k = 0; k < 8; k++) {
91
+ c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
92
+ }
93
+ CRC_TABLE[n] = c;
94
+ }
95
+
96
+ function crc32(buf) {
97
+ let crc = 0xFFFFFFFF;
98
+ for (let i = 0; i < buf.length; i++) {
99
+ crc = CRC_TABLE[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8);
100
+ }
101
+ return (crc ^ 0xFFFFFFFF) >>> 0;
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.4.2",
3
+ "version": "1.4.3",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -24,13 +24,19 @@ import { getWatchData } from '../analyses/watch/index.js';
24
24
 
25
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
26
 
27
- /**
28
- * Generate HTML dashboard from database
29
- * @param {import('node:sqlite').DatabaseSync} db - SQLite database
30
- * @param {string} project - Project name (e.g., 'mysite')
31
- * @param {object} config - Project config
32
- * @returns {string} Path to generated HTML file
33
- */
27
+ /** Build per-card export dropdown HTML */
28
+ function cardExportHtml(section, project) {
29
+ return `<div class="card-export" data-project="${project}" data-section="${section}">
30
+ <button class="card-export-btn" title="Export"><i class="fa-solid fa-download"></i></button>
31
+ <div class="card-export-dropdown">
32
+ <button data-fmt="md"><i class="fa-solid fa-file-lines"></i> Markdown</button>
33
+ <button data-fmt="json"><i class="fa-solid fa-code"></i> JSON</button>
34
+ <button data-fmt="csv"><i class="fa-solid fa-table"></i> CSV</button>
35
+ <button data-fmt="zip"><i class="fa-solid fa-file-zipper"></i> ZIP (all)</button>
36
+ </div>
37
+ </div>`;
38
+ }
39
+
34
40
  /**
35
41
  * Gather all dashboard data for a single project
36
42
  */
@@ -419,6 +425,32 @@ function buildHtmlTemplate(data, opts = {}) {
419
425
  .card.full-width {
420
426
  grid-column: 1 / -1;
421
427
  }
428
+ .card { position: relative; }
429
+ .card-export {
430
+ position: absolute; top: 10px; right: 10px; z-index: 5;
431
+ }
432
+ .card-export-btn {
433
+ background: var(--bg-elevated); border: 1px solid var(--border-subtle);
434
+ color: var(--text-muted); cursor: pointer; border-radius: var(--radius);
435
+ width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
436
+ font-size: 0.7rem; transition: all .15s;
437
+ }
438
+ .card-export-btn:hover { color: var(--accent-gold); border-color: var(--accent-gold); }
439
+ .card-export-dropdown {
440
+ display: none; position: absolute; right: 0; top: 32px;
441
+ background: var(--bg-card); border: 1px solid var(--border-card);
442
+ border-radius: var(--radius); min-width: 150px; padding: 4px 0;
443
+ box-shadow: 0 8px 24px rgba(0,0,0,.4);
444
+ }
445
+ .card-export.open .card-export-dropdown { display: block; }
446
+ .card-export-dropdown button {
447
+ display: flex; align-items: center; gap: 8px; width: 100%;
448
+ background: none; border: none; color: var(--text-secondary);
449
+ padding: 7px 14px; font-size: 0.72rem; cursor: pointer; text-align: left;
450
+ font-family: var(--font-body);
451
+ }
452
+ .card-export-dropdown button:hover { background: var(--bg-elevated); color: var(--text-primary); }
453
+ .card-export-dropdown button i { width: 14px; text-align: center; color: var(--text-muted); font-size: 0.65rem; }
422
454
 
423
455
  /* ─── Extraction Status Bar ─────────────────────────────────────────── */
424
456
  .extraction-status {
@@ -1798,7 +1830,7 @@ function buildHtmlTemplate(data, opts = {}) {
1798
1830
  </div>`}
1799
1831
 
1800
1832
  <!-- SITE WATCH -->
1801
- ${watchData?.current ? buildWatchCard(watchData, escapeHtml) : ''}
1833
+ ${watchData?.current ? buildWatchCard(watchData, escapeHtml, project) : ''}
1802
1834
 
1803
1835
  <!-- PAGE INVENTORY -->
1804
1836
  <div class="card" style="margin-bottom:16px;">
@@ -2157,6 +2189,12 @@ function buildHtmlTemplate(data, opts = {}) {
2157
2189
  </div>
2158
2190
  <button class="export-btn" data-export-cmd="aeo" data-export-project="${project}"><i class="fa-solid fa-robot"></i> AI Citability Audit</button>
2159
2191
  </div>
2192
+ <div class="export-sidebar-header" style="margin-top:12px;">
2193
+ <i class="fa-solid fa-download"></i> Download
2194
+ </div>
2195
+ <div class="export-sidebar-btns">
2196
+ <button class="export-btn download-all-btn" data-project="${project}"><i class="fa-solid fa-file-zipper"></i> Download All Reports (ZIP)</button>
2197
+ </div>
2160
2198
  <div style="position:relative;">
2161
2199
  <div id="exportSaveStatus${suffix}" style="display:none;padding:4px 10px;font-size:.6rem;color:var(--color-success);background:rgba(80,200,120,0.06);border-bottom:1px solid rgba(80,200,120,0.15);font-family:'SF Mono',monospace;">
2162
2200
  <i class="fa-solid fa-check" style="margin-right:4px;"></i><span></span>
@@ -2516,6 +2554,49 @@ function buildHtmlTemplate(data, opts = {}) {
2516
2554
  });
2517
2555
  }
2518
2556
 
2557
+ // ── Card export dropdowns (global — run once, capture phase) ──
2558
+ if (!window._cardExportBound) {
2559
+ window._cardExportBound = true;
2560
+ document.addEventListener('click', function(e) {
2561
+ var btn = e.target.closest('.card-export-btn');
2562
+ var fmtBtn = e.target.closest('[data-fmt]');
2563
+ if (btn) {
2564
+ var wrap = btn.closest('.card-export');
2565
+ if (wrap) {
2566
+ e.stopImmediatePropagation();
2567
+ var wasOpen = wrap.classList.contains('open');
2568
+ document.querySelectorAll('.card-export.open').forEach(function(el) { el.classList.remove('open'); });
2569
+ if (!wasOpen) wrap.classList.add('open');
2570
+ return;
2571
+ }
2572
+ }
2573
+ if (fmtBtn) {
2574
+ var wrap2 = fmtBtn.closest('.card-export');
2575
+ if (wrap2) {
2576
+ e.stopImmediatePropagation();
2577
+ wrap2.classList.remove('open');
2578
+ var sec = wrap2.getAttribute('data-section');
2579
+ var proj2 = wrap2.getAttribute('data-project');
2580
+ var fmt = fmtBtn.getAttribute('data-fmt');
2581
+ if (window.location.protocol.startsWith('http')) {
2582
+ window.location = '/api/export/download?project=' + encodeURIComponent(proj2) + '&section=' + encodeURIComponent(sec) + '&format=' + encodeURIComponent(fmt);
2583
+ }
2584
+ return;
2585
+ }
2586
+ }
2587
+ var dlBtn = e.target.closest('.download-all-btn');
2588
+ if (dlBtn) {
2589
+ var proj3 = dlBtn.getAttribute('data-project');
2590
+ if (window.location.protocol.startsWith('http')) {
2591
+ window.location = '/api/export/download?project=' + encodeURIComponent(proj3) + '&section=all&format=zip';
2592
+ }
2593
+ return;
2594
+ }
2595
+ // Outside click — close all open dropdowns
2596
+ document.querySelectorAll('.card-export.open').forEach(function(el) { el.classList.remove('open'); });
2597
+ }, true);
2598
+ }
2599
+
2519
2600
  // Input enter
2520
2601
  input.addEventListener('keydown', function(e) {
2521
2602
  if (e.key !== 'Enter') return;
@@ -2779,7 +2860,7 @@ function buildHtmlTemplate(data, opts = {}) {
2779
2860
  })() : ''}
2780
2861
 
2781
2862
  <!-- ═══ SITE WATCH ═══ -->
2782
- ${watchData?.current ? buildWatchCard(watchData, escapeHtml) : ''}
2863
+ ${watchData?.current ? buildWatchCard(watchData, escapeHtml, project) : ''}
2783
2864
 
2784
2865
  <div class="section-divider">
2785
2866
  <div class="section-divider-line right"></div>
@@ -2835,6 +2916,7 @@ function buildHtmlTemplate(data, opts = {}) {
2835
2916
 
2836
2917
  <!-- ═══ HEADING DEPTH FLOW ═══ -->
2837
2918
  <div class="card full-width" id="heading-flow">
2919
+ ${cardExportHtml('headings', project)}
2838
2920
  <h2><span class="icon"><i class="fa-solid fa-water"></i></span> Heading Depth Flow</h2>
2839
2921
  <canvas id="headingFlowCanvas${suffix}" width="1100" height="320"></canvas>
2840
2922
  </div>
@@ -2862,6 +2944,7 @@ function buildHtmlTemplate(data, opts = {}) {
2862
2944
  <!-- ═══ ENTITY TOPIC MAP ═══ -->
2863
2945
  ${pro && entityTopicMap.hasData ? `
2864
2946
  <div class="card full-width" id="entity-map">
2947
+ ${cardExportHtml('insights', project)}
2865
2948
  <h2><span class="icon"><i class="fa-solid fa-map"></i></span> Entity Topic Map</h2>
2866
2949
  <div class="entity-map-grid">
2867
2950
  ${Object.entries(entityTopicMap.domainEntities).map(([domain, data]) => `
@@ -2886,6 +2969,7 @@ function buildHtmlTemplate(data, opts = {}) {
2886
2969
  <!-- ═══ KEYWORD BATTLEGROUND ═══ -->
2887
2970
  ${pro ? `
2888
2971
  <div class="card full-width" id="keyword-heatmap">
2972
+ ${cardExportHtml('keywords', project)}
2889
2973
  <h2><span class="icon"><i class="fa-solid fa-shield-halved"></i></span> Keyword Battleground</h2>
2890
2974
  ${keywordHeatmap.keywords.length ? `
2891
2975
  <div class="table-wrapper">
@@ -2930,6 +3014,7 @@ function buildHtmlTemplate(data, opts = {}) {
2930
3014
 
2931
3015
  <!-- ═══ TECHNICAL SEO SCORECARD ═══ -->
2932
3016
  <div class="card full-width" id="technical-seo">
3017
+ ${cardExportHtml('technical', project)}
2933
3018
  <h2><span class="icon"><i class="fa-solid fa-gear"></i></span> Technical SEO Scorecard</h2>
2934
3019
  <div class="scorecard-grid">
2935
3020
  ${technicalScores.map(ts => {
@@ -2998,6 +3083,7 @@ function buildHtmlTemplate(data, opts = {}) {
2998
3083
  <!-- ═══ TECHNICAL SEO GAPS ═══ -->
2999
3084
  ${pro && latestAnalysis?.technical_gaps?.length ? `
3000
3085
  <div class="card full-width" id="technical-gaps">
3086
+ ${cardExportHtml('technical', project)}
3001
3087
  <h2><span class="icon"><i class="fa-solid fa-wrench"></i></span> Technical SEO Gaps</h2>
3002
3088
  <div class="analysis-table-wrap">
3003
3089
  <table class="analysis-table">
@@ -3026,6 +3112,7 @@ function buildHtmlTemplate(data, opts = {}) {
3026
3112
  <!-- ═══ QUICK WINS ═══ -->
3027
3113
  ${pro && latestAnalysis?.quick_wins?.length ? `
3028
3114
  <div class="card" id="quick-wins">
3115
+ ${cardExportHtml('insights', project)}
3029
3116
  <h2><span class="icon"><i class="fa-solid fa-bolt"></i></span> Quick Wins</h2>
3030
3117
  <div class="analysis-table-wrap">
3031
3118
  <table class="analysis-table">
@@ -3048,6 +3135,7 @@ function buildHtmlTemplate(data, opts = {}) {
3048
3135
  <!-- ═══ NEW PAGES TO CREATE ═══ -->
3049
3136
  ${pro && latestAnalysis?.new_pages?.length ? `
3050
3137
  <div class="card" id="new-pages">
3138
+ ${cardExportHtml('pages', project)}
3051
3139
  <h2><span class="icon"><i class="fa-solid fa-file-circle-plus"></i></span> New Pages to Create</h2>
3052
3140
  <div class="new-pages-grid" style="grid-template-columns: 1fr;">
3053
3141
  ${(latestAnalysis.new_pages).map(np => `
@@ -3100,6 +3188,7 @@ function buildHtmlTemplate(data, opts = {}) {
3100
3188
  <!-- ═══ CONTENT GAPS ═══ -->
3101
3189
  ${pro && latestAnalysis?.content_gaps?.length ? `
3102
3190
  <div class="card full-width" id="content-gaps">
3191
+ ${cardExportHtml('insights', project)}
3103
3192
  <h2><span class="icon"><i class="fa-solid fa-magnifying-glass-minus"></i></span> Content Gaps</h2>
3104
3193
  <div class="insights-grid">
3105
3194
  ${(latestAnalysis.content_gaps).map(gap => `
@@ -3308,6 +3397,7 @@ function buildHtmlTemplate(data, opts = {}) {
3308
3397
  <!-- ═══ SCHEMA TYPE BREAKDOWN ═══ -->
3309
3398
  ${schemaBreakdown.hasData ? `
3310
3399
  <div class="card" id="schema-breakdown">
3400
+ ${cardExportHtml('schemas', project)}
3311
3401
  <h2><span class="icon"><i class="fa-solid fa-code"></i></span> Schema Markup Breakdown</h2>
3312
3402
  <div class="table-wrapper">
3313
3403
  <table>
@@ -3334,6 +3424,7 @@ function buildHtmlTemplate(data, opts = {}) {
3334
3424
  <!-- ═══ TOP KEYWORDS ═══ -->
3335
3425
  ${pro ? `
3336
3426
  <div class="card" id="top-keywords">
3427
+ ${cardExportHtml('keywords', project)}
3337
3428
  <h2><span class="icon"><i class="fa-solid fa-key"></i></span> Top Keywords (${targetDomain})</h2>
3338
3429
  ${keywords.length ? `
3339
3430
  <div class="table-wrapper" style="max-height: 400px; overflow-y: auto;">
@@ -3369,6 +3460,7 @@ function buildHtmlTemplate(data, opts = {}) {
3369
3460
 
3370
3461
  <!-- ═══ INTERNAL LINK ANALYSIS ═══ -->
3371
3462
  <div class="card" id="internal-links">
3463
+ ${cardExportHtml('links', project)}
3372
3464
  <h2><span class="icon"><i class="fa-solid fa-link"></i></span> Internal Link Analysis</h2>
3373
3465
  <div class="stat-row">
3374
3466
  <div class="stat-box">
@@ -3407,7 +3499,7 @@ function buildHtmlTemplate(data, opts = {}) {
3407
3499
  </div>` : ''}
3408
3500
 
3409
3501
  <!-- ═══ AEO / AI CITABILITY AUDIT ═══ -->
3410
- ${pro && citabilityData?.length ? buildAeoCard(citabilityData, escapeHtml) : ''}
3502
+ ${pro && citabilityData?.length ? buildAeoCard(citabilityData, escapeHtml, project) : ''}
3411
3503
 
3412
3504
  <!-- ═══ LONG-TAIL OPPORTUNITIES ═══ -->
3413
3505
  ${pro && latestAnalysis?.long_tails?.length ? `
@@ -5232,7 +5324,7 @@ function buildMultiHtmlTemplate(allProjectData) {
5232
5324
 
5233
5325
  // ─── AEO Card Builder ────────────────────────────────────────────────────────
5234
5326
 
5235
- function buildAeoCard(citabilityData, escapeHtml) {
5327
+ function buildAeoCard(citabilityData, escapeHtml, project) {
5236
5328
  const targetScores = citabilityData.filter(s => s.role === 'target' || s.role === 'owned');
5237
5329
  const compScores = citabilityData.filter(s => s.role === 'competitor');
5238
5330
  if (!targetScores.length) return '';
@@ -5296,6 +5388,7 @@ function buildAeoCard(citabilityData, escapeHtml) {
5296
5388
 
5297
5389
  return `
5298
5390
  <div class="card full-width" id="aeo-citability">
5391
+ ${cardExportHtml('aeo', project)}
5299
5392
  <h2><span class="icon"><i class="fa-solid fa-robot"></i></span> AI Citability Audit</h2>
5300
5393
  <div class="ki-stat-bar">
5301
5394
  <div class="ki-stat"><span class="ki-stat-number" style="color:${scoreColor(avgTarget)}">${avgTarget}</span><span class="ki-stat-label">Target Avg</span></div>
@@ -6886,7 +6979,7 @@ function getGscInsights(gscData, db, project) {
6886
6979
  // SITE WATCH CARD
6887
6980
  // ═══════════════════════════════════════════════════════════════════════════
6888
6981
 
6889
- function buildWatchCard(watchData, escapeHtml) {
6982
+ function buildWatchCard(watchData, escapeHtml, project) {
6890
6983
  const { current, previous, events, trend } = watchData;
6891
6984
  const score = current.health_score ?? 0;
6892
6985
  const scoreColor = score >= 80 ? 'var(--color-success)' : score >= 60 ? 'var(--color-warning)' : 'var(--color-danger)';
@@ -6942,6 +7035,7 @@ function buildWatchCard(watchData, escapeHtml) {
6942
7035
 
6943
7036
  return `
6944
7037
  <div class="card" style="margin-bottom:16px;">
7038
+ ${cardExportHtml('watch', project)}
6945
7039
  <h2><span class="icon"><i class="fa-solid fa-eye"></i></span> Site Watch</h2>
6946
7040
 
6947
7041
  <!-- Health Score + Summary -->
package/server.js CHANGED
@@ -96,6 +96,8 @@ const MIME = {
96
96
  '.png': 'image/png',
97
97
  '.svg': 'image/svg+xml',
98
98
  '.md': 'text/markdown; charset=utf-8',
99
+ '.csv': 'text/csv; charset=utf-8',
100
+ '.zip': 'application/zip',
99
101
  };
100
102
 
101
103
  // ── Read progress with PID liveness check (mirrors cli.js) ──
@@ -449,7 +451,7 @@ async function handleRequest(req, res) {
449
451
 
450
452
  try {
451
453
  const data = JSON.parse(rawJson);
452
- const stamp = Date.now();
454
+ const stamp = new Date().toISOString().slice(0, 10);
453
455
  const baseName = `${project}-actions-${stamp}`;
454
456
  writeFileSync(join(REPORTS_DIR, `${baseName}.json`), JSON.stringify(data, null, 2), 'utf8');
455
457
  writeFileSync(join(REPORTS_DIR, `${baseName}.md`), buildActionsMarkdown(data), 'utf8');
@@ -586,6 +588,313 @@ async function handleRequest(req, res) {
586
588
  return;
587
589
  }
588
590
 
591
+ // ─── API: Universal Export Download ───
592
+ if (req.method === 'GET' && path === '/api/export/download') {
593
+ try {
594
+ const project = url.searchParams.get('project');
595
+ const section = url.searchParams.get('section') || 'all';
596
+ const format = url.searchParams.get('format') || 'json';
597
+
598
+ if (!project) { json(res, 400, { error: 'Missing project' }); return; }
599
+
600
+ const { getDb } = await import('./db/db.js');
601
+ const db = getDb(join(__dirname, 'seo-intel.db'));
602
+ const configPath = join(__dirname, 'config', `${project}.json`);
603
+ const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf8')) : null;
604
+
605
+ const dateStr = new Date().toISOString().slice(0, 10);
606
+ const { createZip } = await import('./lib/export-zip.js');
607
+
608
+ const SECTIONS = ['aeo', 'insights', 'technical', 'keywords', 'pages', 'watch', 'schemas', 'headings', 'links'];
609
+
610
+ function querySection(sec) {
611
+ switch (sec) {
612
+ case 'aeo': {
613
+ try {
614
+ return db.prepare(`
615
+ SELECT cs.score, cs.entity_authority, cs.structured_claims, cs.answer_density,
616
+ cs.qa_proximity, cs.freshness, cs.schema_coverage, cs.tier, cs.ai_intents,
617
+ p.url, p.title, p.word_count, d.domain, d.role
618
+ FROM citability_scores cs
619
+ JOIN pages p ON p.id = cs.page_id
620
+ JOIN domains d ON d.id = p.domain_id
621
+ WHERE d.project = ?
622
+ ORDER BY d.role ASC, cs.score ASC
623
+ `).all(project);
624
+ } catch { return []; }
625
+ }
626
+ case 'insights': {
627
+ try {
628
+ const rows = db.prepare(
629
+ `SELECT * FROM insights WHERE project = ? AND status = 'active' ORDER BY type, last_seen DESC`
630
+ ).all(project);
631
+ return rows.map(r => {
632
+ try { return { ...JSON.parse(r.data), _type: r.type, _id: r.id, _first_seen: r.first_seen, _last_seen: r.last_seen }; }
633
+ catch { return { _type: r.type, _id: r.id, raw: r.data }; }
634
+ });
635
+ } catch { return []; }
636
+ }
637
+ case 'technical': {
638
+ try {
639
+ return db.prepare(`
640
+ SELECT p.url, p.status_code, p.word_count, p.load_ms, p.is_indexable, p.click_depth,
641
+ t.has_canonical, t.has_og_tags, t.has_schema, t.has_robots, t.is_mobile_ok,
642
+ d.domain, d.role
643
+ FROM pages p
644
+ JOIN domains d ON d.id = p.domain_id
645
+ LEFT JOIN technical t ON t.page_id = p.id
646
+ WHERE d.project = ?
647
+ ORDER BY d.domain, p.url
648
+ `).all(project);
649
+ } catch { return []; }
650
+ }
651
+ case 'keywords': {
652
+ try {
653
+ return db.prepare(`
654
+ SELECT k.keyword, d.domain, d.role, k.location, COUNT(*) as freq
655
+ FROM keywords k
656
+ JOIN pages p ON p.id = k.page_id
657
+ JOIN domains d ON d.id = p.domain_id
658
+ WHERE d.project = ?
659
+ GROUP BY k.keyword, d.domain
660
+ ORDER BY freq DESC
661
+ `).all(project);
662
+ } catch { return []; }
663
+ }
664
+ case 'pages': {
665
+ try {
666
+ return db.prepare(`
667
+ SELECT p.url, p.status_code, p.word_count, p.load_ms, p.is_indexable, p.click_depth,
668
+ p.title, p.meta_desc, p.published_date, p.modified_date,
669
+ p.crawled_at, p.first_seen_at, d.domain, d.role
670
+ FROM pages p
671
+ JOIN domains d ON d.id = p.domain_id
672
+ WHERE d.project = ?
673
+ ORDER BY d.domain, p.url
674
+ `).all(project);
675
+ } catch { return []; }
676
+ }
677
+ case 'watch': {
678
+ try {
679
+ const snap = db.prepare('SELECT * FROM watch_snapshots WHERE project = ? ORDER BY created_at DESC LIMIT 1').get(project);
680
+ if (!snap) return [];
681
+ const events = db.prepare('SELECT * FROM watch_events WHERE snapshot_id = ? ORDER BY severity, event_type').all(snap.id);
682
+ const pages = db.prepare('SELECT * FROM watch_page_states WHERE snapshot_id = ?').all(snap.id);
683
+ return { snapshot: snap, events, pages };
684
+ } catch { return []; }
685
+ }
686
+ case 'schemas': {
687
+ try {
688
+ return db.prepare(`
689
+ SELECT d.domain, d.role, p.url, ps.schema_type, ps.name, ps.description,
690
+ ps.rating, ps.rating_count, ps.price, ps.currency, ps.author,
691
+ ps.date_published, ps.date_modified
692
+ FROM page_schemas ps
693
+ JOIN pages p ON p.id = ps.page_id
694
+ JOIN domains d ON d.id = p.domain_id
695
+ WHERE d.project = ?
696
+ ORDER BY d.domain, ps.schema_type
697
+ `).all(project);
698
+ } catch { return []; }
699
+ }
700
+ case 'headings': {
701
+ try {
702
+ return db.prepare(`
703
+ SELECT d.domain, d.role, p.url, h.level, h.text
704
+ FROM headings h
705
+ JOIN pages p ON p.id = h.page_id
706
+ JOIN domains d ON d.id = p.domain_id
707
+ WHERE d.project = ?
708
+ ORDER BY d.domain, p.url, h.level
709
+ `).all(project);
710
+ } catch { return []; }
711
+ }
712
+ case 'links': {
713
+ try {
714
+ return db.prepare(`
715
+ SELECT l.source_page_id, l.target_url, l.anchor_text, l.is_internal,
716
+ p.url as source_url, d.domain, d.role
717
+ FROM links l
718
+ JOIN pages p ON p.id = l.source_page_id
719
+ JOIN domains d ON d.id = p.domain_id
720
+ WHERE d.project = ?
721
+ ORDER BY d.domain, p.url
722
+ `).all(project);
723
+ } catch { return []; }
724
+ }
725
+ default: return [];
726
+ }
727
+ }
728
+
729
+ function toCSV(rows) {
730
+ if (!rows || (Array.isArray(rows) && !rows.length)) return '';
731
+ const arr = Array.isArray(rows) ? rows : (rows.events || rows.pages || []);
732
+ if (!arr.length) return '';
733
+ const keys = Object.keys(arr[0]);
734
+ const escape = (v) => {
735
+ if (v == null) return '';
736
+ const s = String(v);
737
+ return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
738
+ };
739
+ return [keys.join(','), ...arr.map(r => keys.map(k => escape(r[k])).join(','))].join('\n');
740
+ }
741
+
742
+ function toMarkdown(sec, data, proj) {
743
+ const date = new Date().toISOString().slice(0, 10);
744
+ const header = `# SEO Intel — ${sec.charAt(0).toUpperCase() + sec.slice(1)} Export\n\n- Project: ${proj}\n- Date: ${date}\n\n`;
745
+ if (!data || (Array.isArray(data) && !data.length)) return header + '_No data available._\n';
746
+
747
+ switch (sec) {
748
+ case 'aeo': {
749
+ const targetRows = data.filter(r => r.role === 'target' || r.role === 'owned');
750
+ const avg = targetRows.length ? Math.round(targetRows.reduce((a, r) => a + r.score, 0) / targetRows.length) : 0;
751
+ let md = header + `## Summary\n\n- Pages scored: ${data.length}\n- Target average: ${avg}/100\n\n`;
752
+ md += '## Page Scores\n\n| Score | Tier | URL | Title | Weakest Signals |\n|-------|------|-----|-------|-----------------|\n';
753
+ for (const r of data) {
754
+ const signals = ['entity_authority', 'structured_claims', 'answer_density', 'qa_proximity', 'freshness', 'schema_coverage'];
755
+ const weakest = signals.sort((a, b) => (r[a] || 0) - (r[b] || 0)).slice(0, 2).map(s => s.replace(/_/g, ' ')).join(', ');
756
+ md += `| ${r.score} | ${r.tier} | ${r.url} | ${(r.title || '').slice(0, 50)} | ${weakest} |\n`;
757
+ }
758
+ return md;
759
+ }
760
+ case 'insights': {
761
+ let md = header + `## Active Insights (${data.length})\n\n`;
762
+ const grouped = {};
763
+ for (const r of data) { (grouped[r._type] ||= []).push(r); }
764
+ for (const [type, items] of Object.entries(grouped)) {
765
+ md += `### ${type.replace(/_/g, ' ')} (${items.length})\n\n`;
766
+ for (const item of items) {
767
+ const desc = item.phrase || item.keyword || item.title || item.page || item.message || JSON.stringify(item).slice(0, 120);
768
+ md += `- ${desc}\n`;
769
+ }
770
+ md += '\n';
771
+ }
772
+ return md;
773
+ }
774
+ case 'technical': {
775
+ let md = header + '## Technical Audit\n\n| URL | Status | Words | Load ms | Canonical | OG | Schema | Robots | Mobile |\n|-----|--------|-------|---------|-----------|-----|--------|--------|--------|\n';
776
+ for (const r of data) {
777
+ md += `| ${r.url} | ${r.status_code} | ${r.word_count || 0} | ${r.load_ms || 0} | ${r.has_canonical ? 'Y' : 'N'} | ${r.has_og_tags ? 'Y' : 'N'} | ${r.has_schema ? 'Y' : 'N'} | ${r.has_robots ? 'Y' : 'N'} | ${r.is_mobile_ok ? 'Y' : 'N'} |\n`;
778
+ }
779
+ return md;
780
+ }
781
+ case 'keywords': {
782
+ let md = header + '## Keyword Matrix\n\n| Keyword | Domain | Role | Location | Frequency |\n|---------|--------|------|----------|-----------|\n';
783
+ for (const r of data.slice(0, 500)) {
784
+ md += `| ${r.keyword} | ${r.domain} | ${r.role} | ${r.location || ''} | ${r.freq} |\n`;
785
+ }
786
+ if (data.length > 500) md += `\n_...and ${data.length - 500} more rows._\n`;
787
+ return md;
788
+ }
789
+ case 'pages': {
790
+ let md = header + '## Crawled Pages\n\n| URL | Status | Words | Title | Domain | Role |\n|-----|--------|-------|-------|--------|------|\n';
791
+ for (const r of data) {
792
+ md += `| ${r.url} | ${r.status_code} | ${r.word_count || 0} | ${(r.title || '').slice(0, 50)} | ${r.domain} | ${r.role} |\n`;
793
+ }
794
+ return md;
795
+ }
796
+ case 'watch': {
797
+ const snap = data.snapshot || {};
798
+ const events = data.events || [];
799
+ let md = header + `## Site Watch Snapshot\n\n- Health score: ${snap.health_score ?? 'N/A'}\n- Pages: ${snap.total_pages || 0}\n- Errors: ${snap.errors_count || 0} | Warnings: ${snap.warnings_count || 0} | Notices: ${snap.notices_count || 0}\n\n`;
800
+ if (events.length) {
801
+ md += '## Events\n\n| Type | Severity | URL | Details |\n|------|----------|-----|---------|\n';
802
+ for (const e of events) {
803
+ md += `| ${e.event_type} | ${e.severity} | ${e.url} | ${(e.details || '').slice(0, 80)} |\n`;
804
+ }
805
+ }
806
+ return md;
807
+ }
808
+ case 'schemas': {
809
+ let md = header + '## Schema Markup\n\n| Domain | URL | Type | Name | Rating | Price |\n|--------|-----|------|------|--------|-------|\n';
810
+ for (const r of data) {
811
+ md += `| ${r.domain} | ${r.url} | ${r.schema_type} | ${(r.name || '').slice(0, 40)} | ${r.rating || ''} | ${r.price ? r.currency + r.price : ''} |\n`;
812
+ }
813
+ return md;
814
+ }
815
+ case 'headings': {
816
+ let md = header + '## Heading Structure\n\n| Domain | URL | Level | Text |\n|--------|-----|-------|------|\n';
817
+ for (const r of data.slice(0, 1000)) {
818
+ md += `| ${r.domain} | ${r.url} | H${r.level} | ${(r.text || '').slice(0, 80)} |\n`;
819
+ }
820
+ if (data.length > 1000) md += `\n_...and ${data.length - 1000} more rows._\n`;
821
+ return md;
822
+ }
823
+ case 'links': {
824
+ let md = header + '## Internal Links\n\n| Source | Target | Anchor |\n|--------|--------|--------|\n';
825
+ for (const r of data.filter(l => l.is_internal).slice(0, 1000)) {
826
+ md += `| ${r.source_url} | ${r.target_url} | ${(r.anchor_text || '').slice(0, 50)} |\n`;
827
+ }
828
+ if (data.length > 1000) md += `\n_...and more rows._\n`;
829
+ return md;
830
+ }
831
+ default: {
832
+ return header + '```json\n' + JSON.stringify(data, null, 2).slice(0, 10000) + '\n```\n';
833
+ }
834
+ }
835
+ }
836
+
837
+ // Build response based on section + format
838
+ const sections = section === 'all' ? SECTIONS : [section];
839
+ if (section !== 'all' && !SECTIONS.includes(section)) {
840
+ json(res, 400, { error: `Invalid section. Allowed: ${SECTIONS.join(', ')}, all` });
841
+ return;
842
+ }
843
+
844
+ if (format === 'zip') {
845
+ // ZIP: bundle all requested sections in all formats
846
+ const entries = [];
847
+ for (const sec of sections) {
848
+ const data = querySection(sec);
849
+ const baseName = `${project}-${sec}-${dateStr}`;
850
+ entries.push({ name: `${baseName}.json`, content: JSON.stringify(data, null, 2) });
851
+ entries.push({ name: `${baseName}.md`, content: toMarkdown(sec, data, project) });
852
+ const csv = toCSV(data);
853
+ if (csv) entries.push({ name: `${baseName}.csv`, content: csv });
854
+ }
855
+ const zipBuf = createZip(entries);
856
+ const zipName = section === 'all' ? `${project}-full-export-${dateStr}.zip` : `${project}-${section}-${dateStr}.zip`;
857
+ res.writeHead(200, {
858
+ 'Content-Type': 'application/zip',
859
+ 'Content-Disposition': `attachment; filename="${zipName}"`,
860
+ 'Content-Length': zipBuf.length,
861
+ });
862
+ res.end(zipBuf);
863
+ } else if (format === 'json') {
864
+ const data = querySection(sections[0]);
865
+ const fileName = `${project}-${sections[0]}-${dateStr}.json`;
866
+ const content = JSON.stringify(data, null, 2);
867
+ res.writeHead(200, {
868
+ 'Content-Type': 'application/json',
869
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
870
+ });
871
+ res.end(content);
872
+ } else if (format === 'csv') {
873
+ const data = querySection(sections[0]);
874
+ const fileName = `${project}-${sections[0]}-${dateStr}.csv`;
875
+ res.writeHead(200, {
876
+ 'Content-Type': 'text/csv; charset=utf-8',
877
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
878
+ });
879
+ res.end(toCSV(data));
880
+ } else if (format === 'md') {
881
+ const data = querySection(sections[0]);
882
+ const fileName = `${project}-${sections[0]}-${dateStr}.md`;
883
+ res.writeHead(200, {
884
+ 'Content-Type': 'text/markdown; charset=utf-8',
885
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
886
+ });
887
+ res.end(toMarkdown(sections[0], data, project));
888
+ } else {
889
+ json(res, 400, { error: 'Invalid format. Allowed: json, csv, md, zip' });
890
+ }
891
+ } catch (e) {
892
+ console.error('[export/download]', e);
893
+ json(res, 500, { error: e.message });
894
+ }
895
+ return;
896
+ }
897
+
589
898
  // ─── API: SSE Terminal — stream command output ───
590
899
  if (req.method === 'GET' && path === '/api/terminal') {
591
900
  const params = url.searchParams;
@@ -410,8 +410,17 @@ async function handleEnv(req, res) {
410
410
  return;
411
411
  }
412
412
 
413
- const result = updateEnvForSetup(keys);
414
- jsonResponse(res, { success: true, path: result.path });
413
+ // saveModelsModule sends raw env var names (OLLAMA_MODEL, ANALYSIS_PROVIDER, etc.)
414
+ // while updateEnvForSetup expects camelCase. Write raw env vars directly.
415
+ for (const [key, value] of Object.entries(keys)) {
416
+ if (/^[A-Z_]+$/.test(key) && value) {
417
+ writeEnvKey(key, String(value));
418
+ process.env[key] = String(value);
419
+ }
420
+ }
421
+
422
+ const envPath = join(ROOT, '.env');
423
+ jsonResponse(res, { success: true, path: envPath });
415
424
  } catch (err) {
416
425
  jsonResponse(res, { error: err.message }, 500);
417
426
  }
package/setup/wizard.html CHANGED
@@ -3152,6 +3152,10 @@ input::placeholder {
3152
3152
  if (state.selectedAnalysisProvider && state.selectedAnalysisModel) {
3153
3153
  payload.ANALYSIS_PROVIDER = state.selectedAnalysisProvider;
3154
3154
  payload.ANALYSIS_MODEL = state.selectedAnalysisModel;
3155
+ } else if (state.selectedAnalysis) {
3156
+ // Local Ollama analysis model — provider/model not set separately
3157
+ payload.ANALYSIS_PROVIDER = 'ollama';
3158
+ payload.ANALYSIS_MODEL = state.selectedAnalysis;
3155
3159
  }
3156
3160
 
3157
3161
  const cloudInput = document.getElementById('cloudApiKeyInput');