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