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()?;
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}