diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index bab36f64..884a18da 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -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 diff --git a/docs/guide/extending.md b/docs/guide/extending.md index 51db6780..f4b9db7c 100644 --- a/docs/guide/extending.md +++ b/docs/guide/extending.md @@ -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. ::: diff --git a/packages/b2c-tooling-sdk/src/cli/base-command.ts b/packages/b2c-tooling-sdk/src/cli/base-command.ts index e5462b2f..06986eba 100644 --- a/packages/b2c-tooling-sdk/src/cli/base-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/base-command.ts @@ -218,6 +218,11 @@ export abstract class BaseCommand 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 { // Access flags that may be defined in subclasses (OAuthCommand, InstanceCommand) @@ -241,10 +246,28 @@ export abstract class BaseCommand 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); } } diff --git a/packages/b2c-tooling-sdk/src/cli/hooks.ts b/packages/b2c-tooling-sdk/src/cli/hooks.ts index 174d42b7..514b752b 100644 --- a/packages/b2c-tooling-sdk/src/cli/hooks.ts +++ b/packages/b2c-tooling-sdk/src/cli/hooks.ts @@ -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; } /** diff --git a/packages/b2c-tooling-sdk/src/config/resolver.ts b/packages/b2c-tooling-sdk/src/config/resolver.ts index fbbc88c8..dc2f6e7a 100644 --- a/packages/b2c-tooling-sdk/src/config/resolver.ts +++ b/packages/b2c-tooling-sdk/src/config/resolver.ts @@ -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, @@ -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)); } /** @@ -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); diff --git a/packages/b2c-tooling-sdk/src/config/sources/dw-json-source.ts b/packages/b2c-tooling-sdk/src/config/sources/dw-json-source.ts index 7dca0844..f52e7c98 100644 --- a/packages/b2c-tooling-sdk/src/config/sources/dw-json-source.ts +++ b/packages/b2c-tooling-sdk/src/config/sources/dw-json-source.ts @@ -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(); diff --git a/packages/b2c-tooling-sdk/src/config/sources/index.ts b/packages/b2c-tooling-sdk/src/config/sources/index.ts index 6dbd8bb8..eadbf20e 100644 --- a/packages/b2c-tooling-sdk/src/config/sources/index.ts +++ b/packages/b2c-tooling-sdk/src/config/sources/index.ts @@ -10,3 +10,4 @@ */ export {DwJsonSource} from './dw-json-source.js'; export {MobifySource} from './mobify-source.js'; +export {PackageJsonSource} from './package-json-source.js'; diff --git a/packages/b2c-tooling-sdk/src/config/sources/mobify-source.ts b/packages/b2c-tooling-sdk/src/config/sources/mobify-source.ts index d148523d..221e11ad 100644 --- a/packages/b2c-tooling-sdk/src/config/sources/mobify-source.ts +++ b/packages/b2c-tooling-sdk/src/config/sources/mobify-source.ts @@ -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(); diff --git a/packages/b2c-tooling-sdk/src/config/sources/package-json-source.ts b/packages/b2c-tooling-sdk/src/config/sources/package-json-source.ts new file mode 100644 index 00000000..229ad62c --- /dev/null +++ b/packages/b2c-tooling-sdk/src/config/sources/package-json-source.ts @@ -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)[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; + } + } +} diff --git a/packages/b2c-tooling-sdk/src/config/types.ts b/packages/b2c-tooling-sdk/src/config/types.ts index b154723b..c16ac7fb 100644 --- a/packages/b2c-tooling-sdk/src/config/types.ts +++ b/packages/b2c-tooling-sdk/src/config/types.ts @@ -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. * diff --git a/packages/b2c-tooling-sdk/test/config/resolver.test.ts b/packages/b2c-tooling-sdk/test/config/resolver.test.ts index ea026d65..a74e7612 100644 --- a/packages/b2c-tooling-sdk/test/config/resolver.test.ts +++ b/packages/b2c-tooling-sdk/test/config/resolver.test.ts @@ -17,11 +17,16 @@ import { * Mock config source for testing. */ class MockSource implements ConfigSource { + public priority?: number; + constructor( public name: string, private config: NormalizedConfig | undefined, private location?: string, - ) {} + priority?: number, + ) { + this.priority = priority; + } load(_options: ResolveConfigOptions): ConfigLoadResult | undefined { if (this.config === undefined) { @@ -353,4 +358,86 @@ describe('config/resolver', () => { expect(config.hostname).to.equal('test.demandware.net'); }); }); + + describe('priority-based sorting', () => { + it('sorts sources by priority (lower number = higher priority)', () => { + // Sources added in wrong order, but should be sorted by priority + const lowPriority = new MockSource('low', {clientId: 'low-client'}, undefined, 100); + const highPriority = new MockSource('high', {clientId: 'high-client'}, undefined, -10); + const defaultPriority = new MockSource('default', {clientId: 'default-client'}, undefined, 0); + + // Pass sources in "wrong" order - they should get sorted + const resolver = new ConfigResolver([lowPriority, defaultPriority, highPriority]); + const {config} = resolver.resolve(); + + // High priority source (-10) wins + expect(config.clientId).to.equal('high-client'); + }); + + it('treats undefined priority as 0', () => { + const withPriority = new MockSource('with', {clientId: 'with-priority'}, undefined, 10); + const noPriority = new MockSource('no', {clientId: 'no-priority'}, undefined, undefined); + + // No priority (=0) should win over priority 10 + const resolver = new ConfigResolver([withPriority, noPriority]); + const {config} = resolver.resolve(); + + expect(config.clientId).to.equal('no-priority'); + }); + + it('maintains insertion order for same priority', () => { + const first = new MockSource('first', {clientId: 'first-client'}, undefined, 0); + const second = new MockSource('second', {clientId: 'second-client'}, undefined, 0); + + const resolver = new ConfigResolver([first, second]); + const {config} = resolver.resolve(); + + // First source should win since both have same priority + expect(config.clientId).to.equal('first-client'); + }); + + it('negative priorities come before 0', () => { + const before = new MockSource('before', {hostname: 'before.com'}, undefined, -1); + const builtin = new MockSource('builtin', {hostname: 'builtin.com'}, undefined, 0); + + const resolver = new ConfigResolver([builtin, before]); + const {config} = resolver.resolve(); + + // -1 priority should win + expect(config.hostname).to.equal('before.com'); + }); + + it('high priorities (1000) come last', () => { + const packageJson = new MockSource('package', {shortCode: 'package-code'}, undefined, 1000); + const dwJson = new MockSource('dwjson', {shortCode: 'dw-code'}, undefined, 0); + + const resolver = new ConfigResolver([packageJson, dwJson]); + const {config} = resolver.resolve(); + + // 0 priority should win over 1000 + expect(config.shortCode).to.equal('dw-code'); + }); + + it('plugin priorities work with before/after pattern', () => { + // Simulating: plugin 'before' (-1), builtin (0), plugin 'after' (10) + const pluginBefore = new MockSource('plugin-before', {clientId: 'before-client'}, undefined, -1); + const builtin = new MockSource('builtin', {clientId: 'builtin-client', hostname: 'builtin.com'}, undefined, 0); + const pluginAfter = new MockSource( + 'plugin-after', + {clientId: 'after-client', mrtProject: 'after-project'}, + undefined, + 10, + ); + + const resolver = new ConfigResolver([pluginAfter, builtin, pluginBefore]); + const {config} = resolver.resolve(); + + // 'before' plugin wins for clientId + expect(config.clientId).to.equal('before-client'); + // builtin provides hostname (not in before plugin) + expect(config.hostname).to.equal('builtin.com'); + // 'after' plugin provides mrtProject (not in others) + expect(config.mrtProject).to.equal('after-project'); + }); + }); }); diff --git a/packages/b2c-tooling-sdk/test/config/sources.test.ts b/packages/b2c-tooling-sdk/test/config/sources.test.ts index 94941919..2ce4bd2e 100644 --- a/packages/b2c-tooling-sdk/test/config/sources.test.ts +++ b/packages/b2c-tooling-sdk/test/config/sources.test.ts @@ -8,6 +8,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import {ConfigResolver} from '@salesforce/b2c-tooling-sdk/config'; +import {PackageJsonSource} from '../../src/config/sources/package-json-source.js'; describe('config/sources', () => { let tempDir: string; @@ -360,4 +361,177 @@ describe('config/sources', () => { } }); }); + + describe('PackageJsonSource', () => { + it('loads allowed fields from package.json b2c key', () => { + const packageJsonPath = path.join(tempDir, 'package.json'); + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ + name: 'test-project', + b2c: { + shortCode: 'abc123', + clientId: 'test-client-id', + mrtProject: 'my-project', + mrtOrigin: 'https://custom.cloud.com', + accountManagerHost: 'account.demandware.com', + }, + }), + ); + + const resolver = new ConfigResolver(); + const {config} = resolver.resolve(); + + expect(config.shortCode).to.equal('abc123'); + expect(config.clientId).to.equal('test-client-id'); + expect(config.mrtProject).to.equal('my-project'); + expect(config.mrtOrigin).to.equal('https://custom.cloud.com'); + expect(config.accountManagerHost).to.equal('account.demandware.com'); + }); + + it('ignores sensitive/instance-specific fields', () => { + const packageJsonPath = path.join(tempDir, 'package.json'); + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ + name: 'test-project', + b2c: { + shortCode: 'abc123', + // These should be ignored + hostname: 'should-be-ignored.demandware.net', + password: 'secret-password', + clientSecret: 'secret-client-secret', + username: 'secret-user', + mrtApiKey: 'secret-api-key', + }, + }), + ); + + // Use PackageJsonSource directly to test in isolation + const source = new PackageJsonSource(); + const result = source.load({startDir: tempDir}); + + expect(result).to.not.be.undefined; + expect(result!.config.shortCode).to.equal('abc123'); + // Sensitive/instance-specific fields should NOT be loaded by PackageJsonSource + expect(result!.config.hostname).to.be.undefined; + expect(result!.config.password).to.be.undefined; + expect(result!.config.clientSecret).to.be.undefined; + expect(result!.config.username).to.be.undefined; + expect(result!.config.mrtApiKey).to.be.undefined; + }); + + it('returns undefined when package.json does not exist', () => { + const resolver = new ConfigResolver(); + const {sources} = resolver.resolve(); + + const packageJsonSource = sources.find((s) => s.name === 'PackageJsonSource'); + expect(packageJsonSource).to.be.undefined; + }); + + it('returns undefined when b2c key is missing', () => { + const packageJsonPath = path.join(tempDir, 'package.json'); + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ + name: 'test-project', + }), + ); + + const resolver = new ConfigResolver(); + const {sources} = resolver.resolve(); + + const packageJsonSource = sources.find((s) => s.name === 'PackageJsonSource'); + expect(packageJsonSource).to.be.undefined; + }); + + it('returns undefined when b2c key has only disallowed fields', () => { + const packageJsonPath = path.join(tempDir, 'package.json'); + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ + name: 'test-project', + b2c: { + hostname: 'should-be-ignored.demandware.net', + password: 'secret', + }, + }), + ); + + const resolver = new ConfigResolver(); + const {sources} = resolver.resolve(); + + const packageJsonSource = sources.find((s) => s.name === 'PackageJsonSource'); + expect(packageJsonSource).to.be.undefined; + }); + + it('has lowest priority (1000) and does not override other sources', () => { + // Create dw.json with clientId + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + hostname: 'test.demandware.net', + 'client-id': 'dw-client-id', + shortCode: 'dw-short-code', + }), + ); + + // Create package.json with different clientId and shortCode + const packageJsonPath = path.join(tempDir, 'package.json'); + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ + name: 'test-project', + b2c: { + clientId: 'package-client-id', + shortCode: 'package-short-code', + mrtProject: 'package-project', // Only in package.json + }, + }), + ); + + const resolver = new ConfigResolver(); + const {config} = resolver.resolve(); + + // dw.json values should take precedence (priority 0 < 1000) + expect(config.clientId).to.equal('dw-client-id'); + expect(config.shortCode).to.equal('dw-short-code'); + // package.json should fill in gaps + expect(config.mrtProject).to.equal('package-project'); + }); + + it('provides location from load result', () => { + const packageJsonPath = path.join(tempDir, 'package.json'); + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ + name: 'test-project', + b2c: { + shortCode: 'abc123', + }, + }), + ); + + const resolver = new ConfigResolver(); + const {sources} = resolver.resolve(); + + const packageJsonSource = sources.find((s) => s.name === 'PackageJsonSource'); + // Normalize paths to handle macOS symlinks + const expectedPath = fs.realpathSync(packageJsonPath); + const actualLocation = packageJsonSource?.location ? fs.realpathSync(packageJsonSource.location) : undefined; + expect(actualLocation).to.equal(expectedPath); + }); + + it('handles invalid JSON gracefully', () => { + const packageJsonPath = path.join(tempDir, 'package.json'); + fs.writeFileSync(packageJsonPath, 'invalid json'); + + const resolver = new ConfigResolver(); + const {sources} = resolver.resolve(); + + const packageJsonSource = sources.find((s) => s.name === 'PackageJsonSource'); + expect(packageJsonSource).to.be.undefined; + }); + }); });