storebound/cli/lib/tools.ts

130 lines
9.1 KiB
TypeScript

// Optional local tools storebound uses (screenshots, previews, icons, simulator and emulator captures), found on this
// machine with their versions. The desktop app can manage its own copies of ffmpeg and Chrome for Testing in its data
// folder and points storebound at them:
// STOREBOUND_TOOL_PATH folders put in front of PATH for every command (app-managed ffmpeg / ffprobe)
// STOREBOUND_CHROME Chrome / Chrome for Testing executable for renders and web captures
// Nothing is installed system-wide. Sources and licenses: docs/desktop.md.
import { existsSync } from 'node:fs';
import { delimiter, join } from 'node:path';
export type ToolId = 'ffmpeg' | 'ffprobe' | 'chrome' | 'xcode' | 'simctl' | 'adb' | 'git';
export interface ToolStatus {
id: ToolId;
name: string;
found: boolean;
path: string | null;
version: string | null;
/** 'app' = the desktop app's own copy (STOREBOUND_TOOL_PATH / STOREBOUND_CHROME), 'system' = installed by the user. */
source: 'app' | 'system' | null;
/** What needs it. */
usedFor: string;
/** How to get it: the desktop app downloads ffmpeg and Chrome for Testing; Xcode and the Android SDK are link-out. */
install: { kind: 'download' | 'link'; url: string; note: string };
}
/** Folders where tools usually live but that are often missing from PATH (apps started from the Dock or Finder get
* a minimal PATH): the Android SDK that Android Studio installs, Homebrew, and the usual local bin folders. */
export function wellKnownToolDirs(env: Record<string, string | undefined> = process.env, platform: string = process.platform): string[] {
const home = env.HOME ?? env.USERPROFILE ?? '';
if (!home) return []; // a bare environment (tests, sandboxes) keeps exactly its own PATH
const sdks = [env.ANDROID_HOME, env.ANDROID_SDK_ROOT,
platform === 'darwin' ? join(home, 'Library/Android/sdk') : platform === 'win32' ? join(env.LOCALAPPDATA ?? join(home, 'AppData/Local'), 'Android/Sdk') : join(home, 'Android/Sdk')];
const dirs = sdks.filter((d): d is string => !!d).flatMap(d => [join(d, 'platform-tools'), join(d, 'emulator')]);
if (platform === 'darwin') dirs.push('/opt/homebrew/bin', '/usr/local/bin');
else if (platform !== 'win32') dirs.push('/usr/local/bin', join(home, '.local/bin'));
return [...new Set(dirs)].filter(d => existsSync(d));
}
/** PATH with the app-managed tool folders first and the well-known tool folders after the user's own PATH. */
export function toolPath(env: Record<string, string | undefined> = process.env, platform: string = process.platform): string {
const extra = (env.STOREBOUND_TOOL_PATH ?? '').split(delimiter).filter(Boolean);
const own = (env.PATH ?? '').split(delimiter).filter(Boolean);
const known = wellKnownToolDirs(env, platform).filter(d => !own.includes(d));
return [...extra, ...own, ...known].join(delimiter);
}
/** First line of `<cmd> <args>` (version banners), or null. */
function firstLine(cmd: string[], env: Record<string, string>): string | null {
try {
const r = Bun.spawnSync(cmd, { stdout: 'pipe', stderr: 'pipe', env, timeout: 5_000 });
if (r.exitCode !== 0) return null;
return (r.stdout.toString() || r.stderr.toString()).trim().split('\n')[0]?.trim() || null;
} catch { return null; }
}
/** "ffmpeg version 7.1.1 Copyright ..." -> "7.1.1"; "Google Chrome for Testing 131.0.6778.85" -> "131.0.6778.85". */
export function parseVersion(line: string | null): string | null {
if (!line) return null;
return line.match(/version\s+([^\s,]+)/i)?.[1] ?? line.match(/(\d+(?:\.\d+)+)/)?.[1] ?? null;
}
export function systemChrome(platform = process.platform, env: Record<string, string | undefined> = process.env): string | null {
const c = platform === 'darwin' ? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome']
: platform === 'win32' ? [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter((d): d is string => !!d).map(d => join(d, 'Google/Chrome/Application/chrome.exe'))
: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
return c.find(p => existsSync(p)) ?? null;
}
const FFMPEG_NOTE = 'Static build into the app data folder (GPL; never bundled with the app). Checksum-verified.';
export function detectTools(env: Record<string, string | undefined> = process.env): ToolStatus[] {
const PATH = toolPath(env);
const spawnEnv: Record<string, string> = { ...Object.fromEntries(Object.entries(env).filter((e): e is [string, string] => e[1] !== undefined)), PATH };
const appDirs = (env.STOREBOUND_TOOL_PATH ?? '').split(delimiter).filter(Boolean);
const which = (n: string): string | null => Bun.which(n, { PATH });
const src = (p: string | null): ToolStatus['source'] => (!p ? null : appDirs.some(d => p.startsWith(d)) ? 'app' : 'system');
const bin = (id: ToolId, name: string, usedFor: string, install: ToolStatus['install'], versionArgs: string[]): ToolStatus => {
const path = which(id);
const version = path ? parseVersion(firstLine([path, ...versionArgs], spawnEnv)) : null;
return { id, name, found: !!path, path, version, source: src(path), usedFor, install };
};
const out: ToolStatus[] = [
bin('ffmpeg', 'ffmpeg', 'icons, screenshot renders, app preview videos, frame crops', { kind: 'download', url: 'https://ffmpeg.org/download.html', note: FFMPEG_NOTE }, ['-version']),
bin('ffprobe', 'ffprobe', 'checks of app preview videos and clips', { kind: 'download', url: 'https://ffmpeg.org/download.html', note: 'Comes with the ffmpeg download.' }, ['-version']),
];
const appChrome = env.STOREBOUND_CHROME && existsSync(env.STOREBOUND_CHROME) ? env.STOREBOUND_CHROME : null;
const chrome = appChrome ?? systemChrome(process.platform, env);
out.push({
id: 'chrome', name: appChrome ? 'Chrome for Testing' : 'Google Chrome', found: !!chrome, path: chrome,
version: chrome ? parseVersion(firstLine([chrome, '--version'], spawnEnv)) : null, source: appChrome ? 'app' : chrome ? 'system' : null,
usedFor: 'screenshot renders, captions, web captures and recordings',
install: { kind: 'download', url: 'https://googlechromelabs.github.io/chrome-for-testing/', note: 'Chrome for Testing from Google\'s official endpoints into the app data folder.' },
});
if (process.platform === 'darwin') {
const dev = firstLine(['xcode-select', '-p'], spawnEnv);
out.push({ id: 'xcode', name: 'Xcode command line tools', found: !!dev, path: dev, version: parseVersion(firstLine(['xcodebuild', '-version'], spawnEnv)), source: dev ? 'system' : null,
usedFor: 'iOS builds, the iOS Simulator (simctl)', install: { kind: 'link', url: 'https://developer.apple.com/xcode/', note: 'Install Xcode from the App Store or developer.apple.com.' } });
const simctl = firstLine(['xcrun', '--find', 'simctl'], spawnEnv);
out.push({ id: 'simctl', name: 'simctl (iOS Simulator)', found: !!simctl, path: simctl, version: null, source: simctl ? 'system' : null,
usedFor: 'iOS Simulator screenshots and recordings', install: { kind: 'link', url: 'https://developer.apple.com/xcode/', note: 'Part of Xcode.' } });
}
out.push(bin('adb', 'adb (Android platform tools)', 'Android emulator / device screenshots and recordings', { kind: 'link', url: 'https://developer.android.com/tools/releases/platform-tools', note: 'Install Android Studio or the SDK platform tools.' }, ['version']));
out.push(bin('git', 'git', 'release notes from history, analytics compare', { kind: 'link', url: 'https://git-scm.com/downloads', note: 'Install git from your OS package manager.' }, ['--version']));
return out;
}
/**
* Commands that install a tool on this OS (shown by `storebound doctor` and the desktop Dependencies screen, never
* run by storebound). Package managers only; the desktop app can also download ffmpeg and Chrome for Testing itself.
*/
export function installCommands(id: ToolId | 'playwright', platform: NodeJS.Platform = process.platform): string[] {
const mac = platform === 'darwin', win = platform === 'win32';
switch (id) {
case 'ffmpeg': case 'ffprobe': return mac ? ['brew install ffmpeg'] : win ? ['winget install --id Gyan.FFmpeg'] : ['sudo apt install ffmpeg', 'sudo dnf install ffmpeg'];
case 'chrome': return ['bunx playwright install chromium', ...(mac ? ['brew install --cask google-chrome'] : win ? ['winget install --id Google.Chrome'] : [])];
case 'playwright': return ['bun add -d playwright'];
case 'xcode': return mac ? ['xcode-select --install'] : [];
case 'simctl': return mac ? ['Install Xcode from the App Store, then: sudo xcode-select -s /Applications/Xcode.app'] : [];
case 'adb': return mac ? ['brew install --cask android-platform-tools'] : win ? ['winget install --id Google.PlatformTools'] : ['sudo apt install adb'];
case 'git': return mac ? ['xcode-select --install', 'brew install git'] : win ? ['winget install --id Git.Git'] : ['sudo apt install git'];
}
}
/** Where playwright resolves from (the project, then the storebound folder), or null. */
export function findPlaywright(bases: string[]): string | null {
for (const base of bases) {
try { return Bun.resolveSync('playwright', base); } catch { /* next */ }
}
return null;
}