/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : cli/lib.mjs * Purpose : CLI shared helpers — config, API client, colors, progress bars * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; export const CONFIG_DIR = path.join(os.homedir(), '.spbdrive'); export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); // Exit codes (spbgit conventions): 0 ok, 1 operation failed, 2 usage/config, 3 network/server. export const EXIT = { OK: 0, FAIL: 1, USAGE: 2, NET: 3 }; const useColor = !process.env.NO_COLOR && process.stdout.isTTY; const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s)); export const c = { bold: paint(1), dim: paint(2), red: paint(31), green: paint(32), yellow: paint(33), blue: paint(34), magenta: paint(35), cyan: paint(36), }; export function fail(message, code = EXIT.FAIL) { console.error(c.red(`✗ ${message}`)); process.exit(code); } export function loadConfig() { if (!existsSync(CONFIG_FILE)) { fail('Not configured. Run `spbdrive init` first.', EXIT.USAGE); } try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { fail(`Corrupt config at ${CONFIG_FILE}. Re-run \`spbdrive init\`.`, EXIT.USAGE); return null; } } export function saveConfig(cfg) { mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 }); chmodSync(CONFIG_FILE, 0o600); } /** Authenticated API request. Exits with EXIT.NET on connection failure. */ export async function req(cfg, apiPath, { method = 'GET', body, rawBody, stream = false } = {}) { const headers = { authorization: `Bearer ${cfg.token}` }; let payload; if (rawBody !== undefined) { headers['content-type'] = 'application/octet-stream'; payload = rawBody; } else if (body !== undefined) { headers['content-type'] = 'application/json'; payload = JSON.stringify(body); } let res; try { res = await fetch(`${cfg.server}${apiPath}`, { method, headers, body: payload }); } catch (err) { fail(`Cannot reach ${cfg.server}: ${err.cause?.code ?? err.message}`, EXIT.NET); } if (stream) return res; const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null; if (!res.ok) { const msg = data?.error?.message ?? `HTTP ${res.status}`; fail(msg, res.status >= 500 ? EXIT.NET : EXIT.FAIL); } return data; } export function fmtSize(bytes) { const n = Number(bytes ?? 0); if (n < 1024) return `${n} B`; const units = ['KB', 'MB', 'GB', 'TB']; let v = n / 1024; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; } return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`; } /** Simple single-line progress bar. */ export function progressBar(label, total) { let last = 0; const width = 26; return { update(done) { if (!process.stdout.isTTY) return; const now = Date.now(); if (now - last < 80 && done < total) return; last = now; const ratio = total ? Math.min(done / total, 1) : 1; const filled = Math.round(ratio * width); const bar = '█'.repeat(filled) + '░'.repeat(width - filled); process.stdout.write(`\r ${bar} ${String(Math.round(ratio * 100)).padStart(3)}% ${label.slice(0, 40)}`); }, done() { if (process.stdout.isTTY) process.stdout.write('\n'); }, }; } /** Resolve a remote path → node via the API (exits if missing). */ export async function resolveRemote(cfg, remotePath, { optional = false } = {}) { const clean = `/${String(remotePath ?? '/').replace(/^\/+|\/+$/g, '')}`; try { const { node } = await req(cfg, `/api/v1/resolve?path=${encodeURIComponent(clean)}`); return node; } catch { if (optional) return null; throw new Error(`Remote path not found: ${clean}`); } } /** Ensure a remote folder path exists, creating segments as needed. */ export async function ensureRemoteDir(cfg, remotePath) { const parts = String(remotePath ?? '/').split('/').filter(Boolean); let { node } = await req(cfg, '/api/v1/resolve?path=%2F'); for (const part of parts) { const { node: created } = await req(cfg, '/api/v1/nodes', { method: 'POST', body: { parentId: node.id, name: part, type: 'folder' }, }); node = created; } return node; }