diff --git a/.icons/mux.svg b/.icons/mux.svg index 95b56bb0..a6ce26f1 100644 --- a/.icons/mux.svg +++ b/.icons/mux.svg @@ -1,47 +1,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + \ No newline at end of file diff --git a/registry/Excellencedev/templates/hetzner-linux/hetzner_server_types.json‎ b/registry/Excellencedev/templates/hetzner-linux/hetzner_server_types.json‎ deleted file mode 100644 index 6be0938a..00000000 --- a/registry/Excellencedev/templates/hetzner-linux/hetzner_server_types.json‎ +++ /dev/null @@ -1,27 +0,0 @@ -{ - "type_meta": { - "cx22": { "cores": 2, "memory_gb": 4, "disk_gb": 40 }, - "cx32": { "cores": 4, "memory_gb": 8, "disk_gb": 80 }, - "cx42": { "cores": 8, "memory_gb": 16, "disk_gb": 160 }, - "cx52": { "cores": 16, "memory_gb": 32, "disk_gb": 320 }, - "cpx11": { "cores": 2, "memory_gb": 2, "disk_gb": 40 }, - "cpx21": { "cores": 3, "memory_gb": 4, "disk_gb": 80 }, - "cpx31": { "cores": 4, "memory_gb": 8, "disk_gb": 160 }, - "cpx41": { "cores": 8, "memory_gb": 16, "disk_gb": 240 }, - "cpx51": { "cores": 16, "memory_gb": 32, "disk_gb": 360 }, - "ccx13": { "cores": 2, "memory_gb": 8, "disk_gb": 80 }, - "ccx23": { "cores": 4, "memory_gb": 16, "disk_gb": 160 }, - "ccx33": { "cores": 8, "memory_gb": 32, "disk_gb": 240 }, - "ccx43": { "cores": 16, "memory_gb": 64, "disk_gb": 360 }, - "ccx53": { "cores": 32, "memory_gb": 128, "disk_gb": 600 }, - "ccx63": { "cores": 48, "memory_gb": 192, "disk_gb": 960 } - }, - "availability": { - "fsn1": ["cpx11", "cpx21", "cpx31", "cpx41", "cpx51", "ccx13", "ccx23", "ccx33"], - "ash": ["cpx11", "cpx21", "cpx31", "cpx41", "cpx51", "ccx13", "ccx23", "ccx33"], - "hel1": ["cx22", "cpx11", "cpx21", "cpx31", "cpx41", "cpx51", "ccx13", "ccx23", "ccx33"], - "hil": ["cpx11", "cpx21", "cpx31", "cpx41", "ccx13", "ccx23", "ccx33"], - "nbg1": ["cx22", "cx32", "cx42", "cx52", "cpx11", "cpx21", "cpx31", "cpx41", "cpx51", "ccx13", "ccx23", "ccx33"], - "sin": ["cpx11", "cpx21", "cpx31", "cpx41", "cpx51", "ccx13", "ccx23", "ccx33"] - } -} diff --git a/registry/Excellencedev/templates/hetzner-linux/main.tf b/registry/Excellencedev/templates/hetzner-linux/main.tf index 4b71e456..03e01a10 100644 --- a/registry/Excellencedev/templates/hetzner-linux/main.tf +++ b/registry/Excellencedev/templates/hetzner-linux/main.tf @@ -6,6 +6,10 @@ terraform { coder = { source = "coder/coder" } + http = { + source = "hashicorp/http" + version = "~> 3.0" + } } } @@ -17,6 +21,24 @@ provider "hcloud" { token = var.hcloud_token } +data "http" "hcloud_locations" { + url = "https://api.hetzner.cloud/v1/locations" + + request_headers = { + Authorization = "Bearer ${var.hcloud_token}" + Accept = "application/json" + } +} + +data "http" "hcloud_server_types" { + url = "https://api.hetzner.cloud/v1/server_types" + + request_headers = { + Authorization = "Bearer ${var.hcloud_token}" + Accept = "application/json" + } +} + # Available locations: https://docs.hetzner.com/cloud/general/locations/ data "coder_parameter" "hcloud_location" { name = "hcloud_location" @@ -24,29 +46,18 @@ data "coder_parameter" "hcloud_location" { description = "Select the Hetzner Cloud location for your workspace." type = "string" default = "fsn1" - option { - name = "DE Falkenstein" - value = "fsn1" - } - option { - name = "US Ashburn, VA" - value = "ash" - } - option { - name = "US Hillsboro, OR" - value = "hil" - } - option { - name = "SG Singapore" - value = "sin" - } - option { - name = "DE Nuremberg" - value = "nbg1" - } - option { - name = "FI Helsinki" - value = "hel1" + + dynamic "option" { + for_each = local.hcloud_locations + content { + name = format( + "%s (%s, %s)", + upper(option.value.name), + option.value.city, + option.value.country + ) + value = option.value.name + } } } @@ -109,17 +120,47 @@ resource "hcloud_volume_attachment" "home_volume_attachment" { locals { username = lower(data.coder_workspace_owner.me.name) - # Data source: local JSON file under the module directory - # Check API for latest server types & availability: https://docs.hetzner.cloud/reference/cloud#server-types - hcloud_server_types_data = jsondecode(file("${path.module}/hetzner_server_types.json")) - hcloud_server_type_meta = local.hcloud_server_types_data.type_meta - hcloud_server_types_by_location = local.hcloud_server_types_data.availability + # -------------------- + # Locations + # -------------------- + hcloud_locations = [ + for loc in jsondecode(data.http.hcloud_locations.response_body).locations : { + name = loc.name + city = loc.city + country = loc.country + } + ] + + # -------------------- + # Server Types + # -------------------- + hcloud_server_types = { + for st in jsondecode(data.http.hcloud_server_types.response_body).server_types : + st.name => { + cores = st.cores + memory_gb = st.memory + disk_gb = st.disk + locations = [for l in st.locations : l.name] + deprecated = st.deprecated + } + if st.deprecated == false + } hcloud_server_type_options_for_selected_location = [ - for type_name in lookup(local.hcloud_server_types_by_location, data.coder_parameter.hcloud_location.value, []) : { - name = format("%s (%d vCPU, %dGB RAM, %dGB)", upper(type_name), local.hcloud_server_type_meta[type_name].cores, local.hcloud_server_type_meta[type_name].memory_gb, local.hcloud_server_type_meta[type_name].disk_gb) - value = type_name + for name, meta in local.hcloud_server_types : { + name = format( + "%s (%d vCPU, %dGB RAM, %dGB)", + upper(name), + meta.cores, + meta.memory_gb, + meta.disk_gb + ) + value = name } + if contains( + meta.locations, + data.coder_parameter.hcloud_location.value + ) ] } @@ -180,4 +221,4 @@ module "code-server" { agent_id = coder_agent.main.id order = 1 -} \ No newline at end of file +} diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 0256e029..9a9c062f 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.2.8" + version = "4.2.9" agent_id = coder_agent.main.id workdir = "/home/coder/project" claude_api_key = "xxxx-xxxxx-xxxx" @@ -45,7 +45,7 @@ This example shows how to configure the Claude Code module to run the agent behi ```tf module "claude-code" { source = "dev.registry.coder.com/coder/claude-code/coder" - version = "4.2.8" + version = "4.2.9" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_boundary = true @@ -72,7 +72,7 @@ data "coder_parameter" "ai_prompt" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.2.8" + version = "4.2.9" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -108,7 +108,7 @@ Run and configure Claude Code as a standalone CLI in your workspace. ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.2.8" + version = "4.2.9" agent_id = coder_agent.main.id workdir = "/home/coder/project" install_claude_code = true @@ -130,7 +130,7 @@ variable "claude_code_oauth_token" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.2.8" + version = "4.2.9" agent_id = coder_agent.main.id workdir = "/home/coder/project" claude_code_oauth_token = var.claude_code_oauth_token @@ -203,7 +203,7 @@ resource "coder_env" "bedrock_api_key" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.2.8" + version = "4.2.9" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" @@ -260,7 +260,7 @@ resource "coder_env" "google_application_credentials" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.2.8" + version = "4.2.9" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "claude-sonnet-4@20250514" diff --git a/registry/coder/modules/claude-code/scripts/start.sh b/registry/coder/modules/claude-code/scripts/start.sh index ebca736c..061dce67 100644 --- a/registry/coder/modules/claude-code/scripts/start.sh +++ b/registry/coder/modules/claude-code/scripts/start.sh @@ -48,6 +48,13 @@ function install_boundary() { if [ "${ARG_COMPILE_FROM_SOURCE:-false}" = "true" ]; then # Install boundary by compiling from source echo "Compiling boundary from source (version: $ARG_BOUNDARY_VERSION)" + + echo "Removing existing boundary directory to allow re-running the script safely" + if [ -d boundary ]; then + rm -rf boundary + fi + + echo "Clone boundary repository" git clone https://github.com/coder/boundary.git cd boundary git checkout "$ARG_BOUNDARY_VERSION" diff --git a/registry/coder/modules/jetbrains/README.md b/registry/coder/modules/jetbrains/README.md index 71861359..cf97d127 100644 --- a/registry/coder/modules/jetbrains/README.md +++ b/registry/coder/modules/jetbrains/README.md @@ -14,10 +14,9 @@ This module adds JetBrains IDE buttons to launch IDEs directly from the dashboar module "jetbrains" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.2.1" + version = "1.3.0" agent_id = coder_agent.main.id folder = "/home/coder/project" - # tooltip = "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button." # Optional } ``` @@ -40,7 +39,7 @@ When `default` contains IDE codes, those IDEs are created directly without user module "jetbrains" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.2.1" + version = "1.3.0" agent_id = coder_agent.main.id folder = "/home/coder/project" default = ["PY", "IU"] # Pre-configure GoLand and IntelliJ IDEA @@ -53,7 +52,7 @@ module "jetbrains" { module "jetbrains" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.2.1" + version = "1.3.0" agent_id = coder_agent.main.id folder = "/home/coder/project" # Show parameter with limited options @@ -67,7 +66,7 @@ module "jetbrains" { module "jetbrains" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.2.1" + version = "1.3.0" agent_id = coder_agent.main.id folder = "/home/coder/project" default = ["IU", "PY"] @@ -82,7 +81,7 @@ module "jetbrains" { module "jetbrains" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.2.1" + version = "1.3.0" agent_id = coder_agent.main.id folder = "/workspace/project" @@ -109,7 +108,7 @@ module "jetbrains" { module "jetbrains_pycharm" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.2.1" + version = "1.3.0" agent_id = coder_agent.main.id folder = "/workspace/project" @@ -129,11 +128,11 @@ Add helpful tooltip text that appears when users hover over the IDE app buttons: module "jetbrains" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.2.1" + version = "1.3.0" agent_id = coder_agent.main.id folder = "/home/coder/project" default = ["IU", "PY"] - tooltip = "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button." + tooltip = "You need to install [JetBrains Toolbox App](https://www.jetbrains.com/toolbox-app/) to use this button." } ``` @@ -170,13 +169,6 @@ resource "coder_metadata" "container_info" { - If the API is unreachable (air-gapped environments), the module automatically falls back to build numbers from `ide_config` - `major_version` and `channel` control which API endpoint is queried (when API access is available) -### Tooltip - -- **`tooltip`**: Optional markdown text displayed when hovering over IDE app buttons -- If not specified, no tooltip is shown -- Supports markdown formatting for rich text (bold, italic, links, etc.) -- All IDE apps created by this module will show the same tooltip text - ## Supported IDEs All JetBrains IDEs with remote development capabilities: diff --git a/registry/coder/modules/jetbrains/jetbrains.tftest.hcl b/registry/coder/modules/jetbrains/jetbrains.tftest.hcl index 21726c25..dba9551d 100644 --- a/registry/coder/modules/jetbrains/jetbrains.tftest.hcl +++ b/registry/coder/modules/jetbrains/jetbrains.tftest.hcl @@ -2,15 +2,15 @@ variables { # Default IDE config, mirrored from main.tf for test assertions. # If main.tf defaults change, update this map to match. expected_ide_config = { - "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "251.26927.39" }, - "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "251.26927.50" }, - "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "251.26927.53" }, - "PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "251.26927.60" }, - "PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "251.26927.74" }, - "RD" = { name = "Rider", icon = "/icon/rider.svg", build = "251.26927.67" }, - "RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "251.26927.47" }, - "RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "251.26927.79" }, - "WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "251.26927.40" } + "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "253.29346.141" }, + "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" }, + "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" }, + "PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "253.29346.151" }, + "PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "253.29346.142" }, + "RD" = { name = "Rider", icon = "/icon/rider.svg", build = "253.29346.144" }, + "RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "253.29346.140" }, + "RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "253.29346.139" }, + "WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "253.29346.143" } } } @@ -187,16 +187,16 @@ run "tooltip_when_provided" { agent_id = "foo" folder = "/home/coder" default = ["GO"] - tooltip = "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button." + tooltip = "You need to install [JetBrains Toolbox App](https://www.jetbrains.com/toolbox-app/) to use this button." } assert { - condition = anytrue([for app in values(resource.coder_app.jetbrains) : app.tooltip == "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button."]) + condition = anytrue([for app in values(resource.coder_app.jetbrains) : app.tooltip == "You need to install [JetBrains Toolbox App](https://www.jetbrains.com/toolbox-app/) to use this button."]) error_message = "Expected coder_app tooltip to be set when provided" } } -run "tooltip_null_when_not_provided" { +run "tooltip_default_when_not_provided" { command = plan variables { @@ -206,8 +206,41 @@ run "tooltip_null_when_not_provided" { } assert { - condition = anytrue([for app in values(resource.coder_app.jetbrains) : app.tooltip == null]) - error_message = "Expected coder_app tooltip to be null when not provided" + condition = anytrue([for app in values(resource.coder_app.jetbrains) : app.tooltip == "You need to install [JetBrains Toolbox App](https://www.jetbrains.com/toolbox-app/) to use this button."]) + error_message = "Expected coder_app tooltip to be the default JetBrains Toolbox message when not provided" + } +} + +run "channel_eap" { + command = plan + + variables { + agent_id = "foo" + folder = "/home/coder" + default = ["GO"] + channel = "eap" + major_version = "latest" + } + + assert { + condition = output.ide_metadata["GO"].json_data.type == "eap" + error_message = "Expected the API to return a release of type 'eap', but got '${output.ide_metadata["GO"].json_data.type}'" + } +} + +run "specific_major_version" { + command = plan + + variables { + agent_id = "foo" + folder = "/home/coder" + default = ["GO"] + major_version = "2025.3" + } + + assert { + condition = output.ide_metadata["GO"].json_data.majorVersion == "2025.3" + error_message = "Expected the API to return a release for major version '2025.3', but got '${output.ide_metadata["GO"].json_data.majorVersion}'" } } @@ -294,3 +327,27 @@ run "output_multiple_ides" { error_message = "Expected ide_metadata['PY'].build to be the fallback '${var.expected_ide_config["PY"].build}'" } } +run "validate_output_schema" { + command = plan + + variables { + agent_id = "foo" + folder = "/home/coder" + default = ["GO"] + } + + assert { + condition = alltrue([ + for key, meta in output.ide_metadata : ( + can(meta.icon) && + can(meta.name) && + can(meta.identifier) && + can(meta.key) && + can(meta.build) && + # json_data can be null, but the key must exist + can(meta.json_data) + ) + ]) + error_message = "The ide_metadata output schema has changed. Please update the 'main.tf' and this test." + } +} diff --git a/registry/coder/modules/jetbrains/main.test.ts b/registry/coder/modules/jetbrains/main.test.ts deleted file mode 100644 index 0acf2ec2..00000000 --- a/registry/coder/modules/jetbrains/main.test.ts +++ /dev/null @@ -1,1054 +0,0 @@ -import { it, expect, describe } from "bun:test"; -import { - runTerraformInit, - testRequiredVariables, - runTerraformApply, -} from "~test"; - -describe("jetbrains", async () => { - await runTerraformInit(import.meta.dir); - - await testRequiredVariables(import.meta.dir, { - agent_id: "foo", - folder: "/home/foo", - }); - - // Core Logic Tests - When default is empty (shows parameter) - describe("when default is empty (shows parameter)", () => { - it("should create parameter with all IDE options when default=[] and major_version=latest", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - major_version: "latest", - }); - - // Should create a parameter when default is empty - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeDefined(); - expect(parameter?.instances[0].attributes.form_type).toBe("multi-select"); - expect(parameter?.instances[0].attributes.default).toBe("[]"); - - // Should have 9 options available (all default IDEs) - expect(parameter?.instances[0].attributes.option).toHaveLength(9); - - // Since no selection is made in test (empty default), should create no apps - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(0); - }); - - it("should create parameter with all IDE options when default=[] and major_version=2025.1", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - major_version: "2025.1", - }); - - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeDefined(); - expect(parameter?.instances[0].attributes.option).toHaveLength(9); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(0); - }); - - it("should create parameter with custom options when default=[] and custom options", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - options: '["GO", "IU", "WS"]', - major_version: "latest", - }); - - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeDefined(); - expect(parameter?.instances[0].attributes.option).toHaveLength(3); // Only custom options - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(0); - }); - - it("should create parameter with single option when default=[] and single option", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - options: '["GO"]', - major_version: "latest", - }); - - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeDefined(); - expect(parameter?.instances[0].attributes.option).toHaveLength(1); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(0); - }); - }); - - // Core Logic Tests - When default has values (skips parameter, creates apps directly) - describe("when default has values (creates apps directly)", () => { - it('should skip parameter and create single app when default=["GO"] and major_version=latest', async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - major_version: "latest", - }); - - // Should NOT create a parameter when default is not empty - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeUndefined(); - - // Should create exactly 1 app - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(1); - expect(coder_apps[0].instances[0].attributes.slug).toBe("jetbrains-go"); - expect(coder_apps[0].instances[0].attributes.display_name).toBe("GoLand"); - }); - - it('should skip parameter and create single app when default=["GO"] and major_version=2025.1', async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - major_version: "2025.1", - }); - - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeUndefined(); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(1); - expect(coder_apps[0].instances[0].attributes.display_name).toBe("GoLand"); - }); - - it("should skip parameter and create app with different IDE", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["RR"]', - major_version: "latest", - }); - - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeUndefined(); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(1); - expect(coder_apps[0].instances[0].attributes.slug).toBe("jetbrains-rr"); - expect(coder_apps[0].instances[0].attributes.display_name).toBe( - "RustRover", - ); - }); - }); - - // Channel Tests - describe("channel variations", () => { - it("should work with EAP channel and latest version", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - major_version: "latest", - channel: "eap", - }); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(1); - - // Check that URLs contain build numbers (from EAP releases) - expect(coder_apps[0].instances[0].attributes.url).toContain( - "ide_build_number=", - ); - }); - - it("should work with EAP channel and specific version", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - major_version: "2025.2", - channel: "eap", - }); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(1); - expect(coder_apps[0].instances[0].attributes.url).toContain( - "ide_build_number=", - ); - }); - - it("should work with release channel (default)", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - channel: "release", - }); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(1); - }); - }); - - // Configuration Tests - describe("configuration parameters", () => { - it("should use custom folder path in URL", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/workspace/myproject", - default: '["GO"]', - major_version: "latest", - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_app?.instances[0].attributes.url).toContain( - "folder=/workspace/myproject", - ); - }); - - it("should set app order when specified", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - coder_app_order: 10, - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_app?.instances[0].attributes.order).toBe(10); - }); - - it("should set parameter order when default is empty", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - coder_parameter_order: 5, - }); - - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter?.instances[0].attributes.order).toBe(5); - }); - - it("should set tooltip when specified", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - tooltip: - "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button.", - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_app?.instances[0].attributes.tooltip).toBe( - "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button.", - ); - }); - - it("should have null tooltip when not specified", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_app?.instances[0].attributes.tooltip).toBeNull(); - }); - }); - - // URL Generation Tests - describe("URL generation", () => { - it("should generate proper jetbrains:// URLs with all required parameters", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "test-agent-123", - folder: "/custom/project/path", - default: '["GO"]', - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - const url = coder_app?.instances[0].attributes.url; - - expect(url).toContain("jetbrains://gateway/coder"); - expect(url).toContain("&workspace="); - expect(url).toContain("&owner="); - expect(url).toContain("&folder=/custom/project/path"); - expect(url).toContain("&url="); - expect(url).toContain("&token=$SESSION_TOKEN"); - expect(url).toContain("&ide_product_code=GO"); - expect(url).toContain("&ide_build_number="); - // No agent_name parameter should be included when agent_name is not specified - expect(url).not.toContain("&agent_name="); - }); - - it("should include agent_name parameter when agent_name is specified", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "test-agent-123", - agent_name: "main-agent", - folder: "/custom/project/path", - default: '["GO"]', - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - const url = coder_app?.instances[0].attributes.url; - - expect(url).toContain("jetbrains://gateway/coder"); - expect(url).toContain("&agent_name=main-agent"); - expect(url).toContain("&ide_product_code=GO"); - expect(url).toContain("&ide_build_number="); - }); - - it("should include build numbers from API in URLs", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - const url = coder_app?.instances[0].attributes.url; - - expect(url).toContain("ide_build_number="); - // Build numbers should be numeric (not empty or placeholder) - if (typeof url === "string") { - const buildMatch = url.match(/ide_build_number=([^&]+)/); - expect(buildMatch).toBeTruthy(); - expect(buildMatch![1]).toMatch(/^\d+/); // Should start with digits - } - }); - }); - - // Version Tests - describe("version handling", () => { - it("should work with latest major version", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - major_version: "latest", - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_app?.instances[0].attributes.url).toContain( - "ide_build_number=", - ); - }); - - it("should work with specific major version", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - major_version: "2025.1", - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_app?.instances[0].attributes.url).toContain( - "ide_build_number=", - ); - }); - }); - - // IDE Metadata Tests - describe("IDE metadata and attributes", () => { - it("should have correct display names and icons for GoLand", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - expect(coder_app?.instances[0].attributes.display_name).toBe("GoLand"); - expect(coder_app?.instances[0].attributes.icon).toBe("/icon/goland.svg"); - expect(coder_app?.instances[0].attributes.slug).toBe("jetbrains-go"); - }); - - it("should have correct display names and icons for RustRover", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["RR"]', - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - expect(coder_app?.instances[0].attributes.display_name).toBe("RustRover"); - expect(coder_app?.instances[0].attributes.icon).toBe( - "/icon/rustrover.svg", - ); - expect(coder_app?.instances[0].attributes.slug).toBe("jetbrains-rr"); - }); - - it("should have correct app attributes set", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "test-agent", - folder: "/home/coder", - default: '["GO"]', - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - expect(coder_app?.instances[0].attributes.agent_id).toBe("test-agent"); - expect(coder_app?.instances[0].attributes.external).toBe(true); - expect(coder_app?.instances[0].attributes.hidden).toBe(false); - expect(coder_app?.instances[0].attributes.share).toBe("owner"); - expect(coder_app?.instances[0].attributes.open_in).toBe("slim-window"); - }); - }); - - // Edge Cases and Validation - describe("edge cases and validation", () => { - it("should validate folder path format", async () => { - // Valid absolute path should work - await expect( - runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder/project", - default: '["GO"]', - }), - ).resolves.toBeDefined(); - }); - - it("should handle empty parameter selection gracefully", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - // Don't pass default at all - let it use the variable's default value of [] - }); - - // Should create parameter but no apps when no selection - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeDefined(); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(0); - }); - }); - - // Custom IDE Config Tests - describe("custom ide_config with subset of options", () => { - const customIdeConfig = JSON.stringify({ - GO: { - name: "Custom GoLand", - icon: "/custom/goland.svg", - build: "999.123.456", - }, - IU: { - name: "Custom IntelliJ", - icon: "/custom/intellij.svg", - build: "999.123.457", - }, - WS: { - name: "Custom WebStorm", - icon: "/custom/webstorm.svg", - build: "999.123.458", - }, - }); - - it("should handle multiple defaults without custom ide_config (debug test)", async () => { - const testParams = { - agent_id: "foo", - folder: "/home/coder", - default: '["GO", "IU"]', // Test multiple defaults without custom config - }; - - const state = await runTerraformApply(import.meta.dir, testParams); - - // Should create at least 1 app (test framework may have issues with multiple values) - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBeGreaterThanOrEqual(1); - - // Should create apps with correct names and metadata - const appNames = coder_apps.map( - (app) => app.instances[0].attributes.display_name, - ); - expect(appNames).toContain("GoLand"); // Should at least have GoLand - }); - - it("should create parameter with custom ide_config when default is empty", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - // Don't pass default to use empty default - options: '["GO", "IU", "WS"]', // Must match the keys in ide_config - ide_config: customIdeConfig, - }); - - // Should create parameter with custom configurations - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeDefined(); - expect(parameter?.instances[0].attributes.option).toHaveLength(3); - - // Check that custom names and icons are used - const options = parameter?.instances[0].attributes.option as Array<{ - name: string; - icon: string; - value: string; - }>; - const goOption = options?.find((opt) => opt.value === "GO"); - expect(goOption?.name).toBe("Custom GoLand"); - expect(goOption?.icon).toBe("/custom/goland.svg"); - - const iuOption = options?.find((opt) => opt.value === "IU"); - expect(iuOption?.name).toBe("Custom IntelliJ"); - expect(iuOption?.icon).toBe("/custom/intellij.svg"); - - // Should create no apps since no selection - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(0); - }); - - it("should create apps with custom ide_config when default has values", async () => { - const testParams = { - agent_id: "foo", - folder: "/home/coder", - default: '["GO", "IU"]', // Subset of available options - options: '["GO", "IU", "WS"]', // Must be superset of default - ide_config: customIdeConfig, - }; - - const state = await runTerraformApply(import.meta.dir, testParams); - - // Should NOT create parameter when default is not empty - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeUndefined(); - - // Should create at least 1 app with custom configurations (test framework may have issues with multiple values) - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBeGreaterThanOrEqual(1); - - // Check that custom display names and icons are used for available apps - const goApp = coder_apps.find( - (app) => app.instances[0].attributes.slug === "jetbrains-go", - ); - if (goApp) { - expect(goApp.instances[0].attributes.display_name).toBe( - "Custom GoLand", - ); - expect(goApp.instances[0].attributes.icon).toBe("/custom/goland.svg"); - } - - const iuApp = coder_apps.find( - (app) => app.instances[0].attributes.slug === "jetbrains-iu", - ); - if (iuApp) { - expect(iuApp.instances[0].attributes.display_name).toBe( - "Custom IntelliJ", - ); - expect(iuApp.instances[0].attributes.icon).toBe("/custom/intellij.svg"); - } - - // At least one app should be created - expect(coder_apps.length).toBeGreaterThan(0); - }); - - it("should use custom build numbers from ide_config in URLs", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - options: '["GO", "IU", "WS"]', - ide_config: customIdeConfig, - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - // Should use build number from API, not from ide_config (this is the correct behavior) - // The module always fetches fresh build numbers from JetBrains API for latest versions - expect(coder_app?.instances[0].attributes.url).toContain( - "ide_build_number=", - ); - // Verify it contains a valid build number (not the custom one) - if (typeof coder_app?.instances[0].attributes.url === "string") { - const buildMatch = coder_app.instances[0].attributes.url.match( - /ide_build_number=([^&]+)/, - ); - expect(buildMatch).toBeTruthy(); - expect(buildMatch![1]).toMatch(/^\d+/); // Should start with digits (API build number) - expect(buildMatch![1]).not.toBe("999.123.456"); // Should NOT be the custom build number - } - }); - - it("should work with single IDE in custom ide_config", async () => { - const singleIdeConfig = JSON.stringify({ - RR: { - name: "My RustRover", - icon: "/my/rustrover.svg", - build: "888.999.111", - }, - }); - - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["RR"]', - options: '["RR"]', // Only one option - ide_config: singleIdeConfig, - }); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(1); - expect(coder_apps[0].instances[0].attributes.display_name).toBe( - "My RustRover", - ); - expect(coder_apps[0].instances[0].attributes.icon).toBe( - "/my/rustrover.svg", - ); - - // Should use build number from API, not custom ide_config - expect(coder_apps[0].instances[0].attributes.url).toContain( - "ide_build_number=", - ); - if (typeof coder_apps[0].instances[0].attributes.url === "string") { - const buildMatch = coder_apps[0].instances[0].attributes.url.match( - /ide_build_number=([^&]+)/, - ); - expect(buildMatch).toBeTruthy(); - expect(buildMatch![1]).not.toBe("888.999.111"); // Should NOT be the custom build number - } - }); - }); - - // Air-Gapped and Fallback Tests - describe("air-gapped environment fallback", () => { - it("should use API build numbers when available", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - // Should use build number from API - expect(coder_app?.instances[0].attributes.url).toContain( - "ide_build_number=", - ); - if (typeof coder_app?.instances[0].attributes.url === "string") { - const buildMatch = coder_app.instances[0].attributes.url.match( - /ide_build_number=([^&]+)/, - ); - expect(buildMatch).toBeTruthy(); - expect(buildMatch![1]).toMatch(/^\d+/); // Should be a valid build number from API - // Should NOT be the default fallback build number - expect(buildMatch![1]).not.toBe("251.25410.140"); - } - }); - - it("should fallback to ide_config build numbers when API fails", async () => { - // Note: Testing true air-gapped scenarios is difficult in unit tests since Terraform - // fails at plan time when HTTP data sources are unreachable. However, our fallback - // logic is implemented using try() which will gracefully handle API failures. - // This test verifies that the ide_config validation and structure is correct. - const customIdeConfig = JSON.stringify({ - CL: { - name: "CLion", - icon: "/icon/clion.svg", - build: "999.fallback.123", - }, - GO: { - name: "GoLand", - icon: "/icon/goland.svg", - build: "999.fallback.124", - }, - IU: { - name: "IntelliJ IDEA", - icon: "/icon/intellij.svg", - build: "999.fallback.125", - }, - PS: { - name: "PhpStorm", - icon: "/icon/phpstorm.svg", - build: "999.fallback.126", - }, - PY: { - name: "PyCharm", - icon: "/icon/pycharm.svg", - build: "999.fallback.127", - }, - RD: { - name: "Rider", - icon: "/icon/rider.svg", - build: "999.fallback.128", - }, - RM: { - name: "RubyMine", - icon: "/icon/rubymine.svg", - build: "999.fallback.129", - }, - RR: { - name: "RustRover", - icon: "/icon/rustrover.svg", - build: "999.fallback.130", - }, - WS: { - name: "WebStorm", - icon: "/icon/webstorm.svg", - build: "999.fallback.131", - }, - }); - - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - ide_config: customIdeConfig, - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - // Should work with custom ide_config (API data will override in connected environments) - expect(coder_app?.instances[0].attributes.url).toContain( - "ide_build_number=", - ); - expect(coder_app?.instances[0].attributes.display_name).toBe("GoLand"); - }); - - it("should work with full custom ide_config covering all IDEs", async () => { - const fullIdeConfig = JSON.stringify({ - CL: { name: "CLion", icon: "/icon/clion.svg", build: "999.test.123" }, - GO: { name: "GoLand", icon: "/icon/goland.svg", build: "999.test.124" }, - IU: { - name: "IntelliJ IDEA", - icon: "/icon/intellij.svg", - build: "999.test.125", - }, - PS: { - name: "PhpStorm", - icon: "/icon/phpstorm.svg", - build: "999.test.126", - }, - PY: { - name: "PyCharm", - icon: "/icon/pycharm.svg", - build: "999.test.127", - }, - RD: { name: "Rider", icon: "/icon/rider.svg", build: "999.test.128" }, - RM: { - name: "RubyMine", - icon: "/icon/rubymine.svg", - build: "999.test.129", - }, - RR: { - name: "RustRover", - icon: "/icon/rustrover.svg", - build: "999.test.130", - }, - WS: { - name: "WebStorm", - icon: "/icon/webstorm.svg", - build: "999.test.131", - }, - }); - - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO", "IU", "WS"]', - ide_config: fullIdeConfig, - }); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - // Should create apps with custom configuration - expect(coder_apps.length).toBeGreaterThan(0); - - // Check that custom display names are preserved - const goApp = coder_apps.find( - (app) => app.instances[0].attributes.slug === "jetbrains-go", - ); - if (goApp) { - expect(goApp.instances[0].attributes.display_name).toBe("GoLand"); - expect(goApp.instances[0].attributes.icon).toBe("/icon/goland.svg"); - } - }); - - it("should handle parameter creation with custom ide_config", async () => { - const customIdeConfig = JSON.stringify({ - CL: { name: "CLion", icon: "/icon/clion.svg", build: "999.param.123" }, - GO: { - name: "GoLand", - icon: "/icon/goland.svg", - build: "999.param.124", - }, - IU: { - name: "IntelliJ IDEA", - icon: "/icon/intellij.svg", - build: "999.param.125", - }, - PS: { - name: "PhpStorm", - icon: "/icon/phpstorm.svg", - build: "999.param.126", - }, - PY: { - name: "PyCharm", - icon: "/icon/pycharm.svg", - build: "999.param.127", - }, - RD: { name: "Rider", icon: "/icon/rider.svg", build: "999.param.128" }, - RM: { - name: "RubyMine", - icon: "/icon/rubymine.svg", - build: "999.param.129", - }, - RR: { - name: "RustRover", - icon: "/icon/rustrover.svg", - build: "999.param.130", - }, - WS: { - name: "WebStorm", - icon: "/icon/webstorm.svg", - build: "999.param.131", - }, - }); - - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - options: '["GO", "IU"]', - ide_config: customIdeConfig, - }); - - // Should create parameter with custom configuration - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeDefined(); - expect(parameter?.instances[0].attributes.option).toHaveLength(2); - - // Parameter should show correct IDE names and icons from ide_config - const options = parameter?.instances[0].attributes.option as Array<{ - name: string; - icon: string; - value: string; - }>; - const goOption = options?.find((opt) => opt.value === "GO"); - expect(goOption?.name).toBe("GoLand"); - expect(goOption?.icon).toBe("/icon/goland.svg"); - }); - - it("should work with mixed API success/failure scenarios", async () => { - // This tests the robustness of the try() mechanism - // Even if some API calls succeed and others fail, the module should handle it gracefully - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - // Use real API endpoint - if it fails, fallback should work - releases_base_link: "https://data.services.jetbrains.com", - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - // Should create app regardless of API success/failure - expect(coder_app).toBeDefined(); - expect(coder_app?.instances[0].attributes.url).toContain( - "ide_build_number=", - ); - }); - - it("should preserve custom IDE metadata in air-gapped environments", async () => { - // This test validates that ide_config structure supports air-gapped deployments - // by ensuring custom metadata is correctly configured for all default IDEs - const airGappedIdeConfig = JSON.stringify({ - CL: { - name: "CLion Enterprise", - icon: "/enterprise/clion.svg", - build: "251.air.123", - }, - GO: { - name: "GoLand Enterprise", - icon: "/enterprise/goland.svg", - build: "251.air.124", - }, - IU: { - name: "IntelliJ IDEA Enterprise", - icon: "/enterprise/intellij.svg", - build: "251.air.125", - }, - PS: { - name: "PhpStorm Enterprise", - icon: "/enterprise/phpstorm.svg", - build: "251.air.126", - }, - PY: { - name: "PyCharm Enterprise", - icon: "/enterprise/pycharm.svg", - build: "251.air.127", - }, - RD: { - name: "Rider Enterprise", - icon: "/enterprise/rider.svg", - build: "251.air.128", - }, - RM: { - name: "RubyMine Enterprise", - icon: "/enterprise/rubymine.svg", - build: "251.air.129", - }, - RR: { - name: "RustRover Enterprise", - icon: "/enterprise/rustrover.svg", - build: "251.air.130", - }, - WS: { - name: "WebStorm Enterprise", - icon: "/enterprise/webstorm.svg", - build: "251.air.131", - }, - }); - - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["RR"]', - ide_config: airGappedIdeConfig, - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - // Should preserve custom metadata for air-gapped setups - expect(coder_app?.instances[0].attributes.display_name).toBe( - "RustRover Enterprise", - ); - expect(coder_app?.instances[0].attributes.icon).toBe( - "/enterprise/rustrover.svg", - ); - // Note: In normal operation with API access, build numbers come from API. - // In air-gapped environments, our fallback logic will use ide_config build numbers. - expect(coder_app?.instances[0].attributes.url).toContain( - "ide_build_number=", - ); - }); - - it("should validate that fallback mechanism doesn't break existing functionality", async () => { - // Regression test to ensure our changes don't break normal operation - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO", "IU"]', - major_version: "latest", - channel: "release", - }); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - // Should work normally with API when available - expect(coder_apps.length).toBeGreaterThan(0); - - for (const app of coder_apps) { - // Should have valid URLs with build numbers - expect(app.instances[0].attributes.url).toContain( - "jetbrains://gateway/coder", - ); - expect(app.instances[0].attributes.url).toContain("ide_build_number="); - expect(app.instances[0].attributes.url).toContain("ide_product_code="); - } - }); - }); -}); diff --git a/registry/coder/modules/jetbrains/main.tf b/registry/coder/modules/jetbrains/main.tf index 51f7c816..2fac060f 100644 --- a/registry/coder/modules/jetbrains/main.tf +++ b/registry/coder/modules/jetbrains/main.tf @@ -62,7 +62,7 @@ variable "coder_parameter_order" { variable "tooltip" { type = string description = "Markdown text that is displayed when hovering over workspace apps." - default = null + default = "You need to install [JetBrains Toolbox App](https://www.jetbrains.com/toolbox-app/) to use this button." } variable "major_version" { @@ -70,8 +70,8 @@ variable "major_version" { description = "The major version of the IDE. i.e. 2025.1" default = "latest" validation { - condition = can(regex("^[0-9]{4}\\.[0-2]{1}$", var.major_version)) || var.major_version == "latest" - error_message = "The major_version must be a valid version number. i.e. 2025.1 or latest" + condition = can(regex("^[0-9]{4}\\.[1-3]$", var.major_version)) || var.major_version == "latest" + error_message = "The major_version must be a valid version number (e.g., 2025.1) or 'latest'" } } @@ -126,7 +126,7 @@ variable "download_base_link" { data "http" "jetbrains_ide_versions" { for_each = length(var.default) == 0 ? var.options : var.default - url = "${var.releases_base_link}/products/releases?code=${each.key}&type=${var.channel}&latest=true${var.major_version == "latest" ? "" : "&major_version=${var.major_version}"}" + url = "${var.releases_base_link}/products/releases?code=${each.key}&type=${var.channel}${var.major_version == "latest" ? "&latest=true" : ""}" } variable "ide_config" { @@ -138,9 +138,9 @@ variable "ide_config" { - build: The build number of the IDE. Example: { - "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "251.26927.39" }, - "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "251.26927.50" }, - "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "251.26927.53" }, + "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "253.29346.141" }, + "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" }, + "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" }, } EOT type = map(object({ @@ -149,15 +149,15 @@ variable "ide_config" { build = string })) default = { - "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "251.26927.39" }, - "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "251.26927.50" }, - "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "251.26927.53" }, - "PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "251.26927.60" }, - "PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "251.26927.74" }, - "RD" = { name = "Rider", icon = "/icon/rider.svg", build = "251.26927.67" }, - "RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "251.26927.47" }, - "RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "251.26927.79" }, - "WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "251.26927.40" } + "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "253.29346.141" }, + "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" }, + "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" }, + "PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "253.29346.151" }, + "PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "253.29346.142" }, + "RD" = { name = "Rider", icon = "/icon/rider.svg", build = "253.29346.144" }, + "RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "253.29346.140" }, + "RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "253.29346.139" }, + "WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "253.29346.143" } } validation { condition = length(var.ide_config) > 0 @@ -182,6 +182,20 @@ locals { ) } + # Filter the parsed response for the requested major version if not "latest" + filtered_releases = { + for code in length(var.default) == 0 ? var.options : var.default : code => [ + for r in try(local.parsed_responses[code][keys(local.parsed_responses[code])[0]], []) : + r if var.major_version == "latest" || r.majorVersion == var.major_version + ] + } + + # Select the latest release for the requested major version (first item in the filtered list) + selected_releases = { + for code in length(var.default) == 0 ? var.options : var.default : code => + length(local.filtered_releases[code]) > 0 ? local.filtered_releases[code][0] : null + } + # Dynamically generate IDE configurations based on options with fallback to ide_config options_metadata = { for code in length(var.default) == 0 ? var.options : var.default : code => { @@ -191,13 +205,10 @@ locals { key = code # Use API build number if available, otherwise fall back to ide_config build number - build = length(keys(local.parsed_responses[code])) > 0 ? ( - local.parsed_responses[code][keys(local.parsed_responses[code])[0]][0].build - ) : var.ide_config[code].build + build = local.selected_releases[code] != null ? local.selected_releases[code].build : var.ide_config[code].build - # Store API data for potential future use (only if API is available) - json_data = length(keys(local.parsed_responses[code])) > 0 ? local.parsed_responses[code][keys(local.parsed_responses[code])[0]][0] : null - response_key = length(keys(local.parsed_responses[code])) > 0 ? keys(local.parsed_responses[code])[0] : null + # Store API data for potential future use + json_data = local.selected_releases[code] } } diff --git a/registry/coder/modules/mux/README.md b/registry/coder/modules/mux/README.md index 4df26ce7..3f55209e 100644 --- a/registry/coder/modules/mux/README.md +++ b/registry/coder/modules/mux/README.md @@ -14,7 +14,7 @@ Automatically install and run [mux](https://github.com/coder/mux) in a Coder wor module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.6" + version = "1.0.7" agent_id = coder_agent.main.id } ``` @@ -37,7 +37,7 @@ module "mux" { module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.6" + version = "1.0.7" agent_id = coder_agent.main.id } ``` @@ -48,7 +48,7 @@ module "mux" { module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.6" + version = "1.0.7" agent_id = coder_agent.main.id # Default is "latest"; set to a specific version to pin install_version = "0.4.0" @@ -61,7 +61,7 @@ module "mux" { module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.6" + version = "1.0.7" agent_id = coder_agent.main.id port = 8080 } @@ -75,7 +75,7 @@ Run an existing copy of mux if found, otherwise install from npm: module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.6" + version = "1.0.7" agent_id = coder_agent.main.id use_cached = true } @@ -89,7 +89,7 @@ Run without installing from the network (requires mux to be pre-installed): module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.6" + version = "1.0.7" agent_id = coder_agent.main.id install = false }