const fs = require("fs"); const crypto = require("crypto"); const path = require("path"); const { spawn } = require("child_process"); const AdmZip = require("adm-zip"); const { resolveData } = require("./paths"); class DownloadManager { constructor(onEvent){ this.jobs=new Map(); this.onEvent=onEvent; } status(id){ return this.jobs.get(id)||null; } start({id,url,filename,sha256,kind,archive=false,size=0}){ if(this.jobs.get(id)?.state==="downloading") throw new Error("Download already running."); if(size&&freeDiskBytes(){const classified=classifyError(error);job.state="error";job.error=classified.message;job.error_category=classified.category;this.onEvent?.({kind:"download",status:"failed",download_id:id,error:job.error,category:classified.category});}); return job; } async download({job,url,filename,sha256,kind,archive}){ job.state="downloading"; const tmp=resolveData("tmp",`${filename}.part`), finalDir=resolveData(kind==="model"?"models":"runtime"); const existing=fs.existsSync(tmp)?fs.statSync(tmp).size:0; const headers=existing?{Range:`bytes=${existing}-`}:{}; const response=await fetch(url,{headers}); if(!response.ok && response.status!==206) throw new Error(`source unavailable (${response.status})`); const resumed=existing>0&&response.status===206; const total=Number(response.headers.get("content-length")||0)+(resumed?existing:0); job.total=total; job.downloaded=resumed?existing:0; const stream=fs.createWriteStream(tmp,{flags:resumed?"a":"w"}); for await(const chunk of response.body){ if(!stream.write(chunk)) await new Promise(r=>stream.once("drain",r)); job.downloaded+=chunk.length; } await new Promise((resolve,reject)=>stream.end(error=>error?reject(error):resolve())); job.state="verifying"; const actual=await hashFile(tmp); if(actual!==sha256.toLowerCase()){fs.unlinkSync(tmp);throw new Error("hash mismatch");} if(archive){ job.state="extracting"; const staging=resolveData("tmp",`runtime-extract-${Date.now()}`); fs.mkdirSync(staging,{recursive:true}); try{ await extractArchive(tmp,staging,filename); await makeRuntimeExecutable(staging); const executable=findRuntimeExecutable(staging); if(!executable)throw new Error("runtime executable missing after extraction"); for(const entry of fs.readdirSync(finalDir))fs.rmSync(path.join(finalDir,entry),{recursive:true,force:true}); for(const entry of fs.readdirSync(staging))fs.renameSync(path.join(staging,entry),path.join(finalDir,entry)); fs.unlinkSync(tmp); job.executable=findRuntimeExecutable(finalDir); }finally{ fs.rmSync(staging,{recursive:true,force:true}); } } else { const final=path.join(finalDir,filename); if(fs.existsSync(final))fs.unlinkSync(final); fs.renameSync(tmp,final); } job.state="complete";job.finished_at=Date.now();job.sha256=actual;this.onEvent?.({kind:"download",status:"success",download_id:job.id,sha256:actual,duration_ms:job.finished_at-job.started_at}); } } async function makeRuntimeExecutable(dir){ if(process.platform==="win32")return; for(const entry of fs.readdirSync(dir,{withFileTypes:true})){ const target=path.join(dir,entry.name); if(entry.isDirectory())await makeRuntimeExecutable(target); else if(entry.name==="llama-server")fs.chmodSync(target,0o755); } } function findRuntimeExecutable(dir){ const name=process.platform==="win32"?"llama-server.exe":"llama-server"; for(const entry of fs.readdirSync(dir,{withFileTypes:true})){ const target=path.join(dir,entry.name); if(entry.isFile()&&entry.name===name)return target; if(entry.isDirectory()){const found=findRuntimeExecutable(target);if(found)return found;} } return null; } async function hashFile(file){const hash=crypto.createHash("sha256");for await(const chunk of fs.createReadStream(file))hash.update(chunk);return hash.digest("hex");} async function extractArchive(file,dest,name){ if(name.endsWith(".zip")){ const zip=new AdmZip(file); for(const entry of zip.getEntries())validateArchivePath(entry.entryName); zip.extractAllTo(dest,true); return; } const entries=await capture("tar",["-tzf",file]); if(entries.code!==0)throw new Error(`archive corrupt (${entries.code})`); for(const entry of entries.stdout.split(/\r?\n/).filter(Boolean))validateArchivePath(entry); await new Promise((resolve,reject)=>{const child=spawn("tar",["-xzf",file,"-C",dest],{windowsHide:true,shell:false});child.on("exit",c=>c===0?resolve():reject(new Error(`archive extraction failed (${c})`)));child.on("error",reject);}); } function validateArchivePath(entry){ const normalized=path.posix.normalize(String(entry).replace(/\\/g,"/")); if(path.posix.isAbsolute(normalized)||normalized===".."||normalized.startsWith("../"))throw new Error("archive path traversal"); } function capture(command,args){return new Promise((resolve,reject)=>{const child=spawn(command,args,{windowsHide:true,shell:false});let stdout="",stderr="";child.stdout.on("data",c=>stdout+=c);child.stderr.on("data",c=>stderr+=c);child.on("error",reject);child.on("exit",code=>resolve({code,stdout,stderr}));});} function classifyError(error){ const message=error?.message||String(error); if(/ENOSPC|not enough disk/i.test(message))return{category:"disk_full",message:"Not enough disk space."}; if(/EACCES|EPERM|permission denied/i.test(message))return{category:"permission_denied",message:"Permission denied."}; if(/hash mismatch/i.test(message))return{category:"hash_mismatch",message:"Downloaded file failed SHA-256 verification."}; if(/archive path traversal/i.test(message))return{category:"archive_path_traversal",message:"Archive contains an unsafe path."}; if(/archive corrupt|extraction failed/i.test(message))return{category:"archive_corrupt",message}; if(/\(404\)/.test(message))return{category:"http_404",message:"Download source was not found (404)."}; if(/\(403\)/.test(message))return{category:"http_403",message:"Download source denied access (403)."}; if(/\(429\)/.test(message))return{category:"http_429",message:"Download source rate limit reached (429)."}; if(/\(5\d\d\)/.test(message))return{category:"server_error",message}; if(/timeout|abort/i.test(message))return{category:"timeout",message:"Download timed out."}; if(/fetch|network|ENOTFOUND|EAI_AGAIN/i.test(message))return{category:"network_unavailable",message}; if(/runtime executable missing/i.test(message))return{category:"install_validation_failed",message}; return{category:"download_failed",message}; } function freeDiskBytes(){try{const stat=fs.statfsSync(resolveData("tmp"));return Number(stat.bavail)*Number(stat.bsize);}catch{return Number.MAX_SAFE_INTEGER;}} module.exports={DownloadManager,hashFile,validateArchivePath,classifyError};