| 1 | /** |
| 2 | * facts-lib.mjs — shared derivation logic for website fact generation and |
| 3 | * drift checking. Imported by both derive-facts.mjs (prebuild) and |
| 4 | * check-facts.mjs (CI gate). |
| 5 | * |
| 6 | * Sources of truth: |
| 7 | * - <repo>/Cargo.toml → version, workspace crates |
| 8 | * - <repo>/crates/tui/src/sandbox/mod.rs → enforced sandbox markers |
| 9 | * - <repo>/crates/tui/src/config.rs → provider list (ApiProvider enum), DEFAULT_TEXT_MODEL |
| 10 | * - <repo>/npm/codewhale/package.json → node engines |
| 11 | * - <repo>/crates/tui/src/tools/*.rs → tool count (ToolSpec impls) |
| 12 | * - <repo>/LICENSE → license |
| 13 | * - <repo>/web/data/latest-published-release.json → latest published release |
| 14 | */ |
| 15 | import { readFileSync, readdirSync, existsSync } from "node:fs"; |
| 16 | import { join, dirname, resolve } from "node:path"; |
| 17 | import { fileURLToPath } from "node:url"; |
| 18 | |
| 19 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 20 | // __dirname is web/scripts; REPO_ROOT is the workspace root (two levels up). |
| 21 | export const REPO_ROOT = resolve(__dirname, "..", ".."); |
| 22 | |
| 23 | function read(rel) { |
| 24 | const p = join(REPO_ROOT, rel); |
| 25 | if (!existsSync(p)) return null; |
| 26 | return readFileSync(p, "utf-8"); |
| 27 | } |
| 28 | |
| 29 | export function deriveVersion() { |
| 30 | const cargo = read("Cargo.toml"); |
| 31 | if (!cargo) return null; |
| 32 | const m = cargo.match(/^version\s*=\s*"([^"]+)"/m); |
| 33 | return m ? m[1] : null; |
| 34 | } |
| 35 | |
| 36 | export function deriveCrates() { |
| 37 | const cargo = read("Cargo.toml"); |
| 38 | if (!cargo) return []; |
| 39 | const block = cargo.match(/members\s*=\s*\[([\s\S]*?)\]/); |
| 40 | if (!block) return []; |
| 41 | return [...block[1].matchAll(/"crates\/([^"]+)"/g)].map((m) => m[1]).sort(); |
| 42 | } |
| 43 | |
| 44 | export function deriveSandboxBackends() { |
| 45 | const source = read("crates/tui/src/sandbox/mod.rs"); |
| 46 | return source ? deriveSandboxBackendsFromSource(source) : []; |
| 47 | } |
| 48 | |
| 49 | export function deriveSandboxBackendsFromSource(source) { |
| 50 | const marker = source.match( |
| 51 | /pub const PUBLIC_SANDBOX_BACKENDS\s*:\s*&\[&str\]\s*=\s*&\[([\s\S]*?)\];/, |
| 52 | ); |
| 53 | if (!marker) return []; |
| 54 | return [...marker[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]); |
| 55 | } |
| 56 | |
| 57 | /** |
| 58 | * Provider label map — the single source of truth for provider → website |
| 59 | * display mapping. MUST be kept in sync with the copy in |
| 60 | * web/lib/facts-drift.ts (for the runtime Cloudflare cron path). |
| 61 | * |
| 62 | * Excluded variants: DeepseekCN (not wired through shared ProviderKind, #1104). |
| 63 | */ |
| 64 | const PROVIDER_LABEL_MAP = { |
| 65 | Deepseek: { id: "deepseek", label: "DeepSeek", env: "DEEPSEEK_API_KEY" }, |
| 66 | DeepseekAnthropic: { id: "deepseek-anthropic", label: "DeepSeek Anthropic", env: "DEEPSEEK_API_KEY / ANTHROPIC_API_KEY" }, |
| 67 | NvidiaNim: { id: "nvidia-nim", label: "NVIDIA NIM", env: "NVIDIA_API_KEY / NVIDIA_NIM_API_KEY" }, |
| 68 | Openai: { id: "openai", label: "OpenAI-compatible", env: "OPENAI_API_KEY" }, |
| 69 | Atlascloud: { id: "atlascloud", label: "AtlasCloud", env: "ATLASCLOUD_API_KEY" }, |
| 70 | WanjieArk: { id: "wanjie-ark", label: "Wanjie Ark", env: "WANJIE_ARK_API_KEY / WANJIE_API_KEY / WANJIE_MAAS_API_KEY" }, |
| 71 | Volcengine: { id: "volcengine", label: "Volcengine Ark", env: "VOLCENGINE_API_KEY / VOLCENGINE_ARK_API_KEY / ARK_API_KEY" }, |
| 72 | Openrouter: { id: "openrouter", label: "OpenRouter", env: "OPENROUTER_API_KEY" }, |
| 73 | XiaomiMimo: { id: "xiaomi-mimo", label: "Xiaomi MiMo", env: "XIAOMI_MIMO_TOKEN_PLAN_API_KEY / MIMO_TOKEN_PLAN_API_KEY / XIAOMI_MIMO_API_KEY / XIAOMI_API_KEY / MIMO_API_KEY" }, |
| 74 | Novita: { id: "novita", label: "Novita AI", env: "NOVITA_API_KEY" }, |
| 75 | Fireworks: { id: "fireworks", label: "Fireworks AI", env: "FIREWORKS_API_KEY" }, |
| 76 | Siliconflow: { id: "siliconflow", label: "SiliconFlow", env: "SILICONFLOW_API_KEY" }, |
| 77 | SiliconflowCn: { id: "siliconflow-CN", label: "SiliconFlow CN", env: "SILICONFLOW_API_KEY" }, |
| 78 | Arcee: { id: "arcee", label: "Arcee AI", env: "ARCEE_API_KEY" }, |
| 79 | Moonshot: { id: "moonshot", label: "Moonshot/Kimi", env: "MOONSHOT_API_KEY / KIMI_API_KEY" }, |
| 80 | Sglang: { id: "sglang", label: "SGLang", env: "SGLANG_API_KEY" }, |
| 81 | Vllm: { id: "vllm", label: "vLLM", env: "VLLM_API_KEY" }, |
| 82 | Ollama: { id: "ollama", label: "Ollama", env: "OLLAMA_API_KEY" }, |
| 83 | Huggingface: { id: "huggingface", label: "Hugging Face", env: "HUGGINGFACE_API_KEY / HF_TOKEN" }, |
| 84 | Deepinfra: { id: "deepinfra", label: "DeepInfra", env: "DEEPINFRA_API_KEY / DEEPINFRA_TOKEN" }, |
| 85 | Together: { id: "together", label: "Together AI", env: "TOGETHER_API_KEY" }, |
| 86 | Qianfan: { id: "qianfan", label: "Baidu Qianfan", env: "QIANFAN_API_KEY / BAIDU_QIANFAN_API_KEY" }, |
| 87 | OpenaiCodex: { id: "openai-codex", label: "OpenAI Codex", env: "ChatGPT/Codex OAuth via `codex login` (OPENAI_CODEX_ACCESS_TOKEN / CODEX_ACCESS_TOKEN override)" }, |
| 88 | OpencodeGo: { id: "opencode-go", label: "OpenCode Go", env: "OPENCODE_GO_API_KEY" }, |
| 89 | OpencodeZen: { id: "opencode-zen", label: "OpenCode Zen", env: "OPENCODE_ZEN_API_KEY / OPENCODE_API_KEY" }, |
| 90 | Anthropic: { id: "anthropic", label: "Anthropic", env: "ANTHROPIC_API_KEY" }, |
| 91 | Zai: { id: "zai", label: "Z.ai", env: "ZAI_API_KEY / Z_AI_API_KEY" }, |
| 92 | Stepfun: { id: "stepfun", label: "StepFun", env: "STEPFUN_API_KEY / STEP_API_KEY" }, |
| 93 | Minimax: { id: "minimax", label: "MiniMax", env: "MINIMAX_API_KEY" }, |
| 94 | MinimaxAnthropic: { id: "minimax-anthropic", label: "MiniMax (Anthropic-compatible)", env: "MINIMAX_API_KEY" }, |
| 95 | Openmodel: { id: "openmodel", label: "OpenModel", env: "OPENMODEL_API_KEY" }, |
| 96 | Sakana: { id: "sakana", label: "Sakana AI", env: "FUGU_API_KEY / SAKANA_API_KEY" }, |
| 97 | LongCat: { id: "longcat", label: "Meituan LongCat", env: "LONGCAT_API_KEY" }, |
| 98 | Meta: { id: "meta", label: "Meta Model API", env: "META_MODEL_API_KEY / MODEL_API_KEY" }, |
| 99 | Telecomjs: { id: "telecomjs", label: "TelecomJS TokenHub", env: "TELECOMJS_API_KEY" }, |
| 100 | Xai: { id: "xai", label: "xAI", env: "XAI_API_KEY" }, |
| 101 | ModelstudioTokenPlan: { id: "modelstudio-token-plan", label: "Model Studio Token Plan", env: "MODELSTUDIO_API_KEY" }, |
| 102 | ModelstudioTokenPlanAnthropic: { id: "modelstudio-token-plan-anthropic", label: "Model Studio Token Plan (Anthropic-compatible)", env: "MODELSTUDIO_API_KEY" }, |
| 103 | ModelstudioCodingPlan: { id: "modelstudio-coding-plan", label: "Model Studio Coding Plan", env: "MODELSTUDIO_API_KEY" }, |
| 104 | ModelstudioCodingPlanAnthropic: { id: "modelstudio-coding-plan-anthropic", label: "Model Studio Coding Plan (Anthropic-compatible)", env: "MODELSTUDIO_API_KEY" }, |
| 105 | }; |
| 106 | |
| 107 | // DeepseekCN: not wired through shared ProviderKind (#1104). |
| 108 | // Custom: the dynamic OpenAI-compatible meta-provider (#1519) — a runtime |
| 109 | // catch-all for user-defined endpoints, not a website-listable provider. |
| 110 | const EXCLUDED_PROVIDERS = new Set(["DeepseekCN", "Custom"]); |
| 111 | |
| 112 | function providerEnumVariants() { |
| 113 | const cfg = read("crates/tui/src/config.rs"); |
| 114 | if (!cfg) return []; |
| 115 | const enumBlock = cfg.match(/pub enum ApiProvider \{([\s\S]*?)\}/); |
| 116 | if (!enumBlock) return []; |
| 117 | return [...enumBlock[1].matchAll(/^\s*(\w+)\s*,\s*$/gm)].map((m) => m[1]); |
| 118 | } |
| 119 | |
| 120 | /** |
| 121 | * ApiProvider variants that are neither mapped to a website label nor |
| 122 | * intentionally excluded. Exposed so the CI gate (`check-facts.mjs`) can |
| 123 | * hard-fail on provider-inventory drift (#3772); the generator stays lenient |
| 124 | * and merely warns so local `prebuild` is not blocked mid-development. |
| 125 | */ |
| 126 | export function unmappedProviderVariants() { |
| 127 | return providerEnumVariants().filter( |
| 128 | (v) => !EXCLUDED_PROVIDERS.has(v) && !PROVIDER_LABEL_MAP[v], |
| 129 | ); |
| 130 | } |
| 131 | |
| 132 | export function deriveProviders() { |
| 133 | const variants = providerEnumVariants(); |
| 134 | |
| 135 | const unmapped = unmappedProviderVariants(); |
| 136 | if (unmapped.length > 0) { |
| 137 | console.error( |
| 138 | `[facts-lib] ApiProvider variants missing from PROVIDER_LABEL_MAP: ${unmapped.join(", ")}. ` + |
| 139 | "Add them to PROVIDER_LABEL_MAP here AND in web/lib/facts-drift.ts (or to EXCLUDED_PROVIDERS if intentionally hidden).", |
| 140 | ); |
| 141 | // The generator stays lenient and returns what it can map; the hard gate |
| 142 | // lives in check-facts.mjs via unmappedProviderVariants() (#3772). |
| 143 | } |
| 144 | return variants.map((v) => PROVIDER_LABEL_MAP[v]).filter(Boolean); |
| 145 | } |
| 146 | |
| 147 | export function deriveDefaultModel() { |
| 148 | // DEFAULT_TEXT_MODEL's definition moved to config/models.rs in the #3311 split; |
| 149 | // read both and match the const *definition* specifically (`= "..."`) so we |
| 150 | // don't mis-bind to a later string at a mere use site. |
| 151 | const cfg = |
| 152 | (read("crates/tui/src/config/models.rs") ?? "") + |
| 153 | "\n" + |
| 154 | (read("crates/tui/src/config.rs") ?? ""); |
| 155 | if (!cfg.trim()) return null; |
| 156 | const m = cfg.match(/DEFAULT_TEXT_MODEL\s*(?::\s*&str\s*)?=\s*"([^"]+)"/); |
| 157 | return m ? m[1] : null; |
| 158 | } |
| 159 | |
| 160 | export function deriveNodeEngines() { |
| 161 | const pkg = read("npm/codewhale/package.json"); |
| 162 | if (!pkg) return null; |
| 163 | try { |
| 164 | return JSON.parse(pkg).engines?.node ?? null; |
| 165 | } catch { |
| 166 | return null; |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | export function deriveToolCount() { |
| 171 | const dir = join(REPO_ROOT, "crates/tui/src/tools"); |
| 172 | if (!existsSync(dir)) return null; |
| 173 | let count = 0; |
| 174 | for (const f of readdirSync(dir)) { |
| 175 | if (!f.endsWith(".rs")) continue; |
| 176 | const body = readFileSync(join(dir, f), "utf-8"); |
| 177 | count += (body.match(/^impl ToolSpec for /gm) ?? []).length; |
| 178 | } |
| 179 | return count > 0 ? count : null; |
| 180 | } |
| 181 | |
| 182 | export function deriveLicense() { |
| 183 | const lic = read("LICENSE"); |
| 184 | if (!lic) return null; |
| 185 | const first = lic.split(/\r?\n/).find((l) => l.trim().length > 0); |
| 186 | if (!first) return null; |
| 187 | if (/^MIT License/i.test(first)) return "MIT"; |
| 188 | if (/Apache.*2\.0/i.test(first)) return "Apache-2.0"; |
| 189 | return first.trim(); |
| 190 | } |
| 191 | |
| 192 | export function deriveLatestPublishedRelease() { |
| 193 | const raw = read("web/data/latest-published-release.json"); |
| 194 | if (!raw) return null; |
| 195 | try { |
| 196 | const release = JSON.parse(raw); |
| 197 | if ( |
| 198 | typeof release.tag !== "string" || |
| 199 | typeof release.version !== "string" || |
| 200 | release.tag !== `v${release.version}` || |
| 201 | typeof release.publishedAt !== "string" || |
| 202 | !Number.isFinite(Date.parse(release.publishedAt)) || |
| 203 | typeof release.url !== "string" || |
| 204 | release.url !== `https://github.com/Hmbown/CodeWhale/releases/tag/${release.tag}` |
| 205 | ) { |
| 206 | return null; |
| 207 | } |
| 208 | return release; |
| 209 | } catch { |
| 210 | return null; |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | /** |
| 215 | * Re-derive all mechanical facts from the current workspace. The returned |
| 216 | * object is the same shape as web/lib/facts.generated.ts → RepoFacts. |
| 217 | */ |
| 218 | export function buildFacts() { |
| 219 | const providers = deriveProviders(); |
| 220 | // In check mode, missing provider mappings are a warning, not a crash. |
| 221 | // But if we truly have zero mapped providers, that signals something |
| 222 | // went wrong (e.g. config.rs renamed) — still return an empty array |
| 223 | // rather than null so the checker can report it. |
| 224 | |
| 225 | const facts = { |
| 226 | generatedAt: new Date().toISOString(), |
| 227 | // next.config.ts injects these from the exact checkout into the built |
| 228 | // artifact. They stay null in the tracked snapshot to avoid a |
| 229 | // self-referential generated-file diff after every commit. |
| 230 | sourceRevision: null, |
| 231 | sourceCommittedAt: null, |
| 232 | version: deriveVersion(), |
| 233 | crates: deriveCrates(), |
| 234 | sandboxBackends: deriveSandboxBackends(), |
| 235 | providers, |
| 236 | defaultModel: deriveDefaultModel(), |
| 237 | nodeEngines: deriveNodeEngines(), |
| 238 | toolCount: deriveToolCount(), |
| 239 | license: deriveLicense(), |
| 240 | latestPublishedRelease: deriveLatestPublishedRelease(), |
| 241 | }; |
| 242 | |
| 243 | return facts; |
| 244 | } |
| 245 |