diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d107f44..b6a0042 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,9 @@ name: release # Build prebuilt `synaptic` binaries for Linux/macOS/Windows and attach them to -# the GitHub Release when a `v*` tag is pushed. Run manually via workflow_dispatch -# to smoke-test the build matrix without cutting a release. +# the GitHub Release when a `v*` tag is pushed, and sync the `wiki/` directory to +# the GitHub wiki. Run manually via workflow_dispatch to smoke-test the build +# matrix (and re-sync the wiki) without cutting a release. on: push: tags: ["v*"] @@ -47,15 +48,19 @@ jobs: cp README.md LICENSE CHANGELOG.md "$dist/" if [ "${{ runner.os }}" = "Windows" ]; then 7z a "${dist}.zip" "$dist" + certutil -hashfile "${dist}.zip" SHA256 | sed -n '2p' | tr -d ' \r' > "${dist}.zip.sha256" else tar czf "${dist}.tar.gz" "$dist" + shasum -a 256 "${dist}.tar.gz" | awk '{print $1}' > "${dist}.tar.gz.sha256" fi - uses: actions/upload-artifact@v7 with: name: synaptic-${{ matrix.target }} path: | synaptic-${{ matrix.target }}.tar.gz + synaptic-${{ matrix.target }}.tar.gz.sha256 synaptic-${{ matrix.target }}.zip + synaptic-${{ matrix.target }}.zip.sha256 if-no-files-found: ignore release: @@ -71,3 +76,42 @@ jobs: with: files: dist/* generate_release_notes: true + + publish-wiki: + # Sync wiki/ (as committed on the released ref) to the GitHub wiki repo, but + # only commit when it actually differs from what is already published. + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v7 + - name: Sync wiki/ to the GitHub wiki + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + if [ ! -d wiki ]; then + echo "no wiki/ directory; nothing to publish" + exit 0 + fi + tmp="$(mktemp -d)" + # Build the authenticated clone URL without a literal user@host token. + auth="https://x-access-token:${GH_TOKEN}" + repo="${auth}@github.com/${GITHUB_REPOSITORY}.wiki.git" + if ! git clone --depth 1 "$repo" "$tmp"; then + echo "::error::wiki repo not found. Create the first page in the GitHub UI once, then re-run." + exit 1 + fi + # Mirror wiki/ into the wiki repo: delete pages removed from source, + # but never touch the wiki repo's own .git. + rsync -a --delete --exclude '.git' wiki/ "$tmp/" + cd "$tmp" + bot="github-actions[bot]" + git config user.name "$bot" + git config user.email "41898282+${bot}@users.noreply.github.com" + git add -A + if git diff --cached --quiet; then + echo "Wiki already up to date; nothing to publish." + else + git commit -m "docs(wiki): sync from ${GITHUB_REF_NAME} (${GITHUB_SHA})" + git push origin HEAD + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index a1df3f3..4474824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ All notable changes to Synaptic are documented here. The format is based on ## [Unreleased] +### Added +- `self-update` command: opt-in self-replacement from the latest GitHub release, + with SHA-256 checksum verification and a confirmation prompt before the binary + is swapped (`--yes` to skip, `--check` to report availability only). An opt-in + background check (`self-update --enable`) prints a one-line "update available" + notice at most once per day; it is off by default, writes to + `~/.synaptic/update.toml`, and can be force-disabled with + `SYNAPTIC_UPDATE_CHECK=0`. Release archives now publish a `.sha256` sidecar. + ## [0.3.0] - 2026-06-21 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 26f463f..2b84f78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,6 +103,15 @@ dependencies = [ "object", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arcstr" version = "1.2.0" @@ -350,7 +359,7 @@ dependencies = [ "log", "quick-xml", "serde", - "zip", + "zip 7.2.0", ] [[package]] @@ -706,6 +715,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "difflib" version = "0.4.0" @@ -838,6 +858,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -856,6 +886,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ + "crc32fast", "miniz_oxide", "zlib-rs", ] @@ -2433,7 +2464,7 @@ version = "0.95.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f281b687352597d29efaad39701d1167d5c48aa76fb973e392bc13e9d44e7f36" dependencies = [ - "zip", + "zip 7.2.0", ] [[package]] @@ -2529,6 +2560,23 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -3000,6 +3048,7 @@ dependencies = [ "synaptic-skillgen", "synaptic-sqlaudit", "synaptic-synql", + "synaptic-upgrade", "synaptic-workspace", "tempfile", "tokio", @@ -3346,6 +3395,25 @@ dependencies = [ "thiserror", ] +[[package]] +name = "synaptic-upgrade" +version = "0.3.0" +dependencies = [ + "anyhow", + "flate2", + "reqwest", + "self-replace", + "semver", + "serde", + "serde_json", + "sha2 0.11.0", + "tar", + "tempfile", + "thiserror", + "toml", + "zip 2.4.2", +] + [[package]] name = "synaptic-workspace" version = "0.3.0" @@ -3386,6 +3454,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4593,6 +4672,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xxhash-rust" version = "0.8.15" @@ -4702,6 +4791,23 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + [[package]] name = "zip" version = "7.2.0" diff --git a/Cargo.toml b/Cargo.toml index dc972c0..aae741a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ synaptic-predict = { path = "crates/synaptic-predict" } synaptic-sandbox = { path = "crates/synaptic-sandbox" } synaptic-eval = { path = "crates/synaptic-eval" } synaptic-sqlaudit = { path = "crates/synaptic-sqlaudit" } +synaptic-upgrade = { path = "crates/synaptic-upgrade" } clap = { version = "4", features = ["derive"] } anyhow = "1" rayon = "1" @@ -103,6 +104,11 @@ strsim = "0.11" tiktoken-rs = "0.12" sha2 = "0.11" hmac = "0.13" +semver = "1" +self-replace = "1" +zip = { version = "2", default-features = false, features = ["deflate"] } +flate2 = "1" +tar = "0.4" tempfile = "3" criterion = "0.8" proptest = "1" diff --git a/README.md b/README.md index a7a361f..f864ca8 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,13 @@ Neo4j/FalkorDB export), and `office` / `gws` / `media` (spreadsheet / Google-Wor audio-video ingest), e.g. `cargo install --path bin/synaptic --features pg,push`. See [Installation](https://github.com/ColinVaughn/Synaptic/wiki/Installation) and [Configuration](https://github.com/ColinVaughn/Synaptic/wiki/Configuration). +Once installed, update in place with `synaptic self-update` (verifies a SHA-256 +checksum and prompts before replacing the binary). Opt in to a background +"update available" notice with `synaptic self-update --enable` — off by default, +runs at most once a day, and never blocks normal commands. `cargo install` / +source builds can self-update too, but the swap installs the default-feature +prebuilt binary. + ## Quickstart ```sh @@ -311,6 +318,7 @@ A code-only corpus runs fully offline; the optional LLM semantic pass over docs | `hook ` | Manage git hooks + the `graph.json` merge driver | | `install` / `uninstall [platform]` | Install the Synaptic skill for a host assistant | | `cache ` | Maintain the on-disk extraction cache | +| `self-update` | Update the binary from the latest GitHub release (opt-in). Flags: `--enable`/`--disable` (background notice), `--check`, `--yes` | The full reference with every flag is in [Commands](https://github.com/ColinVaughn/Synaptic/wiki/Commands). Run `synaptic --help` for the flag list at the terminal. diff --git a/bin/synaptic/Cargo.toml b/bin/synaptic/Cargo.toml index 6afe584..178c5ea 100644 --- a/bin/synaptic/Cargo.toml +++ b/bin/synaptic/Cargo.toml @@ -59,6 +59,7 @@ synaptic-predict = { workspace = true } synaptic-sandbox = { workspace = true } synaptic-eval = { workspace = true } synaptic-sqlaudit = { workspace = true } +synaptic-upgrade = { workspace = true } clap = { workspace = true } anyhow = { workspace = true } rayon = { workspace = true } diff --git a/bin/synaptic/src/cli.rs b/bin/synaptic/src/cli.rs index db3608e..a5daae3 100644 --- a/bin/synaptic/src/cli.rs +++ b/bin/synaptic/src/cli.rs @@ -475,6 +475,25 @@ pub(crate) enum Cmd { #[command(subcommand)] action: SqlAction, }, + /// Update the Synaptic binary to the latest GitHub release (opt-in). + /// Bare: check and, if newer, prompt to download + replace. `--enable` / + /// `--disable` toggle the background "update available" notice (off by + /// default; persisted to ~/.synaptic/update.toml). `--check` reports + /// availability without downloading. + SelfUpdate { + /// Enable the background update-available notice and exit. + #[arg(long, conflicts_with_all = ["disable", "check", "yes"])] + enable: bool, + /// Disable the background update-available notice and exit. + #[arg(long, conflicts_with_all = ["enable", "check", "yes"])] + disable: bool, + /// Report whether an update is available, then exit (no download). + #[arg(long)] + check: bool, + /// Skip the confirmation prompt before downloading and replacing. + #[arg(long, short = 'y')] + yes: bool, + }, } #[derive(Subcommand)] diff --git a/bin/synaptic/src/commands/mod.rs b/bin/synaptic/src/commands/mod.rs index a662724..baf7c21 100644 --- a/bin/synaptic/src/commands/mod.rs +++ b/bin/synaptic/src/commands/mod.rs @@ -16,6 +16,7 @@ pub(crate) mod prs; pub(crate) mod query; pub(crate) mod refactor; pub(crate) mod search; +pub(crate) mod self_update; pub(crate) mod serve; pub(crate) mod skill; pub(crate) mod speculate; diff --git a/bin/synaptic/src/commands/self_update.rs b/bin/synaptic/src/commands/self_update.rs new file mode 100644 index 0000000..f71e438 --- /dev/null +++ b/bin/synaptic/src/commands/self_update.rs @@ -0,0 +1,72 @@ +//! `self-update` command: opt-in self-replacement from GitHub Releases. + +use std::io::Write; + +use anyhow::{Context, Result}; +use synaptic_upgrade::config::{config_path, UpdateConfig}; +use synaptic_upgrade::{github, releases_url, target, updater, version_is_newer}; + +const CURRENT: &str = env!("CARGO_PKG_VERSION"); + +pub(crate) fn run_self_update(enable: bool, disable: bool, check: bool, yes: bool) -> Result<()> { + if enable || disable { + let path = config_path(); + let mut cfg = UpdateConfig::load(&path).unwrap_or_default(); + cfg.enabled = enable; // exactly one of enable/disable is set (clap-enforced) + cfg.save(&path)?; + println!( + "Background update check {}.", + if enable { "enabled" } else { "disabled" } + ); + return Ok(()); + } + + let release = github::latest_release().context("checking for the latest release")?; + if !version_is_newer(CURRENT, &release.version) { + println!("Synaptic is up to date ({CURRENT})."); + return Ok(()); + } + + let latest = release.version.trim_start_matches('v'); + println!("Update available: {CURRENT} -> {latest}"); + if check { + return Ok(()); + } + + let triple = match target::current_target() { + Some(t) => t, + None => { + println!( + "No prebuilt binary is published for this platform.\n\ + Download or build manually from {}", + releases_url() + ); + return Ok(()); + } + }; + + if !release.notes.trim().is_empty() { + println!("\nRelease notes:\n{}\n", release.notes.trim()); + } + + if !yes && !confirm("Download and replace the current binary? [y/N] ")? { + println!("Aborted."); + return Ok(()); + } + + updater::apply_update(&release, triple)?; + println!("Updated to {latest}. Restart synaptic to use the new version."); + Ok(()) +} + +/// Prompt on stderr, read a line from stdin, return true only for y/yes. +fn confirm(prompt: &str) -> Result { + eprint!("{prompt}"); + std::io::stderr().flush().ok(); + let mut line = String::new(); + std::io::stdin() + .read_line(&mut line) + .context("reading confirmation")?; + let ans = line.trim().to_ascii_lowercase(); + Ok(ans == "y" || ans == "yes") +} diff --git a/bin/synaptic/src/lib.rs b/bin/synaptic/src/lib.rs index d194efa..6f2d3e6 100644 --- a/bin/synaptic/src/lib.rs +++ b/bin/synaptic/src/lib.rs @@ -24,6 +24,7 @@ use commands::prs::run_prs; use commands::query::{run_affected, run_explain, run_path, run_query}; use commands::refactor::run_refactor; use commands::search::run_search; +use commands::self_update::run_self_update; use commands::serve::run_serve; use commands::skill::run_skill; use commands::speculate::run_speculate; @@ -49,6 +50,14 @@ pub fn run_cli() -> Result<()> { fn run() -> Result<()> { let cli = Cli::parse(); + // Opt-in background update notice (off by default; throttled to once a day; + // swallows all errors). Skipped for `self-update` itself so the check can't + // nag mid-update. + if !matches!(cli.cmd, Cmd::SelfUpdate { .. }) { + if let Some(note) = synaptic_upgrade::check::maybe_notify(env!("CARGO_PKG_VERSION")) { + eprintln!("{note}"); + } + } match cli.cmd { Cmd::Extract { path, @@ -259,5 +268,11 @@ fn run() -> Result<()> { }), Cmd::Eval { action } => run_eval(action), Cmd::Sql { action } => commands::sql::run_sql(action), + Cmd::SelfUpdate { + enable, + disable, + check, + yes, + } => run_self_update(enable, disable, check, yes), } } diff --git a/bin/synaptic/tests/self_upgrade_cli.rs b/bin/synaptic/tests/self_upgrade_cli.rs new file mode 100644 index 0000000..548f51a --- /dev/null +++ b/bin/synaptic/tests/self_upgrade_cli.rs @@ -0,0 +1,46 @@ +//! CLI tests for `self-update` that do not touch the network. +//! +//! The file name avoids the substring "update": Windows force-elevates (UAC) any +//! executable whose name contains "update"/"setup"/"install"/"patch", which would +//! break the compiled test binary. + +use assert_cmd::Command; + +#[test] +fn enable_then_disable_writes_config() { + let home = tempfile::tempdir().unwrap(); + + Command::cargo_bin("synaptic") + .unwrap() + .env("HOME", home.path()) + .env("USERPROFILE", home.path()) + .env("SYNAPTIC_UPDATE_CHECK", "0") + .args(["self-update", "--enable"]) + .assert() + .success(); + + let cfg = std::fs::read_to_string(home.path().join(".synaptic/update.toml")).unwrap(); + assert!(cfg.contains("enabled = true"), "config was: {cfg}"); + + Command::cargo_bin("synaptic") + .unwrap() + .env("HOME", home.path()) + .env("USERPROFILE", home.path()) + .env("SYNAPTIC_UPDATE_CHECK", "0") + .args(["self-update", "--disable"]) + .assert() + .success(); + + let cfg = std::fs::read_to_string(home.path().join(".synaptic/update.toml")).unwrap(); + assert!(cfg.contains("enabled = false"), "config was: {cfg}"); +} + +#[test] +fn enable_and_disable_conflict() { + Command::cargo_bin("synaptic") + .unwrap() + .env("SYNAPTIC_UPDATE_CHECK", "0") + .args(["self-update", "--enable", "--disable"]) + .assert() + .failure(); +} diff --git a/crates/synaptic-upgrade/Cargo.toml b/crates/synaptic-upgrade/Cargo.toml new file mode 100644 index 0000000..78321d7 --- /dev/null +++ b/crates/synaptic-upgrade/Cargo.toml @@ -0,0 +1,26 @@ +[package] +# Crate name avoids the substrings "update"/"setup"/"install"/"patch": the +# Windows installer-detection heuristic force-elevates (UAC) any executable whose +# file name contains them, which would break this crate's own test binary. The +# user-facing command is still `synaptic self-update`. +name = "synaptic-upgrade" +edition.workspace = true +version.workspace = true +license.workspace = true +rust-version.workspace = true +description = "Opt-in self-update for the Synaptic CLI (GitHub Releases)." + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +sha2 = { workspace = true } +semver = { workspace = true } +reqwest = { workspace = true, features = ["blocking"] } +zip = { workspace = true } +flate2 = { workspace = true } +tar = { workspace = true } +self-replace = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/synaptic-upgrade/src/check.rs b/crates/synaptic-upgrade/src/check.rs new file mode 100644 index 0000000..26f24ef --- /dev/null +++ b/crates/synaptic-upgrade/src/check.rs @@ -0,0 +1,136 @@ +//! The opt-in, throttled background update check. + +use std::path::Path; + +use crate::config::{config_path, UpdateConfig}; +use crate::github::{latest_release, Release}; +use crate::version::version_is_newer; + +/// Current epoch seconds (lives here so deterministic code stays clock-free). +fn now_secs() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +/// Background check called once per CLI invocation. Returns a one-line notice to +/// print to stderr when an update is available, or `None`. Never blocks for long +/// and never errors: any network/parse failure is swallowed (returns `None`). +/// +/// Honors a hard override: `SYNAPTIC_UPDATE_CHECK=0` disables the check even when +/// the config is enabled (e.g. for CI). +pub fn maybe_notify(current_version: &str) -> Option { + if std::env::var("SYNAPTIC_UPDATE_CHECK").as_deref() == Ok("0") { + return None; + } + maybe_notify_with(&config_path(), current_version, now_secs(), || { + latest_release().ok() + }) +} + +/// Testable core: explicit config path, clock, and release fetcher. +pub fn maybe_notify_with( + path: &Path, + current_version: &str, + now: f64, + fetch: impl FnOnce() -> Option, +) -> Option { + let mut cfg = UpdateConfig::load(path).unwrap_or_default(); + if !cfg.enabled || !cfg.due(now) { + return None; + } + // Stamp before fetching so a flaky network does not retry every invocation. + cfg.last_check = Some(now); + let _ = cfg.save(path); + + let latest = fetch()?; + if version_is_newer(current_version, &latest.version) { + Some(format!( + "(note) Synaptic {} is available - run `synaptic self-update`", + latest.version.trim_start_matches('v') + )) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::UpdateConfig; + use crate::github::Release; + + fn rel(v: &str) -> Release { + Release { + version: v.into(), + notes: String::new(), + assets: vec![], + } + } + + #[test] + fn disabled_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("update.toml"); + UpdateConfig { + enabled: false, + last_check: None, + } + .save(&path) + .unwrap(); + let out = maybe_notify_with(&path, "0.3.0", 1000.0, || Some(rel("0.9.9"))); + assert!(out.is_none()); + } + + #[test] + fn throttled_returns_none_and_does_not_fetch() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("update.toml"); + UpdateConfig { + enabled: true, + last_check: Some(1000.0), + } + .save(&path) + .unwrap(); + let mut fetched = false; + let out = maybe_notify_with(&path, "0.3.0", 1000.0 + 60.0, || { + fetched = true; + Some(rel("9.9.9")) + }); + assert!(out.is_none()); + assert!(!fetched, "should not fetch within the throttle window"); + } + + #[test] + fn enabled_and_due_with_newer_returns_notice_and_stamps() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("update.toml"); + UpdateConfig { + enabled: true, + last_check: None, + } + .save(&path) + .unwrap(); + let out = maybe_notify_with(&path, "0.3.0", 5000.0, || Some(rel("v0.3.1"))); + assert!(out.unwrap().contains("0.3.1")); + // last_check advanced so the next call is throttled. + let cfg = UpdateConfig::load(&path).unwrap(); + assert_eq!(cfg.last_check, Some(5000.0)); + } + + #[test] + fn up_to_date_returns_none_but_still_stamps() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("update.toml"); + UpdateConfig { + enabled: true, + last_check: None, + } + .save(&path) + .unwrap(); + let out = maybe_notify_with(&path, "0.3.1", 5000.0, || Some(rel("0.3.1"))); + assert!(out.is_none()); + assert_eq!(UpdateConfig::load(&path).unwrap().last_check, Some(5000.0)); + } +} diff --git a/crates/synaptic-upgrade/src/config.rs b/crates/synaptic-upgrade/src/config.rs new file mode 100644 index 0000000..8bcb227 --- /dev/null +++ b/crates/synaptic-upgrade/src/config.rs @@ -0,0 +1,107 @@ +//! The persisted opt-in config at `~/.synaptic/update.toml`. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// Seconds between background update checks (24h). +const CHECK_INTERVAL_SECS: f64 = 86_400.0; + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct UpdateConfig { + /// Whether the background update check runs. + #[serde(default)] + pub enabled: bool, + /// Epoch seconds of the last completed background check (f64 for parity with + /// the manifest `mtime` convention used elsewhere in the project). + #[serde(default)] + pub last_check: Option, +} + +impl UpdateConfig { + /// Load the config, returning the default (disabled) when the file is absent. + pub fn load(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(text) => { + toml::from_str(&text).with_context(|| format!("parsing {}", path.display())) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), + } + } + + /// Persist the config, creating the parent directory if needed. + pub fn save(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating {}", parent.display()))?; + } + let text = toml::to_string_pretty(self).context("serializing update config")?; + std::fs::write(path, text).with_context(|| format!("writing {}", path.display())) + } + + /// Whether a background check is due as of `now` (epoch seconds). + pub fn due(&self, now: f64) -> bool { + match self.last_check { + None => true, + Some(t) => now - t >= CHECK_INTERVAL_SECS, + } + } +} + +/// The default config path: `~/.synaptic/update.toml` +/// (`%USERPROFILE%\.synaptic\update.toml` on Windows). Falls back to +/// `.synaptic/update.toml` in the CWD when no home directory is set. Mirrors the +/// home resolution used by the global graph store. +pub fn config_path() -> PathBuf { + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from); + let base = match home { + Some(h) => h.join(".synaptic"), + None => PathBuf::from(".synaptic"), + }; + base.join("update.toml") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_to_disabled_when_missing() { + let dir = tempfile::tempdir().unwrap(); + let cfg = UpdateConfig::load(&dir.path().join("update.toml")).unwrap(); + assert!(!cfg.enabled); + assert!(cfg.last_check.is_none()); + } + + #[test] + fn round_trips_through_disk() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("update.toml"); + let cfg = UpdateConfig { + enabled: true, + last_check: Some(1_700_000_000.0), + }; + cfg.save(&path).unwrap(); + let back = UpdateConfig::load(&path).unwrap(); + assert_eq!(back, cfg); + } + + #[test] + fn due_respects_24h_window() { + let never = UpdateConfig { + enabled: true, + last_check: None, + }; + assert!(never.due(1_000.0)); + let fresh = UpdateConfig { + enabled: true, + last_check: Some(1_000.0), + }; + assert!(!fresh.due(1_000.0 + 3_600.0)); // 1h later: not due + assert!(fresh.due(1_000.0 + 86_400.0)); // exactly 24h later: due + } +} diff --git a/crates/synaptic-upgrade/src/github.rs b/crates/synaptic-upgrade/src/github.rs new file mode 100644 index 0000000..71bb41e --- /dev/null +++ b/crates/synaptic-upgrade/src/github.rs @@ -0,0 +1,112 @@ +//! Fetch the latest GitHub release metadata for the Synaptic repo. + +use anyhow::{Context, Result}; +use serde::Deserialize; + +use crate::{REPO_NAME, REPO_OWNER}; + +/// One downloadable release asset. +#[derive(Debug, Clone, PartialEq)] +pub struct Asset { + pub name: String, + pub url: String, +} + +/// The subset of a GitHub release we use. +#[derive(Debug, Clone, PartialEq)] +pub struct Release { + /// The tag name, e.g. "v0.3.1". + pub version: String, + /// The release notes body (may be empty). + pub notes: String, + pub assets: Vec, +} + +// Wire shapes matching the GitHub REST response. +#[derive(Deserialize)] +struct WireRelease { + tag_name: String, + #[serde(default)] + body: Option, + #[serde(default)] + assets: Vec, +} + +#[derive(Deserialize)] +struct WireAsset { + name: String, + browser_download_url: String, +} + +/// Parse a `/releases/latest` JSON body into a [`Release`]. +pub fn parse_latest(json: &str) -> Result { + let w: WireRelease = serde_json::from_str(json).context("parsing release JSON")?; + Ok(Release { + version: w.tag_name, + notes: w.body.unwrap_or_default(), + assets: w + .assets + .into_iter() + .map(|a| Asset { + name: a.name, + url: a.browser_download_url, + }) + .collect(), + }) +} + +/// Fetch the latest release from GitHub. Short timeout; honors `GITHUB_TOKEN` +/// (optional, raises the anonymous rate limit). Network failures are surfaced as +/// errors for the caller to handle (the background check swallows them). +pub fn latest_release() -> Result { + let url = format!("https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest"); + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .user_agent(concat!("synaptic-update/", env!("CARGO_PKG_VERSION"))) + .build() + .context("building HTTP client")?; + let mut req = client + .get(&url) + .header("Accept", "application/vnd.github+json"); + if let Ok(token) = std::env::var("GITHUB_TOKEN") { + if !token.is_empty() { + req = req.header("Authorization", format!("Bearer {token}")); + } + } + let resp = req.send().context("requesting latest release")?; + let resp = resp + .error_for_status() + .context("GitHub returned an error")?; + let body = resp.text().context("reading release body")?; + parse_latest(&body) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#"{ + "tag_name": "v0.3.1", + "body": "Changes - fix things", + "assets": [ + {"name": "synaptic-x86_64-unknown-linux-gnu.tar.gz", + "browser_download_url": "https://example.com/a.tar.gz"}, + {"name": "synaptic-x86_64-unknown-linux-gnu.tar.gz.sha256", + "browser_download_url": "https://example.com/a.sha256"} + ] + }"#; + + #[test] + fn parses_release_metadata() { + let r = parse_latest(SAMPLE).unwrap(); + assert_eq!(r.version, "v0.3.1"); + assert_eq!(r.notes, "Changes - fix things"); + assert_eq!(r.assets.len(), 2); + let a = r + .assets + .iter() + .find(|a| a.name.ends_with(".tar.gz")) + .unwrap(); + assert_eq!(a.url, "https://example.com/a.tar.gz"); + } +} diff --git a/crates/synaptic-upgrade/src/lib.rs b/crates/synaptic-upgrade/src/lib.rs new file mode 100644 index 0000000..5f28556 --- /dev/null +++ b/crates/synaptic-upgrade/src/lib.rs @@ -0,0 +1,26 @@ +//! Opt-in self-update for the Synaptic CLI. +//! +//! Nothing here touches the network or the binary unless the user has opted in +//! (`self-update --enable` for the background check) or explicitly run the +//! update command. See the [`check`] module for the throttled background notice +//! and the [`updater`] module for the actual download/verify/replace pipeline. + +pub mod check; +pub mod config; +pub mod github; +pub mod target; +pub mod updater; +pub mod version; + +pub use config::{config_path, UpdateConfig}; +pub use github::{latest_release, Asset, Release}; +pub use version::version_is_newer; + +/// The repository self-update queries for releases. +pub const REPO_OWNER: &str = "ColinVaughn"; +pub const REPO_NAME: &str = "Synaptic"; + +/// Human-facing releases page, shown when no prebuilt asset fits the platform. +pub fn releases_url() -> String { + format!("https://github.com/{REPO_OWNER}/{REPO_NAME}/releases") +} diff --git a/crates/synaptic-upgrade/src/target.rs b/crates/synaptic-upgrade/src/target.rs new file mode 100644 index 0000000..28f66f4 --- /dev/null +++ b/crates/synaptic-upgrade/src/target.rs @@ -0,0 +1,69 @@ +//! Map the running platform to the release asset built by +//! `.github/workflows/release.yml`. + +/// The release archive file name for a target triple. +/// Windows targets ship a `.zip`; everything else ships a `.tar.gz`. +pub fn archive_name(triple: &str) -> String { + if triple.contains("windows") { + format!("synaptic-{triple}.zip") + } else { + format!("synaptic-{triple}.tar.gz") + } +} + +/// The checksum sidecar name for a target triple. +pub fn sha256_name(triple: &str) -> String { + format!("{}.sha256", archive_name(triple)) +} + +/// The bare binary file name inside the archive (`synaptic`/`synaptic.exe`). +pub fn binary_name(stem: &str) -> String { + if cfg!(windows) { + format!("{stem}.exe") + } else { + stem.to_string() + } +} + +/// The target triple for the running platform, or `None` when no prebuilt +/// release asset is published for it. The four arms match the release matrix. +pub fn current_target() -> Option<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"), + ("macos", "aarch64") => Some("aarch64-apple-darwin"), + ("macos", "x86_64") => Some("x86_64-apple-darwin"), + ("windows", "x86_64") => Some("x86_64-pc-windows-msvc"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unix_triple_uses_tar_gz() { + assert_eq!( + archive_name("x86_64-unknown-linux-gnu"), + "synaptic-x86_64-unknown-linux-gnu.tar.gz" + ); + assert_eq!( + sha256_name("x86_64-unknown-linux-gnu"), + "synaptic-x86_64-unknown-linux-gnu.tar.gz.sha256" + ); + } + + #[test] + fn windows_triple_uses_zip() { + assert_eq!( + archive_name("x86_64-pc-windows-msvc"), + "synaptic-x86_64-pc-windows-msvc.zip" + ); + } + + #[test] + fn current_target_is_some_on_supported_hosts() { + // The CI matrix only runs on the four supported targets. + assert!(current_target().is_some()); + } +} diff --git a/crates/synaptic-upgrade/src/updater.rs b/crates/synaptic-upgrade/src/updater.rs new file mode 100644 index 0000000..ae25d13 --- /dev/null +++ b/crates/synaptic-upgrade/src/updater.rs @@ -0,0 +1,249 @@ +//! Download the matching release archive, verify its checksum, extract the +//! binaries, and atomically replace the running executable. + +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, bail, Context, Result}; +use sha2::{Digest, Sha256}; + +use crate::github::{Asset, Release}; +use crate::target::{archive_name, binary_name, sha256_name}; + +/// Find a release asset by exact file name. +pub fn find_asset<'a>(release: &'a Release, name: &str) -> Option<&'a Asset> { + release.assets.iter().find(|a| a.name == name) +} + +/// Verify `bytes` hashes to the hex digest at the start of `expected` +/// (sidecar files are often `" "`). Case-insensitive. +pub fn verify_sha256(bytes: &[u8], expected: &str) -> bool { + let want = match expected.split_whitespace().next() { + Some(t) => t.to_ascii_lowercase(), + None => return false, + }; + let mut h = Sha256::new(); + h.update(bytes); + let got = h.finalize(); + let got_hex: String = got.iter().map(|b| format!("{b:02x}")).collect(); + got_hex == want +} + +/// Download `url` into memory (blocking). +fn download(url: &str) -> Result> { + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .user_agent(concat!("synaptic-update/", env!("CARGO_PKG_VERSION"))) + .build()?; + let resp = client.get(url).send().context("downloading asset")?; + let resp = resp.error_for_status().context("download failed")?; + Ok(resp.bytes().context("reading download body")?.to_vec()) +} + +/// Extract the binary named `stem` (e.g. "synaptic") from a release archive into +/// `dest_dir`, returning the written path. Dispatches on the archive extension. +pub fn extract_binary(archive: &Path, stem: &str, dest_dir: &Path) -> Result { + let want = binary_name(stem); + let name = archive.to_string_lossy(); + if name.ends_with(".zip") { + extract_from_zip(archive, &want, dest_dir) + } else { + extract_from_tar_gz(archive, &want, dest_dir) + } +} + +fn extract_from_tar_gz(archive: &Path, want: &str, dest_dir: &Path) -> Result { + let f = File::open(archive).with_context(|| format!("opening {}", archive.display()))?; + let mut ar = tar::Archive::new(flate2::read::GzDecoder::new(f)); + for entry in ar.entries().context("reading tar entries")? { + let mut e = entry.context("reading tar entry")?; + let path = e.path().context("entry path")?.into_owned(); + if path.file_name().and_then(|s| s.to_str()) == Some(want) { + let out = dest_dir.join(want); + e.unpack(&out) + .with_context(|| format!("unpacking {want}"))?; + return Ok(out); + } + } + bail!("{want} not found in {}", archive.display()) +} + +fn extract_from_zip(archive: &Path, want: &str, dest_dir: &Path) -> Result { + let f = File::open(archive).with_context(|| format!("opening {}", archive.display()))?; + let mut zip = zip::ZipArchive::new(f).context("opening zip")?; + for i in 0..zip.len() { + let mut file = zip.by_index(i).context("reading zip entry")?; + let name = file.enclosed_name(); + let matches = name + .as_ref() + .and_then(|p| p.file_name()) + .and_then(|s| s.to_str()) + == Some(want); + if matches { + let out = dest_dir.join(want); + let mut buf = Vec::new(); + file.read_to_end(&mut buf).context("reading zip member")?; + std::fs::write(&out, &buf).with_context(|| format!("writing {want}"))?; + return Ok(out); + } + } + bail!("{want} not found in {}", archive.display()) +} + +#[cfg(unix)] +fn make_executable(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(path)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(path, perms)?; + Ok(()) +} + +#[cfg(not(unix))] +fn make_executable(_path: &Path) -> Result<()> { + Ok(()) +} + +/// Run the full update: download + verify + extract + replace the running exe +/// (and best-effort the sibling alias). `triple` is the current platform target. +pub fn apply_update(release: &Release, triple: &str) -> Result<()> { + let archive_file = archive_name(triple); + let asset = find_asset(release, &archive_file) + .ok_or_else(|| anyhow!("release {} has no asset {archive_file}", release.version))?; + + println!("Downloading {archive_file} ..."); + let bytes = download(&asset.url)?; + + // Verify against the sha256 sidecar when present; warn (don't fail) if the + // release predates checksum publishing. + match find_asset(release, &sha256_name(triple)) { + Some(sidecar) => { + let sum = download(&sidecar.url)?; + let sum = String::from_utf8_lossy(&sum); + if !verify_sha256(&bytes, &sum) { + bail!("checksum mismatch for {archive_file} - aborting"); + } + println!("Checksum verified."); + } + None => { + eprintln!("warning: no checksum published for this release; skipping verification") + } + } + + let tmp = tempfile::tempdir().context("creating temp dir")?; + let archive_path = tmp.path().join(&archive_file); + std::fs::write(&archive_path, &bytes).context("writing archive to temp")?; + + // Replace the running executable (whatever its name) with the matching new + // binary, then best-effort replace the sibling alias. + let current = std::env::current_exe().context("resolving current exe")?; + let cur_stem = if current + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s == "syn") + .unwrap_or(false) + { + "syn" + } else { + "synaptic" + }; + let sibling_stem = if cur_stem == "syn" { "synaptic" } else { "syn" }; + + let new_self = extract_binary(&archive_path, cur_stem, tmp.path())?; + make_executable(&new_self)?; + self_replace::self_replace(&new_self).context("replacing running executable")?; + + if let Ok(new_sibling) = extract_binary(&archive_path, sibling_stem, tmp.path()) { + let _ = make_executable(&new_sibling); + let sibling_path = current.with_file_name(binary_name(sibling_stem)); + if let Err(e) = std::fs::copy(&new_sibling, &sibling_path) { + eprintln!( + "warning: updated {cur_stem} but could not update the {sibling_stem} alias at {}: {e}", + sibling_path.display() + ); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::github::{Asset, Release}; + + #[test] + fn verify_sha256_matches_and_rejects() { + let bytes = b"hello world"; + // sha256("hello world") + let good = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"; + assert!(verify_sha256(bytes, good)); + // sidecar files often look like " filename" + assert!(verify_sha256(bytes, &format!("{good} synaptic.tar.gz"))); + assert!(!verify_sha256(bytes, "deadbeef")); + } + + #[test] + fn find_asset_by_name() { + let r = Release { + version: "v1".into(), + notes: String::new(), + assets: vec![ + Asset { + name: "a.zip".into(), + url: "u1".into(), + }, + Asset { + name: "b.tar.gz".into(), + url: "u2".into(), + }, + ], + }; + assert_eq!(find_asset(&r, "b.tar.gz").unwrap().url, "u2"); + assert!(find_asset(&r, "missing").is_none()); + } + + #[cfg(unix)] + #[test] + fn extracts_named_binary_from_tar_gz() { + use flate2::write::GzEncoder; + use flate2::Compression; + let dir = tempfile::tempdir().unwrap(); + let archive = dir.path().join("a.tar.gz"); + { + let f = std::fs::File::create(&archive).unwrap(); + let enc = GzEncoder::new(f, Compression::default()); + let mut tar = tar::Builder::new(enc); + let mut header = tar::Header::new_gnu(); + let data = b"#!/bin/true\n"; + header.set_size(data.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + tar.append_data(&mut header, "synaptic-x/synaptic", &data[..]) + .unwrap(); + tar.into_inner().unwrap().finish().unwrap(); + } + let out = extract_binary(&archive, "synaptic", dir.path()).unwrap(); + assert!(out.exists()); + assert_eq!(std::fs::read(&out).unwrap(), b"#!/bin/true\n"); + } + + #[cfg(windows)] + #[test] + fn extracts_named_binary_from_zip() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let archive = dir.path().join("a.zip"); + { + let f = std::fs::File::create(&archive).unwrap(); + let mut zip = zip::ZipWriter::new(f); + let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default(); + zip.start_file("synaptic-x/synaptic.exe", opts).unwrap(); + zip.write_all(b"MZ binary").unwrap(); + zip.finish().unwrap(); + } + let out = extract_binary(&archive, "synaptic", dir.path()).unwrap(); + assert!(out.exists()); + assert_eq!(std::fs::read(&out).unwrap(), b"MZ binary"); + } +} diff --git a/crates/synaptic-upgrade/src/version.rs b/crates/synaptic-upgrade/src/version.rs new file mode 100644 index 0000000..f51e282 --- /dev/null +++ b/crates/synaptic-upgrade/src/version.rs @@ -0,0 +1,38 @@ +//! Semver comparison tolerant of a leading `v` (release tags are `vX.Y.Z`). + +use semver::Version; + +fn parse(s: &str) -> Option { + Version::parse(s.trim().trim_start_matches('v')).ok() +} + +/// Whether `latest` is a strictly newer semantic version than `current`. +/// Returns `false` if either string fails to parse (never offers a bad update). +pub fn version_is_newer(current: &str, latest: &str) -> bool { + match (parse(current), parse(latest)) { + (Some(c), Some(l)) => l > c, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn newer_patch_is_newer() { + assert!(version_is_newer("0.3.0", "0.3.1")); + assert!(version_is_newer("0.3.0", "v0.3.1")); // leading v tolerated + } + + #[test] + fn same_or_older_is_not_newer() { + assert!(!version_is_newer("0.3.1", "0.3.1")); + assert!(!version_is_newer("0.3.2", "0.3.1")); + } + + #[test] + fn unparseable_is_not_newer() { + assert!(!version_is_newer("0.3.0", "not-a-version")); + } +} diff --git a/wiki/Commands.md b/wiki/Commands.md index eab6d65..31b92f6 100644 --- a/wiki/Commands.md +++ b/wiki/Commands.md @@ -31,6 +31,7 @@ Most read commands operate on `synaptic-out/graph.json` by default; build it fir | [`global`](#global) | Manage the cross-repo global graph store (`~/.synaptic`). | | [`merge-graphs`](#merge-graphs) | Compose several `graph.json` files into one namespaced graph. | | [`cache`](#cache) | Maintain the on-disk extraction cache. | +| [`self-update`](#self-update) | Update the binary from the latest GitHub release (opt-in). | There is also an internal `merge-driver` command. It is hidden from `--help` and invoked by git, not users; see [`hook`](#hook). @@ -993,6 +994,40 @@ synaptic cache clear . --recursive See [Extraction](Extraction). +## self-update + +Update the `synaptic` binary in place from the latest [GitHub Release](../../releases). This is **opt-in**: Synaptic never checks for updates or replaces the binary unless you run this command or enable the background notice below. See [Updating](Updating) for the full walkthrough. + +Syntax: + +```sh +synaptic self-update [--enable | --disable] [--check] [--yes] +``` + +| Name | Default | Description | +| --- | --- | --- | +| `--enable` | off | Turn on the once-a-day "update available" notice and exit. Writes `~/.synaptic/update.toml`; no network. | +| `--disable` | off | Turn the background notice off and exit. | +| `--check` | off | Report whether a newer release exists, then exit without downloading. | +| `--yes` / `-y` | off | Skip the confirmation prompt before downloading and replacing. | + +`--enable` and `--disable` cannot be combined with each other or with `--check`/`--yes` — they only toggle the background notice and exit. + +With no flags, `self-update` queries the latest release and compares it to the running version. If it is not newer it prints `Synaptic is up to date ()` and exits. If it is newer it shows the version delta and release notes, prompts `Download and replace the current binary? [y/N]`, then (on confirmation, or with `--yes`) downloads the prebuilt archive for your platform, verifies its SHA-256 checksum when one is published, and atomically replaces the running binary plus its `syn` alias. The new version takes effect on the next invocation. + +If no prebuilt binary exists for your platform, `self-update` prints the releases URL and exits without changing anything. A source/`cargo install` build can self-update, but the swap installs the default-feature prebuilt binary (rebuild from source to keep extra Cargo features). + +Examples: + +```sh +synaptic self-update # interactive: check, confirm, replace +synaptic self-update --check # just report availability (scriptable) +synaptic self-update --yes # unattended update +synaptic self-update --enable # opt in to the daily reminder +``` + +The background notice is throttled to once per 24 hours, prints a single line to stderr, swallows network errors, and can be force-disabled with `SYNAPTIC_UPDATE_CHECK=0`. See [Updating](Updating) and [Configuration](Configuration). + ## merge-driver (internal) `synaptic merge-driver ` is a git merge driver for `graph.json`. It is hidden from `--help` and invoked by git as `%O %A %B`, not by users. It union-composes both sides into `CURRENT` so `graph.json` never conflicts. It is registered automatically by `synaptic hook install`. diff --git a/wiki/Configuration.md b/wiki/Configuration.md index 61a7363..0e69354 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -72,6 +72,8 @@ OpenAI, DeepSeek, Azure OpenAI, Bedrock, Ollama. Set `SYNAPTIC_BACKEND` to force |---|---| | `HOME` / `USERPROFILE` | Locate the global store `~/.synaptic` (falls back to `.synaptic` in the working directory) | | `SYNAPTIC_SKIP_HOOK` | Skip the installed git hook for one invocation (`1`) | +| `SYNAPTIC_UPDATE_CHECK` | Set to `0` to force the opt-in background update notice off, regardless of config. See [Updating](Updating) | +| `GITHUB_TOKEN` | Optional. Raises the GitHub API rate limit for the `self-update` release lookup | ## Config and output files @@ -84,6 +86,7 @@ OpenAI, DeepSeek, Azure OpenAI, Bedrock, Ollama. Set `SYNAPTIC_BACKEND` to force | `synaptic-out/cache/ast/` | written | Per-file AST cache, keyed by content; auto-invalidated when extractor logic or enabled languages change. Clear with `synaptic cache clear` | | `synaptic-out/cache/semantic/` | written | Semantic-pass response cache | | `~/.synaptic/` | read/written | Global cross-repo store (`global-graph.json`, `global-manifest.json`) | +| `~/.synaptic/update.toml` | read/written | Opt-in self-update state: `enabled` (background notice) and `last_check` (24h throttle). Written by `synaptic self-update --enable`/`--disable`. See [Updating](Updating) | | `.claude/settings.json` | read/written | `PreToolUse` hooks installed by `synaptic install` (Claude). See [Assistant Integration](Assistant-Integration) | | `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` and per-platform skill files | written | Assistant instruction sections written by `install` | | `.codex/config.toml` / `.codex/hooks.json` (+ `~/.codex/config.toml`) | read/written | Codex MCP server + `SessionStart` hook from `synaptic install codex` (project, or global with `--global`). See [Assistant Integration](Assistant-Integration) | diff --git a/wiki/Home.md b/wiki/Home.md index d72b5d2..307dc6b 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -68,7 +68,7 @@ get the binary. ## Documentation map -**Getting started:** [Installation](Installation) - [Quickstart](Quickstart) +**Getting started:** [Installation](Installation) - [Quickstart](Quickstart) - [Updating](Updating) **Concepts:** [Architecture](Architecture) - [Languages](Languages) - [Synaptic vs Other Tools](Synaptic-vs-Other-Tools) @@ -87,8 +87,10 @@ get the binary. ## Design principles -- **Offline by default.** A code-only corpus makes no network calls. Only the opt-in - `--semantic` pass over documents calls an LLM ([Semantic Analysis](Semantic-Analysis)). +- **Offline by default.** A code-only corpus makes no network calls. The only network + features are opt-in and explicit: the `--semantic` pass over documents calls an LLM + ([Semantic Analysis](Semantic-Analysis)), and `self-update` (with its optional daily + notice) contacts GitHub ([Updating](Updating)). - **Auditable.** Every edge carries a confidence level (`EXTRACTED`, `INFERRED`, `AMBIGUOUS`). - **Deterministic.** The same input produces the same graph; ids and community numbers are diff --git a/wiki/Installation.md b/wiki/Installation.md index 8f5f3c8..8a7fb72 100644 --- a/wiki/Installation.md +++ b/wiki/Installation.md @@ -25,6 +25,26 @@ Tagged releases attach prebuilt binaries for Linux (`x86_64`), macOS (`x86_64` a `aarch64`), and Windows (`x86_64`) to the [GitHub Releases](../../releases) page. Each archive bundles the `synaptic` binary plus the README, LICENSE, and CHANGELOG. +## Updating + +Once installed, update in place with: + +```sh +synaptic self-update +``` + +This checks the latest [GitHub Release](../../releases), and if it is newer, prompts you +before downloading the prebuilt archive for your platform, verifying its checksum, and +replacing the running binary (and its `syn` alias). Updating is **opt-in** — Synaptic never +checks or replaces itself on its own. To get a once-a-day "update available" reminder on +ordinary commands, opt in with `synaptic self-update --enable` (off by default, throttled, +and printed only to stderr). + +A `cargo install` / source build can self-update too, but the swap installs the +default-feature prebuilt binary; rebuild from source if you depend on extra features. See +[Updating](Updating) for the full walkthrough and [`self-update`](Commands#self-update) for +the flag reference. + ## Optional features Several integrations are gated behind Cargo features and are **off by default**, so the diff --git a/wiki/Updating.md b/wiki/Updating.md new file mode 100644 index 0000000..5de728f --- /dev/null +++ b/wiki/Updating.md @@ -0,0 +1,143 @@ +# Updating + +Synaptic can update itself in place from the latest [GitHub Release](../../releases). The +update system is **opt-in**: Synaptic never contacts the network or replaces the binary on +its own. You either run `synaptic self-update` explicitly, or enable a once-a-day +"update available" notice that only ever prints a one-line reminder. + +This page covers the whole system. For the bare command reference see +[`self-update`](Commands#self-update) in [Commands](Commands); for the files and environment +variables involved see [Configuration](Configuration). + +## At a glance + +```sh +synaptic self-update # check; if a newer release exists, prompt then replace +synaptic self-update --check # report whether an update is available, then exit +synaptic self-update --yes # update without the confirmation prompt +synaptic self-update --enable # turn on the daily "update available" notice +synaptic self-update --disable # turn the notice back off +``` + +Nothing here runs unless you ask for it. `--enable`/`--disable` only write a small config +file and need no network. + +## How an update works + +Running `synaptic self-update` performs these steps: + +1. **Check.** Query `releases/latest` for `ColinVaughn/Synaptic` and compare the tag to the + running version (a leading `v` is tolerated; the comparison is semantic-version aware). If + the latest release is not newer, it prints `Synaptic is up to date ().` and exits. +2. **Confirm.** If a newer release exists, it prints the version delta and the release notes, + then asks `Download and replace the current binary? [y/N]`. Answer `y`/`yes` to proceed. + `--yes` (or `-y`) skips this prompt for scripts. +3. **Download.** Fetch the prebuilt archive that matches your platform (see + [Platform support](#platform-support)). +4. **Verify.** If the release publishes a `.sha256` checksum next to the archive, the + download is verified against it; a mismatch **aborts** the update before anything is + replaced. Releases made before checksums were published have no sidecar, so verification + is skipped with a printed warning rather than failing. +5. **Replace.** Extract the binary and atomically replace the currently running executable. + The `syn` short alias next to it is updated too. + +The new version takes effect the next time you run Synaptic — the already-running process +keeps the old code in memory, so the command finishes with +`Updated to . Restart synaptic to use the new version.` + +If anything fails (network, checksum mismatch, write error), the existing binary is left +untouched: the download is verified before the swap, never overwritten in place first. + +## The opt-in background notice + +By default Synaptic never checks for updates. Turn the reminder on with: + +```sh +synaptic self-update --enable +``` + +Once enabled, ordinary commands occasionally check for a newer release in the background and, +if one exists, print a single line to **stderr** and then continue normally: + +``` +(note) Synaptic 0.3.1 is available - run `synaptic self-update` +``` + +Properties of the background check: + +- **Throttled.** It runs at most once every 24 hours. The timestamp of the last check is + stored in the config file, so a burst of commands triggers only one check. +- **Non-blocking and silent on failure.** It uses a short timeout, and any network or parse + error is swallowed — a flaky connection never slows down or breaks a normal command. The + timestamp still advances so a failed check is not retried on every invocation. +- **stderr only.** The notice goes to stderr, so it never corrupts machine-readable stdout + (for example a `--json` result or the `serve` MCP stream). +- **Never on `self-update` itself.** The check is skipped while you run the update command. + +Turn it back off at any time: + +```sh +synaptic self-update --disable +``` + +### Disabling the check without changing config + +Set `SYNAPTIC_UPDATE_CHECK=0` to force the background check off even when the config has it +enabled. This is useful in CI or any non-interactive environment: + +```sh +SYNAPTIC_UPDATE_CHECK=0 synaptic query "..." +``` + +The variable only affects the background notice; it does not change what `synaptic +self-update` does when you run it explicitly. + +## Checksum verification + +When a release publishes a `synaptic-.tar.gz.sha256` (or `.zip.sha256`) sidecar next +to its archive, `self-update` downloads it and verifies the SHA-256 of the downloaded archive +before replacing the binary. A mismatch aborts the update. Synaptic's release workflow +publishes these sidecars; releases predating that workflow have none, in which case +verification is skipped with a warning so updating still works against older releases. + +## Platform support + +`self-update` downloads the prebuilt archive matching your platform: + +| Platform | Release asset | +|---|---| +| Linux x86_64 | `synaptic-x86_64-unknown-linux-gnu.tar.gz` | +| macOS Apple Silicon | `synaptic-aarch64-apple-darwin.tar.gz` | +| macOS Intel | `synaptic-x86_64-apple-darwin.tar.gz` | +| Windows x86_64 | `synaptic-x86_64-pc-windows-msvc.zip` | + +On any other platform there is no prebuilt binary to install. `self-update` detects this, +prints the [Releases](../../releases) URL so you can download or build manually, and exits +without changing anything. + +## Updating a source build + +`self-update` replaces whatever binary is currently running, including one produced by +`cargo install --path bin/synaptic` or `cargo build`. Note that the replacement is the +**default-feature** prebuilt binary. If you built with extra Cargo features (for example +`pg`, `push`, `office`, `gws`, `media`, or `live-explain`; see [Installation](Installation) +and [Configuration](Configuration)), self-updating swaps in a binary that does not have them. +Rebuild from source with your features instead of self-updating in that case. + +## Rate limits and tokens + +The check uses GitHub's anonymous REST API, which is rate-limited per IP. If you hit the +limit (for example on a shared CI runner), set `GITHUB_TOKEN` to a token and Synaptic will +send it to raise the limit. The token is optional and only used to authenticate the release +lookup. + +## Files and variables + +| Path / variable | Role | +|---|---| +| `~/.synaptic/update.toml` | Stores `enabled` (the opt-in flag) and `last_check` (the throttle timestamp). Written by `--enable`/`--disable`. On Windows this is `%USERPROFILE%\.synaptic\update.toml`; with no home directory it falls back to `.synaptic/update.toml` in the working directory. | +| `SYNAPTIC_UPDATE_CHECK` | Set to `0` to force the background notice off regardless of config. | +| `GITHUB_TOKEN` | Optional. Raises the GitHub API rate limit for the release lookup. | + +See [Configuration](Configuration) for the full list of files and environment variables, and +[Commands](Commands#self-update) for the command reference. diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md index df2fabd..1a87e04 100644 --- a/wiki/_Sidebar.md +++ b/wiki/_Sidebar.md @@ -4,6 +4,7 @@ - [Home](Home) - [Installation](Installation) - [Quickstart](Quickstart) +- [Updating](Updating) **Concepts** - [Architecture](Architecture)