Browse Source

Sync from gitrepublic-web monorepo

master
Silberengel 3 weeks ago
commit
e530598acd
  1. 7
      .gitignore
  2. 21
      LICENSE
  3. 110
      README.md
  4. 112
      SYNC.md
  5. 61
      package.json
  6. 43
      scripts/get-path.js
  7. 400
      scripts/git-commit-msg-hook.js
  8. 402
      scripts/git-credential-nostr.js
  9. 404
      scripts/git-wrapper.js
  10. 1730
      scripts/gitrepublic.js
  11. 42
      scripts/postinstall.js
  12. 237
      scripts/setup.js
  13. 209
      scripts/uninstall.js

7
.gitignore vendored

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
node_modules/
npm-debug.log
yarn-error.log
.DS_Store
*.log
.env
.env.local

21
LICENSE

@ -0,0 +1,21 @@ @@ -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.

110
README.md

@ -0,0 +1,110 @@ @@ -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

112
SYNC.md

@ -0,0 +1,112 @@ @@ -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).

61
package.json

@ -0,0 +1,61 @@ @@ -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"
}
}

43
scripts/get-path.js

@ -0,0 +1,43 @@ @@ -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');
}

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

@ -0,0 +1,400 @@ @@ -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: <event-id> <pubkey> <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 <commit-message-file>');
process.exit(1);
}
signCommitMessage(commitMessageFile).catch((error) => {
console.error('Fatal error in commit hook:', error);
process.exit(1);
});

402
scripts/git-credential-nostr.js

@ -0,0 +1,402 @@ @@ -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="<hex-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);
});

404
scripts/git-wrapper.js

@ -0,0 +1,404 @@ @@ -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 <git-command> [arguments...]
* gitrep <git-command> [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 <git-command> [arguments...]
gitrep <git-command> [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);
});

1730
scripts/gitrepublic.js

File diff suppressed because it is too large Load Diff

42
scripts/postinstall.js

@ -0,0 +1,42 @@ @@ -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
`);

237
scripts/setup.js

@ -0,0 +1,237 @@ @@ -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"');

209
scripts/uninstall.js

@ -0,0 +1,209 @@ @@ -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);
});
Loading…
Cancel
Save