diff --git a/.changeset/add-setup-config-command.md b/.changeset/add-setup-config-command.md new file mode 100644 index 00000000..f4120ac2 --- /dev/null +++ b/.changeset/add-setup-config-command.md @@ -0,0 +1,7 @@ +--- +'@salesforce/b2c-cli': minor +--- + +Add `setup config` command to display resolved configuration with source tracking. + +Shows all configuration values organized by category (Instance, Authentication, SCAPI, MRT) and indicates which source file or environment variable provided each value. Sensitive values are masked by default; use `--unmask` to reveal them. diff --git a/docs/cli/setup.md b/docs/cli/setup.md index ae3483f5..bae0647b 100644 --- a/docs/cli/setup.md +++ b/docs/cli/setup.md @@ -1,10 +1,107 @@ --- -description: Commands for installing AI agent skills for Claude Code, Cursor, Windsurf, and other agentic IDEs. +description: Commands for viewing configuration, installing AI agent skills, and setting up the development environment. --- # Setup Commands -Commands for setting up the development environment with AI agent skills. +Commands for viewing configuration and setting up the development environment. + +## b2c setup config + +Display the resolved configuration from all sources, showing which values are set and where they came from. Useful for debugging configuration issues. + +### Usage + +```bash +b2c setup config [FLAGS] +``` + +### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--unmask` | Show sensitive values unmasked (passwords, secrets, API keys) | `false` | +| `--json` | Output results as JSON | `false` | + +### Examples + +```bash +# Display resolved configuration (sensitive values masked) +b2c setup config + +# Display configuration with sensitive values unmasked +b2c setup config --unmask + +# Output as JSON for scripting +b2c setup config --json + +# Debug configuration with a specific instance +b2c setup config -i staging +``` + +### Output + +The command displays configuration organized by category: + +- **Instance**: hostname, webdavHostname, codeVersion +- **Authentication (Basic)**: username, password +- **Authentication (OAuth)**: clientId, clientSecret, scopes, authMethods, accountManagerHost +- **SCAPI**: shortCode +- **Managed Runtime (MRT)**: mrtProject, mrtEnvironment, mrtApiKey, mrtOrigin +- **Metadata**: instanceName +- **Sources**: List of configuration sources that contributed values + +Each value shows its source in brackets (e.g., `[dw.json]`, `[SFCC_CLIENT_ID]`, `[~/.mobify]`). + +Example output: + +``` +Configuration +──────────────────────────────────────────────────────────── + +Instance + hostname my-sandbox.dx.commercecloud.salesforce.com [DwJsonSource] + webdavHostname - + codeVersion version1 [DwJsonSource] + +Authentication (Basic) + username admin [DwJsonSource] + password admi...REDACTED [DwJsonSource] + +Authentication (OAuth) + clientId my-client-id [password-store] + clientSecret my-c...REDACTED [password-store] + scopes - + authMethods - + accountManagerHost - + +SCAPI + shortCode abc123 [DwJsonSource] + +Managed Runtime (MRT) + mrtProject my-project [MobifySource] + mrtApiKey mrtk...REDACTED [MobifySource] + +Sources +──────────────────────────────────────────────────────────── + 1. DwJsonSource /path/to/project/dw.json + 2. MobifySource /Users/user/.mobify + 3. password-store pass:b2c-cli/_default +``` + +### Sensitive Values + +By default, sensitive fields are masked to prevent accidental exposure: + +- `password` - Basic auth access key +- `clientSecret` - OAuth client secret +- `mrtApiKey` - MRT API key + +Use `--unmask` to reveal the actual values when needed for debugging. + +### See Also + +- [Configuration Guide](/guide/configuration) - How to configure the CLI ## b2c setup skills diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 884a18da..d7fd3025 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -264,6 +264,29 @@ SFCC_AUTH_METHODS=client-credentials,implicit b2c code deploy The CLI will try each method in order until one succeeds. +## Debugging Configuration + +Use `b2c setup config` to view the resolved configuration and see which source provided each value: + +```bash +# Display resolved configuration (sensitive values masked) +b2c setup config + +# Show actual sensitive values +b2c setup config --unmask + +# Output as JSON +b2c setup config --json +``` + +This command helps troubleshoot issues like: +- Verifying which configuration file is being used +- Checking if environment variables are being read +- Understanding credential source priority +- Identifying hostname mismatch protection triggers + +See [setup config](/cli/setup#b2c-setup-config) for full documentation. + ## Next Steps - [CLI Reference](/cli/) - Browse available commands diff --git a/packages/b2c-cli/src/commands/setup/config.ts b/packages/b2c-cli/src/commands/setup/config.ts new file mode 100644 index 00000000..e671a5d9 --- /dev/null +++ b/packages/b2c-cli/src/commands/setup/config.ts @@ -0,0 +1,251 @@ +/* + * 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 {Flags, ux} from '@oclif/core'; +import cliui from 'cliui'; +import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import type {NormalizedConfig, ConfigSourceInfo} from '@salesforce/b2c-tooling-sdk/config'; + +/** + * Sensitive fields that should be masked by default. + */ +const SENSITIVE_FIELDS = new Set(['clientSecret', 'mrtApiKey', 'password']); + +/** + * JSON output structure for the config command. + */ +interface SetupConfigResponse { + config: Record; + sources: ConfigSourceInfo[]; + warnings?: string[]; +} + +/** + * Mask a sensitive value, showing first 4 characters. + * Matches the pattern used in the logger for consistency. + */ +function maskValue(value: string): string { + if (value.length > 10) { + return `${value.slice(0, 4)}...REDACTED`; + } + return 'REDACTED'; +} + +/** + * Check if a field is sensitive and should be masked. + */ +function isSensitiveField(field: string): boolean { + return SENSITIVE_FIELDS.has(field as keyof NormalizedConfig); +} + +/** + * Get the display value for a config field, applying masking if needed. + */ +function getDisplayValue(field: string, value: unknown, unmask: boolean): string { + if (value === undefined || value === null) { + return '-'; + } + + if (Array.isArray(value)) { + return value.length > 0 ? value.join(', ') : '-'; + } + + const strValue = String(value); + + if (!unmask && isSensitiveField(field)) { + return maskValue(strValue); + } + + return strValue; +} + +/** + * Command to display resolved configuration. + */ +export default class SetupConfig extends BaseCommand { + static description = 'Display resolved configuration'; + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --unmask', + '<%= config.bin %> <%= command.id %> --json', + ]; + + static flags = { + ...BaseCommand.baseFlags, + unmask: Flags.boolean({ + description: 'Show sensitive values unmasked (passwords, secrets, API keys)', + default: false, + }), + }; + + async run(): Promise { + const {values, sources, warnings} = this.resolvedConfig; + const unmask = this.flags.unmask; + + // Build output config with masking applied + const outputConfig: Record = {}; + for (const [key, value] of Object.entries(values)) { + if (value !== undefined) { + outputConfig[key] = isSensitiveField(key) && !unmask ? maskValue(String(value)) : value; + } + } + + const result: SetupConfigResponse = { + config: outputConfig, + sources, + warnings: warnings.length > 0 ? warnings.map((w) => w.message) : undefined, + }; + + // JSON mode - just return the data + if (this.jsonEnabled()) { + return result; + } + + // Human-readable output + if (unmask) { + this.warn('Sensitive values are displayed unmasked.'); + } + + this.printConfig(values, sources, unmask); + + // Show warnings + for (const warning of warnings) { + this.warn(warning.message); + } + + return result; + } + + /** + * Build a map of field -> source name for display. + */ + private buildFieldSourceMap(sources: ConfigSourceInfo[]): Map { + const resultMap = new Map(); + + // Process sources in order - first source with a field (not ignored) wins + for (const source of sources) { + for (const field of source.fields) { + if (!source.fieldsIgnored?.includes(field) && !resultMap.has(field)) { + resultMap.set(field, source.name); + } + } + } + + return resultMap; + } + + /** + * Print the configuration in human-readable format. + */ + private printConfig(config: NormalizedConfig, sources: ConfigSourceInfo[], unmask: boolean): void { + const ui = cliui({width: process.stdout.columns || 80}); + const fieldSources = this.buildFieldSourceMap(sources); + + // Header + ui.div({text: 'Configuration', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(60), padding: [0, 0, 0, 0]}); + + // Instance section + this.renderSection( + ui, + 'Instance', + [ + ['hostname', config.hostname], + ['webdavHostname', config.webdavHostname], + ['codeVersion', config.codeVersion], + ], + fieldSources, + unmask, + ); + + // Auth (Basic) section + this.renderSection( + ui, + 'Authentication (Basic)', + [ + ['username', config.username], + ['password', config.password], + ], + fieldSources, + unmask, + ); + + // Auth (OAuth) section + this.renderSection( + ui, + 'Authentication (OAuth)', + [ + ['clientId', config.clientId], + ['clientSecret', config.clientSecret], + ['scopes', config.scopes], + ['authMethods', config.authMethods], + ['accountManagerHost', config.accountManagerHost], + ], + fieldSources, + unmask, + ); + + // SCAPI section + this.renderSection(ui, 'SCAPI', [['shortCode', config.shortCode]], fieldSources, unmask); + + // MRT section + this.renderSection( + ui, + 'Managed Runtime (MRT)', + [ + ['mrtProject', config.mrtProject], + ['mrtEnvironment', config.mrtEnvironment], + ['mrtApiKey', config.mrtApiKey], + ['mrtOrigin', config.mrtOrigin], + ], + fieldSources, + unmask, + ); + + // Metadata section + this.renderSection(ui, 'Metadata', [['instanceName', config.instanceName]], fieldSources, unmask); + + // Sources section + if (sources.length > 0) { + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: 'Sources', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(60), padding: [0, 0, 0, 0]}); + + for (const [index, source] of sources.entries()) { + ui.div({text: ` ${index + 1}. ${source.name}`, width: 24}, {text: source.location || '-'}); + } + } + + ux.stdout(ui.toString()); + } + + /** + * Render a configuration section with fields. + */ + private renderSection( + ui: ReturnType, + title: string, + fields: [string, unknown][], + fieldSources: Map, + unmask: boolean, + ): void { + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: title, padding: [0, 0, 0, 0]}); + + for (const [field, value] of fields) { + const displayValue = getDisplayValue(field, value, unmask); + const source = fieldSources.get(field); + + ui.div( + {text: ` ${field}`, width: 22}, + {text: displayValue, width: 40}, + {text: source ? `[${source}]` : '', padding: [0, 0, 0, 2]}, + ); + } + } +} diff --git a/packages/b2c-cli/test/commands/setup/config.test.ts b/packages/b2c-cli/test/commands/setup/config.test.ts new file mode 100644 index 00000000..d1d8f877 --- /dev/null +++ b/packages/b2c-cli/test/commands/setup/config.test.ts @@ -0,0 +1,368 @@ +/* + * 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 sinon from 'sinon'; + +import SetupConfig from '../../../src/commands/setup/config.js'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import type {ConfigSourceInfo, NormalizedConfig} from '@salesforce/b2c-tooling-sdk/config'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +function stubCommandConfigAndLogger(command: any): void { + Object.defineProperty(command, 'config', { + value: { + findConfigFile: () => ({ + read: () => ({}), + }), + }, + configurable: true, + }); + + Object.defineProperty(command, 'logger', { + value: {info() {}, debug() {}, warn() {}, error() {}}, + configurable: true, + }); +} + +function stubJsonEnabled(command: any, enabled: boolean): void { + command.jsonEnabled = () => enabled; +} + +/** + * Stub the resolved config with custom values and sources. + */ +function stubResolvedConfig( + command: any, + values: Partial, + sources: ConfigSourceInfo[] = [], + warnings: Array<{code: string; message: string}> = [], +): void { + Object.defineProperty(command, 'resolvedConfig', { + get: () => ({ + values, + warnings, + sources, + }), + configurable: true, + }); +} + +/** + * Unit tests for setup config command. + */ +describe('setup config', () => { + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + describe('command structure', () => { + it('should have correct description', () => { + expect(SetupConfig.description).to.be.a('string'); + expect(SetupConfig.description).to.include('configuration'); + }); + + it('should enable JSON flag', () => { + expect(SetupConfig.enableJsonFlag).to.be.true; + }); + + it('should have unmask flag', () => { + expect(SetupConfig.flags).to.have.property('unmask'); + }); + }); + + describe('masking', () => { + it('should mask password by default', async () => { + const command = new SetupConfig([], {} as any); + (command as any).flags = {unmask: false}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolvedConfig(command, { + hostname: 'test.example.com', + password: 'my-secret-password-123', + }); + + const result = await command.run(); + + expect(result.config.hostname).to.equal('test.example.com'); + expect(result.config.password).to.equal('my-s...REDACTED'); + }); + + it('should mask clientSecret by default', async () => { + const command = new SetupConfig([], {} as any); + (command as any).flags = {unmask: false}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolvedConfig(command, { + clientId: 'my-client-id', + clientSecret: 'super-secret-client-secret', + }); + + const result = await command.run(); + + expect(result.config.clientId).to.equal('my-client-id'); + expect(result.config.clientSecret).to.equal('supe...REDACTED'); + }); + + it('should mask mrtApiKey by default', async () => { + const command = new SetupConfig([], {} as any); + (command as any).flags = {unmask: false}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolvedConfig(command, { + mrtProject: 'my-project', + mrtApiKey: 'mrt-api-key-12345678', + }); + + const result = await command.run(); + + expect(result.config.mrtProject).to.equal('my-project'); + expect(result.config.mrtApiKey).to.equal('mrt-...REDACTED'); + }); + + it('should show REDACTED for short secrets', async () => { + const command = new SetupConfig([], {} as any); + (command as any).flags = {unmask: false}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolvedConfig(command, { + password: 'short', + }); + + const result = await command.run(); + + expect(result.config.password).to.equal('REDACTED'); + }); + + it('should unmask values when --unmask flag is provided', async () => { + const command = new SetupConfig([], {} as any); + (command as any).flags = {unmask: true}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + const warnings: string[] = []; + (command as any).warn = (msg: string) => { + warnings.push(msg); + }; + + stubResolvedConfig(command, { + hostname: 'test.example.com', + password: 'my-secret-password-123', + clientSecret: 'super-secret-client-secret', + mrtApiKey: 'mrt-api-key-12345678', + }); + + const result = await command.run(); + + expect(result.config.password).to.equal('my-secret-password-123'); + expect(result.config.clientSecret).to.equal('super-secret-client-secret'); + expect(result.config.mrtApiKey).to.equal('mrt-api-key-12345678'); + }); + }); + + describe('output formatting', () => { + it('should return structured JSON in --json mode', async () => { + const command = new SetupConfig([], {} as any); + (command as any).flags = {unmask: false}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolvedConfig( + command, + { + hostname: 'test.example.com', + username: 'admin', + }, + [ + { + name: 'dw.json', + location: '/path/to/dw.json', + fields: ['hostname', 'username'], + }, + ], + ); + + const result = await command.run(); + + expect(result).to.have.property('config'); + expect(result).to.have.property('sources'); + expect(result.config.hostname).to.equal('test.example.com'); + expect(result.sources).to.have.length(1); + expect(result.sources[0].name).to.equal('dw.json'); + }); + + it('should display warnings if present', async () => { + const command = new SetupConfig([], {} as any); + (command as any).flags = {unmask: false}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolvedConfig( + command, + {hostname: 'test.example.com'}, + [], + [{code: 'HOSTNAME_MISMATCH', message: 'Hostname mismatch detected'}], + ); + + const result = await command.run(); + + expect(result.warnings).to.deep.equal(['Hostname mismatch detected']); + }); + + it('should handle empty config gracefully', async () => { + const command = new SetupConfig([], {} as any); + (command as any).flags = {unmask: false}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolvedConfig(command, {}); + + const result = await command.run(); + + expect(result.config).to.deep.equal({}); + expect(result.sources).to.deep.equal([]); + }); + + it('should handle array values (scopes)', async () => { + const command = new SetupConfig([], {} as any); + (command as any).flags = {unmask: false}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolvedConfig(command, { + scopes: ['sfcc.products', 'sfcc.orders'], + }); + + const result = await command.run(); + + expect(result.config.scopes).to.deep.equal(['sfcc.products', 'sfcc.orders']); + }); + }); + + describe('source tracking', () => { + it('should track which source provided each field', async () => { + const command = new SetupConfig([], {} as any); + (command as any).flags = {unmask: false}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolvedConfig( + command, + { + hostname: 'test.example.com', + username: 'admin', + mrtApiKey: 'mrt-api-key-12345678', + }, + [ + { + name: 'dw.json', + location: '/path/to/dw.json', + fields: ['hostname', 'username'], + }, + { + name: '~/.mobify', + location: '/home/user/.mobify', + fields: ['mrtApiKey'], + }, + ], + ); + + const result = await command.run(); + + expect(result.sources).to.have.length(2); + expect(result.sources[0].fields).to.include('hostname'); + expect(result.sources[1].fields).to.include('mrtApiKey'); + }); + + it('should handle fieldsIgnored correctly', async () => { + const command = new SetupConfig([], {} as any); + (command as any).flags = {unmask: false}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolvedConfig( + command, + { + hostname: 'test.example.com', + shortCode: 'abc123', + }, + [ + { + name: 'CLI flags', + location: undefined, + fields: ['hostname'], + }, + { + name: 'dw.json', + location: '/path/to/dw.json', + fields: ['hostname', 'shortCode'], + fieldsIgnored: ['hostname'], + }, + ], + ); + + const result = await command.run(); + + // hostname should come from CLI flags (first source without ignore) + // shortCode should come from dw.json + expect(result.sources[0].fields).to.include('hostname'); + expect(result.sources[1].fieldsIgnored).to.include('hostname'); + }); + }); + + describe('human-readable output', () => { + it('should display formatted info in non-JSON mode', async () => { + const command = new SetupConfig([], {} as any); + (command as any).flags = {unmask: false}; + stubJsonEnabled(command, false); + stubCommandConfigAndLogger(command); + + const logs: string[] = []; + command.log = (msg?: string) => { + if (msg !== undefined) logs.push(msg); + }; + + stubResolvedConfig( + command, + { + hostname: 'test.example.com', + username: 'admin', + password: 'secret-password-12345', + }, + [ + { + name: 'dw.json', + location: '/path/to/dw.json', + fields: ['hostname', 'username', 'password'], + }, + ], + ); + + const result = await command.run(); + + expect(result).to.have.property('config'); + expect(result.config.hostname).to.equal('test.example.com'); + }); + + it('should show unmask warning when --unmask is used', async () => { + const command = new SetupConfig([], {} as any); + (command as any).flags = {unmask: true}; + stubJsonEnabled(command, false); + stubCommandConfigAndLogger(command); + + const warnings: string[] = []; + (command as any).warn = (msg: string) => { + warnings.push(msg); + }; + + stubResolvedConfig(command, {hostname: 'test.example.com'}); + + await command.run(); + + expect(warnings).to.include('Sensitive values are displayed unmasked.'); + }); + }); +}); diff --git a/plugins/b2c-cli/skills/b2c-config/SKILL.md b/plugins/b2c-cli/skills/b2c-config/SKILL.md new file mode 100644 index 00000000..83dcf320 --- /dev/null +++ b/plugins/b2c-cli/skills/b2c-config/SKILL.md @@ -0,0 +1,109 @@ +--- +name: b2c-config +description: Using the b2c CLI to view and debug current configuration for instance, SCAPI, oauth and MRT settings. +--- + +# B2C Config Skill + +Use the `b2c setup config` command to view the resolved configuration and understand where each value comes from. This is essential for debugging configuration issues and verifying that the CLI is using the correct settings. + +## When to Use + +Use `b2c setup config` when you need to: + +- Verify which configuration file is being used +- Check if environment variables are being read correctly +- Debug authentication failures by confirming credentials are loaded +- Understand credential source priority (dw.json vs env vars vs plugins) +- Identify hostname mismatch protection issues +- Verify MRT API key is loaded from ~/.mobify + +## Examples + +### View Current Configuration + +```bash +# Display resolved configuration (sensitive values masked by default) +b2c setup config + +# View configuration for a specific instance from dw.json +b2c setup config -i staging + +# View configuration with a specific config file +b2c setup config --config /path/to/dw.json +``` + +### Debug Sensitive Values + +```bash +# Show actual passwords, secrets, and API keys (use with caution) +b2c setup config --unmask +``` + +### JSON Output for Scripting + +```bash +# Output as JSON for parsing in scripts +b2c setup config --json + +# Pretty-print with jq +b2c setup config --json | jq '.config' + +# Check which sources are loaded +b2c setup config --json | jq '.sources' +``` + +## Understanding the Output + +The command displays configuration organized by category: + +- **Instance**: hostname, webdavHostname, codeVersion +- **Authentication (Basic)**: username, password (for WebDAV) +- **Authentication (OAuth)**: clientId, clientSecret, scopes, authMethods +- **SCAPI**: shortCode +- **Managed Runtime (MRT)**: mrtProject, mrtEnvironment, mrtApiKey +- **Metadata**: instanceName (from multi-instance configs) +- **Sources**: List of all configuration sources that were loaded + +Each value shows its source in brackets: +- `[DwJsonSource]` - Value from dw.json file +- `[MobifySource]` - Value from ~/.mobify file +- `[SFCC_*]` - Value from environment variable +- `[password-store]` - Value from a credential plugin + +## Configuration Priority + +Values are resolved with this priority (highest to lowest): + +1. CLI flags and environment variables +2. Plugin sources (high priority) +3. dw.json file +4. ~/.mobify file (MRT API key only) +5. Plugin sources (low priority) +6. package.json b2c key + +When troubleshooting, check the source column to understand which configuration is taking precedence. + +## Common Issues + +### Missing Values + +If a value shows `-`, it means no source provided that configuration. Check: +- Is the field spelled correctly in dw.json? +- Is the environment variable set? +- Does the plugin provide that value? + +### Wrong Source Taking Precedence + +If a value comes from an unexpected source: +- Higher priority sources override lower ones +- Credential groups (username+password, clientId+clientSecret) are atomic +- Hostname mismatch protection may discard values + +### Sensitive Values Masked + +By default, passwords and secrets show partial values like `admi...REDACTED`. Use `--unmask` to see full values when debugging authentication issues. + +## More Commands + +See `b2c setup --help` for other setup commands including `b2c setup skills` for AI agent skill installation.