1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::io::{self, IsTerminal, Write};
4use std::path::{Path, PathBuf};
5
6use anyhow::bail;
7use anyhow::{Context, Result, anyhow};
8use greentic_flow::flow_bundle::load_and_validate_bundle;
9use greentic_runner::desktop::{
10 HttpMock, HttpMockMode, MocksConfig, OtlpHook, Runner, SigningPolicy, ToolsMock,
11};
12use serde_json::{Value as JsonValue, json};
13use serde_yaml_bw as serde_yaml;
14
15#[derive(Debug, Clone)]
16pub struct PackRunConfig<'a> {
17 pub pack_path: &'a Path,
18 pub entry: Option<String>,
19 pub input: Option<String>,
20 pub policy: RunPolicy,
21 pub otlp: Option<String>,
22 pub allow_hosts: Option<Vec<String>>,
23 pub mocks: MockSetting,
24 pub artifacts_dir: Option<&'a Path>,
25}
26
27#[derive(Debug, Clone, Copy)]
28pub enum RunPolicy {
29 Strict,
30 DevOk,
31}
32
33#[derive(Debug, Clone, Copy)]
34pub enum MockSetting {
35 On,
36 Off,
37}
38
39pub fn run(config: PackRunConfig<'_>) -> Result<()> {
40 let _ = tracing_subscriber::fmt::try_init();
42
43 if std::env::var_os("HOME").is_none() || std::env::var_os("WASMTIME_CACHE_DIR").is_none() {
45 let workspace = std::env::current_dir().context("failed to resolve workspace root")?;
46 let home = workspace.join(".greentic").join("wasmtime-home");
47 let cache_dir = home
48 .join("Library")
49 .join("Caches")
50 .join("BytecodeAlliance.wasmtime");
51 let config_dir = home
52 .join("Library")
53 .join("Application Support")
54 .join("wasmtime");
55 fs::create_dir_all(&cache_dir)
56 .with_context(|| format!("failed to create {}", cache_dir.display()))?;
57 fs::create_dir_all(&config_dir)
58 .with_context(|| format!("failed to create {}", config_dir.display()))?;
59 unsafe {
62 std::env::set_var("HOME", &home);
63 std::env::set_var("WASMTIME_CACHE_DIR", &cache_dir);
64 }
65 }
66
67 let input_value = parse_input(config.input)?;
68 let otlp_hook = config.otlp.map(|endpoint| OtlpHook {
69 endpoint,
70 headers: Vec::new(),
71 sample_all: true,
72 });
73
74 unsafe {
76 std::env::set_var("NO_PROXY", "*");
77 std::env::set_var("HTTPS_PROXY", "");
78 std::env::set_var("HTTP_PROXY", "");
79 std::env::set_var("CFNETWORK_DISABLE_SYSTEM_PROXY", "1");
80 }
81
82 let allow_hosts = config.allow_hosts.unwrap_or_default();
83 let mocks_config = build_mocks_config(config.mocks, allow_hosts)?;
84
85 let artifacts_override = config.artifacts_dir.map(|dir| dir.to_path_buf());
86 if let Some(dir) = &artifacts_override {
87 fs::create_dir_all(dir)
88 .with_context(|| format!("failed to create artifacts directory {}", dir.display()))?;
89 }
90
91 let runner = Runner::new();
92 let run_result = runner
93 .run_pack_with(config.pack_path, |opts| {
94 opts.entry_flow = config.entry.clone();
95 opts.input = input_value.clone();
96 opts.signing = signing_policy(config.policy);
97 if let Some(hook) = otlp_hook.clone() {
98 opts.otlp = Some(hook);
99 }
100 opts.mocks = mocks_config.clone();
101 opts.artifacts_dir = artifacts_override.clone();
102 })
103 .context("pack execution failed")?;
104
105 let value = serde_json::to_value(&run_result).context("failed to render run result JSON")?;
106 let status = value
107 .get("status")
108 .and_then(|v| v.as_str())
109 .unwrap_or_default();
110 let rendered =
111 serde_json::to_string_pretty(&value).context("failed to render run result JSON")?;
112 println!("{rendered}");
113
114 if status == "Failure" || status == "PartialFailure" {
115 let err = value
116 .get("error")
117 .and_then(|v| v.as_str())
118 .unwrap_or("pack run returned failure status");
119 bail!("pack run failed: {err}");
120 }
121
122 Ok(())
123}
124
125fn parse_input(input: Option<String>) -> Result<JsonValue> {
126 if let Some(raw) = input {
127 if raw.trim().is_empty() {
128 return Ok(json!({}));
129 }
130 serde_json::from_str(&raw).context("failed to parse --input JSON")
131 } else {
132 Ok(json!({}))
133 }
134}
135
136fn build_mocks_config(setting: MockSetting, allow_hosts: Vec<String>) -> Result<MocksConfig> {
137 let mut config = MocksConfig {
138 net_allowlist: allow_hosts
139 .into_iter()
140 .map(|host| host.trim().to_ascii_lowercase())
141 .filter(|host| !host.is_empty())
142 .collect(),
143 ..MocksConfig::default()
144 };
145
146 if matches!(setting, MockSetting::On) {
147 config.http = Some(HttpMock {
148 record_replay_dir: None,
149 mode: HttpMockMode::RecordReplay,
150 rewrites: Vec::new(),
151 });
152
153 let tools_dir = PathBuf::from(".greentic").join("mocks").join("tools");
154 fs::create_dir_all(&tools_dir)
155 .with_context(|| format!("failed to create {}", tools_dir.display()))?;
156 config.mcp_tools = Some(ToolsMock {
157 directory: None,
158 script_dir: Some(tools_dir),
159 short_circuit: true,
160 });
161 }
162
163 Ok(config)
164}
165
166fn signing_policy(policy: RunPolicy) -> SigningPolicy {
167 match policy {
168 RunPolicy::Strict => SigningPolicy::Strict,
169 RunPolicy::DevOk => SigningPolicy::DevOk,
170 }
171}
172
173#[allow(dead_code)]
175pub fn run_config_flow(flow_path: &Path) -> Result<String> {
176 let source = std::fs::read_to_string(flow_path)
177 .with_context(|| format!("failed to read config flow {}", flow_path.display()))?;
178 load_and_validate_bundle(&source, Some(flow_path)).context("config flow validation failed")?;
180
181 let doc: serde_yaml::Value = serde_yaml::from_str(&source)
182 .with_context(|| format!("invalid YAML in {}", flow_path.display()))?;
183 let nodes = doc
184 .get("nodes")
185 .and_then(|v| v.as_mapping())
186 .ok_or_else(|| anyhow!("config flow missing nodes map"))?;
187
188 let mut current = nodes
189 .iter()
190 .next()
191 .map(|(k, _)| k.as_str().unwrap_or_default().to_string())
192 .ok_or_else(|| anyhow!("config flow has no nodes to execute"))?;
193 let mut state: BTreeMap<String, String> = BTreeMap::new();
194 let mut visited: BTreeSet<String> = BTreeSet::new();
195 let is_tty = io::stdin().is_terminal();
196
197 loop {
198 if !visited.insert(current.clone()) {
199 bail!("config flow routing loop detected at {}", current);
200 }
201
202 let node_val = nodes
203 .get(serde_yaml::Value::String(current.clone(), None))
204 .ok_or_else(|| anyhow!("node `{current}` not found in config flow"))?;
205 let mapping = node_val
206 .as_mapping()
207 .ok_or_else(|| anyhow!("node `{current}` is not a mapping"))?;
208
209 if let Some(fields) = mapping
211 .get(serde_yaml::Value::String("questions".to_string(), None))
212 .and_then(|q| {
213 q.as_mapping()
214 .and_then(|m| m.get(serde_yaml::Value::String("fields".to_string(), None)))
215 })
216 .and_then(|v| v.as_sequence())
217 {
218 for field in fields {
219 let Some(field_map) = field.as_mapping() else {
220 continue;
221 };
222 let id = field_map
223 .get(serde_yaml::Value::String("id".to_string(), None))
224 .and_then(|v| v.as_str())
225 .unwrap_or("")
226 .to_string();
227 if id.is_empty() {
228 continue;
229 }
230 let prompt = field_map
231 .get(serde_yaml::Value::String("prompt".to_string(), None))
232 .and_then(|v| v.as_str())
233 .unwrap_or(&id);
234 let default = field_map
235 .get(serde_yaml::Value::String("default".to_string(), None))
236 .and_then(|v| v.as_str())
237 .unwrap_or("");
238 let value = if is_tty {
239 print!("{prompt} [{default}]: ");
240 let _ = io::stdout().flush();
241 let mut buf = String::new();
242 io::stdin().read_line(&mut buf).ok();
243 let trimmed = buf.trim();
244 if trimmed.is_empty() {
245 default.to_string()
246 } else {
247 trimmed.to_string()
248 }
249 } else {
250 default.to_string()
251 };
252 state.insert(id, value);
253 }
254 }
255
256 if let Some(template) = mapping
258 .get(serde_yaml::Value::String("template".to_string(), None))
259 .and_then(|v| v.as_str())
260 {
261 let mut rendered = template.to_string();
262 for (k, v) in &state {
263 let needle = format!("{{{{state.{k}}}}}");
264 rendered = rendered.replace(&needle, v);
265 }
266 return Ok(rendered);
267 }
268
269 if let Some(payload) = mapping.get(serde_yaml::Value::String("payload".to_string(), None)) {
271 let json_str = serde_json::to_string(&serde_yaml::from_value::<serde_json::Value>(
272 payload.clone(),
273 )?)
274 .context("failed to render config flow payload")?;
275 return Ok(json_str);
276 }
277
278 if let Some(next) = mapping
280 .get(serde_yaml::Value::String("routing".to_string(), None))
281 .and_then(|r| r.as_sequence())
282 .and_then(|seq| seq.first())
283 .and_then(|entry| {
284 entry
285 .as_mapping()
286 .and_then(|m| m.get(serde_yaml::Value::String("to".to_string(), None)))
287 .and_then(|v| v.as_str())
288 })
289 {
290 current = next.to_string();
291 continue;
292 }
293
294 bail!("config flow ended without producing template or payload");
295 }
296}