spb/drive Public
SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.
JavaScript 82.7%
CSS 10.6%
Nunjucks 3.6%
Shell 1.8%
SQL 1.3%
1#!/usr/bin/env node2/**3 * ─────────────────────────────────────────────4 * SPB Drive — Personal Cloud Drive5 * ─────────────────────────────────────────────6 * Author : Simon-Pierre Boucher7 * Contact : contact@spboucher.ai8 * File : cli/spbdrive.mjs9 * Purpose : Companion CLI — init, ls, up, down, share, push, doctor, …10 * License : MIT © Simon-Pierre Boucher11 * ─────────────────────────────────────────────12 */1314import { Command } from 'commander';15import readline from 'node:readline';16import {17 c, EXIT, fail, loadConfig, saveConfig, req, fmtSize, resolveRemote, ensureRemoteDir, CONFIG_FILE,18} from './lib.mjs';19import { cmdUp, cmdDown, cmdPush } from './commands/transfer.mjs';2021const program = new Command();22program23 .name('spbdrive')24 .description('SPB Drive CLI — personal cloud of Simon-Pierre Boucher')25 .version('1.0.0');2627const ask = (q) => new Promise((resolve) => {28 const rl = readline.createInterface({ input: process.stdin, output: process.stdout });29 rl.question(q, (a) => { rl.close(); resolve(a.trim()); });30});3132// ── init ─────────────────────────────────────────────────────────────33program.command('init')34 .description('Configure server URL + API token (created in Settings)')35 .action(async () => {36 const server = (await ask('Server URL [https://drive.spboucher.ai]: ')) || 'https://drive.spboucher.ai';37 const token = await ask('API token (Settings → API tokens): ');38 if (!token) fail('An API token is required.', EXIT.USAGE);39 const cfg = { server: server.replace(/\/$/, ''), token };40 const me = await req(cfg, '/api/v1/me');41 saveConfig(cfg);42 console.log(c.green(`✓ Connected as ${me.user} — config saved to ${CONFIG_FILE}`));43 });4445// ── ls ───────────────────────────────────────────────────────────────46program.command('ls')47 .argument('[remote-path]', 'folder to list', '/')48 .option('--json', 'raw JSON output')49 .description('List a remote folder')50 .action(async (remotePath, opts) => {51 const cfg = loadConfig();52 const node = await resolveRemote(cfg, remotePath).catch((e) => fail(e.message));53 if (node.type !== 'folder') fail(`${remotePath} is a file — use \`spbdrive down\``, EXIT.USAGE);54 const { children } = await req(cfg, `/api/v1/nodes/${node.id}/children`);55 if (opts.json) { console.log(JSON.stringify(children, null, 2)); return; }56 if (!children.length) { console.log(c.dim('(empty)')); return; }57 const nameW = Math.min(Math.max(...children.map((ch) => ch.name.length)) + 2, 50);58 for (const child of children) {59 const icon = child.type === 'folder' ? c.blue('▸') : c.dim('·');60 const name = child.type === 'folder' ? c.blue(`${child.name}/`) : child.name;61 const size = child.type === 'folder' ? '' : fmtSize(child.size).padStart(9);62 const date = new Date(child.modified).toISOString().slice(0, 10);63 console.log(`${icon} ${name.padEnd(nameW)} ${size} ${c.dim(date)}${child.starred ? c.yellow(' ★') : ''}`);64 }65 });6667// ── up / down / push ─────────────────────────────────────────────────68program.command('up')69 .argument('<files...>', 'local files or directories')70 .option('-d, --dest <remote-folder>', 'destination folder', '/')71 .description('Upload files (chunked, resumable; directories recurse)')72 .action(async (files, opts) => cmdUp(loadConfig(), files, opts));7374program.command('down')75 .argument('<remote-path>', 'remote file or folder')76 .argument('[local]', 'local destination')77 .description('Download a file, or a folder as zip')78 .action(async (remotePath, local) => cmdDown(loadConfig(), remotePath, local));7980program.command('push')81 .argument('<local-dir>')82 .argument('<remote-dir>')83 .option('--delete', 'trash remote files that no longer exist locally')84 .description('One-way sync mirror (hash-compare, upload changed)')85 .action(async (localDir, remoteDir, opts) => cmdPush(loadConfig(), localDir, remoteDir, opts));8687// ── tree ops ─────────────────────────────────────────────────────────88program.command('mkdir')89 .argument('<remote-path>')90 .description('Create a remote folder (recursive)')91 .action(async (remotePath) => {92 await ensureRemoteDir(loadConfig(), remotePath);93 console.log(c.green(`✓ ${remotePath}`));94 });9596program.command('mv')97 .argument('<from>').argument('<to-folder>')98 .description('Move a file/folder into another folder')99 .action(async (from, toFolder) => {100 const cfg = loadConfig();101 const src = await resolveRemote(cfg, from).catch((e) => fail(e.message));102 const dest = await resolveRemote(cfg, toFolder).catch((e) => fail(e.message));103 await req(cfg, `/api/v1/nodes/${src.id}`, { method: 'PATCH', body: { parentId: dest.id } });104 console.log(c.green(`✓ ${from} → ${toFolder}`));105 });106107program.command('rm')108 .argument('<remote-path>')109 .option('--force', 'delete forever (skip trash)')110 .description('Move to trash (or delete forever with --force)')111 .action(async (remotePath, opts) => {112 const cfg = loadConfig();113 const node = await resolveRemote(cfg, remotePath).catch((e) => fail(e.message));114 await req(cfg, `/api/v1/nodes/${node.id}${opts.force ? '?force=true' : ''}`, { method: 'DELETE' });115 console.log(c.green(`✓ ${opts.force ? 'Deleted forever' : 'Trashed'}: ${remotePath}`));116 });117118program.command('restore')119 .argument('<name>')120 .description('Restore the most recent trash item matching a name')121 .action(async (name) => {122 const cfg = loadConfig();123 const { items } = await req(cfg, '/api/v1/trash');124 const hit = items.find((i) => i.name === name);125 if (!hit) fail(`Nothing in trash named "${name}"`);126 await req(cfg, `/api/v1/nodes/${hit.id}/restore`, { method: 'POST', body: {} });127 console.log(c.green(`✓ Restored ${name}`));128 });129130// ── shares ───────────────────────────────────────────────────────────131function parseExpiry(s) {132 if (!s) return null;133 const m = String(s).match(/^(\d+)([hdw])$/);134 if (!m) fail('Bad --expires — use forms like 12h, 7d, 2w', EXIT.USAGE);135 const mult = { h: 3_600_000, d: 86_400_000, w: 7 * 86_400_000 }[m[2]];136 return Date.now() + Number(m[1]) * mult;137}138139program.command('share')140 .argument('<remote-path>')141 .option('--expires <dur>', 'expiry like 12h / 7d / 2w')142 .option('--password <pw>', 'protect with a password')143 .option('--max-dl <n>', 'max downloads', (v) => Number(v))144 .option('--no-download', 'preview only')145 .option('--qr', 'print an ANSI QR code')146 .description('Create a share link and print the URL')147 .action(async (remotePath, opts) => {148 const cfg = loadConfig();149 const node = await resolveRemote(cfg, remotePath).catch((e) => fail(e.message));150 const { share } = await req(cfg, '/api/v1/shares', {151 method: 'POST',152 body: {153 nodeId: node.id,154 expiresAt: parseExpiry(opts.expires),155 password: opts.password ?? null,156 maxDownloads: opts.maxDl ?? null,157 allowDownload: opts.download !== false,158 },159 });160 console.log(share.url);161 if (opts.qr) {162 const { default: QRCode } = await import('qrcode');163 console.log(await QRCode.toString(share.url, { type: 'terminal', small: true }));164 }165 });166167program.command('shares')168 .description('List share links')169 .action(async () => {170 const cfg = loadConfig();171 const { shares } = await req(cfg, '/api/v1/shares');172 if (!shares.length) { console.log(c.dim('No shares.')); return; }173 for (const s of shares) {174 const dead = s.revokedAt || (s.expiresAt && s.expiresAt < Date.now());175 const status = dead ? c.red('inactive') : c.green('active ');176 console.log(`${status} ${c.cyan(s.token)} ${s.nodeName.padEnd(28).slice(0, 28)} ${c.dim(`${s.visits}v/${s.downloads}dl`)} ${s.url}`);177 }178 });179180program.command('revoke')181 .argument('<token>')182 .description('Revoke a share link')183 .action(async (token) => {184 const cfg = loadConfig();185 const { shares } = await req(cfg, '/api/v1/shares');186 const share = shares.find((s) => s.token === token);187 if (!share) fail(`No share with token ${token}`);188 await req(cfg, `/api/v1/shares/${share.id}`, { method: 'DELETE' });189 console.log(c.green(`✓ Revoked ${token}`));190 });191192// ── search ───────────────────────────────────────────────────────────193program.command('search')194 .argument('<query>')195 .description('Full-text search (names, tags, file content)')196 .action(async (query) => {197 const cfg = loadConfig();198 const { results } = await req(cfg, `/api/v1/search?q=${encodeURIComponent(query)}`);199 if (!results.length) { console.log(c.dim('No results.')); return; }200 for (const r of results) {201 console.log(`${r.type === 'folder' ? c.blue('▸') : c.dim('·')} ${r.name} ${c.dim(fmtSize(r.size))}`);202 if (r.snippet) console.log(` ${c.dim(r.snippet.replace(/<\/?mark>/g, ''))}`);203 }204 });205206// ── doctor ───────────────────────────────────────────────────────────207program.command('doctor')208 .description('Diagnose config, token, server, and server-side tooling')209 .action(async () => {210 const ok = (label) => console.log(`${c.green('✓')} ${label}`);211 const bad = (label) => console.log(`${c.red('✗')} ${label}`);212 let failures = 0;213 let cfg;214 try {215 cfg = JSON.parse((await import('node:fs')).readFileSync(CONFIG_FILE, 'utf8'));216 ok(`config at ${CONFIG_FILE}`);217 } catch {218 bad(`no config — run \`spbdrive init\``);219 process.exit(EXIT.USAGE);220 }221 try {222 const res = await fetch(`${cfg.server}/healthz`);223 const health = await res.json();224 ok(`server reachable — up ${Math.floor(health.uptimeSec / 60)} min, ${health.nodes} nodes, ${fmtSize(health.usedBytes)} used, queue ${health.queueDepth}`);225 } catch (err) {226 bad(`server unreachable: ${err.cause?.code ?? err.message}`);227 process.exit(EXIT.NET);228 }229 try {230 const me = await req(cfg, '/api/v1/me');231 ok(`token valid (${me.authKind})`);232 } catch {233 failures += 1;234 }235 // Probe server-side tooling through a preview capability check.236 try {237 const { results } = await req(cfg, '/api/v1/search?q=__doctor__');238 ok(`search API responding (${results.length} hits for probe)`);239 } catch { failures += 1; }240 console.log(failures ? c.yellow(`Done with ${failures} issue(s).`) : c.green('All good.'));241 process.exit(failures ? EXIT.FAIL : EXIT.OK);242 });243244program.parseAsync().catch((err) => {245 fail(err.message ?? String(err));246});247