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