Skip to content

Commit 17d5c30

Browse files
committed
package json config sourcce
1 parent 1420b6f commit 17d5c30

10 files changed

Lines changed: 443 additions & 15 deletions

File tree

packages/b2c-tooling-sdk/src/cli/base-command.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,11 @@ export abstract class BaseCommand<T extends typeof Command> extends Command {
218218
* Plugin sources are collected into two arrays based on their priority:
219219
* - `pluginSourcesBefore`: High priority sources (override defaults)
220220
* - `pluginSourcesAfter`: Low priority sources (fill gaps)
221+
*
222+
* Priority mapping:
223+
* - 'before' → -1 (higher priority than defaults)
224+
* - 'after' → 10 (lower priority than defaults)
225+
* - number → used directly
221226
*/
222227
protected async collectPluginConfigSources(): Promise<void> {
223228
// Access flags that may be defined in subclasses (OAuthCommand, InstanceCommand)
@@ -241,10 +246,28 @@ export abstract class BaseCommand<T extends typeof Command> extends Command {
241246
const result = success.result as ConfigSourcesHookResult | undefined;
242247
if (!result?.sources?.length) continue;
243248

244-
if (result.priority === 'before') {
249+
// Map priority: 'before' → -1, 'after' → 10, number → as-is, undefined → 10
250+
const numericPriority =
251+
result.priority === 'before'
252+
? -1
253+
: result.priority === 'after'
254+
? 10
255+
: typeof result.priority === 'number'
256+
? result.priority
257+
: 10; // default 'after'
258+
259+
// Apply priority to sources that don't already have one set
260+
for (const source of result.sources) {
261+
if (source.priority === undefined) {
262+
(source as {priority?: number}).priority = numericPriority;
263+
}
264+
}
265+
266+
// Still use before/after arrays for backwards compatibility
267+
// The resolver will sort all sources by priority anyway
268+
if (numericPriority < 0) {
245269
this.pluginSourcesBefore.push(...result.sources);
246270
} else {
247-
// Default priority is 'after'
248271
this.pluginSourcesAfter.push(...result.sources);
249272
}
250273
}

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,22 @@ export interface ConfigSourcesHookResult {
7171
/** Config sources to add to the resolution chain */
7272
sources: ConfigSource[];
7373
/**
74-
* Where to insert sources relative to default sources.
74+
* Priority for the returned sources. Can be a string or number:
7575
*
76-
* - `'before'`: Higher priority than dw.json/~/.mobify (plugin overrides defaults)
77-
* - `'after'`: Lower priority than defaults (plugin fills gaps)
76+
* String values (legacy, still supported):
77+
* - `'before'`: Maps to priority -1 (higher priority than defaults)
78+
* - `'after'`: Maps to priority 10 (lower priority than defaults)
7879
*
79-
* @default 'after'
80+
* Numeric values (preferred):
81+
* - Any number. Lower numbers = higher priority.
82+
* - Built-in sources use priority 0.
83+
* - package.json uses priority 1000.
84+
*
85+
* If a source already has a `priority` property set, it will not be overridden.
86+
*
87+
* @default 'after' (maps to 10)
8088
*/
81-
priority?: 'before' | 'after';
89+
priority?: 'before' | 'after' | number;
8290
}
8391

8492
/**

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import type {AuthCredentials} from '../auth/types.js';
1515
import type {B2CInstance} from '../instance/index.js';
1616
import {mergeConfigsWithProtection, getPopulatedFields, createInstanceFromConfig} from './mapping.js';
17-
import {DwJsonSource, MobifySource} from './sources/index.js';
17+
import {DwJsonSource, MobifySource, PackageJsonSource} from './sources/index.js';
1818
import type {
1919
ConfigSource,
2020
ConfigSourceInfo,
@@ -125,10 +125,13 @@ export class ConfigResolver {
125125
/**
126126
* Creates a new ConfigResolver.
127127
*
128-
* @param sources - Custom configuration sources. If not provided, uses default sources (dw.json, ~/.mobify).
128+
* @param sources - Custom configuration sources. If not provided, uses default sources (dw.json, ~/.mobify, package.json).
129+
* Sources are automatically sorted by priority (lower number = higher priority).
129130
*/
130131
constructor(sources?: ConfigSource[]) {
131-
this.sources = sources ?? [new DwJsonSource(), new MobifySource()];
132+
const configSources = sources ?? [new DwJsonSource(), new MobifySource(), new PackageJsonSource()];
133+
// Sort sources by priority (lower number = higher priority, undefined = 0)
134+
this.sources = [...configSources].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
132135
}
133136

134137
/**
@@ -355,21 +358,22 @@ export function resolveConfig(
355358
): ResolvedB2CConfig {
356359
// Build sources list with priority ordering:
357360
// 1. sourcesBefore (high priority - override defaults)
358-
// 2. default sources (dw.json, ~/.mobify)
361+
// 2. default sources (dw.json, ~/.mobify, package.json)
359362
// 3. sourcesAfter (low priority - fill gaps)
360363
let sources: ConfigSource[];
361364

362365
if (options.replaceDefaultSources) {
363-
// Replace mode: only use provided sources (no default dw.json/~/.mobify)
366+
// Replace mode: only use provided sources (no default dw.json/~/.mobify/package.json)
364367
sources = [...(options.sourcesBefore ?? []), ...(options.sourcesAfter ?? [])];
365368
} else {
366369
// Normal mode: before + defaults + after
367-
const defaultSources: ConfigSource[] = [new DwJsonSource(), new MobifySource()];
370+
const defaultSources: ConfigSource[] = [new DwJsonSource(), new MobifySource(), new PackageJsonSource()];
368371

369-
// Combine: sourcesBefore > defaults > sourcesAfter
372+
// Combine all sources
370373
sources = [...(options.sourcesBefore ?? []), ...defaultSources, ...(options.sourcesAfter ?? [])];
371374
}
372375

376+
// ConfigResolver constructor will sort by priority
373377
const resolver = new ConfigResolver(sources);
374378
const {config, warnings, sources: sourceInfos} = resolver.resolve(overrides, options);
375379

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {getLogger} from '../../logging/logger.js';
2121
*/
2222
export class DwJsonSource implements ConfigSource {
2323
readonly name = 'DwJsonSource';
24+
readonly priority = 0;
2425

2526
load(options: ResolveConfigOptions): ConfigLoadResult | undefined {
2627
const logger = getLogger();

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@
1010
*/
1111
export {DwJsonSource} from './dw-json-source.js';
1212
export {MobifySource} from './mobify-source.js';
13+
export {PackageJsonSource} from './package-json-source.js';

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ interface MobifyConfigFile {
3939
*/
4040
export class MobifySource implements ConfigSource {
4141
readonly name = 'MobifySource';
42+
readonly priority = 0;
4243

4344
load(options: ResolveConfigOptions): ConfigLoadResult | undefined {
4445
const logger = getLogger();
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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+
* package.json configuration source.
8+
*
9+
* Reads configuration from the `b2c` key in package.json.
10+
* Only loads from cwd (project root), not from parent directories.
11+
*
12+
* @internal This module is internal to the SDK. Use ConfigResolver instead.
13+
*/
14+
import * as fs from 'node:fs';
15+
import * as path from 'node:path';
16+
import type {ConfigSource, ConfigLoadResult, ResolveConfigOptions, NormalizedConfig} from '../types.js';
17+
import {getPopulatedFields} from '../mapping.js';
18+
import {getLogger} from '../../logging/logger.js';
19+
20+
/**
21+
* Fields allowed to be configured in package.json.
22+
* These are non-sensitive, non-instance-specific configuration.
23+
*/
24+
const ALLOWED_FIELDS: (keyof NormalizedConfig)[] = [
25+
'shortCode',
26+
'clientId',
27+
'mrtProject',
28+
'mrtOrigin',
29+
'accountManagerHost',
30+
];
31+
32+
/**
33+
* Structure of the b2c config in package.json
34+
*/
35+
interface PackageJsonB2CConfig {
36+
shortCode?: string;
37+
clientId?: string;
38+
mrtProject?: string;
39+
mrtOrigin?: string;
40+
accountManagerHost?: string;
41+
[key: string]: unknown;
42+
}
43+
44+
/**
45+
* Configuration source that loads from package.json `b2c` key.
46+
*
47+
* This source has the lowest priority (1000) and only provides
48+
* non-sensitive, project-level defaults.
49+
*
50+
* @internal
51+
*/
52+
export class PackageJsonSource implements ConfigSource {
53+
readonly name = 'PackageJsonSource';
54+
readonly priority = 1000;
55+
56+
load(options: ResolveConfigOptions): ConfigLoadResult | undefined {
57+
const logger = getLogger();
58+
59+
// Only look in cwd (or startDir if provided)
60+
const searchDir = options.startDir ?? process.cwd();
61+
const packageJsonPath = path.join(searchDir, 'package.json');
62+
63+
logger.trace({location: packageJsonPath}, '[PackageJsonSource] Checking for package.json');
64+
65+
if (!fs.existsSync(packageJsonPath)) {
66+
logger.trace('[PackageJsonSource] No package.json found');
67+
return undefined;
68+
}
69+
70+
try {
71+
const content = fs.readFileSync(packageJsonPath, 'utf8');
72+
const packageJson = JSON.parse(content) as {b2c?: PackageJsonB2CConfig};
73+
74+
if (!packageJson.b2c) {
75+
logger.trace('[PackageJsonSource] No b2c key in package.json');
76+
return undefined;
77+
}
78+
79+
const b2cConfig = packageJson.b2c;
80+
const config: NormalizedConfig = {};
81+
82+
// Only copy allowed fields
83+
for (const field of ALLOWED_FIELDS) {
84+
const value = b2cConfig[field];
85+
if (value !== undefined) {
86+
(config as Record<string, unknown>)[field] = value;
87+
}
88+
}
89+
90+
// Warn about disallowed fields
91+
const disallowedFields = Object.keys(b2cConfig).filter(
92+
(key) => !ALLOWED_FIELDS.includes(key as keyof NormalizedConfig),
93+
);
94+
if (disallowedFields.length > 0) {
95+
logger.warn(
96+
{disallowedFields},
97+
'[PackageJsonSource] Ignoring sensitive/instance-specific fields in package.json b2c config',
98+
);
99+
}
100+
101+
const fields = getPopulatedFields(config);
102+
if (fields.length === 0) {
103+
logger.trace('[PackageJsonSource] b2c key present but no allowed fields populated');
104+
return undefined;
105+
}
106+
107+
logger.trace({location: packageJsonPath, fields}, '[PackageJsonSource] Loaded config');
108+
109+
return {config, location: packageJsonPath};
110+
} catch (error) {
111+
const message = error instanceof Error ? error.message : String(error);
112+
logger.trace({location: packageJsonPath, error: message}, '[PackageJsonSource] Failed to parse package.json');
113+
return undefined;
114+
}
115+
}
116+
}

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,19 @@ export interface ConfigSource {
183183
/** Human-readable name for diagnostics */
184184
name: string;
185185

186+
/**
187+
* Priority for source ordering. Lower numbers = higher priority.
188+
*
189+
* Recommended ranges:
190+
* - < 0: Before built-in sources (override defaults)
191+
* - 0: Built-in sources (DwJsonSource, MobifySource)
192+
* - 1-999: After built-in sources (fill gaps)
193+
* - 1000: Lowest priority (PackageJsonSource)
194+
*
195+
* @default 0
196+
*/
197+
priority?: number;
198+
186199
/**
187200
* Load configuration from this source.
188201
*

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

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@ import {
1717
* Mock config source for testing.
1818
*/
1919
class MockSource implements ConfigSource {
20+
public priority?: number;
21+
2022
constructor(
2123
public name: string,
2224
private config: NormalizedConfig | undefined,
2325
private location?: string,
24-
) {}
26+
priority?: number,
27+
) {
28+
this.priority = priority;
29+
}
2530

2631
load(_options: ResolveConfigOptions): ConfigLoadResult | undefined {
2732
if (this.config === undefined) {
@@ -353,4 +358,86 @@ describe('config/resolver', () => {
353358
expect(config.hostname).to.equal('test.demandware.net');
354359
});
355360
});
361+
362+
describe('priority-based sorting', () => {
363+
it('sorts sources by priority (lower number = higher priority)', () => {
364+
// Sources added in wrong order, but should be sorted by priority
365+
const lowPriority = new MockSource('low', {clientId: 'low-client'}, undefined, 100);
366+
const highPriority = new MockSource('high', {clientId: 'high-client'}, undefined, -10);
367+
const defaultPriority = new MockSource('default', {clientId: 'default-client'}, undefined, 0);
368+
369+
// Pass sources in "wrong" order - they should get sorted
370+
const resolver = new ConfigResolver([lowPriority, defaultPriority, highPriority]);
371+
const {config} = resolver.resolve();
372+
373+
// High priority source (-10) wins
374+
expect(config.clientId).to.equal('high-client');
375+
});
376+
377+
it('treats undefined priority as 0', () => {
378+
const withPriority = new MockSource('with', {clientId: 'with-priority'}, undefined, 10);
379+
const noPriority = new MockSource('no', {clientId: 'no-priority'}, undefined, undefined);
380+
381+
// No priority (=0) should win over priority 10
382+
const resolver = new ConfigResolver([withPriority, noPriority]);
383+
const {config} = resolver.resolve();
384+
385+
expect(config.clientId).to.equal('no-priority');
386+
});
387+
388+
it('maintains insertion order for same priority', () => {
389+
const first = new MockSource('first', {clientId: 'first-client'}, undefined, 0);
390+
const second = new MockSource('second', {clientId: 'second-client'}, undefined, 0);
391+
392+
const resolver = new ConfigResolver([first, second]);
393+
const {config} = resolver.resolve();
394+
395+
// First source should win since both have same priority
396+
expect(config.clientId).to.equal('first-client');
397+
});
398+
399+
it('negative priorities come before 0', () => {
400+
const before = new MockSource('before', {hostname: 'before.com'}, undefined, -1);
401+
const builtin = new MockSource('builtin', {hostname: 'builtin.com'}, undefined, 0);
402+
403+
const resolver = new ConfigResolver([builtin, before]);
404+
const {config} = resolver.resolve();
405+
406+
// -1 priority should win
407+
expect(config.hostname).to.equal('before.com');
408+
});
409+
410+
it('high priorities (1000) come last', () => {
411+
const packageJson = new MockSource('package', {shortCode: 'package-code'}, undefined, 1000);
412+
const dwJson = new MockSource('dwjson', {shortCode: 'dw-code'}, undefined, 0);
413+
414+
const resolver = new ConfigResolver([packageJson, dwJson]);
415+
const {config} = resolver.resolve();
416+
417+
// 0 priority should win over 1000
418+
expect(config.shortCode).to.equal('dw-code');
419+
});
420+
421+
it('plugin priorities work with before/after pattern', () => {
422+
// Simulating: plugin 'before' (-1), builtin (0), plugin 'after' (10)
423+
const pluginBefore = new MockSource('plugin-before', {clientId: 'before-client'}, undefined, -1);
424+
const builtin = new MockSource('builtin', {clientId: 'builtin-client', hostname: 'builtin.com'}, undefined, 0);
425+
const pluginAfter = new MockSource(
426+
'plugin-after',
427+
{clientId: 'after-client', mrtProject: 'after-project'},
428+
undefined,
429+
10,
430+
);
431+
432+
const resolver = new ConfigResolver([pluginAfter, builtin, pluginBefore]);
433+
const {config} = resolver.resolve();
434+
435+
// 'before' plugin wins for clientId
436+
expect(config.clientId).to.equal('before-client');
437+
// builtin provides hostname (not in before plugin)
438+
expect(config.hostname).to.equal('builtin.com');
439+
// 'after' plugin provides mrtProject (not in others)
440+
expect(config.mrtProject).to.equal('after-project');
441+
});
442+
});
356443
});

0 commit comments

Comments
 (0)