diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 87ed60be..133537e3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,5 +1,3 @@ -Closes # - ## Description @@ -7,6 +5,7 @@ Closes # ## Type of Change - [ ] New module +- [ ] New template - [ ] Bug fix - [ ] Feature/enhancement - [ ] Documentation @@ -20,10 +19,16 @@ Closes # **New version:** `v1.0.0` **Breaking change:** [ ] Yes [ ] No +## Template Information + + + +**Path:** `registry/[namespace]/templates/[template-name]` + ## Testing & Validation - [ ] Tests pass (`bun test`) -- [ ] Code formatted (`bun run fmt`) +- [ ] Code formatted (`bun fmt`) - [ ] Changes tested locally ## Related Issues diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7e14213c..9c676ec1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -28,6 +28,8 @@ jobs: run: bun install - name: Run TypeScript tests run: bun test + - name: Run Terraform tests + run: ./scripts/terraform_test_all.sh - name: Run Terraform Validate run: bun terraform-validate validate-style: @@ -48,7 +50,7 @@ jobs: - name: Validate formatting run: bun fmt:ci - name: Check for typos - uses: crate-ci/typos@v1.37.2 + uses: crate-ci/typos@v1.38.1 with: config: .github/typos.toml validate-readme-files: diff --git a/.icons/auto-dev-server.svg b/.icons/auto-dev-server.svg new file mode 100644 index 00000000..f043b56d --- /dev/null +++ b/.icons/auto-dev-server.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.icons/nexus-repository.svg b/.icons/nexus-repository.svg new file mode 100644 index 00000000..ca135cd5 --- /dev/null +++ b/.icons/nexus-repository.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 97a33d9f..4f46eb67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -124,18 +124,23 @@ This script generates: - Accurate description and usage examples - Correct icon path (usually `../../../../.icons/your-icon.svg`) - Proper tags that describe your module -3. **Create at least one `.tftest.hcl`** to test your module with `terraform test` +3. **Create tests for your module:** + - **Terraform tests**: Create a `*.tftest.hcl` file and test with `terraform test` + - **TypeScript tests**: Create `main.test.ts` file if your module runs scripts or has business logic that Terraform tests can't cover 4. **Add any scripts** or additional files your module needs ### 4. Test and Submit ```bash -# Test your module (from the module directory) +# Test your module +cd registry/[namespace]/modules/[module-name] + +# Required: Test Terraform functionality terraform init -upgrade terraform test -verbose -# Or run all tests in the repo -./scripts/terraform_test_all.sh +# Optional: Test TypeScript files if you have main.test.ts +bun test main.test.ts # Format code bun run fmt @@ -343,8 +348,8 @@ coder templates push test-[template-name] -d . terraform init -upgrade terraform test -verbose -# Test all modules -./scripts/terraform_test_all.sh +# Optional: If you have TypeScript tests +bun test main.test.ts ``` ### 3. Maintain Backward Compatibility @@ -393,7 +398,9 @@ Example: `https://github.com/coder/registry/compare/main...your-branch?template= ### Every Module Must Have - `main.tf` - Terraform code -- One or more `.tftest.hcl` files - Working tests with `terraform test` +- **Tests**: + - `*.tftest.hcl` files with `terraform test` (to test terraform specific logic) + - `main.test.ts` file with `bun test` (to test business logic, i.e., `coder_script` to install a package.) - `README.md` - Documentation with frontmatter ### Every Template Must Have @@ -493,6 +500,10 @@ When reporting bugs, include: 2. **No tests** or broken tests 3. **Hardcoded values** instead of variables 4. **Breaking changes** without defaults -5. **Not running** formatting (`bun run fmt`) and tests (`terraform test`) before submitting +5. **Not running** formatting (`bun run fmt`) and tests (`terraform test`, and `bun test main.test.ts` if applicable) before submitting + +## For Maintainers + +Guidelines for reviewing PRs, managing releases, and maintaining the registry. [See the maintainer guide for detailed information.](./MAINTAINER.md) Happy contributing! πŸš€ diff --git a/MAINTAINER.md b/MAINTAINER.md index 7d80d2fa..d37ea26d 100644 --- a/MAINTAINER.md +++ b/MAINTAINER.md @@ -23,6 +23,7 @@ Check that PRs have: - [ ] Working tests (`terraform test`) - [ ] Formatted code (`bun run fmt`) - [ ] Avatar image for new namespaces (`avatar.png` or `avatar.svg` in `.images/`) +- [ ] Version label: `version:patch`, `version:minor`, or `version:major` ### Version Guidelines @@ -32,7 +33,8 @@ When reviewing PRs, ensure the version change follows semantic versioning: - **Minor** (1.2.3 β†’ 1.3.0): New features, adding inputs - **Major** (1.2.3 β†’ 2.0.0): Breaking changes (removing inputs, changing types) -PRs should clearly indicate the version change (e.g., `v1.2.3 β†’ v1.2.4`). +PRs should clearly indicate the intended version change (e.g., `v1.2.3 β†’ v1.2.4`) and include the appropriate label: `version:patch`, `version:minor`, or `version:major`. +The β€œVersion Bump” CI uses this label to validate required updates (README version refs, etc.). ### Validate READMEs diff --git a/README.md b/README.md index 23746bd6..97f4677e 100644 --- a/README.md +++ b/README.md @@ -48,3 +48,7 @@ Simply include that snippet inside your Coder template, defining any data depend ## Contributing We are always accepting new contributions. [Please see our contributing guide for more information.](./CONTRIBUTING.md) + +## For Maintainers + +Guidelines for maintainers reviewing PRs and managing releases. [See the maintainer guide for more information.](./MAINTAINER.md) diff --git a/examples/modules/MODULE_NAME.tftest.hcl b/examples/modules/MODULE_NAME.tftest.hcl index 6f11666b..a6ccc524 100644 --- a/examples/modules/MODULE_NAME.tftest.hcl +++ b/examples/modules/MODULE_NAME.tftest.hcl @@ -15,7 +15,7 @@ run "app_url_uses_port" { } assert { - condition = resource.coder_app.MODULE_NAME.url == "http://localhost:19999" - error_message = "Expected MODULE_NAME app URL to include configured port" + condition = resource.coder_app.module_name.url == "http://localhost:19999" + error_message = "Expected module-name app URL to include configured port" } } diff --git a/examples/modules/main.tf b/examples/modules/main.tf index 628eb1da..c0acfbc3 100644 --- a/examples/modules/main.tf +++ b/examples/modules/main.tf @@ -35,13 +35,13 @@ variable "agent_id" { variable "log_path" { type = string - description = "The path to log MODULE_NAME to." - default = "/tmp/MODULE_NAME.log" + description = "The path to the module log file." + default = "/tmp/module_name.log" } variable "port" { type = number - description = "The port to run MODULE_NAME on." + description = "The port to run the application on." default = 19999 } @@ -59,9 +59,9 @@ variable "order" { # Add other variables here -resource "coder_script" "MODULE_NAME" { +resource "coder_script" "module_name" { agent_id = var.agent_id - display_name = "MODULE_NAME" + display_name = "Module Name" icon = local.icon_url script = templatefile("${path.module}/run.sh", { LOG_PATH : var.log_path, @@ -70,10 +70,10 @@ resource "coder_script" "MODULE_NAME" { run_on_stop = false } -resource "coder_app" "MODULE_NAME" { +resource "coder_app" "module_name" { agent_id = var.agent_id - slug = "MODULE_NAME" - display_name = "MODULE_NAME" + slug = "module-name" + display_name = "Module Name" url = "http://localhost:${var.port}" icon = local.icon_url subdomain = false @@ -88,10 +88,10 @@ resource "coder_app" "MODULE_NAME" { } } -data "coder_parameter" "MODULE_NAME" { - type = "list(string)" - name = "MODULE_NAME" - display_name = "MODULE_NAME" +data "coder_parameter" "module_name" { + type = "string" + name = "module_name" + display_name = "Module Name" icon = local.icon_url mutable = var.mutable default = local.options["Option 1"]["value"] diff --git a/registry/coder-labs/modules/auggie/README.md b/registry/coder-labs/modules/auggie/README.md index 4b8e9315..6cb7102d 100644 --- a/registry/coder-labs/modules/auggie/README.md +++ b/registry/coder-labs/modules/auggie/README.md @@ -13,7 +13,7 @@ Run Auggie CLI in your workspace to access Augment's AI coding assistant with ad ```tf module "auggie" { source = "registry.coder.com/coder-labs/auggie/coder" - version = "0.1.0" + version = "0.2.0" agent_id = coder_agent.example.id folder = "/home/coder/project" } @@ -47,7 +47,7 @@ module "coder-login" { module "auggie" { source = "registry.coder.com/coder-labs/auggie/coder" - version = "0.1.0" + version = "0.2.0" agent_id = coder_agent.example.id folder = "/home/coder/project" @@ -103,7 +103,7 @@ EOF ```tf module "auggie" { source = "registry.coder.com/coder-labs/auggie/coder" - version = "0.1.0" + version = "0.2.0" agent_id = coder_agent.example.id folder = "/home/coder/project" diff --git a/registry/coder-labs/modules/auggie/main.tf b/registry/coder-labs/modules/auggie/main.tf index d6c326e0..5e268ab9 100644 --- a/registry/coder-labs/modules/auggie/main.tf +++ b/registry/coder-labs/modules/auggie/main.tf @@ -66,7 +66,7 @@ variable "install_agentapi" { variable "agentapi_version" { type = string description = "The version of AgentAPI to install." - default = "v0.6.0" + default = "v0.10.0" validation { condition = can(regex("^v[0-9]+\\.[0-9]+\\.[0-9]+", var.agentapi_version)) error_message = "agentapi_version must be a valid semantic version starting with 'v', like 'v0.3.3'." @@ -178,7 +178,7 @@ locals { module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "1.1.1" + version = "1.2.0" agent_id = var.agent_id web_app_slug = local.app_slug diff --git a/registry/coder-labs/modules/codex/README.md b/registry/coder-labs/modules/codex/README.md index 62c63a32..549721ec 100644 --- a/registry/coder-labs/modules/codex/README.md +++ b/registry/coder-labs/modules/codex/README.md @@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "2.0.0" + version = "2.1.1" agent_id = coder_agent.example.id openai_api_key = var.openai_api_key folder = "/home/coder/project" @@ -33,7 +33,7 @@ module "codex" { module "codex" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder-labs/codex/coder" - version = "2.0.0" + version = "2.1.1" agent_id = coder_agent.example.id openai_api_key = "..." folder = "/home/coder/project" @@ -60,7 +60,7 @@ module "coder-login" { module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "2.0.0" + version = "2.1.1" agent_id = coder_agent.example.id openai_api_key = "..." ai_prompt = data.coder_parameter.ai_prompt.value @@ -106,7 +106,7 @@ For custom Codex configuration, use `base_config_toml` and/or `additional_mcp_se ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "2.0.0" + version = "2.1.1" # ... other variables ... # Override default configuration diff --git a/registry/coder-labs/modules/codex/main.tf b/registry/coder-labs/modules/codex/main.tf index 9fdec401..460d5af9 100644 --- a/registry/coder-labs/modules/codex/main.tf +++ b/registry/coder-labs/modules/codex/main.tf @@ -80,7 +80,7 @@ variable "install_agentapi" { variable "agentapi_version" { type = string description = "The version of AgentAPI to install." - default = "v0.5.0" + default = "v0.10.0" } variable "codex_model" { @@ -128,9 +128,10 @@ locals { module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "1.1.1" + version = "1.2.0" agent_id = var.agent_id + folder = var.folder web_app_slug = local.app_slug web_app_order = var.order web_app_group = var.group diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md new file mode 100644 index 00000000..83f59c7c --- /dev/null +++ b/registry/coder-labs/modules/copilot/README.md @@ -0,0 +1,210 @@ +--- +display_name: Copilot CLI +description: GitHub Copilot CLI agent for AI-powered terminal assistance +icon: ../../../../.icons/github.svg +verified: false +tags: [agent, copilot, ai, github, tasks] +--- + +# Copilot + +Run [GitHub Copilot CLI](https://docs.github.com/copilot/concepts/agents/about-copilot-cli) in your workspace for AI-powered coding assistance directly from the terminal. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for task reporting in the Coder UI. + +```tf +module "copilot" { + source = "registry.coder.com/coder-labs/copilot/coder" + version = "0.2.1" + agent_id = coder_agent.example.id + workdir = "/home/coder/projects" +} +``` + +> [!IMPORTANT] +> This example assumes you have [Coder external authentication](https://coder.com/docs/admin/external-auth) configured with `id = "github"`. If not, you can provide a direct token using the `github_token` variable or provide the correct external authentication id for GitHub by setting `external_auth_id = "my-github"`. + +> [!NOTE] +> By default, this module is configured to run the embedded chat interface as a path-based application. In production, we recommend that you configure a [wildcard access URL](https://coder.com/docs/admin/setup#wildcard-access-url) and set `subdomain = true`. See [here](https://coder.com/docs/tutorials/best-practices/security-best-practices#disable-path-based-apps) for more details. + +## Prerequisites + +- **Node.js v22+** and **npm v10+** +- **[Active Copilot subscription](https://docs.github.com/en/copilot/about-github-copilot/subscription-plans-for-github-copilot)** (GitHub Copilot Pro, Pro+, Business, or Enterprise) +- **GitHub authentication** via one of: + - [Coder external authentication](https://coder.com/docs/admin/external-auth) (recommended) + - Direct token via `github_token` variable + - Interactive login in Copilot + +## Examples + +### Usage with Tasks + +For development environments where you want Copilot to have full access to tools and automatically resume sessions: + +```tf +data "coder_parameter" "ai_prompt" { + type = "string" + name = "AI Prompt" + default = "" + description = "Initial task prompt for Copilot." + mutable = true +} + +module "copilot" { + source = "registry.coder.com/coder-labs/copilot/coder" + version = "0.2.1" + agent_id = coder_agent.example.id + workdir = "/home/coder/projects" + + ai_prompt = data.coder_parameter.ai_prompt.value + copilot_model = "claude-sonnet-4.5" + allow_all_tools = true + resume_session = true + + trusted_directories = ["/home/coder/projects", "/tmp"] +} +``` + +### Advanced Configuration + +Customize tool permissions, MCP servers, and Copilot settings: + +```tf +module "copilot" { + source = "registry.coder.com/coder-labs/copilot/coder" + version = "0.2.1" + agent_id = coder_agent.example.id + workdir = "/home/coder/projects" + + # Version pinning (defaults to "0.0.334", use "latest" for newest version) + copilot_version = "latest" + + # Tool permissions + allow_tools = ["shell(git)", "shell(npm)", "write"] + trusted_directories = ["/home/coder/projects", "/tmp"] + + # Custom Copilot configuration + copilot_config = jsonencode({ + banner = "never" + theme = "dark" + }) + + # MCP server configuration + mcp_config = jsonencode({ + mcpServers = { + filesystem = { + command = "npx" + args = ["-y", "@modelcontextprotocol/server-filesystem", "/home/coder/projects"] + description = "Provides file system access to the workspace" + name = "Filesystem" + timeout = 3000 + type = "local" + tools = ["*"] + trust = true + } + playwright = { + command = "npx" + args = ["-y", "@playwright/mcp@latest", "--headless", "--isolated"] + description = "Browser automation for testing and previewing changes" + name = "Playwright" + timeout = 5000 + type = "local" + tools = ["*"] + trust = false + } + } + }) + + # Pre-install Node.js if needed + pre_install_script = <<-EOT + #!/bin/bash + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + sudo apt-get install -y nodejs + EOT +} +``` + +> [!NOTE] +> GitHub Copilot CLI does not automatically install MCP servers. You have two options: +> +> - Use `npx -y` in the MCP config (shown above) to auto-install on each run +> - Pre-install MCP servers in `pre_install_script` for faster startup (e.g., `npm install -g @modelcontextprotocol/server-filesystem`) + +### Direct Token Authentication + +Use this example when you want to provide a GitHub Personal Access Token instead of using Coder external auth: + +```tf +variable "github_token" { + type = string + description = "GitHub Personal Access Token" + sensitive = true +} + +module "copilot" { + source = "registry.coder.com/coder-labs/copilot/coder" + version = "0.2.1" + agent_id = coder_agent.example.id + workdir = "/home/coder/projects" + github_token = var.github_token +} +``` + +### Standalone Mode + +Run Copilot as a command-line tool without task reporting or web interface. This installs and configures Copilot, making it available as a CLI app in the Coder agent bar that you can launch to interact with Copilot directly from your terminal. Set `report_tasks = false` to disable integration with Coder Tasks. + +```tf +module "copilot" { + source = "registry.coder.com/coder-labs/copilot/coder" + version = "0.2.1" + agent_id = coder_agent.example.id + workdir = "/home/coder" + report_tasks = false + cli_app = true +} +``` + +## Authentication + +The module supports multiple authentication methods (in priority order): + +1. **[Coder External Auth](https://coder.com/docs/admin/external-auth) (Recommended)** - Automatic if GitHub external auth is configured in Coder +2. **Direct Token** - Pass `github_token` variable (OAuth or Personal Access Token) +3. **Interactive** - Copilot prompts for login via `/login` command if no auth found + +> [!NOTE] +> OAuth tokens work best with Copilot. Personal Access Tokens may have limited functionality. + +## Session Resumption + +By default, the module resumes the latest Copilot session when the workspace restarts. Set `resume_session = false` to always start fresh sessions. + +> [!NOTE] +> Session resumption requires persistent storage for the home directory or workspace volume. Without persistent storage, sessions will not resume across workspace restarts. + +## Troubleshooting + +If you encounter any issues, check the log files in the `~/.copilot-module` directory within your workspace for detailed information. + +```bash +# Installation logs +cat ~/.copilot-module/install.log + +# Startup logs +cat ~/.copilot-module/agentapi-start.log + +# Pre/post install script logs +cat ~/.copilot-module/pre_install.log +cat ~/.copilot-module/post_install.log +``` + +> [!NOTE] +> To use tasks with Copilot, you must have an active GitHub Copilot subscription. +> The `workdir` variable is required and specifies the directory where Copilot will run. + +## References + +- [GitHub Copilot CLI Documentation](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) +- [Installing GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli) +- [AgentAPI Documentation](https://github.com/coder/agentapi) +- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents) diff --git a/registry/coder-labs/modules/copilot/copilot.tftest.hcl b/registry/coder-labs/modules/copilot/copilot.tftest.hcl new file mode 100644 index 00000000..185c019b --- /dev/null +++ b/registry/coder-labs/modules/copilot/copilot.tftest.hcl @@ -0,0 +1,236 @@ +run "defaults_are_correct" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = var.copilot_model == "claude-sonnet-4.5" + error_message = "Default model should be 'claude-sonnet-4.5'" + } + + assert { + condition = var.report_tasks == true + error_message = "Task reporting should be enabled by default" + } + + assert { + condition = var.resume_session == true + error_message = "Session resumption should be enabled by default" + } + + assert { + condition = var.allow_all_tools == false + error_message = "allow_all_tools should be disabled by default" + } + + assert { + condition = resource.coder_env.mcp_app_status_slug.name == "CODER_MCP_APP_STATUS_SLUG" + error_message = "Status slug env var should be created" + } + + assert { + condition = resource.coder_env.mcp_app_status_slug.value == "copilot" + error_message = "Status slug value should be 'copilot'" + } +} + +run "github_token_creates_env_var" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + github_token = "test_github_token_abc123" + } + + assert { + condition = length(resource.coder_env.github_token) == 1 + error_message = "github_token env var should be created when token is provided" + } + + assert { + condition = resource.coder_env.github_token[0].name == "GITHUB_TOKEN" + error_message = "github_token env var name should be 'GITHUB_TOKEN'" + } + + assert { + condition = resource.coder_env.github_token[0].value == "test_github_token_abc123" + error_message = "github_token env var value should match input" + } +} + +run "github_token_not_created_when_empty" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + github_token = "" + } + + assert { + condition = length(resource.coder_env.github_token) == 0 + error_message = "github_token env var should not be created when empty" + } +} + +run "copilot_model_env_var_for_non_default" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + copilot_model = "claude-sonnet-4" + } + + assert { + condition = length(resource.coder_env.copilot_model) == 1 + error_message = "copilot_model env var should be created for non-default model" + } + + assert { + condition = resource.coder_env.copilot_model[0].name == "COPILOT_MODEL" + error_message = "copilot_model env var name should be 'COPILOT_MODEL'" + } + + assert { + condition = resource.coder_env.copilot_model[0].value == "claude-sonnet-4" + error_message = "copilot_model env var value should match input" + } +} + +run "copilot_model_not_created_for_default" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + copilot_model = "claude-sonnet-4.5" + } + + assert { + condition = length(resource.coder_env.copilot_model) == 0 + error_message = "copilot_model env var should not be created for default model" + } +} + +run "model_validation_accepts_valid_models" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + copilot_model = "gpt-5" + } + + assert { + condition = contains(["claude-sonnet-4", "claude-sonnet-4.5", "gpt-5"], var.copilot_model) + error_message = "Model should be one of the valid options" + } +} + +run "copilot_config_merges_with_trusted_directories" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder/project" + trusted_directories = ["/workspace", "/data"] + } + + assert { + condition = length(local.final_copilot_config) > 0 + error_message = "final_copilot_config should be computed" + } + + # Verify workdir is trimmed of trailing slash + assert { + condition = local.workdir == "/home/coder/project" + error_message = "workdir should be trimmed of trailing slash" + } +} + +run "custom_copilot_config_overrides_default" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + copilot_config = jsonencode({ + banner = "always" + theme = "dark" + }) + } + + assert { + condition = var.copilot_config != "" + error_message = "Custom copilot config should be set" + } + + assert { + condition = jsondecode(local.final_copilot_config).banner == "always" + error_message = "Custom banner setting should be applied" + } + + assert { + condition = jsondecode(local.final_copilot_config).theme == "dark" + error_message = "Custom theme setting should be applied" + } +} + +run "trusted_directories_merged_with_custom_config" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder/project" + copilot_config = jsonencode({ + banner = "always" + theme = "dark" + trusted_folders = ["/custom"] + }) + trusted_directories = ["/workspace", "/data"] + } + + assert { + condition = contains(jsondecode(local.final_copilot_config).trusted_folders, "/custom") + error_message = "Custom trusted folder should be included" + } + + assert { + condition = contains(jsondecode(local.final_copilot_config).trusted_folders, "/home/coder/project") + error_message = "Workdir should be included in trusted folders" + } + + assert { + condition = contains(jsondecode(local.final_copilot_config).trusted_folders, "/workspace") + error_message = "trusted_directories should be merged into config" + } + + assert { + condition = contains(jsondecode(local.final_copilot_config).trusted_folders, "/data") + error_message = "All trusted_directories should be merged into config" + } +} + +run "app_slug_is_consistent" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = local.app_slug == "copilot" + error_message = "app_slug should be 'copilot'" + } + + assert { + condition = local.module_dir_name == ".copilot-module" + error_message = "module_dir_name should be '.copilot-module'" + } +} diff --git a/registry/coder-labs/modules/copilot/main.test.ts b/registry/coder-labs/modules/copilot/main.test.ts new file mode 100644 index 00000000..1d438e33 --- /dev/null +++ b/registry/coder-labs/modules/copilot/main.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "bun:test"; +import { + findResourceInstance, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("copilot", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder", + }); + + it("creates mcp_app_status_slug env var", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder", + }); + + const statusSlugEnv = findResourceInstance( + state, + "coder_env", + "mcp_app_status_slug", + ); + expect(statusSlugEnv).toBeDefined(); + expect(statusSlugEnv.name).toBe("CODER_MCP_APP_STATUS_SLUG"); + expect(statusSlugEnv.value).toBe("copilot"); + }); + + it("creates github_token env var with correct value", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder", + github_token: "test_token_12345", + }); + + const githubTokenEnv = findResourceInstance( + state, + "coder_env", + "github_token", + ); + expect(githubTokenEnv).toBeDefined(); + expect(githubTokenEnv.name).toBe("GITHUB_TOKEN"); + expect(githubTokenEnv.value).toBe("test_token_12345"); + }); + + it("does not create github_token env var when empty", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder", + github_token: "", + }); + + const githubTokenEnvs = state.resources.filter( + (r) => r.type === "coder_env" && r.name === "github_token", + ); + expect(githubTokenEnvs.length).toBe(0); + }); + + it("creates copilot_model env var for non-default models", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder", + copilot_model: "claude-sonnet-4", + }); + + const modelEnv = findResourceInstance(state, "coder_env", "copilot_model"); + expect(modelEnv).toBeDefined(); + expect(modelEnv.name).toBe("COPILOT_MODEL"); + expect(modelEnv.value).toBe("claude-sonnet-4"); + }); + + it("does not create copilot_model env var for default model", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder", + copilot_model: "claude-sonnet-4.5", + }); + + const modelEnvs = state.resources.filter( + (r) => r.type === "coder_env" && r.name === "copilot_model", + ); + expect(modelEnvs.length).toBe(0); + }); + + it("creates coder_script resources via agentapi module", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder", + }); + + // The agentapi module should create coder_script resources for install and start + const scripts = state.resources.filter((r) => r.type === "coder_script"); + expect(scripts.length).toBeGreaterThan(0); + }); + + it("validates copilot_model accepts valid values", async () => { + // Test valid models don't throw errors + await expect( + runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder", + copilot_model: "gpt-5", + }), + ).resolves.toBeDefined(); + + await expect( + runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder", + copilot_model: "claude-sonnet-4.5", + }), + ).resolves.toBeDefined(); + }); + + it("merges trusted_directories with custom copilot_config", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder/project", + trusted_directories: JSON.stringify(["/workspace", "/data"]), + copilot_config: JSON.stringify({ + banner: "always", + theme: "dark", + trusted_folders: ["/custom"], + }), + }); + + // Verify that the state was created successfully with the merged config + // The actual merging logic is tested in the .tftest.hcl file + expect(state).toBeDefined(); + expect(state.resources).toBeDefined(); + }); +}); diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf new file mode 100644 index 00000000..eb9f78d4 --- /dev/null +++ b/registry/coder-labs/modules/copilot/main.tf @@ -0,0 +1,302 @@ +terraform { + required_version = ">= 1.0" + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.7" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "workdir" { + type = string + description = "The folder to run Copilot in." +} + +variable "external_auth_id" { + type = string + description = "ID of the GitHub external auth provider configured in Coder." + default = "github" +} + +variable "github_token" { + type = string + description = "GitHub OAuth token or Personal Access Token. If provided, this will be used instead of auto-detecting authentication." + default = "" + sensitive = true +} + +variable "copilot_model" { + type = string + description = "Model to use. Supported values: claude-sonnet-4, claude-sonnet-4.5 (default), gpt-5." + default = "claude-sonnet-4.5" + validation { + condition = contains(["claude-sonnet-4", "claude-sonnet-4.5", "gpt-5"], var.copilot_model) + error_message = "copilot_model must be one of: claude-sonnet-4, claude-sonnet-4.5, gpt-5." + } +} + +variable "copilot_config" { + type = string + description = "Custom Copilot configuration as JSON string. Leave empty to use default configuration with banner disabled, theme set to auto, and workdir as trusted folder." + default = "" +} + +variable "ai_prompt" { + type = string + description = "Initial task prompt for programmatic mode." + default = "" +} + +variable "system_prompt" { + type = string + description = "The system prompt to use for the Copilot server. Task reporting instructions are automatically added when report_tasks is enabled." + default = "You are a helpful coding assistant that helps developers write, debug, and understand code. Provide clear explanations, follow best practices, and help solve coding problems efficiently." +} + +variable "trusted_directories" { + type = list(string) + description = "Additional directories to trust for Copilot operations." + default = [] +} + +variable "allow_all_tools" { + type = bool + description = "Allow all tools without prompting (equivalent to --allow-all-tools)." + default = false +} + +variable "allow_tools" { + type = list(string) + description = "Specific tools to allow: shell(command), write, or MCP_SERVER_NAME." + default = [] +} + +variable "deny_tools" { + type = list(string) + description = "Specific tools to deny: shell(command), write, or MCP_SERVER_NAME." + default = [] +} + +variable "mcp_config" { + type = string + description = "Custom MCP server configuration as JSON string." + default = "" +} + +variable "install_agentapi" { + type = bool + description = "Whether to install AgentAPI." + default = true +} + +variable "agentapi_version" { + type = string + description = "The version of AgentAPI to install." + default = "v0.10.0" +} + +variable "copilot_version" { + type = string + description = "The version of GitHub Copilot CLI to install. Use 'latest' for the latest version or specify a version like '0.0.334'." + default = "0.0.334" +} + +variable "report_tasks" { + type = bool + description = "Whether to enable task reporting to Coder UI via AgentAPI." + default = true +} + +variable "subdomain" { + type = bool + description = "Whether to use a subdomain for AgentAPI." + default = false +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation." + default = null +} + +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "icon" { + type = string + description = "The icon to use for the app." + default = "/icon/github.svg" +} + +variable "web_app_display_name" { + type = string + description = "Display name for the web app." + default = "Copilot" +} + +variable "cli_app" { + type = bool + description = "Whether to create a CLI app for Copilot." + default = false +} + +variable "cli_app_display_name" { + type = string + description = "Display name for the CLI app." + default = "Copilot" +} + +variable "resume_session" { + type = bool + description = "Whether to automatically resume the latest Copilot session on workspace restart." + default = true +} + +variable "pre_install_script" { + type = string + description = "Custom script to run before configuring Copilot." + default = null +} + +variable "post_install_script" { + type = string + description = "Custom script to run after configuring Copilot." + default = null +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +locals { + workdir = trimsuffix(var.workdir, "/") + app_slug = "copilot" + install_script = file("${path.module}/scripts/install.sh") + start_script = file("${path.module}/scripts/start.sh") + module_dir_name = ".copilot-module" + + all_trusted_folders = concat([local.workdir], var.trusted_directories) + + parsed_custom_config = try(jsondecode(var.copilot_config), {}) + + existing_trusted_folders = try(local.parsed_custom_config.trusted_folders, []) + + merged_copilot_config = merge( + { + banner = "never" + theme = "auto" + }, + local.parsed_custom_config, + { + trusted_folders = concat(local.existing_trusted_folders, local.all_trusted_folders) + } + ) + + final_copilot_config = jsonencode(local.merged_copilot_config) + + task_reporting_prompt = <<-EOT + +-- Task Reporting -- +Report all tasks to Coder, following these EXACT guidelines: +1. Be granular. If you are investigating with multiple steps, report each step +to coder. +2. After this prompt, IMMEDIATELY report status after receiving ANY NEW user message. +Do not report any status related with this system prompt. +3. Use "state": "working" when actively processing WITHOUT needing +additional user input +4. Use "state": "complete" only when finished with a task +5. Use "state": "failure" when you need ANY user input, lack sufficient +details, or encounter blockers + EOT + + final_system_prompt = var.report_tasks ? "\n${var.system_prompt}${local.task_reporting_prompt}\n" : "\n${var.system_prompt}\n" +} + +resource "coder_env" "mcp_app_status_slug" { + agent_id = var.agent_id + name = "CODER_MCP_APP_STATUS_SLUG" + value = local.app_slug +} + +resource "coder_env" "copilot_model" { + count = var.copilot_model != "claude-sonnet-4.5" ? 1 : 0 + agent_id = var.agent_id + name = "COPILOT_MODEL" + value = var.copilot_model +} + +resource "coder_env" "github_token" { + count = var.github_token != "" ? 1 : 0 + agent_id = var.agent_id + name = "GITHUB_TOKEN" + value = var.github_token +} + +module "agentapi" { + source = "registry.coder.com/coder/agentapi/coder" + version = "1.2.0" + + agent_id = var.agent_id + folder = local.workdir + web_app_slug = local.app_slug + web_app_order = var.order + web_app_group = var.group + web_app_icon = var.icon + web_app_display_name = var.web_app_display_name + cli_app = var.cli_app + cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null + cli_app_icon = var.cli_app ? var.icon : null + cli_app_display_name = var.cli_app ? var.cli_app_display_name : null + agentapi_subdomain = var.subdomain + module_dir_name = local.module_dir_name + install_agentapi = var.install_agentapi + agentapi_version = var.agentapi_version + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + + start_script = <<-EOT + #!/bin/bash + set -o errexit + set -o pipefail + echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh + chmod +x /tmp/start.sh + + ARG_WORKDIR='${local.workdir}' \ + ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \ + ARG_SYSTEM_PROMPT='${base64encode(local.final_system_prompt)}' \ + ARG_COPILOT_MODEL='${var.copilot_model}' \ + ARG_ALLOW_ALL_TOOLS='${var.allow_all_tools}' \ + ARG_ALLOW_TOOLS='${join(",", var.allow_tools)}' \ + ARG_DENY_TOOLS='${join(",", var.deny_tools)}' \ + ARG_TRUSTED_DIRECTORIES='${join(",", var.trusted_directories)}' \ + ARG_EXTERNAL_AUTH_ID='${var.external_auth_id}' \ + ARG_RESUME_SESSION='${var.resume_session}' \ + /tmp/start.sh + EOT + + install_script = <<-EOT + #!/bin/bash + set -o errexit + set -o pipefail + echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh + chmod +x /tmp/install.sh + + ARG_MCP_APP_STATUS_SLUG='${local.app_slug}' \ + ARG_REPORT_TASKS='${var.report_tasks}' \ + ARG_WORKDIR='${local.workdir}' \ + ARG_MCP_CONFIG='${var.mcp_config != "" ? base64encode(var.mcp_config) : ""}' \ + ARG_COPILOT_CONFIG='${base64encode(local.final_copilot_config)}' \ + ARG_EXTERNAL_AUTH_ID='${var.external_auth_id}' \ + ARG_COPILOT_VERSION='${var.copilot_version}' \ + ARG_COPILOT_MODEL='${var.copilot_model}' \ + /tmp/install.sh + EOT +} \ No newline at end of file diff --git a/registry/coder-labs/modules/copilot/scripts/install.sh b/registry/coder-labs/modules/copilot/scripts/install.sh new file mode 100644 index 00000000..0aabd761 --- /dev/null +++ b/registry/coder-labs/modules/copilot/scripts/install.sh @@ -0,0 +1,234 @@ +#!/bin/bash +set -euo pipefail + +source "$HOME"/.bashrc + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"} +ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true} +ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-} +ARG_MCP_CONFIG=$(echo -n "${ARG_MCP_CONFIG:-}" | base64 -d 2> /dev/null || echo "") +ARG_COPILOT_CONFIG=$(echo -n "${ARG_COPILOT_CONFIG:-}" | base64 -d 2> /dev/null || echo "") +ARG_EXTERNAL_AUTH_ID=${ARG_EXTERNAL_AUTH_ID:-github} +ARG_COPILOT_VERSION=${ARG_COPILOT_VERSION:-0.0.334} +ARG_COPILOT_MODEL=${ARG_COPILOT_MODEL:-claude-sonnet-4.5} + +validate_prerequisites() { + if ! command_exists node; then + echo "ERROR: Node.js not found. Copilot requires Node.js v22+." + echo "Install with: curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs" + exit 1 + fi + + if ! command_exists npm; then + echo "ERROR: npm not found. Copilot requires npm v10+." + exit 1 + fi + + node_version=$(node --version | sed 's/v//' | cut -d. -f1) + if [ "$node_version" -lt 22 ]; then + echo "WARNING: Node.js v$node_version detected. Copilot requires v22+." + fi +} + +install_copilot() { + if ! command_exists copilot; then + echo "Installing GitHub Copilot CLI (version: ${ARG_COPILOT_VERSION})..." + if [ "$ARG_COPILOT_VERSION" = "latest" ]; then + npm install -g @github/copilot + else + npm install -g "@github/copilot@${ARG_COPILOT_VERSION}" + fi + + if ! command_exists copilot; then + echo "ERROR: Failed to install Copilot" + exit 1 + fi + + echo "GitHub Copilot CLI installed successfully" + else + echo "GitHub Copilot CLI already installed" + fi +} + +check_github_authentication() { + echo "Checking GitHub authentication..." + + if [ -n "${GITHUB_TOKEN:-}" ]; then + echo "βœ“ GitHub token provided via module configuration" + return 0 + fi + + if command_exists coder; then + if coder external-auth access-token "${ARG_EXTERNAL_AUTH_ID:-github}" > /dev/null 2>&1; then + echo "βœ“ GitHub OAuth authentication via Coder external auth" + return 0 + fi + fi + + if command_exists gh && gh auth status > /dev/null 2>&1; then + echo "βœ“ GitHub OAuth authentication via GitHub CLI" + return 0 + fi + + echo "⚠ No GitHub authentication detected" + echo " Copilot will prompt for authentication when started" + echo " For seamless experience, configure GitHub external auth in Coder or run 'gh auth login'" + return 0 +} + +setup_copilot_configurations() { + mkdir -p "$ARG_WORKDIR" + + local module_path="$HOME/.copilot-module" + mkdir -p "$module_path" + + setup_copilot_config + + echo "$ARG_WORKDIR" > "$module_path/trusted_directories" +} + +setup_copilot_config() { + export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" + local copilot_config_dir="$XDG_CONFIG_HOME/.copilot" + local copilot_config_file="$copilot_config_dir/config.json" + local mcp_config_file="$copilot_config_dir/mcp-config.json" + + mkdir -p "$copilot_config_dir" + + if [ -n "$ARG_COPILOT_CONFIG" ]; then + echo "Setting up Copilot configuration..." + + if command_exists jq; then + echo "$ARG_COPILOT_CONFIG" | jq 'del(.mcpServers)' > "$copilot_config_file" + else + echo "$ARG_COPILOT_CONFIG" > "$copilot_config_file" + fi + + echo "Setting up MCP server configuration..." + setup_mcp_config "$mcp_config_file" + else + echo "ERROR: No Copilot configuration provided" + exit 1 + fi +} + +setup_mcp_config() { + local mcp_config_file="$1" + + echo '{"mcpServers": {}}' > "$mcp_config_file" + + if [ "$ARG_REPORT_TASKS" = "true" ] && [ -n "$ARG_MCP_APP_STATUS_SLUG" ]; then + echo "Adding Coder MCP server for task reporting..." + setup_coder_mcp_server "$mcp_config_file" + fi + + if [ -n "$ARG_MCP_CONFIG" ]; then + echo "Adding custom MCP servers..." + add_custom_mcp_servers "$mcp_config_file" + fi + + echo "MCP configuration completed: $mcp_config_file" +} + +setup_coder_mcp_server() { + local mcp_config_file="$1" + + local coder_mcp_wrapper_script + coder_mcp_wrapper_script=$( + cat << EOF +#!/usr/bin/env bash +set -e + +export CODER_MCP_APP_STATUS_SLUG="${ARG_MCP_APP_STATUS_SLUG}" +export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284" +export CODER_AGENT_URL="${CODER_AGENT_URL}" +export CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN}" + +exec coder exp mcp server +EOF + ) + echo "$coder_mcp_wrapper_script" > "/tmp/coder-mcp-server.sh" + chmod +x /tmp/coder-mcp-server.sh + + local coder_mcp_config + coder_mcp_config=$( + cat << EOF +{ + "mcpServers": { + "coder": { + "command": "/tmp/coder-mcp-server.sh", + "args": [], + "description": "CRITICAL: Call this tool immediately after receiving ANY user message to report task status. Rules: (1) Call FIRST before doing work - report what you will do with state='working'. (2) Be granular - report each step separately. (3) State 'working' = actively processing without needing user input. (4) State 'complete' = task 100% finished. (5) State 'failure' = need user input, missing info, or blocked. Example: User says 'fix the bug' -> call with state='working', description='Investigating authentication bug'. When done -> call with state='complete', description='Fixed token validation'. You MUST report on every interaction.", + "name": "Coder", + "timeout": 3000, + "type": "local", + "tools": ["*"], + "trust": true + } + } +} +EOF + ) + + echo "$coder_mcp_config" > "$mcp_config_file" +} + +add_custom_mcp_servers() { + local mcp_config_file="$1" + + if command_exists jq; then + local custom_servers + custom_servers=$(echo "$ARG_MCP_CONFIG" | jq '.mcpServers // {}') + + local updated_config + updated_config=$(jq --argjson custom "$custom_servers" '.mcpServers += $custom' "$mcp_config_file") + echo "$updated_config" > "$mcp_config_file" + elif command_exists node; then + node -e " + const fs = require('fs'); + const existing = JSON.parse(fs.readFileSync('$mcp_config_file', 'utf8')); + const input = JSON.parse(\`$ARG_MCP_CONFIG\`); + const custom = input.mcpServers || {}; + existing.mcpServers = {...existing.mcpServers, ...custom}; + fs.writeFileSync('$mcp_config_file', JSON.stringify(existing, null, 2)); + " + else + echo "WARNING: jq and node not available, cannot merge custom MCP servers" + fi +} + +configure_copilot_model() { + if [ -n "$ARG_COPILOT_MODEL" ] && [ "$ARG_COPILOT_MODEL" != "claude-sonnet-4.5" ]; then + echo "Setting Copilot model to: $ARG_COPILOT_MODEL" + copilot config model "$ARG_COPILOT_MODEL" || { + echo "WARNING: Failed to set model via copilot config, will use environment variable fallback" + export COPILOT_MODEL="$ARG_COPILOT_MODEL" + } + fi +} + +configure_coder_integration() { + if [ "$ARG_REPORT_TASKS" = "true" ] && [ -n "$ARG_MCP_APP_STATUS_SLUG" ]; then + echo "Configuring Copilot task reporting..." + export CODER_MCP_APP_STATUS_SLUG="$ARG_MCP_APP_STATUS_SLUG" + export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284" + echo "βœ“ Coder MCP server configured for task reporting" + else + echo "Task reporting disabled or no app status slug provided." + export CODER_MCP_APP_STATUS_SLUG="" + export CODER_MCP_AI_AGENTAPI_URL="" + fi +} + +validate_prerequisites +install_copilot +check_github_authentication +setup_copilot_configurations +configure_copilot_model +configure_coder_integration + +echo "Copilot module setup completed." diff --git a/registry/coder-labs/modules/copilot/scripts/start.sh b/registry/coder-labs/modules/copilot/scripts/start.sh new file mode 100644 index 00000000..2653d593 --- /dev/null +++ b/registry/coder-labs/modules/copilot/scripts/start.sh @@ -0,0 +1,157 @@ +#!/bin/bash +set -euo pipefail + +source "$HOME"/.bashrc +export PATH="$HOME/.local/bin:$PATH" + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"} +ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d 2> /dev/null || echo "") +ARG_SYSTEM_PROMPT=$(echo -n "${ARG_SYSTEM_PROMPT:-}" | base64 -d 2> /dev/null || echo "") +ARG_COPILOT_MODEL=${ARG_COPILOT_MODEL:-} +ARG_ALLOW_ALL_TOOLS=${ARG_ALLOW_ALL_TOOLS:-false} +ARG_ALLOW_TOOLS=${ARG_ALLOW_TOOLS:-} +ARG_DENY_TOOLS=${ARG_DENY_TOOLS:-} +ARG_TRUSTED_DIRECTORIES=${ARG_TRUSTED_DIRECTORIES:-} +ARG_EXTERNAL_AUTH_ID=${ARG_EXTERNAL_AUTH_ID:-github} +ARG_RESUME_SESSION=${ARG_RESUME_SESSION:-true} + +validate_copilot_installation() { + if ! command_exists copilot; then + echo "ERROR: Copilot not installed. Run: npm install -g @github/copilot" + exit 1 + fi +} + +build_initial_prompt() { + local initial_prompt="" + + if [ -n "$ARG_AI_PROMPT" ]; then + if [ -n "$ARG_SYSTEM_PROMPT" ]; then + initial_prompt="$ARG_SYSTEM_PROMPT + +$ARG_AI_PROMPT" + else + initial_prompt="$ARG_AI_PROMPT" + fi + fi + + echo "$initial_prompt" +} + +build_copilot_args() { + COPILOT_ARGS=() + + if [ "$ARG_ALLOW_ALL_TOOLS" = "true" ]; then + COPILOT_ARGS+=(--allow-all-tools) + fi + + if [ -n "$ARG_ALLOW_TOOLS" ]; then + IFS=',' read -ra ALLOW_ARRAY <<< "$ARG_ALLOW_TOOLS" + for tool in "${ALLOW_ARRAY[@]}"; do + if [ -n "$tool" ]; then + COPILOT_ARGS+=(--allow-tool "$tool") + fi + done + fi + + if [ -n "$ARG_DENY_TOOLS" ]; then + IFS=',' read -ra DENY_ARRAY <<< "$ARG_DENY_TOOLS" + for tool in "${DENY_ARRAY[@]}"; do + if [ -n "$tool" ]; then + COPILOT_ARGS+=(--deny-tool "$tool") + fi + done + fi +} + +check_existing_session() { + if [ "$ARG_RESUME_SESSION" = "true" ]; then + if copilot --help > /dev/null 2>&1; then + local session_dir="$HOME/.copilot/history-session-state" + if [ -d "$session_dir" ] && [ -n "$(ls "$session_dir"/session_*_*.json 2> /dev/null)" ]; then + echo "Found existing Copilot session. Will continue latest session." >&2 + return 0 + fi + fi + fi + return 1 +} + +setup_github_authentication() { + export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" + echo "Setting up GitHub authentication..." + + if [ -n "${GITHUB_TOKEN:-}" ]; then + export GH_TOKEN="$GITHUB_TOKEN" + echo "βœ“ Using GitHub token from module configuration" + return 0 + fi + + if command_exists coder; then + local github_token + if github_token=$(coder external-auth access-token "${ARG_EXTERNAL_AUTH_ID:-github}" 2> /dev/null); then + if [ -n "$github_token" ] && [ "$github_token" != "null" ]; then + export GITHUB_TOKEN="$github_token" + export GH_TOKEN="$github_token" + echo "βœ“ Using Coder external auth OAuth token" + return 0 + fi + fi + fi + + if command_exists gh && gh auth status > /dev/null 2>&1; then + echo "βœ“ Using GitHub CLI OAuth authentication" + return 0 + fi + + echo "⚠ No GitHub authentication available" + echo " Copilot will prompt for login during first use" + echo " Use the '/login' command in Copilot to authenticate" + return 0 +} + +start_agentapi() { + echo "Starting in directory: $ARG_WORKDIR" + cd "$ARG_WORKDIR" + + build_copilot_args + + if check_existing_session; then + echo "Continuing latest Copilot session..." + if [ ${#COPILOT_ARGS[@]} -gt 0 ]; then + echo "Copilot arguments: ${COPILOT_ARGS[*]}" + agentapi server --type copilot --term-width 120 --term-height 40 -- copilot --continue "${COPILOT_ARGS[@]}" + else + agentapi server --type copilot --term-width 120 --term-height 40 -- copilot --continue + fi + else + echo "Starting new Copilot session..." + local initial_prompt + initial_prompt=$(build_initial_prompt) + + if [ -n "$initial_prompt" ]; then + echo "Using initial prompt with system context" + if [ ${#COPILOT_ARGS[@]} -gt 0 ]; then + echo "Copilot arguments: ${COPILOT_ARGS[*]}" + agentapi server -I="$initial_prompt" --type copilot --term-width 120 --term-height 40 -- copilot "${COPILOT_ARGS[@]}" + else + agentapi server -I="$initial_prompt" --type copilot --term-width 120 --term-height 40 -- copilot + fi + else + if [ ${#COPILOT_ARGS[@]} -gt 0 ]; then + echo "Copilot arguments: ${COPILOT_ARGS[*]}" + agentapi server --type copilot --term-width 120 --term-height 40 -- copilot "${COPILOT_ARGS[@]}" + else + agentapi server --type copilot --term-width 120 --term-height 40 -- copilot + fi + fi + fi +} + +setup_github_authentication +validate_copilot_installation +start_agentapi diff --git a/registry/coder-labs/modules/copilot/testdata/copilot-mock.sh b/registry/coder-labs/modules/copilot/testdata/copilot-mock.sh new file mode 100644 index 00000000..f1daa15f --- /dev/null +++ b/registry/coder-labs/modules/copilot/testdata/copilot-mock.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -euo pipefail + +if [[ "$1" == "--version" ]]; then + echo "GitHub Copilot CLI v1.0.0" + exit 0 +fi + +while true; do + echo "$(date) - Copilot mock running..." + sleep 15 +done diff --git a/registry/coder-labs/modules/cursor-cli/README.md b/registry/coder-labs/modules/cursor-cli/README.md index 0d3cd753..92f045de 100644 --- a/registry/coder-labs/modules/cursor-cli/README.md +++ b/registry/coder-labs/modules/cursor-cli/README.md @@ -13,7 +13,7 @@ Run the Cursor Agent CLI in your workspace for interactive coding assistance and ```tf module "cursor_cli" { source = "registry.coder.com/coder-labs/cursor-cli/coder" - version = "0.1.1" + version = "0.2.0" agent_id = coder_agent.example.id folder = "/home/coder/project" } @@ -42,7 +42,7 @@ module "coder-login" { module "cursor_cli" { source = "registry.coder.com/coder-labs/cursor-cli/coder" - version = "0.1.1" + version = "0.2.0" agent_id = coder_agent.example.id folder = "/home/coder/project" diff --git a/registry/coder-labs/modules/cursor-cli/main.tf b/registry/coder-labs/modules/cursor-cli/main.tf index 48210371..c07193a6 100644 --- a/registry/coder-labs/modules/cursor-cli/main.tf +++ b/registry/coder-labs/modules/cursor-cli/main.tf @@ -56,7 +56,7 @@ variable "install_agentapi" { variable "agentapi_version" { type = string description = "The version of AgentAPI to install." - default = "v0.5.0" + default = "v0.10.0" } variable "force" { @@ -131,7 +131,7 @@ resource "coder_env" "cursor_api_key" { module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "1.1.1" + version = "1.2.0" agent_id = var.agent_id web_app_slug = local.app_slug diff --git a/registry/coder-labs/modules/gemini/README.md b/registry/coder-labs/modules/gemini/README.md index e661093e..1d499817 100644 --- a/registry/coder-labs/modules/gemini/README.md +++ b/registry/coder-labs/modules/gemini/README.md @@ -13,7 +13,7 @@ Run [Gemini CLI](https://github.com/google-gemini/gemini-cli) in your workspace ```tf module "gemini" { source = "registry.coder.com/coder-labs/gemini/coder" - version = "2.0.0" + version = "2.1.0" agent_id = coder_agent.example.id folder = "/home/coder/project" } @@ -46,7 +46,7 @@ variable "gemini_api_key" { module "gemini" { source = "registry.coder.com/coder-labs/gemini/coder" - version = "2.0.0" + version = "2.1.0" agent_id = coder_agent.example.id gemini_api_key = var.gemini_api_key folder = "/home/coder/project" @@ -94,7 +94,7 @@ data "coder_parameter" "ai_prompt" { module "gemini" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder-labs/gemini/coder" - version = "2.0.0" + version = "2.1.0" agent_id = coder_agent.example.id gemini_api_key = var.gemini_api_key gemini_model = "gemini-2.5-flash" @@ -118,7 +118,7 @@ For enterprise users who prefer Google's Vertex AI platform: ```tf module "gemini" { source = "registry.coder.com/coder-labs/gemini/coder" - version = "2.0.0" + version = "2.1.0" agent_id = coder_agent.example.id gemini_api_key = var.gemini_api_key folder = "/home/coder/project" diff --git a/registry/coder-labs/modules/gemini/main.tf b/registry/coder-labs/modules/gemini/main.tf index 889164b1..1dc25c6d 100644 --- a/registry/coder-labs/modules/gemini/main.tf +++ b/registry/coder-labs/modules/gemini/main.tf @@ -81,7 +81,7 @@ variable "install_agentapi" { variable "agentapi_version" { type = string description = "The version of AgentAPI to install." - default = "v0.2.3" + default = "v0.10.0" } variable "gemini_model" { @@ -176,7 +176,7 @@ EOT module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "1.1.1" + version = "1.2.0" agent_id = var.agent_id web_app_slug = local.app_slug diff --git a/registry/coder-labs/modules/sourcegraph-amp/README.md b/registry/coder-labs/modules/sourcegraph-amp/README.md index d4486e65..61607d9d 100644 --- a/registry/coder-labs/modules/sourcegraph-amp/README.md +++ b/registry/coder-labs/modules/sourcegraph-amp/README.md @@ -13,7 +13,7 @@ Run [Amp CLI](https://ampcode.com/) in your workspace to access Sourcegraph's AI ```tf module "amp-cli" { source = "registry.coder.com/coder-labs/sourcegraph-amp/coder" - version = "1.0.3" + version = "1.1.0" agent_id = coder_agent.example.id sourcegraph_amp_api_key = var.sourcegraph_amp_api_key install_sourcegraph_amp = true @@ -60,7 +60,7 @@ variable "sourcegraph_amp_api_key" { module "amp-cli" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder-labs/sourcegraph-amp/coder" - version = "1.0.3" + version = "1.1.0" agent_id = coder_agent.example.id sourcegraph_amp_api_key = var.sourcegraph_amp_api_key # recommended for authenticated usage install_sourcegraph_amp = true diff --git a/registry/coder-labs/modules/sourcegraph-amp/main.tf b/registry/coder-labs/modules/sourcegraph-amp/main.tf index 033fc84e..b5566fef 100644 --- a/registry/coder-labs/modules/sourcegraph-amp/main.tf +++ b/registry/coder-labs/modules/sourcegraph-amp/main.tf @@ -69,7 +69,7 @@ variable "install_agentapi" { variable "agentapi_version" { type = string description = "The version of AgentAPI to install." - default = "v0.3.0" + default = "v0.10.0" } variable "pre_install_script" { @@ -151,7 +151,7 @@ locals { module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "1.0.1" + version = "1.2.0" agent_id = var.agent_id web_app_slug = local.app_slug diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index 0b5ed0ba..d68af511 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI ```tf module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "1.1.1" + version = "1.2.0" agent_id = var.agent_id web_app_slug = local.app_slug diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf index 225a992d..e73f45f6 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -117,7 +117,7 @@ variable "install_agentapi" { variable "agentapi_version" { type = string description = "The version of AgentAPI to install." - default = "v0.3.3" + default = "v0.10.0" } variable "agentapi_port" { diff --git a/registry/coder/modules/amazon-q/README.md b/registry/coder/modules/amazon-q/README.md index 03551487..3146f01e 100644 --- a/registry/coder/modules/amazon-q/README.md +++ b/registry/coder/modules/amazon-q/README.md @@ -13,7 +13,7 @@ Run [Amazon Q](https://aws.amazon.com/q/) in your workspace to access Amazon's A ```tf module "amazon-q" { source = "registry.coder.com/coder/amazon-q/coder" - version = "2.0.0" + version = "2.1.1" agent_id = coder_agent.example.id workdir = "/home/coder" @@ -102,7 +102,7 @@ data "coder_parameter" "ai_prompt" { module "amazon-q" { source = "registry.coder.com/coder/amazon-q/coder" - version = "2.0.0" + version = "2.1.1" agent_id = coder_agent.example.id workdir = "/home/coder" auth_tarball = var.amazon_q_auth_tarball @@ -228,7 +228,7 @@ If no custom `agent_config` is provided, the default agent name "agent" is used. ```tf module "amazon-q" { source = "registry.coder.com/coder/amazon-q/coder" - version = "2.0.0" + version = "2.1.1" agent_id = coder_agent.example.id workdir = "/home/coder" auth_tarball = var.amazon_q_auth_tarball @@ -258,7 +258,7 @@ This example will: ```tf module "amazon-q" { source = "registry.coder.com/coder/amazon-q/coder" - version = "2.0.0" + version = "2.1.1" agent_id = coder_agent.example.id workdir = "/home/coder" auth_tarball = var.amazon_q_auth_tarball @@ -279,7 +279,7 @@ module "amazon-q" { ```tf module "amazon-q" { source = "registry.coder.com/coder/amazon-q/coder" - version = "2.0.0" + version = "2.1.1" agent_id = coder_agent.example.id workdir = "/home/coder" auth_tarball = var.amazon_q_auth_tarball @@ -305,7 +305,7 @@ module "amazon-q" { ```tf module "amazon-q" { source = "registry.coder.com/coder/amazon-q/coder" - version = "2.0.0" + version = "2.1.1" agent_id = coder_agent.example.id workdir = "/home/coder" auth_tarball = var.amazon_q_auth_tarball @@ -319,7 +319,7 @@ module "amazon-q" { ```tf module "amazon-q" { source = "registry.coder.com/coder/amazon-q/coder" - version = "2.0.0" + version = "2.1.1" agent_id = coder_agent.example.id workdir = "/home/coder" auth_tarball = var.amazon_q_auth_tarball @@ -340,14 +340,14 @@ module "amazon-q" { ```tf module "amazon-q" { source = "registry.coder.com/coder/amazon-q/coder" - version = "2.0.0" + version = "2.1.1" agent_id = coder_agent.example.id workdir = "/home/coder" auth_tarball = var.amazon_q_auth_tarball # AgentAPI configuration for environments without wildcard access url. https://coder.com/docs/admin/setup#wildcard-access-url agentapi_chat_based_path = true - agentapi_version = "v0.6.1" + agentapi_version = "v0.10.0" } ``` @@ -358,7 +358,7 @@ For environments without direct internet access, you can host Amazon Q installat ```tf module "amazon-q" { source = "registry.coder.com/coder/amazon-q/coder" - version = "2.0.0" + version = "2.1.1" agent_id = coder_agent.example.id workdir = "/home/coder" auth_tarball = var.amazon_q_auth_tarball diff --git a/registry/coder/modules/amazon-q/main.tf b/registry/coder/modules/amazon-q/main.tf index b845cedc..84ac3c03 100644 --- a/registry/coder/modules/amazon-q/main.tf +++ b/registry/coder/modules/amazon-q/main.tf @@ -88,7 +88,7 @@ variable "post_install_script" { variable "agentapi_version" { type = string description = "The version of AgentAPI to install." - default = "v0.6.1" + default = "v0.10.0" } variable "workdir" { @@ -96,8 +96,6 @@ variable "workdir" { description = "The folder to run Amazon Q in." } -# --------------------------------------------- - variable "install_amazon_q" { type = bool description = "Whether to install Amazon Q." @@ -190,6 +188,7 @@ resource "coder_env" "auth_tarball" { locals { app_slug = "amazonq" + workdir = trimsuffix(var.workdir, "/") install_script = file("${path.module}/scripts/install.sh") start_script = file("${path.module}/scripts/start.sh") module_dir_name = ".amazonq-module" @@ -215,9 +214,10 @@ locals { module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "1.1.1" + version = "1.2.0" agent_id = var.agent_id + folder = local.workdir web_app_slug = local.app_slug web_app_order = var.order web_app_group = var.group diff --git a/registry/coder/modules/amazon-q/scripts/install.sh b/registry/coder/modules/amazon-q/scripts/install.sh index e3ae3361..f8e43e76 100644 --- a/registry/coder/modules/amazon-q/scripts/install.sh +++ b/registry/coder/modules/amazon-q/scripts/install.sh @@ -94,6 +94,13 @@ function install_amazon_q() { function extract_auth_tarball() { if [ -n "$ARG_AUTH_TARBALL" ]; then echo "Extracting auth tarball..." + + if ! command_exists zstd; then + echo "Error: zstd is required to extract the authentication tarball but is not installed." + echo "Please install zstd using the pre_install_script parameter." + exit 1 + fi + PREV_DIR="$PWD" echo "$ARG_AUTH_TARBALL" | base64 -d > /tmp/auth.tar.zst rm -rf ~/.local/share/amazon-q diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 3c334e74..ed10e2a5 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 = "3.0.1" + version = "3.1.1" agent_id = coder_agent.example.id workdir = "/home/coder/project" claude_api_key = "xxxx-xxxxx-xxxx" @@ -49,7 +49,7 @@ data "coder_parameter" "ai_prompt" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "3.0.1" + version = "3.1.1" agent_id = coder_agent.example.id workdir = "/home/coder/project" @@ -58,7 +58,7 @@ module "claude-code" { claude_code_oauth_token = "xxxxx-xxxx-xxxx" claude_code_version = "1.0.82" # Pin to a specific version - agentapi_version = "v0.6.1" + agentapi_version = "v0.10.0" ai_prompt = data.coder_parameter.ai_prompt.value model = "sonnet" @@ -85,7 +85,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 = "3.0.1" + version = "3.1.1" agent_id = coder_agent.example.id workdir = "/home/coder" install_claude_code = true @@ -108,13 +108,168 @@ variable "claude_code_oauth_token" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "3.0.1" + version = "3.1.1" agent_id = coder_agent.example.id workdir = "/home/coder/project" claude_code_oauth_token = var.claude_code_oauth_token } ``` +### Usage with AWS Bedrock + +#### Prerequisites + +AWS account with Bedrock access, Claude models enabled in Bedrock console, appropriate IAM permissions. + +Configure Claude Code to use AWS Bedrock for accessing Claude models through your AWS infrastructure. + +```tf +resource "coder_env" "bedrock_use" { + agent_id = coder_agent.example.id + name = "CLAUDE_CODE_USE_BEDROCK" + value = "1" +} + +resource "coder_env" "aws_region" { + agent_id = coder_agent.example.id + name = "AWS_REGION" + value = "us-east-1" # Choose your preferred region +} + +# Option 1: Using AWS credentials + +variable "aws_access_key_id" { + type = string + description = "Your AWS access key ID. Create this in the AWS IAM console under 'Security credentials'." + sensitive = true + value = "xxxx-xxx-xxxx" +} + +variable "aws_secret_access_key" { + type = string + description = "Your AWS secret access key. This is shown once when you create an access key in the AWS IAM console." + sensitive = true + value = "xxxx-xxx-xxxx" +} + +resource "coder_env" "aws_access_key_id" { + agent_id = coder_agent.example.id + name = "AWS_ACCESS_KEY_ID" + value = var.aws_access_key_id +} + +resource "coder_env" "aws_secret_access_key" { + agent_id = coder_agent.example.id + name = "AWS_SECRET_ACCESS_KEY" + value = var.aws_secret_access_key +} + +# Option 2: Using Bedrock API key (simpler) + +variable "aws_bearer_token_bedrock" { + type = string + description = "Your AWS Bedrock bearer token. This provides access to Bedrock without needing separate access key and secret key." + sensitive = true + value = "xxxx-xxx-xxxx" +} + +resource "coder_env" "bedrock_api_key" { + agent_id = coder_agent.example.id + name = "AWS_BEARER_TOKEN_BEDROCK" + value = var.aws_bearer_token_bedrock +} + +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "3.1.1" + agent_id = coder_agent.example.id + workdir = "/home/coder/project" + model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" +} +``` + +> [!NOTE] +> For additional Bedrock configuration options (model selection, token limits, region overrides, etc.), see the [Claude Code Bedrock documentation](https://docs.claude.com/en/docs/claude-code/amazon-bedrock). + +### Usage with Google Vertex AI + +#### Prerequisites + +GCP project with Vertex AI API enabled, Claude models enabled through Model Garden, service account with Vertex AI permissions, appropriate IAM permissions (Vertex AI User role). + +Configure Claude Code to use Google Vertex AI for accessing Claude models through Google Cloud Platform. + +```tf +variable "vertex_sa_json" { + type = string + description = "The complete JSON content of your Google Cloud service account key file. Create a service account in the GCP Console under 'IAM & Admin > Service Accounts', then create and download a JSON key. Copy the entire JSON content into this variable." + sensitive = true +} + +resource "coder_env" "vertex_use" { + agent_id = coder_agent.example.id + name = "CLAUDE_CODE_USE_VERTEX" + value = "1" +} + +resource "coder_env" "vertex_project_id" { + agent_id = coder_agent.example.id + name = "ANTHROPIC_VERTEX_PROJECT_ID" + value = "your-gcp-project-id" +} + +resource "coder_env" "cloud_ml_region" { + agent_id = coder_agent.example.id + name = "CLOUD_ML_REGION" + value = "global" +} + +resource "coder_env" "vertex_sa_json" { + agent_id = coder_agent.example.id + name = "VERTEX_SA_JSON" + value = var.vertex_sa_json +} + +resource "coder_env" "google_application_credentials" { + agent_id = coder_agent.example.id + name = "GOOGLE_APPLICATION_CREDENTIALS" + value = "/tmp/gcp-sa.json" +} + +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "3.1.1" + agent_id = coder_agent.example.id + workdir = "/home/coder/project" + model = "claude-sonnet-4@20250514" + + pre_install_script = <<-EOT + #!/bin/bash + # Write the service account JSON to a file + echo "$VERTEX_SA_JSON" > /tmp/gcp-sa.json + + # Install prerequisite packages + sudo apt-get update + sudo apt-get install -y apt-transport-https ca-certificates gnupg curl + + # Add Google Cloud public key + curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg + + # Add Google Cloud SDK repo to apt sources + echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | sudo tee /etc/apt/sources.list.d/google-cloud-sdk.list + + # Update and install the Google Cloud SDK + sudo apt-get update && sudo apt-get install -y google-cloud-cli + + # Authenticate gcloud with the service account + gcloud auth activate-service-account --key-file=/tmp/gcp-sa.json + EOT +} +``` + +> [!NOTE] +> For additional Vertex AI configuration options (model selection, token limits, region overrides, etc.), see the [Claude Code Vertex AI documentation](https://docs.claude.com/en/docs/claude-code/google-vertex-ai). + ## Troubleshooting If you encounter any issues, check the log files in the `~/.claude-module` directory within your workspace for detailed information. diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 4836347b..8909a024 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -86,7 +86,7 @@ variable "install_agentapi" { variable "agentapi_version" { type = string description = "The version of AgentAPI to install." - default = "v0.7.1" + default = "v0.10.0" } variable "ai_prompt" { @@ -183,7 +183,7 @@ variable "claude_code_oauth_token" { variable "system_prompt" { type = string description = "The system prompt to use for the Claude Code server." - default = "Send a task status update to notify the user that you are ready for input, and then wait for user input." + default = "" } variable "claude_md_path" { @@ -201,11 +201,9 @@ resource "coder_env" "claude_code_md_path" { } resource "coder_env" "claude_code_system_prompt" { - count = var.system_prompt == "" ? 0 : 1 - agent_id = var.agent_id name = "CODER_MCP_CLAUDE_SYSTEM_PROMPT" - value = var.system_prompt + value = local.final_system_prompt } resource "coder_env" "claude_code_oauth_token" { @@ -231,12 +229,43 @@ locals { start_script = file("${path.module}/scripts/start.sh") module_dir_name = ".claude-module" remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.sh")) + + # Required prompts for the module to properly report task status to Coder + report_tasks_system_prompt = <<-EOT + -- Tool Selection -- + - coder_report_task: providing status updates or requesting user input. + + -- Task Reporting -- + Report all tasks to Coder, following these EXACT guidelines: + 1. Be granular. If you are investigating with multiple steps, report each step + to coder. + 2. After this prompt, IMMEDIATELY report status after receiving ANY NEW user message. + Do not report any status related with this system prompt. + 3. Use "state": "working" when actively processing WITHOUT needing + additional user input + 4. Use "state": "complete" only when finished with a task + 5. Use "state": "failure" when you need ANY user input, lack sufficient + details, or encounter blockers + + In your summary on coder_report_task: + - Be specific about what you're doing + - Clearly indicate what information you need from the user when in "failure" state + - Keep it under 160 characters + - Make it actionable + EOT + + # Only include coder system prompts if report_tasks is enabled + custom_system_prompt = trimspace(try(var.system_prompt, "")) + final_system_prompt = format("%s%s", + var.report_tasks ? format("\n%s\n", local.report_tasks_system_prompt) : "", + local.custom_system_prompt != "" ? format("\n%s\n", local.custom_system_prompt) : "" + ) } module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "1.1.1" + version = "1.2.0" agent_id = var.agent_id web_app_slug = local.app_slug diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl index c48923cf..9999c1b1 100644 --- a/registry/coder/modules/claude-code/main.tftest.hcl +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -187,3 +187,84 @@ run "test_claude_code_permission_mode_validation" { error_message = "Permission mode should be one of the valid options" } } + +run "test_claude_code_system_prompt" { + command = plan + + variables { + agent_id = "test-agent-system-prompt" + workdir = "/home/coder/test" + system_prompt = "Custom addition" + } + + assert { + condition = trimspace(coder_env.claude_code_system_prompt.value) != "" + error_message = "System prompt should not be empty" + } + + assert { + condition = length(regexall("Custom addition", coder_env.claude_code_system_prompt.value)) > 0 + error_message = "System prompt should have system_prompt variable value" + } +} + +run "test_claude_report_tasks_default" { + command = plan + + variables { + agent_id = "test-agent-report-tasks" + workdir = "/home/coder/test" + # report_tasks: default is true + } + + assert { + condition = trimspace(coder_env.claude_code_system_prompt.value) != "" + error_message = "System prompt should not be empty" + } + + # Ensure system prompt is wrapped by + assert { + condition = startswith(trimspace(coder_env.claude_code_system_prompt.value), "") + error_message = "System prompt should start with " + } + assert { + condition = endswith(trimspace(coder_env.claude_code_system_prompt.value), "") + error_message = "System prompt should end with " + } + + # Ensure Coder sections are injected when report_tasks=true (default) + assert { + condition = length(regexall("-- Tool Selection --", coder_env.claude_code_system_prompt.value)) > 0 + error_message = "System prompt should have Tool Selection section" + } + + assert { + condition = length(regexall("-- Task Reporting --", coder_env.claude_code_system_prompt.value)) > 0 + error_message = "System prompt should have Task Reporting section" + } +} + +run "test_claude_report_tasks_disabled" { + command = plan + + variables { + agent_id = "test-agent-report-tasks" + workdir = "/home/coder/test" + report_tasks = false + } + + assert { + condition = trimspace(coder_env.claude_code_system_prompt.value) != "" + error_message = "System prompt should not be empty" + } + + # Ensure system prompt is wrapped by + assert { + condition = startswith(trimspace(coder_env.claude_code_system_prompt.value), "") + error_message = "System prompt should start with " + } + assert { + condition = endswith(trimspace(coder_env.claude_code_system_prompt.value), "") + error_message = "System prompt should end with " + } +} \ No newline at end of file diff --git a/registry/coder/modules/claude-code/scripts/install.sh b/registry/coder/modules/claude-code/scripts/install.sh index c3dcc22f..1285df90 100644 --- a/registry/coder/modules/claude-code/scripts/install.sh +++ b/registry/coder/modules/claude-code/scripts/install.sh @@ -1,7 +1,9 @@ #!/bin/bash set -euo pipefail -source "$HOME"/.bashrc +if [ -f "$HOME/.bashrc" ]; then + source "$HOME"/.bashrc +fi BOLD='\033[0;1m' diff --git a/registry/coder/modules/claude-code/scripts/start.sh b/registry/coder/modules/claude-code/scripts/start.sh index b5fca7a5..6eeb411b 100644 --- a/registry/coder/modules/claude-code/scripts/start.sh +++ b/registry/coder/modules/claude-code/scripts/start.sh @@ -1,7 +1,9 @@ #!/bin/bash set -euo pipefail -source "$HOME"/.bashrc +if [ -f "$HOME/.bashrc" ]; then + source "$HOME"/.bashrc +fi export PATH="$HOME/.local/bin:$PATH" command_exists() { diff --git a/registry/coder/modules/git-clone/README.md b/registry/coder/modules/git-clone/README.md index c12eb590..6ec2ccbe 100644 --- a/registry/coder/modules/git-clone/README.md +++ b/registry/coder/modules/git-clone/README.md @@ -14,7 +14,7 @@ This module allows you to automatically clone a repository by URL and skip if it module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.1.1" + version = "1.2.0" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" } @@ -28,7 +28,7 @@ module "git-clone" { module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.1.1" + version = "1.2.0" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" base_dir = "~/projects/coder" @@ -43,12 +43,12 @@ To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-prov module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.1.1" + version = "1.2.0" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" } -data "coder_git_auth" "github" { +data "coder_external_auth" "github" { id = "github" } ``` @@ -69,7 +69,7 @@ data "coder_parameter" "git_repo" { module "git_clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.1.1" + version = "1.2.0" agent_id = coder_agent.example.id url = data.coder_parameter.git_repo.value } @@ -103,7 +103,7 @@ Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `g module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.1.1" + version = "1.2.0" agent_id = coder_agent.example.id url = "https://github.example.com/coder/coder/tree/feat/example" git_providers = { @@ -122,7 +122,7 @@ To GitLab clone with a specific branch like `feat/example` module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.1.1" + version = "1.2.0" agent_id = coder_agent.example.id url = "https://gitlab.com/coder/coder/-/tree/feat/example" } @@ -134,7 +134,7 @@ Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com` module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.1.1" + version = "1.2.0" agent_id = coder_agent.example.id url = "https://gitlab.example.com/coder/coder/-/tree/feat/example" git_providers = { @@ -155,7 +155,7 @@ For example, to clone the `feat/example` branch: module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.1.1" + version = "1.2.0" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" branch_name = "feat/example" @@ -173,7 +173,7 @@ For example, this will clone into the `~/projects/coder/coder-dev` folder: module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.1.1" + version = "1.2.0" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" folder_name = "coder-dev" @@ -192,9 +192,32 @@ If not defined, the default, `0`, performs a full clone. module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/git-clone/coder" - version = "1.1.0" + version = "1.2.0" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" depth = 1 } ``` + +## Post-clone script + +Run a custom script after cloning the repository by setting the `post_clone_script` variable. +This is useful for running initialization tasks like installing dependencies or setting up the environment. + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/git-clone/coder" + version = "1.2.0" + agent_id = coder_agent.example.id + url = "https://github.com/coder/coder" + post_clone_script = <<-EOT + #!/bin/bash + echo "Repository cloned successfully!" + # Install dependencies + npm install + # Run any other initialization tasks + make setup + EOT +} +``` diff --git a/registry/coder/modules/git-clone/main.test.ts b/registry/coder/modules/git-clone/main.test.ts index 1e074fc0..0ae0a8db 100644 --- a/registry/coder/modules/git-clone/main.test.ts +++ b/registry/coder/modules/git-clone/main.test.ts @@ -30,11 +30,12 @@ describe("git-clone", async () => { url: "fake-url", }); const output = await executeScriptInContainer(state, "alpine/git"); - expect(output.exitCode).toBe(128); expect(output.stdout).toEqual([ "Creating directory ~/fake-url...", "Cloning fake-url to ~/fake-url...", ]); + expect(output.stderr.join(" ")).toContain("fatal"); + expect(output.stderr.join(" ")).toContain("fake-url"); }); it("repo_dir should match repo name for https", async () => { @@ -244,4 +245,20 @@ describe("git-clone", async () => { "Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...", ]); }); + + it("runs post-clone script", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "fake-url", + post_clone_script: "echo 'Post-clone script executed'", + }); + const output = await executeScriptInContainer( + state, + "alpine/git", + "sh", + "mkdir -p ~/fake-url && echo 'existing' > ~/fake-url/file.txt", + ); + expect(output.stdout).toContain("Running post-clone script..."); + expect(output.stdout).toContain("Post-clone script executed"); + }); }); diff --git a/registry/coder/modules/git-clone/main.tf b/registry/coder/modules/git-clone/main.tf index d03a59d7..2d547ad2 100644 --- a/registry/coder/modules/git-clone/main.tf +++ b/registry/coder/modules/git-clone/main.tf @@ -62,6 +62,12 @@ variable "depth" { default = 0 } +variable "post_clone_script" { + description = "Custom script to run after cloning the repository. Runs always after git clone, even if the repository already exists." + type = string + default = null +} + locals { # Remove query parameters and fragments from the URL url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "") @@ -81,6 +87,8 @@ locals { clone_path = var.base_dir != "" ? join("/", [var.base_dir, local.folder_name]) : join("/", ["~", local.folder_name]) # Construct the web URL web_url = startswith(local.clone_url, "git@") ? replace(replace(local.clone_url, ":", "/"), "git@", "https://") : local.clone_url + # Encode the post_clone_script for passing to the shell script + encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : "" } output "repo_dir" { @@ -120,6 +128,7 @@ resource "coder_script" "git_clone" { REPO_URL : local.clone_url, BRANCH_NAME : local.branch_name, DEPTH = var.depth, + POST_CLONE_SCRIPT : local.encoded_post_clone_script, }) display_name = "Git Clone" icon = "/icon/git.svg" diff --git a/registry/coder/modules/git-clone/run.sh b/registry/coder/modules/git-clone/run.sh index 282c667a..07c970e9 100644 --- a/registry/coder/modules/git-clone/run.sh +++ b/registry/coder/modules/git-clone/run.sh @@ -6,6 +6,7 @@ BRANCH_NAME="${BRANCH_NAME}" # Expand home if it's specified! CLONE_PATH="$${CLONE_PATH/#\~/$${HOME}}" DEPTH="${DEPTH}" +POST_CLONE_SCRIPT="${POST_CLONE_SCRIPT}" # Check if the variable is empty... if [ -z "$REPO_URL" ]; then @@ -52,5 +53,14 @@ if [ -z "$(ls -A "$CLONE_PATH")" ]; then fi else echo "$CLONE_PATH already exists and isn't empty, skipping clone!" - exit 0 +fi + +# Run post-clone script if provided +if [ -n "$POST_CLONE_SCRIPT" ]; then + echo "Running post-clone script..." + echo "$POST_CLONE_SCRIPT" | base64 -d > /tmp/post_clone.sh + chmod +x /tmp/post_clone.sh + cd "$CLONE_PATH" + /tmp/post_clone.sh + rm /tmp/post_clone.sh fi diff --git a/registry/coder/modules/goose/README.md b/registry/coder/modules/goose/README.md index a1dbfefe..f4f91ab5 100644 --- a/registry/coder/modules/goose/README.md +++ b/registry/coder/modules/goose/README.md @@ -13,7 +13,7 @@ Run the [Goose](https://block.github.io/goose/) agent in your workspace to gener ```tf module "goose" { source = "registry.coder.com/coder/goose/coder" - version = "2.1.2" + version = "2.2.1" agent_id = coder_agent.example.id folder = "/home/coder" install_goose = true @@ -79,7 +79,7 @@ resource "coder_agent" "main" { module "goose" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/goose/coder" - version = "2.1.2" + version = "2.2.1" agent_id = coder_agent.example.id folder = "/home/coder" install_goose = true diff --git a/registry/coder/modules/goose/main.tf b/registry/coder/modules/goose/main.tf index 2e015e76..51f8b6d6 100644 --- a/registry/coder/modules/goose/main.tf +++ b/registry/coder/modules/goose/main.tf @@ -63,7 +63,7 @@ variable "install_agentapi" { variable "agentapi_version" { type = string description = "The version of AgentAPI to install." - default = "v0.3.3" + default = "v0.10.0" } variable "subdomain" { @@ -135,11 +135,12 @@ EOT install_script = file("${path.module}/scripts/install.sh") start_script = file("${path.module}/scripts/start.sh") module_dir_name = ".goose-module" + folder = trimsuffix(var.folder, "/") } module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "1.1.1" + version = "1.2.0" agent_id = var.agent_id web_app_slug = local.app_slug @@ -156,6 +157,7 @@ module "agentapi" { pre_install_script = var.pre_install_script post_install_script = var.post_install_script start_script = local.start_script + folder = local.folder install_script = <<-EOT #!/bin/bash set -o errexit diff --git a/registry/coder/modules/jetbrains-gateway/README.md b/registry/coder/modules/jetbrains-gateway/README.md index 8cfe3b5e..0c5c8ff8 100644 --- a/registry/coder/modules/jetbrains-gateway/README.md +++ b/registry/coder/modules/jetbrains-gateway/README.md @@ -10,6 +10,7 @@ tags: [ide, jetbrains, parameter, gateway] This module adds a JetBrains Gateway Button to open any workspace with a single click. +> [!TIP] > We recommend using the [Coder Toolbox module](https://registry.coder.com/modules/coder/jetbrains), which offers significant stability and connectivity benefits over Gateway. Reference our [documentation](https://coder.com/docs/user-guides/workspace-access/jetbrains/toolbox) for more information. JetBrains recommends a minimum of 4 CPU cores and 8GB of RAM. @@ -19,7 +20,7 @@ Consult the [JetBrains documentation](https://www.jetbrains.com/help/idea/prereq module "jetbrains_gateway" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.2.4" + version = "1.2.5" agent_id = coder_agent.example.id folder = "/home/coder/example" jetbrains_ides = ["CL", "GO", "IU", "PY", "WS"] @@ -37,7 +38,7 @@ module "jetbrains_gateway" { module "jetbrains_gateway" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.2.4" + version = "1.2.5" agent_id = coder_agent.example.id folder = "/home/coder/example" jetbrains_ides = ["GO", "WS"] @@ -51,7 +52,7 @@ module "jetbrains_gateway" { module "jetbrains_gateway" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.2.4" + version = "1.2.5" agent_id = coder_agent.example.id folder = "/home/coder/example" jetbrains_ides = ["IU", "PY"] @@ -66,7 +67,7 @@ module "jetbrains_gateway" { module "jetbrains_gateway" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.2.4" + version = "1.2.5" agent_id = coder_agent.example.id folder = "/home/coder/example" jetbrains_ides = ["IU", "PY"] @@ -91,7 +92,7 @@ module "jetbrains_gateway" { module "jetbrains_gateway" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.2.4" + version = "1.2.5" agent_id = coder_agent.example.id folder = "/home/coder/example" jetbrains_ides = ["GO", "WS"] @@ -109,7 +110,7 @@ Due to the highest priority of the `ide_download_link` parameter in the `(jetbra module "jetbrains_gateway" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.2.4" + version = "1.2.5" agent_id = coder_agent.example.id folder = "/home/coder/example" jetbrains_ides = ["GO", "WS"] diff --git a/registry/coder/modules/kasmvnc/README.md b/registry/coder/modules/kasmvnc/README.md index cb07e6e9..2bc862d4 100644 --- a/registry/coder/modules/kasmvnc/README.md +++ b/registry/coder/modules/kasmvnc/README.md @@ -14,7 +14,7 @@ Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and module "kasmvnc" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/kasmvnc/coder" - version = "1.2.3" + version = "1.2.4" agent_id = coder_agent.example.id desktop_environment = "xfce" subdomain = true diff --git a/registry/coder/modules/kasmvnc/run.sh b/registry/coder/modules/kasmvnc/run.sh index 8389ea14..04b8b9ee 100644 --- a/registry/coder/modules/kasmvnc/run.sh +++ b/registry/coder/modules/kasmvnc/run.sh @@ -60,6 +60,9 @@ install_deb() { sudo apt-get -o DPkg::Lock::Timeout=300 -qq update fi + echo "Installing required Perl DateTime module..." + DEBIAN_FRONTEND=noninteractive sudo apt-get -o DPkg::Lock::Timeout=300 install --yes -qq --no-install-recommends --no-install-suggests libdatetime-perl + DEBIAN_FRONTEND=noninteractive sudo apt-get -o DPkg::Lock::Timeout=300 install --yes -qq --no-install-recommends --no-install-suggests "$kasmdeb" rm "$kasmdeb" } @@ -233,19 +236,17 @@ get_http_dir() { # Check the system configuration path if [[ -e /etc/kasmvnc/kasmvnc.yaml ]]; then - d=($(grep -E "^\s*httpd_directory:.*$" /etc/kasmvnc/kasmvnc.yaml)) - # If this grep is successful, it will return: - # httpd_directory: /usr/share/kasmvnc/www - if [[ $${#d[@]} -eq 2 && -d "$${d[1]}" ]]; then - httpd_directory="$${d[1]}" + d=$(grep -E '^\s*httpd_directory:.*$' "/etc/kasmvnc/kasmvnc.yaml" | awk '{print $$2}') + if [[ -n "$d" && -d "$d" ]]; then + httpd_directory=$d fi fi # Check the home directory for overriding values if [[ -e "$HOME/.vnc/kasmvnc.yaml" ]]; then - d=($(grep -E "^\s*httpd_directory:.*$" "$HOME/.vnc/kasmvnc.yaml")) - if [[ $${#d[@]} -eq 2 && -d "$${d[1]}" ]]; then - httpd_directory="$${d[1]}" + d=$(grep -E '^\s*httpd_directory:.*$' "$HOME/.vnc/kasmvnc.yaml" | awk '{print $$2}') + if [[ -n "$d" && -d "$d" ]]; then + httpd_directory=$d fi fi echo $httpd_directory diff --git a/registry/coder/templates/kubernetes-devcontainer/main.tf b/registry/coder/templates/kubernetes-devcontainer/main.tf index 07af6586..5e36226d 100644 --- a/registry/coder/templates/kubernetes-devcontainer/main.tf +++ b/registry/coder/templates/kubernetes-devcontainer/main.tf @@ -426,15 +426,14 @@ module "code-server" { # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. version = "~> 1.0" - agent_id = coder_agent.main.id - agent_name = "main" - order = 1 + agent_id = coder_agent.main.id + order = 1 } # See https://registry.coder.com/modules/coder/jetbrains module "jetbrains" { count = data.coder_workspace.me.start_count - source = "registry.coder.com/modules/coder/jetbrains/coder" + source = "registry.coder.com/coder/jetbrains/coder" version = "~> 1.0" agent_id = coder_agent.main.id agent_name = "main" diff --git a/registry/mavrickrishi/README.md b/registry/mavrickrishi/README.md index f4326ac3..eed6a5d9 100644 --- a/registry/mavrickrishi/README.md +++ b/registry/mavrickrishi/README.md @@ -19,3 +19,5 @@ participating in LFX CNCF programs, and helping the developer community grow. ## Modules - **aws-ami-snapshot**: Create and manage AMI snapshots for Coder workspaces with restore capabilities +- [nexus-repository](./modules/nexus-repository/) - Configure package managers to use Sonatype Nexus Repository +- [auto-start-dev-server](modules/auto-start-dev-server/README.md) - Automatically detect and start development servers for various project types diff --git a/registry/mavrickrishi/modules/auto-start-dev-server/README.md b/registry/mavrickrishi/modules/auto-start-dev-server/README.md new file mode 100644 index 00000000..432b453f --- /dev/null +++ b/registry/mavrickrishi/modules/auto-start-dev-server/README.md @@ -0,0 +1,151 @@ +--- +display_name: Auto-Start Dev Servers +description: Automatically detect and start development servers for various project types +icon: ../../../../.icons/auto-dev-server.svg +verified: false +tags: [development, automation, servers] +--- + +# Auto-Start Development Servers + +Automatically detect and start development servers for various project types when a workspace starts. This module scans your workspace for common project structures and starts the appropriate development servers in the background without manual intervention. + +```tf +module "auto_start_dev_servers" { + source = "registry.coder.com/mavrickrishi/auto-start-dev-server/coder" + version = "1.0.1" + agent_id = coder_agent.main.id +} +``` + +## Features + +- **Multi-language support**: Detects and starts servers for Node.js, Python (Django/Flask), Ruby (Rails), Java (Spring Boot), Go, PHP, Rust, and .NET projects +- **Smart script prioritization**: Prioritizes `dev` scripts over `start` scripts for better development experience +- **Intelligent frontend detection**: Automatically identifies frontend projects (React, Vue, Angular, Next.js, Nuxt, Svelte, Vite) and prioritizes them for preview apps +- **Devcontainer integration**: Respects custom start commands defined in `.devcontainer/devcontainer.json` +- **Configurable scanning**: Adjustable directory scan depth and project type toggles +- **Non-blocking startup**: Servers start in the background with configurable startup delay +- **Comprehensive logging**: All server output and detection results logged to a central file +- **Smart detection**: Uses project-specific files and configurations to identify project types +- **Integrated live preview**: Automatically creates a preview app for the primary frontend project + +## Supported Project Types + +| Framework/Language | Detection Files | Start Commands (in priority order) | +| ------------------ | -------------------------------------------- | ----------------------------------------------------- | +| **Node.js/npm** | `package.json` | `npm run dev`, `npm run serve`, `npm start` (or yarn) | +| **Ruby on Rails** | `Gemfile` with rails gem | `bundle exec rails server` | +| **Django** | `manage.py` | `python manage.py runserver` | +| **Flask** | `requirements.txt` with Flask | `python app.py/main.py/run.py` | +| **Spring Boot** | `pom.xml` or `build.gradle` with spring-boot | `mvn spring-boot:run`, `gradle bootRun` | +| **Go** | `go.mod` | `go run main.go` | +| **PHP** | `composer.json` | `php -S 0.0.0.0:8080` | +| **Rust** | `Cargo.toml` | `cargo run` | +| **.NET** | `*.csproj` | `dotnet run` | + +## Examples + +### Basic Usage + +```tf +module "auto_start" { + source = "./modules/auto-start-dev-server" + version = "1.0.1" + agent_id = coder_agent.main.id +} +``` + +### Advanced Usage + +```tf +module "auto_start_dev_servers" { + source = "./modules/auto-start-dev-server" + version = "1.0.1" + agent_id = coder_agent.main.id + + # Optional: Configure which project types to detect + enable_npm = true + enable_rails = true + enable_django = true + enable_flask = true + enable_spring_boot = true + enable_go = true + enable_php = true + enable_rust = true + enable_dotnet = true + + # Optional: Enable devcontainer.json integration + enable_devcontainer = true + + # Optional: Workspace directory to scan (supports environment variables) + workspace_directory = "$HOME" + + # Optional: Directory scan depth (1-5) + scan_depth = 2 + + # Optional: Startup delay in seconds + startup_delay = 10 + + # Optional: Log file path + log_path = "/tmp/dev-servers.log" + + # Optional: Enable automatic preview app (default: true) + enable_preview_app = true +} +``` + +### Disable Preview App + +```tf +module "auto_start" { + source = "./modules/auto-start-dev-server" + version = "1.0.1" + agent_id = coder_agent.main.id + + # Disable automatic preview app creation + enable_preview_app = false +} +``` + +### Selective Project Types + +```tf +module "auto_start" { + source = "./modules/auto-start-dev-server" + version = "1.0.1" + agent_id = coder_agent.main.id + + # Only enable web development projects + enable_npm = true + enable_rails = true + enable_django = true + enable_flask = true + + # Disable other project types + enable_spring_boot = false + enable_go = false + enable_php = false + enable_rust = false + enable_dotnet = false +} +``` + +### Deep Workspace Scanning + +```tf +module "auto_start" { + source = "./modules/auto-start-dev-server" + version = "1.0.1" + agent_id = coder_agent.main.id + + workspace_directory = "/workspaces" + scan_depth = 3 + startup_delay = 5 + log_path = "/var/log/dev-servers.log" +} +``` + +## License + +This module is provided under the same license as the Coder Registry. diff --git a/registry/mavrickrishi/modules/auto-start-dev-server/main.test.ts b/registry/mavrickrishi/modules/auto-start-dev-server/main.test.ts new file mode 100644 index 00000000..05893194 --- /dev/null +++ b/registry/mavrickrishi/modules/auto-start-dev-server/main.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("auto-start-dev-server", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "test-agent-123", + }); + + it("validates scan_depth range", () => { + const t1 = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-123", + scan_depth: "0", + }); + }; + expect(t1).toThrow("Scan depth must be between 1 and 5"); + + const t2 = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-123", + scan_depth: "6", + }); + }; + expect(t2).toThrow("Scan depth must be between 1 and 5"); + }); + + it("applies successfully with default values", async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-123", + }); + }); + + it("applies successfully with all project types enabled", async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-123", + enable_npm: "true", + enable_rails: "true", + enable_django: "true", + enable_flask: "true", + enable_spring_boot: "true", + enable_go: "true", + enable_php: "true", + enable_rust: "true", + enable_dotnet: "true", + enable_devcontainer: "true", + }); + }); + + it("applies successfully with all project types disabled", async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-123", + enable_npm: "false", + enable_rails: "false", + enable_django: "false", + enable_flask: "false", + enable_spring_boot: "false", + enable_go: "false", + enable_php: "false", + enable_rust: "false", + enable_dotnet: "false", + enable_devcontainer: "false", + }); + }); + + it("applies successfully with custom configuration", async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-123", + workspace_directory: "/custom/workspace", + scan_depth: "3", + startup_delay: "5", + log_path: "/var/log/custom-dev-servers.log", + display_name: "Custom Dev Server Startup", + }); + }); + + it("validates scan_depth boundary values", async () => { + // Test valid boundary values + await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-123", + scan_depth: "1", + }); + + await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-123", + scan_depth: "5", + }); + }); + + it("applies with selective project type configuration", async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-123", + enable_npm: "true", + enable_django: "true", + enable_go: "true", + enable_rails: "false", + enable_flask: "false", + enable_spring_boot: "false", + enable_php: "false", + enable_rust: "false", + enable_dotnet: "false", + }); + }); +}); diff --git a/registry/mavrickrishi/modules/auto-start-dev-server/main.tf b/registry/mavrickrishi/modules/auto-start-dev-server/main.tf new file mode 100644 index 00000000..f5d13941 --- /dev/null +++ b/registry/mavrickrishi/modules/auto-start-dev-server/main.tf @@ -0,0 +1,195 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.5" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "workspace_directory" { + type = string + description = "The directory to scan for development projects." + default = "$HOME" +} + +variable "project_detection" { + type = bool + description = "Enable automatic project detection for all supported types. When true, all project types are detected unless individually disabled. When false, only explicitly enabled project types are detected." + default = true +} + +variable "enable_npm" { + type = bool + description = "Enable auto-detection and startup of npm projects." + default = null +} + +variable "enable_rails" { + type = bool + description = "Enable auto-detection and startup of Rails projects." + default = null +} + +variable "enable_django" { + type = bool + description = "Enable auto-detection and startup of Django projects." + default = null +} + +variable "enable_flask" { + type = bool + description = "Enable auto-detection and startup of Flask projects." + default = null +} + +variable "enable_spring_boot" { + type = bool + description = "Enable auto-detection and startup of Spring Boot projects." + default = null +} + +variable "enable_go" { + type = bool + description = "Enable auto-detection and startup of Go projects." + default = null +} + +variable "enable_php" { + type = bool + description = "Enable auto-detection and startup of PHP projects." + default = null +} + +variable "enable_rust" { + type = bool + description = "Enable auto-detection and startup of Rust projects." + default = null +} + +variable "enable_dotnet" { + type = bool + description = "Enable auto-detection and startup of .NET projects." + default = null +} + +variable "enable_devcontainer" { + type = bool + description = "Enable integration with devcontainer.json configuration." + default = null +} + +variable "log_path" { + type = string + description = "The path to log development server output to." + default = "/tmp/dev-servers.log" +} + +variable "scan_depth" { + type = number + description = "Maximum directory depth to scan for projects (1-5)." + default = 2 + validation { + condition = var.scan_depth >= 1 && var.scan_depth <= 5 + error_message = "Scan depth must be between 1 and 5." + } +} + +variable "startup_delay" { + type = number + description = "Delay in seconds before starting dev servers (allows other setup to complete)." + default = 10 +} + +variable "display_name" { + type = string + description = "Display name for the auto-start dev server script." + default = "Auto-Start Dev Servers" +} + +variable "enable_preview_app" { + type = bool + description = "Enable automatic creation of a preview app for the first detected project." + default = true +} + +# Read the detected port from the file written by the script +locals { + detected_port = var.enable_preview_app ? try(tonumber(trimspace(file("/tmp/detected-port.txt"))), 3000) : 3000 + # Attempt to read project information for better preview naming + detected_projects = try(jsondecode(file("/tmp/detected-projects.json")), []) + preview_project = length(local.detected_projects) > 0 ? local.detected_projects[0] : null +} + +resource "coder_script" "auto_start_dev_server" { + agent_id = var.agent_id + display_name = var.display_name + icon = "/icon/auto-dev-server.svg" + script = templatefile("${path.module}/run.sh", { + WORKSPACE_DIR = var.workspace_directory + ENABLE_NPM = coalesce(var.enable_npm, var.project_detection) + ENABLE_RAILS = coalesce(var.enable_rails, var.project_detection) + ENABLE_DJANGO = coalesce(var.enable_django, var.project_detection) + ENABLE_FLASK = coalesce(var.enable_flask, var.project_detection) + ENABLE_SPRING_BOOT = coalesce(var.enable_spring_boot, var.project_detection) + ENABLE_GO = coalesce(var.enable_go, var.project_detection) + ENABLE_PHP = coalesce(var.enable_php, var.project_detection) + ENABLE_RUST = coalesce(var.enable_rust, var.project_detection) + ENABLE_DOTNET = coalesce(var.enable_dotnet, var.project_detection) + ENABLE_DEVCONTAINER = coalesce(var.enable_devcontainer, var.project_detection) + LOG_PATH = var.log_path + SCAN_DEPTH = var.scan_depth + STARTUP_DELAY = var.startup_delay + }) + run_on_start = true +} + +# Create preview app for first detected project +resource "coder_app" "preview" { + count = var.enable_preview_app ? 1 : 0 + agent_id = var.agent_id + slug = "dev-preview" + display_name = "Live Preview" + url = "http://localhost:${local.detected_port}" + icon = "/icon/auto-dev-server.svg" + subdomain = true + share = "owner" +} + +output "log_path" { + value = var.log_path + description = "Path to the log file for dev server output" +} + +# Example output values for common port mappings +output "common_ports" { + value = { + nodejs = 3000 + rails = 3000 + django = 8000 + flask = 5000 + spring = 8080 + go = 8080 + php = 8080 + rust = 8000 + dotnet = 5000 + } + description = "Common default ports for different project types" +} + +output "preview_url" { + value = var.enable_preview_app ? try(coder_app.preview[0].url, null) : null + description = "URL of the live preview app (if enabled)" +} + +output "detected_port" { + value = local.detected_port + description = "Port of the first detected development server" +} diff --git a/registry/mavrickrishi/modules/auto-start-dev-server/run.sh b/registry/mavrickrishi/modules/auto-start-dev-server/run.sh new file mode 100755 index 00000000..d0386e74 --- /dev/null +++ b/registry/mavrickrishi/modules/auto-start-dev-server/run.sh @@ -0,0 +1,468 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Color codes for output +BOLD='\033[0;1m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +RED='\033[0;31m' +RESET='\033[0m' + +echo -e "$${BOLD}πŸš€ Auto-Start Development Servers$${RESET}" +echo "Workspace Directory: ${WORKSPACE_DIR}" +echo "Log Path: ${LOG_PATH}" +echo "Scan Depth: ${SCAN_DEPTH}" + +# Wait for startup delay to allow other setup to complete +if [ "${STARTUP_DELAY}" -gt 0 ]; then + echo -e "$${YELLOW}⏳ Waiting ${STARTUP_DELAY} seconds for system initialization...$${RESET}" + sleep "${STARTUP_DELAY}" +fi + +# Initialize log file +echo "=== Auto-Start Dev Servers Log ===" > "${LOG_PATH}" +echo "Started at: $(date)" >> "${LOG_PATH}" + +# Initialize detected projects JSON file +DETECTED_PROJECTS_FILE="/tmp/detected-projects.json" +echo '[]' > "$DETECTED_PROJECTS_FILE" + +# Initialize detected port file for preview app +DETECTED_PORT_FILE="/tmp/detected-port.txt" +FIRST_PORT_DETECTED=false +FRONTEND_PROJECT_DETECTED=false + +# Function to log messages +log_message() { + echo -e "$1" + echo "$1" >> "${LOG_PATH}" +} + +# Function to determine if a project is likely a frontend project +is_frontend_project() { + local project_dir="$1" + local project_type="$2" + + # Check for common frontend indicators + if [ "$project_type" = "nodejs" ]; then + # Check package.json for frontend dependencies + if [ -f "$project_dir/package.json" ] && command -v jq &> /dev/null; then + # Check for common frontend frameworks + local has_react=$(jq '.dependencies.react // .devDependencies.react // empty' "$project_dir/package.json") + local has_vue=$(jq '.dependencies.vue // .devDependencies.vue // empty' "$project_dir/package.json") + local has_angular=$(jq '.dependencies["@angular/core"] // .devDependencies["@angular/core"] // empty' "$project_dir/package.json") + local has_next=$(jq '.dependencies.next // .devDependencies.next // empty' "$project_dir/package.json") + local has_nuxt=$(jq '.dependencies.nuxt // .devDependencies.nuxt // empty' "$project_dir/package.json") + local has_svelte=$(jq '.dependencies.svelte // .devDependencies.svelte // empty' "$project_dir/package.json") + local has_vite=$(jq '.dependencies.vite // .devDependencies.vite // empty' "$project_dir/package.json") + + if [ -n "$has_react" ] || [ -n "$has_vue" ] || [ -n "$has_angular" ] \ + || [ -n "$has_next" ] || [ -n "$has_nuxt" ] || [ -n "$has_svelte" ] \ + || [ -n "$has_vite" ]; then + return 0 # It's a frontend project + fi + fi + + # Check for common frontend directory structures + if [ -d "$project_dir/src/components" ] || [ -d "$project_dir/components" ] \ + || [ -d "$project_dir/pages" ] || [ -d "$project_dir/views" ] \ + || [ -f "$project_dir/index.html" ] || [ -f "$project_dir/public/index.html" ]; then + return 0 # It's likely a frontend project + fi + fi + + # Rails projects with webpack/webpacker are frontend-enabled + if [ "$project_type" = "rails" ]; then + if [ -f "$project_dir/config/webpacker.yml" ] || [ -f "$project_dir/webpack.config.js" ]; then + return 0 + fi + fi + + # Django projects with static/templates are frontend-enabled + if [ "$project_type" = "django" ]; then + if [ -d "$project_dir/static" ] || [ -d "$project_dir/templates" ]; then + return 0 + fi + fi + + return 1 # Not a frontend project +} + +# Function to add detected project to JSON +add_detected_project() { + local project_dir="$1" + local project_type="$2" + local port="$3" + local command="$4" + + # Check if this is a frontend project + local is_frontend=false + if is_frontend_project "$project_dir" "$project_type"; then + is_frontend=true + log_message "$${BLUE}🎨 Detected frontend project at $project_dir$${RESET}" + fi + + # Prioritize frontend projects for the preview app + # Set port if: 1) No port set yet, OR 2) This is frontend and no frontend detected yet + if [ "$FIRST_PORT_DETECTED" = false ] || ([ "$is_frontend" = true ] && [ "$FRONTEND_PROJECT_DETECTED" = false ]); then + echo "$port" > "$DETECTED_PORT_FILE" + FIRST_PORT_DETECTED=true + if [ "$is_frontend" = true ]; then + FRONTEND_PROJECT_DETECTED=true + log_message "$${BLUE}🎯 Frontend project detected - Preview app will be available on port $port$${RESET}" + else + log_message "$${BLUE}🎯 Project detected - Preview app will be available on port $port$${RESET}" + fi + fi + + # Create JSON entry for this project + local project_json=$(jq -n \ + --arg dir "$project_dir" \ + --arg type "$project_type" \ + --arg port "$port" \ + --arg cmd "$command" \ + --arg frontend "$is_frontend" \ + '{"directory": $dir, "type": $type, "port": $port, "command": $cmd, "is_frontend": ($frontend == "true")}') + + # Append to the detected projects file + jq ". += [$project_json]" "$DETECTED_PROJECTS_FILE" > "$DETECTED_PROJECTS_FILE.tmp" \ + && mv "$DETECTED_PROJECTS_FILE.tmp" "$DETECTED_PROJECTS_FILE" +} + +# Function to detect and start npm/yarn projects +detect_npm_projects() { + if [ "${ENABLE_NPM}" != "true" ]; then + return + fi + + log_message "$${BLUE}πŸ” Scanning for Node.js/npm projects...$${RESET}" + + # Use find with maxdepth to respect scan depth + while IFS= read -r -d '' package_json; do + project_dir=$(dirname "$package_json") + log_message "$${GREEN}πŸ“¦ Found Node.js project: $project_dir$${RESET}" + + cd "$project_dir" + + # Check package.json for start script + if [ -f "package.json" ] && command -v jq &> /dev/null; then + start_script=$(jq -r '.scripts.start // empty' package.json) + dev_script=$(jq -r '.scripts.dev // empty' package.json) + serve_script=$(jq -r '.scripts.serve // empty' package.json) + + # Determine port (check for common port configurations) + local project_port=3000 + if [ -n "$dev_script" ] && echo "$dev_script" | grep -q "\-\-port"; then + project_port=$(echo "$dev_script" | grep -oE "\-\-port[[:space:]]+[0-9]+" | grep -oE "[0-9]+$" || echo "3000") + fi + + # Use yarn if yarn.lock exists + local pkg_manager="npm" + local cmd_prefix="" + if [ -f "yarn.lock" ] && command -v yarn &> /dev/null; then + pkg_manager="yarn" + cmd_prefix="" + else + cmd_prefix="run " + fi + + # Prioritize scripts: 'dev' > 'serve' > 'start' for development environments + if [ -n "$dev_script" ]; then + if [ "$pkg_manager" = "yarn" ]; then + log_message "$${GREEN}🟒 Starting project with 'yarn dev' in $project_dir$${RESET}" + nohup yarn dev >> "${LOG_PATH}" 2>&1 & + add_detected_project "$project_dir" "nodejs" "$project_port" "yarn dev" + else + log_message "$${GREEN}🟒 Starting project with 'npm run dev' in $project_dir$${RESET}" + nohup npm run dev >> "${LOG_PATH}" 2>&1 & + add_detected_project "$project_dir" "nodejs" "$project_port" "npm run dev" + fi + elif [ -n "$serve_script" ]; then + if [ "$pkg_manager" = "yarn" ]; then + log_message "$${GREEN}🟒 Starting project with 'yarn serve' in $project_dir$${RESET}" + nohup yarn serve >> "${LOG_PATH}" 2>&1 & + add_detected_project "$project_dir" "nodejs" "$project_port" "yarn serve" + else + log_message "$${GREEN}🟒 Starting project with 'npm run serve' in $project_dir$${RESET}" + nohup npm run serve >> "${LOG_PATH}" 2>&1 & + add_detected_project "$project_dir" "nodejs" "$project_port" "npm run serve" + fi + elif [ -n "$start_script" ]; then + if [ "$pkg_manager" = "yarn" ]; then + log_message "$${GREEN}🟒 Starting project with 'yarn start' in $project_dir$${RESET}" + nohup yarn start >> "${LOG_PATH}" 2>&1 & + add_detected_project "$project_dir" "nodejs" "$project_port" "yarn start" + else + log_message "$${GREEN}🟒 Starting project with 'npm start' in $project_dir$${RESET}" + nohup npm start >> "${LOG_PATH}" 2>&1 & + add_detected_project "$project_dir" "nodejs" "$project_port" "npm start" + fi + fi + fi + + done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "package.json" -type f -print0) +} + +# Function to detect and start Rails projects +detect_rails_projects() { + if [ "${ENABLE_RAILS}" != "true" ]; then + return + fi + + log_message "$${BLUE}πŸ” Scanning for Ruby on Rails projects...$${RESET}" + + while IFS= read -r -d '' gemfile; do + project_dir=$(dirname "$gemfile") + log_message "$${GREEN}πŸ’Ž Found Rails project: $project_dir$${RESET}" + + cd "$project_dir" + + # Check if it's actually a Rails project + if grep -q "gem ['\"]rails['\"]" Gemfile 2> /dev/null; then + log_message "$${GREEN}🟒 Starting Rails server in $project_dir$${RESET}" + nohup bundle exec rails server >> "${LOG_PATH}" 2>&1 & + add_detected_project "$project_dir" "rails" "3000" "bundle exec rails server" + fi + + done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "Gemfile" -type f -print0) +} + +# Function to detect and start Django projects +detect_django_projects() { + if [ "${ENABLE_DJANGO}" != "true" ]; then + return + fi + + log_message "$${BLUE}πŸ” Scanning for Django projects...$${RESET}" + + while IFS= read -r -d '' manage_py; do + project_dir=$(dirname "$manage_py") + log_message "$${GREEN}🐍 Found Django project: $project_dir$${RESET}" + + cd "$project_dir" + log_message "$${GREEN}🟒 Starting Django development server in $project_dir$${RESET}" + nohup python manage.py runserver 0.0.0.0:8000 >> "${LOG_PATH}" 2>&1 & + add_detected_project "$project_dir" "django" "8000" "python manage.py runserver" + + done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "manage.py" -type f -print0) +} + +# Function to detect and start Flask projects +detect_flask_projects() { + if [ "${ENABLE_FLASK}" != "true" ]; then + return + fi + + log_message "$${BLUE}πŸ” Scanning for Flask projects...$${RESET}" + + while IFS= read -r -d '' requirements_txt; do + project_dir=$(dirname "$requirements_txt") + + # Check if Flask is in requirements + if grep -q -i "flask" "$requirements_txt" 2> /dev/null; then + log_message "$${GREEN}🌢️ Found Flask project: $project_dir$${RESET}" + + cd "$project_dir" + + # Look for common Flask app files + for app_file in app.py main.py run.py; do + if [ -f "$app_file" ]; then + log_message "$${GREEN}🟒 Starting Flask application ($app_file) in $project_dir$${RESET}" + export FLASK_ENV=development + nohup python "$app_file" >> "${LOG_PATH}" 2>&1 & + add_detected_project "$project_dir" "flask" "5000" "python $app_file" + break + fi + done + fi + + done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "requirements.txt" -type f -print0) +} + +# Function to detect and start Spring Boot projects +detect_spring_boot_projects() { + if [ "${ENABLE_SPRING_BOOT}" != "true" ]; then + return + fi + + log_message "$${BLUE}πŸ” Scanning for Spring Boot projects...$${RESET}" + + # Maven projects + while IFS= read -r -d '' pom_xml; do + project_dir=$(dirname "$pom_xml") + + # Check if it's a Spring Boot project + if grep -q "spring-boot" "$pom_xml" 2> /dev/null; then + log_message "$${GREEN}πŸƒ Found Spring Boot Maven project: $project_dir$${RESET}" + + cd "$project_dir" + if command -v ./mvnw &> /dev/null; then + log_message "$${GREEN}🟒 Starting Spring Boot application with Maven wrapper in $project_dir$${RESET}" + nohup ./mvnw spring-boot:run >> "${LOG_PATH}" 2>&1 & + add_detected_project "$project_dir" "spring-boot" "8080" "./mvnw spring-boot:run" + elif command -v mvn &> /dev/null; then + log_message "$${GREEN}🟒 Starting Spring Boot application with Maven in $project_dir$${RESET}" + nohup mvn spring-boot:run >> "${LOG_PATH}" 2>&1 & + add_detected_project "$project_dir" "spring-boot" "8080" "mvn spring-boot:run" + fi + fi + + done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "pom.xml" -type f -print0) + + # Gradle projects + while IFS= read -r -d '' build_gradle; do + project_dir=$(dirname "$build_gradle") + + # Check if it's a Spring Boot project + if grep -q "spring-boot" "$build_gradle" 2> /dev/null; then + log_message "$${GREEN}πŸƒ Found Spring Boot Gradle project: $project_dir$${RESET}" + + cd "$project_dir" + if command -v ./gradlew &> /dev/null; then + log_message "$${GREEN}🟒 Starting Spring Boot application with Gradle wrapper in $project_dir$${RESET}" + nohup ./gradlew bootRun >> "${LOG_PATH}" 2>&1 & + add_detected_project "$project_dir" "spring-boot" "8080" "./gradlew bootRun" + elif command -v gradle &> /dev/null; then + log_message "$${GREEN}🟒 Starting Spring Boot application with Gradle in $project_dir$${RESET}" + nohup gradle bootRun >> "${LOG_PATH}" 2>&1 & + add_detected_project "$project_dir" "spring-boot" "8080" "gradle bootRun" + fi + fi + + done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "build.gradle" -type f -print0) +} + +# Function to detect and start Go projects +detect_go_projects() { + if [ "${ENABLE_GO}" != "true" ]; then + return + fi + + log_message "$${BLUE}πŸ” Scanning for Go projects...$${RESET}" + + while IFS= read -r -d '' go_mod; do + project_dir=$(dirname "$go_mod") + log_message "$${GREEN}🐹 Found Go project: $project_dir$${RESET}" + + cd "$project_dir" + + # Look for main.go or check if there's a main function + if [ -f "main.go" ]; then + log_message "$${GREEN}🟒 Starting Go application in $project_dir$${RESET}" + nohup go run main.go >> "${LOG_PATH}" 2>&1 & + add_detected_project "$project_dir" "go" "8080" "go run main.go" + elif [ -f "cmd/main.go" ]; then + log_message "$${GREEN}🟒 Starting Go application (cmd/main.go) in $project_dir$${RESET}" + nohup go run cmd/main.go >> "${LOG_PATH}" 2>&1 & + add_detected_project "$project_dir" "go" "8080" "go run cmd/main.go" + fi + + done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "go.mod" -type f -print0) +} + +# Function to detect and start PHP projects +detect_php_projects() { + if [ "${ENABLE_PHP}" != "true" ]; then + return + fi + + log_message "$${BLUE}πŸ” Scanning for PHP projects...$${RESET}" + + while IFS= read -r -d '' composer_json; do + project_dir=$(dirname "$composer_json") + log_message "$${GREEN}🐘 Found PHP project: $project_dir$${RESET}" + + cd "$project_dir" + + # Look for common PHP entry points + for entry_file in index.php public/index.php; do + if [ -f "$entry_file" ]; then + log_message "$${GREEN}🟒 Starting PHP development server in $project_dir$${RESET}" + nohup php -S 0.0.0.0:8080 -t "$(dirname "$entry_file")" >> "${LOG_PATH}" 2>&1 & + add_detected_project "$project_dir" "php" "8080" "php -S 0.0.0.0:8080" + break + fi + done + + done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "composer.json" -type f -print0) +} + +# Function to detect and start Rust projects +detect_rust_projects() { + if [ "${ENABLE_RUST}" != "true" ]; then + return + fi + + log_message "$${BLUE}πŸ” Scanning for Rust projects...$${RESET}" + + while IFS= read -r -d '' cargo_toml; do + project_dir=$(dirname "$cargo_toml") + log_message "$${GREEN}πŸ¦€ Found Rust project: $project_dir$${RESET}" + + cd "$project_dir" + + # Check if it's a binary project (has [[bin]] or default main.rs) + if grep -q "\[\[bin\]\]" Cargo.toml 2> /dev/null || [ -f "src/main.rs" ]; then + log_message "$${GREEN}🟒 Starting Rust application in $project_dir$${RESET}" + nohup cargo run >> "${LOG_PATH}" 2>&1 & + add_detected_project "$project_dir" "rust" "8000" "cargo run" + fi + + done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "Cargo.toml" -type f -print0) +} + +# Function to detect and start .NET projects +detect_dotnet_projects() { + if [ "${ENABLE_DOTNET}" != "true" ]; then + return + fi + + log_message "$${BLUE}πŸ” Scanning for .NET projects...$${RESET}" + + while IFS= read -r -d '' csproj; do + project_dir=$(dirname "$csproj") + log_message "$${GREEN}πŸ”· Found .NET project: $project_dir$${RESET}" + + cd "$project_dir" + log_message "$${GREEN}🟒 Starting .NET application in $project_dir$${RESET}" + nohup dotnet run >> "${LOG_PATH}" 2>&1 & + add_detected_project "$project_dir" "dotnet" "5000" "dotnet run" + + done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "*.csproj" -type f -print0) +} + +log_message "Starting auto-detection of development projects..." + +# Expand workspace directory if it contains variables +WORKSPACE_DIR=$(eval echo "${WORKSPACE_DIR}") + +# Check if workspace directory exists +if [ ! -d "$WORKSPACE_DIR" ]; then + log_message "$${RED}❌ Workspace directory does not exist: $WORKSPACE_DIR$${RESET}" + exit 1 +fi + +cd "$WORKSPACE_DIR" + +# Run all detection functions +detect_npm_projects +detect_rails_projects +detect_django_projects +detect_flask_projects +detect_spring_boot_projects +detect_go_projects +detect_php_projects +detect_rust_projects +detect_dotnet_projects + +log_message "$${GREEN}βœ… Auto-start scan completed!$${RESET}" +log_message "$${YELLOW}πŸ’‘ Check running processes with 'ps aux | grep -E \"(npm|rails|python|java|go|php|cargo|dotnet)\"'$${RESET}" +log_message "$${YELLOW}πŸ’‘ View logs: tail -f ${LOG_PATH}$${RESET}" + +# Set default port if no projects were detected +if [ "$FIRST_PORT_DETECTED" = false ]; then + echo "3000" > "$DETECTED_PORT_FILE" + log_message "$${YELLOW}⚠️ No projects detected - Preview app will default to port 3000$${RESET}" +fi diff --git a/registry/mavrickrishi/modules/nexus-repository/README.md b/registry/mavrickrishi/modules/nexus-repository/README.md new file mode 100644 index 00000000..6bf5431c --- /dev/null +++ b/registry/mavrickrishi/modules/nexus-repository/README.md @@ -0,0 +1,149 @@ +--- +display_name: Nexus Repository +description: Configure package managers to use Sonatype Nexus Repository for Maven, npm, PyPI, and Docker registries. +icon: ../../../../.icons/nexus-repository.svg +verified: false +tags: [integration, nexus-repository, maven, npm, pypi, docker] +--- + +# Sonatype Nexus Repository + +Configure package managers (Maven, npm, Go, PyPI, Docker) to use [Sonatype Nexus Repository](https://help.sonatype.com/en/sonatype-nexus-repository.html) with API token authentication. This module provides secure credential handling, multiple repository support per package manager, and flexible username configuration. + +```tf +module "nexus_repository" { + source = "registry.coder.com/mavrickrishi/nexus-repository/coder" + version = "1.0.1" + agent_id = coder_agent.example.id + nexus_url = "https://nexus.example.com" + nexus_password = var.nexus_api_token + package_managers = { + maven = ["maven-public", "maven-releases"] + npm = ["npm-public", "@scoped:npm-private"] + go = ["go-public", "go-private"] + pypi = ["pypi-public", "pypi-private"] + docker = ["docker-public", "docker-private"] + } +} +``` + +## Requirements + +- Nexus Repository Manager 3.x +- Valid API token or user credentials +- Package managers installed on the workspace (Maven, npm, Go, pip, Docker as needed) + +> [!NOTE] +> This module configures package managers but does not install them. You need to handle the installation of Maven, npm, Go, Python pip, and Docker yourself. + +## Examples + +### Configure Maven to use Nexus repositories + +```tf +module "nexus_repository" { + source = "registry.coder.com/mavrickrishi/nexus-repository/coder" + version = "1.0.1" + agent_id = coder_agent.example.id + nexus_url = "https://nexus.example.com" + nexus_password = var.nexus_api_token + package_managers = { + maven = ["maven-public", "maven-releases", "maven-snapshots"] + } +} +``` + +### Configure npm with scoped packages + +```tf +module "nexus_repository" { + source = "registry.coder.com/mavrickrishi/nexus-repository/coder" + version = "1.0.1" + agent_id = coder_agent.example.id + nexus_url = "https://nexus.example.com" + nexus_password = var.nexus_api_token + package_managers = { + npm = ["npm-public", "@mycompany:npm-private"] + } +} +``` + +### Configure Go module proxy + +```tf +module "nexus_repository" { + source = "registry.coder.com/mavrickrishi/nexus-repository/coder" + version = "1.0.1" + agent_id = coder_agent.example.id + nexus_url = "https://nexus.example.com" + nexus_password = var.nexus_api_token + package_managers = { + go = ["go-public", "go-private"] + } +} +``` + +### Configure Python PyPI repositories + +```tf +module "nexus_repository" { + source = "registry.coder.com/mavrickrishi/nexus-repository/coder" + version = "1.0.1" + agent_id = coder_agent.example.id + nexus_url = "https://nexus.example.com" + nexus_password = var.nexus_api_token + package_managers = { + pypi = ["pypi-public", "pypi-private"] + } +} +``` + +### Configure Docker registries + +```tf +module "nexus_repository" { + source = "registry.coder.com/mavrickrishi/nexus-repository/coder" + version = "1.0.1" + agent_id = coder_agent.example.id + nexus_url = "https://nexus.example.com" + nexus_password = var.nexus_api_token + package_managers = { + docker = ["docker-public", "docker-private"] + } +} +``` + +### Use custom username + +```tf +module "nexus_repository" { + source = "registry.coder.com/mavrickrishi/nexus-repository/coder" + version = "1.0.1" + agent_id = coder_agent.example.id + nexus_url = "https://nexus.example.com" + nexus_username = "custom-user" + nexus_password = var.nexus_api_token + package_managers = { + maven = ["maven-public"] + } +} +``` + +### Complete configuration for all package managers + +```tf +module "nexus_repository" { + source = "registry.coder.com/mavrickrishi/nexus-repository/coder" + version = "1.0.1" + agent_id = coder_agent.example.id + nexus_url = "https://nexus.example.com" + nexus_password = var.nexus_api_token + package_managers = { + maven = ["maven-public", "maven-releases"] + npm = ["npm-public", "@company:npm-private"] + go = ["go-public", "go-private"] + pypi = ["pypi-public", "pypi-private"] + docker = ["docker-public", "docker-private"] + } +} +``` diff --git a/registry/mavrickrishi/modules/nexus-repository/main.test.ts b/registry/mavrickrishi/modules/nexus-repository/main.test.ts new file mode 100644 index 00000000..1d5724bb --- /dev/null +++ b/registry/mavrickrishi/modules/nexus-repository/main.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("nexus-repository", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "test-agent", + nexus_url: "https://nexus.example.com", + nexus_password: "test-password", + }); + + it("configures Maven settings", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + nexus_url: "https://nexus.example.com", + nexus_password: "test-token", + package_managers: JSON.stringify({ + maven: ["maven-public"], + }), + }); + + const output = await executeScriptInContainer(state, "ubuntu:20.04"); + expect(output.stdout.join("\n")).toContain("β˜• Configuring Maven..."); + expect(output.stdout.join("\n")).toContain("πŸ₯³ Configuration complete!"); + }); + + it("configures npm registry", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + nexus_url: "https://nexus.example.com", + nexus_password: "test-token", + package_managers: JSON.stringify({ + npm: ["npm-public"], + }), + }); + + const output = await executeScriptInContainer(state, "ubuntu:20.04"); + expect(output.stdout.join("\n")).toContain("πŸ“¦ Configuring npm..."); + expect(output.stdout.join("\n")).toContain("πŸ₯³ Configuration complete!"); + }); + + it("configures PyPI repository", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + nexus_url: "https://nexus.example.com", + nexus_password: "test-token", + package_managers: JSON.stringify({ + pypi: ["pypi-public"], + }), + }); + + const output = await executeScriptInContainer(state, "ubuntu:20.04"); + expect(output.stdout.join("\n")).toContain("🐍 Configuring pip..."); + expect(output.stdout.join("\n")).toContain("πŸ₯³ Configuration complete!"); + }); + + it("configures multiple package managers", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + nexus_url: "https://nexus.example.com", + nexus_password: "test-token", + package_managers: JSON.stringify({ + maven: ["maven-public"], + npm: ["npm-public"], + pypi: ["pypi-public"], + }), + }); + + const output = await executeScriptInContainer(state, "ubuntu:20.04"); + expect(output.stdout.join("\n")).toContain("β˜• Configuring Maven..."); + expect(output.stdout.join("\n")).toContain("πŸ“¦ Configuring npm..."); + expect(output.stdout.join("\n")).toContain("🐍 Configuring pip..."); + expect(output.stdout.join("\n")).toContain( + "βœ… Nexus repository configuration completed!", + ); + }); + + it("handles empty package managers", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + nexus_url: "https://nexus.example.com", + nexus_password: "test-token", + package_managers: JSON.stringify({}), + }); + + const output = await executeScriptInContainer(state, "ubuntu:20.04"); + expect(output.stdout.join("\n")).toContain( + "πŸ€” no maven repository is set, skipping maven configuration.", + ); + expect(output.stdout.join("\n")).toContain( + "πŸ€” no npm repository is set, skipping npm configuration.", + ); + expect(output.stdout.join("\n")).toContain( + "πŸ€” no pypi repository is set, skipping pypi configuration.", + ); + expect(output.stdout.join("\n")).toContain( + "πŸ€” no docker repository is set, skipping docker configuration.", + ); + }); + + it("configures Go module proxy", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + nexus_url: "https://nexus.example.com", + nexus_password: "test-token", + package_managers: JSON.stringify({ + go: ["go-public", "go-private"], + }), + }); + + const output = await executeScriptInContainer(state, "ubuntu:20.04"); + expect(output.stdout.join("\n")).toContain("🐹 Configuring Go..."); + expect(output.stdout.join("\n")).toContain( + "Go proxy configured via GOPROXY environment variable", + ); + expect(output.stdout.join("\n")).toContain("πŸ₯³ Configuration complete!"); + }); + + it("validates nexus_url format", async () => { + await expect( + runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + nexus_url: "invalid-url", + nexus_password: "test-token", + package_managers: JSON.stringify({}), + }), + ).rejects.toThrow(); + }); + + it("validates username_field values", async () => { + await expect( + runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + nexus_url: "https://nexus.example.com", + nexus_password: "test-token", + username_field: "invalid", + package_managers: JSON.stringify({}), + }), + ).rejects.toThrow(); + }); +}); diff --git a/registry/mavrickrishi/modules/nexus-repository/main.tf b/registry/mavrickrishi/modules/nexus-repository/main.tf new file mode 100644 index 00000000..be573121 --- /dev/null +++ b/registry/mavrickrishi/modules/nexus-repository/main.tf @@ -0,0 +1,137 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.5" + } + } +} + +variable "nexus_url" { + type = string + description = "The base URL of your Nexus repository manager (e.g. https://nexus.example.com)" + validation { + condition = can(regex("^(https|http)://", var.nexus_url)) + error_message = "nexus_url must be a valid URL starting with either 'https://' or 'http://'" + } +} + +variable "nexus_username" { + type = string + description = "Custom username for Nexus authentication. If not provided, defaults to the Coder username based on the username_field setting" + default = null +} + +variable "nexus_password" { + type = string + description = "API token or password for Nexus authentication. This value is sensitive and should be stored securely" + sensitive = true +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "package_managers" { + type = object({ + maven = optional(list(string), []) + npm = optional(list(string), []) + go = optional(list(string), []) + pypi = optional(list(string), []) + docker = optional(list(string), []) + }) + default = { + maven = [] + npm = [] + go = [] + pypi = [] + docker = [] + } + description = <<-EOF + Configuration for package managers. Each key maps to a list of Nexus repository names: + - maven: List of Maven repository names + - npm: List of npm repository names (supports scoped packages with "@scope:repo-name") + - go: List of Go proxy repository names + - pypi: List of PyPI repository names + - docker: List of Docker registry names + Unused package managers can be omitted. + Example: + { + maven = ["maven-public", "maven-releases"] + npm = ["npm-public", "@scoped:npm-private"] + go = ["go-public", "go-private"] + pypi = ["pypi-public", "pypi-private"] + docker = ["docker-public", "docker-private"] + } + EOF +} + +variable "username_field" { + type = string + description = "Field to use for username (\"username\" or \"email\"). Defaults to \"username\". Only used when nexus_username is not provided" + default = "username" + validation { + condition = can(regex("^(email|username)$", var.username_field)) + error_message = "username_field must be either 'email' or 'username'" + } +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +locals { + username = coalesce(var.nexus_username, var.username_field == "email" ? data.coder_workspace_owner.me.email : data.coder_workspace_owner.me.name) + nexus_host = split("/", replace(replace(var.nexus_url, "https://", ""), "http://", ""))[0] +} + +locals { + # Get first repository name or use default + maven_repo = length(var.package_managers.maven) > 0 ? var.package_managers.maven[0] : "maven-public" + npm_repo = length(var.package_managers.npm) > 0 ? var.package_managers.npm[0] : "npm-public" + go_repo = length(var.package_managers.go) > 0 ? var.package_managers.go[0] : "go-public" + pypi_repo = length(var.package_managers.pypi) > 0 ? var.package_managers.pypi[0] : "pypi-public" + + npmrc = <<-EOF +registry=${var.nexus_url}/repository/${local.npm_repo}/ +//${local.nexus_host}/repository/${local.npm_repo}/:username=${local.username} +//${local.nexus_host}/repository/${local.npm_repo}/:_password=${base64encode(var.nexus_password)} +//${local.nexus_host}/repository/${local.npm_repo}/:always-auth=true +EOF +} + +resource "coder_script" "nexus" { + agent_id = var.agent_id + display_name = "nexus-repository" + icon = "/icon/nexus-repository.svg" + script = templatefile("${path.module}/run.sh", { + NEXUS_URL = var.nexus_url + NEXUS_HOST = local.nexus_host + NEXUS_USERNAME = local.username + NEXUS_PASSWORD = var.nexus_password + HAS_MAVEN = length(var.package_managers.maven) == 0 ? "" : "YES" + MAVEN_REPO = local.maven_repo + HAS_NPM = length(var.package_managers.npm) == 0 ? "" : "YES" + NPMRC = local.npmrc + HAS_GO = length(var.package_managers.go) == 0 ? "" : "YES" + GO_REPO = local.go_repo + HAS_PYPI = length(var.package_managers.pypi) == 0 ? "" : "YES" + PYPI_REPO = local.pypi_repo + HAS_DOCKER = length(var.package_managers.docker) == 0 ? "" : "YES" + REGISTER_DOCKER = join("\n ", formatlist("register_docker \"%s\"", var.package_managers.docker)) + }) + run_on_start = true +} + +resource "coder_env" "goproxy" { + count = length(var.package_managers.go) == 0 ? 0 : 1 + agent_id = var.agent_id + name = "GOPROXY" + value = join(",", [ + for repo in var.package_managers.go : + "https://${local.username}:${var.nexus_password}@${local.nexus_host}/repository/${repo}" + ]) +} + diff --git a/registry/mavrickrishi/modules/nexus-repository/run.sh b/registry/mavrickrishi/modules/nexus-repository/run.sh new file mode 100644 index 00000000..b860c4bc --- /dev/null +++ b/registry/mavrickrishi/modules/nexus-repository/run.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash + +not_configured() { + type=$1 + echo "πŸ€” no $type repository is set, skipping $type configuration." +} + +config_complete() { + echo "πŸ₯³ Configuration complete!" +} + +register_docker() { + repo=$1 + echo -n "${NEXUS_PASSWORD}" | docker login "${NEXUS_HOST}/repository/$${repo}" --username "${NEXUS_USERNAME}" --password-stdin +} + +echo "πŸš€ Configuring Nexus repository access..." + +# Configure Maven +if [ -n "${HAS_MAVEN}" ]; then + echo "β˜• Configuring Maven..." + mkdir -p ~/.m2 + cat > ~/.m2/settings.xml << 'EOF' + + + + + nexus + ${NEXUS_USERNAME} + ${NEXUS_PASSWORD} + + + + + nexus-mirror + * + ${NEXUS_URL}/repository/${MAVEN_REPO} + + + +EOF + config_complete +else + not_configured maven +fi + +# Configure npm +if [ -n "${HAS_NPM}" ]; then + echo "πŸ“¦ Configuring npm..." + cat > ~/.npmrc << 'EOF' +${NPMRC} +EOF + config_complete +else + not_configured npm +fi + +# Configure Go +if [ -n "${HAS_GO}" ]; then + echo "🐹 Configuring Go..." + # Go configuration is handled via GOPROXY environment variable + # which is set by the Terraform configuration + echo "Go proxy configured via GOPROXY environment variable" + config_complete +else + not_configured go +fi + +# Configure pip +if [ -n "${HAS_PYPI}" ]; then + echo "🐍 Configuring pip..." + mkdir -p ~/.pip + # Create .netrc file for secure credential storage + cat > ~/.netrc << EOF +machine ${NEXUS_HOST} +login ${NEXUS_USERNAME} +password ${NEXUS_PASSWORD} +EOF + chmod 600 ~/.netrc + + # Update pip.conf to use index-url without embedded credentials + cat > ~/.pip/pip.conf << 'EOF' +[global] +index-url = https://${NEXUS_HOST}/repository/${PYPI_REPO}/simple +EOF + config_complete +else + not_configured pypi +fi + +# Configure Docker +if [ -n "${HAS_DOCKER}" ]; then + if command -v docker > /dev/null 2>&1; then + echo "🐳 Configuring Docker credentials..." + mkdir -p ~/.docker + ${REGISTER_DOCKER} + config_complete + else + echo "πŸ€” Docker is not installed, skipping Docker configuration." + fi +else + not_configured docker +fi + +echo "βœ… Nexus repository configuration completed!"