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