From f9c3f32a3a0d309e196c9bbbb4e3bcf283b4e40e Mon Sep 17 00:00:00 2001 From: "junchi.zhang" <435900020@qq.com> Date: Sat, 10 Jan 2026 16:39:12 +0800 Subject: [PATCH] feat: add export car --- bin/exportCar.ts | 264 ++++++++++++++++++++++++++++++++ bin/{import.ts => importCar.ts} | 0 bin/index.ts | 10 +- bin/utils/pinmeApi.ts | 86 +++++++++++ 4 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 bin/exportCar.ts rename bin/{import.ts => importCar.ts} (100%) diff --git a/bin/exportCar.ts b/bin/exportCar.ts new file mode 100644 index 0000000..b62532d --- /dev/null +++ b/bin/exportCar.ts @@ -0,0 +1,264 @@ +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 { requestCarExport, checkCarExportStatus } from './utils/pinmeApi'; +import { getUid } from './utils/getDeviceId'; +import { checkNodeVersion } from './utils/checkNodeVersion'; + +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 totalBytes = parseInt(response.headers['content-length'] || '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 system downloads directory +function getDownloadsDirectory(): string { + const homeDir = os.homedir(); + // macOS and Linux use ~/Downloads, Windows uses %USERPROFILE%\Downloads + return path.join(homeDir, 'Downloads'); +} + +// 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 path + let outputPath = getOutputPathFromArgs(); + if (!outputPath) { + const downloadsDir = getDownloadsDirectory(); + const defaultFileName = `${cid}.car`; + const defaultPath = path.join(downloadsDir, defaultFileName); + const answer = await inquirer.prompt([ + { + type: 'input', + name: 'output', + message: `Output file path (default: ${defaultPath}): `, + default: defaultPath, + }, + ]); + outputPath = answer.output.trim() || defaultPath; + } + + // Convert to absolute path + outputPath = path.resolve(outputPath); + + // Check if output directory exists + const outputDir = path.dirname(outputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Check if file already exists + if (fs.existsSync(outputPath)) { + const answer = await inquirer.prompt([ + { + type: 'confirm', + name: 'overwrite', + message: `File ${outputPath} 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) { + console.log(chalk.red('Export failed or timed out.')); + return; + } + + // Step 3: Download CAR file + const success = await downloadCarFile(downloadUrl, outputPath); + if (success) { + const fileSize = fs.statSync(outputPath).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: ${outputPath}`)); + console.log(chalk.cyan(`Size: ${fileSizeMB} MB`)); + console.log(chalk.cyan(`CID: ${cid}`)); + } else { + console.log(chalk.red('Download failed.')); + } + } catch (error: any) { + spinner.fail(`Error: ${error.message}`); + console.error(chalk.red(`Export error: ${error.message}`)); + } + } catch (error: any) { + console.error(chalk.red(`error executing: ${error.message}`)); + console.error(error.stack); + } +}; + diff --git a/bin/import.ts b/bin/importCar.ts similarity index 100% rename from bin/import.ts rename to bin/importCar.ts diff --git a/bin/index.ts b/bin/index.ts index 634ebeb..0efa084 100644 --- a/bin/index.ts +++ b/bin/index.ts @@ -11,7 +11,8 @@ import figlet from 'figlet'; import { version } from '../package.json'; import upload from './upload'; -import importFile from './import'; +import importFile from './importCar'; +import exportFile from './exportCar'; import remove from './remove'; import { displayUploadHistory, clearUploadHistory } from './utils/history'; import setAppKeyCmd from './set-appkey'; @@ -48,6 +49,12 @@ program .option('-d, --domain ', 'Pinme subdomain') .action(() => importFile()); +program + .command('export') + .description('export IPFS content as CAR file') + .option('-o, --output ', 'output file path for CAR file') + .action(() => exportFile()); + program .command('rm') .description('remove a file from IPFS network') @@ -134,6 +141,7 @@ program.on('--help', () => { console.log(' $ pinme upload --domain '); console.log(' $ pinme import'); console.log(' $ pinme import --domain '); + console.log(' $ pinme export --output '); console.log(' $ pinme rm '); console.log(' $ pinme set-appkey '); console.log(' $ pinme show-appkey'); diff --git a/bin/utils/pinmeApi.ts b/bin/utils/pinmeApi.ts index 3135af4..d99f43a 100644 --- a/bin/utils/pinmeApi.ts +++ b/bin/utils/pinmeApi.ts @@ -93,4 +93,90 @@ export async function getMyDomains(): Promise { return []; } +// CAR Export API +const CAR_API_BASE = process.env.CAR_API_BASE || process.env.PINME_API_BASE || 'http://ipfs-proxy.opena.chat/api/v3'; + +function createCarClient(): AxiosInstance { + let headers = {}; + try { + headers = getAuthHeaders(); + } catch (e) { + // Auth not required for some endpoints, continue without auth headers + } + return axios.create({ + baseURL: CAR_API_BASE, + timeout: 20000, + headers: { + ...headers, + Accept: '*/*', + 'Content-Type': 'application/json', + 'User-Agent': 'Pinme-CLI', + Connection: 'keep-alive', + }, + }); +} + +export interface CarExportResponse { + code: number; + msg: string; + data: { + cid: string; + status: string; + task_id: string; + }; +} + +export interface CarExportStatusResponse { + code: number; + msg: string; + data: { + task_id: string; + cid: string; + status: 'processing' | 'completed' | 'failed'; + download_url?: string; + }; +} + +export async function requestCarExport(cid: string, uid: string): Promise { + try { + const client = createCarClient(); + // Use POST method as shown in the example + const { data } = await client.post('/car/export', null, { + params: { + cid, + uid, + }, + }); + if (data?.code === 200 && data?.data) { + return data.data; + } + throw new Error(data?.msg || 'Failed to request CAR export'); + } catch (e: any) { + if (e.response?.data?.msg) { + throw new Error(e.response.data.msg); + } + throw new Error(`Failed to request CAR export: ${e?.message || e}`); + } +} + +export async function checkCarExportStatus(taskId: string): Promise { + try { + const client = createCarClient(); + const { data } = await client.get('/car/export/status', { + params: { + task_id: taskId, + }, + }); + if (data?.code === 200 && data?.data) { + return data.data; + } + throw new Error(data?.msg || 'Failed to check export status'); + } catch (e: any) { + if (e.response?.data?.msg) { + throw new Error(e.response.data.msg); + } + throw new Error(`Failed to check export status: ${e?.message || e}`); + } +} +