110 lines
7.3 KiB
JavaScript
110 lines
7.3 KiB
JavaScript
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,runtimeMetadata=null}){
|
|
if(this.jobs.get(id)?.state==="downloading") throw new Error("Download already running.");
|
|
if(size&&freeDiskBytes()<size*1.2)throw new Error("not enough disk space");
|
|
const job={id,state:"queued",downloaded:0,total:0,error:null,started_at:Date.now()};this.jobs.set(id,job);
|
|
this.download({job,url,filename,sha256,kind,archive,runtimeMetadata}).catch(error=>{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,runtimeMetadata}){
|
|
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.writeFileSync(path.join(finalDir,"lumi-runtime.json"),`${JSON.stringify({
|
|
backend: runtimeMetadata?.backend || "cpu",
|
|
version: runtimeMetadata?.version || null,
|
|
target: runtimeMetadata?.target || filename,
|
|
installed_at: new Date().toISOString()
|
|
},null,2)}\n`);
|
|
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};
|