#!/usr/bin/env node /** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : cli/spbdrive.mjs * Purpose : Companion CLI — init, ls, up, down, share, push, doctor, … * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { Command } from 'commander'; import readline from 'node:readline'; import { c, EXIT, fail, loadConfig, saveConfig, req, fmtSize, resolveRemote, ensureRemoteDir, CONFIG_FILE, } from './lib.mjs'; import { cmdUp, cmdDown, cmdPush } from './commands/transfer.mjs'; const program = new Command(); program .name('spbdrive') .description('SPB Drive CLI — personal cloud of Simon-Pierre Boucher') .version('1.0.0'); const ask = (q) => new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question(q, (a) => { rl.close(); resolve(a.trim()); }); }); // ── init ───────────────────────────────────────────────────────────── program.command('init') .description('Configure server URL + API token (created in Settings)') .action(async () => { const server = (await ask('Server URL [https://drive.spboucher.ai]: ')) || 'https://drive.spboucher.ai'; const token = await ask('API token (Settings → API tokens): '); if (!token) fail('An API token is required.', EXIT.USAGE); const cfg = { server: server.replace(/\/$/, ''), token }; const me = await req(cfg, '/api/v1/me'); saveConfig(cfg); console.log(c.green(`✓ Connected as ${me.user} — config saved to ${CONFIG_FILE}`)); }); // ── ls ─────────────────────────────────────────────────────────────── program.command('ls') .argument('[remote-path]', 'folder to list', '/') .option('--json', 'raw JSON output') .description('List a remote folder') .action(async (remotePath, opts) => { const cfg = loadConfig(); const node = await resolveRemote(cfg, remotePath).catch((e) => fail(e.message)); if (node.type !== 'folder') fail(`${remotePath} is a file — use \`spbdrive down\``, EXIT.USAGE); const { children } = await req(cfg, `/api/v1/nodes/${node.id}/children`); if (opts.json) { console.log(JSON.stringify(children, null, 2)); return; } if (!children.length) { console.log(c.dim('(empty)')); return; } const nameW = Math.min(Math.max(...children.map((ch) => ch.name.length)) + 2, 50); for (const child of children) { const icon = child.type === 'folder' ? c.blue('▸') : c.dim('·'); const name = child.type === 'folder' ? c.blue(`${child.name}/`) : child.name; const size = child.type === 'folder' ? '' : fmtSize(child.size).padStart(9); const date = new Date(child.modified).toISOString().slice(0, 10); console.log(`${icon} ${name.padEnd(nameW)} ${size} ${c.dim(date)}${child.starred ? c.yellow(' ★') : ''}`); } }); // ── up / down / push ───────────────────────────────────────────────── program.command('up') .argument('', 'local files or directories') .option('-d, --dest ', 'destination folder', '/') .description('Upload files (chunked, resumable; directories recurse)') .action(async (files, opts) => cmdUp(loadConfig(), files, opts)); program.command('down') .argument('', 'remote file or folder') .argument('[local]', 'local destination') .description('Download a file, or a folder as zip') .action(async (remotePath, local) => cmdDown(loadConfig(), remotePath, local)); program.command('push') .argument('') .argument('') .option('--delete', 'trash remote files that no longer exist locally') .description('One-way sync mirror (hash-compare, upload changed)') .action(async (localDir, remoteDir, opts) => cmdPush(loadConfig(), localDir, remoteDir, opts)); // ── tree ops ───────────────────────────────────────────────────────── program.command('mkdir') .argument('') .description('Create a remote folder (recursive)') .action(async (remotePath) => { await ensureRemoteDir(loadConfig(), remotePath); console.log(c.green(`✓ ${remotePath}`)); }); program.command('mv') .argument('').argument('') .description('Move a file/folder into another folder') .action(async (from, toFolder) => { const cfg = loadConfig(); const src = await resolveRemote(cfg, from).catch((e) => fail(e.message)); const dest = await resolveRemote(cfg, toFolder).catch((e) => fail(e.message)); await req(cfg, `/api/v1/nodes/${src.id}`, { method: 'PATCH', body: { parentId: dest.id } }); console.log(c.green(`✓ ${from} → ${toFolder}`)); }); program.command('rm') .argument('') .option('--force', 'delete forever (skip trash)') .description('Move to trash (or delete forever with --force)') .action(async (remotePath, opts) => { const cfg = loadConfig(); const node = await resolveRemote(cfg, remotePath).catch((e) => fail(e.message)); await req(cfg, `/api/v1/nodes/${node.id}${opts.force ? '?force=true' : ''}`, { method: 'DELETE' }); console.log(c.green(`✓ ${opts.force ? 'Deleted forever' : 'Trashed'}: ${remotePath}`)); }); program.command('restore') .argument('') .description('Restore the most recent trash item matching a name') .action(async (name) => { const cfg = loadConfig(); const { items } = await req(cfg, '/api/v1/trash'); const hit = items.find((i) => i.name === name); if (!hit) fail(`Nothing in trash named "${name}"`); await req(cfg, `/api/v1/nodes/${hit.id}/restore`, { method: 'POST', body: {} }); console.log(c.green(`✓ Restored ${name}`)); }); // ── shares ─────────────────────────────────────────────────────────── function parseExpiry(s) { if (!s) return null; const m = String(s).match(/^(\d+)([hdw])$/); if (!m) fail('Bad --expires — use forms like 12h, 7d, 2w', EXIT.USAGE); const mult = { h: 3_600_000, d: 86_400_000, w: 7 * 86_400_000 }[m[2]]; return Date.now() + Number(m[1]) * mult; } program.command('share') .argument('') .option('--expires ', 'expiry like 12h / 7d / 2w') .option('--password ', 'protect with a password') .option('--max-dl ', 'max downloads', (v) => Number(v)) .option('--no-download', 'preview only') .option('--qr', 'print an ANSI QR code') .description('Create a share link and print the URL') .action(async (remotePath, opts) => { const cfg = loadConfig(); const node = await resolveRemote(cfg, remotePath).catch((e) => fail(e.message)); const { share } = await req(cfg, '/api/v1/shares', { method: 'POST', body: { nodeId: node.id, expiresAt: parseExpiry(opts.expires), password: opts.password ?? null, maxDownloads: opts.maxDl ?? null, allowDownload: opts.download !== false, }, }); console.log(share.url); if (opts.qr) { const { default: QRCode } = await import('qrcode'); console.log(await QRCode.toString(share.url, { type: 'terminal', small: true })); } }); program.command('shares') .description('List share links') .action(async () => { const cfg = loadConfig(); const { shares } = await req(cfg, '/api/v1/shares'); if (!shares.length) { console.log(c.dim('No shares.')); return; } for (const s of shares) { const dead = s.revokedAt || (s.expiresAt && s.expiresAt < Date.now()); const status = dead ? c.red('inactive') : c.green('active '); console.log(`${status} ${c.cyan(s.token)} ${s.nodeName.padEnd(28).slice(0, 28)} ${c.dim(`${s.visits}v/${s.downloads}dl`)} ${s.url}`); } }); program.command('revoke') .argument('') .description('Revoke a share link') .action(async (token) => { const cfg = loadConfig(); const { shares } = await req(cfg, '/api/v1/shares'); const share = shares.find((s) => s.token === token); if (!share) fail(`No share with token ${token}`); await req(cfg, `/api/v1/shares/${share.id}`, { method: 'DELETE' }); console.log(c.green(`✓ Revoked ${token}`)); }); // ── search ─────────────────────────────────────────────────────────── program.command('search') .argument('') .description('Full-text search (names, tags, file content)') .action(async (query) => { const cfg = loadConfig(); const { results } = await req(cfg, `/api/v1/search?q=${encodeURIComponent(query)}`); if (!results.length) { console.log(c.dim('No results.')); return; } for (const r of results) { console.log(`${r.type === 'folder' ? c.blue('▸') : c.dim('·')} ${r.name} ${c.dim(fmtSize(r.size))}`); if (r.snippet) console.log(` ${c.dim(r.snippet.replace(/<\/?mark>/g, ''))}`); } }); // ── doctor ─────────────────────────────────────────────────────────── program.command('doctor') .description('Diagnose config, token, server, and server-side tooling') .action(async () => { const ok = (label) => console.log(`${c.green('✓')} ${label}`); const bad = (label) => console.log(`${c.red('✗')} ${label}`); let failures = 0; let cfg; try { cfg = JSON.parse((await import('node:fs')).readFileSync(CONFIG_FILE, 'utf8')); ok(`config at ${CONFIG_FILE}`); } catch { bad(`no config — run \`spbdrive init\``); process.exit(EXIT.USAGE); } try { const res = await fetch(`${cfg.server}/healthz`); const health = await res.json(); ok(`server reachable — up ${Math.floor(health.uptimeSec / 60)} min, ${health.nodes} nodes, ${fmtSize(health.usedBytes)} used, queue ${health.queueDepth}`); } catch (err) { bad(`server unreachable: ${err.cause?.code ?? err.message}`); process.exit(EXIT.NET); } try { const me = await req(cfg, '/api/v1/me'); ok(`token valid (${me.authKind})`); } catch { failures += 1; } // Probe server-side tooling through a preview capability check. try { const { results } = await req(cfg, '/api/v1/search?q=__doctor__'); ok(`search API responding (${results.length} hits for probe)`); } catch { failures += 1; } console.log(failures ? c.yellow(`Done with ${failures} issue(s).`) : c.green('All good.')); process.exit(failures ? EXIT.FAIL : EXIT.OK); }); program.parseAsync().catch((err) => { fail(err.message ?? String(err)); });