diff --git a/README.md b/README.md index 3edd6ee7..3df2bdf7 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ npx @grinev/opencode-telegram-bot@latest > Quick start is for npm usage. You do not need to clone this repository. If you run this command from the source directory (repository root), it may fail with `opencode-telegram: not found`. To run from sources, use the [Development](#development) section. -On first launch, an interactive wizard will guide you through the configuration — it asks for interface language first, then your bot token, user ID, OpenCode API URL, and optional OpenCode server credentials (username/password). After that, you're ready to go. Open your bot in Telegram and start sending tasks. +If required configuration is not supplied through process environment variables or an `.env` file, an interactive wizard will guide you through setup. It asks for interface language first, then your bot token, user ID, OpenCode API URL, and optional OpenCode server credentials (username/password). After that, you're ready to go. Open your bot in Telegram and start sending tasks. #### Alternative: Global Install @@ -199,7 +199,7 @@ For this to work, the console OpenCode instance must be started on the same port ### Environment Variables -When installed via npm, the configuration wizard handles the initial setup. The `.env` file is stored in your platform's app data directory: +Configuration can be provided through process environment variables or an `.env` file. Process environment values take precedence. When installed via npm, the configuration wizard handles any missing required values and stores the generated `.env` file in your platform's app data directory: - **macOS:** `~/Library/Application Support/opencode-telegram-bot/.env` - **Windows:** `%APPDATA%\opencode-telegram-bot\.env` diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index 5ea9e87f..0a5be1bf 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -80,7 +80,9 @@ function isValidHttpUrl(value: string): boolean { } } -export function validateRuntimeEnvValues(values: Record): EnvValidationResult { +export function validateRuntimeEnvValues( + values: Record, +): EnvValidationResult { if (!values.TELEGRAM_BOT_TOKEN || values.TELEGRAM_BOT_TOKEN.trim().length === 0) { return { isValid: false, reason: "Missing TELEGRAM_BOT_TOKEN" }; } @@ -562,13 +564,17 @@ function ensureInteractiveTty(): void { async function validateExistingEnv(envFilePath: string): Promise { const content = await readEnvFileIfExists(envFilePath); + const effectiveValues: Record = + content === null ? {} : dotenv.parse(content); - if (content === null) { - return { isValid: false, reason: "Missing .env" }; + for (const key of WIZARD_ENV_KEYS) { + const processValue = process.env[key]; + if (processValue !== undefined) { + effectiveValues[key] = processValue; + } } - const parsed = dotenv.parse(content); - return validateRuntimeEnvValues(parsed); + return validateRuntimeEnvValues(effectiveValues); } async function runWizardAndPersist(runtimePaths: RuntimePaths): Promise { diff --git a/tests/runtime/bootstrap.test.ts b/tests/runtime/bootstrap.test.ts index 084ab8e5..97a85790 100644 --- a/tests/runtime/bootstrap.test.ts +++ b/tests/runtime/bootstrap.test.ts @@ -1,7 +1,14 @@ import fs from "node:fs"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { buildEnvFileContent, validateRuntimeEnvValues } from "../../src/runtime/bootstrap.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildEnvFileContent, + ensureRuntimeConfigForStart, + validateRuntimeEnvValues, +} from "../../src/runtime/bootstrap.js"; +import { setRuntimeMode } from "../../src/runtime/mode.js"; const ENV_EXAMPLE_CONTENT = fs.readFileSync(path.resolve(process.cwd(), ".env.example"), "utf-8"); @@ -185,3 +192,88 @@ describe("runtime/bootstrap", () => { expect(updated.trimEnd().endsWith("ANOTHER_CUSTOM=1")).toBe(true); }); }); + +describe("runtime/bootstrap installed configuration", () => { + let tempHome: string; + let stdinTtyDescriptor: PropertyDescriptor | undefined; + let stdoutTtyDescriptor: PropertyDescriptor | undefined; + + beforeEach(async () => { + tempHome = await mkdtemp(path.join(os.tmpdir(), "opencode-telegram-bootstrap-")); + stdinTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + stdoutTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false }); + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false }); + vi.stubEnv("OPENCODE_TELEGRAM_HOME", tempHome); + vi.stubEnv("TELEGRAM_BOT_TOKEN", "123456:process-token"); + vi.stubEnv("TELEGRAM_ALLOWED_USER_ID", "123456789"); + vi.stubEnv("OPENCODE_MODEL_PROVIDER", "process-provider"); + vi.stubEnv("OPENCODE_MODEL_ID", "process-model"); + vi.stubEnv("OPENCODE_API_URL", ""); + vi.stubEnv("BOT_LOCALE", "en"); + setRuntimeMode("installed"); + }); + + afterEach(async () => { + if (stdinTtyDescriptor) { + Object.defineProperty(process.stdin, "isTTY", stdinTtyDescriptor); + } else { + Reflect.deleteProperty(process.stdin, "isTTY"); + } + if (stdoutTtyDescriptor) { + Object.defineProperty(process.stdout, "isTTY", stdoutTtyDescriptor); + } else { + Reflect.deleteProperty(process.stdout, "isTTY"); + } + vi.unstubAllEnvs(); + delete process.env.OPENCODE_TELEGRAM_RUNTIME_MODE; + await rm(tempHome, { recursive: true, force: true }); + }); + + it("accepts required values from process.env without an .env file", async () => { + await ensureRuntimeConfigForStart(); + + expect(fs.existsSync(path.join(tempHome, ".env"))).toBe(false); + await expect(readFile(path.join(tempHome, "settings.json"), "utf-8")).resolves.toBe("{}\n"); + }); + + it("merges .env values with process.env taking precedence", async () => { + delete process.env.OPENCODE_MODEL_PROVIDER; + delete process.env.OPENCODE_MODEL_ID; + await writeFile( + path.join(tempHome, ".env"), + [ + "TELEGRAM_BOT_TOKEN=", + "TELEGRAM_ALLOWED_USER_ID=invalid", + "OPENCODE_MODEL_PROVIDER=file-provider", + "OPENCODE_MODEL_ID=file-model", + "", + ].join("\n"), + "utf-8", + ); + + await ensureRuntimeConfigForStart(); + + await expect(readFile(path.join(tempHome, "settings.json"), "utf-8")).resolves.toBe("{}\n"); + }); + + it("rejects an invalid process.env value even when .env is valid", async () => { + vi.stubEnv("TELEGRAM_ALLOWED_USER_ID", "invalid"); + await writeFile( + path.join(tempHome, ".env"), + [ + "TELEGRAM_BOT_TOKEN=123456:file-token", + "TELEGRAM_ALLOWED_USER_ID=42", + "OPENCODE_MODEL_PROVIDER=file-provider", + "OPENCODE_MODEL_ID=file-model", + "", + ].join("\n"), + "utf-8", + ); + + await expect(ensureRuntimeConfigForStart()).rejects.toThrow( + "Interactive wizard requires a TTY terminal", + ); + expect(fs.existsSync(path.join(tempHome, "settings.json"))).toBe(false); + }); +});