GitNexus/gitnexus/src/cli/wiki.ts
azizur100389 a333d94a00
feat(wiki): allow explicit HTTP LLM hosts (#2491)
* 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>
2026-07-16 13:53:58 +01:00

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');
}
}