1use std::io::Read;
48
49use serde::Serialize;
50
51use crate::actions::webdev::{SCRIPT_EXEC_ROUTE, SECRET_HEADER};
52use crate::client::GatewayApi;
53use crate::config::Config;
54use crate::error::CoreError;
55
56#[derive(Debug, Serialize)]
61pub struct ScriptRunResult {
62 pub stdout: String,
65 pub result: serde_json::Value,
69 #[serde(rename = "elapsedMs")]
73 pub elapsed_ms: u64,
74}
75
76pub async fn script_run(
86 api: &dyn GatewayApi,
87 config: &Config,
88 profile_name: &str,
89 project: &str,
90 code: &str,
91) -> Result<ScriptRunResult, CoreError> {
92 let secret = config
96 .profiles
97 .get(profile_name)
98 .and_then(|profile| profile.webdev_secret.clone())
99 .ok_or_else(|| CoreError::ScriptExecNotConfigured {
100 profile: profile_name.to_string(),
101 })?;
102
103 api.webdev_route_call(
109 project,
110 SCRIPT_EXEC_ROUTE,
111 &serde_json::json!({"action": "version"}),
112 &[(SECRET_HEADER, secret.as_str())],
113 )
114 .await?;
115
116 let data = api
120 .webdev_route_call(
121 project,
122 SCRIPT_EXEC_ROUTE,
123 &serde_json::json!({"action": "exec", "code": code}),
124 &[(SECRET_HEADER, secret.as_str())],
125 )
126 .await?;
127
128 Ok(ScriptRunResult {
129 stdout: data
130 .get("stdout")
131 .and_then(serde_json::Value::as_str)
132 .unwrap_or_default()
133 .to_string(),
134 result: data
135 .get("result")
136 .cloned()
137 .unwrap_or(serde_json::Value::Null),
138 elapsed_ms: data
139 .get("elapsedMs")
140 .and_then(serde_json::Value::as_u64)
141 .unwrap_or_default(),
142 })
143}
144
145pub fn read_script_input(code: Option<&str>, file: Option<&str>) -> Result<String, CoreError> {
154 match (code, file) {
155 (Some(_), Some(_)) => Err(CoreError::InvalidInput {
156 reason: "provide exactly one of --code or --file (not both)".to_string(),
157 }),
158 (Some(code), None) => Ok(code.to_string()),
159 (None, Some("-")) => {
160 let mut buffer = String::new();
161 std::io::stdin()
162 .read_to_string(&mut buffer)
163 .map_err(|err| CoreError::InvalidInput {
164 reason: format!("cannot read stdin: {err}"),
165 })?;
166 Ok(buffer)
167 }
168 (None, Some(file)) => {
169 std::fs::read_to_string(file).map_err(|err| CoreError::InvalidInput {
170 reason: format!("cannot read {file}: {err}"),
171 })
172 }
173 (None, None) => Err(CoreError::InvalidInput {
174 reason: "provide the script via --code PY or --file PATH (--file - reads stdin)"
175 .to_string(),
176 }),
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::{read_script_input, script_run};
183 use crate::client::GatewayApi;
184 use crate::config;
185 use crate::error::CoreError;
186 use std::path::PathBuf;
187
188 type CallLog = std::sync::Arc<std::sync::Mutex<Vec<(String, serde_json::Value)>>>;
191
192 struct ScriptRig {
198 calls: CallLog,
199 answers: fn(&str) -> Result<serde_json::Value, CoreError>,
200 }
201
202 #[async_trait::async_trait]
203 impl GatewayApi for ScriptRig {
204 async fn tag_provider_list(
205 &self,
206 _query: &crate::client::query::ListQuery,
207 ) -> Result<
208 crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
209 CoreError,
210 > {
211 unreachable!("not part of this action")
212 }
213 async fn tag_provider_find(
214 &self,
215 _name: &str,
216 ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
217 unreachable!("not part of this action")
218 }
219 async fn tag_provider_create(
220 &self,
221 _body: &[crate::client::tags::TagProviderCreate],
222 ) -> Result<(), CoreError> {
223 unreachable!("not part of this action")
224 }
225 async fn tag_provider_delete(
226 &self,
227 _name: &str,
228 _signature: &str,
229 ) -> Result<(), CoreError> {
230 unreachable!("not part of this action")
231 }
232 async fn webdev_route_call(
233 &self,
234 _project: &str,
235 _route: &str,
236 body: &serde_json::Value,
237 _extra_headers: &[(&str, &str)],
238 ) -> Result<serde_json::Value, CoreError> {
239 let action = body["action"].as_str().unwrap_or_default().to_string();
240 self.calls
241 .lock()
242 .expect("calls lock")
243 .push((action.clone(), body.clone()));
244 (self.answers)(&action)
245 }
246 async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
247 unreachable!("not part of this action")
248 }
249 async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
250 unreachable!("not part of this action")
251 }
252 async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
253 unreachable!("not part of this action")
254 }
255 async fn modules(
256 &self,
257 _quarantined: bool,
258 _query: &crate::client::query::ListQuery,
259 ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
260 {
261 unreachable!("not part of this action")
262 }
263 async fn metrics_current(
264 &self,
265 ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
266 unreachable!("not part of this action")
267 }
268 async fn metrics_historic(
269 &self,
270 ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
271 unreachable!("not part of this action")
272 }
273 async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
274 unreachable!("not part of this action")
275 }
276 async fn designers(
277 &self,
278 _query: &crate::client::query::ListQuery,
279 ) -> Result<
280 crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
281 CoreError,
282 > {
283 unreachable!("not part of this action")
284 }
285 async fn perspective_sessions(
286 &self,
287 _query: &crate::client::query::ListQuery,
288 ) -> Result<
289 crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
290 CoreError,
291 > {
292 unreachable!("not part of this action")
293 }
294 async fn vision_clients(
295 &self,
296 _query: &crate::client::query::ListQuery,
297 ) -> Result<
298 crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
299 CoreError,
300 > {
301 unreachable!("not part of this action")
302 }
303 async fn terminate_perspective_session(
304 &self,
305 _id: &str,
306 _message: Option<&str>,
307 ) -> Result<(), CoreError> {
308 unreachable!("not part of this action")
309 }
310 async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
311 unreachable!("not part of this action")
312 }
313 async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
314 unreachable!("not part of this action")
315 }
316 async fn database_connections(
317 &self,
318 ) -> Result<
319 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
320 CoreError,
321 > {
322 unreachable!("not part of this action")
323 }
324 async fn opc_connections(
325 &self,
326 ) -> Result<
327 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
328 CoreError,
329 > {
330 unreachable!("not part of this action")
331 }
332 async fn logs(
333 &self,
334 _filter: &crate::client::logs::LogQuery,
335 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
336 {
337 unreachable!("not part of this action")
338 }
339 async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
340 unreachable!("not part of this action")
341 }
342 async fn loggers(
343 &self,
344 _query: &crate::client::query::ListQuery,
345 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
346 {
347 unreachable!("not part of this action")
348 }
349 async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
350 unreachable!("not part of this action")
351 }
352 async fn reset_logger_levels(&self) -> Result<(), CoreError> {
353 unreachable!("not part of this action")
354 }
355 async fn restart(&self) -> Result<(), CoreError> {
356 unreachable!("not part of this action")
357 }
358 async fn scan_projects(&self) -> Result<(), CoreError> {
359 unreachable!("not part of this action")
360 }
361 async fn security_properties(
362 &self,
363 ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
364 unreachable!("not part of this action")
365 }
366 async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
367 unreachable!("not part of this action")
368 }
369 async fn webdev_route_probe(
370 &self,
371 _project: &str,
372 _route: &str,
373 _extra_headers: &[(&str, &str)],
374 ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
375 unreachable!("not part of this action")
376 }
377 async fn projects(
378 &self,
379 _query: &crate::client::query::ListQuery,
380 ) -> Result<
381 crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
382 CoreError,
383 > {
384 unreachable!("not part of this action")
385 }
386 async fn project_find(
387 &self,
388 _name: &str,
389 ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
390 unreachable!("not part of this action")
391 }
392 async fn project_create(
393 &self,
394 _body: &crate::client::projects::ProjectCreate,
395 ) -> Result<(), CoreError> {
396 unreachable!("not part of this action")
397 }
398 async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
399 unreachable!("not part of this action")
400 }
401 async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
402 unreachable!("not part of this action")
403 }
404 async fn project_modify(
405 &self,
406 _name: &str,
407 _body: &crate::client::projects::ProjectModify,
408 ) -> Result<(), CoreError> {
409 unreachable!("not part of this action")
410 }
411 async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
412 unreachable!("not part of this action")
413 }
414 async fn project_export_to_file(
415 &self,
416 _name: &str,
417 _out: &std::path::Path,
418 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
419 unreachable!("not part of this action")
420 }
421 async fn project_import(
422 &self,
423 _name: &str,
424 _zip: Vec<u8>,
425 _overwrite: bool,
426 ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
427 unreachable!("not part of this action")
428 }
429 async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
430 unreachable!("not part of this action")
431 }
432 async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
433 unreachable!("not part of this action")
434 }
435 async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
436 unreachable!("not part of this action")
437 }
438 async fn backup_download(
439 &self,
440 _out: &std::path::Path,
441 _backup_type: crate::client::backup::BackupType,
442 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
443 unreachable!("not part of this action")
444 }
445 async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
446 unreachable!("not part of this action")
447 }
448 async fn eam_task_history(
449 &self,
450 _limit: Option<u32>,
451 _search: Option<&str>,
452 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
453 {
454 unreachable!("not part of this action")
455 }
456 async fn eam_task_definitions(
457 &self,
458 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
459 {
460 unreachable!("not part of this action")
461 }
462 async fn eam_task_find(
463 &self,
464 _name: &str,
465 ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
466 unreachable!("not part of this action")
467 }
468 async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
469 unreachable!("not part of this action")
470 }
471 async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
472 unreachable!("not part of this action")
473 }
474 }
475
476 fn temp_config(secret: Option<&str>) -> (tempfile::TempDir, config::Config, PathBuf) {
479 let dir = tempfile::tempdir().expect("tempdir");
480 let path = dir.path().join("config.toml");
481 let secret_line = secret
482 .map(|secret| format!("webdev_secret = \"{secret}\"\n"))
483 .unwrap_or_default();
484 std::fs::write(
485 &path,
486 format!("active = \"dev\"\n\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n{secret_line}"),
487 )
488 .expect("write config");
489 let config = config::load(&path).expect("config loads");
490 (dir, config, path)
491 }
492
493 fn rig(answers: fn(&str) -> Result<serde_json::Value, CoreError>) -> (ScriptRig, CallLog) {
496 let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
497 (
498 ScriptRig {
499 calls: std::sync::Arc::clone(&calls),
500 answers,
501 },
502 calls,
503 )
504 }
505
506 #[tokio::test]
509 async fn missing_secret_refuses_before_any_call() {
510 let (_dir, config, _path) = temp_config(None);
511 let (double, calls) = rig(|_| unreachable!("the gate refuses before any call"));
512 let err = script_run(&double, &config, "dev", "ign-cli", "2+2")
513 .await
514 .expect_err("no secret refuses");
515 assert_eq!(err.code(), "script_exec_not_configured");
516 assert_eq!(err.exit_code(), 6);
517 assert!(
518 err.hint()
519 .unwrap()
520 .contains("ign webdev deploy --with-script-exec"),
521 "hint names the deploy flag: {:?}",
522 err.hint()
523 );
524 assert!(
525 calls.lock().expect("calls lock").is_empty(),
526 "zero route calls"
527 );
528 }
529
530 #[tokio::test]
534 async fn success_round_probes_then_execs_and_maps_the_envelope() {
535 let (_dir, config, _path) = temp_config(Some("aabbcc"));
536 let (double, calls) = rig(|action| {
537 Ok(match action {
538 "version" => serde_json::json!({"routeVersion": "1.0.0", "minCli": "1.0"}),
539 _ => serde_json::json!({
540 "stdout": "hello\n",
541 "result": 4,
542 "elapsedMs": 12,
543 }),
544 })
545 });
546 let result = script_run(&double, &config, "dev", "ign-cli", "print 'hello'\n2+2")
547 .await
548 .expect("exec succeeds");
549 assert_eq!(result.stdout, "hello\n");
550 assert_eq!(result.result, serde_json::json!(4));
551 assert_eq!(result.elapsed_ms, 12);
552
553 let calls = calls.lock().expect("calls lock");
554 assert_eq!(calls.len(), 2, "exactly probe + exec");
555 assert_eq!(calls[0].0, "version");
556 assert_eq!(calls[1].0, "exec");
557 assert_eq!(
558 calls[1].1["code"], "print 'hello'\n2+2",
559 "code rides verbatim"
560 );
561
562 let serialized = serde_json::to_value(&result).expect("serializes");
564 assert_eq!(serialized["stdout"], "hello\n");
565 assert_eq!(serialized["result"], 4);
566 assert_eq!(serialized["elapsedMs"], 12);
567 let mut keys: Vec<&str> = serialized
568 .as_object()
569 .expect("object")
570 .keys()
571 .map(String::as_str)
572 .collect();
573 keys.sort_unstable();
574 assert_eq!(keys, vec!["elapsedMs", "result", "stdout"]);
575 }
576
577 #[tokio::test]
581 async fn absent_answer_fields_default_but_keys_ride() {
582 let (_dir, config, _path) = temp_config(Some("aabbcc"));
583 let (double, _calls) = rig(|_| Ok(serde_json::json!({})));
584 let result = script_run(&double, &config, "dev", "ign-cli", "pass")
585 .await
586 .expect("an empty object still answers");
587 assert_eq!(result.stdout, "");
588 assert_eq!(result.result, serde_json::Value::Null);
589 assert_eq!(result.elapsed_ms, 0);
590 }
591
592 #[tokio::test]
596 async fn probe_denial_surfaces_honestly_without_exec() {
597 let (_dir, config, _path) = temp_config(Some("stale"));
598 let (double, calls) = rig(|action| match action {
599 "version" => Err(CoreError::WebdevRouteError {
600 code: "secret_mismatch".to_string(),
601 message: "scriptExec secret mismatch".to_string(),
602 endpoint: Some("/system/webdev/ign-cli/cli/scriptExec".to_string()),
603 }),
604 _ => unreachable!("exec must not fire after a probe denial"),
605 });
606 let err = script_run(&double, &config, "dev", "ign-cli", "2+2")
607 .await
608 .expect_err("mismatch refuses");
609 assert_eq!(err.code(), "webdev_route_error");
610 assert_eq!(err.exit_code(), 6);
611 assert!(
612 err.hint().unwrap().contains("--rotate-secret"),
613 "the existing hint carries the redeploy/rotate advice: {:?}",
614 err.hint()
615 );
616 let calls = calls.lock().expect("calls lock");
617 assert_eq!(calls.len(), 1, "only the probe ran");
618 assert_eq!(calls[0].0, "version");
619 }
620
621 #[test]
625 fn read_script_input_resolves_the_three_forms() {
626 assert_eq!(read_script_input(Some("2+2"), None).expect("code"), "2+2");
628 let dir = tempfile::tempdir().expect("tempdir");
630 let file = dir.path().join("snippet.py");
631 std::fs::write(&file, "print 'hi'\n").expect("write snippet");
632 assert_eq!(
633 read_script_input(None, file.to_str()).expect("file"),
634 "print 'hi'\n"
635 );
636 let err = read_script_input(Some("2+2"), file.to_str()).expect_err("both refuse");
638 assert_eq!(err.code(), "invalid_input");
639 assert_eq!(err.exit_code(), 2);
640 assert!(
641 err.to_string().contains("--code") && err.to_string().contains("--file"),
642 "reason names both flags: {err}"
643 );
644 let err = read_script_input(None, None).expect_err("neither refuses");
646 assert_eq!(err.code(), "invalid_input");
647 assert!(err.to_string().contains("--file -"), "stdin named: {err}");
648 let err = read_script_input(None, Some("/nonexistent/snippet.py")).expect_err("miss");
650 assert_eq!(err.code(), "invalid_input");
651 assert!(
652 err.to_string().contains("/nonexistent/snippet.py"),
653 "reason names the file: {err}"
654 );
655 }
656}