You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

465 lines
15 KiB

#!/usr/bin/env node
/**
* GitRepublic CLI - Unified command for git operations and API access
*
* This script handles both git commands (with enhanced error messages) and
* API commands (push-all, repos, publish, etc.). It delegates API commands
* to gitrepublic.js and handles git commands directly.
*
* Usage:
* gitrepublic <command> [arguments...]
* gitrep <command> [arguments...] (shorter alias)
*
* Git Commands:
* gitrep clone https://domain.com/api/git/npub1.../repo.git gitrepublic-web
* gitrep push gitrepublic-web main
* gitrep pull gitrepublic-web main
*
* API Commands:
* gitrep push-all [branch] [--force] [--tags] [--dry-run]
* gitrep repos list
* gitrep publish <subcommand>
*/
import { spawn, spawnSync } from 'child_process';
import { createHash } from 'crypto';
import { finalizeEvent } from 'nostr-tools';
import { decode } from 'nostr-tools/nip19';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
// Import API commands handler
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const API_SCRIPT = join(__dirname, 'gitrepublic.js');
// 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'];
// API commands that should be handled by gitrepublic.js
const API_COMMANDS = [
'push-all', 'pushAll',
'repos', 'repo',
'file',
'search',
'publish',
'verify',
'config'
];
// Get git remote URL
// Security: Validates remote name to prevent command injection
function getRemoteUrl(remote = 'origin') {
try {
// Validate remote name to prevent injection
if (!remote || typeof remote !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(remote)) {
return null;
}
// Security: Use spawnSync with argument array instead of string concatenation
const result = spawnSync('git', ['config', '--get', `remote.${remote}.url`], { encoding: 'utf-8' });
if (result.status !== 0) return null;
return result.stdout.trim();
} 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)
Git Commands:
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"
API Commands:
gitrep push-all [branch] [--force] [--tags] [--dry-run] Push to all remotes
gitrep repos list List repositories
gitrep repos get <npub> <repo> Get repository info
gitrep publish <subcommand> Publish Nostr events
gitrep search <query> Search repositories
gitrep verify <event-file> Verify Nostr events
gitrep config [server] Show configuration
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.)
- API commands for repository management and Nostr event publishing
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
Run any command with --help for detailed usage information.
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);
const command = args[0];
const commandArgs = args.slice(1);
// Check if this is an API command - if so, delegate to gitrepublic.js
// Convert kebab-case to camelCase for comparison
const commandKey = command ? command.replace(/-([a-z])/g, (g) => g[1].toUpperCase()) : null;
if (command && (API_COMMANDS.includes(command) || API_COMMANDS.includes(commandKey))) {
// This is an API command, delegate to gitrepublic.js
const apiProcess = spawn('node', [API_SCRIPT, ...args], {
stdio: 'inherit',
cwd: __dirname
});
apiProcess.on('close', (code) => {
process.exit(code || 0);
});
apiProcess.on('error', (err) => {
console.error('Error running API command:', err.message);
process.exit(1);
});
return;
}
// Check for help flag (only if not an API command)
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
showHelp();
process.exit(0);
}
// 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);
});