Skip to main content

mvm_cli/
template_cmd.rs

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
40/// Resolve a flake reference to an absolute path if it's a local path.
41///
42/// Relative paths like "." or "../foo" are resolved against CWD so that
43/// `nix build` works regardless of which directory the build runs from.
44/// Remote flake refs (e.g., "github:user/repo") are passed through unchanged.
45fn resolve_flake_ref(flake: &str) -> String {
46    // Remote flake refs contain ":" (github:, git+https:, path:, etc.)
47    if flake.contains(':') {
48        return flake.to_string();
49    }
50    // Local path — resolve to absolute
51    match std::path::Path::new(flake).canonicalize() {
52        Ok(abs) => abs.to_string_lossy().to_string(),
53        Err(_) => flake.to_string(),
54    }
55}
56
57/// Initialize an empty template directory layout (idempotent).
58pub 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    // Resolve once so all variants share the same absolute path.
87    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        // Check if the current backend supports snapshots.
270        // Snapshots are Firecracker-specific; Apple Container and Docker
271        // backends only support image-only templates.
272        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    // Load existing template spec
309    let mut spec = tmpl::template_load(name)?;
310
311    // Update fields if provided
312    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    // Update timestamp
332    spec.updated_at = now_iso();
333
334    // Save updated spec
335    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    // Python services already provide the HTTP server role, so keep the spec
487    // simpler by rendering a single app service instead of a redundant pair.
488    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 the baseline NixOS guest config. The guest agent modules
1219    // come from the mvm-src flake input automatically.
1220    scaffold_mvm_baseline(dir)?;
1221
1222    Ok(())
1223}
1224
1225/// Write the mvm baseline NixOS config into the scaffold directory.
1226///
1227/// The guest agent modules come from the `mvm-src` flake input,
1228/// but the baseline guest config is scaffolded locally so users can customize it.
1229fn 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}