From 17672957758bd1bdb93b9162126a370f9e3a57f3 Mon Sep 17 00:00:00 2001 From: Wangshuyi Date: Wed, 14 Oct 2026 17:33:56 +0800 Subject: [PATCH 1/3] Run Herdr plugin installation and actions without Bun --- docs/DEPLOYMENT.md | 34 +- herdr-plugin.toml | 34 +- scripts/install-plugin.ps1 | 74 ++++ scripts/install-plugin.sh | 30 ++ scripts/install-plugin.test.ts | 127 ++++++ scripts/install-plugin.windows.test.ts | 164 ++++++++ scripts/studio-plugin.test.ts | 423 ++++---------------- scripts/studio-plugin.ts | 471 +++-------------------- server/src/config/data-paths.test.ts | 2 +- server/src/config/plugin-command.test.ts | 185 +++++++++ server/src/config/plugin-command.ts | 202 ++++++++++ server/src/config/server-config.ts | 1 + server/src/index.ts | 6 + 13 files changed, 954 insertions(+), 799 deletions(-) create mode 100644 scripts/install-plugin.ps1 create mode 100644 scripts/install-plugin.sh create mode 100644 scripts/install-plugin.test.ts create mode 100644 scripts/install-plugin.windows.test.ts create mode 100644 server/src/config/plugin-command.test.ts create mode 100644 server/src/config/plugin-command.ts diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 65ec3525..07086acc 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -26,7 +26,7 @@ and **tagged Herdr 0.9.0 and 0.9.4 / protocol 22**. Protocol 21 and unknown vers are rejected at control/binary probes. Use a compatible Roamgate build or separate server; **do not downgrade a live server**. Published binaries follow their [release notes](https://github.com/powerfooI/roamgate/releases). -The [plugin](#herdr-plugin) separately requires Herdr 0.7.2+. +The [plugin](#herdr-plugin) separately requires Herdr 0.9.4+. Tab reordering requires Herdr 0.7.2+; Herdr 0.7.0 and 0.7.1 retain their fixed tab order. The bridge confirms support before enabling dragging. @@ -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.9.4+. 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: @@ -234,9 +238,9 @@ 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: @@ -244,8 +248,18 @@ For a **published release**, replace `X.Y.Z` with 0.7.1 or newer: 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): @@ -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 diff --git a/herdr-plugin.toml b/herdr-plugin.toml index c690a38b..26d5933c 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,23 +1,25 @@ id = "roamgate" name = "Roamgate" version = "0.8.3" -min_herdr_version = "0.7.2" +min_herdr_version = "0.9.4" 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" @@ -25,40 +27,40 @@ 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"] diff --git a/scripts/install-plugin.ps1 b/scripts/install-plugin.ps1 new file mode 100644 index 00000000..c329e4b8 --- /dev/null +++ b/scripts/install-plugin.ps1 @@ -0,0 +1,74 @@ +$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" +if ($env:ROAMGATE_RELEASE_BASE_URL) { $releaseBase = $env:ROAMGATE_RELEASE_BASE_URL.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 +} diff --git a/scripts/install-plugin.sh b/scripts/install-plugin.sh new file mode 100644 index 00000000..bb2485f6 --- /dev/null +++ b/scripts/install-plugin.sh @@ -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" diff --git a/scripts/install-plugin.test.ts b/scripts/install-plugin.test.ts new file mode 100644 index 00000000..76b0d529 --- /dev/null +++ b/scripts/install-plugin.test.ts @@ -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.9.4"); + 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); + }); +} diff --git a/scripts/install-plugin.windows.test.ts b/scripts/install-plugin.windows.test.ts new file mode 100644 index 00000000..7ea7e10a --- /dev/null +++ b/scripts/install-plugin.windows.test.ts @@ -0,0 +1,164 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +test.skipIf(process.platform !== "win32")( + "Windows plugin installer verifies and stages releases without Bun on PATH", + async () => { + const root = mkdtempSync(join(tmpdir(), "roamgate plugin installer ")); + let host: ReturnType | undefined; + try { + for (const dir of ["scripts", "server", "assets"]) + mkdirSync(join(root, dir)); + copyFileSync( + join(import.meta.dir, "install-plugin.ps1"), + join(root, "scripts/install-plugin.ps1"), + ); + writeFileSync(join(root, "herdr-plugin.toml"), 'version = "9.8.7"\n'); + const powershell = join( + process.env.SystemRoot!, + "System32/WindowsPowerShell/v1.0/powershell.exe", + ); + const arch = Bun.spawnSync([ + powershell, + "-NoProfile", + "-Command", + "[Runtime.InteropServices.RuntimeInformation]::OSArchitecture", + ]) + .stdout.toString() + .trim(); + const platform = `windows-${arch === "Arm64" ? "arm64" : "x64"}`; + const packageDir = `roamgate-${platform}`; + const packagePath = join(root, "assets", packageDir); + mkdirSync(packagePath); + const fixture = join(root, "fixture.ts"); + writeFileSync( + fixture, + `if (process.argv[2] === "--version" || (process.argv[2] === "plugin" && process.argv[3] === "version" && !process.env.PLUGIN_REJECT_NATIVE)) console.log("roamgate 9.8.7"); else process.exit(2);`, + ); + expect( + Bun.spawnSync([ + process.execPath, + "build", + "--compile", + "--no-compile-autoload-dotenv", + "--no-compile-autoload-bunfig", + fixture, + "--outfile", + join(packagePath, "roamgate.exe"), + ]).exitCode, + ).toBe(0); + const archiveName = `roamgate-v9.8.7-${platform}.tar.xz`; + const archive = join(root, "assets", archiveName); + const packageArchive = (version: string) => { + writeFileSync( + join(packagePath, "VERSION"), + `roamgate ${version} ${platform}\n`, + ); + expect( + Bun.spawnSync([ + "tar.exe", + "--options", + "xz:compression-level=0", + "-C", + join(root, "assets"), + "-cJf", + archive, + packageDir, + ]).exitCode, + ).toBe(0); + return readFileSync(archive); + }; + const validArchive = packageArchive("9.8.7"); + const invalidArchive = packageArchive("9.8.6"); + let failure = "none"; + host = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + if (failure === "download") + return new Response("unavailable", { status: 503 }); + const name = new URL(request.url).pathname.slice(1); + return name === archiveName || name === `${archiveName}.sha256` + ? new Response(Bun.file(join(root, "assets", name))) + : new Response("missing", { status: 404 }); + }, + }); + for (failure of [ + "fresh", + "none", + "checksum", + "checksum-name", + "metadata", + "native-command", + "download", + ]) { + writeFileSync( + archive, + failure === "metadata" ? invalidArchive : validArchive, + ); + const digest = + failure === "checksum" + ? "0".repeat(64) + : createHash("sha256").update(readFileSync(archive)).digest("hex"); + writeFileSync( + `${archive}.sha256`, + `${digest} ${failure === "checksum-name" ? "../unrelated" : archiveName}\n`, + ); + const target = join(root, "server/roamgate.exe"); + if (failure !== "fresh") writeFileSync(target, "keep previous binary"); + const child = Bun.spawn( + [ + powershell, + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + join(root, "scripts/install-plugin.ps1"), + ], + { + env: { + ...process.env, + PATH: `${join(process.env.SystemRoot!, "System32")};${join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0")}`, + ROAMGATE_RELEASE_BASE_URL: `http://127.0.0.1:${host.port}`, + PLUGIN_REJECT_NATIVE: failure === "native-command" ? "1" : "", + }, + stdout: "pipe", + stderr: "pipe", + }, + ); + const [code, stderr] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + ]); + if (failure === "fresh" || failure === "none") { + expect(code, stderr).toBe(0); + expect( + Bun.spawnSync([target, "plugin", "version"]) + .stdout.toString() + .trim(), + ).toBe("roamgate 9.8.7"); + } else { + expect(code, failure).not.toBe(0); + expect(readFileSync(target, "utf8")).toBe("keep previous binary"); + } + expect(readdirSync(join(root, "server"))).toEqual(["roamgate.exe"]); + } + } finally { + host?.stop(true); + rmSync(root, { recursive: true, force: true }); + } + }, + 180_000, +); diff --git a/scripts/studio-plugin.test.ts b/scripts/studio-plugin.test.ts index db7d6360..1bc8ce82 100644 --- a/scripts/studio-plugin.test.ts +++ b/scripts/studio-plugin.test.ts @@ -1,377 +1,98 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, expect, test } from "bun:test"; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, - realpathSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { - computeUrl, - parseSha256File, - readServiceEnv, - releaseAssetFor, - supportsIdentityMigrationPrebuilt, -} from "./studio-plugin"; -describe("plugin commands", () => { - const roots: string[] = []; - afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }); - } - }); +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }); +}); - function checkout() { - const root = realpathSync( - mkdtempSync(join(tmpdir(), "studio-plugin-build-test-")), - ); - roots.push(root); - for (const dir of ["scripts", "web", "server", "bin"]) { - mkdirSync(join(root, dir)); - } - copyFileSync( - join(import.meta.dir, "studio-plugin.ts"), - join(root, "scripts/studio-plugin.ts"), - ); - mkdirSync(join(root, "server/src/config"), { recursive: true }); - copyFileSync( - join(import.meta.dir, "../server/src/config/data-paths.ts"), - join(root, "server/src/config/data-paths.ts"), - ); - writeFileSync( - join(root, "package.json"), - JSON.stringify({ version: "9.8.7" }), - ); - writeFileSync( - join(root, "bin/bun"), - `#!/bin/sh -printf '%s: %s\\n' "$PWD" "$*" >> "$BUILD_LOG" -[ "$PWD" != "$FAIL_DIR" ] || exit 23 -`, - { mode: 0o755 }, - ); - writeFileSync( - join(root, "fetch.js"), - `import { appendFileSync } from "node:fs"; -globalThis.fetch = async (url) => { - appendFileSync(process.env.FETCH_LOG, url + "\\n"); - if (process.env.HTTP_STATUS === "throw") throw new Error("network unavailable"); - return new Response("missing", { status: Number(process.env.HTTP_STATUS) }); -}; +function checkout() { + const root = mkdtempSync(join(tmpdir(), "studio-plugin-source-test-")); + roots.push(root); + for (const dir of ["scripts", "server", "bin"]) mkdirSync(join(root, dir)); + copyFileSync( + join(import.meta.dir, "studio-plugin.ts"), + join(root, "scripts/studio-plugin.ts"), + ); + writeFileSync( + join(root, "bin/bun"), + `#!/bin/sh +printf '%s\\n' "$*" >> "$BUILD_LOG" +[ "\${FAIL_INSTALL:-}" != yes ] || exit 23 `, - ); - return root; - } - - test("0.7.0 build refuses downloading legacy identities before mutation", () => { - const root = checkout(); - writeFileSync( - join(root, "package.json"), - JSON.stringify({ version: "0.7.0" }), - ); - const result = invoke(root, "build"); - expect(result.exitCode).toBe(1); - expect(result.stderr.toString()).toContain("requires a source build"); - expect(existsSync(join(root, "fetch.log"))).toBeFalse(); - expect(existsSync(join(root, "build.log"))).toBeFalse(); - }); - - function invoke( - root: string, - verb: string, - env: Record = {}, - ) { - return Bun.spawnSync( - [ - process.execPath, - "--preload", - join(root, "fetch.js"), - join(root, "scripts/studio-plugin.ts"), - verb, - ], - { - env: { - ...process.env, - PATH: `${join(root, "bin")}:${process.env.PATH ?? ""}`, - BUILD_LOG: join(root, "build.log"), - FETCH_LOG: join(root, "fetch.log"), - HTTP_STATUS: "404", - FAIL_DIR: "", - ...env, - }, + { mode: 0o755 }, + ); + return root; +} + +function invoke(root: string, verb: string, env = {}) { + return Bun.spawnSync( + [process.execPath, join(root, "scripts/studio-plugin.ts"), verb], + { + env: { + ...process.env, + PATH: `${join(root, "bin")}:${process.env.PATH}`, + BUILD_LOG: join(root, "build.log"), + ...env, }, - ); - } - - test.each([ - ["start", 0, "generated"], - ["restart", 0, "generated"], - ["restart", 23, "generated"], - ["restart", 1, "custom"], - ] as const)( - "%s preserves settings with exit %i for a %s service", - (verb, exitCode, definitionType) => { - const root = checkout(); - const homeDir = join(root, "home"); - const serviceDir = join(homeDir, ".config/systemd/user"); - const configDir = join(homeDir, ".config/roamgate"); - mkdirSync(serviceDir, { recursive: true }); - mkdirSync(configDir, { recursive: true }); - const definition = join(serviceDir, "roamgate.service"); - const binary = join(root, "server/roamgate"); - const previousBinary = join( - root, - "previous-installation/server/roamgate", - ); - const previousDefinition = - (definitionType === "generated" - ? "# Generated by roamgate service install.\n" - : "# Custom service definition.\n") + - `[Service]\nExecStart=${previousBinary}\n`; - writeFileSync(definition, previousDefinition); - const config = "HOST=127.0.0.1\nPORT=8799\n"; - const token = "a".repeat(64); - writeFileSync(join(configDir, "roamgate.env"), config); - writeFileSync(join(configDir, "auth-token"), token); - writeFileSync( - binary, - '#!/bin/sh\nexec "$PLUGIN_TEST_BUN" "$PLUGIN_SERVICE_FIXTURE" "$0" "$@"\n', - { mode: 0o755 }, - ); - const fixture = join(root, "service-fixture.ts"); - writeFileSync( - fixture, - `import { appendFileSync } from "node:fs"; -import { runServiceCommand } from ${JSON.stringify(join(import.meta.dir, "../server/src/config/service-manager.ts"))}; -const [binary, ...args] = process.argv.slice(2); -process.exit(runServiceCommand(args, { - runtime: { platform: "linux", homeDir: process.env.HOME, execPath: binary, argv: [binary, ...args] }, - runCommand: (argv) => { - if (argv.includes("herdr-gui.service")) return 4; - appendFileSync(process.env.SERVICE_LOG, JSON.stringify(argv) + "\\n"); - return argv.includes("daemon-reload") ? ${exitCode} : 0; - }, - getLanIPs: () => [], - log: () => {}, -})); -`, - ); - const result = invoke(root, verb, { - HOME: homeDir, - PLUGIN_TEST_BUN: process.execPath, - PLUGIN_SERVICE_FIXTURE: fixture, - SERVICE_LOG: join(root, "service.log"), - }); - expect(result.exitCode).toBe(exitCode); - const installed = readFileSync(definition, "utf8"); - expect(readFileSync(join(configDir, "roamgate.env"), "utf8")).toBe( - config, - ); - expect(readFileSync(join(configDir, "auth-token"), "utf8")).toBe(token); - if (definitionType === "custom") { - expect(installed).toBe(previousDefinition); - expect(result.stderr.toString()).toContain( - "was not generated by roamgate", - ); - expect(existsSync(join(root, "service.log"))).toBeFalse(); - return; - } - expect(installed).toContain(`ExecStart=${binary}\n`); - expect(installed).not.toContain(previousBinary); - const commands = readFileSync(join(root, "service.log"), "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - expect(commands).toEqual( - exitCode === 0 - ? [ - ["systemctl", "--user", "daemon-reload"], - ["systemctl", "--user", "enable", "roamgate.service"], - ["systemctl", "--user", "restart", "roamgate.service"], - ] - : [["systemctl", "--user", "daemon-reload"]], - ); }, ); +} - test("build-source installs all dependencies and builds without downloading a release", () => { - const root = checkout(); - const result = invoke(root, "build-source"); - expect(result.exitCode).toBe(0); - expect( - readFileSync(join(root, "build.log"), "utf8").trim().split("\n"), - ).toEqual([ - `${root}: install`, - `${root}/web: install`, - `${root}/server: install`, - `${root}: run build`, - ]); - expect(existsSync(join(root, "fetch.log"))).toBe(false); - }); - - test("build-source stops on dependency installation failure", () => { - const root = checkout(); - expect( - invoke(root, "build-source", { FAIL_DIR: join(root, "web") }).exitCode, - ).toBe(23); - expect( - readFileSync(join(root, "build.log"), "utf8").trim().split("\n"), - ).toHaveLength(2); - expect(existsSync(join(root, "fetch.log"))).toBe(false); - }); - - test.each(["404", "500", "throw"])( - "release-only build fails actionably on %s without source or legacy fallback", - (status) => { - const root = checkout(); - const result = invoke(root, "build", { HTTP_STATUS: status }); - expect(result.exitCode).toBe(1); - expect(result.stderr.toString()).toContain( - "bun scripts/studio-plugin.ts build-source", - ); - expect(result.stderr.toString()).toContain("herdr plugin link ."); - expect(result.stderr.toString()).toContain("--ref vX.Y.Z"); - expect(existsSync(join(root, "build.log"))).toBe(false); - const requests = readFileSync(join(root, "fetch.log"), "utf8") - .trim() - .split("\n"); - expect(requests).toHaveLength(2); - for (const url of requests) { - expect(url).toContain("/releases/download/v9.8.7/roamgate-"); - } - expect(existsSync(join(root, "server/roamgate"))).toBe(false); - expect(existsSync(join(root, "server/roamgate.exe"))).toBe(false); - }, +test("source build uses the root frozen workspace lockfile", () => { + const root = checkout(); + expect(invoke(root, "build-source").exitCode).toBe(0); + expect(readFileSync(join(root, "build.log"), "utf8")).toBe( + "install --frozen-lockfile\nrun build\n", ); }); -test("prebuilt identity floor compares numeric versions", () => { - for (const version of ["0.6.2", "0.7.0", "0.7.0-beta.1", "unknown"]) - expect(supportsIdentityMigrationPrebuilt(version)).toBeFalse(); - for (const version of ["0.7.1", "0.8.0", "0.10.0", "1.0.0"]) - expect(supportsIdentityMigrationPrebuilt(version)).toBeTrue(); -}); - -describe("releaseAssetFor", () => { - test("maps every supported platform to an archive and binary name", () => { - expect(releaseAssetFor("darwin", "arm64")).toEqual({ - asset: "roamgate-darwin-arm64", - binary: "roamgate", - }); - expect(releaseAssetFor("linux", "x64")).toEqual({ - asset: "roamgate-linux-x64", - binary: "roamgate", - }); - expect(releaseAssetFor("win32", "x64")?.binary).toBe("roamgate.exe"); - expect(releaseAssetFor("win32", "arm64")?.asset).toBe( - "roamgate-windows-arm64", - ); - }); - - test("returns null for unsupported platforms", () => { - expect(releaseAssetFor("freebsd", "x64")).toBeNull(); - expect(releaseAssetFor("darwin", "ia32")).toBeNull(); - }); -}); - -describe("parseSha256File", () => { - test("extracts the digest from shasum output", () => { - const digest = "a".repeat(64); - expect(parseSha256File(`${digest} roamgate-darwin-arm64.tar.xz\n`)).toBe( - digest, - ); - }); - - test("rejects content without a digest", () => { - expect(parseSha256File("not a checksum")).toBeNull(); - expect(parseSha256File("zzzz" + "0".repeat(60))).toBeNull(); - }); +test("source build stops after a dependency installation failure", () => { + const root = checkout(); + expect(invoke(root, "build-source", { FAIL_INSTALL: "yes" }).exitCode).toBe( + 23, + ); + expect(readFileSync(join(root, "build.log"), "utf8")).toBe( + "install --frozen-lockfile\n", + ); }); -describe("readServiceEnv", () => { - test("reads plain values", () => { - expect(readServiceEnv("HOST=0.0.0.0\nPORT=8791\n", "HOST")).toBe("0.0.0.0"); - expect(readServiceEnv("HOST=0.0.0.0\nPORT=8791\n", "PORT")).toBe("8791"); - }); - - test("accepts export prefix, whitespace, and quoted values", () => { - const contents = "export HOST=\"0.0.0.0\"\n PORT = '8799'\n"; - expect(readServiceEnv(contents, "HOST")).toBe("0.0.0.0"); - expect(readServiceEnv(contents, "PORT")).toBe("8799"); - }); - - test("last occurrence wins and missing keys are undefined", () => { - expect(readServiceEnv("PORT=1\nPORT=2\n", "PORT")).toBe("2"); - expect(readServiceEnv("HOST=x\n", "PORT")).toBeUndefined(); - }); - - test("ignores comments and unrelated keys", () => { - const contents = - "# HOST=10.0.0.1\nHERDR_GUI_LOG_LEVEL=info\nHOST=127.0.0.1\n"; - expect(readServiceEnv(contents, "HOST")).toBe("127.0.0.1"); - }); +test.each([ + "start", + "restart", + "status", + "url", + "version", + "uninstall", + "panel", +])("legacy %s entry delegates to the native plugin command", (verb) => { + const root = checkout(); + writeFileSync( + join(root, "server/roamgate"), + '#!/bin/sh\nprintf "%s\\n" "$*" > "$BUILD_LOG"\nexit 23\n', + { mode: 0o755 }, + ); + expect(invoke(root, verb).exitCode).toBe(23); + expect(readFileSync(join(root, "build.log"), "utf8")).toBe( + `plugin ${verb}\n`, + ); }); -describe("computeUrl", () => { - const dirs: string[] = []; - function fixture(files: Record): string { - const dir = mkdtempSync(join(tmpdir(), "studio-plugin-")); - dirs.push(dir); - for (const [name, text] of Object.entries(files)) { - writeFileSync(join(dir, name), text); - } - return dir; - } - afterEach(() => { - while (dirs.length) { - rmSync(dirs.pop()!, { recursive: true, force: true }); - } - }); - - test("defaults to loopback when no env file exists", () => { - expect(computeUrl(fixture({}))).toBe("http://127.0.0.1:8787"); - }); - - test("includes the login token for non-loopback binds", () => { - const dir = fixture({ - "roamgate.env": "HOST=0.0.0.0\nPORT=8791\n", - "auth-token": "abc123\n", - }); - expect(computeUrl(dir)).toBe("http://localhost:8791/?token=abc123"); - }); - - test("includes the login token for loopback binds", () => { - const dir = fixture({ - "roamgate.env": "HOST=127.0.0.1\nPORT=8787\n", - "auth-token": "abc123\n", - }); - expect(computeUrl(dir)).toBe("http://127.0.0.1:8787/?token=abc123"); - }); - - test("new password values take precedence, including empty values", () => { - const dir = fixture({ - "roamgate.env": - "HOST=0.0.0.0\nHERDR_GUI_PASSWORD=old\nROAMGATE_PASSWORD=new\n", - "auth-token": "saved-token\n", - }); - expect(computeUrl(dir)).toBe("http://localhost:8787"); - writeFileSync( - join(dir, "roamgate.env"), - "HOST=0.0.0.0\nHERDR_GUI_PASSWORD=old\nROAMGATE_PASSWORD=\n", - ); - expect(computeUrl(dir)).toBe("http://localhost:8787/?token=saved-token"); - }); - - test("honors exported and quoted entries without a token file", () => { - const dir = fixture({ - "roamgate.env": 'export HOST="0.0.0.0"\nPORT = "8799"\n', - }); - expect(computeUrl(dir)).toBe("http://localhost:8799"); - }); +test("a missing source binary fails without silently downloading a release", () => { + const root = checkout(); + const result = invoke(root, "start"); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("build-source"); + expect(existsSync(join(root, "build.log"))).toBe(false); }); diff --git a/scripts/studio-plugin.ts b/scripts/studio-plugin.ts index 4782c90b..54fee4eb 100644 --- a/scripts/studio-plugin.ts +++ b/scripts/studio-plugin.ts @@ -1,446 +1,71 @@ #!/usr/bin/env bun -// Thin Herdr plugin shim for Roamgate. Herdr invokes the verbs below -// through herdr-plugin.toml actions; the verb names and their argv mapping -// are a frozen contract because managed installs call the action set cached -// at install time. -// -// Verbs: build, build-source, start, restart, status, url, version, -// uninstall, panel. The whole shim runs on Bun. `build` downloads the -// checksum-verified prebuilt release binary matching this checkout's -// version; `build-source` compiles unreleased checkouts before local linking. -// The service verbs (start, restart, status, uninstall) delegate to the binary, -// downloading it first when missing. `url` and `version` only read on-disk -// state. `panel` is the interactive popup TUI behind the manifest [[panes]] -// entry and downloads on demand for its service keys. - +// Source-build helper and compatibility entry point; installed plugins run +// `roamgate plugin` directly and do not require an external Bun runtime. import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { - chmodSync, - copyFileSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; +import { existsSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { - dataRoot, - legacyDataRoot, - assertSafeDataPath, -} from "../server/src/config/data-paths"; - const REPO_ROOT = fileURLToPath(new URL("..", import.meta.url)); -const BINARY_CANDIDATES = - process.platform === "win32" ? ["roamgate.exe", "roamgate"] : ["roamgate"]; - -function binaryPath(): string | null { - for (const name of BINARY_CANDIDATES) { - const path = join(REPO_ROOT, "server", name); - if (existsSync(path)) return path; - } - return null; -} -function run(argv: string[], cwd = REPO_ROOT): number { +function run(argv: string[]): number { const result = spawnSync(argv[0], argv.slice(1), { - cwd, + cwd: REPO_ROOT, stdio: "inherit", }); - if (result.error) { - console.error( - `studio-plugin: cannot run ${argv[0]}: ${result.error.message}`, - ); - return 1; - } + if (result.error) console.error(`studio-plugin: ${result.error.message}`); return result.status ?? 1; } -function capture(argv: string[]): { code: number; out: string; err: string } { - const result = spawnSync(argv[0], argv.slice(1), { - cwd: REPO_ROOT, - encoding: "utf8", - }); - if (result.error) return { code: 1, out: "", err: result.error.message }; - return { - code: result.status ?? 1, - out: result.stdout ?? "", - err: result.stderr ?? "", - }; -} - -// Release archives ship one prebuilt binary per platform; `build` downloads -// and checksum-verifies the archive matching this checkout's version. -// Unreleased checkouts require an explicit `build-source`, then local linking. -export const PLATFORM_ASSETS: Record< - string, - { asset: string; binary: string } -> = { - "darwin-arm64": { asset: "roamgate-darwin-arm64", binary: "roamgate" }, - "darwin-x64": { asset: "roamgate-darwin-x64", binary: "roamgate" }, - "linux-arm64": { asset: "roamgate-linux-arm64", binary: "roamgate" }, - "linux-x64": { asset: "roamgate-linux-x64", binary: "roamgate" }, - "win32-arm64": { asset: "roamgate-windows-arm64", binary: "roamgate.exe" }, - "win32-x64": { asset: "roamgate-windows-x64", binary: "roamgate.exe" }, -}; - -const RELEASE_REPOSITORY = "powerfooI/roamgate"; -const SOURCE_INSTALL_HINT = `For an unreleased checkout, run \`bun scripts/studio-plugin.ts build-source\` in a local clone, then \`herdr plugin link .\`. For release-only installation, select a published Roamgate tag with \`herdr plugin install ${RELEASE_REPOSITORY} --ref vX.Y.Z\`.`; - -export function releaseAssetFor( - platform: string, - arch: string, -): { asset: string; binary: string } | null { - return PLATFORM_ASSETS[`${platform}-${arch}`] ?? null; -} - -export function parseSha256File(text: string): string | null { - const match = /\b([0-9a-f]{64})\b/.exec(text); - return match?.[1] ?? null; -} - -export function supportsIdentityMigrationPrebuilt(version: string): boolean { - const match = - /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.exec( - version, - ); - if (!match) return false; - const [major, minor, patch] = match.slice(1, 4).map(Number); - return major > 0 || minor > 7 || (minor === 7 && patch > 0); -} - -async function downloadPrebuilt(): Promise { - const version = packageVersion(); - if (!supportsIdentityMigrationPrebuilt(version)) { - console.error( - `studio-plugin: published versions through 0.7.0 use legacy service/data/plugin identities. This checkout requires a source build. ${SOURCE_INSTALL_HINT}`, - ); - return 1; +function build(): number { + return process.platform === "win32" + ? run([ + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/install-plugin.ps1", + ]) + : run(["sh", "scripts/install-plugin.sh"]); +} + +function main(): number { + const verb = process.argv[2]; + if (verb === "build-source") { + const installed = run(["bun", "install", "--frozen-lockfile"]); + return installed === 0 ? run(["bun", "run", "build"]) : installed; } - const target = releaseAssetFor(process.platform, process.arch); - if (!target) { - console.error( - `studio-plugin: no prebuilt binary for ${process.platform}-${process.arch}. ${SOURCE_INSTALL_HINT}`, + if (verb === "build") return build(); + if ( + [ + "start", + "restart", + "status", + "url", + "version", + "uninstall", + "panel", + ].includes(verb) + ) { + const binary = join( + REPO_ROOT, + "server", + process.platform === "win32" ? "roamgate.exe" : "roamgate", ); - return 1; - } - const base = `https://github.com/${RELEASE_REPOSITORY}/releases/download/v${version}`; - const archiveName = `${target.asset}.tar.xz`; - console.error(`studio-plugin: downloading ${archiveName} (v${version})`); - let tmp: string | null = null; - try { - const [checksumResponse, archiveResponse] = await Promise.all([ - fetch(`${base}/${archiveName}.sha256`), - fetch(`${base}/${archiveName}`), - ]); - if (!checksumResponse.ok || !archiveResponse.ok) { - console.error( - `studio-plugin: download failed for Roamgate v${version} (checksum HTTP ${checksumResponse.status}, archive HTTP ${archiveResponse.status}). ${SOURCE_INSTALL_HINT}`, - ); - return 1; - } - const expected = parseSha256File(await checksumResponse.text()); - const archive = new Uint8Array(await archiveResponse.arrayBuffer()); - const actual = createHash("sha256").update(archive).digest("hex"); - if (!expected || actual !== expected) { - console.error( - `studio-plugin: checksum mismatch (expected ${expected ?? ""}, got ${actual})`, - ); - return 1; - } - tmp = mkdtempSync(join(tmpdir(), "studio-plugin-")); - const archivePath = join(tmp, archiveName); - writeFileSync(archivePath, archive); - const extract = spawnSync("tar", ["-xJf", archivePath, "-C", tmp], { - encoding: "utf8", - }); - if (extract.error) { + if (!existsSync(binary)) { console.error( - `studio-plugin: cannot run tar: ${extract.error.message} (tar with xz support is required)`, + "studio-plugin: binary missing; run build or build-source first", ); return 1; } - if (extract.status !== 0) { - console.error( - `studio-plugin: extraction failed (exit ${extract.status}): ${extract.stderr.trim()}`, - ); - return 1; - } - const extracted = join(tmp, target.asset, target.binary); - const serverDir = join(REPO_ROOT, "server"); - mkdirSync(serverDir, { recursive: true }); - const destination = join(serverDir, target.binary); - copyFileSync(extracted, destination); - if (process.platform !== "win32") chmodSync(destination, 0o755); - console.error(`studio-plugin: installed ${target.binary} ${version}`); - return 0; - } catch (error) { - console.error( - `studio-plugin: download failed: ${error instanceof Error ? error.message : String(error)}. ${SOURCE_INSTALL_HINT}`, - ); - return 1; - } finally { - if (tmp) rmSync(tmp, { recursive: true, force: true }); + return run([binary, "plugin", verb]); } -} - -function buildSource(): number { - // This repo is not a Bun workspace: web/ and server/ carry their own - // dependencies, so a clean checkout needs an install in each location. - for (const dir of [".", "web", "server"]) { - const code = run(["bun", "install"], join(REPO_ROOT, dir)); - if (code !== 0) return code; - } - return run(["bun", "run", "build"]); -} - -async function ensureBinary(): Promise { - const existing = binaryPath(); - if (existing) return existing; - console.error("studio-plugin: roamgate binary missing, downloading it first"); - if ((await downloadPrebuilt()) !== 0) return null; - const downloaded = binaryPath(); - if (!downloaded) { - console.error( - "studio-plugin: download finished but no binary was produced", - ); - } - return downloaded; -} - -async function service(...args: string[]): Promise { - const binary = await ensureBinary(); - if (!binary) return 1; - return run([binary, "service", ...args]); -} - -function packageVersion(): string { - const packageJson = JSON.parse( - readFileSync(join(REPO_ROOT, "package.json"), "utf8"), - ) as { version?: string }; - return packageJson.version ?? "unknown"; -} - -function configDir(): string { - return dataRoot(); -} - -// URL/status are read-only: prefer the new file, but do not migrate on inspection. -function readableConfigFile(dir: string, name: string): string { - const path = join(dir, name); - assertSafeDataPath(path); - if (existsSync(path) || dir !== configDir()) return path; - const legacy = join( - legacyDataRoot(), - name === "roamgate.env" ? "herdr-gui.env" : name, + console.error( + "Usage: bun scripts/studio-plugin.ts ", ); - assertSafeDataPath(legacy); - return legacy; + return verb ? 1 : 0; } -// Mirrors the server's service env parser: leading whitespace, an optional -// `export` prefix, whitespace around `=`, and optionally quoted values; the -// last occurrence wins. -export function readServiceEnv( - contents: string, - name: string, -): string | undefined { - let value: string | undefined; - for (const line of contents.split(/\r?\n/)) { - const match = line.match( - new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*(.*?)\\s*$`), - ); - if (!match) continue; - const raw = match[1] ?? ""; - value = - raw.length >= 2 && - (raw.startsWith("'") || raw.startsWith('"')) && - raw.at(-1) === raw[0] - ? raw.slice(1, -1) - : raw; - } - return value; -} - -export function computeUrl(dir = configDir()): string { - const envFile = readableConfigFile(dir, "roamgate.env"); - let host = "127.0.0.1"; - let port = "8787"; - let usesFixedPassword = false; - if (existsSync(envFile)) { - const contents = readFileSync(envFile, "utf8"); - host = readServiceEnv(contents, "HOST") ?? host; - port = readServiceEnv(contents, "PORT") ?? port; - usesFixedPassword = - ( - readServiceEnv(contents, "ROAMGATE_PASSWORD") ?? - readServiceEnv(contents, "HERDR_GUI_PASSWORD") ?? - "" - ).length > 0; - } - const anyHost = host === "0.0.0.0" || host === "::"; - const browserHost = anyHost ? "localhost" : host; - const formatted = browserHost.includes(":") - ? `[${browserHost}]` - : browserHost; - let url = `http://${formatted}:${port}`; - const tokenPath = readableConfigFile(dir, "auth-token"); - if (!usesFixedPassword && existsSync(tokenPath)) { - const token = readFileSync(tokenPath, "utf8").trim(); - if (token) url = `${url}/?token=${encodeURIComponent(token)}`; - } - return url; -} - -function printUrl(): number { - const envFile = readableConfigFile(configDir(), "roamgate.env"); - if (!existsSync(envFile)) { - console.error( - `studio-plugin: no service environment at ${envFile}, showing defaults`, - ); - } - console.log(computeUrl()); - return 0; -} - -function versionText(): string { - const binary = binaryPath(); - if (binary) { - const result = capture([binary, "--version"]); - if (result.code === 0) return result.out.trim(); - } - return `${packageVersion()} (binary not built)`; -} - -function version(): number { - console.log(versionText()); - return 0; -} - -function statusText(): string { - const binary = binaryPath(); - if (!binary) return "binary not built"; - const result = capture([binary, "service", "status"]); - if (result.code !== 0) return "not installed"; - const firstLine = result.out.trim().split("\n")[0]?.trim(); - return firstLine || "installed"; -} - -function renderPanel(message: string) { - const lines = [ - "Roamgate", - "", - `Status: ${statusText()}`, - `URL: ${computeUrl()}`, - `Version: ${versionText()}`, - "", - "[s] start [r] restart [u] uninstall [q] close", - ]; - if (message) lines.push("", message); - process.stdout.write(`\x1b[?25l\x1b[2J\x1b[H${lines.join("\r\n")}\r\n`); -} - -function panel(): Promise { - if (!process.stdin.isTTY) { - console.log(`Status: ${statusText()}`); - console.log(`URL: ${computeUrl()}`); - console.log(`Version: ${versionText()}`); - return Promise.resolve(0); - } - const stdin = process.stdin; - return new Promise((resolve) => { - let message = ""; - let done = false; - let busy = false; - const cleanup = (code: number) => { - if (done) return; - done = true; - process.stdout.write("\x1b[?25h\x1b[0m\n"); - stdin.setRawMode(false); - stdin.pause(); - resolve(code); - }; - // The host can end the session by closing stdin or signaling the - // process; leave the terminal restored in every exit path. - stdin.on("end", () => cleanup(0)); - stdin.on("close", () => cleanup(0)); - process.on("SIGTERM", () => cleanup(0)); - process.on("SIGINT", () => cleanup(0)); - stdin.setRawMode(true); - stdin.resume(); - stdin.on("data", (chunk: Buffer) => { - const key = chunk.toString("utf8"); - if (key === "q" || key === "\x03") { - cleanup(0); - return; - } - const args = - key === "s" - ? ["install", "--force"] - : key === "r" - ? ["install"] - : key === "u" - ? ["uninstall"] - : null; - if (!args || busy) return; - busy = true; - message = "working..."; - renderPanel(message); - void (async () => { - const binary = await ensureBinary(); - if (done) return; - if (!binary) { - message = "download failed"; - } else { - const result = capture([binary, "service", ...args]); - const detail = - (result.err || result.out).trim().split("\n").pop() ?? ""; - message = - result.code === 0 - ? "done" - : `failed (exit ${result.code}): ${detail}`; - } - busy = false; - renderPanel(message); - })(); - }); - renderPanel(message); - }); -} - -async function main(): Promise { - switch (process.argv[2]) { - case "build": - return downloadPrebuilt(); - case "build-source": - return buildSource(); - case "start": - return service("install", "--force"); - case "restart": - return service("install"); - case "status": - return service("status"); - case "url": - return printUrl(); - case "version": - return version(); - case "uninstall": - return service("uninstall"); - case "panel": - return panel(); - default: - console.error( - "usage: studio-plugin.ts ", - ); - return process.argv[2] ? 1 : 0; - } -} - -if (import.meta.main) { - process.exit(await main()); -} +if (import.meta.main) process.exit(main()); diff --git a/server/src/config/data-paths.test.ts b/server/src/config/data-paths.test.ts index 54daf489..f7fd9ea2 100644 --- a/server/src/config/data-paths.test.ts +++ b/server/src/config/data-paths.test.ts @@ -262,7 +262,7 @@ test("plugin URL reads legacy files without migrating and prefers new paths", as const current = dataRoot(root, process.platform, appData); write(join(legacy, "herdr-gui.env"), "HOST=0.0.0.0\nPORT=8890\n"); write(join(legacy, "auth-token"), `${"c".repeat(64)}\n`); - const script = `import { computeUrl } from ${JSON.stringify(join(import.meta.dir, "../../..", "scripts/studio-plugin.ts"))}; console.log(computeUrl());`; + const script = `import { computeUrl } from ${JSON.stringify(join(import.meta.dir, "plugin-command.ts"))}; console.log(computeUrl());`; const invoke = async () => { const child = Bun.spawn([process.execPath, "-e", script], { env: { ...process.env, HOME: root, USERPROFILE: root, APPDATA: appData }, diff --git a/server/src/config/plugin-command.test.ts b/server/src/config/plugin-command.test.ts new file mode 100644 index 00000000..23923954 --- /dev/null +++ b/server/src/config/plugin-command.test.ts @@ -0,0 +1,185 @@ +import { afterEach, describe, expect, test, spyOn } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { computeUrl, readServiceEnv, runPluginCommand } from "./plugin-command"; + +describe("readServiceEnv", () => { + test("reads plain values", () => { + expect(readServiceEnv("HOST=0.0.0.0\nPORT=8791\n", "HOST")).toBe("0.0.0.0"); + expect(readServiceEnv("HOST=0.0.0.0\nPORT=8791\n", "PORT")).toBe("8791"); + }); + + test("accepts export prefix, whitespace, and quoted values", () => { + const contents = "export HOST=\"0.0.0.0\"\n PORT = '8799'\n"; + expect(readServiceEnv(contents, "HOST")).toBe("0.0.0.0"); + expect(readServiceEnv(contents, "PORT")).toBe("8799"); + }); + + test("last occurrence wins and missing keys are undefined", () => { + expect(readServiceEnv("PORT=1\nPORT=2\n", "PORT")).toBe("2"); + expect(readServiceEnv("HOST=x\n", "PORT")).toBeUndefined(); + }); + + test("ignores comments and unrelated keys", () => { + const contents = + "# HOST=10.0.0.1\nHERDR_GUI_LOG_LEVEL=info\nHOST=127.0.0.1\n"; + expect(readServiceEnv(contents, "HOST")).toBe("127.0.0.1"); + }); +}); + +describe("computeUrl", () => { + const dirs: string[] = []; + function fixture(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), "studio-plugin-")); + dirs.push(dir); + for (const [name, text] of Object.entries(files)) { + writeFileSync(join(dir, name), text); + } + return dir; + } + afterEach(() => { + while (dirs.length) { + rmSync(dirs.pop()!, { recursive: true, force: true }); + } + }); + + test("defaults to loopback when no env file exists", () => { + expect(computeUrl(fixture({}))).toBe("http://127.0.0.1:8787"); + }); + + test("includes the login token for non-loopback binds", () => { + const dir = fixture({ + "roamgate.env": "HOST=0.0.0.0\nPORT=8791\n", + "auth-token": "abc123\n", + }); + expect(computeUrl(dir)).toBe("http://localhost:8791/?token=abc123"); + }); + + test("includes the login token for loopback binds", () => { + const dir = fixture({ + "roamgate.env": "HOST=127.0.0.1\nPORT=8787\n", + "auth-token": "abc123\n", + }); + expect(computeUrl(dir)).toBe("http://127.0.0.1:8787/?token=abc123"); + }); + + test("new password values take precedence, including empty values", () => { + const dir = fixture({ + "roamgate.env": + "HOST=0.0.0.0\nHERDR_GUI_PASSWORD=old\nROAMGATE_PASSWORD=new\n", + "auth-token": "saved-token\n", + }); + expect(computeUrl(dir)).toBe("http://localhost:8787"); + writeFileSync( + join(dir, "roamgate.env"), + "HOST=0.0.0.0\nHERDR_GUI_PASSWORD=old\nROAMGATE_PASSWORD=\n", + ); + expect(computeUrl(dir)).toBe("http://localhost:8787/?token=saved-token"); + }); + + test("honors exported and quoted entries without a token file", () => { + const dir = fixture({ + "roamgate.env": 'export HOST="0.0.0.0"\nPORT = "8799"\n', + }); + expect(computeUrl(dir)).toBe("http://localhost:8799"); + }); +}); + +test("plugin dispatch rejects extra arguments and leaves other CLI commands alone", async () => { + const error = spyOn(console, "error").mockImplementation(() => {}); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await runPluginCommand(["service", "install"], "9.8.7")).toBeNull(); + expect( + await runPluginCommand(["plugin", "restart", "--force"], "9.8.7"), + ).toBe(1); + expect(await runPluginCommand(["plugin", "version"], "9.8.7")).toBe(0); + expect(log).toHaveBeenCalledWith("roamgate 9.8.7"); + } finally { + error.mockRestore(); + log.mockRestore(); + } +}); + +for (const [verb, exitCode, definitionType] of [ + ["start", 0, "generated"], + ["restart", 0, "generated"], + ["restart", 23, "generated"], + ["restart", 1, "custom"], +] as const) { + test(`native ${verb} preserves configuration for a ${definitionType} service with exit ${exitCode}`, async () => { + const root = mkdtempSync(join(tmpdir(), "roamgate-native-plugin-")); + try { + const homeDir = join(root, "home"); + const serviceDir = join(homeDir, ".config/systemd/user"); + const configDir = join(homeDir, ".config/roamgate"); + mkdirSync(serviceDir, { recursive: true }); + mkdirSync(configDir, { recursive: true }); + const definition = join(serviceDir, "roamgate.service"); + const binary = join(root, "roamgate"); + const previous = + (definitionType === "generated" + ? "# Generated by roamgate service install.\n" + : "# Custom service definition.\n") + + "[Service]\nExecStart=/previous-installation/roamgate\n"; + writeFileSync(definition, previous); + const config = "HOST=127.0.0.1\nPORT=8799\n"; + const token = "a".repeat(64); + writeFileSync(join(configDir, "roamgate.env"), config); + writeFileSync(join(configDir, "auth-token"), token); + const fixture = join(root, "service-fixture.ts"); + writeFileSync( + binary, + `#!/bin/sh\nexec '${process.execPath.replaceAll("'", "'\\''")}' '${fixture.replaceAll("'", "'\\''")}' "$0" "$@"\n`, + { mode: 0o755 }, + ); + writeFileSync( + fixture, + `import { appendFileSync } from "node:fs"; +import { runServiceCommand } from ${JSON.stringify(join(import.meta.dir, "service-manager.ts"))}; +const [binary, ...args] = process.argv.slice(2); +const result = runServiceCommand(args, { + runtime: { platform: "linux", homeDir: ${JSON.stringify(homeDir)}, execPath: binary, argv: [binary, ...args] }, + runCommand: argv => { + if (argv.includes("herdr-gui.service")) return 4; + appendFileSync(${JSON.stringify(join(root, "service.log"))}, JSON.stringify(argv) + "\\n"); + return argv.includes("daemon-reload") ? ${exitCode} : 0; + }, getLanIPs: () => [], log: () => {}, error: () => {}, +}); +process.exit(typeof result === "number" ? result : 1); +`, + ); + expect(await runPluginCommand(["plugin", verb], "9.8.7", binary)).toBe( + exitCode, + ); + expect(readFileSync(join(configDir, "roamgate.env"), "utf8")).toBe( + config, + ); + expect(readFileSync(join(configDir, "auth-token"), "utf8")).toBe(token); + if (definitionType === "custom") { + expect(readFileSync(definition, "utf8")).toBe(previous); + expect(existsSync(join(root, "service.log"))).toBe(false); + } else { + expect(readFileSync(definition, "utf8")).toContain( + `ExecStart=${binary}\n`, + ); + expect(readFileSync(definition, "utf8")).not.toContain( + "/previous-installation", + ); + expect(readFileSync(join(root, "service.log"), "utf8")).toContain( + "daemon-reload", + ); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} diff --git a/server/src/config/plugin-command.ts b/server/src/config/plugin-command.ts new file mode 100644 index 00000000..e8cb636f --- /dev/null +++ b/server/src/config/plugin-command.ts @@ -0,0 +1,202 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { dataRoot, legacyDataRoot, assertSafeDataPath } from "./data-paths"; + +function capture(binary: string, args: string[]) { + const result = spawnSync(binary, args, { encoding: "utf8" }); + return { + code: result.status ?? 1, + out: result.stdout ?? "", + err: result.error?.message ?? result.stderr ?? "", + }; +} + +function configDir(): string { + return dataRoot(); +} + +// URL/status are read-only: prefer the new file, but do not migrate on inspection. +function readableConfigFile(dir: string, name: string): string { + const path = join(dir, name); + assertSafeDataPath(path); + if (existsSync(path) || dir !== configDir()) return path; + const legacy = join( + legacyDataRoot(), + name === "roamgate.env" ? "herdr-gui.env" : name, + ); + assertSafeDataPath(legacy); + return legacy; +} + +// Mirrors the server's service env parser: leading whitespace, an optional +// `export` prefix, whitespace around `=`, and optionally quoted values; the +// last occurrence wins. +export function readServiceEnv( + contents: string, + name: string, +): string | undefined { + let value: string | undefined; + for (const line of contents.split(/\r?\n/)) { + const match = line.match( + new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*(.*?)\\s*$`), + ); + if (!match) continue; + const raw = match[1] ?? ""; + value = + raw.length >= 2 && + (raw.startsWith("'") || raw.startsWith('"')) && + raw.at(-1) === raw[0] + ? raw.slice(1, -1) + : raw; + } + return value; +} + +export function computeUrl(dir = configDir()): string { + const envFile = readableConfigFile(dir, "roamgate.env"); + let host = "127.0.0.1"; + let port = "8787"; + let usesFixedPassword = false; + if (existsSync(envFile)) { + const contents = readFileSync(envFile, "utf8"); + host = readServiceEnv(contents, "HOST") ?? host; + port = readServiceEnv(contents, "PORT") ?? port; + usesFixedPassword = + ( + readServiceEnv(contents, "ROAMGATE_PASSWORD") ?? + readServiceEnv(contents, "HERDR_GUI_PASSWORD") ?? + "" + ).length > 0; + } + const anyHost = host === "0.0.0.0" || host === "::"; + const browserHost = anyHost ? "localhost" : host; + const formatted = browserHost.includes(":") + ? `[${browserHost}]` + : browserHost; + let url = `http://${formatted}:${port}`; + const tokenPath = readableConfigFile(dir, "auth-token"); + if (!usesFixedPassword && existsSync(tokenPath)) { + const token = readFileSync(tokenPath, "utf8").trim(); + if (token) url = `${url}/?token=${encodeURIComponent(token)}`; + } + return url; +} + +function printUrl(): number { + const envFile = readableConfigFile(configDir(), "roamgate.env"); + if (!existsSync(envFile)) { + console.error( + `roamgate plugin: no service environment at ${envFile}, showing defaults`, + ); + } + console.log(computeUrl()); + return 0; +} + +function statusText(binary: string): string { + const result = capture(binary, ["service", "status"]); + if (result.code !== 0) return "not installed"; + return result.out.trim().split("\n")[0]?.trim() || "installed"; +} + +function panel(binary: string, version: string): Promise { + if (!process.stdin.isTTY) { + console.log(`Status: ${statusText(binary)}`); + console.log(`URL: ${computeUrl()}`); + console.log(`Version: roamgate ${version}`); + return Promise.resolve(0); + } + const stdin = process.stdin; + return new Promise((resolve) => { + let done = false; + function render(message = "") { + const lines = [ + "Roamgate", + "", + `Status: ${statusText(binary)}`, + `URL: ${computeUrl()}`, + `Version: roamgate ${version}`, + "", + "[s] start [r] restart [u] uninstall [q] close", + ]; + if (message) lines.push("", message); + process.stdout.write(`\x1b[?25l\x1b[2J\x1b[H${lines.join("\r\n")}\r\n`); + } + const cleanup = () => { + if (done) return; + done = true; + process.stdout.write("\x1b[?25h\x1b[0m\n"); + stdin.setRawMode(false); + stdin.pause(); + resolve(0); + }; + // Restore the terminal when the host closes or signals the panel. + stdin.on("end", cleanup); + stdin.on("close", cleanup); + process.on("SIGTERM", cleanup); + process.on("SIGINT", cleanup); + stdin.setRawMode(true); + stdin.resume(); + stdin.on("data", (chunk: Buffer) => { + const key = chunk.toString("utf8"); + if (key === "q" || key === "\x03") return cleanup(); + const args = + key === "s" + ? ["install", "--force"] + : key === "r" + ? ["install"] + : key === "u" + ? ["uninstall"] + : null; + if (!args || done) return; + render("working..."); + const result = capture(binary, ["service", ...args]); + const detail = (result.err || result.out).trim().split("\n").pop() ?? ""; + render( + result.code === 0 ? "done" : `failed (exit ${result.code}): ${detail}`, + ); + }); + render(); + }); +} + +export async function runPluginCommand( + args: string[], + version: string, + binary = process.execPath, +): Promise { + if (args[0] !== "plugin") return null; + if (args.length === 2) { + if (args[1] === "version") { + console.log(`roamgate ${version}`); + return 0; + } + if (args[1] === "url") return printUrl(); + if (args[1] === "panel") return panel(binary, version); + const serviceArgs = + args[1] === "start" + ? ["install", "--force"] + : args[1] === "restart" + ? ["install"] + : args[1] === "status" + ? ["status"] + : args[1] === "uninstall" + ? ["uninstall"] + : null; + if (serviceArgs) { + const result = spawnSync(binary, ["service", ...serviceArgs], { + stdio: "inherit", + }); + if (result.error) + console.error(`roamgate plugin: ${result.error.message}`); + return result.status ?? 1; + } + } + console.error( + "Usage: roamgate plugin ", + ); + return args.length === 1 || (args.length === 2 && args[1] === "--help") + ? 0 + : 1; +} diff --git a/server/src/config/server-config.ts b/server/src/config/server-config.ts index 23ae328c..e9fa6c93 100644 --- a/server/src/config/server-config.ts +++ b/server/src/config/server-config.ts @@ -154,6 +154,7 @@ export function loadServerConfig(appVersion: string): ServerConfig { Usage: roamgate [options] roamgate service + roamgate plugin Service actions: install [--force] install and start the platform user service diff --git a/server/src/index.ts b/server/src/index.ts index 15cfff8e..032df829 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -27,6 +27,7 @@ import { SERVICE_COMMAND_CONTINUE, } from "./config/service-manager"; import { runHerdrCommand } from "./herdr/cli"; +import { runPluginCommand } from "./config/plugin-command"; import { enrichIntegrationVersions } from "./herdr/integration-versions"; import { createHerdrSetupHandlers, @@ -109,6 +110,11 @@ import { } from "./worktree/remove"; const APP_VERSION = packageJson.version; +const pluginCommandResult = await runPluginCommand( + process.argv.slice(2), + APP_VERSION, +); +if (pluginCommandResult !== null) process.exit(pluginCommandResult); const serviceCommandResult = runServiceCommand(process.argv.slice(2)); if (serviceCommandResult === SERVICE_COMMAND_CONTINUE) { process.argv.splice(2); From 9c9972dca25a140a794f79c5f791b24fa46f9f29 Mon Sep 17 00:00:00 2001 From: Wangshuyi Date: Wed, 14 Oct 2026 17:44:53 +0800 Subject: [PATCH 2/3] Preserve legacy Windows plugin release mirrors --- scripts/install-plugin.ps1 | 8 ++++- scripts/install-plugin.windows.test.ts | 46 +++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/scripts/install-plugin.ps1 b/scripts/install-plugin.ps1 index c329e4b8..cb952d42 100644 --- a/scripts/install-plugin.ps1 +++ b/scripts/install-plugin.ps1 @@ -16,7 +16,13 @@ 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" -if ($env:ROAMGATE_RELEASE_BASE_URL) { $releaseBase = $env:ROAMGATE_RELEASE_BASE_URL.TrimEnd('/') } +$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))) { diff --git a/scripts/install-plugin.windows.test.ts b/scripts/install-plugin.windows.test.ts index 7ea7e10a..39de5e1a 100644 --- a/scripts/install-plugin.windows.test.ts +++ b/scripts/install-plugin.windows.test.ts @@ -94,9 +94,14 @@ test.skipIf(process.platform !== "win32")( : new Response("missing", { status: 404 }); }, }); + const installerEnv = { + ...process.env, + PATH: `${join(process.env.SystemRoot!, "System32")};${join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0")}`, + }; for (failure of [ "fresh", "none", + "legacy-mirror", "checksum", "checksum-name", "metadata", @@ -129,9 +134,15 @@ test.skipIf(process.platform !== "win32")( ], { env: { - ...process.env, - PATH: `${join(process.env.SystemRoot!, "System32")};${join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0")}`, - ROAMGATE_RELEASE_BASE_URL: `http://127.0.0.1:${host.port}`, + ...installerEnv, + ROAMGATE_RELEASE_BASE_URL: + failure === "legacy-mirror" + ? undefined + : `http://127.0.0.1:${host.port}`, + HERDR_GUI_RELEASE_BASE_URL: + failure === "legacy-mirror" + ? `http://127.0.0.1:${host.port}` + : "http://legacy.invalid/releases", PLUGIN_REJECT_NATIVE: failure === "native-command" ? "1" : "", }, stdout: "pipe", @@ -142,7 +153,7 @@ test.skipIf(process.platform !== "win32")( child.exited, new Response(child.stderr).text(), ]); - if (failure === "fresh" || failure === "none") { + if (["fresh", "none", "legacy-mirror"].includes(failure)) { expect(code, stderr).toBe(0); expect( Bun.spawnSync([target, "plugin", "version"]) @@ -155,6 +166,33 @@ test.skipIf(process.platform !== "win32")( } expect(readdirSync(join(root, "server"))).toEqual(["roamgate.exe"]); } + const script = join(root, "scripts/install-plugin.ps1").replaceAll( + "'", + "''", + ); + const emptyOverride = Bun.spawnSync( + [ + powershell, + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + `function Invoke-WebRequest { param($Uri) throw "Selected release: $Uri" }; & '${script}'`, + ], + { + env: { + ...installerEnv, + ROAMGATE_RELEASE_BASE_URL: "", + HERDR_GUI_RELEASE_BASE_URL: "http://legacy.invalid/releases", + }, + }, + ); + expect(emptyOverride.exitCode).not.toBe(0); + expect(emptyOverride.stderr.toString()).toContain( + "Selected release: https://github.com/powerfooI/roamgate/releases/download/v9.8.7/", + ); + expect(readdirSync(join(root, "server"))).toEqual(["roamgate.exe"]); } finally { host?.stop(true); rmSync(root, { recursive: true, force: true }); From a76de329b7a4c4658fd908209f52cfc9e6bd1467 Mon Sep 17 00:00:00 2001 From: Wangshuyi Date: Wed, 14 Oct 2026 18:39:57 +0800 Subject: [PATCH 3/3] Keep the existing Herdr plugin minimum version --- docs/DEPLOYMENT.md | 4 ++-- herdr-plugin.toml | 2 +- scripts/install-plugin.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 07086acc..5dbb29dc 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -26,7 +26,7 @@ and **tagged Herdr 0.9.0 and 0.9.4 / protocol 22**. Protocol 21 and unknown vers are rejected at control/binary probes. Use a compatible Roamgate build or separate server; **do not downgrade a live server**. Published binaries follow their [release notes](https://github.com/powerfooI/roamgate/releases). -The [plugin](#herdr-plugin) separately requires Herdr 0.9.4+. +The [plugin](#herdr-plugin) separately requires Herdr 0.7.2+. Tab reordering requires Herdr 0.7.2+; Herdr 0.7.0 and 0.7.1 retain their fixed tab order. The bridge confirms support before enabling dragging. @@ -210,7 +210,7 @@ curl -fsSL \ ## Herdr plugin -Requires Herdr 0.9.4+. The native plugin uses the standalone Roamgate executable +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. diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 26d5933c..5ce7d196 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,7 +1,7 @@ id = "roamgate" name = "Roamgate" version = "0.8.3" -min_herdr_version = "0.9.4" +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"] diff --git a/scripts/install-plugin.test.ts b/scripts/install-plugin.test.ts index 76b0d529..3f301942 100644 --- a/scripts/install-plugin.test.ts +++ b/scripts/install-plugin.test.ts @@ -23,7 +23,7 @@ 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.9.4"); + expect(manifest.min_herdr_version).toBe("0.7.2"); expect(manifest.build.map((entry: any) => entry.platforms)).toEqual([ ["linux", "macos"], ["windows"],