vercel-source-recovery
Version:
Download and recover source files from your Vercel deployments. Uses Vercel API v8 for reliable source code recovery.
227 lines (193 loc) • 8.73 kB
JavaScript
import axios from 'axios';
import colors from 'colors';
import fsExtra from 'fs-extra';
import { join } from 'path';
export const getAuthToken = token => token.includes('Bearer') || token.includes('bearer')
? token[0].toUpperCase() + token.slice(1)
: `bearer ${token}`;
export const appendTeamId = (url, teamId, symbol = '?') => teamId ? `${url}${symbol}teamId=${teamId}` : url;
const generateFile = async (fileIdentifier, currentPath, env, actualFileName) => {
// Use actualFileName if provided (for source files with UIDs), otherwise use fileIdentifier
const fileName = actualFileName || fileIdentifier;
// For source files, build the full path from root
// Need to include the full path from the deployment root
let filePath = fileName;
// Get the path relative to the output directory
// Need to normalize paths for comparison
const normalizedCurrent = currentPath.replace(/^\.\//, '');
const normalizedOutput = env.OUTPUT_DIRECTORY.replace(/^\.\//, '');
let relativePath = '';
if (normalizedCurrent.startsWith(normalizedOutput)) {
relativePath = normalizedCurrent.substring(normalizedOutput.length);
// Remove leading slash if present
if (relativePath.startsWith('/')) {
relativePath = relativePath.substring(1);
}
}
// Build the full file path
if (relativePath) {
filePath = `${relativePath}/${fileName}`;
}
console.log(colors.gray(` Debug: currentPath=${currentPath}, outputDir=${env.OUTPUT_DIRECTORY}, relativePath=${relativePath}, filePath=${filePath}`));
// For source files from Vercel, use the v8 API endpoint
// We can use either the file UID or the path
const isUid = fileIdentifier.match(/^[a-f0-9]{40}$/);
let url;
if (isUid) {
// Use UID-based endpoint for v8 API
url = `https://vercel.com/api/v8/deployments/${env.DEPLOYMENT_UID}/files/${fileIdentifier}${env.TEAM_ID ? `?teamId=${env.TEAM_ID}` : ''}`;
} else {
// Use path-based endpoint with the path parameter
url = `https://vercel.com/api/v8/deployments/${env.DEPLOYMENT_UID}/files?path=${encodeURIComponent(filePath)}${env.TEAM_ID ? `&teamId=${env.TEAM_ID}` : ''}`;
}
console.log(colors.blue(` Trying URL: ${url} (${isUid ? 'UID-based' : 'path-based'})`));
const savePath = join(currentPath, fileName);
try {
// Check if a directory with this name already exists
if (fsExtra.existsSync(savePath) && fsExtra.statSync(savePath).isDirectory()) {
console.log(colors.yellow(`Skipping ${fileName} (directory already exists with this name)`));
return;
}
console.log(colors.yellow('Downloading file: '), colors.cyan(fileName), colors.yellow(' to path: '), colors.cyan(savePath));
const response = await axios.get(url, {
headers: {
Authorization: env.AUTHORIZATION_TOKEN
}
});
// The v8 API returns file content as base64 encoded in a JSON response
let fileContent;
if (response.data && typeof response.data === 'object' && response.data.data) {
// If response contains base64 encoded data
fileContent = Buffer.from(response.data.data, 'base64');
fsExtra.writeFileSync(savePath, fileContent);
} else if (typeof response.data === 'string') {
// If response is a string (might be base64)
try {
fileContent = Buffer.from(response.data, 'base64');
fsExtra.writeFileSync(savePath, fileContent);
} catch {
// If not base64, write as is
fsExtra.writeFileSync(savePath, response.data);
}
} else {
// Direct file content
fsExtra.writeFileSync(savePath, response.data);
}
console.log(colors.green(` Successfully downloaded ${fileName}`));
} catch (err) {
if (err.response && err.response.status === 404) {
console.log(colors.yellow(`Skipping ${fileName} (404 - file not found)`));
console.log(colors.yellow(` Tried URL: ${url}`));
} else if (err.response && err.response.status === 410) {
console.log(colors.yellow(`Skipping ${fileName} (410 - gone/unavailable)`));
console.log(colors.yellow(` This might be a compiled file or lambda function`));
} else {
console.log(colors.red(`Cannot download ${fileName}. Error: ${err.message}`));
if (err.response) {
console.log(colors.red(` Status: ${err.response.status}`));
// Don't try to stringify the entire response data, it might have circular references
if (err.response.data && typeof err.response.data === 'object') {
console.log(colors.red(` Data: ${err.response.data.error || err.response.data.message || 'No error message'}`));
}
}
console.log(colors.red(` URL: ${url}`));
}
}
};
const generateDirectory = (path) => {
try {
fsExtra.mkdirpSync(path);
} catch (err) {
console.log(colors.red(`Cannot write directory on path: ${path} !`));
process.exit();
}
};
const parseCurrent = async (node, currentPath, env) => {
if (!node || !node.name) {
console.log(colors.yellow('Skipping invalid node without name'));
return;
}
// Skip 'out' directory entirely
if (node.name === 'out') {
console.log(colors.yellow(`Skipping 'out' directory and all its contents`));
return;
}
console.log(colors.cyan(`Processing: ${node.name} (type: ${node.type}, uid: ${node.uid || 'none'}, has children: ${!!node.children})`));
// Skip lambda functions and edge functions
if (node.type === 'lambda' || node.type === 'edge') {
console.log(colors.yellow(` Skipping ${node.type} function: ${node.name}`));
return;
}
if (node.type === 'directory' || node.children) {
generateDirectory(join(currentPath, node.name));
if (node.children && Array.isArray(node.children)) {
console.log(colors.yellow(` Processing ${node.children.length} children of ${node.name}`));
await parseStructure(node.children, join(currentPath, node.name), env);
} else {
console.log(colors.red(` No children array found for directory ${node.name}`));
}
} else if (node.type === 'file') {
// For source files, we need to use the UID if available
const fileIdentifier = node.uid || node.name;
console.log(colors.green(` Attempting to download file: ${node.name} with uid: ${node.uid}`));
await generateFile(fileIdentifier, currentPath, env, node.name);
} else {
console.log(colors.yellow(`Unknown node type '${node.type}' for ${node.name}, skipping`));
}
};
export const parseStructure = async (folderStructure, currentPath, env) => {
if (folderStructure) {
for (const structure of folderStructure) {
await parseCurrent(structure, currentPath, env);
}
}
};
// Process a flat list of files (for source files API)
export const processFilesList = async (filesList, outputDirectory, env) => {
if (!filesList || !Array.isArray(filesList)) return;
// Filter out 'out' directory files - only keep source files
const sourceFiles = filesList.filter(file => {
const path = file.file || file.name || file;
return !path.toString().startsWith('out/');
});
console.log(colors.cyan(`Processing ${sourceFiles.length} source files (filtered from ${filesList.length} total)...`));
for (const file of sourceFiles) {
if (typeof file === 'string') {
// Simple file path
const parts = file.split('/');
const fileName = parts.pop();
const dirPath = join(outputDirectory, ...parts);
// Create directory structure
if (parts.length > 0) {
fsExtra.mkdirpSync(dirPath);
}
// Download file
await generateFile(file, outputDirectory, env);
} else if (file.file) {
// v2 API format: {file: "path/to/file", sha: "...", mode: "...", uid: "..."}
const filePath = file.file;
const parts = filePath.split('/');
const fileName = parts.pop();
const dirPath = join(outputDirectory, ...parts);
console.log(colors.green(` Processing file: ${filePath} (uid: ${file.uid})`));
// Create directory structure
if (parts.length > 0) {
fsExtra.mkdirpSync(dirPath);
}
// Download file using UID
const fileIdentifier = file.uid || filePath;
await generateFile(fileIdentifier, dirPath, env, fileName);
} else if (file.name) {
// Alternative format with name property
const parts = file.name.split('/');
const fileName = parts.pop();
const dirPath = join(outputDirectory, ...parts);
// Create directory structure
if (parts.length > 0) {
fsExtra.mkdirpSync(dirPath);
}
// Download file
await generateFile(file.uid || file.name, dirPath, env, fileName);
}
}
};