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/**2 * ─────────────────────────────────────────────3 * SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : cli/lib.mjs8 * Purpose : CLI shared helpers — config, API client, colors, progress bars9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';14import os from 'node:os';15import path from 'node:path';1617export const CONFIG_DIR = path.join(os.homedir(), '.spbdrive');18export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');1920// Exit codes (spbgit conventions): 0 ok, 1 operation failed, 2 usage/config, 3 network/server.21export const EXIT = { OK: 0, FAIL: 1, USAGE: 2, NET: 3 };2223const useColor = !process.env.NO_COLOR && process.stdout.isTTY;24const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));25export 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};2930export function fail(message, code = EXIT.FAIL) {31 console.error(c.red(`✗ ${message}`));32 process.exit(code);33}3435export 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}4647export 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}5253/** Authenticated API request. Exits with EXIT.NET on connection failure. */54export 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}7879export 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}8788/** Simple single-line progress bar. */89export 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}108109/** Resolve a remote path → node via the API (exits if missing). */110export 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}120121/** Ensure a remote folder path exists, creating segments as needed. */122export 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}134