28 changed files with 2177 additions and 1913 deletions
@ -0,0 +1,88 @@
@@ -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 <npub> <repo> <path> [branch], put <npub> <repo> <path> [file] [message] [branch], delete <npub> <repo> <path> [message] [branch]'); |
||||
process.exit(1); |
||||
} |
||||
} |
||||
@ -0,0 +1,7 @@
@@ -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'; |
||||
@ -0,0 +1,129 @@
@@ -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 <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 = 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); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,267 @@
@@ -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 <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] |
||||
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 }; |
||||
@ -0,0 +1,60 @@
@@ -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}`); |
||||
} |
||||
} |
||||
@ -0,0 +1,62 @@
@@ -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}`); |
||||
} |
||||
} |
||||
@ -0,0 +1,111 @@
@@ -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}`); |
||||
} |
||||
} |
||||
@ -0,0 +1,119 @@
@@ -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}`); |
||||
} |
||||
} |
||||
@ -0,0 +1,64 @@
@@ -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}`); |
||||
} |
||||
} |
||||
@ -0,0 +1,65 @@
@@ -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}`); |
||||
} |
||||
} |
||||
@ -0,0 +1,59 @@
@@ -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}`); |
||||
} |
||||
} |
||||
@ -0,0 +1,52 @@
@@ -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}`); |
||||
} |
||||
} |
||||
@ -0,0 +1,132 @@
@@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,280 @@
@@ -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); |
||||
} |
||||
} |
||||
@ -0,0 +1,23 @@
@@ -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'}`); |
||||
}); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,80 @@
@@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,22 @@
@@ -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', |
||||
]; |
||||
@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
|
||||
export { publishToRelays } from './publisher.js'; |
||||
export { fetchRelayLists, enhanceRelayList } from './relay-fetcher.js'; |
||||
@ -0,0 +1,77 @@
@@ -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 }; |
||||
} |
||||
@ -0,0 +1,195 @@
@@ -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; |
||||
} |
||||
@ -0,0 +1,44 @@
@@ -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; |
||||
} |
||||
@ -0,0 +1,44 @@
@@ -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}`; |
||||
} |
||||
@ -0,0 +1,19 @@
@@ -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]'); |
||||
} |
||||
@ -0,0 +1,77 @@
@@ -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
|
||||
} |
||||
} |
||||
@ -0,0 +1,61 @@
@@ -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; |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -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; |
||||
} |
||||
Loading…
Reference in new issue