Browse Source

Sync from gitrepublic-web monorepo

master
Silberengel 3 weeks ago
parent
commit
45b9c7f7b4
  1. 88
      scripts/commands/file.js
  2. 7
      scripts/commands/index.js
  3. 129
      scripts/commands/publish/event.js
  4. 267
      scripts/commands/publish/index.js
  5. 60
      scripts/commands/publish/issue.js
  6. 62
      scripts/commands/publish/ownership-transfer.js
  7. 111
      scripts/commands/publish/patch.js
  8. 119
      scripts/commands/publish/pr-update.js
  9. 64
      scripts/commands/publish/pr.js
  10. 65
      scripts/commands/publish/repo-announcement.js
  11. 59
      scripts/commands/publish/repo-state.js
  12. 52
      scripts/commands/publish/status.js
  13. 132
      scripts/commands/pushAll.js
  14. 280
      scripts/commands/repos.js
  15. 23
      scripts/commands/search.js
  16. 80
      scripts/commands/verify.js
  17. 22
      scripts/config.js
  18. 24
      scripts/git-commit-msg-hook.js
  19. 1941
      scripts/gitrepublic.js
  20. 2
      scripts/relay/index.js
  21. 77
      scripts/relay/publisher.js
  22. 195
      scripts/relay/relay-fetcher.js
  23. 44
      scripts/utils/api.js
  24. 44
      scripts/utils/auth.js
  25. 19
      scripts/utils/error-sanitizer.js
  26. 77
      scripts/utils/event-storage.js
  27. 61
      scripts/utils/keys.js
  28. 12
      scripts/utils/tags.js

88
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 <npub> <repo> <path> [branch], put <npub> <repo> <path> [file] [message] [branch], delete <npub> <repo> <path> [message] [branch]');
process.exit(1);
}
}

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

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

267
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 <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 };

60
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}`);
}
}

62
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}`);
}
}

111
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}`);
}
}

119
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}`);
}
}

64
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}`);
}
}

65
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}`);
}
}

59
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}`);
}
}

52
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}`);
}
}

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

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

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

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

22
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',
];

24
scripts/git-commit-msg-hook.js

@ -30,7 +30,9 @@
* Security: Keep your NOSTRGIT_SECRET_KEY secure and never commit it to version control! * 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 { readFileSync, writeFileSync, existsSync } from 'fs';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { join, dirname, resolve } from 'path'; import { join, dirname, resolve } from 'path';
@ -336,7 +338,7 @@ async function signCommitMessage(commitMessageFile) {
if (publishEvent) { if (publishEvent) {
try { try {
const relaysEnv = process.env.NOSTR_RELAYS; 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://nostr.land',
'wss://relay.damus.io', 'wss://relay.damus.io',
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
@ -346,17 +348,23 @@ async function signCommitMessage(commitMessageFile) {
'wss://nostr.sovbit.host', 'wss://nostr.sovbit.host',
'wss://bevos.nostr1.com', 'wss://bevos.nostr1.com',
'wss://relay.primal.net', 'wss://relay.primal.net',
'wss://nostr.mom',
]; ];
const pool = new SimplePool(); // Enhance relay list with user's relay preferences (outboxes, local relays, blocked relays)
const results = await pool.publish(relays, signedEvent); const relays = await enhanceRelayList(baseRelays, pubkey, baseRelays);
pool.close(relays);
const successCount = results.size; const result = await publishToRelays(signedEvent, relays, keyBytes, pubkey);
if (successCount > 0) {
console.log(` Published to ${successCount} relay(s)`); if (result.success.length > 0) {
console.log(` Published to ${result.success.length} relay(s)`);
} else { } else {
console.log(' ⚠ Failed to publish to relays'); console.log(' ⚠ Failed to publish to relays');
if (result.failed.length > 0) {
result.failed.forEach(f => {
console.log(` ${f.relay}: ${f.error}`);
});
}
} }
} catch (publishError) { } catch (publishError) {
console.log(` Failed to publish event: ${publishError instanceof Error ? publishError.message : 'Unknown error'}`); console.log(` Failed to publish event: ${publishError instanceof Error ? publishError.message : 'Unknown error'}`);

1941
scripts/gitrepublic.js

File diff suppressed because it is too large Load Diff

2
scripts/relay/index.js

@ -0,0 +1,2 @@
export { publishToRelays } from './publisher.js';
export { fetchRelayLists, enhanceRelayList } from './relay-fetcher.js';

77
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 };
}

195
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;
}

44
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;
}

44
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}`;
}

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

77
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
}
}

61
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;
}

12
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;
}
Loading…
Cancel
Save