act-cli 0.7.5__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {act_cli-0.7.5 → act_cli-0.8.0}/Cargo.lock +86 -5
  2. {act_cli-0.7.5 → act_cli-0.8.0}/Cargo.toml +2 -2
  3. {act_cli-0.7.5 → act_cli-0.8.0}/PKG-INFO +1 -1
  4. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/Cargo.toml +1 -0
  5. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/src/http.rs +50 -8
  6. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/src/main.rs +198 -29
  7. act_cli-0.8.0/act-cli/src/resolve.rs +42 -0
  8. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/src/rmcp_bridge.rs +134 -6
  9. act_cli-0.8.0/crates/act-store/Cargo.toml +30 -0
  10. act_cli-0.8.0/crates/act-store/README.md +125 -0
  11. act_cli-0.8.0/crates/act-store/src/fetch.rs +605 -0
  12. act_cli-0.8.0/crates/act-store/src/index.rs +302 -0
  13. act_cli-0.8.0/crates/act-store/src/layout.rs +111 -0
  14. act_cli-0.8.0/crates/act-store/src/lib.rs +61 -0
  15. act_cli-0.8.0/crates/act-store/src/lock.rs +75 -0
  16. act_cli-0.8.0/crates/act-store/src/provenance.rs +172 -0
  17. act_cli-0.8.0/crates/act-store/src/reference.rs +181 -0
  18. act_cli-0.8.0/crates/act-store/src/referrer.rs +64 -0
  19. act_cli-0.8.0/crates/act-store/src/store.rs +626 -0
  20. act_cli-0.7.5/act-cli/src/resolve.rs +0 -418
  21. {act_cli-0.7.5 → act_cli-0.8.0}/README.md +0 -0
  22. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/README.md +0 -0
  23. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/build.rs +0 -0
  24. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/src/config.rs +0 -0
  25. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/src/format.rs +0 -0
  26. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/src/runtime/bindings/mod.rs +0 -0
  27. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/src/runtime/effective.rs +0 -0
  28. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/src/runtime/fs_matcher.rs +0 -0
  29. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/src/runtime/fs_policy.rs +0 -0
  30. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/src/runtime/http_client.rs +0 -0
  31. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/src/runtime/http_policy.rs +0 -0
  32. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/src/runtime/mod.rs +0 -0
  33. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/src/runtime/network.rs +0 -0
  34. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/src/runtime/sessions.rs +0 -0
  35. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/src/runtime/sockets_policy.rs +0 -0
  36. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/wit/deps/act-core/act-core.wit +0 -0
  37. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/wit/deps/act-tools/act-tools.wit +0 -0
  38. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/wit/deps.lock +0 -0
  39. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/wit/deps.toml +0 -0
  40. {act_cli-0.7.5 → act_cli-0.8.0}/act-cli/wit/world.wit +0 -0
  41. {act_cli-0.7.5 → act_cli-0.8.0}/pyproject.toml +0 -0
@@ -4,7 +4,7 @@ version = 4
4
4
 
5
5
  [[package]]
6
6
  name = "act-build"
7
- version = "0.7.5"
7
+ version = "0.8.0"
8
8
  dependencies = [
9
9
  "act-types",
10
10
  "anyhow",
@@ -18,7 +18,7 @@ dependencies = [
18
18
  "json-patch",
19
19
  "minijinja",
20
20
  "oci-client",
21
- "reqwest",
21
+ "reqwest 0.13.3",
22
22
  "serde",
23
23
  "serde_json",
24
24
  "sha2 0.11.0",
@@ -35,8 +35,9 @@ dependencies = [
35
35
 
36
36
  [[package]]
37
37
  name = "act-cli"
38
- version = "0.7.5"
38
+ version = "0.8.0"
39
39
  dependencies = [
40
+ "act-store",
40
41
  "act-types",
41
42
  "anyhow",
42
43
  "axum",
@@ -57,7 +58,7 @@ dependencies = [
57
58
  "owo-colors",
58
59
  "path-clean",
59
60
  "regex",
60
- "reqwest",
61
+ "reqwest 0.13.3",
61
62
  "rmcp",
62
63
  "serde",
63
64
  "serde_json",
@@ -78,6 +79,27 @@ dependencies = [
78
79
  "wasmtime-wasi-http",
79
80
  ]
80
81
 
82
+ [[package]]
83
+ name = "act-store"
84
+ version = "0.1.0"
85
+ dependencies = [
86
+ "chrono",
87
+ "dirs",
88
+ "fs2",
89
+ "oci-client",
90
+ "oci-spec",
91
+ "regex",
92
+ "reqwest 0.12.28",
93
+ "serde",
94
+ "serde_json",
95
+ "sha2 0.11.0",
96
+ "tempfile",
97
+ "thiserror 2.0.18",
98
+ "tokio",
99
+ "tracing",
100
+ "url",
101
+ ]
102
+
81
103
  [[package]]
82
104
  name = "act-types"
83
105
  version = "0.7.1"
@@ -1203,6 +1225,16 @@ dependencies = [
1203
1225
  "windows-sys 0.59.0",
1204
1226
  ]
1205
1227
 
1228
+ [[package]]
1229
+ name = "fs2"
1230
+ version = "0.4.3"
1231
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1232
+ checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
1233
+ dependencies = [
1234
+ "libc",
1235
+ "winapi",
1236
+ ]
1237
+
1206
1238
  [[package]]
1207
1239
  name = "fs_extra"
1208
1240
  version = "1.3.0"
@@ -1577,6 +1609,7 @@ dependencies = [
1577
1609
  "tokio",
1578
1610
  "tokio-rustls",
1579
1611
  "tower-service",
1612
+ "webpki-roots",
1580
1613
  ]
1581
1614
 
1582
1615
  [[package]]
@@ -2219,7 +2252,7 @@ dependencies = [
2219
2252
  "oci-spec",
2220
2253
  "olpc-cjson",
2221
2254
  "regex",
2222
- "reqwest",
2255
+ "reqwest 0.13.3",
2223
2256
  "serde",
2224
2257
  "serde_json",
2225
2258
  "sha2 0.11.0",
@@ -2720,6 +2753,44 @@ version = "0.8.10"
2720
2753
  source = "registry+https://github.com/rust-lang/crates.io-index"
2721
2754
  checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
2722
2755
 
2756
+ [[package]]
2757
+ name = "reqwest"
2758
+ version = "0.12.28"
2759
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2760
+ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
2761
+ dependencies = [
2762
+ "base64",
2763
+ "bytes",
2764
+ "futures-core",
2765
+ "http",
2766
+ "http-body",
2767
+ "http-body-util",
2768
+ "hyper",
2769
+ "hyper-rustls",
2770
+ "hyper-util",
2771
+ "js-sys",
2772
+ "log",
2773
+ "percent-encoding",
2774
+ "pin-project-lite",
2775
+ "quinn",
2776
+ "rustls",
2777
+ "rustls-pki-types",
2778
+ "serde",
2779
+ "serde_json",
2780
+ "serde_urlencoded",
2781
+ "sync_wrapper",
2782
+ "tokio",
2783
+ "tokio-rustls",
2784
+ "tower",
2785
+ "tower-http",
2786
+ "tower-service",
2787
+ "url",
2788
+ "wasm-bindgen",
2789
+ "wasm-bindgen-futures",
2790
+ "web-sys",
2791
+ "webpki-roots",
2792
+ ]
2793
+
2723
2794
  [[package]]
2724
2795
  name = "reqwest"
2725
2796
  version = "0.13.3"
@@ -2873,6 +2944,7 @@ checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
2873
2944
  dependencies = [
2874
2945
  "aws-lc-rs",
2875
2946
  "once_cell",
2947
+ "ring",
2876
2948
  "rustls-pki-types",
2877
2949
  "rustls-webpki",
2878
2950
  "subtle",
@@ -4454,6 +4526,15 @@ dependencies = [
4454
4526
  "rustls-pki-types",
4455
4527
  ]
4456
4528
 
4529
+ [[package]]
4530
+ name = "webpki-roots"
4531
+ version = "1.0.7"
4532
+ source = "registry+https://github.com/rust-lang/crates.io-index"
4533
+ checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
4534
+ dependencies = [
4535
+ "rustls-pki-types",
4536
+ ]
4537
+
4457
4538
  [[package]]
4458
4539
  name = "winapi"
4459
4540
  version = "0.3.9"
@@ -1,9 +1,9 @@
1
1
  [workspace]
2
- members = ["act-cli"]
2
+ members = ["act-cli", "crates/act-store"]
3
3
  resolver = "3"
4
4
 
5
5
  [workspace.package]
6
- version = "0.7.5"
6
+ version = "0.8.0"
7
7
  edition = "2024"
8
8
  license = "MIT OR Apache-2.0"
9
9
  repository = "https://github.com/actcore/act-cli"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: act-cli
3
- Version: 0.7.5
3
+ Version: 0.8.0
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -56,6 +56,7 @@ cidr = "0.3"
56
56
  globset = "0.4"
57
57
  path-clean = "1"
58
58
  owo-colors = { version = "4.3.0", features = ["supports-colors"] }
59
+ act-store = { version = "0.1.0", path = "../crates/act-store" }
59
60
 
60
61
  [dev-dependencies]
61
62
  tempfile = "3"
@@ -24,6 +24,10 @@ pub struct AppState {
24
24
  pub info: act_types::ComponentInfo,
25
25
  pub component: runtime::ComponentHandle,
26
26
  pub metadata: Metadata,
27
+ /// When `Some`, a single default session was pre-opened (session-of-1,
28
+ /// ACT-SESSIONS §3): `/sessions*` routes are unregistered and this id is
29
+ /// forced into every call's `std:session-id` metadata.
30
+ pub default_session_id: Option<String>,
27
31
  }
28
32
 
29
33
  // ── Conversion helpers ──
@@ -84,6 +88,14 @@ fn internal_error_response(message: &str) -> axum::response::Response {
84
88
  .into_response()
85
89
  }
86
90
 
91
+ /// Force `std:session-id` to the default when in session-of-1 mode, overriding
92
+ /// any body-supplied value (ACT-SESSIONS §3 "session-of-1").
93
+ fn apply_default_session(meta: &mut Metadata, default: &Option<String>) {
94
+ if let Some(id) = default {
95
+ meta.insert(META_SESSION_ID, serde_json::Value::String(id.clone()));
96
+ }
97
+ }
98
+
87
99
  /// Format an SseEvent as an axum SSE Event.
88
100
  fn sse_event_to_axum(event: runtime::SseEvent) -> Option<Result<Event, std::convert::Infallible>> {
89
101
  match event {
@@ -151,6 +163,7 @@ async fn list_tools_inner(
151
163
  if let Some(value) = metadata {
152
164
  meta.extend(runtime::Metadata::from(value));
153
165
  }
166
+ apply_default_session(&mut meta, &state.default_session_id);
154
167
 
155
168
  let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
156
169
  let request = runtime::ComponentRequest::ListTools {
@@ -361,6 +374,7 @@ async fn tool_call_dispatcher(
361
374
  if let Some(value) = body.metadata {
362
375
  metadata.extend(Metadata::from(value));
363
376
  }
377
+ apply_default_session(&mut metadata, &state.default_session_id);
364
378
 
365
379
  let metadata_wit: Vec<(String, Vec<u8>)> = metadata.into();
366
380
 
@@ -522,16 +536,25 @@ async fn protocol_version_layer(request: Request, next: Next) -> axum::response:
522
536
  }
523
537
 
524
538
  pub fn create_router(state: Arc<AppState>) -> Router {
525
- Router::new()
539
+ let mut router = Router::new()
526
540
  .route("/info", get(get_info))
527
541
  .route("/tools", axum::routing::any(tools_dispatcher))
528
- .route("/tools/{name}", axum::routing::any(tool_call_dispatcher))
529
- .route(
530
- "/sessions/open-args-schema",
531
- axum::routing::any(session_open_args_schema_dispatcher),
532
- )
533
- .route("/sessions", axum::routing::post(session_open))
534
- .route("/sessions/{id}", axum::routing::delete(session_close))
542
+ .route("/tools/{name}", axum::routing::any(tool_call_dispatcher));
543
+
544
+ // Session-of-1 hides the session machinery (ACT-SESSIONS §3): when a
545
+ // default session is pre-opened, the component looks stateless and the
546
+ // lifecycle endpoints are absent (404).
547
+ if state.default_session_id.is_none() {
548
+ router = router
549
+ .route(
550
+ "/sessions/open-args-schema",
551
+ axum::routing::any(session_open_args_schema_dispatcher),
552
+ )
553
+ .route("/sessions", axum::routing::post(session_open))
554
+ .route("/sessions/{id}", axum::routing::delete(session_close));
555
+ }
556
+
557
+ router
535
558
  .layer(middleware::from_fn(protocol_version_layer))
536
559
  .with_state(state)
537
560
  }
@@ -567,4 +590,23 @@ mod tests {
567
590
  fn query_method_is_valid() {
568
591
  assert_eq!(query_method().as_str(), "QUERY");
569
592
  }
593
+
594
+ #[test]
595
+ fn apply_default_session_overrides_and_skips() {
596
+ // Some(id): inject, overriding any existing value.
597
+ let mut meta = Metadata::from(serde_json::json!({"std:session-id": "client"}));
598
+ apply_default_session(&mut meta, &Some("sid_default".to_string()));
599
+ assert_eq!(
600
+ meta.get_as::<String>(META_SESSION_ID).as_deref(),
601
+ Some("sid_default")
602
+ );
603
+
604
+ // None: leave metadata untouched.
605
+ let mut meta2 = Metadata::from(serde_json::json!({"std:session-id": "client"}));
606
+ apply_default_session(&mut meta2, &None);
607
+ assert_eq!(
608
+ meta2.get_as::<String>(META_SESSION_ID).as_deref(),
609
+ Some("client")
610
+ );
611
+ }
570
612
  }
@@ -101,6 +101,15 @@ enum Command {
101
101
  #[arg(short, long)]
102
102
  listen: Option<String>,
103
103
 
104
+ /// Pre-open a single session at startup from this JSON object and
105
+ /// run as session-of-1: every call uses the pre-opened session, the
106
+ /// session machinery is hidden from clients (no virtual
107
+ /// open_session/close_session tools, no /sessions endpoints), and any
108
+ /// client-supplied std:session-id is ignored. Requires a component
109
+ /// that exports act:sessions/session-provider.
110
+ #[arg(long)]
111
+ session_args: Option<String>,
112
+
104
113
  #[command(flatten)]
105
114
  opts: CommonOpts,
106
115
  },
@@ -176,6 +185,27 @@ enum Command {
176
185
  /// the session lives as long as the host).
177
186
  #[command(subcommand)]
178
187
  Session(SessionCommand),
188
+ /// Manage the local component store (list, update, gc).
189
+ #[command(subcommand)]
190
+ Store(StoreCommand),
191
+ }
192
+
193
+ #[derive(clap::Subcommand)]
194
+ enum StoreCommand {
195
+ /// List components in the local store.
196
+ List {
197
+ /// Output format.
198
+ #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
199
+ format: OutputFormat,
200
+ },
201
+ /// Re-resolve stored components and re-pull any whose digest moved.
202
+ Update {
203
+ /// A single ref to update (omit to update all stored components).
204
+ #[arg(name = "ref")]
205
+ reference: Option<ComponentRef>,
206
+ },
207
+ /// Delete store blobs no longer referenced by any component.
208
+ Gc,
179
209
  }
180
210
 
181
211
  #[derive(clap::Subcommand)]
@@ -211,6 +241,7 @@ async fn main() -> Result<()> {
211
241
  Command::Session(sub) => match sub {
212
242
  SessionCommand::OpenArgsSchema { opts, .. } => opts.config.as_deref(),
213
243
  },
244
+ Command::Store(_) => None,
214
245
  };
215
246
  let log_level = config::load_config(config_path)
216
247
  .ok()
@@ -233,8 +264,9 @@ async fn main() -> Result<()> {
233
264
  mcp,
234
265
  http,
235
266
  listen,
267
+ session_args,
236
268
  opts,
237
- } => cmd_run(component, mcp, http, listen, opts).await,
269
+ } => cmd_run(component, mcp, http, listen, session_args, opts).await,
238
270
  Command::Call {
239
271
  component,
240
272
  tool,
@@ -259,6 +291,11 @@ async fn main() -> Result<()> {
259
291
  cmd_session_open_args_schema(component, opts).await
260
292
  }
261
293
  },
294
+ Command::Store(sub) => match sub {
295
+ StoreCommand::List { format } => cmd_list(format).await,
296
+ StoreCommand::Update { reference } => cmd_update(reference).await,
297
+ StoreCommand::Gc => cmd_gc().await,
298
+ },
262
299
  }
263
300
  }
264
301
 
@@ -404,11 +441,34 @@ fn parse_listen_addr(s: &str) -> Result<SocketAddr> {
404
441
  anyhow::bail!("invalid listen address: {s} (expected [host]:port or port number)")
405
442
  }
406
443
 
444
+ /// If `session_args` is set, open a single default session against the
445
+ /// prepared component and return its id (session-of-1, ACT-SESSIONS §3). The
446
+ /// session is closed automatically when the component actor shuts down
447
+ /// (`runtime` closes every tracked session on deinit).
448
+ async fn maybe_open_default_session(
449
+ pc: &PreparedComponent,
450
+ session_args: &Option<String>,
451
+ ) -> Result<Option<String>> {
452
+ match session_args {
453
+ Some(json) => {
454
+ if !pc.has_sessions {
455
+ anyhow::bail!(
456
+ "--session-args was set, but the component does not export \
457
+ act:sessions/session-provider"
458
+ );
459
+ }
460
+ Ok(Some(open_session_for_call(pc, json).await?))
461
+ }
462
+ None => Ok(None),
463
+ }
464
+ }
465
+
407
466
  async fn cmd_run(
408
467
  component: ComponentRef,
409
468
  mcp: bool,
410
469
  http: bool,
411
470
  listen: Option<String>,
471
+ session_args: Option<String>,
412
472
  opts: CommonOpts,
413
473
  ) -> Result<()> {
414
474
  // Transport matrix:
@@ -422,7 +482,16 @@ async fn cmd_run(
422
482
  None => "[::1]:3000".parse().unwrap(),
423
483
  };
424
484
  let pc = prepare_component(&component, &opts).await?;
425
- return rmcp_bridge::run_http(addr, pc.info, pc.handle, pc.metadata, pc.has_sessions).await;
485
+ let default_session_id = maybe_open_default_session(&pc, &session_args).await?;
486
+ return rmcp_bridge::run_http(
487
+ addr,
488
+ pc.info,
489
+ pc.handle,
490
+ pc.metadata,
491
+ pc.has_sessions,
492
+ default_session_id,
493
+ )
494
+ .await;
426
495
  }
427
496
 
428
497
  if mcp {
@@ -430,7 +499,15 @@ async fn cmd_run(
430
499
  anyhow::bail!("--listen requires --http (MCP stdio has no listen address)");
431
500
  }
432
501
  let pc = prepare_component(&component, &opts).await?;
433
- return rmcp_bridge::run_stdio(pc.info, pc.handle, pc.metadata, pc.has_sessions).await;
502
+ let default_session_id = maybe_open_default_session(&pc, &session_args).await?;
503
+ return rmcp_bridge::run_stdio(
504
+ pc.info,
505
+ pc.handle,
506
+ pc.metadata,
507
+ pc.has_sessions,
508
+ default_session_id,
509
+ )
510
+ .await;
434
511
  }
435
512
 
436
513
  if http || listen.is_some() {
@@ -440,11 +517,13 @@ async fn cmd_run(
440
517
  };
441
518
 
442
519
  let pc = prepare_component(&component, &opts).await?;
520
+ let default_session_id = maybe_open_default_session(&pc, &session_args).await?;
443
521
 
444
522
  let state = Arc::new(http::AppState {
445
523
  info: pc.info,
446
524
  component: pc.handle,
447
525
  metadata: pc.metadata,
526
+ default_session_id,
448
527
  });
449
528
 
450
529
  tracing::info!(%addr, "ACT host listening");
@@ -752,42 +831,132 @@ async fn cmd_pull(
752
831
  output: Option<PathBuf>,
753
832
  output_from_ref: bool,
754
833
  ) -> Result<()> {
755
- // Resolve to local path (downloads to cache for remote refs)
756
- // Always download fresh — pull is explicit user action
757
- let cached_path = resolve::resolve(&reference, true).await?;
834
+ let store = resolve::open_store()?;
835
+ let reference_str = reference.to_string();
836
+ let stored = act_store::pull(&store, &reference_str)
837
+ .await
838
+ .with_context(|| format!("pulling {reference_str}"))?;
839
+
840
+ // Path to the stored wasm blob (read-through hit; no re-pull).
841
+ let stored_path = act_store::ensure(&store, &reference_str).await?;
842
+
843
+ let export = output.or_else(|| {
844
+ output_from_ref.then(|| {
845
+ let ref_str = reference.to_string();
846
+ let base = ref_str
847
+ .rsplit('/')
848
+ .next()
849
+ .unwrap_or(&ref_str)
850
+ .split(':')
851
+ .next()
852
+ .unwrap_or(&ref_str);
853
+ let filename = if base.ends_with(".wasm") {
854
+ base.to_string()
855
+ } else {
856
+ format!("{base}.wasm")
857
+ };
858
+ PathBuf::from(filename)
859
+ })
860
+ });
758
861
 
759
- if let Some(out) = output {
760
- tokio::fs::copy(&cached_path, &out)
761
- .await
762
- .with_context(|| format!("copying to {}", out.display()))?;
763
- println!("{}", out.display());
764
- } else if output_from_ref {
765
- let ref_str = reference.to_string();
766
- let base = ref_str
767
- .rsplit('/')
768
- .next()
769
- .unwrap_or(&ref_str)
770
- .split(':')
771
- .next()
772
- .unwrap_or(&ref_str);
773
- let filename = if base.ends_with(".wasm") {
774
- base.to_string()
775
- } else {
776
- format!("{base}.wasm")
777
- };
778
- let out = PathBuf::from(&filename);
779
- tokio::fs::copy(&cached_path, &out)
862
+ if let Some(out) = export {
863
+ tokio::fs::copy(&stored_path, &out)
780
864
  .await
781
865
  .with_context(|| format!("copying to {}", out.display()))?;
782
866
  println!("{}", out.display());
783
867
  } else {
784
- // No output flag — print cached path
785
- println!("{}", cached_path.display());
868
+ println!(
869
+ "{} -> {} (sha256:{})",
870
+ reference_str,
871
+ stored_path.display(),
872
+ stored.manifest_digest
873
+ );
874
+ }
875
+ Ok(())
876
+ }
877
+
878
+ // ── Store subcommands ─────────────────────────────────────────────────────────
879
+
880
+ async fn cmd_list(format: OutputFormat) -> Result<()> {
881
+ let store = resolve::open_store()?;
882
+ let mut items = store.list()?;
883
+ items.sort_by(|a, b| source_ref(&a.provenance).cmp(source_ref(&b.provenance)));
884
+ match format {
885
+ OutputFormat::Json => {
886
+ let rows: Vec<_> = items
887
+ .iter()
888
+ .map(|s| {
889
+ serde_json::json!({
890
+ "ref": source_ref(&s.provenance),
891
+ "digest": s.provenance.digest,
892
+ "name": s.provenance.name,
893
+ "version": s.provenance.version,
894
+ "fetched_at": s.provenance.fetched_at,
895
+ })
896
+ })
897
+ .collect();
898
+ println!("{}", serde_json::to_string_pretty(&rows)?);
899
+ }
900
+ OutputFormat::Text => {
901
+ if items.is_empty() {
902
+ println!("(store is empty)");
903
+ }
904
+ for s in &items {
905
+ println!(
906
+ "{}\t{}\t{}",
907
+ source_ref(&s.provenance),
908
+ s.provenance.version.as_deref().unwrap_or("-"),
909
+ s.provenance.digest
910
+ );
911
+ }
912
+ }
786
913
  }
914
+ Ok(())
915
+ }
787
916
 
917
+ async fn cmd_update(reference: Option<ComponentRef>) -> Result<()> {
918
+ let store = resolve::open_store()?;
919
+ let refs: Vec<String> = match reference {
920
+ Some(r) => vec![r.to_string()],
921
+ None => store
922
+ .list()?
923
+ .iter()
924
+ .map(|s| source_ref(&s.provenance).to_string())
925
+ .collect(),
926
+ };
927
+ if refs.is_empty() {
928
+ println!("(store is empty)");
929
+ return Ok(());
930
+ }
931
+ for r in refs {
932
+ match act_store::update(&store, &r).await {
933
+ Ok(act_store::UpdateOutcome::Unchanged) => println!("{r}\tunchanged"),
934
+ Ok(act_store::UpdateOutcome::Updated { from, to }) => {
935
+ println!("{r}\tupdated {from} -> {to}")
936
+ }
937
+ Ok(act_store::UpdateOutcome::NotStored) => println!("{r}\tnot stored"),
938
+ Err(e) => eprintln!("{r}\tERROR: {e}"),
939
+ }
940
+ }
941
+ Ok(())
942
+ }
943
+
944
+ async fn cmd_gc() -> Result<()> {
945
+ let store = resolve::open_store()?;
946
+ let removed = store.gc()?;
947
+ println!("removed {removed} unreferenced blob(s)");
788
948
  Ok(())
789
949
  }
790
950
 
951
+ /// The source ref (as typed) recorded in a provenance.
952
+ fn source_ref(p: &act_store::Provenance) -> &str {
953
+ match &p.source {
954
+ act_store::Source::Oci { reference } => reference,
955
+ act_store::Source::Http { url, .. } => url,
956
+ act_store::Source::Local { path } => path,
957
+ }
958
+ }
959
+
791
960
  #[cfg(test)]
792
961
  mod tests {
793
962
  use super::*;
@@ -0,0 +1,42 @@
1
+ //! Component reference resolution, backed by the shared `act-store`.
2
+ //!
3
+ //! `ComponentRef` is re-exported from `act-store` (the parsing source of truth).
4
+ //! Local refs run in place; remote refs (OCI/HTTP) resolve read-through the
5
+ //! store (pulled on first use, then served from disk).
6
+
7
+ use std::path::PathBuf;
8
+
9
+ use anyhow::{Context, Result};
10
+
11
+ pub use act_store::Ref as ComponentRef;
12
+
13
+ /// Open the shared component store at its platform default location.
14
+ pub fn open_store() -> Result<act_store::Store> {
15
+ let dir = act_store::store_dir().context("locating component store")?;
16
+ act_store::Store::open(&dir).context("opening component store")
17
+ }
18
+
19
+ /// Resolve a component reference to a local `.wasm` path.
20
+ ///
21
+ /// Local files are used in place (never copied into the store). Remote refs
22
+ /// (OCI/HTTP) are served read-through from the store; `fresh` forces a re-pull.
23
+ pub async fn resolve(component_ref: &ComponentRef, fresh: bool) -> Result<PathBuf> {
24
+ if let ComponentRef::Local(path) = component_ref {
25
+ anyhow::ensure!(
26
+ tokio::fs::try_exists(path).await.unwrap_or(false),
27
+ "component not found: {}",
28
+ path.display()
29
+ );
30
+ return Ok(path.clone());
31
+ }
32
+ let store = open_store()?;
33
+ let reference = component_ref.to_string();
34
+ if fresh {
35
+ act_store::pull(&store, &reference)
36
+ .await
37
+ .with_context(|| format!("pulling {reference}"))?;
38
+ }
39
+ act_store::ensure(&store, &reference)
40
+ .await
41
+ .with_context(|| format!("resolving {reference}"))
42
+ }