diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 2c1ee8d2..6c1f0084 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -7270,3 +7270,396 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JK0RHMRAH47K8ND4', 'status', 'backlog', 'in_progress', NULL, '2026-06-28 08:55:04', '2026-06-28 08:55:04.660', '2026-06-28 08:55:04.660', NULL, 'e5c075285fc2422545f90d372c175e28', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JK0RHMRAH47K8ND4', 'status', 'in_progress', 'in_progress', NULL, '2026-06-28 08:55:15', '2026-06-28 08:55:15.737', '2026-06-28 08:55:15.737', NULL, '793c4f2984d01abfb3a52d860e60f6d7', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JK0RHMRAH47K8ND4', 'status', 'in_progress', 'done', NULL, '2026-06-28 09:08:31', '2026-06-28 09:08:31.757', '2026-06-28 09:08:31.757', NULL, '168bec5a771d23ab7f0d850a8202ef0c', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7CEKQCMZAV751402G', 'description', 'Check for new versions on startup (or on demand via command palette). Show a non-intrusive notification when an update is available. Support in-place update without losing running Claude sessions (tmux sessions survive). Respect POLICY.md: no silent network calls on default launch path — the check should be opt-in or gated behind a setting. Consider delta updates for bandwidth efficiency. + +───────────────────────────────────────────── +REFINED 2026-06-11 + +## Current state (grounding) +- Version is surfaced at runtime via `lib/src/build_info.g.dart` (`clideVersion`, + `clideCommit`, `clideDate`, `clideRepository` = github.com/postmeridiem/clide), + generated from pubspec by `make gen-build-info`. This is the "installed version". +- Install layout (`make install`): Linux → bundle at `~/.local/lib/clide/`, C client + at `~/.local/bin/clide`, desktop file + icons. macOS → `~/Applications/clide.app` + + `~/.local/bin/clide`. Windows: not yet shipped. +- clide currently makes NO outbound HTTP calls anywhere in `lib/`. Self-update would + be the FIRST one — so this is a policy-sensitive feature, not just plumbing. +- tmux owns Claude session persistence (D-41); the app re-attaches on restart. An + in-place update that restarts the app does NOT lose sessions — they live in tmux, + outside the bundle. + +## HARD CONSTRAINTS (non-negotiable) +- **D-64 (no telemetry / no phone-home):** "No auto-update checks without user + action." This is STRICTER than this ticket''s original "opt-in or gated behind a + setting" wording. A background/startup check — even one a setting enabled — runs + "without user action" at that launch and conflicts with D-64. RESOLUTION: the + version check must be **explicitly user-initiated every time** (a command-palette + "Check for updates…" action / an About-screen button). If we ever want a + startup/periodic check, that needs a deliberate D-64 amendment first — flag, don''t + assume. +- **POLICY.md §"no network on the default launch path":** opening the app, a file, + or typing must never trigger the fetch. The update check + download are explicit + user actions, so they''re allowed — but must meet the §"grudging allowance" + criteria: clear error on failure (not silent), cached result, app fully functional + if the fetch fails. + +## BLOCKING PREREQUISITE (likely its own ticket under T-46) +There is no release channel to update FROM today: only 2 git tags (v2.0.0, v2.1.0) +despite being at 2.3.3, no CI (`.github/workflows` is empty), and no published binary +artifacts. Self-update is meaningless without: + 1. Consistent, automated release tagging (every `release vX.Y.Z` commit → a tag). + 2. CI that builds the per-platform bundles and publishes them as GitHub Releases. + 3. Each artifact accompanied by a checksum AND a signature (POLICY.md: "behavior is + determined by the SIGNED release artifact"). An unsigned/unverified download + would break the trust model the update is supposed to preserve. + 4. A machine-readable "latest version" source — the GitHub Releases API + (`/repos/postmeridiem/clide/releases/latest`) is the zero-infra option; a + committed `latest.json` manifest is the alternative. +RECOMMENDATION: split this prerequisite into a sibling story "Release channel: CI +build + signed GitHub Releases + version manifest" and make T-47 depend on it. + +## DECISIONS TO MAKE (surface before building) +1. Check source: GitHub Releases API vs a hosted `latest.json`. (Lean: Releases API — + no extra infra, origin is already GitHub.) +2. Signature scheme + verification: minisign/age/cosign? Where does the public key + live (vendored in-repo, per POLICY.md provenance)? +3. Delivery: full bundle replacement vs delta/binary-patch (original ask). Lean full + for v1 — deltas are a bandwidth optimization, not correctness; revisit if size hurts. +4. Apply strategy per platform: Linux is easy (swap `~/.local/lib/clide/` + the + `~/.local/bin/clide` client atomically, then relaunch). macOS `.app` replacement + + notarization/quarantine handling is harder. Windows out of scope until it ships. +5. Privilege: user-local installs (`~/.local`, `~/Applications`) need no sudo — good. + A system-wide install would; declare user-local only for v1. + +## PROPOSED SCOPE / PHASES (each independently shippable) +P0 (prereq, separate ticket): release channel — tags + CI + signed GitHub Releases. +P1: "Check for updates…" command (palette + About-screen button). Explicit fetch of + the latest release, semver-compare against `clideVersion`, non-intrusive ToastService + notification ("clide X.Y.Z is available") with a "What''s changed" link to the release + notes. No download yet. Clear error toast on network failure. Fully covers the D-64 / + POLICY-compliant "notify" half of the story. +P2: download + signature/checksum verify into a staging dir; show progress; verify before + touching the install. +P3: apply + relaunch (Linux first): atomic swap of bundle + client, restart the app; + tmux sessions survive (D-41). Confirm-before-apply. +P4 (optional): macOS apply path (.app swap + quarantine), delta updates. + +## ACCEPTANCE (for the full story; refine per-phase ticket) +- No network call on any default launch path (verified — grep + a test that boot makes + no outbound connection). +- "Check for updates" only runs on explicit user action; failure surfaces a clear + toast, never a silent hang or degraded launch. +- A downloaded update is signature+checksum verified before it can replace the install; + verification failure aborts with the old version intact. +- Applying an update and relaunching preserves running Claude/tmux sessions. +- Version comparison is correct semver (2.3.10 > 2.3.9, pre-release handling defined). + +## REFERENCES +POLICY.md (network rule + grudging-allowance criteria); D-64 (no phone-home); +D-41 (tmux session persistence); `lib/src/build_info.g.dart` (version source); +`lib/kernel/src/toast.dart` (ToastService — the notification); settings bool pattern +(`app.*.enabled`, `lib/kernel/src/extensions_manager.dart`); `Makefile` install target +(per-platform layout); parent epic T-46 (cross-platform installer).', 'Check for new versions on startup (or on demand via command palette). Show a non-intrusive notification when an update is available. Support in-place update without losing running Claude sessions (tmux sessions survive). Respect POLICY.md: no silent network calls on default launch path — the check should be opt-in or gated behind a setting. Consider delta updates for bandwidth efficiency. + +───────────────────────────────────────────── +REFINED 2026-06-11 + +## Current state (grounding) +- Version is surfaced at runtime via `lib/src/build_info.g.dart` (`clideVersion`, + `clideCommit`, `clideDate`, `clideRepository` = github.com/postmeridiem/clide), + generated from pubspec by `make gen-build-info`. This is the "installed version". +- Install layout (`make install`): Linux → bundle at `~/.local/lib/clide/`, C client + at `~/.local/bin/clide`, desktop file + icons. macOS → `~/Applications/clide.app` + + `~/.local/bin/clide`. Windows: not yet shipped. +- clide currently makes NO outbound HTTP calls anywhere in `lib/`. Self-update would + be the FIRST one — so this is a policy-sensitive feature, not just plumbing. +- tmux owns Claude session persistence (D-41); the app re-attaches on restart. An + in-place update that restarts the app does NOT lose sessions — they live in tmux, + outside the bundle. + +## HARD CONSTRAINTS (non-negotiable) +- **D-64 (no telemetry / no phone-home):** "No auto-update checks without user + action." This is STRICTER than this ticket''s original "opt-in or gated behind a + setting" wording. A background/startup check — even one a setting enabled — runs + "without user action" at that launch and conflicts with D-64. RESOLUTION: the + version check must be **explicitly user-initiated every time** (a command-palette + "Check for updates…" action / an About-screen button). If we ever want a + startup/periodic check, that needs a deliberate D-64 amendment first — flag, don''t + assume. +- **POLICY.md §"no network on the default launch path":** opening the app, a file, + or typing must never trigger the fetch. The update check + download are explicit + user actions, so they''re allowed — but must meet the §"grudging allowance" + criteria: clear error on failure (not silent), cached result, app fully functional + if the fetch fails. + +## BLOCKING PREREQUISITE (likely its own ticket under T-46) +There is no release channel to update FROM today: only 2 git tags (v2.0.0, v2.1.0) +despite being at 2.3.3, no CI (`.github/workflows` is empty), and no published binary +artifacts. Self-update is meaningless without: + 1. Consistent, automated release tagging (every `release vX.Y.Z` commit → a tag). + 2. CI that builds the per-platform bundles and publishes them as GitHub Releases. + 3. Each artifact accompanied by a checksum AND a signature (POLICY.md: "behavior is + determined by the SIGNED release artifact"). An unsigned/unverified download + would break the trust model the update is supposed to preserve. + 4. A machine-readable "latest version" source — the GitHub Releases API + (`/repos/postmeridiem/clide/releases/latest`) is the zero-infra option; a + committed `latest.json` manifest is the alternative. +RECOMMENDATION: split this prerequisite into a sibling story "Release channel: CI +build + signed GitHub Releases + version manifest" and make T-47 depend on it. + +## DECISIONS TO MAKE (surface before building) +1. Check source: GitHub Releases API vs a hosted `latest.json`. (Lean: Releases API — + no extra infra, origin is already GitHub.) +2. Signature scheme + verification: minisign/age/cosign? Where does the public key + live (vendored in-repo, per POLICY.md provenance)? +3. Delivery: full bundle replacement vs delta/binary-patch (original ask). Lean full + for v1 — deltas are a bandwidth optimization, not correctness; revisit if size hurts. +4. Apply strategy per platform: Linux is easy (swap `~/.local/lib/clide/` + the + `~/.local/bin/clide` client atomically, then relaunch). macOS `.app` replacement + + notarization/quarantine handling is harder. Windows out of scope until it ships. +5. Privilege: user-local installs (`~/.local`, `~/Applications`) need no sudo — good. + A system-wide install would; declare user-local only for v1. + +## PROPOSED SCOPE / PHASES (each independently shippable) +P0 (prereq, separate ticket): release channel — tags + CI + signed GitHub Releases. +P1: "Check for updates…" command (palette + About-screen button). Explicit fetch of + the latest release, semver-compare against `clideVersion`, non-intrusive ToastService + notification ("clide X.Y.Z is available") with a "What''s changed" link to the release + notes. No download yet. Clear error toast on network failure. Fully covers the D-64 / + POLICY-compliant "notify" half of the story. +P2: download + signature/checksum verify into a staging dir; show progress; verify before + touching the install. +P3: apply + relaunch (Linux first): atomic swap of bundle + client, restart the app; + tmux sessions survive (D-41). Confirm-before-apply. +P4 (optional): macOS apply path (.app swap + quarantine), delta updates. + +## ACCEPTANCE (for the full story; refine per-phase ticket) +- No network call on any default launch path (verified — grep + a test that boot makes + no outbound connection). +- "Check for updates" only runs on explicit user action; failure surfaces a clear + toast, never a silent hang or degraded launch. +- A downloaded update is signature+checksum verified before it can replace the install; + verification failure aborts with the old version intact. +- Applying an update and relaunching preserves running Claude/tmux sessions. +- Version comparison is correct semver (2.3.10 > 2.3.9, pre-release handling defined). + +## REFERENCES +POLICY.md (network rule + grudging-allowance criteria); D-64 (no phone-home); +D-41 (tmux session persistence); `lib/src/build_info.g.dart` (version source); +`lib/kernel/src/toast.dart` (ToastService — the notification); settings bool pattern +(`app.*.enabled`, `lib/kernel/src/extensions_manager.dart`); `Makefile` install target +(per-platform layout); parent epic T-46 (cross-platform installer). + +DECISION (user, 2026-06-28): MANUAL-ONLY. The update check is a ''Check for updates'' button inside the About-screen box — explicitly user-initiated every time, so NO D-64 amendment is needed (manual checks already comply). A ''check on startup'' checkbox (the opt-in poll) is DEFERRED, not rejected: it would sit next to the button later, and only THEN would it need the narrow, default-off D-64 amendment we discussed (a data-free version fetch; telemetry stays banned full stop). This supersedes any background/periodic-poll framing in the original scope. Build-order caveat: P1 (the About button) still needs the release channel (P0: CI + signed GitHub Releases + a version manifest) to have something to check against — /releases/latest 404s when no Releases are published (the repo has 2 tags, which are NOT Releases). So either P0 lands first, or P1 ships with graceful ''up to date / couldn''t reach GitHub'' handling that no-ops until Releases exist.', NULL, '2026-06-28 09:57:13', '2026-06-28 09:57:13.881', '2026-06-28 09:57:13.881', NULL, '0eab3ce8f5036bcf831e6a6243e7b228', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGVCY4M0ZK72HZT2FSZF968W', 'description', NULL, 'GitHub Releases exist but carry no downloadable packages — no per-platform bundles are attached. clide''s self-update download/apply (T-47 P2/P3) and any future binary install need signed, checksummed artifacts on each Release. `.github/workflows` is empty today; this stands up the release channel. + +## Scope +1. CI workflow (.github/workflows) triggered on a release — the `release vX.Y.Z` tag (T-393 already wires `make release` + back-tags + the pre-push regex) or the GitHub ''release published'' event — that builds the per-platform bundles: + - Linux: `make build-linux` -> a bundle matching the `make install` layout (~/.local/lib/clide + the `clide` C client). + - macOS: `make build-macos` -> clide.app + the C client. Notarization/quarantine is a known wrinkle (see T-47 P4) — v1 may ship unnotarized with a documented Gatekeeper step, or notarize if signing creds are available. + - Windows: out of scope until it ships. +2. Package each into a versioned archive (tar.gz / zip) + a SHA-256 checksum. +3. SIGN each artifact (POLICY.md: ''behavior is determined by the SIGNED release artifact''). Scheme TBD — minisign or cosign; the public key is vendored in-repo for provenance. Key custody (a CI secret for the private key) + the vendored-pubkey location are decisions to make. +4. Attach the archives + .sha256 + signatures to the GitHub Release as assets. +5. (Optional) a machine-readable latest manifest — though the GitHub Releases API (/releases/latest) already returns the latest version + its asset list, which the T-47 check consumes. + +## Acceptance +1. Cutting a release (the `release vX.Y.Z` tag / `make release`) triggers CI that builds the Linux + macOS bundles. +2. Each GitHub Release carries, per platform: the archive, its .sha256, and its signature. +3. The signature verifies against the vendored public key; a tampered artifact fails verification. +4. The packaged layout matches `make install` so the updater (T-47 P2/P3) can swap it in place. + +## Decisions to surface +- Signing scheme: minisign vs cosign (lean minisign — tiny, no infra, vendored pubkey). +- Key custody: private key as a CI secret; public key committed in-repo (provenance). +- macOS notarization for v1: notarize vs documented-Gatekeeper-step. + +## Relationship to T-47 +This is the P0 release-channel prerequisite called out in T-47. T-47 P1 (the manual ''Check for updates'' About button) does NOT depend on this — Releases already exist for the version check. T-47 P2 (download + verify) and P3 (apply + relaunch) DO depend on signed package assets, so block those on this story. Also unblocks the cross-platform installer epic (T-46).', NULL, '2026-06-28 10:08:32', '2026-06-28 10:08:32.964', '2026-06-28 10:08:32.964', NULL, '9001203bff4e25aaab3765ef71dd4d26', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7CEKQCMZAV751402G', 'description', 'Check for new versions on startup (or on demand via command palette). Show a non-intrusive notification when an update is available. Support in-place update without losing running Claude sessions (tmux sessions survive). Respect POLICY.md: no silent network calls on default launch path — the check should be opt-in or gated behind a setting. Consider delta updates for bandwidth efficiency. + +───────────────────────────────────────────── +REFINED 2026-06-11 + +## Current state (grounding) +- Version is surfaced at runtime via `lib/src/build_info.g.dart` (`clideVersion`, + `clideCommit`, `clideDate`, `clideRepository` = github.com/postmeridiem/clide), + generated from pubspec by `make gen-build-info`. This is the "installed version". +- Install layout (`make install`): Linux → bundle at `~/.local/lib/clide/`, C client + at `~/.local/bin/clide`, desktop file + icons. macOS → `~/Applications/clide.app` + + `~/.local/bin/clide`. Windows: not yet shipped. +- clide currently makes NO outbound HTTP calls anywhere in `lib/`. Self-update would + be the FIRST one — so this is a policy-sensitive feature, not just plumbing. +- tmux owns Claude session persistence (D-41); the app re-attaches on restart. An + in-place update that restarts the app does NOT lose sessions — they live in tmux, + outside the bundle. + +## HARD CONSTRAINTS (non-negotiable) +- **D-64 (no telemetry / no phone-home):** "No auto-update checks without user + action." This is STRICTER than this ticket''s original "opt-in or gated behind a + setting" wording. A background/startup check — even one a setting enabled — runs + "without user action" at that launch and conflicts with D-64. RESOLUTION: the + version check must be **explicitly user-initiated every time** (a command-palette + "Check for updates…" action / an About-screen button). If we ever want a + startup/periodic check, that needs a deliberate D-64 amendment first — flag, don''t + assume. +- **POLICY.md §"no network on the default launch path":** opening the app, a file, + or typing must never trigger the fetch. The update check + download are explicit + user actions, so they''re allowed — but must meet the §"grudging allowance" + criteria: clear error on failure (not silent), cached result, app fully functional + if the fetch fails. + +## BLOCKING PREREQUISITE (likely its own ticket under T-46) +There is no release channel to update FROM today: only 2 git tags (v2.0.0, v2.1.0) +despite being at 2.3.3, no CI (`.github/workflows` is empty), and no published binary +artifacts. Self-update is meaningless without: + 1. Consistent, automated release tagging (every `release vX.Y.Z` commit → a tag). + 2. CI that builds the per-platform bundles and publishes them as GitHub Releases. + 3. Each artifact accompanied by a checksum AND a signature (POLICY.md: "behavior is + determined by the SIGNED release artifact"). An unsigned/unverified download + would break the trust model the update is supposed to preserve. + 4. A machine-readable "latest version" source — the GitHub Releases API + (`/repos/postmeridiem/clide/releases/latest`) is the zero-infra option; a + committed `latest.json` manifest is the alternative. +RECOMMENDATION: split this prerequisite into a sibling story "Release channel: CI +build + signed GitHub Releases + version manifest" and make T-47 depend on it. + +## DECISIONS TO MAKE (surface before building) +1. Check source: GitHub Releases API vs a hosted `latest.json`. (Lean: Releases API — + no extra infra, origin is already GitHub.) +2. Signature scheme + verification: minisign/age/cosign? Where does the public key + live (vendored in-repo, per POLICY.md provenance)? +3. Delivery: full bundle replacement vs delta/binary-patch (original ask). Lean full + for v1 — deltas are a bandwidth optimization, not correctness; revisit if size hurts. +4. Apply strategy per platform: Linux is easy (swap `~/.local/lib/clide/` + the + `~/.local/bin/clide` client atomically, then relaunch). macOS `.app` replacement + + notarization/quarantine handling is harder. Windows out of scope until it ships. +5. Privilege: user-local installs (`~/.local`, `~/Applications`) need no sudo — good. + A system-wide install would; declare user-local only for v1. + +## PROPOSED SCOPE / PHASES (each independently shippable) +P0 (prereq, separate ticket): release channel — tags + CI + signed GitHub Releases. +P1: "Check for updates…" command (palette + About-screen button). Explicit fetch of + the latest release, semver-compare against `clideVersion`, non-intrusive ToastService + notification ("clide X.Y.Z is available") with a "What''s changed" link to the release + notes. No download yet. Clear error toast on network failure. Fully covers the D-64 / + POLICY-compliant "notify" half of the story. +P2: download + signature/checksum verify into a staging dir; show progress; verify before + touching the install. +P3: apply + relaunch (Linux first): atomic swap of bundle + client, restart the app; + tmux sessions survive (D-41). Confirm-before-apply. +P4 (optional): macOS apply path (.app swap + quarantine), delta updates. + +## ACCEPTANCE (for the full story; refine per-phase ticket) +- No network call on any default launch path (verified — grep + a test that boot makes + no outbound connection). +- "Check for updates" only runs on explicit user action; failure surfaces a clear + toast, never a silent hang or degraded launch. +- A downloaded update is signature+checksum verified before it can replace the install; + verification failure aborts with the old version intact. +- Applying an update and relaunching preserves running Claude/tmux sessions. +- Version comparison is correct semver (2.3.10 > 2.3.9, pre-release handling defined). + +## REFERENCES +POLICY.md (network rule + grudging-allowance criteria); D-64 (no phone-home); +D-41 (tmux session persistence); `lib/src/build_info.g.dart` (version source); +`lib/kernel/src/toast.dart` (ToastService — the notification); settings bool pattern +(`app.*.enabled`, `lib/kernel/src/extensions_manager.dart`); `Makefile` install target +(per-platform layout); parent epic T-46 (cross-platform installer). + +DECISION (user, 2026-06-28): MANUAL-ONLY. The update check is a ''Check for updates'' button inside the About-screen box — explicitly user-initiated every time, so NO D-64 amendment is needed (manual checks already comply). A ''check on startup'' checkbox (the opt-in poll) is DEFERRED, not rejected: it would sit next to the button later, and only THEN would it need the narrow, default-off D-64 amendment we discussed (a data-free version fetch; telemetry stays banned full stop). This supersedes any background/periodic-poll framing in the original scope. Build-order caveat: P1 (the About button) still needs the release channel (P0: CI + signed GitHub Releases + a version manifest) to have something to check against — /releases/latest 404s when no Releases are published (the repo has 2 tags, which are NOT Releases). So either P0 lands first, or P1 ships with graceful ''up to date / couldn''t reach GitHub'' handling that no-ops until Releases exist.', 'Check for new versions on startup (or on demand via command palette). Show a non-intrusive notification when an update is available. Support in-place update without losing running Claude sessions (tmux sessions survive). Respect POLICY.md: no silent network calls on default launch path — the check should be opt-in or gated behind a setting. Consider delta updates for bandwidth efficiency. + +───────────────────────────────────────────── +REFINED 2026-06-11 + +## Current state (grounding) +- Version is surfaced at runtime via `lib/src/build_info.g.dart` (`clideVersion`, + `clideCommit`, `clideDate`, `clideRepository` = github.com/postmeridiem/clide), + generated from pubspec by `make gen-build-info`. This is the "installed version". +- Install layout (`make install`): Linux → bundle at `~/.local/lib/clide/`, C client + at `~/.local/bin/clide`, desktop file + icons. macOS → `~/Applications/clide.app` + + `~/.local/bin/clide`. Windows: not yet shipped. +- clide currently makes NO outbound HTTP calls anywhere in `lib/`. Self-update would + be the FIRST one — so this is a policy-sensitive feature, not just plumbing. +- tmux owns Claude session persistence (D-41); the app re-attaches on restart. An + in-place update that restarts the app does NOT lose sessions — they live in tmux, + outside the bundle. + +## HARD CONSTRAINTS (non-negotiable) +- **D-64 (no telemetry / no phone-home):** "No auto-update checks without user + action." This is STRICTER than this ticket''s original "opt-in or gated behind a + setting" wording. A background/startup check — even one a setting enabled — runs + "without user action" at that launch and conflicts with D-64. RESOLUTION: the + version check must be **explicitly user-initiated every time** (a command-palette + "Check for updates…" action / an About-screen button). If we ever want a + startup/periodic check, that needs a deliberate D-64 amendment first — flag, don''t + assume. +- **POLICY.md §"no network on the default launch path":** opening the app, a file, + or typing must never trigger the fetch. The update check + download are explicit + user actions, so they''re allowed — but must meet the §"grudging allowance" + criteria: clear error on failure (not silent), cached result, app fully functional + if the fetch fails. + +## BLOCKING PREREQUISITE (likely its own ticket under T-46) +There is no release channel to update FROM today: only 2 git tags (v2.0.0, v2.1.0) +despite being at 2.3.3, no CI (`.github/workflows` is empty), and no published binary +artifacts. Self-update is meaningless without: + 1. Consistent, automated release tagging (every `release vX.Y.Z` commit → a tag). + 2. CI that builds the per-platform bundles and publishes them as GitHub Releases. + 3. Each artifact accompanied by a checksum AND a signature (POLICY.md: "behavior is + determined by the SIGNED release artifact"). An unsigned/unverified download + would break the trust model the update is supposed to preserve. + 4. A machine-readable "latest version" source — the GitHub Releases API + (`/repos/postmeridiem/clide/releases/latest`) is the zero-infra option; a + committed `latest.json` manifest is the alternative. +RECOMMENDATION: split this prerequisite into a sibling story "Release channel: CI +build + signed GitHub Releases + version manifest" and make T-47 depend on it. + +## DECISIONS TO MAKE (surface before building) +1. Check source: GitHub Releases API vs a hosted `latest.json`. (Lean: Releases API — + no extra infra, origin is already GitHub.) +2. Signature scheme + verification: minisign/age/cosign? Where does the public key + live (vendored in-repo, per POLICY.md provenance)? +3. Delivery: full bundle replacement vs delta/binary-patch (original ask). Lean full + for v1 — deltas are a bandwidth optimization, not correctness; revisit if size hurts. +4. Apply strategy per platform: Linux is easy (swap `~/.local/lib/clide/` + the + `~/.local/bin/clide` client atomically, then relaunch). macOS `.app` replacement + + notarization/quarantine handling is harder. Windows out of scope until it ships. +5. Privilege: user-local installs (`~/.local`, `~/Applications`) need no sudo — good. + A system-wide install would; declare user-local only for v1. + +## PROPOSED SCOPE / PHASES (each independently shippable) +P0 (prereq, separate ticket): release channel — tags + CI + signed GitHub Releases. +P1: "Check for updates…" command (palette + About-screen button). Explicit fetch of + the latest release, semver-compare against `clideVersion`, non-intrusive ToastService + notification ("clide X.Y.Z is available") with a "What''s changed" link to the release + notes. No download yet. Clear error toast on network failure. Fully covers the D-64 / + POLICY-compliant "notify" half of the story. +P2: download + signature/checksum verify into a staging dir; show progress; verify before + touching the install. +P3: apply + relaunch (Linux first): atomic swap of bundle + client, restart the app; + tmux sessions survive (D-41). Confirm-before-apply. +P4 (optional): macOS apply path (.app swap + quarantine), delta updates. + +## ACCEPTANCE (for the full story; refine per-phase ticket) +- No network call on any default launch path (verified — grep + a test that boot makes + no outbound connection). +- "Check for updates" only runs on explicit user action; failure surfaces a clear + toast, never a silent hang or degraded launch. +- A downloaded update is signature+checksum verified before it can replace the install; + verification failure aborts with the old version intact. +- Applying an update and relaunching preserves running Claude/tmux sessions. +- Version comparison is correct semver (2.3.10 > 2.3.9, pre-release handling defined). + +## REFERENCES +POLICY.md (network rule + grudging-allowance criteria); D-64 (no phone-home); +D-41 (tmux session persistence); `lib/src/build_info.g.dart` (version source); +`lib/kernel/src/toast.dart` (ToastService — the notification); settings bool pattern +(`app.*.enabled`, `lib/kernel/src/extensions_manager.dart`); `Makefile` install target +(per-platform layout); parent epic T-46 (cross-platform installer). + +DECISION (user, 2026-06-28): MANUAL-ONLY. The update check is a ''Check for updates'' button inside the About-screen box — explicitly user-initiated every time, so NO D-64 amendment is needed (manual checks already comply). A ''check on startup'' checkbox (the opt-in poll) is DEFERRED, not rejected: it would sit next to the button later, and only THEN would it need the narrow, default-off D-64 amendment we discussed (a data-free version fetch; telemetry stays banned full stop). This supersedes any background/periodic-poll framing in the original scope. Build-order caveat: P1 (the About button) still needs the release channel (P0: CI + signed GitHub Releases + a version manifest) to have something to check against — /releases/latest 404s when no Releases are published (the repo has 2 tags, which are NOT Releases). So either P0 lands first, or P1 ships with graceful ''up to date / couldn''t reach GitHub'' handling that no-ops until Releases exist. + +P0 release-channel prerequisite is now T-491 (CI builds + signed package artifacts on Releases). P1 (this About button) is independent of it — Releases already exist to check against; P2/P3 (download+verify+apply) depend on T-491.', NULL, '2026-06-28 10:08:32', '2026-06-28 10:08:32.993', '2026-06-28 10:08:32.993', NULL, '3e7016c2feb2e8a0c10360240a73cb3b', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGVDBD0F14FV5CDJQB063YSR', 'status', 'backlog', 'in_progress', NULL, '2026-06-28 10:10:28', '2026-06-28 10:10:28.234', '2026-06-28 10:10:28.234', NULL, '149c9f07d16db564a134917d5f42d8f7', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGVDBD0F14FV5CDJQB063YSR', 'status', 'in_progress', 'done', NULL, '2026-06-28 10:17:18', '2026-06-28 10:17:18.750', '2026-06-28 10:17:18.750', NULL, '56fc691a9c2eb8dd703db036acee907a', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 707e533f..e4e754de 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -9461,3 +9461,218 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat Wiring sketch: EditorRegistry already emits editor.opened / editor.active-changed / editor.edited (lib/src/editor/registry.dart:85). The markdown reader (lib/builtin/markdown/src/markdown_viewer.dart) currently pulls content once via files.read with no subscription. The live-sync work is: (1) on editor.opened for a renderable file, auto-activate the markdown reader in the context panel; (2) subscribe the reader to editor.edited and re-render from the in-memory buffer rather than re-reading disk; (3) read-only — no edit affordances in the mirror. HISTORY: T-36 originally bundled four D-50 clauses (parse Claude''s output for file references, swap the open viewer, badge the spine when collapsed, live-sync). The give-clide-hands push (T-208) superseded the first three: instead of clide scraping the terminal for references, the agent explicitly drives the reader via `clide ui open markdown ` (T-231) and ui.open -> diff (T-233). The spine badge was dropped (the open is now an intentional agent act, not a passive notification). Re-scoped 2026-06-06 to the one UI-owned piece that give-clide-hands did not deliver: the live-sync read-mirror. Re-homed from T-7 (Tier 5 canvas/graph, a mis-parent) to T-259 (interaction model). See the D-50 amendment.', 'done', 'low', NULL, NULL, 'D-50', '2026-04-22 20:34:09', '2026-06-28 09:08:31.757', NULL, 'cb89bbf9b488b2611acbe49cc3d59a6a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7CEKQCMZAV751402G', 'story', '06FB0TNQM79H4MPEWW2TWQ89D0', 'clide self-update mechanism', 'Check for new versions on startup (or on demand via command palette). Show a non-intrusive notification when an update is available. Support in-place update without losing running Claude sessions (tmux sessions survive). Respect POLICY.md: no silent network calls on default launch path — the check should be opt-in or gated behind a setting. Consider delta updates for bandwidth efficiency. + +───────────────────────────────────────────── +REFINED 2026-06-11 + +## Current state (grounding) +- Version is surfaced at runtime via `lib/src/build_info.g.dart` (`clideVersion`, + `clideCommit`, `clideDate`, `clideRepository` = github.com/postmeridiem/clide), + generated from pubspec by `make gen-build-info`. This is the "installed version". +- Install layout (`make install`): Linux → bundle at `~/.local/lib/clide/`, C client + at `~/.local/bin/clide`, desktop file + icons. macOS → `~/Applications/clide.app` + + `~/.local/bin/clide`. Windows: not yet shipped. +- clide currently makes NO outbound HTTP calls anywhere in `lib/`. Self-update would + be the FIRST one — so this is a policy-sensitive feature, not just plumbing. +- tmux owns Claude session persistence (D-41); the app re-attaches on restart. An + in-place update that restarts the app does NOT lose sessions — they live in tmux, + outside the bundle. + +## HARD CONSTRAINTS (non-negotiable) +- **D-64 (no telemetry / no phone-home):** "No auto-update checks without user + action." This is STRICTER than this ticket''s original "opt-in or gated behind a + setting" wording. A background/startup check — even one a setting enabled — runs + "without user action" at that launch and conflicts with D-64. RESOLUTION: the + version check must be **explicitly user-initiated every time** (a command-palette + "Check for updates…" action / an About-screen button). If we ever want a + startup/periodic check, that needs a deliberate D-64 amendment first — flag, don''t + assume. +- **POLICY.md §"no network on the default launch path":** opening the app, a file, + or typing must never trigger the fetch. The update check + download are explicit + user actions, so they''re allowed — but must meet the §"grudging allowance" + criteria: clear error on failure (not silent), cached result, app fully functional + if the fetch fails. + +## BLOCKING PREREQUISITE (likely its own ticket under T-46) +There is no release channel to update FROM today: only 2 git tags (v2.0.0, v2.1.0) +despite being at 2.3.3, no CI (`.github/workflows` is empty), and no published binary +artifacts. Self-update is meaningless without: + 1. Consistent, automated release tagging (every `release vX.Y.Z` commit → a tag). + 2. CI that builds the per-platform bundles and publishes them as GitHub Releases. + 3. Each artifact accompanied by a checksum AND a signature (POLICY.md: "behavior is + determined by the SIGNED release artifact"). An unsigned/unverified download + would break the trust model the update is supposed to preserve. + 4. A machine-readable "latest version" source — the GitHub Releases API + (`/repos/postmeridiem/clide/releases/latest`) is the zero-infra option; a + committed `latest.json` manifest is the alternative. +RECOMMENDATION: split this prerequisite into a sibling story "Release channel: CI +build + signed GitHub Releases + version manifest" and make T-47 depend on it. + +## DECISIONS TO MAKE (surface before building) +1. Check source: GitHub Releases API vs a hosted `latest.json`. (Lean: Releases API — + no extra infra, origin is already GitHub.) +2. Signature scheme + verification: minisign/age/cosign? Where does the public key + live (vendored in-repo, per POLICY.md provenance)? +3. Delivery: full bundle replacement vs delta/binary-patch (original ask). Lean full + for v1 — deltas are a bandwidth optimization, not correctness; revisit if size hurts. +4. Apply strategy per platform: Linux is easy (swap `~/.local/lib/clide/` + the + `~/.local/bin/clide` client atomically, then relaunch). macOS `.app` replacement + + notarization/quarantine handling is harder. Windows out of scope until it ships. +5. Privilege: user-local installs (`~/.local`, `~/Applications`) need no sudo — good. + A system-wide install would; declare user-local only for v1. + +## PROPOSED SCOPE / PHASES (each independently shippable) +P0 (prereq, separate ticket): release channel — tags + CI + signed GitHub Releases. +P1: "Check for updates…" command (palette + About-screen button). Explicit fetch of + the latest release, semver-compare against `clideVersion`, non-intrusive ToastService + notification ("clide X.Y.Z is available") with a "What''s changed" link to the release + notes. No download yet. Clear error toast on network failure. Fully covers the D-64 / + POLICY-compliant "notify" half of the story. +P2: download + signature/checksum verify into a staging dir; show progress; verify before + touching the install. +P3: apply + relaunch (Linux first): atomic swap of bundle + client, restart the app; + tmux sessions survive (D-41). Confirm-before-apply. +P4 (optional): macOS apply path (.app swap + quarantine), delta updates. + +## ACCEPTANCE (for the full story; refine per-phase ticket) +- No network call on any default launch path (verified — grep + a test that boot makes + no outbound connection). +- "Check for updates" only runs on explicit user action; failure surfaces a clear + toast, never a silent hang or degraded launch. +- A downloaded update is signature+checksum verified before it can replace the install; + verification failure aborts with the old version intact. +- Applying an update and relaunching preserves running Claude/tmux sessions. +- Version comparison is correct semver (2.3.10 > 2.3.9, pre-release handling defined). + +## REFERENCES +POLICY.md (network rule + grudging-allowance criteria); D-64 (no phone-home); +D-41 (tmux session persistence); `lib/src/build_info.g.dart` (version source); +`lib/kernel/src/toast.dart` (ToastService — the notification); settings bool pattern +(`app.*.enabled`, `lib/kernel/src/extensions_manager.dart`); `Makefile` install target +(per-platform layout); parent epic T-46 (cross-platform installer). + +DECISION (user, 2026-06-28): MANUAL-ONLY. The update check is a ''Check for updates'' button inside the About-screen box — explicitly user-initiated every time, so NO D-64 amendment is needed (manual checks already comply). A ''check on startup'' checkbox (the opt-in poll) is DEFERRED, not rejected: it would sit next to the button later, and only THEN would it need the narrow, default-off D-64 amendment we discussed (a data-free version fetch; telemetry stays banned full stop). This supersedes any background/periodic-poll framing in the original scope. Build-order caveat: P1 (the About button) still needs the release channel (P0: CI + signed GitHub Releases + a version manifest) to have something to check against — /releases/latest 404s when no Releases are published (the repo has 2 tags, which are NOT Releases). So either P0 lands first, or P1 ships with graceful ''up to date / couldn''t reach GitHub'' handling that no-ops until Releases exist.', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-23 20:28:43', '2026-06-28 09:57:13.881', NULL, '79a11ae9a9174184418d05ba83e189c8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGVCY4M0ZK72HZT2FSZF968W', 'story', '06FB0TNQM79H4MPEWW2TWQ89D0', 'Release channel: CI builds + signed package artifacts on GitHub Releases', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-28 10:08:32.928', '2026-06-28 10:08:32.928', NULL, '72cf3ab7771a5cb296153d1efb8ed9f2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGVCY4M0ZK72HZT2FSZF968W', 'story', '06FB0TNQM79H4MPEWW2TWQ89D0', 'Release channel: CI builds + signed package artifacts on GitHub Releases', 'GitHub Releases exist but carry no downloadable packages — no per-platform bundles are attached. clide''s self-update download/apply (T-47 P2/P3) and any future binary install need signed, checksummed artifacts on each Release. `.github/workflows` is empty today; this stands up the release channel. + +## Scope +1. CI workflow (.github/workflows) triggered on a release — the `release vX.Y.Z` tag (T-393 already wires `make release` + back-tags + the pre-push regex) or the GitHub ''release published'' event — that builds the per-platform bundles: + - Linux: `make build-linux` -> a bundle matching the `make install` layout (~/.local/lib/clide + the `clide` C client). + - macOS: `make build-macos` -> clide.app + the C client. Notarization/quarantine is a known wrinkle (see T-47 P4) — v1 may ship unnotarized with a documented Gatekeeper step, or notarize if signing creds are available. + - Windows: out of scope until it ships. +2. Package each into a versioned archive (tar.gz / zip) + a SHA-256 checksum. +3. SIGN each artifact (POLICY.md: ''behavior is determined by the SIGNED release artifact''). Scheme TBD — minisign or cosign; the public key is vendored in-repo for provenance. Key custody (a CI secret for the private key) + the vendored-pubkey location are decisions to make. +4. Attach the archives + .sha256 + signatures to the GitHub Release as assets. +5. (Optional) a machine-readable latest manifest — though the GitHub Releases API (/releases/latest) already returns the latest version + its asset list, which the T-47 check consumes. + +## Acceptance +1. Cutting a release (the `release vX.Y.Z` tag / `make release`) triggers CI that builds the Linux + macOS bundles. +2. Each GitHub Release carries, per platform: the archive, its .sha256, and its signature. +3. The signature verifies against the vendored public key; a tampered artifact fails verification. +4. The packaged layout matches `make install` so the updater (T-47 P2/P3) can swap it in place. + +## Decisions to surface +- Signing scheme: minisign vs cosign (lean minisign — tiny, no infra, vendored pubkey). +- Key custody: private key as a CI secret; public key committed in-repo (provenance). +- macOS notarization for v1: notarize vs documented-Gatekeeper-step. + +## Relationship to T-47 +This is the P0 release-channel prerequisite called out in T-47. T-47 P1 (the manual ''Check for updates'' About button) does NOT depend on this — Releases already exist for the version check. T-47 P2 (download + verify) and P3 (apply + relaunch) DO depend on signed package assets, so block those on this story. Also unblocks the cross-platform installer epic (T-46).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-28 10:08:32.928', '2026-06-28 10:08:32.964', NULL, '255a8a5570317fe516158496cd08b35a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7CEKQCMZAV751402G', 'story', '06FB0TNQM79H4MPEWW2TWQ89D0', 'clide self-update mechanism', 'Check for new versions on startup (or on demand via command palette). Show a non-intrusive notification when an update is available. Support in-place update without losing running Claude sessions (tmux sessions survive). Respect POLICY.md: no silent network calls on default launch path — the check should be opt-in or gated behind a setting. Consider delta updates for bandwidth efficiency. + +───────────────────────────────────────────── +REFINED 2026-06-11 + +## Current state (grounding) +- Version is surfaced at runtime via `lib/src/build_info.g.dart` (`clideVersion`, + `clideCommit`, `clideDate`, `clideRepository` = github.com/postmeridiem/clide), + generated from pubspec by `make gen-build-info`. This is the "installed version". +- Install layout (`make install`): Linux → bundle at `~/.local/lib/clide/`, C client + at `~/.local/bin/clide`, desktop file + icons. macOS → `~/Applications/clide.app` + + `~/.local/bin/clide`. Windows: not yet shipped. +- clide currently makes NO outbound HTTP calls anywhere in `lib/`. Self-update would + be the FIRST one — so this is a policy-sensitive feature, not just plumbing. +- tmux owns Claude session persistence (D-41); the app re-attaches on restart. An + in-place update that restarts the app does NOT lose sessions — they live in tmux, + outside the bundle. + +## HARD CONSTRAINTS (non-negotiable) +- **D-64 (no telemetry / no phone-home):** "No auto-update checks without user + action." This is STRICTER than this ticket''s original "opt-in or gated behind a + setting" wording. A background/startup check — even one a setting enabled — runs + "without user action" at that launch and conflicts with D-64. RESOLUTION: the + version check must be **explicitly user-initiated every time** (a command-palette + "Check for updates…" action / an About-screen button). If we ever want a + startup/periodic check, that needs a deliberate D-64 amendment first — flag, don''t + assume. +- **POLICY.md §"no network on the default launch path":** opening the app, a file, + or typing must never trigger the fetch. The update check + download are explicit + user actions, so they''re allowed — but must meet the §"grudging allowance" + criteria: clear error on failure (not silent), cached result, app fully functional + if the fetch fails. + +## BLOCKING PREREQUISITE (likely its own ticket under T-46) +There is no release channel to update FROM today: only 2 git tags (v2.0.0, v2.1.0) +despite being at 2.3.3, no CI (`.github/workflows` is empty), and no published binary +artifacts. Self-update is meaningless without: + 1. Consistent, automated release tagging (every `release vX.Y.Z` commit → a tag). + 2. CI that builds the per-platform bundles and publishes them as GitHub Releases. + 3. Each artifact accompanied by a checksum AND a signature (POLICY.md: "behavior is + determined by the SIGNED release artifact"). An unsigned/unverified download + would break the trust model the update is supposed to preserve. + 4. A machine-readable "latest version" source — the GitHub Releases API + (`/repos/postmeridiem/clide/releases/latest`) is the zero-infra option; a + committed `latest.json` manifest is the alternative. +RECOMMENDATION: split this prerequisite into a sibling story "Release channel: CI +build + signed GitHub Releases + version manifest" and make T-47 depend on it. + +## DECISIONS TO MAKE (surface before building) +1. Check source: GitHub Releases API vs a hosted `latest.json`. (Lean: Releases API — + no extra infra, origin is already GitHub.) +2. Signature scheme + verification: minisign/age/cosign? Where does the public key + live (vendored in-repo, per POLICY.md provenance)? +3. Delivery: full bundle replacement vs delta/binary-patch (original ask). Lean full + for v1 — deltas are a bandwidth optimization, not correctness; revisit if size hurts. +4. Apply strategy per platform: Linux is easy (swap `~/.local/lib/clide/` + the + `~/.local/bin/clide` client atomically, then relaunch). macOS `.app` replacement + + notarization/quarantine handling is harder. Windows out of scope until it ships. +5. Privilege: user-local installs (`~/.local`, `~/Applications`) need no sudo — good. + A system-wide install would; declare user-local only for v1. + +## PROPOSED SCOPE / PHASES (each independently shippable) +P0 (prereq, separate ticket): release channel — tags + CI + signed GitHub Releases. +P1: "Check for updates…" command (palette + About-screen button). Explicit fetch of + the latest release, semver-compare against `clideVersion`, non-intrusive ToastService + notification ("clide X.Y.Z is available") with a "What''s changed" link to the release + notes. No download yet. Clear error toast on network failure. Fully covers the D-64 / + POLICY-compliant "notify" half of the story. +P2: download + signature/checksum verify into a staging dir; show progress; verify before + touching the install. +P3: apply + relaunch (Linux first): atomic swap of bundle + client, restart the app; + tmux sessions survive (D-41). Confirm-before-apply. +P4 (optional): macOS apply path (.app swap + quarantine), delta updates. + +## ACCEPTANCE (for the full story; refine per-phase ticket) +- No network call on any default launch path (verified — grep + a test that boot makes + no outbound connection). +- "Check for updates" only runs on explicit user action; failure surfaces a clear + toast, never a silent hang or degraded launch. +- A downloaded update is signature+checksum verified before it can replace the install; + verification failure aborts with the old version intact. +- Applying an update and relaunching preserves running Claude/tmux sessions. +- Version comparison is correct semver (2.3.10 > 2.3.9, pre-release handling defined). + +## REFERENCES +POLICY.md (network rule + grudging-allowance criteria); D-64 (no phone-home); +D-41 (tmux session persistence); `lib/src/build_info.g.dart` (version source); +`lib/kernel/src/toast.dart` (ToastService — the notification); settings bool pattern +(`app.*.enabled`, `lib/kernel/src/extensions_manager.dart`); `Makefile` install target +(per-platform layout); parent epic T-46 (cross-platform installer). + +DECISION (user, 2026-06-28): MANUAL-ONLY. The update check is a ''Check for updates'' button inside the About-screen box — explicitly user-initiated every time, so NO D-64 amendment is needed (manual checks already comply). A ''check on startup'' checkbox (the opt-in poll) is DEFERRED, not rejected: it would sit next to the button later, and only THEN would it need the narrow, default-off D-64 amendment we discussed (a data-free version fetch; telemetry stays banned full stop). This supersedes any background/periodic-poll framing in the original scope. Build-order caveat: P1 (the About button) still needs the release channel (P0: CI + signed GitHub Releases + a version manifest) to have something to check against — /releases/latest 404s when no Releases are published (the repo has 2 tags, which are NOT Releases). So either P0 lands first, or P1 ships with graceful ''up to date / couldn''t reach GitHub'' handling that no-ops until Releases exist. + +P0 release-channel prerequisite is now T-491 (CI builds + signed package artifacts on Releases). P1 (this About button) is independent of it — Releases already exist to check against; P2/P3 (download+verify+apply) depend on T-491.', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-23 20:28:43', '2026-06-28 10:08:32.993', NULL, '370de61959e76551b58690033f1ba2bd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGVDBD0F14FV5CDJQB063YSR', 'task', '06FB0TNQM7CEKQCMZAV751402G', 'T-47 P1: manual ''Check for updates'' button in the About box', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-28 10:10:21.572', '2026-06-28 10:10:21.572', NULL, '80921aa917352204a904eb22e0799368', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGVDBD0F14FV5CDJQB063YSR', 'task', '06FB0TNQM7CEKQCMZAV751402G', 'T-47 P1: manual ''Check for updates'' button in the About box', NULL, 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-28 10:10:21.572', '2026-06-28 10:10:28.233', NULL, '0d70cf614e033dc45a3dc314641443fd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGVDBD0F14FV5CDJQB063YSR', 'task', '06FB0TNQM7CEKQCMZAV751402G', 'T-47 P1: manual ''Check for updates'' button in the About box', NULL, 'done', 'medium', NULL, NULL, NULL, '2026-06-28 10:10:21.572', '2026-06-28 10:17:18.750', NULL, 'e0006696152740e3fffc98fd67bf7edb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c240661..7b78be72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Check for updates (About box).** Help → About has a manual "Check for + updates" button that compares your version to the latest GitHub release and + links to the notes — explicit and user-initiated, no background polling (the + first and only outbound call clide makes, on your action). (T-47) - **Live-sync markdown read-mirror.** Editing a `.md` in the editor auto-opens a read-only preview in the context panel that mirrors the buffer and re-renders as you type; non-renderable files get no auto-viewer. (T-36, D-50) diff --git a/assets/i18n/en_us/builtin.menubar.json b/assets/i18n/en_us/builtin.menubar.json index 5654cdc5..f702ba1e 100644 --- a/assets/i18n/en_us/builtin.menubar.json +++ b/assets/i18n/en_us/builtin.menubar.json @@ -6,6 +6,11 @@ "about.commit": { "translation": "Commit" }, "about.built": { "translation": "Built" }, "about.repository": { "translation": "Repository" }, + "about.checkUpdates": { "translation": "Check for updates" }, + "about.checking": { "translation": "Checking…" }, + "about.upToDate": { "translation": "You're on the latest version." }, + "about.updateAvailable": { "translation": "clide {version} is available — release notes" }, + "about.updateFailed": { "translation": "Couldn't check for updates" }, "licenses.heading": { "translation": "Bundled dependencies" }, "licenses.unavailable": { "translation": "Licenses unavailable." }, "licenses.loading": { "translation": "Loading…" }, diff --git a/assets/i18n/nl_nl/builtin.menubar.json b/assets/i18n/nl_nl/builtin.menubar.json index d6b8cf08..e56422af 100644 --- a/assets/i18n/nl_nl/builtin.menubar.json +++ b/assets/i18n/nl_nl/builtin.menubar.json @@ -6,6 +6,11 @@ "about.commit": { "translation": "Commit" }, "about.built": { "translation": "Gebouwd" }, "about.repository": { "translation": "Repository" }, + "about.checkUpdates": { "translation": "Controleer op updates" }, + "about.checking": { "translation": "Bezig met controleren…" }, + "about.upToDate": { "translation": "Je hebt de nieuwste versie." }, + "about.updateAvailable": { "translation": "clide {version} is beschikbaar — releaseopmerkingen" }, + "about.updateFailed": { "translation": "Kon niet op updates controleren" }, "licenses.heading": { "translation": "Meegeleverde afhankelijkheden" }, "licenses.unavailable": { "translation": "Licenties niet beschikbaar." }, "licenses.loading": { "translation": "Laden…" }, diff --git a/lib/builtin/menubar/src/about_dialog.dart b/lib/builtin/menubar/src/about_dialog.dart index 4710ece9..29e22faa 100644 --- a/lib/builtin/menubar/src/about_dialog.dart +++ b/lib/builtin/menubar/src/about_dialog.dart @@ -1,16 +1,24 @@ +import 'dart:async'; + import 'package:clide/clide.dart' show clideName, clideTagline, clideVersion, clideRepository, clideCommit, clideDate; import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; import 'licenses_loader.dart'; +import 'update_check.dart'; /// The Help → About dialog (T-48): clide identity + build info, plus the /// bundled-dependency licenses parsed from `assets/licenses.yaml`. class AboutDialog extends StatelessWidget { - const AboutDialog({super.key, required this.onDismiss}); + const AboutDialog({super.key, required this.onDismiss, this.updateFetch}); final VoidCallback onDismiss; + /// Injected fetch for the update check, so widget tests never touch the + /// network (T-47 P1). Production passes null → the real [githubGet]. + @visibleForTesting + final GithubFetch? updateFetch; + @override Widget build(BuildContext context) { final tokens = ClideSettings.theme.of(context).surface; @@ -52,6 +60,8 @@ class AboutDialog extends StatelessWidget { value: clideRepository, tokens: tokens, ), + const SizedBox(height: 14), + _UpdateCheckRow(tokens: tokens, fetch: updateFetch), const SizedBox(height: 16), ClideText( ClideSettings.i18n.string(context, 'licenses.heading', namespace: 'builtin.menubar', placeholder: 'Bundled dependencies'), @@ -76,6 +86,89 @@ class AboutDialog extends StatelessWidget { } } +/// "Check for updates" button + inline status (T-47 P1). The check runs only on +/// this explicit tap — never on launch, never on a timer (D-64 / POLICY.md). +class _UpdateCheckRow extends StatefulWidget { + const _UpdateCheckRow({required this.tokens, this.fetch}); + final SurfaceTokens tokens; + final GithubFetch? fetch; + + @override + State<_UpdateCheckRow> createState() => _UpdateCheckRowState(); +} + +class _UpdateCheckRowState extends State<_UpdateCheckRow> { + UpdateCheckResult? _result; + bool _checking = false; + + Future _check() async { + setState(() { + _checking = true; + _result = null; + }); + final r = await checkForUpdate(repositoryUrl: clideRepository, currentVersion: clideVersion, fetch: widget.fetch ?? githubGet); + if (mounted) { + setState(() { + _checking = false; + _result = r; + }); + } + } + + String _t(String key, String fallback) => ClideSettings.i18n.string(context, key, namespace: 'builtin.menubar', placeholder: fallback); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + ClideButton(label: _t('about.checkUpdates', 'Check for updates'), onPressed: _checking ? null : _check), + const SizedBox(width: 12), + Expanded(child: _status(context)), + ], + ); + } + + Widget _status(BuildContext context) { + final tokens = widget.tokens; + if (_checking) return ClideText(_t('about.checking', 'Checking…'), fontSize: 12, color: tokens.globalTextMuted); + switch (_result) { + case null: + return const SizedBox.shrink(); + case UpdateUpToDate(): + return ClideText(_t('about.upToDate', "You're on the latest version."), fontSize: 12, color: tokens.globalTextMuted); + case UpdateAvailable(:final latest, :final url): + return Semantics( + button: true, + excludeSemantics: true, + label: 'clide $latest available — release notes', + child: ClideTappable( + cursor: SystemMouseCursors.click, + onTap: () => unawaited(ClideKernel.of(context).os.openURL(url)), + builder: (ctx, hovered, _) => ClideText( + ClideSettings.i18n.interpolated( + context, + 'about.updateAvailable', + namespace: 'builtin.menubar', + placeholder: 'clide {version} is available — release notes', + replacers: [I18nReplacer(from: '{version}', replace: latest)], + ), + fontSize: 12, + color: tokens.globalFocus, + ), + ), + ); + case UpdateCheckFailed(:final message): + return ClideText( + '${_t('about.updateFailed', "Couldn't check for updates")} ($message)', + fontSize: 12, + color: tokens.statusError, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + } +} + /// A label/value row in the build-info block. class _Kv extends StatelessWidget { const _Kv({required this.label, required this.value, required this.tokens}); diff --git a/lib/builtin/menubar/src/update_check.dart b/lib/builtin/menubar/src/update_check.dart new file mode 100644 index 00000000..0efa4b55 --- /dev/null +++ b/lib/builtin/menubar/src/update_check.dart @@ -0,0 +1,104 @@ +/// Manual "check for updates" logic for the About dialog (T-47 P1, story T-46). +/// +/// POLICY-sensitive: this is clide's ONLY outbound HTTP call, and it runs ONLY +/// on explicit user action (the About-box button) — never on a launch path, never +/// on a timer. It sends NO data about the user (a plain GET to the GitHub Releases +/// API), so it doesn't offend D-64's no-telemetry commitment. A background/periodic +/// poll would need a deliberate D-64 amendment first and is deferred. +library; + +import 'dart:convert'; +import 'dart:io'; + +/// Injectable GET → response body (throws on failure). Lets the check run +/// against a fake in tests so no widget test touches the network. +typedef GithubFetch = Future Function(Uri url); + +sealed class UpdateCheckResult { + const UpdateCheckResult(); +} + +/// Already on (or ahead of) the latest published release. +class UpdateUpToDate extends UpdateCheckResult { + const UpdateUpToDate(this.current); + final String current; +} + +/// A newer release is available. +class UpdateAvailable extends UpdateCheckResult { + const UpdateAvailable({required this.latest, required this.url}); + final String latest; + final String url; +} + +/// The check couldn't complete (offline, API error, parse failure). The app is +/// fully functional regardless — the failure is surfaced, never silent. +class UpdateCheckFailed extends UpdateCheckResult { + const UpdateCheckFailed(this.message); + final String message; +} + +/// clide's only outbound HTTP — a plain GET, identifying as `clide`, no body. +Future githubGet(Uri url) async { + final client = HttpClient(); + try { + final req = await client.getUrl(url); + req.headers.set(HttpHeaders.userAgentHeader, 'clide'); + req.headers.set(HttpHeaders.acceptHeader, 'application/vnd.github+json'); + final resp = await req.close(); + if (resp.statusCode != 200) throw HttpException('HTTP ${resp.statusCode}'); + return resp.transform(utf8.decoder).join(); + } finally { + client.close(); + } +} + +/// Pull `owner/repo` from a GitHub URL (`https://github.com/owner/repo[.git]`). +({String owner, String repo})? parseGithubRepo(String repositoryUrl) { + final m = RegExp(r'github\.com[/:]([^/]+)/([^/.\s]+)').firstMatch(repositoryUrl); + return m == null ? null : (owner: m.group(1)!, repo: m.group(2)!); +} + +/// Fetch the latest GitHub Release for [repositoryUrl] and compare its version +/// to [currentVersion]. Never throws — failures come back as [UpdateCheckFailed]. +Future checkForUpdate({required String repositoryUrl, required String currentVersion, GithubFetch fetch = githubGet}) async { + final gh = parseGithubRepo(repositoryUrl); + if (gh == null) return const UpdateCheckFailed('unrecognized repository URL'); + try { + final body = await fetch(Uri.parse('https://api.github.com/repos/${gh.owner}/${gh.repo}/releases/latest')); + final json = jsonDecode(body) as Map; + final tag = (json['tag_name'] as String?)?.trim() ?? ''; + final latest = tag.startsWith('v') ? tag.substring(1) : tag; + if (latest.isEmpty) return const UpdateCheckFailed('no release version found'); + final url = (json['html_url'] as String?) ?? repositoryUrl; + return compareSemver(latest, currentVersion) > 0 ? UpdateAvailable(latest: latest, url: url) : UpdateUpToDate(currentVersion); + } catch (e) { + return UpdateCheckFailed('$e'); + } +} + +/// Minimal semver compare → -1/0/1 for ab. Compares +/// major.minor.patch numerically (so 2.3.10 > 2.3.9), and ranks a pre-release +/// BELOW the same release (2.8.2-rc < 2.8.2). Missing components count as 0. +int compareSemver(String a, String b) { + (List, String) parse(String v) { + final dash = v.indexOf('-'); + final core = dash >= 0 ? v.substring(0, dash) : v; + final pre = dash >= 0 ? v.substring(dash + 1) : ''; + final nums = [for (final p in core.split('.')) int.tryParse(p.trim()) ?? 0]; + while (nums.length < 3) { + nums.add(0); + } + return (nums, pre); + } + + final (an, ap) = parse(a); + final (bn, bp) = parse(b); + for (var i = 0; i < 3; i++) { + if (an[i] != bn[i]) return an[i] < bn[i] ? -1 : 1; + } + if (ap.isEmpty && bp.isEmpty) return 0; + if (ap.isEmpty) return 1; // release outranks a pre-release of the same core + if (bp.isEmpty) return -1; + return ap.compareTo(bp); +} diff --git a/test/builtin/menubar/about_dialog_test.dart b/test/builtin/menubar/about_dialog_test.dart new file mode 100644 index 00000000..53f5669d --- /dev/null +++ b/test/builtin/menubar/about_dialog_test.dart @@ -0,0 +1,62 @@ +/// T-47 P1: the About box "Check for updates" button. The check runs ONLY on +/// the explicit tap (never on open — POLICY/D-64), and surfaces the result +/// inline: up-to-date, available (with a release link), or a clear error. +library; + +import 'package:clide/builtin/menubar/src/about_dialog.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + Future pump(WidgetTester tester, Future Function(Uri) fetch) async { + tester.view.physicalSize = const Size(700, 1000); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + await tester.pumpWidget(harness(f, AboutDialog(onDismiss: () {}, updateFetch: fetch))); + await tester.pump(); + } + + testWidgets('does not fetch until the user taps Check for updates (no network on open)', (tester) async { + var calls = 0; + await pump(tester, (_) async { + calls++; + return '{"tag_name":"v2.9.0","html_url":"https://x/r"}'; + }); + expect(calls, 0, reason: 'opening the About box must not touch the network'); + + await tester.tap(find.text('Check for updates')); + await pumpAsync(tester); + expect(calls, 1); + }); + + testWidgets('shows an update-available link when a newer release exists', (tester) async { + await pump(tester, (_) async => '{"tag_name":"v99.0.0","html_url":"https://github.com/postmeridiem/clide/releases/v99.0.0"}'); + await tester.tap(find.text('Check for updates')); + await pumpAsync(tester); + expect(find.textContaining('99.0.0'), findsOneWidget); + }); + + testWidgets('shows up-to-date when the latest release is not newer', (tester) async { + await pump(tester, (_) async => '{"tag_name":"v0.0.1","html_url":"https://x/r"}'); + await tester.tap(find.text('Check for updates')); + await pumpAsync(tester); + expect(find.textContaining('latest version'), findsOneWidget); + }); + + testWidgets('surfaces a clear error when the check fails', (tester) async { + await pump(tester, (_) => Future.error('offline')); + await tester.tap(find.text('Check for updates')); + await pumpAsync(tester); + expect(find.textContaining("Couldn't check"), findsOneWidget); + }); +} diff --git a/test/builtin/menubar/update_check_test.dart b/test/builtin/menubar/update_check_test.dart new file mode 100644 index 00000000..af07e6d9 --- /dev/null +++ b/test/builtin/menubar/update_check_test.dart @@ -0,0 +1,68 @@ +/// T-47 P1: the manual update-check logic — semver comparison, repo parsing, +/// and the GitHub-release check (against a fake fetch, so no test hits the +/// network). Flutter-free. +library; + +import 'package:clide/builtin/menubar/src/update_check.dart'; +import 'package:test/test.dart'; + +void main() { + group('compareSemver', () { + test('compares major.minor.patch numerically (2.3.10 > 2.3.9)', () { + expect(compareSemver('2.3.10', '2.3.9'), 1); + expect(compareSemver('2.3.9', '2.3.10'), -1); + expect(compareSemver('2.8.1', '2.8.1'), 0); + expect(compareSemver('3.0.0', '2.9.9'), 1); + }); + + test('a pre-release ranks below the release of the same core', () { + expect(compareSemver('2.8.2-rc1', '2.8.2'), -1); + expect(compareSemver('2.8.2', '2.8.2-rc1'), 1); + expect(compareSemver('2.8.2-rc2', '2.8.2-rc1'), 1); + }); + + test('missing components count as 0', () { + expect(compareSemver('2.8', '2.8.0'), 0); + }); + }); + + group('parseGithubRepo', () { + test('extracts owner/repo from an https URL', () { + final r = parseGithubRepo('https://github.com/postmeridiem/clide'); + expect(r?.owner, 'postmeridiem'); + expect(r?.repo, 'clide'); + }); + + test('strips a .git suffix and the ssh form; rejects non-github', () { + expect(parseGithubRepo('git@github.com:foo/bar.git')?.repo, 'bar'); + expect(parseGithubRepo('https://gitlab.com/x/y'), isNull); + }); + }); + + group('checkForUpdate', () { + String release(String tag) => '{"tag_name": "$tag", "html_url": "https://github.com/postmeridiem/clide/releases/$tag"}'; + const repo = 'https://github.com/postmeridiem/clide'; + + test('a newer release returns UpdateAvailable with version + url', () async { + final r = await checkForUpdate(repositoryUrl: repo, currentVersion: '2.8.1', fetch: (_) async => release('v2.9.0')); + expect(r, isA()); + expect((r as UpdateAvailable).latest, '2.9.0'); + expect(r.url, contains('releases/v2.9.0')); + }); + + test('the same or older release returns UpToDate', () async { + expect(await checkForUpdate(repositoryUrl: repo, currentVersion: '2.8.1', fetch: (_) async => release('v2.8.1')), isA()); + expect(await checkForUpdate(repositoryUrl: repo, currentVersion: '2.8.1', fetch: (_) async => release('v2.8.0')), isA()); + }); + + test('a fetch failure returns UpdateCheckFailed and never throws', () async { + final r = await checkForUpdate(repositoryUrl: repo, currentVersion: '2.8.1', fetch: (_) => Future.error('offline')); + expect(r, isA()); + }); + + test('an unrecognized repo URL or a tagless response fails cleanly', () async { + expect(await checkForUpdate(repositoryUrl: 'not-a-url', currentVersion: '2.8.1', fetch: (_) async => '{}'), isA()); + expect(await checkForUpdate(repositoryUrl: repo, currentVersion: '2.8.1', fetch: (_) async => '{}'), isA()); + }); + }); +}