mirror of
https://github.com/coder/registry.git
synced 2026-06-02 20:48:14 +00:00
feat: ttyd module (#790)
## Description Add ttyd module that exposes any command as a web-based terminal via [ttyd](https://github.com/tsl0922/ttyd). - Run commands like `bash`, `htop`, or `tmux` accessible in the browser - Supports readonly mode for log viewers - Configurable sharing (owner/authenticated/public) - Auto-installs ttyd binary (x86_64, aarch64, ARM) - Works with subdomain or path-based routing  ## Type of Change - [X] New module - [ ] New template - [ ] Bug fix - [ ] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information <!-- Delete this section if not applicable --> **Path:** `registry/coder-labs/modules/ttyd` **New version:** `v1.0.0` **Breaking change:** [ ] Yes [ ] No ## Testing & Validation - [X] Tests pass (`bun test`) - [X] Code formatted (`bun fmt`) - [X] Changes tested locally --------- Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M37.3333 213.333C33.0666 213.333 29.3333 211.733 26.1333 208.533C22.9333 205.333 21.3333 201.6 21.3333 197.333V58.6667C21.3333 54.4001 22.9333 50.6667 26.1333 47.4667C29.3333 44.2667 33.0666 42.6667 37.3333 42.6667H218.667C222.933 42.6667 226.667 44.2667 229.867 47.4667C233.067 50.6667 234.667 54.4001 234.667 58.6667V197.333C234.667 201.6 233.067 205.333 229.867 208.533C226.667 211.733 222.933 213.333 218.667 213.333H37.3333ZM37.3333 197.333H218.667V81.0668H37.3333V197.333ZM80 178.133L68.8 166.933L96.2666 139.2L68.5333 111.467L80 100.267L118.933 139.2L80 178.133ZM130.667 179.2V163.2H189.333V179.2H130.667Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 745 B |
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user