commit
e530598acd
13 changed files with 3778 additions and 0 deletions
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
node_modules/ |
||||
npm-debug.log |
||||
yarn-error.log |
||||
.DS_Store |
||||
*.log |
||||
.env |
||||
.env.local |
||||
@ -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. |
||||
@ -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 |
||||
@ -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). |
||||
@ -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" |
||||
} |
||||
} |
||||
@ -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'); |
||||
} |
||||
@ -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); |
||||
}); |
||||
@ -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); |
||||
}); |
||||
@ -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 |
||||
`);
|
||||
@ -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…
Reference in new issue