From 7b6b0d0f92a70432cbe898f4caf664d945a26e32 Mon Sep 17 00:00:00 2001 From: Yuming Hsieh Date: Tue, 13 Jan 2026 13:37:01 -0500 Subject: [PATCH 1/4] @W-20591323 workspace type auto-discovevry --- .github/CODEOWNERS | 1 + docs/guide/configuration.md | 5 +- packages/b2c-dx-mcp/README.md | 56 ++++++ packages/b2c-dx-mcp/src/commands/mcp.ts | 37 ++-- packages/b2c-dx-mcp/src/registry.ts | 98 +++++++++- packages/b2c-dx-mcp/src/utils/types.ts | 2 + packages/b2c-dx-mcp/test/commands/mcp.test.ts | 6 + packages/b2c-dx-mcp/test/registry.test.ts | 94 ++++++++- packages/b2c-tooling-sdk/README.md | 2 + packages/b2c-tooling-sdk/package.json | 11 ++ .../b2c-tooling-sdk/src/cli/base-command.ts | 5 + .../b2c-tooling-sdk/src/discovery/detector.ts | 129 +++++++++++++ .../b2c-tooling-sdk/src/discovery/index.ts | 80 ++++++++ .../src/discovery/patterns/base.ts | 32 +++ .../src/discovery/patterns/custom-api.ts | 48 +++++ .../src/discovery/patterns/index.ts | 40 ++++ .../src/discovery/patterns/pwa-kit.ts | 30 +++ .../src/discovery/patterns/sfra.ts | 33 ++++ .../src/discovery/patterns/storefront-next.ts | 31 +++ .../b2c-tooling-sdk/src/discovery/types.ts | 71 +++++++ .../b2c-tooling-sdk/src/discovery/utils.ts | 92 +++++++++ .../test/discovery/detector.test.ts | 182 ++++++++++++++++++ .../test/discovery/patterns/base.test.ts | 43 +++++ .../discovery/patterns/custom-api.test.ts | 65 +++++++ .../test/discovery/patterns/pwa-kit.test.ts | 71 +++++++ .../test/discovery/patterns/sfra.test.ts | 76 ++++++++ .../patterns/storefront-next.test.ts | 80 ++++++++ .../test/discovery/utils.test.ts | 158 +++++++++++++++ 28 files changed, 1553 insertions(+), 25 deletions(-) create mode 100644 packages/b2c-tooling-sdk/src/discovery/detector.ts create mode 100644 packages/b2c-tooling-sdk/src/discovery/index.ts create mode 100644 packages/b2c-tooling-sdk/src/discovery/patterns/base.ts create mode 100644 packages/b2c-tooling-sdk/src/discovery/patterns/custom-api.ts create mode 100644 packages/b2c-tooling-sdk/src/discovery/patterns/index.ts create mode 100644 packages/b2c-tooling-sdk/src/discovery/patterns/pwa-kit.ts create mode 100644 packages/b2c-tooling-sdk/src/discovery/patterns/sfra.ts create mode 100644 packages/b2c-tooling-sdk/src/discovery/patterns/storefront-next.ts create mode 100644 packages/b2c-tooling-sdk/src/discovery/types.ts create mode 100644 packages/b2c-tooling-sdk/src/discovery/utils.ts create mode 100644 packages/b2c-tooling-sdk/test/discovery/detector.test.ts create mode 100644 packages/b2c-tooling-sdk/test/discovery/patterns/base.test.ts create mode 100644 packages/b2c-tooling-sdk/test/discovery/patterns/custom-api.test.ts create mode 100644 packages/b2c-tooling-sdk/test/discovery/patterns/pwa-kit.test.ts create mode 100644 packages/b2c-tooling-sdk/test/discovery/patterns/sfra.test.ts create mode 100644 packages/b2c-tooling-sdk/test/discovery/patterns/storefront-next.test.ts create mode 100644 packages/b2c-tooling-sdk/test/discovery/utils.test.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e2cdfcee..07259f52 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,3 @@ #GUSINFO:CC Cosmos,B2C CLI and Developer Tools * @clavery +/packages/b2c-dx-mcp/ @yhsieh1 diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index b4eda82b..aacb950d 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -49,10 +49,13 @@ See [Configure WebDAV File Access](https://help.salesforce.com/s/articleView?id= ## Environment Variables -You can configure authentication using environment variables: +You can configure the CLI using environment variables: | Variable | Description | |----------|-------------| +| `SFCC_WORKING_DIRECTORY` | Project working directory | +| `SFCC_CONFIG` | Path to config file (dw.json format) | +| `SFCC_INSTANCE` | Instance name from config file | | `SFCC_SERVER` | The B2C instance hostname | | `SFCC_CLIENT_ID` | OAuth client ID | | `SFCC_CLIENT_SECRET` | OAuth client secret | diff --git a/packages/b2c-dx-mcp/README.md b/packages/b2c-dx-mcp/README.md index 877a001c..9f89cab1 100644 --- a/packages/b2c-dx-mcp/README.md +++ b/packages/b2c-dx-mcp/README.md @@ -52,6 +52,62 @@ Since the package is not yet published to npm, see the [Development](#developmen | `--debug` | Enable debug logging | | `--json` | Output logs as JSON lines | | `--lang` | Language for messages | +| `--working-directory` | Project working directory (env: `SFCC_WORKING_DIRECTORY`) | + +### Workspace Auto-Discovery + +When neither `--toolsets` nor `--tools` are provided, the MCP server automatically detects your project type and enables the appropriate toolsets. + +**How it works:** + +1. The server analyzes your working directory (from `--working-directory` flag, `SFCC_WORKING_DIRECTORY` env var, or current directory) +2. It checks for project markers like `package.json` dependencies, folder structures, and config files +3. It enables all toolsets that match any detected project type + +**Project Types and Toolsets:** + +| Project Type | Detection | Toolsets Enabled | +|--------------|-----------|------------------| +| **PWA Kit v3** | `@salesforce/pwa-kit-*` packages in package.json | PWAV3, MRT, SCAPI | +| **Storefront Next** | `@salesforce/storefront-next-*` packages in package.json | STOREFRONTNEXT, MRT, SCAPI | +| **SFRA** | `cartridges/` folder with controllers or templates | CARTRIDGES, SCAPI | +| **Custom API** | `rest-apis/*/api.json` or `rest-apis/*/schema.yaml` files | CARTRIDGES, SCAPI | +| **Headless** | `dw.json` file (no specific framework detected) | SCAPI | +| **Unknown** | No B2C project markers found | SCAPI (fallback) | + +**Hybrid Projects:** + +If multiple project types are detected (e.g., SFRA + Custom API), toolsets from all matched types are combined. + +**Example:** + +**Cursor** (supports `${workspaceFolder}`): + +```json +{ + "mcpServers": { + "b2c-dx": { + "command": "/path/to/packages/b2c-dx-mcp/bin/dev.js", + "args": ["--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": ["--working-directory", "/path/to/your/project", "--allow-non-ga-tools"] + } + } +} +``` + +> **Note:** Cursor supports `${workspaceFolder}` variable expansion, but Claude Desktop does not. For Claude Desktop, use an explicit path or set the `SFCC_WORKING_DIRECTORY` environment variable. ### Configuration Examples diff --git a/packages/b2c-dx-mcp/src/commands/mcp.ts b/packages/b2c-dx-mcp/src/commands/mcp.ts index de846b4f..36ef1826 100644 --- a/packages/b2c-dx-mcp/src/commands/mcp.ts +++ b/packages/b2c-dx-mcp/src/commands/mcp.ts @@ -14,11 +14,11 @@ * ## Flags * * ### MCP-Specific Flags - * | Flag | Description | - * |------|-------------| - * | `--toolsets` | Comma-separated toolsets to enable (case-insensitive) | - * | `--tools` | Comma-separated individual tools to enable (case-insensitive) | - * | `--allow-non-ga-tools` | Enable experimental/non-GA tools | + * | Flag | Env Variable | Description | + * |------|--------------|-------------| + * | `--toolsets` | `SFCC_TOOLSETS` | Comma-separated toolsets to enable (case-insensitive) | + * | `--tools` | `SFCC_TOOLS` | Comma-separated individual tools to enable (case-insensitive) | + * | `--allow-non-ga-tools` | `SFCC_ALLOW_NON_GA_TOOLS` | Enable experimental/non-GA tools | * * ### MRT Flags (from MrtCommand.baseFlags) * | Flag | Env Variable | Description | @@ -39,14 +39,23 @@ * | `--client-secret` | `SFCC_CLIENT_SECRET` | OAuth client secret | * * ### Global Flags (inherited from BaseCommand) - * | Flag | Description | - * |------|-------------| - * | `--config` | Path to dw.json config file (auto-discovered if not provided) | - * | `--instance` | Instance name from configuration file | - * | `--log-level` | Set logging verbosity (trace, debug, info, warn, error, silent) | - * | `--debug` | Enable debug logging | - * | `--json` | Output logs as JSON lines | - * | `--lang` | Language for messages | + * | Flag | Env Variable | Description | + * |------|--------------|-------------| + * | `--working-directory` | `SFCC_WORKING_DIRECTORY` | Project working directory (see note below) | + * | `--config` | `SFCC_CONFIG` | Path to dw.json config file (auto-discovered if not provided) | + * | `--instance` | `SFCC_INSTANCE` | Instance name from configuration file | + * | `--log-level` | `SFCC_LOG_LEVEL` | Set logging verbosity (trace, debug, info, warn, error, silent) | + * | `--debug` | `SFCC_DEBUG` | Enable debug logging | + * | `--json` | - | Output logs as JSON lines | + * | `--lang` | - | Language for messages | + * + * **Note on `--working-directory`**: Many MCP clients (Cursor, Claude Desktop) spawn servers from the + * user's home directory (`~`) rather than the project directory. This flag is used for: + * - Auto-discovery (detecting project type when no `--toolsets` or `--tools` are provided) + * - Scaffolding tools (creating files in the correct project location) + * - Any tool that needs to operate on the project directory + * + * Use `--working-directory` or `SFCC_WORKING_DIRECTORY` env var to specify the actual project path. * * ## Configuration * @@ -227,6 +236,8 @@ export default class McpServerCommand extends BaseCommand s.trim()) : undefined, allowNonGaTools: this.flags['allow-non-ga-tools'], configPath: this.flags.config, + // Working directory for auto-discovery. oclif handles flag with env fallback. + workingDirectory: this.flags['working-directory'], }; // TODO: Telemetry - Initialize telemetry unless disabled diff --git a/packages/b2c-dx-mcp/src/registry.ts b/packages/b2c-dx-mcp/src/registry.ts index cd066815..c1665ffc 100644 --- a/packages/b2c-dx-mcp/src/registry.ts +++ b/packages/b2c-dx-mcp/src/registry.ts @@ -5,6 +5,7 @@ */ import {getLogger} from '@salesforce/b2c-tooling-sdk/logging'; +import {detectWorkspaceType, type ProjectType} from '@salesforce/b2c-tooling-sdk/discovery'; import type {McpTool, Toolset, StartupFlags} from './utils/index.js'; import {ALL_TOOLSETS, TOOLSETS, VALID_TOOLSET_NAMES} from './utils/index.js'; import type {B2CDxMcpServer} from './server.js'; @@ -15,6 +16,59 @@ import {createPwav3Tools} from './tools/pwav3/index.js'; import {createScapiTools} from './tools/scapi/index.js'; import {createStorefrontNextTools} from './tools/storefrontnext/index.js'; +/** + * Maps a single project type to its associated MCP toolsets. + */ +function getToolsetsForProjectType(projectType: ProjectType): Toolset[] { + switch (projectType) { + case 'custom-api': { + return ['CARTRIDGES', 'SCAPI']; + } + case 'headless': { + return ['SCAPI']; + } + case 'pwa-kit-v3': { + return ['PWAV3', 'MRT', 'SCAPI']; + } + case 'sfra': { + return ['CARTRIDGES', 'SCAPI']; + } + case 'storefront-next': { + return ['STOREFRONTNEXT', 'MRT', 'SCAPI']; + } + default: { + // Fallback: provide basic SCAPI tools for unknown projects + return ['SCAPI']; + } + } +} + +/** + * Maps multiple detected project types to a union of MCP toolsets. + * + * Combines toolsets from all matched project types, enabling hybrid + * project support (e.g., SFRA + Custom API gets both CARTRIDGES and SCAPI). + * + * @param projectTypes - Array of detected project types + * @returns Union of all toolsets for the detected project types + */ +function getToolsetsForProjectTypes(projectTypes: ProjectType[]): Toolset[] { + // Fallback to SCAPI when no project types detected + if (projectTypes.length === 0) { + return ['SCAPI']; + } + + const toolsetSet = new Set(); + + for (const projectType of projectTypes) { + for (const toolset of getToolsetsForProjectType(projectType)) { + toolsetSet.add(toolset); + } + } + + return [...toolsetSet]; +} + /** * Registry of tools organized by toolset. * Tools can belong to multiple toolsets via their `toolsets` array. @@ -61,8 +115,9 @@ export function createToolRegistry(services: Services): ToolRegistry { * Register tools with the MCP server based on startup flags. * * Tool selection logic: - * 1. Start with all tools from --toolsets - * 2. Add individual tools from --tools (can be from any toolset) + * 1. If neither --toolsets nor --tools are provided, 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) * * Example: * --toolsets STOREFRONTNEXT,MRT --tools cartridge_deploy @@ -73,12 +128,43 @@ export function createToolRegistry(services: Services): ToolRegistry { * @param services - Services instance */ export async function registerToolsets(flags: StartupFlags, server: B2CDxMcpServer, services: Services): Promise { - const toolsets = flags.toolsets ?? []; + let 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'}`, + ); - // NOTE: When no --toolsets or --tools flags are provided, auto-discovery - // will detect project type and enable appropriate toolsets automatically. + toolsets = mappedToolsets; + } // Create the tool registry (all available tools) const toolRegistry = createToolRegistry(services); @@ -88,8 +174,6 @@ export async function registerToolsets(flags: StartupFlags, server: B2CDxMcpServ const allToolsByName = new Map(allTools.map((tool) => [tool.name, tool])); const existingToolNames = new Set(allToolsByName.keys()); - const logger = getLogger(); - // Warn about invalid --tools names (but continue with valid ones) const invalidTools = individualTools.filter((name) => !existingToolNames.has(name)); if (invalidTools.length > 0) { diff --git a/packages/b2c-dx-mcp/src/utils/types.ts b/packages/b2c-dx-mcp/src/utils/types.ts index 0d593b79..254be6ac 100644 --- a/packages/b2c-dx-mcp/src/utils/types.ts +++ b/packages/b2c-dx-mcp/src/utils/types.ts @@ -50,4 +50,6 @@ export interface StartupFlags { allowNonGaTools?: boolean; /** Path to config file (dw.json format) */ configPath?: string; + /** Project working directory for tools (auto-discovery, scaffolding, etc.) */ + workingDirectory?: string; } diff --git a/packages/b2c-dx-mcp/test/commands/mcp.test.ts b/packages/b2c-dx-mcp/test/commands/mcp.test.ts index 03944fcd..a273e730 100644 --- a/packages/b2c-dx-mcp/test/commands/mcp.test.ts +++ b/packages/b2c-dx-mcp/test/commands/mcp.test.ts @@ -64,6 +64,12 @@ describe('McpServerCommand', () => { expect(flag).to.not.be.undefined; expect(flag.env).to.equal('SFCC_MRT_API_KEY'); }); + + it('should define working-directory flag with env var support', () => { + const flag = McpServerCommand.flags['working-directory']; + expect(flag).to.not.be.undefined; + expect(flag.env).to.equal('SFCC_WORKING_DIRECTORY'); + }); }); describe('flag parse functions', () => { diff --git a/packages/b2c-dx-mcp/test/registry.test.ts b/packages/b2c-dx-mcp/test/registry.test.ts index 639f1e31..a0d63eb0 100644 --- a/packages/b2c-dx-mcp/test/registry.test.ts +++ b/packages/b2c-dx-mcp/test/registry.test.ts @@ -131,14 +131,35 @@ describe('registry', () => { }); describe('registerToolsets', () => { - it('should register no tools when no toolsets or tools provided', async () => { + it('should auto-discover and register tools when no toolsets or tools provided', async () => { const services = createMockServices(); const server = createMockServer(); - const flags: StartupFlags = {}; + // Use a workspace path that won't match any patterns (should fall back to SCAPI) + const flags: StartupFlags = { + workingDirectory: '/nonexistent/path', + allowNonGaTools: true, + }; - // When no flags provided, no tools are registered (auto-discovery planned) + // When no flags provided, auto-discovery kicks in + // With an unknown project, it falls back to SCAPI toolset await registerToolsets(flags, server, services); - expect(server.registeredTools).to.have.lengthOf(0); + expect(server.registeredTools.length).to.be.greaterThan(0); + // SCAPI tools should be registered as fallback + expect(server.registeredTools).to.include('scapi_discovery'); + }); + + it('should skip auto-discovery when empty toolsets array is explicitly provided', async () => { + const services = createMockServices(); + const server = createMockServer(); + const flags: StartupFlags = { + toolsets: [], + allowNonGaTools: true, + }; + + // Empty toolsets array still triggers auto-discovery (length is 0) + await registerToolsets(flags, server, services); + // Should have auto-discovered SCAPI as fallback + expect(server.registeredTools).to.include('scapi_discovery'); }); it('should register tools from a single toolset', async () => { @@ -318,5 +339,70 @@ describe('registry', () => { // This test documents expected behavior for when GA tools exist // When GA tools are added, this test should be updated to verify they are registered }); + + describe('auto-discovery', () => { + it('should use workingDirectory from flags for detection', async () => { + const services = createMockServices(); + const server = createMockServer(); + const flags: StartupFlags = { + workingDirectory: '/some/workspace', + allowNonGaTools: true, + }; + + // Should not throw even with non-existent path + await registerToolsets(flags, server, services); + // Falls back to SCAPI for unknown projects + expect(server.registeredTools).to.include('scapi_discovery'); + }); + + it('should map detected project type to MCP toolsets', async () => { + const services = createMockServices(); + const server = createMockServer(); + // Use a path that doesn't exist - detection will return 'unknown' project type + // which maps to SCAPI toolset + const flags: StartupFlags = { + workingDirectory: '/nonexistent', + allowNonGaTools: true, + }; + + await registerToolsets(flags, server, services); + + // Only SCAPI tools should be registered (the fallback for unknown projects) + expect(server.registeredTools).to.include('scapi_discovery'); + }); + + it('should not auto-discover when individual tools are provided', async () => { + const services = createMockServices(); + const server = createMockServer(); + const flags: StartupFlags = { + tools: ['cartridge_deploy'], + workingDirectory: '/some/workspace', + allowNonGaTools: true, + }; + + await registerToolsets(flags, server, services); + + // Should only have the explicitly requested tool + expect(server.registeredTools).to.have.lengthOf(1); + expect(server.registeredTools).to.include('cartridge_deploy'); + }); + + it('should not auto-discover when toolsets are explicitly provided', async () => { + const services = createMockServices(); + const server = createMockServer(); + const flags: StartupFlags = { + toolsets: ['CARTRIDGES'], + workingDirectory: '/some/workspace', + allowNonGaTools: true, + }; + + await registerToolsets(flags, server, services); + + // Should only have CARTRIDGES tools, not auto-discovered toolsets + expect(server.registeredTools).to.include('cartridge_deploy'); + // Should not have tools from other toolsets unless explicitly requested + expect(server.registeredTools).to.not.include('pwakit_create_storefront'); + }); + }); }); }); diff --git a/packages/b2c-tooling-sdk/README.md b/packages/b2c-tooling-sdk/README.md index a3ba6706..53719e41 100644 --- a/packages/b2c-tooling-sdk/README.md +++ b/packages/b2c-tooling-sdk/README.md @@ -139,6 +139,8 @@ The SDK provides subpath exports for tree-shaking and organization: | `@salesforce/b2c-tooling-sdk/operations/code` | Code deployment operations | | `@salesforce/b2c-tooling-sdk/operations/jobs` | Job execution and site import/export | | `@salesforce/b2c-tooling-sdk/operations/sites` | Site management | +| `@salesforce/b2c-tooling-sdk/discovery` | Workspace type detection (PWA Kit, SFRA, etc.) | +| `@salesforce/b2c-tooling-sdk/cli` | CLI utilities (BaseCommand, table rendering) | | `@salesforce/b2c-tooling-sdk/logging` | Structured logging utilities | ## Logging diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 0b6145e6..070dc5b8 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -167,6 +167,17 @@ "types": "./dist/cjs/config/index.d.ts", "default": "./dist/cjs/config/index.js" } + }, + "./discovery": { + "development": "./src/discovery/index.ts", + "import": { + "types": "./dist/esm/discovery/index.d.ts", + "default": "./dist/esm/discovery/index.js" + }, + "require": { + "types": "./dist/cjs/discovery/index.d.ts", + "default": "./dist/cjs/discovery/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 22156588..bb9e62c4 100644 --- a/packages/b2c-tooling-sdk/src/cli/base-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/base-command.ts @@ -68,6 +68,11 @@ export abstract class BaseCommand extends Command { env: 'SFCC_INSTANCE', helpGroup: 'GLOBAL', }), + 'working-directory': Flags.string({ + description: 'Project working directory', + env: 'SFCC_WORKING_DIRECTORY', + helpGroup: 'GLOBAL', + }), 'extra-query': Flags.string({ description: 'Extra query parameters as JSON (e.g., \'{"debug":"true"}\')', helpGroup: 'GLOBAL', diff --git a/packages/b2c-tooling-sdk/src/discovery/detector.ts b/packages/b2c-tooling-sdk/src/discovery/detector.ts new file mode 100644 index 00000000..994049dc --- /dev/null +++ b/packages/b2c-tooling-sdk/src/discovery/detector.ts @@ -0,0 +1,129 @@ +/* + * 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 + */ +/** + * Workspace detection implementation. + * + * @module discovery/detector + */ +import type {DetectionResult, DetectionPattern, DetectOptions, ProjectType, ConfigFileInfo} from './types.js'; +import {DEFAULT_PATTERNS} from './patterns/index.js'; + +/** + * Detects the type of B2C Commerce project in a workspace. + * + * WorkspaceTypeDetector analyzes a directory to determine what kind of + * Commerce project it contains. Returns ALL matched project types to enable + * union toolset selection for hybrid projects. + * + * @example + * ```typescript + * import { WorkspaceTypeDetector } from '@salesforce/b2c-tooling-sdk/discovery'; + * + * const detector = new WorkspaceTypeDetector('/path/to/project'); + * const result = await detector.detect(); + * + * console.log(`Project types: ${result.projectTypes.join(', ')}`); + * console.log(`Matched patterns: ${result.matchedPatterns.join(', ')}`); + * ``` + * + * @example Custom patterns + * ```typescript + * const detector = new WorkspaceTypeDetector('/path/to/project', { + * additionalPatterns: [myCustomPattern], + * excludePatterns: ['sfra-cartridge'], + * }); + * ``` + */ +export class WorkspaceTypeDetector { + private workspacePath: string; + private patterns: DetectionPattern[]; + + /** + * Creates a new WorkspaceTypeDetector. + * + * @param workspacePath - Path to the workspace directory to analyze + * @param options - Detection options for customizing behavior + */ + constructor(workspacePath: string, options: DetectOptions = {}) { + this.workspacePath = workspacePath; + this.patterns = this.resolvePatterns(options); + } + + /** + * Performs workspace detection. + * + * Runs all configured patterns against the workspace and returns + * a consolidated result with all detected project types. + * + * @returns Detection result with all project types and matched patterns + */ + async detect(): Promise { + const matchedPatterns: string[] = []; + const projectTypes: ProjectType[] = []; + const configFiles: ConfigFileInfo[] = []; + + for (const pattern of this.patterns) { + try { + if (await pattern.detect(this.workspacePath)) { + matchedPatterns.push(pattern.name); + // Add project type if not already present + if (!projectTypes.includes(pattern.projectType)) { + projectTypes.push(pattern.projectType); + } + } + } catch { + // Skip patterns that fail to detect - could log in debug mode + } + } + + return { + projectTypes, + matchedPatterns, + autoDiscovered: true, + configFiles, + }; + } + + /** + * Resolves patterns based on options. + */ + private resolvePatterns(options: DetectOptions): DetectionPattern[] { + let patterns = options.patterns ?? DEFAULT_PATTERNS; + + if (options.additionalPatterns) { + patterns = [...patterns, ...options.additionalPatterns]; + } + + if (options.excludePatterns) { + patterns = patterns.filter((p) => !options.excludePatterns!.includes(p.name)); + } + + return patterns; + } +} + +/** + * Creates a WorkspaceTypeDetector and performs detection in one call. + * + * This is a convenience function for simple detection use cases. + * + * @param workspacePath - Path to the workspace directory + * @param options - Detection options + * @returns Detection result with all matched project types + * + * @example + * ```typescript + * import { detectWorkspaceType } from '@salesforce/b2c-tooling-sdk/discovery'; + * + * const result = await detectWorkspaceType(process.cwd()); + * console.log(`Detected types: ${result.projectTypes.join(', ')}`); + * console.log(`Patterns: ${result.matchedPatterns.join(', ')}`); + * ``` + */ +export async function detectWorkspaceType(workspacePath: string, options?: DetectOptions): Promise { + const detector = new WorkspaceTypeDetector(workspacePath, options); + return detector.detect(); +} diff --git a/packages/b2c-tooling-sdk/src/discovery/index.ts b/packages/b2c-tooling-sdk/src/discovery/index.ts new file mode 100644 index 00000000..63045f68 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/discovery/index.ts @@ -0,0 +1,80 @@ +/* + * 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 + */ +/** + * Workspace discovery for B2C Commerce projects. + * + * This module provides utilities for detecting the type of B2C Commerce + * project in a workspace. Returns ALL matched project types to enable + * union toolset selection for hybrid projects. + * + * ## Quick Start + * + * ```typescript + * import { detectWorkspaceType } from '@salesforce/b2c-tooling-sdk/discovery'; + * + * const result = await detectWorkspaceType(process.cwd()); + * + * console.log(`Project types: ${result.projectTypes.join(', ')}`); + * console.log(`Matched patterns: ${result.matchedPatterns.join(', ')}`); + * ``` + * + * ## Project Types + * + * The detector recognizes the following project types: + * + * - `pwa-kit-v3` - PWA Kit v3 storefront (has @salesforce/pwa-kit-* dependencies) + * - `storefront-next` - Storefront Next/Odyssey project + * - `sfra` - SFRA/cartridge-based storefront (has cartridges/ directory) + * - `custom-api` - Custom SCAPI project (has api.json files) + * - `headless` - Generic headless project (has dw.json but no specific framework) + * - `unknown` - Could not determine project type + * + * ## Custom Patterns + * + * You can extend detection with custom patterns: + * + * ```typescript + * import { WorkspaceTypeDetector, type DetectionPattern } from '@salesforce/b2c-tooling-sdk/discovery'; + * + * const myPattern: DetectionPattern = { + * name: 'my-framework', + * projectType: 'custom-api', + * priority: 100, + * detect: async (path) => { + * // Custom detection logic + * return false; + * }, + * }; + * + * const detector = new WorkspaceTypeDetector('/path/to/project', { + * additionalPatterns: [myPattern], + * }); + * + * const result = await detector.detect(); + * ``` + * + * @module discovery + */ + +// Main API +export {WorkspaceTypeDetector, detectWorkspaceType} from './detector.js'; + +// Types +export type {ProjectType, DetectionPattern, DetectionResult, DetectOptions, ConfigFileInfo} from './types.js'; + +// Patterns (for customization) +export { + DEFAULT_PATTERNS, + pwaKitV3Pattern, + storefrontNextPattern, + sfraPattern, + customApiPattern, + dwJsonPattern, +} from './patterns/index.js'; + +// Utilities (for building custom patterns) +export {readPackageJson, exists, glob, globDirs} from './utils.js'; +export type {PackageJson} from './utils.js'; diff --git a/packages/b2c-tooling-sdk/src/discovery/patterns/base.ts b/packages/b2c-tooling-sdk/src/discovery/patterns/base.ts new file mode 100644 index 00000000..ff825739 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/discovery/patterns/base.ts @@ -0,0 +1,32 @@ +/* + * 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 + */ +/** + * Base detection patterns for common B2C configuration files. + * + * @module discovery/patterns/base + */ +import * as path from 'node:path'; +import type {DetectionPattern} from '../types.js'; +import {exists} from '../utils.js'; + +/** + * Detection pattern for projects with dw.json configuration. + * + * This is a low-priority fallback pattern that detects any project + * with a dw.json file. It indicates the project connects to a B2C + * Commerce instance but doesn't match any specific framework. + * + * The "headless" project type is assigned to indicate a generic + * headless commerce setup (custom frontend, integration scripts, + * mobile app backends, etc.). + */ +export const dwJsonPattern: DetectionPattern = { + name: 'dw-json', + projectType: 'headless', + detect: async (workspacePath) => { + return await exists(path.join(workspacePath, 'dw.json')); + }, +}; diff --git a/packages/b2c-tooling-sdk/src/discovery/patterns/custom-api.ts b/packages/b2c-tooling-sdk/src/discovery/patterns/custom-api.ts new file mode 100644 index 00000000..32899b7c --- /dev/null +++ b/packages/b2c-tooling-sdk/src/discovery/patterns/custom-api.ts @@ -0,0 +1,48 @@ +/* + * 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 + */ +/** + * Custom SCAPI project detection pattern. + * + * Custom APIs in Salesforce B2C Commerce MUST be organized within custom code + * cartridges in a `rest-apis` directory structure. This is a required structure - + * deviating from it will prevent APIs from being discovered/registered by SFCC. + * + * Required structure: + * ``` + * cartridge/ + * └── rest-apis/ ← Required root folder + * └── {apiName}/ ← API subdirectory (alphanumeric lowercase + hyphens) + * ├── api.json ← Required: API mapping + * ├── schema.yaml ← Required: OAS 3.0 contract + * └── {script}.js ← Required: Implementation + * ``` + * + * @see https://developer.salesforce.com/docs/commerce/commerce-api/guide/custom-apis.html + * @module discovery/patterns/custom-api + */ +import type {DetectionPattern} from '../types.js'; +import {glob} from '../utils.js'; + +/** + * Detection pattern for Custom SCAPI projects. + * + * Detects projects with the required SFCC Custom API `rest-apis` directory structure. + * Only looks for files within `rest-apis/` directories since this structure is required. + */ +export const customApiPattern: DetectionPattern = { + name: 'custom-api', + projectType: 'custom-api', + detect: async (workspacePath) => { + // Look for api.json within required rest-apis directory structure + // The rest-apis/{apiName}/ structure is REQUIRED by SFCC for Custom APIs + const hasRestApisApiJson = await glob('**/rest-apis/*/api.json', {cwd: workspacePath}); + if (hasRestApisApiJson.length > 0) return true; + + // Also check for schema.yaml (OpenAPI 3.0 contract) in rest-apis structure + const hasRestApisSchema = await glob('**/rest-apis/*/schema.yaml', {cwd: workspacePath}); + return hasRestApisSchema.length > 0; + }, +}; diff --git a/packages/b2c-tooling-sdk/src/discovery/patterns/index.ts b/packages/b2c-tooling-sdk/src/discovery/patterns/index.ts new file mode 100644 index 00000000..5c0e41e9 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/discovery/patterns/index.ts @@ -0,0 +1,40 @@ +/* + * 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 + */ +/** + * Detection patterns for workspace discovery. + * + * @module discovery/patterns + */ +import type {DetectionPattern} from '../types.js'; +import {pwaKitV3Pattern} from './pwa-kit.js'; +import {storefrontNextPattern} from './storefront-next.js'; +import {sfraPattern} from './sfra.js'; +import {customApiPattern} from './custom-api.js'; +import {dwJsonPattern} from './base.js'; + +/** + * Default detection patterns in priority order. + * + * Patterns are sorted by priority when detection runs, but this + * array provides a logical grouping: + * 1. Framework-specific patterns (PWA Kit v3, Storefront Next, SFRA) + * 2. Project-type patterns (Custom API) + * 3. Fallback patterns (dw.json) + */ +export const DEFAULT_PATTERNS: DetectionPattern[] = [ + pwaKitV3Pattern, + storefrontNextPattern, + sfraPattern, + customApiPattern, + dwJsonPattern, +]; + +// Individual pattern exports for customization +export {pwaKitV3Pattern} from './pwa-kit.js'; +export {storefrontNextPattern} from './storefront-next.js'; +export {sfraPattern} from './sfra.js'; +export {customApiPattern} from './custom-api.js'; +export {dwJsonPattern} from './base.js'; diff --git a/packages/b2c-tooling-sdk/src/discovery/patterns/pwa-kit.ts b/packages/b2c-tooling-sdk/src/discovery/patterns/pwa-kit.ts new file mode 100644 index 00000000..18ca8832 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/discovery/patterns/pwa-kit.ts @@ -0,0 +1,30 @@ +/* + * 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 + */ +/** + * PWA Kit v3 project detection pattern. + * + * @module discovery/patterns/pwa-kit-v3 + */ +import type {DetectionPattern} from '../types.js'; +import {readPackageJson} from '../utils.js'; + +/** + * Detection pattern for PWA Kit v3 storefronts. + * + * Detects projects that have PWA Kit v3 SDK dependencies in package.json. + * Matches packages starting with @salesforce/pwa-kit (v3+). + */ +export const pwaKitV3Pattern: DetectionPattern = { + name: 'pwa-kit-v3', + projectType: 'pwa-kit-v3', + detect: async (workspacePath) => { + const pkg = await readPackageJson(workspacePath); + if (!pkg) return false; + + const deps = Object.keys({...pkg.dependencies, ...pkg.devDependencies}); + return deps.some((dep) => dep.startsWith('@salesforce/pwa-kit')); + }, +}; diff --git a/packages/b2c-tooling-sdk/src/discovery/patterns/sfra.ts b/packages/b2c-tooling-sdk/src/discovery/patterns/sfra.ts new file mode 100644 index 00000000..c53eea9d --- /dev/null +++ b/packages/b2c-tooling-sdk/src/discovery/patterns/sfra.ts @@ -0,0 +1,33 @@ +/* + * 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 + */ +/** + * SFRA/cartridge project detection pattern. + * + * @module discovery/patterns/sfra + */ +import type {DetectionPattern} from '../types.js'; +import {glob} from '../utils.js'; + +/** + * Detection pattern for SFRA/cartridge-based storefronts. + * + * Detects projects that have the standard cartridge folder structure + * with controllers and/or templates. Searches recursively so cartridges + * can be at the workspace root or in subdirectories (e.g., monorepos). + */ +export const sfraPattern: DetectionPattern = { + name: 'sfra-cartridge', + projectType: 'sfra', + detect: async (workspacePath) => { + // Look for SFRA-style structure (controllers or templates) + // Searches recursively - cartridges can be at root or nested in subdirectories + const hasControllers = await glob('**/cartridge/controllers/**/*.js', {cwd: workspacePath}); + if (hasControllers.length > 0) return true; + + const hasTemplates = await glob('**/cartridge/templates/**/*.isml', {cwd: workspacePath}); + return hasTemplates.length > 0; + }, +}; diff --git a/packages/b2c-tooling-sdk/src/discovery/patterns/storefront-next.ts b/packages/b2c-tooling-sdk/src/discovery/patterns/storefront-next.ts new file mode 100644 index 00000000..1eb12cb7 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/discovery/patterns/storefront-next.ts @@ -0,0 +1,31 @@ +/* + * 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 + */ +/** + * Storefront Next project detection pattern. + * + * @module discovery/patterns/storefront-next + */ +import type {DetectionPattern} from '../types.js'; +import {readPackageJson} from '../utils.js'; + +/** + * Detection pattern for Storefront Next (Odyssey) projects. + * + * Detects projects that have Storefront Next SDK dependencies. + * Matches packages starting with @salesforce/storefront-next + * (e.g., @salesforce/storefront-next-dev, @salesforce/storefront-next-runtime). + */ +export const storefrontNextPattern: DetectionPattern = { + name: 'storefront-next', + projectType: 'storefront-next', + detect: async (workspacePath) => { + const pkg = await readPackageJson(workspacePath); + if (!pkg) return false; + + const deps = Object.keys({...pkg.dependencies, ...pkg.devDependencies}); + return deps.some((dep) => dep.startsWith('@salesforce/storefront-next')); + }, +}; diff --git a/packages/b2c-tooling-sdk/src/discovery/types.ts b/packages/b2c-tooling-sdk/src/discovery/types.ts new file mode 100644 index 00000000..82610772 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/discovery/types.ts @@ -0,0 +1,71 @@ +/* + * 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 + */ +/** + * Types for workspace discovery. + * + * @module discovery/types + */ + +/** + * Identifies the type of B2C Commerce project. + */ +export type ProjectType = + | 'pwa-kit-v3' // PWA Kit v3 storefront (@salesforce/pwa-kit-* packages) + | 'storefront-next' // Storefront Next (Odyssey) + | 'sfra' // SFRA/cartridge-based storefront + | 'custom-api' // Custom SCAPI project + | 'headless' // Generic headless (uses SCAPI/dw.json but no specific framework) + | 'unknown'; // Could not determine + +/** + * Detection pattern definition. + */ +export interface DetectionPattern { + /** Unique pattern identifier */ + name: string; + /** Project type this pattern detects */ + projectType: ProjectType; + /** Detection function */ + detect: (workspacePath: string) => Promise; +} + +/** + * Information about detected config files. + */ +export interface ConfigFileInfo { + /** Type of configuration file */ + type: 'dw.json' | 'package.json' | 'mobify.json' | 'api.json'; + /** Path to the file */ + path: string; + /** Relevant extracted info */ + metadata?: Record; +} + +/** + * Result of workspace detection. + */ +export interface DetectionResult { + /** All detected project types (from matched patterns) */ + projectTypes: ProjectType[]; + /** All patterns that matched */ + matchedPatterns: string[]; + /** Whether auto-discovery was performed */ + autoDiscovered: boolean; + /** Detected configuration files */ + configFiles: ConfigFileInfo[]; +} + +/** + * Options for workspace detection. + */ +export interface DetectOptions { + /** Custom patterns to use (replaces defaults) */ + patterns?: DetectionPattern[]; + /** Additional patterns to include */ + additionalPatterns?: DetectionPattern[]; + /** Patterns to exclude by name */ + excludePatterns?: string[]; +} diff --git a/packages/b2c-tooling-sdk/src/discovery/utils.ts b/packages/b2c-tooling-sdk/src/discovery/utils.ts new file mode 100644 index 00000000..e63781c2 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/discovery/utils.ts @@ -0,0 +1,92 @@ +/* + * 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 + */ +/** + * Utility functions for workspace discovery. + * + * @module discovery/utils + */ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import {glob as globLib} from 'glob'; + +/** + * Package.json structure (partial). + */ +export interface PackageJson { + name?: string; + version?: string; + dependencies?: Record; + devDependencies?: Record; + [key: string]: unknown; +} + +/** + * Reads and parses a package.json file from a directory. + * + * @param dirPath - Directory containing package.json + * @returns Parsed package.json or undefined if not found/invalid + */ +export async function readPackageJson(dirPath: string): Promise { + const pkgPath = path.join(dirPath, 'package.json'); + try { + const content = await fs.readFile(pkgPath, 'utf8'); + return JSON.parse(content) as PackageJson; + } catch { + return undefined; + } +} + +/** + * Checks if a file or directory exists. + * + * @param filePath - Path to check + * @returns True if exists, false otherwise + */ +export async function exists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +/** + * Finds files matching a glob pattern. + * + * @param pattern - Glob pattern to match + * @param options - Glob options including cwd + * @returns Array of matching file paths + */ +export async function glob(pattern: string, options: {cwd: string}): Promise { + try { + return await globLib(pattern, { + cwd: options.cwd, + nodir: true, + ignore: ['**/node_modules/**'], + }); + } catch { + return []; + } +} + +/** + * Finds directories matching a glob pattern. + * + * @param pattern - Glob pattern to match + * @param options - Glob options including cwd + * @returns Array of matching directory paths + */ +export async function globDirs(pattern: string, options: {cwd: string}): Promise { + try { + return await globLib(pattern, { + cwd: options.cwd, + ignore: ['**/node_modules/**'], + }); + } catch { + return []; + } +} diff --git a/packages/b2c-tooling-sdk/test/discovery/detector.test.ts b/packages/b2c-tooling-sdk/test/discovery/detector.test.ts new file mode 100644 index 00000000..0fb14d45 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/discovery/detector.test.ts @@ -0,0 +1,182 @@ +/* + * 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 { + WorkspaceTypeDetector, + detectWorkspaceType, + type DetectionPattern, + type ProjectType, +} from '@salesforce/b2c-tooling-sdk/discovery'; + +/** + * Creates a mock detection pattern for testing. + */ +function createMockPattern(name: string, projectType: ProjectType, matches: boolean | Error = true): DetectionPattern { + return { + name, + projectType, + detect: async () => { + if (matches instanceof Error) { + throw matches; + } + return matches; + }, + }; +} + +describe('discovery/detector', () => { + describe('WorkspaceTypeDetector', () => { + describe('detect', () => { + it('returns empty arrays when no patterns match', async () => { + const detector = new WorkspaceTypeDetector('/test/path', { + patterns: [createMockPattern('no-match-1', 'pwa-kit', false), createMockPattern('no-match-2', 'sfra', false)], + }); + + const result = await detector.detect(); + + expect(result.projectTypes).to.deep.equal([]); + expect(result.matchedPatterns).to.deep.equal([]); + expect(result.autoDiscovered).to.equal(true); + }); + + it('returns matched project types and pattern names', async () => { + const detector = new WorkspaceTypeDetector('/test/path', { + patterns: [createMockPattern('pwa-kit', 'pwa-kit', true), createMockPattern('sfra-cartridge', 'sfra', false)], + }); + + const result = await detector.detect(); + + expect(result.projectTypes).to.deep.equal(['pwa-kit']); + expect(result.matchedPatterns).to.deep.equal(['pwa-kit']); + }); + + it('returns multiple project types when multiple patterns match', async () => { + const detector = new WorkspaceTypeDetector('/test/path', { + patterns: [createMockPattern('pwa-kit', 'pwa-kit', true), createMockPattern('dw-json', 'headless', true)], + }); + + const result = await detector.detect(); + + expect(result.projectTypes).to.deep.equal(['pwa-kit', 'headless']); + expect(result.matchedPatterns).to.deep.equal(['pwa-kit', 'dw-json']); + }); + + it('deduplicates project types when multiple patterns match same type', async () => { + const detector = new WorkspaceTypeDetector('/test/path', { + patterns: [createMockPattern('pattern-1', 'sfra', true), createMockPattern('pattern-2', 'sfra', true)], + }); + + const result = await detector.detect(); + + expect(result.projectTypes).to.deep.equal(['sfra']); + expect(result.matchedPatterns).to.deep.equal(['pattern-1', 'pattern-2']); + }); + + it('skips patterns that throw errors', async () => { + const detector = new WorkspaceTypeDetector('/test/path', { + patterns: [ + createMockPattern('error-pattern', 'pwa-kit', new Error('Test error')), + createMockPattern('good-pattern', 'sfra', true), + ], + }); + + const result = await detector.detect(); + + expect(result.projectTypes).to.deep.equal(['sfra']); + expect(result.matchedPatterns).to.deep.equal(['good-pattern']); + }); + + it('preserves pattern order in results', async () => { + const detector = new WorkspaceTypeDetector('/test/path', { + patterns: [ + createMockPattern('first', 'pwa-kit', true), + createMockPattern('second', 'sfra', true), + createMockPattern('third', 'custom-api', true), + ], + }); + + const result = await detector.detect(); + + expect(result.projectTypes).to.deep.equal(['pwa-kit', 'sfra', 'custom-api']); + expect(result.matchedPatterns).to.deep.equal(['first', 'second', 'third']); + }); + }); + + describe('pattern resolution', () => { + it('uses custom patterns when provided', async () => { + const customPattern = createMockPattern('custom', 'pwa-kit', true); + const detector = new WorkspaceTypeDetector('/test/path', { + patterns: [customPattern], + }); + + const result = await detector.detect(); + + expect(result.matchedPatterns).to.deep.equal(['custom']); + }); + + it('adds additional patterns to defaults', async () => { + const additionalPattern = createMockPattern('additional', 'custom-api', true); + const detector = new WorkspaceTypeDetector('/nonexistent/path', { + additionalPatterns: [additionalPattern], + }); + + const result = await detector.detect(); + + // Additional pattern should match + expect(result.matchedPatterns).to.include('additional'); + }); + + it('excludes patterns by name', async () => { + const detector = new WorkspaceTypeDetector('/test/path', { + patterns: [createMockPattern('keep-me', 'pwa-kit', true), createMockPattern('exclude-me', 'sfra', true)], + excludePatterns: ['exclude-me'], + }); + + const result = await detector.detect(); + + expect(result.matchedPatterns).to.deep.equal(['keep-me']); + expect(result.matchedPatterns).to.not.include('exclude-me'); + }); + + it('combines additionalPatterns and excludePatterns', async () => { + const detector = new WorkspaceTypeDetector('/test/path', { + patterns: [createMockPattern('base-1', 'pwa-kit', true), createMockPattern('base-2', 'sfra', true)], + additionalPatterns: [createMockPattern('added', 'custom-api', true)], + excludePatterns: ['base-2'], + }); + + const result = await detector.detect(); + + expect(result.matchedPatterns).to.include('base-1'); + expect(result.matchedPatterns).to.include('added'); + expect(result.matchedPatterns).to.not.include('base-2'); + }); + }); + }); + + describe('detectWorkspaceType', () => { + it('is a convenience function that returns detection result', async () => { + const result = await detectWorkspaceType('/nonexistent/path', { + patterns: [createMockPattern('test', 'pwa-kit', true)], + }); + + expect(result.projectTypes).to.deep.equal(['pwa-kit']); + expect(result.matchedPatterns).to.deep.equal(['test']); + expect(result.autoDiscovered).to.equal(true); + expect(result.configFiles).to.deep.equal([]); + }); + + it('works without options', async () => { + // This will use default patterns against a non-existent path + // Default patterns should not match + const result = await detectWorkspaceType('/nonexistent/path/that/does/not/exist'); + + expect(result.projectTypes).to.be.an('array'); + expect(result.matchedPatterns).to.be.an('array'); + expect(result.autoDiscovered).to.equal(true); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/discovery/patterns/base.test.ts b/packages/b2c-tooling-sdk/test/discovery/patterns/base.test.ts new file mode 100644 index 00000000..dc8c451e --- /dev/null +++ b/packages/b2c-tooling-sdk/test/discovery/patterns/base.test.ts @@ -0,0 +1,43 @@ +/* + * 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 * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import {dwJsonPattern} from '@salesforce/b2c-tooling-sdk/discovery'; + +describe('discovery/patterns/base', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'b2c-pattern-test-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, {recursive: true, force: true}); + }); + + describe('dwJsonPattern', () => { + it('has correct metadata', () => { + expect(dwJsonPattern.name).to.equal('dw-json'); + expect(dwJsonPattern.projectType).to.equal('headless'); + }); + + it('detects dw.json file', async () => { + await fs.writeFile(path.join(tempDir, 'dw.json'), '{}'); + + const result = await dwJsonPattern.detect(tempDir); + + expect(result).to.be.true; + }); + + it('returns false without dw.json', async () => { + const result = await dwJsonPattern.detect(tempDir); + + expect(result).to.be.false; + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/discovery/patterns/custom-api.test.ts b/packages/b2c-tooling-sdk/test/discovery/patterns/custom-api.test.ts new file mode 100644 index 00000000..5f08864e --- /dev/null +++ b/packages/b2c-tooling-sdk/test/discovery/patterns/custom-api.test.ts @@ -0,0 +1,65 @@ +/* + * 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 * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import {customApiPattern} from '@salesforce/b2c-tooling-sdk/discovery'; + +describe('discovery/patterns/custom-api', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'b2c-pattern-test-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, {recursive: true, force: true}); + }); + + describe('customApiPattern', () => { + it('has correct metadata', () => { + expect(customApiPattern.name).to.equal('custom-api'); + expect(customApiPattern.projectType).to.equal('custom-api'); + }); + + it('detects api.json in rest-apis structure', async () => { + const apiPath = path.join(tempDir, 'cartridges', 'app_custom', 'cartridge', 'rest-apis', 'my-api'); + await fs.mkdir(apiPath, {recursive: true}); + await fs.writeFile(path.join(apiPath, 'api.json'), '{}'); + + const result = await customApiPattern.detect(tempDir); + + expect(result).to.be.true; + }); + + it('detects schema.yaml in rest-apis structure', async () => { + const apiPath = path.join(tempDir, 'cartridges', 'app_custom', 'cartridge', 'rest-apis', 'my-api'); + await fs.mkdir(apiPath, {recursive: true}); + await fs.writeFile(path.join(apiPath, 'schema.yaml'), 'openapi: 3.0.0'); + + const result = await customApiPattern.detect(tempDir); + + expect(result).to.be.true; + }); + + it('returns false without rest-apis structure', async () => { + const cartridgePath = path.join(tempDir, 'cartridges', 'app_custom', 'cartridge'); + await fs.mkdir(cartridgePath, {recursive: true}); + await fs.writeFile(path.join(cartridgePath, 'api.json'), '{}'); + + const result = await customApiPattern.detect(tempDir); + + expect(result).to.be.false; + }); + + it('returns false in empty directory', async () => { + const result = await customApiPattern.detect(tempDir); + + expect(result).to.be.false; + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/discovery/patterns/pwa-kit.test.ts b/packages/b2c-tooling-sdk/test/discovery/patterns/pwa-kit.test.ts new file mode 100644 index 00000000..f0b42c6e --- /dev/null +++ b/packages/b2c-tooling-sdk/test/discovery/patterns/pwa-kit.test.ts @@ -0,0 +1,71 @@ +/* + * 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 * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import {pwaKitV3Pattern} from '@salesforce/b2c-tooling-sdk/discovery'; + +describe('discovery/patterns/pwa-kit', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'b2c-pattern-test-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, {recursive: true, force: true}); + }); + + describe('pwaKitV3Pattern', () => { + it('has correct metadata', () => { + expect(pwaKitV3Pattern.name).to.equal('pwa-kit-v3'); + expect(pwaKitV3Pattern.projectType).to.equal('pwa-kit-v3'); + }); + + it('detects @salesforce/pwa-kit-react-sdk dependency', async () => { + const pkg = {dependencies: {'@salesforce/pwa-kit-react-sdk': '^3.0.0'}}; + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); + + const result = await pwaKitV3Pattern.detect(tempDir); + + expect(result).to.be.true; + }); + + it('detects @salesforce/pwa-kit-runtime dependency', async () => { + const pkg = {dependencies: {'@salesforce/pwa-kit-runtime': '^3.0.0'}}; + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); + + const result = await pwaKitV3Pattern.detect(tempDir); + + expect(result).to.be.true; + }); + + it('does not detect legacy pwa-kit-react-sdk dependency (v2)', async () => { + const pkg = {devDependencies: {'pwa-kit-react-sdk': '^2.0.0'}}; + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); + + const result = await pwaKitV3Pattern.detect(tempDir); + + expect(result).to.be.false; + }); + + it('returns false without PWA Kit dependencies', async () => { + const pkg = {dependencies: {react: '^18.0.0'}}; + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); + + const result = await pwaKitV3Pattern.detect(tempDir); + + expect(result).to.be.false; + }); + + it('returns false without package.json', async () => { + const result = await pwaKitV3Pattern.detect(tempDir); + + expect(result).to.be.false; + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/discovery/patterns/sfra.test.ts b/packages/b2c-tooling-sdk/test/discovery/patterns/sfra.test.ts new file mode 100644 index 00000000..b4c71c10 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/discovery/patterns/sfra.test.ts @@ -0,0 +1,76 @@ +/* + * 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 * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import {sfraPattern} from '@salesforce/b2c-tooling-sdk/discovery'; + +describe('discovery/patterns/sfra', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'b2c-pattern-test-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, {recursive: true, force: true}); + }); + + describe('sfraPattern', () => { + it('has correct metadata', () => { + expect(sfraPattern.name).to.equal('sfra-cartridge'); + expect(sfraPattern.projectType).to.equal('sfra'); + }); + + it('detects cartridges with controllers at root', async () => { + const controllerPath = path.join(tempDir, 'cartridges', 'app_custom', 'cartridge', 'controllers'); + await fs.mkdir(controllerPath, {recursive: true}); + await fs.writeFile(path.join(controllerPath, 'Home.js'), ''); + + const result = await sfraPattern.detect(tempDir); + + expect(result).to.be.true; + }); + + it('detects cartridges with templates at root', async () => { + const templatePath = path.join(tempDir, 'cartridges', 'app_custom', 'cartridge', 'templates', 'default'); + await fs.mkdir(templatePath, {recursive: true}); + await fs.writeFile(path.join(templatePath, 'home.isml'), ''); + + const result = await sfraPattern.detect(tempDir); + + expect(result).to.be.true; + }); + + it('detects cartridges nested in subdirectories (monorepo)', async () => { + // Structure: app_sfrademo/cartridges/app_custom/cartridge/controllers/ + const controllerPath = path.join(tempDir, 'app_sfrademo', 'cartridges', 'app_custom', 'cartridge', 'controllers'); + await fs.mkdir(controllerPath, {recursive: true}); + await fs.writeFile(path.join(controllerPath, 'Home.js'), ''); + + const result = await sfraPattern.detect(tempDir); + + expect(result).to.be.true; + }); + + it('returns false with empty directory', async () => { + const result = await sfraPattern.detect(tempDir); + + expect(result).to.be.false; + }); + + it('returns false with cartridge folder but no controllers or templates', async () => { + const cartridgePath = path.join(tempDir, 'cartridges', 'app_custom', 'cartridge'); + await fs.mkdir(cartridgePath, {recursive: true}); + await fs.writeFile(path.join(cartridgePath, 'readme.txt'), ''); + + const result = await sfraPattern.detect(tempDir); + + expect(result).to.be.false; + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/discovery/patterns/storefront-next.test.ts b/packages/b2c-tooling-sdk/test/discovery/patterns/storefront-next.test.ts new file mode 100644 index 00000000..551399e6 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/discovery/patterns/storefront-next.test.ts @@ -0,0 +1,80 @@ +/* + * 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 * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import {storefrontNextPattern} from '@salesforce/b2c-tooling-sdk/discovery'; + +describe('discovery/patterns/storefront-next', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'b2c-pattern-test-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, {recursive: true, force: true}); + }); + + describe('storefrontNextPattern', () => { + it('has correct metadata', () => { + expect(storefrontNextPattern.name).to.equal('storefront-next'); + expect(storefrontNextPattern.projectType).to.equal('storefront-next'); + }); + + it('detects @salesforce/storefront-next dependency', async () => { + const pkg = {dependencies: {'@salesforce/storefront-next': '^1.0.0'}}; + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); + + const result = await storefrontNextPattern.detect(tempDir); + + expect(result).to.be.true; + }); + + it('detects @salesforce/storefront-next-dev dependency (prefix match)', async () => { + const pkg = {devDependencies: {'@salesforce/storefront-next-dev': 'workspace:*'}}; + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); + + const result = await storefrontNextPattern.detect(tempDir); + + expect(result).to.be.true; + }); + + it('detects @salesforce/storefront-next-runtime dependency (prefix match)', async () => { + const pkg = {dependencies: {'@salesforce/storefront-next-runtime': 'workspace:*'}}; + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); + + const result = await storefrontNextPattern.detect(tempDir); + + expect(result).to.be.true; + }); + + it('does not detect @salesforce/odyssey (not a real package)', async () => { + const pkg = {devDependencies: {'@salesforce/odyssey': '^1.0.0'}}; + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); + + const result = await storefrontNextPattern.detect(tempDir); + + expect(result).to.be.false; + }); + + it('returns false without storefront-next dependencies', async () => { + const pkg = {dependencies: {react: '^18.0.0'}}; + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); + + const result = await storefrontNextPattern.detect(tempDir); + + expect(result).to.be.false; + }); + + it('returns false without package.json', async () => { + const result = await storefrontNextPattern.detect(tempDir); + + expect(result).to.be.false; + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/discovery/utils.test.ts b/packages/b2c-tooling-sdk/test/discovery/utils.test.ts new file mode 100644 index 00000000..4fc923e2 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/discovery/utils.test.ts @@ -0,0 +1,158 @@ +/* + * 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 * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import {readPackageJson, exists, glob, globDirs} from '@salesforce/b2c-tooling-sdk/discovery'; + +describe('discovery/utils', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'b2c-test-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, {recursive: true, force: true}); + }); + + describe('readPackageJson', () => { + it('returns parsed package.json when file exists', async () => { + const pkg = {name: 'test-package', version: '1.0.0', dependencies: {lodash: '^4.0.0'}}; + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); + + const result = await readPackageJson(tempDir); + + expect(result).to.deep.equal(pkg); + }); + + it('returns undefined when package.json does not exist', async () => { + const result = await readPackageJson(tempDir); + + expect(result).to.be.undefined; + }); + + it('returns undefined when package.json is invalid JSON', async () => { + await fs.writeFile(path.join(tempDir, 'package.json'), 'not valid json'); + + const result = await readPackageJson(tempDir); + + expect(result).to.be.undefined; + }); + + it('returns undefined when directory does not exist', async () => { + const result = await readPackageJson('/nonexistent/path'); + + expect(result).to.be.undefined; + }); + }); + + describe('exists', () => { + it('returns true when file exists', async () => { + const filePath = path.join(tempDir, 'test.txt'); + await fs.writeFile(filePath, 'content'); + + const result = await exists(filePath); + + expect(result).to.be.true; + }); + + it('returns true when directory exists', async () => { + const dirPath = path.join(tempDir, 'subdir'); + await fs.mkdir(dirPath); + + const result = await exists(dirPath); + + expect(result).to.be.true; + }); + + it('returns false when path does not exist', async () => { + const result = await exists(path.join(tempDir, 'nonexistent')); + + expect(result).to.be.false; + }); + }); + + describe('glob', () => { + it('finds files matching pattern', async () => { + await fs.writeFile(path.join(tempDir, 'file1.js'), ''); + await fs.writeFile(path.join(tempDir, 'file2.js'), ''); + await fs.writeFile(path.join(tempDir, 'file3.ts'), ''); + + const result = await glob('*.js', {cwd: tempDir}); + + expect(result).to.have.lengthOf(2); + expect(result).to.include('file1.js'); + expect(result).to.include('file2.js'); + }); + + it('finds files in subdirectories', async () => { + const subdir = path.join(tempDir, 'src'); + await fs.mkdir(subdir); + await fs.writeFile(path.join(subdir, 'index.js'), ''); + + const result = await glob('**/*.js', {cwd: tempDir}); + + expect(result).to.include('src/index.js'); + }); + + it('ignores node_modules', async () => { + const nodeModules = path.join(tempDir, 'node_modules', 'pkg'); + await fs.mkdir(nodeModules, {recursive: true}); + await fs.writeFile(path.join(nodeModules, 'index.js'), ''); + await fs.writeFile(path.join(tempDir, 'app.js'), ''); + + const result = await glob('**/*.js', {cwd: tempDir}); + + expect(result).to.include('app.js'); + expect(result).to.not.include('node_modules/pkg/index.js'); + }); + + it('returns empty array when no matches', async () => { + const result = await glob('*.xyz', {cwd: tempDir}); + + expect(result).to.deep.equal([]); + }); + + it('returns empty array for invalid cwd', async () => { + const result = await glob('*.js', {cwd: '/nonexistent/path'}); + + expect(result).to.deep.equal([]); + }); + }); + + describe('globDirs', () => { + it('finds directories matching pattern', async () => { + await fs.mkdir(path.join(tempDir, 'cartridge1')); + await fs.mkdir(path.join(tempDir, 'cartridge2')); + await fs.writeFile(path.join(tempDir, 'file.txt'), ''); + + const result = await globDirs('cartridge*', {cwd: tempDir}); + + expect(result).to.have.lengthOf(2); + expect(result).to.include('cartridge1'); + expect(result).to.include('cartridge2'); + }); + + it('ignores node_modules', async () => { + const nodeModules = path.join(tempDir, 'node_modules', 'pkg'); + await fs.mkdir(nodeModules, {recursive: true}); + await fs.mkdir(path.join(tempDir, 'src')); + + const result = await globDirs('**/*', {cwd: tempDir}); + + const hasNodeModules = result.some((r) => r.includes('node_modules')); + expect(hasNodeModules).to.be.false; + }); + + it('returns empty array when no matches', async () => { + const result = await globDirs('nonexistent*', {cwd: tempDir}); + + expect(result).to.deep.equal([]); + }); + }); +}); From a8460daeb9e0680d932127cdf1453d9afe942956 Mon Sep 17 00:00:00 2001 From: Yuming Hsieh Date: Tue, 13 Jan 2026 15:31:32 -0500 Subject: [PATCH 2/4] detect pwa kit extensible project --- packages/b2c-dx-mcp/README.md | 2 +- packages/b2c-dx-mcp/src/registry.ts | 2 +- .../b2c-tooling-sdk/src/discovery/detector.ts | 4 +--- .../b2c-tooling-sdk/src/discovery/index.ts | 6 ++---- .../src/discovery/patterns/pwa-kit.ts | 14 +++++++++++-- .../b2c-tooling-sdk/src/discovery/types.ts | 19 ++--------------- .../test/discovery/detector.test.ts | 1 - .../test/discovery/patterns/pwa-kit.test.ts | 21 +++++++++++++++++++ 8 files changed, 40 insertions(+), 29 deletions(-) diff --git a/packages/b2c-dx-mcp/README.md b/packages/b2c-dx-mcp/README.md index 9f89cab1..e876605d 100644 --- a/packages/b2c-dx-mcp/README.md +++ b/packages/b2c-dx-mcp/README.md @@ -68,7 +68,7 @@ When neither `--toolsets` nor `--tools` are provided, the MCP server automatical | Project Type | Detection | Toolsets Enabled | |--------------|-----------|------------------| -| **PWA Kit v3** | `@salesforce/pwa-kit-*` packages in package.json | PWAV3, MRT, SCAPI | +| **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 | | **SFRA** | `cartridges/` folder with controllers or templates | CARTRIDGES, SCAPI | | **Custom API** | `rest-apis/*/api.json` or `rest-apis/*/schema.yaml` files | CARTRIDGES, SCAPI | diff --git a/packages/b2c-dx-mcp/src/registry.ts b/packages/b2c-dx-mcp/src/registry.ts index c1665ffc..75b1b6bc 100644 --- a/packages/b2c-dx-mcp/src/registry.ts +++ b/packages/b2c-dx-mcp/src/registry.ts @@ -37,7 +37,7 @@ function getToolsetsForProjectType(projectType: ProjectType): Toolset[] { return ['STOREFRONTNEXT', 'MRT', 'SCAPI']; } default: { - // Fallback: provide basic SCAPI tools for unknown projects + // Fallback: provide basic SCAPI tools return ['SCAPI']; } } diff --git a/packages/b2c-tooling-sdk/src/discovery/detector.ts b/packages/b2c-tooling-sdk/src/discovery/detector.ts index 994049dc..24474db8 100644 --- a/packages/b2c-tooling-sdk/src/discovery/detector.ts +++ b/packages/b2c-tooling-sdk/src/discovery/detector.ts @@ -8,7 +8,7 @@ * * @module discovery/detector */ -import type {DetectionResult, DetectionPattern, DetectOptions, ProjectType, ConfigFileInfo} from './types.js'; +import type {DetectionResult, DetectionPattern, DetectOptions, ProjectType} from './types.js'; import {DEFAULT_PATTERNS} from './patterns/index.js'; /** @@ -63,7 +63,6 @@ export class WorkspaceTypeDetector { async detect(): Promise { const matchedPatterns: string[] = []; const projectTypes: ProjectType[] = []; - const configFiles: ConfigFileInfo[] = []; for (const pattern of this.patterns) { try { @@ -83,7 +82,6 @@ export class WorkspaceTypeDetector { projectTypes, matchedPatterns, autoDiscovered: true, - configFiles, }; } diff --git a/packages/b2c-tooling-sdk/src/discovery/index.ts b/packages/b2c-tooling-sdk/src/discovery/index.ts index 63045f68..5fcf666f 100644 --- a/packages/b2c-tooling-sdk/src/discovery/index.ts +++ b/packages/b2c-tooling-sdk/src/discovery/index.ts @@ -25,12 +25,11 @@ * * The detector recognizes the following project types: * - * - `pwa-kit-v3` - PWA Kit v3 storefront (has @salesforce/pwa-kit-* dependencies) + * - `pwa-kit-v3` - PWA Kit v3 storefront (template copy or extensible flavor) * - `storefront-next` - Storefront Next/Odyssey project * - `sfra` - SFRA/cartridge-based storefront (has cartridges/ directory) * - `custom-api` - Custom SCAPI project (has api.json files) * - `headless` - Generic headless project (has dw.json but no specific framework) - * - `unknown` - Could not determine project type * * ## Custom Patterns * @@ -42,7 +41,6 @@ * const myPattern: DetectionPattern = { * name: 'my-framework', * projectType: 'custom-api', - * priority: 100, * detect: async (path) => { * // Custom detection logic * return false; @@ -63,7 +61,7 @@ export {WorkspaceTypeDetector, detectWorkspaceType} from './detector.js'; // Types -export type {ProjectType, DetectionPattern, DetectionResult, DetectOptions, ConfigFileInfo} from './types.js'; +export type {ProjectType, DetectionPattern, DetectionResult, DetectOptions} from './types.js'; // Patterns (for customization) export { diff --git a/packages/b2c-tooling-sdk/src/discovery/patterns/pwa-kit.ts b/packages/b2c-tooling-sdk/src/discovery/patterns/pwa-kit.ts index 18ca8832..1e2b6012 100644 --- a/packages/b2c-tooling-sdk/src/discovery/patterns/pwa-kit.ts +++ b/packages/b2c-tooling-sdk/src/discovery/patterns/pwa-kit.ts @@ -15,7 +15,9 @@ import {readPackageJson} from '../utils.js'; * Detection pattern for PWA Kit v3 storefronts. * * Detects projects that have PWA Kit v3 SDK dependencies in package.json. - * Matches packages starting with @salesforce/pwa-kit (v3+). + * Supports two flavors: + * - Template copy: Has @salesforce/pwa-kit-* packages + * - Extensible: Has @salesforce/retail-react-app or ccExtensibility field */ export const pwaKitV3Pattern: DetectionPattern = { name: 'pwa-kit-v3', @@ -24,7 +26,15 @@ export const pwaKitV3Pattern: DetectionPattern = { const pkg = await readPackageJson(workspacePath); if (!pkg) return false; + // Check for ccExtensibility field (extensible flavor marker) + if (pkg.ccExtensibility) return true; + const deps = Object.keys({...pkg.dependencies, ...pkg.devDependencies}); - return deps.some((dep) => dep.startsWith('@salesforce/pwa-kit')); + + // Template copy flavor: @salesforce/pwa-kit-* packages + // Extensible flavor: @salesforce/retail-react-app package + return deps.some( + (dep) => dep.startsWith('@salesforce/pwa-kit') || dep === '@salesforce/retail-react-app' + ); }, }; diff --git a/packages/b2c-tooling-sdk/src/discovery/types.ts b/packages/b2c-tooling-sdk/src/discovery/types.ts index 82610772..8b6cca87 100644 --- a/packages/b2c-tooling-sdk/src/discovery/types.ts +++ b/packages/b2c-tooling-sdk/src/discovery/types.ts @@ -13,12 +13,11 @@ * Identifies the type of B2C Commerce project. */ export type ProjectType = - | 'pwa-kit-v3' // PWA Kit v3 storefront (@salesforce/pwa-kit-* packages) + | 'pwa-kit-v3' // PWA Kit v3 storefront (template copy or extensible flavor) | 'storefront-next' // Storefront Next (Odyssey) | 'sfra' // SFRA/cartridge-based storefront | 'custom-api' // Custom SCAPI project - | 'headless' // Generic headless (uses SCAPI/dw.json but no specific framework) - | 'unknown'; // Could not determine + | 'headless'; // Generic headless (uses SCAPI/dw.json but no specific framework) /** * Detection pattern definition. @@ -32,18 +31,6 @@ export interface DetectionPattern { detect: (workspacePath: string) => Promise; } -/** - * Information about detected config files. - */ -export interface ConfigFileInfo { - /** Type of configuration file */ - type: 'dw.json' | 'package.json' | 'mobify.json' | 'api.json'; - /** Path to the file */ - path: string; - /** Relevant extracted info */ - metadata?: Record; -} - /** * Result of workspace detection. */ @@ -54,8 +41,6 @@ export interface DetectionResult { matchedPatterns: string[]; /** Whether auto-discovery was performed */ autoDiscovered: boolean; - /** Detected configuration files */ - configFiles: ConfigFileInfo[]; } /** diff --git a/packages/b2c-tooling-sdk/test/discovery/detector.test.ts b/packages/b2c-tooling-sdk/test/discovery/detector.test.ts index 0fb14d45..b1ae4bdd 100644 --- a/packages/b2c-tooling-sdk/test/discovery/detector.test.ts +++ b/packages/b2c-tooling-sdk/test/discovery/detector.test.ts @@ -166,7 +166,6 @@ describe('discovery/detector', () => { expect(result.projectTypes).to.deep.equal(['pwa-kit']); expect(result.matchedPatterns).to.deep.equal(['test']); expect(result.autoDiscovered).to.equal(true); - expect(result.configFiles).to.deep.equal([]); }); it('works without options', async () => { diff --git a/packages/b2c-tooling-sdk/test/discovery/patterns/pwa-kit.test.ts b/packages/b2c-tooling-sdk/test/discovery/patterns/pwa-kit.test.ts index f0b42c6e..6fc41e6f 100644 --- a/packages/b2c-tooling-sdk/test/discovery/patterns/pwa-kit.test.ts +++ b/packages/b2c-tooling-sdk/test/discovery/patterns/pwa-kit.test.ts @@ -53,6 +53,27 @@ describe('discovery/patterns/pwa-kit', () => { expect(result).to.be.false; }); + it('detects @salesforce/retail-react-app dependency (extensible flavor)', async () => { + const pkg = {devDependencies: {'@salesforce/retail-react-app': '^8.0.0'}}; + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); + + const result = await pwaKitV3Pattern.detect(tempDir); + + expect(result).to.be.true; + }); + + it('detects ccExtensibility field (extensible flavor)', async () => { + const pkg = { + ccExtensibility: {extends: '@salesforce/retail-react-app', overridesDir: 'overrides'}, + devDependencies: {}, + }; + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); + + const result = await pwaKitV3Pattern.detect(tempDir); + + expect(result).to.be.true; + }); + it('returns false without PWA Kit dependencies', async () => { const pkg = {dependencies: {react: '^18.0.0'}}; await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); From 2f104f99da160fbbc0115f0d0d76a7f572538030 Mon Sep 17 00:00:00 2001 From: Yuming Hsieh Date: Tue, 13 Jan 2026 15:31:50 -0500 Subject: [PATCH 3/4] detect pwa kit extensible project --- .../src/discovery/patterns/pwa-kit.ts | 4 +- .../test/discovery/detector.test.ts | 39 ++++++++++++------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/packages/b2c-tooling-sdk/src/discovery/patterns/pwa-kit.ts b/packages/b2c-tooling-sdk/src/discovery/patterns/pwa-kit.ts index 1e2b6012..dff84207 100644 --- a/packages/b2c-tooling-sdk/src/discovery/patterns/pwa-kit.ts +++ b/packages/b2c-tooling-sdk/src/discovery/patterns/pwa-kit.ts @@ -33,8 +33,6 @@ export const pwaKitV3Pattern: DetectionPattern = { // Template copy flavor: @salesforce/pwa-kit-* packages // Extensible flavor: @salesforce/retail-react-app package - return deps.some( - (dep) => dep.startsWith('@salesforce/pwa-kit') || dep === '@salesforce/retail-react-app' - ); + return deps.some((dep) => dep.startsWith('@salesforce/pwa-kit') || dep === '@salesforce/retail-react-app'); }, }; diff --git a/packages/b2c-tooling-sdk/test/discovery/detector.test.ts b/packages/b2c-tooling-sdk/test/discovery/detector.test.ts index b1ae4bdd..60678f63 100644 --- a/packages/b2c-tooling-sdk/test/discovery/detector.test.ts +++ b/packages/b2c-tooling-sdk/test/discovery/detector.test.ts @@ -32,7 +32,10 @@ describe('discovery/detector', () => { describe('detect', () => { it('returns empty arrays when no patterns match', async () => { const detector = new WorkspaceTypeDetector('/test/path', { - patterns: [createMockPattern('no-match-1', 'pwa-kit', false), createMockPattern('no-match-2', 'sfra', false)], + patterns: [ + createMockPattern('no-match-1', 'pwa-kit-v3', false), + createMockPattern('no-match-2', 'sfra', false), + ], }); const result = await detector.detect(); @@ -44,24 +47,30 @@ describe('discovery/detector', () => { it('returns matched project types and pattern names', async () => { const detector = new WorkspaceTypeDetector('/test/path', { - patterns: [createMockPattern('pwa-kit', 'pwa-kit', true), createMockPattern('sfra-cartridge', 'sfra', false)], + patterns: [ + createMockPattern('pwa-kit-v3', 'pwa-kit-v3', true), + createMockPattern('sfra-cartridge', 'sfra', false), + ], }); const result = await detector.detect(); - expect(result.projectTypes).to.deep.equal(['pwa-kit']); - expect(result.matchedPatterns).to.deep.equal(['pwa-kit']); + expect(result.projectTypes).to.deep.equal(['pwa-kit-v3']); + expect(result.matchedPatterns).to.deep.equal(['pwa-kit-v3']); }); it('returns multiple project types when multiple patterns match', async () => { const detector = new WorkspaceTypeDetector('/test/path', { - patterns: [createMockPattern('pwa-kit', 'pwa-kit', true), createMockPattern('dw-json', 'headless', true)], + patterns: [ + createMockPattern('pwa-kit-v3', 'pwa-kit-v3', true), + createMockPattern('dw-json', 'headless', true), + ], }); const result = await detector.detect(); - expect(result.projectTypes).to.deep.equal(['pwa-kit', 'headless']); - expect(result.matchedPatterns).to.deep.equal(['pwa-kit', 'dw-json']); + expect(result.projectTypes).to.deep.equal(['pwa-kit-v3', 'headless']); + expect(result.matchedPatterns).to.deep.equal(['pwa-kit-v3', 'dw-json']); }); it('deduplicates project types when multiple patterns match same type', async () => { @@ -78,7 +87,7 @@ describe('discovery/detector', () => { it('skips patterns that throw errors', async () => { const detector = new WorkspaceTypeDetector('/test/path', { patterns: [ - createMockPattern('error-pattern', 'pwa-kit', new Error('Test error')), + createMockPattern('error-pattern', 'pwa-kit-v3', new Error('Test error')), createMockPattern('good-pattern', 'sfra', true), ], }); @@ -92,7 +101,7 @@ describe('discovery/detector', () => { it('preserves pattern order in results', async () => { const detector = new WorkspaceTypeDetector('/test/path', { patterns: [ - createMockPattern('first', 'pwa-kit', true), + createMockPattern('first', 'pwa-kit-v3', true), createMockPattern('second', 'sfra', true), createMockPattern('third', 'custom-api', true), ], @@ -100,14 +109,14 @@ describe('discovery/detector', () => { const result = await detector.detect(); - expect(result.projectTypes).to.deep.equal(['pwa-kit', 'sfra', 'custom-api']); + expect(result.projectTypes).to.deep.equal(['pwa-kit-v3', 'sfra', 'custom-api']); expect(result.matchedPatterns).to.deep.equal(['first', 'second', 'third']); }); }); describe('pattern resolution', () => { it('uses custom patterns when provided', async () => { - const customPattern = createMockPattern('custom', 'pwa-kit', true); + const customPattern = createMockPattern('custom', 'pwa-kit-v3', true); const detector = new WorkspaceTypeDetector('/test/path', { patterns: [customPattern], }); @@ -131,7 +140,7 @@ describe('discovery/detector', () => { it('excludes patterns by name', async () => { const detector = new WorkspaceTypeDetector('/test/path', { - patterns: [createMockPattern('keep-me', 'pwa-kit', true), createMockPattern('exclude-me', 'sfra', true)], + patterns: [createMockPattern('keep-me', 'pwa-kit-v3', true), createMockPattern('exclude-me', 'sfra', true)], excludePatterns: ['exclude-me'], }); @@ -143,7 +152,7 @@ describe('discovery/detector', () => { it('combines additionalPatterns and excludePatterns', async () => { const detector = new WorkspaceTypeDetector('/test/path', { - patterns: [createMockPattern('base-1', 'pwa-kit', true), createMockPattern('base-2', 'sfra', true)], + patterns: [createMockPattern('base-1', 'pwa-kit-v3', true), createMockPattern('base-2', 'sfra', true)], additionalPatterns: [createMockPattern('added', 'custom-api', true)], excludePatterns: ['base-2'], }); @@ -160,10 +169,10 @@ describe('discovery/detector', () => { describe('detectWorkspaceType', () => { it('is a convenience function that returns detection result', async () => { const result = await detectWorkspaceType('/nonexistent/path', { - patterns: [createMockPattern('test', 'pwa-kit', true)], + patterns: [createMockPattern('test', 'pwa-kit-v3', true)], }); - expect(result.projectTypes).to.deep.equal(['pwa-kit']); + expect(result.projectTypes).to.deep.equal(['pwa-kit-v3']); expect(result.matchedPatterns).to.deep.equal(['test']); expect(result.autoDiscovered).to.equal(true); }); From bb28934c69a270038d4eac7c67988472bfe7699c Mon Sep 17 00:00:00 2001 From: Yuming Hsieh Date: Wed, 14 Jan 2026 12:30:53 -0500 Subject: [PATCH 4/4] refactor sfra detect and workspace types --- packages/b2c-dx-mcp/README.md | 18 +- packages/b2c-dx-mcp/src/registry.ts | 54 +++--- .../b2c-tooling-sdk/src/discovery/index.ts | 20 ++- .../src/discovery/patterns/base.ts | 32 ---- .../src/discovery/patterns/cartridges.ts | 30 ++++ .../src/discovery/patterns/custom-api.ts | 48 ------ .../src/discovery/patterns/index.ts | 30 ++-- .../src/discovery/patterns/sfra.ts | 63 +++++-- .../b2c-tooling-sdk/src/discovery/types.ts | 13 +- .../test/discovery/detector.test.ts | 41 +++-- .../test/discovery/patterns/base.test.ts | 43 ----- .../discovery/patterns/cartridges.test.ts | 75 ++++++++ .../discovery/patterns/custom-api.test.ts | 65 ------- .../test/discovery/patterns/sfra.test.ts | 163 ++++++++++++------ 14 files changed, 361 insertions(+), 334 deletions(-) delete mode 100644 packages/b2c-tooling-sdk/src/discovery/patterns/base.ts create mode 100644 packages/b2c-tooling-sdk/src/discovery/patterns/cartridges.ts delete mode 100644 packages/b2c-tooling-sdk/src/discovery/patterns/custom-api.ts delete mode 100644 packages/b2c-tooling-sdk/test/discovery/patterns/base.test.ts create mode 100644 packages/b2c-tooling-sdk/test/discovery/patterns/cartridges.test.ts delete mode 100644 packages/b2c-tooling-sdk/test/discovery/patterns/custom-api.test.ts diff --git a/packages/b2c-dx-mcp/README.md b/packages/b2c-dx-mcp/README.md index e876605d..16caeae8 100644 --- a/packages/b2c-dx-mcp/README.md +++ b/packages/b2c-dx-mcp/README.md @@ -61,8 +61,12 @@ When neither `--toolsets` nor `--tools` are provided, the MCP server automatical **How it works:** 1. The server analyzes your working directory (from `--working-directory` flag, `SFCC_WORKING_DIRECTORY` env var, or current directory) -2. It checks for project markers like `package.json` dependencies, folder structures, and config files -3. It enables all toolsets that match any detected project type +2. It checks for project markers like `package.json` dependencies and `.project` files +3. It enables all toolsets that match any detected project type, plus the base SCAPI toolset + +**Base Toolset:** + +The **SCAPI** toolset is always enabled, providing API discovery and custom API scaffolding capabilities. **Project Types and Toolsets:** @@ -70,14 +74,12 @@ When neither `--toolsets` nor `--tools` are provided, the MCP server automatical |--------------|-----------|------------------| | **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 | -| **SFRA** | `cartridges/` folder with controllers or templates | CARTRIDGES, SCAPI | -| **Custom API** | `rest-apis/*/api.json` or `rest-apis/*/schema.yaml` files | CARTRIDGES, SCAPI | -| **Headless** | `dw.json` file (no specific framework detected) | SCAPI | -| **Unknown** | No B2C project markers found | SCAPI (fallback) | +| **Cartridges** | Any cartridge with `.project` file (detected via `findCartridges`) | CARTRIDGES, SCAPI | +| **No project detected** | No B2C project markers found | SCAPI (base toolset only) | **Hybrid Projects:** -If multiple project types are detected (e.g., SFRA + Custom API), toolsets from all matched types are combined. +If multiple project types are detected (e.g., cartridges + PWA Kit v3), toolsets from all matched types are combined. **Example:** @@ -109,6 +111,8 @@ If multiple project types are detected (e.g., SFRA + Custom API), toolsets from > **Note:** Cursor supports `${workspaceFolder}` variable expansion, but Claude Desktop does not. For Claude Desktop, use an explicit path or set the `SFCC_WORKING_DIRECTORY` environment variable. +> **Warning:** MCP clients like Cursor and Claude Desktop often spawn servers from the home directory (`~`) rather than the project directory. Always set `--working-directory` or `SFCC_WORKING_DIRECTORY` for reliable auto-discovery and scaffolding operations. + ### Configuration Examples ```json diff --git a/packages/b2c-dx-mcp/src/registry.ts b/packages/b2c-dx-mcp/src/registry.ts index 75b1b6bc..8df8cf93 100644 --- a/packages/b2c-dx-mcp/src/registry.ts +++ b/packages/b2c-dx-mcp/src/registry.ts @@ -17,49 +17,45 @@ import {createScapiTools} from './tools/scapi/index.js'; import {createStorefrontNextTools} from './tools/storefrontnext/index.js'; /** - * Maps a single project type to its associated MCP toolsets. + * Base toolset that is always enabled. + * Provides SCAPI discovery and custom API scaffolding tools. + */ +const BASE_TOOLSET: Toolset = 'SCAPI'; + +/** + * Toolset mapping by project type. + * Each project type enables specific toolsets IN ADDITION to the base toolset. + */ +const PROJECT_TYPE_TOOLSETS: Record = { + cartridges: ['CARTRIDGES'], + 'pwa-kit-v3': ['PWAV3', 'MRT'], + 'storefront-next': ['STOREFRONTNEXT', 'MRT'], +}; + +/** + * Gets toolsets for a project type, always including the base toolset. */ function getToolsetsForProjectType(projectType: ProjectType): Toolset[] { - switch (projectType) { - case 'custom-api': { - return ['CARTRIDGES', 'SCAPI']; - } - case 'headless': { - return ['SCAPI']; - } - case 'pwa-kit-v3': { - return ['PWAV3', 'MRT', 'SCAPI']; - } - case 'sfra': { - return ['CARTRIDGES', 'SCAPI']; - } - case 'storefront-next': { - return ['STOREFRONTNEXT', 'MRT', 'SCAPI']; - } - default: { - // Fallback: provide basic SCAPI tools - return ['SCAPI']; - } - } + const additionalToolsets = PROJECT_TYPE_TOOLSETS[projectType] ?? []; + return [...additionalToolsets, BASE_TOOLSET]; } /** * Maps multiple detected project types to a union of MCP toolsets. * * Combines toolsets from all matched project types, enabling hybrid - * project support (e.g., SFRA + Custom API gets both CARTRIDGES and SCAPI). + * project support (e.g., cartridges + pwa-kit-v3 gets CARTRIDGES + PWAV3 + MRT + SCAPI). * * @param projectTypes - Array of detected project types - * @returns Union of all toolsets for the detected project types + * @returns Union of all toolsets for the detected project types (always includes base toolset) */ function getToolsetsForProjectTypes(projectTypes: ProjectType[]): Toolset[] { - // Fallback to SCAPI when no project types detected - if (projectTypes.length === 0) { - return ['SCAPI']; - } - const toolsetSet = new Set(); + // Always include base toolset + toolsetSet.add(BASE_TOOLSET); + + // Add toolsets for each detected project type for (const projectType of projectTypes) { for (const toolset of getToolsetsForProjectType(projectType)) { toolsetSet.add(toolset); diff --git a/packages/b2c-tooling-sdk/src/discovery/index.ts b/packages/b2c-tooling-sdk/src/discovery/index.ts index 5fcf666f..8578e761 100644 --- a/packages/b2c-tooling-sdk/src/discovery/index.ts +++ b/packages/b2c-tooling-sdk/src/discovery/index.ts @@ -23,13 +23,18 @@ * * ## Project Types * - * The detector recognizes the following project types: + * The detector recognizes 3 workspace types: * - * - `pwa-kit-v3` - PWA Kit v3 storefront (template copy or extensible flavor) + * - `cartridges` - Any cartridge-based project (detected via .project files) + * - `pwa-kit-v3` - PWA Kit v3 storefront * - `storefront-next` - Storefront Next/Odyssey project - * - `sfra` - SFRA/cartridge-based storefront (has cartridges/ directory) - * - `custom-api` - Custom SCAPI project (has api.json files) - * - `headless` - Generic headless project (has dw.json but no specific framework) + * + * ## Toolset Mapping + * + * - base (fallback): SCAPI + * - cartridges: CARTRIDGES + SCAPI + * - pwa-kit-v3: PWAV3 + MRT + SCAPI + * - storefront-next: STOREFRONTNEXT + MRT + SCAPI * * ## Custom Patterns * @@ -40,7 +45,7 @@ * * const myPattern: DetectionPattern = { * name: 'my-framework', - * projectType: 'custom-api', + * projectType: 'cartridges', * detect: async (path) => { * // Custom detection logic * return false; @@ -66,11 +71,10 @@ export type {ProjectType, DetectionPattern, DetectionResult, DetectOptions} from // Patterns (for customization) export { DEFAULT_PATTERNS, + cartridgesPattern, pwaKitV3Pattern, storefrontNextPattern, sfraPattern, - customApiPattern, - dwJsonPattern, } from './patterns/index.js'; // Utilities (for building custom patterns) diff --git a/packages/b2c-tooling-sdk/src/discovery/patterns/base.ts b/packages/b2c-tooling-sdk/src/discovery/patterns/base.ts deleted file mode 100644 index ff825739..00000000 --- a/packages/b2c-tooling-sdk/src/discovery/patterns/base.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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 - */ -/** - * Base detection patterns for common B2C configuration files. - * - * @module discovery/patterns/base - */ -import * as path from 'node:path'; -import type {DetectionPattern} from '../types.js'; -import {exists} from '../utils.js'; - -/** - * Detection pattern for projects with dw.json configuration. - * - * This is a low-priority fallback pattern that detects any project - * with a dw.json file. It indicates the project connects to a B2C - * Commerce instance but doesn't match any specific framework. - * - * The "headless" project type is assigned to indicate a generic - * headless commerce setup (custom frontend, integration scripts, - * mobile app backends, etc.). - */ -export const dwJsonPattern: DetectionPattern = { - name: 'dw-json', - projectType: 'headless', - detect: async (workspacePath) => { - return await exists(path.join(workspacePath, 'dw.json')); - }, -}; diff --git a/packages/b2c-tooling-sdk/src/discovery/patterns/cartridges.ts b/packages/b2c-tooling-sdk/src/discovery/patterns/cartridges.ts new file mode 100644 index 00000000..fd344633 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/discovery/patterns/cartridges.ts @@ -0,0 +1,30 @@ +/* + * 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 + */ +/** + * Cartridge project detection pattern. + * + * Detects any project containing cartridges by looking for .project files + * (Eclipse project markers used in SFCC development). + * + * @module discovery/patterns/cartridges + */ +import type {DetectionPattern} from '../types.js'; +import {findCartridges} from '../../operations/code/cartridges.js'; + +/** + * Detection pattern for cartridge-based projects. + * + * Uses the SDK's findCartridges function to detect any cartridges in the workspace. + * This covers SFRA, custom APIs, and any other cartridge-based development. + */ +export const cartridgesPattern: DetectionPattern = { + name: 'cartridges', + projectType: 'cartridges', + detect: async (workspacePath) => { + const cartridges = findCartridges(workspacePath); + return cartridges.length > 0; + }, +}; diff --git a/packages/b2c-tooling-sdk/src/discovery/patterns/custom-api.ts b/packages/b2c-tooling-sdk/src/discovery/patterns/custom-api.ts deleted file mode 100644 index 32899b7c..00000000 --- a/packages/b2c-tooling-sdk/src/discovery/patterns/custom-api.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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 - */ -/** - * Custom SCAPI project detection pattern. - * - * Custom APIs in Salesforce B2C Commerce MUST be organized within custom code - * cartridges in a `rest-apis` directory structure. This is a required structure - - * deviating from it will prevent APIs from being discovered/registered by SFCC. - * - * Required structure: - * ``` - * cartridge/ - * └── rest-apis/ ← Required root folder - * └── {apiName}/ ← API subdirectory (alphanumeric lowercase + hyphens) - * ├── api.json ← Required: API mapping - * ├── schema.yaml ← Required: OAS 3.0 contract - * └── {script}.js ← Required: Implementation - * ``` - * - * @see https://developer.salesforce.com/docs/commerce/commerce-api/guide/custom-apis.html - * @module discovery/patterns/custom-api - */ -import type {DetectionPattern} from '../types.js'; -import {glob} from '../utils.js'; - -/** - * Detection pattern for Custom SCAPI projects. - * - * Detects projects with the required SFCC Custom API `rest-apis` directory structure. - * Only looks for files within `rest-apis/` directories since this structure is required. - */ -export const customApiPattern: DetectionPattern = { - name: 'custom-api', - projectType: 'custom-api', - detect: async (workspacePath) => { - // Look for api.json within required rest-apis directory structure - // The rest-apis/{apiName}/ structure is REQUIRED by SFCC for Custom APIs - const hasRestApisApiJson = await glob('**/rest-apis/*/api.json', {cwd: workspacePath}); - if (hasRestApisApiJson.length > 0) return true; - - // Also check for schema.yaml (OpenAPI 3.0 contract) in rest-apis structure - const hasRestApisSchema = await glob('**/rest-apis/*/schema.yaml', {cwd: workspacePath}); - return hasRestApisSchema.length > 0; - }, -}; diff --git a/packages/b2c-tooling-sdk/src/discovery/patterns/index.ts b/packages/b2c-tooling-sdk/src/discovery/patterns/index.ts index 5c0e41e9..37c4bb60 100644 --- a/packages/b2c-tooling-sdk/src/discovery/patterns/index.ts +++ b/packages/b2c-tooling-sdk/src/discovery/patterns/index.ts @@ -6,35 +6,29 @@ /** * Detection patterns for workspace discovery. * + * Simplified to 3 workspace types: + * - cartridges: Any project with cartridges + * - pwa-kit-v3: PWA Kit v3 storefront + * - storefront-next: Storefront Next (Odyssey) + * * @module discovery/patterns */ import type {DetectionPattern} from '../types.js'; +import {cartridgesPattern} from './cartridges.js'; import {pwaKitV3Pattern} from './pwa-kit.js'; import {storefrontNextPattern} from './storefront-next.js'; -import {sfraPattern} from './sfra.js'; -import {customApiPattern} from './custom-api.js'; -import {dwJsonPattern} from './base.js'; /** - * Default detection patterns in priority order. + * Default detection patterns. * - * Patterns are sorted by priority when detection runs, but this - * array provides a logical grouping: - * 1. Framework-specific patterns (PWA Kit v3, Storefront Next, SFRA) - * 2. Project-type patterns (Custom API) - * 3. Fallback patterns (dw.json) + * All patterns are checked - multiple can match for hybrid projects. */ -export const DEFAULT_PATTERNS: DetectionPattern[] = [ - pwaKitV3Pattern, - storefrontNextPattern, - sfraPattern, - customApiPattern, - dwJsonPattern, -]; +export const DEFAULT_PATTERNS: DetectionPattern[] = [pwaKitV3Pattern, storefrontNextPattern, cartridgesPattern]; // Individual pattern exports for customization +export {cartridgesPattern} from './cartridges.js'; export {pwaKitV3Pattern} from './pwa-kit.js'; export {storefrontNextPattern} from './storefront-next.js'; + +// Additional patterns (not in DEFAULT_PATTERNS, available for custom use) export {sfraPattern} from './sfra.js'; -export {customApiPattern} from './custom-api.js'; -export {dwJsonPattern} from './base.js'; diff --git a/packages/b2c-tooling-sdk/src/discovery/patterns/sfra.ts b/packages/b2c-tooling-sdk/src/discovery/patterns/sfra.ts index c53eea9d..e6a87dc5 100644 --- a/packages/b2c-tooling-sdk/src/discovery/patterns/sfra.ts +++ b/packages/b2c-tooling-sdk/src/discovery/patterns/sfra.ts @@ -4,30 +4,65 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ /** - * SFRA/cartridge project detection pattern. + * SFRA (Storefront Reference Architecture) detection pattern. + * + * NOTE: This pattern is NOT included in DEFAULT_PATTERNS. + * The simpler `cartridgesPattern` is used instead, which detects any cartridge project. + * This pattern is exported for custom detection scenarios where SFRA-specific + * detection is needed. * * @module discovery/patterns/sfra */ import type {DetectionPattern} from '../types.js'; -import {glob} from '../utils.js'; +import {findCartridges} from '../../operations/code/cartridges.js'; +import {readPackageJson} from '../utils.js'; /** - * Detection pattern for SFRA/cartridge-based storefronts. + * Detection pattern for SFRA projects. + * + * Detects SFRA workspaces by checking for: + * 1. A cartridge named `app_storefront_base` - the core SFRA cartridge that serves as the + * foundation for SFRA-based storefronts. Per Salesforce documentation, this cartridge + * is essential and should not be renamed. (Primary detection method) + * 2. A package.json with `paths.base` containing `app_storefront_base` - used in multi-repo + * setups with sgmf-scripts where the SFRA base cartridge is external to the project. + * (Secondary heuristic, less common) + * + * @see https://developer.salesforce.com/docs/commerce/b2c-commerce/guide/b2c-customizing-sfra.html * - * Detects projects that have the standard cartridge folder structure - * with controllers and/or templates. Searches recursively so cartridges - * can be at the workspace root or in subdirectories (e.g., monorepos). + * @example + * ``` + * // Use in custom detection + * import { sfraPattern } from '@salesforce/b2c-tooling-sdk/discovery'; + * + * const customPatterns = [sfraPattern, ...otherPatterns]; + * const result = await detectWorkspaceType(workspacePath, { patterns: customPatterns }); + * ``` */ export const sfraPattern: DetectionPattern = { - name: 'sfra-cartridge', - projectType: 'sfra', + name: 'sfra', + projectType: 'cartridges', // Maps to cartridges project type detect: async (workspacePath) => { - // Look for SFRA-style structure (controllers or templates) - // Searches recursively - cartridges can be at root or nested in subdirectories - const hasControllers = await glob('**/cartridge/controllers/**/*.js', {cwd: workspacePath}); - if (hasControllers.length > 0) return true; + // Primary check: Look for app_storefront_base cartridge (the core SFRA cartridge) + // This is the definitive indicator per Salesforce documentation + const cartridges = findCartridges(workspacePath); + const hasSfraCartridge = cartridges.some((cartridge) => cartridge.name === 'app_storefront_base'); + if (hasSfraCartridge) { + return true; + } + + // Secondary check: Look for package.json with paths.base pointing to app_storefront_base + // Used in multi-repo setups with sgmf-scripts where SFRA base is in a separate repo + const packageJson = await readPackageJson(workspacePath); + if (packageJson) { + const paths = packageJson.paths as Record | undefined; + if (paths?.base && typeof paths.base === 'string') { + if (paths.base.includes('app_storefront_base')) { + return true; + } + } + } - const hasTemplates = await glob('**/cartridge/templates/**/*.isml', {cwd: workspacePath}); - return hasTemplates.length > 0; + return false; }, }; diff --git a/packages/b2c-tooling-sdk/src/discovery/types.ts b/packages/b2c-tooling-sdk/src/discovery/types.ts index 8b6cca87..021afb27 100644 --- a/packages/b2c-tooling-sdk/src/discovery/types.ts +++ b/packages/b2c-tooling-sdk/src/discovery/types.ts @@ -11,13 +11,16 @@ /** * Identifies the type of B2C Commerce project. + * + * Simplified to 3 workspace types: + * - cartridges: Any project with cartridges (detected via .project files) + * - pwa-kit-v3: PWA Kit v3 storefront + * - storefront-next: Storefront Next (Odyssey) */ export type ProjectType = - | 'pwa-kit-v3' // PWA Kit v3 storefront (template copy or extensible flavor) - | 'storefront-next' // Storefront Next (Odyssey) - | 'sfra' // SFRA/cartridge-based storefront - | 'custom-api' // Custom SCAPI project - | 'headless'; // Generic headless (uses SCAPI/dw.json but no specific framework) + | 'cartridges' // Any cartridge-based project (SFRA, custom APIs, etc.) + | 'pwa-kit-v3' // PWA Kit v3 storefront + | 'storefront-next'; // Storefront Next (Odyssey) /** * Detection pattern definition. diff --git a/packages/b2c-tooling-sdk/test/discovery/detector.test.ts b/packages/b2c-tooling-sdk/test/discovery/detector.test.ts index 60678f63..794009d6 100644 --- a/packages/b2c-tooling-sdk/test/discovery/detector.test.ts +++ b/packages/b2c-tooling-sdk/test/discovery/detector.test.ts @@ -34,7 +34,7 @@ describe('discovery/detector', () => { const detector = new WorkspaceTypeDetector('/test/path', { patterns: [ createMockPattern('no-match-1', 'pwa-kit-v3', false), - createMockPattern('no-match-2', 'sfra', false), + createMockPattern('no-match-2', 'cartridges', false), ], }); @@ -49,7 +49,7 @@ describe('discovery/detector', () => { const detector = new WorkspaceTypeDetector('/test/path', { patterns: [ createMockPattern('pwa-kit-v3', 'pwa-kit-v3', true), - createMockPattern('sfra-cartridge', 'sfra', false), + createMockPattern('cartridges', 'cartridges', false), ], }); @@ -63,24 +63,27 @@ describe('discovery/detector', () => { const detector = new WorkspaceTypeDetector('/test/path', { patterns: [ createMockPattern('pwa-kit-v3', 'pwa-kit-v3', true), - createMockPattern('dw-json', 'headless', true), + createMockPattern('cartridges', 'cartridges', true), ], }); const result = await detector.detect(); - expect(result.projectTypes).to.deep.equal(['pwa-kit-v3', 'headless']); - expect(result.matchedPatterns).to.deep.equal(['pwa-kit-v3', 'dw-json']); + expect(result.projectTypes).to.deep.equal(['pwa-kit-v3', 'cartridges']); + expect(result.matchedPatterns).to.deep.equal(['pwa-kit-v3', 'cartridges']); }); it('deduplicates project types when multiple patterns match same type', async () => { const detector = new WorkspaceTypeDetector('/test/path', { - patterns: [createMockPattern('pattern-1', 'sfra', true), createMockPattern('pattern-2', 'sfra', true)], + patterns: [ + createMockPattern('pattern-1', 'cartridges', true), + createMockPattern('pattern-2', 'cartridges', true), + ], }); const result = await detector.detect(); - expect(result.projectTypes).to.deep.equal(['sfra']); + expect(result.projectTypes).to.deep.equal(['cartridges']); expect(result.matchedPatterns).to.deep.equal(['pattern-1', 'pattern-2']); }); @@ -88,13 +91,13 @@ describe('discovery/detector', () => { const detector = new WorkspaceTypeDetector('/test/path', { patterns: [ createMockPattern('error-pattern', 'pwa-kit-v3', new Error('Test error')), - createMockPattern('good-pattern', 'sfra', true), + createMockPattern('good-pattern', 'cartridges', true), ], }); const result = await detector.detect(); - expect(result.projectTypes).to.deep.equal(['sfra']); + expect(result.projectTypes).to.deep.equal(['cartridges']); expect(result.matchedPatterns).to.deep.equal(['good-pattern']); }); @@ -102,14 +105,14 @@ describe('discovery/detector', () => { const detector = new WorkspaceTypeDetector('/test/path', { patterns: [ createMockPattern('first', 'pwa-kit-v3', true), - createMockPattern('second', 'sfra', true), - createMockPattern('third', 'custom-api', true), + createMockPattern('second', 'storefront-next', true), + createMockPattern('third', 'cartridges', true), ], }); const result = await detector.detect(); - expect(result.projectTypes).to.deep.equal(['pwa-kit-v3', 'sfra', 'custom-api']); + expect(result.projectTypes).to.deep.equal(['pwa-kit-v3', 'storefront-next', 'cartridges']); expect(result.matchedPatterns).to.deep.equal(['first', 'second', 'third']); }); }); @@ -127,7 +130,7 @@ describe('discovery/detector', () => { }); it('adds additional patterns to defaults', async () => { - const additionalPattern = createMockPattern('additional', 'custom-api', true); + const additionalPattern = createMockPattern('additional', 'cartridges', true); const detector = new WorkspaceTypeDetector('/nonexistent/path', { additionalPatterns: [additionalPattern], }); @@ -140,7 +143,10 @@ describe('discovery/detector', () => { it('excludes patterns by name', async () => { const detector = new WorkspaceTypeDetector('/test/path', { - patterns: [createMockPattern('keep-me', 'pwa-kit-v3', true), createMockPattern('exclude-me', 'sfra', true)], + patterns: [ + createMockPattern('keep-me', 'pwa-kit-v3', true), + createMockPattern('exclude-me', 'cartridges', true), + ], excludePatterns: ['exclude-me'], }); @@ -152,8 +158,11 @@ describe('discovery/detector', () => { it('combines additionalPatterns and excludePatterns', async () => { const detector = new WorkspaceTypeDetector('/test/path', { - patterns: [createMockPattern('base-1', 'pwa-kit-v3', true), createMockPattern('base-2', 'sfra', true)], - additionalPatterns: [createMockPattern('added', 'custom-api', true)], + patterns: [ + createMockPattern('base-1', 'pwa-kit-v3', true), + createMockPattern('base-2', 'storefront-next', true), + ], + additionalPatterns: [createMockPattern('added', 'cartridges', true)], excludePatterns: ['base-2'], }); diff --git a/packages/b2c-tooling-sdk/test/discovery/patterns/base.test.ts b/packages/b2c-tooling-sdk/test/discovery/patterns/base.test.ts deleted file mode 100644 index dc8c451e..00000000 --- a/packages/b2c-tooling-sdk/test/discovery/patterns/base.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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 * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import * as os from 'node:os'; -import {dwJsonPattern} from '@salesforce/b2c-tooling-sdk/discovery'; - -describe('discovery/patterns/base', () => { - let tempDir: string; - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'b2c-pattern-test-')); - }); - - afterEach(async () => { - await fs.rm(tempDir, {recursive: true, force: true}); - }); - - describe('dwJsonPattern', () => { - it('has correct metadata', () => { - expect(dwJsonPattern.name).to.equal('dw-json'); - expect(dwJsonPattern.projectType).to.equal('headless'); - }); - - it('detects dw.json file', async () => { - await fs.writeFile(path.join(tempDir, 'dw.json'), '{}'); - - const result = await dwJsonPattern.detect(tempDir); - - expect(result).to.be.true; - }); - - it('returns false without dw.json', async () => { - const result = await dwJsonPattern.detect(tempDir); - - expect(result).to.be.false; - }); - }); -}); diff --git a/packages/b2c-tooling-sdk/test/discovery/patterns/cartridges.test.ts b/packages/b2c-tooling-sdk/test/discovery/patterns/cartridges.test.ts new file mode 100644 index 00000000..168f5b12 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/discovery/patterns/cartridges.test.ts @@ -0,0 +1,75 @@ +/* + * 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 * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import {cartridgesPattern} from '@salesforce/b2c-tooling-sdk/discovery'; + +describe('discovery/patterns/cartridges', () => { + let tempDir: string; + + beforeEach(() => { + // Create a temp directory for each test + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cartridges-test-')); + }); + + afterEach(() => { + // Clean up temp directory + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + describe('cartridgesPattern', () => { + it('should have correct metadata', () => { + expect(cartridgesPattern.name).to.equal('cartridges'); + expect(cartridgesPattern.projectType).to.equal('cartridges'); + }); + + it('should detect cartridge with .project file', async () => { + // Create a cartridge with .project file + const cartridgeDir = path.join(tempDir, 'app_storefront_base'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), ''); + + const result = await cartridgesPattern.detect(tempDir); + expect(result).to.equal(true); + }); + + it('should detect nested cartridges', async () => { + // Create cartridges in a nested structure + const cartridgesDir = path.join(tempDir, 'cartridges', 'app_custom'); + fs.mkdirSync(cartridgesDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgesDir, '.project'), ''); + + const result = await cartridgesPattern.detect(tempDir); + expect(result).to.equal(true); + }); + + it('should return false when no cartridges found', async () => { + // Empty directory - no cartridges + const result = await cartridgesPattern.detect(tempDir); + expect(result).to.equal(false); + }); + + it('should return false for non-existent path', async () => { + const result = await cartridgesPattern.detect('/nonexistent/path/12345'); + expect(result).to.equal(false); + }); + + it('should detect multiple cartridges', async () => { + // Create multiple cartridges + const cartridge1 = path.join(tempDir, 'cartridges', 'app_custom'); + const cartridge2 = path.join(tempDir, 'cartridges', 'int_payment'); + fs.mkdirSync(cartridge1, {recursive: true}); + fs.mkdirSync(cartridge2, {recursive: true}); + fs.writeFileSync(path.join(cartridge1, '.project'), ''); + fs.writeFileSync(path.join(cartridge2, '.project'), ''); + + const result = await cartridgesPattern.detect(tempDir); + expect(result).to.equal(true); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/discovery/patterns/custom-api.test.ts b/packages/b2c-tooling-sdk/test/discovery/patterns/custom-api.test.ts deleted file mode 100644 index 5f08864e..00000000 --- a/packages/b2c-tooling-sdk/test/discovery/patterns/custom-api.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * 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 * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import * as os from 'node:os'; -import {customApiPattern} from '@salesforce/b2c-tooling-sdk/discovery'; - -describe('discovery/patterns/custom-api', () => { - let tempDir: string; - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'b2c-pattern-test-')); - }); - - afterEach(async () => { - await fs.rm(tempDir, {recursive: true, force: true}); - }); - - describe('customApiPattern', () => { - it('has correct metadata', () => { - expect(customApiPattern.name).to.equal('custom-api'); - expect(customApiPattern.projectType).to.equal('custom-api'); - }); - - it('detects api.json in rest-apis structure', async () => { - const apiPath = path.join(tempDir, 'cartridges', 'app_custom', 'cartridge', 'rest-apis', 'my-api'); - await fs.mkdir(apiPath, {recursive: true}); - await fs.writeFile(path.join(apiPath, 'api.json'), '{}'); - - const result = await customApiPattern.detect(tempDir); - - expect(result).to.be.true; - }); - - it('detects schema.yaml in rest-apis structure', async () => { - const apiPath = path.join(tempDir, 'cartridges', 'app_custom', 'cartridge', 'rest-apis', 'my-api'); - await fs.mkdir(apiPath, {recursive: true}); - await fs.writeFile(path.join(apiPath, 'schema.yaml'), 'openapi: 3.0.0'); - - const result = await customApiPattern.detect(tempDir); - - expect(result).to.be.true; - }); - - it('returns false without rest-apis structure', async () => { - const cartridgePath = path.join(tempDir, 'cartridges', 'app_custom', 'cartridge'); - await fs.mkdir(cartridgePath, {recursive: true}); - await fs.writeFile(path.join(cartridgePath, 'api.json'), '{}'); - - const result = await customApiPattern.detect(tempDir); - - expect(result).to.be.false; - }); - - it('returns false in empty directory', async () => { - const result = await customApiPattern.detect(tempDir); - - expect(result).to.be.false; - }); - }); -}); diff --git a/packages/b2c-tooling-sdk/test/discovery/patterns/sfra.test.ts b/packages/b2c-tooling-sdk/test/discovery/patterns/sfra.test.ts index b4c71c10..e8785f43 100644 --- a/packages/b2c-tooling-sdk/test/discovery/patterns/sfra.test.ts +++ b/packages/b2c-tooling-sdk/test/discovery/patterns/sfra.test.ts @@ -4,73 +4,138 @@ * 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 * as fs from 'node:fs/promises'; import * as path from 'node:path'; +import * as fs from 'node:fs'; import * as os from 'node:os'; import {sfraPattern} from '@salesforce/b2c-tooling-sdk/discovery'; describe('discovery/patterns/sfra', () => { let tempDir: string; - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'b2c-pattern-test-')); + beforeEach(() => { + // Create a temp directory for each test + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sfra-test-')); }); - afterEach(async () => { - await fs.rm(tempDir, {recursive: true, force: true}); + afterEach(() => { + // Clean up temp directory + fs.rmSync(tempDir, {recursive: true, force: true}); }); describe('sfraPattern', () => { - it('has correct metadata', () => { - expect(sfraPattern.name).to.equal('sfra-cartridge'); - expect(sfraPattern.projectType).to.equal('sfra'); + it('should have correct metadata', () => { + expect(sfraPattern.name).to.equal('sfra'); + expect(sfraPattern.projectType).to.equal('cartridges'); }); - it('detects cartridges with controllers at root', async () => { - const controllerPath = path.join(tempDir, 'cartridges', 'app_custom', 'cartridge', 'controllers'); - await fs.mkdir(controllerPath, {recursive: true}); - await fs.writeFile(path.join(controllerPath, 'Home.js'), ''); - - const result = await sfraPattern.detect(tempDir); - - expect(result).to.be.true; + describe('app_storefront_base cartridge detection', () => { + it('should detect SFRA project with app_storefront_base cartridge', async () => { + // Create app_storefront_base cartridge with .project file + const cartridgeDir = path.join(tempDir, 'cartridges', 'app_storefront_base'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), ''); + + const result = await sfraPattern.detect(tempDir); + expect(result).to.equal(true); + }); + + it('should detect SFRA project with app_storefront_base at root level', async () => { + // Create app_storefront_base cartridge at root level with .project file + const cartridgeDir = path.join(tempDir, 'app_storefront_base'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), ''); + + const result = await sfraPattern.detect(tempDir); + expect(result).to.equal(true); + }); + + it('should return false for other cartridges without app_storefront_base', async () => { + // Create a different cartridge (not app_storefront_base) + const cartridgeDir = path.join(tempDir, 'cartridges', 'plugin_cart'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), ''); + + const result = await sfraPattern.detect(tempDir); + expect(result).to.equal(false); + }); }); - it('detects cartridges with templates at root', async () => { - const templatePath = path.join(tempDir, 'cartridges', 'app_custom', 'cartridge', 'templates', 'default'); - await fs.mkdir(templatePath, {recursive: true}); - await fs.writeFile(path.join(templatePath, 'home.isml'), ''); - - const result = await sfraPattern.detect(tempDir); - - expect(result).to.be.true; + describe('package.json paths.base detection', () => { + it('should detect SFRA project with paths.base in package.json', async () => { + // Create package.json with paths.base pointing to app_storefront_base + const packageJson = { + name: 'my-sfra-project', + paths: { + base: '../sfra/cartridges/app_storefront_base/cartridge', + }, + }; + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify(packageJson, null, 2)); + + const result = await sfraPattern.detect(tempDir); + expect(result).to.equal(true); + }); + + it('should detect SFRA with absolute path to app_storefront_base', async () => { + const packageJson = { + name: 'my-sfra-project', + paths: { + base: '/Users/dev/sfra/cartridges/app_storefront_base/cartridge', + }, + }; + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify(packageJson, null, 2)); + + const result = await sfraPattern.detect(tempDir); + expect(result).to.equal(true); + }); + + it('should return false if paths.base does not contain app_storefront_base', async () => { + const packageJson = { + name: 'my-project', + paths: { + base: '../other_cartridge/cartridge', + }, + }; + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify(packageJson, null, 2)); + + const result = await sfraPattern.detect(tempDir); + expect(result).to.equal(false); + }); + + it('should return false if package.json has no paths field', async () => { + const packageJson = { + name: 'my-project', + dependencies: {}, + }; + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify(packageJson, null, 2)); + + const result = await sfraPattern.detect(tempDir); + expect(result).to.equal(false); + }); }); - it('detects cartridges nested in subdirectories (monorepo)', async () => { - // Structure: app_sfrademo/cartridges/app_custom/cartridge/controllers/ - const controllerPath = path.join(tempDir, 'app_sfrademo', 'cartridges', 'app_custom', 'cartridge', 'controllers'); - await fs.mkdir(controllerPath, {recursive: true}); - await fs.writeFile(path.join(controllerPath, 'Home.js'), ''); - - const result = await sfraPattern.detect(tempDir); - - expect(result).to.be.true; - }); - - it('returns false with empty directory', async () => { - const result = await sfraPattern.detect(tempDir); - - expect(result).to.be.false; - }); - - it('returns false with cartridge folder but no controllers or templates', async () => { - const cartridgePath = path.join(tempDir, 'cartridges', 'app_custom', 'cartridge'); - await fs.mkdir(cartridgePath, {recursive: true}); - await fs.writeFile(path.join(cartridgePath, 'readme.txt'), ''); - - const result = await sfraPattern.detect(tempDir); - - expect(result).to.be.false; + describe('edge cases', () => { + it('should return false for empty directory', async () => { + const result = await sfraPattern.detect(tempDir); + expect(result).to.equal(false); + }); + + it('should return false for non-existent path', async () => { + const result = await sfraPattern.detect('/nonexistent/path/12345'); + expect(result).to.equal(false); + }); + + it('should prioritize cartridge detection over package.json', async () => { + // Create both: app_storefront_base cartridge and package.json without paths.base + const cartridgeDir = path.join(tempDir, 'app_storefront_base'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), ''); + + const packageJson = {name: 'my-project'}; + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify(packageJson, null, 2)); + + const result = await sfraPattern.detect(tempDir); + expect(result).to.equal(true); + }); }); }); });