Skip to main content

greentic_dev/
dw_cmd.rs

1//! Thin store-API client for agentic worker publish/install.
2//!
3//! Mirrors the HTTP distribution precedent in [`crate::distributor`] and the
4//! designer's `StoreClient` (greentic-designer `src/store/client.rs`). No
5//! upstream CLI exists for these operations, so the commands live natively in
6//! greentic-dev rather than passing through to a delegated binary.
7//!
8//! The store endpoints are:
9//! - `POST {store}/api/v1/agentic-workers` — multipart publish (auth required).
10//! - `GET  {store}/api/v1/agentic-workers/{name}/{version}` — version metadata.
11//! - `GET  {store}/api/v1/agentic-workers/{name}/{version}/artifact` — bytes.
12
13use std::path::{Path, PathBuf};
14use std::time::Duration;
15
16use anyhow::{Context, Result, bail};
17use clap::{Args, Subcommand};
18use reqwest::blocking::{Client, multipart};
19use serde_json::Value;
20use sha2::{Digest, Sha256};
21
22use crate::i18n;
23
24/// Environment variable consulted for the publish bearer token when `--token`
25/// is not supplied.
26const STORE_TOKEN_ENV: &str = "GREENTIC_STORE_TOKEN";
27
28#[derive(Subcommand, Debug)]
29pub enum DwCommand {
30    /// cli.command.dw.publish.about
31    Publish(DwPublishArgs),
32    /// cli.command.dw.install.about
33    Install(DwInstallArgs),
34}
35
36#[derive(Args, Debug, Clone)]
37pub struct DwPublishArgs {
38    /// cli.command.dw.publish.artifact
39    pub artifact: PathBuf,
40    /// cli.command.dw.publish.describe
41    #[arg(long = "describe")]
42    pub describe: PathBuf,
43    /// cli.command.dw.publish.store
44    #[arg(long = "store")]
45    pub store: String,
46    /// cli.command.dw.publish.token
47    #[arg(long = "token")]
48    pub token: Option<String>,
49}
50
51#[derive(Args, Debug, Clone)]
52pub struct DwInstallArgs {
53    /// cli.command.dw.install.name
54    pub name: String,
55    /// cli.command.dw.install.version
56    pub version: String,
57    /// cli.command.dw.install.store
58    #[arg(long = "store")]
59    pub store: String,
60    /// cli.command.dw.install.out
61    #[arg(long = "out")]
62    pub out: Option<PathBuf>,
63}
64
65pub fn run(cmd: DwCommand, locale: &str) -> Result<()> {
66    match cmd {
67        DwCommand::Publish(args) => run_publish(&args, locale),
68        DwCommand::Install(args) => run_install(&args, locale),
69    }
70}
71
72/// Build a blocking HTTP client matching the distributor's configuration.
73fn http_client() -> Result<Client> {
74    Client::builder()
75        .timeout(Duration::from_secs(120))
76        .build()
77        .context("failed to build HTTP client")
78}
79
80/// Percent-encode a single URL path segment. Keeps unreserved characters
81/// (RFC 3986: ALPHA / DIGIT / `-._~`) and encodes everything else, so worker
82/// names like `acme.greeter` and semver versions pass through unchanged while
83/// any unexpected character is escaped safely.
84fn encode_path_segment(segment: &str) -> String {
85    let mut out = String::with_capacity(segment.len());
86    for byte in segment.bytes() {
87        match byte {
88            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
89                out.push(byte as char);
90            }
91            _ => out.push_str(&format!("%{byte:02X}")),
92        }
93    }
94    out
95}
96
97/// Lowercase hex sha256 of the given bytes (matches the store's encoding).
98fn sha256_hex(bytes: &[u8]) -> String {
99    let digest = Sha256::digest(bytes);
100    let mut output = String::with_capacity(digest.len() * 2);
101    for byte in digest {
102        output.push_str(&format!("{byte:02x}"));
103    }
104    output
105}
106
107/// Resolve the publish bearer token: `--token` flag, then `GREENTIC_STORE_TOKEN`.
108///
109/// Mirrors the distributor precedence style (explicit value wins over env).
110fn resolve_publish_token(flag: Option<&str>, locale: &str) -> Result<String> {
111    if let Some(token) = flag {
112        let trimmed = token.trim();
113        if !trimmed.is_empty() {
114            return Ok(trimmed.to_string());
115        }
116    }
117    if let Ok(value) = std::env::var(STORE_TOKEN_ENV) {
118        let trimmed = value.trim();
119        if !trimmed.is_empty() {
120            return Ok(trimmed.to_string());
121        }
122    }
123    bail!(
124        "{}",
125        i18n::tf(
126            locale,
127            "runtime.dw.error.missing_token",
128            &[("env", STORE_TOKEN_ENV.to_string())],
129        )
130    )
131}
132
133/// Construct the `{describe, artifactSha256}` metadata envelope the store
134/// publish endpoint expects (see greentic-store-server publish handler /
135/// `PublishRequest`). The describe document is passed through verbatim.
136fn build_publish_metadata(describe: Value, artifact_sha256: &str) -> Value {
137    serde_json::json!({
138        "describe": describe,
139        "artifactSha256": artifact_sha256,
140    })
141}
142
143fn run_publish(args: &DwPublishArgs, locale: &str) -> Result<()> {
144    let token = resolve_publish_token(args.token.as_deref(), locale)?;
145    let base = args.store.trim_end_matches('/').to_string();
146
147    let describe_raw = std::fs::read(&args.describe).with_context(|| {
148        i18n::tf(
149            locale,
150            "runtime.dw.error.read_describe",
151            &[("path", args.describe.display().to_string())],
152        )
153    })?;
154    let describe: Value = serde_json::from_slice(&describe_raw).with_context(|| {
155        i18n::tf(
156            locale,
157            "runtime.dw.error.parse_describe",
158            &[("path", args.describe.display().to_string())],
159        )
160    })?;
161
162    let artifact_bytes = std::fs::read(&args.artifact).with_context(|| {
163        i18n::tf(
164            locale,
165            "runtime.dw.error.read_artifact",
166            &[("path", args.artifact.display().to_string())],
167        )
168    })?;
169    let artifact_sha256 = sha256_hex(&artifact_bytes);
170    let metadata = build_publish_metadata(describe, &artifact_sha256);
171    let metadata_str =
172        serde_json::to_string(&metadata).context("failed to serialize publish metadata")?;
173
174    let artifact_part = multipart::Part::bytes(artifact_bytes)
175        .file_name("artifact.gtpack")
176        .mime_str("application/octet-stream")
177        .context("failed to build artifact multipart part")?;
178    let form = multipart::Form::new()
179        .text("metadata", metadata_str)
180        .part("artifact", artifact_part);
181
182    let url = format!("{base}/api/v1/agentic-workers");
183    let client = http_client()?;
184    let response = client
185        .post(&url)
186        .bearer_auth(&token)
187        .multipart(form)
188        .send()
189        .with_context(|| {
190            i18n::tf(
191                locale,
192                "runtime.dw.error.request_failed",
193                &[("url", url.clone())],
194            )
195        })?;
196
197    let status = response.status();
198    let body = response.text().unwrap_or_default();
199    if !status.is_success() {
200        eprintln!(
201            "{}",
202            i18n::tf(
203                locale,
204                "runtime.dw.error.publish_status",
205                &[("status", status.to_string()), ("body", body)],
206            )
207        );
208        std::process::exit(1);
209    }
210
211    // Pretty-print the store's JSON response; fall back to raw text.
212    match serde_json::from_str::<Value>(&body) {
213        Ok(json) => {
214            let pretty = serde_json::to_string_pretty(&json).unwrap_or_else(|_| body.clone());
215            println!("{pretty}");
216        }
217        Err(_) => println!("{body}"),
218    }
219    Ok(())
220}
221
222fn run_install(args: &DwInstallArgs, locale: &str) -> Result<()> {
223    let base = args.store.trim_end_matches('/').to_string();
224    let client = http_client()?;
225
226    // 1. Fetch version metadata to learn the expected artifact sha256.
227    let meta_url = format!(
228        "{base}/api/v1/agentic-workers/{name}/{version}",
229        name = encode_path_segment(&args.name),
230        version = encode_path_segment(&args.version),
231    );
232    let meta_resp = client.get(&meta_url).send().with_context(|| {
233        i18n::tf(
234            locale,
235            "runtime.dw.error.request_failed",
236            &[("url", meta_url.clone())],
237        )
238    })?;
239    let meta_status = meta_resp.status();
240    let meta_body = meta_resp.text().unwrap_or_default();
241    if !meta_status.is_success() {
242        bail!(
243            "{}",
244            i18n::tf(
245                locale,
246                "runtime.dw.error.metadata_status",
247                &[("status", meta_status.to_string()), ("body", meta_body)],
248            )
249        );
250    }
251    let metadata: Value =
252        serde_json::from_str(&meta_body).context("store metadata response was not valid JSON")?;
253    let expected_sha = extract_artifact_sha256(&metadata)
254        .ok_or_else(|| anyhow::anyhow!(i18n::t(locale, "runtime.dw.error.missing_sha")))?;
255
256    // 2. Download the artifact bytes.
257    let artifact_url = format!(
258        "{base}/api/v1/agentic-workers/{name}/{version}/artifact",
259        name = encode_path_segment(&args.name),
260        version = encode_path_segment(&args.version),
261    );
262    let artifact_resp = client.get(&artifact_url).send().with_context(|| {
263        i18n::tf(
264            locale,
265            "runtime.dw.error.request_failed",
266            &[("url", artifact_url.clone())],
267        )
268    })?;
269    let artifact_status = artifact_resp.status();
270    if !artifact_status.is_success() {
271        let body = artifact_resp.text().unwrap_or_default();
272        bail!(
273            "{}",
274            i18n::tf(
275                locale,
276                "runtime.dw.error.artifact_status",
277                &[("status", artifact_status.to_string()), ("body", body)],
278            )
279        );
280    }
281    let artifact_bytes = artifact_resp
282        .bytes()
283        .context("failed to read artifact bytes from store")?;
284
285    // 3. Verify sha256 — hard fail with NO file written on mismatch.
286    let actual_sha = sha256_hex(&artifact_bytes);
287    if !actual_sha.eq_ignore_ascii_case(&expected_sha) {
288        bail!(
289            "{}",
290            i18n::tf(
291                locale,
292                "runtime.dw.error.sha_mismatch",
293                &[("expected", expected_sha), ("actual", actual_sha)],
294            )
295        );
296    }
297
298    // 4. Write atomically (temp + rename) into the output directory.
299    let out_dir = args.out.clone().unwrap_or_else(|| PathBuf::from("."));
300    std::fs::create_dir_all(&out_dir).with_context(|| {
301        i18n::tf(
302            locale,
303            "runtime.dw.error.create_out_dir",
304            &[("path", out_dir.display().to_string())],
305        )
306    })?;
307    let file_name = format!("{}-{}.gtpack", args.name, args.version);
308    let dest = out_dir.join(&file_name);
309    write_atomic(&out_dir, &dest, &artifact_bytes).with_context(|| {
310        i18n::tf(
311            locale,
312            "runtime.dw.error.write_artifact",
313            &[("path", dest.display().to_string())],
314        )
315    })?;
316
317    println!(
318        "{}",
319        i18n::tf(
320            locale,
321            "runtime.dw.install.success",
322            &[("path", dest.display().to_string()), ("sha", actual_sha),],
323        )
324    );
325    Ok(())
326}
327
328/// Pull `artifactSha256` out of the version metadata JSON (camelCase per the
329/// store's `AgenticWorkerVersionMetadata` serialization).
330fn extract_artifact_sha256(metadata: &Value) -> Option<String> {
331    metadata
332        .get("artifactSha256")
333        .and_then(|v| v.as_str())
334        .map(str::to_string)
335}
336
337/// Write `bytes` to `dest` via a temporary file in `dir` then rename, so a
338/// failed/interrupted write never leaves a partial `.gtpack` behind.
339fn write_atomic(dir: &Path, dest: &Path, bytes: &[u8]) -> Result<()> {
340    let mut tmp = tempfile::Builder::new()
341        .prefix(".dw-install-")
342        .suffix(".gtpack.tmp")
343        .tempfile_in(dir)
344        .context("failed to create temp file")?;
345    use std::io::Write;
346    tmp.write_all(bytes).context("failed to write temp file")?;
347    tmp.flush().context("failed to flush temp file")?;
348    tmp.persist(dest)
349        .map_err(|e| e.error)
350        .context("failed to move temp file into place")?;
351    Ok(())
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use serde_json::json;
358
359    #[test]
360    fn sha256_hex_matches_known_vector() {
361        // sha256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
362        assert_eq!(
363            sha256_hex(b""),
364            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
365        );
366    }
367
368    #[test]
369    fn build_publish_metadata_wraps_describe_and_sha() {
370        let describe = json!({
371            "apiVersion": "v2",
372            "kind": "AgenticWorker",
373            "manifestSha256": "ab".repeat(32),
374            "metadata": {"id": "acme.greeter", "version": "0.1.0"},
375        });
376        let sha = "cd".repeat(32);
377        let envelope = build_publish_metadata(describe.clone(), &sha);
378        assert_eq!(envelope["describe"], describe);
379        assert_eq!(envelope["artifactSha256"], json!(sha));
380        // Exactly the two fields the store's PublishRequest deserializes.
381        let obj = envelope.as_object().unwrap();
382        assert_eq!(obj.len(), 2);
383        assert!(obj.contains_key("describe"));
384        assert!(obj.contains_key("artifactSha256"));
385    }
386
387    #[test]
388    fn resolve_token_prefers_flag() {
389        let token = resolve_publish_token(Some("flag-token"), "en").unwrap();
390        assert_eq!(token, "flag-token");
391    }
392
393    #[test]
394    fn resolve_token_errors_when_absent() {
395        // Ensure env is unset for this assertion.
396        // Safe: single-threaded test, no other reader of this var here.
397        unsafe {
398            std::env::remove_var(STORE_TOKEN_ENV);
399        }
400        let err = resolve_publish_token(None, "en").unwrap_err();
401        assert!(err.to_string().contains(STORE_TOKEN_ENV));
402    }
403
404    #[test]
405    fn extract_artifact_sha256_reads_camel_case() {
406        let meta = json!({"artifactSha256": "deadbeef", "manifestSha256": "x"});
407        assert_eq!(extract_artifact_sha256(&meta).as_deref(), Some("deadbeef"));
408    }
409
410    #[test]
411    fn extract_artifact_sha256_absent_is_none() {
412        let meta = json!({"manifestSha256": "x"});
413        assert!(extract_artifact_sha256(&meta).is_none());
414    }
415}