From e530598acd7d74e141178536e6d747265c669035 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 19 Feb 2026 14:31:49 +0100 Subject: [PATCH] Sync from gitrepublic-web monorepo --- .gitignore | 7 + LICENSE | 21 + README.md | 110 ++ SYNC.md | 112 ++ package.json | 61 ++ scripts/get-path.js | 43 + scripts/git-commit-msg-hook.js | 400 +++++++ scripts/git-credential-nostr.js | 402 +++++++ scripts/git-wrapper.js | 404 ++++++++ scripts/gitrepublic.js | 1730 +++++++++++++++++++++++++++++++ scripts/postinstall.js | 42 + scripts/setup.js | 237 +++++ scripts/uninstall.js | 209 ++++ 13 files changed, 3778 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SYNC.md create mode 100644 package.json create mode 100755 scripts/get-path.js create mode 100755 scripts/git-commit-msg-hook.js create mode 100755 scripts/git-credential-nostr.js create mode 100755 scripts/git-wrapper.js create mode 100755 scripts/gitrepublic.js create mode 100755 scripts/postinstall.js create mode 100755 scripts/setup.js create mode 100755 scripts/uninstall.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21ca97a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +npm-debug.log +yarn-error.log +.DS_Store +*.log +.env +.env.local diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c24205d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License for GitRepublic CLI + +Copyright (c) 2026 GitCitadel LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f22a1df --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# GitRepublic CLI + +Command-line tools for GitRepublic: git wrapper with enhanced error messages, credential helper, commit signing hook, and API access. + +> **Note**: This CLI is part of the `gitrepublic-web` monorepo but can also be used and published independently. See [SYNC.md](./SYNC.md) for information about syncing to a separate repository. + +## Quick Start + +```bash +# Install +npm install -g gitrepublic-cli + +# Set your Nostr private key +export NOSTRGIT_SECRET_KEY="nsec1..." + +# Setup (configures credential helper and commit hook) +gitrep-setup + +# Use gitrepublic (or gitrep) as a drop-in replacement for git +gitrep clone https://your-domain.com/api/git/npub1.../repo.git gitrepublic-web +gitrep push gitrepublic-web main + +# Note: "gitrep" is a shorter alias for "gitrepublic" - both work the same way. +# We suggest using "gitrepublic-web" as the remote name instead of "origin" +# because "origin" is often already set to GitHub, GitLab, or other services. +``` + +## Commands + +- **`gitrepublic`** or **`gitrep`** - Git wrapper with enhanced error messages (use instead of `git`) +- **`gitrepublic-api`** or **`gitrep-api`** - Access GitRepublic APIs from command line +- **`gitrepublic-setup`** or **`gitrep-setup`** - Automatic setup script +- **`gitrepublic-uninstall`** or **`gitrep-uninstall`** - Remove all configuration + +Run any command with `--help` or `-h` for detailed usage information. + +## Uninstall + +```bash +# Remove all configuration +gitrep-uninstall + +# See what would be removed (dry run) +gitrep-uninstall --dry-run + +# Keep environment variables +gitrep-uninstall --keep-env +``` + +## Features + +- **Git Wrapper**: Enhanced error messages for GitRepublic operations +- **Credential Helper**: Automatic NIP-98 authentication +- **Commit Signing**: Automatically sign commits for GitRepublic repos +- **API Access**: Full command-line access to all GitRepublic APIs + +## Requirements + +- Node.js 18+ +- Git +- Nostr private key (nsec format or hex) + +## Commit Signing + +The commit hook automatically signs **all commits** by default (GitHub, GitLab, GitRepublic, etc.). The signature is just text in the commit message and doesn't interfere with git operations. + +To only sign GitRepublic repositories (skip GitHub/GitLab): + +```bash +export GITREPUBLIC_SIGN_ONLY_GITREPUBLIC=true +``` + +To cancel commits if signing fails: + +```bash +export GITREPUBLIC_CANCEL_ON_SIGN_FAIL=true +``` + +By default, the full event JSON is stored in `nostr/commit-signatures.jsonl` (JSON Lines format) for each signed commit. Events are organized by type in the `nostr/` folder for easy searching. + +To also include the full event JSON in the commit message (base64 encoded): + +```bash +export GITREPUBLIC_INCLUDE_FULL_EVENT=true +``` + +To publish commit signature events to Nostr relays: + +```bash +export GITREPUBLIC_PUBLISH_EVENT=true +export NOSTR_RELAYS="wss://relay1.com,wss://relay2.com" # Optional, has defaults +``` + +## Documentation + +For detailed documentation, run: +- `gitrep --help` or `gitrepublic --help` - Git wrapper usage +- `gitrep-api --help` or `gitrepublic-api --help` - API commands +- `gitrep-setup --help` or `gitrepublic-setup --help` - Setup options +- `gitrep-uninstall --help` or `gitrepublic-uninstall --help` - Uninstall options + +## Links + +- [GitRepublic Web](https://github.com/silberengel/gitrepublic-web) - Full web application +- [NIP-98 Specification](https://github.com/nostr-protocol/nips/blob/master/98.md) - HTTP Authentication +- [Git Credential Helper Documentation](https://git-scm.com/docs/gitcredentials) + +## License + +MIT diff --git a/SYNC.md b/SYNC.md new file mode 100644 index 0000000..029ea32 --- /dev/null +++ b/SYNC.md @@ -0,0 +1,112 @@ +# Syncing CLI to Separate Repository + +This document explains how to keep the `gitrepublic-cli` in sync with a separate repository while maintaining it as part of the `gitrepublic-web` monorepo. + +## When to Sync + +You should sync the CLI to a separate repository when: + +### 1. **Publishing to npm** + - Before publishing a new version to npm, sync to ensure the separate repo is up-to-date + - This allows users to install via `npm install -g gitrepublic-cli` from the published package + - The separate repo serves as the source of truth for npm package releases + +### 2. **Independent Development & Contributions** + - When you want others to contribute to the CLI without needing access to the full web repo + - Allows CLI-specific issues, discussions, and pull requests + - Makes the CLI more discoverable as a standalone project + +### 3. **Separate Release Cycle** + - If you want to version and release the CLI independently from the web application + - Allows different release cadences (e.g., CLI updates more frequently than the web app) + - Enables CLI-specific changelogs and release notes + +### 4. **CI/CD & Automation** + - If you want separate CI/CD pipelines for the CLI (testing, linting, publishing) + - Allows automated npm publishing on version bumps + - Can set up separate GitHub Actions workflows for CLI-specific tasks + +### 5. **Documentation & Discoverability** + - Makes the CLI easier to find for users who only need the CLI tools + - Allows separate documentation site or GitHub Pages + - Better SEO and discoverability on GitHub/npm + +## When NOT to Sync + +You typically don't need to sync if: +- You're only developing internally and not publishing to npm +- The CLI is tightly coupled to the web app and changes together +- You prefer keeping everything in one repository for simplicity + +## Recommended Workflow + +1. **Develop in monorepo**: Make all changes in `gitrepublic-cli/` within the main repo +2. **Sync before publishing**: Run `npm run cli:sync` before publishing to npm +3. **Publish from separate repo**: Publish to npm from the synced repository (or use CI/CD) +4. **Keep in sync**: Sync regularly to ensure the separate repo stays current + +## Option 1: Git Subtree (Recommended) + +Git subtree allows you to maintain the CLI as part of this repo while also syncing it to a separate repository. + +### Initial Setup (One-time) + +1. **Add the separate repo as a remote:** + ```bash + cd /path/to/gitrepublic-web + git remote add cli-repo https://github.com/silberengel/gitrepublic-cli.git + ``` + +2. **Push the CLI directory to the separate repo:** + ```bash + git subtree push --prefix=gitrepublic-cli cli-repo main + ``` + +### Syncing Changes + +**To push changes from monorepo to separate repo:** +```bash +git subtree push --prefix=gitrepublic-cli cli-repo main +``` + +**To pull changes from separate repo to monorepo:** +```bash +git subtree pull --prefix=gitrepublic-cli cli-repo main --squash +``` + +### Publishing to npm + +From the separate repository: +```bash +cd /path/to/gitrepublic-cli +npm publish +``` + +## Option 2: Manual Sync Script + +A script is provided to help sync changes: + +```bash +./scripts/sync-cli.sh +``` + +This script: +1. Copies changes from `gitrepublic-cli/` to a separate repo directory +2. Commits and pushes to the separate repo +3. Can be run after making CLI changes + +## Option 3: GitHub Actions / CI + +You can set up automated syncing using GitHub Actions. See `.github/workflows/sync-cli.yml` (if created). + +## Publishing + +The CLI can be published independently from npm: + +```bash +cd gitrepublic-cli +npm version patch # or minor, major +npm publish +``` + +The CLI's `package.json` is configured to publish only the necessary files (scripts, README, LICENSE). diff --git a/package.json b/package.json new file mode 100644 index 0000000..c4d8f14 --- /dev/null +++ b/package.json @@ -0,0 +1,61 @@ +{ + "name": "gitrepublic-cli", + "version": "1.0.0", + "description": "Command-line tools for GitRepublic: git wrapper with enhanced error messages, credential helper, commit signing hook, and API access", + "type": "module", + "main": "index.js", + "bin": { + "gitrepublic": "./scripts/git-wrapper.js", + "gitrep": "./scripts/git-wrapper.js", + "gitrepublic-api": "./scripts/gitrepublic.js", + "gitrep-api": "./scripts/gitrepublic.js", + "gitrepublic-credential": "./scripts/git-credential-nostr.js", + "gitrep-cred": "./scripts/git-credential-nostr.js", + "gitrepublic-commit-hook": "./scripts/git-commit-msg-hook.js", + "gitrep-commit": "./scripts/git-commit-msg-hook.js", + "gitrepublic-path": "./scripts/get-path.js", + "gitrep-path": "./scripts/get-path.js", + "gitrepublic-setup": "./scripts/setup.js", + "gitrep-setup": "./scripts/setup.js", + "gitrepublic-uninstall": "./scripts/uninstall.js", + "gitrep-uninstall": "./scripts/uninstall.js" + }, + "files": [ + "scripts", + "README.md", + "LICENSE" + ], + "scripts": { + "postinstall": "chmod +x scripts/*.js && node scripts/postinstall.js" + }, + "keywords": [ + "git", + "nostr", + "gitrepublic", + "credential-helper", + "commit-signing", + "nip-98" + ], + "author": "GitCitadel LLC", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.imwald.eu/silberengel/gitrepublic-cli.git" + }, + "repositories": [ + { + "type": "git", + "url": "https://github.com/silberengel/gitrepublic-cli.git" + }, + { + "type": "git", + "url": "https://git.imwald.eu/silberengel/gitrepublic-cli.git" + } + ], + "dependencies": { + "nostr-tools": "^2.22.1" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/scripts/get-path.js b/scripts/get-path.js new file mode 100755 index 0000000..d680801 --- /dev/null +++ b/scripts/get-path.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node +/** + * Helper script to get the installation path of GitRepublic CLI scripts + * Useful for configuring git credential helpers and hooks + */ + +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { existsSync } from 'fs'; + +// Get the directory where this script is located +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const scriptsDir = __dirname; + +// Check if scripts exist +const credentialScript = join(scriptsDir, 'git-credential-nostr.js'); +const commitHookScript = join(scriptsDir, 'git-commit-msg-hook.js'); + +if (process.argv[2] === '--credential' || process.argv[2] === '-c') { + if (existsSync(credentialScript)) { + console.log(credentialScript); + } else { + console.error('Error: git-credential-nostr.js not found'); + process.exit(1); + } +} else if (process.argv[2] === '--hook' || process.argv[2] === '-h') { + if (existsSync(commitHookScript)) { + console.log(commitHookScript); + } else { + console.error('Error: git-commit-msg-hook.js not found'); + process.exit(1); + } +} else { + // Default: show both paths + console.log('GitRepublic CLI Scripts:'); + console.log('Credential Helper:', credentialScript); + console.log('Commit Hook:', commitHookScript); + console.log(''); + console.log('Usage:'); + console.log(' node get-path.js --credential # Get credential helper path'); + console.log(' node get-path.js --hook # Get commit hook path'); +} diff --git a/scripts/git-commit-msg-hook.js b/scripts/git-commit-msg-hook.js new file mode 100755 index 0000000..c5eb9cd --- /dev/null +++ b/scripts/git-commit-msg-hook.js @@ -0,0 +1,400 @@ +#!/usr/bin/env node +/** + * Git commit-msg hook for signing commits with Nostr keys + * + * This hook automatically signs git commits using your Nostr private key. + * By default, it signs ALL commits (GitHub, GitLab, GitRepublic, etc.) since + * the signature is just text in the commit message and doesn't interfere with + * git operations. + * + * Setup: + * 1. Install dependencies: npm install + * 2. Install as a git hook in your repository: + * ln -s /absolute/path/to/gitrepublic-cli/scripts/git-commit-msg-hook.js .git/hooks/commit-msg + * 3. Or install globally for all repositories: + * mkdir -p ~/.git-hooks + * ln -s /absolute/path/to/gitrepublic-cli/scripts/git-commit-msg-hook.js ~/.git-hooks/commit-msg + * git config --global core.hooksPath ~/.git-hooks + * + * Environment variables: + * NOSTRGIT_SECRET_KEY - Your Nostr private key (nsec format or hex) for signing commits + * GITREPUBLIC_SIGN_ONLY_GITREPUBLIC - If true, only sign GitRepublic repos (default: false, signs all) + * GITREPUBLIC_CANCEL_ON_SIGN_FAIL - If true, cancel commit if signing fails (default: false, allows unsigned) + * GITREPUBLIC_INCLUDE_FULL_EVENT - If true, include full event JSON in commit message (default: false, stored in nostr/commit-signatures.jsonl by default) + * GITREPUBLIC_PUBLISH_EVENT - If true, publish commit signature event to Nostr relays (default: false) + * NOSTR_RELAYS - Comma-separated list of Nostr relays for publishing (default: wss://theforest.nostr1.com,wss://relay.damus.io,wss://nostr.land) + * + * By default, the full event JSON is stored in nostr/commit-signatures.jsonl (JSON Lines format). + * Events are organized by type in the nostr/ folder for easy searching. + * + * Security: Keep your NOSTRGIT_SECRET_KEY secure and never commit it to version control! + */ + +import { finalizeEvent, getPublicKey, SimplePool, nip19 } from 'nostr-tools'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { execSync } from 'child_process'; +import { join, dirname, resolve } from 'path'; + +// Commit signature event kind (1640) +const KIND_COMMIT_SIGNATURE = 1640; + +/** + * Decode a Nostr key from bech32 (nsec) or hex format + * Returns the hex-encoded private key as Uint8Array + */ +function decodeNostrKey(key) { + let hexKey; + + // Check if it's already hex (64 characters, hex format) + if (/^[0-9a-fA-F]{64}$/.test(key)) { + hexKey = key.toLowerCase(); + } else { + // Try to decode as bech32 (nsec) + try { + const decoded = nip19.decode(key); + if (decoded.type === 'nsec') { + // decoded.data for nsec is Uint8Array, convert to hex string + const data = decoded.data; + hexKey = Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(''); + } else { + throw new Error('Key is not a valid nsec or hex private key'); + } + } catch (error) { + throw new Error(`Invalid key format: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + // Convert hex string to Uint8Array + const keyBytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + keyBytes[i] = parseInt(hexKey.slice(i * 2, i * 2 + 2), 16); + } + return keyBytes; +} + +/** + * Get git config value + */ +function getGitConfig(key) { + try { + return execSync(`git config --get ${key}`, { encoding: 'utf-8' }).trim() || null; + } catch { + return null; + } +} + +/** + * Check if this is a GitRepublic repository + * Checks if any remote URL points to a GitRepublic server + * GitRepublic URLs have the pattern: http://domain/repos/npub1.../repo-name + * or http://domain/api/git/npub1.../repo-name.git + */ +function isGitRepublicRepo() { + try { + // Get all remotes + const remotes = execSync('git remote -v', { encoding: 'utf-8' }); + const remoteLines = remotes.split('\n').filter(line => line.trim()); + + // Check if any remote URL matches GitRepublic patterns + // GitRepublic URLs use specific path patterns to distinguish from GRASP: + // - http://localhost:5173/api/git/npub1.../repo-name.git (git operations via API) + // - http://domain.com/repos/npub1.../repo-name (web UI endpoint) + // - http://domain.com/npub1.../repo-name.git (direct, but conflicts with GRASP) + // + // Note: We prioritize /api/git/ and /repos/ prefixes to avoid confusion with GRASP + // which uses direct /npub/identifier.git pattern. If we only see /npub/ pattern + // without these prefixes, we can't reliably distinguish from GRASP. + for (const line of remoteLines) { + const match = line.match(/^(?:fetch|push)\s+(https?:\/\/[^\s]+)/); + if (match) { + const remoteUrl = match[1]; + // Check for specific GitRepublic URL patterns (more specific than GRASP): + // - /api/git/npub (GitRepublic API git endpoint - most reliable, unique to GitRepublic) + // - /repos/npub (GitRepublic repos endpoint - unique to GitRepublic) + // These patterns distinguish GitRepublic from GRASP which uses /npub/ directly + if (remoteUrl.includes('/api/git/npub') || + remoteUrl.includes('/repos/npub')) { + return true; + } + // Note: We don't check for direct /npub/ pattern here because it conflicts with GRASP + // Users should use /api/git/ or /repos/ paths for GitRepublic to avoid ambiguity + } + } + + // Also check for .nostr-announcement file (GitRepublic marker) + let gitDir = process.env.GIT_DIR; + if (!gitDir) { + // Try to find .git directory + let currentDir = process.cwd(); + for (let i = 0; i < 10; i++) { + const potentialGitDir = join(currentDir, '.git'); + if (existsSync(potentialGitDir)) { + gitDir = potentialGitDir; + break; + } + const parentDir = dirname(currentDir); + if (parentDir === currentDir) break; + currentDir = parentDir; + } + } + + if (gitDir) { + const gitParent = resolve(gitDir, '..'); + const announcementFile = join(gitParent, '.nostr-announcement'); + if (existsSync(announcementFile)) { + return true; + } + } + + // Also check current directory and parent directories + let currentDir = process.cwd(); + for (let i = 0; i < 5; i++) { + const announcementFile = join(currentDir, '.nostr-announcement'); + if (existsSync(announcementFile)) { + return true; + } + const parentDir = dirname(currentDir); + if (parentDir === currentDir) break; + currentDir = parentDir; + } + + return false; + } catch { + // If we can't determine, default to false (don't sign) + return false; + } +} + +/** + * Convert hex pubkey to shortened npub format + */ +function getShortenedNpub(hexPubkey) { + try { + // Convert hex string to Uint8Array + const pubkeyBytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + pubkeyBytes[i] = parseInt(hexPubkey.slice(i * 2, i * 2 + 2), 16); + } + + // Encode to npub + const npub = nip19.npubEncode(pubkeyBytes); + + // Return shortened version (first 16 characters: npub1 + 12 chars = 16 total) + // This gives us a reasonable identifier while keeping it readable + return npub.substring(0, 16); + } catch (error) { + // Fallback: use first 12 characters of hex pubkey + return hexPubkey.substring(0, 12); + } +} + +/** + * Create a commit signature event and append it to the commit message + */ +async function signCommitMessage(commitMessageFile) { + // Check if NOSTRGIT_SECRET_KEY is set + const secretKey = process.env.NOSTRGIT_SECRET_KEY; + if (!secretKey) { + // Allow unsigned commits, but inform user + console.error('⚠️ NOSTRGIT_SECRET_KEY not set - commit will not be signed'); + console.error(' Set it with: export NOSTRGIT_SECRET_KEY="nsec1..."'); + return; + } + + // Sign all commits by default - the signature is just text in the commit message + // and doesn't interfere with git operations. It's useful to have consistent + // signing across all repositories (GitHub, GitLab, GitRepublic, etc.) + // + // To disable signing for non-GitRepublic repos, set GITREPUBLIC_SIGN_ONLY_GITREPUBLIC=true + const isGitRepublic = isGitRepublicRepo(); + const signOnlyGitRepublic = process.env.GITREPUBLIC_SIGN_ONLY_GITREPUBLIC === 'true'; + + if (!isGitRepublic && signOnlyGitRepublic) { + // User explicitly wants to only sign GitRepublic repos + return; + } + + if (!isGitRepublic) { + // Signing non-GitRepublic repo (GitHub, GitLab, etc.) - this is fine! + // The signature is just metadata in the commit message + } + + try { + // Read the commit message + const commitMessage = readFileSync(commitMessageFile, 'utf-8').trim(); + + // Check if already signed (avoid double-signing) + if (commitMessage.includes('Nostr-Signature:')) { + console.log('ℹ️ Commit already signed, skipping'); + return; + } + + // Decode the private key and get pubkey + const keyBytes = decodeNostrKey(secretKey); + const pubkey = getPublicKey(keyBytes); + + // Get author info from git config, fallback to shortened npub + let authorName = getGitConfig('user.name'); + let authorEmail = getGitConfig('user.email'); + + if (!authorName || !authorEmail) { + const shortenedNpub = getShortenedNpub(pubkey); + + if (!authorName) { + authorName = shortenedNpub; + } + + if (!authorEmail) { + authorEmail = `${shortenedNpub}@gitrepublic.web`; + } + } + + // Create timestamp + const timestamp = Math.floor(Date.now() / 1000); + + // Create a commit signature event template + // Note: We don't have the commit hash yet, so we'll sign without it + // The signature is still valid as it signs the commit message + const eventTemplate = { + kind: KIND_COMMIT_SIGNATURE, + pubkey, + created_at: timestamp, + tags: [ + ['author', authorName, authorEmail], + ['message', commitMessage] + ], + content: `Signed commit: ${commitMessage}` + }; + + // Finalize and sign the event + const signedEvent = finalizeEvent(eventTemplate, keyBytes); + + // Create a signature trailer that git can recognize + // Format: Nostr-Signature: + // Note: The regex expects exactly 64 hex chars for event-id and pubkey, 128 for signature + const signatureTrailer = `\n\nNostr-Signature: ${signedEvent.id} ${signedEvent.pubkey} ${signedEvent.sig}`; + let signedMessage = commitMessage + signatureTrailer; + + // Store full event in nostr/ folder as JSONL (default behavior) + try { + // Find repository root (parent of .git directory) + let repoRoot = null; + let gitDir = process.env.GIT_DIR; + if (!gitDir) { + let currentDir = dirname(commitMessageFile); + for (let i = 0; i < 10; i++) { + const potentialGitDir = join(currentDir, '.git'); + if (existsSync(potentialGitDir)) { + gitDir = potentialGitDir; + repoRoot = currentDir; + break; + } + const parentDir = dirname(currentDir); + if (parentDir === currentDir) break; + currentDir = parentDir; + } + } else { + repoRoot = dirname(gitDir); + } + + if (repoRoot) { + // Store in nostr/ folder in repository root + const nostrDir = join(repoRoot, 'nostr'); + if (!existsSync(nostrDir)) { + execSync(`mkdir -p "${nostrDir}"`, { stdio: 'ignore' }); + } + + // Append to commit-signatures.jsonl (JSON Lines format) + const jsonlFile = join(nostrDir, 'commit-signatures.jsonl'); + const eventLine = JSON.stringify(signedEvent) + '\n'; + writeFileSync(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' }); + } + } catch (storeError) { + // Log but don't fail - storing event is nice-to-have + console.error(' ⚠️ Failed to store event file:', storeError instanceof Error ? storeError.message : 'Unknown error'); + } + + // Optionally include full event JSON in commit message (base64 encoded) + const includeFullEvent = process.env.GITREPUBLIC_INCLUDE_FULL_EVENT === 'true'; + if (includeFullEvent) { + const eventJson = JSON.stringify(signedEvent); + const eventBase64 = Buffer.from(eventJson, 'utf-8').toString('base64'); + signedMessage += `\nNostr-Event: ${eventBase64}`; + } + + // Verify the signature format matches what the server expects + const signatureRegex = /Nostr-Signature:\s+([0-9a-f]{64})\s+([0-9a-f]{64})\s+([0-9a-f]{128})/; + if (!signatureRegex.test(signedMessage)) { + throw new Error(`Generated signature format is invalid. Event ID: ${signedEvent.id.length} chars, Pubkey: ${signedEvent.pubkey.length} chars, Sig: ${signedEvent.sig.length} chars`); + } + + // Write the signed message back to the file + writeFileSync(commitMessageFile, signedMessage, 'utf-8'); + + // Optionally publish event to Nostr relays + const publishEvent = process.env.GITREPUBLIC_PUBLISH_EVENT === 'true'; + if (publishEvent) { + try { + const relaysEnv = process.env.NOSTR_RELAYS; + const relays = relaysEnv ? relaysEnv.split(',').map(r => r.trim()).filter(r => r.length > 0) : [ + 'wss://theforest.nostr1.com', + 'wss://relay.damus.io', + 'wss://nostr.land' + ]; + + const pool = new SimplePool(); + const results = await pool.publish(relays, signedEvent); + pool.close(relays); + + const successCount = results.size; + if (successCount > 0) { + console.log(` Published to ${successCount} relay(s)`); + } else { + console.log(' ⚠️ Failed to publish to relays'); + } + } catch (publishError) { + console.log(` ⚠️ Failed to publish event: ${publishError instanceof Error ? publishError.message : 'Unknown error'}`); + } + } + + // Print success message + const npub = getShortenedNpub(pubkey); + console.log('✅ Commit signed with Nostr key'); + console.log(` Pubkey: ${npub}...`); + console.log(` Event ID: ${signedEvent.id.substring(0, 16)}...`); + console.log(` Event stored in nostr/commit-signatures.jsonl`); + if (includeFullEvent) { + console.log(' Full event also included in commit message'); + } + } catch (error) { + // Log error + console.error('❌ Failed to sign commit:', error instanceof Error ? error.message : 'Unknown error'); + if (error instanceof Error && error.stack && process.env.DEBUG) { + console.error('Stack trace:', error.stack); + } + + // Check if user wants to cancel on signing failure + const cancelOnFailure = process.env.GITREPUBLIC_CANCEL_ON_SIGN_FAIL === 'true'; + + if (cancelOnFailure) { + console.error(' Commit cancelled due to signing failure (GITREPUBLIC_CANCEL_ON_SIGN_FAIL=true)'); + process.exit(1); + } else { + console.error(' Commit will proceed unsigned'); + // Exit with 0 to allow the commit to proceed even if signing fails + process.exit(0); + } + } +} + +// Main execution +const commitMessageFile = process.argv[2]; +if (!commitMessageFile) { + console.error('Usage: git-commit-msg-hook.js '); + process.exit(1); +} + +signCommitMessage(commitMessageFile).catch((error) => { + console.error('Fatal error in commit hook:', error); + process.exit(1); +}); diff --git a/scripts/git-credential-nostr.js b/scripts/git-credential-nostr.js new file mode 100755 index 0000000..b4412c1 --- /dev/null +++ b/scripts/git-credential-nostr.js @@ -0,0 +1,402 @@ +#!/usr/bin/env node +/** + * Git credential helper for GitRepublic using NIP-98 authentication + * + * This script implements the git credential helper protocol to automatically + * generate NIP-98 authentication tokens for git operations. + * + * Usage: + * 1. Install dependencies: npm install + * 2. Configure git: + * git config --global credential.helper '!node /path/to/gitrepublic-cli/scripts/git-credential-nostr.js' + * 3. Or for a specific domain: + * git config --global credential.https://your-domain.com.helper '!node /path/to/gitrepublic-cli/scripts/git-credential-nostr.js' + * + * Environment variables: + * NOSTRGIT_SECRET_KEY - Your Nostr private key (nsec format or hex) for client-side git operations + * + * Security: Keep your NOSTRGIT_SECRET_KEY secure and never commit it to version control! + */ + +import { createHash } from 'crypto'; +import { finalizeEvent, getPublicKey } from 'nostr-tools'; +import { decode } from 'nostr-tools/nip19'; +import { readFileSync, existsSync } from 'fs'; +import { join, resolve } from 'path'; + +// NIP-98 auth event kind +const KIND_NIP98_AUTH = 27235; + +/** + * Read input from stdin (git credential helper protocol) + */ +function readInput() { + const chunks = []; + process.stdin.setEncoding('utf8'); + + return new Promise((resolve) => { + process.stdin.on('readable', () => { + let chunk; + while ((chunk = process.stdin.read()) !== null) { + chunks.push(chunk); + } + }); + + process.stdin.on('end', () => { + const input = chunks.join(''); + const lines = input.trim().split('\n'); + const data = {}; + + for (const line of lines) { + if (!line) continue; + const [key, ...valueParts] = line.split('='); + if (key && valueParts.length > 0) { + data[key] = valueParts.join('='); + } + } + + resolve(data); + }); + }); +} + +/** + * Try to extract the git remote URL path from .git/config + * This is used as a fallback when git calls us with wwwauth[] but no path + */ +function tryGetPathFromGitRemote(host, protocol) { + try { + // Git sets GIT_DIR environment variable when calling credential helpers + // Use it if available, otherwise try to find .git directory + let gitDir = process.env.GIT_DIR; + let configPath = null; + + if (gitDir) { + // GIT_DIR might point directly to .git directory or to the config file + if (existsSync(gitDir) && existsSync(join(gitDir, 'config'))) { + configPath = join(gitDir, 'config'); + } else if (existsSync(gitDir) && gitDir.endsWith('config')) { + configPath = gitDir; + } + } + + // If GIT_DIR didn't work, try to find .git directory starting from current working directory + if (!configPath) { + let currentDir = process.cwd(); + const maxDepth = 10; // Limit search depth + let depth = 0; + + while (depth < maxDepth) { + const potentialGitDir = join(currentDir, '.git'); + if (existsSync(potentialGitDir) && existsSync(join(potentialGitDir, 'config'))) { + configPath = join(potentialGitDir, 'config'); + break; + } + + // Move up one directory + const parentDir = resolve(currentDir, '..'); + if (parentDir === currentDir) { + // Reached filesystem root + break; + } + currentDir = parentDir; + depth++; + } + } + + if (!configPath || !existsSync(configPath)) { + return null; + } + + // Read git config + const config = readFileSync(configPath, 'utf-8'); + + // Find remotes that match our host + // Match: [remote "name"] ... url = http://host/path + const remoteRegex = /\[remote\s+"([^"]+)"\][\s\S]*?url\s*=\s*([^\n]+)/g; + let match; + while ((match = remoteRegex.exec(config)) !== null) { + const remoteUrl = match[2].trim(); + + // Check if this remote URL matches our host + try { + const url = new URL(remoteUrl); + const remoteHost = url.hostname + (url.port ? ':' + url.port : ''); + if (url.host === host || remoteHost === host) { + // Extract path from remote URL + let path = url.pathname; + if (path && path.includes('git-receive-pack')) { + // Already has git-receive-pack in path + return path; + } else if (path && path.endsWith('.git')) { + // Add git-receive-pack to path + return path + '/git-receive-pack'; + } else if (path) { + // Path exists but doesn't end with .git, try adding /git-receive-pack + return path + '/git-receive-pack'; + } + } + } catch (e) { + // Not a valid URL, skip + continue; + } + } + } catch (err) { + // If anything fails, return null silently + } + + return null; +} + +/** + * Normalize URL for NIP-98 (remove trailing slashes, ensure consistent format) + * This must match the normalization used by the server in nip98-auth.ts + */ +function normalizeUrl(url) { + try { + const parsed = new URL(url); + // Remove trailing slash from pathname (must match server normalization) + parsed.pathname = parsed.pathname.replace(/\/$/, ''); + return parsed.toString(); + } catch { + return url; + } +} + +/** + * Calculate SHA256 hash of request body + */ +function calculateBodyHash(body) { + if (!body) return null; + const buffer = Buffer.from(body, 'utf-8'); + return createHash('sha256').update(buffer).digest('hex'); +} + +/** + * Create and sign a NIP-98 authentication event + * @param privateKeyBytes - Private key as Uint8Array (32 bytes) + * @param url - Request URL + * @param method - HTTP method (GET, POST, etc.) + * @param bodyHash - Optional SHA256 hash of request body (for POST requests) + */ +function createNIP98AuthEvent(privateKeyBytes, url, method, bodyHash = null) { + const pubkey = getPublicKey(privateKeyBytes); + const tags = [ + ['u', normalizeUrl(url)], + ['method', method.toUpperCase()] + ]; + + if (bodyHash) { + tags.push(['payload', bodyHash]); + } + + const eventTemplate = { + kind: KIND_NIP98_AUTH, + pubkey, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags + }; + + // Sign the event using finalizeEvent (which computes id and sig) + const signedEvent = finalizeEvent(eventTemplate, privateKeyBytes); + + return signedEvent; +} + +/** + * Main credential helper logic + */ +async function main() { + try { + // Read input from git + const input = await readInput(); + + // Get command (get, store, erase) + const command = process.argv[2] || 'get'; + + // For 'get' command, generate credentials + if (command === 'get') { + // Get private key from environment variable + // Support NOSTRGIT_SECRET_KEY (preferred), with fallbacks for backward compatibility + const nsec = process.env.NOSTRGIT_SECRET_KEY || process.env.NOSTR_PRIVATE_KEY || process.env.NSEC; + if (!nsec) { + console.error('Error: NOSTRGIT_SECRET_KEY environment variable is not set'); + console.error('Set it with: export NOSTRGIT_SECRET_KEY="nsec1..." or NOSTRGIT_SECRET_KEY=""'); + process.exit(1); + } + + // Parse private key (handle both nsec and hex formats) + // Convert to Uint8Array for nostr-tools functions + let privateKeyBytes; + if (nsec.startsWith('nsec')) { + try { + const decoded = decode(nsec); + if (decoded.type === 'nsec') { + // decoded.data is already Uint8Array for nsec + privateKeyBytes = decoded.data; + } else { + throw new Error('Invalid nsec format - decoded type is not nsec'); + } + } catch (err) { + console.error('Error decoding nsec:', err.message); + process.exit(1); + } + } else { + // Assume hex format (32 bytes = 64 hex characters) + if (nsec.length !== 64) { + console.error('Error: Hex private key must be 64 characters (32 bytes)'); + process.exit(1); + } + // Convert hex string to Uint8Array + privateKeyBytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + privateKeyBytes[i] = parseInt(nsec.slice(i * 2, i * 2 + 2), 16); + } + } + + // Extract URL components from input + // Git credential helper protocol passes: protocol, host, path (and sometimes username, password) + // Git may provide either individual attributes (protocol, host, path) or a url attribute + // If we have a url, use it; otherwise construct from individual attributes + let url; + if (input.url) { + // Git provided a url attribute - use it directly + url = input.url; + } else { + // Construct URL from individual attributes + const protocol = input.protocol || 'https'; + const host = input.host; + let path = input.path || ''; + const wwwauth = input['wwwauth[]'] || input.wwwauth; + + if (!host) { + console.error('Error: No host specified in credential request'); + process.exit(1); + } + + // If path is missing, try to extract it from git remote URL + // This happens when git calls us reactively after a 401 with wwwauth[] but no path + if (!path) { + if (wwwauth) { + // Try to get path from git remote URL + const extractedPath = tryGetPathFromGitRemote(host, protocol); + if (extractedPath) { + path = extractedPath; + } else { + // Exit without output - git should call us again with the full path when it retries + process.exit(0); + } + } else { + // Exit without output - git will call us again with the full path + process.exit(0); + } + } + + // Build full URL (include query string if present) + const query = input.query || ''; + const fullPath = query ? `${path}?${query}` : path; + url = `${protocol}://${host}${fullPath}`; + } + + // Parse URL to extract components for method detection + let urlPath = ''; + try { + const urlObj = new URL(url); + urlPath = urlObj.pathname; + } catch (err) { + // If URL parsing fails, try to extract path from the URL string + const match = url.match(/https?:\/\/[^\/]+(\/.*)/); + urlPath = match ? match[1] : ''; + } + + // Determine HTTP method based on git operation + // Git credential helper doesn't know the HTTP method, but we can infer it: + // - If path contains 'git-receive-pack', it's a push (POST) + // - If path contains 'git-upload-pack', it's a fetch (GET) + // - For info/refs requests, check the service query parameter + let method = 'GET'; + let authUrl = url; // The URL for which we generate credentials + + // Parse query string from URL if present + let query = ''; + try { + const urlObj = new URL(url); + query = urlObj.search.slice(1); // Remove leading '?' + } catch (err) { + // If URL parsing fails, try to extract query from the URL string + const match = url.match(/\?(.+)$/); + query = match ? match[1] : ''; + } + + if (urlPath.includes('git-receive-pack')) { + // Direct POST request to git-receive-pack + method = 'POST'; + authUrl = url; + } else if (urlPath.includes('git-upload-pack')) { + // Direct GET request to git-upload-pack + method = 'GET'; + authUrl = url; + } else if (query.includes('service=git-receive-pack')) { + // info/refs?service=git-receive-pack - this is a GET request + // However, git might not call us again for the POST request + // So we need to generate credentials for the POST request that will happen next + // Replace info/refs with git-receive-pack in the path + try { + const urlObj = new URL(url); + urlObj.pathname = urlObj.pathname.replace(/\/info\/refs$/, '/git-receive-pack'); + urlObj.search = ''; // Remove query string for POST request + authUrl = urlObj.toString(); + } catch (err) { + // Fallback: string replacement + authUrl = url.replace(/\/info\/refs(\?.*)?$/, '/git-receive-pack'); + } + method = 'POST'; + } else { + // Default: GET request (info/refs, etc.) + method = 'GET'; + authUrl = url; + } + + // Normalize the URL before creating the event (must match server normalization) + const normalizedAuthUrl = normalizeUrl(authUrl); + + // Create and sign NIP-98 auth event + const authEvent = createNIP98AuthEvent(privateKeyBytes, normalizedAuthUrl, method); + + // Encode event as base64 + const eventJson = JSON.stringify(authEvent); + const base64Event = Buffer.from(eventJson, 'utf-8').toString('base64'); + + // Output credentials in git credential helper format + // Username can be anything (git doesn't use it for NIP-98) + // Password is the base64-encoded signed event + console.log('username=nostr'); + console.log(`password=${base64Event}`); + + } else if (command === 'store') { + // For 'store', we don't store credentials because NIP-98 requires per-request credentials + // The URL and method are part of the signed event, so we can't reuse credentials + // However, we should NOT prevent git from storing - let other credential helpers handle it + // We just exit successfully without storing anything ourselves + // This allows git to call us again for each request + process.exit(0); + } else if (command === 'erase') { + // For 'erase', we don't need to do anything + // Just exit successfully + process.exit(0); + } else { + console.error(`Error: Unknown command: ${command}`); + process.exit(1); + } + } catch (error) { + console.error('Error:', error.message); + process.exit(1); + } +} + +// Run main function +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/scripts/git-wrapper.js b/scripts/git-wrapper.js new file mode 100755 index 0000000..7da3eed --- /dev/null +++ b/scripts/git-wrapper.js @@ -0,0 +1,404 @@ +#!/usr/bin/env node +/** + * Git wrapper that provides detailed error messages for GitRepublic operations + * + * This script wraps git commands and provides helpful error messages when + * operations fail, especially for authentication and permission errors. + * + * Usage: + * gitrepublic [arguments...] + * gitrep [arguments...] (shorter alias) + * + * Examples: + * gitrep clone https://domain.com/api/git/npub1.../repo.git gitrepublic-web + * gitrep push gitrepublic-web main + * gitrep pull gitrepublic-web main + * gitrep fetch gitrepublic-web + */ + +import { spawn, execSync } from 'child_process'; +import { createHash } from 'crypto'; +import { finalizeEvent } from 'nostr-tools'; +import { decode } from 'nostr-tools/nip19'; + +// NIP-98 auth event kind +const KIND_NIP98_AUTH = 27235; + +// Commands that interact with remotes (need error handling) +const REMOTE_COMMANDS = ['clone', 'push', 'pull', 'fetch', 'ls-remote']; + +// Get git remote URL +function getRemoteUrl(remote = 'origin') { + try { + const url = execSync(`git config --get remote.${remote}.url`, { encoding: 'utf-8' }).trim(); + return url; + } catch { + return null; + } +} + +// Extract server URL and repo path from git remote URL +function parseGitUrl(url) { + // Match patterns like: + // http://localhost:5173/api/git/npub1.../repo.git + // https://domain.com/api/git/npub1.../repo.git + // http://localhost:5173/repos/npub1.../repo.git + const match = url.match(/^(https?:\/\/[^\/]+)(\/api\/git\/|\/repos\/)(.+)$/); + if (match) { + return { + server: match[1], + path: match[3] + }; + } + return null; +} + +// Check if URL is a GitRepublic repository +function isGitRepublicUrl(url) { + return url && (url.includes('/api/git/') || url.includes('/repos/')); +} + +// Get NOSTRGIT_SECRET_KEY from environment +function getSecretKey() { + return process.env.NOSTRGIT_SECRET_KEY || null; +} + +// Create NIP-98 authentication event +function createNIP98Auth(url, method, body = null) { + const secretKey = getSecretKey(); + if (!secretKey) { + return null; + } + + try { + // Decode secret key (handle both nsec and hex formats) + let hexKey; + if (secretKey.startsWith('nsec')) { + const decoded = decode(secretKey); + hexKey = decoded.data; + } else { + hexKey = secretKey; + } + + // Create auth event + const tags = [ + ['u', url], + ['method', method] + ]; + + if (body) { + const hash = createHash('sha256').update(body).digest('hex'); + tags.push(['payload', hash]); + } + + const event = finalizeEvent({ + kind: KIND_NIP98_AUTH, + created_at: Math.floor(Date.now() / 1000), + tags, + content: '' + }, hexKey); + + // Encode event as base64 + const eventJson = JSON.stringify(event); + return Buffer.from(eventJson).toString('base64'); + } catch (err) { + return null; + } +} + +// Fetch error message from server +async function fetchErrorMessage(server, path, method = 'POST') { + try { + const url = `${server}/api/git/${path}/git-receive-pack`; + const authEvent = createNIP98Auth(url, method); + + if (!authEvent) { + return null; + } + + // Create Basic auth header (username=nostr, password=base64-event) + const authHeader = Buffer.from(`nostr:${authEvent}`).toString('base64'); + + // Use Node's fetch API (available in Node 18+) + try { + const response = await fetch(url, { + method: method, + headers: { + 'Authorization': `Basic ${authHeader}`, + 'Content-Type': method === 'POST' ? 'application/x-git-receive-pack-request' : 'application/json', + 'Content-Length': '0' + } + }); + + if (response.status === 403 || response.status === 401) { + const text = await response.text(); + return { status: response.status, message: text || null }; + } + + return null; + } catch (fetchErr) { + // Fallback: if fetch is not available, use http module + const { request } = await import('http'); + const { request: httpsRequest } = await import('https'); + const httpModule = url.startsWith('https:') ? httpsRequest : request; + const urlObj = new URL(url); + + return new Promise((resolve) => { + const req = httpModule({ + hostname: urlObj.hostname, + port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), + path: urlObj.pathname, + method: method, + headers: { + 'Authorization': `Basic ${authHeader}`, + 'Content-Type': method === 'POST' ? 'application/x-git-receive-pack-request' : 'application/json', + 'Content-Length': '0' + } + }, (res) => { + let body = ''; + res.on('data', (chunk) => { + body += chunk.toString(); + }); + res.on('end', () => { + if ((res.statusCode === 403 || res.statusCode === 401) && body) { + resolve({ status: res.statusCode, message: body }); + } else { + resolve(null); + } + }); + }); + + req.on('error', () => { + resolve(null); + }); + + req.end(); + }); + } + } catch (err) { + return null; + } +} + +// Format error message for display +function formatErrorMessage(errorInfo, command, args) { + if (!errorInfo || !errorInfo.message) { + return null; + } + + const lines = [ + '', + '='.repeat(70), + `GitRepublic Error Details (${command})`, + '='.repeat(70), + '', + errorInfo.message, + '', + '='.repeat(70), + '' + ]; + + return lines.join('\n'); +} + +// Run git command and capture output +function runGitCommand(command, args) { + return new Promise((resolve) => { + const gitProcess = spawn('git', [command, ...args], { + stdio: ['inherit', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + + gitProcess.stdout.on('data', (chunk) => { + const text = chunk.toString(); + stdout += text; + process.stdout.write(chunk); + }); + + gitProcess.stderr.on('data', (chunk) => { + const text = chunk.toString(); + stderr += text; + process.stderr.write(chunk); + }); + + gitProcess.on('close', (code) => { + resolve({ code, stdout, stderr }); + }); + + gitProcess.on('error', (err) => { + resolve({ code: 1, stdout, stderr, error: err }); + }); + }); +} + +// Show help +function showHelp() { + console.log(` +GitRepublic Git Wrapper + +A drop-in replacement for git that provides enhanced error messages for GitRepublic operations. + +Usage: + gitrepublic [arguments...] + gitrep [arguments...] (shorter alias) + +Examples: + gitrep clone https://domain.com/api/git/npub1.../repo.git gitrepublic-web + gitrep push gitrepublic-web main + gitrep pull gitrepublic-web main + gitrep fetch gitrepublic-web + gitrep branch + gitrep commit -m "My commit" + +Note: "gitrep" is a shorter alias for "gitrepublic" - both work the same way. +We suggest using "gitrepublic-web" as the remote name instead of "origin" +because "origin" is often already set to GitHub, GitLab, or other services. + +Features: + - Works with all git commands (clone, push, pull, fetch, branch, merge, etc.) + - Enhanced error messages for GitRepublic repositories + - Detailed authentication and permission error information + - Transparent pass-through for non-GitRepublic repositories (GitHub, GitLab, etc.) + +For GitRepublic repositories, the wrapper provides: + - Detailed 401/403 error messages with pubkeys and maintainer information + - Helpful guidance on how to fix authentication issues + - Automatic fetching of error details from the server + +Documentation: https://github.com/silberengel/gitrepublic-cli +GitCitadel: Visit us on GitHub: https://github.com/ShadowySupercode or on our homepage: https://gitcitadel.com + +GitRepublic CLI - Copyright (c) 2026 GitCitadel LLC +Licensed under MIT License +`); +} + +// Main function +async function main() { + const args = process.argv.slice(2); + + // Check for help flag + if (args.length === 0 || args.includes('--help') || args.includes('-h')) { + showHelp(); + process.exit(0); + } + + const command = args[0]; + const commandArgs = args.slice(1); + + // For clone, check if URL is GitRepublic + if (command === 'clone' && commandArgs.length > 0) { + const url = commandArgs[commandArgs.length - 1]; + if (!isGitRepublicUrl(url)) { + // Not a GitRepublic URL, just run git normally + const result = await runGitCommand(command, commandArgs); + process.exit(result.code || 0); + return; + } + } + + // For non-remote commands (branch, merge, commit, etc.), just pass through + // These don't interact with remotes, so no special error handling needed + if (!REMOTE_COMMANDS.includes(command)) { + const result = await runGitCommand(command, commandArgs); + process.exit(result.code || 0); + return; + } + + // Run git command (for remote commands) + const result = await runGitCommand(command, commandArgs); + + // If command failed and it's a remote command, try to get detailed error + // But only if it's a GitRepublic repository + if (result.code !== 0 && REMOTE_COMMANDS.includes(command)) { + const hasAuthError = result.stderr.includes('401') || + result.stderr.includes('403') || + result.stdout.includes('401') || + result.stdout.includes('403'); + + if (hasAuthError) { + let remoteUrl = null; + let parsed = null; + + // For clone, get URL from arguments + if (command === 'clone' && commandArgs.length > 0) { + remoteUrl = commandArgs[commandArgs.length - 1]; + parsed = parseGitUrl(remoteUrl); + } else { + // For other commands (push, pull, fetch), try to get remote name from args first + // Commands like "push gitrepublic-web main" or "push -u gitrepublic-web main" + let remoteName = 'origin'; // Default + for (let i = 0; i < commandArgs.length; i++) { + const arg = commandArgs[i]; + // Skip flags like -u, --set-upstream, etc. + if (arg.startsWith('-')) { + continue; + } + // If it doesn't look like a branch/ref (no /, not a commit hash), it might be a remote + if (!arg.includes('/') && !/^[0-9a-f]{7,40}$/.test(arg)) { + remoteName = arg; + break; + } + } + + // Try the specified remote, then fall back to 'origin', then 'gitrepublic-web' + remoteUrl = getRemoteUrl(remoteName); + if (!remoteUrl && remoteName !== 'origin') { + remoteUrl = getRemoteUrl('origin'); + } + if (!remoteUrl) { + remoteUrl = getRemoteUrl('gitrepublic-web'); + } + + if (remoteUrl && isGitRepublicUrl(remoteUrl)) { + parsed = parseGitUrl(remoteUrl); + } + } + + // Only try to fetch detailed errors for GitRepublic repositories + if (parsed) { + // Try to fetch detailed error message + const errorInfo = await fetchErrorMessage(parsed.server, parsed.path, command === 'push' ? 'POST' : 'GET'); + + if (errorInfo && errorInfo.message) { + const formattedError = formatErrorMessage(errorInfo, command, commandArgs); + if (formattedError) { + console.error(formattedError); + } + } else { + // Provide helpful guidance even if we can't fetch the error + console.error(''); + console.error('='.repeat(70)); + console.error(`GitRepublic ${command} failed`); + console.error('='.repeat(70)); + console.error(''); + + if (result.stderr.includes('401') || result.stdout.includes('401')) { + console.error('Authentication failed. Please check:'); + console.error(' 1. NOSTRGIT_SECRET_KEY is set correctly'); + console.error(' 2. Your private key (nsec) matches the repository owner or maintainer'); + console.error(' 3. The credential helper is configured: gitrep-setup (or gitrepublic-setup)'); + } else if (result.stderr.includes('403') || result.stdout.includes('403')) { + console.error('Permission denied. Please check:'); + console.error(' 1. You are using the correct private key (nsec)'); + console.error(' 2. You are the repository owner or have been added as a maintainer'); + } + + console.error(''); + console.error('For more help, see: https://github.com/silberengel/gitrepublic-cli'); + console.error('='.repeat(70)); + console.error(''); + } + } + } + } + + process.exit(result.code || 0); +} + +main().catch((err) => { + console.error('Error:', err.message); + process.exit(1); +}); diff --git a/scripts/gitrepublic.js b/scripts/gitrepublic.js new file mode 100755 index 0000000..8a1c4d5 --- /dev/null +++ b/scripts/gitrepublic.js @@ -0,0 +1,1730 @@ +#!/usr/bin/env node +/** + * GitRepublic CLI - Command-line interface for GitRepublic API + * + * Provides access to all GitRepublic APIs from the command line + * + * Usage: + * gitrepublic [options] + * + * Commands: + * repos list List repositories + * repos get Get repository info + * repos settings Get/update repository settings + * repos maintainers [add|remove] Manage maintainers + * repos branches List branches + * repos tags List tags + * repos fork Fork a repository + * repos delete Delete a repository + * file get Get file content + * file put Create/update file + * file delete Delete file + * search Search repositories + * + * Options: + * --server GitRepublic server URL (default: http://localhost:5173) + * --key Nostr private key (overrides NOSTRGIT_SECRET_KEY) + * --json Output JSON format + * --help Show help + */ + +import { createHash } from 'crypto'; +import { finalizeEvent, getPublicKey, nip19, SimplePool, verifyEvent, getEventHash } from 'nostr-tools'; +import { decode } from 'nostr-tools/nip19'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { execSync } from 'child_process'; +import { join, dirname, resolve } from 'path'; + +// NIP-98 auth event kind +const KIND_NIP98_AUTH = 27235; + +// Default server URL +// Note: localhost:5173 is the SvelteKit dev server port +// In production, set GITREPUBLIC_SERVER environment variable to your server URL +const DEFAULT_SERVER = process.env.GITREPUBLIC_SERVER || 'http://localhost:5173'; + +/** + * Decode Nostr key and get private key bytes + */ +function getPrivateKeyBytes(key) { + 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.'); +} + +/** + * Create NIP-98 authentication header + */ +function createNIP98Auth(url, method, body = null) { + const secretKey = process.env.NOSTRGIT_SECRET_KEY || process.env.NOSTR_PRIVATE_KEY || process.env.NSEC; + if (!secretKey) { + throw new Error('NOSTRGIT_SECRET_KEY environment variable is not set'); + } + + const privateKeyBytes = getPrivateKeyBytes(secretKey); + const pubkey = getPublicKey(privateKeyBytes); + + // Normalize URL (remove trailing slash) + const normalizedUrl = url.replace(/\/$/, ''); + + const tags = [ + ['u', normalizedUrl], + ['method', method.toUpperCase()] + ]; + + if (body) { + const bodyHash = createHash('sha256').update(typeof body === 'string' ? body : JSON.stringify(body)).digest('hex'); + tags.push(['payload', bodyHash]); + } + + const eventTemplate = { + kind: KIND_NIP98_AUTH, + pubkey, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags + }; + + const signedEvent = finalizeEvent(eventTemplate, privateKeyBytes); + const eventJson = JSON.stringify(signedEvent); + const base64Event = Buffer.from(eventJson, 'utf-8').toString('base64'); + + return `Nostr ${base64Event}`; +} + +/** + * Store event in appropriate JSONL file based on event kind + */ +function storeEventInJsonl(event) { + try { + // Find repository root (look for .git directory) + let repoRoot = null; + let currentDir = process.cwd(); + + for (let i = 0; i < 10; i++) { + const potentialGitDir = join(currentDir, '.git'); + if (existsSync(potentialGitDir)) { + repoRoot = currentDir; + break; + } + const parentDir = dirname(currentDir); + if (parentDir === currentDir) break; + currentDir = parentDir; + } + + if (!repoRoot) { + // Not in a git repo, skip storing + return; + } + + // Create nostr/ directory if it doesn't exist + const nostrDir = join(repoRoot, 'nostr'); + if (!existsSync(nostrDir)) { + execSync(`mkdir -p "${nostrDir}"`, { stdio: 'ignore' }); + } + + // Determine JSONL file name based on event kind + let jsonlFile; + switch (event.kind) { + case 30617: // REPO_ANNOUNCEMENT + jsonlFile = join(nostrDir, 'repo-announcements.jsonl'); + break; + case 1641: // OWNERSHIP_TRANSFER + jsonlFile = join(nostrDir, 'ownership-transfers.jsonl'); + break; + case 1617: // PATCH + jsonlFile = join(nostrDir, 'patches.jsonl'); + break; + case 1618: // PULL_REQUEST + jsonlFile = join(nostrDir, 'pull-requests.jsonl'); + break; + case 1619: // PULL_REQUEST_UPDATE + jsonlFile = join(nostrDir, 'pull-request-updates.jsonl'); + break; + case 1621: // ISSUE + jsonlFile = join(nostrDir, 'issues.jsonl'); + break; + case 1630: // STATUS_OPEN + case 1631: // STATUS_APPLIED + case 1632: // STATUS_CLOSED + case 1633: // STATUS_DRAFT + jsonlFile = join(nostrDir, 'status-events.jsonl'); + break; + case 30618: // REPO_STATE + jsonlFile = join(nostrDir, 'repo-states.jsonl'); + break; + default: + // Store unknown event types in a generic file + jsonlFile = join(nostrDir, `events-kind-${event.kind}.jsonl`); + } + + // Append event to JSONL file + const eventLine = JSON.stringify(event) + '\n'; + writeFileSync(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' }); + } catch (error) { + // Silently fail - storing is optional + } +} + +/** + * Publish event to Nostr relays using SimplePool + */ +async function publishToRelays(event, relays) { + const pool = new SimplePool(); + const success = []; + const failed = []; + + try { + // Publish to all relays - SimplePool handles this automatically + // Returns a Set of relays that accepted the event + const results = await pool.publish(relays, event); + + // Check which relays succeeded + for (const relay of relays) { + if (results && results.has && results.has(relay)) { + success.push(relay); + } else { + failed.push({ relay, error: 'Relay did not accept event' }); + } + } + } catch (error) { + // If publish fails entirely, mark all relays as failed + for (const relay of relays) { + failed.push({ relay, error: String(error) }); + } + } finally { + // Close all connections + pool.close(relays); + } + + return { success, failed }; +} + +/** + * Make authenticated API request + */ +async function apiRequest(server, endpoint, method = 'GET', body = null, options = {}) { + const url = `${server.replace(/\/$/, '')}/api${endpoint}`; + const authHeader = createNIP98Auth(url, method, body); + + const headers = { + 'Authorization': authHeader, + 'Content-Type': 'application/json' + }; + + const fetchOptions = { + method, + headers, + ...options + }; + + if (body && method !== 'GET') { + fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body); + } + + const response = await fetch(url, fetchOptions); + const text = await response.text(); + + let data; + try { + data = JSON.parse(text); + } catch { + data = text; + } + + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}\n${typeof data === 'object' ? JSON.stringify(data, null, 2) : data}`); + } + + return data; +} + +/** + * Command handlers + */ +const commands = { + async repos(args, server, json) { + const subcommand = args[0]; + + if (subcommand === 'list') { + // Get registered and unregistered repos from Nostr + const listData = await apiRequest(server, '/repos/list', 'GET'); + + // Get local repos (cloned on server) + let localRepos = []; + try { + localRepos = await apiRequest(server, '/repos/local', 'GET'); + } catch (err) { + // Local repos endpoint might not be available or might fail + // Continue without local repos + } + + // Helper function to check verification status + async function checkVerification(npub, repoName) { + try { + // The verify endpoint doesn't require authentication, so we can call it directly + const url = `${server.replace(/\/$/, '')}/api/repos/${npub}/${repoName}/verify`; + const response = await fetch(url); + if (!response.ok) { + // If endpoint returns error, assume not verified + return false; + } + const verifyData = await response.json(); + // Return true only if verified is explicitly true + return verifyData.verified === true; + } catch (err) { + // Silently fail - assume not verified if check fails + return false; + } + } + + // Check verification status for all repos (in parallel for performance) + const registered = listData.registered || []; + const verificationPromises = []; + + // Check verification for registered repos + for (const repo of registered) { + const name = repo.repoName || repo.name || 'unknown'; + const npub = repo.npub || 'unknown'; + if (name !== 'unknown' && npub !== 'unknown') { + verificationPromises.push( + checkVerification(npub, name).then(verified => ({ + key: `${npub}/${name}`, + verified + })) + ); + } + } + + // Check verification for local repos + for (const repo of localRepos) { + const name = repo.repoName || repo.name || 'unknown'; + const npub = repo.npub || 'unknown'; + if (name !== 'unknown' && npub !== 'unknown') { + verificationPromises.push( + checkVerification(npub, name).then(verified => ({ + key: `${npub}/${name}`, + verified + })) + ); + } + } + + // Wait for all verification checks to complete + const verificationResults = await Promise.all(verificationPromises); + const verifiedMap = new Map(); + verificationResults.forEach(result => { + verifiedMap.set(result.key, result.verified); + }); + + // Debug: Log verification results if needed + // console.error('Verification map:', Array.from(verifiedMap.entries())); + + if (json) { + // Add verification status to JSON output + const registeredWithVerification = registered.map(repo => ({ + ...repo, + verified: verifiedMap.get(`${repo.npub}/${repo.repoName || repo.name || 'unknown'}`) || false + })); + const localWithVerification = localRepos.map(repo => ({ + ...repo, + verified: verifiedMap.get(`${repo.npub}/${repo.repoName || repo.name || 'unknown'}`) || false + })); + + console.log(JSON.stringify({ + registered: registeredWithVerification, + local: localWithVerification, + total: { + registered: registered.length, + local: localRepos.length, + total: (registered.length + localRepos.length) + } + }, null, 2)); + } else { + // Display help text explaining the difference + console.log('Repository Types:'); + console.log(' Registered: Repositories announced on Nostr with this server in their clone URLs'); + console.log(' Local: Repositories cloned on this server (may be registered or unregistered)'); + console.log(' Verified: Repository ownership has been cryptographically verified'); + console.log(''); + + // Display registered repositories + if (registered.length > 0) { + console.log('Registered Repositories:'); + registered.forEach(repo => { + const name = repo.repoName || repo.name || 'unknown'; + const npub = repo.npub || 'unknown'; + const desc = repo.event?.tags?.find(t => t[0] === 'description')?.[1] || + repo.description || + 'No description'; + const key = `${npub}/${name}`; + const verified = verifiedMap.has(key) ? verifiedMap.get(key) : false; + const verifiedStatus = verified ? 'verified' : 'unverified'; + console.log(` ${npub}/${name} (${verifiedStatus}) - ${desc}`); + }); + console.log(''); + } + + // Display local repositories + if (localRepos.length > 0) { + console.log('Local Repositories:'); + localRepos.forEach(repo => { + const name = repo.repoName || repo.name || 'unknown'; + const npub = repo.npub || 'unknown'; + const desc = repo.announcement?.tags?.find(t => t[0] === 'description')?.[1] || + repo.description || + 'No description'; + const registrationStatus = repo.isRegistered ? 'registered' : 'unregistered'; + const key = `${npub}/${name}`; + // Get verification status - use has() to distinguish between false and undefined + const verified = verifiedMap.has(key) ? verifiedMap.get(key) : false; + const verifiedStatus = verified ? 'verified' : 'unverified'; + console.log(` ${npub}/${name} (${registrationStatus}, ${verifiedStatus}) - ${desc}`); + }); + console.log(''); + } + + // Summary + const totalRegistered = registered.length; + const totalLocal = localRepos.length; + const totalVerified = Array.from(verifiedMap.values()).filter(v => v === true).length; + if (totalRegistered === 0 && totalLocal === 0) { + console.log('No repositories found.'); + } else { + console.log(`Total: ${totalRegistered} registered, ${totalLocal} local, ${totalVerified} verified`); + } + } + } else if (subcommand === 'get' && args[1]) { + let npub, repo; + + // Check if first argument is naddr format + if (args[1].startsWith('naddr1')) { + try { + const decoded = decode(args[1]); + if (decoded.type === 'naddr') { + const data = decoded.data; + // naddr contains pubkey (hex) and identifier (d-tag) + npub = nip19.npubEncode(data.pubkey); + repo = data.identifier || data['d']; + if (!repo) { + throw new Error('Invalid naddr: missing identifier (d-tag)'); + } + } else { + throw new Error('Invalid naddr format'); + } + } catch (err) { + console.error(`Error: Failed to decode naddr: ${err.message}`); + process.exit(1); + } + } else if (args[2]) { + // Traditional npub/repo format + [npub, repo] = args.slice(1); + } else { + console.error('Error: Invalid arguments. Use: repos get or repos get '); + process.exit(1); + } + + const data = await apiRequest(server, `/repos/${npub}/${repo}/settings`, 'GET'); + if (json) { + console.log(JSON.stringify(data, null, 2)); + } else { + console.log(`Repository: ${npub}/${repo}`); + console.log(`Description: ${data.description || 'No description'}`); + console.log(`Private: ${data.private ? 'Yes' : 'No'}`); + console.log(`Owner: ${data.owner || npub}`); + } + } else if (subcommand === 'settings' && args[1] && args[2]) { + const [npub, repo] = args.slice(1); + if (args[3]) { + // Update settings + const settings = {}; + for (let i = 3; i < args.length; i += 2) { + const key = args[i].replace('--', ''); + const value = args[i + 1]; + if (key === 'description') settings.description = value; + else if (key === 'private') settings.private = value === 'true'; + } + const data = await apiRequest(server, `/repos/${npub}/${repo}/settings`, 'POST', settings); + console.log(json ? JSON.stringify(data, null, 2) : 'Settings updated successfully'); + } else { + // Get settings + const data = await apiRequest(server, `/repos/${npub}/${repo}/settings`, 'GET'); + console.log(json ? JSON.stringify(data, null, 2) : JSON.stringify(data, null, 2)); + } + } else if (subcommand === 'maintainers' && args[1] && args[2]) { + const [npub, repo] = args.slice(1); + const action = args[3]; + const maintainerNpub = args[4]; + + if (action === 'add' && maintainerNpub) { + const data = await apiRequest(server, `/repos/${npub}/${repo}/maintainers`, 'POST', { maintainer: maintainerNpub }); + console.log(json ? JSON.stringify(data, null, 2) : `Maintainer ${maintainerNpub} added successfully`); + } else if (action === 'remove' && maintainerNpub) { + const data = await apiRequest(server, `/repos/${npub}/${repo}/maintainers`, 'DELETE', { maintainer: maintainerNpub }); + console.log(json ? JSON.stringify(data, null, 2) : `Maintainer ${maintainerNpub} removed successfully`); + } else { + // List maintainers + const data = await apiRequest(server, `/repos/${npub}/${repo}/maintainers`, 'GET'); + if (json) { + console.log(JSON.stringify(data, null, 2)); + } else { + console.log(`Repository: ${npub}/${repo}`); + console.log(`Owner: ${data.owner}`); + console.log(`Maintainers: ${data.maintainers?.length || 0}`); + if (data.maintainers?.length > 0) { + data.maintainers.forEach(m => console.log(` - ${m}`)); + } + } + } + } else if (subcommand === 'branches' && args[1] && args[2]) { + const [npub, repo] = args.slice(1); + const data = await apiRequest(server, `/repos/${npub}/${repo}/branches`, 'GET'); + if (json) { + console.log(JSON.stringify(data, null, 2)); + } else { + console.log(`Branches for ${npub}/${repo}:`); + if (Array.isArray(data)) { + data.forEach(branch => { + console.log(` ${branch.name} - ${branch.commit?.substring(0, 7) || 'N/A'}`); + }); + } + } + } else if (subcommand === 'tags' && args[1] && args[2]) { + const [npub, repo] = args.slice(1); + const data = await apiRequest(server, `/repos/${npub}/${repo}/tags`, 'GET'); + if (json) { + console.log(JSON.stringify(data, null, 2)); + } else { + console.log(`Tags for ${npub}/${repo}:`); + if (Array.isArray(data)) { + data.forEach(tag => { + console.log(` ${tag.name} - ${tag.hash?.substring(0, 7) || 'N/A'}`); + }); + } + } + } else if (subcommand === 'fork' && args[1] && args[2]) { + const [npub, repo] = args.slice(1); + const data = await apiRequest(server, `/repos/${npub}/${repo}/fork`, 'POST', {}); + if (json) { + console.log(JSON.stringify(data, null, 2)); + } else { + console.log(`Repository forked successfully: ${data.npub}/${data.repo}`); + } + } else if (subcommand === 'delete' && args[1] && args[2]) { + const [npub, repo] = args.slice(1); + const data = await apiRequest(server, `/repos/${npub}/${repo}/delete`, 'DELETE'); + console.log(json ? JSON.stringify(data, null, 2) : 'Repository deleted successfully'); + } else { + console.error('Invalid repos command. Use: list, get, settings, maintainers, branches, tags, fork, delete'); + process.exit(1); + } + }, + + async file(args, server, json) { + const subcommand = args[0]; + + if (subcommand === 'get' && args[1] && args[2] && args[3]) { + const [npub, repo, path] = args.slice(1); + const branch = args[4] || 'main'; + const data = await apiRequest(server, `/repos/${npub}/${repo}/file?path=${encodeURIComponent(path)}&branch=${branch}`, 'GET'); + if (json) { + console.log(JSON.stringify(data, null, 2)); + } else { + console.log(data.content || data); + } + } else if (subcommand === 'put' && args[1] && args[2] && args[3]) { + const [npub, repo, path] = args.slice(1); + let content; + if (args[4]) { + // Read from file + try { + content = readFileSync(args[4], 'utf-8'); + } catch (error) { + throw new Error(`Failed to read file ${args[4]}: ${error.message}`); + } + } else { + // Read from stdin + const chunks = []; + process.stdin.setEncoding('utf8'); + return new Promise((resolve, reject) => { + process.stdin.on('readable', () => { + let chunk; + while ((chunk = process.stdin.read()) !== null) { + chunks.push(chunk); + } + }); + process.stdin.on('end', async () => { + content = chunks.join(''); + const commitMessage = args[5] || 'Update file'; + const branch = args[6] || 'main'; + + try { + const data = await apiRequest(server, `/repos/${npub}/${repo}/file`, 'POST', { + path, + content, + commitMessage, + branch, + action: 'write' + }); + console.log(json ? JSON.stringify(data, null, 2) : 'File updated successfully'); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + } + const commitMessage = args[5] || 'Update file'; + const branch = args[6] || 'main'; + + const data = await apiRequest(server, `/repos/${npub}/${repo}/file`, 'POST', { + path, + content, + commitMessage, + branch, + action: 'write' + }); + console.log(json ? JSON.stringify(data, null, 2) : 'File updated successfully'); + } else if (subcommand === 'delete' && args[1] && args[2] && args[3]) { + const [npub, repo, path] = args.slice(1); + const commitMessage = args[4] || `Delete ${path}`; + const branch = args[5] || 'main'; + + const data = await apiRequest(server, `/repos/${npub}/${repo}/file`, 'POST', { + path, + commitMessage, + branch, + action: 'delete' + }); + console.log(json ? JSON.stringify(data, null, 2) : 'File deleted successfully'); + } else { + console.error('Invalid file command. Use: get [branch], put [file] [message] [branch], delete [message] [branch]'); + process.exit(1); + } + }, + + async search(args, server, json) { + const query = args.join(' '); + if (!query) { + console.error('Search query required'); + process.exit(1); + } + const data = await apiRequest(server, `/search?q=${encodeURIComponent(query)}`, 'GET'); + if (json) { + console.log(JSON.stringify(data, null, 2)); + } else { + console.log(`Search results for "${query}":`); + if (Array.isArray(data)) { + data.forEach(repo => { + console.log(` ${repo.npub}/${repo.name} - ${repo.description || 'No description'}`); + }); + } + } + }, + + async publish(args, server, json) { + const subcommand = args[0]; + + if (!subcommand || subcommand === '--help' || subcommand === '-h') { + console.log(` +Publish Nostr Git Events + +Usage: gitrep-api publish [options] + +Subcommands: + repo-announcement [options] + Publish a repository announcement (kind 30617) + Options: + --description Repository description + --clone-url Clone URL (can be specified multiple times) + --web-url Web URL (can be specified multiple times) + --maintainer Maintainer pubkey (can be specified multiple times) + --relay Custom relay URL (can be specified multiple times) + + Example: + gitrep-api publish repo-announcement myrepo \\ + --description "My awesome repo" \\ + --clone-url "https://gitrepublic.com/api/git/npub1.../myrepo.git" \\ + --maintainer "npub1..." + + ownership-transfer [--self-transfer] + Transfer repository ownership (kind 1641) + Note: You must be the current owner (signing with NOSTRGIT_SECRET_KEY) + + Example: + gitrep-api publish ownership-transfer myrepo npub1... --self-transfer + + pr [options] + Create a pull request (kind 1618) + Options: + --content <text> PR description/content + --base <branch> Base branch (default: main) + --head <branch> Head branch (default: main) + + Example: + gitrep-api 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-api 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-api 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-api 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-api 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-api publish pr-update npub1... myrepo pr-event-id new-commit-id \\ + --pr-author npub1... \\ + --clone-url "https://gitrepublic.com/api/git/npub1.../myrepo.git" \\ + --merge-base base-commit-id + +Event Structure: + All events are automatically signed with NOSTRGIT_SECRET_KEY and published to relays. + Events are stored locally in nostr/ directory (JSONL format) for reference. + + For detailed event structure documentation, see: + - https://github.com/silberengel/gitrepublic-web/tree/main/docs + - docs/NIP_COMPLIANCE.md - NIP compliance and event kinds + - docs/CustomKinds.md - Custom event kinds (1640, 1641, 30620) + +Environment Variables: + NOSTRGIT_SECRET_KEY Required: Nostr private key (nsec or hex) + NOSTR_RELAYS Optional: Comma-separated relay URLs (default: wss://theforest.nostr1.com,wss://relay.damus.io,wss://nostr.land) + +For more information, see: https://github.com/silberengel/gitrepublic-cli +`); + process.exit(0); + } + + // Get private key + const secretKey = process.env.NOSTRGIT_SECRET_KEY || process.env.NOSTR_PRIVATE_KEY || process.env.NSEC; + if (!secretKey) { + throw new Error('NOSTRGIT_SECRET_KEY environment variable is not set'); + } + + const privateKeyBytes = getPrivateKeyBytes(secretKey); + const pubkey = getPublicKey(privateKeyBytes); + + // Get relays from environment or use defaults + const relaysEnv = process.env.NOSTR_RELAYS; + const relays = relaysEnv ? relaysEnv.split(',').map(r => r.trim()).filter(r => r.length > 0) : [ + 'wss://theforest.nostr1.com', + 'wss://relay.damus.io', + 'wss://nostr.land' + ]; + + if (subcommand === 'repo-announcement') { + // publish repo-announcement <repo-name> --description <text> --clone-url <url> [--clone-url <url>...] [--web-url <url>...] [--maintainer <npub>...] [--relay <url>...] + const repoName = args[1]; + if (!repoName) { + console.error('Error: Repository name required'); + console.error('Use: publish repo-announcement <repo-name> [options]'); + process.exit(1); + } + + const tags = [['d', repoName]]; + let description = ''; + const cloneUrls = []; + const webUrls = []; + const maintainers = []; + + // Parse options + for (let i = 2; i < args.length; i++) { + if (args[i] === '--description' && args[i + 1]) { + description = args[++i]; + } else if (args[i] === '--clone-url' && args[i + 1]) { + cloneUrls.push(args[++i]); + } else if (args[i] === '--web-url' && args[i + 1]) { + webUrls.push(args[++i]); + } else if (args[i] === '--maintainer' && args[i + 1]) { + maintainers.push(args[++i]); + } else if (args[i] === '--relay' && args[i + 1]) { + relays.push(args[++i]); + } + } + + // Add clone URLs + for (const url of cloneUrls) { + tags.push(['r', url]); + } + + // Add web URLs + for (const url of webUrls) { + tags.push(['web', url]); + } + + // Add maintainers + for (const maintainer of maintainers) { + tags.push(['p', maintainer]); + } + + const event = finalizeEvent({ + kind: 30617, // REPO_ANNOUNCEMENT + created_at: Math.floor(Date.now() / 1000), + tags, + content: description + }, privateKeyBytes); + + // Store event in JSONL file + storeEventInJsonl(event); + + const result = await publishToRelays(event, relays); + + if (json) { + console.log(JSON.stringify({ event, published: result }, null, 2)); + } else { + console.log('Repository announcement published!'); + console.log(`Event ID: ${event.id}`); + console.log(`Repository: ${repoName}`); + console.log(`Event stored in nostr/repo-announcements.jsonl`); + console.log(`Published to ${result.success.length} relay(s): ${result.success.join(', ')}`); + if (result.failed.length > 0) { + console.log(`Failed on ${result.failed.length} relay(s):`); + result.failed.forEach(f => console.log(` ${f.relay}: ${f.error}`)); + } + } + } else if (subcommand === 'ownership-transfer') { + // publish ownership-transfer <repo> <new-owner-npub> [--self-transfer] + // Note: The current owner is determined by the signing key (NOSTRGIT_SECRET_KEY) + const [repoName, newOwnerNpub] = args.slice(1); + if (!repoName || !newOwnerNpub) { + console.error('Error: repo name and new owner npub required'); + console.error('Use: publish ownership-transfer <repo> <new-owner-npub> [--self-transfer]'); + console.error('Note: You must be the current owner (signing with NOSTRGIT_SECRET_KEY)'); + process.exit(1); + } + + const selfTransfer = args.includes('--self-transfer'); + + // Decode new owner npub to hex + let newOwnerPubkey; + try { + newOwnerPubkey = newOwnerNpub.startsWith('npub') ? decode(newOwnerNpub).data : newOwnerNpub; + // Convert to hex string if it's a Uint8Array + if (newOwnerPubkey instanceof Uint8Array) { + newOwnerPubkey = Array.from(newOwnerPubkey).map(b => b.toString(16).padStart(2, '0')).join(''); + } + } catch (err) { + throw new Error(`Invalid npub format: ${err.message}`); + } + + // Current owner is the pubkey from the signing key + const currentOwnerPubkey = pubkey; + const repoAddress = `30617:${currentOwnerPubkey}:${repoName}`; + const tags = [ + ['a', repoAddress], + ['p', newOwnerPubkey], + ['d', repoName] + ]; + + if (selfTransfer) { + tags.push(['t', 'self-transfer']); + } + + const event = finalizeEvent({ + kind: 1641, // OWNERSHIP_TRANSFER + created_at: Math.floor(Date.now() / 1000), + tags, + content: '' + }, privateKeyBytes); + + // Store event in JSONL file + storeEventInJsonl(event); + + const result = await publishToRelays(event, relays); + + if (json) { + console.log(JSON.stringify({ event, published: result }, null, 2)); + } else { + const currentOwnerNpub = nip19.npubEncode(currentOwnerPubkey); + console.log('Ownership transfer published!'); + console.log(`Event ID: ${event.id}`); + console.log(`Repository: ${currentOwnerNpub}/${repoName}`); + console.log(`Current owner: ${currentOwnerNpub}`); + console.log(`New owner: ${newOwnerNpub}`); + console.log(`Event stored in nostr/ownership-transfers.jsonl`); + console.log(`Published to ${result.success.length} relay(s): ${result.success.join(', ')}`); + if (result.failed.length > 0) { + console.log(`Failed on ${result.failed.length} relay(s):`); + result.failed.forEach(f => console.log(` ${f.relay}: ${f.error}`)); + } + } + } else if (subcommand === 'pr' || subcommand === 'pull-request') { + // publish pr <npub> <repo> <title> [--content <text>] [--base <branch>] [--head <branch>] + const [ownerNpub, repoName, title] = args.slice(1); + if (!ownerNpub || !repoName || !title) { + console.error('Error: owner npub, repo name, and title required'); + console.error('Use: publish pr <owner-npub> <repo> <title> [options]'); + process.exit(1); + } + + let ownerPubkey; + try { + ownerPubkey = ownerNpub.startsWith('npub') ? decode(ownerNpub).data : ownerNpub; + } catch (err) { + throw new Error(`Invalid npub format: ${err.message}`); + } + + const repoAddress = `30617:${ownerPubkey}:${repoName}`; + const tags = [ + ['a', repoAddress], + ['p', ownerPubkey], + ['subject', title] + ]; + + let content = ''; + let baseBranch = 'main'; + let headBranch = 'main'; + + for (let i = 4; i < args.length; i++) { + if (args[i] === '--content' && args[i + 1]) { + content = args[++i]; + } else if (args[i] === '--base' && args[i + 1]) { + baseBranch = args[++i]; + } else if (args[i] === '--head' && args[i + 1]) { + headBranch = args[++i]; + } + } + + if (baseBranch !== headBranch) { + tags.push(['base', baseBranch]); + tags.push(['head', headBranch]); + } + + const event = finalizeEvent({ + kind: 1618, // PULL_REQUEST + created_at: Math.floor(Date.now() / 1000), + tags, + content + }, privateKeyBytes); + + // Store event in JSONL file + storeEventInJsonl(event); + + const result = await publishToRelays(event, relays); + + if (json) { + console.log(JSON.stringify({ event, published: result }, null, 2)); + } else { + console.log('Pull request published!'); + console.log(`Event ID: ${event.id}`); + console.log(`Repository: ${ownerNpub}/${repoName}`); + console.log(`Title: ${title}`); + console.log(`Event stored in nostr/pull-requests.jsonl`); + console.log(`Published to ${result.success.length} relay(s): ${result.success.join(', ')}`); + if (result.failed.length > 0) { + console.log(`Failed on ${result.failed.length} relay(s):`); + result.failed.forEach(f => console.log(` ${f.relay}: ${f.error}`)); + } + } + } else if (subcommand === 'issue') { + // publish issue <npub> <repo> <title> [--content <text>] [--label <label>...] + const [ownerNpub, repoName, title] = args.slice(1); + if (!ownerNpub || !repoName || !title) { + console.error('Error: owner npub, repo name, and title required'); + console.error('Use: publish issue <owner-npub> <repo> <title> [options]'); + process.exit(1); + } + + let ownerPubkey; + try { + ownerPubkey = ownerNpub.startsWith('npub') ? decode(ownerNpub).data : ownerNpub; + } catch (err) { + throw new Error(`Invalid npub format: ${err.message}`); + } + + const repoAddress = `30617:${ownerPubkey}:${repoName}`; + const tags = [ + ['a', repoAddress], + ['p', ownerPubkey], + ['subject', title] + ]; + + let content = ''; + const labels = []; + + for (let i = 4; i < args.length; i++) { + if (args[i] === '--content' && args[i + 1]) { + content = args[++i]; + } else if (args[i] === '--label' && args[i + 1]) { + labels.push(args[++i]); + } + } + + for (const label of labels) { + tags.push(['t', label]); + } + + const event = finalizeEvent({ + kind: 1621, // ISSUE + created_at: Math.floor(Date.now() / 1000), + tags, + content + }, privateKeyBytes); + + // Store event in JSONL file + storeEventInJsonl(event); + + const result = await publishToRelays(event, relays); + + if (json) { + console.log(JSON.stringify({ event, published: result }, null, 2)); + } else { + console.log('Issue published!'); + console.log(`Event ID: ${event.id}`); + console.log(`Repository: ${ownerNpub}/${repoName}`); + console.log(`Title: ${title}`); + console.log(`Event stored in nostr/issues.jsonl`); + console.log(`Published to ${result.success.length} relay(s): ${result.success.join(', ')}`); + if (result.failed.length > 0) { + console.log(`Failed on ${result.failed.length} relay(s):`); + result.failed.forEach(f => console.log(` ${f.relay}: ${f.error}`)); + } + } + } else if (subcommand === 'status') { + // publish status <event-id> <status> [--content <text>] + // status: open|applied|closed|draft + const [eventId, status] = args.slice(1); + if (!eventId || !status) { + console.error('Error: event ID and status required'); + console.error('Use: publish status <event-id> <open|applied|closed|draft> [--content <text>]'); + process.exit(1); + } + + const statusKinds = { + 'open': 1630, + 'applied': 1631, + 'closed': 1632, + 'draft': 1633 + }; + + const kind = statusKinds[status.toLowerCase()]; + if (!kind) { + console.error(`Error: Invalid status. Use: open, applied, closed, or draft`); + process.exit(1); + } + + const tags = [['e', eventId]]; + let content = ''; + + for (let i = 3; i < args.length; i++) { + if (args[i] === '--content' && args[i + 1]) { + content = args[++i]; + } + } + + const event = finalizeEvent({ + kind, + created_at: Math.floor(Date.now() / 1000), + tags, + content + }, privateKeyBytes); + + // Store event in JSONL file + storeEventInJsonl(event); + + const result = await publishToRelays(event, relays); + + if (json) { + console.log(JSON.stringify({ event, published: result }, null, 2)); + } else { + console.log(`Status event published!`); + console.log(`Event ID: ${event.id}`); + console.log(`Status: ${status}`); + console.log(`Target event: ${eventId}`); + console.log(`Event stored in nostr/status-events.jsonl`); + console.log(`Published to ${result.success.length} relay(s): ${result.success.join(', ')}`); + if (result.failed.length > 0) { + console.log(`Failed on ${result.failed.length} relay(s):`); + result.failed.forEach(f => console.log(` ${f.relay}: ${f.error}`)); + } + } + } else if (subcommand === 'patch') { + // publish patch <owner-npub> <repo> <patch-file> [options] + // Patch content should be from git format-patch + const [ownerNpub, repoName, patchFile] = args.slice(1); + if (!ownerNpub || !repoName || !patchFile) { + console.error('Error: owner npub, repo name, and patch file required'); + console.error('Use: publish patch <owner-npub> <repo> <patch-file> [options]'); + console.error('Note: Patch file should be generated with: git format-patch'); + process.exit(1); + } + + let ownerPubkey; + try { + ownerPubkey = ownerNpub.startsWith('npub') ? decode(ownerNpub).data : ownerNpub; + if (ownerPubkey instanceof Uint8Array) { + ownerPubkey = Array.from(ownerPubkey).map(b => b.toString(16).padStart(2, '0')).join(''); + } + } catch (err) { + throw new Error(`Invalid npub format: ${err.message}`); + } + + // Read patch file + let patchContent; + try { + patchContent = readFileSync(patchFile, 'utf-8'); + } catch (err) { + throw new Error(`Failed to read patch file: ${err.message}`); + } + + const repoAddress = `30617:${ownerPubkey}:${repoName}`; + const tags = [ + ['a', repoAddress], + ['p', ownerPubkey] + ]; + + // Parse options + let earliestCommit = null; + let commitId = null; + let parentCommit = null; + let isRoot = false; + let isRootRevision = false; + const mentions = []; + + for (let i = 4; i < args.length; i++) { + if (args[i] === '--earliest-commit' && args[i + 1]) { + earliestCommit = args[++i]; + tags.push(['r', earliestCommit]); + } else if (args[i] === '--commit' && args[i + 1]) { + commitId = args[++i]; + tags.push(['commit', commitId]); + tags.push(['r', commitId]); + } else if (args[i] === '--parent-commit' && args[i + 1]) { + parentCommit = args[++i]; + tags.push(['parent-commit', parentCommit]); + } else if (args[i] === '--root') { + isRoot = true; + tags.push(['t', 'root']); + } else if (args[i] === '--root-revision') { + isRootRevision = true; + tags.push(['t', 'root-revision']); + } else if (args[i] === '--mention' && args[i + 1]) { + mentions.push(args[++i]); + } else if (args[i] === '--reply-to' && args[i + 1]) { + // NIP-10 reply tag + tags.push(['e', args[++i], '', 'reply']); + } + } + + // Add earliest commit if provided + if (earliestCommit) { + tags.push(['r', earliestCommit, 'euc']); + } + + // Add mentions + for (const mention of mentions) { + let mentionPubkey = mention; + try { + if (mention.startsWith('npub')) { + mentionPubkey = decode(mention).data; + if (mentionPubkey instanceof Uint8Array) { + mentionPubkey = Array.from(mentionPubkey).map(b => b.toString(16).padStart(2, '0')).join(''); + } + } + } catch { + // Keep original if decode fails + } + tags.push(['p', mentionPubkey]); + } + + const event = finalizeEvent({ + kind: 1617, // PATCH + created_at: Math.floor(Date.now() / 1000), + tags, + content: patchContent + }, privateKeyBytes); + + // Store event in JSONL file + storeEventInJsonl(event); + + const result = await publishToRelays(event, relays); + + if (json) { + console.log(JSON.stringify({ event, published: result }, null, 2)); + } else { + console.log('Patch published!'); + console.log(`Event ID: ${event.id}`); + console.log(`Repository: ${ownerNpub}/${repoName}`); + console.log(`Patch file: ${patchFile}`); + console.log(`Event stored in nostr/patches.jsonl`); + console.log(`Published to ${result.success.length} relay(s): ${result.success.join(', ')}`); + if (result.failed.length > 0) { + console.log(`Failed on ${result.failed.length} relay(s):`); + result.failed.forEach(f => console.log(` ${f.relay}: ${f.error}`)); + } + } + } else if (subcommand === 'repo-state') { + // publish repo-state <repo> [options] + // Options: --ref <ref-path> <commit-id> [--parent <commit-id>...] --head <branch> + const repoName = args[1]; + if (!repoName) { + console.error('Error: Repository name required'); + console.error('Use: publish repo-state <repo> [options]'); + process.exit(1); + } + + // Current owner is the pubkey from the signing key + const currentOwnerPubkey = pubkey; + const tags = [['d', repoName]]; + let headBranch = null; + + // Parse options + for (let i = 2; i < args.length; i++) { + if (args[i] === '--ref' && args[i + 2]) { + const refPath = args[++i]; + const commitId = args[++i]; + const refTag = [refPath, commitId]; + + // Check for parent commits + while (i + 1 < args.length && args[i + 1] !== '--ref' && args[i + 1] !== '--head') { + refTag.push(args[++i]); + } + + tags.push(refTag); + } else if (args[i] === '--head' && args[i + 1]) { + headBranch = args[++i]; + tags.push(['HEAD', `ref: refs/heads/${headBranch}`]); + } + } + + const event = finalizeEvent({ + kind: 30618, // REPO_STATE + created_at: Math.floor(Date.now() / 1000), + tags, + content: '' + }, privateKeyBytes); + + // Store event in JSONL file + storeEventInJsonl(event); + + const result = await publishToRelays(event, relays); + + if (json) { + console.log(JSON.stringify({ event, published: result }, null, 2)); + } else { + const currentOwnerNpub = nip19.npubEncode(currentOwnerPubkey); + console.log('Repository state published!'); + console.log(`Event ID: ${event.id}`); + console.log(`Repository: ${currentOwnerNpub}/${repoName}`); + if (headBranch) { + console.log(`HEAD: ${headBranch}`); + } + console.log(`Refs: ${tags.filter(t => t[0].startsWith('refs/')).length}`); + console.log(`Event stored in nostr/repo-states.jsonl`); + console.log(`Published to ${result.success.length} relay(s): ${result.success.join(', ')}`); + if (result.failed.length > 0) { + console.log(`Failed on ${result.failed.length} relay(s):`); + result.failed.forEach(f => console.log(` ${f.relay}: ${f.error}`)); + } + } + } else if (subcommand === 'pr-update' || subcommand === 'pull-request-update') { + // publish pr-update <owner-npub> <repo> <pr-event-id> <commit-id> [options] + const [ownerNpub, repoName, prEventId, commitId] = args.slice(1); + if (!ownerNpub || !repoName || !prEventId || !commitId) { + console.error('Error: owner npub, repo name, PR event ID, and commit ID required'); + console.error('Use: publish pr-update <owner-npub> <repo> <pr-event-id> <commit-id> [options]'); + process.exit(1); + } + + let ownerPubkey; + try { + ownerPubkey = ownerNpub.startsWith('npub') ? decode(ownerNpub).data : ownerNpub; + if (ownerPubkey instanceof Uint8Array) { + ownerPubkey = Array.from(ownerPubkey).map(b => b.toString(16).padStart(2, '0')).join(''); + } + } catch (err) { + throw new Error(`Invalid npub format: ${err.message}`); + } + + // Get PR author pubkey (needed for NIP-22 tags) + // For now, we'll require it as an option or try to get it from the PR event + let prAuthorPubkey = null; + const cloneUrls = []; + let mergeBase = null; + let earliestCommit = null; + const mentions = []; + + for (let i = 5; i < args.length; i++) { + if (args[i] === '--pr-author' && args[i + 1]) { + let authorNpub = args[++i]; + try { + prAuthorPubkey = authorNpub.startsWith('npub') ? decode(authorNpub).data : authorNpub; + if (prAuthorPubkey instanceof Uint8Array) { + prAuthorPubkey = Array.from(prAuthorPubkey).map(b => b.toString(16).padStart(2, '0')).join(''); + } + } catch (err) { + throw new Error(`Invalid pr-author npub format: ${err.message}`); + } + } else if (args[i] === '--clone-url' && args[i + 1]) { + cloneUrls.push(args[++i]); + } else if (args[i] === '--merge-base' && args[i + 1]) { + mergeBase = args[++i]; + } else if (args[i] === '--earliest-commit' && args[i + 1]) { + earliestCommit = args[++i]; + } else if (args[i] === '--mention' && args[i + 1]) { + mentions.push(args[++i]); + } + } + + const repoAddress = `30617:${ownerPubkey}:${repoName}`; + const tags = [ + ['a', repoAddress], + ['p', ownerPubkey], + ['E', prEventId], // NIP-22 root event reference + ['c', commitId] + ]; + + // Add earliest commit if provided + if (earliestCommit) { + tags.push(['r', earliestCommit, 'euc']); + } + + // Add mentions + for (const mention of mentions) { + let mentionPubkey = mention; + try { + if (mention.startsWith('npub')) { + mentionPubkey = decode(mention).data; + if (mentionPubkey instanceof Uint8Array) { + mentionPubkey = Array.from(mentionPubkey).map(b => b.toString(16).padStart(2, '0')).join(''); + } + } + } catch { + // Keep original if decode fails + } + tags.push(['p', mentionPubkey]); + } + + // Add PR author if provided (NIP-22 root pubkey reference) + if (prAuthorPubkey) { + tags.push(['P', prAuthorPubkey]); + } + + // Add clone URLs (required) + if (cloneUrls.length === 0) { + console.error('Error: At least one --clone-url is required'); + process.exit(1); + } + for (const url of cloneUrls) { + tags.push(['clone', url]); + } + + // Add merge base if provided + if (mergeBase) { + tags.push(['merge-base', mergeBase]); + } + + const event = finalizeEvent({ + kind: 1619, // PULL_REQUEST_UPDATE + created_at: Math.floor(Date.now() / 1000), + tags, + content: '' + }, privateKeyBytes); + + // Store event in JSONL file + storeEventInJsonl(event); + + const result = await publishToRelays(event, relays); + + if (json) { + console.log(JSON.stringify({ event, published: result }, null, 2)); + } else { + console.log('Pull request update published!'); + console.log(`Event ID: ${event.id}`); + console.log(`Repository: ${ownerNpub}/${repoName}`); + console.log(`PR Event ID: ${prEventId}`); + console.log(`New commit: ${commitId}`); + console.log(`Event stored in nostr/pull-request-updates.jsonl`); + console.log(`Published to ${result.success.length} relay(s): ${result.success.join(', ')}`); + if (result.failed.length > 0) { + console.log(`Failed on ${result.failed.length} relay(s):`); + result.failed.forEach(f => console.log(` ${f.relay}: ${f.error}`)); + } + } + } else { + console.error(`Error: Unknown publish subcommand: ${subcommand}`); + console.error('Use: publish repo-announcement|ownership-transfer|pr|pr-update|issue|status|patch|repo-state'); + console.error('Run: publish --help for detailed usage'); + process.exit(1); + } + }, + + async verify(args, server, json) { + // verify <event-file> or verify <event-json> + const input = args[0]; + if (!input) { + console.error('Error: Event file path or JSON required'); + console.error('Use: verify <event-file.jsonl> or verify <event-json>'); + process.exit(1); + } + + let event; + try { + // Try to read as file first + if (existsSync(input)) { + const content = readFileSync(input, 'utf-8').trim(); + // If it's JSONL, get the last line (most recent event) + const lines = content.split('\n').filter(l => l.trim()); + const lastLine = lines[lines.length - 1]; + event = JSON.parse(lastLine); + } else { + // Try to parse as JSON directly + event = JSON.parse(input); + } + } catch (err) { + console.error(`Error: Failed to parse event: ${err instanceof Error ? err.message : 'Unknown error'}`); + process.exit(1); + } + + // Verify event + const signatureValid = verifyEvent(event); + const computedId = getEventHash(event); + const idMatches = event.id === computedId; + + if (json) { + console.log(JSON.stringify({ + valid: signatureValid && idMatches, + signatureValid, + idMatches, + computedId, + eventId: event.id, + kind: event.kind, + pubkey: event.pubkey, + created_at: event.created_at, + timestamp: new Date(event.created_at * 1000).toLocaleString(), + timestamp_utc: new Date(event.created_at * 1000).toISOString() + }, null, 2)); + } else { + console.log('Event Verification:'); + console.log(` Kind: ${event.kind}`); + console.log(` Pubkey: ${event.pubkey.substring(0, 16)}...`); + console.log(` Created: ${new Date(event.created_at * 1000).toLocaleString()}`); + console.log(` Event ID: ${event.id.substring(0, 16)}...`); + console.log(''); + console.log('Verification Results:'); + console.log(` Signature valid: ${signatureValid ? '✅ Yes' : '❌ No'}`); + console.log(` Event ID matches: ${idMatches ? '✅ Yes' : '❌ No'}`); + if (!idMatches) { + console.log(` Computed ID: ${computedId}`); + console.log(` Expected ID: ${event.id}`); + } + console.log(''); + + if (signatureValid && idMatches) { + console.log('✅ Event is VALID'); + } else { + console.log('❌ Event is INVALID'); + if (!signatureValid) { + console.log(' - Signature verification failed'); + } + if (!idMatches) { + console.log(' - Event ID does not match computed hash'); + } + process.exit(1); + } + } + }, + + async pushAll(args, server, json) { + // push-all [branch] [--force] [--tags] [--dry-run] - Push to all remotes + + // Check for help flag + if (args.includes('--help') || args.includes('-h')) { + console.log(`Push to All Remotes + +Usage: gitrep-api 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-api push-all Push all branches to all remotes + gitrep-api push-all main Push main branch to all remotes + gitrep-api push-all main --force Force push main branch to all remotes + gitrep-api push-all --tags Push all branches and tags to all remotes + gitrep-api 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 +`); + process.exit(0); + } + + // Parse arguments + const branch = args.find(arg => !arg.startsWith('--')); + const force = args.includes('--force') || args.includes('-f'); + const tags = args.includes('--tags'); + const dryRun = args.includes('--dry-run') || args.includes('-n'); + + // Get all remotes + let remotes = []; + try { + const remoteOutput = execSync('git remote', { encoding: 'utf-8' }).trim(); + remotes = remoteOutput.split('\n').filter(r => r.trim()); + } catch (err) { + console.error('Error: Not in a git repository or unable to read remotes'); + console.error(err instanceof Error ? err.message : 'Unknown error'); + process.exit(1); + } + + if (remotes.length === 0) { + console.error('Error: No remotes configured'); + process.exit(1); + } + + // Build push command + const pushArgs = []; + if (force) pushArgs.push('--force'); + if (tags) pushArgs.push('--tags'); + if (dryRun) pushArgs.push('--dry-run'); + if (branch) { + // If branch is specified, push to each remote with that branch + pushArgs.push(branch); + } else { + // Push all branches + pushArgs.push('--all'); + } + + const results = []; + let successCount = 0; + let failCount = 0; + + for (const remote of remotes) { + try { + if (!json && !dryRun) { + console.log(`\nPushing to ${remote}...`); + } + + const command = ['push', remote, ...pushArgs]; + + execSync(`git ${command.join(' ')}`, { + stdio: json ? 'pipe' : 'inherit', + encoding: 'utf-8' + }); + + results.push({ remote, status: 'success' }); + successCount++; + + if (!json && !dryRun) { + console.log(`✅ Successfully pushed to ${remote}`); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + results.push({ remote, status: 'failed', error: errorMessage }); + failCount++; + + if (!json && !dryRun) { + console.error(`❌ Failed to push to ${remote}: ${errorMessage}`); + } + } + } + + if (json) { + console.log(JSON.stringify({ + total: remotes.length, + success: successCount, + failed: failCount, + results + }, null, 2)); + } else { + console.log('\n' + '='.repeat(70)); + console.log(`Push Summary: ${successCount} succeeded, ${failCount} failed out of ${remotes.length} remotes`); + console.log('='.repeat(70)); + + if (failCount > 0) { + console.log('\nFailed remotes:'); + results.filter(r => r.status === 'failed').forEach(r => { + console.log(` ${r.remote}: ${r.error}`); + }); + process.exit(1); + } + } + } +}; + +// Main execution +const args = process.argv.slice(2); +const commandIndex = args.findIndex(arg => !arg.startsWith('--')); +const command = commandIndex >= 0 ? args[commandIndex] : null; +const commandArgs = commandIndex >= 0 ? args.slice(commandIndex + 1) : []; + +// Parse options +const serverIndex = args.indexOf('--server'); +const server = serverIndex >= 0 && args[serverIndex + 1] ? args[serverIndex + 1] : DEFAULT_SERVER; +const json = args.includes('--json'); +// Check if --help is in command args (after command) - if so, it's command-specific help +const commandHelpRequested = command && (commandArgs.includes('--help') || commandArgs.includes('-h')); +// Only treat as general help if --help is before the command or there's no command +const help = !commandHelpRequested && args.includes('--help'); + +// Add config command +if (command === 'config') { + const subcommand = commandArgs[0]; + if (subcommand === 'server' || !subcommand) { + if (json) { + console.log(JSON.stringify({ server, default: DEFAULT_SERVER, fromEnv: !!process.env.GITREPUBLIC_SERVER }, null, 2)); + } else { + console.log('GitRepublic Server Configuration:'); + console.log(` Current: ${server}`); + console.log(` Default: ${DEFAULT_SERVER}`); + if (process.env.GITREPUBLIC_SERVER) { + console.log(` From environment: ${process.env.GITREPUBLIC_SERVER}`); + } else { + console.log(' From environment: (not set)'); + console.log(' ⚠️ Note: Default is for development only (localhost:5173)'); + console.log(' ⚠️ Set GITREPUBLIC_SERVER for production use'); + } + console.log(''); + console.log('To change the server:'); + console.log(' gitrep-api --server <url> <command> (or gitrepublic-api)'); + console.log(' export GITREPUBLIC_SERVER=<url>'); + } + process.exit(0); + } else { + console.error('Invalid config command. Use: config [server]'); + process.exit(1); + } +} + +// Convert kebab-case to camelCase for command lookup (do this before help check) +const commandKey = command ? command.replace(/-([a-z])/g, (g) => g[1].toUpperCase()) : null; +const commandHandler = commandKey ? (commands[commandKey] || commands[command]) : null; + +// If help is requested for a specific command, let the handler deal with it +if (commandHelpRequested && commandHandler) { + // The handler will check for --help and show command-specific help + commandHandler(commandArgs, server, json).catch(error => { + console.error('Error:', error.message); + process.exit(1); + }); + // Exit after handler processes help (handler should exit, but just in case) + process.exit(0); +} + +if (help || !command || !commandHandler) { + console.log(`GitRepublic CLI + +Usage: gitrep-api <command> [options] (or gitrepublic-api) + +Commands: + config [server] Show configuration (server URL) + repos list List repositories + repos get <npub> <repo> Get repository info (or use naddr: repos get <naddr>) + repos settings <npub> <repo> [--description <text>] [--private <true|false>] Get/update settings + repos maintainers <npub> <repo> [add|remove <npub>] Manage maintainers + repos branches <npub> <repo> List branches + repos tags <npub> <repo> List tags + repos fork <npub> <repo> Fork a repository + repos delete <npub> <repo> Delete a repository + file get <npub> <repo> <path> [branch] Get file content + file put <npub> <repo> <path> [file] [message] [branch] Create/update file + file delete <npub> <repo> <path> [message] [branch] Delete file + search <query> Search repositories + publish <subcommand> [options] Publish Nostr Git events (use: publish --help for details) + verify <event-file>|<event-json> Verify a Nostr event signature and ID + push-all [branch] [--force] [--tags] [--dry-run] Push to all configured remotes + +Options: + --server <url> GitRepublic server URL (default: ${DEFAULT_SERVER}) + --json Output JSON format + --help Show this help + +Environment variables: + NOSTRGIT_SECRET_KEY Nostr private key (nsec or hex) + GITREPUBLIC_SERVER Default server URL + NOSTR_RELAYS Comma-separated list of Nostr relays (default: wss://theforest.nostr1.com,wss://relay.damus.io,wss://nostr.land) + +Documentation: https://github.com/silberengel/gitrepublic-cli +GitCitadel: Visit us on GitHub: https://github.com/ShadowySupercode or on our homepage: https://gitcitadel.com + +GitRepublic CLI - Copyright (c) 2026 GitCitadel LLC +Licensed under MIT License +`); + process.exit(help ? 0 : 1); +} + +// Execute command + +if (!commandHandler) { + console.error(`Error: Unknown command: ${command}`); + console.error('Use --help to see available commands'); + process.exit(1); +} + +commandHandler(commandArgs, server, json).catch(error => { + console.error('Error:', error.message); + process.exit(1); +}); diff --git a/scripts/postinstall.js b/scripts/postinstall.js new file mode 100755 index 0000000..a94d5f1 --- /dev/null +++ b/scripts/postinstall.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +/** + * Post-install script - Shows welcome message and next steps + */ + +console.log(` +╔══════════════════════════════════════════════════════════════╗ +║ GitRepublic CLI - Installation Complete ║ +╚══════════════════════════════════════════════════════════════╝ + +Quick Start: + 1. Set your Nostr private key: + export NOSTRGIT_SECRET_KEY="nsec1..." + + 2. Run setup to configure git: + gitrep-setup + + 3. Use gitrep (or gitrepublic) as a drop-in replacement for git: + gitrep clone https://your-domain.com/api/git/npub1.../repo.git gitrepublic-web + gitrep push gitrepublic-web main + + Note: "gitrep" is a shorter alias for "gitrepublic" - both work the same way. + Use "gitrepublic-web" as the remote name (not "origin") since + "origin" is often already set to GitHub, GitLab, or other services. + +Commands: + gitrepublic / gitrep Git wrapper with enhanced error messages + gitrepublic-api / gitrep-api Access GitRepublic APIs + gitrepublic-setup / gitrep-setup Configure git credential helper and commit hook + gitrepublic-uninstall / gitrep-uninstall Remove all configuration + +Get Help: + gitrep --help (or gitrepublic --help) + gitrep-api --help (or gitrepublic-api --help) + gitrep-setup --help (or gitrepublic-setup --help) + +Documentation: https://github.com/silberengel/gitrepublic-cli +GitCitadel: Visit us on GitHub: https://github.com/ShadowySupercode or on our homepage: https://gitcitadel.com + +GitRepublic CLI - Copyright (c) 2026 GitCitadel LLC +Licensed under MIT License +`); diff --git a/scripts/setup.js b/scripts/setup.js new file mode 100755 index 0000000..19ba70e --- /dev/null +++ b/scripts/setup.js @@ -0,0 +1,237 @@ +#!/usr/bin/env node +/** + * GitRepublic CLI Setup Script + * + * Automatically configures git credential helper and commit signing hook + * + * Usage: + * node scripts/setup.js [options] + * + * Options: + * --credential-only Only set up credential helper + * --hook-only Only set up commit hook + * --domain <domain> Configure credential helper for specific domain (default: all) + * --global-hook Install hook globally for all repositories (default: current repo) + */ + +import { fileURLToPath } from 'url'; +import { dirname, join, resolve } from 'path'; +import { existsSync } from 'fs'; +import { execSync } from 'child_process'; + +// Get the directory where this script is located +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const scriptsDir = __dirname; + +const credentialScript = join(scriptsDir, 'git-credential-nostr.js'); +const commitHookScript = join(scriptsDir, 'git-commit-msg-hook.js'); + +// Show help +function showHelp() { + console.log(` +GitRepublic CLI Setup + +Automatically configures git credential helper and commit signing hook. + +Usage: + gitrep-setup [options] (or gitrepublic-setup) + +Options: + --credential-only Only set up credential helper + --hook-only Only set up commit hook + --domain <domain> Configure credential helper for specific domain + --global-hook Install hook globally for all repositories + --help, -h Show this help message + +Examples: + gitrep-setup # Setup both credential helper and hook + gitrep-setup --domain your-domain.com # Configure for specific domain + gitrep-setup --global-hook # Install hook globally + gitrep-setup --credential-only # Only setup credential helper + +The setup script will: + - Automatically find the scripts (works with npm install or git clone) + - Configure git credential helper + - Install commit signing hook (current repo or globally) + - Check if NOSTRGIT_SECRET_KEY is set + +For multiple servers, run setup multiple times: + gitrep-setup --domain server1.com --credential-only + gitrep-setup --domain server2.com --credential-only + +Documentation: https://github.com/silberengel/gitrepublic-cli +GitCitadel: Visit us on GitHub: https://github.com/ShadowySupercode or on our homepage: https://gitcitadel.com + +GitRepublic CLI - Copyright (c) 2026 GitCitadel LLC +Licensed under MIT License +`); +} + +// Parse command line arguments +const args = process.argv.slice(2); +const showHelpFlag = args.includes('--help') || args.includes('-h'); +const credentialOnly = args.includes('--credential-only'); +const hookOnly = args.includes('--hook-only'); +const globalHook = args.includes('--global-hook'); +const domainIndex = args.indexOf('--domain'); +const domain = domainIndex >= 0 && args[domainIndex + 1] ? args[domainIndex + 1] : null; + +if (showHelpFlag) { + showHelp(); + process.exit(0); +} + +// Check if scripts exist +if (!existsSync(credentialScript)) { + console.error('Error: git-credential-nostr.js not found at', credentialScript); + process.exit(1); +} + +if (!existsSync(commitHookScript)) { + console.error('Error: git-commit-msg-hook.js not found at', commitHookScript); + process.exit(1); +} + +// Check if NOSTRGIT_SECRET_KEY is set +const secretKey = process.env.NOSTRGIT_SECRET_KEY; +if (!secretKey) { + console.warn('⚠️ Warning: NOSTRGIT_SECRET_KEY environment variable is not set.'); + console.warn(' Set it with: export NOSTRGIT_SECRET_KEY="nsec1..."'); + console.warn(' Or add to ~/.bashrc or ~/.zshrc for persistence\n'); +} + +// Setup credential helper +function setupCredentialHelper() { + console.log('🔐 Setting up git credential helper...'); + + try { + let configCommand; + + if (domain) { + // Configure for specific domain + const protocol = domain.startsWith('https://') ? 'https' : domain.startsWith('http://') ? 'http' : 'https'; + const host = domain.replace(/^https?:\/\//, '').split('/')[0]; + configCommand = `git config --global credential.${protocol}://${host}.helper '!node ${credentialScript}'`; + console.log(` Configuring for domain: ${host}`); + } else { + // Configure globally for all domains + configCommand = `git config --global credential.helper '!node ${credentialScript}'`; + console.log(' Configuring globally for all domains'); + } + + execSync(configCommand, { stdio: 'inherit' }); + console.log('✅ Credential helper configured successfully!\n'); + } catch (error) { + console.error('❌ Failed to configure credential helper:', error.message); + process.exit(1); + } +} + +// Setup commit hook +function setupCommitHook() { + console.log('✍️ Setting up commit signing hook...'); + + try { + if (globalHook) { + // Install globally + const hooksDir = resolve(process.env.HOME, '.git-hooks'); + + // Create hooks directory if it doesn't exist + if (!existsSync(hooksDir)) { + execSync(`mkdir -p "${hooksDir}"`, { stdio: 'inherit' }); + } + + // Create symlink + const hookPath = join(hooksDir, 'commit-msg'); + if (existsSync(hookPath)) { + console.log(' Removing existing hook...'); + execSync(`rm "${hookPath}"`, { stdio: 'inherit' }); + } + + execSync(`ln -s "${commitHookScript}" "${hookPath}"`, { stdio: 'inherit' }); + + // Configure git to use global hooks + execSync('git config --global core.hooksPath ~/.git-hooks', { stdio: 'inherit' }); + + console.log('✅ Commit hook installed globally for all repositories!\n'); + } else { + // Install for current repository + const gitDir = findGitDir(); + if (!gitDir) { + console.error('❌ Error: Not in a git repository. Run this from a git repo or use --global-hook'); + process.exit(1); + } + + const hookPath = join(gitDir, 'hooks', 'commit-msg'); + + // Create hooks directory if it doesn't exist + const hooksDir = join(gitDir, 'hooks'); + if (!existsSync(hooksDir)) { + execSync(`mkdir -p "${hooksDir}"`, { stdio: 'inherit' }); + } + + // Create symlink + if (existsSync(hookPath)) { + console.log(' Removing existing hook...'); + execSync(`rm "${hookPath}"`, { stdio: 'inherit' }); + } + + execSync(`ln -s "${commitHookScript}" "${hookPath}"`, { stdio: 'inherit' }); + + console.log('✅ Commit hook installed for current repository!\n'); + } + } catch (error) { + console.error('❌ Failed to setup commit hook:', error.message); + process.exit(1); + } +} + +// Find .git directory +function findGitDir() { + let currentDir = process.cwd(); + const maxDepth = 10; + let depth = 0; + + while (depth < maxDepth) { + const gitDir = join(currentDir, '.git'); + if (existsSync(gitDir)) { + return gitDir; + } + + const parentDir = resolve(currentDir, '..'); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + depth++; + } + + return null; +} + +// Main execution +console.log('🚀 GitRepublic CLI Setup\n'); +console.log('Scripts location:', scriptsDir); +console.log('Credential helper:', credentialScript); +console.log('Commit hook:', commitHookScript); +console.log(''); + +if (!credentialOnly && !hookOnly) { + // Setup both + setupCredentialHelper(); + setupCommitHook(); +} else if (credentialOnly) { + setupCredentialHelper(); +} else if (hookOnly) { + setupCommitHook(); +} + +console.log('✨ Setup complete!'); +console.log(''); +console.log('Next steps:'); +if (!secretKey) { + console.log('1. Set NOSTRGIT_SECRET_KEY: export NOSTRGIT_SECRET_KEY="nsec1..."'); +} +console.log('2. Test credential helper: gitrep clone <gitrepublic-repo-url> gitrepublic-web'); +console.log('3. Test commit signing: gitrep commit -m "Test commit"'); diff --git a/scripts/uninstall.js b/scripts/uninstall.js new file mode 100755 index 0000000..831b18c --- /dev/null +++ b/scripts/uninstall.js @@ -0,0 +1,209 @@ +#!/usr/bin/env node +/** + * GitRepublic CLI Uninstall Script + * + * Removes all GitRepublic CLI configuration from your system + */ + +import { execSync } from 'child_process'; +import { existsSync, unlinkSync, rmdirSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +function showHelp() { + console.log(` +GitRepublic CLI Uninstall + +This script removes: + - Git credential helper configuration + - Commit signing hook (local and global) + - Environment variable references (from shell config files) + +Usage: + gitrep-uninstall [options] (or gitrepublic-uninstall) + +Options: + --help, -h Show this help message + --dry-run, -d Show what would be removed without actually removing it + --keep-env Don't remove environment variable exports from shell config + +Examples: + gitrep-uninstall # Full uninstall + gitrep-uninstall --dry-run # See what would be removed + gitrep-uninstall --keep-env # Keep environment variables + +Documentation: https://github.com/silberengel/gitrepublic-cli +GitCitadel: Visit us on GitHub: https://github.com/ShadowySupercode or on our homepage: https://gitcitadel.com + +GitRepublic CLI - Copyright (c) 2026 GitCitadel LLC +Licensed under MIT License +`); +} + +function getShellConfigFile() { + const shell = process.env.SHELL || ''; + if (shell.includes('zsh')) { + return join(homedir(), '.zshrc'); + } else if (shell.includes('fish')) { + return join(homedir(), '.config', 'fish', 'config.fish'); + } else { + return join(homedir(), '.bashrc'); + } +} + +function removeFromShellConfig(pattern, dryRun) { + const configFile = getShellConfigFile(); + if (!existsSync(configFile)) { + return false; + } + + try { + const content = readFileSync(configFile, 'utf-8'); + const lines = content.split('\n'); + const filtered = lines.filter(line => !line.includes(pattern)); + + if (filtered.length !== lines.length) { + if (!dryRun) { + writeFileSync(configFile, filtered.join('\n'), 'utf-8'); + } + return true; + } + } catch (err) { + // Ignore errors + } + return false; +} + +function main() { + const args = process.argv.slice(2); + const dryRun = args.includes('--dry-run') || args.includes('-d'); + const keepEnv = args.includes('--keep-env'); + const showHelpFlag = args.includes('--help') || args.includes('-h'); + + if (showHelpFlag) { + showHelp(); + process.exit(0); + } + + console.log('GitRepublic CLI Uninstall\n'); + + if (dryRun) { + console.log('DRY RUN MODE - No changes will be made\n'); + } + + let removed = 0; + + // Remove credential helper configurations + console.log('Removing git credential helper configurations...'); + try { + const credentialConfigs = execSync('git config --global --get-regexp credential.*helper', { encoding: 'utf-8' }) + .split('\n') + .filter(line => line.trim() && line.includes('gitrepublic') || line.includes('git-credential-nostr')); + + for (const config of credentialConfigs) { + if (config.trim()) { + const key = config.split(' ')[0]; + if (key) { + console.log(` - ${key}`); + if (!dryRun) { + try { + execSync(`git config --global --unset "${key}"`, { stdio: 'ignore' }); + } catch { + // Ignore if already removed + } + } + removed++; + } + } + } + } catch { + // No credential helpers configured + } + + // Remove commit hook (global) + console.log('\nRemoving global commit hook...'); + try { + const hooksPath = execSync('git config --global --get core.hooksPath', { encoding: 'utf-8' }).trim(); + if (hooksPath) { + const hookFile = join(hooksPath, 'commit-msg'); + if (existsSync(hookFile)) { + console.log(` - ${hookFile}`); + if (!dryRun) { + try { + unlinkSync(hookFile); + // Try to remove directory if empty + try { + rmdirSync(hooksPath); + } catch { + // Directory not empty, that's fine + } + } catch (err) { + console.error(` Warning: Could not remove ${hookFile}: ${err.message}`); + } + } + removed++; + } + } + + // Remove core.hooksPath config + try { + execSync('git config --global --unset core.hooksPath', { stdio: 'ignore' }); + if (!dryRun) { + console.log(' - Removed core.hooksPath configuration'); + } + } catch { + // Already removed + } + } catch { + // No global hook configured + } + + // Remove commit hook from current directory + console.log('\nChecking current directory for commit hook...'); + const localHook = '.git/hooks/commit-msg'; + if (existsSync(localHook)) { + try { + const hookContent = readFileSync(localHook, 'utf-8'); + if (hookContent.includes('gitrepublic') || hookContent.includes('git-commit-msg-hook')) { + console.log(` - ${localHook}`); + if (!dryRun) { + unlinkSync(localHook); + } + removed++; + } + } catch { + // Ignore errors + } + } + + // Remove environment variables from shell config + if (!keepEnv) { + console.log('\nRemoving environment variables from shell config...'); + const configFile = getShellConfigFile(); + const patterns = ['NOSTRGIT_SECRET_KEY', 'GITREPUBLIC_SERVER']; + + for (const pattern of patterns) { + if (removeFromShellConfig(pattern, dryRun)) { + console.log(` - Removed ${pattern} from ${configFile}`); + removed++; + } + } + } + + console.log(`\n${dryRun ? 'Would remove' : 'Removed'} ${removed} configuration item(s).`); + + if (!dryRun) { + console.log('\n✅ Uninstall complete!'); + console.log('\nNote: Environment variables in your current shell session are still set.'); + console.log('Start a new shell session to clear them, or run:'); + console.log(' unset NOSTRGIT_SECRET_KEY'); + console.log(' unset GITREPUBLIC_SERVER'); + } else { + console.log('\nRun without --dry-run to actually remove these items.'); + } +} + +main().catch((err) => { + console.error('Error:', err.message); + process.exit(1); +});