Files
2026-07-13 12:30:28 +08:00

301 lines
9.3 KiB
TypeScript

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<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 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<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 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.');
}
};