mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-20 00:11:37 +00:00
* fix(wiki): Azure OpenAI compat and HTML viewer script injection - Use max_completion_tokens instead of deprecated max_tokens for all models - Skip sending temperature for Azure provider (some models reject non-default values) - Simplify Azure interactive setup: endpoint + deployment + key (3 prompts instead of 7) - Escape </script> in embedded JSON to prevent premature script tag closure Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(test): align wiki-llm-client test with max_completion_tokens change The test expected max_tokens for non-reasoning models, but the source now uses max_completion_tokens for all models since max_tokens is deprecated by newer OpenAI models. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Abhigyan Patwari <abhigyan@Abhigyans-MacBook-Air.local> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
675 lines
22 KiB
TypeScript
675 lines
22 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 { resolveLLMConfig, type LLMProvider } from '../core/wiki/llm-client.js';
|
|
import { detectCursorCLI } from '../core/wiki/cursor-client.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;
|
|
}
|
|
|
|
/**
|
|
* 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) => {
|
|
// 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;
|
|
}
|
|
|
|
// ── 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) {
|
|
if (options.provider === 'cursor') {
|
|
updates.cursorModel = 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 = !!(
|
|
savedConfig.provider === 'cursor' ||
|
|
(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,
|
|
});
|
|
|
|
// 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 && llmConfig.provider !== 'cursor') {
|
|
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.\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, or Cursor CLI.\n',
|
|
);
|
|
|
|
// Check if Cursor CLI is available
|
|
const hasCursor = detectCursorCLI();
|
|
|
|
// 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');
|
|
if (hasCursor) {
|
|
console.log(' [5] Cursor CLI (local, uses your Cursor subscription)');
|
|
}
|
|
console.log('');
|
|
|
|
const maxChoice = hasCursor ? '5' : '4';
|
|
const choice = await prompt(` Select provider (1/${maxChoice}): `);
|
|
|
|
let baseUrl: string;
|
|
let defaultModel: string;
|
|
let provider: LLMProvider = 'openai';
|
|
let key = '';
|
|
|
|
if (choice === '5' && hasCursor) {
|
|
// Cursor CLI selected - model defaults to 'auto' (Cursor's default)
|
|
provider = 'cursor';
|
|
baseUrl = '';
|
|
|
|
const modelInput = await prompt(' Model (leave empty for auto): ');
|
|
const model = modelInput || '';
|
|
|
|
// Save config for Cursor
|
|
const cursorConfig: Record<string, string> = { provider: 'cursor' };
|
|
if (model) cursorConfig.cursorModel = model;
|
|
await saveCLIConfig(cursorConfig);
|
|
console.log(' Config saved to ~/.gitnexus/config.json\n');
|
|
|
|
llmConfig = { ...llmConfig, provider: 'cursor', 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({
|
|
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 };
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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,
|
|
};
|
|
|
|
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' });
|
|
} 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('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) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
process.exitCode = 1;
|
|
}
|
|
};
|
|
|
|
// ─── Gist Publishing ───────────────────────────────────────────────────
|
|
|
|
function hasGhCLI(): boolean {
|
|
try {
|
|
execSync('gh --version', { stdio: 'ignore' });
|
|
return true;
|
|
} 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'] },
|
|
).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];
|
|
|
|
if (!gistUrl || !gistUrl.includes('gist.github.com')) 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');
|
|
}
|
|
}
|