mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-21 00:21:30 +00:00
* feat(wiki): allow explicit HTTP LLM hosts Keep wiki LLM HTTP endpoints fail-closed by default while adding a narrow exact-host opt-in for LAN/self-hosted models. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(wiki): simplify insecure LLM flag name Rename the wiki HTTP opt-in flag to --allow-insecure-connection per review feedback. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(wiki): simplify insecure connection env Rename the wiki HTTP allowlist environment variable and align validation errors with the CLI flag naming. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
839 lines
28 KiB
TypeScript
839 lines
28 KiB
TypeScript
/**
|
|
* 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 cliProgress from 'cli-progress';
|
|
import { getGitRoot, isGitRepo } from '../storage/git.js';
|
|
import {
|
|
getStoragePaths,
|
|
loadMeta,
|
|
loadCLIConfig,
|
|
saveCLIConfig,
|
|
} from '../storage/repo-manager.js';
|
|
import { WikiGenerator, type WikiOptions } from '../core/wiki/generator.js';
|
|
import {
|
|
parseLLMAllowedInsecureHttpHosts,
|
|
resolveLLMConfig,
|
|
type LLMProvider,
|
|
} from '../core/wiki/llm-client.js';
|
|
import { detectCursorCLI } from '../core/wiki/cursor-client.js';
|
|
import { detectLocalCLI } from '../core/wiki/local-cli-client.js';
|
|
import { logger } from '../core/logger.js';
|
|
|
|
export interface WikiCommandOptions {
|
|
force?: boolean;
|
|
model?: string;
|
|
baseUrl?: string;
|
|
apiKey?: string;
|
|
apiVersion?: string;
|
|
reasoningModel?: boolean;
|
|
concurrency?: string;
|
|
gist?: boolean;
|
|
provider?: LLMProvider;
|
|
verbose?: boolean;
|
|
review?: boolean;
|
|
timeout?: string;
|
|
retries?: string;
|
|
lang?: string;
|
|
allowInsecureConnection?: string;
|
|
}
|
|
|
|
function parsePositiveIntegerOption(
|
|
value: string | undefined,
|
|
flag: string,
|
|
multiplier = 1,
|
|
): number | undefined {
|
|
if (value === undefined) return undefined;
|
|
const trimmed = value.trim();
|
|
if (!/^[1-9]\d*$/.test(trimmed)) {
|
|
throw new Error(`${flag} must be a positive integer`);
|
|
}
|
|
const parsed = parseInt(trimmed, 10);
|
|
if (parsed > Math.floor(Number.MAX_SAFE_INTEGER / multiplier)) {
|
|
throw new Error(`${flag} is too large`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function isLocalProvider(
|
|
provider: LLMProvider | undefined,
|
|
): provider is 'cursor' | 'claude' | 'codex' | 'opencode' {
|
|
return (
|
|
provider === 'cursor' ||
|
|
provider === 'claude' ||
|
|
provider === 'codex' ||
|
|
provider === 'opencode'
|
|
);
|
|
}
|
|
|
|
function localModelConfigKey(provider: 'cursor' | 'claude' | 'codex' | 'opencode') {
|
|
if (provider === 'cursor') return 'cursorModel';
|
|
if (provider === 'claude') return 'claudeModel';
|
|
if (provider === 'codex') return 'codexModel';
|
|
if (provider === 'opencode') return 'opencodeModel';
|
|
throw new Error(`Unsupported local provider: ${provider satisfies never}`);
|
|
}
|
|
|
|
/**
|
|
* Prompt the user for input via stdin.
|
|
*/
|
|
function prompt(question: string, hide = false): Promise<string> {
|
|
return new Promise((resolve) => {
|
|
const rl = readline.createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout,
|
|
});
|
|
|
|
if (hide && process.stdin.isTTY) {
|
|
// Mask input for API keys
|
|
process.stdout.write(question);
|
|
let input = '';
|
|
process.stdin.setRawMode(true);
|
|
process.stdin.resume();
|
|
process.stdin.setEncoding('utf-8');
|
|
|
|
const onData = (char: string) => {
|
|
if (char === '\n' || char === '\r' || char === '\u0004') {
|
|
process.stdin.setRawMode(false);
|
|
process.stdin.removeListener('data', onData);
|
|
process.stdout.write('\n');
|
|
rl.close();
|
|
resolve(input);
|
|
} else if (char === '\u0003') {
|
|
// Ctrl+C
|
|
process.stdin.setRawMode(false);
|
|
rl.close();
|
|
process.exit(1);
|
|
} else if (char === '\u007F' || char === '\b') {
|
|
// Backspace
|
|
if (input.length > 0) {
|
|
input = input.slice(0, -1);
|
|
process.stdout.write('\b \b');
|
|
}
|
|
} else {
|
|
input += char;
|
|
process.stdout.write('*');
|
|
}
|
|
};
|
|
process.stdin.on('data', onData);
|
|
} else {
|
|
rl.question(question, (answer) => {
|
|
rl.close();
|
|
resolve(answer.trim());
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptions) => {
|
|
// Snapshot GITNEXUS_VERBOSE at entry — wikiCommand mutates it (the impl
|
|
// below) so cursor-client (process.env-driven) sees the right value during
|
|
// this run. Restored in finally so back-to-back wiki calls in long-running
|
|
// hosts don't leak verbose state from one invocation to the next. Pairs
|
|
// with the same snapshot/restore pattern in `analyzeCommand`.
|
|
const originalVerbose = process.env.GITNEXUS_VERBOSE;
|
|
try {
|
|
await wikiCommandImpl(inputPath, options);
|
|
} finally {
|
|
if (originalVerbose === undefined) {
|
|
delete process.env.GITNEXUS_VERBOSE;
|
|
} else {
|
|
process.env.GITNEXUS_VERBOSE = originalVerbose;
|
|
}
|
|
}
|
|
};
|
|
|
|
const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions): Promise<void> => {
|
|
// Set verbose mode globally for cursor-client to pick up
|
|
if (options?.verbose) {
|
|
process.env.GITNEXUS_VERBOSE = '1';
|
|
}
|
|
|
|
console.log('\n GitNexus Wiki Generator\n');
|
|
|
|
// ── Resolve repo path ───────────────────────────────────────────────
|
|
let repoPath: string;
|
|
if (inputPath) {
|
|
repoPath = path.resolve(inputPath);
|
|
} else {
|
|
const gitRoot = getGitRoot(process.cwd());
|
|
if (!gitRoot) {
|
|
console.log(' Error: Not inside a git repository\n');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
repoPath = gitRoot;
|
|
}
|
|
|
|
if (!isGitRepo(repoPath)) {
|
|
console.log(' Error: Not a git repository\n');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// ── Check for existing index ────────────────────────────────────────
|
|
const { storagePath, lbugPath } = getStoragePaths(repoPath);
|
|
const meta = await loadMeta(storagePath);
|
|
|
|
if (!meta) {
|
|
console.log(' Error: No GitNexus index found.');
|
|
console.log(' Run `gitnexus analyze` first to index this repository.\n');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
let timeoutSeconds: number | undefined;
|
|
let retries: number | undefined;
|
|
let allowedInsecureHttpHosts: string[] | undefined;
|
|
try {
|
|
timeoutSeconds = parsePositiveIntegerOption(options?.timeout, '--timeout', 1000);
|
|
retries = parsePositiveIntegerOption(options?.retries, '--retries');
|
|
allowedInsecureHttpHosts =
|
|
options?.allowInsecureConnection === undefined
|
|
? undefined
|
|
: parseLLMAllowedInsecureHttpHosts(options.allowInsecureConnection);
|
|
} catch (error) {
|
|
console.log(` Error: ${(error as Error).message}\n`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// ── Resolve LLM config (with interactive fallback) ─────────────────
|
|
// Save any CLI overrides immediately
|
|
if (
|
|
options?.apiKey ||
|
|
options?.model ||
|
|
options?.baseUrl ||
|
|
options?.provider ||
|
|
options?.apiVersion ||
|
|
options?.reasoningModel !== undefined
|
|
) {
|
|
const existing = await loadCLIConfig();
|
|
const updates: Partial<typeof existing> = {};
|
|
if (options.apiKey) updates.apiKey = options.apiKey;
|
|
if (options.baseUrl) updates.baseUrl = options.baseUrl;
|
|
if (options.provider) updates.provider = options.provider;
|
|
if (options.apiVersion) updates.apiVersion = options.apiVersion;
|
|
if (options.reasoningModel !== undefined) updates.isReasoningModel = options.reasoningModel;
|
|
// Save model to appropriate field based on provider.
|
|
if (options.model) {
|
|
const targetProvider = options.provider ?? existing.provider;
|
|
if (isLocalProvider(targetProvider)) {
|
|
updates[localModelConfigKey(targetProvider)] = options.model;
|
|
} else {
|
|
updates.model = options.model;
|
|
}
|
|
}
|
|
await saveCLIConfig({ ...existing, ...updates });
|
|
console.log(' Config saved to ~/.gitnexus/config.json\n');
|
|
}
|
|
|
|
const savedConfig = await loadCLIConfig();
|
|
const hasSavedConfig = !!(
|
|
isLocalProvider(savedConfig.provider) ||
|
|
(savedConfig.apiKey && savedConfig.baseUrl)
|
|
);
|
|
const hasCLIOverrides = !!(
|
|
options?.apiKey ||
|
|
options?.model ||
|
|
options?.baseUrl ||
|
|
options?.provider ||
|
|
options?.apiVersion ||
|
|
options?.reasoningModel !== undefined
|
|
);
|
|
|
|
let llmConfig = await resolveLLMConfig({
|
|
model: options?.model,
|
|
baseUrl: options?.baseUrl,
|
|
apiKey: options?.apiKey,
|
|
provider: options?.provider,
|
|
apiVersion: options?.apiVersion,
|
|
isReasoningModel: options?.reasoningModel,
|
|
allowedInsecureHttpHosts,
|
|
});
|
|
|
|
// 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) {
|
|
if (!process.stdin.isTTY) {
|
|
// Non-interactive mode — need either API key or Cursor CLI
|
|
if (!llmConfig.apiKey && !isLocalProvider(llmConfig.provider)) {
|
|
console.log(' Error: No LLM API key found.');
|
|
console.log(' Set OPENAI_API_KEY or GITNEXUS_API_KEY environment variable,');
|
|
console.log(' or pass --api-key <key>, or use --provider cursor|claude|codex|opencode.\n');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
// Non-interactive with env var or cursor — just use it
|
|
} else {
|
|
console.log(" No LLM configured. Let's set it up.\n");
|
|
console.log(
|
|
' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, Cursor CLI, Claude CLI, Codex CLI, or OpenCode CLI.\n',
|
|
);
|
|
|
|
// Check if local agent CLIs are available.
|
|
const hasCursor = detectCursorCLI();
|
|
const hasClaude = detectLocalCLI('claude');
|
|
const hasCodex = detectLocalCLI('codex');
|
|
const hasOpenCode = detectLocalCLI('opencode');
|
|
const localChoices: Array<{
|
|
choice: string;
|
|
provider: 'cursor' | 'claude' | 'codex' | 'opencode';
|
|
}> = [];
|
|
|
|
// Provider selection
|
|
console.log(' [1] OpenAI (api.openai.com)');
|
|
console.log(' [2] OpenRouter (openrouter.ai)');
|
|
console.log(' [3] Azure OpenAI');
|
|
console.log(' [4] Custom endpoint');
|
|
let nextChoice = 5;
|
|
if (hasCursor) {
|
|
const choice = String(nextChoice++);
|
|
localChoices.push({
|
|
choice,
|
|
provider: 'cursor',
|
|
});
|
|
console.log(` [${choice}] Cursor CLI (local, uses your Cursor subscription)`);
|
|
}
|
|
if (hasClaude) {
|
|
const choice = String(nextChoice++);
|
|
localChoices.push({
|
|
choice,
|
|
provider: 'claude',
|
|
});
|
|
console.log(` [${choice}] Claude CLI (local, uses your Claude Code login)`);
|
|
}
|
|
if (hasCodex) {
|
|
const choice = String(nextChoice++);
|
|
localChoices.push({
|
|
choice,
|
|
provider: 'codex',
|
|
});
|
|
console.log(` [${choice}] Codex CLI (local, uses your Codex login)`);
|
|
}
|
|
if (hasOpenCode) {
|
|
const choice = String(nextChoice++);
|
|
localChoices.push({
|
|
choice,
|
|
provider: 'opencode',
|
|
});
|
|
console.log(` [${choice}] OpenCode CLI (local, uses your OpenCode login/config)`);
|
|
}
|
|
console.log('');
|
|
|
|
const maxChoice = String(nextChoice - 1);
|
|
const choice = await prompt(` Select provider (1/${maxChoice}): `);
|
|
|
|
let baseUrl: string;
|
|
let defaultModel: string;
|
|
let provider: LLMProvider = 'openai';
|
|
let key = '';
|
|
|
|
const selectedLocal = localChoices.find((item) => item.choice === choice);
|
|
if (selectedLocal) {
|
|
// Local CLI selected - model defaults to the CLI's configured default.
|
|
provider = selectedLocal.provider;
|
|
baseUrl = '';
|
|
|
|
const modelInput = await prompt(' Model (leave empty for CLI default): ');
|
|
const model = modelInput || '';
|
|
|
|
const localConfig = { ...savedConfig, provider };
|
|
if (model) (localConfig as Record<string, unknown>)[localModelConfigKey(provider)] = model;
|
|
await saveCLIConfig(localConfig);
|
|
console.log(' Config saved to ~/.gitnexus/config.json\n');
|
|
|
|
llmConfig = { ...llmConfig, provider, model, apiKey: '', baseUrl: '' };
|
|
} else if (choice === '3') {
|
|
// Azure OpenAI guided setup — minimal prompts
|
|
console.log('\n Azure OpenAI setup.\n');
|
|
|
|
const endpoint = (
|
|
await prompt(' Endpoint URL (e.g. https://my-resource.openai.azure.com): ')
|
|
)
|
|
.trim()
|
|
.replace(/\/+$/, '');
|
|
if (!endpoint) {
|
|
console.log('\n No endpoint provided. Aborting.\n');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const deploymentName = (await prompt(' Deployment name: ')).trim();
|
|
if (!deploymentName) {
|
|
console.log('\n No deployment name provided. Aborting.\n');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// API key — use env var if available
|
|
const envKey = process.env.GITNEXUS_API_KEY || process.env.OPENAI_API_KEY || '';
|
|
let azureKey: string;
|
|
if (envKey) {
|
|
const masked = envKey.slice(0, 6) + '...' + envKey.slice(-4);
|
|
const useEnv = await prompt(` Use existing env key (${masked})? (Y/n): `);
|
|
if (!useEnv || useEnv.toLowerCase() === 'y' || useEnv.toLowerCase() === 'yes') {
|
|
azureKey = envKey;
|
|
} else {
|
|
azureKey = await prompt(' API key: ', true);
|
|
}
|
|
} else {
|
|
azureKey = await prompt(' API key: ', true);
|
|
}
|
|
|
|
if (!azureKey) {
|
|
console.log('\n No key provided. Aborting.\n');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Always use v1 API format — no need for api-version
|
|
const azureBaseUrl = `${endpoint}/openai/v1`;
|
|
|
|
await saveCLIConfig({
|
|
...savedConfig,
|
|
apiKey: azureKey,
|
|
baseUrl: azureBaseUrl,
|
|
model: deploymentName,
|
|
provider: 'azure',
|
|
});
|
|
console.log(' Config saved to ~/.gitnexus/config.json\n');
|
|
|
|
llmConfig = {
|
|
...llmConfig,
|
|
apiKey: azureKey,
|
|
baseUrl: azureBaseUrl,
|
|
model: deploymentName,
|
|
provider: 'azure',
|
|
};
|
|
} else {
|
|
// OpenAI-compatible provider (OpenAI, OpenRouter, Custom)
|
|
if (choice === '2') {
|
|
baseUrl = 'https://openrouter.ai/api/v1';
|
|
defaultModel = 'minimax/minimax-m2.5';
|
|
provider = 'openrouter';
|
|
} else if (choice === '4') {
|
|
baseUrl = await prompt(' Base URL (e.g. http://localhost:11434/v1): ');
|
|
if (!baseUrl) {
|
|
console.log('\n No URL provided. Aborting.\n');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
defaultModel = 'gpt-4o-mini';
|
|
provider = 'custom';
|
|
} else {
|
|
baseUrl = 'https://api.openai.com/v1';
|
|
defaultModel = 'gpt-4o-mini';
|
|
provider = 'openai';
|
|
}
|
|
|
|
// Model
|
|
const modelInput = await prompt(` Model (default: ${defaultModel}): `);
|
|
const model = modelInput || defaultModel;
|
|
|
|
// API key — pre-fill hint if env var exists
|
|
const envKey = process.env.GITNEXUS_API_KEY || process.env.OPENAI_API_KEY || '';
|
|
if (envKey) {
|
|
const masked = envKey.slice(0, 6) + '...' + envKey.slice(-4);
|
|
const useEnv = await prompt(` Use existing env key (${masked})? (Y/n): `);
|
|
if (!useEnv || useEnv.toLowerCase() === 'y' || useEnv.toLowerCase() === 'yes') {
|
|
key = envKey;
|
|
} else {
|
|
key = await prompt(' API key: ', true);
|
|
}
|
|
} else {
|
|
key = await prompt(' API key: ', true);
|
|
}
|
|
|
|
if (!key) {
|
|
console.log('\n No key provided. Aborting.\n');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Save
|
|
await saveCLIConfig({ apiKey: key, baseUrl, model, provider });
|
|
console.log(' Config saved to ~/.gitnexus/config.json\n');
|
|
|
|
llmConfig = { ...llmConfig, apiKey: key, baseUrl, model, provider };
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Apply per-run overrides not saved to config ────────────────────
|
|
if (timeoutSeconds !== undefined) {
|
|
llmConfig.requestTimeoutMs = timeoutSeconds * 1000;
|
|
}
|
|
if (retries !== undefined) {
|
|
llmConfig.maxAttempts = retries;
|
|
}
|
|
|
|
// ── 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,
|
|
);
|
|
|
|
bar.start(100, 0, { phase: 'Initializing...' });
|
|
|
|
const t0 = Date.now();
|
|
let lastPhase = '';
|
|
let phaseStart = t0;
|
|
|
|
// Tick elapsed time every second while stuck on the same phase
|
|
const elapsedTimer = setInterval(() => {
|
|
if (lastPhase) {
|
|
const elapsed = Math.round((Date.now() - phaseStart) / 1000);
|
|
if (elapsed >= 3) {
|
|
bar.update({ phase: `${lastPhase} (${elapsed}s)` });
|
|
}
|
|
}
|
|
}, 1000);
|
|
|
|
// ── Run generator ───────────────────────────────────────────────────
|
|
const wikiOptions: WikiOptions = {
|
|
force: options?.force,
|
|
concurrency: options?.concurrency ? parseInt(options.concurrency, 10) : undefined,
|
|
reviewOnly: options?.review,
|
|
lang: options?.lang,
|
|
};
|
|
|
|
const generator = new WikiGenerator(
|
|
repoPath,
|
|
storagePath,
|
|
lbugPath,
|
|
llmConfig,
|
|
wikiOptions,
|
|
(phase, percent, detail) => {
|
|
const label = detail || phase;
|
|
if (label !== lastPhase) {
|
|
lastPhase = label;
|
|
phaseStart = Date.now();
|
|
}
|
|
bar.update(percent, { phase: label });
|
|
},
|
|
);
|
|
|
|
try {
|
|
const result = await generator.run();
|
|
|
|
clearInterval(elapsedTimer);
|
|
bar.stop();
|
|
|
|
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
|
|
const wikiDir = path.join(storagePath, 'wiki');
|
|
const viewerPath = path.join(wikiDir, 'index.html');
|
|
const treeFile = path.join(wikiDir, 'module_tree.json');
|
|
|
|
// Review mode: show module tree and ask for confirmation
|
|
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)`
|
|
: '';
|
|
console.log(`${prefix}- ${node.name}${suffix}`);
|
|
if (node.children && node.children.length > 0) {
|
|
printTree(node.children, indent + 1);
|
|
}
|
|
}
|
|
};
|
|
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');
|
|
}
|
|
const answer = process.stdin.isTTY
|
|
? 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';
|
|
console.log(`\n Opening ${treeFile} in ${editor}...`);
|
|
console.log(' Save and close the editor when done.\n');
|
|
|
|
try {
|
|
execFileSync(editor, [treeFile], { stdio: 'inherit', windowsHide: true });
|
|
} 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,
|
|
lbugPath,
|
|
llmConfig,
|
|
continueOptions,
|
|
(phase, percent, detail) => {
|
|
const label = detail || phase;
|
|
if (label !== lastPhase) {
|
|
lastPhase = label;
|
|
phaseStart = Date.now();
|
|
}
|
|
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);
|
|
return;
|
|
}
|
|
|
|
bar.update(100, { phase: 'Done' });
|
|
|
|
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);
|
|
return;
|
|
}
|
|
|
|
console.log(`\n Wiki generated successfully (${elapsed}s)\n`);
|
|
console.log(` Mode: ${result.mode}`);
|
|
console.log(` Pages: ${result.pagesGenerated}`);
|
|
console.log(` Output: ${wikiDir}`);
|
|
console.log(` Viewer: ${viewerPath}`);
|
|
|
|
if (result.failedModules && result.failedModules.length > 0) {
|
|
console.log(`\n Failed modules (${result.failedModules.length}):`);
|
|
for (const mod of result.failedModules) {
|
|
console.log(` - ${mod}`);
|
|
}
|
|
console.log(' Re-run to retry failed modules (pages will be regenerated).');
|
|
}
|
|
|
|
console.log('');
|
|
|
|
await maybePublishGist(viewerPath, options?.gist);
|
|
} catch (err: any) {
|
|
clearInterval(elapsedTimer);
|
|
bar.stop();
|
|
|
|
if (err.message?.includes('No source files')) {
|
|
console.log(`\n ${err.message}\n`);
|
|
} else if (err.message?.includes('LLM request timed out after')) {
|
|
console.log(`\n Timeout: ${err.message}\n`);
|
|
} else if (err.message?.includes('content filter')) {
|
|
// Content filter block — actionable message
|
|
console.log(`\n Content Filter: ${err.message}\n`);
|
|
console.log(
|
|
' To resolve: rephrase your prompt or adjust the content filter policy for your deployment.\n',
|
|
);
|
|
} else if (err.message?.includes('API key') || err.message?.includes('API error')) {
|
|
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');
|
|
if (isAuthError && process.stdin.isTTY) {
|
|
const answer = await new Promise<string>((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());
|
|
});
|
|
});
|
|
if (!answer || answer === 'y' || answer === 'yes') {
|
|
// Clear saved config so next run triggers interactive setup
|
|
await saveCLIConfig({});
|
|
console.log(' Config cleared. Run `gitnexus wiki` again to reconfigure.\n');
|
|
}
|
|
}
|
|
} else {
|
|
console.log(`\n Error: ${err.message}\n`);
|
|
if (process.env.GITNEXUS_VERBOSE) {
|
|
logger.error({ err }, 'wiki command failed');
|
|
}
|
|
}
|
|
process.exitCode = 1;
|
|
}
|
|
};
|
|
|
|
// ─── Gist Publishing ───────────────────────────────────────────────────
|
|
|
|
function hasGhCLI(): boolean {
|
|
try {
|
|
execSync('gh --version', { stdio: 'ignore', windowsHide: true });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Strict Gist URL predicate. Rejects:
|
|
* - any URL that does not parse (URL constructor throws)
|
|
* - schemes other than https (drops `http:`, `file:`, `gist:`-style spoofs)
|
|
* - hostnames that are not exactly `gist.github.com` (drops substring spoofs
|
|
* like `https://evil.com/?u=gist.github.com` and userinfo-prefixed shapes
|
|
* like `https://[email protected]/...` — note that URL.hostname
|
|
* strips userinfo, so the equality check rejects the userinfo-prefixed
|
|
* spoof if the actual host differs from gist.github.com)
|
|
* - any URL containing userinfo (`username[:password]@`), which the URL
|
|
* parser exposes via `.username` / `.password`. Defense-in-depth: even
|
|
* when hostname matches, a credential-bearing URL is suspect and not
|
|
* produced by `gh gist create`.
|
|
*
|
|
* Closes the substring-bypass class CodeQL `js/incomplete-url-substring-
|
|
* sanitization` flags.
|
|
*/
|
|
function isGistUrl(line: string): boolean {
|
|
const trimmed = line.trim();
|
|
try {
|
|
const u = new URL(trimmed);
|
|
return (
|
|
u.protocol === 'https:' &&
|
|
u.hostname === 'gist.github.com' &&
|
|
u.username === '' &&
|
|
u.password === ''
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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'], windowsHide: true },
|
|
).trim();
|
|
|
|
// `gh gist create` prints the gist URL as a line in the output. Find the
|
|
// first parseable Gist URL — if no line is a valid Gist URL, fail closed
|
|
// (do NOT fall back to lines[last]: a non-Gist last line would propagate
|
|
// through the regex below and produce a malformed `rawUrl`).
|
|
const gistUrl = output.split('\n').find(isGistUrl);
|
|
if (!gistUrl) return null;
|
|
|
|
// Build a raw viewer URL via gist.githack.com.
|
|
// gist URL format: https://gist.github.com/{user}/{id}
|
|
const match = gistUrl.match(/gist\.github\.com\/([^/]+)\/([a-f0-9]+)/);
|
|
let rawUrl = gistUrl;
|
|
if (match) {
|
|
rawUrl = `https://gistcdn.githack.com/${match[1]}/${match[2]}/raw/index.html`;
|
|
}
|
|
|
|
return { url: gistUrl.trim(), rawUrl };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function maybePublishGist(htmlPath: string, gistFlag?: boolean): Promise<void> {
|
|
if (gistFlag === false) return;
|
|
|
|
// Check that the HTML file exists
|
|
try {
|
|
const fs = await import('fs/promises');
|
|
await fs.access(htmlPath);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (!hasGhCLI()) {
|
|
if (gistFlag) {
|
|
console.log(' GitHub CLI (gh) is not installed. Cannot publish gist.');
|
|
console.log(' Install it: https://cli.github.com\n');
|
|
}
|
|
return;
|
|
}
|
|
|
|
let shouldPublish = !!gistFlag;
|
|
|
|
if (!shouldPublish && process.stdin.isTTY) {
|
|
const answer = await new Promise<string>((resolve) => {
|
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
rl.question(' Publish wiki as a GitHub Gist for easy viewing? (Y/n): ', (ans) => {
|
|
rl.close();
|
|
resolve(ans.trim().toLowerCase());
|
|
});
|
|
});
|
|
shouldPublish = !answer || answer === 'y' || answer === 'yes';
|
|
}
|
|
|
|
if (!shouldPublish) return;
|
|
|
|
console.log('\n Publishing to GitHub Gist...');
|
|
const result = publishGist(htmlPath);
|
|
|
|
if (result) {
|
|
console.log(` Gist: ${result.url}`);
|
|
console.log(` Viewer: ${result.rawUrl}\n`);
|
|
} else {
|
|
console.log(' Failed to publish gist. Make sure `gh auth login` is configured.\n');
|
|
}
|
|
}
|