From 68d3a4cf6d44f6f211a5c22d53bfd97dfdb3cc0b Mon Sep 17 00:00:00 2001 From: Mateus Zitelli <201472+MateusZitelli@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:26:52 +0200 Subject: [PATCH 1/4] refactor(core): move dotenv helpers to core --- src/commands/flows/loadEnvFile.ts | 2 +- src/{domains/flows => core}/dotenv.test.ts | 31 ++++++++++++++++++++-- src/{domains/flows => core}/dotenv.ts | 27 ++++++++++++++++++- src/domains/flows/pull/envVars.ts | 2 +- 4 files changed, 57 insertions(+), 5 deletions(-) rename src/{domains/flows => core}/dotenv.test.ts (77%) rename src/{domains/flows => core}/dotenv.ts (70%) diff --git a/src/commands/flows/loadEnvFile.ts b/src/commands/flows/loadEnvFile.ts index e3ce6909a..26e6f0393 100644 --- a/src/commands/flows/loadEnvFile.ts +++ b/src/commands/flows/loadEnvFile.ts @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { isNoEntError } from "~/core/errors.js"; -import { parseDotenv } from "~/domains/flows/dotenv.js"; +import { parseDotenv } from "~/core/dotenv.js"; export async function loadEnvFile(envDir: string): Promise { let content: string; diff --git a/src/domains/flows/dotenv.test.ts b/src/core/dotenv.test.ts similarity index 77% rename from src/domains/flows/dotenv.test.ts rename to src/core/dotenv.test.ts index 575fab1c3..b6d21201a 100644 --- a/src/domains/flows/dotenv.test.ts +++ b/src/core/dotenv.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { parseDotenv, serializeDotenv } from "./dotenv.js"; +import { + parseDotenv, + serializeDotenv, + serializeDotenvSkippingInvalid, +} from "./dotenv.js"; describe("serializeDotenv", () => { it('emits KEY="value" lines sorted by key with trailing newline', () => { @@ -41,6 +45,29 @@ describe("serializeDotenv", () => { }); }); +describe("serializeDotenvSkippingInvalid", () => { + it("skips keys that violate POSIX env-var shape and reports them", () => { + expect( + serializeDotenvSkippingInvalid({ + VALID: "ok", + "BAD KEY": "x", + "1LEADING_DIGIT": "y", + "DOTTED.KEY": "z", + }), + ).toEqual({ + content: 'VALID="ok"\n', + skippedKeys: ["1LEADING_DIGIT", "BAD KEY", "DOTTED.KEY"], + }); + }); + + it("returns empty content when every key is invalid", () => { + expect(serializeDotenvSkippingInvalid({ "BAD KEY": "x" })).toEqual({ + content: "", + skippedKeys: ["BAD KEY"], + }); + }); +}); + describe("parseDotenv", () => { it('parses simple KEY="value" lines', () => { expect(parseDotenv('TOKEN="abc"\nURL="https://example.com"\n')).toEqual({ @@ -61,7 +88,7 @@ describe("parseDotenv", () => { expect(parseDotenv('A="1"\r\n B="2" \r\n')).toEqual({ A: "1", B: "2" }); }); - it('unescapes \\\\, \\", \\n, \\r, \\t inside values', () => { + it('unescapes \\\\, ", \\n, \\r, \\t inside values', () => { expect( parseDotenv( 'BACK="a\\\\b"\nCR="a\\rb"\nNL="a\\nb"\nQUOTE="a\\"b"\nTAB="a\\tb"\n', diff --git a/src/domains/flows/dotenv.ts b/src/core/dotenv.ts similarity index 70% rename from src/domains/flows/dotenv.ts rename to src/core/dotenv.ts index c64fdde93..e5dd644a1 100644 --- a/src/domains/flows/dotenv.ts +++ b/src/core/dotenv.ts @@ -7,10 +7,22 @@ import { flowsMessages } from "~/core/messages/index.js"; const envKeyPattern = /^[A-Za-z_][A-Za-z0-9_]*$/; const lineRe = /^([A-Za-z_][A-Za-z0-9_]*)="((?:[^"\\]|\\.)*)"$/; +export type SerializeDotenvSkippingInvalidResult = { + readonly content: string; + // Keys that are not valid POSIX shell identifiers (e.g. they contain a `.` + // or a space). A `.env` file cannot represent them, so pull callers can + // choose to skip them while surfacing the names to the user. + readonly skippedKeys: readonly string[]; +}; + +export function isDotenvKey(key: string): boolean { + return envKeyPattern.test(key); +} + export function serializeDotenv(vars: Record): string { const keys = Object.keys(vars).sort(); for (const key of keys) { - if (!envKeyPattern.test(key)) { + if (!isDotenvKey(key)) { throw new Error(flowsMessages.dotenv.invalidKey(key)); } } @@ -18,6 +30,19 @@ export function serializeDotenv(vars: Record): string { return `${keys.map((key) => `${key}=${quote(vars[key] ?? "")}`).join("\n")}\n`; } +export function serializeDotenvSkippingInvalid( + vars: Record, +): SerializeDotenvSkippingInvalidResult { + const keys = Object.keys(vars).sort(); + const validVars: Record = {}; + const skippedKeys: string[] = []; + for (const key of keys) { + if (isDotenvKey(key)) validVars[key] = vars[key] ?? ""; + else skippedKeys.push(key); + } + return { content: serializeDotenv(validVars), skippedKeys }; +} + function quote(value: string): string { const escaped = value .replaceAll("\\", "\\\\") diff --git a/src/domains/flows/pull/envVars.ts b/src/domains/flows/pull/envVars.ts index 8a3bced03..2b4cbe26d 100644 --- a/src/domains/flows/pull/envVars.ts +++ b/src/domains/flows/pull/envVars.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { makeDefaultFs } from "~/shell/fs.js"; import type { Fs } from "~/shell/fs.js"; -import { serializeDotenv } from "~/domains/flows/dotenv.js"; +import { serializeDotenv } from "~/core/dotenv.js"; export async function writeEnvFile( envDir: string, From d7181e61c2956bec0aeed7c590569c43c991aedd Mon Sep 17 00:00:00 2001 From: Mateus Zitelli <201472+MateusZitelli@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:27:41 +0200 Subject: [PATCH 2/4] fix(core): keep dotenv key helper internal --- src/core/dotenv.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/dotenv.ts b/src/core/dotenv.ts index e5dd644a1..c107a83d0 100644 --- a/src/core/dotenv.ts +++ b/src/core/dotenv.ts @@ -15,7 +15,7 @@ export type SerializeDotenvSkippingInvalidResult = { readonly skippedKeys: readonly string[]; }; -export function isDotenvKey(key: string): boolean { +function isDotenvKey(key: string): boolean { return envKeyPattern.test(key); } From 690c2af69294f32d3c347ba574978cebb4cb0a81 Mon Sep 17 00:00:00 2001 From: Mateus Zitelli <201472+MateusZitelli@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:27:58 +0200 Subject: [PATCH 3/4] fix(flows): skip invalid dotenv keys on pull --- src/core/messages/flows.ts | 7 +++++++ src/domains/flows/pull/envVars.test.ts | 20 ++++++++++++++++++++ src/domains/flows/pull/envVars.ts | 16 ++++++++++------ src/domains/flows/pull/handler.test.ts | 2 ++ src/domains/flows/pull/handler.ts | 5 +++++ src/domains/flows/pull/stage.test.ts | 1 + src/domains/flows/pull/stage.ts | 12 ++++++++++-- 7 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/core/messages/flows.ts b/src/core/messages/flows.ts index 0bc4dd0fb..4e0011f75 100644 --- a/src/core/messages/flows.ts +++ b/src/core/messages/flows.ts @@ -82,6 +82,13 @@ export const flowsMessages = { dotenv: { invalidKey: (key: string) => `Cannot serialize env var with invalid key: ${JSON.stringify(key)}`, + skippedKeys: (keys: readonly string[]) => + `Skipped ${pluralize( + keys.length, + "environment variable", + )} with key(s) that are not valid POSIX shell identifiers (cannot be written to .env): ${keys + .map((key) => JSON.stringify(key)) + .join(", ")}`, unparseableLine: (line: string) => `Cannot parse .env line: ${JSON.stringify(line)}`, }, diff --git a/src/domains/flows/pull/envVars.test.ts b/src/domains/flows/pull/envVars.test.ts index 92732f9d7..ae137afd7 100644 --- a/src/domains/flows/pull/envVars.test.ts +++ b/src/domains/flows/pull/envVars.test.ts @@ -32,6 +32,26 @@ describe("writeEnvFile", () => { expect(await pathExists(join(workDir, ".env"))).toBe(false); }); + + it("writes valid vars, drops invalid keys, and reports them", async () => { + const { skippedKeys } = await writeEnvFile(workDir, { + TOKEN: "abc", + "BRIAN_2.0_AUTH_TOKEN": "secret", + }); + + expect(skippedKeys).toEqual(["BRIAN_2.0_AUTH_TOKEN"]); + const body = await readFile(join(workDir, ".env"), "utf8"); + expect(body).toBe('TOKEN="abc"\n'); + }); + + it("does not write an empty .env when every key is invalid", async () => { + const { skippedKeys } = await writeEnvFile(workDir, { + "BRIAN_2.0_AUTH_TOKEN": "secret", + }); + + expect(skippedKeys).toEqual(["BRIAN_2.0_AUTH_TOKEN"]); + expect(await pathExists(join(workDir, ".env"))).toBe(false); + }); }); describe("writeEnvFile (memory fs)", () => { diff --git a/src/domains/flows/pull/envVars.ts b/src/domains/flows/pull/envVars.ts index 2b4cbe26d..c88815222 100644 --- a/src/domains/flows/pull/envVars.ts +++ b/src/domains/flows/pull/envVars.ts @@ -3,15 +3,19 @@ import { join } from "node:path"; import { makeDefaultFs } from "~/shell/fs.js"; import type { Fs } from "~/shell/fs.js"; -import { serializeDotenv } from "~/core/dotenv.js"; +import { serializeDotenvSkippingInvalid } from "~/core/dotenv.js"; export async function writeEnvFile( envDir: string, vars: Record, fs: Fs = makeDefaultFs(), -): Promise { - if (Object.keys(vars).length === 0) return; - await fs.writeFile(join(envDir, ".env"), serializeDotenv(vars), { - mode: 0o600, - }); +): Promise<{ skippedKeys: readonly string[] }> { + if (Object.keys(vars).length === 0) return { skippedKeys: [] }; + const { content, skippedKeys } = serializeDotenvSkippingInvalid(vars); + // Only write when there is at least one valid var. An all-invalid set + // serializes to "" and we leave no empty .env behind. + if (content !== "") { + await fs.writeFile(join(envDir, ".env"), content, { mode: 0o600 }); + } + return { skippedKeys }; } diff --git a/src/domains/flows/pull/handler.test.ts b/src/domains/flows/pull/handler.test.ts index 716ccf002..79b46e340 100644 --- a/src/domains/flows/pull/handler.test.ts +++ b/src/domains/flows/pull/handler.test.ts @@ -114,6 +114,7 @@ describe("handleFlowsPull json mode output", () => { "flowCount", "flowsWithTeamStorageRefs", "manifestPath", + "skippedEnvVarKeys", ]); expect(payload).toEqual({ assetsDir: expect.stringContaining("/assets"), @@ -127,6 +128,7 @@ describe("handleFlowsPull json mode output", () => { ), flowCount: 2, envVarCount: 2, + skippedEnvVarKeys: [], flowsWithTeamStorageRefs: [], manifestPath: join(destDir, manifestFilename), }); diff --git a/src/domains/flows/pull/handler.ts b/src/domains/flows/pull/handler.ts index 6464d3992..c92e79adf 100644 --- a/src/domains/flows/pull/handler.ts +++ b/src/domains/flows/pull/handler.ts @@ -111,6 +111,10 @@ export async function handleFlowsPull( ), ); + if (result.skippedEnvVarKeys.length > 0) { + ctx.ui.warn(flowsMessages.dotenv.skippedKeys(result.skippedEnvVarKeys)); + } + if (ctx.ui.mode === "json") { ctx.ui.output( { @@ -120,6 +124,7 @@ export async function handleFlowsPull( fetchedAt: fetched.bundleFetchedAt.toISOString(), flowCount: result.flowCount, envVarCount: result.envVarCount, + skippedEnvVarKeys: result.skippedEnvVarKeys, flowsWithTeamStorageRefs: result.flowsWithTeamStorageRefs, assetDownloadedCount: assetResult.downloadedCount, assetReusedCount: assetResult.reusedCount, diff --git a/src/domains/flows/pull/stage.test.ts b/src/domains/flows/pull/stage.test.ts index 6f675d9c5..c75acd443 100644 --- a/src/domains/flows/pull/stage.test.ts +++ b/src/domains/flows/pull/stage.test.ts @@ -47,6 +47,7 @@ describe("stageBundle", () => { flowCount: 2, envVarCount: 1, flowsWithTeamStorageRefs: [], + skippedEnvVarKeys: [], }); expect(await readFile(join(destDir, "checkout.flow.ts"), "utf8")).toBe( "// checkout\n", diff --git a/src/domains/flows/pull/stage.ts b/src/domains/flows/pull/stage.ts index ca73721f2..a9b6a8f2e 100644 --- a/src/domains/flows/pull/stage.ts +++ b/src/domains/flows/pull/stage.ts @@ -30,6 +30,7 @@ type StageBundleResult = { flowCount: number; envVarCount: number; flowsWithTeamStorageRefs: string[]; + skippedEnvVarKeys: readonly string[]; }; export async function stageBundle( @@ -61,7 +62,11 @@ export async function stageBundle( ...args.envVars, TEAM_STORAGE_DIR: args.assetsAbs, }; - await writeEnvFile(tmpDir, effectiveEnvVars, fs); + const { skippedKeys: skippedEnvVarKeys } = await writeEnvFile( + tmpDir, + effectiveEnvVars, + fs, + ); const manifest = await buildManifest( { envId: args.envId, @@ -93,8 +98,11 @@ export async function stageBundle( return { envDir: args.destAbs, flowCount: manifest.flows.length, - envVarCount: Object.keys(effectiveEnvVars).length, + // Skipped keys never made it into the .env, so don't count them. + envVarCount: + Object.keys(effectiveEnvVars).length - skippedEnvVarKeys.length, flowsWithTeamStorageRefs, + skippedEnvVarKeys, }; } catch (err) { await removeTempDir(tmpDir, registry, fs).catch(() => {}); From 58db0560700aeb081be1161f7b620344dc95000e Mon Sep 17 00:00:00 2001 From: Mateus Zitelli <201472+MateusZitelli@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:30:02 +0200 Subject: [PATCH 4/4] chore(flows): add changeset for skipped dotenv keys --- .changeset/pull-skip-invalid-dotenv-keys.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pull-skip-invalid-dotenv-keys.md diff --git a/.changeset/pull-skip-invalid-dotenv-keys.md b/.changeset/pull-skip-invalid-dotenv-keys.md new file mode 100644 index 000000000..2a3bea2eb --- /dev/null +++ b/.changeset/pull-skip-invalid-dotenv-keys.md @@ -0,0 +1,5 @@ +--- +"@qawolf/cli": patch +--- + +Skip platform environment variables with keys that cannot be represented in local .env files during flows pull.