From ba4ec51d41496b909b83785d2d0267579362eb17 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Wed, 27 May 2026 17:14:53 +0530 Subject: [PATCH 1/5] Onboarding walkthrough for B2C VS Code extension @W-22210602 Adds a 5-phase getting-started walkthrough that guides users through CLI install, dw.json setup, OAuth/Basic auth, cartridge deploy, and scaffold generation. Includes a role-based onboarding panel with persona-specific paths (Storefront, API/Integration, DevOps, AI-augmented). Resolved Config inspect panel: - Mask secrets as a lock pill instead of first-4 preview - Stats strip with field/source/secret counts - Source-tinted pills, zebra-striped table, accent-bordered group cards Scaffold: - Always generate cartridges into /cartridges so the Cartridges view picks them up without dw.json configuration - Explicit Cartridges-view refresh after .project creation, since the **/.project FileSystemWatcher can race scaffold writes --- packages/b2c-vs-extension/eslint.config.mjs | 2 +- packages/b2c-vs-extension/media/b2c-icon.svg | 18 +- .../.vscode-walkthrough-reference.md | 320 +++ .../media/walkthrough/README.md | 106 + .../media/walkthrough/ai-skills.md | 21 + .../media/walkthrough/cartridge-structure.md | 101 + .../media/walkthrough/code-sync.md | 131 ++ .../media/walkthrough/deploy-cartridge.md | 88 + .../media/walkthrough/dw-json-setup.md | 73 + .../media/walkthrough/install-cli.md | 41 + .../media/walkthrough/next-steps.md | 33 + .../media/walkthrough/oauth-setup.md | 69 + .../media/walkthrough/sandbox-explorer.md | 117 ++ .../media/walkthrough/webdav-browser.md | 66 + .../media/walkthrough/welcome-hero-dark.svg | 241 +++ .../media/walkthrough/welcome-hero-light.svg | 245 +++ .../media/walkthrough/welcome.md | 21 + packages/b2c-vs-extension/package.json | 212 +- packages/b2c-vs-extension/src/extension.ts | 356 ++++ .../src/scaffold/scaffold-commands.ts | 11 + .../src/walkthrough/accessibility.ts | 287 +++ .../src/walkthrough/commands.ts | 1693 +++++++++++++++ .../b2c-vs-extension/src/walkthrough/index.ts | 20 + .../src/walkthrough/markdown.ts | 199 ++ .../src/walkthrough/onboardingPanel.ts | 1853 +++++++++++++++++ .../src/walkthrough/personas.ts | 225 ++ .../b2c-vs-extension/src/walkthrough/state.ts | 113 + .../src/walkthrough/telemetry.ts | 192 ++ .../src/walkthrough/test/commands.test.ts | 200 ++ .../src/walkthrough/toolDetection.ts | 282 +++ .../src/walkthrough/validator.ts | 348 ++++ .../test-workspace/.gitignore | 17 + .../b2c-vs-extension/test-workspace/README.md | 99 + .../cartridges/app_storefront_base/.project | 15 + .../cartridge/controllers/Home.js | 21 + .../scripts/helpers/productHelper.js | 22 + .../cartridge/static/default/css/main.css | 27 + .../cartridges/int_custom/.project | 15 + .../scripts/integration/customService.js | 27 + .../test-workspace/package.json | 12 + 40 files changed, 7930 insertions(+), 9 deletions(-) create mode 100644 packages/b2c-vs-extension/media/walkthrough/.vscode-walkthrough-reference.md create mode 100644 packages/b2c-vs-extension/media/walkthrough/README.md create mode 100644 packages/b2c-vs-extension/media/walkthrough/ai-skills.md create mode 100644 packages/b2c-vs-extension/media/walkthrough/cartridge-structure.md create mode 100644 packages/b2c-vs-extension/media/walkthrough/code-sync.md create mode 100644 packages/b2c-vs-extension/media/walkthrough/deploy-cartridge.md create mode 100644 packages/b2c-vs-extension/media/walkthrough/dw-json-setup.md create mode 100644 packages/b2c-vs-extension/media/walkthrough/install-cli.md create mode 100644 packages/b2c-vs-extension/media/walkthrough/next-steps.md create mode 100644 packages/b2c-vs-extension/media/walkthrough/oauth-setup.md create mode 100644 packages/b2c-vs-extension/media/walkthrough/sandbox-explorer.md create mode 100644 packages/b2c-vs-extension/media/walkthrough/webdav-browser.md create mode 100644 packages/b2c-vs-extension/media/walkthrough/welcome-hero-dark.svg create mode 100644 packages/b2c-vs-extension/media/walkthrough/welcome-hero-light.svg create mode 100644 packages/b2c-vs-extension/media/walkthrough/welcome.md create mode 100644 packages/b2c-vs-extension/src/walkthrough/accessibility.ts create mode 100644 packages/b2c-vs-extension/src/walkthrough/commands.ts create mode 100644 packages/b2c-vs-extension/src/walkthrough/index.ts create mode 100644 packages/b2c-vs-extension/src/walkthrough/markdown.ts create mode 100644 packages/b2c-vs-extension/src/walkthrough/onboardingPanel.ts create mode 100644 packages/b2c-vs-extension/src/walkthrough/personas.ts create mode 100644 packages/b2c-vs-extension/src/walkthrough/state.ts create mode 100644 packages/b2c-vs-extension/src/walkthrough/telemetry.ts create mode 100644 packages/b2c-vs-extension/src/walkthrough/test/commands.test.ts create mode 100644 packages/b2c-vs-extension/src/walkthrough/toolDetection.ts create mode 100644 packages/b2c-vs-extension/src/walkthrough/validator.ts create mode 100644 packages/b2c-vs-extension/test-workspace/.gitignore create mode 100644 packages/b2c-vs-extension/test-workspace/README.md create mode 100644 packages/b2c-vs-extension/test-workspace/cartridges/app_storefront_base/.project create mode 100644 packages/b2c-vs-extension/test-workspace/cartridges/app_storefront_base/cartridge/controllers/Home.js create mode 100644 packages/b2c-vs-extension/test-workspace/cartridges/app_storefront_base/cartridge/scripts/helpers/productHelper.js create mode 100644 packages/b2c-vs-extension/test-workspace/cartridges/app_storefront_base/cartridge/static/default/css/main.css create mode 100644 packages/b2c-vs-extension/test-workspace/cartridges/int_custom/.project create mode 100644 packages/b2c-vs-extension/test-workspace/cartridges/int_custom/cartridge/scripts/integration/customService.js create mode 100644 packages/b2c-vs-extension/test-workspace/package.json diff --git a/packages/b2c-vs-extension/eslint.config.mjs b/packages/b2c-vs-extension/eslint.config.mjs index cc8905264..fcb7eb7a0 100644 --- a/packages/b2c-vs-extension/eslint.config.mjs +++ b/packages/b2c-vs-extension/eslint.config.mjs @@ -17,7 +17,7 @@ headerPlugin.rules.header.meta.schema = false; export default [ includeIgnoreFile(gitignorePath), { - ignores: ['src/template/**'], + ignores: ['src/template/**', 'test-workspace/**'], }, ...tseslint.configs.recommended, prettierPlugin, diff --git a/packages/b2c-vs-extension/media/b2c-icon.svg b/packages/b2c-vs-extension/media/b2c-icon.svg index ec49c2507..2d3612d2a 100644 --- a/packages/b2c-vs-extension/media/b2c-icon.svg +++ b/packages/b2c-vs-extension/media/b2c-icon.svg @@ -1,4 +1,16 @@ - - - + + + + + + + + + + B2C + ·DX diff --git a/packages/b2c-vs-extension/media/walkthrough/.vscode-walkthrough-reference.md b/packages/b2c-vs-extension/media/walkthrough/.vscode-walkthrough-reference.md new file mode 100644 index 000000000..04888fe86 --- /dev/null +++ b/packages/b2c-vs-extension/media/walkthrough/.vscode-walkthrough-reference.md @@ -0,0 +1,320 @@ +# VS Code Walkthrough Quick Reference + +This document provides technical reference for maintaining and extending the B2C DX walkthrough. + +## VS Code Walkthrough API + +### Package.json Structure + +```json +{ + "contributes": { + "walkthroughs": [ + { + "id": "unique.walkthrough.id", + "title": "Display Title", + "description": "Short description shown in welcome page", + "when": "context-condition", + "steps": [...] + } + ] + } +} +``` + +### Step Structure + +```json +{ + "id": "unique.step.id", + "title": "Step Title", + "description": "Markdown content with [links](command:commandId)", + "media": { + "markdown": "path/to/file.md" + // OR + "image": "path/to/image.png", + "altText": "Accessible description" + }, + "completionEvents": [ + "onCommand:commandId", + "onView:viewId", + "onContext:contextKey" + ] +} +``` + +## Completion Events + +### Available Event Types + +| Event Type | Syntax | Example | When It Fires | +|------------|--------|---------|---------------| +| Command | `onCommand:id` | `onCommand:b2c-dx.codeSync.deploy` | When command executes | +| View Open | `onView:id` | `onView:b2cWebdavExplorer` | When view becomes visible | +| Context | `onContext:key` | `onContext:workspaceContains:dw.json` | When context becomes true | +| Link | `onLink:uri` | `onLink:https://example.com` | When link is clicked | + +### Common Context Keys + +- `workspaceContains:pattern` - File matching pattern exists +- `editorLangId == language` - Active editor language +- `resourceExtname == .ext` - File extension matches +- Custom contexts set via `vscode.commands.executeCommand('setContext', key, value)` + +## Command Links in Markdown + +### Basic Command Link +```markdown +[Link Text](command:commandId) +``` + +### Command with Arguments +Arguments must be URL-encoded JSON: +```markdown +[Open File](command:workbench.action.quickOpen?%22filename.json%22) +``` + +To encode arguments: +```javascript +const args = ["filename.json"]; +const encoded = encodeURIComponent(JSON.stringify(args)); +// Use: command:commandId?${encoded} +``` + +### Common Built-in Commands + +| Command | Description | +|---------|-------------| +| `workbench.action.quickOpen?args` | Open Quick Open with pre-filled text | +| `workbench.action.openWalkthrough` | Open a specific walkthrough | +| `workbench.view.extension.id` | Open extension view container | +| `workbench.action.openSettings` | Open settings | +| `workbench.action.files.openFile` | Open file picker | + +## Media Types + +### Markdown Media +Best for text-heavy steps with formatting needs: +```json +{ + "media": { + "markdown": "path/to/content.md" + } +} +``` + +**Pros:** +- Rich formatting (headings, lists, code blocks) +- Easy to maintain and update +- No image assets needed +- Command links work naturally + +**Cons:** +- Less visual than images +- Can be text-heavy + +### Image Media +Best for visual demonstrations: +```json +{ + "media": { + "image": "path/to/image.png", + "altText": "Descriptive text for screen readers" + } +} +``` + +**Pros:** +- Highly visual +- Quick to understand +- Professional appearance + +**Cons:** +- Requires image creation/maintenance +- Can become outdated +- Larger file sizes + +### SVG Media +Best for diagrams and scalable graphics: +```json +{ + "media": { + "svg": "path/to/diagram.svg", + "altText": "Descriptive text" + } +} +``` + +**Pros:** +- Scalable (no pixelation) +- Small file size +- Can be version-controlled easily +- Can include text + +### Video Media (Experimental) +```json +{ + "media": { + "video": "path/to/video.mp4" + } +} +``` + +## Conditional Display + +### When Clause +Controls when walkthrough appears: + +```json +{ + "when": "workspaceFolderCount > 0" +} +``` + +Common conditions: +- `workspaceFolderCount > 0` - Workspace is open +- `!isWeb` - Not running in browser +- `extensionId:installed` - Another extension is installed + +### Step Visibility +Currently, steps cannot be conditionally hidden. All steps show in sequence. + +**Workaround:** Use description text to explain prerequisites: +```markdown +**Note:** This step requires OAuth credentials. If you skipped Step 3, come back later. +``` + +## Best Practices + +### Content Guidelines + +1. **Keep steps focused** - One main action per step +2. **Use active voice** - "Deploy your cartridge" not "Cartridges can be deployed" +3. **Provide context** - Explain *why* not just *how* +4. **Include examples** - Code snippets, sample configs +5. **Add troubleshooting** - Common errors and solutions + +### Writing Descriptions + +```markdown +# Good +Configure your instance by creating a `dw.json` file. + +[Create dw.json](command:b2c-dx.walkthrough.createDwJson) + +**Tip:** Add dw.json to .gitignore to avoid committing credentials. + +# Less Good +You need to configure your instance. Click the button to create a file. +``` + +### Completion Events + +**Do:** +- Use clear, specific events +- Test completion triggers +- Provide multiple completion paths if possible + +**Don't:** +- Rely on events that might not trigger +- Use too many completion events (makes step too easy to complete by accident) +- Use events from commands that might fail + +### Accessibility + +1. **Always provide altText** for images +2. **Use semantic markdown** (headings, lists) +3. **Don't rely only on color** to convey information +4. **Test with screen readers** if possible +5. **Keep command links descriptive** (not "click here") + +## Testing Checklist + +- [ ] Build extension without errors +- [ ] Walkthrough appears in Welcome screen +- [ ] All steps load without errors +- [ ] Markdown renders correctly +- [ ] Command links work +- [ ] Completion events trigger correctly +- [ ] Images load (if using images) +- [ ] AltText is descriptive +- [ ] Content is accurate and up-to-date +- [ ] Links to external resources work +- [ ] Tested in Extension Development Host + +## Debugging + +### Extension Host Console +View walkthrough errors: +1. Help → Toggle Developer Tools +2. Console tab +3. Look for walkthrough-related errors + +### Common Issues + +**"Walkthrough not found"** +- Check walkthrough ID matches exactly +- Verify package.json is valid JSON +- Rebuild extension + +**"Step media not loading"** +- Check file path is relative to extension root +- Verify markdown file exists +- Check for typos in path + +**"Completion events not firing"** +- Verify command ID exists in package.json +- Check context key syntax +- Test event manually + +**"Command link does nothing"** +- Verify command is registered +- Check URL encoding of arguments +- Look for errors in Extension Host console + +## Internationalization (i18n) + +Currently not implemented, but can be added: + +```json +{ + "title": "%walkthrough.title%", + "description": "%walkthrough.description%" +} +``` + +With corresponding `package.nls.json`: +```json +{ + "walkthrough.title": "Get Started with B2C Commerce", + "walkthrough.description": "Learn the basics in 30 minutes" +} +``` + +## Performance Considerations + +- Markdown files are loaded lazily (only when step is viewed) +- Images are cached by VS Code +- Large images (>500KB) may slow initial load +- Keep GIFs under 2MB if possible +- Consider using SVG for diagrams + +## Version Compatibility + +- **Walkthroughs API**: VS Code 1.56.0+ (May 2021) +- **Completion events**: VS Code 1.56.0+ +- **Link events**: VS Code 1.58.0+ +- **Video support**: Experimental, not recommended + +Current engine requirement: `^1.105.1` + +## Resources + +- [VS Code Walkthrough API Docs](https://code.visualstudio.com/api/references/contribution-points#contributes.walkthroughs) +- [VS Code Extension Samples](https://github.com/microsoft/vscode-extension-samples/tree/main/getting-started-sample) +- [GitHub Flavored Markdown Spec](https://github.github.com/gfm/) +- [VS Code Built-in Commands](https://code.visualstudio.com/api/references/commands) + +--- + +**Last Updated:** 2026-04-28 diff --git a/packages/b2c-vs-extension/media/walkthrough/README.md b/packages/b2c-vs-extension/media/walkthrough/README.md new file mode 100644 index 000000000..2f354fbc1 --- /dev/null +++ b/packages/b2c-vs-extension/media/walkthrough/README.md @@ -0,0 +1,106 @@ +# B2C DX Walkthrough Media Assets + +This directory contains markdown content and media assets for the **B2C Commerce Development** getting started walkthrough. + +## Files + +### Markdown Content + +| File | Purpose | Used In Step | +|------|---------|--------------| +| `welcome.md` | Introduction and overview | Step 1: Welcome | +| `dw-json-setup.md` | Guide for creating dw.json config | Step 2: Configure Instance | +| `oauth-setup.md` | OAuth credentials setup instructions | Step 3: Setup OAuth | +| `webdav-browser.md` | WebDAV browser feature overview | Step 4: Explore WebDAV | +| `cartridge-structure.md` | Cartridge structure and detection | Step 5: Setup Cartridges | +| `deploy-cartridge.md` | Cartridge deployment guide | Step 6: Deploy Code | +| `sandbox-explorer.md` | Sandbox management instructions | Step 7: Manage Sandboxes | +| `code-sync.md` | Code Sync feature documentation | Step 8: Enable Code Sync | +| `next-steps.md` | Advanced features and resources | Step 9: Next Steps | + +### Image Assets (To Be Added) + +The following image assets should be created for enhanced walkthrough experience: + +| File | Description | Dimensions | +|------|-------------|------------| +| `welcome.png` | Extension overview banner | 800x400 | +| `dw-json-example.png` | Screenshot of configured dw.json | 600x400 | +| `oauth-credentials.png` | Account Manager OAuth setup | 800x500 | +| `webdav-tree.png` | WebDAV browser showing cartridges | 400x600 | +| `cartridge-explorer.png` | Cartridges view with detected cartridges | 400x500 | +| `deploy-success.png` | Deployment success notification | 600x200 | +| `sandbox-explorer.png` | Sandbox Explorer with realms | 400x600 | +| `code-sync-active.png` | Status bar with Code Sync enabled | 800x100 | +| `api-browser.png` | API Browser with Swagger UI | 800x600 | + +### GIF Assets (Optional Enhancement) + +For more engaging walkthrough experience: + +| File | Description | Duration | +|------|-------------|----------| +| `webdav-navigation.gif` | Animated demo of browsing WebDAV | 5-10s | +| `deploy-cartridge.gif` | Animated deployment process | 5-10s | +| `code-sync-demo.gif` | Live demo of file save → auto-upload | 5-10s | + +## Adding Images + +To add image assets: + +1. Create or capture screenshots/images +2. Save them in this directory with the names above +3. Update package.json walkthrough steps to reference images: + +```json +"media": { + "image": "media/walkthrough/welcome.png", + "altText": "B2C DX Extension welcome screen" +} +``` + +Or keep using markdown files: + +```json +"media": { + "markdown": "media/walkthrough/welcome.md" +} +``` + +## Guidelines + +### Screenshot Guidelines +- Use light theme for consistency +- Crop to relevant UI elements +- Highlight important elements (arrows, boxes) +- Use high-resolution images (2x for Retina displays) + +### Markdown Guidelines +- Keep content concise and scannable +- Use headings, bullets, and code blocks +- Include emoji sparingly for visual interest +- Link to relevant commands using `command:` URIs + +### Accessibility +- Always provide `altText` for images +- Ensure markdown is readable without images +- Use semantic headings in markdown files +- Test with screen readers if possible + +## Testing + +To test the walkthrough: + +1. Build the extension: `pnpm run build` +2. Press F5 to launch Extension Development Host +3. Open Command Palette: `Cmd+Shift+P` +4. Run: **Welcome: Open Walkthrough...** +5. Select: **Get Started with B2C Commerce Development** + +## Maintenance + +When updating walkthrough content: +- Update this README if adding/removing files +- Keep markdown files synced with actual extension features +- Test all command links to ensure they work +- Update completion events if command IDs change diff --git a/packages/b2c-vs-extension/media/walkthrough/ai-skills.md b/packages/b2c-vs-extension/media/walkthrough/ai-skills.md new file mode 100644 index 000000000..9076e5bc3 --- /dev/null +++ b/packages/b2c-vs-extension/media/walkthrough/ai-skills.md @@ -0,0 +1,21 @@ +# Set up Agent Skills & MCP + +The B2C developer toolkit ships an **MCP server** and a set of **Agent Skills** that let Claude Code, Cursor, GitHub Copilot, and similar tools share context with your B2C project. Configure them once and your AI tools learn the same instance, dw.json, and cartridge layout this extension already understands. + +## MCP server + +The MCP server exposes B2C-specific tools (deploy, log queries, sandbox info) to any MCP-aware client. Configuration is project-scoped — drop a JSON snippet into your client's MCP config and you're done. + +[MCP server setup guide](https://salesforcecommercecloud.github.io/b2c-developer-tooling/mcp/) — includes copy-paste snippets for Claude Code, Cursor, and Copilot. + +## Agent Skills + +Agent Skills bundle B2C-specific instructions, prompts, and conventions that your IDE's AI features can reference. They keep code generation grounded in B2C patterns rather than generic JavaScript. + +[Agent Skills installation](https://salesforcecommercecloud.github.io/b2c-developer-tooling/guide/agent-skills.html) + +## Pairing with this extension + +- The **Prompt Agent** command (Cursor only) opens a Cursor chat with whatever prompt you type — useful for round-tripping context out of VS Code. +- Keep `dw.json` at the project root; both the MCP server and this extension look there first. +- Set secrets via environment variables (see the dw.json step) so AI tools don't accidentally surface them in context windows. diff --git a/packages/b2c-vs-extension/media/walkthrough/cartridge-structure.md b/packages/b2c-vs-extension/media/walkthrough/cartridge-structure.md new file mode 100644 index 000000000..fc69c5540 --- /dev/null +++ b/packages/b2c-vs-extension/media/walkthrough/cartridge-structure.md @@ -0,0 +1,101 @@ +# Cartridge Development + +Cartridges are the building blocks of B2C Commerce applications. The extension automatically detects cartridges in your workspace. + +## What is a Cartridge? + +A cartridge is a modular unit of code that contains: +- **Scripts**: Server-side JavaScript (ISML templates, controllers) +- **Static assets**: CSS, JavaScript, images +- **Templates**: ISML templates for rendering pages +- **Forms and metadata**: XML configuration files + +## Cartridge Structure + +A typical cartridge looks like this: + +``` +my_cartridge/ +├── .project ← Required for detection! +├── cartridge/ +│ ├── scripts/ ← Server-side scripts +│ ├── templates/ ← ISML templates +│ ├── static/ ← CSS, JS, images +│ │ ├── default/ +│ │ │ ├── css/ +│ │ │ ├── js/ +│ │ │ └── images/ +│ ├── controllers/ ← Page controllers +│ ├── models/ ← Business logic +│ └── forms/ ← Form definitions +└── package.json ← Node.js dependencies (optional) +``` + +## How Cartridges are Detected + +The extension looks for folders containing a **`.project`** file. This is the Eclipse project file that identifies a cartridge. + +### Sample `.project` File + +```xml + + + my_cartridge + + + + + com.demandware.studio.core.beehiveElementBuilder + + + + + com.demandware.studio.core.beehiveNature + + +``` + +## Creating a New Cartridge + +### Option 1: Use the Scaffold Generator +Click **Create New Cartridge** above to use the built-in scaffold generator. + +### Option 2: Manual Creation +1. Create a new folder in your workspace +2. Add a `.project` file (see example above) +3. Create the `cartridge/` directory structure +4. Click **Refresh Cartridge List** + +## Viewing Your Cartridges + +Open the **Cartridges** view in the B2C-DX activity bar to see all detected cartridges. + +### Cartridge Actions + +Right-click a cartridge to: +- 📤 **Upload Cartridge**: Deploy to your instance +- 📥 **Download from Instance**: Sync remote version to local +- ↔️ **Compare with Instance**: See differences +- ➕ **Add to Site Cartridge Path**: Add to site's cartridge path +- ➖ **Remove from Site Cartridge Path**: Remove from cartridge path + +## Multiple Cartridges + +Your workspace can contain multiple cartridges. The extension will detect all of them automatically. + +``` +workspace/ +├── cartridge_1/ +│ ├── .project +│ └── cartridge/ +├── cartridge_2/ +│ ├── .project +│ └── cartridge/ +└── cartridge_3/ + ├── .project + └── cartridge/ +``` + +--- + +Once cartridges are detected, the **Cartridges** view will open automatically! diff --git a/packages/b2c-vs-extension/media/walkthrough/code-sync.md b/packages/b2c-vs-extension/media/walkthrough/code-sync.md new file mode 100644 index 000000000..d1262dc8d --- /dev/null +++ b/packages/b2c-vs-extension/media/walkthrough/code-sync.md @@ -0,0 +1,131 @@ +# Code Sync (Automatic Deployment) + +**Code Sync** watches your cartridge files and automatically uploads changes as you save. Perfect for rapid development! + +## What is Code Sync? + +Code Sync is a **file watcher** that: +- Monitors your cartridge files for changes +- Automatically uploads modified files to your B2C instance +- Shows upload status in the status bar +- Supports multiple cartridges simultaneously + +## Starting Code Sync + +Click **Start Code Sync** above, or: +1. Open Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`) +2. Run **B2C DX - Code Sync: Start Code Sync** + +You'll see a status bar item: **$(sync~spin) Code Sync: Active** + +## Stopping Code Sync + +Click **Stop Code Sync** above, or: +- Click the **$(sync~spin)** status bar item +- Run **B2C DX - Code Sync: Stop Code Sync** from Command Palette + +## What Gets Synced? + +Code Sync monitors these file types in your cartridges: +- `.js` - Scripts and controllers +- `.ds` - Scripts (DemandWare Script) +- `.isml` - Templates +- `.xml` - Forms and metadata +- `.properties` - Configuration files +- `.css` - Stylesheets +- `.scss` / `.sass` - Sass files +- `.json` - JSON configuration +- Images (`.png`, `.jpg`, `.svg`, etc.) + +### Excluded Files +Code Sync ignores: +- `node_modules/` +- `.git/` +- Build artifacts +- `.project` files + +## Status Bar Indicators + +### $(sync~spin) Code Sync: Active +Watching for file changes. + +### $(cloud-upload) Uploading: filename.js +Currently uploading a file. + +### $(check) Upload Complete +File uploaded successfully. + +### $(error) Upload Failed +Upload encountered an error. Check Output panel. + +## Configuration + +Control Code Sync behavior in Settings (`Cmd+,` / `Ctrl+,`): + +### Enable/Disable Code Sync +```json +"b2c-dx.features.codeSync": true +``` + +## How It Works + +1. **Save a file** in a cartridge +2. Code Sync **detects the change** +3. File is **uploaded** to WebDAV (`/Cartridges//...`) +4. B2C instance **updates** the active code version +5. Changes are **immediately available** (no restart needed for most files) + +## Best Practices + +### ✅ When to Use Code Sync + +- **Rapid development**: Making frequent small changes +- **Template editing**: ISML template development +- **CSS/JS tweaks**: Front-end styling adjustments +- **Debugging**: Quick fixes to test theories + +### ❌ When NOT to Use Code Sync + +- **Large refactoring**: Many file changes at once (use manual deploy) +- **Production deployments**: Always use manual deploy for production +- **Multiple cartridges**: Deploy all at once with manual deploy +- **First deployment**: Use manual deploy to ensure everything uploads + +## Performance Tips + +💡 **Watch the Output panel**: Monitor upload progress and errors in **B2C DX** output. + +💡 **Stop when not developing**: Disable Code Sync when not actively coding to save resources. + +💡 **Use .gitignore patterns**: Code Sync respects `.gitignore` files. + +💡 **Exclude large files**: Don't upload huge images or videos via Code Sync (use WebDAV browser for bulk uploads). + +## Troubleshooting + +### Files Not Uploading? +- Check Output panel for errors +- Verify `dw.json` credentials +- Ensure cartridge is detected (Cartridges view) +- Confirm Code Sync is active (status bar) + +### Upload Delays? +- Network latency can slow uploads +- Large files take longer +- Check your internet connection + +### Changes Not Visible on Storefront? +- Some changes require **cache clearing** +- Templates update immediately +- Controllers may need instance restart +- CSS/JS may be cached in browser (hard refresh) + +## Toggle Code Sync + +You can also **toggle** Code Sync on/off: +- Run **B2C DX - Code Sync: Toggle Code Sync** from Command Palette +- Quickly enable/disable without separate commands + +--- + +Click **Start Code Sync** to enable automatic deployment! diff --git a/packages/b2c-vs-extension/media/walkthrough/deploy-cartridge.md b/packages/b2c-vs-extension/media/walkthrough/deploy-cartridge.md new file mode 100644 index 000000000..009a86e17 --- /dev/null +++ b/packages/b2c-vs-extension/media/walkthrough/deploy-cartridge.md @@ -0,0 +1,88 @@ +# Deploy Your First Cartridge + +Deploying cartridges is the core workflow for B2C Commerce development. Upload your local code to your B2C instance with a single command! + +## Deployment Methods + +### Method 1: Deploy All Cartridges +Click **Deploy All Cartridges** above to upload all cartridges in your workspace. + +This creates a ZIP archive of each cartridge and uploads them to your instance's active code version. + +### Method 2: Deploy Individual Cartridge +1. Open the **Cartridges** view (B2C-DX sidebar) +2. Right-click a cartridge +3. Select **Upload Cartridge** + +### Method 3: Command Palette +1. Press `Cmd+Shift+P` (Mac) or `Ctrl+Shift+P` (Windows/Linux) +2. Type "B2C: Deploy" +3. Select **B2C DX - Code Sync: Deploy Cartridges** + +## What Happens During Deployment + +1. **Packaging**: Extension creates a ZIP archive of the cartridge +2. **Upload**: ZIP is uploaded via WebDAV to `/Cartridges/` +3. **Extraction**: B2C instance automatically extracts the cartridge +4. **Activation**: Cartridge is available in the active code version + +## Monitoring Deployment + +Watch the **Output** panel for deployment progress: +1. View → Output +2. Select **B2C DX** from the dropdown + +You'll see logs like: +``` +[INFO] Starting cartridge upload: my_cartridge +[INFO] Creating archive... +[INFO] Uploading to /Cartridges/my_cartridge... +[INFO] Upload complete! (2.3 MB in 1.2s) +``` + +## Code Versions + +Cartridges are deployed to the **active code version** on your instance. + +### Viewing Code Versions +Click the **Code Versions** icon in the Cartridges view to see all code versions on your instance. + +### Creating a New Code Version +1. Click **Code Versions** in Cartridges view +2. Click **Create Code Version** +3. Enter a version name +4. Optionally activate it + +### Activating a Code Version +1. Click **Code Versions** +2. Select a version +3. Click **Activate Code Version** + +## Troubleshooting + +### ❌ "Upload failed: Authentication required" +- Check your `dw.json` credentials +- Verify hostname, username, and password are correct + +### ❌ "Upload failed: Permission denied" +- Ensure your user has WebDAV upload permissions +- Check Business Manager user roles + +### ❌ "Cartridge not found on instance" +- After uploading, the cartridge appears in the cartridge path +- Verify upload succeeded in Output panel + +### ❌ "Code version is read-only" +- You cannot upload to a locked code version +- Create a new code version or unlock the current one + +## Next Steps + +After deploying: +- **Test your changes**: Visit your storefront to see updates +- **Check logs**: Use the **Start Tailing Logs** command to view instance logs +- **Debug**: Use the B2C Script Debugger to debug server-side code + +--- + +Click **Deploy All Cartridges** to upload your code now! diff --git a/packages/b2c-vs-extension/media/walkthrough/dw-json-setup.md b/packages/b2c-vs-extension/media/walkthrough/dw-json-setup.md new file mode 100644 index 000000000..cc665f690 --- /dev/null +++ b/packages/b2c-vs-extension/media/walkthrough/dw-json-setup.md @@ -0,0 +1,73 @@ +# Configure your instance + +The B2C tooling reads config from layered sources. **Different fields belong in different places.** The walkthrough's *Run setup wizard* asks you, pair by pair, where each value should live. + +## What goes where + +| Field | Sensitive? | Recommended home | +|---|---|---| +| `hostname` | No | `dw.json` | +| `code-version` | No | `dw.json` | +| `short-code`, `tenant-id`, `oauth-scopes` | No | `dw.json` (when SCAPI is enabled) | +| `mrtProject`, `mrtEnvironment` | No | `dw.json` | +| `client-id` | Identifier (not sensitive) | Same source as `client-secret` (Credential Grouping) | +| `client-secret` | **Yes** | Keychain / Password Store / `SFCC_CLIENT_SECRET` | +| `username` | Identifier | Same source as `password` | +| `password` | **Yes** (WebDAV access key) | Keychain / Password Store / `SFCC_PASSWORD` | +| MRT API key | **Yes** | `b2c mrt save-credentials` (writes `~/.mobify`) or `MRT_API_KEY` env var | +| `certificate`, `certificate-passphrase` | **Yes** (mTLS) | Env vars only | + +> **Credential Grouping.** If one half of an OAuth or Basic-auth pair comes from a higher-priority source, the matching half from a lower source is **ignored**. The wizard enforces this by asking pair-by-pair. + +## Auth flows you can mix and match + +Different jobs need different fields. The wizard lets you enable any combination: + +- **OAuth** — `client-id` + `client-secret`. Required for Sandbox Explorer, OCAPI / SCAPI, and CI jobs. +- **Basic** — `username` + `password`. Required for WebDAV and cartridge deploys. +- **SCAPI extras** — `short-code` + `tenant-id` + optional `oauth-scopes`. Required by the API Browser. +- **MRT (Managed Runtime)** — `mrtProject` + `mrtEnvironment` (in `dw.json`) + `MRT_API_KEY` (in `~/.mobify` or env var). + +## Resolution precedence + +Highest first — the first source that supplies a value wins for that field: + +1. CLI flags & environment variables +2. Plugin sources at high priority (Keychain, Password Store, IntelliJ Config) +3. `dw.json` +4. `~/.mobify` +5. Plugin sources at low priority +6. `package.json` + +So **environment variables always override `dw.json`** for the same field — useful for CI overrides without touching the file. + +## Inspecting what actually resolved + +Run **B2C DX - Getting Started: Inspect Resolved Config (b2c setup inspect)** any time. It prints every resolved field with its source: `dw.json`, `env (SFCC_CLIENT_SECRET)`, `keychain (b2c-cli/dev)`, etc. Add `--unmask` to show secret values too. + +## Single vs. multi-instance dw.json + +The wizard always writes a `configs[]` array, even for a single instance — that lets you add a second entry later without reshaping the file: + +```json +{ + "configs": [ + { + "name": "dev", + "active": true, + "hostname": "abcd-123.dx.commercecloud.salesforce.com", + "code-version": "version1", + "short-code": "kv7kzm78", + "tenant-id": "zzrf_001" + } + ] +} +``` + +Switch the active instance from the status bar (click the `$(cloud)` item) or via `b2c setup instance set-active `. + +## .gitignore + +The wizard appends `dw.json` to your workspace `.gitignore` automatically. Even if every secret lives outside the file, `dw.json` can still expose hostnames and tenant IDs. + +[Full configuration reference](https://salesforcecommercecloud.github.io/b2c-developer-tooling/guide/configuration.html) · [Third-party plugins](https://salesforcecommercecloud.github.io/b2c-developer-tooling/guide/third-party-plugins.html) · [`b2c setup inspect`](https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/setup.html) diff --git a/packages/b2c-vs-extension/media/walkthrough/install-cli.md b/packages/b2c-vs-extension/media/walkthrough/install-cli.md new file mode 100644 index 000000000..ebf0b877a --- /dev/null +++ b/packages/b2c-vs-extension/media/walkthrough/install-cli.md @@ -0,0 +1,41 @@ +# Install the B2C CLI + +The B2C CLI (`b2c`) drives deploys, log tailing, sandbox management, and more from the terminal. The VS Code extension uses it under the hood for some commands. + +> **Optional.** You can use the extension's Cartridges, WebDAV, and Sandbox views without the CLI. Install it when you want to script the same operations from the terminal or CI. + +## Prerequisites + +- **Node.js** — v22.0.0 or newer required +- **npm** — included with Node.js (used for global install) +- **npx** — included with Node.js (used for one-off runs) +- **Homebrew** — optional, alternative install method on macOS/Linux + +## Install + +Pick whichever fits your toolchain. The published docs list these three: + +- **npm** — `npm install -g @salesforce/b2c-cli` +- **Homebrew** — `brew install salesforcecommercecloud/tools/b2c-cli` +- **npx** — `npx @salesforce/b2c-cli --help` + +## Verify + +After install, confirm the CLI is on your PATH with `b2c --version`. + +Or click **Verify CLI** above — the extension detects the installed version and checks for updates automatically. + +## What it unlocks + +- `b2c code:deploy` — same flow the Cartridges view uses, scriptable from CI. +- `b2c sandbox:*` — create/start/stop/delete sandboxes from the terminal. +- `b2c log:tail` — stream instance logs. +- `b2c auth:*` — non-interactive OAuth client login for pipelines. + +## Troubleshooting + +- **Command not found after `npm install -g`** — your global npm prefix isn't on PATH. Run `npm config get prefix` and add `/bin` to PATH. +- **EACCES on install** — use a Node version manager (`nvm`, `fnm`, `volta`) instead of `sudo npm`. Avoid `sudo`. +- **Old version behaves oddly** — run **Update CLI** (or `npm install -g @salesforce/b2c-cli@latest`) to pin to the latest published release. + +[Full installation guide on the docs site](https://salesforcecommercecloud.github.io/b2c-developer-tooling/guide/installation.html) diff --git a/packages/b2c-vs-extension/media/walkthrough/next-steps.md b/packages/b2c-vs-extension/media/walkthrough/next-steps.md new file mode 100644 index 000000000..5165dc2e0 --- /dev/null +++ b/packages/b2c-vs-extension/media/walkthrough/next-steps.md @@ -0,0 +1,33 @@ +# You're set up — what's next + +You've got a connected instance, a deployed cartridge, and a scaffold workflow. Here's where to go from here. + +## Day-to-day commands + +| Goal | Command | +|---|---| +| Browse remote files | **B2C DX: List WebDAV** or open the WebDAV view | +| Watch & auto-deploy | **B2C DX - Code Sync: Toggle Code Sync** | +| Tail instance logs | **B2C DX - Logs: Start Tailing Logs** | +| Switch active instance | Click the `$(cloud)` item in the status bar | +| Inspect resolved config | **B2C DX: B2C Instance Config** | + +## Features worth exploring + +- **API Browser** — interactive Swagger for your instance's SCAPI specs. Needs OAuth. +- **Sandbox Explorer** — start, stop, restart, extend, and create sandboxes. Needs OAuth. +- **B2C Script Debugger** — set breakpoints in server-side `.js`/`.ds` files; F5 to attach. +- **Commerce App Packages (CAP)** — install B2C apps from a `commerce-app.json`. +- **Page Designer Assistant** — generate Page Designer page files from a guided UI. + +## Going further + +- [Documentation site](https://salesforcecommercecloud.github.io/b2c-developer-tooling/) — CLI reference, SDK, MCP, Agent Skills. +- [Issues](https://github.com/SalesforceCommerceCloud/b2c-developer-tooling/issues) — bug reports & feature requests. +- [SFRA reference](https://github.com/SalesforceCommerceCloud/storefront-reference-architecture) — cartridge patterns to learn from. + +## Re-open this guide + +Run **B2C DX: Open Getting Started Guide** from the Command Palette (`Cmd/Ctrl+Shift+P`). + +Open the role-based deep-dive any time with **B2C DX: Open Onboarding Panel**. diff --git a/packages/b2c-vs-extension/media/walkthrough/oauth-setup.md b/packages/b2c-vs-extension/media/walkthrough/oauth-setup.md new file mode 100644 index 000000000..52d7e7eae --- /dev/null +++ b/packages/b2c-vs-extension/media/walkthrough/oauth-setup.md @@ -0,0 +1,69 @@ +# Set Up OAuth Credentials + +OAuth credentials enable advanced features like **Sandbox Management** and **API Browser**. + +## What You Need + +Add these fields to your `dw.json`: + +```json +{ + "hostname": "your-sandbox.demandware.net", + "username": "your-username", + "password": "your-password", + "clientId": "your-client-id", + "clientSecret": "your-client-secret", + "shortCode": "your-short-code" +} +``` + +## Getting OAuth Credentials + +### 1. Log in to Account Manager +Visit [https://account.demandware.com/](https://account.demandware.com/) + +### 2. Create an API Client +- Navigate to **API Client** section +- Click **Add API Client** +- Configure the client with these scopes: + - `sfcc.sandboxes.rw` (for sandbox management) + - `sfcc.shopper-*` (for SCAPI browsing) + +### 3. Copy Credentials +After creating the client, you'll receive: +- **Client ID**: Unique identifier for your API client +- **Client Secret**: Secret key (save this immediately!) +- **Short Code**: Your organization's short code + +### 4. Add to dw.json +```json +{ + "clientId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "clientSecret": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "shortCode": "your_short_code" +} +``` + +## What OAuth Enables + +✅ **Sandbox Explorer** +- Create and delete sandboxes +- Start, stop, and restart sandboxes +- Extend sandbox expiration +- Open Business Manager directly + +✅ **API Browser** +- Browse SCAPI OpenAPI specifications +- View interactive Swagger UI documentation +- Test API endpoints + +## Optional Step + +OAuth is **optional** for basic development. You can skip this step if you only need: +- WebDAV browsing +- Cartridge deployment +- Code Sync + +--- + +When you're ready, click **Refresh** in the Sandbox Explorer to verify your OAuth setup! diff --git a/packages/b2c-vs-extension/media/walkthrough/sandbox-explorer.md b/packages/b2c-vs-extension/media/walkthrough/sandbox-explorer.md new file mode 100644 index 000000000..63a3633d7 --- /dev/null +++ b/packages/b2c-vs-extension/media/walkthrough/sandbox-explorer.md @@ -0,0 +1,117 @@ +# Sandbox Explorer + +Manage your B2C Commerce development sandboxes directly from VS Code! + +## Prerequisites + +⚠️ **Requires OAuth credentials** configured in `dw.json`. If you haven't set up OAuth yet, go back to the "Set Up OAuth Credentials" step. + +## What is a Sandbox? + +A sandbox is an isolated B2C Commerce development environment where you can: +- Develop and test code changes +- Import/export data +- Configure site settings +- Test storefront functionality + +Each sandbox has its own: +- Database +- Code versions +- Configuration +- Users and permissions + +## Opening the Sandbox Explorer + +Click **Open Sandbox Explorer** above, or: +- Open the **B2C-DX Sandboxes** activity bar icon (left sidebar) +- View the **Realm Explorer** + +## Key Concepts + +### Realm +Your **realm** is your organization's collection of sandboxes. One realm can have multiple sandboxes. + +### Sandbox States +- 🟢 **Started**: Sandbox is running and accessible +- 🔴 **Stopped**: Sandbox is paused (saves resources) +- 🟡 **Starting**: Sandbox is booting up +- 🟠 **Stopping**: Sandbox is shutting down + +## Common Operations + +### Add a Realm +1. Click the **+** icon in Realm Explorer +2. Enter your realm name (short code) +3. Credentials will be used from `dw.json` + +### Create a Sandbox +1. Right-click your realm +2. Select **Create Sandbox** +3. Enter a sandbox name +4. Wait for provisioning (2-5 minutes) + +### Start a Sandbox +1. Right-click a stopped sandbox +2. Select **Start Sandbox** +3. Wait for startup (~1-2 minutes) + +### Stop a Sandbox +1. Right-click a started sandbox +2. Select **Stop Sandbox** +3. Confirm the action + +### Restart a Sandbox +1. Right-click a started sandbox +2. Select **Restart Sandbox** +3. Useful for clearing cache or applying changes + +### View Sandbox Details +1. Right-click any sandbox +2. Select **View Details** +3. See status, expiration, hostname, etc. + +### Open Business Manager +1. Right-click a started sandbox +2. Select **Open Business Manager** +3. BM opens in your default browser + +### Extend Sandbox Expiration +1. Right-click any sandbox +2. Select **Extend Expiration** +3. Choose extension period +4. Prevents automatic deletion + +### Delete a Sandbox +1. Right-click any sandbox +2. Select **Delete Sandbox** +3. Confirm deletion +4. ⚠️ **Warning**: This is permanent! + +## Status Bar Integration + +When connected to a sandbox, the status bar shows: +- **☁️ Instance name**: Click to switch instances +- **$(pinned)**: Indicates pinned project root + +## Tips + +💡 **Stop when not in use**: Save resources by stopping sandboxes you're not actively using. + +💡 **Watch expiration dates**: Sandboxes auto-delete after expiration. Extend them regularly! + +💡 **One realm per team**: Share a realm with your team for easier collaboration. + +💡 **Test on multiple sandboxes**: Use different sandboxes for feature branches or testing. + +## Sandbox Lifecycle Best Practices + +1. **Start** a sandbox when you begin working +2. **Develop** and deploy code changes +3. **Test** on the sandbox storefront +4. **Stop** the sandbox when done for the day +5. **Extend** expiration if working on long-term features +6. **Delete** when completely done with a feature + +--- + +Click **Open Sandbox Explorer** to start managing your sandboxes! diff --git a/packages/b2c-vs-extension/media/walkthrough/webdav-browser.md b/packages/b2c-vs-extension/media/walkthrough/webdav-browser.md new file mode 100644 index 000000000..9709889c6 --- /dev/null +++ b/packages/b2c-vs-extension/media/walkthrough/webdav-browser.md @@ -0,0 +1,66 @@ +# WebDAV Browser + +The **WebDAV Browser** lets you explore and edit files directly on your B2C Commerce instance. + +## What is WebDAV? + +WebDAV (Web Distributed Authoring and Versioning) is a protocol that allows you to access files on your B2C instance as if they were local files. + +## Opening the WebDAV Browser + +Click **Open WebDAV Browser** above, or: +- Open the **B2C-DX** activity bar icon (left sidebar) +- Find the **WebDAV Browser** view + +## What You Can Browse + +### 📁 Cartridges +View and edit cartridge code deployed to your instance. + +### 📚 Libraries +Browse content libraries (Page Designer content, images, etc.). + +### 🛍️ Catalogs +Access product catalog data and imports. + +## Common Actions + +### Open a Remote File +- Click any file in the WebDAV tree +- Edit directly in VS Code +- Save to upload changes to the instance + +### Upload a File +- Right-click a folder +- Select **Upload File** +- Choose a local file to upload + +### Create New Files/Folders +- Right-click a folder +- Select **New File** or **New Folder** + +### Download Files +- Right-click a file +- Select **Download** +- Choose local save location + +### Mount as Workspace +- Right-click a folder +- Select **Open as Workspace Folder** +- Browse the remote folder as if it were local! + +## Tips + +💡 **Browse before deploying**: Check what's currently on your instance before uploading new code. + +💡 **Quick edits**: Make small fixes directly on the instance without a full deployment. + +💡 **Compare versions**: Download a cartridge from the instance to compare with your local version. + +## Performance Note + +The WebDAV browser loads folders on-demand. Large directories may take a moment to load. + +--- + +Click **Open WebDAV Browser** to explore your instance! diff --git a/packages/b2c-vs-extension/media/walkthrough/welcome-hero-dark.svg b/packages/b2c-vs-extension/media/walkthrough/welcome-hero-dark.svg new file mode 100644 index 000000000..a2a97a29b --- /dev/null +++ b/packages/b2c-vs-extension/media/walkthrough/welcome-hero-dark.svg @@ -0,0 +1,241 @@ + + + Pick your starting role + Welcome to B2C DX. Choose one of four roles — Storefront, API/Integration, DevOps/Release, or AI-augmented — to tailor your onboarding path. Each role lists its step count and average completion time. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PHASE 0 / 5 + + + + + WELCOME · TAILOR YOUR PATH + + + + + Pick your starting role. + + + + + Different roles need different things first. Choose one for a tailored deep-dive, + + + or follow the universal five-phase flow shown on the left. + + + + + + + 4 + ROLES + + + + 5 + PHASES + + + + ~30 MIN + AVG TIME + + + + + DOC-BACKED + + + + + + + + + + + + + + + + + + Storefront developer + SFRA · PWA Kit · ISML + Cartridge authoring, fast iteration with Code Sync, + and the WebDAV browser. + 7 PHASES · ~25 MIN + + + + + + + + + + + + + + + + + API / integration developer + SCAPI · OCAPI · jobs · hooks + OAuth setup and the API Browser front-and-center. + Code Sync optional. + 7 PHASES · ~30 MIN + + + + + + + + + + + + + + + + + + + DevOps / release engineer + sandboxes · code versions · CAPs + OAuth + Sandbox Explorer first; less time on + cartridge authoring. + 6 PHASES · ~20 MIN + + + + + + + + + + + + + + + + + + + + NEW + + AI-augmented developer + Cursor · Claude Code · Copilot + Storefront setup plus the documented MCP server + and Agent Skills. + 8 PHASES · ~30 MIN + + + + + + + + + + + Ready to begin? + + + Click Open role-based guide below to launch the deep-dive panel. + + + + Already set up? Mark all done → + + + + + + + + diff --git a/packages/b2c-vs-extension/media/walkthrough/welcome-hero-light.svg b/packages/b2c-vs-extension/media/walkthrough/welcome-hero-light.svg new file mode 100644 index 000000000..241d93eec --- /dev/null +++ b/packages/b2c-vs-extension/media/walkthrough/welcome-hero-light.svg @@ -0,0 +1,245 @@ + + + Pick your starting role + Welcome to B2C DX. Choose one of four roles — Storefront, API/Integration, DevOps/Release, or AI-augmented — to tailor your onboarding path. Each role lists its step count and average completion time. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PHASE 0 / 5 + + + + + WELCOME · TAILOR YOUR PATH + + + + + Pick your starting role. + + + + + Different roles need different things first. Choose one for a tailored deep-dive, + + + or follow the universal five-phase flow shown on the left. + + + + + + + 4 + ROLES + + + + 5 + PHASES + + + + ~30 MIN + AVG TIME + + + + + DOC-BACKED + + + + + + + + + + + + + + + + + + Storefront developer + SFRA · PWA Kit · ISML + Cartridge authoring, fast iteration with Code Sync, + and the WebDAV browser. + 7 PHASES · ~25 MIN + + + + + + + + + + + + + + + + + + API / integration developer + SCAPI · OCAPI · jobs · hooks + OAuth setup and the API Browser front-and-center. + Code Sync optional. + 7 PHASES · ~30 MIN + + + + + + + + + + + + + + + + + + + DevOps / release engineer + sandboxes · code versions · CAPs + OAuth + Sandbox Explorer first; less time on + cartridge authoring. + 6 PHASES · ~20 MIN + + + + + + + + + + + + + + + + + + + + + NEW + + AI-augmented developer + Cursor · Claude Code · Copilot + Storefront setup plus the documented MCP server + and Agent Skills. + 8 PHASES · ~30 MIN + + + + + + + + + + + Ready to begin? + + + Click Open role-based guide below to launch the deep-dive panel. + + + + + Already set up? Mark all done → + + + + + + + + + diff --git a/packages/b2c-vs-extension/media/walkthrough/welcome.md b/packages/b2c-vs-extension/media/walkthrough/welcome.md new file mode 100644 index 000000000..270c2420b --- /dev/null +++ b/packages/b2c-vs-extension/media/walkthrough/welcome.md @@ -0,0 +1,21 @@ +# Welcome to B2C Commerce on VS Code + +Five focused steps and you're deployed. + +## What you'll set up + +1. **Install the B2C CLI** *(optional — skip if you'll only use the views)* +2. **Configure your instance** — drop a `dw.json` at your workspace root +3. **Connect & authenticate** — verify the extension can talk to your sandbox +4. **Deploy your first cartridge** — push code to your active code version +5. **Generate from a scaffold** — boilerplate for new cartridges, controllers, pages + +## Pick a starting point + +Different roles need different things first. Open the **role-based deep-dive guide** for a tailored walkthrough — Storefront, API/Integration, DevOps/Release, or AI-augmented developer. + +The five-step path is the same for everyone. The deep-dive layers in the role-specific bits. + +## Already set up? + +Use **Mark all steps as done** in the last step (or the Command Palette command **B2C DX - Getting Started: Mark Getting Started as Done**) to tick everything at once. diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index a98d86496..4b4bceaca 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -203,23 +203,23 @@ "viewsWelcome": [ { "view": "b2cWebdavExplorer", - "contents": "No B2C Commerce instance configured.\n\nCreate a dw.json file in your workspace or set SFCC_* environment variables.\n\n[Refresh](command:b2c-dx.webdav.refresh)" + "contents": "No B2C Commerce instance configured.\n\n[Set up your instance](command:workbench.action.openWalkthrough?%5B%22Salesforce.b2c-vs-extension%23b2c-dx.gettingStarted%22%2C%22configure-instance%22%5D)\n\n[Refresh](command:b2c-dx.webdav.refresh)" }, { "view": "b2cContentExplorer", - "contents": "No content libraries configured.\n\nSet \"contentLibrary\" in dw.json or add a library manually.\n\n[Add Library](command:b2c-dx.content.addLibrary)" + "contents": "No content libraries configured.\n\n[Set up your instance](command:workbench.action.openWalkthrough?%5B%22Salesforce.b2c-vs-extension%23b2c-dx.gettingStarted%22%2C%22configure-instance%22%5D) to enable content browsing, then add a library.\n\n[Add Library](command:b2c-dx.content.addLibrary)" }, { "view": "b2cApiBrowser", - "contents": "Browse SCAPI OpenAPI schemas for your Commerce Cloud instance.\n\nRequires OAuth credentials (clientId, clientSecret) and shortCode in dw.json.\n\n[Load APIs](command:b2c-dx.apiBrowser.refresh)" + "contents": "Browse SCAPI OpenAPI schemas for your Commerce Cloud instance.\n\nRequires OAuth credentials (`client-id`, `client-secret`, `shortCode`) in dw.json.\n\n[Connect & authenticate](command:workbench.action.openWalkthrough?%5B%22Salesforce.b2c-vs-extension%23b2c-dx.gettingStarted%22%2C%22connect%22%5D)\n\n[Load APIs](command:b2c-dx.apiBrowser.refresh)" }, { "view": "b2cSandboxExplorer", - "contents": "No sandbox realms configured.\n\nSet \"realm\" in dw.json or add a realm manually.\n\n[Add Realm](command:b2c-dx.sandbox.addRealm)" + "contents": "No sandbox realms configured.\n\nSandbox management needs OAuth credentials in dw.json.\n\n[Connect & authenticate](command:workbench.action.openWalkthrough?%5B%22Salesforce.b2c-vs-extension%23b2c-dx.gettingStarted%22%2C%22connect%22%5D)\n\n[Add Realm](command:b2c-dx.sandbox.addRealm)" }, { "view": "b2cCartridgeExplorer", - "contents": "No cartridges found.\n\nCartridges are identified by .project files in the workspace.\n\n[Refresh](command:b2c-dx.codeSync.refreshCartridges)" + "contents": "No cartridges found.\n\nCartridges are folders containing a `.project` file.\n\n[Generate from a scaffold](command:workbench.action.openWalkthrough?%5B%22Salesforce.b2c-vs-extension%23b2c-dx.gettingStarted%22%2C%22scaffold%22%5D)\n\n[Refresh](command:b2c-dx.codeSync.refreshCartridges)" }, { "view": "b2cCipAnalytics", @@ -704,6 +704,208 @@ "title": "Refresh Script API IntelliSense", "icon": "$(refresh)", "category": "B2C DX" + }, + { + "command": "b2c-dx.walkthrough.createDwJson", + "title": "Create dw.json Configuration", + "category": "B2C DX - Getting Started" + }, + { + "command": "b2c-dx.walkthrough.open", + "title": "Open Getting Started Guide", + "category": "B2C DX" + }, + { + "command": "b2c-dx.walkthrough.markAllDone", + "title": "Mark Getting Started as Done", + "category": "B2C DX - Getting Started" + }, + { + "command": "b2c-dx.cli.verify", + "title": "Verify B2C CLI Installation", + "category": "B2C DX - Getting Started" + }, + { + "command": "b2c-dx.cli.update", + "title": "Update B2C CLI to Latest", + "category": "B2C DX - Getting Started" + }, + { + "command": "b2c-dx.cli.installNpm", + "title": "Install B2C CLI via npm", + "category": "B2C DX - Getting Started" + }, + { + "command": "b2c-dx.cli.installBrew", + "title": "Install B2C CLI via Homebrew", + "category": "B2C DX - Getting Started" + }, + { + "command": "b2c-dx.cli.recheck", + "title": "Re-check B2C CLI Installation", + "category": "B2C DX - Getting Started" + }, + { + "command": "b2c-dx.walkthrough.chooseCredentialStorage", + "title": "Configure Instance (Wizard)", + "category": "B2C DX - Getting Started" + }, + { + "command": "b2c-dx.walkthrough.inspectSetup", + "title": "Inspect Resolved Config (b2c setup inspect)", + "category": "B2C DX - Getting Started" + }, + { + "command": "b2c-dx.setup.connection", + "title": "Setup · Connection (instance + hostname)", + "category": "B2C DX - Getting Started" + }, + { + "command": "b2c-dx.setup.oauth", + "title": "Setup · OAuth Credentials", + "category": "B2C DX - Getting Started" + }, + { + "command": "b2c-dx.setup.webdav", + "title": "Setup · WebDAV Credentials", + "category": "B2C DX - Getting Started" + }, + { + "command": "b2c-dx.setup.scapi", + "title": "Setup · SCAPI Configuration", + "category": "B2C DX - Getting Started" + }, + { + "command": "b2c-dx.setup.resetSession", + "title": "Reset Setup Session (Start over from connection step)", + "category": "B2C DX - Getting Started" + }, + { + "command": "b2c-dx.theme.toggle", + "title": "Toggle Light / Dark Theme", + "category": "B2C DX" + }, + { + "command": "b2c-dx.onboarding.open", + "title": "Open Onboarding Panel", + "category": "B2C DX" + }, + { + "command": "b2c-dx.onboarding.reset", + "title": "Reset Onboarding Progress", + "category": "B2C DX" + }, + { + "command": "b2c-dx.onboarding.changePersona", + "title": "Change Onboarding Role", + "category": "B2C DX" + }, + { + "command": "b2c-dx.walkthrough.validate", + "title": "Validate Walkthrough Configuration", + "category": "B2C DX - Development" + }, + { + "command": "b2c-dx.walkthrough.checkAccessibility", + "title": "Check Walkthrough Accessibility", + "category": "B2C DX - Development" + }, + { + "command": "b2c-dx.walkthrough.showTelemetry", + "title": "Show Walkthrough Telemetry", + "category": "B2C DX - Development" + } + ], + "walkthroughs": [ + { + "id": "b2c-dx.gettingStarted", + "title": "B2C DX — Developer Onboarding", + "description": "A guided 5-phase setup — install, configure, connect, deploy, scaffold — for the Salesforce B2C Commerce Developer Experience extension. Most teams complete this in under 30 minutes.", + "icon": "media/b2c-icon.svg", + "steps": [ + { + "id": "pick-start", + "title": "Phase 0 · Choose your role", + "description": "Different roles need different things first. Open the **role-based deep-dive** for a path tailored to how you'll use B2C Commerce — Storefront, API/Integration, DevOps/Release, or AI-augmented developer. Or skip ahead and follow the universal five-phase flow below.\n\n[Open role-based guide](command:b2c-dx.onboarding.open)\n\nAlready set up? [Mark all phases as done](command:b2c-dx.walkthrough.markAllDone).", + "media": { + "image": { + "light": "media/walkthrough/welcome-hero-light.svg", + "dark": "media/walkthrough/welcome-hero-dark.svg", + "hc": "media/walkthrough/welcome-hero-dark.svg", + "hcLight": "media/walkthrough/welcome-hero-light.svg" + }, + "altText": "B2C DX wordmark above a vertical five-step timeline: Install the B2C CLI (optional), Configure your instance (dw.json), Connect and authenticate, Deploy your first cartridge, and Generate from a scaffold." + }, + "completionEvents": [ + "onCommand:b2c-dx.onboarding.open", + "onCommand:b2c-dx.walkthrough.markAllDone" + ] + }, + { + "id": "install-cli", + "title": "Phase 1 · Install the B2C CLI", + "description": "**Optional.** The B2C CLI powers deploys, log tailing, and sandbox commands from the terminal. Skip if you'll only use the VS Code views.\n\nInstall via `npm install -g @salesforce/b2c-cli` or `brew install salesforcecommercecloud/tools/b2c-cli`. \n\nRequires Node.js 22+.\n\n[Verify installation](command:b2c-dx.cli.verify) · [Update to latest](command:b2c-dx.cli.update)", + "media": { + "markdown": "media/walkthrough/install-cli.md" + }, + "completionEvents": [ + "onCommand:b2c-dx.cli.verify", + "onContext:b2c-dx.cliInstalled", + "onCommand:b2c-dx.walkthrough.markAllDone" + ] + }, + { + "id": "configure-instance", + "title": "Phase 2 · Configure your instance", + "description": "Run the configuration wizard. Non-secret fields (hostname, code-version, short-code, tenant-id, mrtProject, mrtEnvironment) go into `dw.json`; secret pairs (OAuth `client-id`+`client-secret`, Basic `username`+`password`, MRT API key) are placed independently — Keychain, `pass`, env vars, or dw.json — per the documented Credential Grouping rule.\n\n[Run setup wizard](command:b2c-dx.walkthrough.chooseCredentialStorage) · [Inspect resolved config](command:b2c-dx.walkthrough.inspectSetup) · [Create dw.json (manual)](command:b2c-dx.walkthrough.createDwJson)", + "media": { + "markdown": "media/walkthrough/dw-json-setup.md" + }, + "completionEvents": [ + "onCommand:b2c-dx.walkthrough.chooseCredentialStorage", + "onCommand:b2c-dx.walkthrough.createDwJson", + "onContext:b2c-dx.dwJsonExists", + "onCommand:b2c-dx.walkthrough.markAllDone" + ] + }, + { + "id": "connect", + "title": "Phase 3 · Connect & authenticate", + "description": "Verify your config resolves and the extension can reach your instance. The active instance shows in the status bar (bottom-left).\n\nOAuth fields (`client-id`, `client-secret`, `short-code`) unlock the Sandbox Explorer and API Browser; basic auth (`username`, `password`) is enough for WebDAV and cartridge deploys.\n\n[Inspect resolved config](command:b2c-dx.walkthrough.inspectSetup) — runs `b2c setup inspect` and shows where each value came from (file / env / keychain).\n\n[Inspect active instance](command:b2c-dx.instance.inspect) · [Open Realm Explorer](command:workbench.view.extension.b2c-dx-sandboxes)", + "media": { + "markdown": "media/walkthrough/oauth-setup.md" + }, + "completionEvents": [ + "onContext:b2c-dx.instanceConnected", + "onCommand:b2c-dx.instance.inspect", + "onCommand:b2c-dx.walkthrough.markAllDone" + ] + }, + { + "id": "deploy-cartridge", + "title": "Phase 4 · Deploy your first cartridge", + "description": "Cartridges are folders containing a `.project` file. The extension auto-detects them in your workspace and lists them under the Cartridges view.\n\n[Deploy all cartridges](command:b2c-dx.codeSync.deploy)\n\n[Open Cartridges view](command:workbench.view.extension.b2c-dx)", + "media": { + "markdown": "media/walkthrough/deploy-cartridge.md" + }, + "completionEvents": [ + "onCommand:b2c-dx.codeSync.deploy", + "onCommand:b2c-dx.walkthrough.markAllDone" + ] + }, + { + "id": "scaffold", + "title": "Phase 5 · Generate from a scaffold", + "description": "Generate boilerplate for cartridges, controllers, models, and Page Designer pages without leaving VS Code.\n\nRight-click a folder in the Explorer → **B2C DX → New from Scaffold…** or run the command directly.\n\n[New from Scaffold…](command:b2c-dx.scaffold.generate)\n\n---\n\nFinished early? [Mark all steps as done](command:b2c-dx.walkthrough.markAllDone).", + "media": { + "markdown": "media/walkthrough/next-steps.md" + }, + "completionEvents": [ + "onCommand:b2c-dx.scaffold.generate", + "onCommand:b2c-dx.walkthrough.markAllDone" + ] + } + ] } ], "menus": { diff --git a/packages/b2c-vs-extension/src/extension.ts b/packages/b2c-vs-extension/src/extension.ts index 2eb5a7ab1..afd89646b 100644 --- a/packages/b2c-vs-extension/src/extension.ts +++ b/packages/b2c-vs-extension/src/extension.ts @@ -6,7 +6,9 @@ import {DwJsonSource} from '@salesforce/b2c-tooling-sdk/config'; import {configureLogger} from '@salesforce/b2c-tooling-sdk/logging'; +import * as cp from 'child_process'; import * as fs from 'fs'; +import * as https from 'https'; import * as path from 'path'; import * as vscode from 'vscode'; import {B2CExtensionConfig} from './config-provider.js'; @@ -26,6 +28,15 @@ import {registerScriptTypes} from './script-types/index.js'; import {registerWebDavTree} from './webdav-tree/index.js'; import {disposeTelemetry, initTelemetry, markFeatureUsed, sendEvent, sendException} from './telemetry.js'; import {registerCipAnalytics} from './cip-analytics/index.js'; +import { + registerWalkthroughCommands, + showWalkthroughOnFirstActivation, + initializeTelemetry, + validateWalkthroughCommand, + checkWalkthroughAccessibilityCommand, + OnboardingStateStore, + OnboardingPanel, +} from './walkthrough/index.js'; function getWebviewContent(context: vscode.ExtensionContext): string { const htmlPath = path.join(context.extensionPath, 'src', 'webview.html'); @@ -113,6 +124,109 @@ function applyLogLevel(log: vscode.OutputChannel): void { } } +interface CliDetectionResult { + installed: boolean; + version?: string; + latestVersion?: string; + isOutdated?: boolean; +} + +/** Extracts a `1.2.3` (with optional `-pre.4` etc.) tail from any string. */ +function parseSemver(raw: string | undefined): string | undefined { + if (!raw) return undefined; + const match = raw.match(/(\d+\.\d+\.\d+(?:[-+][\w.]+)?)/); + return match ? match[1] : undefined; +} + +function compareSemver(a: string, b: string): number { + // Compare numeric major.minor.patch only; treat any pre-release as + // older-than-stable (mirrors npm's behaviour for "is there a newer release"). + const norm = (v: string) => + v + .split(/[-+]/)[0] + .split('.') + .map((n) => parseInt(n, 10) || 0); + const [aA, aB] = [norm(a), norm(b)]; + for (let i = 0; i < 3; i++) { + if ((aA[i] ?? 0) !== (aB[i] ?? 0)) return (aA[i] ?? 0) - (aB[i] ?? 0); + } + // Numeric parts equal — anything with a pre-release tag is older than a clean one. + const aPre = a.includes('-'); + const bPre = b.includes('-'); + if (aPre !== bPre) return aPre ? -1 : 1; + return 0; +} + +const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@salesforce%2Fb2c-cli/latest'; +const LATEST_CACHE_KEY = 'b2c-dx.cli.latestVersionCache'; +const LATEST_CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6h + +interface LatestCache { + version: string; + fetchedAt: number; +} + +async function fetchLatestCliVersion(context: vscode.ExtensionContext): Promise { + const cached = context.globalState.get(LATEST_CACHE_KEY); + if (cached && Date.now() - cached.fetchedAt < LATEST_CACHE_TTL_MS) { + return cached.version; + } + try { + const fetched = await new Promise((resolve) => { + const req = https.get(NPM_REGISTRY_URL, {timeout: 4000}, (res) => { + if (res.statusCode !== 200) { + res.resume(); + resolve(undefined); + return; + } + let body = ''; + res.setEncoding('utf-8'); + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => { + try { + const parsed = JSON.parse(body); + const v = typeof parsed.version === 'string' ? parsed.version : undefined; + resolve(v); + } catch { + resolve(undefined); + } + }); + }); + req.on('timeout', () => { + req.destroy(); + resolve(undefined); + }); + req.on('error', () => resolve(undefined)); + }); + if (fetched) { + await context.globalState.update(LATEST_CACHE_KEY, {version: fetched, fetchedAt: Date.now()}); + } + return fetched; + } catch { + return undefined; + } +} + +async function detectB2cCli(context?: vscode.ExtensionContext): Promise { + const local = await new Promise((resolve) => { + cp.execFile('b2c', ['--version'], {timeout: 5000}, (err, stdout) => { + if (err) { + resolve({installed: false}); + return; + } + const versionRaw = stdout.toString().trim(); + resolve({installed: true, version: versionRaw || 'unknown'}); + }); + }); + if (!local.installed || !context) return local; + const latest = await fetchLatestCliVersion(context); + if (!latest) return local; + const localSem = parseSemver(local.version); + if (!localSem) return {...local, latestVersion: latest}; + const isOutdated = compareSemver(localSem, latest) < 0; + return {...local, latestVersion: latest, isOutdated}; +} + export async function activate(context: vscode.ExtensionContext) { const log = vscode.window.createOutputChannel('B2C DX'); @@ -159,6 +273,209 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu // before the first resolveConfig() call. Failures are non-fatal. await initializePlugins(); + // Initialize walkthrough telemetry + const walkthroughTelemetry = initializeTelemetry(log); + + // Register walkthrough commands early so they're available for first-time users + registerWalkthroughCommands(context); + + // Onboarding (next-gen walkthrough) state + panel + const onboardingStore = new OnboardingStateStore(context); + context.subscriptions.push(onboardingStore); + context.subscriptions.push( + vscode.commands.registerCommand('b2c-dx.onboarding.open', () => { + OnboardingPanel.show(context, onboardingStore, log); + }), + vscode.commands.registerCommand('b2c-dx.onboarding.reset', async () => { + await onboardingStore.reset(); + OnboardingPanel.show(context, onboardingStore, log); + }), + vscode.commands.registerCommand('b2c-dx.onboarding.changePersona', async () => { + await onboardingStore.setPersona(null); + OnboardingPanel.show(context, onboardingStore, log); + }), + ); + + // Register walkthrough validation commands (for development/testing) + context.subscriptions.push( + vscode.commands.registerCommand('b2c-dx.walkthrough.validate', async () => { + await validateWalkthroughCommand(context.extensionPath, log); + }), + vscode.commands.registerCommand('b2c-dx.walkthrough.checkAccessibility', async () => { + await checkWalkthroughAccessibilityCommand(context.extensionPath, log); + }), + vscode.commands.registerCommand('b2c-dx.walkthrough.showTelemetry', () => { + walkthroughTelemetry.logSummary(); + log.show(); + }), + ); + + // "Verify CLI" — runs `b2c --version`, queries npm for the latest, and + // reports back. Flips two context keys: + // b2c-dx.cliInstalled — auto-completes the install-cli walkthrough step. + // b2c-dx.cliOutdated — surfaces the "Update CLI" action when true. + context.subscriptions.push( + vscode.commands.registerCommand('b2c-dx.cli.verify', async () => { + const result = await detectB2cCli(context); + await vscode.commands.executeCommand('setContext', 'b2c-dx.cliInstalled', result.installed); + await vscode.commands.executeCommand('setContext', 'b2c-dx.cliOutdated', !!result.isOutdated); + + if (!result.installed) { + const action = await vscode.window.showWarningMessage( + 'B2C CLI not found on PATH. Install with `npm install -g @salesforce/b2c-cli` or `brew install salesforcecommercecloud/tools/b2c-cli`.', + 'Open Install Guide', + ); + if (action === 'Open Install Guide') { + await vscode.env.openExternal( + vscode.Uri.parse('https://salesforcecommercecloud.github.io/b2c-developer-tooling/guide/installation.html'), + ); + } + return; + } + + if (result.isOutdated && result.latestVersion) { + const action = await vscode.window.showInformationMessage( + `B2C CLI ${result.version} detected — newer version ${result.latestVersion} available.`, + 'Update now', + 'Copy update command', + 'Later', + ); + if (action === 'Update now') { + await vscode.commands.executeCommand('b2c-dx.cli.update'); + } else if (action === 'Copy update command') { + await vscode.env.clipboard.writeText('npm install -g @salesforce/b2c-cli@latest'); + vscode.window.showInformationMessage('Update command copied to clipboard.'); + } + return; + } + + const suffix = result.latestVersion ? ` (latest)` : ''; + vscode.window.showInformationMessage(`B2C CLI detected: ${result.version}${suffix}`); + }), + ); + + // "Install CLI via npm" — opens a terminal with the install command. + context.subscriptions.push( + vscode.commands.registerCommand('b2c-dx.cli.installNpm', async () => { + const term = vscode.window.createTerminal({name: 'B2C DX — CLI install'}); + term.show(); + term.sendText('npm install -g @salesforce/b2c-cli', false); + }), + ); + + // "Install CLI via Homebrew" — opens a terminal with the brew install command. + context.subscriptions.push( + vscode.commands.registerCommand('b2c-dx.cli.installBrew', async () => { + const term = vscode.window.createTerminal({name: 'B2C DX — CLI install'}); + term.show(); + term.sendText('brew install salesforcecommercecloud/tools/b2c-cli', false); + }), + ); + + // "Re-check CLI" — re-runs the CLI detection and refreshes state. + context.subscriptions.push( + vscode.commands.registerCommand('b2c-dx.cli.recheck', async () => { + const result = await detectB2cCli(context); + await vscode.commands.executeCommand('setContext', 'b2c-dx.cliInstalled', result.installed); + await vscode.commands.executeCommand('setContext', 'b2c-dx.cliOutdated', !!result.isOutdated); + if (result.installed) { + const suffix = + result.isOutdated && result.latestVersion ? ` (v${result.latestVersion} available)` : ' (latest)'; + vscode.window.showInformationMessage(`B2C CLI detected: ${result.version}${suffix}`); + } else { + vscode.window.showWarningMessage('B2C CLI still not found on PATH. Install it and try again.'); + } + }), + ); + + // "Update CLI" — opens a terminal preloaded with the npm update command. + // We never auto-execute: a global npm install can prompt for credentials + // or hit privilege errors, so the user runs it themselves. + context.subscriptions.push( + vscode.commands.registerCommand('b2c-dx.cli.update', async () => { + const cmd = 'npm install -g @salesforce/b2c-cli@latest'; + const choice = await vscode.window.showInformationMessage( + 'Update the B2C CLI to the latest version? This runs an npm global install — you may be prompted for permissions.', + {modal: true}, + 'Run in terminal', + 'Copy command', + 'Cancel', + ); + if (!choice || choice === 'Cancel') return; + if (choice === 'Run in terminal') { + const term = vscode.window.createTerminal({name: 'B2C DX — CLI update'}); + term.show(); + // Don't auto-execute — user presses Enter so they see the command first. + term.sendText(cmd, false); + } else { + await vscode.env.clipboard.writeText(cmd); + vscode.window.showInformationMessage('Update command copied to clipboard.'); + } + // Invalidate the cached "latest" so the next verify makes a fresh check. + await context.globalState.update(LATEST_CACHE_KEY, undefined); + }), + ); + + // "Mark all as done" — fires a single onCommand event that every walkthrough + // step lists in its completionEvents, ticking the entire walkthrough at once. + context.subscriptions.push( + vscode.commands.registerCommand('b2c-dx.walkthrough.markAllDone', async () => { + // Re-open the walkthrough so the user sees the freshly-ticked steps. + await vscode.commands.executeCommand( + 'workbench.action.openWalkthrough', + 'Salesforce.b2c-vs-extension#b2c-dx.gettingStarted', + false, + ); + vscode.window.showInformationMessage('B2C DX: Getting Started marked as complete.'); + }), + ); + + // Theme toggle — flips between the user's preferred light + dark themes. + // Persists the last-seen pair so a developer who customised their theme + // keeps that customisation across toggles. Falls back to VS Code's stock + // "Default Light Modern" / "Default Dark Modern" until each side has been + // observed at least once. + const THEME_LIGHT_KEY = 'b2c-dx.theme.preferredLight'; + const THEME_DARK_KEY = 'b2c-dx.theme.preferredDark'; + context.subscriptions.push( + vscode.commands.registerCommand('b2c-dx.theme.toggle', async () => { + const config = vscode.workspace.getConfiguration('workbench'); + const currentTheme = config.get('colorTheme') ?? ''; + const kind = vscode.window.activeColorTheme.kind; + const isDarkLike = kind === vscode.ColorThemeKind.Dark || kind === vscode.ColorThemeKind.HighContrast; + + // Remember whichever side we're leaving so the next toggle restores it. + if (isDarkLike) { + await context.globalState.update(THEME_DARK_KEY, currentTheme); + } else { + await context.globalState.update(THEME_LIGHT_KEY, currentTheme); + } + + const target = isDarkLike + ? (context.globalState.get(THEME_LIGHT_KEY) ?? 'Default Light Modern') + : (context.globalState.get(THEME_DARK_KEY) ?? 'Default Dark Modern'); + + try { + await config.update('colorTheme', target, vscode.ConfigurationTarget.Global); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`B2C DX: Could not switch theme — ${message}`); + } + }), + ); + + // Initialize the cliInstalled context key once (best-effort, non-blocking). + void detectB2cCli(context).then((r) => { + void vscode.commands.executeCommand('setContext', 'b2c-dx.cliInstalled', r.installed); + void vscode.commands.executeCommand('setContext', 'b2c-dx.cliOutdated', !!r.isOutdated); + }); + + // Initialize the setup-session context keys from workspaceState so welcome + // views can react on first frame. + const sessionInstance = context.workspaceState.get('b2c-dx.setup.activeInstance'); + void vscode.commands.executeCommand('setContext', 'b2c-dx.setupSessionActive', !!sessionInstance); + void vscode.commands.executeCommand('setContext', 'b2c-dx.setupInstance', sessionInstance); + registerJobLogViewer(context); const configProvider = new B2CExtensionConfig(log, context.workspaceState); @@ -170,6 +487,39 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu const cartridgeService = new CartridgeService(configProvider); context.subscriptions.push(cartridgeService); + // Walkthrough context keys: drive auto-completion of the native walkthrough + // steps. dwJsonExists tracks the per-workspace dw.json file; instanceConnected + // mirrors whether the config provider successfully resolved a config. + const updateInstanceConnectedContext = () => { + const connected = !!configProvider.getConfig(); + void vscode.commands.executeCommand('setContext', 'b2c-dx.instanceConnected', connected); + }; + updateInstanceConnectedContext(); + configProvider.onDidReset(() => updateInstanceConnectedContext()); + + const updateDwJsonContext = async () => { + const folders = vscode.workspace.workspaceFolders ?? []; + let exists = false; + for (const folder of folders) { + try { + await vscode.workspace.fs.stat(vscode.Uri.joinPath(folder.uri, 'dw.json')); + exists = true; + break; + } catch { + // not in this folder + } + } + await vscode.commands.executeCommand('setContext', 'b2c-dx.dwJsonExists', exists); + }; + void updateDwJsonContext(); + const dwJsonWatcher = vscode.workspace.createFileSystemWatcher('**/dw.json'); + context.subscriptions.push( + dwJsonWatcher, + dwJsonWatcher.onDidCreate(() => void updateDwJsonContext()), + dwJsonWatcher.onDidDelete(() => void updateDwJsonContext()), + vscode.workspace.onDidChangeWorkspaceFolders(() => void updateDwJsonContext()), + ); + const disposable = vscode.commands.registerCommand('b2c-dx.openUI', () => { markFeatureUsed('pageDesigner'); vscode.window.showInformationMessage('B2C DX: Opening Page Designer Assistant.'); @@ -462,4 +812,10 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu configChangeListener, ); log.appendLine('B2C DX extension activated.'); + + // Show walkthrough on first activation (optional, non-blocking) + // This runs asynchronously after activation is complete + showWalkthroughOnFirstActivation(context).catch((err) => { + log.appendLine(`Warning: Failed to show walkthrough: ${err instanceof Error ? err.message : String(err)}`); + }); } diff --git a/packages/b2c-vs-extension/src/scaffold/scaffold-commands.ts b/packages/b2c-vs-extension/src/scaffold/scaffold-commands.ts index 5194a9cd8..aa5b7f61c 100644 --- a/packages/b2c-vs-extension/src/scaffold/scaffold-commands.ts +++ b/packages/b2c-vs-extension/src/scaffold/scaffold-commands.ts @@ -187,6 +187,11 @@ async function runScaffoldWizard( // Source detected (e.g., cartridge) → use projectRoot because cartridgeNamePath is relative to it outputDir = projectRoot; log.appendLine(`[Scaffold] Output dir from source detection: ${outputDir}`); + } else if (scaffold.id === 'cartridge') { + // Cartridges always live under /cartridges so the Cartridges view + // and CLI find them without any extra configuration. + outputDir = path.join(projectRoot, 'cartridges'); + log.appendLine(`[Scaffold] Output dir for cartridge: ${outputDir}`); } else if (uri) { outputDir = uri.fsPath; log.appendLine(`[Scaffold] Output dir from context menu: ${outputDir}`); @@ -247,6 +252,12 @@ async function runScaffoldWizard( const doc = await vscode.workspace.openTextDocument(fileUri); await vscode.window.showTextDocument(doc); + // The fs watcher on **/.project misses files written outside any workspace folder + // and can race scaffold writes; refresh explicitly when a new .project landed. + if (created.some((f) => path.basename(f.path) === '.project')) { + await vscode.commands.executeCommand('b2c-dx.codeSync.refreshCartridges'); + } + // Show message with Reveal action for the output directory const action = await vscode.window.showInformationMessage( `Generated ${created.length} file(s) from ${scaffold.manifest.displayName} scaffold.`, diff --git a/packages/b2c-vs-extension/src/walkthrough/accessibility.ts b/packages/b2c-vs-extension/src/walkthrough/accessibility.ts new file mode 100644 index 000000000..a9bca60e0 --- /dev/null +++ b/packages/b2c-vs-extension/src/walkthrough/accessibility.ts @@ -0,0 +1,287 @@ +/* + * 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 * as vscode from 'vscode'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +/** + * Accessibility validation for walkthrough content. + * Ensures markdown content follows accessibility best practices. + */ + +interface AccessibilityIssue { + file: string; + line?: number; + severity: 'error' | 'warning' | 'info'; + rule: string; + message: string; +} + +/** + * Check walkthrough markdown files for accessibility issues + */ +export async function validateWalkthroughAccessibility(walkthroughDir: string): Promise { + const issues: AccessibilityIssue[] = []; + + try { + const files = await fs.readdir(walkthroughDir); + const mdFiles = files.filter((f) => f.endsWith('.md')); + + for (const file of mdFiles) { + const filePath = path.join(walkthroughDir, file); + const content = await fs.readFile(filePath, 'utf-8'); + const fileIssues = checkMarkdownAccessibility(file, content); + issues.push(...fileIssues); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + issues.push({ + file: walkthroughDir, + severity: 'error', + rule: 'validation-error', + message: `Failed to validate directory: ${message}`, + }); + } + + return issues; +} + +/** + * Check individual markdown file for accessibility issues + */ +function checkMarkdownAccessibility(filename: string, content: string): AccessibilityIssue[] { + const issues: AccessibilityIssue[] = []; + const lines = content.split('\n'); + + // Check 1: Images should have alt text + lines.forEach((line, index) => { + const imageRegex = /!\[(.*?)\]\((.*?)\)/g; + let match; + + while ((match = imageRegex.exec(line)) !== null) { + const altText = match[1]; + if (!altText || altText.trim().length === 0) { + issues.push({ + file: filename, + line: index + 1, + severity: 'error', + rule: 'image-alt-text', + message: 'Image must have descriptive alt text', + }); + } else if (altText.length < 10) { + issues.push({ + file: filename, + line: index + 1, + severity: 'warning', + rule: 'image-alt-text-short', + message: 'Alt text should be more descriptive (at least 10 characters)', + }); + } + } + }); + + // Check 2: Links should have descriptive text + lines.forEach((line, index) => { + const linkRegex = /\[(.*?)\]\((.*?)\)/g; + let match; + + while ((match = linkRegex.exec(line)) !== null) { + const linkText = match[1]; + const nonDescriptive = ['click here', 'here', 'link', 'read more']; + + if (nonDescriptive.some((phrase) => linkText.toLowerCase().includes(phrase))) { + issues.push({ + file: filename, + line: index + 1, + severity: 'warning', + rule: 'link-descriptive-text', + message: `Link text "${linkText}" is not descriptive. Use text that describes the destination.`, + }); + } + + if (linkText.trim().length === 0) { + issues.push({ + file: filename, + line: index + 1, + severity: 'error', + rule: 'link-empty-text', + message: 'Link must have text content', + }); + } + } + }); + + // Check 3: Headings should follow hierarchy + const headingLevels: number[] = []; + lines.forEach((line, index) => { + const headingMatch = line.match(/^(#{1,6})\s/); + if (headingMatch) { + const level = headingMatch[1].length; + headingLevels.push(level); + + // Check if heading skips levels + if (headingLevels.length > 1) { + const prevLevel = headingLevels[headingLevels.length - 2]; + if (level > prevLevel + 1) { + issues.push({ + file: filename, + line: index + 1, + severity: 'warning', + rule: 'heading-hierarchy', + message: `Heading level ${level} skips level ${prevLevel + 1}. Maintain heading hierarchy.`, + }); + } + } + } + }); + + // Check 4: Code blocks should have language specified + lines.forEach((line, index) => { + if (line.trim().startsWith('```') && line.trim() === '```') { + issues.push({ + file: filename, + line: index + 1, + severity: 'info', + rule: 'code-block-language', + message: 'Code block should specify language for syntax highlighting', + }); + } + }); + + // Check 5: Color-only information + const colorKeywords = ['red', 'green', 'blue', 'yellow', 'color']; + lines.forEach((line, index) => { + colorKeywords.forEach((keyword) => { + if ( + line.toLowerCase().includes(keyword) && + !line.includes('$(') && // Exclude icon references + !line.includes('```') + ) { + // Exclude code blocks + issues.push({ + file: filename, + line: index + 1, + severity: 'info', + rule: 'color-only-information', + message: `Line mentions "${keyword}". Ensure information is not conveyed by color alone.`, + }); + } + }); + }); + + // Check 6: Emoji usage + const emojiRegex = + /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u; + lines.forEach((line, index) => { + if (emojiRegex.test(line)) { + // Emojis are generally okay in headings and list items for visual interest + // but should not be the only way to convey information + const emojiCount = (line.match(new RegExp(emojiRegex, 'gu')) || []).length; + if (emojiCount > 3) { + issues.push({ + file: filename, + line: index + 1, + severity: 'info', + rule: 'excessive-emoji', + message: 'Line contains many emoji. Consider if they add value or just clutter.', + }); + } + } + }); + + // Check 7: Table accessibility + lines.forEach((line, index) => { + if (line.includes('|') && line.trim().startsWith('|')) { + // This is likely a table row + const nextLine = lines[index + 1]; + if (nextLine && nextLine.includes('---')) { + // This is a table header row, which is good + } else if (!lines.slice(Math.max(0, index - 2), index).some((l) => l.includes('---'))) { + issues.push({ + file: filename, + line: index + 1, + severity: 'info', + rule: 'table-headers', + message: 'Tables should have header rows for accessibility', + }); + } + } + }); + + return issues; +} + +/** + * Format accessibility issues for display + */ +export function formatAccessibilityReport(issues: AccessibilityIssue[]): string { + if (issues.length === 0) { + return '✅ No accessibility issues found!'; + } + + const lines: string[] = ['=== Walkthrough Accessibility Report ===', `Found ${issues.length} issue(s)`, '']; + + const errorCount = issues.filter((i) => i.severity === 'error').length; + const warningCount = issues.filter((i) => i.severity === 'warning').length; + const infoCount = issues.filter((i) => i.severity === 'info').length; + + lines.push(`Errors: ${errorCount}`); + lines.push(`Warnings: ${warningCount}`); + lines.push(`Info: ${infoCount}`); + lines.push(''); + + // Group by file + const byFile = new Map(); + for (const issue of issues) { + const fileIssues = byFile.get(issue.file) || []; + fileIssues.push(issue); + byFile.set(issue.file, fileIssues); + } + + for (const [file, fileIssues] of byFile) { + lines.push(`File: ${file}`); + for (const issue of fileIssues) { + const severityIcon = issue.severity === 'error' ? '❌' : issue.severity === 'warning' ? '⚠️' : 'ℹ️'; + const location = issue.line ? ` Line ${issue.line}` : ''; + lines.push(` ${severityIcon} [${issue.rule}]${location}: ${issue.message}`); + } + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * VS Code command to check walkthrough accessibility + */ +export async function checkWalkthroughAccessibilityCommand( + extensionPath: string, + log: vscode.OutputChannel, +): Promise { + const walkthroughDir = path.join(extensionPath, 'media', 'walkthrough'); + + log.appendLine('Running accessibility validation...'); + + const issues = await validateWalkthroughAccessibility(walkthroughDir); + const report = formatAccessibilityReport(issues); + + log.appendLine(report); + log.show(); + + if (issues.length === 0) { + vscode.window.showInformationMessage('✅ No accessibility issues found in walkthrough!'); + } else { + const errorCount = issues.filter((i) => i.severity === 'error').length; + if (errorCount > 0) { + vscode.window.showErrorMessage(`Found ${errorCount} accessibility error(s). Check Output > B2C DX for details.`); + } else { + vscode.window.showWarningMessage( + `Found ${issues.length} accessibility issue(s). Check Output > B2C DX for details.`, + ); + } + } +} diff --git a/packages/b2c-vs-extension/src/walkthrough/commands.ts b/packages/b2c-vs-extension/src/walkthrough/commands.ts new file mode 100644 index 000000000..ab2a493db --- /dev/null +++ b/packages/b2c-vs-extension/src/walkthrough/commands.ts @@ -0,0 +1,1693 @@ +/* + * 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 * as vscode from 'vscode'; +import * as cp from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs/promises'; + +/** + * Template for a basic dw.json configuration file. + * Users should replace placeholder values with their actual credentials. + */ +const DW_JSON_TEMPLATE = { + hostname: 'your-sandbox-name.demandware.net', + username: 'your-username', + password: 'your-password', + version: 'v1', + // Optional OAuth credentials for advanced features + // Uncomment and fill in to enable Sandbox Management and API Browser + // clientId: 'your-client-id', + // clientSecret: 'your-client-secret', + // shortCode: 'your-short-code', +}; + +/** + * Template for dw.json with multiple instances configuration + */ +const DW_JSON_MULTI_INSTANCE_TEMPLATE = { + instances: [ + { + name: 'dev', + hostname: 'dev-sandbox.demandware.net', + username: 'your-username', + password: 'your-password', + // Optional OAuth credentials + // clientId: 'your-client-id', + // clientSecret: 'your-client-secret', + // shortCode: 'your-short-code', + }, + { + name: 'staging', + hostname: 'staging-sandbox.demandware.net', + username: 'your-username', + password: 'your-password', + }, + ], +}; + +/** + * Register walkthrough-related commands. + * These commands support the getting started walkthrough experience. + */ +export function registerWalkthroughCommands(context: vscode.ExtensionContext): void { + // Command: Open the getting started walkthrough. + // The new onboarding panel replaces the built-in walkthrough surface; we + // redirect this legacy command to keep existing menu entries working. + context.subscriptions.push( + vscode.commands.registerCommand('b2c-dx.walkthrough.open', async () => { + try { + await vscode.commands.executeCommand('b2c-dx.onboarding.open'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage(`Failed to open walkthrough: ${message}`); + } + }), + ); + + // Command: Create dw.json template file + context.subscriptions.push( + vscode.commands.registerCommand('b2c-dx.walkthrough.createDwJson', async () => { + await createDwJsonTemplate(); + }), + ); + + // Command: Credential-storage wizard. Field-level placement: non-secret + // connection fields go to dw.json, secret pairs are placed independently + // (Keychain / pass / env / dw.json) per Credential Grouping. + context.subscriptions.push( + vscode.commands.registerCommand('b2c-dx.walkthrough.chooseCredentialStorage', async () => { + await chooseCredentialStorage(context); + }), + ); + + // Command: `b2c setup inspect` — opens a terminal showing where each + // resolved config value came from (file / env / keychain / etc.). + context.subscriptions.push( + vscode.commands.registerCommand('b2c-dx.walkthrough.inspectSetup', async () => { + await openInspect(); + }), + ); + + // Per-step setup commands. Each one prompts only for the fields its step + // is responsible for; non-secret fields all go into the same `dw.json` + // configs[] entry (named once during the connection step and reused for + // the rest of the session). Secret pairs are placed independently per + // pair, exactly like the all-at-once wizard. + context.subscriptions.push( + vscode.commands.registerCommand('b2c-dx.setup.connection', async () => { + await runConnectionStep(context); + }), + vscode.commands.registerCommand('b2c-dx.setup.oauth', async () => { + await runOAuthStep(context); + }), + vscode.commands.registerCommand('b2c-dx.setup.webdav', async () => { + await runWebDavStep(context); + }), + vscode.commands.registerCommand('b2c-dx.setup.scapi', async () => { + await runScapiStep(context); + }), + vscode.commands.registerCommand('b2c-dx.setup.resetSession', async () => { + await resetSetupSession(context); + }), + ); +} + +// ─── Credential-storage wizard ───────────────────────── +// +// Walks the user through the documented placement model: +// • Non-secret connection fields → dw.json (single source of truth) +// • Secret pairs (OAuth, Basic) → Keychain / pass / env / dw.json (chosen +// independently per pair, per Credential +// Grouping rule). +// • SCAPI-only fields → dw.json +// • MRT credentials → ~/.mobify (managed by `b2c mrt +// save-credentials`); MRT_API_KEY env var. +// +// The flow asks pair-by-pair so a user can mix sources (OAuth in Keychain, +// WebDAV in dw.json) — the docs explicitly support this. + +type SecretPlacement = 'macos-keychain' | 'password-store' | 'env' | 'dw-json'; + +interface PlacementChoice extends vscode.QuickPickItem { + id: SecretPlacement; +} + +interface FlowChoice extends vscode.QuickPickItem { + id: 'oauth' | 'basic' | 'scapi' | 'mrt' | 'inspect' | 'done'; +} + +interface ConnectionConfig { + instanceName: string; + hostname: string; + codeVersion?: string; + shortCode?: string; + tenantId?: string; + oauthScopes?: string; + mrtProject?: string; + mrtEnvironment?: string; +} + +interface ConfigPlan { + connection: ConnectionConfig; + oauthPlacement?: SecretPlacement; + basicPlacement?: SecretPlacement; + mrtPlacement?: SecretPlacement; + enableSCAPI: boolean; + // Captured during the wizard so the apply phase can write them straight + // into the chosen target. Kept in-memory for the duration of the wizard + // call only; never persisted. + oauthClientId?: string; + oauthClientSecret?: string; + basicUsername?: string; + basicPassword?: string; + mrtApiKey?: string; +} + +/** Wrap showInputBox for the wizard's value-collection prompts. Returns + * `undefined` only if the user cancels (Esc); empty string is accepted so + * optional fields can be skipped without breaking flow. */ +async function secretInput(opts: { + title: string; + prompt: string; + placeholder?: string; + password?: boolean; +}): Promise { + const v = await vscode.window.showInputBox({ + title: opts.title, + prompt: opts.prompt, + placeHolder: opts.placeholder, + password: opts.password ?? false, + ignoreFocusOut: true, + }); + return v; +} + +function defaultSecretPlacement(): SecretPlacement { + if (process.env.SFCC_CI === '1' || process.env.CI === 'true') return 'env'; + if (process.platform === 'darwin') return 'macos-keychain'; + return 'password-store'; +} + +function placementItems(currentDefault: SecretPlacement): PlacementChoice[] { + const isMac = process.platform === 'darwin'; + const items: PlacementChoice[] = [ + { + id: 'macos-keychain', + label: `$(key) macOS Keychain${isMac ? '' : ' (macOS only)'}`, + description: 'Encrypted in the OS Keychain', + detail: + 'Uses the documented b2c-plugin-macos-keychain. Secrets live in the OS Keychain; ' + + 'never written to disk in plaintext.', + }, + { + id: 'password-store', + label: '$(lock) Password Store (pass)', + description: 'GPG-encrypted via the Unix `pass` tool', + detail: 'Cross-platform (macOS / Linux / WSL). Uses the documented b2c-plugin-password-store.', + }, + { + id: 'env', + label: '$(symbol-variable) Environment variables', + description: 'SFCC_* env vars — best for CI / shared machines', + detail: 'Highest precedence in the resolution chain. Nothing written to disk.', + }, + { + id: 'dw-json', + label: '$(file) dw.json (in workspace)', + description: 'Plaintext in your workspace — personal sandboxes only', + detail: 'Quickest. Only safe for personal sandboxes. Always added to .gitignore.', + }, + ]; + // Mark the platform default with "(recommended)" so it's visibly preselected. + const def = items.find((i) => i.id === currentDefault); + if (def) { + def.label = def.label.replace(/^(\$\([^)]+\)\s*)/, '$1') + ' · recommended'; + def.picked = true; + } + return items; +} + +async function chooseCredentialStorage(context: vscode.ExtensionContext): Promise { + const wsFolder = vscode.workspace.workspaceFolders?.[0]; + if (!wsFolder) { + vscode.window.showErrorMessage('B2C DX: Open a folder first — the wizard writes dw.json into your workspace root.'); + return; + } + + // Pick the instance name. Defaults to "dev"; user can override. + const instanceName = await vscode.window.showInputBox({ + title: 'Configure your B2C instance — 1 of 4', + prompt: 'Name this instance (used as the configs[] entry name in dw.json)', + placeHolder: 'dev', + value: 'dev', + ignoreFocusOut: true, + validateInput: (v) => (/^[A-Za-z0-9_-]+$/.test(v) ? null : 'Letters, digits, dash, underscore only'), + }); + if (!instanceName) return; + + const hostname = await vscode.window.showInputBox({ + title: 'Configure your B2C instance — 2 of 4 · Connection', + prompt: 'Instance hostname (no https://)', + placeHolder: 'abcd-123.dx.commercecloud.salesforce.com', + ignoreFocusOut: true, + validateInput: (v) => (v.trim().length > 0 ? null : 'Required'), + }); + if (!hostname) return; + + const codeVersion = await vscode.window.showInputBox({ + title: 'Configure your B2C instance — 2 of 4 · Connection (optional)', + prompt: 'Default code version targeted by deploys (optional)', + placeHolder: 'version1', + ignoreFocusOut: true, + }); + + // Pick which auth flows to wire up. + const flowsItems: FlowChoice[] = [ + { + id: 'oauth', + label: '$(shield) OAuth client credentials', + description: 'client-id + client-secret — Sandbox Explorer, OCAPI / SCAPI, jobs', + picked: true, + }, + { + id: 'basic', + label: '$(person) Basic auth (WebDAV)', + description: 'username + password — cartridge deploys, WebDAV browser', + picked: true, + }, + { + id: 'scapi', + label: '$(symbol-interface) SCAPI extras', + description: 'short-code + tenant-id + scopes — required by API Browser', + }, + { + id: 'mrt', + label: '$(rocket) MRT (Managed Runtime)', + description: 'mrtProject + mrtEnvironment + MRT_API_KEY', + }, + ]; + const flows = await vscode.window.showQuickPick(flowsItems, { + title: 'Configure your B2C instance — 3 of 4 · Auth flows', + placeHolder: 'Pick the flows you actually use (you can re-run later to add more)', + canPickMany: true, + ignoreFocusOut: true, + }); + if (!flows) return; + + const connection: ConnectionConfig = {instanceName, hostname, codeVersion: codeVersion || undefined}; + const plan: ConfigPlan = {connection, enableSCAPI: false}; + + // SCAPI extras + if (flows.some((f) => f.id === 'scapi')) { + plan.enableSCAPI = true; + connection.shortCode = + (await vscode.window.showInputBox({ + title: 'SCAPI · short-code', + prompt: 'Your organisation short code (from Account Manager)', + placeHolder: 'kv7kzm78', + ignoreFocusOut: true, + })) || undefined; + connection.tenantId = + (await vscode.window.showInputBox({ + title: 'SCAPI · tenant-id', + prompt: 'Your tenant ID (e.g. zzrf_001)', + placeHolder: 'zzrf_001', + ignoreFocusOut: true, + })) || undefined; + connection.oauthScopes = + (await vscode.window.showInputBox({ + title: 'SCAPI · oauth scopes (optional)', + prompt: 'Space-separated SCAPI scopes', + placeHolder: 'sfcc.shopper-customers sfcc.shopper-products', + ignoreFocusOut: true, + })) || undefined; + } + + // MRT non-secret fields + if (flows.some((f) => f.id === 'mrt')) { + connection.mrtProject = + (await vscode.window.showInputBox({ + title: 'MRT · project slug', + prompt: 'mrtProject — your MRT project slug', + ignoreFocusOut: true, + })) || undefined; + connection.mrtEnvironment = + (await vscode.window.showInputBox({ + title: 'MRT · environment slug', + prompt: 'mrtEnvironment — your MRT environment slug', + ignoreFocusOut: true, + })) || undefined; + } + + // Step 4: per-pair placement. + const def = defaultSecretPlacement(); + + if (flows.some((f) => f.id === 'oauth')) { + const picked = await vscode.window.showQuickPick(placementItems(def), { + title: 'Configure your B2C instance — 4 of 4 · Where should OAuth secrets live?', + placeHolder: 'client-id and client-secret stay together (Credential Grouping rule).', + ignoreFocusOut: true, + }); + if (!picked) return; + plan.oauthPlacement = picked.id; + plan.oauthClientId = await secretInput({ + title: 'OAuth · client-id', + prompt: 'Paste your client-id (visible — it is an identifier, not a secret).', + placeholder: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }); + if (plan.oauthClientId === undefined) return; + plan.oauthClientSecret = await secretInput({ + title: 'OAuth · client-secret', + prompt: 'Paste your client-secret (input is masked).', + placeholder: '••••••••••••••••••••', + password: true, + }); + if (plan.oauthClientSecret === undefined) return; + } + + if (flows.some((f) => f.id === 'basic')) { + const picked = await vscode.window.showQuickPick(placementItems(def), { + title: 'Configure your B2C instance — 4 of 4 · Where should WebDAV credentials live?', + placeHolder: 'username and password stay together (Credential Grouping rule).', + ignoreFocusOut: true, + }); + if (!picked) return; + plan.basicPlacement = picked.id; + plan.basicUsername = await secretInput({ + title: 'Basic · username', + prompt: 'Your Business Manager username.', + placeholder: 'you@example.com', + }); + if (plan.basicUsername === undefined) return; + plan.basicPassword = await secretInput({ + title: 'Basic · WebDAV access key (password)', + prompt: 'Paste your WebDAV access key (input is masked).', + placeholder: '••••••••••••••••••••', + password: true, + }); + if (plan.basicPassword === undefined) return; + } + + if (flows.some((f) => f.id === 'mrt')) { + // MRT_API_KEY pairing model: same chooser, but Keychain/pass shown as "via b2c mrt save-credentials". + const picked = await vscode.window.showQuickPick(placementItems(def), { + title: 'Configure your B2C instance — 4 of 4 · Where should the MRT API key live?', + placeHolder: 'The CLI manages ~/.mobify automatically when you pick Keychain or pass.', + ignoreFocusOut: true, + }); + if (!picked) return; + plan.mrtPlacement = picked.id; + if (picked.id !== 'dw-json') { + plan.mrtApiKey = await secretInput({ + title: 'MRT · API key', + prompt: 'Paste your MRT_API_KEY (input is masked).', + placeholder: '••••••••••••••••••••', + password: true, + }); + if (plan.mrtApiKey === undefined) return; + } + } + + await applyConfigPlan(context, plan); +} + +async function applyConfigPlan(context: vscode.ExtensionContext, plan: ConfigPlan): Promise { + const wsFolder = vscode.workspace.workspaceFolders![0]; + const dwJsonPath = path.join(wsFolder.uri.fsPath, 'dw.json'); + + // Build the dw.json entry: only non-secret fields go here, except where the + // user explicitly chose dw-json placement for a pair. + const entry: Record = { + name: plan.connection.instanceName, + active: true, + hostname: plan.connection.hostname, + }; + if (plan.connection.codeVersion) entry['code-version'] = plan.connection.codeVersion; + if (plan.enableSCAPI) { + if (plan.connection.shortCode) entry['short-code'] = plan.connection.shortCode; + if (plan.connection.tenantId) entry['tenant-id'] = plan.connection.tenantId; + if (plan.connection.oauthScopes) entry['oauth-scopes'] = plan.connection.oauthScopes; + } + if (plan.connection.mrtProject) entry.mrtProject = plan.connection.mrtProject; + if (plan.connection.mrtEnvironment) entry.mrtEnvironment = plan.connection.mrtEnvironment; + + // Merge into existing dw.json's configs[], or create new file with this entry. + let existing: {configs?: Record[]; [key: string]: unknown} = {}; + if (await checkFileExists(dwJsonPath)) { + try { + existing = JSON.parse(await fs.readFile(dwJsonPath, 'utf-8')); + } catch { + // Malformed dw.json — leave alone, ask user. + const action = await vscode.window.showWarningMessage( + 'dw.json exists but is not valid JSON. Open it for manual fix?', + 'Open', + 'Cancel', + ); + if (action === 'Open') await openFile(dwJsonPath); + return; + } + } + if (!Array.isArray(existing.configs)) existing.configs = []; + // Replace any same-named entry; otherwise append. + const idx = existing.configs.findIndex((c) => c && (c as {name?: string}).name === entry.name); + if (idx >= 0) existing.configs[idx] = entry; + else existing.configs.push(entry); + // Ensure single active. + for (const c of existing.configs) { + if (c && (c as {name?: string}).name !== entry.name) (c as {active?: boolean}).active = false; + } + + // Inline OAuth/basic into dw.json only when explicitly chosen. + if (plan.oauthPlacement === 'dw-json') { + if (plan.oauthClientId) entry['client-id'] = plan.oauthClientId; + if (plan.oauthClientSecret) entry['client-secret'] = plan.oauthClientSecret; + } + if (plan.basicPlacement === 'dw-json') { + if (plan.basicUsername) entry.username = plan.basicUsername; + if (plan.basicPassword) entry.password = plan.basicPassword; + } + + await fs.writeFile(dwJsonPath, JSON.stringify(existing, null, 2) + '\n', 'utf-8'); + await openFile(dwJsonPath); + await ensureGitIgnoreEntry(wsFolder.uri.fsPath, 'dw.json'); + + // Apply secrets to the chosen storage. Each placement is fired internally + // where it can be done safely (Keychain via execFile; env var export + // queued in a terminal because it must run in the user's shell). + const inst = plan.connection.instanceName; + const report: string[] = []; + const errors: string[] = []; + const pluginsToInstall = new Set(); + + if (plan.oauthPlacement === 'macos-keychain') pluginsToInstall.add('macos-keychain'); + if (plan.basicPlacement === 'macos-keychain') pluginsToInstall.add('macos-keychain'); + if (plan.oauthPlacement === 'password-store') pluginsToInstall.add('password-store'); + if (plan.basicPlacement === 'password-store') pluginsToInstall.add('password-store'); + + // Plugin installs queue a single terminal command per plugin. + const terminalLines: string[] = []; + for (const p of pluginsToInstall) { + if (p === 'macos-keychain') { + terminalLines.push('b2c plugins install sfcc-solutions-share/b2c-plugin-macos-keychain'); + } else if (p === 'password-store') { + terminalLines.push('b2c plugins install sfcc-solutions-share/b2c-plugin-password-store'); + } + } + + // OAuth pair + if (plan.oauthPlacement === 'macos-keychain' && plan.oauthClientId && plan.oauthClientSecret) { + try { + await writeKeychainPair(inst, { + clientId: plan.oauthClientId, + clientSecret: plan.oauthClientSecret, + }); + report.push(`Keychain: b2c-cli/${inst} (clientId, clientSecret) ✓`); + } catch (e) { + errors.push(`Keychain (OAuth): ${e instanceof Error ? e.message : String(e)}`); + } + } else if (plan.oauthPlacement === 'password-store' && plan.oauthClientId && plan.oauthClientSecret) { + terminalLines.push( + `pass insert -m b2c-cli/${inst}-oauth <<'EOF'`, + plan.oauthClientSecret, + `clientId: ${plan.oauthClientId}`, + `clientSecret: ${plan.oauthClientSecret}`, + `EOF`, + ); + report.push(`Password Store: b2c-cli/${inst}-oauth (queued in terminal)`); + } else if (plan.oauthPlacement === 'env' && plan.oauthClientId && plan.oauthClientSecret) { + terminalLines.push( + `export SFCC_CLIENT_ID=${shellEscape(plan.oauthClientId)}`, + `export SFCC_CLIENT_SECRET=${shellEscape(plan.oauthClientSecret)}`, + ); + report.push('Env vars: SFCC_CLIENT_ID, SFCC_CLIENT_SECRET (queued in terminal)'); + } else if (plan.oauthPlacement === 'dw-json') { + report.push('dw.json: client-id, client-secret ✓'); + } + + // Basic pair + if (plan.basicPlacement === 'macos-keychain' && plan.basicUsername && plan.basicPassword) { + try { + await writeKeychainPair(`${inst}-basic`, { + username: plan.basicUsername, + password: plan.basicPassword, + }); + report.push(`Keychain: b2c-cli/${inst}-basic (username, password) ✓`); + } catch (e) { + errors.push(`Keychain (Basic): ${e instanceof Error ? e.message : String(e)}`); + } + } else if (plan.basicPlacement === 'password-store' && plan.basicUsername && plan.basicPassword) { + terminalLines.push( + `pass insert -m b2c-cli/${inst}-basic <<'EOF'`, + plan.basicPassword, + `username: ${plan.basicUsername}`, + `password: ${plan.basicPassword}`, + `EOF`, + ); + report.push(`Password Store: b2c-cli/${inst}-basic (queued in terminal)`); + } else if (plan.basicPlacement === 'env' && plan.basicUsername && plan.basicPassword) { + terminalLines.push( + `export SFCC_USERNAME=${shellEscape(plan.basicUsername)}`, + `export SFCC_PASSWORD=${shellEscape(plan.basicPassword)}`, + ); + report.push('Env vars: SFCC_USERNAME, SFCC_PASSWORD (queued in terminal)'); + } else if (plan.basicPlacement === 'dw-json') { + report.push('dw.json: username, password ✓'); + } + + // MRT API key + if (plan.mrtPlacement === 'env' && plan.mrtApiKey) { + terminalLines.push(`export MRT_API_KEY=${shellEscape(plan.mrtApiKey)}`); + report.push('Env vars: MRT_API_KEY (queued in terminal)'); + } else if ((plan.mrtPlacement === 'macos-keychain' || plan.mrtPlacement === 'password-store') && plan.mrtApiKey) { + // MRT credentials live in ~/.mobify; the CLI manages that path. + terminalLines.push(`b2c mrt save-credentials # paste MRT_API_KEY when prompted`); + report.push('MRT: ~/.mobify (run `b2c mrt save-credentials` from the queued terminal)'); + } else if (plan.mrtPlacement === 'dw-json') { + vscode.window.showWarningMessage( + 'MRT_API_KEY cannot live in dw.json. Use env vars or `b2c mrt save-credentials` instead.', + ); + } + + // Persist chosen placement for next-run defaults. + await context.globalState.update('b2c-dx.lastSecretPlacement', plan.oauthPlacement ?? plan.basicPlacement); + + // Surface results. + if (terminalLines.length > 0) { + const term = vscode.window.createTerminal({name: `B2C DX — ${inst} setup`}); + term.show(); + term.sendText('# Review each line and press Enter to run.', false); + for (const l of terminalLines) term.sendText(l, false); + } + + const summary = report.length > 0 ? report.map((l) => ` • ${l}`).join('\n') : ' (none)'; + const errSummary = errors.length > 0 ? '\n\nErrors:\n' + errors.map((e) => ` • ${e}`).join('\n') : ''; + const action = await vscode.window.showInformationMessage( + `B2C DX: ${inst} configured.\n\nApplied:\n${summary}${errSummary}`, + {modal: true}, + 'Inspect resolved config', + 'Done', + ); + if (action === 'Inspect resolved config') { + await vscode.commands.executeCommand('b2c-dx.walkthrough.inspectSetup'); + } +} + +/** POSIX shell-escape: wrap in single quotes, escape any embedded ones. */ +function shellEscape(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** Write a `: ` JSON blob into the macOS Keychain under + * service `b2c-cli`, account ``. Uses `security add-generic-password` + * via execFile so we can pass arguments as an array (no shell injection + * risk from secret content). The `-U` flag updates if the entry exists. */ +async function writeKeychainPair(account: string, fields: Record): Promise { + if (process.platform !== 'darwin') { + throw new Error('Keychain integration is macOS-only.'); + } + const blob = JSON.stringify(fields); + await new Promise((resolve, reject) => { + cp.execFile( + 'security', + ['add-generic-password', '-s', 'b2c-cli', '-a', account, '-w', blob, '-U'], + {timeout: 5000}, + (err) => (err ? reject(err) : resolve()), + ); + }); +} + +async function ensureGitIgnoreEntry(workspaceRoot: string, entry: string): Promise { + const giPath = path.join(workspaceRoot, '.gitignore'); + let body = ''; + if (await checkFileExists(giPath)) { + body = await fs.readFile(giPath, 'utf-8'); + if (body.split(/\r?\n/).some((l) => l.trim() === entry)) return; + body = body.trimEnd() + '\n\n# B2C Commerce credentials\n' + entry + '\n'; + } else { + body = `# B2C Commerce credentials\n${entry}\n`; + } + await fs.writeFile(giPath, body, 'utf-8'); +} + +/** Fields whose values must be redacted before display. Names match every + * documented variant (kebab + camel + env-var) so accidental aliases are + * caught. */ +const SENSITIVE_FIELDS = new Set([ + 'password', + 'client-secret', + 'clientSecret', + 'sfcc_password', + 'sfcc_client_secret', + 'mrt_api_key', + 'mrtApiKey', + 'apiKey', + 'api-key', + 'certificate', + 'certificate-passphrase', + 'certificatePassphrase', +]); + +async function runB2cInspect(workingDir: string): Promise<{stdout: string; ok: boolean}> { + return new Promise((resolve) => { + cp.execFile('b2c', ['setup', 'inspect', '--json'], {cwd: workingDir, timeout: 8000}, (err, stdout) => { + if (err) resolve({stdout: stdout ?? '', ok: false}); + else resolve({stdout: stdout ?? '', ok: true}); + }); + }); +} + +/** Tries to coerce parsed inspect output into a list of `{field, value, source}` + * rows. The CLI's --json shape is `{ values: { field: { value, source } } }` + * on recent releases; older versions emit a flat object. We accept both. */ +interface InspectRow { + field: string; + value: string; + source?: string; + sensitive: boolean; +} + +function flattenInspect(parsed: unknown): InspectRow[] { + const rows: InspectRow[] = []; + const isSensitive = (key: string) => { + const lc = key.toLowerCase(); + return ( + SENSITIVE_FIELDS.has(key) || SENSITIVE_FIELDS.has(lc) || /(secret|password|api[-_]?key|passphrase)/i.test(key) + ); + }; + const stringifyVal = (v: unknown): string => { + if (v === null || v === undefined) return ''; + if (typeof v === 'string') return v; + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + return JSON.stringify(v); + }; + + if (!parsed || typeof parsed !== 'object') return rows; + const obj = parsed as Record; + + // Shape A: { values: { field: { value, source } } } — older CLI versions. + if (obj.values && typeof obj.values === 'object') { + for (const [field, raw] of Object.entries(obj.values as Record)) { + if (raw && typeof raw === 'object' && 'value' in (raw as Record)) { + const r = raw as {value: unknown; source?: unknown}; + rows.push({ + field, + value: stringifyVal(r.value), + source: typeof r.source === 'string' ? r.source : undefined, + sensitive: isSensitive(field), + }); + } else { + rows.push({field, value: stringifyVal(raw), sensitive: isSensitive(field)}); + } + } + return rows; + } + + // Shape B: { config: { …fields }, sources: { field: 'source-name' } } + // (current `b2c setup inspect --json` shape). + if (obj.config && typeof obj.config === 'object') { + const config = obj.config as Record; + const sources = + obj.sources && typeof obj.sources === 'object' && !Array.isArray(obj.sources) + ? (obj.sources as Record) + : {}; + const sourcesArr = Array.isArray(obj.sources) ? (obj.sources as Array>) : null; + // Optional: Shape B-arr — sources is `[{name, fields:[…]}]`. + const arrSourceFor = (field: string): string | undefined => { + if (!sourcesArr) return undefined; + for (const s of sourcesArr) { + const fields = s && typeof s === 'object' ? (s as {fields?: unknown}).fields : undefined; + if (Array.isArray(fields) && fields.includes(field)) { + return typeof (s as {name?: unknown}).name === 'string' ? (s as {name: string}).name : 'unknown'; + } + } + return undefined; + }; + const flat = (prefix: string, val: unknown) => { + if (val && typeof val === 'object' && !Array.isArray(val)) { + for (const [k, v] of Object.entries(val as Record)) { + flat(prefix ? `${prefix}.${k}` : k, v); + } + return; + } + const src = typeof sources[prefix] === 'string' ? (sources[prefix] as string) : arrSourceFor(prefix); + rows.push({ + field: prefix, + value: stringifyVal(val), + source: src, + sensitive: isSensitive(prefix.split('.').pop() || prefix), + }); + }; + for (const [k, v] of Object.entries(config)) flat(k, v); + return rows; + } + + // Shape C: a plain map of field → primitive (no source info). + for (const [field, raw] of Object.entries(obj)) { + if (raw && typeof raw === 'object' && 'value' in (raw as Record)) { + const r = raw as {value: unknown; source?: unknown}; + rows.push({ + field, + value: stringifyVal(r.value), + source: typeof r.source === 'string' ? r.source : undefined, + sensitive: isSensitive(field), + }); + } else { + rows.push({field, value: stringifyVal(raw), sensitive: isSensitive(field)}); + } + } + return rows; +} + +let inspectPanelRef: vscode.WebviewPanel | undefined; + +async function openInspect(): Promise { + const wsFolder = vscode.workspace.workspaceFolders?.[0]; + if (!wsFolder) { + vscode.window.showErrorMessage('B2C DX: Open a folder first — inspect resolves config relative to the workspace.'); + return; + } + + const buildHtml = async (): Promise => { + const [{stdout, ok}, cliVersion] = await Promise.all([ + runB2cInspect(wsFolder.uri.fsPath), + new Promise((resolve) => { + cp.execFile('b2c', ['--version'], {timeout: 5000}, (err, out) => { + if (err) return resolve(undefined); + const match = out.trim().match(/(\d+\.\d+\.\d+(?:[-+][\w.]+)?)/); + resolve(match ? match[1] : out.trim() || undefined); + }); + }), + ]); + if (!ok) return renderInspectError(stdout); + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + parsed = stdout; + } + const rows = flattenInspect(parsed); + return renderInspectPanel(rows, parsed, cliVersion); + }; + + if (inspectPanelRef) { + inspectPanelRef.reveal(undefined, true); + inspectPanelRef.webview.html = await buildHtml(); + return; + } + + inspectPanelRef = vscode.window.createWebviewPanel( + 'b2c-dx.inspectSetup', + 'B2C DX · Resolved Config', + {viewColumn: vscode.ViewColumn.Active, preserveFocus: false}, + {enableScripts: true, retainContextWhenHidden: true}, + ); + inspectPanelRef.onDidDispose(() => { + inspectPanelRef = undefined; + }); + + // Attach the message handler exactly once per panel instance. + inspectPanelRef.webview.onDidReceiveMessage(async (msg: {type?: string}) => { + if (!inspectPanelRef) return; + if (msg && msg.type === 'refresh') { + inspectPanelRef.webview.html = await buildHtml(); + } else if (msg && msg.type === 'openTerminalUnmask') { + const term = vscode.window.createTerminal({name: 'B2C DX — setup inspect (unmasked)'}); + term.show(); + term.sendText('b2c setup inspect --unmask', false); + } + }); + + inspectPanelRef.webview.html = await buildHtml(); +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => + c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : ''', + ); +} + +function renderInspectError(stdout: string): string { + const styles = inspectStyles(); + return ` +
+
+ B2C DX · Resolved Config +

Could not run b2c setup inspect

+

The CLI isn't installed or returned an error. Install via Phase 1, or run the command manually in a terminal.

+
+
+ + +
+
+ ${stdout ? `
${escapeHtml(stdout)}
` : ''} + + `; +} + +function renderInspectPanel(rows: InspectRow[], parsed: unknown, cliVersion?: string): string { + const styles = inspectStyles(); + // Group rows by source for the second view; keeps the per-source breakdown + // clean even when one field is supplied by multiple lower-priority sources. + const bySource = new Map(); + for (const r of rows) { + const key = r.source ?? 'unknown'; + if (!bySource.has(key)) bySource.set(key, []); + bySource.get(key)!.push(r); + } + + const sourceColor = (source: string | undefined): string => { + if (!source) return 'var(--src-fallback)'; + const s = source.toLowerCase(); + if (s.includes('env')) return 'var(--src-env)'; + if (s.includes('keychain')) return 'var(--src-keychain)'; + if (s.includes('pass')) return 'var(--src-pass)'; + if (s.includes('dw.json')) return 'var(--src-file)'; + if (s.includes('plugin')) return 'var(--src-plugin)'; + return 'var(--src-fallback)'; + }; + + const lockSvg = ``; + + const renderRow = (r: InspectRow, idx: number): string => { + const display = r.value + ? r.sensitive + ? `${lockSvg}••••••••` + : escapeHtml(r.value) + : ''; + const srcLabel = r.source ? escapeHtml(r.source) : 'unknown'; + return ` + ${escapeHtml(r.field)} + ${display} + ${srcLabel} + `; + }; + + const renderSourceBlock = (source: string, items: InspectRow[]): string => ` +
+
+ ${escapeHtml(source)} + ${items.length} +
+
    + ${items + .map( + (r) => + `
  • ${escapeHtml(r.field)}${ + r.sensitive ? ` ${lockSvg}` : '' + }
  • `, + ) + .join('')} +
+
`; + + const fallbackJson = !rows.length + ? `
${escapeHtml(typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2))}
` + : ''; + + const secretCount = rows.filter((r) => r.sensitive).length; + const sourceCount = bySource.size; + + return ` +
+
+ B2C DX · Resolved Config +

What the CLI sees right now

+

Source of truth for every config field — secrets are masked.

+ ${ + rows.length + ? `
+ ${rows.length} field${rows.length === 1 ? '' : 's'} + + ${sourceCount} source${sourceCount === 1 ? '' : 's'} + ${secretCount ? `${lockSvg}${secretCount} masked` : ''} +
` + : '' + } +
+
+ + +
+
+ + ${ + rows.length + ? ` +
+
+

All fields

+ ${rows.length} +
+ + + ${rows.map((r, i) => renderRow(r, i)).join('')} +
FieldValueSource
+
+ +
+
+

Grouped by source

+ ${sourceCount} +
+
+ ${[...bySource.entries()].map(([s, items]) => renderSourceBlock(s, items)).join('')} +
+
+ ` + : `
+
+
🔍
+

No resolved fields

+
+

The CLI returned an empty configuration. This usually means one of the following:

+
    +
  • No dw.json was found in this workspace root
  • +
  • The dw.json exists but has no configured instances
  • +
  • Your B2C CLI version is too old to parse the file correctly
  • +
+
+

Tip: You have B2C CLI ${cliVersion ? `v${cliVersion}` : '(unknown version)'} installed. Run npm install -g @salesforce/b2c-cli@latest to update, then click Refresh.

+
+ ${fallbackJson ? `
Raw CLI output${fallbackJson}
` : ''} +
` + } + + + `; +} + +function inspectStyles(): string { + return ` + :root { + color-scheme: light dark; + --hairline: var(--vscode-panel-border, var(--vscode-editorGroup-border, rgba(128,128,128,0.22))); + --surface: var(--vscode-editorWidget-background, var(--vscode-editor-background)); + --row-zebra: color-mix(in srgb, var(--vscode-foreground) 4%, transparent); + --row-hover: color-mix(in srgb, var(--vscode-foreground) 8%, transparent); + --brand-blue: #0176D3; + --brand-blue-deep: #014486; + --brand-blue-soft: rgba(1, 118, 211, 0.10); + --brand-green: #1A8754; + --secret-amber: #C77700; + --secret-amber-soft: rgba(199, 119, 0, 0.10); + --src-env: #1A8754; + --src-keychain: #0176D3; + --src-pass: #6F42C1; + --src-file: #C77700; + --src-plugin: #1B96FF; + --src-fallback: rgba(127,127,127,0.55); + } + *, *::before, *::after { box-sizing: border-box; } + body { + margin: 0; padding: 32px 40px; + min-height: 100vh; + font-family: 'Salesforce Sans','IBM Plex Sans','Source Sans 3',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; + color: var(--vscode-foreground); + background: var(--vscode-editor-background); + } + .muted { color: var(--vscode-descriptionForeground); } + code { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); background: var(--brand-blue-soft); padding: 1px 6px; border-radius: 4px; color: var(--brand-blue-deep); font-size: 0.86em; } + .eyebrow { display: inline-block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.16em; color: var(--brand-blue); text-transform: uppercase; margin-bottom: 6px; } + h1 { margin: 0 0 6px; font-size: 1.7rem; font-weight: 700; letter-spacing: -0.02em; line-height: 1.15; } + h2 { margin: 0; font-size: 0.95rem; font-weight: 600; letter-spacing: -0.005em; } + .hdr { + display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; + margin-bottom: 24px; flex-wrap: wrap; + } + .hdr-text { flex: 1 1 360px; min-width: 0; } + .hdr-text > p { margin: 0 0 12px; max-width: 720px; } + .hdr-actions { display: flex; gap: 8px; flex-wrap: wrap; } + .stats { + display: inline-flex; align-items: center; gap: 10px; + padding: 7px 12px; border-radius: 999px; + background: var(--surface); + border: 1px solid var(--hairline); + font-size: 0.82rem; + } + .stat { display: inline-flex; align-items: center; gap: 5px; color: var(--vscode-descriptionForeground); } + .stat strong { color: var(--vscode-foreground); font-weight: 700; } + .stat-sep { color: var(--vscode-descriptionForeground); opacity: 0.5; } + .secret-stat { color: var(--secret-amber); } + .secret-stat strong { color: var(--secret-amber); } + .secret-stat .lock-icon { color: var(--secret-amber); } + button { + display: inline-flex; align-items: center; gap: 6px; + font: inherit; cursor: pointer; padding: 8px 14px; + border-radius: 999px; font-weight: 600; font-size: 0.84rem; + transition: all 0.15s ease; + } + button svg { display: block; } + .btn-primary { background: var(--brand-blue); color: #fff; border: 1px solid var(--brand-blue); } + .btn-primary:hover { background: var(--brand-blue-deep); border-color: var(--brand-blue-deep); transform: translateY(-1px); box-shadow: 0 2px 8px rgba(1,118,211,0.30); } + .btn-ghost { background: transparent; color: var(--brand-blue); border: 1px solid var(--brand-blue); } + .btn-ghost:hover { background: var(--brand-blue-soft); } + .card { + background: var(--surface); border: 1px solid var(--hairline); + border-radius: 14px; padding: 22px 24px; margin-bottom: 18px; + box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 6px 18px rgba(0,0,0,0.04); + } + .card-hdr { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; } + .badge { + display: inline-flex; align-items: center; justify-content: center; + min-width: 22px; height: 22px; padding: 0 8px; + border-radius: 999px; + background: var(--brand-blue-soft); + color: var(--brand-blue-deep); + font-size: 0.74rem; font-weight: 700; + letter-spacing: 0.02em; + } + .card.empty { text-align: center; padding: 36px 22px; } + table.tbl { width: 100%; border-collapse: separate; border-spacing: 0; } + .tbl th { text-align: left; font-size: 0.7rem; letter-spacing: 0.14em; text-transform: uppercase; font-weight: 700; color: var(--vscode-descriptionForeground); padding: 6px 14px 12px; border-bottom: 1px solid var(--hairline); } + .tbl th.th-source { text-align: left; } + .tbl td { padding: 11px 14px; font-size: 0.9rem; vertical-align: middle; border-bottom: 1px solid color-mix(in srgb, var(--hairline) 60%, transparent); } + .tbl tbody tr.row-odd td { background: var(--row-zebra); } + .tbl tbody tr:hover td { background: var(--row-hover); } + .tbl tbody tr:first-child td:first-child { border-top-left-radius: 8px; } + .tbl tbody tr:first-child td:last-child { border-top-right-radius: 8px; } + .tbl tbody tr:last-child td { border-bottom: none; } + .tbl tbody tr:last-child td:first-child { border-bottom-left-radius: 8px; } + .tbl tbody tr:last-child td:last-child { border-bottom-right-radius: 8px; } + .tbl .field { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 0.86rem; color: var(--vscode-foreground); white-space: nowrap; } + .tbl .value { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 0.86rem; word-break: break-all; } + .tbl .source { white-space: nowrap; width: 1%; } + .masked { + display: inline-flex; align-items: center; gap: 6px; + padding: 3px 10px; border-radius: 999px; + background: var(--secret-amber-soft); + border: 1px solid color-mix(in srgb, var(--secret-amber) 35%, transparent); + color: var(--secret-amber); + font-size: 0.78rem; font-weight: 600; + } + .masked-dots { letter-spacing: 0.18em; line-height: 1; } + .lock-icon { color: var(--secret-amber); flex-shrink: 0; } + .lock-inline { display: inline-flex; vertical-align: middle; opacity: 0.85; } + .src-pill { + display: inline-flex; align-items: center; gap: 7px; + padding: 3px 10px 3px 9px; + border-radius: 999px; + background: color-mix(in srgb, var(--src-color) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--src-color) 30%, transparent); + color: var(--vscode-foreground); + font-size: 0.78rem; font-weight: 500; + } + .src-pill .dot { background: var(--src-color); width: 7px; height: 7px; margin: 0; border-radius: 50%; box-shadow: 0 0 0 2px color-mix(in srgb, var(--src-color) 18%, transparent); } + .src-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; } + .src-block { + background: var(--vscode-editor-background); + border: 1px solid var(--hairline); + border-left: 3px solid var(--src-color); + border-radius: 10px; padding: 14px 16px; + transition: border-color 0.15s ease, transform 0.15s ease; + } + .src-block:hover { transform: translateY(-1px); border-color: color-mix(in srgb, var(--src-color) 50%, var(--hairline)); } + .src-hdr { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; font-size: 0.88rem; font-weight: 600; } + .src-hdr .src-name { color: var(--vscode-foreground); } + .src-hdr .src-count { + margin-left: auto; + display: inline-flex; align-items: center; justify-content: center; + min-width: 22px; height: 20px; padding: 0 7px; + border-radius: 999px; + background: color-mix(in srgb, var(--src-color) 14%, transparent); + color: var(--src-color); + font-size: 0.72rem; font-weight: 700; + } + .src-fields { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 5px; } + .src-fields li { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 0.82rem; color: var(--vscode-descriptionForeground); display: flex; align-items: center; gap: 6px; } + .src-fields li .field { color: var(--vscode-foreground); } + pre.raw { background: rgba(127,127,127,0.10); padding: 12px 14px; border-radius: 8px; border: 1px solid var(--hairline); overflow-x: auto; font-size: 0.82rem; line-height: 1.45; white-space: pre; } + `; +} + +/** + * Creates a dw.json template file in the workspace root. + * Prompts user for configuration type and handles existing file scenarios. + */ +async function createDwJsonTemplate(): Promise { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + + if (!workspaceFolder) { + vscode.window.showErrorMessage('No workspace folder open. Please open a folder first, then try again.'); + return; + } + + const dwJsonPath = path.join(workspaceFolder.uri.fsPath, 'dw.json'); + + // Check if dw.json already exists + const fileExists = await checkFileExists(dwJsonPath); + + if (fileExists) { + const action = await vscode.window.showWarningMessage( + 'dw.json already exists in this workspace.', + 'Open Existing', + 'Overwrite', + 'Cancel', + ); + + if (action === 'Cancel' || !action) { + return; + } + + if (action === 'Open Existing') { + await openFile(dwJsonPath); + return; + } + + // User chose "Overwrite", continue with creation + } + + // Ask user which template they want + const templateType = await vscode.window.showQuickPick( + [ + { + label: 'Single Instance', + description: 'Basic configuration with one B2C instance', + detail: 'Recommended for most users', + value: 'single', + }, + { + label: 'Multiple Instances', + description: 'Configuration with multiple B2C instances', + detail: 'Use if you need to switch between dev, staging, etc.', + value: 'multi', + }, + ], + { + title: 'Select dw.json Template', + placeHolder: 'Choose a configuration template', + }, + ); + + if (!templateType) { + return; // User cancelled + } + + // Select template based on user choice + const template = templateType.value === 'multi' ? DW_JSON_MULTI_INSTANCE_TEMPLATE : DW_JSON_TEMPLATE; + + try { + const content = JSON.stringify(template, null, 2); + await fs.writeFile(dwJsonPath, content, 'utf-8'); + await openFile(dwJsonPath); + + const action = await vscode.window.showInformationMessage( + 'dw.json created. Update it with your B2C Commerce credentials.', + 'Add to .gitignore', + 'Dismiss', + ); + if (action === 'Add to .gitignore') { + await addToGitignore(workspaceFolder.uri.fsPath); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage(`Failed to create dw.json: ${message}`); + } +} + +/** + * Opens a file in the editor. + */ +async function openFile(filePath: string): Promise { + try { + const doc = await vscode.workspace.openTextDocument(filePath); + await vscode.window.showTextDocument(doc, { + preview: false, + viewColumn: vscode.ViewColumn.One, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage(`Failed to open file: ${message}`); + } +} + +/** + * Checks if a file exists. + */ +async function checkFileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +/** + * Adds dw.json to .gitignore file. + * Creates .gitignore if it doesn't exist. + */ +async function addToGitignore(workspaceRoot: string): Promise { + const gitignorePath = path.join(workspaceRoot, '.gitignore'); + + try { + let gitignoreContent = ''; + + // Read existing .gitignore if it exists + const gitignoreExists = await checkFileExists(gitignorePath); + if (gitignoreExists) { + gitignoreContent = await fs.readFile(gitignorePath, 'utf-8'); + + // Check if dw.json is already in .gitignore + if (gitignoreContent.includes('dw.json')) { + vscode.window.showInformationMessage('dw.json is already in .gitignore'); + return; + } + } + + // Add dw.json to .gitignore + const newContent = gitignoreContent.trim() + ? `${gitignoreContent}\n\n# B2C Commerce credentials\ndw.json\n` + : `# B2C Commerce credentials\ndw.json\n`; + + await fs.writeFile(gitignorePath, newContent, 'utf-8'); + + vscode.window.showInformationMessage('✅ Added dw.json to .gitignore'); + + // Ask if user wants to open .gitignore + const action = await vscode.window.showInformationMessage('Would you like to view .gitignore?', 'Yes', 'No'); + + if (action === 'Yes') { + await openFile(gitignorePath); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage(`Failed to update .gitignore: ${message}`); + } +} + +/** + * Open the native VS Code walkthrough automatically on first activation, but + * only when no dw.json exists in the workspace — i.e. the user hasn't set the + * extension up yet. Users can re-open it any time via "B2C DX: Open Getting + * Started Guide", and the role-based deep-dive panel via "B2C DX: Open + * Onboarding Panel". + */ +export async function showWalkthroughOnFirstActivation(context: vscode.ExtensionContext): Promise { + const SEEN_KEY = 'b2c-dx.gettingStarted.autoOpened'; + if (context.globalState.get(SEEN_KEY, false)) return; + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) return; + + // Skip auto-open when the workspace already has a dw.json — the user is + // returning, not starting fresh. + for (const folder of folders) { + try { + await fs.access(path.join(folder.uri.fsPath, 'dw.json')); + await context.globalState.update(SEEN_KEY, true); + return; + } catch { + // not present here, keep checking + } + } + + setTimeout(() => { + void vscode.commands.executeCommand( + 'workbench.action.openWalkthrough', + 'Salesforce.b2c-vs-extension#b2c-dx.gettingStarted', + false, + ); + void context.globalState.update(SEEN_KEY, true); + }, 1000); +} + +// ─── Per-step setup commands + session ────────────────── +// +// The single-shot wizard above asks everything in one go. Per the docs flow +// the user sees, we also expose four step-bound commands that each prompt +// only for the fields their step owns — and reuse the active instance name +// once the connection step has set it for the workspace. + +const SETUP_INSTANCE_KEY = 'b2c-dx.setup.activeInstance'; + +interface SetupSession { + instanceName: string; +} + +/** Read the active session for this workspace. */ +function getSetupSession(context: vscode.ExtensionContext): SetupSession | undefined { + const name = context.workspaceState.get(SETUP_INSTANCE_KEY); + return name ? {instanceName: name} : undefined; +} + +async function setSetupSession(context: vscode.ExtensionContext, instanceName: string): Promise { + await context.workspaceState.update(SETUP_INSTANCE_KEY, instanceName); + // Publish a context key so welcome views / panels can react. + void vscode.commands.executeCommand('setContext', 'b2c-dx.setupSessionActive', true); + void vscode.commands.executeCommand('setContext', 'b2c-dx.setupInstance', instanceName); +} + +async function clearSetupSession(context: vscode.ExtensionContext): Promise { + await context.workspaceState.update(SETUP_INSTANCE_KEY, undefined); + void vscode.commands.executeCommand('setContext', 'b2c-dx.setupSessionActive', false); + void vscode.commands.executeCommand('setContext', 'b2c-dx.setupInstance', undefined); +} + +async function resetSetupSession(context: vscode.ExtensionContext): Promise { + const session = getSetupSession(context); + const choice = await vscode.window.showWarningMessage( + session + ? `Reset the setup session? The "${session.instanceName}" entry stays in dw.json — only the in-memory pointer is cleared, so the next setup step will ask for an instance name again.` + : 'No active setup session to reset.', + {modal: true}, + 'Reset', + 'Cancel', + ); + if (choice === 'Reset') { + await clearSetupSession(context); + vscode.window.showInformationMessage( + 'B2C DX: Setup session cleared. Run "Connect to Your B2C Instance" to start over.', + ); + } +} + +/** Resolve the active instance, prompting only when no session exists yet. */ +async function ensureInstanceName(context: vscode.ExtensionContext): Promise { + const existing = getSetupSession(context); + if (existing) return existing.instanceName; + const name = await vscode.window.showInputBox({ + title: 'Connect to Your B2C Instance', + prompt: 'Name this instance (becomes the configs[] entry in dw.json)', + placeHolder: 'dev', + value: 'dev', + ignoreFocusOut: true, + validateInput: (v) => (/^[A-Za-z0-9_-]+$/.test(v) ? null : 'Letters, digits, dash, underscore only'), + }); + if (!name) return undefined; + await setSetupSession(context, name); + return name; +} + +/** Read the workspace dw.json and return its `configs[]` entry for `name`, + * creating one (with `active: true`) if absent. Caller is responsible for + * writing the result back. */ +async function readOrCreateConfigEntry( + workspaceRoot: string, + name: string, +): Promise<{ + doc: {configs?: Record[]; [key: string]: unknown}; + entry: Record; +}> { + const dwJsonPath = path.join(workspaceRoot, 'dw.json'); + let doc: {configs?: Record[]; [key: string]: unknown} = {}; + if (await checkFileExists(dwJsonPath)) { + try { + doc = JSON.parse(await fs.readFile(dwJsonPath, 'utf-8')); + } catch { + // Malformed — surface and bail to caller. + throw new Error('dw.json exists but is not valid JSON. Open it for manual fix.'); + } + } + if (!Array.isArray(doc.configs)) doc.configs = []; + let entry = doc.configs.find((c) => c && (c as {name?: string}).name === name) as Record | undefined; + if (!entry) { + entry = {name, active: true}; + doc.configs.push(entry); + // Ensure single active. + for (const c of doc.configs) { + if (c && (c as {name?: string}).name !== name) (c as {active?: boolean}).active = false; + } + } + return {doc, entry}; +} + +async function writeConfigDoc(workspaceRoot: string, doc: unknown): Promise { + const dwJsonPath = path.join(workspaceRoot, 'dw.json'); + await fs.writeFile(dwJsonPath, JSON.stringify(doc, null, 2) + '\n', 'utf-8'); + await ensureGitIgnoreEntry(workspaceRoot, 'dw.json'); +} + +/** "Inspect resolved config" follow-up — surfaced after every step apply. */ +async function offerInspectFollowUp(message: string): Promise { + const action = await vscode.window.showInformationMessage(message, 'Inspect resolved config', 'Done'); + if (action === 'Inspect resolved config') { + await vscode.commands.executeCommand('b2c-dx.walkthrough.inspectSetup'); + } +} + +/** Reusable secret-placement → apply helper for an OAuth or Basic pair. */ +async function applySecretPair( + inst: string, + pair: 'oauth' | 'basic', + placement: SecretPlacement, + values: Record, + workspaceRoot: string, +): Promise<{report: string[]; errors: string[]; terminalLines: string[]}> { + const report: string[] = []; + const errors: string[] = []; + const terminalLines: string[] = []; + + const writeDwJsonInline = async (fields: Record) => { + const {doc, entry} = await readOrCreateConfigEntry(workspaceRoot, inst); + Object.assign(entry, fields); + await writeConfigDoc(workspaceRoot, doc); + }; + + if (pair === 'oauth') { + const {clientId, clientSecret} = values; + if (placement === 'macos-keychain') { + try { + await writeKeychainPair(inst, {clientId, clientSecret}); + report.push(`Keychain: b2c-cli/${inst} (clientId, clientSecret) ✓`); + } catch (e) { + errors.push(`Keychain (OAuth): ${e instanceof Error ? e.message : String(e)}`); + } + } else if (placement === 'password-store') { + terminalLines.push( + `b2c plugins install sfcc-solutions-share/b2c-plugin-password-store`, + `pass insert -m b2c-cli/${inst}-oauth <<'EOF'`, + clientSecret, + `clientId: ${clientId}`, + `clientSecret: ${clientSecret}`, + `EOF`, + ); + report.push(`Password Store: b2c-cli/${inst}-oauth (queued in terminal)`); + } else if (placement === 'env') { + terminalLines.push( + `export SFCC_CLIENT_ID=${shellEscape(clientId)}`, + `export SFCC_CLIENT_SECRET=${shellEscape(clientSecret)}`, + ); + report.push('Env vars: SFCC_CLIENT_ID, SFCC_CLIENT_SECRET (queued in terminal)'); + } else if (placement === 'dw-json') { + await writeDwJsonInline({'client-id': clientId, 'client-secret': clientSecret}); + report.push('dw.json: client-id, client-secret ✓'); + } + } else if (pair === 'basic') { + const {username, password} = values; + if (placement === 'macos-keychain') { + try { + await writeKeychainPair(`${inst}-basic`, {username, password}); + report.push(`Keychain: b2c-cli/${inst}-basic (username, password) ✓`); + } catch (e) { + errors.push(`Keychain (Basic): ${e instanceof Error ? e.message : String(e)}`); + } + } else if (placement === 'password-store') { + terminalLines.push( + `b2c plugins install sfcc-solutions-share/b2c-plugin-password-store`, + `pass insert -m b2c-cli/${inst}-basic <<'EOF'`, + password, + `username: ${username}`, + `password: ${password}`, + `EOF`, + ); + report.push(`Password Store: b2c-cli/${inst}-basic (queued in terminal)`); + } else if (placement === 'env') { + terminalLines.push( + `export SFCC_USERNAME=${shellEscape(username)}`, + `export SFCC_PASSWORD=${shellEscape(password)}`, + ); + report.push('Env vars: SFCC_USERNAME, SFCC_PASSWORD (queued in terminal)'); + } else if (placement === 'dw-json') { + await writeDwJsonInline({username, password}); + report.push('dw.json: username, password ✓'); + } + } + return {report, errors, terminalLines}; +} + +function flushTerminal(inst: string, lines: string[]): void { + if (!lines.length) return; + const term = vscode.window.createTerminal({name: `B2C DX — ${inst} setup`}); + term.show(); + term.sendText('# Review each line and press Enter to run.', false); + for (const l of lines) term.sendText(l, false); +} + +// ─── Step 1 — Connection (instance name + hostname + code-version) ────── +async function runConnectionStep(context: vscode.ExtensionContext): Promise { + const wsFolder = vscode.workspace.workspaceFolders?.[0]; + if (!wsFolder) { + vscode.window.showErrorMessage('B2C DX: Open a workspace folder first.'); + return; + } + const inst = await ensureInstanceName(context); + if (!inst) return; + + const hostname = await vscode.window.showInputBox({ + title: `Connect · ${inst} · hostname`, + prompt: 'Instance hostname (no https://)', + placeHolder: 'abcd-123.dx.commercecloud.salesforce.com', + ignoreFocusOut: true, + validateInput: (v) => (v.trim().length > 0 ? null : 'Required'), + }); + if (!hostname) return; + const codeVersion = await vscode.window.showInputBox({ + title: `Connect · ${inst} · code-version (optional)`, + prompt: 'Default code version targeted by deploys', + placeHolder: 'version1', + ignoreFocusOut: true, + }); + + try { + const {doc, entry} = await readOrCreateConfigEntry(wsFolder.uri.fsPath, inst); + entry.hostname = hostname; + if (codeVersion) entry['code-version'] = codeVersion; + else delete entry['code-version']; + await writeConfigDoc(wsFolder.uri.fsPath, doc); + await openFile(path.join(wsFolder.uri.fsPath, 'dw.json')); + await offerInspectFollowUp(`Connection saved to dw.json (${inst}).`); + } catch (e) { + vscode.window.showErrorMessage(`B2C DX: ${e instanceof Error ? e.message : String(e)}`); + } +} + +// ─── Step 2 — OAuth credentials ──────────────────────── +async function runOAuthStep(context: vscode.ExtensionContext): Promise { + const wsFolder = vscode.workspace.workspaceFolders?.[0]; + if (!wsFolder) { + vscode.window.showErrorMessage('B2C DX: Open a workspace folder first.'); + return; + } + const inst = await ensureInstanceName(context); + if (!inst) return; + + const placementPicked = await vscode.window.showQuickPick(placementItems(defaultSecretPlacement()), { + title: `OAuth · ${inst} · where should client-id + client-secret live?`, + placeHolder: 'Both halves of the OAuth pair stay together (Credential Grouping rule).', + ignoreFocusOut: true, + }); + if (!placementPicked) return; + + const clientId = await secretInput({ + title: `OAuth · ${inst} · client-id`, + prompt: 'Paste your client-id (visible — it is an identifier).', + placeholder: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }); + if (clientId === undefined) return; + const clientSecret = await secretInput({ + title: `OAuth · ${inst} · client-secret`, + prompt: 'Paste your client-secret (input is masked).', + placeholder: '••••••••••••••••••••', + password: true, + }); + if (clientSecret === undefined) return; + + const {report, errors, terminalLines} = await applySecretPair( + inst, + 'oauth', + placementPicked.id, + {clientId, clientSecret}, + wsFolder.uri.fsPath, + ); + flushTerminal(inst, terminalLines); + const summary = report.length ? report.map((l) => ` • ${l}`).join('\n') : ' (none)'; + const errSummary = errors.length ? '\n\nErrors:\n' + errors.map((e) => ` • ${e}`).join('\n') : ''; + await offerInspectFollowUp(`OAuth credentials applied for ${inst}.\n\nApplied:\n${summary}${errSummary}`); +} + +// ─── Step 3 — WebDAV credentials (Basic auth) ────────── +async function runWebDavStep(context: vscode.ExtensionContext): Promise { + const wsFolder = vscode.workspace.workspaceFolders?.[0]; + if (!wsFolder) { + vscode.window.showErrorMessage('B2C DX: Open a workspace folder first.'); + return; + } + const inst = await ensureInstanceName(context); + if (!inst) return; + + const placementPicked = await vscode.window.showQuickPick(placementItems(defaultSecretPlacement()), { + title: `WebDAV · ${inst} · where should username + password live?`, + placeHolder: 'Both halves of the Basic pair stay together (Credential Grouping rule).', + ignoreFocusOut: true, + }); + if (!placementPicked) return; + + const username = await secretInput({ + title: `WebDAV · ${inst} · username`, + prompt: 'Your Business Manager username.', + placeholder: 'you@example.com', + }); + if (username === undefined) return; + const password = await secretInput({ + title: `WebDAV · ${inst} · access key (password)`, + prompt: 'Paste your WebDAV access key (input is masked).', + placeholder: '••••••••••••••••••••', + password: true, + }); + if (password === undefined) return; + + const {report, errors, terminalLines} = await applySecretPair( + inst, + 'basic', + placementPicked.id, + {username, password}, + wsFolder.uri.fsPath, + ); + flushTerminal(inst, terminalLines); + const summary = report.length ? report.map((l) => ` • ${l}`).join('\n') : ' (none)'; + const errSummary = errors.length ? '\n\nErrors:\n' + errors.map((e) => ` • ${e}`).join('\n') : ''; + await offerInspectFollowUp(`WebDAV credentials applied for ${inst}.\n\nApplied:\n${summary}${errSummary}`); +} + +// ─── Step 4 — SCAPI extras ───────────────────────────── +async function runScapiStep(context: vscode.ExtensionContext): Promise { + const wsFolder = vscode.workspace.workspaceFolders?.[0]; + if (!wsFolder) { + vscode.window.showErrorMessage('B2C DX: Open a workspace folder first.'); + return; + } + const inst = await ensureInstanceName(context); + if (!inst) return; + + const shortCode = await vscode.window.showInputBox({ + title: `SCAPI · ${inst} · short-code`, + prompt: 'Your organisation short code (from Account Manager)', + placeHolder: 'kv7kzm78', + ignoreFocusOut: true, + }); + if (shortCode === undefined) return; + const tenantId = await vscode.window.showInputBox({ + title: `SCAPI · ${inst} · tenant-id`, + prompt: 'Your tenant ID', + placeHolder: 'zzrf_001', + ignoreFocusOut: true, + }); + if (tenantId === undefined) return; + const oauthScopes = await vscode.window.showInputBox({ + title: `SCAPI · ${inst} · oauth-scopes (optional)`, + prompt: 'Space-separated SCAPI scopes', + placeHolder: 'sfcc.shopper-customers sfcc.shopper-products', + ignoreFocusOut: true, + }); + + try { + const {doc, entry} = await readOrCreateConfigEntry(wsFolder.uri.fsPath, inst); + if (shortCode) entry['short-code'] = shortCode; + if (tenantId) entry['tenant-id'] = tenantId; + if (oauthScopes) entry['oauth-scopes'] = oauthScopes; + await writeConfigDoc(wsFolder.uri.fsPath, doc); + await offerInspectFollowUp(`SCAPI fields saved to dw.json (${inst}).`); + } catch (e) { + vscode.window.showErrorMessage(`B2C DX: ${e instanceof Error ? e.message : String(e)}`); + } +} diff --git a/packages/b2c-vs-extension/src/walkthrough/index.ts b/packages/b2c-vs-extension/src/walkthrough/index.ts new file mode 100644 index 000000000..8248fb29c --- /dev/null +++ b/packages/b2c-vs-extension/src/walkthrough/index.ts @@ -0,0 +1,20 @@ +/* + * 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 + */ + +export {registerWalkthroughCommands, showWalkthroughOnFirstActivation} from './commands.js'; +export {initializeTelemetry, getTelemetry} from './telemetry.js'; +export { + validateWalkthroughAccessibility, + formatAccessibilityReport, + checkWalkthroughAccessibilityCommand, +} from './accessibility.js'; +export {validateWalkthroughConfiguration, formatValidationResult, validateWalkthroughCommand} from './validator.js'; +export {OnboardingStateStore} from './state.js'; +export {OnboardingPanel} from './onboardingPanel.js'; +export {PERSONAS, listPersonas, resolveSteps, STEP_CATALOG} from './personas.js'; +export type {PersonaId, PersonaDefinition, StepDefinition, StepAction} from './personas.js'; +export {detectTools, generateInstallCliHtml} from './toolDetection.js'; +export type {ToolDetectionResult, ToolStatus} from './toolDetection.js'; diff --git a/packages/b2c-vs-extension/src/walkthrough/markdown.ts b/packages/b2c-vs-extension/src/walkthrough/markdown.ts new file mode 100644 index 000000000..f75259ca7 --- /dev/null +++ b/packages/b2c-vs-extension/src/walkthrough/markdown.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 + */ + +/** + * Minimal, trust-the-source markdown renderer for our own walkthrough content. + * Handles the subset used by media/walkthrough/*.md: headings, paragraphs, lists + * (ordered + unordered, including nested), fenced code, inline code, emphasis, + * bold, links, and horizontal rules. Escapes all raw HTML — we never embed + * user-authored content here, but defense in depth. + * + * We avoid adding `marked` / `markdown-it` to the extension bundle for a + * ~30KB saving; the rule set below covers every construct present in the + * existing nine walkthrough pages. + */ + +const HTML_ESCAPE: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +function escapeHtml(text: string): string { + return text.replace(/[&<>"']/g, (c) => HTML_ESCAPE[c] ?? c); +} + +function renderInline(text: string): string { + let out = escapeHtml(text); + // Inline code: `foo` + out = out.replace(/`([^`]+)`/g, (_, code) => `${code}`); + // Links: [label](url) + out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => { + const safeUrl = /^(https?:|mailto:|command:|#)/i.test(url) ? url : '#'; + return `${label}`; + }); + // Bold: **foo** or __foo__ + out = out.replace(/\*\*([^*]+)\*\*/g, '$1'); + out = out.replace(/__([^_]+)__/g, '$1'); + // Emphasis: *foo* or _foo_ + out = out.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2'); + out = out.replace(/(^|[^_])_([^_\n]+)_/g, '$1$2'); + return out; +} + +interface ListState { + ordered: boolean; + indent: number; + items: string[]; +} + +export function renderMarkdown(input: string): string { + const lines = input.replace(/\r\n/g, '\n').split('\n'); + const out: string[] = []; + const listStack: ListState[] = []; + let paragraph: string[] = []; + let inCodeBlock = false; + let codeLang = ''; + let codeBuffer: string[] = []; + + const flushParagraph = () => { + if (paragraph.length === 0) return; + out.push(`

${renderInline(paragraph.join(' '))}

`); + paragraph = []; + }; + + const closeListsTo = (indent: number) => { + while (listStack.length > 0 && listStack[listStack.length - 1].indent >= indent) { + const list = listStack.pop()!; + const tag = list.ordered ? 'ol' : 'ul'; + out.push(`<${tag}>${list.items.map((i) => `
  • ${i}
  • `).join('')}`); + } + }; + + for (const rawLine of lines) { + const line = rawLine.replace(/\t/g, ' '); + + // Fenced code blocks. When consecutive code blocks are separated only + // by blank lines (the renderer's blank-line rule fires between them), + // we merge them into a single
     with newline separators so they
    +    // render as a tight stanza — no per-block padding stacking.
    +    const fence = line.match(/^```(\w*)\s*$/);
    +    if (fence) {
    +      if (inCodeBlock) {
    +        const html = `
    ${escapeHtml(
    +          codeBuffer.join('\n'),
    +        )}
    `; + // If the previous emission was a
     (i.e. nothing in between but
    +        // whitespace-driven flushParagraph/closeListsTo no-ops), merge the
    +        // two into one block by stripping the closing tag of the previous
    +        // and the opening tag of the new one.
    +        const last = out[out.length - 1];
    +        if (last && last.startsWith('
    ') && last.endsWith('
    ')) { + // Reuse the previous
    's opening; concatenate inner content
    +          // separated by a blank-line so commands stay readable.
    +          const merged =
    +            last.slice(0, last.length - '
    '.length) + + '\n' + + escapeHtml(codeBuffer.join('\n')) + + '
    '; + out[out.length - 1] = merged; + } else { + out.push(html); + } + inCodeBlock = false; + codeBuffer = []; + codeLang = ''; + } else { + flushParagraph(); + closeListsTo(0); + inCodeBlock = true; + codeLang = fence[1] ?? ''; + } + continue; + } + if (inCodeBlock) { + codeBuffer.push(line); + continue; + } + + // Blank line ends paragraph and any open lists at deeper indents than 0 + if (/^\s*$/.test(line)) { + flushParagraph(); + closeListsTo(0); + continue; + } + + // Horizontal rule + if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) { + flushParagraph(); + closeListsTo(0); + out.push('
    '); + continue; + } + + // Heading + const heading = line.match(/^(#{1,6})\s+(.*)$/); + if (heading) { + flushParagraph(); + closeListsTo(0); + const level = heading[1].length; + out.push(`${renderInline(heading[2].trim())}`); + continue; + } + + // List item (unordered or ordered) + const listItem = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/); + if (listItem) { + flushParagraph(); + const indent = listItem[1].length; + const ordered = /^\d+\./.test(listItem[2]); + const content = renderInline(listItem[3]); + + // Close lists deeper than current indent + while (listStack.length > 0 && listStack[listStack.length - 1].indent > indent) { + const list = listStack.pop()!; + const tag = list.ordered ? 'ol' : 'ul'; + const html = `<${tag}>${list.items.map((i) => `
  • ${i}
  • `).join('')}`; + const parent = listStack[listStack.length - 1]; + if (parent) { + parent.items[parent.items.length - 1] += html; + } else { + out.push(html); + } + } + + const top = listStack[listStack.length - 1]; + if (top && top.indent === indent && top.ordered === ordered) { + top.items.push(content); + } else { + if (top && top.indent === indent && top.ordered !== ordered) { + // Same indent, different type — close the old one. + const list = listStack.pop()!; + const tag = list.ordered ? 'ol' : 'ul'; + const html = `<${tag}>${list.items.map((i) => `
  • ${i}
  • `).join('')}`; + const parent = listStack[listStack.length - 1]; + if (parent) parent.items[parent.items.length - 1] += html; + else out.push(html); + } + listStack.push({ordered, indent, items: [content]}); + } + continue; + } + + // Paragraph line + closeListsTo(0); + paragraph.push(line.trim()); + } + + flushParagraph(); + closeListsTo(0); + if (inCodeBlock) { + out.push(`
    ${escapeHtml(codeBuffer.join('\n'))}
    `); + } + return out.join('\n'); +} diff --git a/packages/b2c-vs-extension/src/walkthrough/onboardingPanel.ts b/packages/b2c-vs-extension/src/walkthrough/onboardingPanel.ts new file mode 100644 index 000000000..6fd68b5ea --- /dev/null +++ b/packages/b2c-vs-extension/src/walkthrough/onboardingPanel.ts @@ -0,0 +1,1853 @@ +/* + * 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 * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs/promises'; + +import {OnboardingStateStore, StepStatus} from './state.js'; +import {PERSONAS, PersonaId, StepAction, StepDefinition, getPersona, listPersonas, resolveSteps} from './personas.js'; +import {renderMarkdown} from './markdown.js'; +import {detectTools, generateInstallCliHtml, ToolDetectionResult} from './toolDetection.js'; + +type InboundMessage = + | {type: 'selectPersona'; personaId: PersonaId} + | {type: 'changePersona'} + | {type: 'openStep'; stepId: string} + | {type: 'completeStep'; stepId: string} + | {type: 'skipStep'; stepId: string} + | {type: 'goNext'} + | {type: 'goPrev'} + | {type: 'runAction'; command: string; args?: unknown[]; stepId?: string} + | {type: 'openLink'; url: string} + | {type: 'reset'} + | {type: 'ready'}; + +interface PersonaView { + id: PersonaId; + label: string; + tagline: string; + description: string; + stepCount: number; + estimatedMinutes: number; + recommended?: boolean; +} + +interface StepView { + id: string; + title: string; + summary: string; + status: StepStatus; + actions: StepAction[]; + html: string; +} + +interface ViewState { + persona: PersonaView | null; + personas: PersonaView[]; + steps: StepView[]; + activeStepId: string | null; + setupInstance: string | null; +} + +export class OnboardingPanel { + private static current: OnboardingPanel | undefined; + + static show(context: vscode.ExtensionContext, store: OnboardingStateStore, log: vscode.OutputChannel): void { + if (OnboardingPanel.current) { + OnboardingPanel.current.panel.reveal(); + return; + } + const panel = vscode.window.createWebviewPanel('b2c-dx.onboarding', 'B2C DX: Get Started', vscode.ViewColumn.One, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.file(context.extensionPath)], + }); + OnboardingPanel.current = new OnboardingPanel(context, store, log, panel); + } + + private readonly disposables: vscode.Disposable[] = []; + private activeStepId: string | null = null; + + private constructor( + private readonly context: vscode.ExtensionContext, + private readonly store: OnboardingStateStore, + private readonly log: vscode.OutputChannel, + private readonly panel: vscode.WebviewPanel, + ) { + this.panel.webview.html = this.renderShell(); + this.disposables.push( + this.panel.onDidDispose(() => this.dispose()), + this.panel.webview.onDidReceiveMessage((msg) => this.handleMessage(msg as InboundMessage)), + this.store.onDidChange(() => void this.refresh()), + ); + } + + private async handleMessage(msg: InboundMessage): Promise { + try { + switch (msg.type) { + case 'ready': + await this.refresh(); + return; + case 'selectPersona': + await this.store.setPersona(msg.personaId); + this.activeStepId = PERSONAS[msg.personaId]?.stepIds[0] ?? null; + await this.refresh(); + return; + case 'changePersona': + await this.store.setPersona(null); + this.activeStepId = null; + await this.refresh(); + return; + case 'openStep': { + const persona = this.store.getPersona(); + if (!persona) return; + // Ignore clicks on locked steps: the user must complete predecessors. + const view = await this.buildViewState(); + const target = view.steps.find((s) => s.id === msg.stepId); + if (!target || target.status === 'locked') return; + this.activeStepId = msg.stepId; + await this.store.markStarted(persona, msg.stepId); + await this.refresh(); + return; + } + case 'completeStep': { + const persona = this.store.getPersona(); + if (!persona) return; + await this.store.markCompleted(persona, msg.stepId); + return; + } + case 'skipStep': { + const persona = this.store.getPersona(); + if (!persona) return; + await this.store.markSkipped(persona, msg.stepId); + return; + } + case 'runAction': { + const persona = this.store.getPersona(); + if (persona && msg.stepId) { + await this.store.markStarted(persona, msg.stepId); + } + await vscode.commands.executeCommand(msg.command, ...(msg.args ?? [])); + // Setup commands mutate workspaceState; ensure the chip + Start Over + // button re-render once the action returns. + if (typeof msg.command === 'string' && msg.command.startsWith('b2c-dx.setup.')) { + await this.refresh(); + } + // CLI actions (install, recheck) should re-detect tools and refresh. + if (typeof msg.command === 'string' && msg.command.startsWith('b2c-dx.cli.')) { + this.invalidateToolDetection(); + await this.refresh(); + } + return; + } + case 'openLink': { + // All markdown link clicks route through here. Safely dispatch + // command: URIs and open http(s) externally; ignore anything else. + const url = msg.url; + if (url.startsWith('command:')) { + const rest = url.slice('command:'.length); + const qIdx = rest.indexOf('?'); + const commandId = qIdx >= 0 ? rest.slice(0, qIdx) : rest; + let args: unknown[] = []; + if (qIdx >= 0) { + try { + const parsed = JSON.parse(decodeURIComponent(rest.slice(qIdx + 1))); + args = Array.isArray(parsed) ? parsed : [parsed]; + } catch { + args = []; + } + } + await vscode.commands.executeCommand(commandId, ...args); + } else if (/^https?:/i.test(url) || url.startsWith('mailto:')) { + await vscode.env.openExternal(vscode.Uri.parse(url)); + } + return; + } + case 'goNext': { + const persona = this.store.getPersona(); + if (!persona) return; + const steps = resolveSteps(persona as PersonaId); + const currentIdx = steps.findIndex((s) => s.id === this.activeStepId); + if (currentIdx >= 0) { + await this.store.markCompleted(persona, steps[currentIdx].id); + } + const next = steps[currentIdx + 1]; + if (next) { + this.activeStepId = next.id; + await this.store.markStarted(persona, next.id); + } + await this.refresh(); + return; + } + case 'goPrev': { + const persona = this.store.getPersona(); + if (!persona) return; + const steps = resolveSteps(persona as PersonaId); + const currentIdx = steps.findIndex((s) => s.id === this.activeStepId); + const prev = currentIdx > 0 ? steps[currentIdx - 1] : null; + if (prev) { + this.activeStepId = prev.id; + await this.refresh(); + } + return; + } + case 'reset': + await this.store.reset(); + this.activeStepId = null; + await this.refresh(); + return; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.log.appendLine(`[onboarding] handleMessage(${msg.type}) failed: ${message}`); + vscode.window.showErrorMessage(`Onboarding: ${message}`); + } + } + + private async refresh(): Promise { + const view = await this.buildViewState(); + if (view.persona) { + const active = view.steps.find((s) => s.id === this.activeStepId); + // If no active step, or the active one is now locked (shouldn't happen + // but defend against it), pick the first step the user can work on. + if (!active || active.status === 'locked') { + const pick = + view.steps.find((s) => s.status === 'in-progress') ?? + view.steps.find((s) => s.status === 'available') ?? + view.steps[0]; + this.activeStepId = pick?.id ?? null; + } + } + this.panel.webview.postMessage({type: 'state', state: {...view, activeStepId: this.activeStepId}}); + } + + private async buildViewState(): Promise { + // Average step ≈ 3.5 min. The "ai-augmented" persona gets a `recommended` + // flag so the gate highlights it as the new path. + const personas: PersonaView[] = listPersonas().map((p) => ({ + id: p.id, + label: p.label, + tagline: p.tagline, + description: p.description, + stepCount: p.stepIds.length, + estimatedMinutes: Math.max(15, Math.round((p.stepIds.length * 3.5) / 5) * 5), + recommended: p.id === 'ai-augmented', + })); + const personaId = this.store.getPersona(); + const personaDef = getPersona(personaId); + const setupInstance = this.context.workspaceState.get('b2c-dx.setup.activeInstance') ?? null; + if (!personaDef) { + return {persona: null, personas, steps: [], activeStepId: null, setupInstance}; + } + const defs = resolveSteps(personaDef.id); + const rawSteps = await Promise.all(defs.map((def) => this.buildStepView(personaDef.id, def))); + // Sequential gating: a step is locked until every step before it is done + // or skipped. The first step is always available. + const steps = rawSteps.map((step, idx) => { + if (idx === 0) return step; + const allPriorResolved = rawSteps + .slice(0, idx) + .every((prior) => prior.status === 'done' || prior.status === 'skipped'); + if (!allPriorResolved && step.status !== 'done') { + return {...step, status: 'locked' as const}; + } + return step; + }); + const activePersonaView = personas.find((p) => p.id === personaDef.id) ?? { + id: personaDef.id, + label: personaDef.label, + tagline: personaDef.tagline, + description: personaDef.description, + stepCount: personaDef.stepIds.length, + estimatedMinutes: Math.max(15, Math.round((personaDef.stepIds.length * 3.5) / 5) * 5), + }; + return { + persona: activePersonaView, + personas, + steps, + activeStepId: this.activeStepId, + setupInstance, + }; + } + + private toolDetectionCache: ToolDetectionResult | null = null; + + private async buildStepView(personaId: PersonaId, def: StepDefinition): Promise { + const record = this.store.getStep(personaId, def.id); + let html: string; + let actions = def.actions ?? []; + + if (def.id === 'install-cli') { + const result = await this.getToolDetection(); + html = generateInstallCliHtml(result); + actions = this.buildInstallCliActions(result); + } else { + const markdown = await this.readMarkdown(def.markdown); + html = renderMarkdown(markdown); + } + + return { + id: def.id, + title: def.title, + summary: def.summary, + status: record?.status ?? 'available', + actions, + html, + }; + } + + private async getToolDetection(): Promise { + if (!this.toolDetectionCache) { + const cached = this.context.globalState.get<{version: string; fetchedAt: number}>( + 'b2c-dx.cli.latestVersionCache', + ); + const latestVersion = cached?.version; + this.toolDetectionCache = await detectTools(latestVersion); + } + return this.toolDetectionCache; + } + + /** Invalidates cached detection so the next refresh re-detects. */ + invalidateToolDetection(): void { + this.toolDetectionCache = null; + } + + private buildInstallCliActions(result: ToolDetectionResult): StepAction[] { + const actions: StepAction[] = []; + + if (!result.b2cCli.installed) { + if (result.npm.installed) { + actions.push({label: 'Install via npm', command: 'b2c-dx.cli.installNpm', primary: true}); + } else if (result.homebrew.installed) { + actions.push({label: 'Install via Homebrew', command: 'b2c-dx.cli.installBrew', primary: true}); + } + actions.push({label: 'Verify CLI', command: 'b2c-dx.cli.verify'}); + actions.push({label: 'Re-check', command: 'b2c-dx.cli.recheck'}); + } else if (result.b2cCliOutdated) { + actions.push({label: 'Update CLI', command: 'b2c-dx.cli.update', primary: true}); + actions.push({label: 'Verify CLI', command: 'b2c-dx.cli.verify'}); + actions.push({label: 'Re-check', command: 'b2c-dx.cli.recheck'}); + } else { + actions.push({label: 'Verify CLI', command: 'b2c-dx.cli.verify', primary: true}); + actions.push({label: 'Update CLI', command: 'b2c-dx.cli.update'}); + actions.push({label: 'Re-check', command: 'b2c-dx.cli.recheck'}); + } + + return actions; + } + + private async readMarkdown(relativePath: string): Promise { + try { + const abs = path.join(this.context.extensionPath, relativePath); + return await fs.readFile(abs, 'utf-8'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.log.appendLine(`[onboarding] failed to read ${relativePath}: ${message}`); + return `# Content unavailable\n\n\`${relativePath}\` could not be loaded.`; + } + } + + private renderShell(): string { + const webview = this.panel.webview; + const nonce = makeNonce(); + const cspSource = webview.cspSource; + const csp = [ + `default-src 'none'`, + `img-src ${cspSource} https: data:`, + `style-src ${cspSource} 'unsafe-inline'`, + `script-src 'nonce-${nonce}'`, + `font-src ${cspSource}`, + ].join('; '); + + return /* html */ ` + + + + + B2C DX: Get Started + + + + + + + + + + + +`; + } + + dispose(): void { + OnboardingPanel.current = undefined; + this.disposables.forEach((d) => d.dispose()); + this.panel.dispose(); + } +} + +function makeNonce(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let out = ''; + for (let i = 0; i < 32; i++) out += chars.charAt(Math.floor(Math.random() * chars.length)); + return out; +} + +const PANEL_CSS = ` +:root { + color-scheme: light dark; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --sidebar-width: 308px; + --content-max: 920px; + --brand-blue: #0176D3; + --brand-blue-deep: #014486; + --brand-blue-soft: rgba(1, 118, 211, 0.10); + --brand-blue-hairline: rgba(1, 118, 211, 0.28); + --brand-green: #1A8754; + --brand-green-bright: #2FA86A; + --brand-green-soft: rgba(26, 135, 84, 0.12); + --brand-green-hairline: rgba(26, 135, 84, 0.40); + /* Status palette for the sidebar checklist: + done = green, in-progress = amber/yellow, idle/locked/skipped = neutral grey. */ + --status-amber: #C77700; + --status-amber-bright: #E58A0F; + --status-amber-soft: rgba(199, 119, 0, 0.14); + --status-grey: rgba(127, 127, 127, 0.45); + --status-grey-soft: rgba(127, 127, 127, 0.18); + --surface-card: var(--vscode-editorWidget-background, var(--vscode-editor-background)); + --surface-elevated: var(--vscode-sideBar-background, var(--vscode-editor-background)); + --hairline: var(--vscode-panel-border, var(--vscode-editorGroup-border, rgba(128,128,128,0.25))); + --shadow-sm: 0 1px 2px rgba(0,0,0,0.06), 0 2px 8px rgba(0,0,0,0.04); + --shadow-md: 0 4px 14px rgba(0,0,0,0.08), 0 2px 6px rgba(0,0,0,0.04); +} +* { box-sizing: border-box; } +body { + margin: 0; + font-family: var(--vscode-font-family); + color: var(--vscode-foreground); + /* Layered page gradient: a soft brand-blue radial wash at the top-left, + plus a faint diagonal gradient that fades into the editor background. + Reads as a polished hero surface in light mode and stays subtle in dark + mode because the brand-blue tints sit at low alpha against any base. */ + background: + radial-gradient(ellipse 1200px 600px at 8% -10%, var(--brand-blue-soft), transparent 60%), + radial-gradient(ellipse 900px 500px at 110% 0%, rgba(26, 135, 84, 0.06), transparent 55%), + linear-gradient(180deg, var(--vscode-editor-background) 0%, var(--vscode-editor-background) 100%); + background-attachment: fixed; + padding: 0; + min-height: 100vh; +} +h1, h2, h3 { letter-spacing: -0.01em; } +.muted { color: var(--vscode-descriptionForeground); margin: 0; } +.eyebrow { + display: inline-block; + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.7rem; + font-weight: 600; + color: var(--brand-blue); + margin-bottom: 8px; +} + +/* ─── Brand bar ─────────────────────────────────────── */ +.brand-bar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 32px; + /* Translucent so the body's soft page gradient shows through. */ + background: color-mix(in srgb, var(--vscode-editor-background) 80%, transparent); + border-bottom: 1px solid var(--hairline); + backdrop-filter: saturate(180%) blur(8px); +} +.brand { display: flex; align-items: center; gap: 14px; min-width: 0; } +.brand-mark { + font-family: 'Inter', 'SF Pro Display', 'Segoe UI Variable Display', 'Segoe UI', system-ui, -apple-system, sans-serif; + font-weight: 800; + font-size: 1.55rem; + letter-spacing: -0.02em; + line-height: 1; + white-space: nowrap; +} +.brand-mark__b2c { + color: var(--brand-blue); + background: linear-gradient(135deg, #1B96FF 0%, var(--brand-blue) 50%, var(--brand-blue-deep) 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + padding-right: 6px; +} +.brand-mark__dx { + color: var(--brand-blue); + font-style: italic; + font-weight: 700; + position: relative; +} +.brand-mark__dx::before { + content: "·"; + color: var(--brand-blue); + margin-right: 6px; + font-style: normal; + font-weight: 700; +} +.brand-divider { + width: 1px; + height: 22px; + background: var(--hairline); + margin: 0 4px; +} +.brand-tag { + font-size: 0.78rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--vscode-descriptionForeground); +} +/* Active-session chip — shown when a dw.json setup session has named an + instance for this workspace. Clicking it isn't required; users reach the + reset action via the adjacent "Start over" button. */ +.setup-chip { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: 12px; + padding: 4px 10px; + border-radius: 999px; + background: var(--brand-green-soft); + border: 1px solid var(--brand-green-hairline); + color: var(--brand-green); + font-size: 0.74rem; + font-weight: 600; + letter-spacing: 0.04em; +} +.setup-chip__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--brand-green); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--brand-green) 22%, transparent); +} +.brand-actions { display: flex; gap: 8px; flex-shrink: 0; align-items: center; } +button.icon-only { + width: 36px; + height: 36px; + padding: 0; + display: inline-grid; + place-items: center; + border-radius: 50%; +} +button.icon-only .theme-glyph { + font-size: 1.05rem; + line-height: 1; + display: inline-block; + transition: transform 200ms ease; +} +button.icon-only:hover .theme-glyph { transform: rotate(20deg); } +button { + background: var(--brand-blue); + color: #fff; + border: 1px solid transparent; + border-radius: var(--radius-sm); + padding: 7px 14px; + font: inherit; + font-weight: 500; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, transform 80ms ease, box-shadow 120ms ease; +} +button:hover { background: var(--brand-blue-deep); } +button:active { transform: translateY(1px); } +button:focus-visible { + outline: 2px solid var(--brand-blue); + outline-offset: 2px; +} +button.ghost { + background: transparent; + color: var(--vscode-foreground); + border-color: var(--hairline); +} +button.ghost:hover { + background: var(--brand-blue-soft); + border-color: var(--brand-blue-hairline); + color: var(--brand-blue); +} +button.secondary { + background: var(--vscode-button-secondaryBackground, transparent); + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); + border-color: var(--hairline); +} +button.secondary:hover { + background: var(--vscode-button-secondaryHoverBackground, var(--brand-blue-soft)); +} + +/* ─── Persona gate ─────────────────────────────────── */ +#persona-gate { + position: relative; + max-width: 1080px; + margin: 0 auto; + padding: 64px 40px 56px; +} + +/* Top-right corner: phase chip + concentric rings */ +.gate-corner { + position: absolute; + top: 56px; + right: 40px; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 18px; + pointer-events: none; + z-index: 1; +} +.phase-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + border-radius: 999px; + background: var(--surface-card); + border: 1px solid var(--brand-blue-hairline); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.18em; + color: var(--vscode-foreground); +} +.phase-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--brand-green); + box-shadow: 0 0 0 3px var(--brand-green-soft); +} +.gate-rings { + width: 160px; + height: 160px; + opacity: 0.55; +} +.gate-ring { stroke: var(--brand-blue-hairline); stroke-width: 1; } +.gate-ring.dashed { stroke-dasharray: 2 6; } + +.gate-hero { + position: relative; + z-index: 2; + text-align: center; + margin: 24px auto 32px; + max-width: 760px; +} +.gate-hero .eyebrow { + margin-bottom: 14px; +} +.gate-hero h1 { + font-family: 'Inter','SF Pro Display','Segoe UI Variable Display','Segoe UI',system-ui,-apple-system,sans-serif; + font-size: 3.25rem; + font-weight: 800; + letter-spacing: -0.035em; + line-height: 1.05; + margin: 0 0 16px; +} +.gate-hero .lede { + max-width: 640px; + margin: 0 auto; + font-size: 1.05rem; + line-height: 1.6; + color: var(--vscode-descriptionForeground); +} + +/* Stat strip — enterprise trust signal */ +.stat-strip { + display: grid; + grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr; + align-items: center; + gap: 0; + margin: 0 auto 36px; + max-width: 720px; + padding: 22px 12px; + border-top: 1px solid var(--hairline); + border-bottom: 1px solid var(--hairline); +} +.stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} +.stat-value { + font-family: 'Inter','SF Pro Display','Segoe UI Variable Display','Segoe UI',system-ui,-apple-system,sans-serif; + font-size: 1.85rem; + font-weight: 800; + letter-spacing: -0.03em; + line-height: 1; + color: var(--vscode-foreground); +} +.stat-value small { + font-size: 0.55em; + font-weight: 700; + margin-left: 2px; + color: var(--vscode-descriptionForeground); +} +.stat-value.stat-check { color: var(--brand-green); } +.stat-label { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--vscode-descriptionForeground); +} +.stat-divider { + width: 1px; + height: 36px; + background: var(--hairline); +} +@media (max-width: 720px) { + .stat-strip { grid-template-columns: 1fr 1fr; gap: 18px 0; } + .stat-divider { display: none; } +} + +/* Role cards */ +.persona-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; + margin-bottom: 32px; +} +@media (max-width: 720px) { .persona-grid { grid-template-columns: 1fr; } } +.persona-card { + position: relative; + color: var(--vscode-foreground); + background: var(--surface-card); + border: 1px solid var(--hairline); + border-radius: 16px; + padding: 24px 28px 64px; + display: grid; + grid-template-columns: 56px 1fr; + column-gap: 20px; + row-gap: 6px; + align-items: start; + cursor: pointer; + user-select: none; + overflow: hidden; + box-shadow: var(--shadow-sm); + transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} +.persona-card::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(135deg, var(--brand-blue-soft) 0%, transparent 55%); + opacity: 0; + transition: opacity 160ms ease; + pointer-events: none; +} +.persona-card:hover { + border-color: var(--brand-blue-hairline); + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} +.persona-card:hover::before { opacity: 1; } +.persona-card:hover .persona-arrow { transform: translateX(3px); } +.persona-card:focus-visible { + outline: 2px solid var(--brand-blue); + outline-offset: 3px; +} +.persona-card.is-recommended { + border-color: var(--brand-green-hairline); +} +.persona-card.is-recommended::before { + background: linear-gradient(135deg, var(--brand-green-soft) 0%, transparent 55%); +} +.persona-card.is-recommended:hover { border-color: var(--brand-green-hairline); } + +.persona-avatar { + grid-row: 1 / span 3; + width: 56px; + height: 56px; + border-radius: 14px; + background: linear-gradient(135deg, #1B96FF, var(--brand-blue) 60%, var(--brand-blue-deep)); + color: #fff; + display: grid; + place-items: center; + font-weight: 700; + font-size: 1.05rem; + letter-spacing: 0.02em; + box-shadow: 0 4px 12px rgba(1, 118, 211, 0.25); + position: relative; + z-index: 1; + flex-shrink: 0; +} +.persona-avatar svg { + width: 28px; + height: 28px; + color: #fff; +} +.persona-card.is-recommended .persona-avatar { + background: linear-gradient(135deg, var(--brand-green-bright), var(--brand-green)); + box-shadow: 0 4px 12px rgba(26, 135, 84, 0.30); +} +.persona-card h3 { + margin: 0; + font-size: 1.10rem; + font-weight: 700; + line-height: 1.3; + position: relative; + z-index: 1; +} +.persona-card .tagline { + color: var(--brand-blue); + font-size: 0.86rem; + font-weight: 500; + margin: 0; + line-height: 1.4; + position: relative; + z-index: 1; +} +.persona-card.is-recommended .tagline { color: var(--brand-green); } +.persona-card .desc { + color: var(--vscode-foreground); + opacity: 0.78; + font-size: 0.88rem; + line-height: 1.55; + margin: 10px 0 0; + grid-column: 2; + position: relative; + z-index: 1; +} +.persona-meta { + position: absolute; + left: 28px; + bottom: 26px; + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--brand-green); + z-index: 1; +} +/* Scoped under .persona-card so we beat the generic button.ghost rules + (same fix that the gate-cta pill needed). The pill renders large and + bright so it reads as "the action" at a glance. */ +.persona-card .persona-arrow, +.persona-card span.persona-arrow { + position: absolute; + right: 20px; + bottom: 16px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 18px; + border-radius: 999px; + background: var(--brand-blue); + color: #FFFFFF; + font-size: 0.84rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + box-shadow: 0 6px 14px rgba(1, 118, 211, 0.32), 0 1px 2px rgba(1, 118, 211, 0.30); + transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease; + z-index: 2; + pointer-events: none; /* card receives the click */ +} +.persona-card .persona-arrow > span { color: #FFFFFF; } +.persona-card .persona-arrow svg { color: #FFFFFF; stroke: #FFFFFF; } +.persona-card:hover .persona-arrow { + transform: translateX(4px); + box-shadow: 0 8px 18px rgba(1, 118, 211, 0.42), 0 1px 2px rgba(1, 118, 211, 0.30); +} +.persona-card.is-recommended .persona-arrow { + background: var(--brand-green); + box-shadow: 0 6px 14px rgba(26, 135, 84, 0.32), 0 1px 2px rgba(26, 135, 84, 0.30); +} +.persona-card.is-recommended:hover .persona-arrow { + box-shadow: 0 8px 18px rgba(26, 135, 84, 0.42), 0 1px 2px rgba(26, 135, 84, 0.30); +} +.persona-new-pill { + position: absolute; + top: 22px; + right: 22px; + padding: 3px 10px; + border-radius: 999px; + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.16em; + background: var(--brand-green-soft); + color: var(--brand-green); + border: 1px solid var(--brand-green-hairline); + z-index: 1; +} + +/* Bottom CTA strip */ +.gate-cta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 22px 28px; + border-radius: 16px; + background: linear-gradient(90deg, var(--brand-blue), var(--brand-blue-deep)); + color: #fff; + flex-wrap: wrap; + box-shadow: 0 6px 20px rgba(1, 118, 211, 0.20); +} +.gate-cta__copy { + display: flex; + flex-direction: column; + gap: 2px; +} +.gate-cta__copy strong { + font-size: 1.05rem; + font-weight: 700; +} +.gate-cta__copy span { + font-size: 0.85rem; + opacity: 0.85; +} +/* Scoped under .gate-cta to win against the generic button.ghost:hover + rule above, which would otherwise force the label back to brand-blue + on a brand-blue background. */ +.gate-cta .cta-pill, +.gate-cta button.ghost.cta-pill { + background: rgba(255, 255, 255, 0.16); + color: #FFFFFF; + border: 1px solid rgba(255, 255, 255, 0.55); + border-radius: 999px; + padding: 9px 18px; + font-weight: 600; + font-size: 0.86rem; + letter-spacing: 0.02em; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.15); +} +.gate-cta .cta-pill:hover, +.gate-cta button.ghost.cta-pill:hover, +.gate-cta button.ghost.cta-pill:focus-visible { + background: rgba(255, 255, 255, 0.28); + border-color: rgba(255, 255, 255, 0.85); + color: #FFFFFF; +} + +/* ─── Dashboard ─────────────────────────────────────── */ +#dashboard { + max-width: 1240px; + margin: 0 auto; + padding: 32px 32px 48px; +} +.dashboard-hero { + display: flex; + justify-content: space-between; + /* Vertically centre the progress block against the eyebrow + headline pair. */ + align-items: center; + gap: 32px; + margin-bottom: 28px; + flex-wrap: wrap; +} +.dashboard-hero h1 { + /* Refined enterprise serif-grotesk pairing: prefer Salesforce Sans → + IBM Plex Sans → Source Sans 3 (humanist sans, used by Stripe / Shopify) + before the system-ui fallbacks, so SCAPI / OCAPI sit cleanly without + the chunky display weight from the previous Inter/SF-Pro stack. */ + font-family: + 'Salesforce Sans', 'IBM Plex Sans', 'Source Sans 3', 'Source Sans Pro', + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', + Arial, sans-serif; + font-size: 1.75rem; + font-weight: 600; + font-style: normal; + letter-spacing: -0.01em; + line-height: 1.25; + margin: 0; + max-width: 620px; + color: var(--vscode-foreground); +} +.progress-block { + flex: 0 0 auto; + align-self: center; + width: 320px; + max-width: 100%; +} +.progress-meta { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: 0.82rem; + font-weight: 500; + margin-bottom: 8px; +} +.progress-track { + height: 8px; + border-radius: 999px; + background: var(--brand-blue-soft); + overflow: hidden; + border: 1px solid var(--brand-blue-hairline); +} +.progress-fill { + height: 100%; + width: 0%; + background: linear-gradient(90deg, #1B96FF, var(--brand-blue) 60%, var(--brand-blue-deep)); + transition: width 240ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.layout { + display: grid; + grid-template-columns: var(--sidebar-width) 1fr; + gap: 32px; + align-items: start; +} +@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } } + +/* ─── Sidebar ───────────────────────────────────────── */ +.sidebar { + position: sticky; + top: 80px; + background: var(--surface-elevated); + border: 1px solid var(--hairline); + border-radius: var(--radius-md); + padding: 16px; +} +.sidebar-title { + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.7rem; + font-weight: 600; + color: var(--vscode-descriptionForeground); + padding: 0 6px 10px; +} +.step-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.step-item { + position: relative; + display: flex; + align-items: flex-start; + gap: 12px; + padding: 10px 12px; + border-radius: var(--radius-sm); + cursor: pointer; + border: 1px solid transparent; + color: var(--vscode-foreground); + transition: background 120ms ease, color 120ms ease; +} +.step-item:hover { background: var(--brand-blue-soft); } +.step-item.active { + background: var(--brand-blue-soft); + border-color: var(--brand-blue-hairline); +} +.step-item.active::before { + content: ""; + position: absolute; + left: -16px; + top: 12px; + bottom: 12px; + width: 3px; + background: var(--brand-blue); + border-radius: 0 3px 3px 0; +} +.step-item .status { + flex: 0 0 auto; + width: 22px; + height: 22px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 11px; + font-weight: 600; + margin-top: 1px; + /* Default = "available" / not-yet-touched: greyed disabled-looking dot. */ + background: var(--status-grey-soft); + color: var(--vscode-descriptionForeground); + border: 1px solid var(--status-grey); +} +.step-item[data-status="done"] .status { + background: var(--brand-green); + color: #fff; + border-color: var(--brand-green); + box-shadow: 0 0 0 2px var(--brand-green-soft); +} +.step-item[data-status="in-progress"] .status { + background: var(--status-amber); + color: #fff; + border-color: var(--status-amber); + box-shadow: 0 0 0 2px var(--status-amber-soft); +} +.step-item[data-status="skipped"] .status { + background: var(--status-grey-soft); + color: var(--vscode-descriptionForeground); + border-color: var(--status-grey); + border-style: dashed; +} +.step-item[data-status="locked"] .status { + background: transparent; + color: var(--vscode-descriptionForeground); + border-color: var(--status-grey); + border-style: dashed; +} +.step-item.locked { cursor: not-allowed; opacity: 0.55; } +.step-item.locked:hover { background: transparent; } +.step-item.locked .label { color: var(--vscode-descriptionForeground); } +/* Lighter type weight: the previous 500 read as bold at small sizes. */ +.step-item .label { + font-size: 0.9rem; + line-height: 1.4; + min-width: 0; + font-weight: 400; + letter-spacing: 0.005em; +} +.step-item .label .title { font-weight: 450; color: var(--vscode-foreground); } +.step-item.active .label .title { font-weight: 600; } +.step-item[data-status="done"] .label .title { color: var(--vscode-descriptionForeground); } +.step-item .label small { + display: block; + color: var(--vscode-descriptionForeground); + font-size: 0.72rem; + margin-top: 2px; + font-weight: 400; + letter-spacing: 0.02em; +} + +/* ─── Step card ─────────────────────────────────────── */ +.content { min-width: 0; } +.step-card { + position: relative; + background: var(--surface-card); + border: 1px solid var(--hairline); + border-radius: var(--radius-lg); + padding: 28px 32px; + box-shadow: var(--shadow-sm); + overflow: hidden; +} +.step-card__rail { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background: linear-gradient(180deg, #1B96FF, var(--brand-blue) 50%, var(--brand-blue-deep)); +} +.step-card__header { + display: grid; + grid-template-columns: 56px 1fr; + gap: 18px; + align-items: start; + margin-bottom: 6px; +} +.step-number { + width: 56px; + height: 56px; + border-radius: 14px; + background: linear-gradient(135deg, #1B96FF, var(--brand-blue) 60%, var(--brand-blue-deep)); + color: #fff; + display: grid; + place-items: center; + font-family: 'Inter', system-ui, sans-serif; + font-weight: 800; + font-size: 1.5rem; + letter-spacing: -0.02em; + box-shadow: var(--shadow-sm); +} +.step-card__title-block { min-width: 0; } +.step-position { + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.12em; + font-weight: 600; + color: var(--brand-blue); + margin: 0 0 4px; + display: block; +} +.step-card__title-block h2 { + margin: 0 0 6px; + font-size: 1.45rem; + font-weight: 600; + line-height: 1.25; +} +.step-card__title-block p { + margin: 0; + color: var(--vscode-descriptionForeground); + font-size: 0.95rem; + line-height: 1.55; +} +.step-card__actions { + margin-top: 22px; + padding: 14px 16px; + background: var(--brand-blue-soft); + border: 1px solid var(--brand-blue-hairline); + border-radius: var(--radius-md); +} +.step-card__section-label { + display: block; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.12em; + font-weight: 600; + color: var(--brand-blue-deep); + margin-bottom: 10px; +} +.step-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} +.step-actions button { min-height: 36px; padding: 7px 16px; font-weight: 600; } +/* Scoped under .step-actions so we beat the generic button.ghost rule: + inside the Quick-actions panel a "secondary" button needs to read on + the brand-blue-soft tint, not vanish into it. */ +.step-actions button.ghost, +.step-actions button.ghost:hover, +.step-actions button.ghost:focus-visible { + background: var(--surface-card); + color: var(--brand-blue); + border: 1px solid var(--brand-blue-hairline); +} +.step-actions button.ghost:hover { + background: var(--brand-blue-soft); + border-color: var(--brand-blue); + color: var(--brand-blue-deep); +} +.step-card__body { + margin-top: 22px; + padding-top: 22px; + border-top: 1px solid var(--hairline); +} + +/* ─── Markdown body ─────────────────────────────────── */ +.markdown-body { + line-height: 1.65; + font-size: 0.95rem; + max-width: 720px; +} +.markdown-body > *:first-child { margin-top: 0; } +.markdown-body h1, .markdown-body h2, .markdown-body h3 { + margin-top: 1.6em; + margin-bottom: 0.5em; + font-weight: 600; + letter-spacing: -0.01em; +} +.markdown-body h1 { font-size: 1.25rem; } +.markdown-body h2 { + font-size: 1.1rem; + padding-bottom: 6px; + border-bottom: 1px solid var(--hairline); +} +.markdown-body h3 { font-size: 1rem; color: var(--brand-blue-deep); } +.markdown-body p { margin: 0 0 0.9em; } +.markdown-body code { + background: var(--brand-blue-soft); + color: var(--brand-blue-deep); + padding: 1px 6px; + border-radius: 4px; + font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.86em; + border: 1px solid var(--brand-blue-hairline); +} +.markdown-body pre { + background: var(--vscode-textCodeBlock-background, rgba(127,127,127,0.10)); + padding: 10px 14px; + border-radius: var(--radius-sm); + overflow-x: auto; + border: 1px solid var(--hairline); + margin: 0 0 12px; + /* Tight single-line-height box. Body inherits 1.65; without these resets, + single-line commands render with empty rows of air. */ + line-height: 1.55; + font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.86em; + min-height: 0; + white-space: pre; +} +.markdown-body pre code { + background: transparent; + padding: 0; + border: none; + margin: 0; + color: var(--vscode-foreground); + font: inherit; + line-height: inherit; + /* Inline so the block doesn't add its own line-box height. */ + display: inline; + white-space: inherit; +} +/* Tight stanza for adjacent code blocks. The adjacent-sibling combinator + (+) breaks when the renderer joins blocks with newlines (whitespace + text nodes between siblings); use general-sibling (~) instead. */ +.markdown-body pre ~ pre { margin-top: 0; } +.markdown-body p + pre { margin-top: 0; } +.markdown-body pre + p { margin-top: 8px; } +.markdown-body a { color: var(--brand-blue); text-decoration: none; border-bottom: 1px solid var(--brand-blue-hairline); } +.markdown-body a:hover { color: var(--brand-blue-deep); border-bottom-color: var(--brand-blue); } +.markdown-body hr { border: none; border-top: 1px solid var(--hairline); margin: 24px 0; } +.markdown-body ul, .markdown-body ol { padding-left: 1.4em; } +.markdown-body li { margin: 0.25em 0; } +.markdown-body blockquote { + margin: 0 0 1em; + padding: 10px 16px; + border-left: 3px solid var(--brand-blue); + background: var(--brand-blue-soft); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + color: var(--vscode-foreground); +} +.markdown-body table { + border-collapse: collapse; + width: 100%; + margin: 0 0 1em; + font-size: 0.9em; +} +.markdown-body th, .markdown-body td { + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid var(--hairline); +} +.markdown-body th { + background: var(--brand-blue-soft); + color: var(--brand-blue-deep); + font-weight: 600; +} +.markdown-body strong { font-weight: 600; } + +/* ─── Bottom nav ───────────────────────────────────── */ +/* Sticky to the bottom of the viewport so Previous/Skip/Next stay reachable + without scrolling — matches the pattern Stripe and Datadog use for + long-form onboarding. Translucent background + backdrop-blur lets the + page wash bleed through. */ +.step-nav { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 16px; + margin-top: 24px; + position: sticky; + bottom: 0; + z-index: 5; + padding: 14px 16px 16px; + /* Solid editor background so scrolling body text doesn't bleed through. + The hairline + lifted shadow signal the sticky boundary cleanly. */ + background: var(--vscode-editor-background); + border-top: 1px solid var(--hairline); + border-radius: var(--radius-md) var(--radius-md) 0 0; + box-shadow: 0 -10px 24px -16px rgba(0, 0, 0, 0.18); +} +/* Bottom padding on .content so the last paragraph isn't trapped behind the + sticky bar (~96px = nav height + breathing room). */ +.content { padding-bottom: 96px; } +.nav-btn { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--surface-card); + color: var(--vscode-foreground); + border: 1px solid var(--hairline); + border-radius: var(--radius-md); + cursor: pointer; + text-align: left; + min-height: 56px; + width: 100%; + /* Use the same humanist sans as the dashboard headline so the nav reads + as part of the chrome, not the markdown body. */ + font-family: + 'Salesforce Sans', 'IBM Plex Sans', 'Source Sans 3', 'Source Sans Pro', + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-weight: 500; + letter-spacing: 0; +} +.nav-btn:hover:not([disabled]) { + border-color: var(--brand-blue-hairline); + background: var(--brand-blue-soft); + color: var(--vscode-foreground); +} +.nav-btn[disabled] { opacity: 0.4; cursor: not-allowed; } +.nav-btn.next { justify-content: flex-end; } +.nav-btn.next.primary { + background: var(--brand-blue); + color: #fff; + border-color: var(--brand-blue); +} +.nav-btn.next.primary:hover:not([disabled]) { + background: var(--brand-blue-deep); + color: #fff; +} +.nav-btn .nav-text { display: flex; flex-direction: column; min-width: 0; } +.nav-btn .nav-text.right { text-align: right; } +.nav-btn .nav-text small { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.14em; + font-weight: 700; + color: var(--vscode-descriptionForeground); + opacity: 0.85; +} +.nav-btn.next.primary .nav-text small { color: rgba(255, 255, 255, 0.85); opacity: 1; } +.nav-btn .nav-text span:not(small) { + font-size: 0.92rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.nav-btn .nav-chevron { + font-size: 1.5rem; + line-height: 1; + flex-shrink: 0; +} +.nav-spacer { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} +.link-btn { + background: transparent; + color: var(--brand-blue); + border: none; + padding: 4px 10px; + cursor: pointer; + font-weight: 500; + font-size: 0.88rem; + border-radius: 4px; +} +.link-btn:hover { background: var(--brand-blue-soft); color: var(--brand-blue-deep); } +.kbd-hint { font-size: 0.75rem; } +.kbd-hint kbd { + background: var(--vscode-keybindingLabel-background, rgba(127,127,127,0.18)); + border: 1px solid var(--vscode-keybindingLabel-border, rgba(127,127,127,0.3)); + border-radius: 3px; + padding: 1px 5px; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 0.72rem; +} +@media (max-width: 720px) { + .step-nav { grid-template-columns: 1fr; } + .nav-btn { width: 100%; } +} +`; + +const PANEL_JS = ` +(function () { + const vscode = acquireVsCodeApi(); + const gate = document.getElementById('persona-gate'); + const dashboard = document.getElementById('dashboard'); + const personaCards = document.getElementById('persona-cards'); + const personaLabel = document.getElementById('persona-label'); + const personaTagline = document.getElementById('persona-tagline'); + const stepList = document.getElementById('step-list'); + const stepNumber = document.getElementById('step-number'); + const stepPosition = document.getElementById('step-position'); + const stepTitle = document.getElementById('step-title'); + const stepSummary = document.getElementById('step-summary'); + const stepActions = document.getElementById('step-actions'); + const stepActionsWrap = document.getElementById('step-actions-wrap'); + const stepBody = document.getElementById('step-body'); + const stepCard = document.querySelector('.step-card'); + const btnPrev = document.getElementById('btn-prev'); + const btnNext = document.getElementById('btn-next'); + const btnPrevTop = document.getElementById('btn-prev-top'); + const btnNextTop = document.getElementById('btn-next-top'); + const prevTitleEl = document.getElementById('prev-title'); + const nextTitleEl = document.getElementById('next-title'); + const progressCounter = document.getElementById('progress-counter'); + const progressPercent = document.getElementById('progress-percent'); + const progressFill = document.getElementById('progress-fill'); + const btnSkip = document.getElementById('btn-skip'); + const btnChangePersona = document.getElementById('btn-change-persona'); + const btnReset = document.getElementById('btn-reset'); + const btnMarkAllDone = document.getElementById('btn-mark-all-done'); + const btnThemeToggle = document.getElementById('btn-theme-toggle'); + const btnCtaMarkAllDone = document.getElementById('btn-cta-mark-all-done'); + const btnStartOver = document.getElementById('btn-start-over'); + const setupChip = document.getElementById('setup-chip'); + const setupChipName = document.getElementById('setup-chip-name'); + + let currentState = null; + + function post(msg) { vscode.postMessage(msg); } + + function renderSetupChip(state) { + if (!setupChip || !btnStartOver || !setupChipName) return; + if (state.setupInstance) { + setupChipName.textContent = state.setupInstance; + setupChip.hidden = false; + btnStartOver.hidden = false; + } else { + setupChip.hidden = true; + btnStartOver.hidden = true; + } + } + + function render(state) { + currentState = state; + renderSetupChip(state); + if (!state.persona) { + gate.hidden = false; + dashboard.hidden = true; + renderPersonaGate(state.personas); + return; + } + gate.hidden = true; + dashboard.hidden = false; + personaLabel.textContent = state.persona.label; + // Headers don't carry trailing punctuation — strip a single period if + // the persona definition's tagline ends with one. + personaTagline.textContent = state.persona.tagline.replace(/\.$/, ''); + renderStepList(state.steps, state.activeStepId); + + const activeIdx = state.steps.findIndex((s) => s.id === state.activeStepId); + const idx = activeIdx >= 0 ? activeIdx : 0; + const active = state.steps[idx]; + const prevStep = idx > 0 ? state.steps[idx - 1] : null; + const nextStep = idx < state.steps.length - 1 ? state.steps[idx + 1] : null; + renderActiveStep(active, idx, state.steps.length); + renderNav(prevStep, nextStep); + renderProgress(state.steps, idx); + } + + function renderProgress(steps, idx) { + const total = steps.length; + const doneCount = steps.filter((s) => s.status === 'done').length; + const pct = total === 0 ? 0 : Math.round((doneCount / total) * 100); + progressCounter.textContent = 'Step ' + (idx + 1) + ' of ' + total; + progressPercent.textContent = pct + '% complete'; + progressFill.style.width = pct + '%'; + } + + function renderNav(prev, next) { + prevTitleEl.textContent = prev ? prev.title : 'Start of walkthrough'; + nextTitleEl.textContent = next ? next.title : 'Finish walkthrough'; + btnPrev.disabled = !prev; + if (btnPrevTop) btnPrevTop.disabled = !prev; + } + + // Per-persona icon SVGs. Stroke uses currentColor so the avatar tile's + // foreground (white inside the gradient square) drives them. + const PERSONA_ICONS = { + 'storefront': + '', + 'api-integration': + '', + 'devops-release': + '', + 'ai-augmented': + '', + }; + + function renderPersonaGate(personas) { + personaCards.innerHTML = ''; + personas.forEach((p) => { + const card = document.createElement('div'); + card.className = 'persona-card' + (p.recommended ? ' is-recommended' : ''); + card.setAttribute('role', 'button'); + card.setAttribute('tabindex', '0'); + card.setAttribute('aria-label', p.label); + const newPill = p.recommended ? 'NEW' : ''; + // Generic fallback icon (a small square cluster) for any persona that + // doesn't have a dedicated SVG yet — still icon-based, never letters. + const fallbackIcon = + ''; + const iconHtml = PERSONA_ICONS[p.id] || fallbackIcon; + card.innerHTML = [ + '', + '

    ', + '

    ', + '

    ', + '', + '', + newPill, + ].join(''); + card.querySelector('h3').textContent = p.label; + card.querySelector('.tagline').textContent = p.tagline.replace(/\.$/, ''); + card.querySelector('.desc').textContent = p.description; + card.querySelector('.persona-meta').textContent = + p.stepCount + ' phases · ~' + p.estimatedMinutes + ' min'; + const select = () => post({type: 'selectPersona', personaId: p.id}); + card.addEventListener('click', select); + card.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + select(); + } + }); + personaCards.appendChild(card); + }); + } + + function renderStepList(steps, activeId) { + stepList.innerHTML = ''; + steps.forEach((step, idx) => { + const locked = step.status === 'locked'; + const li = document.createElement('li'); + li.className = 'step-item' + (step.id === activeId ? ' active' : '') + (locked ? ' locked' : ''); + li.dataset.status = step.status; + li.innerHTML = [ + '', + '', + ].join(''); + li.querySelector('.status').textContent = statusGlyph(step.status, idx + 1); + li.querySelector('.title').textContent = step.title; + li.querySelector('small').textContent = labelForStatus(step.status); + if (locked) { + li.setAttribute('aria-disabled', 'true'); + li.setAttribute('title', 'Complete the previous step to unlock this one.'); + } else { + li.setAttribute('role', 'button'); + li.setAttribute('tabindex', '0'); + li.addEventListener('click', () => post({type: 'openStep', stepId: step.id})); + li.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + post({type: 'openStep', stepId: step.id}); + } + }); + } + stepList.appendChild(li); + }); + } + + function statusGlyph(status, ordinal) { + switch (status) { + case 'done': return '✓'; + case 'in-progress': return '●'; + case 'skipped': return '–'; + case 'locked': return '🔒'; + default: return String(ordinal); + } + } + + function labelForStatus(status) { + switch (status) { + case 'done': return 'Completed'; + case 'in-progress': return 'In progress'; + case 'skipped': return 'Skipped'; + case 'locked': return 'Locked — complete the previous step'; + default: return ''; + } + } + + function renderActiveStep(step, idx, total) { + if (!step) { + stepNumber.textContent = ''; + stepPosition.textContent = ''; + stepTitle.textContent = ''; + stepSummary.textContent = ''; + stepActions.innerHTML = ''; + stepActionsWrap.hidden = true; + stepBody.innerHTML = ''; + return; + } + stepNumber.textContent = String(idx + 1); + stepPosition.textContent = 'Step ' + (idx + 1) + ' of ' + total; + stepTitle.textContent = step.title; + stepSummary.textContent = step.summary; + stepBody.innerHTML = step.html; + stepActions.innerHTML = ''; + if (step.actions && step.actions.length > 0) { + stepActionsWrap.hidden = false; + step.actions.forEach((action) => { + const btn = document.createElement('button'); + if (!action.primary) btn.className = 'ghost'; + btn.textContent = action.label; + btn.addEventListener('click', () => + post({type: 'runAction', command: action.command, args: action.args, stepId: step.id}), + ); + stepActions.appendChild(btn); + }); + } else { + stepActionsWrap.hidden = true; + } + // Scroll the card (not the body) so step-header stays visible after navigation. + if (stepCard && typeof stepCard.scrollIntoView === 'function') { + stepCard.scrollIntoView({behavior: 'smooth', block: 'start'}); + } + } + + // Intercept clicks on any link inside the content area. We NEVER let the + // webview follow command: or http links directly — all routing goes through + // the extension host via postMessage. + document.addEventListener('click', (e) => { + const anchor = e.target && e.target.closest && e.target.closest('a[href]'); + if (!anchor) return; + const href = anchor.getAttribute('href'); + if (!href || href === '#') return; + e.preventDefault(); + post({type: 'openLink', url: href}); + }); + + btnSkip.addEventListener('click', () => { + if (!currentState || !currentState.activeStepId) return; + post({type: 'skipStep', stepId: currentState.activeStepId}); + }); + btnPrev.addEventListener('click', () => post({type: 'goPrev'})); + btnNext.addEventListener('click', () => post({type: 'goNext'})); + if (btnPrevTop) btnPrevTop.addEventListener('click', () => post({type: 'goPrev'})); + if (btnNextTop) btnNextTop.addEventListener('click', () => post({type: 'goNext'})); + btnChangePersona.addEventListener('click', () => post({type: 'changePersona'})); + btnReset.addEventListener('click', () => post({type: 'reset'})); + if (btnMarkAllDone) { + btnMarkAllDone.addEventListener('click', () => + post({type: 'runAction', command: 'b2c-dx.walkthrough.markAllDone'}), + ); + } + if (btnThemeToggle) { + btnThemeToggle.addEventListener('click', () => post({type: 'runAction', command: 'b2c-dx.theme.toggle'})); + } + if (btnCtaMarkAllDone) { + btnCtaMarkAllDone.addEventListener('click', () => + post({type: 'runAction', command: 'b2c-dx.walkthrough.markAllDone'}), + ); + } + if (btnStartOver) { + btnStartOver.addEventListener('click', () => post({type: 'runAction', command: 'b2c-dx.setup.resetSession'})); + } + + // Keyboard navigation: Alt+← / Alt+→ (and the usual PageUp/Down pattern). + document.addEventListener('keydown', (e) => { + if (!currentState || !currentState.persona) return; + if (e.altKey && e.key === 'ArrowRight') { e.preventDefault(); post({type: 'goNext'}); } + else if (e.altKey && e.key === 'ArrowLeft') { e.preventDefault(); post({type: 'goPrev'}); } + }); + + window.addEventListener('message', (event) => { + const msg = event.data; + if (msg && msg.type === 'state') render(msg.state); + }); + + post({type: 'ready'}); +}()); +`; diff --git a/packages/b2c-vs-extension/src/walkthrough/personas.ts b/packages/b2c-vs-extension/src/walkthrough/personas.ts new file mode 100644 index 000000000..c1f93d5f0 --- /dev/null +++ b/packages/b2c-vs-extension/src/walkthrough/personas.ts @@ -0,0 +1,225 @@ +/* + * 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 + */ + +export type PersonaId = 'storefront' | 'api-integration' | 'devops-release' | 'ai-augmented'; + +export interface StepDefinition { + id: string; + title: string; + summary: string; + /** Path relative to the extension root. The onboarding panel resolves this via asWebviewUri. */ + markdown: string; + /** + * Commands the step can trigger from the UI. Surfaced as buttons in the panel header. + */ + actions?: StepAction[]; +} + +export interface StepAction { + label: string; + command: string; + args?: unknown[]; + /** Marks this as the primary call-to-action (rendered as a filled button). */ + primary?: boolean; +} + +export interface PersonaDefinition { + id: PersonaId; + label: string; + tagline: string; + description: string; + stepIds: string[]; +} + +/** + * Catalog of every step that can appear in any persona flow. + * The markdown files are the existing walkthrough content — we render them inside the panel + * instead of the built-in VS Code walkthrough surface. + */ +export const STEP_CATALOG: Record = { + welcome: { + id: 'welcome', + title: 'Welcome to B2C Commerce Development', + summary: 'What the extension does and what you will learn.', + markdown: 'media/walkthrough/welcome.md', + }, + 'configure-dw-json': { + id: 'configure-dw-json', + title: 'Connect to Your B2C Instance', + summary: 'Connection-only: name the instance and pick its hostname / code-version.', + markdown: 'media/walkthrough/dw-json-setup.md', + actions: [ + {label: 'Set up connection', command: 'b2c-dx.setup.connection', primary: true}, + {label: 'Inspect resolved config', command: 'b2c-dx.walkthrough.inspectSetup'}, + {label: 'Open dw.json', command: 'workbench.action.quickOpen', args: ['dw.json']}, + ], + }, + 'setup-oauth': { + id: 'setup-oauth', + title: 'Set Up OAuth Credentials', + summary: 'Add `client-id` + `client-secret`. Pick where the secret lives.', + markdown: 'media/walkthrough/oauth-setup.md', + actions: [ + {label: 'Set up OAuth', command: 'b2c-dx.setup.oauth', primary: true}, + {label: 'Inspect resolved config', command: 'b2c-dx.walkthrough.inspectSetup'}, + ], + }, + 'explore-webdav': { + id: 'explore-webdav', + title: 'Browse Your Instance with WebDAV', + summary: 'Add `username` + `password`, then open the WebDAV browser.', + markdown: 'media/walkthrough/webdav-browser.md', + actions: [ + {label: 'Set up WebDAV credentials', command: 'b2c-dx.setup.webdav', primary: true}, + {label: 'Open WebDAV Browser', command: 'b2c-dx.listWebDav'}, + {label: 'Inspect resolved config', command: 'b2c-dx.walkthrough.inspectSetup'}, + ], + }, + 'setup-cartridges': { + id: 'setup-cartridges', + title: 'Set Up Cartridge Development', + summary: 'Detect or create cartridges. Add SCAPI fields here if you need the API Browser.', + markdown: 'media/walkthrough/cartridge-structure.md', + actions: [ + {label: 'Create New Cartridge', command: 'b2c-dx.scaffold.generate', primary: true}, + {label: 'Set up SCAPI (short-code, tenant-id)', command: 'b2c-dx.setup.scapi'}, + {label: 'Refresh Cartridge List', command: 'b2c-dx.codeSync.refreshCartridges'}, + {label: 'Inspect resolved config', command: 'b2c-dx.walkthrough.inspectSetup'}, + ], + }, + 'deploy-code': { + id: 'deploy-code', + title: 'Deploy Your First Cartridge', + summary: 'Upload cartridge code to your sandbox.', + markdown: 'media/walkthrough/deploy-cartridge.md', + actions: [{label: 'Deploy All Cartridges', command: 'b2c-dx.codeSync.deploy', primary: true}], + }, + 'manage-sandboxes': { + id: 'manage-sandboxes', + title: 'Work with Development Sandboxes', + summary: 'Create, start, stop, and extend sandboxes.', + markdown: 'media/walkthrough/sandbox-explorer.md', + actions: [{label: 'Open Sandbox Explorer', command: 'workbench.view.extension.b2c-dx-sandboxes', primary: true}], + }, + 'enable-code-sync': { + id: 'enable-code-sync', + title: 'Automate Deployment with Code Sync', + summary: 'Auto-upload cartridge changes as you save.', + markdown: 'media/walkthrough/code-sync.md', + actions: [ + {label: 'Start Code Sync', command: 'b2c-dx.codeSync.start', primary: true}, + {label: 'Stop Code Sync', command: 'b2c-dx.codeSync.stop'}, + ], + }, + 'next-steps': { + id: 'next-steps', + title: "You're Ready! Explore More Features", + summary: 'Where to go next.', + markdown: 'media/walkthrough/next-steps.md', + }, + 'install-cli': { + id: 'install-cli', + title: 'Install the B2C CLI', + summary: 'Optional, but unlocks deploys, log tailing, and sandbox commands from the terminal.', + markdown: 'media/walkthrough/install-cli.md', + actions: [ + {label: 'Verify CLI', command: 'b2c-dx.cli.verify', primary: true}, + {label: 'Update CLI', command: 'b2c-dx.cli.update'}, + ], + }, + 'ai-skills': { + id: 'ai-skills', + title: 'Set Up Agent Skills & MCP', + summary: + 'Pair the extension with Claude Code, Cursor, or Copilot using the documented MCP server and Agent Skills.', + markdown: 'media/walkthrough/ai-skills.md', + }, +}; + +export const PERSONAS: Record = { + storefront: { + id: 'storefront', + label: 'Storefront developer', + tagline: 'Build SFRA / PWA Kit templates, controllers, and ISML.', + description: + 'Cartridge authoring, fast iteration with Code Sync, and WebDAV. If you used UX Studio or Prophet before, this is your closest map.', + stepIds: [ + 'welcome', + 'install-cli', + 'configure-dw-json', + 'setup-cartridges', + 'deploy-code', + 'enable-code-sync', + 'explore-webdav', + 'next-steps', + ], + }, + 'api-integration': { + id: 'api-integration', + label: 'API / integration developer', + tagline: 'Work with SCAPI, OCAPI, jobs, and hooks.', + description: 'OAuth setup and the API Browser are first-class; Code Sync is optional.', + stepIds: [ + 'welcome', + 'install-cli', + 'configure-dw-json', + 'setup-oauth', + 'explore-webdav', + 'setup-cartridges', + 'deploy-code', + 'next-steps', + ], + }, + 'devops-release': { + id: 'devops-release', + label: 'DevOps / release engineer', + tagline: 'Manage sandbox lifecycle, code versions, and CAPs.', + description: 'OAuth + Sandbox Explorer front and center. Less time on cartridge authoring.', + stepIds: [ + 'welcome', + 'install-cli', + 'configure-dw-json', + 'setup-oauth', + 'manage-sandboxes', + 'deploy-code', + 'next-steps', + ], + }, + 'ai-augmented': { + id: 'ai-augmented', + label: 'AI-augmented developer', + tagline: 'Pair Cursor / Claude Code / Copilot with this extension.', + description: + 'Same setup as a storefront developer, plus the documented MCP server and Agent Skills so your AI tools share context with the extension.', + stepIds: [ + 'welcome', + 'install-cli', + 'configure-dw-json', + 'ai-skills', + 'setup-cartridges', + 'deploy-code', + 'enable-code-sync', + 'next-steps', + ], + }, +}; + +export function getPersona(id: string | null | undefined): PersonaDefinition | null { + if (!id) return null; + return (PERSONAS as Record)[id] ?? null; +} + +export function listPersonas(): PersonaDefinition[] { + return Object.values(PERSONAS); +} + +export function resolveSteps(personaId: PersonaId): StepDefinition[] { + return PERSONAS[personaId].stepIds.map((id) => { + const def = STEP_CATALOG[id]; + if (!def) throw new Error(`Unknown step id in persona ${personaId}: ${id}`); + return def; + }); +} diff --git a/packages/b2c-vs-extension/src/walkthrough/state.ts b/packages/b2c-vs-extension/src/walkthrough/state.ts new file mode 100644 index 000000000..484e6f6c8 --- /dev/null +++ b/packages/b2c-vs-extension/src/walkthrough/state.ts @@ -0,0 +1,113 @@ +/* + * 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 * as vscode from 'vscode'; + +export type StepStatus = 'locked' | 'available' | 'in-progress' | 'done' | 'skipped'; + +export interface StepRecord { + status: StepStatus; + startedAt?: string; + completedAt?: string; + skippedAt?: string; +} + +export interface OnboardingSnapshot { + persona: string | null; + steps: Record; + schemaVersion: number; +} + +const STATE_KEY = 'b2c-dx.onboarding.state'; +const CURRENT_SCHEMA_VERSION = 1; + +function emptySnapshot(): OnboardingSnapshot { + return {persona: null, steps: {}, schemaVersion: CURRENT_SCHEMA_VERSION}; +} + +function stepKey(persona: string, stepId: string): string { + return `${persona}:${stepId}`; +} + +export class OnboardingStateStore { + private readonly memento: vscode.Memento; + private readonly emitter = new vscode.EventEmitter(); + + readonly onDidChange = this.emitter.event; + + constructor(context: vscode.ExtensionContext) { + this.memento = context.globalState; + // Sync progress across machines signed into the same VS Code account. + context.globalState.setKeysForSync([STATE_KEY]); + } + + get(): OnboardingSnapshot { + const raw = this.memento.get(STATE_KEY); + if (!raw || typeof raw !== 'object') return emptySnapshot(); + if (raw.schemaVersion !== CURRENT_SCHEMA_VERSION) { + return {...emptySnapshot(), persona: raw.persona ?? null}; + } + return raw; + } + + getPersona(): string | null { + return this.get().persona; + } + + async setPersona(persona: string | null): Promise { + const current = this.get(); + await this.write({...current, persona}); + } + + getStep(persona: string, stepId: string): StepRecord | undefined { + return this.get().steps[stepKey(persona, stepId)]; + } + + async updateStep(persona: string, stepId: string, patch: Partial): Promise { + const current = this.get(); + const key = stepKey(persona, stepId); + const existing: StepRecord = current.steps[key] ?? {status: 'available'}; + const next: StepRecord = {...existing, ...patch}; + await this.write({...current, steps: {...current.steps, [key]: next}}); + return next; + } + + async markStarted(persona: string, stepId: string): Promise { + const existing = this.getStep(persona, stepId); + if (existing?.status === 'done') return; + await this.updateStep(persona, stepId, { + status: 'in-progress', + startedAt: existing?.startedAt ?? new Date().toISOString(), + }); + } + + async markCompleted(persona: string, stepId: string): Promise { + await this.updateStep(persona, stepId, { + status: 'done', + completedAt: new Date().toISOString(), + }); + } + + async markSkipped(persona: string, stepId: string): Promise { + await this.updateStep(persona, stepId, { + status: 'skipped', + skippedAt: new Date().toISOString(), + }); + } + + async reset(): Promise { + await this.write(emptySnapshot()); + } + + private async write(next: OnboardingSnapshot): Promise { + await this.memento.update(STATE_KEY, next); + this.emitter.fire(next); + } + + dispose(): void { + this.emitter.dispose(); + } +} diff --git a/packages/b2c-vs-extension/src/walkthrough/telemetry.ts b/packages/b2c-vs-extension/src/walkthrough/telemetry.ts new file mode 100644 index 000000000..4680ea51d --- /dev/null +++ b/packages/b2c-vs-extension/src/walkthrough/telemetry.ts @@ -0,0 +1,192 @@ +/* + * 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 * as vscode from 'vscode'; + +/** + * Walkthrough telemetry and performance tracking. + * Note: This is a basic implementation. For production, consider using + * a proper telemetry service like Application Insights or VS Code's telemetry API. + */ + +interface WalkthroughMetrics { + commandExecutions: Map; + commandDurations: Map; + stepCompletions: Map; + errors: Array<{command: string; error: string; timestamp: Date}>; +} + +class WalkthroughTelemetry { + private metrics: WalkthroughMetrics = { + commandExecutions: new Map(), + commandDurations: new Map(), + stepCompletions: new Map(), + errors: [], + }; + + private log: vscode.OutputChannel; + + constructor(log: vscode.OutputChannel) { + this.log = log; + } + + /** + * Track command execution start time + */ + startCommand(commandId: string): () => void { + const startTime = Date.now(); + + // Increment execution count + const count = this.metrics.commandExecutions.get(commandId) || 0; + this.metrics.commandExecutions.set(commandId, count + 1); + + // Return a function to call when command completes + return () => { + const duration = Date.now() - startTime; + + // Store duration + const durations = this.metrics.commandDurations.get(commandId) || []; + durations.push(duration); + this.metrics.commandDurations.set(commandId, durations); + + this.log.appendLine(`[Telemetry] Command '${commandId}' completed in ${duration}ms`); + }; + } + + /** + * Track step completion + */ + trackStepCompletion(stepId: string): void { + this.metrics.stepCompletions.set(stepId, new Date()); + this.log.appendLine(`[Telemetry] Step '${stepId}' completed`); + } + + /** + * Track error + */ + trackError(commandId: string, error: Error | string): void { + const errorMessage = error instanceof Error ? error.message : error; + this.metrics.errors.push({ + command: commandId, + error: errorMessage, + timestamp: new Date(), + }); + this.log.appendLine(`[Telemetry] Error in '${commandId}': ${errorMessage}`); + } + + /** + * Get average duration for a command + */ + getAverageDuration(commandId: string): number | null { + const durations = this.metrics.commandDurations.get(commandId); + if (!durations || durations.length === 0) { + return null; + } + + const sum = durations.reduce((a, b) => a + b, 0); + return sum / durations.length; + } + + /** + * Get metrics summary + */ + getSummary(): string { + const lines: string[] = ['=== Walkthrough Telemetry Summary ===', '', 'Command Executions:']; + + for (const [command, count] of this.metrics.commandExecutions) { + const avgDuration = this.getAverageDuration(command); + const avgStr = avgDuration ? `avg: ${avgDuration.toFixed(2)}ms` : 'no timing data'; + lines.push(` ${command}: ${count} executions (${avgStr})`); + } + + lines.push('', 'Step Completions:'); + for (const [step, date] of this.metrics.stepCompletions) { + lines.push(` ${step}: ${date.toISOString()}`); + } + + if (this.metrics.errors.length > 0) { + lines.push('', 'Errors:'); + for (const error of this.metrics.errors) { + lines.push(` [${error.timestamp.toISOString()}] ${error.command}: ${error.error}`); + } + } + + return lines.join('\n'); + } + + /** + * Log summary to output channel + */ + logSummary(): void { + this.log.appendLine(this.getSummary()); + } + + /** + * Reset all metrics + */ + reset(): void { + this.metrics = { + commandExecutions: new Map(), + commandDurations: new Map(), + stepCompletions: new Map(), + errors: [], + }; + this.log.appendLine('[Telemetry] Metrics reset'); + } +} + +let telemetryInstance: WalkthroughTelemetry | null = null; + +/** + * Initialize telemetry + */ +export function initializeTelemetry(log: vscode.OutputChannel): WalkthroughTelemetry { + telemetryInstance = new WalkthroughTelemetry(log); + return telemetryInstance; +} + +/** + * Get telemetry instance + */ +export function getTelemetry(): WalkthroughTelemetry | null { + return telemetryInstance; +} + +/** + * Decorator for tracking command execution time + */ +export function trackCommand(commandId: string): MethodDecorator { + return function ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + target: any, + propertyKey: string | symbol, + descriptor: PropertyDescriptor, + ) { + const originalMethod = descriptor.value; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + descriptor.value = async function (...args: any[]) { + const telemetry = getTelemetry(); + if (!telemetry) { + return originalMethod.apply(this, args); + } + + const endTracking = telemetry.startCommand(commandId); + + try { + const result = await originalMethod.apply(this, args); + endTracking(); + return result; + } catch (error) { + telemetry.trackError(commandId, error as Error); + endTracking(); + throw error; + } + }; + + return descriptor; + }; +} diff --git a/packages/b2c-vs-extension/src/walkthrough/test/commands.test.ts b/packages/b2c-vs-extension/src/walkthrough/test/commands.test.ts new file mode 100644 index 000000000..b50f0c8cc --- /dev/null +++ b/packages/b2c-vs-extension/src/walkthrough/test/commands.test.ts @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs/promises'; + +suite('Walkthrough Commands Test Suite', () => { + let testWorkspaceUri: vscode.Uri; + let testDwJsonPath: string; + + suiteSetup(async () => { + // Get the test workspace folder + const workspaceFolders = vscode.workspace.workspaceFolders; + assert.ok(workspaceFolders && workspaceFolders.length > 0, 'Test workspace should be open'); + + testWorkspaceUri = workspaceFolders[0].uri; + testDwJsonPath = path.join(testWorkspaceUri.fsPath, 'dw.json'); + }); + + setup(async () => { + // Clean up any existing dw.json before each test + try { + await fs.unlink(testDwJsonPath); + } catch { + // File doesn't exist, that's fine + } + }); + + teardown(async () => { + // Clean up after each test + try { + await fs.unlink(testDwJsonPath); + } catch { + // File doesn't exist, that's fine + } + }); + + suite('b2c-dx.walkthrough.open', () => { + test('should open walkthrough without errors', async () => { + // Execute the command + await vscode.commands.executeCommand('b2c-dx.walkthrough.open'); + + // Give it a moment to execute + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // If we got here without throwing, the command executed + assert.ok(true, 'Walkthrough open command executed'); + }); + + test('should be registered in command palette', async () => { + const commands = await vscode.commands.getCommands(); + assert.ok(commands.includes('b2c-dx.walkthrough.open'), 'Command should be registered'); + }); + }); + + suite('b2c-dx.walkthrough.createDwJson', () => { + test('should be registered in command palette', async () => { + const commands = await vscode.commands.getCommands(); + assert.ok(commands.includes('b2c-dx.walkthrough.createDwJson'), 'Command should be registered'); + }); + + // Note: Full integration testing of createDwJson requires user interaction + // (QuickPick dialogs), so we test the command registration here. + // Manual testing covers the full user interaction flow. + }); + + suite('dw.json file operations', () => { + test('should detect when dw.json exists', async () => { + // Create a test dw.json + const testContent = JSON.stringify( + { + hostname: 'test.demandware.net', + username: 'test', + password: 'test', + }, + null, + 2, + ); + + await fs.writeFile(testDwJsonPath, testContent, 'utf-8'); + + // Verify file exists + try { + await fs.access(testDwJsonPath); + assert.ok(true, 'dw.json file was created'); + } catch { + assert.fail('dw.json file should exist'); + } + + // Verify content + const content = await fs.readFile(testDwJsonPath, 'utf-8'); + const parsed = JSON.parse(content); + assert.strictEqual(parsed.hostname, 'test.demandware.net'); + }); + + test('should handle missing dw.json gracefully', async () => { + // Ensure file doesn't exist + try { + await fs.unlink(testDwJsonPath); + } catch { + // Already doesn't exist + } + + // Try to access + try { + await fs.access(testDwJsonPath); + assert.fail('dw.json should not exist'); + } catch { + assert.ok(true, 'Correctly detected missing dw.json'); + } + }); + }); + + suite('Walkthrough completion events', () => { + test('should complete Step 2 when dw.json exists', async () => { + // Create dw.json + const testContent = JSON.stringify( + { + hostname: 'test.demandware.net', + username: 'test', + password: 'test', + }, + null, + 2, + ); + + await fs.writeFile(testDwJsonPath, testContent, 'utf-8'); + + // Trigger workspace file change event + await vscode.commands.executeCommand('workbench.action.reloadWindow'); + + // Note: Actual completion tracking is handled by VS Code's walkthrough API + // This test verifies the file exists, which is the completion condition + const exists = await fs + .access(testDwJsonPath) + .then(() => true) + .catch(() => false); + + assert.ok(exists, 'dw.json exists, Step 2 should be completable'); + }); + }); + + suite('Error handling', () => { + test('should handle command execution errors gracefully', async () => { + try { + // Try to execute a non-existent command + await vscode.commands.executeCommand('b2c-dx.nonexistent.command'); + assert.fail('Should have thrown an error'); + } catch (error) { + assert.ok(error, 'Error should be thrown for non-existent command'); + } + }); + }); + + suite('Extension activation', () => { + test('should activate extension in test workspace', async () => { + const extension = vscode.extensions.getExtension('Salesforce.b2c-vs-extension'); + assert.ok(extension, 'Extension should be installed'); + + if (!extension.isActive) { + await extension.activate(); + } + + assert.ok(extension.isActive, 'Extension should be active'); + }); + + test('should have walkthrough commands after activation', async () => { + const extension = vscode.extensions.getExtension('Salesforce.b2c-vs-extension'); + assert.ok(extension, 'Extension should be installed'); + + if (!extension.isActive) { + await extension.activate(); + } + + const commands = await vscode.commands.getCommands(); + assert.ok(commands.includes('b2c-dx.walkthrough.open'), 'Walkthrough open command should be available'); + assert.ok(commands.includes('b2c-dx.walkthrough.createDwJson'), 'Create dw.json command should be available'); + assert.ok(commands.includes('b2c-dx.walkthrough.markAllDone'), 'Mark all done command should be available'); + assert.ok(commands.includes('b2c-dx.cli.verify'), 'CLI verify command should be available'); + }); + }); + + suite('Personas', () => { + test('should expose the four current personas', async () => { + // Lazy import to avoid a hard module load before activation. + const personas = await import('../personas.js'); + const ids = personas.listPersonas().map((p) => p.id); + assert.deepStrictEqual( + ids.sort(), + ['ai-augmented', 'api-integration', 'devops-release', 'storefront'], + 'Persona ids should match the documented set', + ); + }); + }); +}); diff --git a/packages/b2c-vs-extension/src/walkthrough/toolDetection.ts b/packages/b2c-vs-extension/src/walkthrough/toolDetection.ts new file mode 100644 index 000000000..1793eca6d --- /dev/null +++ b/packages/b2c-vs-extension/src/walkthrough/toolDetection.ts @@ -0,0 +1,282 @@ +/* + * 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 * as cp from 'child_process'; + +export interface ToolStatus { + name: string; + installed: boolean; + version?: string; + label: string; +} + +export interface ToolDetectionResult { + node: ToolStatus; + npm: ToolStatus; + homebrew: ToolStatus; + npx: ToolStatus; + b2cCli: ToolStatus; + b2cCliLatest?: string; + b2cCliOutdated?: boolean; +} + +function execVersion(command: string, args: string[]): Promise { + return new Promise((resolve) => { + cp.execFile(command, args, {timeout: 5000}, (err, stdout) => { + if (err) { + resolve(undefined); + return; + } + const output = stdout.toString().trim(); + resolve(output || undefined); + }); + }); +} + +function extractVersion(raw: string | undefined): string | undefined { + if (!raw) return undefined; + const match = raw.match(/(\d+\.\d+\.\d+(?:[-+][\w.]+)?)/); + return match ? match[1] : raw; +} + +export function compareSemver(a: string, b: string): number { + const norm = (v: string) => + v + .split(/[-+]/)[0] + .split('.') + .map((n) => parseInt(n, 10) || 0); + const [aA, aB] = [norm(a), norm(b)]; + for (let i = 0; i < 3; i++) { + if ((aA[i] ?? 0) !== (aB[i] ?? 0)) return (aA[i] ?? 0) - (aB[i] ?? 0); + } + const aPre = a.includes('-'); + const bPre = b.includes('-'); + if (aPre !== bPre) return aPre ? -1 : 1; + return 0; +} + +export async function detectTools(latestCliVersion?: string): Promise { + const [nodeRaw, npmRaw, brewRaw, npxRaw, b2cRaw] = await Promise.all([ + execVersion('node', ['--version']), + execVersion('npm', ['--version']), + execVersion('brew', ['--version']), + execVersion('npx', ['--version']), + execVersion('b2c', ['--version']), + ]); + + const nodeVersion = extractVersion(nodeRaw); + const npmVersion = extractVersion(npmRaw); + const brewVersion = extractVersion(brewRaw); + const npxVersion = extractVersion(npxRaw); + const b2cVersion = extractVersion(b2cRaw); + + let b2cCliOutdated: boolean | undefined; + let b2cCliLatest: string | undefined; + if (b2cVersion && latestCliVersion) { + b2cCliOutdated = compareSemver(b2cVersion, latestCliVersion) < 0; + b2cCliLatest = latestCliVersion; + } + + return { + node: { + name: 'node', + installed: !!nodeVersion, + version: nodeVersion, + label: 'Node.js', + }, + npm: { + name: 'npm', + installed: !!npmVersion, + version: npmVersion, + label: 'npm', + }, + homebrew: { + name: 'homebrew', + installed: !!brewVersion, + version: brewVersion, + label: 'Homebrew', + }, + npx: { + name: 'npx', + installed: !!npxVersion, + version: npxVersion, + label: 'npx', + }, + b2cCli: { + name: 'b2c-cli', + installed: !!b2cVersion, + version: b2cVersion, + label: 'B2C CLI', + }, + b2cCliLatest: b2cCliLatest, + b2cCliOutdated: b2cCliOutdated, + }; +} + +function toolRowHtml(tool: ToolStatus, note?: string): string { + if (tool.installed) { + const extra = note ? `${note}` : ''; + return [ + `
    `, + ``, + `${tool.label}`, + `v${tool.version}`, + extra, + `
    `, + ].join(''); + } + const extra = note ? `${note}` : ''; + return [ + `
    `, + ``, + `${tool.label}`, + `not found`, + extra, + `
    `, + ].join(''); +} + +/** + * Generates styled HTML for the install-cli step. This bypasses the markdown + * renderer to allow colored status indicators and version badges. + */ +export function generateInstallCliHtml(result: ToolDetectionResult): string { + const parts: string[] = []; + + // Scoped styles for tool detection UI + parts.push(``); + + // Intro (title is already shown in the step header — skip h1 to avoid duplication) + parts.push( + `

    The B2C CLI (b2c) drives deploys, log tailing, sandbox management, and more from the terminal. The VS Code extension uses it under the hood for some commands.

    `, + ); + parts.push( + `

    Optional. You can use the extension's Cartridges, WebDAV, and Sandbox views without the CLI. Install it when you want to script the same operations from the terminal or CI.

    `, + ); + + // Prerequisites grid + parts.push(`

    Prerequisites

    `); + parts.push(`
    `); + parts.push(toolRowHtml(result.node, result.node.installed ? undefined : 'required, v22.0.0+')); + parts.push(toolRowHtml(result.npm, result.npm.installed ? 'for global install' : undefined)); + parts.push(toolRowHtml(result.npx, result.npx.installed ? 'for one-off runs' : undefined)); + parts.push(toolRowHtml(result.homebrew, result.homebrew.installed ? 'alt install method' : 'optional')); + parts.push(`
    `); + + // B2C CLI status + parts.push(`

    B2C CLI

    `); + + if (result.b2cCli.installed) { + const ver = result.b2cCli.version ?? 'unknown'; + if (result.b2cCliOutdated && result.b2cCliLatest) { + parts.push(`
    `); + parts.push(`Update available`); + parts.push( + `

    Installed: ${ver} → Latest: ${result.b2cCliLatest}

    `, + ); + parts.push(`

    Run the Update CLI action above to upgrade.

    `); + parts.push(`
    `); + } else { + parts.push(`
    `); + parts.push(`✔ Installed & up to date`); + parts.push(`

    ${ver}${result.b2cCliLatest ? ' (latest)' : ''}

    `); + parts.push( + `

    The CLI is on your PATH and ready to use. Move to the next step or run b2c --version in the terminal to confirm.

    `, + ); + parts.push(`
    `); + } + } else { + parts.push(`
    `); + parts.push(`✗ Not found on PATH`); + parts.push(`

    Install using one of the methods below, then click Re-check above.

    `); + parts.push(`
    `); + + parts.push(`

    Install

    `); + parts.push(`

    Pick whichever fits your toolchain:

    `); + parts.push(`
      `); + parts.push(`
    • npmnpm install -g @salesforce/b2c-cli
    • `); + parts.push( + `
    • Homebrewbrew install salesforcecommercecloud/tools/b2c-cli
    • `, + ); + parts.push(`
    • npx (no install)npx @salesforce/b2c-cli --help
    • `); + parts.push(`
    `); + parts.push(`

    After installing, click Re-check above to confirm detection.

    `); + } + + // What it unlocks + parts.push(`

    What it unlocks

    `); + parts.push(`
      `); + parts.push(`
    • b2c code:deploy — same flow the Cartridges view uses, scriptable from CI.
    • `); + parts.push(`
    • b2c sandbox:* — create/start/stop/delete sandboxes from the terminal.
    • `); + parts.push(`
    • b2c log:tail — stream instance logs.
    • `); + parts.push(`
    • b2c auth:* — non-interactive OAuth client login for pipelines.
    • `); + parts.push(`
    `); + + // Troubleshooting + parts.push(`

    Troubleshooting

    `); + parts.push(`
      `); + parts.push( + `
    • Command not found after npm install -g — your global npm prefix isn't on PATH. Run npm config get prefix and add <prefix>/bin to PATH.
    • `, + ); + parts.push( + `
    • EACCES on install — use a Node version manager (nvm, fnm, volta) instead of sudo npm. Avoid sudo.
    • `, + ); + parts.push( + `
    • Old version behaves oddly — run Update CLI (or npm install -g @salesforce/b2c-cli@latest) to upgrade.
    • `, + ); + parts.push(`
    `); + + parts.push( + `

    Full installation guide on the docs site

    `, + ); + + return parts.join('\n'); +} diff --git a/packages/b2c-vs-extension/src/walkthrough/validator.ts b/packages/b2c-vs-extension/src/walkthrough/validator.ts new file mode 100644 index 000000000..bac4dbe11 --- /dev/null +++ b/packages/b2c-vs-extension/src/walkthrough/validator.ts @@ -0,0 +1,348 @@ +/* + * 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 * as vscode from 'vscode'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +/** + * Validation utilities for walkthrough configuration. + * Ensures package.json walkthrough configuration is correct. + */ + +interface ValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; + info: string[]; +} + +interface WalkthroughConfig { + id?: string; + title?: string; + description?: string; + steps?: StepConfig[]; + [key: string]: unknown; +} + +type ThemedImage = {light?: string; dark?: string; hc?: string; hcLight?: string}; + +interface StepConfig { + id?: string; + title?: string; + description?: string; + media?: { + markdown?: string; + image?: string | ThemedImage; + svg?: string; + altText?: string; + [key: string]: unknown; + }; + completionEvents?: string[]; + [key: string]: unknown; +} + +interface PackageJsonConfig { + contributes?: { + walkthroughs?: WalkthroughConfig[]; + commands?: Array<{id?: string; title?: string; [key: string]: unknown}>; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +/** + * Validate walkthrough configuration in package.json + */ +export async function validateWalkthroughConfiguration(extensionPath: string): Promise { + const result: ValidationResult = { + valid: true, + errors: [], + warnings: [], + info: [], + }; + + try { + // Read package.json + const packageJsonPath = path.join(extensionPath, 'package.json'); + const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8'); + const packageJson = JSON.parse(packageJsonContent); + + // Check if walkthroughs exist + if (!packageJson.contributes?.walkthroughs) { + result.valid = false; + result.errors.push('No walkthroughs defined in package.json'); + return result; + } + + const walkthroughs = packageJson.contributes.walkthroughs; + + // Validate each walkthrough + for (const walkthrough of walkthroughs) { + await validateWalkthrough(walkthrough, extensionPath, result); + } + + // Validate commands exist + await validateCommands(packageJson, result); + } catch (error) { + result.valid = false; + const message = error instanceof Error ? error.message : String(error); + result.errors.push(`Failed to validate: ${message}`); + } + + return result; +} + +/** + * Validate individual walkthrough + */ +async function validateWalkthrough( + walkthrough: WalkthroughConfig, + extensionPath: string, + result: ValidationResult, +): Promise { + const walkthroughId = walkthrough.id || 'unknown'; + + // Check required fields + if (!walkthrough.id) { + result.errors.push('Walkthrough missing required field: id'); + result.valid = false; + } + + if (!walkthrough.title) { + result.errors.push(`Walkthrough "${walkthroughId}" missing required field: title`); + result.valid = false; + } + + if (!walkthrough.description) { + result.warnings.push(`Walkthrough "${walkthroughId}" missing description`); + } + + // Check steps + if (!walkthrough.steps || walkthrough.steps.length === 0) { + result.errors.push(`Walkthrough "${walkthroughId}" has no steps`); + result.valid = false; + return; + } + + result.info.push(`Walkthrough "${walkthroughId}" has ${walkthrough.steps.length} steps`); + + // Validate each step + for (let i = 0; i < walkthrough.steps.length; i++) { + const step = walkthrough.steps[i]; + await validateStep(step, i + 1, walkthroughId, extensionPath, result); + } +} + +/** + * Validate individual step + */ +async function validateStep( + step: StepConfig, + stepNumber: number, + walkthroughId: string, + extensionPath: string, + result: ValidationResult, +): Promise { + const stepId = step.id || `step-${stepNumber}`; + + // Check required fields + if (!step.id) { + result.errors.push(`Step ${stepNumber} in "${walkthroughId}" missing id`); + result.valid = false; + } + + if (!step.title) { + result.errors.push(`Step "${stepId}" missing title`); + result.valid = false; + } + + if (!step.description) { + result.warnings.push(`Step "${stepId}" missing description`); + } + + // Check media + if (!step.media) { + result.warnings.push(`Step "${stepId}" has no media (markdown or image)`); + } else { + // Validate media files exist + if (step.media.markdown) { + const mediaPath = path.join(extensionPath, step.media.markdown); + try { + await fs.access(mediaPath); + result.info.push(`✓ Step "${stepId}": markdown file exists`); + } catch { + result.errors.push(`Step "${stepId}": markdown file not found: ${step.media.markdown}`); + result.valid = false; + } + } + + if (step.media.image) { + const imagePaths = + typeof step.media.image === 'string' + ? [step.media.image] + : Object.values(step.media.image).filter((v): v is string => typeof v === 'string'); + for (const rel of imagePaths) { + const imagePath = path.join(extensionPath, rel); + try { + await fs.access(imagePath); + result.info.push(`✓ Step "${stepId}": image file exists (${rel})`); + } catch { + result.warnings.push(`Step "${stepId}": image file not found: ${rel}`); + } + } + if (!step.media.altText) { + result.warnings.push(`Step "${stepId}": image has no altText (accessibility issue)`); + } + } + + if (step.media.svg) { + const svgPath = path.join(extensionPath, step.media.svg); + try { + await fs.access(svgPath); + result.info.push(`✓ Step "${stepId}": svg file exists`); + if (!step.media.altText) { + result.warnings.push(`Step "${stepId}": svg has no altText (accessibility issue)`); + } + } catch { + result.errors.push(`Step "${stepId}": svg file not found: ${step.media.svg}`); + result.valid = false; + } + } + } + + // Validate completion events + if (step.completionEvents && step.completionEvents.length > 0) { + result.info.push(`Step "${stepId}" has ${step.completionEvents.length} completion event(s)`); + + for (const event of step.completionEvents) { + validateCompletionEvent(event, stepId, result); + } + } else { + result.info.push(`Step "${stepId}" has no completion events (manual completion)`); + } + + // Check description for command links + if (step.description) { + const commandLinks = step.description.match(/command:[\w.-]+/g) || []; + if (commandLinks.length > 0) { + result.info.push(`Step "${stepId}" has ${commandLinks.length} command link(s)`); + } + } +} + +/** + * Validate completion event syntax + */ +function validateCompletionEvent(event: string, stepId: string, result: ValidationResult): void { + const validPrefixes = ['onCommand:', 'onView:', 'onContext:', 'onLink:']; + const hasValidPrefix = validPrefixes.some((prefix) => event.startsWith(prefix)); + + if (!hasValidPrefix) { + result.warnings.push( + `Step "${stepId}": completion event "${event}" doesn't use recognized prefix (${validPrefixes.join(', ')})`, + ); + } + + // Check specific event types + if (event.startsWith('onCommand:')) { + const commandId = event.substring('onCommand:'.length); + result.info.push(`Step "${stepId}" completes on command: ${commandId}`); + } else if (event.startsWith('onView:')) { + const viewId = event.substring('onView:'.length); + result.info.push(`Step "${stepId}" completes on view open: ${viewId}`); + } else if (event.startsWith('onContext:')) { + const contextKey = event.substring('onContext:'.length); + result.info.push(`Step "${stepId}" completes on context: ${contextKey}`); + } +} + +/** + * Validate walkthrough commands are registered + */ +async function validateCommands(packageJson: PackageJsonConfig, result: ValidationResult): Promise { + const commands = packageJson.contributes?.commands || []; + const commandIds = commands.map((cmd) => cmd.id); + + const expectedCommands = [ + 'b2c-dx.walkthrough.open', + 'b2c-dx.walkthrough.createDwJson', + 'b2c-dx.walkthrough.markAllDone', + 'b2c-dx.cli.verify', + 'b2c-dx.cli.update', + 'b2c-dx.walkthrough.chooseCredentialStorage', + 'b2c-dx.walkthrough.inspectSetup', + 'b2c-dx.setup.connection', + 'b2c-dx.setup.oauth', + 'b2c-dx.setup.webdav', + 'b2c-dx.setup.scapi', + 'b2c-dx.setup.resetSession', + ]; + + for (const expectedCommand of expectedCommands) { + if (commandIds.includes(expectedCommand)) { + result.info.push(`✓ Command registered: ${expectedCommand}`); + } else { + result.errors.push(`Command not registered: ${expectedCommand}`); + result.valid = false; + } + } +} + +/** + * Format validation result for display + */ +export function formatValidationResult(result: ValidationResult): string { + const lines: string[] = ['=== Walkthrough Configuration Validation ===', '']; + + if (result.valid) { + lines.push('✅ Configuration is valid!'); + } else { + lines.push('❌ Configuration has errors'); + } + + lines.push(''); + + if (result.errors.length > 0) { + lines.push('Errors:'); + result.errors.forEach((error) => lines.push(` ❌ ${error}`)); + lines.push(''); + } + + if (result.warnings.length > 0) { + lines.push('Warnings:'); + result.warnings.forEach((warning) => lines.push(` ⚠️ ${warning}`)); + lines.push(''); + } + + if (result.info.length > 0) { + lines.push('Info:'); + result.info.forEach((info) => lines.push(` ℹ️ ${info}`)); + } + + return lines.join('\n'); +} + +/** + * VS Code command to validate walkthrough configuration + */ +export async function validateWalkthroughCommand(extensionPath: string, log: vscode.OutputChannel): Promise { + log.appendLine('Validating walkthrough configuration...'); + + const result = await validateWalkthroughConfiguration(extensionPath); + const report = formatValidationResult(result); + + log.appendLine(report); + log.show(); + + if (result.valid) { + vscode.window.showInformationMessage('✅ Walkthrough configuration is valid!'); + } else { + vscode.window.showErrorMessage( + `Walkthrough configuration has ${result.errors.length} error(s). Check Output > B2C DX for details.`, + ); + } +} diff --git a/packages/b2c-vs-extension/test-workspace/.gitignore b/packages/b2c-vs-extension/test-workspace/.gitignore new file mode 100644 index 000000000..e612aa018 --- /dev/null +++ b/packages/b2c-vs-extension/test-workspace/.gitignore @@ -0,0 +1,17 @@ +# B2C Commerce credentials +dw.json + +# Node modules +node_modules/ + +# Build artifacts +dist/ +*.zip + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db diff --git a/packages/b2c-vs-extension/test-workspace/README.md b/packages/b2c-vs-extension/test-workspace/README.md new file mode 100644 index 000000000..23c07dc25 --- /dev/null +++ b/packages/b2c-vs-extension/test-workspace/README.md @@ -0,0 +1,99 @@ +# B2C DX Extension Test Workspace + +This directory serves as a sample B2C Commerce project for testing the extension and walkthrough features. + +## Purpose + +Testing the B2C DX extension requires a workspace with: +- Proper cartridge structure (`.project` files) +- Configuration files (`dw.json`) +- Typical B2C Commerce project layout + +## Usage + +### Option 1: Use This Test Workspace + +```bash +# From the extension directory +cd packages/b2c-vs-extension + +# Open the test workspace in Extension Development Host +code test-workspace --extensionDevelopmentPath=$(pwd) +``` + +### Option 2: From VS Code + +1. Build the extension: `pnpm run build` +2. Press **F5** to launch Extension Development Host +3. In the new window: **File → Open Folder** +4. Navigate to: `packages/b2c-vs-extension/test-workspace` +5. Click **Open** + +### Option 3: Add to launch.json + +The `.vscode/launch.json` is already configured to open this workspace automatically. + +## What's Included + +``` +test-workspace/ +├── README.md (this file) +├── .gitignore (ignores dw.json) +├── cartridges/ (sample cartridges) +│ ├── app_storefront_base/ +│ │ ├── .project +│ │ └── cartridge/ +│ └── int_custom/ +│ ├── .project +│ └── cartridge/ +└── package.json (project metadata) +``` + +## Testing Scenarios + +### Test 1: Fresh Project (No dw.json) +1. Delete `dw.json` if it exists +2. Reload extension +3. Should trigger first-time welcome prompt +4. Click "Create dw.json Template" +5. Verify dw.json is created + +### Test 2: Existing Project (With dw.json) +1. Keep existing `dw.json` +2. Reload extension +3. Walkthrough Step 2 should auto-complete +4. Cartridges should be detected + +### Test 3: Cartridge Detection +1. Open **Cartridges** view +2. Should show 2 cartridges: + - app_storefront_base + - int_custom + +## Updating Configuration + +Edit `dw.json` with your actual B2C instance credentials: + +```json +{ + "hostname": "your-sandbox.demandware.net", + "username": "your-username", + "password": "your-password", + "version": "v1" +} +``` + +**Remember:** Never commit real credentials to Git! + +## Cleanup + +To reset the test workspace: + +```bash +# Remove generated files +rm dw.json +rm .gitignore + +# Or use the cleanup script +./cleanup.sh +``` diff --git a/packages/b2c-vs-extension/test-workspace/cartridges/app_storefront_base/.project b/packages/b2c-vs-extension/test-workspace/cartridges/app_storefront_base/.project new file mode 100644 index 000000000..0c543e3b1 --- /dev/null +++ b/packages/b2c-vs-extension/test-workspace/cartridges/app_storefront_base/.project @@ -0,0 +1,15 @@ + + + app_storefront_base + + + + + com.demandware.studio.core.beehiveElementBuilder + + + + + com.demandware.studio.core.beehiveNature + + diff --git a/packages/b2c-vs-extension/test-workspace/cartridges/app_storefront_base/cartridge/controllers/Home.js b/packages/b2c-vs-extension/test-workspace/cartridges/app_storefront_base/cartridge/controllers/Home.js new file mode 100644 index 000000000..9ed436510 --- /dev/null +++ b/packages/b2c-vs-extension/test-workspace/cartridges/app_storefront_base/cartridge/controllers/Home.js @@ -0,0 +1,21 @@ +'use strict'; + +/** + * Home page controller + */ + +var server = require('server'); + +server.get('Show', function (req, res, next) { + var Site = require('dw/system/Site'); + var URLUtils = require('dw/web/URLUtils'); + + res.render('home/homepage', { + site: Site.current.name, + homeUrl: URLUtils.home().toString(), + }); + + next(); +}); + +module.exports = server.exports(); diff --git a/packages/b2c-vs-extension/test-workspace/cartridges/app_storefront_base/cartridge/scripts/helpers/productHelper.js b/packages/b2c-vs-extension/test-workspace/cartridges/app_storefront_base/cartridge/scripts/helpers/productHelper.js new file mode 100644 index 000000000..9790252d4 --- /dev/null +++ b/packages/b2c-vs-extension/test-workspace/cartridges/app_storefront_base/cartridge/scripts/helpers/productHelper.js @@ -0,0 +1,22 @@ +'use strict'; + +/** + * Product helper functions + */ + +/** + * Get product availability + * @param {dw.catalog.Product} product - Product object + * @returns {Object} Availability object + */ +function getAvailability(product) { + return { + available: product.availabilityModel.availability > 0, + inStock: product.availabilityModel.inStock, + levels: product.availabilityModel.inventoryRecord.ATS.value, + }; +} + +module.exports = { + getAvailability: getAvailability, +}; diff --git a/packages/b2c-vs-extension/test-workspace/cartridges/app_storefront_base/cartridge/static/default/css/main.css b/packages/b2c-vs-extension/test-workspace/cartridges/app_storefront_base/cartridge/static/default/css/main.css new file mode 100644 index 000000000..a8237d884 --- /dev/null +++ b/packages/b2c-vs-extension/test-workspace/cartridges/app_storefront_base/cartridge/static/default/css/main.css @@ -0,0 +1,27 @@ +/* Main stylesheet for B2C storefront */ + +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + background-color: #f5f5f5; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +header { + background-color: #333; + color: white; + padding: 1rem; +} + +.product-tile { + border: 1px solid #ddd; + padding: 15px; + margin: 10px; + background: white; +} diff --git a/packages/b2c-vs-extension/test-workspace/cartridges/int_custom/.project b/packages/b2c-vs-extension/test-workspace/cartridges/int_custom/.project new file mode 100644 index 000000000..c1490eae8 --- /dev/null +++ b/packages/b2c-vs-extension/test-workspace/cartridges/int_custom/.project @@ -0,0 +1,15 @@ + + + int_custom + + + + + com.demandware.studio.core.beehiveElementBuilder + + + + + com.demandware.studio.core.beehiveNature + + diff --git a/packages/b2c-vs-extension/test-workspace/cartridges/int_custom/cartridge/scripts/integration/customService.js b/packages/b2c-vs-extension/test-workspace/cartridges/int_custom/cartridge/scripts/integration/customService.js new file mode 100644 index 000000000..f50705dc1 --- /dev/null +++ b/packages/b2c-vs-extension/test-workspace/cartridges/int_custom/cartridge/scripts/integration/customService.js @@ -0,0 +1,27 @@ +'use strict'; + +/** + * Custom integration service + */ + +var LocalServiceRegistry = require('dw/svc/LocalServiceRegistry'); + +/** + * Initialize custom service + * @returns {dw.svc.Service} Service instance + */ +function getService() { + return LocalServiceRegistry.createService('custom.http.service', { + createRequest: function (svc, params) { + svc.setRequestMethod('GET'); + return params; + }, + parseResponse: function (svc, httpClient) { + return JSON.parse(httpClient.text); + }, + }); +} + +module.exports = { + getService: getService, +}; diff --git a/packages/b2c-vs-extension/test-workspace/package.json b/packages/b2c-vs-extension/test-workspace/package.json new file mode 100644 index 000000000..907cd8e14 --- /dev/null +++ b/packages/b2c-vs-extension/test-workspace/package.json @@ -0,0 +1,12 @@ +{ + "name": "b2c-test-workspace", + "version": "1.0.0", + "description": "Test workspace for B2C DX VS Code Extension", + "private": true, + "scripts": { + "test": "echo \"Test workspace - no scripts needed\"" + }, + "keywords": ["b2c", "commerce", "test"], + "author": "Salesforce", + "license": "Apache-2.0" +} From aad4b484e0584486442de6b86874526736ba4546 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 2 Jun 2026 23:57:25 +0530 Subject: [PATCH 2/5] @W-22799934: Creation of walkthrough on VS Code --- packages/b2c-vs-extension/package.json | 6 + .../src/code-sync/deploy-command.ts | 121 ++++ .../b2c-vs-extension/src/code-sync/index.ts | 18 +- packages/b2c-vs-extension/src/extension.ts | 14 +- .../src/sandbox-tree/sandbox-tree-provider.ts | 11 +- .../b2c-vs-extension/src/scaffold/index.ts | 2 +- .../src/scaffold/scaffold-commands.ts | 14 +- .../src/walkthrough/aiSkillsContent.ts | 449 ++++++++++++ .../src/walkthrough/commands.ts | 431 ++++++++++- .../b2c-vs-extension/src/walkthrough/index.ts | 2 + .../src/walkthrough/onboardingPanel.ts | 675 +++++++++++++++++- .../src/walkthrough/personas.ts | 18 +- .../b2c-vs-extension/src/walkthrough/state.ts | 6 +- .../src/walkthrough/stepDetection.ts | 171 +++++ 14 files changed, 1876 insertions(+), 62 deletions(-) create mode 100644 packages/b2c-vs-extension/src/walkthrough/aiSkillsContent.ts create mode 100644 packages/b2c-vs-extension/src/walkthrough/stepDetection.ts diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index 4b4bceaca..e4dff7107 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -633,6 +633,12 @@ "icon": "$(cloud-upload)", "category": "B2C DX - Code Sync" }, + { + "command": "b2c-dx.codeSync.deployOne", + "title": "Deploy a Single Cartridge", + "icon": "$(cloud-upload)", + "category": "B2C DX - Code Sync" + }, { "command": "b2c-dx.codeSync.refreshCartridges", "title": "Refresh Cartridges", diff --git a/packages/b2c-vs-extension/src/code-sync/deploy-command.ts b/packages/b2c-vs-extension/src/code-sync/deploy-command.ts index f3be4c955..e6556771b 100644 --- a/packages/b2c-vs-extension/src/code-sync/deploy-command.ts +++ b/packages/b2c-vs-extension/src/code-sync/deploy-command.ts @@ -106,6 +106,14 @@ export function createDeployCommand( `Deployed ${selectedCartridges.length} cartridge(s) to "${codeVersion}" successfully`, ); outputChannel.appendLine(`--- Deploy complete ---`); + + // Refresh the WebDAV browser so newly-uploaded cartridges show up. + try { + await vscode.commands.executeCommand('b2c-dx.webdav.refresh'); + } catch { + // best-effort — webdav tree may not be registered if feature is disabled + } + vscode.window.showInformationMessage( `B2C DX: Deployed ${selectedCartridges.length} cartridge(s) to "${codeVersion}".`, ); @@ -120,6 +128,112 @@ export function createDeployCommand( }; } +/** + * Deploys a single cartridge — defaults the picker to the last-scaffolded + * cartridge so users coming from the walkthrough's "Create New Cartridge" + * step can deploy what they just made in one click. + */ +export function createDeployOneCommand( + configProvider: B2CExtensionConfig, + outputChannel: vscode.OutputChannel, + context: vscode.ExtensionContext, +): () => Promise { + return async () => { + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('B2C DX: No B2C Commerce instance configured.'); + return; + } + + let codeVersion = instance.config.codeVersion; + if (!codeVersion) { + try { + const active = await getActiveCodeVersion(instance); + if (active?.id) { + codeVersion = active.id; + instance.config.codeVersion = codeVersion; + } + } catch { + // fall through + } + } + if (!codeVersion) { + vscode.window.showErrorMessage('B2C DX: No code version configured.'); + return; + } + + const directory = configProvider.getWorkingDirectory(); + const cartridges = findCartridges(directory); + if (cartridges.length === 0) { + vscode.window.showWarningMessage('B2C DX: No cartridges found.'); + return; + } + + // Default to the last-scaffolded cartridge so the walkthrough flow is + // one-click. Falls back to the first cartridge if there's no record. + const lastScaffolded = context.workspaceState.get('b2c-dx.scaffold.lastCartridgeName'); + const recommended = lastScaffolded ? (cartridges.find((c) => c.name === lastScaffolded) ?? null) : null; + + let toDeploy; + if (cartridges.length === 1) { + toDeploy = cartridges[0]; + } else { + const items = cartridges.map((c) => ({ + label: c.name === recommended?.name ? `$(star-full) ${c.name}` : c.name, + description: c.name === recommended?.name ? 'recently scaffolded' : c.src, + detail: c.name === recommended?.name ? c.src : undefined, + cartridge: c, + })); + // Sort the recommended cartridge to the top so it's the default focus. + if (recommended) { + items.sort((a, b) => + a.cartridge.name === recommended.name ? -1 : b.cartridge.name === recommended.name ? 1 : 0, + ); + } + const picked = await vscode.window.showQuickPick(items, { + title: 'Deploy a cartridge', + placeHolder: recommended + ? `Default: ${recommended.name} — choose a cartridge to deploy` + : 'Select a cartridge to deploy', + }); + if (!picked) return; + toDeploy = picked.cartridge; + } + + outputChannel.appendLine(`--- Deploy single started ---`); + outputChannel.appendLine(`Cartridge: ${toDeploy.name}`); + outputChannel.appendLine(`Code Version: ${codeVersion}`); + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Deploying ${toDeploy.name}...`, + cancellable: false, + }, + async () => { + try { + await uploadCartridges(instance, [toDeploy]); + outputChannel.appendLine(`Deployed "${toDeploy.name}" to "${codeVersion}"`); + outputChannel.appendLine(`--- Deploy single complete ---`); + + try { + await vscode.commands.executeCommand('b2c-dx.webdav.refresh'); + } catch { + // best-effort + } + + vscode.window.showInformationMessage(`B2C DX: Deployed "${toDeploy.name}" to "${codeVersion}".`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + outputChannel.appendLine(`[Error] Deploy failed: ${message}`); + outputChannel.appendLine(`--- Deploy single failed ---`); + vscode.window.showErrorMessage(`B2C DX: Deploy failed: ${message}`); + } + }, + ); + }; +} + export function createDeleteAndDeployCommand( configProvider: B2CExtensionConfig, outputChannel: vscode.OutputChannel, @@ -176,6 +290,13 @@ export function createDeleteAndDeployCommand( outputChannel.appendLine(`Clean deployed ${cartridges.length} cartridge(s) to "${codeVersion}"`); outputChannel.appendLine(`--- Clean Deploy complete ---`); + + try { + await vscode.commands.executeCommand('b2c-dx.webdav.refresh'); + } catch { + // best-effort + } + vscode.window.showInformationMessage(`B2C DX: Clean deploy complete.`); } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/packages/b2c-vs-extension/src/code-sync/index.ts b/packages/b2c-vs-extension/src/code-sync/index.ts index ee228eeea..76e32e59b 100644 --- a/packages/b2c-vs-extension/src/code-sync/index.ts +++ b/packages/b2c-vs-extension/src/code-sync/index.ts @@ -9,7 +9,7 @@ import type {B2CExtensionConfig} from '../config-provider.js'; import {registerSafeCommand} from '../safety.js'; import {CodeSyncManager} from './code-sync-manager.js'; import {CartridgeTreeProvider, CartridgeItem} from './cartridge-tree-provider.js'; -import {createDeployCommand} from './deploy-command.js'; +import {createDeployCommand, createDeployOneCommand} from './deploy-command.js'; import {registerCartridgeCommands, updateCodeVersionDisplay} from './cartridge-commands.js'; export function registerCodeSync( @@ -58,6 +58,11 @@ export function registerCodeSync( createDeployCommand(configProvider, manager.outputChannel), ); + const deployOneCmd = registerSafeCommand( + 'b2c-dx.codeSync.deployOne', + createDeployOneCommand(configProvider, manager.outputChannel, context), + ); + const refreshCmd = registerSafeCommand('b2c-dx.codeSync.refreshCartridges', () => { cartridgeService.refresh(); manager.refreshCartridges(configProvider.getWorkingDirectory()); @@ -77,6 +82,11 @@ export function registerCodeSync( return; } await manager.uploadSingleCartridge(instance, item.cartridge); + try { + await vscode.commands.executeCommand('b2c-dx.webdav.refresh'); + } catch { + // best-effort + } }); const uploadToInstanceCmd = registerSafeCommand('b2c-dx.codeSync.uploadToInstance', async (uri?: vscode.Uri) => { @@ -87,6 +97,11 @@ export function registerCodeSync( return; } await manager.uploadFileOrFolder(instance, uri, configProvider.getWorkingDirectory()); + try { + await vscode.commands.executeCommand('b2c-dx.webdav.refresh'); + } catch { + // best-effort + } }); // --- Cartridge commands (download, diff, site path, code versions) --- @@ -157,6 +172,7 @@ export function registerCodeSync( startCmd, stopCmd, deployCmd, + deployOneCmd, refreshCmd, uploadCartridgeCmd, uploadToInstanceCmd, diff --git a/packages/b2c-vs-extension/src/extension.ts b/packages/b2c-vs-extension/src/extension.ts index afd89646b..440767430 100644 --- a/packages/b2c-vs-extension/src/extension.ts +++ b/packages/b2c-vs-extension/src/extension.ts @@ -279,20 +279,25 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu // Register walkthrough commands early so they're available for first-time users registerWalkthroughCommands(context); - // Onboarding (next-gen walkthrough) state + panel + // Onboarding (next-gen walkthrough) state + panel. + // The configProvider is created later (line ~480), so we expose a lazy getter + // that the panel calls at refresh time — by then the provider is resolved. const onboardingStore = new OnboardingStateStore(context); context.subscriptions.push(onboardingStore); + // Forward declare; the actual configProvider is assigned below once created. + let lateConfigProvider: B2CExtensionConfig | null = null; + const getConfigProvider = (): B2CExtensionConfig | null => lateConfigProvider; context.subscriptions.push( vscode.commands.registerCommand('b2c-dx.onboarding.open', () => { - OnboardingPanel.show(context, onboardingStore, log); + OnboardingPanel.show(context, onboardingStore, log, getConfigProvider); }), vscode.commands.registerCommand('b2c-dx.onboarding.reset', async () => { await onboardingStore.reset(); - OnboardingPanel.show(context, onboardingStore, log); + OnboardingPanel.show(context, onboardingStore, log, getConfigProvider); }), vscode.commands.registerCommand('b2c-dx.onboarding.changePersona', async () => { await onboardingStore.setPersona(null); - OnboardingPanel.show(context, onboardingStore, log); + OnboardingPanel.show(context, onboardingStore, log, getConfigProvider); }), ); @@ -479,6 +484,7 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu registerJobLogViewer(context); const configProvider = new B2CExtensionConfig(log, context.workspaceState); + lateConfigProvider = configProvider; context.subscriptions.push(configProvider); await configProvider.ensureResolved(); diff --git a/packages/b2c-vs-extension/src/sandbox-tree/sandbox-tree-provider.ts b/packages/b2c-vs-extension/src/sandbox-tree/sandbox-tree-provider.ts index 7d0337b3b..a05977d0a 100644 --- a/packages/b2c-vs-extension/src/sandbox-tree/sandbox-tree-provider.ts +++ b/packages/b2c-vs-extension/src/sandbox-tree/sandbox-tree-provider.ts @@ -231,12 +231,15 @@ export class SandboxTreeDataProvider implements vscode.TreeDataProvider { try { - await runScaffoldWizard(uri, configProvider, log, builtInScaffoldsDir); + await runScaffoldWizard(uri, context, configProvider, log, builtInScaffoldsDir); } catch (err) { const message = err instanceof Error ? err.message : String(err); log.appendLine(`[Scaffold] Error: ${message}`); @@ -57,6 +58,7 @@ export function registerScaffoldCommands( async function runScaffoldWizard( uri: vscode.Uri | undefined, + context: vscode.ExtensionContext, configProvider: B2CExtensionConfig, log: vscode.OutputChannel, builtInScaffoldsDir: string, @@ -254,8 +256,16 @@ async function runScaffoldWizard( // The fs watcher on **/.project misses files written outside any workspace folder // and can race scaffold writes; refresh explicitly when a new .project landed. - if (created.some((f) => path.basename(f.path) === '.project')) { + const projectFile = created.find((f) => path.basename(f.path) === '.project'); + if (projectFile) { await vscode.commands.executeCommand('b2c-dx.codeSync.refreshCartridges'); + // Track the most-recently-scaffolded cartridge name so the walkthrough's + // "Deploy Recommended Cartridge" action can default to it. + const cartridgeName = path.basename(path.dirname(projectFile.absolutePath)); + if (cartridgeName) { + await context.workspaceState.update('b2c-dx.scaffold.lastCartridgeName', cartridgeName); + log.appendLine(`[Scaffold] Recorded last-scaffolded cartridge: ${cartridgeName}`); + } } // Show message with Reveal action for the output directory diff --git a/packages/b2c-vs-extension/src/walkthrough/aiSkillsContent.ts b/packages/b2c-vs-extension/src/walkthrough/aiSkillsContent.ts new file mode 100644 index 000000000..e4b629571 --- /dev/null +++ b/packages/b2c-vs-extension/src/walkthrough/aiSkillsContent.ts @@ -0,0 +1,449 @@ +/* + * 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 * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +/** + * Renders the AI Skills & MCP step body as styled HTML with one-click install + * actions for each IDE the B2C CLI's `setup skills` command supports. + * + * IDE list and detection paths come directly from + * `@salesforce/b2c-tooling-sdk/skills` IDE_CONFIGS (DRY by mirroring the + * paths since the SDK runs in the extension host too). + */ + +export type IdeStatus = 'not-installed' | 'ide-present' | 'skills-installed'; + +export interface AiSkillsTarget { + /** CLI flag value for `b2c setup skills --ide `. */ + id: string; + label: string; + description: string; + /** Filesystem path used to detect IDE presence. */ + detectPath: string; + /** Per-IDE skills install dir for `--global` installs (absolute). */ + globalSkillsDir: string; + /** Per-IDE skills install dir for project-scoped installs (relative to workspace root). */ + projectSkillsDir: string; + /** Marketplace plugin command, if applicable. */ + marketplaceCommand?: string; + /** MCP install one-liner / snippet. */ + mcpCommand?: string; +} + +const home = os.homedir(); + +/** Only IDEs the b2c CLI's `setup skills` command actually supports. */ +export const AI_SKILL_TARGETS: AiSkillsTarget[] = [ + { + id: 'claude-code', + label: 'Claude Code', + description: 'Anthropic CLI agent. Marketplace plugin recommended for auto-updates.', + detectPath: path.join(home, '.claude'), + globalSkillsDir: path.join(home, '.claude', 'skills'), + projectSkillsDir: path.join('.claude', 'skills'), + marketplaceCommand: + 'claude plugin marketplace add SalesforceCommerceCloud/b2c-developer-tooling && claude plugin install b2c-cli', + mcpCommand: + 'claude mcp add --transport stdio --scope project b2c-dx-mcp -- npx -y @salesforce/b2c-dx-mcp@latest --allow-non-ga-tools', + }, + { + id: 'cursor', + label: 'Cursor', + description: 'Cursor IDE. Skills install via b2c CLI; MCP via .cursor/mcp.json.', + detectPath: path.join(home, '.cursor'), + globalSkillsDir: path.join(home, '.cursor', 'skills'), + projectSkillsDir: path.join('.cursor', 'skills'), + mcpCommand: + 'mkdir -p .cursor && printf \'%s\' \'{"mcpServers":{"b2c-dx-mcp":{"command":"npx","args":["-y","@salesforce/b2c-dx-mcp@latest","--allow-non-ga-tools"]}}}\' > .cursor/mcp.json', + }, + { + id: 'windsurf', + label: 'Windsurf', + description: 'Codeium Windsurf editor.', + detectPath: path.join(home, '.codeium', 'windsurf'), + globalSkillsDir: path.join(home, '.codeium', 'windsurf', 'skills'), + projectSkillsDir: path.join('.windsurf', 'skills'), + }, + { + id: 'vscode', + label: 'VS Code / GitHub Copilot', + description: 'Copilot Chat in VS Code. MCP via .vscode/mcp.json.', + detectPath: path.join(home, '.copilot'), + globalSkillsDir: path.join(home, '.copilot', 'skills'), + projectSkillsDir: path.join('.github', 'skills'), + mcpCommand: + 'mkdir -p .vscode && printf \'%s\' \'{"servers":{"b2c-dx-mcp":{"type":"stdio","command":"npx","args":["-y","@salesforce/b2c-dx-mcp@latest","--allow-non-ga-tools"]}}}\' > .vscode/mcp.json', + }, + { + id: 'codex', + label: 'OpenAI Codex CLI', + description: 'Codex CLI agent. Marketplace plugin available.', + detectPath: path.join(home, '.codex'), + globalSkillsDir: path.join(home, '.codex', 'skills'), + projectSkillsDir: path.join('.codex', 'skills'), + marketplaceCommand: 'codex plugin marketplace add SalesforceCommerceCloud/b2c-developer-tooling', + }, + { + id: 'opencode', + label: 'OpenCode', + description: 'OpenCode agentic editor.', + detectPath: path.join(home, '.config', 'opencode'), + globalSkillsDir: path.join(home, '.config', 'opencode', 'skills'), + projectSkillsDir: path.join('.opencode', 'skills'), + }, + { + id: 'agentforce-vibes', + label: 'Agentforce Vibes', + description: 'Salesforce Agentforce Vibes (VS Code extension).', + detectPath: getAgentforceVibesProbePath(), + globalSkillsDir: getAgentforceVibesGlobalDir(), + projectSkillsDir: path.join('.a4drules', 'skills'), + }, +]; + +function getAgentforceVibesGlobalDir(): string { + if (process.platform === 'darwin') { + return path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage'); + } else if (process.platform === 'win32') { + return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Code', 'User', 'globalStorage'); + } + return path.join(home, '.config', 'Code', 'User', 'globalStorage'); +} + +function getAgentforceVibesProbePath(): string { + return path.join(getAgentforceVibesGlobalDir(), 'salesforce.salesforcedx-einstein-gpt'); +} + +async function pathExists(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +async function dirHasB2cSkills(dir: string): Promise { + try { + const entries = await fs.readdir(dir); + return entries.some((e) => e.toLowerCase().startsWith('b2c')); + } catch { + return false; + } +} + +/** + * Returns the install state for a single target. + * - `not-installed` — no IDE installed + * - `ide-present` — IDE is on disk but no B2C skills found + * - `skills-installed` — at least one b2c-* skill is already in the IDE's skills dir + * + * Checks both the global skills directory AND every workspace folder's + * project-scoped skills dir, since `b2c setup skills` defaults to project + * scope unless `--global` is passed. + */ +export async function detectIdeStatus(target: AiSkillsTarget, workspaceRoots: string[] = []): Promise { + const idePresent = await pathExists(target.detectPath); + if (!idePresent) return 'not-installed'; + + // Project-scoped checks (one per workspace folder). + for (const root of workspaceRoots) { + const dir = path.join(root, target.projectSkillsDir); + if (await dirHasB2cSkills(dir)) return 'skills-installed'; + } + + // Global / user-home check. + if (await dirHasB2cSkills(target.globalSkillsDir)) return 'skills-installed'; + + return 'ide-present'; +} + +export interface DetectedTarget extends AiSkillsTarget { + status: IdeStatus; +} + +/** + * Detect status for every target in parallel. Pulls workspace roots from + * VS Code so project-scoped skill installs are picked up. + */ +export async function detectAllTargets(): Promise { + const roots = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + return Promise.all(AI_SKILL_TARGETS.map(async (t) => ({...t, status: await detectIdeStatus(t, roots)}))); +} + +const escape = (s: string): string => + s.replace(/[&<>"']/g, (c) => + c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : ''', + ); + +function statusPill(status: IdeStatus): string { + switch (status) { + case 'skills-installed': + return `Ready · Skills installed`; + case 'ide-present': + return `IDE detected`; + case 'not-installed': + return `Not installed`; + } +} + +/** Per-IDE icon SVG. Stylised glyphs only — no logos to avoid trademark issues. */ +function ideIcon(id: string): string { + switch (id) { + case 'claude-code': + // Star-burst (Anthropic-ish accent) + return ``; + case 'cursor': + // Cursor / pointer arrow + return ``; + case 'windsurf': + // Wind/wave lines + return ``; + case 'vscode': + // Chat bubble (Copilot) + return ``; + case 'codex': + // Code brackets + return ``; + case 'opencode': + // Open hexagon + return ``; + case 'agentforce-vibes': + // Spark with cloud + return ``; + default: + return ``; + } +} + +export function generateAiSkillsHtml(targets: DetectedTarget[]): string { + // Render detected IDEs first, then "ready" ones, then "not installed". + const sorted = [...targets].sort((a, b) => { + const order: Record = {'skills-installed': 0, 'ide-present': 1, 'not-installed': 2}; + return order[a.status] - order[b.status]; + }); + + const cards = sorted + .map((t) => { + const isInstalled = t.status === 'skills-installed'; + const isReady = t.status === 'ide-present'; + const isMissing = t.status === 'not-installed'; + + const skillsLabel = isInstalled ? 'Reinstall' : isReady ? 'Install Skills' : 'Install Skills'; + const skillsDisabled = isMissing ? 'disabled' : ''; + const skillsClass = isReady ? 'ai-btn ai-btn--primary' : isInstalled ? 'ai-btn ai-btn--ghost' : 'ai-btn'; + + const secondaryActions: string[] = []; + if (t.marketplaceCommand) { + secondaryActions.push( + ``, + ); + } + if (t.mcpCommand) { + secondaryActions.push( + ``, + ); + } + + return ` +
    +
    +
    ${ideIcon(t.id)}
    +
    +

    ${escape(t.label)}

    + ${statusPill(t.status)} +
    +
    +

    ${escape(t.description)}

    +
    + + ${secondaryActions.length ? `
    ${secondaryActions.join('')}
    ` : ''} +
    +
    `; + }) + .join(''); + + const installedCount = targets.filter((t) => t.status !== 'not-installed').length; + const skillsInstalledCount = targets.filter((t) => t.status === 'skills-installed').length; + + return ` +
    +

    Configure once and your AI tools share the same instance, dw.json, and cartridge layout this extension already understands.

    + +
    +
    + ${targets.length}compatible + · + ${installedCount}detected + · + ${skillsInstalledCount}skills installed +
    + +
    + +
    ${cards}
    + +

    One-click install. Click Install Skills on a detected IDE and a terminal opens with b2c setup skills b2c --ide <ide> queued. Press Enter to run; the CLI handles paths, downloads, and overwrites.

    + +

    What gets installed

    +
      +
    • Agent Skills — B2C-specific instructions, prompts, and conventions your AI tool can reference.
    • +
    • MCP server — exposes B2C-specific tools (deploy, log queries, sandbox info) to any MCP-aware client.
    • +
    + +

    Agent Skills documentation · MCP server documentation

    +
    `; +} diff --git a/packages/b2c-vs-extension/src/walkthrough/commands.ts b/packages/b2c-vs-extension/src/walkthrough/commands.ts index ab2a493db..32bd88fa5 100644 --- a/packages/b2c-vs-extension/src/walkthrough/commands.ts +++ b/packages/b2c-vs-extension/src/walkthrough/commands.ts @@ -872,17 +872,27 @@ function renderInspectPanel(rows: InspectRow[], parsed: unknown, cliVersion?: st }; const lockSvg = ``; + const copySvg = ``; const renderRow = (r: InspectRow, idx: number): string => { + const valueStr = r.value || ''; const display = r.value ? r.sensitive ? `${lockSvg}••••••••` : escapeHtml(r.value) : ''; const srcLabel = r.source ? escapeHtml(r.source) : 'unknown'; - return ` - ${escapeHtml(r.field)} - ${display} + const srcLower = (r.source ?? 'unknown').toLowerCase(); + const searchHaystack = `${r.field} ${valueStr} ${srcLabel}`.toLowerCase(); + return ` + + ${escapeHtml(r.field)} + + + + ${display} + ${!r.sensitive && r.value ? `` : ''} + ${srcLabel} `; }; @@ -912,22 +922,30 @@ function renderInspectPanel(rows: InspectRow[], parsed: unknown, cliVersion?: st const secretCount = rows.filter((r) => r.sensitive).length; const sourceCount = bySource.size; + // Source legend — show distinct sources with their colors so users can decode the pills. + const legendItems = [...bySource.keys()] + .map( + (s) => + `${escapeHtml(s)}`, + ) + .join(''); + return `
    B2C DX · Resolved Config

    What the CLI sees right now

    Source of truth for every config field — secrets are masked.

    - ${ - rows.length - ? `
    - ${rows.length} field${rows.length === 1 ? '' : 's'} - - ${sourceCount} source${sourceCount === 1 ? '' : 's'} - ${secretCount ? `${lockSvg}${secretCount} masked` : ''} -
    ` - : '' - } +
    + ${cliVersion ? `CLI v${escapeHtml(cliVersion)}` : ''} + ${ + rows.length + ? `${rows.length} field${rows.length === 1 ? '' : 's'} + ${sourceCount} source${sourceCount === 1 ? '' : 's'} + ${secretCount ? `${lockSvg}${secretCount} masked` : ''}` + : '' + } +
    @@ -944,15 +962,36 @@ function renderInspectPanel(rows: InspectRow[], parsed: unknown, cliVersion?: st ${ rows.length ? ` +
    +
    + + + +
    +
    + + ${[...bySource.entries()] + .map( + ([s, items]) => + ``, + ) + .join('')} +
    +
    +

    All fields

    - ${rows.length} + ${rows.length} + +
    - - - ${rows.map((r, i) => renderRow(r, i)).join('')} -
    FieldValueSource
    +
    + + + ${rows.map((r, i) => renderRow(r, i)).join('')} +
    FieldValueSource
    +
    @@ -960,10 +999,13 @@ function renderInspectPanel(rows: InspectRow[], parsed: unknown, cliVersion?: st

    Grouped by source

    ${sourceCount} + ${legendItems ? `
    ${legendItems}
    ` : ''}
    ${[...bySource.entries()].map(([s, items]) => renderSourceBlock(s, items)).join('')}
    + + ` : `
    @@ -983,7 +1025,88 @@ function renderInspectPanel(rows: InspectRow[], parsed: unknown, cliVersion?: st
    ` } - + `; } @@ -1127,6 +1250,167 @@ function inspectStyles(): string { .src-fields li { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 0.82rem; color: var(--vscode-descriptionForeground); display: flex; align-items: center; gap: 6px; } .src-fields li .field { color: var(--vscode-foreground); } pre.raw { background: rgba(127,127,127,0.10); padding: 12px 14px; border-radius: 8px; border: 1px solid var(--hairline); overflow-x: auto; font-size: 0.82rem; line-height: 1.45; white-space: pre; } + + /* Meta chips in header */ + .meta-chips { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 4px; } + .meta-chip { + display: inline-flex; align-items: center; gap: 5px; + padding: 4px 10px; border-radius: 999px; + background: var(--surface); + border: 1px solid var(--hairline); + font-size: 0.78rem; + color: var(--vscode-descriptionForeground); + } + .meta-chip strong { color: var(--vscode-foreground); font-weight: 700; } + .meta-chip svg { color: var(--brand-blue); } + .meta-chip.secret-chip { color: var(--secret-amber); border-color: color-mix(in srgb, var(--secret-amber) 30%, transparent); background: var(--secret-amber-soft); } + .meta-chip.secret-chip strong { color: var(--secret-amber); } + .meta-chip.secret-chip .lock-icon { color: var(--secret-amber); } + + /* Toolbar (search + filter pills) */ + .toolbar { + display: flex; align-items: center; gap: 14px; flex-wrap: wrap; + margin-bottom: 16px; + padding: 12px 14px; + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: 12px; + } + .search-wrap { + position: relative; + flex: 1 1 280px; + min-width: 240px; + display: flex; align-items: center; + } + .search-icon { + position: absolute; left: 12px; top: 50%; transform: translateY(-50%); + color: var(--vscode-descriptionForeground); pointer-events: none; + } + #search { + width: 100%; + padding: 8px 36px 8px 36px; + border-radius: 8px; + border: 1px solid var(--hairline); + background: var(--vscode-input-background, var(--vscode-editor-background)); + color: var(--vscode-input-foreground, var(--vscode-foreground)); + font: inherit; font-size: 0.88rem; + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + } + #search:focus { border-color: var(--brand-blue); box-shadow: 0 0 0 3px rgba(1,118,211,0.18); } + #search::placeholder { color: var(--vscode-input-placeholderForeground, var(--vscode-descriptionForeground)); opacity: 0.85; } + .search-clear { + position: absolute; right: 6px; top: 50%; transform: translateY(-50%); + width: 22px; height: 22px; + display: inline-flex; align-items: center; justify-content: center; + padding: 0; border-radius: 50%; border: 0; + background: transparent; + color: var(--vscode-descriptionForeground); + font-size: 1.1rem; line-height: 1; + cursor: pointer; + } + .search-clear:hover { background: var(--row-hover); color: var(--vscode-foreground); } + + .filter-pills { display: inline-flex; flex-wrap: wrap; gap: 6px; } + .filter-pill { + display: inline-flex; align-items: center; gap: 6px; + padding: 5px 11px; border-radius: 999px; + background: transparent; + border: 1px solid var(--hairline); + color: var(--vscode-descriptionForeground); + font-size: 0.78rem; font-weight: 500; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; + } + .filter-pill:hover { background: var(--row-hover); color: var(--vscode-foreground); } + .filter-pill.active { + background: var(--brand-blue); + color: #fff; border-color: var(--brand-blue); + } + .filter-pill.active .pill-count { background: rgba(255,255,255,0.22); color: #fff; } + .filter-pill .dot { + display: inline-block; width: 7px; height: 7px; border-radius: 50%; + background: var(--src-color, var(--src-fallback)); + } + .filter-pill.active .dot { background: rgba(255,255,255,0.85); } + .pill-count { + display: inline-flex; align-items: center; justify-content: center; + min-width: 18px; height: 16px; padding: 0 5px; + border-radius: 999px; + background: var(--brand-blue-soft); + color: var(--brand-blue-deep); + font-size: 0.68rem; font-weight: 700; + } + + /* Card header spacer + hint */ + .card-hdr-spacer { flex: 1 1 auto; } + .card-hdr .hint { font-size: 0.82rem; } + + /* Sticky table header */ + .tbl-wrap { position: relative; } + .tbl thead th { + position: sticky; top: 0; + background: var(--surface); + z-index: 2; + } + + /* Copy-to-clipboard buttons */ + .copy-btn { + display: inline-flex; align-items: center; justify-content: center; + width: 22px; height: 22px; + margin-left: 6px; + padding: 0; border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease; + vertical-align: middle; + } + .tbl tr:hover .copy-btn { opacity: 0.7; } + .copy-btn:hover { opacity: 1 !important; background: var(--row-hover); color: var(--brand-blue); } + .copy-btn:focus-visible { opacity: 1; outline: 2px solid var(--brand-blue); outline-offset: 1px; } + .field-name { display: inline-block; } + + /* Source legend */ + .legend { + display: flex; flex-wrap: wrap; gap: 8px; + padding: 8px 12px; + margin-bottom: 14px; + background: var(--vscode-editor-background); + border: 1px solid var(--hairline); + border-radius: 8px; + } + .legend-item { + display: inline-flex; align-items: center; gap: 6px; + padding: 3px 9px; border-radius: 999px; + background: color-mix(in srgb, var(--src-color) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--src-color) 22%, transparent); + color: var(--vscode-foreground); + font-size: 0.76rem; font-weight: 500; + } + .legend-item .dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--src-color); + } + + /* Toast */ + .toast { + position: fixed; + bottom: 24px; left: 50%; transform: translateX(-50%) translateY(8px); + padding: 10px 18px; + background: var(--vscode-foreground); + color: var(--vscode-editor-background); + border-radius: 999px; + font-size: 0.84rem; font-weight: 600; + box-shadow: 0 6px 24px rgba(0,0,0,0.18); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease, transform 0.2s ease; + z-index: 100; + } + .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } `; } @@ -1295,7 +1579,8 @@ async function addToGitignore(workspaceRoot: string): Promise { */ export async function showWalkthroughOnFirstActivation(context: vscode.ExtensionContext): Promise { const SEEN_KEY = 'b2c-dx.gettingStarted.autoOpened'; - if (context.globalState.get(SEEN_KEY, false)) return; + // Per-workspace flag: each workspace gets its own first-run experience. + if (context.workspaceState.get(SEEN_KEY, false)) return; const folders = vscode.workspace.workspaceFolders; if (!folders || folders.length === 0) return; @@ -1304,7 +1589,7 @@ export async function showWalkthroughOnFirstActivation(context: vscode.Extension for (const folder of folders) { try { await fs.access(path.join(folder.uri.fsPath, 'dw.json')); - await context.globalState.update(SEEN_KEY, true); + await context.workspaceState.update(SEEN_KEY, true); return; } catch { // not present here, keep checking @@ -1317,7 +1602,7 @@ export async function showWalkthroughOnFirstActivation(context: vscode.Extension 'Salesforce.b2c-vs-extension#b2c-dx.gettingStarted', false, ); - void context.globalState.update(SEEN_KEY, true); + void context.workspaceState.update(SEEN_KEY, true); }, 1000); } @@ -1388,6 +1673,58 @@ async function ensureInstanceName(context: vscode.ExtensionContext): Promise { + const dwJsonPath = path.join(workspaceRoot, 'dw.json'); + if (!(await checkFileExists(dwJsonPath))) return undefined; + try { + const doc = JSON.parse(await fs.readFile(dwJsonPath, 'utf-8')) as { + configs?: {name?: string; active?: boolean}[]; + }; + const configs = Array.isArray(doc.configs) ? doc.configs : []; + const active = configs.find((c) => c?.active === true && typeof c.name === 'string'); + if (active?.name) return active.name; + // Fall back to the only entry when there's exactly one — it's implicitly active. + if (configs.length === 1 && typeof configs[0]?.name === 'string') return configs[0].name; + return undefined; + } catch { + return undefined; + } +} + +/** + * Resolve the instance name for follow-on setup steps (OAuth, WebDAV, SCAPI). + * These edit an existing config rather than creating one, so they should + * target the currently *active* entry in dw.json — not the workspace session, + * which can drift after the user activates a different instance externally. + * + * Order of preference: + * 1. Active config in dw.json (the canonical source of truth) + * 2. Workspace session (set by the Connection step) + * 3. Prompt the user (same UX as ensureInstanceName) + * + * Whenever we resolve from dw.json or by prompt, we sync the workspace session + * so the panel chip and inspect-resolved-config reflect the same target. + */ +async function ensureActiveInstanceName( + context: vscode.ExtensionContext, + workspaceRoot: string, +): Promise { + const activeName = await readActiveInstanceName(workspaceRoot); + if (activeName) { + const session = getSetupSession(context); + if (session?.instanceName !== activeName) { + await setSetupSession(context, activeName); + } + return activeName; + } + return ensureInstanceName(context); +} + /** Read the workspace dw.json and return its `configs[]` entry for `name`, * creating one (with `active: true`) if absent. Caller is responsible for * writing the result back. */ @@ -1553,6 +1890,13 @@ async function runConnectionStep(context: vscode.ExtensionContext): Promise { const wsFolder = vscode.workspace.workspaceFolders?.[0]; @@ -1568,7 +1929,7 @@ async function runOAuthStep(context: vscode.ExtensionContext): Promise { vscode.window.showErrorMessage('B2C DX: Open a workspace folder first.'); return; } - const inst = await ensureInstanceName(context); + const inst = await ensureActiveInstanceName(context, wsFolder.uri.fsPath); if (!inst) return; const placementPicked = await vscode.window.showQuickPick(placementItems(defaultSecretPlacement()), { @@ -1612,7 +1973,7 @@ async function runWebDavStep(context: vscode.ExtensionContext): Promise { vscode.window.showErrorMessage('B2C DX: Open a workspace folder first.'); return; } - const inst = await ensureInstanceName(context); + const inst = await ensureActiveInstanceName(context, wsFolder.uri.fsPath); if (!inst) return; const placementPicked = await vscode.window.showQuickPick(placementItems(defaultSecretPlacement()), { @@ -1656,7 +2017,7 @@ async function runScapiStep(context: vscode.ExtensionContext): Promise { vscode.window.showErrorMessage('B2C DX: Open a workspace folder first.'); return; } - const inst = await ensureInstanceName(context); + const inst = await ensureActiveInstanceName(context, wsFolder.uri.fsPath); if (!inst) return; const shortCode = await vscode.window.showInputBox({ @@ -1675,8 +2036,11 @@ async function runScapiStep(context: vscode.ExtensionContext): Promise { if (tenantId === undefined) return; const oauthScopes = await vscode.window.showInputBox({ title: `SCAPI · ${inst} · oauth-scopes (optional)`, - prompt: 'Space-separated SCAPI scopes', - placeHolder: 'sfcc.shopper-customers sfcc.shopper-products', + prompt: + 'Space- or comma-separated scopes the AM client is authorized to grant. ' + + 'Leave blank if your AM client uses default scopes — pinning shopper-* scopes ' + + 'will break Sandbox Explorer if the same client is not registered for them.', + placeHolder: 'e.g. sfcc.products sfcc.catalogs (leave blank to use AM defaults)', ignoreFocusOut: true, }); @@ -1684,7 +2048,16 @@ async function runScapiStep(context: vscode.ExtensionContext): Promise { const {doc, entry} = await readOrCreateConfigEntry(wsFolder.uri.fsPath, inst); if (shortCode) entry['short-code'] = shortCode; if (tenantId) entry['tenant-id'] = tenantId; - if (oauthScopes) entry['oauth-scopes'] = oauthScopes; + // Persist oauth-scopes as a string[] — the SDK / Sandbox Explorer expects + // an array; the older space-delimited string form trips `scopes.sort()`. + // Accept either delimiter from the user. + if (oauthScopes && oauthScopes.trim().length > 0) { + const scopes = oauthScopes + .split(/[\s,]+/) + .map((s) => s.trim()) + .filter(Boolean); + if (scopes.length > 0) entry['oauth-scopes'] = scopes; + } await writeConfigDoc(wsFolder.uri.fsPath, doc); await offerInspectFollowUp(`SCAPI fields saved to dw.json (${inst}).`); } catch (e) { diff --git a/packages/b2c-vs-extension/src/walkthrough/index.ts b/packages/b2c-vs-extension/src/walkthrough/index.ts index 8248fb29c..236c7b4b7 100644 --- a/packages/b2c-vs-extension/src/walkthrough/index.ts +++ b/packages/b2c-vs-extension/src/walkthrough/index.ts @@ -18,3 +18,5 @@ export {PERSONAS, listPersonas, resolveSteps, STEP_CATALOG} from './personas.js' export type {PersonaId, PersonaDefinition, StepDefinition, StepAction} from './personas.js'; export {detectTools, generateInstallCliHtml} from './toolDetection.js'; export type {ToolDetectionResult, ToolStatus} from './toolDetection.js'; +export {detectStepConfigurations, getDetectionForStep} from './stepDetection.js'; +export type {DetectionSummary, StepDetection} from './stepDetection.js'; diff --git a/packages/b2c-vs-extension/src/walkthrough/onboardingPanel.ts b/packages/b2c-vs-extension/src/walkthrough/onboardingPanel.ts index 6fd68b5ea..633f18727 100644 --- a/packages/b2c-vs-extension/src/walkthrough/onboardingPanel.ts +++ b/packages/b2c-vs-extension/src/walkthrough/onboardingPanel.ts @@ -12,6 +12,16 @@ import {OnboardingStateStore, StepStatus} from './state.js'; import {PERSONAS, PersonaId, StepAction, StepDefinition, getPersona, listPersonas, resolveSteps} from './personas.js'; import {renderMarkdown} from './markdown.js'; import {detectTools, generateInstallCliHtml, ToolDetectionResult} from './toolDetection.js'; +import {detectAllTargets, generateAiSkillsHtml} from './aiSkillsContent.js'; +import { + detectStepConfigurations, + getDetectionForStep, + readDeployContext, + DetectionSummary, + StepDetection, +} from './stepDetection.js'; +import {findCartridges, listCodeVersions, type CodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; +import type {B2CExtensionConfig} from '../config-provider.js'; type InboundMessage = | {type: 'selectPersona'; personaId: PersonaId} @@ -24,7 +34,10 @@ type InboundMessage = | {type: 'runAction'; command: string; args?: unknown[]; stepId?: string} | {type: 'openLink'; url: string} | {type: 'reset'} - | {type: 'ready'}; + | {type: 'ready'} + | {type: 'aiSkills.installSkills'; ide: string} + | {type: 'aiSkills.runCommand'; cmd: string; label: string} + | {type: 'aiSkills.recheck'}; interface PersonaView { id: PersonaId; @@ -43,6 +56,8 @@ interface StepView { status: StepStatus; actions: StepAction[]; html: string; + /** Optional per-step detection chip ("1 configuration detected"). */ + detection?: {label: string; matchedNames: string[]} | null; } interface ViewState { @@ -53,10 +68,22 @@ interface ViewState { setupInstance: string | null; } +type DeployedCartridgesResult = + | {kind: 'ok'; names: string[]; source: 'ocapi' | 'webdav'} + | {kind: 'no-provider'} + | {kind: 'no-instance'; reason?: string} + | {kind: 'no-code-version'} + | {kind: 'error'; reason: string}; + export class OnboardingPanel { private static current: OnboardingPanel | undefined; - static show(context: vscode.ExtensionContext, store: OnboardingStateStore, log: vscode.OutputChannel): void { + static show( + context: vscode.ExtensionContext, + store: OnboardingStateStore, + log: vscode.OutputChannel, + getConfigProvider?: () => B2CExtensionConfig | null, + ): void { if (OnboardingPanel.current) { OnboardingPanel.current.panel.reveal(); return; @@ -66,7 +93,7 @@ export class OnboardingPanel { retainContextWhenHidden: true, localResourceRoots: [vscode.Uri.file(context.extensionPath)], }); - OnboardingPanel.current = new OnboardingPanel(context, store, log, panel); + OnboardingPanel.current = new OnboardingPanel(context, store, log, panel, getConfigProvider); } private readonly disposables: vscode.Disposable[] = []; @@ -77,15 +104,110 @@ export class OnboardingPanel { private readonly store: OnboardingStateStore, private readonly log: vscode.OutputChannel, private readonly panel: vscode.WebviewPanel, + private readonly getConfigProvider?: () => B2CExtensionConfig | null, ) { this.panel.webview.html = this.renderShell(); this.disposables.push( this.panel.onDidDispose(() => this.dispose()), this.panel.webview.onDidReceiveMessage((msg) => this.handleMessage(msg as InboundMessage)), this.store.onDidChange(() => void this.refresh()), + // Re-check whenever the user returns to the panel — covers the case where + // they ran an install in the terminal and switched back. + this.panel.onDidChangeViewState((e) => { + if (e.webviewPanel.active) { + if (this.activeStepId === 'ai-skills') { + this.aiSkillsCache = null; + } + void this.refresh(); + } + }), ); } + /** Cached AI-skills detection result, invalidated on install actions. */ + private aiSkillsCache: import('./aiSkillsContent.js').DetectedTarget[] | null = null; + /** Tracks the in-flight watcher so multiple installs don't stack. */ + private aiSkillsWatcher: NodeJS.Timeout | null = null; + /** Disposables for terminal-shell-execution and close listeners on the in-flight install. */ + private aiSkillsTermDisposables: vscode.Disposable[] = []; + + /** + * Subscribes to terminal events for the install terminal so we can + * deterministically refresh as soon as the install command exits — much + * more responsive than polling. + */ + private watchTerminalForAiSkills(terminal: vscode.Terminal): void { + // Clear any previous subscriptions; only one in-flight install at a time. + this.aiSkillsTermDisposables.forEach((d) => d.dispose()); + this.aiSkillsTermDisposables = []; + + // Stronger signal: shell-execution end (proposed but stable in 1.93+). + // Falls back silently on older runtimes via try/catch. + try { + const api = vscode.window as unknown as { + onDidEndTerminalShellExecution?: (listener: (e: {terminal: vscode.Terminal}) => void) => vscode.Disposable; + }; + if (typeof api.onDidEndTerminalShellExecution === 'function') { + const sub = api.onDidEndTerminalShellExecution((e) => { + if (e.terminal === terminal) { + this.aiSkillsCache = null; + void this.refresh(); + } + }); + this.aiSkillsTermDisposables.push(sub); + } + } catch { + // ignore + } + + // Fallback: when the user closes the terminal, refresh. + const closeSub = vscode.window.onDidCloseTerminal((closed) => { + if (closed === terminal) { + this.aiSkillsCache = null; + void this.refresh(); + this.aiSkillsTermDisposables.forEach((d) => d.dispose()); + this.aiSkillsTermDisposables = []; + } + }); + this.aiSkillsTermDisposables.push(closeSub); + } + + /** + * After kicking off an install in the terminal we don't get a deterministic + * completion signal, so we poll the filesystem every 2s for up to ~60s. + * As soon as detection produces a different snapshot we refresh and stop. + */ + private startAiSkillsWatcher(): void { + if (this.aiSkillsWatcher) clearInterval(this.aiSkillsWatcher); + const before = JSON.stringify(this.aiSkillsCache?.map((t) => ({id: t.id, status: t.status})) ?? []); + let elapsed = 0; + const TICK_MS = 2000; + const MAX_MS = 60000; + this.aiSkillsWatcher = setInterval(async () => { + elapsed += TICK_MS; + try { + const {detectAllTargets} = await import('./aiSkillsContent.js'); + const fresh = await detectAllTargets(); + const snapshot = JSON.stringify(fresh.map((t) => ({id: t.id, status: t.status}))); + if (snapshot !== before) { + this.aiSkillsCache = fresh; + if (this.aiSkillsWatcher) { + clearInterval(this.aiSkillsWatcher); + this.aiSkillsWatcher = null; + } + await this.refresh(); + return; + } + } catch { + // best-effort — keep polling + } + if (elapsed >= MAX_MS && this.aiSkillsWatcher) { + clearInterval(this.aiSkillsWatcher); + this.aiSkillsWatcher = null; + } + }, TICK_MS); + } + private async handleMessage(msg: InboundMessage): Promise { try { switch (msg.type) { @@ -200,6 +322,32 @@ export class OnboardingPanel { this.activeStepId = null; await this.refresh(); return; + case 'aiSkills.installSkills': { + // Open a terminal preloaded with `b2c setup skills b2c --ide `. + // We don't auto-run — the user reviews it and presses Enter. + const ide = msg.ide.replace(/[^a-z0-9-]/gi, ''); + if (!ide) return; + const term = vscode.window.createTerminal({name: `B2C DX — Skills (${ide})`}); + term.show(); + term.sendText(`b2c setup skills b2c --ide ${ide}`, false); + this.watchTerminalForAiSkills(term); + this.startAiSkillsWatcher(); + return; + } + case 'aiSkills.runCommand': { + const safeLabel = msg.label.replace(/[^\w\s—()-]/g, '').slice(0, 40) || 'Install'; + const term = vscode.window.createTerminal({name: `B2C DX — ${safeLabel}`}); + term.show(); + term.sendText(msg.cmd, false); + this.watchTerminalForAiSkills(term); + this.startAiSkillsWatcher(); + return; + } + case 'aiSkills.recheck': { + this.aiSkillsCache = null; + await this.refresh(); + return; + } } } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -226,15 +374,27 @@ export class OnboardingPanel { } private async buildViewState(): Promise { - // Average step ≈ 3.5 min. The "ai-augmented" persona gets a `recommended` - // flag so the gate highlights it as the new path. + // Per-persona time estimates (minutes). Tuned from real walkthrough runs; + // these override the previous step-count × 3.5 formula which over-counted + // assistive UI steps (welcome, next-steps) that take seconds, not minutes. + const PERSONA_MINUTES: Record = { + storefront: 8, + 'api-integration': 10, + 'devops-release': 6, + 'ai-augmented': 12, + }; + const estimateMinutes = (id: string, stepCount: number): number => + PERSONA_MINUTES[id] ?? Math.max(5, Math.round((stepCount * 1.25) / 1) * 1); + + // The "ai-augmented" persona gets a `recommended` flag so the gate + // highlights it as the new path. const personas: PersonaView[] = listPersonas().map((p) => ({ id: p.id, label: p.label, tagline: p.tagline, description: p.description, stepCount: p.stepIds.length, - estimatedMinutes: Math.max(15, Math.round((p.stepIds.length * 3.5) / 5) * 5), + estimatedMinutes: estimateMinutes(p.id, p.stepIds.length), recommended: p.id === 'ai-augmented', })); const personaId = this.store.getPersona(); @@ -244,7 +404,9 @@ export class OnboardingPanel { return {persona: null, personas, steps: [], activeStepId: null, setupInstance}; } const defs = resolveSteps(personaDef.id); - const rawSteps = await Promise.all(defs.map((def) => this.buildStepView(personaDef.id, def))); + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const detectionSummary = await detectStepConfigurations(workspaceRoot); + const rawSteps = await Promise.all(defs.map((def) => this.buildStepView(personaDef.id, def, detectionSummary))); // Sequential gating: a step is locked until every step before it is done // or skipped. The first step is always available. const steps = rawSteps.map((step, idx) => { @@ -263,7 +425,7 @@ export class OnboardingPanel { tagline: personaDef.tagline, description: personaDef.description, stepCount: personaDef.stepIds.length, - estimatedMinutes: Math.max(15, Math.round((personaDef.stepIds.length * 3.5) / 5) * 5), + estimatedMinutes: estimateMinutes(personaDef.id, personaDef.stepIds.length), }; return { persona: activePersonaView, @@ -276,7 +438,11 @@ export class OnboardingPanel { private toolDetectionCache: ToolDetectionResult | null = null; - private async buildStepView(personaId: PersonaId, def: StepDefinition): Promise { + private async buildStepView( + personaId: PersonaId, + def: StepDefinition, + detectionSummary?: DetectionSummary, + ): Promise { const record = this.store.getStep(personaId, def.id); let html: string; let actions = def.actions ?? []; @@ -285,9 +451,44 @@ export class OnboardingPanel { const result = await this.getToolDetection(); html = generateInstallCliHtml(result); actions = this.buildInstallCliActions(result); + } else if (def.id === 'ai-skills') { + if (!this.aiSkillsCache) { + this.aiSkillsCache = await detectAllTargets(); + } + html = generateAiSkillsHtml(this.aiSkillsCache); } else { const markdown = await this.readMarkdown(def.markdown); html = renderMarkdown(markdown); + // For the deploy step, prepend a banner showing exactly what will be + // deployed when the user clicks "Deploy Recommended Cartridge", and + // gray out the primary action if the recommended cartridge is already + // present in the active code version. + if (def.id === 'deploy-code') { + const result = await this.buildDeployBanner(); + if (result) { + html = result.html + html; + if (result.alreadyDeployed && actions.length > 0) { + actions = actions.map((a) => + a.command === 'b2c-dx.codeSync.deployOne' + ? { + ...a, + label: `Already Deployed${result.cartridgeName ? ` · ${result.cartridgeName}` : ''}`, + disabled: true, + tooltip: 'This cartridge is already in the active code version.', + } + : a, + ); + } + } + } + } + + let detection: StepView['detection'] = null; + if (detectionSummary) { + const found: StepDetection | null = getDetectionForStep(def.id, detectionSummary); + if (found && found.matchCount > 0 && found.label) { + detection = {label: found.label, matchedNames: found.matchedNames ?? []}; + } } return { @@ -297,9 +498,363 @@ export class OnboardingPanel { status: record?.status ?? 'available', actions, html, + detection, }; } + /** Cached list of deployed cartridges per code-version, keyed by `host|version`. */ + private deployedCartridgesCache = new Map(); + + /** + * Fetch the cartridges currently deployed to the active code version. + * Tries OCAPI `/code_versions` first (richer data) and falls back to a + * WebDAV PROPFIND on `Cartridges//` (which is what the deploy + * command itself uses, so credentials are usually already set up). + * + * Returns a tagged result so the banner can show a specific reason instead + * of a generic "OAuth not configured" message. + * + * Cached for 30 seconds to avoid hammering the network on every refresh. + */ + private async fetchDeployedCartridges(codeVersion: string | undefined): Promise { + const provider = this.getConfigProvider?.(); + if (!provider) return {kind: 'no-provider'}; + const instance = provider.getInstance(); + if (!instance) { + const err = provider.getConfigError?.(); + return {kind: 'no-instance', reason: err ?? undefined}; + } + if (!codeVersion) return {kind: 'no-code-version'}; + + const host = provider.getConfig()?.values.hostname ?? 'unknown'; + const cacheKey = `${host}|${codeVersion}`; + const cached = this.deployedCartridgesCache.get(cacheKey); + if (cached && Date.now() - cached.fetchedAt < 30_000) return cached.result; + + let ocapiError: string | undefined; + + // 1) Try OCAPI — gives back the canonical list directly. + try { + const versions: CodeVersion[] = await listCodeVersions(instance); + const target = versions.find((v) => v.id === codeVersion); + if (target) { + const names = target.cartridges ?? []; + const result: DeployedCartridgesResult = {kind: 'ok', names, source: 'ocapi'}; + this.deployedCartridgesCache.set(cacheKey, {result, fetchedAt: Date.now()}); + return result; + } + ocapiError = `code version "${codeVersion}" not found on instance`; + } catch (err) { + ocapiError = err instanceof Error ? err.message : String(err); + this.log.appendLine(`[onboarding] OCAPI listCodeVersions failed: ${ocapiError}`); + } + + // 2) Fallback to WebDAV — same auth path as the deploy command itself. + try { + const entries = await instance.webdav.propfind(`Cartridges/${codeVersion}`, '1'); + const names = entries + .filter((e) => e.isCollection && e.displayName && e.displayName !== codeVersion) + .map((e) => e.displayName as string); + const result: DeployedCartridgesResult = {kind: 'ok', names, source: 'webdav'}; + this.deployedCartridgesCache.set(cacheKey, {result, fetchedAt: Date.now()}); + return result; + } catch (err) { + const webdavError = err instanceof Error ? err.message : String(err); + this.log.appendLine(`[onboarding] WebDAV propfind failed: ${webdavError}`); + return {kind: 'error', reason: ocapiError ?? webdavError}; + } + } + + /** + * Builds a "what will be deployed" + "already deployed" banner for the + * deploy-code step. Returns the HTML plus a flag indicating whether the + * recommended cartridge is already in the active code version (so the + * caller can disable the primary action). + */ + private async buildDeployBanner(): Promise<{html: string; alreadyDeployed: boolean; cartridgeName?: string} | null> { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const ctx = await readDeployContext(workspaceRoot); + const lastScaffolded = this.context.workspaceState.get('b2c-dx.scaffold.lastCartridgeName'); + + // Mirror the resolution logic in createDeployOneCommand: + // 1. If the last-scaffolded cartridge still exists in the workspace, use it. + // 2. Otherwise, if there's only one cartridge, that's what gets deployed. + // 3. Otherwise the user is shown a picker (we can't predict the choice). + let resolvedCartridge: string | undefined; + let cartridgeSource: 'scaffolded' | 'only' | 'picker' | 'none' = 'none'; + if (workspaceRoot) { + try { + const cartridges = findCartridges(workspaceRoot); + if (lastScaffolded && cartridges.some((c) => c.name === lastScaffolded)) { + resolvedCartridge = lastScaffolded; + cartridgeSource = 'scaffolded'; + } else if (cartridges.length === 1) { + resolvedCartridge = cartridges[0].name; + cartridgeSource = 'only'; + } else if (cartridges.length > 1) { + cartridgeSource = 'picker'; + } + } catch { + // best-effort — leave as 'none' + } + } + + // Nothing to show if every field is empty. + if (!resolvedCartridge && cartridgeSource === 'none' && !ctx.hostname && !ctx.codeVersion) return null; + + const escape = (s: string) => + s.replace(/[&<>"']/g, (c) => + c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : ''', + ); + const fmt = (v?: string) => + v ? `${escape(v)}` : `not set`; + + const sourceHint = + cartridgeSource === 'scaffolded' + ? 'recently scaffolded' + : cartridgeSource === 'only' + ? 'only cartridge in workspace' + : ''; + const cartridgeLabel = resolvedCartridge + ? `${escape(resolvedCartridge)} ${sourceHint}` + : cartridgeSource === 'picker' + ? `multiple found — you'll be asked to pick one` + : `no cartridges found in workspace`; + + const cartridgeReady = !!resolvedCartridge; + const allReady = cartridgeReady && !!ctx.codeVersion && !!ctx.hostname; + + // Fetch deployed cartridges (best-effort — falls back gracefully). + const deployedResult = await this.fetchDeployedCartridges(ctx.codeVersion); + const deployedNames = deployedResult.kind === 'ok' ? deployedResult.names : []; + const alreadyDeployed = !!(resolvedCartridge && deployedNames.includes(resolvedCartridge)); + + const deployedSection = (() => { + const folderIcon = ``; + const warnIcon = ``; + + if (deployedResult.kind === 'no-provider') { + return `
    + ${warnIcon} + Deployed + + resolving connection… + +
    `; + } + if (deployedResult.kind === 'no-instance') { + const detail = deployedResult.reason ? ` — ${escape(deployedResult.reason)}` : ''; + return `
    + ${warnIcon} + Deployed + + no active B2C instance${detail} + +
    `; + } + if (deployedResult.kind === 'no-code-version') { + return `
    + ${warnIcon} + Deployed + + code-version not set in dw.json + +
    `; + } + if (deployedResult.kind === 'error') { + return `
    + ${warnIcon} + Deployed + + unable to query — ${escape(deployedResult.reason)} + +
    `; + } + const names = deployedResult.names; + if (names.length === 0) { + return `
    + ${folderIcon} + Deployed + + no cartridges deployed yet + +
    `; + } + const chips = names + .map( + (n) => + `${escape(n)}`, + ) + .join(''); + return `
    + ${folderIcon} + Deployed ${names.length} + ${chips} +
    `; + })(); + + const html = ` +
    +
    + + + On click of "Deploy Recommended Cartridge" + + + + ${alreadyDeployed ? 'Already deployed' : allReady ? 'Ready to deploy' : 'Missing details'} + +
    +
    +
    + + Cartridge + ${cartridgeLabel} +
    +
    + + Code version + ${fmt(ctx.codeVersion)} +
    +
    + + Target host + ${fmt(ctx.hostname)} +
    + ${deployedSection} +
    +
    `; + + return {html, alreadyDeployed, cartridgeName: resolvedCartridge}; + } + private async getToolDetection(): Promise { if (!this.toolDetectionCache) { const cached = this.context.globalState.get<{version: string; fetchedAt: number}>( @@ -425,7 +980,7 @@ export class OnboardingPanel {
    - ~30min + ~10min Avg time
    @@ -478,7 +1033,13 @@ export class OnboardingPanel {
    @@ -513,6 +1074,12 @@ export class OnboardingPanel { dispose(): void { OnboardingPanel.current = undefined; + if (this.aiSkillsWatcher) { + clearInterval(this.aiSkillsWatcher); + this.aiSkillsWatcher = null; + } + this.aiSkillsTermDisposables.forEach((d) => d.dispose()); + this.aiSkillsTermDisposables = []; this.disposables.forEach((d) => d.dispose()); this.panel.dispose(); } @@ -1320,8 +1887,31 @@ button.secondary:hover { letter-spacing: 0.12em; font-weight: 600; color: var(--brand-blue-deep); +} +.step-card__actions-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; margin-bottom: 10px; } +.detection-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: 999px; + background: var(--brand-green-soft, rgba(26, 135, 84, 0.12)); + color: var(--brand-green, #1A8754); + border: 1px solid var(--brand-green-hairline, rgba(26, 135, 84, 0.40)); + font-size: 0.74rem; + font-weight: 600; + letter-spacing: 0.01em; + white-space: nowrap; + cursor: default; +} +.detection-chip svg { color: inherit; } .step-actions { display: flex; gap: 10px; @@ -1343,6 +1933,17 @@ button.secondary:hover { border-color: var(--brand-blue); color: var(--brand-blue-deep); } +.step-actions button:disabled, +.step-actions button.ghost:disabled, +.step-actions button:disabled:hover { + cursor: not-allowed; + opacity: 0.55; + background: var(--surface-card); + color: var(--vscode-descriptionForeground); + border: 1px solid var(--hairline); + transform: none; + box-shadow: none; +} .step-card__body { margin-top: 22px; padding-top: 22px; @@ -1761,6 +2362,8 @@ const PANEL_JS = ` } function renderActiveStep(step, idx, total) { + const detectionChip = document.getElementById('step-detection-chip'); + const detectionLabel = document.getElementById('step-detection-label'); if (!step) { stepNumber.textContent = ''; stepPosition.textContent = ''; @@ -1769,6 +2372,7 @@ const PANEL_JS = ` stepActions.innerHTML = ''; stepActionsWrap.hidden = true; stepBody.innerHTML = ''; + if (detectionChip) detectionChip.hidden = true; return; } stepNumber.textContent = String(idx + 1); @@ -1783,20 +2387,63 @@ const PANEL_JS = ` const btn = document.createElement('button'); if (!action.primary) btn.className = 'ghost'; btn.textContent = action.label; - btn.addEventListener('click', () => - post({type: 'runAction', command: action.command, args: action.args, stepId: step.id}), - ); + if (action.tooltip) btn.title = action.tooltip; + if (action.disabled) { + btn.disabled = true; + btn.setAttribute('aria-disabled', 'true'); + } else { + btn.addEventListener('click', () => + post({type: 'runAction', command: action.command, args: action.args, stepId: step.id}), + ); + } stepActions.appendChild(btn); }); } else { stepActionsWrap.hidden = true; } + // Per-step config detection chip (e.g., "1 configuration detected") + if (detectionChip && detectionLabel) { + if (step.detection && step.detection.label) { + detectionLabel.textContent = step.detection.label; + const names = step.detection.matchedNames || []; + detectionChip.title = names.length + ? 'Detected in dw.json: ' + names.join(', ') + : 'Detected in dw.json'; + detectionChip.hidden = false; + // Ensure the actions wrapper is visible even if there are no actions, + // so the chip alone can communicate "this step is already configured". + if (step.actions && step.actions.length === 0) stepActionsWrap.hidden = false; + } else { + detectionChip.hidden = true; + } + } // Scroll the card (not the body) so step-header stays visible after navigation. if (stepCard && typeof stepCard.scrollIntoView === 'function') { stepCard.scrollIntoView({behavior: 'smooth', block: 'start'}); } } + // AI skills step: dispatch button clicks (install skills / run cmd) before + // the generic link interceptor sees them. + document.addEventListener('click', (e) => { + const btn = e.target && e.target.closest && e.target.closest('[data-action]'); + if (!btn) return; + const action = btn.getAttribute('data-action'); + if (action === 'install-skills') { + const ide = btn.getAttribute('data-ide') || ''; + e.preventDefault(); + post({type: 'aiSkills.installSkills', ide: ide}); + } else if (action === 'run-cmd') { + const cmd = btn.getAttribute('data-cmd') || ''; + const label = btn.getAttribute('data-label') || 'Install'; + e.preventDefault(); + post({type: 'aiSkills.runCommand', cmd: cmd, label: label}); + } else if (action === 'ai-recheck') { + e.preventDefault(); + post({type: 'aiSkills.recheck'}); + } + }); + // Intercept clicks on any link inside the content area. We NEVER let the // webview follow command: or http links directly — all routing goes through // the extension host via postMessage. diff --git a/packages/b2c-vs-extension/src/walkthrough/personas.ts b/packages/b2c-vs-extension/src/walkthrough/personas.ts index c1f93d5f0..be12097b9 100644 --- a/packages/b2c-vs-extension/src/walkthrough/personas.ts +++ b/packages/b2c-vs-extension/src/walkthrough/personas.ts @@ -24,6 +24,10 @@ export interface StepAction { args?: unknown[]; /** Marks this as the primary call-to-action (rendered as a filled button). */ primary?: boolean; + /** When true, the button renders disabled with a hint tooltip. */ + disabled?: boolean; + /** Tooltip / aria-label describing why the action is in its current state. */ + tooltip?: string; } export interface PersonaDefinition { @@ -95,7 +99,11 @@ export const STEP_CATALOG: Record = { title: 'Deploy Your First Cartridge', summary: 'Upload cartridge code to your sandbox.', markdown: 'media/walkthrough/deploy-cartridge.md', - actions: [{label: 'Deploy All Cartridges', command: 'b2c-dx.codeSync.deploy', primary: true}], + actions: [ + {label: 'Deploy Recommended Cartridge', command: 'b2c-dx.codeSync.deployOne', primary: true}, + {label: 'Deploy All Cartridges', command: 'b2c-dx.codeSync.deploy'}, + {label: 'Refresh WebDAV Browser', command: 'b2c-dx.webdav.refresh'}, + ], }, 'manage-sandboxes': { id: 'manage-sandboxes', @@ -134,7 +142,7 @@ export const STEP_CATALOG: Record = { id: 'ai-skills', title: 'Set Up Agent Skills & MCP', summary: - 'Pair the extension with Claude Code, Cursor, or Copilot using the documented MCP server and Agent Skills.', + 'One-click install of B2C agent skills + MCP for Claude Code, Cursor, Copilot, Windsurf, Codex, OpenCode, and more.', markdown: 'media/walkthrough/ai-skills.md', }, }; @@ -193,12 +201,14 @@ export const PERSONAS: Record = { label: 'AI-augmented developer', tagline: 'Pair Cursor / Claude Code / Copilot with this extension.', description: - 'Same setup as a storefront developer, plus the documented MCP server and Agent Skills so your AI tools share context with the extension.', + 'AI-first onboarding: get your IDE wired up to B2C agent skills and MCP first, then connect to a sandbox and deploy.', + // AI setup leads — agent skills + MCP get installed before instance config + // so the IDE has B2C context while the user works through the rest. stepIds: [ 'welcome', 'install-cli', - 'configure-dw-json', 'ai-skills', + 'configure-dw-json', 'setup-cartridges', 'deploy-code', 'enable-code-sync', diff --git a/packages/b2c-vs-extension/src/walkthrough/state.ts b/packages/b2c-vs-extension/src/walkthrough/state.ts index 484e6f6c8..e0d0bb260 100644 --- a/packages/b2c-vs-extension/src/walkthrough/state.ts +++ b/packages/b2c-vs-extension/src/walkthrough/state.ts @@ -39,9 +39,9 @@ export class OnboardingStateStore { readonly onDidChange = this.emitter.event; constructor(context: vscode.ExtensionContext) { - this.memento = context.globalState; - // Sync progress across machines signed into the same VS Code account. - context.globalState.setKeysForSync([STATE_KEY]); + // Per-workspace state: each workspace has its own onboarding lifecycle. + // A fresh workspace = a fresh onboarding flow. + this.memento = context.workspaceState; } get(): OnboardingSnapshot { diff --git a/packages/b2c-vs-extension/src/walkthrough/stepDetection.ts b/packages/b2c-vs-extension/src/walkthrough/stepDetection.ts new file mode 100644 index 000000000..b48716283 --- /dev/null +++ b/packages/b2c-vs-extension/src/walkthrough/stepDetection.ts @@ -0,0 +1,171 @@ +/* + * 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 * as fs from 'fs/promises'; +import * as path from 'path'; + +/** + * Lightweight per-step config detection. Reads dw.json directly from the + * workspace root so the panel can show "1 similar configuration detected" + * style hints without depending on the b2c CLI being installed. + */ + +export interface StepDetection { + /** Number of matching configurations detected (e.g., instances with OAuth). */ + matchCount: number; + /** Total instance entries scanned. */ + totalInstances: number; + /** Short label to display in the chip (e.g., "1 similar configuration detected"). */ + label?: string; + /** Names of matched instances, when applicable. */ + matchedNames?: string[]; +} + +interface DwJsonInstance { + name?: string; + hostname?: string; + 'code-version'?: string; + codeVersion?: string; + username?: string; + password?: string; + 'client-id'?: string; + clientId?: string; + 'client-secret'?: string; + clientSecret?: string; + 'short-code'?: string; + shortCode?: string; + 'tenant-id'?: string; + tenantId?: string; + cartridge?: unknown; + cartridgesPath?: string; + active?: boolean; +} + +interface DwJsonShape extends DwJsonInstance { + configs?: DwJsonInstance[]; +} + +async function readDwJson(workspaceRoot: string): Promise { + try { + const raw = await fs.readFile(path.join(workspaceRoot, 'dw.json'), 'utf-8'); + return JSON.parse(raw) as DwJsonShape; + } catch { + return null; + } +} + +/** Flatten a dw.json into a list of instance config blocks. */ +function flattenInstances(dw: DwJsonShape | null): DwJsonInstance[] { + if (!dw) return []; + if (Array.isArray(dw.configs) && dw.configs.length > 0) { + return dw.configs; + } + // Top-level shape: dw.json itself describes one instance. + return [dw]; +} + +const has = (v: unknown): boolean => typeof v === 'string' && v.trim().length > 0; + +function pluralize(n: number, sing: string, plural: string): string { + return n === 1 ? sing : plural; +} + +/** Check what's configured on each instance and tally per category. */ +export interface DetectionSummary { + connection: StepDetection; + oauth: StepDetection; + webdav: StepDetection; + scapi: StepDetection; + cartridges: StepDetection; +} + +export async function detectStepConfigurations(workspaceRoot: string | undefined): Promise { + const empty: StepDetection = {matchCount: 0, totalInstances: 0}; + if (!workspaceRoot) { + return { + connection: {...empty}, + oauth: {...empty}, + webdav: {...empty}, + scapi: {...empty}, + cartridges: {...empty}, + }; + } + + const dw = await readDwJson(workspaceRoot); + const instances = flattenInstances(dw); + const total = instances.length; + + const namesWith = (predicate: (i: DwJsonInstance) => boolean): string[] => + instances + .filter(predicate) + .map((i) => i.name) + .filter((n): n is string => typeof n === 'string' && n.length > 0); + + const connectionNames = namesWith((i) => has(i.hostname)); + const oauthNames = namesWith((i) => has(i['client-id'] ?? i.clientId)); + const webdavNames = namesWith((i) => has(i.username) && has(i.password)); + const scapiNames = namesWith((i) => has(i['short-code'] ?? i.shortCode) && has(i['tenant-id'] ?? i.tenantId)); + const cartridgeNames = namesWith((i) => has(i.cartridgesPath) || Array.isArray(i.cartridge)); + + const make = (names: string[]): StepDetection => { + if (names.length === 0) return {matchCount: 0, totalInstances: total}; + return { + matchCount: names.length, + totalInstances: total, + matchedNames: names, + label: `${names.length} ${pluralize(names.length, 'configuration', 'configurations')} detected`, + }; + }; + + return { + connection: make(connectionNames), + oauth: make(oauthNames), + webdav: make(webdavNames), + scapi: make(scapiNames), + cartridges: make(cartridgeNames), + }; +} + +/** Pulled from dw.json for the deploy-code step's "what will be deployed" preview. */ +export interface DeployContext { + hostname?: string; + codeVersion?: string; + instanceName?: string; +} + +/** Read the active instance's deploy-relevant fields from dw.json. */ +export async function readDeployContext(workspaceRoot: string | undefined): Promise { + if (!workspaceRoot) return {}; + const dw = await readDwJson(workspaceRoot); + if (!dw) return {}; + const instances = flattenInstances(dw); + // Prefer the active instance; fall back to the first one. + const active = instances.find((i) => i.active === true) ?? instances[0]; + if (!active) return {}; + return { + hostname: active.hostname, + codeVersion: active['code-version'] ?? active.codeVersion, + instanceName: active.name, + }; +} + +/** Map a step id to the relevant detection bucket. */ +export function getDetectionForStep(stepId: string, summary: DetectionSummary): StepDetection | null { + switch (stepId) { + case 'configure-dw-json': + return summary.connection; + case 'setup-oauth': + return summary.oauth; + case 'explore-webdav': + return summary.webdav; + case 'setup-cartridges': + // Cartridges step also covers SCAPI; report whichever has more matches, + // preferring cartridges when tied. + return summary.cartridges.matchCount >= summary.scapi.matchCount ? summary.cartridges : summary.scapi; + default: + return null; + } +} From 3d164e243990eb04fb93cfaddeec1bc4c7501c2e Mon Sep 17 00:00:00 2001 From: amit-kumar8-sf Date: Wed, 3 Jun 2026 00:31:30 +0530 Subject: [PATCH 3/5] @W-22799934: Update eslint.config.mjs --- packages/b2c-vs-extension/eslint.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/b2c-vs-extension/eslint.config.mjs b/packages/b2c-vs-extension/eslint.config.mjs index fcb7eb7a0..cc8905264 100644 --- a/packages/b2c-vs-extension/eslint.config.mjs +++ b/packages/b2c-vs-extension/eslint.config.mjs @@ -17,7 +17,7 @@ headerPlugin.rules.header.meta.schema = false; export default [ includeIgnoreFile(gitignorePath), { - ignores: ['src/template/**', 'test-workspace/**'], + ignores: ['src/template/**'], }, ...tseslint.configs.recommended, prettierPlugin, From 25ed94a8538d01247331a454d081a39091df3cdb Mon Sep 17 00:00:00 2001 From: amit-kumar8-sf Date: Wed, 3 Jun 2026 00:35:15 +0530 Subject: [PATCH 4/5] @W-22799934: Update package.json --- packages/b2c-vs-extension/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index e4dff7107..f814e8b11 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -219,7 +219,7 @@ }, { "view": "b2cCartridgeExplorer", - "contents": "No cartridges found.\n\nCartridges are folders containing a `.project` file.\n\n[Generate from a scaffold](command:workbench.action.openWalkthrough?%5B%22Salesforce.b2c-vs-extension%23b2c-dx.gettingStarted%22%2C%22scaffold%22%5D)\n\n[Refresh](command:b2c-dx.codeSync.refreshCartridges)" + "contents": "No cartridges found.\n\nCartridges are identified by .project files in the workspace.\n\n[Generate from a scaffold](command:workbench.action.openWalkthrough?%5B%22Salesforce.b2c-vs-extension%23b2c-dx.gettingStarted%22%2C%22scaffold%22%5D)\n\n[Refresh](command:b2c-dx.codeSync.refreshCartridges)" }, { "view": "b2cCipAnalytics", From e9b8d02003c64d99625ad237723b72dab13400dd Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Wed, 3 Jun 2026 09:07:51 +0530 Subject: [PATCH 5/5] @W-22799934: Changes for onboarding walkthrough --- .../media/walkthrough/README.md | 26 +++--- .../media/walkthrough/oauth-setup.md | 86 ++++++++----------- .../media/walkthrough/welcome-hero-dark.svg | 48 +++++------ .../media/walkthrough/welcome-hero-light.svg | 48 +++++------ packages/b2c-vs-extension/package.json | 5 ++ packages/b2c-vs-extension/src/extension.ts | 50 +++++++++++ .../src/walkthrough/commands.ts | 27 ++++++ .../b2c-vs-extension/src/walkthrough/index.ts | 6 +- 8 files changed, 184 insertions(+), 112 deletions(-) diff --git a/packages/b2c-vs-extension/media/walkthrough/README.md b/packages/b2c-vs-extension/media/walkthrough/README.md index 2f354fbc1..abaad0bf0 100644 --- a/packages/b2c-vs-extension/media/walkthrough/README.md +++ b/packages/b2c-vs-extension/media/walkthrough/README.md @@ -6,17 +6,21 @@ This directory contains markdown content and media assets for the **B2C Commerce ### Markdown Content -| File | Purpose | Used In Step | -|------|---------|--------------| -| `welcome.md` | Introduction and overview | Step 1: Welcome | -| `dw-json-setup.md` | Guide for creating dw.json config | Step 2: Configure Instance | -| `oauth-setup.md` | OAuth credentials setup instructions | Step 3: Setup OAuth | -| `webdav-browser.md` | WebDAV browser feature overview | Step 4: Explore WebDAV | -| `cartridge-structure.md` | Cartridge structure and detection | Step 5: Setup Cartridges | -| `deploy-cartridge.md` | Cartridge deployment guide | Step 6: Deploy Code | -| `sandbox-explorer.md` | Sandbox management instructions | Step 7: Manage Sandboxes | -| `code-sync.md` | Code Sync feature documentation | Step 8: Enable Code Sync | -| `next-steps.md` | Advanced features and resources | Step 9: Next Steps | +Step IDs come from `src/walkthrough/personas.ts` (`STEP_CATALOG`). Each persona in `PERSONAS` picks a subset and ordering of these. + +| File | Step ID | Purpose | +|------|---------|---------| +| `welcome.md` | `welcome` | Intro and the universal five-step path | +| `install-cli.md` | `install-cli` | Optional B2C CLI install (npm / brew / npx) | +| `dw-json-setup.md` | `configure-dw-json` | dw.json layout, credential grouping, resolution precedence | +| `oauth-setup.md` | `setup-oauth` | client-id / client-secret in the active config | +| `webdav-browser.md` | `explore-webdav` | username / password and the WebDAV view | +| `cartridge-structure.md` | `setup-cartridges` | Cartridge layout, `.project` detection, SCAPI fields | +| `deploy-cartridge.md` | `deploy-code` | First deploy via Cartridges view / `b2c-dx.codeSync.deploy` | +| `sandbox-explorer.md` | `manage-sandboxes` | Realm + sandbox lifecycle (DevOps persona) | +| `code-sync.md` | `enable-code-sync` | Auto-deploy on save | +| `ai-skills.md` | `ai-skills` | Agent Skills + MCP install (AI-augmented persona) | +| `next-steps.md` | `next-steps` | Where to go after onboarding | ### Image Assets (To Be Added) diff --git a/packages/b2c-vs-extension/media/walkthrough/oauth-setup.md b/packages/b2c-vs-extension/media/walkthrough/oauth-setup.md index 52d7e7eae..28a7ef233 100644 --- a/packages/b2c-vs-extension/media/walkthrough/oauth-setup.md +++ b/packages/b2c-vs-extension/media/walkthrough/oauth-setup.md @@ -1,69 +1,51 @@ # Set Up OAuth Credentials -OAuth credentials enable advanced features like **Sandbox Management** and **API Browser**. +OAuth credentials unlock features that talk to Account Manager and SCAPI: **Sandbox Explorer**, **API Browser**, **Code Versions**, and most CLI/CI flows. WebDAV and basic cartridge deploys do *not* need OAuth. -## What You Need +> **Optional.** Skip this step if you only need WebDAV browsing or cartridge deploys via username/password. -Add these fields to your `dw.json`: +## What you need in `dw.json` -```json -{ - "hostname": "your-sandbox.demandware.net", - "username": "your-username", - "password": "your-password", - "clientId": "your-client-id", - "clientSecret": "your-client-secret", - "shortCode": "your-short-code" -} -``` - -## Getting OAuth Credentials +The wizard adds these fields to your active config — names use **kebab-case**, the same as the SDK reads: -### 1. Log in to Account Manager -Visit [https://account.demandware.com/](https://account.demandware.com/) - -### 2. Create an API Client -- Navigate to **API Client** section -- Click **Add API Client** -- Configure the client with these scopes: - - `sfcc.sandboxes.rw` (for sandbox management) - - `sfcc.shopper-*` (for SCAPI browsing) - -### 3. Copy Credentials -After creating the client, you'll receive: -- **Client ID**: Unique identifier for your API client -- **Client Secret**: Secret key (save this immediately!) -- **Short Code**: Your organization's short code - -### 4. Add to dw.json ```json { - "clientId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "clientSecret": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "shortCode": "your_short_code" + "configs": [ + { + "name": "dev", + "active": true, + "hostname": "your-sandbox.dx.commercecloud.salesforce.com", + "code-version": "version1", + "client-id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "client-secret": "", + "short-code": "kv7kzm78", + "tenant-id": "zzrf_001" + } + ] } ``` -## What OAuth Enables +`client-id` is an identifier (not sensitive). `client-secret` is sensitive — pick where it should live when the wizard prompts you (Keychain, `pass`, `SFCC_CLIENT_SECRET` env var, or `dw.json`). Same source as `client-id` per the **Credential Grouping** rule. + +## Getting the credentials -✅ **Sandbox Explorer** -- Create and delete sandboxes -- Start, stop, and restart sandboxes -- Extend sandbox expiration -- Open Business Manager directly +1. Open [Account Manager](https://account.demandware.com/) and go to **API Client**. +2. Click **Add API Client** and grant the scopes you need: + - `sfcc.sandboxes.rw` — Sandbox Explorer + - `sfcc.code-versions` (rw) — code-version management + - `sfcc.shopper-*` — only if the same client is also used for SCAPI calls. Pinning these scopes will break Sandbox Explorer if the AM client is **not** registered for them, so leave `oauth-scopes` blank in the SCAPI step unless you know your client supports them. +3. Save and copy the **Client ID** and **Client Secret** immediately — the secret is shown only once. +4. Run the **Set up OAuth** action above (or **B2C DX - Getting Started: Setup · OAuth Credentials** from the Command Palette). The wizard writes `client-id` and the secret to your chosen sources, targeting the **active** config in `dw.json`. -✅ **API Browser** -- Browse SCAPI OpenAPI specifications -- View interactive Swagger UI documentation -- Test API endpoints +## Verify -## Optional Step +- Run **Inspect Resolved Config** above — it shows whether `client-id` and `client-secret` resolved, and from which source. +- Open the **Realm Explorer** (B2C-DX Sandboxes activity bar). If OAuth is wired up correctly the realm loads and lists your sandboxes. -OAuth is **optional** for basic development. You can skip this step if you only need: -- WebDAV browsing -- Cartridge deployment -- Code Sync +## What OAuth unlocks ---- +- **Sandbox Explorer** — create, start, stop, restart, extend, and delete sandboxes; open Business Manager. +- **API Browser** — browse SCAPI OpenAPI specs (also needs `short-code` + `tenant-id` from the SCAPI step). +- **Code Versions** — list, create, and activate code versions from the Cartridges view. -When you're ready, click **Refresh** in the Sandbox Explorer to verify your OAuth setup! +[Full OAuth + scopes reference](https://salesforcecommercecloud.github.io/b2c-developer-tooling/guide/configuration.html#oauth) · [`b2c setup inspect`](https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/setup.html) diff --git a/packages/b2c-vs-extension/media/walkthrough/welcome-hero-dark.svg b/packages/b2c-vs-extension/media/walkthrough/welcome-hero-dark.svg index a2a97a29b..ecc973298 100644 --- a/packages/b2c-vs-extension/media/walkthrough/welcome-hero-dark.svg +++ b/packages/b2c-vs-extension/media/walkthrough/welcome-hero-dark.svg @@ -95,26 +95,26 @@ - - 4 - ROLES + + 4 + ROLES - - - 5 - PHASES + + + 5 + PHASES - - - ~30 MIN - AVG TIME + + + ~10 MIN + AVG TIME - - - - DOC-BACKED + + + + DOC-BACKED - + @@ -125,7 +125,7 @@ - + @@ -134,7 +134,7 @@ SFRA · PWA Kit · ISML Cartridge authoring, fast iteration with Code Sync, and the WebDAV browser. - 7 PHASES · ~25 MIN + 8 PHASES · ~8 MIN @@ -155,7 +155,7 @@ SCAPI · OCAPI · jobs · hooks OAuth setup and the API Browser front-and-center. Code Sync optional. - 7 PHASES · ~30 MIN + 8 PHASES · ~10 MIN @@ -178,7 +178,7 @@ sandboxes · code versions · CAPs OAuth + Sandbox Explorer first; less time on cartridge authoring. - 6 PHASES · ~20 MIN + 7 PHASES · ~6 MIN @@ -206,7 +206,7 @@ Cursor · Claude Code · Copilot Storefront setup plus the documented MCP server and Agent Skills. - 8 PHASES · ~30 MIN + 8 PHASES · ~12 MIN @@ -226,9 +226,9 @@ font-size="13" font-weight="500" fill="#E6F0FB" opacity="0.85"> Click Open role-based guide below to launch the deep-dive panel. - - - + + Already set up? Mark all done → diff --git a/packages/b2c-vs-extension/media/walkthrough/welcome-hero-light.svg b/packages/b2c-vs-extension/media/walkthrough/welcome-hero-light.svg index 241d93eec..6b0768d92 100644 --- a/packages/b2c-vs-extension/media/walkthrough/welcome-hero-light.svg +++ b/packages/b2c-vs-extension/media/walkthrough/welcome-hero-light.svg @@ -95,26 +95,26 @@ - - 4 - ROLES + + 4 + ROLES - - - 5 - PHASES + + + 5 + PHASES - - - ~30 MIN - AVG TIME + + + ~10 MIN + AVG TIME - - - - DOC-BACKED + + + + DOC-BACKED - + @@ -125,7 +125,7 @@ - + @@ -134,7 +134,7 @@ SFRA · PWA Kit · ISML Cartridge authoring, fast iteration with Code Sync, and the WebDAV browser. - 7 PHASES · ~25 MIN + 8 PHASES · ~8 MIN @@ -156,7 +156,7 @@ SCAPI · OCAPI · jobs · hooks OAuth setup and the API Browser front-and-center. Code Sync optional. - 7 PHASES · ~30 MIN + 8 PHASES · ~10 MIN @@ -179,7 +179,7 @@ sandboxes · code versions · CAPs OAuth + Sandbox Explorer first; less time on cartridge authoring. - 6 PHASES · ~20 MIN + 7 PHASES · ~6 MIN @@ -208,7 +208,7 @@ Cursor · Claude Code · Copilot Storefront setup plus the documented MCP server and Agent Skills. - 8 PHASES · ~30 MIN + 8 PHASES · ~12 MIN @@ -229,9 +229,9 @@ Click Open role-based guide below to launch the deep-dive panel. - - - + + Already set up? Mark all done → diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index f814e8b11..f7e05191e 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -726,6 +726,11 @@ "title": "Mark Getting Started as Done", "category": "B2C DX - Getting Started" }, + { + "command": "b2c-dx.walkthrough.resetProgress", + "title": "Reset Getting Started Progress", + "category": "B2C DX - Getting Started" + }, { "command": "b2c-dx.cli.verify", "title": "Verify B2C CLI Installation", diff --git a/packages/b2c-vs-extension/src/extension.ts b/packages/b2c-vs-extension/src/extension.ts index 440767430..1306b5452 100644 --- a/packages/b2c-vs-extension/src/extension.ts +++ b/packages/b2c-vs-extension/src/extension.ts @@ -30,6 +30,7 @@ import {disposeTelemetry, initTelemetry, markFeatureUsed, sendEvent, sendExcepti import {registerCipAnalytics} from './cip-analytics/index.js'; import { registerWalkthroughCommands, + resetWorkspaceOnboardingIfFresh, showWalkthroughOnFirstActivation, initializeTelemetry, validateWalkthroughCommand, @@ -433,6 +434,27 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu ); vscode.window.showInformationMessage('B2C DX: Getting Started marked as complete.'); }), + // "Reset Getting Started Progress" — clears both surfaces: + // • our per-workspace OnboardingStateStore (deep-dive panel) + // • VS Code's per-installation native walkthrough ticks + // VS Code stores native walkthrough completion in user-global state and + // does not expose a per-workspace API to clear it; this command lets the + // user trigger a clean slate manually when switching workspaces. + vscode.commands.registerCommand('b2c-dx.walkthrough.resetProgress', async () => { + await onboardingStore.reset(); + await context.workspaceState.update('b2c-dx.gettingStarted.autoOpened', undefined); + try { + await vscode.commands.executeCommand('resetGettingStartedProgress'); + } catch { + // built-in command not available in older VS Code releases; no-op + } + await vscode.commands.executeCommand( + 'workbench.action.openWalkthrough', + 'Salesforce.b2c-vs-extension#b2c-dx.gettingStarted', + false, + ); + vscode.window.showInformationMessage('B2C DX: Getting Started progress reset.'); + }), ); // Theme toggle — flips between the user's preferred light + dark themes. @@ -819,6 +841,34 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu ); log.appendLine('B2C DX extension activated.'); + // Workspace-only reset: clear stale setup-session keys when the current + // workspace has no dw.json, so a fresh workspace doesn't inherit the + // previous one's onboarding chips/tooltips. + await resetWorkspaceOnboardingIfFresh(context).catch((err) => { + log.appendLine( + `Warning: Failed to reset onboarding for fresh workspace: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + + // Drop the per-workspace onboarding panel state (persona + step records) + // when the workspace has no dw.json, so the deep-dive panel reopens with no + // selection. Cheap to call: workspaceState writes are local. + const workspaceHasDwJson = await (async () => { + const folders = vscode.workspace.workspaceFolders ?? []; + for (const folder of folders) { + try { + await vscode.workspace.fs.stat(vscode.Uri.joinPath(folder.uri, 'dw.json')); + return true; + } catch { + // keep checking + } + } + return false; + })(); + if (!workspaceHasDwJson) { + await onboardingStore.reset(); + } + // Show walkthrough on first activation (optional, non-blocking) // This runs asynchronously after activation is complete showWalkthroughOnFirstActivation(context).catch((err) => { diff --git a/packages/b2c-vs-extension/src/walkthrough/commands.ts b/packages/b2c-vs-extension/src/walkthrough/commands.ts index 32bd88fa5..7b891b3a9 100644 --- a/packages/b2c-vs-extension/src/walkthrough/commands.ts +++ b/packages/b2c-vs-extension/src/walkthrough/commands.ts @@ -1570,6 +1570,33 @@ async function addToGitignore(workspaceRoot: string): Promise { } } +/** + * Defensive per-workspace cleanup of onboarding session state. Runs on every + * activation: if the current workspace has no dw.json, treat it as a fresh + * onboarding context — drop any stale `setup.activeInstance` (which would + * otherwise leak the previous workspace's instance name into chips/tooltips) + * and clear the auto-opened seen flag so the deep-dive panel triggers again. + * + * The OnboardingStateStore itself uses workspaceState and resets naturally + * per workspace; this function only mops up loose keys that aren't covered. + */ +export async function resetWorkspaceOnboardingIfFresh(context: vscode.ExtensionContext): Promise { + const folders = vscode.workspace.workspaceFolders ?? []; + if (folders.length === 0) return; + for (const folder of folders) { + try { + await fs.access(path.join(folder.uri.fsPath, 'dw.json')); + return; // workspace already configured — leave state alone + } catch { + // keep checking + } + } + await context.workspaceState.update('b2c-dx.setup.activeInstance', undefined); + await context.workspaceState.update('b2c-dx.gettingStarted.autoOpened', undefined); + void vscode.commands.executeCommand('setContext', 'b2c-dx.setupSessionActive', false); + void vscode.commands.executeCommand('setContext', 'b2c-dx.setupInstance', undefined); +} + /** * Open the native VS Code walkthrough automatically on first activation, but * only when no dw.json exists in the workspace — i.e. the user hasn't set the diff --git a/packages/b2c-vs-extension/src/walkthrough/index.ts b/packages/b2c-vs-extension/src/walkthrough/index.ts index 236c7b4b7..b455ac031 100644 --- a/packages/b2c-vs-extension/src/walkthrough/index.ts +++ b/packages/b2c-vs-extension/src/walkthrough/index.ts @@ -4,7 +4,11 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -export {registerWalkthroughCommands, showWalkthroughOnFirstActivation} from './commands.js'; +export { + registerWalkthroughCommands, + resetWorkspaceOnboardingIfFresh, + showWalkthroughOnFirstActivation, +} from './commands.js'; export {initializeTelemetry, getTelemetry} from './telemetry.js'; export { validateWalkthroughAccessibility,