Skip to content

Instantly share code, notes, and snippets.

@b0o
Created December 30, 2025 05:38
Show Gist options
  • Select an option

  • Save b0o/8b935b7b165725108ba8ee4033d76ba0 to your computer and use it in GitHub Desktop.

Select an option

Save b0o/8b935b7b165725108ba8ee4033d76ba0 to your computer and use it in GitHub Desktop.
Lovable Project Downloader
// ==UserScript==
// @name Lovable Project Downloader
// @license MIT
// @namespace https://lovable.dev
// @version 2.0.0
// @description Download entire Lovable project
// @match https://lovable.dev/projects/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=lovable.dev
// @connect api.lovable.dev
// @grant GM_registerMenuCommand
// ==/UserScript==
(function() {
'use strict';
const API_BASE_URL = "https://api.lovable.dev";
const JWT_REGEX = /eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/;
const JWT_REGEX_STRICT = /^eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/;
// --- Logging ---
const log = {
prefix: '[Lovable DL]',
info: (msg, ...args) => console.log(`${log.prefix} ${msg}`, ...args),
error: (msg, ...args) => console.error(`${log.prefix} ${msg}`, ...args),
};
// --- Utility Functions ---
function getProjectIdFromUrl() {
const match = window.location.pathname.match(/\/projects\/([a-f0-9-]+)/i);
return match?.[1] || null;
}
function base64ToArrayBuffer(base64) {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
function triggerDownload(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
log.info(`Download triggered: ${filename}`);
}
// --- Token Extraction ---
function validateJwt(jwt) {
return jwt && JWT_REGEX_STRICT.test(jwt);
}
function extractJwtFromString(str) {
if (!str) return null;
const match = str.match(JWT_REGEX);
return match?.[0] || null;
}
function getTokenFromNextData() {
if (typeof __next_f === 'undefined') return null;
for (let i = 0; i < __next_f.length; i++) {
const content = __next_f[i]?.[1];
if (typeof content === 'string' && content.includes('eyJ')) {
const jwt = extractJwtFromString(content);
if (validateJwt(jwt)) {
log.info(`Token found in __next_f[${i}]`);
return jwt;
}
}
}
return null;
}
function getTokenFromLocalStorage() {
const keys = ['rl_session', 'rl_trait'];
for (const key of keys) {
try {
const value = localStorage.getItem(key);
if (value) {
try {
const parsed = JSON.parse(value);
const tokenStr = JSON.stringify(parsed);
const jwt = extractJwtFromString(tokenStr);
if (validateJwt(jwt)) {
log.info(`Token found in localStorage[${key}]`);
return jwt;
}
} catch {
const jwt = extractJwtFromString(value);
if (validateJwt(jwt)) {
log.info(`Token found in localStorage[${key}]`);
return jwt;
}
}
}
} catch (e) {
// localStorage access error
}
}
return null;
}
function getTokenFromScripts() {
const scripts = document.querySelectorAll('script');
const regex = /\\"idToken\\":\\"([A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+)\\"/;
for (const script of scripts) {
const content = script.textContent;
if (content) {
const match = content.match(regex);
if (match?.[1] && validateJwt(match[1])) {
log.info("Token found in script tag");
return match[1];
}
}
}
return null;
}
function getIdToken() {
return getTokenFromNextData() || getTokenFromLocalStorage() || getTokenFromScripts();
}
// --- TAR Library (inline, zero dependencies) ---
// Minimal tar implementation based on USTAR format
function createTar(files) {
const encoder = new TextEncoder();
const chunks = [];
for (const file of files) {
const nameBytes = encoder.encode(file.name);
if (nameBytes.length > 100) {
log.error(`Filename too long, skipping: ${file.name}`);
continue;
}
const content = file.binary
? new Uint8Array(base64ToArrayBuffer(file.contents))
: encoder.encode(file.contents);
const size = content.length;
// Create 512-byte header
const header = new Uint8Array(512);
// File name (0-99)
header.set(nameBytes, 0);
// File mode (100-107) - "0000644\0"
header.set(encoder.encode('0000644\0'), 100);
// UID (108-115) - "0000000\0"
header.set(encoder.encode('0000000\0'), 108);
// GID (116-123) - "0000000\0"
header.set(encoder.encode('0000000\0'), 116);
// File size in octal (124-135)
const sizeOctal = size.toString(8).padStart(11, '0') + '\0';
header.set(encoder.encode(sizeOctal), 124);
// Mtime (136-147) - current time in octal
const mtime = Math.floor(Date.now() / 1000).toString(8).padStart(11, '0') + '\0';
header.set(encoder.encode(mtime), 136);
// Checksum placeholder (148-155) - spaces for calculation
header.set(encoder.encode(' '), 148);
// Type flag (156) - '0' for regular file
header[156] = 48; // '0'
// USTAR indicator (257-262)
header.set(encoder.encode('ustar\0'), 257);
// USTAR version (263-264)
header.set(encoder.encode('00'), 263);
// Calculate checksum
let checksum = 0;
for (let i = 0; i < 512; i++) {
checksum += header[i];
}
const checksumOctal = checksum.toString(8).padStart(6, '0') + '\0 ';
header.set(encoder.encode(checksumOctal), 148);
chunks.push(header);
chunks.push(content);
// Pad content to 512-byte boundary
const padding = 512 - (size % 512);
if (padding < 512) {
chunks.push(new Uint8Array(padding));
}
}
// End of archive - two 512-byte blocks of zeros
chunks.push(new Uint8Array(1024));
// Combine all chunks
const totalSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return new Blob([result], { type: 'application/x-tar' });
}
// --- Main Download Function ---
async function downloadProject() {
log.info("Starting project download...");
const projectId = getProjectIdFromUrl();
if (!projectId) {
alert("Could not find project ID in URL.");
return;
}
log.info(`Project ID: ${projectId}`);
const idToken = getIdToken();
if (!idToken) {
alert("Authentication token not found. Make sure you're logged in.");
return;
}
log.info("Auth token found.");
// Fetch project details
log.info("Fetching project details...");
let projectDetails;
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/details`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${idToken}` },
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
projectDetails = await response.json();
log.info(`Project: ${projectDetails.display_name}`);
} catch (error) {
log.error("Failed to fetch project details:", error);
alert(`Failed to fetch project details: ${error.message}`);
return;
}
const displayName = projectDetails.display_name || 'project';
const shaShort = (projectDetails.preview_build_commit_sha || '').substring(0, 7);
log.info("Fetching project source code...");
let sourceData;
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/source-code`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${idToken}` },
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
sourceData = await response.json();
log.info(`Fetched ${sourceData.files?.length || 0} files.`);
} catch (error) {
log.error("Fetch failed:", error);
alert(`Failed to fetch project data: ${error.message}`);
return;
}
if (!sourceData.files || sourceData.files.length === 0) {
alert("No files found in project.");
return;
}
// Create TAR
log.info("Creating TAR archive...");
try {
const files = sourceData.files
.filter(f => f.contents !== undefined)
.map(f => ({
name: `${displayName}/${f.name}`,
contents: f.contents,
binary: !!f.binary
}));
log.info(`Packaging ${files.length} files...`);
const tarBlob = createTar(files);
log.info(`TAR created, size: ${tarBlob.size} bytes`);
const filename = `Lovable - ${displayName} ${shaShort}.tar`;
triggerDownload(tarBlob, filename);
log.info("Download complete!");
} catch (error) {
log.error("TAR creation failed:", error);
alert(`Failed to create TAR: ${error.message}`);
}
}
// --- Register Menu Command ---
GM_registerMenuCommand("📥 Download Project", downloadProject);
log.info("Script loaded. Use Tampermonkey menu to download project.");
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment