#!/usr/bin/env node // fp.mjs — command-line client for the framepeek API (reference: https://framepeek.com/api-guide.txt). // One file, zero dependencies: Node 18+ built-ins and global fetch. Copy it anywhere, run `node fp.mjs help`. // Env: FRAMEPEEK_API_KEY (required; never accepted as an argument), FRAMEPEEK_BASE (default https://framepeek.com). // Exit codes: 0 ok · 1 some items failed · 2 usage/config error · 3 server says stop (key/plan/quota): do not retry. // There is deliberately no permanent-delete command: delete only moves files to the Trash, and only with --yes. import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; const BASE = (process.env.FRAMEPEEK_BASE || 'https://framepeek.com').replace(/\/+$/, ''); const KEY = (process.env.FRAMEPEEK_API_KEY || '').trim(); const IMAGE_EXT = new Set(['jpg', 'jpeg', 'jfif', 'png', 'gif', 'webp', 'avif', 'bmp', 'heic', 'heif', 'tif', 'tiff']); const VIDEO_EXT = new Set(['mp4', 'webm', 'mov', 'm4v']); const MAX_BYTES = { image: 25 * 1024 * 1024, video: 200 * 1024 * 1024 }; const ID_RE = /^[a-f0-9]{24}$/; const ZIP_MAX = 2000, CHUNK = 1000, MAX_RETRY = 5, MAX_WAIT_S = 60, SHOW = 20; class CliError extends Error { constructor(msg, exit = 1, code = null) { super(msg); this.exit = exit; this.code = code; } } const usage = (m) => new CliError(m, 2, 'USAGE'); // Server answers that mean "stop now": never retried. Each message is one line: cause + next action. const dur = (s) => (s >= 7200 ? `${Math.ceil(s / 3600)} h` : s >= 120 ? `${Math.ceil(s / 60)} min` : `${s} s`); const STOP = { KEY_INVALID: () => 'API key rejected (invalid or deleted). Stop; ask the owner for a new key.', KEY_EXPIRED: () => 'API key expired. Stop; ask the owner to create a new key on the My page.', KEY_SUSPENDED: () => 'API key suspended by FramePeek. Stop, tell the owner; do not retry.', KEY_SCOPE: (m) => `${m}. Stop; ask the owner for a key with that permission.`, API_PLAN_REQUIRED: () => 'Account is below Plus, so API keys are off. Stop; tell the owner (files are untouched).', KEY_ROUTE_FORBIDDEN: () => 'Not available to API keys. Stop; use the web app for this.', API_SUSPENDED: () => 'API access for this account is suspended. Stop; tell the owner.', KEY_QUOTA: (m, ra) => `Daily transfer limit reached${ra ? `; frees up in about ${dur(ra)}` : ''}. Stop; tell the owner.`, QUOTA_EXCEEDED: () => 'Storage is full. Stop; tell the owner (empty the Trash in the web app or upgrade).', }; // ---- output (every string goes through scrub(): the key can never reach stdout/stderr) ---- const SECRET = KEY.split('_').slice(2).join('_'); const scrub = (s) => { s = String(s); if (KEY) s = s.split(KEY).join('fpk_***'); if (SECRET.length >= 16) s = s.split(SECRET).join('***'); return s.replace(/fpk_[A-Za-z0-9]+_[A-Za-z0-9_-]{8,}/g, 'fpk_***'); }; const print = (s) => process.stdout.write(scrub(s) + '\n'); const eprint = (s) => process.stderr.write(scrub(s) + '\n'); const emit = (o, obj, text) => print(o.json ? JSON.stringify(obj) : text); const fmt = (n) => { n = Number(n) || 0; let i = 0; while (n >= 1024 && i < 4) { n /= 1024; i++; } return i ? `${n.toFixed(n < 10 ? 2 : 1)} ${['B', 'KB', 'MB', 'GB', 'TB'][i]}` : `${n} B`; }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const more = (arr, fn) => [...arr.slice(0, SHOW).map(fn), ...(arr.length > SHOW ? [` … and ${arr.length - SHOW} more (use --json for all)`] : [])]; // ---- HTTP ---- async function api(method, p, { json, body, ms = 120000, raw } = {}) { const headers = { Authorization: 'Bearer ' + KEY }; if (json !== undefined) { headers['Content-Type'] = 'application/json'; body = JSON.stringify(json); } else if (body) headers['Content-Type'] = 'application/octet-stream'; for (let attempt = 0; ; attempt++) { let r; try { r = await fetch(BASE + p, { method, headers, body, redirect: 'manual', signal: AbortSignal.timeout(ms) }); } catch (e) { throw new CliError(`cannot reach ${BASE}: ${(e.cause && e.cause.code) || e.name}`, 1, 'NETWORK'); } if (r.ok) return raw ? r : r.json(); const text = await r.text().catch(() => ''); let d = {}; try { d = JSON.parse(text) || {}; } catch {} const code = d.code || '', msg = String(d.error || text.slice(0, 120) || r.statusText); const ra = Math.max(0, parseInt(r.headers.get('retry-after') || '', 10) || 0); if (STOP[code]) throw new CliError(STOP[code](msg, ra), 3, code); if (r.status === 429) { // KEY_RATE / KEY_BUSY / zip "busy": wait Retry-After, then retry (bounded) if (attempt >= MAX_RETRY) throw new CliError(`still rate limited after ${MAX_RETRY} retries (${code || 'busy'}). Stop; wait a few minutes.`, 3, code || 'RATE'); if (ra > MAX_WAIT_S) throw new CliError(`rate limited for ${dur(ra)} (${code || 'busy'}). Stop; try again later.`, 3, code || 'RATE'); await sleep((ra || 3) * 1000); continue; } if (r.status === 401 || r.status === 403) throw new CliError(`${msg} (${code || r.status}). Stop; tell the key owner.`, 3, code || String(r.status)); if (r.status >= 300 && r.status < 400) throw new CliError(`unexpected redirect (${r.status}); check FRAMEPEEK_BASE`, 1, 'REDIRECT'); throw new CliError(`${r.status}${code ? ' ' + code : ''} ${msg}`, 1, code || String(r.status)); } } const listItems = async () => (await api('GET', '/api/items')).items || []; const listFolders = async () => (await api('GET', '/api/folders')).folders || []; const cleanName = (s) => String(s).replace(/[\x00-\x1f\x7f]/g, '').trim().slice(0, 60); // same rule as the server const needIds = (ids) => { for (const id of ids) if (!ID_RE.test(id)) throw usage(`not an item id: ${id.slice(0, 40)} (24 hex chars; see \`fp ls\`)`); return ids; }; function findFolder(folders, spec) { const byId = folders.find((f) => f.id === spec); if (byId) return byId; const name = cleanName(spec); let hits = folders.filter((f) => f.name === name); if (!hits.length) hits = folders.filter((f) => f.name.toLowerCase() === name.toLowerCase()); if (hits.length > 1) throw usage(`folder name "${name}" is ambiguous; use an id: ${hits.map((f) => f.id).join(', ')}`); return hits[0] || null; } function needFolder(folders, spec) { const f = findFolder(folders, spec); if (!f) throw usage(`no folder "${spec}" (create it with \`fp mkdir\`; list with \`fp ls\`)`); return f; } async function ensureFolder(spec) { const found = findFolder(await listFolders(), spec); if (found) return { ...found, created: false }; const name = cleanName(spec); if (!name) throw usage('folder name is empty'); const { folder } = await api('POST', '/api/folders', { json: { name } }); return { ...folder, created: true }; } // ---- image size: PNG/JPEG/GIF/WebP straight from the header, else ffprobe/sips, else 0 0 ---- function exifOrientation(b, t) { // t = start of the TIFF header; returns 1..8 const le = b.toString('latin1', t, t + 2) === 'II'; const u16 = (p) => (le ? b.readUInt16LE(p) : b.readUInt16BE(p)); const ifd = t + (le ? b.readUInt32LE(t + 4) : b.readUInt32BE(t + 4)); for (let k = 0, n = u16(ifd); k < n; k++) if (u16(ifd + 2 + k * 12) === 0x0112) return u16(ifd + 10 + k * 12); return 1; } function jpegDims(b) { // size as displayed: EXIF orientation 5-8 swaps width/height (as browsers do) let i = 2, o = 1; while (i + 4 < b.length) { if (b[i] !== 0xff) { i++; continue; } const m = b[i + 1]; if (m === 0xff) { i++; continue; } if (m === 0xda) break; if (m === 0x01 || m === 0xd8 || (m >= 0xd0 && m <= 0xd7)) { i += 2; continue; } if (m === 0xe1 && b.toString('latin1', i + 4, i + 10) === 'Exif\0\0') { try { o = exifOrientation(b, i + 10); } catch {} } if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) { const w = b.readUInt16BE(i + 7), h = b.readUInt16BE(i + 5); return o >= 5 ? [h, w] : [w, h]; } i += 2 + b.readUInt16BE(i + 2); } return null; } function webpDims(b) { const t = b.toString('latin1', 12, 16); if (t === 'VP8 ') return [b.readUInt16LE(26) & 0x3fff, b.readUInt16LE(28) & 0x3fff]; if (t === 'VP8L') { const v = b.readUInt32LE(21); return [(v & 0x3fff) + 1, ((v >> 14) & 0x3fff) + 1]; } if (t === 'VP8X') return [b.readUIntLE(24, 3) + 1, b.readUIntLE(27, 3) + 1]; return null; } function probe(file) { const run = (cmd, args) => { try { return execFileSync(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000 }).toString(); } catch { return ''; } }; let m = /(\d+)x(\d+)/.exec(run('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=s=x:p=0', file])); if (!m) { const s = run('sips', ['-g', 'pixelWidth', '-g', 'pixelHeight', file]); const w = /pixelWidth: (\d+)/.exec(s), h = /pixelHeight: (\d+)/.exec(s); if (w && h) m = [0, w[1], h[1]]; } return m ? [+m[1], +m[2]] : null; } function dims(buf, file) { let d = null; try { const head4 = buf.toString('latin1', 0, 4); if (buf.length > 24 && buf.readUInt32BE(0) === 0x89504e47) d = [buf.readUInt32BE(16), buf.readUInt32BE(20)]; else if (head4 === 'GIF8') d = [buf.readUInt16LE(6), buf.readUInt16LE(8)]; else if (buf[0] === 0xff && buf[1] === 0xd8) d = jpegDims(buf); else if (head4 === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') d = webpDims(buf); } catch {} if (!d || !d[0] || !d[1]) d = probe(file) || [0, 0]; // 0 0 makes the grid show a square, so only as a last resort return { w: d[0], h: d[1] }; } // ---- commands ---- async function cmdWhoami(o) { const r = await api('GET', '/api/key/whoami'); const days = r.key.expires ? Math.floor((Date.parse(r.key.expires) - Date.now()) / 864e5) : null; emit(o, r, [ `key "${r.key.name}" permissions: ${r.key.scopes.join(', ')} expires: ${r.key.expires ? `${r.key.expires.slice(0, 10)} (${days} days left${days < 7 ? ' — renew soon' : ''})` : 'never'}`, `plan: ${r.plan} storage: ${fmt(r.used)} used of ${fmt(r.quota)} (${fmt(Math.max(0, r.quota - r.used))} free)`, `transfer (last 24h): ${fmt(r.transferToday)} of ${fmt(r.transferLimit)} (${fmt(Math.max(0, r.transferLimit - r.transferToday))} left)`, ].join('\n')); } async function cmdLs(o) { if (o.folder !== undefined && o.unfiled) throw usage('use --folder or --unfiled, not both'); const limit = o.limit === undefined ? SHOW : o.limit === 'all' ? 0 : Number(o.limit); if (!Number.isInteger(limit) || limit < 0) throw usage('--limit takes a number or "all"'); const [items, folders] = await Promise.all([listItems(), listFolders()]); const name = new Map(folders.map((f) => [f.id, f.name])); let rows = items, scope = ''; if (o.folder !== undefined) { const f = needFolder(folders, o.folder); rows = items.filter((i) => i.folderId === f.id); scope = ` in "${f.name}"`; } else if (o.unfiled) { rows = items.filter((i) => !i.folderId); scope = ' (unfiled)'; } rows = [...rows].sort((a, b) => String(b.t).localeCompare(String(a.t))); const shown = limit ? rows.slice(0, limit) : rows; const sum = { total: rows.length, images: rows.filter((i) => i.kind === 'image').length, videos: rows.filter((i) => i.kind === 'video').length, bytes: rows.reduce((s, i) => s + (i.size || 0), 0) }; const fcount = folders.map((f) => ({ id: f.id, name: f.name, count: items.filter((i) => i.folderId === f.id).length })); const unfiled = items.filter((i) => !i.folderId).length; const view = shown.map((i) => ({ id: i.id, name: i.name, kind: i.kind, w: i.w, h: i.h, size: i.size, t: i.t, folderId: i.folderId || null, folder: name.get(i.folderId) || null })); const lines = [`${sum.total} files${scope} · ${sum.images} images, ${sum.videos} videos · ${fmt(sum.bytes)}`]; if (!scope) lines.push(`folders: ${fcount.map((f) => `${f.name} (${f.count})`).join(', ') || 'none'} · unfiled: ${unfiled}`); if (view.length < sum.total) lines.push(`latest ${view.length} of ${sum.total} (--limit all for everything):`); for (const i of view) lines.push(`${i.id} ${i.kind === 'video' ? 'vid' : 'img'} ${i.w}x${i.h} ${fmt(i.size)} ${i.name}${i.folder ? ` [${i.folder}]` : ''}`); emit(o, { ...sum, unfiled, folders: fcount, items: view }, lines.join('\n')); } async function cmdMkdir(o) { if (o._.length !== 1) throw usage('mkdir takes exactly one folder name'); const f = await ensureFolder(o._[0]); emit(o, { id: f.id, name: f.name, created: f.created }, `${f.created ? 'created' : 'already exists:'} folder "${f.name}" (${f.id})`); } function collect(p, recursive, found, skipped, failed) { let st; try { st = fs.statSync(p); } catch { failed.push({ name: p, reason: 'not found' }); return; } if (st.isDirectory()) { for (const e of fs.readdirSync(p).sort()) { if (e.startsWith('.')) continue; const q = path.join(p, e), qs = fs.statSync(q, { throwIfNoEntry: false }); if (!qs) continue; // broken symlink if (qs.isDirectory() && !recursive) skipped.push({ name: q, reason: 'subfolder (use --recursive)' }); else collect(q, recursive, found, skipped, failed); } return; } const name = path.basename(p), ext = path.extname(p).slice(1).toLowerCase(); const kind = IMAGE_EXT.has(ext) ? 'image' : VIDEO_EXT.has(ext) ? 'video' : null; if (!kind) skipped.push({ name, reason: 'unsupported type' }); else if (st.size === 0) failed.push({ name, reason: 'empty file' }); else if (st.size > MAX_BYTES[kind]) failed.push({ name, reason: `too large (${fmt(st.size)}; ${kind} limit ${fmt(MAX_BYTES[kind])})` }); else found.push({ path: path.resolve(p), name, size: st.size }); } async function cmdUpload(o) { if (!o._.length) throw usage('upload needs at least one file or folder path'); const found = [], skipped = [], failed = [], uploaded = []; for (const p of o._) collect(p, o.recursive, found, skipped, failed); let todo = found; if (found.length && !o['no-skip-existing']) { // re-runs are safe: same name + same size = already there const have = new Set((await listItems()).map((i) => `${i.name}\0${i.size}`)); todo = found.filter((f) => { const k = `${f.name}\0${f.size}`; if (!have.has(k)) { have.add(k); return true; } skipped.push({ name: f.name, reason: 'already exists' }); return false; }); } const folder = todo.length && o.folder !== undefined ? await ensureFolder(o.folder) : null; // before any bytes move let bytes = 0, moved = 0, stopped = null; for (const [n, f] of todo.entries()) { if (process.stderr.isTTY) process.stderr.write(`\r${n + 1}/${todo.length}`); try { const buf = fs.readFileSync(f.path); const { w, h } = dims(buf, f.path); const r = await api('POST', `/api/upload?name=${encodeURIComponent(f.name)}&w=${w}&h=${h}`, { body: buf, ms: 30 * 60e3 }); uploaded.push({ id: r.item.id, name: f.name, size: buf.length, w: r.item.w, h: r.item.h }); bytes += buf.length; } catch (e) { if (e.exit === 3) { stopped = e; break; } // server said stop: no more requests of any kind failed.push({ name: f.name, reason: e.message }); } } if (process.stderr.isTTY && todo.length) process.stderr.write('\r'); if (folder && uploaded.length && !stopped) { try { for (let i = 0; i < uploaded.length; i += CHUNK) { const ids = uploaded.slice(i, i + CHUNK).map((u) => u.id); moved += (await api('POST', '/api/items/move', { json: { ids, folderId: folder.id } })).moved || 0; } } catch (e) { if (e.exit === 3) stopped = e; else failed.push({ name: '(moving into folder)', reason: e.message }); } } const why = {}; for (const s of skipped) why[s.reason] = (why[s.reason] || 0) + 1; const lines = [`uploaded ${uploaded.length} / skipped ${skipped.length} / failed ${failed.length} — ${fmt(bytes)} sent`]; if (skipped.length) lines.push('skipped: ' + Object.entries(why).map(([r, c]) => `${c} ${r}`).join(', ')); lines.push(...more(skipped.filter((s) => s.reason !== 'already exists'), (s) => ` - ${s.name} (${s.reason})`)); if (failed.length) lines.push('failed:', ...more(failed, (x) => ` - ${x.name}: ${x.reason}`)); if (folder) lines.push(`folder "${folder.name}" (${folder.id}): ${folder.created ? 'created, ' : ''}${moved} moved in`); if (stopped) lines.push(`STOPPED: ${stopped.message}`, uploaded.length ? 'Files uploaded before the stop were left unfiled/where they landed.' : ''); emit(o, { uploaded, skipped, failed, bytes, folder: folder && { id: folder.id, name: folder.name, created: folder.created, moved }, stopped: stopped && stopped.message }, lines.filter(Boolean).join('\n')); if (stopped) { stopped.printed = true; throw stopped; } return failed.length ? 1 : 0; } async function cmdMove(o) { if (!o._.length) throw usage('move needs item ids'); if ((o.to === undefined) === !o.unfiled) throw usage('move needs exactly one of --to or --unfiled'); const ids = needIds(o._); const f = o.unfiled ? null : needFolder(await listFolders(), o.to); let moved = 0; for (let i = 0; i < ids.length; i += CHUNK) { moved += (await api('POST', '/api/items/move', { json: { ids: ids.slice(i, i + CHUNK), folderId: f ? f.id : null } })).moved || 0; } emit(o, { moved, requested: ids.length, folderId: f ? f.id : null }, `moved ${moved} of ${ids.length} to ${f ? `"${f.name}"` : 'unfiled'}${moved < ids.length ? ' (the rest were already there or not found)' : ''}`); } async function fetchZip(ids, file) { const { token } = await api('POST', '/api/download-zip', { json: { ids } }); // step 1: one-time token (90 s) const r = await api('GET', '/api/download-zip/' + token, { raw: true, ms: 30 * 60e3 }); // step 2: the ZIP const tmp = file + '.part'; try { await pipeline(Readable.fromWeb(r.body), fs.createWriteStream(tmp)); const size = fs.statSync(tmp).size, want = Number(r.headers.get('content-length')) || 0; if (want && size !== want) throw new CliError(`download cut short (${size} of ${want} bytes)`, 1, 'SHORT'); fs.renameSync(tmp, file); return size; } catch (e) { fs.rmSync(tmp, { force: true }); throw e instanceof CliError ? e : new CliError(`download failed: ${e.message}`, 1, 'DOWNLOAD'); } } async function cmdDownload(o) { if ([o.folder !== undefined, o.all, o._.length > 0].filter(Boolean).length !== 1) throw usage('download needs exactly one of: --folder , --all, or item ids'); if (!o.output) throw usage('download needs -o '); const items = await listItems(); let ids; if (o.all) ids = items.map((i) => i.id); else if (o.folder !== undefined) { const f = needFolder(await listFolders(), o.folder); ids = items.filter((i) => i.folderId === f.id).map((i) => i.id); } else { const known = new Set(items.map((i) => i.id)); ids = needIds(o._).filter((id) => known.has(id)); if (!ids.length) throw new CliError('none of those ids exist (see `fp ls`)', 1, 'NOT_FOUND'); } if (!ids.length) { emit(o, { parts: [] }, 'no files to download'); return 0; } const pp = path.parse(o.output), nParts = Math.ceil(ids.length / ZIP_MAX); const files = Array.from({ length: nParts }, (_, n) => (nParts === 1 ? o.output : path.join(pp.dir, `${pp.name}-${String(n + 1).padStart(3, '0')}${pp.ext}`))); for (const f of files) if (fs.existsSync(f)) throw usage(`${f} already exists; choose another -o or remove it (fp never overwrites)`); const parts = []; for (const [n, file] of files.entries()) { const chunk = ids.slice(n * ZIP_MAX, (n + 1) * ZIP_MAX); parts.push({ file, items: chunk.length, bytes: await fetchZip(chunk, file) }); } emit(o, { parts }, `downloaded ${ids.length} files (${fmt(parts.reduce((s, x) => s + x.bytes, 0))}) → ${files.join(', ')}`); } async function cmdDelete(o) { if ((o._.length > 0) === (o.folder !== undefined)) throw usage('delete needs item ids or --folder (not both, not neither)'); const items = await listItems(); let targets; if (o.folder !== undefined) { const f = needFolder(await listFolders(), o.folder); targets = items.filter((i) => i.folderId === f.id); } else { const by = new Map(items.map((i) => [i.id, i])); targets = needIds(o._).map((id) => by.get(id)).filter(Boolean); } if (!targets.length) { emit(o, { count: 0 }, 'nothing to delete'); return o._.length ? 1 : 0; } const bytes = targets.reduce((s, i) => s + (i.size || 0), 0); const view = targets.map((i) => ({ id: i.id, name: i.name, size: i.size })); const list = more(view, (i) => ` ${i.id} ${i.name} (${fmt(i.size)})`).join('\n'); if (!o.yes) { emit(o, { dryRun: true, count: view.length, bytes, items: view }, `DRY RUN — would move ${view.length} file(s) (${fmt(bytes)}) to the Trash (kept 30 days):\n${list}\nNothing was deleted. Run again with --yes to do it.`); return 0; } const trashed = []; // the server answers with the ids it actually moved for (let i = 0; i < view.length; i += CHUNK) { trashed.push(...((await api('POST', '/api/items/delete', { json: { ids: view.slice(i, i + CHUNK).map((x) => x.id) } })).trashed || [])); } emit(o, { dryRun: false, trashed: trashed.length, ids: trashed, items: view }, `moved ${trashed.length} file(s) to the Trash (restorable for 30 days in the web app):\n${list}`); } const HELP = `fp — framepeek command-line client (Plus/Pro API key). One file, no dependencies, Node 18+. Setup: export FRAMEPEEK_API_KEY=fpk_... (environment only — never an argument) FRAMEPEEK_BASE=https://framepeek.com (default) Commands (add --json for machine-readable output; = folder name or id): whoami permissions, plan, free space, 24h transfer, key expiry ls [--folder N] [--unfiled] [--limit n|all] summary + latest files (default 20) mkdir create a folder (no-op if it exists) upload [--folder N] [--recursive] [--no-skip-existing] many files/dirs in one run; skips same name+size already there move (--to N | --unfiled) download (--folder N | --all | ) -o >2000 files → file-001.zip, file-002.zip … delete ( | --folder N) [--yes] moves to the Trash (30 days); without --yes only lists help Exit codes: 0 ok · 1 some items failed · 2 usage/config error · 3 server says stop (key bad/expired/suspended, no permission, plan, quota, storage full) — do not retry; tell the owner what the message says. Limits: image 25 MB, video 200 MB; ~120 requests/min per key; ZIP 2000 files; folders 1 level, 200 max. Permanent deletion is not available here — use the web app.`; const CMDS = { whoami: { flags: ['json'], values: [], run: cmdWhoami }, ls: { flags: ['json', 'unfiled'], values: ['folder', 'limit'], run: cmdLs }, mkdir: { flags: ['json'], values: [], run: cmdMkdir }, upload: { flags: ['json', 'recursive', 'no-skip-existing'], values: ['folder'], run: cmdUpload }, move: { flags: ['json', 'unfiled'], values: ['to'], run: cmdMove }, download: { flags: ['json', 'all'], values: ['folder', 'output'], run: cmdDownload }, delete: { flags: ['json', 'yes'], values: ['folder'], run: cmdDelete }, }; const ALIAS = { o: 'output', r: 'recursive' }; function parseArgs(argv, spec) { const o = { _: [] }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--') { o._.push(...argv.slice(i + 1)); break; } const m = /^--?([A-Za-z][\w-]*)(?:=([\s\S]*))?$/.exec(a); if (!m) { if (a.startsWith('-') && a !== '-') throw usage(`bad option ${a.slice(0, 20)}`); o._.push(a); continue; } const k = ALIAS[m[1]] || m[1]; if (k === 'key' || k === 'api-key') throw usage('the API key is read only from FRAMEPEEK_API_KEY (an argument would show in process lists)'); if (spec.flags.includes(k) && m[2] === undefined) o[k] = true; else if (spec.values.includes(k)) { const v = m[2] !== undefined ? m[2] : argv[++i]; if (v === undefined) throw usage(`--${k} needs a value`); o[k] = v; } else throw usage(`unknown option --${k} for this command (see \`fp help\`)`); } return o; } function checkEnv() { if (!KEY) throw usage('FRAMEPEEK_API_KEY is not set. Ask the owner to create a key (My page → API keys) and export it; never pass it as an argument.'); if (!/^fpk_[A-Za-z0-9_-]+$/.test(KEY)) throw usage('FRAMEPEEK_API_KEY does not look like a framepeek key (expected fpk_…)'); let u; try { u = new URL(BASE); } catch { throw usage('FRAMEPEEK_BASE is not a valid URL'); } if (u.protocol !== 'https:' && !(u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname))) { throw usage('FRAMEPEEK_BASE must be https:// (plain http is allowed for localhost only): the key would travel unencrypted'); } } async function main() { const [cmd, ...rest] = process.argv.slice(2); if (!cmd) { print(HELP); return 2; } if (cmd === 'help' || cmd === '-h' || cmd === '--help') { print(HELP); return 0; } const c = CMDS[cmd]; if (!c) throw usage(`unknown command "${cmd.slice(0, 30)}" (run \`fp help\`)`); const o = parseArgs(rest, c); checkEnv(); return (await c.run(o)) || 0; } main().then((code) => { process.exitCode = code; }, (e) => { const err = e instanceof CliError ? e : new CliError(`unexpected error: ${(e && e.message) || e}`, 1, 'INTERNAL'); eprint(`fp: ${err.message}`); if (process.argv.includes('--json') && !err.printed) print(JSON.stringify({ ok: false, code: err.code, error: err.message, exit: err.exit })); process.exitCode = err.exit; });