1use anyhow::{Context, Result};
2use chrono::Utc;
3use mvm_core::template::{TemplateConfig, TemplateSpec, template_dir, templates_base_dir};
4use mvm_runtime::vm::template::lifecycle as tmpl;
5use std::fs;
6use std::fs::read_dir;
7use std::path::Path;
8use std::time::Duration;
9
10fn now_iso() -> String {
11 Utc::now().to_rfc3339()
12}
13
14pub fn create_single(
15 name: &str,
16 flake: &str,
17 profile: &str,
18 role: &str,
19 cpus: u8,
20 mem: u32,
21 data_disk: u32,
22) -> Result<()> {
23 let flake_ref = resolve_flake_ref(flake);
24 let ts = now_iso();
25 let spec = TemplateSpec {
26 schema_version: mvm_core::template::CURRENT_SCHEMA_VERSION,
27 template_id: name.to_string(),
28 flake_ref,
29 profile: profile.to_string(),
30 role: role.to_string(),
31 vcpus: cpus,
32 mem_mib: mem,
33 data_disk_mib: data_disk,
34 created_at: ts.clone(),
35 updated_at: ts,
36 };
37 tmpl::template_create(&spec)
38}
39
40fn resolve_flake_ref(flake: &str) -> String {
46 if flake.contains(':') {
48 return flake.to_string();
49 }
50 match std::path::Path::new(flake).canonicalize() {
52 Ok(abs) => abs.to_string_lossy().to_string(),
53 Err(_) => flake.to_string(),
54 }
55}
56
57pub fn init(
59 name: &str,
60 local: bool,
61 base_dir: &str,
62 preset: Option<&str>,
63 prompt: Option<&str>,
64) -> Result<()> {
65 if local {
66 let selected_preset = resolve_scaffold_preset(preset, prompt);
67 let dir = std::path::Path::new(base_dir).join(name);
68 scaffold_template_files(&dir, name, &selected_preset, prompt)?;
69 return Ok(());
70 }
71 if prompt.is_some() {
72 anyhow::bail!("--prompt currently requires --local");
73 }
74 tmpl::template_init(name)
75}
76
77pub fn create_multi(
78 base: &str,
79 flake: &str,
80 profile: &str,
81 roles: &[String],
82 cpus: u8,
83 mem: u32,
84 data_disk: u32,
85) -> Result<()> {
86 let flake_ref = resolve_flake_ref(flake);
88 for role in roles {
89 let name = format!("{base}-{role}");
90 create_single(&name, &flake_ref, profile, role, cpus, mem, data_disk)?;
91 }
92 Ok(())
93}
94
95pub fn list(json: bool) -> Result<()> {
96 let vm_items = tmpl::template_list()?;
97 let local_items = local_templates(Path::new("."))?;
98
99 let base = templates_base_dir();
100
101 if json {
102 #[derive(serde::Serialize)]
103 struct Out {
104 vm_base: String,
105 vm: Vec<String>,
106 local_base: String,
107 local: Vec<String>,
108 }
109 let out = Out {
110 vm_base: base,
111 vm: vm_items,
112 local_base: std::env::current_dir()
113 .unwrap_or_else(|_| Path::new(".").to_path_buf())
114 .display()
115 .to_string(),
116 local: local_items,
117 };
118 println!("{}", serde_json::to_string_pretty(&out)?);
119 return Ok(());
120 }
121
122 println!("Templates ({base}):");
123 if vm_items.is_empty() {
124 println!(" (none)");
125 } else {
126 for t in &vm_items {
127 println!(" {}", t);
128 }
129 }
130
131 println!("\nLocal templates (base: ./):");
132 if local_items.is_empty() {
133 println!(" (none)");
134 } else {
135 for t in &local_items {
136 println!(" {}", t);
137 }
138 }
139
140 Ok(())
141}
142
143pub fn info(name: &str, json: bool) -> Result<()> {
144 let spec = tmpl::template_load(name)?;
145 let revision = tmpl::template_load_current_revision(name)?;
146
147 if json {
148 #[derive(serde::Serialize)]
149 struct InfoOut {
150 spec: TemplateSpec,
151 revision: Option<mvm_core::template::TemplateRevision>,
152 path: String,
153 }
154 let out = InfoOut {
155 spec,
156 revision,
157 path: template_dir(name),
158 };
159 println!("{}", serde_json::to_string_pretty(&out)?);
160 } else {
161 println!("Template: {}", spec.template_id);
162 println!(" Flake: {}", spec.flake_ref);
163 println!(" Profile: {}", spec.profile);
164 println!(" Role: {}", spec.role);
165 println!(" vCPUs: {}", spec.vcpus);
166 println!(" MemMiB: {}", spec.mem_mib);
167 println!(" DataMiB: {}", spec.data_disk_mib);
168 println!(" Created: {}", spec.created_at);
169 println!(" Updated: {}", spec.updated_at);
170 println!(" Path: {}", template_dir(name));
171
172 if let Some(rev) = &revision {
173 use mvm_core::pool::format_bytes;
174 println!();
175 println!("Current revision:");
176 println!(
177 " Hash: {}",
178 &rev.revision_hash[..rev.revision_hash.len().min(12)]
179 );
180 println!(" Built: {}", rev.built_at);
181 if let Some(sizes) = &rev.artifact_paths.sizes {
182 println!(" Kernel: {}", format_bytes(sizes.vmlinux_bytes));
183 println!(" Rootfs: {}", format_bytes(sizes.rootfs_bytes));
184 if let Some(initrd) = sizes.initrd_bytes {
185 println!(" Initrd: {}", format_bytes(initrd));
186 }
187 println!(" Total: {}", format_bytes(sizes.total_bytes()));
188 if let Some(closure) = sizes.nix_closure_bytes {
189 println!(" Closure: {}", format_bytes(closure));
190 }
191 }
192
193 match &rev.snapshot {
194 Some(snap) => {
195 println!();
196 println!("Snapshot:");
197 println!(" Created: {}", snap.created_at);
198 println!(" VM state: {}", format_bytes(snap.vmstate_size_bytes));
199 println!(" Memory: {}", format_bytes(snap.mem_size_bytes));
200 println!(
201 " Total: {}",
202 format_bytes(snap.vmstate_size_bytes + snap.mem_size_bytes)
203 );
204 }
205 None => {
206 println!();
207 println!("Snapshot: (none)");
208 }
209 }
210 } else {
211 println!();
212 println!("Revision: (not yet built)");
213 }
214 }
215 Ok(())
216}
217
218pub fn delete(name: &str, force: bool) -> Result<()> {
219 tmpl::template_delete(name, force)
220}
221
222pub fn build(
223 name: &str,
224 force: bool,
225 snapshot: bool,
226 config: Option<&str>,
227 update_hash: bool,
228) -> Result<()> {
229 if let Some(cfg_path) = config {
230 let cfg = load_config(cfg_path)?;
231 for variant in &cfg.variants {
232 let base = if !cfg.template_id.is_empty() {
233 cfg.template_id.clone()
234 } else {
235 name.to_string()
236 };
237 let template_name = if !variant.name.is_empty() {
238 variant.name.clone()
239 } else {
240 format!("{base}-{}", variant.role)
241 };
242
243 let ts = now_iso();
244 let spec = TemplateSpec {
245 schema_version: mvm_core::template::CURRENT_SCHEMA_VERSION,
246 template_id: template_name.clone(),
247 flake_ref: resolve_flake_ref(&cfg.flake_ref),
248 profile: if variant.profile.is_empty() {
249 cfg.profile.clone()
250 } else {
251 variant.profile.clone()
252 },
253 role: variant.role.clone(),
254 vcpus: variant.vcpus,
255 mem_mib: variant.mem_mib,
256 data_disk_mib: variant.data_disk_mib,
257 created_at: ts.clone(),
258 updated_at: ts,
259 };
260 tmpl::template_create(&spec)?;
261 if snapshot {
262 tmpl::template_build_with_snapshot(&template_name, force, update_hash)?;
263 } else {
264 tmpl::template_build(&template_name, force, update_hash)?;
265 }
266 }
267 Ok(())
268 } else if snapshot {
269 let backend = mvm_runtime::vm::backend::AnyBackend::auto_select();
273 if backend.capabilities().snapshots {
274 tmpl::template_build_with_snapshot(name, force, update_hash)
275 } else {
276 crate::ui::warn(&format!(
277 "Backend '{}' does not support snapshots. Building image-only template.",
278 backend.name()
279 ));
280 tmpl::template_build(name, force, update_hash)
281 }
282 } else {
283 tmpl::template_build(name, force, update_hash)
284 }
285}
286
287pub fn push(name: &str, revision: Option<&str>) -> Result<()> {
288 tmpl::template_push(name, revision)
289}
290
291pub fn pull(name: &str, revision: Option<&str>) -> Result<()> {
292 tmpl::template_pull(name, revision)
293}
294
295pub fn verify(name: &str, revision: Option<&str>) -> Result<()> {
296 tmpl::template_verify(name, revision)
297}
298
299pub fn edit(
300 name: &str,
301 flake: Option<&str>,
302 profile: Option<&str>,
303 role: Option<&str>,
304 cpus: Option<u8>,
305 mem: Option<u32>,
306 data_disk: Option<u32>,
307) -> Result<()> {
308 let mut spec = tmpl::template_load(name)?;
310
311 if let Some(f) = flake {
313 spec.flake_ref = resolve_flake_ref(f);
314 }
315 if let Some(p) = profile {
316 spec.profile = p.to_string();
317 }
318 if let Some(r) = role {
319 spec.role = r.to_string();
320 }
321 if let Some(c) = cpus {
322 spec.vcpus = c;
323 }
324 if let Some(m) = mem {
325 spec.mem_mib = m;
326 }
327 if let Some(d) = data_disk {
328 spec.data_disk_mib = d;
329 }
330
331 spec.updated_at = now_iso();
333
334 tmpl::template_create(&spec)?;
336
337 println!("Updated template '{}'", name);
338 println!(" vCPUs: {}", spec.vcpus);
339 println!(" MemMiB: {}", spec.mem_mib);
340 println!(" DataMiB: {}", spec.data_disk_mib);
341 println!(
342 "\nRun 'mvmctl template build {} --force' to rebuild with new settings",
343 name
344 );
345
346 Ok(())
347}
348
349fn load_config(path: &str) -> Result<TemplateConfig> {
350 let data = fs::read_to_string(Path::new(path))
351 .map_err(|e| anyhow::anyhow!("Failed to read template config {}: {}", path, e))?;
352 let cfg: TemplateConfig = toml::from_str(&data)
353 .map_err(|e| anyhow::anyhow!("Failed to parse template config {}: {}", path, e))?;
354 Ok(cfg)
355}
356
357fn local_templates(base: &Path) -> Result<Vec<String>> {
358 let mut names = Vec::new();
359 if let Ok(entries) = read_dir(base) {
360 for entry in entries.flatten() {
361 let path = entry.path();
362 if path.is_dir() {
363 let artifacts = path.join("artifacts").join("revisions");
364 if artifacts.exists()
365 && let Some(name) = path.file_name().and_then(|s| s.to_str())
366 {
367 names.push(name.to_string());
368 }
369 }
370 }
371 }
372 names.sort();
373 Ok(names)
374}
375
376fn flake_content_for_preset(preset: &str) -> Result<&'static str> {
377 match preset {
378 "minimal" => Ok(include_str!("../resources/template_scaffold/flake.nix")),
379 "http" => Ok(include_str!(
380 "../resources/template_scaffold/flake-http.nix"
381 )),
382 "postgres" => Ok(include_str!(
383 "../resources/template_scaffold/flake-postgres.nix"
384 )),
385 "worker" => Ok(include_str!(
386 "../resources/template_scaffold/flake-worker.nix"
387 )),
388 "python" => Ok(include_str!(
389 "../resources/template_scaffold/flake-python.nix"
390 )),
391 other => anyhow::bail!(
392 "Unknown preset {:?}. Valid presets: minimal, http, postgres, worker, python",
393 other
394 ),
395 }
396}
397
398#[derive(Clone, Copy, Debug, Eq, PartialEq)]
399enum ScaffoldFeature {
400 Python,
401 Http,
402 Postgres,
403 Worker,
404}
405
406impl ScaffoldFeature {
407 fn as_str(self) -> &'static str {
408 match self {
409 Self::Python => "python",
410 Self::Http => "http",
411 Self::Postgres => "postgres",
412 Self::Worker => "worker",
413 }
414 }
415}
416
417#[derive(Debug, Eq, PartialEq)]
418struct GeneratedTemplateSpec {
419 primary_preset: String,
420 features: Vec<ScaffoldFeature>,
421 http_port: Option<u16>,
422 health_path: Option<String>,
423 worker_interval_secs: Option<u32>,
424 python_entrypoint: Option<String>,
425}
426
427#[derive(Debug)]
428struct PromptGenerationResult {
429 spec: GeneratedTemplateSpec,
430 details: PromptGenerationDetails,
431}
432
433#[derive(Debug)]
434struct PromptGenerationDetails {
435 generation_mode: String,
436 provider: Option<String>,
437 model: Option<String>,
438 summary: Option<String>,
439 notes: Vec<String>,
440}
441
442#[derive(Debug)]
443struct LlmGenerationConfig {
444 provider: LlmProvider,
445 base_url: String,
446 model: String,
447 api_key: Option<String>,
448}
449
450#[derive(Clone, Copy, Debug, Eq, PartialEq)]
451enum LlmProvider {
452 OpenAi,
453 Local,
454}
455
456#[derive(Debug, serde::Deserialize)]
457struct OpenAiTemplatePlan {
458 schema_version: u8,
459 summary: String,
460 primary_preset: String,
461 features: Vec<String>,
462 http_port: Option<u16>,
463 health_path: Option<String>,
464 worker_interval_secs: Option<u32>,
465 python_entrypoint: Option<String>,
466 notes: Vec<String>,
467}
468
469#[derive(Debug)]
470struct ValidatedOpenAiPlan {
471 spec: GeneratedTemplateSpec,
472 summary: String,
473 notes: Vec<String>,
474}
475
476fn generated_template_spec(preset: Option<&str>, prompt: &str) -> GeneratedTemplateSpec {
477 let mut features = infer_prompt_features(prompt);
478 let primary_preset = resolve_scaffold_preset(preset, Some(prompt));
479
480 if let Some(primary_feature) = feature_for_preset(&primary_preset)
481 && !features.contains(&primary_feature)
482 {
483 features.push(primary_feature);
484 }
485
486 if features.contains(&ScaffoldFeature::Python) {
489 features.retain(|feature| *feature != ScaffoldFeature::Http);
490 }
491
492 GeneratedTemplateSpec {
493 primary_preset,
494 features,
495 http_port: Some(default_http_port()),
496 health_path: Some(default_health_path().to_string()),
497 worker_interval_secs: Some(default_worker_interval_secs()),
498 python_entrypoint: Some(default_python_entrypoint().to_string()),
499 }
500}
501
502fn prompt_generated_template(
503 name: &str,
504 preset: Option<&str>,
505 prompt: &str,
506) -> Result<PromptGenerationResult> {
507 if let Some(config) = llm_generation_config_from_env()? {
508 let plan = generate_spec_with_llm(&config, name, preset, prompt)?;
509 Ok(PromptGenerationResult {
510 spec: plan.spec,
511 details: PromptGenerationDetails {
512 generation_mode: "llm".to_string(),
513 provider: Some(config.provider.as_str().to_string()),
514 model: Some(config.model),
515 summary: Some(plan.summary),
516 notes: plan.notes,
517 },
518 })
519 } else {
520 Ok(PromptGenerationResult {
521 spec: generated_template_spec(preset, prompt),
522 details: PromptGenerationDetails {
523 generation_mode: "heuristic".to_string(),
524 provider: None,
525 model: None,
526 summary: Some(
527 "No hosted or local LLM provider configured; used built-in prompt planner."
528 .to_string(),
529 ),
530 notes: vec![],
531 },
532 })
533 }
534}
535
536impl LlmProvider {
537 fn as_str(self) -> &'static str {
538 match self {
539 Self::OpenAi => "openai",
540 Self::Local => "local",
541 }
542 }
543}
544
545fn llm_generation_config_from_env() -> Result<Option<LlmGenerationConfig>> {
546 let provider = std::env::var("MVM_TEMPLATE_PROVIDER")
547 .unwrap_or_else(|_| "auto".to_string())
548 .to_ascii_lowercase();
549
550 match provider.as_str() {
551 "auto" => {
552 if let Some(config) = openai_generation_config_from_env() {
553 Ok(Some(config))
554 } else {
555 Ok(local_generation_config_from_env())
556 }
557 }
558 "openai" => Ok(Some(openai_generation_config_from_env().context(
559 "MVM_TEMPLATE_PROVIDER=openai requires OPENAI_API_KEY to be set",
560 )?)),
561 "local" => Ok(Some(local_generation_config_from_env().context(
562 "MVM_TEMPLATE_PROVIDER=local requires a local model or base URL",
563 )?)),
564 "heuristic" => Ok(None),
565 other => anyhow::bail!(
566 "Unsupported MVM_TEMPLATE_PROVIDER {:?}. Valid values: auto, openai, local, heuristic",
567 other
568 ),
569 }
570}
571
572fn openai_generation_config_from_env() -> Option<LlmGenerationConfig> {
573 let api_key = std::env::var("OPENAI_API_KEY").ok()?;
574 let base_url = std::env::var("MVM_TEMPLATE_OPENAI_BASE_URL")
575 .or_else(|_| std::env::var("OPENAI_BASE_URL"))
576 .unwrap_or_else(|_| "https://api.openai.com".to_string());
577 let model =
578 std::env::var("MVM_TEMPLATE_OPENAI_MODEL").unwrap_or_else(|_| "gpt-5.2".to_string());
579 Some(LlmGenerationConfig {
580 provider: LlmProvider::OpenAi,
581 api_key: Some(api_key),
582 base_url,
583 model,
584 })
585}
586
587fn local_generation_config_from_env() -> Option<LlmGenerationConfig> {
588 let base_url = std::env::var("MVM_TEMPLATE_LOCAL_BASE_URL")
589 .ok()
590 .or_else(|| std::env::var("LOCALAI_BASE_URL").ok())?;
591 let model = std::env::var("MVM_TEMPLATE_LOCAL_MODEL")
592 .ok()
593 .or_else(|| std::env::var("LOCALAI_MODEL").ok())
594 .unwrap_or_else(|| "qwen2.5-coder-7b-instruct".to_string());
595 let api_key = std::env::var("MVM_TEMPLATE_LOCAL_API_KEY")
596 .ok()
597 .or_else(|| std::env::var("LOCALAI_API_KEY").ok());
598 Some(LlmGenerationConfig {
599 provider: LlmProvider::Local,
600 api_key,
601 base_url,
602 model,
603 })
604}
605
606fn generate_spec_with_llm(
607 config: &LlmGenerationConfig,
608 name: &str,
609 preset: Option<&str>,
610 prompt: &str,
611) -> Result<ValidatedOpenAiPlan> {
612 let client = reqwest::blocking::Client::builder()
613 .user_agent(concat!("mvmctl/", env!("CARGO_PKG_VERSION")))
614 .timeout(Duration::from_secs(60))
615 .build()
616 .context("Failed to build OpenAI HTTP client")?;
617 let endpoint = format!("{}/v1/responses", config.base_url.trim_end_matches('/'));
618 let request = build_openai_prompt_request(&config.model, name, preset, prompt);
619 let mut request_builder = client
620 .post(&endpoint)
621 .header("Accept", "application/json")
622 .header("Content-Type", "application/json");
623 if let Some(api_key) = config.api_key.as_ref() {
624 request_builder = request_builder.header("Authorization", format!("Bearer {}", api_key));
625 }
626 let response = request_builder
627 .json(&request)
628 .send()
629 .with_context(|| format!("{} request failed: {}", config.provider.as_str(), endpoint))?;
630
631 let status = response.status();
632 let body = response
633 .text()
634 .with_context(|| format!("Failed to read LLM response body from {}", endpoint))?;
635 if !status.is_success() {
636 anyhow::bail!(
637 "{} template planning failed with HTTP {}: {}",
638 config.provider.as_str(),
639 status,
640 body
641 );
642 }
643
644 let plan = parse_openai_prompt_response(&body)?;
645 validate_openai_plan(plan, preset)
646}
647
648fn feature_for_preset(preset: &str) -> Option<ScaffoldFeature> {
649 match preset {
650 "minimal" => None,
651 "python" => Some(ScaffoldFeature::Python),
652 "http" => Some(ScaffoldFeature::Http),
653 "postgres" => Some(ScaffoldFeature::Postgres),
654 "worker" => Some(ScaffoldFeature::Worker),
655 _ => None,
656 }
657}
658
659fn resolve_scaffold_preset(preset: Option<&str>, prompt: Option<&str>) -> String {
660 preset
661 .map(ToOwned::to_owned)
662 .or_else(|| prompt.map(infer_prompt_preset))
663 .unwrap_or_else(|| "minimal".to_string())
664}
665
666fn infer_prompt_preset(prompt: &str) -> String {
667 let lower = prompt.to_ascii_lowercase();
668 if lower.contains("python")
669 || lower.contains("fastapi")
670 || lower.contains("flask")
671 || lower.contains("django")
672 {
673 "python".to_string()
674 } else if lower.contains("worker")
675 || lower.contains("queue")
676 || lower.contains("cron")
677 || lower.contains("job")
678 || lower.contains("poll")
679 {
680 "worker".to_string()
681 } else if lower.contains("http")
682 || lower.contains("web")
683 || lower.contains("api")
684 || lower.contains("server")
685 {
686 "http".to_string()
687 } else if lower.contains("postgres")
688 || lower.contains("postgresql")
689 || lower.contains("database")
690 {
691 "postgres".to_string()
692 } else {
693 "minimal".to_string()
694 }
695}
696
697fn infer_prompt_features(prompt: &str) -> Vec<ScaffoldFeature> {
698 let lower = prompt.to_ascii_lowercase();
699 let mut features = Vec::new();
700
701 if lower.contains("python")
702 || lower.contains("fastapi")
703 || lower.contains("flask")
704 || lower.contains("django")
705 {
706 features.push(ScaffoldFeature::Python);
707 }
708
709 if lower.contains("http")
710 || lower.contains("web")
711 || lower.contains("api")
712 || lower.contains("server")
713 {
714 features.push(ScaffoldFeature::Http);
715 }
716
717 if lower.contains("postgres") || lower.contains("postgresql") || lower.contains("database") {
718 features.push(ScaffoldFeature::Postgres);
719 }
720
721 if lower.contains("worker")
722 || lower.contains("queue")
723 || lower.contains("cron")
724 || lower.contains("job")
725 || lower.contains("poll")
726 {
727 features.push(ScaffoldFeature::Worker);
728 }
729
730 features
731}
732
733fn build_openai_prompt_request(
734 model: &str,
735 name: &str,
736 preset: Option<&str>,
737 prompt: &str,
738) -> serde_json::Value {
739 let preset_hint = preset.unwrap_or("none");
740 serde_json::json!({
741 "model": model,
742 "input": [
743 {
744 "role": "system",
745 "content": [
746 {
747 "type": "input_text",
748 "text": "You generate safe microVM scaffold plans for mvmctl. Output only schema-compliant JSON. Keep plans constrained to supported presets and features. Never emit secrets, host paths, shell substitutions, or arbitrary package names."
749 }
750 ]
751 },
752 {
753 "role": "user",
754 "content": [
755 {
756 "type": "input_text",
757 "text": format!(
758 "Template name: {name}\nExplicit preset override: {preset_hint}\nPrompt: {prompt}\n\nChoose primary_preset from minimal/http/postgres/worker/python. Features may include python/http/postgres/worker. Use only safe defaults: port 8080 unless the workload strongly implies another HTTP port, health_path should start with '/', worker_interval_secs should be 1-3600, python_entrypoint should be a relative file path like main.py. Prefer python over plain http when the prompt is Python-specific. Prefer app/runtime presets over backing services."
759 )
760 }
761 ]
762 }
763 ],
764 "text": {
765 "format": {
766 "type": "json_schema",
767 "name": "mvm_template_plan",
768 "strict": true,
769 "schema": {
770 "type": "object",
771 "additionalProperties": false,
772 "properties": {
773 "schema_version": { "type": "integer", "enum": [1] },
774 "summary": { "type": "string" },
775 "primary_preset": {
776 "type": "string",
777 "enum": ["minimal", "http", "postgres", "worker", "python"]
778 },
779 "features": {
780 "type": "array",
781 "items": {
782 "type": "string",
783 "enum": ["python", "http", "postgres", "worker"]
784 },
785 "uniqueItems": true
786 },
787 "http_port": {
788 "anyOf": [
789 { "type": "integer", "minimum": 1, "maximum": 65535 },
790 { "type": "null" }
791 ]
792 },
793 "health_path": {
794 "anyOf": [
795 { "type": "string" },
796 { "type": "null" }
797 ]
798 },
799 "worker_interval_secs": {
800 "anyOf": [
801 { "type": "integer", "minimum": 1, "maximum": 3600 },
802 { "type": "null" }
803 ]
804 },
805 "python_entrypoint": {
806 "anyOf": [
807 { "type": "string" },
808 { "type": "null" }
809 ]
810 },
811 "notes": {
812 "type": "array",
813 "items": { "type": "string" }
814 }
815 },
816 "required": [
817 "schema_version",
818 "summary",
819 "primary_preset",
820 "features",
821 "http_port",
822 "health_path",
823 "worker_interval_secs",
824 "python_entrypoint",
825 "notes"
826 ]
827 }
828 }
829 }
830 })
831}
832
833fn parse_openai_prompt_response(body: &str) -> Result<OpenAiTemplatePlan> {
834 let response: serde_json::Value =
835 serde_json::from_str(body).context("Failed to parse OpenAI JSON response")?;
836
837 if let Some(output_text) = response
838 .get("output_text")
839 .and_then(serde_json::Value::as_str)
840 {
841 return serde_json::from_str(output_text)
842 .context("Failed to parse JSON plan from OpenAI output_text");
843 }
844
845 let output = response
846 .get("output")
847 .and_then(serde_json::Value::as_array)
848 .context("OpenAI response missing output array")?;
849 for item in output {
850 if let Some(content) = item.get("content").and_then(serde_json::Value::as_array) {
851 for part in content {
852 if part.get("type").and_then(serde_json::Value::as_str) == Some("output_text")
853 && let Some(text) = part.get("text").and_then(serde_json::Value::as_str)
854 {
855 return serde_json::from_str(text)
856 .context("Failed to parse JSON plan from OpenAI output content");
857 }
858 }
859 }
860 }
861
862 anyhow::bail!("OpenAI response did not include structured output text")
863}
864
865fn validate_openai_plan(
866 plan: OpenAiTemplatePlan,
867 preset: Option<&str>,
868) -> Result<ValidatedOpenAiPlan> {
869 if plan.schema_version != 1 {
870 anyhow::bail!(
871 "Unsupported OpenAI template plan schema: {}",
872 plan.schema_version
873 );
874 }
875
876 let mut features = Vec::new();
877 for feature in plan.features {
878 let parsed = parse_feature_name(&feature)
879 .with_context(|| format!("OpenAI returned unsupported feature {:?}", feature))?;
880 if !features.contains(&parsed) {
881 features.push(parsed);
882 }
883 }
884
885 let primary_preset = resolve_scaffold_preset(preset, Some(&plan.primary_preset));
886 if let Some(primary_feature) = feature_for_preset(&primary_preset)
887 && !features.contains(&primary_feature)
888 {
889 features.push(primary_feature);
890 }
891 if features.contains(&ScaffoldFeature::Python) {
892 features.retain(|feature| *feature != ScaffoldFeature::Http);
893 }
894
895 let health_path = validate_health_path(plan.health_path)?;
896 let python_entrypoint = validate_python_entrypoint(plan.python_entrypoint)?;
897 let worker_interval_secs = plan.worker_interval_secs.map(|secs| secs.clamp(1, 3600));
898 let http_port = if features.contains(&ScaffoldFeature::Python)
899 || features.contains(&ScaffoldFeature::Http)
900 {
901 Some(plan.http_port.unwrap_or(default_http_port()))
902 } else {
903 None
904 };
905
906 Ok(ValidatedOpenAiPlan {
907 spec: GeneratedTemplateSpec {
908 primary_preset,
909 features,
910 http_port,
911 health_path,
912 worker_interval_secs,
913 python_entrypoint,
914 },
915 summary: plan.summary,
916 notes: plan.notes,
917 })
918}
919
920fn parse_feature_name(value: &str) -> Result<ScaffoldFeature> {
921 match value {
922 "python" => Ok(ScaffoldFeature::Python),
923 "http" => Ok(ScaffoldFeature::Http),
924 "postgres" => Ok(ScaffoldFeature::Postgres),
925 "worker" => Ok(ScaffoldFeature::Worker),
926 other => anyhow::bail!("unsupported feature {:?}", other),
927 }
928}
929
930fn validate_health_path(path: Option<String>) -> Result<Option<String>> {
931 match path {
932 Some(path) if path.starts_with('/') => Ok(Some(path)),
933 Some(path) => anyhow::bail!("health_path must start with '/': {}", path),
934 None => Ok(Some(default_health_path().to_string())),
935 }
936}
937
938fn validate_python_entrypoint(path: Option<String>) -> Result<Option<String>> {
939 match path {
940 Some(path)
941 if !path.is_empty()
942 && !path.starts_with('/')
943 && path.chars().all(|ch| {
944 ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-' | '/')
945 }) =>
946 {
947 Ok(Some(path))
948 }
949 Some(path) => anyhow::bail!("invalid python_entrypoint {:?}", path),
950 None => Ok(Some(default_python_entrypoint().to_string())),
951 }
952}
953
954fn default_http_port() -> u16 {
955 8080
956}
957
958fn default_health_path() -> &'static str {
959 "/"
960}
961
962fn default_worker_interval_secs() -> u32 {
963 10
964}
965
966fn default_python_entrypoint() -> &'static str {
967 "main.py"
968}
969
970fn render_prompt_generated_flake(name: &str, spec: &GeneratedTemplateSpec) -> String {
971 let http_port = spec.http_port.unwrap_or(default_http_port());
972 let health_path = spec.health_path.as_deref().unwrap_or(default_health_path());
973 let worker_interval_secs = spec
974 .worker_interval_secs
975 .unwrap_or(default_worker_interval_secs());
976 let python_entrypoint = spec
977 .python_entrypoint
978 .as_deref()
979 .unwrap_or(default_python_entrypoint());
980
981 let mut let_lines = vec![
982 " system = \"aarch64-linux\"; # change to x86_64-linux if needed".to_string(),
983 " pkgs = import nixpkgs { inherit system; };".to_string(),
984 ];
985
986 if spec.features.contains(&ScaffoldFeature::Postgres) {
987 let_lines.push(" pgData = \"/var/lib/postgresql/data\";".to_string());
988 }
989
990 if spec.features.contains(&ScaffoldFeature::Python) {
991 let_lines.push(String::new());
992 let_lines.push(" # Python with dependencies from nixpkgs.".to_string());
993 let_lines.push(
994 " # Add packages to the list: ps.fastapi, ps.flask, ps.requests, etc.".to_string(),
995 );
996 let_lines.push(" python = pkgs.python3.withPackages (ps: [".to_string());
997 let_lines.push(" # ps.fastapi".to_string());
998 let_lines.push(" # ps.uvicorn".to_string());
999 let_lines.push(" ]);".to_string());
1000 let_lines.push(String::new());
1001 let_lines.push(" appSrc = pkgs.stdenv.mkDerivation {".to_string());
1002 let_lines.push(format!(" pname = \"{name}-app\";"));
1003 let_lines.push(" version = \"0\";".to_string());
1004 let_lines.push(" src = ./app;".to_string());
1005 let_lines.push(" installPhase = \"cp -r . $out\";".to_string());
1006 let_lines.push(" };".to_string());
1007 }
1008
1009 let mut package_items: Vec<&str> = Vec::new();
1010 if spec.features.contains(&ScaffoldFeature::Python) {
1011 package_items.extend(["python", "appSrc"]);
1012 }
1013 if spec.features.contains(&ScaffoldFeature::Postgres) {
1014 package_items.push("pkgs.postgresql");
1015 }
1016 if spec.features.contains(&ScaffoldFeature::Worker) {
1017 package_items.extend(["pkgs.bash", "pkgs.coreutils"]);
1018 }
1019 if spec.features.contains(&ScaffoldFeature::Http)
1020 && !spec.features.contains(&ScaffoldFeature::Python)
1021 {
1022 package_items.push("pkgs.python3");
1023 }
1024 if spec.features.is_empty() {
1025 package_items.extend(["pkgs.curl", "pkgs.bash"]);
1026 } else if spec.features.contains(&ScaffoldFeature::Python)
1027 || spec.features.contains(&ScaffoldFeature::Http)
1028 || spec.features.contains(&ScaffoldFeature::Postgres)
1029 {
1030 package_items.push("pkgs.curl");
1031 }
1032
1033 let mut packages = Vec::new();
1034 for item in package_items {
1035 if !packages.contains(&item) {
1036 packages.push(item);
1037 }
1038 }
1039
1040 let mut service_entries = Vec::new();
1041 let mut health_entries = Vec::new();
1042
1043 if spec.features.contains(&ScaffoldFeature::Python) {
1044 service_entries.push(format!(
1045 " services.app = {{\n command = \"${{python}}/bin/python3 ${{appSrc}}/{python_entrypoint}\";\n env = {{\n PORT = \"{http_port}\";\n PYTHONUNBUFFERED = \"1\";\n }};\n }};"
1046 ));
1047 health_entries.push(format!(
1048 " healthChecks.app = {{\n healthCmd = \"${{pkgs.curl}}/bin/curl -sf http://localhost:{http_port}{health_path} >/dev/null\";\n healthIntervalSecs = 5;\n healthTimeoutSecs = 3;\n }};"
1049 ));
1050 } else if spec.features.contains(&ScaffoldFeature::Http) {
1051 service_entries.push(format!(
1052 " services.web = {{\n command = \"${{pkgs.python3}}/bin/python3 -m http.server {http_port}\";\n }};"
1053 ));
1054 health_entries.push(format!(
1055 " healthChecks.web = {{\n healthCmd = \"${{pkgs.curl}}/bin/curl -sf http://localhost:{http_port}{health_path} >/dev/null\";\n healthIntervalSecs = 5;\n healthTimeoutSecs = 3;\n }};"
1056 ));
1057 }
1058
1059 if spec.features.contains(&ScaffoldFeature::Postgres) {
1060 service_entries.push(
1061 r#" services.postgres = {
1062 preStart = ''
1063 if [ ! -f ${pgData}/PG_VERSION ]; then
1064 mkdir -p ${pgData}
1065 chown postgres:postgres ${pgData}
1066 su -s /bin/sh postgres -c "${pkgs.postgresql}/bin/initdb -D ${pgData}"
1067 fi
1068 '';
1069 command = "${pkgs.postgresql}/bin/postgres -D ${pgData} -k /run/postgresql";
1070 };"#
1071 .to_string(),
1072 );
1073 health_entries.push(
1074 r#" healthChecks.postgres = {
1075 healthCmd = "${pkgs.postgresql}/bin/pg_isready -h localhost";
1076 healthIntervalSecs = 5;
1077 healthTimeoutSecs = 5;
1078 };"#
1079 .to_string(),
1080 );
1081 }
1082
1083 if spec.features.contains(&ScaffoldFeature::Worker) {
1084 service_entries.push(format!(
1085 " services.worker = {{\n preStart = \"mkdir -p /run/worker\";\n command = \"${{pkgs.bash}}/bin/bash -c 'while true; do echo \\\"[worker] tick $(date)\\\"; touch /run/worker/healthy; sleep {worker_interval_secs}; done'\";\n }};"
1086 ));
1087 health_entries.push(format!(
1088 " healthChecks.worker = {{\n healthCmd = \"${{pkgs.bash}}/bin/bash -c 'test -f /run/worker/healthy'\";\n healthIntervalSecs = {worker_interval_secs};\n healthTimeoutSecs = 5;\n }};"
1089 ));
1090 }
1091
1092 let mut body_lines = vec![format!(" name = \"{name}\";"), String::new()];
1093 body_lines.push(format!(" packages = [ {} ];", packages.join(" ")));
1094
1095 if !service_entries.is_empty() {
1096 body_lines.push(String::new());
1097 body_lines
1098 .push(" # Generated service definitions inferred from the prompt.".to_string());
1099 body_lines.extend(service_entries.into_iter().flat_map(|entry| {
1100 let mut lines: Vec<String> = entry.lines().map(ToOwned::to_owned).collect();
1101 lines.push(String::new());
1102 lines
1103 }));
1104 body_lines.pop();
1105 } else {
1106 body_lines.push(String::new());
1107 body_lines.push(" # Add supervised services here.".to_string());
1108 }
1109
1110 if !health_entries.is_empty() {
1111 body_lines.push(String::new());
1112 body_lines.push(" # Generated health checks inferred from the prompt.".to_string());
1113 body_lines.extend(health_entries.into_iter().flat_map(|entry| {
1114 let mut lines: Vec<String> = entry.lines().map(ToOwned::to_owned).collect();
1115 lines.push(String::new());
1116 lines
1117 }));
1118 body_lines.pop();
1119 }
1120
1121 format!(
1122 "{{\n description = \"mvm microVM — {} prompt scaffold\";\n\n inputs = {{\n mvm.url = \"github:auser/mvm?dir=nix\";\n nixpkgs.url = \"github:NixOS/nixpkgs/nixos-25.11\";\n }};\n\n outputs = {{ mvm, nixpkgs, ... }}:\n let\n{}\n in {{\n packages.${{system}}.default = mvm.lib.${{system}}.mkGuest {{\n{}\n }};\n }};\n}}\n",
1123 spec.primary_preset,
1124 let_lines.join("\n"),
1125 body_lines.join("\n")
1126 )
1127}
1128
1129#[derive(serde::Serialize)]
1130struct PromptMetadata {
1131 schema_version: u8,
1132 template_name: String,
1133 prompt: String,
1134 generation_mode: String,
1135 provider: Option<String>,
1136 model: Option<String>,
1137 summary: Option<String>,
1138 notes: Vec<String>,
1139 primary_preset: String,
1140 inferred_features: Vec<&'static str>,
1141 http_port: Option<u16>,
1142 health_path: Option<String>,
1143 worker_interval_secs: Option<u32>,
1144 python_entrypoint: Option<String>,
1145 created_at: String,
1146}
1147
1148fn scaffold_template_files(
1149 dir: &Path,
1150 name: &str,
1151 preset: &str,
1152 prompt: Option<&str>,
1153) -> Result<()> {
1154 fs::create_dir_all(dir)?;
1155 let prompt_result = prompt
1156 .map(|prompt| prompt_generated_template(name, Some(preset), prompt))
1157 .transpose()?;
1158
1159 let gitignore = dir.join(".gitignore");
1160 if !gitignore.exists() {
1161 fs::write(
1162 &gitignore,
1163 include_str!("../resources/template_scaffold/.gitignore"),
1164 )?;
1165 }
1166
1167 let flake_path = dir.join("flake.nix");
1168 if !flake_path.exists() {
1169 let flake = if let Some(result) = prompt_result.as_ref() {
1170 render_prompt_generated_flake(name, &result.spec)
1171 } else {
1172 flake_content_for_preset(preset)?.to_string()
1173 };
1174 fs::write(&flake_path, flake)?;
1175 }
1176
1177 let readme_path = dir.join("README.md");
1178 if !readme_path.exists() {
1179 let content =
1180 include_str!("../resources/template_scaffold/README.md").replace("{{name}}", name);
1181 fs::write(&readme_path, content)?;
1182 }
1183
1184 if let Some(result) = prompt_result.as_ref() {
1185 scaffold_prompt_support_files(dir, &result.spec)?;
1186 }
1187
1188 if let (Some(prompt), Some(result)) = (prompt, prompt_result.as_ref()) {
1189 let prompt_path = dir.join("mvm-template-prompt.json");
1190 if !prompt_path.exists() {
1191 let metadata = PromptMetadata {
1192 schema_version: 3,
1193 template_name: name.to_string(),
1194 prompt: prompt.to_string(),
1195 generation_mode: result.details.generation_mode.clone(),
1196 provider: result.details.provider.clone(),
1197 model: result.details.model.clone(),
1198 summary: result.details.summary.clone(),
1199 notes: result.details.notes.clone(),
1200 primary_preset: result.spec.primary_preset.clone(),
1201 inferred_features: result
1202 .spec
1203 .features
1204 .iter()
1205 .copied()
1206 .map(ScaffoldFeature::as_str)
1207 .collect(),
1208 http_port: result.spec.http_port,
1209 health_path: result.spec.health_path.clone(),
1210 worker_interval_secs: result.spec.worker_interval_secs,
1211 python_entrypoint: result.spec.python_entrypoint.clone(),
1212 created_at: now_iso(),
1213 };
1214 fs::write(&prompt_path, serde_json::to_string_pretty(&metadata)?)?;
1215 }
1216 }
1217
1218 scaffold_mvm_baseline(dir)?;
1221
1222 Ok(())
1223}
1224
1225fn scaffold_mvm_baseline(dir: &Path) -> Result<()> {
1230 let baseline_path = dir.join("baseline.nix");
1231 if !baseline_path.exists() {
1232 fs::write(&baseline_path, include_str!("../resources/baseline.nix"))?;
1233 }
1234 Ok(())
1235}
1236
1237fn scaffold_prompt_support_files(dir: &Path, spec: &GeneratedTemplateSpec) -> Result<()> {
1238 if spec.features.contains(&ScaffoldFeature::Python) {
1239 let entrypoint = spec
1240 .python_entrypoint
1241 .as_deref()
1242 .unwrap_or(default_python_entrypoint());
1243 let app_path = dir.join("app").join(entrypoint);
1244 if !app_path.exists() {
1245 if let Some(parent) = app_path.parent() {
1246 fs::create_dir_all(parent)?;
1247 }
1248 fs::write(
1249 &app_path,
1250 render_python_app_stub(
1251 spec.http_port.unwrap_or(default_http_port()),
1252 spec.health_path.as_deref().unwrap_or(default_health_path()),
1253 ),
1254 )?;
1255 }
1256 }
1257 Ok(())
1258}
1259
1260fn render_python_app_stub(port: u16, health_path: &str) -> String {
1261 format!(
1262 "import os\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\nPORT = int(os.environ.get(\"PORT\", \"{port}\"))\nHEALTH_PATH = \"{health_path}\"\n\n\nclass Handler(BaseHTTPRequestHandler):\n def do_GET(self):\n if self.path in (\"/\", HEALTH_PATH):\n self.send_response(200)\n self.send_header(\"Content-Type\", \"text/plain; charset=utf-8\")\n self.end_headers()\n self.wfile.write(b\"ok\\n\")\n return\n self.send_response(404)\n self.end_headers()\n\n\nif __name__ == \"__main__\":\n server = HTTPServer((\"0.0.0.0\", PORT), Handler)\n print(f\"listening on {{PORT}}\")\n server.serve_forever()\n"
1263 )
1264}
1265
1266#[cfg(test)]
1267mod tests {
1268 use super::{
1269 GeneratedTemplateSpec, ScaffoldFeature, build_openai_prompt_request,
1270 generated_template_spec, infer_prompt_features, infer_prompt_preset,
1271 parse_openai_prompt_response, render_prompt_generated_flake, resolve_scaffold_preset,
1272 validate_openai_plan,
1273 };
1274
1275 #[test]
1276 fn test_infer_prompt_preset_python() {
1277 assert_eq!(
1278 infer_prompt_preset("Python API worker with FastAPI"),
1279 "python"
1280 );
1281 }
1282
1283 #[test]
1284 fn test_infer_prompt_preset_worker() {
1285 assert_eq!(
1286 infer_prompt_preset("Background worker that polls an API every minute"),
1287 "worker"
1288 );
1289 }
1290
1291 #[test]
1292 fn test_resolve_scaffold_preset_explicit_wins() {
1293 assert_eq!(
1294 resolve_scaffold_preset(Some("postgres"), Some("python web app")),
1295 "postgres"
1296 );
1297 }
1298
1299 #[test]
1300 fn test_infer_prompt_features_can_merge_python_and_postgres() {
1301 assert_eq!(
1302 infer_prompt_features("Python API with PostgreSQL backing store"),
1303 vec![
1304 ScaffoldFeature::Python,
1305 ScaffoldFeature::Http,
1306 ScaffoldFeature::Postgres
1307 ]
1308 );
1309 }
1310
1311 #[test]
1312 fn test_generated_template_spec_deduplicates_http_when_python_present() {
1313 assert_eq!(
1314 generated_template_spec(None, "python http api with postgres"),
1315 GeneratedTemplateSpec {
1316 primary_preset: "python".to_string(),
1317 features: vec![ScaffoldFeature::Python, ScaffoldFeature::Postgres],
1318 http_port: Some(8080),
1319 health_path: Some("/".to_string()),
1320 worker_interval_secs: Some(10),
1321 python_entrypoint: Some("main.py".to_string()),
1322 }
1323 );
1324 }
1325
1326 #[test]
1327 fn test_render_prompt_generated_flake_combines_python_and_postgres() {
1328 let flake = render_prompt_generated_flake(
1329 "analytics-worker",
1330 &GeneratedTemplateSpec {
1331 primary_preset: "python".to_string(),
1332 features: vec![ScaffoldFeature::Python, ScaffoldFeature::Postgres],
1333 http_port: Some(9090),
1334 health_path: Some("/healthz".to_string()),
1335 worker_interval_secs: Some(10),
1336 python_entrypoint: Some("server.py".to_string()),
1337 },
1338 );
1339 assert!(flake.contains("services.app"));
1340 assert!(flake.contains("services.postgres"));
1341 assert!(flake.contains("pkgs.postgresql"));
1342 assert!(flake.contains("healthChecks.postgres"));
1343 assert!(flake.contains("localhost:9090/healthz"));
1344 assert!(flake.contains("${appSrc}/server.py"));
1345 }
1346
1347 #[test]
1348 fn test_build_openai_prompt_request_uses_json_schema() {
1349 let request = build_openai_prompt_request("gpt-5.2", "demo", None, "python api");
1350 assert_eq!(request["model"], "gpt-5.2");
1351 assert_eq!(request["text"]["format"]["type"], "json_schema");
1352 assert_eq!(request["text"]["format"]["strict"], true);
1353 }
1354
1355 #[test]
1356 fn test_parse_openai_prompt_response_reads_output_text() {
1357 let response = r#"{
1358 "output": [{
1359 "content": [{
1360 "type": "output_text",
1361 "text": "{\"schema_version\":1,\"summary\":\"Python API\",\"primary_preset\":\"python\",\"features\":[\"python\",\"postgres\"],\"http_port\":8000,\"health_path\":\"/health\",\"worker_interval_secs\":null,\"python_entrypoint\":\"service.py\",\"notes\":[\"Use python app stub\"]}"
1362 }]
1363 }]
1364 }"#;
1365 let plan = parse_openai_prompt_response(response).expect("parse plan");
1366 assert_eq!(plan.primary_preset, "python");
1367 assert_eq!(plan.http_port, Some(8000));
1368 assert_eq!(plan.python_entrypoint.as_deref(), Some("service.py"));
1369 }
1370
1371 #[test]
1372 fn test_validate_openai_plan_normalizes_and_merges_features() {
1373 let validated = validate_openai_plan(
1374 super::OpenAiTemplatePlan {
1375 schema_version: 1,
1376 summary: "Python API with postgres".to_string(),
1377 primary_preset: "python".to_string(),
1378 features: vec![
1379 "python".to_string(),
1380 "http".to_string(),
1381 "postgres".to_string(),
1382 ],
1383 http_port: Some(8000),
1384 health_path: Some("/ready".to_string()),
1385 worker_interval_secs: None,
1386 python_entrypoint: Some("app.py".to_string()),
1387 notes: vec!["Keep postgres local".to_string()],
1388 },
1389 None,
1390 )
1391 .expect("validated plan");
1392 assert_eq!(
1393 validated.spec.features,
1394 vec![ScaffoldFeature::Python, ScaffoldFeature::Postgres]
1395 );
1396 assert_eq!(validated.spec.http_port, Some(8000));
1397 assert_eq!(validated.spec.health_path.as_deref(), Some("/ready"));
1398 assert_eq!(validated.spec.python_entrypoint.as_deref(), Some("app.py"));
1399 assert_eq!(validated.summary, "Python API with postgres");
1400 }
1401}