From 64aa5575f004c2bc438701f93743595b377242d0 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 15 Jan 2026 17:39:05 -0500 Subject: [PATCH 1/9] better logging in config sources for debugging --- packages/b2c-tooling-sdk/src/cli/config.ts | 8 +++++ .../b2c-tooling-sdk/src/config/dw-json.ts | 33 ++++++++++++++++++- .../src/config/sources/dw-json-source.ts | 13 ++++++-- .../src/config/sources/mobify-source.ts | 15 +++++++-- packages/b2c-tooling-sdk/src/config/types.ts | 4 +-- .../test/config/sources.test.ts | 4 +-- 6 files changed, 68 insertions(+), 9 deletions(-) diff --git a/packages/b2c-tooling-sdk/src/cli/config.ts b/packages/b2c-tooling-sdk/src/cli/config.ts index 6b521c09..66cfbf7c 100644 --- a/packages/b2c-tooling-sdk/src/cli/config.ts +++ b/packages/b2c-tooling-sdk/src/cli/config.ts @@ -117,6 +117,14 @@ export function loadConfig( sourcesAfter: pluginSources.after, }); + // Log source summary + for (const source of resolved.sources) { + logger.trace( + {source: source.name, path: source.path, fields: source.fieldsContributed}, + `[${source.name}] Contributed fields`, + ); + } + // Log warnings for (const warning of resolved.warnings) { logger.trace({warning}, `[Config] ${warning.message}`); diff --git a/packages/b2c-tooling-sdk/src/config/dw-json.ts b/packages/b2c-tooling-sdk/src/config/dw-json.ts index ef70a1a7..95702a82 100644 --- a/packages/b2c-tooling-sdk/src/config/dw-json.ts +++ b/packages/b2c-tooling-sdk/src/config/dw-json.ts @@ -14,6 +14,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type {AuthMethod} from '../auth/types.js'; +import {getLogger} from '../logging/logger.js'; /** * Configuration structure matching dw.json file format. @@ -114,8 +115,14 @@ export function findDwJson(startDir: string = process.cwd()): string | undefined * 3. Root-level config */ function selectConfig(json: DwJsonMultiConfig, instanceName?: string): DwJsonConfig { + const logger = getLogger(); + // Single config or no configs array if (!Array.isArray(json.configs) || json.configs.length === 0) { + logger.trace( + {selection: 'root', instanceName: json.name}, + `[DwJsonSource] Selected config "${json.name ?? 'root'}" (single config)`, + ); return json; } @@ -123,14 +130,23 @@ function selectConfig(json: DwJsonMultiConfig, instanceName?: string): DwJsonCon if (instanceName) { // Check root first if (json.name === instanceName) { + logger.trace( + {selection: 'named', instanceName}, + `[DwJsonSource] Selected config "${instanceName}" by name (root)`, + ); return json; } // Then check configs array const found = json.configs.find((c) => c.name === instanceName); if (found) { + logger.trace({selection: 'named', instanceName}, `[DwJsonSource] Selected config "${instanceName}" by name`); return found; } // Instance not found, fall through to other selection methods + logger.trace( + {requestedInstance: instanceName}, + `[DwJsonSource] Named instance "${instanceName}" not found, falling back`, + ); } // Find active config @@ -138,11 +154,19 @@ function selectConfig(json: DwJsonMultiConfig, instanceName?: string): DwJsonCon // Root is inactive, look for active in configs const activeConfig = json.configs.find((c) => c.active === true); if (activeConfig) { + logger.trace( + {selection: 'active', instanceName: activeConfig.name}, + `[DwJsonSource] Selected config "${activeConfig.name}" by active flag`, + ); return activeConfig; } } // Default to root config + logger.trace( + {selection: 'root', instanceName: json.name}, + `[DwJsonSource] Selected config "${json.name ?? 'root'}" (default to root)`, + ); return json; } @@ -171,10 +195,15 @@ function selectConfig(json: DwJsonMultiConfig, instanceName?: string): DwJsonCon * const config = loadDwJson({ path: './config/dw.json' }); */ export function loadDwJson(options: LoadDwJsonOptions = {}): DwJsonConfig | undefined { + const logger = getLogger(); + // If explicit path provided, use it. Otherwise default to ./dw.json (no upward search) const dwJsonPath = options.path ?? path.join(options.startDir || process.cwd(), 'dw.json'); + logger.trace({path: dwJsonPath}, '[DwJsonSource] Checking for config file'); + if (!fs.existsSync(dwJsonPath)) { + logger.trace({path: dwJsonPath}, '[DwJsonSource] No config file found'); return undefined; } @@ -182,8 +211,10 @@ export function loadDwJson(options: LoadDwJsonOptions = {}): DwJsonConfig | unde const content = fs.readFileSync(dwJsonPath, 'utf8'); const json = JSON.parse(content) as DwJsonMultiConfig; return selectConfig(json, options.instance); - } catch { + } catch (error) { // Invalid JSON or read error + const message = error instanceof Error ? error.message : String(error); + logger.trace({path: dwJsonPath, error: message}, '[DwJsonSource] Failed to parse config file'); return undefined; } } 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 2fa07ea9..e385b558 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 @@ -10,8 +10,10 @@ */ import * as path from 'node:path'; import {loadDwJson} from '../dw-json.js'; +import {getPopulatedFields} from '../mapping.js'; import {mapDwJsonToNormalizedConfig} from '../mapping.js'; import type {ConfigSource, NormalizedConfig, ResolveConfigOptions} from '../types.js'; +import {getLogger} from '../../logging/logger.js'; /** * Configuration source that loads from dw.json files. @@ -19,10 +21,12 @@ import type {ConfigSource, NormalizedConfig, ResolveConfigOptions} from '../type * @internal */ export class DwJsonSource implements ConfigSource { - readonly name = 'dw.json'; + readonly name = 'DwJsonSource'; private lastPath?: string; load(options: ResolveConfigOptions): NormalizedConfig | undefined { + const logger = getLogger(); + const dwConfig = loadDwJson({ instance: options.instance, path: options.configPath, @@ -37,7 +41,12 @@ export class DwJsonSource implements ConfigSource { // Track the path for diagnostics - use explicit path or default location this.lastPath = options.configPath ?? path.join(options.startDir || process.cwd(), 'dw.json'); - return mapDwJsonToNormalizedConfig(dwConfig); + const normalized = mapDwJsonToNormalizedConfig(dwConfig); + const fields = getPopulatedFields(normalized); + + logger.trace({path: this.lastPath, fields}, '[DwJsonSource] Loaded config'); + + return normalized; } getPath(): string | undefined { 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 8ddb2553..daa2c577 100644 --- a/packages/b2c-tooling-sdk/src/config/sources/mobify-source.ts +++ b/packages/b2c-tooling-sdk/src/config/sources/mobify-source.ts @@ -12,6 +12,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import type {ConfigSource, NormalizedConfig, ResolveConfigOptions} from '../types.js'; +import {getLogger} from '../../logging/logger.js'; /** * Mobify config file structure (~/.mobify) @@ -37,15 +38,20 @@ interface MobifyConfigFile { * @internal */ export class MobifySource implements ConfigSource { - readonly name = 'mobify'; + readonly name = 'MobifySource'; private lastPath?: string; load(options: ResolveConfigOptions): NormalizedConfig | undefined { + const logger = getLogger(); + // Use explicit credentialsFile if provided, otherwise use default path const mobifyPath = options.credentialsFile ?? this.getMobifyPath(options.cloudOrigin); this.lastPath = mobifyPath; + logger.trace({path: mobifyPath}, '[MobifySource] Checking for credentials file'); + if (!fs.existsSync(mobifyPath)) { + logger.trace({path: mobifyPath}, '[MobifySource] No credentials file found'); return undefined; } @@ -54,14 +60,19 @@ export class MobifySource implements ConfigSource { const config = JSON.parse(content) as MobifyConfigFile; if (!config.api_key) { + logger.trace({path: mobifyPath}, '[MobifySource] Credentials file found but no api_key present'); return undefined; } + logger.trace({path: mobifyPath, fields: ['mrtApiKey']}, '[MobifySource] Loaded credentials'); + return { mrtApiKey: config.api_key, }; - } catch { + } catch (error) { // Invalid JSON or read error + const message = error instanceof Error ? error.message : String(error); + logger.trace({path: mobifyPath, error: message}, '[MobifySource] Failed to parse credentials file'); return undefined; } } diff --git a/packages/b2c-tooling-sdk/src/config/types.ts b/packages/b2c-tooling-sdk/src/config/types.ts index 82ea45a1..0006a88f 100644 --- a/packages/b2c-tooling-sdk/src/config/types.ts +++ b/packages/b2c-tooling-sdk/src/config/types.ts @@ -64,7 +64,7 @@ export interface NormalizedConfig { mrtOrigin?: string; // Metadata - /** Instance name (from multi-config dw.json) */ + /** Instance name (from multi-config supporting sources) */ instanceName?: string; } @@ -113,7 +113,7 @@ export interface ConfigResolutionResult { * Options for configuration resolution. */ export interface ResolveConfigOptions { - /** Named instance from dw.json "configs" array */ + /** Named instance for supporting ConfigSources */ instance?: string; /** Explicit path to config file (defaults to auto-discover) */ configPath?: string; diff --git a/packages/b2c-tooling-sdk/test/config/sources.test.ts b/packages/b2c-tooling-sdk/test/config/sources.test.ts index 501df536..ea328658 100644 --- a/packages/b2c-tooling-sdk/test/config/sources.test.ts +++ b/packages/b2c-tooling-sdk/test/config/sources.test.ts @@ -126,7 +126,7 @@ describe('config/sources', () => { resolver.resolve(); const {sources} = resolver.resolve(); - const dwJsonSource = sources.find((s) => s.name === 'dw.json'); + const dwJsonSource = sources.find((s) => s.name === 'DwJsonSource'); // Normalize paths to handle macOS symlinks (/var -> /private/var) const expectedPath = fs.realpathSync(dwJsonPath); const actualPath = dwJsonSource?.path ? fs.realpathSync(dwJsonSource.path) : undefined; @@ -346,7 +346,7 @@ describe('config/sources', () => { resolver.resolve(); const {sources} = resolver.resolve(); - const mobifySource = sources.find((s) => s.name === 'mobify'); + const mobifySource = sources.find((s) => s.name === 'MobifySource'); // Normalize paths to handle macOS symlinks const expectedPath = fs.realpathSync(mobifyPath); const actualPath = mobifySource?.path ? fs.realpathSync(mobifySource.path) : undefined; From cfe0581f6fa7e81b700b37b6824d05c4b19f21ae Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 15 Jan 2026 18:43:30 -0500 Subject: [PATCH 2/9] Refactor ConfigSource API for better encapsulation - Change loadDwJson() to return {config, path} for proper path tracking - Make internal utilities internal (remove mapDwJsonToNormalizedConfig, mergeConfigsWithProtection, getPopulatedFields from exports) - Remove deprecated 'sources' option from ResolveConfigOptions - CLI commands now use ResolvedB2CConfig directly with .values.* access - SDK command classes use factory methods (createB2CInstance(), createMrtAuth(), etc.) instead of manual construction - Fix replaceDefaultSources option to work without sourcesAfter BREAKING CHANGE: loadConfig() now returns ResolvedB2CConfig instead of NormalizedConfig. Access config values via .values.* property. --- .../b2c-cli/src/commands/code/activate.ts | 4 +- packages/b2c-cli/src/commands/code/delete.ts | 2 +- packages/b2c-cli/src/commands/code/deploy.ts | 4 +- packages/b2c-cli/src/commands/code/list.ts | 2 +- packages/b2c-cli/src/commands/code/watch.ts | 4 +- .../b2c-cli/src/commands/docs/download.ts | 2 +- packages/b2c-cli/src/commands/job/export.ts | 2 +- packages/b2c-cli/src/commands/job/import.ts | 2 +- packages/b2c-cli/src/commands/job/run.ts | 4 +- packages/b2c-cli/src/commands/job/search.ts | 2 +- .../b2c-cli/src/commands/mrt/env/create.ts | 6 +- .../b2c-cli/src/commands/mrt/env/delete.ts | 4 +- .../src/commands/mrt/env/var/delete.ts | 4 +- .../b2c-cli/src/commands/mrt/env/var/list.ts | 4 +- .../b2c-cli/src/commands/mrt/env/var/set.ts | 4 +- packages/b2c-cli/src/commands/mrt/push.ts | 4 +- packages/b2c-cli/src/commands/ods/create.ts | 4 +- .../src/commands/scapi/custom/status.ts | 2 +- packages/b2c-cli/src/commands/sites/list.ts | 2 +- .../b2c-cli/src/commands/slas/client/open.ts | 2 +- packages/b2c-cli/src/index.ts | 2 +- packages/b2c-cli/src/utils/slas/client.ts | 2 +- packages/b2c-cli/test/helpers/ods.ts | 8 +- .../b2c-tooling-sdk/src/cli/base-command.ts | 7 +- .../src/cli/cartridge-command.ts | 2 +- packages/b2c-tooling-sdk/src/cli/config.ts | 45 +-- packages/b2c-tooling-sdk/src/cli/index.ts | 2 +- .../src/cli/instance-command.ts | 28 +- .../b2c-tooling-sdk/src/cli/mrt-command.ts | 16 +- .../b2c-tooling-sdk/src/cli/oauth-command.ts | 27 +- .../b2c-tooling-sdk/src/config/dw-json.ts | 31 +- packages/b2c-tooling-sdk/src/config/index.ts | 13 +- .../b2c-tooling-sdk/src/config/resolver.ts | 18 +- .../src/config/sources/dw-json-source.ts | 11 +- packages/b2c-tooling-sdk/src/config/types.ts | 6 - .../b2c-tooling-sdk/test/cli/config.test.ts | 51 ++- .../test/config/dw-json.test.ts | 22 +- .../test/config/mapping.test.ts | 340 ------------------ .../test/config/resolved-config.test.ts | 24 +- .../test-cli/src/commands/test-instance.js | 4 +- 40 files changed, 173 insertions(+), 550 deletions(-) delete mode 100644 packages/b2c-tooling-sdk/test/config/mapping.test.ts diff --git a/packages/b2c-cli/src/commands/code/activate.ts b/packages/b2c-cli/src/commands/code/activate.ts index af4197cb..5f9fcee2 100644 --- a/packages/b2c-cli/src/commands/code/activate.ts +++ b/packages/b2c-cli/src/commands/code/activate.ts @@ -38,10 +38,10 @@ export default class CodeActivate extends InstanceCommand { this.requireOAuthCredentials(); const codeVersionArg = this.args.codeVersion; - const hostname = this.resolvedConfig.hostname!; + const hostname = this.resolvedConfig.values.hostname!; // Get code version from arg, flag, or config - const codeVersion = codeVersionArg ?? this.resolvedConfig.codeVersion; + const codeVersion = codeVersionArg ?? this.resolvedConfig.values.codeVersion; if (this.flags.reload) { // Reload mode - re-activate the code version diff --git a/packages/b2c-cli/src/commands/code/delete.ts b/packages/b2c-cli/src/commands/code/delete.ts index 4bb0888a..bc2870a4 100644 --- a/packages/b2c-cli/src/commands/code/delete.ts +++ b/packages/b2c-cli/src/commands/code/delete.ts @@ -55,7 +55,7 @@ export default class CodeDelete extends InstanceCommand { this.requireOAuthCredentials(); const codeVersion = this.args.codeVersion; - const hostname = this.resolvedConfig.hostname!; + const hostname = this.resolvedConfig.values.hostname!; // Confirm deletion unless --force is used if (!this.flags.force) { diff --git a/packages/b2c-cli/src/commands/code/deploy.ts b/packages/b2c-cli/src/commands/code/deploy.ts index 5a600a5e..d1180e86 100644 --- a/packages/b2c-cli/src/commands/code/deploy.ts +++ b/packages/b2c-cli/src/commands/code/deploy.ts @@ -51,8 +51,8 @@ export default class CodeDeploy extends CartridgeCommand { this.requireWebDavCredentials(); this.requireOAuthCredentials(); - const hostname = this.resolvedConfig.hostname!; - let version = this.resolvedConfig.codeVersion; + const hostname = this.resolvedConfig.values.hostname!; + let version = this.resolvedConfig.values.codeVersion; // If no code version specified, discover the active one if (!version) { diff --git a/packages/b2c-cli/src/commands/code/list.ts b/packages/b2c-cli/src/commands/code/list.ts index b0d9a48f..86012b01 100644 --- a/packages/b2c-cli/src/commands/code/list.ts +++ b/packages/b2c-cli/src/commands/code/list.ts @@ -47,7 +47,7 @@ export default class CodeList extends InstanceCommand { async run(): Promise { this.requireOAuthCredentials(); - const hostname = this.resolvedConfig.hostname!; + const hostname = this.resolvedConfig.values.hostname!; this.log(t('commands.code.list.fetching', 'Fetching code versions from {{hostname}}...', {hostname})); diff --git a/packages/b2c-cli/src/commands/code/watch.ts b/packages/b2c-cli/src/commands/code/watch.ts index 03f763d4..849fe0a7 100644 --- a/packages/b2c-cli/src/commands/code/watch.ts +++ b/packages/b2c-cli/src/commands/code/watch.ts @@ -31,8 +31,8 @@ export default class CodeWatch extends CartridgeCommand { this.requireWebDavCredentials(); this.requireOAuthCredentials(); - const hostname = this.resolvedConfig.hostname!; - const version = this.resolvedConfig.codeVersion; + const hostname = this.resolvedConfig.values.hostname!; + const version = this.resolvedConfig.values.codeVersion; this.log(t('commands.code.watch.starting', 'Starting watcher for {{path}}', {path: this.cartridgePath})); this.log(t('commands.code.watch.target', 'Target: {{hostname}}', {hostname})); diff --git a/packages/b2c-cli/src/commands/docs/download.ts b/packages/b2c-cli/src/commands/docs/download.ts index 42d2d6f5..20927d6a 100644 --- a/packages/b2c-cli/src/commands/docs/download.ts +++ b/packages/b2c-cli/src/commands/docs/download.ts @@ -46,7 +46,7 @@ export default class DocsDownload extends InstanceCommand { this.log( t('commands.docs.download.downloading', 'Downloading documentation from {{hostname}}...', { - hostname: this.resolvedConfig.hostname, + hostname: this.resolvedConfig.values.hostname, }), ); diff --git a/packages/b2c-cli/src/commands/job/export.ts b/packages/b2c-cli/src/commands/job/export.ts index 611c2ea9..21888451 100644 --- a/packages/b2c-cli/src/commands/job/export.ts +++ b/packages/b2c-cli/src/commands/job/export.ts @@ -117,7 +117,7 @@ export default class JobExport extends JobCommand { 'show-log': showLog, } = this.flags; - const hostname = this.resolvedConfig.hostname!; + const hostname = this.resolvedConfig.values.hostname!; // Build data units configuration const dataUnits = this.buildDataUnits({ diff --git a/packages/b2c-cli/src/commands/job/import.ts b/packages/b2c-cli/src/commands/job/import.ts index 3b735add..eaca9f4e 100644 --- a/packages/b2c-cli/src/commands/job/import.ts +++ b/packages/b2c-cli/src/commands/job/import.ts @@ -63,7 +63,7 @@ export default class JobImport extends JobCommand { const {target} = this.args; const {'keep-archive': keepArchive, remote, timeout, 'show-log': showLog} = this.flags; - const hostname = this.resolvedConfig.hostname!; + const hostname = this.resolvedConfig.values.hostname!; // Create lifecycle context const context = this.createContext('job:import', { diff --git a/packages/b2c-cli/src/commands/job/run.ts b/packages/b2c-cli/src/commands/job/run.ts index 6eafde8f..1284187f 100644 --- a/packages/b2c-cli/src/commands/job/run.ts +++ b/packages/b2c-cli/src/commands/job/run.ts @@ -82,7 +82,7 @@ export default class JobRun extends JobCommand { parameters: rawBody ? undefined : parameters, body: rawBody, wait, - hostname: this.resolvedConfig.hostname, + hostname: this.resolvedConfig.values.hostname, }); // Run beforeOperation hooks - check for skip @@ -100,7 +100,7 @@ export default class JobRun extends JobCommand { this.log( t('commands.job.run.executing', 'Executing job {{jobId}} on {{hostname}}...', { jobId, - hostname: this.resolvedConfig.hostname!, + hostname: this.resolvedConfig.values.hostname!, }), ); diff --git a/packages/b2c-cli/src/commands/job/search.ts b/packages/b2c-cli/src/commands/job/search.ts index e371774c..021bfe10 100644 --- a/packages/b2c-cli/src/commands/job/search.ts +++ b/packages/b2c-cli/src/commands/job/search.ts @@ -86,7 +86,7 @@ export default class JobSearch extends InstanceCommand { this.log( t('commands.job.search.searching', 'Searching job executions on {{hostname}}...', { - hostname: this.resolvedConfig.hostname!, + hostname: this.resolvedConfig.values.hostname!, }), ); diff --git a/packages/b2c-cli/src/commands/mrt/env/create.ts b/packages/b2c-cli/src/commands/mrt/env/create.ts index 011a6bce..012f23dd 100644 --- a/packages/b2c-cli/src/commands/mrt/env/create.ts +++ b/packages/b2c-cli/src/commands/mrt/env/create.ts @@ -197,7 +197,7 @@ export default class MrtEnvCreate extends MrtCommand { this.requireMrtCredentials(); const {slug} = this.args; - const {mrtProject: project} = this.resolvedConfig; + const {mrtProject: project} = this.resolvedConfig.values; if (!project) { this.error( @@ -242,7 +242,7 @@ export default class MrtEnvCreate extends MrtCommand { allowCookies: allowCookies || undefined, enableSourceMaps: enableSourceMaps || undefined, proxyConfigs, - origin: this.resolvedConfig.mrtOrigin, + origin: this.resolvedConfig.values.mrtOrigin, }, this.getMrtAuth(), ); @@ -256,7 +256,7 @@ export default class MrtEnvCreate extends MrtCommand { { projectSlug: project, slug, - origin: this.resolvedConfig.mrtOrigin, + origin: this.resolvedConfig.values.mrtOrigin, onPoll: (env) => { if (!this.jsonEnabled()) { const elapsed = Math.round((Date.now() - waitStartTime) / 1000); diff --git a/packages/b2c-cli/src/commands/mrt/env/delete.ts b/packages/b2c-cli/src/commands/mrt/env/delete.ts index ece18f8d..140276a3 100644 --- a/packages/b2c-cli/src/commands/mrt/env/delete.ts +++ b/packages/b2c-cli/src/commands/mrt/env/delete.ts @@ -59,7 +59,7 @@ export default class MrtEnvDelete extends MrtCommand { this.requireMrtCredentials(); const {slug} = this.args; - const {mrtProject: project} = this.resolvedConfig; + const {mrtProject: project} = this.resolvedConfig.values; if (!project) { this.error( @@ -99,7 +99,7 @@ export default class MrtEnvDelete extends MrtCommand { { projectSlug: project, slug, - origin: this.resolvedConfig.mrtOrigin, + origin: this.resolvedConfig.values.mrtOrigin, }, this.getMrtAuth(), ); diff --git a/packages/b2c-cli/src/commands/mrt/env/var/delete.ts b/packages/b2c-cli/src/commands/mrt/env/var/delete.ts index 62d61417..63592752 100644 --- a/packages/b2c-cli/src/commands/mrt/env/var/delete.ts +++ b/packages/b2c-cli/src/commands/mrt/env/var/delete.ts @@ -39,7 +39,7 @@ export default class MrtEnvVarDelete extends MrtCommand this.requireMrtCredentials(); const {key} = this.args; - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig; + const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; if (!project) { this.error( @@ -57,7 +57,7 @@ export default class MrtEnvVarDelete extends MrtCommand projectSlug: project, environment, key, - origin: this.resolvedConfig.mrtOrigin, + origin: this.resolvedConfig.values.mrtOrigin, }, this.getMrtAuth(), ); diff --git a/packages/b2c-cli/src/commands/mrt/env/var/list.ts b/packages/b2c-cli/src/commands/mrt/env/var/list.ts index 1b3d2bfc..ef907975 100644 --- a/packages/b2c-cli/src/commands/mrt/env/var/list.ts +++ b/packages/b2c-cli/src/commands/mrt/env/var/list.ts @@ -56,7 +56,7 @@ export default class MrtEnvVarList extends MrtCommand { async run(): Promise { this.requireMrtCredentials(); - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig; + const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; if (!project) { this.error( @@ -80,7 +80,7 @@ export default class MrtEnvVarList extends MrtCommand { { projectSlug: project, environment, - origin: this.resolvedConfig.mrtOrigin, + origin: this.resolvedConfig.values.mrtOrigin, }, this.getMrtAuth(), ); diff --git a/packages/b2c-cli/src/commands/mrt/env/var/set.ts b/packages/b2c-cli/src/commands/mrt/env/var/set.ts index 537d4e1e..6744ebd9 100644 --- a/packages/b2c-cli/src/commands/mrt/env/var/set.ts +++ b/packages/b2c-cli/src/commands/mrt/env/var/set.ts @@ -43,7 +43,7 @@ export default class MrtEnvVarSet extends MrtCommand { this.requireMrtCredentials(); const {argv} = await this.parse(MrtEnvVarSet); - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig; + const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; if (!project) { this.error( @@ -88,7 +88,7 @@ export default class MrtEnvVarSet extends MrtCommand { projectSlug: project, environment, variables, - origin: this.resolvedConfig.mrtOrigin, + origin: this.resolvedConfig.values.mrtOrigin, }, this.getMrtAuth(), ); diff --git a/packages/b2c-cli/src/commands/mrt/push.ts b/packages/b2c-cli/src/commands/mrt/push.ts index 5153cd5b..a0f22deb 100644 --- a/packages/b2c-cli/src/commands/mrt/push.ts +++ b/packages/b2c-cli/src/commands/mrt/push.ts @@ -79,7 +79,7 @@ export default class MrtPush extends MrtCommand { async run(): Promise { this.requireMrtCredentials(); - const {mrtProject: project, mrtEnvironment: target} = this.resolvedConfig; + const {mrtProject: project, mrtEnvironment: target} = this.resolvedConfig.values; const {message} = this.flags; if (!project) { @@ -116,7 +116,7 @@ export default class MrtPush extends MrtCommand { ssrOnly, ssrShared, ssrParameters, - origin: this.resolvedConfig.mrtOrigin, + origin: this.resolvedConfig.values.mrtOrigin, }, this.getMrtAuth(), ); diff --git a/packages/b2c-cli/src/commands/ods/create.ts b/packages/b2c-cli/src/commands/ods/create.ts index f7529196..d98cb570 100644 --- a/packages/b2c-cli/src/commands/ods/create.ts +++ b/packages/b2c-cli/src/commands/ods/create.ts @@ -122,7 +122,7 @@ export default class OdsCreate extends OdsCommand { t( 'commands.ods.create.settingPermissions', 'Setting OCAPI and WebDAV permissions for client ID: {{clientId}}', - {clientId: this.resolvedConfig.clientId!}, + {clientId: this.resolvedConfig.values.clientId!}, ), ); } @@ -177,7 +177,7 @@ export default class OdsCreate extends OdsCommand { return undefined; } - const clientId = this.resolvedConfig.clientId; + const clientId = this.resolvedConfig.values.clientId; if (!clientId) { return undefined; } diff --git a/packages/b2c-cli/src/commands/scapi/custom/status.ts b/packages/b2c-cli/src/commands/scapi/custom/status.ts index de5c066c..e8ed0df6 100644 --- a/packages/b2c-cli/src/commands/scapi/custom/status.ts +++ b/packages/b2c-cli/src/commands/scapi/custom/status.ts @@ -219,7 +219,7 @@ export default class ScapiCustomStatus extends ScapiCustomCommand { async run(): Promise { this.requireOAuthCredentials(); - const hostname = this.resolvedConfig.hostname!; + const hostname = this.resolvedConfig.values.hostname!; this.log(t('commands.sites.list.fetching', 'Fetching sites from {{hostname}}...', {hostname})); diff --git a/packages/b2c-cli/src/commands/slas/client/open.ts b/packages/b2c-cli/src/commands/slas/client/open.ts index a7a6f243..e0e47491 100644 --- a/packages/b2c-cli/src/commands/slas/client/open.ts +++ b/packages/b2c-cli/src/commands/slas/client/open.ts @@ -48,7 +48,7 @@ export default class SlasClientOpen extends BaseCommand { const {'tenant-id': tenantId, 'short-code': shortCodeFlag} = this.flags; const {clientId} = this.args; - const shortCode = shortCodeFlag ?? this.resolvedConfig.shortCode; + const shortCode = shortCodeFlag ?? this.resolvedConfig.values.shortCode; if (!shortCode) { this.error( diff --git a/packages/b2c-cli/src/index.ts b/packages/b2c-cli/src/index.ts index 3d71f7d4..8f47cb5e 100644 --- a/packages/b2c-cli/src/index.ts +++ b/packages/b2c-cli/src/index.ts @@ -16,4 +16,4 @@ export { loadConfig, findDwJson, } from '@salesforce/b2c-tooling-sdk/cli'; -export type {ResolvedConfig, LoadConfigOptions} from '@salesforce/b2c-tooling-sdk/cli'; +export type {LoadConfigOptions} from '@salesforce/b2c-tooling-sdk/cli'; diff --git a/packages/b2c-cli/src/utils/slas/client.ts b/packages/b2c-cli/src/utils/slas/client.ts index 70e5ed19..0a483888 100644 --- a/packages/b2c-cli/src/utils/slas/client.ts +++ b/packages/b2c-cli/src/utils/slas/client.ts @@ -180,7 +180,7 @@ export abstract class SlasClientCommand extends OAuthC * Get the SLAS client, ensuring short code is configured. */ protected getSlasClient(): SlasClient { - const {shortCode} = this.resolvedConfig; + const {shortCode} = this.resolvedConfig.values; if (!shortCode) { this.error( t( diff --git a/packages/b2c-cli/test/helpers/ods.ts b/packages/b2c-cli/test/helpers/ods.ts index 802dba6f..ba84bdc3 100644 --- a/packages/b2c-cli/test/helpers/ods.ts +++ b/packages/b2c-cli/test/helpers/ods.ts @@ -42,9 +42,13 @@ export function stubOdsClient(command: any, client: Partial<{GET: any; POST: any }); } -export function stubResolvedConfig(command: any, resolvedConfig: Record): void { +export function stubResolvedConfig(command: any, values: Record): void { Object.defineProperty(command, 'resolvedConfig', { - get: () => resolvedConfig, + get: () => ({ + values, + warnings: [], + sources: [], + }), configurable: true, }); } diff --git a/packages/b2c-tooling-sdk/src/cli/base-command.ts b/packages/b2c-tooling-sdk/src/cli/base-command.ts index 7af85468..6bb6ba99 100644 --- a/packages/b2c-tooling-sdk/src/cli/base-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/base-command.ts @@ -5,7 +5,8 @@ */ import {Command, Flags, type Interfaces} from '@oclif/core'; import {loadConfig} from './config.js'; -import type {ResolvedConfig, LoadConfigOptions, PluginSources} from './config.js'; +import type {LoadConfigOptions, PluginSources} from './config.js'; +import type {ResolvedB2CConfig} from '../config/index.js'; import type { ConfigSourcesHookOptions, ConfigSourcesHookResult, @@ -95,7 +96,7 @@ export abstract class BaseCommand extends Command { protected flags!: Flags; protected args!: Args; - protected resolvedConfig!: ResolvedConfig; + protected resolvedConfig!: ResolvedB2CConfig; protected logger!: Logger; /** High-priority config sources from plugins (inserted before defaults) */ @@ -193,7 +194,7 @@ export abstract class BaseCommand extends Command { return input; } - protected loadConfiguration(): ResolvedConfig { + protected loadConfiguration(): ResolvedB2CConfig { const options: LoadConfigOptions = { instance: this.flags.instance, configPath: this.flags.config, diff --git a/packages/b2c-tooling-sdk/src/cli/cartridge-command.ts b/packages/b2c-tooling-sdk/src/cli/cartridge-command.ts index ec903fba..a6907722 100644 --- a/packages/b2c-tooling-sdk/src/cli/cartridge-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/cartridge-command.ts @@ -157,7 +157,7 @@ export abstract class CartridgeCommand extends Instanc directory: absoluteDir, include: filterOptions.include, exclude: filterOptions.exclude, - codeVersion: this.resolvedConfig?.codeVersion, + codeVersion: this.resolvedConfig?.values.codeVersion, instance: this.instance, }; diff --git a/packages/b2c-tooling-sdk/src/cli/config.ts b/packages/b2c-tooling-sdk/src/cli/config.ts index 66cfbf7c..59e5c34a 100644 --- a/packages/b2c-tooling-sdk/src/cli/config.ts +++ b/packages/b2c-tooling-sdk/src/cli/config.ts @@ -9,14 +9,11 @@ * This module provides configuration loading for CLI commands. * It uses {@link resolveConfig} internally for consistent behavior. * - * For most use cases, prefer using {@link resolveConfig} directly from the - * `config` module, which provides a richer API with factory methods. - * * @module cli/config */ import type {AuthMethod} from '../auth/types.js'; import {ALL_AUTH_METHODS} from '../auth/types.js'; -import {resolveConfig, type NormalizedConfig, type ConfigSource} from '../config/index.js'; +import {resolveConfig, type NormalizedConfig, type ConfigSource, type ResolvedB2CConfig} from '../config/index.js'; import {findDwJson} from '../config/dw-json.js'; import {getLogger} from '../logging/logger.js'; @@ -25,15 +22,6 @@ export type {AuthMethod}; export {ALL_AUTH_METHODS}; export {findDwJson}; -/** - * Resolved configuration for CLI commands. - * - * This type is an alias for NormalizedConfig to maintain backward compatibility - * with existing CLI code. For new code, prefer using {@link resolveConfig} - * which returns a {@link ResolvedB2CConfig} with factory methods. - */ -export type ResolvedConfig = NormalizedConfig; - /** * Options for loading configuration. */ @@ -78,7 +66,7 @@ export interface PluginSources { * @param flags - Configuration values from CLI flags/env vars * @param options - Loading options * @param pluginSources - Optional sources from CLI plugins (via b2c:config-sources hook) - * @returns Resolved configuration values + * @returns Resolved configuration with factory methods * * @example * ```typescript @@ -87,27 +75,26 @@ export interface PluginSources { * { hostname: this.flags.server, clientId: this.flags['client-id'] }, * { instance: this.flags.instance } * ); - * ``` * - * @example - * ```typescript - * // For richer API with factory methods, use resolveConfig directly: - * import { resolveConfig } from '@salesforce/b2c-tooling-sdk/config'; - * - * const config = resolveConfig(flags, options); * if (config.hasB2CInstanceConfig()) { * const instance = config.createB2CInstance(); * } * ``` */ export function loadConfig( - flags: Partial = {}, + flags: Partial = {}, options: LoadConfigOptions = {}, pluginSources: PluginSources = {}, -): ResolvedConfig { +): ResolvedB2CConfig { const logger = getLogger(); - const resolved = resolveConfig(flags, { + // Preserve instanceName from options.instance if not already in flags + const effectiveFlags = { + ...flags, + instanceName: flags.instanceName ?? options.instance, + }; + + const resolved = resolveConfig(effectiveFlags, { instance: options.instance, configPath: options.configPath, hostnameProtection: true, @@ -130,13 +117,5 @@ export function loadConfig( logger.trace({warning}, `[Config] ${warning.message}`); } - const config = resolved.values; - - // Handle instanceName from options if not in resolved config - // This preserves backward compatibility with the old behavior - if (!config.instanceName && options.instance) { - config.instanceName = options.instance; - } - - return config as ResolvedConfig; + return resolved; } diff --git a/packages/b2c-tooling-sdk/src/cli/index.ts b/packages/b2c-tooling-sdk/src/cli/index.ts index 234ce269..81f91952 100644 --- a/packages/b2c-tooling-sdk/src/cli/index.ts +++ b/packages/b2c-tooling-sdk/src/cli/index.ts @@ -104,7 +104,7 @@ export type {WebDavRootKey} from './webdav-command.js'; // Config utilities export {loadConfig, findDwJson} from './config.js'; -export type {ResolvedConfig, LoadConfigOptions, PluginSources} from './config.js'; +export type {LoadConfigOptions, PluginSources} from './config.js'; // Hook types for plugin extensibility export type { diff --git a/packages/b2c-tooling-sdk/src/cli/instance-command.ts b/packages/b2c-tooling-sdk/src/cli/instance-command.ts index 255332d1..c50b716b 100644 --- a/packages/b2c-tooling-sdk/src/cli/instance-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/instance-command.ts @@ -6,8 +6,8 @@ import {Command, Flags} from '@oclif/core'; import {OAuthCommand} from './oauth-command.js'; import {loadConfig} from './config.js'; -import type {ResolvedConfig, LoadConfigOptions, PluginSources} from './config.js'; -import {createInstanceFromConfig} from '../config/index.js'; +import type {LoadConfigOptions, PluginSources} from './config.js'; +import type {NormalizedConfig, ResolvedB2CConfig} from '../config/index.js'; import type {B2CInstance} from '../instance/index.js'; import {t} from '../i18n/index.js'; import { @@ -159,13 +159,13 @@ export abstract class InstanceCommand extends OAuthCom await this.lifecycleRunner.runAfter(context, result); } - protected override loadConfiguration(): ResolvedConfig { + protected override loadConfiguration(): ResolvedB2CConfig { const options: LoadConfigOptions = { instance: this.flags.instance, configPath: this.flags.config, }; - const flagConfig: Partial = { + const flagConfig: Partial = { hostname: this.flags.server, webdavHostname: this.flags['webdav-server'], codeVersion: this.flags['code-version'], @@ -175,6 +175,8 @@ export abstract class InstanceCommand extends OAuthCom clientSecret: this.flags['client-secret'], authMethods: this.parseAuthMethods(), accountManagerHost: this.flags['account-manager-host'], + // Merge scopes from flags (if provided) + scopes: this.flags.scope && this.flags.scope.length > 0 ? this.flags.scope : undefined, }; const pluginSources: PluginSources = { @@ -182,14 +184,7 @@ export abstract class InstanceCommand extends OAuthCom after: this.pluginSourcesAfter, }; - const config = loadConfig(flagConfig, options, pluginSources); - - // Merge scopes from flags with config file scopes (flags take precedence if provided) - if (this.flags.scope && this.flags.scope.length > 0) { - config.scopes = this.flags.scope; - } - - return config; + return loadConfig(flagConfig, options, pluginSources); } /** @@ -208,7 +203,7 @@ export abstract class InstanceCommand extends OAuthCom protected get instance(): B2CInstance { if (!this._instance) { this.requireServer(); - this._instance = createInstanceFromConfig(this.resolvedConfig); + this._instance = this.resolvedConfig.createB2CInstance(); } return this._instance; } @@ -217,16 +212,15 @@ export abstract class InstanceCommand extends OAuthCom * Check if WebDAV credentials are available (Basic or OAuth including implicit). */ protected hasWebDavCredentials(): boolean { - const config = this.resolvedConfig; // Basic auth, or OAuth (client-credentials needs secret, implicit only needs clientId) - return Boolean((config.username && config.password) || config.clientId); + return this.resolvedConfig.hasBasicAuthConfig() || this.resolvedConfig.hasOAuthConfig(); } /** * Validates that server is configured, errors if not. */ protected requireServer(): void { - if (!this.resolvedConfig.hostname) { + if (!this.resolvedConfig.hasB2CInstanceConfig()) { this.error(t('error.serverRequired', 'Server is required. Set via --server, SFCC_SERVER env var, or dw.json.')); } } @@ -235,7 +229,7 @@ export abstract class InstanceCommand extends OAuthCom * Validates that code version is configured, errors if not. */ protected requireCodeVersion(): void { - if (!this.resolvedConfig.codeVersion) { + if (!this.resolvedConfig.values.codeVersion) { this.error( t( 'error.codeVersionRequired', diff --git a/packages/b2c-tooling-sdk/src/cli/mrt-command.ts b/packages/b2c-tooling-sdk/src/cli/mrt-command.ts index 79441f92..0c2e6712 100644 --- a/packages/b2c-tooling-sdk/src/cli/mrt-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/mrt-command.ts @@ -6,9 +6,9 @@ import {Command, Flags} from '@oclif/core'; import {BaseCommand} from './base-command.js'; import {loadConfig} from './config.js'; -import type {ResolvedConfig, LoadConfigOptions, PluginSources} from './config.js'; +import type {LoadConfigOptions, PluginSources} from './config.js'; +import type {NormalizedConfig, ResolvedB2CConfig} from '../config/index.js'; import type {AuthStrategy} from '../auth/types.js'; -import {ApiKeyStrategy} from '../auth/api-key.js'; import {MrtClient} from '../platform/mrt.js'; import type {MrtProject} from '../platform/mrt.js'; import {t} from '../i18n/index.js'; @@ -61,7 +61,7 @@ export abstract class MrtCommand extends BaseCommand extends BaseCommand = { + const flagConfig: Partial = { // Flag/env takes precedence, ConfigResolver handles ~/.mobify fallback mrtApiKey: this.flags['api-key'], // Project/environment from flags @@ -94,10 +94,8 @@ export abstract class MrtCommand extends BaseCommand extends BaseCommand extends BaseCommand return methods.length > 0 ? methods : undefined; } - protected override loadConfiguration(): ResolvedConfig { + protected override loadConfiguration(): ResolvedB2CConfig { const options: LoadConfigOptions = { instance: this.flags.instance, configPath: this.flags.config, }; - const flagConfig: Partial = { + const flagConfig: Partial = { clientId: this.flags['client-id'], clientSecret: this.flags['client-secret'], shortCode: this.flags['short-code'], authMethods: this.parseAuthMethods(), accountManagerHost: this.flags['account-manager-host'], + // Merge scopes from flags (if provided) + scopes: this.flags.scope && this.flags.scope.length > 0 ? this.flags.scope : undefined, }; const pluginSources: PluginSources = { @@ -102,21 +105,14 @@ export abstract class OAuthCommand extends BaseCommand after: this.pluginSourcesAfter, }; - const config = loadConfig(flagConfig, options, pluginSources); - - // Merge scopes from flags with config file scopes (flags take precedence if provided) - if (this.flags.scope && this.flags.scope.length > 0) { - config.scopes = this.flags.scope; - } - - return config; + return loadConfig(flagConfig, options, pluginSources); } /** * Gets the configured Account Manager host. */ protected get accountManagerHost(): string { - return this.resolvedConfig.accountManagerHost ?? DEFAULT_ACCOUNT_MANAGER_HOST; + return this.resolvedConfig.values.accountManagerHost ?? DEFAULT_ACCOUNT_MANAGER_HOST; } /** @@ -128,7 +124,7 @@ export abstract class OAuthCommand extends BaseCommand * @throws Error if no allowed method has the required credentials configured */ protected getOAuthStrategy(): OAuthStrategy | ImplicitOAuthStrategy { - const config = this.resolvedConfig; + const config = this.resolvedConfig.values; const accountManagerHost = this.accountManagerHost; // Default to client-credentials and implicit if no methods specified const allowedMethods = config.authMethods || (['client-credentials', 'implicit'] as AuthMethod[]); @@ -177,8 +173,7 @@ export abstract class OAuthCommand extends BaseCommand * Returns true if clientId is configured (with or without clientSecret). */ protected hasOAuthCredentials(): boolean { - const config = this.resolvedConfig; - return Boolean(config.clientId); + return this.resolvedConfig.hasOAuthConfig(); } /** @@ -186,7 +181,7 @@ export abstract class OAuthCommand extends BaseCommand * Returns true only if both clientId and clientSecret are configured. */ protected hasFullOAuthCredentials(): boolean { - const config = this.resolvedConfig; + const config = this.resolvedConfig.values; return Boolean(config.clientId && config.clientSecret); } diff --git a/packages/b2c-tooling-sdk/src/config/dw-json.ts b/packages/b2c-tooling-sdk/src/config/dw-json.ts index 95702a82..93baab3d 100644 --- a/packages/b2c-tooling-sdk/src/config/dw-json.ts +++ b/packages/b2c-tooling-sdk/src/config/dw-json.ts @@ -79,6 +79,16 @@ export interface LoadDwJsonOptions { startDir?: string; } +/** + * Result of loading dw.json configuration. + */ +export interface LoadDwJsonResult { + /** The parsed configuration */ + config: DwJsonConfig; + /** The path to the dw.json file that was loaded */ + path: string; +} + /** * Finds dw.json by searching upward from the starting directory. * @@ -179,22 +189,26 @@ function selectConfig(json: DwJsonMultiConfig, instanceName?: string): DwJsonCon * Use `findDwJson()` if you need to search upward through parent directories. * * @param options - Loading options - * @returns The parsed config, or undefined if no dw.json found + * @returns The parsed config with its path, or undefined if no dw.json found * * @example * // Load from ./dw.json (current directory) - * const config = loadDwJson(); + * const result = loadDwJson(); + * if (result) { + * console.log(`Loaded from ${result.path}`); + * console.log(result.config.hostname); + * } * * // Load from specific directory - * const config = loadDwJson({ startDir: '/path/to/project' }); + * const result = loadDwJson({ startDir: '/path/to/project' }); * * // Use named instance - * const config = loadDwJson({ instance: 'staging' }); + * const result = loadDwJson({ instance: 'staging' }); * * // Explicit path - * const config = loadDwJson({ path: './config/dw.json' }); + * const result = loadDwJson({ path: './config/dw.json' }); */ -export function loadDwJson(options: LoadDwJsonOptions = {}): DwJsonConfig | undefined { +export function loadDwJson(options: LoadDwJsonOptions = {}): LoadDwJsonResult | undefined { const logger = getLogger(); // If explicit path provided, use it. Otherwise default to ./dw.json (no upward search) @@ -210,7 +224,10 @@ export function loadDwJson(options: LoadDwJsonOptions = {}): DwJsonConfig | unde try { const content = fs.readFileSync(dwJsonPath, 'utf8'); const json = JSON.parse(content) as DwJsonMultiConfig; - return selectConfig(json, options.instance); + return { + config: selectConfig(json, options.instance), + path: dwJsonPath, + }; } catch (error) { // Invalid JSON or read error const message = error instanceof Error ? error.message : String(error); diff --git a/packages/b2c-tooling-sdk/src/config/index.ts b/packages/b2c-tooling-sdk/src/config/index.ts index 4c3549db..5142d76a 100644 --- a/packages/b2c-tooling-sdk/src/config/index.ts +++ b/packages/b2c-tooling-sdk/src/config/index.ts @@ -107,16 +107,9 @@ export type { CreateMrtClientOptions, } from './types.js'; -// Mapping utilities -export { - mapDwJsonToNormalizedConfig, - mergeConfigsWithProtection, - getPopulatedFields, - buildAuthConfigFromNormalized, - createInstanceFromConfig, -} from './mapping.js'; -export type {MergeConfigOptions, MergeConfigResult} from './mapping.js'; +// Instance creation utility (public API for CLI commands) +export {createInstanceFromConfig} from './mapping.js'; // Low-level dw.json API (still available for advanced use) export {loadDwJson, findDwJson} from './dw-json.js'; -export type {DwJsonConfig, DwJsonMultiConfig, LoadDwJsonOptions} from './dw-json.js'; +export type {DwJsonConfig, DwJsonMultiConfig, LoadDwJsonOptions, LoadDwJsonResult} from './dw-json.js'; diff --git a/packages/b2c-tooling-sdk/src/config/resolver.ts b/packages/b2c-tooling-sdk/src/config/resolver.ts index fd8fed03..93d196ed 100644 --- a/packages/b2c-tooling-sdk/src/config/resolver.ts +++ b/packages/b2c-tooling-sdk/src/config/resolver.ts @@ -332,24 +332,18 @@ export function resolveConfig( // Build sources list with priority ordering: // 1. sourcesBefore (high priority - override defaults) // 2. default sources (dw.json, ~/.mobify) - // 3. sourcesAfter / sources (low priority - fill gaps) + // 3. sourcesAfter (low priority - fill gaps) let sources: ConfigSource[]; - if (options.replaceDefaultSources && (options.sources || options.sourcesAfter)) { - // Replace mode: only use provided sources - sources = [...(options.sourcesBefore ?? []), ...(options.sourcesAfter ?? options.sources ?? [])]; + if (options.replaceDefaultSources) { + // Replace mode: only use provided sources (no default dw.json/~/.mobify) + sources = [...(options.sourcesBefore ?? []), ...(options.sourcesAfter ?? [])]; } else { // Normal mode: before + defaults + after const defaultSources: ConfigSource[] = [new DwJsonSource(), new MobifySource()]; - // Combine: sourcesBefore > defaults > sourcesAfter/sources - sources = [ - ...(options.sourcesBefore ?? []), - ...defaultSources, - ...(options.sourcesAfter ?? []), - // Backward compat: 'sources' is treated as 'after' priority - ...(options.sources ?? []), - ]; + // Combine: sourcesBefore > defaults > sourcesAfter + sources = [...(options.sourcesBefore ?? []), ...defaultSources, ...(options.sourcesAfter ?? [])]; } const resolver = new ConfigResolver(sources); 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 e385b558..fd29db83 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 @@ -8,7 +8,6 @@ * * @internal This module is internal to the SDK. Use ConfigResolver instead. */ -import * as path from 'node:path'; import {loadDwJson} from '../dw-json.js'; import {getPopulatedFields} from '../mapping.js'; import {mapDwJsonToNormalizedConfig} from '../mapping.js'; @@ -27,21 +26,21 @@ export class DwJsonSource implements ConfigSource { load(options: ResolveConfigOptions): NormalizedConfig | undefined { const logger = getLogger(); - const dwConfig = loadDwJson({ + const result = loadDwJson({ instance: options.instance, path: options.configPath, startDir: options.startDir, }); - if (!dwConfig) { + if (!result) { this.lastPath = undefined; return undefined; } - // Track the path for diagnostics - use explicit path or default location - this.lastPath = options.configPath ?? path.join(options.startDir || process.cwd(), 'dw.json'); + // Track the actual path from the loaded result + this.lastPath = result.path; - const normalized = mapDwJsonToNormalizedConfig(dwConfig); + const normalized = mapDwJsonToNormalizedConfig(result.config); const fields = getPopulatedFields(normalized); logger.trace({path: this.lastPath, fields}, '[DwJsonSource] Loaded config'); diff --git a/packages/b2c-tooling-sdk/src/config/types.ts b/packages/b2c-tooling-sdk/src/config/types.ts index 0006a88f..bfa3be71 100644 --- a/packages/b2c-tooling-sdk/src/config/types.ts +++ b/packages/b2c-tooling-sdk/src/config/types.ts @@ -138,12 +138,6 @@ export interface ResolveConfigOptions { */ sourcesAfter?: ConfigSource[]; - /** - * Custom configuration sources (added after default sources). - * @deprecated Use `sourcesAfter` for clarity. This is kept for backward compatibility. - */ - sources?: ConfigSource[]; - /** Replace default sources entirely (instead of appending) */ replaceDefaultSources?: boolean; } diff --git a/packages/b2c-tooling-sdk/test/cli/config.test.ts b/packages/b2c-tooling-sdk/test/cli/config.test.ts index edcb923e..b36bfdfe 100644 --- a/packages/b2c-tooling-sdk/test/cli/config.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/config.test.ts @@ -4,13 +4,8 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {expect} from 'chai'; -import { - loadConfig, - type ResolvedConfig, - type LoadConfigOptions, - type PluginSources, -} from '@salesforce/b2c-tooling-sdk/cli'; -import type {ConfigSource} from '@salesforce/b2c-tooling-sdk/config'; +import {loadConfig, type LoadConfigOptions, type PluginSources} from '@salesforce/b2c-tooling-sdk/cli'; +import type {ConfigSource, NormalizedConfig} from '@salesforce/b2c-tooling-sdk/config'; /** * Mock config source for testing. @@ -18,12 +13,12 @@ import type {ConfigSource} from '@salesforce/b2c-tooling-sdk/config'; class MockConfigSource implements ConfigSource { constructor( public name: string, - private config: Partial | undefined, + private config: Partial | undefined, private path?: string, ) {} load() { - return this.config as ResolvedConfig | undefined; + return this.config as NormalizedConfig | undefined; } getPath(): string | undefined { @@ -34,29 +29,29 @@ class MockConfigSource implements ConfigSource { describe('cli/config', () => { describe('loadConfig', () => { it('loads config from flags only', () => { - const flags: Partial = { + const flags: Partial = { hostname: 'test.demandware.net', codeVersion: 'v1', }; const config = loadConfig(flags); - expect(config.hostname).to.equal('test.demandware.net'); - expect(config.codeVersion).to.equal('v1'); + expect(config.values.hostname).to.equal('test.demandware.net'); + expect(config.values.codeVersion).to.equal('v1'); }); it('merges flags with config file sources', () => { - const flags: Partial = { + const flags: Partial = { hostname: 'flag-hostname.demandware.net', }; // loadConfig uses resolveConfig internally which will try to load from dw.json // In test environment, this may not exist, so we test with flags only const config = loadConfig(flags); - expect(config.hostname).to.equal('flag-hostname.demandware.net'); + expect(config.values.hostname).to.equal('flag-hostname.demandware.net'); }); it('handles instance option', () => { - const flags: Partial = {}; + const flags: Partial = {}; const options: LoadConfigOptions = { instance: 'test-instance', }; @@ -67,7 +62,7 @@ describe('cli/config', () => { }); it('handles configPath option', () => { - const flags: Partial = {}; + const flags: Partial = {}; const options: LoadConfigOptions = { configPath: '/custom/path/dw.json', }; @@ -77,7 +72,7 @@ describe('cli/config', () => { }); it('handles cloudOrigin option', () => { - const flags: Partial = {}; + const flags: Partial = {}; const options: LoadConfigOptions = { cloudOrigin: 'https://cloud-staging.mobify.com', }; @@ -87,7 +82,7 @@ describe('cli/config', () => { }); it('merges plugin sources before defaults', () => { - const flags: Partial = { + const flags: Partial = { hostname: 'flag-hostname.demandware.net', }; const beforeSource = new MockConfigSource('before', { @@ -104,7 +99,7 @@ describe('cli/config', () => { }); it('merges plugin sources after defaults', () => { - const flags: Partial = { + const flags: Partial = { hostname: 'flag-hostname.demandware.net', }; const afterSource = new MockConfigSource('after', { @@ -125,33 +120,33 @@ describe('cli/config', () => { }); it('handles empty options', () => { - const flags: Partial = { + const flags: Partial = { hostname: 'test.demandware.net', }; const config = loadConfig(flags, {}); - expect(config.hostname).to.equal('test.demandware.net'); + expect(config.values.hostname).to.equal('test.demandware.net'); }); it('handles empty plugin sources', () => { - const flags: Partial = { + const flags: Partial = { hostname: 'test.demandware.net', }; const config = loadConfig(flags, {}, {}); - expect(config.hostname).to.equal('test.demandware.net'); + expect(config.values.hostname).to.equal('test.demandware.net'); }); it('preserves instanceName from options when not in resolved config', () => { - const flags: Partial = {}; + const flags: Partial = {}; const options: LoadConfigOptions = { instance: 'custom-instance', }; const config = loadConfig(flags, options); - expect(config.instanceName).to.equal('custom-instance'); + expect(config.values.instanceName).to.equal('custom-instance'); }); it('does not override instanceName if already in resolved config', () => { - const flags: Partial = { + const flags: Partial = { instanceName: 'resolved-instance', }; const options: LoadConfigOptions = { @@ -160,11 +155,11 @@ describe('cli/config', () => { const config = loadConfig(flags, options); // Flags take precedence - expect(config.instanceName).to.equal('resolved-instance'); + expect(config.values.instanceName).to.equal('resolved-instance'); }); it('handles multiple plugin sources with priority', () => { - const flags: Partial = { + const flags: Partial = { hostname: 'flag-hostname.demandware.net', }; const beforeSource1 = new MockConfigSource('before1', {codeVersion: 'v1'}); diff --git a/packages/b2c-tooling-sdk/test/config/dw-json.test.ts b/packages/b2c-tooling-sdk/test/config/dw-json.test.ts index 0c5e8151..ea7d5a92 100644 --- a/packages/b2c-tooling-sdk/test/config/dw-json.test.ts +++ b/packages/b2c-tooling-sdk/test/config/dw-json.test.ts @@ -76,7 +76,7 @@ describe('config/dw-json', () => { fs.writeFileSync(dwJsonPath, JSON.stringify(config)); const result = loadDwJson(); - expect(result).to.deep.equal(config); + expect(result?.config).to.deep.equal(config); }); it('loads config from explicit path', () => { @@ -87,7 +87,7 @@ describe('config/dw-json', () => { fs.writeFileSync(customPath, JSON.stringify(config)); const result = loadDwJson({path: customPath}); - expect(result).to.deep.equal(config); + expect(result?.config).to.deep.equal(config); }); it('selects named instance from multi-config', () => { @@ -102,8 +102,8 @@ describe('config/dw-json', () => { fs.writeFileSync(dwJsonPath, JSON.stringify(multiConfig)); const result = loadDwJson({instance: 'staging'}); - expect(result?.hostname).to.equal('staging.demandware.net'); - expect(result?.name).to.equal('staging'); + expect(result?.config.hostname).to.equal('staging.demandware.net'); + expect(result?.config.name).to.equal('staging'); }); it('selects active config when no instance specified', () => { @@ -119,8 +119,8 @@ describe('config/dw-json', () => { fs.writeFileSync(dwJsonPath, JSON.stringify(multiConfig)); const result = loadDwJson(); - expect(result?.hostname).to.equal('prod.demandware.net'); - expect(result?.name).to.equal('production'); + expect(result?.config.hostname).to.equal('prod.demandware.net'); + expect(result?.config.name).to.equal('production'); }); it('returns root config when no active config found', () => { @@ -133,7 +133,7 @@ describe('config/dw-json', () => { fs.writeFileSync(dwJsonPath, JSON.stringify(multiConfig)); const result = loadDwJson(); - expect(result?.hostname).to.equal('root.demandware.net'); + expect(result?.config.hostname).to.equal('root.demandware.net'); }); it('returns undefined for invalid JSON', () => { @@ -160,9 +160,9 @@ describe('config/dw-json', () => { fs.writeFileSync(dwJsonPath, JSON.stringify(config)); const result = loadDwJson(); - expect(result?.['client-id']).to.equal('test-client'); - expect(result?.['client-secret']).to.equal('test-secret'); - expect(result?.['oauth-scopes']).to.deep.equal(['mail', 'roles']); + expect(result?.config['client-id']).to.equal('test-client'); + expect(result?.config['client-secret']).to.equal('test-secret'); + expect(result?.config['oauth-scopes']).to.deep.equal(['mail', 'roles']); }); it('handles webdav-hostname', () => { @@ -174,7 +174,7 @@ describe('config/dw-json', () => { fs.writeFileSync(dwJsonPath, JSON.stringify(config)); const result = loadDwJson(); - expect(result?.['webdav-hostname']).to.equal('webdav.test.com'); + expect(result?.config['webdav-hostname']).to.equal('webdav.test.com'); }); }); }); diff --git a/packages/b2c-tooling-sdk/test/config/mapping.test.ts b/packages/b2c-tooling-sdk/test/config/mapping.test.ts deleted file mode 100644 index 1b92b1a0..00000000 --- a/packages/b2c-tooling-sdk/test/config/mapping.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -/* - * 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 - */ -import {expect} from 'chai'; -import { - mapDwJsonToNormalizedConfig, - mergeConfigsWithProtection, - getPopulatedFields, -} from '@salesforce/b2c-tooling-sdk/config'; - -describe('config/mapping', () => { - describe('mapDwJsonToNormalizedConfig', () => { - it('maps basic dw.json fields to normalized config', () => { - const dwJson = { - hostname: 'example.demandware.net', - 'code-version': 'v1', - username: 'test-user', - password: 'test-pass', - }; - - const config = mapDwJsonToNormalizedConfig(dwJson); - - expect(config.hostname).to.equal('example.demandware.net'); - expect(config.codeVersion).to.equal('v1'); - expect(config.username).to.equal('test-user'); - expect(config.password).to.equal('test-pass'); - }); - - it('maps OAuth credentials', () => { - const dwJson = { - hostname: 'example.demandware.net', - 'client-id': 'my-client-id', - 'client-secret': 'my-client-secret', - 'oauth-scopes': ['mail', 'roles'], - }; - - const config = mapDwJsonToNormalizedConfig(dwJson); - - expect(config.clientId).to.equal('my-client-id'); - expect(config.clientSecret).to.equal('my-client-secret'); - expect(config.scopes).to.deep.equal(['mail', 'roles']); - }); - - it('maps webdav-hostname as first priority', () => { - const dwJson = { - hostname: 'example.demandware.net', - 'webdav-hostname': 'webdav.example.com', - secureHostname: 'secure.example.com', - 'secure-server': 'secure-server.example.com', - }; - - const config = mapDwJsonToNormalizedConfig(dwJson); - - expect(config.webdavHostname).to.equal('webdav.example.com'); - }); - - it('maps secureHostname when webdav-hostname is not present', () => { - const dwJson = { - hostname: 'example.demandware.net', - secureHostname: 'secure.example.com', - 'secure-server': 'secure-server.example.com', - }; - - const config = mapDwJsonToNormalizedConfig(dwJson); - - expect(config.webdavHostname).to.equal('secure.example.com'); - }); - - it('maps secure-server when other webdav options are not present', () => { - const dwJson = { - hostname: 'example.demandware.net', - 'secure-server': 'secure-server.example.com', - }; - - const config = mapDwJsonToNormalizedConfig(dwJson); - - expect(config.webdavHostname).to.equal('secure-server.example.com'); - }); - - it('maps shortCode as first priority', () => { - const dwJson = { - hostname: 'example.demandware.net', - shortCode: 'abc123', - 'short-code': 'def456', - 'scapi-shortcode': 'ghi789', - }; - - const config = mapDwJsonToNormalizedConfig(dwJson); - - expect(config.shortCode).to.equal('abc123'); - }); - - it('maps short-code when shortCode is not present', () => { - const dwJson = { - hostname: 'example.demandware.net', - 'short-code': 'def456', - 'scapi-shortcode': 'ghi789', - }; - - const config = mapDwJsonToNormalizedConfig(dwJson); - - expect(config.shortCode).to.equal('def456'); - }); - - it('maps scapi-shortcode when other short code options are not present', () => { - const dwJson = { - hostname: 'example.demandware.net', - 'scapi-shortcode': 'ghi789', - }; - - const config = mapDwJsonToNormalizedConfig(dwJson); - - expect(config.shortCode).to.equal('ghi789'); - }); - - it('maps MRT fields', () => { - const dwJson = { - hostname: 'example.demandware.net', - mrtProject: 'my-project', - mrtEnvironment: 'staging', - }; - - const config = mapDwJsonToNormalizedConfig(dwJson); - - expect(config.mrtProject).to.equal('my-project'); - expect(config.mrtEnvironment).to.equal('staging'); - }); - - it('maps instance name from dw.json name field', () => { - const dwJson = { - hostname: 'example.demandware.net', - name: 'production', - }; - - const config = mapDwJsonToNormalizedConfig(dwJson); - - expect(config.instanceName).to.equal('production'); - }); - - it('maps auth-methods', () => { - const dwJson = { - hostname: 'example.demandware.net', - 'auth-methods': ['client-credentials', 'basic'] as ('client-credentials' | 'basic')[], - }; - - const config = mapDwJsonToNormalizedConfig(dwJson); - - expect(config.authMethods).to.deep.equal(['client-credentials', 'basic']); - }); - }); - - describe('mergeConfigsWithProtection', () => { - it('merges overrides with base config (overrides win)', () => { - const overrides = { - codeVersion: 'v2', - clientId: 'override-client', - }; - const base = { - hostname: 'example.demandware.net', - codeVersion: 'v1', - clientId: 'base-client', - clientSecret: 'base-secret', - }; - - const {config, warnings, hostnameMismatch} = mergeConfigsWithProtection(overrides, base); - - expect(config.hostname).to.equal('example.demandware.net'); - expect(config.codeVersion).to.equal('v2'); - expect(config.clientId).to.equal('override-client'); - expect(config.clientSecret).to.equal('base-secret'); - expect(warnings).to.have.length(0); - expect(hostnameMismatch).to.equal(false); - }); - - it('detects hostname mismatch and ignores base config', () => { - const overrides = { - hostname: 'staging.demandware.net', - clientId: 'staging-client', - }; - const base = { - hostname: 'prod.demandware.net', - clientId: 'prod-client', - clientSecret: 'prod-secret', - }; - - const {config, warnings, hostnameMismatch} = mergeConfigsWithProtection(overrides, base); - - expect(config.hostname).to.equal('staging.demandware.net'); - expect(config.clientId).to.equal('staging-client'); - expect(config.clientSecret).to.be.undefined; - expect(hostnameMismatch).to.equal(true); - expect(warnings).to.have.length(1); - expect(warnings[0].code).to.equal('HOSTNAME_MISMATCH'); - expect(warnings[0].message).to.include('staging.demandware.net'); - expect(warnings[0].message).to.include('prod.demandware.net'); - }); - - it('does not trigger mismatch when hostnames match', () => { - const overrides = { - hostname: 'example.demandware.net', - codeVersion: 'v2', - }; - const base = { - hostname: 'example.demandware.net', - codeVersion: 'v1', - clientSecret: 'secret', - }; - - const {config, warnings, hostnameMismatch} = mergeConfigsWithProtection(overrides, base); - - expect(config.hostname).to.equal('example.demandware.net'); - expect(config.codeVersion).to.equal('v2'); - expect(config.clientSecret).to.equal('secret'); - expect(hostnameMismatch).to.equal(false); - expect(warnings).to.have.length(0); - }); - - it('does not trigger mismatch when no override hostname is provided', () => { - const overrides = { - codeVersion: 'v2', - }; - const base = { - hostname: 'example.demandware.net', - codeVersion: 'v1', - }; - - const {config, warnings, hostnameMismatch} = mergeConfigsWithProtection(overrides, base); - - expect(config.hostname).to.equal('example.demandware.net'); - expect(config.codeVersion).to.equal('v2'); - expect(hostnameMismatch).to.equal(false); - expect(warnings).to.have.length(0); - }); - - it('can disable hostname protection', () => { - const overrides = { - hostname: 'staging.demandware.net', - }; - const base = { - hostname: 'prod.demandware.net', - clientSecret: 'prod-secret', - }; - - const {config, warnings, hostnameMismatch} = mergeConfigsWithProtection(overrides, base, { - hostnameProtection: false, - }); - - expect(config.hostname).to.equal('staging.demandware.net'); - expect(config.clientSecret).to.equal('prod-secret'); - expect(hostnameMismatch).to.equal(false); - expect(warnings).to.have.length(0); - }); - - it('handles empty base config', () => { - const overrides = { - hostname: 'example.demandware.net', - clientId: 'client', - }; - const base = {}; - - const {config, warnings} = mergeConfigsWithProtection(overrides, base); - - expect(config.hostname).to.equal('example.demandware.net'); - expect(config.clientId).to.equal('client'); - expect(warnings).to.have.length(0); - }); - - it('handles empty overrides', () => { - const overrides = {}; - const base = { - hostname: 'example.demandware.net', - codeVersion: 'v1', - }; - - const {config, warnings} = mergeConfigsWithProtection(overrides, base); - - expect(config.hostname).to.equal('example.demandware.net'); - expect(config.codeVersion).to.equal('v1'); - expect(warnings).to.have.length(0); - }); - }); - - describe('getPopulatedFields', () => { - it('returns list of fields with values', () => { - const config = { - hostname: 'example.demandware.net', - codeVersion: 'v1', - username: 'user', - }; - - const fields = getPopulatedFields(config); - - expect(fields).to.have.members(['hostname', 'codeVersion', 'username']); - }); - - it('excludes undefined fields', () => { - const config = { - hostname: 'example.demandware.net', - codeVersion: undefined, - username: undefined, - }; - - const fields = getPopulatedFields(config); - - expect(fields).to.deep.equal(['hostname']); - }); - - it('excludes null fields', () => { - const config = { - hostname: 'example.demandware.net', - codeVersion: null as unknown as string, - }; - - const fields = getPopulatedFields(config); - - expect(fields).to.deep.equal(['hostname']); - }); - - it('excludes empty string fields', () => { - const config = { - hostname: 'example.demandware.net', - codeVersion: '', - }; - - const fields = getPopulatedFields(config); - - expect(fields).to.deep.equal(['hostname']); - }); - - it('returns empty array for empty config', () => { - const config = {}; - - const fields = getPopulatedFields(config); - - expect(fields).to.have.length(0); - }); - }); -}); diff --git a/packages/b2c-tooling-sdk/test/config/resolved-config.test.ts b/packages/b2c-tooling-sdk/test/config/resolved-config.test.ts index 93fdcf7e..ea36531b 100644 --- a/packages/b2c-tooling-sdk/test/config/resolved-config.test.ts +++ b/packages/b2c-tooling-sdk/test/config/resolved-config.test.ts @@ -16,7 +16,7 @@ describe('config/resolved-config', () => { }); it('hasB2CInstanceConfig returns false when hostname is missing', () => { - const config = resolveConfig({}, {replaceDefaultSources: true, sources: []}); + const config = resolveConfig({}, {replaceDefaultSources: true}); expect(config.hasB2CInstanceConfig()).to.be.false; }); @@ -26,7 +26,7 @@ describe('config/resolved-config', () => { }); it('hasMrtConfig returns false when mrtApiKey is missing', () => { - const config = resolveConfig({}, {replaceDefaultSources: true, sources: []}); + const config = resolveConfig({}, {replaceDefaultSources: true}); expect(config.hasMrtConfig()).to.be.false; }); @@ -36,7 +36,7 @@ describe('config/resolved-config', () => { }); it('hasOAuthConfig returns false when clientId is missing', () => { - const config = resolveConfig({}, {replaceDefaultSources: true, sources: []}); + const config = resolveConfig({}, {replaceDefaultSources: true}); expect(config.hasOAuthConfig()).to.be.false; }); @@ -46,12 +46,12 @@ describe('config/resolved-config', () => { }); it('hasBasicAuthConfig returns false when username is missing', () => { - const config = resolveConfig({password: 'pass'}, {replaceDefaultSources: true, sources: []}); + const config = resolveConfig({password: 'pass'}, {replaceDefaultSources: true}); expect(config.hasBasicAuthConfig()).to.be.false; }); it('hasBasicAuthConfig returns false when password is missing', () => { - const config = resolveConfig({username: 'user'}, {replaceDefaultSources: true, sources: []}); + const config = resolveConfig({username: 'user'}, {replaceDefaultSources: true}); expect(config.hasBasicAuthConfig()).to.be.false; }); }); @@ -67,7 +67,7 @@ describe('config/resolved-config', () => { }); it('throws error when hostname is missing', () => { - const config = resolveConfig({}, {replaceDefaultSources: true, sources: []}); + const config = resolveConfig({}, {replaceDefaultSources: true}); expect(() => config.createB2CInstance()).to.throw('B2C instance requires hostname'); }); }); @@ -82,12 +82,12 @@ describe('config/resolved-config', () => { }); it('throws error when username is missing', () => { - const config = resolveConfig({password: 'pass'}, {replaceDefaultSources: true, sources: []}); + const config = resolveConfig({password: 'pass'}, {replaceDefaultSources: true}); expect(() => config.createBasicAuth()).to.throw('Basic auth requires username and password'); }); it('throws error when password is missing', () => { - const config = resolveConfig({username: 'user'}, {replaceDefaultSources: true, sources: []}); + const config = resolveConfig({username: 'user'}, {replaceDefaultSources: true}); expect(() => config.createBasicAuth()).to.throw('Basic auth requires username and password'); }); }); @@ -102,7 +102,7 @@ describe('config/resolved-config', () => { }); it('throws error when clientId is missing', () => { - const config = resolveConfig({}, {replaceDefaultSources: true, sources: []}); + const config = resolveConfig({}, {replaceDefaultSources: true}); expect(() => config.createOAuth()).to.throw('OAuth requires clientId'); }); @@ -123,7 +123,7 @@ describe('config/resolved-config', () => { }); it('throws error when mrtApiKey is missing', () => { - const config = resolveConfig({}, {replaceDefaultSources: true, sources: []}); + const config = resolveConfig({}, {replaceDefaultSources: true}); expect(() => config.createMrtAuth()).to.throw('MRT auth requires mrtApiKey'); }); }); @@ -146,7 +146,7 @@ describe('config/resolved-config', () => { }); it('throws error when no auth is available', () => { - const config = resolveConfig({}, {replaceDefaultSources: true, sources: []}); + const config = resolveConfig({}, {replaceDefaultSources: true}); expect(() => config.createWebDavAuth()).to.throw( 'WebDAV auth requires basic auth (username/password) or OAuth (clientId)', ); @@ -171,7 +171,7 @@ describe('config/resolved-config', () => { }); it('throws error when mrtApiKey is missing', () => { - const config = resolveConfig({}, {replaceDefaultSources: true, sources: []}); + const config = resolveConfig({}, {replaceDefaultSources: true}); expect(() => config.createMrtClient({org: 'test-org', project: 'test-project'})).to.throw( 'MRT auth requires mrtApiKey', ); diff --git a/packages/b2c-tooling-sdk/test/fixtures/test-cli/src/commands/test-instance.js b/packages/b2c-tooling-sdk/test/fixtures/test-cli/src/commands/test-instance.js index 9225ae41..f1bc079b 100644 --- a/packages/b2c-tooling-sdk/test/fixtures/test-cli/src/commands/test-instance.js +++ b/packages/b2c-tooling-sdk/test/fixtures/test-cli/src/commands/test-instance.js @@ -17,11 +17,11 @@ export default class TestInstance extends InstanceCommand { async run() { // Check server via resolvedConfig (no hasServer method on InstanceCommand) - const hasServer = Boolean(this.resolvedConfig.hostname); + const hasServer = Boolean(this.resolvedConfig.values.hostname); // Return server/instance info without requiring server (for testing flags work) const result = { - server: this.resolvedConfig.hostname, + server: this.resolvedConfig.values.hostname, hasServer, }; From f6b4b2d74ad89a103a95a427c5ed4057f4aaf22d Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 15 Jan 2026 19:01:31 -0500 Subject: [PATCH 3/9] debugging logging --- packages/b2c-cli/src/commands/code/deploy.ts | 8 +++++--- packages/b2c-tooling-sdk/src/operations/code/deploy.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/b2c-cli/src/commands/code/deploy.ts b/packages/b2c-cli/src/commands/code/deploy.ts index d1180e86..65fa2d74 100644 --- a/packages/b2c-cli/src/commands/code/deploy.ts +++ b/packages/b2c-cli/src/commands/code/deploy.ts @@ -102,7 +102,8 @@ export default class CodeDeploy extends CartridgeCommand { this.error(t('commands.code.deploy.noCartridges', 'No cartridges found in {{path}}', {path: this.cartridgePath})); } - this.log( + this.logger?.info( + {path: this.cartridgePath, server: hostname, codeVersion: version}, t('commands.code.deploy.deploying', 'Deploying {{path}} to {{hostname}} ({{version}})', { path: this.cartridgePath, hostname, @@ -112,7 +113,7 @@ export default class CodeDeploy extends CartridgeCommand { // Log found cartridges for (const c of cartridges) { - this.logger?.debug(` ${c.name} (${c.src})`); + this.logger?.debug({cartridgeName: c.name, path: c.src}, ` ${c.name}`); } try { @@ -141,7 +142,8 @@ export default class CodeDeploy extends CartridgeCommand { reloaded, }; - this.log( + this.logger?.info( + {codeVersion: result.codeVersion, cartridgeCount: result.cartridges.length}, t('commands.code.deploy.summary', 'Deployed {{count}} cartridge(s) to {{codeVersion}}', { count: result.cartridges.length, codeVersion: result.codeVersion, diff --git a/packages/b2c-tooling-sdk/src/operations/code/deploy.ts b/packages/b2c-tooling-sdk/src/operations/code/deploy.ts index 45af88ce..e0d4ee16 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/deploy.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/deploy.ts @@ -178,7 +178,7 @@ export async function uploadCartridges(instance: B2CInstance, cartridges: Cartri logger.debug('Temporary archive deleted'); logger.debug( - {hostname: instance.config.hostname, codeVersion, cartridgeCount: cartridges.length}, + {server: instance.config.hostname, codeVersion, cartridgeCount: cartridges.length}, `Uploaded ${cartridges.length} cartridges to ${instance.config.hostname}`, ); } From c1269900cf995a51ed6d07e07ee39a11f871e7c9 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 15 Jan 2026 19:31:08 -0500 Subject: [PATCH 4/9] Improve structured logging with proper fields and messages Add missing structured fields to log calls while preserving human-readable messages with interpolated values. This makes logs both machine-parseable and human-friendly. Changes: - Add jobId, executionId, path fields to job operations - Add cartridgeName, codeVersionId fields to code operations - Add method, url fields to client logging - Add headerName, keyPreview, username, port fields to auth - Change reserved 'hostname' field to 'server' in watch.ts --- packages/b2c-tooling-sdk/src/auth/api-key.ts | 2 +- .../src/auth/oauth-implicit.ts | 38 +++++++------------ packages/b2c-tooling-sdk/src/auth/oauth.ts | 6 +-- .../b2c-tooling-sdk/src/clients/middleware.ts | 4 +- .../b2c-tooling-sdk/src/clients/webdav.ts | 12 +++++- .../src/operations/code/deploy.ts | 6 +-- .../src/operations/code/versions.ts | 6 +-- .../src/operations/code/watch.ts | 10 ++--- .../src/operations/jobs/run.ts | 12 +++--- .../src/operations/jobs/site-archive.ts | 34 +++++++++-------- 10 files changed, 64 insertions(+), 66 deletions(-) diff --git a/packages/b2c-tooling-sdk/src/auth/api-key.ts b/packages/b2c-tooling-sdk/src/auth/api-key.ts index 97f4f40f..4a0643c7 100644 --- a/packages/b2c-tooling-sdk/src/auth/api-key.ts +++ b/packages/b2c-tooling-sdk/src/auth/api-key.ts @@ -37,7 +37,7 @@ export class ApiKeyStrategy implements AuthStrategy { // Show partial key for identification (first 8 chars) const keyPreview = key.length > 8 ? `${key.slice(0, 8)}...` : key; - logger.debug({headerName}, `[Auth] Using API Key authentication (${headerName}): ${keyPreview}`); + logger.debug({headerName, keyPreview}, `[Auth] Using API Key authentication (${headerName}): ${keyPreview}`); } async fetch(url: string, init: RequestInit = {}): Promise { diff --git a/packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts b/packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts index 4044446d..f6d7da19 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts @@ -107,20 +107,17 @@ export class ImplicitOAuthStrategy implements AuthStrategy { const logger = getLogger(); logger.debug( - {clientId: this.config.clientId, accountManagerHost: this.accountManagerHost, localPort: this.localPort}, - `[Auth] ImplicitOAuthStrategy initialized for client: ${this.config.clientId}`, - ); - logger.trace( - {scopes: this.config.scopes}, - `[Auth] Configured scopes: ${this.config.scopes?.join(', ') || '(none)'}`, + {clientId: this.config.clientId, accountManagerHost: this.accountManagerHost, port: this.localPort}, + '[Auth] ImplicitOAuthStrategy initialized', ); + logger.trace({scopes: this.config.scopes}, '[Auth] Configured scopes'); } async fetch(url: string, init: RequestInit = {}): Promise { const logger = getLogger(); const method = init.method || 'GET'; - logger.trace({method, url}, `[Auth] Fetching with implicit OAuth: ${method} ${url}`); + logger.trace({method, url}, '[Auth] Fetching with implicit OAuth'); const token = await this.getAccessToken(); @@ -132,10 +129,7 @@ export class ImplicitOAuthStrategy implements AuthStrategy { let res = await fetch(url, {...init, headers}); const duration = Date.now() - startTime; - logger.debug( - {method, url, status: res.status, duration}, - `[Auth] Response: ${method} ${url} ${res.status} ${duration}ms`, - ); + logger.debug({method, url, status: res.status, duration}, '[Auth] Response'); // RESILIENCE: If the server says 401, the token might have expired or been revoked. // We retry exactly once after invalidating the cached token. @@ -149,10 +143,7 @@ export class ImplicitOAuthStrategy implements AuthStrategy { res = await fetch(url, {...init, headers}); const retryDuration = Date.now() - retryStart; - logger.debug( - {method, url, status: res.status, duration: retryDuration}, - `[Auth] Retry response: ${method} ${url} ${res.status} ${retryDuration}ms`, - ); + logger.debug({method, url, status: res.status, duration: retryDuration}, '[Auth] Retry response'); } return res; @@ -244,10 +235,7 @@ export class ImplicitOAuthStrategy implements AuthStrategy { ); ACCESS_TOKEN_CACHE.delete(clientId); } else { - logger.debug( - {timeUntilExpiryMs: timeUntilExpiry}, - `[Auth] Reusing cached access token (expires in ${Math.round(timeUntilExpiry / 1000)}s)`, - ); + logger.debug({timeUntilExpiryMs: timeUntilExpiry}, '[Auth] Reusing cached access token'); return cached.accessToken; } } @@ -314,7 +302,7 @@ export class ImplicitOAuthStrategy implements AuthStrategy { logger.trace({authorizeUrl}, '[Auth] Authorization URL'); // Print URL to console (in case machine has no default browser) - logger.info(`Login URL: ${authorizeUrl}`); + logger.info({url: authorizeUrl}, `Login URL: ${authorizeUrl}`); logger.info('If the URL does not open automatically, copy/paste it into a browser on this machine.'); // Attempt to open the browser @@ -337,7 +325,7 @@ export class ImplicitOAuthStrategy implements AuthStrategy { hasAccessToken: !!accessToken, hasError: !!error, }, - `[Auth] Received redirect request: ${requestUrl.pathname}`, + '[Auth] Received redirect request', ); if (!accessToken && !error) { @@ -349,7 +337,7 @@ export class ImplicitOAuthStrategy implements AuthStrategy { } else if (accessToken) { const authDuration = Date.now() - startTime; // Successfully received access token - logger.debug({authDurationMs: authDuration}, `[Auth] Got access token response (took ${authDuration}ms)`); + logger.debug({duration: authDuration}, `[Auth] Got access token response (${authDuration}ms)`); logger.info('Successfully authenticated'); try { @@ -366,7 +354,7 @@ export class ImplicitOAuthStrategy implements AuthStrategy { logger.debug( {expiresIn, expiresAt: expiration.toISOString(), scopes}, - `[Auth] Token expires in ${expiresIn}s, scopes: ${scopes.join(', ') || '(none)'}`, + `[Auth] Token expires in ${expiresIn}s, scopes: ${scopes.join(' ')}`, ); resolve({ @@ -391,7 +379,7 @@ export class ImplicitOAuthStrategy implements AuthStrategy { } else if (error) { // OAuth error response const errorMessage = errorDescription || error; - logger.error({error, errorDescription}, `[Auth] OAuth error: ${errorMessage}`); + logger.error({error, errorDescription}, `[Auth] OAuth error: ${error}`); response.writeHead(500, {'Content-Type': 'text/plain'}); response.write(`Authentication failed: ${errorMessage}`); response.end(); @@ -421,7 +409,7 @@ export class ImplicitOAuthStrategy implements AuthStrategy { }); server.on('error', (err) => { - logger.error({error: err.message, port: this.localPort}, `[Auth] Failed to start OAuth redirect server`); + logger.error({error: err.message, port: this.localPort}, '[Auth] Failed to start OAuth redirect server'); reject(new Error(`Failed to start OAuth redirect server: ${err.message}`)); }); }); diff --git a/packages/b2c-tooling-sdk/src/auth/oauth.ts b/packages/b2c-tooling-sdk/src/auth/oauth.ts index b5e1e39f..30c75367 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth.ts @@ -178,7 +178,7 @@ export class OAuthStrategy implements AuthStrategy { logger.debug({method, url}, `[Auth REQ] ${method} ${url}`); // Trace: Log request details - logger.trace({headers: requestHeaders, body: params.toString()}, `[Auth REQ BODY] ${method} ${url}`); + logger.trace({method, url, headers: requestHeaders, body: params.toString()}, `[Auth REQ BODY] ${method} ${url}`); const startTime = Date.now(); const response = await fetch(url, { @@ -202,7 +202,7 @@ export class OAuthStrategy implements AuthStrategy { if (!response.ok) { const errorText = await response.text(); - logger.trace({headers: responseHeaders, body: errorText}, `[Auth RESP BODY] ${method} ${url}`); + logger.trace({method, url, headers: responseHeaders, body: errorText}, `[Auth RESP BODY] ${method} ${url}`); throw new Error(`Failed to get access token: ${response.status} ${response.statusText} - ${errorText}`); } @@ -213,7 +213,7 @@ export class OAuthStrategy implements AuthStrategy { }; // Trace: Log response details - logger.trace({headers: responseHeaders, body: data}, `[Auth RESP BODY] ${method} ${url}`); + logger.trace({method, url, headers: responseHeaders, body: data}, `[Auth RESP BODY] ${method} ${url}`); const jwt = decodeJWT(data.access_token); logger.trace({jwt: jwt.payload}, '[Auth] JWT payload'); diff --git a/packages/b2c-tooling-sdk/src/clients/middleware.ts b/packages/b2c-tooling-sdk/src/clients/middleware.ts index 29bf37df..df3a818e 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware.ts @@ -143,7 +143,7 @@ export function createLoggingMiddleware(config?: string | LoggingMiddlewareConfi // Mask sensitive/large body keys before logging const maskedBody = maskBody(body, maskBodyKeys); logger.trace( - {headers: headersToObject(request.headers), body: maskedBody}, + {method: request.method, url, headers: headersToObject(request.headers), body: maskedBody}, `${reqTag} ${request.method} ${url} body`, ); @@ -178,7 +178,7 @@ export function createLoggingMiddleware(config?: string | LoggingMiddlewareConfi // Mask sensitive/large body keys before logging const maskedResponseBody = maskBody(responseBody, maskBodyKeys); logger.trace( - {headers: headersToObject(response.headers), body: maskedResponseBody}, + {method: request.method, url, headers: headersToObject(response.headers), body: maskedResponseBody}, `${respTag} ${request.method} ${url} body`, ); diff --git a/packages/b2c-tooling-sdk/src/clients/webdav.ts b/packages/b2c-tooling-sdk/src/clients/webdav.ts index dac2d4e7..06103e42 100644 --- a/packages/b2c-tooling-sdk/src/clients/webdav.ts +++ b/packages/b2c-tooling-sdk/src/clients/webdav.ts @@ -143,7 +143,12 @@ export class WebDavClient { // Trace: Log request details logger.trace( - {headers: this.headersToObject(request.headers), body: this.formatBody(init?.body)}, + { + method: request.method, + url: request.url, + headers: this.headersToObject(request.headers), + body: this.formatBody(init?.body), + }, `[WebDAV REQ BODY] ${request.method} ${request.url}`, ); @@ -188,7 +193,10 @@ export class WebDavClient { const clonedResponse = response.clone(); responseBody = await clonedResponse.text(); } - logger.trace({headers: responseHeaders, body: responseBody}, `[WebDAV RESP BODY] ${request.method} ${request.url}`); + logger.trace( + {method: request.method, url: request.url, headers: responseHeaders, body: responseBody}, + `[WebDAV RESP BODY] ${request.method} ${request.url}`, + ); return response; } diff --git a/packages/b2c-tooling-sdk/src/operations/code/deploy.ts b/packages/b2c-tooling-sdk/src/operations/code/deploy.ts index e0d4ee16..d936670c 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/deploy.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/deploy.ts @@ -92,10 +92,10 @@ export async function deleteCartridges(instance: B2CInstance, cartridges: Cartri const cartridgePath = `Cartridges/${codeVersion}/${c.dest}`; try { await webdav.delete(cartridgePath); - logger.debug({cartridge: c.dest}, `Deleted ${cartridgePath}`); + logger.debug({cartridgeName: c.dest, path: cartridgePath}, `Deleted ${cartridgePath}`); } catch { // Ignore errors - cartridge may not exist - logger.debug({cartridge: c.dest}, `Could not delete ${cartridgePath} (may not exist)`); + logger.debug({cartridgeName: c.dest, path: cartridgePath}, `Could not delete ${cartridgePath} (may not exist)`); } } } @@ -243,7 +243,7 @@ export async function findAndDeployCartridges( logger.debug({count: cartridges.length}, `Found ${cartridges.length} cartridge(s)`); for (const c of cartridges) { - logger.debug({cartridge: c.name, path: c.src}, ` ${c.name}`); + logger.debug({cartridgeName: c.name, path: c.src}, ` ${c.name}`); } // Optionally delete existing cartridges first diff --git a/packages/b2c-tooling-sdk/src/operations/code/versions.ts b/packages/b2c-tooling-sdk/src/operations/code/versions.ts index d3f5f676..052be7f8 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/versions.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/versions.ts @@ -116,7 +116,7 @@ export async function reloadCodeVersion(instance: B2CInstance, codeVersionId?: s throw new Error('No code version specified and no active version found'); } - logger.debug({targetVersion}, `Reloading code version ${targetVersion}`); + logger.debug({codeVersionId: targetVersion}, `Reloading code version ${targetVersion}`); // If the target is already active, we need to toggle to another version first if (activeVersion?.id === targetVersion) { @@ -125,13 +125,13 @@ export async function reloadCodeVersion(instance: B2CInstance, codeVersionId?: s throw new Error('Cannot reload: no alternate code version available for toggle'); } - logger.debug({alternateVersion: alternateVersion.id}, `Temporarily activating ${alternateVersion.id}`); + logger.debug({codeVersionId: alternateVersion.id}, `Temporarily activating ${alternateVersion.id}`); await activateCodeVersion(instance, alternateVersion.id!); } // Now activate the target version await activateCodeVersion(instance, targetVersion); - logger.debug({targetVersion}, `Code version ${targetVersion} reloaded`); + logger.debug({codeVersionId: targetVersion}, `Code version ${targetVersion} reloaded`); } /** diff --git a/packages/b2c-tooling-sdk/src/operations/code/watch.ts b/packages/b2c-tooling-sdk/src/operations/code/watch.ts index b0939b40..b2d39bf9 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/watch.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/watch.ts @@ -145,7 +145,7 @@ export async function watchCartridges( logger.debug({count: cartridges.length}, `Watching ${cartridges.length} cartridge(s)`); for (const c of cartridges) { - logger.info({cartridge: c.name, path: c.src}, ` ${c.name}`); + logger.info({cartridgeName: c.name, path: c.src}, ` ${c.name}`); } const webdav = instance.webdav; @@ -229,7 +229,7 @@ export async function watchCartridges( await webdav.delete(uploadPath); logger.debug( - {fileCount: validUploadFiles.length, hostname: instance.config.hostname}, + {fileCount: validUploadFiles.length, server: instance.config.hostname}, `Uploaded ${validUploadFiles.length} file(s)`, ); @@ -253,9 +253,9 @@ export async function watchCartridges( const deletePath = `${webdavLocation}/${f.dest}`; try { await webdav.delete(deletePath); - logger.info({file: deletePath}, `Deleted: ${deletePath}`); + logger.info({path: deletePath}, `Deleted: ${deletePath}`); } catch (error) { - logger.debug({file: deletePath, error}, `Failed to delete ${deletePath}`); + logger.debug({path: deletePath, error}, `Failed to delete ${deletePath}`); } } @@ -291,7 +291,7 @@ export async function watchCartridges( options.onError?.(error); }); - logger.debug({hostname: instance.config.hostname, codeVersion}, 'Watching for changes...'); + logger.debug({server: instance.config.hostname, codeVersion}, 'Watching for changes...'); return { watcher, diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/run.ts b/packages/b2c-tooling-sdk/src/operations/jobs/run.ts index bb7d5b0f..d6a1cba6 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/run.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/run.ts @@ -117,12 +117,12 @@ export async function executeJob( const errorBody = await response.text().catch(() => ''); if (errorBody.includes('JobAlreadyRunningException')) { if (waitForRunning) { - logger.warn(`Job ${jobId} already running, waiting for it to finish...`); + logger.warn({jobId}, `Job ${jobId} already running, waiting for it to finish...`); // Search for the running execution const runningExecution = await findRunningJobExecution(instance, jobId); if (runningExecution) { - logger.debug({executionId: runningExecution.id}, `Found running execution ${runningExecution.id}`); + logger.debug({jobId, executionId: runningExecution.id}, `Found running execution ${runningExecution.id}`); await waitForJob(instance, jobId, runningExecution.id!); // Retry execution after the running job finishes return executeJob(instance, jobId, {...options, waitForRunning: false}); @@ -139,7 +139,7 @@ export async function executeJob( throw new Error(message); } - logger.debug({executionId: data.id, status: data.execution_status}, `Job ${jobId} started: ${data.id}`); + logger.debug({jobId, executionId: data.id, status: data.execution_status}, `Job ${jobId} started: ${data.id}`); return data; } @@ -231,14 +231,14 @@ export async function waitForJob( // Check for terminal states if (execution.execution_status === 'aborted' || execution.exit_status?.code === 'ERROR') { - logger.debug({execution}, `Job ${jobId} failed`); + logger.debug({jobId, executionId, execution}, `Job ${jobId} failed`); throw new JobExecutionError(`Job ${jobId} failed`, execution); } if (execution.execution_status === 'finished') { const durationSec = (execution.duration ?? 0) / 1000; logger.debug( - {executionId, status: execution.exit_status?.code, duration: durationSec}, + {jobId, executionId, status: execution.exit_status?.code, duration: durationSec}, `Job ${jobId} finished. Status: ${execution.exit_status?.code} (duration: ${durationSec}s)`, ); return execution; @@ -247,7 +247,7 @@ export async function waitForJob( // Log periodic updates if (ticks % 5 === 0) { logger.debug( - {executionId, status: execution.execution_status, elapsed: elapsed / 1000}, + {jobId, executionId, status: execution.execution_status, elapsed: elapsed / 1000}, `Waiting for job ${jobId} to finish (${(elapsed / 1000).toFixed(0)}s elapsed)...`, ); } diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts b/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts index 7cb6f949..e4254d1b 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts @@ -120,7 +120,7 @@ export async function siteArchiveImport( const archiveDirName = archiveName || `import-${timestamp}`; zipFilename = `${archiveDirName}.zip`; - logger.debug({directory: targetPath}, `Creating archive from directory: ${targetPath}`); + logger.debug({path: targetPath}, `Creating archive from directory: ${targetPath}`); archiveContent = await createArchiveFromDirectory(targetPath, archiveDirName); } else { throw new Error(`Target must be a file or directory: ${targetPath}`); @@ -133,11 +133,14 @@ export async function siteArchiveImport( if (needsUpload && archiveContent) { logger.debug({path: uploadPath}, `Uploading archive to ${uploadPath}`); await instance.webdav.put(uploadPath, archiveContent as Buffer, 'application/zip'); - logger.debug(`Archive uploaded: ${uploadPath}`); + logger.debug({path: uploadPath}, `Archive uploaded: ${uploadPath}`); } // Execute the import job with file_name parameter - logger.debug(`Executing ${IMPORT_JOB_ID} job with file_name: ${zipFilename}`); + logger.debug( + {jobId: IMPORT_JOB_ID, file: zipFilename}, + `Executing ${IMPORT_JOB_ID} job with file_name: ${zipFilename}`, + ); let execution: JobExecution; @@ -167,7 +170,7 @@ export async function siteArchiveImport( execution = data; } - logger.debug({executionId: execution.id}, `Import job started: ${execution.id}`); + logger.debug({jobId: IMPORT_JOB_ID, executionId: execution.id}, `Import job started: ${execution.id}`); // Wait for completion try { @@ -177,9 +180,9 @@ export async function siteArchiveImport( // Try to get log file try { const log = await getJobLog(instance, error.execution); - logger.error({logFile: error.execution.log_file_path}, `Job log:\n${log}`); + logger.error({jobId: IMPORT_JOB_ID, logFile: error.execution.log_file_path, log}, `Job log:\n${log}`); } catch { - logger.error('Could not retrieve job log'); + logger.error({jobId: IMPORT_JOB_ID}, 'Could not retrieve job log'); } } throw error; @@ -188,7 +191,7 @@ export async function siteArchiveImport( // Clean up archive if not keeping if (!keepArchive && needsUpload) { await instance.webdav.delete(uploadPath); - logger.debug(`Archive deleted: ${uploadPath}`); + logger.debug({path: uploadPath}, `Archive deleted: ${uploadPath}`); } return { @@ -389,8 +392,7 @@ export async function siteArchiveExport( const zipFilename = `${archiveDirName}.zip`; const webdavPath = `Impex/src/instance/${zipFilename}`; - logger.debug(`Executing ${EXPORT_JOB_ID} job`); - logger.debug({dataUnits}, 'Export data units'); + logger.debug({jobId: EXPORT_JOB_ID, dataUnits}, `Executing ${EXPORT_JOB_ID} job`); let execution: JobExecution; @@ -430,7 +432,7 @@ export async function siteArchiveExport( execution = data; } - logger.debug({executionId: execution.id}, `Export job started: ${execution.id}`); + logger.debug({jobId: EXPORT_JOB_ID, executionId: execution.id}, `Export job started: ${execution.id}`); // Wait for completion try { @@ -440,22 +442,22 @@ export async function siteArchiveExport( // Try to get log file try { const log = await getJobLog(instance, error.execution); - logger.error({logFile: error.execution.log_file_path}, `Job log:\n${log}`); + logger.error({jobId: EXPORT_JOB_ID, logFile: error.execution.log_file_path, log}, `Job log:\n${log}`); } catch { - logger.error('Could not retrieve job log'); + logger.error({jobId: EXPORT_JOB_ID}, 'Could not retrieve job log'); } } throw error; } // Download archive - logger.debug(`Downloading archive: ${webdavPath}`); + logger.debug({path: webdavPath}, `Downloading archive: ${webdavPath}`); const archiveData = await instance.webdav.get(webdavPath); // Clean up if not keeping if (!keepArchive) { await instance.webdav.delete(webdavPath); - logger.debug(`Archive deleted: ${webdavPath}`); + logger.debug({path: webdavPath}, `Archive deleted: ${webdavPath}`); } return { @@ -510,7 +512,7 @@ export async function siteArchiveExportToPath( await fs.promises.mkdir(path.dirname(zipPath), {recursive: true}); await fs.promises.writeFile(zipPath, result.data); - logger.debug(`Archive saved to: ${zipPath}`); + logger.debug({path: zipPath}, `Archive saved to: ${zipPath}`); return { ...result, @@ -535,7 +537,7 @@ export async function siteArchiveExportToPath( } } - logger.debug(`Archive extracted to: ${outputPath}`); + logger.debug({path: outputPath}, `Archive extracted to: ${outputPath}`); return { ...result, From 035cb592e9add197bb5221cb56d8c82386f23d84 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 15 Jan 2026 20:34:39 -0500 Subject: [PATCH 5/9] change interface of config sources for clarity --- .../src/sources/env-file-source.ts | 51 ++++++++---------- packages/b2c-tooling-sdk/src/cli/config.ts | 7 ++- packages/b2c-tooling-sdk/src/config/index.ts | 10 +++- .../b2c-tooling-sdk/src/config/resolver.ts | 35 ++++++++---- .../src/config/sources/dw-json-source.ts | 21 +++----- .../src/config/sources/mobify-source.ts | 23 ++++---- packages/b2c-tooling-sdk/src/config/types.ts | 39 ++++++++------ .../b2c-tooling-sdk/test/cli/config.test.ts | 15 +++--- .../test/config/resolver.test.ts | 53 ++++++++++++++----- .../test/config/sources.test.ts | 14 +++-- 10 files changed, 154 insertions(+), 114 deletions(-) diff --git a/packages/b2c-plugin-example-config/src/sources/env-file-source.ts b/packages/b2c-plugin-example-config/src/sources/env-file-source.ts index 50c572d1..c41c2c9c 100644 --- a/packages/b2c-plugin-example-config/src/sources/env-file-source.ts +++ b/packages/b2c-plugin-example-config/src/sources/env-file-source.ts @@ -11,7 +11,7 @@ */ import {existsSync, readFileSync} from 'node:fs'; import {join} from 'node:path'; -import type {ConfigSource, NormalizedConfig, ResolveConfigOptions} from '@salesforce/b2c-tooling-sdk/config'; +import type {ConfigSource, ConfigLoadResult, ResolveConfigOptions} from '@salesforce/b2c-tooling-sdk/config'; /** * ConfigSource implementation that loads from .env.b2c files. @@ -53,8 +53,6 @@ import type {ConfigSource, NormalizedConfig, ResolveConfigOptions} from '@salesf export class EnvFileSource implements ConfigSource { readonly name = 'env-file (.env.b2c)'; - private envFilePath?: string; - /** * Load configuration from .env.b2c file. * @@ -64,48 +62,45 @@ export class EnvFileSource implements ConfigSource { * 3. .env.b2c in current working directory * * @param options - Resolution options (startDir used for file lookup) - * @returns Parsed configuration or undefined if file not found + * @returns Parsed configuration and location, or undefined if file not found */ - load(options: ResolveConfigOptions): NormalizedConfig | undefined { + load(options: ResolveConfigOptions): ConfigLoadResult | undefined { // Check for explicit path override via environment variable const envOverride = process.env.B2C_ENV_FILE_PATH; + let envFilePath: string; if (envOverride) { - this.envFilePath = envOverride; + envFilePath = envOverride; } else { const searchDir = options.startDir ?? process.cwd(); - this.envFilePath = join(searchDir, '.env.b2c'); + envFilePath = join(searchDir, '.env.b2c'); } - if (!existsSync(this.envFilePath)) { + if (!existsSync(envFilePath)) { return undefined; } - const content = readFileSync(this.envFilePath, 'utf-8'); + const content = readFileSync(envFilePath, 'utf-8'); const vars = this.parseEnvFile(content); return { - hostname: vars.HOSTNAME, - webdavHostname: vars.WEBDAV_HOSTNAME, - codeVersion: vars.CODE_VERSION, - username: vars.USERNAME, - password: vars.PASSWORD, - clientId: vars.CLIENT_ID, - clientSecret: vars.CLIENT_SECRET, - scopes: vars.SCOPES ? vars.SCOPES.split(',').map((s) => s.trim()) : undefined, - shortCode: vars.SHORT_CODE, - mrtProject: vars.MRT_PROJECT, - mrtEnvironment: vars.MRT_ENVIRONMENT, - mrtApiKey: vars.MRT_API_KEY, + config: { + hostname: vars.HOSTNAME, + webdavHostname: vars.WEBDAV_HOSTNAME, + codeVersion: vars.CODE_VERSION, + username: vars.USERNAME, + password: vars.PASSWORD, + clientId: vars.CLIENT_ID, + clientSecret: vars.CLIENT_SECRET, + scopes: vars.SCOPES ? vars.SCOPES.split(',').map((s) => s.trim()) : undefined, + shortCode: vars.SHORT_CODE, + mrtProject: vars.MRT_PROJECT, + mrtEnvironment: vars.MRT_ENVIRONMENT, + mrtApiKey: vars.MRT_API_KEY, + }, + location: envFilePath, }; } - /** - * Get the path to the env file (for diagnostics). - */ - getPath(): string | undefined { - return this.envFilePath; - } - /** * Parse a .env file format into key-value pairs. * diff --git a/packages/b2c-tooling-sdk/src/cli/config.ts b/packages/b2c-tooling-sdk/src/cli/config.ts index 59e5c34a..67b7599a 100644 --- a/packages/b2c-tooling-sdk/src/cli/config.ts +++ b/packages/b2c-tooling-sdk/src/cli/config.ts @@ -107,7 +107,12 @@ export function loadConfig( // Log source summary for (const source of resolved.sources) { logger.trace( - {source: source.name, path: source.path, fields: source.fieldsContributed}, + { + source: source.name, + location: source.location, + fields: source.fields, + fieldsIgnored: source.fieldsIgnored, + }, `[${source.name}] Contributed fields`, ); } diff --git a/packages/b2c-tooling-sdk/src/config/index.ts b/packages/b2c-tooling-sdk/src/config/index.ts index 5142d76a..369486e4 100644 --- a/packages/b2c-tooling-sdk/src/config/index.ts +++ b/packages/b2c-tooling-sdk/src/config/index.ts @@ -66,11 +66,16 @@ * Implement the {@link ConfigSource} interface to create custom sources: * * ```typescript - * import { ConfigResolver, type ConfigSource } from '@salesforce/b2c-tooling-sdk/config'; + * import { ConfigResolver, type ConfigSource, type ConfigLoadResult } from '@salesforce/b2c-tooling-sdk/config'; * * class MySource implements ConfigSource { * name = 'my-source'; - * load(options) { return { hostname: 'custom.example.com' }; } + * load(options): ConfigLoadResult | undefined { + * return { + * config: { hostname: 'custom.example.com' }, + * location: '/path/to/source', + * }; + * } * } * * const resolver = new ConfigResolver([new MySource()]); @@ -97,6 +102,7 @@ export {resolveConfig, ConfigResolver, createConfigResolver} from './resolver.js export type { NormalizedConfig, ConfigSource, + ConfigLoadResult, ConfigSourceInfo, ConfigResolutionResult, ConfigWarning, diff --git a/packages/b2c-tooling-sdk/src/config/resolver.ts b/packages/b2c-tooling-sdk/src/config/resolver.ts index 93d196ed..8eb484a5 100644 --- a/packages/b2c-tooling-sdk/src/config/resolver.ts +++ b/packages/b2c-tooling-sdk/src/config/resolver.ts @@ -157,33 +157,46 @@ export class ConfigResolver { // Load from each source in order, merging results // Earlier sources have higher priority - later sources only fill in missing values for (const source of this.sources) { - const sourceConfig = source.load(options); - if (sourceConfig) { - const fieldsContributed = getPopulatedFields(sourceConfig); - if (fieldsContributed.length > 0) { - sourceInfos.push({ - name: source.name, - path: source.getPath?.(), - fieldsContributed, - }); - + const result = source.load(options); + if (result) { + const {config: sourceConfig, location} = result; + const fields = getPopulatedFields(sourceConfig); + if (fields.length > 0) { // Capture which credential groups are already claimed BEFORE processing this source // This allows a single source to provide complete credential pairs const claimedGroups = getClaimedCredentialGroups(baseConfig); + // Track which fields are ignored during merge + const fieldsIgnored: (keyof NormalizedConfig)[] = []; + // Merge: source values fill in gaps (don't override existing values) for (const [key, value] of Object.entries(sourceConfig)) { if (value === undefined) continue; - if (baseConfig[key as keyof NormalizedConfig] !== undefined) continue; + + const fieldKey = key as keyof NormalizedConfig; + + // Skip if already set by higher-priority source + if (baseConfig[fieldKey] !== undefined) { + fieldsIgnored.push(fieldKey); + continue; + } // Skip if this field's credential group was already claimed by a higher-priority source // This prevents mixing credentials from different sources if (isFieldInClaimedGroup(key, claimedGroups)) { + fieldsIgnored.push(fieldKey); continue; } (baseConfig as Record)[key] = value; } + + sourceInfos.push({ + name: source.name, + location, + fields, + fieldsIgnored: fieldsIgnored.length > 0 ? fieldsIgnored : undefined, + }); } } } 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 fd29db83..7dca0844 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 @@ -11,7 +11,7 @@ import {loadDwJson} from '../dw-json.js'; import {getPopulatedFields} from '../mapping.js'; import {mapDwJsonToNormalizedConfig} from '../mapping.js'; -import type {ConfigSource, NormalizedConfig, ResolveConfigOptions} from '../types.js'; +import type {ConfigSource, ConfigLoadResult, ResolveConfigOptions} from '../types.js'; import {getLogger} from '../../logging/logger.js'; /** @@ -21,9 +21,8 @@ import {getLogger} from '../../logging/logger.js'; */ export class DwJsonSource implements ConfigSource { readonly name = 'DwJsonSource'; - private lastPath?: string; - load(options: ResolveConfigOptions): NormalizedConfig | undefined { + load(options: ResolveConfigOptions): ConfigLoadResult | undefined { const logger = getLogger(); const result = loadDwJson({ @@ -33,22 +32,14 @@ export class DwJsonSource implements ConfigSource { }); if (!result) { - this.lastPath = undefined; return undefined; } - // Track the actual path from the loaded result - this.lastPath = result.path; + const config = mapDwJsonToNormalizedConfig(result.config); + const fields = getPopulatedFields(config); - const normalized = mapDwJsonToNormalizedConfig(result.config); - const fields = getPopulatedFields(normalized); + logger.trace({location: result.path, fields}, '[DwJsonSource] Loaded config'); - logger.trace({path: this.lastPath, fields}, '[DwJsonSource] Loaded config'); - - return normalized; - } - - getPath(): string | undefined { - return this.lastPath; + return {config, location: result.path}; } } 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 daa2c577..d148523d 100644 --- a/packages/b2c-tooling-sdk/src/config/sources/mobify-source.ts +++ b/packages/b2c-tooling-sdk/src/config/sources/mobify-source.ts @@ -11,7 +11,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import type {ConfigSource, NormalizedConfig, ResolveConfigOptions} from '../types.js'; +import type {ConfigSource, ConfigLoadResult, ResolveConfigOptions} from '../types.js'; import {getLogger} from '../../logging/logger.js'; /** @@ -39,19 +39,17 @@ interface MobifyConfigFile { */ export class MobifySource implements ConfigSource { readonly name = 'MobifySource'; - private lastPath?: string; - load(options: ResolveConfigOptions): NormalizedConfig | undefined { + load(options: ResolveConfigOptions): ConfigLoadResult | undefined { const logger = getLogger(); // Use explicit credentialsFile if provided, otherwise use default path const mobifyPath = options.credentialsFile ?? this.getMobifyPath(options.cloudOrigin); - this.lastPath = mobifyPath; - logger.trace({path: mobifyPath}, '[MobifySource] Checking for credentials file'); + logger.trace({location: mobifyPath}, '[MobifySource] Checking for credentials file'); if (!fs.existsSync(mobifyPath)) { - logger.trace({path: mobifyPath}, '[MobifySource] No credentials file found'); + logger.trace({location: mobifyPath}, '[MobifySource] No credentials file found'); return undefined; } @@ -60,27 +58,24 @@ export class MobifySource implements ConfigSource { const config = JSON.parse(content) as MobifyConfigFile; if (!config.api_key) { - logger.trace({path: mobifyPath}, '[MobifySource] Credentials file found but no api_key present'); + logger.trace({location: mobifyPath}, '[MobifySource] Credentials file found but no api_key present'); return undefined; } - logger.trace({path: mobifyPath, fields: ['mrtApiKey']}, '[MobifySource] Loaded credentials'); + logger.trace({location: mobifyPath, fields: ['mrtApiKey']}, '[MobifySource] Loaded credentials'); return { - mrtApiKey: config.api_key, + config: {mrtApiKey: config.api_key}, + location: mobifyPath, }; } catch (error) { // Invalid JSON or read error const message = error instanceof Error ? error.message : String(error); - logger.trace({path: mobifyPath, error: message}, '[MobifySource] Failed to parse credentials file'); + logger.trace({location: mobifyPath, error: message}, '[MobifySource] Failed to parse credentials file'); return undefined; } } - getPath(): string | undefined { - return this.lastPath; - } - /** * Determines the mobify config file path based on cloud origin. */ diff --git a/packages/b2c-tooling-sdk/src/config/types.ts b/packages/b2c-tooling-sdk/src/config/types.ts index bfa3be71..e63231b5 100644 --- a/packages/b2c-tooling-sdk/src/config/types.ts +++ b/packages/b2c-tooling-sdk/src/config/types.ts @@ -91,10 +91,12 @@ export interface ConfigWarning { export interface ConfigSourceInfo { /** Human-readable name of the source */ name: string; - /** Path to the source file (if applicable) */ - path?: string; - /** Fields that this source contributed to the final config */ - fieldsContributed: (keyof NormalizedConfig)[]; + /** Location of the source (file path, keychain entry, URL, etc.) */ + location?: string; + /** All fields that this source provided values for */ + fields: (keyof NormalizedConfig)[]; + /** Fields that were not used because a higher priority source already provided them */ + fieldsIgnored?: (keyof NormalizedConfig)[]; } /** @@ -142,6 +144,19 @@ export interface ResolveConfigOptions { replaceDefaultSources?: boolean; } +/** + * Result of loading configuration from a source. + */ +export interface ConfigLoadResult { + /** The loaded configuration */ + config: NormalizedConfig; + /** + * Location of the source (for diagnostics). + * May be a file path, keychain entry, URL, or other identifier. + */ + location?: string; +} + /** * A configuration source that can contribute config values. * @@ -150,14 +165,14 @@ export interface ResolveConfigOptions { * * @example * ```typescript - * import type { ConfigSource, NormalizedConfig, ResolveConfigOptions } from '@salesforce/b2c-tooling-sdk/config'; + * import type { ConfigSource, ConfigLoadResult, ResolveConfigOptions } from '@salesforce/b2c-tooling-sdk/config'; * * class MyCustomSource implements ConfigSource { * name = 'my-custom-source'; * - * load(options: ResolveConfigOptions): NormalizedConfig | undefined { + * load(options: ResolveConfigOptions): ConfigLoadResult | undefined { * // Load config from your custom source - * return { hostname: 'example.com' }; + * return { config: { hostname: 'example.com' }, location: '/path/to/config' }; * } * } * ``` @@ -170,15 +185,9 @@ export interface ConfigSource { * Load configuration from this source. * * @param options - Resolution options - * @returns Partial config from this source, or undefined if source not available - */ - load(options: ResolveConfigOptions): NormalizedConfig | undefined; - - /** - * Get the path to this source's file (if applicable). - * Used for diagnostics and source info. + * @returns Config and location from this source, or undefined if source not available */ - getPath?(): string | undefined; + load(options: ResolveConfigOptions): ConfigLoadResult | undefined; } /** diff --git a/packages/b2c-tooling-sdk/test/cli/config.test.ts b/packages/b2c-tooling-sdk/test/cli/config.test.ts index b36bfdfe..d71a9ed7 100644 --- a/packages/b2c-tooling-sdk/test/cli/config.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/config.test.ts @@ -5,7 +5,7 @@ */ import {expect} from 'chai'; import {loadConfig, type LoadConfigOptions, type PluginSources} from '@salesforce/b2c-tooling-sdk/cli'; -import type {ConfigSource, NormalizedConfig} from '@salesforce/b2c-tooling-sdk/config'; +import type {ConfigSource, ConfigLoadResult, NormalizedConfig} from '@salesforce/b2c-tooling-sdk/config'; /** * Mock config source for testing. @@ -14,15 +14,14 @@ class MockConfigSource implements ConfigSource { constructor( public name: string, private config: Partial | undefined, - private path?: string, + private location?: string, ) {} - load() { - return this.config as NormalizedConfig | undefined; - } - - getPath(): string | undefined { - return this.path; + load(): ConfigLoadResult | undefined { + if (this.config === undefined) { + return undefined; + } + return {config: this.config as NormalizedConfig, location: this.location}; } } diff --git a/packages/b2c-tooling-sdk/test/config/resolver.test.ts b/packages/b2c-tooling-sdk/test/config/resolver.test.ts index 361a7031..ea026d65 100644 --- a/packages/b2c-tooling-sdk/test/config/resolver.test.ts +++ b/packages/b2c-tooling-sdk/test/config/resolver.test.ts @@ -8,6 +8,7 @@ import { ConfigResolver, createConfigResolver, type ConfigSource, + type ConfigLoadResult, type NormalizedConfig, type ResolveConfigOptions, } from '@salesforce/b2c-tooling-sdk/config'; @@ -19,15 +20,14 @@ class MockSource implements ConfigSource { constructor( public name: string, private config: NormalizedConfig | undefined, - private path?: string, + private location?: string, ) {} - load(_options: ResolveConfigOptions): NormalizedConfig | undefined { - return this.config; - } - - getPath(): string | undefined { - return this.path; + load(_options: ResolveConfigOptions): ConfigLoadResult | undefined { + if (this.config === undefined) { + return undefined; + } + return {config: this.config, location: this.location}; } } @@ -90,16 +90,16 @@ describe('config/resolver', () => { expect(sources).to.have.length(2); }); - it('tracks source paths when available', () => { + it('tracks source locations when available', () => { const source = new MockSource('test', {hostname: 'example.demandware.net'}, '/path/to/dw.json'); const resolver = new ConfigResolver([source]); const {sources} = resolver.resolve(); - expect(sources[0].path).to.equal('/path/to/dw.json'); + expect(sources[0].location).to.equal('/path/to/dw.json'); }); - it('tracks which fields each source contributed', () => { + it('tracks which fields each source provided', () => { const source1 = new MockSource('first', { hostname: 'example.demandware.net', }); @@ -111,8 +111,37 @@ describe('config/resolver', () => { const {sources} = resolver.resolve(); - expect(sources[0].fieldsContributed).to.deep.equal(['hostname']); - expect(sources[1].fieldsContributed).to.have.members(['clientId', 'clientSecret']); + expect(sources[0].fields).to.deep.equal(['hostname']); + expect(sources[0].fieldsIgnored).to.be.undefined; + expect(sources[1].fields).to.have.members(['clientId', 'clientSecret']); + expect(sources[1].fieldsIgnored).to.be.undefined; + }); + + it('tracks fieldsIgnored when higher priority source provides same fields', () => { + const source1 = new MockSource('higher-priority', { + hostname: 'example.demandware.net', + clientId: 'higher-client', + clientSecret: 'higher-secret', + }); + const source2 = new MockSource('lower-priority', { + clientId: 'lower-client', + clientSecret: 'lower-secret', + }); + const resolver = new ConfigResolver([source1, source2]); + + const {sources, config} = resolver.resolve(); + + // Higher priority source provides and uses all its fields + expect(sources[0].fields).to.have.members(['hostname', 'clientId', 'clientSecret']); + expect(sources[0].fieldsIgnored).to.be.undefined; + + // Lower priority source provides fields but they are ignored + expect(sources[1].fields).to.have.members(['clientId', 'clientSecret']); + expect(sources[1].fieldsIgnored).to.have.members(['clientId', 'clientSecret']); + + // Final config uses higher priority values + expect(config.clientId).to.equal('higher-client'); + expect(config.clientSecret).to.equal('higher-secret'); }); it('skips sources that return undefined', () => { diff --git a/packages/b2c-tooling-sdk/test/config/sources.test.ts b/packages/b2c-tooling-sdk/test/config/sources.test.ts index ea328658..94941919 100644 --- a/packages/b2c-tooling-sdk/test/config/sources.test.ts +++ b/packages/b2c-tooling-sdk/test/config/sources.test.ts @@ -113,7 +113,7 @@ describe('config/sources', () => { expect(config.hostname).to.equal('staging.demandware.net'); }); - it('provides path via getPath', () => { + it('provides location from load result', () => { const dwJsonPath = path.join(tempDir, 'dw.json'); fs.writeFileSync( dwJsonPath, @@ -123,14 +123,13 @@ describe('config/sources', () => { ); const resolver = new ConfigResolver(); - resolver.resolve(); const {sources} = resolver.resolve(); const dwJsonSource = sources.find((s) => s.name === 'DwJsonSource'); // Normalize paths to handle macOS symlinks (/var -> /private/var) const expectedPath = fs.realpathSync(dwJsonPath); - const actualPath = dwJsonSource?.path ? fs.realpathSync(dwJsonSource.path) : undefined; - expect(actualPath).to.equal(expectedPath); + const actualLocation = dwJsonSource?.location ? fs.realpathSync(dwJsonSource.location) : undefined; + expect(actualLocation).to.equal(expectedPath); }); }); @@ -318,7 +317,7 @@ describe('config/sources', () => { } }); - it('provides path via getPath', function () { + it('provides location from load result', function () { const originalHomedir = os.homedir; let canMock = false; try { @@ -343,14 +342,13 @@ describe('config/sources', () => { ); const resolver = new ConfigResolver(); - resolver.resolve(); const {sources} = resolver.resolve(); const mobifySource = sources.find((s) => s.name === 'MobifySource'); // Normalize paths to handle macOS symlinks const expectedPath = fs.realpathSync(mobifyPath); - const actualPath = mobifySource?.path ? fs.realpathSync(mobifySource.path) : undefined; - expect(actualPath).to.equal(expectedPath); + const actualLocation = mobifySource?.location ? fs.realpathSync(mobifySource.location) : undefined; + expect(actualLocation).to.equal(expectedPath); // Restore Object.defineProperty(os, 'homedir', { From 9ebc6becb4c31acdebeb53a0bc7413d7b7e85a9c Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 15 Jan 2026 20:46:59 -0500 Subject: [PATCH 6/9] dw json should not return config on invalid instance name --- packages/b2c-tooling-sdk/src/config/dw-json.ts | 16 +++++++++------- .../b2c-tooling-sdk/test/config/dw-json.test.ts | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/b2c-tooling-sdk/src/config/dw-json.ts b/packages/b2c-tooling-sdk/src/config/dw-json.ts index 93baab3d..c0e6c0bf 100644 --- a/packages/b2c-tooling-sdk/src/config/dw-json.ts +++ b/packages/b2c-tooling-sdk/src/config/dw-json.ts @@ -124,7 +124,7 @@ export function findDwJson(startDir: string = process.cwd()): string | undefined * 2. Config marked as `active: true` * 3. Root-level config */ -function selectConfig(json: DwJsonMultiConfig, instanceName?: string): DwJsonConfig { +function selectConfig(json: DwJsonMultiConfig, instanceName?: string): DwJsonConfig | undefined { const logger = getLogger(); // Single config or no configs array @@ -152,11 +152,9 @@ function selectConfig(json: DwJsonMultiConfig, instanceName?: string): DwJsonCon logger.trace({selection: 'named', instanceName}, `[DwJsonSource] Selected config "${instanceName}" by name`); return found; } - // Instance not found, fall through to other selection methods - logger.trace( - {requestedInstance: instanceName}, - `[DwJsonSource] Named instance "${instanceName}" not found, falling back`, - ); + // Instance explicitly requested but not found - return undefined + logger.trace({requestedInstance: instanceName}, `[DwJsonSource] Named instance "${instanceName}" not found`); + return undefined; } // Find active config @@ -224,8 +222,12 @@ export function loadDwJson(options: LoadDwJsonOptions = {}): LoadDwJsonResult | try { const content = fs.readFileSync(dwJsonPath, 'utf8'); const json = JSON.parse(content) as DwJsonMultiConfig; + const config = selectConfig(json, options.instance); + if (!config) { + return undefined; + } return { - config: selectConfig(json, options.instance), + config, path: dwJsonPath, }; } catch (error) { diff --git a/packages/b2c-tooling-sdk/test/config/dw-json.test.ts b/packages/b2c-tooling-sdk/test/config/dw-json.test.ts index ea7d5a92..e73fc5ee 100644 --- a/packages/b2c-tooling-sdk/test/config/dw-json.test.ts +++ b/packages/b2c-tooling-sdk/test/config/dw-json.test.ts @@ -106,6 +106,21 @@ describe('config/dw-json', () => { expect(result?.config.name).to.equal('staging'); }); + it('returns undefined when requested instance does not exist', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + const multiConfig = { + hostname: 'root.demandware.net', + configs: [ + {name: 'staging', hostname: 'staging.demandware.net'}, + {name: 'production', hostname: 'prod.demandware.net'}, + ], + }; + fs.writeFileSync(dwJsonPath, JSON.stringify(multiConfig)); + + const result = loadDwJson({instance: 'nonexistent'}); + expect(result).to.be.undefined; + }); + it('selects active config when no instance specified', () => { const dwJsonPath = path.join(tempDir, 'dw.json'); const multiConfig = { From a4c1dd89321099e0a73a60d021f6920c34761ecf Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 15 Jan 2026 20:54:07 -0500 Subject: [PATCH 7/9] update docs --- docs/guide/extending.md | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/docs/guide/extending.md b/docs/guide/extending.md index 8d0e0e72..51db6780 100644 --- a/docs/guide/extending.md +++ b/docs/guide/extending.md @@ -151,29 +151,28 @@ export default hook; // src/sources/my-custom-source.ts import type { ConfigSource, - NormalizedConfig, + ConfigLoadResult, ResolveConfigOptions } from '@salesforce/b2c-tooling-sdk/config'; export class MyCustomSource implements ConfigSource { readonly name = 'my-custom-source'; - load(options: ResolveConfigOptions): NormalizedConfig | undefined { + load(options: ResolveConfigOptions): ConfigLoadResult | undefined { // Load config from your custom source // Return undefined if source is not available return { - hostname: 'example.sandbox.us03.dx.commercecloud.salesforce.com', - clientId: 'your-client-id', - clientSecret: 'your-client-secret', - codeVersion: 'version1', + config: { + hostname: 'example.sandbox.us03.dx.commercecloud.salesforce.com', + clientId: 'your-client-id', + clientSecret: 'your-client-secret', + codeVersion: 'version1', + }, + // Location is used for diagnostics - can be a file path, keychain entry, URL, etc. + location: '/path/to/config/source', }; } - - // Optional: return path for diagnostics - getPath(): string | undefined { - return '/path/to/config/source'; - } } ``` From 1a091a578427d1176cf57c2c95debe301474e680 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 15 Jan 2026 21:45:47 -0500 Subject: [PATCH 8/9] config loading options for plugins --- packages/b2c-tooling-sdk/src/cli/base-command.ts | 6 +++++- packages/b2c-tooling-sdk/src/cli/config.ts | 3 +++ packages/b2c-tooling-sdk/src/config/dw-json.ts | 2 ++ packages/b2c-tooling-sdk/src/config/mapping.ts | 1 + packages/b2c-tooling-sdk/src/config/resolver.ts | 13 ++++++++++++- packages/b2c-tooling-sdk/src/config/types.ts | 2 ++ 6 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/b2c-tooling-sdk/src/cli/base-command.ts b/packages/b2c-tooling-sdk/src/cli/base-command.ts index 6bb6ba99..e5462b2f 100644 --- a/packages/b2c-tooling-sdk/src/cli/base-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/base-command.ts @@ -220,13 +220,17 @@ export abstract class BaseCommand extends Command { * - `pluginSourcesAfter`: Low priority sources (fill gaps) */ protected async collectPluginConfigSources(): Promise { + // Access flags that may be defined in subclasses (OAuthCommand, InstanceCommand) + const flags = this.flags as Record; + const hookOptions: ConfigSourcesHookOptions = { instance: this.flags.instance, configPath: this.flags.config, - flags: this.flags as Record, + flags, resolveOptions: { instance: this.flags.instance, configPath: this.flags.config, + accountManagerHost: flags['account-manager-host'] as string | undefined, }, }; diff --git a/packages/b2c-tooling-sdk/src/cli/config.ts b/packages/b2c-tooling-sdk/src/cli/config.ts index 67b7599a..da258af3 100644 --- a/packages/b2c-tooling-sdk/src/cli/config.ts +++ b/packages/b2c-tooling-sdk/src/cli/config.ts @@ -34,6 +34,8 @@ export interface LoadConfigOptions { cloudOrigin?: string; /** Path to custom MRT credentials file (overrides default ~/.mobify) */ credentialsFile?: string; + /** Account Manager hostname for OAuth (passed to plugins for host-specific config) */ + accountManagerHost?: string; } /** @@ -100,6 +102,7 @@ export function loadConfig( hostnameProtection: true, cloudOrigin: options.cloudOrigin, credentialsFile: options.credentialsFile, + accountManagerHost: options.accountManagerHost, sourcesBefore: pluginSources.before, sourcesAfter: pluginSources.after, }); diff --git a/packages/b2c-tooling-sdk/src/config/dw-json.ts b/packages/b2c-tooling-sdk/src/config/dw-json.ts index c0e6c0bf..f190fdba 100644 --- a/packages/b2c-tooling-sdk/src/config/dw-json.ts +++ b/packages/b2c-tooling-sdk/src/config/dw-json.ts @@ -53,6 +53,8 @@ export interface DwJsonConfig { 'secure-server'?: string; /** Allowed authentication methods in priority order */ 'auth-methods'?: AuthMethod[]; + /** Account Manager hostname for OAuth */ + 'account-manager-host'?: string; /** MRT project slug */ mrtProject?: string; /** MRT environment name (e.g., staging, production) */ diff --git a/packages/b2c-tooling-sdk/src/config/mapping.ts b/packages/b2c-tooling-sdk/src/config/mapping.ts index abc12083..8fb47ffc 100644 --- a/packages/b2c-tooling-sdk/src/config/mapping.ts +++ b/packages/b2c-tooling-sdk/src/config/mapping.ts @@ -51,6 +51,7 @@ export function mapDwJsonToNormalizedConfig(json: DwJsonConfig): NormalizedConfi shortCode: json.shortCode || json['short-code'] || json['scapi-shortcode'], instanceName: json.name, authMethods: json['auth-methods'], + accountManagerHost: json['account-manager-host'], mrtProject: json.mrtProject, mrtEnvironment: json.mrtEnvironment, }; diff --git a/packages/b2c-tooling-sdk/src/config/resolver.ts b/packages/b2c-tooling-sdk/src/config/resolver.ts index 8eb484a5..fbbc88c8 100644 --- a/packages/b2c-tooling-sdk/src/config/resolver.ts +++ b/packages/b2c-tooling-sdk/src/config/resolver.ts @@ -154,10 +154,15 @@ export class ConfigResolver { const sourceInfos: ConfigSourceInfo[] = []; const baseConfig: NormalizedConfig = {}; + // Create enriched options that will be updated with accumulated config values. + // This allows later sources (like plugins) to use values discovered by earlier sources (like dw.json). + // CLI-provided options always take precedence over accumulated values. + const enrichedOptions: ResolveConfigOptions = {...options}; + // Load from each source in order, merging results // Earlier sources have higher priority - later sources only fill in missing values for (const source of this.sources) { - const result = source.load(options); + const result = source.load(enrichedOptions); if (result) { const {config: sourceConfig, location} = result; const fields = getPopulatedFields(sourceConfig); @@ -197,6 +202,12 @@ export class ConfigResolver { fields, fieldsIgnored: fieldsIgnored.length > 0 ? fieldsIgnored : undefined, }); + + // Enrich options with accumulated config values for subsequent sources. + // Only set if not already provided via CLI options. + if (!enrichedOptions.accountManagerHost && baseConfig.accountManagerHost) { + enrichedOptions.accountManagerHost = baseConfig.accountManagerHost; + } } } } diff --git a/packages/b2c-tooling-sdk/src/config/types.ts b/packages/b2c-tooling-sdk/src/config/types.ts index e63231b5..b154723b 100644 --- a/packages/b2c-tooling-sdk/src/config/types.ts +++ b/packages/b2c-tooling-sdk/src/config/types.ts @@ -127,6 +127,8 @@ export interface ResolveConfigOptions { cloudOrigin?: string; /** Path to custom MRT credentials file (overrides default ~/.mobify) */ credentialsFile?: string; + /** Account Manager hostname for OAuth (passed to plugins for host-specific config) */ + accountManagerHost?: string; /** * Custom sources to add BEFORE default sources (higher priority). From 147d85780b89fadab2cad39c07776a0f5e6e37b5 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 15 Jan 2026 21:55:09 -0500 Subject: [PATCH 9/9] fix account-manager-host flag default overriding dw.json config The oclif flag had a default value that was always passed as an override to config resolution, preventing dw.json values from being used. Remove the default from the flag and rely on the existing fallback in the accountManagerHost getter. --- packages/b2c-tooling-sdk/src/cli/oauth-command.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/b2c-tooling-sdk/src/cli/oauth-command.ts b/packages/b2c-tooling-sdk/src/cli/oauth-command.ts index d4d64415..a3ff2c30 100644 --- a/packages/b2c-tooling-sdk/src/cli/oauth-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/oauth-command.ts @@ -59,9 +59,8 @@ export abstract class OAuthCommand extends BaseCommand helpGroup: 'AUTH', }), 'account-manager-host': Flags.string({ - description: 'Account Manager hostname for OAuth', + description: `Account Manager hostname for OAuth (default: ${DEFAULT_ACCOUNT_MANAGER_HOST})`, env: 'SFCC_ACCOUNT_MANAGER_HOST', - default: DEFAULT_ACCOUNT_MANAGER_HOST, helpGroup: 'AUTH', }), };