From 2072babeb34e8b26ba6386a2782ba0beea376bc7 Mon Sep 17 00:00:00 2001 From: Codada Date: Wed, 1 Apr 2026 23:09:22 +0800 Subject: [PATCH] fix(config): detect placeholder API keys as missing --- .env.example | 5 +-- .../cli/src/__tests__/cli-integration.test.ts | 31 ++++++++++++++++++ packages/cli/src/commands/doctor.ts | 14 ++++---- packages/cli/src/commands/init.ts | 6 ++-- packages/cli/src/utils.ts | 8 ++++- .../core/src/__tests__/config-loader.test.ts | 21 ++++++++++++ packages/core/src/index.ts | 2 +- packages/core/src/utils/config-loader.ts | 32 +++++++++++++++++-- 8 files changed, 102 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index 3e9d3347..032e9d59 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,15 @@ # InkOS Environment Configuration # Copy to .env and fill in your values -# LLM Provider (openai, anthropic, custom) +# LLM Configuration +# Provider: openai / anthropic / custom INKOS_LLM_PROVIDER=openai # API Base URL (OpenAI-compatible endpoint) INKOS_LLM_BASE_URL=https://api.openai.com/v1 # API Key -INKOS_LLM_API_KEY=sk-your-key-here +INKOS_LLM_API_KEY=your-api-key-here # Model name INKOS_LLM_MODEL=gpt-4o diff --git a/packages/cli/src/__tests__/cli-integration.test.ts b/packages/cli/src/__tests__/cli-integration.test.ts index b6731a4b..70541dbc 100644 --- a/packages/cli/src/__tests__/cli-integration.test.ts +++ b/packages/cli/src/__tests__/cli-integration.test.ts @@ -95,6 +95,13 @@ describe("CLI integration", () => { expect(envContent).toContain("INKOS_LLM_API_KEY"); }); + it("includes optional env guidance without duplicate anthropic examples", async () => { + const envContent = await readFile(join(projectDir, ".env"), "utf-8"); + expect(envContent).toContain("# INKOS_LLM_API_FORMAT=chat"); + expect(envContent).toContain("# INKOS_LLM_API_KEY="); + expect(envContent.match(/# INKOS_LLM_PROVIDER=anthropic/g) ?? []).toHaveLength(1); + }); + it("creates .gitignore", async () => { const gitignore = await readFile(join(projectDir, ".gitignore"), "utf-8"); expect(gitignore).toContain(".env"); @@ -485,6 +492,30 @@ describe("CLI integration", () => { } }); + it("treats placeholder API keys as missing config", async () => { + await stat(join(projectDir, "inkos.json")).catch(() => { + run(["init"]); + }); + const envPath = join(projectDir, ".env"); + const originalEnv = await readFile(envPath, "utf-8"); + + try { + await writeFile(envPath, [ + "INKOS_LLM_PROVIDER=openai", + "INKOS_LLM_BASE_URL=https://api.openai.com/v1", + "INKOS_LLM_MODEL=gpt-5.4", + "INKOS_LLM_API_KEY=your-api-key-here", + "", + ].join("\n"), "utf-8"); + + const { stdout } = runStderr(["doctor"]); + expect(stdout).toContain("LLM API Key: Missing"); + expect(stdout).toContain("No LLM config available"); + } finally { + await writeFile(envPath, originalEnv, "utf-8"); + } + }); + it("reports legacy books in the version migration check", async () => { const bookDir = join(projectDir, "books", "legacy-doctor-hint"); const storyDir = join(bookDir, "story"); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index a42ba8d2..ed26daae 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,7 +1,8 @@ import { Command } from "commander"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import { findProjectRoot, log, logError, GLOBAL_ENV_PATH } from "../utils.js"; +import { hasConfiguredApiKey, isApiKeyOptionalForEndpoint } from "@actalk/inkos-core"; +import { findProjectRoot, log, logError, GLOBAL_ENV_PATH, hasConfiguredApiKeyInEnvContent } from "../utils.js"; import { ensureNodeRuntimePinFiles, evaluateSqliteMemorySupport, @@ -64,7 +65,7 @@ export const doctorCommand = new Command("doctor") let hasGlobal = false; try { const globalContent = await readFile(GLOBAL_ENV_PATH, "utf-8"); - hasGlobal = globalContent.includes("INKOS_LLM_API_KEY=") && !globalContent.includes("your-api-key-here"); + hasGlobal = hasConfiguredApiKeyInEnvContent(globalContent); } catch { /* no global config */ } checks.push({ name: "Global Config", @@ -79,7 +80,6 @@ export const doctorCommand = new Command("doctor") const { config: loadDotenv } = await import("dotenv"); loadDotenv({ path: GLOBAL_ENV_PATH }); loadDotenv({ path: join(root, ".env"), override: true }); - const { isApiKeyOptionalForEndpoint } = await import("@actalk/inkos-core"); let provider = process.env.INKOS_LLM_PROVIDER; let baseUrl = process.env.INKOS_LLM_BASE_URL; try { @@ -91,7 +91,7 @@ export const doctorCommand = new Command("doctor") } const apiKey = process.env.INKOS_LLM_API_KEY; const apiKeyOptional = isApiKeyOptionalForEndpoint({ provider, baseUrl }); - const hasKey = apiKeyOptional || (!!apiKey && apiKey.length > 10 && apiKey !== "your-api-key-here"); + const hasKey = apiKeyOptional || hasConfiguredApiKey(apiKey); checks.push({ name: "LLM API Key", ok: hasKey, @@ -149,7 +149,7 @@ export const doctorCommand = new Command("doctor") // 6. API connectivity test try { - const { createLLMClient, chatCompletion, LLMConfigSchema, isApiKeyOptionalForEndpoint } = await import("@actalk/inkos-core"); + const { createLLMClient, chatCompletion, LLMConfigSchema } = await import("@actalk/inkos-core"); const { loadConfig } = await import("../utils.js"); let llmConfig; @@ -165,11 +165,11 @@ export const doctorCommand = new Command("doctor") provider: env.INKOS_LLM_PROVIDER, baseUrl: env.INKOS_LLM_BASE_URL, }); - if ((env.INKOS_LLM_API_KEY || apiKeyOptional) && env.INKOS_LLM_BASE_URL && env.INKOS_LLM_MODEL) { + if ((hasConfiguredApiKey(env.INKOS_LLM_API_KEY) || apiKeyOptional) && env.INKOS_LLM_BASE_URL && env.INKOS_LLM_MODEL) { llmConfig = LLMConfigSchema.parse({ provider: env.INKOS_LLM_PROVIDER ?? "custom", baseUrl: env.INKOS_LLM_BASE_URL, - apiKey: env.INKOS_LLM_API_KEY ?? "", + apiKey: hasConfiguredApiKey(env.INKOS_LLM_API_KEY) ? env.INKOS_LLM_API_KEY : "", model: env.INKOS_LLM_MODEL, }); } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 1bdc88df..32a32eb5 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,12 +1,12 @@ import { Command } from "commander"; import { access, readFile, writeFile, mkdir } from "node:fs/promises"; import { join, basename, resolve } from "node:path"; -import { log, logError, GLOBAL_ENV_PATH } from "../utils.js"; +import { log, logError, GLOBAL_ENV_PATH, hasConfiguredApiKeyInEnvContent } from "../utils.js"; async function hasGlobalConfig(): Promise { try { const content = await readFile(GLOBAL_ENV_PATH, "utf-8"); - return content.includes("INKOS_LLM_API_KEY=") && !content.includes("your-api-key-here"); + return hasConfiguredApiKeyInEnvContent(content); } catch { return false; } @@ -107,8 +107,8 @@ export const initCommand = new Command("init") "", "# Anthropic example:", "# INKOS_LLM_PROVIDER=anthropic", - "# INKOS_LLM_PROVIDER=anthropic", "# INKOS_LLM_BASE_URL=", + "# INKOS_LLM_API_KEY=", "# INKOS_LLM_MODEL=", ].join("\n"), "utf-8", diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index a47f02c8..35425d8e 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -1,6 +1,7 @@ import { readFile, stat } from "node:fs/promises"; import { join, resolve } from "node:path"; -import { createLLMClient, StateManager, createLogger, createStderrSink, createJsonLineSink, loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH, type ProjectConfig, type PipelineConfig, type LogSink } from "@actalk/inkos-core"; +import { parse as parseDotenv } from "dotenv"; +import { createLLMClient, StateManager, createLogger, createStderrSink, createJsonLineSink, loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH, hasConfiguredApiKey, type ProjectConfig, type PipelineConfig, type LogSink } from "@actalk/inkos-core"; import { formatSqliteMemorySupportWarning } from "./runtime-requirements.js"; export { GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH }; @@ -99,6 +100,11 @@ export function logError(message: string): void { process.stderr.write(`[ERROR] ${message}\n`); } +export function hasConfiguredApiKeyInEnvContent(content: string): boolean { + const parsed = parseDotenv(content); + return hasConfiguredApiKey(parsed.INKOS_LLM_API_KEY); +} + /** * Resolve book-id: if provided use it, otherwise auto-detect when exactly one book exists. * Validates that the book actually exists. diff --git a/packages/core/src/__tests__/config-loader.test.ts b/packages/core/src/__tests__/config-loader.test.ts index 45fe3049..de0a3e4f 100644 --- a/packages/core/src/__tests__/config-loader.test.ts +++ b/packages/core/src/__tests__/config-loader.test.ts @@ -77,4 +77,25 @@ describe("loadProjectConfig local provider auth", () => { await writeFile(join(root, ".env"), "", "utf-8"); await expect(loadProjectConfig(root)).rejects.toThrow(/INKOS_LLM_API_KEY not set/i); }); + + it("rejects placeholder API keys copied from env examples", async () => { + root = await mkdtemp(join(tmpdir(), "inkos-config-loader-placeholder-")); + for (const key of ENV_KEYS) { + previousEnv.set(key, process.env[key]); + process.env[key] = ""; + } + + await writeFile(join(root, "inkos.json"), JSON.stringify({ + name: "placeholder-project", + version: "0.1.0", + llm: { + provider: "openai", + baseUrl: "https://api.openai.com/v1", + model: "gpt-5.4", + }, + }, null, 2), "utf-8"); + await writeFile(join(root, ".env"), "INKOS_LLM_API_KEY=your-api-key-here\n", "utf-8"); + + await expect(loadProjectConfig(root)).rejects.toThrow(/INKOS_LLM_API_KEY not set/i); + }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c3c1ca3b..df149b6a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -103,7 +103,7 @@ export { loadRuntimeStateSnapshot, buildRuntimeStateArtifacts, saveRuntimeStateS export { splitChapters, type SplitChapter } from "./utils/chapter-splitter.js"; export { countChapterLength, resolveLengthCountingMode, formatLengthCount, buildLengthSpec, isOutsideSoftRange, isOutsideHardRange, chooseNormalizeMode, type LengthLanguage } from "./utils/length-metrics.js"; export { createLogger, createStderrSink, createJsonLineSink, nullSink, type Logger, type LogSink, type LogLevel, type LogEntry } from "./utils/logger.js"; -export { loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH, isApiKeyOptionalForEndpoint } from "./utils/config-loader.js"; +export { loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH, hasConfiguredApiKey, isApiKeyOptionalForEndpoint } from "./utils/config-loader.js"; export { computeAnalytics, type AnalyticsData, type TokenStats } from "./utils/analytics.js"; export { collectStaleHookDebt, diff --git a/packages/core/src/utils/config-loader.ts b/packages/core/src/utils/config-loader.ts index 309eaef4..d00ca95a 100644 --- a/packages/core/src/utils/config-loader.ts +++ b/packages/core/src/utils/config-loader.ts @@ -6,6 +6,31 @@ import { ProjectConfigSchema, type ProjectConfig } from "../models/project.js"; export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos"); export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env"); +const API_KEY_PLACEHOLDERS = new Set([ + "your-api-key-here", + "sk-your-key-here", +]); + +function normalizeApiKey(apiKey: string | undefined): string { + const value = (apiKey ?? "").trim(); + if ( + (value.startsWith("\"") && value.endsWith("\"")) + || (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1).trim(); + } + return value; +} + +function isPlaceholderApiKey(apiKey: string | undefined): boolean { + return API_KEY_PLACEHOLDERS.has(normalizeApiKey(apiKey).toLowerCase()); +} + +export function hasConfiguredApiKey(apiKey: string | undefined): boolean { + const value = normalizeApiKey(apiKey); + return value.length > 0 && !isPlaceholderApiKey(value); +} + export function isApiKeyOptionalForEndpoint(params: { readonly provider?: string | undefined; readonly baseUrl?: string | undefined; @@ -101,12 +126,13 @@ export async function loadProjectConfig( if (env.INKOS_DEFAULT_LANGUAGE) config.language = env.INKOS_DEFAULT_LANGUAGE; // API key ONLY from env — never stored in inkos.json - const apiKey = env.INKOS_LLM_API_KEY; + const apiKey = normalizeApiKey(env.INKOS_LLM_API_KEY); const provider = typeof llm.provider === "string" ? llm.provider : undefined; const baseUrl = typeof llm.baseUrl === "string" ? llm.baseUrl : undefined; const apiKeyOptional = isApiKeyOptionalForEndpoint({ provider, baseUrl }); + const hasApiKey = hasConfiguredApiKey(apiKey); - if (!apiKey && options?.requireApiKey !== false && !apiKeyOptional) { + if (!hasApiKey && options?.requireApiKey !== false && !apiKeyOptional) { throw new Error( "INKOS_LLM_API_KEY not set. Run 'inkos config set-global' or add it to project .env file.", ); @@ -122,7 +148,7 @@ export async function loadProjectConfig( ? llm.model : "noop-model"; } - llm.apiKey = apiKey ?? ""; + llm.apiKey = hasApiKey ? apiKey : ""; return ProjectConfigSchema.parse(config); }