diff --git a/.icons/trae-cn.png b/.icons/trae-cn.png new file mode 100644 index 000000000..c7ca571c5 Binary files /dev/null and b/.icons/trae-cn.png differ diff --git a/registry/coder/modules/trae-cn/README.md b/registry/coder/modules/trae-cn/README.md new file mode 100644 index 000000000..cb82be1d2 --- /dev/null +++ b/registry/coder/modules/trae-cn/README.md @@ -0,0 +1,69 @@ +--- +display_name: Trae CN +description: Add a one-click button to launch Trae CN +icon: ../../../../.icons/trae-cn.png +verified: false +tags: [ide, trae, ai] +--- + +# Trae CN + +Add a button to open any workspace with a single click in Trae CN. + +Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder). + +```tf +module "trae_cn" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/trae-cn/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} +``` + +## Examples + +### Open in a specific directory + +```tf +module "trae_cn" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/trae-cn/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + folder = "/home/coder/project" +} +``` + +### Configure MCP servers for Trae CN + +Provide a JSON-encoded string via the `mcp` input. When set, the module writes the value to `.trae/mcp.json` in `folder`, or `$HOME/.trae/mcp.json` when `folder` is not set. + +If your MCP configuration includes credentials, either add `.trae/mcp.json` to the project's `.gitignore`, or set `mcp_config_path` to a path outside the repository. + +The following example configures Trae CN to use the GitHub MCP server with authentication facilitated by the [`coder_external_auth`](https://coder.com/docs/admin/external-auth#configure-a-github-oauth-app) resource. + +```tf +module "trae_cn" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/trae-cn/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + folder = "/home/coder/project" + mcp = jsonencode({ + mcpServers = { + "github" : { + "url" : "https://api.githubcopilot.com/mcp/", + "headers" : { + "Authorization" : "Bearer ${data.coder_external_auth.github.access_token}", + }, + "type" : "http" + } + } + }) +} + +data "coder_external_auth" "github" { + id = "github" +} +``` diff --git a/registry/coder/modules/trae-cn/main.test.ts b/registry/coder/modules/trae-cn/main.test.ts new file mode 100644 index 000000000..db130a9eb --- /dev/null +++ b/registry/coder/modules/trae-cn/main.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "bun:test"; +import { + findResourceInstance, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +const encodeBase64 = (value: string) => Buffer.from(value).toString("base64"); + +describe("trae-cn", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + expect(state.outputs.trae_cn_url.value).toBe( + "trae-cn://coder.coder-remote/open?owner=default&workspace=default&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + + const coderApp = state.resources.find( + (res) => + res.type === "coder_app" && + res.module === "module.vscode-desktop-core" && + res.name === "vscode-desktop", + ); + + expect(coderApp).not.toBeNull(); + expect(coderApp?.instances.length).toBe(1); + expect(coderApp?.instances[0].attributes.icon).toBe("/icon/trae-cn.png"); + expect(coderApp?.instances[0].attributes.slug).toBe("trae-cn"); + expect(coderApp?.instances[0].attributes.display_name).toBe("Trae CN"); + expect(coderApp?.instances[0].attributes.order).toBeNull(); + }); + + it("adds folder", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + }); + expect(state.outputs.trae_cn_url.value).toBe( + "trae-cn://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds folder and open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + open_recent: "true", + }); + expect(state.outputs.trae_cn_url.value).toBe( + "trae-cn://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds folder but not open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + open_recent: "false", + }); + expect(state.outputs.trae_cn_url.value).toBe( + "trae-cn://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + open_recent: "true", + }); + expect(state.outputs.trae_cn_url.value).toBe( + "trae-cn://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("sets order", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + order: "22", + }); + + const coderApp = state.resources.find( + (res) => + res.type === "coder_app" && + res.module === "module.vscode-desktop-core" && + res.name === "vscode-desktop", + ); + + expect(coderApp).not.toBeNull(); + expect(coderApp?.instances.length).toBe(1); + expect(coderApp?.instances[0].attributes.order).toBe(22); + }); + + it("adds MCP script for folder/.trae/mcp.json when mcp and folder provided", async () => { + const mcp = JSON.stringify({ + mcpServers: { demo: { url: "http://localhost:1234" } }, + }); + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/tmp/project", + mcp, + }); + const script = findResourceInstance(state, "coder_script", "trae_cn_mcp"); + + expect(script.display_name).toBe("Trae CN MCP"); + expect(script.icon).toBe("/icon/trae-cn.png"); + expect(script.script).toContain(encodeBase64(mcp)); + expect(script.script).toContain( + encodeBase64("/tmp/project/.trae/mcp.json"), + ); + }); + + it("adds MCP script for custom mcp_config_path when provided", async () => { + const mcp = JSON.stringify({ + mcpServers: { demo: { url: "http://localhost:1234" } }, + }); + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/tmp/project", + mcp, + mcp_config_path: "$HOME/.config/trae/mcp.json", + }); + const script = findResourceInstance(state, "coder_script", "trae_cn_mcp"); + + expect(script.script).toContain(encodeBase64(mcp)); + expect(script.script).toContain( + encodeBase64("$HOME/.config/trae/mcp.json"), + ); + }); +}); diff --git a/registry/coder/modules/trae-cn/main.tf b/registry/coder/modules/trae-cn/main.tf new file mode 100644 index 000000000..cffb7e799 --- /dev/null +++ b/registry/coder/modules/trae-cn/main.tf @@ -0,0 +1,124 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.5" + } + } +} + +variable "agent_id" { + description = "The ID of a Coder agent." + type = string +} + +variable "folder" { + description = "The folder to open in Trae CN." + type = string + default = "" +} + +variable "open_recent" { + description = "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open." + type = bool + default = false +} + +variable "order" { + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name." + type = number + default = null +} + +variable "group" { + description = "The name of a group that this app belongs to." + type = string + default = null +} + +variable "slug" { + description = "The slug of the app." + type = string + default = "trae-cn" +} + +variable "display_name" { + description = "The display name of the app." + type = string + default = "Trae CN" +} + +variable "mcp" { + description = "JSON-encoded string to configure MCP servers for Trae CN. When set, writes mcp_config_path." + type = string + default = "" +} + +variable "mcp_config_path" { + description = "Path to write the Trae CN MCP configuration. Defaults to folder/.trae/mcp.json when folder is set, otherwise $HOME/.trae/mcp.json." + type = string + default = "" +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +locals { + mcp_b64 = var.mcp != "" ? base64encode(var.mcp) : "" + mcp_config_path = ( + var.mcp_config_path != "" + ? var.mcp_config_path + : var.folder != "" + ? "${var.folder}/.trae/mcp.json" + : "$HOME/.trae/mcp.json" + ) + mcp_config_path_b64 = base64encode(local.mcp_config_path) +} + +module "vscode-desktop-core" { + source = "registry.coder.com/coder/vscode-desktop-core/coder" + version = "1.0.2" + + agent_id = var.agent_id + + coder_app_icon = "/icon/trae-cn.png" + coder_app_slug = var.slug + coder_app_display_name = var.display_name + coder_app_order = var.order + coder_app_group = var.group + + folder = var.folder + open_recent = var.open_recent + protocol = "trae-cn" +} + +resource "coder_script" "trae_cn_mcp" { + count = var.mcp != "" ? 1 : 0 + agent_id = var.agent_id + display_name = "Trae CN MCP" + icon = "/icon/trae-cn.png" + run_on_start = true + start_blocks_login = false + script = <<-EOT + #!/bin/sh + set -eu + + mcp_config_path="$(echo -n '${local.mcp_config_path_b64}' | base64 -d)" + case "$mcp_config_path" in + "\$HOME/"*) mcp_config_path="$HOME/$${mcp_config_path#\$HOME/}" ;; + "~/"*) mcp_config_path="$HOME/$${mcp_config_path#~/}" ;; + esac + + mkdir -p "$(dirname "$mcp_config_path")" + echo -n '${local.mcp_b64}' | base64 -d > "$mcp_config_path" + chmod 600 "$mcp_config_path" + EOT +} + +output "trae_cn_url" { + description = "Trae CN URL." + value = module.vscode-desktop-core.ide_uri +} diff --git a/registry/coder/modules/trae-cn/trae-cn.tftest.hcl b/registry/coder/modules/trae-cn/trae-cn.tftest.hcl new file mode 100644 index 000000000..3d77bc6d3 --- /dev/null +++ b/registry/coder/modules/trae-cn/trae-cn.tftest.hcl @@ -0,0 +1,110 @@ +run "required_vars" { + command = plan + + variables { + agent_id = "foo" + } +} + +run "default_output" { + command = plan + + variables { + agent_id = "foo" + } + + assert { + condition = output.trae_cn_url == "trae-cn://coder.coder-remote/open?owner=default&workspace=default&url=https://mydeployment.coder.com&token=$SESSION_TOKEN" + error_message = "Default trae_cn_url must match expected value" + } +} + +run "adds_folder" { + command = plan + + variables { + agent_id = "foo" + folder = "/foo/bar" + } + + assert { + condition = output.trae_cn_url == "trae-cn://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN" + error_message = "URL must include folder parameter" + } +} + +run "folder_and_open_recent" { + command = plan + + variables { + agent_id = "foo" + folder = "/foo/bar" + open_recent = true + } + + assert { + condition = output.trae_cn_url == "trae-cn://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN" + error_message = "URL must include folder and openRecent parameters" + } +} + +run "adds_open_recent" { + command = plan + + variables { + agent_id = "foo" + open_recent = true + } + + assert { + condition = output.trae_cn_url == "trae-cn://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN" + error_message = "URL must include openRecent parameter" + } +} + +run "writes_mcp_json" { + command = plan + + variables { + agent_id = "foo" + folder = "/foo/bar" + mcp = jsonencode({ + mcpServers = { + demo = { url = "http://localhost:1234" } + } + }) + } + + assert { + condition = strcontains(coder_script.trae_cn_mcp[0].script, base64encode(jsonencode({ + mcpServers = { + demo = { url = "http://localhost:1234" } + } + }))) + error_message = "coder_script must contain base64-encoded MCP JSON" + } + + assert { + condition = strcontains(coder_script.trae_cn_mcp[0].script, base64encode("/foo/bar/.trae/mcp.json")) + error_message = "coder_script must contain the default folder MCP path" + } +} + +run "writes_custom_mcp_path" { + command = plan + + variables { + agent_id = "foo" + mcp_config_path = "$HOME/.config/trae/mcp.json" + mcp = jsonencode({ + mcpServers = { + demo = { url = "http://localhost:1234" } + } + }) + } + + assert { + condition = strcontains(coder_script.trae_cn_mcp[0].script, base64encode("$HOME/.config/trae/mcp.json")) + error_message = "coder_script must contain the custom MCP path" + } +}