irys-git
Version:
Irys based git-like CLI tool for decentralized repository management
1,365 lines (1,131 loc) • 149 kB
JavaScript
#!/usr/bin/env node
import { Command } from 'commander';
import inquirer from 'inquirer';
import chalk from 'chalk';
import ora from 'ora';
import { Uploader } from '@irys/upload';
import { Solana } from '@irys/upload-solana';
import simpleGit from 'simple-git';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';
import { createHash, randomBytes, createCipheriv, createDecipheriv, pbkdf2Sync } from 'crypto';
import { spawn } from 'child_process';
import { tmpdir } from 'os';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CONFIG_DIR = path.join(os.homedir(), '.igit');
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
const REPOS_DIR = path.join(CONFIG_DIR, 'repos');
const IGIT_DIR = path.join('..', '.igit');
const REFS_DIR = path.join(IGIT_DIR, 'refs', 'heads');
const HEAD_FILE = path.join(IGIT_DIR, 'HEAD');
const REPO_CONFIG_PATH = '.igit_config.json';
// Security settings
const MAX_UPLOAD_COST_SOL = 1.0; // Maximum 1 SOL
const MIN_FUNDING_AMOUNT = 0.001; // Minimum safe funding amount in SOL
const FUNDING_SAFETY_MARGIN = 1.1; // 10% safety margin for funding
const FORBIDDEN_EXTENSIONS = ['.exe', '.bat', '.cmd', '.scr', '.com', '.pif', '.jar'];
const MAX_FILE_COUNT = 10000;
const rateLimitMap = new Map();
// ═══════════════════════════════════════════════════════════════
// Security utility functions
// Encrypt private key (AES-256-GCM)
function encryptPrivateKey(privateKey, password) {
try {
const salt = randomBytes(32);
const key = pbkdf2Sync(password, salt, 100000, 32, 'sha256');
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(privateKey, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return {
encrypted: encrypted,
salt: salt.toString('hex'),
iv: iv.toString('hex'),
authTag: authTag.toString('hex')
};
} catch (error) {
throw new Error('Failed to encrypt private key');
}
}
// Decrypt private key (AES-256-GCM)
function decryptPrivateKey(encryptedData, password) {
try {
const salt = Buffer.from(encryptedData.salt, 'hex');
const key = pbkdf2Sync(password, salt, 100000, 32, 'sha256');
const iv = Buffer.from(encryptedData.iv, 'hex');
const authTag = Buffer.from(encryptedData.authTag, 'hex');
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
throw new Error('Failed to decrypt private key - the password may be incorrect');
}
}
// Validate wallet address
function validateWalletAddress(address) {
if (!address || typeof address !== 'string') {
throw new Error('Wallet address not provided.');
}
// Solana 주소 형식 검증 (Base58, 32-44자)
if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(address)) {
throw new Error('Invalid wallet address format.');
}
return address;
}
// 저장소명 검증
// Validate repository name
function validateRepositoryName(name) {
if (!name || typeof name !== 'string') {
throw new Error('Repository name not provided.');
}
if (!/^[a-zA-Z0-9\-_]{1,100}$/.test(name)) {
throw new Error('Repository name must be up to 100 characters and contain only alphanumeric characters, hyphens, and underscores.');
}
return name.trim();
}
// Validate and sanitize branch name
function sanitizeBranchName(branchName) {
if (!branchName || typeof branchName !== 'string') {
throw new Error('Branch name not provided.');
}
if (!/^[a-zA-Z0-9\-\/_]{1,100}$/.test(branchName)) {
throw new Error('Branch name must be up to 100 characters and contain only alphanumeric characters, hyphens, slashes, and underscores.');
}
if (branchName.includes('..') || branchName.startsWith('/') || branchName.endsWith('/')) {
throw new Error('Branch name cannot contain ".." or start/end with a slash.');
}
return branchName.trim();
}
// 안전한 파일 경로 생성
// Create safe file path
function createSafeFilePath(basePath, userPath) {
const normalized = path.normalize(userPath);
const resolved = path.resolve(basePath, normalized);
const baseResolved = path.resolve(basePath);
if (!resolved.startsWith(baseResolved)) {
throw new Error('Path not allowed.');
}
return resolved;
}
// Create secure temporary file
function createSecureTempFile(extension = '.tmp') {
const tempName = randomBytes(16).toString('hex') + extension;
const tempPath = path.join(tmpdir(), tempName);
// Create with secure permissions (owner read/write only)
fs.writeFileSync(tempPath, '', { mode: 0o600 });
return tempPath;
}
// Rate limiting check
function checkRateLimit(address, maxRequests = 10, timeWindow = 60000) {
const now = Date.now();
const userRequests = rateLimitMap.get(address) || [];
// Filter requests within the time window
const validRequests = userRequests.filter(time => now - time < timeWindow);
if (validRequests.length >= maxRequests) {
throw new Error('Request limit exceeded. Please try again later.');
}
validRequests.push(now);
rateLimitMap.set(address, validRequests);
}
// Execute safe GraphQL query to prevent injection
async function executeSafeGraphQLQuery(query, variables = {}) {
try {
const response = await fetch('https://uploader.irys.xyz/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables })
});
if (!response.ok) {
throw new Error(`GraphQL request failed: ${response.statusText}`);
}
const result = await response.json();
if (result.errors) {
throw new Error(`GraphQL error: ${result.errors[0].message}`);
}
return result.data;
} catch (error) {
console.error(chalk.red('❌ GraphQL request failed:'), error.message);
throw error;
}
}
// Create Irys Uploader instance
async function getIrysUploader(privateKey) {
try {
const uploader = await Uploader(Solana).withWallet(privateKey);
return uploader;
} catch (error) {
console.error(chalk.red('❌ Failed to initialize Irys uploader:'), error.message);
process.exit(1);
}
}
// Check wallet balance with retry logic
async function checkWalletBalance(uploader) {
const maxRetries = 3;
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const balance = await uploader.getBalance();
const balanceInSOL = balance.toNumber() / 1e9; // lamports to SOL
console.log(chalk.blue(`💰 Wallet balance: ${balanceInSOL.toFixed(6)} SOL`));
console.log(chalk.gray(` Address: ${uploader.address}`));
return balanceInSOL;
} catch (error) {
lastError = error;
if (attempt < maxRetries) {
console.log(chalk.yellow(`⚠️ Balance check failed (attempt ${attempt}/${maxRetries}): ${error.message}`));
console.log(chalk.gray(' Retrying in 2 seconds...'));
await new Promise(resolve => setTimeout(resolve, 2000));
} else {
console.error(chalk.red('❌ Failed to check wallet balance after all retries:'), error.message);
console.log(chalk.yellow('💡 This might be a network issue. Please try again.'));
}
}
}
throw new Error(`Balance check failed after ${maxRetries} attempts: ${lastError.message}`);
}
// Fund wallet if balance is insufficient
async function checkAndFundWallet(uploader, requiredAmount) {
try {
const balance = await checkWalletBalance(uploader);
if (balance < requiredAmount) {
console.log(chalk.red(`❌ Insufficient balance: ${balance.toFixed(6)} SOL`));
console.log(chalk.yellow(`💡 Required: ${requiredAmount.toFixed(6)} SOL`));
console.log(chalk.yellow(`💡 Shortfall: ${(requiredAmount - balance).toFixed(6)} SOL`));
console.log(chalk.cyan('\n🔗 Fund your wallet:'));
console.log(chalk.cyan(` Address: ${uploader.address}`));
console.log(chalk.cyan(` You can fund via:
• SOL transfer from another wallet
• Exchange withdrawal to this address
• Faucet (if on devnet/testnet)`));
return false;
}
return true;
} catch (error) {
console.error(chalk.red('❌ Balance check failed:'), error.message);
return false;
}
}
// Load config file (includes decrypting encrypted private key)
async function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) {
console.error(chalk.red('❌ Config file not found. Please run `igit login` first.'));
process.exit(1);
}
try {
const configData = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
if (configData.privateKey && typeof configData.privateKey === 'string') {
return configData;
}
if (configData.encryptedPrivateKey) {
const { password } = await inquirer.prompt([{
type: 'password',
name: 'password',
message: 'Enter the config file password:',
mask: '*'
}]);
try {
if (!configData.encryptedPrivateKey.authTag) {
console.log(chalk.yellow('⚠️ Legacy encryption format detected. Please log in again to upgrade.'));
throw new Error('Legacy encryption format is not supported. Please log in again.');
}
const privateKey = decryptPrivateKey(configData.encryptedPrivateKey, password);
return { privateKey, ...configData };
} catch (error) {
console.error(chalk.red('❌ Incorrect password or legacy encryption format.'));
console.error(chalk.yellow('💡 Run `igit login` again to upgrade to the new security format.'));
process.exit(1);
}
}
console.error(chalk.red('❌ No private key information in config file.'));
process.exit(1);
} catch (error) {
console.error(chalk.red('❌ Failed to read config file:'), error.message);
process.exit(1);
}
}
// Save config file (includes encrypting private key)
async function saveConfig(cfg) {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
if (cfg.privateKey) {
const { password } = await inquirer.prompt([{
type: 'password',
name: 'password',
message: 'Enter a password to protect the config file:',
mask: '*',
validate: (input) => input.length >= 8 || 'Password must be at least 8 characters.'
}]);
const { password: confirmPassword } = await inquirer.prompt([{
type: 'password',
name: 'password',
message: 'Re-enter the password:',
mask: '*'
}]);
if (password !== confirmPassword) {
console.error(chalk.red('❌ Passwords do not match.'));
process.exit(1);
}
const encryptedPrivateKey = encryptPrivateKey(cfg.privateKey, password);
const configToSave = {
encryptedPrivateKey,
created: new Date().toISOString(),
version: '1.5.2'
};
fs.writeFileSync(CONFIG_PATH, JSON.stringify(configToSave, null, 2), { mode: 0o600 });
} else {
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
}
}
// Generate unique identifier based on project path
function getProjectId() {
const projectPath = process.cwd();
return createHash('md5').update(projectPath).digest('hex').substring(0, 16);
}
// Generate repository config file path
function getRepoConfigPath() {
const projectId = getProjectId();
return path.join(REPOS_DIR, `${projectId}.json`);
}
// Migrate legacy .igit_config.json file to global config
function migrateOldConfig() {
const oldConfigPath = '.igit_config.json';
const newConfigPath = getRepoConfigPath();
if (fs.existsSync(oldConfigPath) && !fs.existsSync(newConfigPath)) {
try {
const oldConfig = JSON.parse(fs.readFileSync(oldConfigPath, 'utf-8'));
if (!fs.existsSync(REPOS_DIR)) {
fs.mkdirSync(REPOS_DIR, { recursive: true });
}
const migratedConfig = {
...oldConfig,
projectPath: process.cwd(),
configId: getProjectId(),
migrated: true,
migratedAt: new Date().toISOString(),
lastUpdated: new Date().toISOString()
};
fs.writeFileSync(newConfigPath, JSON.stringify(migratedConfig, null, 2));
fs.unlinkSync(oldConfigPath);
console.log(chalk.blue('📦 Migrated config file to global directory.'));
console.log(chalk.gray(` ${oldConfigPath} → ${newConfigPath}`));
return migratedConfig;
} catch (error) {
console.error(chalk.yellow('⚠️ Failed to migrate legacy config file:'), error.message);
return null;
}
}
return null;
}
// Load local repository config
function loadRepoConfig() {
const migratedConfig = migrateOldConfig();
if (migratedConfig) {
return migratedConfig;
}
const configPath = getRepoConfigPath();
if (!fs.existsSync(configPath)) {
return null;
}
try {
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
} catch (error) {
return null;
}
}
// Save local repository config
function saveRepoConfig(config) {
if (!fs.existsSync(REPOS_DIR)) {
fs.mkdirSync(REPOS_DIR, { recursive: true });
}
const configPath = getRepoConfigPath();
const configWithPath = {
...config,
projectPath: process.cwd(),
configId: getProjectId(),
lastUpdated: new Date().toISOString()
};
fs.writeFileSync(configPath, JSON.stringify(configWithPath, null, 2));
}
// Check if this is the first push after init (legacy function, use isFirstRepositoryPush for better logic)
function isFirstPush() {
const repoConfig = loadRepoConfig();
return repoConfig && !repoConfig.firstPushComplete;
}
// Check if this is truly the first repository push (no branches have been pushed yet)
function isFirstRepositoryPush() {
const repoConfig = loadRepoConfig();
if (!repoConfig || repoConfig.firstPushComplete) {
return false;
}
// Check if any branches have been pushed before
try {
const localBranches = getLocalBranches();
for (const branchName of localBranches) {
const metadata = getBranchMetadata(branchName);
if (metadata && metadata.transactionId) {
// Found a branch that has been pushed, so this is not the first repository push
return false;
}
}
} catch (error) {
// If we can't check, assume it's not the first push to be safe
return false;
}
return true;
}
// Check if this is truly the first repository push (async version with remote check)
async function isFirstRepositoryPushAsync(uploader, repoConfig) {
if (!repoConfig || repoConfig.firstPushComplete) {
return false;
}
// Check local branches first
try {
const localBranches = getLocalBranches();
for (const branchName of localBranches) {
const metadata = getBranchMetadata(branchName);
if (metadata && metadata.transactionId) {
// Found a branch that has been pushed, so this is not the first repository push
return false;
}
}
} catch (error) {
// If we can't check local branches, continue to remote check
}
// Check if any branches exist on Irys
try {
const results = await queryIrysData(uploader, [
{ name: 'App-Name', value: 'irys-git' },
{ name: 'Repository', value: repoConfig.name },
{ name: 'git-owner', value: uploader.address }
]);
if (results.length > 0) {
// Found existing branches on Irys, so this is not the first repository push
return false;
}
} catch (error) {
// If remote check fails, assume it's not the first push to be safe
return false;
}
return true;
}
// Check if this branch is new (never been pushed before)
function isNewBranch(branchName) {
const metadata = getBranchMetadata(branchName);
return !metadata || !metadata.transactionId || metadata.transactionId === '';
}
// Mark first push as complete
function markFirstPushComplete() {
const repoConfig = loadRepoConfig();
if (repoConfig) {
repoConfig.firstPushComplete = true;
saveRepoConfig(repoConfig);
}
}
// Push a single branch to Irys
async function pushSingleBranch(git, uploader, repoConfig, branchName, providedBundleData = null, skipCostConfirmation = false) {
try {
const gitInfo = await getCurrentGitInfo();
const currentIrysBranch = getCurrentBranch();
const currentGitBranch = gitInfo.branch;
// Priority: explicit argument > Git current branch > Irys current branch > 'main'
let actualBranch = branchName || currentGitBranch || currentIrysBranch || 'main';
// Sync Irys branch with Git branch if they differ
if (currentGitBranch && currentGitBranch !== currentIrysBranch && !branchName) {
console.log(chalk.yellow(`⚠️ Git branch (${currentGitBranch}) differs from Irys branch (${currentIrysBranch})`));
console.log(chalk.blue(`🔄 Syncing Irys branch to match Git branch: ${currentGitBranch}`));
setCurrentBranch(currentGitBranch);
actualBranch = currentGitBranch;
// Check if Git branch has existing metadata
const gitBranchMetadata = getBranchMetadata(currentGitBranch);
if (!gitBranchMetadata) {
console.log(chalk.yellow(`💡 Creating new branch metadata for '${currentGitBranch}'`));
setBranchMetadata(currentGitBranch, '', null);
}
}
try {
actualBranch = sanitizeBranchName(actualBranch);
} catch (error) {
console.error(chalk.red('❌ Branch name error:'), error.message);
return false;
}
// Use provided bundle data or create new one
const bundleData = providedBundleData || await createRepoBundle(git);
// Skip cost confirmation if requested (for first push with pre-calculated costs)
if (!skipCostConfirmation) {
try {
await estimateAndConfirmUploadCost(uploader, bundleData.length);
} catch (error) {
if (error.message.includes('Insufficient balance') || error.message.includes('balance too low')) {
console.error(chalk.red('❌ Upload cancelled due to insufficient balance'));
return false;
}
if (error.message.includes('cancelled by user')) {
console.error(chalk.red('❌ Upload cancelled by user'));
return false;
}
throw error;
}
}
let targetOwner = uploader.address;
let targetRepoName = repoConfig.name;
let pushToOriginal = false;
if (repoConfig.originalOwner && repoConfig.originalOwner !== uploader.address) {
console.log(chalk.blue('🔍 Checking edit permission...'));
const permissionCheck = await hasEditPermission(
repoConfig.name,
repoConfig.originalOwner,
uploader.address
);
if (permissionCheck.hasPermission) {
targetOwner = repoConfig.originalOwner;
pushToOriginal = true;
console.log(chalk.green('✅ Edit permission confirmed - pushing to original repository.'));
console.log(chalk.gray(` Target repository: ${targetRepoName} (owner: ${targetOwner.substring(0, 8)}...)`));
} else {
console.log(chalk.yellow('⚠️ Edit permission not found - pushing to personal repository.'));
console.log(chalk.gray(` Target repository: ${targetRepoName} (owner: ${uploader.address.substring(0, 8)}...)`));
}
} else {
console.log(chalk.blue('📂 Pushing to your repository.'));
}
const existingMetadata = getBranchMetadata(actualBranch);
const isUpdate = existingMetadata && existingMetadata.mutableAddress;
const spinner = ora(isUpdate ? 'Updating Irys...' : 'Uploading to Irys...').start();
const tags = [
{ name: 'App-Name', value: 'irys-git' },
{ name: 'Repository', value: targetRepoName },
{ name: 'Branch', value: actualBranch },
{ name: 'Commit-Hash', value: gitInfo.fullCommit },
{ name: 'Commit-Message', value: gitInfo.message },
{ name: 'Author', value: gitInfo.author },
{ name: 'Timestamp', value: gitInfo.date },
{ name: 'Content-Type', value: 'application/x-tar' },
{ name: 'git-owner', value: targetOwner }
];
if (pushToOriginal) {
// Check original repository branch metadata
const originalBranches = await getAllRepositoryBranches(targetOwner, targetRepoName);
const originalBranchInfo = originalBranches.find(b => b.name === actualBranch);
if (originalBranchInfo && originalBranchInfo.mutableAddress) {
// Use original repository mutable address
tags.push({ name: 'Mutable-Address', value: originalBranchInfo.mutableAddress });
tags.push({ name: 'Root-TX', value: originalBranchInfo.mutableAddress });
spinner.text = `Updating original repository branch... (${originalBranchInfo.mutableAddress.substring(0, 8)}...)`;
try {
const receipt = await uploader.upload(bundleData, {
tags,
mutable: originalBranchInfo.mutableAddress
});
setBranchMetadata(actualBranch, receipt.id, originalBranchInfo.mutableAddress);
setCurrentBranch(actualBranch);
spinner.succeed(chalk.green(`✅ Original repository branch '${actualBranch}' updated`));
console.log(chalk.blue('🔄 Updated transaction ID:'), receipt.id);
console.log(chalk.blue('📍 Mutable address:'), `https://gateway.irys.xyz/mutable/${originalBranchInfo.mutableAddress}`);
console.log(chalk.gray(` Repository: ${targetRepoName} (${targetOwner.substring(0, 8)}...)`));
console.log(chalk.gray(` Branch: ${actualBranch}`));
console.log(chalk.gray(` Commit: ${gitInfo.commit}`));
return true;
} catch (error) {
console.log(chalk.yellow(`⚠️ Original repository branch '${actualBranch}' update failed, creating new branch.`));
console.log(chalk.gray(` Error: ${error.message}`));
}
} else {
console.log(chalk.yellow(`⚠️ Branch '${actualBranch}' not found in original repository, will create new branch.`));
}
}
if (isUpdate && existingMetadata.mutableAddress && !pushToOriginal) {
// Update existing branch with mutable address
const currentMutableAddress = existingMetadata.mutableAddress;
tags.push({ name: 'Mutable-Address', value: currentMutableAddress });
tags.push({ name: 'Root-TX', value: currentMutableAddress });
spinner.text = `Updating existing branch... (${currentMutableAddress.substring(0, 8)}...)`;
try {
const receipt = await uploader.upload(bundleData, {
tags,
mutable: currentMutableAddress
});
setBranchMetadata(actualBranch, receipt.id, currentMutableAddress);
setCurrentBranch(actualBranch);
spinner.succeed(chalk.green(`✅ Branch '${actualBranch}' updated`));
console.log(chalk.blue('🔄 Updated transaction ID:'), receipt.id);
console.log(chalk.blue('📍 Mutable address:'), `https://gateway.irys.xyz/mutable/${currentMutableAddress}`);
console.log(chalk.gray(` Branch: ${actualBranch}`));
} catch (error) {
console.log(chalk.yellow('⚠️ Mutable update failed, creating new mutable address...'));
// Create new mutable address for this branch
const newTags = [
{ name: 'App-Name', value: 'irys-git' },
{ name: 'Repository', value: targetRepoName },
{ name: 'Branch', value: actualBranch },
{ name: 'Commit-Hash', value: gitInfo.fullCommit },
{ name: 'Commit-Message', value: gitInfo.message },
{ name: 'Author', value: gitInfo.author },
{ name: 'Timestamp', value: gitInfo.date },
{ name: 'Content-Type', value: 'application/x-tar' },
{ name: 'git-owner', value: targetOwner }
];
const receipt = await uploader.upload(bundleData, { tags: newTags, mutable: true });
const newMutableAddress = receipt.id;
newTags.push({ name: 'Mutable-Address', value: newMutableAddress });
newTags.push({ name: 'Root-TX', value: newMutableAddress });
setBranchMetadata(actualBranch, receipt.id, newMutableAddress);
setCurrentBranch(actualBranch);
spinner.succeed(chalk.green(`✅ New mutable address created for branch '${actualBranch}'`));
console.log(chalk.blue('📄 Transaction ID:'), receipt.id);
console.log(chalk.blue('📍 New Mutable address:'), `https://gateway.irys.xyz/mutable/${newMutableAddress}`);
console.log(chalk.gray(` Branch: ${actualBranch}`));
}
} else {
// Create new branch with new mutable address
console.log(chalk.blue(`🆕 Creating new mutable address for branch '${actualBranch}'`));
spinner.text = `Creating new mutable branch '${actualBranch}'...`;
const receipt = await uploader.upload(bundleData, { tags, mutable: true });
const mutableAddress = receipt.id;
// Update tags with mutable information - note: this is for logging only as tags are already set during upload
tags.push({ name: 'Mutable-Address', value: mutableAddress });
tags.push({ name: 'Root-TX', value: mutableAddress });
setBranchMetadata(actualBranch, receipt.id, mutableAddress);
setCurrentBranch(actualBranch);
spinner.succeed(chalk.green(`✅ New branch '${actualBranch}' created`));
console.log(chalk.blue('📄 Transaction ID:'), receipt.id);
console.log(chalk.blue('📍 Mutable address:'), `https://gateway.irys.xyz/mutable/${mutableAddress}`);
console.log(chalk.gray(` Branch: ${actualBranch}`));
}
console.log(chalk.gray(` Repository: ${targetRepoName} (${targetOwner.substring(0, 8)}...)`));
console.log(chalk.gray(` Branch: ${actualBranch}`));
console.log(chalk.gray(` Commit: ${gitInfo.commit}`));
// Verify the upload by checking if we can retrieve the latest data
console.log(chalk.blue('🔍 Verifying upload...'));
try {
const verificationResults = await queryIrysData(uploader, [
{ name: 'App-Name', value: 'irys-git' },
{ name: 'Repository', value: targetRepoName },
{ name: 'Branch', value: actualBranch },
{ name: 'git-owner', value: targetOwner }
]);
if (verificationResults.length > 0) {
const latestVerified = verificationResults.sort((a, b) => {
const timeA = a.tags.find(t => t.name === 'Timestamp')?.value || '0';
const timeB = b.tags.find(t => t.name === 'Timestamp')?.value || '0';
return new Date(timeB) - new Date(timeA);
})[0];
const verifiedMutableTag = latestVerified.tags.find(t => t.name === 'Mutable-Address');
const verifiedMutableAddress = verifiedMutableTag ? verifiedMutableTag.value : null;
console.log(chalk.green('✅ Upload verification successful'));
console.log(chalk.gray(` Latest transaction: ${latestVerified.id.substring(0, 12)}...`));
if (verifiedMutableAddress) {
console.log(chalk.gray(` Verified mutable: ${verifiedMutableAddress.substring(0, 12)}...`));
}
console.log(chalk.cyan(`\n🌐 View online: https://githirys.xyz/${targetOwner.substring(0, 8)}.../${targetRepoName}`));
} else {
console.log(chalk.yellow('⚠️ Upload verification: Data not immediately available (propagation delay)'));
}
} catch (verifyError) {
console.log(chalk.yellow(`⚠️ Upload verification failed: ${verifyError.message}`));
}
return true;
} catch (error) {
console.error(chalk.red('❌ Push failed:'), error.message);
return false;
}
}
// Branch metadata management functions
// Initialize .igit directory
function initIgitDir() {
if (!fs.existsSync(IGIT_DIR)) {
fs.mkdirSync(IGIT_DIR, { recursive: true });
}
if (!fs.existsSync(REFS_DIR)) {
fs.mkdirSync(REFS_DIR, { recursive: true });
}
}
// Get current branch
function getCurrentBranch() {
try {
if (!fs.existsSync(HEAD_FILE)) {
return 'main';
}
const headContent = fs.readFileSync(HEAD_FILE, 'utf-8').trim();
if (headContent.startsWith('ref: refs/heads/')) {
return headContent.replace('ref: refs/heads/', '');
}
return headContent || 'main';
} catch (error) {
return 'main';
}
}
// Set current branch
function setCurrentBranch(branchName) {
initIgitDir();
fs.writeFileSync(HEAD_FILE, `ref: refs/heads/${branchName}`);
}
// Get branch metadata (transaction ID and mutable address)
function getBranchMetadata(branchName) {
const branchFile = path.join(REFS_DIR, branchName);
try {
if (!fs.existsSync(branchFile)) {
return null;
}
const content = fs.readFileSync(branchFile, 'utf-8').trim();
if (!content) {
return null;
}
// Parse metadata stored in JSON format
try {
return JSON.parse(content);
} catch (error) {
// 기존 단순 트랜잭션 ID 형태 호환
return { transactionId: content, mutableAddress: null };
}
} catch (error) {
return null;
}
}
// Get latest transaction ID for branch (backward compatibility)
function getBranchTransactionId(branchName) {
const metadata = getBranchMetadata(branchName);
return metadata?.transactionId || null;
}
// Save branch metadata (transaction ID and mutable address)
function setBranchMetadata(branchName, transactionId, mutableAddress = null) {
try {
const safeBranchName = sanitizeBranchName(branchName);
initIgitDir();
const branchFile = createSafeFilePath(REFS_DIR, safeBranchName);
const branchDir = path.dirname(branchFile);
if (!fs.existsSync(branchDir)) {
fs.mkdirSync(branchDir, { recursive: true });
}
const metadata = {
transactionId: transactionId,
mutableAddress: mutableAddress,
lastUpdated: new Date().toISOString()
};
fs.writeFileSync(branchFile, JSON.stringify(metadata, null, 2), { mode: 0o600 });
} catch (error) {
console.error(chalk.red('❌ Failed to save branch metadata:'), error.message);
throw error;
}
}
// Save latest transaction ID of branch (backward compatibility)
function setBranchTransactionId(branchName, transactionId) {
const existingMetadata = getBranchMetadata(branchName);
const mutableAddress = existingMetadata?.mutableAddress || null;
setBranchMetadata(branchName, transactionId, mutableAddress);
}
// Get list of all local branches
function getLocalBranches() {
try {
if (!fs.existsSync(REFS_DIR)) {
return ['main'];
}
const branches = fs.readdirSync(REFS_DIR).filter(file => {
const filePath = path.join(REFS_DIR, file);
return fs.statSync(filePath).isFile();
});
return branches.length > 0 ? branches : ['main'];
} catch (error) {
return ['main'];
}
}
async function checkoutBranch(branchName, uploader) {
const spinner = ora(`Switching to branch ${branchName}...`).start();
try {
const repoConfig = loadRepoConfig();
if (!repoConfig) {
throw new Error('Not an Irys repository.');
}
const results = await queryIrysData(uploader, [
{ name: 'App-Name', value: 'irys-git' },
{ name: 'Repository', value: repoConfig.name },
{ name: 'Branch', value: branchName },
{ name: 'git-owner', value: uploader.address }
]);
if (results.length === 0) {
spinner.text = `Creating new branch ${branchName}...`;
setCurrentBranch(branchName);
const branchFile = path.join(REFS_DIR, branchName);
const branchDir = path.dirname(branchFile);
if (!fs.existsSync(branchDir)) {
fs.mkdirSync(branchDir, { recursive: true });
}
fs.writeFileSync(branchFile, '');
// Sync with Git branch
try {
const git = simpleGit();
const currentGitBranch = (await git.status()).current;
if (currentGitBranch !== branchName) {
console.log(chalk.blue(`🔄 Creating and switching to Git branch: ${branchName}`));
try {
await git.checkoutLocalBranch(branchName);
console.log(chalk.green(`✅ Git branch created and switched to ${branchName}`));
} catch (gitError) {
console.log(chalk.yellow(`⚠️ Could not create Git branch: ${gitError.message}`));
}
}
} catch (gitCheckError) {
console.log(chalk.gray(` Git sync skipped: ${gitCheckError.message}`));
}
spinner.succeed(chalk.green(`✅ New branch '${branchName}' created`));
return;
}
const latest = results.sort((a, b) => {
const timeA = a.tags.find(t => t.name === 'Timestamp')?.value || '0';
const timeB = b.tags.find(t => t.name === 'Timestamp')?.value || '0';
return new Date(timeB) - new Date(timeA);
})[0];
const mutableTag = latest.tags.find(t => t.name === 'Mutable-Address');
const mutableAddress = mutableTag ? mutableTag.value : null;
const downloadId = mutableAddress ? `mutable/${mutableAddress}` : latest.id;
spinner.text = `Downloading branch data... (${downloadId.substring(0, 8)}...)`;
const response = await fetch(`https://gateway.irys.xyz/${downloadId}`);
if (!response.ok) {
throw new Error(`Download failed: ${response.statusText}`);
}
const bundleData = await response.arrayBuffer();
const gitDir = '.git';
const igitDir = IGIT_DIR;
const igitConfig = REPO_CONFIG_PATH;
const tempGitDir = '.git_temp';
const tempIgitDir = path.join('..', '.igit_temp');
const tempIgitConfig = '.igit_config_temp.json';
if (fs.existsSync(gitDir)) fs.renameSync(gitDir, tempGitDir);
if (fs.existsSync(igitDir)) fs.renameSync(igitDir, tempIgitDir);
if (fs.existsSync(igitConfig)) fs.renameSync(igitConfig, tempIgitConfig);
try {
const filesToKeep = [tempGitDir, tempIgitDir, tempIgitConfig];
const currentFiles = fs.readdirSync('.').filter(file => !filesToKeep.includes(file));
for (const file of currentFiles) {
const filePath = path.join('.', file);
if (fs.statSync(filePath).isDirectory()) {
fs.rmSync(filePath, { recursive: true, force: true });
} else {
fs.unlinkSync(filePath);
}
}
const tempBundle = path.join(process.cwd(), '.temp_bundle.tar');
fs.writeFileSync(tempBundle, Buffer.from(bundleData));
const { spawn } = await import('child_process');
const extractProcess = spawn('tar', ['-xf', tempBundle], {
stdio: 'pipe'
});
await new Promise((resolve, reject) => {
extractProcess.on('close', (code) => {
fs.unlinkSync(tempBundle);
if (code === 0) {
resolve();
} else {
reject(new Error(`Extraction failed: ${code}`));
}
});
});
} finally {
if (fs.existsSync(tempGitDir)) fs.renameSync(tempGitDir, gitDir);
if (fs.existsSync(tempIgitDir)) fs.renameSync(tempIgitDir, igitDir);
if (fs.existsSync(tempIgitConfig)) fs.renameSync(tempIgitConfig, igitConfig);
}
setBranchMetadata(branchName, latest.id, mutableAddress);
setCurrentBranch(branchName);
// Sync Git branch with Irys branch
try {
const git = simpleGit();
const currentGitBranch = (await git.status()).current;
if (currentGitBranch !== branchName) {
console.log(chalk.blue(`🔄 Syncing Git branch to match Irys branch: ${branchName}`));
try {
// Check if Git branch exists
const branches = await git.branchLocal();
if (branches.all.includes(branchName)) {
await git.checkout(branchName);
console.log(chalk.green(`✅ Git branch switched to ${branchName}`));
} else {
await git.checkoutLocalBranch(branchName);
console.log(chalk.green(`✅ Git branch created and switched to ${branchName}`));
}
} catch (gitError) {
console.log(chalk.yellow(`⚠️ Could not sync Git branch: ${gitError.message}`));
}
}
} catch (gitCheckError) {
console.log(chalk.gray(` Git sync skipped: ${gitCheckError.message}`));
}
const commitHash = latest.tags.find(t => t.name === 'Commit-Hash')?.value || 'unknown';
spinner.succeed(chalk.green(`✅ Switched to branch '${branchName}' successfully`));
console.log(chalk.gray(` Commit: ${commitHash.substring(0, 8)}`));
console.log(chalk.gray(` Transaction ID: ${latest.id}`));
if (mutableAddress) {
console.log(chalk.gray(` Mutable address: https://gateway.irys.xyz/mutable/${mutableAddress}`));
}
} catch (error) {
spinner.fail(`Branch switch failed`);
throw error;
}
}
function executeGitCommand(command, args) {
return new Promise((resolve, reject) => {
const gitProcess = spawn('git', [command, ...args], {
stdio: 'inherit',
shell: false
});
gitProcess.on('close', (code) => {
if (code === 0) {
resolve();
} else {
process.exit(code);
}
});
gitProcess.on('error', (error) => {
console.error(chalk.red(`❌ Failed to execute Git command: ${error.message}`));
reject(error);
});
});
}
// Fund wallet with required amount
async function fundWallet(uploader, requiredAmount) {
try {
const spinner = ora('Funding wallet...').start();
// Check initial balance to prevent duplicate funding
const initialBalance = await checkWalletBalance(uploader);
console.log(chalk.gray(` Initial balance: ${initialBalance.toFixed(6)} SOL`));
// Convert to atomic units for funding
const atomicAmount = Math.ceil(requiredAmount * 1e9); // SOL to lamports
spinner.text = 'Submitting fund transaction...';
let fundResult;
let confirmResult;
try {
// Fund the wallet
fundResult = await uploader.fund(atomicAmount);
spinner.text = 'Confirming fund transaction...';
// Submit the fund transaction with timeout
confirmResult = await Promise.race([
uploader.submitFundTransaction(fundResult),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Transaction confirmation timeout')), 30000)
)
]);
spinner.text = 'Verifying balance update...';
} catch (transactionError) {
spinner.text = 'Transaction may have completed, checking balance...';
console.log(chalk.yellow(`⚠️ Transaction confirmation issue: ${transactionError.message}`));
// Wait a bit longer and check if balance actually increased
await new Promise(resolve => setTimeout(resolve, 3000));
try {
const currentBalance = await checkWalletBalance(uploader);
const balanceIncrease = currentBalance - initialBalance;
if (balanceIncrease >= requiredAmount * 0.9) { // Allow 10% tolerance
spinner.succeed(chalk.green('✅ Wallet funded successfully (verified by balance)'));
console.log(chalk.blue('💰 Amount funded:'), `${balanceIncrease.toFixed(6)} SOL`);
console.log(chalk.gray(` Balance increased from ${initialBalance.toFixed(6)} to ${currentBalance.toFixed(6)} SOL`));
console.log(chalk.green('✅ Funding confirmed by balance verification'));
return currentBalance;
} else {
throw new Error(`Transaction failed - balance increase ${balanceIncrease.toFixed(6)} SOL is less than required ${requiredAmount.toFixed(6)} SOL`);
}
} catch (balanceCheckError) {
throw new Error(`Transaction confirmation failed and balance check failed: ${balanceCheckError.message}`);
}
}
// If transaction succeeded, wait for balance to update
spinner.text = 'Waiting for balance update...';
let newBalance = initialBalance;
let attempts = 0;
const maxAttempts = 10;
while (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 2000));
try {
newBalance = await checkWalletBalance(uploader);
const balanceIncrease = newBalance - initialBalance;
if (balanceIncrease >= requiredAmount * 0.9) { // Allow 10% tolerance
spinner.succeed(chalk.green('✅ Wallet funded successfully'));
console.log(chalk.blue('📄 Fund transaction ID:'), confirmResult.id);
console.log(chalk.blue('💰 Amount funded:'), `${balanceIncrease.toFixed(6)} SOL`);
console.log(chalk.gray(` Balance increased from ${initialBalance.toFixed(6)} to ${newBalance.toFixed(6)} SOL`));
console.log(chalk.green('✅ Balance update confirmed'));
return newBalance;
}
spinner.text = `Waiting for balance update... (${attempts + 1}/${maxAttempts})`;
attempts++;
} catch (balanceError) {
console.log(chalk.yellow(`⚠️ Balance check failed (attempt ${attempts + 1}): ${balanceError.message}`));
attempts++;
}
}
// Final balance check
const finalBalance = await checkWalletBalance(uploader);
const finalIncrease = finalBalance - initialBalance;
if (finalIncrease >= requiredAmount * 0.9) {
spinner.succeed(chalk.green('✅ Wallet funded successfully (final verification)'));
console.log(chalk.blue('📄 Fund transaction ID:'), confirmResult.id);
console.log(chalk.blue('💰 Amount funded:'), `${finalIncrease.toFixed(6)} SOL`);
console.log(chalk.gray(` Balance increased from ${initialBalance.toFixed(6)} to ${finalBalance.toFixed(6)} SOL`));
return finalBalance;
} else {
throw new Error(`Funding verification failed - expected increase: ${requiredAmount.toFixed(6)} SOL, actual: ${finalIncrease.toFixed(6)} SOL`);
}
} catch (error) {
console.error(chalk.red('❌ Wallet funding failed:'), error.message);
// Final safety check - if balance actually increased despite error
try {
const currentBalance = await checkWalletBalance(uploader);
console.log(chalk.blue(`🔍 Final balance check: ${currentBalance.toFixed(6)} SOL`));
return currentBalance;
} catch (finalError) {
console.error(chalk.red('❌ Final balance check also failed:'), finalError.message);
}
throw new Error(`Wallet funding failed: ${error.message}`);
}
}
async function estimateAndConfirmUploadCost(uploader, bundleSize) {
try {
const price = await uploader.getPrice(bundleSize);
const priceInSOL = price.toNumber() / 1e9; // lamports to SOL
console.log(chalk.blue(`📊 Estimated upload cost: ${priceInSOL.toFixed(6)} SOL`));
// Check wallet balance first
const balance = await checkWalletBalance(uploader);
if (balance < priceInSOL) {
const shortfall = priceInSOL - balance;
console.log(chalk.red(`❌ Insufficient balance for upload`));
console.log(chalk.yellow(`💡 Required: ${priceInSOL.toFixed(6)} SOL`));
console.log(chalk.yellow(`💡 Available: ${balance.toFixed(6)} SOL`));
console.log(chalk.yellow(`💡 Shortfall: ${shortfall.toFixed(6)} SOL`));
// Calculate safe funding amount to show user
let safeFundingAmount;
if (shortfall < MIN_FUNDING_AMOUNT) {
safeFundingAmount = MIN_FUNDING_AMOUNT;
console.log(chalk.blue(`💡 Will fund safe minimum: ${safeFundingAmount.toFixed(6)} SOL`));
console.log(chalk.gray(` (Shortfall ${shortfall.toFixed(6)} SOL < ${MIN_FUNDING_AMOUNT} SOL minimum)`));
} else {
safeFundingAmount = shortfall * FUNDING_SAFETY_MARGIN;
console.log(chalk.blue(`💡 Will fund with safety margin: ${safeFundingAmount.toFixed(6)} SOL`));
console.log(chalk.gray(` (Shortfall ${shortfall.toFixed(6)} SOL + 10% safety margin)`));
}
// Ask user if they want to fund the wallet automatically
const { autoFund } = await inquirer.prompt([{
type: 'confirm',
name: 'autoFund',
message: `Would you like to fund your wallet with ${safeFundingAmount.toFixed(6)} SOL to proceed?`,
default: true
}]);
if (!autoFund) {
console.log(chalk.cyan('\n🔗 Manual funding options:'));
console.log(chalk.cyan(` Address: ${uploader.address}`));
console.log(chalk.cyan(` You can fund via:
• SOL transfer from another wallet
• Exchange withdrawal to this address
• Faucet (if on devnet/testnet)`));
throw new Error('Upload cancelled - insufficient balance');
}
// Fund the wallet automatically
try {
// Double-check current balance before funding to prevent duplicate funding
const currentBalance = await checkWalletBalance(uploader);
if (currentBalance >= priceInSOL) {
console.log(chalk.green('✅ Balance is now sufficient - skipping funding'));
console.log(chalk.gray(` Current balance: ${currentBalance.toFixed(6)} SOL`));
console.log(chalk.gray(` Required: ${priceInSOL.toFixed(6)} SOL`));
return priceInSOL;
}
const actualShortfall = priceInSOL - currentBalance;
// Calculate safe funding amount
let fundingAmount;
if (actualShortfall < 0.001) {
// If shortfall is less than minimum, fund 0.001 SOL for safety
fundingAmount = 0.001;
} else {
// If shortfall is >= 0.001, fund 10% more than needed for safety
fundingAmount = actualShortfall * 1.1;
}
const newBalance = await fundWallet(uploader, fundingAmount);
if (newBalance < priceInSOL) {
console.log(chalk.yellow('⚠️ Funding completed but balance still insufficient'));
console.log(chalk.yellow(`💡 New balance: ${newBalance.toFixed(6)} SOL`));
console.log(chalk.yellow(`💡 Still needed: ${(priceInSOL - newBalance).toFixed(6)} SOL`));
throw new Error('Insufficient balance after funding');
}
} catch (fundError) {
console.log(chalk.red('❌ Auto-funding failed'));
// Check if balance actually increased despite error
try {
const finalBalance = await checkWalletBalance(uploader);
if (finalBalance >= priceInSOL) {
console.log(chalk.green('✅ Balance is sufficient despite funding error - proceeding'));
return priceInSOL;
}
} catch (finalCheckError) {
console.log(chalk.gray(' Final balance check failed'));
}
console.log(chalk.cyan('\n🔗 Manual funding required:'));
console.log(chalk.cyan(` Address: ${uploader.address}`));
console.log(chalk.cyan(` Required amount: ${shortfall.toFixed(6)} SOL`));
throw new Error(`Auto-funding failed: ${fundError.message}`);
}
}
if (priceInSOL > MAX_UPLOAD_COST_SOL) {
console.log(chalk.yellow(`⚠️ Upload cost exceeds the maximum allowed (${MAX_UPLOAD_COST_SOL} SOL).`));
const { proceed } = await inquirer.prompt([{
type: 'confirm',
name: 'proceed',
message: `Proceed with upload paying ${priceInSOL.toFixed(6)} SOL?`,
default: false
}]);
if (!proceed) {
throw new Error('Upload cancelled by user.');
}
} else {
console.log(chalk.green(`✅ Balance sufficient for upload`));
const currentBalance = await checkWalletBalance(uploader);
console.log(chalk.gray(` Remaining after upload: ${(currentBalance - priceInSOL).toFixed(6)} SOL`));
}
return priceInSOL;
} catch (error) {
if (error.message.includes('Insufficient balance') ||
error.message.includes('cancelled by user') ||
error.message.includes('cancelled - insufficient balance') ||
error.message.includes('Auto-funding failed')) {
throw error;
}
console.log(chalk.yellow('⚠️ Cost estimation failed, checking balance anyway...'));
console.log(chalk.gray(` Reason: ${error.message}`));
// Still check balance even if price estimation fails
try {
const balance = await checkWalletBalance(uploader);
if (balance < 0.001) { // Minimum 0.001 SOL for any upload
console.log(chalk.red(`❌ Wallet balance too low: ${balance.toFixed(6)} SOL`));
console.log(chalk.yellow(`💡 Minimum recommended: 0.001 SOL`));
// Calculate safe funding amount based on current balance
const currentBalance = await checkWalletBalance(uploader);
const minimumRequired = MIN_FUNDING_AMOUNT;
const shortfall = minimumRequired - currentBalance;
let safeFundingAmount;
if (shortfall < MIN_FUNDING_AMOUNT) {
safeFundingAmount = MIN_FUNDING_AMOUNT;
console.log(chalk.blue(`💡 Will fund safe minimum: ${safeFundingAmount.toFixed(6)} SOL`));
console.log(chalk.gray(` (Shortfall ${shortfall.toFixed(6)} SOL < ${MIN_FUNDING_AMOUNT} SOL minimum)`));
} else {
safeFundingAmount = shortfall * FUNDING_SAFETY_MARGIN;
console.log(chalk.blue(`💡 Will fund with safety margin: ${safeFundingAmount.toFixed(6)} SOL`));
console.log(chalk.gray(` (Shortfall ${shortfall.toFixed(6)} SOL + 10% safety margin)`));
}
// Ask for auto-funding even when estimation fails
const { autoFund } = await inquirer.prompt([{
type: 'confirm',
name: 'autoFund',
message: `Would you like to fund your wallet with ${safeFundingAmount.toFixed(6)} SOL to proceed?`,
default: true
}]);
if (autoFund) {
try {
// Double-check balance before funding
const currentBalance = await checkWalletBalance(uploader);
const minimumRequired = 0.001;
if (currentBalance >= minimumRequired) {
co