diff --git a/registry/coder/modules/jetbrains/README.md b/registry/coder/modules/jetbrains/README.md index cf97d127e..bcb18d533 100644 --- a/registry/coder/modules/jetbrains/README.md +++ b/registry/coder/modules/jetbrains/README.md @@ -136,6 +136,50 @@ module "jetbrains" { } ``` +### Pre-install Plugins + +Pre-install JetBrains plugins to ensure your team has the required tools ready when they open the IDE: + +```tf +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "1.3.0" + agent_id = coder_agent.main.id + folder = "/home/coder/project" + default = ["IU", "GO"] + + # Pre-install common plugins + plugins = [ + "org.jetbrains.plugins.github", # GitHub integration + "com.intellij.plugins.vscodekeymap", # VS Code keymap + "String Manipulation", # String manipulation tools + ] +} +``` + +> [!NOTE] +> Plugin IDs can be found on the [JetBrains Marketplace](https://plugins.jetbrains.com). Go to any plugin's page and look under "Additional Information" for the Plugin ID. + +#### How Plugin Pre-installation Works + +1. **Project-level configuration**: Creates `.idea/externalDependencies.xml` in your project folder. When you open the project in your IDE, you'll be prompted to install any missing plugins. + +2. **Background installation**: A background process monitors for IDE installation and automatically installs plugins when the IDE becomes available. + +#### Popular Plugin IDs + +| Plugin | ID | +| ---------------- | ----------------------------------- | +| GitHub | `org.jetbrains.plugins.github` | +| GitLab | `org.jetbrains.plugins.gitlab` | +| Docker | `Docker` | +| Kubernetes | `com.intellij.kubernetes` | +| VS Code Keymap | `com.intellij.plugins.vscodekeymap` | +| Rainbow Brackets | `izhangzhihao.rainbow.brackets` | +| SonarLint | `org.sonarlint.idea` | +| Copilot | `com.github.copilot` | + ### Accessing the IDE Metadata You can now reference the output `ide_metadata` as a map. diff --git a/registry/coder/modules/jetbrains/install_plugins.sh b/registry/coder/modules/jetbrains/install_plugins.sh new file mode 100644 index 000000000..daa2ffffb --- /dev/null +++ b/registry/coder/modules/jetbrains/install_plugins.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash + +# JetBrains Plugin Pre-installation Script +# This script handles plugin installation for JetBrains IDEs in Coder workspaces + +PLUGINS="${PLUGINS}" +PROJECT_DIR="${PROJECT_DIR}" + +BOLD='\033[0;1m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +RESET='\033[0m' + +IDE_CACHE_DIR="$HOME/.cache/JetBrains/RemoteDev/dist" +PLUGIN_INSTALL_LOG="/tmp/jetbrains-plugin-install.log" + +# Exit early if no plugins specified +if [ -z "$PLUGINS" ]; then + echo "No plugins specified, skipping plugin setup." + exit 0 +fi + +echo -e "$${BOLD}🔌 Setting up JetBrains plugins...$${RESET}" + +# Step 1: Create .idea/externalDependencies.xml +# This ensures users are prompted to install required plugins when opening the project +setup_external_dependencies() { + if [ ! -d "$PROJECT_DIR" ]; then + echo -e "$${YELLOW}⚠️ Project directory $PROJECT_DIR does not exist yet, skipping externalDependencies.xml$${RESET}" + return + fi + + mkdir -p "$PROJECT_DIR/.idea" + + echo -e "📦 Creating plugin requirements file..." + + cat > "$PROJECT_DIR/.idea/externalDependencies.xml" << 'XMLHEADER' + + + +XMLHEADER + + IFS=',' read -r -a PLUGIN_ARRAY <<< "$PLUGINS" + for plugin in "$${PLUGIN_ARRAY[@]}"; do + plugin=$(echo "$plugin" | xargs) # trim whitespace + if [ -n "$plugin" ]; then + echo " " >> "$PROJECT_DIR/.idea/externalDependencies.xml" + fi + done + + cat >> "$PROJECT_DIR/.idea/externalDependencies.xml" << 'XMLFOOTER' + + +XMLFOOTER + + echo -e "$${GREEN}✅ Created $PROJECT_DIR/.idea/externalDependencies.xml$${RESET}" + echo " When you open this project, JetBrains IDE will prompt you to install the required plugins." +} + +# Step 2: Background plugin installer +# Monitors for IDE installation and installs plugins automatically +install_plugins_background() { + echo -e "🔄 Starting background plugin installer..." + + # Run in background subshell + ( + exec > "$PLUGIN_INSTALL_LOG" 2>&1 + + MAX_WAIT=7200 # Wait up to 2 hours for IDE to be installed + INTERVAL=30 # Check every 30 seconds + WAITED=0 + + echo "[$(date)] Background plugin installer started" + echo "[$(date)] Watching for IDE installation in $IDE_CACHE_DIR" + echo "[$(date)] Plugins to install: $PLUGINS" + + while [ $WAITED -lt $MAX_WAIT ]; do + # Look for any installed IDE's remote-dev-server.sh + if [ -d "$IDE_CACHE_DIR" ]; then + IDE_SCRIPT=$(find "$IDE_CACHE_DIR" -name "remote-dev-server.sh" -type f 2> /dev/null | head -1) + + if [ -n "$IDE_SCRIPT" ] && [ -x "$IDE_SCRIPT" ]; then + echo "[$(date)] Found IDE at: $IDE_SCRIPT" + + # Get the IDE's bin directory + IDE_BIN_DIR=$(dirname "$IDE_SCRIPT") + + # Wait a moment for IDE to fully initialize + sleep 5 + + # Install each plugin + IFS=',' read -r -a PLUGIN_ARRAY <<< "$PLUGINS" + INSTALL_SUCCESS=true + + for plugin in "$${PLUGIN_ARRAY[@]}"; do + plugin=$(echo "$plugin" | xargs) # trim whitespace + if [ -n "$plugin" ]; then + echo "[$(date)] Installing plugin: $plugin" + + # Try using remote-dev-server.sh first + if "$IDE_SCRIPT" installPlugins "$PROJECT_DIR" "$plugin" 2>&1; then + echo "[$(date)] Successfully installed: $plugin" + else + echo "[$(date)] Failed to install via remote-dev-server.sh, trying alternative methods..." + + # Try finding IDE-specific launcher (idea.sh, pycharm.sh, etc.) + for launcher in "$IDE_BIN_DIR"/*.sh; do + if [[ "$launcher" != *"remote-dev"* ]] && [ -x "$launcher" ]; then + echo "[$(date)] Trying launcher: $launcher" + if "$launcher" installPlugins "$plugin" 2>&1; then + echo "[$(date)] Successfully installed via $launcher: $plugin" + break + fi + fi + done + fi + fi + done + + echo "[$(date)] Plugin installation process completed!" + exit 0 + fi + fi + + sleep $INTERVAL + WAITED=$((WAITED + INTERVAL)) + done + + echo "[$(date)] Timed out waiting for IDE installation after $MAX_WAIT seconds" + ) & + + local BG_PID=$! + echo -e "📋 Background installer running (PID: $BG_PID)" + echo -e " Log file: $PLUGIN_INSTALL_LOG" + + # Save PID for potential cleanup + echo "$BG_PID" > /tmp/jetbrains-plugin-installer.pid +} + +# Main execution +setup_external_dependencies +install_plugins_background + +echo -e "$${GREEN}🎉 JetBrains plugin setup complete!$${RESET}" +echo "" +echo "Plugins will be installed automatically when JetBrains IDE is detected." +echo "You can also install them manually via the IDE's plugin manager." diff --git a/registry/coder/modules/jetbrains/jetbrains.tftest.hcl b/registry/coder/modules/jetbrains/jetbrains.tftest.hcl index dba9551da..3c8020366 100644 --- a/registry/coder/modules/jetbrains/jetbrains.tftest.hcl +++ b/registry/coder/modules/jetbrains/jetbrains.tftest.hcl @@ -351,3 +351,135 @@ run "validate_output_schema" { error_message = "The ide_metadata output schema has changed. Please update the 'main.tf' and this test." } } + +# Plugin installation tests +run "no_plugin_script_when_plugins_empty" { + command = plan + + variables { + agent_id = "foo" + folder = "/home/coder" + default = ["GO"] + plugins = [] + } + + assert { + condition = length(resource.coder_script.jetbrains_plugins) == 0 + error_message = "Expected no coder_script when plugins list is empty" + } +} + +run "plugin_script_created_when_plugins_provided" { + command = plan + + variables { + agent_id = "foo" + folder = "/home/coder" + default = ["GO"] + plugins = ["org.jetbrains.plugins.github"] + } + + assert { + condition = length(resource.coder_script.jetbrains_plugins) == 1 + error_message = "Expected coder_script to be created when plugins are provided" + } +} + +run "plugin_script_has_correct_display_name" { + command = plan + + variables { + agent_id = "foo" + folder = "/home/coder" + default = ["GO"] + plugins = ["org.jetbrains.plugins.github"] + } + + assert { + condition = resource.coder_script.jetbrains_plugins[0].display_name == "JetBrains Plugins" + error_message = "Expected plugin script display_name to be 'JetBrains Plugins'" + } +} + +run "plugin_script_runs_on_start" { + command = plan + + variables { + agent_id = "foo" + folder = "/home/coder" + default = ["GO"] + plugins = ["org.jetbrains.plugins.github"] + } + + assert { + condition = resource.coder_script.jetbrains_plugins[0].run_on_start == true + error_message = "Expected plugin script to run on start" + } +} + +run "plugins_output_contains_correct_values" { + command = plan + + variables { + agent_id = "foo" + folder = "/home/coder" + default = ["GO"] + plugins = ["org.jetbrains.plugins.github", "Docker"] + } + + assert { + condition = length(output.plugins) == 2 + error_message = "Expected plugins output to contain 2 items" + } + + assert { + condition = contains(output.plugins, "org.jetbrains.plugins.github") + error_message = "Expected plugins output to contain 'org.jetbrains.plugins.github'" + } + + assert { + condition = contains(output.plugins, "Docker") + error_message = "Expected plugins output to contain 'Docker'" + } +} + +run "plugins_output_empty_when_not_provided" { + command = plan + + variables { + agent_id = "foo" + folder = "/home/coder" + default = ["GO"] + } + + assert { + condition = length(output.plugins) == 0 + error_message = "Expected plugins output to be empty when not provided" + } +} + +run "multiple_plugins_supported" { + command = plan + + variables { + agent_id = "foo" + folder = "/home/coder" + default = ["GO"] + plugins = [ + "org.jetbrains.plugins.github", + "com.intellij.plugins.vscodekeymap", + "Docker", + "izhangzhihao.rainbow.brackets" + ] + } + + assert { + condition = length(output.plugins) == 4 + error_message = "Expected plugins output to contain 4 items" + } + + assert { + condition = length(resource.coder_script.jetbrains_plugins) == 1 + error_message = "Expected exactly one coder_script for plugins" + } +} diff --git a/registry/coder/modules/jetbrains/main.test.ts b/registry/coder/modules/jetbrains/main.test.ts new file mode 100644 index 000000000..5165303f0 --- /dev/null +++ b/registry/coder/modules/jetbrains/main.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + readFileContainer, + runContainer, + execContainer, + removeContainer, + findResourceInstance, +} from "~test"; + +const BASH_IMAGE = "bash:latest"; + +describe("jetbrains-plugins", async () => { + await runTerraformInit(import.meta.dir); + + it("does not create script when plugins empty", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + folder: "/home/coder/project", + default: '["GO"]', + plugins: "[]", + }); + + const scripts = state.resources.filter((r) => r.type === "coder_script"); + expect(scripts.length).toBe(0); + }); + + it("creates script when plugins provided", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + folder: "/home/coder/project", + default: '["GO"]', + plugins: '["org.jetbrains.plugins.github"]', + }); + + const script = findResourceInstance(state, "coder_script"); + expect(script).toBeDefined(); + expect(script.script).toContain("Setting up JetBrains plugins"); + }); + + it("creates externalDependencies.xml with single plugin", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + folder: "/tmp/project", + default: '["GO"]', + plugins: '["org.jetbrains.plugins.github"]', + }); + + const id = await runContainer(BASH_IMAGE); + try { + await execContainer(id, ["mkdir", "-p", "/tmp/project"]); + + const script = findResourceInstance(state, "coder_script"); + await execContainer(id, ["bash", "-c", script.script]); + + const xmlContent = await readFileContainer( + id, + "/tmp/project/.idea/externalDependencies.xml", + ); + expect(xmlContent).toContain(''); + expect(xmlContent).toContain( + '', + ); + } finally { + await removeContainer(id); + } + }); + + it("creates externalDependencies.xml with multiple plugins", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + folder: "/tmp/project", + default: '["GO"]', + plugins: + '["org.jetbrains.plugins.github", "Docker", "com.intellij.kubernetes"]', + }); + + const id = await runContainer(BASH_IMAGE); + try { + await execContainer(id, ["mkdir", "-p", "/tmp/project"]); + + const script = findResourceInstance(state, "coder_script"); + await execContainer(id, ["bash", "-c", script.script]); + + const xmlContent = await readFileContainer( + id, + "/tmp/project/.idea/externalDependencies.xml", + ); + expect(xmlContent).toContain( + '', + ); + expect(xmlContent).toContain(''); + expect(xmlContent).toContain(''); + } finally { + await removeContainer(id); + } + }); + + it("handles missing project directory gracefully", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + folder: "/nonexistent/project", + default: '["GO"]', + plugins: '["org.jetbrains.plugins.github"]', + }); + + const id = await runContainer(BASH_IMAGE); + try { + const script = findResourceInstance(state, "coder_script"); + const result = await execContainer(id, ["bash", "-c", script.script]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("does not exist yet"); + } finally { + await removeContainer(id); + } + }); + + it("script exits early when no plugins specified", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + folder: "/tmp/project", + default: '["GO"]', + plugins: '["test-plugin"]', + }); + + const script = findResourceInstance(state, "coder_script"); + const modifiedScript = script.script.replace( + 'PLUGINS="test-plugin"', + 'PLUGINS=""', + ); + + const id = await runContainer(BASH_IMAGE); + try { + const result = await execContainer(id, ["bash", "-c", modifiedScript]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("No plugins specified"); + } finally { + await removeContainer(id); + } + }); + + it("starts background installer process", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + folder: "/tmp/project", + default: '["GO"]', + plugins: '["org.jetbrains.plugins.github"]', + }); + + const id = await runContainer(BASH_IMAGE); + try { + await execContainer(id, ["mkdir", "-p", "/tmp/project"]); + + const script = findResourceInstance(state, "coder_script"); + const result = await execContainer(id, ["bash", "-c", script.script]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Background installer running"); + expect(result.stdout).toContain("JetBrains plugin setup complete"); + + const pidFile = await readFileContainer( + id, + "/tmp/jetbrains-plugin-installer.pid", + ); + expect(pidFile.trim()).toMatch(/^\d+$/); + } finally { + await removeContainer(id); + } + }); +}); diff --git a/registry/coder/modules/jetbrains/main.tf b/registry/coder/modules/jetbrains/main.tf index 2fac060f1..7b3896f10 100644 --- a/registry/coder/modules/jetbrains/main.tf +++ b/registry/coder/modules/jetbrains/main.tf @@ -65,6 +65,22 @@ variable "tooltip" { default = "You need to install [JetBrains Toolbox App](https://www.jetbrains.com/toolbox-app/) to use this button." } +variable "plugins" { + type = list(string) + description = <<-EOT + List of JetBrains plugin IDs to pre-install. Plugin IDs can be found on the + JetBrains Marketplace (https://plugins.jetbrains.com) under "Additional Information". + Example: ["org.jetbrains.plugins.github", "com.intellij.plugins.vscodekeymap"] + EOT + default = [] + validation { + condition = alltrue([ + for plugin in var.plugins : can(regex("^[a-zA-Z0-9._-]+$", plugin)) + ]) + error_message = "Plugin IDs must contain only alphanumeric characters, dots, underscores, and hyphens." + } +} + variable "major_version" { type = string description = "The major version of the IDE. i.e. 2025.1" @@ -270,6 +286,18 @@ resource "coder_app" "jetbrains" { ]) } +resource "coder_script" "jetbrains_plugins" { + count = length(var.plugins) > 0 ? 1 : 0 + agent_id = var.agent_id + display_name = "JetBrains Plugins" + icon = "/icon/jetbrains-toolbox.svg" + script = templatefile("${path.module}/install_plugins.sh", { + PLUGINS = join(",", var.plugins) + PROJECT_DIR = var.folder + }) + run_on_start = true +} + output "ide_metadata" { description = "A map of the metadata for each selected JetBrains IDE." value = { @@ -278,3 +306,8 @@ output "ide_metadata" { for key, val in local.selected_ides : key => local.options_metadata[key] } } + +output "plugins" { + description = "List of plugin IDs configured for pre-installation." + value = var.plugins +}