refactor: add upm application facade and module api

This commit is contained in:
stoorps 2026-03-21 23:43:14 +00:00
parent 005d6ebfdb
commit e2a01d3095
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
36 changed files with 1058 additions and 607 deletions

View file

@ -2,27 +2,92 @@
## Workspace Shape
`upm` is a Rust workspace with three main crates:
`upm` is a Rust workspace with three main crates today and a fourth planned frontend:
- `crates/upm-core`: source normalization, add/update orchestration, registry persistence, install policies, desktop integration, and the provider-composition seam.
- `crates/upm`: argument parsing, config loading, terminal UX, prompting, progress reporting, summary rendering, and provider assembly.
- `crates/upm-appimage`: AppImageHub transport, search-provider behavior, and exact add-resolution for AppImage-backed installs.
- `crates/upm-core`: the application layer for `upm`. It owns command orchestration, module contracts, module registry and composition, registry persistence, install policies, desktop integration, and the unified frontend-facing API that both the CLI and a future GUI will call.
- `crates/upm`: the CLI frontend over `upm-core`. It handles argument parsing, config loading, terminal UX, prompting, progress reporting, summary rendering, and config-driven module presentation.
- `crates/upm-appimage`: the AppImage package-manager module. It should own AppImage-specific acquisition backends, artifact selection, and install-resolution behavior.
- `crates/upm-ui` (planned): a GUI frontend over `upm-core`, not a second application layer.
The split keeps frontend-agnostic logic in `upm-core`, while concrete package-source behavior is composed at the CLI boundary. That keeps the headless layer reusable for future frontends without making provider behavior a permanent core dependency.
The intended split is strict:
- `upm-core` is effectively the application
- `upm` is one frontend over that application
- `upm-ui` will be another frontend over that application
- package-manager modules own their own implementation detail and speak to `upm-core` through normalized traits
That keeps frontend-agnostic logic in `upm-core`, makes a future GUI a first-class consumer instead of a later retrofit, and prevents frontend layers from accumulating package-manager-specific behavior.
## Application Boundary
The architectural boundary is:
- `upm` may know which modules exist for configuration, enablement, disablement, priority, and display
- `upm-ui` should operate under the same rule as the CLI: it talks to `upm-core`, not directly to modules
- `upm` must not talk directly to a package-manager module or implement module-specific logic
- `upm-core` owns the unified application interface used by the CLI now and a GUI later
- `upm-core` owns module registration, composition, enablement checks, and request fan-out
- `upm-core` fans requests out to enabled modules and aggregates normalized results
- each module owns its own internal backends, source quirks, artifact selection, and provider-specific rules
In practical terms, `upm-core` is where the product behavior lives. The CLI should remain replaceable.
## Public API Shape
`upm-core` should expose one high-level application facade to frontend crates.
- the public boundary should be an application-facing type such as `UpmApp`
- the facade should present operations like search, add, show, update, remove, and config management in product terms
- frontends should not compose lower-level orchestration services themselves
That public facade should stay thin. The internal implementation in `upm-core` can and should be split into smaller services such as:
- module registry and module loading
- search orchestration
- add planning and execution
- show resolution
- update planning and execution
- configuration and state services
This gives both frontends one stable application boundary without turning the facade into a god object. The orchestration depth stays inside `upm-core`, where it belongs.
## Module Tree
The intended tree is:
- `upm-core`
- public application facade
- internal orchestration services
- module registry and composition
- normalized contracts for package-manager modules
- frontend crates
- `upm` for CLI concerns only
- `upm-ui` for GUI concerns only
- module crates
- `upm-appimage`
- AppImageHub backend
- GitHub-backed AppImage acquisition
- GitLab-backed AppImage acquisition
- SourceForge-backed AppImage acquisition
- direct AppImage URL handling
- AppImage-specific artifact and metadata rules
The important constraint is that the top layer understands package-manager modules, not the inner mechanics of how each module finds or resolves artifacts.
## Core Flow
The main execution path is:
1. Parse CLI input and load runtime config in `upm`.
2. Assemble a `ProviderRegistry` in `crates/upm/src/providers.rs`.
3. Resolve the query into a normalized source in `upm-core`.
4. Build an add or update plan through core orchestration plus any registered external providers.
4. Download the selected AppImage into a staged path.
5. Verify integrity metadata when available.
6. Commit the payload into the managed install location.
7. Write desktop integration artifacts and refresh helper caches.
8. Persist registry state atomically.
2. Call the unified application facade in `upm-core`.
3. Let `upm-core` route the request into internal orchestration services.
4. Let those services select enabled modules and fan the request out through normalized module traits.
5. Aggregate normalized results into an add, show, update, search, or remove flow.
6. Download the selected AppImage into a staged path when the chosen module requires it.
7. Verify integrity metadata when available.
8. Commit the payload into the managed install location.
9. Write desktop integration artifacts and refresh helper caches.
10. Persist registry state atomically.
## Source And Provider Model
@ -35,7 +100,9 @@ Supported source classes currently include:
- direct URLs
- local file imports
Core source normalization and orchestration live in `crates/upm-core`. AppImageHub-specific transport and provider behavior live in `crates/upm-appimage` and are injected through `ProviderRegistry` rather than hardcoded into core entrypoints.
Core orchestration and normalized module contracts live in `crates/upm-core`. Package-manager-specific behavior belongs in module crates.
For the AppImage module, that means `crates/upm-appimage` is the package-manager boundary and should grow to own AppImage-specific backing sources internally. `upm-core` should coordinate the module through normalized traits, not absorb AppImageHub, GitHub-backed AppImage discovery, GitLab-backed AppImage discovery, SourceForge-backed AppImage discovery, or direct AppImage URL handling as first-class application concepts.
## Runtime Interface

View file

@ -2,9 +2,9 @@
## Direction
The project is evolving from a focused AppImage manager into `upm`, a modular universal package manager. The target system manages multiple package sources through a shared headless core, keeps the CLI thin, and leaves room for future GUI frontends.
The project is evolving from a focused AppImage manager into `upm`, a modular universal package manager. The target system manages multiple package-manager modules through a shared headless application core, keeps frontend crates thin, and leaves room for future GUI frontends.
The initial rename-and-extraction slice has now landed as a hard cutover: the runtime surface is `upm`, the shared core is `upm-core`, and AppImage support is composed through `upm-appimage` instead of being embedded directly into the core.
The initial rename-and-extraction slice has now landed as a hard cutover: the runtime surface is `upm`, the shared core is `upm-core`, and AppImage support is beginning its transition into `upm-appimage` instead of remaining embedded directly into the core.
The near-term goal is a Linux-first platform with honest cross-platform architecture. Phase 2 will implement Linux package sources while establishing the abstractions needed for later macOS support and possible Windows support.
@ -14,7 +14,8 @@ The near-term goal is a Linux-first platform with honest cross-platform architec
- `aim-core` stops being the all-in-one backend and is split.
- AppImage support becomes an installable module named `upm-appimage`.
- Shared orchestration, config, state, resolution, ranking, and frontend-facing APIs move into `upm-core`.
- The `upm` crate becomes a thin CLI client over `upm-core`.
- The `upm` crate becomes a thin CLI frontend over `upm-core`.
- A future `upm-ui` crate will become a GUI frontend over the same `upm-core` application boundary.
- The rename is a hard cutover. Legacy `AIM_*` runtime interfaces are removed rather than preserved.
- The declarative package file starts in a hybrid mode and is intended to become the source of truth over time.
- Every `upm` invocation should detect drift between declared state and observed system state, then auto-sync metadata as needed.
@ -29,9 +30,10 @@ The near-term goal is a Linux-first platform with honest cross-platform architec
The intended workspace shape after the initial refactor is:
- `upm`: thin CLI frontend, ratatui config UI, command routing, presentation.
- `upm-core`: headless application layer, provider registry, resolution pipeline, state model, declarative sync engine, ranking, policies, and frontend-agnostic APIs.
- `upm-appimage`: AppImage provider module extracted from the current `aim-core` implementation.
- future provider modules: `upm-pacman`, `upm-aur`, `upm-flatpak`, `upm-cargo`, `upm-npm`, and later macOS or Windows-specific modules.
- `upm-ui`: future GUI frontend over the same application core.
- `upm-core`: headless application layer, public application facade, internal orchestration services, module registry, state model, declarative sync engine, ranking, policies, and frontend-agnostic APIs.
- `upm-appimage`: AppImage package-manager module extracted from the current `aim-core` implementation.
- future module crates: `upm-pacman`, `upm-aur`, `upm-flatpak`, `upm-cargo`, `upm-npm`, and later macOS or Windows-specific modules.
### Module Model
@ -42,7 +44,9 @@ UPM should stay modular in both code and packaging:
- distro packaging can offer grouped installs such as `upm-full`
- lighter installs can ship only the core and selected modules
This means provider capabilities, discovery, search, install, remove, inspect, and sync behavior need stable interfaces in `upm-core` rather than provider-specific branching in the CLI.
This means module capabilities, discovery, search, install, remove, inspect, and sync behavior need stable interfaces in `upm-core` rather than package-manager-specific branching in the CLI or GUI.
The public API shape should be one application facade in `upm-core`, backed by smaller internal services. Frontends should call the facade; they should not compose lower-level services or talk directly to modules.
### State Model
@ -65,7 +69,7 @@ Goals:
- create `upm-core` by extracting reusable infrastructure from `aim-core`
- reduce the CLI crate to a frontend over headless APIs
- isolate current AppImage-specific logic into `upm-appimage`
- compose provider behavior in the CLI through `ProviderRegistry` rather than hardcoded AppImage paths in `upm-core`
- remove direct AppImage composition from the CLI and move module composition into `upm-core`
- preserve current AppImage functionality and tests during the move
Exit criteria:
@ -76,18 +80,20 @@ Exit criteria:
### Milestone 1: AppImage On The New Core
Make the current AppImage implementation the first real module on the modular architecture.
Make the current AppImage implementation the first real package-manager module on the modular architecture.
Goals:
- validate the provider module contract using AppImage as the reference implementation
- move search, add, install, update, show, and remove behaviors behind core provider APIs
- prove the CLI can treat AppImage as just one enabled source
- establish `upm-core` as the sole application boundary for CLI and future GUI frontends
- validate the module contract using AppImage as the reference implementation
- move AppImage-specific acquisition backends behind `upm-appimage`
- prove the CLI can treat AppImage as just one enabled module
Exit criteria:
- AppImage support is no longer special-cased as the whole product
- provider registration and capability discovery exist in `upm-core`
- CLI command paths run through the `upm-core` application facade
- AppImage acquisition minutia no longer lives as top-level application concepts in `upm-core`
### Milestone 2: Linux Native Sources
@ -175,8 +181,8 @@ Exit criteria:
The following are explicitly not required to complete Phase 2:
- full macOS provider implementation
- Windows provider implementation
- full macOS module implementation
- Windows module implementation
- GUI frontend delivery
- forcing strict config-authoritative reconciliation before provider behavior is stable
- shipping every conceivable Linux package manager in the first expansion
@ -199,7 +205,7 @@ That means:
Implementation plans should follow this order:
1. rename and crate extraction
2. provider API definition and AppImage migration onto `upm-core`
2. application facade definition and AppImage migration behind a real module boundary
3. Linux provider onboarding in a stable order, likely `pacman` then `Flatpak`, then `AUR`, then `cargo`, then `npm`
4. ratatui configuration and ranking UX
5. declarative state model, drift detection, and `update` sync behavior

View file

@ -2,57 +2,59 @@
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Validate AppImage as the first real provider module by making `upm-core` treat provider composition as the normal path for capability discovery, remote show resolution, and update execution.
**Goal:** Make `upm-appimage` the first real package-manager module by moving AppImage-specific acquisition paths behind a module boundary and exposing a single application facade from `upm-core` to the CLI and future GUI.
**Architecture:** Keep AppImage-specific transport and resolution logic in `upm-appimage`. Extend `upm-core` only with generic provider-registry contracts and plumbing so the CLI can compose providers once and reuse that composition across `search`, `add`, `show`, and `update`. Avoid introducing speculative provider hooks for `remove` or `list`; those flows are already generic and should stay that way until a real provider needs more surface area.
**Architecture:** `upm-core` becomes the application boundary. It should own a public facade, internal orchestration services, and module registration and composition. `upm` stays a thin frontend and must stop composing AppImage behavior directly. `upm-appimage` becomes the AppImage package-manager module and should absorb AppImageHub plus the other AppImage-producing backends that are still modeled as top-level source concepts in the core.
**Tech Stack:** Rust workspace, `upm`, `upm-core`, `upm-appimage`, Cargo integration tests, fixture-backed provider tests, CLI end-to-end tests.
---
### Task 1: Add capability discovery to `ProviderRegistry`
### Task 1: Define the public application facade in `upm-core`
**Files:**
- Modify: `crates/upm-core/src/app/providers.rs`
- Modify: `crates/upm-core/src/app/`
- Modify: `crates/upm-core/src/lib.rs`
- Test: `crates/upm-core/tests/provider_registry.rs`
- Test: `crates/upm-core/tests/`
**Step 1: Write the failing capability-discovery expectations**
**Step 1: Write the failing facade expectations**
Extend `crates/upm-core/tests/provider_registry.rs` to assert:
Add focused tests proving that:
- the registry can report which provider ids are registered
- the registry can report whether a provider supports search and/or external add
- empty registries report no capabilities
- `upm-core` exposes one public application-facing entrypoint for frontend consumers
- that entrypoint can be constructed without the CLI owning module composition
- the public surface delegates to internal services instead of exposing module wiring details
Keep the expectations generic. Do not encode AppImage-only behavior into the registry API.
Keep the assertions about API shape and ownership, not AppImage specifics.
**Step 2: Run the focused test to verify failure**
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package upm-core --test provider_registry
cargo test --package upm-core
```
Expected: FAIL because `ProviderRegistry` is still only a passive bag of references.
Expected: FAIL because the current public surface still assumes narrower provider plumbing and does not expose the intended facade cleanly.
**Step 3: Implement minimal capability discovery**
**Step 3: Implement the minimal facade**
Update `crates/upm-core/src/app/providers.rs` so `ProviderRegistry` exposes a small, stable query surface such as:
Introduce or reshape the public API so `upm-core` exposes a single high-level application facade.
- a way to enumerate registered provider ids
- a way to report capabilities for a provider id
- a small capability record rather than operation-specific booleans scattered around callers
The public boundary should:
Do not add plugin loading, global registries, or dynamic configuration yet.
- represent product operations such as search, add, show, update, remove, and config handling
- hide module registry and orchestration details from frontends
- stay thin and delegate work to internal services
**Step 4: Run the focused test to verify pass**
Do not add dynamic plugin loading yet.
**Step 4: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm-core --test provider_registry
cargo test --package upm-core
```
Expected: PASS.
@ -60,113 +62,115 @@ Expected: PASS.
**Step 5: Commit**
```bash
git add crates/upm-core/src/app/providers.rs crates/upm-core/src/lib.rs crates/upm-core/tests/provider_registry.rs
git commit -m "feat: add provider capability discovery"
git add crates/upm-core/src crates/upm-core/tests
git commit -m "feat: add application facade to upm-core"
```
### Task 2: Route remote `show` through registered providers
### Task 2: Move module composition out of the CLI and into `upm-core`
**Files:**
- Modify: `crates/upm-core/src/app/`
- Modify: `crates/upm/src/lib.rs`
- Modify: `crates/upm/src/providers.rs`
- Test: `crates/upm-core/tests/`
- Test: `crates/upm/tests/end_to_end_cli.rs`
**Step 1: Write the failing ownership expectations**
Add coverage proving that:
- the CLI does not assemble AppImage module composition directly
- the application facade can build or receive module composition internally
- CLI command paths still behave the same through the new boundary
Prefer one core ownership test and one CLI integration test.
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package upm --test end_to_end_cli
```
Expected: FAIL because the CLI still owns direct provider assembly.
**Step 3: Move composition into `upm-core`**
Update the architecture so:
- `upm-core` owns module registration and composition
- the CLI constructs the application facade rather than AppImage-specific registries
- direct module composition in `crates/upm/src/providers.rs` is removed or reduced to generic application bootstrapping
Keep CLI UX, rendering, and summary formatting unchanged.
**Step 4: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm --test end_to_end_cli
```
Expected: PASS.
**Step 5: Commit**
```bash
git add crates/upm-core/src crates/upm/src/lib.rs crates/upm/src/providers.rs crates/upm/tests/end_to_end_cli.rs
git commit -m "refactor: move module composition into upm-core"
```
### Task 3: Turn `upm-appimage` into the AppImage package-manager boundary
**Files:**
- Modify: `crates/upm-appimage/src/`
- Modify: `crates/upm-core/src/domain/source.rs`
- Modify: `crates/upm-core/src/app/add.rs`
- Modify: `crates/upm-core/src/app/show.rs`
- Modify: `crates/upm/src/lib.rs`
- Test: `crates/upm-core/tests/show_resolution.rs`
- Test: `crates/upm/tests/end_to_end_cli.rs`
**Step 1: Write the failing `show` expectations**
Add coverage proving that:
- remote `show appimagehub/<id>` resolves through the registered provider path
- installed-app `show` behavior remains unchanged
- unsupported queries still fail distinctly from provider-backed remote queries
Prefer one new `upm-core` test for remote resolution and one CLI-facing assertion in `crates/upm/tests/end_to_end_cli.rs`.
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package upm-core --test show_resolution
cargo test --package upm --test end_to_end_cli
```
Expected: FAIL because the remote show path still calls the add planner without a `ProviderRegistry`.
**Step 3: Thread provider composition through `show`**
Update the show pipeline so:
- `upm-core` exposes provider-aware show entrypoints alongside the current defaults
- remote show resolution uses `build_add_plan_with_registered_providers` rather than the provider-blind path
- the CLI wraps remote show dispatch in `providers::with_provider_registry(...)`
Keep installed-record rendering and summary formatting unchanged.
**Step 4: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm-core --test show_resolution
cargo test --package upm --test end_to_end_cli
```
Expected: PASS.
**Step 5: Commit**
```bash
git add crates/upm-core/src/app/show.rs crates/upm/src/lib.rs crates/upm-core/tests/show_resolution.rs crates/upm/tests/end_to_end_cli.rs
git commit -m "feat: route show through provider registry"
```
### Task 3: Route `update` execution through registered providers
**Files:**
- Modify: `crates/upm-core/src/app/update.rs`
- Modify: `crates/upm/src/lib.rs`
- Test: `crates/upm-core/tests/update_planning.rs`
- Test: `crates/upm/tests/end_to_end_cli.rs`
- Test: `crates/upm-appimage/tests/`
- Test: `crates/upm-core/tests/`
**Step 1: Write the failing `update` expectations**
**Step 1: Write the failing module-boundary expectations**
Add coverage proving that:
- AppImage-backed records can be refreshed through the update path with registered providers
- existing GitHub and direct-url update behavior remains unchanged
- the update execution path still restores previous payloads on failure
- AppImage-backed acquisition through GitHub, GitLab, SourceForge, direct URLs, and AppImageHub resolves through `upm-appimage`
- `upm-core` no longer treats those AppImage-producing backends as top-level package-manager concepts
- add, show, and update continue to work through normalized module contracts
Prefer focused `upm-core` tests plus one CLI integration assertion for an AppImage-backed update review or execution path.
Prefer module-focused tests plus a small number of core integration tests.
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package upm-core --test update_planning
cargo test --package upm --test end_to_end_cli
cargo test --package upm-appimage
cargo test --package upm-core
```
Expected: FAIL because update execution still rebuilds add plans without a `ProviderRegistry`.
Expected: FAIL because AppImage acquisition paths are still split between the core and the AppImage module.
**Step 3: Thread provider composition through `update`**
**Step 3: Move AppImage-specific acquisition logic behind the module**
Update the update pipeline so:
Reshape the source and module boundary so:
- provider-aware update entrypoints exist in `upm-core`
- `execute_update` rebuilds plans through the provider-aware add planner
- the CLI wraps `update` execution in `providers::with_provider_registry(...)`
- AppImage-specific GitHub, GitLab, SourceForge, AppImageHub, and direct URL handling lives in `upm-appimage`
- `upm-core` coordinates AppImage work through normalized module contracts
- core source taxonomy is reduced or reframed so package-manager concepts stay above backend minutia
Do not generalize the update-channel model yet. Reuse the current channel semantics and only change how provider-backed plans are rebuilt.
Do not over-generalize for Flatpak or future providers yet. Only extract what the AppImage module demonstrably needs.
**Step 4: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm-core --test update_planning
cargo test --package upm --test end_to_end_cli
cargo test --package upm-appimage
cargo test --package upm-core
```
Expected: PASS.
@ -174,57 +178,57 @@ Expected: PASS.
**Step 5: Commit**
```bash
git add crates/upm-core/src/app/update.rs crates/upm/src/lib.rs crates/upm-core/tests/update_planning.rs crates/upm/tests/end_to_end_cli.rs
git commit -m "feat: route updates through provider registry"
git add crates/upm-appimage/src crates/upm-core/src crates/upm-appimage/tests crates/upm-core/tests
git commit -m "refactor: make upm-appimage the appimage module boundary"
```
### Task 4: Lock AppImage in as the reference provider module
### Task 4: Route search, add, show, and update through the application facade
**Files:**
- Modify: `crates/upm-appimage/tests/appimagehub_search.rs`
- Modify: `crates/upm-core/src/app/`
- Modify: `crates/upm/src/lib.rs`
- Modify: `crates/upm/tests/end_to_end_cli.rs`
- Modify: `crates/upm/tests/ui_summary.rs`
- Test: `crates/upm-core/tests/provider_registry.rs`
- Test: `crates/upm-core/tests/`
**Step 1: Write the failing reference-provider expectations**
**Step 1: Write the failing facade-routing expectations**
Add end-to-end coverage proving that AppImage support is fully module-driven:
- `search` still surfaces AppImageHub hits through the registry
- `show` for AppImageHub remote queries works through the registry
- `update` can refresh AppImage-backed records through the registry
- `search` flows through the public application facade
- `add` flows through the public application facade
- `show` flows through the public application facade
- `update` flows through the public application facade
- user-facing summaries still render truthful `upm` paths and origins
Keep the assertions focused on module composition rather than UI restyling.
Keep the assertions focused on boundary correctness rather than UI restyling.
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package upm-appimage --test appimagehub_search
cargo test --package upm --test end_to_end_cli
cargo test --package upm --test ui_summary
```
Expected: FAIL until the new `show` and `update` registry plumbing is complete.
Expected: FAIL until the facade is the normal command path.
**Step 3: Tighten provider-contract validation**
**Step 3: Tighten application-boundary validation**
Update the tests so they prove:
- AppImage is composed only through `upm-appimage`
- `ProviderRegistry` is the shared composition point for all AppImage-facing command paths
- AppImage is still not reintroduced as a hardcoded built-in inside `upm-core`
- frontend command handlers call the application facade rather than module-specific helpers
- AppImage is composed only through `upm-core` and `upm-appimage`
- AppImage is not reintroduced as a hardcoded built-in in the CLI
Do not move AppImageHub back into `all_adapter_kinds()`.
Do not reintroduce package-manager-specific branching in the CLI.
**Step 4: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm-appimage --test appimagehub_search
cargo test --package upm --test end_to_end_cli
cargo test --package upm --test ui_summary
```
@ -234,35 +238,35 @@ Expected: PASS.
**Step 5: Commit**
```bash
git add crates/upm-appimage/tests/appimagehub_search.rs crates/upm/tests/end_to_end_cli.rs crates/upm/tests/ui_summary.rs crates/upm-core/tests/provider_registry.rs
git commit -m "test: validate appimage as reference provider module"
git add crates/upm-core/src crates/upm/src/lib.rs crates/upm/tests/end_to_end_cli.rs crates/upm/tests/ui_summary.rs crates/upm-core/tests
git commit -m "refactor: route commands through upm-core facade"
```
### Task 5: Update architecture docs and run full verification
### Task 5: Lock the architecture into docs and verify the workspace
**Files:**
- Modify: `.architecture/overview.md`
- Modify: `.architecture/roadmap.md`
- Modify: `README.md`
**Step 1: Update docs for Milestone 1 completion state**
**Step 1: Update docs for the new module model**
Document:
- capability discovery in `ProviderRegistry`
- provider-aware `show` and `update` execution paths
- AppImage as the first validated provider module on the new architecture
- the explicit non-goal that `remove` and `list` remain generic until a provider needs extra hooks
- `upm-core` as the application boundary
- one public application facade over smaller internal services
- CLI and GUI as thin frontends
- `upm-appimage` as the AppImage package-manager boundary with internal acquisition backends
**Step 2: Verify the docs mention the Milestone 1 state**
**Step 2: Verify the docs mention the agreed architecture**
Run:
```bash
rg -n "ProviderRegistry|capabilit|upm-appimage|show|update" README.md .architecture/overview.md .architecture/roadmap.md
rg -n "upm-core|facade|upm-ui|upm-appimage|module" README.md .architecture/overview.md .architecture/roadmap.md
```
Expected: matches describing the expanded provider surface.
Expected: matches describing the application boundary, thin frontends, and module ownership.
**Step 3: Run full verification**
@ -280,5 +284,5 @@ Expected: PASS.
```bash
git add README.md .architecture/overview.md .architecture/roadmap.md
git commit -m "docs: describe appimage provider milestone"
git commit -m "docs: describe application facade architecture"
```

10
Cargo.lock generated
View file

@ -1959,6 +1959,7 @@ dependencies = [
"reqwest",
"serde",
"upm-core",
"upm-module-api",
]
[[package]]
@ -1975,6 +1976,15 @@ dependencies = [
"sha2",
"tempfile",
"toml",
"upm-appimage",
"upm-module-api",
]
[[package]]
name = "upm-module-api"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]

View file

@ -1,5 +1,6 @@
[workspace]
members = [
"crates/upm-module-api",
"crates/upm-core",
"crates/upm-appimage",
"crates/upm",

View file

@ -1,15 +1,16 @@
# upm
Universal Package Manager
`upm` is a Rust Cargo workspace for a modular package manager with a shared headless core and provider crates.
`upm` is a Rust Cargo workspace for a modular package manager with a shared headless application core, thin frontend crates, and package-manager modules.
## Workspace
- `crates/upm-core`: headless application layer for query normalization, resolution, planning, registry persistence, install/update orchestration, and provider-facing APIs
- `crates/upm-core`: headless application layer for query normalization, orchestration, module registration and composition, registry persistence, install/update planning, and the unified frontend-facing API
- `crates/upm`: thin terminal frontend for argument parsing, config loading, prompting, progress reporting, and summary rendering
- `crates/upm-appimage`: AppImageHub transport, search, and add-provider integration composed into the CLI through `ProviderRegistry`
- `crates/upm-appimage`: AppImage package-manager module responsible for AppImage-specific acquisition and resolution behavior
- `crates/upm-ui` (planned): GUI frontend over `upm-core`
The split is intentional so future frontends can reuse `upm-core`, while package-source behavior stays modular instead of being hardcoded into the core.
The split is intentional so future frontends can reuse `upm-core`, while package-manager behavior stays modular instead of being hardcoded into the core or the CLI.
## Commands
@ -37,11 +38,11 @@ upm remove <QUERY>
## Search
`upm search <QUERY>` is part of the initial modular provider surface.
`upm search <QUERY>` is part of the initial modular module surface.
- search is provider-extensible and currently includes GitHub plus AppImageHub
- search is module-extensible and currently includes the built-in core search path plus AppImage-backed search sources
- search results should resolve to install-ready queries such as `owner/repo` and `appimagehub/<id>`
- provider composition happens in `crates/upm/src/providers.rs`, not through AppImageHub-specific wiring inside `upm-core`
- module composition belongs in `upm-core`, not in the CLI frontend
## Scope Overrides

View file

@ -11,4 +11,7 @@ path = "src/lib.rs"
quick-xml.workspace = true
reqwest.workspace = true
serde.workspace = true
upm-module-api = { path = "../upm-module-api" }
[dev-dependencies]
upm-core = { path = "../upm-core" }

View file

@ -1,13 +1,14 @@
use crate::source::appimagehub::{
AppImageHubError, AppImageHubTransport, resolve_appimagehub_item, resolve_appimagehub_item_with,
};
use upm_core::adapters::traits::{
use upm_module_api::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter,
};
use upm_core::app::providers::{ExternalAddProvider, ExternalAddResolution};
use upm_core::app::query::resolve_query;
use upm_core::domain::source::{ResolvedRelease, SourceKind, SourceRef};
use upm_core::domain::update::{
use upm_module_api::app::providers::{ExternalAddProvider, ExternalAddResolution};
use upm_module_api::domain::source::{
NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef,
};
use upm_module_api::domain::update::{
ArtifactCandidate, ChannelPreference, UpdateChannelKind, UpdateStrategy,
};
@ -58,7 +59,7 @@ impl SourceAdapter for AppImageHubAdapter {
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
let source = resolve_appimagehub_query(query)?;
if source.kind != SourceKind::AppImageHub {
return Err(AdapterError::UnsupportedQuery);
}
@ -92,17 +93,17 @@ impl SourceAdapter for AppImageHubAdapter {
}
}
pub struct AppImageHubAddProvider<'a, T: AppImageHubTransport + ?Sized> {
transport: &'a T,
pub struct AppImageHubAddProvider {
transport: Box<dyn AppImageHubTransport>,
}
impl<'a, T: AppImageHubTransport + ?Sized> AppImageHubAddProvider<'a, T> {
pub fn new(transport: &'a T) -> Self {
impl AppImageHubAddProvider {
pub fn new(transport: Box<dyn AppImageHubTransport>) -> Self {
Self { transport }
}
}
impl<T: AppImageHubTransport + ?Sized> ExternalAddProvider for AppImageHubAddProvider<'_, T> {
impl ExternalAddProvider for AppImageHubAddProvider {
fn id(&self) -> &'static str {
"appimagehub"
}
@ -113,11 +114,11 @@ impl<T: AppImageHubTransport + ?Sized> ExternalAddProvider for AppImageHubAddPro
}
let adapter = AppImageHubAdapter;
let resolution = match adapter.resolve_source_with(source, self.transport)? {
let resolution = match adapter.resolve_source_with(source, self.transport.as_ref())? {
AdapterResolveOutcome::Resolved(resolution) => resolution,
AdapterResolveOutcome::NoInstallableArtifact { .. } => return Ok(None),
};
let Some(resolved_item) = resolve_appimagehub_item_with(source, self.transport)
let Some(resolved_item) = resolve_appimagehub_item_with(source, self.transport.as_ref())
.map_err(|error| AdapterError::ResolutionFailed(format!("{error:?}")))?
else {
return Ok(None);
@ -161,3 +162,35 @@ fn render_appimagehub_error(error: &AppImageHubError) -> String {
}
}
}
fn resolve_appimagehub_query(query: &str) -> Result<SourceRef, AdapterError> {
let trimmed = query.trim();
let id = if let Some(id) = trimmed.strip_prefix("appimagehub/") {
id
} else if let Some(id) = trimmed.strip_prefix("https://www.appimagehub.com/p/") {
id
} else if let Some(id) = trimmed.strip_prefix("http://www.appimagehub.com/p/") {
id
} else {
return Err(AdapterError::UnsupportedQuery);
};
if !id.chars().all(|ch| ch.is_ascii_digit()) {
return Err(AdapterError::UnsupportedQuery);
}
Ok(SourceRef {
kind: SourceKind::AppImageHub,
locator: format!("https://www.appimagehub.com/p/{id}"),
input_kind: if trimmed.starts_with("appimagehub/") {
SourceInputKind::AppImageHubShorthand
} else {
SourceInputKind::AppImageHubUrl
},
normalized_kind: NormalizedSourceKind::AppImageHub,
canonical_locator: Some(id.to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
})
}

View file

@ -1,25 +1,29 @@
use crate::source::appimagehub::{
AppImageHubSearchError, AppImageHubTransport, search_appimagehub_with,
};
use upm_core::app::search::{SearchProvider, SearchProviderError};
use upm_core::domain::search::{SearchInstallStatus, SearchQuery, SearchResult};
use upm_module_api::app::search::{SearchProvider, SearchProviderError};
use upm_module_api::domain::search::{SearchInstallStatus, SearchQuery, SearchResult};
pub struct AppImageHubSearchProvider<'a, T: AppImageHubTransport + ?Sized> {
transport: &'a T,
pub struct AppImageHubSearchProvider {
transport: Box<dyn AppImageHubTransport>,
}
impl<'a, T: AppImageHubTransport + ?Sized> AppImageHubSearchProvider<'a, T> {
pub fn new(transport: &'a T) -> Self {
impl AppImageHubSearchProvider {
pub fn new(transport: Box<dyn AppImageHubTransport>) -> Self {
Self { transport }
}
}
impl<T: AppImageHubTransport + ?Sized> SearchProvider for AppImageHubSearchProvider<'_, T> {
impl SearchProvider for AppImageHubSearchProvider {
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError> {
let hits = search_appimagehub_with(&query.text, query.remote_limit, self.transport)
.map_err(|error| {
SearchProviderError::new("appimagehub", &render_appimagehub_search_error(&error))
})?;
let hits =
search_appimagehub_with(&query.text, query.remote_limit, self.transport.as_ref())
.map_err(|error| {
SearchProviderError::new(
"appimagehub",
&render_appimagehub_search_error(&error),
)
})?;
let normalized_query = normalize_lookup(&query.text);
let mut ranked_hits = hits

View file

@ -1,7 +1,7 @@
use std::env;
use std::time::Duration;
use upm_core::domain::source::SourceRef;
use upm_module_api::domain::source::SourceRef;
const DEFAULT_APPIMAGEHUB_API_BASE: &str = "https://api.appimagehub.com/ocs/v1/content";
const GLOBAL_FIXTURE_MODE_ENV: &str = "UPM_FIXTURE_MODE";

View file

@ -24,7 +24,7 @@ impl SearchProvider for StubProvider {
#[test]
fn appimagehub_search_provider_maps_hits_to_install_ready_results() {
let provider = AppImageHubSearchProvider::new(&FixtureAppImageHubTransport);
let provider = AppImageHubSearchProvider::new(Box::new(FixtureAppImageHubTransport));
let results = provider.search(&SearchQuery::new("firefox")).unwrap();
@ -38,7 +38,7 @@ fn appimagehub_search_provider_maps_hits_to_install_ready_results() {
#[test]
fn appimagehub_hits_are_annotated_as_installed_by_canonical_id() {
let provider = AppImageHubSearchProvider::new(&FixtureAppImageHubTransport);
let provider = AppImageHubSearchProvider::new(Box::new(FixtureAppImageHubTransport));
let installed = vec![AppRecord {
stable_id: "firefox".to_owned(),
display_name: "Firefox by Mozilla - Official AppImage Edition".to_owned(),
@ -76,7 +76,7 @@ fn appimagehub_hits_are_annotated_as_installed_by_canonical_id() {
#[test]
fn search_can_merge_github_and_appimagehub_providers() {
let github = GitHubSearchProvider::new(&FixtureGitHubTransport);
let appimagehub = AppImageHubSearchProvider::new(&FixtureAppImageHubTransport);
let appimagehub = AppImageHubSearchProvider::new(Box::new(FixtureAppImageHubTransport));
let stub = StubProvider {
hit: SearchResult {
provider_id: "github".to_owned(),
@ -131,7 +131,7 @@ fn appimagehub_adapter_resolves_installable_items_through_fixture_transport() {
#[test]
fn appimagehub_add_provider_resolves_external_add_plan() {
let provider = AppImageHubAddProvider::new(&FixtureAppImageHubTransport);
let provider = AppImageHubAddProvider::new(Box::new(FixtureAppImageHubTransport));
let source = resolve_query("appimagehub/2338455").unwrap();
let resolution = provider.resolve(&source).unwrap().unwrap();

View file

@ -17,6 +17,8 @@ serde.workspace = true
serde_yaml.workspace = true
sha2.workspace = true
toml.workspace = true
upm-appimage = { path = "../upm-appimage" }
upm-module-api = { path = "../upm-module-api" }
[dev-dependencies]
tempfile.workspace = true

View file

@ -1,72 +1 @@
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct AdapterCapabilities {
pub supports_search: bool,
pub supports_exact_resolution: bool,
}
impl AdapterCapabilities {
pub fn exact_resolution_only() -> Self {
Self {
supports_search: false,
supports_exact_resolution: true,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AdapterResolution {
pub source: SourceRef,
pub release: ResolvedRelease,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AdapterResolveOutcome {
Resolved(AdapterResolution),
NoInstallableArtifact { source: SourceRef },
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AdapterError {
UnsupportedQuery,
UnsupportedSource,
ResolutionFailed(String),
}
pub trait SourceAdapter {
fn id(&self) -> &'static str;
fn capabilities(&self) -> AdapterCapabilities;
fn repository_source_kind(&self) -> Option<SourceKind> {
None
}
fn exact_source_kind(&self) -> Option<SourceKind> {
None
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError>;
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError>;
fn resolve_supported_source(
&self,
source: &SourceRef,
) -> Result<AdapterResolveOutcome, AdapterError> {
self.resolve(source).map(AdapterResolveOutcome::Resolved)
}
fn supports_source(&self, source: &SourceRef) -> bool {
crate::adapters::supports_source(self, source)
}
fn resolve_source(&self, source: &SourceRef) -> Result<AdapterResolveOutcome, AdapterError> {
if !self.supports_source(source) {
return Err(AdapterError::UnsupportedSource);
}
self.resolve_supported_source(source)
}
}
pub use upm_module_api::adapters::traits::*;

View file

@ -0,0 +1,182 @@
use std::path::Path;
use crate::app::add::{
AddPlan, AddSecurityPolicy, BuildAddPlanError, InstalledApp,
build_add_plan_with_registered_providers,
build_add_plan_with_reporter_and_registered_providers, install_app_with_reporter,
};
use crate::app::list::{ListRow, build_list_rows};
use crate::app::progress::ProgressReporter;
use crate::app::providers::ProviderRegistry;
use crate::app::remove::{RemovalResult, RemoveRegisteredAppError, remove_registered_app};
use crate::app::search::{SearchError, SearchProvider, build_search_results_with};
use crate::app::show::build_show_result_with;
use crate::app::update::{
BuildUpdatePlanError, ExecuteUpdatesError, build_update_plan,
execute_updates_with_reporter_and_policy,
};
use crate::domain::app::{AppRecord, InstallScope};
use crate::domain::search::{SearchQuery, SearchResults};
use crate::domain::show::{InstalledShow, ShowResult, ShowResultError};
use crate::domain::update::{UpdateExecutionResult, UpdatePlan};
use crate::source::github::{GitHubTransport, default_transport};
pub struct UpmApp<'a> {
github_transport: Box<dyn GitHubTransport>,
providers: ProviderRegistry<'a>,
}
pub struct UpmAppBuilder<'a> {
github_transport: Option<Box<dyn GitHubTransport>>,
providers: ProviderRegistry<'a>,
}
impl UpmApp<'static> {
pub fn new() -> Self {
Self::builder()
.with_provider_registry(default_provider_registry())
.build()
}
}
impl Default for UpmApp<'static> {
fn default() -> Self {
Self::new()
}
}
impl<'a> UpmApp<'a> {
pub fn builder() -> UpmAppBuilder<'a> {
UpmAppBuilder {
github_transport: None,
providers: ProviderRegistry::default(),
}
}
pub fn search(
&self,
query: &SearchQuery,
installed_apps: &[AppRecord],
) -> Result<SearchResults, SearchError> {
let github_provider =
crate::app::search::GitHubSearchProvider::new(self.github_transport.as_ref());
let mut resolved_providers = vec![&github_provider as &dyn SearchProvider];
resolved_providers.extend(
self.providers
.search_providers
.iter()
.map(|provider| provider.as_ref() as &dyn SearchProvider),
);
build_search_results_with(query, installed_apps, &resolved_providers)
}
pub fn build_add_plan(
&self,
query: &str,
policy: AddSecurityPolicy,
) -> Result<AddPlan, BuildAddPlanError> {
build_add_plan_with_registered_providers(
query,
self.github_transport.as_ref(),
&self.providers,
policy,
)
}
pub fn build_add_plan_with_reporter(
&self,
query: &str,
reporter: &mut impl ProgressReporter,
policy: AddSecurityPolicy,
) -> Result<AddPlan, BuildAddPlanError> {
build_add_plan_with_reporter_and_registered_providers(
query,
self.github_transport.as_ref(),
reporter,
&self.providers,
policy,
)
}
pub fn install_app(
&self,
query: &str,
plan: &AddPlan,
install_home: &Path,
requested_scope: InstallScope,
reporter: &mut impl ProgressReporter,
) -> Result<InstalledApp, crate::app::add::InstallAppError> {
install_app_with_reporter(query, plan, install_home, requested_scope, reporter)
}
pub fn show(
&self,
query: &str,
installed_apps: &[AppRecord],
) -> Result<ShowResult, ShowResultError> {
build_show_result_with(query, installed_apps, self.github_transport.as_ref())
}
pub fn show_all(&self, installed_apps: &[AppRecord]) -> Vec<InstalledShow> {
crate::app::show::build_installed_show_results(installed_apps)
}
pub fn list(&self, apps: &[AppRecord]) -> Vec<ListRow> {
build_list_rows(apps)
}
pub fn build_update_plan(
&self,
apps: &[AppRecord],
) -> Result<UpdatePlan, BuildUpdatePlanError> {
build_update_plan(apps)
}
pub fn execute_updates(
&self,
apps: &[AppRecord],
install_home: &Path,
reporter: &mut impl ProgressReporter,
policy: AddSecurityPolicy,
) -> Result<UpdateExecutionResult, ExecuteUpdatesError> {
execute_updates_with_reporter_and_policy(apps, install_home, reporter, policy)
}
pub fn remove_registered_app(
&self,
query: &str,
apps: &[AppRecord],
install_home: &Path,
) -> Result<RemovalResult, RemoveRegisteredAppError> {
remove_registered_app(query, apps, install_home)
}
}
impl<'a> UpmAppBuilder<'a> {
pub fn with_github_transport(mut self, github_transport: Box<dyn GitHubTransport>) -> Self {
self.github_transport = Some(github_transport);
self
}
pub fn with_provider_registry(mut self, providers: ProviderRegistry<'a>) -> Self {
self.providers = providers;
self
}
pub fn build(self) -> UpmApp<'a> {
UpmApp {
github_transport: self.github_transport.unwrap_or_else(default_transport),
providers: self.providers,
}
}
}
fn default_provider_registry() -> ProviderRegistry<'static> {
ProviderRegistry::default()
.with_search_provider(upm_appimage::AppImageHubSearchProvider::new(
upm_appimage::source::appimagehub::default_transport(),
))
.with_external_add_provider(upm_appimage::AppImageHubAddProvider::new(
upm_appimage::source::appimagehub::default_transport(),
))
}

View file

@ -1,4 +1,5 @@
pub mod add;
pub mod application;
pub mod identity;
pub mod interaction;
pub mod list;
@ -10,3 +11,5 @@ pub mod scope;
pub mod search;
pub mod show;
pub mod update;
pub use application::{UpmApp, UpmAppBuilder};

View file

@ -1,24 +1 @@
use crate::adapters::traits::{AdapterError, AdapterResolution};
use crate::app::search::SearchProvider;
use crate::domain::source::SourceRef;
use crate::domain::update::{ArtifactCandidate, UpdateStrategy};
pub trait ExternalAddProvider {
fn id(&self) -> &'static str;
fn resolve(&self, source: &SourceRef) -> Result<Option<ExternalAddResolution>, AdapterError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ExternalAddResolution {
pub resolution: AdapterResolution,
pub selected_artifact: ArtifactCandidate,
pub update_strategy: UpdateStrategy,
pub display_name_hint: Option<String>,
}
#[derive(Default)]
pub struct ProviderRegistry<'a> {
pub search_providers: Vec<&'a dyn SearchProvider>,
pub external_add_providers: Vec<&'a dyn ExternalAddProvider>,
}
pub use upm_module_api::app::providers::*;

View file

@ -9,25 +9,7 @@ use crate::source::github::{
search_github_repositories_with,
};
use std::collections::HashSet;
pub trait SearchProvider {
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchProviderError {
pub provider_id: String,
pub message: String,
}
impl SearchProviderError {
pub fn new(provider_id: &str, message: &str) -> Self {
Self {
provider_id: provider_id.to_owned(),
message: message.to_owned(),
}
}
}
pub use upm_module_api::app::search::{SearchProvider, SearchProviderError};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SearchError {
@ -53,7 +35,12 @@ pub fn build_search_results_with_registered_providers(
let github_transport = default_transport();
let github_provider = GitHubSearchProvider::new(github_transport.as_ref());
let mut resolved_providers = vec![&github_provider as &dyn SearchProvider];
resolved_providers.extend(providers.search_providers.iter().copied());
resolved_providers.extend(
providers
.search_providers
.iter()
.map(|provider| provider.as_ref() as &dyn SearchProvider),
);
build_search_results_with(query, installed_apps, &resolved_providers)
}

View file

@ -1,68 +1 @@
pub const DEFAULT_REMOTE_LIMIT: usize = 10;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SearchInstallStatus {
Available,
Installed {
installed_version: Option<String>,
},
UpdateAvailable {
installed_version: Option<String>,
latest_version: Option<String>,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchQuery {
pub text: String,
pub remote_limit: usize,
}
impl SearchQuery {
pub fn new(text: &str) -> Self {
Self {
text: text.to_owned(),
remote_limit: DEFAULT_REMOTE_LIMIT,
}
}
pub fn with_remote_limit(text: &str, remote_limit: usize) -> Self {
Self {
text: text.to_owned(),
remote_limit,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchResult {
pub provider_id: String,
pub display_name: String,
pub description: Option<String>,
pub source_locator: String,
pub install_query: String,
pub canonical_locator: String,
pub version: Option<String>,
pub install_status: SearchInstallStatus,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InstalledSearchMatch {
pub stable_id: String,
pub display_name: String,
pub installed_version: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchWarning {
pub provider_id: Option<String>,
pub message: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchResults {
pub query_text: String,
pub remote_hits: Vec<SearchResult>,
pub installed_matches: Vec<InstalledSearchMatch>,
pub warnings: Vec<SearchWarning>,
}
pub use upm_module_api::domain::search::*;

View file

@ -1,117 +1 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum SourceKind {
GitHub,
GitLab,
AppImageHub,
SourceForge,
DirectUrl,
File,
}
impl SourceKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::GitHub => "github",
Self::GitLab => "gitlab",
Self::AppImageHub => "appimagehub",
Self::SourceForge => "sourceforge",
Self::DirectUrl => "direct-url",
Self::File => "file",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum SourceInputKind {
RepoShorthand,
GitHubRepositoryUrl,
GitHubReleaseUrl,
GitHubReleaseAssetUrl,
GitLabUrl,
AppImageHubUrl,
AppImageHubShorthand,
SourceForgeUrl,
DirectUrl,
File,
}
impl SourceInputKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::RepoShorthand => "repo-shorthand",
Self::GitHubRepositoryUrl => "github-repository-url",
Self::GitHubReleaseUrl => "github-release-url",
Self::GitHubReleaseAssetUrl => "github-release-asset-url",
Self::GitLabUrl => "gitlab-url",
Self::AppImageHubUrl => "appimagehub-url",
Self::AppImageHubShorthand => "appimagehub-shorthand",
Self::SourceForgeUrl => "sourceforge-url",
Self::DirectUrl => "direct-url",
Self::File => "file",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum NormalizedSourceKind {
GitHubRepository,
GitHubRelease,
GitHubReleaseAsset,
GitLab,
GitLabCandidate,
AppImageHub,
SourceForge,
SourceForgeCandidate,
DirectUrl,
File,
}
impl NormalizedSourceKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::GitHubRepository => "github-repository",
Self::GitHubRelease => "github-release",
Self::GitHubReleaseAsset => "github-release-asset",
Self::GitLab => "gitlab",
Self::GitLabCandidate => "gitlab-candidate",
Self::AppImageHub => "appimagehub",
Self::SourceForge => "sourceforge",
Self::SourceForgeCandidate => "sourceforge-candidate",
Self::DirectUrl => "direct-url",
Self::File => "file",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct SourceRef {
pub kind: SourceKind,
pub locator: String,
#[serde(default = "default_source_input_kind")]
pub input_kind: SourceInputKind,
#[serde(default = "default_normalized_source_kind")]
pub normalized_kind: NormalizedSourceKind,
#[serde(default)]
pub canonical_locator: Option<String>,
#[serde(default)]
pub requested_tag: Option<String>,
#[serde(default)]
pub requested_asset_name: Option<String>,
#[serde(default)]
pub tracks_latest: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct ResolvedRelease {
pub version: String,
#[serde(default)]
pub prerelease: bool,
}
const fn default_source_input_kind() -> SourceInputKind {
SourceInputKind::DirectUrl
}
const fn default_normalized_source_kind() -> NormalizedSourceKind {
NormalizedSourceKind::DirectUrl
}
pub use upm_module_api::domain::source::*;

View file

@ -1,4 +1,7 @@
use crate::domain::app::AppRecord;
pub use upm_module_api::domain::update::{
ArtifactCandidate, ChannelPreference, UpdateChannelKind, UpdateStrategy,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum ParsedMetadataKind {
@ -31,25 +34,6 @@ pub struct ParsedMetadata {
pub confidence: u8,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum UpdateChannelKind {
GitHubReleases,
ElectronBuilder,
Zsync,
DirectAsset,
}
impl UpdateChannelKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::GitHubReleases => "github-releases",
Self::ElectronBuilder => "electron-builder",
Self::Zsync => "zsync",
Self::DirectAsset => "direct-asset-lineage",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct UpdateChannel {
pub kind: UpdateChannelKind,
@ -66,30 +50,6 @@ pub struct UpdateChannel {
pub prerelease: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct ChannelPreference {
pub kind: UpdateChannelKind,
pub locator: String,
pub reason: String,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct UpdateStrategy {
pub preferred: ChannelPreference,
#[serde(default)]
pub alternates: Vec<ChannelPreference>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ArtifactCandidate {
pub url: String,
pub version: String,
pub arch: Option<String>,
pub trusted_checksum: Option<String>,
pub weak_checksum_md5: Option<String>,
pub selection_reason: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UpdatePlan {
pub items: Vec<PlannedUpdate>,

View file

@ -9,3 +9,4 @@ pub mod source;
pub mod update;
pub use app::providers::{ExternalAddProvider, ExternalAddResolution, ProviderRegistry};
pub use app::{UpmApp, UpmAppBuilder};

View file

@ -0,0 +1,124 @@
use upm_core::adapters::traits::AdapterResolution;
use upm_core::app::UpmApp;
use upm_core::app::add::AddSecurityPolicy;
use upm_core::app::providers::{ExternalAddProvider, ExternalAddResolution, ProviderRegistry};
use upm_core::app::search::{SearchProvider, SearchProviderError};
use upm_core::domain::search::{SearchInstallStatus, SearchQuery, SearchResult};
use upm_core::domain::source::{
NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef,
};
use upm_core::domain::update::{
ArtifactCandidate, ChannelPreference, UpdateChannelKind, UpdateStrategy,
};
use upm_core::source::github::FixtureGitHubTransport;
struct StubSearchProvider;
impl SearchProvider for StubSearchProvider {
fn search(&self, _query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError> {
Ok(vec![SearchResult {
provider_id: "external-search".to_owned(),
display_name: "Firefox Nightly".to_owned(),
description: Some("Provided by facade-owned providers".to_owned()),
source_locator: "https://example.invalid/firefox-nightly".to_owned(),
install_query: "external/firefox-nightly".to_owned(),
canonical_locator: "external/firefox-nightly".to_owned(),
version: Some("2026.03.21".to_owned()),
install_status: SearchInstallStatus::Available,
}])
}
}
struct StubExternalAddProvider;
impl ExternalAddProvider for StubExternalAddProvider {
fn id(&self) -> &'static str {
"stub-appimage"
}
fn resolve(
&self,
source: &SourceRef,
) -> Result<Option<ExternalAddResolution>, upm_core::adapters::traits::AdapterError> {
Ok(
(source.kind == SourceKind::AppImageHub).then(|| ExternalAddResolution {
resolution: AdapterResolution {
source: SourceRef {
kind: SourceKind::AppImageHub,
locator: source.locator.clone(),
input_kind: SourceInputKind::AppImageHubShorthand,
normalized_kind: NormalizedSourceKind::AppImageHub,
canonical_locator: Some("2338455".to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
},
release: ResolvedRelease {
version: "stable".to_owned(),
prerelease: false,
},
},
selected_artifact: ArtifactCandidate {
url: "https://downloads.example.invalid/firefox.AppImage".to_owned(),
version: "stable".to_owned(),
arch: Some("x86_64".to_owned()),
trusted_checksum: None,
weak_checksum_md5: Some("deadbeef".to_owned()),
selection_reason: "provider-release".to_owned(),
},
update_strategy: UpdateStrategy {
preferred: ChannelPreference {
kind: UpdateChannelKind::DirectAsset,
locator: "https://downloads.example.invalid/firefox.AppImage".to_owned(),
reason: "provider-release".to_owned(),
},
alternates: Vec::new(),
},
display_name_hint: Some(
"Firefox by Mozilla - Official AppImage Edition".to_owned(),
),
}),
)
}
}
#[test]
fn upm_app_can_be_constructed_without_cli_owned_module_composition() {
let _app = UpmApp::new();
}
#[test]
fn upm_app_search_delegates_through_the_application_facade() {
let app = UpmApp::builder()
.with_github_transport(Box::new(FixtureGitHubTransport))
.with_provider_registry(
ProviderRegistry::default().with_search_provider(StubSearchProvider),
)
.build();
let results = app.search(&SearchQuery::new("firefox"), &[]).unwrap();
assert!(results.remote_hits.iter().any(|hit| {
hit.provider_id == "external-search" && hit.install_query == "external/firefox-nightly"
}));
}
#[test]
fn upm_app_add_planning_delegates_through_the_application_facade() {
let app = UpmApp::builder()
.with_github_transport(Box::new(FixtureGitHubTransport))
.with_provider_registry(
ProviderRegistry::default().with_external_add_provider(StubExternalAddProvider),
)
.build();
let plan = app
.build_add_plan("appimagehub/2338455", AddSecurityPolicy::default())
.unwrap();
assert_eq!(plan.resolution.source.kind, SourceKind::AppImageHub);
assert_eq!(
plan.selected_artifact.url,
"https://downloads.example.invalid/firefox.AppImage"
);
}

View file

@ -86,11 +86,7 @@ impl ExternalAddProvider for StubExternalAddProvider {
#[test]
fn build_search_results_with_registered_providers_uses_external_hits() {
let query = SearchQuery::new("firefox");
let search_provider = StubSearchProvider;
let providers = ProviderRegistry {
search_providers: vec![&search_provider],
external_add_providers: Vec::new(),
};
let providers = ProviderRegistry::default().with_search_provider(StubSearchProvider);
let results = build_search_results_with_registered_providers(&query, &[], &providers).unwrap();
@ -129,11 +125,7 @@ fn build_add_plan_with_registered_providers_requires_external_provider_for_appim
#[test]
fn build_add_plan_with_registered_providers_delegates_appimagehub_like_sources() {
let provider = StubExternalAddProvider;
let registry = ProviderRegistry {
search_providers: Vec::new(),
external_add_providers: vec![&provider],
};
let registry = ProviderRegistry::default().with_external_add_provider(StubExternalAddProvider);
let plan = build_add_plan_with_registered_providers(
"appimagehub/2338455",

View file

@ -0,0 +1,11 @@
[package]
name = "upm-module-api"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
path = "src/lib.rs"
[dependencies]
serde.workspace = true

View file

@ -0,0 +1 @@
pub mod traits;

View file

@ -0,0 +1,73 @@
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct AdapterCapabilities {
pub supports_search: bool,
pub supports_exact_resolution: bool,
}
impl AdapterCapabilities {
pub fn exact_resolution_only() -> Self {
Self {
supports_search: false,
supports_exact_resolution: true,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AdapterResolution {
pub source: SourceRef,
pub release: ResolvedRelease,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AdapterResolveOutcome {
Resolved(AdapterResolution),
NoInstallableArtifact { source: SourceRef },
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AdapterError {
UnsupportedQuery,
UnsupportedSource,
ResolutionFailed(String),
}
pub trait SourceAdapter {
fn id(&self) -> &'static str;
fn capabilities(&self) -> AdapterCapabilities;
fn repository_source_kind(&self) -> Option<SourceKind> {
None
}
fn exact_source_kind(&self) -> Option<SourceKind> {
None
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError>;
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError>;
fn resolve_supported_source(
&self,
source: &SourceRef,
) -> Result<AdapterResolveOutcome, AdapterError> {
self.resolve(source).map(AdapterResolveOutcome::Resolved)
}
fn supports_source(&self, source: &SourceRef) -> bool {
self.repository_source_kind() == Some(source.kind)
|| self.exact_source_kind() == Some(source.kind)
}
fn resolve_source(&self, source: &SourceRef) -> Result<AdapterResolveOutcome, AdapterError> {
if !self.supports_source(source) {
return Err(AdapterError::UnsupportedSource);
}
self.resolve_supported_source(source)
}
}

View file

@ -0,0 +1,2 @@
pub mod providers;
pub mod search;

View file

@ -0,0 +1,42 @@
use crate::adapters::traits::{AdapterError, AdapterResolution};
use crate::app::search::SearchProvider;
use crate::domain::source::SourceRef;
use crate::domain::update::{ArtifactCandidate, UpdateStrategy};
pub trait ExternalAddProvider {
fn id(&self) -> &'static str;
fn resolve(&self, source: &SourceRef) -> Result<Option<ExternalAddResolution>, AdapterError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ExternalAddResolution {
pub resolution: AdapterResolution,
pub selected_artifact: ArtifactCandidate,
pub update_strategy: UpdateStrategy,
pub display_name_hint: Option<String>,
}
#[derive(Default)]
pub struct ProviderRegistry<'a> {
pub search_providers: Vec<Box<dyn SearchProvider + 'a>>,
pub external_add_providers: Vec<Box<dyn ExternalAddProvider + 'a>>,
}
impl<'a> ProviderRegistry<'a> {
pub fn with_search_provider<P>(mut self, provider: P) -> Self
where
P: SearchProvider + 'a,
{
self.search_providers.push(Box::new(provider));
self
}
pub fn with_external_add_provider<P>(mut self, provider: P) -> Self
where
P: ExternalAddProvider + 'a,
{
self.external_add_providers.push(Box::new(provider));
self
}
}

View file

@ -0,0 +1,23 @@
use crate::domain::search::SearchResult;
pub trait SearchProvider {
fn search(
&self,
query: &crate::domain::search::SearchQuery,
) -> Result<Vec<SearchResult>, SearchProviderError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchProviderError {
pub provider_id: String,
pub message: String,
}
impl SearchProviderError {
pub fn new(provider_id: &str, message: &str) -> Self {
Self {
provider_id: provider_id.to_owned(),
message: message.to_owned(),
}
}
}

View file

@ -0,0 +1,3 @@
pub mod search;
pub mod source;
pub mod update;

View file

@ -0,0 +1,68 @@
pub const DEFAULT_REMOTE_LIMIT: usize = 10;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SearchInstallStatus {
Available,
Installed {
installed_version: Option<String>,
},
UpdateAvailable {
installed_version: Option<String>,
latest_version: Option<String>,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchQuery {
pub text: String,
pub remote_limit: usize,
}
impl SearchQuery {
pub fn new(text: &str) -> Self {
Self {
text: text.to_owned(),
remote_limit: DEFAULT_REMOTE_LIMIT,
}
}
pub fn with_remote_limit(text: &str, remote_limit: usize) -> Self {
Self {
text: text.to_owned(),
remote_limit,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchResult {
pub provider_id: String,
pub display_name: String,
pub description: Option<String>,
pub source_locator: String,
pub install_query: String,
pub canonical_locator: String,
pub version: Option<String>,
pub install_status: SearchInstallStatus,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InstalledSearchMatch {
pub stable_id: String,
pub display_name: String,
pub installed_version: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchWarning {
pub provider_id: Option<String>,
pub message: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchResults {
pub query_text: String,
pub remote_hits: Vec<SearchResult>,
pub installed_matches: Vec<InstalledSearchMatch>,
pub warnings: Vec<SearchWarning>,
}

View file

@ -0,0 +1,117 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum SourceKind {
GitHub,
GitLab,
AppImageHub,
SourceForge,
DirectUrl,
File,
}
impl SourceKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::GitHub => "github",
Self::GitLab => "gitlab",
Self::AppImageHub => "appimagehub",
Self::SourceForge => "sourceforge",
Self::DirectUrl => "direct-url",
Self::File => "file",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum SourceInputKind {
RepoShorthand,
GitHubRepositoryUrl,
GitHubReleaseUrl,
GitHubReleaseAssetUrl,
GitLabUrl,
AppImageHubUrl,
AppImageHubShorthand,
SourceForgeUrl,
DirectUrl,
File,
}
impl SourceInputKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::RepoShorthand => "repo-shorthand",
Self::GitHubRepositoryUrl => "github-repository-url",
Self::GitHubReleaseUrl => "github-release-url",
Self::GitHubReleaseAssetUrl => "github-release-asset-url",
Self::GitLabUrl => "gitlab-url",
Self::AppImageHubUrl => "appimagehub-url",
Self::AppImageHubShorthand => "appimagehub-shorthand",
Self::SourceForgeUrl => "sourceforge-url",
Self::DirectUrl => "direct-url",
Self::File => "file",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum NormalizedSourceKind {
GitHubRepository,
GitHubRelease,
GitHubReleaseAsset,
GitLab,
GitLabCandidate,
AppImageHub,
SourceForge,
SourceForgeCandidate,
DirectUrl,
File,
}
impl NormalizedSourceKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::GitHubRepository => "github-repository",
Self::GitHubRelease => "github-release",
Self::GitHubReleaseAsset => "github-release-asset",
Self::GitLab => "gitlab",
Self::GitLabCandidate => "gitlab-candidate",
Self::AppImageHub => "appimagehub",
Self::SourceForge => "sourceforge",
Self::SourceForgeCandidate => "sourceforge-candidate",
Self::DirectUrl => "direct-url",
Self::File => "file",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct SourceRef {
pub kind: SourceKind,
pub locator: String,
#[serde(default = "default_source_input_kind")]
pub input_kind: SourceInputKind,
#[serde(default = "default_normalized_source_kind")]
pub normalized_kind: NormalizedSourceKind,
#[serde(default)]
pub canonical_locator: Option<String>,
#[serde(default)]
pub requested_tag: Option<String>,
#[serde(default)]
pub requested_asset_name: Option<String>,
#[serde(default)]
pub tracks_latest: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct ResolvedRelease {
pub version: String,
#[serde(default)]
pub prerelease: bool,
}
const fn default_source_input_kind() -> SourceInputKind {
SourceInputKind::DirectUrl
}
const fn default_normalized_source_kind() -> NormalizedSourceKind {
NormalizedSourceKind::DirectUrl
}

View file

@ -0,0 +1,42 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum UpdateChannelKind {
GitHubReleases,
ElectronBuilder,
Zsync,
DirectAsset,
}
impl UpdateChannelKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::GitHubReleases => "github-releases",
Self::ElectronBuilder => "electron-builder",
Self::Zsync => "zsync",
Self::DirectAsset => "direct-asset-lineage",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct ChannelPreference {
pub kind: UpdateChannelKind,
pub locator: String,
pub reason: String,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct UpdateStrategy {
pub preferred: ChannelPreference,
#[serde(default)]
pub alternates: Vec<ChannelPreference>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ArtifactCandidate {
pub url: String,
pub version: String,
pub arch: Option<String>,
pub trusted_checksum: Option<String>,
pub weak_checksum_md5: Option<String>,
pub selection_reason: String,
}

View file

@ -0,0 +1,3 @@
pub mod adapters;
pub mod app;
pub mod domain;

View file

@ -7,19 +7,12 @@ use std::collections::{HashMap, HashSet};
use std::env;
use std::path::{Path, PathBuf};
use upm_core::app::add::{
AddPlan, AddSecurityPolicy, InstalledApp,
build_add_plan_with_reporter_and_registered_providers, install_app_with_reporter,
resolve_requested_scope,
};
use upm_core::app::list::{ListRow, build_list_rows};
use upm_core::app::add::{AddPlan, AddSecurityPolicy, InstalledApp, resolve_requested_scope};
use upm_core::app::list::ListRow;
use upm_core::app::progress::{
NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter,
};
use upm_core::app::remove::{RemovalResult, remove_registered_app_with_reporter};
use upm_core::app::search::build_search_results_with_registered_providers;
use upm_core::app::show::{build_installed_show_results, build_show_result};
use upm_core::app::update::{build_update_plan, execute_updates_with_reporter_and_policy};
use upm_core::domain::app::AppRecord;
use upm_core::domain::search::{SearchQuery, SearchResults};
use upm_core::domain::show::{InstalledShow, ShowResult};
@ -54,14 +47,15 @@ pub fn dispatch_with_reporter_and_config(
let store = RegistryStore::new(registry_path);
let registry = store.load()?;
let apps = registry.apps.clone();
let app = providers::application();
if cli.is_review_update_flow() {
return Ok(DispatchResult::UpdatePlan(build_update_plan(&apps)?));
return Ok(DispatchResult::UpdatePlan(app.build_update_plan(&apps)?));
}
if let Some(command) = cli.command {
return match command {
cli::args::Command::List => Ok(DispatchResult::List(build_list_rows(&apps))),
cli::args::Command::List => Ok(DispatchResult::List(app.list(&apps))),
cli::args::Command::Remove { query } => {
let removal =
remove_registered_app_with_reporter(&query, &apps, &install_home, reporter)?;
@ -82,13 +76,7 @@ pub fn dispatch_with_reporter_and_config(
kind: OperationKind::Search,
label: query.clone(),
});
let results = providers::with_provider_registry(|providers| {
build_search_results_with_registered_providers(
&SearchQuery::new(&query),
&apps,
providers,
)
})?;
let results = app.search(&SearchQuery::new(&query), &apps)?;
reporter.report(&OperationEvent::Finished {
summary: format!("search complete: {} remote hits", results.remote_hits.len()),
});
@ -96,13 +84,13 @@ pub fn dispatch_with_reporter_and_config(
}
cli::args::Command::Show { value } => match value {
Some(value) => {
let result = build_show_result(&value, &apps)?;
let result = app.show(&value, &apps)?;
Ok(DispatchResult::Show(Box::new(result)))
}
None => Ok(DispatchResult::ShowAll(build_installed_show_results(&apps))),
None => Ok(DispatchResult::ShowAll(app.show_all(&apps))),
},
cli::args::Command::Update => {
let updates = execute_updates_with_reporter_and_policy(
let updates = app.execute_updates(
&apps,
&install_home,
reporter,
@ -131,18 +119,13 @@ pub fn dispatch_with_reporter_and_config(
if let Some(query) = cli.query {
let requested_scope = resolve_requested_scope(cli.system, cli.user, is_effective_root());
let transport = upm_core::source::github::default_transport();
let plan_result = providers::with_provider_registry(|providers| {
build_add_plan_with_reporter_and_registered_providers(
&query,
transport.as_ref(),
reporter,
providers,
AddSecurityPolicy {
allow_http_user_sources: config.allow_http,
},
)
});
let plan_result = app.build_add_plan_with_reporter(
&query,
reporter,
AddSecurityPolicy {
allow_http_user_sources: config.allow_http,
},
);
let mut plan = match plan_result {
Ok(plan) => plan,
Err(
@ -155,13 +138,7 @@ pub fn dispatch_with_reporter_and_config(
kind: OperationKind::Search,
label: query.clone(),
});
let results = providers::with_provider_registry(|providers| {
build_search_results_with_registered_providers(
&SearchQuery::new(&query),
&apps,
providers,
)
})?;
let results = app.search(&SearchQuery::new(&query), &apps)?;
reporter.report(&OperationEvent::Finished {
summary: format!("search complete: {} remote hits", results.remote_hits.len()),
});
@ -178,8 +155,7 @@ pub fn dispatch_with_reporter_and_config(
}
}
let installed =
install_app_with_reporter(&query, &plan, &install_home, requested_scope, reporter)?;
let installed = app.install_app(&query, &plan, &install_home, requested_scope, reporter)?;
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SaveRegistry,
message: "saving registry".to_owned(),

View file

@ -1,16 +1,3 @@
use upm_appimage::AppImageHubAddProvider;
use upm_appimage::AppImageHubSearchProvider;
use upm_appimage::source::appimagehub;
use upm_core::ProviderRegistry;
pub fn with_provider_registry<T>(build: impl FnOnce(&ProviderRegistry<'_>) -> T) -> T {
let appimagehub_transport = appimagehub::default_transport();
let appimagehub_search = AppImageHubSearchProvider::new(appimagehub_transport.as_ref());
let appimagehub_add = AppImageHubAddProvider::new(appimagehub_transport.as_ref());
let providers = ProviderRegistry {
search_providers: vec![&appimagehub_search],
external_add_providers: vec![&appimagehub_add],
};
build(&providers)
pub fn application() -> upm_core::UpmApp<'static> {
upm_core::UpmApp::new()
}