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
25#[derive(Debug, Clone)]
26struct SimpleStub {
27 artifact_path: PathBuf,
28 digest: String,
29 signature: greentic_types::distributor::SignatureSummary,
30 cache: greentic_types::distributor::CacheInfo,
31}
32
33pub fn run_component_add(
34 coordinate: &str,
35 profile: Option<&str>,
36 intent: PackInitIntent,
37) -> Result<PathBuf> {
38 let coordinate_path = PathBuf::from(coordinate);
39 if coordinate_path.exists() {
40 return Ok(coordinate_path);
41 }
42
43 let offline = std::env::var("GREENTIC_DEV_OFFLINE")
44 .ok()
45 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
46 .unwrap_or(false);
47 let stubbed_response = load_stubbed_response();
48 if offline && stubbed_response.is_none() {
49 bail!(
50 "offline mode enabled (GREENTIC_DEV_OFFLINE=1) and coordinate `{coordinate}` is not a local path; provide a stub via GREENTIC_DEV_RESOLVE_STUB or use a local component path"
51 );
52 }
53
54 let (component_id, version_req) = parse_coordinate(coordinate)?;
55
56 let response = if let Some(resp) = stubbed_response {
57 resp?
58 } else {
59 let cfg = config::load_with_meta(None)?;
60 let profile = distributor::resolve_profile(&cfg, profile)?;
61 let tenant_ctx = build_tenant_ctx(&profile)?;
62 let environment_id = DistributorEnvironmentId::from(profile.environment_id.as_str());
63 let pack_id = detect_pack_id().unwrap_or_else(|| "greentic-dev-local".to_string());
64
65 let req = ResolveComponentRequest {
66 tenant: tenant_ctx.clone(),
67 environment_id,
68 pack_id,
69 component_id: component_id.clone(),
70 version: version_req.to_string(),
71 extra: json!({ "intent": format!("{:?}", intent) }),
72 };
73
74 let client = http_client(&profile)?;
75 let rt = Runtime::new().context("failed to start tokio runtime for distributor client")?;
76 rt.block_on(client.resolve_component(req))?
77 };
78
79 let artifact_bytes = fetch_artifact(&response.artifact)?;
80 let (cache_dir, cache_path) =
81 write_component_to_cache(&component_id, &version_req, &artifact_bytes)?;
82 update_manifest(coordinate, &component_id, &version_req, &cache_path)?;
83
84 println!(
85 "Resolved {} -> {}@{}",
86 coordinate, component_id, version_req
87 );
88 println!("Cached component at {}", cache_path.display());
89 println!(
90 "Updated workspace manifest at {}",
91 manifest_path()?.display()
92 );
93
94 Ok(cache_dir)
95}
96
97fn parse_coordinate(input: &str) -> Result<(String, String)> {
98 if let Some((id, ver)) = input.rsplit_once('@') {
99 Ok((id.to_string(), ver.to_string()))
100 } else {
101 Ok((input.to_string(), "*".to_string()))
102 }
103}
104
105fn build_tenant_ctx(profile: &distributor::DistributorProfile) -> Result<TenantCtx> {
106 let env = EnvId::from_str(&profile.environment_id)
107 .or_else(|_| EnvId::try_from(profile.environment_id.as_str()))
108 .map_err(|err| anyhow!("invalid environment id `{}`: {err}", profile.environment_id))?;
109 let tenant = TenantId::from_str(&profile.tenant_id)
110 .or_else(|_| TenantId::try_from(profile.tenant_id.as_str()))
111 .map_err(|err| anyhow!("invalid tenant id `{}`: {err}", profile.tenant_id))?;
112 Ok(TenantCtx::new(env, tenant))
113}
114
115fn http_client(profile: &distributor::DistributorProfile) -> Result<HttpDistributorClient> {
116 let env_id = EnvId::from_str(&profile.environment_id)
117 .or_else(|_| EnvId::try_from(profile.environment_id.as_str()))
118 .map_err(|err| anyhow!("invalid environment id `{}`: {err}", profile.environment_id))?;
119 let tenant_id = TenantId::from_str(&profile.tenant_id)
120 .or_else(|_| TenantId::try_from(profile.tenant_id.as_str()))
121 .map_err(|err| anyhow!("invalid tenant id `{}`: {err}", profile.tenant_id))?;
122 let cfg = DistributorClientConfig {
123 base_url: Some(profile.url.clone()),
124 environment_id: DistributorEnvironmentId::from(profile.environment_id.as_str()),
125 tenant: TenantCtx::new(env_id, tenant_id),
126 auth_token: profile.token.clone(),
127 extra_headers: profile.headers.clone(),
128 request_timeout: None,
129 };
130 HttpDistributorClient::new(cfg).map_err(Into::into)
131}
132
133fn fetch_artifact(location: &greentic_distributor_client::ArtifactLocation) -> Result<Bytes> {
134 match location {
135 greentic_distributor_client::ArtifactLocation::FilePath { path } => {
136 if path.starts_with("http://") || path.starts_with("https://") {
137 let client = Client::new();
138 let resp = client
139 .get(path)
140 .send()
141 .context("failed to download artifact")?;
142 if !resp.status().is_success() {
143 bail!("artifact download failed with status {}", resp.status());
144 }
145 resp.bytes().map_err(Into::into)
146 } else if let Some(rest) = path.strip_prefix("file://") {
147 fs::read(rest)
148 .map(Bytes::from)
149 .with_context(|| format!("failed to read component at {}", rest))
150 } else {
151 fs::read(path)
152 .map(Bytes::from)
153 .with_context(|| format!("failed to read component at {}", path))
154 }
155 }
156 greentic_distributor_client::ArtifactLocation::OciReference { reference } => {
157 bail!("OCI component artifacts are not supported yet ({reference})")
158 }
159 greentic_distributor_client::ArtifactLocation::DistributorInternal { handle } => {
160 bail!("Distributor internal artifacts are not supported yet ({handle})")
161 }
162 }
163}
164
165fn write_component_to_cache(
166 component_id: &str,
167 version: &str,
168 bytes: &Bytes,
169) -> Result<(PathBuf, PathBuf)> {
170 let mut path = cache_base_dir()?;
171 let slug = cache_slug_parts(component_id, version);
172 path.push(slug);
173 fs::create_dir_all(&path).with_context(|| format!("failed to create {}", path.display()))?;
174 let file_path = path.join("artifact.wasm");
175 fs::write(&file_path, bytes)
176 .with_context(|| format!("failed to write {}", file_path.display()))?;
177 Ok((path, file_path))
178}
179
180fn cache_base_dir() -> Result<PathBuf> {
181 let mut base = std::env::current_dir().context("unable to determine workspace root")?;
182 base.push(".greentic");
183 base.push("components");
184 fs::create_dir_all(&base)
185 .with_context(|| format!("failed to create cache directory {}", base.display()))?;
186 Ok(base)
187}
188
189fn cache_slug_parts(component_id: &str, version: &str) -> String {
190 slugify(&format!("{}-{}", component_id.replace('/', "-"), version))
191}
192
193fn update_manifest(
194 coordinate: &str,
195 component_id: &str,
196 version: &str,
197 wasm_path: &Path,
198) -> Result<()> {
199 let manifest_path = manifest_path()?;
200 let mut manifest: WorkspaceManifest = if manifest_path.exists() {
201 let data = fs::read_to_string(&manifest_path)
202 .with_context(|| format!("failed to read {}", manifest_path.display()))?;
203 serde_json::from_str(&data)
204 .with_context(|| format!("failed to parse {}", manifest_path.display()))?
205 } else {
206 WorkspaceManifest::default()
207 };
208
209 let entry = WorkspaceComponent {
210 coordinate: coordinate.to_string(),
211 entry: ComponentEntry {
212 name: component_id.to_string(),
213 version: Version::parse(version).unwrap_or_else(|_| Version::new(0, 0, 0)),
214 file_wasm: wasm_path.display().to_string(),
215 hash_blake3: String::new(),
216 schema_file: None,
217 manifest_file: None,
218 world: None,
219 capabilities: None,
220 },
221 };
222
223 let mut replaced = false;
224 for existing in manifest.components.iter_mut() {
225 if existing.entry.name == entry.entry.name {
226 *existing = entry.clone();
227 replaced = true;
228 break;
229 }
230 }
231 if !replaced {
232 manifest.components.push(entry);
233 }
234
235 let rendered =
236 serde_json::to_string_pretty(&manifest).context("failed to render workspace manifest")?;
237 fs::write(&manifest_path, rendered)
238 .with_context(|| format!("failed to write {}", manifest_path.display()))?;
239 Ok(())
240}
241
242fn detect_pack_id() -> Option<String> {
243 let candidates = ["pack.toml", "Pack.toml"];
244 for candidate in candidates {
245 let path = Path::new(candidate);
246 if path.exists() {
247 let data = fs::read_to_string(path).ok()?;
248 if let Ok(value) = data.parse::<toml::Value>()
249 && let Some(id) = value.get("pack_id").and_then(|v| v.as_str())
250 {
251 return Some(id.to_string());
252 }
253 }
254 }
255 None
256}
257
258fn load_stubbed_response() -> Option<Result<greentic_distributor_client::ResolveComponentResponse>>
259{
260 let path = std::env::var("GREENTIC_DEV_RESOLVE_STUB").ok()?;
261 let data = fs::read_to_string(&path)
262 .map_err(|err| anyhow!("failed to read stub response {}: {err}", path))
263 .ok()?;
264
265 if let Ok(resp) =
267 serde_json::from_str::<greentic_distributor_client::ResolveComponentResponse>(&data)
268 {
269 return Some(Ok(resp));
270 }
271
272 let parsed = serde_json::from_str::<serde_json::Value>(&data).ok()?;
274 let Some(artifact_path) = parsed
275 .get("artifact_path")
276 .and_then(|v| v.as_str())
277 .map(PathBuf::from)
278 else {
279 return Some(Err(anyhow!(
280 "stub missing `artifact_path` (expected JSON with artifact_path pointing to the component wasm)"
281 )));
282 };
283 let digest = parsed
284 .get("digest")
285 .and_then(|v| v.as_str())
286 .unwrap_or("sha256:stub")
287 .to_string();
288 let signature = greentic_types::distributor::SignatureSummary {
289 verified: false,
290 signer: "stub".to_string(),
291 extra: serde_json::Value::Object(serde_json::Map::new()),
292 };
293 let cache = greentic_types::distributor::CacheInfo {
294 size_bytes: 0,
295 last_used_utc: "stub".to_string(),
296 last_refreshed_utc: "stub".to_string(),
297 };
298 let stub = SimpleStub {
299 artifact_path,
300 digest,
301 signature,
302 cache,
303 };
304
305 Some(Ok(to_resolve_response(stub)))
306}
307
308fn to_resolve_response(stub: SimpleStub) -> greentic_distributor_client::ResolveComponentResponse {
309 greentic_distributor_client::ResolveComponentResponse {
310 status: greentic_types::distributor::ComponentStatus::Ready,
311 digest: greentic_types::distributor::ComponentDigest(stub.digest),
312 artifact: greentic_distributor_client::ArtifactLocation::FilePath {
313 path: stub.artifact_path.display().to_string(),
314 },
315 signature: stub.signature,
316 cache: stub.cache,
317 secret_requirements: None,
318 }
319}