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