From 6a14c6d9b44bdf54a6e50a2fb52d209e2cf84f53 Mon Sep 17 00:00:00 2001 From: brunozoric Date: Tue, 8 Sep 2026 11:18:37 +0200 Subject: [PATCH 1/5] fix(wizard): fall back to built-in template when .env.example has no placeholders Older scaffolded projects have a plain .env.example without {{TOKEN}} placeholders. The wizard now silently falls back to the built-in template instead of throwing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commands/transfer/wizard/envWriter.test.ts | 6 ++++-- src/commands/transfer/wizard/envWriter.ts | 16 ++++------------ 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/__tests__/commands/transfer/wizard/envWriter.test.ts b/__tests__/commands/transfer/wizard/envWriter.test.ts index a14d95c1..e6a222f8 100644 --- a/__tests__/commands/transfer/wizard/envWriter.test.ts +++ b/__tests__/commands/transfer/wizard/envWriter.test.ts @@ -80,10 +80,12 @@ describe("writeEnv", () => { expect(content).toContain("SEGMENTS=8"); }); - it("throws when .env.example exists but has no {{tokens}}", async () => { + it("falls back to built-in template when .env.example has no {{tokens}}", async () => { await writeFile(join(tmpDir, ".env.example"), "# no tokens here\n"); - await expect(writeEnv(tmpDir, SAMPLE_VALUES)).rejects.toThrow(/{{TOKEN}}/); + await writeEnv(tmpDir, SAMPLE_VALUES); + const content = await readFile(join(tmpDir, ".env"), "utf8"); + expect(content).toContain("SOURCE_REGION=eu-central-1"); }); it("uses built-in template when .env.example is absent", async () => { diff --git a/src/commands/transfer/wizard/envWriter.ts b/src/commands/transfer/wizard/envWriter.ts index a0582632..6df265d6 100644 --- a/src/commands/transfer/wizard/envWriter.ts +++ b/src/commands/transfer/wizard/envWriter.ts @@ -84,19 +84,11 @@ export async function writeEnv(projectDir: string, values: EnvValues): Promise Date: Tue, 8 Sep 2026 11:20:12 +0200 Subject: [PATCH 2/5] fix(scaffold): use {{TOKEN}} placeholders in .env.example template The wizard substitutes these placeholders when writing .env. Old projects with plain KEY=value format now silently fall back to the built-in template. Co-Authored-By: Claude Opus 4.6 (1M context) --- templates/projects/example/.env.example | 44 +++++++++++++++++-------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/templates/projects/example/.env.example b/templates/projects/example/.env.example index ebdc9683..386e7c87 100644 --- a/templates/projects/example/.env.example +++ b/templates/projects/example/.env.example @@ -2,22 +2,38 @@ # .env is gitignored — credentials never get committed. # --- Source environment --- -SOURCE_REGION=eu-central-1 -SOURCE_PROFILE=default -SOURCE_DDB_TABLE= -SOURCE_S3_BUCKET= +SOURCE_REGION={{SOURCE_REGION}} +SOURCE_ACCOUNT_ID={{SOURCE_ACCOUNT_ID}} + +# Option A: profile name (reads ~/.aws/credentials) +# SOURCE_PROFILE=my-source-profile + +# Option B: literal credentials (uncomment + fill in) +# SOURCE_AWS_ACCESS_KEY_ID= +# SOURCE_AWS_SECRET_ACCESS_KEY= +# SOURCE_AWS_SESSION_TOKEN= + +SOURCE_DDB_TABLE={{SOURCE_DDB_TABLE}} +SOURCE_S3_BUCKET={{SOURCE_S3_BUCKET}} +SOURCE_AUDIT_LOGS_TABLE={{SOURCE_AUDIT_LOGS_TABLE}} +SOURCE_OS_TABLE={{SOURCE_OS_TABLE}} # --- Target environment --- -TARGET_REGION=eu-central-1 -TARGET_PROFILE=default -TARGET_DDB_TABLE= -TARGET_S3_BUCKET= +TARGET_REGION={{TARGET_REGION}} +TARGET_ACCOUNT_ID={{TARGET_ACCOUNT_ID}} + +# TARGET_PROFILE=my-target-profile + +# TARGET_AWS_ACCESS_KEY_ID= +# TARGET_AWS_SECRET_ACCESS_KEY= +# TARGET_AWS_SESSION_TOKEN= -# --- OpenSearch (uncomment if used) --- -# SOURCE_OS_TABLE= -# TARGET_OS_TABLE= -# TARGET_OS_ENDPOINT= -# TARGET_OS_INDEX_PREFIX= +TARGET_DDB_TABLE={{TARGET_DDB_TABLE}} +TARGET_S3_BUCKET={{TARGET_S3_BUCKET}} +TARGET_AUDIT_LOGS_TABLE={{TARGET_AUDIT_LOGS_TABLE}} +TARGET_OS_TABLE={{TARGET_OS_TABLE}} +TARGET_OS_ENDPOINT={{TARGET_OS_ENDPOINT}} +TARGET_OS_INDEX_PREFIX={{TARGET_OS_INDEX_PREFIX}} # --- Tuning --- -SEGMENTS=4 +SEGMENTS={{SEGMENTS}} From a58921cc9c8a85bb8fcec1a63f4e3ce15b0dc7b2 Mon Sep 17 00:00:00 2001 From: brunozoric Date: Tue, 8 Sep 2026 11:23:23 +0200 Subject: [PATCH 3/5] fix(wizard): support plain KEY=value format in .env.example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit writeEnv now handles both formats: {{TOKEN}} placeholders (substituted as before) and plain KEY=value lines (parsed and replaced for known keys). Comments, blank lines, and unknown keys are preserved. Falls back to the built-in template when no .env.example exists. The scaffolded .env.example uses plain format — readable as-is for manual copy, and the wizard fills in values automatically. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../transfer/wizard/envWriter.test.ts | 21 +++++++- src/commands/transfer/wizard/envWriter.ts | 33 +++++++++++-- templates/projects/example/.env.example | 48 +++++++------------ 3 files changed, 64 insertions(+), 38 deletions(-) diff --git a/__tests__/commands/transfer/wizard/envWriter.test.ts b/__tests__/commands/transfer/wizard/envWriter.test.ts index e6a222f8..44aab4e1 100644 --- a/__tests__/commands/transfer/wizard/envWriter.test.ts +++ b/__tests__/commands/transfer/wizard/envWriter.test.ts @@ -80,12 +80,29 @@ describe("writeEnv", () => { expect(content).toContain("SEGMENTS=8"); }); - it("falls back to built-in template when .env.example has no {{tokens}}", async () => { - await writeFile(join(tmpDir, ".env.example"), "# no tokens here\n"); + it("substitutes values in a plain KEY=value .env.example (no {{tokens}})", async () => { + const plain = [ + "# Source", + "SOURCE_REGION=eu-central-1", + "SOURCE_DDB_TABLE=", + "SOURCE_S3_BUCKET=old-bucket", + "# comment preserved", + "", + "SEGMENTS=4", + "CUSTOM_KEY=untouched" + ].join("\n"); + await writeFile(join(tmpDir, ".env.example"), plain); await writeEnv(tmpDir, SAMPLE_VALUES); + const content = await readFile(join(tmpDir, ".env"), "utf8"); expect(content).toContain("SOURCE_REGION=eu-central-1"); + expect(content).toContain("SOURCE_DDB_TABLE=wby-source-primary"); + expect(content).toContain("SOURCE_S3_BUCKET=wby-source-bucket"); + expect(content).toContain("SEGMENTS=8"); + expect(content).toContain("# comment preserved"); + expect(content).toContain("CUSTOM_KEY=untouched"); + expect(content).not.toContain("old-bucket"); }); it("uses built-in template when .env.example is absent", async () => { diff --git a/src/commands/transfer/wizard/envWriter.ts b/src/commands/transfer/wizard/envWriter.ts index 6df265d6..3bb0d1cd 100644 --- a/src/commands/transfer/wizard/envWriter.ts +++ b/src/commands/transfer/wizard/envWriter.ts @@ -78,19 +78,42 @@ function substituteTokens(template: string, values: EnvValues): string { return result; } -export async function writeEnv(projectDir: string, values: EnvValues): Promise { - let template = BUILT_IN_TEMPLATE; +function substituteEnvLines(content: string, values: EnvValues): string { + return content + .split("\n") + .map(line => { + const trimmed = line.trimStart(); + if (!trimmed || trimmed.startsWith("#")) { + return line; + } + const eqIndex = trimmed.indexOf("="); + if (eqIndex === -1) { + return line; + } + const key = trimmed.slice(0, eqIndex).trim(); + const envKey = TOKEN_MAP[key]; + if (envKey) { + return `${key}=${String(values[envKey])}`; + } + return line; + }) + .join("\n"); +} +export async function writeEnv(projectDir: string, values: EnvValues): Promise { const examplePath = join(projectDir, ".env.example"); + let content: string; + try { const candidate = await readFile(examplePath, "utf8"); if (candidate.includes("{{")) { - template = candidate; + content = substituteTokens(candidate, values); + } else { + content = substituteEnvLines(candidate, values); } } catch { - // no .env.example or unreadable — use built-in + content = substituteTokens(BUILT_IN_TEMPLATE, values); } - const content = substituteTokens(template, values); await writeFile(join(projectDir, ".env"), content, "utf8"); } diff --git a/templates/projects/example/.env.example b/templates/projects/example/.env.example index 386e7c87..3bc1c785 100644 --- a/templates/projects/example/.env.example +++ b/templates/projects/example/.env.example @@ -2,38 +2,24 @@ # .env is gitignored — credentials never get committed. # --- Source environment --- -SOURCE_REGION={{SOURCE_REGION}} -SOURCE_ACCOUNT_ID={{SOURCE_ACCOUNT_ID}} - -# Option A: profile name (reads ~/.aws/credentials) -# SOURCE_PROFILE=my-source-profile - -# Option B: literal credentials (uncomment + fill in) -# SOURCE_AWS_ACCESS_KEY_ID= -# SOURCE_AWS_SECRET_ACCESS_KEY= -# SOURCE_AWS_SESSION_TOKEN= - -SOURCE_DDB_TABLE={{SOURCE_DDB_TABLE}} -SOURCE_S3_BUCKET={{SOURCE_S3_BUCKET}} -SOURCE_AUDIT_LOGS_TABLE={{SOURCE_AUDIT_LOGS_TABLE}} -SOURCE_OS_TABLE={{SOURCE_OS_TABLE}} +SOURCE_REGION=eu-central-1 +SOURCE_PROFILE=default +SOURCE_DDB_TABLE= +SOURCE_S3_BUCKET= +SOURCE_ACCOUNT_ID= +SOURCE_AUDIT_LOGS_TABLE= +SOURCE_OS_TABLE= # --- Target environment --- -TARGET_REGION={{TARGET_REGION}} -TARGET_ACCOUNT_ID={{TARGET_ACCOUNT_ID}} - -# TARGET_PROFILE=my-target-profile - -# TARGET_AWS_ACCESS_KEY_ID= -# TARGET_AWS_SECRET_ACCESS_KEY= -# TARGET_AWS_SESSION_TOKEN= - -TARGET_DDB_TABLE={{TARGET_DDB_TABLE}} -TARGET_S3_BUCKET={{TARGET_S3_BUCKET}} -TARGET_AUDIT_LOGS_TABLE={{TARGET_AUDIT_LOGS_TABLE}} -TARGET_OS_TABLE={{TARGET_OS_TABLE}} -TARGET_OS_ENDPOINT={{TARGET_OS_ENDPOINT}} -TARGET_OS_INDEX_PREFIX={{TARGET_OS_INDEX_PREFIX}} +TARGET_REGION=eu-central-1 +TARGET_PROFILE=default +TARGET_DDB_TABLE= +TARGET_S3_BUCKET= +TARGET_ACCOUNT_ID= +TARGET_AUDIT_LOGS_TABLE= +TARGET_OS_TABLE= +TARGET_OS_ENDPOINT= +TARGET_OS_INDEX_PREFIX= # --- Tuning --- -SEGMENTS={{SEGMENTS}} +SEGMENTS=4 From 05107d2cf71130a995befe00e12ef7e9fc8ad597 Mon Sep 17 00:00:00 2001 From: brunozoric Date: Tue, 8 Sep 2026 11:25:38 +0200 Subject: [PATCH 4/5] test(wizard): add thorough coverage for both env template formats Tests for plain KEY=value format: all known keys replaced, comments and blank lines preserved, empty values produce KEY= lines. Existing {{TOKEN}} format tests unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../transfer/wizard/envWriter.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/__tests__/commands/transfer/wizard/envWriter.test.ts b/__tests__/commands/transfer/wizard/envWriter.test.ts index 44aab4e1..d24c3f0d 100644 --- a/__tests__/commands/transfer/wizard/envWriter.test.ts +++ b/__tests__/commands/transfer/wizard/envWriter.test.ts @@ -105,6 +105,80 @@ describe("writeEnv", () => { expect(content).not.toContain("old-bucket"); }); + it("replaces all known keys in plain format", async () => { + const plain = [ + "SOURCE_REGION=eu-central-1", + "SOURCE_DDB_TABLE=", + "SOURCE_S3_BUCKET=", + "SOURCE_AUDIT_LOGS_TABLE=", + "SOURCE_OS_TABLE=", + "SOURCE_ACCOUNT_ID=", + "TARGET_REGION=eu-central-1", + "TARGET_DDB_TABLE=", + "TARGET_S3_BUCKET=", + "TARGET_AUDIT_LOGS_TABLE=", + "TARGET_OS_TABLE=", + "TARGET_OS_ENDPOINT=", + "TARGET_OS_INDEX_PREFIX=", + "TARGET_ACCOUNT_ID=", + "SEGMENTS=4" + ].join("\n"); + await writeFile(join(tmpDir, ".env.example"), plain); + + await writeEnv(tmpDir, SAMPLE_VALUES); + + const content = await readFile(join(tmpDir, ".env"), "utf8"); + expect(content).toContain("SOURCE_REGION=eu-central-1"); + expect(content).toContain("SOURCE_DDB_TABLE=wby-source-primary"); + expect(content).toContain("SOURCE_S3_BUCKET=wby-source-bucket"); + expect(content).toContain("SOURCE_OS_TABLE=wby-source-es"); + expect(content).toContain("SOURCE_ACCOUNT_ID=111111111111"); + expect(content).toContain("TARGET_REGION=us-east-1"); + expect(content).toContain("TARGET_DDB_TABLE=wby-target-primary"); + expect(content).toContain("TARGET_S3_BUCKET=wby-target-bucket"); + expect(content).toContain("TARGET_AUDIT_LOGS_TABLE=wby-target-audit-logs"); + expect(content).toContain("TARGET_OS_TABLE=wby-target-os"); + expect(content).toContain("TARGET_OS_ENDPOINT=search-target.us-east-1.es.amazonaws.com"); + expect(content).toContain("TARGET_OS_INDEX_PREFIX=my-prefix"); + expect(content).toContain("TARGET_ACCOUNT_ID=222222222222"); + expect(content).toContain("SEGMENTS=8"); + }); + + it("plain format preserves commented-out lines and blank lines", async () => { + const plain = [ + "# --- Source ---", + "SOURCE_REGION=eu-central-1", + "", + "# SOURCE_PROFILE=my-source-profile", + "SOURCE_DDB_TABLE=", + "# --- Target ---", + "TARGET_REGION=us-east-1", + "# TARGET_PROFILE=my-target-profile", + "TARGET_DDB_TABLE=" + ].join("\n"); + await writeFile(join(tmpDir, ".env.example"), plain); + + await writeEnv(tmpDir, SAMPLE_VALUES); + + const content = await readFile(join(tmpDir, ".env"), "utf8"); + expect(content).toContain("# --- Source ---"); + expect(content).toContain("# SOURCE_PROFILE=my-source-profile"); + expect(content).toContain("# --- Target ---"); + expect(content).toContain("# TARGET_PROFILE=my-target-profile"); + expect(content).toContain("\n\n"); + }); + + it("plain format writes empty string for empty values", async () => { + const values: EnvValues = { ...SAMPLE_VALUES, targetOsIndexPrefix: "" }; + const plain = "TARGET_OS_INDEX_PREFIX=old-prefix\n"; + await writeFile(join(tmpDir, ".env.example"), plain); + + await writeEnv(tmpDir, values); + + const content = await readFile(join(tmpDir, ".env"), "utf8"); + expect(content).toBe("TARGET_OS_INDEX_PREFIX=\n"); + }); + it("uses built-in template when .env.example is absent", async () => { await writeEnv(tmpDir, SAMPLE_VALUES); const content = await readFile(join(tmpDir, ".env"), "utf8"); From f96a9e1886ced7462acac21a70e579b2ae89c8ec Mon Sep 17 00:00:00 2001 From: brunozoric Date: Tue, 8 Sep 2026 11:29:08 +0200 Subject: [PATCH 5/5] chore: add changeset for env vars fix Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/big-months-lose.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/big-months-lose.md diff --git a/.changeset/big-months-lose.md b/.changeset/big-months-lose.md new file mode 100644 index 00000000..e4935496 --- /dev/null +++ b/.changeset/big-months-lose.md @@ -0,0 +1,5 @@ +--- +"@webiny/data-transfer": patch +--- + +Fix `writeEnv` to support plain `KEY=value` format in `.env.example` — older scaffolded projects no longer crash during the wizard. Both `{{TOKEN}}` placeholders and plain env lines are handled; falls back to the built-in template when no `.env.example` exists.