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
+}