1use std::convert::TryFrom;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5
6use anyhow::{Context, Result, anyhow, bail};
7use bytes::Bytes;
8use greentic_distributor_client::{
9 DistributorClient, DistributorClientConfig, DistributorEnvironmentId, HttpDistributorClient,
10 ResolveComponentRequest,
11};
12use greentic_pack::builder::ComponentEntry;
13use greentic_types::{EnvId, TenantCtx, TenantId};
14use reqwest::blocking::Client;
15use semver::Version;
16use serde_json::json;
17use tokio::runtime::Runtime;
18
19use crate::config;
20use crate::distributor;
21use crate::pack_init::{
22 PackInitIntent, WorkspaceComponent, WorkspaceManifest, manifest_path, slugify,
23};
24
25pub fn run_component_add(
26 coordinate: &str,
27 profile: Option<&str>,
28 intent: PackInitIntent,
29) -> Result<PathBuf> {
30 let cfg = config::load_with_meta(None)?;
31 let profile = distributor::resolve_profile(&cfg, profile)?;
32 let (component_id, version_req) = parse_coordinate(coordinate)?;
33 let tenant_ctx = build_tenant_ctx(&profile)?;
34 let environment_id = DistributorEnvironmentId::from(profile.environment_id.as_str());
35 let pack_id = detect_pack_id().unwrap_or_else(|| "greentic-dev-local".to_string());
36
37 let req = ResolveComponentRequest {
38 tenant: tenant_ctx.clone(),
39 environment_id,
40 pack_id,
41 component_id: component_id.clone(),
42 version: version_req.to_string(),
43 extra: json!({ "intent": format!("{:?}", intent) }),
44 };
45
46 let client = http_client(&profile)?;
47 let rt = Runtime::new().context("failed to start tokio runtime for distributor client")?;
48 let response = rt.block_on(client.resolve_component(req))?;
49
50 let artifact_bytes = fetch_artifact(&response.artifact)?;
51 let (cache_dir, cache_path) =
52 write_component_to_cache(&component_id, &version_req, &artifact_bytes)?;
53 update_manifest(coordinate, &component_id, &version_req, &cache_path)?;
54
55 println!(
56 "Resolved {} -> {}@{}",
57 coordinate, component_id, version_req
58 );
59 println!("Cached component at {}", cache_path.display());
60 println!(
61 "Updated workspace manifest at {}",
62 manifest_path()?.display()
63 );
64
65 Ok(cache_dir)
66}
67
68fn parse_coordinate(input: &str) -> Result<(String, String)> {
69 if let Some((id, ver)) = input.rsplit_once('@') {
70 Ok((id.to_string(), ver.to_string()))
71 } else {
72 Ok((input.to_string(), "*".to_string()))
73 }
74}
75
76fn build_tenant_ctx(profile: &distributor::DistributorProfile) -> Result<TenantCtx> {
77 let env = EnvId::from_str(&profile.environment_id)
78 .or_else(|_| EnvId::try_from(profile.environment_id.as_str()))
79 .map_err(|err| anyhow!("invalid environment id `{}`: {err}", profile.environment_id))?;
80 let tenant = TenantId::from_str(&profile.tenant_id)
81 .or_else(|_| TenantId::try_from(profile.tenant_id.as_str()))
82 .map_err(|err| anyhow!("invalid tenant id `{}`: {err}", profile.tenant_id))?;
83 Ok(TenantCtx::new(env, tenant))
84}
85
86fn http_client(profile: &distributor::DistributorProfile) -> Result<HttpDistributorClient> {
87 let env_id = EnvId::from_str(&profile.environment_id)
88 .or_else(|_| EnvId::try_from(profile.environment_id.as_str()))
89 .map_err(|err| anyhow!("invalid environment id `{}`: {err}", profile.environment_id))?;
90 let tenant_id = TenantId::from_str(&profile.tenant_id)
91 .or_else(|_| TenantId::try_from(profile.tenant_id.as_str()))
92 .map_err(|err| anyhow!("invalid tenant id `{}`: {err}", profile.tenant_id))?;
93 let cfg = DistributorClientConfig {
94 base_url: Some(profile.url.clone()),
95 environment_id: DistributorEnvironmentId::from(profile.environment_id.as_str()),
96 tenant: TenantCtx::new(env_id, tenant_id),
97 auth_token: profile.token.clone(),
98 extra_headers: profile.headers.clone(),
99 request_timeout: None,
100 };
101 HttpDistributorClient::new(cfg).map_err(Into::into)
102}
103
104fn fetch_artifact(location: &greentic_distributor_client::ArtifactLocation) -> Result<Bytes> {
105 match location {
106 greentic_distributor_client::ArtifactLocation::FilePath { path } => {
107 if path.starts_with("http://") || path.starts_with("https://") {
108 let client = Client::new();
109 let resp = client
110 .get(path)
111 .send()
112 .context("failed to download artifact")?;
113 if !resp.status().is_success() {
114 bail!("artifact download failed with status {}", resp.status());
115 }
116 resp.bytes().map_err(Into::into)
117 } else if let Some(rest) = path.strip_prefix("file://") {
118 fs::read(rest)
119 .map(Bytes::from)
120 .with_context(|| format!("failed to read component at {}", rest))
121 } else {
122 fs::read(path)
123 .map(Bytes::from)
124 .with_context(|| format!("failed to read component at {}", path))
125 }
126 }
127 greentic_distributor_client::ArtifactLocation::OciReference { reference } => {
128 bail!("OCI component artifacts are not supported yet ({reference})")
129 }
130 greentic_distributor_client::ArtifactLocation::DistributorInternal { handle } => {
131 bail!("Distributor internal artifacts are not supported yet ({handle})")
132 }
133 }
134}
135
136fn write_component_to_cache(
137 component_id: &str,
138 version: &str,
139 bytes: &Bytes,
140) -> Result<(PathBuf, PathBuf)> {
141 let mut path = cache_base_dir()?;
142 let slug = cache_slug_parts(component_id, version);
143 path.push(slug);
144 fs::create_dir_all(&path).with_context(|| format!("failed to create {}", path.display()))?;
145 let file_path = path.join("artifact.wasm");
146 fs::write(&file_path, bytes)
147 .with_context(|| format!("failed to write {}", file_path.display()))?;
148 Ok((path, file_path))
149}
150
151fn cache_base_dir() -> Result<PathBuf> {
152 let mut base = std::env::current_dir().context("unable to determine workspace root")?;
153 base.push(".greentic");
154 base.push("components");
155 fs::create_dir_all(&base)
156 .with_context(|| format!("failed to create cache directory {}", base.display()))?;
157 Ok(base)
158}
159
160fn cache_slug_parts(component_id: &str, version: &str) -> String {
161 slugify(&format!("{}-{}", component_id.replace('/', "-"), version))
162}
163
164fn update_manifest(
165 coordinate: &str,
166 component_id: &str,
167 version: &str,
168 wasm_path: &Path,
169) -> Result<()> {
170 let manifest_path = manifest_path()?;
171 let mut manifest: WorkspaceManifest = if manifest_path.exists() {
172 let data = fs::read_to_string(&manifest_path)
173 .with_context(|| format!("failed to read {}", manifest_path.display()))?;
174 serde_json::from_str(&data)
175 .with_context(|| format!("failed to parse {}", manifest_path.display()))?
176 } else {
177 WorkspaceManifest::default()
178 };
179
180 let entry = WorkspaceComponent {
181 coordinate: coordinate.to_string(),
182 entry: ComponentEntry {
183 name: component_id.to_string(),
184 version: Version::parse(version).unwrap_or_else(|_| Version::new(0, 0, 0)),
185 file_wasm: wasm_path.display().to_string(),
186 hash_blake3: String::new(),
187 schema_file: None,
188 manifest_file: None,
189 world: None,
190 capabilities: None,
191 },
192 };
193
194 let mut replaced = false;
195 for existing in manifest.components.iter_mut() {
196 if existing.entry.name == entry.entry.name {
197 *existing = entry.clone();
198 replaced = true;
199 break;
200 }
201 }
202 if !replaced {
203 manifest.components.push(entry);
204 }
205
206 let rendered =
207 serde_json::to_string_pretty(&manifest).context("failed to render workspace manifest")?;
208 fs::write(&manifest_path, rendered)
209 .with_context(|| format!("failed to write {}", manifest_path.display()))?;
210 Ok(())
211}
212
213fn detect_pack_id() -> Option<String> {
214 let candidates = ["pack.toml", "Pack.toml"];
215 for candidate in candidates {
216 let path = Path::new(candidate);
217 if path.exists() {
218 let data = fs::read_to_string(path).ok()?;
219 if let Ok(value) = data.parse::<toml::Value>()
220 && let Some(id) = value.get("pack_id").and_then(|v| v.as_str())
221 {
222 return Some(id.to_string());
223 }
224 }
225 }
226 None
227}