Skip to content

Commit f3a30d0

Browse files
jorgemoyaclaude
andcommitted
fix(cli): follow .env.example ordering and comments when writing .env.local
writeEnv previously emitted a flat, unordered list of KEY=VALUE pairs, ignoring core/.env.example — the file that documents every variable with comments, ordering, and sensible defaults. Generated .env.local files were therefore inconsistent and undocumented, and channel link/create clobbered any values the user had already set. writeEnv now reads the project's own .env.example as the source of truth for shape. It reproduces the documented ordering and per-key comment blocks, substitutes CLI-supplied values in place, and leaves documented-but-unsupplied keys as their example line (blank or default) so users still see what's expected. CLI-only variables (e.g. access tokens not in .env.example) are appended in a clearly separated trailing section. Existing .env.local files are reconciled rather than overwritten: user-set values are preserved unless the CLI explicitly supplies a new one, and newly documented keys are added in their canonical position without disturbing the ordering of unknown keys. Falls back to a flat merge when no .env.example is present. Refs LTRAC-974 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8d9f9a9 commit f3a30d0

3 files changed

Lines changed: 297 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bigcommerce/catalyst": patch
3+
---
4+
5+
The CLI now follows `.env.example` as the source of truth when writing `.env.local`. Generated env files preserve the documented ordering and per-key comment blocks, render documented-but-unsupplied keys as blank/default active keys, and append any CLI-only variables in a clearly separated trailing section. Existing `.env.local` values are reconciled rather than clobbered on re-runs (e.g. `channel link`), so user-set values are preserved while newly documented keys are added in their canonical position.
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5+
6+
import { writeEnv } from './write-env';
7+
8+
// A trimmed-down stand-in for core/.env.example: one blank documented key, one
9+
// documented key with a default value, each with its own leading comment block.
10+
const EXAMPLE = `# Store hash comment.
11+
BIGCOMMERCE_STORE_HASH=
12+
13+
# Channel id comment.
14+
BIGCOMMERCE_CHANNEL_ID=1
15+
16+
# Admin route comment.
17+
ENABLE_ADMIN_ROUTE=true
18+
`;
19+
20+
let projectDir: string;
21+
22+
const writeExample = (contents: string) => {
23+
writeFileSync(join(projectDir, '.env.example'), contents);
24+
};
25+
26+
const writeLocal = (contents: string) => {
27+
writeFileSync(join(projectDir, '.env.local'), contents);
28+
};
29+
30+
const readLocal = () => readFileSync(join(projectDir, '.env.local'), 'utf-8');
31+
32+
beforeEach(() => {
33+
projectDir = mkdtempSync(join(tmpdir(), 'catalyst-write-env-test-'));
34+
});
35+
36+
afterEach(() => {
37+
rmSync(projectDir, { recursive: true, force: true });
38+
});
39+
40+
describe('writeEnv', () => {
41+
it('follows .env.example ordering and preserves each key comment block', () => {
42+
writeExample(EXAMPLE);
43+
44+
writeEnv(projectDir, {
45+
BIGCOMMERCE_STORE_HASH: 'abc123',
46+
BIGCOMMERCE_CHANNEL_ID: '42',
47+
});
48+
49+
expect(readLocal()).toBe(
50+
`# Store hash comment.
51+
BIGCOMMERCE_STORE_HASH=abc123
52+
53+
# Channel id comment.
54+
BIGCOMMERCE_CHANNEL_ID=42
55+
56+
# Admin route comment.
57+
ENABLE_ADMIN_ROUTE=true
58+
`,
59+
);
60+
});
61+
62+
it('renders documented keys that were not supplied using the example line', () => {
63+
writeExample(EXAMPLE);
64+
65+
// Only supply the store hash — the other two keep the example's own line, so
66+
// the blank placeholder and the `=1` default both survive.
67+
writeEnv(projectDir, { BIGCOMMERCE_STORE_HASH: 'abc123' });
68+
69+
const local = readLocal();
70+
71+
expect(local).toContain('BIGCOMMERCE_STORE_HASH=abc123');
72+
expect(local).toContain('BIGCOMMERCE_CHANNEL_ID=1');
73+
expect(local).toContain('ENABLE_ADMIN_ROUTE=true');
74+
});
75+
76+
it('appends keys not present in .env.example in a separated trailing section', () => {
77+
writeExample(EXAMPLE);
78+
79+
writeEnv(projectDir, {
80+
BIGCOMMERCE_STORE_HASH: 'abc123',
81+
CATALYST_ACCESS_TOKEN: 'tok_secret',
82+
});
83+
84+
expect(readLocal()).toBe(
85+
`# Store hash comment.
86+
BIGCOMMERCE_STORE_HASH=abc123
87+
88+
# Channel id comment.
89+
BIGCOMMERCE_CHANNEL_ID=1
90+
91+
# Admin route comment.
92+
ENABLE_ADMIN_ROUTE=true
93+
94+
# Additional variables set by the Catalyst CLI (not in .env.example).
95+
CATALYST_ACCESS_TOKEN=tok_secret
96+
`,
97+
);
98+
});
99+
100+
it('preserves existing user values that the CLI does not supply on a re-run', () => {
101+
writeExample(EXAMPLE);
102+
writeLocal(
103+
`BIGCOMMERCE_STORE_HASH=old_hash
104+
BIGCOMMERCE_CHANNEL_ID=7
105+
ENABLE_ADMIN_ROUTE=false
106+
`,
107+
);
108+
109+
// Re-link a channel: only the channel-specific keys are supplied.
110+
writeEnv(projectDir, {
111+
BIGCOMMERCE_STORE_HASH: 'new_hash',
112+
BIGCOMMERCE_CHANNEL_ID: '9',
113+
});
114+
115+
const local = readLocal();
116+
117+
// Supplied keys are updated in place...
118+
expect(local).toContain('BIGCOMMERCE_STORE_HASH=new_hash');
119+
expect(local).toContain('BIGCOMMERCE_CHANNEL_ID=9');
120+
// ...while the user's untouched value is preserved (not reset to default).
121+
expect(local).toContain('ENABLE_ADMIN_ROUTE=false');
122+
});
123+
124+
it('reconciles a stale .env.local by inserting a newly documented key in canonical position', () => {
125+
// The existing file predates ENABLE_ADMIN_ROUTE being documented and also
126+
// carries an unknown key the user added by hand.
127+
writeExample(EXAMPLE);
128+
writeLocal(
129+
`BIGCOMMERCE_STORE_HASH=abc123
130+
BIGCOMMERCE_CHANNEL_ID=1
131+
MY_CUSTOM_KEY=custom_value
132+
`,
133+
);
134+
135+
writeEnv(projectDir, {});
136+
137+
expect(readLocal()).toBe(
138+
`# Store hash comment.
139+
BIGCOMMERCE_STORE_HASH=abc123
140+
141+
# Channel id comment.
142+
BIGCOMMERCE_CHANNEL_ID=1
143+
144+
# Admin route comment.
145+
ENABLE_ADMIN_ROUTE=true
146+
147+
# Additional variables set by the Catalyst CLI (not in .env.example).
148+
MY_CUSTOM_KEY=custom_value
149+
`,
150+
);
151+
});
152+
153+
it('preserves the ordering of existing unknown keys ahead of newly supplied ones', () => {
154+
writeExample(EXAMPLE);
155+
writeLocal(
156+
`EXISTING_UNKNOWN_A=a
157+
EXISTING_UNKNOWN_B=b
158+
`,
159+
);
160+
161+
writeEnv(projectDir, { NEW_UNKNOWN: 'c' });
162+
163+
const local = readLocal();
164+
const indexA = local.indexOf('EXISTING_UNKNOWN_A=a');
165+
const indexB = local.indexOf('EXISTING_UNKNOWN_B=b');
166+
const indexNew = local.indexOf('NEW_UNKNOWN=c');
167+
168+
expect(indexA).toBeGreaterThan(-1);
169+
expect(indexB).toBeGreaterThan(indexA);
170+
expect(indexNew).toBeGreaterThan(indexB);
171+
});
172+
173+
it('falls back to a flat merge when .env.example is absent', () => {
174+
writeLocal(`EXISTING=keep\n`);
175+
176+
writeEnv(projectDir, { SUPPLIED: 'value' });
177+
178+
const local = readLocal();
179+
180+
expect(local).toContain('EXISTING=keep');
181+
expect(local).toContain('SUPPLIED=value');
182+
});
183+
});
Lines changed: 109 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,116 @@
11
import { outputFileSync } from 'fs-extra/esm';
2-
import { join } from 'path';
2+
import { existsSync, readFileSync } from 'node:fs';
3+
import { join } from 'node:path';
4+
5+
// Matches an "active" env assignment — `KEY=VALUE` that isn't commented out.
6+
// The value is captured greedily (and may be empty) so blank documented keys
7+
// like `KEY=` still parse and `KEY=a=b` keeps the full value.
8+
const ACTIVE_KEY_LINE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
9+
10+
const keyFromLine = (line: string): string | null => {
11+
const match = ACTIVE_KEY_LINE.exec(line);
12+
13+
return match ? match[1] : null;
14+
};
15+
16+
// Parse a .env-style body into an ordered KEY -> VALUE map, skipping comments
17+
// and blank lines. Last assignment wins, matching dotenv semantics.
18+
const parseEnvValues = (contents: string): Map<string, string> => {
19+
const values = new Map<string, string>();
20+
21+
contents.split('\n').forEach((line) => {
22+
const match = ACTIVE_KEY_LINE.exec(line);
23+
24+
if (match) {
25+
values.set(match[1], match[2]);
26+
}
27+
});
28+
29+
return values;
30+
};
331

432
// Writes .env.local at the project root — Next.js (and all the catalyst CLI
533
// commands) read env vars from there, since the extracted project is the package
634
// they run inside.
35+
//
36+
// `.env.example` (shipped with the scaffolded project) is the source of truth
37+
// for shape: the generated `.env.local` mirrors its ordering and per-key comment
38+
// blocks so the file stays self-documenting. Values supplied by the CLI are
39+
// substituted in place; documented keys we weren't given keep the example's own
40+
// line (blank or default) so the user still sees what's expected. Any existing
41+
// `.env.local` is reconciled rather than clobbered — the user's current values
42+
// are preserved unless the CLI is explicitly supplying a new one.
743
export const writeEnv = (projectDir: string, envVars: Record<string, string>) => {
8-
outputFileSync(
9-
join(projectDir, '.env.local'),
10-
`${Object.entries(envVars)
11-
.map(([key, value]) => `${key}=${value}`)
12-
.join('\n')}\n`,
13-
);
44+
const examplePath = join(projectDir, '.env.example');
45+
const localPath = join(projectDir, '.env.local');
46+
47+
const existingLocal = existsSync(localPath)
48+
? parseEnvValues(readFileSync(localPath, 'utf-8'))
49+
: new Map<string, string>();
50+
51+
// CLI-supplied vars win over what's already on disk so a re-run (e.g.
52+
// `channel link`) updates credentials in place; anything the CLI didn't touch
53+
// falls back to the user's existing value.
54+
const resolve = (key: string): string | undefined =>
55+
key in envVars ? envVars[key] : existingLocal.get(key);
56+
57+
// Without a template we can't follow the documented ordering/comments, so fall
58+
// back to a flat merge that still preserves the user's existing values.
59+
if (!existsSync(examplePath)) {
60+
const keys = [...new Set([...existingLocal.keys(), ...Object.keys(envVars)])];
61+
62+
outputFileSync(localPath, `${keys.map((key) => `${key}=${resolve(key) ?? ''}`).join('\n')}\n`);
63+
64+
return;
65+
}
66+
67+
// Drop a single trailing newline before splitting so we don't reproduce a
68+
// spurious blank line; the final `join` re-adds exactly one.
69+
const exampleLines = readFileSync(examplePath, 'utf-8').replace(/\n$/, '').split('\n');
70+
const exampleKeys = new Set<string>();
71+
72+
const lines = exampleLines.map((line) => {
73+
const key = keyFromLine(line);
74+
75+
// Comments and blank lines are copied verbatim, preserving each key's
76+
// leading comment block.
77+
if (key === null) {
78+
return line;
79+
}
80+
81+
exampleKeys.add(key);
82+
83+
const value = resolve(key);
84+
85+
// Documented keys without a value keep the example's own line so defaults
86+
// (e.g. `BIGCOMMERCE_CHANNEL_ID=1`) and blank placeholders survive.
87+
return value === undefined ? line : `${key}=${value}`;
88+
});
89+
90+
// Keys the CLI supplied — or the user already had — that the template doesn't
91+
// document get appended in a clearly separated section, preserving the order
92+
// they were first seen (existing keys first, then newly supplied ones).
93+
const extraKeys: string[] = [];
94+
95+
[...existingLocal.keys()].forEach((key) => {
96+
if (!exampleKeys.has(key)) {
97+
extraKeys.push(key);
98+
}
99+
});
100+
101+
Object.keys(envVars).forEach((key) => {
102+
if (!exampleKeys.has(key) && !extraKeys.includes(key)) {
103+
extraKeys.push(key);
104+
}
105+
});
106+
107+
if (extraKeys.length > 0) {
108+
lines.push('', '# Additional variables set by the Catalyst CLI (not in .env.example).');
109+
110+
extraKeys.forEach((key) => {
111+
lines.push(`${key}=${resolve(key) ?? ''}`);
112+
});
113+
}
114+
115+
outputFileSync(localPath, `${lines.join('\n')}\n`);
14116
};

0 commit comments

Comments
 (0)