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