diff --git a/.changeset/add-debug-cli-command.md b/.changeset/add-debug-cli-command.md new file mode 100644 index 00000000..04007e30 --- /dev/null +++ b/.changeset/add-debug-cli-command.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-cli': minor +--- + +Add `b2c debug cli` command for interactive terminal-based script debugging. Includes a REPL with commands for breakpoints, stepping, variable inspection, and expression evaluation. Use `--rpc` for JSONL-over-stdio mode suitable for headless scripts and agents. diff --git a/.changeset/add-debug-diagnostics-toolset.md b/.changeset/add-debug-diagnostics-toolset.md new file mode 100644 index 00000000..ceb3c920 --- /dev/null +++ b/.changeset/add-debug-diagnostics-toolset.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-dx-mcp': minor +--- + +Add script debugger MCP tools to the CARTRIDGES and STOREFRONTNEXT toolsets. Includes `debug_start_session`, `debug_end_session`, `debug_list_sessions`, `debug_set_breakpoints`, `debug_wait_for_stop`, `debug_get_stack`, `debug_get_variables`, `debug_evaluate`, `debug_continue`, `debug_step_over`, `debug_step_into`, `debug_step_out`, and `debug_capture_at_breakpoint`. diff --git a/.changeset/add-debug-skill-and-docs.md b/.changeset/add-debug-skill-and-docs.md new file mode 100644 index 00000000..764b678b --- /dev/null +++ b/.changeset/add-debug-skill-and-docs.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-dx-docs': patch +'@salesforce/b2c-agent-plugins': patch +--- + +Add debug command documentation and b2c-debug agent skill covering interactive REPL, RPC mode, and DAP usage. diff --git a/.changeset/add-mcp-server-context.md b/.changeset/add-mcp-server-context.md new file mode 100644 index 00000000..f2349521 --- /dev/null +++ b/.changeset/add-mcp-server-context.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-dx-mcp': minor +--- + +Add `ServerContext` for persistent server-scoped state across MCP tool invocations. Enables stateful tools (debug sessions, log watches) while preserving per-call config reloading for existing tools. diff --git a/.changeset/add-resolve-breakpoint-path.md b/.changeset/add-resolve-breakpoint-path.md new file mode 100644 index 00000000..bfbd4a11 --- /dev/null +++ b/.changeset/add-resolve-breakpoint-path.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-tooling-sdk': minor +--- + +Add `resolveBreakpointPath` utility that normalizes user-provided file paths to SDAPI script paths. Accepts server paths, absolute/relative local paths, and cartridge-name-prefixed paths with helpful error messages on failure. diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index a6f76369..e429d159 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -128,18 +128,45 @@ const referenceSidebar = [ { text: 'MCP Tools', items: [ - {text: 'cartridge_deploy', link: '/mcp/tools/cartridge-deploy'}, - {text: 'mrt_bundle_push', link: '/mcp/tools/mrt-bundle-push'}, - {text: 'pwakit_get_guidelines', link: '/mcp/tools/pwakit-get-guidelines'}, - {text: 'scapi_schemas_list', link: '/mcp/tools/scapi-schemas-list'}, - {text: 'scapi_custom_api_generate_scaffold', link: '/mcp/tools/scapi-custom-api-generate-scaffold'}, - {text: 'scapi_custom_apis_get_status', link: '/mcp/tools/scapi-custom-apis-get-status'}, - {text: 'sfnext_get_guidelines', link: '/mcp/tools/sfnext-get-guidelines'}, - {text: 'sfnext_start_figma_workflow', link: '/mcp/tools/sfnext-start-figma-workflow'}, - {text: 'sfnext_analyze_component', link: '/mcp/tools/sfnext-analyze-component'}, - {text: 'sfnext_match_tokens_to_theme', link: '/mcp/tools/sfnext-match-tokens-to-theme'}, - {text: 'sfnext_add_page_designer_decorator', link: '/mcp/tools/sfnext-add-page-designer-decorator'}, - {text: 'sfnext_configure_theme', link: '/mcp/tools/sfnext-configure-theme'}, + { + text: 'Cartridges', + collapsed: true, + items: [{text: 'cartridge_deploy', link: '/mcp/tools/cartridge-deploy'}], + }, + { + text: 'SCAPI', + collapsed: true, + items: [ + {text: 'scapi_schemas_list', link: '/mcp/tools/scapi-schemas-list'}, + {text: 'scapi_custom_api_generate_scaffold', link: '/mcp/tools/scapi-custom-api-generate-scaffold'}, + {text: 'scapi_custom_apis_get_status', link: '/mcp/tools/scapi-custom-apis-get-status'}, + ], + }, + { + text: 'PWA Kit', + collapsed: true, + items: [ + {text: 'mrt_bundle_push', link: '/mcp/tools/mrt-bundle-push'}, + {text: 'pwakit_get_guidelines', link: '/mcp/tools/pwakit-get-guidelines'}, + ], + }, + { + text: 'Storefront Next', + collapsed: true, + items: [ + {text: 'sfnext_get_guidelines', link: '/mcp/tools/sfnext-get-guidelines'}, + {text: 'sfnext_start_figma_workflow', link: '/mcp/tools/sfnext-start-figma-workflow'}, + {text: 'sfnext_analyze_component', link: '/mcp/tools/sfnext-analyze-component'}, + {text: 'sfnext_match_tokens_to_theme', link: '/mcp/tools/sfnext-match-tokens-to-theme'}, + {text: 'sfnext_add_page_designer_decorator', link: '/mcp/tools/sfnext-add-page-designer-decorator'}, + {text: 'sfnext_configure_theme', link: '/mcp/tools/sfnext-configure-theme'}, + ], + }, + { + text: 'Diagnostics', + collapsed: true, + items: [{text: 'Script Debugger', link: '/mcp/tools/diagnostics'}], + }, ], }, ]; diff --git a/docs/cli/debug.md b/docs/cli/debug.md index 7d1ef247..48fe95e2 100644 --- a/docs/cli/debug.md +++ b/docs/cli/debug.md @@ -1,10 +1,13 @@ --- -description: Start a DAP debug adapter for B2C Commerce server-side script debugging. +description: DAP debug adapter, interactive REPL, and JSONL RPC mode for B2C Commerce server-side script debugging. --- -# Debug Command +# Debug Commands -The `b2c debug` command launches a [Debug Adapter Protocol (DAP)](https://microsoft.github.io/debug-adapter-protocol/) adapter that bridges your IDE to the B2C Commerce script debugger. It's designed to be invoked by an IDE (VS Code, JetBrains, etc.) over stdio, not run directly in a shell. +Commands for connecting to the B2C Commerce Script Debugger API (SDAPI) to set breakpoints, inspect variables, and step through server-side code. + +- **`b2c debug`** — Debug Adapter Protocol (DAP) adapter for IDE integrations (VS Code, JetBrains, etc.). +- **`b2c debug cli`** — interactive terminal REPL, or JSONL-over-stdio RPC mode for headless scripts and agents. ## Authentication @@ -14,11 +17,15 @@ The script debugger uses **Basic auth** (Business Manager username and password) - `SFCC_USERNAME` / `SFCC_PASSWORD` environment variables - `username` / `password` fields in `dw.json` +The script debugger must also be enabled on the instance: Business Manager > Administration > Development Configuration > Script Debugger > Enable. + See the [Authentication Guide](/guide/authentication) for details. +--- + ## b2c debug -Start the DAP adapter for the configured B2C instance. +The `b2c debug` command launches a [Debug Adapter Protocol (DAP)](https://microsoft.github.io/debug-adapter-protocol/) adapter that bridges your IDE to the B2C Commerce script debugger. It's designed to be invoked by an IDE over stdio, not run directly in a shell. ### Usage @@ -52,8 +59,214 @@ b2c debug --client-id my-debugger Most IDEs spawn DAP adapters automatically based on a launch configuration. The adapter speaks DAP over **stdin/stdout**, so direct shell invocation will appear to hang — that's expected. Configure your IDE's debug launcher to invoke `b2c debug` and supply the appropriate environment. -## Notes +### Notes - A warning is emitted if no cartridges are found at `--cartridge-path`. - Source maps are derived from the discovered cartridge layout; ensure your local cartridge tree matches what's deployed to the instance, otherwise breakpoints may not bind. - The adapter exits when its stdin stream closes. + +--- + +## b2c debug cli + +Start an interactive CLI debug session with a REPL interface. Provides a terminal-based debugging experience without requiring a DAP client. Add `--rpc` to switch to JSONL-over-stdio mode for headless scripts and agents. + +### Usage + +```bash +b2c debug cli [--cartridge-path ] [--client-id ] [--rpc] +``` + +### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--cartridge-path` | Path to directory containing cartridges | `.` | +| `--client-id` | Client ID for the debugger API | `b2c-cli` | +| `--rpc` | Run in RPC mode (JSONL over stdin/stdout) | `false` | + +Inherits the [global instance and authentication flags](./index#global-flags). + +### REPL Commands + +| Command | Alias | Description | +|---------|-------|-------------| +| `break : [if ]` | `b` | Set breakpoint | +| `breakpoints` | `bl` | List active breakpoints | +| `delete ` | `d` | Delete breakpoint | +| `continue` | `c` | Resume current thread | +| `step` | `s` | Step over | +| `stepin` | `si` | Step into | +| `stepout` | `so` | Step out | +| `stack` | `bt` | Show call stack | +| `frame ` | `f` | Select stack frame | +| `vars` | `v` | Show variables in current frame | +| `members ` | `m` | Expand object members | +| `eval ` | `e` | Evaluate expression | +| `threads` | `t` | List known threads | +| `thread ` | | Switch to thread | +| `help` | `h` | Show commands | +| `quit` | `q` | Disconnect and exit | + +### Examples + +```bash +# Start interactive debugger +b2c debug cli + +# Specify cartridge directory +b2c debug cli --cartridge-path ./cartridges + +# Use a custom client ID (for concurrent sessions) +b2c debug cli --client-id my-session + +# Start in RPC mode for headless scripts +b2c debug cli --rpc +``` + +### Interactive Session Example + +``` +debug> break Cart.js:42 +Breakpoint #1 set at ./cartridges/app_storefront/cartridge/controllers/Cart.js:42 + +debug> break Checkout.js:100 if basket.totalGrossPrice > 100 +Breakpoint #2 set at ./cartridges/app_storefront/cartridge/controllers/Checkout.js:100 + +● Thread 5 halted at ./cartridges/app_storefront/cartridge/controllers/Cart.js:42 in show() + +debug> vars + request: dw.system.Request = [object Request] [local] + basket: dw.order.Basket = [object Basket] [local] + +debug> eval basket.productLineItems.length +3 + +debug> stack + → #0 show ./cartridges/app_storefront/cartridge/controllers/Cart.js:42 + #1 execute /app_storefront/cartridge/controllers/Cart.js:1 + +debug> continue +Thread 5 resumed. +``` + +--- + +## RPC Mode + +When started with `--rpc`, the debug CLI runs as a JSONL-over-stdio RPC server. This enables headless scripts, agents, and other tools to drive the debugger programmatically. + +### Protocol + +- **Input** (stdin): One JSON object per line (JSONL) +- **Output** (stdout): One JSON object per line — either a response or an async event + +### Request Format + +```json +{"id": 1, "command": "set_breakpoints", "args": {"breakpoints": [{"file": "Cart.js", "line": 42}]}} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `id` | number or string | Optional. Echoed back in the response for correlation. | +| `command` | string | Required. The command to execute. | +| `args` | object | Optional. Command-specific arguments. | + +### Response Format + +```json +{"id": 1, "result": {"breakpoints": [{"id": 1, "file": "Cart.js", "line": 42, "script_path": "/app_storefront/cartridge/controllers/Cart.js"}]}} +``` + +On error: + +```json +{"id": 1, "error": "No thread selected. Wait for a thread_stopped event."} +``` + +### Event Format + +Events are emitted asynchronously (not in response to a command): + +```json +{"event": "ready", "data": {}} +{"event": "thread_stopped", "data": {"thread_id": 5, "location": {"file": "Cart.js", "line": 42, "function_name": "show", "script_path": "/app_storefront/cartridge/controllers/Cart.js"}}} +``` + +### Available Commands + +| Command | Args | Description | +|---------|------|-------------| +| `set_breakpoints` | `breakpoints: [{file, line, condition?}]` | Replace all breakpoints | +| `list_breakpoints` | | List current breakpoints | +| `continue` | `thread_id?` | Resume a halted thread | +| `step_over` | `thread_id?` | Step to next line | +| `step_into` | `thread_id?` | Step into function call | +| `step_out` | `thread_id?` | Step out of function | +| `get_stack` | `thread_id?` | Get call stack frames | +| `get_variables` | `thread_id?, frame_index?, scope?, object_path?` | Get variables | +| `evaluate` | `expression, thread_id?, frame_index?` | Evaluate expression | +| `list_threads` | | List known threads | +| `select_thread` | `thread_id` | Switch current thread | +| `select_frame` | `index` | Switch current frame | + +When `thread_id` is omitted, the last thread that halted is used. + +### Events + +| Event | Description | +|-------|-------------| +| `ready` | Emitted once after connection is established | +| `thread_stopped` | A thread hit a breakpoint or step completed | + +### Example Session (Python) + +```python +import subprocess, json + +proc = subprocess.Popen( + ["b2c", "debug", "cli", "--rpc"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + text=True, bufsize=1 +) + +def send(cmd, args=None, id=None): + msg = {"command": cmd} + if args: msg["args"] = args + if id is not None: msg["id"] = id + proc.stdin.write(json.dumps(msg) + "\n") + proc.stdin.flush() + +def recv(): + return json.loads(proc.stdout.readline()) + +# Wait for ready +assert recv()["event"] == "ready" + +# Set a breakpoint +send("set_breakpoints", {"breakpoints": [{"file": "Cart.js", "line": 42}]}, id=1) +response = recv() # {"id": 1, "result": {...}} + +# Wait for breakpoint hit (trigger a request on the instance) +event = recv() # {"event": "thread_stopped", "data": {...}} + +# Inspect state +send("get_stack", id=2) +stack = recv() + +send("get_variables", id=3) +variables = recv() + +# Continue execution +send("continue", id=4) +recv() +``` + +--- + +## See Also + +- [Authentication Guide](/guide/authentication) — Setting up instance credentials +- [Logs Commands](/cli/logs) — Retrieving server logs for debugging +- [Code Commands](/cli/code) — Deploying code before debugging diff --git a/docs/mcp/tools/diagnostics.md b/docs/mcp/tools/diagnostics.md new file mode 100644 index 00000000..6f78d862 --- /dev/null +++ b/docs/mcp/tools/diagnostics.md @@ -0,0 +1,201 @@ +--- +description: MCP tools for script debugging on B2C Commerce instances. +--- + +# Diagnostics Tools + +MCP tools for connecting to the B2C Commerce Script Debugger API (SDAPI), setting breakpoints, inspecting variables, and stepping through server-side code. These tools are available in the **CARTRIDGES** and **SCAPI** toolsets. + +## Authentication + +Requires **Basic Auth** credentials only. OAuth is not supported by the SDAPI. + +**Required:** +- **Basic Auth** - `hostname`, `username`, and `password` (WebDAV access key) for a Business Manager user with the `WebDAV_Manage_Customization` permission. + +**Configuration priority:** Flags → Environment variables → `dw.json` config file + +The script debugger must also be enabled on the instance: Business Manager > Administration > Development Configuration > Script Debugger > Enable. + +See [Configuration](../configuration) for complete credential setup details including flags and environment variables. See [Authentication Setup](../../guide/authentication#webdav-access) for WebDAV access key configuration instructions. + +## Recovery from broken or orphaned sessions + +Debug sessions are stateful and live in the MCP server process. If the agent loses track of an active session (context flush, crash, restart), or breakpoints stop firing as expected: + +1. **List active sessions** — call `debug_list_sessions` (no args). It returns all sessions known to the server with their `session_id`, `hostname`, halted threads, and currently armed breakpoints. +2. **End orphaned sessions** — call `debug_end_session` with the `session_id` to free the debugger slot on the instance. +3. **SDAPI single-client guarantee** — the script debugger only supports one client per `client_id` per host. Calling `debug_start_session` with the same `client_id` against the same host implicitly takes over (replaces) any prior client. This is the safety net when a session is lost without a clean shutdown. +4. **Idle cleanup** — sessions inactive for 30 minutes are automatically cleaned up by the server. +5. **Restart the MCP server** — as a last resort, restarting the MCP server destroys all session state. The orphaned debugger slot on the instance will be freed by SDAPI's own 60-second halt-timeout or by the next `debug_start_session` with the same client ID. + +--- + +## Session Lifecycle + +### debug_start_session + +Start a new script debugger session. Connects to the SDAPI, discovers cartridge mappings, and begins polling for halted threads. + +> **Warning:** Debug sessions can halt remote request threads on the instance. Use `debug_end_session` to cleanly disconnect when done. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `cartridge_directory` | string | No | Project directory | Path to directory containing cartridges | +| `client_id` | string | No | `b2c-cli` | Client ID for the debugger API. Use a different ID for concurrent sessions on the same host. | + +**Returns:** `session_id`, `hostname`, discovered `cartridges`, and `warnings`. + +### debug_end_session + +End a script debugger session. Disconnects from the SDAPI, stops polling, and cleans up resources. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `session_id` | string | Yes | | Session ID from `debug_start_session` | +| `clear_breakpoints` | boolean | No | `false` | Delete all breakpoints before disconnecting | + +### debug_list_sessions + +List all active debug sessions. Returns session IDs, connected hostnames, and any currently halted threads. + +No parameters. + +--- + +## Breakpoints + +### debug_set_breakpoints + +Set breakpoints in a debug session. **Replaces** all previously set breakpoints. + +Accepts local file paths (mapped to server paths via cartridge discovery), cartridge-prefixed paths (e.g. `app_storefront/cartridge/controllers/Cart.js`), or server paths starting with `/`. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `session_id` | string | Yes | Session ID from `debug_start_session` | +| `breakpoints` | array | Yes | Array of `{file, line, condition?}` objects | + +Each breakpoint object: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `file` | string | Yes | Local file path, cartridge-prefixed path, or server script path | +| `line` | number | Yes | Line number | +| `condition` | string | No | Conditional expression — breakpoint only triggers when true | + +--- + +## Execution Control + +### debug_wait_for_stop + +Wait for a thread to halt at a breakpoint or step. Returns immediately if a thread is already halted. Otherwise **blocks** until a halt occurs or the timeout expires — the user or an external process must trigger a request on the instance while this tool is waiting. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `session_id` | string | Yes | | Session ID | +| `timeout_ms` | number | No | 30000 | Timeout in milliseconds (max 120000) | + +**Returns:** `{halted, thread_id, location}` or `{halted: false, timed_out: true}`. + +### debug_continue + +Resume execution of a halted thread. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `session_id` | string | Yes | Session ID | +| `thread_id` | number | Yes | Thread ID of the halted thread | + +### debug_step_over + +Step to the next line in the current function. Follow with `debug_wait_for_stop`. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `session_id` | string | Yes | Session ID | +| `thread_id` | number | Yes | Thread ID | + +### debug_step_into + +Step into the function call on the current line. Follow with `debug_wait_for_stop`. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `session_id` | string | Yes | Session ID | +| `thread_id` | number | Yes | Thread ID | + +### debug_step_out + +Step out of the current function, returning to the caller. Follow with `debug_wait_for_stop`. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `session_id` | string | Yes | Session ID | +| `thread_id` | number | Yes | Thread ID | + +--- + +## Inspection + +### debug_get_stack + +Get the call stack for a halted thread. Returns stack frames with mapped local file paths and server script paths. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `session_id` | string | Yes | Session ID | +| `thread_id` | number | Yes | Thread ID | + +### debug_get_variables + +Get variables for a stack frame in a halted thread. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `session_id` | string | Yes | | Session ID | +| `thread_id` | number | Yes | | Thread ID | +| `frame_index` | number | No | `0` | Stack frame index (0 = top frame) | +| `scope` | string | No | All scopes | Filter by `local`, `closure`, or `global` | +| `object_path` | string | No | | Dot-delimited path to drill into an object (e.g. `request.httpParameters`) | + +### debug_evaluate + +Evaluate an expression in the context of a halted thread and stack frame. + +> **Warning:** Expressions can have side effects (modify variables, call functions). Use with care. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `session_id` | string | Yes | | Session ID | +| `thread_id` | number | Yes | | Thread ID | +| `frame_index` | number | No | `0` | Stack frame index | +| `expression` | string | Yes | | JavaScript expression to evaluate | + +--- + +## Higher-Level Tools + +### debug_capture_at_breakpoint + +Set a breakpoint, wait for it to be hit, and capture a diagnostic snapshot — stack, variables, and optional expression results in a single call. Optionally resumes the thread after capture. + +> **Important:** This tool **blocks** until the breakpoint is hit or the timeout expires. The user or an external process must trigger a request on the instance while this tool is waiting. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `session_id` | string | Yes | | Session ID | +| `file` | string | Yes | | File path for the breakpoint | +| `line` | number | Yes | | Line number | +| `condition` | string | No | | Conditional expression | +| `expressions` | string[] | No | | Expressions to evaluate when hit | +| `timeout_ms` | number | No | 30000 | Timeout waiting for the breakpoint (max 120000) | +| `auto_continue` | boolean | No | `false` | Resume the thread after capturing | + +--- + +## See Also + +- [Debug CLI Commands](/cli/debug) — Interactive REPL and RPC mode for the CLI +- [Configuration](../configuration) — Setting up instance credentials diff --git a/packages/b2c-cli/src/commands/debug/cli.ts b/packages/b2c-cli/src/commands/debug/cli.ts new file mode 100644 index 00000000..0f14122e --- /dev/null +++ b/packages/b2c-cli/src/commands/debug/cli.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 {Flags} from '@oclif/core'; +import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {findCartridges} from '@salesforce/b2c-tooling-sdk/operations/code'; +import { + DebugSessionManager, + createSourceMapper, + type DebugSessionCallbacks, +} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {DebugRepl} from '../../utils/debug/repl.js'; +import {DebugRpc} from '../../utils/debug/rpc.js'; + +export default class DebugCli extends InstanceCommand { + static description = + 'Start an interactive CLI debug session for B2C Commerce script debugging. ' + + 'Use --rpc for JSONL-over-stdio RPC mode suitable for headless scripts and agents.'; + + static examples = [ + '<%= config.bin %> debug cli', + '<%= config.bin %> debug cli --cartridge-path ./cartridges', + '<%= config.bin %> debug cli --client-id my-debugger', + '<%= config.bin %> debug cli --rpc', + ]; + + static flags = { + ...InstanceCommand.baseFlags, + 'cartridge-path': Flags.string({ + description: 'Path to cartridges directory', + default: '.', + }), + 'client-id': Flags.string({ + description: 'Client ID for the debugger API', + default: 'b2c-cli', + }), + rpc: Flags.boolean({ + description: 'Run in RPC mode: JSONL commands on stdin, JSONL responses on stdout', + default: false, + }), + }; + + async run(): Promise { + this.requireServer(); + + const hostname = this.resolvedConfig.values.hostname!; + const username = this.resolvedConfig.values.username; + const password = this.resolvedConfig.values.password; + + if (!username || !password) { + this.error( + 'Basic auth credentials (username/password) are required for the script debugger. ' + + 'Set via --username/--password flags, SFCC_USERNAME/SFCC_PASSWORD env vars, or dw.json.', + ); + } + + const cartridgePath = this.flags['cartridge-path'] ?? '.'; + const cartridges = findCartridges(cartridgePath); + if (cartridges.length === 0) { + this.warn(`No cartridges found in ${cartridgePath}`); + } + + this.logger.info( + `Mapped ${cartridges.length} cartridge(s): ${cartridges.map((c) => c.name).join(', ') || '(none)'}`, + ); + + const sourceMapper = createSourceMapper(cartridges); + + const isRpc = this.flags.rpc; + const holder: {handler?: DebugRepl | DebugRpc} = {}; + + const callbacks: DebugSessionCallbacks = { + onConnected: (host) => this.logger.debug(`Connected to script debugger on ${host}`), + onDisconnected: () => this.logger.debug('Script debugger disconnected'), + onDebuggerDisabled: () => this.logger.debug('Script debugger was disabled externally'), + onThreadStopped(thread) { + holder.handler?.onThreadStopped(thread); + }, + }; + + const manager = new DebugSessionManager( + { + hostname, + username, + password, + clientId: this.flags['client-id'], + cartridgeRoots: cartridges, + }, + callbacks, + ); + + await manager.connect(); + + if (isRpc) { + const rpc = new DebugRpc({ + manager, + sourceMapper, + cartridges, + output: process.stdout, + input: process.stdin, + }); + holder.handler = rpc; + + try { + await rpc.run(); + } finally { + await manager.disconnect(); + } + } else { + const repl = new DebugRepl({ + manager, + sourceMapper, + cartridges, + output: process.stderr, + input: process.stdin, + }); + holder.handler = repl; + + try { + await repl.run(); + } finally { + await manager.disconnect(); + } + } + } +} diff --git a/packages/b2c-cli/src/utils/debug/repl.ts b/packages/b2c-cli/src/utils/debug/repl.ts new file mode 100644 index 00000000..c7a948e3 --- /dev/null +++ b/packages/b2c-cli/src/utils/debug/repl.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 readline from 'node:readline'; +import type { + DebugSessionManager, + SourceMapper, + SdapiBreakpoint, + SdapiScriptThread, + SdapiObjectMember, +} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import type {CartridgeMapping} from '@salesforce/b2c-tooling-sdk/operations/code'; +import {resolveBreakpointPath} from '@salesforce/b2c-tooling-sdk/operations/debug'; + +const RED = ''; +const YELLOW = ''; +const CYAN = ''; +const GREEN = ''; +const DIM = ''; +const BOLD = ''; +const RESET = ''; + +interface ReplState { + currentThreadId?: number; + currentFrameIndex: number; +} + +export interface DebugReplOptions { + cartridges: CartridgeMapping[]; + manager: DebugSessionManager; + sourceMapper: SourceMapper; + output: NodeJS.WritableStream; + input: NodeJS.ReadableStream; +} + +type CommandHandler = (args: string) => Promise; + +export class DebugRepl { + private breakpoints: SdapiBreakpoint[] = []; + private readonly cartridges: CartridgeMapping[]; + private closed = false; + private commands: Map = new Map(); + private readonly input: NodeJS.ReadableStream; + private readonly manager: DebugSessionManager; + private readonly output: NodeJS.WritableStream; + private resolveRun?: () => void; + private rl?: readline.Interface; + private readonly sourceMapper: SourceMapper; + private readonly state: ReplState = {currentFrameIndex: 0}; + + constructor(options: DebugReplOptions) { + this.manager = options.manager; + this.sourceMapper = options.sourceMapper; + this.cartridges = options.cartridges; + this.output = options.output; + this.input = options.input; + this.registerCommands(); + } + + onThreadStopped(thread: SdapiScriptThread): void { + this.state.currentThreadId = thread.id; + this.state.currentFrameIndex = 0; + + const topFrame = thread.call_stack?.[0]; + if (topFrame) { + const loc = topFrame.location; + const localPath = this.sourceMapper.toLocalPath(loc.script_path); + const displayPath = localPath ?? loc.script_path; + const fn = loc.function_name || ''; + this.print( + `\n${YELLOW}${BOLD}● Thread ${thread.id}${RESET} halted at ${CYAN}${displayPath}:${loc.line_number}${RESET} in ${fn}()`, + ); + } else { + this.print(`\n${YELLOW}${BOLD}● Thread ${thread.id}${RESET} halted`); + } + } + + async run(): Promise { + this.closed = false; + this.rl = readline.createInterface({ + input: this.input, + output: this.output, + prompt: `${DIM}debug>${RESET} `, + }); + + this.print(`${GREEN}Script debugger connected.${RESET} Type ${BOLD}help${RESET} for available commands.`); + this.rl.prompt(); + + this.rl.on('line', (line: string) => { + const trimmed = line.trim(); + if (!trimmed) { + if (!this.closed) this.rl?.prompt(); + return; + } + this.handleLine(trimmed) + .then(() => { + if (!this.closed) this.rl?.prompt(); + }) + .catch(() => { + if (!this.closed) this.rl?.prompt(); + }); + }); + + this.rl.on('close', () => { + this.closed = true; + this.resolveRun?.(); + }); + + return new Promise((resolve) => { + this.resolveRun = resolve; + }); + } + + private async handleLine(line: string): Promise { + const spaceIdx = line.indexOf(' '); + const cmd = spaceIdx === -1 ? line : line.slice(0, spaceIdx); + const args = spaceIdx === -1 ? '' : line.slice(spaceIdx + 1).trim(); + + const handler = this.commands.get(cmd); + if (!handler) { + this.print(`${RED}Unknown command: ${cmd}${RESET}. Type ${BOLD}help${RESET} for available commands.`); + return; + } + + try { + await handler(args); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + this.print(`${RED}Error: ${msg}${RESET}`); + } + } + + private print(text: string): void { + this.output.write(text + '\n'); + } + + private printVariables(members: SdapiObjectMember[]): void { + if (members.length === 0) { + this.print(' (no variables)'); + return; + } + for (const m of members) { + const scope = m.scope ? ` ${DIM}[${m.scope}]${RESET}` : ''; + const value = m.value.length > 200 ? m.value.slice(0, 200) + '...' : m.value; + this.print(` ${BOLD}${m.name}${RESET}: ${DIM}${m.type}${RESET} = ${value}${scope}`); + } + } + + private registerCommand(name: string, shortcut: string, handler: CommandHandler): void { + this.commands.set(name, handler); + if (shortcut) this.commands.set(shortcut, handler); + } + + private registerCommands(): void { + this.registerCommand('break', 'b', async (args) => { + const match = args.match(/^(.+):(\d+)(?:\s+if\s+(.+))?$/); + if (!match) { + this.print('Usage: break : [if ]'); + return; + } + const [, file, lineStr, condition] = match; + const line = Number.parseInt(lineStr, 10); + const scriptPath = resolveBreakpointPath(file, this.sourceMapper, this.cartridges); + + const input = {script_path: scriptPath, line_number: line, condition}; + const existingInputs = this.breakpoints.map((bp) => ({ + script_path: bp.script_path, + line_number: bp.line_number, + condition: bp.condition, + })); + this.breakpoints = await this.manager.setBreakpoints([...existingInputs, input]); + const added = this.breakpoints.at(-1); + if (added) { + const local = this.sourceMapper.toLocalPath(added.script_path); + this.print( + `Breakpoint #${added.id} set at ${CYAN}${local ?? added.script_path}:${added.line_number}${RESET}` + + (added.condition ? ` ${DIM}if ${added.condition}${RESET}` : ''), + ); + } + }); + + this.registerCommand('breakpoints', 'bl', async () => { + if (this.breakpoints.length === 0) { + this.print('No breakpoints set.'); + return; + } + for (const bp of this.breakpoints) { + const local = this.sourceMapper.toLocalPath(bp.script_path); + this.print( + ` #${bp.id} ${CYAN}${local ?? bp.script_path}:${bp.line_number}${RESET}` + + (bp.condition ? ` ${DIM}if ${bp.condition}${RESET}` : ''), + ); + } + }); + + this.registerCommand('delete', 'd', async (args) => { + const id = Number.parseInt(args, 10); + if (Number.isNaN(id)) { + this.print('Usage: delete '); + return; + } + const remaining = this.breakpoints.filter((bp) => bp.id !== id); + if (remaining.length === this.breakpoints.length) { + this.print(`${RED}No breakpoint with id ${id}${RESET}`); + return; + } + const inputs = remaining.map((bp) => ({ + script_path: bp.script_path, + line_number: bp.line_number, + condition: bp.condition, + })); + this.breakpoints = await this.manager.setBreakpoints(inputs); + this.print(`Breakpoint #${id} deleted.`); + }); + + this.registerCommand('continue', 'c', async () => { + const threadId = this.requireThread(); + await this.manager.resume(threadId); + this.print(`Thread ${threadId} resumed.`); + }); + + this.registerCommand('step', 's', async () => { + const threadId = this.requireThread(); + await this.manager.stepOver(threadId); + this.print(`Thread ${threadId} stepping over...`); + }); + + this.registerCommand('stepin', 'si', async () => { + const threadId = this.requireThread(); + await this.manager.stepInto(threadId); + this.print(`Thread ${threadId} stepping into...`); + }); + + this.registerCommand('stepout', 'so', async () => { + const threadId = this.requireThread(); + await this.manager.stepOut(threadId); + this.print(`Thread ${threadId} stepping out...`); + }); + + this.registerCommand('stack', 'bt', async () => { + const threadId = this.requireThread(); + const thread = await this.manager.client.getThread(threadId); + for (const frame of thread.call_stack) { + const loc = frame.location; + const local = this.sourceMapper.toLocalPath(loc.script_path); + const marker = frame.index === this.state.currentFrameIndex ? '→' : ' '; + this.print( + ` ${marker} #${frame.index} ${loc.function_name || ''} ${CYAN}${local ?? loc.script_path}:${loc.line_number}${RESET}`, + ); + } + }); + + this.registerCommand('frame', 'f', async (args) => { + const idx = Number.parseInt(args, 10); + if (Number.isNaN(idx) || idx < 0) { + this.print('Usage: frame '); + return; + } + this.state.currentFrameIndex = idx; + this.print(`Switched to frame #${idx}`); + }); + + this.registerCommand('vars', 'v', async () => { + const threadId = this.requireThread(); + const result = await this.manager.client.getVariables(threadId, this.state.currentFrameIndex); + this.printVariables(result.object_members); + }); + + this.registerCommand('members', 'm', async (args) => { + if (!args) { + this.print('Usage: members '); + return; + } + const threadId = this.requireThread(); + const result = await this.manager.client.getMembers(threadId, this.state.currentFrameIndex, args); + this.printVariables(result.object_members); + }); + + this.registerCommand('eval', 'e', async (args) => { + if (!args) { + this.print('Usage: eval '); + return; + } + const threadId = this.requireThread(); + const result = await this.manager.client.evaluate(threadId, this.state.currentFrameIndex, args); + this.print(result.result); + }); + + this.registerCommand('threads', 't', async () => { + const threads = this.manager.getKnownThreads(); + if (threads.length === 0) { + this.print('No active threads.'); + return; + } + for (const thread of threads) { + const marker = thread.id === this.state.currentThreadId ? '→' : ' '; + const status = thread.status === 'halted' ? `${YELLOW}halted${RESET}` : 'running'; + const topFrame = thread.call_stack?.[0]; + const location = topFrame + ? ` at ${CYAN}${this.sourceMapper.toLocalPath(topFrame.location.script_path) ?? topFrame.location.script_path}:${topFrame.location.line_number}${RESET}` + : ''; + this.print(` ${marker} Thread ${thread.id} ${status}${location}`); + } + }); + + this.commands.set('thread', async (args) => { + const id = Number.parseInt(args, 10); + if (Number.isNaN(id)) { + this.print('Usage: thread '); + return; + } + this.state.currentThreadId = id; + this.state.currentFrameIndex = 0; + this.print(`Switched to thread ${id}`); + }); + + this.registerCommand('help', 'h', async () => { + this.print(`${BOLD}Available commands:${RESET}`); + this.print(` ${BOLD}break${RESET} (b) : [if ] Set breakpoint`); + this.print(` ${BOLD}breakpoints${RESET} (bl) List breakpoints`); + this.print(` ${BOLD}delete${RESET} (d) Delete breakpoint`); + this.print(` ${BOLD}continue${RESET} (c) Resume thread`); + this.print(` ${BOLD}step${RESET} (s) Step over`); + this.print(` ${BOLD}stepin${RESET} (si) Step into`); + this.print(` ${BOLD}stepout${RESET} (so) Step out`); + this.print(` ${BOLD}stack${RESET} (bt) Show call stack`); + this.print(` ${BOLD}frame${RESET} (f) Select frame`); + this.print(` ${BOLD}vars${RESET} (v) Show variables`); + this.print(` ${BOLD}members${RESET} (m) Expand object`); + this.print(` ${BOLD}eval${RESET} (e) Evaluate expression`); + this.print(` ${BOLD}threads${RESET} (t) List threads`); + this.print(` ${BOLD}thread${RESET} Switch to thread`); + this.print(` ${BOLD}quit${RESET} (q) Disconnect and exit`); + }); + + this.registerCommand('quit', 'q', async () => { + this.rl?.close(); + }); + } + + private requireThread(): number { + if (this.state.currentThreadId === undefined) { + throw new Error('No thread selected. Wait for a thread to halt at a breakpoint.'); + } + return this.state.currentThreadId; + } +} diff --git a/packages/b2c-cli/src/utils/debug/rpc.ts b/packages/b2c-cli/src/utils/debug/rpc.ts new file mode 100644 index 00000000..02eafbc3 --- /dev/null +++ b/packages/b2c-cli/src/utils/debug/rpc.ts @@ -0,0 +1,252 @@ +/* + * 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 readline from 'node:readline'; +import type { + BreakpointInput, + DebugSessionManager, + SdapiScriptThread, + SourceMapper, +} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import type {CartridgeMapping} from '@salesforce/b2c-tooling-sdk/operations/code'; +import { + projectBreakpoint, + projectFrame, + projectThreadLocation, + projectVariable, + resolveBreakpointPath, +} from '@salesforce/b2c-tooling-sdk/operations/debug'; + +interface RpcRequest { + id?: number | string; + command: string; + args?: Record; +} + +interface RpcResponse { + id?: number | string; + result?: unknown; + error?: string; +} + +interface RpcEvent { + event: string; + data: unknown; +} + +export interface DebugRpcOptions { + cartridges: CartridgeMapping[]; + input: NodeJS.ReadableStream; + manager: DebugSessionManager; + output: NodeJS.WritableStream; + sourceMapper: SourceMapper; +} + +export class DebugRpc { + private readonly cartridges: CartridgeMapping[]; + private currentFrameIndex = 0; + private currentThreadId?: number; + private readonly input: NodeJS.ReadableStream; + private readonly manager: DebugSessionManager; + private readonly output: NodeJS.WritableStream; + private resolveRun?: () => void; + private readonly sourceMapper: SourceMapper; + + constructor(options: DebugRpcOptions) { + this.manager = options.manager; + this.sourceMapper = options.sourceMapper; + this.cartridges = options.cartridges; + this.output = options.output; + this.input = options.input; + } + + onThreadStopped(thread: SdapiScriptThread): void { + this.currentThreadId = thread.id; + this.currentFrameIndex = 0; + + this.emitEvent('thread_stopped', { + thread_id: thread.id, + location: projectThreadLocation(thread, this.sourceMapper), + }); + } + + async run(): Promise { + const rl = readline.createInterface({input: this.input, terminal: false}); + + this.emitEvent('ready', {}); + + rl.on('line', (line: string) => { + const trimmed = line.trim(); + if (!trimmed) return; + this.handleMessage(trimmed).catch(() => {}); + }); + + rl.on('close', () => { + this.resolveRun?.(); + }); + + return new Promise((resolve) => { + this.resolveRun = resolve; + }); + } + + private emitEvent(event: string, data: unknown): void { + const msg: RpcEvent = {event, data}; + this.output.write(JSON.stringify(msg) + '\n'); + } + + private getThread(): number { + if (this.currentThreadId === undefined) { + throw new Error('No thread selected. Wait for a thread_stopped event.'); + } + return this.currentThreadId; + } + + private async handleCommand(command: string, args: Record): Promise { + switch (command) { + case 'continue': { + const threadId = this.resolveThreadId(args); + await this.manager.resume(threadId); + return {thread_id: threadId, status: 'resumed'}; + } + + case 'evaluate': { + const threadId = this.resolveThreadId(args); + const frameIndex = (args.frame_index as number) ?? this.currentFrameIndex; + const expression = args.expression as string; + if (!expression) throw new Error('Missing required arg: expression'); + const evalResult = await this.manager.client.evaluate(threadId, frameIndex, expression); + return {expression: evalResult.expression, result: evalResult.result}; + } + + case 'get_stack': { + const threadId = this.resolveThreadId(args); + const thread = await this.manager.client.getThread(threadId); + return { + thread_id: thread.id, + frames: thread.call_stack.map((frame) => projectFrame(frame, this.sourceMapper)), + }; + } + + case 'get_variables': { + const threadId = this.resolveThreadId(args); + const frameIndex = (args.frame_index as number) ?? this.currentFrameIndex; + const objectPath = args.object_path as string | undefined; + + if (objectPath) { + const result = await this.manager.client.getMembers(threadId, frameIndex, objectPath); + return {variables: result.object_members.map((m) => projectVariable(m, {includeScope: false}))}; + } + + const result = await this.manager.client.getVariables(threadId, frameIndex); + const members = args.scope + ? result.object_members.filter((m) => m.scope === args.scope) + : result.object_members; + return {variables: members.map((m) => projectVariable(m))}; + } + + case 'list_breakpoints': { + const bps = await this.manager.client.getBreakpoints(); + return {breakpoints: bps.map((bp) => projectBreakpoint(bp, this.sourceMapper))}; + } + + case 'list_threads': { + const threads = this.manager.getKnownThreads(); + return { + threads: threads.map((t) => ({ + thread_id: t.id, + status: t.status, + current: t.id === this.currentThreadId, + location: projectThreadLocation(t, this.sourceMapper), + })), + }; + } + + case 'select_frame': { + const index = args.index as number; + if (index === undefined || index < 0) throw new Error('Missing required arg: index'); + this.currentFrameIndex = index; + return {frame_index: index}; + } + + case 'select_thread': { + const id = args.thread_id as number; + if (id === undefined) throw new Error('Missing required arg: thread_id'); + this.currentThreadId = id; + this.currentFrameIndex = 0; + return {thread_id: id}; + } + + case 'set_breakpoints': { + const breakpoints = args.breakpoints as Array<{file: string; line: number; condition?: string}>; + if (!breakpoints) throw new Error('Missing required arg: breakpoints'); + + const inputs: BreakpointInput[] = breakpoints.map((bp) => ({ + script_path: resolveBreakpointPath(bp.file, this.sourceMapper, this.cartridges), + line_number: bp.line, + condition: bp.condition, + })); + + const result = await this.manager.setBreakpoints(inputs); + return {breakpoints: result.map((bp) => projectBreakpoint(bp, this.sourceMapper))}; + } + + case 'step_into': { + const threadId = this.resolveThreadId(args); + await this.manager.stepInto(threadId); + return {thread_id: threadId, action: 'step_into'}; + } + + case 'step_out': { + const threadId = this.resolveThreadId(args); + await this.manager.stepOut(threadId); + return {thread_id: threadId, action: 'step_out'}; + } + + case 'step_over': { + const threadId = this.resolveThreadId(args); + await this.manager.stepOver(threadId); + return {thread_id: threadId, action: 'step_over'}; + } + + default: { + throw new Error(`Unknown command: ${command}`); + } + } + } + + private async handleMessage(line: string): Promise { + let request: RpcRequest; + try { + request = JSON.parse(line) as RpcRequest; + } catch { + this.sendResponse({error: 'Invalid JSON'}); + return; + } + + if (!request.command) { + this.sendResponse({id: request.id, error: 'Missing "command" field'}); + return; + } + + try { + const result = await this.handleCommand(request.command, request.args ?? {}); + this.sendResponse({id: request.id, result}); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.sendResponse({id: request.id, error: message}); + } + } + + /** Resolve a thread_id arg, falling back to the currently selected thread. */ + private resolveThreadId(args: Record): number { + return (args.thread_id as number) ?? this.getThread(); + } + + private sendResponse(response: RpcResponse): void { + this.output.write(JSON.stringify(response) + '\n'); + } +} diff --git a/packages/b2c-dx-mcp/src/commands/mcp.ts b/packages/b2c-dx-mcp/src/commands/mcp.ts index 990b17e5..170ee2d4 100644 --- a/packages/b2c-dx-mcp/src/commands/mcp.ts +++ b/packages/b2c-dx-mcp/src/commands/mcp.ts @@ -148,6 +148,7 @@ import type {ResolvedB2CConfig} from '@salesforce/b2c-tooling-sdk/config'; import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js'; import {B2CDxMcpServer} from '../server.js'; import {Services} from '../services.js'; +import {ServerContext} from '../server-context.js'; import {registerToolsets} from '../registry.js'; import {TOOLSETS, type StartupFlags} from '../utils/index.js'; @@ -219,6 +220,9 @@ export default class McpServerCommand extends BaseCommand { const sendStopAndResolve = (signal: string): void => { this.shutdownSignal = signal; - this.telemetry?.sendEvent('SERVER_STOPPED', {signal}); - // Flush telemetry before resolving to ensure SERVER_STOPPED is sent - // before finally() proceeds to stop telemetry - const flushPromise = this.telemetry?.flush() ?? Promise.resolve(); - flushPromise.then(() => resolve()).catch(() => resolve()); + const cleanup = this.serverContext?.destroyAll() ?? Promise.resolve(); + cleanup + .catch(() => {}) + .then(() => { + this.telemetry?.sendEvent('SERVER_STOPPED', {signal}); + const flushPromise = this.telemetry?.flush() ?? Promise.resolve(); + return flushPromise; + }) + .then(() => resolve()) + .catch(() => resolve()); }; // Handle stdin close (MCP client disconnects normally) diff --git a/packages/b2c-dx-mcp/src/registry.ts b/packages/b2c-dx-mcp/src/registry.ts index 737099cb..3d7147f0 100644 --- a/packages/b2c-dx-mcp/src/registry.ts +++ b/packages/b2c-dx-mcp/src/registry.ts @@ -10,7 +10,9 @@ import type {McpTool, Toolset, StartupFlags} from './utils/index.js'; import {ALL_TOOLSETS, TOOLSETS, VALID_TOOLSET_NAMES} from './utils/index.js'; import type {B2CDxMcpServer} from './server.js'; import type {Services} from './services.js'; +import type {ServerContext} from './server-context.js'; import {createCartridgesTools} from './tools/cartridges/index.js'; +import {createDiagnosticsTools} from './tools/diagnostics/index.js'; import {createMrtTools} from './tools/mrt/index.js'; import {createPwav3Tools} from './tools/pwav3/index.js'; import {createScapiTools} from './tools/scapi/index.js'; @@ -79,7 +81,10 @@ export type ToolRegistry = Record; * @param loadServices - Function that loads configuration and returns Services instance * @returns Complete tool registry */ -export function createToolRegistry(loadServices: () => Promise | Services): ToolRegistry { +export function createToolRegistry( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): ToolRegistry { const registry: ToolRegistry = { CARTRIDGES: [], MRT: [], @@ -91,6 +96,7 @@ export function createToolRegistry(loadServices: () => Promise | Servi // Collect all tools from all factories const allTools: McpTool[] = [ ...createCartridgesTools(loadServices), + ...createDiagnosticsTools(loadServices, serverContext), ...createMrtTools(loadServices), ...createPwav3Tools(loadServices), ...createScapiTools(loadServices), @@ -173,6 +179,7 @@ export async function registerToolsets( flags: StartupFlags, server: B2CDxMcpServer, loadServices: () => Promise | Services, + serverContext?: ServerContext, ): Promise { const toolsets = flags.toolsets ?? []; const individualTools = flags.tools ?? []; @@ -180,7 +187,7 @@ export async function registerToolsets( const logger = getLogger(); // Create the tool registry (all available tools) - const toolRegistry = createToolRegistry(loadServices); + const toolRegistry = createToolRegistry(loadServices, serverContext); // Build flat list of all tools for lookup const allTools = Object.values(toolRegistry).flat(); diff --git a/packages/b2c-dx-mcp/src/server-context.ts b/packages/b2c-dx-mcp/src/server-context.ts new file mode 100644 index 00000000..f62e8ce3 --- /dev/null +++ b/packages/b2c-dx-mcp/src/server-context.ts @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {DebugSessionRegistry} from './tools/diagnostics/session-registry.js'; + +/** + * Server-scoped persistent state that lives for the lifetime of the MCP + * server process. Holds registries for stateful resources (debug sessions, + * future log watches) that need to span multiple tool invocations. + * + * ## Multi-agent / shared-state caveats + * + * One `ServerContext` is created per MCP server process. With the default + * stdio transport, each MCP client connection spawns its own server + * subprocess, so this state is naturally isolated per client/agent. + * + * If this server is ever wired up to a shared transport (e.g. HTTP with + * multiple connected clients), `ServerContext` state would be shared + * across all connected clients. Sub-agents that run within the same MCP + * client would also share the same context. Tools that mutate registries + * (like the debug tools) should not assume single-tenant access. + * + * The debug registry already handles concurrent sessions via session IDs + * and host:client_id pairs, so multi-agent use is functional but agents + * may see each other's sessions via `debug_list_sessions`. + */ +export class ServerContext { + readonly debugSessions: DebugSessionRegistry; + + constructor() { + this.debugSessions = new DebugSessionRegistry(); + } + + async destroyAll(): Promise { + await this.debugSessions.destroyAll(); + } +} diff --git a/packages/b2c-dx-mcp/src/services.ts b/packages/b2c-dx-mcp/src/services.ts index b659e51b..1815e1b2 100644 --- a/packages/b2c-dx-mcp/src/services.ts +++ b/packages/b2c-dx-mcp/src/services.ts @@ -175,6 +175,16 @@ export class Services { return fs.existsSync(targetPath); } + /** + * Get Basic auth credentials for SDAPI operations (script debugger). + * Returns undefined if credentials are not configured. + */ + public getBasicAuthCredentials(): undefined | {hostname: string; username: string; password: string} { + const {hostname, username, password} = this.resolvedConfig.values; + if (!hostname || !username || !password) return undefined; + return {hostname, username, password}; + } + /** * Get Custom APIs client for managing custom SCAPI endpoints. * Requires shortCode, tenantId, and OAuth credentials to be configured. diff --git a/packages/b2c-dx-mcp/src/tools/adapter.ts b/packages/b2c-dx-mcp/src/tools/adapter.ts index a3d50c7d..7daded4a 100644 --- a/packages/b2c-dx-mcp/src/tools/adapter.ts +++ b/packages/b2c-dx-mcp/src/tools/adapter.ts @@ -75,6 +75,7 @@ import {z, type ZodRawShape, type ZodObject, type ZodType} from 'zod'; import type {B2CInstance} from '@salesforce/b2c-tooling-sdk'; import type {McpTool, ToolResult, Toolset} from '../utils/index.js'; import type {Services, MrtConfig} from '../services.js'; +import type {ServerContext} from '../server-context.js'; /** * Context provided to tool execute functions. @@ -99,6 +100,12 @@ export interface ToolExecutionContext { * Services instance for file system access and other utilities. */ services: Services; + + /** + * Server-scoped persistent state (debug sessions, log watches, etc.). + * Created once at server startup and shared across all tool invocations. + */ + serverContext?: ServerContext; } /** @@ -255,6 +262,7 @@ function formatZodErrors(error: z.ZodError): string { export function createToolAdapter( options: ToolAdapterOptions, loadServices: () => Promise | Services, + serverContext?: ServerContext, ): McpTool { const { name, @@ -322,6 +330,7 @@ export function createToolAdapter( b2cInstance, mrtConfig, services, + serverContext, }; const output = await execute(args, context); diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts new file mode 100644 index 00000000..9acf6fe5 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts @@ -0,0 +1,180 @@ +/* + * 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 {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; +import { + projectFrame, + projectVariable, + resolveBreakpointPath, + type BreakpointInput, + type MappedFrame, + type MappedVariable, +} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {getRegistry, getSessionEntry} from './session-registry.js'; + +const DEFAULT_TIMEOUT_MS = 30_000; +const MAX_TIMEOUT_MS = 120_000; + +interface CaptureInput { + session_id: string; + file: string; + line: number; + condition?: string; + expressions?: string[]; + timeout_ms?: number; + auto_continue?: boolean; + trigger_url?: string; +} + +interface CaptureOutput { + breakpoint: { + file: null | string; + line: number; + script_path: string; + }; + halted: boolean; + timed_out?: boolean; + thread_id?: number; + stack?: MappedFrame[]; + variables?: MappedVariable[]; + evaluations?: Array<{expression: string; result: string}>; + auto_continued: boolean; + trigger_status?: number; +} + +export function createDebugCaptureAtBreakpointTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: 'debug_capture_at_breakpoint', + description: + 'Set a breakpoint, optionally trigger an HTTP request, wait for the breakpoint to be hit, and capture a diagnostic snapshot (stack, variables, expression results). ' + + 'Use trigger_url to have the tool fire the request itself (recommended) — this avoids needing to coordinate a separate request while the tool blocks. ' + + 'Without trigger_url, the tool BLOCKS until the breakpoint is hit or timeout expires and requires the user to trigger a request externally. ' + + 'For more control, use the non-blocking workflow: debug_set_breakpoints → trigger request → debug_list_sessions (check halted_threads) → debug_get_variables.', + toolsets: ['CARTRIDGES', 'SCAPI'], + inputSchema: { + session_id: z.string().describe('Session ID returned by debug_start_session.'), + file: z.string().describe('Local file path or server script path for the breakpoint.'), + line: z.number().int().positive().describe('Line number for the breakpoint.'), + condition: z.string().optional().describe('Optional conditional expression for the breakpoint.'), + expressions: z.array(z.string()).optional().describe('Expressions to evaluate when the breakpoint is hit.'), + timeout_ms: z + .number() + .int() + .positive() + .max(MAX_TIMEOUT_MS) + .optional() + .describe( + `Timeout in milliseconds waiting for the breakpoint to be hit (default: ${DEFAULT_TIMEOUT_MS}, max: ${MAX_TIMEOUT_MS}).`, + ), + auto_continue: z + .boolean() + .optional() + .describe('If true, resume the thread after capturing the snapshot. Defaults to false.'), + trigger_url: z + .string() + .optional() + .describe( + 'URL to request after arming the breakpoint. The tool fires this HTTP GET in the background, then waits for the breakpoint to halt. ' + + 'This is the recommended approach — it avoids needing to coordinate a separate request while the tool blocks.', + ), + }, + async execute(args, context) { + const entry = getSessionEntry(context, args.session_id); + const registry = getRegistry(context); + const timeout = Math.min(args.timeout_ms ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS); + + const scriptPath = resolveBreakpointPath(args.file, entry.sourceMapper, entry.cartridges); + + // Add breakpoint to existing set + const allBps: BreakpointInput[] = [ + ...entry.breakpoints.map((bp) => ({ + script_path: bp.script_path, + line_number: bp.line_number, + condition: bp.condition, + })), + {script_path: scriptPath, line_number: args.line, condition: args.condition}, + ]; + entry.breakpoints = await entry.manager.setBreakpoints(allBps); + + const breakpointInfo = { + file: entry.sourceMapper.toLocalPath(scriptPath) ?? null, + line: args.line, + script_path: scriptPath, + }; + + // Fire trigger URL in the background (it will hang when the breakpoint halts the thread) + const triggerPromise = args.trigger_url + ? fetch(args.trigger_url, {redirect: 'follow'}) + .then((r) => r.status) + .catch((): undefined => undefined) + : undefined; + + const thread = await registry.waitForHalt(entry, timeout); + + if (!thread) { + return { + breakpoint: breakpointInfo, + halted: false, + timed_out: true, + auto_continued: false, + }; + } + + const threadDetail = await entry.manager.client.getThread(thread.id); + const stack = threadDetail.call_stack.map((frame) => projectFrame(frame, entry.sourceMapper)); + + const varsResult = await entry.manager.client.getVariables(thread.id, 0); + const variables = varsResult.object_members.map((m) => projectVariable(m)); + + const evaluations: Array<{expression: string; result: string}> = []; + if (args.expressions) { + for (const expr of args.expressions) { + try { + // eslint-disable-next-line no-await-in-loop -- sequential: each eval must complete before the next + const evalResult = await entry.manager.client.evaluate(thread.id, 0, expr); + evaluations.push({expression: evalResult.expression, result: evalResult.result}); + } catch (error) { + evaluations.push({ + expression: expr, + result: `Error: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + } + + let autoContinued = false; + if (args.auto_continue) { + await entry.manager.resume(thread.id); + autoContinued = true; + } + + const triggerStatus = triggerPromise ? await triggerPromise : undefined; + + return { + breakpoint: breakpointInfo, + halted: true, + thread_id: thread.id, + stack, + variables, + evaluations: evaluations.length > 0 ? evaluations : undefined, + auto_continued: autoContinued, + trigger_status: triggerStatus, + }; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-continue.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-continue.ts new file mode 100644 index 00000000..a863452a --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-continue.ts @@ -0,0 +1,49 @@ +/* + * 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 {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; +import {getSessionEntry} from './session-registry.js'; + +interface ContinueInput { + session_id: string; + thread_id: number; +} + +interface ContinueOutput { + thread_id: number; + status: string; +} + +export function createDebugContinueTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: 'debug_continue', + description: + 'Resume execution of a halted thread. ' + + 'The thread runs until the next breakpoint or completes the current request.', + toolsets: ['CARTRIDGES', 'SCAPI'], + inputSchema: { + session_id: z.string().describe('Session ID returned by debug_start_session.'), + thread_id: z.number().int().describe('Thread ID of the halted thread to resume.'), + }, + async execute(args, context) { + const entry = getSessionEntry(context, args.session_id); + await entry.manager.resume(args.thread_id); + return {thread_id: args.thread_id, status: 'resumed'}; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-end-session.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-end-session.ts new file mode 100644 index 00000000..45a78485 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-end-session.ts @@ -0,0 +1,62 @@ +/* + * 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 {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; +import {getRegistry, getSessionEntry} from './session-registry.js'; + +interface EndSessionInput { + session_id: string; + clear_breakpoints?: boolean; +} + +interface EndSessionOutput { + session_id: string; + status: string; +} + +export function createDebugEndSessionTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: 'debug_end_session', + description: + 'End a script debugger session and free its slot on the instance. ' + + 'IMPORTANT: Always call this when finished debugging — leaving sessions open can interfere with other debuggers and consumes a debugger client slot.', + toolsets: ['CARTRIDGES', 'SCAPI'], + inputSchema: { + session_id: z.string().describe('Session ID returned by debug_start_session.'), + clear_breakpoints: z + .boolean() + .optional() + .describe('If true, delete all breakpoints before disconnecting. Defaults to false.'), + }, + async execute(args, context) { + const entry = getSessionEntry(context, args.session_id); + + if (args.clear_breakpoints) { + try { + await entry.manager.client.deleteBreakpoints(); + } catch { + // Best-effort + } + } + + await getRegistry(context).destroySession(args.session_id); + + return {session_id: args.session_id, status: 'disconnected'}; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-evaluate.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-evaluate.ts new file mode 100644 index 00000000..e1f750e3 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-evaluate.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 {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; +import {getSessionEntry} from './session-registry.js'; + +interface EvaluateInput { + session_id: string; + thread_id: number; + frame_index?: number; + expression: string; +} + +interface EvaluateOutput { + expression: string; + result: string; +} + +export function createDebugEvaluateTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: 'debug_evaluate', + description: + 'Evaluate a JavaScript expression in the context of a halted thread and stack frame. ' + + 'WARNING: Expressions may have side effects (modify variables, call functions). Use with care.', + toolsets: ['CARTRIDGES', 'SCAPI'], + inputSchema: { + session_id: z.string().describe('Session ID returned by debug_start_session.'), + thread_id: z.number().int().describe('Thread ID from debug_wait_for_stop or debug_list_sessions.'), + frame_index: z.number().int().min(0).optional().describe('Stack frame index (0 = top frame). Defaults to 0.'), + expression: z.string().describe('JavaScript expression to evaluate in the frame context.'), + }, + async execute(args, context) { + const entry = getSessionEntry(context, args.session_id); + const frameIndex = args.frame_index ?? 0; + const result = await entry.manager.client.evaluate(args.thread_id, frameIndex, args.expression); + return {expression: result.expression, result: result.result}; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-stack.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-stack.ts new file mode 100644 index 00000000..5a161b12 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-stack.ts @@ -0,0 +1,53 @@ +/* + * 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 {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; +import {projectFrame, type MappedFrame} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {getSessionEntry} from './session-registry.js'; + +interface GetStackInput { + session_id: string; + thread_id: number; +} + +interface GetStackOutput { + thread_id: number; + frames: MappedFrame[]; +} + +export function createDebugGetStackTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: 'debug_get_stack', + description: + 'Get the call stack for a halted thread. ' + + 'Returns frames with mapped local file paths and server script paths.', + toolsets: ['CARTRIDGES', 'SCAPI'], + inputSchema: { + session_id: z.string().describe('Session ID returned by debug_start_session.'), + thread_id: z.number().int().describe('Thread ID from debug_wait_for_stop or debug_list_sessions.'), + }, + async execute(args, context) { + const entry = getSessionEntry(context, args.session_id); + const thread = await entry.manager.client.getThread(args.thread_id); + return { + thread_id: thread.id, + frames: thread.call_stack.map((frame) => projectFrame(frame, entry.sourceMapper)), + }; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-variables.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-variables.ts new file mode 100644 index 00000000..dbe90530 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-variables.ts @@ -0,0 +1,73 @@ +/* + * 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 {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; +import {projectVariable, type MappedVariable} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {getSessionEntry} from './session-registry.js'; + +interface GetVariablesInput { + session_id: string; + thread_id: number; + frame_index?: number; + scope?: string; + object_path?: string; +} + +interface GetVariablesOutput { + variables: MappedVariable[]; +} + +export function createDebugGetVariablesTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: 'debug_get_variables', + description: + 'Get variables for a stack frame in a halted thread. ' + + 'Defaults to top-frame locals. Use scope to filter (local/closure/global) or object_path to drill into nested objects.', + toolsets: ['CARTRIDGES', 'SCAPI'], + inputSchema: { + session_id: z.string().describe('Session ID returned by debug_start_session.'), + thread_id: z.number().int().describe('Thread ID from debug_wait_for_stop or debug_list_sessions.'), + frame_index: z.number().int().min(0).optional().describe('Stack frame index (0 = top frame). Defaults to 0.'), + scope: z + .enum(['local', 'closure', 'global']) + .optional() + .describe('Filter variables by scope. If omitted, returns all scopes.'), + object_path: z + .string() + .optional() + .describe( + 'Dot-delimited path to drill into an object (e.g. "request.httpParameters"). Returns child members.', + ), + }, + async execute(args, context) { + const entry = getSessionEntry(context, args.session_id); + const frameIndex = args.frame_index ?? 0; + + if (args.object_path) { + const result = await entry.manager.client.getMembers(args.thread_id, frameIndex, args.object_path); + return {variables: result.object_members.map((m) => projectVariable(m, {includeScope: false}))}; + } + + const result = await entry.manager.client.getVariables(args.thread_id, frameIndex); + const members = args.scope + ? result.object_members.filter((m) => m.scope === args.scope) + : result.object_members; + return {variables: members.map((m) => projectVariable(m))}; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts new file mode 100644 index 00000000..32ddfd3c --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts @@ -0,0 +1,61 @@ +/* + * 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 {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; +import {projectBreakpoint, type MappedBreakpoint} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {getRegistry} from './session-registry.js'; + +interface ListSessionsOutput { + sessions: Array<{ + session_id: string; + hostname: string; + client_id: string; + halted_threads: number[]; + breakpoints: MappedBreakpoint[]; + created_at: string; + last_activity_at: string; + }>; +} + +export function createDebugListSessionsTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter, ListSessionsOutput>( + { + name: 'debug_list_sessions', + description: + 'List active script debugger sessions with their breakpoints and any halted threads. ' + + 'Use this to discover orphaned sessions, check whether breakpoints are armed, and poll for halted threads in the non-blocking debug workflow.', + toolsets: ['CARTRIDGES', 'SCAPI'], + inputSchema: {}, + async execute(_args, context) { + const registry = getRegistry(context); + const entries = registry.listSessions(); + return { + sessions: entries.map((entry) => ({ + session_id: entry.sessionId, + hostname: entry.hostname, + client_id: entry.clientId, + halted_threads: entry.manager + .getKnownThreads() + .filter((t) => t.status === 'halted') + .map((t) => t.id), + breakpoints: entry.breakpoints.map((bp) => projectBreakpoint(bp, entry.sourceMapper)), + created_at: new Date(entry.createdAt).toISOString(), + last_activity_at: new Date(entry.lastActivityAt).toISOString(), + })), + }; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts new file mode 100644 index 00000000..38bbe23c --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts @@ -0,0 +1,96 @@ +/* + * 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 {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; +import { + projectBreakpoint, + resolveBreakpointPath, + type BreakpointInput, + type MappedBreakpoint, +} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {getSessionEntry} from './session-registry.js'; + +interface SetBreakpointsInput { + session_id: string; + breakpoints: Array<{file: string; line: number; condition?: string}>; +} + +interface BreakpointResult extends MappedBreakpoint { + verified: boolean; +} + +interface SetBreakpointsOutput { + breakpoints: BreakpointResult[]; + warnings?: string[]; +} + +export function createDebugSetBreakpointsTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: 'debug_set_breakpoints', + description: + 'Set breakpoints in a debug session. Replaces all previously set breakpoints. ' + + 'Accepts local file paths (mapped to server paths via cartridge discovery), cartridge-prefixed paths, or server paths starting with /. ' + + 'Check the "verified" field and "warnings" — unmapped paths are flagged.', + toolsets: ['CARTRIDGES', 'SCAPI'], + inputSchema: { + session_id: z.string().describe('Session ID returned by debug_start_session.'), + breakpoints: z + .array( + z.object({ + file: z + .string() + .describe( + 'Local file path or server script path (e.g. /app_storefront/cartridge/controllers/Cart.js).', + ), + line: z.number().int().positive().describe('Line number for the breakpoint.'), + condition: z + .string() + .optional() + .describe('Optional conditional expression. Breakpoint only triggers when this evaluates to true.'), + }), + ) + .describe('Array of breakpoints to set. Replaces all existing breakpoints.'), + }, + async execute(args, context) { + const entry = getSessionEntry(context, args.session_id); + const warnings: string[] = []; + + const bpInputs: BreakpointInput[] = args.breakpoints.map((bp) => { + const scriptPath = resolveBreakpointPath(bp.file, entry.sourceMapper, entry.cartridges); + if (!entry.sourceMapper.toLocalPath(scriptPath)) { + warnings.push( + `"${bp.file}" resolved to server path "${scriptPath}" but could not be mapped back to a local file. ` + + `Verify this path exists on the instance.`, + ); + } + return {script_path: scriptPath, line_number: bp.line, condition: bp.condition}; + }); + + const result = await entry.manager.setBreakpoints(bpInputs); + entry.breakpoints = result; + + return { + breakpoints: result.map((bp) => ({ + ...projectBreakpoint(bp, entry.sourceMapper), + verified: entry.sourceMapper.toLocalPath(bp.script_path) !== undefined, + })), + warnings: warnings.length > 0 ? warnings : undefined, + }; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts new file mode 100644 index 00000000..11b17fe7 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts @@ -0,0 +1,119 @@ +/* + * 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 {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; +import { + DebugSessionManager, + createSourceMapper, + type DebugSessionCallbacks, + type SdapiScriptThread, +} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {findCartridges} from '@salesforce/b2c-tooling-sdk/operations/code'; +import {getRegistry} from './session-registry.js'; + +interface StartSessionInput { + cartridge_directory?: string; + client_id?: string; +} + +interface StartSessionOutput { + session_id: string; + hostname: string; + cartridges: string[]; + cartridge_mappings: Record; + warnings: string[]; +} + +export function createDebugStartSessionTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: 'debug_start_session', + description: + 'Start a script debugger session on a B2C Commerce instance to debug SFRA controllers, custom API scripts, hooks, jobs, or any server-side script. ' + + 'Returns a session_id for use with other debug tools, plus discovered cartridge mappings. ' + + 'WARNING: Debug sessions halt remote request threads on the instance. Always call debug_end_session when finished. ' + + 'Requires Basic auth credentials (username/password) and the script debugger enabled in Business Manager.', + toolsets: ['CARTRIDGES', 'SCAPI'], + inputSchema: { + cartridge_directory: z + .string() + .optional() + .describe('Path to directory containing cartridges. Defaults to project directory.'), + client_id: z + .string() + .optional() + .describe( + 'Client ID for the debugger API. Defaults to "b2c-cli". Use a different ID to run concurrent sessions on the same host.', + ), + }, + async execute(args, context) { + const registry = getRegistry(context); + + const credentials = context.services.getBasicAuthCredentials(); + if (!credentials) { + throw new Error( + 'Basic auth credentials (hostname, username, password) are required for the script debugger. ' + + 'Set via SFCC_SERVER/SFCC_USERNAME/SFCC_PASSWORD env vars, or configure in dw.json.', + ); + } + + const {hostname, username, password} = credentials; + const clientId = args.client_id ?? 'b2c-cli'; + const cartridgeDir = context.services.resolveWithProjectDirectory(args.cartridge_directory); + const cartridges = findCartridges(cartridgeDir); + const warnings: string[] = []; + + if (cartridges.length === 0) { + warnings.push(`No cartridges found in ${cartridgeDir}. Breakpoints will use server paths only.`); + } + + const sourceMapper = createSourceMapper(cartridges); + + const callbacks: DebugSessionCallbacks = { + onThreadStopped(thread: SdapiScriptThread) { + const entry = registry.findByHostAndClientId(hostname, clientId); + if (!entry) return; + while (entry.haltWaiters.length > 0) { + const waiter = entry.haltWaiters.shift()!; + clearTimeout(waiter.timer); + waiter.resolve(thread); + } + }, + }; + + const manager = new DebugSessionManager( + {hostname, username, password, clientId, cartridgeRoots: cartridges}, + callbacks, + ); + + await manager.connect(); + + const entry = registry.registerSession({hostname, clientId, manager, sourceMapper, cartridges}); + + const cartridgeMappings: Record = {}; + for (const c of cartridges) cartridgeMappings[c.name] = c.src; + + return { + session_id: entry.sessionId, + hostname, + cartridges: cartridges.map((c) => c.name), + cartridge_mappings: cartridgeMappings, + warnings, + }; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-step.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-step.ts new file mode 100644 index 00000000..17f75403 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-step.ts @@ -0,0 +1,74 @@ +/* + * 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 {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import type {DebugSessionManager} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {createToolAdapter, jsonResult} from '../adapter.js'; +import {getSessionEntry} from './session-registry.js'; + +interface StepInput { + session_id: string; + thread_id: number; +} + +interface StepOutput { + thread_id: number; + action: string; +} + +type StepAction = 'step_into' | 'step_out' | 'step_over'; + +const STEP_HANDLERS: Record Promise> = { + step_into: (m, id) => m.stepInto(id), + step_out: (m, id) => m.stepOut(id), + step_over: (m, id) => m.stepOver(id), +}; + +function createStepTool( + action: StepAction, + description: string, + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: `debug_${action}`, + description: description + ' Follow with debug_wait_for_stop to see where execution landed.', + toolsets: ['CARTRIDGES', 'SCAPI'], + inputSchema: { + session_id: z.string().describe('Session ID returned by debug_start_session.'), + thread_id: z.number().int().describe('Thread ID of the halted thread to step.'), + }, + async execute(args, context) { + const entry = getSessionEntry(context, args.session_id); + await STEP_HANDLERS[action](entry.manager, args.thread_id); + return {thread_id: args.thread_id, action}; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} + +export function createDebugStepTools( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool[] { + return [ + createStepTool('step_over', 'Step to the next line in the current function.', loadServices, serverContext), + createStepTool('step_into', 'Step into the function call on the current line.', loadServices, serverContext), + createStepTool( + 'step_out', + 'Step out of the current function, returning to the caller.', + loadServices, + serverContext, + ), + ]; +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts new file mode 100644 index 00000000..f6ee4ee6 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; +import {projectThreadLocation, type MappedLocation} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {getRegistry, getSessionEntry} from './session-registry.js'; + +const DEFAULT_TIMEOUT_MS = 30_000; +const MAX_TIMEOUT_MS = 120_000; + +interface WaitForStopInput { + session_id: string; + timeout_ms?: number; +} + +interface WaitForStopOutput { + halted: boolean; + timed_out?: boolean; + thread_id?: number; + location?: MappedLocation; +} + +export function createDebugWaitForStopTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: 'debug_wait_for_stop', + description: + 'Wait for a thread to halt at a breakpoint or step. ' + + 'Returns immediately if a thread is already halted; otherwise BLOCKS until a halt occurs or the timeout expires. ' + + 'Preferred non-blocking alternative: after debug_set_breakpoints, trigger the request yourself, then use debug_list_sessions to check halted_threads before calling debug_get_stack/debug_get_variables.', + toolsets: ['CARTRIDGES', 'SCAPI'], + inputSchema: { + session_id: z.string().describe('Session ID returned by debug_start_session.'), + timeout_ms: z + .number() + .int() + .positive() + .max(MAX_TIMEOUT_MS) + .optional() + .describe(`Timeout in milliseconds (default: ${DEFAULT_TIMEOUT_MS}, max: ${MAX_TIMEOUT_MS}).`), + }, + async execute(args, context) { + const entry = getSessionEntry(context, args.session_id); + const timeout = Math.min(args.timeout_ms ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS); + + const thread = await getRegistry(context).waitForHalt(entry, timeout); + if (!thread) return {halted: false, timed_out: true}; + + const location = projectThreadLocation(thread, entry.sourceMapper); + return { + halted: true, + thread_id: thread.id, + location: location ?? undefined, + }; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/index.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/index.ts new file mode 100644 index 00000000..99a1834d --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/index.ts @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createDebugListSessionsTool} from './debug-list-sessions.js'; +import {createDebugStartSessionTool} from './debug-start-session.js'; +import {createDebugEndSessionTool} from './debug-end-session.js'; +import {createDebugSetBreakpointsTool} from './debug-set-breakpoints.js'; +import {createDebugWaitForStopTool} from './debug-wait-for-stop.js'; +import {createDebugGetStackTool} from './debug-get-stack.js'; +import {createDebugGetVariablesTool} from './debug-get-variables.js'; +import {createDebugEvaluateTool} from './debug-evaluate.js'; +import {createDebugContinueTool} from './debug-continue.js'; +import {createDebugStepTools} from './debug-step.js'; +import {createDebugCaptureAtBreakpointTool} from './debug-capture-at-breakpoint.js'; + +export function createDiagnosticsTools( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool[] { + return [ + createDebugListSessionsTool(loadServices, serverContext), + createDebugStartSessionTool(loadServices, serverContext), + createDebugEndSessionTool(loadServices, serverContext), + createDebugSetBreakpointsTool(loadServices, serverContext), + createDebugWaitForStopTool(loadServices, serverContext), + createDebugGetStackTool(loadServices, serverContext), + createDebugGetVariablesTool(loadServices, serverContext), + createDebugEvaluateTool(loadServices, serverContext), + createDebugContinueTool(loadServices, serverContext), + ...createDebugStepTools(loadServices, serverContext), + createDebugCaptureAtBreakpointTool(loadServices, serverContext), + ]; +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/session-registry.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/session-registry.ts new file mode 100644 index 00000000..a5c0b476 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/session-registry.ts @@ -0,0 +1,199 @@ +/* + * 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 {getLogger} from '@salesforce/b2c-tooling-sdk/logging'; +import type { + DebugSessionManager, + SourceMapper, + SdapiBreakpoint, + SdapiScriptThread, +} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import type {CartridgeMapping} from '@salesforce/b2c-tooling-sdk/operations/code'; +import type {ToolExecutionContext} from '../adapter.js'; + +const IDLE_TTL_MS = 30 * 60 * 1000; // 30 minutes +const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + +export interface HaltWaiter { + resolve: (thread: SdapiScriptThread) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +export interface DebugSessionEntry { + sessionId: string; + hostname: string; + clientId: string; + manager: DebugSessionManager; + sourceMapper: SourceMapper; + cartridges: CartridgeMapping[]; + breakpoints: SdapiBreakpoint[]; + haltWaiters: HaltWaiter[]; + createdAt: number; + lastActivityAt: number; +} + +export interface RegisterSessionOptions { + hostname: string; + clientId: string; + manager: DebugSessionManager; + sourceMapper: SourceMapper; + cartridges: CartridgeMapping[]; +} + +export class DebugSessionRegistry { + private cleanupTimer: ReturnType | undefined; + private readonly sessions = new Map(); + + constructor() { + this.cleanupTimer = setInterval(() => { + this.cleanupIdleSessions().catch(() => {}); + }, CLEANUP_INTERVAL_MS); + this.cleanupTimer.unref(); + } + + async destroyAll(): Promise { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = undefined; + } + + const destroyPromises = [...this.sessions.keys()].map((id) => this.destroySession(id)); + await Promise.allSettled(destroyPromises); + } + + async destroySession(sessionId: string): Promise { + const entry = this.sessions.get(sessionId); + if (!entry) return; + + for (const waiter of entry.haltWaiters) { + clearTimeout(waiter.timer); + waiter.reject(new Error('Debug session ended')); + } + entry.haltWaiters.length = 0; + + try { + await entry.manager.disconnect(); + } catch { + // Best-effort disconnect + } + + this.sessions.delete(sessionId); + } + + findByHostAndClientId(hostname: string, clientId: string): DebugSessionEntry | undefined { + for (const entry of this.sessions.values()) { + if (entry.hostname === hostname && entry.clientId === clientId) { + return entry; + } + } + return undefined; + } + + getSession(sessionId: string): DebugSessionEntry | undefined { + return this.sessions.get(sessionId); + } + + getSessionOrThrow(sessionId: string): DebugSessionEntry { + const entry = this.sessions.get(sessionId); + if (!entry) { + throw new Error(`No debug session found with id "${sessionId}". Use debug_list_sessions to see active sessions.`); + } + entry.lastActivityAt = Date.now(); + return entry; + } + + listSessions(): DebugSessionEntry[] { + return [...this.sessions.values()]; + } + + registerSession(opts: RegisterSessionOptions): DebugSessionEntry { + const {hostname, clientId, manager, sourceMapper, cartridges} = opts; + const existing = this.findByHostAndClientId(hostname, clientId); + if (existing) { + throw new Error( + `A debug session already exists for ${hostname} with client ID "${clientId}" ` + + `(session_id: "${existing.sessionId}"). ` + + `End it with debug_end_session first, or use a different client_id.`, + ); + } + + const sessionId = randomUUID(); + const now = Date.now(); + const entry: DebugSessionEntry = { + sessionId, + hostname, + clientId, + manager, + sourceMapper, + cartridges, + breakpoints: [], + haltWaiters: [], + createdAt: now, + lastActivityAt: now, + }; + this.sessions.set(sessionId, entry); + return entry; + } + + /** + * Wait for any thread in the session to halt. + * + * If a thread is already halted in the session's known threads, returns it + * immediately. Otherwise registers a halt waiter that resolves when the + * `onThreadStopped` callback fires, or returns null on timeout. + */ + async waitForHalt(entry: DebugSessionEntry, timeoutMs: number): Promise { + const halted = entry.manager.getKnownThreads().find((t) => t.status === 'halted'); + if (halted) return halted; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = entry.haltWaiters.findIndex((w) => w.timer === timer); + if (idx !== -1) entry.haltWaiters.splice(idx, 1); + resolve(null); + }, timeoutMs); + entry.haltWaiters.push({resolve: (t) => resolve(t), reject, timer}); + }); + } + + private async cleanupIdleSessions(): Promise { + const logger = getLogger(); + const now = Date.now(); + + const idleSessions = [...this.sessions.entries()].filter(([, entry]) => now - entry.lastActivityAt > IDLE_TTL_MS); + + await Promise.allSettled( + idleSessions.map(([sessionId, entry]) => { + logger.info({sessionId, hostname: entry.hostname}, 'Cleaning up idle debug session'); + return this.destroySession(sessionId); + }), + ); + } +} + +/** + * Resolve the registry from the tool execution context, throwing a clear + * error if it's missing, then look up the session by ID. Used by every + * debug tool that takes a session_id. + */ +export function getSessionEntry(context: ToolExecutionContext, sessionId: string): DebugSessionEntry { + const registry = context.serverContext?.debugSessions; + if (!registry) { + throw new Error('Debug session registry not available'); + } + return registry.getSessionOrThrow(sessionId); +} + +/** Resolve the registry from the tool context (for tools that don't need a specific session). */ +export function getRegistry(context: ToolExecutionContext): DebugSessionRegistry { + const registry = context.serverContext?.debugSessions; + if (!registry) { + throw new Error('Debug session registry not available'); + } + return registry; +} diff --git a/packages/b2c-dx-mcp/src/tools/index.ts b/packages/b2c-dx-mcp/src/tools/index.ts index cdb14230..1d884d51 100644 --- a/packages/b2c-dx-mcp/src/tools/index.ts +++ b/packages/b2c-dx-mcp/src/tools/index.ts @@ -19,6 +19,7 @@ export * from './adapter.js'; // Toolset exports export * from './cartridges/index.js'; +export * from './diagnostics/index.js'; export * from './mrt/index.js'; export * from './pwav3/index.js'; export * from './scapi/index.js'; diff --git a/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts b/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts new file mode 100644 index 00000000..77114093 --- /dev/null +++ b/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts @@ -0,0 +1,1125 @@ +/* + * 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'; +import path from 'node:path'; +import os from 'node:os'; +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Services} from '../../../src/services.js'; +import {ServerContext} from '../../../src/server-context.js'; +import {createMockResolvedConfig} from '../../test-helpers.js'; +import {createDebugListSessionsTool} from '../../../src/tools/diagnostics/debug-list-sessions.js'; +import {createDebugEndSessionTool} from '../../../src/tools/diagnostics/debug-end-session.js'; +import {createDebugContinueTool} from '../../../src/tools/diagnostics/debug-continue.js'; +import {createDebugGetStackTool} from '../../../src/tools/diagnostics/debug-get-stack.js'; +import {createDebugEvaluateTool} from '../../../src/tools/diagnostics/debug-evaluate.js'; +import {createDebugGetVariablesTool} from '../../../src/tools/diagnostics/debug-get-variables.js'; +import {createDebugSetBreakpointsTool} from '../../../src/tools/diagnostics/debug-set-breakpoints.js'; +import {createDebugStepTools} from '../../../src/tools/diagnostics/debug-step.js'; +import {createDebugWaitForStopTool} from '../../../src/tools/diagnostics/debug-wait-for-stop.js'; +import {createDebugCaptureAtBreakpointTool} from '../../../src/tools/diagnostics/debug-capture-at-breakpoint.js'; +import {createDebugStartSessionTool} from '../../../src/tools/diagnostics/debug-start-session.js'; +import {DebugSessionManager} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import type {SourceMapper} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import type {ToolResult} from '../../../src/utils/index.js'; + +function getResultJson(result: ToolResult): T { + const text = result.content[0]; + if (text?.type !== 'text') throw new Error('Expected text content'); + return JSON.parse(text.text) as T; +} + +function getResultText(result: ToolResult): string { + const text = result.content[0]; + if (text?.type !== 'text') throw new Error('Expected text content'); + return text.text; +} + +type MockDebugSessionManager = InstanceType; + +function createMockManager(overrides?: Record): MockDebugSessionManager { + return { + client: { + getThread: sinon.stub().resolves({ + id: 1, + status: 'halted', + call_stack: [ + { + index: 0, + location: {function_name: 'show', line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}, + }, + ], + }), + getVariables: sinon.stub().resolves({ + object_members: [ + {name: 'x', type: 'number', value: '42', scope: 'local'}, + {name: 'obj', type: 'Object', value: '[object Object]', scope: 'local'}, + {name: 'g', type: 'string', value: 'hi', scope: 'global'}, + ], + count: 3, + start: 0, + total: 3, + _v: '2.0', + }), + getMembers: sinon.stub().resolves({ + object_members: [{name: 'foo', type: 'string', value: 'bar'}], + count: 1, + start: 0, + total: 1, + _v: '2.0', + }), + evaluate: sinon.stub().resolves({_v: '2.0', expression: 'x', result: '42'}), + deleteBreakpoints: sinon.stub().resolves(), + }, + connect: sinon.stub().resolves(), + disconnect: sinon.stub().resolves(), + setBreakpoints: sinon.stub().resolves([]), + resume: sinon.stub().resolves(), + stepOver: sinon.stub().resolves(), + stepInto: sinon.stub().resolves(), + stepOut: sinon.stub().resolves(), + getKnownThreads: sinon.stub().returns([]), + ...overrides, + } as unknown as MockDebugSessionManager; +} + +function createMockSourceMapper(): SourceMapper { + return { + toServerPath: sinon.stub().callsFake((p: string) => { + if (p.includes('/app_test/')) { + const idx = p.indexOf('/app_test/'); + return p.slice(idx); + } + return undefined; + }), + toLocalPath: sinon.stub().callsFake((p: string) => (p.startsWith('/app_test') ? `/local${p}` : undefined)), + }; +} + +function createServices(): Services { + return new Services({ + resolvedConfig: createMockResolvedConfig({hostname: 'test.example.com', username: 'user', password: 'pass'}), + }); +} + +describe('tools/diagnostics', () => { + let serverContext: ServerContext; + let loadServices: () => Services; + + beforeEach(() => { + serverContext = new ServerContext(); + loadServices = () => createServices(); + }); + + afterEach(async () => { + await serverContext.destroyAll(); + }); + + describe('debug_list_sessions', () => { + it('should have correct metadata', () => { + const tool = createDebugListSessionsTool(loadServices, serverContext); + expect(tool.name).to.equal('debug_list_sessions'); + expect(tool.toolsets).to.include('CARTRIDGES'); + expect(tool.toolsets).to.include('SCAPI'); + }); + + it('should return empty sessions array when none exist', async () => { + const tool = createDebugListSessionsTool(loadServices, serverContext); + const result = await tool.handler({}); + const json = getResultJson<{sessions: unknown[]}>(result); + expect(json.sessions).to.deep.equal([]); + }); + + it('should list sessions with breakpoints and halted threads', async () => { + const manager = createMockManager({ + getKnownThreads: sinon.stub().returns([{id: 5, status: 'halted', call_stack: []}]), + }); + const sourceMapper = createMockSourceMapper(); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host.example.com', + clientId: 'c1', + manager, + sourceMapper, + cartridges: [], + }); + entry.breakpoints = [{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]; + + const tool = createDebugListSessionsTool(loadServices, serverContext); + const result = await tool.handler({}); + const json = getResultJson<{ + sessions: Array<{session_id: string; halted_threads: number[]; breakpoints: unknown[]}>; + }>(result); + + expect(json.sessions).to.have.lengthOf(1); + expect(json.sessions[0].session_id).to.equal(entry.sessionId); + expect(json.sessions[0].halted_threads).to.deep.equal([5]); + expect(json.sessions[0].breakpoints).to.have.lengthOf(1); + }); + + it('should error when server context is missing', async () => { + const tool = createDebugListSessionsTool(loadServices, undefined); + const result = await tool.handler({}); + expect(result.isError).to.be.true; + expect(getResultText(result)).to.include('Debug session registry not available'); + }); + }); + + describe('debug_end_session', () => { + it('should disconnect and remove the session', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugEndSessionTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId}); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{status: string}>(result); + expect(json.status).to.equal('disconnected'); + expect(serverContext.debugSessions.getSession(entry.sessionId)).to.be.undefined; + expect((manager.disconnect as sinon.SinonStub).calledOnce).to.be.true; + }); + + it('should clear breakpoints when requested', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugEndSessionTool(loadServices, serverContext); + await tool.handler({session_id: entry.sessionId, clear_breakpoints: true}); + + expect((manager.client.deleteBreakpoints as sinon.SinonStub).calledOnce).to.be.true; + }); + + it('should handle deleteBreakpoints failure silently', async () => { + const manager = createMockManager(); + (manager.client.deleteBreakpoints as sinon.SinonStub).rejects(new Error('SDAPI down')); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugEndSessionTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId, clear_breakpoints: true}); + + expect(result.isError).to.be.undefined; + }); + + it('should return error for unknown session', async () => { + const tool = createDebugEndSessionTool(loadServices, serverContext); + const result = await tool.handler({session_id: 'nonexistent'}); + + expect(result.isError).to.be.true; + expect(getResultText(result)).to.include('No debug session found'); + }); + + it('should error when server context is missing', async () => { + const tool = createDebugEndSessionTool(loadServices, undefined); + const result = await tool.handler({session_id: 'anything'}); + expect(result.isError).to.be.true; + }); + }); + + describe('debug_continue', () => { + it('should resume the specified thread', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugContinueTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId, thread_id: 5}); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{thread_id: number; status: string}>(result); + expect(json.thread_id).to.equal(5); + expect(json.status).to.equal('resumed'); + expect((manager.resume as sinon.SinonStub).calledWith(5)).to.be.true; + }); + + it('should error when server context is missing', async () => { + const tool = createDebugContinueTool(loadServices, undefined); + const result = await tool.handler({session_id: 'x', thread_id: 1}); + expect(result.isError).to.be.true; + }); + }); + + describe('debug_get_stack', () => { + it('should return mapped stack frames', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugGetStackTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId, thread_id: 1}); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{frames: Array<{function_name: string; file: string; line: number}>}>(result); + expect(json.frames).to.have.lengthOf(1); + expect(json.frames[0].function_name).to.equal('show'); + expect(json.frames[0].line).to.equal(42); + expect(json.frames[0].file).to.equal('/local/app_test/cartridge/controllers/Cart.js'); + }); + + it('should error when server context is missing', async () => { + const tool = createDebugGetStackTool(loadServices, undefined); + const result = await tool.handler({session_id: 'x', thread_id: 1}); + expect(result.isError).to.be.true; + }); + }); + + describe('debug_evaluate', () => { + it('should evaluate expression and return result', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugEvaluateTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId, thread_id: 1, expression: 'x'}); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{expression: string; result: string}>(result); + expect(json.expression).to.equal('x'); + expect(json.result).to.equal('42'); + }); + + it('should use frame_index 0 by default', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugEvaluateTool(loadServices, serverContext); + await tool.handler({session_id: entry.sessionId, thread_id: 1, expression: 'x'}); + + expect((manager.client.evaluate as sinon.SinonStub).calledWith(1, 0, 'x')).to.be.true; + }); + + it('should use specified frame_index', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugEvaluateTool(loadServices, serverContext); + await tool.handler({session_id: entry.sessionId, thread_id: 1, frame_index: 2, expression: 'y'}); + + expect((manager.client.evaluate as sinon.SinonStub).calledWith(1, 2, 'y')).to.be.true; + }); + + it('should error when server context is missing', async () => { + const tool = createDebugEvaluateTool(loadServices, undefined); + const result = await tool.handler({session_id: 'x', thread_id: 1, expression: 'y'}); + expect(result.isError).to.be.true; + }); + }); + + describe('debug_get_variables', () => { + it('should return variables with has_children flag based on type', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugGetVariablesTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId, thread_id: 1}); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{ + variables: Array<{name: string; type: string; has_children: boolean; scope?: string}>; + }>(result); + expect(json.variables).to.have.lengthOf(3); + const x = json.variables.find((v) => v.name === 'x')!; + expect(x.has_children).to.be.false; + const obj = json.variables.find((v) => v.name === 'obj')!; + expect(obj.has_children).to.be.true; + }); + + it('should filter by scope', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugGetVariablesTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId, thread_id: 1, scope: 'global'}); + + const json = getResultJson<{variables: Array<{name: string}>}>(result); + expect(json.variables).to.have.lengthOf(1); + expect(json.variables[0].name).to.equal('g'); + }); + + it('should use getMembers when object_path is provided', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugGetVariablesTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId, thread_id: 1, object_path: 'obj'}); + + expect((manager.client.getMembers as sinon.SinonStub).calledOnce).to.be.true; + const json = getResultJson<{variables: Array<{name: string}>}>(result); + expect(json.variables).to.have.lengthOf(1); + expect(json.variables[0].name).to.equal('foo'); + }); + + it('should truncate long values', async () => { + const longValue = 'x'.repeat(300); + const manager = createMockManager({ + client: { + getVariables: sinon.stub().resolves({ + object_members: [{name: 'big', type: 'string', value: longValue, scope: 'local'}], + count: 1, + start: 0, + total: 1, + _v: '2.0', + }), + getMembers: sinon.stub(), + evaluate: sinon.stub(), + getThread: sinon.stub(), + deleteBreakpoints: sinon.stub(), + }, + }); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugGetVariablesTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId, thread_id: 1}); + + const json = getResultJson<{variables: Array<{value: string}>}>(result); + expect(json.variables[0].value).to.have.lengthOf(203); // 200 + '...' + expect(json.variables[0].value.endsWith('...')).to.be.true; + }); + + it('should error when server context is missing', async () => { + const tool = createDebugGetVariablesTool(loadServices, undefined); + const result = await tool.handler({session_id: 'x', thread_id: 1}); + expect(result.isError).to.be.true; + }); + }); + + describe('debug_set_breakpoints', () => { + it('should set breakpoints and map paths', async () => { + const manager = createMockManager({ + setBreakpoints: sinon + .stub() + .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), + }); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugSetBreakpointsTool(loadServices, serverContext); + const result = await tool.handler({ + session_id: entry.sessionId, + breakpoints: [{file: '/app_test/cartridge/controllers/Cart.js', line: 42}], + }); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{breakpoints: Array<{id: number; verified: boolean; file: string}>}>(result); + expect(json.breakpoints).to.have.lengthOf(1); + expect(json.breakpoints[0].verified).to.be.true; + }); + + it('should warn when path cannot be round-trip mapped', async () => { + const manager = createMockManager({ + setBreakpoints: sinon.stub().resolves([{id: 1, line_number: 10, script_path: '/unknown/cartridge/foo.js'}]), + }); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugSetBreakpointsTool(loadServices, serverContext); + const result = await tool.handler({ + session_id: entry.sessionId, + breakpoints: [{file: '/unknown/cartridge/foo.js', line: 10}], + }); + + const json = getResultJson<{breakpoints: Array<{verified: boolean}>; warnings?: string[]}>(result); + expect(json.breakpoints[0].verified).to.be.false; + expect(json.warnings).to.exist; + expect(json.warnings![0]).to.include('could not be mapped back to a local file'); + }); + + it('should support breakpoint conditions', async () => { + const setBreakpointsStub = sinon + .stub() + .resolves([ + {id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js', condition: 'x > 5'}, + ]); + const manager = createMockManager({setBreakpoints: setBreakpointsStub}); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugSetBreakpointsTool(loadServices, serverContext); + await tool.handler({ + session_id: entry.sessionId, + breakpoints: [{file: '/app_test/cartridge/controllers/Cart.js', line: 42, condition: 'x > 5'}], + }); + + const [bpInputs] = setBreakpointsStub.firstCall.args; + expect(bpInputs[0].condition).to.equal('x > 5'); + }); + + it('should error when server context is missing', async () => { + const tool = createDebugSetBreakpointsTool(loadServices, undefined); + const result = await tool.handler({session_id: 'x', breakpoints: [{file: '/a/b.js', line: 1}]}); + expect(result.isError).to.be.true; + }); + }); + + describe('debug_step_* tools', () => { + it('should create three step tools', () => { + const tools = createDebugStepTools(loadServices, serverContext); + expect(tools).to.have.lengthOf(3); + expect(tools.map((t) => t.name)).to.deep.equal(['debug_step_over', 'debug_step_into', 'debug_step_out']); + }); + + it('step_over should call manager.stepOver', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + const [stepOver] = createDebugStepTools(loadServices, serverContext); + + const result = await stepOver.handler({session_id: entry.sessionId, thread_id: 3}); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{action: string}>(result); + expect(json.action).to.equal('step_over'); + expect((manager.stepOver as sinon.SinonStub).calledWith(3)).to.be.true; + }); + + it('step_into should call manager.stepInto', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + const [, stepInto] = createDebugStepTools(loadServices, serverContext); + + await stepInto.handler({session_id: entry.sessionId, thread_id: 3}); + + expect((manager.stepInto as sinon.SinonStub).calledWith(3)).to.be.true; + }); + + it('step_out should call manager.stepOut', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + const stepOut = createDebugStepTools(loadServices, serverContext)[2]; + + await stepOut.handler({session_id: entry.sessionId, thread_id: 3}); + + expect((manager.stepOut as sinon.SinonStub).calledWith(3)).to.be.true; + }); + + it('should error when server context is missing', async () => { + const [stepOver] = createDebugStepTools(loadServices, undefined); + const result = await stepOver.handler({session_id: 'x', thread_id: 1}); + expect(result.isError).to.be.true; + }); + }); + + describe('debug_wait_for_stop', () => { + it('should return immediately if a thread is already halted', async () => { + const haltedThread = { + id: 5, + status: 'halted', + call_stack: [ + { + index: 0, + location: {function_name: 'show', line_number: 10, script_path: '/app_test/cartridge/controllers/Cart.js'}, + }, + ], + }; + const manager = createMockManager({getKnownThreads: sinon.stub().returns([haltedThread])}); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugWaitForStopTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId}); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{halted: boolean; thread_id: number; location: {line: number}}>(result); + expect(json.halted).to.be.true; + expect(json.thread_id).to.equal(5); + expect(json.location.line).to.equal(10); + }); + + it('should handle halted thread with no call stack', async () => { + const manager = createMockManager({ + getKnownThreads: sinon.stub().returns([{id: 5, status: 'halted', call_stack: []}]), + }); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugWaitForStopTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId}); + + const json = getResultJson<{halted: boolean; location?: unknown}>(result); + expect(json.halted).to.be.true; + expect(json.location).to.be.undefined; + }); + + it('should time out when no halt occurs', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugWaitForStopTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId, timeout_ms: 50}); + + const json = getResultJson<{halted: boolean; timed_out?: boolean}>(result); + expect(json.halted).to.be.false; + expect(json.timed_out).to.be.true; + }); + + it('should resolve when onThreadStopped callback fires', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugWaitForStopTool(loadServices, serverContext); + const promise = tool.handler({session_id: entry.sessionId, timeout_ms: 5000}); + + // Simulate thread halt via the waiter mechanism + setTimeout(() => { + const waiter = entry.haltWaiters.shift()!; + clearTimeout(waiter.timer); + waiter.resolve({ + id: 7, + status: 'halted', + call_stack: [ + { + index: 0, + location: {function_name: 'foo', line_number: 5, script_path: '/app_test/cartridge/foo.js'}, + }, + ], + }); + }, 10); + + const result = await promise; + const json = getResultJson<{halted: boolean; thread_id: number}>(result); + expect(json.halted).to.be.true; + expect(json.thread_id).to.equal(7); + }); + + it('should error when server context is missing', async () => { + const tool = createDebugWaitForStopTool(loadServices, undefined); + const result = await tool.handler({session_id: 'x'}); + expect(result.isError).to.be.true; + }); + }); + + describe('debug_capture_at_breakpoint', () => { + it('should set breakpoint, wait, capture, and optionally continue', async () => { + const haltedThread = { + id: 5, + status: 'halted', + call_stack: [ + { + index: 0, + location: {function_name: 'show', line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}, + }, + ], + }; + const manager = createMockManager({ + getKnownThreads: sinon.stub().returns([haltedThread]), + setBreakpoints: sinon + .stub() + .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), + }); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); + const result = await tool.handler({ + session_id: entry.sessionId, + file: '/app_test/cartridge/controllers/Cart.js', + line: 42, + expressions: ['x', 'y'], + auto_continue: true, + }); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{ + halted: boolean; + thread_id: number; + stack: unknown[]; + variables: unknown[]; + evaluations: Array<{expression: string}>; + auto_continued: boolean; + }>(result); + expect(json.halted).to.be.true; + expect(json.thread_id).to.equal(5); + expect(json.stack).to.have.lengthOf(1); + expect(json.variables).to.have.lengthOf(3); + expect(json.evaluations).to.have.lengthOf(2); + expect(json.auto_continued).to.be.true; + expect((manager.resume as sinon.SinonStub).calledOnce).to.be.true; + }); + + it('should handle evaluation errors gracefully', async () => { + const haltedThread = { + id: 5, + status: 'halted', + call_stack: [ + { + index: 0, + location: {function_name: 'f', line_number: 1, script_path: '/app_test/cartridge/x.js'}, + }, + ], + }; + const manager = createMockManager({ + getKnownThreads: sinon.stub().returns([haltedThread]), + setBreakpoints: sinon + .stub() + .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), + }); + (manager.client.evaluate as sinon.SinonStub).rejects(new Error('bad expression')); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); + const result = await tool.handler({ + session_id: entry.sessionId, + file: '/app_test/cartridge/controllers/Cart.js', + line: 42, + expressions: ['broken'], + }); + + const json = getResultJson<{evaluations: Array<{result: string}>}>(result); + expect(json.evaluations[0].result).to.include('Error: bad expression'); + }); + + it('should time out when no halt occurs', async () => { + const manager = createMockManager({ + setBreakpoints: sinon + .stub() + .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), + }); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); + const result = await tool.handler({ + session_id: entry.sessionId, + file: '/app_test/cartridge/controllers/Cart.js', + line: 42, + timeout_ms: 50, + }); + + const json = getResultJson<{halted: boolean; timed_out?: boolean}>(result); + expect(json.halted).to.be.false; + expect(json.timed_out).to.be.true; + }); + + it('should fire trigger_url in background', async () => { + const haltedThread = { + id: 5, + status: 'halted', + call_stack: [ + { + index: 0, + location: {function_name: 'f', line_number: 1, script_path: '/app_test/cartridge/x.js'}, + }, + ], + }; + const manager = createMockManager({ + getKnownThreads: sinon.stub().returns([haltedThread]), + setBreakpoints: sinon + .stub() + .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), + }); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const fetchStub = sinon.stub(globalThis, 'fetch').resolves(new Response('', {status: 200})); + + try { + const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); + const result = await tool.handler({ + session_id: entry.sessionId, + file: '/app_test/cartridge/controllers/Cart.js', + line: 42, + trigger_url: 'https://example.com/trigger', + }); + + const json = getResultJson<{trigger_status?: number}>(result); + expect(json.trigger_status).to.equal(200); + expect(fetchStub.calledWith('https://example.com/trigger')).to.be.true; + } finally { + fetchStub.restore(); + } + }); + + it('should handle trigger_url fetch failure', async () => { + const haltedThread = { + id: 5, + status: 'halted', + call_stack: [ + { + index: 0, + location: {function_name: 'f', line_number: 1, script_path: '/app_test/cartridge/x.js'}, + }, + ], + }; + const manager = createMockManager({ + getKnownThreads: sinon.stub().returns([haltedThread]), + setBreakpoints: sinon + .stub() + .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), + }); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const fetchStub = sinon.stub(globalThis, 'fetch').rejects(new Error('network')); + + try { + const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); + const result = await tool.handler({ + session_id: entry.sessionId, + file: '/app_test/cartridge/controllers/Cart.js', + line: 42, + trigger_url: 'https://example.com/trigger', + }); + + const json = getResultJson<{trigger_status?: number}>(result); + expect(json.trigger_status).to.be.undefined; + } finally { + fetchStub.restore(); + } + }); + + it('should wait via waiter when no thread is already halted', async () => { + const manager = createMockManager({ + setBreakpoints: sinon + .stub() + .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), + }); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); + const promise = tool.handler({ + session_id: entry.sessionId, + file: '/app_test/cartridge/controllers/Cart.js', + line: 42, + timeout_ms: 5000, + }); + + setTimeout(() => { + const waiter = entry.haltWaiters.shift()!; + clearTimeout(waiter.timer); + waiter.resolve({ + id: 9, + status: 'halted', + call_stack: [ + { + index: 0, + location: {function_name: 'foo', line_number: 5, script_path: '/app_test/cartridge/foo.js'}, + }, + ], + }); + }, 10); + + const result = await promise; + const json = getResultJson<{halted: boolean; thread_id: number}>(result); + expect(json.halted).to.be.true; + expect(json.thread_id).to.equal(9); + }); + + it('should truncate long variable values in capture', async () => { + const longValue = 'y'.repeat(300); + const haltedThread = { + id: 5, + status: 'halted', + call_stack: [ + { + index: 0, + location: {function_name: 'f', line_number: 1, script_path: '/app_test/cartridge/x.js'}, + }, + ], + }; + const manager = createMockManager({ + getKnownThreads: sinon.stub().returns([haltedThread]), + setBreakpoints: sinon + .stub() + .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), + }); + (manager.client.getVariables as sinon.SinonStub).resolves({ + object_members: [{name: 'big', type: 'string', value: longValue, scope: 'local'}], + count: 1, + start: 0, + total: 1, + _v: '2.0', + }); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); + const result = await tool.handler({ + session_id: entry.sessionId, + file: '/app_test/cartridge/controllers/Cart.js', + line: 42, + }); + + const json = getResultJson<{variables: Array<{value: string}>}>(result); + expect(json.variables[0].value.endsWith('...')).to.be.true; + }); + + it('should error when server context is missing', async () => { + const tool = createDebugCaptureAtBreakpointTool(loadServices, undefined); + const result = await tool.handler({session_id: 'x', file: '/a.js', line: 1}); + expect(result.isError).to.be.true; + }); + }); + + describe('debug_start_session', () => { + let tmpDir: string; + let connectStub: sinon.SinonStub; + + beforeEach(() => { + // Create a real cartridge fixture so findCartridges works + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'debug-start-')); + const cartridgeDir = path.join(tmpDir, 'app_test'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), ''); + + // Stub the manager's network calls + connectStub = sinon.stub(DebugSessionManager.prototype, 'connect').resolves(); + sinon.stub(DebugSessionManager.prototype, 'disconnect').resolves(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, {recursive: true, force: true}); + sinon.restore(); + }); + + it('should start a session and return session_id, hostname, cartridges, and mappings', async () => { + const tool = createDebugStartSessionTool(loadServices, serverContext); + const result = await tool.handler({cartridge_directory: tmpDir}); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{ + session_id: string; + hostname: string; + cartridges: string[]; + cartridge_mappings: Record; + warnings: string[]; + }>(result); + + expect(json.session_id).to.be.a('string'); + expect(json.hostname).to.equal('test.example.com'); + expect(json.cartridges).to.deep.equal(['app_test']); + expect(json.cartridge_mappings).to.have.property('app_test'); + expect(connectStub.calledOnce).to.be.true; + }); + + it('should warn when no cartridges found', async () => { + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-')); + try { + const tool = createDebugStartSessionTool(loadServices, serverContext); + const result = await tool.handler({cartridge_directory: emptyDir}); + + const json = getResultJson<{warnings: string[]}>(result); + expect(json.warnings).to.not.be.empty; + } finally { + fs.rmSync(emptyDir, {recursive: true, force: true}); + } + }); + + it('should error when credentials are missing', async () => { + const servicesNoAuth = new Services({ + resolvedConfig: createMockResolvedConfig({}), + }); + const tool = createDebugStartSessionTool(() => servicesNoAuth, serverContext); + const result = await tool.handler({cartridge_directory: tmpDir}); + + expect(result.isError).to.be.true; + expect(getResultText(result)).to.include('Basic auth credentials'); + }); + + it('should error when server context is missing', async () => { + const tool = createDebugStartSessionTool(loadServices, undefined); + const result = await tool.handler({cartridge_directory: tmpDir}); + expect(result.isError).to.be.true; + }); + + it('should use custom client_id', async () => { + const tool = createDebugStartSessionTool(loadServices, serverContext); + await tool.handler({cartridge_directory: tmpDir, client_id: 'custom-client'}); + + const sessions = serverContext.debugSessions.listSessions(); + expect(sessions[0].clientId).to.equal('custom-client'); + }); + + it('should resolve halt waiters via onThreadStopped callback', async () => { + const tool = createDebugStartSessionTool(loadServices, serverContext); + await tool.handler({cartridge_directory: tmpDir}); + + const entry = serverContext.debugSessions.listSessions()[0]; + // Register a halt waiter + const halted = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('timeout')), 1000); + entry.haltWaiters.push({ + resolve(t) { + clearTimeout(timer); + resolve(t.id); + }, + reject, + timer, + }); + }); + + // Fire the callback registered by start_session + const callbacks = (entry.manager as unknown as {callbacks: {onThreadStopped: (t: unknown) => void}}).callbacks; + callbacks.onThreadStopped({ + id: 42, + status: 'halted', + call_stack: [], + }); + + const id = await halted; + expect(id).to.equal(42); + }); + }); +}); diff --git a/packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.ts b/packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.ts new file mode 100644 index 00000000..25a89a42 --- /dev/null +++ b/packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.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 {expect} from 'chai'; +import sinon from 'sinon'; +import {DebugSessionRegistry} from '../../../src/tools/diagnostics/session-registry.js'; +import type {DebugSessionManager, SourceMapper} from '@salesforce/b2c-tooling-sdk/operations/debug'; + +function createMockManager(overrides?: Partial): DebugSessionManager { + return { + client: {} as unknown, + connect: sinon.stub().resolves(), + disconnect: sinon.stub().resolves(), + setBreakpoints: sinon.stub().resolves([]), + resume: sinon.stub().resolves(), + stepOver: sinon.stub().resolves(), + stepInto: sinon.stub().resolves(), + stepOut: sinon.stub().resolves(), + getKnownThreads: sinon.stub().returns([]), + ...overrides, + } as unknown as DebugSessionManager; +} + +function createMockSourceMapper(): SourceMapper { + return { + toServerPath: sinon.stub().returns(undefined), + toLocalPath: sinon.stub().returns(undefined), + }; +} + +describe('DebugSessionRegistry', () => { + let registry: DebugSessionRegistry; + + beforeEach(() => { + registry = new DebugSessionRegistry(); + }); + + afterEach(async () => { + await registry.destroyAll(); + }); + + describe('registerSession', () => { + it('should register a session and return an entry with a UUID', () => { + const manager = createMockManager(); + const sourceMapper = createMockSourceMapper(); + + const entry = registry.registerSession({ + hostname: 'host.example.com', + clientId: 'client-1', + manager, + sourceMapper, + cartridges: [], + }); + + expect(entry.sessionId).to.be.a('string'); + expect(entry.sessionId).to.match(/^[\da-f-]{36}$/); + expect(entry.hostname).to.equal('host.example.com'); + expect(entry.clientId).to.equal('client-1'); + expect(entry.manager).to.equal(manager); + expect(entry.sourceMapper).to.equal(sourceMapper); + expect(entry.breakpoints).to.deep.equal([]); + expect(entry.haltWaiters).to.deep.equal([]); + }); + + it('should reject duplicate hostname:clientId pairs', () => { + const manager = createMockManager(); + const sourceMapper = createMockSourceMapper(); + + registry.registerSession({ + hostname: 'host.example.com', + clientId: 'client-1', + manager, + sourceMapper, + cartridges: [], + }); + + expect(() => { + registry.registerSession({ + hostname: 'host.example.com', + clientId: 'client-1', + manager: createMockManager(), + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + }).to.throw(/already exists.*client-1/); + }); + + it('should allow different client IDs on same host', () => { + const sourceMapper = createMockSourceMapper(); + + registry.registerSession({ + hostname: 'host.example.com', + clientId: 'client-1', + manager: createMockManager(), + sourceMapper, + cartridges: [], + }); + const entry2 = registry.registerSession({ + hostname: 'host.example.com', + clientId: 'client-2', + manager: createMockManager(), + sourceMapper, + cartridges: [], + }); + + expect(entry2.sessionId).to.be.a('string'); + }); + + it('should allow same client ID on different hosts', () => { + const sourceMapper = createMockSourceMapper(); + + registry.registerSession({ + hostname: 'host1.example.com', + clientId: 'client-1', + manager: createMockManager(), + sourceMapper, + cartridges: [], + }); + const entry2 = registry.registerSession({ + hostname: 'host2.example.com', + clientId: 'client-1', + manager: createMockManager(), + sourceMapper, + cartridges: [], + }); + + expect(entry2.sessionId).to.be.a('string'); + }); + }); + + describe('getSession', () => { + it('should return undefined for unknown session ID', () => { + expect(registry.getSession('nonexistent')).to.be.undefined; + }); + + it('should return the session entry', () => { + const entry = registry.registerSession({ + hostname: 'host.example.com', + clientId: 'c', + manager: createMockManager(), + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + expect(registry.getSession(entry.sessionId)).to.equal(entry); + }); + }); + + describe('getSessionOrThrow', () => { + it('should throw for unknown session ID', () => { + expect(() => registry.getSessionOrThrow('nonexistent')).to.throw(/No debug session found/); + }); + + it('should return entry and update lastActivityAt', () => { + const entry = registry.registerSession({ + hostname: 'host.example.com', + clientId: 'c', + manager: createMockManager(), + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + const before = entry.lastActivityAt; + + // Small delay to ensure timestamp changes + const result = registry.getSessionOrThrow(entry.sessionId); + + expect(result).to.equal(entry); + expect(result.lastActivityAt).to.be.at.least(before); + }); + }); + + describe('findByHostAndClientId', () => { + it('should return undefined when no match', () => { + expect(registry.findByHostAndClientId('nope', 'nope')).to.be.undefined; + }); + + it('should find matching entry', () => { + const entry = registry.registerSession({ + hostname: 'host.example.com', + clientId: 'c', + manager: createMockManager(), + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + expect(registry.findByHostAndClientId('host.example.com', 'c')).to.equal(entry); + }); + }); + + describe('listSessions', () => { + it('should return empty array when no sessions', () => { + expect(registry.listSessions()).to.deep.equal([]); + }); + + it('should return all sessions', () => { + registry.registerSession({ + hostname: 'h1', + clientId: 'c1', + manager: createMockManager(), + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + registry.registerSession({ + hostname: 'h2', + clientId: 'c2', + manager: createMockManager(), + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const list = registry.listSessions(); + expect(list).to.have.lengthOf(2); + }); + }); + + describe('destroySession', () => { + it('should disconnect the manager', async () => { + const manager = createMockManager(); + const entry = registry.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + await registry.destroySession(entry.sessionId); + + expect((manager.disconnect as sinon.SinonStub).calledOnce).to.be.true; + expect(registry.getSession(entry.sessionId)).to.be.undefined; + }); + + it('should reject pending halt waiters', async () => { + const entry = registry.registerSession({ + hostname: 'host', + clientId: 'c', + manager: createMockManager(), + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + const waiterPromise = new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve('timeout'), 5000); + entry.haltWaiters.push({ + resolve() { + clearTimeout(timer); + resolve('resolved'); + }, + reject(e) { + clearTimeout(timer); + reject(e); + }, + timer, + }); + }); + + await registry.destroySession(entry.sessionId); + + try { + await waiterPromise; + expect.fail('Should have rejected'); + } catch (error) { + expect((error as Error).message).to.equal('Debug session ended'); + } + }); + + it('should be a no-op for unknown session ID', async () => { + await registry.destroySession('nonexistent'); // Should not throw + }); + + it('should swallow disconnect errors (best-effort cleanup)', async () => { + const manager = createMockManager({disconnect: sinon.stub().rejects(new Error('boom'))}); + const entry = registry.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + await registry.destroySession(entry.sessionId); // Should not throw + expect(registry.getSession(entry.sessionId)).to.be.undefined; + }); + }); + + describe('cleanupIdleSessions', () => { + it('should destroy sessions idle past TTL', async () => { + const manager = createMockManager(); + const entry = registry.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + // Fake an ancient lastActivityAt + entry.lastActivityAt = Date.now() - 31 * 60 * 1000; + + // Invoke the private method via any-cast for coverage + await (registry as unknown as {cleanupIdleSessions: () => Promise}).cleanupIdleSessions(); + + expect(registry.getSession(entry.sessionId)).to.be.undefined; + expect((manager.disconnect as sinon.SinonStub).calledOnce).to.be.true; + }); + + it('should leave active sessions alone', async () => { + const manager = createMockManager(); + const entry = registry.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + await (registry as unknown as {cleanupIdleSessions: () => Promise}).cleanupIdleSessions(); + + expect(registry.getSession(entry.sessionId)).to.equal(entry); + }); + }); + + describe('destroyAll', () => { + it('should destroy all sessions', async () => { + const m1 = createMockManager(); + const m2 = createMockManager(); + registry.registerSession({ + hostname: 'h1', + clientId: 'c1', + manager: m1, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + registry.registerSession({ + hostname: 'h2', + clientId: 'c2', + manager: m2, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + + await registry.destroyAll(); + + expect((m1.disconnect as sinon.SinonStub).calledOnce).to.be.true; + expect((m2.disconnect as sinon.SinonStub).calledOnce).to.be.true; + expect(registry.listSessions()).to.deep.equal([]); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/src/operations/debug/index.ts b/packages/b2c-tooling-sdk/src/operations/debug/index.ts index 9d4803b1..52a26a4e 100644 --- a/packages/b2c-tooling-sdk/src/operations/debug/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/debug/index.ts @@ -18,6 +18,17 @@ export {B2CScriptDebugAdapter} from './dap-adapter.js'; export {createSourceMapper} from './source-mapping.js'; export type {SourceMapper} from './source-mapping.js'; export {VariableStore} from './variable-store.js'; +export {resolveBreakpointPath} from './resolve-path.js'; +export { + DEFAULT_MAX_VALUE_LENGTH, + isPrimitiveType, + projectBreakpoint, + projectFrame, + projectThreadLocation, + projectVariable, + truncateValue, +} from './projections.js'; +export type {MappedBreakpoint, MappedFrame, MappedLocation, MappedVariable} from './projections.js'; export type {VariableRef} from './variable-store.js'; export type { SdapiLocation, diff --git a/packages/b2c-tooling-sdk/src/operations/debug/projections.ts b/packages/b2c-tooling-sdk/src/operations/debug/projections.ts new file mode 100644 index 00000000..94ec6888 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/debug/projections.ts @@ -0,0 +1,137 @@ +/* + * 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 + */ + +/** + * Shared projections that map SDAPI response types to JSON-friendly objects + * with both server paths and resolved local file paths. Used by both the MCP + * debugger tools and the CLI RPC mode. + * + * @module operations/debug/projections + */ + +import type {SourceMapper} from './source-mapping.js'; +import type {SdapiBreakpoint, SdapiObjectMember, SdapiScriptThread, SdapiStackFrame} from './types.js'; + +/** Default maximum length for variable values before truncation. */ +export const DEFAULT_MAX_VALUE_LENGTH = 200; + +/** + * SDAPI primitive type names. Variables of these types are not expandable + * (no children to drill into). + */ +const PRIMITIVE_TYPES = new Set(['boolean', 'Boolean', 'null', 'number', 'Number', 'string', 'String', 'undefined']); + +/** A mapped source location with both server and local file paths. */ +export interface MappedLocation { + file: null | string; + function_name: string; + line: number; + script_path: string; +} + +/** A mapped stack frame. */ +export interface MappedFrame { + file: null | string; + function_name: string; + index: number; + line: number; + script_path: string; +} + +/** A mapped variable for inspection. */ +export interface MappedVariable { + has_children: boolean; + name: string; + scope?: string; + type: string; + value: string; +} + +/** A mapped breakpoint. */ +export interface MappedBreakpoint { + condition?: string; + file: null | string; + id: number; + line: number; + script_path: string; +} + +/** True when the SDAPI type name represents a non-expandable primitive value. */ +export function isPrimitiveType(type: string): boolean { + return PRIMITIVE_TYPES.has(type); +} + +/** + * Truncate a string to at most `max` characters, appending `...` if truncated. + */ +export function truncateValue(value: string, max: number = DEFAULT_MAX_VALUE_LENGTH): string { + if (value.length <= max) return value; + return value.slice(0, max) + '...'; +} + +/** + * Project an SDAPI stack frame to a structured object with mapped local file path. + */ +export function projectFrame(frame: SdapiStackFrame, mapper: SourceMapper): MappedFrame { + return { + file: mapper.toLocalPath(frame.location.script_path) ?? null, + function_name: frame.location.function_name, + index: frame.index, + line: frame.location.line_number, + script_path: frame.location.script_path, + }; +} + +/** + * Project an SDAPI object member to a structured variable. + * + * @param member - The SDAPI object member + * @param options.includeScope - Whether to include the scope field (defaults to true) + * @param options.maxValueLength - Maximum value length before truncation + */ +export function projectVariable( + member: SdapiObjectMember, + options: {includeScope?: boolean; maxValueLength?: number} = {}, +): MappedVariable { + const {includeScope = true, maxValueLength = DEFAULT_MAX_VALUE_LENGTH} = options; + const result: MappedVariable = { + has_children: !isPrimitiveType(member.type), + name: member.name, + type: member.type, + value: truncateValue(member.value, maxValueLength), + }; + if (includeScope) result.scope = member.scope; + return result; +} + +/** + * Project an SDAPI breakpoint to a structured object with mapped local file path. + */ +export function projectBreakpoint(bp: SdapiBreakpoint, mapper: SourceMapper): MappedBreakpoint { + return { + condition: bp.condition, + file: mapper.toLocalPath(bp.script_path) ?? null, + id: bp.id, + line: bp.line_number, + script_path: bp.script_path, + }; +} + +/** + * Project the top-of-stack location for a thread, mapping the script path. + * Returns null if the thread has no call stack. + */ +export function projectThreadLocation(thread: SdapiScriptThread, mapper: SourceMapper): MappedLocation | null { + const topFrame = thread.call_stack?.[0]; + if (!topFrame) return null; + const loc = topFrame.location; + return { + file: mapper.toLocalPath(loc.script_path) ?? null, + function_name: loc.function_name, + line: loc.line_number, + script_path: loc.script_path, + }; +} diff --git a/packages/b2c-tooling-sdk/src/operations/debug/resolve-path.ts b/packages/b2c-tooling-sdk/src/operations/debug/resolve-path.ts new file mode 100644 index 00000000..ae8c2c12 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/debug/resolve-path.ts @@ -0,0 +1,63 @@ +/* + * 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 type {SourceMapper} from './source-mapping.js'; +import type {CartridgeMapping} from '../code/cartridges.js'; + +/** + * Resolve a user-provided file path to an SDAPI script_path. + * + * Accepts: + * - Absolute local path: `/Users/.../app_storefront/cartridge/controllers/Cart.js` + * - Relative local path: `./cartridges/app_storefront/cartridge/controllers/Cart.js` + * - Server path: `/app_storefront/cartridge/controllers/Cart.js` (passed through if not a local match) + * - Cartridge-name-prefixed: `app_storefront/cartridge/controllers/Cart.js` (prefixed with /) + * + * Returns the SDAPI script_path (e.g. `/app_storefront/cartridge/controllers/Cart.js`). + * Throws with a helpful message if the path cannot be resolved. + */ +export function resolveBreakpointPath( + input: string, + sourceMapper: SourceMapper, + cartridges: CartridgeMapping[], +): string { + // Always try source mapper first — handles both absolute and relative local paths + const mapped = sourceMapper.toServerPath(input); + if (mapped) return mapped; + + // Try resolving relative to cwd (for paths like ./cartridges/app_mysite/...) + const resolved = path.resolve(input); + if (resolved !== input) { + const mappedResolved = sourceMapper.toServerPath(resolved); + if (mappedResolved) return mappedResolved; + } + + // If it starts with / and the source mapper didn't match, treat as server path + if (input.startsWith('/')) { + return input; + } + + // Check if the input starts with a known cartridge name — treat as server path missing leading / + const normalized = input.split(path.sep).join('/'); + const firstSegment = normalized.split('/')[0]; + if (cartridges.some((c) => c.name === firstSegment)) { + return `/${normalized}`; + } + + const cartridgeNames = cartridges.map((c) => c.name).join(', '); + const hint = + cartridges.length > 0 + ? `Known cartridges: ${cartridgeNames}\n` + + `Accepted forms:\n` + + ` /cartridge_name/cartridge/path/to/file.js (server path)\n` + + ` cartridge_name/cartridge/path/to/file.js (server path without leading /)\n` + + ` ./path/to/cartridge_name/cartridge/file.js (relative local path)\n` + + ` /absolute/path/to/cartridge_name/file.js (absolute local path)` + : 'No cartridges discovered. Use a server path: /cartridge_name/cartridge/path/file.js'; + + throw new Error(`Cannot resolve "${input}" to a server script path.\n${hint}`); +} diff --git a/skills/b2c-cli/skills/b2c-debug/SKILL.md b/skills/b2c-cli/skills/b2c-debug/SKILL.md new file mode 100644 index 00000000..4dd7cbe1 --- /dev/null +++ b/skills/b2c-cli/skills/b2c-debug/SKILL.md @@ -0,0 +1,127 @@ +--- +name: b2c-debug +description: Debug B2C Commerce server-side scripts using the b2c CLI. Use this skill whenever the user needs to set breakpoints, step through code, inspect variables, evaluate expressions, or investigate runtime behavior on a B2C Commerce instance. Also use when the user wants to understand what a script is doing at runtime, capture state at a specific line, or drive the debugger from a headless script — even if they just say "debug this controller" or "what's the value of basket at line 42". +--- + +# B2C Debug Skill + +Use the `b2c` CLI to debug server-side scripts on Salesforce B2C Commerce instances. The `debug cli` command provides an interactive REPL for terminal debugging, with an `--rpc` mode for headless/programmatic use. + +> **Tip:** If `b2c` is not installed globally, use `npx @salesforce/b2c-cli` instead (e.g., `npx @salesforce/b2c-cli debug cli`). + +## Prerequisites + +- Basic Auth credentials (username/password) for a BM user with `WebDAV_Manage_Customization` +- Script Debugger enabled: BM > Administration > Development Configuration > Enable Script Debugger + +## Interactive Debugging + +### Start a Debug Session + +```bash +# Start interactive debugger +b2c debug cli + +# Specify cartridge directory for source mapping +b2c debug cli --cartridge-path ./cartridges + +# Use a custom client ID (for concurrent sessions) +b2c debug cli --client-id my-session +``` + +### Set Breakpoints + +In the REPL: + +``` +break Cart.js:42 +break Checkout.js:100 if basket.totalGrossPrice > 100 +breakpoints +delete 1 +``` + +### Inspect State When Halted + +``` +stack +vars +members basket.productLineItems +eval basket.productLineItems.length +eval request.httpParameterMap.get("pid").stringValue +``` + +### Control Execution + +``` +continue +step +stepin +stepout +``` + +### Thread Management + +``` +threads +thread 5 +frame 2 +``` + +## RPC Mode (Headless / Agent Use) + +For headless scripts, agents, and programmatic integration, use `--rpc` mode. Commands and responses are JSONL (one JSON object per line) on stdin/stdout. + +```bash +b2c debug cli --rpc +``` + +### Send Commands + +```json +{"id": 1, "command": "set_breakpoints", "args": {"breakpoints": [{"file": "Cart.js", "line": 42}]}} +{"id": 2, "command": "get_stack"} +{"id": 3, "command": "get_variables", "args": {"scope": "local"}} +{"id": 4, "command": "evaluate", "args": {"expression": "basket.totalGrossPrice"}} +{"id": 5, "command": "continue"} +``` + +### Receive Responses and Events + +```json +{"event": "ready", "data": {}} +{"id": 1, "result": {"breakpoints": [{"id": 1, "file": "Cart.js", "line": 42, "script_path": "/app_storefront/cartridge/controllers/Cart.js"}]}} +{"event": "thread_stopped", "data": {"thread_id": 5, "location": {"file": "Cart.js", "line": 42, "function_name": "show"}}} +``` + +### Available RPC Commands + +| Command | Key Args | Description | +|---------|----------|-------------| +| `set_breakpoints` | `breakpoints: [{file, line, condition?}]` | Replace all breakpoints | +| `list_breakpoints` | | List current breakpoints | +| `continue` | `thread_id?` | Resume halted thread | +| `step_over` | `thread_id?` | Step to next line | +| `step_into` | `thread_id?` | Step into function | +| `step_out` | `thread_id?` | Step out of function | +| `get_stack` | `thread_id?` | Get call stack | +| `get_variables` | `thread_id?, frame_index?, scope?, object_path?` | Get variables | +| `evaluate` | `expression, thread_id?, frame_index?` | Evaluate expression | +| `list_threads` | | List threads | +| `select_thread` | `thread_id` | Switch thread | +| `select_frame` | `index` | Switch frame | + +## DAP Mode (IDE Integration) + +For VS Code and other DAP-compatible IDEs: + +```bash +b2c debug +``` + +This starts a DAP adapter over stdio, used by IDE launch configurations. + +## Related Skills + +- `b2c-cli:b2c-logs` - Retrieve server logs for investigating errors found during debugging +- `b2c-cli:b2c-code` - Deploy code changes before debugging +- `b2c-cli:b2c-config` - Verify instance configuration and credentials diff --git a/skills/b2c-cli/skills/b2c-debug/evals/trigger-evals.json b/skills/b2c-cli/skills/b2c-debug/evals/trigger-evals.json new file mode 100644 index 00000000..ea90d34f --- /dev/null +++ b/skills/b2c-cli/skills/b2c-debug/evals/trigger-evals.json @@ -0,0 +1,16 @@ +[ + {"query": "debug this controller", "should_trigger": true}, + {"query": "set a breakpoint on line 42 of Cart.js", "should_trigger": true}, + {"query": "what's the value of basket at line 100", "should_trigger": true}, + {"query": "step through the checkout code", "should_trigger": true}, + {"query": "start a debug session on my sandbox", "should_trigger": true}, + {"query": "inspect variables when the order is placed", "should_trigger": true}, + {"query": "I need to debug why the payment hook fails", "should_trigger": true}, + {"query": "how do I use the debugger from a script", "should_trigger": true}, + {"query": "connect to the script debugger via RPC", "should_trigger": true}, + {"query": "evaluate an expression in the halted thread", "should_trigger": true}, + {"query": "deploy my cartridges to the sandbox", "should_trigger": false}, + {"query": "check the error logs for failures", "should_trigger": false}, + {"query": "create a new custom object type", "should_trigger": false}, + {"query": "search for products using SCAPI", "should_trigger": false} +]