1use 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
24const STORE_TOKEN_ENV: &str = "GREENTIC_STORE_TOKEN";
27
28#[derive(Subcommand, Debug)]
29pub enum DwCommand {
30 Publish(DwPublishArgs),
32 Install(DwInstallArgs),
34}
35
36#[derive(Args, Debug, Clone)]
37pub struct DwPublishArgs {
38 pub artifact: PathBuf,
40 #[arg(long = "describe")]
42 pub describe: PathBuf,
43 #[arg(long = "store")]
45 pub store: String,
46 #[arg(long = "token")]
48 pub token: Option<String>,
49}
50
51#[derive(Args, Debug, Clone)]
52pub struct DwInstallArgs {
53 pub name: String,
55 pub version: String,
57 #[arg(long = "store")]
59 pub store: String,
60 #[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
72fn http_client() -> Result<Client> {
74 Client::builder()
75 .timeout(Duration::from_secs(120))
76 .build()
77 .context("failed to build HTTP client")
78}
79
80fn 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
97fn 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
107fn 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
133fn 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 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 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 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 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 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
328fn 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
337fn 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 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 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 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}