diff --git a/.changeset/instance-management.md b/.changeset/instance-management.md new file mode 100644 index 00000000..76f9b3a0 --- /dev/null +++ b/.changeset/instance-management.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-tooling-sdk': minor +--- + +Add `setup instance` commands for managing B2C Commerce instance configurations (create, list, remove, set-active). diff --git a/docs/cli/setup.md b/docs/cli/setup.md index dc84cf3b..81885e4f 100644 --- a/docs/cli/setup.md +++ b/docs/cli/setup.md @@ -105,6 +105,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/docs/guide/extending.md b/docs/guide/extending.md index 817589ce..02c807f4 100644 --- a/docs/guide/extending.md +++ b/docs/guide/extending.md @@ -206,6 +206,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: 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 new file mode 100644 index 00000000..70be281d --- /dev/null +++ b/packages/b2c-cli/src/commands/setup/instance/create.ts @@ -0,0 +1,313 @@ +/* + * 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, createInstanceFromConfig, type NormalizedConfig} from '@salesforce/b2c-tooling-sdk/config'; +import {getActiveCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; +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'; + +/** + * 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. + */ +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; + + if (!force) { + ux.stdout('Create a new B2C Commerce instance configuration.'); + ux.stdout('For more information on configuration, see:'); + ux.stdout(' https://salesforcecommercecloud.github.io/b2c-developer-tooling/guide/configuration.html'); + ux.stdout(''); + } + + // 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 (accepts a URL or plain 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 or URL:', + validate: (v) => (v.trim() ? true : 'Hostname is required'), + }); + } + hostname = parseHostname(hostname); + + // Build config + const config: Partial = { + hostname, + }; + + // 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']) { + config.clientSecret = this.flags['client-secret']; + } + } + } else { + // Interactive mode - prompt for auth type and credentials + const authType = await select({ + message: 'Configure authentication:', + choices: [ + {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'}, + ], + }); + + // 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'), + })); + + const accessKeyUrl = `https://${hostname}/on/demandware.store/Sites-Site/default/ViewAccessKeys-List`; + config.password = + this.flags.password || + (await password({ + message: `Enter WebDAV password or access key (${accessKeyUrl}):`, + validate: (v) => (v.trim() ? true : 'Password or access key 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'), + })); + + const clientSecret = + this.flags['client-secret'] || + (await password({ + 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(); + } + } + + // 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/index.ts b/packages/b2c-cli/src/commands/setup/instance/index.ts new file mode 100644 index 00000000..4cef54f0 --- /dev/null +++ b/packages/b2c-cli/src/commands/setup/instance/index.ts @@ -0,0 +1,27 @@ +/* + * 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 {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {withDocs} from '../../../i18n/index.js'; + +/** + * Default instance command - shows topic help for instance subcommands. + */ +export default class SetupInstanceIndex 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']); + } +} 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..b17bd43b --- /dev/null +++ b/packages/b2c-cli/src/commands/setup/instance/set-active.ts @@ -0,0 +1,103 @@ +/* + * 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 {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'; + +/** + * 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', + }), + }; + + 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 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) { + 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-tooling-sdk/src/config/dw-json.ts b/packages/b2c-tooling-sdk/src/config/dw-json.ts index 4805584d..37bd3b1f 100644 --- a/packages/b2c-tooling-sdk/src/config/dw-json.ts +++ b/packages/b2c-tooling-sdk/src/config/dw-json.ts @@ -196,6 +196,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 d3e04aa2..c557ae91 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, normalizeConfigKeys} 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 b2781530..98001409 100644 --- a/packages/b2c-tooling-sdk/src/config/mapping.ts +++ b/packages/b2c-tooling-sdk/src/config/mapping.ts @@ -130,6 +130,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.webdavHostname = config.webdavHostname; + } + if (config.codeVersion !== undefined) { + result.codeVersion = config.codeVersion; + } + if (config.username !== undefined) { + result.username = config.username; + } + if (config.password !== undefined) { + result.password = config.password; + } + if (config.clientId !== undefined) { + result.clientId = config.clientId; + } + if (config.clientSecret !== undefined) { + result.clientSecret = config.clientSecret; + } + if (config.scopes !== undefined) { + result.oauthScopes = config.scopes; + } + if (config.shortCode !== undefined) { + result.shortCode = config.shortCode; + } + if (config.tenantId !== undefined) { + result.tenantId = config.tenantId; + } + if (config.authMethods !== undefined) { + result.authMethods = config.authMethods; + } + if (config.accountManagerHost !== undefined) { + result.accountManagerHost = 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.certificatePassphrase = config.certificatePassphrase; + } + if (config.selfSigned !== undefined) { + result.selfSigned = 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..caee5c3b 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, + 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, + 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 3f2de7dd..44498622 100644 --- a/packages/b2c-tooling-sdk/src/config/types.ts +++ b/packages/b2c-tooling-sdk/src/config/types.ts @@ -220,6 +220,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; } /** @@ -256,6 +314,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 65944092..6e39cf8b 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,16 @@ 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} from '@salesforce/b2c-tooling-sdk/config'; +import { + findDwJson, + loadDwJson, + loadFullDwJson, + saveDwJson, + addInstance, + removeInstance, + setActiveInstance, + type DwJsonMultiConfig, +} from '@salesforce/b2c-tooling-sdk/config'; describe('config/dw-json', () => { let tempDir: string; @@ -262,4 +271,258 @@ describe('config/dw-json', () => { expect(result?.config.shortCode).to.equal('abc123'); }); }); + + 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 53ed2177..1e8da636 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', () => { diff --git a/skills/b2c-cli/skills/b2c-config/SKILL.md b/skills/b2c-cli/skills/b2c-config/SKILL.md index baa064d2..f6763cb8 100644 --- a/skills/b2c-cli/skills/b2c-config/SKILL.md +++ b/skills/b2c-cli/skills/b2c-config/SKILL.md @@ -5,7 +5,7 @@ description: View and debug b2c CLI configuration and understand where credentia # B2C Config Skill -Use the `b2c setup inspect` 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. > **Tip:** `b2c setup config` still works as an alias. If `b2c` is not installed globally, use `npx @salesforce/b2c-cli` instead (e.g., `npx @salesforce/b2c-cli setup inspect`). @@ -20,7 +20,14 @@ Use `b2c setup inspect` 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 @@ -55,9 +62,61 @@ b2c setup inspect --json | jq '.config' 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)