Skip to content

Commit 42c0d04

Browse files
authored
add package.json config source with numeric priority system (#60)
* package json config sourcce * docs: add package.json config source and priority system documentation - Add "Project Configuration (package.json)" section to configuration.md - Document allowed fields: shortCode, clientId, mrtProject, mrtOrigin, accountManagerHost - Explain security rationale for excluding sensitive fields - Update resolution priority list to include package.json as lowest priority - Document numeric priority system in extending.md - Add priority table showing ranges (< 0, 0, 1-999, 1000) - Add example showing how to set priority on custom ConfigSource
1 parent 8720621 commit 42c0d04

12 files changed

Lines changed: 519 additions & 24 deletions

File tree

docs/guide/configuration.md

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,18 +148,56 @@ For multi-instance configurations, each config object also supports:
148148
| `name` | Instance name for selection with `-i`/`--instance` |
149149
| `active` | Set to `true` to use this config by default |
150150

151+
## Project Configuration (package.json)
152+
153+
You can store project-level defaults in your `package.json` file under the `b2c` key. This is useful for settings that are shared across your entire project and safe to commit to version control.
154+
155+
```json
156+
{
157+
"name": "my-storefront",
158+
"version": "1.0.0",
159+
"b2c": {
160+
"shortCode": "abc123",
161+
"clientId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
162+
"mrtProject": "my-project",
163+
"accountManagerHost": "account.demandware.com"
164+
}
165+
}
166+
```
167+
168+
### Allowed Fields
169+
170+
Only non-sensitive, project-level fields can be configured in `package.json`:
171+
172+
| Field | Description |
173+
|-------|-------------|
174+
| `shortCode` | SCAPI short code |
175+
| `clientId` | OAuth client ID (for implicit login discovery) |
176+
| `mrtProject` | MRT project slug |
177+
| `mrtOrigin` | MRT API origin URL override |
178+
| `accountManagerHost` | Account Manager hostname for OAuth |
179+
180+
::: warning Security Note
181+
Sensitive fields like `hostname`, `password`, `clientSecret`, `username`, and `mrtApiKey` are intentionally **not** supported in `package.json`. These should be configured via `dw.json` (which should be in `.gitignore`), environment variables, or secure credential stores.
182+
:::
183+
184+
::: tip Lowest Priority
185+
`package.json` has the lowest priority of all configuration sources. Values from `dw.json`, environment variables, or CLI flags will always override `package.json` settings. This makes it ideal for project defaults that can be overridden per-environment.
186+
:::
187+
151188
### Resolution Priority
152189

153190
Configuration is resolved with the following precedence (highest to lowest):
154191

155192
1. **CLI flags and environment variables** - Explicit values always take priority
156-
2. **Plugin sources (high priority)** - Custom sources with `priority: 'before'`
157-
3. **dw.json** - Project configuration file
158-
4. **~/.mobify** - Home directory file (for MRT API key only)
159-
5. **Plugin sources (low priority)** - Custom sources with `priority: 'after'`
193+
2. **Plugin sources (high priority)** - Custom sources with `priority: 'before'` (or priority < 0)
194+
3. **dw.json** - Project configuration file (priority 0)
195+
4. **~/.mobify** - Home directory file for MRT API key (priority 0)
196+
5. **Plugin sources (low priority)** - Custom sources with `priority: 'after'` (or priority 1-999)
197+
6. **package.json** - Project-level defaults (priority 1000, lowest)
160198

161199
::: tip Extending Configuration
162-
Plugins can add custom configuration sources like secret managers or environment-specific files. See [Extending the CLI](./extending) for details.
200+
Plugins can add custom configuration sources like secret managers or environment-specific files. Plugins can use numeric priorities for fine-grained control over ordering. See [Extending the CLI](./extending) for details.
163201
:::
164202

165203
### Credential Grouping

docs/guide/extending.md

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,19 +58,48 @@ This hook is called during command initialization, after CLI flags are parsed bu
5858
| Property | Type | Description |
5959
|----------|------|-------------|
6060
| `sources` | `ConfigSource[]` | Config sources to add to resolution |
61-
| `priority` | `'before' \| 'after'` | Where to insert relative to defaults (default: `'after'`) |
61+
| `priority` | `'before' \| 'after' \| number` | Priority for sources (see below). Default: `'after'` |
62+
63+
::: tip Numeric Priorities
64+
String values map to numeric priorities: `'before'` → -1, `'after'` → 10. You can also use any numeric value directly for fine-grained control. Lower numbers = higher priority.
65+
:::
6266

6367
### Priority Ordering
6468

69+
Configuration sources use a numeric priority system where **lower numbers = higher priority**:
70+
71+
| Priority | Description | Example |
72+
|----------|-------------|---------|
73+
| < 0 | Override built-in sources | `'before'` maps to -1 |
74+
| 0 | Built-in sources | `dw.json`, `~/.mobify` |
75+
| 1-999 | After built-in sources | `'after'` maps to 10 |
76+
| 1000 | Lowest priority | `package.json` |
77+
6578
Configuration is resolved with the following precedence:
6679

6780
1. **CLI flags and environment variables** - Always highest priority
68-
2. **Plugin sources with `priority: 'before'`** - Override dw.json defaults
69-
3. **Default sources** - `dw.json` and `~/.mobify`
70-
4. **Plugin sources with `priority: 'after'`** - Fill gaps left by defaults
81+
2. **Plugin sources with `priority: 'before'` (or < 0)** - Override dw.json defaults
82+
3. **Default sources** - `dw.json` and `~/.mobify` (priority 0)
83+
4. **Plugin sources with `priority: 'after'` (or 1-999)** - Fill gaps left by defaults
84+
5. **package.json** - Project-level defaults (priority 1000)
7185

7286
Each source fills in missing values - it doesn't override values from higher-priority sources.
7387

88+
::: tip Custom ConfigSource Priority
89+
When implementing a custom `ConfigSource`, you can set the `priority` property directly on your class:
90+
91+
```typescript
92+
export class MyCustomSource implements ConfigSource {
93+
readonly name = 'my-custom-source';
94+
readonly priority = 5; // Between 'before' (-1) and 'after' (10)
95+
96+
load(options: ResolveConfigOptions): ConfigLoadResult | undefined {
97+
// ...
98+
}
99+
}
100+
```
101+
:::
102+
74103
::: warning Credential Grouping
75104
OAuth credentials (`clientId`/`clientSecret`) and Basic auth credentials (`username`/`password`) are treated as atomic groups. If any field in a group is already set by a higher-priority source, all fields in that group from your source will be ignored. Ensure your source provides complete credential pairs, or that higher-priority sources don't partially define the same credentials.
76105
:::

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
*

0 commit comments

Comments
 (0)