import * as fs from 'fs/promises'; import * as path from 'path'; import { existsSync } from 'fs'; /** * Manages lua-addons: discovery, versioning, copying, and cleanup. * All operations assume semver versioning (X.Y.Z format). * Addon folder names follow the pattern: `.` (e.g., dcs-types.0.0.5) */ interface LuaAddonInfo { name: string; sourceDir: string; version: string; } interface InstalledAddonInfo { name: string; version: string; path: string; } /** * Reads the extension's version from package.json * @param extensionRoot - Absolute path to the extension root directory * @returns The semantic version string (e.g., "0.0.5") * @throws Error if package.json cannot be read or version is not found */ export async function getExtensionVersion(extensionRoot: string): Promise { try { const packageJsonPath = path.join(extensionRoot, 'package.json'); const content = await fs.readFile(packageJsonPath, 'utf-8'); const packageJson = JSON.parse(content); const version = packageJson.version as string | undefined; if (!version) { throw new Error('Version field not found in package.json'); } return version; } catch (err) { throw new Error( `Failed to read extension version: ${err instanceof Error ? err.message : String(err)}` ); } } /** * Discovers all lua-addons in the bundled lua-addons directory * @param extensionPath - Absolute path to the extension installation directory * @returns Map of addon name to source directory path * @throws Error if lua-addons directory cannot be read */ export async function discoverLuaAddons(extensionPath: string): Promise> { const luaAddonsDir = path.join(extensionPath, 'lua-addons'); const addons = new Map(); try { // Check if lua-addons directory exists if (!existsSync(luaAddonsDir)) { return addons; // Empty map if no lua-addons } const entries = await fs.readdir(luaAddonsDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const addonName = entry.name; const addonPath = path.join(luaAddonsDir, addonName); addons.set(addonName, addonPath); } } return addons; } catch (err) { throw new Error( `Failed to discover lua-addons: ${err instanceof Error ? err.message : String(err)}` ); } } /** * Determines the workspace lua-addons directory path (.vscode/lua-addons) * @param workspaceRoot - Absolute path to the workspace root * @returns Absolute path to .vscode/lua-addons */ export function getWorkspaceLuaAddonsDir(workspaceRoot: string): string { return path.join(workspaceRoot, '.vscode', 'lua-addons'); } /** * Parses installed addon versions from the workspace lua-addons directory * Expects folder names in format: `.` (e.g., dcs-types.0.0.5) * @param workspaceRoot - Absolute path to the workspace root * @returns Map of addon name to version string; returns empty map if directory doesn't exist * @throws Error if directory cannot be read */ export async function getInstalledVersions( workspaceRoot: string ): Promise> { const luaAddonsDir = getWorkspaceLuaAddonsDir(workspaceRoot); const installed = new Map(); try { // Return empty map if directory doesn't exist yet if (!existsSync(luaAddonsDir)) { return installed; } const entries = await fs.readdir(luaAddonsDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const folderName = entry.name; const parsed = parseAddonFolderName(folderName); if (parsed) { installed.set(parsed.name, { name: parsed.name, version: parsed.version, path: path.join(luaAddonsDir, folderName) }); } } } return installed; } catch (err) { throw new Error( `Failed to read installed versions: ${err instanceof Error ? err.message : String(err)}` ); } } /** * Copies lua-addons from the extension bundle to the workspace with versioned folder names * @param extensionPath - Absolute path to the extension installation directory * @param workspaceRoot - Absolute path to the workspace root * @param extensionVersion - The current extension version string * @param addonsToCopy - Map of addon name to source directory (from discoverLuaAddons) * @returns Map of addon name to installed path in workspace * @throws Error if copy fails or directory creation fails */ export async function copyLuaAddons( extensionPath: string, workspaceRoot: string, extensionVersion: string, addonsToCopy: Map ): Promise> { const luaAddonsDir = getWorkspaceLuaAddonsDir(workspaceRoot); const results = new Map(); try { // Ensure .vscode directory exists const vscodeDir = path.join(workspaceRoot, '.vscode'); await ensureDirectoryExists(vscodeDir); // Ensure lua-addons directory exists await ensureDirectoryExists(luaAddonsDir); // Copy each addon for (const [addonName, sourceDir] of addonsToCopy) { const versionedFolderName = `${addonName}.${extensionVersion}`; const destDir = path.join(luaAddonsDir, versionedFolderName); // Remove destination if it already exists (shouldn't happen, but be safe) if (existsSync(destDir)) { await fs.rm(destDir, { recursive: true, force: true }); } // Copy the addon await copyDirectory(sourceDir, destDir); results.set(addonName, destDir); } return results; } catch (err) { throw new Error( `Failed to copy lua-addons: ${err instanceof Error ? err.message : String(err)}` ); } } /** * Deletes all old versions of a specific addon * @param workspaceRoot - Absolute path to the workspace root * @param addonName - Name of the addon (e.g., "dcs-types") * @param currentVersion - Version to keep (e.g., "0.0.5") * @throws Error if directory operations fail */ export async function deleteOldVersions( workspaceRoot: string, addonName: string, currentVersion: string ): Promise { const luaAddonsDir = getWorkspaceLuaAddonsDir(workspaceRoot); try { // Return silently if directory doesn't exist if (!existsSync(luaAddonsDir)) { return; } const entries = await fs.readdir(luaAddonsDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const parsed = parseAddonFolderName(entry.name); // Delete if it's an old version of this addon if (parsed && parsed.name === addonName && parsed.version !== currentVersion) { const oldPath = path.join(luaAddonsDir, entry.name); await fs.rm(oldPath, { recursive: true, force: true }); } } } } catch (err) { throw new Error( `Failed to delete old versions of ${addonName}: ${ err instanceof Error ? err.message : String(err) }` ); } } /** * Checks if an addon needs to be updated (version mismatch) * @param installedVersion - Currently installed version or undefined if not installed * @param currentExtensionVersion - Current extension version to compare against * @returns true if no version is installed or version doesn't match */ export function requiresUpdate( installedVersion: string | undefined, currentExtensionVersion: string ): boolean { return !installedVersion || installedVersion !== currentExtensionVersion; } /** * Deletes all versioned addon folders for a specific addon name * @param workspaceRoot - Absolute path to the workspace root * @param addonName - Name of the addon to clean up completely * @throws Error if directory operations fail */ export async function deleteAllVersionsOfAddon( workspaceRoot: string, addonName: string ): Promise { const luaAddonsDir = getWorkspaceLuaAddonsDir(workspaceRoot); try { // Return silently if directory doesn't exist if (!existsSync(luaAddonsDir)) { return; } const entries = await fs.readdir(luaAddonsDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const parsed = parseAddonFolderName(entry.name); // Delete all versions of this addon if (parsed && parsed.name === addonName) { const addonPath = path.join(luaAddonsDir, entry.name); await fs.rm(addonPath, { recursive: true, force: true }); } } } } catch (err) { throw new Error( `Failed to delete all versions of ${addonName}: ${ err instanceof Error ? err.message : String(err) }` ); } } /** * Gets the currently installed addon info for all addons * @param workspaceRoot - Absolute path to the workspace root * @returns Map of addon name to its latest installed version info */ export async function getInstalledAddons( workspaceRoot: string ): Promise> { return getInstalledVersions(workspaceRoot); } // ============================================================================ // Private Helper Functions // ============================================================================ /** * Parses an addon folder name in format `.` * @param folderName - The folder name to parse * @returns Object with name and version, or null if format doesn't match */ function parseAddonFolderName( folderName: string ): { name: string; version: string } | null { // Split only on the last dot to handle addon names with dots (unlikely, but safe) const lastDotIndex = folderName.lastIndexOf('.'); if (lastDotIndex === -1) { return null; } const name = folderName.substring(0, lastDotIndex); const version = folderName.substring(lastDotIndex + 1); // Validate semver format (basic check) if (!/^\d+\.\d+\.\d+/.test(version)) { return null; } return { name, version }; } /** * Ensures a directory exists, creating it if necessary * @param dirPath - Absolute path to the directory */ async function ensureDirectoryExists(dirPath: string): Promise { try { await fs.mkdir(dirPath, { recursive: true }); } catch (err) { // Ignore EEXIST errors if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { throw err; } } } /** * Recursively copies a directory and its contents * @param srcDir - Source directory path * @param destDir - Destination directory path */ async function copyDirectory(srcDir: string, destDir: string): Promise { await ensureDirectoryExists(destDir); const entries = await fs.readdir(srcDir, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(srcDir, entry.name); const destPath = path.join(destDir, entry.name); if (entry.isDirectory()) { await copyDirectory(srcPath, destPath); } else { await fs.copyFile(srcPath, destPath); } } }