// 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 //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 `//plugins//`. * 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 }; }