Symmetric with the existing VS Code branch — the env var was only wired into getVSCodeExtensionRoots(), so the plan's master verification (`LLM_SECURITY_IDE_ROOTS=... --intellij-only`) reported 0 discovered plugins. Adding the same fallback to discoverJetBrainsExtensions makes both families honor the CLI override and closes the gap.
375 lines
12 KiB
JavaScript
375 lines
12 KiB
JavaScript
// ide-extension-discovery.mjs — OS-aware discovery of installed VS Code / JetBrains extensions.
|
|
// Zero dependencies (Node.js builtins only).
|
|
//
|
|
// VS Code + forks (Cursor, Windsurf, VSCodium, code-server, Insiders, Remote-SSH):
|
|
// Parses extensions.json (per-dir manifest) + falls back to dir-name regex.
|
|
// JetBrains: stub (v1.1).
|
|
|
|
import { readFile, readdir, stat, lstat, access } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { homedir } from 'node:os';
|
|
import { existsSync } from 'node:fs';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OS path resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Return all candidate VS Code extension root directories for current OS.
|
|
* @returns {string[]}
|
|
*/
|
|
export function getVSCodeExtensionRoots() {
|
|
const home = homedir();
|
|
const roots = [
|
|
join(home, '.vscode', 'extensions'),
|
|
join(home, '.vscode-insiders', 'extensions'),
|
|
join(home, '.cursor', 'extensions'),
|
|
join(home, '.windsurf', 'extensions'),
|
|
join(home, '.vscode-oss', 'extensions'), // VSCodium
|
|
join(home, '.vscode-server', 'extensions'), // Remote-SSH
|
|
];
|
|
if (process.platform === 'linux') {
|
|
roots.push(join(home, '.local', 'share', 'code-server', 'extensions'));
|
|
}
|
|
return roots;
|
|
}
|
|
|
|
/**
|
|
* Return the JetBrains base directory (contains per-IDE-per-version subdirectories).
|
|
* Actual plugins live under <base>/<IDE><Edition><Version>/plugins/. v1.1 walks these.
|
|
* @returns {string|null}
|
|
*/
|
|
export function getJetBrainsBaseDir() {
|
|
const home = homedir();
|
|
let base;
|
|
if (process.platform === 'darwin') {
|
|
base = join(home, 'Library', 'Application Support', 'JetBrains');
|
|
} else if (process.platform === 'win32') {
|
|
base = join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'JetBrains');
|
|
} else {
|
|
base = join(home, '.local', 'share', 'JetBrains');
|
|
}
|
|
return existsSync(base) ? base : null;
|
|
}
|
|
|
|
/**
|
|
* Return the Android Studio base directory. Diverges from JetBrains on Linux:
|
|
* Android Studio uses ~/.config/Google (config path) rather than ~/.local/share.
|
|
* @returns {string|null}
|
|
*/
|
|
export function getAndroidStudioBaseDir() {
|
|
const home = homedir();
|
|
let base;
|
|
if (process.platform === 'darwin') {
|
|
base = join(home, 'Library', 'Application Support', 'Google');
|
|
} else if (process.platform === 'win32') {
|
|
base = join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'Google');
|
|
} else {
|
|
base = join(home, '.config', 'Google');
|
|
}
|
|
return existsSync(base) ? base : null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Known VS Code target-platform suffixes per
|
|
// https://code.visualstudio.com/api/working-with-extensions/publishing-extension#platformspecific-extensions
|
|
const PLATFORM_SUFFIXES = [
|
|
'win32-x64', 'win32-ia32', 'win32-arm64',
|
|
'linux-x64', 'linux-arm64', 'linux-armhf',
|
|
'darwin-x64', 'darwin-arm64',
|
|
'alpine-x64', 'alpine-arm64',
|
|
'web',
|
|
];
|
|
|
|
/**
|
|
* Parse directory name of form "publisher.name-version[-platform]".
|
|
* Strategy: strip a trailing known-platform suffix first, then match identifier + version.
|
|
* Returns { publisher, name, version, targetPlatform } or null.
|
|
* @param {string} dirName
|
|
*/
|
|
export function parseDirName(dirName) {
|
|
let trimmed = dirName;
|
|
let targetPlatform = null;
|
|
for (const plat of PLATFORM_SUFFIXES) {
|
|
const suffix = '-' + plat;
|
|
if (trimmed.toLowerCase().endsWith(suffix)) {
|
|
targetPlatform = plat;
|
|
trimmed = trimmed.slice(0, -suffix.length);
|
|
break;
|
|
}
|
|
}
|
|
const m = trimmed.match(/^(.+?)-(\d+\.\d+\.\d+(?:-[a-z0-9.]+)?)$/i);
|
|
if (!m) return null;
|
|
const idPart = m[1];
|
|
const version = m[2];
|
|
const dotIdx = idPart.indexOf('.');
|
|
if (dotIdx === -1) return null;
|
|
return {
|
|
publisher: idPart.slice(0, dotIdx),
|
|
name: idPart.slice(dotIdx + 1),
|
|
version,
|
|
targetPlatform,
|
|
};
|
|
}
|
|
|
|
async function readJson(filePath) {
|
|
try {
|
|
const raw = await readFile(filePath, 'utf8');
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function pathExists(p) {
|
|
try { await access(p); return true; } catch { return false; }
|
|
}
|
|
|
|
async function isSymlink(p) {
|
|
try {
|
|
const s = await lstat(p);
|
|
return s.isSymbolicLink();
|
|
} catch { return false; }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// VS Code discovery
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @typedef {object} ExtensionRecord
|
|
* @property {string} id
|
|
* @property {string} publisher
|
|
* @property {string} name
|
|
* @property {string} version
|
|
* @property {string} location
|
|
* @property {'vscode'|'jetbrains'} type
|
|
* @property {'gallery'|'vsix'|null} source
|
|
* @property {boolean} isBuiltin
|
|
* @property {number|null} installedTimestamp
|
|
* @property {string|null} targetPlatform
|
|
* @property {string|null} publisherDisplayName
|
|
* @property {boolean} signed
|
|
* @property {string} rootDir
|
|
*/
|
|
|
|
/**
|
|
* Discover VS Code extensions across all roots.
|
|
* @param {object} [options]
|
|
* @param {string[]} [options.rootsOverride] - Test injection: use these roots only.
|
|
* @param {boolean} [options.includeBuiltin=false]
|
|
* @param {boolean} [options.followSymlinks=false]
|
|
* @returns {Promise<{ extensions: ExtensionRecord[], warnings: string[], rootsScanned: string[] }>}
|
|
*/
|
|
export async function discoverVSCodeExtensions(options = {}) {
|
|
const warnings = [];
|
|
const extensions = [];
|
|
const rootsScanned = [];
|
|
|
|
let roots;
|
|
if (options.rootsOverride) {
|
|
roots = options.rootsOverride;
|
|
} else if (process.env.LLM_SECURITY_IDE_ROOTS) {
|
|
roots = process.env.LLM_SECURITY_IDE_ROOTS.split(':').filter(Boolean);
|
|
} else {
|
|
roots = getVSCodeExtensionRoots();
|
|
}
|
|
|
|
for (const root of roots) {
|
|
if (!await pathExists(root)) continue;
|
|
rootsScanned.push(root);
|
|
|
|
// Load per-root extensions.json (machine index) to get metadata.source, isBuiltin etc.
|
|
const indexPath = join(root, 'extensions.json');
|
|
const index = await readJson(indexPath);
|
|
const metaByRelLoc = new Map();
|
|
if (Array.isArray(index)) {
|
|
for (const entry of index) {
|
|
if (entry && entry.relativeLocation) {
|
|
metaByRelLoc.set(entry.relativeLocation, entry);
|
|
}
|
|
}
|
|
} else if (index !== null) {
|
|
warnings.push(`malformed extensions.json in ${root}`);
|
|
}
|
|
|
|
let entries;
|
|
try {
|
|
entries = await readdir(root, { withFileTypes: true });
|
|
} catch (err) {
|
|
warnings.push(`failed to read ${root}: ${err.message}`);
|
|
continue;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
// Skip dotfiles, extensions.json, .obsolete
|
|
if (entry.name.startsWith('.')) continue;
|
|
if (entry.name === 'extensions.json') continue;
|
|
|
|
// Check symlink handling
|
|
if (entry.isSymbolicLink()) {
|
|
if (!options.followSymlinks) continue;
|
|
} else if (!entry.isDirectory()) {
|
|
continue;
|
|
}
|
|
|
|
const extDir = join(root, entry.name);
|
|
const parsed = parseDirName(entry.name);
|
|
|
|
// Read package.json to get authoritative publisher + name
|
|
const pkgPath = join(extDir, 'package.json');
|
|
const pkg = await readJson(pkgPath);
|
|
let publisher = pkg?.publisher;
|
|
let name = pkg?.name;
|
|
let version = pkg?.version;
|
|
|
|
if (!publisher || !name) {
|
|
if (!parsed) {
|
|
warnings.push(`could not identify extension in ${extDir}`);
|
|
continue;
|
|
}
|
|
publisher = publisher || parsed.publisher;
|
|
name = name || parsed.name;
|
|
version = version || parsed.version;
|
|
}
|
|
|
|
if (!publisher || !name || !version) {
|
|
warnings.push(`incomplete identity for ${extDir}`);
|
|
continue;
|
|
}
|
|
|
|
const id = `${publisher}.${name}`.toLowerCase();
|
|
const indexEntry = metaByRelLoc.get(entry.name);
|
|
const meta = indexEntry?.metadata || {};
|
|
const isBuiltin = meta.isBuiltin === true;
|
|
|
|
if (isBuiltin && !options.includeBuiltin) continue;
|
|
|
|
const signed = await pathExists(join(extDir, '.signature.p7s'));
|
|
|
|
extensions.push({
|
|
id,
|
|
publisher: publisher.toLowerCase(),
|
|
name: name.toLowerCase(),
|
|
version,
|
|
location: extDir,
|
|
type: 'vscode',
|
|
source: meta.source || null,
|
|
isBuiltin,
|
|
installedTimestamp: typeof meta.installedTimestamp === 'number' ? meta.installedTimestamp : null,
|
|
targetPlatform: meta.targetPlatform || parsed?.targetPlatform || null,
|
|
publisherDisplayName: meta.publisherDisplayName || null,
|
|
signed,
|
|
rootDir: root,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { extensions, warnings, rootsScanned };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JetBrains discovery — stub (v1.1)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Matches JetBrains + Android Studio product directory names. Explicitly excludes
|
|
// Fleet (separate SDK, different plugin model) and Toolbox (launcher cache dir).
|
|
const JB_PRODUCT_DIR_RE = /^(IntelliJIdea|IdeaIC|PyCharm|PyCharmCE|WebStorm|GoLand|PhpStorm|RubyMine|CLion|DataGrip|Rider|RustRover|DataSpell|Aqua|AndroidStudio)([0-9]+\.[0-9]+(?:\.[0-9]+)?)?$/;
|
|
|
|
/**
|
|
* Discover JetBrains + Android Studio plugins from installed IDE directories.
|
|
*
|
|
* **`rootsOverride` semantics for JetBrains:** Each entry is a BASE directory —
|
|
* the equivalent of `getJetBrainsBaseDir()` output (the `JetBrains/` or
|
|
* `Google/` parent). The discovery walks `<base>/<ProductDir>/plugins/<plugin>/`.
|
|
* This differs from VS Code where `rootsOverride` entries point at the
|
|
* `extensions/` level directly. Rationale: JetBrains has many product dirs per
|
|
* base (IntelliJIdea, PyCharm, GoLand, AndroidStudio…), so base-level injection
|
|
* is the natural analogue for tests.
|
|
*
|
|
* Fleet and Toolbox are always excluded.
|
|
*
|
|
* @param {object} [options]
|
|
* @param {string[]} [options.rootsOverride] - Base directories (not `plugins/` level).
|
|
* @param {boolean} [options.followSymlinks=false]
|
|
* @returns {Promise<{ extensions: ExtensionRecord[], warnings: string[], rootsScanned: string[] }>}
|
|
*/
|
|
export async function discoverJetBrainsExtensions(options = {}) {
|
|
const warnings = [];
|
|
const extensions = [];
|
|
const rootsScanned = [];
|
|
|
|
let baseDirs;
|
|
if (Array.isArray(options.rootsOverride) && options.rootsOverride.length > 0) {
|
|
baseDirs = options.rootsOverride;
|
|
} else if (process.env.LLM_SECURITY_IDE_ROOTS) {
|
|
baseDirs = process.env.LLM_SECURITY_IDE_ROOTS.split(':').filter(Boolean);
|
|
} else {
|
|
baseDirs = [getJetBrainsBaseDir(), getAndroidStudioBaseDir()].filter(Boolean);
|
|
}
|
|
|
|
for (const base of baseDirs) {
|
|
if (!await pathExists(base)) continue;
|
|
rootsScanned.push(base);
|
|
|
|
let productEntries;
|
|
try {
|
|
productEntries = await readdir(base, { withFileTypes: true });
|
|
} catch (err) {
|
|
warnings.push(`failed to read ${base}: ${err.message}`);
|
|
continue;
|
|
}
|
|
|
|
for (const pe of productEntries) {
|
|
if (!pe.isDirectory() && !(pe.isSymbolicLink() && options.followSymlinks)) continue;
|
|
if (pe.name.startsWith('.')) continue;
|
|
if (!JB_PRODUCT_DIR_RE.test(pe.name)) continue; // excludes Fleet, Toolbox
|
|
|
|
const productDir = join(base, pe.name);
|
|
const pluginsDir = join(productDir, 'plugins');
|
|
if (!await pathExists(pluginsDir)) continue;
|
|
|
|
let pluginEntries;
|
|
try {
|
|
pluginEntries = await readdir(pluginsDir, { withFileTypes: true });
|
|
} catch (err) {
|
|
warnings.push(`failed to read ${pluginsDir}: ${err.message}`);
|
|
continue;
|
|
}
|
|
|
|
for (const plug of pluginEntries) {
|
|
if (plug.name.startsWith('.')) continue;
|
|
if (plug.name === 'disabled_plugins.txt') continue;
|
|
if (plug.isSymbolicLink() && !options.followSymlinks) continue;
|
|
if (!plug.isDirectory() && !plug.isSymbolicLink()) continue;
|
|
|
|
const pluginDir = join(pluginsDir, plug.name);
|
|
// Safety: skip anything rooted under Toolbox cache
|
|
if (pluginDir.includes(join('Caches', 'JetBrains', 'Toolbox'))) continue;
|
|
|
|
const id = plug.name.toLowerCase();
|
|
extensions.push({
|
|
id,
|
|
publisher: '',
|
|
name: plug.name,
|
|
version: '',
|
|
location: pluginDir,
|
|
type: 'jetbrains',
|
|
source: null,
|
|
isBuiltin: false,
|
|
installedTimestamp: null,
|
|
targetPlatform: null,
|
|
publisherDisplayName: null,
|
|
signed: false,
|
|
rootDir: base,
|
|
productDir: pe.name,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return { extensions, warnings, rootsScanned };
|
|
}
|