import path from 'path'; import os from 'os'; import chalk from 'chalk'; import inquirer from 'inquirer'; import figlet from 'figlet'; import fs from 'fs'; import axios from 'axios'; import ora from 'ora'; import { printCliError } from './utils/cliError'; import { requestCarExport, checkCarExportStatus } from './utils/pinmeApi'; import { getUid } from './utils/getDeviceId'; import { checkNodeVersion } from './utils/checkNodeVersion'; import tracker, { getTrackErrorReason } from './utils/tracker'; import { TRACK_EVENTS, TRACK_PAGES, resolveTrackAction, } from './utils/trackerEvents'; checkNodeVersion(); // Polling configuration const POLL_INTERVAL = 5000; // 5 seconds const MAX_POLL_TIME = 30 * 60 * 1000; // 30 minutes // Poll export status until completion async function pollExportStatus( taskId: string, cid: string, spinner: ora.Ora, startTime: number, ): Promise { while (Date.now() - startTime < MAX_POLL_TIME) { try { const status = await checkCarExportStatus(taskId); if (status.status === 'completed' && status.download_url) { spinner.succeed(`Export completed for CID: ${cid}`); return status.download_url; } else if (status.status === 'failed') { spinner.fail(`Export failed for CID: ${cid}`); return null; } else if (status.status === 'processing') { const elapsed = Math.floor((Date.now() - startTime) / 1000); const minutes = Math.floor(elapsed / 60); const seconds = elapsed % 60; spinner.text = `Exporting CAR file... (${minutes}m ${seconds}s)`; } } catch (error: any) { console.log(chalk.yellow(`Polling error: ${error.message}`)); } // Wait before next poll await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)); } const maxPollTimeMinutes = Math.floor(MAX_POLL_TIME / (60 * 1000)); spinner.fail(`Export timeout after ${maxPollTimeMinutes} minutes`); return null; } // Download CAR file from URL async function downloadCarFile( downloadUrl: string, outputPath: string, ): Promise { try { const spinner = ora(`Downloading CAR file to ${outputPath}...`).start(); const response = await axios({ method: 'GET', url: downloadUrl, responseType: 'stream', timeout: 1800000, // 30 minutes timeout }); const writer = fs.createWriteStream(outputPath); let downloadedBytes = 0; const contentLength = response.headers['content-length']; const totalBytes = parseInt( typeof contentLength === 'string' ? contentLength : '0', 10, ); response.data.on('data', (chunk: Buffer) => { downloadedBytes += chunk.length; if (totalBytes > 0) { const progress = (downloadedBytes / totalBytes) * 100; spinner.text = `Downloading CAR file... ${progress.toFixed(1)}%`; } }); response.data.pipe(writer); return new Promise((resolve, reject) => { writer.on('finish', () => { spinner.succeed(`CAR file downloaded successfully: ${outputPath}`); resolve(true); }); writer.on('error', (error) => { spinner.fail(`Download failed: ${error.message}`); reject(error); }); }); } catch (error: any) { console.error(chalk.red(`Download error: ${error.message}`)); return false; } } // Validate CID format (basic check) function isValidCID(cid: string): boolean { // Basic CID validation - should start with 'Qm' (CIDv0) or 'bafy' (CIDv1) return /^(Qm|bafy|bafk|bafz)/.test(cid); } // Get CID from command line arguments function getCidFromArgs(): string | null { const args = process.argv.slice(2); const cidIdx = args.findIndex((a) => a === 'export') + 1; if (cidIdx > 0 && args[cidIdx] && !args[cidIdx].startsWith('-')) { return args[cidIdx].trim(); } return null; } // Get current working directory function getCurrentDirectory(): string { return process.cwd(); } // Get output path from command line arguments function getOutputPathFromArgs(): string | null { const args = process.argv.slice(2); const outputIdx = args.findIndex((a) => a === '--output' || a === '-o'); if (outputIdx >= 0 && args[outputIdx + 1] && !args[outputIdx + 1].startsWith('-')) { return String(args[outputIdx + 1]).trim(); } return null; } export default async (): Promise => { try { console.log( figlet.textSync('PINME EXPORT', { font: 'Standard', horizontalLayout: 'default', verticalLayout: 'default', width: 180, whitespaceBreak: true, }), ); // Get CID from arguments or prompt let cid = getCidFromArgs(); if (!cid) { const answer = await inquirer.prompt([ { type: 'input', name: 'cid', message: 'Enter CID to export: ', validate: (input: string) => { if (!input.trim()) { return 'CID cannot be empty'; } if (!isValidCID(input.trim())) { return 'Invalid CID format. CID should start with Qm, bafy, bafk, or bafz'; } return true; }, }, ]); cid = answer.cid.trim(); } if (!cid || !isValidCID(cid)) { console.log(chalk.red('Invalid CID format. CID should start with Qm, bafy, bafk, or bafz')); return; } // Get output directory (output parameter is always a directory, not a file) let outputDir = getOutputPathFromArgs(); if (!outputDir) { // Default to current directory const currentDir = getCurrentDirectory(); const answer = await inquirer.prompt([ { type: 'input', name: 'output', message: `Output directory (default: ${currentDir}): `, default: currentDir, }, ]); outputDir = answer.output.trim() || currentDir; } // Convert to absolute path outputDir = path.resolve(outputDir); // Create directory if it doesn't exist if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } else if (!fs.statSync(outputDir).isDirectory()) { // If path exists but is not a directory, show error console.log(chalk.red(`Error: ${outputDir} exists but is not a directory.`)); return; } // Final output path is always {outputDir}/{cid}.car const finalOutputPath = path.join(outputDir, `${cid}.car`); // Check if file already exists if (fs.existsSync(finalOutputPath)) { const answer = await inquirer.prompt([ { type: 'confirm', name: 'overwrite', message: `File ${finalOutputPath} already exists. Overwrite?`, default: false, }, ]); if (!answer.overwrite) { console.log(chalk.blue('Export cancelled.')); return; } } // Get UID const uid = getUid(); // Step 1: Request export const spinner = ora(`Requesting CAR export for CID: ${cid}...`).start(); try { const exportResponse = await requestCarExport(cid, uid); spinner.succeed(`Export task created: ${exportResponse.task_id}`); // Step 2: Poll for status const pollSpinner = ora('Waiting for export to complete...').start(); const startTime = Date.now(); const downloadUrl = await pollExportStatus( exportResponse.task_id, cid, pollSpinner, startTime, ); if (!downloadUrl) { void tracker.trackEvent(TRACK_EVENTS.exportFailed, TRACK_PAGES.export, { a: resolveTrackAction(TRACK_EVENTS.exportFailed), cid, reason: 'export_failed_or_timed_out', }); console.log(chalk.red('Export failed or timed out.')); return; } // Step 3: Download CAR file const success = await downloadCarFile(downloadUrl, finalOutputPath); if (success) { void tracker.trackEvent(TRACK_EVENTS.exportSuccess, TRACK_PAGES.export, { a: resolveTrackAction(TRACK_EVENTS.exportSuccess), cid, output_dir: outputDir, }); const fileSize = fs.statSync(finalOutputPath).size; const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2); console.log( chalk.cyan( figlet.textSync('Successful', { horizontalLayout: 'full' }), ), ); console.log(chalk.green(`\nšŸŽ‰ Export successful!`)); console.log(chalk.cyan(`File: ${finalOutputPath}`)); console.log(chalk.cyan(`Size: ${fileSizeMB} MB`)); console.log(chalk.cyan(`CID: ${cid}`)); } else { void tracker.trackEvent(TRACK_EVENTS.exportFailed, TRACK_PAGES.export, { a: resolveTrackAction(TRACK_EVENTS.exportFailed), cid, reason: 'download_failed', }); console.log(chalk.red('Download failed.')); } } catch (error: any) { void tracker.trackEvent(TRACK_EVENTS.exportFailed, TRACK_PAGES.export, { a: resolveTrackAction(TRACK_EVENTS.exportFailed), cid, reason: getTrackErrorReason(error), }); spinner.fail(`Error: ${error.message}`); printCliError(error, 'Export failed.'); } } catch (error: any) { void tracker.trackEvent(TRACK_EVENTS.exportFailed, TRACK_PAGES.export, { a: resolveTrackAction(TRACK_EVENTS.exportFailed), reason: getTrackErrorReason(error), }); printCliError(error, 'Export failed.'); } };