From aa8e8906f38948aecccc923ebcf0ce19f89f6686 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 30 Jan 2026 21:05:23 -0500 Subject: [PATCH 1/7] Add instance management commands - Rename `setup config` to `setup inspect` - Add `setup instance list` to view configured instances - Add `setup instance create` with interactive prompts - Add `setup instance remove` to delete instances - Add `setup instance set-active` to set default instance Extends ConfigSource interface with optional instance management methods and implements CRUD operations in DwJsonSource for managing the dw.json configs array. --- .changeset/instance-management.md | 12 + docs/cli/setup.md | 192 ++++++++++++- .../commands/setup/{config.ts => inspect.ts} | 12 +- .../src/commands/setup/instance/create.ts | 263 +++++++++++++++++ .../src/commands/setup/instance/list.ts | 88 ++++++ .../src/commands/setup/instance/remove.ts | 98 +++++++ .../src/commands/setup/instance/set-active.ts | 85 ++++++ .../setup/{config.test.ts => inspect.test.ts} | 40 +-- .../b2c-tooling-sdk/src/config/dw-json.ts | 216 ++++++++++++++ packages/b2c-tooling-sdk/src/config/index.ts | 28 +- .../src/config/instance-manager.ts | 204 ++++++++++++++ .../b2c-tooling-sdk/src/config/mapping.ts | 81 ++++++ .../src/config/sources/dw-json-source.ts | 90 +++++- packages/b2c-tooling-sdk/src/config/types.ts | 86 ++++++ .../test/config/dw-json.test.ts | 266 +++++++++++++++++- .../test/config/sources.test.ts | 124 +++++++- 16 files changed, 1845 insertions(+), 40 deletions(-) create mode 100644 .changeset/instance-management.md rename packages/b2c-cli/src/commands/setup/{config.ts => inspect.ts} (95%) create mode 100644 packages/b2c-cli/src/commands/setup/instance/create.ts create mode 100644 packages/b2c-cli/src/commands/setup/instance/list.ts create mode 100644 packages/b2c-cli/src/commands/setup/instance/remove.ts create mode 100644 packages/b2c-cli/src/commands/setup/instance/set-active.ts rename packages/b2c-cli/test/commands/setup/{config.test.ts => inspect.test.ts} (90%) create mode 100644 packages/b2c-tooling-sdk/src/config/instance-manager.ts diff --git a/.changeset/instance-management.md b/.changeset/instance-management.md new file mode 100644 index 00000000..d69758df --- /dev/null +++ b/.changeset/instance-management.md @@ -0,0 +1,12 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-tooling-sdk': minor +--- + +Add instance management commands for configuring B2C Commerce instances. + +- Renamed `setup config` to `setup inspect` +- Added `setup instance list` to view all configured instances +- Added `setup instance create` to add new instance configurations +- Added `setup instance remove` to delete instance configurations +- Added `setup instance set-active` to set the default instance diff --git a/docs/cli/setup.md b/docs/cli/setup.md index bae0647b..6f90dc57 100644 --- a/docs/cli/setup.md +++ b/docs/cli/setup.md @@ -6,14 +6,14 @@ description: Commands for viewing configuration, installing AI agent skills, and Commands for viewing configuration and setting up the development environment. -## b2c setup config +## b2c setup inspect 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] +b2c setup inspect [FLAGS] ``` ### Flags @@ -27,16 +27,16 @@ b2c setup config [FLAGS] ```bash # Display resolved configuration (sensitive values masked) -b2c setup config +b2c setup inspect # Display configuration with sensitive values unmasked -b2c setup config --unmask +b2c setup inspect --unmask # Output as JSON for scripting -b2c setup config --json +b2c setup inspect --json # Debug configuration with a specific instance -b2c setup config -i staging +b2c setup inspect -i staging ``` ### Output @@ -103,6 +103,186 @@ Use `--unmask` to reveal the actual values when needed for debugging. - [Configuration Guide](/guide/configuration) - How to configure the CLI +## b2c setup instance list + +List all configured B2C Commerce instances from dw.json. + +### Usage + +```bash +b2c setup instance list [FLAGS] +``` + +### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--json` | Output results as JSON | `false` | + +### Examples + +```bash +# List all configured instances +b2c setup instance list + +# Output as JSON +b2c setup instance list --json +``` + +### Output + +The command displays a table of configured instances: + +``` +Instances +──────────────────────────────────────────────────────────── +Name Hostname Source Active +production prod.demandware.net DwJsonSource +staging staging.demandware.net DwJsonSource ✓ +development dev.demandware.net DwJsonSource +``` + +## b2c setup instance create + +Create a new B2C Commerce instance configuration in dw.json. + +### Usage + +```bash +b2c setup instance create [NAME] [FLAGS] +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `NAME` | Instance name | Yes (or prompted) | + +### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--hostname`, `-s` | B2C instance hostname | Prompted | +| `--username` | WebDAV username | | +| `--password` | WebDAV password | Prompted if username set | +| `--client-id` | OAuth client ID | | +| `--client-secret` | OAuth client secret | Prompted if client-id set | +| `--code-version` | Code version | | +| `--active` | Set as active instance | `false` | +| `--force` | Non-interactive mode | `false` | +| `--json` | Output results as JSON | `false` | + +### Examples + +```bash +# Interactive mode (prompts for all values) +b2c setup instance create staging + +# Create with hostname +b2c setup instance create staging --hostname staging.example.com + +# Create and set as active +b2c setup instance create staging --hostname staging.example.com --active + +# Non-interactive mode (CI/CD) +b2c setup instance create staging --hostname staging.example.com --username admin --password secret --force +``` + +### Interactive Mode + +When run without `--force`, the command provides an interactive experience: + +1. Prompts for instance name (if not provided) +2. Prompts for hostname (if not provided) +3. Prompts for authentication type (Basic, OAuth, Both, or Skip) +4. Prompts for credentials based on selection +5. Asks whether to set as active instance +6. Shows summary and confirms before creating + +## b2c setup instance remove + +Remove a B2C Commerce instance configuration from dw.json. + +### Usage + +```bash +b2c setup instance remove NAME [FLAGS] +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `NAME` | Instance name to remove | Yes | + +### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--force` | Skip confirmation prompt | `false` | +| `--json` | Output results as JSON | `false` | + +### Examples + +```bash +# Remove with confirmation +b2c setup instance remove staging + +# Remove without confirmation +b2c setup instance remove staging --force +``` + +## b2c setup instance set-active + +Set a B2C Commerce instance as the default (active) instance. + +### Usage + +```bash +b2c setup instance set-active NAME [FLAGS] +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `NAME` | Instance name to set as active | Yes | + +### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--json` | Output results as JSON | `false` | + +### Examples + +```bash +# Set staging as the active instance +b2c setup instance set-active staging + +# Set production as active +b2c setup instance set-active production +``` + +### How Active Instance Works + +The active instance is used as the default when no `--instance` or `-i` flag is provided to other commands. This allows you to work with multiple instances without specifying which one to use each time. + +Example workflow: + +```bash +# Configure multiple instances +b2c setup instance create staging --hostname staging.example.com +b2c setup instance create production --hostname prod.example.com + +# Set staging as active +b2c setup instance set-active staging + +# Commands now use staging by default +b2c code list # Uses staging +b2c code list -i production # Uses production +``` + ## b2c setup skills Install agent skills from the B2C Developer Tooling project to AI-powered IDEs. diff --git a/packages/b2c-cli/src/commands/setup/config.ts b/packages/b2c-cli/src/commands/setup/inspect.ts similarity index 95% rename from packages/b2c-cli/src/commands/setup/config.ts rename to packages/b2c-cli/src/commands/setup/inspect.ts index d671232d..c13193b4 100644 --- a/packages/b2c-cli/src/commands/setup/config.ts +++ b/packages/b2c-cli/src/commands/setup/inspect.ts @@ -15,9 +15,9 @@ import {withDocs} from '../../i18n/index.js'; const SENSITIVE_FIELDS = new Set(['clientSecret', 'mrtApiKey', 'password']); /** - * JSON output structure for the config command. + * JSON output structure for the inspect command. */ -interface SetupConfigResponse { +interface SetupInspectResponse { config: Record; sources: ConfigSourceInfo[]; warnings?: string[]; @@ -65,8 +65,8 @@ function getDisplayValue(field: string, value: unknown, unmask: boolean): string /** * Command to display resolved configuration. */ -export default class SetupConfig extends BaseCommand { - static description = withDocs('Display resolved configuration', '/cli/setup.html#b2c-setup-config'); +export default class SetupInspect extends BaseCommand { + static description = withDocs('Display resolved configuration', '/cli/setup.html#b2c-setup-inspect'); static enableJsonFlag = true; @@ -84,7 +84,7 @@ export default class SetupConfig extends BaseCommand { }), }; - async run(): Promise { + async run(): Promise { const {values, sources, warnings} = this.resolvedConfig; const unmask = this.flags.unmask; @@ -96,7 +96,7 @@ export default class SetupConfig extends BaseCommand { } } - const result: SetupConfigResponse = { + const result: SetupInspectResponse = { config: outputConfig, sources, warnings: warnings.length > 0 ? warnings.map((w) => w.message) : undefined, diff --git a/packages/b2c-cli/src/commands/setup/instance/create.ts b/packages/b2c-cli/src/commands/setup/instance/create.ts new file mode 100644 index 00000000..f4e92b8c --- /dev/null +++ b/packages/b2c-cli/src/commands/setup/instance/create.ts @@ -0,0 +1,263 @@ +/* + * 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 {Args, Flags, ux} from '@oclif/core'; +import {input, password, confirm, select} from '@inquirer/prompts'; +import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {DwJsonSource, type NormalizedConfig} from '@salesforce/b2c-tooling-sdk/config'; +import {withDocs} from '../../../i18n/index.js'; + +/** + * JSON output structure for the create command. + */ +interface InstanceCreateResponse { + name: string; + hostname: string; + created: boolean; + active?: boolean; +} + +/** + * Auth type selection values. + */ +type AuthType = 'basic' | 'both' | 'none' | 'oauth'; + +/** + * Create a new B2C Commerce instance configuration. + */ +export default class SetupInstanceCreate extends BaseCommand { + static args = { + name: Args.string({ + description: 'Instance name', + }), + }; + + static description = withDocs( + 'Create a new B2C Commerce instance configuration', + '/cli/setup.html#b2c-setup-instance-create', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> staging', + '<%= config.bin %> <%= command.id %> staging --hostname staging.example.com', + '<%= config.bin %> <%= command.id %> staging --hostname staging.example.com --active', + '<%= config.bin %> <%= command.id %> staging --hostname staging.example.com --username admin --force', + ]; + + static flags = { + ...BaseCommand.baseFlags, + hostname: Flags.string({ + char: 's', + description: 'B2C instance hostname', + }), + username: Flags.string({ + description: 'WebDAV username', + }), + password: Flags.string({ + description: 'WebDAV password', + }), + 'client-id': Flags.string({ + description: 'OAuth client ID', + }), + 'client-secret': Flags.string({ + description: 'OAuth client secret', + }), + 'code-version': Flags.string({ + description: 'Code version', + }), + active: Flags.boolean({ + description: 'Set as active instance', + default: false, + }), + force: Flags.boolean({ + description: 'Non-interactive mode (fail if required flags missing)', + default: false, + }), + }; + + async run(): Promise { + const source = new DwJsonSource(); + const force = this.flags.force; + + // Get or prompt for instance name + let name = this.args.name; + if (!name) { + if (force) { + this.error('Instance name is required in non-interactive mode. Provide as argument.'); + } + name = await input({ + message: 'Enter instance name:', + validate: (v) => (v.trim() ? true : 'Instance name is required'), + }); + } + + // Check if instance already exists + const existingInstances = source.listInstances({configPath: this.flags.config}); + if (existingInstances.some((i) => i.name === name)) { + this.error(`Instance "${name}" already exists. Use a different name.`); + } + + // Get or prompt for hostname + let hostname = this.flags.hostname; + if (!hostname) { + if (force) { + this.error('Hostname is required in non-interactive mode. Use --hostname flag.'); + } + hostname = await input({ + message: 'Enter B2C instance hostname:', + validate: (v) => (v.trim() ? true : 'Hostname is required'), + }); + } + + // Build config + const config: Partial = { + hostname, + }; + + // Code version + if (this.flags['code-version']) { + config.codeVersion = this.flags['code-version']; + } + + // Handle authentication - in non-interactive mode, use provided flags + if (force) { + // Basic auth + if (this.flags.username) { + config.username = this.flags.username; + if (!this.flags.password) { + this.error('Password is required when username is provided in non-interactive mode.'); + } + config.password = this.flags.password; + } + + // OAuth + if (this.flags['client-id']) { + config.clientId = this.flags['client-id']; + if (!this.flags['client-secret']) { + this.error('Client secret is required when client ID is provided in non-interactive mode.'); + } + config.clientSecret = this.flags['client-secret']; + } + } else { + // Interactive mode - prompt for auth type and credentials + const authType = await select({ + message: 'Configure authentication:', + choices: [ + {name: 'Basic (username/password)', value: 'basic'}, + {name: 'OAuth (client credentials)', value: 'oauth'}, + {name: 'Both', value: 'both'}, + {name: 'Skip for now', value: 'none'}, + ], + }); + + // Basic auth + if (authType === 'basic' || authType === 'both') { + config.username = + this.flags.username || + (await input({ + message: 'Enter WebDAV username:', + validate: (v) => (v.trim() ? true : 'Username is required'), + })); + + config.password = + this.flags.password || + (await password({ + message: 'Enter WebDAV password:', + validate: (v) => (v.trim() ? true : 'Password is required'), + })); + } + + // OAuth + if (authType === 'oauth' || authType === 'both') { + config.clientId = + this.flags['client-id'] || + (await input({ + message: 'Enter OAuth client ID:', + validate: (v) => (v.trim() ? true : 'Client ID is required'), + })); + + config.clientSecret = + this.flags['client-secret'] || + (await password({ + message: 'Enter OAuth client secret:', + validate: (v) => (v.trim() ? true : 'Client secret is required'), + })); + } + } + + // Determine if this should be the active instance + let setActive = this.flags.active; + if (!force && !setActive && existingInstances.length > 0) { + setActive = await confirm({ + message: 'Set as active instance?', + default: false, + }); + } else if (existingInstances.length === 0) { + // If this is the first instance, make it active by default + setActive = true; + } + + // Show summary and confirm in interactive mode + if (!force) { + ux.stdout(''); + ux.stdout('Instance configuration:'); + ux.stdout(` Name: ${name}`); + ux.stdout(` Hostname: ${hostname}`); + if (config.codeVersion) { + ux.stdout(` Code Version: ${config.codeVersion}`); + } + if (config.username) { + ux.stdout(` Auth: Basic (${config.username})`); + } + if (config.clientId) { + ux.stdout(` Auth: OAuth (${config.clientId})`); + } + if (setActive) { + ux.stdout(' Active: Yes'); + } + ux.stdout(''); + + const proceed = await confirm({ + message: 'Create this instance?', + default: true, + }); + + if (!proceed) { + ux.stdout('Instance creation cancelled.'); + return { + name, + hostname, + created: false, + }; + } + } + + // Create the instance + source.createInstance({ + name, + config, + setActive, + configPath: this.flags.config, + }); + + const result: InstanceCreateResponse = { + name, + hostname, + created: true, + active: setActive, + }; + + if (!this.jsonEnabled()) { + ux.stdout(`Instance "${name}" created successfully.`); + if (setActive) { + ux.stdout(`"${name}" is now the active instance.`); + } + } + + return result; + } +} diff --git a/packages/b2c-cli/src/commands/setup/instance/list.ts b/packages/b2c-cli/src/commands/setup/instance/list.ts new file mode 100644 index 00000000..49930076 --- /dev/null +++ b/packages/b2c-cli/src/commands/setup/instance/list.ts @@ -0,0 +1,88 @@ +/* + * 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 {ux} from '@oclif/core'; +import {BaseCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {DwJsonSource, type InstanceInfo} from '@salesforce/b2c-tooling-sdk/config'; +import {withDocs} from '../../../i18n/index.js'; + +/** + * Table columns for instance listing. + */ +const COLUMNS: Record> = { + name: { + header: 'Name', + get: (i) => i.name, + }, + hostname: { + header: 'Hostname', + get: (i) => i.hostname || '-', + }, + source: { + header: 'Source', + get: (i) => i.source, + }, + active: { + header: 'Active', + get: (i) => (i.active ? '✓' : ''), + }, +}; + +const DEFAULT_COLUMNS = ['name', 'hostname', 'source', 'active']; + +/** + * JSON output structure for the list command. + */ +interface InstanceListResponse { + instances: InstanceInfo[]; + count: number; +} + +/** + * List all configured B2C Commerce instances. + */ +export default class SetupInstanceList extends BaseCommand { + static description = withDocs('List configured B2C Commerce instances', '/cli/setup.html#b2c-setup-instance-list'); + + static enableJsonFlag = true; + + static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --json']; + + static flags = { + ...BaseCommand.baseFlags, + }; + + async run(): Promise { + // Get instances from all sources that support listing + const source = new DwJsonSource(); + const instances = source.listInstances({ + configPath: this.flags.config, + }); + + const result: InstanceListResponse = { + instances, + count: instances.length, + }; + + // In JSON mode, just return the data + if (this.jsonEnabled()) { + return result; + } + + // Human-readable table output + if (instances.length === 0) { + ux.stdout('No instances configured. Use `b2c setup instance create` to add one.'); + return result; + } + + ux.stdout(''); + ux.stdout('Instances'); + ux.stdout('─'.repeat(60)); + + createTable(COLUMNS).render(instances, DEFAULT_COLUMNS); + + return result; + } +} diff --git a/packages/b2c-cli/src/commands/setup/instance/remove.ts b/packages/b2c-cli/src/commands/setup/instance/remove.ts new file mode 100644 index 00000000..26fe56b1 --- /dev/null +++ b/packages/b2c-cli/src/commands/setup/instance/remove.ts @@ -0,0 +1,98 @@ +/* + * 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 {Args, Flags, ux} from '@oclif/core'; +import {confirm} from '@inquirer/prompts'; +import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {DwJsonSource} from '@salesforce/b2c-tooling-sdk/config'; +import {withDocs} from '../../../i18n/index.js'; + +/** + * JSON output structure for the remove command. + */ +interface InstanceRemoveResponse { + name: string; + removed: boolean; +} + +/** + * Remove a B2C Commerce instance configuration. + */ +export default class SetupInstanceRemove extends BaseCommand { + static args = { + name: Args.string({ + description: 'Instance name to remove', + required: true, + }), + }; + + static description = withDocs( + 'Remove a B2C Commerce instance configuration', + '/cli/setup.html#b2c-setup-instance-remove', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> staging', + '<%= config.bin %> <%= command.id %> staging --force', + ]; + + static flags = { + ...BaseCommand.baseFlags, + force: Flags.boolean({ + description: 'Skip confirmation prompt', + default: false, + }), + }; + + async run(): Promise { + const source = new DwJsonSource(); + const name = this.args.name; + + // Check if instance exists + const instances = source.listInstances({configPath: this.flags.config}); + const instance = instances.find((i) => i.name === name); + + if (!instance) { + const availableNames = instances.map((i) => i.name).join(', '); + if (availableNames) { + this.error(`Instance "${name}" not found. Available instances: ${availableNames}`); + } else { + this.error(`Instance "${name}" not found. No instances are configured.`); + } + } + + // Confirm removal + if (!this.flags.force) { + const proceed = await confirm({ + message: `Remove instance "${name}"? This cannot be undone.`, + default: false, + }); + + if (!proceed) { + ux.stdout('Instance removal cancelled.'); + return { + name, + removed: false, + }; + } + } + + // Remove the instance + source.removeInstance(name, {configPath: this.flags.config}); + + const result: InstanceRemoveResponse = { + name, + removed: true, + }; + + if (!this.jsonEnabled()) { + ux.stdout(`Instance "${name}" removed successfully.`); + } + + return result; + } +} diff --git a/packages/b2c-cli/src/commands/setup/instance/set-active.ts b/packages/b2c-cli/src/commands/setup/instance/set-active.ts new file mode 100644 index 00000000..f7c63b7b --- /dev/null +++ b/packages/b2c-cli/src/commands/setup/instance/set-active.ts @@ -0,0 +1,85 @@ +/* + * 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 {Args, ux} from '@oclif/core'; +import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {DwJsonSource} from '@salesforce/b2c-tooling-sdk/config'; +import {withDocs} from '../../../i18n/index.js'; + +/** + * JSON output structure for the set-active command. + */ +interface InstanceSetActiveResponse { + name: string; + active: boolean; +} + +/** + * Set a B2C Commerce instance as the default (active) instance. + */ +export default class SetupInstanceSetActive extends BaseCommand { + static args = { + name: Args.string({ + description: 'Instance name to set as active', + required: true, + }), + }; + + static description = withDocs( + 'Set a B2C Commerce instance as the default (active) instance', + '/cli/setup.html#b2c-setup-instance-set-active', + ); + + static enableJsonFlag = true; + + static examples = ['<%= config.bin %> <%= command.id %> staging', '<%= config.bin %> <%= command.id %> production']; + + static flags = { + ...BaseCommand.baseFlags, + }; + + async run(): Promise { + const source = new DwJsonSource(); + const name = this.args.name; + + // Check if instance exists + const instances = source.listInstances({configPath: this.flags.config}); + const instance = instances.find((i) => i.name === name); + + if (!instance) { + const availableNames = instances.map((i) => i.name).join(', '); + if (availableNames) { + this.error(`Instance "${name}" not found. Available instances: ${availableNames}`); + } else { + this.error(`Instance "${name}" not found. No instances are configured.`); + } + } + + // Check if already active + if (instance.active) { + if (!this.jsonEnabled()) { + ux.stdout(`Instance "${name}" is already the active instance.`); + } + return { + name, + active: true, + }; + } + + // Set as active + source.setActiveInstance(name, {configPath: this.flags.config}); + + const result: InstanceSetActiveResponse = { + name, + active: true, + }; + + if (!this.jsonEnabled()) { + ux.stdout(`Instance "${name}" is now the active instance.`); + } + + return result; + } +} diff --git a/packages/b2c-cli/test/commands/setup/config.test.ts b/packages/b2c-cli/test/commands/setup/inspect.test.ts similarity index 90% rename from packages/b2c-cli/test/commands/setup/config.test.ts rename to packages/b2c-cli/test/commands/setup/inspect.test.ts index 19162870..0349dab0 100644 --- a/packages/b2c-cli/test/commands/setup/config.test.ts +++ b/packages/b2c-cli/test/commands/setup/inspect.test.ts @@ -7,7 +7,7 @@ import {expect} from 'chai'; import sinon from 'sinon'; -import SetupConfig from '../../../src/commands/setup/config.js'; +import SetupInspect from '../../../src/commands/setup/inspect.js'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import {runSilent} from '../../helpers/test-setup.js'; import type {ConfigSourceInfo, NormalizedConfig} from '@salesforce/b2c-tooling-sdk/config'; @@ -52,9 +52,9 @@ function stubResolvedConfig( } /** - * Unit tests for setup config command. + * Unit tests for setup inspect command. */ -describe('setup config', () => { +describe('setup inspect', () => { beforeEach(() => { isolateConfig(); }); @@ -66,22 +66,22 @@ describe('setup config', () => { describe('command structure', () => { it('should have correct description', () => { - expect(SetupConfig.description).to.be.a('string'); - expect(SetupConfig.description).to.include('configuration'); + expect(SetupInspect.description).to.be.a('string'); + expect(SetupInspect.description).to.include('configuration'); }); it('should enable JSON flag', () => { - expect(SetupConfig.enableJsonFlag).to.be.true; + expect(SetupInspect.enableJsonFlag).to.be.true; }); it('should have unmask flag', () => { - expect(SetupConfig.flags).to.have.property('unmask'); + expect(SetupInspect.flags).to.have.property('unmask'); }); }); describe('masking', () => { it('should mask password by default', async () => { - const command = new SetupConfig([], {} as any); + const command = new SetupInspect([], {} as any); (command as any).flags = {unmask: false}; stubJsonEnabled(command, true); stubCommandConfigAndLogger(command); @@ -97,7 +97,7 @@ describe('setup config', () => { }); it('should mask clientSecret by default', async () => { - const command = new SetupConfig([], {} as any); + const command = new SetupInspect([], {} as any); (command as any).flags = {unmask: false}; stubJsonEnabled(command, true); stubCommandConfigAndLogger(command); @@ -113,7 +113,7 @@ describe('setup config', () => { }); it('should mask mrtApiKey by default', async () => { - const command = new SetupConfig([], {} as any); + const command = new SetupInspect([], {} as any); (command as any).flags = {unmask: false}; stubJsonEnabled(command, true); stubCommandConfigAndLogger(command); @@ -129,7 +129,7 @@ describe('setup config', () => { }); it('should show REDACTED for short secrets', async () => { - const command = new SetupConfig([], {} as any); + const command = new SetupInspect([], {} as any); (command as any).flags = {unmask: false}; stubJsonEnabled(command, true); stubCommandConfigAndLogger(command); @@ -143,7 +143,7 @@ describe('setup config', () => { }); it('should unmask values when --unmask flag is provided', async () => { - const command = new SetupConfig([], {} as any); + const command = new SetupInspect([], {} as any); (command as any).flags = {unmask: true}; stubJsonEnabled(command, true); stubCommandConfigAndLogger(command); @@ -170,7 +170,7 @@ describe('setup config', () => { describe('output formatting', () => { it('should return structured JSON in --json mode', async () => { - const command = new SetupConfig([], {} as any); + const command = new SetupInspect([], {} as any); (command as any).flags = {unmask: false}; stubJsonEnabled(command, true); stubCommandConfigAndLogger(command); @@ -199,7 +199,7 @@ describe('setup config', () => { }); it('should display warnings if present', async () => { - const command = new SetupConfig([], {} as any); + const command = new SetupInspect([], {} as any); (command as any).flags = {unmask: false}; stubJsonEnabled(command, true); stubCommandConfigAndLogger(command); @@ -216,7 +216,7 @@ describe('setup config', () => { }); it('should handle empty config gracefully', async () => { - const command = new SetupConfig([], {} as any); + const command = new SetupInspect([], {} as any); (command as any).flags = {unmask: false}; stubJsonEnabled(command, true); stubCommandConfigAndLogger(command); @@ -229,7 +229,7 @@ describe('setup config', () => { }); it('should handle array values (scopes)', async () => { - const command = new SetupConfig([], {} as any); + const command = new SetupInspect([], {} as any); (command as any).flags = {unmask: false}; stubJsonEnabled(command, true); stubCommandConfigAndLogger(command); @@ -245,7 +245,7 @@ describe('setup config', () => { describe('source tracking', () => { it('should track which source provided each field', async () => { - const command = new SetupConfig([], {} as any); + const command = new SetupInspect([], {} as any); (command as any).flags = {unmask: false}; stubJsonEnabled(command, true); stubCommandConfigAndLogger(command); @@ -278,7 +278,7 @@ describe('setup config', () => { }); it('should handle fieldsIgnored correctly', async () => { - const command = new SetupConfig([], {} as any); + const command = new SetupInspect([], {} as any); (command as any).flags = {unmask: false}; stubJsonEnabled(command, true); stubCommandConfigAndLogger(command); @@ -314,7 +314,7 @@ describe('setup config', () => { describe('human-readable output', () => { it('should display formatted info in non-JSON mode', async () => { - const command = new SetupConfig([], {} as any); + const command = new SetupInspect([], {} as any); (command as any).flags = {unmask: false}; stubJsonEnabled(command, false); stubCommandConfigAndLogger(command); @@ -347,7 +347,7 @@ describe('setup config', () => { }); it('should show unmask warning when --unmask is used', async () => { - const command = new SetupConfig([], {} as any); + const command = new SetupInspect([], {} as any); (command as any).flags = {unmask: true}; stubJsonEnabled(command, false); stubCommandConfigAndLogger(command); diff --git a/packages/b2c-tooling-sdk/src/config/dw-json.ts b/packages/b2c-tooling-sdk/src/config/dw-json.ts index f86258ac..7af34de9 100644 --- a/packages/b2c-tooling-sdk/src/config/dw-json.ts +++ b/packages/b2c-tooling-sdk/src/config/dw-json.ts @@ -200,6 +200,222 @@ function selectConfig(json: DwJsonMultiConfig, instanceName?: string): DwJsonCon return json; } +/** + * Load the raw dw.json file without selecting a specific instance. + * + * This is useful for instance management operations that need to work + * with the full configs array. + * + * @param options - Loading options + * @returns The raw multi-config structure and path, or undefined if not found + */ +export function loadFullDwJson(options: LoadDwJsonOptions = {}): {config: DwJsonMultiConfig; path: string} | undefined { + const logger = getLogger(); + 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; + } + + try { + const content = fs.readFileSync(dwJsonPath, 'utf8'); + const json = JSON.parse(content) as DwJsonMultiConfig; + return {config: json, path: dwJsonPath}; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.trace({path: dwJsonPath, error: message}, '[DwJsonSource] Failed to parse config file'); + throw error; + } +} + +/** + * Save a dw.json configuration to disk. + * + * @param config - The configuration to save + * @param filePath - Path to save to + */ +export function saveDwJson(config: DwJsonMultiConfig, filePath: string): void { + const content = JSON.stringify(config, null, 2) + '\n'; + fs.writeFileSync(filePath, content, 'utf8'); +} + +/** + * Options for adding an instance. + */ +export interface AddInstanceOptions { + /** Path to dw.json (defaults to ./dw.json) */ + path?: string; + /** Starting directory for search */ + startDir?: string; + /** Whether to set as active instance */ + setActive?: boolean; +} + +/** + * Add a new instance to dw.json. + * + * If dw.json doesn't exist, creates a new one. If an instance with the same + * name already exists, throws an error. + * + * @param instance - The instance configuration to add + * @param options - Options for adding + * @throws Error if instance with same name already exists + */ +export function addInstance(instance: DwJsonConfig, options: AddInstanceOptions = {}): void { + const dwJsonPath = options.path ?? path.join(options.startDir || process.cwd(), 'dw.json'); + + let existing: DwJsonMultiConfig = {}; + if (fs.existsSync(dwJsonPath)) { + const content = fs.readFileSync(dwJsonPath, 'utf8'); + existing = JSON.parse(content) as DwJsonMultiConfig; + } + + // Check if instance name already exists + const instanceName = instance.name; + if (!instanceName) { + throw new Error('Instance must have a name'); + } + + // Check root config + if (existing.name === instanceName) { + throw new Error(`Instance "${instanceName}" already exists`); + } + + // Check configs array + if (existing.configs?.some((c) => c.name === instanceName)) { + throw new Error(`Instance "${instanceName}" already exists`); + } + + // Handle setActive - clear other active flags + if (options.setActive) { + instance.active = true; + // Clear active on root if it has it + if (existing.active !== undefined) { + existing.active = false; + } + // Clear active on all other configs + if (existing.configs) { + for (const c of existing.configs) { + if (c.active !== undefined) { + c.active = false; + } + } + } + } + + // Initialize configs array if needed + if (!existing.configs) { + existing.configs = []; + } + + // Add the new instance + existing.configs.push(instance); + + saveDwJson(existing, dwJsonPath); +} + +/** + * Options for removing an instance. + */ +export interface RemoveInstanceOptions { + /** Path to dw.json */ + path?: string; + /** Starting directory for search */ + startDir?: string; +} + +/** + * Remove an instance from dw.json. + * + * @param name - Name of the instance to remove + * @param options - Options for removal + * @throws Error if instance not found or dw.json doesn't exist + */ +export function removeInstance(name: string, options: RemoveInstanceOptions = {}): void { + const dwJsonPath = options.path ?? path.join(options.startDir || process.cwd(), 'dw.json'); + + if (!fs.existsSync(dwJsonPath)) { + throw new Error('No dw.json file found'); + } + + const content = fs.readFileSync(dwJsonPath, 'utf8'); + const existing = JSON.parse(content) as DwJsonMultiConfig; + + // Check if trying to remove root config + if (existing.name === name) { + throw new Error(`Cannot remove root instance "${name}". Edit dw.json manually to remove root config.`); + } + + // Find and remove from configs array + if (!existing.configs || !existing.configs.some((c) => c.name === name)) { + throw new Error(`Instance "${name}" not found`); + } + + existing.configs = existing.configs.filter((c) => c.name !== name); + + saveDwJson(existing, dwJsonPath); +} + +/** + * Options for setting active instance. + */ +export interface SetActiveInstanceOptions { + /** Path to dw.json */ + path?: string; + /** Starting directory for search */ + startDir?: string; +} + +/** + * Set an instance as the active default. + * + * @param name - Name of the instance to set as active + * @param options - Options + * @throws Error if instance not found or dw.json doesn't exist + */ +export function setActiveInstance(name: string, options: SetActiveInstanceOptions = {}): void { + const dwJsonPath = options.path ?? path.join(options.startDir || process.cwd(), 'dw.json'); + + if (!fs.existsSync(dwJsonPath)) { + throw new Error('No dw.json file found'); + } + + const content = fs.readFileSync(dwJsonPath, 'utf8'); + const existing = JSON.parse(content) as DwJsonMultiConfig; + + // Find the target instance + let found = false; + + // Check root config + if (existing.name === name) { + found = true; + existing.active = true; + } else if (existing.active !== undefined) { + existing.active = false; + } + + // Check and update configs array + if (existing.configs) { + for (const c of existing.configs) { + if (c.name === name) { + found = true; + c.active = true; + } else if (c.active !== undefined) { + c.active = false; + } + } + } + + if (!found) { + throw new Error(`Instance "${name}" not found`); + } + + saveDwJson(existing, dwJsonPath); +} + /** * Loads configuration from a dw.json file. * diff --git a/packages/b2c-tooling-sdk/src/config/index.ts b/packages/b2c-tooling-sdk/src/config/index.ts index a9fb24af..f4a0e5de 100644 --- a/packages/b2c-tooling-sdk/src/config/index.ts +++ b/packages/b2c-tooling-sdk/src/config/index.ts @@ -110,11 +110,35 @@ export type { ResolveConfigOptions, ResolvedB2CConfig, CreateOAuthOptions, + InstanceInfo, + CreateInstanceOptions, } from './types.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, LoadDwJsonResult} from './dw-json.js'; +export { + loadDwJson, + loadFullDwJson, + findDwJson, + saveDwJson, + addInstance, + removeInstance, + setActiveInstance, +} from './dw-json.js'; +export type { + DwJsonConfig, + DwJsonMultiConfig, + LoadDwJsonOptions, + LoadDwJsonResult, + AddInstanceOptions, + RemoveInstanceOptions, + SetActiveInstanceOptions, +} from './dw-json.js'; + +// Instance management +export {InstanceManager, createInstanceManager} from './instance-manager.js'; + +// Config sources (for direct use) +export {DwJsonSource} from './sources/dw-json-source.js'; diff --git a/packages/b2c-tooling-sdk/src/config/instance-manager.ts b/packages/b2c-tooling-sdk/src/config/instance-manager.ts new file mode 100644 index 00000000..6851697e --- /dev/null +++ b/packages/b2c-tooling-sdk/src/config/instance-manager.ts @@ -0,0 +1,204 @@ +/* + * 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 + */ +/** + * Instance management service. + * + * Aggregates instance management operations across multiple config sources. + * + * @module config/instance-manager + */ +import type { + ConfigSource, + InstanceInfo, + CreateInstanceOptions, + ResolveConfigOptions, + NormalizedConfig, +} from './types.js'; + +/** + * Service for managing B2C instances across multiple config sources. + * + * This class aggregates instance management operations from all sources + * that implement the optional instance management methods. + * + * @example + * ```typescript + * import { InstanceManager, DwJsonSource } from '@salesforce/b2c-tooling-sdk/config'; + * + * const manager = new InstanceManager([new DwJsonSource()]); + * + * // List all instances + * const instances = manager.listAllInstances(); + * + * // Create a new instance + * manager.createInstance({ + * name: 'staging', + * config: { hostname: 'staging.example.com' }, + * setActive: true, + * }); + * ``` + */ +export class InstanceManager { + constructor(private readonly sources: ConfigSource[]) {} + + /** + * List instances from all sources that implement listInstances(). + * + * @param options - Resolution options + * @returns Array of all instances from all sources + */ + listAllInstances(options?: ResolveConfigOptions): InstanceInfo[] { + const allInstances: InstanceInfo[] = []; + + for (const source of this.sources) { + if (source.listInstances) { + const instances = source.listInstances(options); + allInstances.push(...instances); + } + } + + return allInstances; + } + + /** + * Get sources that can create instances. + * + * @returns Array of sources with createInstance() method + */ + getInstanceSources(): ConfigSource[] { + return this.sources.filter((s) => s.createInstance); + } + + /** + * Get sources that can store a specific credential field. + * + * @param field - The credential field to check + * @returns Array of sources that can store the field + */ + getCredentialSources(field: keyof NormalizedConfig): ConfigSource[] { + return this.sources.filter((s) => s.credentialFields?.includes(field)); + } + + /** + * Create an instance in the specified source (or default to first instance source). + * + * @param options - Instance creation options + * @param targetSource - Source name to use (optional, defaults to first available) + * @throws Error if no instance sources available or specified source not found + */ + createInstance(options: CreateInstanceOptions & ResolveConfigOptions, targetSource?: string): void { + const instanceSources = this.getInstanceSources(); + + if (instanceSources.length === 0) { + throw new Error('No config sources support instance creation'); + } + + let source: ConfigSource; + if (targetSource) { + const found = instanceSources.find((s) => s.name === targetSource); + if (!found) { + throw new Error(`Source "${targetSource}" not found or does not support instance creation`); + } + source = found; + } else { + // Default to first (highest priority) instance source + source = instanceSources[0]; + } + + source.createInstance!(options); + } + + /** + * Remove an instance from the source that contains it. + * + * @param name - Instance name to remove + * @param options - Resolution options + * @throws Error if instance not found in any source + */ + removeInstance(name: string, options?: ResolveConfigOptions): void { + // Find the source that has this instance + for (const source of this.sources) { + if (source.listInstances && source.removeInstance) { + const instances = source.listInstances(options); + if (instances.some((i) => i.name === name)) { + source.removeInstance(name, options); + return; + } + } + } + + throw new Error(`Instance "${name}" not found in any source`); + } + + /** + * Set an instance as active in the source that contains it. + * + * @param name - Instance name to set as active + * @param options - Resolution options + * @throws Error if instance not found in any source + */ + setActiveInstance(name: string, options?: ResolveConfigOptions): void { + // Find the source that has this instance + for (const source of this.sources) { + if (source.listInstances && source.setActiveInstance) { + const instances = source.listInstances(options); + if (instances.some((i) => i.name === name)) { + source.setActiveInstance(name, options); + return; + } + } + } + + throw new Error(`Instance "${name}" not found in any source`); + } + + /** + * Store a credential for an instance in the specified source. + * + * @param instanceName - Instance name + * @param field - Config field to store + * @param value - Value to store + * @param targetSource - Source name to use (optional) + * @param options - Resolution options + * @throws Error if no credential sources support the field + */ + storeCredential( + instanceName: string, + field: keyof NormalizedConfig, + value: string, + targetSource?: string, + options?: ResolveConfigOptions, + ): void { + const credentialSources = this.getCredentialSources(field); + + if (credentialSources.length === 0) { + throw new Error(`No config sources support storing credential field "${String(field)}"`); + } + + let source: ConfigSource; + if (targetSource) { + const found = credentialSources.find((s) => s.name === targetSource); + if (!found) { + throw new Error(`Source "${targetSource}" not found or does not support credential storage`); + } + source = found; + } else { + source = credentialSources[0]; + } + + source.storeCredential!(instanceName, field, value, options); + } +} + +/** + * Create an InstanceManager with the given sources. + * + * @param sources - Config sources to use + * @returns InstanceManager instance + */ +export function createInstanceManager(sources: ConfigSource[]): InstanceManager { + return new InstanceManager(sources); +} diff --git a/packages/b2c-tooling-sdk/src/config/mapping.ts b/packages/b2c-tooling-sdk/src/config/mapping.ts index 029ff5fa..944330a5 100644 --- a/packages/b2c-tooling-sdk/src/config/mapping.ts +++ b/packages/b2c-tooling-sdk/src/config/mapping.ts @@ -63,6 +63,87 @@ export function mapDwJsonToNormalizedConfig(json: DwJsonConfig): NormalizedConfi }; } +/** + * Maps normalized config to dw.json format. + * + * This is the reverse of mapDwJsonToNormalizedConfig. It converts normalized + * config (camelCase) back to dw.json format (kebab-case). + * + * @param config - The normalized configuration + * @param name - Optional instance name to include + * @returns DwJsonConfig structure + * + * @example + * ```typescript + * const config = { hostname: 'example.com', codeVersion: 'v1', clientId: 'abc' }; + * const dwJson = mapNormalizedConfigToDwJson(config, 'staging'); + * // { name: 'staging', hostname: 'example.com', 'code-version': 'v1', 'client-id': 'abc' } + * ``` + */ +export function mapNormalizedConfigToDwJson(config: Partial, name?: string): DwJsonConfig { + const result: DwJsonConfig = {}; + + if (name !== undefined) { + result.name = name; + } + if (config.hostname !== undefined) { + result.hostname = config.hostname; + } + if (config.webdavHostname !== undefined) { + result['webdav-hostname'] = config.webdavHostname; + } + if (config.codeVersion !== undefined) { + result['code-version'] = config.codeVersion; + } + if (config.username !== undefined) { + result.username = config.username; + } + if (config.password !== undefined) { + result.password = config.password; + } + if (config.clientId !== undefined) { + result['client-id'] = config.clientId; + } + if (config.clientSecret !== undefined) { + result['client-secret'] = config.clientSecret; + } + if (config.scopes !== undefined) { + result['oauth-scopes'] = config.scopes; + } + if (config.shortCode !== undefined) { + result['short-code'] = config.shortCode; + } + if (config.tenantId !== undefined) { + result['tenant-id'] = config.tenantId; + } + if (config.authMethods !== undefined) { + result['auth-methods'] = config.authMethods; + } + if (config.accountManagerHost !== undefined) { + result['account-manager-host'] = config.accountManagerHost; + } + if (config.mrtProject !== undefined) { + result.mrtProject = config.mrtProject; + } + if (config.mrtEnvironment !== undefined) { + result.mrtEnvironment = config.mrtEnvironment; + } + if (config.mrtOrigin !== undefined) { + result.mrtOrigin = config.mrtOrigin; + } + if (config.certificate !== undefined) { + result.certificate = config.certificate; + } + if (config.certificatePassphrase !== undefined) { + result['certificate-passphrase'] = config.certificatePassphrase; + } + if (config.selfSigned !== undefined) { + result['self-signed'] = config.selfSigned; + } + + return result; +} + /** * Options for merging configurations. */ 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 f52e7c98..9f0950ca 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,10 +8,15 @@ * * @internal This module is internal to the SDK. Use ConfigResolver instead. */ -import {loadDwJson} from '../dw-json.js'; -import {getPopulatedFields} from '../mapping.js'; -import {mapDwJsonToNormalizedConfig} from '../mapping.js'; -import type {ConfigSource, ConfigLoadResult, ResolveConfigOptions} from '../types.js'; +import {loadDwJson, loadFullDwJson, addInstance, removeInstance, setActiveInstance} from '../dw-json.js'; +import {getPopulatedFields, mapDwJsonToNormalizedConfig, mapNormalizedConfigToDwJson} from '../mapping.js'; +import type { + ConfigSource, + ConfigLoadResult, + ResolveConfigOptions, + InstanceInfo, + CreateInstanceOptions, +} from '../types.js'; import {getLogger} from '../../logging/logger.js'; /** @@ -43,4 +48,81 @@ export class DwJsonSource implements ConfigSource { return {config, location: result.path}; } + + /** + * List all instances from dw.json. + */ + listInstances(options?: ResolveConfigOptions): InstanceInfo[] { + const result = loadFullDwJson({ + path: options?.configPath, + startDir: options?.startDir, + }); + + if (!result) { + return []; + } + + const instances: InstanceInfo[] = []; + const {config, path: dwJsonPath} = result; + + // Add root config if it has a name + if (config.name) { + instances.push({ + name: config.name, + hostname: config.hostname || config.server, + active: config.active, + source: this.name, + location: dwJsonPath, + }); + } + + // Add configs array entries + if (config.configs) { + for (const c of config.configs) { + if (c.name) { + instances.push({ + name: c.name, + hostname: c.hostname || c.server, + active: c.active, + source: this.name, + location: dwJsonPath, + }); + } + } + } + + return instances; + } + + /** + * Create a new instance in dw.json. + */ + createInstance(options: CreateInstanceOptions & ResolveConfigOptions): void { + const dwJsonConfig = mapNormalizedConfigToDwJson(options.config, options.name); + addInstance(dwJsonConfig, { + path: options.configPath, + startDir: options.startDir, + setActive: options.setActive, + }); + } + + /** + * Remove an instance from dw.json. + */ + removeInstance(name: string, options?: ResolveConfigOptions): void { + removeInstance(name, { + path: options?.configPath, + startDir: options?.startDir, + }); + } + + /** + * Set an instance as active in dw.json. + */ + setActiveInstance(name: string, options?: ResolveConfigOptions): void { + setActiveInstance(name, { + path: options?.configPath, + startDir: options?.startDir, + }); + } } diff --git a/packages/b2c-tooling-sdk/src/config/types.ts b/packages/b2c-tooling-sdk/src/config/types.ts index 27577788..26260be7 100644 --- a/packages/b2c-tooling-sdk/src/config/types.ts +++ b/packages/b2c-tooling-sdk/src/config/types.ts @@ -212,6 +212,64 @@ export interface ConfigSource { * @returns Config and location from this source, or undefined if source not available */ load(options: ResolveConfigOptions): ConfigLoadResult | undefined; + + // === Instance Management (for dw.json-style sources) === + + /** + * List all instances from this source. + * @param options - Resolution options + * @returns Array of instance info objects + */ + listInstances?(options?: ResolveConfigOptions): InstanceInfo[]; + + /** + * Create a new instance in this source. + * @param options - Creation options including name and config + */ + createInstance?(options: CreateInstanceOptions & ResolveConfigOptions): void; + + /** + * Remove an instance from this source. + * @param name - Instance name to remove + * @param options - Resolution options + */ + removeInstance?(name: string, options?: ResolveConfigOptions): void; + + /** + * Set an instance as active. + * @param name - Instance name to set as active + * @param options - Resolution options + */ + setActiveInstance?(name: string, options?: ResolveConfigOptions): void; + + // === Credential Storage (for keychain-style sources) === + + /** + * Fields this source can securely store (e.g., ['password', 'clientSecret']). + */ + credentialFields?: (keyof NormalizedConfig)[]; + + /** + * Store a credential value for an instance. + * @param instanceName - Instance name + * @param field - Config field to store + * @param value - Value to store + * @param options - Resolution options + */ + storeCredential?( + instanceName: string, + field: keyof NormalizedConfig, + value: string, + options?: ResolveConfigOptions, + ): void; + + /** + * Remove a credential for an instance. + * @param instanceName - Instance name + * @param field - Config field to remove + * @param options - Resolution options + */ + removeCredential?(instanceName: string, field: keyof NormalizedConfig, options?: ResolveConfigOptions): void; } /** @@ -248,6 +306,34 @@ export interface CreateOAuthOptions { * } * ``` */ +/** + * Information about a configured instance. + */ +export interface InstanceInfo { + /** Instance name */ + name: string; + /** B2C instance hostname */ + hostname?: string; + /** Whether this instance is currently active */ + active?: boolean; + /** Source name for display */ + source: string; + /** Location (file path, etc.) */ + location?: string; +} + +/** + * Options for creating an instance. + */ +export interface CreateInstanceOptions { + /** Instance name */ + name: string; + /** Configuration values for the instance */ + config: Partial; + /** Whether to set as active instance */ + setActive?: boolean; +} + export interface ResolvedB2CConfig { /** Raw configuration values */ readonly values: NormalizedConfig; 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 639ae4aa..c8ee63f0 100644 --- a/packages/b2c-tooling-sdk/test/config/dw-json.test.ts +++ b/packages/b2c-tooling-sdk/test/config/dw-json.test.ts @@ -7,7 +7,17 @@ import {expect} from 'chai'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import {findDwJson, loadDwJson, type DwJsonConfig} from '@salesforce/b2c-tooling-sdk/config'; +import { + findDwJson, + loadDwJson, + loadFullDwJson, + saveDwJson, + addInstance, + removeInstance, + setActiveInstance, + type DwJsonConfig, + type DwJsonMultiConfig, +} from '@salesforce/b2c-tooling-sdk/config'; describe('config/dw-json', () => { let tempDir: string; @@ -191,4 +201,258 @@ describe('config/dw-json', () => { expect(result?.config['webdav-hostname']).to.equal('webdav.test.com'); }); }); + + describe('loadFullDwJson', () => { + it('returns undefined when no dw.json exists', () => { + const result = loadFullDwJson(); + expect(result).to.be.undefined; + }); + + it('loads the full multi-config structure', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + const multiConfig: DwJsonMultiConfig = { + 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 = loadFullDwJson(); + expect(result?.config).to.deep.equal(multiConfig); + expect(result?.config.configs).to.have.length(2); + }); + }); + + describe('saveDwJson', () => { + it('writes config to file', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + const config: DwJsonMultiConfig = { + hostname: 'test.demandware.net', + configs: [{name: 'staging', hostname: 'staging.demandware.net'}], + }; + + saveDwJson(config, dwJsonPath); + + const content = fs.readFileSync(dwJsonPath, 'utf8'); + expect(JSON.parse(content)).to.deep.equal(config); + }); + + it('formats with 2-space indentation and trailing newline', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + const config: DwJsonMultiConfig = {hostname: 'test.demandware.net'}; + + saveDwJson(config, dwJsonPath); + + const content = fs.readFileSync(dwJsonPath, 'utf8'); + expect(content).to.match(/^\{[\s\S]*\}\n$/); + expect(content).to.contain(' "hostname"'); + }); + }); + + describe('addInstance', () => { + it('creates dw.json if it does not exist', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + expect(fs.existsSync(dwJsonPath)).to.be.false; + + addInstance({name: 'staging', hostname: 'staging.demandware.net'}); + + expect(fs.existsSync(dwJsonPath)).to.be.true; + const result = loadFullDwJson(); + expect(result?.config.configs).to.have.length(1); + expect(result?.config.configs?.[0].name).to.equal('staging'); + }); + + it('adds instance to existing configs array', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + configs: [{name: 'production', hostname: 'prod.demandware.net'}], + }), + ); + + addInstance({name: 'staging', hostname: 'staging.demandware.net'}); + + const result = loadFullDwJson(); + expect(result?.config.configs).to.have.length(2); + expect(result?.config.configs?.[1].name).to.equal('staging'); + }); + + it('throws if instance already exists in configs array', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + configs: [{name: 'staging', hostname: 'staging.demandware.net'}], + }), + ); + + expect(() => addInstance({name: 'staging', hostname: 'new.demandware.net'})).to.throw('already exists'); + }); + + it('throws if instance name matches root config name', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + name: 'staging', + hostname: 'staging.demandware.net', + }), + ); + + expect(() => addInstance({name: 'staging', hostname: 'new.demandware.net'})).to.throw('already exists'); + }); + + it('throws if instance has no name', () => { + expect(() => addInstance({hostname: 'test.demandware.net'})).to.throw('must have a name'); + }); + + it('sets instance as active and clears other active flags', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + active: true, + hostname: 'root.demandware.net', + configs: [{name: 'production', hostname: 'prod.demandware.net', active: true}], + }), + ); + + addInstance({name: 'staging', hostname: 'staging.demandware.net'}, {setActive: true}); + + const result = loadFullDwJson(); + expect(result?.config.active).to.be.false; + expect(result?.config.configs?.[0].active).to.be.false; + expect(result?.config.configs?.[1].active).to.be.true; + }); + }); + + describe('removeInstance', () => { + it('removes instance from configs array', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + configs: [ + {name: 'staging', hostname: 'staging.demandware.net'}, + {name: 'production', hostname: 'prod.demandware.net'}, + ], + }), + ); + + removeInstance('staging'); + + const result = loadFullDwJson(); + expect(result?.config.configs).to.have.length(1); + expect(result?.config.configs?.[0].name).to.equal('production'); + }); + + it('throws if dw.json does not exist', () => { + expect(() => removeInstance('staging')).to.throw('No dw.json file found'); + }); + + it('throws if instance not found', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + configs: [{name: 'production', hostname: 'prod.demandware.net'}], + }), + ); + + expect(() => removeInstance('staging')).to.throw('not found'); + }); + + it('throws if trying to remove root config', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + name: 'staging', + hostname: 'staging.demandware.net', + }), + ); + + expect(() => removeInstance('staging')).to.throw('Cannot remove root instance'); + }); + }); + + describe('setActiveInstance', () => { + it('sets instance as active in configs array', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + configs: [ + {name: 'staging', hostname: 'staging.demandware.net'}, + {name: 'production', hostname: 'prod.demandware.net'}, + ], + }), + ); + + setActiveInstance('staging'); + + const result = loadFullDwJson(); + expect(result?.config.configs?.[0].active).to.be.true; + expect(result?.config.configs?.[1].active).to.be.undefined; + }); + + it('sets root config as active', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + name: 'root', + hostname: 'root.demandware.net', + configs: [{name: 'staging', hostname: 'staging.demandware.net', active: true}], + }), + ); + + setActiveInstance('root'); + + const result = loadFullDwJson(); + expect(result?.config.active).to.be.true; + expect(result?.config.configs?.[0].active).to.be.false; + }); + + it('clears other active flags when setting new active', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + active: true, + hostname: 'root.demandware.net', + configs: [ + {name: 'staging', hostname: 'staging.demandware.net', active: true}, + {name: 'production', hostname: 'prod.demandware.net'}, + ], + }), + ); + + setActiveInstance('production'); + + const result = loadFullDwJson(); + expect(result?.config.active).to.be.false; + expect(result?.config.configs?.[0].active).to.be.false; + expect(result?.config.configs?.[1].active).to.be.true; + }); + + it('throws if dw.json does not exist', () => { + expect(() => setActiveInstance('staging')).to.throw('No dw.json file found'); + }); + + it('throws if instance not found', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + configs: [{name: 'production', hostname: 'prod.demandware.net'}], + }), + ); + + expect(() => setActiveInstance('staging')).to.throw('not found'); + }); + }); }); diff --git a/packages/b2c-tooling-sdk/test/config/sources.test.ts b/packages/b2c-tooling-sdk/test/config/sources.test.ts index fda8662f..ff36e2ac 100644 --- a/packages/b2c-tooling-sdk/test/config/sources.test.ts +++ b/packages/b2c-tooling-sdk/test/config/sources.test.ts @@ -7,7 +7,7 @@ import {expect} from 'chai'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import {ConfigResolver} from '@salesforce/b2c-tooling-sdk/config'; +import {ConfigResolver, DwJsonSource} from '@salesforce/b2c-tooling-sdk/config'; import {PackageJsonSource} from '../../src/config/sources/package-json-source.js'; describe('config/sources', () => { @@ -148,6 +148,128 @@ describe('config/sources', () => { const actualLocation = dwJsonSource?.location ? fs.realpathSync(dwJsonSource.location) : undefined; expect(actualLocation).to.equal(expectedPath); }); + + describe('listInstances', () => { + it('returns empty array when no dw.json exists', () => { + const source = new DwJsonSource(); + const instances = source.listInstances(); + expect(instances).to.deep.equal([]); + }); + + it('returns instances from configs array', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + configs: [ + {name: 'staging', hostname: 'staging.demandware.net'}, + {name: 'production', hostname: 'prod.demandware.net', active: true}, + ], + }), + ); + + const source = new DwJsonSource(); + const instances = source.listInstances(); + + expect(instances).to.have.length(2); + expect(instances[0].name).to.equal('staging'); + expect(instances[0].hostname).to.equal('staging.demandware.net'); + expect(instances[1].name).to.equal('production'); + expect(instances[1].active).to.be.true; + }); + + it('includes root config if it has a name', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + name: 'root', + hostname: 'root.demandware.net', + active: true, + configs: [{name: 'staging', hostname: 'staging.demandware.net'}], + }), + ); + + const source = new DwJsonSource(); + const instances = source.listInstances(); + + expect(instances).to.have.length(2); + expect(instances[0].name).to.equal('root'); + expect(instances[0].active).to.be.true; + expect(instances[1].name).to.equal('staging'); + }); + }); + + describe('createInstance', () => { + it('creates a new instance', () => { + const source = new DwJsonSource(); + source.createInstance({ + name: 'staging', + config: {hostname: 'staging.demandware.net'}, + }); + + const instances = source.listInstances(); + expect(instances).to.have.length(1); + expect(instances[0].name).to.equal('staging'); + expect(instances[0].hostname).to.equal('staging.demandware.net'); + }); + + it('creates instance with setActive', () => { + const source = new DwJsonSource(); + source.createInstance({ + name: 'staging', + config: {hostname: 'staging.demandware.net'}, + setActive: true, + }); + + const instances = source.listInstances(); + expect(instances[0].active).to.be.true; + }); + }); + + describe('removeInstance', () => { + it('removes an instance', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + configs: [ + {name: 'staging', hostname: 'staging.demandware.net'}, + {name: 'production', hostname: 'prod.demandware.net'}, + ], + }), + ); + + const source = new DwJsonSource(); + source.removeInstance('staging'); + + const instances = source.listInstances(); + expect(instances).to.have.length(1); + expect(instances[0].name).to.equal('production'); + }); + }); + + describe('setActiveInstance', () => { + it('sets an instance as active', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + configs: [ + {name: 'staging', hostname: 'staging.demandware.net'}, + {name: 'production', hostname: 'prod.demandware.net'}, + ], + }), + ); + + const source = new DwJsonSource(); + source.setActiveInstance('staging'); + + const instances = source.listInstances(); + const staging = instances.find((i) => i.name === 'staging'); + expect(staging?.active).to.be.true; + }); + }); }); describe('MobifySource', () => { From 34f80a92e3165be9a954c1fb17036daf289427ee Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 30 Jan 2026 21:18:36 -0500 Subject: [PATCH 2/7] =?UTF-8?q?Update=20skills=20for=20setup=20config=20?= =?UTF-8?q?=E2=86=92=20setup=20inspect=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update b2c-config skill with new command name and instance management docs - Fix reference in b2c-custom-api-development skill --- plugins/b2c-cli/skills/b2c-config/SKILL.md | 81 ++++++++++++++++--- .../b2c-custom-api-development/SKILL.md | 2 +- 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/plugins/b2c-cli/skills/b2c-config/SKILL.md b/plugins/b2c-cli/skills/b2c-config/SKILL.md index f2da6396..7225a3ff 100644 --- a/plugins/b2c-cli/skills/b2c-config/SKILL.md +++ b/plugins/b2c-cli/skills/b2c-config/SKILL.md @@ -5,11 +5,11 @@ description: View and debug B2C CLI configuration and understand where credentia # 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. +Use the `b2c setup inspect` command to view the resolved configuration and understand where each value comes from. Use the `b2c setup instance` commands to manage named instance configurations. ## When to Use -Use `b2c setup config` when you need to: +Use `b2c setup inspect` when you need to: - Verify which configuration file is being used - Check if environment variables are being read correctly @@ -18,44 +18,103 @@ Use `b2c setup config` when you need to: - Identify hostname mismatch protection issues - Verify MRT API key is loaded from ~/.mobify -## Examples +Use `b2c setup instance` commands when you need to: + +- List all configured instances +- Create a new instance configuration +- Switch between instances (set active) +- Remove an instance configuration + +## Inspecting Configuration ### View Current Configuration ```bash # Display resolved configuration (sensitive values masked by default) -b2c setup config +b2c setup inspect # View configuration for a specific instance from dw.json -b2c setup config -i staging +b2c setup inspect -i staging # View configuration with a specific config file -b2c setup config --config /path/to/dw.json +b2c setup inspect --config /path/to/dw.json ``` ### Debug Sensitive Values ```bash # Show actual passwords, secrets, and API keys (use with caution) -b2c setup config --unmask +b2c setup inspect --unmask ``` ### JSON Output for Scripting ```bash # Output as JSON for parsing in scripts -b2c setup config --json +b2c setup inspect --json # Pretty-print with jq -b2c setup config --json | jq '.config' +b2c setup inspect --json | jq '.config' # Check which sources are loaded -b2c setup config --json | jq '.sources' +b2c setup inspect --json | jq '.sources' +``` + +## Managing Instances + +### List Configured Instances + +```bash +# Show all instances from dw.json +b2c setup instance list + +# Output as JSON +b2c setup instance list --json +``` + +### Create a New Instance + +```bash +# Interactive mode - prompts for all values +b2c setup instance create staging + +# With hostname +b2c setup instance create staging --hostname staging.example.com + +# Create and set as active +b2c setup instance create staging --hostname staging.example.com --active + +# Non-interactive mode (for scripts) +b2c setup instance create staging \ + --hostname staging.example.com \ + --username admin \ + --password secret \ + --force +``` + +### Switch Active Instance + +```bash +# Set staging as the default instance +b2c setup instance set-active staging + +# Now commands use staging by default +b2c code list # Uses staging +``` + +### Remove an Instance + +```bash +# Remove with confirmation prompt +b2c setup instance remove staging + +# Remove without confirmation +b2c setup instance remove staging --force ``` ## Understanding the Output -The command displays configuration organized by category: +The `setup inspect` command displays configuration organized by category: - **Instance**: hostname, webdavHostname, codeVersion - **Authentication (Basic)**: username, password (for WebDAV) diff --git a/plugins/b2c/skills/b2c-custom-api-development/SKILL.md b/plugins/b2c/skills/b2c-custom-api-development/SKILL.md index d6f1167a..950760eb 100644 --- a/plugins/b2c/skills/b2c-custom-api-development/SKILL.md +++ b/plugins/b2c/skills/b2c-custom-api-development/SKILL.md @@ -371,7 +371,7 @@ Using a private SLAS client with client credentials grant: ```bash # Set your credentials -SHORTCODE="your-short-code" # see b2c-cli:b2c-config (b2c setup config) skill to find this value; this it NOT the instance realm ID +SHORTCODE="your-short-code" # see b2c-cli:b2c-config (b2c setup inspect) skill to find this value; this is NOT the instance realm ID ORG="f_ecom_xxxx_xxx" SLAS_CLIENT_ID="your-client-id" SLAS_CLIENT_SECRET="your-client-secret" From b655eec67ba808130e7cc6843b64fad0c9119e28 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 30 Jan 2026 21:19:43 -0500 Subject: [PATCH 3/7] Document instance management and credential storage methods Add documentation for the new optional ConfigSource methods: - listInstances, createInstance, removeInstance, setActiveInstance - credentialFields, storeCredential, removeCredential These enable plugins to provide custom instance storage (cloud config, global registry) and secure credential storage (keychain, vault). --- docs/guide/extending.md | 123 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/docs/guide/extending.md b/docs/guide/extending.md index 21439b09..7d0db820 100644 --- a/docs/guide/extending.md +++ b/docs/guide/extending.md @@ -205,6 +205,129 @@ export class MyCustomSource implements ConfigSource { } ``` +### Instance Management Methods + +Config sources can optionally implement instance management methods to support the `b2c setup instance` commands. This enables plugins to store and manage instance configurations in custom locations (cloud config, global registry, etc.). + +```typescript +import type { + ConfigSource, + ConfigLoadResult, + ResolveConfigOptions, + InstanceInfo, + CreateInstanceOptions, +} from '@salesforce/b2c-tooling-sdk/config'; + +export class MyInstanceSource implements ConfigSource { + readonly name = 'my-instance-source'; + + load(options: ResolveConfigOptions): ConfigLoadResult | undefined { + // Standard config loading... + } + + // List all instances from this source + listInstances(options?: ResolveConfigOptions): InstanceInfo[] { + return [ + { + name: 'staging', + hostname: 'staging.example.com', + active: true, + source: this.name, + location: '/path/to/config', + }, + ]; + } + + // Create a new instance + createInstance(options: CreateInstanceOptions & ResolveConfigOptions): void { + // Store the instance configuration + } + + // Remove an instance + removeInstance(name: string, options?: ResolveConfigOptions): void { + // Delete the instance configuration + } + + // Set an instance as active + setActiveInstance(name: string, options?: ResolveConfigOptions): void { + // Update the active flag + } +} +``` + +When a source implements `listInstances()`, its instances appear in `b2c setup instance list`. The `InstanceManager` class aggregates instances from all sources. + +### Credential Storage Methods + +Config sources can optionally implement credential storage methods to securely store secrets. This is useful for keychain integrations, vault plugins, or other secure storage backends. + +```typescript +import type { + ConfigSource, + NormalizedConfig, + ResolveConfigOptions, +} from '@salesforce/b2c-tooling-sdk/config'; + +export class KeychainSource implements ConfigSource { + readonly name = 'keychain'; + + // Declare which credential fields this source can store + readonly credentialFields: (keyof NormalizedConfig)[] = [ + 'password', + 'clientSecret', + ]; + + load(options: ResolveConfigOptions): ConfigLoadResult | undefined { + // Load credentials from keychain for the requested instance + const instanceName = options.instance || '_default'; + const password = this.getFromKeychain(`b2c/${instanceName}/password`); + const clientSecret = this.getFromKeychain(`b2c/${instanceName}/clientSecret`); + + if (!password && !clientSecret) { + return undefined; + } + + return { + config: { password, clientSecret }, + location: `keychain:b2c/${instanceName}`, + }; + } + + // Store a credential value for an instance + storeCredential( + instanceName: string, + field: keyof NormalizedConfig, + value: string, + options?: ResolveConfigOptions + ): void { + this.saveToKeychain(`b2c/${instanceName}/${String(field)}`, value); + } + + // Remove a credential for an instance + removeCredential( + instanceName: string, + field: keyof NormalizedConfig, + options?: ResolveConfigOptions + ): void { + this.deleteFromKeychain(`b2c/${instanceName}/${String(field)}`); + } + + private getFromKeychain(key: string): string | undefined { + // Keychain lookup implementation + } + + private saveToKeychain(key: string, value: string): void { + // Keychain save implementation + } + + private deleteFromKeychain(key: string): void { + // Keychain delete implementation + } +} +``` + +When `b2c setup instance create` collects credentials, it checks for sources with `credentialFields` and can route secrets to secure storage instead of plaintext files. + ### Error Handling If your `ConfigSource` encounters an error (e.g., malformed config file, network failure), you can: From 9390557db8f18b428176dc867d89dc383e8b8408 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 12 Feb 2026 17:04:48 -0500 Subject: [PATCH 4/7] Improve instance create and set-active interactive experience - Accept URL or hostname in create command (auto-extracts hostname) - Rename auth choices: WebDAV, API Client; show access key URL - Make client secret optional (supports user auth flow) - Auto-detect active code version via OCAPI when OAuth is configured - Add searchable instance picker to set-active when name omitted --- .../src/commands/setup/instance/create.ts | 79 ++++++++++++++----- .../src/commands/setup/instance/set-active.ts | 26 +++++- 2 files changed, 83 insertions(+), 22 deletions(-) diff --git a/packages/b2c-cli/src/commands/setup/instance/create.ts b/packages/b2c-cli/src/commands/setup/instance/create.ts index f4e92b8c..92edb377 100644 --- a/packages/b2c-cli/src/commands/setup/instance/create.ts +++ b/packages/b2c-cli/src/commands/setup/instance/create.ts @@ -6,7 +6,8 @@ import {Args, Flags, ux} from '@oclif/core'; import {input, password, confirm, select} from '@inquirer/prompts'; import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {DwJsonSource, type NormalizedConfig} from '@salesforce/b2c-tooling-sdk/config'; +import {DwJsonSource, createInstanceFromConfig, type NormalizedConfig} from '@salesforce/b2c-tooling-sdk/config'; +import {getActiveCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; import {withDocs} from '../../../i18n/index.js'; /** @@ -24,6 +25,19 @@ interface InstanceCreateResponse { */ type AuthType = 'basic' | 'both' | 'none' | 'oauth'; +/** + * Extract the hostname from a URL or return the input as-is if it's already a hostname. + */ +function parseHostname(input: string): string { + const trimmed = input.trim(); + try { + const url = new URL(trimmed); + return url.hostname; + } catch { + return trimmed; + } +} + /** * Create a new B2C Commerce instance configuration. */ @@ -101,28 +115,24 @@ export default class SetupInstanceCreate extends BaseCommand (v.trim() ? true : 'Hostname is required'), }); } + hostname = parseHostname(hostname); // Build config const config: Partial = { hostname, }; - // Code version - if (this.flags['code-version']) { - config.codeVersion = this.flags['code-version']; - } - // Handle authentication - in non-interactive mode, use provided flags if (force) { // Basic auth @@ -137,18 +147,17 @@ export default class SetupInstanceCreate extends BaseCommand({ message: 'Configure authentication:', choices: [ - {name: 'Basic (username/password)', value: 'basic'}, - {name: 'OAuth (client credentials)', value: 'oauth'}, + {name: 'WebDAV (username/password or access key)', value: 'basic'}, + {name: 'API Client (OAuth client credentials)', value: 'oauth'}, {name: 'Both', value: 'both'}, {name: 'Skip for now', value: 'none'}, ], @@ -163,11 +172,12 @@ export default class SetupInstanceCreate extends BaseCommand (v.trim() ? true : 'Username is required'), })); + const accessKeyUrl = `https://${hostname}/on/demandware.store/Sites-Site/default/ViewAccessKeys-List`; config.password = this.flags.password || (await password({ - message: 'Enter WebDAV password:', - validate: (v) => (v.trim() ? true : 'Password is required'), + message: `Enter WebDAV password or access key (${accessKeyUrl}):`, + validate: (v) => (v.trim() ? true : 'Password or access key is required'), })); } @@ -180,12 +190,45 @@ export default class SetupInstanceCreate extends BaseCommand (v.trim() ? true : 'Client ID is required'), })); - config.clientSecret = + const clientSecret = this.flags['client-secret'] || (await password({ - message: 'Enter OAuth client secret:', - validate: (v) => (v.trim() ? true : 'Client secret is required'), + message: 'Enter OAuth client secret (leave blank for user auth):', })); + + if (clientSecret.trim()) { + config.clientSecret = clientSecret.trim(); + } + } + } + + // Code version - use flag, or try to detect via OCAPI if OAuth credentials are available + if (this.flags['code-version']) { + config.codeVersion = this.flags['code-version']; + } else if (!force) { + let detectedVersion: string | undefined; + + if (config.clientId) { + try { + const tempInstance = createInstanceFromConfig({ + hostname, + clientId: config.clientId, + clientSecret: config.clientSecret, + }); + const activeVersion = await getActiveCodeVersion(tempInstance); + detectedVersion = activeVersion?.id; + } catch { + // Detection failed - continue without a default + } + } + + const codeVersion = await input({ + message: 'Enter code version:', + default: detectedVersion, + }); + + if (codeVersion.trim()) { + config.codeVersion = codeVersion.trim(); } } diff --git a/packages/b2c-cli/src/commands/setup/instance/set-active.ts b/packages/b2c-cli/src/commands/setup/instance/set-active.ts index f7c63b7b..cc7e74d6 100644 --- a/packages/b2c-cli/src/commands/setup/instance/set-active.ts +++ b/packages/b2c-cli/src/commands/setup/instance/set-active.ts @@ -4,6 +4,7 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, ux} from '@oclif/core'; +import {search} from '@inquirer/prompts'; import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {DwJsonSource} from '@salesforce/b2c-tooling-sdk/config'; import {withDocs} from '../../../i18n/index.js'; @@ -23,7 +24,6 @@ export default class SetupInstanceSetActive extends BaseCommand { const source = new DwJsonSource(); - const name = this.args.name; - - // Check if instance exists const instances = source.listInstances({configPath: this.flags.config}); + + let name = this.args.name; + + if (!name) { + if (instances.length === 0) { + this.error('No instances are configured. Use `b2c setup instance create` to add one.'); + } + + name = await search({ + message: 'Select instance:', + source: (term) => { + const filtered = term ? instances.filter((i) => i.name.includes(term)) : instances; + const sorted = [...filtered].sort((a, b) => (a.active === b.active ? 0 : a.active ? -1 : 1)); + return sorted.map((i) => ({ + name: `${i.name}${i.hostname ? ` (${i.hostname})` : ''}${i.active ? ' [active]' : ''}`, + value: i.name, + })); + }, + }); + } + const instance = instances.find((i) => i.name === name); if (!instance) { From 8058b9f135a9a74e569abe38783f924d4a5a3bc4 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 12 Feb 2026 17:22:06 -0500 Subject: [PATCH 5/7] initial setup --- packages/b2c-cli/src/commands/setup/index.ts | 39 +++++++++++++++++++ .../src/commands/setup/instance/create.ts | 7 ++++ .../src/commands/setup/instance/index.ts | 27 +++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 packages/b2c-cli/src/commands/setup/index.ts create mode 100644 packages/b2c-cli/src/commands/setup/instance/index.ts diff --git a/packages/b2c-cli/src/commands/setup/index.ts b/packages/b2c-cli/src/commands/setup/index.ts new file mode 100644 index 00000000..2522d015 --- /dev/null +++ b/packages/b2c-cli/src/commands/setup/index.ts @@ -0,0 +1,39 @@ +/* + * 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 {confirm} from '@inquirer/prompts'; +import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {withDocs} from '../../i18n/index.js'; + +/** + * Default setup command - provides topic help and prompts to create an instance if none configured. + * + * - `b2c setup` with no instance configured (TTY): prompts to create one + * - `b2c setup` with instance configured or non-TTY: shows topic help + */ +export default class SetupIndex extends BaseCommand { + static description = withDocs('Manage instances, view configuration, and install agent skills', '/cli/setup.html'); + + static examples = ['<%= config.bin %> setup --help', '<%= config.bin %> setup instance create']; + + async run(): Promise { + const hasInstance = this.resolvedConfig.hasB2CInstanceConfig(); + const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY); + + if (!hasInstance && isTTY) { + const shouldCreate = await confirm({ + message: 'No instance configured. Would you like to set one up?', + default: true, + }); + + if (shouldCreate) { + await this.config.runCommand('setup:instance:create'); + return; + } + } + + await this.config.runCommand('help', ['setup']); + } +} diff --git a/packages/b2c-cli/src/commands/setup/instance/create.ts b/packages/b2c-cli/src/commands/setup/instance/create.ts index 92edb377..70be281d 100644 --- a/packages/b2c-cli/src/commands/setup/instance/create.ts +++ b/packages/b2c-cli/src/commands/setup/instance/create.ts @@ -97,6 +97,13 @@ export default class SetupInstanceCreate extends BaseCommand { + static description = withDocs( + 'Create, list, and manage B2C Commerce instance configurations', + '/cli/setup.html#b2c-setup-instance', + ); + + static examples = [ + '<%= config.bin %> setup instance create', + '<%= config.bin %> setup instance list', + '<%= config.bin %> setup instance set-active', + ]; + + async run(): Promise { + await this.config.runCommand('help', ['setup', 'instance']); + } +} From 947f0bd19d28ebf87f503eec526687a150892667 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 12 Feb 2026 17:23:15 -0500 Subject: [PATCH 6/7] changeset --- .changeset/instance-management.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.changeset/instance-management.md b/.changeset/instance-management.md index d69758df..76f9b3a0 100644 --- a/.changeset/instance-management.md +++ b/.changeset/instance-management.md @@ -3,10 +3,4 @@ '@salesforce/b2c-tooling-sdk': minor --- -Add instance management commands for configuring B2C Commerce instances. - -- Renamed `setup config` to `setup inspect` -- Added `setup instance list` to view all configured instances -- Added `setup instance create` to add new instance configurations -- Added `setup instance remove` to delete instance configurations -- Added `setup instance set-active` to set the default instance +Add `setup instance` commands for managing B2C Commerce instance configurations (create, list, remove, set-active). From f0b12639b7fbb1d584f3bf6b7c10f1cdd5b41765 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 12 Feb 2026 17:27:59 -0500 Subject: [PATCH 7/7] lint --- packages/b2c-cli/src/commands/setup/instance/set-active.ts | 2 +- packages/b2c-tooling-sdk/test/config/dw-json.test.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/b2c-cli/src/commands/setup/instance/set-active.ts b/packages/b2c-cli/src/commands/setup/instance/set-active.ts index cc7e74d6..b17bd43b 100644 --- a/packages/b2c-cli/src/commands/setup/instance/set-active.ts +++ b/packages/b2c-cli/src/commands/setup/instance/set-active.ts @@ -53,7 +53,7 @@ export default class SetupInstanceSetActive extends BaseCommand { + source(term) { const filtered = term ? instances.filter((i) => i.name.includes(term)) : instances; const sorted = [...filtered].sort((a, b) => (a.active === b.active ? 0 : a.active ? -1 : 1)); return sorted.map((i) => ({ 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 d56dc248..6e39cf8b 100644 --- a/packages/b2c-tooling-sdk/test/config/dw-json.test.ts +++ b/packages/b2c-tooling-sdk/test/config/dw-json.test.ts @@ -15,7 +15,6 @@ import { addInstance, removeInstance, setActiveInstance, - type DwJsonConfig, type DwJsonMultiConfig, } from '@salesforce/b2c-tooling-sdk/config';