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:
parent
a8a889fb35
commit
2598c4953a
10 changed files with 137 additions and 14 deletions
|
|
@ -2,6 +2,11 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- **Build dependencies in `doctor` and the desktop Dependencies screen.** JDK (with the version the project needs from
|
||||
the Android Gradle plugin / jvmToolchain, "too old" when below it), Android SDK command-line tools, CocoaPods, Node.js,
|
||||
Bun and bundletool join ffmpeg, Chrome, Xcode, adb and git. `storebound doctor --install <tool> --yes` runs this OS's
|
||||
package-manager command (winget / brew); sudo and admin commands are printed, never run.
|
||||
|
||||
- **A livelier terminal.** At a terminal, lines get colors and status emoji (✅ ⚠️ ❌ 📦 🚀 🧪) and every wait ("waiting
|
||||
for ...", uploads, downloads, progress) is one live spinner line with the elapsed time. Piped, CI, `NO_COLOR`,
|
||||
`TERM=dumb`, `--json` and MCP keep the plain lines exactly as before.
|
||||
|
|
|
|||
|
|
@ -543,6 +543,7 @@ export const en = {
|
|||
'deps.tools': 'Tools on this computer',
|
||||
'deps.found': '{found} of {total} found',
|
||||
'deps.missing': 'Missing',
|
||||
'deps.outdated': 'Too old (needs {version})',
|
||||
'deps.appCopy': 'App copy',
|
||||
'deps.installed': 'Installed',
|
||||
'deps.download': 'Download',
|
||||
|
|
|
|||
|
|
@ -545,6 +545,7 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'deps.tools': 'Bu bilgisayardaki araçlar',
|
||||
'deps.found': '{total} araçtan {found} tanesi var',
|
||||
'deps.missing': 'Eksik',
|
||||
'deps.outdated': 'Eski (gereken {version})',
|
||||
'deps.appCopy': 'Uygulamanın kopyası',
|
||||
'deps.installed': 'Kurulu',
|
||||
'deps.download': 'İndir',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const TOOL_ICON: Record<string, IconName> = { ffmpeg: 'image', ffprobe: 'search'
|
|||
/** Status chip of one tool. */
|
||||
export function toolState(x: ToolStatus): { text: string; tone: 'ok' | 'warn' | 'accent' } {
|
||||
if (!x.found) return { text: t('deps.missing'), tone: 'warn' };
|
||||
if (x.status === 'outdated') return { text: t('deps.outdated', { version: x.required ?? '' }), tone: 'warn' };
|
||||
return x.source === 'app' ? { text: t('deps.appCopy'), tone: 'accent' } : { text: t('deps.installed'), tone: 'ok' };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const PLAIN: Record<string, [() => Promise<Plain>, string]> = {
|
|||
'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only | folders | add [<folder>] | remove <folder>: the app folders of this machine, the same list as the desktop app'],
|
||||
'desktop': [() => import('./desktop'), 'open the storebound desktop app on this folder; not installed or another version: download it for this system (size, SHA-256, signature checked), install it for this user (asks once; --yes) and open it; --download only saves the installer, --dry / --json change nothing'],
|
||||
'accounts': [() => import('./accounts'), 'the store accounts of this machine (credential profiles in ~/.storebound/credentials, shared with the desktop app): list | import (a project\'s store keys -> a profile; --dry first) | use <profile> | default <profile> (the machine default; --clear)'],
|
||||
'doctor': [() => import('./doctor'), 'local tools for screenshots, previews, icons and captures (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install what is missing; --json'],
|
||||
'doctor': [() => import('./doctor'), 'build and capture dependencies (JDK with the version the project needs, Android SDK, Xcode, CocoaPods, Node, Bun, ffmpeg, Chrome, adb, git, bundletool) and how to install what is missing; --install <tool> --yes; --json'],
|
||||
'scan': [() => import('./scan'), 'offline, read-only rejection-risk scan of the project itself (stack, SDKs, Info.plist, manifest, listings) with guideline, evidence file:line, fix and skill, plus the Data safety / App Privacy / ATT / privacy manifest proposal; --write puts the detected answers into the config; --json; exit 1 on blockers'],
|
||||
'api': [() => import('./api'), 'every App Store Connect / Google Play API operation: list | coverage | apple|play <operationId|METHOD path> [--param k=v] [--body f.json] [--all-pages] (GET runs; writes dry unless --yes in a terminal; owner-only ones need the id typed)'],
|
||||
'privacy-manifest': [() => import('./privacy-manifest'), 'iOS PrivacyInfo.xcprivacy + required-reason API + SDK manifest scan (--ios <path>)'],
|
||||
|
|
|
|||
|
|
@ -459,7 +459,8 @@
|
|||
"xdg-mime"
|
||||
],
|
||||
"doctor": [
|
||||
"dir"
|
||||
"dir",
|
||||
"install"
|
||||
],
|
||||
"edit": [
|
||||
"ops",
|
||||
|
|
@ -1019,6 +1020,7 @@
|
|||
"identity",
|
||||
"include-drafts",
|
||||
"include-high-income",
|
||||
"install",
|
||||
"internal",
|
||||
"internal-only",
|
||||
"invite",
|
||||
|
|
@ -1370,7 +1372,8 @@
|
|||
"storebound desktop --download only save the installer for this system into ~/Downloads (checked as above)"
|
||||
],
|
||||
"doctor": [
|
||||
"storebound doctor [--dir .] [--json]"
|
||||
"storebound doctor [--dir .] [--json]",
|
||||
"storebound doctor --install <tool> [--yes] runs this OS's install command for one tool (winget / brew / apt);"
|
||||
],
|
||||
"edit": [
|
||||
"storebound edit --ops '<json>' [--dry] [--json]",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
// Dependencies screen (lib/tools.ts). Store work (listings, products, releases, reviews) needs none of these tools;
|
||||
// screenshots, app previews, icons and simulator / emulator captures do. Read-only: it prints commands, it runs none.
|
||||
// storebound doctor [--dir .] [--json]
|
||||
// storebound doctor --install <tool> [--yes] runs this OS's install command for one tool (winget / brew / apt);
|
||||
// without --yes only prints it. Never sudo, never an admin prompt.
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import pkg from '../package.json' with { type: 'json' };
|
||||
|
|
@ -22,6 +24,9 @@ export interface DoctorRow {
|
|||
path: string | null;
|
||||
source: ToolStatus['source'];
|
||||
usedFor: string;
|
||||
/** The version the project needs and ok / missing / outdated. */
|
||||
required?: string | null;
|
||||
status?: 'ok' | 'missing' | 'outdated';
|
||||
/** Install commands for this OS (empty when found). */
|
||||
install: string[];
|
||||
url: string | null;
|
||||
|
|
@ -38,30 +43,54 @@ export interface DoctorResult {
|
|||
}
|
||||
|
||||
export function doctor(cwd: string, env: Record<string, string | undefined> = process.env): DoctorResult {
|
||||
const tools: DoctorRow[] = detectTools(env).map(t => ({ id: t.id, name: t.name, found: t.found, version: t.version, path: t.path, source: t.source, usedFor: t.usedFor,
|
||||
install: t.found ? [] : installCommands(t.id), url: t.install.url }));
|
||||
const tools: DoctorRow[] = detectTools(env, cwd).map(t => ({ id: t.id, name: t.name, found: t.found, version: t.version, path: t.path, source: t.source, usedFor: t.usedFor,
|
||||
required: t.required ?? null, status: t.status, install: t.status === 'ok' ? [] : installCommands(t.id), url: t.install.url }));
|
||||
const pw = findPlaywright([cwd, rt.packageRoot()]);
|
||||
tools.splice(3, 0, { id: 'playwright', name: 'playwright', found: !!pw, version: null, path: pw, source: pw ? 'system' : null,
|
||||
usedFor: 'screenshot renders and web captures from a terminal (the desktop app brings its own)', install: pw ? [] : installCommands('playwright'), url: 'https://playwright.dev/docs/intro' });
|
||||
const project = CONFIG_NAMES.some(n => existsSync(join(cwd, n))) ? cwd : null;
|
||||
const sdk = androidSdk([join(cwd, 'android'), cwd], env);
|
||||
return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found).length, androidSdk: sdk, credentials: credentialStatus(cwd),
|
||||
return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found || t.status === 'outdated').length, androidSdk: sdk, credentials: credentialStatus(cwd),
|
||||
foreignRsync: process.platform === 'darwin' ? foreignRsync(env.PATH) : null };
|
||||
}
|
||||
|
||||
/** One install command of a tool, run only with --yes; sudo / admin commands are printed, never run. */
|
||||
export function installPlan(r: DoctorResult, id: string, platform: NodeJS.Platform = process.platform): { tool: DoctorRow; command: string | null; runnable: boolean } {
|
||||
const tool = r.tools.find(t => t.id === id);
|
||||
if (!tool) throw new Error(`doctor --install: no tool "${id}" (one of ${r.tools.map(t => t.id).join(', ')})`);
|
||||
const command = installCommands(tool.id, platform)[0] ?? null;
|
||||
return { tool, command, runnable: !!command && !/^sudo |\| *(ba)?sh|^Install /.test(command) };
|
||||
}
|
||||
|
||||
export async function run(args: Args): Promise<void> {
|
||||
const r = doctor(resolve(args.str('dir', '.')));
|
||||
const want = args.get('install');
|
||||
if (want) {
|
||||
const plan = installPlan(r, want);
|
||||
if (plan.tool.status === 'ok') { log('doctor', `${plan.tool.name} ${plan.tool.version ?? ''} is already here${plan.tool.required ? ` (the project needs ${plan.tool.required})` : ''}`); return; }
|
||||
if (!plan.command) throw new Error(`doctor --install: no install command for ${plan.tool.name} on this OS: ${plan.tool.url}`);
|
||||
if (!plan.runnable || !args.has('yes')) {
|
||||
log('doctor', `would run: ${plan.command}${plan.runnable ? ' (add --yes to run it)' : ' (needs admin rights: run it yourself)'}`);
|
||||
return;
|
||||
}
|
||||
log('doctor', `installing ${plan.tool.name}: ${plan.command}`);
|
||||
const p = rt.spawnSync(plan.command.split(/\s+/), { stdout: 'inherit', stderr: 'inherit', timeout: 20 * 60_000 });
|
||||
if (!p.success) throw new Error(`${plan.command} failed (exit ${p.exitCode}); open a new terminal if it installed but is not on PATH yet`);
|
||||
const after = doctor(resolve(args.str('dir', '.'))).tools.find(t => t.id === plan.tool.id);
|
||||
log('doctor', `${plan.tool.name}: ${after?.status ?? 'unknown'}${after?.version ? ` ${after.version}` : ''} (a new terminal may be needed for PATH)`);
|
||||
return;
|
||||
}
|
||||
if (args.has('json')) { printJson(r); return; }
|
||||
log('doctor', `storebound ${r.storebound} · ${r.runtime} · ${r.platform}${r.project ? ' · project config found' : ''}`);
|
||||
console.log(textTable(['tool', 'status', 'version', 'used for'], r.tools.map(t => [t.name, t.found ? (t.source === 'app' ? 'ok (app copy)' : 'ok') : 'missing', t.version ?? '', t.usedFor])));
|
||||
console.log(textTable(['tool', 'status', 'version', 'needs', 'used for'], r.tools.map(t => [t.name, t.status === 'outdated' ? 'too old' : t.found ? (t.source === 'app' ? 'ok (app copy)' : 'ok') : 'missing', t.version ?? '', t.required ?? '', t.usedFor])));
|
||||
log('doctor', r.androidSdk.path ? `Android SDK: ${r.androidSdk.path} (from ${r.androidSdk.source})`
|
||||
: `Android SDK: not found (play build needs it). Checked: ${r.androidSdk.checked.join('; ')}`);
|
||||
const set = r.credentials.filter(c => c.status === 'set').map(c => c.key), ph = r.credentials.filter(c => c.status === 'placeholder').map(c => c.key);
|
||||
log('doctor', `credentials: ${set.length ? set.join(', ') : 'none set'}${ph.length ? ` · placeholders (treated as missing): ${ph.join(', ')}` : ''}`);
|
||||
if (r.foreignRsync) log('doctor', `rsync: ${r.foreignRsync} is first on PATH and breaks Xcode exportArchive ("Copy failed") outside storebound; storebound apple build puts /usr/bin first. Fix for everything else: brew unlink rsync, or put /usr/bin before it`);
|
||||
const missing = r.tools.filter(t => !t.found);
|
||||
const missing = r.tools.filter(t => !t.found || t.status === 'outdated');
|
||||
if (!missing.length) { log('doctor', 'everything storebound can use is here'); return; }
|
||||
console.log('\nTo install what is missing (pick one per tool; storebound never runs these for you):');
|
||||
console.log('\nTo install what is missing (storebound doctor --install <tool> --yes runs the first one; sudo / admin ones you run yourself):');
|
||||
for (const t of missing) {
|
||||
console.log(` ${t.name}`);
|
||||
for (const c of t.install) console.log(` ${c}`);
|
||||
|
|
|
|||
|
|
@ -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'] : [];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
36
tests/deps.test.ts
Normal file
36
tests/deps.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Build dependencies: the JDK the project needs, version parsing, and doctor --install planning.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { installPlan, type DoctorResult } from '../cli/doctor';
|
||||
import { installCommands, majorOf, parseVersion, requiredJdk } from '../cli/lib/tools';
|
||||
|
||||
function project(files: Record<string, string>): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-deps-'));
|
||||
for (const [p, text] of Object.entries(files)) { mkdirSync(join(dir, p, '..'), { recursive: true }); writeFileSync(join(dir, p), text); }
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('build dependencies', () => {
|
||||
test('JDK version from java -version and majors', () => {
|
||||
expect(parseVersion('openjdk version "17.0.9" 2023-10-17')).toBe('17.0.9');
|
||||
expect(parseVersion('java version "1.8.0_392"')).toBe('1.8.0_392');
|
||||
expect(majorOf('17.0.9')).toBe(17);
|
||||
expect(majorOf('1.8.0_392')).toBe(8);
|
||||
});
|
||||
test('the JDK a project needs: AGP minimum, jvmToolchain wins when higher', () => {
|
||||
expect(requiredJdk(project({ 'android/build.gradle': "classpath 'com.android.tools.build:gradle:8.7.2'" }))).toBe('17');
|
||||
expect(requiredJdk(project({ 'android/build.gradle': "classpath 'com.android.tools.build:gradle:7.4.2'" }))).toBe('11');
|
||||
expect(requiredJdk(project({ 'android/gradle/libs.versions.toml': 'agp = "8.5.0"', 'android/app/build.gradle.kts': 'kotlin { jvmToolchain(21) }' }))).toBe('21');
|
||||
expect(requiredJdk(project({ 'package.json': '{}' }))).toBeNull();
|
||||
});
|
||||
test('install commands per OS; doctor --install runs only package-manager commands, never sudo', () => {
|
||||
expect(installCommands('java', 'win32')[0]).toBe('winget install --id EclipseAdoptium.Temurin.17.JDK');
|
||||
expect(installCommands('sdkmanager', 'darwin')[0]).toBe('brew install --cask android-commandlinetools');
|
||||
const r = { tools: [{ id: 'java', name: 'JDK (Java)', found: false, version: null, path: null, source: null, usedFor: '', install: [], url: null, status: 'missing' }] } as unknown as DoctorResult;
|
||||
expect(installPlan(r, 'java', 'win32')).toMatchObject({ command: 'winget install --id EclipseAdoptium.Temurin.17.JDK', runnable: true });
|
||||
expect(installPlan(r, 'java', 'linux').runnable).toBe(false);
|
||||
expect(() => installPlan(r, 'nope', 'win32')).toThrow('no tool "nope"');
|
||||
});
|
||||
});
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
// bun tools/gen-flags.ts write cli/data/flags.json
|
||||
// bun tools/gen-flags.ts --check exit 1 when the file is stale (tests/cli-help.test.ts runs it)
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const CLI = join(ROOT, 'cli');
|
||||
|
|
@ -48,9 +48,9 @@ function scan(entry: string, errors: string[]): Set<string> {
|
|||
const f = todo.pop()!;
|
||||
if (seen.has(f)) continue;
|
||||
seen.add(f);
|
||||
const rel = relative(CLI, f);
|
||||
const rel = relative(CLI, f).split(sep).join('/');
|
||||
// lib/embedded.ts holds file texts (templates, shots pages: their own scripts read URL params), not flag reads
|
||||
if (rel === 'index.ts' || rel === 'commands.ts' || rel === join('lib', 'embedded.ts')) continue;
|
||||
if (rel === 'index.ts' || rel === 'commands.ts' || rel === 'lib/embedded.ts') continue;
|
||||
const src = readFileSync(f, 'utf8');
|
||||
for (const m of src.matchAll(READ)) if (!NOT_ARGS.test(m[1]!)) flags.add(m[3]!);
|
||||
for (const fl of DYNAMIC[rel] ?? []) flags.add(fl);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue