1use std::fs;
2use std::io::{Cursor, Read, Write};
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, anyhow, bail};
6use bytes::Bytes;
7use greentic_pack::builder::ComponentEntry;
8use semver::Version;
9use serde::{Deserialize, Serialize};
10use zip::ZipArchive;
11
12use crate::config;
13use crate::distributor::{
14 DevArtifactKind, DevDistributorClient, DevDistributorError, DevIntent, DevResolveRequest,
15 DevResolveResponse, resolve_profile,
16};
17
18#[derive(Debug, Clone, Copy)]
19pub enum PackInitIntent {
20 Dev,
21 Runtime,
22}
23
24pub fn run(from: &str, profile: Option<&str>) -> Result<()> {
25 let config = config::load_with_meta(None)?;
26 let profile = resolve_profile(&config, profile)?;
27 let client = DevDistributorClient::from_profile(profile.clone())?;
28
29 let resolve = client.resolve(&DevResolveRequest {
30 coordinate: from.to_string(),
31 intent: DevIntent::Dev,
32 platform: Some(default_platform()),
33 features: Vec::new(),
34 });
35
36 let resolved = handle_resolve_result(resolve)?;
37 if resolved.kind != DevArtifactKind::Pack {
38 bail!(
39 "coordinate `{}` resolved to {:?}, expected pack",
40 resolved.coordinate,
41 resolved.kind
42 );
43 }
44
45 let bytes = client.download_artifact(&resolved.artifact_download_path)?;
46 let cache_path = write_pack_to_cache(&resolved, &bytes)?;
47 let workspace_dir = slug_to_dir(&resolved.name)?;
48 fs::create_dir(&workspace_dir).with_context(|| {
49 format!(
50 "failed to create workspace directory {}",
51 workspace_dir.display()
52 )
53 })?;
54
55 let bundle_path = workspace_dir.join("bundle.gtpack");
56 fs::write(&bundle_path, &bytes)
57 .with_context(|| format!("failed to write {}", bundle_path.display()))?;
58 unpack_gtpack(&workspace_dir, bytes.clone())?;
59
60 println!(
61 "Initialized pack {}@{} in {} (cached at {})",
62 resolved.name,
63 resolved.version,
64 workspace_dir.display(),
65 cache_path.display()
66 );
67
68 Ok(())
69}
70
71pub fn run_component_add(
74 coordinate: &str,
75 profile: Option<&str>,
76 intent: PackInitIntent,
77) -> Result<PathBuf> {
78 let config = config::load_with_meta(None)?;
79 let profile = resolve_profile(&config, profile)?;
80 let client = DevDistributorClient::from_profile(profile.clone())?;
81
82 let resolve = client.resolve(&DevResolveRequest {
83 coordinate: coordinate.to_string(),
84 intent: match intent {
85 PackInitIntent::Dev => DevIntent::Dev,
86 PackInitIntent::Runtime => DevIntent::Runtime,
87 },
88 platform: Some(default_platform()),
89 features: Vec::new(),
90 });
91 let resolved = handle_resolve_result(resolve)?;
92 if resolved.kind != DevArtifactKind::Component {
93 bail!(
94 "coordinate `{}` resolved to {:?}, expected component",
95 resolved.coordinate,
96 resolved.kind
97 );
98 }
99
100 let bytes = client.download_artifact(&resolved.artifact_download_path)?;
101 let (cache_dir, cache_path) = write_component_to_cache(&resolved, &bytes)?;
102 update_workspace_manifest(&resolved, &cache_path)?;
103
104 println!(
105 "Resolved {} -> {}@{}",
106 resolved.coordinate, resolved.name, resolved.version
107 );
108 println!("Cached component at {}", cache_path.display());
109 println!(
110 "Updated workspace manifest at {}",
111 manifest_path()?.display()
112 );
113 Ok(cache_dir)
114}
115
116fn default_platform() -> String {
117 "wasm32-wasip2".to_string()
118}
119
120fn handle_resolve_result(
121 result: Result<DevResolveResponse, DevDistributorError>,
122) -> Result<DevResolveResponse> {
123 match result {
124 Ok(resp) => Ok(resp),
125 Err(DevDistributorError::LicenseRequired(body)) => bail!(
126 "license required for {}: {}\nCheckout URL: {}",
127 body.coordinate,
128 body.message,
129 body.checkout_url
130 ),
131 Err(other) => Err(anyhow!(other)),
132 }
133}
134
135fn cache_base_dir() -> Result<PathBuf> {
136 let mut base = dirs::home_dir().ok_or_else(|| anyhow!("unable to determine home directory"))?;
137 base.push(".greentic");
138 base.push("cache");
139 fs::create_dir_all(&base)
140 .with_context(|| format!("failed to create cache directory {}", base.display()))?;
141 Ok(base)
142}
143
144fn write_component_to_cache(
145 resolved: &DevResolveResponse,
146 bytes: &Bytes,
147) -> Result<(PathBuf, PathBuf)> {
148 let mut path = cache_base_dir()?;
149 path.push("components");
150 let slug = cache_slug(resolved);
151 path.push(slug);
152 fs::create_dir_all(&path).with_context(|| format!("failed to create {}", path.display()))?;
153 let file_path = path.join("artifact.wasm");
154 fs::write(&file_path, bytes)
155 .with_context(|| format!("failed to write {}", file_path.display()))?;
156 Ok((path, file_path))
157}
158
159fn write_pack_to_cache(resolved: &DevResolveResponse, bytes: &Bytes) -> Result<PathBuf> {
160 let mut path = cache_base_dir()?;
161 path.push("packs");
162 let slug = cache_slug(resolved);
163 path.push(slug);
164 fs::create_dir_all(&path).with_context(|| format!("failed to create {}", path.display()))?;
165 let file_path = path.join("bundle.gtpack");
166 fs::write(&file_path, bytes)
167 .with_context(|| format!("failed to write {}", file_path.display()))?;
168 Ok(file_path)
169}
170
171pub fn cache_slug(resolved: &DevResolveResponse) -> String {
172 if let Some(digest) = &resolved.digest {
173 return digest.replace(':', "-");
174 }
175 slugify(&format!("{}-{}", resolved.name, resolved.version))
176}
177
178pub fn slugify(raw: &str) -> String {
179 let mut out = String::new();
180 let mut prev_dash = false;
181 for ch in raw.chars() {
182 let c = ch.to_ascii_lowercase();
183 if c.is_ascii_alphanumeric() {
184 out.push(c);
185 prev_dash = false;
186 } else if !prev_dash {
187 out.push('-');
188 prev_dash = true;
189 }
190 }
191 out.trim_matches('-').to_string()
192}
193
194pub fn manifest_path() -> Result<PathBuf> {
195 let mut root = std::env::current_dir().context("unable to determine current directory")?;
196 root.push(".greentic");
197 fs::create_dir_all(&root).with_context(|| format!("failed to create {}", root.display()))?;
198 Ok(root.join("manifest.json"))
199}
200
201#[derive(Debug, Default, Serialize, Deserialize)]
202pub struct WorkspaceManifest {
203 pub components: Vec<WorkspaceComponent>,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct WorkspaceComponent {
208 pub coordinate: String,
209 pub entry: ComponentEntry,
210}
211
212pub fn update_workspace_manifest(resolved: &DevResolveResponse, cache_path: &Path) -> Result<()> {
213 let manifest_path = manifest_path()?;
214 let mut manifest: WorkspaceManifest = if manifest_path.exists() {
215 let data = fs::read_to_string(&manifest_path)
216 .with_context(|| format!("failed to read {}", manifest_path.display()))?;
217 serde_json::from_str(&data)
218 .with_context(|| format!("failed to parse {}", manifest_path.display()))?
219 } else {
220 WorkspaceManifest::default()
221 };
222
223 let version = Version::parse(&resolved.version)
224 .with_context(|| format!("invalid semver version `{}`", resolved.version))?;
225 let entry = ComponentEntry {
226 name: resolved.name.clone(),
227 version,
228 file_wasm: cache_path.display().to_string(),
229 hash_blake3: resolved.digest.clone().unwrap_or_default(),
230 schema_file: None,
231 manifest_file: None,
232 world: None,
233 capabilities: None,
234 };
235
236 let mut replaced = false;
237 for existing in manifest.components.iter_mut() {
238 if existing.entry.name == entry.name {
239 existing.coordinate = resolved.coordinate.clone();
240 existing.entry = entry.clone();
241 replaced = true;
242 break;
243 }
244 }
245 if !replaced {
246 manifest.components.push(WorkspaceComponent {
247 coordinate: resolved.coordinate.clone(),
248 entry,
249 });
250 }
251
252 let rendered =
253 serde_json::to_string_pretty(&manifest).context("failed to render workspace manifest")?;
254 fs::write(&manifest_path, rendered)
255 .with_context(|| format!("failed to write {}", manifest_path.display()))?;
256 Ok(())
257}
258
259fn slug_to_dir(name: &str) -> Result<PathBuf> {
260 let slug = slugify(name);
261 let root = std::env::current_dir().context("unable to determine current directory")?;
262 let dest = root.join(slug);
263 if dest.exists() {
264 bail!(
265 "destination {} already exists; choose a different directory or remove it first",
266 dest.display()
267 );
268 }
269 Ok(dest)
270}
271
272fn unpack_gtpack(dest: &Path, bytes: Bytes) -> Result<()> {
273 let cursor = Cursor::new(bytes);
274 let mut archive = ZipArchive::new(cursor).context("failed to open gtpack archive")?;
275 for i in 0..archive.len() {
276 let mut file = archive.by_index(i).context("failed to read gtpack entry")?;
277 let name = match file.enclosed_name() {
278 Some(path) => path.to_owned(),
279 None => bail!("gtpack contained a suspicious path; aborting extract"),
280 };
281 let out_path = dest.join(name);
282 if file.name().ends_with('/') {
283 fs::create_dir_all(&out_path)
284 .with_context(|| format!("failed to create {}", out_path.display()))?;
285 } else {
286 if let Some(parent) = out_path.parent() {
287 fs::create_dir_all(parent)
288 .with_context(|| format!("failed to create {}", parent.display()))?;
289 }
290 let mut buffer = Vec::new();
291 file.read_to_end(&mut buffer)
292 .context("failed to read gtpack entry")?;
293 let mut out = fs::File::create(&out_path)
294 .with_context(|| format!("failed to create {}", out_path.display()))?;
295 out.write_all(&buffer)
296 .with_context(|| format!("failed to write {}", out_path.display()))?;
297 }
298 }
299 Ok(())
300}