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 uninstall_module(
205 &self,
206 _gateway_module_id: &str,
207 _module_id: &str,
208 ) -> Result<(), CoreError> {
209 unreachable!("not part of this action")
210 }
211 async fn bundle_generate(
212 &self,
213 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
214 unreachable!("not part of this action")
215 }
216 async fn bundle_status(
217 &self,
218 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
219 unreachable!("not part of this action")
220 }
221 async fn bundle_download(
222 &self,
223 _out: &std::path::Path,
224 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
225 unreachable!("not part of this action")
226 }
227 async fn tag_provider_list(
228 &self,
229 _query: &crate::client::query::ListQuery,
230 ) -> Result<
231 crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
232 CoreError,
233 > {
234 unreachable!("not part of this action")
235 }
236 async fn tag_provider_find(
237 &self,
238 _name: &str,
239 ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
240 unreachable!("not part of this action")
241 }
242 async fn tag_provider_create(
243 &self,
244 _body: &[crate::client::tags::TagProviderCreate],
245 ) -> Result<(), CoreError> {
246 unreachable!("not part of this action")
247 }
248 async fn tag_provider_delete(
249 &self,
250 _name: &str,
251 _signature: &str,
252 ) -> Result<(), CoreError> {
253 unreachable!("not part of this action")
254 }
255 async fn webdev_route_call(
256 &self,
257 _project: &str,
258 _route: &str,
259 body: &serde_json::Value,
260 _extra_headers: &[(&str, &str)],
261 ) -> Result<serde_json::Value, CoreError> {
262 let action = body["action"].as_str().unwrap_or_default().to_string();
263 self.calls
264 .lock()
265 .expect("calls lock")
266 .push((action.clone(), body.clone()));
267 (self.answers)(&action)
268 }
269 async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
270 unreachable!("not part of this action")
271 }
272 async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
273 unreachable!("not part of this action")
274 }
275 async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
276 unreachable!("not part of this action")
277 }
278 async fn modules(
279 &self,
280 _quarantined: bool,
281 _query: &crate::client::query::ListQuery,
282 ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
283 {
284 unreachable!("not part of this action")
285 }
286 async fn metrics_current(
287 &self,
288 ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
289 unreachable!("not part of this action")
290 }
291 async fn metrics_historic(
292 &self,
293 ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
294 unreachable!("not part of this action")
295 }
296 async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
297 unreachable!("not part of this action")
298 }
299 async fn designers(
300 &self,
301 _query: &crate::client::query::ListQuery,
302 ) -> Result<
303 crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
304 CoreError,
305 > {
306 unreachable!("not part of this action")
307 }
308 async fn perspective_sessions(
309 &self,
310 _query: &crate::client::query::ListQuery,
311 ) -> Result<
312 crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
313 CoreError,
314 > {
315 unreachable!("not part of this action")
316 }
317 async fn vision_clients(
318 &self,
319 _query: &crate::client::query::ListQuery,
320 ) -> Result<
321 crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
322 CoreError,
323 > {
324 unreachable!("not part of this action")
325 }
326 async fn terminate_perspective_session(
327 &self,
328 _id: &str,
329 _message: Option<&str>,
330 ) -> Result<(), CoreError> {
331 unreachable!("not part of this action")
332 }
333 async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
334 unreachable!("not part of this action")
335 }
336 async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
337 unreachable!("not part of this action")
338 }
339 async fn database_connections(
340 &self,
341 ) -> Result<
342 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
343 CoreError,
344 > {
345 unreachable!("not part of this action")
346 }
347 async fn opc_connections(
348 &self,
349 ) -> Result<
350 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
351 CoreError,
352 > {
353 unreachable!("not part of this action")
354 }
355 async fn logs(
356 &self,
357 _filter: &crate::client::logs::LogQuery,
358 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
359 {
360 unreachable!("not part of this action")
361 }
362 async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
363 unreachable!("not part of this action")
364 }
365 async fn loggers(
366 &self,
367 _query: &crate::client::query::ListQuery,
368 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
369 {
370 unreachable!("not part of this action")
371 }
372 async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
373 unreachable!("not part of this action")
374 }
375 async fn reset_logger_levels(&self) -> Result<(), CoreError> {
376 unreachable!("not part of this action")
377 }
378 async fn restart(&self) -> Result<(), CoreError> {
379 unreachable!("not part of this action")
380 }
381 async fn scan_projects(&self) -> Result<(), CoreError> {
382 unreachable!("not part of this action")
383 }
384 async fn security_properties(
385 &self,
386 ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
387 unreachable!("not part of this action")
388 }
389 async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
390 unreachable!("not part of this action")
391 }
392 async fn webdev_route_probe(
393 &self,
394 _project: &str,
395 _route: &str,
396 _extra_headers: &[(&str, &str)],
397 ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
398 unreachable!("not part of this action")
399 }
400 async fn projects(
401 &self,
402 _query: &crate::client::query::ListQuery,
403 ) -> Result<
404 crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
405 CoreError,
406 > {
407 unreachable!("not part of this action")
408 }
409 async fn project_find(
410 &self,
411 _name: &str,
412 ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
413 unreachable!("not part of this action")
414 }
415 async fn project_create(
416 &self,
417 _body: &crate::client::projects::ProjectCreate,
418 ) -> Result<(), CoreError> {
419 unreachable!("not part of this action")
420 }
421 async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
422 unreachable!("not part of this action")
423 }
424 async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
425 unreachable!("not part of this action")
426 }
427 async fn project_modify(
428 &self,
429 _name: &str,
430 _body: &crate::client::projects::ProjectModify,
431 ) -> Result<(), CoreError> {
432 unreachable!("not part of this action")
433 }
434 async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
435 unreachable!("not part of this action")
436 }
437 async fn project_export_to_file(
438 &self,
439 _name: &str,
440 _out: &std::path::Path,
441 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
442 unreachable!("not part of this action")
443 }
444 async fn project_import(
445 &self,
446 _name: &str,
447 _zip: Vec<u8>,
448 _overwrite: bool,
449 ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
450 unreachable!("not part of this action")
451 }
452 async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
453 unreachable!("not part of this action")
454 }
455 async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
456 unreachable!("not part of this action")
457 }
458 async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
459 unreachable!("not part of this action")
460 }
461 async fn backup_download(
462 &self,
463 _out: &std::path::Path,
464 _backup_type: crate::client::backup::BackupType,
465 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
466 unreachable!("not part of this action")
467 }
468 async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
469 unreachable!("not part of this action")
470 }
471 async fn eam_task_history(
472 &self,
473 _limit: Option<u32>,
474 _search: Option<&str>,
475 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
476 {
477 unreachable!("not part of this action")
478 }
479 async fn eam_task_definitions(
480 &self,
481 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
482 {
483 unreachable!("not part of this action")
484 }
485 async fn eam_task_find(
486 &self,
487 _name: &str,
488 ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
489 unreachable!("not part of this action")
490 }
491 async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
492 unreachable!("not part of this action")
493 }
494 async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
495 unreachable!("not part of this action")
496 }
497 async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
498 unreachable!("not part of this action")
499 }
500 async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
501 unreachable!("not part of this action")
502 }
503 async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
504 unreachable!("not part of this action")
505 }
506 async fn eam_tasks_scheduled(
507 &self,
508 _running: bool,
509 ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
510 unreachable!("not part of this action")
511 }
512 async fn eam_task_modify(
513 &self,
514 _definition: &serde_json::Value,
515 ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
516 unreachable!("not part of this action")
517 }
518 async fn eam_task_delete(
519 &self,
520 _name: &str,
521 _signature: &str,
522 _confirm: bool,
523 ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
524 unreachable!("not part of this action")
525 }
526 async fn api_call(
527 &self,
528 _call: &crate::client::apicall::ApiCallRequest,
529 ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
530 unreachable!("not part of this action")
531 }
532 async fn license_status(
533 &self,
534 ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
535 unreachable!("not part of this action")
536 }
537 async fn redundancy_status(
538 &self,
539 ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
540 unreachable!("not part of this action")
541 }
542 async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
543 unreachable!("not part of this action")
544 }
545 }
546
547 fn temp_config(secret: Option<&str>) -> (tempfile::TempDir, config::Config, PathBuf) {
550 let dir = tempfile::tempdir().expect("tempdir");
551 let path = dir.path().join("config.toml");
552 let secret_line = secret
553 .map(|secret| format!("webdev_secret = \"{secret}\"\n"))
554 .unwrap_or_default();
555 std::fs::write(
556 &path,
557 format!("active = \"dev\"\n\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n{secret_line}"),
558 )
559 .expect("write config");
560 let config = config::load(&path).expect("config loads");
561 (dir, config, path)
562 }
563
564 fn rig(answers: fn(&str) -> Result<serde_json::Value, CoreError>) -> (ScriptRig, CallLog) {
567 let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
568 (
569 ScriptRig {
570 calls: std::sync::Arc::clone(&calls),
571 answers,
572 },
573 calls,
574 )
575 }
576
577 #[tokio::test]
580 async fn missing_secret_refuses_before_any_call() {
581 let (_dir, config, _path) = temp_config(None);
582 let (double, calls) = rig(|_| unreachable!("the gate refuses before any call"));
583 let err = script_run(&double, &config, "dev", "ign-cli", "2+2")
584 .await
585 .expect_err("no secret refuses");
586 assert_eq!(err.code(), "script_exec_not_configured");
587 assert_eq!(err.exit_code(), 6);
588 assert!(
589 err.hint()
590 .unwrap()
591 .contains("ign webdev deploy --with-script-exec"),
592 "hint names the deploy flag: {:?}",
593 err.hint()
594 );
595 assert!(
596 calls.lock().expect("calls lock").is_empty(),
597 "zero route calls"
598 );
599 }
600
601 #[tokio::test]
605 async fn success_round_probes_then_execs_and_maps_the_envelope() {
606 let (_dir, config, _path) = temp_config(Some("aabbcc"));
607 let (double, calls) = rig(|action| {
608 Ok(match action {
609 "version" => serde_json::json!({"routeVersion": "1.0.0", "minCli": "1.0"}),
610 _ => serde_json::json!({
611 "stdout": "hello\n",
612 "result": 4,
613 "elapsedMs": 12,
614 }),
615 })
616 });
617 let result = script_run(&double, &config, "dev", "ign-cli", "print 'hello'\n2+2")
618 .await
619 .expect("exec succeeds");
620 assert_eq!(result.stdout, "hello\n");
621 assert_eq!(result.result, serde_json::json!(4));
622 assert_eq!(result.elapsed_ms, 12);
623
624 let calls = calls.lock().expect("calls lock");
625 assert_eq!(calls.len(), 2, "exactly probe + exec");
626 assert_eq!(calls[0].0, "version");
627 assert_eq!(calls[1].0, "exec");
628 assert_eq!(
629 calls[1].1["code"], "print 'hello'\n2+2",
630 "code rides verbatim"
631 );
632
633 let serialized = serde_json::to_value(&result).expect("serializes");
635 assert_eq!(serialized["stdout"], "hello\n");
636 assert_eq!(serialized["result"], 4);
637 assert_eq!(serialized["elapsedMs"], 12);
638 let mut keys: Vec<&str> = serialized
639 .as_object()
640 .expect("object")
641 .keys()
642 .map(String::as_str)
643 .collect();
644 keys.sort_unstable();
645 assert_eq!(keys, vec!["elapsedMs", "result", "stdout"]);
646 }
647
648 #[tokio::test]
652 async fn absent_answer_fields_default_but_keys_ride() {
653 let (_dir, config, _path) = temp_config(Some("aabbcc"));
654 let (double, _calls) = rig(|_| Ok(serde_json::json!({})));
655 let result = script_run(&double, &config, "dev", "ign-cli", "pass")
656 .await
657 .expect("an empty object still answers");
658 assert_eq!(result.stdout, "");
659 assert_eq!(result.result, serde_json::Value::Null);
660 assert_eq!(result.elapsed_ms, 0);
661 }
662
663 #[tokio::test]
667 async fn probe_denial_surfaces_honestly_without_exec() {
668 let (_dir, config, _path) = temp_config(Some("stale"));
669 let (double, calls) = rig(|action| match action {
670 "version" => Err(CoreError::WebdevRouteError {
671 code: "secret_mismatch".to_string(),
672 message: "scriptExec secret mismatch".to_string(),
673 endpoint: Some("/system/webdev/ign-cli/cli/scriptExec".to_string()),
674 }),
675 _ => unreachable!("exec must not fire after a probe denial"),
676 });
677 let err = script_run(&double, &config, "dev", "ign-cli", "2+2")
678 .await
679 .expect_err("mismatch refuses");
680 assert_eq!(err.code(), "webdev_route_error");
681 assert_eq!(err.exit_code(), 6);
682 assert!(
683 err.hint().unwrap().contains("--rotate-secret"),
684 "the existing hint carries the redeploy/rotate advice: {:?}",
685 err.hint()
686 );
687 let calls = calls.lock().expect("calls lock");
688 assert_eq!(calls.len(), 1, "only the probe ran");
689 assert_eq!(calls[0].0, "version");
690 }
691
692 #[test]
696 fn read_script_input_resolves_the_three_forms() {
697 assert_eq!(read_script_input(Some("2+2"), None).expect("code"), "2+2");
699 let dir = tempfile::tempdir().expect("tempdir");
701 let file = dir.path().join("snippet.py");
702 std::fs::write(&file, "print 'hi'\n").expect("write snippet");
703 assert_eq!(
704 read_script_input(None, file.to_str()).expect("file"),
705 "print 'hi'\n"
706 );
707 let err = read_script_input(Some("2+2"), file.to_str()).expect_err("both refuse");
709 assert_eq!(err.code(), "invalid_input");
710 assert_eq!(err.exit_code(), 2);
711 assert!(
712 err.to_string().contains("--code") && err.to_string().contains("--file"),
713 "reason names both flags: {err}"
714 );
715 let err = read_script_input(None, None).expect_err("neither refuses");
717 assert_eq!(err.code(), "invalid_input");
718 assert!(err.to_string().contains("--file -"), "stdin named: {err}");
719 let err = read_script_input(None, Some("/nonexistent/snippet.py")).expect_err("miss");
721 assert_eq!(err.code(), "invalid_input");
722 assert!(
723 err.to_string().contains("/nonexistent/snippet.py"),
724 "reason names the file: {err}"
725 );
726 }
727}