diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 8ec38c41ee..6bcbba6a5f 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -85,6 +85,11 @@ machine-readable `code` and, when there is a next step, a `hint`: Some commands add their own fields next to `error`, such as `findings` from `lint` or `reason` and `fix` from a refused `timeline` edit. +**The project `.env`.** The `.env` in the current directory sets only the CLI's own +settings: `HEYGEN_API_KEY`, `HYPERFRAMES_API_KEY`, `ELEVENLABS_API_KEY`, `FIGMA_TOKEN`, and the +vision keys, models, Vertex settings and timeout, and the telemetry opt-outs `HYPERFRAMES_NO_TELEMETRY` and `DO_NOT_TRACK`. A variable already in your environment wins. Any +other key in the file is ignored, and one line on stderr names them. + **Flags, not prompts.** A missing required flag fails immediately with a usage example rather than waiting for input. Two commands are interactive on a TTY: `init` prompts for a project name and example unless you pass diff --git a/packages/cli/src/capture/scaffolding.ts b/packages/cli/src/capture/scaffolding.ts index f722459842..32786264ba 100644 --- a/packages/cli/src/capture/scaffolding.ts +++ b/packages/cli/src/capture/scaffolding.ts @@ -8,7 +8,7 @@ import { captureSiteName } from "./localPage.js"; import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; -import { applyDotEnv, CAPTURE_ENV_KEYS } from "../utils/dotEnv.js"; +import { applyDotEnv, PROJECT_ENV_KEYS } from "../utils/dotEnv.js"; import { writeNewFileSync } from "../utils/writeNewFile.js"; import type { CatalogedAsset } from "./assetCataloger.js"; import type { CaptureResult, DesignTokens } from "./types.js"; @@ -24,7 +24,7 @@ export function loadEnvFile(startDir: string): void { for (let i = 0; i < 5; i++) { const envPath = resolve(dir, ".env"); try { - applyDotEnv(readFileSync(envPath, "utf-8"), process.env, CAPTURE_ENV_KEYS); + applyDotEnv(readFileSync(envPath, "utf-8"), process.env, PROJECT_ENV_KEYS); break; } catch { dir = resolve(dir, ".."); diff --git a/packages/cli/src/cli.dotEnv.test.ts b/packages/cli/src/cli.dotEnv.test.ts index ecccb09103..f4af3198ca 100644 --- a/packages/cli/src/cli.dotEnv.test.ts +++ b/packages/cli/src/cli.dotEnv.test.ts @@ -44,8 +44,8 @@ it.each( [ ["capture", false, undefined, "fixture-key", undefined], ["capture", true, undefined, undefined, undefined], - ["init", false, process.execPath, "fixture-key", undefined], - ["init", true, process.execPath, "fixture-key", undefined], + ["init", false, undefined, "fixture-key", undefined], + ["init", true, undefined, "fixture-key", undefined], ["capture", false, process.execPath, "fixture-key", process.execPath], ] as const ).flatMap((test) => @@ -84,6 +84,6 @@ it.each( mockTelemetry(); process.argv = ["node", "cli.ts", ...prefix, command]; await import("./cli.js"); - expect(observed).toEqual([browser, command === "capture" ? undefined : "--no-warnings", key]); + expect(observed).toEqual([browser, undefined, key]); }, ); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6772c56d79..541937460a 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -65,22 +65,12 @@ if (rootVersionRequested) { process.exit(0); } -// Capture treats CWD as project data too: its early loader must use the same -// allowlist/opt-out as output ancestors, before browser selection can observe it. -const { applyDotEnv, CAPTURE_ENV_KEYS } = await import("./utils/dotEnv.js"); +// The working directory is project data: load only the settings a project `.env` may supply, +// before browser selection can observe the environment. +const { loadProjectDotEnv } = await import("./utils/dotEnv.js"); const captureEnv = argv[commandWordIndex(argv)] === "capture"; if (!captureEnv || process.env.HYPERFRAMES_NO_CAPTURE_DOTENV !== "1") { - try { - const { readFileSync } = await import("node:fs"); - const envPath = resolve(process.cwd(), ".env"); - applyDotEnv( - readFileSync(envPath, "utf-8"), - process.env, - captureEnv ? CAPTURE_ENV_KEYS : undefined, - ); - } catch { - /* .env not present — fine, env vars may be set another way */ - } + loadProjectDotEnv(process.cwd(), process.env, (line) => console.error(line)); } // ── Lazy imports ──────────────────────────────────────────────────────────── diff --git a/packages/cli/src/utils/dotEnv.test.ts b/packages/cli/src/utils/dotEnv.test.ts index c49b19bc3b..571680d9e0 100644 --- a/packages/cli/src/utils/dotEnv.test.ts +++ b/packages/cli/src/utils/dotEnv.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from "vitest"; -import { applyDotEnv } from "./dotEnv.js"; +import { describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { applyDotEnv, loadProjectDotEnv } from "./dotEnv.js"; describe("applyDotEnv", () => { it("copies a project's keys without overwriting what is already set", () => { @@ -84,3 +87,51 @@ describe("applyDotEnv quoted multiline values", () => { expect(env.AFTER).toBe("2"); }); }); + +describe("loadProjectDotEnv", () => { + const projectWith = (content: string): string => { + const dir = mkdtempSync(join(tmpdir(), "hf-dotenv-")); + writeFileSync(join(dir, ".env"), content); + return dir; + }; + + it("sets the CLI's own settings and ignores every other key, naming them once", () => { + const dir = projectWith( + "GEMINI_API_KEY=abc\nNODE_OPTIONS=--require ./x.js\nHTTPS_PROXY=http://example.test\nNODE_OPTIONS=again\n", + ); + try { + const env: NodeJS.ProcessEnv = {}; + const warn = vi.fn(); + loadProjectDotEnv(dir, env, warn); + expect(env).toEqual({ GEMINI_API_KEY: "abc" }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain("NODE_OPTIONS, HTTPS_PROXY"); + expect(warn.mock.calls[0]?.[0]).not.toContain("example.test"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("keeps a telemetry opt-out from the project .env", () => { + const dir = projectWith("HYPERFRAMES_NO_TELEMETRY=1\nDO_NOT_TRACK=1\n"); + try { + const env: NodeJS.ProcessEnv = {}; + loadProjectDotEnv(dir, env, vi.fn()); + expect(env).toEqual({ HYPERFRAMES_NO_TELEMETRY: "1", DO_NOT_TRACK: "1" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("says nothing when every key is one the CLI reads, or there is no .env", () => { + const dir = projectWith("OPENROUTER_API_KEY=k\n"); + try { + const warn = vi.fn(); + loadProjectDotEnv(dir, {}, warn); + loadProjectDotEnv(join(dir, "missing"), {}, warn); + expect(warn).not.toHaveBeenCalled(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/utils/dotEnv.ts b/packages/cli/src/utils/dotEnv.ts index 336063f7f5..0cf4ca6c61 100644 --- a/packages/cli/src/utils/dotEnv.ts +++ b/packages/cli/src/utils/dotEnv.ts @@ -1,6 +1,12 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { FEEDBACK_EMAIL_ENV } from "./feedbackSource.js"; -export const CAPTURE_ENV_KEYS = [ +export const PROJECT_ENV_KEYS = [ + "HEYGEN_API_KEY", + "HYPERFRAMES_API_KEY", + "ELEVENLABS_API_KEY", + "FIGMA_TOKEN", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", @@ -11,6 +17,9 @@ export const CAPTURE_ENV_KEYS = [ "HYPERFRAMES_OPENROUTER_MODEL", "HYPERFRAMES_GEMINI_MODEL", "HYPERFRAMES_VISION_TIMEOUT_MS", + // Telemetry opt-outs only turn sending off, and a project .env could already set them. + "HYPERFRAMES_NO_TELEMETRY", + "DO_NOT_TRACK", ]; // A quoted value ends at its closing quote; unquoted, `#` starts a comment only after whitespace, so `pass#word` stays. @@ -76,17 +85,34 @@ export function applyDotEnv( content: string, env: NodeJS.ProcessEnv, allowedKeys?: readonly string[], -): void { +): string[] { const allowed = allowedKeys === undefined ? undefined : new Set(allowedKeys); + const ignored: string[] = []; for (const entry of logicalLines(content).map(entryOf)) { if (!entry) continue; const [key, value] = entry; - if ( - key && - key.toUpperCase() !== FEEDBACK_EMAIL_ENV && - (allowed === undefined || allowed.has(key)) && - !(key in env) - ) - env[key] = value; + if (!key || key.toUpperCase() === FEEDBACK_EMAIL_ENV) continue; + if (allowed !== undefined && !allowed.has(key)) ignored.push(key); + else if (!(key in env)) env[key] = value; + } + return ignored; +} + +export function loadProjectDotEnv( + dir: string, + env: NodeJS.ProcessEnv, + warn: (line: string) => void, +): void { + let content: string; + try { + content = readFileSync(join(dir, ".env"), "utf-8"); + } catch { + return; + } + const ignored = [...new Set(applyDotEnv(content, env, PROJECT_ENV_KEYS))]; + if (ignored.length > 0) { + warn( + `[hyperframes] .env: ignoring ${ignored.join(", ")} (a project .env only sets the CLI's own settings)`, + ); } }