feat: spbdrive CLI — init, ls, up/down, mkdir/mv/rm/restore, share --qr, push mirror, doctor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 3 changed files with +534 and −0
added
cli/commands/transfer.mjs
+155 −0
@@ -0,0 +1,155 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Drive — Personal Cloud Drive | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : cli/commands/transfer.mjs | |
| 8 | + * Purpose : Upload (chunked, recursive), download, one-way push sync | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { createHash } from 'node:crypto'; | |
| 14 | +import { createWriteStream, createReadStream, existsSync, statSync, readdirSync, readFileSync } from 'node:fs'; | |
| 15 | +import path from 'node:path'; | |
| 16 | +import { pipeline } from 'node:stream/promises'; | |
| 17 | +import { Readable } from 'node:stream'; | |
| 18 | +import { c, EXIT, fail, fmtSize, progressBar, req, resolveRemote, ensureRemoteDir } from '../lib.mjs'; | |
| 19 | + | |
| 20 | +const CHUNK = 8 * 1024 * 1024; | |
| 21 | + | |
| 22 | +function walkLocal(dir, prefix = '') { | |
| 23 | + const out = []; | |
| 24 | + for (const entry of readdirSync(dir, { withFileTypes: true })) { | |
| 25 | + if (entry.name === '.DS_Store') continue; | |
| 26 | + const full = path.join(dir, entry.name); | |
| 27 | + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; | |
| 28 | + if (entry.isDirectory()) out.push(...walkLocal(full, rel)); | |
| 29 | + else if (entry.isFile()) out.push({ full, rel, size: statSync(full).size }); | |
| 30 | + } | |
| 31 | + return out; | |
| 32 | +} | |
| 33 | + | |
| 34 | +async function uploadFile(cfg, localPath, parentId, relPath, size) { | |
| 35 | + const init = await req(cfg, '/api/v1/upload/init', { | |
| 36 | + method: 'POST', | |
| 37 | + body: { | |
| 38 | + parentId, | |
| 39 | + name: path.basename(relPath), | |
| 40 | + path: relPath.includes('/') ? relPath : undefined, | |
| 41 | + size, | |
| 42 | + }, | |
| 43 | + }); | |
| 44 | + const bar = progressBar(relPath, size); | |
| 45 | + const nChunks = init.nChunks; | |
| 46 | + for (let n = 0; n < nChunks; n += 1) { | |
| 47 | + const start = n * CHUNK; | |
| 48 | + const end = Math.min(start + CHUNK, size); | |
| 49 | + const chunk = size === 0 ? Buffer.alloc(0) : await readSlice(localPath, start, end); | |
| 50 | + await req(cfg, `/api/v1/upload/${init.uploadId}/chunk/${n}`, { method: 'PUT', rawBody: chunk }); | |
| 51 | + bar.update(end); | |
| 52 | + } | |
| 53 | + const { node } = await req(cfg, `/api/v1/upload/${init.uploadId}/complete`, { | |
| 54 | + method: 'POST', body: { conflict: 'keep-both' }, | |
| 55 | + }); | |
| 56 | + bar.done(); | |
| 57 | + return node; | |
| 58 | +} | |
| 59 | + | |
| 60 | +function readSlice(file, start, end) { | |
| 61 | + return new Promise((resolve, reject) => { | |
| 62 | + const chunks = []; | |
| 63 | + createReadStream(file, { start, end: end - 1 }) | |
| 64 | + .on('data', (d) => chunks.push(d)) | |
| 65 | + .on('end', () => resolve(Buffer.concat(chunks))) | |
| 66 | + .on('error', reject); | |
| 67 | + }); | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** spbdrive up <files...> [-d /remote/folder] */ | |
| 71 | +export async function cmdUp(cfg, files, opts) { | |
| 72 | + const destNode = await ensureRemoteDir(cfg, opts.dest ?? '/'); | |
| 73 | + let count = 0; let bytes = 0; | |
| 74 | + for (const file of files) { | |
| 75 | + if (!existsSync(file)) fail(`No such file: ${file}`, EXIT.USAGE); | |
| 76 | + const stat = statSync(file); | |
| 77 | + if (stat.isDirectory()) { | |
| 78 | + const base = path.basename(path.resolve(file)); | |
| 79 | + for (const entry of walkLocal(file, base)) { | |
| 80 | + await uploadFile(cfg, entry.full, destNode.id, entry.rel, entry.size); | |
| 81 | + count += 1; bytes += entry.size; | |
| 82 | + } | |
| 83 | + } else { | |
| 84 | + await uploadFile(cfg, file, destNode.id, path.basename(file), stat.size); | |
| 85 | + count += 1; bytes += stat.size; | |
| 86 | + } | |
| 87 | + } | |
| 88 | + console.log(c.green(`✓ Uploaded ${count} file(s), ${fmtSize(bytes)} → ${opts.dest ?? '/'}`)); | |
| 89 | +} | |
| 90 | + | |
| 91 | +/** spbdrive down <remote-path> [local] */ | |
| 92 | +export async function cmdDown(cfg, remotePath, local) { | |
| 93 | + const node = await resolveRemote(cfg, remotePath).catch((e) => fail(e.message, EXIT.FAIL)); | |
| 94 | + const isFolder = node.type === 'folder'; | |
| 95 | + const target = local ?? (isFolder ? `${node.name || 'drive'}.zip` : node.name); | |
| 96 | + const url = isFolder ? `/api/v1/zip?ids=${node.id}` : `/dl/${node.id}`; | |
| 97 | + const res = await req(cfg, url, { stream: true }); | |
| 98 | + if (!res.ok) fail(`Download failed: HTTP ${res.status}`, EXIT.NET); | |
| 99 | + const total = Number(res.headers.get('content-length') ?? 0); | |
| 100 | + const bar = progressBar(target, total); | |
| 101 | + let done = 0; | |
| 102 | + await pipeline( | |
| 103 | + Readable.fromWeb(res.body), | |
| 104 | + async function* (source) { for await (const chunk of source) { done += chunk.length; bar.update(done); yield chunk; } }, | |
| 105 | + createWriteStream(target), | |
| 106 | + ); | |
| 107 | + bar.done(); | |
| 108 | + console.log(c.green(`✓ ${target} (${fmtSize(done)})`)); | |
| 109 | +} | |
| 110 | + | |
| 111 | +/** spbdrive push <local-dir> <remote-dir> [--delete] — one-way mirror. */ | |
| 112 | +export async function cmdPush(cfg, localDir, remoteDir, opts) { | |
| 113 | + if (!existsSync(localDir) || !statSync(localDir).isDirectory()) { | |
| 114 | + fail(`Not a directory: ${localDir}`, EXIT.USAGE); | |
| 115 | + } | |
| 116 | + const destRoot = await ensureRemoteDir(cfg, remoteDir); | |
| 117 | + const localFiles = walkLocal(localDir); | |
| 118 | + console.log(`${c.bold('push')} ${localDir} → ${remoteDir} (${localFiles.length} local files)`); | |
| 119 | + | |
| 120 | + // Index remote subtree: path → node (size for cheap compare, sha when needed). | |
| 121 | + const remoteIndex = new Map(); | |
| 122 | + const indexRemote = async (folderId, prefix) => { | |
| 123 | + const { children } = await req(cfg, `/api/v1/nodes/${folderId}/children`); | |
| 124 | + for (const child of children) { | |
| 125 | + const rel = prefix ? `${prefix}/${child.name}` : child.name; | |
| 126 | + if (child.type === 'folder') await indexRemote(child.id, rel); | |
| 127 | + else remoteIndex.set(rel, child); | |
| 128 | + } | |
| 129 | + }; | |
| 130 | + await indexRemote(destRoot.id, ''); | |
| 131 | + | |
| 132 | + let uploaded = 0; let skipped = 0; let deleted = 0; | |
| 133 | + for (const file of localFiles) { | |
| 134 | + const remote = remoteIndex.get(file.rel); | |
| 135 | + remoteIndex.delete(file.rel); | |
| 136 | + if (remote && remote.size === file.size) { | |
| 137 | + // Same size → compare content hash before skipping. | |
| 138 | + const localSha = createHash('sha256').update(readFileSync(file.full)).digest('hex'); | |
| 139 | + const desc = await req(cfg, `/api/v1/preview/${remote.id}`); | |
| 140 | + if (desc.sha === localSha) { skipped += 1; continue; } | |
| 141 | + } | |
| 142 | + if (remote) await req(cfg, `/api/v1/nodes/${remote.id}?force=true`, { method: 'DELETE' }); | |
| 143 | + await uploadFile(cfg, file.full, destRoot.id, file.rel, file.size); | |
| 144 | + uploaded += 1; | |
| 145 | + } | |
| 146 | + | |
| 147 | + if (opts.delete) { | |
| 148 | + for (const [rel, node] of remoteIndex) { | |
| 149 | + await req(cfg, `/api/v1/nodes/${node.id}`, { method: 'DELETE' }); | |
| 150 | + console.log(c.dim(` − ${rel} (trashed)`)); | |
| 151 | + deleted += 1; | |
| 152 | + } | |
| 153 | + } | |
| 154 | + console.log(c.green(`✓ push done — ${uploaded} uploaded, ${skipped} unchanged${opts.delete ? `, ${deleted} removed` : ''}`)); | |
| 155 | +} | |
added
cli/lib.mjs
+133 −0
@@ -0,0 +1,133 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Drive — Personal Cloud Drive | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : cli/lib.mjs | |
| 8 | + * Purpose : CLI shared helpers — config, API client, colors, progress bars | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs'; | |
| 14 | +import os from 'node:os'; | |
| 15 | +import path from 'node:path'; | |
| 16 | + | |
| 17 | +export const CONFIG_DIR = path.join(os.homedir(), '.spbdrive'); | |
| 18 | +export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); | |
| 19 | + | |
| 20 | +// Exit codes (spbgit conventions): 0 ok, 1 operation failed, 2 usage/config, 3 network/server. | |
| 21 | +export const EXIT = { OK: 0, FAIL: 1, USAGE: 2, NET: 3 }; | |
| 22 | + | |
| 23 | +const useColor = !process.env.NO_COLOR && process.stdout.isTTY; | |
| 24 | +const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s)); | |
| 25 | +export const c = { | |
| 26 | + bold: paint(1), dim: paint(2), red: paint(31), green: paint(32), | |
| 27 | + yellow: paint(33), blue: paint(34), magenta: paint(35), cyan: paint(36), | |
| 28 | +}; | |
| 29 | + | |
| 30 | +export function fail(message, code = EXIT.FAIL) { | |
| 31 | + console.error(c.red(`✗ ${message}`)); | |
| 32 | + process.exit(code); | |
| 33 | +} | |
| 34 | + | |
| 35 | +export function loadConfig() { | |
| 36 | + if (!existsSync(CONFIG_FILE)) { | |
| 37 | + fail('Not configured. Run `spbdrive init` first.', EXIT.USAGE); | |
| 38 | + } | |
| 39 | + try { | |
| 40 | + return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); | |
| 41 | + } catch { | |
| 42 | + fail(`Corrupt config at ${CONFIG_FILE}. Re-run \`spbdrive init\`.`, EXIT.USAGE); | |
| 43 | + return null; | |
| 44 | + } | |
| 45 | +} | |
| 46 | + | |
| 47 | +export function saveConfig(cfg) { | |
| 48 | + mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); | |
| 49 | + writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 }); | |
| 50 | + chmodSync(CONFIG_FILE, 0o600); | |
| 51 | +} | |
| 52 | + | |
| 53 | +/** Authenticated API request. Exits with EXIT.NET on connection failure. */ | |
| 54 | +export async function req(cfg, apiPath, { method = 'GET', body, rawBody, stream = false } = {}) { | |
| 55 | + const headers = { authorization: `Bearer ${cfg.token}` }; | |
| 56 | + let payload; | |
| 57 | + if (rawBody !== undefined) { | |
| 58 | + headers['content-type'] = 'application/octet-stream'; | |
| 59 | + payload = rawBody; | |
| 60 | + } else if (body !== undefined) { | |
| 61 | + headers['content-type'] = 'application/json'; | |
| 62 | + payload = JSON.stringify(body); | |
| 63 | + } | |
| 64 | + let res; | |
| 65 | + try { | |
| 66 | + res = await fetch(`${cfg.server}${apiPath}`, { method, headers, body: payload }); | |
| 67 | + } catch (err) { | |
| 68 | + fail(`Cannot reach ${cfg.server}: ${err.cause?.code ?? err.message}`, EXIT.NET); | |
| 69 | + } | |
| 70 | + if (stream) return res; | |
| 71 | + const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null; | |
| 72 | + if (!res.ok) { | |
| 73 | + const msg = data?.error?.message ?? `HTTP ${res.status}`; | |
| 74 | + fail(msg, res.status >= 500 ? EXIT.NET : EXIT.FAIL); | |
| 75 | + } | |
| 76 | + return data; | |
| 77 | +} | |
| 78 | + | |
| 79 | +export function fmtSize(bytes) { | |
| 80 | + const n = Number(bytes ?? 0); | |
| 81 | + if (n < 1024) return `${n} B`; | |
| 82 | + const units = ['KB', 'MB', 'GB', 'TB']; | |
| 83 | + let v = n / 1024; let i = 0; | |
| 84 | + while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; } | |
| 85 | + return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`; | |
| 86 | +} | |
| 87 | + | |
| 88 | +/** Simple single-line progress bar. */ | |
| 89 | +export function progressBar(label, total) { | |
| 90 | + let last = 0; | |
| 91 | + const width = 26; | |
| 92 | + return { | |
| 93 | + update(done) { | |
| 94 | + if (!process.stdout.isTTY) return; | |
| 95 | + const now = Date.now(); | |
| 96 | + if (now - last < 80 && done < total) return; | |
| 97 | + last = now; | |
| 98 | + const ratio = total ? Math.min(done / total, 1) : 1; | |
| 99 | + const filled = Math.round(ratio * width); | |
| 100 | + const bar = '█'.repeat(filled) + '░'.repeat(width - filled); | |
| 101 | + process.stdout.write(`\r ${bar} ${String(Math.round(ratio * 100)).padStart(3)}% ${label.slice(0, 40)}`); | |
| 102 | + }, | |
| 103 | + done() { | |
| 104 | + if (process.stdout.isTTY) process.stdout.write('\n'); | |
| 105 | + }, | |
| 106 | + }; | |
| 107 | +} | |
| 108 | + | |
| 109 | +/** Resolve a remote path → node via the API (exits if missing). */ | |
| 110 | +export async function resolveRemote(cfg, remotePath, { optional = false } = {}) { | |
| 111 | + const clean = `/${String(remotePath ?? '/').replace(/^\/+|\/+$/g, '')}`; | |
| 112 | + try { | |
| 113 | + const { node } = await req(cfg, `/api/v1/resolve?path=${encodeURIComponent(clean)}`); | |
| 114 | + return node; | |
| 115 | + } catch { | |
| 116 | + if (optional) return null; | |
| 117 | + throw new Error(`Remote path not found: ${clean}`); | |
| 118 | + } | |
| 119 | +} | |
| 120 | + | |
| 121 | +/** Ensure a remote folder path exists, creating segments as needed. */ | |
| 122 | +export async function ensureRemoteDir(cfg, remotePath) { | |
| 123 | + const parts = String(remotePath ?? '/').split('/').filter(Boolean); | |
| 124 | + let { node } = await req(cfg, '/api/v1/resolve?path=%2F'); | |
| 125 | + for (const part of parts) { | |
| 126 | + const { node: created } = await req(cfg, '/api/v1/nodes', { | |
| 127 | + method: 'POST', | |
| 128 | + body: { parentId: node.id, name: part, type: 'folder' }, | |
| 129 | + }); | |
| 130 | + node = created; | |
| 131 | + } | |
| 132 | + return node; | |
| 133 | +} | |
added
cli/spbdrive.mjs
+246 −0
@@ -0,0 +1,246 @@ | ||
| 1 | +#!/usr/bin/env node | |
| 2 | +/** | |
| 3 | + * ───────────────────────────────────────────── | |
| 4 | + * SPB Drive — Personal Cloud Drive | |
| 5 | + * ───────────────────────────────────────────── | |
| 6 | + * Author : Simon-Pierre Boucher | |
| 7 | + * Contact : contact@spboucher.ai | |
| 8 | + * File : cli/spbdrive.mjs | |
| 9 | + * Purpose : Companion CLI — init, ls, up, down, share, push, doctor, … | |
| 10 | + * License : MIT © Simon-Pierre Boucher | |
| 11 | + * ───────────────────────────────────────────── | |
| 12 | + */ | |
| 13 | + | |
| 14 | +import { Command } from 'commander'; | |
| 15 | +import readline from 'node:readline'; | |
| 16 | +import { | |
| 17 | + c, EXIT, fail, loadConfig, saveConfig, req, fmtSize, resolveRemote, ensureRemoteDir, CONFIG_FILE, | |
| 18 | +} from './lib.mjs'; | |
| 19 | +import { cmdUp, cmdDown, cmdPush } from './commands/transfer.mjs'; | |
| 20 | + | |
| 21 | +const program = new Command(); | |
| 22 | +program | |
| 23 | + .name('spbdrive') | |
| 24 | + .description('SPB Drive CLI — personal cloud of Simon-Pierre Boucher') | |
| 25 | + .version('1.0.0'); | |
| 26 | + | |
| 27 | +const 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 | +}); | |
| 31 | + | |
| 32 | +// ── init ───────────────────────────────────────────────────────────── | |
| 33 | +program.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 | + }); | |
| 44 | + | |
| 45 | +// ── ls ─────────────────────────────────────────────────────────────── | |
| 46 | +program.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 | + }); | |
| 66 | + | |
| 67 | +// ── up / down / push ───────────────────────────────────────────────── | |
| 68 | +program.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)); | |
| 73 | + | |
| 74 | +program.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)); | |
| 79 | + | |
| 80 | +program.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)); | |
| 86 | + | |
| 87 | +// ── tree ops ───────────────────────────────────────────────────────── | |
| 88 | +program.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 | + }); | |
| 95 | + | |
| 96 | +program.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 | + }); | |
| 106 | + | |
| 107 | +program.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 | + }); | |
| 117 | + | |
| 118 | +program.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 | + }); | |
| 129 | + | |
| 130 | +// ── shares ─────────────────────────────────────────────────────────── | |
| 131 | +function 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 | +} | |
| 138 | + | |
| 139 | +program.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 | + }); | |
| 166 | + | |
| 167 | +program.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 | + }); | |
| 179 | + | |
| 180 | +program.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 | + }); | |
| 191 | + | |
| 192 | +// ── search ─────────────────────────────────────────────────────────── | |
| 193 | +program.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 | + }); | |
| 205 | + | |
| 206 | +// ── doctor ─────────────────────────────────────────────────────────── | |
| 207 | +program.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 | + }); | |
| 243 | + | |
| 244 | +program.parseAsync().catch((err) => { | |
| 245 | + fail(err.message ?? String(err)); | |
| 246 | +}); | |
| 247 | ||