diff --git a/.changeset/ext-config-improvements.md b/.changeset/ext-config-improvements.md new file mode 100644 index 00000000..f771b777 --- /dev/null +++ b/.changeset/ext-config-improvements.md @@ -0,0 +1,5 @@ +--- +'b2c-vs-extension': minor +--- + +Add `.env` file loading, `SFCC_*` env var support, and smart workspace folder detection for multi-root workspaces diff --git a/.changeset/ext-config-source-and-ci.md b/.changeset/ext-config-source-and-ci.md new file mode 100644 index 00000000..1fafd57f --- /dev/null +++ b/.changeset/ext-config-source-and-ci.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-tooling-sdk': minor +--- + +Add `EnvSource` config source that maps `SFCC_*` environment variables to config fields diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a22e9fee..5eba2d78 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -400,6 +400,35 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Create VS Code extension release + if: >- + steps.release-type.outputs.type == 'stable' + && steps.packages.outputs.publish_vsx == 'true' + && (steps.packages.outputs.publish_cli == 'true' || steps.packages.outputs.publish_sdk == 'true' || steps.packages.outputs.publish_mcp == 'true') + run: | + VSX_TAG="b2c-vs-extension@${{ steps.packages.outputs.version_vsx }}" + + # Create a dedicated release for the extension (not latest — main releases own that) + gh release create "$VSX_TAG" \ + --title "VS Code Extension ${{ steps.packages.outputs.version_vsx }}" \ + --latest=false \ + --notes "$(cat <<'NOTES' + ## B2C DX VS Code Extension v${{ steps.packages.outputs.version_vsx }} + + Download the \`.vsix\` file below and install via: + \`\`\` + code --install-extension b2c-vs-extension-${{ steps.packages.outputs.version_vsx }}.vsix + \`\`\` + + Or in VS Code: Extensions → ⋯ → Install from VSIX... + NOTES + )" + + # Upload the VSIX to the dedicated release + gh release upload "$VSX_TAG" packages/b2c-vs-extension/*.vsix + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Trigger documentation deployment if: >- steps.release-type.outputs.type == 'stable' && steps.changesets.outputs.skip != 'true' && steps.quick-check.outputs.skip != 'true' diff --git a/AGENTS.md b/AGENTS.md index d79fcf8b..5a5a55fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ This is a monorepo project with the following packages: - `./packages/b2c-cli` - the command line interface built with oclif - `./packages/b2c-tooling-sdk` - the SDK/library for B2C Commerce operations; supports the CLI and can be used standalone - `./packages/b2c-dx-mcp` - Model Context Protocol server; also built with oclif +- `./packages/b2c-vs-extension` - VS Code extension (not published to npm; packaged as VSIX and versioned via git tags) - `./docs` - documentation site (private `@salesforce/b2c-dx-docs` workspace package; not published to npm) ## Common Commands @@ -182,7 +183,7 @@ Changeset guidelines: - HOW a consumer should update their code - Good changesets are brief and user-focused (not contributor); they are generally 1 line or two; The content of the changeset is used in CHANGELOG and release notes. You do not need to list internal implementation details or all details of commands; just the high level summary for users. -Valid changeset packages: `@salesforce/b2c-cli`, `@salesforce/b2c-tooling-sdk`, `@salesforce/b2c-dx-mcp`, `@salesforce/b2c-dx-docs` +Valid changeset packages: `@salesforce/b2c-cli`, `@salesforce/b2c-tooling-sdk`, `@salesforce/b2c-dx-mcp`, `b2c-vs-extension`, `@salesforce/b2c-dx-docs` Create a changeset file directly in `.changeset/` with a unique filename (e.g., `descriptive-change-name.md`): diff --git a/packages/b2c-tooling-sdk/src/config/index.ts b/packages/b2c-tooling-sdk/src/config/index.ts index 4dcedee5..d0072f1f 100644 --- a/packages/b2c-tooling-sdk/src/config/index.ts +++ b/packages/b2c-tooling-sdk/src/config/index.ts @@ -145,3 +145,4 @@ export {ConfigSourceRegistry, globalConfigSourceRegistry} from './config-source- // Config sources (for direct use) export {DwJsonSource} from './sources/dw-json-source.js'; +export {EnvSource} from './sources/env-source.js'; diff --git a/packages/b2c-tooling-sdk/src/config/sources/env-source.ts b/packages/b2c-tooling-sdk/src/config/sources/env-source.ts new file mode 100644 index 00000000..ea076226 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/config/sources/env-source.ts @@ -0,0 +1,111 @@ +/* + * 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 + */ +/** + * Environment variable configuration source. + * + * Maps SFCC_* environment variables to NormalizedConfig fields. + * Not included in default sources — opt-in only via `sourcesBefore`. + * + * @internal This module is internal to the SDK. Use ConfigResolver instead. + */ +import type {AuthMethod} from '../../auth/types.js'; +import {getPopulatedFields} from '../mapping.js'; +import type {ConfigSource, ConfigLoadResult, NormalizedConfig, ResolveConfigOptions} from '../types.js'; +import {getLogger} from '../../logging/logger.js'; + +/** + * Mapping of SFCC_* environment variable names to NormalizedConfig field names. + */ +const ENV_VAR_MAP: Record = { + SFCC_SERVER: 'hostname', + SFCC_WEBDAV_SERVER: 'webdavHostname', + SFCC_CODE_VERSION: 'codeVersion', + SFCC_USERNAME: 'username', + SFCC_PASSWORD: 'password', + SFCC_CERTIFICATE: 'certificate', + SFCC_CERTIFICATE_PASSPHRASE: 'certificatePassphrase', + SFCC_SELFSIGNED: 'selfSigned', + SFCC_CLIENT_ID: 'clientId', + SFCC_CLIENT_SECRET: 'clientSecret', + SFCC_OAUTH_SCOPES: 'scopes', + SFCC_SHORTCODE: 'shortCode', + SFCC_TENANT_ID: 'tenantId', + SFCC_AUTH_METHODS: 'authMethods', + SFCC_ACCOUNT_MANAGER_HOST: 'accountManagerHost', + SFCC_SANDBOX_API_HOST: 'sandboxApiHost', +}; + +/** Fields that should be parsed as comma-separated arrays. */ +const ARRAY_FIELDS = new Set(['scopes', 'authMethods']); + +/** Fields that should be parsed as booleans. */ +const BOOLEAN_FIELDS = new Set(['selfSigned']); + +/** + * Configuration source that reads SFCC_* environment variables. + * + * Priority -10 (higher than dw.json at 0), matching CLI behavior where + * env vars override file-based config. + * + * Not added to default sources — opt-in only. The CLI handles env vars + * via oclif flag `env:` mappings; this source is for consumers like + * the VS Code extension that call `resolveConfig()` directly. + * + * @example + * ```typescript + * import { resolveConfig, EnvSource } from '@salesforce/b2c-tooling-sdk/config'; + * + * const config = resolveConfig({}, { + * sourcesBefore: [new EnvSource()], + * }); + * ``` + * + * @internal + */ +export class EnvSource implements ConfigSource { + readonly name = 'EnvSource'; + readonly priority = -10; + + private readonly env: Record; + + /** + * @param env - Environment object to read from. Defaults to `process.env`. + */ + constructor(env?: Record) { + this.env = env ?? process.env; + } + + load(_options: ResolveConfigOptions): ConfigLoadResult | undefined { + const logger = getLogger(); + const config: NormalizedConfig = {}; + + for (const [envVar, configField] of Object.entries(ENV_VAR_MAP)) { + const value = this.env[envVar]; + if (value === undefined || value === '') continue; + + if (BOOLEAN_FIELDS.has(configField)) { + (config as Record)[configField] = value === 'true' || value === '1'; + } else if (ARRAY_FIELDS.has(configField)) { + (config as Record)[configField] = value + .split(',') + .map((s) => s.trim()) + .filter(Boolean) as string[] | AuthMethod[]; + } else { + (config as Record)[configField] = value; + } + } + + const fields = getPopulatedFields(config); + if (fields.length === 0) { + logger.trace('[EnvSource] No SFCC_* environment variables found'); + return undefined; + } + + logger.trace({fields}, '[EnvSource] Loaded config from environment variables'); + + return {config, location: 'environment variables'}; + } +} diff --git a/packages/b2c-tooling-sdk/src/config/sources/index.ts b/packages/b2c-tooling-sdk/src/config/sources/index.ts index eadbf20e..d1ef4dc3 100644 --- a/packages/b2c-tooling-sdk/src/config/sources/index.ts +++ b/packages/b2c-tooling-sdk/src/config/sources/index.ts @@ -9,5 +9,6 @@ * @internal This module is internal to the SDK. Use ConfigResolver instead. */ export {DwJsonSource} from './dw-json-source.js'; +export {EnvSource} from './env-source.js'; export {MobifySource} from './mobify-source.js'; export {PackageJsonSource} from './package-json-source.js'; diff --git a/packages/b2c-tooling-sdk/test/config/env-source.test.ts b/packages/b2c-tooling-sdk/test/config/env-source.test.ts new file mode 100644 index 00000000..b1fe28cc --- /dev/null +++ b/packages/b2c-tooling-sdk/test/config/env-source.test.ts @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import {EnvSource, ConfigResolver, DwJsonSource} from '@salesforce/b2c-tooling-sdk/config'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +describe('config/EnvSource', () => { + describe('string mapping', () => { + it('maps SFCC_SERVER to hostname', () => { + const source = new EnvSource({SFCC_SERVER: 'test.demandware.net'}); + const result = source.load({}); + expect(result).to.not.be.undefined; + expect(result!.config.hostname).to.equal('test.demandware.net'); + }); + + it('maps SFCC_WEBDAV_SERVER to webdavHostname', () => { + const source = new EnvSource({SFCC_WEBDAV_SERVER: 'webdav.example.com'}); + const result = source.load({}); + expect(result!.config.webdavHostname).to.equal('webdav.example.com'); + }); + + it('maps SFCC_CODE_VERSION to codeVersion', () => { + const source = new EnvSource({SFCC_CODE_VERSION: 'v1'}); + const result = source.load({}); + expect(result!.config.codeVersion).to.equal('v1'); + }); + + it('maps SFCC_USERNAME to username', () => { + const source = new EnvSource({SFCC_USERNAME: 'user@example.com'}); + const result = source.load({}); + expect(result!.config.username).to.equal('user@example.com'); + }); + + it('maps SFCC_PASSWORD to password', () => { + const source = new EnvSource({SFCC_PASSWORD: 'secret'}); + const result = source.load({}); + expect(result!.config.password).to.equal('secret'); + }); + + it('maps SFCC_CERTIFICATE to certificate', () => { + const source = new EnvSource({SFCC_CERTIFICATE: '/path/to/cert.p12'}); + const result = source.load({}); + expect(result!.config.certificate).to.equal('/path/to/cert.p12'); + }); + + it('maps SFCC_CERTIFICATE_PASSPHRASE to certificatePassphrase', () => { + const source = new EnvSource({SFCC_CERTIFICATE_PASSPHRASE: 'pass123'}); + const result = source.load({}); + expect(result!.config.certificatePassphrase).to.equal('pass123'); + }); + + it('maps SFCC_CLIENT_ID to clientId', () => { + const source = new EnvSource({SFCC_CLIENT_ID: 'my-client'}); + const result = source.load({}); + expect(result!.config.clientId).to.equal('my-client'); + }); + + it('maps SFCC_CLIENT_SECRET to clientSecret', () => { + const source = new EnvSource({SFCC_CLIENT_SECRET: 'my-secret'}); + const result = source.load({}); + expect(result!.config.clientSecret).to.equal('my-secret'); + }); + + it('maps SFCC_SHORTCODE to shortCode', () => { + const source = new EnvSource({SFCC_SHORTCODE: 'abc123'}); + const result = source.load({}); + expect(result!.config.shortCode).to.equal('abc123'); + }); + + it('maps SFCC_TENANT_ID to tenantId', () => { + const source = new EnvSource({SFCC_TENANT_ID: 'abcd_prd'}); + const result = source.load({}); + expect(result!.config.tenantId).to.equal('abcd_prd'); + }); + + it('maps SFCC_ACCOUNT_MANAGER_HOST to accountManagerHost', () => { + const source = new EnvSource({SFCC_ACCOUNT_MANAGER_HOST: 'account.demandware.com'}); + const result = source.load({}); + expect(result!.config.accountManagerHost).to.equal('account.demandware.com'); + }); + + it('maps SFCC_SANDBOX_API_HOST to sandboxApiHost', () => { + const source = new EnvSource({SFCC_SANDBOX_API_HOST: 'admin.dx.commercecloud.salesforce.com'}); + const result = source.load({}); + expect(result!.config.sandboxApiHost).to.equal('admin.dx.commercecloud.salesforce.com'); + }); + }); + + describe('boolean parsing', () => { + it('parses SFCC_SELFSIGNED=true as boolean true', () => { + const source = new EnvSource({SFCC_SELFSIGNED: 'true'}); + const result = source.load({}); + expect(result!.config.selfSigned).to.be.true; + }); + + it('parses SFCC_SELFSIGNED=1 as boolean true', () => { + const source = new EnvSource({SFCC_SELFSIGNED: '1'}); + const result = source.load({}); + expect(result!.config.selfSigned).to.be.true; + }); + + it('parses SFCC_SELFSIGNED=false as boolean false', () => { + const source = new EnvSource({SFCC_SELFSIGNED: 'false'}); + const result = source.load({}); + expect(result!.config.selfSigned).to.be.false; + }); + + it('parses SFCC_SELFSIGNED=0 as boolean false', () => { + const source = new EnvSource({SFCC_SELFSIGNED: '0'}); + const result = source.load({}); + expect(result!.config.selfSigned).to.be.false; + }); + }); + + describe('array parsing', () => { + it('parses SFCC_OAUTH_SCOPES as comma-separated array', () => { + const source = new EnvSource({SFCC_OAUTH_SCOPES: 'mail,roles,openid'}); + const result = source.load({}); + expect(result!.config.scopes).to.deep.equal(['mail', 'roles', 'openid']); + }); + + it('trims whitespace in comma-separated values', () => { + const source = new EnvSource({SFCC_OAUTH_SCOPES: ' mail , roles , openid '}); + const result = source.load({}); + expect(result!.config.scopes).to.deep.equal(['mail', 'roles', 'openid']); + }); + + it('filters empty values in comma-separated arrays', () => { + const source = new EnvSource({SFCC_OAUTH_SCOPES: 'mail,,roles,'}); + const result = source.load({}); + expect(result!.config.scopes).to.deep.equal(['mail', 'roles']); + }); + + it('parses SFCC_AUTH_METHODS as comma-separated array', () => { + const source = new EnvSource({SFCC_AUTH_METHODS: 'client-credentials,implicit'}); + const result = source.load({}); + expect(result!.config.authMethods).to.deep.equal(['client-credentials', 'implicit']); + }); + }); + + describe('empty/undefined handling', () => { + it('returns undefined when no SFCC_* vars are set', () => { + const source = new EnvSource({}); + const result = source.load({}); + expect(result).to.be.undefined; + }); + + it('skips empty string values', () => { + const source = new EnvSource({SFCC_SERVER: '', SFCC_CLIENT_ID: 'my-client'}); + const result = source.load({}); + expect(result).to.not.be.undefined; + expect(result!.config.hostname).to.be.undefined; + expect(result!.config.clientId).to.equal('my-client'); + }); + + it('skips undefined values', () => { + const source = new EnvSource({SFCC_SERVER: undefined, SFCC_CLIENT_ID: 'my-client'}); + const result = source.load({}); + expect(result!.config.hostname).to.be.undefined; + expect(result!.config.clientId).to.equal('my-client'); + }); + + it('ignores non-SFCC environment variables', () => { + const source = new EnvSource({HOME: '/home/user', PATH: '/usr/bin', SFCC_SERVER: 'test.demandware.net'}); + const result = source.load({}); + expect(result!.config.hostname).to.equal('test.demandware.net'); + expect(Object.keys(result!.config)).to.have.length(1); + }); + }); + + describe('metadata', () => { + it('has name EnvSource', () => { + const source = new EnvSource({}); + expect(source.name).to.equal('EnvSource'); + }); + + it('has priority -10', () => { + const source = new EnvSource({}); + expect(source.priority).to.equal(-10); + }); + + it('reports location as environment variables', () => { + const source = new EnvSource({SFCC_SERVER: 'test.demandware.net'}); + const result = source.load({}); + expect(result!.location).to.equal('environment variables'); + }); + }); + + describe('integration with ConfigResolver', () => { + let tempDir: string; + let originalCwd: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'env-source-test-')); + originalCwd = process.cwd(); + process.chdir(tempDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, {recursive: true, force: true}); + } + }); + + it('EnvSource overrides DwJsonSource values (priority -10 < 0)', () => { + // Create dw.json with a hostname + fs.writeFileSync(path.join(tempDir, 'dw.json'), JSON.stringify({hostname: 'dw.demandware.net'})); + + // EnvSource with different hostname + const envSource = new EnvSource({SFCC_SERVER: 'env.demandware.net'}); + const resolver = new ConfigResolver([envSource, new DwJsonSource()]); + const {config} = resolver.resolve(); + + // EnvSource should win (priority -10 < 0) + expect(config.hostname).to.equal('env.demandware.net'); + }); + + it('DwJsonSource fills gaps not covered by EnvSource', () => { + // dw.json provides code-version + fs.writeFileSync( + path.join(tempDir, 'dw.json'), + JSON.stringify({hostname: 'dw.demandware.net', 'code-version': 'v2'}), + ); + + // EnvSource provides only hostname + const envSource = new EnvSource({SFCC_SERVER: 'env.demandware.net'}); + const resolver = new ConfigResolver([envSource, new DwJsonSource()]); + const {config} = resolver.resolve(); + + expect(config.hostname).to.equal('env.demandware.net'); + expect(config.codeVersion).to.equal('v2'); + }); + }); + + describe('defaults to process.env', () => { + it('reads from process.env when no env param given', () => { + const original = process.env.SFCC_SERVER; + try { + process.env.SFCC_SERVER = 'from-process-env.demandware.net'; + const source = new EnvSource(); + const result = source.load({}); + expect(result).to.not.be.undefined; + expect(result!.config.hostname).to.equal('from-process-env.demandware.net'); + } finally { + if (original === undefined) { + delete process.env.SFCC_SERVER; + } else { + process.env.SFCC_SERVER = original; + } + } + }); + }); +}); diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index 5646799f..7723c672 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -348,6 +348,16 @@ "title": "New from Scaffold...", "icon": "$(file-code)", "category": "B2C DX" + }, + { + "command": "b2c-dx.setProjectRoot", + "title": "Use as B2C Commerce Root", + "category": "B2C DX" + }, + { + "command": "b2c-dx.resetProjectRoot", + "title": "Reset B2C Commerce Root to Auto-Detect", + "category": "B2C DX" } ], "menus": { @@ -502,6 +512,11 @@ "submenu": "b2c-dx.submenu", "when": "explorerResourceIsFolder", "group": "7_modification@9" + }, + { + "command": "b2c-dx.setProjectRoot", + "when": "explorerResourceIsRoot && workspaceFolderCount > 1", + "group": "7_modification@10" } ], "b2c-dx.submenu": [ @@ -592,6 +607,10 @@ { "command": "b2c-dx.content.clearFilter", "when": "false" + }, + { + "command": "b2c-dx.setProjectRoot", + "when": "false" } ] } diff --git a/packages/b2c-vs-extension/src/config-provider.ts b/packages/b2c-vs-extension/src/config-provider.ts index f61ce40e..a535dd69 100644 --- a/packages/b2c-vs-extension/src/config-provider.ts +++ b/packages/b2c-vs-extension/src/config-provider.ts @@ -3,20 +3,101 @@ * 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 {resolveConfig, type NormalizedConfig, type ResolvedB2CConfig} from '@salesforce/b2c-tooling-sdk/config'; +import { + resolveConfig, + EnvSource, + type NormalizedConfig, + type ResolvedB2CConfig, +} from '@salesforce/b2c-tooling-sdk/config'; import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; const DW_JSON = 'dw.json'; +const DOT_ENV = '.env'; +const PROJECT_ROOT_KEY = 'b2c-dx.projectRoot'; + +/** + * Detect the best workspace folder for B2C config resolution. + * + * Scans all workspace folders for B2C indicators in priority order: + * 1. Folder containing dw.json (strongest signal) + * 2. Folder containing .env with SFCC_* variables + * 3. Folder containing package.json with `b2c` key + * 4. Falls back to first folder (current behavior) + * + * Single-folder workspaces skip scanning (fast path). + */ +function detectWorkingDirectory(log: vscode.OutputChannel): string { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) { + log.appendLine('[Config] No workspace folders open, falling back to process.cwd()'); + return process.cwd(); + } + + // Single-folder workspace — fast path + if (folders.length === 1) { + return folders[0].uri.fsPath; + } + + // Multi-root: scan for B2C indicators + const folderNames = folders.map((f) => f.uri.fsPath).join(', '); + log.appendLine( + `[Config] Multi-root workspace detected (${folders.length} folders: ${folderNames}), scanning for B2C project...`, + ); + + for (const folder of folders) { + const dwJsonPath = path.join(folder.uri.fsPath, DW_JSON); + if (fs.existsSync(dwJsonPath)) { + log.appendLine(`[Config] Selected workspace folder via dw.json: ${folder.uri.fsPath}`); + return folder.uri.fsPath; + } + } + + for (const folder of folders) { + const envPath = path.join(folder.uri.fsPath, DOT_ENV); + try { + if (fs.existsSync(envPath)) { + const content = fs.readFileSync(envPath, 'utf-8'); + if (/^SFCC_/m.test(content)) { + log.appendLine(`[Config] Selected workspace folder via .env with SFCC_* vars: ${folder.uri.fsPath}`); + return folder.uri.fsPath; + } + } + } catch { + // Ignore read errors + } + } + + for (const folder of folders) { + const pkgPath = path.join(folder.uri.fsPath, 'package.json'); + try { + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + if (pkg && typeof pkg === 'object' && 'b2c' in pkg) { + log.appendLine(`[Config] Selected workspace folder via package.json "b2c" key: ${folder.uri.fsPath}`); + return folder.uri.fsPath; + } + } + } catch { + // Ignore parse errors + } + } + + // Fallback to first folder + log.appendLine( + `[Config] No B2C indicators found in any workspace folder, falling back to first folder: ${folders[0].uri.fsPath}`, + ); + return folders[0].uri.fsPath; +} /** * Centralized B2C config provider for the VS Code extension. * - * Resolves config from dw.json / env vars once, caches the result, + * Resolves config from dw.json / .env / env vars once, caches the result, * and exposes an event so all features can react to config changes. - * Watches for dw.json changes via both a FileSystemWatcher (external edits, + * Watches for dw.json and .env changes via both FileSystemWatchers (external edits, * creates, deletes) and onDidSaveTextDocument (in-editor saves). */ export class B2CExtensionConfig implements vscode.Disposable { @@ -24,18 +105,24 @@ export class B2CExtensionConfig implements vscode.Disposable { private instance: B2CInstance | null = null; private configError: string | null = null; private resolved = false; + private detectedDirectory = ''; + private pinned = false; private readonly _onDidReset = new vscode.EventEmitter(); readonly onDidReset = this._onDidReset.event; private readonly disposables: vscode.Disposable[] = []; - constructor(private readonly log: vscode.OutputChannel) { - // Watch for dw.json saves made within VS Code (most reliable for in-editor edits) + constructor( + private readonly log: vscode.OutputChannel, + private readonly workspaceState?: vscode.Memento, + ) { + // Watch for dw.json and .env saves made within VS Code (most reliable for in-editor edits) this.disposables.push( vscode.workspace.onDidSaveTextDocument((doc) => { - if (path.basename(doc.fileName) === DW_JSON) { - this.log.appendLine(`[Config] dw.json saved in editor: ${doc.fileName}`); + const basename = path.basename(doc.fileName); + if (basename === DW_JSON || basename === DOT_ENV) { + this.log.appendLine(`[Config] ${basename} saved in editor: ${doc.fileName}`); this.reset(); } }), @@ -44,22 +131,24 @@ export class B2CExtensionConfig implements vscode.Disposable { // FileSystemWatcher per workspace folder for external changes and create/delete. // RelativePattern is more reliable than a bare glob string on macOS. for (const folder of vscode.workspace.workspaceFolders ?? []) { - const pattern = new vscode.RelativePattern(folder, `**/${DW_JSON}`); - const watcher = vscode.workspace.createFileSystemWatcher(pattern); - watcher.onDidChange((uri) => { - this.log.appendLine(`[Config] dw.json changed (fs watcher): ${uri.fsPath}`); - this.reset(); - }); - watcher.onDidCreate((uri) => { - this.log.appendLine(`[Config] dw.json created: ${uri.fsPath}`); - this.reset(); - }); - watcher.onDidDelete((uri) => { - this.log.appendLine(`[Config] dw.json deleted: ${uri.fsPath}`); - this.reset(); - }); - this.disposables.push(watcher); - this.log.appendLine(`[Config] File watcher registered for ${folder.uri.fsPath}/**/${DW_JSON}`); + for (const filename of [DW_JSON, DOT_ENV]) { + const pattern = new vscode.RelativePattern(folder, `**/${filename}`); + const watcher = vscode.workspace.createFileSystemWatcher(pattern); + watcher.onDidChange((uri) => { + this.log.appendLine(`[Config] ${filename} changed (fs watcher): ${uri.fsPath}`); + this.reset(); + }); + watcher.onDidCreate((uri) => { + this.log.appendLine(`[Config] ${filename} created: ${uri.fsPath}`); + this.reset(); + }); + watcher.onDidDelete((uri) => { + this.log.appendLine(`[Config] ${filename} deleted: ${uri.fsPath}`); + this.reset(); + }); + this.disposables.push(watcher); + this.log.appendLine(`[Config] File watcher registered for ${folder.uri.fsPath}/**/${filename}`); + } } } @@ -84,12 +173,55 @@ export class B2CExtensionConfig implements vscode.Disposable { return this.configError; } + /** + * Returns the working directory used for config resolution. + * Either the pinned project root or the auto-detected workspace folder. + */ + getWorkingDirectory(): string { + if (!this.resolved) { + this.resolve(); + } + return this.detectedDirectory; + } + + /** + * Whether the project root was explicitly pinned by the user + * (vs auto-detected). + */ + isProjectRootPinned(): boolean { + if (!this.resolved) { + this.resolve(); + } + return this.pinned; + } + + /** + * Pin a specific folder as the B2C project root. + * Persisted in workspace state so it survives reloads. + */ + async setProjectRoot(folderPath: string): Promise { + this.log.appendLine(`[Config] Pinning project root to: ${folderPath}`); + await this.workspaceState?.update(PROJECT_ROOT_KEY, folderPath); + this.reset(); + } + + /** + * Clear the pinned project root and return to auto-detection. + */ + async resetProjectRoot(): Promise { + this.log.appendLine('[Config] Clearing pinned project root, returning to auto-detect'); + await this.workspaceState?.update(PROJECT_ROOT_KEY, undefined); + this.reset(); + } + reset(): void { this.log.appendLine('[Config] Resetting cached config (will re-resolve on next access)'); this.config = null; this.instance = null; this.configError = null; this.resolved = false; + this.detectedDirectory = ''; + this.pinned = false; this._onDidReset.fire(); } @@ -111,12 +243,43 @@ export class B2CExtensionConfig implements vscode.Disposable { private resolve(): void { this.resolved = true; try { - let workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); + // Check for pinned project root first + const pinnedRoot = this.workspaceState?.get(PROJECT_ROOT_KEY); + let workingDirectory: string; + if (pinnedRoot && fs.existsSync(pinnedRoot)) { + workingDirectory = pinnedRoot; + this.pinned = true; + this.log.appendLine(`[Config] Using pinned project root: ${pinnedRoot}`); + } else { + if (pinnedRoot) { + // Pinned path no longer exists — clear it + this.log.appendLine(`[Config] Pinned project root no longer exists, clearing: ${pinnedRoot}`); + void this.workspaceState?.update(PROJECT_ROOT_KEY, undefined); + } + workingDirectory = detectWorkingDirectory(this.log); + this.pinned = false; + } if (!workingDirectory || workingDirectory === '/' || !fs.existsSync(workingDirectory)) { workingDirectory = ''; } + this.detectedDirectory = workingDirectory; this.log.appendLine(`[Config] Resolving config from ${workingDirectory || '(no working directory)'}`); - const config = resolveConfig({}, {workingDirectory}); + + // Load .env file if present (same as CLI's bin/run.js) + if (workingDirectory) { + const envFilePath = path.join(workingDirectory, DOT_ENV); + try { + if (typeof process.loadEnvFile === 'function' && fs.existsSync(envFilePath)) { + process.loadEnvFile(envFilePath); + this.log.appendLine(`[Config] Loaded .env file: ${envFilePath}`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.log.appendLine(`[Config] Failed to load .env file: ${message}`); + } + } + + const config = resolveConfig({}, {workingDirectory, sourcesBefore: [new EnvSource()]}); this.config = config; if (!config.hasB2CInstanceConfig()) { diff --git a/packages/b2c-vs-extension/src/extension.ts b/packages/b2c-vs-extension/src/extension.ts index 1c26ff4a..4204e9da 100644 --- a/packages/b2c-vs-extension/src/extension.ts +++ b/packages/b2c-vs-extension/src/extension.ts @@ -151,7 +151,7 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu // before the first resolveConfig() call. Failures are non-fatal. await initializePlugins(); - const configProvider = new B2CExtensionConfig(log); + const configProvider = new B2CExtensionConfig(log, context.workspaceState); context.subscriptions.push(configProvider); const disposable = vscode.commands.registerCommand('b2c-dx.openUI', () => { @@ -189,7 +189,7 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu let targetUri: vscode.Uri; if (vscode.workspace.workspaceFolders?.length) { - const rootUri = vscode.workspace.workspaceFolders[0].uri; + const rootUri = vscode.Uri.file(configProvider.getWorkingDirectory()); const routesUri = vscode.Uri.joinPath(rootUri, 'routes'); const routesPath = routesUri.fsPath; const hasRoutesFolder = fs.existsSync(routesPath) && fs.statSync(routesPath).isDirectory(); @@ -783,7 +783,7 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu // --- Active instance status bar --- const dwJsonSource = new DwJsonSource(); - const getWorkingDirectory = () => vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); + const getWorkingDirectory = () => configProvider.getWorkingDirectory(); const instanceStatusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); instanceStatusBar.command = 'b2c-dx.instance.switch'; @@ -797,9 +797,13 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu const host = config.values.hostname ?? ''; const truncatedHost = host.length > 40 ? host.slice(0, 37) + '...' : host; const display = name || truncatedHost || 'unnamed'; - instanceStatusBar.text = `$(cloud) ${display}`; + const pinnedSuffix = configProvider.isProjectRootPinned() ? ' $(pinned)' : ''; + instanceStatusBar.text = `$(cloud) ${display}${pinnedSuffix}`; const tooltipLines = [`B2C Instance: ${name ?? 'unnamed'}`]; if (host) tooltipLines.push(`Host: ${host}`); + if (configProvider.isProjectRootPinned()) { + tooltipLines.push(`Project root: ${getWorkingDirectory()} (pinned)`); + } tooltipLines.push('Click to switch instance'); instanceStatusBar.tooltip = tooltipLines.join('\n'); instanceStatusBar.show(); @@ -897,6 +901,25 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu } }); + const setProjectRootDisposable = vscode.commands.registerCommand( + 'b2c-dx.setProjectRoot', + async (uri?: vscode.Uri) => { + if (!uri) return; + const folderPath = uri.fsPath; + await configProvider.setProjectRoot(folderPath); + vscode.window.showInformationMessage(`B2C DX: Project root set to ${path.basename(folderPath)}`); + }, + ); + + const resetProjectRootDisposable = vscode.commands.registerCommand('b2c-dx.resetProjectRoot', async () => { + if (!configProvider.isProjectRootPinned()) { + vscode.window.showInformationMessage('B2C DX: Project root is already using auto-detection.'); + return; + } + await configProvider.resetProjectRoot(); + vscode.window.showInformationMessage('B2C DX: Project root reset to auto-detect.'); + }); + const settings = vscode.workspace.getConfiguration('b2c-dx'); if (settings.get('features.webdavBrowser', true)) { @@ -931,6 +954,8 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu instanceConfigRegistration, inspectInstanceDisposable, switchInstanceDisposable, + setProjectRootDisposable, + resetProjectRootDisposable, configChangeListener, ); log.appendLine('B2C DX extension activated.');