diff --git a/.changeset/scaffolding-framework.md b/.changeset/scaffolding-framework.md new file mode 100644 index 00000000..74f33d49 --- /dev/null +++ b/.changeset/scaffolding-framework.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-tooling-sdk': minor +--- + +Add scaffolding framework for generating B2C Commerce components from templates. Includes 7 built-in scaffolds (cartridge, controller, hook, service, custom-api, job-step, page-designer-component) and support for custom project/user scaffolds. SDK provides programmatic API for IDE integrations and MCP servers. diff --git a/.gitignore b/.gitignore index afe61566..3178a969 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ dw.json dw.json* .env .config/wt.toml +.b2c/ diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 6c1e59e1..ee6a264b 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -15,6 +15,7 @@ const guideSidebar = [ text: 'Guides', items: [ { text: 'Authentication Setup', link: '/guide/authentication' }, + { text: 'Scaffolding', link: '/guide/scaffolding' }, { text: 'IDE Support', link: '/guide/ide-support' }, { text: 'Security', link: '/guide/security' }, ], @@ -42,6 +43,7 @@ const guideSidebar = [ { text: 'Custom APIs', link: '/cli/custom-apis' }, { text: 'SCAPI Schemas', link: '/cli/scapi-schemas' }, { text: 'Setup Commands', link: '/cli/setup' }, + { text: 'Scaffold Commands', link: '/cli/scaffold' }, { text: 'Auth Commands', link: '/cli/auth' }, { text: 'Logging', link: '/cli/logging' }, ], diff --git a/docs/api-readme.md b/docs/api-readme.md index 237e747d..43ea30ff 100644 --- a/docs/api-readme.md +++ b/docs/api-readme.md @@ -29,6 +29,7 @@ The SDK is organized into focused submodules that can be imported individually: ├── /operations/mrt # Managed Runtime bundle operations ├── /operations/ods # On-demand sandbox utilities │ +├── /scaffold # Scaffold discovery, generation, and validation ├── /docs # B2C Script API documentation search └── /schemas # OpenAPI schema utilities ``` diff --git a/docs/cli/index.md b/docs/cli/index.md index db9aa3b4..916f9527 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -49,6 +49,10 @@ These flags are available on all commands that interact with B2C instances: - [SLAS Commands](./slas) - Manage Shopper Login and Access Service (SLAS) API clients - [Custom APIs](./custom-apis) - SCAPI Custom API endpoint status +### Development + +- [Scaffold Commands](./scaffold) - Generate cartridges, controllers, hooks, and more from templates + ### Account Management - [Account Manager Commands](./account-manager) - Manage Account Manager users, roles, and organizations diff --git a/docs/cli/scaffold.md b/docs/cli/scaffold.md new file mode 100644 index 00000000..0b6f3c48 --- /dev/null +++ b/docs/cli/scaffold.md @@ -0,0 +1,268 @@ +--- +description: B2C CLI scaffold commands for generating cartridges, controllers, hooks, custom APIs, and other B2C Commerce components from templates. +--- + +# Scaffold Commands + +The `b2c scaffold` commands help you generate B2C Commerce components from templates (scaffolds). Built-in scaffolds include cartridges, controllers, hooks, custom APIs, job steps, and Page Designer components. + +## Commands Overview + +| Command | Description | +|---------|-------------| +| `b2c scaffold list` | List available scaffolds | +| `b2c scaffold generate ` | Generate files from a scaffold | +| `b2c scaffold info ` | Show scaffold details and parameters | +| `b2c scaffold search ` | Search scaffolds by name/tags | +| `b2c scaffold init ` | Create a custom scaffold | +| `b2c scaffold validate ` | Validate a scaffold manifest | + +## b2c scaffold list + +List available project scaffolds with optional filtering. + +### Usage + +```bash +b2c scaffold list [--category ] [--source ] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--category`, `-c` | Filter by category: `cartridge`, `custom-api`, `page-designer`, `job`, `metadata` | +| `--source`, `-s` | Filter by source: `built-in`, `user`, `project`, `plugin` | +| `--columns` | Columns to display (comma-separated) | +| `--extended`, `-x` | Show all columns including description and tags | + +### Output + +Default columns: `id`, `displayName`, `category`, `source` + +Extended columns (with `-x`): adds `description`, `tags`, `path` + +### Examples + +```bash +# list all available scaffolds +b2c scaffold list + +# list only cartridge scaffolds +b2c scaffold list --category cartridge + +# list project-local scaffolds +b2c scaffold list --source project + +# show extended information +b2c scaffold list -x +``` + +## b2c scaffold generate + +Generate files from a scaffold template. You can also use the shorthand `b2c scaffold `. + +### Usage + +```bash +b2c scaffold generate [--name ] [--option key=value] [--output ] +b2c scaffold # shorthand +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `scaffoldId` | ID of the scaffold to generate (required) | + +### Flags + +| Flag | Description | +|------|-------------| +| `--name`, `-n` | Primary name parameter (shorthand for `--option name=VALUE`) | +| `--output`, `-o` | Output directory (defaults to scaffold default or current directory) | +| `--option` | Parameter value in `key=value` format (repeatable) | +| `--dry-run` | Preview files without writing them | +| `--force`, `-f` | Skip prompts, use defaults, and overwrite existing files | + +### Examples + +```bash +# generate a cartridge interactively +b2c scaffold generate cartridge + +# generate with name specified +b2c scaffold cartridge --name app_custom + +# generate with multiple options +b2c scaffold generate controller --option controllerName=Account --option cartridgeName=app_custom + +# preview what would be generated +b2c scaffold generate cartridge --name app_custom --dry-run + +# skip all prompts and use defaults +b2c scaffold generate cartridge --name app_custom --force + +# generate to a specific directory +b2c scaffold generate cartridge --name app_custom --output ./src/cartridges +``` + +## b2c scaffold info + +Show detailed information about a scaffold including its parameters and usage. + +### Usage + +```bash +b2c scaffold info +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `scaffoldId` | ID of the scaffold to get info for (required) | + +### Output + +Displays: +- Scaffold ID, category, source, and description +- Tags (if any) +- Parameters with types, requirements, defaults, and conditions +- Usage example with required parameters +- Post-generation instructions (if any) + +### Examples + +```bash +# show info for the cartridge scaffold +b2c scaffold info cartridge + +# show info for the controller scaffold +b2c scaffold info controller +``` + +## b2c scaffold search + +Search for scaffolds by name, description, or tags. + +### Usage + +```bash +b2c scaffold search [--category ] +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `query` | Search query (required) | + +### Flags + +| Flag | Description | +|------|-------------| +| `--category`, `-c` | Filter results by category | + +### Examples + +```bash +# search for scaffolds related to API +b2c scaffold search api + +# search within a specific category +b2c scaffold search template --category page-designer +``` + +## b2c scaffold init + +Create a new custom scaffold template. + +### Usage + +```bash +b2c scaffold init [name] [--project | --user | --output ] +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `name` | Name for the new scaffold (kebab-case, optional - prompts if not provided) | + +### Flags + +| Flag | Description | +|------|-------------| +| `--project` | Create in project scaffolds directory (`.b2c/scaffolds/`) | +| `--user` | Create in user scaffolds directory (`~/.b2c/scaffolds/`) | +| `--output`, `-o` | Custom output directory for the scaffold | +| `--force`, `-f` | Overwrite existing scaffold if it exists | + +### Examples + +```bash +# create a project-local scaffold interactively +b2c scaffold init --project + +# create a user scaffold with a specific name +b2c scaffold init my-component --user + +# create a scaffold in a custom directory +b2c scaffold init my-scaffold --output ./custom-scaffolds +``` + +## b2c scaffold validate + +Validate a custom scaffold manifest and templates. + +### Usage + +```bash +b2c scaffold validate [--strict] +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `path` | Path to the scaffold directory (required) | + +### Flags + +| Flag | Description | +|------|-------------| +| `--strict` | Treat warnings as errors | + +### Validation Checks + +- Manifest structure (scaffold.json) +- Required fields and types +- Parameter definitions and validation patterns +- Template file existence +- Orphaned template files +- EJS syntax in templates + +### Examples + +```bash +# validate a custom scaffold +b2c scaffold validate ./.b2c/scaffolds/my-scaffold + +# validate with strict mode +b2c scaffold validate ./my-scaffold --strict +``` + +## Built-in Scaffolds + +| Scaffold | Category | Description | +|----------|----------|-------------| +| `cartridge` | cartridge | B2C cartridge with standard directory structure | +| `controller` | cartridge | SFRA controller with route handlers and middleware | +| `hook` | cartridge | Hook implementation with hooks.json registration | +| `service` | cartridge | B2C web service using LocalServiceRegistry | +| `custom-api` | cartridge | Custom SCAPI endpoint with OAS 3.0 schema | +| `job-step` | cartridge | Custom job step with steptypes.json registration | +| `page-designer-component` | cartridge | Page Designer component with meta/script/template | + +Use `b2c scaffold info ` to see the parameters for each scaffold. diff --git a/docs/guide/extending.md b/docs/guide/extending.md index 21439b09..817589ce 100644 --- a/docs/guide/extending.md +++ b/docs/guide/extending.md @@ -14,6 +14,7 @@ The B2C CLI can be extended with custom plugins using the [oclif plugin system]( | [`b2c:http-middleware`](#http-middleware) | HTTP request/response middleware | Yes | | [`b2c:operation-lifecycle`](#operation-lifecycle-hooks) | Operation before/after callbacks | CLI only | | [`b2c:cartridge-providers`](#cartridge-providers) | Custom cartridge discovery | CLI only | +| [`b2c:scaffold-providers`](#scaffold-providers) | Custom scaffold providers | Yes | **SDK Support** indicates whether the hook can be used programmatically without the CLI. Only HTTP middleware supports direct SDK registration via `globalMiddlewareRegistry`. @@ -714,6 +715,133 @@ const envFilterTransformer: CartridgeTransformer = { }; ``` +## Scaffold Providers + +Plugins can provide custom scaffolds via the `b2c:scaffold-providers` hook, or register providers programmatically via the SDK's `scaffoldRegistry`. + +### Hook: `b2c:scaffold-providers` + +This hook is called during scaffold command initialization. Providers and transformers are collected and used during scaffold discovery. + +**Hook Options:** + +| Property | Type | Description | +|----------|------|-------------| +| `flags` | `Record` | Parsed CLI flags (read-only) | + +**Hook Result:** + +| Property | Type | Description | +|----------|------|-------------| +| `providers` | `ScaffoldProvider[]` | Scaffold discovery providers | +| `transformers` | `ScaffoldTransformer[]` | Scaffold mapping transformers | + +### ScaffoldProvider Interface + +```typescript +import type { + ScaffoldProvider, + ScaffoldDiscoveryOptions, + Scaffold, +} from '@salesforce/b2c-tooling-sdk/scaffold'; + +const provider: ScaffoldProvider = { + name: 'my-scaffold-provider', + priority: 'after', // 'before' or 'after' default discovery + + async getScaffolds(options: ScaffoldDiscoveryOptions): Promise { + // Return scaffolds from custom source + return [ + { + id: 'my-custom-scaffold', + displayName: 'My Custom Scaffold', + description: 'A custom scaffold from my plugin', + category: 'cartridge', + source: 'plugin', + path: '/path/to/scaffold', + manifest: { /* scaffold.json contents */ }, + }, + ]; + }, +}; +``` + +**Priority:** +- `'before'` - Runs before default discovery (can be overridden by built-in/user/project scaffolds) +- `'after'` - Runs after default discovery (overrides scaffolds with same ID) + +Scaffolds are deduplicated by ID (last provider wins for same ID). + +### ScaffoldTransformer Interface + +Transformers modify the final scaffold list after all providers have contributed: + +```typescript +import type { ScaffoldTransformer, Scaffold } from '@salesforce/b2c-tooling-sdk/scaffold'; + +const transformer: ScaffoldTransformer = { + name: 'scaffold-filter', + + async transform(scaffolds: Scaffold[], options): Promise { + // Filter or modify scaffolds + return scaffolds.filter(s => !s.id.startsWith('internal-')); + }, +}; +``` + +### SDK Usage (without CLI) + +For programmatic SDK usage without the CLI, register providers directly with the scaffold registry: + +```typescript +import { scaffoldRegistry } from '@salesforce/b2c-tooling-sdk/scaffold'; + +// Add a custom provider +scaffoldRegistry.addProviders([{ + name: 'my-provider', + priority: 'after', + async getScaffolds(options) { + return [/* scaffolds */]; + }, +}]); + +// Add a transformer +scaffoldRegistry.addTransformers([{ + name: 'my-transformer', + async transform(scaffolds, options) { + return scaffolds; + }, +}]); +``` + +### Example: Plugin Hook Implementation + +```typescript +// src/hooks/scaffold-providers.ts +import type { ScaffoldProvidersHook } from '@salesforce/b2c-tooling-sdk/cli'; +import type { ScaffoldProvider } from '@salesforce/b2c-tooling-sdk/scaffold'; + +const hook: ScaffoldProvidersHook = async function(options) { + const customProvider: ScaffoldProvider = { + name: 'my-plugin-scaffolds', + priority: 'after', + + async getScaffolds(discoveryOptions) { + // Load scaffolds from plugin's bundled templates + const scaffoldsDir = new URL('../scaffolds', import.meta.url).pathname; + // ... load and return scaffolds + return []; + }, + }; + + return { providers: [customProvider] }; +}; + +export default hook; +``` + +See [Scaffolding Guide](./scaffolding) for details on creating custom scaffolds. + ## Adding Custom Commands Extend the B2C base command classes to create commands with built-in configuration and authentication: diff --git a/docs/guide/scaffolding.md b/docs/guide/scaffolding.md new file mode 100644 index 00000000..a43a3340 --- /dev/null +++ b/docs/guide/scaffolding.md @@ -0,0 +1,599 @@ +--- +description: Generate B2C Commerce cartridges, controllers, hooks, custom APIs, and more from templates using the scaffolding framework. +--- + +# Scaffolding + +The B2C CLI includes a scaffolding framework for generating B2C Commerce components from templates. Scaffolds provide consistent project structure and reduce boilerplate when creating cartridges, controllers, hooks, custom APIs, job steps, and Page Designer components. + +## Quick Start + +```bash +# list available scaffolds +b2c scaffold list + +# generate a new cartridge +b2c scaffold cartridge --name app_custom + +# preview without creating files +b2c scaffold cartridge --name app_custom --dry-run +``` + +## Built-in Scaffolds + +The CLI includes scaffolds for common B2C development tasks: + +| Scaffold | Category | Description | +|----------|----------|-------------| +| `cartridge` | cartridge | Complete B2C cartridge with standard directory structure | +| `controller` | cartridge | SFRA controller with route handlers and middleware | +| `hook` | cartridge | Hook implementation with hooks.json registration | +| `service` | cartridge | B2C web service using LocalServiceRegistry | +| `custom-api` | cartridge | Custom SCAPI endpoint with OAS 3.0 schema | +| `job-step` | cartridge | Custom job step with steptypes.json registration | +| `page-designer-component` | cartridge | Page Designer component with meta/script/template | + +## Using Scaffolds + +### Interactive Mode + +By default, scaffolds prompt for required parameters: + +```bash +b2c scaffold generate cartridge +# ? Cartridge name: app_custom +# ? Include controllers? Yes +# ... +``` + +### Non-Interactive Mode + +Use `--force` to skip prompts and use default values: + +```bash +b2c scaffold generate cartridge --name app_custom --force +``` + +### Providing Parameters + +Pass parameters with `--option`: + +```bash +b2c scaffold generate controller \ + --option controllerName=Account \ + --option cartridgeName=app_custom \ + --option routes=Show,Submit +``` + +The `--name` flag is a shorthand for the primary name parameter: + +```bash +# these are equivalent +b2c scaffold cartridge --name app_custom +b2c scaffold cartridge --option cartridgeName=app_custom +``` + +### Preview Changes + +Use `--dry-run` to see what files would be created without writing them: + +```bash +b2c scaffold generate controller --name Account --dry-run +# Would create: cartridges/app_custom/cartridge/controllers/Account.js +# Would create: cartridges/app_custom/cartridge/templates/default/account/account.isml +``` + +### Output Directory + +By default, scaffolds generate files relative to the current directory. Use `--output` to specify a different location: + +```bash +b2c scaffold generate cartridge --name app_custom --output ./src +``` + +Some scaffolds have default output directories (e.g., `cartridge` defaults to `cartridges/`). + +## Scaffold Details + +### cartridge + +Creates a complete B2C cartridge with standard SFRA directory structure. + +**Parameters:** +- `cartridgeName` (required) - Cartridge name (e.g., `app_custom`) +- `includeControllers` - Include controllers directory (default: true) +- `includeModels` - Include models directory (default: true) +- `includeScripts` - Include scripts directory (default: true) +- `includeTemplates` - Include templates directory (default: true) +- `includeStatic` - Include static directory (default: false) + +```bash +b2c scaffold cartridge --name app_custom +``` + +### controller + +Creates an SFRA controller with route handlers and optional middleware. + +**Parameters:** +- `controllerName` (required) - Controller name in PascalCase (e.g., `Account`) +- `cartridgeName` (required) - Target cartridge (auto-discovered from project) +- `routes` (required) - Route handlers to create: Show, Submit, JSON, SubmitJSON +- `useMiddleware` - Include middleware guards (default: true) +- `includeTemplate` - Create corresponding ISML template (default: true) + +```bash +b2c scaffold controller \ + --option controllerName=Account \ + --option cartridgeName=app_custom \ + --option routes=Show,Submit +``` + +### hook + +Creates a hook implementation with automatic hooks.json registration. + +**Parameters:** +- `hookName` (required) - Hook function name in camelCase +- `hookType` (required) - Hook type: ocapi, scapi, or system +- `hookPoint` (required) - Extension point (auto-discovered list) +- `cartridgeName` (required) - Target cartridge + +```bash +b2c scaffold hook \ + --option hookName=validateBasket \ + --option hookType=ocapi \ + --option hookPoint=dw.ocapi.shop.basket.beforePOST \ + --option cartridgeName=app_custom +``` + +### service + +Creates a B2C Commerce web service using LocalServiceRegistry. + +**Parameters:** +- `serviceName` (required) - Service name in PascalCase (e.g., `PaymentGateway`) +- `cartridgeName` (required) - Target cartridge (auto-discovered from project) +- `serviceType` (required) - Service type: HTTP, SOAP, SFTP (default: HTTP) +- `authType` - Authentication method: NONE, BASIC, BEARER, API_KEY (default: NONE, HTTP only) +- `includeErrorHandling` - Include robust error handling (default: true) +- `includeMocking` - Include mock callback for testing (default: false) + +```bash +b2c scaffold service \ + --option serviceName=PaymentGateway \ + --option cartridgeName=app_custom \ + --option serviceType=HTTP \ + --option authType=BASIC +``` + +### custom-api + +Creates a Custom SCAPI endpoint with OAS 3.0 schema. + +**Parameters:** +- `apiName` (required) - API name in kebab-case (e.g., `my-api`) +- `apiType` (required) - API type: shopper or admin (default: shopper) +- `apiDescription` - API description +- `cartridgeName` (required) - Target cartridge +- `includeExampleEndpoints` - Include example endpoints (default: true) + +```bash +b2c scaffold custom-api \ + --option apiName=loyalty-points \ + --option apiType=shopper \ + --option cartridgeName=app_custom +``` + +### job-step + +Creates a custom job step with steptypes.json registration. + +**Parameters:** +- `stepId` (required) - Step ID (e.g., `custom.ImportProducts`) +- `stepType` (required) - Step type: task or chunk (default: task) +- `stepDescription` - Step description +- `cartridgeName` (required) - Target cartridge + +```bash +b2c scaffold job-step \ + --option stepId=custom.ImportProducts \ + --option stepType=chunk \ + --option cartridgeName=app_custom +``` + +### page-designer-component + +Creates a Page Designer component with meta JSON, script, and template. + +**Parameters:** +- `componentId` (required) - Component ID in camelCase +- `componentName` (required) - Display name +- `componentGroup` (required) - Component group: content, commerce, layouts, custom +- `hasRegions` - Support nested components (default: false) +- `cartridgeName` (required) - Target cartridge + +```bash +b2c scaffold page-designer-component \ + --option componentId=heroCarousel \ + --option componentName="Hero Carousel" \ + --option componentGroup=content \ + --option cartridgeName=app_custom +``` + +## Creating Custom Scaffolds + +You can create your own scaffolds for project-specific patterns. + +### Scaffold Locations + +Scaffolds are discovered from multiple locations (later sources override earlier ones with the same ID): + +1. **Built-in** - Included with the CLI +2. **User** - `~/.b2c/scaffolds/` (personal scaffolds) +3. **Project** - `.b2c/scaffolds/` (project-specific scaffolds) +4. **Plugin** - Provided by installed plugins + +### Initialize a New Scaffold + +Use `scaffold init` to create the scaffold structure: + +```bash +# create a project-local scaffold +b2c scaffold init my-component --project + +# create a user scaffold +b2c scaffold init my-component --user +``` + +This creates: +``` +.b2c/scaffolds/my-component/ +├── scaffold.json # Manifest defining parameters and files +└── files/ + └── example.txt.ejs # Template file +``` + +### Scaffold Manifest + +The `scaffold.json` manifest defines the scaffold: + +```json +{ + "name": "my-component", + "displayName": "My Component", + "description": "Creates a custom component", + "category": "cartridge", + "version": "1.0", + "tags": ["component", "custom"], + "defaultOutputDir": "cartridges", + "parameters": [ + { + "name": "componentName", + "prompt": "Component name", + "type": "string", + "required": true, + "pattern": "^[A-Z][a-zA-Z0-9]*$", + "validationMessage": "Must be PascalCase" + }, + { + "name": "cartridgeName", + "prompt": "Target cartridge", + "type": "string", + "required": true, + "source": "cartridges" + }, + { + "name": "includeTests", + "prompt": "Include test files?", + "type": "boolean", + "required": false, + "default": true + } + ], + "files": [ + { + "template": "component.js.ejs", + "destination": "{{cartridgeName}}/cartridge/scripts/{{kebabCase componentName}}.js" + }, + { + "template": "test.js.ejs", + "destination": "{{cartridgeName}}/test/{{kebabCase componentName}}.test.js", + "condition": "includeTests" + } + ], + "postInstructions": "Component created! Add to your cartridge path." +} +``` + +### Parameter Types + +| Type | Description | Options | +|------|-------------|---------| +| `string` | Text input | `pattern`, `validationMessage` | +| `boolean` | Yes/no choice | `default` | +| `choice` | Single selection | `choices` array | +| `multi-choice` | Multiple selections | `choices` array | + +#### Dynamic Sources + +Parameters can be populated from dynamic sources: + +```json +{ + "name": "cartridgeName", + "prompt": "Select cartridge", + "type": "choice", + "source": "cartridges" +} +``` + +Available sources: +- `cartridges` - Discovers cartridges in the project via `.project` files +- `hook-points` - Common B2C hook extension points +- `sites` - Sites from the connected B2C instance (requires authentication) + +#### Conditional Parameters + +Show parameters based on other parameter values: + +```json +{ + "name": "hookPoint", + "prompt": "Select hook point", + "type": "choice", + "source": "hook-points", + "when": "hookType=system" +} +``` + +### Template Files + +Template files use [EJS](https://ejs.co/) syntax and are stored in the `files/` directory: + +```javascript +// files/component.js.ejs +'use strict'; + +/** + * <%= componentName %> component + * Created: <%= date %> + */ + +var Component = { + name: '<%= kebabCase(componentName) %>', + + <% if (includeLogging) { %> + log: function(message) { + require('dw/system/Logger').info(message); + }, + <% } %> + + execute: function() { + // Implementation + } +}; + +module.exports = Component; +``` + +### Template Helpers + +These helpers are available in templates and path patterns: + +| Helper | Description | Example | +|--------|-------------|---------| +| `kebabCase(str)` | Convert to kebab-case | `my-component` | +| `camelCase(str)` | Convert to camelCase | `myComponent` | +| `pascalCase(str)` | Convert to PascalCase | `MyComponent` | +| `snakeCase(str)` | Convert to snake_case | `my_component` | +| `year` | Current year | `2025` | +| `date` | Current date (YYYY-MM-DD) | `2025-02-01` | +| `uuid()` | Generate a UUID v4 | `550e8400-e29b-41d4-a716-446655440000` | + +### File Mappings + +The `files` array maps templates to output paths: + +```json +{ + "files": [ + { + "template": "controller.js.ejs", + "destination": "{{cartridgeName}}/cartridge/controllers/{{controllerName}}.js", + "condition": "includeControllers", + "overwrite": "prompt" + } + ] +} +``` + +| Property | Description | +|----------|-------------| +| `template` | Path to template in `files/` directory | +| `destination` | Output path with `{{variable}}` substitution | +| `condition` | Conditional expression (optional) | +| `overwrite` | Behavior if file exists: `never`, `always`, `prompt`, `merge` | + +Path templates support the same helpers: + +```json +{ + "destination": "{{cartridgeName}}/cartridge/scripts/{{kebabCase componentName}}.js" +} +``` + +### File Modifications + +Modify existing files instead of creating new ones: + +```json +{ + "modifications": [ + { + "target": "{{cartridgeName}}/cartridge/hooks.json", + "type": "json-merge", + "jsonPath": "hooks", + "contentTemplate": "hooks-entry.json.ejs" + }, + { + "target": "{{cartridgeName}}/cartridge/package.json", + "type": "insert-after", + "marker": "\"dependencies\": {", + "content": " \"my-dep\": \"^1.0.0\"," + } + ] +} +``` + +| Modification Type | Description | +|-------------------|-------------| +| `json-merge` | Merge JSON objects at `jsonPath` | +| `insert-after` | Insert content after `marker` string | +| `insert-before` | Insert content before `marker` string | +| `append` | Append content to end of file | +| `prepend` | Prepend content to start of file | + +### Post Instructions + +Display instructions after generation: + +```json +{ + "postInstructions": "Component <%= componentName %> created!\n\nNext steps:\n1. Add <%= cartridgeName %> to your cartridge path\n2. Run `b2c code deploy` to upload" +} +``` + +Post instructions support EJS templates. + +### Validation + +Validate your scaffold before use: + +```bash +b2c scaffold validate ./.b2c/scaffolds/my-component +``` + +This checks: +- Required manifest fields +- Parameter definitions +- Template file existence +- EJS syntax errors + +## Scaffold Discovery Priority + +When multiple scaffolds have the same ID, later sources take precedence: + +1. Built-in scaffolds (lowest priority) +2. Plugin-provided scaffolds +3. User scaffolds (`~/.b2c/scaffolds/`) +4. Project scaffolds (`.b2c/scaffolds/`) (highest priority) + +This allows you to override built-in scaffolds with project-specific versions. + +## Programmatic Usage (SDK) + +The scaffold functionality is also available as a programmatic API for IDE integrations, MCP servers, and custom tooling. + +### Discovery and Generation + +```typescript +import { + createScaffoldRegistry, + generateFromScaffold, + resolveScaffoldParameters, + parseParameterOptions, + resolveOutputDirectory, +} from '@salesforce/b2c-tooling-sdk/scaffold'; + +// Create registry and discover scaffolds +const registry = createScaffoldRegistry(); +const scaffolds = await registry.getScaffolds({ projectRoot: '/path/to/project' }); +const scaffold = await registry.getScaffold('service', { projectRoot: '/path/to/project' }); + +// Parse command-line style options +const providedVariables = parseParameterOptions( + ['serviceName=PaymentGateway', 'serviceType=HTTP'], + scaffold +); + +// Resolve parameters (validate, apply defaults, resolve sources) +const resolved = await resolveScaffoldParameters(scaffold, { + providedVariables, + projectRoot: '/path/to/project', + useDefaults: true, // Apply defaults for missing optional params +}); + +// Check for errors or missing required parameters +if (resolved.errors.length > 0) { + console.error('Validation errors:', resolved.errors); +} +if (resolved.missingParameters.length > 0) { + console.log('Missing parameters:', resolved.missingParameters.map(p => p.name)); +} + +// Resolve output directory +const outputDir = resolveOutputDirectory({ + outputDir: undefined, // Optional explicit override + scaffold, + projectRoot: '/path/to/project', +}); + +// Generate files +const result = await generateFromScaffold(scaffold, { + outputDir, + variables: resolved.variables, + dryRun: false, + force: false, +}); + +console.log('Generated files:', result.files); +console.log('Post instructions:', result.postInstructions); +``` + +### Parameter Schema Discovery + +For building dynamic UIs or input schemas: + +```typescript +import { getParameterSchemas } from '@salesforce/b2c-tooling-sdk/scaffold'; + +// Get parameter schemas with resolved choices +const schemas = await getParameterSchemas(scaffold, { + projectRoot: '/path/to/project', +}); + +for (const schema of schemas) { + console.log(`${schema.parameter.name}: ${schema.parameter.type}`); + if (schema.resolvedChoices) { + console.log(' Choices:', schema.resolvedChoices.map(c => c.value)); + } + if (schema.warning) { + console.log(' Warning:', schema.warning); + } +} +``` + +### Validation + +```typescript +import { + validateScaffoldDirectory, + validateEjsSyntax, +} from '@salesforce/b2c-tooling-sdk/scaffold'; + +// Validate a scaffold directory +const result = await validateScaffoldDirectory('/path/to/scaffold', { + strict: false, // Set true to treat warnings as errors +}); + +console.log('Valid:', result.valid); +console.log('Errors:', result.errors); +console.log('Warnings:', result.warnings); +for (const issue of result.issues) { + console.log(`${issue.severity}: ${issue.message} (${issue.file})`); +} + +// Validate EJS template syntax directly +const ejsIssues = validateEjsSyntax('<%= name %>', 'template.ejs'); +``` diff --git a/packages/b2c-cli/.gitignore b/packages/b2c-cli/.gitignore index 0fd5a82f..f298a9f4 100644 --- a/packages/b2c-cli/.gitignore +++ b/packages/b2c-cli/.gitignore @@ -17,3 +17,4 @@ package-lock.json dw.json export/ +cartridges/ diff --git a/packages/b2c-cli/package.json b/packages/b2c-cli/package.json index 4d4cb6e8..6432f152 100644 --- a/packages/b2c-cli/package.json +++ b/packages/b2c-cli/package.json @@ -22,6 +22,7 @@ "@oclif/plugin-warn-if-update-available": "^3", "@salesforce/b2c-tooling-sdk": "workspace:*", "cliui": "^9.0.1", + "glob": "^13.0.0", "marked": "^15.0.0", "marked-terminal": "^7.3.0", "open": "^11.0.0" @@ -271,6 +272,9 @@ }, "setup": { "description": "Setup commands for development environment\n\nDocs: https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/setup.html" + }, + "scaffold": { + "description": "Generate project scaffolds for cartridges, APIs, and components\n\nDocs: https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/scaffold.html" } } }, diff --git a/packages/b2c-cli/src/commands/scaffold/generate.ts b/packages/b2c-cli/src/commands/scaffold/generate.ts new file mode 100644 index 00000000..2cad8a9c --- /dev/null +++ b/packages/b2c-cli/src/commands/scaffold/generate.ts @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {executeScaffoldGenerate, type GenerateResponse} from '../../lib/scaffold/generate-helper.js'; +import {t, withDocs} from '../../i18n/index.js'; + +export type {GenerateResponse} from '../../lib/scaffold/generate-helper.js'; + +/** + * Command to generate a project from a scaffold. + */ +export default class ScaffoldGenerate extends BaseCommand { + static args = { + scaffoldId: Args.string({ + description: 'Scaffold ID to generate', + required: true, + }), + }; + + static description = withDocs( + t('commands.scaffold.generate.description', 'Generate files from a scaffold template'), + '/cli/scaffold.html#b2c-scaffold-generate', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> cartridge', + '<%= config.bin %> <%= command.id %> cartridge --name app_custom', + '<%= config.bin %> <%= command.id %> custom-api --option apiName=my-api --option cartridgeName=app_custom', + '<%= config.bin %> <%= command.id %> cartridge --dry-run', + '<%= config.bin %> <%= command.id %> cartridge --force', + '<%= config.bin %> <%= command.id %> cartridge --output ./src', + ]; + + static flags = { + name: Flags.string({ + char: 'n', + description: 'Primary name parameter (shorthand for --option name=VALUE)', + }), + output: Flags.string({ + char: 'o', + description: 'Output directory (defaults to scaffold default or current directory)', + }), + option: Flags.string({ + description: 'Parameter value in key=value format (can be repeated)', + multiple: true, + }), + 'dry-run': Flags.boolean({ + description: 'Preview files without writing them', + default: false, + }), + force: Flags.boolean({ + char: 'f', + description: 'Skip prompts, use defaults, and overwrite existing files', + default: false, + }), + }; + + async run(): Promise { + const projectRoot = this.flags['working-directory'] || process.cwd(); + const response = await executeScaffoldGenerate( + { + scaffoldId: this.args.scaffoldId, + name: this.flags.name, + output: this.flags.output, + options: this.flags.option, + dryRun: this.flags['dry-run'], + force: this.flags.force, + projectRoot, + }, + { + logger: this.logger, + log: (msg) => this.log(msg), + warn: (msg) => this.warn(msg), + error: (msg) => this.error(msg), + }, + ); + + if (this.jsonEnabled()) { + return response; + } + } +} diff --git a/packages/b2c-cli/src/commands/scaffold/index.ts b/packages/b2c-cli/src/commands/scaffold/index.ts new file mode 100644 index 00000000..26990b8f --- /dev/null +++ b/packages/b2c-cli/src/commands/scaffold/index.ts @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {executeScaffoldGenerate, type GenerateResponse} from '../../lib/scaffold/generate-helper.js'; +import {t, withDocs} from '../../i18n/index.js'; + +/** + * Default scaffold command - provides topic help and shorthand for `scaffold generate`. + * + * - `b2c scaffold` shows topic help with available subcommands + * - `b2c scaffold cartridge` is shorthand for `b2c scaffold generate cartridge` + */ +export default class ScaffoldIndex extends BaseCommand { + static args = { + scaffoldId: Args.string({ + description: 'Scaffold ID to generate (optional - omit to see available commands)', + required: false, + }), + }; + + static description = withDocs( + t('commands.scaffold.index.description', 'Work with project scaffolds and templates'), + '/cli/scaffold.html', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> scaffold --help', + '<%= config.bin %> scaffold list', + '<%= config.bin %> scaffold cartridge', + '<%= config.bin %> scaffold cartridge --name app_custom', + ]; + + static flags = { + name: Flags.string({ + char: 'n', + description: 'Primary name parameter (shorthand for --option name=VALUE)', + }), + output: Flags.string({ + char: 'o', + description: 'Output directory (defaults to scaffold default or current directory)', + }), + option: Flags.string({ + description: 'Parameter value in key=value format (can be repeated)', + multiple: true, + }), + 'dry-run': Flags.boolean({ + description: 'Preview files without writing them', + default: false, + }), + force: Flags.boolean({ + char: 'f', + description: 'Skip prompts, use defaults, and overwrite existing files', + default: false, + }), + }; + + async run(): Promise { + const {scaffoldId} = this.args; + + if (!scaffoldId) { + // No scaffold specified - show topic help + await this.config.runCommand('help', ['scaffold']); + return; + } + + // Scaffold specified - run generation + const response = await executeScaffoldGenerate( + { + scaffoldId, + name: this.flags.name, + output: this.flags.output, + options: this.flags.option, + dryRun: this.flags['dry-run'], + force: this.flags.force, + }, + { + logger: this.logger, + log: (msg) => this.log(msg), + warn: (msg) => this.warn(msg), + error: (msg) => this.error(msg), + }, + ); + + if (this.jsonEnabled()) { + return response; + } + } +} diff --git a/packages/b2c-cli/src/commands/scaffold/info.ts b/packages/b2c-cli/src/commands/scaffold/info.ts new file mode 100644 index 00000000..1e5c8fbb --- /dev/null +++ b/packages/b2c-cli/src/commands/scaffold/info.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 + */ +import {Args, ux} from '@oclif/core'; +import cliui from 'cliui'; +import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {createScaffoldRegistry, type ScaffoldManifest} from '@salesforce/b2c-tooling-sdk/scaffold'; +import {t, withDocs} from '../../i18n/index.js'; + +/** + * Response type for the info command. + */ +interface ScaffoldInfoResponse { + id: string; + source: string; + manifest: ScaffoldManifest; + path: string; +} + +/** + * Command to show detailed information about a scaffold. + */ +export default class ScaffoldInfo extends BaseCommand { + static args = { + scaffoldId: Args.string({ + description: 'Scaffold ID to get info for', + required: true, + }), + }; + + static description = withDocs( + t('commands.scaffold.info.description', 'Show detailed information about a scaffold'), + '/cli/scaffold.html#b2c-scaffold-info', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> cartridge', + '<%= config.bin %> <%= command.id %> custom-api --json', + ]; + + async run(): Promise { + const {scaffoldId} = this.args; + const registry = createScaffoldRegistry(); + const projectRoot = this.flags['working-directory'] || process.cwd(); + + const scaffold = await registry.getScaffold(scaffoldId, { + projectRoot, + }); + + if (!scaffold) { + this.error(t('commands.scaffold.info.scaffoldNotFound', 'Scaffold not found: {{id}}', {id: scaffoldId})); + } + + const response: ScaffoldInfoResponse = { + id: scaffold.id, + source: scaffold.source, + manifest: scaffold.manifest, + path: scaffold.path, + }; + + if (this.jsonEnabled()) { + return response; + } + + // Display formatted output + const ui = cliui({width: process.stdout.columns || 80}); + + // Header + ui.div({text: `\n${scaffold.manifest.displayName}`, padding: [0, 0, 0, 0]}); + ui.div({text: '='.repeat(scaffold.manifest.displayName.length), padding: [0, 0, 1, 0]}); + + // Basic info + ui.div({text: 'ID:', width: 15, padding: [0, 2, 0, 0]}, {text: scaffold.id}); + ui.div({text: 'Category:', width: 15, padding: [0, 2, 0, 0]}, {text: scaffold.manifest.category}); + ui.div({text: 'Source:', width: 15, padding: [0, 2, 0, 0]}, {text: scaffold.source}); + ui.div({text: 'Description:', width: 15, padding: [0, 2, 0, 0]}, {text: scaffold.manifest.description}); + + // Parameters + if (scaffold.manifest.parameters.length > 0) { + ui.div({text: '\nParameters:', padding: [1, 0, 0, 0]}); + ui.div({text: '-'.repeat(11), padding: [0, 0, 1, 0]}); + + for (const param of scaffold.manifest.parameters) { + const required = param.required ? ' (required)' : ''; + const defaultVal = param.default === undefined ? '' : ` [default: ${param.default}]`; + const conditional = param.when ? ` [when: ${param.when}]` : ''; + + ui.div( + {text: ` ${param.name}`, width: 25, padding: [0, 2, 0, 0]}, + {text: `${param.type}${required}${defaultVal}${conditional}`}, + ); + ui.div({text: '', width: 25, padding: [0, 2, 0, 0]}, {text: param.prompt}); + + if (param.choices && param.choices.length > 0) { + ui.div( + {text: '', width: 25, padding: [0, 2, 0, 0]}, + {text: `Choices: ${param.choices.map((c) => c.value).join(', ')}`}, + ); + } + } + } + + // Usage example + ui.div({text: '\nUsage:', padding: [1, 0, 0, 0]}); + ui.div({text: '------', padding: [0, 0, 1, 0]}); + + const requiredParams = scaffold.manifest.parameters + .filter((p) => p.required && !p.when) + .map((p) => `--option ${p.name}=`) + .join(' '); + + ui.div({text: ` b2c scaffold generate ${scaffold.id} ${requiredParams}`, padding: [0, 0, 0, 0]}); + + // Post instructions + if (scaffold.manifest.postInstructions) { + ui.div({text: '\nPost-generation instructions:', padding: [1, 0, 0, 0]}); + ui.div({text: '-----------------------------', padding: [0, 0, 1, 0]}); + ui.div({text: ' (shown after generation completes)', padding: [0, 0, 0, 0]}); + } + + ux.stdout(ui.toString()); + + return response; + } +} diff --git a/packages/b2c-cli/src/commands/scaffold/init.ts b/packages/b2c-cli/src/commands/scaffold/init.ts new file mode 100644 index 00000000..b19cc719 --- /dev/null +++ b/packages/b2c-cli/src/commands/scaffold/init.ts @@ -0,0 +1,256 @@ +/* + * 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 fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {Args, Flags} from '@oclif/core'; +import {input, select} from '@inquirer/prompts'; +import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {isValidScaffoldName, type ScaffoldCategory} from '@salesforce/b2c-tooling-sdk/scaffold'; +import {t, withDocs} from '../../i18n/index.js'; + +/** + * Response type for the init command. + */ +interface ScaffoldInitResponse { + name: string; + path: string; + location: 'custom' | 'project' | 'user'; + files: string[]; +} + +const SCAFFOLD_TEMPLATE = `{ + "name": "{{name}}", + "displayName": "{{displayName}}", + "description": "{{description}}", + "category": "{{category}}", + "version": "1.0", + "tags": [], + "parameters": [ + { + "name": "exampleParam", + "prompt": "Enter a value for the example parameter:", + "type": "string", + "required": true + } + ], + "files": [ + { + "template": "example.txt.ejs", + "destination": "{{exampleParam}}/example.txt" + } + ], + "postInstructions": "Your scaffold has been generated!\\n\\nNext steps:\\n1. Review the generated files\\n2. Customize as needed" +}`; + +const EXAMPLE_TEMPLATE = `<%# Example template file %> +<%# Available helpers: kebabCase, camelCase, pascalCase, snakeCase, year, date, uuid %> + +This is an example file generated by the <%= name %> scaffold. + +Parameter value: <%= exampleParam %> +Generated on: <%= date %> +`; + +/** + * Command to initialize a new custom scaffold. + */ +export default class ScaffoldInit extends BaseCommand { + static args = { + name: Args.string({ + description: 'Name for the new scaffold (kebab-case)', + required: false, + }), + }; + + static description = withDocs( + t('commands.scaffold.init.description', 'Create a new custom scaffold template'), + '/cli/scaffold.html#b2c-scaffold-init', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> my-scaffold', + '<%= config.bin %> <%= command.id %> my-scaffold --project', + '<%= config.bin %> <%= command.id %> my-scaffold --user', + '<%= config.bin %> <%= command.id %> --output ./scaffolds/custom', + ]; + + static flags = { + project: Flags.boolean({ + description: 'Create in project scaffolds directory (.b2c/scaffolds/)', + exclusive: ['user', 'output'], + }), + user: Flags.boolean({ + description: 'Create in user scaffolds directory (~/.b2c/scaffolds/)', + exclusive: ['project', 'output'], + }), + output: Flags.string({ + char: 'o', + description: 'Custom output directory for the scaffold', + exclusive: ['project', 'user'], + }), + force: Flags.boolean({ + char: 'f', + description: 'Overwrite existing scaffold if it exists', + default: false, + }), + }; + + async run(): Promise { + let scaffoldName = this.args.name; + const isTTY = process.stdin.isTTY && process.stdout.isTTY; + + // Prompt for name if not provided + if (!scaffoldName && isTTY) { + scaffoldName = await input({ + message: 'What is the scaffold name?', + validate(value) { + if (!value) return 'Name is required'; + if (!isValidScaffoldName(value)) { + return 'Scaffold name must be kebab-case (lowercase letters, numbers, hyphens)'; + } + return true; + }, + }); + } + + if (!scaffoldName) { + this.error('Scaffold name is required'); + } + + if (!isValidScaffoldName(scaffoldName)) { + this.error('Scaffold name must be kebab-case (lowercase letters, numbers, hyphens)'); + } + + // Determine output location + let scaffoldDir: string; + let location: 'custom' | 'project' | 'user'; + + if (this.flags.output) { + scaffoldDir = path.resolve(this.flags.output, scaffoldName); + location = 'custom'; + } else if (this.flags.user) { + scaffoldDir = path.join(os.homedir(), '.b2c', 'scaffolds', scaffoldName); + location = 'user'; + } else if (this.flags.project || !isTTY) { + scaffoldDir = path.join(process.cwd(), '.b2c', 'scaffolds', scaffoldName); + location = 'project'; + } else { + // Interactive location selection + const selectedLocation = await select({ + message: 'Where should the scaffold be created?', + choices: [ + { + name: 'Project (.b2c/scaffolds/)', + value: 'project', + description: 'Scaffold available only in this project', + }, + { + name: 'User (~/.b2c/scaffolds/)', + value: 'user', + description: 'Scaffold available to all your projects', + }, + ], + }); + location = selectedLocation as 'project' | 'user'; + + scaffoldDir = + location === 'user' + ? path.join(os.homedir(), '.b2c', 'scaffolds', scaffoldName) + : path.join(process.cwd(), '.b2c', 'scaffolds', scaffoldName); + } + + // Check if scaffold already exists + try { + await fs.access(scaffoldDir); + if (!this.flags.force) { + this.error(`Scaffold already exists at ${scaffoldDir}. Use --force to overwrite.`); + } + } catch { + // Directory doesn't exist, which is what we want + } + + // Gather additional info interactively + let displayName = scaffoldName + .split('-') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + let description = 'A custom scaffold'; + let category: ScaffoldCategory = 'cartridge'; + + if (isTTY && !this.jsonEnabled()) { + displayName = await input({ + message: 'Display name for the scaffold:', + default: displayName, + }); + + description = await input({ + message: 'Describe what this scaffold creates:', + default: description, + }); + + category = (await select({ + message: 'Category for the scaffold:', + choices: [ + {name: 'Cartridge', value: 'cartridge'}, + {name: 'Custom API', value: 'custom-api'}, + {name: 'Page Designer', value: 'page-designer'}, + {name: 'Job', value: 'job'}, + {name: 'Metadata', value: 'metadata'}, + ], + })) as ScaffoldCategory; + } + + // Create the scaffold directory structure + const filesDir = path.join(scaffoldDir, 'files'); + await fs.mkdir(filesDir, {recursive: true}); + + // Generate scaffold.json + const manifestContent = SCAFFOLD_TEMPLATE.replaceAll('{{name}}', scaffoldName) + .replaceAll('{{displayName}}', displayName) + .replaceAll('{{description}}', description) + .replaceAll('{{category}}', category); + + await fs.writeFile(path.join(scaffoldDir, 'scaffold.json'), manifestContent, 'utf8'); + + // Generate example template + const exampleContent = EXAMPLE_TEMPLATE.replaceAll('{{name}}', scaffoldName); + await fs.writeFile(path.join(filesDir, 'example.txt.ejs'), exampleContent, 'utf8'); + + const createdFiles = ['scaffold.json', 'files/example.txt.ejs']; + + const response: ScaffoldInitResponse = { + name: scaffoldName, + path: scaffoldDir, + location, + files: createdFiles, + }; + + if (this.jsonEnabled()) { + return response; + } + + this.log(''); + this.log(t('commands.scaffold.init.success', 'Scaffold "{{name}}" created successfully!', {name: scaffoldName})); + this.log(''); + this.log(`Location: ${scaffoldDir}`); + this.log(''); + this.log('Created files:'); + for (const file of createdFiles) { + this.log(` + ${file}`); + } + this.log(''); + this.log('Next steps:'); + this.log(' 1. Edit scaffold.json to define your parameters'); + this.log(' 2. Add template files in the files/ directory'); + this.log(' 3. Test with: b2c scaffold generate ' + scaffoldName + ' --dry-run'); + this.log(' 4. Validate with: b2c scaffold validate ' + scaffoldDir); + + return response; + } +} diff --git a/packages/b2c-cli/src/commands/scaffold/list.ts b/packages/b2c-cli/src/commands/scaffold/list.ts new file mode 100644 index 00000000..7f0d9ce0 --- /dev/null +++ b/packages/b2c-cli/src/commands/scaffold/list.ts @@ -0,0 +1,175 @@ +/* + * 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 {Flags} from '@oclif/core'; +import {BaseCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + createScaffoldRegistry, + type Scaffold, + type ScaffoldCategory, + type ScaffoldSource, +} from '@salesforce/b2c-tooling-sdk/scaffold'; +import {t, withDocs} from '../../i18n/index.js'; + +/** + * Response type for the list command. + */ +interface ScaffoldListResponse { + count: number; + categories: ScaffoldCategory[]; + sources: ScaffoldSource[]; + data: Array<{ + id: string; + displayName: string; + description: string; + category: ScaffoldCategory; + source: string; + path: string; + }>; +} + +const COLUMNS: Record> = { + id: { + header: 'ID', + get: (s) => s.id, + }, + displayName: { + header: 'Name', + get: (s) => s.manifest.displayName, + }, + category: { + header: 'Category', + get: (s) => s.manifest.category, + }, + source: { + header: 'Source', + get: (s) => s.source, + }, + description: { + header: 'Description', + get: (s) => s.manifest.description, + extended: true, + }, + path: { + header: 'Path', + get: (s) => s.path, + extended: true, + }, +}; + +const DEFAULT_COLUMNS = ['id', 'displayName', 'category', 'source']; + +const tableRenderer = new TableRenderer(COLUMNS); + +/** + * Command to list available project scaffolds. + */ +export default class ScaffoldList extends BaseCommand { + static description = withDocs( + t('commands.scaffold.list.description', 'List available project scaffolds'), + '/cli/scaffold.html#b2c-scaffold-list', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --category cartridge', + '<%= config.bin %> <%= command.id %> --source project', + '<%= config.bin %> <%= command.id %> --extended', + '<%= config.bin %> <%= command.id %> --json', + ]; + + static flags = { + category: Flags.string({ + char: 'c', + description: 'Filter by category', + }), + source: Flags.string({ + char: 's', + description: 'Filter by source (built-in, user, project, plugin)', + options: ['built-in', 'user', 'project', 'plugin'], + }), + columns: Flags.string({ + description: `Columns to display (comma-separated). Available: ${Object.keys(COLUMNS).join(', ')}`, + }), + extended: Flags.boolean({ + char: 'x', + description: 'Show all columns including extended fields', + default: false, + }), + }; + + async run(): Promise { + const registry = createScaffoldRegistry(); + const category = this.flags.category as ScaffoldCategory | undefined; + const source = this.flags.source as ScaffoldSource | undefined; + const projectRoot = this.flags['working-directory'] || process.cwd(); + + const scaffolds = await registry.getScaffolds({ + category, + sources: source ? [source] : undefined, + projectRoot, + }); + + // Collect unique categories and sources for metadata + const uniqueCategories = [...new Set(scaffolds.map((s) => s.manifest.category))]; + const uniqueSources = [...new Set(scaffolds.map((s) => s.source))]; + + const response: ScaffoldListResponse = { + count: scaffolds.length, + categories: uniqueCategories, + sources: uniqueSources, + data: scaffolds.map((s) => ({ + id: s.id, + displayName: s.manifest.displayName, + description: s.manifest.description, + category: s.manifest.category, + source: s.source, + path: s.path, + })), + }; + + if (this.jsonEnabled()) { + return response; + } + + if (scaffolds.length === 0) { + this.log(t('commands.scaffold.list.noScaffolds', 'No scaffolds found.')); + return response; + } + + this.log(t('commands.scaffold.list.foundScaffolds', 'Found {{count}} scaffold(s):', {count: scaffolds.length})); + this.log(''); + + tableRenderer.render(scaffolds, this.getSelectedColumns()); + + return response; + } + + /** + * Determines which columns to display based on flags. + */ + private getSelectedColumns(): string[] { + const columnsFlag = this.flags.columns; + const extended = this.flags.extended; + + if (columnsFlag) { + const requested = columnsFlag.split(',').map((c) => c.trim()); + const valid = tableRenderer.validateColumnKeys(requested); + if (valid.length === 0) { + this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); + return DEFAULT_COLUMNS; + } + return valid; + } + + if (extended) { + return tableRenderer.getColumnKeys(); + } + + return DEFAULT_COLUMNS; + } +} diff --git a/packages/b2c-cli/src/commands/scaffold/search.ts b/packages/b2c-cli/src/commands/scaffold/search.ts new file mode 100644 index 00000000..66439954 --- /dev/null +++ b/packages/b2c-cli/src/commands/scaffold/search.ts @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {BaseCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {createScaffoldRegistry, type Scaffold, type ScaffoldCategory} from '@salesforce/b2c-tooling-sdk/scaffold'; +import {t, withDocs} from '../../i18n/index.js'; + +/** + * Response type for the search command. + */ +interface ScaffoldSearchResponse { + query: string; + count: number; + data: Array<{ + id: string; + displayName: string; + description: string; + category: ScaffoldCategory; + source: string; + }>; +} + +const COLUMNS: Record> = { + id: { + header: 'ID', + get: (s) => s.id, + }, + displayName: { + header: 'Name', + get: (s) => s.manifest.displayName, + }, + category: { + header: 'Category', + get: (s) => s.manifest.category, + }, + source: { + header: 'Source', + get: (s) => s.source, + }, + description: { + header: 'Description', + get: (s) => s.manifest.description, + extended: true, + }, +}; + +const DEFAULT_COLUMNS = ['id', 'displayName', 'category', 'description']; + +const tableRenderer = new TableRenderer(COLUMNS); + +/** + * Command to search available scaffolds. + */ +export default class ScaffoldSearch extends BaseCommand { + static args = { + query: Args.string({ + description: 'Search query (matches name and description)', + required: true, + }), + }; + + static description = withDocs( + t('commands.scaffold.search.description', 'Search for scaffolds by name or description'), + '/cli/scaffold.html#b2c-scaffold-search', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> controller', + '<%= config.bin %> <%= command.id %> api', + '<%= config.bin %> <%= command.id %> hook --category cartridge', + '<%= config.bin %> <%= command.id %> job --json', + ]; + + static flags = { + category: Flags.string({ + char: 'c', + description: 'Filter results by category', + }), + }; + + async run(): Promise { + const {query} = this.args; + const registry = createScaffoldRegistry(); + const category = this.flags.category as ScaffoldCategory | undefined; + const projectRoot = this.flags['working-directory'] || process.cwd(); + + const scaffolds = await registry.searchScaffolds(query, { + category, + projectRoot, + }); + + const response: ScaffoldSearchResponse = { + query, + count: scaffolds.length, + data: scaffolds.map((s) => ({ + id: s.id, + displayName: s.manifest.displayName, + description: s.manifest.description, + category: s.manifest.category, + source: s.source, + })), + }; + + if (this.jsonEnabled()) { + return response; + } + + if (scaffolds.length === 0) { + this.log(t('commands.scaffold.search.noResults', 'No scaffolds found matching "{{query}}"', {query})); + return response; + } + + this.log( + t('commands.scaffold.search.foundScaffolds', 'Found {{count}} scaffold(s) matching "{{query}}":', { + count: scaffolds.length, + query, + }), + ); + this.log(''); + + tableRenderer.render(scaffolds, DEFAULT_COLUMNS); + + this.log(''); + this.log(t('commands.scaffold.search.hint', 'Use "b2c scaffold info " for more details')); + + return response; + } +} diff --git a/packages/b2c-cli/src/commands/scaffold/validate.ts b/packages/b2c-cli/src/commands/scaffold/validate.ts new file mode 100644 index 00000000..2ea57fba --- /dev/null +++ b/packages/b2c-cli/src/commands/scaffold/validate.ts @@ -0,0 +1,95 @@ +/* + * 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 path from 'node:path'; +import {Args, Flags} from '@oclif/core'; +import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {validateScaffoldDirectory, type ValidationResult} from '@salesforce/b2c-tooling-sdk/scaffold'; +import {t, withDocs} from '../../i18n/index.js'; + +/** + * Response type for the validate command. + */ +interface ScaffoldValidateResponse extends ValidationResult { + path: string; +} + +/** + * Command to validate a custom scaffold. + */ +export default class ScaffoldValidate extends BaseCommand { + static args = { + path: Args.string({ + description: 'Path to the scaffold directory', + required: true, + }), + }; + + static description = withDocs( + t('commands.scaffold.validate.description', 'Validate a custom scaffold manifest and templates'), + '/cli/scaffold.html#b2c-scaffold-validate', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> ./my-scaffold', + '<%= config.bin %> <%= command.id %> ~/.b2c/scaffolds/my-scaffold', + '<%= config.bin %> <%= command.id %> .b2c/scaffolds/my-scaffold --json', + ]; + + static flags = { + strict: Flags.boolean({ + description: 'Treat warnings as errors', + default: false, + }), + }; + + async run(): Promise { + const scaffoldPath = path.resolve(this.args.path); + + // Use SDK validation function + const result = await validateScaffoldDirectory(scaffoldPath, { + strict: this.flags.strict, + }); + + const response: ScaffoldValidateResponse = { + path: scaffoldPath, + ...result, + }; + + if (this.jsonEnabled()) { + return response; + } + + // Display results + this.log(''); + this.log(`Validating scaffold at: ${scaffoldPath}`); + this.log(''); + + if (result.issues.length === 0) { + this.log('No issues found.'); + } else { + for (const issue of result.issues) { + const prefix = issue.severity === 'error' ? 'ERROR' : 'WARN'; + const fileInfo = issue.file ? ` (${issue.file})` : ''; + this.log(` ${prefix}: ${issue.message}${fileInfo}`); + } + } + + this.log(''); + this.log(`Summary: ${result.errors} error(s), ${result.warnings} warning(s)`); + + if (result.valid) { + this.log(''); + this.log('Scaffold is valid.'); + } else { + this.log(''); + this.error('Validation failed'); + } + + return response; + } +} diff --git a/packages/b2c-cli/src/i18n/locales/en.ts b/packages/b2c-cli/src/i18n/locales/en.ts index 5ef46af1..fe4a578e 100644 --- a/packages/b2c-cli/src/i18n/locales/en.ts +++ b/packages/b2c-cli/src/i18n/locales/en.ts @@ -149,5 +149,47 @@ export const en = { ideNotes: 'See IDE documentation for skill configuration:', }, }, + scaffold: { + list: { + description: 'List available project scaffolds', + noScaffolds: 'No scaffolds found.', + foundScaffolds: 'Found {{count}} scaffold(s):', + error: 'Failed to list scaffolds: {{message}}', + }, + generate: { + description: 'Generate files from a scaffold template', + scaffoldNotFound: 'Scaffold not found: {{id}}', + generating: 'Generating {{scaffold}} scaffold...', + dryRun: 'Dry run - no files will be written', + success: 'Successfully generated {{count}} file(s)', + skipped: 'Skipped {{count}} file(s)', + error: 'Failed to generate scaffold: {{message}}', + validationError: 'Invalid parameters: {{errors}}', + }, + info: { + description: 'Show detailed information about a scaffold', + scaffoldNotFound: 'Scaffold not found: {{id}}', + error: 'Failed to get scaffold info: {{message}}', + }, + index: { + description: 'Work with project scaffolds and templates', + }, + search: { + description: 'Search for scaffolds by name, description, or tags', + noResults: 'No scaffolds found matching "{{query}}"', + foundScaffolds: 'Found {{count}} scaffold(s) matching "{{query}}":', + hint: 'Use "b2c scaffold info " for more details', + }, + init: { + description: 'Create a new custom scaffold template', + success: 'Scaffold "{{name}}" created successfully!', + alreadyExists: 'Scaffold already exists at {{path}}. Use --force to overwrite.', + }, + validate: { + description: 'Validate a custom scaffold manifest and templates', + valid: 'Scaffold is valid.', + invalid: 'Validation failed', + }, + }, }, }; diff --git a/packages/b2c-cli/src/lib/scaffold/generate-helper.ts b/packages/b2c-cli/src/lib/scaffold/generate-helper.ts new file mode 100644 index 00000000..fb33e644 --- /dev/null +++ b/packages/b2c-cli/src/lib/scaffold/generate-helper.ts @@ -0,0 +1,374 @@ +/* + * 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 {input, confirm, select, checkbox, search} from '@inquirer/prompts'; +import type {Logger} from '@salesforce/b2c-tooling-sdk'; +import { + createScaffoldRegistry, + generateFromScaffold, + evaluateCondition, + resolveScaffoldParameters, + parseParameterOptions, + resolveOutputDirectory, + type ScaffoldParameter, + type ScaffoldGenerateResult, + type ScaffoldChoice, +} from '@salesforce/b2c-tooling-sdk/scaffold'; +import {resolveLocalSource, resolveRemoteSource, isRemoteSource, type SourceResult} from './source-resolver.js'; + +/** + * Response type for scaffold generation. + */ +export interface GenerateResponse { + scaffold: string; + outputDir: string; + dryRun: boolean; + files: Array<{ + path: string; + action: string; + skipReason?: string; + }>; + postInstructions?: string; +} + +/** + * Options for scaffold generation. + */ +export interface GenerateOptions { + /** Scaffold ID to generate */ + scaffoldId: string; + /** Primary name parameter (shorthand) */ + name?: string; + /** Output directory override */ + output?: string; + /** Key=value option pairs */ + options?: string[]; + /** Preview without writing */ + dryRun?: boolean; + /** Skip prompts, use defaults */ + force?: boolean; + /** Project root directory (defaults to process.cwd()) */ + projectRoot?: string; +} + +/** + * Command context for logging and output. + */ +export interface CommandContext { + logger: Logger; + log: (message: string) => void; + warn: (message: string) => void; + error: (message: string) => never; +} + +/** + * Execute scaffold generation with the given options. + */ +export async function executeScaffoldGenerate( + options: GenerateOptions, + ctx: CommandContext, +): Promise { + const {scaffoldId, dryRun = false, force = false, projectRoot = process.cwd()} = options; + const registry = createScaffoldRegistry(); + + // Find the scaffold + const scaffold = await registry.getScaffold(scaffoldId, { + projectRoot, + }); + + if (!scaffold) { + ctx.error(`Scaffold not found: ${scaffoldId}`); + } + + // Parse option flags into variables + const providedVariables = parseParameterOptions(options.options || [], scaffold); + + // Handle --name shorthand for the first string parameter + if (options.name) { + const firstStringParam = scaffold.manifest.parameters.find((p) => p.type === 'string'); + if (firstStringParam) { + providedVariables[firstStringParam.name] = options.name; + } + } + + // Resolve parameters using SDK, then prompt for any missing + const isTTY = process.stdin.isTTY && process.stdout.isTTY; + const interactive = !force && isTTY; + + const resolved = await resolveScaffoldParameters(scaffold, { + providedVariables, + projectRoot, + useDefaults: !interactive, + }); + + // Report any validation errors + for (const error of resolved.errors) { + ctx.error(error.message); + } + + // Check for missing required parameters in non-interactive mode + if (!interactive) { + const missingRequired = resolved.missingParameters.filter((p) => p.required); + if (missingRequired.length > 0) { + ctx.error(`Missing required parameter: ${missingRequired[0].name}`); + } + } + + // Prompt for any missing parameters in interactive mode + const resolvedVariables = {...resolved.variables}; + if (interactive && resolved.missingParameters.length > 0) { + for (const param of resolved.missingParameters) { + // Re-check condition since earlier params may have been filled in + if (param.when && !evaluateCondition(param.when, resolvedVariables)) { + continue; + } + + // eslint-disable-next-line no-await-in-loop + const value = await promptForParameter(param, projectRoot, ctx); + if (value !== undefined) { + resolvedVariables[param.name] = value; + + // Set companion path variable for cartridges source + if (param.source === 'cartridges' && typeof value === 'string') { + const result = resolveLocalSource('cartridges', projectRoot); + const cartridgePath = result.pathMap?.get(value); + if (cartridgePath) { + resolvedVariables[`${param.name}Path`] = cartridgePath; + } + } + } + } + } + + // Resolve output directory using SDK function + ctx.logger.trace( + {flagOutput: options.output, defaultOutputDir: scaffold.manifest.defaultOutputDir}, + 'Resolving output directory', + ); + const outputDir = resolveOutputDirectory({ + outputDir: options.output, + scaffold, + projectRoot, + }); + ctx.logger.debug({outputDir}, 'Resolved output directory'); + + if (dryRun) { + ctx.log('Dry run - no files will be written'); + } + + ctx.log(`Generating ${scaffold.manifest.displayName} scaffold...`); + + // Generate the scaffold + let result: ScaffoldGenerateResult; + try { + result = await generateFromScaffold(scaffold, { + outputDir, + variables: resolvedVariables, + dryRun, + force, + }); + } catch (error) { + ctx.error(`Failed to generate scaffold: ${(error as Error).message}`); + } + + const response: GenerateResponse = { + scaffold: scaffoldId, + outputDir, + dryRun, + files: result.files.map((f) => ({ + path: f.path, + action: f.action, + skipReason: f.skipReason, + })), + postInstructions: result.postInstructions, + }; + + // Display results + const created = result.files.filter((f) => f.action === 'created' || f.action === 'overwritten'); + const skipped = result.files.filter((f) => f.action === 'skipped'); + + if (created.length > 0) { + ctx.log(''); + ctx.log(`Successfully generated ${created.length} file(s):`); + for (const file of created) { + // file.path is already relative to cwd from the executor + ctx.log(` ${file.action === 'overwritten' ? '(overwritten)' : '+'} ${file.path}`); + } + } + + if (skipped.length > 0) { + ctx.log(''); + ctx.log(`Skipped ${skipped.length} file(s):`); + for (const file of skipped) { + ctx.log(` - ${file.path}${file.skipReason ? ` (${file.skipReason})` : ''}`); + } + } + + // Show post-instructions + if (result.postInstructions) { + ctx.log(''); + ctx.log(result.postInstructions); + } + + return response; +} + +/** + * Prompt for a single parameter value. + */ +async function promptForParameter( + param: ScaffoldParameter, + projectRoot: string, + ctx: CommandContext, +): Promise { + switch (param.type) { + case 'boolean': { + return confirm({ + message: param.prompt, + default: param.default as boolean | undefined, + }); + } + + case 'choice': { + const {choices, warning} = await resolveSourceChoices(param, projectRoot); + if (warning) ctx.warn(warning); + + if (choices.length === 0) { + if (param.source) ctx.warn(`No ${param.source} found. Please enter a value manually.`); + return promptTextInput(param); + } + + if (choices.length > 10) { + return search({ + message: param.prompt, + source: createSearchSource(choices), + }); + } + + return select({ + message: param.prompt, + choices: choices.map((c) => ({name: c.label, value: c.value})), + default: param.default as string | undefined, + }); + } + + case 'multi-choice': { + const {choices, warning} = await resolveSourceChoices(param, projectRoot); + if (warning) ctx.warn(warning); + if (choices.length === 0) return []; + + // Pre-select default values if specified + const defaults = Array.isArray(param.default) ? param.default : []; + return checkbox({ + message: param.prompt, + choices: choices.map((c) => ({ + name: c.label, + value: c.value, + checked: defaults.includes(c.value), + })), + }); + } + + case 'string': { + if (param.source) { + const {choices, warning} = await resolveSourceChoices(param, projectRoot); + if (warning) ctx.warn(warning); + + if (choices.length > 0) { + if (choices.length > 10) { + return search({ + message: param.prompt, + source: createSearchSource(choices), + }); + } + return select({ + message: param.prompt, + choices: choices.map((c) => ({name: c.label, value: c.value})), + default: param.default as string | undefined, + }); + } + + ctx.warn(`No ${param.source} found. Please enter a value manually.`); + } + + return promptTextInput(param); + } + + default: { + return undefined; + } + } +} + +/** + * Prompt for text input with validation. + */ +async function promptTextInput(param: ScaffoldParameter): Promise { + const value = await input({ + message: param.prompt, + default: param.default as string | undefined, + required: param.required, + validate(val) { + if (param.required && !val) return 'This field is required'; + if (param.pattern && val) { + const regex = new RegExp(param.pattern); + if (!regex.test(val)) { + return param.validationMessage || `Value does not match pattern: ${param.pattern}`; + } + } + return true; + }, + }); + return value || undefined; +} + +/** + * Create a search source function for inquirer search prompt. + */ +function createSearchSource(choices: ScaffoldChoice[]) { + return async (term: string | undefined) => { + if (!term) { + return choices.map((c) => ({name: c.label, value: c.value})); + } + const lowerTerm = term.toLowerCase(); + return choices + .filter((c) => c.label.toLowerCase().includes(lowerTerm) || c.value.toLowerCase().includes(lowerTerm)) + .map((c) => ({name: c.label, value: c.value})); + }; +} + +/** + * Resolve choices for a parameter with dynamic source. + */ +async function resolveSourceChoices( + param: ScaffoldParameter, + projectRoot: string, +): Promise<{ + choices: ScaffoldChoice[]; + pathMap?: Map; + warning?: string; +}> { + if (!param.source) { + return {choices: param.choices || []}; + } + + if (isRemoteSource(param.source)) { + try { + const choices = await resolveRemoteSource(param.source); + return {choices}; + } catch (error) { + return { + choices: [], + warning: `Could not fetch ${param.source}: ${(error as Error).message}`, + }; + } + } + + const result: SourceResult = resolveLocalSource(param.source, projectRoot); + return { + choices: result.choices, + pathMap: result.pathMap, + }; +} diff --git a/packages/b2c-cli/src/lib/scaffold/source-resolver.ts b/packages/b2c-cli/src/lib/scaffold/source-resolver.ts new file mode 100644 index 00000000..63604196 --- /dev/null +++ b/packages/b2c-cli/src/lib/scaffold/source-resolver.ts @@ -0,0 +1,54 @@ +/* + * 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 { + resolveLocalSource as sdkResolveLocalSource, + resolveRemoteSource as sdkResolveRemoteSource, + type DynamicParameterSource, + type ScaffoldChoice, + type SourceResult, +} from '@salesforce/b2c-tooling-sdk/scaffold'; +import {loadConfig} from '@salesforce/b2c-tooling-sdk/cli'; + +// Re-export SDK functions and types +export {isRemoteSource, validateAgainstSource, type SourceResult} from '@salesforce/b2c-tooling-sdk/scaffold'; + +/** + * @deprecated Use SourceResult from '@salesforce/b2c-tooling-sdk/scaffold' instead. + * This type alias is kept for backwards compatibility. + */ +export type LocalSourceResult = SourceResult; + +/** + * Resolves a local (non-remote) parameter source. + * Delegates to SDK function. + * + * @param source - The source type to resolve + * @param projectRoot - Project root directory for cartridge discovery + * @returns Resolved choices and optional path mapping + */ +export function resolveLocalSource(source: DynamicParameterSource, projectRoot: string): SourceResult { + return sdkResolveLocalSource(source, projectRoot); +} + +/** + * CLI wrapper for remote source resolution. + * Handles auth orchestration using CLI config loading. + * + * @param source - The source type to resolve + * @returns Promise resolving to choices array + * @throws Error if authentication fails or API call fails + */ +export async function resolveRemoteSource(source: DynamicParameterSource): Promise { + const config = loadConfig({}, {configPath: undefined}); + + if (!config.hasB2CInstanceConfig() || !config.hasOAuthConfig()) { + throw new Error('B2C instance configuration with OAuth required for sites source'); + } + + const instance = config.createB2CInstance(); + return sdkResolveRemoteSource(source, instance); +} diff --git a/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/.project.ejs b/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/.project.ejs new file mode 100644 index 00000000..64bbe53a --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/.project.ejs @@ -0,0 +1,17 @@ + + + <%= cartridgeName %> + + + + + + com.demandware.studio.core.beehiveElementBuilder + + + + + + com.demandware.studio.core.beehiveNature + + diff --git a/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/cartridge/cartridge.properties.ejs b/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/cartridge/cartridge.properties.ejs new file mode 100644 index 00000000..d92aeb6a --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/cartridge/cartridge.properties.ejs @@ -0,0 +1,4 @@ +# <%= cartridgeName %> cartridge properties +# Generated on <%= date %> + +demandware.cartridges.<%= cartridgeName %>.multipleLanguageStorefront=true diff --git a/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/cartridge/controllers/Example.js.ejs b/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/cartridge/controllers/Example.js.ejs new file mode 100644 index 00000000..3e3095c7 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/cartridge/controllers/Example.js.ejs @@ -0,0 +1,19 @@ +'use strict'; + +/** + * Example controller for <%= cartridgeName %> + */ + +var server = require('server'); + +/** + * Example-Show: Renders an example page + */ +server.get('Show', function (req, res, next) { + res.render('example/show', { + message: 'Hello from <%= cartridgeName %>!' + }); + next(); +}); + +module.exports = server.exports(); diff --git a/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/cartridge/models/example.js.ejs b/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/cartridge/models/example.js.ejs new file mode 100644 index 00000000..b9039969 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/cartridge/models/example.js.ejs @@ -0,0 +1,14 @@ +'use strict'; + +/** + * Example model for <%= cartridgeName %> + * @param {Object} data - The input data + * @constructor + */ +function ExampleModel(data) { + this.id = data.id || null; + this.name = data.name || ''; + this.description = data.description || ''; +} + +module.exports = ExampleModel; diff --git a/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/cartridge/scripts/helpers/exampleHelpers.js.ejs b/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/cartridge/scripts/helpers/exampleHelpers.js.ejs new file mode 100644 index 00000000..426a2012 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/cartridge/scripts/helpers/exampleHelpers.js.ejs @@ -0,0 +1,18 @@ +'use strict'; + +/** + * Example helper functions for <%= cartridgeName %> + */ + +/** + * Formats a string for display + * @param {string} input - The input string + * @returns {string} The formatted string + */ +function formatString(input) { + return input ? input.trim() : ''; +} + +module.exports = { + formatString: formatString +}; diff --git a/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/cartridge/static/default/css/example.css b/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/cartridge/static/default/css/example.css new file mode 100644 index 00000000..7adf31b9 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/cartridge/static/default/css/example.css @@ -0,0 +1,6 @@ +/* Example styles */ +.example-container { + padding: 20px; + margin: 10px auto; + max-width: 1200px; +} diff --git a/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/cartridge/templates/default/example/show.isml.ejs b/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/cartridge/templates/default/example/show.isml.ejs new file mode 100644 index 00000000..ffaf5742 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/cartridge/files/cartridge/templates/default/example/show.isml.ejs @@ -0,0 +1,11 @@ + + + var assets = require('*/cartridge/scripts/assets'); + assets.addCss('/css/example.css'); + + +
+

${pdict.message}

+

This is an example page from the <%= cartridgeName %> cartridge.

+
+
diff --git a/packages/b2c-tooling-sdk/data/scaffolds/cartridge/scaffold.json b/packages/b2c-tooling-sdk/data/scaffolds/cartridge/scaffold.json new file mode 100644 index 00000000..f6534d8d --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/cartridge/scaffold.json @@ -0,0 +1,88 @@ +{ + "name": "cartridge", + "displayName": "Cartridge", + "description": "Create a new B2C Commerce cartridge with standard structure", + "category": "cartridge", + "defaultOutputDir": "cartridges", + "parameters": [ + { + "name": "cartridgeName", + "prompt": "What is the cartridge name?", + "type": "string", + "required": true, + "pattern": "^[a-z][a-z0-9_]*$", + "validationMessage": "Cartridge name must start with a lowercase letter and contain only lowercase letters, numbers, and underscores" + }, + { + "name": "includeControllers", + "prompt": "Include controllers directory?", + "type": "boolean", + "required": false, + "default": true + }, + { + "name": "includeModels", + "prompt": "Include models directory?", + "type": "boolean", + "required": false, + "default": true + }, + { + "name": "includeScripts", + "prompt": "Include scripts directory?", + "type": "boolean", + "required": false, + "default": true + }, + { + "name": "includeTemplates", + "prompt": "Include templates directory?", + "type": "boolean", + "required": false, + "default": true + }, + { + "name": "includeStatic", + "prompt": "Include static assets directory?", + "type": "boolean", + "required": false, + "default": false + } + ], + "files": [ + { + "template": ".project.ejs", + "destination": "{{cartridgeName}}/.project" + }, + { + "template": "cartridge/cartridge.properties.ejs", + "destination": "{{cartridgeName}}/cartridge/{{cartridgeName}}.properties" + }, + { + "template": "cartridge/controllers/Example.js.ejs", + "destination": "{{cartridgeName}}/cartridge/controllers/Example.js", + "condition": "includeControllers" + }, + { + "template": "cartridge/models/example.js.ejs", + "destination": "{{cartridgeName}}/cartridge/models/example.js", + "condition": "includeModels" + }, + { + "template": "cartridge/scripts/helpers/exampleHelpers.js.ejs", + "destination": "{{cartridgeName}}/cartridge/scripts/helpers/exampleHelpers.js", + "condition": "includeScripts" + }, + { + "template": "cartridge/templates/default/example/show.isml.ejs", + "destination": "{{cartridgeName}}/cartridge/templates/default/example/show.isml", + "condition": "includeTemplates" + }, + { + "template": "cartridge/static/default/css/example.css", + "destination": "{{cartridgeName}}/cartridge/static/default/css/example.css", + "condition": "includeStatic" + } + ], + "postInstructions": "Cartridge '<%= cartridgeName %>' has been created.\n\nNext steps:\n1. Add the cartridge to your cartridge path in Business Manager\n2. Deploy the cartridge to your instance" +} diff --git a/packages/b2c-tooling-sdk/data/scaffolds/controller/files/controller.js.ejs b/packages/b2c-tooling-sdk/data/scaffolds/controller/files/controller.js.ejs new file mode 100644 index 00000000..a7dcffa4 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/controller/files/controller.js.ejs @@ -0,0 +1,134 @@ +'use strict'; + +/** + * <%= controllerName %> Controller + * + * @module controllers/<%= controllerName %> + */ + +var server = require('server'); +<% if (useMiddleware) { %> +var cache = require('*/cartridge/scripts/middleware/cache'); +var consentTracking = require('*/cartridge/scripts/middleware/consentTracking'); +var pageMetaData = require('*/cartridge/scripts/middleware/pageMetaData'); +<% } %> + +<% if (routes.includes('Show')) { %> +/** + * <%= controllerName %>-Show : Renders the <%= controllerName.toLowerCase() %> page + * @name <%= controllerName %>-Show + * @function + * @memberof <%= controllerName %> +<% if (useMiddleware) { %> * @param {middleware} - server.middleware.https + * @param {middleware} - consentTracking.consent + * @param {middleware} - cache.applyDefaultCache<% } %> + * @param {category} - sensitive + * @param {renders} - isml + * @param {serverfunction} - get + */ +server.get( + 'Show', +<% if (useMiddleware) { %> server.middleware.https, + consentTracking.consent, + cache.applyDefaultCache, +<% } %> function (req, res, next) { + var viewData = { + title: '<%= controllerName %>', + message: 'Hello from <%= controllerName %>!' + }; + + res.render('<%= kebabCase(controllerName) %>/show', viewData); + next(); + } +); +<% if (useMiddleware) { %> +server.append('Show', pageMetaData.computedPageMetaData); +<% } %> +<% } %> +<% if (routes.includes('Submit')) { %> +/** + * <%= controllerName %>-Submit : Handles form submission for <%= controllerName.toLowerCase() %> + * @name <%= controllerName %>-Submit + * @function + * @memberof <%= controllerName %> +<% if (useMiddleware) { %> * @param {middleware} - server.middleware.https<% } %> + * @param {httpparameter} - csrf_token - CSRF token + * @param {category} - sensitive + * @param {returns} - json + * @param {serverfunction} - post + */ +server.post( + 'Submit', +<% if (useMiddleware) { %> server.middleware.https, +<% } %> function (req, res, next) { + var form = req.form; + + // TODO: Process form data + var result = { + success: true, + message: 'Form submitted successfully' + }; + + res.json(result); + next(); + } +); +<% } %> +<% if (routes.includes('JSON')) { %> +/** + * <%= controllerName %>-JSON : Returns JSON data for <%= controllerName.toLowerCase() %> + * @name <%= controllerName %>-JSON + * @function + * @memberof <%= controllerName %> +<% if (useMiddleware) { %> * @param {middleware} - cache.applyDefaultCache<% } %> + * @param {category} - non-sensitive + * @param {returns} - json + * @param {serverfunction} - get + */ +server.get( + 'JSON', +<% if (useMiddleware) { %> cache.applyDefaultCache, +<% } %> function (req, res, next) { + var data = { + success: true, + data: { + // TODO: Add your data here + } + }; + + res.json(data); + next(); + } +); +<% } %> +<% if (routes.includes('SubmitJSON')) { %> +/** + * <%= controllerName %>-SubmitJSON : Handles AJAX form submission for <%= controllerName.toLowerCase() %> + * @name <%= controllerName %>-SubmitJSON + * @function + * @memberof <%= controllerName %> +<% if (useMiddleware) { %> * @param {middleware} - server.middleware.https<% } %> + * @param {httpparameter} - csrf_token - CSRF token + * @param {category} - sensitive + * @param {returns} - json + * @param {serverfunction} - post + */ +server.post( + 'SubmitJSON', +<% if (useMiddleware) { %> server.middleware.https, +<% } %> function (req, res, next) { + var form = req.form; + + // TODO: Validate and process form data + var result = { + success: true, + redirectUrl: null + }; + + res.json(result); + next(); + } +); +<% } %> + +module.exports = server.exports(); diff --git a/packages/b2c-tooling-sdk/data/scaffolds/controller/files/template.isml.ejs b/packages/b2c-tooling-sdk/data/scaffolds/controller/files/template.isml.ejs new file mode 100644 index 00000000..69117efd --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/controller/files/template.isml.ejs @@ -0,0 +1,22 @@ + + + + + + + + + var assets = require('*/cartridge/scripts/assets'); + assets.addCss('/css/<%= kebabCase(controllerName) %>.css'); + assets.addJs('/js/<%= kebabCase(controllerName) %>.js'); + + +
+
+
+

${pdict.title}

+

${pdict.message}

+
+
+
+
diff --git a/packages/b2c-tooling-sdk/data/scaffolds/controller/scaffold.json b/packages/b2c-tooling-sdk/data/scaffolds/controller/scaffold.json new file mode 100644 index 00000000..fcb2cdea --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/controller/scaffold.json @@ -0,0 +1,64 @@ +{ + "name": "controller", + "displayName": "SFRA Controller", + "description": "Create an SFRA controller with route handlers and middleware", + "category": "cartridge", + "parameters": [ + { + "name": "controllerName", + "prompt": "What is the controller name?", + "type": "string", + "required": true, + "pattern": "^[A-Z][a-zA-Z0-9]*$", + "validationMessage": "Controller name must be PascalCase (start with uppercase letter)" + }, + { + "name": "cartridgeName", + "prompt": "Which cartridge should contain this controller?", + "type": "string", + "required": true, + "source": "cartridges", + "pattern": "^[a-z][a-z0-9_]*$", + "validationMessage": "Cartridge name must start with a lowercase letter and contain only lowercase letters, numbers, and underscores" + }, + { + "name": "routes", + "prompt": "Which route types do you want to include?", + "type": "multi-choice", + "required": true, + "choices": [ + { "value": "Show", "label": "Show (GET - render page)" }, + { "value": "Submit", "label": "Submit (POST - form handler)" }, + { "value": "JSON", "label": "JSON (GET - AJAX endpoint)" }, + { "value": "SubmitJSON", "label": "SubmitJSON (POST - AJAX form)" } + ], + "default": ["Show"] + }, + { + "name": "useMiddleware", + "prompt": "Include middleware chain example?", + "type": "boolean", + "required": false, + "default": true + }, + { + "name": "includeTemplate", + "prompt": "Generate accompanying ISML template?", + "type": "boolean", + "required": false, + "default": true + } + ], + "files": [ + { + "template": "controller.js.ejs", + "destination": "{{cartridgeNamePath}}/cartridge/controllers/{{controllerName}}.js" + }, + { + "template": "template.isml.ejs", + "destination": "{{cartridgeNamePath}}/cartridge/templates/default/{{kebabCase controllerName}}/show.isml", + "condition": "includeTemplate" + } + ], + "postInstructions": "Controller '<%= controllerName %>' has been created in '<%= cartridgeName %>'.\n\nRoutes created:\n<% routes.forEach(function(route) { %>\n- <%= controllerName %>-<%= route %>: /<%= controllerName %>-<%= route %>\n<% }); %>\n\nNext steps:\n1. Deploy the cartridge to your instance\n2. Ensure <%= cartridgeName %> is in your cartridge path" +} diff --git a/packages/b2c-tooling-sdk/data/scaffolds/custom-api/files/api.json.ejs b/packages/b2c-tooling-sdk/data/scaffolds/custom-api/files/api.json.ejs new file mode 100644 index 00000000..09d9f5cd --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/custom-api/files/api.json.ejs @@ -0,0 +1,11 @@ +{ + "endpoints": [ +<% if (includeExampleEndpoints) { %> + {"endpoint": "getHello", "schema": "schema.yaml", "implementation": "script"}, + {"endpoint": "getItems", "schema": "schema.yaml", "implementation": "script"}, + {"endpoint": "getItem", "schema": "schema.yaml", "implementation": "script"} +<% } else { %> + {"endpoint": "getExample", "schema": "schema.yaml", "implementation": "script"} +<% } %> + ] +} diff --git a/packages/b2c-tooling-sdk/data/scaffolds/custom-api/files/schema.yaml.ejs b/packages/b2c-tooling-sdk/data/scaffolds/custom-api/files/schema.yaml.ejs new file mode 100644 index 00000000..95608c98 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/custom-api/files/schema.yaml.ejs @@ -0,0 +1,188 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: <%= apiName %> + description: <%= apiDescription %> +components: + securitySchemes: +<% if (apiType === 'shopper') { %> + ShopperToken: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://{shortCode}.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/{organizationId}/oauth2/token + scopes: + c_<%= apiName.replace(/-/g, '_') %>: Access <%= apiName %> API +<% } else { %> + AmOAuth2: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://account.demandware.com/dwsso/oauth2/access_token + scopes: + c_<%= apiName.replace(/-/g, '_') %>: Access <%= apiName %> API +<% } %> +<% if (includeExampleEndpoints) { %> +paths: + /hello: + get: + summary: Returns a hello message + operationId: getHello +<% if (apiType === 'shopper') { %> + parameters: + - in: query + name: siteId + required: true + schema: + type: string + minLength: 1 +<% } %> + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + message: + type: string + security: +<% if (apiType === 'shopper') { %> + - ShopperToken: [c_<%= apiName.replace(/-/g, '_') %>] +<% } else { %> + - AmOAuth2: [c_<%= apiName.replace(/-/g, '_') %>] +<% } %> + /items: + get: + summary: Get a list of items + operationId: getItems + parameters: +<% if (apiType === 'shopper') { %> + - in: query + name: siteId + required: true + schema: + type: string + minLength: 1 +<% } %> + - in: query + name: c_limit + required: false + schema: + type: integer + default: 10 + - in: query + name: c_offset + required: false + schema: + type: integer + default: 0 + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + count: + type: integer + total: + type: integer + data: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + security: +<% if (apiType === 'shopper') { %> + - ShopperToken: [c_<%= apiName.replace(/-/g, '_') %>] +<% } else { %> + - AmOAuth2: [c_<%= apiName.replace(/-/g, '_') %>] +<% } %> + /items/{itemId}: + get: + summary: Get a specific item by ID + operationId: getItem + parameters: +<% if (apiType === 'shopper') { %> + - in: query + name: siteId + required: true + schema: + type: string + minLength: 1 +<% } %> + - in: path + name: itemId + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + '404': + description: Item not found + content: + application/json: + schema: + type: object + properties: + type: + type: string + message: + type: string + security: +<% if (apiType === 'shopper') { %> + - ShopperToken: [c_<%= apiName.replace(/-/g, '_') %>] +<% } else { %> + - AmOAuth2: [c_<%= apiName.replace(/-/g, '_') %>] +<% } %> +<% } else { %> +paths: + /example: + get: + summary: Example endpoint + operationId: getExample +<% if (apiType === 'shopper') { %> + parameters: + - in: query + name: siteId + required: true + schema: + type: string + minLength: 1 +<% } %> + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + message: + type: string + security: +<% if (apiType === 'shopper') { %> + - ShopperToken: [c_<%= apiName.replace(/-/g, '_') %>] +<% } else { %> + - AmOAuth2: [c_<%= apiName.replace(/-/g, '_') %>] +<% } %> +<% } %> diff --git a/packages/b2c-tooling-sdk/data/scaffolds/custom-api/files/script.js.ejs b/packages/b2c-tooling-sdk/data/scaffolds/custom-api/files/script.js.ejs new file mode 100644 index 00000000..eace31a8 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/custom-api/files/script.js.ejs @@ -0,0 +1,95 @@ +'use strict'; + +/** + * Custom SCAPI implementation for <%= apiName %> + * + * Each exported function corresponds to an operationId in schema.yaml. + * Functions must be marked as public: exports.functionName.public = true; + * + * Use RESTResponseMgr for proper SCAPI responses. + */ + +var RESTResponseMgr = require('dw/system/RESTResponseMgr'); + +<% if (includeExampleEndpoints) { %> +/** + * GET /hello + * Returns a hello message + */ +exports.getHello = function () { + RESTResponseMgr + .createSuccess({ + message: 'Hello from <%= apiName %>!' + }) + .render(); +}; +exports.getHello.public = true; + +/** + * GET /items + * Get a list of items with pagination + */ +exports.getItems = function () { + var limit = request.httpParameterMap.get('c_limit').intValue || 10; + var offset = request.httpParameterMap.get('c_offset').intValue || 0; + + // TODO: Replace with actual data retrieval logic + var allItems = [ + { id: 'item-1', name: 'Example Item 1' }, + { id: 'item-2', name: 'Example Item 2' }, + { id: 'item-3', name: 'Example Item 3' } + ]; + + var items = allItems.slice(offset, offset + limit); + + RESTResponseMgr + .createSuccess({ + count: items.length, + total: allItems.length, + data: items + }) + .render(); +}; +exports.getItems.public = true; + +/** + * GET /items/{itemId} + * Get a specific item by ID + */ +exports.getItem = function () { + var itemId = request.getSCAPIPathParameters().get('itemId'); + + // TODO: Replace with actual data retrieval logic + var items = { + 'item-1': { id: 'item-1', name: 'Example Item 1', description: 'First example item' }, + 'item-2': { id: 'item-2', name: 'Example Item 2', description: 'Second example item' }, + 'item-3': { id: 'item-3', name: 'Example Item 3', description: 'Third example item' } + }; + + var item = items[itemId]; + + if (item) { + RESTResponseMgr + .createSuccess(item) + .render(); + } else { + RESTResponseMgr + .createError(404, 'item-not-found', 'Item Not Found', 'The requested item was not found.') + .render(); + } +}; +exports.getItem.public = true; +<% } else { %> +/** + * GET /example + * Example endpoint - replace with your implementation + */ +exports.getExample = function () { + RESTResponseMgr + .createSuccess({ + message: 'Hello from <%= apiName %>!' + }) + .render(); +}; +exports.getExample.public = true; +<% } %> diff --git a/packages/b2c-tooling-sdk/data/scaffolds/custom-api/scaffold.json b/packages/b2c-tooling-sdk/data/scaffolds/custom-api/scaffold.json new file mode 100644 index 00000000..7b882811 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/custom-api/scaffold.json @@ -0,0 +1,65 @@ +{ + "name": "custom-api", + "displayName": "Custom SCAPI", + "description": "Create a custom SCAPI endpoint with OAS 3.0 schema", + "category": "cartridge", + "parameters": [ + { + "name": "apiName", + "prompt": "What is the API name?", + "type": "string", + "required": true, + "pattern": "^[a-z][a-z0-9-]*$", + "validationMessage": "API name must be kebab-case (lowercase letters, numbers, hyphens)" + }, + { + "name": "apiType", + "prompt": "What type of API is this?", + "type": "choice", + "required": true, + "default": "shopper", + "choices": [ + { "value": "shopper", "label": "Shopper API (requires siteId, customer-facing)" }, + { "value": "admin", "label": "Admin API (no siteId, administrative)" } + ] + }, + { + "name": "apiDescription", + "prompt": "Describe your API:", + "type": "string", + "required": false, + "default": "A custom B2C Commerce API" + }, + { + "name": "cartridgeName", + "prompt": "Which cartridge should contain this API?", + "type": "string", + "required": true, + "source": "cartridges", + "pattern": "^[a-z][a-z0-9_]*$", + "validationMessage": "Cartridge name must start with a lowercase letter and contain only lowercase letters, numbers, and underscores" + }, + { + "name": "includeExampleEndpoints", + "prompt": "Include example endpoints?", + "type": "boolean", + "required": false, + "default": true + } + ], + "files": [ + { + "template": "schema.yaml.ejs", + "destination": "{{cartridgeNamePath}}/cartridge/rest-apis/{{apiName}}/schema.yaml" + }, + { + "template": "api.json.ejs", + "destination": "{{cartridgeNamePath}}/cartridge/rest-apis/{{apiName}}/api.json" + }, + { + "template": "script.js.ejs", + "destination": "{{cartridgeNamePath}}/cartridge/rest-apis/{{apiName}}/script.js" + } + ], + "postInstructions": "Custom API '<%= apiName %>' has been created in cartridge '<%= cartridgeName %>'.\n\nFiles created:\n- rest-apis/<%= apiName %>/schema.yaml (OAS 3.0 contract)\n- rest-apis/<%= apiName %>/api.json (endpoint mapping)\n- rest-apis/<%= apiName %>/script.js (implementation)\n\nNext steps:\n1. Deploy the cartridge to your instance\n2. Activate the code version to register the API\n3. The API will be available at:\n https://{shortCode}.api.commercecloud.salesforce.com/custom/<%= apiName %>/v1/organizations/{organizationId}/...\n<% if (apiType === 'shopper') { %>\nNote: Shopper APIs require the siteId query parameter and ShopperToken authentication.\n<% } else { %>\nNote: Admin APIs use AmOAuth2 authentication. Add cartridge to Business Manager site's cartridge path.\n<% } %>" +} diff --git a/packages/b2c-tooling-sdk/data/scaffolds/hook/files/hook.js.ejs b/packages/b2c-tooling-sdk/data/scaffolds/hook/files/hook.js.ejs new file mode 100644 index 00000000..e4cd118a --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/hook/files/hook.js.ejs @@ -0,0 +1,99 @@ +'use strict'; + +/** + * <%= hookName %> Hook Implementation + * + * Hook point: <%= hookPoint %> + * + * @module scripts/hooks/<%= hookName %> + */ + +var Logger = require('dw/system/Logger'); +var Status = require('dw/system/Status'); + +var log = Logger.getLogger('<%= hookName %>', '<%= cartridgeName %>'); + +<% if (hookType === 'ocapi') { %> +/** + * OCAPI Hook - Called before/after OCAPI operations + * + * @param {dw.order.Basket|dw.order.Order|Object} object - The object being processed + * @param {Object} doc - The OCAPI document + * @returns {dw.system.Status} - Status object + */ +exports.<%= hookName %> = function (object, doc) { + log.info('<%= hookName %> hook called'); + + try { + // TODO: Implement hook logic + // - Modify doc to add/change response fields + // - Validate/modify object before processing + + } catch (e) { + log.error('<%= hookName %> hook error: ' + e.message); + return new Status( + Status.ERROR, + '<%= hookName.toUpperCase() %>_ERROR', + e.message + ); + } + + return new Status(Status.OK); +}; +<% } else if (hookType === 'scapi') { %> +/** + * SCAPI Hook - Called for Shopper API extensions + * + * @param {dw.order.Basket|dw.order.Order|Object} object - The object being processed + * @param {Object} scriptResponse - Response object to populate + * @returns {dw.system.Status} - Status object + */ +exports.<%= hookName %> = function (object, scriptResponse) { + log.info('<%= hookName %> SCAPI hook called'); + + try { + // TODO: Implement hook logic + // - Add custom properties to scriptResponse + // - Validate/enrich the response + + } catch (e) { + log.error('<%= hookName %> hook error: ' + e.message); + return new Status( + Status.ERROR, + '<%= hookName.toUpperCase() %>_ERROR', + e.message + ); + } + + return new Status(Status.OK); +}; +<% } else { %> +/** + * System Hook Implementation + * + * @param {dw.order.Basket|dw.order.Order|dw.customer.Customer|Object} object - The object being processed + * @returns {dw.system.Status} - Status object + */ +exports.<%= hookName %> = function (object) { + log.info('<%= hookName %> system hook called'); + + try { + // TODO: Implement hook logic based on extension point + // Common extension points: + // - dw.order.calculate: Basket/order calculation + // - dw.order.createOrder: Order creation + // - dw.customer.registration: Customer registration + // - dw.extensions.applepay: Apple Pay processing + + } catch (e) { + log.error('<%= hookName %> hook error: ' + e.message); + return new Status( + Status.ERROR, + '<%= hookName.toUpperCase() %>_ERROR', + e.message + ); + } + + return new Status(Status.OK); +}; +<% } %> diff --git a/packages/b2c-tooling-sdk/data/scaffolds/hook/files/hooks-entry.json.ejs b/packages/b2c-tooling-sdk/data/scaffolds/hook/files/hooks-entry.json.ejs new file mode 100644 index 00000000..51207b35 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/hook/files/hooks-entry.json.ejs @@ -0,0 +1,6 @@ +[ + { + "name": "<%= hookPoint %>", + "script": "./scripts/hooks/<%= hookName %>" + } +] diff --git a/packages/b2c-tooling-sdk/data/scaffolds/hook/scaffold.json b/packages/b2c-tooling-sdk/data/scaffolds/hook/scaffold.json new file mode 100644 index 00000000..c714be78 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/hook/scaffold.json @@ -0,0 +1,60 @@ +{ + "name": "hook", + "displayName": "Hook Implementation", + "description": "Create a hook implementation with hooks.json registration", + "category": "cartridge", + "parameters": [ + { + "name": "hookName", + "prompt": "What is the hook script name?", + "type": "string", + "required": true, + "pattern": "^[a-z][a-zA-Z0-9]*$", + "validationMessage": "Hook name must be camelCase (start with lowercase letter)" + }, + { + "name": "hookType", + "prompt": "What type of hook is this?", + "type": "choice", + "required": true, + "choices": [ + { "value": "ocapi", "label": "OCAPI Hook (shop/data API extension)" }, + { "value": "scapi", "label": "SCAPI Hook (shopper API extension)" }, + { "value": "system", "label": "System Hook (order, basket, etc.)" } + ], + "default": "system" + }, + { + "name": "hookPoint", + "prompt": "What is the hook extension point?", + "type": "string", + "required": true, + "source": "hook-points", + "validationMessage": "Hook point is required (e.g., dw.order.calculate, dw.ocapi.shop.order.beforePOST)" + }, + { + "name": "cartridgeName", + "prompt": "Which cartridge should contain this hook?", + "type": "string", + "required": true, + "source": "cartridges", + "pattern": "^[a-z][a-z0-9_]*$", + "validationMessage": "Cartridge name must start with a lowercase letter and contain only lowercase letters, numbers, and underscores" + } + ], + "files": [ + { + "template": "hook.js.ejs", + "destination": "{{cartridgeNamePath}}/cartridge/scripts/hooks/{{hookName}}.js" + } + ], + "modifications": [ + { + "target": "{{cartridgeNamePath}}/cartridge/hooks.json", + "type": "json-merge", + "contentTemplate": "hooks-entry.json.ejs", + "jsonPath": "hooks" + } + ], + "postInstructions": "Hook '<%= hookName %>' has been created in '<%= cartridgeName %>'.\n\nHook point: <%= hookPoint %>\n\nNext steps:\n1. Implement the hook logic in cartridge/scripts/hooks/<%= hookName %>.js\n2. Deploy the cartridge to your instance\n3. Ensure <%= cartridgeName %> is in your cartridge path" +} diff --git a/packages/b2c-tooling-sdk/data/scaffolds/job-step/files/step-chunk.js.ejs b/packages/b2c-tooling-sdk/data/scaffolds/job-step/files/step-chunk.js.ejs new file mode 100644 index 00000000..8aa24332 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/job-step/files/step-chunk.js.ejs @@ -0,0 +1,136 @@ +'use strict'; + +/** + * <%= stepId %> Job Step (Chunk-oriented) + * + * <%= stepDescription %> + * + * Chunk-oriented steps process data in batches with automatic + * transaction handling and checkpoint/restart capability. + * + * @module scripts/jobsteps/<%= camelCase(stepId) %> + */ + +var Status = require('dw/system/Status'); +var Logger = require('dw/system/Logger'); + +var log = Logger.getLogger('<%= camelCase(stepId) %>', 'job'); + +/** + * Read the next item to process + * + * Called repeatedly until it returns null. + * + * @param {dw.job.JobStepExecution} stepExecution - The step execution context + * @param {Object} parameters - Step parameters from job configuration + * @returns {Object|null} - The next item to process, or null when done + */ +exports.read = function (stepExecution, parameters) { + // TODO: Implement your read logic + // Examples: + // - Read next product from search results + // - Read next line from file + // - Read next record from database query + + // Return null to signal end of data + return null; +}; + +/** + * Process a single item + * + * Called for each item returned by read(). + * Processing happens within a transaction. + * + * @param {Object} item - The item to process (returned by read) + * @param {dw.job.JobStepExecution} stepExecution - The step execution context + * @param {Object} parameters - Step parameters from job configuration + * @returns {Object} - The processed item (passed to write) + */ +exports.process = function (item, stepExecution, parameters) { + // TODO: Implement your processing logic + // Examples: + // - Transform product data + // - Validate and enrich records + // - Calculate values + + return item; +}; + +/** + * Write processed items + * + * Called with a chunk of processed items. + * Chunk size is configured in steptypes.json. + * + * @param {dw.util.List} items - List of processed items + * @param {dw.job.JobStepExecution} stepExecution - The step execution context + * @param {Object} parameters - Step parameters from job configuration + */ +exports.write = function (items, stepExecution, parameters) { + // TODO: Implement your write logic + // Examples: + // - Write records to database + // - Export to file + // - Send to external service + + log.info('Wrote ' + items.size() + ' items'); +}; + +/** + * Called before processing begins + * + * @param {dw.job.JobStepExecution} stepExecution - The step execution context + * @param {Object} parameters - Step parameters from job configuration + */ +exports.beforeStep = function (stepExecution, parameters) { + log.info('<%= stepId %> chunk step started'); + // TODO: Initialize resources (file handles, connections, etc.) +}; + +/** + * Called after all processing is complete + * + * @param {boolean} success - Whether the step completed successfully + * @param {dw.job.JobStepExecution} stepExecution - The step execution context + * @param {Object} parameters - Step parameters from job configuration + */ +exports.afterStep = function (success, stepExecution, parameters) { + // TODO: Cleanup resources + log.info('<%= stepId %> chunk step ' + (success ? 'completed' : 'failed')); +}; + +/** + * Called before each chunk is processed + * + * @param {dw.job.JobStepExecution} stepExecution - The step execution context + * @param {Object} parameters - Step parameters from job configuration + */ +exports.beforeChunk = function (stepExecution, parameters) { + // Optional: Pre-chunk initialization +}; + +/** + * Called after each chunk is processed + * + * @param {boolean} success - Whether the chunk completed successfully + * @param {dw.job.JobStepExecution} stepExecution - The step execution context + * @param {Object} parameters - Step parameters from job configuration + */ +exports.afterChunk = function (success, stepExecution, parameters) { + // Optional: Post-chunk cleanup or logging +}; + +/** + * Get the total count of items to process (optional) + * + * Used for progress tracking in Business Manager. + * + * @param {dw.job.JobStepExecution} stepExecution - The step execution context + * @param {Object} parameters - Step parameters from job configuration + * @returns {Number} - Total number of items to process + */ +exports.getTotalCount = function (stepExecution, parameters) { + // TODO: Return total count if known, -1 if unknown + return -1; +}; diff --git a/packages/b2c-tooling-sdk/data/scaffolds/job-step/files/step-task.js.ejs b/packages/b2c-tooling-sdk/data/scaffolds/job-step/files/step-task.js.ejs new file mode 100644 index 00000000..c7f0e610 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/job-step/files/step-task.js.ejs @@ -0,0 +1,47 @@ +'use strict'; + +/** + * <%= stepId %> Job Step (Task-oriented) + * + * <%= stepDescription %> + * + * @module scripts/jobsteps/<%= camelCase(stepId) %> + */ + +var Status = require('dw/system/Status'); +var Logger = require('dw/system/Logger'); + +var log = Logger.getLogger('<%= camelCase(stepId) %>', 'job'); + +/** + * Execute the job step + * + * Task-oriented steps run once per job execution. + * Use for simple operations that don't need chunked processing. + * + * @param {dw.job.JobStepExecution} stepExecution - The step execution context + * @param {Object} parameters - Step parameters from job configuration + * @returns {dw.system.Status} - Execution status + */ +exports.execute = function (stepExecution, parameters) { + log.info('<%= stepId %> step started'); + + try { + // Access job parameters + // var myParam = parameters.get('MyParameter'); + + // TODO: Implement your step logic here + // Examples: + // - Export data to a file + // - Call an external service + // - Update records in bulk + // - Generate reports + + log.info('<%= stepId %> step completed successfully'); + return new Status(Status.OK); + + } catch (e) { + log.error('<%= stepId %> step failed: ' + e.message); + return new Status(Status.ERROR, 'ERROR', e.message); + } +}; diff --git a/packages/b2c-tooling-sdk/data/scaffolds/job-step/files/steptypes-entry.json.ejs b/packages/b2c-tooling-sdk/data/scaffolds/job-step/files/steptypes-entry.json.ejs new file mode 100644 index 00000000..6fc76041 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/job-step/files/steptypes-entry.json.ejs @@ -0,0 +1,41 @@ +[ + { + "@type-id": "<%= stepId %>", +<% if (stepType === 'task') { %> "@supports-parallel-execution": "false", + "@supports-site-context": "true", + "@supports-organization-context": "false", + "module": "cartridge/scripts/jobsteps/<%= camelCase(stepId) %>.js", + "function": "execute", +<% } else { %> "@supports-parallel-execution": "true", + "@supports-site-context": "true", + "@supports-organization-context": "false", + "chunk-size": 100, + "transactional": "true", + "module": "cartridge/scripts/jobsteps/<%= camelCase(stepId) %>.js", + "before-step-function": "beforeStep", + "before-chunk-function": "beforeChunk", + "read-function": "read", + "process-function": "process", + "write-function": "write", + "after-chunk-function": "afterChunk", + "after-step-function": "afterStep", + "total-count-function": "getTotalCount", +<% } %> "description": "<%= stepDescription %>", + "parameters": { + "parameter": [ + ] + }, + "status-codes": { + "status": [ + { + "@code": "ERROR", + "description": "An error occurred during step execution" + }, + { + "@code": "OK", + "description": "Step completed successfully" + } + ] + } + } +] diff --git a/packages/b2c-tooling-sdk/data/scaffolds/job-step/scaffold.json b/packages/b2c-tooling-sdk/data/scaffolds/job-step/scaffold.json new file mode 100644 index 00000000..40fe4282 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/job-step/scaffold.json @@ -0,0 +1,64 @@ +{ + "name": "job-step", + "displayName": "Custom Job Step", + "description": "Create a custom job step with steptypes.json registration", + "category": "cartridge", + "parameters": [ + { + "name": "stepId", + "prompt": "What is the step ID?", + "type": "string", + "required": true, + "pattern": "^[a-z][a-zA-Z0-9.]*$", + "validationMessage": "Step ID must start with a lowercase letter (e.g., custom.MyStep)" + }, + { + "name": "stepType", + "prompt": "What type of job step?", + "type": "choice", + "required": true, + "choices": [ + { "value": "task", "label": "Task-oriented (simple, single execution)" }, + { "value": "chunk", "label": "Chunk-oriented (batch processing with read/process/write)" } + ], + "default": "task" + }, + { + "name": "stepDescription", + "prompt": "Describe what this step does:", + "type": "string", + "required": false, + "default": "A custom job step" + }, + { + "name": "cartridgeName", + "prompt": "Which cartridge should contain this job step?", + "type": "string", + "required": true, + "source": "cartridges", + "pattern": "^[a-z][a-z0-9_]*$", + "validationMessage": "Cartridge name must start with a lowercase letter and contain only lowercase letters, numbers, and underscores" + } + ], + "files": [ + { + "template": "step-task.js.ejs", + "destination": "{{cartridgeNamePath}}/cartridge/scripts/jobsteps/{{camelCase stepId}}.js", + "condition": "stepType=task" + }, + { + "template": "step-chunk.js.ejs", + "destination": "{{cartridgeNamePath}}/cartridge/scripts/jobsteps/{{camelCase stepId}}.js", + "condition": "stepType=chunk" + } + ], + "modifications": [ + { + "target": "{{cartridgeNamePath}}/steptypes.json", + "type": "json-merge", + "contentTemplate": "steptypes-entry.json.ejs", + "jsonPath": "step-types" + } + ], + "postInstructions": "Job step '<%= stepId %>' has been created in '<%= cartridgeName %>'.\n\nStep type: <%= stepType %>\n\nNext steps:\n1. Implement the step logic in cartridge/scripts/jobsteps/<%= camelCase(stepId) %>.js\n2. Deploy the cartridge to your instance\n3. Create a job in Business Manager using step type '<%= stepId %>'" +} diff --git a/packages/b2c-tooling-sdk/data/scaffolds/page-designer-component/files/component.isml.ejs b/packages/b2c-tooling-sdk/data/scaffolds/page-designer-component/files/component.isml.ejs new file mode 100644 index 00000000..70be2fd0 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/page-designer-component/files/component.isml.ejs @@ -0,0 +1,23 @@ + + + + +
+ +

${pdict.heading}

+
+ + +
+ +
+
+ +<% if (hasRegions) { %> + +
+ +
+
+<% } %> +
diff --git a/packages/b2c-tooling-sdk/data/scaffolds/page-designer-component/files/component.js.ejs b/packages/b2c-tooling-sdk/data/scaffolds/page-designer-component/files/component.js.ejs new file mode 100644 index 00000000..54b9ce59 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/page-designer-component/files/component.js.ejs @@ -0,0 +1,35 @@ +'use strict'; + +/** + * <%= componentName %> Page Designer Component + * + * @module experience/components/<%= componentGroup %>/<%= componentId %> + */ + +var Template = require('dw/util/Template'); +var HashMap = require('dw/util/HashMap'); +<% if (hasRegions) { %>var PageRenderHelper = require('*/cartridge/experience/utilities/PageRenderHelper.js'); +<% } %> + +/** + * Render the <%= componentName %> component + * + * @param {dw.experience.ComponentScriptContext} context - The component script context + * @returns {string} - The rendered template string + */ +module.exports.render = function (context) { + var model = new HashMap(); + var content = context.content; + + // Get component attributes + model.heading = content.heading || ''; + model.content = content.content || ''; + model.cssClass = content.cssClass || ''; + +<% if (hasRegions) { %> // Render regions + var regions = PageRenderHelper.getRegionModelRegistry(context.component); + model.regions = regions; + model.mainRegion = regions.main_region ? regions.main_region.render() : ''; + +<% } %> return new Template('experience/components/<%= componentGroup %>/<%= componentId %>').render(model).text; +}; diff --git a/packages/b2c-tooling-sdk/data/scaffolds/page-designer-component/files/component.json.ejs b/packages/b2c-tooling-sdk/data/scaffolds/page-designer-component/files/component.json.ejs new file mode 100644 index 00000000..38987188 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/page-designer-component/files/component.json.ejs @@ -0,0 +1,50 @@ +{ + "name": "<%= componentName %>", + "description": "<%= componentName %> component for Page Designer", + "group": "<%= componentGroup %>", + "attribute_definition_groups": [ + { + "id": "main", + "name": "Main Settings", + "description": "Main component settings", + "attribute_definitions": [ + { + "id": "heading", + "name": "Heading", + "description": "Component heading text", + "type": "string", + "required": false + }, + { + "id": "content", + "name": "Content", + "description": "Main content text", + "type": "markup", + "required": false + } + ] + }, + { + "id": "styling", + "name": "Styling", + "description": "Visual styling options", + "attribute_definitions": [ + { + "id": "cssClass", + "name": "CSS Class", + "description": "Additional CSS classes", + "type": "string", + "required": false + } + ] + } + ]<% if (hasRegions) { %>, + "region_definitions": [ + { + "id": "main_region", + "name": "Main Region", + "description": "Main content region for nested components", + "max_components": 10 + } + ]<% } %> +} diff --git a/packages/b2c-tooling-sdk/data/scaffolds/page-designer-component/scaffold.json b/packages/b2c-tooling-sdk/data/scaffolds/page-designer-component/scaffold.json new file mode 100644 index 00000000..20bc1703 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/page-designer-component/scaffold.json @@ -0,0 +1,66 @@ +{ + "name": "page-designer-component", + "displayName": "Page Designer Component", + "description": "Create a Page Designer component with meta JSON, script, and template", + "category": "cartridge", + "parameters": [ + { + "name": "componentId", + "prompt": "What is the component ID?", + "type": "string", + "required": true, + "pattern": "^[a-z][a-zA-Z0-9_]*$", + "validationMessage": "Component ID must start with a lowercase letter and contain only letters, numbers, and underscores" + }, + { + "name": "componentName", + "prompt": "What is the display name for the component?", + "type": "string", + "required": true + }, + { + "name": "componentGroup", + "prompt": "Which group should the component appear in?", + "type": "choice", + "required": true, + "choices": [ + { "value": "content", "label": "Content (text, images, banners)" }, + { "value": "commerce", "label": "Commerce (products, categories)" }, + { "value": "layouts", "label": "Layouts (grids, containers)" }, + { "value": "custom", "label": "Custom" } + ], + "default": "content" + }, + { + "name": "hasRegions", + "prompt": "Does this component have regions (nested components)?", + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "cartridgeName", + "prompt": "Which cartridge should contain this component?", + "type": "string", + "required": true, + "source": "cartridges", + "pattern": "^[a-z][a-z0-9_]*$", + "validationMessage": "Cartridge name must start with a lowercase letter and contain only lowercase letters, numbers, and underscores" + } + ], + "files": [ + { + "template": "component.json.ejs", + "destination": "{{cartridgeNamePath}}/cartridge/experience/components/{{componentGroup}}/{{componentId}}.json" + }, + { + "template": "component.js.ejs", + "destination": "{{cartridgeNamePath}}/cartridge/experience/components/{{componentGroup}}/{{componentId}}.js" + }, + { + "template": "component.isml.ejs", + "destination": "{{cartridgeNamePath}}/cartridge/templates/default/experience/components/{{componentGroup}}/{{componentId}}.isml" + } + ], + "postInstructions": "Page Designer component '<%= componentId %>' has been created in '<%= cartridgeName %>'.\n\nComponent files:\n- Meta: experience/components/<%= componentGroup %>/<%= componentId %>.json\n- Script: experience/components/<%= componentGroup %>/<%= componentId %>.js\n- Template: templates/default/experience/components/<%= componentGroup %>/<%= componentId %>.isml\n\nNext steps:\n1. Deploy the cartridge to your instance\n2. The component will appear in Page Designer under '<%= componentGroup.charAt(0).toUpperCase() + componentGroup.slice(1) %>'" +} diff --git a/packages/b2c-tooling-sdk/data/scaffolds/service/files/service-http.js.ejs b/packages/b2c-tooling-sdk/data/scaffolds/service/files/service-http.js.ejs new file mode 100644 index 00000000..c6a1b632 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/service/files/service-http.js.ejs @@ -0,0 +1,245 @@ +'use strict'; + +/** + * <%= serviceName %> Service + * + * HTTP service for external API integration. + * Configure in Business Manager: Administration > Operations > Services + * + * Service ID: <%= cartridgeName %>.<%= serviceName.toLowerCase() %> + * + * @module scripts/services/<%= serviceName %>Service + */ + +var LocalServiceRegistry = require('dw/svc/LocalServiceRegistry'); +var Logger = require('dw/system/Logger'); + +var log = Logger.getLogger('<%= serviceName %>Service', '<%= cartridgeName %>'); + +/** + * Service ID - must match Business Manager configuration + * @type {string} + */ +var SERVICE_ID = '<%= cartridgeName %>.<%= serviceName.toLowerCase() %>'; + +/** + * Create the <%= serviceName %> service instance + * @returns {dw.svc.HTTPService} The service instance + */ +function createService() { + return LocalServiceRegistry.createService(SERVICE_ID, { + /** + * Configure the HTTP request + * @param {dw.svc.HTTPService} svc - The service instance + * @param {Object} params - Parameters passed to service.call() + * @returns {string|null} Request body (null for GET/DELETE) + */ + createRequest: function (svc, params) { + var method = params.method || 'GET'; + svc.setRequestMethod(method); +<% if (authType === 'BASIC') { %> + // Basic authentication - uses credentials from Business Manager + svc.setAuthentication('BASIC'); +<% } else if (authType === 'BEARER') { %> + // Bearer token authentication + svc.setAuthentication('NONE'); + if (params.accessToken) { + svc.addHeader('Authorization', 'Bearer ' + params.accessToken); + } +<% } else if (authType === 'API_KEY') { %> + // API key authentication - password field stores the API key + svc.setAuthentication('NONE'); + var credential = svc.configuration.credential; + svc.addHeader('X-API-Key', credential.password); +<% } else { %> + svc.setAuthentication('NONE'); +<% } %> + + // Set headers + svc.addHeader('Accept', 'application/json'); + + // Append path to base URL if provided + if (params.path) { + svc.setURL(svc.URL + params.path); + } + + // Add query parameters + if (params.queryParams) { + Object.keys(params.queryParams).forEach(function (key) { + svc.addParam(key, String(params.queryParams[key])); + }); + } + + // Set request body for POST/PUT/PATCH + if (params.body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) { + svc.addHeader('Content-Type', 'application/json'); + return JSON.stringify(params.body); + } + + return null; + }, + + /** + * Parse the HTTP response + * @param {dw.svc.HTTPService} svc - The service instance + * @param {dw.net.HTTPClient} client - The HTTP client with response + * @returns {Object} Parsed response + */ + parseResponse: function (svc, client) { +<% if (includeErrorHandling) { %> + var response = { + statusCode: client.statusCode, + success: client.statusCode >= 200 && client.statusCode < 300, + data: null, + errors: [] + }; + + if (response.success) { + try { + response.data = JSON.parse(client.text); + } catch (e) { + response.data = client.text; + } + } else { + var errorBody = client.errorText || client.text; + try { + var errorData = JSON.parse(errorBody); + response.errors = errorData.errors || [errorData.message || errorData.error]; + } catch (e) { + response.errors = [errorBody || 'Unknown error (HTTP ' + client.statusCode + ')']; + } + } + + return response; +<% } else { %> + if (client.statusCode >= 200 && client.statusCode < 300) { + return JSON.parse(client.text); + } + return null; +<% } %> + }, +<% if (includeMocking) { %> + + /** + * Mock response for testing (when service is in mock mode) + * @param {dw.svc.HTTPService} svc - The service instance + * @param {Object} params - Parameters passed to service.call() + * @returns {Object} Mock response + */ + mockCall: function (svc, params) { + log.info('<%= serviceName %>Service mock call with params: ' + JSON.stringify(params)); + return { + statusCode: 200, + text: JSON.stringify({ + success: true, + message: 'Mock response from <%= serviceName %>Service', + requestedPath: params.path, + timestamp: new Date().toISOString() + }) + }; + }, +<% } %> + + /** + * Filter sensitive data from log messages (required for production) + * @param {string} msg - The message to filter + * @returns {string} Filtered message + */ + filterLogMessage: function (msg) { +<% if (authType === 'BASIC') { %> + // Filter Basic auth header + msg = msg.replace(/Authorization:\s*Basic\s+[^\r\n]+/gi, 'Authorization: Basic ***'); +<% } else if (authType === 'BEARER') { %> + // Filter Bearer token + msg = msg.replace(/Bearer\s+[^\s\r\n]+/gi, 'Bearer ***'); +<% } else if (authType === 'API_KEY') { %> + // Filter API key header + msg = msg.replace(/X-API-Key:\s*[^\r\n]+/gi, 'X-API-Key: ***'); +<% } %> + // Filter common sensitive fields in JSON + msg = msg.replace(/"(password|secret|token|api_key|apiKey)"\s*:\s*"[^"]+"/gi, '"$1":"***"'); + return msg; + } + }); +} + +/** + * Get or create the service instance + * @returns {dw.svc.HTTPService} The service instance + */ +function getService() { + return createService(); +} + +/** + * Call the <%= serviceName %> service + * + * @param {Object} params - Request parameters + * @param {string} [params.method='GET'] - HTTP method (GET, POST, PUT, DELETE, PATCH) + * @param {string} [params.path] - Path to append to base URL + * @param {Object} [params.queryParams] - Query string parameters + * @param {Object} [params.body] - Request body (for POST/PUT/PATCH) +<% if (authType === 'BEARER') { %> + * @param {string} [params.accessToken] - Bearer token for authentication +<% } %> + * @returns {dw.svc.Result} Service result + * + * @example + * // GET request + * var result = <%= serviceName %>Service.call({ path: '/items/123' }); + * + * @example + * // POST request + * var result = <%= serviceName %>Service.call({ + * method: 'POST', + * path: '/items', + * body: { name: 'New Item' } + * }); + */ +function call(params) { + var service = getService(); + var result = service.call(params || {}); + + if (!result.ok) { + log.error('<%= serviceName %>Service call failed: ' + result.errorMessage + + ' (status: ' + result.status + ', unavailable: ' + result.unavailableReason + ')'); + } + + return result; +} +<% if (includeErrorHandling) { %> + +/** + * Call the service and return data or throw error + * + * @param {Object} params - Request parameters (see call() for details) + * @returns {Object} Response data + * @throws {Error} If service call fails + */ +function callOrThrow(params) { + var result = call(params); + + if (!result.ok) { + var errorMsg = '<%= serviceName %>Service error: ' + result.errorMessage; + if (result.unavailableReason) { + errorMsg += ' (reason: ' + result.unavailableReason + ')'; + } + throw new Error(errorMsg); + } + + var response = result.object; + if (!response.success) { + throw new Error('<%= serviceName %>Service HTTP error ' + response.statusCode + ': ' + + (response.errors.length > 0 ? response.errors.join(', ') : 'Unknown error')); + } + + return response.data; +} +<% } %> + +module.exports = { + SERVICE_ID: SERVICE_ID, + getService: getService, + call: call<% if (includeErrorHandling) { %>, + callOrThrow: callOrThrow<% } %> +}; diff --git a/packages/b2c-tooling-sdk/data/scaffolds/service/files/service-sftp.js.ejs b/packages/b2c-tooling-sdk/data/scaffolds/service/files/service-sftp.js.ejs new file mode 100644 index 00000000..455d7703 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/service/files/service-sftp.js.ejs @@ -0,0 +1,371 @@ +'use strict'; + +/** + * <%= serviceName %> Service + * + * SFTP service for secure file transfer operations. + * Configure in Business Manager: Administration > Operations > Services + * + * Service ID: <%= cartridgeName %>.<%= serviceName.toLowerCase() %> + * + * Credential configuration: + * - URL: sftp://hostname:port (default port 22) + * - User: SFTP username + * - Password: SFTP password or private key passphrase + * + * @module scripts/services/<%= serviceName %>Service + */ + +var LocalServiceRegistry = require('dw/svc/LocalServiceRegistry'); +var Logger = require('dw/system/Logger'); +var File = require('dw/io/File'); + +var log = Logger.getLogger('<%= serviceName %>Service', '<%= cartridgeName %>'); + +/** + * Service ID - must match Business Manager configuration + * @type {string} + */ +var SERVICE_ID = '<%= cartridgeName %>.<%= serviceName.toLowerCase() %>'; + +/** + * Supported SFTP operations + * @enum {string} + */ +var Operations = { + LIST: 'list', + GET: 'get', + GET_BINARY: 'getBinary', + PUT: 'put', + PUT_BINARY: 'putBinary', + DELETE: 'del', + MKDIR: 'mkdir', + RENAME: 'rename', + FILE_INFO: 'getFileInfo' +}; + +/** + * Create the <%= serviceName %> SFTP service instance + * @returns {dw.svc.FTPService} The service instance + */ +function createService() { + return LocalServiceRegistry.createService(SERVICE_ID, { + /** + * Configure the SFTP operation + * @param {dw.svc.FTPService} svc - The service instance + * @param {Object} params - Operation parameters + */ + createRequest: function (svc, params) { + var operation = params.operation || Operations.LIST; + var args = []; + + switch (operation) { + case Operations.LIST: + args = [params.path || '/']; + break; + + case Operations.GET: + args = [params.remotePath]; + break; + + case Operations.GET_BINARY: + var downloadFile = new File(File.IMPEX + '/src/' + params.localFilename); + args = [params.remotePath, downloadFile]; + break; + + case Operations.PUT: + args = [params.remotePath, params.content]; + break; + + case Operations.PUT_BINARY: + var uploadFile = new File(File.IMPEX + '/src/' + params.localFilename); + args = [params.remotePath, uploadFile]; + break; + + case Operations.DELETE: + args = [params.remotePath]; + break; + + case Operations.MKDIR: + args = [params.remotePath]; + break; + + case Operations.RENAME: + args = [params.oldPath, params.newPath]; + break; + + case Operations.FILE_INFO: + args = [params.remotePath]; + break; + + default: + throw new Error('Unknown SFTP operation: ' + operation); + } + + svc.setOperation.apply(svc, [operation].concat(args)); + }, + + /** + * Parse the SFTP response + * @param {dw.svc.FTPService} svc - The service instance + * @param {*} response - The operation result + * @returns {Object} Parsed response + */ + parseResponse: function (svc, response) { +<% if (includeErrorHandling) { %> + var result = { + success: true, + data: null + }; + + // Handle different response types + if (response === null || response === undefined) { + result.success = false; + result.data = null; + } else if (typeof response === 'boolean') { + result.success = response; + result.data = response; + } else if (Array.isArray(response) || (response && typeof response.length === 'number')) { + // File list - convert to plain objects + var files = []; + for (var i = 0; i < response.length; i++) { + var f = response[i]; + files.push({ + name: f.name, + directory: f.directory, + size: f.size, + modificationTime: f.modificationTime + }); + } + result.data = files; + } else if (response && response.name !== undefined) { + // Single file info + result.data = { + name: response.name, + directory: response.directory, + size: response.size, + modificationTime: response.modificationTime + }; + } else { + // String content or other + result.data = response; + } + + return result; +<% } else { %> + // Handle file list responses + if (Array.isArray(response) || (response && typeof response.length === 'number')) { + var files = []; + for (var i = 0; i < response.length; i++) { + var f = response[i]; + files.push({ + name: f.name, + directory: f.directory, + size: f.size, + modificationTime: f.modificationTime + }); + } + return files; + } + return response; +<% } %> + }, +<% if (includeMocking) { %> + + /** + * Mock SFTP operations for testing + * @param {dw.svc.FTPService} svc - The service instance + * @param {Object} params - Operation parameters + * @returns {*} Mock response + */ + mockCall: function (svc, params) { + log.info('<%= serviceName %>Service mock call: ' + params.operation); + + switch (params.operation) { + case Operations.LIST: + return [ + { name: 'mock-file-1.xml', directory: false, size: 1024, modificationTime: new Date() }, + { name: 'mock-file-2.csv', directory: false, size: 2048, modificationTime: new Date() }, + { name: 'archive', directory: true, size: 0, modificationTime: new Date() } + ]; + + case Operations.GET: + return 'Mock file content'; + + case Operations.FILE_INFO: + return { name: 'mock-file.xml', directory: false, size: 1024, modificationTime: new Date() }; + + default: + return true; + } + }, +<% } %> + + /** + * Filter sensitive data from log messages + * @param {string} msg - The message to filter + * @returns {string} Filtered message + */ + filterLogMessage: function (msg) { + // Filter password from connection string + msg = msg.replace(/password=[^&\s]+/gi, 'password=***'); + return msg; + } + }); +} + +/** + * Get or create the service instance + * @returns {dw.svc.FTPService} The service instance + */ +function getService() { + return createService(); +} + +/** + * List files in a directory + * + * @param {string} [path='/'] - Remote directory path + * @returns {dw.svc.Result} Service result with file list + * + * @example + * var result = <%= serviceName %>Service.listFiles('/incoming'); + * if (result.ok) { + * result.object<% if (includeErrorHandling) { %>.data<% } %>.forEach(function(file) { + * log.info('File: ' + file.name + ' (' + file.size + ' bytes)'); + * }); + * } + */ +function listFiles(path) { + return call({ operation: Operations.LIST, path: path || '/' }); +} + +/** + * Download file content as string + * + * @param {string} remotePath - Remote file path + * @returns {dw.svc.Result} Service result with file content + */ +function getFile(remotePath) { + return call({ operation: Operations.GET, remotePath: remotePath }); +} + +/** + * Download file to local IMPEX directory + * + * @param {string} remotePath - Remote file path + * @param {string} localFilename - Local filename (saved to IMPEX/src/) + * @returns {dw.svc.Result} Service result (success boolean) + */ +function downloadFile(remotePath, localFilename) { + return call({ + operation: Operations.GET_BINARY, + remotePath: remotePath, + localFilename: localFilename + }); +} + +/** + * Upload string content to remote file + * + * @param {string} remotePath - Remote file path + * @param {string} content - File content + * @returns {dw.svc.Result} Service result (success boolean) + */ +function putFile(remotePath, content) { + return call({ + operation: Operations.PUT, + remotePath: remotePath, + content: content + }); +} + +/** + * Upload local file to remote path + * + * @param {string} localFilename - Local filename (from IMPEX/src/) + * @param {string} remotePath - Remote file path + * @returns {dw.svc.Result} Service result (success boolean) + */ +function uploadFile(localFilename, remotePath) { + return call({ + operation: Operations.PUT_BINARY, + localFilename: localFilename, + remotePath: remotePath + }); +} + +/** + * Delete a remote file + * + * @param {string} remotePath - Remote file path + * @returns {dw.svc.Result} Service result (success boolean) + */ +function deleteFile(remotePath) { + return call({ operation: Operations.DELETE, remotePath: remotePath }); +} + +/** + * Move or rename a remote file + * + * @param {string} oldPath - Current file path + * @param {string} newPath - New file path + * @returns {dw.svc.Result} Service result (success boolean) + */ +function moveFile(oldPath, newPath) { + return call({ + operation: Operations.RENAME, + oldPath: oldPath, + newPath: newPath + }); +} + +/** + * Get file metadata + * + * @param {string} remotePath - Remote file path + * @returns {dw.svc.Result} Service result with file info + */ +function getFileInfo(remotePath) { + return call({ operation: Operations.FILE_INFO, remotePath: remotePath }); +} + +/** + * Generic service call + * + * @param {Object} params - Operation parameters + * @param {string} params.operation - SFTP operation (list, get, put, etc.) + * @param {string} [params.path] - Directory path (for list) + * @param {string} [params.remotePath] - Remote file path + * @param {string} [params.localFilename] - Local filename + * @param {string} [params.content] - File content (for put) + * @param {string} [params.oldPath] - Old path (for rename) + * @param {string} [params.newPath] - New path (for rename) + * @returns {dw.svc.Result} Service result + */ +function call(params) { + var service = getService(); + var result = service.call(params); + + if (!result.ok) { + log.error('<%= serviceName %>Service call failed: ' + result.errorMessage + + ' (operation: ' + params.operation + ', status: ' + result.status + ')'); + } + + return result; +} + +module.exports = { + SERVICE_ID: SERVICE_ID, + Operations: Operations, + getService: getService, + call: call, + listFiles: listFiles, + getFile: getFile, + downloadFile: downloadFile, + putFile: putFile, + uploadFile: uploadFile, + deleteFile: deleteFile, + moveFile: moveFile, + getFileInfo: getFileInfo +}; diff --git a/packages/b2c-tooling-sdk/data/scaffolds/service/files/service-soap.js.ejs b/packages/b2c-tooling-sdk/data/scaffolds/service/files/service-soap.js.ejs new file mode 100644 index 00000000..500514ac --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/service/files/service-soap.js.ejs @@ -0,0 +1,230 @@ +'use strict'; + +/** + * <%= serviceName %> Service + * + * SOAP service for WSDL-based web service integration. + * Configure in Business Manager: Administration > Operations > Services + * + * Service ID: <%= cartridgeName %>.<%= serviceName.toLowerCase() %> + * + * Prerequisites: + * 1. Upload WSDL file to: <%= cartridgeName %>/cartridge/webreferences2/<%= serviceName %>.wsdl + * 2. The stub will be auto-generated as: webreferences2.<%= serviceName %> + * + * @module scripts/services/<%= serviceName %>Service + */ + +var LocalServiceRegistry = require('dw/svc/LocalServiceRegistry'); +var Logger = require('dw/system/Logger'); + +var log = Logger.getLogger('<%= serviceName %>Service', '<%= cartridgeName %>'); + +/** + * Service ID - must match Business Manager configuration + * @type {string} + */ +var SERVICE_ID = '<%= cartridgeName %>.<%= serviceName.toLowerCase() %>'; + +/** + * Create the <%= serviceName %> SOAP service instance + * @returns {dw.svc.SOAPService} The service instance + */ +function createService() { + return LocalServiceRegistry.createService(SERVICE_ID, { + /** + * Initialize the SOAP service client (stub) + * @param {dw.svc.SOAPService} svc - The service instance + * @returns {Object} The WSDL-generated service stub + */ + initServiceClient: function (svc) { + // TODO: Replace with your actual WSDL reference + // The stub is auto-generated from the WSDL file in webreferences2/ + // return webreferences2.<%= serviceName %>.getDefaultService(); + throw new Error('SOAP stub not configured. Upload WSDL to webreferences2/<%= serviceName %>.wsdl'); + }, + + /** + * Configure the SOAP request + * @param {dw.svc.SOAPService} svc - The service instance + * @param {Object} params - Parameters passed to service.call() + * @returns {Object} SOAP request object + */ + createRequest: function (svc, params) { + // TODO: Create WSDL-generated request object + // Example: + // var request = new webreferences2.<%= serviceName %>.<%= serviceName %>Request(); + // request.setId(params.id); + // request.setData(params.data); + // return request; + + return params; + }, + + /** + * Execute the SOAP operation + * @param {dw.svc.SOAPService} svc - The service instance + * @param {Object} request - The request object from createRequest + * @returns {Object} SOAP response object + */ + execute: function (svc, request) { + var client = svc.serviceClient; + + // TODO: Call the appropriate SOAP operation + // Example: + // return client.myOperation(request); + + throw new Error('SOAP operation not configured'); + }, + + /** + * Parse the SOAP response + * @param {dw.svc.SOAPService} svc - The service instance + * @param {Object} response - The response from execute + * @returns {Object} Parsed response + */ + parseResponse: function (svc, response) { +<% if (includeErrorHandling) { %> + var result = { + success: false, + data: null, + errorCode: null, + errorMessage: null + }; + + if (!response) { + result.errorMessage = 'No response received'; + return result; + } + + // TODO: Parse response based on your WSDL structure + // Example: + // result.success = response.getStatus() === 'SUCCESS'; + // result.data = { + // id: response.getId(), + // value: response.getValue() + // }; + // if (!result.success) { + // result.errorCode = response.getErrorCode(); + // result.errorMessage = response.getErrorMessage(); + // } + + result.success = true; + result.data = response; + return result; +<% } else { %> + // TODO: Parse response based on your WSDL structure + // Example: + // return { + // status: response.getStatus(), + // result: response.getResult() + // }; + return response; +<% } %> + }, +<% if (includeMocking) { %> + + /** + * Mock the SOAP execution phase for testing + * @param {dw.svc.SOAPService} svc - The service instance + * @param {Object} request - The request object + * @returns {Object} Mock response object + */ + mockCall: function (svc, request) { + log.info('<%= serviceName %>Service mock call'); + // Return mock response that mimics WSDL response structure + return { + getStatus: function () { return 'SUCCESS'; }, + getId: function () { return 'mock-id-123'; }, + getMessage: function () { return 'Mock response'; } + }; + }, +<% } %> + + /** + * Filter sensitive data from SOAP log messages + * @param {string} msg - The message to filter + * @returns {string} Filtered message + */ + filterLogMessage: function (msg) { + // Filter password elements + msg = msg.replace(/[^<]+<\/password>/gi, '***'); + msg = msg.replace(/]*>[^<]+<\/wsse:Password>/gi, '***'); + // Filter common sensitive elements + msg = msg.replace(/<(apiKey|secret|token)>[^<]+<\/\1>/gi, '<$1>***'); + return msg; + } + }); +} + +/** + * Get or create the service instance + * @returns {dw.svc.SOAPService} The service instance + */ +function getService() { + return createService(); +} + +/** + * Call the <%= serviceName %> SOAP service + * + * @param {Object} params - Request parameters (depends on WSDL operation) + * @returns {dw.svc.Result} Service result + * + * @example + * var result = <%= serviceName %>Service.call({ + * operation: 'getData', + * id: '12345' + * }); + * if (result.ok) { + * var data = result.object; + * } + */ +function call(params) { + var service = getService(); + var result = service.call(params || {}); + + if (!result.ok) { + log.error('<%= serviceName %>Service call failed: ' + result.errorMessage + + ' (status: ' + result.status + ', unavailable: ' + result.unavailableReason + ')'); + } + + return result; +} +<% if (includeErrorHandling) { %> + +/** + * Call the service and return data or throw error + * + * @param {Object} params - Request parameters + * @returns {Object} Response data + * @throws {Error} If service call fails + */ +function callOrThrow(params) { + var result = call(params); + + if (!result.ok) { + var errorMsg = '<%= serviceName %>Service error: ' + result.errorMessage; + if (result.unavailableReason) { + errorMsg += ' (reason: ' + result.unavailableReason + ')'; + } + throw new Error(errorMsg); + } + + var response = result.object; + if (!response.success) { + throw new Error('<%= serviceName %>Service SOAP error: ' + + (response.errorCode ? response.errorCode + ' - ' : '') + + (response.errorMessage || 'Unknown error')); + } + + return response.data; +} +<% } %> + +module.exports = { + SERVICE_ID: SERVICE_ID, + getService: getService, + call: call<% if (includeErrorHandling) { %>, + callOrThrow: callOrThrow<% } %> +}; diff --git a/packages/b2c-tooling-sdk/data/scaffolds/service/scaffold.json b/packages/b2c-tooling-sdk/data/scaffolds/service/scaffold.json new file mode 100644 index 00000000..ffbca097 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/scaffolds/service/scaffold.json @@ -0,0 +1,83 @@ +{ + "name": "service", + "displayName": "B2C Service", + "description": "Create a B2C Commerce web service using LocalServiceRegistry", + "category": "cartridge", + "parameters": [ + { + "name": "serviceName", + "prompt": "What is the service name?", + "type": "string", + "required": true, + "pattern": "^[A-Z][a-zA-Z0-9]*$", + "validationMessage": "Service name must be PascalCase (e.g., ProductAPI, OrderSync)" + }, + { + "name": "cartridgeName", + "prompt": "Which cartridge should contain this service?", + "type": "string", + "required": true, + "source": "cartridges", + "pattern": "^[a-z][a-z0-9_]*$", + "validationMessage": "Cartridge name must start with a lowercase letter and contain only lowercase letters, numbers, and underscores" + }, + { + "name": "serviceType", + "prompt": "What type of service?", + "type": "choice", + "required": true, + "choices": [ + { "value": "HTTP", "label": "HTTP (REST API calls)" }, + { "value": "SOAP", "label": "SOAP (WSDL-based web services)" }, + { "value": "SFTP", "label": "SFTP (Secure file transfers)" } + ], + "default": "HTTP" + }, + { + "name": "authType", + "prompt": "What authentication method?", + "type": "choice", + "required": false, + "choices": [ + { "value": "NONE", "label": "None (no authentication)" }, + { "value": "BASIC", "label": "Basic (username/password)" }, + { "value": "BEARER", "label": "Bearer Token (OAuth/JWT)" }, + { "value": "API_KEY", "label": "API Key (header-based)" } + ], + "default": "NONE", + "condition": "serviceType=HTTP" + }, + { + "name": "includeErrorHandling", + "prompt": "Include robust error handling pattern?", + "type": "boolean", + "required": false, + "default": true + }, + { + "name": "includeMocking", + "prompt": "Include mock callback for testing?", + "type": "boolean", + "required": false, + "default": false + } + ], + "files": [ + { + "template": "service-http.js.ejs", + "destination": "{{cartridgeNamePath}}/cartridge/scripts/services/{{serviceName}}Service.js", + "condition": "serviceType=HTTP" + }, + { + "template": "service-soap.js.ejs", + "destination": "{{cartridgeNamePath}}/cartridge/scripts/services/{{serviceName}}Service.js", + "condition": "serviceType=SOAP" + }, + { + "template": "service-sftp.js.ejs", + "destination": "{{cartridgeNamePath}}/cartridge/scripts/services/{{serviceName}}Service.js", + "condition": "serviceType=SFTP" + } + ], + "postInstructions": "Service '<%= serviceName %>Service' has been created in '<%= cartridgeName %>'.\n\nService Type: <%= serviceType %>\n<% if (serviceType === 'HTTP' && authType) { %>Authentication: <%= authType %>\n<% } %>\nNext steps:\n1. Configure the service in Business Manager:\n - Go to Administration > Operations > Services\n - Create a Service Configuration with ID: <%= cartridgeName %>.<%= serviceName.toLowerCase() %>\n - Create a Service Credential with your endpoint URL<% if (serviceType === 'HTTP' && (authType === 'BASIC' || authType === 'API_KEY')) { %> and credentials<% } %>\n - Create a Service Profile with timeout and rate limiting\n2. Deploy the cartridge to your instance\n3. Call the service:\n var <%= serviceName %>Service = require('*/cartridge/scripts/services/<%= serviceName %>Service');\n var result = <%= serviceName %>Service.call({ /* params */ });" +} diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index f8c0f1cc..37c8df18 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -233,6 +233,17 @@ "default": "./dist/cjs/skills/index.js" } }, + "./scaffold": { + "development": "./src/scaffold/index.ts", + "import": { + "types": "./dist/esm/scaffold/index.d.ts", + "default": "./dist/esm/scaffold/index.js" + }, + "require": { + "types": "./dist/cjs/scaffold/index.d.ts", + "default": "./dist/cjs/scaffold/index.js" + } + }, "./test-utils": { "development": "./src/test-utils/index.ts", "import": { @@ -293,6 +304,7 @@ "@salesforce/dev-config": "^4.3.2", "@tony.ganchev/eslint-plugin-header": "^3.1.11", "@types/archiver": "^7.0.0", + "@types/ejs": "^3.1.5", "@types/chai": "^4.3.20", "@types/mocha": "^10.0.10", "@types/node": "^18.19.130", @@ -327,6 +339,7 @@ "dependencies": { "@salesforce/telemetry": "^6.1.0", "archiver": "^7.0.1", + "ejs": "^3.1.10", "chokidar": "^5.0.0", "cliui": "^9.0.1", "fuse.js": "^7.0.0", diff --git a/packages/b2c-tooling-sdk/src/scaffold/engine.ts b/packages/b2c-tooling-sdk/src/scaffold/engine.ts new file mode 100644 index 00000000..bbb45031 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/scaffold/engine.ts @@ -0,0 +1,174 @@ +/* + * 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 {randomUUID} from 'node:crypto'; +import ejs from 'ejs'; +import type {TemplateHelpers} from './types.js'; + +/** + * Convert a string to kebab-case + */ +export function kebabCase(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/[\s_]+/g, '-') + .toLowerCase(); +} + +/** + * Convert a string to camelCase + */ +export function camelCase(str: string): string { + return str.replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : '')).replace(/^[A-Z]/, (c) => c.toLowerCase()); +} + +/** + * Convert a string to PascalCase + */ +export function pascalCase(str: string): string { + const camel = camelCase(str); + return camel.charAt(0).toUpperCase() + camel.slice(1); +} + +/** + * Convert a string to snake_case + */ +export function snakeCase(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, '$1_$2') + .replace(/[-\s]+/g, '_') + .toLowerCase(); +} + +/** + * Create template helpers with current date/time + */ +export function createTemplateHelpers(): TemplateHelpers { + const now = new Date(); + return { + kebabCase, + camelCase, + pascalCase, + snakeCase, + year: now.getFullYear(), + date: now.toISOString().split('T')[0], + uuid: () => randomUUID(), + }; +} + +/** + * Template rendering context combining variables and helpers + */ +export interface TemplateContext extends TemplateHelpers { + [key: string]: string | boolean | string[] | number | ((str: string) => string) | (() => string); +} + +/** + * Create a full template context with variables and helpers + */ +export function createTemplateContext(variables: Record): TemplateContext { + const helpers = createTemplateHelpers(); + return { + ...helpers, + ...variables, + }; +} + +/** + * Render an EJS template string + * @param template - The EJS template string + * @param context - Template context with variables and helpers + * @returns Rendered string + */ +export function renderTemplate(template: string, context: TemplateContext): string { + return ejs.render(template, context, { + // Allow includes with relative paths + root: '.', + }); +} + +/** + * Render a file path template (using {{variable}} syntax) + * @param pathTemplate - The path template string (e.g., "{{cartridgeName}}/cartridge/{{kebabCase moduleName}}.js") + * @param context - Template context with variables and helpers + * @returns Rendered path string + */ +export function renderPathTemplate(pathTemplate: string, context: TemplateContext): string { + // Convert {{variable}} syntax to values + return pathTemplate.replace(/\{\{([^}]+)\}\}/g, (_, expr) => { + const trimmed = expr.trim(); + + // Handle function calls like {{kebabCase moduleName}} + const funcMatch = trimmed.match(/^(\w+)\s+(.+)$/); + if (funcMatch) { + const [, funcName, argName] = funcMatch; + const func = context[funcName]; + const arg = context[argName]; + if (typeof func === 'function' && typeof arg === 'string') { + return func(arg); + } + } + + // Handle direct variable reference + const value = context[trimmed]; + if (typeof value === 'function') { + // Only call 0-argument functions (like uuid) + if (value.length === 0) { + return (value as () => string)(); + } + // Functions with arguments require the {{func arg}} syntax + return `{{${trimmed}}}`; + } + if (value !== undefined) { + return String(value); + } + + // Return original if not found (will likely cause an error later) + return `{{${trimmed}}}`; + }); +} + +/** + * Scaffold template engine + */ +export class ScaffoldEngine { + private context: TemplateContext; + + constructor(variables: Record) { + this.context = createTemplateContext(variables); + } + + /** + * Get the current template context + */ + getContext(): TemplateContext { + return this.context; + } + + /** + * Render an EJS template string + */ + render(template: string): string { + return renderTemplate(template, this.context); + } + + /** + * Render a file path template + */ + renderPath(pathTemplate: string): string { + return renderPathTemplate(pathTemplate, this.context); + } + + /** + * Render an EJS template file + */ + async renderFile(filePath: string): Promise { + const result = await ejs.renderFile(filePath, this.context, { + async: true, + }); + return result; + } +} diff --git a/packages/b2c-tooling-sdk/src/scaffold/executor.ts b/packages/b2c-tooling-sdk/src/scaffold/executor.ts new file mode 100644 index 00000000..ccc5ced1 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/scaffold/executor.ts @@ -0,0 +1,350 @@ +/* + * 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 fs from 'node:fs/promises'; +import path from 'node:path'; +import {glob} from 'glob'; +import type { + Scaffold, + ScaffoldGenerateOptions, + ScaffoldGenerateResult, + GeneratedFile, + FileMapping, + FileModification, +} from './types.js'; +import {ScaffoldEngine} from './engine.js'; +import {evaluateCondition, validateParameters} from './validators.js'; +import {mergeJson, insertAfter, insertBefore, appendContent, prependContent} from './merge.js'; + +/** + * Options for resolving output directory. + */ +export interface ResolveOutputDirectoryOptions { + /** Explicit output directory override */ + outputDir?: string; + /** Scaffold with potential defaultOutputDir */ + scaffold?: Scaffold; + /** Project root directory (defaults to cwd) */ + projectRoot?: string; +} + +/** + * Resolve output directory with priority: + * 1. Explicit outputDir option + * 2. Scaffold's defaultOutputDir + * 3. Project root / cwd + * + * @param options - Resolution options + * @returns Resolved absolute output directory path + */ +export function resolveOutputDirectory(options: ResolveOutputDirectoryOptions): string { + const {outputDir, scaffold, projectRoot = process.cwd()} = options; + + if (outputDir) { + // Explicit output directory takes priority + return path.resolve(projectRoot, outputDir); + } + + if (scaffold?.manifest.defaultOutputDir) { + // Scaffold's default output directory + return path.resolve(projectRoot, scaffold.manifest.defaultOutputDir); + } + + // Fall back to project root + return projectRoot; +} + +/** + * Check if a file exists + */ +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +/** + * Get default file mappings by scanning the files/ directory + */ +async function getDefaultFileMappings(filesPath: string): Promise { + const files = await glob('**/*', { + cwd: filesPath, + nodir: true, + dot: true, + }); + + return files.map((file) => ({ + template: file, + destination: file.replace(/\.ejs$/, ''), + })); +} + +/** + * Generate files from a scaffold + * @param scaffold - The scaffold to use + * @param options - Generation options + * @returns Generation result + */ +export async function generateFromScaffold( + scaffold: Scaffold, + options: ScaffoldGenerateOptions = {}, +): Promise { + const outputDir = options.outputDir || process.cwd(); + const variables = options.variables || {}; + const dryRun = options.dryRun ?? false; + const force = options.force ?? false; + + // Validate parameters + const validationResult = validateParameters(scaffold.manifest, variables); + if (!validationResult.valid) { + const errorMessages = validationResult.errors.map((e) => `${e.parameter}: ${e.message}`).join(', '); + throw new Error(`Invalid parameters: ${errorMessages}`); + } + + // Create template engine with resolved variables + const engine = new ScaffoldEngine(validationResult.values); + + // Get file mappings + const fileMappings = scaffold.manifest.files || (await getDefaultFileMappings(scaffold.filesPath)); + + const generatedFiles: GeneratedFile[] = []; + const createdDirs = new Set(); + + for (const mapping of fileMappings) { + // Check condition + if (mapping.condition && !evaluateCondition(mapping.condition, validationResult.values)) { + continue; + } + + const templatePath = path.join(scaffold.filesPath, mapping.template); + const destRendered = engine.renderPath(mapping.destination); + // If destination is absolute path, use it directly; otherwise join with outputDir + const destAbsolute = path.isAbsolute(destRendered) ? destRendered : path.join(outputDir, destRendered); + // For display purposes, show path relative to cwd + const destRelative = path.relative(process.cwd(), destAbsolute) || destAbsolute; + + // Check if destination exists + const exists = await fileExists(destAbsolute); + const overwrite = mapping.overwrite || 'never'; + + if (exists) { + if (overwrite === 'never' && !force) { + generatedFiles.push({ + path: destRelative, + absolutePath: destAbsolute, + action: 'skipped', + skipReason: 'File already exists', + }); + continue; + } + + if (overwrite === 'prompt' && !force) { + // In non-interactive mode without force, skip + generatedFiles.push({ + path: destRelative, + absolutePath: destAbsolute, + action: 'skipped', + skipReason: 'File already exists (prompt required)', + }); + continue; + } + } + + // Read and render template + let content: string; + try { + const templateContent = await fs.readFile(templatePath, 'utf-8'); + // Only render EJS if the file has .ejs extension + if (mapping.template.endsWith('.ejs')) { + content = engine.render(templateContent); + } else { + content = templateContent; + } + } catch (error) { + throw new Error(`Failed to read template ${mapping.template}: ${(error as Error).message}`); + } + + if (!dryRun) { + // Create parent directories + const destDir = path.dirname(destAbsolute); + if (!createdDirs.has(destDir)) { + await fs.mkdir(destDir, {recursive: true}); + createdDirs.add(destDir); + } + + // Write file + await fs.writeFile(destAbsolute, content, 'utf-8'); + } + + generatedFiles.push({ + path: destRelative, + absolutePath: destAbsolute, + action: exists ? 'overwritten' : 'created', + }); + } + + // Process file modifications + if (scaffold.manifest.modifications) { + for (const modification of scaffold.manifest.modifications) { + // Check condition + if (modification.condition && !evaluateCondition(modification.condition, validationResult.values)) { + continue; + } + + const targetRendered = engine.renderPath(modification.target); + const targetAbsolute = path.isAbsolute(targetRendered) ? targetRendered : path.join(outputDir, targetRendered); + const targetRelative = path.relative(process.cwd(), targetAbsolute) || targetAbsolute; + + // Get modification content + let modContent: string; + if (modification.contentTemplate) { + const templatePath = path.join(scaffold.filesPath, modification.contentTemplate); + try { + const templateContent = await fs.readFile(templatePath, 'utf-8'); + modContent = modification.contentTemplate.endsWith('.ejs') ? engine.render(templateContent) : templateContent; + } catch (error) { + throw new Error( + `Failed to read modification template ${modification.contentTemplate}: ${(error as Error).message}`, + ); + } + } else if (modification.content) { + modContent = engine.render(modification.content); + } else { + throw new Error(`Modification for ${modification.target} must have content or contentTemplate`); + } + + // Process the modification + const result = await processModification(modification, targetAbsolute, targetRelative, modContent, dryRun); + generatedFiles.push(result); + } + } + + // Render post-instructions if present + let postInstructions: string | undefined; + if (scaffold.manifest.postInstructions) { + postInstructions = engine.render(scaffold.manifest.postInstructions); + } + + return { + scaffold, + files: generatedFiles, + postInstructions, + dryRun, + outputDir, + }; +} + +/** + * Process a single file modification + */ +async function processModification( + modification: FileModification, + targetAbsolute: string, + targetRelative: string, + content: string, + dryRun: boolean, +): Promise { + const exists = await fileExists(targetAbsolute); + + // For modifications, the file should exist (except for json-merge which can create) + if (!exists && modification.type !== 'json-merge') { + return { + path: targetRelative, + absolutePath: targetAbsolute, + action: 'skipped', + skipReason: `Target file does not exist for ${modification.type}`, + }; + } + + let existingContent = ''; + if (exists) { + existingContent = await fs.readFile(targetAbsolute, 'utf-8'); + } + + let newContent: string; + try { + switch (modification.type) { + case 'json-merge': { + if (!exists) { + // Create new JSON file + newContent = JSON.stringify(JSON.parse(content), null, 2); + } else { + newContent = mergeJson(existingContent, content, { + jsonPath: modification.jsonPath, + createPath: true, + }); + } + break; + } + + case 'insert-after': { + if (!modification.marker) { + throw new Error('insert-after requires a marker'); + } + newContent = insertAfter(existingContent, content, modification.marker); + break; + } + + case 'insert-before': { + if (!modification.marker) { + throw new Error('insert-before requires a marker'); + } + newContent = insertBefore(existingContent, content, modification.marker); + break; + } + + case 'append': { + newContent = appendContent(existingContent, content); + break; + } + + case 'prepend': { + newContent = prependContent(existingContent, content); + break; + } + + default: { + throw new Error(`Unknown modification type: ${modification.type}`); + } + } + } catch (error) { + return { + path: targetRelative, + absolutePath: targetAbsolute, + action: 'skipped', + skipReason: `Modification failed: ${(error as Error).message}`, + }; + } + + if (!dryRun) { + // Create parent directories if needed + const targetDir = path.dirname(targetAbsolute); + await fs.mkdir(targetDir, {recursive: true}); + await fs.writeFile(targetAbsolute, newContent, 'utf-8'); + } + + return { + path: targetRelative, + absolutePath: targetAbsolute, + action: exists ? 'merged' : 'created', + }; +} + +/** + * Preview scaffold generation without writing files + * @param scaffold - The scaffold to preview + * @param options - Generation options + * @returns Preview result + */ +export async function previewScaffold( + scaffold: Scaffold, + options: Omit = {}, +): Promise { + return generateFromScaffold(scaffold, {...options, dryRun: true}); +} diff --git a/packages/b2c-tooling-sdk/src/scaffold/index.ts b/packages/b2c-tooling-sdk/src/scaffold/index.ts new file mode 100644 index 00000000..ae5c6ca9 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/scaffold/index.ts @@ -0,0 +1,155 @@ +/* + * 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 + */ + +/** + * Scaffold generation for B2C Commerce projects. + * + * This module provides functions for discovering, validating, and executing + * project scaffolds (templates) for cartridges, custom APIs, Page Designer + * components, jobs, and other B2C artifacts. + * + * ## Scaffold Discovery + * + * Scaffolds are discovered from multiple sources in priority order: + * + * 1. **Project scaffolds** (`.b2c/scaffolds/`) - highest priority + * 2. **Plugin scaffolds** (via `b2c:scaffold-providers` hook) + * 3. **User scaffolds** (`~/.b2c/scaffolds/`) + * 4. **Built-in scaffolds** - lowest priority + * + * Later sources override earlier ones by name. + * + * - {@link ScaffoldRegistry} - Registry for managing scaffold discovery + * - {@link createScaffoldRegistry} - Create a new registry instance + * + * ## Scaffold Generation + * + * - {@link generateFromScaffold} - Generate files from a scaffold + * - {@link previewScaffold} - Preview generation without writing files + * + * ## Template Engine + * + * - {@link ScaffoldEngine} - EJS-based template rendering engine + * + * ## Validation + * + * - {@link validateScaffoldManifest} - Validate a scaffold manifest + * - {@link validateParameters} - Validate parameter values + * + * ## Usage + * + * ```typescript + * import { + * createScaffoldRegistry, + * generateFromScaffold, + * } from '@salesforce/b2c-tooling-sdk/scaffold'; + * + * // Create registry and find scaffolds + * const registry = createScaffoldRegistry(); + * const scaffolds = await registry.getScaffolds(); + * + * // Get a specific scaffold + * const cartridgeScaffold = await registry.getScaffold('cartridge'); + * + * // Generate files + * const result = await generateFromScaffold(cartridgeScaffold, { + * outputDir: './output', + * variables: { cartridgeName: 'app_custom' }, + * }); + * ``` + * + * @module scaffold + */ + +// Types +export type { + ScaffoldManifest, + ScaffoldParameter, + ScaffoldChoice, + ScaffoldParameterType, + ScaffoldCategory, + DynamicParameterSource, + FileMapping, + FileModification, + OverwriteBehavior, + Scaffold, + ScaffoldSource, + ScaffoldDiscoveryOptions, + ScaffoldProvider, + ScaffoldProviderPriority, + ScaffoldTransformer, + ScaffoldContext, + ScaffoldGenerateOptions, + ScaffoldGenerateResult, + GeneratedFile, + ParameterValidationError, + ParameterValidationResult, + TemplateHelpers, + SourceResult, +} from './types.js'; + +export {SCAFFOLDS_DATA_DIR} from './types.js'; + +// Source resolution +export { + HOOK_POINTS, + resolveLocalSource, + resolveRemoteSource, + isRemoteSource, + validateAgainstSource, +} from './sources.js'; + +// Registry +export {ScaffoldRegistry, createScaffoldRegistry} from './registry.js'; + +// Engine +export { + ScaffoldEngine, + createTemplateContext, + createTemplateHelpers, + renderTemplate, + renderPathTemplate, + kebabCase, + camelCase, + pascalCase, + snakeCase, +} from './engine.js'; +export type {TemplateContext} from './engine.js'; + +// Executor +export {generateFromScaffold, previewScaffold, resolveOutputDirectory} from './executor.js'; +export type {ResolveOutputDirectoryOptions} from './executor.js'; + +// Validators +export { + validateScaffoldManifest, + validateParameters, + evaluateCondition, + isValidScaffoldName, + isValidParameterName, +} from './validators.js'; + +// Merge utilities +export {mergeJson, insertAfter, insertBefore, appendContent, prependContent} from './merge.js'; +export type {JsonMergeOptions, TextInsertOptions} from './merge.js'; + +// Parameter resolution +export {resolveScaffoldParameters, parseParameterOptions, getParameterSchemas} from './parameter-resolver.js'; +export type { + ResolveParametersOptions, + ParameterResolutionError, + ResolvedParameters, + ResolvedParameterSchema, +} from './parameter-resolver.js'; + +// Validation +export {validateEjsSyntax, checkTemplateFiles, checkOrphanedFiles, validateScaffoldDirectory} from './validation.js'; +export type { + ValidationIssueSeverity, + ValidationIssue, + ValidationResult, + ValidateScaffoldOptions, +} from './validation.js'; diff --git a/packages/b2c-tooling-sdk/src/scaffold/merge.ts b/packages/b2c-tooling-sdk/src/scaffold/merge.ts new file mode 100644 index 00000000..77424abf --- /dev/null +++ b/packages/b2c-tooling-sdk/src/scaffold/merge.ts @@ -0,0 +1,234 @@ +/* + * 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 + */ + +/** + * Options for JSON merge operations + */ +export interface JsonMergeOptions { + /** JSON path to the target location (e.g., "scripts", "hooks.dw.ocapi") */ + jsonPath?: string; + /** Whether to create the path if it doesn't exist */ + createPath?: boolean; +} + +/** + * Navigate to a nested JSON path and return parent + key + * @param obj - The object to navigate + * @param path - Dot-separated path (e.g., "hooks.dw.ocapi") + * @returns Tuple of [parent object, final key, success] + */ +function navigateToPath(obj: unknown, path: string): [Record | null, string, boolean] { + if (!path) { + return [null, '', false]; + } + + const parts = path.split('.'); + const finalKey = parts.pop()!; + let current = obj as Record; + + for (const part of parts) { + if (current === null || typeof current !== 'object') { + return [null, finalKey, false]; + } + if (!(part in current)) { + return [null, finalKey, false]; + } + current = current[part] as Record; + } + + if (current === null || typeof current !== 'object') { + return [null, finalKey, false]; + } + + return [current, finalKey, true]; +} + +/** + * Create a nested path in an object + * @param obj - The object to modify + * @param path - Dot-separated path to create + * @returns The parent object of the final key + */ +function createPath(obj: Record, path: string): [Record, string] { + const parts = path.split('.'); + const finalKey = parts.pop()!; + let current = obj; + + for (const part of parts) { + if (!(part in current) || typeof current[part] !== 'object' || current[part] === null) { + current[part] = {}; + } + current = current[part] as Record; + } + + return [current, finalKey]; +} + +/** + * Deep merge two objects, with source overriding target for conflicts + */ +function deepMerge(target: unknown, source: unknown): unknown { + if (Array.isArray(source)) { + if (Array.isArray(target)) { + // Merge arrays by appending source items not already in target + const result = [...target]; + for (const item of source) { + const itemStr = JSON.stringify(item); + const exists = result.some((existing) => JSON.stringify(existing) === itemStr); + if (!exists) { + result.push(item); + } + } + return result; + } + return source; + } + + if (source !== null && typeof source === 'object') { + if (target !== null && typeof target === 'object' && !Array.isArray(target)) { + const result = {...(target as Record)}; + for (const key of Object.keys(source as Record)) { + result[key] = deepMerge(result[key], (source as Record)[key]); + } + return result; + } + return source; + } + + return source; +} + +/** + * Merge JSON content into an existing JSON string + * @param existingJson - The existing JSON string + * @param newContent - JSON content to merge (string or object) + * @param options - Merge options + * @returns Updated JSON string + */ +export function mergeJson( + existingJson: string, + newContent: string | Record, + options: JsonMergeOptions = {}, +): string { + const existing = JSON.parse(existingJson); + const content = typeof newContent === 'string' ? JSON.parse(newContent) : newContent; + + if (options.jsonPath) { + const [parent, key, found] = navigateToPath(existing, options.jsonPath); + + if (!found) { + if (options.createPath !== false) { + const [newParent, newKey] = createPath(existing, options.jsonPath); + newParent[newKey] = content; + } else { + throw new Error(`JSON path not found: ${options.jsonPath}`); + } + } else if (parent) { + parent[key] = deepMerge(parent[key], content); + } + } else { + // Merge at root level + const merged = deepMerge(existing, content); + return JSON.stringify(merged, null, 2); + } + + return JSON.stringify(existing, null, 2); +} + +/** + * Options for text insertion operations + */ +export interface TextInsertOptions { + /** Marker string to find for insert-after/insert-before */ + marker?: string; + /** Whether to add a newline after the inserted content */ + addNewline?: boolean; +} + +/** + * Insert text after a marker in existing content + * @param existingContent - The existing text content + * @param newContent - Content to insert + * @param marker - Marker string to find + * @returns Updated content + */ +export function insertAfter(existingContent: string, newContent: string, marker: string): string { + const index = existingContent.indexOf(marker); + if (index === -1) { + throw new Error(`Marker not found: ${marker}`); + } + + let insertPoint = index + marker.length; + + // If there's a newline right after the marker, insert after it + if (existingContent[insertPoint] === '\n') { + insertPoint++; + } + + // Check if we need to add a newline before the new content + const needsNewlineBefore = + insertPoint > 0 && existingContent[insertPoint - 1] !== '\n' && !newContent.startsWith('\n'); + // Check if we need to add a newline after the new content + const needsNewlineAfter = + !newContent.endsWith('\n') && insertPoint < existingContent.length && existingContent[insertPoint] !== '\n'; + + return ( + existingContent.slice(0, insertPoint) + + (needsNewlineBefore ? '\n' : '') + + newContent + + (needsNewlineAfter ? '\n' : '') + + existingContent.slice(insertPoint) + ); +} + +/** + * Insert text before a marker in existing content + * @param existingContent - The existing text content + * @param newContent - Content to insert + * @param marker - Marker string to find + * @returns Updated content + */ +export function insertBefore(existingContent: string, newContent: string, marker: string): string { + const index = existingContent.indexOf(marker); + if (index === -1) { + throw new Error(`Marker not found: ${marker}`); + } + + // Check if we need to add a newline before the new content + const needsNewlineBefore = index > 0 && existingContent[index - 1] !== '\n' && !newContent.startsWith('\n'); + // Check if we need to add a newline after the new content (before marker) + const needsNewlineAfter = !newContent.endsWith('\n'); + + return ( + existingContent.slice(0, index) + + (needsNewlineBefore ? '\n' : '') + + newContent + + (needsNewlineAfter ? '\n' : '') + + existingContent.slice(index) + ); +} + +/** + * Append content to the end of existing content + * @param existingContent - The existing text content + * @param newContent - Content to append + * @returns Updated content + */ +export function appendContent(existingContent: string, newContent: string): string { + const needsNewline = existingContent.length > 0 && !existingContent.endsWith('\n'); + return existingContent + (needsNewline ? '\n' : '') + newContent; +} + +/** + * Prepend content to the beginning of existing content + * @param existingContent - The existing text content + * @param newContent - Content to prepend + * @returns Updated content + */ +export function prependContent(existingContent: string, newContent: string): string { + const needsNewline = !newContent.endsWith('\n') && existingContent.length > 0; + return newContent + (needsNewline ? '\n' : '') + existingContent; +} diff --git a/packages/b2c-tooling-sdk/src/scaffold/parameter-resolver.ts b/packages/b2c-tooling-sdk/src/scaffold/parameter-resolver.ts new file mode 100644 index 00000000..49da97f9 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/scaffold/parameter-resolver.ts @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import type {B2CInstance} from '../instance/index.js'; +import type {Scaffold, ScaffoldParameter, ScaffoldChoice} from './types.js'; +import {evaluateCondition} from './validators.js'; +import {resolveLocalSource, resolveRemoteSource, isRemoteSource, validateAgainstSource} from './sources.js'; + +/** + * Options for resolving scaffold parameters. + */ +export interface ResolveParametersOptions { + /** Pre-provided variables (from flags, env, etc.) */ + providedVariables?: Record; + /** Project root for resolving local sources */ + projectRoot?: string; + /** B2C instance for resolving remote sources (sites) */ + b2cInstance?: B2CInstance; + /** Use defaults for missing values instead of erroring */ + useDefaults?: boolean; +} + +/** + * Error encountered during parameter validation. + */ +export interface ParameterResolutionError { + /** Parameter name */ + parameter: string; + /** Error message */ + message: string; + /** The invalid value, if applicable */ + value?: unknown; + /** Available choices, if applicable */ + availableChoices?: string[]; +} + +/** + * Result of resolving scaffold parameters. + */ +export interface ResolvedParameters { + /** All resolved variable values */ + variables: Record; + /** Parameters still missing values (need prompting) */ + missingParameters: ScaffoldParameter[]; + /** Validation errors encountered */ + errors: ParameterResolutionError[]; +} + +/** + * Schema for a resolved parameter with source choices populated. + */ +export interface ResolvedParameterSchema { + /** Parameter definition */ + parameter: ScaffoldParameter; + /** Resolved choices from dynamic source */ + resolvedChoices?: ScaffoldChoice[]; + /** Path map for cartridges (name -> absolute path) */ + pathMap?: Map; + /** Warning message if source resolution failed */ + warning?: string; +} + +/** + * Resolve scaffold parameters by: + * 1. Validating provided variables against sources + * 2. Setting companion path variables for cartridges + * 3. Applying defaults where appropriate + * 4. Filtering by condition (`when` field) + * 5. Collecting missing required parameters + * + * @param scaffold - The scaffold to resolve parameters for + * @param options - Resolution options + * @returns Resolved parameters, missing parameters, and any errors + */ +export async function resolveScaffoldParameters( + scaffold: Scaffold, + options: ResolveParametersOptions = {}, +): Promise { + const {providedVariables = {}, projectRoot = process.cwd(), useDefaults = false} = options; + + const variables: Record = {...providedVariables}; + const missingParameters: ScaffoldParameter[] = []; + const errors: ParameterResolutionError[] = []; + + // Cache for cartridge paths (only resolved once if needed) + let cartridgePathMap: Map | undefined; + + for (const param of scaffold.manifest.parameters) { + // Check if conditional parameter should be evaluated + if (param.when && !evaluateCondition(param.when, variables)) { + continue; + } + + // If value was already provided, validate it against source + if (variables[param.name] !== undefined && param.source) { + const providedValue = String(variables[param.name]); + const validation = validateAgainstSource(param.source, providedValue, projectRoot); + + if (!validation.valid) { + errors.push({ + parameter: param.name, + message: `Invalid value "${providedValue}" for ${param.name}. Available ${param.source}: ${validation.availableChoices?.join(', ') || 'none'}`, + value: providedValue, + availableChoices: validation.availableChoices, + }); + continue; + } + + // Set companion path variable for cartridges source + if (param.source === 'cartridges') { + if (!cartridgePathMap) { + const result = resolveLocalSource('cartridges', projectRoot); + cartridgePathMap = result.pathMap; + } + const cartridgePath = cartridgePathMap?.get(providedValue); + if (cartridgePath) { + variables[`${param.name}Path`] = cartridgePath; + } + } + continue; + } + + // Skip if already provided (no source validation needed) + if (variables[param.name] !== undefined) { + continue; + } + + // Use default if available and useDefaults is enabled + if (useDefaults && param.default !== undefined) { + variables[param.name] = param.default; + + // Set companion path variable for cartridges source with default value + if (param.source === 'cartridges' && typeof param.default === 'string') { + if (!cartridgePathMap) { + const result = resolveLocalSource('cartridges', projectRoot); + cartridgePathMap = result.pathMap; + } + const cartridgePath = cartridgePathMap?.get(param.default); + if (cartridgePath) { + variables[`${param.name}Path`] = cartridgePath; + } + } + continue; + } + + // Parameter is missing - track it + if (param.required || param.default === undefined) { + missingParameters.push(param); + } else if (param.default !== undefined) { + // Optional parameter with default - apply it + variables[param.name] = param.default; + } + } + + return {variables, missingParameters, errors}; +} + +/** + * Parse key=value option strings into variables object. + * Handles boolean values and array values for multi-choice params. + * + * @param options - Array of "key=value" strings + * @param scaffold - Optional scaffold for multi-choice detection + * @returns Variables object + */ +export function parseParameterOptions( + options: string[], + scaffold?: Scaffold, +): Record { + const variables: Record = {}; + + // Build set of multi-choice parameter names + const multiChoiceParams = new Set(); + if (scaffold) { + for (const param of scaffold.manifest.parameters) { + if (param.type === 'multi-choice') { + multiChoiceParams.add(param.name); + } + } + } + + for (const opt of options) { + const eqIndex = opt.indexOf('='); + if (eqIndex === -1) { + // No equals sign - treat as boolean flag + variables[opt] = true; + } else { + const key = opt.slice(0, Math.max(0, eqIndex)); + const value = opt.slice(Math.max(0, eqIndex + 1)); + + if (value === 'true') { + variables[key] = true; + } else if (value === 'false') { + variables[key] = false; + } else if (multiChoiceParams.has(key)) { + // Split comma-separated values for multi-choice parameters + variables[key] = value.split(',').map((v) => v.trim()); + } else { + variables[key] = value; + } + } + } + + return variables; +} + +/** + * Get parameter metadata with resolved source choices. + * Useful for MCP/other consumers to build input schemas. + * + * @param scaffold - The scaffold to get parameter schemas for + * @param options - Options for resolving sources + * @returns Array of resolved parameter schemas + */ +export async function getParameterSchemas( + scaffold: Scaffold, + options: {projectRoot?: string; b2cInstance?: B2CInstance} = {}, +): Promise { + const {projectRoot = process.cwd(), b2cInstance} = options; + const schemas: ResolvedParameterSchema[] = []; + + for (const param of scaffold.manifest.parameters) { + const schema: ResolvedParameterSchema = {parameter: param}; + + if (param.source) { + if (isRemoteSource(param.source)) { + // Remote source - needs B2C instance + if (b2cInstance) { + try { + schema.resolvedChoices = await resolveRemoteSource(param.source, b2cInstance); + } catch (error) { + schema.warning = `Could not fetch ${param.source}: ${(error as Error).message}`; + schema.resolvedChoices = []; + } + } else { + schema.warning = `Remote source '${param.source}' requires B2C instance`; + schema.resolvedChoices = []; + } + } else { + // Local source + const result = resolveLocalSource(param.source, projectRoot); + schema.resolvedChoices = result.choices; + schema.pathMap = result.pathMap; + } + } else if (param.choices) { + // Static choices + schema.resolvedChoices = param.choices; + } + + schemas.push(schema); + } + + return schemas; +} diff --git a/packages/b2c-tooling-sdk/src/scaffold/registry.ts b/packages/b2c-tooling-sdk/src/scaffold/registry.ts new file mode 100644 index 00000000..022690b0 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/scaffold/registry.ts @@ -0,0 +1,264 @@ +/* + * 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 fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import {glob} from 'glob'; +import type { + Scaffold, + ScaffoldManifest, + ScaffoldSource, + ScaffoldDiscoveryOptions, + ScaffoldProvider, + ScaffoldTransformer, +} from './types.js'; +import {SCAFFOLDS_DATA_DIR} from './types.js'; +import {validateScaffoldManifest} from './validators.js'; +import {getLogger} from '../logging/logger.js'; + +/** + * Load a scaffold manifest from a directory + * @param scaffoldDir - Path to the scaffold directory + * @param source - Source type for this scaffold + * @returns Scaffold object or null if invalid + */ +async function loadScaffold(scaffoldDir: string, source: ScaffoldSource): Promise { + const manifestPath = path.join(scaffoldDir, 'scaffold.json'); + + try { + const manifestContent = await fs.readFile(manifestPath, 'utf-8'); + const manifest = JSON.parse(manifestContent) as ScaffoldManifest; + + // Validate manifest + const errors = validateScaffoldManifest(manifest); + if (errors.length > 0) { + const logger = getLogger(); + logger.warn({manifestPath, errors}, 'Invalid scaffold manifest'); + return null; + } + + const filesPath = path.join(scaffoldDir, 'files'); + + // Check if files directory exists + try { + await fs.access(filesPath); + } catch { + const logger = getLogger(); + logger.warn({scaffoldDir}, 'Scaffold has no files/ directory'); + return null; + } + + return { + id: manifest.name, + manifest, + path: scaffoldDir, + filesPath, + source, + }; + } catch { + // Manifest doesn't exist or is invalid JSON + return null; + } +} + +/** + * Discover scaffolds from a directory + * @param baseDir - Base directory to search + * @param source - Source type for scaffolds found here + * @returns Array of discovered scaffolds + */ +async function discoverScaffoldsFromDir(baseDir: string, source: ScaffoldSource): Promise { + const scaffolds: Scaffold[] = []; + + try { + await fs.access(baseDir); + } catch { + // Directory doesn't exist + return scaffolds; + } + + // Find all scaffold.json files + const manifestPaths = await glob('*/scaffold.json', { + cwd: baseDir, + absolute: false, + }); + + for (const manifestPath of manifestPaths) { + const scaffoldDir = path.join(baseDir, path.dirname(manifestPath)); + const scaffold = await loadScaffold(scaffoldDir, source); + if (scaffold) { + scaffolds.push(scaffold); + } + } + + return scaffolds; +} + +/** + * Filter scaffolds based on discovery options + */ +function filterScaffolds(scaffolds: Scaffold[], options: ScaffoldDiscoveryOptions): Scaffold[] { + let filtered = scaffolds; + + // Filter by category + if (options.category) { + filtered = filtered.filter((s) => s.manifest.category === options.category); + } + + // Filter by source + if (options.sources && options.sources.length > 0) { + filtered = filtered.filter((s) => options.sources!.includes(s.source)); + } + + // Filter by search query + if (options.query) { + const query = options.query.toLowerCase(); + filtered = filtered.filter((s) => { + const searchText = [s.manifest.name, s.manifest.displayName, s.manifest.description].join(' ').toLowerCase(); + return searchText.includes(query); + }); + } + + return filtered; +} + +/** + * Scaffold registry for discovering and managing scaffolds + */ +export class ScaffoldRegistry { + private providers: ScaffoldProvider[] = []; + private transformers: ScaffoldTransformer[] = []; + private scaffoldCache: Map = new Map(); + + /** + * Add scaffold providers + */ + addProviders(providers: ScaffoldProvider[]): void { + this.providers.push(...providers); + this.clearCache(); + } + + /** + * Add scaffold transformers + */ + addTransformers(transformers: ScaffoldTransformer[]): void { + this.transformers.push(...transformers); + this.clearCache(); + } + + /** + * Clear the scaffold cache + */ + clearCache(): void { + this.scaffoldCache.clear(); + } + + /** + * Get all scaffolds from all sources + * @param options - Discovery options + * @returns Array of scaffolds (deduplicated by name, later sources override earlier) + */ + async getScaffolds(options: ScaffoldDiscoveryOptions = {}): Promise { + const cacheKey = JSON.stringify(options); + if (this.scaffoldCache.has(cacheKey)) { + return this.scaffoldCache.get(cacheKey)!; + } + + // Collect scaffolds from all sources in priority order + const allScaffolds: Scaffold[] = []; + + // 1. Run 'before' providers + const beforeProviders = this.providers.filter((p) => p.priority === 'before'); + for (const provider of beforeProviders) { + const providerScaffolds = await provider.getScaffolds(options); + allScaffolds.push(...providerScaffolds); + } + + // 2. Built-in scaffolds (lowest priority for built-ins) + const builtInScaffolds = await discoverScaffoldsFromDir(SCAFFOLDS_DATA_DIR, 'built-in'); + allScaffolds.push(...builtInScaffolds); + + // 3. User scaffolds (~/.b2c/scaffolds/) + const userScaffoldsDir = path.join(os.homedir(), '.b2c', 'scaffolds'); + const userScaffolds = await discoverScaffoldsFromDir(userScaffoldsDir, 'user'); + allScaffolds.push(...userScaffolds); + + // 4. Project scaffolds (.b2c/scaffolds/) - highest priority + if (options.projectRoot) { + const projectScaffoldsDir = path.join(options.projectRoot, '.b2c', 'scaffolds'); + const projectScaffolds = await discoverScaffoldsFromDir(projectScaffoldsDir, 'project'); + allScaffolds.push(...projectScaffolds); + } + + // 5. Run 'after' providers + const afterProviders = this.providers.filter((p) => p.priority === 'after'); + for (const provider of afterProviders) { + const providerScaffolds = await provider.getScaffolds(options); + allScaffolds.push(...providerScaffolds); + } + + // Deduplicate by ID (later sources override earlier) + const scaffoldMap = new Map(); + for (const scaffold of allScaffolds) { + scaffoldMap.set(scaffold.id, scaffold); + } + + let scaffolds = Array.from(scaffoldMap.values()); + + // Apply transformers + for (const transformer of this.transformers) { + scaffolds = await Promise.all( + scaffolds.map((s) => + transformer.transform(s, { + outputDir: process.cwd(), + variables: {}, + dryRun: false, + force: false, + interactive: false, + }), + ), + ); + } + + // Apply filters + scaffolds = filterScaffolds(scaffolds, options); + + // Sort by name + scaffolds.sort((a, b) => a.id.localeCompare(b.id)); + + this.scaffoldCache.set(cacheKey, scaffolds); + return scaffolds; + } + + /** + * Get a specific scaffold by ID + * @param id - Scaffold ID + * @param options - Discovery options + * @returns Scaffold or null if not found + */ + async getScaffold(id: string, options: ScaffoldDiscoveryOptions = {}): Promise { + const scaffolds = await this.getScaffolds(options); + return scaffolds.find((s) => s.id === id) || null; + } + + /** + * Search scaffolds by query + * @param query - Search query + * @param options - Additional discovery options + * @returns Matching scaffolds + */ + async searchScaffolds(query: string, options: ScaffoldDiscoveryOptions = {}): Promise { + return this.getScaffolds({...options, query}); + } +} + +/** + * Create a new scaffold registry instance + */ +export function createScaffoldRegistry(): ScaffoldRegistry { + return new ScaffoldRegistry(); +} diff --git a/packages/b2c-tooling-sdk/src/scaffold/sources.ts b/packages/b2c-tooling-sdk/src/scaffold/sources.ts new file mode 100644 index 00000000..cdd65921 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/scaffold/sources.ts @@ -0,0 +1,131 @@ +/* + * 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 {findCartridges} from '../operations/code/cartridges.js'; +import type {B2CInstance} from '../instance/index.js'; +import type {OcapiComponents} from '../clients/index.js'; +import type {ScaffoldChoice, DynamicParameterSource, SourceResult} from './types.js'; + +/** + * Common B2C Commerce hook extension points. + */ +export const HOOK_POINTS: ScaffoldChoice[] = [ + {value: 'dw.order.calculate', label: 'Order Calculate'}, + {value: 'dw.order.calculateShipping', label: 'Calculate Shipping'}, + {value: 'dw.order.createOrder', label: 'Create Order'}, + {value: 'dw.order.afterPOST', label: 'OCAPI Order afterPOST'}, + {value: 'dw.order.beforePOST', label: 'OCAPI Order beforePOST'}, + {value: 'dw.ocapi.shop.basket.afterPOST', label: 'OCAPI Basket afterPOST'}, + {value: 'dw.ocapi.shop.basket.modifyGETResponse', label: 'OCAPI Basket modifyGET'}, + {value: 'dw.ocapi.shop.order.afterPOST', label: 'OCAPI Shop Order afterPOST'}, + {value: 'dw.ocapi.shop.order.beforePOST', label: 'OCAPI Shop Order beforePOST'}, + {value: 'dw.ocapi.data.order.afterPATCH', label: 'OCAPI Data Order afterPATCH'}, + {value: 'dw.customer.registration', label: 'Customer Registration'}, + {value: 'dw.customer.afterCreate', label: 'Customer afterCreate'}, + {value: 'app.payment.processor', label: 'Payment Processor'}, + {value: 'app.payment.form.processor', label: 'Payment Form Processor'}, + {value: 'dw.system.request.onSession', label: 'On Session'}, + {value: 'dw.extensions.csv.onFileProcess', label: 'CSV File Process'}, +]; + +/** + * Resolve a local (non-remote) parameter source. + * Does not require authentication. + * + * @param source - The source type to resolve + * @param projectRoot - Project root directory for cartridge discovery + * @returns Resolved choices and optional path mapping + */ +export function resolveLocalSource(source: DynamicParameterSource, projectRoot: string): SourceResult { + switch (source) { + case 'cartridges': { + const cartridges = findCartridges(projectRoot); + const pathMap = new Map(cartridges.map((c) => [c.name, c.src])); + return { + choices: cartridges.map((c) => ({value: c.name, label: c.name})), + pathMap, + }; + } + case 'hook-points': { + return {choices: HOOK_POINTS}; + } + default: { + return {choices: []}; + } + } +} + +/** + * Resolve a remote parameter source. + * Requires authenticated B2CInstance (follows SDK operation pattern). + * + * @param source - The source type + * @param instance - Authenticated B2C instance + * @returns Promise resolving to choices array + * @throws Error if API call fails + */ +export async function resolveRemoteSource( + source: DynamicParameterSource, + instance: B2CInstance, +): Promise { + switch (source) { + case 'sites': { + const {data, error} = await instance.ocapi.GET('/sites', { + params: {query: {select: '(**)'}}, + }); + + if (error) { + throw new Error('Failed to fetch sites from B2C instance'); + } + + const sites = data as OcapiComponents['schemas']['sites']; + return (sites.data ?? []).map((s) => ({ + value: s.id ?? '', + label: s.display_name?.default || s.id || '', + })); + } + default: { + return []; + } + } +} + +/** + * Check if a source requires remote API access. + * + * @param source - The source type to check + * @returns True if the source requires remote access + */ +export function isRemoteSource(source: DynamicParameterSource): boolean { + return source === 'sites'; +} + +/** + * Validate a value against a dynamic source (local only). + * Used for non-interactive validation of provided values. + * + * @param source - The source type + * @param value - The value to validate + * @param projectRoot - Project root for local sources + * @returns Object with valid status and available choices if invalid + */ +export function validateAgainstSource( + source: DynamicParameterSource, + value: string, + projectRoot: string, +): {valid: boolean; availableChoices?: string[]} { + if (source === 'cartridges') { + const {choices} = resolveLocalSource(source, projectRoot); + const valid = choices.some((c) => c.value === value); + return { + valid, + availableChoices: valid ? undefined : choices.map((c) => c.value), + }; + } + + // For hook-points and other sources, no validation (allow any value) + return {valid: true}; +} diff --git a/packages/b2c-tooling-sdk/src/scaffold/types.ts b/packages/b2c-tooling-sdk/src/scaffold/types.ts new file mode 100644 index 00000000..6ea7bdb1 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/scaffold/types.ts @@ -0,0 +1,315 @@ +/* + * 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 path from 'node:path'; +import {createRequire} from 'node:module'; + +const require = createRequire(import.meta.url); +const packageRoot = path.dirname(require.resolve('@salesforce/b2c-tooling-sdk/package.json')); + +/** + * Path to the built-in scaffolds data directory + */ +export const SCAFFOLDS_DATA_DIR = path.join(packageRoot, 'data/scaffolds'); + +/** + * Scaffold category. Built-in scaffolds use 'cartridge', but custom scaffolds + * can define their own categories. + */ +export type ScaffoldCategory = string; + +/** + * Parameter types supported by scaffold parameters + */ +export type ScaffoldParameterType = 'string' | 'boolean' | 'choice' | 'multi-choice'; + +/** + * Dynamic sources for populating parameter choices at runtime. + * + * - `cartridges`: Discovers cartridges in project via .project files + * - `hook-points`: Static list of common hook extension points + * - `sites`: Remote - fetches sites from connected B2C instance + */ +export type DynamicParameterSource = 'cartridges' | 'hook-points' | 'sites'; + +/** + * Overwrite behavior for generated files + */ +export type OverwriteBehavior = 'never' | 'always' | 'prompt' | 'merge'; + +/** + * Choice option for choice/multi-choice parameters + */ +export interface ScaffoldChoice { + /** The value to use when this choice is selected */ + value: string; + /** Human-readable label for this choice */ + label: string; +} + +/** + * Parameter definition for scaffold prompts and flags + */ +export interface ScaffoldParameter { + /** Parameter name (camelCase), used in templates as variable name */ + name: string; + /** Prompt message shown in interactive mode */ + prompt: string; + /** Type of the parameter */ + type: ScaffoldParameterType; + /** Whether this parameter is required */ + required: boolean; + /** Default value if not provided */ + default?: string | boolean | string[]; + /** Regex pattern for validation (string types only) */ + pattern?: string; + /** Error message shown when validation fails */ + validationMessage?: string; + /** Available choices for choice/multi-choice types */ + choices?: ScaffoldChoice[]; + /** CLI flag name override (e.g., "--name"). If not set, uses --{paramName} */ + flag?: string; + /** Conditional expression: only prompt if condition is met (e.g., "otherParam=value") */ + when?: string; + /** Dynamic source for populating choices at runtime */ + source?: DynamicParameterSource; +} + +/** + * File mapping from template to destination + */ +export interface FileMapping { + /** Template file path relative to the scaffold's files/ directory */ + template: string; + /** Destination path (supports {{variable}} substitution) */ + destination: string; + /** Conditional expression: only generate if truthy */ + condition?: string; + /** Overwrite behavior for existing files */ + overwrite?: OverwriteBehavior; +} + +/** + * File modification definition for modifying existing files + */ +export interface FileModification { + /** Target file path (supports {{variable}} substitution) */ + target: string; + /** Type of modification */ + type: 'json-merge' | 'insert-after' | 'insert-before' | 'append' | 'prepend'; + /** Content to insert/merge (for text modifications) */ + content?: string; + /** Template file for the content */ + contentTemplate?: string; + /** Marker string to find (for insert-after/insert-before) */ + marker?: string; + /** JSON path for json-merge operations (e.g., "scripts") */ + jsonPath?: string; + /** Conditional expression */ + condition?: string; +} + +/** + * Scaffold manifest (scaffold.json) + */ +export interface ScaffoldManifest { + /** Unique identifier (kebab-case) */ + name: string; + /** Human-readable display name */ + displayName: string; + /** Description of what this scaffold creates */ + description: string; + /** Category for filtering and organization */ + category: ScaffoldCategory; + /** Parameters for user input (prompts/flags) */ + parameters: ScaffoldParameter[]; + /** File mappings (optional - defaults to all files in files/ directory) */ + files?: FileMapping[]; + /** Modifications to existing files (optional) */ + modifications?: FileModification[]; + /** Instructions to show after generation */ + postInstructions?: string; + /** Default output directory relative to cwd (created if needed) */ + defaultOutputDir?: string; +} + +/** + * Resolved scaffold with full paths and source information + */ +export interface Scaffold { + /** Unique identifier */ + id: string; + /** The manifest definition */ + manifest: ScaffoldManifest; + /** Full path to the scaffold directory */ + path: string; + /** Full path to the files/ directory within the scaffold */ + filesPath: string; + /** Source of this scaffold */ + source: ScaffoldSource; +} + +/** + * Source/origin of a scaffold + */ +export type ScaffoldSource = 'built-in' | 'user' | 'project' | 'plugin'; + +/** + * Priority ordering for scaffold providers + */ +export type ScaffoldProviderPriority = 'before' | 'after'; + +/** + * Options for scaffold discovery + */ +export interface ScaffoldDiscoveryOptions { + /** Filter by category */ + category?: ScaffoldCategory; + /** Search query for name/description */ + query?: string; + /** Include only scaffolds from specific sources */ + sources?: ScaffoldSource[]; + /** Project root directory (for project-local scaffolds) */ + projectRoot?: string; +} + +/** + * Scaffold provider interface for extensibility + */ +export interface ScaffoldProvider { + /** Provider name for identification */ + readonly name: string; + /** Priority: 'before' runs before built-in, 'after' runs after */ + readonly priority: ScaffoldProviderPriority; + /** Get scaffolds from this provider */ + getScaffolds(options: ScaffoldDiscoveryOptions): Promise; +} + +/** + * Scaffold transformer interface for modifying scaffolds + */ +export interface ScaffoldTransformer { + /** Transformer name for identification */ + readonly name: string; + /** Transform a scaffold definition */ + transform(scaffold: Scaffold, context: ScaffoldContext): Promise; +} + +/** + * Context passed during scaffold operations + */ +export interface ScaffoldContext { + /** Output directory for generated files */ + outputDir: string; + /** Resolved parameter values */ + variables: Record; + /** Whether running in dry-run mode */ + dryRun: boolean; + /** Whether to force overwrite existing files */ + force: boolean; + /** Whether running in interactive mode */ + interactive: boolean; +} + +/** + * Options for scaffold generation + */ +export interface ScaffoldGenerateOptions { + /** Output directory (defaults to cwd) */ + outputDir?: string; + /** Pre-supplied variable values (from flags/env) */ + variables?: Record; + /** Preview without writing files */ + dryRun?: boolean; + /** Skip prompts and overwrite existing files */ + force?: boolean; + /** Enable interactive prompts (defaults to true if TTY) */ + interactive?: boolean; +} + +/** + * Result of a file generation operation + */ +export interface GeneratedFile { + /** Relative path from output directory */ + path: string; + /** Absolute path to the file */ + absolutePath: string; + /** Action taken */ + action: 'created' | 'skipped' | 'overwritten' | 'merged'; + /** Reason for skip (if action is 'skipped') */ + skipReason?: string; +} + +/** + * Result of scaffold generation + */ +export interface ScaffoldGenerateResult { + /** The scaffold that was used */ + scaffold: Scaffold; + /** Files that were generated */ + files: GeneratedFile[]; + /** Post-generation instructions */ + postInstructions?: string; + /** Whether this was a dry run */ + dryRun: boolean; + /** Output directory */ + outputDir: string; +} + +/** + * Validation error for scaffold parameters + */ +export interface ParameterValidationError { + /** Parameter name */ + parameter: string; + /** Error message */ + message: string; + /** The invalid value */ + value?: unknown; +} + +/** + * Result of parameter validation + */ +export interface ParameterValidationResult { + /** Whether validation passed */ + valid: boolean; + /** Validation errors (if any) */ + errors: ParameterValidationError[]; + /** Resolved parameter values */ + values: Record; +} + +/** + * Template rendering helpers available in EJS templates + */ +export interface TemplateHelpers { + /** Convert to kebab-case */ + kebabCase: (str: string) => string; + /** Convert to camelCase */ + camelCase: (str: string) => string; + /** Convert to PascalCase */ + pascalCase: (str: string) => string; + /** Convert to snake_case */ + snakeCase: (str: string) => string; + /** Current year */ + year: number; + /** Current date (YYYY-MM-DD) */ + date: string; + /** Generate a UUID v4 */ + uuid: () => string; +} + +/** + * Result of resolving a dynamic parameter source. + */ +export interface SourceResult { + /** Available choices */ + choices: ScaffoldChoice[]; + /** For cartridges: map of name to absolute path */ + pathMap?: Map; +} diff --git a/packages/b2c-tooling-sdk/src/scaffold/validation.ts b/packages/b2c-tooling-sdk/src/scaffold/validation.ts new file mode 100644 index 00000000..82e3bfdc --- /dev/null +++ b/packages/b2c-tooling-sdk/src/scaffold/validation.ts @@ -0,0 +1,336 @@ +/* + * 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 fs from 'node:fs/promises'; +import path from 'node:path'; +import {glob} from 'glob'; +import type {ScaffoldManifest, FileMapping} from './types.js'; +import {validateScaffoldManifest} from './validators.js'; + +/** + * Severity of a validation issue. + */ +export type ValidationIssueSeverity = 'error' | 'warning'; + +/** + * A validation issue found during scaffold validation. + */ +export interface ValidationIssue { + /** Severity of the issue */ + severity: ValidationIssueSeverity; + /** Human-readable message describing the issue */ + message: string; + /** File path where the issue was found, if applicable */ + file?: string; +} + +/** + * Result of validating a scaffold directory. + */ +export interface ValidationResult { + /** Whether the scaffold is valid (no errors) */ + valid: boolean; + /** Number of errors found */ + errors: number; + /** Number of warnings found */ + warnings: number; + /** All issues found during validation */ + issues: ValidationIssue[]; +} + +/** + * Check if a file exists. + */ +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +/** + * Check if a path is a directory. + */ +async function isDirectory(dirPath: string): Promise { + try { + const stat = await fs.stat(dirPath); + return stat.isDirectory(); + } catch { + return false; + } +} + +/** + * Load and parse a manifest file. + */ +async function loadManifest(manifestPath: string): Promise<{manifest: ScaffoldManifest | null; error: string | null}> { + try { + const content = await fs.readFile(manifestPath, 'utf8'); + return {manifest: JSON.parse(content) as ScaffoldManifest, error: null}; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {manifest: null, error: 'scaffold.json not found'}; + } + return {manifest: null, error: 'scaffold.json is not valid JSON'}; + } +} + +/** + * Validate EJS syntax in template content. + * + * Checks for: + * - Mismatched opening/closing EJS tags + * - Invalid EJS tag patterns + * - Empty output tags + * + * @param content - Template content to validate + * @param filename - Optional filename for error reporting + * @returns Array of validation issues found + */ +export function validateEjsSyntax(content: string, filename?: string): ValidationIssue[] { + const issues: ValidationIssue[] = []; + const fileRef = filename ? `files/${filename}` : undefined; + + // Check for unclosed EJS tags + const openTags = (content.match(/<%/g) || []).length; + const closeTags = (content.match(/%>/g) || []).length; + + if (openTags !== closeTags) { + issues.push({ + severity: 'error', + message: `Mismatched EJS tags: ${openTags} opening, ${closeTags} closing`, + file: fileRef, + }); + } + + // Check for common EJS errors + const invalidPatterns = [ + {pattern: /<%[^%=_\-\s#]/, message: 'Invalid EJS tag opening (missing space or modifier)'}, + {pattern: /<%=\s*%>/, message: 'Empty EJS output tag'}, + ]; + + for (const {pattern, message} of invalidPatterns) { + if (pattern.test(content)) { + issues.push({severity: 'warning', message, file: fileRef}); + } + } + + return issues; +} + +/** + * Check that all referenced template files exist. + * + * @param filesDir - Path to the files/ directory + * @param manifest - Scaffold manifest + * @returns Array of validation issues for missing files + */ +export async function checkTemplateFiles(filesDir: string, manifest: ScaffoldManifest): Promise { + const issues: ValidationIssue[] = []; + const filesToCheck: Array<{path: string; template: string}> = []; + + // Collect files from file mappings + if (manifest.files && Array.isArray(manifest.files)) { + for (const mapping of manifest.files as FileMapping[]) { + filesToCheck.push({path: path.join(filesDir, mapping.template), template: mapping.template}); + } + } + + // Collect files from modifications + if (manifest.modifications) { + for (const mod of manifest.modifications) { + if (mod.contentTemplate) { + filesToCheck.push({path: path.join(filesDir, mod.contentTemplate), template: mod.contentTemplate}); + } + } + } + + // Check all files in parallel + const results = await Promise.all( + filesToCheck.map(async ({path: filePath, template}) => { + const exists = await fileExists(filePath); + return {template, exists}; + }), + ); + + for (const {template, exists} of results) { + if (!exists) { + issues.push({ + severity: 'error', + message: `Template file not found: ${template}`, + file: `files/${template}`, + }); + } + } + + return issues; +} + +/** + * Check for orphaned template files not referenced in manifest. + * + * @param allTemplates - All template file paths in files/ directory + * @param manifest - Scaffold manifest + * @returns Array of validation issues for orphaned files + */ +export function checkOrphanedFiles(allTemplates: string[], manifest: ScaffoldManifest): ValidationIssue[] { + const issues: ValidationIssue[] = []; + const referencedTemplates = new Set(); + + if (manifest.files) { + for (const f of manifest.files as FileMapping[]) { + referencedTemplates.add(f.template); + } + } + + if (manifest.modifications) { + for (const mod of manifest.modifications) { + if (mod.contentTemplate) { + referencedTemplates.add(mod.contentTemplate); + } + } + } + + for (const template of allTemplates) { + if (!referencedTemplates.has(template)) { + issues.push({ + severity: 'warning', + message: `Template file not referenced in manifest: ${template}`, + file: `files/${template}`, + }); + } + } + + return issues; +} + +/** + * Validate manifest structure and return issues. + */ +function validateManifestStructure(manifest: ScaffoldManifest): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + const manifestErrors = validateScaffoldManifest(manifest); + for (const error of manifestErrors) { + issues.push({severity: 'error', message: error, file: 'scaffold.json'}); + } + + if (!manifest.postInstructions) { + issues.push({ + severity: 'warning', + message: 'Consider adding postInstructions to guide users after generation', + file: 'scaffold.json', + }); + } + + return issues; +} + +/** + * Validate EJS syntax in all template files. + */ +async function validateAllEjsTemplates(filesDir: string, allTemplates: string[]): Promise { + const ejsTemplates = allTemplates.filter((t) => t.endsWith('.ejs')); + + const results = await Promise.all( + ejsTemplates.map(async (template) => { + try { + const content = await fs.readFile(path.join(filesDir, template), 'utf8'); + return validateEjsSyntax(content, template); + } catch { + return []; + } + }), + ); + + return results.flat(); +} + +/** + * Options for scaffold directory validation. + */ +export interface ValidateScaffoldOptions { + /** Treat warnings as errors */ + strict?: boolean; +} + +/** + * Validate a complete scaffold directory. + * + * Performs comprehensive validation including: + * - Checking scaffold.json exists and is valid JSON + * - Validating manifest structure against schema + * - Verifying files/ directory exists + * - Checking all referenced template files exist + * - Finding orphaned template files + * - Validating EJS syntax in templates + * + * @param scaffoldPath - Path to the scaffold directory + * @param options - Validation options + * @returns Validation result with issues found + */ +export async function validateScaffoldDirectory( + scaffoldPath: string, + options: ValidateScaffoldOptions = {}, +): Promise { + const issues: ValidationIssue[] = []; + + // Check if path exists and is a directory + if (!(await isDirectory(scaffoldPath))) { + issues.push({ + severity: 'error', + message: `Path does not exist or is not a directory: ${scaffoldPath}`, + }); + return buildResult(issues, options.strict); + } + + // Load and validate manifest + const manifestPath = path.join(scaffoldPath, 'scaffold.json'); + const {manifest, error: manifestError} = await loadManifest(manifestPath); + + if (manifestError) { + issues.push({severity: 'error', message: manifestError, file: 'scaffold.json'}); + } + + if (manifest) { + issues.push(...validateManifestStructure(manifest)); + } + + // Check files directory + const filesDir = path.join(scaffoldPath, 'files'); + const filesExist = await isDirectory(filesDir); + + if (!filesExist) { + issues.push({severity: 'error', message: 'files/ directory not found'}); + } + + if (filesExist && manifest) { + // Get all template files + const allTemplates = await glob('**/*', {cwd: filesDir, nodir: true, dot: true}); + + // Check template files exist, orphans, and EJS syntax + issues.push( + ...(await checkTemplateFiles(filesDir, manifest)), + ...checkOrphanedFiles(allTemplates, manifest), + ...(await validateAllEjsTemplates(filesDir, allTemplates)), + ); + } + + return buildResult(issues, options.strict); +} + +/** + * Build a ValidationResult from issues. + */ +function buildResult(issues: ValidationIssue[], strict?: boolean): ValidationResult { + const errors = issues.filter((i) => i.severity === 'error').length; + const warnings = issues.filter((i) => i.severity === 'warning').length; + const valid = strict ? errors === 0 && warnings === 0 : errors === 0; + + return {valid, errors, warnings, issues}; +} diff --git a/packages/b2c-tooling-sdk/src/scaffold/validators.ts b/packages/b2c-tooling-sdk/src/scaffold/validators.ts new file mode 100644 index 00000000..b00c99c3 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/scaffold/validators.ts @@ -0,0 +1,373 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import type { + ScaffoldManifest, + ParameterValidationError, + ParameterValidationResult, + DynamicParameterSource, +} from './types.js'; + +/** Valid parameter types */ +const VALID_PARAMETER_TYPES = ['string', 'boolean', 'choice', 'multi-choice']; + +/** Reserved variable names that cannot be used as parameter names */ +const RESERVED_NAMES = ['kebabCase', 'camelCase', 'pascalCase', 'snakeCase', 'year', 'date', 'uuid']; + +/** Valid dynamic parameter sources */ +const VALID_SOURCES: DynamicParameterSource[] = ['cartridges', 'hook-points', 'sites']; + +/** + * Validate a scaffold manifest + * @param manifest - The manifest to validate + * @returns Array of validation error messages (empty if valid) + */ +export function validateScaffoldManifest(manifest: unknown): string[] { + const errors: string[] = []; + + if (!manifest || typeof manifest !== 'object') { + return ['Manifest must be an object']; + } + + const m = manifest as Record; + + // Required fields + if (!m.name || typeof m.name !== 'string') { + errors.push('Manifest must have a "name" field (string)'); + } else if (!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(m.name) && m.name.length > 1) { + errors.push('Manifest "name" must be kebab-case (lowercase letters, numbers, hyphens)'); + } + + if (!m.displayName || typeof m.displayName !== 'string') { + errors.push('Manifest must have a "displayName" field (string)'); + } + + if (!m.description || typeof m.description !== 'string') { + errors.push('Manifest must have a "description" field (string)'); + } + + if (!m.category || typeof m.category !== 'string') { + errors.push('Manifest must have a "category" field (string)'); + } + + // Parameters validation + if (!Array.isArray(m.parameters)) { + errors.push('Manifest must have a "parameters" array'); + } else { + const paramNames = new Set(); + for (let i = 0; i < m.parameters.length; i++) { + const param = m.parameters[i]; + const prefix = `parameters[${i}]`; + + if (!param || typeof param !== 'object') { + errors.push(`${prefix}: must be an object`); + continue; + } + + const p = param as Record; + + if (!p.name || typeof p.name !== 'string') { + errors.push(`${prefix}: must have a "name" field (string)`); + } else { + if (!/^[a-z][a-zA-Z0-9]*$/.test(p.name)) { + errors.push(`${prefix}: "name" must be camelCase (start with lowercase letter)`); + } + if (paramNames.has(p.name)) { + errors.push(`${prefix}: duplicate parameter name "${p.name}"`); + } + if (RESERVED_NAMES.includes(p.name)) { + errors.push(`${prefix}: "${p.name}" is a reserved name`); + } + paramNames.add(p.name); + } + + if (!p.prompt || typeof p.prompt !== 'string') { + errors.push(`${prefix}: must have a "prompt" field (string)`); + } + + if (!p.type || typeof p.type !== 'string') { + errors.push(`${prefix}: must have a "type" field (string)`); + } else if (!VALID_PARAMETER_TYPES.includes(p.type)) { + errors.push(`${prefix}: "type" must be one of: ${VALID_PARAMETER_TYPES.join(', ')}`); + } + + if (typeof p.required !== 'boolean') { + errors.push(`${prefix}: must have a "required" field (boolean)`); + } + + // Source validation + if (p.source !== undefined) { + if (typeof p.source !== 'string') { + errors.push(`${prefix}: "source" must be a string`); + } else if (!VALID_SOURCES.includes(p.source as DynamicParameterSource)) { + errors.push(`${prefix}: "source" must be one of: ${VALID_SOURCES.join(', ')}`); + } + } + + // Choice validation (choices are optional if source is provided) + if ((p.type === 'choice' || p.type === 'multi-choice') && !Array.isArray(p.choices) && !p.source) { + errors.push(`${prefix}: choice/multi-choice types must have a "choices" array or a "source" field`); + } else if (Array.isArray(p.choices)) { + for (let j = 0; j < p.choices.length; j++) { + const choice = p.choices[j] as Record; + if (!choice || typeof choice !== 'object') { + errors.push(`${prefix}.choices[${j}]: must be an object`); + } else { + if (!choice.value || typeof choice.value !== 'string') { + errors.push(`${prefix}.choices[${j}]: must have a "value" field (string)`); + } + if (!choice.label || typeof choice.label !== 'string') { + errors.push(`${prefix}.choices[${j}]: must have a "label" field (string)`); + } + } + } + } + + // Pattern validation + if (p.pattern !== undefined && typeof p.pattern !== 'string') { + errors.push(`${prefix}: "pattern" must be a string`); + } else if (typeof p.pattern === 'string') { + try { + new RegExp(p.pattern); + } catch { + errors.push(`${prefix}: "pattern" is not a valid regex`); + } + } + } + } + + // Files validation (optional) + if (m.files !== undefined) { + if (!Array.isArray(m.files)) { + errors.push('Manifest "files" must be an array'); + } else { + for (let i = 0; i < m.files.length; i++) { + const file = m.files[i] as Record; + const prefix = `files[${i}]`; + + if (!file || typeof file !== 'object') { + errors.push(`${prefix}: must be an object`); + continue; + } + + if (!file.template || typeof file.template !== 'string') { + errors.push(`${prefix}: must have a "template" field (string)`); + } + + if (!file.destination || typeof file.destination !== 'string') { + errors.push(`${prefix}: must have a "destination" field (string)`); + } + } + } + } + + return errors; +} + +/** + * Check if a condition expression is satisfied + * @param condition - The condition expression (e.g., "paramName=value" or "paramName") + * @param variables - The current variable values + * @returns Whether the condition is satisfied + */ +export function evaluateCondition( + condition: string | undefined, + variables: Record, +): boolean { + if (!condition) { + return true; + } + + // Handle equality check: "paramName=value" + if (condition.includes('=')) { + const [paramName, expectedValue] = condition.split('=', 2); + const actualValue = variables[paramName]; + + if (Array.isArray(actualValue)) { + return actualValue.includes(expectedValue); + } + + return String(actualValue) === expectedValue; + } + + // Handle negation: "!paramName" + if (condition.startsWith('!')) { + const paramName = condition.slice(1); + const value = variables[paramName]; + return !value || value === '' || (Array.isArray(value) && value.length === 0); + } + + // Handle truthy check: "paramName" + const value = variables[condition]; + return Boolean(value) && value !== '' && !(Array.isArray(value) && value.length === 0); +} + +/** + * Validate parameter values against a manifest + * @param manifest - The scaffold manifest + * @param values - The parameter values to validate + * @returns Validation result + */ +export function validateParameters( + manifest: ScaffoldManifest, + values: Record, +): ParameterValidationResult { + const errors: ParameterValidationError[] = []; + const resolvedValues: Record = {}; + + for (const param of manifest.parameters) { + // Check if this parameter should be evaluated (based on `when` condition) + if (param.when && !evaluateCondition(param.when, resolvedValues)) { + // Parameter is conditional and condition not met - skip it + continue; + } + + let value = values[param.name]; + + // Use default if not provided + if (value === undefined || value === '') { + if (param.default !== undefined) { + value = param.default; + } + } + + // Required check + if (param.required) { + const isEmpty = value === undefined || value === '' || (Array.isArray(value) && value.length === 0); + if (isEmpty) { + errors.push({ + parameter: param.name, + message: `"${param.name}" is required`, + value, + }); + continue; + } + } + + // Type-specific validation + if (value !== undefined && value !== '') { + switch (param.type) { + case 'boolean': + if (typeof value !== 'boolean' && value !== 'true' && value !== 'false') { + errors.push({ + parameter: param.name, + message: `"${param.name}" must be a boolean`, + value, + }); + } else { + // Normalize to boolean + resolvedValues[param.name] = value === true || value === 'true'; + } + break; + + case 'string': + if (typeof value !== 'string') { + errors.push({ + parameter: param.name, + message: `"${param.name}" must be a string`, + value, + }); + } else { + // Pattern validation + if (param.pattern) { + const regex = new RegExp(param.pattern); + if (!regex.test(value)) { + errors.push({ + parameter: param.name, + message: param.validationMessage || `"${param.name}" does not match required pattern`, + value, + }); + } + } + resolvedValues[param.name] = value; + } + break; + + case 'choice': + if (typeof value !== 'string') { + errors.push({ + parameter: param.name, + message: `"${param.name}" must be a string`, + value, + }); + } else if (param.choices) { + const validValues = param.choices.map((c) => c.value); + if (!validValues.includes(value)) { + errors.push({ + parameter: param.name, + message: `"${param.name}" must be one of: ${validValues.join(', ')}`, + value, + }); + } else { + resolvedValues[param.name] = value; + } + } + break; + + case 'multi-choice': + { + const arr = Array.isArray(value) ? value : [String(value)]; + if (param.choices) { + const validValues = param.choices.map((c) => c.value); + for (const v of arr) { + if (!validValues.includes(v)) { + errors.push({ + parameter: param.name, + message: `"${param.name}" contains invalid value "${v}". Must be one of: ${validValues.join(', ')}`, + value, + }); + } + } + } + resolvedValues[param.name] = arr; + } + break; + } + } else if (value !== undefined) { + // Value is defined but empty - still store it if not required + if (param.type === 'multi-choice') { + resolvedValues[param.name] = []; + } else if (param.type === 'boolean') { + resolvedValues[param.name] = false; + } else { + resolvedValues[param.name] = ''; + } + } + } + + // Preserve any extra variables that aren't manifest parameters + // (e.g., cartridgeNamePath set by CLI for cartridge source parameters) + const manifestParamNames = new Set(manifest.parameters.map((p) => p.name)); + for (const [key, value] of Object.entries(values)) { + if (!manifestParamNames.has(key) && value !== undefined) { + resolvedValues[key] = value; + } + } + + return { + valid: errors.length === 0, + errors, + values: resolvedValues, + }; +} + +/** + * Validate that a string is a valid scaffold name (kebab-case) + * @param name - The name to validate + * @returns Whether the name is valid + */ +export function isValidScaffoldName(name: string): boolean { + return /^[a-z][a-z0-9-]*[a-z0-9]$/.test(name) || /^[a-z]$/.test(name); +} + +/** + * Validate that a string is a valid parameter name (camelCase) + * @param name - The name to validate + * @returns Whether the name is valid + */ +export function isValidParameterName(name: string): boolean { + return /^[a-z][a-zA-Z0-9]*$/.test(name) && !RESERVED_NAMES.includes(name); +} diff --git a/packages/b2c-tooling-sdk/test/scaffold/engine.test.ts b/packages/b2c-tooling-sdk/test/scaffold/engine.test.ts new file mode 100644 index 00000000..45be63e5 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/scaffold/engine.test.ts @@ -0,0 +1,200 @@ +/* + * 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 { + kebabCase, + camelCase, + pascalCase, + snakeCase, + createTemplateHelpers, + createTemplateContext, + renderTemplate, + renderPathTemplate, + ScaffoldEngine, +} from '../../src/scaffold/engine.js'; + +describe('scaffold/engine', () => { + describe('case conversion functions', () => { + describe('kebabCase', () => { + it('should convert camelCase to kebab-case', () => { + expect(kebabCase('cartridgeName')).to.equal('cartridge-name'); + expect(kebabCase('myApiEndpoint')).to.equal('my-api-endpoint'); + }); + + it('should convert PascalCase to kebab-case', () => { + expect(kebabCase('CartridgeName')).to.equal('cartridge-name'); + }); + + it('should convert spaces to hyphens', () => { + expect(kebabCase('my cartridge name')).to.equal('my-cartridge-name'); + }); + + it('should convert underscores to hyphens', () => { + expect(kebabCase('my_cartridge_name')).to.equal('my-cartridge-name'); + }); + }); + + describe('camelCase', () => { + it('should convert kebab-case to camelCase', () => { + expect(camelCase('cartridge-name')).to.equal('cartridgeName'); + }); + + it('should convert snake_case to camelCase', () => { + expect(camelCase('cartridge_name')).to.equal('cartridgeName'); + }); + + it('should convert spaces to camelCase', () => { + expect(camelCase('cartridge name')).to.equal('cartridgeName'); + }); + + it('should lowercase first character', () => { + expect(camelCase('CartridgeName')).to.equal('cartridgeName'); + }); + }); + + describe('pascalCase', () => { + it('should convert kebab-case to PascalCase', () => { + expect(pascalCase('cartridge-name')).to.equal('CartridgeName'); + }); + + it('should convert camelCase to PascalCase', () => { + expect(pascalCase('cartridgeName')).to.equal('CartridgeName'); + }); + }); + + describe('snakeCase', () => { + it('should convert camelCase to snake_case', () => { + expect(snakeCase('cartridgeName')).to.equal('cartridge_name'); + }); + + it('should convert kebab-case to snake_case', () => { + expect(snakeCase('cartridge-name')).to.equal('cartridge_name'); + }); + + it('should convert spaces to underscores', () => { + expect(snakeCase('cartridge name')).to.equal('cartridge_name'); + }); + }); + }); + + describe('createTemplateHelpers', () => { + it('should create helpers with current year', () => { + const helpers = createTemplateHelpers(); + expect(helpers.year).to.equal(new Date().getFullYear()); + }); + + it('should create helpers with current date', () => { + const helpers = createTemplateHelpers(); + expect(helpers.date).to.match(/^\d{4}-\d{2}-\d{2}$/); + }); + + it('should create helpers with uuid function', () => { + const helpers = createTemplateHelpers(); + const uuid1 = helpers.uuid(); + const uuid2 = helpers.uuid(); + expect(uuid1).to.match(/^[0-9a-f-]{36}$/); + expect(uuid1).to.not.equal(uuid2); + }); + + it('should create helpers with case functions', () => { + const helpers = createTemplateHelpers(); + expect(helpers.kebabCase('testName')).to.equal('test-name'); + expect(helpers.camelCase('test-name')).to.equal('testName'); + expect(helpers.pascalCase('test-name')).to.equal('TestName'); + expect(helpers.snakeCase('testName')).to.equal('test_name'); + }); + }); + + describe('createTemplateContext', () => { + it('should merge variables with helpers', () => { + const context = createTemplateContext({ + cartridgeName: 'app_custom', + includeTests: true, + }); + expect(context.cartridgeName).to.equal('app_custom'); + expect(context.includeTests).to.equal(true); + expect(context.kebabCase).to.be.a('function'); + expect(context.year).to.be.a('number'); + }); + }); + + describe('renderTemplate', () => { + it('should render EJS template with variables', () => { + const context = createTemplateContext({ + name: 'test', + }); + const result = renderTemplate('Hello, <%= name %>!', context); + expect(result).to.equal('Hello, test!'); + }); + + it('should render EJS template with helpers', () => { + const context = createTemplateContext({ + name: 'testName', + }); + const result = renderTemplate('Kebab: <%= kebabCase(name) %>', context); + expect(result).to.equal('Kebab: test-name'); + }); + + it('should handle conditionals', () => { + const context = createTemplateContext({ + includeTests: true, + }); + const result = renderTemplate('<% if (includeTests) { %>Tests included<% } %>', context); + expect(result).to.equal('Tests included'); + }); + }); + + describe('renderPathTemplate', () => { + it('should render path variables', () => { + const context = createTemplateContext({ + cartridgeName: 'app_custom', + moduleName: 'myModule', + }); + const result = renderPathTemplate('{{cartridgeName}}/cartridge/{{moduleName}}.js', context); + expect(result).to.equal('app_custom/cartridge/myModule.js'); + }); + + it('should render path with helper functions', () => { + const context = createTemplateContext({ + moduleName: 'MyModule', + }); + const result = renderPathTemplate('{{kebabCase moduleName}}.js', context); + expect(result).to.equal('my-module.js'); + }); + + it('should preserve unmatched placeholders', () => { + const context = createTemplateContext({}); + const result = renderPathTemplate('{{unknownVar}}/file.js', context); + expect(result).to.equal('{{unknownVar}}/file.js'); + }); + }); + + describe('ScaffoldEngine', () => { + it('should create engine with variables', () => { + const engine = new ScaffoldEngine({ + cartridgeName: 'app_custom', + }); + const context = engine.getContext(); + expect(context.cartridgeName).to.equal('app_custom'); + }); + + it('should render templates', () => { + const engine = new ScaffoldEngine({ + name: 'test', + }); + const result = engine.render('Hello, <%= name %>!'); + expect(result).to.equal('Hello, test!'); + }); + + it('should render paths', () => { + const engine = new ScaffoldEngine({ + cartridgeName: 'app_custom', + }); + const result = engine.renderPath('{{cartridgeName}}/cartridge'); + expect(result).to.equal('app_custom/cartridge'); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/scaffold/merge.test.ts b/packages/b2c-tooling-sdk/test/scaffold/merge.test.ts new file mode 100644 index 00000000..60a862d3 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/scaffold/merge.test.ts @@ -0,0 +1,177 @@ +/* + * 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 {mergeJson, insertAfter, insertBefore, appendContent, prependContent} from '../../src/scaffold/merge.js'; + +describe('scaffold/merge', () => { + describe('mergeJson', () => { + it('should merge objects at root level', () => { + const existing = JSON.stringify({a: 1, b: 2}); + const newContent = JSON.stringify({c: 3}); + const result = JSON.parse(mergeJson(existing, newContent)); + + expect(result).to.deep.equal({a: 1, b: 2, c: 3}); + }); + + it('should merge arrays by appending unique items', () => { + const existing = JSON.stringify({items: [1, 2, 3]}); + const newContent = JSON.stringify({items: [3, 4, 5]}); + const result = JSON.parse(mergeJson(existing, newContent)); + + expect(result.items).to.deep.equal([1, 2, 3, 4, 5]); + }); + + it('should merge at a specific JSON path', () => { + const existing = JSON.stringify({ + hooks: [{name: 'existing'}], + }); + const newContent = JSON.stringify([{name: 'new'}]); + const result = JSON.parse(mergeJson(existing, newContent, {jsonPath: 'hooks'})); + + expect(result.hooks).to.deep.equal([{name: 'existing'}, {name: 'new'}]); + }); + + it('should create path if it does not exist', () => { + const existing = JSON.stringify({}); + const newContent = JSON.stringify([{id: 'step1'}]); + const result = JSON.parse(mergeJson(existing, newContent, {jsonPath: 'step-types'})); + + expect(result['step-types']).to.deep.equal([{id: 'step1'}]); + }); + + it('should create nested path', () => { + const existing = JSON.stringify({}); + const newContent = JSON.stringify({value: 'test'}); + const result = JSON.parse(mergeJson(existing, newContent, {jsonPath: 'a.b.c'})); + + expect(result.a.b.c).to.deep.equal({value: 'test'}); + }); + + it('should deep merge nested objects', () => { + const existing = JSON.stringify({ + config: {a: 1, b: {x: 1}}, + }); + const newContent = JSON.stringify({ + config: {c: 3, b: {y: 2}}, + }); + const result = JSON.parse(mergeJson(existing, newContent)); + + expect(result.config).to.deep.equal({a: 1, b: {x: 1, y: 2}, c: 3}); + }); + + it('should accept object as newContent', () => { + const existing = JSON.stringify({a: 1}); + const newContent = {b: 2}; + const result = JSON.parse(mergeJson(existing, newContent)); + + expect(result).to.deep.equal({a: 1, b: 2}); + }); + }); + + describe('insertAfter', () => { + it('should insert content after marker with proper newlines', () => { + const existing = 'line1\nMARKER\nline3'; + const result = insertAfter(existing, 'inserted', 'MARKER'); + // Existing has newline after MARKER, content inserted, newline before remaining content + expect(result).to.equal('line1\nMARKER\ninserted\nline3'); + }); + + it('should throw if marker not found', () => { + const existing = 'line1\nline2'; + + expect(() => insertAfter(existing, 'content', 'MISSING')).to.throw('Marker not found'); + }); + + it('should handle marker at end of file', () => { + const existing = 'line1\nMARKER'; + const result = insertAfter(existing, 'inserted', 'MARKER'); + + expect(result).to.equal('line1\nMARKER\ninserted'); + }); + + it('should handle content that already ends with newline', () => { + const existing = 'before\nMARKER\nafter'; + const result = insertAfter(existing, 'inserted\n', 'MARKER'); + // Content ends with newline, next line is preserved + expect(result).to.equal('before\nMARKER\ninserted\nafter'); + }); + }); + + describe('insertBefore', () => { + it('should insert content before marker with newline', () => { + const existing = 'line1\nMARKER\nline3'; + const result = insertBefore(existing, 'inserted', 'MARKER'); + // Content gets newline after since it doesn't end with one + expect(result).to.equal('line1\ninserted\nMARKER\nline3'); + }); + + it('should throw if marker not found', () => { + const existing = 'line1\nline2'; + + expect(() => insertBefore(existing, 'content', 'MISSING')).to.throw('Marker not found'); + }); + + it('should handle marker at start of file', () => { + const existing = 'MARKER\nline2'; + const result = insertBefore(existing, 'inserted', 'MARKER'); + // Content gets newline after it, no newline before since at start + expect(result).to.equal('inserted\nMARKER\nline2'); + }); + + it('should not add extra newline if content ends with one', () => { + const existing = 'before\nMARKER\nafter'; + const result = insertBefore(existing, 'inserted\n', 'MARKER'); + // Content ends with newline, so no extra newline needed + expect(result).to.equal('before\ninserted\nMARKER\nafter'); + }); + }); + + describe('appendContent', () => { + it('should append content to end of file', () => { + const existing = 'line1\nline2'; + const result = appendContent(existing, 'line3'); + + expect(result).to.equal('line1\nline2\nline3'); + }); + + it('should handle empty existing content', () => { + const existing = ''; + const result = appendContent(existing, 'content'); + + expect(result).to.equal('content'); + }); + + it('should not add extra newline if existing ends with newline', () => { + const existing = 'line1\n'; + const result = appendContent(existing, 'line2'); + + expect(result).to.equal('line1\nline2'); + }); + }); + + describe('prependContent', () => { + it('should prepend content to start of file', () => { + const existing = 'line2\nline3'; + const result = prependContent(existing, 'line1'); + + expect(result).to.equal('line1\nline2\nline3'); + }); + + it('should handle empty existing content', () => { + const existing = ''; + const result = prependContent(existing, 'content'); + + expect(result).to.equal('content'); + }); + + it('should not add extra newline if new content ends with newline', () => { + const existing = 'line2'; + const result = prependContent(existing, 'line1\n'); + + expect(result).to.equal('line1\nline2'); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/scaffold/parameter-resolver.test.ts b/packages/b2c-tooling-sdk/test/scaffold/parameter-resolver.test.ts new file mode 100644 index 00000000..2d529595 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/scaffold/parameter-resolver.test.ts @@ -0,0 +1,279 @@ +/* + * 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 { + resolveScaffoldParameters, + parseParameterOptions, + getParameterSchemas, + type Scaffold, +} from '../../src/scaffold/index.js'; + +describe('scaffold/parameter-resolver', () => { + describe('parseParameterOptions', () => { + it('should parse key=value pairs', () => { + const result = parseParameterOptions(['name=test', 'description=hello world']); + expect(result).to.deep.equal({ + name: 'test', + description: 'hello world', + }); + }); + + it('should handle boolean true values', () => { + const result = parseParameterOptions(['enabled=true', 'disabled=false']); + expect(result).to.deep.equal({ + enabled: true, + disabled: false, + }); + }); + + it('should handle flag-style booleans without values', () => { + const result = parseParameterOptions(['verbose', 'debug']); + expect(result).to.deep.equal({ + verbose: true, + debug: true, + }); + }); + + it('should handle values with equals signs', () => { + const result = parseParameterOptions(['formula=a=b+c']); + expect(result).to.deep.equal({ + formula: 'a=b+c', + }); + }); + + it('should parse multi-choice values as arrays when scaffold provided', () => { + const scaffold = { + manifest: { + parameters: [{name: 'features', type: 'multi-choice'}], + }, + } as Scaffold; + const result = parseParameterOptions(['features=a,b,c'], scaffold); + expect(result).to.deep.equal({ + features: ['a', 'b', 'c'], + }); + }); + + it('should not split comma values for regular strings', () => { + const scaffold = { + manifest: { + parameters: [{name: 'name', type: 'string'}], + }, + } as Scaffold; + const result = parseParameterOptions(['name=a,b,c'], scaffold); + expect(result).to.deep.equal({ + name: 'a,b,c', + }); + }); + + it('should handle empty input', () => { + const result = parseParameterOptions([]); + expect(result).to.deep.equal({}); + }); + }); + + describe('resolveScaffoldParameters', () => { + const createTestScaffold = (parameters: Scaffold['manifest']['parameters']): Scaffold => ({ + id: 'test-scaffold', + manifest: { + name: 'test-scaffold', + displayName: 'Test Scaffold', + description: 'Test scaffold', + category: 'cartridge', + parameters, + }, + path: '/test', + filesPath: '/test/files', + source: 'built-in', + }); + + it('should pass through provided variables', async () => { + const scaffold = createTestScaffold([{name: 'name', type: 'string', prompt: 'Name?', required: true}]); + + const result = await resolveScaffoldParameters(scaffold, { + providedVariables: {name: 'test-value'}, + }); + + expect(result.variables).to.deep.equal({name: 'test-value'}); + expect(result.missingParameters).to.have.lengthOf(0); + expect(result.errors).to.have.lengthOf(0); + }); + + it('should track missing required parameters', async () => { + const scaffold = createTestScaffold([{name: 'name', type: 'string', prompt: 'Name?', required: true}]); + + const result = await resolveScaffoldParameters(scaffold, {}); + + expect(result.missingParameters).to.have.lengthOf(1); + expect(result.missingParameters[0].name).to.equal('name'); + }); + + it('should apply defaults when useDefaults is true', async () => { + const scaffold = createTestScaffold([ + {name: 'name', type: 'string', prompt: 'Name?', required: true, default: 'default-name'}, + ]); + + const result = await resolveScaffoldParameters(scaffold, { + useDefaults: true, + }); + + expect(result.variables).to.deep.equal({name: 'default-name'}); + expect(result.missingParameters).to.have.lengthOf(0); + }); + + it('should not apply defaults when useDefaults is false', async () => { + const scaffold = createTestScaffold([ + {name: 'name', type: 'string', prompt: 'Name?', required: true, default: 'default-name'}, + ]); + + const result = await resolveScaffoldParameters(scaffold, { + useDefaults: false, + }); + + expect(result.variables).to.not.have.property('name'); + expect(result.missingParameters).to.have.lengthOf(1); + }); + + it('should skip conditional parameters when condition is false', async () => { + const scaffold = createTestScaffold([ + {name: 'type', type: 'choice', prompt: 'Type?', required: true, choices: [{value: 'a', label: 'A'}]}, + {name: 'advanced', type: 'string', prompt: 'Advanced?', required: true, when: 'type=b'}, + ]); + + const result = await resolveScaffoldParameters(scaffold, { + providedVariables: {type: 'a'}, + }); + + expect(result.variables).to.deep.equal({type: 'a'}); + expect(result.missingParameters).to.have.lengthOf(0); + }); + + it('should evaluate conditional parameters when condition is true', async () => { + const scaffold = createTestScaffold([ + {name: 'type', type: 'choice', prompt: 'Type?', required: true, choices: [{value: 'b', label: 'B'}]}, + {name: 'advanced', type: 'string', prompt: 'Advanced?', required: true, when: 'type=b'}, + ]); + + const result = await resolveScaffoldParameters(scaffold, { + providedVariables: {type: 'b'}, + }); + + expect(result.missingParameters).to.have.lengthOf(1); + expect(result.missingParameters[0].name).to.equal('advanced'); + }); + + it('should validate against hook-points source (allows any)', async () => { + const scaffold = createTestScaffold([ + {name: 'hook', type: 'choice', prompt: 'Hook?', required: true, source: 'hook-points'}, + ]); + + const result = await resolveScaffoldParameters(scaffold, { + providedVariables: {hook: 'dw.order.calculate'}, + }); + + expect(result.variables).to.deep.equal({hook: 'dw.order.calculate'}); + expect(result.errors).to.have.lengthOf(0); + }); + }); + + describe('getParameterSchemas', () => { + it('should return parameter schemas with static choices', async () => { + const scaffold: Scaffold = { + id: 'test', + manifest: { + name: 'test', + displayName: 'Test', + description: 'Test', + category: 'cartridge', + parameters: [ + { + name: 'type', + type: 'choice', + prompt: 'Type?', + required: true, + choices: [ + {value: 'a', label: 'Option A'}, + {value: 'b', label: 'Option B'}, + ], + }, + ], + }, + path: '/test', + filesPath: '/test/files', + source: 'built-in', + }; + + const schemas = await getParameterSchemas(scaffold); + + expect(schemas).to.have.lengthOf(1); + expect(schemas[0].parameter.name).to.equal('type'); + expect(schemas[0].resolvedChoices).to.deep.equal([ + {value: 'a', label: 'Option A'}, + {value: 'b', label: 'Option B'}, + ]); + }); + + it('should resolve hook-points source', async () => { + const scaffold: Scaffold = { + id: 'test', + manifest: { + name: 'test', + displayName: 'Test', + description: 'Test', + category: 'cartridge', + parameters: [ + { + name: 'hook', + type: 'choice', + prompt: 'Hook?', + required: true, + source: 'hook-points', + }, + ], + }, + path: '/test', + filesPath: '/test/files', + source: 'built-in', + }; + + const schemas = await getParameterSchemas(scaffold); + + expect(schemas).to.have.lengthOf(1); + expect(schemas[0].resolvedChoices).to.be.an('array'); + expect(schemas[0].resolvedChoices!.length).to.be.greaterThan(0); + expect(schemas[0].resolvedChoices!.some((c) => c.value === 'dw.order.calculate')).to.be.true; + }); + + it('should report warning for remote source without b2cInstance', async () => { + const scaffold: Scaffold = { + id: 'test', + manifest: { + name: 'test', + displayName: 'Test', + description: 'Test', + category: 'cartridge', + parameters: [ + { + name: 'site', + type: 'choice', + prompt: 'Site?', + required: true, + source: 'sites', + }, + ], + }, + path: '/test', + filesPath: '/test/files', + source: 'built-in', + }; + + const schemas = await getParameterSchemas(scaffold); + + expect(schemas).to.have.lengthOf(1); + expect(schemas[0].warning).to.include('requires B2C instance'); + expect(schemas[0].resolvedChoices).to.deep.equal([]); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/scaffold/registry.test.ts b/packages/b2c-tooling-sdk/test/scaffold/registry.test.ts new file mode 100644 index 00000000..13d36212 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/scaffold/registry.test.ts @@ -0,0 +1,131 @@ +/* + * 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 {createScaffoldRegistry, ScaffoldRegistry, SCAFFOLDS_DATA_DIR} from '../../src/scaffold/index.js'; + +describe('scaffold/registry', () => { + describe('createScaffoldRegistry', () => { + it('should create a registry instance', () => { + const registry = createScaffoldRegistry(); + expect(registry).to.be.instanceOf(ScaffoldRegistry); + }); + }); + + describe('ScaffoldRegistry', () => { + let registry: ScaffoldRegistry; + + beforeEach(() => { + registry = createScaffoldRegistry(); + }); + + describe('getScaffolds', () => { + it('should discover built-in scaffolds', async () => { + const scaffolds = await registry.getScaffolds(); + expect(scaffolds).to.be.an('array'); + expect(scaffolds.length).to.be.greaterThan(0); + }); + + it('should include cartridge scaffold', async () => { + const scaffolds = await registry.getScaffolds(); + const cartridge = scaffolds.find((s) => s.id === 'cartridge'); + expect(cartridge).to.exist; + expect(cartridge?.manifest.category).to.equal('cartridge'); + expect(cartridge?.source).to.equal('built-in'); + }); + + it('should include custom-api scaffold', async () => { + const scaffolds = await registry.getScaffolds(); + const customApi = scaffolds.find((s) => s.id === 'custom-api'); + expect(customApi).to.exist; + expect(customApi?.manifest.category).to.equal('cartridge'); + }); + + it('should filter by category', async () => { + const scaffolds = await registry.getScaffolds({category: 'cartridge'}); + expect(scaffolds.every((s) => s.manifest.category === 'cartridge')).to.be.true; + }); + + it('should filter by query', async () => { + const scaffolds = await registry.getScaffolds({query: 'cartridge'}); + expect(scaffolds.length).to.be.greaterThan(0); + expect(scaffolds.some((s) => s.id === 'cartridge')).to.be.true; + }); + + it('should cache results', async () => { + const scaffolds1 = await registry.getScaffolds(); + const scaffolds2 = await registry.getScaffolds(); + expect(scaffolds1).to.equal(scaffolds2); + }); + + it('should clear cache', async () => { + const scaffolds1 = await registry.getScaffolds(); + registry.clearCache(); + const scaffolds2 = await registry.getScaffolds(); + expect(scaffolds1).to.not.equal(scaffolds2); + expect(scaffolds1).to.deep.equal(scaffolds2); + }); + }); + + describe('getScaffold', () => { + it('should get a scaffold by ID', async () => { + const scaffold = await registry.getScaffold('cartridge'); + expect(scaffold).to.exist; + expect(scaffold?.id).to.equal('cartridge'); + }); + + it('should return null for non-existent scaffold', async () => { + const scaffold = await registry.getScaffold('non-existent'); + expect(scaffold).to.be.null; + }); + }); + + describe('searchScaffolds', () => { + it('should search by query', async () => { + const results = await registry.searchScaffolds('api'); + expect(results.length).to.be.greaterThan(0); + expect(results.some((s) => s.id === 'custom-api')).to.be.true; + }); + }); + + describe('providers', () => { + it('should support custom providers', async () => { + registry.addProviders([ + { + name: 'test-provider', + priority: 'after', + async getScaffolds() { + return [ + { + id: 'test-scaffold', + manifest: { + name: 'test-scaffold', + displayName: 'Test Scaffold', + description: 'A test scaffold', + category: 'cartridge', + parameters: [], + }, + path: '/tmp/test', + filesPath: '/tmp/test/files', + source: 'plugin', + }, + ]; + }, + }, + ]); + + const scaffolds = await registry.getScaffolds(); + expect(scaffolds.some((s) => s.id === 'test-scaffold')).to.be.true; + }); + }); + }); + + describe('SCAFFOLDS_DATA_DIR', () => { + it('should be a valid path', () => { + expect(SCAFFOLDS_DATA_DIR).to.be.a('string'); + expect(SCAFFOLDS_DATA_DIR).to.include('data/scaffolds'); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/scaffold/validation.test.ts b/packages/b2c-tooling-sdk/test/scaffold/validation.test.ts new file mode 100644 index 00000000..a426d743 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/scaffold/validation.test.ts @@ -0,0 +1,154 @@ +/* + * 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 path from 'node:path'; +import { + validateEjsSyntax, + checkOrphanedFiles, + validateScaffoldDirectory, + SCAFFOLDS_DATA_DIR, + type ScaffoldManifest, +} from '../../src/scaffold/index.js'; + +describe('scaffold/validation', () => { + describe('validateEjsSyntax', () => { + it('should pass valid EJS content', () => { + const content = '<%= name %> is <%= value %>'; + const issues = validateEjsSyntax(content, 'test.ejs'); + expect(issues).to.have.lengthOf(0); + }); + + it('should detect mismatched EJS tags', () => { + const content = '<%= name %> is <% unclosed'; + const issues = validateEjsSyntax(content, 'test.ejs'); + + expect(issues.some((i) => i.message.includes('Mismatched EJS tags'))).to.be.true; + expect(issues[0].severity).to.equal('error'); + }); + + it('should detect empty output tags', () => { + const content = '<%= %>'; + const issues = validateEjsSyntax(content, 'test.ejs'); + + expect(issues.some((i) => i.message.includes('Empty EJS output tag'))).to.be.true; + expect(issues[0].severity).to.equal('warning'); + }); + + it('should include filename in issue', () => { + const content = '<% unclosed'; + const issues = validateEjsSyntax(content, 'template.ejs'); + + expect(issues[0].file).to.equal('files/template.ejs'); + }); + + it('should handle content without EJS', () => { + const content = 'Just plain text'; + const issues = validateEjsSyntax(content, 'test.txt'); + expect(issues).to.have.lengthOf(0); + }); + }); + + describe('checkOrphanedFiles', () => { + it('should find orphaned files not in manifest', () => { + const allTemplates = ['used.ejs', 'orphan.ejs']; + const manifest: ScaffoldManifest = { + name: 'test', + displayName: 'Test', + description: 'Test', + category: 'cartridge', + parameters: [], + files: [{template: 'used.ejs', destination: 'used.txt'}], + }; + + const issues = checkOrphanedFiles(allTemplates, manifest); + + expect(issues).to.have.lengthOf(1); + expect(issues[0].message).to.include('orphan.ejs'); + expect(issues[0].severity).to.equal('warning'); + }); + + it('should not flag files referenced in modifications', () => { + const allTemplates = ['main.ejs', 'mod-content.ejs']; + const manifest: ScaffoldManifest = { + name: 'test', + displayName: 'Test', + description: 'Test', + category: 'cartridge', + parameters: [], + files: [{template: 'main.ejs', destination: 'main.txt'}], + modifications: [{target: 'existing.json', type: 'json-merge', contentTemplate: 'mod-content.ejs'}], + }; + + const issues = checkOrphanedFiles(allTemplates, manifest); + + expect(issues).to.have.lengthOf(0); + }); + + it('should handle empty files array', () => { + const allTemplates = ['orphan.ejs']; + const manifest: ScaffoldManifest = { + name: 'test', + displayName: 'Test', + description: 'Test', + category: 'cartridge', + parameters: [], + }; + + const issues = checkOrphanedFiles(allTemplates, manifest); + + expect(issues).to.have.lengthOf(1); + }); + }); + + describe('validateScaffoldDirectory', () => { + it('should validate built-in service scaffold', async () => { + const serviceScaffoldPath = path.join(SCAFFOLDS_DATA_DIR, 'service'); + const result = await validateScaffoldDirectory(serviceScaffoldPath); + + expect(result.valid).to.be.true; + expect(result.errors).to.equal(0); + }); + + it('should validate built-in cartridge scaffold', async () => { + const cartridgeScaffoldPath = path.join(SCAFFOLDS_DATA_DIR, 'cartridge'); + const result = await validateScaffoldDirectory(cartridgeScaffoldPath); + + expect(result.valid).to.be.true; + expect(result.errors).to.equal(0); + }); + + it('should fail for non-existent path', async () => { + const result = await validateScaffoldDirectory('/nonexistent/path/scaffold'); + + expect(result.valid).to.be.false; + expect(result.errors).to.be.greaterThan(0); + expect(result.issues[0].message).to.include('does not exist'); + }); + + it('should detect missing scaffold.json', async () => { + // Use the data directory itself which has no scaffold.json + const result = await validateScaffoldDirectory(SCAFFOLDS_DATA_DIR); + + expect(result.valid).to.be.false; + expect(result.issues.some((i) => i.message.includes('scaffold.json not found'))).to.be.true; + }); + + it('should fail validation in strict mode with warnings', async () => { + // The service scaffold has postInstructions, so no warning + // We'll just verify strict mode works - most scaffolds have warnings + const serviceScaffoldPath = path.join(SCAFFOLDS_DATA_DIR, 'service'); + const resultNormal = await validateScaffoldDirectory(serviceScaffoldPath, {strict: false}); + const resultStrict = await validateScaffoldDirectory(serviceScaffoldPath, {strict: true}); + + // With strict mode, warnings count as failures + if (resultNormal.warnings > 0) { + expect(resultStrict.valid).to.be.false; + } else { + expect(resultStrict.valid).to.equal(resultNormal.valid); + } + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/scaffold/validators.test.ts b/packages/b2c-tooling-sdk/test/scaffold/validators.test.ts new file mode 100644 index 00000000..38ecef43 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/scaffold/validators.test.ts @@ -0,0 +1,323 @@ +/* + * 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 { + validateScaffoldManifest, + evaluateCondition, + validateParameters, + isValidScaffoldName, + isValidParameterName, +} from '../../src/scaffold/validators.js'; +import type {ScaffoldManifest} from '../../src/scaffold/types.js'; + +describe('scaffold/validators', () => { + describe('validateScaffoldManifest', () => { + const validManifest = { + name: 'test-scaffold', + displayName: 'Test Scaffold', + description: 'A test scaffold', + category: 'cartridge', + parameters: [], + }; + + it('should accept a valid manifest', () => { + const errors = validateScaffoldManifest(validManifest); + expect(errors).to.be.empty; + }); + + it('should reject null manifest', () => { + const errors = validateScaffoldManifest(null); + expect(errors).to.include('Manifest must be an object'); + }); + + it('should reject missing name', () => { + const manifest = {...validManifest, name: undefined}; + const errors = validateScaffoldManifest(manifest); + expect(errors).to.include('Manifest must have a "name" field (string)'); + }); + + it('should reject invalid name format', () => { + const manifest = {...validManifest, name: 'InvalidName'}; + const errors = validateScaffoldManifest(manifest); + expect(errors).to.include('Manifest "name" must be kebab-case (lowercase letters, numbers, hyphens)'); + }); + + it('should reject missing category', () => { + const manifest = {...validManifest, category: undefined}; + const errors = validateScaffoldManifest(manifest); + expect(errors).to.include('Manifest must have a "category" field (string)'); + }); + + it('should accept any category string', () => { + const manifest = {...validManifest, category: 'custom-category'}; + const errors = validateScaffoldManifest(manifest); + expect(errors).to.be.empty; + }); + + it('should validate parameter definitions', () => { + const manifest = { + ...validManifest, + parameters: [ + { + name: 'testParam', + prompt: 'Enter a value', + type: 'string', + required: true, + }, + ], + }; + const errors = validateScaffoldManifest(manifest); + expect(errors).to.be.empty; + }); + + it('should reject parameters with reserved names', () => { + const manifest = { + ...validManifest, + parameters: [ + { + name: 'kebabCase', + prompt: 'Enter a value', + type: 'string', + required: true, + }, + ], + }; + const errors = validateScaffoldManifest(manifest); + expect(errors.some((e) => e.includes('reserved name'))).to.be.true; + }); + + it('should reject choice parameters without choices or source', () => { + const manifest = { + ...validManifest, + parameters: [ + { + name: 'testChoice', + prompt: 'Select an option', + type: 'choice', + required: true, + }, + ], + }; + const errors = validateScaffoldManifest(manifest); + expect(errors.some((e) => e.includes('must have a "choices" array or a "source" field'))).to.be.true; + }); + + it('should accept choice parameters with source instead of choices', () => { + const manifest = { + ...validManifest, + parameters: [ + { + name: 'testChoice', + prompt: 'Select an option', + type: 'choice', + required: true, + source: 'cartridges', + }, + ], + }; + const errors = validateScaffoldManifest(manifest); + expect(errors).to.be.empty; + }); + + it('should accept valid source values', () => { + const manifest = { + ...validManifest, + parameters: [ + { + name: 'cartridgeName', + prompt: 'Select cartridge', + type: 'string', + required: true, + source: 'cartridges', + }, + { + name: 'hookPoint', + prompt: 'Select hook', + type: 'string', + required: true, + source: 'hook-points', + }, + { + name: 'siteId', + prompt: 'Select site', + type: 'choice', + required: true, + source: 'sites', + }, + ], + }; + const errors = validateScaffoldManifest(manifest); + expect(errors).to.be.empty; + }); + + it('should reject invalid source values', () => { + const manifest = { + ...validManifest, + parameters: [ + { + name: 'testParam', + prompt: 'Select something', + type: 'string', + required: true, + source: 'invalid-source', + }, + ], + }; + const errors = validateScaffoldManifest(manifest); + expect(errors.some((e) => e.includes('"source" must be one of'))).to.be.true; + }); + }); + + describe('evaluateCondition', () => { + it('should return true for undefined condition', () => { + expect(evaluateCondition(undefined, {})).to.be.true; + }); + + it('should evaluate equality condition', () => { + expect(evaluateCondition('foo=bar', {foo: 'bar'})).to.be.true; + expect(evaluateCondition('foo=bar', {foo: 'baz'})).to.be.false; + }); + + it('should evaluate negation condition', () => { + expect(evaluateCondition('!foo', {})).to.be.true; + expect(evaluateCondition('!foo', {foo: ''})).to.be.true; + expect(evaluateCondition('!foo', {foo: 'value'})).to.be.false; + }); + + it('should evaluate truthy condition', () => { + expect(evaluateCondition('foo', {foo: 'value'})).to.be.true; + expect(evaluateCondition('foo', {foo: true})).to.be.true; + expect(evaluateCondition('foo', {foo: ''})).to.be.false; + expect(evaluateCondition('foo', {})).to.be.false; + }); + + it('should handle array values in equality check', () => { + expect(evaluateCondition('tags=api', {tags: ['api', 'rest']})).to.be.true; + expect(evaluateCondition('tags=web', {tags: ['api', 'rest']})).to.be.false; + }); + }); + + describe('validateParameters', () => { + const manifest: ScaffoldManifest = { + name: 'test', + displayName: 'Test', + description: 'Test', + category: 'cartridge', + parameters: [ + { + name: 'requiredString', + prompt: 'Enter value', + type: 'string', + required: true, + }, + { + name: 'optionalString', + prompt: 'Enter optional value', + type: 'string', + required: false, + default: 'default-value', + }, + { + name: 'booleanParam', + prompt: 'Yes or no?', + type: 'boolean', + required: false, + default: true, + }, + { + name: 'patternParam', + prompt: 'Enter pattern value', + type: 'string', + required: false, + pattern: '^[a-z]+$', + validationMessage: 'Must be lowercase letters only', + }, + ], + }; + + it('should validate required parameters', () => { + const result = validateParameters(manifest, {}); + expect(result.valid).to.be.false; + expect(result.errors).to.have.lengthOf(1); + expect(result.errors[0].parameter).to.equal('requiredString'); + }); + + it('should accept valid parameters', () => { + const result = validateParameters(manifest, {requiredString: 'value'}); + expect(result.valid).to.be.true; + expect(result.values.requiredString).to.equal('value'); + }); + + it('should use defaults for missing optional parameters', () => { + const result = validateParameters(manifest, {requiredString: 'value'}); + expect(result.values.optionalString).to.equal('default-value'); + expect(result.values.booleanParam).to.equal(true); + }); + + it('should validate pattern constraints', () => { + const result = validateParameters(manifest, { + requiredString: 'value', + patternParam: 'INVALID', + }); + expect(result.valid).to.be.false; + expect(result.errors.some((e) => e.parameter === 'patternParam')).to.be.true; + }); + + it('should accept valid pattern values', () => { + const result = validateParameters(manifest, { + requiredString: 'value', + patternParam: 'lowercase', + }); + expect(result.valid).to.be.true; + }); + + it('should normalize boolean string values', () => { + const result = validateParameters(manifest, { + requiredString: 'value', + booleanParam: 'false', + }); + expect(result.valid).to.be.true; + expect(result.values.booleanParam).to.equal(false); + }); + }); + + describe('isValidScaffoldName', () => { + it('should accept valid kebab-case names', () => { + expect(isValidScaffoldName('cartridge')).to.be.true; + expect(isValidScaffoldName('custom-api')).to.be.true; + expect(isValidScaffoldName('page-designer-component')).to.be.true; + expect(isValidScaffoldName('a')).to.be.true; + }); + + it('should reject invalid names', () => { + expect(isValidScaffoldName('Invalid')).to.be.false; + expect(isValidScaffoldName('with_underscore')).to.be.false; + expect(isValidScaffoldName('123-starts-with-number')).to.be.false; + expect(isValidScaffoldName('-starts-with-hyphen')).to.be.false; + }); + }); + + describe('isValidParameterName', () => { + it('should accept valid camelCase names', () => { + expect(isValidParameterName('cartridgeName')).to.be.true; + expect(isValidParameterName('apiType')).to.be.true; + expect(isValidParameterName('a')).to.be.true; + }); + + it('should reject invalid names', () => { + expect(isValidParameterName('Invalid')).to.be.false; + expect(isValidParameterName('with-hyphen')).to.be.false; + expect(isValidParameterName('with_underscore')).to.be.false; + }); + + it('should reject reserved names', () => { + expect(isValidParameterName('kebabCase')).to.be.false; + expect(isValidParameterName('camelCase')).to.be.false; + expect(isValidParameterName('year')).to.be.false; + expect(isValidParameterName('uuid')).to.be.false; + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b272a98..cbf806b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: cliui: specifier: ^9.0.1 version: 9.0.1 + glob: + specifier: ^13.0.0 + version: 13.0.0 marked: specifier: ^15.0.0 version: 15.0.12 @@ -287,6 +290,9 @@ importers: cliui: specifier: ^9.0.1 version: 9.0.1 + ejs: + specifier: ^3.1.10 + version: 3.1.10 fuse.js: specifier: ^7.0.0 version: 7.1.0 @@ -345,6 +351,9 @@ importers: '@types/chai': specifier: ^4.3.20 version: 4.3.20 + '@types/ejs': + specifier: ^3.1.5 + version: 3.1.5 '@types/mocha': specifier: ^10.0.10 version: 10.0.10 @@ -2779,6 +2788,9 @@ packages: '@types/chai@4.3.20': resolution: {integrity: sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==} + '@types/ejs@3.1.5': + resolution: {integrity: sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -10104,6 +10116,8 @@ snapshots: '@types/chai@4.3.20': {} + '@types/ejs@3.1.5': {} + '@types/estree@1.0.8': {} '@types/hast@3.0.4': diff --git a/skills/b2c-cli/skills/b2c-scaffold/SKILL.md b/skills/b2c-cli/skills/b2c-scaffold/SKILL.md new file mode 100644 index 00000000..b5c3c6f7 --- /dev/null +++ b/skills/b2c-cli/skills/b2c-scaffold/SKILL.md @@ -0,0 +1,168 @@ +--- +name: b2c-scaffold +description: Generate B2C Commerce cartridges, controllers, hooks, custom APIs, job steps, and Page Designer components from scaffold templates. +--- + +# B2C Scaffold Skill + +Use the `b2c scaffold` commands to generate B2C Commerce components from templates. + +> **Tip:** If `b2c` is not installed globally, use `npx @salesforce/b2c-cli` instead. + +## Examples + +### List Available Scaffolds + +```bash +# list all scaffolds +b2c scaffold list + +# list only cartridge scaffolds +b2c scaffold list --category cartridge + +# show extended info (description, tags) +b2c scaffold list -x +``` + +### Generate a Cartridge + +```bash +# generate interactively +b2c scaffold cartridge + +# generate with name +b2c scaffold cartridge --name app_custom + +# generate to specific directory +b2c scaffold cartridge --name app_custom --output ./src/cartridges + +# skip prompts, use defaults +b2c scaffold cartridge --name app_custom --force + +# preview without creating files +b2c scaffold cartridge --name app_custom --dry-run +``` + +### Generate a Controller + +```bash +# generate interactively (prompts for cartridge selection) +b2c scaffold controller + +# generate with all options +b2c scaffold controller \ + --option controllerName=Account \ + --option cartridgeName=app_custom \ + --option routes=Show,Submit +``` + +### Generate a Hook + +```bash +# generate a system hook +b2c scaffold hook \ + --option hookName=validateBasket \ + --option hookType=system \ + --option hookPoint=dw.order.calculate \ + --option cartridgeName=app_custom + +# generate an OCAPI hook +b2c scaffold hook \ + --option hookName=modifyBasket \ + --option hookType=ocapi \ + --option hookPoint=dw.ocapi.shop.basket.beforePOST \ + --option cartridgeName=app_custom +``` + +### Generate a Custom API + +```bash +# generate a shopper API +b2c scaffold custom-api \ + --option apiName=loyalty-points \ + --option apiType=shopper \ + --option cartridgeName=app_custom + +# generate an admin API +b2c scaffold custom-api \ + --option apiName=inventory-sync \ + --option apiType=admin \ + --option cartridgeName=app_custom +``` + +### Generate a Job Step + +```bash +# generate a task-based job step +b2c scaffold job-step \ + --option stepId=custom.CleanupOrders \ + --option stepType=task \ + --option cartridgeName=app_custom + +# generate a chunk-based job step +b2c scaffold job-step \ + --option stepId=custom.ImportProducts \ + --option stepType=chunk \ + --option cartridgeName=app_custom +``` + +### Generate a Page Designer Component + +```bash +b2c scaffold page-designer-component \ + --option componentId=heroCarousel \ + --option componentName="Hero Carousel" \ + --option componentGroup=content \ + --option cartridgeName=app_custom +``` + +### Get Scaffold Info + +```bash +# see parameters and usage for a scaffold +b2c scaffold info cartridge +b2c scaffold info controller +``` + +### Search Scaffolds + +```bash +# search by keyword +b2c scaffold search api + +# search within a category +b2c scaffold search template --category page-designer +``` + +### Create Custom Scaffolds + +```bash +# create a project-local scaffold +b2c scaffold init my-component --project + +# create a user scaffold +b2c scaffold init my-component --user + +# validate a custom scaffold +b2c scaffold validate ./.b2c/scaffolds/my-component +``` + +## Built-in Scaffolds + +| Scaffold | Category | Description | +|----------|----------|-------------| +| `cartridge` | cartridge | B2C cartridge with standard structure | +| `controller` | cartridge | SFRA controller with routes and middleware | +| `hook` | cartridge | Hook with hooks.json registration | +| `custom-api` | custom-api | Custom SCAPI with OAS 3.0 schema | +| `job-step` | job | Job step with steptypes.json registration | +| `page-designer-component` | page-designer | Page Designer component | + +## Related Skills + +- `b2c-cli:b2c-code` - Deploy generated cartridges to B2C instances +- `b2c:b2c-controllers` - SFRA controller patterns and best practices +- `b2c:b2c-hooks` - B2C hook extension points +- `b2c:b2c-custom-api-development` - Custom API development guide +- `b2c:b2c-custom-job-steps` - Job step implementation patterns +- `b2c:b2c-page-designer` - Page Designer component development diff --git a/typedoc.json b/typedoc.json index a9cd3c30..8474b1dc 100644 --- a/typedoc.json +++ b/typedoc.json @@ -11,6 +11,7 @@ "./packages/b2c-tooling-sdk/src/operations/logs/index.ts", "./packages/b2c-tooling-sdk/src/operations/mrt/index.ts", "./packages/b2c-tooling-sdk/src/operations/ods/index.ts", + "./packages/b2c-tooling-sdk/src/scaffold/index.ts", "./packages/b2c-tooling-sdk/src/docs/index.ts", "./packages/b2c-tooling-sdk/src/schemas/index.ts", "./packages/b2c-tooling-sdk/src/cli/index.ts",