Skip to content

Commit e326678

Browse files
committed
Merge remote-tracking branch 'origin/feature/generic-plugins-hooks' into feature/vsc-ext-native-improvements
# Conflicts: # packages/b2c-vs-extension/src/extension.ts
2 parents 224efbd + 15ff8ca commit e326678

19 files changed

Lines changed: 1487 additions & 130 deletions

.changeset/sdk-plugin-module.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@salesforce/b2c-tooling-sdk': minor
3+
---
4+
5+
Add `@salesforce/b2c-tooling-sdk/plugins` module for discovering and loading b2c-cli plugins outside of oclif. Enables the VS Code extension and other non-CLI consumers to use installed plugins (keychain managers, config sources, middleware) without depending on `@oclif/core`.

packages/b2c-tooling-sdk/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,17 @@
266266
"default": "./dist/cjs/scaffold/index.js"
267267
}
268268
},
269+
"./plugins": {
270+
"development": "./src/plugins/index.ts",
271+
"import": {
272+
"types": "./dist/esm/plugins/index.d.ts",
273+
"default": "./dist/esm/plugins/index.js"
274+
},
275+
"require": {
276+
"types": "./dist/cjs/plugins/index.d.ts",
277+
"default": "./dist/cjs/plugins/index.js"
278+
}
279+
},
269280
"./test-utils": {
270281
"development": "./src/test-utils/index.ts",
271282
"import": {

packages/b2c-tooling-sdk/src/cli/config.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -255,19 +255,6 @@ export function loadConfig(
255255
sourcesAfter: pluginSources.after,
256256
});
257257

258-
// Log source summary
259-
for (const source of resolved.sources) {
260-
logger.trace(
261-
{
262-
source: source.name,
263-
location: source.location,
264-
fields: source.fields,
265-
fieldsIgnored: source.fieldsIgnored,
266-
},
267-
`[${source.name}] Contributed fields`,
268-
);
269-
}
270-
271258
// Log warnings (at warn level so users can see configuration issues)
272259
for (const warning of resolved.warnings) {
273260
logger.warn({warning}, `[Config] ${warning.message}`);

packages/b2c-tooling-sdk/src/config/resolver.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
*/
1414
import type {AuthCredentials} from '../auth/types.js';
1515
import type {B2CInstance} from '../instance/index.js';
16+
import {getLogger} from '../logging/logger.js';
1617
import {mergeConfigsWithProtection, getPopulatedFields, createInstanceFromConfig} from './mapping.js';
1718
import {DwJsonSource, MobifySource, PackageJsonSource} from './sources/index.js';
1819
import type {
@@ -221,6 +222,17 @@ export class ConfigResolver {
221222
fieldsIgnored: fieldsIgnored.length > 0 ? fieldsIgnored : undefined,
222223
});
223224

225+
const logger = getLogger();
226+
logger.trace(
227+
{
228+
source: source.name,
229+
location,
230+
fields,
231+
fieldsIgnored: fieldsIgnored.length > 0 ? fieldsIgnored : undefined,
232+
},
233+
`[${source.name}] Contributed fields`,
234+
);
235+
224236
// Enrich options with accumulated config values for subsequent sources.
225237
// Only set if not already provided via CLI options.
226238
if (!enrichedOptions.accountManagerHost && baseConfig.accountManagerHost) {
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
import * as fs from 'node:fs';
7+
import * as path from 'node:path';
8+
import * as os from 'node:os';
9+
import type {Logger} from '../logging/types.js';
10+
11+
/**
12+
* Hook names that the plugin system supports.
13+
*/
14+
const SUPPORTED_HOOKS = ['b2c:config-sources', 'b2c:http-middleware', 'b2c:auth-middleware'] as const;
15+
16+
export type SupportedHookName = (typeof SUPPORTED_HOOKS)[number];
17+
18+
/**
19+
* A discovered plugin with its hook file paths.
20+
*/
21+
export interface DiscoveredPlugin {
22+
/** Plugin package name */
23+
name: string;
24+
/** Absolute path to the plugin's package directory */
25+
packageDir: string;
26+
/** Map of hook name to relative file path(s) within the plugin package */
27+
hooks: Partial<Record<SupportedHookName, string[]>>;
28+
}
29+
30+
/**
31+
* Options for plugin discovery.
32+
*/
33+
export interface PluginDiscoveryOptions {
34+
/** Override the oclif data directory (for testing) */
35+
dataDir?: string;
36+
/** Override the dirname used to resolve the data directory (default: 'b2c') */
37+
dirname?: string;
38+
/** Logger for warnings */
39+
logger?: Logger;
40+
}
41+
42+
/**
43+
* Resolves the oclif data directory for the CLI.
44+
*
45+
* Cross-platform: uses `$XDG_DATA_HOME/<dirname>` or `~/.local/share/<dirname>`
46+
* on POSIX; `$LOCALAPPDATA\<dirname>` on Windows.
47+
*/
48+
export function resolveOclifDataDir(dirname = 'b2c'): string {
49+
if (process.platform === 'win32') {
50+
const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
51+
return path.join(localAppData, dirname);
52+
}
53+
54+
const xdgDataHome = process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share');
55+
return path.join(xdgDataHome, dirname);
56+
}
57+
58+
/**
59+
* Reads a JSON file, returning undefined on any error.
60+
*/
61+
function readJsonSafe(filePath: string): unknown {
62+
try {
63+
const content = fs.readFileSync(filePath, 'utf-8');
64+
return JSON.parse(content);
65+
} catch {
66+
return undefined;
67+
}
68+
}
69+
70+
/**
71+
* Normalizes a hook value (string or string[]) to a string array.
72+
*/
73+
function normalizeHookPaths(value: unknown): string[] {
74+
if (typeof value === 'string') return [value];
75+
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string');
76+
return [];
77+
}
78+
79+
/**
80+
* Extracts the plugin name from an oclif plugins entry.
81+
*
82+
* Oclif stores user-installed plugins as objects: `{name, type, url}`,
83+
* while linked/dev plugins may appear as plain strings.
84+
*/
85+
function resolvePluginName(entry: unknown): string | undefined {
86+
if (typeof entry === 'string') return entry;
87+
if (typeof entry === 'object' && entry !== null && 'name' in entry) {
88+
const name = (entry as {name: unknown}).name;
89+
if (typeof name === 'string') return name;
90+
}
91+
return undefined;
92+
}
93+
94+
/**
95+
* Discovers installed b2c-cli plugins by reading the oclif data directory.
96+
*
97+
* Reads `<dataDir>/package.json` -> `oclif.plugins` array -> each plugin's
98+
* `package.json` -> `oclif.hooks` -> returns `DiscoveredPlugin[]`.
99+
*
100+
* Only hooks matching `b2c:config-sources`, `b2c:http-middleware`, and
101+
* `b2c:auth-middleware` are included.
102+
*/
103+
export function discoverPlugins(options: PluginDiscoveryOptions = {}): DiscoveredPlugin[] {
104+
const {logger} = options;
105+
const dataDir = options.dataDir ?? resolveOclifDataDir(options.dirname);
106+
const plugins: DiscoveredPlugin[] = [];
107+
108+
// Read the root package.json in the data directory
109+
const rootPkgPath = path.join(dataDir, 'package.json');
110+
const rootPkg = readJsonSafe(rootPkgPath) as {oclif?: {plugins?: unknown[]}} | undefined;
111+
if (!rootPkg?.oclif?.plugins?.length) {
112+
return plugins;
113+
}
114+
115+
const nodeModulesDir = path.join(dataDir, 'node_modules');
116+
117+
for (const pluginEntry of rootPkg.oclif.plugins) {
118+
const pluginName = resolvePluginName(pluginEntry);
119+
if (!pluginName) continue;
120+
121+
try {
122+
const pluginDir = path.join(nodeModulesDir, ...pluginName.split('/'));
123+
const pluginPkgPath = path.join(pluginDir, 'package.json');
124+
const pluginPkg = readJsonSafe(pluginPkgPath) as {oclif?: {hooks?: Record<string, unknown>}} | undefined;
125+
126+
if (!pluginPkg?.oclif?.hooks) continue;
127+
128+
const hookEntries = pluginPkg.oclif.hooks;
129+
const discoveredHooks: Partial<Record<SupportedHookName, string[]>> = {};
130+
let hasHooks = false;
131+
132+
for (const hookName of SUPPORTED_HOOKS) {
133+
if (hookName in hookEntries) {
134+
const paths = normalizeHookPaths(hookEntries[hookName]);
135+
if (paths.length > 0) {
136+
discoveredHooks[hookName] = paths;
137+
hasHooks = true;
138+
}
139+
}
140+
}
141+
142+
if (hasHooks) {
143+
plugins.push({
144+
name: pluginName,
145+
packageDir: pluginDir,
146+
hooks: discoveredHooks,
147+
});
148+
}
149+
} catch (err) {
150+
const message = err instanceof Error ? err.message : String(err);
151+
logger?.warn(`Failed to discover plugin ${pluginName}: ${message}`);
152+
}
153+
}
154+
155+
return plugins;
156+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
/**
7+
* Plugin discovery and loading for non-oclif consumers.
8+
*
9+
* This module enables VS Code extensions, MCP servers, and other non-CLI consumers
10+
* to load b2c-cli plugins installed via `b2c plugins:install`. It discovers plugins
11+
* from the oclif data directory and invokes their hooks without requiring `@oclif/core`.
12+
*
13+
* ## Quick Start
14+
*
15+
* ```typescript
16+
* import { B2CPluginManager } from '@salesforce/b2c-tooling-sdk/plugins';
17+
* import { resolveConfig } from '@salesforce/b2c-tooling-sdk/config';
18+
*
19+
* const manager = new B2CPluginManager();
20+
* await manager.initialize();
21+
* manager.applyMiddleware();
22+
*
23+
* const { sourcesBefore, sourcesAfter } = manager.getConfigSources();
24+
* const config = resolveConfig({}, { sourcesBefore, sourcesAfter });
25+
* ```
26+
*
27+
* @module plugins
28+
*/
29+
30+
export {B2CPluginManager, type PluginHookOptions} from './manager.js';
31+
export {
32+
discoverPlugins,
33+
resolveOclifDataDir,
34+
type DiscoveredPlugin,
35+
type PluginDiscoveryOptions,
36+
type SupportedHookName,
37+
} from './discovery.js';
38+
export {createHookContext, invokeHook, type HookContext, type HookContextOptions} from './loader.js';
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
import type {Logger} from '../logging/types.js';
7+
8+
/**
9+
* Minimal hook context that shims the `this` context oclif provides to hooks.
10+
*
11+
* Provides `debug()`, `log()`, `warn()`, `error()`, and a stub `config` object
12+
* so that existing hook implementations work without `@oclif/core`.
13+
*/
14+
export interface HookContext {
15+
debug(...args: unknown[]): void;
16+
log(...args: unknown[]): void;
17+
warn(...args: unknown[]): void;
18+
error(...args: unknown[]): void;
19+
config: Record<string, unknown>;
20+
}
21+
22+
export interface HookContextOptions {
23+
/** Logger to route debug/log/warn/error through */
24+
logger?: Logger;
25+
/** Extra properties to include on the stub config object */
26+
config?: Record<string, unknown>;
27+
}
28+
29+
/**
30+
* Creates a minimal hook context matching what oclif provides to hooks.
31+
*/
32+
export function createHookContext(options: HookContextOptions = {}): HookContext {
33+
const {logger} = options;
34+
35+
return {
36+
debug(...args: unknown[]) {
37+
logger?.debug(args.map(String).join(' '));
38+
},
39+
log(...args: unknown[]) {
40+
logger?.info(args.map(String).join(' '));
41+
},
42+
warn(...args: unknown[]) {
43+
logger?.warn(args.map(String).join(' '));
44+
},
45+
error(...args: unknown[]) {
46+
logger?.error(args.map(String).join(' '));
47+
},
48+
config: options.config ?? {},
49+
};
50+
}
51+
52+
/**
53+
* Dynamic import that survives esbuild CJS bundling.
54+
*
55+
* esbuild transforms `import()` to `require()` in CJS output, which cannot
56+
* load ESM plugins. Using `new Function` preserves the native dynamic import.
57+
*/
58+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
59+
const dynamicImport = new Function('specifier', 'return import(specifier)') as (
60+
specifier: string,
61+
) => Promise<Record<string, unknown>>;
62+
63+
/**
64+
* Dynamically imports a hook file and invokes its default export.
65+
*
66+
* @param hookFilePath - Absolute path to the hook JS file
67+
* @param context - Hook context (`this` inside the hook)
68+
* @param hookOptions - Options passed as the first argument to the hook function
69+
* @param logger - Optional logger for warnings on failure
70+
* @returns The hook function's return value, or `undefined` on error
71+
*/
72+
export async function invokeHook<TResult>(
73+
hookFilePath: string,
74+
context: HookContext,
75+
hookOptions: Record<string, unknown>,
76+
logger?: Logger,
77+
): Promise<TResult | undefined> {
78+
try {
79+
const mod = await dynamicImport(hookFilePath);
80+
const hookFn = (mod.default ?? mod) as (...args: unknown[]) => Promise<TResult>;
81+
82+
if (typeof hookFn !== 'function') {
83+
logger?.warn(`Hook file ${hookFilePath} does not export a function`);
84+
return undefined;
85+
}
86+
87+
return await hookFn.call(context, hookOptions);
88+
} catch (err) {
89+
const message = err instanceof Error ? err.message : String(err);
90+
logger?.warn(`Failed to invoke hook ${hookFilePath}: ${message}`);
91+
return undefined;
92+
}
93+
}

0 commit comments

Comments
 (0)