#!/usr/bin/env node import { existsSync, readFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; import { parseEnv } from 'node:util'; import { fileURLToPath } from 'node:url'; import { projectRoot } from './project-root.mjs'; import { selectMapStartupRoute } from '../src/mapStartup.js'; const ROOT = projectRoot(import.meta.url); const OPENSKY_AUTH_MODE_DEFAULT = 'oauth'; const OPENSKY_AUTH_MODES = new Set(['basic', 'oauth', 'auto', 'anon']); export const CREDENTIALS = Object.freeze([ { name: 'GOOGLE_MAPS_API_KEY', label: 'Google Maps', keychain: [['google-maps-api', 'api-key'], ['google-maps-api', 'default'], ['google-maps-api', 'key']] }, { name: 'GOOGLE_MAPS_SERVER_API_KEY', label: 'Google Places / Street View server', keychain: [] }, { name: 'CESIUM_ION_TOKEN', label: 'Cesium ion', keychain: [['cesium-ion', 'token']] }, { name: 'OPENAI_API_KEY', label: 'OpenAI voice', keychain: [['openai-api', 'api-key']] }, { name: 'AISSTREAM_API_KEY', label: 'AISStream vessels', keychain: [['aisstream-api', 'api-key']] }, { name: 'FIRMS_MAP_KEY', label: 'NASA FIRMS fires', keychain: [['firms-map', 'map-key']] }, { name: 'TOMTOM_API_KEY', label: 'TomTom traffic', keychain: [['tomtom-api', 'api-key']] }, { name: 'OPENSKY_CLIENT_ID', label: 'OpenSky client ID', keychain: ['opensky-network', 'opensky'].flatMap((service) => ( ['client_id', 'client-id', 'client', 'api-key'].map((account) => [service, account]) )), }, { name: 'OPENSKY_CLIENT_SECRET', label: 'OpenSky client secret', keychain: ['opensky-network', 'opensky'].flatMap((service) => ( ['client_secret', 'client-secret', 'secret'].map((account) => [service, account]) )), }, { name: 'LL2_API_TOKEN', label: 'Launch Library 2', keychain: [] }, { name: 'MAPILLARY_CLIENT_TOKEN', label: 'Mapillary street level', keychain: [['mapillary', 'client-token'], ['mapillary', 'token']] }, ]); export function isConfiguredValue(value) { const normalized = String(value || '').trim(); return normalized.length > 0 && !/^(your_|replace_|example|changeme)/i.test(normalized); } export function classifyNodeVersion(version = process.versions.node) { const [major = 0, minor = 0] = String(version).split('.').map(Number); if (major === 24 && minor >= 14) { return { level: 'ok', summary: 'supported LTS and calibrated for release gates' }; } if (major === 26) return { level: 'ok', summary: 'supported runtime' }; if (major === 25) { return { level: 'warn', summary: 'usable but EOL; allocation benchmarks will be skipped' }; } if (major < 24 || (major === 24 && minor < 14)) { return { level: 'error', summary: 'too old; install Node 24.14 or newer' }; } // NEWER than this release has verified is a warning, never a refusal: a // future Node must not brick a no-terminal install with advice its user // cannot follow. Too-old stays an error above — old runtimes genuinely fail. return { level: 'warn', summary: 'newer than this release has verified; Node 24.22.x or 26.x is the tested path' }; } /** Verify that every direct package declared by this checkout is present. */ export function hasRequiredDependencies(rootDir = ROOT) { try { const manifest = JSON.parse(readFileSync(path.join(rootDir, 'package.json'), 'utf8')); const packages = new Set([ ...Object.keys(manifest.dependencies || {}), ...Object.keys(manifest.devDependencies || {}), ]); return packages.size > 0 && [...packages].every((name) => ( existsSync(path.join(rootDir, 'node_modules', ...name.split('/'), 'package.json')) )); } catch { return false; } } /** * Return the npm command, arguments and spawn mode for the target platform. * Windows must run npm.cmd through a shell, and Node 24 deprecates passing an * argument array with `shell: true` (DEP0190), so there the arguments, which * are fixed npm subcommands, join the command string. */ export function npmProcessSpec(args, platform = process.platform) { if (platform !== 'win32') return { command: 'npm', args, shell: false }; return { command: ['npm.cmd', ...args].join(' '), args: [], shell: true }; } /** Read one key from Vite's dotenv file ladder without depending on Vite. */ export function readDoctorDotenvValue( variableName, rootDir = ROOT, mode = 'development', ) { const key = String(variableName || '').trim(); if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) return ''; const values = {}; for (const filename of ['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`]) { const filepath = path.join(rootDir, filename); if (!existsSync(filepath)) continue; try { Object.assign(values, parseEnv(readFileSync(filepath, 'utf8'))); } catch { // A malformed optional dotenv file must not crash the setup diagnosis. } } return String(values[key] ?? ''); } /** Resolve the OpenSky auth mode using the same environment and dotenv precedence as the launcher. */ export function resolveOpenSkyAuthMode({ authoritativeEnvironment = false, environment = process.env, rootDir = ROOT, } = {}) { const environmentDefinesMode = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_AUTH_MODE'); const environmentMode = String(environment.OPENSKY_AUTH_MODE || '').trim(); const dotenvMode = authoritativeEnvironment && environmentDefinesMode ? '' : readDoctorDotenvValue('OPENSKY_AUTH_MODE', rootDir); const mode = (environmentMode || dotenvMode).trim().toLowerCase(); return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT; } /** Whether OPENSKY_CREDENTIALS_FILE is set, with the same precedence as the auth mode. The server reads that file when the client id and secret are empty. */ export function resolveOpenSkyCredentialsFile({ authoritativeEnvironment = false, environment = process.env, rootDir = ROOT, } = {}) { const environmentDefines = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_CREDENTIALS_FILE'); const value = authoritativeEnvironment && environmentDefines ? String(environment.OPENSKY_CREDENTIALS_FILE || '') : String(environment.OPENSKY_CREDENTIALS_FILE || '') || readDoctorDotenvValue('OPENSKY_CREDENTIALS_FILE', rootDir); return isConfiguredValue(value.trim()); } function hasKeychainItem(service, account) { if (process.platform !== 'darwin') return false; const result = spawnSync('security', [ 'find-generic-password', '-s', service, '-a', account, ], { stdio: 'ignore' }); return result.status === 0; } export function resolveCredential(spec, { includeKeychain = true, authoritativeEnvironment = false, environment = process.env, rootDir = ROOT, keychainLookup = hasKeychainItem, } = {}) { const environmentDefinesKey = Object.prototype.hasOwnProperty.call(environment, spec.name); if (isConfiguredValue(environment[spec.name])) return { configured: true, source: 'environment' }; if (authoritativeEnvironment && environmentDefinesKey) return { configured: false, source: null }; if (isConfiguredValue(readDoctorDotenvValue(spec.name, rootDir))) return { configured: true, source: 'dotenv files' }; if (includeKeychain && spec.keychain.some(([service, account]) => keychainLookup(service, account))) { return { configured: true, source: 'macOS Keychain' }; } return { configured: false, source: null }; } export function buildCapabilitySummary( credentials, { openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT, openSkyCredentialsFile = false } = {}, ) { const configured = (name) => credentials[name]?.configured === true; const hasClientPair = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET'); const hasOAuthCredentials = hasClientPair || openSkyCredentialsFile === true; const requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase(); const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode) ? requestedOpenSkyMode : OPENSKY_AUTH_MODE_DEFAULT; const flights = openSkyMode === 'anon' ? 'OpenSky keyless anonymous access (rate-limited)' : openSkyMode === 'basic' ? 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)' : openSkyMode === 'auto' ? 'OpenSky auto mode selected (runtime credential choice and validity not verified)' : hasClientPair ? 'OpenSky OAuth credentials present (runtime mode and validity not verified)' : hasOAuthCredentials ? 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)' : 'OpenSky keyless anonymous access (rate-limited)'; const route = selectMapStartupRoute({ googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '', cesiumToken: configured('CESIUM_ION_TOKEN') ? 'configured' : '', }); return { map: route === 'google-direct' ? 'Google Photorealistic 3D Tiles (direct)' : route === 'google-ion' ? 'Google Photorealistic 3D Tiles through Cesium ion; Bing and world-terrain stacks available' : 'Esri World Imagery (keyless satellite basemap) with keyless terrain', flights, voice: configured('OPENAI_API_KEY') ? 'available' : 'off until an OpenAI key is added, or a Codex ChatGPT sign-in is selected in Provider Settings (experimental)', vessels: configured('AISSTREAM_API_KEY') ? 'live AISStream feed' : 'off until an AISStream key is added', fires: configured('FIRMS_MAP_KEY') ? 'live NASA FIRMS feed' : 'off until a FIRMS key is added', traffic: configured('TOMTOM_API_KEY') ? 'live TomTom flow' : 'built-in traffic simulation', missions: configured('LL2_API_TOKEN') ? 'Launch Library 2 token allowance' : 'Launch Library 2 public access', }; } export function inspectSetup({ includeKeychain = true, authoritativeEnvironment = false, rootDir = ROOT } = {}) { const node = classifyNodeVersion(); const npm = npmProcessSpec(['--version']); const npmResult = spawnSync(npm.command, npm.args, { encoding: 'utf8', shell: npm.shell, }); const credentials = Object.fromEntries(CREDENTIALS.map((spec) => [ spec.name, resolveCredential(spec, { includeKeychain, authoritativeEnvironment, rootDir }), ])); const dependenciesInstalled = hasRequiredDependencies(rootDir); return { ready: node.level !== 'error' && npmResult.status === 0 && dependenciesInstalled, node: { version: process.versions.node, ...node }, npm: npmResult.status === 0 ? { available: true, version: String(npmResult.stdout || '').trim() } : { available: false, version: null }, dependenciesInstalled, credentials, capabilities: buildCapabilitySummary(credentials, { openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }), openSkyCredentialsFile: resolveOpenSkyCredentialsFile({ authoritativeEnvironment, rootDir }), }), }; } function symbol(level) { if (level === 'ok') return 'OK'; if (level === 'warn') return 'WARN'; return 'ERROR'; } export function formatSetupReport(report, { readyMessage } = {}) { const hasKeychainSource = Object.values(report.credentials || {}) .some((credential) => credential?.source === 'macOS Keychain'); const resolvedReadyMessage = readyMessage || (hasKeychainSource ? 'Ready. Run ./scripts/dev-fresh.sh, then open http://localhost:4173.' : 'Ready. Run npm run dev, then open http://localhost:4173.'); const lines = [ "God's Eye View setup doctor", '', `[${symbol(report.node.level)}] Node ${report.node.version}: ${report.node.summary}`, report.npm.available ? `[OK] npm ${report.npm.version}` : '[ERROR] npm was not found', report.dependenciesInstalled ? '[OK] dependencies installed' : '[WARN] dependencies missing; run npm install', '', `Map: ${report.capabilities.map}`, `Flights: ${report.capabilities.flights}`, `Voice: ${report.capabilities.voice}`, `Vessels: ${report.capabilities.vessels}`, `Fires: ${report.capabilities.fires}`, `Traffic: ${report.capabilities.traffic}`, `Missions: ${report.capabilities.missions}`, '', 'Configured providers:', ...CREDENTIALS.map((spec) => { const state = report.credentials[spec.name]; return state.configured ? ` [OK] ${spec.label} (${state.source})` : ` [--] ${spec.label}`; }), '', report.ready ? resolvedReadyMessage : 'Setup needs attention before the app can start.', ]; return lines.join('\n'); } const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ''; if (invokedPath === fileURLToPath(import.meta.url)) { const report = inspectSetup(); if (process.argv.includes('--json')) console.log(JSON.stringify(report, null, 2)); else console.log(formatSetupReport(report)); if (!report.ready) process.exitCode = 1; }