diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 4dc263d..f60c68e 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -12,6 +12,13 @@ const guideSidebar = [ { text: 'Agent Skills & Plugins', link: '/guide/agent-skills' }, ], }, + { + text: 'Extending', + items: [ + { text: 'Custom Plugins', link: '/guide/extending' }, + { text: '3rd Party Plugins', link: '/guide/third-party-plugins' }, + ], + }, { text: 'CLI Reference', items: [ diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 8a115b1..1fa1b0a 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -86,7 +86,98 @@ You can configure authentication using environment variables: ## Configuration File -You can create a configuration file to store instance settings. See the [CLI Reference](/cli/) for more details on configuration file options. +You can create a `dw.json` file to store instance settings. The CLI searches for this file starting from the current directory and walking up the directory tree. + +### Single Instance + +```json +{ + "hostname": "your-instance.demandware.net", + "code-version": "version1", + "client-id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "client-secret": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +} +``` + +### Multiple Instances + +For projects that work with multiple instances, use the `configs` array: + +```json +{ + "configs": [ + { + "name": "dev", + "active": true, + "hostname": "dev-instance.demandware.net", + "code-version": "version1", + "client-id": "dev-client-id" + }, + { + "name": "staging", + "hostname": "staging-instance.demandware.net", + "code-version": "version1", + "client-id": "staging-client-id" + } + ] +} +``` + +Use the `--instance` flag to select a specific configuration: + +```bash +b2c code deploy --instance staging +``` + +If no instance is specified, the config with `"active": true` is used. + +### Supported Fields + +| Field | Description | +|-------|-------------| +| `hostname` | B2C instance hostname | +| `webdav-hostname` | Separate hostname for WebDAV (if different) | +| `code-version` | Code version for deployments | +| `client-id` | OAuth client ID | +| `client-secret` | OAuth client secret | +| `username` | Basic auth username | +| `password` | Basic auth password/access-key | +| `scopes` | OAuth scopes (array or comma-separated string) | +| `auth-methods` | Authentication methods in priority order | +| `account-manager-host` | Custom Account Manager hostname | +| `shortCode` | SCAPI short code | + +### Resolution Priority + +Configuration is resolved with the following precedence (highest to lowest): + +1. **CLI flags and environment variables** - Explicit values always take priority +2. **Plugin sources (high priority)** - Custom sources with `priority: 'before'` +3. **dw.json** - Project configuration file +4. **~/.mobify** - Home directory file (for MRT API key only) +5. **Plugin sources (low priority)** - Custom sources with `priority: 'after'` + +::: tip Extending Configuration +Plugins can add custom configuration sources like secret managers or environment-specific files. See [Extending the CLI](./extending) for details. +::: + +### Credential Grouping + +To prevent mixing credentials from different sources, certain fields are treated as atomic groups: + +- **OAuth**: `clientId` and `clientSecret` +- **Basic Auth**: `username` and `password` + +If any field in a group is set by a higher-priority source, all fields in that group from lower-priority sources are ignored. This ensures credential pairs always come from the same source. + +**Example:** +- dw.json provides `clientId` only +- A plugin provides `clientSecret` +- Result: Only `clientId` is used; the plugin's `clientSecret` is ignored to prevent mismatched credentials + +::: warning Hostname Mismatch Protection +When you explicitly specify a hostname that differs from the `dw.json` hostname, the CLI ignores all other values from `dw.json` and only uses your explicit overrides. This prevents accidentally using credentials from one instance with a different server. +::: ## Next Steps diff --git a/docs/guide/extending.md b/docs/guide/extending.md new file mode 100644 index 0000000..1b3b103 --- /dev/null +++ b/docs/guide/extending.md @@ -0,0 +1,731 @@ +# Extending the CLI + +The B2C CLI can be extended with custom plugins using the [oclif plugin system](https://oclif.io/docs/plugins). Plugins can add new commands, provide custom configuration sources, and integrate with external systems. + +## Available Hooks + +| Hook | Purpose | SDK Support | +|------|---------|-------------| +| [`b2c:config-sources`](#custom-configuration-sources) | Custom configuration loading | CLI only | +| [`b2c:http-middleware`](#http-middleware) | HTTP request/response middleware | Yes | +| [`b2c:operation-lifecycle`](#operation-lifecycle-hooks) | Operation before/after callbacks | CLI only | +| [`b2c:cartridge-providers`](#cartridge-providers) | Custom cartridge discovery | CLI only | + +**SDK Support** indicates whether the hook can be used programmatically without the CLI. Only HTTP middleware supports direct SDK registration via `globalMiddlewareRegistry`. + +## Plugin Architecture + +B2C CLI plugins are standard oclif plugins with access to B2C-specific hooks and base command classes. + +### Installing Plugins + +```bash +# Install from npm +b2c plugins install @your-org/b2c-plugin-example + +# Link a local plugin (for development) +b2c plugins link /path/to/your/plugin + +# List installed plugins +b2c plugins + +# Uninstall a plugin +b2c plugins uninstall @your-org/b2c-plugin-example +``` + +## Custom Configuration Sources + +Plugins can provide custom configuration sources via the `b2c:config-sources` hook. This allows loading configuration from external systems like secret managers, environment-specific files, or remote APIs. + +### Hook: `b2c:config-sources` + +This hook is called during command initialization, after CLI flags are parsed but before configuration is resolved. Plugins return one or more `ConfigSource` instances that integrate into the configuration resolution chain. + +**Hook Options:** + +| Property | Type | Description | +|----------|------|-------------| +| `instance` | `string \| undefined` | The `--instance` flag value | +| `configPath` | `string \| undefined` | The `--config` flag value | +| `resolveOptions` | `ResolveConfigOptions` | Full resolution options for advanced use | + +**Hook Result:** + +| Property | Type | Description | +|----------|------|-------------| +| `sources` | `ConfigSource[]` | Config sources to add to resolution | +| `priority` | `'before' \| 'after'` | Where to insert relative to defaults (default: `'after'`) | + +### Priority Ordering + +Configuration is resolved with the following precedence: + +1. **CLI flags and environment variables** - Always highest priority +2. **Plugin sources with `priority: 'before'`** - Override dw.json defaults +3. **Default sources** - `dw.json` and `~/.mobify` +4. **Plugin sources with `priority: 'after'`** - Fill gaps left by defaults + +Each source fills in missing values - it doesn't override values from higher-priority sources. + +::: warning Credential Grouping +OAuth credentials (`clientId`/`clientSecret`) and Basic auth credentials (`username`/`password`) are treated as atomic groups. If any field in a group is already set by a higher-priority source, all fields in that group from your source will be ignored. Ensure your source provides complete credential pairs, or that higher-priority sources don't partially define the same credentials. +::: + +### Example: Custom Config Source Plugin + +The SDK includes an example plugin that loads configuration from `.env.b2c` files: + +**Repository:** [`packages/b2c-plugin-example-config`](https://github.com/SalesforceCommerceCloud/b2c-developer-tooling/tree/main/packages/b2c-plugin-example-config) + +#### Plugin Structure + +``` +b2c-plugin-example-config/ +├── package.json +├── tsconfig.json +└── src/ + ├── index.ts + ├── hooks/ + │ └── config-sources.ts # Hook implementation + └── sources/ + └── env-file-source.ts # ConfigSource implementation +``` + +#### package.json + +Register your hook in the oclif configuration: + +```json +{ + "name": "@your-org/b2c-plugin-custom-config", + "oclif": { + "hooks": { + "b2c:config-sources": "./dist/hooks/config-sources.js", + "b2c:http-middleware": "./dist/hooks/http-middleware.js", + "b2c:operation-lifecycle": "./dist/hooks/operation-lifecycle.js", + "b2c:cartridge-providers": "./dist/hooks/cartridge-providers.js" + } + }, + "dependencies": { + "@salesforce/b2c-tooling-sdk": "^0.0.1-preview" + }, + "peerDependencies": { + "@oclif/core": "^4" + } +} +``` + +::: tip +You only need to register hooks your plugin actually implements. The example above shows all available hooks. +::: + +#### Hook Implementation + +```typescript +// src/hooks/config-sources.ts +import type { ConfigSourcesHook } from '@salesforce/b2c-tooling-sdk/cli'; +import { MyCustomSource } from '../sources/my-custom-source.js'; + +const hook: ConfigSourcesHook = async function(options) { + // Access oclif context via `this` + this.debug(`Hook called with instance: ${options.instance}`); + + return { + sources: [new MyCustomSource()], + // 'before' = higher priority than dw.json + // 'after' = lower priority (default) + priority: 'before', + }; +}; + +export default hook; +``` + +#### ConfigSource Implementation + +```typescript +// src/sources/my-custom-source.ts +import type { + ConfigSource, + NormalizedConfig, + ResolveConfigOptions +} from '@salesforce/b2c-tooling-sdk/config'; + +export class MyCustomSource implements ConfigSource { + readonly name = 'my-custom-source'; + + load(options: ResolveConfigOptions): NormalizedConfig | undefined { + // Load config from your custom source + // Return undefined if source is not available + + return { + hostname: 'example.sandbox.us03.dx.commercecloud.salesforce.com', + clientId: 'your-client-id', + clientSecret: 'your-client-secret', + codeVersion: 'version1', + }; + } + + // Optional: return path for diagnostics + getPath(): string | undefined { + return '/path/to/config/source'; + } +} +``` + +### Plugin Configuration + +Plugins cannot add flags to commands they don't own (this is an oclif limitation). Instead, plugins should accept configuration via environment variables: + +```bash +# Configure the example plugin's env file location +export B2C_ENV_FILE_PATH=/path/to/custom/.env.b2c +b2c code deploy +``` + +**Plugin authors should:** + +1. Document supported environment variables in your plugin README +2. Use sensible defaults when env vars are not set +3. Access the `flags` property in hook options for future extensibility + +**Hook Options:** + +The hook receives a `flags` property containing all parsed CLI flags from the current command: + +```typescript +const hook: ConfigSourcesHook = async function(options) { + // Access parsed flags (read-only) + this.debug(`Debug mode: ${options.flags?.debug}`); + + // Use env vars for plugin-specific configuration + const customPath = process.env.MY_PLUGIN_CONFIG_PATH; + + return { + sources: [new MySource(customPath)], + priority: 'after', + }; +}; +``` + +### NormalizedConfig Fields + +Your `ConfigSource` can return any of these configuration fields: + +| Field | Type | Description | +|-------|------|-------------| +| `hostname` | `string` | B2C instance hostname | +| `webdavHostname` | `string` | Separate WebDAV hostname | +| `codeVersion` | `string` | Code version for deployments | +| `username` | `string` | Basic auth username | +| `password` | `string` | Basic auth password | +| `clientId` | `string` | OAuth client ID | +| `clientSecret` | `string` | OAuth client secret | +| `scopes` | `string[]` | OAuth scopes | +| `authMethods` | `AuthMethod[]` | Allowed auth methods | +| `accountManagerHost` | `string` | Account Manager hostname | +| `shortCode` | `string` | SCAPI short code | +| `mrtProject` | `string` | MRT project slug | +| `mrtEnvironment` | `string` | MRT environment name | +| `mrtApiKey` | `string` | MRT API key | + +## HTTP Middleware + +Plugins can inject middleware into HTTP clients via the `b2c:http-middleware` hook. This allows logging, metrics collection, custom headers, or request/response transformation. + +### Hook: `b2c:http-middleware` + +This hook is called during command initialization, after flags are parsed but before any API clients are created. Middleware is applied to all HTTP clients (OCAPI, SLAS, WebDAV, etc.). + +**Hook Options:** + +| Property | Type | Description | +|----------|------|-------------| +| `flags` | `Record` | Parsed CLI flags (read-only) | + +**Hook Result:** + +| Property | Type | Description | +|----------|------|-------------| +| `providers` | `HttpMiddlewareProvider[]` | Middleware providers to register | + +### HttpMiddlewareProvider Interface + +```typescript +import type { HttpMiddlewareProvider, HttpClientType } from '@salesforce/b2c-tooling-sdk/clients'; + +const provider: HttpMiddlewareProvider = { + name: 'my-middleware', + + getMiddleware(clientType: HttpClientType) { + // Return middleware for specific client types + // Return undefined to skip this client type + return { + onRequest({ request }) { + // Modify request before sending + request.headers.set('X-Custom-Header', 'value'); + return request; + }, + onResponse({ response }) { + // Process response + console.log(`Status: ${response.status}`); + return response; + }, + }; + }, +}; +``` + +**Client Types:** +- `ocapi` - OCAPI Data API +- `slas` - SLAS Admin API +- `ods` - On-Demand Sandbox API +- `mrt` - Managed Runtime API +- `custom-apis` - Custom SCAPI endpoints +- `webdav` - WebDAV file operations + +### Example: Request Logging Middleware + +```typescript +// src/hooks/http-middleware.ts +import type { HttpMiddlewareHook } from '@salesforce/b2c-tooling-sdk/cli'; +import type { HttpMiddlewareProvider } from '@salesforce/b2c-tooling-sdk/clients'; + +const hook: HttpMiddlewareHook = async function(options) { + const loggingProvider: HttpMiddlewareProvider = { + name: 'request-logger', + getMiddleware(clientType) { + return { + onRequest({ request }) { + const startTime = Date.now(); + (request as any)._startTime = startTime; + console.log(`[${clientType}] ${request.method} ${request.url}`); + return request; + }, + onResponse({ request, response }) { + const duration = Date.now() - ((request as any)._startTime || 0); + console.log(`[${clientType}] ${response.status} (${duration}ms)`); + return response; + }, + }; + }, + }; + + return { providers: [loggingProvider] }; +}; + +export default hook; +``` + +### SDK Usage (without CLI) + +For programmatic SDK usage without the CLI, register middleware directly: + +```typescript +import { globalMiddlewareRegistry } from '@salesforce/b2c-tooling-sdk/clients'; + +globalMiddlewareRegistry.register({ + name: 'my-sdk-middleware', + getMiddleware(clientType) { + return { + onRequest({ request }) { + // Modify request + return request; + }, + }; + }, +}); +``` + +## B2C Operation Lifecycle Hooks + +Plugins can observe and control B2C operation execution via the `b2c:operation-lifecycle` hook. This enables audit logging, notifications, metrics, or governance policies. + +The lifecycle hooks are B2C-specific (prefixed with `B2C`) to allow for future platform-specific lifecycle hooks (e.g., MRT). + +### Hook: `b2c:operation-lifecycle` + +This hook is called during command initialization. Registered providers receive callbacks before and after supported B2C operations. + +**Hook Options:** + +| Property | Type | Description | +|----------|------|-------------| +| `flags` | `Record` | Parsed CLI flags (read-only) | + +**Hook Result:** + +| Property | Type | Description | +|----------|------|-------------| +| `providers` | `B2COperationLifecycleProvider[]` | Lifecycle providers to register | + +### Supported Operations + +| Operation Type | Command | +|----------------|---------| +| `job:run` | `b2c job run` | +| `job:import` | `b2c job import` | +| `job:export` | `b2c job export` | +| `code:deploy` | `b2c code deploy` | + +### B2COperationLifecycleProvider Interface + +```typescript +import type { + B2COperationLifecycleProvider, + B2COperationContext, + B2COperationResult, +} from '@salesforce/b2c-tooling-sdk/cli'; + +const provider: B2COperationLifecycleProvider = { + name: 'my-lifecycle-provider', + + async beforeOperation(context) { + // Called before operation executes + // Return { skip: true } to prevent execution + console.log(`Starting: ${context.operationType}`); + + // Access the B2C instance for API calls + const { instance } = context; + console.log(`Target: ${instance.config.hostname}`); + + // Optional: skip operation based on policy + if (shouldBlock(context)) { + return { + skip: true, + skipReason: 'Blocked by policy', + }; + } + + return {}; // Continue with operation + }, + + async afterOperation(context, result) { + // Called after operation completes (success or failure) + console.log(`Completed: ${context.operationType}`); + console.log(`Success: ${result.success}`); + console.log(`Duration: ${result.duration}ms`); + + if (!result.success && result.error) { + console.error(`Error: ${result.error.message}`); + } + }, +}; +``` + +### B2COperationContext + +The context includes the full `B2CInstance`, giving plugins access to API clients without reconstruction. + +| Property | Type | Description | +|----------|------|-------------| +| `operationType` | `B2COperationType` | Operation type (e.g., `job:run`) | +| `operationId` | `string` | Unique ID for this invocation | +| `instance` | `B2CInstance` | Target B2C instance with configured clients | +| `startTime` | `number` | Start timestamp | +| `metadata` | `Record` | Operation-specific data | + +**Accessing the instance:** +```typescript +async beforeOperation(context) { + const { instance } = context; + + // Make API calls using the instance's clients + const { data } = await instance.ocapi.GET('/sites'); + + // Access configuration + console.log(`Hostname: ${instance.config.hostname}`); + console.log(`Code version: ${instance.config.codeVersion}`); +} +``` + +### B2COperationResult + +| Property | Type | Description | +|----------|------|-------------| +| `success` | `boolean` | Whether operation succeeded | +| `error` | `Error \| undefined` | Error if failed | +| `duration` | `number` | Execution time in ms | +| `data` | `unknown` | Operation-specific result data | + +### Example: Audit Logging Plugin + +```typescript +// src/hooks/operation-lifecycle.ts +import type { B2COperationLifecycleHook } from '@salesforce/b2c-tooling-sdk/cli'; +import type { B2COperationLifecycleProvider } from '@salesforce/b2c-tooling-sdk/cli'; + +const hook: B2COperationLifecycleHook = async function(options) { + const auditProvider: B2COperationLifecycleProvider = { + name: 'audit-logger', + + async beforeOperation(context) { + console.log(JSON.stringify({ + event: 'operation_start', + type: context.operationType, + id: context.operationId, + hostname: context.instance.config.hostname, + metadata: context.metadata, + timestamp: new Date().toISOString(), + })); + return {}; + }, + + async afterOperation(context, result) { + console.log(JSON.stringify({ + event: 'operation_end', + type: context.operationType, + id: context.operationId, + success: result.success, + duration: result.duration, + error: result.error?.message, + timestamp: new Date().toISOString(), + })); + }, + }; + + return { providers: [auditProvider] }; +}; + +export default hook; +``` + +### Example: Deployment Freeze Policy + +```typescript +const freezeProvider: B2COperationLifecycleProvider = { + name: 'deployment-freeze', + + async beforeOperation(context) { + // Only check deploy operations + if (!context.operationType.startsWith('code:')) { + return {}; + } + + // Check if deployment freeze is active + const freezeUntil = process.env.DEPLOYMENT_FREEZE_UNTIL; + if (freezeUntil && new Date() < new Date(freezeUntil)) { + return { + skip: true, + skipReason: `Deployment freeze until ${freezeUntil}`, + }; + } + + return {}; + }, +}; +``` + +## Cartridge Providers + +Plugins can provide custom cartridge discovery logic via the `b2c:cartridge-providers` hook. This allows loading cartridges from manifest files, remote sources, or custom locations. + +### Hook: `b2c:cartridge-providers` + +This hook is called during cartridge command initialization. Providers and transformers are collected and used during cartridge discovery. + +**Hook Options:** + +| Property | Type | Description | +|----------|------|-------------| +| `directory` | `string` | Directory being searched | +| `flags` | `Record` | Parsed CLI flags (read-only) | + +**Hook Result:** + +| Property | Type | Description | +|----------|------|-------------| +| `providers` | `CartridgeProvider[]` | Cartridge discovery providers | +| `transformers` | `CartridgeTransformer[]` | Cartridge mapping transformers | + +### CartridgeProvider Interface + +```typescript +import type { + CartridgeProvider, + CartridgeDiscoveryOptions, +} from '@salesforce/b2c-tooling-sdk/cli'; +import type { CartridgeMapping } from '@salesforce/b2c-tooling-sdk/operations/code'; + +const provider: CartridgeProvider = { + name: 'manifest-provider', + priority: 'before', // 'before' or 'after' default discovery + + async findCartridges(options: CartridgeDiscoveryOptions) { + // Return cartridge mappings from custom source + // Return empty array if no cartridges available + return [ + { + name: 'app_custom', + src: '/path/to/cartridge', + dest: 'app_custom', + }, + ]; + }, +}; +``` + +**Priority:** +- `'before'` - Runs before default `.project` discovery (can override defaults) +- `'after'` - Runs after default discovery (adds additional cartridges) + +Cartridges are deduplicated by name (first wins). + +### CartridgeTransformer Interface + +Transformers modify the final cartridge list after all providers have contributed: + +```typescript +import type { CartridgeTransformer } from '@salesforce/b2c-tooling-sdk/cli'; + +const transformer: CartridgeTransformer = { + name: 'version-suffix', + + async transform(cartridges, options) { + // Modify cartridge mappings + return cartridges.map(c => ({ + ...c, + dest: `${c.name}_v2`, // Rename destination + })); + }, +}; +``` + +### CartridgeDiscoveryOptions + +| Property | Type | Description | +|----------|------|-------------| +| `directory` | `string` | Search directory (absolute path) | +| `include` | `string[]` | Cartridge names to include | +| `exclude` | `string[]` | Cartridge names to exclude | +| `codeVersion` | `string` | Target code version (if known) | +| `instance` | `B2CInstance` | Target B2C instance | + +### Example: Manifest-Based Discovery + +```typescript +// src/hooks/cartridge-providers.ts +import type { CartridgeProvidersHook } from '@salesforce/b2c-tooling-sdk/cli'; +import type { CartridgeProvider } from '@salesforce/b2c-tooling-sdk/cli'; +import fs from 'node:fs'; +import path from 'node:path'; + +const hook: CartridgeProvidersHook = async function(options) { + const manifestProvider: CartridgeProvider = { + name: 'manifest-provider', + priority: 'before', + + async findCartridges(discoveryOptions) { + const manifestPath = path.join(discoveryOptions.directory, 'cartridges.json'); + + if (!fs.existsSync(manifestPath)) { + return []; + } + + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + + return manifest.cartridges.map((c: any) => ({ + name: c.name, + src: path.resolve(discoveryOptions.directory, c.path), + dest: c.name, + })); + }, + }; + + return { providers: [manifestProvider] }; +}; + +export default hook; +``` + +### Example: Environment-Based Filtering + +```typescript +const envFilterTransformer: CartridgeTransformer = { + name: 'env-filter', + + async transform(cartridges, options) { + const env = process.env.B2C_ENVIRONMENT || 'development'; + + // Exclude test cartridges in production + if (env === 'production') { + return cartridges.filter(c => !c.name.startsWith('test_')); + } + + return cartridges; + }, +}; +``` + +## Adding Custom Commands + +Extend the B2C base command classes to create commands with built-in configuration and authentication: + +```typescript +import { InstanceCommand } from '@salesforce/b2c-tooling-sdk/cli'; +import { Flags } from '@oclif/core'; + +export default class MyCommand extends InstanceCommand { + static description = 'My custom command'; + + static flags = { + site: Flags.string({ description: 'Site ID', required: true }), + }; + + async run(): Promise { + // Access resolved configuration + const { hostname, clientId } = this.resolvedConfig; + + // Access B2C instance with pre-configured clients + const instance = this.instance; + + // Make API calls + const { data } = await instance.ocapi.GET('/sites/{site_id}', { + params: { path: { site_id: this.flags.site } }, + }); + + this.log(`Site: ${data.id}`); + } +} +``` + +### Base Command Classes + +| Class | Use Case | +|-------|----------| +| `BaseCommand` | Minimal base with logging and config loading | +| `OAuthCommand` | Commands requiring OAuth authentication | +| `InstanceCommand` | Commands targeting a B2C instance | +| `CartridgeCommand` | Code deployment commands | +| `JobCommand` | Job execution commands | +| `WebDavCommand` | WebDAV file operations | +| `MrtCommand` | Managed Runtime operations | +| `OdsCommand` | On-Demand Sandbox operations | + +## Testing Plugins + +Link your plugin locally for development: + +```bash +# Build your plugin +cd /path/to/your/plugin +pnpm build + +# Link to CLI +b2c plugins link /path/to/your/plugin + +# Verify installation +b2c plugins + +# Test with debug logging +DEBUG='oclif:*' b2c your-command + +# Unlink when done +b2c plugins unlink @your-org/your-plugin +``` + +## Next Steps + +- [Configuration Guide](./configuration) - Learn about config resolution +- [API Reference](/api/) - Explore the SDK API +- [Example Plugin Source](https://github.com/SalesforceCommerceCloud/b2c-developer-tooling/tree/main/packages/b2c-plugin-example-config) - Full working example diff --git a/docs/guide/third-party-plugins.md b/docs/guide/third-party-plugins.md new file mode 100644 index 0000000..6bc12c2 --- /dev/null +++ b/docs/guide/third-party-plugins.md @@ -0,0 +1,135 @@ +# 3rd Party Plugins + +The B2C CLI can be extended with community-developed plugins that add new functionality or integrate with external tools. + +## Installing Plugins + +Plugins can be installed directly from GitHub or npm: + +```bash +# Install from GitHub (owner/repo format) +b2c plugins install sfcc-solutions-share/b2c-plugin-intellij-sfcc-config + +# Install from npm +b2c plugins install @some-org/b2c-plugin-example + +# List installed plugins +b2c plugins + +# Uninstall a plugin +b2c plugins uninstall b2c-plugin-intellij-sfcc-config +``` + +## Available Plugins + +### IntelliJ SFCC Config Plugin + +**Repository:** [sfcc-solutions-share/b2c-plugin-intellij-sfcc-config](https://github.com/sfcc-solutions-share/b2c-plugin-intellij-sfcc-config) + +Loads B2C instance configuration from the [IntelliJ SFCC plugin](https://plugins.jetbrains.com/plugin/13668-salesforce-b2c-commerce-sfcc-) settings. This allows you to share configuration between your IDE and the CLI without duplicating settings. + +#### Installation + +```bash +b2c plugins install sfcc-solutions-share/b2c-plugin-intellij-sfcc-config +``` + +#### Features + +- Reads connection settings from `.idea/misc.xml` +- Optionally decrypts credentials from the IntelliJ SFCC plugin's encrypted credentials file +- Supports the `--instance` flag to select specific connections +- Provides hostname, username, code version, client ID, and more + +#### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `SFCC_INTELLIJ_PROJECT_FILE` | Path to `.idea/misc.xml` | `./.idea/misc.xml` | +| `SFCC_INTELLIJ_CREDENTIALS_FILE` | Path to encrypted credentials file | (none) | +| `SFCC_INTELLIJ_CREDENTIALS_KEY` | Decryption key for credentials | (none) | +| `SFCC_INTELLIJ_ALGORITHM` | Encryption algorithm | `aes-192-ecb` | + +#### Usage + +```bash +# Basic usage - reads from .idea/misc.xml in current directory +cd /path/to/intellij-project +b2c code list + +# Select a specific instance +b2c code list --instance staging + +# With credentials decryption +export SFCC_INTELLIJ_CREDENTIALS_FILE=~/.intellij-sfcc-credentials +export SFCC_INTELLIJ_CREDENTIALS_KEY="your-24-byte-key" +b2c code deploy +``` + +### macOS Keychain Plugin + +**Repository:** [sfcc-solutions-share/b2c-plugin-macos-keychain](https://github.com/sfcc-solutions-share/b2c-plugin-macos-keychain) + +Loads B2C credentials from the macOS Keychain. This allows secure storage of sensitive credentials without keeping them in files like `dw.json`. + +::: warning macOS Only +This plugin only works on macOS. +::: + +#### Installation + +```bash +b2c plugins install sfcc-solutions-share/b2c-plugin-macos-keychain +``` + +#### Features + +- Stores credentials as JSON blobs in the macOS Keychain +- Supports global defaults via a `*` account (shared OAuth credentials) +- Supports instance-specific credentials that override globals +- Optional `defaultInstance` to auto-select an instance +- Merges with other config sources (dw.json, environment variables) + +#### Storing Credentials + +```bash +# Store global OAuth credentials (shared across all instances) +security add-generic-password -s 'b2c-cli' -a '*' \ + -w '{"clientId":"shared-id","clientSecret":"shared-secret","defaultInstance":"staging"}' -U + +# Store instance-specific credentials +security add-generic-password -s 'b2c-cli' -a 'staging' \ + -w '{"username":"user@example.com","password":"my-webdav-key"}' -U +``` + +#### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `SFCC_KEYCHAIN_SERVICE` | Service name in keychain | `b2c-cli` | +| `SFCC_KEYCHAIN_INSTANCE` | Fallback instance name | (none) | + +#### Usage + +```bash +# Use with explicit instance +b2c code deploy --instance staging + +# Uses defaultInstance from * config if set +b2c code deploy + +# Global OAuth merges with dw.json for other settings +b2c code deploy +``` + +## Creating Your Own Plugin + +Want to create a plugin? See the [Extending the CLI](./extending) guide for documentation on: + +- The `b2c:config-sources` hook for custom configuration sources +- Base command classes for building new commands +- Plugin structure and packaging + +## Submitting Plugins + +If you've created a plugin you'd like listed here, please submit a pull request to the [B2C Developer Tooling repository](https://github.com/SalesforceCommerceCloud/b2c-developer-tooling). diff --git a/eslint.config.mjs b/eslint.config.mjs index 79353db..dcadaf7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -36,6 +36,8 @@ export const sharedRules = { ], // Disable new-cap - incompatible with openapi-fetch (uses GET, POST, etc. methods) 'new-cap': 'off', + // Allow snake_case in object properties - common when working with external APIs + camelcase: ['error', {properties: 'never'}], }; /** diff --git a/packages/b2c-cli/src/commands/_test/index.ts b/packages/b2c-cli/src/commands/_test/index.ts index 3b565a6..e7e065f 100644 --- a/packages/b2c-cli/src/commands/_test/index.ts +++ b/packages/b2c-cli/src/commands/_test/index.ts @@ -38,7 +38,7 @@ export default class Test extends BaseCommand { { username: 'testuser', password: 'secret123', - client_secret: 'abc123xyz', // eslint-disable-line camelcase + client_secret: 'abc123xyz', accessToken: 'eyJhbGciOiJIUzI1NiJ9.test', }, 'This should have redacted fields', diff --git a/packages/b2c-cli/src/commands/code/deploy.ts b/packages/b2c-cli/src/commands/code/deploy.ts index af8b880..5a600a5 100644 --- a/packages/b2c-cli/src/commands/code/deploy.ts +++ b/packages/b2c-cli/src/commands/code/deploy.ts @@ -5,8 +5,10 @@ */ import {Flags} from '@oclif/core'; import { - findAndDeployCartridges, + uploadCartridges, + deleteCartridges, getActiveCodeVersion, + reloadCodeVersion, type DeployResult, } from '@salesforce/b2c-tooling-sdk/operations/code'; import {CartridgeCommand} from '@salesforce/b2c-tooling-sdk/cli'; @@ -68,6 +70,38 @@ export default class CodeDeploy extends CartridgeCommand { this.instance.config.codeVersion = version; } + // Create lifecycle context + const context = this.createContext('code:deploy', { + cartridgePath: this.cartridgePath, + hostname, + codeVersion: version, + reload: this.flags.reload, + delete: this.flags.delete, + ...this.cartridgeOptions, + }); + + // Run beforeOperation hooks - check for skip + const beforeResult = await this.runBeforeHooks(context); + if (beforeResult.skip) { + this.log( + t('commands.code.deploy.skipped', 'Deployment skipped: {{reason}}', { + reason: beforeResult.skipReason || 'skipped by plugin', + }), + ); + return { + cartridges: [], + codeVersion: version, + reloaded: false, + }; + } + + // Find cartridges using providers (supports custom discovery plugins) + const cartridges = await this.findCartridgesWithProviders(); + + if (cartridges.length === 0) { + this.error(t('commands.code.deploy.noCartridges', 'No cartridges found in {{path}}', {path: this.cartridgePath})); + } + this.log( t('commands.code.deploy.deploying', 'Deploying {{path}} to {{hostname}} ({{version}})', { path: this.cartridgePath, @@ -76,12 +110,36 @@ export default class CodeDeploy extends CartridgeCommand { }), ); + // Log found cartridges + for (const c of cartridges) { + this.logger?.debug(` ${c.name} (${c.src})`); + } + try { - const result = await findAndDeployCartridges(this.instance, this.cartridgePath, { - reload: this.flags.reload, - delete: this.flags.delete, - ...this.cartridgeOptions, - }); + // Optionally delete existing cartridges first + if (this.flags.delete) { + await deleteCartridges(this.instance, cartridges); + } + + // Upload cartridges + await uploadCartridges(this.instance, cartridges); + + // Optionally reload code version + let reloaded = false; + if (this.flags.reload) { + try { + await reloadCodeVersion(this.instance, version); + reloaded = true; + } catch (error) { + this.logger?.debug(`Could not reload code version: ${error instanceof Error ? error.message : error}`); + } + } + + const result: DeployResult = { + cartridges, + codeVersion: version, + reloaded, + }; this.log( t('commands.code.deploy.summary', 'Deployed {{count}} cartridge(s) to {{codeVersion}}', { @@ -94,8 +152,22 @@ export default class CodeDeploy extends CartridgeCommand { this.log(t('commands.code.deploy.reloaded', 'Code version reloaded')); } + // Run afterOperation hooks with success + await this.runAfterHooks(context, { + success: true, + duration: Date.now() - context.startTime, + data: result, + }); + return result; } catch (error) { + // Run afterOperation hooks with failure + await this.runAfterHooks(context, { + success: false, + error: error instanceof Error ? error : new Error(String(error)), + duration: Date.now() - context.startTime, + }); + if (error instanceof Error) { this.error(t('commands.code.deploy.failed', 'Deployment failed: {{message}}', {message: error.message})); } diff --git a/packages/b2c-cli/src/commands/job/export.ts b/packages/b2c-cli/src/commands/job/export.ts index db3d954..611c2ea 100644 --- a/packages/b2c-cli/src/commands/job/export.ts +++ b/packages/b2c-cli/src/commands/job/export.ts @@ -140,6 +140,30 @@ export default class JobExport extends JobCommand { ); } + // Create lifecycle context + const context = this.createContext('job:export', { + dataUnits, + output, + hostname, + keepArchive, + zipOnly, + }); + + // Run beforeOperation hooks - check for skip + const beforeResult = await this.runBeforeHooks(context); + if (beforeResult.skip) { + this.log( + t('commands.job.export.skipped', 'Export skipped: {{reason}}', { + reason: beforeResult.skipReason || 'skipped by plugin', + }), + ); + return { + execution: {execution_status: 'finished', exit_status: {code: 'skipped'}}, + archiveFilename: '', + archiveKept: false, + } as unknown as SiteArchiveExportResult & {localPath?: string}; + } + this.log( t('commands.job.export.exporting', 'Exporting data from {{hostname}}...', { hostname, @@ -192,8 +216,23 @@ export default class JobExport extends JobCommand { ); } + // Run afterOperation hooks with success + await this.runAfterHooks(context, { + success: true, + duration: Date.now() - context.startTime, + data: result, + }); + return result; } catch (error) { + // Run afterOperation hooks with failure + await this.runAfterHooks(context, { + success: false, + error: error instanceof Error ? error : new Error(String(error)), + duration: Date.now() - context.startTime, + data: error instanceof JobExecutionError ? error.execution : undefined, + }); + if (error instanceof JobExecutionError) { if (showLog) { await this.showJobLog(error.execution); @@ -268,7 +307,6 @@ export default class JobExport extends JobCommand { // Inventory lists (API uses snake_case keys) if (params.inventoryList && params.inventoryList.length > 0) { - // eslint-disable-next-line camelcase dataUnits.inventory_lists = {}; for (const listId of params.inventoryList) { dataUnits.inventory_lists[listId] = true; @@ -277,7 +315,6 @@ export default class JobExport extends JobCommand { // Price books (API uses snake_case keys) if (params.priceBook && params.priceBook.length > 0) { - // eslint-disable-next-line camelcase dataUnits.price_books = {}; for (const bookId of params.priceBook) { dataUnits.price_books[bookId] = true; @@ -286,7 +323,6 @@ export default class JobExport extends JobCommand { // Global data (API uses snake_case keys) if (params.globalData) { - // eslint-disable-next-line camelcase dataUnits.global_data = this.parseGlobalDataUnits(params.globalData); } diff --git a/packages/b2c-cli/src/commands/job/import.ts b/packages/b2c-cli/src/commands/job/import.ts index 0088015..3b735ad 100644 --- a/packages/b2c-cli/src/commands/job/import.ts +++ b/packages/b2c-cli/src/commands/job/import.ts @@ -65,6 +65,29 @@ export default class JobImport extends JobCommand { const hostname = this.resolvedConfig.hostname!; + // Create lifecycle context + const context = this.createContext('job:import', { + target, + remote, + keepArchive, + hostname, + }); + + // Run beforeOperation hooks - check for skip + const beforeResult = await this.runBeforeHooks(context); + if (beforeResult.skip) { + this.log( + t('commands.job.import.skipped', 'Import skipped: {{reason}}', { + reason: beforeResult.skipReason || 'skipped by plugin', + }), + ); + return { + execution: {execution_status: 'finished', exit_status: {code: 'skipped'}}, + archiveFilename: '', + archiveKept: false, + } as unknown as SiteArchiveImportResult; + } + if (remote) { this.log( t('commands.job.import.importingRemote', 'Importing {{target}} from {{hostname}}...', { @@ -118,8 +141,23 @@ export default class JobImport extends JobCommand { ); } + // Run afterOperation hooks with success + await this.runAfterHooks(context, { + success: true, + duration: Date.now() - context.startTime, + data: result, + }); + return result; } catch (error) { + // Run afterOperation hooks with failure + await this.runAfterHooks(context, { + success: false, + error: error instanceof Error ? error : new Error(String(error)), + duration: Date.now() - context.startTime, + data: error instanceof JobExecutionError ? error.execution : undefined, + }); + if (error instanceof JobExecutionError) { if (showLog) { await this.showJobLog(error.execution); diff --git a/packages/b2c-cli/src/commands/job/run.ts b/packages/b2c-cli/src/commands/job/run.ts index b6d1503..6c33f84 100644 --- a/packages/b2c-cli/src/commands/job/run.ts +++ b/packages/b2c-cli/src/commands/job/run.ts @@ -76,6 +76,27 @@ export default class JobRun extends JobCommand { const parameters = this.parseParameters(param || []); const rawBody = body ? this.parseBody(body) : undefined; + // Create lifecycle context + const context = this.createContext('job:run', { + jobId, + parameters: rawBody ? undefined : parameters, + body: rawBody, + wait, + hostname: this.resolvedConfig.hostname, + }); + + // Run beforeOperation hooks - check for skip + const beforeResult = await this.runBeforeHooks(context); + if (beforeResult.skip) { + this.log( + t('commands.job.run.skipped', 'Job execution skipped: {{reason}}', { + reason: beforeResult.skipReason || 'skipped by plugin', + }), + ); + // Return a mock execution for JSON output + return {execution_status: 'finished', exit_status: {code: 'skipped'}} as unknown as JobExecution; + } + this.log( t('commands.job.run.executing', 'Executing job {{jobId}} on {{hostname}}...', { jobId, @@ -91,6 +112,13 @@ export default class JobRun extends JobCommand { waitForRunning: !noWaitRunning, }); } catch (error) { + // Run afterOperation hooks with failure + await this.runAfterHooks(context, { + success: false, + error: error instanceof Error ? error : new Error(String(error)), + duration: Date.now() - context.startTime, + }); + if (error instanceof Error) { this.error( t('commands.job.run.executionFailed', 'Failed to execute job: {{message}}', {message: error.message}), @@ -133,7 +161,22 @@ export default class JobRun extends JobCommand { duration: durationSec, }), ); + + // Run afterOperation hooks with success + await this.runAfterHooks(context, { + success: true, + duration: Date.now() - context.startTime, + data: execution, + }); } catch (error) { + // Run afterOperation hooks with failure + await this.runAfterHooks(context, { + success: false, + error: error instanceof Error ? error : new Error(String(error)), + duration: Date.now() - context.startTime, + data: error instanceof JobExecutionError ? error.execution : undefined, + }); + if (error instanceof JobExecutionError) { if (showLog) { await this.showJobLog(error.execution); @@ -146,6 +189,13 @@ export default class JobRun extends JobCommand { } throw error; } + } else { + // Not waiting - run afterOperation hooks with current state + await this.runAfterHooks(context, { + success: true, + duration: Date.now() - context.startTime, + data: execution, + }); } // JSON output handled by oclif diff --git a/packages/b2c-cli/src/commands/ods/create.ts b/packages/b2c-cli/src/commands/ods/create.ts index 5fa0e2f..a558676 100644 --- a/packages/b2c-cli/src/commands/ods/create.ts +++ b/packages/b2c-cli/src/commands/ods/create.ts @@ -23,7 +23,7 @@ const TERMINAL_STATES = new Set(['deleted', 'failed', 'started']); * Default OCAPI resources to grant the client ID access to. * These enable common CI/CD operations like code deployment and job execution. */ -/* eslint-disable camelcase */ + const DEFAULT_OCAPI_RESOURCES: NonNullable = [ {resource_id: '/code_versions', methods: ['get'], read_attributes: '(**)', write_attributes: '(**)'}, {resource_id: '/code_versions/*', methods: ['patch', 'delete'], read_attributes: '(**)', write_attributes: '(**)'}, @@ -31,7 +31,6 @@ const DEFAULT_OCAPI_RESOURCES: NonNullable = {resource_id: '/jobs/*/executions/*', methods: ['get'], read_attributes: '(**)', write_attributes: '(**)'}, {resource_id: '/sites/*/cartridges', methods: ['post'], read_attributes: '(**)', write_attributes: '(**)'}, ]; -/* eslint-enable camelcase */ /** * Default WebDAV permissions to grant the client ID. @@ -183,7 +182,6 @@ export default class OdsCreate extends OdsCommand { return undefined; } - /* eslint-disable camelcase */ return { ocapi: [ { @@ -198,7 +196,6 @@ export default class OdsCreate extends OdsCommand { }, ], }; - /* eslint-enable camelcase */ } private printSandboxSummary(sandbox: SandboxModel): void { diff --git a/packages/b2c-cli/src/commands/ods/list.ts b/packages/b2c-cli/src/commands/ods/list.ts index a7415bc..57a9b78 100644 --- a/packages/b2c-cli/src/commands/ods/list.ts +++ b/packages/b2c-cli/src/commands/ods/list.ts @@ -134,9 +134,8 @@ export default class OdsList extends OdsCommand { const result = await this.odsClient.GET('/sandboxes', { params: { query: { - // eslint-disable-next-line camelcase include_deleted: includeDeleted, - // eslint-disable-next-line camelcase + filter_params: filterParams, }, }, diff --git a/packages/b2c-dx-mcp/src/services.ts b/packages/b2c-dx-mcp/src/services.ts index 29c0c10..5121264 100644 --- a/packages/b2c-dx-mcp/src/services.ts +++ b/packages/b2c-dx-mcp/src/services.ts @@ -50,10 +50,9 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import {B2CInstance, type B2CInstanceOptions} from '@salesforce/b2c-tooling-sdk'; -import {ApiKeyStrategy} from '@salesforce/b2c-tooling-sdk/auth'; +import type {B2CInstance} from '@salesforce/b2c-tooling-sdk'; import type {AuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; -import {loadMobifyConfig} from '@salesforce/b2c-tooling-sdk/cli'; +import {resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; /** * MRT (Managed Runtime) configuration. @@ -68,6 +67,26 @@ export interface MrtConfig { environment?: string; } +/** + * B2C instance input options for Services.create(). + */ +export interface B2CInstanceCreateOptions { + /** B2C instance hostname from --server flag */ + hostname?: string; + /** Code version from --code-version flag */ + codeVersion?: string; + /** Username for Basic auth from --username flag */ + username?: string; + /** Password for Basic auth from --password flag */ + password?: string; + /** OAuth client ID from --client-id flag */ + clientId?: string; + /** OAuth client secret from --client-secret flag */ + clientSecret?: string; + /** Explicit path to dw.json config file */ + configPath?: string; +} + /** * MRT input options for Services.create(). */ @@ -87,7 +106,7 @@ export interface MrtCreateOptions { */ export interface ServicesCreateOptions { /** B2C instance configuration (from InstanceCommand.baseFlags) */ - b2cInstance?: B2CInstanceOptions; + b2cInstance?: B2CInstanceCreateOptions; /** MRT configuration (from MrtCommand.baseFlags) */ mrt?: MrtCreateOptions; } @@ -143,12 +162,13 @@ export class Services { /** * Creates a Services instance with all configuration resolved eagerly. * - * **MRT auth resolution** (matches CLI behavior): - * 1. `mrt.apiKey` option (from --api-key flag, which includes SFCC_MRT_API_KEY env var via oclif) - * 2. `~/.mobify` config file (or `~/.mobify--[hostname]` if `mrt.cloudOrigin` is set) + * Uses the unified {@link resolveConfig} API from the SDK to resolve all + * configuration from multiple sources (flags, dw.json, ~/.mobify). * - * **B2C instance resolution**: - * - `b2cInstance` options merged with dw.json (auto-discovered or via configPath) + * **Resolution priority** (highest to lowest): + * 1. Explicit flag values (hostname, clientId, apiKey, etc.) + * 2. dw.json file (auto-discovered or via configPath) + * 3. ~/.mobify config file (or ~/.mobify--[hostname] if cloudOrigin is set) * * @param options - Configuration options * @returns Services instance with resolved config @@ -162,18 +182,34 @@ export class Services { * ``` */ public static create(options: ServicesCreateOptions = {}): Services { - // Resolve MRT config (auth from flag/env via oclif → ~/.mobify, plus project/environment) - const mrtConfig: MrtConfig = { - auth: Services.resolveMrtAuth({ - apiKey: options.mrt?.apiKey, + // Use unified config resolution from SDK + const config = resolveConfig( + { + hostname: options.b2cInstance?.hostname, + codeVersion: options.b2cInstance?.codeVersion, + username: options.b2cInstance?.username, + password: options.b2cInstance?.password, + clientId: options.b2cInstance?.clientId, + clientSecret: options.b2cInstance?.clientSecret, + mrtApiKey: options.mrt?.apiKey, + mrtProject: options.mrt?.project, + mrtEnvironment: options.mrt?.environment, + }, + { + configPath: options.b2cInstance?.configPath, cloudOrigin: options.mrt?.cloudOrigin, - }), - project: options.mrt?.project, - environment: options.mrt?.environment, + }, + ); + + // Build MRT config using factory methods + const mrtConfig: MrtConfig = { + auth: config.hasMrtConfig() ? config.createMrtAuth() : undefined, + project: config.values.mrtProject, + environment: config.values.mrtEnvironment, }; - // Resolve B2C instance from options (B2CInstanceOptions passed directly) - const b2cInstance = Services.resolveB2CInstance(options.b2cInstance); + // Build B2C instance using factory method + const b2cInstance = config.hasB2CInstanceConfig() ? config.createB2CInstance() : undefined; return new Services({ b2cInstance, @@ -181,56 +217,6 @@ export class Services { }); } - /** - * Resolves B2C instance from available sources. - * - * Resolution merges: - * 1. Explicit flag values (highest priority) - * 2. dw.json file (via configPath or auto-discovery) - * - * @param options - Resolution options (same as B2CInstance.fromEnvironment) - * @returns B2CInstance if configured, undefined if resolution fails - */ - public static resolveB2CInstance(options: B2CInstanceOptions = {}): B2CInstance | undefined { - try { - return B2CInstance.fromEnvironment(options); - } catch { - // B2C instance resolution failed (no config available) - // This is not an error - tools that don't need B2C instance will work fine - return undefined; - } - } - - /** - * Resolves MRT auth strategy from available sources. - * - * Resolution order: - * 1. apiKey option (from --api-key flag, which includes SFCC_MRT_API_KEY env var via oclif) - * 2. ~/.mobify config file (or ~/.mobify--[hostname] if cloudOrigin is set) - * - * Note: The --api-key flag in MrtCommand.baseFlags has `env: 'SFCC_MRT_API_KEY'`, - * so oclif automatically falls back to the env var during flag parsing. - * - * @param options - Resolution options - * @param options.apiKey - MRT API key from --api-key flag (includes env var via oclif) - * @param options.cloudOrigin - MRT cloud origin URL for environment-specific config - * @returns AuthStrategy if configured, undefined otherwise - */ - public static resolveMrtAuth(options: {apiKey?: string; cloudOrigin?: string} = {}): AuthStrategy | undefined { - // 1. Check apiKey option (oclif handles --api-key flag → SFCC_MRT_API_KEY env var fallback) - if (options.apiKey?.trim()) { - return new ApiKeyStrategy(options.apiKey, 'Authorization'); - } - - // 2. Check ~/.mobify config file (or ~/.mobify--[hostname] if cloud origin specified) - const mobifyConfig = loadMobifyConfig(options.cloudOrigin); - if (mobifyConfig.apiKey) { - return new ApiKeyStrategy(mobifyConfig.apiKey, 'Authorization'); - } - - return undefined; - } - // ============================================ // Internal OS Resource Access Methods // These are for internal use by tools, not exposed to AI assistants diff --git a/packages/b2c-plugin-example-config/package.json b/packages/b2c-plugin-example-config/package.json new file mode 100644 index 0000000..d88f75d --- /dev/null +++ b/packages/b2c-plugin-example-config/package.json @@ -0,0 +1,46 @@ +{ + "name": "@salesforce/b2c-plugin-example-config", + "version": "0.0.1-preview", + "description": "Example plugin demonstrating custom config source for B2C CLI", + "author": "Salesforce Commerce Cloud", + "license": "Apache-2.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "oclif": { + "hooks": { + "b2c:config-sources": "./dist/hooks/config-sources.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "lint": "eslint", + "format": "prettier --write src", + "format:check": "prettier --check src" + }, + "dependencies": { + "@salesforce/b2c-tooling-sdk": "workspace:*" + }, + "peerDependencies": { + "@oclif/core": "^4" + }, + "devDependencies": { + "@eslint/compat": "^1", + "@oclif/core": "^4", + "@salesforce/dev-config": "^4.3.2", + "@types/node": "^18", + "eslint": "^9", + "eslint-config-oclif": "^6", + "eslint-config-prettier": "^10", + "eslint-plugin-header": "^3.1.1", + "eslint-plugin-prettier": "^5.5.4", + "prettier": "^3.6.2", + "typescript": "^5" + }, + "engines": { + "node": ">=22.16.0" + } +} diff --git a/packages/b2c-plugin-example-config/src/hooks/config-sources.ts b/packages/b2c-plugin-example-config/src/hooks/config-sources.ts new file mode 100644 index 0000000..8d449eb --- /dev/null +++ b/packages/b2c-plugin-example-config/src/hooks/config-sources.ts @@ -0,0 +1,37 @@ +/* + * 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 + */ +/** + * Hook implementation for b2c:config-sources. + * + * This hook is called during CLI command initialization, after flags are parsed + * but before configuration is resolved. It returns custom ConfigSource instances + * that will be merged with the default configuration sources. + */ +import type {ConfigSourcesHook} from '@salesforce/b2c-tooling-sdk/cli'; +import {EnvFileSource} from '../sources/env-file-source.js'; + +/** + * The b2c:config-sources hook handler. + * + * This function is called by the B2C CLI when initializing any command. + * It receives context from the command (like --instance flag) and returns + * custom ConfigSource instances. + * + * @param options - Hook options containing instance name and config path + * @returns Config sources and their priority + */ +const hook: ConfigSourcesHook = async function (options) { + this.debug(`b2c:config-sources hook called with instance: ${options.instance}`); + + return { + sources: [new EnvFileSource()], + // 'before' = override dw.json/~/.mobify (higher priority) + // 'after' = fill gaps after dw.json/~/.mobify (lower priority) - this is the default + priority: 'before', + }; +}; + +export default hook; diff --git a/packages/b2c-plugin-example-config/src/index.ts b/packages/b2c-plugin-example-config/src/index.ts new file mode 100644 index 0000000..097ff4a --- /dev/null +++ b/packages/b2c-plugin-example-config/src/index.ts @@ -0,0 +1,34 @@ +/* + * 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 + */ +/** + * Example B2C CLI plugin demonstrating custom configuration sources. + * + * This plugin shows how to use the `b2c:config-sources` hook to provide + * custom ConfigSource implementations that integrate with the B2C CLI + * configuration resolution system. + * + * ## Installation + * + * ```bash + * b2c plugins link ./packages/b2c-plugin-example-config + * ``` + * + * ## Usage + * + * Create a `.env.b2c` file in your project root: + * + * ``` + * HOSTNAME=example.sandbox.us03.dx.commercecloud.salesforce.com + * CLIENT_ID=your-client-id + * CLIENT_SECRET=your-client-secret + * CODE_VERSION=version1 + * ``` + * + * The plugin will automatically load these values when running CLI commands. + * + * @module b2c-plugin-example-config + */ +export {EnvFileSource} from './sources/env-file-source.js'; diff --git a/packages/b2c-plugin-example-config/src/sources/env-file-source.ts b/packages/b2c-plugin-example-config/src/sources/env-file-source.ts new file mode 100644 index 0000000..50c572d --- /dev/null +++ b/packages/b2c-plugin-example-config/src/sources/env-file-source.ts @@ -0,0 +1,144 @@ +/* + * 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 + */ +/** + * Example ConfigSource that loads configuration from a .env.b2c file. + * + * This demonstrates how plugin authors can implement custom configuration + * sources that integrate with the B2C CLI configuration resolution system. + */ +import {existsSync, readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import type {ConfigSource, NormalizedConfig, ResolveConfigOptions} from '@salesforce/b2c-tooling-sdk/config'; + +/** + * ConfigSource implementation that loads from .env.b2c files. + * + * ## Configuration + * + * Set `B2C_ENV_FILE_PATH` to override the default file location: + * + * ```bash + * export B2C_ENV_FILE_PATH=/path/to/custom/.env.b2c + * b2c code deploy + * ``` + * + * ## Supported Fields + * + * The .env.b2c file supports the following environment variables: + * - HOSTNAME: B2C instance hostname + * - WEBDAV_HOSTNAME: Separate WebDAV hostname (optional) + * - CODE_VERSION: Code version + * - USERNAME: Basic auth username + * - PASSWORD: Basic auth password + * - CLIENT_ID: OAuth client ID + * - CLIENT_SECRET: OAuth client secret + * - SCOPES: OAuth scopes (comma-separated) + * - SHORT_CODE: SCAPI short code + * - MRT_PROJECT: MRT project slug + * - MRT_ENVIRONMENT: MRT environment name + * - MRT_API_KEY: MRT API key + * + * @example + * ``` + * # .env.b2c + * HOSTNAME=example.sandbox.us03.dx.commercecloud.salesforce.com + * CLIENT_ID=my-client-id + * CLIENT_SECRET=my-client-secret + * CODE_VERSION=version1 + * ``` + */ +export class EnvFileSource implements ConfigSource { + readonly name = 'env-file (.env.b2c)'; + + private envFilePath?: string; + + /** + * Load configuration from .env.b2c file. + * + * File location priority: + * 1. B2C_ENV_FILE_PATH environment variable (explicit override) + * 2. .env.b2c in startDir (from options) + * 3. .env.b2c in current working directory + * + * @param options - Resolution options (startDir used for file lookup) + * @returns Parsed configuration or undefined if file not found + */ + load(options: ResolveConfigOptions): NormalizedConfig | undefined { + // Check for explicit path override via environment variable + const envOverride = process.env.B2C_ENV_FILE_PATH; + if (envOverride) { + this.envFilePath = envOverride; + } else { + const searchDir = options.startDir ?? process.cwd(); + this.envFilePath = join(searchDir, '.env.b2c'); + } + + if (!existsSync(this.envFilePath)) { + return undefined; + } + + const content = readFileSync(this.envFilePath, 'utf-8'); + const vars = this.parseEnvFile(content); + + return { + hostname: vars.HOSTNAME, + webdavHostname: vars.WEBDAV_HOSTNAME, + codeVersion: vars.CODE_VERSION, + username: vars.USERNAME, + password: vars.PASSWORD, + clientId: vars.CLIENT_ID, + clientSecret: vars.CLIENT_SECRET, + scopes: vars.SCOPES ? vars.SCOPES.split(',').map((s) => s.trim()) : undefined, + shortCode: vars.SHORT_CODE, + mrtProject: vars.MRT_PROJECT, + mrtEnvironment: vars.MRT_ENVIRONMENT, + mrtApiKey: vars.MRT_API_KEY, + }; + } + + /** + * Get the path to the env file (for diagnostics). + */ + getPath(): string | undefined { + return this.envFilePath; + } + + /** + * Parse a .env file format into key-value pairs. + * + * @param content - File content + * @returns Parsed environment variables + */ + private parseEnvFile(content: string): Record { + const vars: Record = {}; + + for (const line of content.split('\n')) { + // Skip empty lines and comments + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + // Parse KEY=VALUE format + const eqIndex = trimmed.indexOf('='); + if (eqIndex === -1) { + continue; + } + + const key = trimmed.slice(0, eqIndex).trim(); + let value = trimmed.slice(eqIndex + 1).trim(); + + // Handle quoted values + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + vars[key] = value; + } + + return vars; + } +} diff --git a/packages/b2c-plugin-example-config/tsconfig.build.json b/packages/b2c-plugin-example-config/tsconfig.build.json new file mode 100644 index 0000000..250e234 --- /dev/null +++ b/packages/b2c-plugin-example-config/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"] +} diff --git a/packages/b2c-plugin-example-config/tsconfig.json b/packages/b2c-plugin-example-config/tsconfig.json new file mode 100644 index 0000000..2ff87a3 --- /dev/null +++ b/packages/b2c-plugin-example-config/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@salesforce/dev-config/tsconfig-strict-esm", + "compilerOptions": { + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*"] +} diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 0fa2f6a..ea8abb7 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -144,6 +144,17 @@ "types": "./dist/cjs/errors/index.d.ts", "default": "./dist/cjs/errors/index.js" } + }, + "./config": { + "development": "./src/config/index.ts", + "import": { + "types": "./dist/esm/config/index.d.ts", + "default": "./dist/esm/config/index.js" + }, + "require": { + "types": "./dist/cjs/config/index.d.ts", + "default": "./dist/cjs/config/index.js" + } } }, "main": "./dist/cjs/index.js", diff --git a/packages/b2c-tooling-sdk/src/cli/base-command.ts b/packages/b2c-tooling-sdk/src/cli/base-command.ts index 0ad1039..2215658 100644 --- a/packages/b2c-tooling-sdk/src/cli/base-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/base-command.ts @@ -5,10 +5,18 @@ */ import {Command, Flags, type Interfaces} from '@oclif/core'; import {loadConfig} from './config.js'; -import type {ResolvedConfig, LoadConfigOptions} from './config.js'; +import type {ResolvedConfig, LoadConfigOptions, PluginSources} from './config.js'; +import type { + ConfigSourcesHookOptions, + ConfigSourcesHookResult, + HttpMiddlewareHookOptions, + HttpMiddlewareHookResult, +} from './hooks.js'; import {setLanguage} from '../i18n/index.js'; import {configureLogger, getLogger, type LogLevel, type Logger} from '../logging/index.js'; import type {ExtraParamsConfig} from '../clients/middleware.js'; +import type {ConfigSource} from '../config/types.js'; +import {globalMiddlewareRegistry} from '../clients/middleware-registry.js'; export type Flags = Interfaces.InferredFlags<(typeof BaseCommand)['baseFlags'] & T['flags']>; export type Args = Interfaces.InferredArgs; @@ -77,6 +85,11 @@ export abstract class BaseCommand extends Command { protected resolvedConfig!: ResolvedConfig; protected logger!: Logger; + /** High-priority config sources from plugins (inserted before defaults) */ + protected pluginSourcesBefore: ConfigSource[] = []; + /** Low-priority config sources from plugins (inserted after defaults) */ + protected pluginSourcesAfter: ConfigSource[] = []; + public async init(): Promise { await super.init(); @@ -95,6 +108,13 @@ export abstract class BaseCommand extends Command { } this.configureLogging(); + + // Collect middleware from plugins before any API clients are created + await this.collectPluginHttpMiddleware(); + + // Collect config sources from plugins before loading configuration + await this.collectPluginConfigSources(); + this.resolvedConfig = this.loadConfiguration(); } @@ -162,7 +182,88 @@ export abstract class BaseCommand extends Command { configPath: this.flags.config, }; - return loadConfig({}, options); + const pluginSources: PluginSources = { + before: this.pluginSourcesBefore, + after: this.pluginSourcesAfter, + }; + + return loadConfig({}, options, pluginSources); + } + + /** + * Collects config sources from plugins via the `b2c:config-sources` hook. + * + * This method is called during command initialization, after flags are parsed + * but before configuration is resolved. It allows CLI plugins to provide + * custom ConfigSource implementations. + * + * Plugin sources are collected into two arrays based on their priority: + * - `pluginSourcesBefore`: High priority sources (override defaults) + * - `pluginSourcesAfter`: Low priority sources (fill gaps) + */ + protected async collectPluginConfigSources(): Promise { + const hookOptions: ConfigSourcesHookOptions = { + instance: this.flags.instance, + configPath: this.flags.config, + flags: this.flags as Record, + resolveOptions: { + instance: this.flags.instance, + configPath: this.flags.config, + }, + }; + + const hookResult = await this.config.runHook('b2c:config-sources', hookOptions); + + // Collect sources from all plugins that responded, respecting priority + for (const success of hookResult.successes) { + const result = success.result as ConfigSourcesHookResult | undefined; + if (!result?.sources?.length) continue; + + if (result.priority === 'before') { + this.pluginSourcesBefore.push(...result.sources); + } else { + // Default priority is 'after' + this.pluginSourcesAfter.push(...result.sources); + } + } + + // Log warnings for hook failures (don't break the CLI) + for (const failure of hookResult.failures) { + this.logger?.warn(`Plugin ${failure.plugin.name} b2c:config-sources hook failed: ${failure.error.message}`); + } + } + + /** + * Collects HTTP middleware from plugins via the `b2c:http-middleware` hook. + * + * This method is called during command initialization, after flags are parsed + * but before any API clients are created. It allows CLI plugins to provide + * custom middleware that will be applied to all HTTP clients. + * + * Plugin middleware is registered with the global middleware registry. + */ + protected async collectPluginHttpMiddleware(): Promise { + const hookOptions: HttpMiddlewareHookOptions = { + flags: this.flags as Record, + }; + + const hookResult = await this.config.runHook('b2c:http-middleware', hookOptions); + + // Register middleware from all plugins that responded + for (const success of hookResult.successes) { + const result = success.result as HttpMiddlewareHookResult | undefined; + if (!result?.providers?.length) continue; + + for (const provider of result.providers) { + globalMiddlewareRegistry.register(provider); + this.logger?.debug(`Registered HTTP middleware provider: ${provider.name}`); + } + } + + // Log warnings for hook failures (don't break the CLI) + for (const failure of hookResult.failures) { + this.logger?.warn(`Plugin ${failure.plugin.name} b2c:http-middleware hook failed: ${failure.error.message}`); + } } /** diff --git a/packages/b2c-tooling-sdk/src/cli/cartridge-command.ts b/packages/b2c-tooling-sdk/src/cli/cartridge-command.ts index 0c802a7..ec903fb 100644 --- a/packages/b2c-tooling-sdk/src/cli/cartridge-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/cartridge-command.ts @@ -3,9 +3,16 @@ * 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 path from 'node:path'; import {Args, Command, Flags} from '@oclif/core'; import {InstanceCommand} from './instance-command.js'; -import type {FindCartridgesOptions} from '../operations/code/cartridges.js'; +import {findCartridges, type CartridgeMapping, type FindCartridgesOptions} from '../operations/code/cartridges.js'; +import { + CartridgeProviderRunner, + type CartridgeDiscoveryOptions, + type CartridgeProvidersHookOptions, + type CartridgeProvidersHookResult, +} from './cartridge-providers.js'; /** * Base command for cartridge operations (deploy, watch, etc.). @@ -47,6 +54,51 @@ export abstract class CartridgeCommand extends Instanc }), }; + /** Cartridge provider runner for custom cartridge discovery */ + protected cartridgeProviderRunner?: CartridgeProviderRunner; + + /** + * Override init to collect cartridge providers from plugins. + */ + public async init(): Promise { + await super.init(); + await this.collectCartridgeProviders(); + } + + /** + * Collects cartridge providers from plugins via the `b2c:cartridge-providers` hook. + */ + protected async collectCartridgeProviders(): Promise { + this.cartridgeProviderRunner = new CartridgeProviderRunner(this.logger); + + const hookOptions: CartridgeProvidersHookOptions = { + directory: this.cartridgePath, + flags: this.flags as Record, + }; + + const hookResult = await this.config.runHook('b2c:cartridge-providers', hookOptions); + + for (const success of hookResult.successes) { + const result = success.result as CartridgeProvidersHookResult | undefined; + if (result?.providers?.length) { + this.cartridgeProviderRunner.addProviders(result.providers); + } + if (result?.transformers?.length) { + this.cartridgeProviderRunner.addTransformers(result.transformers); + } + } + + for (const failure of hookResult.failures) { + this.logger?.warn(`Plugin ${failure.plugin.name} b2c:cartridge-providers hook failed: ${failure.error.message}`); + } + + const providerCount = this.cartridgeProviderRunner.providerCount; + const transformerCount = this.cartridgeProviderRunner.transformerCount; + if (providerCount > 0 || transformerCount > 0) { + this.logger?.debug(`Registered ${providerCount} cartridge provider(s) and ${transformerCount} transformer(s)`); + } + } + /** * Gets the cartridge path from args. */ @@ -63,4 +115,53 @@ export abstract class CartridgeCommand extends Instanc exclude: this.flags['exclude-cartridge'] as string[] | undefined, }; } + + /** + * Find cartridges using registered providers and the default discovery. + * + * This method integrates custom cartridge providers from plugins with the + * default `.project` file-based discovery. Providers with priority 'before' + * run first, then default discovery, then 'after' providers. Results are + * deduplicated by name (first wins) and transformers are applied. + * + * @param directory - Directory to search (defaults to cartridgePath) + * @param options - Filter options (defaults to cartridgeOptions) + * @returns Array of cartridge mappings from all sources + * + * @example + * ```typescript + * // In a cartridge command + * const cartridges = await this.findCartridgesWithProviders(); + * ``` + */ + protected async findCartridgesWithProviders( + directory?: string, + options?: FindCartridgesOptions, + ): Promise { + const searchDir = directory ?? this.cartridgePath; + const filterOptions = options ?? this.cartridgeOptions; + + // Resolve to absolute path + const absoluteDir = path.resolve(searchDir); + + // Run default discovery + const defaultCartridges = findCartridges(absoluteDir, filterOptions); + + // If no provider runner or no providers/transformers, return default + if (!this.cartridgeProviderRunner) { + return defaultCartridges; + } + + // Build discovery options with full context + const discoveryOptions: CartridgeDiscoveryOptions = { + directory: absoluteDir, + include: filterOptions.include, + exclude: filterOptions.exclude, + codeVersion: this.resolvedConfig?.codeVersion, + instance: this.instance, + }; + + // Run providers and transformers + return this.cartridgeProviderRunner.findCartridges(defaultCartridges, discoveryOptions); + } } diff --git a/packages/b2c-tooling-sdk/src/cli/cartridge-providers.ts b/packages/b2c-tooling-sdk/src/cli/cartridge-providers.ts new file mode 100644 index 0000000..486900c --- /dev/null +++ b/packages/b2c-tooling-sdk/src/cli/cartridge-providers.ts @@ -0,0 +1,242 @@ +/* + * 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 type {Hook} from '@oclif/core'; +import type {CartridgeMapping, FindCartridgesOptions} from '../operations/code/cartridges.js'; +import type {B2CInstance} from '../instance/index.js'; + +/** + * Extended options for cartridge discovery that includes instance context. + */ +export interface CartridgeDiscoveryOptions extends FindCartridgesOptions { + /** Directory to search for cartridges */ + directory: string; + /** Code version being deployed to (if known) */ + codeVersion?: string; + /** B2C instance context (if available) */ + instance?: B2CInstance; +} + +/** + * Provider interface for custom cartridge discovery. + * + * Plugins can implement this interface to provide cartridges from custom sources + * such as remote Git repos, manifest files, or other locations. + * + * @example + * ```typescript + * const manifestProvider: CartridgeProvider = { + * name: 'manifest-provider', + * priority: 'before', + * async findCartridges(options) { + * const manifest = JSON.parse(await fs.readFile('cartridges.json', 'utf-8')); + * return manifest.cartridges.map(c => ({ + * name: c.name, + * src: path.resolve(c.path), + * dest: c.name, + * })); + * }, + * }; + * ``` + */ +export interface CartridgeProvider { + /** Unique name for this provider (used for logging) */ + readonly name: string; + + /** + * Priority relative to default provider. + * - 'before': Runs first, can provide cartridges that override defaults + * - 'after': Runs after defaults, adds additional cartridges + */ + readonly priority: 'before' | 'after'; + + /** + * Find cartridges from this provider. + * + * @param options - Discovery options including directory, filters, and instance context + * @returns Array of cartridge mappings, or empty array if no cartridges available + */ + findCartridges(options: CartridgeDiscoveryOptions): Promise; +} + +/** + * Transformer interface for modifying cartridge mappings before deployment. + * + * Transformers run after all providers have contributed cartridges and can + * modify paths, rename cartridges, or filter the final list. + * + * @example + * ```typescript + * const versioningTransformer: CartridgeTransformer = { + * name: 'versioning-transformer', + * async transform(cartridges, options) { + * // Append version suffix to cartridge names + * return cartridges.map(c => ({ + * ...c, + * dest: `${c.name}_v2`, + * })); + * }, + * }; + * ``` + */ +export interface CartridgeTransformer { + /** Unique name for this transformer (used for logging) */ + readonly name: string; + + /** + * Transform cartridge mappings before deployment. + * + * Can modify paths, names, or filter cartridges. + * + * @param cartridges - Current list of cartridge mappings + * @param options - Discovery options for context + * @returns Transformed array of cartridge mappings + */ + transform(cartridges: CartridgeMapping[], options: CartridgeDiscoveryOptions): Promise; +} + +/** + * Options passed to the b2c:cartridge-providers hook. + */ +export interface CartridgeProvidersHookOptions { + /** Directory being searched for cartridges */ + directory: string; + /** Command flags (for context) */ + flags?: Record; + /** Allow additional properties */ + [key: string]: unknown; +} + +/** + * Result returned from the b2c:cartridge-providers hook. + */ +export interface CartridgeProvidersHookResult { + /** Cartridge providers to register */ + providers?: CartridgeProvider[]; + /** Cartridge transformers to register */ + transformers?: CartridgeTransformer[]; +} + +/** + * Type for the b2c:cartridge-providers hook function. + */ +export type CartridgeProvidersHook = Hook<'b2c:cartridge-providers'>; + +/** + * Runner that manages cartridge providers and transformers. + * + * Collects providers from plugins and orchestrates discovery across + * all sources with proper priority ordering and deduplication. + */ +export class CartridgeProviderRunner { + private providers: CartridgeProvider[] = []; + private transformers: CartridgeTransformer[] = []; + private logger?: {debug: (msg: string) => void}; + + constructor(logger?: {debug: (msg: string) => void}) { + this.logger = logger; + } + + /** + * Add providers and transformers to the runner. + */ + addProviders(providers: CartridgeProvider[]): void { + this.providers.push(...providers); + } + + /** + * Add transformers to the runner. + */ + addTransformers(transformers: CartridgeTransformer[]): void { + this.transformers.push(...transformers); + } + + /** + * Get count of registered providers. + */ + get providerCount(): number { + return this.providers.length; + } + + /** + * Get count of registered transformers. + */ + get transformerCount(): number { + return this.transformers.length; + } + + /** + * Find cartridges using all registered providers and apply transformers. + * + * @param defaultCartridges - Cartridges from default discovery (SDK findCartridges) + * @param options - Discovery options + * @returns Final list of cartridge mappings + */ + async findCartridges( + defaultCartridges: CartridgeMapping[], + options: CartridgeDiscoveryOptions, + ): Promise { + let cartridges: CartridgeMapping[] = []; + + // Run 'before' providers first + const beforeProviders = this.providers.filter((p) => p.priority === 'before'); + for (const provider of beforeProviders) { + try { + this.logger?.debug(`Running cartridge provider: ${provider.name} (before)`); + const found = await provider.findCartridges(options); + cartridges.push(...found); + this.logger?.debug(`Provider ${provider.name} found ${found.length} cartridge(s)`); + } catch (error) { + this.logger?.debug( + `Provider ${provider.name} failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + // Add default cartridges + cartridges.push(...defaultCartridges); + + // Run 'after' providers + const afterProviders = this.providers.filter((p) => p.priority === 'after'); + for (const provider of afterProviders) { + try { + this.logger?.debug(`Running cartridge provider: ${provider.name} (after)`); + const found = await provider.findCartridges(options); + cartridges.push(...found); + this.logger?.debug(`Provider ${provider.name} found ${found.length} cartridge(s)`); + } catch (error) { + this.logger?.debug( + `Provider ${provider.name} failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + // Deduplicate by name (first wins) + const seen = new Set(); + cartridges = cartridges.filter((c) => { + if (seen.has(c.name)) { + return false; + } + seen.add(c.name); + return true; + }); + + // Apply transformers in order + for (const transformer of this.transformers) { + try { + this.logger?.debug(`Running cartridge transformer: ${transformer.name}`); + cartridges = await transformer.transform(cartridges, options); + this.logger?.debug(`Transformer ${transformer.name} returned ${cartridges.length} cartridge(s)`); + } catch (error) { + this.logger?.debug( + `Transformer ${transformer.name} failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + return cartridges; + } +} diff --git a/packages/b2c-tooling-sdk/src/cli/config.ts b/packages/b2c-tooling-sdk/src/cli/config.ts index 4eec911..a3f0abe 100644 --- a/packages/b2c-tooling-sdk/src/cli/config.ts +++ b/packages/b2c-tooling-sdk/src/cli/config.ts @@ -3,318 +3,129 @@ * 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 * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; +/** + * CLI configuration utilities. + * + * This module provides configuration loading for CLI commands. + * It uses {@link resolveConfig} internally for consistent behavior. + * + * For most use cases, prefer using {@link resolveConfig} directly from the + * `config` module, which provides a richer API with factory methods. + * + * @module cli/config + */ import type {AuthMethod} from '../auth/types.js'; import {ALL_AUTH_METHODS} from '../auth/types.js'; +import {resolveConfig, type NormalizedConfig, type ConfigSource} from '../config/index.js'; +import {findDwJson} from '../config/dw-json.js'; import {getLogger} from '../logging/logger.js'; // Re-export for convenience export type {AuthMethod}; export {ALL_AUTH_METHODS}; - -export interface ResolvedConfig { - hostname?: string; - webdavHostname?: string; - codeVersion?: string; - username?: string; - password?: string; - clientId?: string; - clientSecret?: string; - scopes?: string[]; - shortCode?: string; - mrtApiKey?: string; - /** MRT project slug */ - mrtProject?: string; - /** MRT environment name (e.g., staging, production) */ - mrtEnvironment?: string; - /** MRT API origin URL override */ - mrtOrigin?: string; - instanceName?: string; - /** Allowed authentication methods (in priority order). If not set, all methods are allowed. */ - authMethods?: AuthMethod[]; -} +export {findDwJson}; /** - * dw.json single config structure + * Resolved configuration for CLI commands. + * + * This type is an alias for NormalizedConfig to maintain backward compatibility + * with existing CLI code. For new code, prefer using {@link resolveConfig} + * which returns a {@link ResolvedB2CConfig} with factory methods. */ -interface DwJsonConfig { - name?: string; - active?: boolean; - hostname?: string; - 'code-version'?: string; - username?: string; - password?: string; - 'client-id'?: string; - 'client-secret'?: string; - 'oauth-scopes'?: string[]; - /** SCAPI short code (multiple key formats supported) */ - shortCode?: string; - 'short-code'?: string; - 'scapi-shortcode'?: string; - secureHostname?: string; - 'secure-server'?: string; - /** Allowed authentication methods (in priority order) */ - 'auth-methods'?: AuthMethod[]; - /** MRT project slug */ - mrtProject?: string; - /** MRT environment name (e.g., staging, production) */ - mrtEnvironment?: string; -} +export type ResolvedConfig = NormalizedConfig; /** - * dw.json with multi-config support + * Options for loading configuration. */ -interface DwJsonMultiConfig extends DwJsonConfig { - configs?: DwJsonConfig[]; -} - export interface LoadConfigOptions { + /** Named instance from dw.json "configs" array */ instance?: string; + /** Explicit path to config file (skips searching if provided) */ configPath?: string; + /** Cloud origin for MRT ~/.mobify lookup (e.g., https://cloud-staging.mobify.com) */ + cloudOrigin?: string; } /** - * Finds dw.json by walking up from current directory. - */ -export function findDwJson(startDir: string = process.cwd()): string | null { - const logger = getLogger(); - let dir = startDir; - const root = path.parse(dir).root; - - logger.trace({startDir}, '[Config] Searching for dw.json'); - - while (dir !== root) { - const dwJsonPath = path.join(dir, 'dw.json'); - if (fs.existsSync(dwJsonPath)) { - logger.trace({path: dwJsonPath}, '[Config] Found dw.json'); - return dwJsonPath; - } - dir = path.dirname(dir); - } - - logger.trace('[Config] No dw.json found'); - return null; -} - -/** - * Maps dw.json fields to ResolvedConfig - */ -function mapDwJsonToConfig(json: DwJsonConfig): ResolvedConfig { - return { - hostname: json.hostname, - webdavHostname: json.secureHostname || json['secure-server'], - codeVersion: json['code-version'], - username: json.username, - password: json.password, - clientId: json['client-id'], - clientSecret: json['client-secret'], - scopes: json['oauth-scopes'], - shortCode: json.shortCode || json['short-code'] || json['scapi-shortcode'], - instanceName: json.name, - authMethods: json['auth-methods'], - mrtProject: json.mrtProject, - mrtEnvironment: json.mrtEnvironment, - }; -} - -/** - * Loads configuration from dw.json file. - * Supports multi-config format with 'configs' array. - */ -function loadDwJson(instanceName?: string, configPath?: string): ResolvedConfig { - const logger = getLogger(); - const dwJsonPath = configPath || findDwJson(); - - if (!dwJsonPath || !fs.existsSync(dwJsonPath)) { - logger.trace('[Config] No dw.json to load'); - return {}; - } - - try { - const content = fs.readFileSync(dwJsonPath, 'utf8'); - const json = JSON.parse(content) as DwJsonMultiConfig; - - let selectedConfig: DwJsonConfig = json; - let selectedName = json.name || 'root'; - - // Handle multi-config format - if (Array.isArray(json.configs)) { - if (instanceName) { - // Find by instance name - const found = json.name === instanceName ? json : json.configs.find((c) => c.name === instanceName); - if (found) { - selectedConfig = found; - selectedName = found.name || instanceName; - } - } else if (json.active === false) { - // Root config is inactive, find active one in configs - const activeConfig = json.configs.find((c) => c.active === true); - if (activeConfig) { - selectedConfig = activeConfig; - selectedName = activeConfig.name || 'active'; - } - } - // Otherwise use root config - } - - logger.trace({path: dwJsonPath, instance: selectedName}, '[Config] Loaded dw.json'); - return mapDwJsonToConfig(selectedConfig); - } catch (error) { - logger.trace({path: dwJsonPath, error}, '[Config] Failed to parse dw.json'); - return {}; - } -} - -/** - * Merges config sources with precedence: flags (includes env via OCLIF) > dw.json + * Plugin-provided configuration sources with priority ordering. * - * Note: Environment variables are handled by OCLIF's flag parsing with the `env` - * property on each flag definition. By the time flags reach this function, they - * already contain env var values where applicable. - * - * IMPORTANT: If the hostname is explicitly provided (via flags/env) and differs - * from the dw.json hostname, we do NOT use ANY configuration from dw.json since - * the dw.json is configured for a different server. + * Used by BaseCommand to pass sources collected from the `b2c:config-sources` hook + * to the configuration resolver. */ -function mergeConfigs( - flags: Partial, - dwJson: ResolvedConfig, - options: LoadConfigOptions, -): ResolvedConfig { - const logger = getLogger(); - - // Check if hostname was explicitly provided and differs from dw.json - const hostnameExplicitlyProvided = Boolean(flags.hostname); - const hostnameMismatch = hostnameExplicitlyProvided && dwJson.hostname && flags.hostname !== dwJson.hostname; - - // If hostname mismatch, ignore dw.json entirely - if (hostnameMismatch) { - logger.trace( - {providedHostname: flags.hostname, dwJsonHostname: dwJson.hostname, ignoredConfig: dwJson}, - '[Config] Hostname mismatch - ignoring dw.json configuration', - ); - return { - hostname: flags.hostname, - webdavHostname: flags.webdavHostname, - codeVersion: flags.codeVersion, - username: flags.username, - password: flags.password, - clientId: flags.clientId, - clientSecret: flags.clientSecret, - scopes: flags.scopes, - shortCode: flags.shortCode, - mrtApiKey: flags.mrtApiKey, - mrtProject: flags.mrtProject, - mrtEnvironment: flags.mrtEnvironment, - mrtOrigin: flags.mrtOrigin, - instanceName: undefined, - authMethods: flags.authMethods, - }; - } - - return { - hostname: flags.hostname || dwJson.hostname, - webdavHostname: flags.webdavHostname || dwJson.webdavHostname, - codeVersion: flags.codeVersion || dwJson.codeVersion, - username: flags.username || dwJson.username, - password: flags.password || dwJson.password, - clientId: flags.clientId || dwJson.clientId, - clientSecret: flags.clientSecret || dwJson.clientSecret, - scopes: flags.scopes || dwJson.scopes, - shortCode: flags.shortCode || dwJson.shortCode, - mrtApiKey: flags.mrtApiKey, - mrtProject: flags.mrtProject || dwJson.mrtProject, - mrtEnvironment: flags.mrtEnvironment || dwJson.mrtEnvironment, - mrtOrigin: flags.mrtOrigin, - instanceName: dwJson.instanceName || options.instance, - authMethods: flags.authMethods || dwJson.authMethods, - }; +export interface PluginSources { + /** + * Sources with high priority (inserted BEFORE dw.json/~/.mobify). + * These sources can override values from default configuration files. + */ + before?: ConfigSource[]; + /** + * Sources with low priority (inserted AFTER dw.json/~/.mobify). + * These sources fill in gaps left by default configuration files. + */ + after?: ConfigSource[]; } /** - * Loads configuration with precedence: CLI flags/env vars > dw.json + * Loads configuration with precedence: CLI flags/env vars > dw.json > ~/.mobify * * OCLIF handles environment variables automatically via flag `env` properties. * The flags parameter already contains resolved env var values. - */ -export function loadConfig(flags: Partial = {}, options: LoadConfigOptions = {}): ResolvedConfig { - const dwJsonConfig = loadDwJson(options.instance, options.configPath); - return mergeConfigs(flags, dwJsonConfig, options); -} - -/** - * Mobify config file structure (~/.mobify) - */ -interface MobifyConfig { - username?: string; - api_key?: string; -} - -/** - * Result from loading mobify config - */ -export interface MobifyConfigResult { - apiKey?: string; - username?: string; -} - -/** - * Loads MRT API key from ~/.mobify config file. * - * The mobify config file is a JSON file located at ~/.mobify containing: - * ```json - * { - * "username": "user@example.com", - * "api_key": "your-api-key" - * } + * Uses {@link resolveConfig} internally for consistent behavior across CLI and SDK. + * + * @param flags - Configuration values from CLI flags/env vars + * @param options - Loading options + * @param pluginSources - Optional sources from CLI plugins (via b2c:config-sources hook) + * @returns Resolved configuration values + * + * @example + * ```typescript + * // In a CLI command + * const config = loadConfig( + * { hostname: this.flags.server, clientId: this.flags['client-id'] }, + * { instance: this.flags.instance } + * ); * ``` * - * When a cloudOrigin is provided, looks for ~/.mobify--[cloudOrigin] instead. - * For example, if cloudOrigin is "https://cloud-staging.mobify.com", the file - * would be ~/.mobify--cloud-staging.mobify.com + * @example + * ```typescript + * // For richer API with factory methods, use resolveConfig directly: + * import { resolveConfig } from '@salesforce/b2c-tooling-sdk/config'; * - * @param cloudOrigin - Optional cloud origin URL to determine which config file to read - * @returns The API key and username if found, undefined otherwise + * const config = resolveConfig(flags, options); + * if (config.hasB2CInstanceConfig()) { + * const instance = config.createB2CInstance(); + * } + * ``` */ -export function loadMobifyConfig(cloudOrigin?: string): MobifyConfigResult { +export function loadConfig( + flags: Partial = {}, + options: LoadConfigOptions = {}, + pluginSources: PluginSources = {}, +): ResolvedConfig { const logger = getLogger(); - let mobifyPath: string; - if (cloudOrigin) { - // Extract hostname from origin URL for the config file suffix - try { - const url = new URL(cloudOrigin); - mobifyPath = path.join(os.homedir(), `.mobify--${url.hostname}`); - } catch { - // If URL parsing fails, use the origin as-is - mobifyPath = path.join(os.homedir(), `.mobify--${cloudOrigin}`); - } - } else { - mobifyPath = path.join(os.homedir(), '.mobify'); + const resolved = resolveConfig(flags, { + instance: options.instance, + configPath: options.configPath, + hostnameProtection: true, + cloudOrigin: options.cloudOrigin, + sourcesBefore: pluginSources.before, + sourcesAfter: pluginSources.after, + }); + + // Log warnings + for (const warning of resolved.warnings) { + logger.trace({warning}, `[Config] ${warning.message}`); } - logger.trace({path: mobifyPath}, '[Config] Checking for mobify config'); + const config = resolved.values; - if (!fs.existsSync(mobifyPath)) { - logger.trace({path: mobifyPath}, '[Config] No mobify config found'); - return {}; + // Handle instanceName from options if not in resolved config + // This preserves backward compatibility with the old behavior + if (!config.instanceName && options.instance) { + config.instanceName = options.instance; } - try { - const content = fs.readFileSync(mobifyPath, 'utf8'); - const config = JSON.parse(content) as MobifyConfig; - - const hasApiKey = Boolean(config.api_key); - logger.trace({path: mobifyPath, hasApiKey, username: config.username}, '[Config] Loaded mobify config'); - - return { - apiKey: config.api_key, - username: config.username, - }; - } catch (error) { - logger.trace({path: mobifyPath, error}, '[Config] Failed to parse mobify config'); - return {}; - } + return config as ResolvedConfig; } diff --git a/packages/b2c-tooling-sdk/src/cli/hooks.ts b/packages/b2c-tooling-sdk/src/cli/hooks.ts new file mode 100644 index 0000000..174d42b --- /dev/null +++ b/packages/b2c-tooling-sdk/src/cli/hooks.ts @@ -0,0 +1,269 @@ +/* + * 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 + */ +/** + * Hook types for CLI plugin extensibility. + * + * This module defines the hook interfaces that plugins can implement to extend + * CLI functionality, particularly for custom configuration sources. + * + * ## Custom Config Sources Hook + * + * The `b2c:config-sources` hook allows plugins to provide custom {@link ConfigSource} + * implementations that integrate with the CLI's configuration resolution system. + * + * @example + * ```typescript + * // In your plugin's hooks/config-sources.ts + * import type { ConfigSourcesHook } from '@salesforce/b2c-tooling-sdk/cli'; + * import { MyCustomSource } from '../sources/my-custom-source.js'; + * + * const hook: ConfigSourcesHook = async function(options) { + * return { + * sources: [new MyCustomSource()], + * priority: 'before', // Override dw.json defaults + * }; + * }; + * + * export default hook; + * ``` + * + * @module cli/hooks + */ +import type {Hook} from '@oclif/core'; +import type {ConfigSource, ResolveConfigOptions} from '../config/types.js'; +import type {HttpMiddlewareProvider} from '../clients/middleware-registry.js'; + +/** + * Options passed to the `b2c:config-sources` hook. + * + * These options provide context about the current CLI invocation, + * allowing plugins to customize their config sources based on user input. + */ +export interface ConfigSourcesHookOptions { + /** The --instance flag value (if provided) */ + instance?: string; + /** The --config flag value (if provided) */ + configPath?: string; + /** Full ResolveConfigOptions for advanced sources that need more context */ + resolveOptions: ResolveConfigOptions; + /** + * All parsed CLI flags from the current command. + * + * Plugins can check for flags they care about. Note that plugins cannot + * add flags to commands - they can only read flags that the CLI defines. + * For plugin-specific configuration, use environment variables instead. + */ + flags?: Record; + /** Index signature for oclif hook compatibility */ + [key: string]: unknown; +} + +/** + * Result returned by the `b2c:config-sources` hook. + * + * Plugins return one or more ConfigSource instances that will be integrated + * into the configuration resolution chain. + */ +export interface ConfigSourcesHookResult { + /** Config sources to add to the resolution chain */ + sources: ConfigSource[]; + /** + * Where to insert sources relative to default sources. + * + * - `'before'`: Higher priority than dw.json/~/.mobify (plugin overrides defaults) + * - `'after'`: Lower priority than defaults (plugin fills gaps) + * + * @default 'after' + */ + priority?: 'before' | 'after'; +} + +/** + * Hook type for `b2c:config-sources`. + * + * Implement this hook in your oclif plugin to provide custom configuration sources. + * The hook is called during command initialization, after CLI flags are parsed + * but before configuration is resolved. + * + * ## Plugin Registration + * + * Register the hook in your plugin's package.json: + * + * ```json + * { + * "oclif": { + * "hooks": { + * "b2c:config-sources": "./dist/hooks/config-sources.js" + * } + * } + * } + * ``` + * + * ## Hook Context + * + * Inside the hook function, you have access to: + * - `this.config` - oclif Config object + * - `this.debug()`, `this.log()`, `this.warn()`, `this.error()` - logging methods + * + * @example + * ```typescript + * import type { ConfigSourcesHook } from '@salesforce/b2c-tooling-sdk/cli'; + * + * const hook: ConfigSourcesHook = async function(options) { + * this.debug(`Hook called with instance: ${options.instance}`); + * + * // Load config from a custom source (e.g., secrets manager) + * const source = new VaultConfigSource(options.instance); + * + * return { + * sources: [source], + * priority: 'before', // Override dw.json with secrets + * }; + * }; + * + * export default hook; + * ``` + */ +export type ConfigSourcesHook = Hook<'b2c:config-sources'>; + +// ============================================================================ +// HTTP Middleware Hook +// ============================================================================ + +/** + * Options passed to the `b2c:http-middleware` hook. + */ +export interface HttpMiddlewareHookOptions { + /** + * All parsed CLI flags from the current command. + * + * Plugins can inspect flags but cannot add new flags to commands. + * For plugin-specific configuration, use environment variables instead. + */ + flags?: Record; + /** Index signature for oclif hook compatibility */ + [key: string]: unknown; +} + +/** + * Result returned by the `b2c:http-middleware` hook. + * + * Plugins return one or more HttpMiddlewareProvider instances that will be + * registered with the global middleware registry. + */ +export interface HttpMiddlewareHookResult { + /** Middleware providers to register */ + providers: HttpMiddlewareProvider[]; +} + +/** + * Hook type for `b2c:http-middleware`. + * + * Implement this hook in your oclif plugin to provide custom HTTP middleware + * that will be applied to all API clients (OCAPI, SLAS, WebDAV, etc.). + * + * The hook is called during command initialization, after flags are parsed + * but before any API clients are created. + * + * ## Plugin Registration + * + * Register the hook in your plugin's package.json: + * + * ```json + * { + * "oclif": { + * "hooks": { + * "b2c:http-middleware": "./dist/hooks/http-middleware.js" + * } + * } + * } + * ``` + * + * ## Hook Context + * + * Inside the hook function, you have access to: + * - `this.config` - oclif Config object + * - `this.debug()`, `this.log()`, `this.warn()`, `this.error()` - logging methods + * + * @example + * ```typescript + * import type { HttpMiddlewareHook } from '@salesforce/b2c-tooling-sdk/cli'; + * import type { HttpMiddlewareProvider } from '@salesforce/b2c-tooling-sdk/clients'; + * + * const hook: HttpMiddlewareHook = async function(options) { + * this.debug('Registering custom middleware'); + * + * const metricsProvider: HttpMiddlewareProvider = { + * name: 'metrics-collector', + * getMiddleware(clientType) { + * return { + * onRequest({ request }) { + * (request as any)._startTime = Date.now(); + * return request; + * }, + * onResponse({ request, response }) { + * const duration = Date.now() - (request as any)._startTime; + * console.log(`[${clientType}] ${request.method} ${request.url} ${response.status} ${duration}ms`); + * return response; + * }, + * }; + * }, + * }; + * + * return { providers: [metricsProvider] }; + * }; + * + * export default hook; + * ``` + */ +export type HttpMiddlewareHook = Hook<'b2c:http-middleware'>; + +// Re-export B2C lifecycle types for convenience +export type { + B2COperationType, + B2COperationContext, + BeforeB2COperationResult, + B2COperationResult, + AfterB2COperationResult, + B2COperationLifecycleProvider, + B2COperationLifecycleHookOptions, + B2COperationLifecycleHookResult, + B2COperationLifecycleHook, +} from './lifecycle.js'; +export {createB2COperationContext, B2CLifecycleRunner} from './lifecycle.js'; + +// Re-export cartridge provider types for convenience +export type { + CartridgeDiscoveryOptions, + CartridgeProvider, + CartridgeTransformer, + CartridgeProvidersHookOptions, + CartridgeProvidersHookResult, + CartridgeProvidersHook, +} from './cartridge-providers.js'; +export {CartridgeProviderRunner} from './cartridge-providers.js'; + +// Module augmentation for oclif to recognize the custom hooks +declare module '@oclif/core' { + interface Hooks { + 'b2c:config-sources': { + options: ConfigSourcesHookOptions; + return: ConfigSourcesHookResult; + }; + 'b2c:http-middleware': { + options: HttpMiddlewareHookOptions; + return: HttpMiddlewareHookResult; + }; + 'b2c:operation-lifecycle': { + options: import('./lifecycle.js').B2COperationLifecycleHookOptions; + return: import('./lifecycle.js').B2COperationLifecycleHookResult; + }; + 'b2c:cartridge-providers': { + options: import('./cartridge-providers.js').CartridgeProvidersHookOptions; + return: import('./cartridge-providers.js').CartridgeProvidersHookResult; + }; + } +} diff --git a/packages/b2c-tooling-sdk/src/cli/index.ts b/packages/b2c-tooling-sdk/src/cli/index.ts index 360009b..234ce26 100644 --- a/packages/b2c-tooling-sdk/src/cli/index.ts +++ b/packages/b2c-tooling-sdk/src/cli/index.ts @@ -103,8 +103,31 @@ export {WebDavCommand, WEBDAV_ROOTS, VALID_ROOTS} from './webdav-command.js'; export type {WebDavRootKey} from './webdav-command.js'; // Config utilities -export {loadConfig, findDwJson, loadMobifyConfig} from './config.js'; -export type {ResolvedConfig, LoadConfigOptions, MobifyConfigResult} from './config.js'; +export {loadConfig, findDwJson} from './config.js'; +export type {ResolvedConfig, LoadConfigOptions, PluginSources} from './config.js'; + +// Hook types for plugin extensibility +export type { + ConfigSourcesHookOptions, + ConfigSourcesHookResult, + ConfigSourcesHook, + HttpMiddlewareHookOptions, + HttpMiddlewareHookResult, + HttpMiddlewareHook, + // B2C lifecycle hook types + B2COperationType, + B2COperationContext, + BeforeB2COperationResult, + B2COperationResult, + AfterB2COperationResult, + B2COperationLifecycleProvider, + B2COperationLifecycleHookOptions, + B2COperationLifecycleHookResult, + B2COperationLifecycleHook, +} from './hooks.js'; +export {createB2COperationContext, B2CLifecycleRunner} from './hooks.js'; +// Re-export module augmentation for @oclif/core Hooks interface +export {} from './hooks.js'; // Table rendering utilities export {TableRenderer, createTable} from './table.js'; diff --git a/packages/b2c-tooling-sdk/src/cli/instance-command.ts b/packages/b2c-tooling-sdk/src/cli/instance-command.ts index dd6b7b5..255332d 100644 --- a/packages/b2c-tooling-sdk/src/cli/instance-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/instance-command.ts @@ -6,10 +6,20 @@ import {Command, Flags} from '@oclif/core'; import {OAuthCommand} from './oauth-command.js'; import {loadConfig} from './config.js'; -import type {ResolvedConfig, LoadConfigOptions} from './config.js'; -import {B2CInstance} from '../instance/index.js'; -import type {AuthConfig} from '../auth/types.js'; +import type {ResolvedConfig, LoadConfigOptions, PluginSources} from './config.js'; +import {createInstanceFromConfig} from '../config/index.js'; +import type {B2CInstance} from '../instance/index.js'; import {t} from '../i18n/index.js'; +import { + B2CLifecycleRunner, + createB2COperationContext, + type B2COperationType, + type B2COperationContext, + type B2COperationResult, + type BeforeB2COperationResult, + type B2COperationLifecycleHookOptions, + type B2COperationLifecycleHookResult, +} from './lifecycle.js'; /** * Base command for B2C instance operations. @@ -74,6 +84,81 @@ export abstract class InstanceCommand extends OAuthCom private _instance?: B2CInstance; + /** Lifecycle runner for B2C operation hooks */ + protected lifecycleRunner?: B2CLifecycleRunner; + + /** + * Override init to collect lifecycle providers from plugins. + */ + public async init(): Promise { + await super.init(); + await this.collectLifecycleProviders(); + } + + /** + * Collects lifecycle providers from plugins via the `b2c:operation-lifecycle` hook. + */ + protected async collectLifecycleProviders(): Promise { + this.lifecycleRunner = new B2CLifecycleRunner(this.logger); + + const hookOptions: B2COperationLifecycleHookOptions = { + flags: this.flags as Record, + }; + + const hookResult = await this.config.runHook('b2c:operation-lifecycle', hookOptions); + + for (const success of hookResult.successes) { + const result = success.result as B2COperationLifecycleHookResult | undefined; + if (!result?.providers?.length) continue; + this.lifecycleRunner.addProviders(result.providers); + } + + for (const failure of hookResult.failures) { + this.logger?.warn(`Plugin ${failure.plugin.name} b2c:operation-lifecycle hook failed: ${failure.error.message}`); + } + + if (this.lifecycleRunner.size > 0) { + this.logger?.debug(`Registered ${this.lifecycleRunner.size} lifecycle provider(s)`); + } + } + + /** + * Creates a B2C operation context for lifecycle hooks. + * + * @param operationType - Type of B2C operation + * @param metadata - Operation-specific metadata + * @returns B2C operation context + */ + protected createContext(operationType: B2COperationType, metadata: Record): B2COperationContext { + return createB2COperationContext(operationType, metadata, this.instance); + } + + /** + * Runs beforeOperation hooks for all providers. + * + * @param context - B2C operation context + * @returns Result indicating if operation should be skipped + */ + protected async runBeforeHooks(context: B2COperationContext): Promise { + if (!this.lifecycleRunner) { + return {}; + } + return this.lifecycleRunner.runBefore(context); + } + + /** + * Runs afterOperation hooks for all providers. + * + * @param context - B2C operation context + * @param result - Operation result + */ + protected async runAfterHooks(context: B2COperationContext, result: B2COperationResult): Promise { + if (!this.lifecycleRunner) { + return; + } + await this.lifecycleRunner.runAfter(context, result); + } + protected override loadConfiguration(): ResolvedConfig { const options: LoadConfigOptions = { instance: this.flags.instance, @@ -89,9 +174,15 @@ export abstract class InstanceCommand extends OAuthCom clientId: this.flags['client-id'], clientSecret: this.flags['client-secret'], authMethods: this.parseAuthMethods(), + accountManagerHost: this.flags['account-manager-host'], }; - const config = loadConfig(flagConfig, options); + const pluginSources: PluginSources = { + before: this.pluginSourcesBefore, + after: this.pluginSourcesAfter, + }; + + const config = loadConfig(flagConfig, options, pluginSources); // Merge scopes from flags with config file scopes (flags take precedence if provided) if (this.flags.scope && this.flags.scope.length > 0) { @@ -117,38 +208,7 @@ export abstract class InstanceCommand extends OAuthCom protected get instance(): B2CInstance { if (!this._instance) { this.requireServer(); - - const config = this.resolvedConfig; - - const authConfig: AuthConfig = { - authMethods: config.authMethods, - }; - - if (config.username && config.password) { - authConfig.basic = { - username: config.username, - password: config.password, - }; - } - - // Only require clientId for OAuth - clientSecret is optional for implicit flow - if (config.clientId) { - authConfig.oauth = { - clientId: config.clientId, - clientSecret: config.clientSecret, - scopes: config.scopes, - accountManagerHost: this.accountManagerHost, - }; - } - - this._instance = new B2CInstance( - { - hostname: config.hostname!, - codeVersion: config.codeVersion, - webdavHostname: config.webdavHostname, - }, - authConfig, - ); + this._instance = createInstanceFromConfig(this.resolvedConfig); } return this._instance; } diff --git a/packages/b2c-tooling-sdk/src/cli/lifecycle.ts b/packages/b2c-tooling-sdk/src/cli/lifecycle.ts new file mode 100644 index 0000000..c7fd2cf --- /dev/null +++ b/packages/b2c-tooling-sdk/src/cli/lifecycle.ts @@ -0,0 +1,305 @@ +/* + * 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 + */ +/** + * B2C operation lifecycle types for CLI plugin extensibility. + * + * This module defines the interfaces for B2C-specific operation lifecycle hooks + * that allow CLI plugins to observe and control operation execution for jobs, + * deployments, and other B2C Commerce operations. + * + * ## Plugin Usage + * + * Plugins implement the `b2c:operation-lifecycle` hook to receive lifecycle events: + * + * ```typescript + * import type { B2COperationLifecycleHook, B2COperationLifecycleProvider } from '@salesforce/b2c-tooling-sdk/cli'; + * + * const auditProvider: B2COperationLifecycleProvider = { + * name: 'audit-logger', + * async beforeOperation(context) { + * console.log(`Starting ${context.operationType}: ${context.operationId}`); + * // Return { skip: true } to prevent execution + * }, + * async afterOperation(context, result) { + * console.log(`Completed ${context.operationType}: ${result.success ? 'SUCCESS' : 'FAILED'}`); + * }, + * }; + * + * const hook: B2COperationLifecycleHook = async function() { + * return { providers: [auditProvider] }; + * }; + * + * export default hook; + * ``` + * + * @module cli/lifecycle + */ +import {randomUUID} from 'node:crypto'; +import type {Hook} from '@oclif/core'; +import type {B2CInstance} from '../instance/index.js'; +import type {Logger} from '../logging/index.js'; + +/** + * Types of B2C operations that support lifecycle hooks. + */ +export type B2COperationType = + | 'job:run' + | 'job:import' + | 'job:export' + | 'code:deploy' + | 'code:activate' + | 'site-archive:import' + | 'site-archive:export'; + +/** + * Context provided to lifecycle hooks for a B2C operation. + * + * Includes the B2CInstance so plugins can access API clients and configuration + * without needing to construct their own instance. + */ +export interface B2COperationContext { + /** Type of operation being executed */ + operationType: B2COperationType; + /** Unique ID for this operation invocation */ + operationId: string; + /** B2C instance with configured API clients */ + instance: B2CInstance; + /** Start timestamp */ + startTime: number; + /** Operation-specific metadata (jobId, codeVersion, parameters, etc.) */ + metadata: Record; +} + +/** + * Result returned by a beforeOperation hook. + */ +export interface BeforeB2COperationResult { + /** Set to true to skip the operation */ + skip?: boolean; + /** Reason for skipping (logged to user) */ + skipReason?: string; + /** Modified context to pass through to afterOperation */ + context?: Partial; +} + +/** + * Result of a B2C operation execution. + */ +export interface B2COperationResult { + /** Whether the operation succeeded */ + success: boolean; + /** Error if operation failed */ + error?: Error; + /** Duration in milliseconds */ + duration: number; + /** Operation-specific result data */ + data?: unknown; +} + +/** + * Result returned by an afterOperation hook. + */ +export interface AfterB2COperationResult { + /** Additional metadata to include */ + metadata?: Record; +} + +/** + * Provider interface for B2C operation lifecycle hooks. + * + * Plugins implement this interface to observe and control B2C operation execution. + * The context includes the B2CInstance, giving plugins access to: + * - `context.instance.ocapi` - OCAPI client for API calls + * - `context.instance.webdav` - WebDAV client for file operations + * - `context.instance.config` - Resolved configuration (hostname, credentials, etc.) + */ +export interface B2COperationLifecycleProvider { + /** Human-readable name for the provider (used in logging/debugging) */ + readonly name: string; + + /** + * Called before an operation executes. + * + * Can return `{ skip: true }` to prevent the operation from executing. + * + * @param context - Operation context with B2CInstance and metadata + * @returns Optional result to skip or modify the operation + */ + beforeOperation?(context: B2COperationContext): Promise; + + /** + * Called after an operation completes (success or failure). + * + * @param context - Operation context with B2CInstance and metadata + * @param result - Operation result with success/failure info + * @returns Optional result with additional metadata + */ + afterOperation?(context: B2COperationContext, result: B2COperationResult): Promise; +} + +/** + * Options passed to the `b2c:operation-lifecycle` hook. + */ +export interface B2COperationLifecycleHookOptions { + /** + * All parsed CLI flags from the current command. + */ + flags?: Record; + /** Index signature for oclif hook compatibility */ + [key: string]: unknown; +} + +/** + * Result returned by the `b2c:operation-lifecycle` hook. + */ +export interface B2COperationLifecycleHookResult { + /** Lifecycle providers to register */ + providers: B2COperationLifecycleProvider[]; +} + +/** + * Hook type for `b2c:operation-lifecycle`. + * + * Implement this hook in your oclif plugin to receive B2C operation lifecycle events + * for jobs, deployments, and other B2C Commerce operations. + * + * ## Plugin Registration + * + * Register the hook in your plugin's package.json: + * + * ```json + * { + * "oclif": { + * "hooks": { + * "b2c:operation-lifecycle": "./dist/hooks/operation-lifecycle.js" + * } + * } + * } + * ``` + * + * @example + * ```typescript + * import type { B2COperationLifecycleHook } from '@salesforce/b2c-tooling-sdk/cli'; + * + * const hook: B2COperationLifecycleHook = async function(options) { + * return { + * providers: [{ + * name: 'my-audit-provider', + * async beforeOperation(context) { + * // Access context.instance for API calls + * // Log or check policies before operation + * }, + * async afterOperation(context, result) { + * // Log results, send notifications, etc. + * }, + * }], + * }; + * }; + * + * export default hook; + * ``` + */ +export type B2COperationLifecycleHook = Hook<'b2c:operation-lifecycle'>; + +/** + * Creates a new B2C operation context for lifecycle hooks. + * + * @param operationType - Type of B2C operation + * @param metadata - Operation-specific metadata + * @param instance - B2C instance with configured clients + * @returns New operation context + */ +export function createB2COperationContext( + operationType: B2COperationType, + metadata: Record, + instance: B2CInstance, +): B2COperationContext { + return { + operationType, + operationId: randomUUID(), + instance, + startTime: Date.now(), + metadata, + }; +} + +/** + * Helper class for running B2C lifecycle hooks in CLI commands. + * + * This class is used internally by CLI commands to collect and invoke + * lifecycle providers from plugins. + */ +export class B2CLifecycleRunner { + private providers: B2COperationLifecycleProvider[] = []; + private logger?: Logger; + + constructor(logger?: Logger) { + this.logger = logger; + } + + /** + * Adds providers to this runner. + */ + addProviders(providers: B2COperationLifecycleProvider[]): void { + this.providers.push(...providers); + } + + /** + * Runs beforeOperation hooks for all providers. + * + * @param context - Operation context + * @returns Aggregated result (skip if any provider requests skip) + */ + async runBefore(context: B2COperationContext): Promise { + const aggregatedResult: BeforeB2COperationResult = {}; + + for (const provider of this.providers) { + if (!provider.beforeOperation) continue; + + try { + const result = await provider.beforeOperation(context); + if (result?.skip) { + this.logger?.debug(`Provider ${provider.name} requested skip: ${result.skipReason}`); + return result; // First skip wins + } + if (result?.context) { + Object.assign(context.metadata, result.context); + } + } catch (error) { + // Don't fail the operation on hook errors + this.logger?.warn(`Lifecycle provider ${provider.name} beforeOperation failed: ${error}`); + } + } + + return aggregatedResult; + } + + /** + * Runs afterOperation hooks for all providers. + * + * @param context - Operation context + * @param result - Operation result + */ + async runAfter(context: B2COperationContext, result: B2COperationResult): Promise { + for (const provider of this.providers) { + if (!provider.afterOperation) continue; + + try { + await provider.afterOperation(context, result); + } catch (error) { + // Don't fail on hook errors + this.logger?.warn(`Lifecycle provider ${provider.name} afterOperation failed: ${error}`); + } + } + } + + /** + * Returns the number of registered providers. + */ + get size(): number { + return this.providers.length; + } +} diff --git a/packages/b2c-tooling-sdk/src/cli/mrt-command.ts b/packages/b2c-tooling-sdk/src/cli/mrt-command.ts index 07b4793..95702ef 100644 --- a/packages/b2c-tooling-sdk/src/cli/mrt-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/mrt-command.ts @@ -5,8 +5,8 @@ */ import {Command, Flags} from '@oclif/core'; import {BaseCommand} from './base-command.js'; -import {loadConfig, loadMobifyConfig} from './config.js'; -import type {ResolvedConfig, LoadConfigOptions} from './config.js'; +import {loadConfig} from './config.js'; +import type {ResolvedConfig, LoadConfigOptions, PluginSources} from './config.js'; import type {AuthStrategy} from '../auth/types.js'; import {ApiKeyStrategy} from '../auth/api-key.js'; import {MrtClient} from '../platform/mrt.js'; @@ -58,27 +58,30 @@ export abstract class MrtCommand extends BaseCommand = { - // Flag/env takes precedence, then ~/.mobify - mrtApiKey: this.flags['api-key'] || mobifyConfig.apiKey, - // Project/environment from flags (if present - subclasses define these) + // Flag/env takes precedence, ConfigResolver handles ~/.mobify fallback + mrtApiKey: this.flags['api-key'], + // Project/environment from flags mrtProject: this.flags.project as string | undefined, mrtEnvironment: this.flags.environment as string | undefined, // Cloud origin override mrtOrigin: cloudOrigin, }; - return loadConfig(flagConfig, options); + const pluginSources: PluginSources = { + before: this.pluginSourcesBefore, + after: this.pluginSourcesAfter, + }; + + return loadConfig(flagConfig, options, pluginSources); } /** diff --git a/packages/b2c-tooling-sdk/src/cli/oauth-command.ts b/packages/b2c-tooling-sdk/src/cli/oauth-command.ts index ee93777..3bd482b 100644 --- a/packages/b2c-tooling-sdk/src/cli/oauth-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/oauth-command.ts @@ -6,7 +6,7 @@ import {Command, Flags} from '@oclif/core'; import {BaseCommand} from './base-command.js'; import {loadConfig, ALL_AUTH_METHODS} from './config.js'; -import type {ResolvedConfig, LoadConfigOptions, AuthMethod} from './config.js'; +import type {ResolvedConfig, LoadConfigOptions, AuthMethod, PluginSources} from './config.js'; import {OAuthStrategy} from '../auth/oauth.js'; import {ImplicitOAuthStrategy} from '../auth/oauth-implicit.js'; import {t} from '../i18n/index.js'; @@ -94,9 +94,15 @@ export abstract class OAuthCommand extends BaseCommand clientSecret: this.flags['client-secret'], shortCode: this.flags['short-code'], authMethods: this.parseAuthMethods(), + accountManagerHost: this.flags['account-manager-host'], }; - const config = loadConfig(flagConfig, options); + const pluginSources: PluginSources = { + before: this.pluginSourcesBefore, + after: this.pluginSourcesAfter, + }; + + const config = loadConfig(flagConfig, options, pluginSources); // Merge scopes from flags with config file scopes (flags take precedence if provided) if (this.flags.scope && this.flags.scope.length > 0) { @@ -110,7 +116,7 @@ export abstract class OAuthCommand extends BaseCommand * Gets the configured Account Manager host. */ protected get accountManagerHost(): string { - return this.flags['account-manager-host'] ?? DEFAULT_ACCOUNT_MANAGER_HOST; + return this.resolvedConfig.accountManagerHost ?? DEFAULT_ACCOUNT_MANAGER_HOST; } /** diff --git a/packages/b2c-tooling-sdk/src/clients/custom-apis.ts b/packages/b2c-tooling-sdk/src/clients/custom-apis.ts index d334c26..912b801 100644 --- a/packages/b2c-tooling-sdk/src/clients/custom-apis.ts +++ b/packages/b2c-tooling-sdk/src/clients/custom-apis.ts @@ -17,6 +17,7 @@ import type {AuthStrategy} from '../auth/types.js'; import {OAuthStrategy} from '../auth/oauth.js'; import type {paths, components} from './custom-apis.generated.js'; import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; /** * Re-export generated types for external use. @@ -66,6 +67,12 @@ export interface CustomApisClientConfig { * (sfcc.custom-apis) plus tenant-specific scope (SALESFORCE_COMMERCE_API:{tenant}). */ scopes?: string[]; + + /** + * Middleware registry to use for this client. + * If not specified, uses the global middleware registry. + */ + middlewareRegistry?: MiddlewareRegistry; } /** @@ -103,6 +110,8 @@ export interface CustomApisClientConfig { * }); */ export function createCustomApisClient(config: CustomApisClientConfig, auth: AuthStrategy): CustomApisClient { + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + const client = createClient({ baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/dx/custom-apis/v1`, }); @@ -113,8 +122,15 @@ export function createCustomApisClient(config: CustomApisClientConfig, auth: Aut // If OAuth strategy, add required scopes; otherwise use as-is const scopedAuth = auth instanceof OAuthStrategy ? auth.withAdditionalScopes(requiredScopes) : auth; - // Middleware order: auth → logging (logging sees fully modified request) + // Core middleware: auth first client.use(createAuthMiddleware(scopedAuth)); + + // Plugin middleware from registry + for (const middleware of registry.getMiddleware('custom-apis')) { + client.use(middleware); + } + + // Logging middleware last (sees complete request with all modifications) client.use(createLoggingMiddleware('CUSTOM-APIS')); return client; diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 929a51d..b795f49 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -113,14 +113,18 @@ * @module clients */ export {WebDavClient} from './webdav.js'; -export type {PropfindEntry} from './webdav.js'; +export type {PropfindEntry, WebDavClientOptions} from './webdav.js'; export {createAuthMiddleware, createLoggingMiddleware, createExtraParamsMiddleware} from './middleware.js'; export type {ExtraParamsConfig, LoggingMiddlewareConfig} from './middleware.js'; +export {MiddlewareRegistry, globalMiddlewareRegistry} from './middleware-registry.js'; +export type {HttpClientType, HttpMiddlewareProvider, UnifiedMiddleware} from './middleware-registry.js'; + export {createOcapiClient} from './ocapi.js'; export type { OcapiClient, + OcapiClientOptions, OcapiError, OcapiResponse, paths as OcapiPaths, diff --git a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts new file mode 100644 index 0000000..7e204c8 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -0,0 +1,227 @@ +/* + * 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 + */ +/** + * HTTP Middleware Registry for B2C SDK. + * + * Provides a unified middleware system that works across all HTTP clients, + * including openapi-fetch clients (OCAPI, SLAS, etc.) and the WebDAV client. + * + * ## SDK Usage + * + * ```typescript + * import { globalMiddlewareRegistry, HttpMiddlewareProvider } from '@salesforce/b2c-tooling-sdk/clients'; + * + * const loggingProvider: HttpMiddlewareProvider = { + * name: 'my-logger', + * getMiddleware(clientType) { + * return { + * onRequest({ request }) { + * console.log(`[${clientType}] ${request.method} ${request.url}`); + * return request; + * }, + * onResponse({ response }) { + * console.log(`[${clientType}] ${response.status}`); + * return response; + * }, + * }; + * }, + * }; + * + * globalMiddlewareRegistry.register(loggingProvider); + * ``` + * + * ## CLI Plugin Usage + * + * Plugins can provide middleware via the `b2c:http-middleware` hook. + * + * @module clients/middleware-registry + */ +import type {Middleware} from 'openapi-fetch'; + +/** + * Types of HTTP clients that can receive middleware. + */ +export type HttpClientType = 'ocapi' | 'slas' | 'ods' | 'mrt' | 'custom-apis' | 'webdav'; + +/** + * Middleware interface compatible with openapi-fetch. + * + * This is the same interface as openapi-fetch's Middleware, re-exported + * for convenience. It can be used for both openapi-fetch clients and + * the WebDAV client (which adapts it internally). + * + * @example + * ```typescript + * const middleware: UnifiedMiddleware = { + * async onRequest({ request }) { + * request.headers.set('X-Custom-Header', 'value'); + * return request; + * }, + * async onResponse({ response }) { + * // Inspect or modify response + * return response; + * }, + * }; + * ``` + */ +export type UnifiedMiddleware = Middleware; + +/** + * Middleware provider that supplies middleware for HTTP clients. + * + * Providers can return different middleware for different client types, + * or return `undefined` to skip certain client types. + * + * @example + * ```typescript + * const provider: HttpMiddlewareProvider = { + * name: 'metrics-collector', + * getMiddleware(clientType) { + * // Only collect metrics for OCAPI calls + * if (clientType !== 'ocapi') return undefined; + * + * return { + * onRequest({ request }) { + * (request as any)._startTime = Date.now(); + * return request; + * }, + * onResponse({ request, response }) { + * const duration = Date.now() - (request as any)._startTime; + * recordMetric('ocapi_request_duration', duration); + * return response; + * }, + * }; + * }, + * }; + * ``` + */ +export interface HttpMiddlewareProvider { + /** + * Human-readable name for the provider (used in logging/debugging). + */ + readonly name: string; + + /** + * Returns middleware for a specific client type. + * + * @param clientType - The type of HTTP client requesting middleware + * @returns Middleware to apply, or undefined to skip this client type + */ + getMiddleware(clientType: HttpClientType): UnifiedMiddleware | undefined; +} + +/** + * Registry for HTTP middleware providers. + * + * The registry collects middleware from multiple providers and returns + * them in registration order when requested by client factories. + * + * ## Usage Modes + * + * **SDK Mode**: Register providers directly via `register()`: + * ```typescript + * globalMiddlewareRegistry.register(myProvider); + * ``` + * + * **CLI Mode**: Providers are collected via the `b2c:http-middleware` hook + * and registered during command initialization. + */ +export class MiddlewareRegistry { + private providers: HttpMiddlewareProvider[] = []; + + /** + * Registers a middleware provider. + * + * Providers are called in registration order when middleware is requested. + * + * @param provider - The provider to register + */ + register(provider: HttpMiddlewareProvider): void { + this.providers.push(provider); + } + + /** + * Unregisters a middleware provider by name. + * + * @param name - The name of the provider to remove + * @returns true if a provider was removed, false if not found + */ + unregister(name: string): boolean { + const index = this.providers.findIndex((p) => p.name === name); + if (index >= 0) { + this.providers.splice(index, 1); + return true; + } + return false; + } + + /** + * Collects middleware from all providers for a specific client type. + * + * @param clientType - The type of client requesting middleware + * @returns Array of middleware in registration order + */ + getMiddleware(clientType: HttpClientType): UnifiedMiddleware[] { + const middleware: UnifiedMiddleware[] = []; + + for (const provider of this.providers) { + const m = provider.getMiddleware(clientType); + if (m) { + middleware.push(m); + } + } + + return middleware; + } + + /** + * Clears all registered providers. + * + * Primarily useful for testing. + */ + clear(): void { + this.providers = []; + } + + /** + * Returns the number of registered providers. + */ + get size(): number { + return this.providers.length; + } + + /** + * Returns the names of all registered providers. + */ + getProviderNames(): string[] { + return this.providers.map((p) => p.name); + } +} + +/** + * Global middleware registry instance. + * + * This is the default registry used by all B2C SDK clients. Register + * middleware providers here to have them applied automatically. + * + * @example + * ```typescript + * import { globalMiddlewareRegistry } from '@salesforce/b2c-tooling-sdk/clients'; + * + * globalMiddlewareRegistry.register({ + * name: 'request-logger', + * getMiddleware() { + * return { + * onRequest({ request }) { + * console.log(`Request: ${request.method} ${request.url}`); + * return request; + * }, + * }; + * }, + * }); + * ``` + */ +export const globalMiddlewareRegistry = new MiddlewareRegistry(); diff --git a/packages/b2c-tooling-sdk/src/clients/mrt.ts b/packages/b2c-tooling-sdk/src/clients/mrt.ts index dd2ea47..f650397 100644 --- a/packages/b2c-tooling-sdk/src/clients/mrt.ts +++ b/packages/b2c-tooling-sdk/src/clients/mrt.ts @@ -16,6 +16,7 @@ import createClient, {type Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; import type {paths, components} from './mrt.generated.js'; import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; /** * Re-export generated types for external use. @@ -72,6 +73,12 @@ export interface MrtClientConfig { * @example "https://cloud.mobify.com" */ origin?: string; + + /** + * Middleware registry to use for this client. + * If not specified, uses the global middleware registry. + */ + middlewareRegistry?: MiddlewareRegistry; } /** @@ -123,6 +130,7 @@ export const DEFAULT_MRT_ORIGIN = 'https://cloud.mobify.com'; */ export function createMrtClient(config: MrtClientConfig, auth: AuthStrategy): MrtClient { let origin = config.origin || DEFAULT_MRT_ORIGIN; + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; // Normalize origin: add https:// if no protocol specified if (origin && !origin.startsWith('http://') && !origin.startsWith('https://')) { @@ -133,8 +141,15 @@ export function createMrtClient(config: MrtClientConfig, auth: AuthStrategy): Mr baseUrl: origin, }); - // Middleware order: auth → logging (logging sees fully modified request) + // Core middleware: auth first client.use(createAuthMiddleware(auth)); + + // Plugin middleware from registry + for (const middleware of registry.getMiddleware('mrt')) { + client.use(middleware); + } + + // Logging middleware last (sees complete request with all modifications) client.use( createLoggingMiddleware({ prefix: 'MRT', diff --git a/packages/b2c-tooling-sdk/src/clients/ocapi.ts b/packages/b2c-tooling-sdk/src/clients/ocapi.ts index 7dd48d1..7e3986d 100644 --- a/packages/b2c-tooling-sdk/src/clients/ocapi.ts +++ b/packages/b2c-tooling-sdk/src/clients/ocapi.ts @@ -15,6 +15,7 @@ import createClient, {type Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; import type {paths, components} from './ocapi.generated.js'; import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; const DEFAULT_API_VERSION = 'v25_6'; @@ -58,6 +59,21 @@ export type OcapiError = components['schemas']['fault']; // Re-export middleware for backwards compatibility export {createAuthMiddleware, createLoggingMiddleware}; +/** + * Options for creating an OCAPI client. + */ +export interface OcapiClientOptions { + /** + * API version (defaults to v25_6). + */ + apiVersion?: string; + /** + * Middleware registry to use for this client. + * If not specified, uses the global middleware registry. + */ + middlewareRegistry?: MiddlewareRegistry; +} + /** * Creates a typed OCAPI Data API client. * @@ -70,7 +86,7 @@ export {createAuthMiddleware, createLoggingMiddleware}; * * @param hostname - B2C instance hostname * @param auth - Authentication strategy (typically OAuth) - * @param apiVersion - API version (defaults to v25_6) + * @param options - Optional configuration including API version and middleware registry * @returns Typed openapi-fetch client * * @example @@ -101,14 +117,27 @@ export {createAuthMiddleware, createLoggingMiddleware}; export function createOcapiClient( hostname: string, auth: AuthStrategy, - apiVersion: string = DEFAULT_API_VERSION, + options?: OcapiClientOptions | string, ): OcapiClient { + // Support legacy string parameter for apiVersion (backwards compatibility) + const opts: OcapiClientOptions = typeof options === 'string' ? {apiVersion: options} : (options ?? {}); + + const apiVersion = opts.apiVersion ?? DEFAULT_API_VERSION; + const registry = opts.middlewareRegistry ?? globalMiddlewareRegistry; + const client = createClient({ baseUrl: `https://${hostname}/s/-/dw/data/${apiVersion}`, }); - // Middleware order: auth → logging (logging sees fully modified request) + // Core middleware: auth first client.use(createAuthMiddleware(auth)); + + // Plugin middleware from registry + for (const middleware of registry.getMiddleware('ocapi')) { + client.use(middleware); + } + + // Logging middleware last (sees complete request with all modifications) client.use(createLoggingMiddleware('OCAPI')); return client; diff --git a/packages/b2c-tooling-sdk/src/clients/ods.ts b/packages/b2c-tooling-sdk/src/clients/ods.ts index 04ee7e6..75706f1 100644 --- a/packages/b2c-tooling-sdk/src/clients/ods.ts +++ b/packages/b2c-tooling-sdk/src/clients/ods.ts @@ -18,6 +18,7 @@ import type {AuthStrategy} from '../auth/types.js'; import type {paths, components} from './ods.generated.js'; import {createAuthMiddleware, createLoggingMiddleware, createExtraParamsMiddleware} from './middleware.js'; import type {ExtraParamsConfig} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; /** * Default ODS API host for US region. @@ -75,6 +76,12 @@ export interface OdsClientConfig { * parameters that aren't in the typed OpenAPI schema. */ extraParams?: ExtraParamsConfig; + + /** + * Middleware registry to use for this client. + * If not specified, uses the global middleware registry. + */ + middlewareRegistry?: MiddlewareRegistry; } /** @@ -140,17 +147,24 @@ export interface OdsClientConfig { */ export function createOdsClient(config: OdsClientConfig, auth: AuthStrategy): OdsClient { const host = config.host ?? DEFAULT_ODS_HOST; + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; const client = createClient({ baseUrl: `https://${host}/api/v1`, }); - // Middleware order: extraParams → auth → logging - // This ensures logging sees the fully modified request (with auth headers and extra params) + // Core middleware: extraParams → auth if (config.extraParams) { client.use(createExtraParamsMiddleware(config.extraParams)); } client.use(createAuthMiddleware(auth)); + + // Plugin middleware from registry + for (const middleware of registry.getMiddleware('ods')) { + client.use(middleware); + } + + // Logging middleware last (sees complete request with all modifications) client.use(createLoggingMiddleware('ODS')); return client; diff --git a/packages/b2c-tooling-sdk/src/clients/slas-admin.ts b/packages/b2c-tooling-sdk/src/clients/slas-admin.ts index ade8c67..cd96bcf 100644 --- a/packages/b2c-tooling-sdk/src/clients/slas-admin.ts +++ b/packages/b2c-tooling-sdk/src/clients/slas-admin.ts @@ -16,6 +16,7 @@ import createClient, {type Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; import type {paths, components} from './slas-admin.generated.js'; import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; /** * Re-export generated types for external use. @@ -55,6 +56,12 @@ export interface SlasClientConfig { * @example "kv7kzm78" */ shortCode: string; + + /** + * Middleware registry to use for this client. + * If not specified, uses the global middleware registry. + */ + middlewareRegistry?: MiddlewareRegistry; } /** @@ -104,12 +111,21 @@ export interface SlasClientConfig { * }); */ export function createSlasClient(config: SlasClientConfig, auth: AuthStrategy): SlasClient { + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + const client = createClient({ baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/shopper/auth-admin/v1`, }); - // Middleware order: auth → logging (logging sees fully modified request) + // Core middleware: auth first client.use(createAuthMiddleware(auth)); + + // Plugin middleware from registry + for (const middleware of registry.getMiddleware('slas')) { + client.use(middleware); + } + + // Logging middleware last (sees complete request with all modifications) client.use(createLoggingMiddleware('SLAS')); return client; diff --git a/packages/b2c-tooling-sdk/src/clients/webdav.ts b/packages/b2c-tooling-sdk/src/clients/webdav.ts index f034d5e..f61b14d 100644 --- a/packages/b2c-tooling-sdk/src/clients/webdav.ts +++ b/packages/b2c-tooling-sdk/src/clients/webdav.ts @@ -15,6 +15,7 @@ import {parseStringPromise} from 'xml2js'; import type {AuthStrategy} from '../auth/types.js'; import {HTTPError} from '../errors/http-error.js'; import {getLogger} from '../logging/logger.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry, type UnifiedMiddleware} from './middleware-registry.js'; /** * Result of a PROPFIND operation. @@ -49,20 +50,35 @@ export interface PropfindEntry { * await client.mkcol('Cartridges/v1'); * await client.put('Cartridges/v1/app_storefront/cartridge.zip', zipBuffer); */ +/** + * Options for creating a WebDAV client. + */ +export interface WebDavClientOptions { + /** + * Middleware registry to use for this client. + * If not specified, uses the global middleware registry. + */ + middlewareRegistry?: MiddlewareRegistry; +} + export class WebDavClient { private baseUrl: string; + private middlewareRegistry: MiddlewareRegistry; /** * Creates a new WebDAV client. * * @param hostname - WebDAV hostname (may differ from API hostname) * @param auth - Authentication strategy to use for requests + * @param options - Optional configuration including middleware registry */ constructor( hostname: string, private auth: AuthStrategy, + options?: WebDavClientOptions, ) { this.baseUrl = `https://${hostname}/on/demandware.servlet/webdav/Sites`; + this.middlewareRegistry = options?.middlewareRegistry ?? globalMiddlewareRegistry; } /** @@ -76,6 +92,13 @@ export class WebDavClient { return `${this.baseUrl}/${cleanPath}`; } + /** + * Collects middleware from the registry for WebDAV client. + */ + private getMiddleware(): UnifiedMiddleware[] { + return this.middlewareRegistry.getMiddleware('webdav'); + } + /** * Makes a raw WebDAV request. * @@ -86,25 +109,74 @@ export class WebDavClient { async request(path: string, init?: RequestInit): Promise { const logger = getLogger(); const url = this.buildUrl(path); - const method = init?.method ?? 'GET'; + + // Build initial request object + let request = new Request(url, init); + + // Apply onRequest middleware (in registration order) + // We construct a compatible params object for openapi-fetch middleware + const middleware = this.getMiddleware(); + const middlewareParams = { + request, + schemaPath: path, + // Minimal compatibility fields for openapi-fetch middleware + options: {baseUrl: this.baseUrl}, + params: {}, + id: 'webdav', + }; + + for (const m of middleware) { + if (m.onRequest) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await m.onRequest(middlewareParams as any); + if (result instanceof Request) { + request = result; + middlewareParams.request = request; + } + } + } // Debug: Log request start - logger.debug({method, url}, `[WebDAV REQ] ${method} ${url}`); + logger.debug({method: request.method, url: request.url}, `[WebDAV REQ] ${request.method} ${request.url}`); // Trace: Log request details logger.trace( - {headers: this.headersToObject(init?.headers), body: this.formatBody(init?.body)}, - `[WebDAV REQ BODY] ${method} ${url}`, + {headers: this.headersToObject(request.headers), body: this.formatBody(init?.body)}, + `[WebDAV REQ BODY] ${request.method} ${request.url}`, ); const startTime = Date.now(); - const response = await this.auth.fetch(url, init); + + // Use auth.fetch with the (potentially modified) request + let response = await this.auth.fetch(request.url, { + method: request.method, + headers: request.headers, + body: init?.body, // Use original body since Request body may have been consumed + }); + const duration = Date.now() - startTime; + // Apply onResponse middleware (in registration order) + const responseParams = { + ...middlewareParams, + request, + response, + }; + for (const m of middleware) { + if (m.onResponse) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await m.onResponse(responseParams as any); + if (result instanceof Response) { + response = result; + responseParams.response = response; + } + } + } + // Debug: Log response summary logger.debug( - {method, url, status: response.status, duration}, - `[WebDAV RESP] ${method} ${url} ${response.status} ${duration}ms`, + {method: request.method, url: request.url, status: response.status, duration}, + `[WebDAV RESP] ${request.method} ${request.url} ${response.status} ${duration}ms`, ); // Trace: Log response details @@ -114,7 +186,7 @@ export class WebDavClient { const clonedResponse = response.clone(); responseBody = await clonedResponse.text(); } - logger.trace({headers: responseHeaders, body: responseBody}, `[WebDAV RESP BODY] ${method} ${url}`); + logger.trace({headers: responseHeaders, body: responseBody}, `[WebDAV RESP BODY] ${request.method} ${request.url}`); return response; } diff --git a/packages/b2c-tooling-sdk/src/config/dw-json.ts b/packages/b2c-tooling-sdk/src/config/dw-json.ts index 4cd739b..391f012 100644 --- a/packages/b2c-tooling-sdk/src/config/dw-json.ts +++ b/packages/b2c-tooling-sdk/src/config/dw-json.ts @@ -46,8 +46,16 @@ export interface DwJsonConfig { 'scapi-shortcode'?: string; /** Alternate hostname for WebDAV (if different from main hostname) */ 'webdav-hostname'?: string; + /** Alternate hostname for WebDAV (legacy camelCase format) */ + secureHostname?: string; + /** Alternate hostname for WebDAV (legacy kebab-case format) */ + 'secure-server'?: string; /** Allowed authentication methods in priority order */ 'auth-methods'?: AuthMethod[]; + /** MRT project slug */ + mrtProject?: string; + /** MRT environment name (e.g., staging, production) */ + mrtEnvironment?: string; } /** diff --git a/packages/b2c-tooling-sdk/src/config/index.ts b/packages/b2c-tooling-sdk/src/config/index.ts index 075ded1..4c3549d 100644 --- a/packages/b2c-tooling-sdk/src/config/index.ts +++ b/packages/b2c-tooling-sdk/src/config/index.ts @@ -6,10 +6,117 @@ /** * Configuration loading utilities. * - * This module provides utilities for loading B2C Commerce configuration, - * primarily from dw.json files. + * This module provides utilities for loading B2C Commerce configuration. + * The preferred high-level API is {@link resolveConfig}, which returns + * a rich configuration object with factory methods. + * + * ## Quick Start + * + * ```typescript + * import { resolveConfig } from '@salesforce/b2c-tooling-sdk/config'; + * + * const config = resolveConfig({ + * hostname: process.env.SFCC_SERVER, + * clientId: process.env.SFCC_CLIENT_ID, + * mrtApiKey: process.env.MRT_API_KEY, + * }); + * + * // Check what's available and create objects + * if (config.hasB2CInstanceConfig()) { + * const instance = config.createB2CInstance(); + * await instance.webdav.propfind('Cartridges'); + * } + * + * if (config.hasMrtConfig()) { + * const mrtClient = config.createMrtClient({ project: 'my-project' }); + * } + * ``` + * + * ## Resolution Priority + * + * Configuration is resolved with the following precedence (highest to lowest): + * + * 1. **Explicit overrides** - Values passed to `resolve()`, `createInstance()`, etc. + * 2. **Configuration sources** - dw.json, ~/.mobify (in order) + * + * Later sources only fill in missing values—they do not override values from + * higher-priority sources. + * + * ## Hostname Mismatch Protection + * + * The configuration system includes safety protection against accidentally using + * credentials from one instance with a different hostname. When you explicitly + * specify a hostname that differs from the dw.json hostname: + * + * - The entire base configuration from dw.json is ignored + * - Only your explicit overrides are used + * - A warning is included in the resolution result + * + * This prevents credential leakage between different B2C instances. + * + * ## Default Configuration Sources + * + * The default sources loaded by {@link createConfigResolver} are: + * + * - **dw.json** - Project configuration file, searched upward from cwd + * - **~/.mobify** - Home directory file for MRT API key + * + * ## Custom Configuration Sources + * + * Implement the {@link ConfigSource} interface to create custom sources: + * + * ```typescript + * import { ConfigResolver, type ConfigSource } from '@salesforce/b2c-tooling-sdk/config'; + * + * class MySource implements ConfigSource { + * name = 'my-source'; + * load(options) { return { hostname: 'custom.example.com' }; } + * } + * + * const resolver = new ConfigResolver([new MySource()]); + * ``` + * + * ## Lower-Level APIs + * + * For advanced use cases, you can use the lower-level dw.json loading functions: + * + * ```typescript + * import { loadDwJson, findDwJson } from '@salesforce/b2c-tooling-sdk/config'; + * + * const dwJsonPath = findDwJson(); + * const config = loadDwJson({ path: dwJsonPath, instance: 'staging' }); + * ``` * * @module config */ + +// High-level API (preferred) +export {resolveConfig, ConfigResolver, createConfigResolver} from './resolver.js'; + +// Types +export type { + NormalizedConfig, + ConfigSource, + ConfigSourceInfo, + ConfigResolutionResult, + ConfigWarning, + ConfigWarningCode, + ResolveConfigOptions, + ResolvedB2CConfig, + CreateOAuthOptions, + CreateMrtClientOptions, +} from './types.js'; + +// Mapping utilities +export { + mapDwJsonToNormalizedConfig, + mergeConfigsWithProtection, + getPopulatedFields, + buildAuthConfigFromNormalized, + createInstanceFromConfig, +} from './mapping.js'; +export type {MergeConfigOptions, MergeConfigResult} from './mapping.js'; + +// Low-level dw.json API (still available for advanced use) export {loadDwJson, findDwJson} from './dw-json.js'; export type {DwJsonConfig, DwJsonMultiConfig, LoadDwJsonOptions} from './dw-json.js'; diff --git a/packages/b2c-tooling-sdk/src/config/mapping.ts b/packages/b2c-tooling-sdk/src/config/mapping.ts new file mode 100644 index 0000000..abc1208 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/config/mapping.ts @@ -0,0 +1,270 @@ +/* + * 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 + */ +/** + * Configuration mapping utilities. + * + * This module provides the single source of truth for mapping between + * different configuration formats (dw.json, normalized config, etc.). + * + * @module config/mapping + */ +import type {AuthConfig} from '../auth/types.js'; +import {B2CInstance, type InstanceConfig} from '../instance/index.js'; +import type {DwJsonConfig} from './dw-json.js'; +import type {NormalizedConfig, ConfigWarning} from './types.js'; + +/** + * Maps dw.json fields to normalized config format. + * + * This is the SINGLE place where dw.json field mapping happens. + * Handles multiple field name variants for backward compatibility: + * - WebDAV hostname: `webdav-hostname`, `secureHostname`, `secure-server` + * - Short code: `shortCode`, `short-code`, `scapi-shortcode` + * + * @param json - The raw dw.json config + * @returns Normalized configuration + * + * @example + * ```typescript + * import { mapDwJsonToNormalizedConfig } from '@salesforce/b2c-tooling-sdk/config'; + * + * const dwJson = { hostname: 'example.com', 'code-version': 'v1' }; + * const config = mapDwJsonToNormalizedConfig(dwJson); + * // { hostname: 'example.com', codeVersion: 'v1' } + * ``` + */ +export function mapDwJsonToNormalizedConfig(json: DwJsonConfig): NormalizedConfig { + return { + hostname: json.hostname, + // Support multiple field names for webdav hostname (priority order) + webdavHostname: json['webdav-hostname'] || json.secureHostname || json['secure-server'], + codeVersion: json['code-version'], + username: json.username, + password: json.password, + clientId: json['client-id'], + clientSecret: json['client-secret'], + scopes: json['oauth-scopes'], + // Support multiple field names for short code (priority order) + shortCode: json.shortCode || json['short-code'] || json['scapi-shortcode'], + instanceName: json.name, + authMethods: json['auth-methods'], + mrtProject: json.mrtProject, + mrtEnvironment: json.mrtEnvironment, + }; +} + +/** + * Options for merging configurations. + */ +export interface MergeConfigOptions { + /** + * Whether to apply hostname mismatch protection. + * When true, if overrides.hostname differs from base.hostname, + * the entire base config is ignored. + * @default true + */ + hostnameProtection?: boolean; +} + +/** + * Result of merging configurations. + */ +export interface MergeConfigResult { + /** The merged configuration */ + config: NormalizedConfig; + /** Warnings generated during merge (e.g., hostname mismatch) */ + warnings: ConfigWarning[]; + /** Whether a hostname mismatch was detected and base was ignored */ + hostnameMismatch: boolean; +} + +/** + * Merges configurations with hostname mismatch protection. + * + * Applies the precedence rule: overrides > base. + * If hostname protection is enabled and the override hostname differs from + * the base hostname, the entire base config is ignored to prevent + * credential leakage between different instances. + * + * @param overrides - Higher-priority config values (e.g., from CLI flags/env) + * @param base - Lower-priority config values (e.g., from dw.json) + * @param options - Merge options + * @returns Merged config with warnings + * + * @example + * ```typescript + * import { mergeConfigsWithProtection } from '@salesforce/b2c-tooling-sdk/config'; + * + * const { config, warnings } = mergeConfigsWithProtection( + * { hostname: 'staging.example.com' }, + * { hostname: 'prod.example.com', clientId: 'abc' }, + * { hostnameProtection: true } + * ); + * // config = { hostname: 'staging.example.com' } + * // warnings = [{ code: 'HOSTNAME_MISMATCH', ... }] + * ``` + */ +export function mergeConfigsWithProtection( + overrides: Partial, + base: NormalizedConfig, + options: MergeConfigOptions = {}, +): MergeConfigResult { + const warnings: ConfigWarning[] = []; + const hostnameProtection = options.hostnameProtection !== false; + + // Check for hostname mismatch + const hostnameExplicitlyProvided = Boolean(overrides.hostname); + const hostnameMismatch = hostnameExplicitlyProvided && Boolean(base.hostname) && overrides.hostname !== base.hostname; + + if (hostnameMismatch && hostnameProtection) { + warnings.push({ + code: 'HOSTNAME_MISMATCH', + message: `Hostname override "${overrides.hostname}" differs from config file "${base.hostname}". Config file values ignored.`, + details: { + providedHostname: overrides.hostname, + configHostname: base.hostname, + }, + }); + + // Return only overrides, ignore base entirely + return { + config: {...overrides} as NormalizedConfig, + warnings, + hostnameMismatch: true, + }; + } + + // Normal merge - overrides win, use ?? for proper undefined handling + return { + config: { + hostname: overrides.hostname ?? base.hostname, + webdavHostname: overrides.webdavHostname ?? base.webdavHostname, + codeVersion: overrides.codeVersion ?? base.codeVersion, + username: overrides.username ?? base.username, + password: overrides.password ?? base.password, + clientId: overrides.clientId ?? base.clientId, + clientSecret: overrides.clientSecret ?? base.clientSecret, + scopes: overrides.scopes ?? base.scopes, + authMethods: overrides.authMethods ?? base.authMethods, + accountManagerHost: overrides.accountManagerHost ?? base.accountManagerHost, + shortCode: overrides.shortCode ?? base.shortCode, + instanceName: overrides.instanceName ?? base.instanceName, + mrtProject: overrides.mrtProject ?? base.mrtProject, + mrtEnvironment: overrides.mrtEnvironment ?? base.mrtEnvironment, + mrtApiKey: overrides.mrtApiKey ?? base.mrtApiKey, + mrtOrigin: overrides.mrtOrigin ?? base.mrtOrigin, + }, + warnings, + hostnameMismatch: false, + }; +} + +/** + * Gets the list of fields that have values in a config. + * + * Used for tracking which sources contributed which fields during + * configuration resolution. + * + * @param config - The configuration to inspect + * @returns Array of field names that have non-empty values + * + * @example + * ```typescript + * const config = { hostname: 'example.com', clientId: 'abc' }; + * const fields = getPopulatedFields(config); + * // ['hostname', 'clientId'] + * ``` + */ +export function getPopulatedFields(config: NormalizedConfig): (keyof NormalizedConfig)[] { + const fields: (keyof NormalizedConfig)[] = []; + for (const [key, value] of Object.entries(config)) { + if (value !== undefined && value !== null && value !== '') { + fields.push(key as keyof NormalizedConfig); + } + } + return fields; +} + +/** + * Builds an AuthConfig from a NormalizedConfig. + * + * This is the single source of truth for converting normalized config + * to the AuthConfig format expected by B2CInstance. + * + * @param config - The normalized configuration + * @returns AuthConfig for B2CInstance + * + * @example + * ```typescript + * const config = { + * clientId: 'my-client-id', + * clientSecret: 'my-secret', + * username: 'admin', + * password: 'pass', + * }; + * const authConfig = buildAuthConfigFromNormalized(config); + * // { oauth: { clientId: '...', clientSecret: '...' }, basic: { username: '...', password: '...' } } + * ``` + */ +export function buildAuthConfigFromNormalized(config: NormalizedConfig): AuthConfig { + const authConfig: AuthConfig = { + authMethods: config.authMethods, + }; + + if (config.username && config.password) { + authConfig.basic = { + username: config.username, + password: config.password, + }; + } + + if (config.clientId) { + authConfig.oauth = { + clientId: config.clientId, + clientSecret: config.clientSecret, + scopes: config.scopes, + accountManagerHost: config.accountManagerHost, + }; + } + + return authConfig; +} + +/** + * Creates a B2CInstance from a NormalizedConfig. + * + * This utility provides a single source of truth for instance creation + * from resolved configuration. It is used by both ConfigResolver.createInstance() + * and CLI commands (e.g., InstanceCommand). + * + * @param config - The normalized configuration (must include hostname) + * @returns Configured B2CInstance + * @throws Error if hostname is not available in config + * + * @example + * ```typescript + * import { createInstanceFromConfig } from '@salesforce/b2c-tooling-sdk/config'; + * + * const config = { hostname: 'example.demandware.net', clientId: 'abc' }; + * const instance = createInstanceFromConfig(config); + * await instance.webdav.mkcol('Cartridges/v1'); + * ``` + */ +export function createInstanceFromConfig(config: NormalizedConfig): B2CInstance { + if (!config.hostname) { + throw new Error('Hostname is required. Set in dw.json or provide via overrides.'); + } + + const instanceConfig: InstanceConfig = { + hostname: config.hostname, + codeVersion: config.codeVersion, + webdavHostname: config.webdavHostname, + }; + + const authConfig = buildAuthConfigFromNormalized(config); + + return new B2CInstance(instanceConfig, authConfig); +} diff --git a/packages/b2c-tooling-sdk/src/config/resolved-config.ts b/packages/b2c-tooling-sdk/src/config/resolved-config.ts new file mode 100644 index 0000000..3be1263 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/config/resolved-config.ts @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Resolved configuration implementation. + * + * @module config/resolved-config + */ +import type {AuthStrategy, AuthCredentials} from '../auth/types.js'; +import {BasicAuthStrategy} from '../auth/basic.js'; +import {ApiKeyStrategy} from '../auth/api-key.js'; +import {resolveAuthStrategy} from '../auth/resolve.js'; +import type {B2CInstance} from '../instance/index.js'; +import {MrtClient} from '../platform/mrt.js'; +import {createInstanceFromConfig} from './mapping.js'; +import type { + NormalizedConfig, + ConfigWarning, + ConfigSourceInfo, + ResolvedB2CConfig, + CreateOAuthOptions, + CreateMrtClientOptions, +} from './types.js'; + +/** + * Implementation of ResolvedB2CConfig. + * + * Provides validation methods and factory methods for creating SDK objects + * from resolved configuration. + */ +export class ResolvedConfigImpl implements ResolvedB2CConfig { + constructor( + readonly values: NormalizedConfig, + readonly warnings: ConfigWarning[], + readonly sources: ConfigSourceInfo[], + ) {} + + // Validation methods + + hasB2CInstanceConfig(): boolean { + return Boolean(this.values.hostname); + } + + hasMrtConfig(): boolean { + return Boolean(this.values.mrtApiKey); + } + + hasOAuthConfig(): boolean { + return Boolean(this.values.clientId); + } + + hasBasicAuthConfig(): boolean { + return Boolean(this.values.username && this.values.password); + } + + // Factory methods + + createB2CInstance(): B2CInstance { + if (!this.hasB2CInstanceConfig()) { + throw new Error('B2C instance requires hostname'); + } + return createInstanceFromConfig(this.values); + } + + createBasicAuth(): AuthStrategy { + if (!this.hasBasicAuthConfig()) { + throw new Error('Basic auth requires username and password'); + } + return new BasicAuthStrategy(this.values.username!, this.values.password!); + } + + createOAuth(options?: CreateOAuthOptions): AuthStrategy { + if (!this.hasOAuthConfig()) { + throw new Error('OAuth requires clientId'); + } + const credentials: AuthCredentials = { + clientId: this.values.clientId, + clientSecret: this.values.clientSecret, + scopes: this.values.scopes, + accountManagerHost: this.values.accountManagerHost, + }; + return resolveAuthStrategy(credentials, {allowedMethods: options?.allowedMethods}); + } + + createMrtAuth(): AuthStrategy { + if (!this.hasMrtConfig()) { + throw new Error('MRT auth requires mrtApiKey'); + } + return new ApiKeyStrategy(this.values.mrtApiKey!, 'Authorization'); + } + + createWebDavAuth(): AuthStrategy { + // Prefer basic auth if available (simpler, no token refresh) + if (this.hasBasicAuthConfig()) { + return this.createBasicAuth(); + } + // Fall back to OAuth + if (this.hasOAuthConfig()) { + return this.createOAuth(); + } + throw new Error('WebDAV auth requires basic auth (username/password) or OAuth (clientId)'); + } + + createMrtClient(options?: CreateMrtClientOptions): MrtClient { + return new MrtClient( + { + org: options?.org ?? '', + project: options?.project ?? this.values.mrtProject ?? '', + env: options?.env ?? this.values.mrtEnvironment ?? '', + }, + this.createMrtAuth(), + ); + } +} diff --git a/packages/b2c-tooling-sdk/src/config/resolver.ts b/packages/b2c-tooling-sdk/src/config/resolver.ts new file mode 100644 index 0000000..fd8fed0 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/config/resolver.ts @@ -0,0 +1,359 @@ +/* + * 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 + */ +/** + * Configuration resolution. + * + * This module provides the ConfigResolver class, the preferred high-level API + * for loading B2C Commerce configuration from multiple sources. + * + * @module config/resolver + */ +import type {AuthCredentials} from '../auth/types.js'; +import type {B2CInstance} from '../instance/index.js'; +import {mergeConfigsWithProtection, getPopulatedFields, createInstanceFromConfig} from './mapping.js'; +import {DwJsonSource, MobifySource} from './sources/index.js'; +import type { + ConfigSource, + ConfigSourceInfo, + ConfigResolutionResult, + NormalizedConfig, + ResolveConfigOptions, + ResolvedB2CConfig, +} from './types.js'; +import {ResolvedConfigImpl} from './resolved-config.js'; + +/** + * Credential groups that must come from the same source. + * + * When merging configuration, if any field in a group is already set by a + * higher-priority source, all fields in that group from lower-priority + * sources are skipped. This prevents mixing credentials that don't belong together. + */ +const CREDENTIAL_GROUPS: (keyof NormalizedConfig)[][] = [ + ['clientId', 'clientSecret'], + ['username', 'password'], +]; + +/** + * Get the set of credential groups that are already claimed in the config. + * + * A group is "claimed" if any of its fields are set. + * + * @param config - The current configuration + * @returns Set of group indices that are claimed + */ +function getClaimedCredentialGroups(config: NormalizedConfig): Set { + const claimed = new Set(); + for (let i = 0; i < CREDENTIAL_GROUPS.length; i++) { + const group = CREDENTIAL_GROUPS[i]; + if (group.some((f) => config[f] !== undefined)) { + claimed.add(i); + } + } + return claimed; +} + +/** + * Check if a field belongs to a credential group in the claimed set. + * + * @param field - The field name to check + * @param claimedGroups - Set of group indices that are already claimed + * @returns true if the field's credential group is claimed + */ +function isFieldInClaimedGroup(field: string, claimedGroups: Set): boolean { + const groupIndex = CREDENTIAL_GROUPS.findIndex((g) => g.includes(field as keyof NormalizedConfig)); + if (groupIndex === -1) return false; + return claimedGroups.has(groupIndex); +} + +/** + * Resolves configuration from multiple sources with consistent behavior. + * + * ConfigResolver is the preferred high-level API for loading B2C configuration. + * It provides: + * - Consistent hostname mismatch protection across SDK and CLI + * - Extensibility via the ConfigSource interface + * - Convenience methods for creating B2CInstance and auth credentials + * + * ## Resolution Priority + * + * Configuration is resolved with the following precedence (highest to lowest): + * 1. Explicit overrides (passed to resolve methods) + * 2. Sources in order (dw.json, ~/.mobify by default) + * + * ## Usage + * + * ```typescript + * import { createConfigResolver } from '@salesforce/b2c-tooling-sdk/config'; + * + * const resolver = createConfigResolver(); + * + * // Simple resolution + * const { config, warnings } = resolver.resolve({ + * hostname: process.env.SFCC_SERVER, + * clientId: process.env.SFCC_CLIENT_ID, + * }); + * + * // Create B2CInstance directly + * const instance = resolver.createInstance({ hostname: '...' }); + * + * // Get auth credentials for use with resolveAuthStrategy + * const credentials = resolver.createAuthCredentials({ clientId: '...' }); + * ``` + * + * ## Custom Sources + * + * You can provide custom configuration sources: + * + * ```typescript + * import { ConfigResolver } from '@salesforce/b2c-tooling-sdk/config'; + * + * class MySource implements ConfigSource { + * name = 'my-source'; + * load(options) { return { hostname: 'custom.example.com' }; } + * } + * + * const resolver = new ConfigResolver([new MySource()]); + * ``` + */ +export class ConfigResolver { + private sources: ConfigSource[]; + + /** + * Creates a new ConfigResolver. + * + * @param sources - Custom configuration sources. If not provided, uses default sources (dw.json, ~/.mobify). + */ + constructor(sources?: ConfigSource[]) { + this.sources = sources ?? [new DwJsonSource(), new MobifySource()]; + } + + /** + * Resolves configuration from all sources. + * + * @param overrides - Explicit values that take highest priority + * @param options - Resolution options + * @returns Resolution result with config, warnings, and source info + * + * @example + * ```typescript + * const { config, warnings, sources } = resolver.resolve( + * { hostname: process.env.SFCC_SERVER }, + * { instance: 'staging' } + * ); + * + * if (warnings.length > 0) { + * console.warn('Config warnings:', warnings); + * } + * ``` + */ + resolve(overrides: Partial = {}, options: ResolveConfigOptions = {}): ConfigResolutionResult { + const sourceInfos: ConfigSourceInfo[] = []; + const baseConfig: NormalizedConfig = {}; + + // Load from each source in order, merging results + // Earlier sources have higher priority - later sources only fill in missing values + for (const source of this.sources) { + const sourceConfig = source.load(options); + if (sourceConfig) { + const fieldsContributed = getPopulatedFields(sourceConfig); + if (fieldsContributed.length > 0) { + sourceInfos.push({ + name: source.name, + path: source.getPath?.(), + fieldsContributed, + }); + + // Capture which credential groups are already claimed BEFORE processing this source + // This allows a single source to provide complete credential pairs + const claimedGroups = getClaimedCredentialGroups(baseConfig); + + // Merge: source values fill in gaps (don't override existing values) + for (const [key, value] of Object.entries(sourceConfig)) { + if (value === undefined) continue; + if (baseConfig[key as keyof NormalizedConfig] !== undefined) continue; + + // Skip if this field's credential group was already claimed by a higher-priority source + // This prevents mixing credentials from different sources + if (isFieldInClaimedGroup(key, claimedGroups)) { + continue; + } + + (baseConfig as Record)[key] = value; + } + } + } + } + + // Apply overrides with hostname mismatch protection + const {config, warnings} = mergeConfigsWithProtection(overrides, baseConfig, { + hostnameProtection: options.hostnameProtection, + }); + + return {config, warnings, sources: sourceInfos}; + } + + /** + * Creates a B2CInstance from resolved configuration. + * + * This is a convenience method that combines configuration resolution + * with B2CInstance creation. + * + * @param overrides - Explicit values that take highest priority + * @param options - Resolution options + * @returns Configured B2CInstance + * @throws Error if hostname is not available in resolved config + * + * @example + * ```typescript + * const instance = resolver.createInstance({ + * clientId: process.env.SFCC_CLIENT_ID, + * clientSecret: process.env.SFCC_CLIENT_SECRET, + * }); + * + * await instance.webdav.put('path/file.txt', content); + * ``` + */ + createInstance(overrides: Partial = {}, options: ResolveConfigOptions = {}): B2CInstance { + const {config, warnings} = this.resolve(overrides, options); + + // Log warnings (in production, this would use the SDK logger) + for (const warning of warnings) { + // Could integrate with getLogger() here if desired + console.warn(`[ConfigResolver] ${warning.message}`); + } + + return createInstanceFromConfig(config); + } + + /** + * Creates auth credentials from resolved configuration. + * + * The returned credentials can be used with `resolveAuthStrategy()` + * to automatically select the best authentication method. + * + * @param overrides - Explicit values that take highest priority + * @param options - Resolution options + * @returns Auth credentials suitable for resolveAuthStrategy() + * + * @example + * ```typescript + * import { resolveAuthStrategy } from '@salesforce/b2c-tooling-sdk'; + * + * const credentials = resolver.createAuthCredentials({ + * clientId: process.env.SFCC_CLIENT_ID, + * }); + * + * const strategy = resolveAuthStrategy(credentials); + * ``` + */ + createAuthCredentials( + overrides: Partial = {}, + options: ResolveConfigOptions = {}, + ): AuthCredentials { + const {config} = this.resolve(overrides, options); + + return { + clientId: config.clientId, + clientSecret: config.clientSecret, + scopes: config.scopes, + username: config.username, + password: config.password, + apiKey: config.mrtApiKey, + }; + } +} + +/** + * Creates a ConfigResolver with default sources (dw.json, ~/.mobify). + * + * This is the recommended way to create a ConfigResolver for most use cases. + * + * @returns ConfigResolver with default configuration sources + * + * @example + * ```typescript + * import { createConfigResolver } from '@salesforce/b2c-tooling-sdk/config'; + * + * const resolver = createConfigResolver(); + * const { config } = resolver.resolve({ hostname: 'example.com' }); + * ``` + */ +export function createConfigResolver(): ConfigResolver { + return new ConfigResolver(); +} + +/** + * Resolves configuration from multiple sources and returns a rich config object. + * + * This is the preferred high-level API for configuration resolution. It returns + * a {@link ResolvedB2CConfig} object with validation methods and factory methods + * for creating SDK objects. + * + * ## Resolution Priority + * + * 1. Explicit overrides (passed as first argument) + * 2. Default sources (dw.json, ~/.mobify) + * 3. Custom sources (via options.sources) + * + * ## Example + * + * ```typescript + * import { resolveConfig } from '@salesforce/b2c-tooling-sdk/config'; + * + * const config = resolveConfig({ + * hostname: process.env.SFCC_SERVER, + * clientId: process.env.SFCC_CLIENT_ID, + * mrtApiKey: process.env.MRT_API_KEY, + * }); + * + * // Check what's available and create objects + * if (config.hasB2CInstanceConfig()) { + * const instance = config.createB2CInstance(); + * await instance.webdav.propfind('Cartridges'); + * } + * + * if (config.hasMrtConfig()) { + * const mrtClient = config.createMrtClient({ project: 'my-project' }); + * } + * ``` + * + * @param overrides - Explicit configuration values (highest priority) + * @param options - Resolution options + * @returns Resolved configuration with factory methods + */ +export function resolveConfig( + overrides: Partial = {}, + options: ResolveConfigOptions = {}, +): ResolvedB2CConfig { + // Build sources list with priority ordering: + // 1. sourcesBefore (high priority - override defaults) + // 2. default sources (dw.json, ~/.mobify) + // 3. sourcesAfter / sources (low priority - fill gaps) + let sources: ConfigSource[]; + + if (options.replaceDefaultSources && (options.sources || options.sourcesAfter)) { + // Replace mode: only use provided sources + sources = [...(options.sourcesBefore ?? []), ...(options.sourcesAfter ?? options.sources ?? [])]; + } else { + // Normal mode: before + defaults + after + const defaultSources: ConfigSource[] = [new DwJsonSource(), new MobifySource()]; + + // Combine: sourcesBefore > defaults > sourcesAfter/sources + sources = [ + ...(options.sourcesBefore ?? []), + ...defaultSources, + ...(options.sourcesAfter ?? []), + // Backward compat: 'sources' is treated as 'after' priority + ...(options.sources ?? []), + ]; + } + + const resolver = new ConfigResolver(sources); + const {config, warnings, sources: sourceInfos} = resolver.resolve(overrides, options); + + return new ResolvedConfigImpl(config, warnings, sourceInfos); +} 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 new file mode 100644 index 0000000..3fa6557 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/config/sources/dw-json-source.ts @@ -0,0 +1,45 @@ +/* + * 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 + */ +/** + * dw.json configuration source. + * + * @internal This module is internal to the SDK. Use ConfigResolver instead. + */ +import {loadDwJson, findDwJson} from '../dw-json.js'; +import {mapDwJsonToNormalizedConfig} from '../mapping.js'; +import type {ConfigSource, NormalizedConfig, ResolveConfigOptions} from '../types.js'; + +/** + * Configuration source that loads from dw.json files. + * + * @internal + */ +export class DwJsonSource implements ConfigSource { + readonly name = 'dw.json'; + private lastPath?: string; + + load(options: ResolveConfigOptions): NormalizedConfig | undefined { + const dwConfig = loadDwJson({ + instance: options.instance, + path: options.configPath, + startDir: options.startDir, + }); + + if (!dwConfig) { + this.lastPath = undefined; + return undefined; + } + + // Track the path for diagnostics + this.lastPath = options.configPath || findDwJson(options.startDir); + + return mapDwJsonToNormalizedConfig(dwConfig); + } + + getPath(): string | undefined { + return this.lastPath; + } +} diff --git a/packages/b2c-tooling-sdk/src/config/sources/index.ts b/packages/b2c-tooling-sdk/src/config/sources/index.ts new file mode 100644 index 0000000..6dbd8bb --- /dev/null +++ b/packages/b2c-tooling-sdk/src/config/sources/index.ts @@ -0,0 +1,12 @@ +/* + * 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 + */ +/** + * Internal configuration sources. + * + * @internal This module is internal to the SDK. Use ConfigResolver instead. + */ +export {DwJsonSource} from './dw-json-source.js'; +export {MobifySource} from './mobify-source.js'; diff --git a/packages/b2c-tooling-sdk/src/config/sources/mobify-source.ts b/packages/b2c-tooling-sdk/src/config/sources/mobify-source.ts new file mode 100644 index 0000000..d711285 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/config/sources/mobify-source.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 + */ +/** + * Mobify (~/.mobify) configuration source for MRT API key. + * + * @internal This module is internal to the SDK. Use ConfigResolver instead. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type {ConfigSource, NormalizedConfig, ResolveConfigOptions} from '../types.js'; + +/** + * Mobify config file structure (~/.mobify) + */ +interface MobifyConfigFile { + username?: string; + api_key?: string; +} + +/** + * Configuration source that loads MRT API key from ~/.mobify file. + * + * The mobify config file is a JSON file located at ~/.mobify containing: + * ```json + * { + * "username": "user@example.com", + * "api_key": "your-api-key" + * } + * ``` + * + * When a cloudOrigin is provided in options, looks for ~/.mobify--[hostname] instead. + * + * @internal + */ +export class MobifySource implements ConfigSource { + readonly name = 'mobify'; + private lastPath?: string; + + load(options: ResolveConfigOptions): NormalizedConfig | undefined { + const mobifyPath = this.getMobifyPath(options.cloudOrigin); + this.lastPath = mobifyPath; + + if (!fs.existsSync(mobifyPath)) { + return undefined; + } + + try { + const content = fs.readFileSync(mobifyPath, 'utf8'); + const config = JSON.parse(content) as MobifyConfigFile; + + if (!config.api_key) { + return undefined; + } + + return { + mrtApiKey: config.api_key, + }; + } catch { + // Invalid JSON or read error + return undefined; + } + } + + getPath(): string | undefined { + return this.lastPath; + } + + /** + * Determines the mobify config file path based on cloud origin. + */ + private getMobifyPath(cloudOrigin?: string): string { + if (cloudOrigin) { + // Extract hostname from origin URL for the config file suffix + try { + const url = new URL(cloudOrigin); + return path.join(os.homedir(), `.mobify--${url.hostname}`); + } catch { + // If URL parsing fails, use the origin as-is + return path.join(os.homedir(), `.mobify--${cloudOrigin}`); + } + } + return path.join(os.homedir(), '.mobify'); + } +} diff --git a/packages/b2c-tooling-sdk/src/config/types.ts b/packages/b2c-tooling-sdk/src/config/types.ts new file mode 100644 index 0000000..c91736d --- /dev/null +++ b/packages/b2c-tooling-sdk/src/config/types.ts @@ -0,0 +1,310 @@ +/* + * 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 + */ +/** + * Configuration types for the B2C SDK. + * + * This module defines the canonical configuration format and interfaces + * for the configuration resolution system. + * + * @module config/types + */ +import type {AuthMethod, AuthStrategy} from '../auth/types.js'; +import type {B2CInstance} from '../instance/index.js'; +import type {MrtClient} from '../platform/mrt.js'; + +/** + * Normalized B2C configuration with camelCase fields. + * + * This is the canonical intermediate format that all configuration sources + * map to. It provides a consistent interface regardless of the source format + * (dw.json uses kebab-case, env vars use SCREAMING_SNAKE_CASE, etc.). + */ +export interface NormalizedConfig { + // Instance fields + /** B2C instance hostname */ + hostname?: string; + /** Separate hostname for WebDAV operations (if different from main hostname) */ + webdavHostname?: string; + /** Code version for deployments */ + codeVersion?: string; + + // Auth fields (Basic) + /** Username for Basic auth (WebDAV) */ + username?: string; + /** Password/access-key for Basic auth (WebDAV) */ + password?: string; + + // Auth fields (OAuth) + /** OAuth client ID */ + clientId?: string; + /** OAuth client secret */ + clientSecret?: string; + /** OAuth scopes */ + scopes?: string[]; + /** Allowed authentication methods in priority order */ + authMethods?: AuthMethod[]; + /** Account Manager hostname for OAuth (default: account.demandware.com) */ + accountManagerHost?: string; + + // SCAPI + /** SCAPI short code */ + shortCode?: string; + + // MRT fields + /** MRT project slug */ + mrtProject?: string; + /** MRT environment name (e.g., staging, production) */ + mrtEnvironment?: string; + /** MRT API key */ + mrtApiKey?: string; + /** MRT API origin URL override */ + mrtOrigin?: string; + + // Metadata + /** Instance name (from multi-config dw.json) */ + instanceName?: string; +} + +/** + * Warning codes for configuration resolution. + */ +export type ConfigWarningCode = 'HOSTNAME_MISMATCH' | 'DEPRECATED_FIELD' | 'MISSING_REQUIRED' | 'SOURCE_ERROR'; + +/** + * A warning generated during configuration resolution. + */ +export interface ConfigWarning { + /** Warning code for programmatic handling */ + code: ConfigWarningCode; + /** Human-readable warning message */ + message: string; + /** Additional details about the warning */ + details?: Record; +} + +/** + * Information about a configuration source that contributed to resolution. + */ +export interface ConfigSourceInfo { + /** Human-readable name of the source */ + name: string; + /** Path to the source file (if applicable) */ + path?: string; + /** Fields that this source contributed to the final config */ + fieldsContributed: (keyof NormalizedConfig)[]; +} + +/** + * Result of configuration resolution. + */ +export interface ConfigResolutionResult { + /** The resolved configuration */ + config: NormalizedConfig; + /** Warnings generated during resolution */ + warnings: ConfigWarning[]; + /** Information about which sources contributed to the config */ + sources: ConfigSourceInfo[]; +} + +/** + * Options for configuration resolution. + */ +export interface ResolveConfigOptions { + /** Named instance from dw.json "configs" array */ + instance?: string; + /** Explicit path to config file (defaults to auto-discover) */ + configPath?: string; + /** Starting directory for config file search */ + startDir?: string; + /** Whether to apply hostname mismatch protection (default: true) */ + hostnameProtection?: boolean; + /** Cloud origin for ~/.mobify lookup (MRT) */ + cloudOrigin?: string; + + /** + * Custom sources to add BEFORE default sources (higher priority). + * These sources can override values from dw.json and ~/.mobify. + */ + sourcesBefore?: ConfigSource[]; + + /** + * Custom sources to add AFTER default sources (lower priority). + * These sources fill in gaps left by dw.json and ~/.mobify. + */ + sourcesAfter?: ConfigSource[]; + + /** + * Custom configuration sources (added after default sources). + * @deprecated Use `sourcesAfter` for clarity. This is kept for backward compatibility. + */ + sources?: ConfigSource[]; + + /** Replace default sources entirely (instead of appending) */ + replaceDefaultSources?: boolean; +} + +/** + * A configuration source that can contribute config values. + * + * Implement this interface to create custom configuration sources. + * Sources are called in order, and later sources can override earlier ones. + * + * @example + * ```typescript + * import type { ConfigSource, NormalizedConfig, ResolveConfigOptions } from '@salesforce/b2c-tooling-sdk/config'; + * + * class MyCustomSource implements ConfigSource { + * name = 'my-custom-source'; + * + * load(options: ResolveConfigOptions): NormalizedConfig | undefined { + * // Load config from your custom source + * return { hostname: 'example.com' }; + * } + * } + * ``` + */ +export interface ConfigSource { + /** Human-readable name for diagnostics */ + name: string; + + /** + * Load configuration from this source. + * + * @param options - Resolution options + * @returns Partial config from this source, or undefined if source not available + */ + load(options: ResolveConfigOptions): NormalizedConfig | undefined; + + /** + * Get the path to this source's file (if applicable). + * Used for diagnostics and source info. + */ + getPath?(): string | undefined; +} + +/** + * Options for creating OAuth auth strategy. + */ +export interface CreateOAuthOptions { + /** Allowed OAuth methods (default: ['client-credentials', 'implicit']) */ + allowedMethods?: AuthMethod[]; +} + +/** + * Options for creating MRT client. + */ +export interface CreateMrtClientOptions { + /** MRT organization (currently unused but required by MrtClient) */ + org?: string; + /** MRT project slug (overrides config value) */ + project?: string; + /** MRT environment name (overrides config value) */ + env?: string; +} + +/** + * Result of configuration resolution with factory methods. + * + * Provides both raw configuration values and factory methods for creating + * B2C SDK objects (B2CInstance, AuthStrategy, MrtClient) based on the + * resolved configuration. + * + * @example + * ```typescript + * import { resolveConfig } from '@salesforce/b2c-tooling-sdk/config'; + * + * const config = resolveConfig({ + * hostname: process.env.SFCC_SERVER, + * clientId: process.env.SFCC_CLIENT_ID, + * }); + * + * if (config.hasB2CInstanceConfig()) { + * const instance = config.createB2CInstance(); + * await instance.webdav.propfind('Cartridges'); + * } + * + * if (config.hasMrtConfig()) { + * const mrtAuth = config.createMrtAuth(); + * } + * ``` + */ +export interface ResolvedB2CConfig { + /** Raw configuration values */ + readonly values: NormalizedConfig; + + /** Warnings generated during resolution */ + readonly warnings: ConfigWarning[]; + + /** Information about which sources contributed to the config */ + readonly sources: ConfigSourceInfo[]; + + // Validation methods + + /** + * Check if B2C instance configuration is available. + * Requires: hostname + */ + hasB2CInstanceConfig(): boolean; + + /** + * Check if MRT configuration is available. + * Requires: mrtApiKey + */ + hasMrtConfig(): boolean; + + /** + * Check if OAuth configuration is available. + * Requires: clientId + */ + hasOAuthConfig(): boolean; + + /** + * Check if Basic auth configuration is available. + * Requires: username and password + */ + hasBasicAuthConfig(): boolean; + + // Factory methods + + /** + * Creates a B2CInstance from the resolved configuration. + * @throws Error if hostname is not configured + */ + createB2CInstance(): B2CInstance; + + /** + * Creates a Basic auth strategy. + * @throws Error if username or password is not configured + */ + createBasicAuth(): AuthStrategy; + + /** + * Creates an OAuth auth strategy. + * @param options - OAuth options (allowed methods) + * @throws Error if clientId is not configured + */ + createOAuth(options?: CreateOAuthOptions): AuthStrategy; + + /** + * Creates an MRT auth strategy (API key). + * @throws Error if mrtApiKey is not configured + */ + createMrtAuth(): AuthStrategy; + + /** + * Creates a WebDAV auth strategy. + * Prefers Basic auth if available, falls back to OAuth. + * @throws Error if neither Basic auth nor OAuth is configured + */ + createWebDavAuth(): AuthStrategy; + + /** + * Creates an MRT client. + * @param options - MRT project/environment options + * @throws Error if mrtApiKey is not configured + */ + createMrtClient(options?: CreateMrtClientOptions): MrtClient; +} diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index f68f363..b130114 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -15,8 +15,21 @@ export {t, setLanguage, getLanguage, getI18nInstance, registerTranslations, B2C_ export type {TOptions} from './i18n/index.js'; // Config -export {loadDwJson, findDwJson} from './config/index.js'; -export type {DwJsonConfig, DwJsonMultiConfig, LoadDwJsonOptions} from './config/index.js'; +export {loadDwJson, findDwJson, resolveConfig, ConfigResolver, createConfigResolver} from './config/index.js'; +export type { + DwJsonConfig, + DwJsonMultiConfig, + LoadDwJsonOptions, + NormalizedConfig, + ResolvedB2CConfig, + ConfigWarning, + ConfigWarningCode, + ConfigSourceInfo, + ConfigSource, + ResolveConfigOptions, + CreateOAuthOptions, + CreateMrtClientOptions, +} from './config/index.js'; // Auth Layer - Strategies and Resolution export { diff --git a/packages/b2c-tooling-sdk/src/instance/index.ts b/packages/b2c-tooling-sdk/src/instance/index.ts index 0783bbd..ca84edd 100644 --- a/packages/b2c-tooling-sdk/src/instance/index.ts +++ b/packages/b2c-tooling-sdk/src/instance/index.ts @@ -45,6 +45,8 @@ import {resolveAuthStrategy} from '../auth/resolve.js'; import {WebDavClient} from '../clients/webdav.js'; import {createOcapiClient, type OcapiClient} from '../clients/ocapi.js'; import {loadDwJson} from '../config/dw-json.js'; +import {mapDwJsonToNormalizedConfig, mergeConfigsWithProtection} from '../config/mapping.js'; +import type {NormalizedConfig} from '../config/types.js'; /** * Instance configuration (hostname, code version, etc.) @@ -133,47 +135,61 @@ export class B2CInstance { * }); */ static fromEnvironment(options: FromEnvironmentOptions = {}): B2CInstance { - const dwConfig = loadDwJson({ + // Load dw.json and map to normalized config + const dwJsonRaw = loadDwJson({ instance: options.instance, path: options.configPath, }); + const dwConfig = dwJsonRaw ? mapDwJsonToNormalizedConfig(dwJsonRaw) : {}; - // Merge dw.json with overrides (overrides win) - const hostname = options.hostname ?? dwConfig?.hostname; - const codeVersion = options.codeVersion ?? dwConfig?.['code-version']; - const webdavHostname = options.webdavHostname ?? dwConfig?.['webdav-hostname']; - const username = options.username ?? dwConfig?.username; - const password = options.password ?? dwConfig?.password; - const clientId = options.clientId ?? dwConfig?.['client-id']; - const clientSecret = options.clientSecret ?? dwConfig?.['client-secret']; - const scopes = options.scopes ?? dwConfig?.['oauth-scopes']; - const authMethods = options.authMethods ?? (dwConfig?.['auth-methods'] as AuthMethod[] | undefined); + // Build overrides from options + const overrides: Partial = { + hostname: options.hostname, + codeVersion: options.codeVersion, + webdavHostname: options.webdavHostname, + username: options.username, + password: options.password, + clientId: options.clientId, + clientSecret: options.clientSecret, + scopes: options.scopes, + authMethods: options.authMethods, + }; + + // Merge with hostname mismatch protection (consistent with CLI behavior) + const {config: resolved, warnings} = mergeConfigsWithProtection(overrides, dwConfig, { + hostnameProtection: true, + }); + + // Log warnings (optional - could integrate with SDK logger) + for (const warning of warnings) { + console.warn(`[B2CInstance] ${warning.message}`); + } - if (!hostname) { + if (!resolved.hostname) { throw new Error( - 'Hostname is required. Set in dw.json or provide via options. ' + (dwConfig ? '' : 'No dw.json file found.'), + 'Hostname is required. Set in dw.json or provide via options. ' + (dwJsonRaw ? '' : 'No dw.json file found.'), ); } const config: InstanceConfig = { - hostname, - codeVersion, - webdavHostname, + hostname: resolved.hostname, + codeVersion: resolved.codeVersion, + webdavHostname: resolved.webdavHostname, }; const auth: AuthConfig = { - authMethods, + authMethods: resolved.authMethods, }; - if (username && password) { - auth.basic = {username, password}; + if (resolved.username && resolved.password) { + auth.basic = {username: resolved.username, password: resolved.password}; } - if (clientId) { + if (resolved.clientId) { auth.oauth = { - clientId, - clientSecret, - scopes, + clientId: resolved.clientId, + clientSecret: resolved.clientSecret, + scopes: resolved.scopes, }; } diff --git a/packages/b2c-tooling-sdk/test/config/mapping.test.ts b/packages/b2c-tooling-sdk/test/config/mapping.test.ts new file mode 100644 index 0000000..1b92b1a --- /dev/null +++ b/packages/b2c-tooling-sdk/test/config/mapping.test.ts @@ -0,0 +1,340 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import { + mapDwJsonToNormalizedConfig, + mergeConfigsWithProtection, + getPopulatedFields, +} from '@salesforce/b2c-tooling-sdk/config'; + +describe('config/mapping', () => { + describe('mapDwJsonToNormalizedConfig', () => { + it('maps basic dw.json fields to normalized config', () => { + const dwJson = { + hostname: 'example.demandware.net', + 'code-version': 'v1', + username: 'test-user', + password: 'test-pass', + }; + + const config = mapDwJsonToNormalizedConfig(dwJson); + + expect(config.hostname).to.equal('example.demandware.net'); + expect(config.codeVersion).to.equal('v1'); + expect(config.username).to.equal('test-user'); + expect(config.password).to.equal('test-pass'); + }); + + it('maps OAuth credentials', () => { + const dwJson = { + hostname: 'example.demandware.net', + 'client-id': 'my-client-id', + 'client-secret': 'my-client-secret', + 'oauth-scopes': ['mail', 'roles'], + }; + + const config = mapDwJsonToNormalizedConfig(dwJson); + + expect(config.clientId).to.equal('my-client-id'); + expect(config.clientSecret).to.equal('my-client-secret'); + expect(config.scopes).to.deep.equal(['mail', 'roles']); + }); + + it('maps webdav-hostname as first priority', () => { + const dwJson = { + hostname: 'example.demandware.net', + 'webdav-hostname': 'webdav.example.com', + secureHostname: 'secure.example.com', + 'secure-server': 'secure-server.example.com', + }; + + const config = mapDwJsonToNormalizedConfig(dwJson); + + expect(config.webdavHostname).to.equal('webdav.example.com'); + }); + + it('maps secureHostname when webdav-hostname is not present', () => { + const dwJson = { + hostname: 'example.demandware.net', + secureHostname: 'secure.example.com', + 'secure-server': 'secure-server.example.com', + }; + + const config = mapDwJsonToNormalizedConfig(dwJson); + + expect(config.webdavHostname).to.equal('secure.example.com'); + }); + + it('maps secure-server when other webdav options are not present', () => { + const dwJson = { + hostname: 'example.demandware.net', + 'secure-server': 'secure-server.example.com', + }; + + const config = mapDwJsonToNormalizedConfig(dwJson); + + expect(config.webdavHostname).to.equal('secure-server.example.com'); + }); + + it('maps shortCode as first priority', () => { + const dwJson = { + hostname: 'example.demandware.net', + shortCode: 'abc123', + 'short-code': 'def456', + 'scapi-shortcode': 'ghi789', + }; + + const config = mapDwJsonToNormalizedConfig(dwJson); + + expect(config.shortCode).to.equal('abc123'); + }); + + it('maps short-code when shortCode is not present', () => { + const dwJson = { + hostname: 'example.demandware.net', + 'short-code': 'def456', + 'scapi-shortcode': 'ghi789', + }; + + const config = mapDwJsonToNormalizedConfig(dwJson); + + expect(config.shortCode).to.equal('def456'); + }); + + it('maps scapi-shortcode when other short code options are not present', () => { + const dwJson = { + hostname: 'example.demandware.net', + 'scapi-shortcode': 'ghi789', + }; + + const config = mapDwJsonToNormalizedConfig(dwJson); + + expect(config.shortCode).to.equal('ghi789'); + }); + + it('maps MRT fields', () => { + const dwJson = { + hostname: 'example.demandware.net', + mrtProject: 'my-project', + mrtEnvironment: 'staging', + }; + + const config = mapDwJsonToNormalizedConfig(dwJson); + + expect(config.mrtProject).to.equal('my-project'); + expect(config.mrtEnvironment).to.equal('staging'); + }); + + it('maps instance name from dw.json name field', () => { + const dwJson = { + hostname: 'example.demandware.net', + name: 'production', + }; + + const config = mapDwJsonToNormalizedConfig(dwJson); + + expect(config.instanceName).to.equal('production'); + }); + + it('maps auth-methods', () => { + const dwJson = { + hostname: 'example.demandware.net', + 'auth-methods': ['client-credentials', 'basic'] as ('client-credentials' | 'basic')[], + }; + + const config = mapDwJsonToNormalizedConfig(dwJson); + + expect(config.authMethods).to.deep.equal(['client-credentials', 'basic']); + }); + }); + + describe('mergeConfigsWithProtection', () => { + it('merges overrides with base config (overrides win)', () => { + const overrides = { + codeVersion: 'v2', + clientId: 'override-client', + }; + const base = { + hostname: 'example.demandware.net', + codeVersion: 'v1', + clientId: 'base-client', + clientSecret: 'base-secret', + }; + + const {config, warnings, hostnameMismatch} = mergeConfigsWithProtection(overrides, base); + + expect(config.hostname).to.equal('example.demandware.net'); + expect(config.codeVersion).to.equal('v2'); + expect(config.clientId).to.equal('override-client'); + expect(config.clientSecret).to.equal('base-secret'); + expect(warnings).to.have.length(0); + expect(hostnameMismatch).to.equal(false); + }); + + it('detects hostname mismatch and ignores base config', () => { + const overrides = { + hostname: 'staging.demandware.net', + clientId: 'staging-client', + }; + const base = { + hostname: 'prod.demandware.net', + clientId: 'prod-client', + clientSecret: 'prod-secret', + }; + + const {config, warnings, hostnameMismatch} = mergeConfigsWithProtection(overrides, base); + + expect(config.hostname).to.equal('staging.demandware.net'); + expect(config.clientId).to.equal('staging-client'); + expect(config.clientSecret).to.be.undefined; + expect(hostnameMismatch).to.equal(true); + expect(warnings).to.have.length(1); + expect(warnings[0].code).to.equal('HOSTNAME_MISMATCH'); + expect(warnings[0].message).to.include('staging.demandware.net'); + expect(warnings[0].message).to.include('prod.demandware.net'); + }); + + it('does not trigger mismatch when hostnames match', () => { + const overrides = { + hostname: 'example.demandware.net', + codeVersion: 'v2', + }; + const base = { + hostname: 'example.demandware.net', + codeVersion: 'v1', + clientSecret: 'secret', + }; + + const {config, warnings, hostnameMismatch} = mergeConfigsWithProtection(overrides, base); + + expect(config.hostname).to.equal('example.demandware.net'); + expect(config.codeVersion).to.equal('v2'); + expect(config.clientSecret).to.equal('secret'); + expect(hostnameMismatch).to.equal(false); + expect(warnings).to.have.length(0); + }); + + it('does not trigger mismatch when no override hostname is provided', () => { + const overrides = { + codeVersion: 'v2', + }; + const base = { + hostname: 'example.demandware.net', + codeVersion: 'v1', + }; + + const {config, warnings, hostnameMismatch} = mergeConfigsWithProtection(overrides, base); + + expect(config.hostname).to.equal('example.demandware.net'); + expect(config.codeVersion).to.equal('v2'); + expect(hostnameMismatch).to.equal(false); + expect(warnings).to.have.length(0); + }); + + it('can disable hostname protection', () => { + const overrides = { + hostname: 'staging.demandware.net', + }; + const base = { + hostname: 'prod.demandware.net', + clientSecret: 'prod-secret', + }; + + const {config, warnings, hostnameMismatch} = mergeConfigsWithProtection(overrides, base, { + hostnameProtection: false, + }); + + expect(config.hostname).to.equal('staging.demandware.net'); + expect(config.clientSecret).to.equal('prod-secret'); + expect(hostnameMismatch).to.equal(false); + expect(warnings).to.have.length(0); + }); + + it('handles empty base config', () => { + const overrides = { + hostname: 'example.demandware.net', + clientId: 'client', + }; + const base = {}; + + const {config, warnings} = mergeConfigsWithProtection(overrides, base); + + expect(config.hostname).to.equal('example.demandware.net'); + expect(config.clientId).to.equal('client'); + expect(warnings).to.have.length(0); + }); + + it('handles empty overrides', () => { + const overrides = {}; + const base = { + hostname: 'example.demandware.net', + codeVersion: 'v1', + }; + + const {config, warnings} = mergeConfigsWithProtection(overrides, base); + + expect(config.hostname).to.equal('example.demandware.net'); + expect(config.codeVersion).to.equal('v1'); + expect(warnings).to.have.length(0); + }); + }); + + describe('getPopulatedFields', () => { + it('returns list of fields with values', () => { + const config = { + hostname: 'example.demandware.net', + codeVersion: 'v1', + username: 'user', + }; + + const fields = getPopulatedFields(config); + + expect(fields).to.have.members(['hostname', 'codeVersion', 'username']); + }); + + it('excludes undefined fields', () => { + const config = { + hostname: 'example.demandware.net', + codeVersion: undefined, + username: undefined, + }; + + const fields = getPopulatedFields(config); + + expect(fields).to.deep.equal(['hostname']); + }); + + it('excludes null fields', () => { + const config = { + hostname: 'example.demandware.net', + codeVersion: null as unknown as string, + }; + + const fields = getPopulatedFields(config); + + expect(fields).to.deep.equal(['hostname']); + }); + + it('excludes empty string fields', () => { + const config = { + hostname: 'example.demandware.net', + codeVersion: '', + }; + + const fields = getPopulatedFields(config); + + expect(fields).to.deep.equal(['hostname']); + }); + + it('returns empty array for empty config', () => { + const config = {}; + + const fields = getPopulatedFields(config); + + expect(fields).to.have.length(0); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/config/resolver.test.ts b/packages/b2c-tooling-sdk/test/config/resolver.test.ts new file mode 100644 index 0000000..361a703 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/config/resolver.test.ts @@ -0,0 +1,327 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import { + ConfigResolver, + createConfigResolver, + type ConfigSource, + type NormalizedConfig, + type ResolveConfigOptions, +} from '@salesforce/b2c-tooling-sdk/config'; + +/** + * Mock config source for testing. + */ +class MockSource implements ConfigSource { + constructor( + public name: string, + private config: NormalizedConfig | undefined, + private path?: string, + ) {} + + load(_options: ResolveConfigOptions): NormalizedConfig | undefined { + return this.config; + } + + getPath(): string | undefined { + return this.path; + } +} + +describe('config/resolver', () => { + describe('ConfigResolver', () => { + describe('resolve', () => { + it('resolves from a single source', () => { + const source = new MockSource('test', { + hostname: 'example.demandware.net', + codeVersion: 'v1', + }); + const resolver = new ConfigResolver([source]); + + const {config, warnings, sources} = resolver.resolve(); + + expect(config.hostname).to.equal('example.demandware.net'); + expect(config.codeVersion).to.equal('v1'); + expect(warnings).to.have.length(0); + expect(sources).to.have.length(1); + expect(sources[0].name).to.equal('test'); + }); + + it('applies overrides with highest priority', () => { + const source = new MockSource('test', { + hostname: 'source.demandware.net', + codeVersion: 'v1', + clientId: 'source-client', + }); + const resolver = new ConfigResolver([source]); + + const {config} = resolver.resolve({ + hostname: 'source.demandware.net', + codeVersion: 'v2', + }); + + expect(config.hostname).to.equal('source.demandware.net'); + expect(config.codeVersion).to.equal('v2'); + expect(config.clientId).to.equal('source-client'); + }); + + it('resolves from multiple sources with priority order', () => { + const source1 = new MockSource('first', { + hostname: 'first.demandware.net', + codeVersion: 'v1', + }); + const source2 = new MockSource('second', { + hostname: 'second.demandware.net', + codeVersion: 'v2', + clientId: 'second-client', + }); + const resolver = new ConfigResolver([source1, source2]); + + const {config, sources} = resolver.resolve(); + + // First source wins for hostname and codeVersion + expect(config.hostname).to.equal('first.demandware.net'); + expect(config.codeVersion).to.equal('v1'); + // Second source contributes clientId (not in first source) + expect(config.clientId).to.equal('second-client'); + expect(sources).to.have.length(2); + }); + + it('tracks source paths when available', () => { + const source = new MockSource('test', {hostname: 'example.demandware.net'}, '/path/to/dw.json'); + const resolver = new ConfigResolver([source]); + + const {sources} = resolver.resolve(); + + expect(sources[0].path).to.equal('/path/to/dw.json'); + }); + + it('tracks which fields each source contributed', () => { + const source1 = new MockSource('first', { + hostname: 'example.demandware.net', + }); + const source2 = new MockSource('second', { + clientId: 'test-client', + clientSecret: 'test-secret', + }); + const resolver = new ConfigResolver([source1, source2]); + + const {sources} = resolver.resolve(); + + expect(sources[0].fieldsContributed).to.deep.equal(['hostname']); + expect(sources[1].fieldsContributed).to.have.members(['clientId', 'clientSecret']); + }); + + it('skips sources that return undefined', () => { + const source1 = new MockSource('empty', undefined); + const source2 = new MockSource('valid', { + hostname: 'example.demandware.net', + }); + const resolver = new ConfigResolver([source1, source2]); + + const {config, sources} = resolver.resolve(); + + expect(config.hostname).to.equal('example.demandware.net'); + expect(sources).to.have.length(1); + expect(sources[0].name).to.equal('valid'); + }); + + it('skips sources that return empty config', () => { + const source1 = new MockSource('empty', {}); + const source2 = new MockSource('valid', { + hostname: 'example.demandware.net', + }); + const resolver = new ConfigResolver([source1, source2]); + + const {sources} = resolver.resolve(); + + expect(sources).to.have.length(1); + expect(sources[0].name).to.equal('valid'); + }); + + it('applies hostname mismatch protection', () => { + const source = new MockSource('test', { + hostname: 'prod.demandware.net', + clientId: 'prod-client', + clientSecret: 'prod-secret', + }); + const resolver = new ConfigResolver([source]); + + const {config, warnings} = resolver.resolve({hostname: 'staging.demandware.net'}, {hostnameProtection: true}); + + expect(config.hostname).to.equal('staging.demandware.net'); + expect(config.clientId).to.be.undefined; + expect(config.clientSecret).to.be.undefined; + expect(warnings).to.have.length(1); + expect(warnings[0].code).to.equal('HOSTNAME_MISMATCH'); + }); + + it('returns empty config when no sources have data', () => { + const resolver = new ConfigResolver([]); + + const {config, sources} = resolver.resolve(); + + // Config has all fields set to undefined (not an empty object) + expect(config.hostname).to.be.undefined; + expect(config.clientId).to.be.undefined; + expect(sources).to.have.length(0); + }); + }); + + describe('credential grouping', () => { + it('does not mix clientId and clientSecret from different sources', () => { + const source1 = new MockSource('first', {clientId: 'first-client'}); + const source2 = new MockSource('second', {clientSecret: 'second-secret'}); + const resolver = new ConfigResolver([source1, source2]); + + const {config} = resolver.resolve(); + + expect(config.clientId).to.equal('first-client'); + expect(config.clientSecret).to.be.undefined; // Not mixed from source2 + }); + + it('does not mix username and password from different sources', () => { + const source1 = new MockSource('first', {username: 'user1'}); + const source2 = new MockSource('second', {password: 'pass2'}); + const resolver = new ConfigResolver([source1, source2]); + + const {config} = resolver.resolve(); + + expect(config.username).to.equal('user1'); + expect(config.password).to.be.undefined; // Not mixed from source2 + }); + + it('allows complete credential pairs from same source', () => { + const source1 = new MockSource('first', {hostname: 'example.com'}); + const source2 = new MockSource('second', { + clientId: 'client', + clientSecret: 'secret', + }); + const resolver = new ConfigResolver([source1, source2]); + + const {config} = resolver.resolve(); + + expect(config.hostname).to.equal('example.com'); + expect(config.clientId).to.equal('client'); + expect(config.clientSecret).to.equal('secret'); + }); + + it('allows non-grouped fields to merge normally', () => { + const source1 = new MockSource('first', {clientId: 'client'}); + const source2 = new MockSource('second', { + hostname: 'example.com', + codeVersion: 'v1', + }); + const resolver = new ConfigResolver([source1, source2]); + + const {config} = resolver.resolve(); + + expect(config.clientId).to.equal('client'); + expect(config.hostname).to.equal('example.com'); + expect(config.codeVersion).to.equal('v1'); + }); + + it('blocks both oauth fields when clientId is claimed', () => { + const source1 = new MockSource('first', {clientId: 'first-client'}); + const source2 = new MockSource('second', { + clientId: 'second-client', + clientSecret: 'second-secret', + }); + const resolver = new ConfigResolver([source1, source2]); + + const {config} = resolver.resolve(); + + expect(config.clientId).to.equal('first-client'); + expect(config.clientSecret).to.be.undefined; // Blocked due to group claim + }); + + it('blocks both basic auth fields when username is claimed', () => { + const source1 = new MockSource('first', {username: 'first-user'}); + const source2 = new MockSource('second', { + username: 'second-user', + password: 'second-pass', + }); + const resolver = new ConfigResolver([source1, source2]); + + const {config} = resolver.resolve(); + + expect(config.username).to.equal('first-user'); + expect(config.password).to.be.undefined; // Blocked due to group claim + }); + + it('allows independent credential groups to come from different sources', () => { + const source1 = new MockSource('first', { + clientId: 'oauth-client', + clientSecret: 'oauth-secret', + }); + const source2 = new MockSource('second', { + username: 'basic-user', + password: 'basic-pass', + }); + const resolver = new ConfigResolver([source1, source2]); + + const {config} = resolver.resolve(); + + // OAuth from source1 + expect(config.clientId).to.equal('oauth-client'); + expect(config.clientSecret).to.equal('oauth-secret'); + // Basic from source2 + expect(config.username).to.equal('basic-user'); + expect(config.password).to.equal('basic-pass'); + }); + }); + + describe('createAuthCredentials', () => { + it('creates auth credentials from resolved config', () => { + const source = new MockSource('test', { + hostname: 'example.demandware.net', + clientId: 'test-client', + clientSecret: 'test-secret', + scopes: ['mail', 'roles'], + username: 'user', + password: 'pass', + mrtApiKey: 'api-key', + }); + const resolver = new ConfigResolver([source]); + + const credentials = resolver.createAuthCredentials(); + + expect(credentials.clientId).to.equal('test-client'); + expect(credentials.clientSecret).to.equal('test-secret'); + expect(credentials.scopes).to.deep.equal(['mail', 'roles']); + expect(credentials.username).to.equal('user'); + expect(credentials.password).to.equal('pass'); + expect(credentials.apiKey).to.equal('api-key'); + }); + + it('applies overrides to auth credentials', () => { + const source = new MockSource('test', { + hostname: 'example.demandware.net', + clientId: 'source-client', + }); + const resolver = new ConfigResolver([source]); + + const credentials = resolver.createAuthCredentials({ + hostname: 'example.demandware.net', + clientId: 'override-client', + }); + + expect(credentials.clientId).to.equal('override-client'); + }); + }); + }); + + describe('createConfigResolver', () => { + it('creates a resolver with default sources', () => { + const resolver = createConfigResolver(); + + // Should not throw + const {config} = resolver.resolve({hostname: 'test.demandware.net'}); + + expect(config.hostname).to.equal('test.demandware.net'); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfe26e8..850cbe2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,6 +197,46 @@ importers: specifier: ^8 version: 8.46.4(eslint@9.39.1)(typescript@5.9.3) + packages/b2c-plugin-example-config: + dependencies: + '@salesforce/b2c-tooling-sdk': + specifier: workspace:* + version: link:../b2c-tooling-sdk + devDependencies: + '@eslint/compat': + specifier: ^1 + version: 1.4.1(eslint@9.39.1) + '@oclif/core': + specifier: ^4 + version: 4.8.0 + '@salesforce/dev-config': + specifier: ^4.3.2 + version: 4.3.2 + '@types/node': + specifier: ^18 + version: 18.19.130 + eslint: + specifier: ^9 + version: 9.39.1 + eslint-config-oclif: + specifier: ^6 + version: 6.0.116(eslint@9.39.1)(typescript@5.9.3) + eslint-config-prettier: + specifier: ^10 + version: 10.1.8(eslint@9.39.1) + eslint-plugin-header: + specifier: ^3.1.1 + version: 3.1.1(eslint@9.39.1) + eslint-plugin-prettier: + specifier: ^5.5.4 + version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.39.1))(eslint@9.39.1)(prettier@3.6.2) + prettier: + specifier: ^3.6.2 + version: 3.6.2 + typescript: + specifier: ^5 + version: 5.9.3 + packages/b2c-tooling-sdk: dependencies: archiver: