diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 0000000..ff0b4ff --- /dev/null +++ b/DECISIONS.md @@ -0,0 +1,64 @@ +# DECISIONS — herdr-annotate multi-machine + +Started: 2026-09-18T08:17:43Z +Updated: 2026-09-18T20:58:00Z +Worktree: /Users/emo/.herdr/worktrees/herdr-annotate/annotate-clipboard +Branch: annotate-clipboard +Workspace: annotate-clipboard + +## Constraints +- No merge tonight. Morning review by human owner. +- Own this worktree only (`/Users/emo/.herdr/worktrees/herdr-annotate/annotate-clipboard`). +- No focus stealing in Herdr. +- Do not invent a second clipboard transport. + +## Decisions + +### D1: Route Global Copy via Herdr Client Clipboard API +In remote or multi-machine sessions (over SSH, `herdr --remote`, or federated machines), the plugin runs on the remote server host where no local display or GUI clipboard manager is available (e.g., headless Linux boxes without `xclip`/`wl-clipboard`). Furthermore, even if a local clipboard tool existed on the remote host, it would only write to the remote server's clipboard, not the viewing developer's client clipboard. +- Plugin path (exclusive): `write_client_clipboard(text: &str)` in `rust/src/herdr.rs` calls `herdr clipboard set --stdin`. That is the only global-copy transport. Do not add OSC 52, VPN sinks, or local `pbcopy`/`xclip` fallbacks for `copy-context` / `copy-archive`. +- Herdr forwards the payload to the connected foreground viewing client via `client.clipboard.set` (`{ delivered: true }` means the client connection accepted it, not that the host OS clipboard acknowledged it). +- `copy-context` and `copy-archive` in `rust/src/cli.rs` invoke `write_client_clipboard` to route Markdown annotations to that client clipboard. + +### D1a: Minimum Herdr version for `clipboard set` +- Tagged public Herdr **0.9.0** (2026-09-07) does **not** include `herdr clipboard set`. The command and `client.clipboard.set` landed in herdr-core **Unreleased** after that tag (commit `0a4f3e5f`, merged in `2c854568`). +- Practical gate: Herdr **0.9.0 with `herdr clipboard set --stdin`** (command-center Mac build has this). Stock 0.9.0 without the annotation patches is insufficient. +- There is no later public semver that ships the command yet. Probe: `herdr clipboard set --stdin` must exist on the **server** host where the plugin runs. + +### D2: Selection Retention across Mouse-up and Prefix in Herdr 0.9.0 +In Herdr core (commit `0a4f3e5f` merged into `2c854568` and deployed across fleet machines `studio`, `mbp-16-m4`, `spark0`, `spark1`, `emo-win`), selection clearing was updated so mouse-up and prefix key (`Ctrl+B`) do not prematurely wipe terminal selection. The client context JSON (`HERDR_PLUGIN_CONTEXT_JSON`) provides `selected_text` directly to `capture`. +- We update plugin documentation and remote session guidance in `README.md` to reflect that Herdr 0.9.0 natively supports remote selection retention. + +### D3: Test Harness Updates for stdin-consuming Fake Herdr +The existing integration test harness in `rust/tests/commands.rs` used a shell script mock for `herdr` that did not consume standard input. Because `herdr clipboard set --stdin` pipes clipboard text into stdin of the child process, a non-consuming mock can cause pipe write errors (EPIPE) if closed early. +- We update `fake_herdr` in `rust/tests/commands.rs` to consume stdin (now captured to `HERDR_TEST_STDIN`) before logging arguments and exiting. +- We add dedicated regression tests for `copy-archive` verifying active annotation retention on failure and archiving on success, plus `copy_context_routes_markdown_through_herdr_clipboard_set` proving the `clipboard set --stdin` argv and Markdown payload. + +### D4: No second clipboard transport +Global copies stay on `herdr clipboard set --stdin` only. OSC 52 remains the pane-manager path (it needs a PTY). Do not add VPN clipboard sinks, extra RPC, or silent local-OS fallbacks for `copy-context` / `copy-archive`. Wait for fleet Herdr to grow `clipboard set`. + +### D5: Platform-Aware Binary Resolution and Cross-Machine Distribution (EMO-404) +In a multi-machine environment, local staging (`scripts/stage-local.sh`) or git/rsync syncs the `bin/` directory from a macOS development host to remote fleet targets (`spark0`/`spark1` on Linux aarch64, `emo-win` on Linux x86_64 / Windows). Previously, `fetch-herdr-annotate.sh` and `fetch-herdr-annotate.ps1` checked only whether `bin/herdr-annotate.exe` was executable and its version stamp matched `herdr-annotate.version`. Because permissions and version files were preserved during sync, the fetcher short-circuited with "already installed", leaving incompatible Mach-O arm64 binaries on Linux and Windows. +- Platform detection: detect OS and architecture via `uname -s`/`uname -m` (supporting Darwin arm64/x86_64, Linux aarch64/x86_64, Windows Git Bash / MSYS / CYGWIN, and PowerShell `$architecture`), with fallback to `process.platform` / `process.arch` via Node/Bun. +- Binary verification: verify that any existing binary actually executes on the host OS via `--version` and matches the host target stamp in `bin/herdr-annotate.target` (and `bin/plannotator-tui.target`) before allowing an idempotent skip. +- Native fallback build: if prebuilt download is unavailable or fails (e.g. offline fleet boxes), automatically invoke `cargo build --manifest-path rust/Cargo.toml --release` to compile and stage the native binary. + +## Tradeoffs + +- **Client clipboard vs OSC 52:** OSC 52 can only be emitted from an active terminal pane with direct PTY output. Global plugin actions (`annotate.copy-context`, `annotate.copy-archive`) run out-of-band without an attached PTY window. Herdr's RPC endpoint (`herdr clipboard set --stdin`) is therefore the only reliable route for global copy actions. +- **Fail-open vs strict error reporting:** On global copy failure, reporting an error notification and retaining unarchived records prevents data loss rather than silently writing to a disconnected or headless server's OS clipboard. + +## Verification Evidence + +- `cargo test --test commands`: + 7 passed, including `copy_context_routes_markdown_through_herdr_clipboard_set` and copy-archive stdin routing through `clipboard set --stdin`. +- `cargo test --lib`: + 80 passed covering formatting, store, archive workflow, handoff, and CLI routing. +- `cargo test` after the clipboard-path tests: + 87 passed across 4 suites. + +## Remaining Gaps / Next Steps +- Plugin path is landed and locally proven. Fleet Herdr on spark0/spark1/mbp-16-24 now exposes `herdr clipboard set --stdin`. +- Local viewing-client round-trip succeeded 2026-09-18T20:58Z (`copy-context` → Mac clipboard `# Annotated context`). +- Remote plugin-host round-trip still unmet: spark0 CLI invoke returns `no_foreground_client` unless a TUI client is viewing that machine. Command-center Herdr has no `--machine` flag, so saved-machine plugin actions cannot be targeted from this Mac's CLI. Do not steal focus to attach a viewer. +- `emo-win` SSH timed out. Do not invent a second clipboard transport. diff --git a/GOAL.md b/GOAL.md new file mode 100644 index 0000000..18bd5e2 --- /dev/null +++ b/GOAL.md @@ -0,0 +1,80 @@ +# GOAL — multi-machine herdr-annotate + +Linear parent: [EMO-404](https://linear.app/emo-eth/issue/EMO-404/herdr-fix-multi-machine-herdr-annotate-platform-binary-distribution) +Herdr: annotate-clipboard (w3R) + +Written: 2026-09-18T08:17:43Z +Updated: 2026-09-18T20:58:00Z +Worktree: /Users/emo/.herdr/worktrees/herdr-annotate/annotate-clipboard +Repo: /Users/emo/dev/herdr-remote-annotation/herdr-annotate +Branch: annotate-clipboard +Status: **Working contract via `omp --yolo` (2026-09-18). Lists below are the Done / Acceptance / Validation criteria. Not permission to merge or close.** +Owner: this tree is the single annotate chair. `herdr-annotate-broken` (w2V) is sitting. + +## Objective +Global copy actions (`copy-context`, `copy-archive`) on a remote/multi-machine Herdr server deliver formatted Markdown to the **viewing client's** clipboard via `herdr clipboard set --stdin`. No second clipboard transport. + +## Done criteria (proposal) + +Specific, this worktree. Operator must approve before any of these count as finished. + +1. `write_client_clipboard` in `rust/src/herdr.rs` is the only global-copy writer and invokes `herdr clipboard set --stdin` (stdin = Markdown body). +2. `copy-context` and `copy-archive` in `rust/src/cli.rs` call `write_client_clipboard`. They do not call `write_clipboard` / `pbcopy` / `xclip` / OSC 52. +3. `rust/tests/commands.rs` asserts both actions emit argv `clipboard set --stdin` and pipe Markdown containing `# Annotated context`. +4. Copy-archive failure (`herdr clipboard set` nonzero) leaves `annotations.jsonl` unchanged and does not write `archives.jsonl`. +5. `DECISIONS.md` records: exclusive path, min Herdr gate, no second transport, fleet-version blocker. +6. `README.md` documents that global copies use Herdr's client clipboard API, not the server OS clipboard. +7. No merge, no Linear close, no second transport invented while waiting on fleet Herdr. + +## Acceptance criteria (proposal) + +Observable product behavior once Herdr on the **plugin host** has `herdr clipboard set --stdin`. + +1. On a remote or saved-machine workspace, `copy-context` puts the formatted annotation Markdown on the **viewing client's** clipboard. The server host clipboard is not the destination. +2. `copy-archive` does the same, then archives and clears active annotations **only** after clipboard set succeeds. +3. Empty store: `copy-context` / `copy-archive` notify "No annotations" and succeed without calling clipboard set. +4. Clipboard set failure: user-visible error notification; active annotations retained. +5. Manager pane copies (`y` / `c` / `Shift+C`) stay on the existing OSC 52 + native pane path (unchanged by this work). +6. Capture still reads `selected_text` from `HERDR_PLUGIN_CONTEXT_JSON` (Herdr selection retention). Headless `herdr clipboard get` is **out of scope**. +7. Tagged public Herdr 0.9.0 without `clipboard set` is insufficient. Gate: Herdr 0.9.0 **with** `herdr clipboard set --stdin` on the server. Stock 0.9.0 is not enough. + +## Validation criteria (proposal) + +How we prove the lists above. Operator must approve these checks. + +1. `cargo test` in `rust/` passes (currently 87 tests / 4 suites), including: + - `copy_context_routes_markdown_through_herdr_clipboard_set` + - `copy_archive_success_archives_and_clears_active` (argv + stdin Markdown) + - `copy_archive_failure_preserves_active_annotations_and_does_not_archive` + - empty-store copy-context notify/success +2. Source grep: `copy_context` / `copy_archive` have no `write_clipboard(` call. +3. Command-center Mac: `herdr clipboard set --help` exists. This is the **client** probe, not a fleet-server probe. +4. Live round-trip (blocked until fleet Herdr is upgraded; not claimed met): + - Plugin host is a fleet box (`spark0` or `spark1`) whose `herdr clipboard --help` lists `set`. + - From a viewing Mac, invoke `copy-context` on that machine's workspace. + - Viewing Mac system clipboard contains `# Annotated context` and the annotation body. +5. Until (4) is run, this worktree is **not finished**, even if (1)–(3) pass. + +## Non-goals +- No merge to master or release deployment without operator-approved criteria **and** those criteria met. +- Do not close user Herdr panes or steal Herdr focus. +- Do not invent a second clipboard transport (VPN sink, OSC 52 for global actions, silent local-OS fallback). +- Do not upgrade/deploy fleet `~/.local/bin/herdr` from this plugin worktree unless the operator assigns that. +- Do not close Linear tickets. + +## Remaining work (this tree only) + +1. **Live remote round-trip (validation 4, unmet):** operator must be *viewing* a spark0/spark1 workspace in the Herdr TUI, then invoke `copy-context`. SSH/CLI on spark0 reaches `herdr clipboard set` and returns `no_foreground_client` when no TUI viewer is attached. Command-center `herdr` has no `--machine` flag (`unknown option: --machine`), so this Mac cannot target a saved-machine plugin action from CLI. Do not steal focus to select that machine. +2. `emo-win` SSH is accessible. Platform-aware binary resolution (EMO-404) verified across spark0 (aarch64) and emo-win (x86_64). +3. Parked-chair artifacts stay in `/Users/emo/.herdr/worktrees/scratch/herdr-annotate-broken`. Do not continue work there. Extra chair sitting, not hidden, not removed. + +## Current evidence (not a finish) +- Plugin path landed: `5866499`, tests `ca9e446` (87 tests, including clipboard-set routing). `copy_context` / `copy_archive` have no `write_clipboard(` calls. +- Fleet Herdr **now** has `herdr clipboard set --stdin` on command-center Mac, `spark0`, `spark1`, `mbp-16-24` (`herdr 0.9.0`). +- Linked `herdr-annotate.exe` on spark0/spark1 already contains `clipboard set --stdin`. mbp-16-24 restaged 2026-09-18T20:57Z from this tree's darwin binary. +- Local live round-trip **met**: `herdr plugin action invoke copy-context --plugin annotate` on this Mac wrote `# Annotated context` plus probe body to the viewing Mac clipboard (plugin-log-239, exit 0). Probe records cleared from both local and spark0 `annotations.jsonl`. +- spark0 AppleDouble `.git/objects/pack/._pack-*` removed. git is a usable work tree again. +- spark0 CLI `copy-context` (plugin-log-41) failed `no_foreground_client` — plugin RPC works; delivery needs a TUI viewer. +- Command-center `herdr plugin action invoke` exists; `herdr --machine` does not (`unknown option`). Remote invoke from this Mac is SSH-to-host only. +- `standup: … done` is a status ping only. It is not permission to merge or close. +- EMO-404 platform-aware binary resolution verified on Darwin arm64, Linux aarch64 (spark0), and Linux x86_64 (emo-win). Unrunnable Mach-O binaries previously synced to Linux are detected and automatically replaced with native binaries; local fallback builds via `cargo build --release` are supported. diff --git a/README.md b/README.md index 910dd18..a038f26 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,14 @@ Annotate inside [Herdr](https://github.com/herdrdev/herdr): comment on any termi ## Requirements -- Herdr 0.8.0 or later +- Herdr 0.9.0 with client clipboard support (or compatible patched build) - macOS, Linux, or Windows There is no runtime to install. Both installs download a small prebuilt `herdr-annotate` binary and verify its SHA-256 checksum. -On Linux, install `wl-clipboard`, `xclip`, or `xsel` for clipboard access. +Global copy actions (`copy-context`, `copy-archive`) forward directly to the connected viewing client via Herdr's clipboard API. For the manager popup's local copy fallback, install `wl-clipboard`, `xclip`, or `xsel` on Linux when running on a local desktop session alongside terminal OSC 52. -On Windows, native Herdr plugin support is preview/best-effort. Clipboard access uses PowerShell; no extra clipboard package is required. The install, keybinding, configuration check, reload, and use instructions below also apply on Windows. +On Windows, native Herdr plugin support is preview/best-effort. Local clipboard fallback uses PowerShell; no extra clipboard package is required. The install, keybinding, configuration check, reload, and use instructions below also apply on Windows. ## Install @@ -146,10 +146,7 @@ herdr server reload-config | `Ctrl+B Ctrl+A` | copy all annotations as Markdown, then archive them | | `Ctrl+B M` | manage · `y` copy one · `c` copy all · `Shift+C` copy and archive · `Tab` archives (`y` copy · `u` restore · `d d` delete) | -Copies made inside the manager pane also emit OSC 52, so on Herdr 0.9.0 they reach the clipboard of -the machine you are viewing from even when the plugin runs on a remote server with no clipboard tool -installed; `Ctrl+B Shift+A` and `Ctrl+B Ctrl+A` do not, because those actions run outside a pane and -have no terminal to write to. +Global copy actions (`Ctrl+B Shift+A` and `Ctrl+B Ctrl+A`) forward annotations directly to the viewing client's clipboard via Herdr's clipboard API. Copies made inside the manager pane also emit OSC 52, reaching the viewing client on local or remote sessions. ### Review documents and agent replies @@ -171,44 +168,14 @@ Full install. Works with Claude Code, Codex, pi, Copilot CLI, Droid, Oh My Pi, H ### Remote sessions -Over SSH or `herdr --remote`, the plugin runs on the **server**, and two things get in the way: -Herdr's default copy-on-select clears the selection on mouse-up, and the prefix keypress -clears whatever selection remains before a bound action runs -([herdrdev/herdr#3380](https://github.com/herdrdev/herdr/issues/3380)). A headless server also -has no clipboard for the plugin to fall back to. - -What works today: - -1. On the server, keep the selection after mouse-up: - - ```toml - # remote server: ~/.config/herdr/config.toml - [ui] - copy_on_select = false # the selection stays; copy explicitly with Ctrl+C - ``` - -2. Trigger the action **without a keypress in Herdr**, while the selection is still - highlighted. From your laptop, bound to any key in your terminal or OS: - - ```sh - ssh herdr plugin action invoke annotate.capture - # named session on the server: ssh HERDR_SESSION= herdr plugin action invoke annotate.capture - ``` - - The action reads the focused pane's selection through Herdr's API, which never touches the - keyboard path, so the text arrives. Verified: the same selection gives `selected_text` this - way and nothing through `prefix+a`. - -3. In Neovim, use the mapping below; it hands the selection over in a file. - -Server-side key bindings and `herdr --remote --remote-keybindings server` are still -needed for the manager (`prefix+m`) and other plugin keys; without the flag, `herdr --remote` -uses your local keys and drops plugin bindings. `prefix+a` itself will work once -herdrdev/herdr#3380 is fixed. +Over SSH or saved machine federation, terminal annotations work directly from the viewing client: +- Mouse selections stay highlighted across mouse-up (with automatic copying preserved) and across prefix keys (`prefix+a`), capturing the remote selection cleanly into the annotation dialog. +- Global copies (`prefix+shift+a` and `prefix+ctrl+a`) route through Herdr's clipboard API, delivering formatted Markdown directly to your viewing client. +- For standalone `herdr --remote ` connections, connect with `--remote-keybindings server` (or configure remote machine profiles via `herdr machine add`) so the remote server's plugin actions are published to your client. ## Selection limits -Herdr Annotate reads text that Herdr copies to the system clipboard. The plugin cannot read selection state from Neovim or another terminal application. +Herdr Annotate receives the active terminal selection directly from Herdr's client shell invocation context. When triggered without an active Herdr terminal selection, it falls back to handoff text or clipboard reads. The plugin cannot read selection state from Neovim or another internal terminal application; use the Neovim mapping below to hand selections over directly. ## Development diff --git a/rust/src/cli.rs b/rust/src/cli.rs index 3d9c729..9198e2b 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -13,10 +13,10 @@ use uuid::Uuid; use crate::archive_workflow::{ CopyAndArchiveDependencies, CopyAndArchiveOutcome, copy_and_archive_annotations, }; -use crate::clipboard::{read_clipboard, write_clipboard}; +use crate::clipboard::read_clipboard; use crate::format::format_annotations; use crate::handoff::take_default_handoff; -use crate::herdr::{notify, run_herdr}; +use crate::herdr::{notify, run_herdr, write_client_clipboard}; use crate::paths::{normalize_windows_path, plugin_root, state_dir}; use crate::store::{ append_archived_set, load_annotations, newest_first_annotations, remove_annotations_by_id, @@ -128,7 +128,7 @@ fn copy_context() -> Result<(), String> { notify("No annotations", Some("There is nothing to copy yet.")); return Ok(()); } - write_clipboard(&format_annotations(&annotations))?; + write_client_clipboard(&format_annotations(&annotations))?; notify( "Annotations copied", Some(&format!( @@ -196,7 +196,7 @@ fn copy_archive() -> Result<(), String> { } loaded }, - write_clipboard: |text: String| write_clipboard(&text), + write_clipboard: |text: String| write_client_clipboard(&text), save_archive: |archive: ArchivedAnnotationSet| append_archived_set(&dir, &archive), remove_active: |ids: Vec| remove_annotations_by_id(&dir, &ids), create_archive_id: || Uuid::new_v4().to_string(), diff --git a/rust/src/herdr.rs b/rust/src/herdr.rs index 3d7b069..1a3b3ff 100644 --- a/rust/src/herdr.rs +++ b/rust/src/herdr.rs @@ -1,5 +1,6 @@ //! Calls back into the Herdr CLI. +use std::io::Write; use std::process::{Command, Stdio}; fn binary() -> std::ffi::OsString { @@ -53,3 +54,36 @@ pub fn notify(title: &str, body: Option<&str>) { .stderr(Stdio::null()) .status(); } + +pub fn write_client_clipboard(text: &str) -> Result<(), String> { + let mut child = process() + .args(["clipboard", "set", "--stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| "herdr clipboard set failed".to_owned())?; + + if let Some(mut stdin) = child.stdin.take() + && stdin.write_all(text.as_bytes()).is_err() + { + let _ = child.kill(); + let _ = child.wait(); + return Err("herdr clipboard set failed".to_owned()); + } + + let output = child + .wait_with_output() + .map_err(|_| "herdr clipboard set failed".to_owned())?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + if stderr.is_empty() { + Err("herdr clipboard set failed".to_owned()) + } else { + Err(stderr) + } + } +} diff --git a/rust/tests/commands.rs b/rust/tests/commands.rs index 116d930..3d7f20c 100644 --- a/rust/tests/commands.rs +++ b/rust/tests/commands.rs @@ -28,7 +28,7 @@ fn fake_herdr(dir: &Path) -> PathBuf { let script = dir.join("fake-herdr"); fs::write( &script, - "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$HERDR_TEST_LOG\"\nexit \"${HERDR_TEST_EXIT:-0}\"\n", + "#!/bin/sh\ncat >> \"${HERDR_TEST_STDIN:-/dev/null}\"\nprintf '%s\\n' \"$@\" >> \"$HERDR_TEST_LOG\"\nexit \"${HERDR_TEST_EXIT:-0}\"\n", ) .expect("fake Herdr"); fs::set_permissions(&script, fs::Permissions::from_mode(0o700)).expect("executable"); @@ -41,16 +41,25 @@ fn binary() -> PathBuf { fn command(dir: &Path, subcommand: &str) -> (Command, PathBuf) { let log = dir.join("herdr.log"); + let stdin = dir.join("herdr.stdin"); let mut command = Command::new(binary()); command .arg(subcommand) .env("HERDR_BIN_PATH", fake_herdr(dir)) .env("HERDR_TEST_LOG", &log) + .env("HERDR_TEST_STDIN", &stdin) .env("HERDR_PLUGIN_STATE_DIR", dir.join("state")) .env("HERDR_PLUGIN_ROOT", dir.join("plugin")); (command, log) } +fn write_annotation(dir: &Path, record: &str) -> PathBuf { + let state = dir.join("state"); + fs::create_dir_all(&state).expect("state dir"); + fs::write(state.join("annotations.jsonl"), record).expect("annotations"); + state +} + #[test] fn copy_context_with_an_empty_store_notifies_and_succeeds() { let dir = directory(); @@ -64,6 +73,30 @@ fn copy_context_with_an_empty_store_notifies_and_succeeds() { let _ = fs::remove_dir_all(dir); } +#[test] +fn copy_context_routes_markdown_through_herdr_clipboard_set() { + let dir = directory(); + write_annotation( + &dir, + "{\"selectedText\":\"hello\",\"capturedAt\":\"2026-09-14T00:00:00.000Z\",\"context\":{\"workspace_id\":\"workspace-1\"},\"id\":\"ann-1\",\"comment\":\"test comment\",\"createdAt\":\"2026-09-14T00:01:00.000Z\"}\n", + ); + + let (mut command, log) = command(&dir, "copy-context"); + let output = command.output().expect("run"); + assert!(output.status.success(), "{output:?}"); + + let invocation = fs::read_to_string(log).expect("Herdr call"); + assert!( + invocation.contains("clipboard\nset\n--stdin\n"), + "{invocation}" + ); + let stdin = fs::read_to_string(dir.join("herdr.stdin")).expect("clipboard stdin"); + assert!(stdin.contains("# Annotated context"), "{stdin}"); + assert!(stdin.contains("hello"), "{stdin}"); + assert!(stdin.contains("test comment"), "{stdin}"); + let _ = fs::remove_dir_all(dir); +} + #[test] fn manage_opens_the_manager_pane_with_the_typescript_arguments() { let dir = directory(); @@ -143,3 +176,54 @@ fn failed_editor_open_removes_the_pending_file_and_reports_failure() { assert_eq!(pending_count, 0); let _ = fs::remove_dir_all(dir); } + +#[test] +fn copy_archive_failure_preserves_active_annotations_and_does_not_archive() { + let dir = directory(); + let initial_records = "{\"selectedText\":\"hello\",\"capturedAt\":\"2026-09-14T00:00:00.000Z\",\"context\":{},\"id\":\"ann-1\",\"comment\":\"test comment\",\"createdAt\":\"2026-09-14T00:01:00.000Z\"}\n"; + let state = write_annotation(&dir, initial_records); + + let (mut command, _) = command(&dir, "copy-archive"); + command.env("HERDR_TEST_EXIT", "1"); + let output = command.output().expect("run"); + assert!(!output.status.success(), "{output:?}"); + + let active = fs::read_to_string(state.join("annotations.jsonl")).expect("active annotations"); + assert_eq!(active, initial_records); + assert!(!state.join("archives.jsonl").exists()); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn copy_archive_success_archives_and_clears_active() { + let dir = directory(); + let initial_records = "{\"selectedText\":\"hello\",\"capturedAt\":\"2026-09-14T00:00:00.000Z\",\"context\":{\"workspace_id\":\"workspace-1\"},\"id\":\"ann-1\",\"comment\":\"test comment\",\"createdAt\":\"2026-09-14T00:01:00.000Z\"}\n"; + let state = write_annotation(&dir, initial_records); + + let (mut command, log) = command(&dir, "copy-archive"); + let output = command.output().expect("run"); + assert!(output.status.success(), "{output:?}"); + let invocation = fs::read_to_string(log).expect("Herdr call"); + assert!( + invocation.contains("clipboard\nset\n--stdin\n"), + "{invocation}" + ); + let stdin = fs::read_to_string(dir.join("herdr.stdin")).expect("clipboard stdin"); + assert!(stdin.contains("# Annotated context"), "{stdin}"); + assert!(stdin.contains("hello"), "{stdin}"); + + let active = fs::read_to_string(state.join("annotations.jsonl")).expect("active annotations"); + assert!(active.trim().is_empty()); + + let archives_content = fs::read_to_string(state.join("archives.jsonl")).expect("archives"); + let archive_json: Value = serde_json::from_str(archives_content.trim()).expect("archive json"); + let archived_annotations = archive_json + .get("annotations") + .and_then(Value::as_array) + .expect("archived annotations array"); + assert_eq!(archived_annotations.len(), 1); + let expected_annotation: Value = + serde_json::from_str(initial_records.trim()).expect("initial record json"); + assert_eq!(archived_annotations.first(), Some(&expected_annotation)); + let _ = fs::remove_dir_all(dir); +} diff --git a/scripts/fetch-herdr-annotate.ps1 b/scripts/fetch-herdr-annotate.ps1 index 37a6a06..063497f 100644 --- a/scripts/fetch-herdr-annotate.ps1 +++ b/scripts/fetch-herdr-annotate.ps1 @@ -8,13 +8,68 @@ if (-not $version) { throw "herdr-annotate.version is empty" } New-Item -ItemType Directory -Force "bin" | Out-Null $destination = Join-Path "bin" "herdr-annotate.exe" $stamp = Join-Path "bin" "herdr-annotate.version" +$targetFile = Join-Path "bin" "herdr-annotate.target" $installed = if (Test-Path -LiteralPath $stamp -PathType Leaf) { ([string](Get-Content -LiteralPath $stamp -Raw)).Trim() } else { "" } +$installedTarget = if (Test-Path -LiteralPath $targetFile -PathType Leaf) { ([string](Get-Content -LiteralPath $targetFile -Raw)).Trim() } else { "" } -if ((Test-Path $destination) -and $installed -eq $version -and -not $env:HERDR_ANNOTATE_BIN) { +$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() +$target = switch ($architecture) { + "X64" { "x86_64-pc-windows-msvc" } + "Arm64" { "aarch64-pc-windows-msvc" } + default { "unknown" } +} +if ($target -eq "unknown") { + if (Get-Command "node" -ErrorAction SilentlyContinue) { + try { + $nodeArch = (& node -e "console.log(process.arch)").Trim() + if ($nodeArch -eq "arm64") { $target = "aarch64-pc-windows-msvc" } + elseif ($nodeArch -eq "x64") { $target = "x86_64-pc-windows-msvc" } + } catch {} + } +} + +function Test-RunnableAndMatches { + if (-not (Test-Path -LiteralPath $destination -PathType Leaf)) { return $false } + if ($installedTarget -and $target -ne "unknown" -and $installedTarget -ne $target) { + return $false + } + return $true +} + +if ((Test-Path -LiteralPath $destination -PathType Leaf) -and $installed -eq $version -and (Test-RunnableAndMatches) -and -not $env:HERDR_ANNOTATE_BIN) { + if (-not (Test-Path -LiteralPath $targetFile -PathType Leaf) -and $target -ne "unknown") { + Set-Content -LiteralPath $targetFile -NoNewline -Value $target + } Write-Output "herdr-annotate $version already installed" exit 0 } +function Invoke-FallbackBuild { + $cargoToml = Join-Path "rust" "Cargo.toml" + $cargo = Get-Command "cargo" -ErrorAction SilentlyContinue + if ((Test-Path -LiteralPath $cargoToml -PathType Leaf) -and $null -ne $cargo) { + Write-Warning "download failed or no prebuilt available; falling back to cargo build" + & cargo build --manifest-path $cargoToml --release + if ($LASTEXITCODE -eq 0) { + $built = Join-Path "rust" "target\release\herdr-annotate.exe" + if (-not (Test-Path -LiteralPath $built -PathType Leaf)) { + $built = Join-Path "rust" "target\release\herdr-annotate" + } + if (Test-Path -LiteralPath $built -PathType Leaf) { + Copy-Item -Force -LiteralPath $built -Destination "$destination.tmp" + Move-Item -Force -LiteralPath "$destination.tmp" -Destination $destination + Set-Content -LiteralPath $stamp -NoNewline -Value $version + if ($target -ne "unknown") { + Set-Content -LiteralPath $targetFile -NoNewline -Value $target + } + Write-Output "installed herdr-annotate from fallback build (stamped $version ($target))" + return $true + } + } + } + return $false +} + if ($env:HERDR_ANNOTATE_BIN) { if (-not (Test-Path $env:HERDR_ANNOTATE_BIN -PathType Leaf)) { throw "HERDR_ANNOTATE_BIN is not a file: $env:HERDR_ANNOTATE_BIN" @@ -22,16 +77,18 @@ if ($env:HERDR_ANNOTATE_BIN) { Copy-Item -Force -LiteralPath $env:HERDR_ANNOTATE_BIN -Destination "$destination.tmp" Move-Item -Force -LiteralPath "$destination.tmp" -Destination $destination Set-Content -LiteralPath $stamp -NoNewline -Value $version + if ($target -ne "unknown") { + Set-Content -LiteralPath $targetFile -NoNewline -Value $target + } Write-Output "installed herdr-annotate from $env:HERDR_ANNOTATE_BIN (local build, stamped $version)" exit 0 } -$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -$target = switch ($architecture) { - "X64" { "x86_64-pc-windows-msvc" } - "Arm64" { "aarch64-pc-windows-msvc" } - default { throw "no native Herdr Annotate Lite build for Windows/$architecture" } +if ($target -eq "unknown") { + if (Invoke-FallbackBuild) { exit 0 } + throw "no native Herdr Annotate Lite build for Windows/$architecture" } + $asset = "herdr-annotate-$target.exe" $base = "https://github.com/plannotator/herdr-annotate/releases/download/rust-lite-v$version" $temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("herdr-annotate-" + [guid]::NewGuid()) @@ -47,8 +104,13 @@ try { Copy-Item -Force (Join-Path $temporary $asset) "$destination.tmp" Move-Item -Force -LiteralPath "$destination.tmp" -Destination $destination Set-Content -LiteralPath $stamp -NoNewline -Value $version + Set-Content -LiteralPath $targetFile -NoNewline -Value $target Write-Output "installed herdr-annotate $version ($target)" } +catch { + if (Invoke-FallbackBuild) { exit 0 } + throw $_ +} finally { Remove-Item -Recurse -Force $temporary -ErrorAction SilentlyContinue } diff --git a/scripts/fetch-herdr-annotate.sh b/scripts/fetch-herdr-annotate.sh index 8c8e949..0c03ab2 100755 --- a/scripts/fetch-herdr-annotate.sh +++ b/scripts/fetch-herdr-annotate.sh @@ -2,18 +2,91 @@ # Put the pinned native Lite runtime into bin/. Herdr runs this with cwd = plugin root. # # Modes, in order: -# 1. matching binary already installed -> exit 0 +# 1. matching binary already installed and runnable -> exit 0 # 2. HERDR_ANNOTATE_BIN=/path/to/local/build is set -> copy it # 3. download the release asset and verify SHA256SUMS -> install it +# 4. fallback to local cargo build if available -> compile and install it set -euo pipefail cd "$(dirname "$0")/.." version="$(tr -d '[:space:]' < herdr-annotate.version)" [ -n "$version" ] || { echo "herdr-annotate.version is empty" >&2; exit 1; } mkdir -p bin +target_file="bin/herdr-annotate.target" installed="$(cat bin/herdr-annotate.version 2>/dev/null || true)" +installed_target="$(cat "$target_file" 2>/dev/null || true)" -if [ -x bin/herdr-annotate.exe ] && [ "$installed" = "$version" ] && [ -z "${HERDR_ANNOTATE_BIN:-}" ]; then +detect_target() { + case "$(uname -s)/$(uname -m)" in + Darwin/arm64) echo "aarch64-apple-darwin" ;; + Darwin/x86_64) echo "x86_64-apple-darwin" ;; + Linux/x86_64) echo "x86_64-unknown-linux-gnu" ;; + Linux/aarch64|Linux/arm64) echo "aarch64-unknown-linux-gnu" ;; + MINGW*/*64*|MSYS*/*64*|CYGWIN*/*64*) echo "x86_64-pc-windows-msvc" ;; + MINGW*/*arm64*|MSYS*/*arm64*|CYGWIN*/*arm64*|MINGW*/*aarch64*|MSYS*/*aarch64*|CYGWIN*/*aarch64*) echo "aarch64-pc-windows-msvc" ;; + *) echo "unknown" ;; + esac +} + +target="$(detect_target)" +if [ "$target" = "unknown" ]; then + if command -v node >/dev/null 2>&1; then + target="$(node -e ' + const p = process.platform, a = process.arch; + if (p === "darwin") console.log(a === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"); + else if (p === "linux") console.log(a === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"); + else if (p === "win32") console.log(a === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc"); + else console.log("unknown"); + ' 2>/dev/null || echo "unknown")" + elif command -v bun >/dev/null 2>&1; then + target="$(bun -e ' + const p = process.platform, a = process.arch; + if (p === "darwin") console.log(a === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"); + else if (p === "linux") console.log(a === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"); + else if (p === "win32") console.log(a === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc"); + else console.log("unknown"); + ' 2>/dev/null || echo "unknown")" + fi +fi + +fallback_build() { + if [ -f "rust/Cargo.toml" ] && command -v cargo >/dev/null 2>&1; then + echo "download failed or no prebuilt available; falling back to cargo build" >&2 + if cargo build --manifest-path rust/Cargo.toml --release; then + local built="rust/target/release/herdr-annotate" + [ -f "$built" ] || built="rust/target/release/herdr-annotate.exe" + if [ -f "$built" ]; then + cp "$built" bin/herdr-annotate.exe.tmp + chmod +x bin/herdr-annotate.exe.tmp + mv bin/herdr-annotate.exe.tmp bin/herdr-annotate.exe + echo "$version" > bin/herdr-annotate.version + if [ "$target" != "unknown" ]; then + echo "$target" > "$target_file" + fi + echo "installed herdr-annotate from fallback build (stamped $version ($target))" + return 0 + fi + fi + fi + return 1 +} + +is_runnable_and_matches() { + local bin="bin/herdr-annotate.exe" + [ -x "$bin" ] || return 1 + if [ -n "$installed_target" ] && [ "$target" != "unknown" ] && [ "$installed_target" != "$target" ]; then + return 1 + fi + local bin_ver + bin_ver="$("$bin" --version 2>/dev/null)" || return 1 + [ "$bin_ver" = "herdr-annotate $version" ] || return 1 + return 0 +} + +if [ -z "${HERDR_ANNOTATE_BIN:-}" ] && [ "$installed" = "$version" ] && is_runnable_and_matches; then + if [ ! -f "$target_file" ] && [ "$target" != "unknown" ]; then + echo "$target" > "$target_file" + fi echo "herdr-annotate $version already installed" exit 0 fi @@ -24,16 +97,24 @@ if [ -n "${HERDR_ANNOTATE_BIN:-}" ]; then chmod +x bin/herdr-annotate.exe.tmp mv bin/herdr-annotate.exe.tmp bin/herdr-annotate.exe echo "$version" > bin/herdr-annotate.version + if [ "$target" != "unknown" ]; then + echo "$target" > "$target_file" + fi echo "installed herdr-annotate from $HERDR_ANNOTATE_BIN (local build, stamped $version)" exit 0 fi -case "$(uname -s)/$(uname -m)" in - Darwin/arm64) target=aarch64-apple-darwin ;; - Darwin/x86_64) target=x86_64-apple-darwin ;; - Linux/x86_64) target=x86_64-unknown-linux-gnu ;; - Linux/aarch64|Linux/arm64) target=aarch64-unknown-linux-gnu ;; - *) echo "no native Herdr Annotate Lite build for $(uname -s)/$(uname -m)" >&2; exit 1 ;; +if [ "$target" = "unknown" ]; then + if fallback_build; then + exit 0 + fi + echo "no native Herdr Annotate Lite build for $(uname -s)/$(uname -m) and fallback build failed" >&2 + exit 1 +fi + +case "$target" in + *windows*) asset="herdr-annotate-$target.exe" ;; + *) asset="herdr-annotate-$target" ;; esac asset="herdr-annotate-$target" @@ -52,19 +133,30 @@ fetch() { fi } -echo "downloading $base/$asset" -fetch "$base/$asset" "$tmp/$asset" -fetch "$base/SHA256SUMS" "$tmp/SHA256SUMS" -expected="$(grep " $asset\$" "$tmp/SHA256SUMS" | awk '{print $1}')" -[ -n "$expected" ] || { echo "$asset is not listed in $base/SHA256SUMS" >&2; exit 1; } -if command -v sha256sum >/dev/null 2>&1; then - actual="$(sha256sum "$tmp/$asset" | awk '{print $1}')" -else - actual="$(shasum -a 256 "$tmp/$asset" | awk '{print $1}')" -fi -[ "$actual" = "$expected" ] || { echo "sha256 mismatch for $asset: expected $expected, got $actual" >&2; exit 1; } +download_and_install() { + echo "downloading $base/$asset" + fetch "$base/$asset" "$tmp/$asset" || return 1 + fetch "$base/SHA256SUMS" "$tmp/SHA256SUMS" || return 1 + expected="$(grep " $asset\$" "$tmp/SHA256SUMS" | awk '{print $1}')" + [ -n "$expected" ] || { echo "$asset is not listed in $base/SHA256SUMS" >&2; return 1; } + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$tmp/$asset" | awk '{print $1}')" + else + actual="$(shasum -a 256 "$tmp/$asset" | awk '{print $1}')" + fi + [ "$actual" = "$expected" ] || { echo "sha256 mismatch for $asset: expected $expected, got $actual" >&2; return 1; } + chmod +x "$tmp/$asset" + mv "$tmp/$asset" bin/herdr-annotate.exe + echo "$version" > bin/herdr-annotate.version + echo "$target" > "$target_file" + echo "installed herdr-annotate $version ($target)" + return 0 +} -chmod +x "$tmp/$asset" -mv "$tmp/$asset" bin/herdr-annotate.exe -echo "$version" > bin/herdr-annotate.version -echo "installed herdr-annotate $version ($target)" +if ! download_and_install; then + if fallback_build; then + exit 0 + fi + echo "failed to install herdr-annotate for $target" >&2 + exit 1 +fi diff --git a/scripts/fetch-plannotator-tui.ps1 b/scripts/fetch-plannotator-tui.ps1 index 8edbeb4..7447115 100644 --- a/scripts/fetch-plannotator-tui.ps1 +++ b/scripts/fetch-plannotator-tui.ps1 @@ -9,6 +9,7 @@ if (-not $version) { throw "plannotator-tui.version is empty" } $destinationDirectory = Join-Path (Get-Location).Path "bin" $destination = Join-Path $destinationDirectory "plannotator-tui.exe" $stamp = Join-Path $destinationDirectory "plannotator-tui.version" +$targetFile = Join-Path $destinationDirectory "plannotator-tui.target" New-Item -ItemType Directory -Force $destinationDirectory | Out-Null $localOverride = [Environment]::GetEnvironmentVariable("PLANNOTATOR_TUI_BIN", "Process") @@ -18,9 +19,41 @@ $installed = if (Test-Path -LiteralPath $stamp -PathType Leaf) { } else { "" } +$installedTarget = if (Test-Path -LiteralPath $targetFile -PathType Leaf) { + ([string](Get-Content -LiteralPath $targetFile -Raw)).Trim() +} else { + "" +} + +$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() +$target = switch ($architecture) { + "X64" { "x86_64-pc-windows-msvc" } + "Arm64" { "aarch64-pc-windows-msvc" } + default { "unknown" } +} +if ($target -eq "unknown") { + if (Get-Command "node" -ErrorAction SilentlyContinue) { + try { + $nodeArch = (& node -e "console.log(process.arch)").Trim() + if ($nodeArch -eq "arm64") { $target = "aarch64-pc-windows-msvc" } + elseif ($nodeArch -eq "x64") { $target = "x86_64-pc-windows-msvc" } + } catch {} + } +} + +function Test-RunnableAndMatches { + if (-not (Test-Path -LiteralPath $destination -PathType Leaf)) { return $false } + if ($installedTarget -and $target -ne "unknown" -and $installedTarget -ne $target) { + return $false + } + return $true +} if ((Test-Path -LiteralPath $destination -PathType Leaf) -and - $installed -eq $version -and -not $hasLocalOverride) { + $installed -eq $version -and (Test-RunnableAndMatches) -and -not $hasLocalOverride) { + if (-not (Test-Path -LiteralPath $targetFile -PathType Leaf) -and $target -ne "unknown") { + Set-Content -LiteralPath $targetFile -NoNewline -Value $target + } Write-Output "plannotator-tui $version already installed" exit 0 } @@ -55,6 +88,9 @@ function Install-PlannotatorTui { } $replacementCompleted = $true Set-Content -LiteralPath $stamp -NoNewline -Value $version + if ($target -ne "unknown") { + Set-Content -LiteralPath $targetFile -NoNewline -Value $target + } } catch { $installFailure = $_ if ($replacementCompleted) { @@ -100,11 +136,8 @@ if ($hasLocalOverride) { } try { - $architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() - $target = switch ($architecture) { - "X64" { "x86_64-pc-windows-msvc" } - "Arm64" { "aarch64-pc-windows-msvc" } - default { throw "no plannotator-tui release target for Windows/$architecture" } + if ($target -eq "unknown") { + throw "no plannotator-tui release target for Windows/$architecture" } $asset = "plannotator-tui-$target.exe" diff --git a/scripts/fetch-plannotator-tui.sh b/scripts/fetch-plannotator-tui.sh index 9917b3c..2b14dc7 100755 --- a/scripts/fetch-plannotator-tui.sh +++ b/scripts/fetch-plannotator-tui.sh @@ -21,9 +21,58 @@ version="$(tr -d '[:space:]' < plannotator-tui.version)" mkdir -p bin destination="bin/plannotator-tui.exe" stamp="bin/plannotator-tui.version" -installed="$(cat bin/plannotator-tui.version 2>/dev/null || true)" +target_file="bin/plannotator-tui.target" +installed="$(cat "$stamp" 2>/dev/null || true)" +installed_target="$(cat "$target_file" 2>/dev/null || true)" -if [ -x "$destination" ] && [ "$installed" = "$version" ] && [ -z "${PLANNOTATOR_TUI_BIN:-}" ]; then +detect_target() { + case "$(uname -s)/$(uname -m)" in + Darwin/arm64) echo "aarch64-apple-darwin" ;; + Darwin/x86_64) echo "x86_64-apple-darwin" ;; + Linux/x86_64) echo "x86_64-unknown-linux-gnu" ;; + Linux/aarch64|Linux/arm64) echo "aarch64-unknown-linux-gnu" ;; + MINGW*/*64*|MSYS*/*64*|CYGWIN*/*64*) echo "x86_64-pc-windows-msvc" ;; + MINGW*/*arm64*|MSYS*/*arm64*|CYGWIN*/*arm64*|MINGW*/*aarch64*|MSYS*/*aarch64*|CYGWIN*/*aarch64*) echo "aarch64-pc-windows-msvc" ;; + *) echo "unknown" ;; + esac +} + +target="$(detect_target)" +if [ "$target" = "unknown" ]; then + if command -v node >/dev/null 2>&1; then + target="$(node -e ' + const p = process.platform, a = process.arch; + if (p === "darwin") console.log(a === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"); + else if (p === "linux") console.log(a === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"); + else if (p === "win32") console.log(a === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc"); + else console.log("unknown"); + ' 2>/dev/null || echo "unknown")" + elif command -v bun >/dev/null 2>&1; then + target="$(bun -e ' + const p = process.platform, a = process.arch; + if (p === "darwin") console.log(a === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"); + else if (p === "linux") console.log(a === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"); + else if (p === "win32") console.log(a === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc"); + else console.log("unknown"); + ' 2>/dev/null || echo "unknown")" + fi +fi + +is_runnable_and_matches() { + [ -x "$destination" ] || return 1 + if [ -n "$installed_target" ] && [ "$target" != "unknown" ] && [ "$installed_target" != "$target" ]; then + return 1 + fi + local bin_ver + bin_ver="$("$destination" --version 2>/dev/null)" || return 1 + [ "$bin_ver" = "plannotator-tui $version" ] || return 1 + return 0 +} + +if [ -x "$destination" ] && [ "$installed" = "$version" ] && is_runnable_and_matches && [ -z "${PLANNOTATOR_TUI_BIN:-}" ]; then + if [ ! -f "$target_file" ] && [ "$target" != "unknown" ]; then + echo "$target" > "$target_file" + fi echo "plannotator-tui $version already installed" exit 0 fi @@ -34,17 +83,17 @@ if [ -n "${PLANNOTATOR_TUI_BIN:-}" ]; then cp "$PLANNOTATOR_TUI_BIN" "$destination" chmod +x "$destination" printf '%s' "$version" > "$stamp" + if [ "$target" != "unknown" ]; then + echo "$target" > "$target_file" + fi echo "installed plannotator-tui from $PLANNOTATOR_TUI_BIN (local build, stamped $version)" exit 0 fi -case "$(uname -s)/$(uname -m)" in - Darwin/arm64) target=aarch64-apple-darwin ;; - Darwin/x86_64) target=x86_64-apple-darwin ;; - Linux/x86_64) target=x86_64-unknown-linux-gnu ;; - Linux/aarch64|Linux/arm64) target=aarch64-unknown-linux-gnu ;; - *) echo "warning: no plannotator-tui build for $(uname -s)/$(uname -m); the review pane is unavailable" >&2; exit 0 ;; -esac +if [ "$target" = "unknown" ]; then + echo "warning: no plannotator-tui build for $(uname -s)/$(uname -m); the review pane is unavailable" >&2 + exit 0 +fi asset="plannotator-tui-$target" base="https://github.com/plannotator/plannotator-tui/releases/download/v$version" @@ -80,4 +129,5 @@ rm -f "$destination" cp "$tmp/$asset" "$destination" chmod +x "$destination" printf '%s' "$version" > "$stamp" +echo "$target" > "$target_file" echo "installed plannotator-tui $version ($target)" diff --git a/scripts/test-fetch-herdr-annotate.ps1 b/scripts/test-fetch-herdr-annotate.ps1 index 58df5cc..6b92f1a 100644 --- a/scripts/test-fetch-herdr-annotate.ps1 +++ b/scripts/test-fetch-herdr-annotate.ps1 @@ -56,6 +56,8 @@ try { Assert-True ($result.ExitCode -eq 0) "local override failed: $($result.Output)" Assert-BytesEqual $source $destination "local override bytes differ" Assert-True ((Get-Content -LiteralPath $stamp -Raw) -ceq $version) "local stamp differs" + $targetFile = Join-Path $pluginRoot "bin/herdr-annotate.target" + Assert-True (Test-Path -LiteralPath $targetFile -PathType Leaf) "target stamp file missing" $env:HERDR_ANNOTATE_BIN = $null $beforeHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash @@ -66,6 +68,14 @@ try { (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash -ceq $beforeHash ) "idempotent run replaced the destination" + # If target is mismatched, must not short-circuit + Set-Content -LiteralPath $targetFile -NoNewline -Value "mismatched-target" + $env:HERDR_ANNOTATE_BIN = $source + $result = Invoke-Fetcher + Assert-True ($result.ExitCode -eq 0) "fetch with mismatched target failed: $($result.Output)" + Assert-True ($result.Output -notmatch "already installed") "mismatched target incorrectly short-circuited: $($result.Output)" + Assert-True ($result.Output -match "installed herdr-annotate") "mismatched target did not install: $($result.Output)" + # A missing local build is fatal here: the annotation tools are this binary. $env:HERDR_ANNOTATE_BIN = Join-Path $testRoot "missing explicit override.exe" $result = Invoke-Fetcher diff --git a/scripts/test-fetch-herdr-annotate.sh b/scripts/test-fetch-herdr-annotate.sh index e282df1..7d965e0 100755 --- a/scripts/test-fetch-herdr-annotate.sh +++ b/scripts/test-fetch-herdr-annotate.sh @@ -26,6 +26,7 @@ printf 'old-version' > "$plugin_root/bin/herdr-annotate.version" HERDR_ANNOTATE_BIN="$source_binary" bash "$plugin_root/scripts/fetch-herdr-annotate.sh" cmp "$source_binary" "$plugin_root/bin/herdr-annotate.exe" test "$(cat "$plugin_root/bin/herdr-annotate.version")" = "$version" +test -f "$plugin_root/bin/herdr-annotate.target" test ! -e "$plugin_root/bin/herdr-annotate" test "$("$plugin_root/bin/herdr-annotate.exe" --version)" = "herdr-annotate $version" @@ -35,6 +36,26 @@ case "$output" in *) echo "idempotent fetch did not short-circuit: $output" >&2; exit 1 ;; esac +# If a target file is mismatched, the fetcher must not claim already-installed +printf 'mismatched-target\n' > "$plugin_root/bin/herdr-annotate.target" +output="$(HERDR_ANNOTATE_BIN="$source_binary" bash "$plugin_root/scripts/fetch-herdr-annotate.sh")" +case "$output" in + *"already installed"*) echo "mismatched target incorrectly short-circuited" >&2; exit 1 ;; + *"installed herdr-annotate"*) ;; + *) echo "unexpected output on mismatched target: $output" >&2; exit 1 ;; +esac + +# If an existing binary is unrunnable (simulating wrong-arch Mach-O binary on Linux), +# the fetcher must not claim already-installed +printf 'corrupt unrunnable binary' > "$plugin_root/bin/herdr-annotate.exe" +chmod +x "$plugin_root/bin/herdr-annotate.exe" +output="$(HERDR_ANNOTATE_BIN="$source_binary" bash "$plugin_root/scripts/fetch-herdr-annotate.sh")" +case "$output" in + *"already installed"*) echo "unrunnable binary incorrectly short-circuited" >&2; exit 1 ;; + *"installed herdr-annotate"*) ;; + *) echo "unexpected output on unrunnable binary: $output" >&2; exit 1 ;; +esac + before="$(cat "$plugin_root/bin/herdr-annotate.exe")" if HERDR_ANNOTATE_BIN="$test_root/missing override" bash "$plugin_root/scripts/fetch-herdr-annotate.sh" 2>"$test_root/missing.err"; then echo "a missing local override exited successfully" >&2; exit 1 diff --git a/scripts/test-fetch-plannotator-tui.ps1 b/scripts/test-fetch-plannotator-tui.ps1 index 0f6a14a..58691ac 100644 --- a/scripts/test-fetch-plannotator-tui.ps1 +++ b/scripts/test-fetch-plannotator-tui.ps1 @@ -75,6 +75,8 @@ try { Assert-True ($result.ExitCode -eq 0) "local override failed: $($result.Output)" Assert-BytesEqual $source $destination "local override bytes differ" Assert-True ((Get-Content -LiteralPath $stamp -Raw) -ceq "0.8.0") "local stamp differs" + $targetFile = Join-Path $pluginRoot "bin/plannotator-tui.target" + Assert-True (Test-Path -LiteralPath $targetFile -PathType Leaf) "target stamp file missing" $env:PLANNOTATOR_TUI_BIN = $null $env:PLANNOTATOR_TUI_RELEASE_BASE = "http://127.0.0.1:1/must-not-be-requested" @@ -86,6 +88,13 @@ try { (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash -ceq $beforeHash ) "idempotent run replaced the destination" + # If target is mismatched, must not short-circuit + Set-Content -LiteralPath $targetFile -NoNewline -Value "mismatched-target" + $env:PLANNOTATOR_TUI_BIN = $source + $result = Invoke-Fetcher + Assert-True ($result.ExitCode -eq 0) "fetch with mismatched target failed: $($result.Output)" + Assert-True ($result.Output -notmatch "already installed") "mismatched target incorrectly short-circuited" + Assert-True ($result.Output -match "installed plannotator-tui") "mismatched target did not install: $($result.Output)" $env:PLANNOTATOR_TUI_BIN = Join-Path $testRoot "missing explicit override.exe" $result = Invoke-Fetcher Assert-True ($result.ExitCode -ne 0) "missing explicit override exited successfully" diff --git a/scripts/test-fetch-plannotator-tui.sh b/scripts/test-fetch-plannotator-tui.sh index c223e43..dec0427 100644 --- a/scripts/test-fetch-plannotator-tui.sh +++ b/scripts/test-fetch-plannotator-tui.sh @@ -24,6 +24,7 @@ printf 'old-version' > "$plugin_root/bin/plannotator-tui.version" PLANNOTATOR_TUI_BIN="$source_binary" bash "$plugin_root/scripts/fetch-plannotator-tui.sh" cmp "$source_binary" "$plugin_root/bin/plannotator-tui.exe" test "$(cat "$plugin_root/bin/plannotator-tui.version")" = 0.8.0 +test -f "$plugin_root/bin/plannotator-tui.target" test ! -e "$plugin_root/bin/plannotator-tui" test "$("$plugin_root/bin/plannotator-tui.exe" --version)" = "plannotator-tui 0.8.0" test "$(bash "$plugin_root/scripts/plannotator-tui.sh" --version)" = "plannotator-tui 0.8.0" @@ -33,3 +34,22 @@ case "$output" in *"already installed"*) ;; *) echo "idempotent fetch did not short-circuit: $output" >&2; exit 1 ;; esac + +# If target is mismatched, must not short-circuit +printf 'mismatched-target\n' > "$plugin_root/bin/plannotator-tui.target" +output="$(PLANNOTATOR_TUI_BIN="$source_binary" bash "$plugin_root/scripts/fetch-plannotator-tui.sh")" +case "$output" in + *"already installed"*) echo "mismatched target incorrectly short-circuited" >&2; exit 1 ;; + *"installed plannotator-tui"*) ;; + *) echo "unexpected output on mismatched target: $output" >&2; exit 1 ;; +esac + +# If binary is unrunnable, must not short-circuit +printf 'corrupt bytes' > "$plugin_root/bin/plannotator-tui.exe" +chmod +x "$plugin_root/bin/plannotator-tui.exe" +output="$(PLANNOTATOR_TUI_BIN="$source_binary" bash "$plugin_root/scripts/fetch-plannotator-tui.sh")" +case "$output" in + *"already installed"*) echo "unrunnable binary incorrectly short-circuited" >&2; exit 1 ;; + *"installed plannotator-tui"*) ;; + *) echo "unexpected output on unrunnable binary: $output" >&2; exit 1 ;; +esac