Skip to main content

greentic_dev/
pack_init.rs

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
71/// Fetch a component via distributor and cache it locally.
72/// Returns the cache directory containing the component artifact/flows.
73pub 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}