commit
e530598acd
13 changed files with 3778 additions and 0 deletions
@ -0,0 +1,7 @@ |
|||||||
|
node_modules/ |
||||||
|
npm-debug.log |
||||||
|
yarn-error.log |
||||||
|
.DS_Store |
||||||
|
*.log |
||||||
|
.env |
||||||
|
.env.local |
||||||
@ -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 @@ |
|||||||
|
# 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 @@ |
|||||||
|
# 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 @@ |
|||||||
|
{ |
||||||
|
"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 @@ |
|||||||
|
#!/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 @@ |
|||||||
|
#!/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 @@ |
|||||||
|
#!/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 @@ |
|||||||
|
#!/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 @@ |
|||||||
|
#!/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