diff --git a/packages/b2c-dx-mcp/README.md b/packages/b2c-dx-mcp/README.md index c374bcf0..d810bf37 100644 --- a/packages/b2c-dx-mcp/README.md +++ b/packages/b2c-dx-mcp/README.md @@ -60,7 +60,9 @@ npm install -g @salesforce/b2c-dx-mcp ### Workspace Auto-Discovery -When neither `--toolsets` nor `--tools` are provided, the MCP server automatically detects your project type and enables the appropriate toolsets. +The MCP server automatically detects your project type and enables appropriate toolsets when: +1. Neither `--toolsets` nor `--tools` are provided +2. All provided `--toolsets` or `--tools` are invalid (typos, unknown names) **How it works:** @@ -77,7 +79,7 @@ The **SCAPI** toolset is always enabled, providing API discovery and custom API | Project Type | Detection | Toolsets Enabled | |--------------|-----------|------------------| | **PWA Kit v3** | `@salesforce/pwa-kit-*`, `@salesforce/retail-react-app`, or `ccExtensibility` | PWAV3, MRT, SCAPI | -| **Storefront Next** | `@salesforce/storefront-next-*` packages in package.json | STOREFRONTNEXT, MRT, SCAPI | +| **Storefront Next** | `@salesforce/storefront-next-*` packages in package.json | STOREFRONTNEXT, MRT, CARTRIDGES, SCAPI | | **Cartridges** | Any cartridge with `.project` file (detected via `findCartridges`) | CARTRIDGES, SCAPI | | **No project detected** | No B2C project markers found | SCAPI (base toolset only) | @@ -504,10 +506,43 @@ Tools that interact with B2C Commerce instances (e.g., `cartridge_deploy`, SCAPI **Option E: dw.json with auto-discovery** -When `--config` is not provided, the MCP server searches upward from `~/` for a `dw.json` file. +When `--config` is not provided, the MCP server searches for `dw.json` starting from the `--working-directory` path (or `SFCC_WORKING_DIRECTORY` env var). -> **Note:** Auto-discovery starts from the home directory, so it won't find project-level `dw.json` files. Use `--config` with an explicit path instead. +> **Important:** MCP clients like Cursor and Claude Desktop often spawn servers from the home directory (`~`) rather than the project directory. Always set `--working-directory` for reliable configuration loading and auto-discovery. +**Cursor** (supports `${workspaceFolder}`): +```json +{ + "mcpServers": { + "b2c-dx": { + "command": "/path/to/packages/b2c-dx-mcp/bin/dev.js", + "args": [ + "--toolsets", "CARTRIDGES", + "--working-directory", "${workspaceFolder}", + "--allow-non-ga-tools" + ] + } + } +} +``` + +**Claude Desktop** (use explicit path): +```json +{ + "mcpServers": { + "b2c-dx": { + "command": "/path/to/packages/b2c-dx-mcp/bin/dev.js", + "args": [ + "--toolsets", "CARTRIDGES", + "--working-directory", "/path/to/your/project", + "--allow-non-ga-tools" + ] + } + } +} +``` + +**Example dw.json:** ```json { "hostname": "your-sandbox.demandware.net", diff --git a/packages/b2c-dx-mcp/src/commands/mcp.ts b/packages/b2c-dx-mcp/src/commands/mcp.ts index 36ef1826..07ee4072 100644 --- a/packages/b2c-dx-mcp/src/commands/mcp.ts +++ b/packages/b2c-dx-mcp/src/commands/mcp.ts @@ -128,7 +128,16 @@ */ import {Flags} from '@oclif/core'; -import {BaseCommand, MrtCommand, InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import { + BaseCommand, + MrtCommand, + InstanceCommand, + loadConfig, + extractInstanceFlags, + extractMrtFlags, +} from '@salesforce/b2c-tooling-sdk/cli'; +import type {LoadConfigOptions} from '@salesforce/b2c-tooling-sdk/cli'; +import type {ResolvedB2CConfig} from '@salesforce/b2c-tooling-sdk/config'; import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js'; import {B2CDxMcpServer} from '../server.js'; import {Services} from '../services.js'; @@ -202,6 +211,38 @@ export default class McpServerCommand extends BaseCommand); + const options: LoadConfigOptions = { + ...this.getBaseConfigOptions(), + ...mrt.options, + }; + + // Combine B2C instance flags and MRT config flags + const flagConfig = { + ...extractInstanceFlags(this.flags as Record), + ...mrt.config, + }; + + return loadConfig(flagConfig, options); + } + /** * Main entry point - starts the MCP server. * @@ -209,7 +250,7 @@ export default class McpServerCommand extends BaseCommand = { cartridges: ['CARTRIDGES'], 'pwa-kit-v3': ['PWAV3', 'MRT'], - 'storefront-next': ['STOREFRONTNEXT', 'MRT'], + 'storefront-next': ['STOREFRONTNEXT', 'MRT', 'CARTRIDGES'], }; /** @@ -107,14 +107,60 @@ export function createToolRegistry(services: Services): ToolRegistry { return registry; } +/** + * Performs workspace auto-discovery and returns appropriate toolsets. + * Always includes BASE_TOOLSET even if no project types are detected. + * + * @param flags - Startup flags containing workingDirectory + * @param reason - Reason for triggering auto-discovery (for logging) + * @returns Array of toolsets to enable + */ +async function performAutoDiscovery(flags: StartupFlags, reason: string): Promise { + const logger = getLogger(); + + // Working directory from --working-directory flag or SFCC_WORKING_DIRECTORY env var + const workingDirectory = flags.workingDirectory ?? process.cwd(); + + // Warn if working directory wasn't explicitly configured + if (!flags.workingDirectory) { + logger.warn( + {cwd: workingDirectory}, + 'No --working-directory flag or SFCC_WORKING_DIRECTORY env var provided. ' + + 'MCP clients like Cursor and Claude Desktop often spawn servers from ~ instead of the project directory. ' + + 'Set --working-directory or SFCC_WORKING_DIRECTORY for reliable auto-discovery.', + ); + } + + const detectionResult = await detectWorkspaceType(workingDirectory); + + // Map all detected project types to MCP toolsets (union) + // Note: getToolsetsForProjectTypes always includes BASE_TOOLSET + const mappedToolsets = getToolsetsForProjectTypes(detectionResult.projectTypes); + + logger.info( + { + reason, + projectTypes: detectionResult.projectTypes, + matchedPatterns: detectionResult.matchedPatterns, + enabledToolsets: mappedToolsets, + }, + `Auto-discovery (${reason}): project types: ${detectionResult.projectTypes.join(', ') || 'none'}`, + ); + + return mappedToolsets; +} + /** * Register tools with the MCP server based on startup flags. * * Tool selection logic: - * 1. If neither --toolsets nor --tools are provided, perform auto-discovery + * 1. If no valid tools result from --toolsets and --tools, perform auto-discovery * 2. Start with all tools from --toolsets (or auto-discovered toolsets) * 3. Add individual tools from --tools (can be from any toolset) * + * Auto-discovery always enables at least the BASE_TOOLSET (SCAPI), even if no + * project types are detected in the workspace. + * * Example: * --toolsets STOREFRONTNEXT,MRT --tools cartridge_deploy * This enables STOREFRONTNEXT and MRT toolsets, plus adds cartridge_deploy from CARTRIDGES. @@ -124,44 +170,11 @@ export function createToolRegistry(services: Services): ToolRegistry { * @param services - Services instance */ export async function registerToolsets(flags: StartupFlags, server: B2CDxMcpServer, services: Services): Promise { - let toolsets = flags.toolsets ?? []; + const toolsets = flags.toolsets ?? []; const individualTools = flags.tools ?? []; const allowNonGaTools = flags.allowNonGaTools ?? false; const logger = getLogger(); - // Auto-discovery: When no --toolsets or --tools flags are provided, - // detect project type and enable appropriate toolsets automatically. - if (toolsets.length === 0 && individualTools.length === 0) { - // Working directory from --working-directory flag or SFCC_WORKING_DIRECTORY env var - const workingDirectory = flags.workingDirectory ?? process.cwd(); - - // Warn if working directory wasn't explicitly configured - if (!flags.workingDirectory) { - logger.warn( - {cwd: workingDirectory}, - 'No --working-directory flag or SFCC_WORKING_DIRECTORY env var provided. ' + - 'MCP clients like Cursor and Claude Desktop often spawn servers from ~ instead of the project directory. ' + - 'Set --working-directory or SFCC_WORKING_DIRECTORY for reliable auto-discovery.', - ); - } - - const detectionResult = await detectWorkspaceType(workingDirectory); - - // Map all detected project types to MCP toolsets (union) - const mappedToolsets = getToolsetsForProjectTypes(detectionResult.projectTypes); - - logger.info( - { - projectTypes: detectionResult.projectTypes, - matchedPatterns: detectionResult.matchedPatterns, - enabledToolsets: mappedToolsets, - }, - `Auto-discovered project types: ${detectionResult.projectTypes.join(', ') || 'none'}`, - ); - - toolsets = mappedToolsets; - } - // Create the tool registry (all available tools) const toolRegistry = createToolRegistry(services); @@ -170,8 +183,11 @@ export async function registerToolsets(flags: StartupFlags, server: B2CDxMcpServ const allToolsByName = new Map(allTools.map((tool) => [tool.name, tool])); const existingToolNames = new Set(allToolsByName.keys()); - // Warn about invalid --tools names (but continue with valid ones) + // Determine valid individual tools const invalidTools = individualTools.filter((name) => !existingToolNames.has(name)); + const validIndividualTools = individualTools.filter((name) => existingToolNames.has(name)); + + // Warn about invalid --tools names (but continue with valid ones) if (invalidTools.length > 0) { logger.warn( {invalidTools, validTools: [...existingToolNames]}, @@ -194,6 +210,17 @@ export async function registerToolsets(flags: StartupFlags, server: B2CDxMcpServ const validToolsets = toolsets.filter((t): t is Toolset => TOOLSETS.includes(t as Toolset)); const toolsetsToEnable = new Set(toolsets.includes(ALL_TOOLSETS) ? TOOLSETS : validToolsets); + // Auto-discovery: If no valid toolsets AND no valid individual tools, detect workspace type. + // This handles both: (1) no flags provided, and (2) all provided flags are invalid. + // Auto-discovery enables appropriate toolsets based on workspace type, + // or at minimum BASE_TOOLSET if no project types are detected. + if (toolsetsToEnable.size === 0 && validIndividualTools.length === 0) { + const discoveredToolsets = await performAutoDiscovery(flags, 'no valid toolsets or tools'); + for (const toolset of discoveredToolsets) { + toolsetsToEnable.add(toolset); + } + } + // Build the set of tools to register: // 1. Start with tools from enabled toolsets // 2. Add individual tools from --tools @@ -211,7 +238,7 @@ export async function registerToolsets(flags: StartupFlags, server: B2CDxMcpServ } // Step 2: Add individual tools from --tools (can be from any toolset) - for (const toolName of individualTools) { + for (const toolName of validIndividualTools) { const tool = allToolsByName.get(toolName); if (tool && !registeredToolNames.has(toolName)) { toolsToRegister.push(tool); diff --git a/packages/b2c-dx-mcp/src/services.ts b/packages/b2c-dx-mcp/src/services.ts index 5121264a..54efd485 100644 --- a/packages/b2c-dx-mcp/src/services.ts +++ b/packages/b2c-dx-mcp/src/services.ts @@ -16,20 +16,11 @@ * * ## Creating Services * - * Use {@link Services.create} to create an instance with all configuration - * resolved eagerly at startup: + * Use {@link Services.fromResolvedConfig} with an already-resolved configuration: * * ```typescript - * const services = Services.create({ - * b2cInstance: { - * configPath: flags.config, - * hostname: flags.server, - * }, - * mrt: { - * apiKey: flags['api-key'], - * project: flags.project, - * }, - * }); + * // In a command that extends BaseCommand + * const services = Services.fromResolvedConfig(this.resolvedConfig); * ``` * * ## Resolution Pattern @@ -52,7 +43,7 @@ import path from 'node:path'; import os from 'node:os'; import type {B2CInstance} from '@salesforce/b2c-tooling-sdk'; import type {AuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; -import {resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; +import type {ResolvedB2CConfig} from '@salesforce/b2c-tooling-sdk/config'; /** * MRT (Managed Runtime) configuration. @@ -67,50 +58,6 @@ 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(). - */ -export interface MrtCreateOptions { - /** MRT API key from --api-key flag */ - apiKey?: string; - /** MRT cloud origin URL for environment-specific config files */ - cloudOrigin?: string; - /** MRT project slug from --project flag */ - project?: string; - /** MRT environment from --environment flag */ - environment?: string; -} - -/** - * Options for Services.create() factory method. - */ -export interface ServicesCreateOptions { - /** B2C instance configuration (from InstanceCommand.baseFlags) */ - b2cInstance?: B2CInstanceCreateOptions; - /** MRT configuration (from MrtCommand.baseFlags) */ - mrt?: MrtCreateOptions; -} - /** * Options for Services constructor (internal). */ @@ -124,15 +71,13 @@ export interface ServicesOptions { /** * Services class that provides utilities for MCP tools. * - * Use the static `Services.create()` factory method to create an instance - * with all configuration resolved eagerly at startup. + * Use the static `Services.fromResolvedConfig()` factory method to create + * an instance from an already-resolved configuration. * * @example * ```typescript - * const services = Services.create({ - * b2cInstance: { hostname: flags.server }, - * mrt: { apiKey: flags['api-key'], project: flags.project }, - * }); + * // In a command that extends BaseCommand + * const services = Services.fromResolvedConfig(this.resolvedConfig); * * // Access resolved config * services.b2cInstance; // B2CInstance | undefined @@ -160,47 +105,18 @@ export class Services { } /** - * Creates a Services instance with all configuration resolved eagerly. - * - * Uses the unified {@link resolveConfig} API from the SDK to resolve all - * configuration from multiple sources (flags, dw.json, ~/.mobify). + * Creates a Services instance from an already-resolved configuration. * - * **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 + * @param config - Already-resolved configuration from BaseCommand.resolvedConfig * @returns Services instance with resolved config * * @example * ```typescript - * const services = Services.create({ - * b2cInstance: { configPath: flags.config, hostname: flags.server }, - * mrt: { apiKey: flags['api-key'], project: flags.project }, - * }); + * // In a command that extends BaseCommand + * const services = Services.fromResolvedConfig(this.resolvedConfig); * ``` */ - public static create(options: ServicesCreateOptions = {}): Services { - // 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, - }, - ); - + public static fromResolvedConfig(config: ResolvedB2CConfig): Services { // Build MRT config using factory methods const mrtConfig: MrtConfig = { auth: config.hasMrtConfig() ? config.createMrtAuth() : undefined, diff --git a/packages/b2c-dx-mcp/src/tools/adapter.ts b/packages/b2c-dx-mcp/src/tools/adapter.ts index e5803099..1f50c1d1 100644 --- a/packages/b2c-dx-mcp/src/tools/adapter.ts +++ b/packages/b2c-dx-mcp/src/tools/adapter.ts @@ -16,7 +16,7 @@ * ## Configuration Resolution * * Both B2C instance and MRT auth are resolved once at server startup via - * {@link Services.create} and reused for all tool calls: + * {@link Services.fromResolvedConfig} and reused for all tool calls: * * - **B2CInstance**: Resolved from flags + dw.json. Available when `requiresInstance: true`. * - **MRT Auth**: Resolved from --api-key → SFCC_MRT_API_KEY → ~/.mobify. Available when `requiresMrtAuth: true`. @@ -48,11 +48,8 @@ * * @example MRT tool (MRT API) * ```typescript - * // Services created with auth resolved at startup - * const services = Services.create({ - * mrtApiKey: flags['api-key'], - * mrtCloudOrigin: flags['cloud-origin'], - * }); + * // Services created from already-resolved config at startup + * const services = Services.fromResolvedConfig(this.resolvedConfig); * * const mrtTool = createToolAdapter({ * name: 'mrt_bundle_push', diff --git a/packages/b2c-dx-mcp/test/registry.test.ts b/packages/b2c-dx-mcp/test/registry.test.ts index a0d63eb0..a5f7624d 100644 --- a/packages/b2c-dx-mcp/test/registry.test.ts +++ b/packages/b2c-dx-mcp/test/registry.test.ts @@ -295,7 +295,7 @@ describe('registry', () => { expect(server.registeredTools).to.include('cartridge_deploy'); }); - it('should register no tools when all toolsets are invalid', async () => { + it('should trigger auto-discovery when all toolsets are invalid', async () => { const services = createMockServices(); const server = createMockServer(); const flags: StartupFlags = { @@ -303,11 +303,30 @@ describe('registry', () => { allowNonGaTools: true, }; - // Should not throw + // Should not throw - triggers auto-discovery as fallback await registerToolsets(flags, server, services); - // No tools should be registered - expect(server.registeredTools).to.have.lengthOf(0); + // Auto-discovery always includes BASE_TOOLSET (SCAPI), even if no project type detected + expect(server.registeredTools).to.include('scapi_discovery'); + expect(server.registeredTools).to.include('scapi_customapi_scaffold'); + expect(server.registeredTools).to.include('scapi_custom_api_discovery'); + }); + + it('should trigger auto-discovery when all individual tools are invalid', async () => { + const services = createMockServices(); + const server = createMockServer(); + const flags: StartupFlags = { + tools: ['nonexistent_tool', 'another_fake_tool'], + allowNonGaTools: true, + }; + + // Should not throw - triggers auto-discovery as fallback + await registerToolsets(flags, server, services); + + // Auto-discovery always includes BASE_TOOLSET (SCAPI), even if no project type detected + expect(server.registeredTools).to.include('scapi_discovery'); + expect(server.registeredTools).to.include('scapi_customapi_scaffold'); + expect(server.registeredTools).to.include('scapi_custom_api_discovery'); }); it('should skip non-GA tools when allowNonGaTools is false', async () => { diff --git a/packages/b2c-dx-mcp/test/tools/adapter.test.ts b/packages/b2c-dx-mcp/test/tools/adapter.test.ts index cd098293..37e03652 100644 --- a/packages/b2c-dx-mcp/test/tools/adapter.test.ts +++ b/packages/b2c-dx-mcp/test/tools/adapter.test.ts @@ -11,6 +11,7 @@ import {Services} from '../../src/services.js'; import type {ToolExecutionContext} from '../../src/tools/adapter.js'; import type {ToolResult} from '../../src/utils/types.js'; import type {AuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; +import {resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; // Create a mock services instance for testing function createMockServices(options?: {mrtAuth?: AuthStrategy}): Services { @@ -532,8 +533,9 @@ describe('tools/adapter', () => { }); it('should provide mrtConfig in context when auth is configured', async () => { - // Use Services.create() to resolve auth (simulating what mcp.ts does at startup) - const services = Services.create({mrt: {apiKey: 'test-api-key-12345'}}); + // Use resolveConfig + Services.fromResolvedConfig (simulating what mcp.ts does at startup) + const config = resolveConfig({mrtApiKey: 'test-api-key-12345'}); + const services = Services.fromResolvedConfig(config); let contextReceived: ToolExecutionContext | undefined; const tool = createToolAdapter( @@ -560,12 +562,11 @@ describe('tools/adapter', () => { expect(contextReceived?.mrtConfig?.auth).to.have.property('fetch'); }); - it('should support mrtCloudOrigin option in Services.create()', async () => { - // Services.create() accepts cloudOrigin for environment-specific config - // Note: oclif handles env var fallback for --api-key flag, so we pass apiKey explicitly here - const services = Services.create({ - mrt: {apiKey: 'staging-api-key', cloudOrigin: 'https://cloud-staging.mobify.com'}, - }); + it('should support cloudOrigin option in resolveConfig()', async () => { + // resolveConfig() accepts cloudOrigin for environment-specific config + // Note: oclif handles env var fallback for --api-key flag, so we pass mrtApiKey explicitly here + const config = resolveConfig({mrtApiKey: 'staging-api-key'}, {cloudOrigin: 'https://cloud-staging.mobify.com'}); + const services = Services.fromResolvedConfig(config); let contextReceived: ToolExecutionContext | undefined; const tool = createToolAdapter( diff --git a/packages/b2c-tooling-sdk/src/cli/base-command.ts b/packages/b2c-tooling-sdk/src/cli/base-command.ts index 8e21deed..f7d83d2d 100644 --- a/packages/b2c-tooling-sdk/src/cli/base-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/base-command.ts @@ -205,18 +205,43 @@ export abstract class BaseCommand extends Command { return input; } - protected loadConfiguration(): ResolvedB2CConfig { - const options: LoadConfigOptions = { + /** + * Gets base configuration options from common flags. + * + * Subclasses should spread these options when overriding loadConfiguration() + * to ensure common options like startDir are always included. + * + * @example + * ```typescript + * protected override loadConfiguration(): ResolvedB2CConfig { + * const options: LoadConfigOptions = { + * ...this.getBaseConfigOptions(), + * // Add subclass-specific options here + * }; + * return loadConfig(extractMyFlags(this.flags), options, this.getPluginSources()); + * } + * ``` + */ + protected getBaseConfigOptions(): LoadConfigOptions { + return { instance: this.flags.instance, configPath: this.flags.config, + startDir: this.flags['working-directory'], }; + } - const pluginSources: PluginSources = { + /** + * Gets plugin sources for configuration resolution. + */ + protected getPluginSources(): PluginSources { + return { before: this.pluginSourcesBefore, after: this.pluginSourcesAfter, }; + } - return loadConfig({}, options, pluginSources); + protected loadConfiguration(): ResolvedB2CConfig { + return loadConfig({}, this.getBaseConfigOptions(), this.getPluginSources()); } /** diff --git a/packages/b2c-tooling-sdk/src/cli/config.ts b/packages/b2c-tooling-sdk/src/cli/config.ts index da258af3..2f619312 100644 --- a/packages/b2c-tooling-sdk/src/cli/config.ts +++ b/packages/b2c-tooling-sdk/src/cli/config.ts @@ -22,6 +22,128 @@ export type {AuthMethod}; export {ALL_AUTH_METHODS}; export {findDwJson}; +/** + * Type for oclif parsed flags object. + * Using Record since flags can have various types. + */ +export type ParsedFlags = Record; + +/** + * Extracts OAuth-related configuration from oclif flags. + * + * Use this to extract OAuth flags (--client-id, --client-secret, etc.) + * from parsed oclif flags into a NormalizedConfig partial. + * + * @param flags - Parsed oclif flags + * @returns Partial NormalizedConfig with OAuth fields + * + * @example + * ```typescript + * const flagConfig = extractOAuthFlags(this.flags); + * return loadConfig(flagConfig, options); + * ``` + */ +export function extractOAuthFlags(flags: ParsedFlags): Partial { + const scopes = flags.scope as string[] | undefined; + + // Parse auth methods from --auth-methods flag + const authMethodValues = flags['auth-methods'] as string[] | undefined; + let authMethods: AuthMethod[] | undefined; + if (authMethodValues && authMethodValues.length > 0) { + const methods = authMethodValues + .map((s) => s.trim()) + .filter((s): s is AuthMethod => ALL_AUTH_METHODS.includes(s as AuthMethod)); + authMethods = methods.length > 0 ? methods : undefined; + } + + return { + clientId: flags['client-id'] as string | undefined, + clientSecret: flags['client-secret'] as string | undefined, + shortCode: flags['short-code'] as string | undefined, + tenantId: flags['tenant-id'] as string | undefined, + authMethods, + accountManagerHost: flags['account-manager-host'] as string | undefined, + scopes: scopes && scopes.length > 0 ? scopes : undefined, + }; +} + +/** + * Extracts B2C instance-related configuration from oclif flags. + * + * Includes both instance-specific flags (--server, --username, etc.) + * and OAuth flags since instance operations often need both. + * + * @param flags - Parsed oclif flags + * @returns Partial NormalizedConfig with instance and OAuth fields + * + * @example + * ```typescript + * const flagConfig = extractInstanceFlags(this.flags); + * return loadConfig(flagConfig, options); + * ``` + */ +export function extractInstanceFlags(flags: ParsedFlags): Partial { + return { + // Instance-specific flags + hostname: flags.server as string | undefined, + webdavHostname: flags['webdav-server'] as string | undefined, + codeVersion: flags['code-version'] as string | undefined, + username: flags.username as string | undefined, + password: flags.password as string | undefined, + // Include OAuth flags (instance operations often need OAuth too) + ...extractOAuthFlags(flags), + }; +} + +/** + * Result of extracting MRT flags from oclif parsed flags. + * + * Contains both config values (for loadConfig's first argument) and + * loading options (to spread into LoadConfigOptions). + */ +export interface ExtractedMrtFlags { + /** MRT config values to pass to loadConfig's first argument */ + config: Partial; + /** MRT loading options to spread into LoadConfigOptions */ + options: Pick; +} + +/** + * Extracts MRT (Managed Runtime) configuration from oclif flags. + * + * Use this to extract MRT flags (--api-key, --project, --environment, --cloud-origin, --credentials-file) + * from parsed oclif flags. Returns both config values and loading options. + * + * @param flags - Parsed oclif flags + * @returns Object with `config` (NormalizedConfig partial) and `options` (LoadConfigOptions partial) + * + * @example + * ```typescript + * const mrt = extractMrtFlags(this.flags); + * const options: LoadConfigOptions = { + * ...this.getBaseConfigOptions(), + * ...mrt.options, + * }; + * return loadConfig(mrt.config, options, this.getPluginSources()); + * ``` + */ +export function extractMrtFlags(flags: ParsedFlags): ExtractedMrtFlags { + const cloudOrigin = flags['cloud-origin'] as string | undefined; + const credentialsFile = flags['credentials-file'] as string | undefined; + return { + config: { + mrtApiKey: flags['api-key'] as string | undefined, + mrtProject: flags.project as string | undefined, + mrtEnvironment: flags.environment as string | undefined, + mrtOrigin: cloudOrigin, + }, + options: { + cloudOrigin, + credentialsFile, + }, + }; +} + /** * Options for loading configuration. */ @@ -30,6 +152,8 @@ export interface LoadConfigOptions { instance?: string; /** Explicit path to config file (skips searching if provided) */ configPath?: string; + /** Starting directory for config file search (default: current working directory) */ + startDir?: string; /** Cloud origin for MRT ~/.mobify lookup (e.g., https://cloud-staging.mobify.com) */ cloudOrigin?: string; /** Path to custom MRT credentials file (overrides default ~/.mobify) */ @@ -99,6 +223,7 @@ export function loadConfig( const resolved = resolveConfig(effectiveFlags, { instance: options.instance, configPath: options.configPath, + startDir: options.startDir, hostnameProtection: true, cloudOrigin: options.cloudOrigin, credentialsFile: options.credentialsFile, diff --git a/packages/b2c-tooling-sdk/src/cli/index.ts b/packages/b2c-tooling-sdk/src/cli/index.ts index 81f91952..8d821d4c 100644 --- a/packages/b2c-tooling-sdk/src/cli/index.ts +++ b/packages/b2c-tooling-sdk/src/cli/index.ts @@ -103,8 +103,15 @@ export {WebDavCommand, WEBDAV_ROOTS, VALID_ROOTS} from './webdav-command.js'; export type {WebDavRootKey} from './webdav-command.js'; // Config utilities -export {loadConfig, findDwJson} from './config.js'; -export type {LoadConfigOptions, PluginSources} from './config.js'; +export { + loadConfig, + findDwJson, + // Flag extraction helpers for composable configuration + extractOAuthFlags, + extractInstanceFlags, + extractMrtFlags, +} from './config.js'; +export type {LoadConfigOptions, PluginSources, ParsedFlags, ExtractedMrtFlags} from './config.js'; // Hook types for plugin extensibility export type { diff --git a/packages/b2c-tooling-sdk/src/cli/instance-command.ts b/packages/b2c-tooling-sdk/src/cli/instance-command.ts index c50b716b..b4cf4425 100644 --- a/packages/b2c-tooling-sdk/src/cli/instance-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/instance-command.ts @@ -5,9 +5,8 @@ */ import {Command, Flags} from '@oclif/core'; import {OAuthCommand} from './oauth-command.js'; -import {loadConfig} from './config.js'; -import type {LoadConfigOptions, PluginSources} from './config.js'; -import type {NormalizedConfig, ResolvedB2CConfig} from '../config/index.js'; +import {loadConfig, extractInstanceFlags} from './config.js'; +import type {ResolvedB2CConfig} from '../config/index.js'; import type {B2CInstance} from '../instance/index.js'; import {t} from '../i18n/index.js'; import { @@ -160,31 +159,11 @@ export abstract class InstanceCommand extends OAuthCom } protected override loadConfiguration(): ResolvedB2CConfig { - const options: LoadConfigOptions = { - instance: this.flags.instance, - configPath: this.flags.config, - }; - - const flagConfig: Partial = { - hostname: this.flags.server, - webdavHostname: this.flags['webdav-server'], - codeVersion: this.flags['code-version'], - username: this.flags.username, - password: this.flags.password, - clientId: this.flags['client-id'], - clientSecret: this.flags['client-secret'], - authMethods: this.parseAuthMethods(), - accountManagerHost: this.flags['account-manager-host'], - // Merge scopes from flags (if provided) - scopes: this.flags.scope && this.flags.scope.length > 0 ? this.flags.scope : undefined, - }; - - const pluginSources: PluginSources = { - before: this.pluginSourcesBefore, - after: this.pluginSourcesAfter, - }; - - return loadConfig(flagConfig, options, pluginSources); + return loadConfig( + extractInstanceFlags(this.flags as Record), + this.getBaseConfigOptions(), + this.getPluginSources(), + ); } /** diff --git a/packages/b2c-tooling-sdk/src/cli/mrt-command.ts b/packages/b2c-tooling-sdk/src/cli/mrt-command.ts index dc5370b4..e9d7ba5d 100644 --- a/packages/b2c-tooling-sdk/src/cli/mrt-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/mrt-command.ts @@ -5,9 +5,9 @@ */ import {Command, Flags} from '@oclif/core'; import {BaseCommand} from './base-command.js'; -import {loadConfig} from './config.js'; -import type {LoadConfigOptions, PluginSources} from './config.js'; -import type {NormalizedConfig, ResolvedB2CConfig} from '../config/index.js'; +import {loadConfig, extractMrtFlags} from './config.js'; +import type {LoadConfigOptions} from './config.js'; +import type {ResolvedB2CConfig} from '../config/index.js'; import type {AuthStrategy} from '../auth/types.js'; import {MrtClient} from '../platform/mrt.js'; import type {MrtProject} from '../platform/mrt.js'; @@ -63,32 +63,13 @@ export abstract class MrtCommand extends BaseCommand); const options: LoadConfigOptions = { - instance: this.flags.instance, - configPath: this.flags.config, - cloudOrigin, // MobifySource uses this to load ~/.mobify--[hostname] if set - credentialsFile, // Override path to MRT credentials file - }; - - const flagConfig: Partial = { - // 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, - }; - - const pluginSources: PluginSources = { - before: this.pluginSourcesBefore, - after: this.pluginSourcesAfter, + ...this.getBaseConfigOptions(), + ...mrt.options, }; - return loadConfig(flagConfig, options, pluginSources); + return loadConfig(mrt.config, options, this.getPluginSources()); } /** diff --git a/packages/b2c-tooling-sdk/src/cli/oauth-command.ts b/packages/b2c-tooling-sdk/src/cli/oauth-command.ts index c4625e7f..9da11df3 100644 --- a/packages/b2c-tooling-sdk/src/cli/oauth-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/oauth-command.ts @@ -5,9 +5,9 @@ */ import {Command, Flags} from '@oclif/core'; import {BaseCommand} from './base-command.js'; -import {loadConfig, ALL_AUTH_METHODS} from './config.js'; -import type {LoadConfigOptions, AuthMethod, PluginSources} from './config.js'; -import type {NormalizedConfig, ResolvedB2CConfig} from '../config/index.js'; +import {loadConfig, extractOAuthFlags, ALL_AUTH_METHODS} from './config.js'; +import type {AuthMethod} from './config.js'; +import type {ResolvedB2CConfig} from '../config/index.js'; import {OAuthStrategy} from '../auth/oauth.js'; import {ImplicitOAuthStrategy} from '../auth/oauth-implicit.js'; import {t} from '../i18n/index.js'; @@ -73,6 +73,7 @@ export abstract class OAuthCommand extends BaseCommand /** * Parses auth methods from flags. * Returns methods in the order specified (priority order). + * @deprecated Use extractOAuthFlags() instead which handles this internally */ protected parseAuthMethods(): AuthMethod[] | undefined { const flagValues = this.flags['auth-methods'] as string[] | undefined; @@ -80,7 +81,6 @@ export abstract class OAuthCommand extends BaseCommand return undefined; } - // Filter to valid auth methods (oclif handles comma splitting via delimiter) const methods = flagValues .map((s) => s.trim()) .filter((s): s is AuthMethod => ALL_AUTH_METHODS.includes(s as AuthMethod)); @@ -89,28 +89,11 @@ export abstract class OAuthCommand extends BaseCommand } protected override loadConfiguration(): ResolvedB2CConfig { - const options: LoadConfigOptions = { - instance: this.flags.instance, - configPath: this.flags.config, - }; - - const flagConfig: Partial = { - clientId: this.flags['client-id'], - clientSecret: this.flags['client-secret'], - shortCode: this.flags['short-code'], - tenantId: this.flags['tenant-id'], - authMethods: this.parseAuthMethods(), - accountManagerHost: this.flags['account-manager-host'], - // Merge scopes from flags (if provided) - scopes: this.flags.scope && this.flags.scope.length > 0 ? this.flags.scope : undefined, - }; - - const pluginSources: PluginSources = { - before: this.pluginSourcesBefore, - after: this.pluginSourcesAfter, - }; - - return loadConfig(flagConfig, options, pluginSources); + return loadConfig( + extractOAuthFlags(this.flags as Record), + this.getBaseConfigOptions(), + this.getPluginSources(), + ); } /** diff --git a/packages/b2c-tooling-sdk/test/cli/config.test.ts b/packages/b2c-tooling-sdk/test/cli/config.test.ts index d71a9ed7..973ef9c7 100644 --- a/packages/b2c-tooling-sdk/test/cli/config.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/config.test.ts @@ -4,7 +4,15 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {expect} from 'chai'; -import {loadConfig, type LoadConfigOptions, type PluginSources} from '@salesforce/b2c-tooling-sdk/cli'; +import { + loadConfig, + extractOAuthFlags, + extractInstanceFlags, + extractMrtFlags, + type LoadConfigOptions, + type PluginSources, + type ParsedFlags, +} from '@salesforce/b2c-tooling-sdk/cli'; import type {ConfigSource, ConfigLoadResult, NormalizedConfig} from '@salesforce/b2c-tooling-sdk/config'; /** @@ -173,4 +181,160 @@ describe('cli/config', () => { expect(config).to.be.an('object'); }); }); + + describe('extractOAuthFlags', () => { + it('extracts OAuth flags from parsed flags', () => { + const flags: ParsedFlags = { + 'client-id': 'my-client-id', + 'client-secret': 'my-client-secret', + 'short-code': 'abc123', + 'tenant-id': 'my-tenant_001', + 'account-manager-host': 'account.demandware.com', + scope: ['sfcc.products', 'sfcc.orders'], + }; + + const result = extractOAuthFlags(flags); + + expect(result.clientId).to.equal('my-client-id'); + expect(result.clientSecret).to.equal('my-client-secret'); + expect(result.shortCode).to.equal('abc123'); + expect(result.tenantId).to.equal('my-tenant_001'); + expect(result.accountManagerHost).to.equal('account.demandware.com'); + expect(result.scopes).to.deep.equal(['sfcc.products', 'sfcc.orders']); + }); + + it('handles empty flags', () => { + const flags: ParsedFlags = {}; + const result = extractOAuthFlags(flags); + + expect(result.clientId).to.be.undefined; + expect(result.clientSecret).to.be.undefined; + expect(result.shortCode).to.be.undefined; + expect(result.tenantId).to.be.undefined; + expect(result.scopes).to.be.undefined; + }); + + it('parses auth methods from flags', () => { + const flags: ParsedFlags = { + 'auth-methods': ['client-credentials', 'basic'], + }; + + const result = extractOAuthFlags(flags); + expect(result.authMethods).to.deep.equal(['client-credentials', 'basic']); + }); + + it('filters invalid auth methods', () => { + const flags: ParsedFlags = { + 'auth-methods': ['client-credentials', 'invalid_method', 'basic'], + }; + + const result = extractOAuthFlags(flags); + expect(result.authMethods).to.deep.equal(['client-credentials', 'basic']); + }); + + it('returns undefined for empty scopes array', () => { + const flags: ParsedFlags = { + scope: [], + }; + + const result = extractOAuthFlags(flags); + expect(result.scopes).to.be.undefined; + }); + }); + + describe('extractInstanceFlags', () => { + it('extracts instance flags from parsed flags', () => { + const flags: ParsedFlags = { + server: 'my-sandbox.demandware.net', + 'webdav-server': 'webdav.demandware.net', + 'code-version': 'version1', + username: 'my-user', + password: 'my-password', + }; + + const result = extractInstanceFlags(flags); + + expect(result.hostname).to.equal('my-sandbox.demandware.net'); + expect(result.webdavHostname).to.equal('webdav.demandware.net'); + expect(result.codeVersion).to.equal('version1'); + expect(result.username).to.equal('my-user'); + expect(result.password).to.equal('my-password'); + }); + + it('includes OAuth flags', () => { + const flags: ParsedFlags = { + server: 'my-sandbox.demandware.net', + 'client-id': 'my-client-id', + 'client-secret': 'my-client-secret', + }; + + const result = extractInstanceFlags(flags); + + expect(result.hostname).to.equal('my-sandbox.demandware.net'); + expect(result.clientId).to.equal('my-client-id'); + expect(result.clientSecret).to.equal('my-client-secret'); + }); + + it('handles empty flags', () => { + const flags: ParsedFlags = {}; + const result = extractInstanceFlags(flags); + + expect(result.hostname).to.be.undefined; + expect(result.username).to.be.undefined; + expect(result.password).to.be.undefined; + }); + }); + + describe('extractMrtFlags', () => { + it('extracts MRT flags from parsed flags', () => { + const flags: ParsedFlags = { + 'api-key': 'my-api-key', + project: 'my-project', + environment: 'staging', + 'cloud-origin': 'https://cloud-staging.mobify.com', + 'credentials-file': '/custom/path/.mobify', + }; + + const result = extractMrtFlags(flags); + + // Config values + expect(result.config.mrtApiKey).to.equal('my-api-key'); + expect(result.config.mrtProject).to.equal('my-project'); + expect(result.config.mrtEnvironment).to.equal('staging'); + expect(result.config.mrtOrigin).to.equal('https://cloud-staging.mobify.com'); + + // Loading options + expect(result.options.cloudOrigin).to.equal('https://cloud-staging.mobify.com'); + expect(result.options.credentialsFile).to.equal('/custom/path/.mobify'); + }); + + it('handles empty flags', () => { + const flags: ParsedFlags = {}; + const result = extractMrtFlags(flags); + + // Config values + expect(result.config.mrtApiKey).to.be.undefined; + expect(result.config.mrtProject).to.be.undefined; + expect(result.config.mrtEnvironment).to.be.undefined; + expect(result.config.mrtOrigin).to.be.undefined; + + // Loading options + expect(result.options.cloudOrigin).to.be.undefined; + expect(result.options.credentialsFile).to.be.undefined; + }); + + it('handles partial flags', () => { + const flags: ParsedFlags = { + project: 'my-project', + }; + + const result = extractMrtFlags(flags); + + expect(result.config.mrtApiKey).to.be.undefined; + expect(result.config.mrtProject).to.equal('my-project'); + expect(result.config.mrtEnvironment).to.be.undefined; + expect(result.options.cloudOrigin).to.be.undefined; + expect(result.options.credentialsFile).to.be.undefined; + }); + }); });