diff --git a/docs/resources/(resources)/webstorm.mdx b/docs/resources/(resources)/webstorm.mdx new file mode 100644 index 0000000..873874b --- /dev/null +++ b/docs/resources/(resources)/webstorm.mdx @@ -0,0 +1,60 @@ +--- +title: webstorm +description: A reference page for the webstorm resource +--- + +The webstorm resource installs [JetBrains WebStorm](https://www.jetbrains.com/webstorm/), a JavaScript IDE. On macOS it is installed via Homebrew Cask (`brew install --cask webstorm`); on Linux via Snap (`snap install webstorm --classic`). + +## Parameters + +- **settingsZip** *(string, optional)* — Absolute path to a WebStorm settings ZIP file (exported via *File | Manage IDE Settings | Export Settings*) to import on first install. The archive is extracted directly into the WebStorm config directory, so all exported settings (keymaps, code styles, inspections, etc.) are applied before WebStorm is first launched. + +- **importSettings** *(boolean, optional, default: `true`)* — Controls whether the `settingsZip` is imported during `create`. Set to `false` to skip the import even when `settingsZip` is specified. This is a setting parameter and is not tracked as state, so it only has effect when the resource is first applied. + +- **plugins** *(string[], optional)* — JetBrains Marketplace plugin IDs to install (e.g. `"dev.blachut.svelte.lang"`, `"org.jetbrains.plugins.github"`). Plugin IDs can be found on the plugin's page in the Marketplace under *Additional Information*. Plugins are managed statefully: Codify adds missing plugins and removes plugins no longer in the list. + +- **jvmMaxHeapSize** *(string, optional)* — Maximum JVM heap allocated to WebStorm, e.g. `"2048m"` for 2 GB or `"4096m"` for 4 GB. Written to `webstorm.vmoptions` in the IDE config directory as `-Xmx`. + +- **jvmMinHeapSize** *(string, optional)* — Initial JVM heap allocated to WebStorm, e.g. `"512m"`. Written to `webstorm.vmoptions` as `-Xms`. Typically set to half the max heap size. + +## Example usage + +### Install WebStorm with plugins + +```json title="codify.jsonc" +[ + { + "type": "webstorm", + "plugins": [ + "dev.blachut.svelte.lang", + "org.jetbrains.plugins.github" + ] + } +] +``` + +### Install WebStorm, import previous settings, and increase heap + +```json title="codify.jsonc" +[ + { + "type": "webstorm", + "settingsZip": "/path/to/webstorm-settings.zip", + "importSettings": true, + "jvmMaxHeapSize": "4096m", + "jvmMinHeapSize": "1024m", + "plugins": [ + "dev.blachut.svelte.lang", + "org.jetbrains.plugins.github" + ] + } +] +``` + +## Notes + +- On macOS a CLI launcher symlink is created at `/usr/local/bin/webstorm` during install so that `webstorm` is available in terminal sessions. It is removed on destroy. +- Plugin IDs must be exact JetBrains Marketplace IDs. You can find them on the plugin's Marketplace page under *Additional Information → Plugin ID*. +- The `settingsZip` import only runs during `create` (first apply), not on subsequent applies. If you need to re-import, destroy and re-apply the resource. +- JVM options are written to `webstorm.vmoptions` in `~/Library/Application Support/JetBrains/WebStorm/` on macOS and `~/.config/JetBrains/WebStorm/` on Linux. If WebStorm has never been launched, Codify creates this directory and file automatically. +- On Linux, Snap must be available. Codify will attempt to install `snapd` via the system package manager if it is not found. diff --git a/package.json b/package.json index 9792028..364de1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "default", - "version": "1.3.0", + "version": "1.4.0-beta.5", "description": "Default plugin for Codify - provides 50+ declarative resources for managing development tools and system configuration across macOS and Linux", "main": "dist/index.js", "scripts": { diff --git a/src/index.ts b/src/index.ts index 5e29686..49c6b47 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,7 @@ import { TartVmResource } from './resources/tart/tart-vm.js'; import { TerraformResource } from './resources/terraform/terraform.js'; import { CursorResource } from './resources/cursor/cursor.js'; import { VscodeResource } from './resources/vscode/vscode.js'; +import { WebStormResource } from './resources/webstorm/webstorm.js'; import { XcodeToolsResource } from './resources/xcode-tools/xcode-tools.js'; import { YumResource } from './resources/yum/yum.js'; @@ -83,6 +84,7 @@ runPlugin(Plugin.create( new PgcliResource(), new CursorResource(), new VscodeResource(), + new WebStormResource(), new GitRepositoryResource(), new GitRepositoriesResource(), new AndroidStudioResource(), diff --git a/src/resources/claude-code/claude-code-project.ts b/src/resources/claude-code/claude-code-project.ts index 8bade65..14af636 100644 --- a/src/resources/claude-code/claude-code-project.ts +++ b/src/resources/claude-code/claude-code-project.ts @@ -141,7 +141,7 @@ export class ClaudeCodeProjectResource extends Resource // preventing the framework from re-planning a CREATE on every validation pass. const result: Partial = { ...parameters }; - if (parameters.claudeMd !== undefined) { + if (parameters.claudeMd != null) { if (isRemoteUrl(parameters.claudeMd)) { // For remote URLs, keep the URL as-is so the framework compares URL vs URL. // Change detection for remote content is done via hash on apply. diff --git a/src/resources/claude-code/claude-code.ts b/src/resources/claude-code/claude-code.ts index 286aad3..ea976bf 100644 --- a/src/resources/claude-code/claude-code.ts +++ b/src/resources/claude-code/claude-code.ts @@ -150,7 +150,7 @@ export class ClaudeCodeResource extends Resource { const result: Partial = {}; - if (parameters.globalClaudeMd !== undefined) { + if (parameters.globalClaudeMd != null) { if (isRemoteUrl(parameters.globalClaudeMd)) { result.globalClaudeMd = parameters.globalClaudeMd; } else { diff --git a/src/resources/cursor/cursor.ts b/src/resources/cursor/cursor.ts index f264d7a..1d20c24 100644 --- a/src/resources/cursor/cursor.ts +++ b/src/resources/cursor/cursor.ts @@ -136,9 +136,16 @@ export class CursorResource extends Resource { const directory = plan.currentConfig.directory ?? '/Applications'; await $.spawn(`rm -rf "${path.join(directory, CURSOR_APPLICATION_NAME)}"`); } else if (Utils.isLinux()) { - const directory = plan.currentConfig.directory ?? CURSOR_LOCAL_BIN; - await $.spawnSafe(`rm -f "${path.join(directory, 'cursor')}"`); - await FileUtils.removeLineFromShellRc(CURSOR_LOCAL_BIN_EXPORT); + const aptCheck = await $.spawnSafe('which apt-get'); + const dnfCheck = await $.spawnSafe('which dnf'); + const yumCheck = await $.spawnSafe('which yum'); + if (aptCheck.status === SpawnStatus.SUCCESS || dnfCheck.status === SpawnStatus.SUCCESS || yumCheck.status === SpawnStatus.SUCCESS) { + await Utils.uninstallViaPkgMgr('cursor'); + } else { + const directory = plan.currentConfig.directory ?? CURSOR_LOCAL_BIN; + await $.spawnSafe(`rm -f "${path.join(directory, 'cursor')}"`); + await FileUtils.removeLineFromShellRc(CURSOR_LOCAL_BIN_EXPORT); + } } } @@ -168,8 +175,36 @@ export class CursorResource extends Resource { private async installLinux(plan: CreatePlan): Promise { const $ = getPty(); + + const aptCheck = await $.spawnSafe('which apt-get'); + if (aptCheck.status === SpawnStatus.SUCCESS) { + await $.spawn( + 'bash -c "curl -fsSL https://downloads.cursor.com/keys/anysphere.asc | gpg --dearmor | tee /etc/apt/keyrings/cursor.gpg > /dev/null"', + { requiresRoot: true }, + ); + await $.spawn( + 'bash -c "echo \\"deb [arch=amd64,arm64 signed-by=/etc/apt/keyrings/cursor.gpg] https://downloads.cursor.com/aptrepo stable main\\" | tee /etc/apt/sources.list.d/cursor.list > /dev/null"', + { requiresRoot: true }, + ); + await Utils.installViaPkgMgr('cursor'); + return; + } + + const dnfCheck = await $.spawnSafe('which dnf'); + const yumCheck = await $.spawnSafe('which yum'); + if (dnfCheck.status === SpawnStatus.SUCCESS || yumCheck.status === SpawnStatus.SUCCESS) { + const pkgMgr = dnfCheck.status === SpawnStatus.SUCCESS ? 'dnf' : 'yum'; + await $.spawn( + `${pkgMgr} config-manager --add-repo https://downloads.cursor.com/yumrepo/cursor.repo`, + { requiresRoot: true }, + ); + await Utils.installViaPkgMgr('cursor'); + return; + } + + // Fallback: AppImage const isArm = await Utils.isArmArch(); - const downloadUrl = `https://downloader.cursor.sh/linux/appImage/${isArm ? 'arm64' : 'x64'}`; + const downloadUrl = `https://api2.cursor.sh/updates/download/golden/linux-${isArm ? 'arm64' : 'x64'}/cursor/latest`; const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cursor-')); const tmpAppImage = path.join(tmpDir, 'cursor.AppImage'); diff --git a/src/resources/cursor/extensions-parameter.ts b/src/resources/cursor/extensions-parameter.ts index dd73978..1772890 100644 --- a/src/resources/cursor/extensions-parameter.ts +++ b/src/resources/cursor/extensions-parameter.ts @@ -14,10 +14,23 @@ function getCursorBinary(directory?: string | null): string { 'Contents', 'Resources', 'app', 'bin', 'cursor', ); } - // On Linux, use the full path to the AppImage/binary so it works before PATH is sourced. + // On Linux, prefer the directory-scoped path (AppImage install), but fall back to + // the system PATH location (apt/dnf install puts it at /usr/bin/cursor). return path.join(directory ?? CURSOR_LOCAL_BIN, 'cursor'); } +async function resolveCursorBinary(directory?: string | null): Promise { + if (Utils.isMacOS()) return getCursorBinary(directory); + const candidate = getCursorBinary(directory); + const $ = getPty(); + const check = await $.spawnSafe(`test -x "${candidate}"`); + if (check.status === SpawnStatus.SUCCESS) return candidate; + // Fall back to whatever is on PATH (e.g. /usr/bin/cursor from apt install) + const which = await $.spawnSafe('which cursor'); + if (which.status === SpawnStatus.SUCCESS) return which.data.trim(); + return candidate; +} + export class ExtensionsParameter extends StatefulParameter { getSettings(): ArrayParameterSetting { return { @@ -30,7 +43,7 @@ export class ExtensionsParameter extends StatefulParameter): Promise { const $ = getPty(); - const cursor = getCursorBinary(config.directory); + const cursor = await resolveCursorBinary(config.directory); const result = await $.spawnSafe(`"${cursor}" --list-extensions`); if (result.status !== SpawnStatus.SUCCESS || result.data == null) { return null; @@ -40,9 +53,9 @@ export class ExtensionsParameter extends StatefulParameter): Promise { const $ = getPty(); - const cursor = getCursorBinary(plan.desiredConfig?.directory); + const cursor = await resolveCursorBinary(plan.desiredConfig?.directory); for (const ext of valueToAdd) { - await $.spawn(`"${cursor}" --install-extension ${ext} --force`, { interactive: true }); + await $.spawn(`"${cursor}" --install-extension ${ext}`, { interactive: true }); } } @@ -55,7 +68,7 @@ export class ExtensionsParameter extends StatefulParameter): Promise { const $ = getPty(); - const cursor = getCursorBinary(plan.desiredConfig?.directory ?? plan.currentConfig?.directory); + const cursor = await resolveCursorBinary(plan.desiredConfig?.directory ?? plan.currentConfig?.directory); for (const ext of valueToRemove) { await $.spawnSafe(`"${cursor}" --uninstall-extension ${ext}`); } diff --git a/src/resources/webstorm/completions/webstorm.plugins.ts b/src/resources/webstorm/completions/webstorm.plugins.ts new file mode 100644 index 0000000..3bc8194 --- /dev/null +++ b/src/resources/webstorm/completions/webstorm.plugins.ts @@ -0,0 +1,15 @@ +export default async function loadWebStormPlugins(): Promise { + const response = await fetch( + 'https://plugins.jetbrains.com/api/plugins?build=WS&orderBy=downloads&offset=0&limit=500', + { headers: { Accept: 'application/json' } } + ); + + if (!response.ok) { + return []; + } + + const data = await response.json() as Array<{ xmlId?: string }>; + return data + .map((p) => p.xmlId) + .filter((id): id is string => typeof id === 'string' && id.length > 0); +} diff --git a/src/resources/webstorm/plugins-parameter.ts b/src/resources/webstorm/plugins-parameter.ts new file mode 100644 index 0000000..168df7d --- /dev/null +++ b/src/resources/webstorm/plugins-parameter.ts @@ -0,0 +1,231 @@ +import { ArrayStatefulParameter, getPty, Plan, SpawnStatus, Utils } from '@codifycli/plugin-core'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { WebStormConfig } from './webstorm.js'; + +export const MACOS_APP_PATH = '/Applications/WebStorm.app'; +export const MACOS_BINARY = `${MACOS_APP_PATH}/Contents/MacOS/webstorm`; + +export function getWebStormBinary(): string { + return Utils.isMacOS() ? MACOS_BINARY : 'webstorm'; +} + +export async function findConfigDir(): Promise { + const parentDir = Utils.isMacOS() + ? path.join(os.homedir(), 'Library', 'Application Support', 'JetBrains') + : path.join(os.homedir(), '.config', 'JetBrains'); + + try { + const entries = await fs.readdir(parentDir); + const dirs = entries.filter((e) => e.startsWith('WebStorm')).sort(); + return dirs.length > 0 ? path.join(parentDir, dirs[dirs.length - 1]) : null; + } catch { + return null; + } +} + +export async function getOrCreateConfigDir(): Promise { + const existing = await findConfigDir(); + if (existing) return existing; + + const version = await getWebStormMajorMinorVersion(); + if (!version) return null; + + const parentDir = Utils.isMacOS() + ? path.join(os.homedir(), 'Library', 'Application Support', 'JetBrains') + : path.join(os.homedir(), '.config', 'JetBrains'); + + const configDir = path.join(parentDir, `WebStorm${version}`); + await fs.mkdir(configDir, { recursive: true }); + return configDir; +} + +async function getWebStormMajorMinorVersion(): Promise { + const $ = getPty(); + + if (Utils.isMacOS()) { + const result = await $.spawnSafe( + `/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "${MACOS_APP_PATH}/Contents/Info.plist"` + ); + if (result.status !== SpawnStatus.SUCCESS) return null; + const parts = result.data.trim().split('.'); + return parts.length >= 2 ? `${parts[0]}.${parts[1]}` : null; + } + + if (Utils.isLinux()) { + const result = await $.spawnSafe('snap list webstorm'); + if (result.status !== SpawnStatus.SUCCESS) return null; + const lines = result.data.split('\n'); + const line = lines.find((l) => l.startsWith('webstorm')); + const match = line?.match(/(\d+\.\d+)/); + return match ? match[1] : null; + } + + return null; +} + +function getPluginsDir(configDir: string): string { + // macOS: plugins are in a `plugins/` subdir of the config dir + // Linux: plugins are in ~/.local/share/JetBrains/WebStorm/ directly + if (Utils.isMacOS()) { + return path.join(configDir, 'plugins'); + } + // For Linux, derive from config dir path by swapping .config → .local/share + const version = path.basename(configDir); + return path.join(os.homedir(), '.local', 'share', 'JetBrains', version); +} + +function getBundledPluginsDir(): string | null { + if (Utils.isMacOS()) return path.join(MACOS_APP_PATH, 'Contents', 'plugins'); + if (Utils.isLinux()) return '/snap/webstorm/current/plugins'; + return null; +} + +async function readPluginIdFromDir(pluginDir: string): Promise { + // Try plain META-INF/plugin.xml first (user-installed plugins unzipped as directories) + const xmlPath = path.join(pluginDir, 'META-INF', 'plugin.xml'); + try { + const content = await fs.readFile(xmlPath, 'utf8'); + const match = content.match(/([^<]+)<\/id>/); + if (match) return match[1].trim(); + } catch { /* fall through to JAR search */ } + + // Bundled plugins ship as directories containing JAR files in lib/. + // Requires unzip; skip silently if not available. + const $ = getPty(); + const unzipCheck = await $.spawnSafe('which unzip'); + if (unzipCheck.status !== SpawnStatus.SUCCESS) return null; + + const tryReadIdFromJars = async (subdir: string): Promise => { + const libDir = subdir === '.' ? pluginDir : path.join(pluginDir, subdir); + const entries = await fs.readdir(libDir); + const pluginName = path.basename(pluginDir).toLowerCase(); + // Try the JAR named after the plugin dir first — it's almost always the main one + const jars = entries + .filter((e) => e.endsWith('.jar')) + .sort((a, b) => { + const aMatch = a.toLowerCase().startsWith(pluginName) ? -1 : 0; + const bMatch = b.toLowerCase().startsWith(pluginName) ? -1 : 0; + return aMatch - bMatch; + }); + for (const entry of jars) { + const result = await $.spawnSafe(`unzip -p "${path.join(libDir, entry)}" META-INF/plugin.xml`); + if (result.status !== SpawnStatus.SUCCESS || !result.data) continue; + const match = result.data.match(/([^<]+)<\/id>/); + if (match) return match[1].trim(); + } + throw new Error('no id'); + }; + + const results = await Promise.allSettled(['lib', '.'].map(tryReadIdFromJars)); + for (const r of results) { + if (r.status === 'fulfilled') return r.value; + } + + return null; +} + +export class PluginsParameter extends ArrayStatefulParameter { + override getSettings() { + return { + type: 'array' as const, + isElementEqual: (desired: string, current: string) => + desired.toLowerCase() === current.toLowerCase(), + }; + } + + override async refresh(desired: string[] | null): Promise { + const readIdsFromDir = async (dir: string): Promise => { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const results = await Promise.all( + entries + .filter((e) => e.isDirectory()) + .map((e) => readPluginIdFromDir(path.join(dir, e.name))) + ); + return results.filter((id): id is string => id != null); + }; + + const [configDir, bundledDir] = await Promise.all([ + findConfigDir(), + Promise.resolve(getBundledPluginsDir()), + ]); + + if (!configDir && !bundledDir) return null; + + const userIds = configDir + ? await readIdsFromDir(getPluginsDir(configDir)).catch(() => [] as string[]) + : []; + + // Only check the bundled dir for desired plugins not found in the user dir, + // to avoid flooding refresh with all default-installed bundled plugins. + if (bundledDir && desired) { + const missing = desired.filter((d) => !userIds.some((u) => u.toLowerCase() === d.toLowerCase())); + if (missing.length > 0) { + const bundledEntries = await fs.readdir(bundledDir, { withFileTypes: true }).catch(() => []); + const bundledIds = await Promise.all( + bundledEntries + .filter((e) => e.isDirectory()) + .map((e) => readPluginIdFromDir(path.join(bundledDir, e.name))) + ); + for (const id of bundledIds) { + if (id && missing.some((m) => m.toLowerCase() === id.toLowerCase())) { + userIds.push(id); + } + } + } + } + + return userIds; + } + + async addItem(item: string, _plan: Plan): Promise { + // If the plugin is already present in the bundled plugins dir, skip installation. + // On Linux the snap binary fails headlessly with XDG_RUNTIME_DIR errors, so + // we must not call it for plugins that are already bundled. + const bundledDir = getBundledPluginsDir(); + if (bundledDir) { + try { + const entries = await fs.readdir(bundledDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const id = await readPluginIdFromDir(path.join(bundledDir, entry.name)); + if (id?.toLowerCase() === item.toLowerCase()) return; + } + } catch { /* bundled dir inaccessible, fall through to install */ } + } + + const $ = getPty(); + const binary = getWebStormBinary(); + try { + await $.spawn(`"${binary}" installPlugins ${item}`, { interactive: true }); + } catch (e: unknown) { + const msg = (e instanceof Error ? e.message : String(e)).toLowerCase(); + if (msg.includes('already installed')) return; + if (msg.includes('one instance') || msg.includes('already running') || msg.includes('already open')) { + throw new Error('WebStorm is currently open. Webstorm only allows one instance open at a time. Please close it and re-run.'); + } + throw e; + } + } + + async removeItem(item: string, _plan: Plan): Promise { + const configDir = await findConfigDir(); + if (!configDir) return; + + const pluginsDir = getPluginsDir(configDir); + try { + const entries = await fs.readdir(pluginsDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const id = await readPluginIdFromDir(path.join(pluginsDir, entry.name)); + if (id?.toLowerCase() === item.toLowerCase()) { + await fs.rm(path.join(pluginsDir, entry.name), { recursive: true, force: true }); + return; + } + } + } catch { /* plugin dir doesn't exist, nothing to remove */ } + } +} diff --git a/src/resources/webstorm/webstorm.ts b/src/resources/webstorm/webstorm.ts new file mode 100644 index 0000000..3ce2ce9 --- /dev/null +++ b/src/resources/webstorm/webstorm.ts @@ -0,0 +1,297 @@ +import { + CreatePlan, + DestroyPlan, + ExampleConfig, + ModifyPlan, + ParameterChange, + Resource, + ResourceSettings, + SpawnStatus, + Utils, + getPty, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { MACOS_APP_PATH, MACOS_BINARY, PluginsParameter, findConfigDir, getOrCreateConfigDir } from './plugins-parameter.js'; + +const schema = z + .object({ + settingsZip: z + .string() + .optional() + .describe('Absolute path to a WebStorm settings ZIP file to import on first install.'), + importSettings: z + .boolean() + .optional() + .describe( + 'Whether to import the settings from settingsZip during create. ' + + 'Defaults to true. Set to false to skip the import even when settingsZip is provided.' + ), + plugins: z + .array(z.string()) + .optional() + .describe( + 'JetBrains Marketplace plugin IDs to install ' + + '(e.g. "dev.blachut.svelte.lang", "org.jetbrains.plugins.github"). ' + + 'Plugin IDs can be found on the plugin page under Additional Information.' + ), + jvmMaxHeapSize: z + .string() + .optional() + .describe('Maximum JVM heap size for WebStorm, e.g. "2048m" for 2 GB. Defaults to the IDE default (~2 GB).'), + jvmMinHeapSize: z + .string() + .optional() + .describe('Initial JVM heap size for WebStorm, e.g. "512m". Defaults to the IDE default.'), + }) + .meta({ $comment: 'https://codifycli.com/docs/resources/webstorm' }) + .describe('Install and configure JetBrains WebStorm IDE with plugins and JVM settings.'); + +export type WebStormConfig = z.infer; + +const defaultConfig: Partial = { + plugins: [], +}; + +const exampleBasic: ExampleConfig = { + title: 'WebStorm with Svelte and GitHub plugins', + description: + 'Install WebStorm and add the Svelte and GitHub integration plugins for a modern front-end workflow.', + configs: [ + { + type: 'webstorm', + plugins: ['dev.blachut.svelte.lang', 'org.jetbrains.plugins.github'], + }, + ], +}; + +const exampleAdvanced: ExampleConfig = { + title: 'WebStorm with tuned JVM and imported settings', + description: + 'Install WebStorm, import previous settings from a ZIP, and increase the heap to 4 GB for large projects.', + configs: [ + { + type: 'webstorm', + settingsZip: '/path/to/webstorm-settings.zip', + importSettings: true, + jvmMaxHeapSize: '4096m', + jvmMinHeapSize: '1024m', + plugins: ['dev.blachut.svelte.lang', 'org.jetbrains.plugins.github'], + }, + ], +}; + +export class WebStormResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'webstorm', + operatingSystems: [OS.Darwin, OS.Linux], + schema, + defaultConfig, + exampleConfigs: { + example1: exampleBasic, + example2: exampleAdvanced, + }, + parameterSettings: { + settingsZip: { type: 'string', setting: true }, + importSettings: { type: 'boolean', setting: true }, + plugins: { type: 'stateful', definition: new PluginsParameter(), order: 1 }, + jvmMaxHeapSize: { type: 'string', canModify: true }, + jvmMinHeapSize: { type: 'string', canModify: true }, + }, + }; + } + + override async refresh(parameters: Partial): Promise | null> { + const installed = await this.isInstalled(); + if (!installed) return null; + + const result: Partial = {}; + + const configDir = await findConfigDir(); + if (configDir) { + const vmOptions = await readVmOptions(configDir); + if (parameters.jvmMaxHeapSize != null) result.jvmMaxHeapSize = vmOptions.maxHeap; + if (parameters.jvmMinHeapSize != null) result.jvmMinHeapSize = vmOptions.minHeap; + } + + return result; + } + + override async create(plan: CreatePlan): Promise { + if (Utils.isMacOS()) { + await this.installMacOS(); + } else { + await this.installLinux(); + } + + const { settingsZip, importSettings = true, jvmMaxHeapSize, jvmMinHeapSize } = plan.desiredConfig; + + if (settingsZip && importSettings) { + await this.importSettingsZip(settingsZip); + } + + if (jvmMaxHeapSize != null || jvmMinHeapSize != null) { + const configDir = await getOrCreateConfigDir(); + if (configDir) { + await writeVmOptions(configDir, jvmMaxHeapSize, jvmMinHeapSize); + } + } + } + + override async modify(pc: ParameterChange, plan: ModifyPlan): Promise { + if (pc.name !== 'jvmMaxHeapSize' && pc.name !== 'jvmMinHeapSize') return; + + const configDir = await getOrCreateConfigDir(); + if (!configDir) return; + + const { jvmMaxHeapSize, jvmMinHeapSize } = plan.desiredConfig; + + if (jvmMaxHeapSize == null && jvmMinHeapSize == null) { + await removeVmOptions(configDir); + } else { + await writeVmOptions(configDir, jvmMaxHeapSize, jvmMinHeapSize); + } + } + + override async destroy(plan: DestroyPlan): Promise { + const { jvmMaxHeapSize, jvmMinHeapSize } = plan.currentConfig; + + if (jvmMaxHeapSize != null || jvmMinHeapSize != null) { + const configDir = await findConfigDir(); + if (configDir) await removeVmOptions(configDir); + } + + if (Utils.isMacOS()) { + await this.uninstallMacOS(); + } else { + await this.uninstallLinux(); + } + } + + // ── macOS ──────────────────────────────────────────────────────────────────── + + private async installMacOS(): Promise { + const $ = getPty(); + await $.spawn('brew install --cask webstorm', { + interactive: true, + env: { HOMEBREW_NO_AUTO_UPDATE: '1' }, + }); + // Create a CLI launcher symlink so `webstorm` works from the terminal + await $.spawnSafe( + `ln -sf "${MACOS_BINARY}" /usr/local/bin/webstorm`, + { requiresRoot: true } + ); + } + + private async uninstallMacOS(): Promise { + const $ = getPty(); + await $.spawnSafe('brew uninstall --cask webstorm', { + env: { HOMEBREW_NO_AUTO_UPDATE: '1' }, + }); + await $.spawnSafe('rm -f /usr/local/bin/webstorm', { requiresRoot: true }); + } + + // ── Linux ──────────────────────────────────────────────────────────────────── + + private async installLinux(): Promise { + const $ = getPty(); + const snapCheck = await $.spawnSafe('which snap'); + if (snapCheck.status !== SpawnStatus.SUCCESS) { + await Utils.installViaPkgMgr('snapd'); + } + await $.spawn('snap install webstorm --classic', { + interactive: true, + requiresRoot: true, + }); + // unzip is needed to read plugin IDs from bundled JAR files + await Utils.installViaPkgMgr('unzip'); + } + + private async uninstallLinux(): Promise { + const $ = getPty(); + await $.spawnSafe('snap remove webstorm', { requiresRoot: true }); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private async isInstalled(): Promise { + if (Utils.isMacOS()) { + try { + await fs.access(path.join(MACOS_APP_PATH, 'Contents', 'MacOS', 'webstorm')); + return true; + } catch { + return false; + } + } + + const $ = getPty(); + const result = await $.spawnSafe('which webstorm'); + return result.status === SpawnStatus.SUCCESS; + } + + private async importSettingsZip(settingsZip: string): Promise { + const $ = getPty(); + + const unzipCheck = await $.spawnSafe('which unzip'); + if (unzipCheck.status !== SpawnStatus.SUCCESS) { + await Utils.installViaPkgMgr('unzip'); + } + + const configDir = await getOrCreateConfigDir(); + if (!configDir) { + throw new Error('Cannot determine WebStorm config directory for settings import.'); + } + + await fs.mkdir(configDir, { recursive: true }); + await $.spawn(`unzip -o "${settingsZip}" -d "${configDir}"`, { interactive: true }); + } +} + +// ── vmoptions file helpers ──────────────────────────────────────────────────── + +async function readVmOptions(configDir: string): Promise<{ maxHeap?: string; minHeap?: string }> { + try { + const content = await fs.readFile(path.join(configDir, 'webstorm.vmoptions'), 'utf8'); + const lines = content.split('\n'); + const maxHeap = lines.find((l) => l.startsWith('-Xmx'))?.slice('-Xmx'.length).trim(); + const minHeap = lines.find((l) => l.startsWith('-Xms'))?.slice('-Xms'.length).trim(); + return { maxHeap, minHeap }; + } catch { + return {}; + } +} + +async function writeVmOptions(configDir: string, maxHeap?: string, minHeap?: string): Promise { + const optionsPath = path.join(configDir, 'webstorm.vmoptions'); + let lines: string[] = []; + + try { + lines = (await fs.readFile(optionsPath, 'utf8')).split('\n'); + } catch { /* file doesn't exist yet */ } + + lines = lines.filter((l) => !l.startsWith('-Xmx') && !l.startsWith('-Xms')); + if (maxHeap) lines.push(`-Xmx${maxHeap}`); + if (minHeap) lines.push(`-Xms${minHeap}`); + + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile(optionsPath, lines.join('\n').trim() + '\n'); +} + +async function removeVmOptions(configDir: string): Promise { + const optionsPath = path.join(configDir, 'webstorm.vmoptions'); + try { + const lines = (await fs.readFile(optionsPath, 'utf8')) + .split('\n') + .filter((l) => !l.startsWith('-Xmx') && !l.startsWith('-Xms')); + const content = lines.join('\n').trim(); + if (content) { + await fs.writeFile(optionsPath, content + '\n'); + } else { + await fs.rm(optionsPath, { force: true }); + } + } catch { /* nothing to remove */ } +} diff --git a/test/claude-code/claude-code.test.ts b/test/claude-code/claude-code.test.ts index 4ce5f4c..4219f61 100644 --- a/test/claude-code/claude-code.test.ts +++ b/test/claude-code/claude-code.test.ts @@ -17,14 +17,11 @@ describe('claude-code resource integration tests', async () => { pluginPath, [{ type: 'claude-code' }], { + skipUninstall: true, validateApply: async () => { const exists = await fs.access(claudeBin).then(() => true).catch(() => false); expect(exists).toBe(true); }, - validateDestroy: async () => { - const exists = await fs.access(claudeBin).then(() => true).catch(() => false); - expect(exists).toBe(false); - }, }, ); }); @@ -44,6 +41,7 @@ describe('claude-code resource integration tests', async () => { pluginPath, [{ type: 'claude-code', settings: initialSettings }], { + skipUninstall: true, validateApply: async () => { const content = await fs.readFile(CLAUDE_SETTINGS_PATH, 'utf8'); const parsed = JSON.parse(content); @@ -79,6 +77,7 @@ describe('claude-code resource integration tests', async () => { pluginPath, [{ type: 'claude-code', globalClaudeMd: initialContent }], { + skipUninstall: true, validateApply: async () => { const content = await fs.readFile(CLAUDE_MD_PATH, 'utf8'); expect(content).toBe(initialContent); diff --git a/test/cursor/cursor.test.ts b/test/cursor/cursor.test.ts index 40699fa..bcff770 100644 --- a/test/cursor/cursor.test.ts +++ b/test/cursor/cursor.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; import { PluginTester, testSpawn } from '@codifycli/plugin-test'; import * as path from 'node:path'; import fs from 'node:fs/promises'; @@ -9,9 +9,11 @@ describe('Cursor integration tests', async () => { const pluginPath = path.resolve('./src/index.ts'); // On macOS the cursor binary is inside the app bundle and not on PATH until a new shell is opened. - const cursorBin = Utils.isMacOS() + // On Linux the binary location depends on install method; resolve lazily after install. + const getCursorBin = async () => Utils.isMacOS() ? '/Applications/Cursor.app/Contents/Resources/app/bin/cursor' - : path.join(os.homedir(), '.local', 'bin', 'cursor'); + : (await testSpawn('which cursor').then((r) => r.data?.trim()).catch(() => null)) + ?? path.join(os.homedir(), '.local', 'bin', 'cursor'); const settingsFile = Utils.isMacOS() ? path.join(os.homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'settings.json') @@ -19,17 +21,23 @@ describe('Cursor integration tests', async () => { const mcpFile = path.join(os.homedir(), '.cursor', 'mcp.json'); + beforeAll(async () => { + await fs.rm(path.join(os.homedir(), '.cursor'), { recursive: true, force: true }); + }) + it('Can install cursor', { timeout: 300000 }, async () => { await PluginTester.fullTest(pluginPath, [{ type: 'cursor', }], { + skipUninstall: true, validateApply: async () => { if (Utils.isMacOS()) { const lstat = await fs.lstat('/Applications/Cursor.app'); expect(lstat.isDirectory()).to.be.true; } else { - const lstat = await fs.lstat(path.join(os.homedir(), '.local', 'bin', 'cursor')); - expect(lstat.isFile()).to.be.true; + const bin = await getCursorBin(); + const lstat = await fs.lstat(bin); + expect(lstat.isFile() || lstat.isSymbolicLink()).to.be.true; } }, validateDestroy: async () => { @@ -45,8 +53,9 @@ describe('Cursor integration tests', async () => { type: 'cursor', extensions: ['ms-python.python'], }], { + skipUninstall: true, validateApply: async () => { - const { data } = await testSpawn(`"${cursorBin}" --list-extensions`); + const { data } = await testSpawn(`"${await getCursorBin()}" --list-extensions`); expect(data?.toLowerCase()).to.include('ms-python.python'); }, testModify: { @@ -55,13 +64,13 @@ describe('Cursor integration tests', async () => { extensions: ['ms-python.python', 'eamodio.gitlens'], }], validateModify: async () => { - const { data } = await testSpawn(`"${cursorBin}" --list-extensions`); + const { data } = await testSpawn(`"${await getCursorBin()}" --list-extensions`); expect(data?.toLowerCase()).to.include('ms-python.python'); expect(data?.toLowerCase()).to.include('eamodio.gitlens'); }, }, validateDestroy: async () => { - const { data } = await testSpawn(`"${cursorBin}" --list-extensions`); + const { data } = await testSpawn(`"${await getCursorBin()}" --list-extensions`); expect(data?.toLowerCase()).not.to.include('eamodio.gitlens'); }, }); @@ -72,6 +81,7 @@ describe('Cursor integration tests', async () => { type: 'cursor', settings: { 'editor.fontSize': 14, 'editor.formatOnSave': true }, }], { + skipUninstall: true, validateApply: async () => { const { data } = await testSpawn(`cat "${settingsFile}"`); const content = JSON.parse(data!); @@ -92,7 +102,7 @@ describe('Cursor integration tests', async () => { }); }); - it('Can manage MCP servers', { timeout: 120000 }, async () => { + it('Can manage MCP servers', { timeout: 300000 }, async () => { await PluginTester.fullTest(pluginPath, [{ type: 'cursor', mcpServers: { diff --git a/test/webstorm/webstorm.test.ts b/test/webstorm/webstorm.test.ts new file mode 100644 index 0000000..41a645d --- /dev/null +++ b/test/webstorm/webstorm.test.ts @@ -0,0 +1,126 @@ +import { Utils } from '@codifycli/plugin-core'; +import { PluginTester, testSpawn } from '@codifycli/plugin-test'; +import { expect, describe, it, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +describe('WebStorm integration tests', async () => { + const pluginPath = path.resolve('./src/index.ts'); + + let xdgLine: string | null = null; + + beforeAll(async () => { + if (!Utils.isLinux()) return; + + // Wait for unattended-upgrades to release the dpkg lock before running any apt installs. + await testSpawn('systemctl stop unattended-upgrades || true', { requiresRoot: true }); + await testSpawn('flock /var/lib/dpkg/lock-frontend true', { requiresRoot: true }); + + const uid = process.getuid!(); + const xdgDir = `/tmp/xdg-runtime-${uid}`; + await fs.mkdir(xdgDir, { recursive: true }); + await fs.chmod(xdgDir, 0o700); + process.env.XDG_RUNTIME_DIR = xdgDir; + + const bashrc = path.join(os.homedir(), '.bashrc'); + const line = `export XDG_RUNTIME_DIR=${xdgDir}`; + const contents = await fs.readFile(bashrc, 'utf8').catch(() => ''); + if (!contents.includes(line)) { + await fs.appendFile(bashrc, `\n${line}\n`); + xdgLine = line; + } + }); + + afterAll(async () => { + if (!xdgLine) return; + + const bashrc = path.join(os.homedir(), '.bashrc'); + const contents = await fs.readFile(bashrc, 'utf8').catch(() => ''); + await fs.writeFile(bashrc, contents.replace(`\n${xdgLine}\n`, '')); + }); + + it('Can install WebStorm', { timeout: 600_000 }, async () => { + await PluginTester.fullTest(pluginPath, [{ type: 'webstorm' }], { + validateApply: async () => { + if (Utils.isMacOS()) { + const stat = await fs.lstat('/Applications/WebStorm.app'); + expect(stat.isDirectory()).to.be.true; + } else { + const { data } = await testSpawn('which webstorm'); + expect(data?.trim()).to.include('webstorm'); + } + }, + validateDestroy: async () => { + if (Utils.isMacOS()) { + const exists = await fs.access('/Applications/WebStorm.app').then(() => true).catch(() => false); + expect(exists).to.be.false; + } else { + const { data } = await testSpawn('which webstorm'); + expect(data?.trim() ?? '').not.to.include('webstorm'); + } + }, + }); + }); + + it('Can manage JVM heap size', { timeout: 600_000 }, async () => { + const configParent = Utils.isMacOS() + ? path.join(os.homedir(), 'Library', 'Application Support', 'JetBrains') + : path.join(os.homedir(), '.config', 'JetBrains'); + + const findVmOptions = async (): Promise => { + try { + const entries = await fs.readdir(configParent); + const dir = entries.filter((e) => e.startsWith('WebStorm')).sort().pop(); + if (!dir) return null; + return path.join(configParent, dir, 'webstorm.vmoptions'); + } catch { + return null; + } + }; + + await PluginTester.fullTest(pluginPath, [{ + type: 'webstorm', + jvmMaxHeapSize: '2048m', + jvmMinHeapSize: '512m', + }], { + validateApply: async () => { + const vmOptionsPath = await findVmOptions(); + expect(vmOptionsPath).to.not.be.null; + const { data } = await testSpawn(`cat "${vmOptionsPath}"`); + expect(data).to.include('-Xmx2048m'); + expect(data).to.include('-Xms512m'); + }, + testModify: { + modifiedConfigs: [{ + type: 'webstorm', + jvmMaxHeapSize: '4096m', + jvmMinHeapSize: '1024m', + }], + validateModify: async () => { + const vmOptionsPath = await findVmOptions(); + expect(vmOptionsPath).to.not.be.null; + const { data } = await testSpawn(`cat "${vmOptionsPath}"`); + expect(data).to.include('-Xmx4096m'); + expect(data).to.include('-Xms1024m'); + }, + }, + validateDestroy: async () => { + const vmOptionsPath = await findVmOptions(); + if (!vmOptionsPath) return; + try { + const content = await fs.readFile(vmOptionsPath, 'utf8'); + expect(content).not.to.include('-Xmx'); + expect(content).not.to.include('-Xms'); + } catch { /* file removed, that's fine */ } + }, + }); + }); + + it('Can install plugins', { timeout: 600_000 }, async () => { + await PluginTester.fullTest(pluginPath, [{ + type: 'webstorm', + plugins: ['izhangzhihao.rainbow.brackets'], + }]) + }) +});