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/src/index.ts b/src/index.ts index 076647c..083ae0c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,6 +51,7 @@ import { TartResource } from './resources/tart/tart.js'; import { TartVmResource } from './resources/tart/tart-vm.js'; import { TerraformResource } from './resources/terraform/terraform.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'; @@ -79,6 +80,7 @@ runPlugin(Plugin.create( new GoenvResource(), new PgcliResource(), new VscodeResource(), + new WebStormResource(), new GitRepositoryResource(), new GitRepositoriesResource(), new AndroidStudioResource(), 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..b5113f0 --- /dev/null +++ b/src/resources/webstorm/plugins-parameter.ts @@ -0,0 +1,145 @@ +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); +} + +async function readPluginIdFromDir(pluginDir: string): Promise { + const xmlPath = path.join(pluginDir, 'META-INF', 'plugin.xml'); + try { + const content = await fs.readFile(xmlPath, 'utf8'); + const match = content.match(/([^<]+)<\/id>/); + return match ? match[1].trim() : null; + } catch { + 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 configDir = await findConfigDir(); + if (!configDir) return null; + + const pluginsDir = getPluginsDir(configDir); + try { + const entries = await fs.readdir(pluginsDir, { withFileTypes: true }); + const ids: string[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const id = await readPluginIdFromDir(path.join(pluginsDir, entry.name)); + if (id) ids.push(id); + } + + return ids; + } catch { + return []; + } + } + + async addItem(item: string, _plan: Plan): Promise { + const $ = getPty(); + const binary = getWebStormBinary(); + await $.spawn(`"${binary}" installPlugins ${item}`, { interactive: true }); + } + + 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..d8a9f42 --- /dev/null +++ b/src/resources/webstorm/webstorm.ts @@ -0,0 +1,295 @@ +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', default: true, setting: true }, + plugins: { type: 'stateful', definition: new PluginsParameter(), order: 1 }, + jvmMaxHeapSize: { type: 'string' }, + jvmMinHeapSize: { type: 'string' }, + }, + }; + } + + 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, + }); + } + + 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/webstorm/webstorm.test.ts b/test/webstorm/webstorm.test.ts new file mode 100644 index 0000000..49bdd6d --- /dev/null +++ b/test/webstorm/webstorm.test.ts @@ -0,0 +1,115 @@ +import { Utils } from '@codifycli/plugin-core'; +import { PluginTester, testSpawn } from '@codifycli/plugin-test'; +import { expect, describe, it } 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'); + + const webstormBinary = Utils.isMacOS() + ? '/Applications/WebStorm.app/Contents/MacOS/webstorm' + : 'webstorm'; + + 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: ['org.jetbrains.plugins.github'], + }], { + validateApply: async () => { + const configParent = Utils.isMacOS() + ? path.join(os.homedir(), 'Library', 'Application Support', 'JetBrains') + : path.join(os.homedir(), '.config', 'JetBrains'); + + const entries = await fs.readdir(configParent); + const dir = entries.filter((e) => e.startsWith('WebStorm')).sort().pop(); + expect(dir).to.not.be.undefined; + + const pluginsDir = Utils.isMacOS() + ? path.join(configParent, dir!, 'plugins') + : path.join(os.homedir(), '.local', 'share', 'JetBrains', dir!); + + const pluginDirs = await fs.readdir(pluginsDir); + expect(pluginDirs.length).to.be.greaterThan(0); + }, + }); + }); +});