Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 43 additions & 5 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,18 +148,56 @@ For multi-instance configurations, each config object also supports:
| `name` | Instance name for selection with `-i`/`--instance` |
| `active` | Set to `true` to use this config by default |

## Project Configuration (package.json)

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.

```json
{
"name": "my-storefront",
"version": "1.0.0",
"b2c": {
"shortCode": "abc123",
"clientId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"mrtProject": "my-project",
"accountManagerHost": "account.demandware.com"
}
}
```

### Allowed Fields

Only non-sensitive, project-level fields can be configured in `package.json`:

| Field | Description |
|-------|-------------|
| `shortCode` | SCAPI short code |
| `clientId` | OAuth client ID (for implicit login discovery) |
| `mrtProject` | MRT project slug |
| `mrtOrigin` | MRT API origin URL override |
| `accountManagerHost` | Account Manager hostname for OAuth |

::: warning Security Note
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.
:::

::: tip Lowest Priority
`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.
:::

### Resolution Priority

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

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

::: tip Extending Configuration
Plugins can add custom configuration sources like secret managers or environment-specific files. See [Extending the CLI](./extending) for details.
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.
:::

### Credential Grouping
Expand Down
37 changes: 33 additions & 4 deletions docs/guide/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,48 @@ This hook is called during command initialization, after CLI flags are parsed bu
| Property | Type | Description |
|----------|------|-------------|
| `sources` | `ConfigSource[]` | Config sources to add to resolution |
| `priority` | `'before' \| 'after'` | Where to insert relative to defaults (default: `'after'`) |
| `priority` | `'before' \| 'after' \| number` | Priority for sources (see below). Default: `'after'` |

::: tip Numeric Priorities
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.
:::

### Priority Ordering

Configuration sources use a numeric priority system where **lower numbers = higher priority**:

| Priority | Description | Example |
|----------|-------------|---------|
| < 0 | Override built-in sources | `'before'` maps to -1 |
| 0 | Built-in sources | `dw.json`, `~/.mobify` |
| 1-999 | After built-in sources | `'after'` maps to 10 |
| 1000 | Lowest priority | `package.json` |

Configuration is resolved with the following precedence:

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

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

::: tip Custom ConfigSource Priority
When implementing a custom `ConfigSource`, you can set the `priority` property directly on your class:

```typescript
export class MyCustomSource implements ConfigSource {
readonly name = 'my-custom-source';
readonly priority = 5; // Between 'before' (-1) and 'after' (10)

load(options: ResolveConfigOptions): ConfigLoadResult | undefined {
// ...
}
}
```
:::

::: warning Credential Grouping
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.
:::
Expand Down
27 changes: 25 additions & 2 deletions packages/b2c-tooling-sdk/src/cli/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,11 @@ export abstract class BaseCommand<T extends typeof Command> extends Command {
* Plugin sources are collected into two arrays based on their priority:
* - `pluginSourcesBefore`: High priority sources (override defaults)
* - `pluginSourcesAfter`: Low priority sources (fill gaps)
*
* Priority mapping:
* - 'before' → -1 (higher priority than defaults)
* - 'after' → 10 (lower priority than defaults)
* - number → used directly
*/
protected async collectPluginConfigSources(): Promise<void> {
// Access flags that may be defined in subclasses (OAuthCommand, InstanceCommand)
Expand All @@ -241,10 +246,28 @@ export abstract class BaseCommand<T extends typeof Command> extends Command {
const result = success.result as ConfigSourcesHookResult | undefined;
if (!result?.sources?.length) continue;

if (result.priority === 'before') {
// Map priority: 'before' → -1, 'after' → 10, number → as-is, undefined → 10
const numericPriority =
result.priority === 'before'
? -1
: result.priority === 'after'
? 10
: typeof result.priority === 'number'
? result.priority
: 10; // default 'after'

// Apply priority to sources that don't already have one set
for (const source of result.sources) {
if (source.priority === undefined) {
(source as {priority?: number}).priority = numericPriority;
}
}

// Still use before/after arrays for backwards compatibility
// The resolver will sort all sources by priority anyway
if (numericPriority < 0) {
this.pluginSourcesBefore.push(...result.sources);
} else {
// Default priority is 'after'
this.pluginSourcesAfter.push(...result.sources);
}
}
Expand Down
18 changes: 13 additions & 5 deletions packages/b2c-tooling-sdk/src/cli/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,22 @@ export interface ConfigSourcesHookResult {
/** Config sources to add to the resolution chain */
sources: ConfigSource[];
/**
* Where to insert sources relative to default sources.
* Priority for the returned sources. Can be a string or number:
*
* - `'before'`: Higher priority than dw.json/~/.mobify (plugin overrides defaults)
* - `'after'`: Lower priority than defaults (plugin fills gaps)
* String values (legacy, still supported):
* - `'before'`: Maps to priority -1 (higher priority than defaults)
* - `'after'`: Maps to priority 10 (lower priority than defaults)
*
* @default 'after'
* Numeric values (preferred):
* - Any number. Lower numbers = higher priority.
* - Built-in sources use priority 0.
* - package.json uses priority 1000.
*
* If a source already has a `priority` property set, it will not be overridden.
*
* @default 'after' (maps to 10)
*/
priority?: 'before' | 'after';
priority?: 'before' | 'after' | number;
}

/**
Expand Down
18 changes: 11 additions & 7 deletions packages/b2c-tooling-sdk/src/config/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import type {AuthCredentials} from '../auth/types.js';
import type {B2CInstance} from '../instance/index.js';
import {mergeConfigsWithProtection, getPopulatedFields, createInstanceFromConfig} from './mapping.js';
import {DwJsonSource, MobifySource} from './sources/index.js';
import {DwJsonSource, MobifySource, PackageJsonSource} from './sources/index.js';
import type {
ConfigSource,
ConfigSourceInfo,
Expand Down Expand Up @@ -125,10 +125,13 @@ export class ConfigResolver {
/**
* Creates a new ConfigResolver.
*
* @param sources - Custom configuration sources. If not provided, uses default sources (dw.json, ~/.mobify).
* @param sources - Custom configuration sources. If not provided, uses default sources (dw.json, ~/.mobify, package.json).
* Sources are automatically sorted by priority (lower number = higher priority).
*/
constructor(sources?: ConfigSource[]) {
this.sources = sources ?? [new DwJsonSource(), new MobifySource()];
const configSources = sources ?? [new DwJsonSource(), new MobifySource(), new PackageJsonSource()];
// Sort sources by priority (lower number = higher priority, undefined = 0)
this.sources = [...configSources].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
}

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

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

// Combine: sourcesBefore > defaults > sourcesAfter
// Combine all sources
sources = [...(options.sourcesBefore ?? []), ...defaultSources, ...(options.sourcesAfter ?? [])];
}

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {getLogger} from '../../logging/logger.js';
*/
export class DwJsonSource implements ConfigSource {
readonly name = 'DwJsonSource';
readonly priority = 0;

load(options: ResolveConfigOptions): ConfigLoadResult | undefined {
const logger = getLogger();
Expand Down
1 change: 1 addition & 0 deletions packages/b2c-tooling-sdk/src/config/sources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
*/
export {DwJsonSource} from './dw-json-source.js';
export {MobifySource} from './mobify-source.js';
export {PackageJsonSource} from './package-json-source.js';
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ interface MobifyConfigFile {
*/
export class MobifySource implements ConfigSource {
readonly name = 'MobifySource';
readonly priority = 0;

load(options: ResolveConfigOptions): ConfigLoadResult | undefined {
const logger = getLogger();
Expand Down
116 changes: 116 additions & 0 deletions packages/b2c-tooling-sdk/src/config/sources/package-json-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* SPDX-License-Identifier: Apache-2
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
*/
/**
* package.json configuration source.
*
* Reads configuration from the `b2c` key in package.json.
* Only loads from cwd (project root), not from parent directories.
*
* @internal This module is internal to the SDK. Use ConfigResolver instead.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import type {ConfigSource, ConfigLoadResult, ResolveConfigOptions, NormalizedConfig} from '../types.js';
import {getPopulatedFields} from '../mapping.js';
import {getLogger} from '../../logging/logger.js';

/**
* Fields allowed to be configured in package.json.
* These are non-sensitive, non-instance-specific configuration.
*/
const ALLOWED_FIELDS: (keyof NormalizedConfig)[] = [
'shortCode',
'clientId',
'mrtProject',
'mrtOrigin',
'accountManagerHost',
];

/**
* Structure of the b2c config in package.json
*/
interface PackageJsonB2CConfig {
shortCode?: string;
clientId?: string;
mrtProject?: string;
mrtOrigin?: string;
accountManagerHost?: string;
[key: string]: unknown;
}

/**
* Configuration source that loads from package.json `b2c` key.
*
* This source has the lowest priority (1000) and only provides
* non-sensitive, project-level defaults.
*
* @internal
*/
export class PackageJsonSource implements ConfigSource {
readonly name = 'PackageJsonSource';
readonly priority = 1000;

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

// Only look in cwd (or startDir if provided)
const searchDir = options.startDir ?? process.cwd();
const packageJsonPath = path.join(searchDir, 'package.json');

logger.trace({location: packageJsonPath}, '[PackageJsonSource] Checking for package.json');

if (!fs.existsSync(packageJsonPath)) {
logger.trace('[PackageJsonSource] No package.json found');
return undefined;
}

try {
const content = fs.readFileSync(packageJsonPath, 'utf8');
const packageJson = JSON.parse(content) as {b2c?: PackageJsonB2CConfig};

if (!packageJson.b2c) {
logger.trace('[PackageJsonSource] No b2c key in package.json');
return undefined;
}

const b2cConfig = packageJson.b2c;
const config: NormalizedConfig = {};

// Only copy allowed fields
for (const field of ALLOWED_FIELDS) {
const value = b2cConfig[field];
if (value !== undefined) {
(config as Record<string, unknown>)[field] = value;
}
}

// Warn about disallowed fields
const disallowedFields = Object.keys(b2cConfig).filter(
(key) => !ALLOWED_FIELDS.includes(key as keyof NormalizedConfig),
);
if (disallowedFields.length > 0) {
logger.warn(
{disallowedFields},
'[PackageJsonSource] Ignoring sensitive/instance-specific fields in package.json b2c config',
);
}

const fields = getPopulatedFields(config);
if (fields.length === 0) {
logger.trace('[PackageJsonSource] b2c key present but no allowed fields populated');
return undefined;
}

logger.trace({location: packageJsonPath, fields}, '[PackageJsonSource] Loaded config');

return {config, location: packageJsonPath};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.trace({location: packageJsonPath, error: message}, '[PackageJsonSource] Failed to parse package.json');
return undefined;
}
}
}
13 changes: 13 additions & 0 deletions packages/b2c-tooling-sdk/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,19 @@ export interface ConfigSource {
/** Human-readable name for diagnostics */
name: string;

/**
* Priority for source ordering. Lower numbers = higher priority.
*
* Recommended ranges:
* - < 0: Before built-in sources (override defaults)
* - 0: Built-in sources (DwJsonSource, MobifySource)
* - 1-999: After built-in sources (fill gaps)
* - 1000: Lowest priority (PackageJsonSource)
*
* @default 0
*/
priority?: number;

/**
* Load configuration from this source.
*
Expand Down
Loading
Loading