config-loader.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. import { readFile, access } from "node:fs/promises";
  2. import { homedir } from "node:os";
  3. import { join } from "node:path";
  4. import { ProjectConfigSchema, type ProjectConfig } from "../models/project.js";
  5. export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos");
  6. export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env");
  7. export function isApiKeyOptionalForEndpoint(params: {
  8. readonly provider?: string | undefined;
  9. readonly baseUrl?: string | undefined;
  10. }): boolean {
  11. if (params.provider === "anthropic") {
  12. return false;
  13. }
  14. if (!params.baseUrl) {
  15. return false;
  16. }
  17. try {
  18. const url = new URL(params.baseUrl);
  19. const hostname = url.hostname.toLowerCase();
  20. return (
  21. hostname === "localhost"
  22. || hostname === "127.0.0.1"
  23. || hostname === "::1"
  24. || hostname === "0.0.0.0"
  25. || hostname === "host.docker.internal"
  26. || hostname.endsWith(".local")
  27. || isPrivateIpv4(hostname)
  28. );
  29. } catch {
  30. return false;
  31. }
  32. }
  33. /**
  34. * Load project config from inkos.json with .env overrides.
  35. * Shared by CLI and Studio — single source of truth for config loading.
  36. */
  37. export async function loadProjectConfig(
  38. root: string,
  39. options?: { readonly requireApiKey?: boolean },
  40. ): Promise<ProjectConfig> {
  41. // Load global ~/.inkos/.env first, then project .env overrides
  42. const { config: loadEnv } = await import("dotenv");
  43. loadEnv({ path: GLOBAL_ENV_PATH });
  44. loadEnv({ path: join(root, ".env"), override: true });
  45. const configPath = join(root, "inkos.json");
  46. try {
  47. await access(configPath);
  48. } catch {
  49. throw new Error(
  50. `inkos.json not found in ${root}.\nMake sure you are inside an InkOS project directory (cd into the project created by 'inkos init').`,
  51. );
  52. }
  53. const raw = await readFile(configPath, "utf-8");
  54. let config: Record<string, unknown>;
  55. try {
  56. config = JSON.parse(raw);
  57. } catch {
  58. throw new Error(`inkos.json in ${root} is not valid JSON. Check the file for syntax errors.`);
  59. }
  60. // .env overrides inkos.json for LLM settings
  61. const env = process.env;
  62. const llm = (config.llm ?? {}) as Record<string, unknown>;
  63. if (env.INKOS_LLM_PROVIDER) llm.provider = env.INKOS_LLM_PROVIDER;
  64. if (env.INKOS_LLM_BASE_URL) llm.baseUrl = env.INKOS_LLM_BASE_URL;
  65. if (env.INKOS_LLM_MODEL) llm.model = env.INKOS_LLM_MODEL;
  66. if (env.INKOS_LLM_TEMPERATURE) llm.temperature = parseFloat(env.INKOS_LLM_TEMPERATURE);
  67. if (env.INKOS_LLM_MAX_TOKENS) llm.maxTokens = parseInt(env.INKOS_LLM_MAX_TOKENS, 10);
  68. if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
  69. // Extra params from env: INKOS_LLM_EXTRA_<key>=<value>
  70. const extraFromEnv: Record<string, unknown> = {};
  71. for (const [key, value] of Object.entries(env)) {
  72. if (key.startsWith("INKOS_LLM_EXTRA_") && value) {
  73. const paramName = key.slice("INKOS_LLM_EXTRA_".length);
  74. // Auto-coerce: numbers, booleans, JSON objects
  75. if (/^\d+(\.\d+)?$/.test(value)) extraFromEnv[paramName] = parseFloat(value);
  76. else if (value === "true") extraFromEnv[paramName] = true;
  77. else if (value === "false") extraFromEnv[paramName] = false;
  78. else if (value.startsWith("{") || value.startsWith("[")) {
  79. try { extraFromEnv[paramName] = JSON.parse(value); } catch { extraFromEnv[paramName] = value; }
  80. }
  81. else extraFromEnv[paramName] = value;
  82. }
  83. }
  84. if (Object.keys(extraFromEnv).length > 0) {
  85. llm.extra = { ...(llm.extra as Record<string, unknown> ?? {}), ...extraFromEnv };
  86. }
  87. if (env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;
  88. config.llm = llm;
  89. // Global language override
  90. if (env.INKOS_DEFAULT_LANGUAGE) config.language = env.INKOS_DEFAULT_LANGUAGE;
  91. // API key ONLY from env — never stored in inkos.json
  92. const apiKey = env.INKOS_LLM_API_KEY;
  93. const provider = typeof llm.provider === "string" ? llm.provider : undefined;
  94. const baseUrl = typeof llm.baseUrl === "string" ? llm.baseUrl : undefined;
  95. const apiKeyOptional = isApiKeyOptionalForEndpoint({ provider, baseUrl });
  96. if (!apiKey && options?.requireApiKey !== false && !apiKeyOptional) {
  97. throw new Error(
  98. "INKOS_LLM_API_KEY not set. Run 'inkos config set-global' or add it to project .env file.",
  99. );
  100. }
  101. if (options?.requireApiKey === false) {
  102. llm.provider = typeof llm.provider === "string" && llm.provider.length > 0
  103. ? llm.provider
  104. : "openai";
  105. llm.baseUrl = typeof llm.baseUrl === "string" && llm.baseUrl.length > 0
  106. ? llm.baseUrl
  107. : "https://example.invalid/v1";
  108. llm.model = typeof llm.model === "string" && llm.model.length > 0
  109. ? llm.model
  110. : "noop-model";
  111. }
  112. llm.apiKey = apiKey ?? "";
  113. return ProjectConfigSchema.parse(config);
  114. }
  115. function isPrivateIpv4(hostname: string): boolean {
  116. const parts = hostname.split(".").map((segment) => Number.parseInt(segment, 10));
  117. if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
  118. return false;
  119. }
  120. if (parts[0] === 10) return true;
  121. if (parts[0] === 192 && parts[1] === 168) return true;
  122. if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
  123. return false;
  124. }