diff --git a/.icons/terminal.svg b/.icons/terminal.svg new file mode 100644 index 00000000..6cb6efec --- /dev/null +++ b/.icons/terminal.svg @@ -0,0 +1,3 @@ + + + diff --git a/registry/coder-labs/modules/ttyd/README.md b/registry/coder-labs/modules/ttyd/README.md new file mode 100644 index 00000000..6fca5e36 --- /dev/null +++ b/registry/coder-labs/modules/ttyd/README.md @@ -0,0 +1,57 @@ +--- +display_name: ttyd +description: Share a terminal command over the web via a Coder app +icon: ../../../../.icons/terminal.svg +verified: true +tags: [terminal, web, ttyd] +--- + +# ttyd + +Run any command and expose it as a web-based terminal via [ttyd](https://github.com/tsl0922/ttyd). Each connection spawns a new process for the configured command. The terminal is accessible as a Coder app in the workspace UI. + +```tf +module "ttyd" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder-labs/ttyd/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + command = "bash" +} +``` + +## Examples + +### Custom command + +```tf +module "ttyd" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder-labs/ttyd/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + display_name = "Shared Terminal" + command = "tmux new-session -A -s main" + share = "authenticated" +} +``` + +### Readonly with custom ttyd options + +```tf +module "ttyd" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder-labs/ttyd/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + command = "tail -f /var/log/app.log" + writable = false + additional_args = "-t fontSize=18" +} +``` + +## Session Behavior + +By default, each browser tab that opens the ttyd app spawns a **new process** for the configured command. Closing the tab kills that process. + +To get a **persistent, shared session** that survives tab closes and allows multiple viewers, use tmux as the command (see example above). This requires tmux to be installed in the workspace image. diff --git a/registry/coder-labs/modules/ttyd/main.test.ts b/registry/coder-labs/modules/ttyd/main.test.ts new file mode 100644 index 00000000..ab12879b --- /dev/null +++ b/registry/coder-labs/modules/ttyd/main.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, + type scriptOutput, + testRequiredVariables, +} from "~test"; + +function testBaseLine(output: scriptOutput) { + expect(output.exitCode).toBe(0); + + const stdout = output.stdout.join("\n"); + expect(stdout).toContain("Installing ttyd"); + expect(stdout).toContain("Installation complete!"); + expect(stdout).toContain("Starting ttyd in background..."); +} + +describe("ttyd", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + command: "bash", + }); + + it("runs with bash", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + command: "bash", + }); + + const output = await executeScriptInContainer( + state, + "alpine/curl", + "sh", + "apk add bash", + ); + + testBaseLine(output); + }, 30000); + + it("runs with custom command", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + command: "htop", + }); + + const output = await executeScriptInContainer( + state, + "alpine/curl", + "sh", + "apk add bash", + ); + + testBaseLine(output); + expect(output.stdout.join("\n")).toContain("htop"); + }, 30000); + + it("runs with writable=false", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + command: "bash", + writable: "false", + }); + + const output = await executeScriptInContainer( + state, + "alpine/curl", + "sh", + "apk add bash", + ); + + testBaseLine(output); + }, 30000); + + it("runs with subdomain=false", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + command: "bash", + agent_name: "main", + subdomain: "false", + }); + + const output = await executeScriptInContainer( + state, + "alpine/curl", + "sh", + "apk add bash", + ); + + testBaseLine(output); + }, 30000); + + it("runs with additional_args", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + command: "bash", + additional_args: "-t fontSize=18", + }); + + const output = await executeScriptInContainer( + state, + "alpine/curl", + "sh", + "apk add bash", + ); + + testBaseLine(output); + expect(output.stdout.join("\n")).toContain("fontSize=18"); + }, 30000); +}); diff --git a/registry/coder-labs/modules/ttyd/main.tf b/registry/coder-labs/modules/ttyd/main.tf new file mode 100644 index 00000000..b84ac361 --- /dev/null +++ b/registry/coder-labs/modules/ttyd/main.tf @@ -0,0 +1,165 @@ +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." +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "agent_name" { + type = string + description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)" + default = null +} + +variable "slug" { + type = string + description = "The slug of the coder_app resource." + default = "ttyd" +} + +variable "display_name" { + type = string + description = "The display name for the ttyd application." + default = "Web Terminal" +} + +variable "port" { + type = number + description = "The port to run ttyd on." + default = 7681 +} + +variable "command" { + type = string + description = "The command for ttyd to run (e.g., bash, fish, htop)." +} + +variable "writable" { + type = bool + description = "Allow clients to write to the terminal." + default = true +} + +variable "max_clients" { + type = number + description = "Maximum number of concurrent clients (0 for unlimited)." + default = 0 +} + +variable "additional_args" { + type = string + description = "Additional arguments to pass to ttyd." + default = "" +} + +variable "log_path" { + type = string + description = "The path to log ttyd output to. Defaults to ~/.local/state/ttyd/ttyd.log (XDG-compliant)." + default = "" +} + +variable "ttyd_version" { + type = string + description = "The version of ttyd to install." + default = "1.7.7" +} + +variable "share" { + type = string + description = "Who can access the app: 'owner' (workspace owner only), 'authenticated' (logged-in users), or 'public' (anyone)." + default = "owner" + validation { + condition = var.share == "owner" || var.share == "authenticated" || var.share == "public" + error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'." + } +} + +variable "subdomain" { + type = bool + description = <<-EOT + Determines whether the app will be accessed via its own subdomain or whether it will be accessed via a path on Coder. + If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible. + EOT + default = true +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "open_in" { + type = string + description = <<-EOT + Determines where the app will be opened. Valid values are "tab" and "slim-window" (default). + "tab" opens in a new tab in the same browser window. + "slim-window" opens a new browser window without navigation controls. + EOT + default = "slim-window" + validation { + condition = contains(["tab", "slim-window"], var.open_in) + error_message = "The 'open_in' variable must be one of: 'tab', 'slim-window'." + } +} + +resource "coder_script" "ttyd" { + agent_id = var.agent_id + display_name = var.display_name + icon = "/icon/terminal.svg" + script = templatefile("${path.module}/run.sh", { + PORT = var.port, + COMMAND = var.command, + WRITABLE = var.writable, + MAX_CLIENTS = var.max_clients, + ADDITIONAL_ARGS = var.additional_args, + LOG_PATH = local.log_path, + VERSION = var.ttyd_version, + BASE_PATH = local.base_path, + }) + run_on_start = true +} + +resource "coder_app" "ttyd" { + count = var.command != "" ? 1 : 0 + agent_id = var.agent_id + slug = var.slug + display_name = var.display_name + url = "http://localhost:${var.port}${local.base_path}/" + icon = "/icon/terminal.svg" + subdomain = var.subdomain + share = var.share + order = var.order + group = var.group + open_in = var.open_in + + healthcheck { + url = "http://localhost:${var.port}${local.base_path}/token" + interval = 5 + threshold = 6 + } +} + +locals { + base_path = var.subdomain ? "" : format("/@%s/%s%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name != null ? ".${var.agent_name}" : "", var.slug) + log_path = var.log_path != "" ? var.log_path : "~/.local/state/ttyd/ttyd.log" +} diff --git a/registry/coder-labs/modules/ttyd/run.sh b/registry/coder-labs/modules/ttyd/run.sh new file mode 100644 index 00000000..141beb63 --- /dev/null +++ b/registry/coder-labs/modules/ttyd/run.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BOLD='\033[[0;1m' + +if command -v ttyd &> /dev/null; then + printf "%sFound existing ttyd installation\n\n" "$${BOLD}" +else + printf "%sInstalling ttyd %s\n\n" "$${BOLD}" "${VERSION}" + + ARCH=$(uname -m) + # shellcheck disable=SC2195 + case "$${ARCH}" in + x86_64) BINARY="ttyd.x86_64" ;; + aarch64) BINARY="ttyd.aarch64" ;; + armv7l) BINARY="ttyd.armhf" ;; + armv6l) BINARY="ttyd.arm" ;; + *) + echo "ERROR: Unsupported architecture: $${ARCH}" >&2 + exit 1 + ;; + esac + + BIN_DIR="$${HOME}/.local/bin" + mkdir -p "$${BIN_DIR}" + export PATH="$${BIN_DIR}:$${PATH}" + + TTYD_BIN="$${BIN_DIR}/ttyd" + LOCK_DIR="/tmp/ttyd-install.lock" + + if [[ ! -f "$${TTYD_BIN}" ]]; then + if mkdir "$${LOCK_DIR}" 2> /dev/null; then + if [[ ! -f "$${TTYD_BIN}" ]]; then + DOWNLOAD_URL="https://github.com/tsl0922/ttyd/releases/download/${VERSION}/$${BINARY}" + printf "Downloading ttyd from %s\n" "$${DOWNLOAD_URL}" + curl -fsSL "$${DOWNLOAD_URL}" -o "$${TTYD_BIN}.tmp" + chmod +x "$${TTYD_BIN}.tmp" + mv "$${TTYD_BIN}.tmp" "$${TTYD_BIN}" + fi + rmdir "$${LOCK_DIR}" 2> /dev/null || true + else + printf "Waiting for ttyd installation to complete...\n" + while [[ -d "$${LOCK_DIR}" ]] && [[ ! -f "$${TTYD_BIN}" ]]; do + sleep 0.5 + done + fi + fi + + printf "Installation complete!\n\n" +fi + +if [[ -z "${COMMAND}" ]]; then + printf "No command specified, skipping ttyd startup.\n" + exit 0 +fi + +ARGS="-p ${PORT}" + +if [[ "${WRITABLE}" = "true" ]]; then + ARGS="$${ARGS} -W" +fi + +if [[ "${MAX_CLIENTS}" -gt 0 ]] 2> /dev/null; then + ARGS="$${ARGS} -m ${MAX_CLIENTS}" +fi + +if [[ -n "${BASE_PATH}" ]]; then + ARGS="$${ARGS} -b ${BASE_PATH}" +fi + +if [[ -n "${ADDITIONAL_ARGS}" ]]; then + ARGS="$${ARGS} ${ADDITIONAL_ARGS}" +fi + +TTYD_LOG_PATH="${LOG_PATH}" +TTYD_LOG_PATH="$${TTYD_LOG_PATH/#\~/$${HOME}}" +TTYD_LOG_DIR="$${TTYD_LOG_PATH%/*}" +mkdir -p "$${TTYD_LOG_DIR}" + +printf "Starting ttyd in background...\n" +printf "Running: ttyd %s -- %s\n\n" "$${ARGS}" "${COMMAND}" + +# shellcheck disable=SC2086 +ttyd $${ARGS} -- ${COMMAND} >> "$${TTYD_LOG_PATH}" 2>&1 & + +printf "Logs at %s\n" "$${TTYD_LOG_PATH}"