Skip to content

Commit 5a241eb

Browse files
DevelopmentCatsblink-so[bot]Copilot
authored
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 ![TTYD-Module-Demo](https://github.com/user-attachments/assets/1c884e89-b1b1-4f1b-ab5b-56df3dd6d9af) ## 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>
1 parent 4b3045e commit 5a241eb

5 files changed

Lines changed: 424 additions & 0 deletions

File tree

.icons/terminal.svg

Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
display_name: ttyd
3+
description: Share a terminal command over the web via a Coder app
4+
icon: ../../../../.icons/terminal.svg
5+
verified: true
6+
tags: [terminal, web, ttyd]
7+
---
8+
9+
# ttyd
10+
11+
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.
12+
13+
```tf
14+
module "ttyd" {
15+
count = data.coder_workspace.me.start_count
16+
source = "registry.coder.com/coder-labs/ttyd/coder"
17+
version = "1.0.0"
18+
agent_id = coder_agent.main.id
19+
command = "bash"
20+
}
21+
```
22+
23+
## Examples
24+
25+
### Custom command
26+
27+
```tf
28+
module "ttyd" {
29+
count = data.coder_workspace.me.start_count
30+
source = "registry.coder.com/coder-labs/ttyd/coder"
31+
version = "1.0.0"
32+
agent_id = coder_agent.main.id
33+
display_name = "Shared Terminal"
34+
command = "tmux new-session -A -s main"
35+
share = "authenticated"
36+
}
37+
```
38+
39+
### Readonly with custom ttyd options
40+
41+
```tf
42+
module "ttyd" {
43+
count = data.coder_workspace.me.start_count
44+
source = "registry.coder.com/coder-labs/ttyd/coder"
45+
version = "1.0.0"
46+
agent_id = coder_agent.main.id
47+
command = "tail -f /var/log/app.log"
48+
writable = false
49+
additional_args = "-t fontSize=18"
50+
}
51+
```
52+
53+
## Session Behavior
54+
55+
By default, each browser tab that opens the ttyd app spawns a **new process** for the configured command. Closing the tab kills that process.
56+
57+
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.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { describe, expect, it } from "bun:test";
2+
import {
3+
executeScriptInContainer,
4+
runTerraformApply,
5+
runTerraformInit,
6+
type scriptOutput,
7+
testRequiredVariables,
8+
} from "~test";
9+
10+
function testBaseLine(output: scriptOutput) {
11+
expect(output.exitCode).toBe(0);
12+
13+
const stdout = output.stdout.join("\n");
14+
expect(stdout).toContain("Installing ttyd");
15+
expect(stdout).toContain("Installation complete!");
16+
expect(stdout).toContain("Starting ttyd in background...");
17+
}
18+
19+
describe("ttyd", async () => {
20+
await runTerraformInit(import.meta.dir);
21+
22+
testRequiredVariables(import.meta.dir, {
23+
agent_id: "foo",
24+
command: "bash",
25+
});
26+
27+
it("runs with bash", async () => {
28+
const state = await runTerraformApply(import.meta.dir, {
29+
agent_id: "foo",
30+
command: "bash",
31+
});
32+
33+
const output = await executeScriptInContainer(
34+
state,
35+
"alpine/curl",
36+
"sh",
37+
"apk add bash",
38+
);
39+
40+
testBaseLine(output);
41+
}, 30000);
42+
43+
it("runs with custom command", async () => {
44+
const state = await runTerraformApply(import.meta.dir, {
45+
agent_id: "foo",
46+
command: "htop",
47+
});
48+
49+
const output = await executeScriptInContainer(
50+
state,
51+
"alpine/curl",
52+
"sh",
53+
"apk add bash",
54+
);
55+
56+
testBaseLine(output);
57+
expect(output.stdout.join("\n")).toContain("htop");
58+
}, 30000);
59+
60+
it("runs with writable=false", async () => {
61+
const state = await runTerraformApply(import.meta.dir, {
62+
agent_id: "foo",
63+
command: "bash",
64+
writable: "false",
65+
});
66+
67+
const output = await executeScriptInContainer(
68+
state,
69+
"alpine/curl",
70+
"sh",
71+
"apk add bash",
72+
);
73+
74+
testBaseLine(output);
75+
}, 30000);
76+
77+
it("runs with subdomain=false", async () => {
78+
const state = await runTerraformApply(import.meta.dir, {
79+
agent_id: "foo",
80+
command: "bash",
81+
agent_name: "main",
82+
subdomain: "false",
83+
});
84+
85+
const output = await executeScriptInContainer(
86+
state,
87+
"alpine/curl",
88+
"sh",
89+
"apk add bash",
90+
);
91+
92+
testBaseLine(output);
93+
}, 30000);
94+
95+
it("runs with additional_args", async () => {
96+
const state = await runTerraformApply(import.meta.dir, {
97+
agent_id: "foo",
98+
command: "bash",
99+
additional_args: "-t fontSize=18",
100+
});
101+
102+
const output = await executeScriptInContainer(
103+
state,
104+
"alpine/curl",
105+
"sh",
106+
"apk add bash",
107+
);
108+
109+
testBaseLine(output);
110+
expect(output.stdout.join("\n")).toContain("fontSize=18");
111+
}, 30000);
112+
});
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
terraform {
2+
required_version = ">= 1.0"
3+
4+
required_providers {
5+
coder = {
6+
source = "coder/coder"
7+
version = ">= 2.5"
8+
}
9+
}
10+
}
11+
12+
variable "agent_id" {
13+
type = string
14+
description = "The ID of a Coder agent."
15+
}
16+
17+
data "coder_workspace" "me" {}
18+
19+
data "coder_workspace_owner" "me" {}
20+
21+
variable "agent_name" {
22+
type = string
23+
description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)"
24+
default = null
25+
}
26+
27+
variable "slug" {
28+
type = string
29+
description = "The slug of the coder_app resource."
30+
default = "ttyd"
31+
}
32+
33+
variable "display_name" {
34+
type = string
35+
description = "The display name for the ttyd application."
36+
default = "Web Terminal"
37+
}
38+
39+
variable "port" {
40+
type = number
41+
description = "The port to run ttyd on."
42+
default = 7681
43+
}
44+
45+
variable "command" {
46+
type = string
47+
description = "The command for ttyd to run (e.g., bash, fish, htop)."
48+
}
49+
50+
variable "writable" {
51+
type = bool
52+
description = "Allow clients to write to the terminal."
53+
default = true
54+
}
55+
56+
variable "max_clients" {
57+
type = number
58+
description = "Maximum number of concurrent clients (0 for unlimited)."
59+
default = 0
60+
}
61+
62+
variable "additional_args" {
63+
type = string
64+
description = "Additional arguments to pass to ttyd."
65+
default = ""
66+
}
67+
68+
variable "log_path" {
69+
type = string
70+
description = "The path to log ttyd output to. Defaults to ~/.local/state/ttyd/ttyd.log (XDG-compliant)."
71+
default = ""
72+
}
73+
74+
variable "ttyd_version" {
75+
type = string
76+
description = "The version of ttyd to install."
77+
default = "1.7.7"
78+
}
79+
80+
variable "share" {
81+
type = string
82+
description = "Who can access the app: 'owner' (workspace owner only), 'authenticated' (logged-in users), or 'public' (anyone)."
83+
default = "owner"
84+
validation {
85+
condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
86+
error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
87+
}
88+
}
89+
90+
variable "subdomain" {
91+
type = bool
92+
description = <<-EOT
93+
Determines whether the app will be accessed via its own subdomain or whether it will be accessed via a path on Coder.
94+
If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
95+
EOT
96+
default = true
97+
}
98+
99+
variable "order" {
100+
type = number
101+
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)."
102+
default = null
103+
}
104+
105+
variable "group" {
106+
type = string
107+
description = "The name of a group that this app belongs to."
108+
default = null
109+
}
110+
111+
variable "open_in" {
112+
type = string
113+
description = <<-EOT
114+
Determines where the app will be opened. Valid values are "tab" and "slim-window" (default).
115+
"tab" opens in a new tab in the same browser window.
116+
"slim-window" opens a new browser window without navigation controls.
117+
EOT
118+
default = "slim-window"
119+
validation {
120+
condition = contains(["tab", "slim-window"], var.open_in)
121+
error_message = "The 'open_in' variable must be one of: 'tab', 'slim-window'."
122+
}
123+
}
124+
125+
resource "coder_script" "ttyd" {
126+
agent_id = var.agent_id
127+
display_name = var.display_name
128+
icon = "/icon/terminal.svg"
129+
script = templatefile("${path.module}/run.sh", {
130+
PORT = var.port,
131+
COMMAND = var.command,
132+
WRITABLE = var.writable,
133+
MAX_CLIENTS = var.max_clients,
134+
ADDITIONAL_ARGS = var.additional_args,
135+
LOG_PATH = local.log_path,
136+
VERSION = var.ttyd_version,
137+
BASE_PATH = local.base_path,
138+
})
139+
run_on_start = true
140+
}
141+
142+
resource "coder_app" "ttyd" {
143+
count = var.command != "" ? 1 : 0
144+
agent_id = var.agent_id
145+
slug = var.slug
146+
display_name = var.display_name
147+
url = "http://localhost:${var.port}${local.base_path}/"
148+
icon = "/icon/terminal.svg"
149+
subdomain = var.subdomain
150+
share = var.share
151+
order = var.order
152+
group = var.group
153+
open_in = var.open_in
154+
155+
healthcheck {
156+
url = "http://localhost:${var.port}${local.base_path}/token"
157+
interval = 5
158+
threshold = 6
159+
}
160+
}
161+
162+
locals {
163+
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)
164+
log_path = var.log_path != "" ? var.log_path : "~/.local/state/ttyd/ttyd.log"
165+
}

0 commit comments

Comments
 (0)