mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
refactor(tools): support an explicit project directory
This commit is contained in:
1 parent
844c25212c
commit
6d5478ffa9
15 files changed
+169
-16
No files matched your search
@@ -1,5 +1,7 @@
|
|||||||
# Changelog
|
# 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.
|
- 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
|
## Voice component boundaries
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# God's Eye View Current State
|
# 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.
|
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.
|
Voice controls compose a supplied action runner and connection controller.
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
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"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
PORT="${PORT:-4173}"
|
PORT="${PORT:-4173}"
|
||||||
@@ -75,7 +76,7 @@ read_dotenv_value() {
|
|||||||
echo "warning: node not found; cannot parse dotenv files" >&2
|
echo "warning: node not found; cannot parse dotenv files" >&2
|
||||||
return
|
return
|
||||||
fi
|
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
|
# 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")}"
|
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")}"
|
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"
|
echo "error: expected CCTV layer file missing: src/data/cctv.js"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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"
|
echo "error: CCTV layer not wired in src/app/data.js"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
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"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
PORT="${PORT:-4173}"
|
PORT="${PORT:-4173}"
|
||||||
|
|||||||
@@ -553,5 +553,7 @@
|
|||||||
"src/app/data.js",
|
"src/app/data.js",
|
||||||
"src/app/tools.js",
|
"src/app/tools.js",
|
||||||
"src/app/startupChrome.js",
|
"src/app/startupChrome.js",
|
||||||
"src/app/errors.js"
|
"src/app/errors.js",
|
||||||
|
"scripts/project-root.mjs",
|
||||||
|
"src/toolProjectRoot.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
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}"
|
cd "${ROOT_DIR}"
|
||||||
|
|
||||||
if ! command -v security >/dev/null 2>&1; then
|
if ! command -v security >/dev/null 2>&1; then
|
||||||
|
|||||||
@@ -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)), '..'),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,9 +4,10 @@ import { spawnSync } from 'node:child_process';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { parseEnv } from 'node:util';
|
import { parseEnv } from 'node:util';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { projectRoot } from './project-root.mjs';
|
||||||
import { selectMapStartupRoute } from '../src/mapStartup.js';
|
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([
|
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_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 node = classifyNodeVersion();
|
||||||
const npm = npmProcessSpec();
|
const npm = npmProcessSpec();
|
||||||
const npmResult = spawnSync(npm.command, ['--version'], {
|
const npmResult = spawnSync(npm.command, ['--version'], {
|
||||||
@@ -161,9 +162,9 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment
|
|||||||
});
|
});
|
||||||
const credentials = Object.fromEntries(CREDENTIALS.map((spec) => [
|
const credentials = Object.fromEntries(CREDENTIALS.map((spec) => [
|
||||||
spec.name,
|
spec.name,
|
||||||
resolveCredential(spec, { includeKeychain, authoritativeEnvironment }),
|
resolveCredential(spec, { includeKeychain, authoritativeEnvironment, rootDir }),
|
||||||
]));
|
]));
|
||||||
const dependenciesInstalled = hasRequiredDependencies();
|
const dependenciesInstalled = hasRequiredDependencies(rootDir);
|
||||||
return {
|
return {
|
||||||
ready: node.level !== 'error' && npmResult.status === 0 && dependenciesInstalled,
|
ready: node.level !== 'error' && npmResult.status === 0 && dependenciesInstalled,
|
||||||
node: { version: process.versions.node, ...node },
|
node: { version: process.versions.node, ...node },
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -4,6 +4,12 @@ CLI scripts for fetching, rendering, and reprojecting geospatial imagery. All to
|
|||||||
|
|
||||||
Output files go to `output/` by default (gitignored).
|
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
|
## Prerequisites
|
||||||
|
|
||||||
- Node.js (via `mise`)
|
- Node.js (via `mise`)
|
||||||
|
|||||||
@@ -48,11 +48,12 @@
|
|||||||
import { readFileSync, mkdirSync } from 'node:fs';
|
import { readFileSync, mkdirSync } from 'node:fs';
|
||||||
import { resolve, join } from 'node:path';
|
import { resolve, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { projectRoot } from '../scripts/project-root.mjs';
|
||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
const PROJECT_ROOT = resolve(__dirname, '..');
|
const PROJECT_ROOT = projectRoot(import.meta.url);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Argument parsing
|
// Argument parsing
|
||||||
|
|||||||
@@ -37,10 +37,11 @@
|
|||||||
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import { resolve, join, basename, extname } from 'node:path';
|
import { resolve, join, basename, extname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { projectRoot } from '../scripts/project-root.mjs';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
|
||||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
const PROJECT_ROOT = resolve(__dirname, '..');
|
const PROJECT_ROOT = projectRoot(import.meta.url);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Argument parsing
|
// Argument parsing
|
||||||
|
|||||||
+2
-1
@@ -33,10 +33,11 @@
|
|||||||
import { readFileSync, mkdirSync } from 'node:fs';
|
import { readFileSync, mkdirSync } from 'node:fs';
|
||||||
import { resolve, join } from 'node:path';
|
import { resolve, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { projectRoot } from '../scripts/project-root.mjs';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
|
||||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
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 TILE_SIZE = 256;
|
||||||
const MAX_CONCURRENT = 8;
|
const MAX_CONCURRENT = 8;
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,12 @@
|
|||||||
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import { resolve, join } from 'node:path';
|
import { resolve, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { projectRoot } from '../scripts/project-root.mjs';
|
||||||
import { parseEnv } from 'node:util';
|
import { parseEnv } from 'node:util';
|
||||||
import { resolveGoogleServerKey } from '../scripts/google-server-key.mjs';
|
import { resolveGoogleServerKey } from '../scripts/google-server-key.mjs';
|
||||||
|
|
||||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
const PROJECT_ROOT = resolve(__dirname, '..');
|
const PROJECT_ROOT = projectRoot(import.meta.url);
|
||||||
|
|
||||||
function parseArgs() {
|
function parseArgs() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
|
|||||||
@@ -25,10 +25,11 @@
|
|||||||
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import { resolve, join } from 'node:path';
|
import { resolve, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { projectRoot } from '../scripts/project-root.mjs';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
|
||||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
const PROJECT_ROOT = resolve(__dirname, '..');
|
const PROJECT_ROOT = projectRoot(import.meta.url);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Argument parsing
|
// Argument parsing
|
||||||
|
|||||||
Reference in new issue
Block a user