#!/usr/bin/env node /** * GitRepublic CLI - API command handler * * This script handles API commands (push-all, repos, publish, etc.) when called * from git-wrapper.js. It can also be called directly via gitrep-api/gitrepublic-api * for backward compatibility. * * Usage (via gitrep/gitrepublic): * gitrep push-all [branch] [options] * gitrep repos list * gitrep publish * * Usage (direct, for backward compatibility): * gitrep-api push-all [branch] [options] * gitrepublic-api repos list * * Commands: * push-all [branch] [--force] [--tags] [--dry-run] Push to all remotes * repos list List repositories * repos get Get repository info * repos settings Get/update repository settings * repos maintainers [add|remove] Manage maintainers * repos branches List branches * repos tags List tags * repos fork Fork a repository * repos delete Delete a repository * file get Get file content * file put Create/update file * file delete Delete file * search Search repositories * publish Publish Nostr events * verify Verify Nostr event signatures * config [server] Show configuration * * Options: * --server GitRepublic server URL (default: http://localhost:5173) * --key Nostr private key (overrides NOSTRGIT_SECRET_KEY) * --json Output JSON format * --help Show help */ import { createHash } from 'crypto'; import { finalizeEvent, getPublicKey, nip19, SimplePool, verifyEvent, getEventHash, Relay } from 'nostr-tools'; import { decode } from 'nostr-tools/nip19'; import { readFileSync, writeFileSync, existsSync } from 'fs'; import { execSync } from 'child_process'; import { join, dirname, resolve } from 'path'; /** * Sanitize error messages to prevent private key leaks * @param {string} message - Error message to sanitize * @returns {string} - Sanitized error message */ function sanitizeErrorMessage(message) { if (!message || typeof message !== 'string') { return String(message); } return message .replace(/nsec[0-9a-z]+/gi, '[nsec]') .replace(/[0-9a-f]{64}/gi, '[hex-key]') .replace(/private.*key[=:]\s*[^\s]+/gi, '[private-key]') .replace(/secret.*key[=:]\s*[^\s]+/gi, '[secret-key]') .replace(/NOSTRGIT_SECRET_KEY[=:]\s*[^\s]+/gi, 'NOSTRGIT_SECRET_KEY=[redacted]') .replace(/NOSTR_PRIVATE_KEY[=:]\s*[^\s]+/gi, 'NOSTR_PRIVATE_KEY=[redacted]') .replace(/NSEC[=:]\s*[^\s]+/gi, 'NSEC=[redacted]'); } // Handle unhandled promise rejections from SimplePool to prevent crashes // SimplePool can reject promises asynchronously from WebSocket handlers // NEVER log private keys or sensitive data process.on('unhandledRejection', (reason, promise) => { // Silently handle relay errors - they're already logged in publishToRelays // Only log if it's not a known relay error pattern const errorMessage = reason instanceof Error ? reason.message : String(reason); const sanitized = sanitizeErrorMessage(errorMessage); if (!sanitized.includes('restricted') && !sanitized.includes('Relay did not accept')) { // Unknown error, but don't crash - just log it (sanitized) console.error('Warning: Unhandled promise rejection:', sanitized); } // Don't exit - let the normal error handling continue }); // NIP-98 auth event kind const KIND_NIP98_AUTH = 27235; // Default server URL // Note: localhost:5173 is the SvelteKit dev server port // In production, set GITREPUBLIC_SERVER environment variable to your server URL const DEFAULT_SERVER = process.env.GITREPUBLIC_SERVER || 'http://localhost:5173'; /** * Decode Nostr key and get private key bytes */ /** * Get private key bytes from nsec or hex string * NEVER logs or exposes the private key * @param {string} key - nsec string or hex private key * @returns {Uint8Array} - Private key bytes */ function getPrivateKeyBytes(key) { if (!key || typeof key !== 'string') { throw new Error('Invalid key: key must be a string'); } try { if (key.startsWith('nsec')) { const decoded = decode(key); if (decoded.type === 'nsec') { return decoded.data; } throw new Error('Invalid nsec format'); } else if (/^[0-9a-fA-F]{64}$/.test(key)) { // Hex format const keyBytes = new Uint8Array(32); for (let i = 0; i < 32; i++) { keyBytes[i] = parseInt(key.slice(i * 2, i * 2 + 2), 16); } return keyBytes; } throw new Error('Invalid key format. Use nsec or hex.'); } catch (error) { // NEVER expose the key in error messages if (error instanceof Error && error.message.includes(key)) { throw new Error('Invalid key format. Use nsec or hex.'); } throw error; } } /** * Create NIP-98 authentication header */ function createNIP98Auth(url, method, body = null) { const secretKey = process.env.NOSTRGIT_SECRET_KEY || process.env.NOSTR_PRIVATE_KEY || process.env.NSEC; if (!secretKey) { throw new Error('NOSTRGIT_SECRET_KEY environment variable is not set'); } const privateKeyBytes = getPrivateKeyBytes(secretKey); const pubkey = getPublicKey(privateKeyBytes); // Normalize URL (remove trailing slash) const normalizedUrl = url.replace(/\/$/, ''); const tags = [ ['u', normalizedUrl], ['method', method.toUpperCase()] ]; if (body) { const bodyHash = createHash('sha256').update(typeof body === 'string' ? body : JSON.stringify(body)).digest('hex'); tags.push(['payload', bodyHash]); } const eventTemplate = { kind: KIND_NIP98_AUTH, pubkey, created_at: Math.floor(Date.now() / 1000), content: '', tags }; const signedEvent = finalizeEvent(eventTemplate, privateKeyBytes); const eventJson = JSON.stringify(signedEvent); const base64Event = Buffer.from(eventJson, 'utf-8').toString('base64'); return `Nostr ${base64Event}`; } /** * Store event in appropriate JSONL file based on event kind */ function storeEventInJsonl(event) { try { // Find repository root (look for .git directory) let repoRoot = null; let currentDir = process.cwd(); for (let i = 0; i < 10; i++) { const potentialGitDir = join(currentDir, '.git'); if (existsSync(potentialGitDir)) { repoRoot = currentDir; break; } const parentDir = dirname(currentDir); if (parentDir === currentDir) break; currentDir = parentDir; } if (!repoRoot) { // Not in a git repo, skip storing return; } // Create nostr/ directory if it doesn't exist const nostrDir = join(repoRoot, 'nostr'); if (!existsSync(nostrDir)) { execSync(`mkdir -p "${nostrDir}"`, { stdio: 'ignore' }); } // Determine JSONL file name based on event kind let jsonlFile; switch (event.kind) { case 30617: // REPO_ANNOUNCEMENT jsonlFile = join(nostrDir, 'repo-announcements.jsonl'); break; case 1641: // OWNERSHIP_TRANSFER jsonlFile = join(nostrDir, 'ownership-transfers.jsonl'); break; case 1617: // PATCH jsonlFile = join(nostrDir, 'patches.jsonl'); break; case 1618: // PULL_REQUEST jsonlFile = join(nostrDir, 'pull-requests.jsonl'); break; case 1619: // PULL_REQUEST_UPDATE jsonlFile = join(nostrDir, 'pull-request-updates.jsonl'); break; case 1621: // ISSUE jsonlFile = join(nostrDir, 'issues.jsonl'); break; case 1630: // STATUS_OPEN case 1631: // STATUS_APPLIED case 1632: // STATUS_CLOSED case 1633: // STATUS_DRAFT jsonlFile = join(nostrDir, 'status-events.jsonl'); break; case 30618: // REPO_STATE jsonlFile = join(nostrDir, 'repo-states.jsonl'); break; default: // Store unknown event types in a generic file jsonlFile = join(nostrDir, `events-kind-${event.kind}.jsonl`); } // Append event to JSONL file const eventLine = JSON.stringify(event) + '\n'; writeFileSync(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' }); } catch (error) { // Silently fail - storing is optional } } /** * Publish event to Nostr relays using SimplePool * * This refactored version uses SimplePool (the recommended approach from nostr-tools) * instead of managing individual Relay instances. SimplePool handles: * - Connection establishment and management automatically * - Automatic reconnection on failures * - Timeout handling * - Connection pooling for efficiency * * Auth is handled on-demand only when a relay requires it (not proactively), * which matches the behavior before auth was added and avoids connection issues. * * We publish to each relay individually to get per-relay results and error details. */ async function publishToRelays(event, relays, privateKeyBytes, pubkey = null) { if (!privateKeyBytes) { throw new Error('Private key is required for publishing events'); } if (!relays || relays.length === 0) { return { success: [], failed: [] }; } const pool = new SimplePool(); const success = []; const failed = []; // Publish to each relay individually with individual timeouts // This prevents hanging when all relays fail - each relay gets its own timeout const publishPromises = relays.map(async (relayUrl) => { try { // Publish to a single relay with timeout const publishPromise = pool.publish([relayUrl], event); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Publish timeout after 8 seconds')), 8000) ); const successfulRelays = await Promise.race([publishPromise, timeoutPromise]); // Check if this relay succeeded if (successfulRelays && typeof successfulRelays.has === 'function') { if (successfulRelays.has(relayUrl)) { return { relay: relayUrl, success: true }; } } else if (Array.isArray(successfulRelays) && successfulRelays.includes(relayUrl)) { return { relay: relayUrl, success: true }; } // Relay didn't succeed return { relay: relayUrl, success: false, error: 'Failed to publish to relay' }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const sanitizedError = sanitizeErrorMessage(errorMessage); if (errorMessage.includes('timeout')) { return { relay: relayUrl, success: false, error: 'Publish timeout - relay did not respond' }; } else { return { relay: relayUrl, success: false, error: sanitizedError }; } } }); // Wait for all publish attempts to complete (with their individual timeouts) const results = await Promise.allSettled(publishPromises); // Process results for (const result of results) { if (result.status === 'fulfilled') { if (result.value.success) { success.push(result.value.relay); } else { failed.push({ relay: result.value.relay, error: result.value.error }); } } else { failed.push({ relay: 'unknown', error: result.reason?.message || 'Unknown error' }); } } // Close all connections in the pool try { await pool.close(relays); } catch (closeError) { // Ignore close errors } return { success, failed }; } /** * Add client tag to event tags unless --no-client-tag is specified * @param {Array} tags - Array of tag arrays * @param {Array} args - Command arguments array */ function addClientTag(tags, args) { const noClientTag = args && args.includes('--no-client-tag'); if (!noClientTag) { tags.push(['client', 'gitrepublic-cli']); } return tags; } /** * Make authenticated API request */ async function apiRequest(server, endpoint, method = 'GET', body = null, options = {}) { const url = `${server.replace(/\/$/, '')}/api${endpoint}`; const authHeader = createNIP98Auth(url, method, body); const headers = { 'Authorization': authHeader, 'Content-Type': 'application/json' }; const fetchOptions = { method, headers, ...options }; if (body && method !== 'GET') { fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body); } const response = await fetch(url, fetchOptions); const text = await response.text(); let data; try { data = JSON.parse(text); } catch { data = text; } if (!response.ok) { // Sanitize error message to prevent key leaks const errorData = typeof data === 'object' ? JSON.stringify(data, null, 2) : String(data); const sanitizedData = sanitizeErrorMessage(errorData); throw new Error(`API request failed: ${response.status} ${response.statusText}\n${sanitizedData}`); } return data; } /** * Command handlers */ const commands = { async repos(args, server, json) { const subcommand = args[0]; if (subcommand === 'list') { // Get registered and unregistered repos from Nostr const listData = await apiRequest(server, '/repos/list', 'GET'); // Get local repos (cloned on server) let localRepos = []; try { localRepos = await apiRequest(server, '/repos/local', 'GET'); } catch (err) { // Local repos endpoint might not be available or might fail // Continue without local repos } // Helper function to check verification status async function checkVerification(npub, repoName) { try { // The verify endpoint doesn't require authentication, so we can call it directly const url = `${server.replace(/\/$/, '')}/api/repos/${npub}/${repoName}/verify`; const response = await fetch(url); if (!response.ok) { // If endpoint returns error, assume not verified return false; } const verifyData = await response.json(); // Return true only if verified is explicitly true return verifyData.verified === true; } catch (err) { // Silently fail - assume not verified if check fails return false; } } // Check verification status for all repos (in parallel for performance) const registered = listData.registered || []; const verificationPromises = []; // Check verification for registered repos for (const repo of registered) { const name = repo.repoName || repo.name || 'unknown'; const npub = repo.npub || 'unknown'; if (name !== 'unknown' && npub !== 'unknown') { verificationPromises.push( checkVerification(npub, name).then(verified => ({ key: `${npub}/${name}`, verified })) ); } } // Check verification for local repos for (const repo of localRepos) { const name = repo.repoName || repo.name || 'unknown'; const npub = repo.npub || 'unknown'; if (name !== 'unknown' && npub !== 'unknown') { verificationPromises.push( checkVerification(npub, name).then(verified => ({ key: `${npub}/${name}`, verified })) ); } } // Wait for all verification checks to complete const verificationResults = await Promise.all(verificationPromises); const verifiedMap = new Map(); verificationResults.forEach(result => { verifiedMap.set(result.key, result.verified); }); // Debug: Log verification results if needed // console.error('Verification map:', Array.from(verifiedMap.entries())); if (json) { // Add verification status to JSON output const registeredWithVerification = registered.map(repo => ({ ...repo, verified: verifiedMap.get(`${repo.npub}/${repo.repoName || repo.name || 'unknown'}`) || false })); const localWithVerification = localRepos.map(repo => ({ ...repo, verified: verifiedMap.get(`${repo.npub}/${repo.repoName || repo.name || 'unknown'}`) || false })); console.log(JSON.stringify({ registered: registeredWithVerification, local: localWithVerification, total: { registered: registered.length, local: localRepos.length, total: (registered.length + localRepos.length) } }, null, 2)); } else { // Display help text explaining the difference console.log('Repository Types:'); console.log(' Registered: Repositories announced on Nostr with this server in their clone URLs'); console.log(' Local: Repositories cloned on this server (may be registered or unregistered)'); console.log(' Verified: Repository ownership has been cryptographically verified'); console.log(''); // Display registered repositories if (registered.length > 0) { console.log('Registered Repositories:'); registered.forEach(repo => { const name = repo.repoName || repo.name || 'unknown'; const npub = repo.npub || 'unknown'; const desc = repo.event?.tags?.find(t => t[0] === 'description')?.[1] || repo.description || 'No description'; const key = `${npub}/${name}`; const verified = verifiedMap.has(key) ? verifiedMap.get(key) : false; const verifiedStatus = verified ? 'verified' : 'unverified'; console.log(` ${npub}/${name} (${verifiedStatus}) - ${desc}`); }); console.log(''); } // Display local repositories if (localRepos.length > 0) { console.log('Local Repositories:'); localRepos.forEach(repo => { const name = repo.repoName || repo.name || 'unknown'; const npub = repo.npub || 'unknown'; const desc = repo.announcement?.tags?.find(t => t[0] === 'description')?.[1] || repo.description || 'No description'; const registrationStatus = repo.isRegistered ? 'registered' : 'unregistered'; const key = `${npub}/${name}`; // Get verification status - use has() to distinguish between false and undefined const verified = verifiedMap.has(key) ? verifiedMap.get(key) : false; const verifiedStatus = verified ? 'verified' : 'unverified'; console.log(` ${npub}/${name} (${registrationStatus}, ${verifiedStatus}) - ${desc}`); }); console.log(''); } // Summary const totalRegistered = registered.length; const totalLocal = localRepos.length; const totalVerified = Array.from(verifiedMap.values()).filter(v => v === true).length; if (totalRegistered === 0 && totalLocal === 0) { console.log('No repositories found.'); } else { console.log(`Total: ${totalRegistered} registered, ${totalLocal} local, ${totalVerified} verified`); } } } else if (subcommand === 'get' && args[1]) { let npub, repo; // Check if first argument is naddr format if (args[1].startsWith('naddr1')) { try { const decoded = decode(args[1]); if (decoded.type === 'naddr') { const data = decoded.data; // naddr contains pubkey (hex) and identifier (d-tag) npub = nip19.npubEncode(data.pubkey); repo = data.identifier || data['d']; if (!repo) { throw new Error('Invalid naddr: missing identifier (d-tag)'); } } else { throw new Error('Invalid naddr format'); } } catch (err) { console.error(`Error: Failed to decode naddr: ${err.message}`); process.exit(1); } } else if (args[2]) { // Traditional npub/repo format [npub, repo] = args.slice(1); } else { console.error('Error: Invalid arguments. Use: repos get or repos get '); process.exit(1); } const data = await apiRequest(server, `/repos/${npub}/${repo}/settings`, 'GET'); if (json) { console.log(JSON.stringify(data, null, 2)); } else { console.log(`Repository: ${npub}/${repo}`); console.log(`Description: ${data.description || 'No description'}`); console.log(`Private: ${data.private ? 'Yes' : 'No'}`); console.log(`Owner: ${data.owner || npub}`); } } else if (subcommand === 'settings' && args[1] && args[2]) { const [npub, repo] = args.slice(1); if (args[3]) { // Update settings const settings = {}; for (let i = 3; i < args.length; i += 2) { const key = args[i].replace('--', ''); const value = args[i + 1]; if (key === 'description') settings.description = value; else if (key === 'private') settings.private = value === 'true'; } const data = await apiRequest(server, `/repos/${npub}/${repo}/settings`, 'POST', settings); console.log(json ? JSON.stringify(data, null, 2) : 'Settings updated successfully'); } else { // Get settings const data = await apiRequest(server, `/repos/${npub}/${repo}/settings`, 'GET'); console.log(json ? JSON.stringify(data, null, 2) : JSON.stringify(data, null, 2)); } } else if (subcommand === 'maintainers' && args[1] && args[2]) { const [npub, repo] = args.slice(1); const action = args[3]; const maintainerNpub = args[4]; if (action === 'add' && maintainerNpub) { const data = await apiRequest(server, `/repos/${npub}/${repo}/maintainers`, 'POST', { maintainer: maintainerNpub }); console.log(json ? JSON.stringify(data, null, 2) : `Maintainer ${maintainerNpub} added successfully`); } else if (action === 'remove' && maintainerNpub) { const data = await apiRequest(server, `/repos/${npub}/${repo}/maintainers`, 'DELETE', { maintainer: maintainerNpub }); console.log(json ? JSON.stringify(data, null, 2) : `Maintainer ${maintainerNpub} removed successfully`); } else { // List maintainers const data = await apiRequest(server, `/repos/${npub}/${repo}/maintainers`, 'GET'); if (json) { console.log(JSON.stringify(data, null, 2)); } else { console.log(`Repository: ${npub}/${repo}`); console.log(`Owner: ${data.owner}`); console.log(`Maintainers: ${data.maintainers?.length || 0}`); if (data.maintainers?.length > 0) { data.maintainers.forEach(m => console.log(` - ${m}`)); } } } } else if (subcommand === 'branches' && args[1] && args[2]) { const [npub, repo] = args.slice(1); const data = await apiRequest(server, `/repos/${npub}/${repo}/branches`, 'GET'); if (json) { console.log(JSON.stringify(data, null, 2)); } else { console.log(`Branches for ${npub}/${repo}:`); if (Array.isArray(data)) { data.forEach(branch => { console.log(` ${branch.name} - ${branch.commit?.substring(0, 7) || 'N/A'}`); }); } } } else if (subcommand === 'tags' && args[1] && args[2]) { const [npub, repo] = args.slice(1); const data = await apiRequest(server, `/repos/${npub}/${repo}/tags`, 'GET'); if (json) { console.log(JSON.stringify(data, null, 2)); } else { console.log(`Tags for ${npub}/${repo}:`); if (Array.isArray(data)) { data.forEach(tag => { console.log(` ${tag.name} - ${tag.hash?.substring(0, 7) || 'N/A'}`); }); } } } else if (subcommand === 'fork' && args[1] && args[2]) { const [npub, repo] = args.slice(1); const data = await apiRequest(server, `/repos/${npub}/${repo}/fork`, 'POST', {}); if (json) { console.log(JSON.stringify(data, null, 2)); } else { console.log(`Repository forked successfully: ${data.npub}/${data.repo}`); } } else if (subcommand === 'delete' && args[1] && args[2]) { const [npub, repo] = args.slice(1); const data = await apiRequest(server, `/repos/${npub}/${repo}/delete`, 'DELETE'); console.log(json ? JSON.stringify(data, null, 2) : 'Repository deleted successfully'); } else { console.error('Invalid repos command. Use: list, get, settings, maintainers, branches, tags, fork, delete'); process.exit(1); } }, async file(args, server, json) { const subcommand = args[0]; if (subcommand === 'get' && args[1] && args[2] && args[3]) { const [npub, repo, path] = args.slice(1); const branch = args[4] || 'main'; const data = await apiRequest(server, `/repos/${npub}/${repo}/file?path=${encodeURIComponent(path)}&branch=${branch}`, 'GET'); if (json) { console.log(JSON.stringify(data, null, 2)); } else { console.log(data.content || data); } } else if (subcommand === 'put' && args[1] && args[2] && args[3]) { const [npub, repo, path] = args.slice(1); let content; if (args[4]) { // Read from file try { content = readFileSync(args[4], 'utf-8'); } catch (error) { throw new Error(`Failed to read file ${args[4]}: ${error.message}`); } } else { // Read from stdin const chunks = []; process.stdin.setEncoding('utf8'); return new Promise((resolve, reject) => { process.stdin.on('readable', () => { let chunk; while ((chunk = process.stdin.read()) !== null) { chunks.push(chunk); } }); process.stdin.on('end', async () => { content = chunks.join(''); const commitMessage = args[5] || 'Update file'; const branch = args[6] || 'main'; try { const data = await apiRequest(server, `/repos/${npub}/${repo}/file`, 'POST', { path, content, commitMessage, branch, action: 'write' }); console.log(json ? JSON.stringify(data, null, 2) : 'File updated successfully'); resolve(); } catch (error) { reject(error); } }); }); } const commitMessage = args[5] || 'Update file'; const branch = args[6] || 'main'; const data = await apiRequest(server, `/repos/${npub}/${repo}/file`, 'POST', { path, content, commitMessage, branch, action: 'write' }); console.log(json ? JSON.stringify(data, null, 2) : 'File updated successfully'); } else if (subcommand === 'delete' && args[1] && args[2] && args[3]) { const [npub, repo, path] = args.slice(1); const commitMessage = args[4] || `Delete ${path}`; const branch = args[5] || 'main'; const data = await apiRequest(server, `/repos/${npub}/${repo}/file`, 'POST', { path, commitMessage, branch, action: 'delete' }); console.log(json ? JSON.stringify(data, null, 2) : 'File deleted successfully'); } else { console.error('Invalid file command. Use: get [branch], put [file] [message] [branch], delete [message] [branch]'); process.exit(1); } }, async search(args, server, json) { const query = args.join(' '); if (!query) { console.error('Search query required'); process.exit(1); } const data = await apiRequest(server, `/search?q=${encodeURIComponent(query)}`, 'GET'); if (json) { console.log(JSON.stringify(data, null, 2)); } else { console.log(`Search results for "${query}":`); if (Array.isArray(data)) { data.forEach(repo => { console.log(` ${repo.npub}/${repo.name} - ${repo.description || 'No description'}`); }); } } }, async publish(args, server, json) { const subcommand = args[0]; if (!subcommand || subcommand === '--help' || subcommand === '-h') { console.log(` Publish Nostr Git Events Usage: gitrep publish [options] Subcommands: repo-announcement [options] Publish a repository announcement (kind 30617) Options: --description Repository description --clone-url Clone URL (can be specified multiple times) --web-url Web URL (can be specified multiple times) --maintainer Maintainer pubkey (can be specified multiple times) --relay Custom relay URL (can be specified multiple times) Example: gitrep publish repo-announcement myrepo \\ --description "My awesome repo" \\ --clone-url "https://gitrepublic.com/api/git/npub1.../myrepo.git" \\ --maintainer "npub1..." ownership-transfer [--self-transfer] Transfer repository ownership (kind 1641) Note: You must be the current owner (signing with NOSTRGIT_SECRET_KEY) Example: gitrep publish ownership-transfer myrepo npub1... --self-transfer pr [options] Create a pull request (kind 1618) Options: --content <text> PR description/content --base <branch> Base branch (default: main) --head <branch> Head branch (default: main) Example: gitrep publish pr npub1... myrepo "Fix bug" \\ --content "This PR fixes a critical bug" \\ --base main --head feature-branch issue <owner-npub> <repo> <title> [options] Create an issue (kind 1621) Options: --content <text> Issue description --label <label> Label (can be specified multiple times) Example: gitrep publish issue npub1... myrepo "Bug report" \\ --content "Found a bug" --label bug --label critical status <event-id> <open|applied|closed|draft> [--content <text>] Update PR/issue status (kinds 1630-1633) Example: gitrep publish status abc123... closed --content "Fixed in v1.0" patch <owner-npub> <repo> <patch-file> [options] Publish a git patch (kind 1617) Options: --earliest-commit <id> Earliest unique commit ID (euc) --commit <id> Current commit ID --parent-commit <id> Parent commit ID --root Mark as root patch --root-revision Mark as root revision --reply-to <event-id> Reply to previous patch (NIP-10) --mention <npub> Mention user (can be specified multiple times) Example: gitrep publish patch npub1... myrepo patch-0001.patch \\ --earliest-commit abc123 --commit def456 --root repo-state <repo> [options] Publish repository state (kind 30618) Options: --ref <ref-path> <commit-id> [parent-commits...] Add ref (can be specified multiple times) --head <branch> Set HEAD branch Example: gitrep publish repo-state myrepo \\ --ref refs/heads/main abc123 def456 \\ --ref refs/tags/v1.0.0 xyz789 \\ --head main pr-update <owner-npub> <repo> <pr-event-id> <commit-id> [options] event [options] Publish a generic Nostr event (defaults to kind 1) Options: --kind <number> Event kind (default: 1) --content <text> Event content (default: '') --tag <name> <value> Add a tag (can be specified multiple times) --no-client-tag Don't add client tag (default: adds 'client' tag) --relay <url> Custom relay URL (can be specified multiple times) Examples: gitrep publish event --kind 1 --content "Hello, Nostr!" gitrep publish event --kind 1 --content "Hello" --tag "p" "npub1..." gitrep publish event --kind 42 --content "" --tag "t" "hashtag" --tag "p" "npub1..." gitrep publish event --kind 1 --content "Test" --no-client-tag Update pull request tip commit (kind 1619) Options: --pr-author <npub> PR author pubkey (for NIP-22 tags) --clone-url <url> Clone URL (required, can be specified multiple times) --merge-base <commit-id> Most recent common ancestor --earliest-commit <id> Earliest unique commit ID --mention <npub> Mention user (can be specified multiple times) Example: gitrep publish pr-update npub1... myrepo pr-event-id new-commit-id \\ --pr-author npub1... \\ --clone-url "https://gitrepublic.com/api/git/npub1.../myrepo.git" \\ --merge-base base-commit-id Event Structure: All events are automatically signed with NOSTRGIT_SECRET_KEY and published to relays. Events are stored locally in nostr/ directory (JSONL format) for reference. For detailed event structure documentation, see: - https://github.com/silberengel/gitrepublic-web/tree/main/docs - docs/NIP_COMPLIANCE.md - NIP compliance and event kinds - docs/CustomKinds.md - Custom event kinds (1640, 1641, 30620) Environment Variables: NOSTRGIT_SECRET_KEY Required: Nostr private key (nsec or hex) NOSTR_RELAYS Optional: Comma-separated relay URLs (default: wss://theforest.nostr1.com,wss://relay.damus.io,wss://nostr.land) For more information, see: https://github.com/silberengel/gitrepublic-cli `); process.exit(0); } // Get private key const secretKey = process.env.NOSTRGIT_SECRET_KEY || process.env.NOSTR_PRIVATE_KEY || process.env.NSEC; if (!secretKey) { throw new Error('NOSTRGIT_SECRET_KEY environment variable is not set'); } const privateKeyBytes = getPrivateKeyBytes(secretKey); const pubkey = getPublicKey(privateKeyBytes); // Get relays from environment or use defaults const relaysEnv = process.env.NOSTR_RELAYS; const relays = relaysEnv ? relaysEnv.split(',').map(r => r.trim()).filter(r => r.length > 0) : [ 'wss://nostr.land', 'wss://relay.damus.io', 'wss://thecitadel.nostr1.com', 'wss://nostr21.com', 'wss://theforest.nostr1.com', 'wss://freelay.sovbit.host', 'wss://nostr.sovbit.host', 'wss://bevos.nostr1.com', 'wss://relay.primal.net', ]; if (subcommand === 'repo-announcement') { // publish repo-announcement <repo-name> --description <text> --clone-url <url> [--clone-url <url>...] [--web-url <url>...] [--maintainer <npub>...] [--relay <url>...] const repoName = args[1]; if (!repoName) { console.error('Error: Repository name required'); console.error('Use: publish repo-announcement <repo-name> [options]'); process.exit(1); } const tags = [['d', repoName]]; let description = ''; const cloneUrls = []; const webUrls = []; const maintainers = []; // Parse options for (let i = 2; i < args.length; i++) { if (args[i] === '--description' && args[i + 1]) { description = args[++i]; } else if (args[i] === '--clone-url' && args[i + 1]) { cloneUrls.push(args[++i]); } else if (args[i] === '--web-url' && args[i + 1]) { webUrls.push(args[++i]); } else if (args[i] === '--maintainer' && args[i + 1]) { maintainers.push(args[++i]); } else if (args[i] === '--relay' && args[i + 1]) { relays.push(args[++i]); } } // Add clone URLs for (const url of cloneUrls) { tags.push(['r', url]); } // Add web URLs for (const url of webUrls) { tags.push(['web', url]); } // Add maintainers for (const maintainer of maintainers) { tags.push(['p', maintainer]); } // Add client tag unless --no-client-tag is specified addClientTag(tags, args); const event = finalizeEvent({ kind: 30617, // REPO_ANNOUNCEMENT created_at: Math.floor(Date.now() / 1000), tags, content: description }, privateKeyBytes); // Store event in JSONL file storeEventInJsonl(event); const result = await publishToRelays(event, relays, privateKeyBytes, pubkey); if (json) { console.log(JSON.stringify({ event, published: result }, null, 2)); } else { console.log('Repository announcement published!'); console.log(`Event ID: ${event.id}`); console.log(`Repository: ${repoName}`); console.log(`Event stored in nostr/repo-announcements.jsonl`); console.log(`Published to ${result.success.length} relay(s): ${result.success.join(', ')}`); if (result.failed.length > 0) { console.log(`Failed on ${result.failed.length} relay(s):`); result.failed.forEach(f => console.log(` ${f.relay}: ${f.error}`)); } } } else if (subcommand === 'ownership-transfer') { // publish ownership-transfer <repo> <new-owner-npub> [--self-transfer] // Note: The current owner is determined by the signing key (NOSTRGIT_SECRET_KEY) const [repoName, newOwnerNpub] = args.slice(1); if (!repoName || !newOwnerNpub) { console.error('Error: repo name and new owner npub required'); console.error('Use: publish ownership-transfer <repo> <new-owner-npub> [--self-transfer]'); console.error('Note: You must be the current owner (signing with NOSTRGIT_SECRET_KEY)'); process.exit(1); } const selfTransfer = args.includes('--self-transfer'); // Decode new owner npub to hex let newOwnerPubkey; try { newOwnerPubkey = newOwnerNpub.startsWith('npub') ? decode(newOwnerNpub).data : newOwnerNpub; // Convert to hex string if it's a Uint8Array if (newOwnerPubkey instanceof Uint8Array) { newOwnerPubkey = Array.from(newOwnerPubkey).map(b => b.toString(16).padStart(2, '0')).join(''); } } catch (err) { throw new Error(`Invalid npub format: ${err.message}`); } // Current owner is the pubkey from the signing key const currentOwnerPubkey = pubkey; const repoAddress = `30617:${currentOwnerPubkey}:${repoName}`; const tags = [ ['a', repoAddress], ['p', newOwnerPubkey], ['d', repoName] ]; if (selfTransfer) { tags.push(['t', 'self-transfer']); } // Add client tag unless --no-client-tag is specified addClientTag(tags, args); const event = finalizeEvent({ kind: 1641, // OWNERSHIP_TRANSFER created_at: Math.floor(Date.now() / 1000), tags, content: '' }, privateKeyBytes); // Store event in JSONL file storeEventInJsonl(event); const result = await publishToRelays(event, relays, privateKeyBytes, pubkey); if (json) { console.log(JSON.stringify({ event, published: result }, null, 2)); } else { const currentOwnerNpub = nip19.npubEncode(currentOwnerPubkey); console.log('Ownership transfer published!'); console.log(`Event ID: ${event.id}`); console.log(`Repository: ${currentOwnerNpub}/${repoName}`); console.log(`Current owner: ${currentOwnerNpub}`); console.log(`New owner: ${newOwnerNpub}`); console.log(`Event stored in nostr/ownership-transfers.jsonl`); console.log(`Published to ${result.success.length} relay(s): ${result.success.join(', ')}`); if (result.failed.length > 0) { console.log(`Failed on ${result.failed.length} relay(s):`); result.failed.forEach(f => console.log(` ${f.relay}: ${f.error}`)); } } } else if (subcommand === 'pr' || subcommand === 'pull-request') { // publish pr <npub> <repo> <title> [--content <text>] [--base <branch>] [--head <branch>] const [ownerNpub, repoName, title] = args.slice(1); if (!ownerNpub || !repoName || !title) { console.error('Error: owner npub, repo name, and title required'); console.error('Use: publish pr <owner-npub> <repo> <title> [options]'); process.exit(1); } let ownerPubkey; try { ownerPubkey = ownerNpub.startsWith('npub') ? decode(ownerNpub).data : ownerNpub; } catch (err) { throw new Error(`Invalid npub format: ${err.message}`); } const repoAddress = `30617:${ownerPubkey}:${repoName}`; const tags = [ ['a', repoAddress], ['p', ownerPubkey], ['subject', title] ]; let content = ''; let baseBranch = 'main'; let headBranch = 'main'; for (let i = 4; i < args.length; i++) { if (args[i] === '--content' && args[i + 1]) { content = args[++i]; } else if (args[i] === '--base' && args[i + 1]) { baseBranch = args[++i]; } else if (args[i] === '--head' && args[i + 1]) { headBranch = args[++i]; } } if (baseBranch !== headBranch) { tags.push(['base', baseBranch]); tags.push(['head', headBranch]); } // Add client tag unless --no-client-tag is specified addClientTag(tags, args); const event = finalizeEvent({ kind: 1618, // PULL_REQUEST created_at: Math.floor(Date.now() / 1000), tags, content }, privateKeyBytes); // Store event in JSONL file storeEventInJsonl(event); const result = await publishToRelays(event, relays, privateKeyBytes, pubkey); if (json) { console.log(JSON.stringify({ event, published: result }, null, 2)); } else { console.log('Pull request published!'); console.log(`Event ID: ${event.id}`); console.log(`Repository: ${ownerNpub}/${repoName}`); console.log(`Title: ${title}`); console.log(`Event stored in nostr/pull-requests.jsonl`); console.log(`Published to ${result.success.length} relay(s): ${result.success.join(', ')}`); if (result.failed.length > 0) { console.log(`Failed on ${result.failed.length} relay(s):`); result.failed.forEach(f => console.log(` ${f.relay}: ${f.error}`)); } } } else if (subcommand === 'issue') { // publish issue <npub> <repo> <title> [--content <text>] [--label <label>...] const [ownerNpub, repoName, title] = args.slice(1); if (!ownerNpub || !repoName || !title) { console.error('Error: owner npub, repo name, and title required'); console.error('Use: publish issue <owner-npub> <repo> <title> [options]'); process.exit(1); } let ownerPubkey; try { ownerPubkey = ownerNpub.startsWith('npub') ? decode(ownerNpub).data : ownerNpub; } catch (err) { throw new Error(`Invalid npub format: ${err.message}`); } const repoAddress = `30617:${ownerPubkey}:${repoName}`; const tags = [ ['a', repoAddress], ['p', ownerPubkey], ['subject', title] ]; let content = ''; const labels = []; for (let i = 4; i < args.length; i++) { if (args[i] === '--content' && args[i + 1]) { content = args[++i]; } else if (args[i] === '--label' && args[i + 1]) { labels.push(args[++i]); } } for (const label of labels) { tags.push(['t', label]); } // Add client tag unless --no-client-tag is specified addClientTag(tags, args); const event = finalizeEvent({ kind: 1621, // ISSUE created_at: Math.floor(Date.now() / 1000), tags, content }, privateKeyBytes); // Store event in JSONL file storeEventInJsonl(event); const result = await publishToRelays(event, relays, privateKeyBytes, pubkey); if (json) { console.log(JSON.stringify({ event, published: result }, null, 2)); } else { console.log('Issue published!'); console.log(`Event ID: ${event.id}`); console.log(`Repository: ${ownerNpub}/${repoName}`); console.log(`Title: ${title}`); console.log(`Event stored in nostr/issues.jsonl`); console.log(`Published to ${result.success.length} relay(s): ${result.success.join(', ')}`); if (result.failed.length > 0) { console.log(`Failed on ${result.failed.length} relay(s):`); result.failed.forEach(f => console.log(` ${f.relay}: ${f.error}`)); } } } else if (subcommand === 'status') { // publish status <event-id> <status> [--content <text>] // status: open|applied|closed|draft const [eventId, status] = args.slice(1); if (!eventId || !status) { console.error('Error: event ID and status required'); console.error('Use: publish status <event-id> <open|applied|closed|draft> [--content <text>]'); process.exit(1); } const statusKinds = { 'open': 1630, 'applied': 1631, 'closed': 1632, 'draft': 1633 }; const kind = statusKinds[status.toLowerCase()]; if (!kind) { console.error(`Error: Invalid status. Use: open, applied, closed, or draft`); process.exit(1); } const tags = [['e', eventId]]; let content = ''; for (let i = 3; i < args.length; i++) { if (args[i] === '--content' && args[i + 1]) { content = args[++i]; } } // Add client tag unless --no-client-tag is specified addClientTag(tags, args); const event = finalizeEvent({ kind, created_at: Math.floor(Date.now() / 1000), tags, content }, privateKeyBytes); // Store event in JSONL file storeEventInJsonl(event); const result = await publishToRelays(event, relays, privateKeyBytes, pubkey); if (json) { console.log(JSON.stringify({ event, published: result }, null, 2)); } else { console.log(`Status event published!`); console.log(`Event ID: ${event.id}`); console.log(`Status: ${status}`); console.log(`Target event: ${eventId}`); console.log(`Event stored in nostr/status-events.jsonl`); console.log(`Published to ${result.success.length} relay(s): ${result.success.join(', ')}`); if (result.failed.length > 0) { console.log(`Failed on ${result.failed.length} relay(s):`); result.failed.forEach(f => console.log(` ${f.relay}: ${f.error}`)); } } } else if (subcommand === 'patch') { // publish patch <owner-npub> <repo> <patch-file> [options] // Patch content should be from git format-patch const [ownerNpub, repoName, patchFile] = args.slice(1); if (!ownerNpub || !repoName || !patchFile) { console.error('Error: owner npub, repo name, and patch file required'); console.error('Use: publish patch <owner-npub> <repo> <patch-file> [options]'); console.error('Note: Patch file should be generated with: git format-patch'); process.exit(1); } let ownerPubkey; try { ownerPubkey = ownerNpub.startsWith('npub') ? decode(ownerNpub).data : ownerNpub; if (ownerPubkey instanceof Uint8Array) { ownerPubkey = Array.from(ownerPubkey).map(b => b.toString(16).padStart(2, '0')).join(''); } } catch (err) { throw new Error(`Invalid npub format: ${err.message}`); } // Read patch file let patchContent; try { patchContent = readFileSync(patchFile, 'utf-8'); } catch (err) { throw new Error(`Failed to read patch file: ${err.message}`); } const repoAddress = `30617:${ownerPubkey}:${repoName}`; const tags = [ ['a', repoAddress], ['p', ownerPubkey] ]; // Parse options let earliestCommit = null; let commitId = null; let parentCommit = null; let isRoot = false; let isRootRevision = false; const mentions = []; for (let i = 4; i < args.length; i++) { if (args[i] === '--earliest-commit' && args[i + 1]) { earliestCommit = args[++i]; tags.push(['r', earliestCommit]); } else if (args[i] === '--commit' && args[i + 1]) { commitId = args[++i]; tags.push(['commit', commitId]); tags.push(['r', commitId]); } else if (args[i] === '--parent-commit' && args[i + 1]) { parentCommit = args[++i]; tags.push(['parent-commit', parentCommit]); } else if (args[i] === '--root') { isRoot = true; tags.push(['t', 'root']); } else if (args[i] === '--root-revision') { isRootRevision = true; tags.push(['t', 'root-revision']); } else if (args[i] === '--mention' && args[i + 1]) { mentions.push(args[++i]); } else if (args[i] === '--reply-to' && args[i + 1]) { // NIP-10 reply tag tags.push(['e', args[++i], '', 'reply']); } } // Add earliest commit if provided if (earliestCommit) { tags.push(['r', earliestCommit, 'euc']); } // Add mentions for (const mention of mentions) { let mentionPubkey = mention; try { if (mention.startsWith('npub')) { mentionPubkey = decode(mention).data; if (mentionPubkey instanceof Uint8Array) { mentionPubkey = Array.from(mentionPubkey).map(b => b.toString(16).padStart(2, '0')).join(''); } } } catch { // Keep original if decode fails } tags.push(['p', mentionPubkey]); } // Add client tag unless --no-client-tag is specified addClientTag(tags, args); const event = finalizeEvent({ kind: 1617, // PATCH created_at: Math.floor(Date.now() / 1000), tags, content: patchContent }, privateKeyBytes); // Store event in JSONL file storeEventInJsonl(event); const result = await publishToRelays(event, relays, privateKeyBytes, pubkey); if (json) { console.log(JSON.stringify({ event, published: result }, null, 2)); } else { console.log('Patch published!'); console.log(`Event ID: ${event.id}`); console.log(`Repository: ${ownerNpub}/${repoName}`); console.log(`Patch file: ${patchFile}`); console.log(`Event stored in nostr/patches.jsonl`); console.log(`Published to ${result.success.length} relay(s): ${result.success.join(', ')}`); if (result.failed.length > 0) { console.log(`Failed on ${result.failed.length} relay(s):`); result.failed.forEach(f => console.log(` ${f.relay}: ${f.error}`)); } } } else if (subcommand === 'repo-state') { // publish repo-state <repo> [options] // Options: --ref <ref-path> <commit-id> [--parent <commit-id>...] --head <branch> const repoName = args[1]; if (!repoName) { console.error('Error: Repository name required'); console.error('Use: publish repo-state <repo> [options]'); process.exit(1); } // Current owner is the pubkey from the signing key const currentOwnerPubkey = pubkey; const tags = [['d', repoName]]; let headBranch = null; // Parse options for (let i = 2; i < args.length; i++) { if (args[i] === '--ref' && args[i + 2]) { const refPath = args[++i]; const commitId = args[++i]; const refTag = [refPath, commitId]; // Check for parent commits while (i + 1 < args.length && args[i + 1] !== '--ref' && args[i + 1] !== '--head') { refTag.push(args[++i]); } tags.push(refTag); } else if (args[i] === '--head' && args[i + 1]) { headBranch = args[++i]; tags.push(['HEAD', `ref: refs/heads/${headBranch}`]); } } // Add client tag unless --no-client-tag is specified addClientTag(tags, args); const event = finalizeEvent({ kind: 30618, // REPO_STATE created_at: Math.floor(Date.now() / 1000), tags, content: '' }, privateKeyBytes); // Store event in JSONL file storeEventInJsonl(event); const result = await publishToRelays(event, relays, privateKeyBytes, pubkey); if (json) { console.log(JSON.stringify({ event, published: result }, null, 2)); } else { const currentOwnerNpub = nip19.npubEncode(currentOwnerPubkey); console.log('Repository state published!'); console.log(`Event ID: ${event.id}`); console.log(`Repository: ${currentOwnerNpub}/${repoName}`); if (headBranch) { console.log(`HEAD: ${headBranch}`); } console.log(`Refs: ${tags.filter(t => t[0].startsWith('refs/')).length}`); console.log(`Event stored in nostr/repo-states.jsonl`); console.log(`Published to ${result.success.length} relay(s): ${result.success.join(', ')}`); if (result.failed.length > 0) { console.log(`Failed on ${result.failed.length} relay(s):`); result.failed.forEach(f => console.log(` ${f.relay}: ${f.error}`)); } } } else if (subcommand === 'pr-update' || subcommand === 'pull-request-update') { // publish pr-update <owner-npub> <repo> <pr-event-id> <commit-id> [options] const [ownerNpub, repoName, prEventId, commitId] = args.slice(1); if (!ownerNpub || !repoName || !prEventId || !commitId) { console.error('Error: owner npub, repo name, PR event ID, and commit ID required'); console.error('Use: publish pr-update <owner-npub> <repo> <pr-event-id> <commit-id> [options]'); process.exit(1); } let ownerPubkey; try { ownerPubkey = ownerNpub.startsWith('npub') ? decode(ownerNpub).data : ownerNpub; if (ownerPubkey instanceof Uint8Array) { ownerPubkey = Array.from(ownerPubkey).map(b => b.toString(16).padStart(2, '0')).join(''); } } catch (err) { throw new Error(`Invalid npub format: ${err.message}`); } // Get PR author pubkey (needed for NIP-22 tags) // For now, we'll require it as an option or try to get it from the PR event let prAuthorPubkey = null; const cloneUrls = []; let mergeBase = null; let earliestCommit = null; const mentions = []; for (let i = 5; i < args.length; i++) { if (args[i] === '--pr-author' && args[i + 1]) { let authorNpub = args[++i]; try { prAuthorPubkey = authorNpub.startsWith('npub') ? decode(authorNpub).data : authorNpub; if (prAuthorPubkey instanceof Uint8Array) { prAuthorPubkey = Array.from(prAuthorPubkey).map(b => b.toString(16).padStart(2, '0')).join(''); } } catch (err) { throw new Error(`Invalid pr-author npub format: ${err.message}`); } } else if (args[i] === '--clone-url' && args[i + 1]) { cloneUrls.push(args[++i]); } else if (args[i] === '--merge-base' && args[i + 1]) { mergeBase = args[++i]; } else if (args[i] === '--earliest-commit' && args[i + 1]) { earliestCommit = args[++i]; } else if (args[i] === '--mention' && args[i + 1]) { mentions.push(args[++i]); } } const repoAddress = `30617:${ownerPubkey}:${repoName}`; const tags = [ ['a', repoAddress], ['p', ownerPubkey], ['E', prEventId], // NIP-22 root event reference ['c', commitId] ]; // Add earliest commit if provided if (earliestCommit) { tags.push(['r', earliestCommit, 'euc']); } // Add mentions for (const mention of mentions) { let mentionPubkey = mention; try { if (mention.startsWith('npub')) { mentionPubkey = decode(mention).data; if (mentionPubkey instanceof Uint8Array) { mentionPubkey = Array.from(mentionPubkey).map(b => b.toString(16).padStart(2, '0')).join(''); } } } catch { // Keep original if decode fails } tags.push(['p', mentionPubkey]); } // Add PR author if provided (NIP-22 root pubkey reference) if (prAuthorPubkey) { tags.push(['P', prAuthorPubkey]); } // Add clone URLs (required) if (cloneUrls.length === 0) { console.error('Error: At least one --clone-url is required'); process.exit(1); } for (const url of cloneUrls) { tags.push(['clone', url]); } // Add merge base if provided if (mergeBase) { tags.push(['merge-base', mergeBase]); } // Add client tag unless --no-client-tag is specified addClientTag(tags, args); const event = finalizeEvent({ kind: 1619, // PULL_REQUEST_UPDATE created_at: Math.floor(Date.now() / 1000), tags, content: '' }, privateKeyBytes); // Store event in JSONL file storeEventInJsonl(event); const result = await publishToRelays(event, relays, privateKeyBytes, pubkey); if (json) { console.log(JSON.stringify({ event, published: result }, null, 2)); } else { console.log('Pull request update published!'); console.log(`Event ID: ${event.id}`); console.log(`Repository: ${ownerNpub}/${repoName}`); console.log(`PR Event ID: ${prEventId}`); console.log(`New commit: ${commitId}`); console.log(`Event stored in nostr/pull-request-updates.jsonl`); console.log(`Published to ${result.success.length} relay(s): ${result.success.join(', ')}`); if (result.failed.length > 0) { console.log(`Failed on ${result.failed.length} relay(s):`); result.failed.forEach(f => console.log(` ${f.relay}: ${f.error}`)); } } } else if (subcommand === 'event') { // Generic event publishing // Check for help if (args.slice(1).includes('--help') || args.slice(1).includes('-h')) { console.log(` Publish Generic Nostr Event Usage: gitrep publish event [content] [options] Description: Publish a generic Nostr event with any kind, content, and tags. Defaults to kind 1 (text note) if not specified. Content can be provided as a positional argument or with --content. Options: --kind <number> Event kind (default: 1) --content <text> Event content (default: '') --tag <name> <value> Add a tag (can be specified multiple times) --no-client-tag Don't add client tag (default: adds 'client' tag) --relay <url> Custom relay URL (can be specified multiple times) --help, -h Show this help message Examples: gitrep publish event "Hello, Nostr!" # Simple text note gitrep publish event --kind 1 --content "Hello, Nostr!" gitrep publish event --kind 1 --content "Hello" --tag "p" "npub1..." gitrep publish event --kind 42 "" --tag "t" "hashtag" --tag "p" "npub1..." gitrep publish event "Test" --no-client-tag gitrep publish event "Test" --relay "wss://relay.example.com" Notes: - All events are automatically signed with NOSTRGIT_SECRET_KEY - Events are stored locally in nostr/events-kind-<kind>.jsonl - Client tag is added by default unless --no-client-tag is specified `); process.exit(0); } let kind = 1; // Default to kind 1 let content = ''; const tags = []; const customRelays = []; let positionalContent = null; // Parse options and positional arguments for (let i = 1; i < args.length; i++) { if (args[i] === '--kind' && args[i + 1]) { kind = parseInt(args[++i], 10); if (isNaN(kind)) { console.error('Error: --kind must be a number'); process.exit(1); } } else if (args[i] === '--content' && args[i + 1] !== undefined) { content = args[++i]; } else if (args[i] === '--tag' && args[i + 1] && args[i + 2]) { const tagName = args[++i]; const tagValue = args[++i]; tags.push([tagName, tagValue]); } else if (args[i] === '--relay' && args[i + 1]) { customRelays.push(args[++i]); } else if (args[i] === '--no-client-tag') { // Handled by addClientTag function } else if (args[i] === '--help' || args[i] === '-h') { // Already handled above } else if (!args[i].startsWith('--')) { // Positional argument - treat as content if no --content was specified if (positionalContent === null && content === '') { positionalContent = args[i]; } else { console.error(`Error: Unexpected positional argument: ${args[i]}`); console.error('Use: publish event [content] [options]'); console.error('Run: publish event --help for detailed usage'); process.exit(1); } } else { console.error(`Error: Unknown option: ${args[i]}`); console.error('Use: publish event [content] [options]'); console.error('Run: publish event --help for detailed usage'); process.exit(1); } } // Use positional content if provided and --content was not used if (positionalContent !== null && content === '') { content = positionalContent; } // Add client tag unless --no-client-tag is specified addClientTag(tags, args); // Use custom relays if provided, otherwise use defaults const eventRelays = customRelays.length > 0 ? customRelays : relays; const event = finalizeEvent({ kind, created_at: Math.floor(Date.now() / 1000), tags, content }, privateKeyBytes); // Store event in JSONL file storeEventInJsonl(event); let result; try { result = await publishToRelays(event, eventRelays, privateKeyBytes, pubkey); } catch (error) { // Handle relay errors gracefully - don't crash const errorMessage = error instanceof Error ? error.message : String(error); result = { success: [], failed: eventRelays.map(relay => ({ relay, error: errorMessage })) }; } if (json) { console.log(JSON.stringify({ event, published: result }, null, 2)); } else { console.log('Event published!'); console.log(`Event ID: ${event.id}`); console.log(`Kind: ${kind}`); console.log(`Content: ${content || '(empty)'}`); console.log(`Tags: ${tags.length}`); console.log(`Event stored in nostr/events-kind-${kind}.jsonl`); if (result.success.length > 0) { console.log(`Published to ${result.success.length} relay(s): ${result.success.join(', ')}`); } if (result.failed.length > 0) { console.log(`Failed on ${result.failed.length} relay(s):`); result.failed.forEach(f => console.log(` ${f.relay}: ${f.error}`)); } // Exit with error code only if all relays failed if (result.success.length === 0 && result.failed.length > 0) { process.exit(1); } } } else { console.error(`Error: Unknown publish subcommand: ${subcommand}`); console.error('Use: publish repo-announcement|ownership-transfer|pr|pr-update|issue|status|patch|repo-state|event'); console.error('Run: publish --help for detailed usage'); process.exit(1); } }, async verify(args, server, json) { // verify <event-file> or verify <event-json> const input = args[0]; if (!input) { console.error('Error: Event file path or JSON required'); console.error('Use: verify <event-file.jsonl> or verify <event-json>'); process.exit(1); } let event; try { // Try to read as file first if (existsSync(input)) { const content = readFileSync(input, 'utf-8').trim(); // If it's JSONL, get the last line (most recent event) const lines = content.split('\n').filter(l => l.trim()); const lastLine = lines[lines.length - 1]; event = JSON.parse(lastLine); } else { // Try to parse as JSON directly event = JSON.parse(input); } } catch (err) { console.error(`Error: Failed to parse event: ${err instanceof Error ? err.message : 'Unknown error'}`); process.exit(1); } // Verify event const signatureValid = verifyEvent(event); const computedId = getEventHash(event); const idMatches = event.id === computedId; if (json) { console.log(JSON.stringify({ valid: signatureValid && idMatches, signatureValid, idMatches, computedId, eventId: event.id, kind: event.kind, pubkey: event.pubkey, created_at: event.created_at, timestamp: new Date(event.created_at * 1000).toLocaleString(), timestamp_utc: new Date(event.created_at * 1000).toISOString() }, null, 2)); } else { console.log('Event Verification:'); console.log(` Kind: ${event.kind}`); console.log(` Pubkey: ${event.pubkey.substring(0, 16)}...`); console.log(` Created: ${new Date(event.created_at * 1000).toLocaleString()}`); console.log(` Event ID: ${event.id.substring(0, 16)}...`); console.log(''); console.log('Verification Results:'); console.log(` Signature valid: ${signatureValid ? '✅ Yes' : '❌ No'}`); console.log(` Event ID matches: ${idMatches ? '✅ Yes' : '❌ No'}`); if (!idMatches) { console.log(` Computed ID: ${computedId}`); console.log(` Expected ID: ${event.id}`); } console.log(''); if (signatureValid && idMatches) { console.log('✅ Event is VALID'); } else { console.log('❌ Event is INVALID'); if (!signatureValid) { console.log(' - Signature verification failed'); } if (!idMatches) { console.log(' - Event ID does not match computed hash'); } process.exit(1); } } }, async pushAll(args, server, json) { // push-all [branch] [--force] [--tags] [--dry-run] - Push to all remotes // Check for help flag if (args.includes('--help') || args.includes('-h')) { console.log(`Push to All Remotes Usage: gitrep push-all [branch] [options] Description: Pushes the current branch (or specified branch) to all configured git remotes. This is useful when you have multiple remotes (e.g., GitHub, GitLab, GitRepublic) and want to push to all of them at once. Arguments: branch Optional branch name to push. If not specified, pushes all branches. Options: --force, -f Force push (use with caution) --tags Also push tags --dry-run, -n Show what would be pushed without actually pushing --help, -h Show this help message Examples: gitrep push-all Push all branches to all remotes gitrep push-all main Push main branch to all remotes gitrep push-all main --force Force push main branch to all remotes gitrep push-all --tags Push all branches and tags to all remotes gitrep push-all main --dry-run Show what would be pushed without pushing Notes: - This command requires you to be in a git repository - It will push to all remotes listed by 'git remote' - If any remote fails, the command will exit with an error code - Use --dry-run to test before actually pushing `); return; // Exit the function (process.exit is handled by the caller) } // Parse arguments const branch = args.find(arg => !arg.startsWith('--')); const force = args.includes('--force') || args.includes('-f'); const tags = args.includes('--tags'); const dryRun = args.includes('--dry-run') || args.includes('-n'); // Get all remotes let remotes = []; try { const remoteOutput = execSync('git remote', { encoding: 'utf-8' }).trim(); remotes = remoteOutput.split('\n').filter(r => r.trim()); } catch (err) { console.error('Error: Not in a git repository or unable to read remotes'); console.error(err instanceof Error ? err.message : 'Unknown error'); process.exit(1); } if (remotes.length === 0) { console.error('Error: No remotes configured'); process.exit(1); } // Build push command const pushArgs = []; if (force) pushArgs.push('--force'); if (tags) pushArgs.push('--tags'); if (dryRun) pushArgs.push('--dry-run'); if (branch) { // If branch is specified, push to each remote with that branch pushArgs.push(branch); } else { // Push all branches pushArgs.push('--all'); } const results = []; let successCount = 0; let failCount = 0; for (const remote of remotes) { try { if (!json && !dryRun) { console.log(`\nPushing to ${remote}...`); } const command = ['push', remote, ...pushArgs]; execSync(`git ${command.join(' ')}`, { stdio: json ? 'pipe' : 'inherit', encoding: 'utf-8' }); results.push({ remote, status: 'success' }); successCount++; if (!json && !dryRun) { console.log(`✅ Successfully pushed to ${remote}`); } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; results.push({ remote, status: 'failed', error: errorMessage }); failCount++; if (!json && !dryRun) { console.error(`❌ Failed to push to ${remote}: ${errorMessage}`); } } } if (json) { console.log(JSON.stringify({ total: remotes.length, success: successCount, failed: failCount, results }, null, 2)); } else { console.log('\n' + '='.repeat(70)); console.log(`Push Summary: ${successCount} succeeded, ${failCount} failed out of ${remotes.length} remotes`); console.log('='.repeat(70)); if (failCount > 0) { console.log('\nFailed remotes:'); results.filter(r => r.status === 'failed').forEach(r => { console.log(` ${r.remote}: ${r.error}`); }); process.exit(1); } } } }; // Main execution const args = process.argv.slice(2); const commandIndex = args.findIndex(arg => !arg.startsWith('--')); const command = commandIndex >= 0 ? args[commandIndex] : null; const commandArgs = commandIndex >= 0 ? args.slice(commandIndex + 1) : []; // Parse options const serverIndex = args.indexOf('--server'); const server = serverIndex >= 0 && args[serverIndex + 1] ? args[serverIndex + 1] : DEFAULT_SERVER; const json = args.includes('--json'); // Check if --help or -h is in command args (after command) - if so, it's command-specific help const commandHelpRequested = command && (commandArgs.includes('--help') || commandArgs.includes('-h')); // Only treat as general help if --help or -h is before the command or there's no command const help = !commandHelpRequested && (args.includes('--help') || args.includes('-h')); // Add config command if (command === 'config') { const subcommand = commandArgs[0]; if (subcommand === 'server' || !subcommand) { if (json) { console.log(JSON.stringify({ server, default: DEFAULT_SERVER, fromEnv: !!process.env.GITREPUBLIC_SERVER }, null, 2)); } else { console.log('GitRepublic Server Configuration:'); console.log(` Current: ${server}`); console.log(` Default: ${DEFAULT_SERVER}`); if (process.env.GITREPUBLIC_SERVER) { console.log(` From environment: ${process.env.GITREPUBLIC_SERVER}`); } else { console.log(' From environment: (not set)'); console.log(' ⚠️ Note: Default is for development only (localhost:5173)'); console.log(' ⚠️ Set GITREPUBLIC_SERVER for production use'); } console.log(''); console.log('To change the server:'); console.log(' gitrep --server <url> <command> (or gitrepublic)'); console.log(' export GITREPUBLIC_SERVER=<url>'); } process.exit(0); } else { console.error('Invalid config command. Use: config [server]'); process.exit(1); } } // Convert kebab-case to camelCase for command lookup (do this before help check) const commandKey = command ? command.replace(/-([a-z])/g, (g) => g[1].toUpperCase()) : null; const commandHandler = commandKey ? (commands[commandKey] || commands[command]) : null; // If help is requested for a specific command, let the handler deal with it if (commandHelpRequested && commandHandler) { // The handler will check for --help and show command-specific help commandHandler(commandArgs, server, json).catch(error => { console.error('Error:', error.message); process.exit(1); }); // Exit after handler processes help (handler should exit, but just in case) process.exit(0); } if (help || !command || !commandHandler) { console.log(`GitRepublic CLI Usage: gitrep <command> [options] (or gitrepublic) Commands: config [server] Show configuration (server URL) repos list List repositories repos get <npub> <repo> Get repository info (or use naddr: repos get <naddr>) repos settings <npub> <repo> [--description <text>] [--private <true|false>] Get/update settings repos maintainers <npub> <repo> [add|remove <npub>] Manage maintainers repos branches <npub> <repo> List branches repos tags <npub> <repo> List tags repos fork <npub> <repo> Fork a repository repos delete <npub> <repo> Delete a repository file get <npub> <repo> <path> [branch] Get file content file put <npub> <repo> <path> [file] [message] [branch] Create/update file file delete <npub> <repo> <path> [message] [branch] Delete file search <query> Search repositories publish <subcommand> [options] Publish Nostr Git events (use: publish --help for details) verify <event-file>|<event-json> Verify a Nostr event signature and ID push-all [branch] [--force] [--tags] [--dry-run] Push to all configured remotes Options: --server <url> GitRepublic server URL (default: ${DEFAULT_SERVER}) --json Output JSON format --help Show this help Environment variables: NOSTRGIT_SECRET_KEY Nostr private key (nsec or hex) GITREPUBLIC_SERVER Default server URL NOSTR_RELAYS Comma-separated list of Nostr relays (default: wss://theforest.nostr1.com,wss://relay.damus.io,wss://nostr.land) Documentation: https://github.com/silberengel/gitrepublic-cli GitCitadel: Visit us on GitHub: https://github.com/ShadowySupercode or on our homepage: https://gitcitadel.com GitRepublic CLI - Copyright (c) 2026 GitCitadel LLC Licensed under MIT License `); process.exit(help ? 0 : 1); } // Execute command if (!commandHandler) { console.error(`Error: Unknown command: ${command}`); console.error('Use --help to see available commands'); process.exit(1); } commandHandler(commandArgs, server, json).catch(error => { console.error('Error:', error.message); process.exit(1); });