feat: add export car

This commit is contained in:
junchi.zhang
2026-01-10 16:39:12 +08:00
parent 1143290d96
commit 660a2dfc20
4 changed files with 359 additions and 1 deletions
+264
View File
@@ -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<string | null> {
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<boolean> {
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<void> => {
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);
}
};
View File
+9 -1
View File
@@ -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 <name>', 'Pinme subdomain')
.action(() => importFile());
program
.command('export')
.description('export IPFS content as CAR file')
.option('-o, --output <path>', '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 <path> --domain <name>');
console.log(' $ pinme import');
console.log(' $ pinme import <path> --domain <name>');
console.log(' $ pinme export <cid> --output <path>');
console.log(' $ pinme rm <hash>');
console.log(' $ pinme set-appkey <AppKey>');
console.log(' $ pinme show-appkey');
+86
View File
@@ -93,4 +93,90 @@ export async function getMyDomains(): Promise<MyDomainItem[]> {
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<CarExportResponse['data']> {
try {
const client = createCarClient();
// Use POST method as shown in the example
const { data } = await client.post<CarExportResponse>('/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<CarExportStatusResponse['data']> {
try {
const client = createCarClient();
const { data } = await client.get<CarExportStatusResponse>('/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}`);
}
}