UNPKG

irys-git

Version:

Irys based git-like CLI tool for decentralized repository management

1,365 lines (1,131 loc) 149 kB
#!/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