#!/usr/bin/env node /** * shotbot | Node.js CLI for the Shotbot screenshot API. * * Single-file standalone script. Requires Node.js 18+ (built-in fetch). * No third-party dependencies. Run `./shotbot help` for usage. * * Copyright (c) 2026 Valentin Beck * SPDX-License-Identifier: MIT */ 'use strict'; const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const { URL } = require('node:url'); const VERSION = '0.3.0'; const API_BASE_DEFAULT = 'https://api.shotbot.net'; const POLL_INITIAL_S = 2; const POLL_INTERVAL_S = 2; const POLL_TIMEOUT_S = 180; // ── Bootstrap ──────────────────────────────────────────────────────────────── const _nodeMajor = parseInt(process.versions.node.split('.')[0], 10); if (!Number.isFinite(_nodeMajor) || _nodeMajor < 18) { process.stderr.write(`shotbot: requires Node.js 18+ (got ${process.versions.node})\n`); process.exit(2); } if (typeof fetch !== 'function') { process.stderr.write('shotbot: this Node build is missing the global fetch() (need 18+)\n'); process.exit(2); } // ── Output helpers ─────────────────────────────────────────────────────────── let _colorCache = null; function useColor() { if (_colorCache !== null) return _colorCache; if ('NO_COLOR' in process.env) { _colorCache = false; return false; } _colorCache = !!process.stdout.isTTY; return _colorCache; } function clr(s, code) { return useColor() ? `\x1b[${code}m${s}\x1b[0m` : s; } function note(s = '') { process.stdout.write(s + '\n'); } function ok(s) { process.stdout.write(clr('✓', '32') + ' ' + s + '\n'); } function warn(s) { process.stderr.write(clr('!', '33') + ' ' + s + '\n'); } function err(s) { process.stderr.write(clr('✗', '31') + ' ' + s + '\n'); } function dim(s) { return clr(s, '2'); } function bold(s) { return clr(s, '1'); } // ── Config ─────────────────────────────────────────────────────────────────── function home() { const h = os.homedir() || process.env.HOME || process.env.USERPROFILE || ''; if (!h) { err('cannot determine home directory'); process.exit(2); } return h.replace(/[\\/]+$/, ''); } function configDir() { return path.join(home(), '.shotbot-node-cli'); } function configPath() { return path.join(configDir(), 'config.json'); } function configLoad() { try { const raw = fs.readFileSync(configPath(), 'utf8'); const j = JSON.parse(raw); return (j && typeof j === 'object' && !Array.isArray(j)) ? j : {}; } catch { return {}; } } function configSave(c) { const d = configDir(); try { fs.mkdirSync(d, { recursive: true, mode: 0o700 }); } catch { err(`cannot create config directory: ${d}`); process.exit(2); } try { fs.chmodSync(d, 0o700); } catch {} const p = configPath(); try { fs.writeFileSync(p, JSON.stringify(c, null, 2) + '\n', { mode: 0o600 }); } catch { err(`cannot write config file: ${p}`); process.exit(2); } try { fs.chmodSync(p, 0o600); } catch {} } function apiKey() { const env = (process.env.SHOTBOT_API_KEY || '').trim(); if (env) return env; const cfg = configLoad(); if (cfg.api_key) return String(cfg.api_key); return promptForKey(); } // ── Persistent capture defaults ────────────────────────────────────────────── // Keys allowed in config.json `defaults`. Type drives coercion in `set` and // validation when merging into the parsed CLI options at capture time. function defaultKeys() { return { // All tiers 'preset': 'string', // og|mobile|youtube_thumbnail|square|reel|pinterest|tablet|desktop|desktop_hd|twitter_header|linkedin_banner|hero_banner 'frame': 'string', // rounded|shadow|browser_chrome|browser_chrome_dark|mobile|mobile_light|tablet|tablet_light|laptop|polaroid|gradient|shotbot_brand 'format': 'string', // jpg|png|webp|avif|pdf 'viewport': 'int', // 280..3840 (Pro custom; common 360..1920) 'output-size': 'int', // 120..1920 'wait': 'int', // 0..120 'ratio': 'string', // 16:9, 4:3, 1:1, ... 'color-scheme': 'string', // dark|light 'full-page': 'bool', 'nojs': 'bool', 'hidpi': 'bool', 'reduce-motion': 'bool', // emulate prefers-reduced-motion: reduce // Pro 'dismiss-cookies': 'string', // accept|reject 'scroll': 'bool', 'scroll-offset': 'int', // 0..30000, scroll N px before capture 'scroll-to': 'string', // CSS selector, scrollIntoView before capture 'block-ads': 'bool', 'crop-height': 'int', 'selector': 'string', 'autoplay-videos': 'bool', 'omit-background': 'bool', 'emulate-print-media': 'bool', 'http-auth-user': 'string', 'http-auth-pass': 'string', // PDF (only meaningful when format=pdf) 'pdf-page-size': 'string', // A4|A3|A5|Letter|Legal|Tabloid 'pdf-margin': 'int', // 0..50 mm 'pdf-scale': 'float', // 0.10..2.00 'pdf-landscape': 'bool', // CLI ergonomics 'output': 'string', // path or "true" for cwd-auto-name 'cdn': 'bool', // upload to the public CDN (default: private) 'timeout': 'int', // poll timeout, seconds }; } const ENUMS = { 'color-scheme': ['dark', 'light'], 'dismiss-cookies': ['accept', 'reject'], 'format': ['jpg', 'png', 'webp', 'avif', 'pdf'], 'pdf-page-size': ['A4', 'A3', 'A5', 'Letter', 'Legal', 'Tabloid'], }; function coerce(key, raw, kind) { if (kind === 'bool') { const v = String(raw).trim().toLowerCase(); if (['1','true','yes','on'].includes(v)) return true; if (['0','false','no','off'].includes(v)) return false; err(`invalid boolean for ${key}: '${raw}' (use true|false)`); process.exit(2); } if (kind === 'int') { if (!/^-?\d+$/.test(String(raw).trim())) { err(`invalid integer for ${key}: '${raw}'`); process.exit(2); } return parseInt(raw, 10); } if (kind === 'float') { const n = Number(String(raw).trim()); if (!Number.isFinite(n)) { err(`invalid number for ${key}: '${raw}'`); process.exit(2); } return n; } // Enum keys: validate against the canonical set defined in capture_options.php. if (Object.prototype.hasOwnProperty.call(ENUMS, key) && !ENUMS[key].includes(raw)) { err(`invalid value for ${key}: '${raw}' (allowed: ${ENUMS[key].join('|')})`); process.exit(2); } return raw; } function applyDefaults(opts) { const cfg = configLoad(); const defaults = (cfg.defaults && typeof cfg.defaults === 'object' && !Array.isArray(cfg.defaults)) ? cfg.defaults : null; if (!defaults) return opts; const allowed = defaultKeys(); for (const [k, v] of Object.entries(defaults)) { if (!Object.prototype.hasOwnProperty.call(allowed, k)) continue; // unknown key: ignore silently if (Object.prototype.hasOwnProperty.call(opts, k)) continue; // CLI flag wins opts[k] = v; } return opts; } function apiBase() { const b = (process.env.SHOTBOT_API_BASE || '').trim(); return b ? b.replace(/\/+$/, '') : API_BASE_DEFAULT; } function promptForKey() { note(); note(bold('No Shotbot API key configured.')); note('Get one at ' + clr('https://www.shotbot.net/api-key/', '36')); note(); process.stdout.write('API key: '); const buf = Buffer.alloc(4096); let n = 0; try { n = fs.readSync(0, buf, 0, buf.length, null); } catch { err('cannot read from stdin'); process.exit(2); } const key = buf.slice(0, n).toString('utf8').replace(/[\r\n]+$/, '').trim(); if (!key) { err('empty key, aborting'); process.exit(2); } if (!/^[0-9A-Za-z]{12}$/.test(key)) { warn('key does not match expected format (12 alphanumeric chars), saving anyway'); } const cfg = configLoad(); cfg.api_key = key; configSave(cfg); ok(`Saved to ${configPath()} (chmod 600)`); note(); return key; } // ── HTTP ───────────────────────────────────────────────────────────────────── function truthy(v) { if (v === undefined || v === null || v === false) return false; if (v === '' || v === 0 || v === '0') return false; return true; } async function http(method, urlPath, body) { const url = apiBase() + urlPath; const headers = { 'Accept': 'application/json', 'User-Agent': `shotbot-node-cli/${VERSION}`, }; const init = { method, headers }; if (method === 'POST') { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body || {}); } let resp; const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 60000); try { resp = await fetch(url, { ...init, signal: ctrl.signal }); } catch (e) { err(`network error: ${e && e.message ? e.message : e}`); process.exit(3); } finally { clearTimeout(t); } const raw = await resp.text(); let j = null; try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) j = parsed; } catch {} return { code: resp.status, json: j, raw }; } async function download(url, dest) { let resp; const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 120000); try { resp = await fetch(url, { headers: { 'User-Agent': `shotbot-node-cli/${VERSION}` }, redirect: 'follow', signal: ctrl.signal, }); } catch (e) { try { fs.unlinkSync(dest); } catch {} err(`download failed: ${e && e.message ? e.message : e}`); process.exit(3); } finally { clearTimeout(t); } if (resp.status !== 200) { try { fs.unlinkSync(dest); } catch {} err(`download failed (HTTP ${resp.status})`); process.exit(3); } let buf; try { buf = Buffer.from(await resp.arrayBuffer()); } catch (e) { err(`download failed: ${e && e.message ? e.message : e}`); process.exit(3); } try { fs.writeFileSync(dest, buf); } catch { err(`cannot open ${dest} for writing`); process.exit(2); } try { return fs.statSync(dest).size; } catch { return 0; } } // ── Args parser ────────────────────────────────────────────────────────────── function parseArgs(argv) { const rest = argv.slice(2); // drop node + script let cmd = ''; if (rest.length && !rest[0].startsWith('-')) cmd = rest.shift(); const opts = {}; const pos = []; let i = 0; while (i < rest.length) { const a = rest[i]; if (a.startsWith('--')) { const tail = a.slice(2); const eq = tail.indexOf('='); if (eq >= 0) { opts[tail.slice(0, eq)] = tail.slice(eq + 1); } else { const nxt = i + 1 < rest.length ? rest[i + 1] : undefined; if (nxt !== undefined && !nxt.startsWith('-')) { opts[tail] = nxt; i++; } else { opts[tail] = true; } } } else if (a.startsWith('-') && a.length > 1) { opts[a.slice(1)] = true; } else { pos.push(a); } i++; } return { cmd, opts, pos }; } // ── Commands ───────────────────────────────────────────────────────────────── function cmdHelp() { const cfg = configPath(); process.stdout.write(` shotbot v${VERSION} Node.js CLI for the Shotbot screenshot API USAGE shotbot [options] COMMANDS capture Capture a single URL (see CAPTURE OPTIONS). status Show account plan, credits, quota and in-flight captures. set [value] Set a persistent default for \`capture\` (see DEFAULTS). Booleans accept true/false/yes/no/1/0; bare \`set \` on a boolean is shorthand for \`set true\`. unset Remove a persistent default. defaults List currently-set persistent defaults. defaults --keys List every key you can \`set\` and its type. config Print the path to the config file. reset Re-prompt for the API key (overwrites the stored one). version Print the version and exit. help Print this help. CAPTURE OPTIONS (free tier) --url=URL Required. The page to capture (http/https). --preset=NAME Named output bundle: og, mobile, youtube_thumbnail, square, reel, pinterest, tablet (free); desktop, desktop_hd, twitter_header, linkedin_banner, hero_banner (Pro). Fills viewport/output-size/crop-height in one go. --frame=NAME Decorative frame around the image: rounded, shadow, browser_chrome, browser_chrome_dark, mobile, mobile_light, tablet, tablet_light, laptop, polaroid, gradient, shotbot_brand. Free accounts get a "shotbot.fr" mark | clean with Pro. --format=FMT jpg | png | webp | avif | pdf (default: jpg) --viewport=N Viewport width, common 360..1920; Pro 280..3840 (default: 1280) --output-size=N Resize the result. 120..1920 px (default: same as viewport) --wait=N Seconds to wait after page load 0..120 (default: 5) --ratio=R 16:9, 4:3, 1:1, 9:16, etc. (default: 16:9) --full-page Capture the full scrollable page (max 30000 px). --nojs Disable JavaScript before navigation. --color-scheme=dark|light Force prefers-color-scheme (matches API field). --hidpi Render at 2x device pixel ratio. --reduce-motion Emulate prefers-reduced-motion: reduce (well-built sites skip animations | cleaner captures). CAPTURE OPTIONS (Pro) --dismiss-cookies=accept|reject Auto-handle cookie consent banners. --scroll Scroll the page before capture (triggers lazy-loaded images). --scroll-offset=N Scroll down N px before capture (0..30000). --scroll-to=CSS Scroll the matched element into view before capture (mutually exclusive with --scroll-offset). --block-ads Block ad-network requests. --crop-height=N Crop the result to N px tall (10..20000), overrides --ratio, implies full-page. --selector=CSS Capture only the matched DOM element. --autoplay-videos Allow unmuted