diff --git a/.icons/cloudcli.svg b/.icons/cloudcli.svg new file mode 100644 index 000000000..35b24715a --- /dev/null +++ b/.icons/cloudcli.svg @@ -0,0 +1,18 @@ + + + + + diff --git a/registry/edd88-pixel/.images/avatar.png b/registry/edd88-pixel/.images/avatar.png new file mode 100644 index 000000000..402805c1d Binary files /dev/null and b/registry/edd88-pixel/.images/avatar.png differ diff --git a/registry/edd88-pixel/README.md b/registry/edd88-pixel/README.md new file mode 100644 index 000000000..6327c06f0 --- /dev/null +++ b/registry/edd88-pixel/README.md @@ -0,0 +1,11 @@ +--- +display_name: "Eddy Marc" +bio: "Open source contributor focused on Terraform, DevOps, and cloud platform engineering" +avatar: "./.images/avatar.png" +github: "Edd88-pixel" +status: "community" +--- + +# Eddy Marc + +Open source contributor focused on Terraform, DevOps, and cloud platform engineering. diff --git a/registry/edd88-pixel/modules/cloudcli/README.md b/registry/edd88-pixel/modules/cloudcli/README.md new file mode 100644 index 000000000..2d7643aad --- /dev/null +++ b/registry/edd88-pixel/modules/cloudcli/README.md @@ -0,0 +1,95 @@ +--- +display_name: CloudCLI +description: Run the CloudCLI web interface securely inside a Coder workspace +icon: ../../../../.icons/cloudcli.svg +verified: false +tags: [agent, ai, cloudcli, web] +--- + +# CloudCLI + +Install and run the open source [CloudCLI](https://cloudcli.ai/) web interface for AI coding agents already available in a Coder workspace. + +```tf +module "cloudcli" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/edd88-pixel/cloudcli/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} +``` + +## Prerequisites + +The workspace image must provide Node.js 22 or newer and npm. Install and authenticate at least one CloudCLI-supported coding agent, such as Claude Code, Cursor CLI, Codex, Gemini CLI, or OpenCode, before using this module. The module reuses those existing agent installations and credentials; it does not install agents or modify their authentication, permissions, settings, or MCP configuration. + +For example, install and authenticate Claude Code alongside CloudCLI: + +```tf +module "claude-code" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/claude-code/coder" + version = "5.2.0" + agent_id = coder_agent.main.id + + anthropic_api_key = var.anthropic_api_key +} + +module "cloudcli" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/edd88-pixel/cloudcli/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} +``` + +See the [Claude Code module](https://registry.coder.com/modules/coder/claude-code) for OAuth and Coder AI Gateway authentication alternatives. + +## Security + +CloudCLI has access to files and authenticated coding agents inside the workspace. This module therefore: + +- binds CloudCLI explicitly to `127.0.0.1`; +- exposes it only through an owner-only Coder app; +- installs the pinned npm package under `$HOME/.coder-modules/edd88-pixel/cloudcli/runtime`; +- stores module-managed logs, runtime data, and process state under `$HOME/.coder-modules/edd88-pixel/cloudcli`. + +No public listener, TLS proxy, tunnel, process manager, or nested container runtime is created. + +> [!IMPORTANT] +> CloudCLI currently requires Coder's [wildcard access URL](https://coder.com/docs/admin/networking/wildcard-access-url). Its frontend uses root-relative API and WebSocket routes that are not compatible with Coder's path-based app proxy. + +> [!NOTE] +> CloudCLI's open source single-user mode currently requires a one-time account setup. The Coder app remains restricted to the workspace owner, but this module does not bypass CloudCLI's authentication. + +## Limit project discovery + +CloudCLI discovers projects under the workspace user's home directory by default. For a narrower and safer scope, set `workspaces_root` to an absolute path: + +```tf +module "cloudcli" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/edd88-pixel/cloudcli/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + workspaces_root = "/home/coder/project" +} +``` + +The path is validated before it is rendered into the startup script. Relative paths, whitespace, shell metacharacters, and parent-directory components are rejected. + +## Troubleshooting + +```bash +cat ~/.coder-modules/edd88-pixel/cloudcli/logs/install.log +cat ~/.coder-modules/edd88-pixel/cloudcli/logs/start.log +cat ~/.coder-modules/edd88-pixel/cloudcli/logs/cloudcli.log +``` + +If startup reports that the configured port is already in use, choose a different `port` or stop the conflicting process. The module never terminates an unrelated listener. + +## References + +- [CloudCLI prerequisites](https://cloudcli.ai/docs/open-source-self-hosting/prerequisites) +- [CloudCLI environment variables](https://cloudcli.ai/docs/configuration/environment-variables) +- [CloudCLI source](https://github.com/siteboon/claudecodeui) diff --git a/registry/edd88-pixel/modules/cloudcli/main.test.ts b/registry/edd88-pixel/modules/cloudcli/main.test.ts new file mode 100644 index 000000000..f85a6febe --- /dev/null +++ b/registry/edd88-pixel/modules/cloudcli/main.test.ts @@ -0,0 +1,490 @@ +import { + afterEach, + beforeAll, + describe, + expect, + setDefaultTimeout, + test, +} from "bun:test"; +import { + execContainer, + readFileContainer, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + TerraformState, + testRequiredVariables, + writeCoder, + writeFileContainer, +} from "~test"; +import path from "node:path"; + +const MODULE_ROOT = "/home/coder/.coder-modules/edd88-pixel/cloudcli"; +const DEFAULT_PATH = + "/usr/local/test-bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; + +interface ModuleScripts { + install: string; + start: string; +} + +interface SetupResult { + id: string; + scripts: ModuleScripts; +} + +let defaultScripts: ModuleScripts; +let customScripts: ModuleScripts; +let cleanupFunctions: Array<() => Promise> = []; + +const collectScripts = (state: TerraformState): ModuleScripts => { + const scripts: Partial = {}; + + for (const resource of state.resources) { + if (resource.type !== "coder_script") { + continue; + } + + for (const instance of resource.instances) { + const attributes = instance.attributes as Record; + const displayName = attributes.display_name; + const script = attributes.script; + if (typeof displayName !== "string" || typeof script !== "string") { + continue; + } + + if (displayName === "CloudCLI: Install Script") { + scripts.install = script; + } else if (displayName === "CloudCLI: Start Script") { + scripts.start = script; + } + } + } + + if (!scripts.install || !scripts.start) { + throw new Error("CloudCLI install and start scripts were not found"); + } + + return scripts as ModuleScripts; +}; + +const npmMock = `#!/usr/bin/env bash +set -euo pipefail + +if [ "\${1:-}" = "--version" ]; then + printf '10.0.0\\n' + exit 0 +fi + +if [ "\${1:-}" != "install" ]; then + printf 'unexpected npm command: %s\\n' "$*" >&2 + exit 2 +fi + +printf '%s\\n' "$*" > /tmp/npm-invocation +prefix="" +package_spec="" +shift + +while [ "$#" -gt 0 ]; do + case "$1" in + --prefix) + prefix="$2" + shift 2 + ;; + @cloudcli-ai/cloudcli@*) + package_spec="$1" + shift + ;; + *) + shift + ;; + esac +done + +if [ -z "$prefix" ] || [ -z "$package_spec" ]; then + printf 'missing npm prefix or CloudCLI package spec\\n' >&2 + exit 2 +fi + +version="\${package_spec##*@}" +package_dir="$prefix/node_modules/@cloudcli-ai/cloudcli" +mkdir -p "$package_dir" "$prefix/node_modules/.bin" +printf '{"name":"@cloudcli-ai/cloudcli","version":"%s"}\\n' "$version" > "$package_dir/package.json" +cp /tmp/cloudcli-mock.sh "$prefix/node_modules/.bin/cloudcli" +chmod 755 "$prefix/node_modules/.bin/cloudcli" +`; + +const runScript = async ( + id: string, + name: string, + script: string, + pathValue = DEFAULT_PATH, +) => { + const scriptPath = `/tmp/${name}.sh`; + await writeFileContainer(id, scriptPath, script, { user: "root" }); + const chmod = await execContainer( + id, + ["chmod", "755", scriptPath], + ["--user", "root"], + ); + expect(chmod.exitCode).toBe(0); + + return execContainer( + id, + ["bash", scriptPath], + [ + "--user", + "coder", + "--env", + "HOME=/home/coder", + "--env", + `PATH=${pathValue}`, + ], + ); +}; + +const setup = async (scripts = defaultScripts): Promise => { + const id = await runContainer("node:22-bookworm-slim"); + cleanupFunctions.push(async () => { + await removeContainer(id); + }); + + const createUser = await execContainer( + id, + [ + "bash", + "-c", + "id coder >/dev/null 2>&1 || useradd --create-home --shell /bin/bash coder", + ], + ["--user", "root"], + ); + expect(createUser.exitCode).toBe(0); + + await writeCoder(id, "#!/usr/bin/env bash\nexit 0\n"); + await writeFileContainer( + id, + "/tmp/cloudcli-mock.sh", + await Bun.file( + path.join(import.meta.dir, "testdata", "cloudcli-mock.sh"), + ).text(), + { user: "root" }, + ); + await writeFileContainer(id, "/tmp/npm-mock.sh", npmMock, { + user: "root", + }); + + const installMocks = await execContainer( + id, + [ + "bash", + "-c", + "mkdir -p /usr/local/test-bin && cp /tmp/npm-mock.sh /usr/local/test-bin/npm && chmod 755 /usr/local/test-bin/npm /tmp/cloudcli-mock.sh", + ], + ["--user", "root"], + ); + expect(installMocks.exitCode).toBe(0); + + return { id, scripts }; +}; + +const installCloudCLI = async (result: SetupResult) => { + const response = await runScript( + result.id, + "cloudcli-install", + result.scripts.install, + ); + if (response.exitCode !== 0) { + console.error(response.stdout); + console.error(response.stderr); + } + expect(response.exitCode).toBe(0); +}; + +const startCloudCLI = async (result: SetupResult) => { + const response = await runScript( + result.id, + "cloudcli-start", + result.scripts.start, + ); + if (response.exitCode !== 0) { + console.error(response.stdout); + console.error(response.stderr); + } + expect(response.exitCode).toBe(0); + return response; +}; + +afterEach(async () => { + const cleanups = cleanupFunctions.reverse(); + cleanupFunctions = []; + + for (const cleanup of cleanups) { + try { + await cleanup(); + } catch (error) { + console.error("Container cleanup failed:", error); + } + } +}); + +setDefaultTimeout(120_000); + +describe("cloudcli", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + defaultScripts = collectScripts( + await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + }), + ); + customScripts = collectScripts( + await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + port: 43123, + workspaces_root: "/home/coder/project", + }), + ); + }); + + testRequiredVariables(import.meta.dir, { + agent_id: "test-agent", + }); + + test("installs the exact package version in the isolated runtime", async () => { + const result = await setup(); + await installCloudCLI(result); + + const packageJSON = await readFileContainer( + result.id, + `${MODULE_ROOT}/runtime/node_modules/@cloudcli-ai/cloudcli/package.json`, + ); + expect(JSON.parse(packageJSON).version).toBe("1.35.0"); + + const invocation = await readFileContainer( + result.id, + "/tmp/npm-invocation", + ); + expect(invocation).toContain(`--prefix ${MODULE_ROOT}/runtime`); + expect(invocation).toContain("@cloudcli-ai/cloudcli@1.35.0"); + expect(invocation).not.toMatch(/(^|\s)(-g|--global)(\s|$)/); + + const globalBinary = await execContainer( + result.id, + ["bash", "-c", "command -v cloudcli"], + [ + "--user", + "coder", + "--env", + "HOME=/home/coder", + "--env", + `PATH=${DEFAULT_PATH}`, + ], + ); + expect(globalBinary.exitCode).not.toBe(0); + }); + + test("starts on IPv4 loopback with the secure defaults", async () => { + const result = await setup(); + await installCloudCLI(result); + + await execContainer( + result.id, + [ + "bash", + "-c", + `mkdir -p '${MODULE_ROOT}/run' && echo 999999 > '${MODULE_ROOT}/run/cloudcli.pid' && chown -R coder:coder '/home/coder/.coder-modules'`, + ], + ["--user", "root"], + ); + const response = await startCloudCLI(result); + expect(response.stdout).toContain("Waiting for CloudCLI to come online..."); + expect(response.stderr).not.toContain("curl:"); + + const health = await execContainer( + result.id, + [ + "node", + "-e", + "require('node:http').get('http://127.0.0.1:3001/health',r=>{r.resume();process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))", + ], + ["--user", "coder"], + ); + expect(health.exitCode).toBe(0); + + const listener = await execContainer(result.id, [ + "bash", + "-c", + "port_hex=$(printf '%04X' 3001); awk -v expected=\"0100007F:$port_hex\" '$2 == expected && $4 == \"0A\" { print $2 }' /proc/net/tcp", + ]); + expect(listener.stdout.trim()).toBe("0100007F:0BB9"); + + const environment = await readFileContainer( + result.id, + `${MODULE_ROOT}/run/mock-environment`, + ); + expect(environment).toContain("HOST=127.0.0.1"); + expect(environment).toContain("SERVER_PORT=3001"); + expect(environment).toContain(`DATABASE_PATH=${MODULE_ROOT}/data/auth.db`); + expect(environment).toContain("WORKSPACES_ROOT=\n"); + + const pid = await readFileContainer( + result.id, + `${MODULE_ROOT}/run/cloudcli.pid`, + ); + expect(pid.trim()).not.toBe("999999"); + }); + + test("renders a custom port and workspace root safely", async () => { + const result = await setup(customScripts); + await installCloudCLI(result); + await startCloudCLI(result); + + const environment = await readFileContainer( + result.id, + `${MODULE_ROOT}/run/mock-environment`, + ); + expect(environment).toContain("HOST=127.0.0.1"); + expect(environment).toContain("SERVER_PORT=43123"); + expect(environment).toContain("WORKSPACES_ROOT=/home/coder/project"); + + const argumentsFile = await readFileContainer( + result.id, + `${MODULE_ROOT}/run/mock-arguments`, + ); + expect(argumentsFile).toContain("--port 43123"); + expect(argumentsFile).toContain( + `--database-path ${MODULE_ROOT}/data/auth.db`, + ); + }); + + test("does not start a second process when CloudCLI is healthy", async () => { + const result = await setup(); + await installCloudCLI(result); + await startCloudCLI(result); + + const firstPID = ( + await readFileContainer(result.id, `${MODULE_ROOT}/run/cloudcli.pid`) + ).trim(); + await startCloudCLI(result); + const secondPID = ( + await readFileContainer(result.id, `${MODULE_ROOT}/run/cloudcli.pid`) + ).trim(); + + expect(secondPID).toBe(firstPID); + }); + + test("fails without terminating an unrelated listener", async () => { + const result = await setup(); + await installCloudCLI(result); + + const occupied = await execContainer( + result.id, + [ + "bash", + "-c", + "nohup node -e \"require('node:http').createServer((q,s)=>{s.statusCode=404;s.end()}).listen(3001,'127.0.0.1')\" >/tmp/occupied.log 2>&1 & echo $! >/tmp/occupied.pid; sleep 1", + ], + ["--user", "coder", "--env", "HOME=/home/coder"], + ); + expect(occupied.exitCode).toBe(0); + + const response = await runScript( + result.id, + "cloudcli-start-occupied", + result.scripts.start, + ); + expect(response.exitCode).not.toBe(0); + expect(`${response.stdout}\n${response.stderr}`).toContain( + "already used by another process", + ); + + const stillRunning = await execContainer( + result.id, + ["bash", "-c", 'kill -0 "$(cat /tmp/occupied.pid)"'], + ["--user", "coder"], + ); + expect(stillRunning.exitCode).toBe(0); + }); + + test("rejects a healthy listener that is not restricted to loopback", async () => { + const result = await setup(); + await installCloudCLI(result); + + const occupied = await execContainer( + result.id, + [ + "bash", + "-c", + "nohup node -e \"require('node:http').createServer((q,s)=>{s.setHeader('content-type','application/json');s.end(JSON.stringify({status:'ok'}))}).listen(3001,'0.0.0.0')\" >/tmp/wildcard.log 2>&1 & echo $! >/tmp/wildcard.pid; sleep 1", + ], + ["--user", "coder", "--env", "HOME=/home/coder"], + ); + expect(occupied.exitCode).toBe(0); + + const response = await runScript( + result.id, + "cloudcli-start-wildcard", + result.scripts.start, + ); + expect(response.exitCode).not.toBe(0); + expect(`${response.stdout}\n${response.stderr}`).toContain( + "not restricted to IPv4 loopback", + ); + + const stillRunning = await execContainer( + result.id, + ["bash", "-c", 'kill -0 "$(cat /tmp/wildcard.pid)"'], + ["--user", "coder"], + ); + expect(stillRunning.exitCode).toBe(0); + }); + + test("rejects a workspace without Node.js", async () => { + const result = await setup(); + const response = await runScript( + result.id, + "cloudcli-install-no-node", + result.scripts.install, + "/usr/local/test-bin:/usr/bin:/bin", + ); + + expect(response.exitCode).not.toBe(0); + expect(`${response.stdout}\n${response.stderr}`).toContain( + "node is required", + ); + }); + + test("rejects Node.js versions older than 22", async () => { + const result = await setup(); + await writeFileContainer( + result.id, + "/tmp/node", + "#!/usr/bin/env bash\nprintf 'v20.19.0\\n'\n", + { user: "root" }, + ); + const prepareOldNode = await execContainer( + result.id, + [ + "bash", + "-c", + "mkdir -p /tmp/old-node-bin && cp /tmp/node /tmp/old-node-bin/node && chmod 755 /tmp/old-node-bin/node", + ], + ["--user", "root"], + ); + expect(prepareOldNode.exitCode).toBe(0); + + const response = await runScript( + result.id, + "cloudcli-install-old-node", + result.scripts.install, + "/tmp/old-node-bin:/usr/local/test-bin:/usr/bin:/bin", + ); + + expect(response.exitCode).not.toBe(0); + expect(`${response.stdout}\n${response.stderr}`).toContain( + "requires Node.js 22 or newer; found v20.19.0", + ); + }); +}); diff --git a/registry/edd88-pixel/modules/cloudcli/main.tf b/registry/edd88-pixel/modules/cloudcli/main.tf new file mode 100644 index 000000000..5b7214bf5 --- /dev/null +++ b/registry/edd88-pixel/modules/cloudcli/main.tf @@ -0,0 +1,139 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.13" + } + } +} + +variable "agent_id" { + description = "The ID of a Coder agent." + type = string +} + +variable "port" { + description = "The loopback port used by the CloudCLI server and Coder app." + type = number + default = 3001 + + validation { + condition = var.port >= 1024 && var.port <= 65535 && floor(var.port) == var.port + error_message = "port must be an integer between 1024 and 65535." + } +} + +variable "cloudcli_version" { + description = "Exact CloudCLI package version to install." + type = string + default = "1.35.0" + + validation { + condition = can(regex("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$", var.cloudcli_version)) + error_message = "cloudcli_version must be an exact stable semantic version such as 1.35.0." + } +} + +variable "workspaces_root" { + description = "Optional absolute directory that limits CloudCLI project discovery. When unset, CloudCLI uses the workspace user's home directory." + type = string + default = null + + validation { + condition = var.workspaces_root == null ? true : ( + can(regex("^/[A-Za-z0-9._/-]+$", var.workspaces_root)) && + !can(regex("(^|/)\\.\\.(/|$)", var.workspaces_root)) + ) + error_message = "workspaces_root must be an absolute path containing only letters, numbers, dots, underscores, hyphens, and slashes, without parent-directory components." + } +} + +variable "order" { + description = "The order determines the position of the app in the Coder UI. The lowest order is shown first." + type = number + default = null +} + +variable "group" { + description = "The name of a group that this app belongs to." + type = string + default = null +} + +locals { + icon_url = "https://avatars.githubusercontent.com/u/252026187?s=200&v=4" + module_directory = "$HOME/.coder-modules/edd88-pixel/cloudcli" + start_script_name = "edd88-pixel-cloudcli-start_script" + start_script_path = "${local.module_directory}/scripts/start.sh" + start_log_path = "${local.module_directory}/logs/start.log" + + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_CLOUDCLI_VERSION = var.cloudcli_version + }) + + start_script = templatefile("${path.module}/scripts/start.sh.tftpl", { + ARG_PORT = tostring(var.port) + ARG_WORKSPACES_ROOT_B64 = var.workspaces_root == null ? "" : base64encode(var.workspaces_root) + }) +} + +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" + + agent_id = var.agent_id + module_directory = local.module_directory + display_name_prefix = "CloudCLI" + icon = local.icon_url + install_script = local.install_script +} + +resource "coder_script" "start_script" { + agent_id = var.agent_id + display_name = "CloudCLI: Start Script" + icon = local.icon_url + run_on_start = true + start_blocks_login = false + + script = <<-EOT + #!/bin/bash + set -o errexit + set -o pipefail + + trap 'coder exp sync complete ${local.start_script_name}' EXIT + + coder exp sync want ${local.start_script_name} ${module.coder_utils.scripts[0]} + coder exp sync start --timeout 30m ${local.start_script_name} + + echo -n '${base64encode(local.start_script)}' | base64 -d > ${local.start_script_path} + chmod +x ${local.start_script_path} + + ${local.start_script_path} 2>&1 | tee ${local.start_log_path} + EOT +} + +# CloudCLI 1.35.0 uses root-relative API and WebSocket routes, so Coder's path proxy cannot serve it correctly. +resource "coder_app" "cloudcli" { + agent_id = var.agent_id + slug = "cloudcli" + display_name = "CloudCLI" + url = "http://localhost:${var.port}" + icon = local.icon_url + subdomain = true + share = "owner" + order = var.order + group = var.group + + healthcheck { + url = "http://localhost:${var.port}/health" + interval = 5 + threshold = 6 + } +} + +output "scripts" { + description = "Ordered list of coder exp sync names produced by the CloudCLI install and start pipeline." + value = concat(module.coder_utils.scripts, [local.start_script_name]) +} diff --git a/registry/edd88-pixel/modules/cloudcli/main.tftest.hcl b/registry/edd88-pixel/modules/cloudcli/main.tftest.hcl new file mode 100644 index 000000000..9a6d3277a --- /dev/null +++ b/registry/edd88-pixel/modules/cloudcli/main.tftest.hcl @@ -0,0 +1,184 @@ +mock_provider "coder" {} + +run "defaults_are_secure" { + command = apply + + variables { + agent_id = "test-agent" + } + + assert { + condition = coder_app.cloudcli.url == "http://localhost:3001" + error_message = "The default app URL must use port 3001." + } + + assert { + condition = coder_app.cloudcli.subdomain + error_message = "CloudCLI must use a Coder subdomain." + } + + assert { + condition = coder_app.cloudcli.share == "owner" + error_message = "CloudCLI must be restricted to the workspace owner." + } + + assert { + condition = coder_app.cloudcli.icon == "https://avatars.githubusercontent.com/u/252026187?s=200&v=4" + error_message = "The app must use the CloudCLI project icon." + } + + assert { + condition = one(coder_app.cloudcli.healthcheck).url == "http://localhost:3001/health" + error_message = "The health check must use CloudCLI's /health endpoint." + } + + assert { + condition = strcontains(local.start_script, "export HOST=\"127.0.0.1\"") + error_message = "The startup script must bind CloudCLI to IPv4 loopback." + } + + assert { + condition = !strcontains(local.start_script, "0.0.0.0") + error_message = "The default startup script must not contain an all-interface listener." + } + + assert { + condition = strcontains(local.start_script, "DATABASE_PATH=\"$DATA_DIR/auth.db\"") + error_message = "CloudCLI's database must be stored in the module data directory." + } + + assert { + condition = local.module_directory == "$HOME/.coder-modules/edd88-pixel/cloudcli" + error_message = "The module must use the standard per-module data root." + } + + assert { + condition = length(output.scripts) == 2 + error_message = "The coder-utils pipeline must expose install and start scripts." + } + + assert { + condition = strcontains(coder_script.start_script.script, "coder exp sync start --timeout 30m edd88-pixel-cloudcli-start_script") + error_message = "The start script must allow slow first-time CloudCLI installations to finish." + } + + assert { + condition = ( + strcontains(local.start_script, "Waiting for CloudCLI to come online...") && + !strcontains(local.start_script, "--show-error") + ) + error_message = "Readiness polling must log a calm status message without transient curl warnings." + } +} + +run "custom_configuration" { + command = apply + + variables { + agent_id = "test-agent" + port = 43123 + workspaces_root = "/home/coder/project" + order = 7 + group = "AI Tools" + } + + assert { + condition = coder_app.cloudcli.url == "http://localhost:43123" + error_message = "The app URL must use the configured port." + } + + assert { + condition = one(coder_app.cloudcli.healthcheck).url == "http://localhost:43123/health" + error_message = "The health check must use the configured port." + } + + assert { + condition = coder_app.cloudcli.order == 7 && coder_app.cloudcli.group == "AI Tools" + error_message = "The app must preserve its configured order and group." + } + + assert { + condition = strcontains(local.start_script, base64encode("/home/coder/project")) + error_message = "The configured workspace root must be encoded into the startup script." + } +} + +run "rejects_low_port" { + command = plan + + variables { + agent_id = "test-agent" + port = 1023 + } + + expect_failures = [var.port] +} + +run "rejects_high_port" { + command = plan + + variables { + agent_id = "test-agent" + port = 65536 + } + + expect_failures = [var.port] +} + +run "rejects_relative_workspace_root" { + command = plan + + variables { + agent_id = "test-agent" + workspaces_root = "project" + } + + expect_failures = [var.workspaces_root] +} + +run "rejects_dangerous_workspace_root" { + command = plan + + variables { + agent_id = "test-agent" + workspaces_root = "/home/coder/project;touch-pwned" + } + + expect_failures = [var.workspaces_root] +} + +run "rejects_parent_workspace_root" { + command = plan + + variables { + agent_id = "test-agent" + workspaces_root = "/home/coder/../root" + } + + expect_failures = [var.workspaces_root] +} + +run "rejects_unpinned_version" { + command = plan + + variables { + agent_id = "test-agent" + cloudcli_version = "latest" + } + + expect_failures = [var.cloudcli_version] +} + +run "accepts_exact_version" { + command = plan + + variables { + agent_id = "test-agent" + cloudcli_version = "2.4.6" + } + + assert { + condition = strcontains(local.install_script, "ARG_CLOUDCLI_VERSION='2.4.6'") + error_message = "The exact CloudCLI version must be rendered into the install script." + } +} diff --git a/registry/edd88-pixel/modules/cloudcli/scripts/install.sh.tftpl b/registry/edd88-pixel/modules/cloudcli/scripts/install.sh.tftpl new file mode 100644 index 000000000..c3400e0fe --- /dev/null +++ b/registry/edd88-pixel/modules/cloudcli/scripts/install.sh.tftpl @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +if [ -f "$HOME/.bashrc" ]; then + # shellcheck disable=SC1090 + source "$HOME/.bashrc" +fi + +if [ -s "$HOME/.nvm/nvm.sh" ]; then + # shellcheck disable=SC1091 + source "$HOME/.nvm/nvm.sh" +fi + +set -euo pipefail + +ARG_CLOUDCLI_VERSION='${ARG_CLOUDCLI_VERSION}' +MODULE_ROOT="$HOME/.coder-modules/edd88-pixel/cloudcli" +RUNTIME_DIR="$MODULE_ROOT/runtime" +PACKAGE_DIR="$RUNTIME_DIR/node_modules/@cloudcli-ai/cloudcli" +CLOUDCLI_BIN="$RUNTIME_DIR/node_modules/.bin/cloudcli" +PACKAGE_SPEC="@cloudcli-ai/cloudcli@$ARG_CLOUDCLI_VERSION" + +require_command() { + if ! command -v "$1" > /dev/null 2>&1; then + printf 'Error: %s is required. Install Node.js 22 or newer with npm in the workspace image.\n' "$1" >&2 + exit 1 + fi +} + +require_supported_node() { + local node_version + local node_major + + node_version="$(node --version)" + node_major="$${node_version#v}" + node_major="$${node_major%%.*}" + + if ! [[ "$node_major" =~ ^[0-9]+$ ]] || ((node_major < 22)); then + printf 'Error: CloudCLI requires Node.js 22 or newer; found %s.\n' "$node_version" >&2 + exit 1 + fi + + printf 'Using Node.js %s and npm %s.\n' "$node_version" "$(npm --version)" +} + +installed_version() { + if [ ! -f "$PACKAGE_DIR/package.json" ]; then + return 1 + fi + + PACKAGE_JSON="$PACKAGE_DIR/package.json" node -p "require(process.env.PACKAGE_JSON).version" +} + +require_command node +require_command npm +require_supported_node + +mkdir -p "$RUNTIME_DIR" "$MODULE_ROOT/data" "$MODULE_ROOT/run" + +current_version="$(installed_version || true)" +if [ "$current_version" = "$ARG_CLOUDCLI_VERSION" ] && [ -x "$CLOUDCLI_BIN" ]; then + printf 'CloudCLI %s is already installed in %s.\n' "$current_version" "$RUNTIME_DIR" + exit 0 +fi + +printf 'Installing %s in %s.\n' "$PACKAGE_SPEC" "$RUNTIME_DIR" +npm install \ + --prefix "$RUNTIME_DIR" \ + --no-save \ + --package-lock=false \ + --omit=dev \ + --no-audit \ + --no-fund \ + "$PACKAGE_SPEC" + +if [ ! -x "$CLOUDCLI_BIN" ]; then + printf 'Error: CloudCLI installation completed without creating %s.\n' "$CLOUDCLI_BIN" >&2 + exit 1 +fi + +current_version="$(installed_version)" +if [ "$current_version" != "$ARG_CLOUDCLI_VERSION" ]; then + printf 'Error: expected CloudCLI %s but npm installed %s.\n' "$ARG_CLOUDCLI_VERSION" "$current_version" >&2 + exit 1 +fi + +printf 'Installed CloudCLI %s successfully.\n' "$current_version" diff --git a/registry/edd88-pixel/modules/cloudcli/scripts/start.sh.tftpl b/registry/edd88-pixel/modules/cloudcli/scripts/start.sh.tftpl new file mode 100644 index 000000000..b9255b7ba --- /dev/null +++ b/registry/edd88-pixel/modules/cloudcli/scripts/start.sh.tftpl @@ -0,0 +1,185 @@ +#!/usr/bin/env bash + +if [ -f "$HOME/.bashrc" ]; then + # shellcheck disable=SC1090 + source "$HOME/.bashrc" +fi + +if [ -s "$HOME/.nvm/nvm.sh" ]; then + # shellcheck disable=SC1091 + source "$HOME/.nvm/nvm.sh" +fi + +set -euo pipefail + +ARG_PORT='${ARG_PORT}' +ARG_WORKSPACES_ROOT_B64='${ARG_WORKSPACES_ROOT_B64}' +MODULE_ROOT="$HOME/.coder-modules/edd88-pixel/cloudcli" +CLOUDCLI_BIN="$MODULE_ROOT/runtime/node_modules/.bin/cloudcli" +DATA_DIR="$MODULE_ROOT/data" +RUN_DIR="$MODULE_ROOT/run" +PID_FILE="$RUN_DIR/cloudcli.pid" +SERVICE_LOG="$MODULE_ROOT/logs/cloudcli.log" +HEALTH_URL="http://127.0.0.1:$ARG_PORT/health" + +health_ready() { + if command -v curl > /dev/null 2>&1; then + curl --fail --silent --max-time 2 "$HEALTH_URL" > /dev/null + return + fi + + if command -v wget > /dev/null 2>&1; then + wget --quiet --timeout=2 --output-document=/dev/null "$HEALTH_URL" + return + fi + + node -e ' + const http = require("node:http"); + const request = http.get(process.argv[1], { timeout: 2000 }, (response) => { + response.resume(); + process.exit(response.statusCode >= 200 && response.statusCode < 400 ? 0 : 1); + }); + request.on("timeout", () => request.destroy()); + request.on("error", () => process.exit(1)); + ' "$HEALTH_URL" +} + +port_in_use() { + node -e ' + const net = require("node:net"); + const socket = net.createConnection({ + host: process.argv[1], + port: Number(process.argv[2]), + timeout: 500, + }); + socket.on("connect", () => { + socket.destroy(); + process.exit(0); + }); + socket.on("timeout", () => { + socket.destroy(); + process.exit(1); + }); + socket.on("error", () => process.exit(1)); + ' "127.0.0.1" "$ARG_PORT" +} + +stop_started_process() { + local pid="$1" + + if kill -0 "$pid" 2> /dev/null; then + kill "$pid" 2> /dev/null || true + wait "$pid" 2> /dev/null || true + fi + rm -f "$PID_FILE" +} + +verify_loopback_listener() { + local listeners + local address + local port_hex="" + + if command -v ss > /dev/null 2>&1; then + listeners="$(ss -H -ltn "sport = :$ARG_PORT" | awk '{print $4}')" + elif [ -r /proc/net/tcp ]; then + printf -v port_hex '%04X' "$ARG_PORT" + listeners="$( + awk -v port=":$port_hex" \ + '$4 == "0A" && substr($2, length($2) - length(port) + 1) == port { print $2 }' \ + /proc/net/tcp + )" + else + return 0 + fi + + if [ -z "$listeners" ]; then + printf 'Error: CloudCLI health check succeeded but no listener was found on port %s.\n' "$ARG_PORT" >&2 + return 1 + fi + + while IFS= read -r address; do + if [ "$address" != "127.0.0.1:$ARG_PORT" ] && + { [ -z "$port_hex" ] || [ "$address" != "0100007F:$port_hex" ]; }; then + printf 'Error: CloudCLI listener %s is not restricted to IPv4 loopback.\n' "$address" >&2 + return 1 + fi + done <<< "$listeners" +} + +if [ ! -x "$CLOUDCLI_BIN" ]; then + printf 'Error: CloudCLI is not installed at %s.\n' "$CLOUDCLI_BIN" >&2 + exit 1 +fi + +mkdir -p "$DATA_DIR" "$RUN_DIR" "$(dirname "$SERVICE_LOG")" + +if health_ready; then + if ! verify_loopback_listener; then + printf 'Error: the existing healthy process was left running.\n' >&2 + exit 1 + fi + printf 'CloudCLI is already healthy at %s; leaving the existing process running.\n' "$HEALTH_URL" + exit 0 +fi + +if [ -f "$PID_FILE" ]; then + existing_pid="$(cat "$PID_FILE")" + if [[ "$existing_pid" =~ ^[0-9]+$ ]] && kill -0 "$existing_pid" 2> /dev/null; then + printf 'Error: CloudCLI process %s is running but its health check is failing.\n' "$existing_pid" >&2 + exit 1 + fi + + printf 'Removing stale CloudCLI PID file.\n' + rm -f "$PID_FILE" +fi + +if port_in_use; then + printf 'Error: port %s is already used by another process; no process was terminated.\n' "$ARG_PORT" >&2 + exit 1 +fi + +export HOST="127.0.0.1" +export SERVER_PORT="$ARG_PORT" +export PORT="$ARG_PORT" +export DATABASE_PATH="$DATA_DIR/auth.db" +export ENABLE_HTTPS="false" + +if [ -n "$ARG_WORKSPACES_ROOT_B64" ]; then + WORKSPACES_ROOT="$(printf '%s' "$ARG_WORKSPACES_ROOT_B64" | base64 --decode)" + export WORKSPACES_ROOT +else + unset WORKSPACES_ROOT +fi + +printf 'Starting CloudCLI on %s with data stored under %s.\n' "$HEALTH_URL" "$DATA_DIR" +nohup "$CLOUDCLI_BIN" start \ + --port "$ARG_PORT" \ + --database-path "$DATABASE_PATH" \ + >> "$SERVICE_LOG" 2>&1 & +cloudcli_pid=$! +printf '%s\n' "$cloudcli_pid" > "$PID_FILE" +printf 'Waiting for CloudCLI to come online...\n' + +for _ in $(seq 1 60); do + if health_ready; then + if ! verify_loopback_listener; then + stop_started_process "$cloudcli_pid" + exit 1 + fi + printf 'CloudCLI is ready at %s.\n' "$HEALTH_URL" + exit 0 + fi + + if ! kill -0 "$cloudcli_pid" 2> /dev/null; then + wait "$cloudcli_pid" || exit_code=$? + rm -f "$PID_FILE" + printf 'Error: CloudCLI exited before becoming healthy (exit code %s). See %s.\n' "$${exit_code:-unknown}" "$SERVICE_LOG" >&2 + exit 1 + fi + + sleep 1 +done + +printf 'Error: CloudCLI did not become healthy within 60 seconds. See %s.\n' "$SERVICE_LOG" >&2 +stop_started_process "$cloudcli_pid" +exit 1 diff --git a/registry/edd88-pixel/modules/cloudcli/testdata/cloudcli-mock.sh b/registry/edd88-pixel/modules/cloudcli/testdata/cloudcli-mock.sh new file mode 100644 index 000000000..85423cd43 --- /dev/null +++ b/registry/edd88-pixel/modules/cloudcli/testdata/cloudcli-mock.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [ "${1:-}" = "version" ] || [ "${1:-}" = "--version" ]; then + printf '1.35.0\n' + exit 0 +fi + +if [ "${1:-}" != "start" ]; then + printf 'unexpected CloudCLI command: %s\n' "$*" >&2 + exit 2 +fi + +module_root="$(dirname "$(dirname "$DATABASE_PATH")")" +printf '%s\n' "$*" > "$module_root/run/mock-arguments" +printf 'HOST=%s\nSERVER_PORT=%s\nDATABASE_PATH=%s\nWORKSPACES_ROOT=%s\n' \ + "$HOST" \ + "$SERVER_PORT" \ + "$DATABASE_PATH" \ + "${WORKSPACES_ROOT:-}" \ + > "$module_root/run/mock-environment" + +exec node << 'NODE' +const http = require("node:http"); + +const server = http.createServer((request, response) => { + if (request.url === "/health") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ status: "ok" })); + return; + } + + response.writeHead(404); + response.end(); +}); + +server.listen(Number(process.env.SERVER_PORT), process.env.HOST); +NODE