From 45b9c7f7b4167cebb887c562de3eb21d6b6315be Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 19 Feb 2026 16:37:37 +0100 Subject: [PATCH] Sync from gitrepublic-web monorepo --- scripts/commands/file.js | 88 + scripts/commands/index.js | 7 + scripts/commands/publish/event.js | 129 ++ scripts/commands/publish/index.js | 267 +++ scripts/commands/publish/issue.js | 60 + .../commands/publish/ownership-transfer.js | 62 + scripts/commands/publish/patch.js | 111 + scripts/commands/publish/pr-update.js | 119 + scripts/commands/publish/pr.js | 64 + scripts/commands/publish/repo-announcement.js | 65 + scripts/commands/publish/repo-state.js | 59 + scripts/commands/publish/status.js | 52 + scripts/commands/pushAll.js | 132 ++ scripts/commands/repos.js | 280 +++ scripts/commands/search.js | 23 + scripts/commands/verify.js | 80 + scripts/config.js | 22 + scripts/git-commit-msg-hook.js | 24 +- scripts/gitrepublic.js | 1915 +---------------- scripts/relay/index.js | 2 + scripts/relay/publisher.js | 77 + scripts/relay/relay-fetcher.js | 195 ++ scripts/utils/api.js | 44 + scripts/utils/auth.js | 44 + scripts/utils/error-sanitizer.js | 19 + scripts/utils/event-storage.js | 77 + scripts/utils/keys.js | 61 + scripts/utils/tags.js | 12 + 28 files changed, 2177 insertions(+), 1913 deletions(-) create mode 100644 scripts/commands/file.js create mode 100644 scripts/commands/index.js create mode 100644 scripts/commands/publish/event.js create mode 100644 scripts/commands/publish/index.js create mode 100644 scripts/commands/publish/issue.js create mode 100644 scripts/commands/publish/ownership-transfer.js create mode 100644 scripts/commands/publish/patch.js create mode 100644 scripts/commands/publish/pr-update.js create mode 100644 scripts/commands/publish/pr.js create mode 100644 scripts/commands/publish/repo-announcement.js create mode 100644 scripts/commands/publish/repo-state.js create mode 100644 scripts/commands/publish/status.js create mode 100644 scripts/commands/pushAll.js create mode 100644 scripts/commands/repos.js create mode 100644 scripts/commands/search.js create mode 100644 scripts/commands/verify.js create mode 100755 scripts/config.js create mode 100644 scripts/relay/index.js create mode 100644 scripts/relay/publisher.js create mode 100644 scripts/relay/relay-fetcher.js create mode 100644 scripts/utils/api.js create mode 100644 scripts/utils/auth.js create mode 100644 scripts/utils/error-sanitizer.js create mode 100644 scripts/utils/event-storage.js create mode 100644 scripts/utils/keys.js create mode 100644 scripts/utils/tags.js diff --git a/scripts/commands/file.js b/scripts/commands/file.js new file mode 100644 index 0000000..94ab240 --- /dev/null +++ b/scripts/commands/file.js @@ -0,0 +1,88 @@ +import { readFileSync } from 'fs'; +import { apiRequest } from '../utils/api.js'; + +/** + * File operations command + */ +export async function 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); + } +} diff --git a/scripts/commands/index.js b/scripts/commands/index.js new file mode 100644 index 0000000..a6cab31 --- /dev/null +++ b/scripts/commands/index.js @@ -0,0 +1,7 @@ +// Export all commands +export { repos } from './repos.js'; +export { file } from './file.js'; +export { search } from './search.js'; +export { publish } from './publish/index.js'; +export { verify } from './verify.js'; +export { pushAll } from './pushAll.js'; diff --git a/scripts/commands/publish/event.js b/scripts/commands/publish/event.js new file mode 100644 index 0000000..20953df --- /dev/null +++ b/scripts/commands/publish/event.js @@ -0,0 +1,129 @@ +import { finalizeEvent } from 'nostr-tools'; +import { publishEventCommon, addClientTag } from './index.js'; + +/** + * Publish generic event + */ +export async function publishEvent(args, relays, privateKeyBytes, pubkey, json) { + // Check for help + if (args.includes('--help') || args.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 Event kind (default: 1) + --content Event content (default: '') + --tag Add a tag (can be specified multiple times) + --no-client-tag Don't add client tag (default: adds 'client' tag) + --relay 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-.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 = 0; 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); + + let result; + try { + result = await publishEventCommon(event, eventRelays, privateKeyBytes, pubkey, json, 'Event'); + } catch (error) { + // Handle relay errors gracefully - don't crash + const { sanitizeErrorMessage } = await import('../../utils/error-sanitizer.js'); + const errorMessage = error instanceof Error ? error.message : String(error); + const sanitized = sanitizeErrorMessage(errorMessage); + result = { + success: [], + failed: eventRelays.map(relay => ({ relay, error: sanitized })) + }; + } + + if (!json) { + console.log(`Kind: ${kind}`); + console.log(`Content: ${content || '(empty)'}`); + console.log(`Tags: ${tags.length}`); + // Exit with error code only if all relays failed + if (result.success.length === 0 && result.failed.length > 0) { + process.exit(1); + } + } +} diff --git a/scripts/commands/publish/index.js b/scripts/commands/publish/index.js new file mode 100644 index 0000000..cf8dd10 --- /dev/null +++ b/scripts/commands/publish/index.js @@ -0,0 +1,267 @@ +import { getPrivateKeyFromEnv, getPrivateKeyBytes } from '../../utils/keys.js'; +import { getPublicKey } from 'nostr-tools'; +import { DEFAULT_RELAYS } from '../../config.js'; +import { publishToRelays } from '../../relay/publisher.js'; +import { enhanceRelayList } from '../../relay/relay-fetcher.js'; +import { storeEventInJsonl } from '../../utils/event-storage.js'; +import { addClientTag } from '../../utils/tags.js'; + +// Import publish subcommands +import { publishRepoAnnouncement } from './repo-announcement.js'; +import { publishOwnershipTransfer } from './ownership-transfer.js'; +import { publishPR } from './pr.js'; +import { publishIssue } from './issue.js'; +import { publishStatus } from './status.js'; +import { publishPatch } from './patch.js'; +import { publishRepoState } from './repo-state.js'; +import { publishPRUpdate } from './pr-update.js'; +import { publishEvent } from './event.js'; + +/** + * Main publish command handler + */ +export async function publish(args, server, json) { + const subcommand = args[0]; + + if (!subcommand || subcommand === '--help' || subcommand === '-h') { + showPublishHelp(); + process.exit(0); + } + + // Get private key + const secretKey = getPrivateKeyFromEnv(); + const privateKeyBytes = getPrivateKeyBytes(secretKey); + const pubkey = getPublicKey(privateKeyBytes); + + // Get relays from environment or use defaults + const relaysEnv = process.env.NOSTR_RELAYS; + const baseRelays = relaysEnv ? relaysEnv.split(',').map(r => r.trim()).filter(r => r.length > 0) : DEFAULT_RELAYS; + + // Enhance relay list with user's relay preferences (outboxes, local relays, blocked relays) + const relays = await enhanceRelayList(baseRelays, pubkey, baseRelays); + + // Route to appropriate subcommand + try { + switch (subcommand) { + case 'repo-announcement': + await publishRepoAnnouncement(args.slice(1), relays, privateKeyBytes, pubkey, json); + break; + case 'ownership-transfer': + await publishOwnershipTransfer(args.slice(1), relays, privateKeyBytes, pubkey, json); + break; + case 'pr': + case 'pull-request': + await publishPR(args.slice(1), relays, privateKeyBytes, pubkey, json); + break; + case 'issue': + await publishIssue(args.slice(1), relays, privateKeyBytes, pubkey, json); + break; + case 'status': + await publishStatus(args.slice(1), relays, privateKeyBytes, pubkey, json); + break; + case 'patch': + await publishPatch(args.slice(1), relays, privateKeyBytes, pubkey, json); + break; + case 'repo-state': + await publishRepoState(args.slice(1), relays, privateKeyBytes, pubkey, json); + break; + case 'pr-update': + case 'pull-request-update': + await publishPRUpdate(args.slice(1), relays, privateKeyBytes, pubkey, json); + break; + case 'event': + await publishEvent(args.slice(1), relays, privateKeyBytes, pubkey, json); + break; + default: + 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); + } + } catch (error) { + const { sanitizeErrorMessage } = await import('../../utils/error-sanitizer.js'); + const errorMessage = error instanceof Error ? error.message : String(error); + const sanitized = sanitizeErrorMessage(errorMessage); + console.error('Error:', sanitized); + process.exit(1); + } +} + +/** + * Common publish function that handles event creation, storage, and publishing + */ +export async function publishEventCommon(event, relays, privateKeyBytes, pubkey, json, eventType = 'Event') { + // 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(`${eventType} published!`); + console.log(`Event ID: ${event.id}`); + console.log(`Event stored in nostr/${getEventStorageFile(event.kind)}`); + 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}`)); + } + } + + return result; +} + +/** + * Get storage file name for event kind + */ +function getEventStorageFile(kind) { + switch (kind) { + case 30617: return 'repo-announcements.jsonl'; + case 1641: return 'ownership-transfers.jsonl'; + case 1617: return 'patches.jsonl'; + case 1618: return 'pull-requests.jsonl'; + case 1619: return 'pull-request-updates.jsonl'; + case 1621: return 'issues.jsonl'; + case 1630: + case 1631: + case 1632: + case 1633: return 'status-events.jsonl'; + case 30618: return 'repo-states.jsonl'; + default: return `events-kind-${kind}.jsonl`; + } +} + +function showPublishHelp() { + 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] + 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 [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 + +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 +`); +} + +// Export helper functions for subcommands +export { addClientTag }; diff --git a/scripts/commands/publish/issue.js b/scripts/commands/publish/issue.js new file mode 100644 index 0000000..855e809 --- /dev/null +++ b/scripts/commands/publish/issue.js @@ -0,0 +1,60 @@ +import { finalizeEvent } from 'nostr-tools'; +import { decode } from 'nostr-tools/nip19'; +import { publishEventCommon, addClientTag } from './index.js'; + +/** + * Publish issue + */ +export async function publishIssue(args, relays, privateKeyBytes, pubkey, json) { + const [ownerNpub, repoName, title] = args; + 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 = 3; 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); + + await publishEventCommon(event, relays, privateKeyBytes, pubkey, json, 'Issue'); + if (!json) { + console.log(`Repository: ${ownerNpub}/${repoName}`); + console.log(`Title: ${title}`); + } +} diff --git a/scripts/commands/publish/ownership-transfer.js b/scripts/commands/publish/ownership-transfer.js new file mode 100644 index 0000000..ec03946 --- /dev/null +++ b/scripts/commands/publish/ownership-transfer.js @@ -0,0 +1,62 @@ +import { finalizeEvent } from 'nostr-tools'; +import { decode } from 'nostr-tools/nip19'; +import { nip19 } from 'nostr-tools'; +import { publishEventCommon, addClientTag } from './index.js'; + +/** + * Publish ownership transfer + */ +export async function publishOwnershipTransfer(args, relays, privateKeyBytes, pubkey, json) { + const [repoName, newOwnerNpub] = args; + 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); + + await publishEventCommon(event, relays, privateKeyBytes, pubkey, json, 'Ownership transfer'); + if (!json) { + const currentOwnerNpub = nip19.npubEncode(currentOwnerPubkey); + console.log(`Repository: ${currentOwnerNpub}/${repoName}`); + console.log(`Current owner: ${currentOwnerNpub}`); + console.log(`New owner: ${newOwnerNpub}`); + } +} diff --git a/scripts/commands/publish/patch.js b/scripts/commands/publish/patch.js new file mode 100644 index 0000000..09b324d --- /dev/null +++ b/scripts/commands/publish/patch.js @@ -0,0 +1,111 @@ +import { readFileSync } from 'fs'; +import { finalizeEvent } from 'nostr-tools'; +import { decode } from 'nostr-tools/nip19'; +import { publishEventCommon, addClientTag } from './index.js'; + +/** + * Publish patch + */ +export async function publishPatch(args, relays, privateKeyBytes, pubkey, json) { + const [ownerNpub, repoName, patchFile] = args; + 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 = 3; 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); + + await publishEventCommon(event, relays, privateKeyBytes, pubkey, json, 'Patch'); + if (!json) { + console.log(`Repository: ${ownerNpub}/${repoName}`); + console.log(`Patch file: ${patchFile}`); + } +} diff --git a/scripts/commands/publish/pr-update.js b/scripts/commands/publish/pr-update.js new file mode 100644 index 0000000..bf40371 --- /dev/null +++ b/scripts/commands/publish/pr-update.js @@ -0,0 +1,119 @@ +import { finalizeEvent } from 'nostr-tools'; +import { decode } from 'nostr-tools/nip19'; +import { publishEventCommon, addClientTag } from './index.js'; + +/** + * Publish pull request update + */ +export async function publishPRUpdate(args, relays, privateKeyBytes, pubkey, json) { + const [ownerNpub, repoName, prEventId, commitId] = args; + 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) + let prAuthorPubkey = null; + const cloneUrls = []; + let mergeBase = null; + let earliestCommit = null; + const mentions = []; + + for (let i = 4; 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); + + await publishEventCommon(event, relays, privateKeyBytes, pubkey, json, 'Pull request update'); + if (!json) { + console.log(`Repository: ${ownerNpub}/${repoName}`); + console.log(`PR Event ID: ${prEventId}`); + console.log(`New commit: ${commitId}`); + } +} diff --git a/scripts/commands/publish/pr.js b/scripts/commands/publish/pr.js new file mode 100644 index 0000000..64ce450 --- /dev/null +++ b/scripts/commands/publish/pr.js @@ -0,0 +1,64 @@ +import { finalizeEvent } from 'nostr-tools'; +import { decode } from 'nostr-tools/nip19'; +import { publishEventCommon, addClientTag } from './index.js'; + +/** + * Publish pull request + */ +export async function publishPR(args, relays, privateKeyBytes, pubkey, json) { + const [ownerNpub, repoName, title] = args; + 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 = 3; 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); + + await publishEventCommon(event, relays, privateKeyBytes, pubkey, json, 'Pull request'); + if (!json) { + console.log(`Repository: ${ownerNpub}/${repoName}`); + console.log(`Title: ${title}`); + } +} diff --git a/scripts/commands/publish/repo-announcement.js b/scripts/commands/publish/repo-announcement.js new file mode 100644 index 0000000..88595a8 --- /dev/null +++ b/scripts/commands/publish/repo-announcement.js @@ -0,0 +1,65 @@ +import { finalizeEvent } from 'nostr-tools'; +import { publishEventCommon, addClientTag } from './index.js'; + +/** + * Publish repository announcement + */ +export async function publishRepoAnnouncement(args, relays, privateKeyBytes, pubkey, json) { + const repoName = args[0]; + 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 = 1; 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); + + await publishEventCommon(event, relays, privateKeyBytes, pubkey, json, 'Repository announcement'); + if (!json) { + console.log(`Repository: ${repoName}`); + } +} diff --git a/scripts/commands/publish/repo-state.js b/scripts/commands/publish/repo-state.js new file mode 100644 index 0000000..8916f32 --- /dev/null +++ b/scripts/commands/publish/repo-state.js @@ -0,0 +1,59 @@ +import { finalizeEvent } from 'nostr-tools'; +import { nip19 } from 'nostr-tools'; +import { publishEventCommon, addClientTag } from './index.js'; + +/** + * Publish repository state + */ +export async function publishRepoState(args, relays, privateKeyBytes, pubkey, json) { + const repoName = args[0]; + 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 = 1; 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); + + await publishEventCommon(event, relays, privateKeyBytes, pubkey, json, 'Repository state'); + if (!json) { + const currentOwnerNpub = nip19.npubEncode(currentOwnerPubkey); + console.log(`Repository: ${currentOwnerNpub}/${repoName}`); + if (headBranch) { + console.log(`HEAD: ${headBranch}`); + } + console.log(`Refs: ${tags.filter(t => t[0].startsWith('refs/')).length}`); + } +} diff --git a/scripts/commands/publish/status.js b/scripts/commands/publish/status.js new file mode 100644 index 0000000..c08e6a9 --- /dev/null +++ b/scripts/commands/publish/status.js @@ -0,0 +1,52 @@ +import { finalizeEvent } from 'nostr-tools'; +import { publishEventCommon, addClientTag } from './index.js'; + +/** + * Publish status event + */ +export async function publishStatus(args, relays, privateKeyBytes, pubkey, json) { + const [eventId, status] = args; + 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 = 2; 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); + + await publishEventCommon(event, relays, privateKeyBytes, pubkey, json, 'Status event'); + if (!json) { + console.log(`Status: ${status}`); + console.log(`Target event: ${eventId}`); + } +} diff --git a/scripts/commands/pushAll.js b/scripts/commands/pushAll.js new file mode 100644 index 0000000..0329a65 --- /dev/null +++ b/scripts/commands/pushAll.js @@ -0,0 +1,132 @@ +import { execSync } from 'child_process'; + +/** + * Push to all remotes + */ +export async function pushAll(args, server, json) { + // 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; + } + + // 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); + } + } +} diff --git a/scripts/commands/repos.js b/scripts/commands/repos.js new file mode 100644 index 0000000..ff39f35 --- /dev/null +++ b/scripts/commands/repos.js @@ -0,0 +1,280 @@ +import { decode } from 'nostr-tools/nip19'; +import { nip19 } from 'nostr-tools'; +import { apiRequest } from '../utils/api.js'; + +/** + * Repository operations command + */ +export async function 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); + }); + + 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 <npub> <repo> or repos get <naddr>'); + 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); + } +} diff --git a/scripts/commands/search.js b/scripts/commands/search.js new file mode 100644 index 0000000..9cbb9eb --- /dev/null +++ b/scripts/commands/search.js @@ -0,0 +1,23 @@ +import { apiRequest } from '../utils/api.js'; + +/** + * Search repositories + */ +export async function 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'}`); + }); + } + } +} diff --git a/scripts/commands/verify.js b/scripts/commands/verify.js new file mode 100644 index 0000000..a4659d4 --- /dev/null +++ b/scripts/commands/verify.js @@ -0,0 +1,80 @@ +import { readFileSync, existsSync } from 'fs'; +import { verifyEvent, getEventHash } from 'nostr-tools'; + +/** + * Verify a Nostr event signature and ID + */ +export async function verify(args, server, 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); + } + } +} diff --git a/scripts/config.js b/scripts/config.js new file mode 100755 index 0000000..bdce3d4 --- /dev/null +++ b/scripts/config.js @@ -0,0 +1,22 @@ +/** + * Configuration constants + */ + +// NIP-98 auth event kind +export const KIND_NIP98_AUTH = 27235; + +// Default server URL +export const DEFAULT_SERVER = process.env.GITREPUBLIC_SERVER || 'http://localhost:5173'; + +// Default relays +export const DEFAULT_RELAYS = [ + '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', +]; diff --git a/scripts/git-commit-msg-hook.js b/scripts/git-commit-msg-hook.js index 6d1dd92..d94d27f 100755 --- a/scripts/git-commit-msg-hook.js +++ b/scripts/git-commit-msg-hook.js @@ -30,7 +30,9 @@ * Security: Keep your NOSTRGIT_SECRET_KEY secure and never commit it to version control! */ -import { finalizeEvent, getPublicKey, SimplePool, nip19 } from 'nostr-tools'; +import { finalizeEvent, getPublicKey, nip19 } from 'nostr-tools'; +import { publishToRelays } from './relay/publisher.js'; +import { enhanceRelayList } from './relay/relay-fetcher.js'; import { readFileSync, writeFileSync, existsSync } from 'fs'; import { execSync } from 'child_process'; import { join, dirname, resolve } from 'path'; @@ -336,7 +338,7 @@ async function signCommitMessage(commitMessageFile) { if (publishEvent) { try { const relaysEnv = process.env.NOSTR_RELAYS; - const relays = relaysEnv ? relaysEnv.split(',').map(r => r.trim()).filter(r => r.length > 0) : [ + const baseRelays = relaysEnv ? relaysEnv.split(',').map(r => r.trim()).filter(r => r.length > 0) : [ 'wss://nostr.land', 'wss://relay.damus.io', 'wss://thecitadel.nostr1.com', @@ -346,17 +348,23 @@ async function signCommitMessage(commitMessageFile) { 'wss://nostr.sovbit.host', 'wss://bevos.nostr1.com', 'wss://relay.primal.net', + 'wss://nostr.mom', ]; - const pool = new SimplePool(); - const results = await pool.publish(relays, signedEvent); - pool.close(relays); + // Enhance relay list with user's relay preferences (outboxes, local relays, blocked relays) + const relays = await enhanceRelayList(baseRelays, pubkey, baseRelays); - const successCount = results.size; - if (successCount > 0) { - console.log(` Published to ${successCount} relay(s)`); + const result = await publishToRelays(signedEvent, relays, keyBytes, pubkey); + + if (result.success.length > 0) { + console.log(` Published to ${result.success.length} relay(s)`); } else { console.log(' ⚠️ Failed to publish to relays'); + if (result.failed.length > 0) { + result.failed.forEach(f => { + console.log(` ⚠️ ${f.relay}: ${f.error}`); + }); + } } } catch (publishError) { console.log(` ⚠️ Failed to publish event: ${publishError instanceof Error ? publishError.message : 'Unknown error'}`); diff --git a/scripts/gitrepublic.js b/scripts/gitrepublic.js index ae6dabb..6713266 100755 --- a/scripts/gitrepublic.js +++ b/scripts/gitrepublic.js @@ -14,58 +14,11 @@ * 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 <npub> <repo> Get repository info - * repos settings <npub> <repo> Get/update repository 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> Get file content - * file put <npub> <repo> <path> Create/update file - * file delete <npub> <repo> <path> Delete file - * search <query> Search repositories - * publish <subcommand> Publish Nostr events - * verify <event-file> Verify Nostr event signatures - * config [server] Show configuration - * - * Options: - * --server <url> GitRepublic server URL (default: http://localhost:5173) - * --key <nsec> 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]'); -} +import { DEFAULT_SERVER } from './config.js'; +import { sanitizeErrorMessage } from './utils/error-sanitizer.js'; +import * as commands from './commands/index.js'; // Handle unhandled promise rejections from SimplePool to prevent crashes // SimplePool can reject promises asynchronously from WebSocket handlers @@ -83,1857 +36,6 @@ process.on('unhandledRejection', (reason, promise) => { // 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 <npub> <repo> or repos get <naddr>'); - 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 <npub> <repo> <path> [branch], put <npub> <repo> <path> [file] [message] [branch], delete <npub> <repo> <path> [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 <subcommand> [options] - -Subcommands: - repo-announcement <repo-name> [options] - Publish a repository announcement (kind 30617) - Options: - --description <text> Repository description - --clone-url <url> Clone URL (can be specified multiple times) - --web-url <url> Web URL (can be specified multiple times) - --maintainer <npub> Maintainer pubkey (can be specified multiple times) - --relay <url> 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 <repo> <new-owner-npub> [--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 <owner-npub> <repo> <title> [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('--')); @@ -1949,7 +51,7 @@ const commandHelpRequested = command && (commandArgs.includes('--help') || comma // 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 +// Handle config command if (command === 'config') { const subcommand = commandArgs[0]; if (subcommand === 'server' || !subcommand) { @@ -1986,7 +88,9 @@ const commandHandler = commandKey ? (commands[commandKey] || commands[command]) 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); + const errorMessage = error instanceof Error ? error.message : String(error); + const sanitized = sanitizeErrorMessage(errorMessage); + console.error('Error:', sanitized); process.exit(1); }); // Exit after handler processes help (handler should exit, but just in case) @@ -2036,7 +140,6 @@ Licensed under MIT License } // Execute command - if (!commandHandler) { console.error(`Error: Unknown command: ${command}`); console.error('Use --help to see available commands'); @@ -2044,6 +147,8 @@ if (!commandHandler) { } commandHandler(commandArgs, server, json).catch(error => { - console.error('Error:', error.message); + const errorMessage = error instanceof Error ? error.message : String(error); + const sanitized = sanitizeErrorMessage(errorMessage); + console.error('Error:', sanitized); process.exit(1); }); diff --git a/scripts/relay/index.js b/scripts/relay/index.js new file mode 100644 index 0000000..8b49e58 --- /dev/null +++ b/scripts/relay/index.js @@ -0,0 +1,2 @@ +export { publishToRelays } from './publisher.js'; +export { fetchRelayLists, enhanceRelayList } from './relay-fetcher.js'; \ No newline at end of file diff --git a/scripts/relay/publisher.js b/scripts/relay/publisher.js new file mode 100644 index 0000000..3f3f13e --- /dev/null +++ b/scripts/relay/publisher.js @@ -0,0 +1,77 @@ +import { SimplePool } from 'nostr-tools'; +import { sanitizeErrorMessage } from '../utils/error-sanitizer.js'; + +/** + * Publish event to Nostr relays using SimplePool + * + * This function publishes to all relays in parallel. Each relay has its own + * timeout (default 4400ms) to prevent hanging when relays fail. + * + * @param {Object} event - Nostr event to publish + * @param {string[]} relays - Array of relay URLs + * @param {Uint8Array} privateKeyBytes - Private key bytes (required for auth if needed) + * @param {string} pubkey - Public key (optional, for logging) + * @returns {Promise<{success: string[], failed: Array<{relay: string, error: string}>}>} + */ +export 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 = []; + + try { + // pool.publish returns an array of Promises, one for each relay + // Each promise resolves to a string (reason) on success or rejects with an error on failure + const publishPromises = pool.publish(relays, event); + + // Wait for all promises to settle (either resolve or reject) + // Use allSettled to handle both successes and failures without throwing + const results = await Promise.allSettled(publishPromises); + + // Process results - map back to relay URLs + for (let i = 0; i < results.length; i++) { + const result = results[i]; + const relayUrl = relays[i]; + + if (result.status === 'fulfilled') { + // Promise resolved successfully - relay accepted the event + // The resolved value is a string (reason from the relay's OK message) + success.push(relayUrl); + } else { + // Promise rejected - relay failed or timed out + const errorMessage = result.reason instanceof Error + ? result.reason.message + : String(result.reason); + + failed.push({ + relay: relayUrl, + error: sanitizeErrorMessage(errorMessage) + }); + } + } + } catch (error) { + // Fallback error handling (shouldn't happen with allSettled, but just in case) + const errorMessage = error instanceof Error ? error.message : String(error); + const sanitizedError = sanitizeErrorMessage(errorMessage); + + for (const relayUrl of relays) { + failed.push({ relay: relayUrl, error: sanitizedError }); + } + } finally { + // Close all connections in the pool + try { + await pool.close(relays); + } catch (closeError) { + // Ignore close errors - connections may already be closed + } + } + + return { success, failed }; +} diff --git a/scripts/relay/relay-fetcher.js b/scripts/relay/relay-fetcher.js new file mode 100644 index 0000000..16bfef9 --- /dev/null +++ b/scripts/relay/relay-fetcher.js @@ -0,0 +1,195 @@ +import { SimplePool } from 'nostr-tools'; +import { DEFAULT_RELAYS } from '../config.js'; + +/** + * Normalize a relay URL (similar to nostr-tools normalizeURL but simpler) + */ +function normalizeRelayUrl(url) { + if (!url) return null; + + try { + // Remove trailing slashes + url = url.trim().replace(/\/+$/, ''); + + // Add protocol if missing + if (!url.includes('://')) { + url = 'wss://' + url; + } + + // Parse and normalize + const urlObj = new URL(url); + + // Normalize protocol + if (urlObj.protocol === 'http:') { + urlObj.protocol = 'ws:'; + } else if (urlObj.protocol === 'https:') { + urlObj.protocol = 'wss:'; + } + + // Normalize pathname + urlObj.pathname = urlObj.pathname.replace(/\/+/g, '/'); + if (urlObj.pathname.endsWith('/')) { + urlObj.pathname = urlObj.pathname.slice(0, -1); + } + + // Remove default ports + if (urlObj.port === '80' && urlObj.protocol === 'ws:') { + urlObj.port = ''; + } else if (urlObj.port === '443' && urlObj.protocol === 'wss:') { + urlObj.port = ''; + } + + // Remove hash and sort search params + urlObj.hash = ''; + urlObj.searchParams.sort(); + + return urlObj.toString(); + } catch (e) { + // Invalid URL, return null + return null; + } +} + +/** + * Extract relay URLs from an event's "r" tags + */ +function extractRelayUrls(event) { + if (!event || !event.tags) return []; + + const relayUrls = []; + for (const tag of event.tags) { + if (tag[0] === 'r' && tag[1]) { + const normalized = normalizeRelayUrl(tag[1]); + if (normalized) { + relayUrls.push(normalized); + } + } + } + + return relayUrls; +} + +/** + * Fetch relay lists from a pubkey + * Returns: { outboxes: string[], localRelays: string[], blockedRelays: string[] } + */ +export async function fetchRelayLists(pubkey, queryRelays = null) { + const pool = new SimplePool(); + const relays = queryRelays || DEFAULT_RELAYS; + + const outboxes = []; + const localRelays = []; + const blockedRelays = []; + + try { + // Fetch kind 10002 (inboxes/outboxes) + try { + const outboxEvents = await pool.querySync(relays, [ + { + kinds: [10002], + authors: [pubkey], + limit: 1 + } + ]); + + if (outboxEvents.length > 0) { + // Get the most recent event + const latestEvent = outboxEvents.sort((a, b) => b.created_at - a.created_at)[0]; + const urls = extractRelayUrls(latestEvent); + outboxes.push(...urls); + } + } catch (error) { + // Silently fail - relay lists are optional + } + + // Fetch kind 10432 (local relays) + try { + const localRelayEvents = await pool.querySync(relays, [ + { + kinds: [10432], + authors: [pubkey], + limit: 1 + } + ]); + + if (localRelayEvents.length > 0) { + // Get the most recent event + const latestEvent = localRelayEvents.sort((a, b) => b.created_at - a.created_at)[0]; + const urls = extractRelayUrls(latestEvent); + localRelays.push(...urls); + } + } catch (error) { + // Silently fail - relay lists are optional + } + + // Fetch kind 10006 (blocked relays) + try { + const blockedRelayEvents = await pool.querySync(relays, [ + { + kinds: [10006], + authors: [pubkey], + limit: 1 + } + ]); + + if (blockedRelayEvents.length > 0) { + // Get the most recent event + const latestEvent = blockedRelayEvents.sort((a, b) => b.created_at - a.created_at)[0]; + const urls = extractRelayUrls(latestEvent); + blockedRelays.push(...urls); + } + } catch (error) { + // Silently fail - relay lists are optional + } + } finally { + // Close pool connections + try { + await pool.close(relays); + } catch (closeError) { + // Ignore close errors + } + } + + return { outboxes, localRelays, blockedRelays }; +} + +/** + * Enhance relay list with user's relay preferences + * - Adds outboxes (write relays) and local relays + * - Removes blocked relays + * - Normalizes and deduplicates + */ +export async function enhanceRelayList(baseRelays, pubkey, queryRelays = null) { + // Normalize base relays + const normalizedBase = baseRelays + .map(url => normalizeRelayUrl(url)) + .filter(url => url !== null); + + // Fetch user's relay lists + const { outboxes, localRelays, blockedRelays } = await fetchRelayLists(pubkey, queryRelays || normalizedBase); + + // Normalize blocked relays + const normalizedBlocked = new Set( + blockedRelays.map(url => normalizeRelayUrl(url)).filter(url => url !== null) + ); + + // Combine base relays, outboxes, and local relays + const allRelays = [ + ...normalizedBase, + ...outboxes.map(url => normalizeRelayUrl(url)).filter(url => url !== null), + ...localRelays.map(url => normalizeRelayUrl(url)).filter(url => url !== null) + ]; + + // Deduplicate and remove blocked relays + const seen = new Set(); + const enhanced = []; + + for (const relay of allRelays) { + if (relay && !seen.has(relay) && !normalizedBlocked.has(relay)) { + seen.add(relay); + enhanced.push(relay); + } + } + + return enhanced; +} diff --git a/scripts/utils/api.js b/scripts/utils/api.js new file mode 100644 index 0000000..7f3ba01 --- /dev/null +++ b/scripts/utils/api.js @@ -0,0 +1,44 @@ +import { createNIP98Auth } from './auth.js'; + +/** + * Make authenticated API request + */ +export 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 { sanitizeErrorMessage } = await import('./error-sanitizer.js'); + 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; +} diff --git a/scripts/utils/auth.js b/scripts/utils/auth.js new file mode 100644 index 0000000..3364561 --- /dev/null +++ b/scripts/utils/auth.js @@ -0,0 +1,44 @@ +import { createHash } from 'crypto'; +import { finalizeEvent, getPublicKey } from 'nostr-tools'; +import { KIND_NIP98_AUTH } from '../config.js'; +import { getPrivateKeyBytes } from './keys.js'; + +/** + * Create NIP-98 authentication header + */ +export 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}`; +} diff --git a/scripts/utils/error-sanitizer.js b/scripts/utils/error-sanitizer.js new file mode 100644 index 0000000..d33d0a6 --- /dev/null +++ b/scripts/utils/error-sanitizer.js @@ -0,0 +1,19 @@ +/** + * Sanitize error messages to prevent private key leaks + * @param {string} message - Error message to sanitize + * @returns {string} - Sanitized error message + */ +export 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]'); +} diff --git a/scripts/utils/event-storage.js b/scripts/utils/event-storage.js new file mode 100644 index 0000000..a57ef28 --- /dev/null +++ b/scripts/utils/event-storage.js @@ -0,0 +1,77 @@ +import { writeFileSync, existsSync } from 'fs'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; + +/** + * Store event in appropriate JSONL file based on event kind + */ +export 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 + } +} diff --git a/scripts/utils/keys.js b/scripts/utils/keys.js new file mode 100644 index 0000000..105add0 --- /dev/null +++ b/scripts/utils/keys.js @@ -0,0 +1,61 @@ +import { decode } from 'nostr-tools/nip19'; +import { getPublicKey } from 'nostr-tools'; + +/** + * 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 + */ +export 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; + } +} + +/** + * Get the public key from private key + * @param {string} secretKey - nsec or hex private key + * @returns {string} - Public key (hex) + */ +export function getPublicKeyFromSecret(secretKey) { + const privateKeyBytes = getPrivateKeyBytes(secretKey); + return getPublicKey(privateKeyBytes); +} + +/** + * Get private key from environment + * @returns {string} - Private key + * @throws {Error} - If no key is found + */ +export function getPrivateKeyFromEnv() { + 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'); + } + return secretKey; +} diff --git a/scripts/utils/tags.js b/scripts/utils/tags.js new file mode 100644 index 0000000..18bf02f --- /dev/null +++ b/scripts/utils/tags.js @@ -0,0 +1,12 @@ +/** + * 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 + */ +export function addClientTag(tags, args) { + const noClientTag = args && args.includes('--no-client-tag'); + if (!noClientTag) { + tags.push(['client', 'gitrepublic-cli']); + } + return tags; +}