diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a30caf1..948b70467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +- Let CLI tools, development launchers and the setup doctor use an explicit project directory while retaining their existing default paths. + - Split application scene, controls, catalog, tools and HTML into reusable components; configure application request services and sources without changing global fetch. Preserve standalone markup and voice behavior. Explicit annotation navigation may resolve a distant named target. ## Voice component boundaries diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7bc10963a..6bf6ad5ea 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +CLI tools and development launchers accept `GEV_PROJECT_ROOT` to select the project environment, dependencies and output directory. Their default remains this checkout; bundled tool resources and source checks stay relative to the script installation. The setup doctor also accepts an explicit `rootDir`. + Application composition now uses shared scene/control/catalog/tool constructors and allowlisted HTML component templates. The standalone entry supplies settings and the default sources. Terrain, boundaries, weather, regional context and summaries use configurable services; renderer/action owners remain page-scoped. Annotation proximity guards remain on by default and allow distant targets only for explicit navigation. Voice controls compose a supplied action runner and connection controller. diff --git a/scripts/dev-fresh.sh b/scripts/dev-fresh.sh index cc87e1960..873989bf3 100755 --- a/scripts/dev-fresh.sh +++ b/scripts/dev-fresh.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ROOT_DIR="$(cd "${GEV_PROJECT_ROOT:-$SOURCE_ROOT}" && pwd)" cd "$ROOT_DIR" PORT="${PORT:-4173}" @@ -75,7 +76,7 @@ read_dotenv_value() { echo "warning: node not found; cannot parse dotenv files" >&2 return fi - node scripts/read-dotenv-value.mjs "${variable_name}" + node "$SOURCE_ROOT/scripts/read-dotenv-value.mjs" "${variable_name}" } # Vite loads .env for browser build-time configuration, but this launcher needs @@ -240,12 +241,12 @@ CESIUM_ION_TOKEN="${CESIUM_ION_TOKEN:-$(read_keychain_secret "cesium-ion" "token TOMTOM_API_KEY="${TOMTOM_API_KEY:-$(read_keychain_secret "tomtom-api" "api-key")}" FIRMS_MAP_KEY="${FIRMS_MAP_KEY:-$(read_keychain_secret "firms-map" "map-key")}" -if [[ ! -f "src/data/cctv.js" ]]; then +if [[ ! -f "$SOURCE_ROOT/src/data/cctv.js" ]]; then echo "error: expected CCTV layer file missing: src/data/cctv.js" exit 1 fi -if ! grep -q "dataManager.register(cctvLayer)" src/app/data.js; then +if ! grep -q "dataManager.register(cctvLayer)" "$SOURCE_ROOT/src/app/data.js"; then echo "error: CCTV layer not wired in src/app/data.js" exit 1 fi diff --git a/scripts/dev-secure.sh b/scripts/dev-secure.sh index 6ac6c1599..9ab669979 100755 --- a/scripts/dev-secure.sh +++ b/scripts/dev-secure.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ROOT_DIR="$(cd "${GEV_PROJECT_ROOT:-$SOURCE_ROOT}" && pwd)" cd "$ROOT_DIR" PORT="${PORT:-4173}" diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 1a5a2a54c..f1196b9fd 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -553,5 +553,7 @@ "src/app/data.js", "src/app/tools.js", "src/app/startupChrome.js", - "src/app/errors.js" + "src/app/errors.js", + "scripts/project-root.mjs", + "src/toolProjectRoot.test.mjs" ] diff --git a/scripts/opensky-import-client.sh b/scripts/opensky-import-client.sh index 021539331..f023d9dda 100755 --- a/scripts/opensky-import-client.sh +++ b/scripts/opensky-import-client.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ROOT_DIR="$(cd "${GEV_PROJECT_ROOT:-$SOURCE_ROOT}" && pwd)" cd "${ROOT_DIR}" if ! command -v security >/dev/null 2>&1; then diff --git a/scripts/project-root.mjs b/scripts/project-root.mjs new file mode 100644 index 000000000..da0e785d9 --- /dev/null +++ b/scripts/project-root.mjs @@ -0,0 +1,10 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Resolve the explicit CLI project directory, defaulting to the tool's repository. */ +export function projectRoot(moduleUrl, environment = process.env) { + return path.resolve( + environment.GEV_PROJECT_ROOT || + path.join(path.dirname(fileURLToPath(moduleUrl)), '..'), + ); +} diff --git a/scripts/setup-doctor.mjs b/scripts/setup-doctor.mjs index 37c01156e..51b78be7d 100644 --- a/scripts/setup-doctor.mjs +++ b/scripts/setup-doctor.mjs @@ -4,9 +4,10 @@ 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 = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const ROOT = projectRoot(import.meta.url); 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']] }, @@ -152,7 +153,7 @@ export function buildCapabilitySummary(credentials) { }; } -export function inspectSetup({ includeKeychain = true, authoritativeEnvironment = false } = {}) { +export function inspectSetup({ includeKeychain = true, authoritativeEnvironment = false, rootDir = ROOT } = {}) { const node = classifyNodeVersion(); const npm = npmProcessSpec(); const npmResult = spawnSync(npm.command, ['--version'], { @@ -161,9 +162,9 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment }); const credentials = Object.fromEntries(CREDENTIALS.map((spec) => [ spec.name, - resolveCredential(spec, { includeKeychain, authoritativeEnvironment }), + resolveCredential(spec, { includeKeychain, authoritativeEnvironment, rootDir }), ])); - const dependenciesInstalled = hasRequiredDependencies(); + const dependenciesInstalled = hasRequiredDependencies(rootDir); return { ready: node.level !== 'error' && npmResult.status === 0 && dependenciesInstalled, node: { version: process.versions.node, ...node }, diff --git a/src/toolProjectRoot.test.mjs b/src/toolProjectRoot.test.mjs new file mode 100644 index 000000000..c00b52db5 --- /dev/null +++ b/src/toolProjectRoot.test.mjs @@ -0,0 +1,122 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { projectRoot } from '../scripts/project-root.mjs'; +import { inspectSetup } from '../scripts/setup-doctor.mjs'; +const run = promisify(execFile); +const sourceRoot = fileURLToPath(new URL('../', import.meta.url)); + +test('tool project paths default to the installation and accept an explicit directory', () => { + const moduleUrl = pathToFileURL( + path.join(os.tmpdir(), 'source', 'tools', 'test.mjs'), + ); + assert.equal(projectRoot(moduleUrl, {}), path.join(os.tmpdir(), 'source')); + const root = path.join(os.tmpdir(), 'other project'); + assert.equal(projectRoot(moduleUrl, { GEV_PROJECT_ROOT: root }), root); +}); + +test('doctor inspects the selected project dependencies and dotenv without disclosing values', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'gev-doctor-project-')); + try { + await writeFile( + path.join(root, 'package.json'), + JSON.stringify({ dependencies: { fixture: '1' } }), + ); + await mkdir(path.join(root, 'node_modules', 'fixture'), { + recursive: true, + }); + await writeFile( + path.join(root, 'node_modules', 'fixture', 'package.json'), + '{}', + ); + await writeFile( + path.join(root, '.env'), + 'GOOGLE_MAPS_SERVER_API_KEY=fixture-server-secret\n', + ); + const report = inspectSetup({ rootDir: root, includeKeychain: false }); + assert.equal(report.dependenciesInstalled, true); + assert.equal( + report.credentials.GOOGLE_MAPS_SERVER_API_KEY.configured, + true, + ); + assert.doesNotMatch(JSON.stringify(report), /fixture-server-secret/); + await rm(path.join(root, 'node_modules'), { recursive: true }); + assert.equal( + inspectSetup({ rootDir: root, includeKeychain: false }) + .dependenciesInstalled, + false, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +const bashTest = process.platform === 'win32' ? test.skip : test; +bashTest( + 'launcher reads the selected project keys and starts its command while source checks use the installation', + async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'gev other project-')); + try { + const bin = path.join(root, 'bin'); + await mkdir(bin); + await writeFile( + path.join(root, '.env'), + 'GOOGLE_MAPS_API_KEY=fixture-browser\nGOOGLE_MAPS_SERVER_API_KEY=fixture-server\nOPENAI_API_KEY=fixture-voice\n', + ); + for (const command of ['security', 'pkill', 'lsof']) { + await writeFile(path.join(bin, command), '#!/bin/sh\nexit 1\n', { + mode: 0o755, + }); + } + const capture = path.join(root, 'capture.json'); + await writeFile( + path.join(bin, 'npm'), + `#!/usr/bin/env node +require('node:fs').writeFileSync(process.env.TEST_CAPTURE, JSON.stringify({ cwd: process.cwd(), args: process.argv.slice(2), browser: process.env.GOOGLE_MAPS_API_KEY, server: process.env.GOOGLE_MAPS_SERVER_API_KEY, voice: process.env.OPENAI_API_KEY, provenance: process.env.GEV_KEY_SETUP_EXTERNAL_KEYS })); +`, + { mode: 0o755 }, + ); + const { stdout, stderr } = await run( + 'bash', + [path.join(sourceRoot, 'scripts/dev-fresh.sh')], + { + cwd: os.tmpdir(), + env: { + PATH: `${bin}${path.delimiter}${process.env.PATH}`, + GEV_PROJECT_ROOT: root, + TEST_CAPTURE: capture, + GOOGLE_MAPS_SERVER_API_KEY: 'fixture-exported-server', + }, + timeout: 30000, + }, + ); + const result = JSON.parse(await readFile(capture, 'utf8')); + assert.equal(result.cwd, root); + assert.deepEqual(result.args, [ + 'run', + 'dev', + '--', + '--host', + 'localhost', + '--port', + '4173', + '--force', + ]); + assert.equal(result.browser, 'fixture-browser'); + assert.equal(result.server, 'fixture-exported-server'); + assert.equal(result.voice, 'fixture-voice'); + assert.equal(result.provenance, 'GOOGLE_MAPS_SERVER_API_KEY'); + assert.doesNotMatch( + stdout + stderr, + /fixture-browser|fixture-exported-server|fixture-voice/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/tools/README.md b/tools/README.md index 85ae926cf..5fd252187 100644 --- a/tools/README.md +++ b/tools/README.md @@ -4,6 +4,12 @@ CLI scripts for fetching, rendering, and reprojecting geospatial imagery. All to Output files go to `output/` by default (gitignored). +When invoking these tools from a separate project, set `GEV_PROJECT_ROOT` to its +absolute directory. Environment files, dependencies and relative output paths +resolve there; bundled rendering HTML stays with the tool. The same setting is +supported by `dev-fresh.sh`, `dev-secure.sh`, `opensky-import-client.sh` and the +setup doctor. Without it, paths continue to resolve from this repository. + ## Prerequisites - Node.js (via `mise`) diff --git a/tools/cesium-render.mjs b/tools/cesium-render.mjs index ca1f84afa..b0957de40 100644 --- a/tools/cesium-render.mjs +++ b/tools/cesium-render.mjs @@ -48,11 +48,12 @@ import { readFileSync, mkdirSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { projectRoot } from '../scripts/project-root.mjs'; import { createServer } from 'node:http'; import puppeteer from 'puppeteer'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); -const PROJECT_ROOT = resolve(__dirname, '..'); +const PROJECT_ROOT = projectRoot(import.meta.url); // --------------------------------------------------------------------------- // Argument parsing diff --git a/tools/pano-pinhole.mjs b/tools/pano-pinhole.mjs index 727dd8bc1..36ceaa546 100644 --- a/tools/pano-pinhole.mjs +++ b/tools/pano-pinhole.mjs @@ -37,10 +37,11 @@ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { resolve, join, basename, extname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { projectRoot } from '../scripts/project-root.mjs'; import sharp from 'sharp'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); -const PROJECT_ROOT = resolve(__dirname, '..'); +const PROJECT_ROOT = projectRoot(import.meta.url); // --------------------------------------------------------------------------- // Argument parsing diff --git a/tools/sat-ortho.mjs b/tools/sat-ortho.mjs index 367db0066..d42039e46 100644 --- a/tools/sat-ortho.mjs +++ b/tools/sat-ortho.mjs @@ -33,10 +33,11 @@ import { readFileSync, mkdirSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { projectRoot } from '../scripts/project-root.mjs'; import sharp from 'sharp'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); -const PROJECT_ROOT = resolve(__dirname, '..'); +const PROJECT_ROOT = projectRoot(import.meta.url); const TILE_SIZE = 256; const MAX_CONCURRENT = 8; diff --git a/tools/streetview-headings.mjs b/tools/streetview-headings.mjs index 60a33f85b..708ae7b93 100644 --- a/tools/streetview-headings.mjs +++ b/tools/streetview-headings.mjs @@ -24,11 +24,12 @@ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { projectRoot } from '../scripts/project-root.mjs'; import { parseEnv } from 'node:util'; import { resolveGoogleServerKey } from '../scripts/google-server-key.mjs'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); -const PROJECT_ROOT = resolve(__dirname, '..'); +const PROJECT_ROOT = projectRoot(import.meta.url); function parseArgs() { const args = process.argv.slice(2); diff --git a/tools/streetview-panorama.mjs b/tools/streetview-panorama.mjs index 1883f1935..c93f487bd 100755 --- a/tools/streetview-panorama.mjs +++ b/tools/streetview-panorama.mjs @@ -25,10 +25,11 @@ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { projectRoot } from '../scripts/project-root.mjs'; import sharp from 'sharp'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); -const PROJECT_ROOT = resolve(__dirname, '..'); +const PROJECT_ROOT = projectRoot(import.meta.url); // --------------------------------------------------------------------------- // Argument parsing