Skip to content

Commit add48bf

Browse files
committed
Add EnvSource config source and improve VS Code extension config loading
- Add EnvSource ConfigSource in SDK that maps SFCC_* env vars to NormalizedConfig fields (priority -10, opt-in via sourcesBefore) - Extension now loads .env files and uses EnvSource for env var support - Smart workspace folder detection scans for dw.json, .env with SFCC_* vars, and package.json b2c key in multi-root workspaces - Add "Use as B2C Commerce Root" context menu on workspace folders to pin a specific folder, with "Reset to Auto-Detect" command to clear - Pinned root persisted in workspace state, shown in status bar - Add .env file watchers alongside existing dw.json watchers
1 parent 732d4ad commit add48bf

File tree

7 files changed

+608
-29
lines changed

7 files changed

+608
-29
lines changed

packages/b2c-tooling-sdk/src/config/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,4 @@ export {ConfigSourceRegistry, globalConfigSourceRegistry} from './config-source-
145145

146146
// Config sources (for direct use)
147147
export {DwJsonSource} from './sources/dw-json-source.js';
148+
export {EnvSource} from './sources/env-source.js';
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
/**
7+
* Environment variable configuration source.
8+
*
9+
* Maps SFCC_* environment variables to NormalizedConfig fields.
10+
* Not included in default sources — opt-in only via `sourcesBefore`.
11+
*
12+
* @internal This module is internal to the SDK. Use ConfigResolver instead.
13+
*/
14+
import type {AuthMethod} from '../../auth/types.js';
15+
import {getPopulatedFields} from '../mapping.js';
16+
import type {ConfigSource, ConfigLoadResult, NormalizedConfig, ResolveConfigOptions} from '../types.js';
17+
import {getLogger} from '../../logging/logger.js';
18+
19+
/**
20+
* Mapping of SFCC_* environment variable names to NormalizedConfig field names.
21+
*/
22+
const ENV_VAR_MAP: Record<string, keyof NormalizedConfig> = {
23+
SFCC_SERVER: 'hostname',
24+
SFCC_WEBDAV_SERVER: 'webdavHostname',
25+
SFCC_CODE_VERSION: 'codeVersion',
26+
SFCC_USERNAME: 'username',
27+
SFCC_PASSWORD: 'password',
28+
SFCC_CERTIFICATE: 'certificate',
29+
SFCC_CERTIFICATE_PASSPHRASE: 'certificatePassphrase',
30+
SFCC_SELFSIGNED: 'selfSigned',
31+
SFCC_CLIENT_ID: 'clientId',
32+
SFCC_CLIENT_SECRET: 'clientSecret',
33+
SFCC_OAUTH_SCOPES: 'scopes',
34+
SFCC_SHORTCODE: 'shortCode',
35+
SFCC_TENANT_ID: 'tenantId',
36+
SFCC_AUTH_METHODS: 'authMethods',
37+
SFCC_ACCOUNT_MANAGER_HOST: 'accountManagerHost',
38+
SFCC_SANDBOX_API_HOST: 'sandboxApiHost',
39+
};
40+
41+
/** Fields that should be parsed as comma-separated arrays. */
42+
const ARRAY_FIELDS = new Set<keyof NormalizedConfig>(['scopes', 'authMethods']);
43+
44+
/** Fields that should be parsed as booleans. */
45+
const BOOLEAN_FIELDS = new Set<keyof NormalizedConfig>(['selfSigned']);
46+
47+
/**
48+
* Configuration source that reads SFCC_* environment variables.
49+
*
50+
* Priority -10 (higher than dw.json at 0), matching CLI behavior where
51+
* env vars override file-based config.
52+
*
53+
* Not added to default sources — opt-in only. The CLI handles env vars
54+
* via oclif flag `env:` mappings; this source is for consumers like
55+
* the VS Code extension that call `resolveConfig()` directly.
56+
*
57+
* @example
58+
* ```typescript
59+
* import { resolveConfig, EnvSource } from '@salesforce/b2c-tooling-sdk/config';
60+
*
61+
* const config = resolveConfig({}, {
62+
* sourcesBefore: [new EnvSource()],
63+
* });
64+
* ```
65+
*
66+
* @internal
67+
*/
68+
export class EnvSource implements ConfigSource {
69+
readonly name = 'EnvSource';
70+
readonly priority = -10;
71+
72+
private readonly env: Record<string, string | undefined>;
73+
74+
/**
75+
* @param env - Environment object to read from. Defaults to `process.env`.
76+
*/
77+
constructor(env?: Record<string, string | undefined>) {
78+
this.env = env ?? process.env;
79+
}
80+
81+
load(_options: ResolveConfigOptions): ConfigLoadResult | undefined {
82+
const logger = getLogger();
83+
const config: NormalizedConfig = {};
84+
85+
for (const [envVar, configField] of Object.entries(ENV_VAR_MAP)) {
86+
const value = this.env[envVar];
87+
if (value === undefined || value === '') continue;
88+
89+
if (BOOLEAN_FIELDS.has(configField)) {
90+
(config as Record<string, unknown>)[configField] = value === 'true' || value === '1';
91+
} else if (ARRAY_FIELDS.has(configField)) {
92+
(config as Record<string, unknown>)[configField] = value
93+
.split(',')
94+
.map((s) => s.trim())
95+
.filter(Boolean) as string[] | AuthMethod[];
96+
} else {
97+
(config as Record<string, unknown>)[configField] = value;
98+
}
99+
}
100+
101+
const fields = getPopulatedFields(config);
102+
if (fields.length === 0) {
103+
logger.trace('[EnvSource] No SFCC_* environment variables found');
104+
return undefined;
105+
}
106+
107+
logger.trace({fields}, '[EnvSource] Loaded config from environment variables');
108+
109+
return {config, location: 'environment variables'};
110+
}
111+
}

packages/b2c-tooling-sdk/src/config/sources/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@
99
* @internal This module is internal to the SDK. Use ConfigResolver instead.
1010
*/
1111
export {DwJsonSource} from './dw-json-source.js';
12+
export {EnvSource} from './env-source.js';
1213
export {MobifySource} from './mobify-source.js';
1314
export {PackageJsonSource} from './package-json-source.js';
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
import {expect} from 'chai';
7+
import {EnvSource, ConfigResolver, DwJsonSource} from '@salesforce/b2c-tooling-sdk/config';
8+
import * as fs from 'node:fs';
9+
import * as path from 'node:path';
10+
import * as os from 'node:os';
11+
12+
describe('config/EnvSource', () => {
13+
describe('string mapping', () => {
14+
it('maps SFCC_SERVER to hostname', () => {
15+
const source = new EnvSource({SFCC_SERVER: 'test.demandware.net'});
16+
const result = source.load({});
17+
expect(result).to.not.be.undefined;
18+
expect(result!.config.hostname).to.equal('test.demandware.net');
19+
});
20+
21+
it('maps SFCC_WEBDAV_SERVER to webdavHostname', () => {
22+
const source = new EnvSource({SFCC_WEBDAV_SERVER: 'webdav.example.com'});
23+
const result = source.load({});
24+
expect(result!.config.webdavHostname).to.equal('webdav.example.com');
25+
});
26+
27+
it('maps SFCC_CODE_VERSION to codeVersion', () => {
28+
const source = new EnvSource({SFCC_CODE_VERSION: 'v1'});
29+
const result = source.load({});
30+
expect(result!.config.codeVersion).to.equal('v1');
31+
});
32+
33+
it('maps SFCC_USERNAME to username', () => {
34+
const source = new EnvSource({SFCC_USERNAME: 'user@example.com'});
35+
const result = source.load({});
36+
expect(result!.config.username).to.equal('user@example.com');
37+
});
38+
39+
it('maps SFCC_PASSWORD to password', () => {
40+
const source = new EnvSource({SFCC_PASSWORD: 'secret'});
41+
const result = source.load({});
42+
expect(result!.config.password).to.equal('secret');
43+
});
44+
45+
it('maps SFCC_CERTIFICATE to certificate', () => {
46+
const source = new EnvSource({SFCC_CERTIFICATE: '/path/to/cert.p12'});
47+
const result = source.load({});
48+
expect(result!.config.certificate).to.equal('/path/to/cert.p12');
49+
});
50+
51+
it('maps SFCC_CERTIFICATE_PASSPHRASE to certificatePassphrase', () => {
52+
const source = new EnvSource({SFCC_CERTIFICATE_PASSPHRASE: 'pass123'});
53+
const result = source.load({});
54+
expect(result!.config.certificatePassphrase).to.equal('pass123');
55+
});
56+
57+
it('maps SFCC_CLIENT_ID to clientId', () => {
58+
const source = new EnvSource({SFCC_CLIENT_ID: 'my-client'});
59+
const result = source.load({});
60+
expect(result!.config.clientId).to.equal('my-client');
61+
});
62+
63+
it('maps SFCC_CLIENT_SECRET to clientSecret', () => {
64+
const source = new EnvSource({SFCC_CLIENT_SECRET: 'my-secret'});
65+
const result = source.load({});
66+
expect(result!.config.clientSecret).to.equal('my-secret');
67+
});
68+
69+
it('maps SFCC_SHORTCODE to shortCode', () => {
70+
const source = new EnvSource({SFCC_SHORTCODE: 'abc123'});
71+
const result = source.load({});
72+
expect(result!.config.shortCode).to.equal('abc123');
73+
});
74+
75+
it('maps SFCC_TENANT_ID to tenantId', () => {
76+
const source = new EnvSource({SFCC_TENANT_ID: 'abcd_prd'});
77+
const result = source.load({});
78+
expect(result!.config.tenantId).to.equal('abcd_prd');
79+
});
80+
81+
it('maps SFCC_ACCOUNT_MANAGER_HOST to accountManagerHost', () => {
82+
const source = new EnvSource({SFCC_ACCOUNT_MANAGER_HOST: 'account.demandware.com'});
83+
const result = source.load({});
84+
expect(result!.config.accountManagerHost).to.equal('account.demandware.com');
85+
});
86+
87+
it('maps SFCC_SANDBOX_API_HOST to sandboxApiHost', () => {
88+
const source = new EnvSource({SFCC_SANDBOX_API_HOST: 'admin.dx.commercecloud.salesforce.com'});
89+
const result = source.load({});
90+
expect(result!.config.sandboxApiHost).to.equal('admin.dx.commercecloud.salesforce.com');
91+
});
92+
});
93+
94+
describe('boolean parsing', () => {
95+
it('parses SFCC_SELFSIGNED=true as boolean true', () => {
96+
const source = new EnvSource({SFCC_SELFSIGNED: 'true'});
97+
const result = source.load({});
98+
expect(result!.config.selfSigned).to.be.true;
99+
});
100+
101+
it('parses SFCC_SELFSIGNED=1 as boolean true', () => {
102+
const source = new EnvSource({SFCC_SELFSIGNED: '1'});
103+
const result = source.load({});
104+
expect(result!.config.selfSigned).to.be.true;
105+
});
106+
107+
it('parses SFCC_SELFSIGNED=false as boolean false', () => {
108+
const source = new EnvSource({SFCC_SELFSIGNED: 'false'});
109+
const result = source.load({});
110+
expect(result!.config.selfSigned).to.be.false;
111+
});
112+
113+
it('parses SFCC_SELFSIGNED=0 as boolean false', () => {
114+
const source = new EnvSource({SFCC_SELFSIGNED: '0'});
115+
const result = source.load({});
116+
expect(result!.config.selfSigned).to.be.false;
117+
});
118+
});
119+
120+
describe('array parsing', () => {
121+
it('parses SFCC_OAUTH_SCOPES as comma-separated array', () => {
122+
const source = new EnvSource({SFCC_OAUTH_SCOPES: 'mail,roles,openid'});
123+
const result = source.load({});
124+
expect(result!.config.scopes).to.deep.equal(['mail', 'roles', 'openid']);
125+
});
126+
127+
it('trims whitespace in comma-separated values', () => {
128+
const source = new EnvSource({SFCC_OAUTH_SCOPES: ' mail , roles , openid '});
129+
const result = source.load({});
130+
expect(result!.config.scopes).to.deep.equal(['mail', 'roles', 'openid']);
131+
});
132+
133+
it('filters empty values in comma-separated arrays', () => {
134+
const source = new EnvSource({SFCC_OAUTH_SCOPES: 'mail,,roles,'});
135+
const result = source.load({});
136+
expect(result!.config.scopes).to.deep.equal(['mail', 'roles']);
137+
});
138+
139+
it('parses SFCC_AUTH_METHODS as comma-separated array', () => {
140+
const source = new EnvSource({SFCC_AUTH_METHODS: 'client-credentials,implicit'});
141+
const result = source.load({});
142+
expect(result!.config.authMethods).to.deep.equal(['client-credentials', 'implicit']);
143+
});
144+
});
145+
146+
describe('empty/undefined handling', () => {
147+
it('returns undefined when no SFCC_* vars are set', () => {
148+
const source = new EnvSource({});
149+
const result = source.load({});
150+
expect(result).to.be.undefined;
151+
});
152+
153+
it('skips empty string values', () => {
154+
const source = new EnvSource({SFCC_SERVER: '', SFCC_CLIENT_ID: 'my-client'});
155+
const result = source.load({});
156+
expect(result).to.not.be.undefined;
157+
expect(result!.config.hostname).to.be.undefined;
158+
expect(result!.config.clientId).to.equal('my-client');
159+
});
160+
161+
it('skips undefined values', () => {
162+
const source = new EnvSource({SFCC_SERVER: undefined, SFCC_CLIENT_ID: 'my-client'});
163+
const result = source.load({});
164+
expect(result!.config.hostname).to.be.undefined;
165+
expect(result!.config.clientId).to.equal('my-client');
166+
});
167+
168+
it('ignores non-SFCC environment variables', () => {
169+
const source = new EnvSource({HOME: '/home/user', PATH: '/usr/bin', SFCC_SERVER: 'test.demandware.net'});
170+
const result = source.load({});
171+
expect(result!.config.hostname).to.equal('test.demandware.net');
172+
expect(Object.keys(result!.config)).to.have.length(1);
173+
});
174+
});
175+
176+
describe('metadata', () => {
177+
it('has name EnvSource', () => {
178+
const source = new EnvSource({});
179+
expect(source.name).to.equal('EnvSource');
180+
});
181+
182+
it('has priority -10', () => {
183+
const source = new EnvSource({});
184+
expect(source.priority).to.equal(-10);
185+
});
186+
187+
it('reports location as environment variables', () => {
188+
const source = new EnvSource({SFCC_SERVER: 'test.demandware.net'});
189+
const result = source.load({});
190+
expect(result!.location).to.equal('environment variables');
191+
});
192+
});
193+
194+
describe('integration with ConfigResolver', () => {
195+
let tempDir: string;
196+
let originalCwd: string;
197+
198+
beforeEach(() => {
199+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'env-source-test-'));
200+
originalCwd = process.cwd();
201+
process.chdir(tempDir);
202+
});
203+
204+
afterEach(() => {
205+
process.chdir(originalCwd);
206+
if (fs.existsSync(tempDir)) {
207+
fs.rmSync(tempDir, {recursive: true, force: true});
208+
}
209+
});
210+
211+
it('EnvSource overrides DwJsonSource values (priority -10 < 0)', () => {
212+
// Create dw.json with a hostname
213+
fs.writeFileSync(path.join(tempDir, 'dw.json'), JSON.stringify({hostname: 'dw.demandware.net'}));
214+
215+
// EnvSource with different hostname
216+
const envSource = new EnvSource({SFCC_SERVER: 'env.demandware.net'});
217+
const resolver = new ConfigResolver([envSource, new DwJsonSource()]);
218+
const {config} = resolver.resolve();
219+
220+
// EnvSource should win (priority -10 < 0)
221+
expect(config.hostname).to.equal('env.demandware.net');
222+
});
223+
224+
it('DwJsonSource fills gaps not covered by EnvSource', () => {
225+
// dw.json provides code-version
226+
fs.writeFileSync(
227+
path.join(tempDir, 'dw.json'),
228+
JSON.stringify({hostname: 'dw.demandware.net', 'code-version': 'v2'}),
229+
);
230+
231+
// EnvSource provides only hostname
232+
const envSource = new EnvSource({SFCC_SERVER: 'env.demandware.net'});
233+
const resolver = new ConfigResolver([envSource, new DwJsonSource()]);
234+
const {config} = resolver.resolve();
235+
236+
expect(config.hostname).to.equal('env.demandware.net');
237+
expect(config.codeVersion).to.equal('v2');
238+
});
239+
});
240+
241+
describe('defaults to process.env', () => {
242+
it('reads from process.env when no env param given', () => {
243+
const original = process.env.SFCC_SERVER;
244+
try {
245+
process.env.SFCC_SERVER = 'from-process-env.demandware.net';
246+
const source = new EnvSource();
247+
const result = source.load({});
248+
expect(result).to.not.be.undefined;
249+
expect(result!.config.hostname).to.equal('from-process-env.demandware.net');
250+
} finally {
251+
if (original === undefined) {
252+
delete process.env.SFCC_SERVER;
253+
} else {
254+
process.env.SFCC_SERVER = original;
255+
}
256+
}
257+
});
258+
});
259+
});

0 commit comments

Comments
 (0)