1use serde_json::json;
2use std::path::{Path, PathBuf};
3use std::process::ExitCode;
4
5use crate::api::Output;
6use crate::auth;
7use crate::site::{self, SiteDir};
8use crate::HyphaError;
9use substrate::{
10 BondRelation, PrettyJson, Spore, SporeBond, SporeCapsule, SporeCore, SPORE_CORE_SCHEMA,
11 SPORE_SCHEMA,
12};
13
14use super::read_spawned_from_uri;
15use super::updated_at::compute_updated_at_ms;
16
17#[derive(Debug, Clone, Copy)]
19pub enum ArchiveFormat {
20 Zstd,
21}
22
23impl ArchiveFormat {
24 pub(crate) fn from_str(s: &str) -> Result<Self, crate::sink::HyphaError> {
25 match s.to_lowercase().as_str() {
26 "zstd" | "zst" => Ok(Self::Zstd),
27 _ => Err(crate::sink::HyphaError::new(
28 "invalid_args",
29 format!(
30 "Unsupported archive format for release generation: {}. Use: zstd",
31 s
32 ),
33 )),
34 }
35 }
36
37 pub(crate) fn extension(&self) -> &'static str {
38 match self {
39 Self::Zstd => "tar.zst",
40 }
41 }
42}
43
44pub struct ReleaseArgs<'a> {
45 pub domain: &'a str,
46 pub source: Option<String>,
47 pub site_path: Option<&'a str>,
48 pub dist_git: Option<String>,
49 pub dist_ref: Option<String>,
50 pub archive: &'a str,
51 pub dry_run: bool,
52}
53
54pub(crate) struct ReleasePlan {
62 pub site: SiteDir,
63 pub spawned_from_spore_path: PathBuf,
64 pub draft: SporeCore,
65 pub entries: Vec<substrate::TreeEntry>,
66 pub core: SporeCore,
67 pub core_signature: String,
68 pub uri_hash: String,
69 pub uri: String,
70}
71
72pub(crate) fn build_release_plan(
76 domain: &str,
77 site_path: Option<&str>,
78 working_dir: PathBuf,
79 now_epoch_ms: u64,
80) -> Result<ReleasePlan, HyphaError> {
81 if site_path.is_none() {
82 site::validate_site_domain_path(domain)?;
83 }
84
85 let site = SiteDir::from_args(domain, site_path);
86 if !site.exists() {
87 return Err(HyphaError::with_hint(
88 "NO_SITE",
89 format!("Site not found at {}", site.root.display()),
90 format!("run: hypha mycelium root --domain {}", domain),
91 ));
92 }
93
94 let spore_core_path = working_dir.join("spore.core.json");
95
96 if !spore_core_path.exists() {
97 return Err(HyphaError::with_hint(
98 "NO_SPORE",
99 format!("No spore.core.json found at {}", working_dir.display()),
100 "run: hypha hatch",
101 ));
102 }
103
104 let draft_content = std::fs::read_to_string(&spore_core_path).map_err(|e| {
105 HyphaError::new(
106 "read_error",
107 format!("Failed to read spore.core.json: {}", e),
108 )
109 })?;
110
111 let draft_value: serde_json::Value = serde_json::from_str(&draft_content)
112 .map_err(|e| HyphaError::new("parse_error", format!("Invalid spore.core.json: {}", e)))?;
113 let schema_type = substrate::validate_schema(&draft_value).map_err(|e| {
114 HyphaError::new(
115 "schema_error",
116 format!("spore.core.json schema validation failed: {}", e),
117 )
118 })?;
119 if schema_type != substrate::SchemaType::SporeCore {
120 return Err(HyphaError::new(
121 "schema_error",
122 format!("spore.core.json must use {}", SPORE_CORE_SCHEMA),
123 ));
124 }
125 if draft_value.get("updated_at_epoch_ms").is_some() {
127 return Err(HyphaError::with_hint(
128 "INVALID_FIELD",
129 "spore.core.json must not contain updated_at_epoch_ms (computed at release time)",
130 "run: hypha hatch (hatch removes this field automatically)",
131 ));
132 }
133
134 let draft: SporeCore = serde_json::from_value(draft_value)
135 .map_err(|e| HyphaError::new("parse_error", format!("Invalid spore.core.json: {}", e)))?;
136
137 if draft.domain.is_empty() {
139 return Err(HyphaError::with_hint(
140 "DOMAIN_EMPTY",
141 "spore.core.json domain is empty",
142 format!("run: hypha hatch --domain {}", domain),
143 ));
144 }
145 if draft.domain != domain {
146 return Err(HyphaError::with_hint(
147 "DOMAIN_MISMATCH",
148 format!(
149 "spore.core.json domain '{}' does not match --domain '{}'",
150 draft.domain, domain
151 ),
152 format!("run: hypha hatch --domain {}", domain),
153 ));
154 }
155
156 let public_key = auth::get_identity_with_site(domain, &site)
158 .map_err(|e| HyphaError::new("identity_error", e.to_string()))?
159 .public_key;
160
161 if draft.key.is_empty() {
163 return Err(HyphaError::with_hint(
164 "KEY_EMPTY",
165 "spore.core.json key is empty",
166 format!("run: hypha hatch --domain {}", domain),
167 ));
168 }
169 if draft.key != public_key {
170 return Err(HyphaError::with_hint(
171 "KEY_MISMATCH",
172 format!(
173 "Key in spore.core.json does not match domain '{}' (key may have rotated)",
174 domain
175 ),
176 format!("run: hypha hatch --domain {}", domain),
177 ));
178 }
179
180 let mut release_bonds: Vec<SporeBond> = draft.bonds.clone();
183 let spawned_from_spore_path = working_dir
184 .join(".cmn")
185 .join("spawned-from")
186 .join("spore.json");
187 if let Some(parent_uri) = read_spawned_from_uri(&spawned_from_spore_path) {
188 release_bonds.push(SporeBond {
189 uri: parent_uri,
190 relation: BondRelation::SpawnedFrom,
191 id: None,
192 reason: None,
193 with: None,
194 });
195 }
196
197 crate::tree::check_no_symlinks(
199 &working_dir,
200 &draft.tree.exclude_names,
201 &draft.tree.follow_rules,
202 )
203 .map_err(|e| HyphaError::new("SYMLINK_ERR", format!("{}", e)))?;
204 let entries = crate::tree::walk_dir(
205 &working_dir,
206 &draft.tree.exclude_names,
207 &draft.tree.follow_rules,
208 )
209 .map_err(|e| HyphaError::new("HASH_ERR", format!("Failed to walk directory: {}", e)))?;
210 let (tree_hash, size_bytes) = draft
211 .tree
212 .compute_hash_and_size(&entries)
213 .map_err(|e| HyphaError::new("HASH_ERR", format!("Failed to compute tree hash: {}", e)))?;
214
215 let core = SporeCore {
216 id: draft.id.clone(),
217 version: draft.version.clone(),
218 name: draft.name.clone(),
219 domain: domain.to_string(),
220 key: public_key,
221 synopsis: draft.synopsis.clone(),
222 intent: draft.intent.clone(),
223 license: draft.license.clone(),
224 mutations: draft.mutations.clone(),
225 size_bytes,
226 bonds: release_bonds,
227 tree: draft.tree.clone(),
228 updated_at_epoch_ms: match compute_updated_at_ms(
229 &working_dir,
230 &draft.tree.exclude_names,
231 &draft.tree.follow_rules,
232 ) {
233 Ok(ms) if ms > 0 => ms,
234 _ => now_epoch_ms,
235 },
236 };
237
238 let core_signature = auth::sign_json_with_site(&site, &core).map_err(|e| match e {
240 auth::JsonSignError::Jcs(message) => HyphaError::new("jcs_error", message),
241 auth::JsonSignError::Sign(err) => HyphaError::new("sign_error", err.to_string()),
242 })?;
243
244 let uri_hash = (substrate::Spore {
246 schema: substrate::SPORE_SCHEMA.to_string(),
247 capsule: substrate::SporeCapsule {
248 uri: String::new(),
249 core: core.clone(),
250 core_signature: core_signature.clone(),
251 dist: vec![],
252 },
253 capsule_signature: String::new(),
254 })
255 .computed_uri_hash_from_tree_hash(&tree_hash)
256 .map_err(|e| HyphaError::new("jcs_error", e.to_string()))?;
257
258 let uri = format!("cmn://{}/{}", domain, uri_hash);
260
261 Ok(ReleasePlan {
262 site,
263 spawned_from_spore_path,
264 draft,
265 entries,
266 core,
267 core_signature,
268 uri_hash,
269 uri,
270 })
271}
272
273pub fn handle_release(out: &Output, args: ReleaseArgs<'_>) -> ExitCode {
274 let ReleaseArgs {
275 domain,
276 source,
277 site_path,
278 dist_git,
279 dist_ref,
280 archive,
281 dry_run,
282 } = args;
283 let now_epoch_ms = crate::time::now_epoch_ms();
284
285 let archive_format = match ArchiveFormat::from_str(archive) {
287 Ok(f) => f,
288 Err(e) => return out.error_hypha(&e),
289 };
290
291 if dist_git.is_some() && dist_ref.is_none() {
293 return out.error("invalid_args", "--dist-git requires --dist-ref");
294 }
295
296 if dist_git.is_none() && dist_ref.is_some() {
297 return out.error("invalid_args", "--dist-ref requires --dist-git");
298 }
299
300 let working_dir = match source
301 .map(std::path::PathBuf::from)
302 .map(Ok)
303 .unwrap_or_else(std::env::current_dir)
304 {
305 Ok(d) => d,
306 Err(e) => {
307 return out.error(
308 "dir_error",
309 &format!("Failed to get working directory: {}", e),
310 );
311 }
312 };
313
314 let plan = match build_release_plan(domain, site_path, working_dir, now_epoch_ms) {
315 Ok(p) => p,
316 Err(e) => return out.error_hypha(&e),
317 };
318 let ReleasePlan {
319 site,
320 spawned_from_spore_path,
321 draft,
322 entries,
323 core,
324 core_signature,
325 uri_hash,
326 uri,
327 } = plan;
328 let filename = uri_hash.clone();
329
330 if dry_run {
332 return out.ok_trace(
333 json!({
334 "cmn_url": uri,
335 "hash": uri_hash,
336 }),
337 json!({
338 "status": "dry_run",
339 "site": site.public.display().to_string(),
340 }),
341 );
342 }
343
344 let mut dist: Vec<substrate::SporeDist> = vec![];
346
347 if let (Some(git_url), Some(git_ref)) = (&dist_git, &dist_ref) {
349 dist.push(substrate::SporeDist {
350 kind: substrate::DistKind::Git,
351 filename: None,
352 url: Some(git_url.clone()),
353 git_ref: Some(git_ref.clone()),
354 cid: None,
355 extra: Default::default(),
356 });
357 }
358
359 {
361 let mut files = substrate::flatten_entries(&entries);
362
363 let archive_filename = format!("{}.{}", filename, archive_format.extension());
364 let archive_dir = site.archive_dir();
365 if let Err(e) = std::fs::create_dir_all(&archive_dir) {
366 return out.error("dir_error", &format!("Failed to create archive dir: {}", e));
367 }
368 let archive_path = archive_dir.join(&archive_filename);
369
370 if let Err(e) = create_archive_from_files(&mut files, &archive_path, archive_format) {
372 return out.error("archive_error", &format!("Failed to create archive: {}", e));
373 }
374
375 if let Some(old_hash) = find_previous_hash(&site, domain, &draft.id) {
377 if old_hash != uri_hash {
378 let old_archive_path = archive_dir.join(format!("{}.tar.zst", old_hash));
379 if old_archive_path.exists() {
380 match generate_delta_archive(
381 &mut files,
382 &old_archive_path,
383 &archive_dir,
384 &uri_hash,
385 &old_hash,
386 ) {
387 Ok(_delta_filename) => {}
388 Err(e) => {
389 out.warn(
391 "DELTA_WARN",
392 &format!("Failed to generate delta archive: {}", e),
393 );
394 }
395 }
396 }
397 }
398 }
399
400 dist.push(substrate::SporeDist {
401 kind: substrate::DistKind::Archive,
402 filename: None,
403 url: None,
404 git_ref: None,
405 cid: None,
406 extra: Default::default(),
407 });
408 }
409
410 let capsule = SporeCapsule {
412 uri: uri.clone(),
413 core,
414 core_signature,
415 dist,
416 };
417
418 let capsule_signature = match auth::sign_json_with_site(&site, &capsule) {
420 Ok(sig) => sig,
421 Err(auth::JsonSignError::Jcs(message)) => return out.error("jcs_error", &message),
422 Err(auth::JsonSignError::Sign(err)) => return out.error_from("sign_error", &err),
423 };
424
425 let spore_manifest_path = site.spores_dir().join(format!("{}.json", filename));
427
428 if spore_manifest_path.exists() {
429 let existing_json = match std::fs::read_to_string(&spore_manifest_path) {
431 Ok(j) => j,
432 Err(e) => {
433 return out.error(
434 "read_error",
435 &format!("Spore manifest exists but cannot be read: {}", e),
436 );
437 }
438 };
439 let existing: serde_json::Value = match serde_json::from_str(&existing_json) {
440 Ok(v) => v,
441 Err(e) => {
442 return out.error(
443 "parse_error",
444 &format!("Spore manifest exists but is invalid JSON: {}", e),
445 );
446 }
447 };
448
449 if let Some(parent) = spawned_from_spore_path.parent() {
451 let _ = std::fs::create_dir_all(parent);
452 }
453 let _ = std::fs::write(&spawned_from_spore_path, &existing_json);
454
455 let data = json!({
456 "cmn_url": uri,
457 "hash": uri_hash,
458 "spore": existing,
459 });
460 let hypha = json!({
461 "status": "skipped",
462 "site": site.public.display().to_string(),
463 });
464
465 return out.ok_trace(&data, hypha);
466 }
467
468 let spore_manifest = Spore {
470 schema: SPORE_SCHEMA.to_string(),
471 capsule,
472 capsule_signature,
473 };
474
475 let spore_value = match serde_json::to_value(&spore_manifest) {
477 Ok(v) => v,
478 Err(e) => return out.error("serialize_error", &e.to_string()),
479 };
480 if let Err(e) = substrate::validate_schema(&spore_value) {
481 return out.error(
482 "schema_error",
483 &format!("Spore schema validation failed: {}", e),
484 );
485 }
486
487 let spore_json = match spore_manifest.to_pretty_json() {
488 Ok(j) => j,
489 Err(e) => {
490 return out.error(
491 "serialize_error",
492 &format!("Failed to format spore manifest: {}", e),
493 );
494 }
495 };
496
497 if let Err(e) = std::fs::write(&spore_manifest_path, &spore_json) {
498 return out.error(
499 "write_error",
500 &format!("Failed to write spore manifest: {}", e),
501 );
502 }
503
504 if let Err(e) = crate::mycelium::update_inventory(
506 &site,
507 domain,
508 &draft.id,
509 &uri_hash,
510 &draft.name,
511 Some(&draft.synopsis),
512 now_epoch_ms,
513 ) {
514 return out.error(
515 "INVENTORY_ERR",
516 &format!("Failed to update cmn.json: {}", e),
517 );
518 }
519
520 {
524 if let Some(parent) = spawned_from_spore_path.parent() {
525 let _ = std::fs::create_dir_all(parent);
526 }
527 let _ = std::fs::write(&spawned_from_spore_path, &spore_json);
528 }
529
530 let data = json!({
531 "cmn_url": uri,
532 "hash": uri_hash,
533 "spore": spore_manifest,
534 });
535 let hypha = json!({
536 "status": "released",
537 "site": site.public.display().to_string(),
538 });
539
540 out.ok_trace(&data, hypha)
541}
542
543fn create_archive_from_files(
545 files: &mut [(String, Vec<u8>, bool)],
546 output_path: &Path,
547 _format: ArchiveFormat,
548) -> anyhow::Result<()> {
549 create_tar_archive_from_files(files, output_path)
550}
551
552pub(crate) fn build_raw_tar_bytes(
554 files: &mut [(String, Vec<u8>, bool)],
555) -> anyhow::Result<Vec<u8>> {
556 files.sort_by(|a, b| a.0.cmp(&b.0));
558
559 let mut buf = Vec::new();
560 {
561 let mut tar = tar::Builder::new(&mut buf);
562 for (rel_path, content, is_executable) in files.iter() {
563 let mut header = tar::Header::new_gnu();
564 header.set_size(content.len() as u64);
565 header.set_mode(if *is_executable { 0o755 } else { 0o644 });
566 header.set_mtime(0);
567 header.set_uid(0);
568 header.set_gid(0);
569 let _ = header.set_username("");
570 let _ = header.set_groupname("");
571 header.set_cksum();
572 tar.append_data(&mut header, rel_path.as_str(), content.as_slice())?;
573 }
574 tar.finish()?;
575 }
576 Ok(buf)
577}
578
579fn create_tar_archive_from_files(
581 files: &mut [(String, Vec<u8>, bool)],
582 output_path: &Path,
583) -> anyhow::Result<()> {
584 let raw_tar = build_raw_tar_bytes(files)?;
585 let compressed =
586 substrate::archive::encode_zstd(&raw_tar, 19).map_err(|e| anyhow::anyhow!("{}", e))?;
587 std::fs::write(output_path, &compressed)?;
588 Ok(())
589}
590
591fn find_previous_hash(site: &SiteDir, domain: &str, spore_id: &str) -> Option<String> {
593 crate::mycelium::find_local_spore_hash(site, domain, spore_id)
594}
595
596fn generate_delta_archive(
599 files: &mut [(String, Vec<u8>, bool)],
600 old_archive_path: &Path,
601 archive_dir: &Path,
602 new_hash: &str,
603 old_hash: &str,
604) -> anyhow::Result<String> {
605 let new_raw_tar = build_raw_tar_bytes(files)?;
607
608 let old_compressed = std::fs::read(old_archive_path)?;
610 let old_raw_tar = substrate::archive::decode_zstd(&old_compressed, 512 * 1024 * 1024)
611 .map_err(|e| anyhow::anyhow!("{}", e))?;
612
613 let delta_filename = format!("{}.from.{}.tar.zst", new_hash, old_hash);
615 let delta_path = archive_dir.join(&delta_filename);
616
617 let compressed = substrate::archive::encode_zstd_with_dict(&new_raw_tar, &old_raw_tar, 19)
618 .map_err(|e| anyhow::anyhow!("{}", e))?;
619 std::fs::write(&delta_path, &compressed)?;
620
621 Ok(delta_filename)
622}