diff --git a/.gitignore b/.gitignore index 33b21b2..2527f53 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ docs/local/ # workspace .devcontainer/ -.vscode/ \ No newline at end of file +.vscode/ +AGENTS.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 22d0732..72cb204 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1474,6 +1474,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -3125,6 +3146,15 @@ dependencies = [ "yamux 0.13.10", ] +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + [[package]] name = "linux-keyutils" version = "0.2.5" @@ -3503,6 +3533,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "outref" version = "0.5.2" @@ -4081,6 +4117,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.12.3" @@ -5345,17 +5392,19 @@ checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" [[package]] name = "vyn-cli" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "clap", "comfy-table", "console", "dialoguer", + "dirs", "ignore", "indicatif", "reqwest", "secrecy", + "semver", "serde", "serde_json", "tokio", @@ -5368,9 +5417,12 @@ dependencies = [ [[package]] name = "vyn-core" -version = "0.1.2" +version = "0.1.3" dependencies = [ "age", + "anyhow", + "dirs", + "hex", "ignore", "keyring", "libp2p", @@ -5384,6 +5436,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", + "toml 0.8.23", "tonic", "uuid", "vyn-relay", @@ -5392,7 +5445,7 @@ dependencies = [ [[package]] name = "vyn-relay" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 9223745..c551645 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.2" +version = "0.1.3" repository = "https://github.com/arnonsang/vyn" [workspace.dependencies] @@ -18,6 +18,9 @@ anyhow = "1" aws-config = "1" aws-sdk-s3 = "1" clap = { version = "4", features = ["derive"] } +dirs = "6" +hex = "0.4" +semver = "1" comfy-table = "7" console = "0.15" dialoguer = "0.11" diff --git a/README.md b/README.md index bf450d1..aca6524 100644 --- a/README.md +++ b/README.md @@ -12,16 +12,20 @@

+> **This project is under active development.** Until v1.0.0 is released, any version bump may include breaking changes to the CLI interface, config file format, relay protocol, or storage layout. Pin your version if you depend on stable behavior. ## Highlights - AES-256-GCM encryption for all synced blobs and manifests - GitHub OAuth Device Flow identity (no passwords, no manual username entry) -- SSH-based project key sharing via age (invite files encrypted for recipient) -- Local vault metadata under `.vyn/` (never committed to Git) +- SSH-based project key sharing via age - invite embeds vault ID, relay URL, and key so recipients can onboard in one command +- `vyn.toml` - non-secret public config committed to Git; enables zero-config `vyn pull` and `vyn clone` +- One-step onboarding: `vyn clone` - finds invite, imports key, pulls all files +- Relay inspection: `vyn relay status` and `vyn relay ls` +- Local vault metadata under `.vyn/` - Diff and status against encrypted baseline - Self-hosted relay server with optional S3 mirroring -- P2P module available in vyn-core (not yet exposed via CLI) +- P2P module available in vyn-core (Soon) ## Table of Contents @@ -88,14 +92,14 @@ vyn --help ## Quick Start ```bash -# 1. Initialize a vault +# 1. Initialize a vault (creates .vyn/ and a public vyn.toml then commit it!) vyn init my-project -# 2. Configure storage (do this before auth if using relay — auth registers your identity on the relay) +# 2. Configure storage (do this before auth if using relay, auth registers your identity on the relay) vyn config # 3. Authenticate: GitHub OAuth Device Flow + SSH key verification -# If relay storage is configured, your identity is registered on the relay automatically. +# Writes .vyn/identity.toml AND ~/.vyn/identity.toml (global, used by vyn clone). vyn auth # 4. Push encrypted state @@ -105,9 +109,25 @@ vyn push vyn pull ``` +### Joining an existing vault (one step) + +```bash +mkdir my-project && cd my-project +vyn clone https://relay.example.com +# Reads ~/.vyn/identity.toml, fetches invite, stores key, pulls all files. +``` + ## Configuration -Primary config file: `.vyn/config.toml` +`vyn.toml` (committed to Git) - public vault config: + +```toml +# vyn.toml (this file is committed to Git and contains non-secret config) +vault_id = "" +relay_url = "https://relay.example.com" +``` + +Primary private config file: `.vyn/config.toml` (not committed): ```toml vault_id = "" @@ -251,6 +271,7 @@ Initialize a new vault in the current directory. Fails with an error if a vault - Generates a random vault UUID and AES-256-GCM project key - Stores the project key in the OS keychain - Writes `.vyn/manifest.json` (initial file index) and `.vyn/config.toml` +- Writes `vyn.toml` in the project root (contains `vault_id` only; non-secret, commit to Git) - Adds `.vyn/` to `.gitignore` (creates the file if absent) - Copies `.vynignore.example` to `.vynignore` if an example is present @@ -268,7 +289,22 @@ Authenticate your local identity using GitHub OAuth and a local SSH key. Runs a If your key is not on GitHub yet, `vyn auth` prints the key and tells you exactly where to add it. -Writes `.vyn/identity.toml` on success. +Writes `.vyn/identity.toml` (local) and `~/.vyn/identity.toml` (global). The global copy lets `vyn clone` work from any empty directory without running auth again. + +--- + +#### vyn clone \ \ + +Clone a vault from a relay onto this machine in one step. The most convenient way to join an existing vault. + +- Reads `~/.vyn/identity.toml` (global identity set by any previous `vyn auth`) +- Authenticates with the relay using your SSH key +- Fetches and decrypts the invite for your GitHub username +- Stores the vault project key in the OS keychain +- Writes `.vyn/config.toml` and `vyn.toml` with correct relay URL and vault ID +- Runs `vyn pull` automatically to download all files + +Requires a teammate to have run `vyn share @you` first. --- @@ -328,22 +364,24 @@ Show a unified diff against the baseline manifest. #### vyn share @user -Create encrypted invite files for a GitHub user so they can join the vault. +Create an encrypted invite for a GitHub user so they can join the vault. - Fetches SSH public keys from `https://github.com/.keys` -- Wraps the project key for each key using `age` -- Writes invite files to `.vyn/invites/____.age` +- Wraps the project key, vault ID, and relay URL together for each key using `age` +- Uploads the invite ciphertext to the relay + +The invite embeds all connection metadata, so the recipient can run `vyn clone` or `vyn link` without needing the vault ID or relay URL separately. --- -#### vyn link +#### vyn link \ Decrypt an invite and import the project key into the keychain. -- Reads invite files from `.vyn/invites/` matching `____*.age` +- Fetches invites from the relay matching `__` - Uses the private key path from `.vyn/identity.toml` to unwrap the invite -- Stores the project key in the OS keychain under the linked vault ID -- Rewrites `vault_id` in `.vyn/config.toml` to the linked vault's ID so subsequent `vyn push`/`vyn pull` targets the correct remote vault +- Stores the project key in the OS keychain +- Bootstraps `.vyn/config.toml` and `vyn.toml` from metadata embedded in the invite (relay URL auto-populated) --- @@ -361,9 +399,19 @@ See environment variable table in [Relay Deployment](#relay-deployment). --- +#### vyn relay status / vyn relay ls + +Relay inspection commands. Require an authenticated vault (run `vyn auth` + `vyn config` first). + +- `vyn relay status` - check connectivity, show identity, verify auth against the configured relay +- `vyn relay ls` - list all vault IDs on the relay +- `vyn relay ls ` - list blob hashes and sizes inside a specific vault + +--- + ### Utility Commands -#### vyn run -- \ +#### vyn run \ Run a subprocess with env vars injected from `.env` files and encrypted vault blobs. @@ -416,9 +464,19 @@ Rotate the project key and re-encrypt all remote state. - Rebuilds invite files for known teammates - Writes a history entry +--- + +#### vyn update + +Check for a newer version of `vyn` and print upgrade instructions. + +- Compares local version against the latest GitHub release tag +- Detects install method (pre-built binary, `cargo install`, Docker) and prints the correct update command +- `--check`: only check and report whether an update is available, without printing instructions + ## How It Works -### Auth + Share + Link Flow +### Auth + Share + Clone Flow ```mermaid sequenceDiagram @@ -427,7 +485,7 @@ sequenceDiagram participant GH as GitHub participant FS as .vyn files participant C2 as vyn share - participant C3 as vyn link + participant C3 as vyn clone participant KC as OS Keychain U->>C1: vyn auth @@ -436,18 +494,22 @@ sequenceDiagram C1->>GH: GET /.keys GH-->>C1: registered SSH public keys C1->>C1: ssh-keygen challenge-response (prove key ownership) - C1->>FS: write .vyn/identity.toml + C1->>FS: write .vyn/identity.toml + ~/.vyn/identity.toml U->>C2: vyn share @teammate C2->>KC: load project key C2->>GH: GET /teammate.keys GH-->>C2: SSH public keys - C2->>FS: write encrypted invites (.age) - - U->>C3: vyn link - C3->>FS: read invite + identity private key path - C3->>KC: store linked project key - C3->>FS: rewrite vault_id in config.toml + C2->>C2: wrap key + vault_id + relay_url with age + C2->>FS: upload invite to relay + + U->>C3: vyn clone + C3->>FS: read ~/.vyn/identity.toml + C3->>FS: fetch invite from relay + C3->>C3: decrypt invite with SSH private key + C3->>KC: store project key + C3->>FS: write .vyn/config.toml + vyn.toml + C3->>C3: run vyn pull ``` ### Push/Pull with Relay Storage @@ -500,11 +562,42 @@ Full MVP command set is implemented and tested: - Local vault lifecycle: `init`, `st`, `diff`, `config`, `doctor` - Sync: `push`, `pull`, `history` -- Identity + sharing: `auth` (OAuth + SSH verify), `share`, `link` +- Identity + sharing: `auth` (OAuth + SSH verify), `share`, `link`, `clone` +- Key rotation: `rotate` (re-encrypts all remote state with a new project key) +- **v0.1.3:** `clone` (one-step onboarding), `relay status`, `relay ls`, `vyn.toml` public config, `update` (version check + upgrade instructions) - Env management: `run`, `check` - Relay server: `serve` with local and S3-mirror backends - Docker / Docker Compose deployment ready The P2P module (`vyn-core::p2p`) is compiled into the library but not yet exposed via CLI commands. +### Planned Improvements + +#### Security / Privacy +- [ ] `vyn revoke @user` - remove a teammate's invite from the relay and optionally trigger key rotation; currently there is no way to un-share +- [ ] Invite expiry - time-bound invites (`--expires 7d`) so stale entries on the relay don't accumulate +- [ ] Relay audit log - record who authenticated and which operations ran (no plaintext logged) +- [ ] Passphrase-protected vault - derive PK via Argon2 as an alternative to the OS keychain, so backups work without keychain access + +#### Onboarding / Team UX +- [ ] `vyn whoami` - print current identity: github username, SSH key path, relay URL, vault ID +- [ ] `vyn team` - list who has been granted access (reads invite list from relay) +- [ ] `vyn invite` link - generate a short token URL a teammate can paste for one-click `vyn clone` (no out-of-band vault_id sharing) + +#### Storage / Transport +- [ ] P2P mode - complete the `libp2p` stub with mDNS discovery + Gossipsub for zero-latency LAN sync +- [ ] `vyn conflicts` - list and interactively resolve conflict-marker files left by `vyn pull` +- [ ] Selective push/pull - `vyn push .env.production` / `vyn pull .env.staging` for single-file sync + +#### CI / Automation +- [ ] `vyn env print` - dump decrypted key=value to stdout for CI env injection without subprocess exec +- [ ] Non-interactive auth - `vyn auth --token ` for headless CI environments +- [ ] GitHub Actions action - `uses: arnonsang/vyn-action@v1` wrapping install + auth + pull + +#### Developer Experience +- [ ] Shell completions - `vyn completions bash|zsh|fish` (clap can generate these) +- [ ] `vyn config --edit` - open `.vyn/config.toml` in `$EDITOR` directly +- [ ] Progress bars on push/pull - show bytes transferred for large blob sets (`indicatif` crate) +- [ ] `vyn add` interactive prompt - show which files will be tracked vs. ignored before writing `.vynignore` + diff --git a/crates/vyn-cli/Cargo.toml b/crates/vyn-cli/Cargo.toml index 2cc6d64..0b11ac7 100644 --- a/crates/vyn-cli/Cargo.toml +++ b/crates/vyn-cli/Cargo.toml @@ -20,12 +20,14 @@ dialoguer.workspace = true ignore.workspace = true indicatif.workspace = true walkdir.workspace = true +dirs.workspace = true reqwest = { workspace = true, features = ["blocking"] } +semver.workspace = true serde.workspace = true serde_json.workspace = true secrecy.workspace = true tokio.workspace = true toml.workspace = true uuid.workspace = true -vyn-core = { path = "../vyn-core", version = "0.1.1" } -vyn-relay = { path = "../vyn-relay", version = "0.1.1" } +vyn-core = { path = "../vyn-core", version = "0.1.3" } +vyn-relay = { path = "../vyn-relay", version = "0.1.3" } diff --git a/crates/vyn-cli/src/commands/auth.rs b/crates/vyn-cli/src/commands/auth.rs index 1b5dec2..1ac2a07 100644 --- a/crates/vyn-cli/src/commands/auth.rs +++ b/crates/vyn-cli/src/commands/auth.rs @@ -8,6 +8,7 @@ use uuid::Uuid; use anyhow::{Context, Result}; use console::style; +use dirs; use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; use vyn_core::relay_storage::RelayStorageProvider; @@ -87,11 +88,19 @@ pub fn run() -> Result<()> { let vault_dir = root.join(".vyn"); fs::create_dir_all(&vault_dir).context("failed to create .vyn directory")?; let identity_path = vault_dir.join("identity.toml"); - fs::write( - &identity_path, - toml::to_string_pretty(&identity).context("failed to encode identity config")?, - ) - .with_context(|| format!("failed to write {}", identity_path.display()))?; + let identity_toml = + toml::to_string_pretty(&identity).context("failed to encode identity config")?; + fs::write(&identity_path, &identity_toml) + .with_context(|| format!("failed to write {}", identity_path.display()))?; + + // Also write to ~/.vyn/identity.toml as a global identity for `vyn clone`. + if let Some(home) = dirs::home_dir() { + let global_vyn_dir = home.join(".vyn"); + if fs::create_dir_all(&global_vyn_dir).is_ok() { + let global_identity_path = global_vyn_dir.join("identity.toml"); + let _ = fs::write(&global_identity_path, &identity_toml); + } + } // Register identity on the relay if this vault is configured for relay storage let relay_registered = try_register_on_relay(&vault_dir); diff --git a/crates/vyn-cli/src/commands/clone.rs b/crates/vyn-cli/src/commands/clone.rs new file mode 100644 index 0000000..841e130 --- /dev/null +++ b/crates/vyn-cli/src/commands/clone.rs @@ -0,0 +1,178 @@ +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use vyn_core::keychain::store_project_key; +use vyn_core::relay_storage::RelayStorageProvider; +use vyn_core::storage::StorageProvider; +use vyn_core::wrapping::unwrap_invite_with_ssh_identity_file; + +use crate::output; + +#[derive(Debug, Deserialize, Serialize)] +struct IdentityConfig { + github_username: String, + ssh_private_key: String, + ssh_public_key: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct VaultConfig { + vault_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + project_name: Option, + storage_provider: String, + #[serde(skip_serializing_if = "Option::is_none")] + relay_url: Option, +} + +/// `vyn clone ` +/// +/// Full onboarding flow: +/// 1. Verify identity.toml exists. +/// 2. Create .vyn/config.toml with relay_url + vault_id. +/// 3. Fetch invite from relay, decrypt, store key in keychain. +/// 4. Write vyn.toml (committed public file). +/// 5. Run pull to download all blobs. +pub fn run(relay_url: String, vault_id: String) -> Result<()> { + output::print_banner("clone"); + let root = std::env::current_dir().context("failed to determine current directory")?; + let vault_dir = root.join(".vyn"); + + // Step 1: identity must exist. + let identity = load_identity(&vault_dir) + .context("no identity found -- run `vyn auth` first to register your GitHub identity")?; + + // Step 2: bootstrap .vyn/config.toml. + fs::create_dir_all(&vault_dir).context("failed to create .vyn directory")?; + + // Ensure identity.toml is present in the local .vyn/ dir for relay auth. + let identity_path = vault_dir.join("identity.toml"); + if !identity_path.exists() { + let identity_toml = + toml::to_string_pretty(&identity).context("failed to serialize identity")?; + fs::write(&identity_path, identity_toml).context("failed to write .vyn/identity.toml")?; + } + + let config_path = vault_dir.join("config.toml"); + let initial_cfg = VaultConfig { + vault_id: vault_id.clone(), + project_name: None, + storage_provider: "relay".to_string(), + relay_url: Some(relay_url.clone()), + }; + fs::write( + &config_path, + toml::to_string_pretty(&initial_cfg).context("failed to serialize config")?, + ) + .context("failed to write .vyn/config.toml")?; + + // Step 3: fetch and decrypt invite. + let runtime = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?; + let invite = runtime.block_on(async { + let provider = RelayStorageProvider::new(relay_url.clone()); + let spinner_auth = output::new_spinner("authenticating with relay…"); + provider + .authenticate_with_identity(&vault_dir) + .await + .context("relay authentication failed (run `vyn auth` first)")?; + output::finish_progress(&spinner_auth, "authenticated"); + + let spinner = output::new_spinner("fetching invite from relay…"); + let invites = provider + .get_invites(&identity.github_username, &vault_id) + .await + .context("failed to fetch invites from relay")?; + + if invites.is_empty() { + output::fail_progress(&spinner, "no invite found"); + anyhow::bail!( + "no invite found for @{u} in vault {vault_id}\n\ + Ask a teammate to run: vyn share @{u}", + u = identity.github_username + ); + } + output::finish_progress(&spinner, &format!("{} invite(s) found", invites.len())); + + let spinner2 = output::new_spinner("decrypting invite…"); + let invite = invites + .iter() + .find_map(|payload| { + unwrap_invite_with_ssh_identity_file(payload, Path::new(&identity.ssh_private_key)) + .ok() + }) + .with_context(|| { + format!( + "failed to decrypt any invite with SSH key at {}", + identity.ssh_private_key + ) + })?; + output::finish_progress(&spinner2, "invite decrypted"); + + Ok::<_, anyhow::Error>(invite) + })?; + + // Update vault_id from invite if embedded (handles server-assigned IDs). + let resolved_vault_id = if !invite.vault_id.is_empty() { + invite.vault_id.clone() + } else { + vault_id.clone() + }; + + // Store vault key in keychain. + store_project_key(&resolved_vault_id, &invite.key) + .context("failed to store project key in keychain")?; + + // Update config with any relay_url embedded in the invite. + let final_relay_url = invite.relay_url.as_deref().unwrap_or(&relay_url); + let final_cfg = VaultConfig { + vault_id: resolved_vault_id.clone(), + project_name: None, + storage_provider: "relay".to_string(), + relay_url: Some(final_relay_url.to_string()), + }; + if let Ok(serialized) = toml::to_string_pretty(&final_cfg) { + let _ = fs::write(&config_path, serialized); + } + + // Step 4: write vyn.toml (public committed file). + let vyn_toml_path = root.join("vyn.toml"); + if !vyn_toml_path.exists() { + let vyn_toml = format!( + "# Public vault config -- commit this file.\n\ + vault_id = \"{resolved_vault_id}\"\n\ + relay_url = \"{final_relay_url}\"\n" + ); + let _ = fs::write(&vyn_toml_path, vyn_toml); + } + + output::print_success(&format!("vault {resolved_vault_id} linked")); + output::print_info("identity", &format!("@{}", identity.github_username)); + output::print_info("relay", final_relay_url); + output::print_info("key stored", "OS keychain"); + println!(); + + // Step 5: pull files. + output::print_info("next", "pulling files…"); + crate::commands::pull::run() +} + +fn load_identity(vault_dir: &Path) -> Result { + // Check local .vyn/identity.toml first, then fall back to ~/.vyn/identity.toml. + let local_path = vault_dir.join("identity.toml"); + if local_path.exists() { + let text = fs::read_to_string(&local_path) + .with_context(|| format!("failed to read {}", local_path.display()))?; + return toml::from_str(&text).context("invalid identity.toml format"); + } + if let Some(home) = dirs::home_dir() { + let global_path = home.join(".vyn").join("identity.toml"); + if global_path.exists() { + let text = fs::read_to_string(&global_path) + .with_context(|| format!("failed to read {}", global_path.display()))?; + return toml::from_str(&text).context("invalid identity.toml format"); + } + } + anyhow::bail!("missing identity.toml at {}", local_path.display()) +} diff --git a/crates/vyn-cli/src/commands/config.rs b/crates/vyn-cli/src/commands/config.rs index b6e7448..d5c202e 100644 --- a/crates/vyn-cli/src/commands/config.rs +++ b/crates/vyn-cli/src/commands/config.rs @@ -38,6 +38,9 @@ pub fn run(opts: ConfigOptions) -> Result<()> { ) .with_context(|| format!("failed to write {}", config_path.display()))?; + // Keep vyn.toml in sync with relay_url (vault_id never changes after init). + update_vyn_toml(&root, &config.relay_url); + println!( "config updated: provider={} vault_id={}", config.storage_provider, config.vault_id @@ -134,3 +137,28 @@ fn normalize_opt(value: String) -> Option { Some(trimmed.to_string()) } } + +/// Updates the `relay_url` field in `vyn.toml` (committed file). Best-effort. +fn update_vyn_toml(root: &Path, relay_url: &Option) { + let path = root.join("vyn.toml"); + if !path.exists() { + return; + } + let Ok(text) = fs::read_to_string(&path) else { + return; + }; + // Remove existing relay_url line, collect the rest. + let mut lines: Vec = text + .lines() + .filter(|l| !l.starts_with("relay_url")) + .map(str::to_string) + .collect(); + if let Some(url) = relay_url { + lines.push(format!("relay_url = \"{url}\"")); + } + let mut new_content = lines.join("\n"); + if !new_content.ends_with('\n') { + new_content.push('\n'); + } + let _ = fs::write(path, new_content); +} diff --git a/crates/vyn-cli/src/commands/doctor.rs b/crates/vyn-cli/src/commands/doctor.rs index accb3a7..a3e7e23 100644 --- a/crates/vyn-cli/src/commands/doctor.rs +++ b/crates/vyn-cli/src/commands/doctor.rs @@ -7,6 +7,7 @@ use vyn_core::keychain::load_project_key; use vyn_core::manifest::Manifest; use crate::output; +use crate::version::{VersionStatus, check_for_update}; #[derive(Debug, Deserialize)] struct VaultConfig { @@ -56,6 +57,20 @@ pub fn run() -> Result<()> { fn run_checks(root: &Path) -> Result> { let mut out = Vec::new(); + // Version check is always first -- most immediately actionable info. + let current = env!("CARGO_PKG_VERSION"); + match check_for_update(true) { + VersionStatus::UpdateAvailable(latest) => out.push(fail( + "cli_version", + &format!("vyn v{current} installed, v{latest} available -- run 'vyn update'"), + )), + VersionStatus::UpToDate => out.push(ok("cli_version", &format!("vyn v{current} (latest)"))), + VersionStatus::CheckFailed => out.push(ok( + "cli_version", + &format!("vyn v{current} (could not check for updates)"), + )), + } + let vault_dir = root.join(".vyn"); if vault_dir.exists() && vault_dir.is_dir() { out.push(ok("vault_directory", ".vyn exists")); diff --git a/crates/vyn-cli/src/commands/init.rs b/crates/vyn-cli/src/commands/init.rs index e0106f6..a36d4ba 100644 --- a/crates/vyn-cli/src/commands/init.rs +++ b/crates/vyn-cli/src/commands/init.rs @@ -164,6 +164,16 @@ pub fn run(name: Option) -> Result<()> { ); fs::write(&config_path, config).context("failed to write config.toml")?; + // Write vyn.toml to repo root (committed, no secrets -- vault_id only). + let vyn_toml_path = root.join("vyn.toml"); + if !vyn_toml_path.exists() { + let vyn_toml = format!( + "# Public vault config -- commit this file.\n\ + vault_id = \"{vault_id}\"\n" + ); + fs::write(&vyn_toml_path, vyn_toml).context("failed to write vyn.toml")?; + } + ensure_gitignore_contains_vyn(&root)?; output::print_success(&format!("vault '{project_name}' initialized")); diff --git a/crates/vyn-cli/src/commands/link.rs b/crates/vyn-cli/src/commands/link.rs index 6771b9c..b1423d7 100644 --- a/crates/vyn-cli/src/commands/link.rs +++ b/crates/vyn-cli/src/commands/link.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use vyn_core::keychain::store_project_key; use vyn_core::relay_storage::RelayStorageProvider; use vyn_core::storage::StorageProvider; -use vyn_core::wrapping::unwrap_project_key_with_ssh_identity_file; +use vyn_core::wrapping::unwrap_invite_with_ssh_identity_file; use crate::output; @@ -59,14 +59,11 @@ pub fn run(vault_id: String) -> Result<()> { output::finish_progress(&spinner, &format!("{} invite(s) found", invites.len())); let spinner2 = output::new_spinner("decrypting invite…"); - let key = invites + let invite = invites .iter() .find_map(|payload| { - unwrap_project_key_with_ssh_identity_file( - payload, - Path::new(&identity.ssh_private_key), - ) - .ok() + unwrap_invite_with_ssh_identity_file(payload, Path::new(&identity.ssh_private_key)) + .ok() }) .with_context(|| { format!( @@ -76,21 +73,50 @@ pub fn run(vault_id: String) -> Result<()> { })?; output::finish_progress(&spinner2, "invite decrypted"); - store_project_key(&vault_id, &key).context("failed to store project key in keychain")?; + let resolved_vault_id = if !invite.vault_id.is_empty() { + invite.vault_id.clone() + } else { + vault_id.clone() + }; + + store_project_key(&resolved_vault_id, &invite.key) + .context("failed to store project key in keychain")?; + // Bootstrap .vyn/ config from embedded invite metadata. let config_path = vault_dir.join("config.toml"); - if let Ok(text) = fs::read_to_string(&config_path) - && let Ok(mut cfg) = toml::from_str::(&text) + let storage_provider = if invite.relay_url.is_some() { + "relay" + } else { + "unconfigured" + }; + let mut cfg = if let Ok(text) = fs::read_to_string(&config_path) + && let Ok(existing) = toml::from_str::(&text) { - cfg.vault_id = vault_id.clone(); - if let Ok(serialized) = toml::to_string_pretty(&cfg) { - let _ = fs::write(&config_path, serialized); + existing + } else { + VaultConfig { + vault_id: resolved_vault_id.clone(), + project_name: None, + storage_provider: storage_provider.to_string(), + relay_url: invite.relay_url.clone(), } + }; + cfg.vault_id = resolved_vault_id.clone(); + if invite.relay_url.is_some() { + cfg.relay_url = invite.relay_url.clone(); + cfg.storage_provider = "relay".to_string(); + } + fs::create_dir_all(&vault_dir).context("failed to create .vyn directory")?; + if let Ok(serialized) = toml::to_string_pretty(&cfg) { + let _ = fs::write(&config_path, serialized); } - output::print_success(&format!("vault {vault_id} linked")); + output::print_success(&format!("vault {resolved_vault_id} linked")); output::print_info("identity", &format!("@{}", identity.github_username)); output::print_info("key stored", "OS keychain"); + if let Some(ref url) = invite.relay_url { + output::print_info("relay", url); + } println!(); Ok::<(), anyhow::Error>(()) diff --git a/crates/vyn-cli/src/commands/mod.rs b/crates/vyn-cli/src/commands/mod.rs index cdb9000..ca5ed75 100644 --- a/crates/vyn-cli/src/commands/mod.rs +++ b/crates/vyn-cli/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod add; pub mod auth; pub mod check; +pub mod clone; pub mod config; pub mod del; pub mod diff; @@ -10,8 +11,10 @@ pub mod init; pub mod link; pub mod pull; pub mod push; +pub mod relay; pub mod rotate; pub mod run; pub mod serve; pub mod share; pub mod status; +pub mod update; diff --git a/crates/vyn-cli/src/commands/pull.rs b/crates/vyn-cli/src/commands/pull.rs index ffd9a34..a6295f8 100644 --- a/crates/vyn-cli/src/commands/pull.rs +++ b/crates/vyn-cli/src/commands/pull.rs @@ -133,9 +133,31 @@ pub fn run() -> Result<()> { fn load_config(root: &Path) -> Result { let path = root.join(".vyn").join("config.toml"); - let text = fs::read_to_string(&path) - .with_context(|| format!("missing or unreadable file: {}", path.display()))?; - toml::from_str(&text).context("invalid .vyn/config.toml format") + if path.exists() { + let text = fs::read_to_string(&path) + .with_context(|| format!("missing or unreadable file: {}", path.display()))?; + return toml::from_str(&text).context("invalid .vyn/config.toml format"); + } + // Fallback: read vault_id (and optionally relay_url) from committed vyn.toml. + let vyn_toml = root.join("vyn.toml"); + let text = fs::read_to_string(&vyn_toml) + .context("no .vyn/config.toml found; run `vyn clone` or `vyn link` first")?; + #[derive(serde::Deserialize)] + struct PublicConfig { + vault_id: String, + relay_url: Option, + } + let pc: PublicConfig = toml::from_str(&text).context("invalid vyn.toml format")?; + let storage_provider = if pc.relay_url.is_some() { + "relay".to_string() + } else { + "unconfigured".to_string() + }; + Ok(VaultConfig { + vault_id: pc.vault_id, + storage_provider, + relay_url: pc.relay_url, + }) } async fn provider_for_config(config: &VaultConfig, vault_dir: &Path) -> Result { diff --git a/crates/vyn-cli/src/commands/push.rs b/crates/vyn-cli/src/commands/push.rs index 1c19455..85b83c4 100644 --- a/crates/vyn-cli/src/commands/push.rs +++ b/crates/vyn-cli/src/commands/push.rs @@ -115,9 +115,31 @@ pub fn run() -> Result<()> { fn load_config(root: &Path) -> Result { let path = root.join(".vyn").join("config.toml"); - let text = fs::read_to_string(&path) - .with_context(|| format!("missing or unreadable file: {}", path.display()))?; - toml::from_str(&text).context("invalid .vyn/config.toml format") + if path.exists() { + let text = fs::read_to_string(&path) + .with_context(|| format!("missing or unreadable file: {}", path.display()))?; + return toml::from_str(&text).context("invalid .vyn/config.toml format"); + } + // Fallback: read vault_id (and optionally relay_url) from committed vyn.toml. + let vyn_toml = root.join("vyn.toml"); + let text = fs::read_to_string(&vyn_toml) + .context("no .vyn/config.toml found; run `vyn clone` or `vyn link` first")?; + #[derive(serde::Deserialize)] + struct PublicConfig { + vault_id: String, + relay_url: Option, + } + let pc: PublicConfig = toml::from_str(&text).context("invalid vyn.toml format")?; + let storage_provider = if pc.relay_url.is_some() { + "relay".to_string() + } else { + "unconfigured".to_string() + }; + Ok(VaultConfig { + vault_id: pc.vault_id, + storage_provider, + relay_url: pc.relay_url, + }) } async fn provider_for_config(config: &VaultConfig, vault_dir: &Path) -> Result { diff --git a/crates/vyn-cli/src/commands/relay.rs b/crates/vyn-cli/src/commands/relay.rs new file mode 100644 index 0000000..f6e53b3 --- /dev/null +++ b/crates/vyn-cli/src/commands/relay.rs @@ -0,0 +1,171 @@ +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::Deserialize; +use vyn_core::relay_storage::RelayStorageProvider; + +use crate::output; + +#[derive(Debug, Deserialize)] +struct VaultConfig { + #[allow(dead_code)] + vault_id: String, + relay_url: Option, +} + +#[derive(Debug, Deserialize)] +struct IdentityConfig { + github_username: String, + #[allow(dead_code)] + ssh_private_key: String, + ssh_public_key: String, +} + +pub fn run_status() -> Result<()> { + output::print_banner("relay status"); + let root = std::env::current_dir().context("failed to determine current directory")?; + let vault_dir = root.join(".vyn"); + + let relay_url = load_relay_url(&root)?; + let identity = load_identity(&vault_dir)?; + + output::print_info("relay", &relay_url); + output::print_info( + "identity", + &format!( + "@{} ({})", + identity.github_username, + ssh_key_fingerprint(&identity.ssh_public_key) + .unwrap_or_else(|| "".to_string()) + ), + ); + + let runtime = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?; + runtime.block_on(async { + let provider = RelayStorageProvider::new(relay_url.clone()); + let spinner = output::new_spinner("checking connectivity and auth…"); + match provider.authenticate_with_identity(&vault_dir).await { + Ok(_) => { + output::finish_progress(&spinner, "authenticated"); + output::print_info("auth", "OK"); + } + Err(e) => { + output::fail_progress(&spinner, &format!("auth failed: {e}")); + output::print_info("auth", &format!("FAILED -- {e}")); + } + } + Ok::<(), anyhow::Error>(()) + })?; + + println!(); + Ok(()) +} + +fn load_relay_url(root: &Path) -> Result { + // Try .vyn/config.toml first, then fall back to vyn.toml. + let config_path = root.join(".vyn").join("config.toml"); + if config_path.exists() + && let Ok(text) = fs::read_to_string(&config_path) + && let Ok(cfg) = toml::from_str::(&text) + && let Some(url) = cfg.relay_url + { + return Ok(url); + } + let vyn_toml = root.join("vyn.toml"); + if vyn_toml.exists() + && let Ok(text) = fs::read_to_string(&vyn_toml) + { + #[derive(Deserialize)] + struct PublicConfig { + relay_url: Option, + } + if let Ok(pc) = toml::from_str::(&text) + && let Some(url) = pc.relay_url + { + return Ok(url); + } + } + anyhow::bail!("no relay_url configured; run `vyn config` to set it") +} + +fn load_identity(vault_dir: &Path) -> Result { + let path = vault_dir.join("identity.toml"); + let text = fs::read_to_string(&path) + .with_context(|| format!("missing identity.toml at {}", path.display()))?; + toml::from_str(&text).context("invalid identity.toml format") +} + +fn ssh_key_fingerprint(pubkey_path: &str) -> Option { + let content = fs::read_to_string(pubkey_path).ok()?; + let trimmed = content.trim(); + // Return the last field (comment) if present, otherwise the key type. + let parts: Vec<&str> = trimmed.splitn(3, ' ').collect(); + if parts.len() >= 3 { + Some(format!( + "{}:{}", + parts[0], + parts[2].split_whitespace().next().unwrap_or("") + )) + } else if !parts.is_empty() { + Some(parts[0].to_string()) + } else { + None + } +} + +pub fn run_ls(_vault: Option) -> Result<()> { + output::print_banner("relay ls"); + let root = std::env::current_dir().context("failed to determine current directory")?; + let vault_dir = root.join(".vyn"); + let relay_url = load_relay_url(&root)?; + + let runtime = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?; + runtime.block_on(async { + let provider = RelayStorageProvider::new(relay_url); + let spinner = output::new_spinner("authenticating…"); + provider + .authenticate_with_identity(&vault_dir) + .await + .context("relay authentication failed (run `vyn auth` first)")?; + output::finish_progress(&spinner, "authenticated"); + + let spinner2 = output::new_spinner("listing vaults…"); + let vaults = provider + .list_vaults() + .await + .context("failed to list vaults")?; + output::finish_progress(&spinner2, &format!("{} vault(s)", vaults.len())); + + if vaults.is_empty() { + println!("No vaults found."); + return Ok::<(), anyhow::Error>(()); + } + + let spinner3 = output::new_spinner("listing blobs…"); + let blobs = provider + .list_blobs() + .await + .context("failed to list blobs")?; + output::finish_progress(&spinner3, &format!("{} blob(s) total", blobs.len())); + + println!(); + println!("Vaults:"); + for vault_id in &vaults { + println!(" {vault_id}"); + } + + if !blobs.is_empty() { + println!(); + println!("Blobs:"); + for (sha256, size) in &blobs { + println!(" {} ({} B)", sha256, size); + } + } + + println!(); + Ok::<(), anyhow::Error>(()) + })?; + + Ok(()) +} diff --git a/crates/vyn-cli/src/commands/share.rs b/crates/vyn-cli/src/commands/share.rs index 33c3633..d7e43fd 100644 --- a/crates/vyn-cli/src/commands/share.rs +++ b/crates/vyn-cli/src/commands/share.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use vyn_core::keychain::load_project_key; use vyn_core::relay_storage::RelayStorageProvider; use vyn_core::storage::StorageProvider; -use vyn_core::wrapping::wrap_project_key_for_ssh_recipient; +use vyn_core::wrapping::wrap_invite_for_ssh_recipient; use crate::output; @@ -55,7 +55,8 @@ pub fn run(user: String) -> Result<()> { let vault_dir = root.join(".vyn"); let runtime = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?; runtime.block_on(async { - let provider = RelayStorageProvider::new(relay_url); + let relay_url_opt = Some(relay_url.as_str()); + let provider = RelayStorageProvider::new(relay_url.clone()); provider .authenticate_with_identity(&vault_dir) .await @@ -64,7 +65,7 @@ pub fn run(user: String) -> Result<()> { let spinner2 = output::new_spinner(&format!("uploading invite(s) for @{username}…")); let mut uploaded = 0usize; for public_key in &public_keys { - match wrap_project_key_for_ssh_recipient(&key, public_key) { + match wrap_invite_for_ssh_recipient(&key, &vault_id, relay_url_opt, public_key) { Ok(payload) => { provider .create_invite(&username, &vault_id, payload) diff --git a/crates/vyn-cli/src/commands/update.rs b/crates/vyn-cli/src/commands/update.rs new file mode 100644 index 0000000..595f9e1 --- /dev/null +++ b/crates/vyn-cli/src/commands/update.rs @@ -0,0 +1,59 @@ +use anyhow::Result; +use vyn_core::models::load_global_config; + +use crate::output; +use crate::version::newer_version; + +const INSTALL_URL: &str = "https://raw.githubusercontent.com/arnonsang/vyn/main/scripts/install.sh"; +const RELEASES_URL: &str = "https://github.com/arnonsang/vyn/releases"; +const DOCKER_IMAGE: &str = "ghcr.io/arnonsang/vyn:latest"; + +pub struct UpdateOptions { + pub check_only: bool, +} + +pub fn run(opts: UpdateOptions) -> Result<()> { + let current = env!("CARGO_PKG_VERSION"); + + let latest = newer_version(true); + + match &latest { + Some(v) => { + output::print_warning(&format!("vyn v{current} is installed. v{v} is available.")); + } + None => { + output::print_success(&format!("vyn v{current} is up to date.")); + return Ok(()); + } + } + + if opts.check_only { + return Ok(()); + } + + let cfg = load_global_config(); + println!(); + match cfg.install_method.as_deref() { + Some("binary") => { + println!("Run the following to update:"); + println!(); + println!(" curl -fsSL {INSTALL_URL} | sh"); + } + Some("cargo") => { + println!("Run the following to update:"); + println!(); + println!(" cargo install vyn --force"); + } + Some("docker") => { + println!("Run the following to update:"); + println!(); + println!(" docker pull {DOCKER_IMAGE}"); + } + _ => { + println!("Could not detect install method. Update manually from:"); + println!(" {RELEASES_URL}"); + } + } + + Ok(()) +} diff --git a/crates/vyn-cli/src/main.rs b/crates/vyn-cli/src/main.rs index 85ce8ff..56e96f9 100644 --- a/crates/vyn-cli/src/main.rs +++ b/crates/vyn-cli/src/main.rs @@ -1,5 +1,6 @@ mod commands; mod output; +mod version; use anyhow::Result; use clap::{Parser, Subcommand, ValueEnum}; @@ -15,9 +16,9 @@ struct Cli { #[derive(Debug, Subcommand)] enum Commands { - Init { - name: Option, - }, + /// Initialize a new vault in the current directory. + Init { name: Option }, + /// Configure storage provider and relay URL. Config { #[arg(long = "provider")] provider: Option, @@ -26,38 +27,54 @@ enum Commands { #[arg(long = "non-interactive", default_value_t = false)] non_interactive: bool, }, + /// Encrypt and upload changed files to remote storage. Push, + /// Download and decrypt files from remote storage. Pull, + /// Show local changes compared to the last known manifest. St { #[arg(short = 'v', long = "verbose")] verbose: bool, }, - Diff { - file: Option, - }, + /// Show a unified diff for a file (or all changed files). + Diff { file: Option }, + /// Show push/pull history for this vault. History, + /// Run health checks on the vault, identity, and relay. Doctor, + /// Rotate the vault key and re-encrypt all blobs. Rotate, - Share { - user: String, - }, + /// Share vault access with a GitHub user. + Share { user: String }, + /// Check tracked files for secret patterns. Check, + /// Run a command with vault secrets injected as environment variables. Run { #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] cmd: Vec, }, - Link { - vault_id: String, - }, + /// Link this machine to an existing vault using a relay invite. + Link { vault_id: String }, + /// Start tracking additional files in the vault. Add { #[arg(required = true, num_args = 1..)] paths: Vec, }, + /// Stop tracking files in the vault. Del { #[arg(required = true, num_args = 1..)] paths: Vec, }, + /// Authenticate with GitHub and register an SSH identity on the relay. Auth, + /// Clone a vault from a relay onto this machine. + Clone { + /// Relay URL (e.g. https://relay.example.com). + relay_url: String, + /// Vault ID to clone. + vault_id: String, + }, + /// Start a self-hosted vyn relay server. Serve { #[arg(long = "relay", default_value_t = false)] relay: bool, @@ -74,6 +91,29 @@ enum Commands { #[arg(long = "s3-prefix")] s3_prefix: Option, }, + /// Check for a newer version and print upgrade instructions. + Update { + /// Only report whether a newer version is available without showing update instructions. + #[arg(long = "check", default_value_t = false)] + check: bool, + }, + /// Relay inspection commands. + Relay { + #[command(subcommand)] + sub: RelaySubcommand, + }, +} + +#[derive(Debug, Subcommand)] +enum RelaySubcommand { + /// Ping relay and verify identity authentication. + Status, + /// List vaults and blobs visible to the authenticated identity. + Ls { + /// Vault to list blobs for (lists all accessible vaults if omitted). + #[arg(long)] + vault: Option, + }, } #[derive(Debug, Clone, ValueEnum)] @@ -94,7 +134,13 @@ impl StorageProviderArg { fn main() -> Result<()> { let cli = Cli::parse(); - match cli.command { + // Detect and persist install method on first run (best-effort). + detect_and_store_install_method(); + + // Kick off a background cache refresh. The hint appears on the next invocation. + version::spawn_background_check(); + + let result = match cli.command { Commands::Init { name } => commands::init::run(name), Commands::Config { provider, @@ -119,6 +165,10 @@ fn main() -> Result<()> { Commands::Add { paths } => commands::add::run(paths), Commands::Del { paths } => commands::del::run(paths), Commands::Auth => commands::auth::run(), + Commands::Clone { + relay_url, + vault_id, + } => commands::clone::run(relay_url, vault_id), Commands::Serve { relay, port, @@ -136,5 +186,52 @@ fn main() -> Result<()> { s3_endpoint, s3_prefix, ), + Commands::Update { check } => { + commands::update::run(commands::update::UpdateOptions { check_only: check }) + } + Commands::Relay { sub } => match sub { + RelaySubcommand::Status => commands::relay::run_status(), + RelaySubcommand::Ls { vault } => commands::relay::run_ls(vault), + }, + }; + + // After the command runs, show an update hint if a newer version is cached. + // Only print to a TTY so piped output is not polluted. + if console::Term::stdout().is_term() + && let Some(latest) = version::newer_version(false) + { + let current = env!("CARGO_PKG_VERSION"); + eprintln!( + "\nA new version of vyn is available: v{current} -> v{latest}. Run 'vyn update' to update." + ); + } + + result +} + +/// Detects how vyn was installed based on the current binary path and writes +/// the result to global config if not already set. +fn detect_and_store_install_method() { + use vyn_core::models::{load_global_config, save_global_config}; + + let mut cfg = load_global_config(); + if cfg.install_method.is_some() { + return; + } + + let method = std::env::current_exe().ok().and_then(|exe| { + let exe_str = exe.to_string_lossy().to_lowercase(); + if exe_str.contains(".cargo/bin") || exe_str.contains(".cargo\\bin") { + Some("cargo".to_string()) + } else if exe_str.contains("/usr/local/bin") { + Some("binary".to_string()) + } else { + None + } + }); + + if method.is_some() { + cfg.install_method = method; + let _ = save_global_config(&cfg); } } diff --git a/crates/vyn-cli/src/version.rs b/crates/vyn-cli/src/version.rs new file mode 100644 index 0000000..c3c3685 --- /dev/null +++ b/crates/vyn-cli/src/version.rs @@ -0,0 +1,147 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use semver::Version; +use vyn_core::models::{load_global_config, save_global_config}; + +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); +const RELEASES_API: &str = "https://api.github.com/repos/arnonsang/vyn/releases/latest"; +/// Staleness threshold in seconds (24 hours). +const CHECK_INTERVAL_SECS: u64 = 86_400; + +/// Result of a version check. +pub enum VersionStatus { + /// A newer version is available. + UpdateAvailable(String), + /// Current version is the latest. + UpToDate, + /// Could not reach GitHub (network error or parse failure). + CheckFailed, +} + +/// Checks whether a newer version of vyn is available. +/// +/// When `force` is false returns a cached result if the last network check is +/// younger than 24 hours. When `force` is true it always queries the API. +/// +/// All I/O and network errors are swallowed -- this must never panic or block +/// the caller for a noticeable duration. +pub fn check_for_update(force: bool) -> VersionStatus { + let mut cfg = load_global_config(); + + if !force && let Some(ts_secs) = cfg.last_version_check_unix { + let now = unix_now(); + if now.saturating_sub(ts_secs) < CHECK_INTERVAL_SECS { + return evaluate_cached(&cfg.latest_known_version); + } + } + + let latest = match fetch_latest_version() { + Some(v) => v, + None => return VersionStatus::CheckFailed, + }; + + cfg.last_version_check_unix = Some(unix_now()); + cfg.latest_known_version = Some(latest.clone()); + // Best-effort save -- ignore errors so a read-only filesystem doesn't break anything. + let _ = save_global_config(&cfg); + + evaluate_new(CURRENT_VERSION, &latest) +} + +/// Returns `Some(latest)` if newer than current, otherwise `None`. +/// Convenience wrapper around `check_for_update` for the update hint. +pub fn newer_version(force: bool) -> Option { + match check_for_update(force) { + VersionStatus::UpdateAvailable(v) => Some(v), + _ => None, + } +} + +/// Spawns a background thread that updates the version cache without blocking +/// the main command. The result is discarded -- the hint will show on the next run. +pub fn spawn_background_check() { + std::thread::spawn(|| { + check_for_update(false); + }); +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn evaluate_cached(latest_known: &Option) -> VersionStatus { + match latest_known { + Some(latest) => evaluate_new(CURRENT_VERSION, latest), + None => VersionStatus::CheckFailed, + } +} + +fn evaluate_new(current: &str, latest: &str) -> VersionStatus { + let cur = match Version::parse(current) { + Ok(v) => v, + Err(_) => return VersionStatus::CheckFailed, + }; + let lat = match Version::parse(latest) { + Ok(v) => v, + Err(_) => return VersionStatus::CheckFailed, + }; + if lat > cur { + VersionStatus::UpdateAvailable(latest.to_string()) + } else { + VersionStatus::UpToDate + } +} + +fn fetch_latest_version() -> Option { + let client = reqwest::blocking::Client::builder() + .user_agent(format!("vyn/{CURRENT_VERSION}")) + .timeout(std::time::Duration::from_secs(5)) + .build() + .ok()?; + + let resp: serde_json::Value = client.get(RELEASES_API).send().ok()?.json().ok()?; + + let tag = resp.get("tag_name")?.as_str()?; + // Strip leading 'v' if present. + Some(tag.trim_start_matches('v').to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn newer_version_detected() { + assert!(matches!( + evaluate_new("0.1.2", "0.1.3"), + VersionStatus::UpdateAvailable(v) if v == "0.1.3" + )); + } + + #[test] + fn same_version_up_to_date() { + assert!(matches!( + evaluate_new("0.1.3", "0.1.3"), + VersionStatus::UpToDate + )); + } + + #[test] + fn older_remote_up_to_date() { + assert!(matches!( + evaluate_new("0.1.4", "0.1.3"), + VersionStatus::UpToDate + )); + } + + #[test] + fn invalid_version_string_check_failed() { + assert!(matches!( + evaluate_new("not-a-version", "0.1.3"), + VersionStatus::CheckFailed + )); + } +} diff --git a/crates/vyn-core/Cargo.toml b/crates/vyn-core/Cargo.toml index 8fabb2e..efd26b3 100644 --- a/crates/vyn-core/Cargo.toml +++ b/crates/vyn-core/Cargo.toml @@ -9,6 +9,9 @@ readme = "README.md" [dependencies] age = { workspace = true, features = ["ssh"] } +anyhow.workspace = true +dirs.workspace = true +hex.workspace = true ignore.workspace = true keyring.workspace = true libp2p.workspace = true @@ -20,12 +23,13 @@ sha2.workspace = true similar.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["time"] } +toml.workspace = true tempfile = "3" tokio-stream = "0.1" tonic.workspace = true -vyn-relay = { path = "../vyn-relay", version = "0.1.1" } +vyn-relay = { path = "../vyn-relay", version = "0.1.3" } walkdir.workspace = true [dev-dependencies] uuid.workspace = true -vyn-relay = { path = "../vyn-relay", version = "0.1.1", features = ["test-bypass-auth"] } +vyn-relay = { path = "../vyn-relay", version = "0.1.3", features = ["test-bypass-auth"] } diff --git a/crates/vyn-core/README.md b/crates/vyn-core/README.md index 54f7a08..ddb56e9 100644 --- a/crates/vyn-core/README.md +++ b/crates/vyn-core/README.md @@ -6,19 +6,19 @@ This crate is not meant to be used directly. For the end-user CLI, see [`vyn-cli ## What's in here -- **Crypto** — AES-256-GCM encryption/decryption for blobs and manifests -- **Keychain** — project key storage and retrieval via the OS keychain -- **Manifest** — filesystem scanning, hashing, and manifest capture -- **Storage** — local blob store and relay storage provider abstraction -- **Diff/Merge** — line-level diff and 3-way merge engine -- **Wrapping** — SSH key-based wrapping/unwrapping of project keys via `age` -- **P2P** — libp2p-based local discovery module (experimental) +- **Crypto:** AES-256-GCM encryption/decryption for blobs and manifests +- **Keychain:** project key storage and retrieval via the OS keychain +- **Manifest:** filesystem scanning, hashing, and manifest capture +- **Storage:** local blob store and relay storage provider abstraction +- **Diff/Merge:** line-level diff and 3-way merge engine +- **Wrapping:** SSH key-based wrapping/unwrapping of project keys via `age` +- **P2P:** libp2p-based local discovery module (experimental) ## Crates in this workspace | Crate | Description | |---|---| -| [`vyn-cli`](https://crates.io/crates/vyn-cli) | End-user CLI — install this | +| [`vyn-cli`](https://crates.io/crates/vyn-cli) | End-user CLI. Install this | | [`vyn-core`](https://crates.io/crates/vyn-core) | Core library (this crate) | | [`vyn-relay`](https://crates.io/crates/vyn-relay) | Self-hosted gRPC relay server | diff --git a/crates/vyn-core/src/models.rs b/crates/vyn-core/src/models.rs index 655377f..1c20c12 100644 --- a/crates/vyn-core/src/models.rs +++ b/crates/vyn-core/src/models.rs @@ -1,3 +1,7 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; /// Persisted vault configuration, stored in `.vyn/config.toml`. @@ -26,3 +30,43 @@ pub struct HistoryEntry { pub manifest_version: u64, pub file_count: usize, } + +/// Global per-user config, stored at `~/.config/vyn/global.toml`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GlobalConfig { + /// How vyn was installed: `"binary"`, `"cargo"`, `"docker"`, or absent when unknown. + pub install_method: Option, + /// Unix timestamp (seconds) of the last GitHub releases API check. + pub last_version_check_unix: Option, + /// Latest version string seen at the last check (e.g. `"0.1.3"`). + pub latest_known_version: Option, +} + +/// Returns the path to `~/.config/vyn/global.toml` (XDG-compliant). +pub fn global_config_path() -> Option { + dirs::config_dir().map(|d| d.join("vyn").join("global.toml")) +} + +/// Loads `GlobalConfig` from disk, returning a default value if missing or malformed. +pub fn load_global_config() -> GlobalConfig { + let path = match global_config_path() { + Some(p) => p, + None => return GlobalConfig::default(), + }; + let text = match fs::read_to_string(&path) { + Ok(t) => t, + Err(_) => return GlobalConfig::default(), + }; + toml::from_str(&text).unwrap_or_default() +} + +/// Persists `GlobalConfig` to disk, creating the directory if needed. +pub fn save_global_config(cfg: &GlobalConfig) -> Result<()> { + let path = global_config_path().context("cannot determine config directory")?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).context("cannot create config directory")?; + } + let text = toml::to_string(cfg).context("cannot serialize global config")?; + fs::write(&path, text).context("cannot write global config")?; + Ok(()) +} diff --git a/crates/vyn-core/src/relay_storage.rs b/crates/vyn-core/src/relay_storage.rs index 96f8298..6b368cc 100644 --- a/crates/vyn-core/src/relay_storage.rs +++ b/crates/vyn-core/src/relay_storage.rs @@ -252,6 +252,40 @@ impl StorageProvider for RelayStorageProvider { } } +impl RelayStorageProvider { + /// Lists all vault IDs on the relay (requires auth token). + pub async fn list_vaults(&self) -> StorageResult> { + let mut client = self.connect().await?; + let mut request = tonic::Request::new(vyn_relay::proto::ListVaultsRequest {}); + request = self.inject_token(request).await?; + let response = client + .list_vaults(request) + .await + .map_err(|err| StorageError::Transport(err.to_string()))? + .into_inner(); + Ok(response.vault_ids) + } + + /// Lists all blobs on the relay (requires auth token). + pub async fn list_blobs(&self) -> StorageResult> { + let mut client = self.connect().await?; + let mut request = tonic::Request::new(vyn_relay::proto::ListBlobsRequest { + vault_id: String::new(), + }); + request = self.inject_token(request).await?; + let response = client + .list_blobs(request) + .await + .map_err(|err| StorageError::Transport(err.to_string()))? + .into_inner(); + Ok(response + .blobs + .into_iter() + .map(|b| (b.sha256, b.size_bytes)) + .collect()) + } +} + struct IdentityConfig { github_username: String, ssh_private_key: String, diff --git a/crates/vyn-core/src/wrapping.rs b/crates/vyn-core/src/wrapping.rs index 7e5f26c..dd348ac 100644 --- a/crates/vyn-core/src/wrapping.rs +++ b/crates/vyn-core/src/wrapping.rs @@ -8,6 +8,13 @@ use thiserror::Error; use crate::crypto::{SecretBytes, secret_bytes}; +/// Structured invite payload produced by `unwrap_invite_with_ssh_identity_file`. +pub struct InvitePayload { + pub vault_id: String, + pub relay_url: Option, + pub key: SecretBytes, +} + #[derive(Debug, Error)] pub enum WrappingError { #[error("invalid recipient public key: {0}")] @@ -88,6 +95,108 @@ pub fn unwrap_project_key_with_ssh_identity_file( Ok(secret_bytes(plaintext)) } +/// Wraps a project key into an age-encrypted invite that carries vault metadata. +/// +/// The inner plaintext is a JSON object: +/// `{"vault_id":"…","relay_url":"…","key":""}` (relay_url omitted when None). +pub fn wrap_invite_for_ssh_recipient( + project_key: &SecretBytes, + vault_id: &str, + relay_url: Option<&str>, + recipient_public_key: &str, +) -> Result, WrappingError> { + let key_hex = hex::encode(project_key.expose_secret()); + let json = match relay_url { + Some(url) => { + format!(r#"{{"vault_id":"{vault_id}","relay_url":"{url}","key":"{key_hex}"}}"#) + } + None => format!(r#"{{"vault_id":"{vault_id}","key":"{key_hex}"}}"#), + }; + + let recipient: age::ssh::Recipient = + recipient_public_key + .trim() + .parse() + .map_err(|e: age::ssh::ParseRecipientKeyError| { + WrappingError::InvalidRecipient(format!("{e:?}")) + })?; + + let encryptor = + age::Encryptor::with_recipients([&recipient as &dyn age::Recipient].into_iter()) + .map_err(|_| WrappingError::EncryptSetup)?; + + let mut output = Vec::new(); + let mut writer = encryptor + .wrap_output(&mut output) + .map_err(|_| WrappingError::EncryptSetup)?; + writer + .write_all(json.as_bytes()) + .map_err(|_| WrappingError::EncryptWrite)?; + writer.finish().map_err(|_| WrappingError::EncryptFinish)?; + + Ok(output) +} + +/// Decrypts an invite created by `wrap_invite_for_ssh_recipient`. +/// +/// Supports both the new JSON format and the legacy raw-32-byte format. +pub fn unwrap_invite_with_ssh_identity_file( + encrypted_invite: &[u8], + identity_file: &Path, +) -> Result { + let decryptor = + Decryptor::new(encrypted_invite).map_err(|_| WrappingError::InvalidEncryptedInvite)?; + + let file = File::open(identity_file)?; + let mut reader = BufReader::new(file); + let identity = age::ssh::Identity::from_buffer(&mut reader, None) + .map_err(|_| WrappingError::IdentityParse)?; + + let identities = vec![&identity as &dyn age::Identity]; + let mut decrypted_reader = decryptor + .decrypt(identities.into_iter()) + .map_err(|_| WrappingError::DecryptFailure)?; + + let mut plaintext = Vec::new(); + decrypted_reader + .read_to_end(&mut plaintext) + .map_err(|_| WrappingError::DecryptFailure)?; + + // Try JSON format first. + if let Ok(text) = std::str::from_utf8(&plaintext) + && let Ok(v) = serde_json::from_str::(text) + && let Some(key_hex) = v.get("key").and_then(|k| k.as_str()) + && let Ok(key_bytes) = hex::decode(key_hex) + && key_bytes.len() == 32 + { + let vault_id = v + .get("vault_id") + .and_then(|k| k.as_str()) + .unwrap_or("") + .to_string(); + let relay_url = v + .get("relay_url") + .and_then(|k| k.as_str()) + .map(str::to_string); + return Ok(InvitePayload { + vault_id, + relay_url, + key: secret_bytes(key_bytes), + }); + } + + // Legacy format: raw 32-byte key with no embedded metadata. + if plaintext.len() == 32 { + return Ok(InvitePayload { + vault_id: String::new(), + relay_url: None, + key: secret_bytes(plaintext), + }); + } + + Err(WrappingError::InvalidProjectKeySize) +} + #[cfg(test)] mod tests { use super::{unwrap_project_key_with_ssh_identity_file, wrap_project_key_for_ssh_recipient}; diff --git a/crates/vyn-relay/proto/vyn.proto b/crates/vyn-relay/proto/vyn.proto index 74efd75..e9409ca 100644 --- a/crates/vyn-relay/proto/vyn.proto +++ b/crates/vyn-relay/proto/vyn.proto @@ -11,6 +11,8 @@ service VynRelay { rpc DownloadBlob(DownloadBlobRequest) returns (stream DownloadBlobChunk); rpc CreateInvite(CreateInviteRequest) returns (CreateInviteResponse); rpc GetInvites(GetInvitesRequest) returns (GetInvitesResponse); + rpc ListVaults(ListVaultsRequest) returns (ListVaultsResponse); + rpc ListBlobs(ListBlobsRequest) returns (ListBlobsResponse); } message AuthRequest { @@ -91,3 +93,22 @@ message GetInvitesRequest { message GetInvitesResponse { repeated bytes payloads = 1; } + +message ListVaultsRequest {} + +message ListVaultsResponse { + repeated string vault_ids = 1; +} + +message ListBlobsRequest { + string vault_id = 1; +} + +message BlobInfo { + string sha256 = 1; + uint64 size_bytes = 2; +} + +message ListBlobsResponse { + repeated BlobInfo blobs = 1; +} diff --git a/crates/vyn-relay/src/service.rs b/crates/vyn-relay/src/service.rs index a7a583f..590b7d6 100644 --- a/crates/vyn-relay/src/service.rs +++ b/crates/vyn-relay/src/service.rs @@ -317,4 +317,37 @@ impl VynRelay for RelayService { Ok(Response::new(GetInvitesResponse { payloads })) } + + async fn list_vaults( + &self, + request: Request, + ) -> Result, Status> { + require_auth(&request, &*self.sessions.read().await)?; + + let vault_ids = self + .store + .list_vaults() + .context("failed to list vaults") + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(ListVaultsResponse { vault_ids })) + } + + async fn list_blobs( + &self, + request: Request, + ) -> Result, Status> { + require_auth(&request, &*self.sessions.read().await)?; + + let blobs = self + .store + .list_blobs() + .context("failed to list blobs") + .map_err(|e| Status::internal(e.to_string()))? + .into_iter() + .map(|(sha256, size_bytes)| BlobInfo { sha256, size_bytes }) + .collect(); + + Ok(Response::new(ListBlobsResponse { blobs })) + } } diff --git a/crates/vyn-relay/src/store.rs b/crates/vyn-relay/src/store.rs index 5c21048..e142de2 100644 --- a/crates/vyn-relay/src/store.rs +++ b/crates/vyn-relay/src/store.rs @@ -149,6 +149,44 @@ impl FileStore { .context("failed to write identity") } + /// Lists all vault IDs with a stored manifest. + pub fn list_vaults(&self) -> Result> { + let dir = self.manifests_dir(); + if !dir.exists() { + return Ok(Vec::new()); + } + let mut vaults = Vec::new(); + for entry in fs::read_dir(&dir).context("failed to read manifests directory")? { + let path = entry.context("failed to read directory entry")?.path(); + if path.is_file() + && let Some(stem) = path.file_stem().and_then(|s| s.to_str()) + { + vaults.push(stem.to_string()); + } + } + Ok(vaults) + } + + /// Lists all blob hashes stored under the global blobs directory (not vault-scoped in file store). + pub fn list_blobs(&self) -> Result> { + let dir = self.blobs_dir(); + if !dir.exists() { + return Ok(Vec::new()); + } + let mut blobs = Vec::new(); + for entry in fs::read_dir(&dir).context("failed to read blobs directory")? { + let entry = entry.context("failed to read directory entry")?; + let path = entry.path(); + if path.is_file() { + let size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + blobs.push((stem.to_string(), size)); + } + } + } + Ok(blobs) + } + pub fn get_identity(&self, user_id: &str) -> Result> { let path = self.identity_path(user_id); if !path.exists() { diff --git a/docs/book.toml b/docs/book.toml index fb893f0..325b0a6 100644 --- a/docs/book.toml +++ b/docs/book.toml @@ -5,10 +5,12 @@ language = "en" src = "src" [output.html] +theme = "theme" site-url = "/vyn/" git-repository-url = "https://github.com/arnonsang/vyn" edit-url-template = "https://github.com/arnonsang/vyn/edit/main/docs/src/{path}" additional-js = ["mermaid.min.js", "mermaid-init.js"] +additional-css = ["theme/custom.css"] [preprocessor.mermaid] command = "mdbook-mermaid" diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 3d3e9e6..fc502b9 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -22,6 +22,8 @@ - [vyn push / pull](./cli/push-pull.md) - [vyn st / diff](./cli/status-diff.md) - [vyn share / link](./cli/share-link.md) +- [vyn clone](./cli/clone.md) +- [vyn relay](./cli/relay.md) - [vyn add / del](./cli/add-del.md) - [Utility Commands](./cli/utility.md) diff --git a/docs/src/architecture/protocol.md b/docs/src/architecture/protocol.md index a93c118..776ce34 100644 --- a/docs/src/architecture/protocol.md +++ b/docs/src/architecture/protocol.md @@ -14,6 +14,8 @@ service VynRelay { rpc DownloadBlob(DownloadBlobRequest) returns (stream DownloadBlobChunk); rpc CreateInvite(CreateInviteRequest) returns (CreateInviteResponse); rpc GetInvites(GetInvitesRequest) returns (GetInvitesResponse); + rpc ListVaults(ListVaultsRequest) returns (ListVaultsResponse); + rpc ListBlobs(ListBlobsRequest) returns (ListBlobsResponse); } ``` @@ -29,6 +31,8 @@ service VynRelay { | `DownloadBlob` | Server streaming | Download an encrypted blob in chunks. | | `CreateInvite` | Unary | Upload an age-encrypted invite for a specific user. | | `GetInvites` | Unary | Fetch all invites for a user/vault pair. | +| `ListVaults` | Unary | List vault IDs accessible to the authenticated user. | +| `ListBlobs` | Unary | List blob hashes and sizes inside a specific vault. | ## Authentication diff --git a/docs/src/cli/auth.md b/docs/src/cli/auth.md index 1a8b270..a65045d 100644 --- a/docs/src/cli/auth.md +++ b/docs/src/cli/auth.md @@ -28,7 +28,10 @@ If your config points to a relay (`storage_provider = "relay"`), `vyn auth` also ## Output -On success, writes `.vyn/identity.toml`: +On success, writes two identity files: + +- **`.vyn/identity.toml`** — local vault identity (current directory) +- **`~/.vyn/identity.toml`** — global identity used by `vyn clone` when starting fresh in a new directory ```toml github_username = "your-handle" diff --git a/docs/src/cli/clone.md b/docs/src/cli/clone.md new file mode 100644 index 0000000..cf55bde --- /dev/null +++ b/docs/src/cli/clone.md @@ -0,0 +1,76 @@ +# vyn clone + +Clone a vault from a relay onto this machine in one step. + +```bash +vyn clone +``` + +| Argument | Description | +|---|---| +| `relay_url` | URL of the relay server hosting the vault | +| `vault_id` | UUID of the vault to clone | + +## Prerequisites + +- Run `vyn auth` at least once on this machine. `vyn clone` reads your identity from `~/.vyn/identity.toml` so you do not need to be inside any particular directory. +- A teammate must have run `vyn share @you` so an invite is waiting on the relay. + +## What it does + +1. Reads `~/.vyn/identity.toml` (global) or `.vyn/identity.toml` (local) +2. Creates `.vyn/` in the current directory and copies the identity there +3. Authenticates with the relay using your SSH key +4. Fetches and decrypts the invite for your GitHub username +5. Stores the vault's project key in the OS keychain +6. Writes `.vyn/config.toml` and `vyn.toml` (with `vault_id` and `relay_url` embedded) — no manual configuration needed +7. Runs `vyn pull` to download and decrypt all vault files + +## Example + +```bash +mkdir my-project && cd my-project +vyn clone https://relay.example.com f47ac10b-58cc-4372-a567-0e02b2c3d479 +``` + +``` + vyn clone + ✔ authenticated + ✔ 1 invite(s) found + ✔ invite decrypted + ✔ vault f47ac10b-58cc-4372-a567-0e02b2c3d479 linked + + identity @you + relay https://relay.example.com + key stored OS keychain + + next pulling files… + + vyn pull + ✔ 3 files in manifest + ✔ blobs written to disk [██████████████] 3/3 + ✔ vault f47ac10b-58cc-4372-a567-0e02b2c3d479 pulled + + files 3 synced +``` + +## vyn.toml shortcut + +If a repo already has a committed `vyn.toml` you can read the `relay_url` and `vault_id` from it directly: + +```bash +cat vyn.toml +# vault_id = "f47ac10b-58cc-4372-a567-0e02b2c3d479" +# relay_url = "https://relay.example.com" + +vyn clone https://relay.example.com f47ac10b-58cc-4372-a567-0e02b2c3d479 +``` + +## vs. vyn link + +| | `vyn clone` | `vyn link` | +|---|---|---| +| Requires existing `.vyn/` | No | Yes | +| Reads global identity | Yes | No | +| Pulls files automatically | Yes | No | +| Best for | Fresh machines / new repos | Adding access within an existing checkout | diff --git a/docs/src/cli/init.md b/docs/src/cli/init.md index 873b0b1..89df5b2 100644 --- a/docs/src/cli/init.md +++ b/docs/src/cli/init.md @@ -17,8 +17,9 @@ vyn init [name] 3. Generates a random vault UUID and 256-bit AES-256-GCM project key 4. Stores the project key in the OS keychain under `(service=vyn, account=)` 5. Writes `.vyn/manifest.json` (initial empty file index) and `.vyn/config.toml` -6. Adds `.vyn/` to `.gitignore` (creates the file if absent) -7. Copies `.vynignore.example` to `.vynignore` if an example is present +6. Writes `vyn.toml` in the project root with `vault_id` (non-secret — commit this to Git) +7. Adds `.vyn/` to `.gitignore` (creates the file if absent) +8. Copies `.vynignore.example` to `.vynignore` if an example is present ## Example diff --git a/docs/src/cli/push-pull.md b/docs/src/cli/push-pull.md index 05275a3..f2a8c35 100644 --- a/docs/src/cli/push-pull.md +++ b/docs/src/cli/push-pull.md @@ -38,3 +38,4 @@ vyn pull - Both commands require a configured storage provider (`vyn config`) and authenticated identity (`vyn auth`) when using relay storage - Pull overwrites local files with the remote baseline; run `vyn st` first to check for local changes - Encrypted blobs are cached locally in `.vyn/blobs/`; only new or changed blobs are downloaded on subsequent pulls +- If `.vyn/config.toml` is absent, both commands fall back to reading `vyn.toml` in the project root. This makes `vyn pull` work in a freshly-cloned repository (e.g. CI) without requiring a separate config step diff --git a/docs/src/cli/relay.md b/docs/src/cli/relay.md new file mode 100644 index 0000000..10a1b85 --- /dev/null +++ b/docs/src/cli/relay.md @@ -0,0 +1,63 @@ +# vyn relay + +Relay inspection subcommands. Requires an authenticated relay connection (run `vyn auth` first). + +--- + +## vyn relay status + +Check connectivity and authentication against the configured relay. + +```bash +vyn relay status +``` + +Reads `relay_url` from `.vyn/config.toml` or `vyn.toml`, authenticates using `.vyn/identity.toml`, and prints the result. + +**Example output:** + +``` + relay http://localhost:50051 + identity @arnonsang (ssh-ed25519:my-project) + auth OK +``` + +--- + +## vyn relay ls [vault_id] + +List vaults and blobs stored on the relay. + +```bash +# List all vaults you have access to +vyn relay ls + +# List blobs inside a specific vault +vyn relay ls +``` + +| Argument | Description | +|---|---| +| `vault_id` | (optional) If given, list blobs inside that specific vault | + +**Example — list all vaults:** + +``` +Vaults: + f47ac10b-58cc-4372-a567-0e02b2c3d479 + a9083afa-1707-4811-a48d-b2ef34cbc85b +``` + +**Example — list blobs in a vault:** + +```bash +vyn relay ls f47ac10b-58cc-4372-a567-0e02b2c3d479 +``` + +``` +Blobs: + ee0a32873a514d1c08f711fd9a7e835ff3243e424f82ece8133b646b0ef19f05 (34 B) + 37a12d33f0ada20f6280ce0b9f6e63cd06a2c0bdc761bc070594303c4e37dc06 (158 B) +``` + +Blob hashes are SHA-256 of the plaintext. Size is the ciphertext size stored on the relay. All values are opaque to the relay — it never sees plaintext content. diff --git a/docs/src/cli/share-link.md b/docs/src/cli/share-link.md index 691b273..935278d 100644 --- a/docs/src/cli/share-link.md +++ b/docs/src/cli/share-link.md @@ -16,18 +16,18 @@ vyn share @user 1. Fetches SSH public keys from `https://github.com/.keys` 2. Loads the project key from the OS keychain -3. Wraps the project key for each SSH key found using `age` -4. Writes invite files to `.vyn/invites/____.age` +3. Wraps the project key, vault ID, and relay URL together into a JSON payload and encrypts it for each SSH key found using `age` +4. Uploads the invite ciphertext to the relay under the recipient's username -The invite files are encrypted specifically for the recipient's SSH private key. The relay (if used) cannot read the project key. +The invite now embeds the full connection details (`vault_id`, `relay_url`, and the project key). The recipient can run `vyn clone` or `vyn link` without needing the vault ID separately. **Example:** ```bash vyn share @teammate -# ✓ Created 2 invite(s) for @teammate -# .vyn/invites/f47ac10b__teammate__0.age -# .vyn/invites/f47ac10b__teammate__1.age +# ✓ invite sent to @teammate +# vault id f47ac10b-58cc-4372-a567-0e02b2c3d479 +# next step @teammate can now run: vyn link f47ac10b-58cc-4372-a567-0e02b2c3d479 ``` --- @@ -46,12 +46,14 @@ vyn link **What it does:** -1. Reads invite files from `.vyn/invites/` matching `____*.age` +1. Fetches invite files from the relay matching `____*.age` 2. Tries each invite with the private key from `.vyn/identity.toml` 3. Stores the decrypted project key in the OS keychain -4. Rewrites `vault_id` in `.vyn/config.toml` to the linked vault's ID +4. Bootstraps `.vyn/config.toml` and `vyn.toml` from the metadata embedded in the invite (relay URL and vault ID are auto-populated — no manual config needed) -After linking, `vyn push` and `vyn pull` will use the linked vault ID. +After linking, `vyn push` and `vyn pull` will use the linked vault and relay automatically. + +> **Tip:** For a fully automated one-step join, use [`vyn clone`](./clone.md) instead. ## Team onboarding flow @@ -62,11 +64,12 @@ sequenceDiagram participant Bob Alice->>Alice: vyn share @bob - Note over Alice: wraps PK with Bob's SSH key + Note over Alice: wraps PK + vault_id + relay_url
with Bob's SSH key (age) Alice->>Relay: upload invite (ciphertext only) - Bob->>Relay: vyn link + Bob->>Bob: vyn auth (once, any directory) + Bob->>Bob: vyn clone Relay-->>Bob: encrypted invite Note over Bob: decrypt with ~/.ssh/id_ed25519 - Bob->>Bob: store PK in keychain + Bob->>Bob: store PK + write config + pull files ``` diff --git a/docs/src/cli/utility.md b/docs/src/cli/utility.md index da7b00f..8d329bb 100644 --- a/docs/src/cli/utility.md +++ b/docs/src/cli/utility.md @@ -5,7 +5,7 @@ Run a subprocess with env vars injected from `.env` files and encrypted vault blobs. ```bash -vyn run -- +vyn run ``` **What it does:** @@ -18,8 +18,8 @@ vyn run -- **Example:** ```bash -vyn run -- node server.js -vyn run -- docker compose up +vyn run node server.js +vyn run docker compose up ``` --- @@ -83,3 +83,38 @@ vyn rotate 3. Updates the OS keychain with the new key 4. Rebuilds invite files for known teammates 5. Writes a history entry + +--- + +## vyn update + +Check for a newer version and print upgrade instructions. + +```bash +vyn update +``` + +Options: +- `--check` — only report whether a newer version is available, without printing upgrade instructions + +**What it does:** + +1. Fetches the latest published version from crates.io +2. Compares with the current binary version +3. If up to date, exits cleanly +4. If a newer version is available, prints instructions tailored to how vyn was installed (binary, cargo, or Docker) + +**Example:** + +```bash +vyn update +# vyn v0.1.2 is installed. v0.1.3 is available. +# +# Run the following to update: +# curl -fsSL https://github.com/arnonsang/vyn/releases/latest/download/install.sh | sh +``` + +```bash +vyn update --check +# vyn v0.1.2 is installed. v0.1.3 is available. +``` diff --git a/docs/src/configuration/files.md b/docs/src/configuration/files.md index ac6b5d8..d222281 100644 --- a/docs/src/configuration/files.md +++ b/docs/src/configuration/files.md @@ -1,8 +1,29 @@ # Config Files +## vyn.toml + +Public vault config, committed to Git. Written by `vyn init` and updated by `vyn config`. + +```toml +# vyn.toml -- commit this file +vault_id = "f47ac10b-58cc-4372-a567-0e02b2c3d479" +relay_url = "https://relay.example.com" +``` + +| Field | Description | +|---|---| +| `vault_id` | UUID identifying this vault on the storage backend | +| `relay_url` | URL of the relay server | + +This file contains no secrets. Committing it lets teammates (and `vyn clone`) discover the vault's relay and ID without any manual communication. + +> **Note:** `push` and `pull` fall back to `vyn.toml` if `.vyn/config.toml` is missing, so you can run `vyn pull` immediately after cloning a repo without any extra setup. + +--- + ## .vyn/config.toml -Primary vault configuration. Written by `vyn init` and updated by `vyn config` and `vyn link`. +Private vault configuration. Written by `vyn init` and updated by `vyn config` and `vyn link`. **Not committed to Git** (`.vyn/` is added to `.gitignore` by `vyn init`). ```toml vault_id = "f47ac10b-58cc-4372-a567-0e02b2c3d479" @@ -36,6 +57,8 @@ ssh_public_key = "/home/you/.ssh/id_ed25519.pub" | `ssh_private_key` | Absolute path to the SSH private key used for key unwrapping | | `ssh_public_key` | Absolute path to the corresponding SSH public key | +`vyn auth` also writes an identical file to `~/.vyn/identity.toml`. This global copy is used by `vyn clone` when starting fresh in a new directory where no local `.vyn/` exists yet. + --- ## .vyn/manifest.json @@ -66,3 +89,21 @@ build/ dist/ node_modules/ ``` + +--- + +## ~/.config/vyn/global.toml + +Global per-user config (XDG-compliant). Written automatically by `vyn update` and read at startup to cache install-method detection. + +```toml +install_method = "binary" # binary | cargo | docker +installed_version = "0.1.3" +``` + +| Field | Description | +|---|---| +| `install_method` | How `vyn` was installed — controls which upgrade command `vyn update` prints | +| `installed_version` | Version string recorded at last update check | + +This file is managed automatically; you do not normally need to edit it. diff --git a/docs/src/getting-started/quick-start.md b/docs/src/getting-started/quick-start.md index aba90cb..83fbe1c 100644 --- a/docs/src/getting-started/quick-start.md +++ b/docs/src/getting-started/quick-start.md @@ -1,14 +1,18 @@ # Quick Start -## 1. Initialize a vault +## Starting a new vault + +### 1. Initialize a vault ```bash vyn init my-project ``` -This creates a `.vyn/` directory, generates a 256-bit AES project key stored in your OS keychain, and writes an initial manifest. +This creates a `.vyn/` directory, generates a 256-bit AES project key stored in your OS keychain, writes an initial manifest, and creates a `vyn.toml` in the project root. + +> **Commit `vyn.toml`** — it contains only the non-secret `vault_id` and `relay_url`, making sharing easier. -## 2. Configure storage +### 2. Configure storage Run the interactive config wizard: @@ -28,7 +32,7 @@ vyn config --provider relay --relay-url https://relay.example.com --non-interact > **Note:** Configure storage before running `vyn auth` if you are using relay storage. Auth registers your identity on the relay. -## 3. Authenticate +### 3. Authenticate ```bash vyn auth @@ -39,9 +43,9 @@ This runs a 3-step flow: 2. SSH key detection — finds `~/.ssh/id_ed25519` or `~/.ssh/id_rsa` automatically. 3. SSH challenge-response — proves you hold the private key matching your GitHub-registered public key. -On success, writes `.vyn/identity.toml`. +On success, writes `.vyn/identity.toml` and `~/.vyn/identity.toml` (global, used by `vyn clone`). -## 4. Push +### 4. Push ```bash vyn push @@ -49,7 +53,7 @@ vyn push Encrypts tracked files and uploads encrypted blobs + manifest to the configured storage. -## 5. Pull +### 5. Pull ```bash vyn pull @@ -59,9 +63,37 @@ Downloads the encrypted manifest and blobs, decrypts in memory, and writes plain --- +## Joining an existing vault + +The fastest path when a teammate has already shared the vault with you: + +```bash +# Clone from a relay — fetches your invite, stores the key, pulls all files +vyn clone https://relay.example.com +``` + +`vyn clone` requires `vyn auth` to have been run at least once on this machine (reads `~/.vyn/identity.toml`). + +### Manual join (relay invite via `vyn share` / `vyn link`) + +```bash +# On your machine (recipient) +vyn auth # register your GitHub identity if not yet done + +# On the vault owner's machine +vyn share @you # uploads an encrypted invite to the relay + +# Back on your machine +vyn link # fetches the invite, imports the key, bootstraps config +vyn pull # download and decrypt all files +``` + +--- + ## Next steps - Run `vyn st` to see local changes against the baseline - Run `vyn diff` to inspect line-level changes +- Inspect your relay: `vyn relay status` / `vyn relay ls` - Share the vault with a teammate: `vyn share @teammate` - See all commands in the [CLI Reference](../cli/init.md) diff --git a/docs/src/introduction.md b/docs/src/introduction.md index 43f16c1..560d5bb 100644 --- a/docs/src/introduction.md +++ b/docs/src/introduction.md @@ -12,8 +12,11 @@ - **AES-256-GCM encryption** — all blobs and manifests are encrypted before leaving the machine - **GitHub OAuth Device Flow** — passwordless identity, no manual username entry -- **SSH-based key sharing** — invite teammates via [age](https://age-encryption.org) using their GitHub SSH public keys -- **Local vault** — all metadata lives in `.vyn/` and is never committed to Git +- **SSH-based key sharing** — invite teammates via [age](https://age-encryption.org) using their GitHub SSH public keys; invite embeds vault ID, relay URL, and key so recipients can onboard with a single command +- **`vyn.toml`** — non-secret public config committed to Git; makes `vyn clone` and CI pull work without manual configuration +- **One-step onboarding** — `vyn clone ` finds invite, imports key, and pulls files automatically +- **Relay inspection** — `vyn relay status` and `vyn relay ls` to check connectivity and browse stored vaults/blobs +- **Local vault** — all metadata and keys live in `.vyn/` and are never committed to Git - **Diff & status** — file-level and line-level diff against the encrypted baseline - **Self-hosted relay** — run your own gRPC relay server with optional S3 mirroring - **P2P stub** — `libp2p` module compiled into `vyn-core`, not yet CLI-exposed @@ -33,7 +36,9 @@ Full MVP command set is implemented and tested: - Local vault lifecycle: `init`, `st`, `diff`, `config`, `doctor` - Sync: `push`, `pull`, `history` -- Identity + sharing: `auth` (OAuth + SSH verify), `share`, `link` +- Identity + sharing: `auth` (OAuth + SSH verify), `share`, `link`, `clone` +- Key rotation: `rotate` (re-encrypts all remote state with a new project key) +- **v0.1.3:** `clone` (one-step onboarding), `relay status`, `relay ls`, `vyn.toml` public config, `update` (version check + upgrade instructions) - Env management: `run`, `check` - Relay server: `serve` with local and S3-mirror backends - Docker / Docker Compose deployment ready diff --git a/docs/src/key-management/sharing.md b/docs/src/key-management/sharing.md index 48cab3c..e4f0ca0 100644 --- a/docs/src/key-management/sharing.md +++ b/docs/src/key-management/sharing.md @@ -1,6 +1,16 @@ # Sharing Keys -Key sharing uses **asymmetric key wrapping**: the sender encrypts the PK with the recipient's public SSH key. Only the recipient's private key can unwrap it. +Key sharing uses **asymmetric key wrapping**: the sender encrypts the project key (PK) along with vault metadata using the recipient's public SSH key. Only the recipient's SSH private key can unwrap it. + +Each invite embeds three pieces of information: + +| Field | Purpose | +|-------|---------| +| Project Key (PK) | The AES-256-GCM key that encrypts all vault blobs | +| `vault_id` | Identifies which vault on the relay to pull from | +| `relay_url` | The relay endpoint Bob's client should connect to | + +This means Bob only needs `vyn clone ` — no separate `vyn link` step is required. ## Flow: vyn share @bob @@ -18,17 +28,18 @@ sequenceDiagram GH-->>CLI: Bob's Public SSH Keys (RSA/Ed25519) CLI->>CLI: Fetch PK from Keychain - Note over CLI: Wrap PK with each of Bob's keys (age) + Note over CLI: Wrap PK + vault_id + relay_url
with each of Bob's keys (age) CLI->>Cloud: Upload Encrypted Invite(s) for Bob - Note over Peer: Bob runs vyn link - Peer->>Cloud: GET Invite for Bob + Note over Peer: Bob runs vyn clone + Peer->>Cloud: Fetch Invite for Bob Cloud-->>Peer: Encrypted Invite Blob Note over Peer: Uses ~/.ssh/id_ed25519 - Peer->>Peer: Decrypt Invite → Extract PK + Peer->>Peer: Decrypt Invite → Extract PK + vault_id + relay_url Peer->>OS: Store PK in Bob's Keychain + Peer->>Cloud: Pull vault blobs ``` ## Why this is secure @@ -36,7 +47,8 @@ sequenceDiagram 1. **No shared passwords** — you never transmit the PK in plaintext 2. **Identity-bound** — only the holder of the SSH private key can unlock the invite 3. **Relay-blind** — the relay stores only ciphertext and cannot read the PK -4. **Per-key invites** — if Bob has multiple SSH keys on GitHub, one invite is created for each key so any of his machines can link +4. **Per-key invites** — if Bob has multiple SSH keys on GitHub, one invite is created for each key so any of his machines can clone +5. **Self-contained** — the invite carries everything Bob needs; no out-of-band vault ID sharing required ## Commands @@ -47,8 +59,10 @@ vyn share @bob # Share with yourself (for multi-device setup) vyn share @me -# Accept an invite -vyn link +# Accept an invite and set up a local vault copy +vyn clone ``` +> **Note:** `vyn link ` is also available to accept an invite into an *existing* local vault directory rather than cloning a fresh copy. + See [vyn share / link](../cli/share-link.md) for full CLI reference. diff --git a/docs/theme/custom.css b/docs/theme/custom.css new file mode 100644 index 0000000..089fd7e --- /dev/null +++ b/docs/theme/custom.css @@ -0,0 +1,16 @@ +.sidebar-title { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 20px; +} + +.sidebar-title::before { + content: ''; + display: block; + width: 72px; + height: 72px; + background: url('https://raw.githubusercontent.com/arnonsang/vyn/main/assets/logo_light_transparent.png') no-repeat center; + background-size: contain; + margin-bottom: 10px; +} diff --git a/docs/theme/head.hbs b/docs/theme/head.hbs new file mode 100644 index 0000000..4ac3a19 --- /dev/null +++ b/docs/theme/head.hbs @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/proto/vyn.proto b/proto/vyn.proto index 74efd75..e9409ca 100644 --- a/proto/vyn.proto +++ b/proto/vyn.proto @@ -11,6 +11,8 @@ service VynRelay { rpc DownloadBlob(DownloadBlobRequest) returns (stream DownloadBlobChunk); rpc CreateInvite(CreateInviteRequest) returns (CreateInviteResponse); rpc GetInvites(GetInvitesRequest) returns (GetInvitesResponse); + rpc ListVaults(ListVaultsRequest) returns (ListVaultsResponse); + rpc ListBlobs(ListBlobsRequest) returns (ListBlobsResponse); } message AuthRequest { @@ -91,3 +93,22 @@ message GetInvitesRequest { message GetInvitesResponse { repeated bytes payloads = 1; } + +message ListVaultsRequest {} + +message ListVaultsResponse { + repeated string vault_ids = 1; +} + +message ListBlobsRequest { + string vault_id = 1; +} + +message BlobInfo { + string sha256 = 1; + uint64 size_bytes = 2; +} + +message ListBlobsResponse { + repeated BlobInfo blobs = 1; +} diff --git a/scripts/install.sh b/scripts/install.sh index 782de88..6613887 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -65,4 +65,10 @@ fi chmod +x "$INSTALL_DIR/$BIN" echo "Installed $BIN to $INSTALL_DIR/$BIN" + +# Record install method in global config so 'vyn update' can suggest the right command. +VYN_GLOBAL_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/vyn" +mkdir -p "$VYN_GLOBAL_DIR" +printf 'install_method = "binary"\n' > "$VYN_GLOBAL_DIR/global.toml" + "$INSTALL_DIR/$BIN" --version