diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd499fa..bca9b32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,11 @@ jobs: - run: cargo fmt --all --check - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test --workspace + - run: python3 herdr/test-manifest.py + - run: cargo build --release -p plannotator-tui + - run: bash herdr/stage-plannotator-tui.sh + - run: cmp target/release/plannotator-tui herdr/bin/plannotator-tui.exe + - run: ./herdr/bin/plannotator-tui.exe --version check-windows: runs-on: windows-latest @@ -41,3 +46,10 @@ jobs: - run: cargo test --workspace --all-targets - run: cargo build --release -p plannotator-tui - run: '& .\target\release\plannotator-tui.exe --version' + - run: python herdr/test-manifest.py + - run: powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File herdr/stage-plannotator-tui.ps1 + - run: | + $source = (Get-FileHash .\target\release\plannotator-tui.exe).Hash + $staged = (Get-FileHash .\herdr\bin\plannotator-tui.exe).Hash + if ($source -ne $staged) { throw "staged binary differs from Cargo output" } + - run: '& .\herdr\bin\plannotator-tui.exe --version' diff --git a/.gitignore b/.gitignore index 3554085..2711e2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target +/herdr/bin/ *.annotations.json.tmp diff --git a/herdr/herdr-plugin.toml b/herdr/herdr-plugin.toml index 6f7f6f2..2fe98d0 100644 --- a/herdr/herdr-plugin.toml +++ b/herdr/herdr-plugin.toml @@ -3,21 +3,32 @@ name = "plannotator-tui" version = "0.2.0" min_herdr_version = "0.8.0" description = "Annotate Markdown in a pane: select text, comment, looks-good, delete; send feedback to the agent." -platforms = ["macos", "linux"] +platforms = ["macos", "linux", "windows"] # Where plannotator-tui opens is the user's choice, not the manifest's: `[herdr] placement` in -# ~/.config/plannotator-tui/config.toml (overlay | split | popup), or PLANNOTATOR_TUI_PLACEMENT. The -# launcher (`plannotator-tui herdr open`) passes --placement, which overrides the default below. +# its config file (overlay | split | popup), or PLANNOTATOR_TUI_PLACEMENT. The launcher +# (`plannotator-tui herdr open`) passes --placement, which overrides the default below. # overlay = a real pane zoomed over the whole tab; Herdr restores focus and zoom on exit. [[build]] command = ["cargo", "build", "--release", "--manifest-path", "../Cargo.toml"] +[[build]] +platforms = ["macos", "linux"] +command = ["bash", "stage-plannotator-tui.sh"] + +[[build]] +platforms = ["windows"] +command = [ + "powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", "stage-plannotator-tui.ps1", +] + [[panes]] id = "doc" title = "plannotator-tui" placement = "overlay" -command = ["sh", "-c", "exec \"$HERDR_PLUGIN_ROOT/../target/release/plannotator-tui\" herdr pane"] +command = ["./bin/plannotator-tui.exe", "herdr", "pane"] # Human entry points. Both run the launcher, which reads Herdr's invocation context: the # focused pane's folder (open) or the clicked file:// link (open-link), and the focused @@ -26,20 +37,20 @@ command = ["sh", "-c", "exec \"$HERDR_PLUGIN_ROOT/../target/release/plannotator- id = "open" title = "Annotate: open here" contexts = ["workspace", "pane"] -command = ["sh", "-c", "exec \"$HERDR_PLUGIN_ROOT/../target/release/plannotator-tui\" herdr open"] +command = ["./bin/plannotator-tui.exe", "herdr", "open"] [[actions]] id = "open-link" title = "Annotate this file" contexts = ["pane"] -command = ["sh", "-c", "exec \"$HERDR_PLUGIN_ROOT/../target/release/plannotator-tui\" herdr open"] +command = ["./bin/plannotator-tui.exe", "herdr", "open"] # Ctrl-click on a file:// markdown link. Anchored on the scheme so web links never match. [[actions]] id = "last" title = "Annotate: agent's last message" contexts = ["pane"] -command = ["sh", "-c", "exec \"$HERDR_PLUGIN_ROOT/../target/release/plannotator-tui\" herdr last"] +command = ["./bin/plannotator-tui.exe", "herdr", "last"] [[link_handlers]] id = "markdown-file" diff --git a/herdr/stage-plannotator-tui.ps1 b/herdr/stage-plannotator-tui.ps1 new file mode 100644 index 0000000..7439bdd --- /dev/null +++ b/herdr/stage-plannotator-tui.ps1 @@ -0,0 +1,10 @@ +$ErrorActionPreference = "Stop" + +$pluginRoot = $PSScriptRoot +$repositoryRoot = Split-Path -Parent $pluginRoot +$source = Join-Path $repositoryRoot "target/release/plannotator-tui.exe" +$destinationDirectory = Join-Path $pluginRoot "bin" +$destination = Join-Path $destinationDirectory "plannotator-tui.exe" + +New-Item -ItemType Directory -Force $destinationDirectory | Out-Null +Copy-Item -LiteralPath $source -Destination $destination -Force diff --git a/herdr/stage-plannotator-tui.sh b/herdr/stage-plannotator-tui.sh new file mode 100755 index 0000000..73b4b55 --- /dev/null +++ b/herdr/stage-plannotator-tui.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +plugin_root="$(cd "$(dirname "$0")" && pwd)" +repository_root="$(cd "$plugin_root/.." && pwd)" + +mkdir -p "$plugin_root/bin" +# Remove first: cp over an existing signed binary reuses the inode and invalidates the +# macOS code signature, and the next exec is killed. +rm -f "$plugin_root/bin/plannotator-tui.exe" +cp "$repository_root/target/release/plannotator-tui" \ + "$plugin_root/bin/plannotator-tui.exe" diff --git a/herdr/test-manifest.py b/herdr/test-manifest.py new file mode 100755 index 0000000..8aeb4ef --- /dev/null +++ b/herdr/test-manifest.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Check the Windows Full entrypoint contract across one or more manifests.""" + +from __future__ import annotations + +import sys +import tomllib +from pathlib import Path + + +PROGRAM = "./bin/plannotator-tui.exe" +COMMANDS = { + ("panes", "doc"): [PROGRAM, "herdr", "pane"], + ("actions", "open"): [PROGRAM, "herdr", "open"], + ("actions", "open-link"): [PROGRAM, "herdr", "open"], + ("actions", "last"): [PROGRAM, "herdr", "last"], +} +LINK_HANDLERS = {"markdown-file": "open-link"} +DEVELOPMENT_BUILDS = [ + ["cargo", "build", "--release", "--manifest-path", "../Cargo.toml"], + ["bash", "stage-plannotator-tui.sh"], + [ + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + "stage-plannotator-tui.ps1", + ], +] + + +def fail(path: Path, message: str) -> None: + raise AssertionError(f"{path}: {message}") + + +def supports_windows(manifest: dict[str, object], entry: dict[str, object]) -> bool: + platforms = entry.get("platforms", manifest.get("platforms", [])) + return isinstance(platforms, list) and "windows" in platforms + + +def entry( + path: Path, + manifest: dict[str, object], + table: str, + entry_id: str, +) -> dict[str, object]: + entries = manifest.get(table, []) + if not isinstance(entries, list): + fail(path, f"[[{table}]] is not an array") + matches = [ + item + for item in entries + if isinstance(item, dict) + and item.get("id") == entry_id + and supports_windows(manifest, item) + ] + if len(matches) != 1: + fail(path, f"expected one Windows {table}.{entry_id}, found {len(matches)}") + return matches[0] + + +def shape(path: Path) -> dict[str, tuple[str, ...]]: + with path.open("rb") as handle: + manifest = tomllib.load(handle) + + if set(manifest.get("platforms", [])) != {"macos", "linux", "windows"}: + fail(path, "top-level platforms must be macos, linux, and windows") + + result: dict[str, tuple[str, ...]] = {} + for (table, entry_id), expected in COMMANDS.items(): + command = entry(path, manifest, table, entry_id).get("command") + if command != expected: + fail(path, f"{table}.{entry_id} command is {command!r}, expected {expected!r}") + result[entry_id] = tuple(expected[1:]) + + for entry_id, expected_action in LINK_HANDLERS.items(): + action = entry(path, manifest, "link_handlers", entry_id).get("action") + if action != expected_action: + fail(path, f"link_handlers.{entry_id} action is {action!r}, expected {expected_action!r}") + result[entry_id] = (expected_action,) + + return result + + +def check_development_builds(path: Path) -> None: + with path.open("rb") as handle: + manifest = tomllib.load(handle) + builds = manifest.get("build", []) + if not isinstance(builds, list): + fail(path, "[[build]] is not an array") + commands = [item.get("command") for item in builds if isinstance(item, dict)] + if commands != DEVELOPMENT_BUILDS: + fail(path, f"development build commands are {commands!r}") + if not supports_windows(manifest, builds[0]): + fail(path, "Cargo build does not run on Windows") + if set(builds[1].get("platforms", [])) != {"macos", "linux"}: + fail(path, "Unix staging command must run on macOS and Linux only") + if set(builds[2].get("platforms", [])) != {"windows"}: + fail(path, "PowerShell staging command must run on Windows only") + + +def main() -> None: + paths = [Path(value) for value in sys.argv[1:]] or [ + Path(__file__).with_name("herdr-plugin.toml") + ] + reference = shape(paths[0]) + check_development_builds(paths[0]) + for path in paths[1:]: + candidate = shape(path) + if candidate != reference: + fail(path, f"Full entrypoint tails differ: {candidate!r} != {reference!r}") + + +if __name__ == "__main__": + main()