Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,12 @@ curl -fsSL \

## Herdr plugin

Requires Herdr 0.7.2+ and Bun for the shim. Plugin ID is `roamgate` from 0.7.1;
0.7.0 uses `herdr.studio`. The shim refuses prebuilt downloads through 0.7.0.
Requires Herdr 0.7.2+. The native plugin uses the standalone Roamgate executable
for its actions and panel; users do not need Bun or Node.js. Unix installation
uses the same system tools as the standalone installer; Windows uses Windows
PowerShell and `tar.exe`. Bun remains a source-build dependency.
Herdr's GitHub plugin installation also requires Git.
Plugin ID is `roamgate` from 0.7.1; 0.7.0 uses `herdr.studio`.

**Migrating `herdr.studio`:** stop/uninstall its old service first, close old plugin
panes, finish pending actions, then unregister:
Expand All @@ -234,18 +238,28 @@ bun scripts/studio-plugin.ts build-source
herdr plugin link .
```

`build-source` installs workspace dependencies and builds `server/roamgate`
(`roamgate.exe` on Windows). Linking after success skips release download;
neither step starts the service.
`build-source` installs the root workspace with its frozen lockfile and builds
`server/roamgate` (`roamgate.exe` on Windows). Linking after success skips release
download; neither step starts the service.

For a **published release**, replace `X.Y.Z` with 0.7.1 or newer:

```bash
herdr plugin install powerfooI/roamgate --ref vX.Y.Z
```

The manifest downloads/checksum-verifies that version's binary. Failure never
falls back to compilation or legacy assets; unpublished versions cannot use this path.
The system installer downloads that version's package into a staging directory,
checks its SHA-256, version and platform, and verifies native plugin commands
before publishing the executable. Failure preserves an existing executable and
never falls back to compilation or legacy assets. Tags predating native plugin
commands still use their own Bun launcher; installing an unreleased checkout
against one of those older release packages fails with source-build guidance.

To upgrade a version-pinned installation, run the install command with the new
published tag. Herdr 0.9.4's `herdr plugin update roamgate` follows the original
ref, so a fixed tag remains pinned. Locally linked checkouts are updated and
rebuilt by their owner. Neither installation nor plugin update starts Roamgate
automatically.

Plugin actions manage the same [user service](#run-as-a-user-service):

Expand All @@ -271,6 +285,10 @@ forced-install behavior; Restart refuses to overwrite a service definition that
Roamgate did not generate. Use the standalone `roamgate service restart` command
to restart an existing custom definition without changing it.

The panel and actions invoke `roamgate plugin` directly. The source-only
`scripts/studio-plugin.ts` entry point remains available for build helpers and
delegates runtime verbs to the built executable.

## Basic runtime configuration

Flags override environment variables, then defaults; `roamgate --help` lists all
Expand Down
32 changes: 17 additions & 15 deletions herdr-plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,60 +5,62 @@ min_herdr_version = "0.7.2"
description = "Web and PWA client for Herdr: workspaces, tabs, panes, agent status, session inspection, diffs, and review annotations"
platforms = ["linux", "macos", "windows"]

# Thin launcher: every action delegates to scripts/studio-plugin.ts, which
# downloads the checksum-verified prebuilt release binary matching this
# checkout's version on demand and forwards service verbs to it (systemd,
# launchd, or Windows Task Scheduler). The verb set is a frozen contract; see
# scripts/studio-plugin.ts. The shim itself runs on Bun. For an unreleased
# checkout, run `bun scripts/studio-plugin.ts build-source`, then
# `herdr plugin link .`; see docs/DEPLOYMENT.md#herdr-plugin.
# System installers download and verify the matching standalone release.
# Actions and the panel run that executable; users do not need Bun.
# Unreleased checkouts require `bun scripts/studio-plugin.ts build-source`,
# then `herdr plugin link .`; see docs/DEPLOYMENT.md#herdr-plugin.
# The panel opens as a session-modal popup in the Herdr TUI by default. Open
# it with --placement split (or tab/zoomed/overlay) to get a regular pane
# that other Herdr clients, including Roamgate itself, can see.

[[build]]
command = ["bun", "scripts/studio-plugin.ts", "build"]
platforms = ["linux", "macos"]
command = ["sh", "scripts/install-plugin.sh"]

[[build]]
platforms = ["windows"]
command = ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", "scripts/install-plugin.ps1"]

[[panes]]
id = "panel"
title = "Roamgate"
placement = "popup"
width = "72%"
height = 12
command = ["bun", "scripts/studio-plugin.ts", "panel"]
command = ["./server/roamgate", "plugin", "panel"]

[[actions]]
id = "start"
title = "Start Roamgate"
contexts = ["workspace"]
command = ["bun", "scripts/studio-plugin.ts", "start"]
command = ["./server/roamgate", "plugin", "start"]

[[actions]]
id = "restart"
title = "Restart Roamgate"
contexts = ["workspace"]
command = ["bun", "scripts/studio-plugin.ts", "restart"]
command = ["./server/roamgate", "plugin", "restart"]

[[actions]]
id = "status"
title = "Roamgate status"
contexts = ["workspace"]
command = ["bun", "scripts/studio-plugin.ts", "status"]
command = ["./server/roamgate", "plugin", "status"]

[[actions]]
id = "url"
title = "Show Roamgate URL"
contexts = ["workspace"]
command = ["bun", "scripts/studio-plugin.ts", "url"]
command = ["./server/roamgate", "plugin", "url"]

[[actions]]
id = "version"
title = "Show Roamgate version"
contexts = ["workspace"]
command = ["bun", "scripts/studio-plugin.ts", "version"]
command = ["./server/roamgate", "plugin", "version"]

[[actions]]
id = "uninstall"
title = "Uninstall Roamgate service"
contexts = ["workspace"]
command = ["bun", "scripts/studio-plugin.ts", "uninstall"]
command = ["./server/roamgate", "plugin", "uninstall"]
80 changes: 80 additions & 0 deletions scripts/install-plugin.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'

$pluginRoot = Split-Path -Parent $PSScriptRoot
$manifest = [IO.File]::ReadAllText((Join-Path $pluginRoot 'herdr-plugin.toml'))
$versionMatch = [regex]::Match($manifest, '(?m)^version = "([0-9]+\.[0-9]+\.[0-9]+)"\s*$')
if (!$versionMatch.Success) { throw 'Roamgate plugin: invalid manifest version' }
$version = $versionMatch.Groups[1].Value
$architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()
switch ($architecture) {
'X64' { $platform = 'windows-x64' }
'Arm64' { $platform = 'windows-arm64' }
default { throw "Roamgate plugin: unsupported Windows architecture $architecture" }
}
Get-Command tar.exe -CommandType Application -ErrorAction Stop | Out-Null
$packageDir = "roamgate-$platform"
$archiveName = "roamgate-v$version-$platform.tar.xz"
$releaseBase = "https://github.com/powerfooI/roamgate/releases/download/v$version"
$environment = [Environment]::GetEnvironmentVariables()
$customReleaseBase = if ($environment.Contains('ROAMGATE_RELEASE_BASE_URL')) {
$environment['ROAMGATE_RELEASE_BASE_URL']
} else {
$environment['HERDR_GUI_RELEASE_BASE_URL']
}
if ($customReleaseBase) { $releaseBase = $customReleaseBase.TrimEnd('/') }
$uri = [Uri]$releaseBase
if (!$uri.IsAbsoluteUri -or $uri.UserInfo -or $uri.Query -or $uri.Fragment -or
($uri.Scheme -ne 'https' -and !($uri.Scheme -eq 'http' -and $uri.IsLoopback))) {
throw 'Roamgate plugin: release base must use HTTPS (or loopback HTTP), without credentials, query or fragment'
}

$serverDir = Join-Path $pluginRoot 'server'
New-Item -ItemType Directory -Path $serverDir -Force | Out-Null
$staging = Join-Path $serverDir ('.plugin-install-' + [IO.Path]::GetRandomFileName())
New-Item -ItemType Directory -Path $staging | Out-Null
try {
$archive = Join-Path $staging $archiveName
$checksum = "$archive.sha256"
Invoke-WebRequest -UseBasicParsing -TimeoutSec 120 -Uri "$releaseBase/$archiveName" -OutFile $archive
Invoke-WebRequest -UseBasicParsing -TimeoutSec 120 -Uri "$releaseBase/$archiveName.sha256" -OutFile $checksum
if ((Get-Item -LiteralPath $checksum).Length -gt 4096) { throw 'Invalid package checksum file' }
$pattern = '\A([0-9a-fA-F]{64})[ \t]+' + [regex]::Escape($archiveName) + '\r?\n?\z'
$digest = [regex]::Match([IO.File]::ReadAllText($checksum), $pattern)
if (!$digest.Success) { throw 'Invalid package checksum file' }
if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash -ne $digest.Groups[1].Value) {
throw 'Package checksum mismatch'
}
& tar.exe -xJf $archive -C $staging "$packageDir/VERSION" "$packageDir/roamgate.exe"
if ($LASTEXITCODE -ne 0) { throw 'Could not extract the Roamgate package' }
$extracted = Join-Path $staging $packageDir
$binary = Join-Path $extracted 'roamgate.exe'
$versionFile = Join-Path $extracted 'VERSION'
foreach ($path in @($extracted, $binary, $versionFile)) {
if ((Get-Item -LiteralPath $path).Attributes -band [IO.FileAttributes]::ReparsePoint) {
throw 'Invalid package layout'
}
}
if ([IO.File]::ReadAllText($versionFile).Trim() -ne "roamgate $version $platform") {
throw 'Package VERSION does not match the plugin version and platform'
}
$actualVersion = (& $binary --version | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or $actualVersion -ne "roamgate $version") { throw 'Binary version mismatch' }
$nativeVersion = (& $binary plugin version | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or $nativeVersion -ne "roamgate $version") {
throw 'This release has no native plugin commands. Build an unreleased checkout with `bun scripts/studio-plugin.ts build-source`, then `herdr plugin link .`, or select a published tag that includes native plugin support.'
}
$target = Join-Path $serverDir 'roamgate.exe'
if (Test-Path -LiteralPath $target) {
$existing = Get-Item -LiteralPath $target
if ($existing.PSIsContainer -or ($existing.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
throw 'Install target is not a regular file'
}
[IO.File]::Replace($binary, $target, (Join-Path $staging 'previous-roamgate.exe'))
} else {
[IO.File]::Move($binary, $target)
}
Write-Output "Installed Roamgate plugin binary $version"
} finally {
Remove-Item -LiteralPath $staging -Recurse -Force
}
30 changes: 30 additions & 0 deletions scripts/install-plugin.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/bin/sh
set -eu

plugin_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
version="$(awk -F '"' '/^version = "/ { print $2; exit }' "$plugin_root/herdr-plugin.toml")"
printf '%s\n' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || {
printf 'Roamgate plugin: invalid manifest version\n' >&2
exit 1
}

mkdir -p "$plugin_root/server"
staging="$(mktemp -d "$plugin_root/server/.plugin-install.XXXXXX")"
trap 'rm -rf "$staging"' EXIT
trap 'exit 1' HUP INT TERM

ROAMGATE_VERSION="$version" ROAMGATE_INSTALL_DIR="$staging" \
sh "$plugin_root/scripts/install-roamgate.sh"
if ! native_version="$("$staging/roamgate" plugin version)" ||
[ "$native_version" != "roamgate $version" ]; then
printf 'Roamgate plugin: this release has no native plugin commands. Build an unreleased checkout with `bun scripts/studio-plugin.ts build-source`, then `herdr plugin link .`, or select a published tag that includes native plugin support.\n' >&2
exit 1
fi
target="$plugin_root/server/roamgate"
if { [ -e "$target" ] || [ -L "$target" ]; } &&
{ [ ! -f "$target" ] || [ -L "$target" ]; }; then
printf 'Roamgate plugin: install target is not a regular file\n' >&2
exit 1
fi
mv -f "$staging/roamgate" "$target"
printf 'Installed Roamgate plugin binary %s\n' "$version"
127 changes: 127 additions & 0 deletions scripts/install-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { afterEach, expect, test } from "bun:test";
import { createHash } from "node:crypto";
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0))
rmSync(root, { recursive: true, force: true });
});

test("plugin manifest runs system installers and the native executable", () => {
const manifest = Bun.TOML.parse(
readFileSync(join(import.meta.dir, "../herdr-plugin.toml"), "utf8"),
) as any;
expect(manifest.min_herdr_version).toBe("0.7.2");
expect(manifest.build.map((entry: any) => entry.platforms)).toEqual([
["linux", "macos"],
["windows"],
]);
expect(manifest.build.map((entry: any) => entry.command[0])).toEqual([
"sh",
"powershell.exe",
]);
for (const entry of [...manifest.actions, ...manifest.panes]) {
expect(entry.command).toEqual(["./server/roamgate", "plugin", entry.id]);
}
});

for (const failure of [
"none",
"checksum",
"metadata",
"native-command",
"native-exit",
"download",
] as const) {
test(`plugin installer stages a matching native release without Bun: ${failure}`, () => {
const root = mkdtempSync(join(tmpdir(), "roamgate plugin install "));
roots.push(root);
const scripts = join(root, "scripts");
const assets = join(root, "assets");
const bin = join(root, "bin");
const server = join(root, "server");
for (const dir of [scripts, assets, bin, server]) mkdirSync(dir);
for (const name of ["install-plugin.sh", "install-roamgate.sh"])
copyFileSync(join(import.meta.dir, name), join(scripts, name));
writeFileSync(join(root, "herdr-plugin.toml"), 'version = "9.8.7"\n');
const platform = `${process.platform === "darwin" ? "darwin" : "linux"}-${process.arch}`;
const packageDir = `roamgate-${platform}`;
const packagePath = join(assets, packageDir);
mkdirSync(packagePath);
writeFileSync(
join(packagePath, "roamgate"),
`#!/bin/sh
if [ "$1" = "--version" ]; then echo 'roamgate 9.8.7'; exit 0; fi
if [ "$1 $2" = "plugin version" ] && [ "${failure}" != "native-command" ]; then echo 'roamgate 9.8.7'; [ "${failure}" != "native-exit" ]; exit $?; fi
exit 2
`,
{ mode: 0o755 },
);
writeFileSync(
join(packagePath, "VERSION"),
`roamgate ${failure === "metadata" ? "9.8.6" : "9.8.7"} ${platform}\n`,
);
const archiveName = `roamgate-v9.8.7-${platform}.tar.xz`;
const archive = join(assets, archiveName);
expect(
Bun.spawnSync(["tar", "-C", assets, "-cJf", archive, packageDir], {
env: { ...process.env, COPYFILE_DISABLE: "1" },
}).exitCode,
).toBe(0);
const digest =
failure === "checksum"
? "0".repeat(64)
: createHash("sha256").update(readFileSync(archive)).digest("hex");
writeFileSync(`${archive}.sha256`, `${digest} ${archiveName}\n`);
writeFileSync(
join(bin, "curl"),
`#!/bin/sh
[ "${failure}" != "download" ] || exit 22
while [ "$#" -gt 0 ]; do
case "$1" in -o) out="$2"; shift 2;; -*) shift;; *) url="$1"; shift;; esac
done
cp "$FIXTURE_ASSETS/\${url##*/}" "$out"
`,
{ mode: 0o755 },
);
writeFileSync(join(bin, "bun"), "#!/bin/sh\nexit 99\n", { mode: 0o755 });
writeFileSync(join(server, "roamgate"), "previous binary", { mode: 0o755 });
const result = Bun.spawnSync(["sh", join(scripts, "install-plugin.sh")], {
env: {
...process.env,
PATH: `${bin}:/usr/bin:/bin:/usr/sbin:/sbin`,
FIXTURE_ASSETS: assets,
ROAMGATE_RELEASE_BASE_URL: "http://127.0.0.1/releases",
ROAMGATE_VERSION: "ignored",
ROAMGATE_INSTALL_DIR: join(root, "ignored"),
},
});
expect(result.exitCode, result.stderr.toString()).toBe(
failure === "none" ? 0 : failure === "download" ? 22 : 1,
);
if (failure === "none")
expect(
Bun.spawnSync([join(server, "roamgate"), "plugin", "version"])
.stdout.toString()
.trim(),
).toBe("roamgate 9.8.7");
else
expect(readFileSync(join(server, "roamgate"), "utf8")).toBe(
"previous binary",
);
expect(readdirSync(server)).toEqual(["roamgate"]);
expect(existsSync(join(root, "ignored"))).toBe(false);
});
}
Loading
Loading