diff --git a/gitnexus-web/api/proxy.ts b/gitnexus-web/api/proxy.ts new file mode 100644 index 0000000000..a3280f462d --- /dev/null +++ b/gitnexus-web/api/proxy.ts @@ -0,0 +1,166 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; + +/** + * CORS Proxy for isomorphic-git + * + * isomorphic-git calls: /api/proxy?url=https://github.com/... + */ +const ALLOWED_TARGET_HOSTS = [ + 'github.com', + 'api.github.com', + 'raw.githubusercontent.com', + 'codeload.github.com', +]; + +const isLocalOnlyMode = process.env.GITNEXUS_LOCAL_ONLY === undefined + || process.env.GITNEXUS_LOCAL_ONLY === '' + || (process.env.GITNEXUS_LOCAL_ONLY !== '0' && process.env.GITNEXUS_LOCAL_ONLY !== 'false'); + +const ALLOWED_CORS_ORIGINS = new Set( + isLocalOnlyMode + ? [ + 'http://localhost:5173', + 'http://127.0.0.1:5173', + 'http://localhost:4173', + 'http://127.0.0.1:4173', + ] + : [ + 'https://gitnexus.vercel.app', + 'http://localhost:5173', + 'http://127.0.0.1:5173', + 'http://localhost:4173', + 'http://127.0.0.1:4173', + ] +); + +const isAllowedTargetHost = (hostname: string): boolean => ( + ALLOWED_TARGET_HOSTS.some((host) => hostname === host || hostname.endsWith(`.${host}`)) +); + +const isAllowedCorsOrigin = (origin: string | undefined): boolean => ( + !!origin && ALLOWED_CORS_ORIGINS.has(origin) +); + +const setCorsHeaders = (req: VercelRequest, res: VercelResponse): boolean => { + const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined; + if (!isAllowedCorsOrigin(origin)) return false; + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Git-Protocol, Accept'); + res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Length, ETag, Last-Modified'); + return true; +}; + +export default async function handler(req: VercelRequest, res: VercelResponse) { + // Handle CORS preflight + if (req.method === 'OPTIONS') { + if (!setCorsHeaders(req, res)) { + res.status(403).json({ error: 'Origin not allowed' }); + return; + } + res.status(200).end(); + return; + } + + if (!setCorsHeaders(req, res)) { + res.status(403).json({ error: 'Origin not allowed' }); + return; + } + + if (req.method !== 'GET' && req.method !== 'POST') { + res.status(405).json({ error: 'Method not allowed' }); + return; + } + + // Get URL from query parameter + const { url } = req.query; + + if (!url || typeof url !== 'string') { + res.status(400).json({ error: 'Missing url query parameter' }); + return; + } + + // Only allow trusted GitHub hosts for security + let parsedUrl: URL; + + try { + parsedUrl = new URL(url); + } catch { + res.status(400).json({ error: 'Invalid URL' }); + return; + } + + if (parsedUrl.protocol !== 'https:') { + res.status(403).json({ error: 'Only HTTPS URLs are allowed' }); + return; + } + + if (!isAllowedTargetHost(parsedUrl.hostname)) { + res.status(403).json({ error: 'Only GitHub URLs are allowed' }); + return; + } + + try { + const headers: Record = { + 'User-Agent': 'git/isomorphic-git', + }; + + // Forward relevant headers + // Never forward browser auth headers to non-core GitHub hosts. + if ( + req.headers.authorization + && (parsedUrl.hostname === 'github.com' || parsedUrl.hostname === 'api.github.com' || parsedUrl.hostname === 'codeload.github.com') + ) { + headers['Authorization'] = req.headers.authorization as string; + } + if (req.headers['content-type']) { + headers['Content-Type'] = req.headers['content-type'] as string; + } + if (req.headers['git-protocol']) { + headers['Git-Protocol'] = req.headers['git-protocol'] as string; + } + if (req.headers.accept) { + headers['Accept'] = req.headers.accept as string; + } + + // Get request body for POST requests + let body: Buffer | undefined; + if (req.method === 'POST') { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + } + body = Buffer.concat(chunks); + } + + const response = await fetch(url, { + method: req.method || 'GET', + headers, + body: body ? new Uint8Array(body) : undefined, + }); + + // Forward response headers (except ones that cause issues) + const skipHeaders = [ + 'content-encoding', + 'transfer-encoding', + 'connection', + 'www-authenticate', // IMPORTANT: Strip this to prevent browser's native auth popup! + ]; + + response.headers.forEach((value, key) => { + if (!skipHeaders.includes(key.toLowerCase())) { + res.setHeader(key, value); + } + }); + + res.status(response.status); + const buffer = await response.arrayBuffer(); + res.send(Buffer.from(buffer)); + + } catch (error) { + console.error('Proxy error:', error); + res.status(500).json({ error: 'Proxy request failed' }); + } +} + diff --git a/gitnexus-web/src/components/SettingsPanel.tsx b/gitnexus-web/src/components/SettingsPanel.tsx index 4ba6a3f742..b7cfc823d4 100644 --- a/gitnexus-web/src/components/SettingsPanel.tsx +++ b/gitnexus-web/src/components/SettingsPanel.tsx @@ -1,18 +1,5 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { - X, - Key, - Server, - Brain, - Check, - AlertCircle, - Eye, - EyeOff, - RefreshCw, - ChevronDown, - Loader2, - Search, -} from '@/lib/lucide-icons'; +import { X, Key, Server, Brain, Check, AlertCircle, Eye, EyeOff, RefreshCw, ChevronDown, Loader2, Search } from '@/lib/lucide-icons'; import { loadSettings, saveSettings, @@ -22,6 +9,7 @@ import { } from '../core/llm/settings-service'; import type { LLMSettings, LLMProvider } from '../core/llm/types'; import { DEFAULT_OLLAMA_BASE_URL } from '../config/ui-constants'; +import { isLocalOnlyMode } from '../config/security-mode'; import { ProviderConfigCard } from './settings/ProviderConfigCard'; interface SettingsPanelProps { @@ -44,13 +32,7 @@ interface OpenRouterModelComboboxProps { onLoadModels: () => void; } -const OpenRouterModelCombobox = ({ - value, - onChange, - models, - isLoading, - onLoadModels, -}: OpenRouterModelComboboxProps) => { +const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadModels }: OpenRouterModelComboboxProps) => { const [isOpen, setIsOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const inputRef = useRef(null); @@ -60,15 +42,16 @@ const OpenRouterModelCombobox = ({ const filteredModels = useMemo(() => { if (!searchTerm.trim()) return models; const lower = searchTerm.toLowerCase(); - return models.filter( - (m) => m.id.toLowerCase().includes(lower) || m.name.toLowerCase().includes(lower), + return models.filter(m => + m.id.toLowerCase().includes(lower) || + m.name.toLowerCase().includes(lower) ); }, [models, searchTerm]); // Find display name for current value const displayValue = useMemo(() => { if (!value) return ''; - const found = models.find((m) => m.id === value); + const found = models.find(m => m.id === value); return found ? found.name : value; }, [value, models]); @@ -111,7 +94,7 @@ const OpenRouterModelCombobox = ({ const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && searchTerm) { // If exact match in filtered, select it; otherwise use raw input - const exact = filteredModels.find((m) => m.id.toLowerCase() === searchTerm.toLowerCase()); + const exact = filteredModels.find(m => m.id.toLowerCase() === searchTerm.toLowerCase()); if (exact) { handleSelect(exact.id); } else if (filteredModels.length === 1) { @@ -133,7 +116,8 @@ const OpenRouterModelCombobox = ({ {/* Main input/button */}
{isOpen ? ( e.stopPropagation()} + className="flex-1 bg-transparent text-text-primary placeholder:text-text-muted outline-none font-mono text-sm" + onClick={e => e.stopPropagation()} /> ) : ( - + {displayValue || 'Select or type a model...'} )}
- {isLoading && } - + {isLoading && } +
{/* Dropdown */} {isOpen && ( -
+
{isLoading ? ( -
- +
+ Loading models...
) : filteredModels.length === 0 ? (
{models.length === 0 ? ( -
- +
+

Type a model ID or press Enter

-

e.g. openai/gpt-4o

+

e.g. openai/gpt-4o

) : ( -
+

No models match "{searchTerm}"

-

Press Enter to use as custom ID

+

Press Enter to use as custom ID

)}
) : (
- {filteredModels.slice(0, 50).map((model) => ( + {filteredModels.slice(0, 50).map(model => ( ))} {filteredModels.length > 50 && ( -
+
+{filteredModels.length - 50} more • Refine your search
)} @@ -212,9 +193,7 @@ const OpenRouterModelCombobox = ({ /** * Check connection to local Ollama instance */ -const checkOllamaStatus = async ( - baseUrl: string, -): Promise<{ ok: boolean; error: string | null }> => { +const checkOllamaStatus = async (baseUrl: string): Promise<{ ok: boolean; error: string | null }> => { try { const response = await fetch(`${baseUrl}/api/tags`, { method: 'GET', @@ -223,10 +202,7 @@ const checkOllamaStatus = async ( if (!response.ok) { if (response.status === 0 || response.status === 404) { - return { - ok: false, - error: "Cannot connect to Ollama. Make sure it's running with `ollama serve`", - }; + return { ok: false, error: 'Cannot connect to Ollama. Make sure it\'s running with `ollama serve`' }; } return { ok: false, error: `Ollama API error: ${response.status}` }; } @@ -235,19 +211,13 @@ const checkOllamaStatus = async ( } catch (error) { return { ok: false, - error: "Cannot connect to Ollama. Make sure it's running with `ollama serve`", + error: 'Cannot connect to Ollama. Make sure it\'s running with `ollama serve`' }; } }; -export const SettingsPanel = ({ - isOpen, - onClose, - onSettingsSaved, - backendUrl, - isBackendConnected, - onBackendUrlChange, -}: SettingsPanelProps) => { +export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, isBackendConnected, onBackendUrlChange }: SettingsPanelProps) => { + const localOnlyMode = isLocalOnlyMode(); const [settings, setSettings] = useState(loadSettings); const [showApiKey, setShowApiKey] = useState>({}); const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle'); @@ -306,7 +276,7 @@ export const SettingsPanel = ({ }, [settings.ollama?.baseUrl, settings.activeProvider, checkOllamaConnection]); const handleProviderChange = (provider: LLMProvider) => { - setSettings((prev) => ({ ...prev, activeProvider: provider })); + setSettings(prev => ({ ...prev, activeProvider: provider })); }; const handleSave = () => { @@ -324,34 +294,31 @@ export const SettingsPanel = ({ }; const toggleApiKeyVisibility = (key: string) => { - setShowApiKey((prev) => ({ ...prev, [key]: !prev[key] })); + setShowApiKey(prev => ({ ...prev, [key]: !prev[key] })); }; if (!isOpen) return null; - const providers: LLMProvider[] = [ - 'openai', - 'gemini', - 'anthropic', - 'azure-openai', - 'ollama', - 'openrouter', - 'minimax', - 'glm', - ]; + const providers: LLMProvider[] = localOnlyMode + ? ['ollama', 'openai', 'glm'] + : ['openai', 'gemini', 'anthropic', 'azure-openai', 'ollama', 'openrouter', 'minimax', 'glm']; + return (
{/* Backdrop */} -
+
{/* Panel */} -
+
{/* Header */} -
+
-
- +
+

AI Settings

@@ -360,25 +327,25 @@ export const SettingsPanel = ({
{/* Content */} -
+
{/* Local Server */} {backendUrl !== undefined && onBackendUrlChange && (
- +
-
- +
+ Backend URL - + {isBackendConnected ? 'Connected' : 'Not connected'} @@ -388,11 +355,10 @@ export const SettingsPanel = ({ value={backendUrl} onChange={(e) => onBackendUrlChange(e.target.value)} placeholder="http://localhost:4747" - className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20" + className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm" />

- Run gitnexus serve to - start the local server + Run gitnexus serve to start the local server

@@ -400,36 +366,27 @@ export const SettingsPanel = ({ {/* Provider Selection */}
- -
- {providers.map((provider) => ( + +
+ {providers.map(provider => ( @@ -437,7 +394,7 @@ export const SettingsPanel = ({
-
+
API keys are stored in session storage and will be cleared when you close this tab.
@@ -452,43 +409,38 @@ export const SettingsPanel = ({ helperLink: 'https://platform.openai.com/api-keys', helperLinkLabel: 'OpenAI Platform', isVisible: !!showApiKey['openai'], - onChange: (value) => - setSettings((prev) => ({ - ...prev, - openai: { ...prev.openai!, apiKey: value }, - })), + onChange: (value) => setSettings(prev => ({ + ...prev, + openai: { ...prev.openai!, apiKey: value } + })), onToggleVisibility: () => toggleApiKeyVisibility('openai'), }} model={{ value: settings.openai?.model ?? 'gpt-5.2-chat', placeholder: 'e.g., gpt-4o, gpt-4-turbo, gpt-3.5-turbo', - onChange: (value) => - setSettings((prev) => ({ - ...prev, - openai: { ...prev.openai!, model: value }, - })), + onChange: (value) => setSettings(prev => ({ + ...prev, + openai: { ...prev.openai!, model: value } + })), }} >
- setSettings((prev) => ({ - ...prev, - openai: { ...prev.openai!, baseUrl: e.target.value }, - })) - } + onChange={e => setSettings(prev => ({ + ...prev, + openai: { ...prev.openai!, baseUrl: e.target.value } + }))} placeholder="https://api.openai.com/v1 (default)" - className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20" + className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all" />

- Leave empty to use the default OpenAI API. Set a custom URL for proxies or - compatible APIs. + Leave empty to use the default OpenAI API. Set a custom URL for proxies or compatible APIs.

@@ -505,21 +457,19 @@ export const SettingsPanel = ({ helperLink: 'https://aistudio.google.com/app/apikey', helperLinkLabel: 'Google AI Studio', isVisible: !!showApiKey['gemini'], - onChange: (value) => - setSettings((prev) => ({ - ...prev, - gemini: { ...prev.gemini!, apiKey: value }, - })), + onChange: (value) => setSettings(prev => ({ + ...prev, + gemini: { ...prev.gemini!, apiKey: value } + })), onToggleVisibility: () => toggleApiKeyVisibility('gemini'), }} model={{ value: settings.gemini?.model ?? 'gemini-2.0-flash', placeholder: 'e.g., gemini-2.0-flash, gemini-1.5-pro', - onChange: (value) => - setSettings((prev) => ({ - ...prev, - gemini: { ...prev.gemini!, model: value }, - })), + onChange: (value) => setSettings(prev => ({ + ...prev, + gemini: { ...prev.gemini!, model: value } + })), }} /> )} @@ -535,76 +485,66 @@ export const SettingsPanel = ({ helperLink: 'https://console.anthropic.com/settings/keys', helperLinkLabel: 'Anthropic Console', isVisible: !!showApiKey['anthropic'], - onChange: (value) => - setSettings((prev) => ({ - ...prev, - anthropic: { ...prev.anthropic!, apiKey: value }, - })), + onChange: (value) => setSettings(prev => ({ + ...prev, + anthropic: { ...prev.anthropic!, apiKey: value } + })), onToggleVisibility: () => toggleApiKeyVisibility('anthropic'), }} model={{ value: settings.anthropic?.model ?? 'claude-sonnet-4-20250514', placeholder: 'e.g., claude-sonnet-4-20250514, claude-3-opus', - onChange: (value) => - setSettings((prev) => ({ - ...prev, - anthropic: { ...prev.anthropic!, model: value }, - })), + onChange: (value) => setSettings(prev => ({ + ...prev, + anthropic: { ...prev.anthropic!, model: value } + })), }} /> )} {/* Azure OpenAI Settings */} {settings.activeProvider === 'azure-openai' && ( -
+
- setSettings((prev) => ({ - ...prev, - azureOpenAI: { ...prev.azureOpenAI!, apiKey: e.target.value }, - })) - } + onChange={e => setSettings(prev => ({ + ...prev, + azureOpenAI: { ...prev.azureOpenAI!, apiKey: e.target.value } + }))} placeholder="Enter your Azure OpenAI API key" - className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 pr-12 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20" + className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all" />
- setSettings((prev) => ({ - ...prev, - azureOpenAI: { ...prev.azureOpenAI!, endpoint: e.target.value }, - })) - } + onChange={e => setSettings(prev => ({ + ...prev, + azureOpenAI: { ...prev.azureOpenAI!, endpoint: e.target.value } + }))} placeholder="https://your-resource.openai.azure.com" - className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20" + className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all" />
@@ -613,14 +553,12 @@ export const SettingsPanel = ({ - setSettings((prev) => ({ - ...prev, - azureOpenAI: { ...prev.azureOpenAI!, deploymentName: e.target.value }, - })) - } + onChange={e => setSettings(prev => ({ + ...prev, + azureOpenAI: { ...prev.azureOpenAI!, deploymentName: e.target.value } + }))} placeholder="e.g., gpt-4o-deployment" - className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20" + className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all" />
@@ -630,14 +568,12 @@ export const SettingsPanel = ({ - setSettings((prev) => ({ - ...prev, - azureOpenAI: { ...prev.azureOpenAI!, model: e.target.value }, - })) - } + onChange={e => setSettings(prev => ({ + ...prev, + azureOpenAI: { ...prev.azureOpenAI!, model: e.target.value } + }))} placeholder="gpt-4o" - className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20" + className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all" />
@@ -646,14 +582,12 @@ export const SettingsPanel = ({ - setSettings((prev) => ({ - ...prev, - azureOpenAI: { ...prev.azureOpenAI!, apiVersion: e.target.value }, - })) - } + onChange={e => setSettings(prev => ({ + ...prev, + azureOpenAI: { ...prev.azureOpenAI!, apiVersion: e.target.value } + }))} placeholder="2024-08-01-preview" - className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20" + className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all" />
@@ -674,10 +608,10 @@ export const SettingsPanel = ({ {/* Ollama Settings */} {settings.activeProvider === 'ollama' && ( -
+
{/* How to run Ollama */} -
-

+

+

📋 Quick Start: Install Ollama from{' '} ollama.ai - - , then run: + , then run:

- + ollama serve
- setSettings((prev) => ({ - ...prev, - ollama: { ...prev.ollama!, baseUrl: e.target.value }, - })) - } + onChange={e => setSettings(prev => ({ + ...prev, + ollama: { ...prev.ollama!, baseUrl: e.target.value } + }))} placeholder={DEFAULT_OLLAMA_BASE_URL} - className="flex-1 rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20" + className="flex-1 px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm" />

- Default port is 11434. + Default port is 11434.

@@ -733,9 +662,9 @@ export const SettingsPanel = ({ {ollamaError && !isCheckingOllama && ( -
-

- +

+

+ {ollamaError}

@@ -744,18 +673,15 @@ export const SettingsPanel = ({ - setSettings((prev) => ({ - ...prev, - ollama: { ...prev.ollama!, model: e.target.value }, - })) - } + onChange={e => setSettings(prev => ({ + ...prev, + ollama: { ...prev.ollama!, model: e.target.value } + }))} placeholder="e.g., llama3.2, mistral, codellama" - className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20" + className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm" />

- Pull a model with{' '} - ollama pull llama3.2 + Pull a model with ollama pull llama3.2

@@ -772,11 +698,10 @@ export const SettingsPanel = ({ helperLink: 'https://openrouter.ai/keys', helperLinkLabel: 'OpenRouter Keys', isVisible: !!showApiKey['openrouter'], - onChange: (value) => - setSettings((prev) => ({ - ...prev, - openrouter: { ...prev.openrouter!, apiKey: value }, - })), + onChange: (value) => setSettings(prev => ({ + ...prev, + openrouter: { ...prev.openrouter!, apiKey: value } + })), onToggleVisibility: () => toggleApiKeyVisibility('openrouter'), }} > @@ -784,12 +709,10 @@ export const SettingsPanel = ({ - setSettings((prev) => ({ - ...prev, - openrouter: { ...prev.openrouter!, model }, - })) - } + onChange={(model) => setSettings(prev => ({ + ...prev, + openrouter: { ...prev.openrouter!, model } + }))} models={openRouterModels} isLoading={isLoadingModels} onLoadModels={loadOpenRouterModels} @@ -820,21 +743,19 @@ export const SettingsPanel = ({ helperLink: 'https://platform.minimax.io', helperLinkLabel: 'MiniMax Platform', isVisible: !!showApiKey['minimax'], - onChange: (value) => - setSettings((prev) => ({ - ...prev, - minimax: { ...prev.minimax!, apiKey: value }, - })), + onChange: (value) => setSettings(prev => ({ + ...prev, + minimax: { ...prev.minimax!, apiKey: value } + })), onToggleVisibility: () => toggleApiKeyVisibility('minimax'), }} model={{ value: settings.minimax?.model ?? 'MiniMax-M2.5', placeholder: 'e.g., MiniMax-M2.5, MiniMax-M2.5-highspeed', - onChange: (value) => - setSettings((prev) => ({ - ...prev, - minimax: { ...prev.minimax!, model: value }, - })), + onChange: (value) => setSettings(prev => ({ + ...prev, + minimax: { ...prev.minimax!, model: value } + })), helperText: 'Available: MiniMax-M2.5 (default), MiniMax-M2.5-highspeed (faster)', }} /> @@ -842,35 +763,29 @@ export const SettingsPanel = ({ {/* GLM Settings */} {settings.activeProvider === 'glm' && ( -
+
- setSettings((prev) => ({ - ...prev, - glm: { ...prev.glm!, apiKey: e.target.value }, - })) - } + onChange={e => setSettings(prev => ({ + ...prev, + glm: { ...prev.glm!, apiKey: e.target.value } + }))} placeholder="Enter your Z.AI API key" - className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 pr-12 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20" + className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all" />

@@ -890,18 +805,14 @@ export const SettingsPanel = ({

@@ -911,14 +822,12 @@ export const SettingsPanel = ({ - setSettings((prev) => ({ - ...prev, - glm: { ...prev.glm!, baseUrl: e.target.value }, - })) - } + onChange={e => setSettings(prev => ({ + ...prev, + glm: { ...prev.glm!, baseUrl: e.target.value } + }))} placeholder="https://api.z.ai/api/coding/paas/v4" - className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20" + className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm" />

Coding API (default). Use https://api.z.ai/api/paas/v4 for the general API. @@ -928,33 +837,33 @@ export const SettingsPanel = ({ )} {/* Privacy Note */} -

+
-
+
🔒
-
- Privacy: Your API keys are - stored only in your browser's session storage and are cleared when the tab closes. - They're sent directly to the LLM provider when you chat. Your code never leaves your - machine. +
+ Privacy:{' '} + {localOnlyMode + ? 'Local-only mode is ON. Only local provider endpoints should be used. API keys remain in browser session storage and are cleared when the tab closes.' + : 'API keys are stored only in browser session storage and are cleared when the tab closes. If you use remote providers, prompts and selected code context may leave your machine.'}
{/* Footer */} -
+
{saveStatus === 'saved' && ( - - + + Settings saved )} {saveStatus === 'error' && ( - - + + Failed to save )} @@ -962,13 +871,13 @@ export const SettingsPanel = ({
@@ -978,3 +887,4 @@ export const SettingsPanel = ({
); }; + diff --git a/gitnexus-web/src/config/security-mode.ts b/gitnexus-web/src/config/security-mode.ts new file mode 100644 index 0000000000..20670d5444 --- /dev/null +++ b/gitnexus-web/src/config/security-mode.ts @@ -0,0 +1,20 @@ +const FALSEY_LOCAL_ONLY_VALUES = new Set(['', '0', 'false']); + +export const isLocalOnlyEnabled = (value: string | undefined): boolean => { + if (value === undefined) return false; + return !FALSEY_LOCAL_ONLY_VALUES.has(value.trim().toLowerCase()); +}; + +export const isLocalOnlyMode = (): boolean => ( + isLocalOnlyEnabled(import.meta.env.VITE_GITNEXUS_LOCAL_ONLY) +); + +export const isLoopbackUrl = (rawUrl: string | undefined): boolean => { + if (!rawUrl) return false; + try { + const parsed = new URL(rawUrl); + return parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '::1'; + } catch { + return false; + } +}; diff --git a/gitnexus-web/src/core/llm/agent.ts b/gitnexus-web/src/core/llm/agent.ts index c8cfa8d7cc..21eeeed470 100644 --- a/gitnexus-web/src/core/llm/agent.ts +++ b/gitnexus-web/src/core/llm/agent.ts @@ -27,6 +27,7 @@ import type { } from './types'; import { type CodebaseContext, buildDynamicSystemPrompt } from './context-builder'; import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../config/ui-constants'; +import { isLocalOnlyMode, isLoopbackUrl } from '../../config/security-mode'; /** * System prompt for the Graph RAG agent @@ -125,6 +126,34 @@ BAD: A[User's Data] --> B(Process & Save) GOOD: A["User Data"] --> B["Process and Save"] `; export const createChatModel = (config: ProviderConfig): BaseChatModel => { + if (isLocalOnlyMode()) { + switch (config.provider) { + case 'ollama': { + const baseUrl = (config as OllamaConfig).baseUrl ?? DEFAULT_OLLAMA_BASE_URL; + if (!isLoopbackUrl(baseUrl)) { + throw new Error(`Local-only mode requires Ollama base URL to be local (got: ${baseUrl})`); + } + break; + } + case 'openai': { + const baseUrl = (config as OpenAIConfig).baseUrl; + if (!isLoopbackUrl(baseUrl)) { + throw new Error('Local-only mode only allows OpenAI-compatible endpoints on localhost/127.0.0.1/::1'); + } + break; + } + case 'glm': { + const baseUrl = (config as GLMConfig).baseUrl; + if (!isLoopbackUrl(baseUrl)) { + throw new Error('Local-only mode only allows GLM endpoints on localhost/127.0.0.1/::1'); + } + break; + } + default: + throw new Error(`Local-only mode does not allow provider: ${config.provider}`); + } + } + switch (config.provider) { case 'openai': { const openaiConfig = config as OpenAIConfig; diff --git a/gitnexus-web/src/core/llm/settings-service.ts b/gitnexus-web/src/core/llm/settings-service.ts index 5e49cb7af7..d8e62f6031 100644 --- a/gitnexus-web/src/core/llm/settings-service.ts +++ b/gitnexus-web/src/core/llm/settings-service.ts @@ -20,6 +20,7 @@ import { ProviderConfig, } from './types'; import { DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OLLAMA_BASE_URL } from '../../config/ui-constants'; +import { isLocalOnlyMode, isLoopbackUrl } from '../../config/security-mode'; const STORAGE_KEY = 'gitnexus-llm-settings'; @@ -319,6 +320,20 @@ const providerBuilders: Record = { export const getActiveProviderConfig = (): ProviderConfig | null => { const settings = loadSettings(); + if (isLocalOnlyMode()) { + if (settings.activeProvider === 'ollama') { + const baseUrl = settings.ollama?.baseUrl ?? DEFAULT_OLLAMA_BASE_URL; + if (!isLoopbackUrl(baseUrl)) return null; + } else if (settings.activeProvider === 'openai') { + const baseUrl = settings.openai?.baseUrl; + if (!isLoopbackUrl(baseUrl)) return null; + } else if (settings.activeProvider === 'glm') { + const baseUrl = settings.glm?.baseUrl; + if (!isLoopbackUrl(baseUrl)) return null; + } else { + return null; + } + } const builder = providerBuilders[settings.activeProvider]; return builder ? builder(settings) : null; }; diff --git a/gitnexus-web/src/services/git-clone.ts b/gitnexus-web/src/services/git-clone.ts new file mode 100644 index 0000000000..b0fe79e539 --- /dev/null +++ b/gitnexus-web/src/services/git-clone.ts @@ -0,0 +1,221 @@ +import git from 'isomorphic-git'; +import http from 'isomorphic-git/http/web'; +import LightningFS from '@isomorphic-git/lightning-fs'; +import { shouldIgnorePath } from '../config/ignore-service'; +import { isLocalOnlyMode } from '../config/security-mode'; +import { FileEntry } from './zip'; + +// Initialize virtual filesystem (persists in IndexedDB) +// Use a unique name each time to avoid stale data issues +let fs: LightningFS; +let pfs: any; + +const initFS = () => { + // Create a fresh filesystem instance + const fsName = `gitnexus-git-${Date.now()}`; + fs = new LightningFS(fsName); + pfs = fs.promises; + return fsName; +}; + +// Hosted proxy URL - use this for localhost to avoid local proxy issues +const HOSTED_PROXY_URL = 'https://gitnexus.vercel.app/api/proxy'; + +/** + * Custom HTTP client that uses a query-param based proxy + * - In development (localhost): uses the hosted Vercel proxy for reliability + * - In production: uses the local /api/proxy endpoint + */ +const createProxiedHttp = (): typeof http => { + const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost'; + const localOnly = isLocalOnlyMode(); + + return { + request: async (config) => { + // In local-only mode, never route through the hosted proxy. + const proxyBase = isDev && !localOnly ? HOSTED_PROXY_URL : '/api/proxy'; + const proxyUrl = `${proxyBase}?url=${encodeURIComponent(config.url)}`; + + // Call the original http.request with the proxied URL + return http.request({ + ...config, + url: proxyUrl, + }); + }, + }; +}; + +/** + * Parse GitHub URL to extract owner and repo + * Supports: + * - https://github.com/owner/repo + * - https://github.com/owner/repo.git + * - github.com/owner/repo + */ +export const parseGitHubUrl = (url: string): { owner: string; repo: string } | null => { + const cleaned = url.trim().replace(/\.git$/, ''); + const match = cleaned.match(/github\.com\/([^\/]+)\/([^\/]+)/); + + if (!match) return null; + + return { + owner: match[1], + repo: match[2], + }; +}; + +/** + * Clone a GitHub repository using isomorphic-git + * Returns files in the same format as extractZip for compatibility + * + * @param url - GitHub repository URL + * @param onProgress - Progress callback + * @param token - Optional GitHub PAT for private repos (sent only to trusted GitHub hosts via proxy) + */ +export const cloneRepository = async ( + url: string, + onProgress?: (phase: string, progress: number) => void, + token?: string +): Promise => { + const parsed = parseGitHubUrl(url); + if (!parsed) { + throw new Error('Invalid GitHub URL. Use format: https://github.com/owner/repo'); + } + + // Initialize fresh filesystem to avoid stale IndexedDB data + const fsName = initFS(); + + const dir = `/${parsed.repo}`; + const repoUrl = `https://github.com/${parsed.owner}/${parsed.repo}.git`; + + try { + onProgress?.('cloning', 0); + + const httpClient = createProxiedHttp(); + + // Clone with shallow depth for speed + await git.clone({ + fs, + http: httpClient, + dir, + url: repoUrl, + depth: 1, + // Auth callback for private repos (PAT forwarded only via configured proxy path) + onAuth: token ? () => ({ username: token, password: 'x-oauth-basic' }) : undefined, + onProgress: (event) => { + if (event.total) { + const percent = Math.round((event.loaded / event.total) * 100); + onProgress?.('cloning', percent); + } + }, + }); + + onProgress?.('reading', 0); + + // Read all files from the cloned repo + const files = await readAllFiles(dir, dir); + + // Cleanup: remove the cloned repo from virtual FS to save space + await removeDirectory(dir); + + // Also try to clean up the IndexedDB database + try { + indexedDB.deleteDatabase(fsName); + } catch {} + + onProgress?.('complete', 100); + + return files; + } catch (error) { + // Cleanup on error + try { + await removeDirectory(dir); + indexedDB.deleteDatabase(fsName); + } catch {} + + throw error; + } +}; + +/** + * Recursively read all files from a directory in the virtual filesystem + */ +const readAllFiles = async (baseDir: string, currentDir: string): Promise => { + const files: FileEntry[] = []; + + let entries: string[]; + try { + entries = await pfs.readdir(currentDir); + } catch (err) { + // Directory might not exist or be inaccessible + console.warn(`Cannot read directory: ${currentDir}`); + return files; + } + + for (const entry of entries) { + // Skip .git directory + if (entry === '.git') continue; + + const fullPath = `${currentDir}/${entry}`; + const relativePath = fullPath.replace(`${baseDir}/`, ''); + + // Check ignore rules + if (shouldIgnorePath(relativePath)) continue; + + // Try to stat the file - skip if it fails (broken symlinks, etc.) + let stat; + try { + stat = await pfs.stat(fullPath); + } catch { + // Skip files that can't be stat'd (broken symlinks, permission issues) + if (import.meta.env.DEV) { + console.warn(`Skipping unreadable entry: ${relativePath}`); + } + continue; + } + + if (stat.isDirectory()) { + // Recurse into subdirectory + const subFiles = await readAllFiles(baseDir, fullPath); + files.push(...subFiles); + } else { + // Read file content + try { + const content = await pfs.readFile(fullPath, { encoding: 'utf8' }) as string; + files.push({ + path: relativePath, + content, + }); + } catch { + // Skip binary files or files that can't be read as text + } + } + } + + return files; +}; + +/** + * Recursively remove a directory from the virtual filesystem + */ +const removeDirectory = async (dir: string): Promise => { + try { + const entries = await pfs.readdir(dir); + + for (const entry of entries) { + const fullPath = `${dir}/${entry}`; + const stat = await pfs.stat(fullPath); + + if (stat.isDirectory()) { + await removeDirectory(fullPath); + } else { + await pfs.unlink(fullPath); + } + } + + await pfs.rmdir(dir); + } catch { + // Ignore errors during cleanup + } +}; + diff --git a/gitnexus-web/test/unit/security-mode.test.ts b/gitnexus-web/test/unit/security-mode.test.ts new file mode 100644 index 0000000000..5fcd5448f0 --- /dev/null +++ b/gitnexus-web/test/unit/security-mode.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { isLocalOnlyEnabled } from '../../src/config/security-mode'; + +describe('web security-mode', () => { + it('treats unset value as disabled', () => { + expect(isLocalOnlyEnabled(undefined)).toBe(false); + }); + + it('treats explicit falsey values as disabled', () => { + expect(isLocalOnlyEnabled('')).toBe(false); + expect(isLocalOnlyEnabled('0')).toBe(false); + expect(isLocalOnlyEnabled('false')).toBe(false); + expect(isLocalOnlyEnabled('FALSE')).toBe(false); + expect(isLocalOnlyEnabled(' false ')).toBe(false); + }); + + it('treats explicit truthy values as enabled', () => { + expect(isLocalOnlyEnabled('1')).toBe(true); + expect(isLocalOnlyEnabled('true')).toBe(true); + expect(isLocalOnlyEnabled('yes')).toBe(true); + }); +}); diff --git a/gitnexus-web/test/unit/settings-service.test.ts b/gitnexus-web/test/unit/settings-service.test.ts index 17514725c7..c87983eeab 100644 --- a/gitnexus-web/test/unit/settings-service.test.ts +++ b/gitnexus-web/test/unit/settings-service.test.ts @@ -96,7 +96,11 @@ describe('getActiveProviderConfig', () => { it('returns config for openai when API key is set', () => { const settings = loadSettings(); settings.activeProvider = 'openai'; - settings.openai = { ...settings.openai, apiKey: 'sk-test-123' }; + settings.openai = { + ...settings.openai, + apiKey: 'sk-test-123', + baseUrl: 'http://127.0.0.1:11434/v1', + }; saveSettings(settings); const config = getActiveProviderConfig(); diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 805c55968d..e0ede139d7 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -47,6 +47,7 @@ program .description('Start local HTTP server for web UI connection') .option('-p, --port ', 'Port number', '4747') .option('--host ', 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)') + .option('--local-only', 'Strict local mode: loopback-only host and localhost-only CORS') .action(createLazyAction(() => import('./serve.js'), 'serveCommand')); program @@ -93,6 +94,7 @@ program .option('--no-reasoning-model', 'Disable reasoning model mode (overrides saved config)') .option('--concurrency ', 'Parallel LLM calls (default: 3)', '3') .option('--gist', 'Publish wiki as a public GitHub Gist after generation') + .option('--local-only', 'Strict local mode: local LLM endpoint only and gist publishing disabled') .option('-v, --verbose', 'Enable verbose output (show LLM commands and responses)') .option('--review', 'Stop after grouping to review module structure before generating pages') .action(createLazyAction(() => import('./wiki.js'), 'wikiCommand')); diff --git a/gitnexus/src/cli/serve.ts b/gitnexus/src/cli/serve.ts index 499831e547..252c2de518 100644 --- a/gitnexus/src/cli/serve.ts +++ b/gitnexus/src/cli/serve.ts @@ -1,34 +1,9 @@ import { createServer } from '../server/api.js'; +import { isLocalOnlyMode } from '../config/security-mode.js'; -// Catch anything that would cause a silent exit -process.on('uncaughtException', (err) => { - console.error('\n[gitnexus serve] Uncaught exception:', err.message); - if (process.env.DEBUG) console.error(err.stack); - process.exit(1); -}); -process.on('unhandledRejection', (reason: any) => { - console.error('\n[gitnexus serve] Unhandled rejection:', reason?.message || reason); - if (process.env.DEBUG) console.error(reason?.stack); - process.exit(1); -}); - -export const serveCommand = async (options?: { port?: string; host?: string }) => { +export const serveCommand = async (options?: { port?: string; host?: string; localOnly?: boolean }) => { const port = Number(options?.port ?? 4747); const host = options?.host ?? '127.0.0.1'; - - try { - await createServer(port, host); - } catch (err: any) { - console.error(`\nFailed to start GitNexus server:\n`); - console.error(` ${err.message || err}\n`); - if (err.code === 'EADDRINUSE') { - console.error(` Port ${port} is already in use. Either:`); - console.error(` 1. Stop the other process using port ${port}`); - console.error(` 2. Use a different port: gitnexus serve --port 4748\n`); - } - if (err.stack && process.env.DEBUG) { - console.error(err.stack); - } - process.exit(1); - } + const localOnly = !!options?.localOnly || isLocalOnlyMode(); + await createServer(port, host, { localOnly }); }; diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index ccd1cae4ec..4cd9b47099 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -1,23 +1,18 @@ /** * Wiki Command - * + * * Generates repository documentation from the knowledge graph. * Usage: gitnexus wiki [path] [options] */ import path from 'path'; import readline from 'readline'; -import { execSync, execFileSync } from 'child_process'; +import { execSync, execFileSync, spawnSync } from 'child_process'; import cliProgress from 'cli-progress'; import { getGitRoot, isGitRepo } from '../storage/git.js'; -import { - getStoragePaths, - loadMeta, - loadCLIConfig, - saveCLIConfig, -} from '../storage/repo-manager.js'; +import { getStoragePaths, loadMeta, loadCLIConfig, saveCLIConfig } from '../storage/repo-manager.js'; import { WikiGenerator, type WikiOptions } from '../core/wiki/generator.js'; -import { resolveLLMConfig, type LLMProvider } from '../core/wiki/llm-client.js'; +import { resolveLLMConfig, type LLMProvider, isLocalOnlyMode, isLoopbackUrl } from '../core/wiki/llm-client.js'; import { detectCursorCLI } from '../core/wiki/cursor-client.js'; export interface WikiCommandOptions { @@ -32,6 +27,50 @@ export interface WikiCommandOptions { provider?: LLMProvider; verbose?: boolean; review?: boolean; + localOnly?: boolean; +} + +function parseCommandArgs(command: string): string[] { + const tokens: string[] = []; + let current = ''; + let quote: '"' | "'" | null = null; + let escaping = false; + + for (const ch of command) { + if (escaping) { + current += ch; + escaping = false; + continue; + } + if (ch === '\\') { + escaping = true; + continue; + } + if (quote) { + if (ch === quote) { + quote = null; + } else { + current += ch; + } + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (/\s/.test(ch)) { + if (current.length > 0) { + tokens.push(current); + current = ''; + } + continue; + } + current += ch; + } + + if (escaping) current += '\\'; + if (current.length > 0) tokens.push(current); + return tokens; } /** @@ -85,7 +124,12 @@ function prompt(question: string, hide = false): Promise { }); } -export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptions) => { +export const wikiCommand = async ( + inputPath?: string, + options?: WikiCommandOptions, +) => { + const localOnly = !!options?.localOnly || isLocalOnlyMode(); + // Set verbose mode globally for cursor-client to pick up if (options?.verbose) { process.env.GITNEXUS_VERBOSE = '1'; @@ -176,6 +220,20 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio isReasoningModel: options?.reasoningModel, }); + if (localOnly) { + if (llmConfig.provider === 'cursor') { + console.log(' Error: --local-only does not allow Cursor provider (networked service).\n'); + process.exitCode = 1; + return; + } + if (!isLoopbackUrl(llmConfig.baseUrl)) { + console.log(` Error: --local-only requires a local LLM endpoint (got: ${llmConfig.baseUrl}).`); + console.log(' Example: --base-url http://localhost:11434/v1\n'); + process.exitCode = 1; + return; + } + } + // Run interactive setup if no saved config and no CLI flags provided // (even if env vars exist — let user explicitly choose their provider) if (!hasSavedConfig && !hasCLIOverrides) { @@ -346,20 +404,31 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio } } + if (localOnly) { + if (llmConfig.provider === 'cursor') { + console.log(' Error: --local-only does not allow Cursor provider (networked service).\n'); + process.exitCode = 1; + return; + } + if (!isLoopbackUrl(llmConfig.baseUrl)) { + console.log(` Error: --local-only requires a local LLM endpoint (got: ${llmConfig.baseUrl}).`); + console.log(' Example: --base-url http://localhost:11434/v1\n'); + process.exitCode = 1; + return; + } + } + // ── Setup progress bar with elapsed timer ────────────────────────── - const bar = new cliProgress.SingleBar( - { - format: ' {bar} {percentage}% | {phase}', - barCompleteChar: '\u2588', - barIncompleteChar: '\u2591', - hideCursor: true, - barGlue: '', - autopadding: true, - clearOnComplete: false, - stopOnComplete: false, - }, - cliProgress.Presets.shades_grey, - ); + const bar = new cliProgress.SingleBar({ + format: ' {bar} {percentage}% | {phase}', + barCompleteChar: '\u2588', + barIncompleteChar: '\u2591', + hideCursor: true, + barGlue: '', + autopadding: true, + clearOnComplete: false, + stopOnComplete: false, + }, cliProgress.Presets.shades_grey); bar.start(100, 0, { phase: 'Initializing...' }); @@ -416,18 +485,13 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio if (options?.review && result.moduleTree) { console.log(`\n Module structure ready for review (${elapsed}s)\n`); console.log(' Modules to generate:\n'); - + const printTree = (nodes: typeof result.moduleTree, indent = 0) => { for (const node of nodes) { const prefix = ' '.repeat(indent + 2); const fileCount = node.files?.length || 0; const childCount = node.children?.length || 0; - const suffix = - fileCount > 0 - ? ` (${fileCount} files)` - : childCount > 0 - ? ` (${childCount} children)` - : ''; + const suffix = fileCount > 0 ? ` (${fileCount} files)` : childCount > 0 ? ` (${childCount} children)` : ''; console.log(`${prefix}- ${node.name}${suffix}`); if (node.children && node.children.length > 0) { printTree(node.children, indent + 1); @@ -435,10 +499,10 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio } }; printTree(result.moduleTree); - + console.log(`\n Tree saved to: ${treeFile}`); console.log(' You can edit this file to remove/rename modules.\n'); - + // Ask for confirmation (auto-continue in non-interactive environments) if (!process.stdin.isTTY) { console.log(' Non-interactive mode — auto-continuing with generation.\n'); @@ -447,37 +511,49 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio ? await prompt(' Continue with generation? (Y/n/edit): ') : 'y'; const choice = answer.trim().toLowerCase(); - + if (choice === 'n' || choice === 'no') { console.log('\n Generation cancelled. Run `gitnexus wiki` later to generate.\n'); return; } - + if (choice === 'edit' || choice === 'e') { // Open editor for the user const editor = process.env.EDITOR || process.env.VISUAL || 'vi'; + const editorParts = parseCommandArgs(editor); + const editorCmd = editorParts[0]; + const editorArgs = editorParts.slice(1); console.log(`\n Opening ${treeFile} in ${editor}...`); console.log(' Save and close the editor when done.\n'); - + try { - execFileSync(editor, [treeFile], { stdio: 'inherit' }); + if (!editorCmd) { + throw new Error('No editor command provided'); + } + const result = spawnSync(editorCmd, [...editorArgs, treeFile], { + stdio: 'inherit', + shell: false, + }); + if (result.error || result.status !== 0) { + throw result.error ?? new Error(`Editor exited with code ${result.status}`); + } } catch { console.log(` Could not open editor. Please edit manually:\n ${treeFile}\n`); console.log(' Then run `gitnexus wiki` to continue.\n'); return; } } - + // Continue with generation using the (possibly edited) tree console.log('\n Continuing with wiki generation...\n'); bar.start(100, 30, { phase: 'Generating pages...' }); - + // Re-run generator without reviewOnly flag const continueOptions: WikiOptions = { ...wikiOptions, reviewOnly: false, }; - + const continueGenerator = new WikiGenerator( repoPath, storagePath, @@ -493,28 +569,28 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio bar.update(percent, { phase: label }); }, ); - + const continueResult = await continueGenerator.run(); - + bar.update(100, { phase: 'Done' }); bar.stop(); - + const totalElapsed = ((Date.now() - t0) / 1000).toFixed(1); console.log(`\n Wiki generated successfully (${totalElapsed}s)\n`); console.log(` Mode: ${continueResult.mode}`); console.log(` Pages: ${continueResult.pagesGenerated}`); console.log(` Output: ${wikiDir}`); console.log(` Viewer: ${viewerPath}`); - + if (continueResult.failedModules && continueResult.failedModules.length > 0) { console.log(`\n Failed modules (${continueResult.failedModules.length}):`); for (const mod of continueResult.failedModules) { console.log(` - ${mod}`); } } - + console.log(''); - await maybePublishGist(viewerPath, options?.gist); + await maybePublishGist(viewerPath, localOnly ? false : options?.gist); return; } @@ -523,7 +599,7 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio if (result.mode === 'up-to-date' && !options?.force) { console.log('\n Wiki is already up to date.'); console.log(` Viewer: ${viewerPath}\n`); - await maybePublishGist(viewerPath, options?.gist); + await maybePublishGist(viewerPath, localOnly ? false : options?.gist); return; } @@ -543,7 +619,7 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio console.log(''); - await maybePublishGist(viewerPath, options?.gist); + await maybePublishGist(viewerPath, localOnly ? false : options?.gist); } catch (err: any) { clearInterval(elapsedTimer); bar.stop(); @@ -560,19 +636,13 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio console.log(`\n LLM Error: ${err.message}\n`); // Offer to reconfigure on auth-related failures - const isAuthError = - err.message?.includes('401') || - err.message?.includes('403') || - err.message?.includes('502') || - err.message?.includes('authenticate') || - err.message?.includes('Unauthorized'); + const isAuthError = err.message?.includes('401') || err.message?.includes('403') + || err.message?.includes('502') || err.message?.includes('authenticate') + || err.message?.includes('Unauthorized'); if (isAuthError && process.stdin.isTTY) { const answer = await new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - rl.question(' Reconfigure LLM settings? (Y/n): ', (ans) => { - rl.close(); - resolve(ans.trim().toLowerCase()); - }); + rl.question(' Reconfigure LLM settings? (Y/n): ', (ans) => { rl.close(); resolve(ans.trim().toLowerCase()); }); }); if (!answer || answer === 'y' || answer === 'yes') { // Clear saved config so next run triggers interactive setup @@ -603,15 +673,15 @@ function hasGhCLI(): boolean { function publishGist(htmlPath: string): { url: string; rawUrl: string } | null { try { - const output = execFileSync( - 'gh', - ['gist', 'create', htmlPath, '--desc', 'Repository Wiki — generated by GitNexus', '--public'], - { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, - ).trim(); + const output = execFileSync('gh', [ + 'gist', 'create', htmlPath, + '--desc', 'Repository Wiki — generated by GitNexus', + '--public', + ], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); // gh gist create prints the gist URL as the last line const lines = output.split('\n'); - const gistUrl = lines.find((l) => l.includes('gist.github.com')) || lines[lines.length - 1]; + const gistUrl = lines.find(l => l.includes('gist.github.com')) || lines[lines.length - 1]; if (!gistUrl || !gistUrl.includes('gist.github.com')) return null; diff --git a/gitnexus/src/config/security-mode.ts b/gitnexus/src/config/security-mode.ts new file mode 100644 index 0000000000..4cbd51d2c5 --- /dev/null +++ b/gitnexus/src/config/security-mode.ts @@ -0,0 +1,10 @@ +const FALSEY_LOCAL_ONLY_VALUES = new Set(['', '0', 'false']); + +export const isLocalOnlyEnabled = (value: string | undefined): boolean => { + if (value === undefined) return false; + return !FALSEY_LOCAL_ONLY_VALUES.has(value.trim().toLowerCase()); +}; + +export const isLocalOnlyMode = (): boolean => ( + isLocalOnlyEnabled(process.env.GITNEXUS_LOCAL_ONLY) +); diff --git a/gitnexus/src/core/wiki/html-viewer.ts b/gitnexus/src/core/wiki/html-viewer.ts index f961d36ae1..0b5682bb78 100644 --- a/gitnexus/src/core/wiki/html-viewer.ts +++ b/gitnexus/src/core/wiki/html-viewer.ts @@ -7,6 +7,7 @@ import fs from 'fs/promises'; import path from 'path'; +import { isLocalOnlyMode } from '../../config/security-mode.js'; interface ModuleTreeNode { name: string; @@ -18,29 +19,28 @@ interface ModuleTreeNode { /** * Generate the wiki HTML viewer (index.html) from existing markdown pages. */ -export async function generateHTMLViewer(wikiDir: string, projectName: string): Promise { +export async function generateHTMLViewer( + wikiDir: string, + projectName: string, +): Promise { // Load module tree let moduleTree: ModuleTreeNode[] = []; try { const raw = await fs.readFile(path.join(wikiDir, 'module_tree.json'), 'utf-8'); moduleTree = JSON.parse(raw); - } catch { - /* will show empty nav */ - } + } catch { /* will show empty nav */ } // Load meta let meta: Record | null = null; try { const raw = await fs.readFile(path.join(wikiDir, 'meta.json'), 'utf-8'); meta = JSON.parse(raw); - } catch { - /* no meta */ - } + } catch { /* no meta */ } // Read all markdown files into a { slug: content } map const pages: Record = {}; const dirEntries = await fs.readdir(wikiDir); - for (const f of dirEntries.filter((f) => f.endsWith('.md'))) { + for (const f of dirEntries.filter(f => f.endsWith('.md'))) { const content = await fs.readFile(path.join(wikiDir, f), 'utf-8'); pages[f.replace(/\.md$/, '')] = content; } @@ -61,18 +61,26 @@ function esc(text: string): string { .replace(/"/g, '"'); } +function serializeForInlineScript(value: unknown): string { + return JSON.stringify(value) + .replace(//g, '\\u003e') + .replace(/&/g, '\\u0026') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); +} + function buildHTML( projectName: string, moduleTree: ModuleTreeNode[], pages: Record, meta: Record | null, ): string { - // Embed data as JSON inside the HTML. - // Escape sequences so they don't prematurely close the