From 5e574b0b96b16e090e1b381ffbffc2c9eef47f36 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 27 Oct 2026 10:36:35 -0400 Subject: [PATCH 1/3] fix(cli): a project .env only sets the CLI's own settings The .env in the working directory could set any environment variable for every command except capture. It now sets only the keys the CLI reads from a project: the HeyGen, ElevenLabs and Figma credentials and the vision keys, models, Vertex settings and timeout. Other keys are ignored, and one stderr line names them. --- docs/packages/cli.mdx | 5 +++ packages/cli/src/capture/scaffolding.ts | 4 +-- packages/cli/src/cli.ts | 18 +++------- packages/cli/src/utils/dotEnv.test.ts | 44 +++++++++++++++++++++++-- packages/cli/src/utils/dotEnv.ts | 43 +++++++++++++++++++----- 5 files changed, 87 insertions(+), 27 deletions(-) diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 8ec38c41eed..3e6d4efda33 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. 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 f7224598422..32786264ba2 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.ts b/packages/cli/src/cli.ts index 6772c56d796..541937460ad 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 c49b19bc3bf..7c9b8ba760b 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,40 @@ 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("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 336063f7f56..dd2218b14b8 100644 --- a/packages/cli/src/utils/dotEnv.ts +++ b/packages/cli/src/utils/dotEnv.ts @@ -1,6 +1,13 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { FEEDBACK_EMAIL_ENV } from "./feedbackSource.js"; -export const CAPTURE_ENV_KEYS = [ +/** The settings a project `.env` may supply: provider credentials, models and timeouts. Nothing else is read from it. */ +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", @@ -76,17 +83,35 @@ 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; +} + +/** Loads `/.env` into `env` for the keys the CLI reads from a project, and names the others on `warn` once. */ +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)`, + ); } } From 722f917b99ca05434401ceb56aabe520ba00e894 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 27 Oct 2026 10:57:31 -0400 Subject: [PATCH 2/3] test(cli): the startup test and comment budget follow the single project .env key list --- packages/cli/src/cli.dotEnv.test.ts | 6 +++--- packages/cli/src/utils/dotEnv.ts | 2 -- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/cli.dotEnv.test.ts b/packages/cli/src/cli.dotEnv.test.ts index ecccb09103e..f4af3198ca8 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/utils/dotEnv.ts b/packages/cli/src/utils/dotEnv.ts index dd2218b14b8..27fa605e252 100644 --- a/packages/cli/src/utils/dotEnv.ts +++ b/packages/cli/src/utils/dotEnv.ts @@ -2,7 +2,6 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { FEEDBACK_EMAIL_ENV } from "./feedbackSource.js"; -/** The settings a project `.env` may supply: provider credentials, models and timeouts. Nothing else is read from it. */ export const PROJECT_ENV_KEYS = [ "HEYGEN_API_KEY", "HYPERFRAMES_API_KEY", @@ -96,7 +95,6 @@ export function applyDotEnv( return ignored; } -/** Loads `/.env` into `env` for the keys the CLI reads from a project, and names the others on `warn` once. */ export function loadProjectDotEnv( dir: string, env: NodeJS.ProcessEnv, From b799c869bfa98eee21d0833ec54d53ac37d6dd5c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 29 Oct 2026 08:06:32 -0400 Subject: [PATCH 3/3] fix(cli): a project .env can still turn telemetry off --- docs/packages/cli.mdx | 2 +- packages/cli/src/utils/dotEnv.test.ts | 11 +++++++++++ packages/cli/src/utils/dotEnv.ts | 3 +++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 3e6d4efda33..6bcbba6a5f3 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -87,7 +87,7 @@ Some commands add their own fields next to `error`, such as `findings` from **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. A variable already in your environment wins. Any +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 diff --git a/packages/cli/src/utils/dotEnv.test.ts b/packages/cli/src/utils/dotEnv.test.ts index 7c9b8ba760b..571680d9e08 100644 --- a/packages/cli/src/utils/dotEnv.test.ts +++ b/packages/cli/src/utils/dotEnv.test.ts @@ -112,6 +112,17 @@ describe("loadProjectDotEnv", () => { } }); + 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 { diff --git a/packages/cli/src/utils/dotEnv.ts b/packages/cli/src/utils/dotEnv.ts index 27fa605e252..0cf4ca6c610 100644 --- a/packages/cli/src/utils/dotEnv.ts +++ b/packages/cli/src/utils/dotEnv.ts @@ -17,6 +17,9 @@ export const PROJECT_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.