Skip to content

Commit fcc2227

Browse files
committed
feat: surface config source errors as warnings
Config sources that encounter errors (e.g., malformed JSON) now: - Re-throw after logging at trace level - Get caught by resolver which creates SOURCE_ERROR warning - Warnings are logged at warn level in CLI This helps users identify configuration problems without blocking execution.
1 parent dbb0981 commit fcc2227

File tree

10 files changed

+136
-14
lines changed

10 files changed

+136
-14
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@salesforce/b2c-tooling-sdk': minor
3+
---
4+
5+
Surface config source errors as warnings. When a config source (like dw.json) has malformed content, the error is now displayed as a warning instead of being silently ignored.

docs/guide/extending.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,31 @@ export class MyCustomSource implements ConfigSource {
205205
}
206206
```
207207

208+
### Error Handling
209+
210+
If your `ConfigSource` encounters an error (e.g., malformed config file, network failure), you can:
211+
212+
1. **Return `undefined`** - Source is silently skipped (use for "source not available")
213+
2. **Throw an exception** - Error is surfaced as a warning to the user
214+
215+
```typescript
216+
load(options: ResolveConfigOptions): ConfigLoadResult | undefined {
217+
const configPath = '/path/to/config';
218+
219+
if (!fs.existsSync(configPath)) {
220+
return undefined; // Source not available - skip silently
221+
}
222+
223+
// Let JSON.parse errors propagate - they'll become warnings
224+
const content = fs.readFileSync(configPath, 'utf8');
225+
const config = JSON.parse(content); // Throws if malformed
226+
227+
return { config, location: configPath };
228+
}
229+
```
230+
231+
When a source throws, the CLI displays a warning and continues with other sources. This helps users identify configuration problems without blocking execution.
232+
208233
### Plugin Configuration
209234

210235
Plugins cannot add flags to commands they don't own (this is an oclif limitation). Instead, plugins should accept configuration via environment variables:

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,9 @@ export function loadConfig(
245245
);
246246
}
247247

248-
// Log warnings
248+
// Log warnings (at warn level so users can see configuration issues)
249249
for (const warning of resolved.warnings) {
250-
logger.trace({warning}, `[Config] ${warning.message}`);
250+
logger.warn({warning}, `[Config] ${warning.message}`);
251251
}
252252

253253
return resolved;

packages/b2c-tooling-sdk/src/config/dw-json.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,9 +239,10 @@ export function loadDwJson(options: LoadDwJsonOptions = {}): LoadDwJsonResult |
239239
path: dwJsonPath,
240240
};
241241
} catch (error) {
242-
// Invalid JSON or read error
242+
// Invalid JSON or read error - log at trace level and re-throw
243+
// The resolver will catch this and create a SOURCE_ERROR warning
243244
const message = error instanceof Error ? error.message : String(error);
244245
logger.trace({path: dwJsonPath, error: message}, '[DwJsonSource] Failed to parse config file');
245-
return undefined;
246+
throw error;
246247
}
247248
}

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ import type {B2CInstance} from '../instance/index.js';
1616
import {mergeConfigsWithProtection, getPopulatedFields, createInstanceFromConfig} from './mapping.js';
1717
import {DwJsonSource, MobifySource, PackageJsonSource} from './sources/index.js';
1818
import type {
19+
ConfigLoadResult,
1920
ConfigSource,
2021
ConfigSourceInfo,
2122
ConfigResolutionResult,
23+
ConfigWarning,
2224
NormalizedConfig,
2325
ResolveConfigOptions,
2426
ResolvedB2CConfig,
@@ -155,6 +157,7 @@ export class ConfigResolver {
155157
*/
156158
resolve(overrides: Partial<NormalizedConfig> = {}, options: ResolveConfigOptions = {}): ConfigResolutionResult {
157159
const sourceInfos: ConfigSourceInfo[] = [];
160+
const sourceWarnings: ConfigWarning[] = [];
158161
const baseConfig: NormalizedConfig = {};
159162

160163
// Create enriched options that will be updated with accumulated config values.
@@ -165,7 +168,19 @@ export class ConfigResolver {
165168
// Load from each source in order, merging results
166169
// Earlier sources have higher priority - later sources only fill in missing values
167170
for (const source of this.sources) {
168-
const result = source.load(enrichedOptions);
171+
let result: ConfigLoadResult | undefined;
172+
try {
173+
result = source.load(enrichedOptions);
174+
} catch (error) {
175+
// Source threw an error (e.g., malformed config file) - create warning and continue
176+
const message = error instanceof Error ? error.message : String(error);
177+
sourceWarnings.push({
178+
code: 'SOURCE_ERROR',
179+
message: `Failed to load configuration from ${source.name}: ${message}`,
180+
details: {source: source.name, error: message},
181+
});
182+
continue;
183+
}
169184
if (result && result.config) {
170185
const {config: sourceConfig, location} = result;
171186
const fields = getPopulatedFields(sourceConfig);
@@ -219,10 +234,13 @@ export class ConfigResolver {
219234
}
220235

221236
// Apply overrides with hostname mismatch protection
222-
const {config, warnings} = mergeConfigsWithProtection(overrides, baseConfig, {
237+
const {config, warnings: mergeWarnings} = mergeConfigsWithProtection(overrides, baseConfig, {
223238
hostnameProtection: options.hostnameProtection,
224239
});
225240

241+
// Combine source warnings with merge warnings
242+
const warnings = [...sourceWarnings, ...mergeWarnings];
243+
226244
return {config, warnings, sources: sourceInfos};
227245
}
228246

packages/b2c-tooling-sdk/src/config/sources/mobify-source.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,11 @@ export class MobifySource implements ConfigSource {
7070
location: mobifyPath,
7171
};
7272
} catch (error) {
73-
// Invalid JSON or read error
73+
// Invalid JSON or read error - log at trace level and re-throw
74+
// The resolver will catch this and create a SOURCE_ERROR warning
7475
const message = error instanceof Error ? error.message : String(error);
7576
logger.trace({location: mobifyPath, error: message}, '[MobifySource] Failed to parse credentials file');
76-
return undefined;
77+
throw error;
7778
}
7879
}
7980

packages/b2c-tooling-sdk/src/config/sources/package-json-source.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,11 @@ export class PackageJsonSource implements ConfigSource {
108108

109109
return {config, location: packageJsonPath};
110110
} catch (error) {
111+
// Invalid JSON or read error - log at trace level and re-throw
112+
// The resolver will catch this and create a SOURCE_ERROR warning
111113
const message = error instanceof Error ? error.message : String(error);
112114
logger.trace({location: packageJsonPath, error: message}, '[PackageJsonSource] Failed to parse package.json');
113-
return undefined;
115+
throw error;
114116
}
115117
}
116118
}

packages/b2c-tooling-sdk/test/config/dw-json.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,12 +151,11 @@ describe('config/dw-json', () => {
151151
expect(result?.config.hostname).to.equal('root.demandware.net');
152152
});
153153

154-
it('returns undefined for invalid JSON', () => {
154+
it('throws for invalid JSON', () => {
155155
const dwJsonPath = path.join(tempDir, 'dw.json');
156156
fs.writeFileSync(dwJsonPath, 'invalid json');
157157

158-
const result = loadDwJson();
159-
expect(result).to.be.undefined;
158+
expect(() => loadDwJson()).to.throw(SyntaxError);
160159
});
161160

162161
it('returns undefined for non-existent explicit path', () => {

packages/b2c-tooling-sdk/test/config/resolver.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,72 @@ describe('config/resolver', () => {
217217
expect(warnings[0].code).to.equal('HOSTNAME_MISMATCH');
218218
});
219219

220+
it('creates SOURCE_ERROR warning when source throws', () => {
221+
// Create a source that throws an error
222+
const throwingSource: ConfigSource = {
223+
name: 'throwing-source',
224+
load() {
225+
throw new Error('Malformed config file');
226+
},
227+
};
228+
const validSource = new MockSource('valid', {
229+
hostname: 'example.demandware.net',
230+
clientId: 'valid-client',
231+
});
232+
const resolver = new ConfigResolver([throwingSource, validSource]);
233+
234+
const {config, warnings, sources} = resolver.resolve();
235+
236+
// Should have one SOURCE_ERROR warning
237+
expect(warnings).to.have.length(1);
238+
expect(warnings[0].code).to.equal('SOURCE_ERROR');
239+
expect(warnings[0].message).to.include('throwing-source');
240+
expect(warnings[0].message).to.include('Malformed config file');
241+
expect(warnings[0].details).to.deep.equal({
242+
source: 'throwing-source',
243+
error: 'Malformed config file',
244+
});
245+
246+
// Valid source should still contribute config
247+
expect(config.hostname).to.equal('example.demandware.net');
248+
expect(config.clientId).to.equal('valid-client');
249+
expect(sources).to.have.length(1);
250+
expect(sources[0].name).to.equal('valid');
251+
});
252+
253+
it('continues with remaining sources after SOURCE_ERROR', () => {
254+
// First source throws, second succeeds, third also throws
255+
const throwingSource1: ConfigSource = {
256+
name: 'bad-source-1',
257+
priority: -1,
258+
load() {
259+
throw new Error('Error 1');
260+
},
261+
};
262+
const validSource = new MockSource('valid', {hostname: 'example.com'}, undefined, 0);
263+
const throwingSource2: ConfigSource = {
264+
name: 'bad-source-2',
265+
priority: 1,
266+
load() {
267+
throw new Error('Error 2');
268+
},
269+
};
270+
const resolver = new ConfigResolver([throwingSource1, validSource, throwingSource2]);
271+
272+
const {config, warnings, sources} = resolver.resolve();
273+
274+
// Should have two SOURCE_ERROR warnings
275+
expect(warnings).to.have.length(2);
276+
expect(warnings[0].code).to.equal('SOURCE_ERROR');
277+
expect(warnings[0].message).to.include('bad-source-1');
278+
expect(warnings[1].code).to.equal('SOURCE_ERROR');
279+
expect(warnings[1].message).to.include('bad-source-2');
280+
281+
// Valid source contributes config
282+
expect(config.hostname).to.equal('example.com');
283+
expect(sources).to.have.length(1);
284+
});
285+
220286
it('returns empty config when no sources have data', () => {
221287
const resolver = new ConfigResolver([]);
222288

packages/b2c-tooling-sdk/test/config/sources.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ describe('config/sources', () => {
300300
}
301301
});
302302

303-
it('returns undefined for invalid JSON in ~/.mobify', function () {
303+
it('creates SOURCE_ERROR warning for invalid JSON in ~/.mobify', function () {
304304
const originalHomedir = os.homedir;
305305
let canMock = false;
306306
try {
@@ -320,9 +320,14 @@ describe('config/sources', () => {
320320
fs.writeFileSync(mobifyPath, 'invalid json');
321321

322322
const resolver = new ConfigResolver();
323-
const {config} = resolver.resolve();
323+
const {config, warnings} = resolver.resolve();
324324

325+
// Config should not have the API key
325326
expect(config.mrtApiKey).to.be.undefined;
327+
// Should have a SOURCE_ERROR warning for MobifySource
328+
const sourceError = warnings.find((w) => w.code === 'SOURCE_ERROR' && w.message.includes('MobifySource'));
329+
expect(sourceError).to.not.be.undefined;
330+
expect(sourceError?.message).to.include('Failed to load configuration');
326331

327332
// Restore
328333
Object.defineProperty(os, 'homedir', {

0 commit comments

Comments
 (0)