mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Windows runs npm.cmd through a shell, and passing an argument array with `shell: true` is deprecated in Node 24 (DEP0190). Every `npm run doctor` and every Pinokio install on Windows printed the warning. npmProcessSpec now takes the npm arguments and, on Windows, joins them into the command string; the arguments are fixed subcommands (`--version`, `ci`). Unix behavior is unchanged.
271 lines
12 KiB
JavaScript
271 lines
12 KiB
JavaScript
#!/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.14.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;
|
|
}
|
|
|
|
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 } = {},
|
|
) {
|
|
const configured = (name) => credentials[name]?.configured === true;
|
|
const hasOAuthCredentials = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET');
|
|
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 (credential presence and validity not verified)'
|
|
: openSkyMode === 'auto'
|
|
? 'OpenSky auto mode selected (runtime credential choice and validity not verified)'
|
|
: hasOAuthCredentials
|
|
? 'OpenSky OAuth credentials present (runtime mode 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 }),
|
|
}),
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|