seo-intel 1.1.4 β†’ 1.1.6

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.
@@ -1,27 +1,15 @@
1
1
  #!/bin/bash
2
- cd "$(dirname "$0")" || cd ~
3
- clear
2
+ cd "$(dirname "$0")"
4
3
  echo ""
5
- echo " πŸ¦€ SEO Intel β€” Setup Wizard"
6
- echo " Opening in your browser..."
4
+ echo " SEO Intel β€” Setup Wizard"
5
+ echo " Opening setup in your browser..."
7
6
  echo ""
8
-
9
- # Start server in background if not already running
10
- if ! curl -s http://localhost:3000/ > /dev/null 2>&1; then
11
- npx seo-intel serve &
12
- SERVER_PID=$!
13
- sleep 2
14
- fi
15
-
16
- # Open setup wizard in browser
17
- open "http://localhost:3000/setup" 2>/dev/null || xdg-open "http://localhost:3000/setup" 2>/dev/null
18
-
19
- echo " Setup wizard is open at http://localhost:3000/setup"
20
- echo " Keep this window open while using the wizard."
21
- echo ""
22
- read -n 1 -s -r -p " Press any key to stop the server and exit..."
23
-
24
- # Clean up
25
- if [ -n "$SERVER_PID" ]; then
26
- kill $SERVER_PID 2>/dev/null
27
- fi
7
+ node cli.js serve &
8
+ SERVER_PID=$!
9
+ # Wait for server to be ready
10
+ for i in {1..10}; do
11
+ sleep 1
12
+ if curl -s http://localhost:3000/ > /dev/null 2>&1; then break; fi
13
+ done
14
+ open "http://localhost:3000/setup"
15
+ wait $SERVER_PID
package/cli.js CHANGED
@@ -91,6 +91,38 @@ async function checkOllamaAvailability() {
91
91
  // ── EXTRACTION PROGRESS TRACKER ──────────────────────────────────────────
92
92
  const PROGRESS_FILE = join(__dirname, '.extraction-progress.json');
93
93
 
94
+ // ── Graceful shutdown support ──
95
+ // Cleanup callbacks registered by crawl/extract commands (e.g. close browser)
96
+ const _shutdownCallbacks = [];
97
+ let _shuttingDown = false;
98
+
99
+ function onShutdown(fn) { _shutdownCallbacks.push(fn); }
100
+ function clearShutdownCallbacks() { _shutdownCallbacks.length = 0; }
101
+
102
+ async function _gracefulExit(signal) {
103
+ if (_shuttingDown) return;
104
+ _shuttingDown = true;
105
+ console.log(chalk.yellow(`\n⏹ Received ${signal} β€” stopping gracefully…`));
106
+
107
+ // Update progress file
108
+ try {
109
+ const progress = readProgress();
110
+ if (progress && progress.status === 'running' && progress.pid === process.pid) {
111
+ writeProgress({ ...progress, status: 'stopped', stopped_at: Date.now() });
112
+ }
113
+ } catch { /* best-effort */ }
114
+
115
+ // Run cleanup callbacks (close browsers, etc.)
116
+ for (const fn of _shutdownCallbacks) {
117
+ try { await Promise.resolve(fn()); } catch { /* best-effort */ }
118
+ }
119
+
120
+ process.exit(0);
121
+ }
122
+
123
+ process.on('SIGTERM', () => _gracefulExit('SIGTERM'));
124
+ process.on('SIGINT', () => _gracefulExit('SIGINT'));
125
+
94
126
  function writeProgress(data) {
95
127
  try {
96
128
  writeFileSync(PROGRESS_FILE, JSON.stringify({
@@ -481,6 +513,7 @@ program
481
513
  page_index: totalExtracted + 1,
482
514
  started_at: crawlStart,
483
515
  failed: totalFailed,
516
+ stealth: !!crawlOpts.stealth,
484
517
  });
485
518
  upsertTechnical(db, { pageId, hasCanonical: page.hasCanonical, hasOgTags: page.hasOgTags, hasSchema: page.hasSchema, hasRobots: page.hasRobots });
486
519
  try {
@@ -1559,6 +1592,14 @@ program
1559
1592
  console.log(chalk.magenta(' πŸ₯· Advanced mode β€” full browser rendering, persistent sessions\n'));
1560
1593
  }
1561
1594
 
1595
+ // Register cleanup so SIGTERM closes the browser gracefully
1596
+ onShutdown(async () => {
1597
+ if (stealthSession) {
1598
+ await stealthSession.close();
1599
+ console.log(chalk.magenta(' πŸ₯· Stealth session closed'));
1600
+ }
1601
+ });
1602
+
1562
1603
  try {
1563
1604
  for (const row of pendingPages) {
1564
1605
  process.stdout.write(chalk.gray(` [${done + failed + 1}/${pendingPages.length}] ${row.url.slice(0, 65)} β†’ `));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl β†’ SQLite β†’ cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -418,17 +418,21 @@ function buildHtmlTemplate(data, opts = {}) {
418
418
  border-radius: var(--radius);
419
419
  padding: 14px 20px;
420
420
  display: flex;
421
- align-items: center;
422
- gap: 20px;
421
+ flex-direction: column;
422
+ gap: 12px;
423
423
  font-size: 0.78rem;
424
424
  }
425
425
  .extraction-status.is-running {
426
426
  border-color: rgba(232,213,163,0.3);
427
427
  }
428
+ .es-top-row {
429
+ display: flex; align-items: center; gap: 16px; width: 100%;
430
+ }
428
431
  .es-indicator {
429
432
  display: flex; align-items: center; gap: 8px;
430
433
  font-family: var(--font-display); font-weight: 700;
431
434
  font-size: 0.8rem; white-space: nowrap;
435
+ flex-shrink: 0;
432
436
  }
433
437
  .es-dot {
434
438
  width: 8px; height: 8px; border-radius: 50%;
@@ -444,21 +448,27 @@ function buildHtmlTemplate(data, opts = {}) {
444
448
  50% { opacity: 0.7; box-shadow: 0 0 0 6px rgba(232,213,163,0); }
445
449
  }
446
450
  .es-domains {
447
- display: flex; gap: 12px; flex-wrap: wrap; flex: 1;
451
+ display: grid;
452
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
453
+ gap: 6px 20px;
454
+ flex: 1;
448
455
  }
449
456
  .es-domain {
450
457
  display: flex; align-items: center; gap: 6px;
451
458
  }
452
459
  .es-domain-name {
453
460
  color: var(--text-secondary); font-size: 0.72rem;
461
+ white-space: nowrap;
462
+ width: 68px; min-width: 68px; flex-shrink: 0;
463
+ overflow: hidden; text-overflow: ellipsis;
454
464
  }
455
465
  .es-domain-name.is-target { color: var(--accent-gold); }
456
466
  .es-bar-wrap {
457
- width: 60px; height: 4px; background: var(--border-subtle);
458
- border-radius: 2px; overflow: hidden;
467
+ flex: 1; height: 5px; background: var(--border-subtle);
468
+ border-radius: 2.5px; overflow: hidden;
459
469
  }
460
470
  .es-bar-fill {
461
- height: 100%; border-radius: 2px;
471
+ height: 100%; border-radius: 2.5px;
462
472
  background: var(--color-success);
463
473
  transition: width 0.3s;
464
474
  }
@@ -466,7 +476,7 @@ function buildHtmlTemplate(data, opts = {}) {
466
476
  .es-bar-fill.low { background: var(--color-danger); }
467
477
  .es-pct {
468
478
  font-size: 0.68rem; color: var(--text-muted);
469
- min-width: 28px; text-align: right;
479
+ width: 32px; min-width: 32px; text-align: right; flex-shrink: 0;
470
480
  }
471
481
  .es-live {
472
482
  font-size: 0.7rem; color: var(--accent-gold);
@@ -479,9 +489,14 @@ function buildHtmlTemplate(data, opts = {}) {
479
489
  background: var(--color-danger);
480
490
  animation: pulse-dot 2s ease-in-out infinite;
481
491
  }
492
+ .es-bottom-row {
493
+ display: flex; align-items: center; gap: 12px; width: 100%;
494
+ padding-top: 10px;
495
+ border-top: 1px solid var(--border-subtle);
496
+ }
482
497
  .es-meta {
483
498
  display: flex; gap: 12px; align-items: center;
484
- margin-left: auto; white-space: nowrap; font-size: 0.7rem;
499
+ white-space: nowrap; font-size: 0.7rem;
485
500
  }
486
501
  .es-meta-item { color: var(--text-muted); }
487
502
  .es-meta-item i { margin-right: 3px; font-size: 0.62rem; }
@@ -493,8 +508,7 @@ function buildHtmlTemplate(data, opts = {}) {
493
508
  /* ─── Extraction Controls (server mode) ──────────────────────────────── */
494
509
  .es-controls {
495
510
  display: flex; align-items: center; gap: 10px;
496
- margin-left: 16px; padding-left: 16px;
497
- border-left: 1px solid var(--border-subtle);
511
+ margin-left: auto;
498
512
  }
499
513
  .es-controls.hidden { display: none; }
500
514
  .es-btn {
@@ -1866,69 +1880,85 @@ function buildHtmlTemplate(data, opts = {}) {
1866
1880
  extractionStatus.liveProgress?.status === 'running' ? 'is-running' :
1867
1881
  extractionStatus.liveProgress?.status === 'crashed' ? 'is-crashed' : ''
1868
1882
  }">
1869
- <div class="es-indicator">
1870
- <span class="es-dot ${
1871
- extractionStatus.liveProgress?.status === 'running' ? 'running' :
1872
- extractionStatus.liveProgress?.status === 'crashed' ? 'crashed' : ''
1873
- }"></span>
1874
- ${extractionStatus.liveProgress?.status === 'running'
1875
- ? `<span style="color:var(--accent-gold);">Extracting</span>`
1876
- : extractionStatus.liveProgress?.status === 'crashed'
1877
- ? `<span style="color:var(--color-danger);">Crashed</span>`
1878
- : `<span style="color:var(--text-muted);">${extractionStatus.overallPct === 100 ? 'Fully Extracted' : extractionStatus.overallPct + '% Extracted'}</span>`
1879
- }
1880
- </div>
1881
- <div class="es-domains">
1882
- ${extractionStatus.coverage.map(c => {
1883
- const pct = c.total_pages > 0 ? Math.round((c.extracted_pages / c.total_pages) * 100) : 0;
1884
- const barClass = pct === 100 ? '' : pct > 50 ? 'partial' : 'low';
1885
- return `
1886
- <div class="es-domain">
1887
- <span class="es-domain-name ${c.role === 'target' ? 'is-target' : ''}">${getDomainShortName(c.domain)}</span>
1888
- <div class="es-bar-wrap"><div class="es-bar-fill ${barClass}" style="width:${pct}%;"></div></div>
1889
- <span class="es-pct">${pct}%</span>
1890
- </div>`;
1891
- }).join('')}
1892
- </div>
1893
- <div class="es-meta">
1894
- ${extractionStatus.liveProgress?.status === 'running' ? `
1895
- <span class="es-meta-item" style="color:var(--accent-gold);">
1896
- <i class="fa-solid fa-spinner fa-spin"></i>
1897
- ${extractionStatus.liveProgress.current_url ? extractionStatus.liveProgress.current_url.replace(/https?:\/\/[^/]+/, '').slice(0, 30) : ''}
1898
- ${extractionStatus.liveProgress.total ? ` Β· ${extractionStatus.liveProgress.page_index}/${extractionStatus.liveProgress.total}` : ''}
1899
- </span>
1900
- ` : ''}
1901
- ${extractionStatus.liveProgress?.status === 'crashed' ? `
1902
- <span class="es-meta-item blocked">
1903
- <i class="fa-solid fa-skull"></i> PID ${extractionStatus.liveProgress.pid} dead
1904
- </span>
1905
- ` : ''}
1906
- ${extractionStatus.liveProgress?.skipped > 0 ? `
1907
- <span class="es-meta-item skipped">
1908
- <i class="fa-solid fa-forward"></i> ${extractionStatus.liveProgress.skipped} skipped
1909
- </span>
1910
- ` : ''}
1911
- ${extractionStatus.hashedPages > 0 ? `
1912
- <span class="es-meta-item">
1913
- <i class="fa-solid fa-fingerprint"></i> ${extractionStatus.hashedPages} hashed
1914
- </span>
1915
- ` : ''}
1883
+ <!-- Row 1: Status indicator + domain coverage bars (full width) -->
1884
+ <div class="es-top-row">
1885
+ <div class="es-indicator">
1886
+ <span class="es-dot ${
1887
+ extractionStatus.liveProgress?.status === 'running' ? 'running' :
1888
+ extractionStatus.liveProgress?.status === 'crashed' ? 'crashed' : ''
1889
+ }"></span>
1890
+ ${extractionStatus.liveProgress?.status === 'running'
1891
+ ? `<span style="color:var(--accent-gold);">Extracting</span>`
1892
+ : extractionStatus.liveProgress?.status === 'crashed'
1893
+ ? `<span style="color:var(--color-danger);">Crashed</span>`
1894
+ : `<span style="color:var(--text-muted);">${extractionStatus.overallPct === 100 ? 'Fully Extracted' : extractionStatus.overallPct + '% Extracted'}</span>`
1895
+ }
1896
+ </div>
1897
+ <div class="es-domains">
1898
+ ${extractionStatus.coverage.map(c => {
1899
+ const pct = c.total_pages > 0 ? Math.round((c.extracted_pages / c.total_pages) * 100) : 0;
1900
+ const barClass = pct === 100 ? '' : pct > 50 ? 'partial' : 'low';
1901
+ return `
1902
+ <div class="es-domain">
1903
+ <span class="es-domain-name ${c.role === 'target' ? 'is-target' : ''}">${getDomainShortName(c.domain)}</span>
1904
+ <div class="es-bar-wrap"><div class="es-bar-fill ${barClass}" style="width:${pct}%;"></div></div>
1905
+ <span class="es-pct">${pct}%</span>
1906
+ </div>`;
1907
+ }).join('')}
1908
+ </div>
1916
1909
  </div>
1917
- <div class="es-controls" id="esControls${suffix}">
1918
- <button class="es-btn" id="btnCrawl${suffix}" onclick="startJob('crawl','${project}')">
1919
- <i class="fa-solid fa-spider"></i> Crawl
1920
- </button>
1921
- <button class="es-btn" id="btnExtract${suffix}" onclick="startJob('extract','${project}')">
1922
- <i class="fa-solid fa-brain"></i> Extract
1923
- </button>
1924
- <button class="es-btn es-btn-stop" id="btnStop${suffix}" onclick="stopJob()" style="display:none;">
1910
+ <!-- Row 2: Meta info + controls -->
1911
+ <div class="es-bottom-row">
1912
+ <div class="es-meta">
1913
+ ${extractionStatus.liveProgress?.status === 'running' ? `
1914
+ <span class="es-meta-item" style="color:var(--accent-gold);">
1915
+ <i class="fa-solid fa-spinner fa-spin"></i>
1916
+ ${extractionStatus.liveProgress.current_url ? extractionStatus.liveProgress.current_url.replace(/https?:\/\/[^/]+/, '').slice(0, 30) : ''}
1917
+ ${extractionStatus.liveProgress.total ? ` Β· ${extractionStatus.liveProgress.page_index}/${extractionStatus.liveProgress.total}` : ''}
1918
+ </span>
1919
+ ` : ''}
1920
+ ${extractionStatus.liveProgress?.status === 'crashed' ? `
1921
+ <span class="es-meta-item blocked">
1922
+ <i class="fa-solid fa-skull"></i> PID ${extractionStatus.liveProgress.pid} dead
1923
+ </span>
1924
+ ` : ''}
1925
+ ${extractionStatus.liveProgress?.skipped > 0 ? `
1926
+ <span class="es-meta-item skipped">
1927
+ <i class="fa-solid fa-forward"></i> ${extractionStatus.liveProgress.skipped} skipped
1928
+ </span>
1929
+ ` : ''}
1930
+ ${extractionStatus.hashedPages > 0 ? `
1931
+ <span class="es-meta-item">
1932
+ <i class="fa-solid fa-fingerprint"></i> ${extractionStatus.hashedPages} hashed
1933
+ </span>
1934
+ ` : ''}
1935
+ </div>
1936
+ <div class="es-controls" id="esControls${suffix}">
1937
+ ${extractionStatus.liveProgress?.status === 'running' && extractionStatus.liveProgress?.command === 'crawl'
1938
+ ? `<button class="es-btn running" id="btnCrawl${suffix}" onclick="startJob('crawl','${project}')" disabled>
1939
+ <i class="fa-solid fa-spinner fa-spin"></i> Crawling\u2026
1940
+ </button>`
1941
+ : `<button class="es-btn" id="btnCrawl${suffix}" onclick="startJob('crawl','${project}')"${extractionStatus.liveProgress?.status === 'running' ? ' disabled' : ''}>
1942
+ <i class="fa-solid fa-spider"></i> Crawl
1943
+ </button>`
1944
+ }
1945
+ ${extractionStatus.liveProgress?.status === 'running' && extractionStatus.liveProgress?.command === 'extract'
1946
+ ? `<button class="es-btn running" id="btnExtract${suffix}" onclick="startJob('extract','${project}')" disabled>
1947
+ <i class="fa-solid fa-spinner fa-spin"></i> Extracting\u2026
1948
+ </button>`
1949
+ : `<button class="es-btn" id="btnExtract${suffix}" onclick="startJob('extract','${project}')"${extractionStatus.liveProgress?.status === 'running' ? ' disabled' : ''}>
1950
+ <i class="fa-solid fa-brain"></i> Extract
1951
+ </button>`
1952
+ }
1953
+ <button class="es-btn es-btn-stop" id="btnStop${suffix}" onclick="stopJob()" style="display:${extractionStatus.liveProgress?.status === 'running' ? 'inline-flex' : 'none'};">
1925
1954
  <i class="fa-solid fa-stop"></i> Stop
1926
1955
  </button>
1927
1956
  <label class="es-stealth-toggle">
1928
- <input type="checkbox" id="stealthToggle${suffix}">
1957
+ <input type="checkbox" id="stealthToggle${suffix}"${extractionStatus.liveProgress?.stealth ? ' checked' : ''}>
1929
1958
  <i class="fa-solid fa-user-ninja"></i> Stealth
1930
1959
  </label>
1931
1960
  </div>
1961
+ </div>
1932
1962
  </div>
1933
1963
 
1934
1964
  <!-- ═══ INTEGRATED TERMINAL + EXPORT SIDEBAR ═══ -->
@@ -2188,6 +2218,53 @@ function buildHtmlTemplate(data, opts = {}) {
2188
2218
  if (scopeIdx > -1 && parts[scopeIdx + 1]) extra.scope = parts[scopeIdx + 1];
2189
2219
  runCommand(cmd, proj, extra);
2190
2220
  });
2221
+
2222
+ // Autorun: if URL has ?autorun=setup-classic, fire seo-intel setup --classic via SSE
2223
+ (function() {
2224
+ const urlParams = new URLSearchParams(window.location.search);
2225
+ if (urlParams.get('autorun') === 'setup-classic') {
2226
+ // Remove the param so it doesn't re-trigger on refresh
2227
+ window.history.replaceState({}, '', window.location.pathname);
2228
+ // Wait a tick for the panel to be ready, then stream the command
2229
+ setTimeout(function() {
2230
+ if (!isServed) {
2231
+ appendLine('Not connected to server. Cannot run setup --classic automatically.', 'error');
2232
+ return;
2233
+ }
2234
+ running = true;
2235
+ status.textContent = 'running...';
2236
+ status.style.color = 'var(--color-warning)';
2237
+ appendLine('$ seo-intel setup --classic', 'cmd');
2238
+ const params = new URLSearchParams({ command: 'setup', classic: 'true' });
2239
+ eventSource = new EventSource('/api/terminal?' + params.toString());
2240
+ eventSource.onmessage = function(e) {
2241
+ try {
2242
+ const msg = JSON.parse(e.data);
2243
+ if (msg.type === 'stdout') appendLine(msg.data, 'stdout');
2244
+ else if (msg.type === 'stderr') appendLine(msg.data, 'stderr');
2245
+ else if (msg.type === 'error') { appendLine('Error: ' + msg.data, 'error'); }
2246
+ else if (msg.type === 'exit') {
2247
+ const code = msg.data?.code ?? msg.data;
2248
+ appendLine(code === 0 ? 'Done.' : 'Exited with code ' + code, code === 0 ? 'exit-ok' : 'exit-err');
2249
+ running = false;
2250
+ status.textContent = code === 0 ? 'done' : 'failed';
2251
+ status.style.color = code === 0 ? 'var(--color-success)' : 'var(--color-danger)';
2252
+ eventSource.close();
2253
+ eventSource = null;
2254
+ }
2255
+ } catch (_) {}
2256
+ };
2257
+ eventSource.onerror = function() {
2258
+ if (running) { appendLine('Connection lost.', 'error'); }
2259
+ running = false;
2260
+ status.textContent = 'disconnected';
2261
+ status.style.color = 'var(--color-danger)';
2262
+ eventSource?.close();
2263
+ eventSource = null;
2264
+ };
2265
+ }, 300);
2266
+ }
2267
+ })();
2191
2268
  })();
2192
2269
  </script>
2193
2270
 
@@ -3499,7 +3576,6 @@ function buildHtmlTemplate(data, opts = {}) {
3499
3576
  };
3500
3577
 
3501
3578
  window.stopJob = function() {
3502
- if (!confirm('Stop the running job?')) return;
3503
3579
  fetch('/api/stop', { method: 'POST' })
3504
3580
  .then(function(r) { return r.json(); })
3505
3581
  .then(function(data) {
@@ -3582,6 +3658,11 @@ function buildHtmlTemplate(data, opts = {}) {
3582
3658
  dot.className = 'es-dot';
3583
3659
  label.style.color = 'var(--color-success)';
3584
3660
  label.textContent = 'Completed (' + (data.extracted || 0) + ' extracted' + (data.failed ? ', ' + data.failed + ' failed' : '') + ')';
3661
+ } else if (data.status === 'stopped') {
3662
+ panel.classList.remove('is-running', 'is-crashed');
3663
+ dot.className = 'es-dot';
3664
+ label.style.color = 'var(--accent-gold)';
3665
+ label.textContent = 'Stopped' + (data.extracted ? ' (' + data.extracted + ' extracted)' : '');
3585
3666
  } else if (data.status === 'crashed') {
3586
3667
  panel.classList.remove('is-running');
3587
3668
  panel.classList.add('is-crashed');
@@ -3595,6 +3676,11 @@ function buildHtmlTemplate(data, opts = {}) {
3595
3676
  fetch('/api/progress')
3596
3677
  .then(function(r) { return r.json(); })
3597
3678
  .then(function(data) {
3679
+ // Sync stealth toggle with progress state
3680
+ var stealthEl = document.getElementById('stealthToggle' + sfx);
3681
+ if (stealthEl && data.stealth !== undefined) {
3682
+ stealthEl.checked = !!data.stealth;
3683
+ }
3598
3684
  if (data.status === 'running') {
3599
3685
  setButtonsState(true, data.command);
3600
3686
  startPolling();
@@ -4691,7 +4777,6 @@ function buildMultiHtmlTemplate(allProjectData) {
4691
4777
  };
4692
4778
 
4693
4779
  window.stopJob = function() {
4694
- if (!confirm('Stop the running job?')) return;
4695
4780
  fetch('/api/stop', { method: 'POST' })
4696
4781
  .then(function(r) { return r.json(); })
4697
4782
  .then(function(data) {
@@ -4750,6 +4835,10 @@ function buildMultiHtmlTemplate(allProjectData) {
4750
4835
  panel.classList.remove('is-running', 'is-crashed');
4751
4836
  dot.className = 'es-dot'; label.style.color = 'var(--color-success)';
4752
4837
  label.textContent = 'Completed (' + (data.extracted || 0) + ' extracted)';
4838
+ } else if (data.status === 'stopped') {
4839
+ panel.classList.remove('is-running', 'is-crashed');
4840
+ dot.className = 'es-dot'; label.style.color = 'var(--accent-gold)';
4841
+ label.textContent = 'Stopped' + (data.extracted ? ' (' + data.extracted + ' extracted)' : '');
4753
4842
  } else if (data.status === 'crashed') {
4754
4843
  panel.classList.remove('is-running'); panel.classList.add('is-crashed');
4755
4844
  dot.className = 'es-dot crashed'; label.style.color = 'var(--color-danger)';
package/server.js CHANGED
@@ -345,15 +345,26 @@ async function handleRequest(req, res) {
345
345
  return;
346
346
  }
347
347
  try {
348
+ // Graceful: SIGTERM lets the CLI close browsers / write progress
348
349
  process.kill(progress.pid, 'SIGTERM');
349
- // Give it a moment, then force kill if still alive
350
+ // Escalate: SIGKILL after 5s if still alive (stealth browser cleanup needs time)
350
351
  setTimeout(() => {
352
+ try { process.kill(progress.pid, 0); } catch { return; } // already dead
351
353
  try { process.kill(progress.pid, 'SIGKILL'); } catch {}
352
- }, 3000);
354
+ }, 5000);
353
355
  } catch (e) {
354
356
  if (e.code !== 'ESRCH') throw e;
355
357
  // Already dead
356
358
  }
359
+ // Update progress file (CLI also writes this on SIGTERM, but server does it too as safety net)
360
+ try {
361
+ writeFileSync(PROGRESS_FILE, JSON.stringify({
362
+ ...progress,
363
+ status: 'stopped',
364
+ stopped_at: Date.now(),
365
+ updated_at: Date.now(),
366
+ }, null, 2));
367
+ } catch { /* best-effort */ }
357
368
  json(res, 200, { stopped: true, pid: progress.pid, command: progress.command });
358
369
  } catch (e) {
359
370
  json(res, 500, { error: e.message });
@@ -619,6 +630,18 @@ const server = createServer((req, res) => {
619
630
  // Start background update check
620
631
  checkForUpdates();
621
632
 
633
+ server.on('error', (err) => {
634
+ if (err.code === 'EADDRINUSE') {
635
+ console.log(`\n ⚠️ Port ${PORT} is already in use β€” opening existing dashboard…\n`);
636
+ const url = `http://localhost:${PORT}`;
637
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
638
+ import('child_process').then(({ exec }) => exec(`${cmd} "${url}"`));
639
+ setTimeout(() => process.exit(0), 500);
640
+ } else {
641
+ throw err;
642
+ }
643
+ });
644
+
622
645
  server.listen(PORT, '127.0.0.1', () => {
623
646
  console.log(`\n SEO Intel Dashboard Server`);
624
647
  console.log(` http://localhost:${PORT}\n`);
@@ -66,13 +66,39 @@ async function askAgent(messages, opts = {}) {
66
66
  */
67
67
  export async function isGatewayReady() {
68
68
  try {
69
+ // Try to read token from env or openclaw config file
70
+ let token = process.env.OPENCLAW_TOKEN;
71
+ if (!token) {
72
+ try {
73
+ const configPath = join(process.env.HOME || '~', '.openclaw', 'openclaw.json');
74
+ const { readFileSync } = await import('fs');
75
+ const raw = readFileSync(configPath, 'utf8');
76
+ // Gateway auth token is a 48-char hex string in the gateway.auth block
77
+ const matches = [...raw.matchAll(/"token":\s*"([a-f0-9]{40,})"/g)];
78
+ if (matches.length > 0) token = matches[matches.length - 1][1];
79
+ } catch {}
80
+ }
81
+ if (!token) return false;
82
+
83
+ // Verify gateway is reachable via a lightweight chat completions ping
69
84
  const controller = new AbortController();
70
- const timeout = setTimeout(() => controller.abort(), 3000);
71
- const res = await fetch(`${OPENCLAW_API}/v1/models`, {
85
+ const timeout = setTimeout(() => controller.abort(), 5000);
86
+ const res = await fetch(`${OPENCLAW_API}/v1/chat/completions`, {
87
+ method: 'POST',
72
88
  signal: controller.signal,
89
+ headers: {
90
+ 'Authorization': `Bearer ${token}`,
91
+ 'Content-Type': 'application/json',
92
+ },
93
+ body: JSON.stringify({
94
+ model: 'anthropic/claude-haiku-4-5',
95
+ messages: [{ role: 'user', content: 'ping' }],
96
+ max_tokens: 1,
97
+ }),
73
98
  });
74
99
  clearTimeout(timeout);
75
- return res.ok;
100
+ const ct = res.headers.get('content-type') || '';
101
+ return res.ok && ct.includes('application/json');
76
102
  } catch {
77
103
  return false;
78
104
  }
package/setup/wizard.html CHANGED
@@ -54,6 +54,7 @@ body {
54
54
  max-width: var(--max-width);
55
55
  margin: 0 auto 24px;
56
56
  text-align: center;
57
+ position: relative;
57
58
  }
58
59
  .wizard-header h1 {
59
60
  font-family: var(--font-display);
@@ -1172,6 +1173,7 @@ input::placeholder {
1172
1173
  <div class="wizard-header">
1173
1174
  <h1>SEO Intel</h1>
1174
1175
  <div class="subtitle">Setup Wizard</div>
1176
+ <a href="http://localhost:3000/?autorun=setup-classic" title="Run setup in Terminal instead" style="position:absolute;top:14px;right:18px;font-size:0.68rem;color:var(--color-muted,#888);text-decoration:none;opacity:0.7;transition:opacity 0.15s;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.7'"><i class="fa-solid fa-terminal"></i> Terminal setup</a>
1175
1177
  </div>
1176
1178
 
1177
1179
  <!-- ═══════════════════════════════════════════════════════════════════════