#!/usr/bin/env node /* jshint esversion: 8,-W097, -W040, node: true, expr: true, undef: true */ const /* dependencies */ [ fs, readline, https, { spawn } ]= [ "fs", "readline", "https", "child_process" ].map(p=> require(p)); const /* helper for coloring console | main program params */ colors= { e: "\x1b[38;2;252;76;76m", s: "\x1b[38;2;76;252;125m", w: "\x1b[33m", R: "\x1b[0m", y: "\x1b[38;2;200;190;90m", g: "\x1b[38;2;150;150;150m" }, info= { name: __filename.slice(__filename.lastIndexOf("/")+1, __filename.lastIndexOf(".")), version: "1.2.1", description: "Helper for working with “packages” stored in GitHub releases.", config: `${__filename.slice(0, __filename.lastIndexOf("."))}.json`, folder: __filename.slice(0, __filename.lastIndexOf("/")+1), commands: [ { cmd: "help", args: [ "--help", "-h" ], desc: "Shows this text" }, { cmd: "config", args: [ "--config" ], desc: "Opens config file in terminal editor (defaults to vim)" }, { cmd: "check", args: [ "--check", "-c" ], desc: "Shows/checks updates for registered packages" }, { cmd: "update", args: [ "--update", "-u" ], param: "group", desc: "Installs lates versions of registered packages" }, { cmd: "uninstall", args: [ "--uninstall", "-u" ], param: "package", desc: "Deletes downloaded file and moves package to the 'skip' group" }, { cmd: "register", args: [ "--register", "--change" ], param: "package", desc: "Add package infos to internal list to be able installing/updating" }, { cmd: "remove", args: [ "--remove" ], param: "package", desc: ([ "Uninstall package if needed (see `-u`)", "And remove it from internal list (see `--config`)" ]).join(". ") } ], params: { group: ([ "You can label each package to update only choosen one", "There are sereved options:", " - '' (empty): these packages are includes in all groups", " - 'all': in case of `--update` process all packages (except skipped)", " - 'skip': these packages are “uninstalled”", " No updates will be downloaded", "Group can be setted via '--register'" ]).join(". "), package: ([ "Represents package identificator, it is in fact GitHub repository path", "So, it schould be in the form `username/repository`" ]).join(". ") } }; printMain(); const current= getCurrent(process.argv.slice(2)); (function main_(){ const { cmd }= current.command; if(!cmd) return Promise.resolve("No arguments (use `--help` for showing all oprions)."); switch(cmd){ case "help": return Promise.resolve(printHelp()); case "config": return vim_(info.config); } const config= getConfig(); switch(cmd){ case "register": return register_(config); } if(!config.packages) return Promise.resolve("No packages yet!"); switch(cmd){ case "check": return check_(config); case "update": return update_(config); case "uninstall": case "remove": return uninstall_(cmd, config); } })() .then(function(message){ if(message) log(1, `Operation '${current.command.cmd}' successfull: @s_${message}`); process.exit(); }) .catch(error); async function uninstall_(cmd, config){ const progress= [ [ "Deleting file", "not needed" ], [ "Check out from updates", "yes" ], [ "Remove from packages list", "no" ] ]; const pkg_name= current.param; const pkg_index= config.packages.findIndex(({ repository })=> repository===pkg_name); if(pkg_index===-1) return "nothing to do (maybe typo)"; const pkg= config.packages[pkg_index]; const { downloads }= pkg; if(downloads&&fs.existsSync(downloads)){ try{ fs.unlinkSync(downloads); progress[0][1]= "done"; } catch (_){ progress[0][1]= colors.e+"error, try manually – "+downloads; } } Reflect.deleteProperty(pkg, "last_update"); Reflect.set(pkg, "group", "skip"); progress[1][1]= "done"; if(cmd!=="remove") return gotoEnd(); const y= await promt_(`Are you realy want to remove package ${pkg.repository} (yes/no)`, "no"); if(y!=="yes") return gotoEnd(); config.packages.splice(pkg_index, 1); progress[2][1]= "done"; return gotoEnd(); function gotoEnd(){ const o= progress.reduce((o, [ k, v ])=> Reflect.set(o, k, v)&&o, {}); logSection(" ", pkg_name, o); save(config); } } function vim_(file){ return new Promise(function(resolve, reject){ const cmd= spawn((process.env.EDITOR||"vim")+(process.platform==="win32"?".bat":""), [ file ], { stdio: 'inherit' }); cmd.on('exit', e=> e ? reject("Editor error, try manually: "+file) : resolve("OK")); });} async function update_(config){ const filter= current.param; const is_all= filter==="all"; let updates= []; log(1, "Collecting packages to download:"); for(const [ i, { repository, last_update, group, file_name, exec, downloaded, tag_name_regex } ] of Object.entries(config.packages)){ if(group==="skip") continue; if(!is_all&&group&&filter!==group) continue; const { tag_name, published_at, html_url, assets_url }= await githubRelease_(repository, tag_name_regex); const status= packageStatus(last_update, published_at); if(status!==3) continue; const assets= await downloadJSON_(repository, assets_url); if(!assets.length){ console.log(" Nothing to download: Visit "+html_url); continue; } const options= assets.map(({ name, download_count, size })=> `${name} | size: ${Math.round(size/1048576)}MB | downloads: ${download_count}`); logSection(" ", " "+repository, { "Version": tag_name, "Url": html_url }); logSection(" ", " Available assets:", options); const choose= await promt_(" Choose (empty for skip)", ""); if(choose==="") continue; const { browser_download_url: url, name: remote_name, size }= assets[choose]; updates.push({ index: i, file_name, exec, downloaded, repository, version: tag_name, last_update: published_at, url, remote_name, size }); } if(!updates.length){ log(2, "No packages in "+`group ${filter} needs updates.`); return Promise.resolve("nothing to update"); } log(1, "Downloading:"); return applySequentially_(updates, async function(todo){ const to= todo.file_name ? info.folder+todo.file_name : ( todo.downloaded ? todo.downloaded : info.folder+todo.remote_name); const d= await downloadFile_(to, todo); return Object.assign(todo, d); }) .then(function(dones){ log(1, "Finalizing:"); let e= 0; for(const nth of dones){ if(!nth.success){ e+= 1; log(2, `${nth.repository}: @e_${nth.message}`); continue; } Object.assign(config.packages[nth.index], registerDownloads(nth)); } save(config); const { length }= dones; const msg= `updated ${length-e} of ${length} packages.`; return e ? Promise.reject(msg) : Promise.resolve(msg); }); } function registerDownloads({ repository, last_update, message: downloads, exec, version }){ let msg= colors.s+"OK"; if(exec==="yes"){ try{ fs.chmodSync(downloads, 0o755); } catch(e){ msg= colors.e+"try manual `chmod+x` for '"+downloads+"'"; } } log(2, `${repository}: ${msg}`); return { last_update, downloads, version }; } async function check_({ packages }){ let updates= 0, skipped= 0; for(const { repository, name, version, last_update, group, tag_name_regex } of packages){ const { tag_name, published_at }= await githubRelease_(repository, tag_name_regex); const status= packageStatus(last_update, published_at); updates+= status===3; const skip= group==="skip"; skipped+= skip; log(2, `@g_${repository} [${group}]: `+( !version ? "not installed" : packageStatusText(status, skip) )); } const u= updates-skipped; const s= skipped ? ` (inc. skipped: ${updates})` : ""; return (!u ? "" : colors.w)+u+" update(s) available"+s; } async function register_(config){ const { param: repository }= current; if(!Reflect.has(config, "packages")) Reflect.set(config, "packages", []); const packages= Reflect.get(config, "packages"); let local_id= packages.findIndex(p=> p.repository===repository); if(local_id===-1) local_id= packages.push({ repository })-1; const local= config.packages[local_id]; const remote= await githubRepo_(repository) || {}; log(1, "Registering: "+repository); const spaces= " "; local.name= await promt_(spaces+"Name", local.name || remote.name || ""); if(!local.description) local.description= remote.description; logLines(2, [ "@g_Group info:", "- you can update specific packages by using their group name", "- There some reserved options:", " - '' (empty): will be included in all groups", " - 'skip': will be always skipped" ]); local.group= await promt_(spaces+"Group", local.group || ""); local.file_name= await promt_(spaces+"File Name", local.file_name || local.name.toLowerCase().replace(/\s/g, "-") || ""); local.exec= await promt_(spaces+"Make executable (yes/no)", local.exec || "no"); save(config); return `${repository}: saved`; } function packageStatusText(status, skip){ const s= skip ? colors.R+"skipped – "+colors.g : ""; switch(status){ case 0: return s+"nothing to compare"; case 1: return s+"@s_up-to-date"; case 2: return s+"newer"; case 3: return s+"@e_outdated/not instaled"; } } function packageStatus(local, remote){ if(!remote) return 0; if(!local) return 3; if(remote===local) return 1; return 2+(local colors[m])+colors.R); } function githubRelease_(repository, tag_name_regex= ""){ return downloadJSON_(repository, "https://api.github.com/repos/"+repository+"/releases") .then(data=> data.find(function find({ draft, published_at, tag_name }){ if(draft||!published_at) return false; if(!tag_name_regex) return true; return (new RegExp(tag_name_regex, 'g')).test(tag_name); })||{}); } function githubRepo_(repository){ return downloadJSON_(repository, "https://api.github.com/repos/"+repository); } function promt_(q, def){ const rl= readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise(function(resolve){ rl.question(q+": ", a=> { rl.close(); resolve(a); }); rl.write(def); }); } function getConfig(){ let config; try{ config= JSON.parse(fs.readFileSync(info.config)); } catch(e){ config= {}; log(1, "@w_Missing or corrupted config file. Creates empty one."); } return config; } function save(config){ return fs.writeFileSync(info.config, JSON.stringify(config, null, " ")); } function getCurrent(args){ let command, command_arg, param; const hasArg= arg=> ({ args })=> args.includes(arg); for(let i=0, { length }= args, arg; i data+= chunk); response.on("end", ()=> resolve(data)); }); }); } function downloadFile_(to, { url, repository, size }){ const file= fs.createWriteStream(to); return get_(url) .then(r=> get_(r.headers.location)) .then(function(response){ return new Promise(function(resolve){ let progress= 0, pc_prev= 0, avg= 0; const start= new Date(); const i= setInterval(function(){ readline.clearLine(process.stdout); const pc= (100*progress/size).toFixed(2); if(!pc_prev) pc_prev= pc; else { avg= ((100-pc)/(60*(pc-pc_prev))).toFixed(2); pc_prev= 0; } const running= ((new Date()-start)/60000).toFixed(2); log(2, repository+": "+pc+"%"+` (end in ~${avg} mins, running ${running} mins)`); readline.moveCursor(process.stdout, 0, -1); }, 500); response.on('data', function(chunk){ file.write(chunk); progress+= chunk.length; }); response.on('end', function(){ clearInterval(i); readline.clearLine(process.stdout); log(2, repository+": @s_OK"); file.close(()=> resolve({ success: 1, message: to })); /* close() is async, call cb after close completes. */ }); }); }) .catch(({ message })=> { fs.unlink(to); // Delete the file async. (But we don't check the result) return { success: 0, message }; }); } function get_(url){ return new Promise(function(resolve, reject){ https.get( url, { headers: { 'Cache-Control': 'no-cache', 'User-Agent': 'node' } }, resolve ).on("error", reject); });} function applySequentially_(input, pF){ const data= []; let p= pF(input[0]); const tie= nth=> result_mth=> ( data.push(result_mth), pF(input[nth]) ); for(let i= 1, { length }= input; i (data.push(o), data)); } function error(message){ const help_text= `@w_See help using '${info.commands[0].args[0]}'.`; log(1, `@e_Error: ${message} ${help_text}`); return process.exit(1); } function printMain(){ const { name, version, description }= info; log(1, `@w_${name}@${version}`); log(1, description); const cmds= info.commands.map(({args})=> args[0].replace("--", "")).join(", "); log(1, `@w_Usage: ${name} --[cmd] [param]`); log(2, `…cmd: ${cmds}`); log(2, "…param: Based on cmd\n"); } function printHelp(){ log(1, "@s_Help:"); log(2, "Commands:"); info.commands.forEach(({ args, param, desc })=> { const args_text= args.join("|"); param= param ? " "+param : ""; log(3, `@g_${args_text}@R_${param}`); logLines(4, desc); }); log(2, "Params:"); for(const [ param, desc ] of Object.entries(info.params)){ log(3, `@g_${param}`); logLines(4, desc); } } function log(tab, text){ return console.log(" ".repeat(tab)+text.replace(/@(\w)_/g, (_, m)=> colors[m])+colors.R); } function logLines(tab, multiline_text){ if(!Array.isArray(multiline_text)) multiline_text= multiline_text.split(/(?<=\.) /g); return log(tab, multiline_text.join("\n"+" ".repeat(tab))); }