use std::io::Read;
use serde::Serialize;
use crate::actions::webdev::{SCRIPT_EXEC_ROUTE, SECRET_HEADER};
use crate::client::GatewayApi;
use crate::config::Config;
use crate::error::CoreError;
#[derive(Debug, Serialize)]
pub struct ScriptRunResult {
pub stdout: String,
pub result: serde_json::Value,
#[serde(rename = "elapsedMs")]
pub elapsed_ms: u64,
}
pub async fn script_run(
api: &dyn GatewayApi,
config: &Config,
profile_name: &str,
project: &str,
code: &str,
) -> Result<ScriptRunResult, CoreError> {
let secret = config
.profiles
.get(profile_name)
.and_then(|profile| profile.webdev_secret.clone())
.ok_or_else(|| CoreError::ScriptExecNotConfigured {
profile: profile_name.to_string(),
})?;
api.webdev_route_call(
project,
SCRIPT_EXEC_ROUTE,
&serde_json::json!({"action": "version"}),
&[(SECRET_HEADER, secret.as_str())],
)
.await?;
let data = api
.webdev_route_call(
project,
SCRIPT_EXEC_ROUTE,
&serde_json::json!({"action": "exec", "code": code}),
&[(SECRET_HEADER, secret.as_str())],
)
.await?;
Ok(ScriptRunResult {
stdout: data
.get("stdout")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
result: data
.get("result")
.cloned()
.unwrap_or(serde_json::Value::Null),
elapsed_ms: data
.get("elapsedMs")
.and_then(serde_json::Value::as_u64)
.unwrap_or_default(),
})
}
pub fn read_script_input(code: Option<&str>, file: Option<&str>) -> Result<String, CoreError> {
match (code, file) {
(Some(_), Some(_)) => Err(CoreError::InvalidInput {
reason: "provide exactly one of --code or --file (not both)".to_string(),
}),
(Some(code), None) => Ok(code.to_string()),
(None, Some("-")) => {
let mut buffer = String::new();
std::io::stdin()
.read_to_string(&mut buffer)
.map_err(|err| CoreError::InvalidInput {
reason: format!("cannot read stdin: {err}"),
})?;
Ok(buffer)
}
(None, Some(file)) => {
std::fs::read_to_string(file).map_err(|err| CoreError::InvalidInput {
reason: format!("cannot read {file}: {err}"),
})
}
(None, None) => Err(CoreError::InvalidInput {
reason: "provide the script via --code PY or --file PATH (--file - reads stdin)"
.to_string(),
}),
}
}
#[cfg(test)]
mod tests {
use super::{read_script_input, script_run};
use crate::client::GatewayApi;
use crate::config;
use crate::error::CoreError;
use std::path::PathBuf;
type CallLog = std::sync::Arc<std::sync::Mutex<Vec<(String, serde_json::Value)>>>;
struct ScriptRig {
calls: CallLog,
answers: fn(&str) -> Result<serde_json::Value, CoreError>,
}
#[async_trait::async_trait]
impl GatewayApi for ScriptRig {
async fn bundle_generate(
&self,
) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
unreachable!("not part of this action")
}
async fn bundle_status(
&self,
) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
unreachable!("not part of this action")
}
async fn bundle_download(
&self,
_out: &std::path::Path,
) -> Result<crate::client::projects::ExportMeta, CoreError> {
unreachable!("not part of this action")
}
async fn tag_provider_list(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<
crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
CoreError,
> {
unreachable!("not part of this action")
}
async fn tag_provider_find(
&self,
_name: &str,
) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
unreachable!("not part of this action")
}
async fn tag_provider_create(
&self,
_body: &[crate::client::tags::TagProviderCreate],
) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn tag_provider_delete(
&self,
_name: &str,
_signature: &str,
) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn webdev_route_call(
&self,
_project: &str,
_route: &str,
body: &serde_json::Value,
_extra_headers: &[(&str, &str)],
) -> Result<serde_json::Value, CoreError> {
let action = body["action"].as_str().unwrap_or_default().to_string();
self.calls
.lock()
.expect("calls lock")
.push((action.clone(), body.clone()));
(self.answers)(&action)
}
async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
unreachable!("not part of this action")
}
async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
unreachable!("not part of this action")
}
async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
unreachable!("not part of this action")
}
async fn modules(
&self,
_quarantined: bool,
_query: &crate::client::query::ListQuery,
) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
{
unreachable!("not part of this action")
}
async fn metrics_current(
&self,
) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
unreachable!("not part of this action")
}
async fn metrics_historic(
&self,
) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
unreachable!("not part of this action")
}
async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
unreachable!("not part of this action")
}
async fn designers(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<
crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
CoreError,
> {
unreachable!("not part of this action")
}
async fn perspective_sessions(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<
crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
CoreError,
> {
unreachable!("not part of this action")
}
async fn vision_clients(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<
crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
CoreError,
> {
unreachable!("not part of this action")
}
async fn terminate_perspective_session(
&self,
_id: &str,
_message: Option<&str>,
) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn database_connections(
&self,
) -> Result<
crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
CoreError,
> {
unreachable!("not part of this action")
}
async fn opc_connections(
&self,
) -> Result<
crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
CoreError,
> {
unreachable!("not part of this action")
}
async fn logs(
&self,
_filter: &crate::client::logs::LogQuery,
) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
{
unreachable!("not part of this action")
}
async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
unreachable!("not part of this action")
}
async fn loggers(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
{
unreachable!("not part of this action")
}
async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn reset_logger_levels(&self) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn restart(&self) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn scan_projects(&self) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn security_properties(
&self,
) -> Result<crate::client::restart::SecurityProperties, CoreError> {
unreachable!("not part of this action")
}
async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
unreachable!("not part of this action")
}
async fn webdev_route_probe(
&self,
_project: &str,
_route: &str,
_extra_headers: &[(&str, &str)],
) -> Result<crate::client::webdev::RouteProbe, CoreError> {
unreachable!("not part of this action")
}
async fn projects(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<
crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
CoreError,
> {
unreachable!("not part of this action")
}
async fn project_find(
&self,
_name: &str,
) -> Result<crate::client::projects::ProjectRecord, CoreError> {
unreachable!("not part of this action")
}
async fn project_create(
&self,
_body: &crate::client::projects::ProjectCreate,
) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn project_modify(
&self,
_name: &str,
_body: &crate::client::projects::ProjectModify,
) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn project_export_to_file(
&self,
_name: &str,
_out: &std::path::Path,
) -> Result<crate::client::projects::ExportMeta, CoreError> {
unreachable!("not part of this action")
}
async fn project_import(
&self,
_name: &str,
_zip: Vec<u8>,
_overwrite: bool,
) -> Result<crate::client::projects::ImportOutcome, CoreError> {
unreachable!("not part of this action")
}
async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
unreachable!("not part of this action")
}
async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
unreachable!("not part of this action")
}
async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
unreachable!("not part of this action")
}
async fn backup_download(
&self,
_out: &std::path::Path,
_backup_type: crate::client::backup::BackupType,
) -> Result<crate::client::projects::ExportMeta, CoreError> {
unreachable!("not part of this action")
}
async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_history(
&self,
_limit: Option<u32>,
_search: Option<&str>,
) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
{
unreachable!("not part of this action")
}
async fn eam_task_definitions(
&self,
) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
{
unreachable!("not part of this action")
}
async fn eam_task_find(
&self,
_name: &str,
) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_tasks_scheduled(
&self,
_running: bool,
) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_modify(
&self,
_definition: &serde_json::Value,
) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_delete(
&self,
_name: &str,
_signature: &str,
_confirm: bool,
) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
unreachable!("not part of this action")
}
async fn api_call(
&self,
_call: &crate::client::apicall::ApiCallRequest,
) -> Result<crate::client::apicall::ApiCallData, CoreError> {
unreachable!("not part of this action")
}
async fn license_status(
&self,
) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
unreachable!("not part of this action")
}
async fn redundancy_status(
&self,
) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
unreachable!("not part of this action")
}
async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
unreachable!("not part of this action")
}
}
fn temp_config(secret: Option<&str>) -> (tempfile::TempDir, config::Config, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
let secret_line = secret
.map(|secret| format!("webdev_secret = \"{secret}\"\n"))
.unwrap_or_default();
std::fs::write(
&path,
format!("active = \"dev\"\n\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n{secret_line}"),
)
.expect("write config");
let config = config::load(&path).expect("config loads");
(dir, config, path)
}
fn rig(answers: fn(&str) -> Result<serde_json::Value, CoreError>) -> (ScriptRig, CallLog) {
let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
(
ScriptRig {
calls: std::sync::Arc::clone(&calls),
answers,
},
calls,
)
}
#[tokio::test]
async fn missing_secret_refuses_before_any_call() {
let (_dir, config, _path) = temp_config(None);
let (double, calls) = rig(|_| unreachable!("the gate refuses before any call"));
let err = script_run(&double, &config, "dev", "ign-cli", "2+2")
.await
.expect_err("no secret refuses");
assert_eq!(err.code(), "script_exec_not_configured");
assert_eq!(err.exit_code(), 6);
assert!(
err.hint()
.unwrap()
.contains("ign webdev deploy --with-script-exec"),
"hint names the deploy flag: {:?}",
err.hint()
);
assert!(
calls.lock().expect("calls lock").is_empty(),
"zero route calls"
);
}
#[tokio::test]
async fn success_round_probes_then_execs_and_maps_the_envelope() {
let (_dir, config, _path) = temp_config(Some("aabbcc"));
let (double, calls) = rig(|action| {
Ok(match action {
"version" => serde_json::json!({"routeVersion": "1.0.0", "minCli": "1.0"}),
_ => serde_json::json!({
"stdout": "hello\n",
"result": 4,
"elapsedMs": 12,
}),
})
});
let result = script_run(&double, &config, "dev", "ign-cli", "print 'hello'\n2+2")
.await
.expect("exec succeeds");
assert_eq!(result.stdout, "hello\n");
assert_eq!(result.result, serde_json::json!(4));
assert_eq!(result.elapsed_ms, 12);
let calls = calls.lock().expect("calls lock");
assert_eq!(calls.len(), 2, "exactly probe + exec");
assert_eq!(calls[0].0, "version");
assert_eq!(calls[1].0, "exec");
assert_eq!(
calls[1].1["code"], "print 'hello'\n2+2",
"code rides verbatim"
);
let serialized = serde_json::to_value(&result).expect("serializes");
assert_eq!(serialized["stdout"], "hello\n");
assert_eq!(serialized["result"], 4);
assert_eq!(serialized["elapsedMs"], 12);
let mut keys: Vec<&str> = serialized
.as_object()
.expect("object")
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(keys, vec!["elapsedMs", "result", "stdout"]);
}
#[tokio::test]
async fn absent_answer_fields_default_but_keys_ride() {
let (_dir, config, _path) = temp_config(Some("aabbcc"));
let (double, _calls) = rig(|_| Ok(serde_json::json!({})));
let result = script_run(&double, &config, "dev", "ign-cli", "pass")
.await
.expect("an empty object still answers");
assert_eq!(result.stdout, "");
assert_eq!(result.result, serde_json::Value::Null);
assert_eq!(result.elapsed_ms, 0);
}
#[tokio::test]
async fn probe_denial_surfaces_honestly_without_exec() {
let (_dir, config, _path) = temp_config(Some("stale"));
let (double, calls) = rig(|action| match action {
"version" => Err(CoreError::WebdevRouteError {
code: "secret_mismatch".to_string(),
message: "scriptExec secret mismatch".to_string(),
endpoint: Some("/system/webdev/ign-cli/cli/scriptExec".to_string()),
}),
_ => unreachable!("exec must not fire after a probe denial"),
});
let err = script_run(&double, &config, "dev", "ign-cli", "2+2")
.await
.expect_err("mismatch refuses");
assert_eq!(err.code(), "webdev_route_error");
assert_eq!(err.exit_code(), 6);
assert!(
err.hint().unwrap().contains("--rotate-secret"),
"the existing hint carries the redeploy/rotate advice: {:?}",
err.hint()
);
let calls = calls.lock().expect("calls lock");
assert_eq!(calls.len(), 1, "only the probe ran");
assert_eq!(calls[0].0, "version");
}
#[test]
fn read_script_input_resolves_the_three_forms() {
assert_eq!(read_script_input(Some("2+2"), None).expect("code"), "2+2");
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("snippet.py");
std::fs::write(&file, "print 'hi'\n").expect("write snippet");
assert_eq!(
read_script_input(None, file.to_str()).expect("file"),
"print 'hi'\n"
);
let err = read_script_input(Some("2+2"), file.to_str()).expect_err("both refuse");
assert_eq!(err.code(), "invalid_input");
assert_eq!(err.exit_code(), 2);
assert!(
err.to_string().contains("--code") && err.to_string().contains("--file"),
"reason names both flags: {err}"
);
let err = read_script_input(None, None).expect_err("neither refuses");
assert_eq!(err.code(), "invalid_input");
assert!(err.to_string().contains("--file -"), "stdin named: {err}");
let err = read_script_input(None, Some("/nonexistent/snippet.py")).expect_err("miss");
assert_eq!(err.code(), "invalid_input");
assert!(
err.to_string().contains("/nonexistent/snippet.py"),
"reason names the file: {err}"
);
}
}