diff --git a/CLAUDE.md b/CLAUDE.md index 11f68e5..b4d6159 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,7 @@ npack is an independent package manager whose registry metadata will be publishe npack remove / [--user|--system] [--store ] npack inspect npack appstream [--output ] + npack daemon [--socket ] ## Conventions @@ -46,3 +47,4 @@ npack is an independent package manager whose registry metadata will be publishe - `npack update --check` (alias of `install-ref --check`) reports available updates for one or all installed packages without downloading or installing anything, the `apt update` counterpart to `update`'s `apt upgrade`. - A manifest's optional `app` object (`summary`, `description`, `homepage`, `license`, `categories`, `icon`, `screenshots`, `desktop_file`, `release_date`) carries desktop-store metadata; `icon` and `desktop_file` are package-relative paths validated to exist and, for `desktop_file`, to be a syntactically valid freedesktop.org Desktop Entry file with `Exec` required when `Type=Application`. - `npack appstream` maps a manifest's `app` metadata to an AppStream `` document per the freedesktop.org AppStream spec: `console-application` when there is no `desktop_file` (advertising ``), `desktop-application` otherwise (advertising ``). Component IDs are namespaced `io.npack..` since publishers are Nostr pubkeys, not domains. +- `npack daemon` runs npackd, a local JSON-RPC-over-Unix-socket service (newline-delimited JSON requests/responses) exposing `Search`, `GetPackage`, `ListInstalled`, `Install`, `Remove`, `Update`, and `CheckUpdates` so a GUI store or other tool does not need to understand Nostr, Blossom, or `.npk` internals. It defaults to `$XDG_RUNTIME_DIR/npackd.sock`. Connections are handled one at a time (the recursive install future is not `Send`, so it cannot be spawned onto the runtime); transaction/progress-event streaming is future work. diff --git a/Cargo.lock b/Cargo.lock index d4be935..ffd1cdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1899,6 +1899,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "slab" version = "0.4.12" @@ -2080,6 +2090,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index da9f68f..2a83061 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ nostr-sdk = "0.45.2" semver = "1" tar = "0.4" toml = "0.8" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "io-util", "signal"] } zstd = "0.13" [dev-dependencies] diff --git a/README.md b/README.md index 6f2a045..4552482 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,10 @@ npack update --user --check # report available updates without installin # artifact themselves npack resolve /myapp --relay wss://relay.example +# Run npackd, a local JSON-RPC service over a Unix socket for a GUI store or +# other tool that shouldn't need to know about Nostr, Blossom, or .npk +npack daemon --socket $XDG_RUNTIME_DIR/npackd.sock + # Publish an artifact and its Nostr events npack publish ./myapp.manifest.json \ --secret-key \ diff --git a/docs/npack.1 b/docs/npack.1 index 925da8c..8d3cc9d 100644 --- a/docs/npack.1 +++ b/docs/npack.1 @@ -85,6 +85,9 @@ Extract the embedded manifest from an .npk archive npack\-appstream(1) Generate an AppStream component XML document from an .npk archive\*(Aqs manifest .TP +npack\-daemon(1) +Run npackd, a local JSON\-RPC service over a Unix socket that exposes Search/GetPackage/ListInstalled/Install/Remove/Update/CheckUpdates to the CLI, GUI store plugins, and other tools without them needing to understand Nostr, Blossom, or .npk internals +.TP npack\-help(1) Print this message or the help of the given subcommand(s) .SH VERSION diff --git a/docs/roadmap.md b/docs/roadmap.md index 44a6d29..a7e1648 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -27,7 +27,7 @@ Add first-class AppStream support: Goal: an npack package can describe itself in the language already understood by Linux application stores. -## Phase 2: Stable service layer +## Phase 2: Stable service layer (in progress) Introduce `npackd` as the common local backend for the CLI, GUI store plugins, and other tools: @@ -48,18 +48,29 @@ CLI / GUI plugins / other tools Keep the API local and narrow. D-Bus is the leading Linux-native option; a Unix socket API remains an alternative. -Core operations: +Shipped: `npack daemon` runs npackd as a JSON-RPC-over-Unix-socket service +(newline-delimited JSON, defaulting to `$XDG_RUNTIME_DIR/npackd.sock`) +exposing: ```text Search() GetPackage() ListInstalled() Install() Remove() Update() -CheckUpdates() GetTransaction() -CancelTransaction() +CheckUpdates() ``` -Expose progress and transaction events. GUI integrations should not need to -understand Nostr, Blossom, or `.npk` internals. +Chose a Unix socket over D-Bus for this first slice: no new system dependency +or session/system bus requirement, so it behaves the same in minimal and +containerized environments. A D-Bus adapter in front of the same handlers +remains possible later if a desktop-store integration phase needs it. + +Remaining work: + +- `GetTransaction()`/`CancelTransaction()` and streamed progress events for + long-running `Install`/`Update` calls -- npackd currently handles one + connection at a time and responds only once an operation completes. +- Expose progress and transaction events. GUI integrations should not need to + understand Nostr, Blossom, or `.npk` internals. ## Phase 3: Security and privilege separation diff --git a/docs/using-npack.md b/docs/using-npack.md index 50c4868..511b609 100644 --- a/docs/using-npack.md +++ b/docs/using-npack.md @@ -433,6 +433,46 @@ derivation generator would want to build one `fetchurl`-plus-unpack derivation per package in the graph, without needing a separate `npack resolve` invocation for every dependency. +## Running npackd (local service API) + +`npack daemon` runs npackd, a common local backend that a GUI store or other +tool can talk to without needing to understand Nostr, Blossom, or `.npk` +internals -- the CLI itself is one possible client: + +```bash +npack daemon --socket $XDG_RUNTIME_DIR/npackd.sock +``` + +`--socket` defaults to `$XDG_RUNTIME_DIR/npackd.sock`. The protocol is +newline-delimited JSON over that Unix socket: one JSON object request per +line, one JSON object response per line, matched by `id`. + +```text +--> {"id": 1, "method": "ListInstalled", "params": {"user": true}} +<-- {"id": 1, "result": [{"publisher": "npub1...", "name": "myapp", "version": "1.0.0", ...}]} + +--> {"id": 2, "method": "GetPackage", "params": {"package": "npub1.../myapp", "relay": ["wss://relay.example"]}} +<-- {"id": 2, "result": {"publisher": "...", "name": "myapp", "version": "1.2.0", "sha256": "...", ...}} +``` + +Supported methods, mirroring the CLI operations above: + +| Method | Params | Result | +| --- | --- | --- | +| `Search` | `query`, `relay[]`, `trusted_publisher[]`, `pubkey`, `refresh`, `no_cache` | Array of matching releases. | +| `GetPackage` | `package`, `relay[]`, `requirement`, `os`, `arch`, `trusted_publisher[]`, `store`, `user` | The same resolved-metadata object as `npack resolve`. | +| `ListInstalled` | `user`, `store` | Array of installed packages. | +| `Install` | `package`, `requirement`, `relay[]`, `server[]`, `user`, `store`, `allow_capability[]` | The installed package's record. | +| `Remove` | `package`, `user`, `store` | `{"removed": ""}`. | +| `Update` | `package` (omit for all), `relay[]`, `server[]`, `user`, `store`, `allow_capability[]` | Array of per-package update outcomes. | +| `CheckUpdates` | `package` (omit for all), `relay[]`, `trusted_publisher[]`, `user`, `store` | Array of `{reference, current_version, available_version}`. | + +An unknown method or a request that fails to deserialize its params returns +`{"id": ..., "error": "..."}` instead of `result`. Connections are handled +one at a time -- npackd does not yet stream install/update progress back to +the client; a client sees the final result once the operation completes. +GetTransaction/CancelTransaction-style progress reporting is future work. + ## Configuration Configuration is stored at the platform's user config path, normally: @@ -696,4 +736,5 @@ npack verify-installed [--user|--system] npack remove / [--user|--system] npack publish --secret-key [options] npack appstream [--output ] +npack daemon [--socket ] ``` diff --git a/src/main.rs b/src/main.rs index 57231a7..f58c3ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -392,6 +392,17 @@ enum Command { #[arg(long, help = "Write the AppStream XML to this path instead of stdout")] output: Option, }, + /// Run npackd, a local JSON-RPC service over a Unix socket that exposes + /// Search/GetPackage/ListInstalled/Install/Remove/Update/CheckUpdates to + /// the CLI, GUI store plugins, and other tools without them needing to + /// understand Nostr, Blossom, or .npk internals. + Daemon { + #[arg( + long, + help = "Unix socket path to listen on; defaults to $XDG_RUNTIME_DIR/npackd.sock" + )] + socket: Option, + }, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -882,6 +893,7 @@ async fn main() -> Result<()> { Command::Inspect { artifact } => inspect_artifact(&artifact)?, Command::Manifest { artifact, output } => write_manifest(&artifact, &output)?, Command::Appstream { artifact, output } => appstream_command(&artifact, output.as_deref())?, + Command::Daemon { socket } => run_daemon(socket, config).await?, } Ok(()) } @@ -2870,6 +2882,15 @@ fn save_search_cache(path: &Path, cache: &SearchCache) -> Result<()> { Ok(()) } +#[derive(Debug, Clone, Serialize)] +struct SearchResult { + release_event_id: String, + publisher: String, + name: String, + version: String, + content: String, +} + async fn search_releases( query: &str, relays: &[String], @@ -2878,6 +2899,34 @@ async fn search_releases( refresh: bool, no_cache: bool, ) -> Result<()> { + for result in search_matching_releases( + query, + relays, + trusted_publishers, + user_pubkey, + refresh, + no_cache, + ) + .await? + { + println!( + "{} {} {}", + result.release_event_id, + display_publisher(&result.publisher), + result.content + ); + } + Ok(()) +} + +async fn search_matching_releases( + query: &str, + relays: &[String], + trusted_publishers: &[String], + user_pubkey: Option<&str>, + refresh: bool, + no_cache: bool, +) -> Result> { const SEARCH_CACHE_MAX_AGE_SECS: u64 = 300; let cache_path = search_cache_path(query, relays, trusted_publishers, user_pubkey); let cached = if !refresh && !no_cache { @@ -2977,6 +3026,7 @@ async fn search_releases( } matches.push(event); } + let mut results = Vec::new(); for event in matches { let name = tag_value(&event, "name").unwrap_or_default(); let version = tag_value(&event, "version").unwrap_or_default(); @@ -2985,17 +3035,504 @@ async fn search_releases( .get(&key) .is_some_and(|latest| latest == &Version::parse(version).unwrap()) { - println!( - "{} {} {}", - event.id, - display_publisher(&event.pubkey.to_hex()), - event.content - ); + results.push(SearchResult { + release_event_id: event.id.to_hex(), + publisher: event.pubkey.to_hex(), + name: name.to_owned(), + version: version.to_owned(), + content: event.content.clone(), + }); + } + } + Ok(results) +} + +// npackd: a local JSON-RPC-over-Unix-socket service exposing the core +// operations a GUI store or other tool needs (Phase 2 of the roadmap), so +// clients do not need to understand Nostr, Blossom, or .npk internals. +// Requests and responses are newline-delimited JSON: +// {"id": 1, "method": "ListInstalled", "params": {"user": true}} +// {"id": 1, "result": [...]} +// Transaction/progress-event streaming is deferred to a later phase; Install +// and Update run to completion before responding. + +#[derive(Debug, Deserialize)] +struct DaemonRequest { + id: u64, + method: String, + #[serde(default)] + params: serde_json::Value, +} + +#[derive(Debug, Serialize)] +struct DaemonResponse { + id: u64, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl DaemonResponse { + fn ok(id: u64, result: serde_json::Value) -> Self { + Self { + id, + result: Some(result), + error: None, + } + } + + fn err(id: u64, error: impl std::fmt::Display) -> Self { + Self { + id, + result: None, + error: Some(error.to_string()), + } + } +} + +fn daemon_socket_path(override_path: Option) -> PathBuf { + override_path.unwrap_or_else(|| { + dirs::runtime_dir() + .unwrap_or_else(std::env::temp_dir) + .join("npackd.sock") + }) +} + +async fn run_daemon(socket: Option, config: Config) -> Result<()> { + let socket_path = daemon_socket_path(socket); + if let Some(parent) = socket_path.parent() { + fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + } + if socket_path.exists() { + fs::remove_file(&socket_path) + .with_context(|| format!("removing stale socket {}", socket_path.display()))?; + } + let listener = tokio::net::UnixListener::bind(&socket_path) + .with_context(|| format!("binding {}", socket_path.display()))?; + eprintln!("npackd listening on {}", socket_path.display()); + // Connections are handled one at a time rather than spawned onto the + // runtime: the recursive dependency-install future is not `Send` (it + // uses `Pin>` without a `Send` bound), which rules + // out `tokio::spawn`. Concurrent request handling is future work. + loop { + let (stream, _) = listener.accept().await?; + if let Err(error) = handle_daemon_connection(stream, &config).await { + eprintln!("npackd connection error: {error:#}"); + } + } +} + +async fn handle_daemon_connection(stream: tokio::net::UnixStream, config: &Config) -> Result<()> { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + let (reader, mut writer) = stream.into_split(); + let mut lines = tokio::io::BufReader::new(reader).lines(); + while let Some(line) = lines.next_line().await? { + if line.trim().is_empty() { + continue; } + let response = match serde_json::from_str::(&line) { + Ok(request) => { + let id = request.id; + match dispatch_daemon_request(config, request).await { + Ok(result) => DaemonResponse::ok(id, result), + Err(error) => DaemonResponse::err(id, error), + } + } + Err(error) => DaemonResponse { + id: 0, + result: None, + error: Some(format!("invalid request: {error}")), + }, + }; + let mut serialized = serde_json::to_string(&response)?; + serialized.push('\n'); + writer.write_all(serialized.as_bytes()).await?; + writer.flush().await?; } Ok(()) } +async fn dispatch_daemon_request( + config: &Config, + request: DaemonRequest, +) -> Result { + match request.method.as_str() { + "Search" => daemon_search(config, request.params).await, + "GetPackage" => daemon_get_package(config, request.params).await, + "ListInstalled" => daemon_list_installed(config, request.params), + "Install" => daemon_install(config, request.params).await, + "Remove" => daemon_remove(config, request.params), + "Update" => daemon_update(config, request.params).await, + "CheckUpdates" => daemon_check_updates(config, request.params).await, + other => bail!("unknown method {other}"), + } +} + +#[derive(Debug, Deserialize, Default)] +struct SearchParams { + query: String, + #[serde(default)] + relay: Vec, + #[serde(default)] + trusted_publisher: Vec, + #[serde(default)] + pubkey: Option, + #[serde(default)] + refresh: bool, + #[serde(default)] + no_cache: bool, +} + +async fn daemon_search(config: &Config, params: serde_json::Value) -> Result { + let params: SearchParams = serde_json::from_value(params).context("invalid Search params")?; + let relays = configured_relays(params.relay, config)?; + let trusted_publishers = configured_publishers(params.trusted_publisher, config)?; + let pubkey = params.pubkey.or_else(|| config.identity.pubkey.clone()); + let results = search_matching_releases( + ¶ms.query, + &relays, + &trusted_publishers, + pubkey.as_deref(), + params.refresh, + params.no_cache, + ) + .await?; + Ok(serde_json::to_value(results)?) +} + +#[derive(Debug, Deserialize)] +struct GetPackageParams { + package: String, + #[serde(default)] + relay: Vec, + #[serde(default)] + requirement: Option, + #[serde(default)] + os: Option, + #[serde(default)] + arch: Option, + #[serde(default)] + trusted_publisher: Vec, + #[serde(default)] + store: Option, + #[serde(default)] + user: bool, +} + +async fn daemon_get_package( + config: &Config, + params: serde_json::Value, +) -> Result { + let params: GetPackageParams = + serde_json::from_value(params).context("invalid GetPackage params")?; + let relays = configured_relays(params.relay, config)?; + let trusted_publishers = configured_publishers(params.trusted_publisher, config)?; + let (publisher, name) = params + .package + .split_once('/') + .map_or((None, params.package.as_str()), |(publisher, name)| { + (Some(normalize_publisher_reference(publisher)), name) + }); + let use_user = params.user || config.install.user; + let root = install_paths(params.store.as_deref(), use_user).0; + let client = Client::default(); + for relay in &relays { + client.add_relay(relay).await?; + } + connect_with_timeout(&client).await?; + let resolved = resolve_release( + &client, + &root, + name, + publisher.as_deref(), + params.requirement.as_deref(), + &trusted_publishers, + None, + params.os.as_deref().unwrap_or(OS), + params.arch.as_deref().unwrap_or(ARCH), + ) + .await; + client.disconnect().await; + Ok(serde_json::to_value(resolved?)?) +} + +#[derive(Debug, Deserialize, Default)] +struct ListInstalledParams { + #[serde(default)] + user: bool, + #[serde(default)] + store: Option, +} + +fn daemon_list_installed(config: &Config, params: serde_json::Value) -> Result { + let params: ListInstalledParams = + serde_json::from_value(params).context("invalid ListInstalled params")?; + let use_user = params.user || config.install.user; + let root = install_paths(params.store.as_deref(), use_user).0; + Ok(serde_json::to_value(installed_packages(Some(&root))?)?) +} + +#[derive(Debug, Deserialize)] +struct InstallParams { + package: String, + #[serde(default)] + requirement: Option, + #[serde(default)] + relay: Vec, + #[serde(default)] + server: Vec, + #[serde(default)] + user: bool, + #[serde(default)] + store: Option, + #[serde(default)] + allow_capability: Vec, +} + +async fn daemon_install(config: &Config, params: serde_json::Value) -> Result { + let params: InstallParams = serde_json::from_value(params).context("invalid Install params")?; + let package = params.package.clone(); + install_remote_command( + &package, + params.requirement, + params.relay, + params.server, + params.store.clone(), + params.user, + false, + Vec::new(), + None, + None, + false, + false, + params.allow_capability, + config, + ) + .await?; + let use_user = params.user || config.install.user; + let root = install_paths(params.store.as_deref(), use_user).0; + let (publisher, name) = package + .split_once('/') + .map_or((None, package.as_str()), |(publisher, name)| { + (Some(normalize_publisher_reference(publisher)), name) + }); + let installed = installed_packages(Some(&root))? + .into_iter() + .find(|candidate| { + candidate.name == name + && publisher + .as_deref() + .is_none_or(|publisher| candidate.publisher == publisher) + }) + .context("install reported success but the package was not found afterward")?; + Ok(serde_json::to_value(installed)?) +} + +#[derive(Debug, Deserialize)] +struct RemoveParams { + package: String, + #[serde(default)] + user: bool, + #[serde(default)] + store: Option, +} + +fn daemon_remove(config: &Config, params: serde_json::Value) -> Result { + let params: RemoveParams = serde_json::from_value(params).context("invalid Remove params")?; + let use_user = params.user || config.install.user; + remove_package_at(¶ms.package, params.store.as_deref(), use_user)?; + Ok(serde_json::json!({ "removed": params.package })) +} + +#[derive(Debug, Serialize)] +struct UpdateOutcome { + reference: String, + previous_version: String, + updated: bool, + new_version: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct UpdateParams { + #[serde(default)] + package: Option, + #[serde(default)] + relay: Vec, + #[serde(default)] + server: Vec, + #[serde(default)] + user: bool, + #[serde(default)] + store: Option, + #[serde(default)] + allow_capability: Vec, +} + +async fn daemon_update(config: &Config, params: serde_json::Value) -> Result { + let params: UpdateParams = serde_json::from_value(params).context("invalid Update params")?; + let use_user = params.user || config.install.user; + let root = install_paths(params.store.as_deref(), use_user).0; + let mut installed = installed_packages(Some(&root))?; + if let Some(package) = ¶ms.package { + let (publisher, name) = package + .split_once('/') + .map_or((None, package.as_str()), |(publisher, name)| { + (Some(normalize_publisher_reference(publisher)), name) + }); + installed.retain(|candidate| { + candidate.name == name + && publisher + .as_deref() + .is_none_or(|publisher| candidate.publisher == publisher) + }); + if installed.is_empty() { + bail!("{package} is not installed"); + } + } + let mut outcomes = Vec::new(); + for package in installed { + let reference = installed_package_reference(&package); + let result = install_remote_command( + &reference, + Some(format!(">{}", package.version)), + params.relay.clone(), + params.server.clone(), + params.store.clone(), + params.user, + false, + Vec::new(), + None, + None, + false, + false, + params.allow_capability.clone(), + config, + ) + .await; + match result { + Ok(()) => { + let new_version = installed_packages(Some(&root))? + .into_iter() + .find(|candidate| { + candidate.name == package.name && candidate.publisher == package.publisher + }) + .map(|candidate| candidate.version); + outcomes.push(UpdateOutcome { + reference, + previous_version: package.version, + updated: true, + new_version, + }); + } + Err(error) + if error.to_string() + == format!("no verified release found for {}", package.name) => + { + outcomes.push(UpdateOutcome { + reference, + previous_version: package.version, + updated: false, + new_version: None, + }); + } + Err(error) => return Err(error), + } + } + Ok(serde_json::to_value(outcomes)?) +} + +#[derive(Debug, Serialize)] +struct UpdateStatus { + reference: String, + current_version: String, + available_version: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct CheckUpdatesParams { + #[serde(default)] + package: Option, + #[serde(default)] + relay: Vec, + #[serde(default)] + trusted_publisher: Vec, + #[serde(default)] + user: bool, + #[serde(default)] + store: Option, +} + +async fn daemon_check_updates( + config: &Config, + params: serde_json::Value, +) -> Result { + let params: CheckUpdatesParams = + serde_json::from_value(params).context("invalid CheckUpdates params")?; + let relays = configured_relays(params.relay, config)?; + let trusted_publishers = configured_publishers(params.trusted_publisher, config)?; + let use_user = params.user || config.install.user; + let root = install_paths(params.store.as_deref(), use_user).0; + let mut installed = installed_packages(Some(&root))?; + if let Some(package) = ¶ms.package { + let (publisher, name) = package + .split_once('/') + .map_or((None, package.as_str()), |(publisher, name)| { + (Some(normalize_publisher_reference(publisher)), name) + }); + installed.retain(|candidate| { + candidate.name == name + && publisher + .as_deref() + .is_none_or(|publisher| candidate.publisher == publisher) + }); + if installed.is_empty() { + bail!("{package} is not installed"); + } + } + let client = Client::default(); + for relay in &relays { + client.add_relay(relay).await?; + } + connect_with_timeout(&client).await?; + let mut statuses = Vec::new(); + for package in &installed { + let requirement = format!(">{}", package.version); + let available = match resolve_release( + &client, + &root, + &package.name, + Some(&package.publisher), + Some(&requirement), + &trusted_publishers, + None, + OS, + ARCH, + ) + .await + { + Ok(resolved) => Some(resolved.version), + Err(error) + if error.to_string() + == format!("no verified release found for {}", package.name) => + { + None + } + Err(error) => { + client.disconnect().await; + return Err(error); + } + }; + statuses.push(UpdateStatus { + reference: installed_package_reference(package), + current_version: package.version.clone(), + available_version: available, + }); + } + client.disconnect().await; + Ok(serde_json::to_value(statuses)?) +} + fn verify_release_event(event: &Event, manifest: &Manifest) -> Result<()> { if event.kind.as_u16() != RELEASE_KIND { bail!( @@ -6041,4 +6578,54 @@ mod tests { assert!(verify_locked_install(&capabilities, root.path()).is_err()); Ok(()) } + + #[tokio::test] + async fn daemon_serves_list_installed_and_rejects_unknown_methods() -> Result<()> { + let dir = tempdir()?; + let socket_path = dir.path().join("npackd.sock"); + let store = dir.path().join("store"); + fs::create_dir_all(&store)?; + + let listener = tokio::net::UnixListener::bind(&socket_path)?; + let config = Config::default(); + let server = async { + let (stream, _) = listener.accept().await.unwrap(); + handle_daemon_connection(stream, &config).await.unwrap(); + }; + + let client_work = async { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + let mut client = tokio::net::UnixStream::connect(&socket_path).await?; + let request = serde_json::json!({ + "id": 1, + "method": "ListInstalled", + "params": { "store": store }, + }); + client + .write_all(format!("{}\n", request).as_bytes()) + .await?; + client + .write_all(b"{\"id\":2,\"method\":\"NoSuchMethod\"}\n") + .await?; + drop(client.shutdown().await); + + let (read_half, _write_half) = client.into_split(); + let mut lines = BufReader::new(read_half).lines(); + + let first = lines.next_line().await?.context("expected first reply")?; + let first: serde_json::Value = serde_json::from_str(&first)?; + assert_eq!(first["id"], 1); + assert_eq!(first["result"], serde_json::json!([])); + + let second = lines.next_line().await?.context("expected second reply")?; + let second: serde_json::Value = serde_json::from_str(&second)?; + assert_eq!(second["id"], 2); + assert!(second["error"].as_str().unwrap().contains("unknown method")); + Ok::<(), anyhow::Error>(()) + }; + + let ((), result) = tokio::join!(server, client_work); + result?; + Ok(()) + } }