SPB Git

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%
11.3 KB · 247 lines javascript
Raw Blame History
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