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%

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>
Simon-Pierre Boucher committed yesterday (Aug 10, 2026) parent 7a1b25c

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