diff --git a/registry/coder/.images/boo.png b/registry/coder/.images/boo.png new file mode 100644 index 000000000..c861de287 Binary files /dev/null and b/registry/coder/.images/boo.png differ diff --git a/registry/coder/modules/boo/README.md b/registry/coder/modules/boo/README.md new file mode 100644 index 000000000..6feb155cd --- /dev/null +++ b/registry/coder/modules/boo/README.md @@ -0,0 +1,150 @@ +--- +display_name: Boo +description: Run commands in persistent boo terminal sessions, one app per session. +icon: ../../../../.icons/coder.svg +verified: false +tags: [terminal, multiplexer, session, boo] +--- + +# Boo + +![Boo sessions in a Coder workspace](../../.images/boo.png) + +Install [boo](https://github.com/coder/boo) and run commands in persistent, named terminal sessions. Boo is a GNU screen-style terminal multiplexer built on [libghostty](https://github.com/ghostty-org/ghostty) (Zig). + +```tf +module "boo" { + source = "registry.coder.com/coder/boo/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} +``` + +## Usage + +Pass a list of session definitions and the module creates one `coder_app` per session. Each session requires `session_name` and `command`; `display_name` and `slug` are optional. Clicking an app creates the session and attaches to it; clicking again reattaches to the running session. + +### Multiple sessions + +Create separate persistent sessions for a dev server and an interactive shell, each with its own coder_app. + +```tf +module "boo" { + source = "registry.coder.com/coder/boo/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + sessions = [ + { + session_name = "server" + display_name = "Server" + slug = "server" + command = "npm run dev" + }, + { + session_name = "shell" + display_name = "Shell" + slug = "shell" + command = "bash" + }, + ] +} +``` + +This creates: + +- `coder_app` slugs `server` and `shell` +- Display names `Server` and `Shell` + +### Multi-line commands + +Session commands can be full shell scripts. The script is written to `~/.coder-modules/coder/boo//scripts/start.sh` and executed inside the boo session. + +```tf +module "boo" { + source = "registry.coder.com/coder/boo/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + sessions = [ + { + session_name = "watcher" + display_name = "Watcher" + slug = "watcher" + command = <<-EOT + #!/bin/bash + while true; do + echo "$(date): watching..." + sleep 10 + done + EOT + }, + ] +} +``` + +### Use pre/post install hooks + +```tf +module "boo" { + source = "registry.coder.com/coder/boo/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + pre_install_script = "echo 'Preparing environment...'" + post_install_script = "echo 'boo ready'" + sessions = [ + { + session_name = "shell" + display_name = "Shell" + slug = "shell" + command = "bash" + }, + ] +} +``` + +### Serialize another module behind the boo install + +Use `output.scripts` to wait for the boo install pipeline to complete before running downstream work. + +```tf +module "boo" { + source = "registry.coder.com/coder/boo/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + sessions = [ + { + session_name = "shell" + display_name = "Shell" + slug = "shell" + command = "bash" + }, + ] +} + +resource "coder_script" "after_boo" { + agent_id = coder_agent.main.id + display_name = "After Boo" + run_on_start = true + script = <<-EOT + #!/bin/bash + coder exp sync want after-boo ${join(" ", module.boo.scripts)} + coder exp sync start after-boo + trap 'coder exp sync complete after-boo' EXIT + echo "boo install complete" + EOT +} +``` + +## Troubleshooting + +The install log is written under `~/.coder-modules/coder/boo/logs/`. Session scripts are written to `~/.coder-modules/coder/boo//scripts/start.sh`. + +``` +~/.coder-modules/coder/boo/ +├── logs/ +│ └── install.log +└── / + └── scripts/ + └── start.sh +``` + +Check `install.log` for installation errors. If an app does not connect, verify the session exists by running `boo ls` in a terminal. diff --git a/registry/coder/modules/boo/main.test.ts b/registry/coder/modules/boo/main.test.ts new file mode 100644 index 000000000..732531d82 --- /dev/null +++ b/registry/coder/modules/boo/main.test.ts @@ -0,0 +1,300 @@ +import { + test, + afterEach, + describe, + setDefaultTimeout, + beforeAll, + expect, +} from "bun:test"; +import { + execContainer, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + TerraformState, +} from "~test"; +import { writeExecutable } from "../agentapi/test-util"; + +interface ModuleScripts { + pre_install?: string; + install: string; + post_install?: string; +} + +const SCRIPT_SUFFIXES = [ + "Pre-Install Script", + "Install Script", + "Post-Install Script", +] as const; + +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 scripts: Partial = {}; + for (const suffix of SCRIPT_SUFFIXES) { + const key = `Boo: ${suffix}`; + if (!(key in byDisplayName)) continue; + switch (suffix) { + case "Pre-Install Script": + scripts.pre_install = byDisplayName[key]; + break; + case "Install Script": + scripts.install = byDisplayName[key]; + break; + case "Post-Install Script": + scripts.post_install = byDisplayName[key]; + break; + } + } + if (!scripts.install) { + throw new Error("install script not found in terraform state"); + } + return scripts as ModuleScripts; +}; + +const findAppBySlug = ( + state: TerraformState, + slug: string, +): Record => { + for (const resource of state.resources) { + if (resource.type !== "coder_app") continue; + for (const instance of resource.instances) { + const attrs = instance.attributes as Record; + if (attrs.slug === slug) return attrs; + } + } + throw new Error(`coder_app with slug '${slug}' not found in terraform state`); +}; + +const countApps = (state: TerraformState): number => { + let count = 0; + for (const resource of state.resources) { + if (resource.type === "coder_app") { + count += resource.instances.length; + } + } + return count; +}; + +let cleanupFunctions: (() => Promise)[] = []; +const registerCleanup = (cleanup: () => Promise) => { + cleanupFunctions.push(cleanup); +}; +afterEach(async () => { + const fns = cleanupFunctions.slice().reverse(); + cleanupFunctions = []; + for (const fn of fns) { + try { + await fn(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } +}); + +const defaultSession = + '[{"session_name":"main","display_name":"Main","slug":"boo-main","command":"bash"}]'; + +const setup = async (moduleVariables?: Record) => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + sessions: defaultSession, + install_boo: "false", + ...moduleVariables, + }); + const scripts = collectScripts(state); + const id = await runContainer("codercom/enterprise-node:latest"); + registerCleanup(async () => { + if (process.env["DEBUG"] === "true" || process.env["DEBUG"] === "1") return; + await removeContainer(id); + }); + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: "#!/bin/bash\nexit 0\n", + }); + return { id, state, scripts }; +}; + +const runScripts = async (id: string, scripts: ModuleScripts) => { + const ordered: [string, string | undefined][] = [ + ["pre_install", scripts.pre_install], + ["install", scripts.install], + ["post_install", scripts.post_install], + ]; + for (const [name, script] of ordered) { + if (!script) continue; + const target = `/tmp/boo-${name}.sh`; + await writeExecutable({ + containerId: id, + filePath: target, + content: script, + }); + const resp = await execContainer(id, ["bash", "-c", target]); + if (resp.exitCode !== 0) { + throw new Error( + `${name} script exited ${resp.exitCode}:\n${resp.stdout}\n${resp.stderr}`, + ); + } + } +}; + +setDefaultTimeout(60 * 1000); + +describe("boo", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + test("defaults", async () => { + const { state } = await setup(); + const app = findAppBySlug(state, "boo-main"); + expect(app.display_name).toBe("Main"); + expect(app.icon).toBe("/icon/coder.svg"); + expect(app.order).toBeNull(); + expect(app.group).toBeNull(); + }); + + test("multiple-sessions-create-multiple-apps", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + sessions: + '[{"session_name":"alpha","display_name":"Alpha","slug":"alpha","command":"bash"},{"session_name":"beta","display_name":"Beta","slug":"beta","command":"vim"}]', + }); + expect(countApps(state)).toBe(2); + const alpha = findAppBySlug(state, "alpha"); + const beta = findAppBySlug(state, "beta"); + expect(alpha.display_name).toBe("Alpha"); + expect(beta.display_name).toBe("Beta"); + }); + + test("per-session-slug-and-display-name", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + sessions: + '[{"session_name":"main","display_name":"Terminal: main","slug":"term-main","command":"bash"}]', + }); + const app = findAppBySlug(state, "term-main"); + expect(app.slug).toBe("term-main"); + expect(app.display_name).toBe("Terminal: main"); + }); + + test("session-name-used-in-boo-command", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + sessions: + '[{"session_name":"my-session","display_name":"My Session","slug":"my-boo","command":"bash"}]', + }); + const app = findAppBySlug(state, "my-boo"); + expect(app.command as string).toContain("'my-session'"); + expect(app.command as string).not.toContain("'my-boo'"); + }); + + test("derived-slug-from-session-name", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + sessions: '[{"session_name":"my.dev_server","command":"bash"}]', + }); + const app = findAppBySlug(state, "my-dev-server"); + expect(app.slug).toBe("my-dev-server"); + }); + + test("derived-display-name-from-session-name", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + sessions: '[{"session_name":"my-session","command":"bash"}]', + }); + const app = findAppBySlug(state, "my-session"); + expect(app.display_name).toBe("my-session"); + }); + + test("order-and-group", async () => { + const { state } = await setup({ order: "5", group: "ai-tools" }); + const app = findAppBySlug(state, "boo-main"); + expect(app.order).toBe(5); + expect(app.group).toBe("ai-tools"); + }); + + test("install-skipped-when-install-boo-false", async () => { + const { id, scripts } = await setup({ install_boo: "false" }); + await runScripts(id, scripts); + const resp = await execContainer(id, [ + "bash", + "-c", + "command -v boo && echo FOUND || echo ABSENT", + ]); + expect(resp.stdout.trim()).toBe("ABSENT"); + }); + + test("install-skipped-when-boo-already-installed", async () => { + const { id, scripts } = await setup({ install_boo: "true" }); + await execContainer(id, ["mkdir", "-p", "/home/coder/.local/bin"]); + await writeExecutable({ + containerId: id, + filePath: "/home/coder/.local/bin/boo", + content: + '#!/bin/bash\nif [ "$1" = "-V" ]; then echo "0.6.4"; fi\nexit 0\n', + }); + const resp = await execContainer(id, [ + "bash", + "-c", + `export PATH="$HOME/.local/bin:$PATH" && /tmp/boo-install.sh 2>&1 || true`, + ]); + await writeExecutable({ + containerId: id, + filePath: "/tmp/boo-install.sh", + content: scripts.install, + }); + const result = await execContainer(id, [ + "bash", + "-c", + 'export PATH="$HOME/.local/bin:$PATH" && /tmp/boo-install.sh', + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("already installed"); + }); + + test("pre-post-install-scripts", async () => { + const { scripts } = await setup({ + pre_install_script: "#!/bin/bash\necho 'boo-pre-install'", + post_install_script: "#!/bin/bash\necho 'boo-post-install'", + }); + expect(scripts.pre_install).toBeDefined(); + expect(scripts.post_install).toBeDefined(); + }); + + test("custom-install-script-url", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + sessions: defaultSession, + install_boo: "true", + install_script_url: "https://mirror.example.com/boo/install.sh", + }); + const scripts = collectScripts(state); + const match = scripts.install.match( + /echo -n '([A-Za-z0-9+/=]+)' \| base64 -d/, + ); + expect(match).not.toBeNull(); + const decoded = Buffer.from(match![1], "base64").toString("utf8"); + expect(decoded).toContain("https://mirror.example.com/boo/install.sh"); + }); + + test("install-only-no-sessions", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + install_boo: "false", + }); + expect(countApps(state)).toBe(0); + }); +}); diff --git a/registry/coder/modules/boo/main.tf b/registry/coder/modules/boo/main.tf new file mode 100644 index 000000000..44615dc9a --- /dev/null +++ b/registry/coder/modules/boo/main.tf @@ -0,0 +1,141 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.13" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "sessions" { + type = list(object({ + session_name = string + display_name = optional(string) + slug = optional(string) + command = string + })) + description = "List of boo sessions to create. Each entry requires session_name and command. display_name defaults to session_name; slug is derived from session_name (lowercased, '.' and '_' replaced with '-') when omitted." + default = [] +} + +variable "install_boo" { + type = bool + description = "Whether to install boo." + default = true +} + +variable "boo_version" { + type = string + description = "The version of boo to install. Use 'latest' to accept any installed version or always install the latest release." + default = "latest" +} + +variable "icon" { + type = string + description = "The icon to use for boo apps and scripts." + default = "/icon/coder.svg" +} + +variable "order" { + type = number + description = "The order determines the position of apps in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "group" { + type = string + description = "The name of a group that boo apps belong to." + default = null +} + +variable "install_script_url" { + type = string + description = "URL of the boo install.sh script. Override for air-gapped or mirrored environments." + default = "https://raw.githubusercontent.com/coder/boo/main/install.sh" +} + +variable "pre_install_script" { + type = string + description = "Custom script to run before installing boo." + default = null +} + +variable "post_install_script" { + type = string + description = "Custom script to run after installing boo." + default = null +} + +locals { + module_dir = "$HOME/.coder-modules/coder/boo" + + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_INSTALL_BOO = tostring(var.install_boo) + ARG_BOO_VERSION = var.boo_version + ARG_INSTALL_SCRIPT_URL = var.install_script_url + }) + + sessions_resolved = [ + for s in var.sessions : { + session_name = s.session_name + display_name = s.display_name != null ? s.display_name : s.session_name + slug = s.slug != null ? s.slug : replace(lower(s.session_name), "/[._]/", "-") + command = s.command + } + ] + + sessions_map = { for s in local.sessions_resolved : s.slug => s } +} + +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" + + agent_id = var.agent_id + module_directory = local.module_dir + display_name_prefix = "Boo" + icon = var.icon + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + install_script = local.install_script +} + +resource "coder_app" "boo" { + for_each = local.sessions_map + + agent_id = var.agent_id + slug = each.value.slug + display_name = each.value.display_name + icon = var.icon + command = <<-EOT + #!/bin/bash + export PATH="$HOME/.local/bin:$PATH" + if boo peek '${each.value.session_name}' >/dev/null 2>&1; then + boo attach '${each.value.session_name}' + else + SESSION_DIR="${local.module_dir}/${each.value.session_name}" + mkdir -p "$SESSION_DIR/scripts" + SCRIPT="$SESSION_DIR/scripts/start.sh" + printf '%s' '${base64encode(each.value.command)}' | base64 -d > "$SCRIPT" + chmod +x "$SCRIPT" + boo new '${each.value.session_name}' -d + boo wait '${each.value.session_name}' --idle + boo send '${each.value.session_name}' --text "$SCRIPT" --enter + boo attach '${each.value.session_name}' + fi + EOT + order = var.order + group = var.group +} + +output "scripts" { + description = "Ordered list of coder exp sync names for the install pipeline scripts, in run order." + value = module.coder_utils.scripts +} diff --git a/registry/coder/modules/boo/main.tftest.hcl b/registry/coder/modules/boo/main.tftest.hcl new file mode 100644 index 000000000..677fdd4e8 --- /dev/null +++ b/registry/coder/modules/boo/main.tftest.hcl @@ -0,0 +1,382 @@ +run "test_defaults" { + command = plan + + variables { + agent_id = "test-agent-defaults" + } + + assert { + condition = length(var.sessions) == 0 + error_message = "sessions should default to []" + } + + assert { + condition = var.install_boo == true + error_message = "install_boo should default to true" + } + + assert { + condition = var.boo_version == "latest" + error_message = "boo_version should default to 'latest'" + } + + assert { + condition = var.icon == "/icon/coder.svg" + error_message = "icon should default to '/icon/coder.svg'" + } + + assert { + condition = var.order == null + error_message = "order should default to null" + } + + assert { + condition = var.group == null + error_message = "group should default to null" + } + + assert { + condition = var.install_script_url == "https://raw.githubusercontent.com/coder/boo/main/install.sh" + error_message = "install_script_url should default to the upstream GitHub URL" + } +} + +run "test_single_session" { + command = plan + + variables { + agent_id = "test-agent-single" + sessions = [ + { + session_name = "dev" + display_name = "Dev Server" + slug = "dev" + command = "make dev" + } + ] + } + + assert { + condition = var.sessions[0].session_name == "dev" + error_message = "session_name should be 'dev'" + } + + assert { + condition = var.sessions[0].command == "make dev" + error_message = "command should be 'make dev'" + } +} + +run "test_multiple_sessions" { + command = plan + + variables { + agent_id = "test-agent-multi" + sessions = [ + { + session_name = "server" + display_name = "Server" + slug = "server" + command = "npm run dev" + }, + { + session_name = "worker" + display_name = "Worker" + slug = "worker" + command = "npm run worker" + }, + { + session_name = "shell" + display_name = "Shell" + slug = "shell" + command = "bash" + } + ] + } + + assert { + condition = length(var.sessions) == 3 + error_message = "sessions should contain 3 entries" + } + + assert { + condition = var.sessions[0].command == "npm run dev" + error_message = "first session command should be 'npm run dev'" + } +} + +run "test_skip_install" { + command = plan + + variables { + agent_id = "test-agent-skip" + sessions = [ + { + session_name = "main" + display_name = "Main" + slug = "main" + command = "bash" + } + ] + install_boo = false + } + + assert { + condition = var.install_boo == false + error_message = "install_boo should be false" + } +} + +run "test_pinned_version" { + command = plan + + variables { + agent_id = "test-agent-pinned" + sessions = [ + { + session_name = "main" + display_name = "Main" + slug = "main" + command = "bash" + } + ] + boo_version = "v0.6.4" + } + + assert { + condition = var.boo_version == "v0.6.4" + error_message = "boo_version should be 'v0.6.4'" + } +} + +run "test_icon_order_group" { + command = plan + + variables { + agent_id = "test-agent-app" + sessions = [ + { + session_name = "main" + display_name = "Main" + slug = "boo-main" + command = "bash" + } + ] + icon = "/icon/custom.svg" + order = 10 + group = "terminals" + } + + assert { + condition = var.icon == "/icon/custom.svg" + error_message = "icon should be '/icon/custom.svg'" + } + + assert { + condition = var.order == 10 + error_message = "order should be 10" + } + + assert { + condition = var.group == "terminals" + error_message = "group should be 'terminals'" + } +} + +run "test_hooks" { + command = plan + + variables { + agent_id = "test-agent-hooks" + sessions = [ + { + session_name = "main" + display_name = "Main" + slug = "main" + command = "bash" + } + ] + pre_install_script = "echo 'pre-install'" + post_install_script = "echo 'post-install'" + } + + assert { + condition = var.pre_install_script == "echo 'pre-install'" + error_message = "pre_install_script should be set correctly" + } + + assert { + condition = var.post_install_script == "echo 'post-install'" + error_message = "post_install_script should be set correctly" + } +} + +run "test_scripts_output_single_session" { + command = plan + + variables { + agent_id = "test-agent-output-single" + sessions = [ + { + session_name = "main" + display_name = "Main" + slug = "main" + command = "bash" + } + ] + } + + assert { + condition = output.scripts == ["coder-boo-install_script"] + error_message = "scripts output should list install_script" + } +} + +run "test_scripts_output_multi_session" { + command = plan + + variables { + agent_id = "test-agent-output-multi" + sessions = [ + { + session_name = "alpha" + display_name = "Alpha" + slug = "alpha" + command = "bash" + }, + { + session_name = "beta" + display_name = "Beta" + slug = "beta" + command = "bash" + } + ] + } + + assert { + condition = output.scripts == ["coder-boo-install_script"] + error_message = "scripts output should list install_script" + } +} + +run "test_scripts_output_with_hooks" { + command = plan + + variables { + agent_id = "test-agent-output-hooks" + pre_install_script = "echo pre" + post_install_script = "echo post" + sessions = [ + { + session_name = "main" + display_name = "Main" + slug = "main" + command = "bash" + } + ] + } + + assert { + condition = output.scripts == ["coder-boo-pre_install_script", "coder-boo-install_script", "coder-boo-post_install_script"] + error_message = "scripts output should list pre_install, install, and post_install scripts in run order" + } +} + +run "test_custom_install_script_url" { + command = plan + + variables { + agent_id = "test-agent-url" + install_script_url = "https://mirror.example.com/boo/install.sh" + sessions = [ + { + session_name = "main" + display_name = "Main" + slug = "main" + command = "bash" + } + ] + } + + assert { + condition = var.install_script_url == "https://mirror.example.com/boo/install.sh" + error_message = "install_script_url should be overridable" + } +} + +run "test_session_name_in_command" { + command = plan + + variables { + agent_id = "test-agent-cmd" + sessions = [ + { + session_name = "my-session" + display_name = "My Session" + slug = "my-boo" + command = "bash" + } + ] + } + + assert { + condition = can(regex("'my-session'", coder_app.boo["my-boo"].command)) + error_message = "boo command should use session_name 'my-session', not slug 'my-boo'" + } +} + +run "test_derived_slug" { + command = plan + + variables { + agent_id = "test-agent-derived-slug" + sessions = [ + { + session_name = "my.dev_server" + command = "bash" + } + ] + } + + assert { + condition = coder_app.boo["my-dev-server"].slug == "my-dev-server" + error_message = "slug should be derived as 'my-dev-server' from session_name 'my.dev_server'" + } +} + +run "test_derived_display_name" { + command = plan + + variables { + agent_id = "test-agent-derived-dn" + sessions = [ + { + session_name = "my-session" + command = "bash" + } + ] + } + + assert { + condition = coder_app.boo["my-session"].display_name == "my-session" + error_message = "display_name should default to session_name 'my-session' when omitted" + } +} + +run "test_install_only" { + command = plan + + variables { + agent_id = "test-agent-install-only" + } + + assert { + condition = length(var.sessions) == 0 + error_message = "sessions should default to []" + } + + assert { + condition = length(coder_app.boo) == 0 + error_message = "no coder_app resources should be created with no sessions" + } +} diff --git a/registry/coder/modules/boo/scripts/install.sh.tftpl b/registry/coder/modules/boo/scripts/install.sh.tftpl new file mode 100644 index 000000000..5f5f5ad4b --- /dev/null +++ b/registry/coder/modules/boo/scripts/install.sh.tftpl @@ -0,0 +1,40 @@ +#!/bin/bash +set -o errexit +set -o pipefail + +INSTALL_BOO="${ARG_INSTALL_BOO}" +BOO_VERSION="${ARG_BOO_VERSION}" +INSTALL_SCRIPT_URL="${ARG_INSTALL_SCRIPT_URL}" + +if [ "$INSTALL_BOO" != "true" ]; then + echo "Skipping boo installation (install_boo=false)" + exit 0 +fi + +export PATH="$HOME/.local/bin:$PATH" + +if command -v boo &>/dev/null; then + INSTALLED_VERSION=$(boo -V 2>&1 | tr -d 'v\n' || true) + + if [ "$BOO_VERSION" = "latest" ]; then + echo "boo $${INSTALLED_VERSION} already installed; skipping (boo_version=latest)" + exit 0 + fi + + REQUESTED_VERSION=$(echo -n "$BOO_VERSION" | sed 's/^v//') + if [ "$INSTALLED_VERSION" = "$REQUESTED_VERSION" ]; then + echo "boo $${INSTALLED_VERSION} already installed and matches requested version; skipping" + exit 0 + fi + + echo "boo $${INSTALLED_VERSION} installed but $${REQUESTED_VERSION} requested; reinstalling" +fi + +export BOO_INSTALL_DIR="$HOME/.local/bin" +if [ "$BOO_VERSION" != "latest" ]; then + export BOO_VERSION=$(echo -n "$BOO_VERSION" | sed 's/^v//') +fi + +echo "Installing boo..." +curl -fsSL "$INSTALL_SCRIPT_URL" | sh +echo "boo installation complete"