doctor: build dependencies (JDK with the version the project needs, Android SDK command-line tools, CocoaPods, Node, Bun, bundletool), too-old status, doctor --install <tool> --yes runs the package-manager command (never sudo); desktop Dependencies shows too old; gen-flags works on Windows paths

This commit is contained in:
CRTkafa 2026-10-22 02:21:26 +08:00
parent a8a889fb35
commit 2598c4953a
10 changed files with 137 additions and 14 deletions

View file

@ -8,7 +8,7 @@ import { existsSync, readFileSync } from 'node:fs';
import { delimiter, join } from 'node:path';
import * as rt from './rt';
export type ToolId = 'ffmpeg' | 'ffprobe' | 'chrome' | 'xcode' | 'simctl' | 'adb' | 'git';
export type ToolId = 'ffmpeg' | 'ffprobe' | 'chrome' | 'xcode' | 'simctl' | 'adb' | 'git' | 'java' | 'sdkmanager' | 'cocoapods' | 'node' | 'bun' | 'bundletool';
export interface ToolStatus {
id: ToolId;
@ -22,6 +22,10 @@ export interface ToolStatus {
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 };
/** The version the project needs (JDK from the Android Gradle plugin / jvmToolchain), when one is known. */
required?: string | null;
/** ok, missing, or found but older than `required`. */
status?: 'ok' | 'missing' | 'outdated';
}
/** Folders where tools usually live but that are often missing from PATH (apps started from the Dock or Finder get
@ -57,6 +61,8 @@ function firstLine(cmd: string[], env: Record<string, string>): string | 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;
const java = /version "(\d[^"]*)"/.exec(line)?.[1];
if (java) return java;
return line.match(/version\s+([^\s,]+)/i)?.[1] ?? line.match(/(\d+(?:\.\d+)+)/)?.[1] ?? null;
}
@ -69,7 +75,7 @@ export function systemChrome(platform = process.platform, env: Record<string, st
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[] {
export function detectTools(env: Record<string, string | undefined> = process.env, project?: string): 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);
@ -102,9 +108,44 @@ export function detectTools(env: Record<string, string | undefined> = process.en
}
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']));
// build toolchain: what play build / apple build need
const javaBin = env.JAVA_HOME && existsSync(join(env.JAVA_HOME, 'bin')) ? join(env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'java.exe' : 'java') : which('java');
out.push({ id: 'java', name: 'JDK (Java)', found: !!javaBin, path: javaBin, version: javaBin ? parseVersion(firstLine([javaBin, '-version'], spawnEnv)) : null, source: javaBin ? 'system' : null,
usedFor: 'play build (Gradle), keystore checks (keytool)', required: project ? requiredJdk(project) : null,
install: { kind: 'link', url: 'https://adoptium.net/temurin/releases/', note: 'Temurin JDK; the version the Android Gradle plugin asks for.' } });
const sdk = androidSdk(project ? [join(project, 'android'), project] : [], env);
const sdkm = sdk.path ? ['cmdline-tools/latest/bin', 'tools/bin'].map(d => join(sdk.path!, d, process.platform === 'win32' ? 'sdkmanager.bat' : 'sdkmanager')).find(f => existsSync(f)) ?? null : null;
out.push({ id: 'sdkmanager', name: 'Android SDK command-line tools', found: !!sdkm, path: sdkm ?? sdk.path, version: null, source: sdkm ? 'system' : null,
usedFor: 'play build: SDK platforms and build tools', install: { kind: 'link', url: 'https://developer.android.com/studio#command-tools', note: 'Android Studio, or the command-line tools alone.' } });
if (process.platform === 'darwin') out.push(bin('pod' as ToolId, 'CocoaPods', 'iOS builds of Capacitor / React Native / Flutter apps with a Podfile', { kind: 'link', url: 'https://cocoapods.org', note: 'brew install cocoapods' }, ['--version']));
out.push(bin('node', 'Node.js', 'web builds (npm scripts), npx storebound', { kind: 'link', url: 'https://nodejs.org', note: 'The LTS release.' }, ['--version']));
out.push(bin('bun', 'Bun', 'storebound from a git clone, web builds', { kind: 'link', url: 'https://bun.sh', note: 'bun.sh installer or a package manager.' }, ['--version']));
out.push(bin('bundletool', 'bundletool', 'checks of the AAB (sizes, APKs per device)', { kind: 'link', url: 'https://github.com/google/bundletool/releases', note: 'Optional.' }, ['version']));
for (const t of out) {
if ((t.id as string) === 'pod') t.id = 'cocoapods';
t.status = !t.found ? 'missing' : t.required && t.version && majorOf(t.version) < majorOf(t.required) ? 'outdated' : 'ok';
}
return out;
}
/** "17.0.9" -> 17; "1.8.0_392" -> 8. */
export const majorOf = (v: string): number => { const n = v.replace(/^1\.(?=\d+\.)/, '').match(/\d+/); return n ? Number(n[0]) : 0; };
/**
* The JDK major version an Android project needs: jvmToolchain(N) / JavaVersion.VERSION_N in its Gradle files, at
* least the Android Gradle plugin's minimum (AGP 8.x: 17, 7.x: 11). Null when the project has no Gradle files.
*/
export function requiredJdk(project: string): string | null {
const files = ['android/build.gradle', 'android/build.gradle.kts', 'android/app/build.gradle', 'android/app/build.gradle.kts', 'android/gradle/libs.versions.toml',
'build.gradle', 'build.gradle.kts', 'app/build.gradle', 'app/build.gradle.kts', 'gradle/libs.versions.toml'].map(f => join(project, f)).filter(f => existsSync(f));
if (!files.length) return null;
const text = files.map(f => { try { return readFileSync(f, 'utf8'); } catch { return ''; } }).join('\n');
const toolchain = /jvmToolchain\s*\(\s*(\d+)/.exec(text)?.[1] ?? /JavaVersion\.VERSION_(\d+)/.exec(text)?.[1] ?? /JavaLanguageVersion\.of\(\s*(\d+)/.exec(text)?.[1];
const agp = /com\.android\.tools\.build:gradle:(\d+)/.exec(text)?.[1] ?? /^\s*(?:agp|androidGradlePlugin|android-gradle-plugin)\s*=\s*"(\d+)/m.exec(text)?.[1];
const fromAgp = agp ? (Number(agp) >= 8 ? 17 : Number(agp) >= 7 ? 11 : 8) : 17;
return String(Math.max(toolchain ? Number(toolchain) : 0, fromAgp));
}
/**
* 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.
@ -119,6 +160,12 @@ export function installCommands(id: ToolId | 'playwright', platform: NodeJS.Plat
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'];
case 'java': return mac ? ['brew install --cask temurin@17'] : win ? ['winget install --id EclipseAdoptium.Temurin.17.JDK'] : ['sudo apt install openjdk-17-jdk'];
case 'sdkmanager': return mac ? ['brew install --cask android-commandlinetools'] : win ? ['winget install --id Google.AndroidStudio'] : ['sudo snap install android-studio --classic'];
case 'cocoapods': return mac ? ['brew install cocoapods'] : [];
case 'node': return mac ? ['brew install node'] : win ? ['winget install --id OpenJS.NodeJS.LTS'] : ['sudo apt install nodejs npm'];
case 'bun': return mac ? ['brew install oven-sh/bun/bun'] : win ? ['winget install --id Oven-sh.Bun'] : ['curl -fsSL https://bun.sh/install | bash'];
case 'bundletool': return mac ? ['brew install bundletool'] : [];
}
}