#!/usr/bin/env node // Merge the committed core config with every data/custom/*/satvis.yaml plugin // config, inline each group's extraRecordsFile TLE text into extraRecords, // validate, and write worker/src/config/satvis.generated.json. // // A config contributes two independent sections: `groups` (what is served, as // which unit) and `satellites` (static per-satellite facts, keyed by NORAD id // and attached to records at refresh time). With no plugin configs present this // still produces a valid generated file from the core config alone (so lint/CI // stay green). import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import YAML from "yaml"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const workerDir = path.resolve(scriptDir, ".."); const repoRoot = path.resolve(workerDir, ".."); const coreConfigPath = path.join(workerDir, "src", "config", "satvis.core.yaml"); const customDir = path.join(repoRoot, "data", "custom"); const outPath = path.join(workerDir, "src", "config", "satvis.generated.json"); const PLUGIN_CONFIG_NAME = "satvis.yaml"; // Pre-YAML plugin config name. Detected only to fail loudly: silently skipping // it would make a plugin's groups vanish from the build without a word. const LEGACY_PLUGIN_CONFIG_NAME = "groups.json"; const GROUP_NAME_RE = /^[a-zA-Z0-9_-]+$/; function readYaml(file) { return YAML.parse(fs.readFileSync(file, "utf8")); } // Parse TLE text into TleRecord objects. Supports 3-line blocks (optional // leading "0 " on the name line), and bare 2-line blocks (no name). // // Deliberate near-duplicate of src/modules/util/gp.ts parseTleText with an // intentionally opposite error policy: this build tool fails loud (throws) so // bad input never ships, while the browser path warns and skips. Do not // "unify" them. function parseTleText(text) { const lines = text .split(/\r?\n/) .map((line) => line.replace(/\s+$/, "")) .filter((line) => line.length > 0); const records = []; let i = 0; while (i < lines.length) { const line = lines[i]; if (line.startsWith("1 ") && i + 1 < lines.length && lines[i + 1].startsWith("2 ")) { // Bare 2-line block. records.push({ TLE_LINE1: line, TLE_LINE2: lines[i + 1] }); i += 2; } else if (i + 2 < lines.length && lines[i + 1].startsWith("1 ") && lines[i + 2].startsWith("2 ")) { // 3-line block; strip an optional "0 " name prefix. const name = line.startsWith("0 ") ? line.slice(2) : line; records.push({ OBJECT_NAME: name, TLE_LINE1: lines[i + 1], TLE_LINE2: lines[i + 2] }); i += 3; } else { throw new Error(`unrecognized TLE block at line ${i + 1}: ${JSON.stringify(line)}`); } } return records; } // Load and normalize one config file. `extraRecordsFile` (generator-only) is // resolved relative to the config's directory and inlined into extraRecords. function loadConfig(configPath) { const config = readYaml(configPath); const dir = path.dirname(configPath); const groups = (config.groups ?? []).map((group) => { const { extraRecordsFile, ...rest } = group; if (extraRecordsFile) { const txtPath = path.join(dir, extraRecordsFile); const parsed = parseTleText(fs.readFileSync(txtPath, "utf8")); rest.extraRecords = [...(rest.extraRecords ?? []), ...parsed]; } return rest; }); return { groups, satellites: config.satellites ?? [] }; } function discoverPluginConfigs() { if (!fs.existsSync(customDir)) { return []; } const configs = []; for (const entry of fs.readdirSync(customDir, { withFileTypes: true }).toSorted((a, b) => a.name.localeCompare(b.name))) { if (!entry.isDirectory()) { continue; } const dir = path.join(customDir, entry.name); const candidate = path.join(dir, PLUGIN_CONFIG_NAME); if (fs.existsSync(candidate)) { configs.push(candidate); continue; } // Fail loudly rather than silently dropping a plugin that has not migrated. if (fs.existsSync(path.join(dir, LEGACY_PLUGIN_CONFIG_NAME))) { throw new Error( `${path.relative(repoRoot, path.join(dir, LEGACY_PLUGIN_CONFIG_NAME))} is the pre-YAML config format; ` + `rename it to ${PLUGIN_CONFIG_NAME} and convert it to YAML (see worker/src/config/satvis.core.yaml)`, ); } } return configs; } // Validate a group's `satellites` rows (if any). Each row must select by // `noradId` (number) or `upstreamName` (string); `name`/`metadata` optional // with checked types. Throws with the group name on any violation. function validateSatellites(group) { if (group.satellites === undefined) { return; } if (!Array.isArray(group.satellites)) { throw new Error(`group ${JSON.stringify(group.name)}: "satellites" must be an array`); } group.satellites.forEach((row, i) => { const where = `group ${JSON.stringify(group.name)} satellites[${i}]`; if (row === null || typeof row !== "object" || Array.isArray(row)) { throw new Error(`${where}: must be an object`); } if (row.noradId !== undefined && typeof row.noradId !== "number") { throw new Error(`${where}: "noradId" must be a number`); } if (row.upstreamName !== undefined && typeof row.upstreamName !== "string") { throw new Error(`${where}: "upstreamName" must be a string`); } if (row.noradId === undefined && row.upstreamName === undefined) { throw new Error(`${where}: must have a "noradId" or "upstreamName"`); } if (row.name !== undefined && typeof row.name !== "string") { throw new Error(`${where}: "name" must be a string`); } if (row.metadata !== undefined) { validateMetadata(row.metadata, where); if (row.noradId === undefined) { // Metadata is keyed by NORAD id in the merged table; a name-only row has // no key to lift it under. throw new Error(`${where}: "metadata" requires a "noradId" (matching is by NORAD id only)`); } // Same rule as a top-level table entry: an empty bag would attach a // meaningless `metadata` key to the served record, which is supposed to be // present only when the satellite actually has facts to carry. if (Object.keys(row.metadata).length === 0) { throw new Error(`${where}: "metadata" is empty — remove it or give it a field`); } } if (row.decayed !== undefined && typeof row.decayed !== "boolean") { throw new Error(`${where}: "decayed" must be a boolean`); } }); } // Validate a metadata bag. The worker treats it as opaque, so only the fields // whose shape the frontend depends on are checked here: the swath extents, which // must be positive numbers and must be given for both sides or neither (see // swathExtentsOf in src/config/satelliteMetadata.ts, which reads them as a pair // and treats a half-specified swath as absent). function validateMetadata(metadata, where) { if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) { throw new Error(`${where}: "metadata" must be an object`); } const sides = ["swathStarboardKm", "swathPortKm"]; for (const side of sides) { const value = metadata[side]; if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value) || value <= 0)) { throw new Error(`${where}: "${side}" must be a positive number`); } } const given = sides.filter((side) => metadata[side] !== undefined); if (given.length === 1) { throw new Error(`${where}: swath needs both "swathStarboardKm" and "swathPortKm" (got only ${JSON.stringify(given[0])})`); } } // The keys of a satellite-table entry that are bookkeeping rather than payload. // Everything else in the entry IS the metadata bag, which is what keeps adding a // field a data-only edit. const TABLE_ENTRY_KEYS = new Set(["noradId", "name", "decayed"]); function metadataFields(entry) { return Object.fromEntries(Object.entries(entry).filter(([key]) => !TABLE_ENTRY_KEYS.has(key))); } // Validate a config's top-level `satellites` table. Every entry keys on a // numeric `noradId`; `name` is documentation only. function validateSatelliteTable(entries, source) { if (!Array.isArray(entries)) { throw new Error(`${source}: "satellites" must be an array`); } entries.forEach((entry, i) => { const where = `${source} satellites[${i}]`; if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { throw new Error(`${where}: must be an object`); } if (typeof entry.noradId !== "number") { throw new Error(`${where}: "noradId" must be a number`); } if (entry.name !== undefined && typeof entry.name !== "string") { throw new Error(`${where}: "name" must be a string`); } if (entry.decayed !== undefined && typeof entry.decayed !== "boolean") { throw new Error(`${where}: "decayed" must be a boolean`); } const metadata = metadataFields(entry); validateMetadata(metadata, where); if (Object.keys(metadata).length === 0) { throw new Error(`${where}: has no metadata fields — remove the entry or give it something to attach`); } }); } function validate(groups) { const names = new Set(); for (const group of groups) { if (typeof group.name !== "string" || !GROUP_NAME_RE.test(group.name)) { throw new Error(`invalid group name ${JSON.stringify(group.name)} (must match ${GROUP_NAME_RE})`); } if (names.has(group.name)) { throw new Error(`duplicate group name ${JSON.stringify(group.name)}`); } names.add(group.name); validateSatellites(group); } // include targets must exist. for (const group of groups) { for (const dep of group.include ?? []) { if (!names.has(dep)) { throw new Error(`group ${JSON.stringify(group.name)} includes unknown group ${JSON.stringify(dep)}`); } } } // no include cycles (DFS). const byName = new Map(groups.map((g) => [g.name, g])); const state = new Map(); const visit = (name, stack) => { if (state.get(name) === "done") { return; } if (state.get(name) === "visiting") { throw new Error(`include cycle detected: ${[...stack, name].join(" -> ")}`); } state.set(name, "visiting"); for (const dep of byName.get(name)?.include ?? []) { visit(dep, [...stack, name]); } state.set(name, "done"); }; for (const group of groups) { visit(group.name, []); } } // Accumulator for the merged satellite table, keyed by NORAD id. Contributions // arrive from two kinds of place — a config's top-level `satellites` table and a // group's `satellites[].metadata` rows — and are merged field-wise in arrival // order, so a later, more specific contribution wins per field. // // Conflicts (two places giving one field different values for one satellite) are // a config bug: whichever won would depend on file discovery order, so we fail // with both origins instead of picking. Identical values merge silently, which // is what makes it safe to repeat a satellite across groups. function createSatelliteTable() { const byNoradId = new Map(); return { add(noradId, fields, origin) { const existing = byNoradId.get(noradId); if (existing === undefined) { byNoradId.set(noradId, { noradId, metadata: { ...fields.metadata }, origins: [origin], name: fields.name, decayed: fields.decayed }); return; } for (const [key, value] of Object.entries(fields.metadata)) { const previous = existing.metadata[key]; if (previous !== undefined && previous !== value) { throw new Error( `conflicting metadata for noradId ${noradId}: ${origin} sets ${key}=${JSON.stringify(value)}, ` + `but ${existing.origins.join(", ")} set ${key}=${JSON.stringify(previous)}`, ); } existing.metadata[key] = value; } existing.name ??= fields.name; existing.decayed ||= fields.decayed; existing.origins.push(origin); }, // Strip the bookkeeping (`origins`) that only the merge needed, and drop // absent optional keys so the generated JSON stays free of nulls. entries() { const out = []; for (const { noradId, name, decayed, metadata } of byNoradId.values()) { const entry = { noradId }; if (name !== undefined) { entry.name = name; } if (decayed) { entry.decayed = true; } entry.metadata = metadata; out.push(entry); } return out.toSorted((a, b) => a.noradId - b.noradId); }, }; } function main() { const configs = [ { path: coreConfigPath, config: loadConfig(coreConfigPath) }, ...discoverPluginConfigs().map((configPath) => ({ path: configPath, config: loadConfig(configPath) })), ]; const groups = configs.flatMap(({ config }) => config.groups); validate(groups); const table = createSatelliteTable(); for (const { path: configPath, config } of configs) { const source = path.relative(repoRoot, configPath); validateSatelliteTable(config.satellites, source); for (const entry of config.satellites) { table.add(entry.noradId, { metadata: metadataFields(entry), name: entry.name, decayed: entry.decayed }, `${source} satellites`); } } // Group rows contribute after every table. Order only decides which origin is // named first in a conflict message — it does NOT establish precedence: two // places giving one satellite different values for a field is a build failure, // because whichever won would depend on config discovery order. for (const group of groups) { for (const row of group.satellites ?? []) { if (row.metadata !== undefined) { table.add(row.noradId, { metadata: row.metadata, name: row.name, decayed: row.decayed }, `group ${JSON.stringify(group.name)}`); } } } const satellites = table.entries(); const generated = { groups, satellites }; fs.writeFileSync(outPath, `${JSON.stringify(generated, null, 2)}\n`); process.stdout.write(`Wrote ${path.relative(repoRoot, outPath)} (${groups.length} groups, ${satellites.length} satellites)\n`); } main();