1use std::path::{Path, PathBuf};
28
29use serde::Serialize;
30
31use crate::client::GatewayApi;
32use crate::client::projects::{ProjectCreate, ProjectModify, ProjectRecord};
33use crate::client::query::ListQuery;
34use crate::error::CoreError;
35
36pub const EXPORT_INCLUDES: &[&str] = &[
41 "views",
42 "scripts",
43 "named-queries",
44 "vision-windows",
45 "perspective-themes-styles",
46 "reporting",
47 "alarm-notification-profiles",
48 "webdev-routes",
49 "translations",
50 "sfc-charts",
51];
52
53pub const EXPORT_EXCLUDES: &[&str] = &[
58 "tag-providers",
59 "tags",
60 "udts",
61 "gateway-config",
62 "database-connections",
63 "users-roles",
64 "alarm-journal",
65 "certificates",
66];
67
68#[derive(Debug, Clone, PartialEq, Serialize)]
72pub struct ExportScope {
73 pub includes: Vec<&'static str>,
75 pub excludes: Vec<&'static str>,
77}
78
79impl ExportScope {
80 pub fn new() -> Self {
82 Self {
83 includes: EXPORT_INCLUDES.to_vec(),
84 excludes: EXPORT_EXCLUDES.to_vec(),
85 }
86 }
87}
88
89impl Default for ExportScope {
90 fn default() -> Self {
91 Self::new()
92 }
93}
94
95pub const IMPORT_MAX_BYTES: usize = 512 * 1024 * 1024;
99
100const ZIP_MAGIC: [u8; 4] = [0x50, 0x4B, 0x03, 0x04];
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
110pub enum CollisionPolicy {
111 Abort,
114 Overwrite,
118}
119
120impl CollisionPolicy {
121 pub fn label(self) -> &'static str {
123 match self {
124 Self::Abort => "abort",
125 Self::Overwrite => "overwrite",
126 }
127 }
128}
129
130fn import_size_error(len: usize) -> Option<CoreError> {
133 (len > IMPORT_MAX_BYTES).then(|| CoreError::InvalidImportFile {
134 reason: format!(
135 "{len} bytes exceeds the {} MB sanity limit",
136 IMPORT_MAX_BYTES / (1024 * 1024)
137 ),
138 })
139}
140
141pub(crate) fn validate_import(zip: &[u8]) -> Result<(), CoreError> {
149 if !zip.starts_with(&ZIP_MAGIC) {
150 return Err(CoreError::InvalidImportFile {
151 reason: "missing ZIP magic (PK\\x03\\x04) — not a project export archive".to_string(),
152 });
153 }
154 if let Some(err) = import_size_error(zip.len()) {
155 return Err(err);
156 }
157 let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip)).map_err(|err| {
166 CoreError::InvalidImportFile {
167 reason: format!("not a readable ZIP archive: {err}"),
168 }
169 })?;
170 for index in 0..archive.len() {
171 let mut file = archive
172 .by_index(index)
173 .map_err(|err| CoreError::InvalidImportFile {
174 reason: format!("cannot read import archive member {index}: {err}"),
175 })?;
176 let name = file.name().to_string();
177 let mut sink = Vec::new();
178 std::io::Read::read_to_end(&mut file, &mut sink).map_err(|err| {
179 CoreError::InvalidImportFile {
180 reason: format!("cannot decompress import member {name:?}: {err}"),
181 }
182 })?;
183 }
184 Ok(())
185}
186
187fn sanitize_basename(raw: &str) -> Option<String> {
192 let name = raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim();
193 if name.is_empty() || name == "." || name == ".." {
194 None
195 } else {
196 Some(name.to_string())
197 }
198}
199
200fn safe_fallback_stem(name: &str) -> String {
204 name.replace(['/', '\\'], "_")
205}
206
207#[derive(Debug, Clone, PartialEq, Serialize)]
209pub struct ProjectSummary {
210 pub name: String,
212 pub title: Option<String>,
214 pub description: Option<String>,
216 pub enabled: bool,
218 pub parent: Option<String>,
220 pub inheritable: Option<bool>,
223}
224
225impl ProjectSummary {
226 fn from_record(record: &ProjectRecord) -> Self {
228 Self {
229 name: record.name.clone(),
230 title: record.title.clone(),
231 description: record.description.clone(),
232 enabled: record.enabled,
233 parent: record.parent.clone(),
234 inheritable: record.inheritable,
235 }
236 }
237}
238
239#[derive(Debug, Serialize)]
241pub struct ProjectsResult {
242 pub projects: Vec<ProjectSummary>,
244}
245
246#[derive(Debug, Default, Clone)]
250pub struct NewOptions {
251 pub enabled: bool,
253 pub title: Option<String>,
255 pub description: Option<String>,
257 pub parent: Option<String>,
259 pub inheritable: Option<bool>,
261}
262
263#[derive(Debug, Default, Clone)]
266pub struct SetOptions {
267 pub title: Option<String>,
269 pub description: Option<String>,
271 pub parent: Option<String>,
273 pub enabled: Option<bool>,
275 pub inheritable: Option<bool>,
277}
278
279impl SetOptions {
280 fn fields_set(&self) -> Vec<String> {
283 let mut fields = Vec::new();
284 if self.title.is_some() {
285 fields.push("title".to_string());
286 }
287 if self.description.is_some() {
288 fields.push("description".to_string());
289 }
290 if self.parent.is_some() {
291 fields.push("parent".to_string());
292 }
293 if self.enabled.is_some() {
294 fields.push("enabled".to_string());
295 }
296 if self.inheritable.is_some() {
297 fields.push("inheritable".to_string());
298 }
299 fields
300 }
301}
302
303#[derive(Debug, Serialize)]
306pub struct ProjectCopyResult {
307 pub from: String,
309 #[serde(flatten)]
311 pub project: ProjectSummary,
312}
313
314#[derive(Debug, Serialize)]
317pub struct ProjectRenameResult {
318 pub previous_name: String,
320 #[serde(flatten)]
322 pub project: ProjectSummary,
323}
324
325#[derive(Debug, Serialize)]
329pub struct ProjectSetResult {
330 #[serde(skip)]
332 pub fields: Vec<String>,
333 #[serde(flatten)]
335 pub project: ProjectSummary,
336}
337
338#[derive(Debug, Serialize)]
340pub struct ProjectDeleteResult {
341 pub deleted: String,
343}
344
345#[derive(Debug, Serialize)]
348pub struct ExportResult {
349 pub project: String,
351 pub file: String,
354 pub bytes: u64,
356 pub scope: ExportScope,
358}
359
360#[derive(Debug, Serialize)]
364pub struct ImportResult {
365 pub name: String,
367 pub collision_policy: String,
369 pub bytes: usize,
371 pub scope: ExportScope,
374 pub outcome: serde_json::Value,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
391pub struct ProjectMetaDelta {
392 pub field: String,
394 pub a: String,
396 pub b: String,
398}
399
400#[derive(Debug, Serialize)]
407pub struct ProjectDiffResult {
408 pub scope: &'static str,
410 pub profile_a: String,
412 pub profile_b: String,
414 pub project: String,
416 pub project_meta: Vec<ProjectMetaDelta>,
419 pub summary: crate::client::resources::DiffSummary,
421 pub entries: Vec<crate::client::resources::MemberDiffEntry>,
423}
424
425pub async fn project_diff(
431 api_a: &dyn GatewayApi,
432 api_b: &dyn GatewayApi,
433 project: &str,
434 profile_a: &str,
435 profile_b: &str,
436) -> Result<ProjectDiffResult, CoreError> {
437 if profile_a == profile_b {
438 return Err(CoreError::InvalidInput {
439 reason: "diffing a profile against itself is a no-op — name two \
440 different profiles"
441 .to_string(),
442 });
443 }
444 let zip_a = crate::actions::resources::export_zip_bytes(api_a, project).await?;
445 let zip_b = crate::actions::resources::export_zip_bytes(api_b, project).await?;
446 let diff = crate::client::resources::diff_members(&zip_a, &zip_b)?;
447 let project_meta = crate::client::resources::project_meta_delta(&zip_a, &zip_b)?
448 .into_iter()
449 .map(|(field, a, b)| ProjectMetaDelta { field, a, b })
450 .collect();
451 Ok(ProjectDiffResult {
452 scope: "project",
453 profile_a: profile_a.to_string(),
454 profile_b: profile_b.to_string(),
455 project: project.to_string(),
456 project_meta,
457 summary: diff.summary,
458 entries: diff.entries,
459 })
460}
461
462#[derive(Debug, Default, Clone)]
466pub struct SyncSelection {
467 pub resources: Vec<String>,
470 pub all_changed: bool,
474}
475
476#[derive(Debug, Serialize)]
480pub struct ProjectSyncResult {
481 pub scope: &'static str,
483 pub profile_a: String,
485 pub profile_b: String,
487 pub project: String,
489 pub synced: Vec<String>,
491 pub removed: Vec<String>,
493}
494
495pub async fn project_sync(
507 api_a: &dyn GatewayApi,
508 api_b: &dyn GatewayApi,
509 project: &str,
510 selection: &SyncSelection,
511 delete: bool,
512 profile_a: &str,
513 profile_b: &str,
514) -> Result<ProjectSyncResult, CoreError> {
515 if selection.resources.is_empty() && !selection.all_changed {
516 return Err(CoreError::InvalidInput {
517 reason: "sync needs a selection — pass --resource PATH (repeatable) \
518 and/or --all-changed"
519 .to_string(),
520 });
521 }
522 let zip_a = crate::actions::resources::export_zip_bytes(api_a, project).await?;
523 let zip_b = crate::actions::resources::export_zip_bytes(api_b, project).await?;
524
525 let mut upserts: Vec<String> = Vec::new();
528 let mut removals: Vec<String> = Vec::new();
529 for path in &selection.resources {
530 match crate::client::resources::read_member(&zip_a, path) {
531 Ok(_) => upserts.push(path.clone()),
532 Err(CoreError::NotFound { .. }) if delete => removals.push(path.clone()),
536 Err(other) => return Err(other),
537 }
538 }
539 if selection.all_changed {
540 for entry in crate::client::resources::diff_members(&zip_a, &zip_b)?.entries {
549 match entry.status {
550 crate::client::resources::MemberStatus::Removed
551 | crate::client::resources::MemberStatus::Changed => {
552 upserts.push(entry.path);
553 }
554 crate::client::resources::MemberStatus::Added if delete => {
555 removals.push(entry.path);
556 }
557 _ => {}
558 }
559 }
560 }
561 upserts.sort();
562 upserts.dedup();
563 removals.sort();
564 removals.dedup();
565
566 let mut surgical = zip_b;
569 for path in &upserts {
570 let bytes = crate::client::resources::read_member(&zip_a, path)?;
571 surgical = crate::client::resources::replace_member(&surgical, path, &bytes)?;
572 }
573 for path in &removals {
574 surgical = crate::client::resources::remove_member(&surgical, path)?;
575 }
576
577 if !upserts.is_empty() || !removals.is_empty() {
582 validate_import(&surgical)?;
583 api_b.project_import(project, surgical, true).await?;
584 }
585 Ok(ProjectSyncResult {
586 scope: "project",
587 profile_a: profile_a.to_string(),
588 profile_b: profile_b.to_string(),
589 project: project.to_string(),
590 synced: upserts,
591 removed: removals,
592 })
593}
594
595pub async fn projects(api: &dyn GatewayApi) -> Result<ProjectsResult, CoreError> {
598 let page = api.projects(&ListQuery::default()).await?;
599 Ok(ProjectsResult {
600 projects: page.items.iter().map(ProjectSummary::from_record).collect(),
601 })
602}
603
604pub async fn project_new(
608 api: &dyn GatewayApi,
609 name: &str,
610 opts: &NewOptions,
611) -> Result<ProjectSummary, CoreError> {
612 let body = ProjectCreate {
613 name: name.to_string(),
614 enabled: opts.enabled,
615 title: opts.title.clone(),
616 description: opts.description.clone(),
617 parent: opts.parent.clone(),
618 inheritable: opts.inheritable,
619 default_db: None,
620 tag_provider: None,
621 user_source: None,
622 };
623 api.project_create(&body).await?;
624 let record = api.project_find(name).await?;
625 Ok(ProjectSummary::from_record(&record))
626}
627
628pub async fn project_copy(
630 api: &dyn GatewayApi,
631 from: &str,
632 to: &str,
633) -> Result<ProjectCopyResult, CoreError> {
634 api.project_copy(from, to).await?;
635 let record = api.project_find(to).await?;
636 Ok(ProjectCopyResult {
637 from: from.to_string(),
638 project: ProjectSummary::from_record(&record),
639 })
640}
641
642pub async fn project_rename(
644 api: &dyn GatewayApi,
645 old: &str,
646 new: &str,
647) -> Result<ProjectRenameResult, CoreError> {
648 api.project_rename(old, new).await?;
649 let record = api.project_find(new).await?;
650 Ok(ProjectRenameResult {
651 previous_name: old.to_string(),
652 project: ProjectSummary::from_record(&record),
653 })
654}
655
656pub async fn project_set(
660 api: &dyn GatewayApi,
661 name: &str,
662 opts: &SetOptions,
663) -> Result<ProjectSetResult, CoreError> {
664 let body = ProjectModify {
665 enabled: opts.enabled,
666 title: opts.title.clone(),
667 description: opts.description.clone(),
668 parent: opts.parent.clone(),
669 inheritable: opts.inheritable,
670 default_db: None,
671 tag_provider: None,
672 user_source: None,
673 };
674 api.project_modify(name, &body).await?;
675 let record = api.project_find(name).await?;
676 Ok(ProjectSetResult {
677 fields: opts.fields_set(),
678 project: ProjectSummary::from_record(&record),
679 })
680}
681
682pub async fn project_delete(
686 api: &dyn GatewayApi,
687 name: &str,
688) -> Result<ProjectDeleteResult, CoreError> {
689 api.project_delete(name).await?;
690 Ok(ProjectDeleteResult {
691 deleted: name.to_string(),
692 })
693}
694
695pub async fn project_export(
703 api: &dyn GatewayApi,
704 name: &str,
705 output: Option<&Path>,
706) -> Result<ExportResult, CoreError> {
707 let scope = ExportScope::new();
708 if let Some(out) = output {
709 let meta = api.project_export_to_file(name, out).await?;
710 return Ok(ExportResult {
711 project: name.to_string(),
712 file: out.display().to_string(),
713 bytes: meta.bytes,
714 scope,
715 });
716 }
717
718 let fallback = format!("{}.zip", safe_fallback_stem(name));
721 let part = PathBuf::from(format!("{fallback}.part"));
722 let meta = match api.project_export_to_file(name, &part).await {
723 Ok(meta) => meta,
724 Err(err) => {
725 let _ = std::fs::remove_file(&part); return Err(err);
727 }
728 };
729 let final_name = meta
730 .filename
731 .as_deref()
732 .and_then(sanitize_basename)
733 .unwrap_or(fallback);
734 if let Err(err) = std::fs::rename(&part, &final_name) {
735 let _ = std::fs::remove_file(&part); return Err(CoreError::Internal(format!(
737 "cannot finalize export {final_name}: {err}"
738 )));
739 }
740 Ok(ExportResult {
741 project: name.to_string(),
742 file: final_name,
743 bytes: meta.bytes,
744 scope,
745 })
746}
747
748pub async fn project_import(
755 api: &dyn GatewayApi,
756 name: &str,
757 zip: Vec<u8>,
758 policy: CollisionPolicy,
759) -> Result<ImportResult, CoreError> {
760 let bytes = zip.len();
761 let scope = ExportScope::new();
762 validate_import(&zip)?;
763 if matches!(policy, CollisionPolicy::Abort) && api.project_find(name).await.is_ok() {
764 return Err(CoreError::ProjectExists {
765 name: name.to_string(),
766 endpoint: None,
767 });
768 }
769 let overwrite = matches!(policy, CollisionPolicy::Overwrite);
770 let outcome = api.project_import(name, zip, overwrite).await?;
771 Ok(ImportResult {
772 name: name.to_string(),
773 collision_policy: policy.label().to_string(),
774 bytes,
775 scope,
776 outcome: outcome.response,
777 })
778}
779
780#[derive(Debug, Serialize)]
785pub struct ExportDecodedResult {
786 pub project: String,
788 pub dir: String,
790 pub members: usize,
792 pub scripts_decoded: usize,
794 pub bytes: u64,
796 pub scope: ExportScope,
798}
799
800pub async fn project_export_decoded(
809 api: &dyn GatewayApi,
810 name: &str,
811 out_dir: Option<&Path>,
812) -> Result<ExportDecodedResult, CoreError> {
813 let zip = crate::actions::resources::export_zip_bytes(api, name).await?;
814 let dir = match out_dir {
815 Some(dir) => dir.to_path_buf(),
816 None => PathBuf::from(format!("{}-export", safe_fallback_stem(name))),
817 };
818 let members = crate::client::scripts_codec::count_file_members(&zip)?;
819 let scripts_decoded = crate::client::scripts_codec::decode_export_tree(&zip, &dir)?;
820 Ok(ExportDecodedResult {
821 project: name.to_string(),
822 dir: dir.display().to_string(),
823 members,
824 scripts_decoded,
825 bytes: zip.len() as u64,
826 scope: ExportScope::new(),
827 })
828}
829
830#[cfg(test)]
831mod tests {
832 use super::{NewOptions, ProjectSummary, SetOptions, project_new, projects};
833 use crate::client::GatewayApi;
834 use crate::client::projects::{ProjectCreate, ProjectModify, ProjectRecord};
835 use crate::client::query::{ListEnvelope, ListMetadata};
836 use crate::error::CoreError;
837
838 use std::sync::Mutex;
839
840 #[derive(Default)]
848 struct ProjectsRig {
849 creates: Mutex<Vec<ProjectCreate>>,
850 modifies: Mutex<Vec<(String, ProjectModify)>>,
851 deletes: Mutex<Vec<String>>,
852 finds: Mutex<Vec<String>>,
853 exports: Mutex<Vec<String>>,
854 imports: Mutex<Vec<(String, usize, bool)>>,
855 absent: bool,
859 export_body: Option<Vec<u8>>,
862 }
863
864 impl ProjectsRig {
865 fn zip_fixture() -> Vec<u8> {
869 use std::io::Write as _;
870 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
871 let options = zip::write::SimpleFileOptions::default();
872 writer
873 .start_file("project.json", options)
874 .expect("fixture member starts");
875 writer
876 .write_all(br#"{"title":"fixture"}"#)
877 .expect("fixture member writes");
878 writer.finish().expect("fixture finalizes").into_inner()
879 }
880 }
881
882 fn record(name: &str) -> ProjectRecord {
883 ProjectRecord {
884 name: name.into(),
885 title: Some(format!("{name} title")),
886 description: None,
887 enabled: true,
888 parent: Some("Base".into()),
889 inheritable: Some(false),
890 invalid_parent: None,
891 default_db: None,
892 tag_provider: None,
893 user_source: None,
894 extra: Default::default(),
895 }
896 }
897
898 fn page(items: Vec<ProjectRecord>) -> ListEnvelope<ProjectRecord> {
899 let total = items.len() as i64;
900 ListEnvelope {
901 items,
902 metadata: ListMetadata {
903 total,
904 matching: total,
905 limit: -1,
906 offset: 0,
907 },
908 }
909 }
910
911 #[async_trait::async_trait]
912 impl GatewayApi for ProjectsRig {
913 async fn thread_dump(
914 &self,
915 ) -> Result<crate::client::threads::FormattedThreadDump, CoreError> {
916 unreachable!("not part of this action")
917 }
918 async fn thread_deadlocks(
919 &self,
920 ) -> Result<crate::client::threads::DeadlocksWire, CoreError> {
921 unreachable!("not part of this action")
922 }
923 async fn uninstall_module(
924 &self,
925 _gateway_module_id: &str,
926 _module_id: &str,
927 ) -> Result<(), CoreError> {
928 unreachable!("not part of this action")
929 }
930 async fn bundle_generate(
931 &self,
932 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
933 unreachable!("not part of this action")
934 }
935 async fn bundle_status(
936 &self,
937 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
938 unreachable!("not part of this action")
939 }
940 async fn bundle_download(
941 &self,
942 _out: &std::path::Path,
943 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
944 unreachable!("not part of this action")
945 }
946 async fn tag_provider_list(
947 &self,
948 _query: &crate::client::query::ListQuery,
949 ) -> Result<
950 crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
951 CoreError,
952 > {
953 unreachable!("not part of this action")
954 }
955 async fn tag_provider_find(
956 &self,
957 _name: &str,
958 ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
959 unreachable!("not part of this action")
960 }
961 async fn tag_provider_create(
962 &self,
963 _body: &[crate::client::tags::TagProviderCreate],
964 ) -> Result<(), CoreError> {
965 unreachable!("not part of this action")
966 }
967 async fn tag_provider_delete(
968 &self,
969 _name: &str,
970 _signature: &str,
971 ) -> Result<(), CoreError> {
972 unreachable!("not part of this action")
973 }
974 async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
975 unreachable!("not part of this action")
976 }
977 async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
978 unreachable!("not part of this action")
979 }
980 async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
981 unreachable!("not part of this action")
982 }
983 async fn backup_download(
984 &self,
985 _out: &std::path::Path,
986 _backup_type: crate::client::backup::BackupType,
987 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
988 unreachable!("not part of this action")
989 }
990 async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
991 unreachable!("not part of this action")
992 }
993 async fn eam_task_history(
994 &self,
995 _limit: Option<u32>,
996 _search: Option<&str>,
997 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
998 {
999 unreachable!("not part of this action")
1000 }
1001 async fn eam_task_definitions(
1002 &self,
1003 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
1004 {
1005 unreachable!("not part of this action")
1006 }
1007 async fn eam_task_find(
1008 &self,
1009 _name: &str,
1010 ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
1011 unreachable!("not part of this action")
1012 }
1013 async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
1014 unreachable!("not part of this action")
1015 }
1016 async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
1017 unreachable!("not part of this action")
1018 }
1019 async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
1020 unreachable!("not part of this action")
1021 }
1022 async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
1023 unreachable!("not part of this action")
1024 }
1025 async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
1026 unreachable!("not part of this action")
1027 }
1028 async fn eam_tasks_scheduled(
1029 &self,
1030 _running: bool,
1031 ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
1032 unreachable!("not part of this action")
1033 }
1034 async fn eam_task_modify(
1035 &self,
1036 _definition: &serde_json::Value,
1037 ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
1038 unreachable!("not part of this action")
1039 }
1040 async fn eam_task_delete(
1041 &self,
1042 _name: &str,
1043 _signature: &str,
1044 _confirm: bool,
1045 ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
1046 unreachable!("not part of this action")
1047 }
1048 async fn api_call(
1049 &self,
1050 _call: &crate::client::apicall::ApiCallRequest,
1051 ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
1052 unreachable!("not part of this action")
1053 }
1054 async fn license_status(
1055 &self,
1056 ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
1057 unreachable!("not part of this action")
1058 }
1059 async fn redundancy_status(
1060 &self,
1061 ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
1062 unreachable!("not part of this action")
1063 }
1064 async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
1065 unreachable!("not part of this action")
1066 }
1067 async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
1068 unreachable!("not part of this action")
1069 }
1070 async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
1071 unreachable!("not part of this action")
1072 }
1073 async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
1074 unreachable!("not part of this action")
1075 }
1076 async fn modules(
1077 &self,
1078 _quarantined: bool,
1079 _query: &crate::client::query::ListQuery,
1080 ) -> Result<ListEnvelope<crate::client::status::ModuleInfo>, CoreError> {
1081 unreachable!("not part of this action")
1082 }
1083 async fn metrics_current(
1084 &self,
1085 ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
1086 unreachable!("not part of this action")
1087 }
1088 async fn metrics_historic(
1089 &self,
1090 ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
1091 unreachable!("not part of this action")
1092 }
1093 async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
1094 unreachable!("not part of this action")
1095 }
1096 async fn designers(
1097 &self,
1098 _query: &crate::client::query::ListQuery,
1099 ) -> Result<ListEnvelope<crate::client::sessions::DesignerInfo>, CoreError> {
1100 unreachable!("not part of this action")
1101 }
1102 async fn perspective_sessions(
1103 &self,
1104 _query: &crate::client::query::ListQuery,
1105 ) -> Result<ListEnvelope<crate::client::sessions::PerspectiveSession>, CoreError> {
1106 unreachable!("not part of this action")
1107 }
1108 async fn vision_clients(
1109 &self,
1110 _query: &crate::client::query::ListQuery,
1111 ) -> Result<ListEnvelope<crate::client::sessions::VisionClient>, CoreError> {
1112 unreachable!("not part of this action")
1113 }
1114 async fn terminate_perspective_session(
1115 &self,
1116 _id: &str,
1117 _message: Option<&str>,
1118 ) -> Result<(), CoreError> {
1119 unreachable!("not part of this action")
1120 }
1121 async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
1122 unreachable!("not part of this action")
1123 }
1124 async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
1125 unreachable!("not part of this action")
1126 }
1127 async fn database_connections(
1128 &self,
1129 ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
1130 {
1131 unreachable!("not part of this action")
1132 }
1133 async fn opc_connections(
1134 &self,
1135 ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
1136 {
1137 unreachable!("not part of this action")
1138 }
1139 async fn logs(
1140 &self,
1141 _filter: &crate::client::logs::LogQuery,
1142 ) -> Result<ListEnvelope<crate::client::logs::LogEntry>, CoreError> {
1143 unreachable!("not part of this action")
1144 }
1145 async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
1146 unreachable!("not part of this action")
1147 }
1148 async fn loggers(
1149 &self,
1150 _query: &crate::client::query::ListQuery,
1151 ) -> Result<ListEnvelope<crate::client::logs::LoggerInfo>, CoreError> {
1152 unreachable!("not part of this action")
1153 }
1154 async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
1155 unreachable!("not part of this action")
1156 }
1157 async fn reset_logger_levels(&self) -> Result<(), CoreError> {
1158 unreachable!("not part of this action")
1159 }
1160 async fn restart(&self) -> Result<(), CoreError> {
1161 unreachable!("not part of this action")
1162 }
1163 async fn scan_projects(&self) -> Result<(), CoreError> {
1164 unreachable!("not part of this action")
1165 }
1166 async fn security_properties(
1167 &self,
1168 ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
1169 unreachable!("not part of this action")
1170 }
1171 async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
1172 unreachable!("not part of this action")
1173 }
1174 async fn webdev_route_call(
1175 &self,
1176 _project: &str,
1177 _route: &str,
1178 _body: &serde_json::Value,
1179 _extra_headers: &[(&str, &str)],
1180 ) -> Result<serde_json::Value, CoreError> {
1181 unreachable!("not part of this action")
1182 }
1183 async fn webdev_route_probe(
1184 &self,
1185 _project: &str,
1186 _route: &str,
1187 _extra_headers: &[(&str, &str)],
1188 ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
1189 unreachable!("not part of this action")
1190 }
1191 async fn projects(
1192 &self,
1193 _query: &crate::client::query::ListQuery,
1194 ) -> Result<ListEnvelope<ProjectRecord>, CoreError> {
1195 Ok(page(vec![record("PlantFloor"), record("Base")]))
1196 }
1197 async fn project_find(&self, name: &str) -> Result<ProjectRecord, CoreError> {
1198 self.finds.lock().unwrap().push(name.into());
1199 if self.absent {
1200 Err(CoreError::NotFound { endpoint: None })
1201 } else {
1202 Ok(record("whatever-the-rig-is-asked-for"))
1203 }
1204 }
1205 async fn project_create(&self, body: &ProjectCreate) -> Result<(), CoreError> {
1206 self.creates.lock().unwrap().push(body.clone());
1207 Ok(())
1208 }
1209 async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
1210 Ok(())
1211 }
1212 async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
1213 Ok(())
1214 }
1215 async fn project_modify(&self, name: &str, body: &ProjectModify) -> Result<(), CoreError> {
1216 self.modifies
1217 .lock()
1218 .unwrap()
1219 .push((name.into(), body.clone()));
1220 Ok(())
1221 }
1222 async fn project_delete(&self, name: &str) -> Result<(), CoreError> {
1223 self.deletes.lock().unwrap().push(name.into());
1224 Ok(())
1225 }
1226 async fn project_export_to_file(
1227 &self,
1228 name: &str,
1229 out: &std::path::Path,
1230 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
1231 self.exports.lock().unwrap().push(name.into());
1232 let fixture = self.export_body.clone().unwrap_or_else(Self::zip_fixture);
1233 std::fs::write(out, &fixture)
1234 .map_err(|err| CoreError::Internal(format!("rig export write: {err}")))?;
1235 Ok(crate::client::projects::ExportMeta {
1236 filename: Some("rig-export.zip".into()),
1237 bytes: fixture.len() as u64,
1238 content_type: Some("application/zip".into()),
1239 })
1240 }
1241 async fn project_import(
1242 &self,
1243 name: &str,
1244 zip: Vec<u8>,
1245 overwrite: bool,
1246 ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
1247 self.imports
1248 .lock()
1249 .unwrap()
1250 .push((name.into(), zip.len(), overwrite));
1251 Ok(crate::client::projects::ImportOutcome {
1252 response: serde_json::json!({"status": "success"}),
1253 })
1254 }
1255 }
1256
1257 #[test]
1261 fn set_options_only_title_serializes_exactly_title() {
1262 let opts = SetOptions {
1263 title: Some("T".into()),
1264 ..Default::default()
1265 };
1266 let body = ProjectModify {
1267 enabled: opts.enabled,
1268 title: opts.title.clone(),
1269 description: opts.description.clone(),
1270 parent: opts.parent.clone(),
1271 inheritable: opts.inheritable,
1272 default_db: None,
1273 tag_provider: None,
1274 user_source: None,
1275 };
1276 assert_eq!(
1277 serde_json::to_value(&body).expect("serializes"),
1278 serde_json::json!({"title": "T"})
1279 );
1280 }
1281
1282 #[tokio::test]
1285 async fn projects_action_selects_the_six_stable_fields() {
1286 let rig = ProjectsRig::default();
1287 let result = projects(&rig).await.expect("list");
1288 assert_eq!(result.projects.len(), 2);
1289 assert_eq!(
1290 result.projects[0],
1291 ProjectSummary {
1292 name: "PlantFloor".into(),
1293 title: Some("PlantFloor title".into()),
1294 description: None,
1295 enabled: true,
1296 parent: Some("Base".into()),
1297 inheritable: Some(false),
1298 }
1299 );
1300 let json = serde_json::to_value(&result).expect("serialize");
1302 let mut keys: Vec<&str> = json["projects"][0]
1303 .as_object()
1304 .unwrap()
1305 .keys()
1306 .map(String::as_str)
1307 .collect();
1308 keys.sort_unstable();
1309 assert_eq!(
1310 keys,
1311 [
1312 "description",
1313 "enabled",
1314 "inheritable",
1315 "name",
1316 "parent",
1317 "title"
1318 ]
1319 );
1320 }
1321
1322 #[tokio::test]
1325 async fn project_new_creates_then_reads_back() {
1326 let rig = ProjectsRig::default();
1327 let opts = NewOptions {
1328 enabled: true,
1329 title: Some("T".into()),
1330 description: None,
1331 parent: Some("Base".into()),
1332 inheritable: Some(true),
1333 };
1334 let summary = project_new(&rig, "child", &opts).await.expect("new");
1335 assert_eq!(summary.name, "whatever-the-rig-is-asked-for");
1336
1337 let creates = rig.creates.lock().unwrap();
1338 assert_eq!(creates.len(), 1);
1339 assert_eq!(
1340 serde_json::to_value(&creates[0]).unwrap(),
1341 serde_json::json!({
1342 "name": "child",
1343 "enabled": true,
1344 "title": "T",
1345 "parent": "Base",
1346 "inheritable": true
1347 })
1348 );
1349 }
1350
1351 #[tokio::test]
1355 async fn project_set_modifies_with_somes_and_reads_back() {
1356 let rig = ProjectsRig::default();
1357 let opts = SetOptions {
1358 title: Some("T".into()),
1359 parent: Some("Base".into()),
1360 ..Default::default()
1361 };
1362 let result = super::project_set(&rig, "x", &opts).await.expect("set");
1363 assert_eq!(result.fields, vec!["title", "parent"]);
1364
1365 let modifies = rig.modifies.lock().unwrap();
1366 assert_eq!(modifies.len(), 1);
1367 assert_eq!(modifies[0].0, "x");
1368 assert_eq!(
1369 serde_json::to_value(&modifies[0].1).unwrap(),
1370 serde_json::json!({"title": "T", "parent": "Base"})
1371 );
1372
1373 let json = serde_json::to_value(&result).expect("serialize");
1375 let keys: Vec<&str> = json
1376 .as_object()
1377 .unwrap()
1378 .keys()
1379 .map(String::as_str)
1380 .collect();
1381 assert_eq!(
1382 keys,
1383 [
1384 "description",
1385 "enabled",
1386 "inheritable",
1387 "name",
1388 "parent",
1389 "title"
1390 ],
1391 "no `fields` key in the agent shape"
1392 );
1393 }
1394
1395 #[tokio::test]
1397 async fn project_delete_records_the_name() {
1398 let rig = ProjectsRig::default();
1399 let result = super::project_delete(&rig, "gone").await.expect("delete");
1400 assert_eq!(result.deleted, "gone");
1401 assert_eq!(*rig.deletes.lock().unwrap(), vec!["gone".to_string()]);
1402 }
1403
1404 #[tokio::test]
1408 async fn import_refuses_non_zip_before_any_network() {
1409 let rig = ProjectsRig::default();
1410 let err = super::project_import(
1411 &rig,
1412 "x",
1413 b"definitely not a zip".to_vec(),
1414 super::CollisionPolicy::Abort,
1415 )
1416 .await
1417 .expect_err("the magic guard refuses");
1418 assert_eq!(
1419 err.exit_code(),
1420 2,
1421 "usage class — the caller must fix the file"
1422 );
1423 assert_eq!(err.code(), "invalid_import_file");
1424 assert!(
1425 rig.finds.lock().unwrap().is_empty(),
1426 "zero pre-check calls — the guard runs first"
1427 );
1428 assert!(rig.imports.lock().unwrap().is_empty(), "zero uploads");
1429 }
1430
1431 #[tokio::test]
1437 async fn import_refuses_truncated_zip_before_any_network() {
1438 let rig = ProjectsRig::default();
1439 let truncated = {
1440 let full = ProjectsRig::zip_fixture();
1441 let cut = full.len() - 10;
1445 full[..cut].to_vec()
1446 };
1447 let err = super::project_import(&rig, "x", truncated, super::CollisionPolicy::Overwrite)
1448 .await
1449 .expect_err("the structure guard refuses");
1450 assert_eq!(err.exit_code(), 2);
1451 assert_eq!(err.code(), "invalid_import_file");
1452 assert!(
1453 rig.finds.lock().unwrap().is_empty() && rig.imports.lock().unwrap().is_empty(),
1454 "zero network of any kind — the structure guard runs before everything"
1455 );
1456 }
1457
1458 #[test]
1462 fn import_size_guard_refuses_over_512mb() {
1463 let err = super::import_size_error(super::IMPORT_MAX_BYTES + 1)
1464 .expect("one byte over the limit refuses");
1465 assert_eq!(err.exit_code(), 2);
1466 assert_eq!(err.code(), "invalid_import_file");
1467 let message = err.to_string();
1468 assert!(
1469 message.contains("512 MB"),
1470 "the reason names the limit: {message}"
1471 );
1472 assert!(
1473 super::import_size_error(super::IMPORT_MAX_BYTES).is_none(),
1474 "exactly at the limit is fine"
1475 );
1476 }
1477
1478 #[tokio::test]
1483 async fn import_abort_over_existing_refuses_project_exists() {
1484 let rig = ProjectsRig::default(); let err = super::project_import(
1486 &rig,
1487 "PlantFloor",
1488 ProjectsRig::zip_fixture(),
1489 super::CollisionPolicy::Abort,
1490 )
1491 .await
1492 .expect_err("the collision pre-check refuses");
1493 assert!(
1494 matches!(&err, CoreError::ProjectExists { name, .. } if name == "PlantFloor"),
1495 "wrong class: {err}"
1496 );
1497 assert_eq!(err.exit_code(), 6);
1498 assert_eq!(err.code(), "project_exists");
1499 let hint = err.hint().expect("hint required");
1500 assert!(
1501 hint.contains("--collision-policy overwrite"),
1502 "hint names the flag: {hint}"
1503 );
1504 assert!(
1505 hint.contains("ENTIRE project") && hint.contains("Designer-only"),
1506 "hint warns replace-not-merge: {hint}"
1507 );
1508 assert!(
1509 rig.imports.lock().unwrap().is_empty(),
1510 "the refusal happened BEFORE any upload"
1511 );
1512 assert_eq!(*rig.finds.lock().unwrap(), vec!["PlantFloor".to_string()]);
1513 }
1514
1515 #[tokio::test]
1518 async fn import_abort_when_free_uploads_without_overwrite() {
1519 let rig = ProjectsRig {
1520 absent: true,
1521 ..Default::default()
1522 };
1523 let result = super::project_import(
1524 &rig,
1525 "fresh",
1526 ProjectsRig::zip_fixture(),
1527 super::CollisionPolicy::Abort,
1528 )
1529 .await
1530 .expect("free name imports");
1531 assert_eq!(result.name, "fresh");
1532 assert_eq!(result.collision_policy, "abort");
1533 assert_eq!(result.bytes, ProjectsRig::zip_fixture().len());
1534 assert_eq!(
1535 result.scope,
1536 super::ExportScope::new(),
1537 "import carries the SAME scope consts as export"
1538 );
1539 assert_eq!(
1540 *rig.imports.lock().unwrap(),
1541 vec![("fresh".to_string(), ProjectsRig::zip_fixture().len(), false)]
1542 );
1543 }
1544
1545 #[tokio::test]
1548 async fn import_overwrite_skips_pre_check_and_uploads() {
1549 let rig = ProjectsRig::default(); let result = super::project_import(
1551 &rig,
1552 "PlantFloor",
1553 ProjectsRig::zip_fixture(),
1554 super::CollisionPolicy::Overwrite,
1555 )
1556 .await
1557 .expect("overwrite imports without a pre-check");
1558 assert_eq!(result.collision_policy, "overwrite");
1559 assert!(
1560 rig.finds.lock().unwrap().is_empty(),
1561 "overwrite performs ZERO pre-check calls"
1562 );
1563 assert_eq!(
1564 *rig.imports.lock().unwrap(),
1565 vec![(
1566 "PlantFloor".to_string(),
1567 ProjectsRig::zip_fixture().len(),
1568 true
1569 )]
1570 );
1571 }
1572
1573 #[test]
1577 fn export_scope_arrays_are_data() {
1578 assert!(
1579 super::EXPORT_EXCLUDES.contains(&"tag-providers"),
1580 "the headline exclusion (tags are gateway config, not project export)"
1581 );
1582 assert!(super::EXPORT_EXCLUDES.contains(&"tags"));
1583 assert!(super::EXPORT_EXCLUDES.contains(&"udts"));
1584 assert!(super::EXPORT_INCLUDES.contains(&"views"));
1585 assert!(super::EXPORT_INCLUDES.contains(&"scripts"));
1586 assert!(super::EXPORT_INCLUDES.contains(&"named-queries"));
1587 let json = serde_json::to_value(super::ExportScope::new()).expect("scope serializes");
1588 assert_eq!(
1589 json["includes"]
1590 .as_array()
1591 .expect("includes is an array")
1592 .len(),
1593 super::EXPORT_INCLUDES.len()
1594 );
1595 assert_eq!(
1596 json["excludes"][0], "tag-providers",
1597 "declaration order is the agent-visible order"
1598 );
1599 }
1600
1601 #[tokio::test]
1604 async fn export_to_explicit_path_streams_and_reports() {
1605 let rig = ProjectsRig::default();
1606 let dir = tempfile::tempdir().expect("tempdir");
1607 let out = dir.path().join("proj.zip");
1608 let result = super::project_export(&rig, "My Proj", Some(&out))
1609 .await
1610 .expect("export");
1611 assert_eq!(result.project, "My Proj");
1612 assert_eq!(result.file, out.display().to_string());
1613 assert_eq!(result.bytes as usize, ProjectsRig::zip_fixture().len());
1614 assert_eq!(
1615 std::fs::read(&out).expect("file written"),
1616 ProjectsRig::zip_fixture(),
1617 "the fixture landed byte-for-byte"
1618 );
1619 assert_eq!(result.scope, super::ExportScope::new());
1620 assert_eq!(*rig.exports.lock().unwrap(), vec!["My Proj".to_string()]);
1621 }
1622
1623 #[test]
1627 fn sanitize_basename_strips_path_components() {
1628 assert_eq!(
1629 super::sanitize_basename("MyProj-export.zip"),
1630 Some("MyProj-export.zip".to_string())
1631 );
1632 assert_eq!(
1633 super::sanitize_basename("../../etc/passwd"),
1634 Some("passwd".to_string()),
1635 "path components never survive"
1636 );
1637 assert_eq!(
1638 super::sanitize_basename(r"..\..\win\evil.zip"),
1639 Some("evil.zip".to_string())
1640 );
1641 assert_eq!(super::sanitize_basename(".."), None);
1642 assert_eq!(super::sanitize_basename("."), None);
1643 assert_eq!(super::sanitize_basename(" "), None);
1644 assert_eq!(super::safe_fallback_stem("a/b\\c"), "a_b_c");
1645 }
1646
1647 #[tokio::test]
1651 async fn project_diff_same_profile_refuses_before_any_export() {
1652 let rig = ProjectsRig::default();
1653 let err = super::project_diff(&rig, &rig, "p", "dev", "dev")
1654 .await
1655 .expect_err("the same-profile refusal");
1656 assert_eq!(err.exit_code(), 2);
1657 assert_eq!(err.code(), "invalid_input");
1658 assert!(
1659 rig.exports.lock().unwrap().is_empty(),
1660 "zero exports — the refusal leads"
1661 );
1662 }
1663
1664 #[tokio::test]
1667 async fn project_sync_selection_less_refuses_before_any_export() {
1668 let rig = ProjectsRig::default();
1669 let err = super::project_sync(
1670 &rig,
1671 &rig,
1672 "p",
1673 &super::SyncSelection::default(),
1674 false,
1675 "a",
1676 "b",
1677 )
1678 .await
1679 .expect_err("the selection-less refusal");
1680 assert_eq!(err.exit_code(), 2);
1681 assert_eq!(err.code(), "invalid_input");
1682 assert!(rig.exports.lock().unwrap().is_empty());
1683 }
1684
1685 fn script_bearing_zip() -> Vec<u8> {
1692 use std::io::Write as _;
1693 let view = br#"{
1694 "scope": "G",
1695 "children": [
1696 {
1697 "type": "ia.display.label",
1698 "eventScripts": {
1699 "actionPerformed": {
1700 "config": {
1701 "script": "\tprint \u0027clicked\u0027\n\tprint \u0027done\u0027"
1702 }
1703 }
1704 }
1705 },
1706 {
1707 "type": "ia.chart",
1708 "transform": {
1709 "script": "\t\tfor i in range(3):\n\t\t\tprint i\n\t\tprint \u0027end\u0027"
1710 },
1711 "props": {
1712 "expression": "toStr({view.args.x} * 2)"
1713 }
1714 }
1715 ]
1716}"#;
1717 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
1718 let options = zip::write::SimpleFileOptions::default();
1719 writer.start_file("project.json", options).expect("starts");
1720 writer.write_all(br#"{"title":"T"}"#).expect("writes");
1721 writer
1722 .start_file("c/resources/views/Dash/view.json", options)
1723 .expect("starts");
1724 writer.write_all(view).expect("writes");
1725 writer
1726 .start_file("c/resources/views/Dash/resource.json", options)
1727 .expect("starts");
1728 writer
1729 .write_all(br#"{"scope":"G","version":1,"files":["view.json"]}"#)
1730 .expect("writes");
1731 writer
1732 .start_file("ignition/resources/scratch", options)
1733 .expect("starts");
1734 writer.write_all(b"print('plain')").expect("writes");
1735 writer.finish().expect("finalize").into_inner()
1736 }
1737
1738 #[tokio::test]
1742 async fn project_export_decoded_writes_the_tree() {
1743 let rig = ProjectsRig {
1744 export_body: Some(script_bearing_zip()),
1745 ..Default::default()
1746 };
1747 let dir = tempfile::tempdir().expect("tempdir");
1748 let result = super::project_export_decoded(&rig, "p", Some(dir.path()))
1749 .await
1750 .expect("decode export");
1751 assert_eq!(result.members, 4);
1752 assert_eq!(result.scripts_decoded, 2);
1753 assert_eq!(result.dir, dir.path().display().to_string());
1754 assert!(
1755 dir.path()
1756 .join("c/resources/views/Dash/view.json.1.py")
1757 .is_file()
1758 );
1759 assert!(
1760 dir.path()
1761 .join("c/resources/views/Dash/view.json.2.py")
1762 .is_file()
1763 );
1764 assert!(
1765 dir.path()
1766 .join(crate::client::scripts_codec::MANIFEST_NAME)
1767 .is_file()
1768 );
1769 assert_eq!(
1772 std::fs::read(dir.path().join("ignition/resources/scratch")).expect("scratch member"),
1773 b"print('plain')"
1774 );
1775 let json = serde_json::to_value(&result).expect("serialize");
1778 let mut keys: Vec<&str> = json
1779 .as_object()
1780 .unwrap()
1781 .keys()
1782 .map(String::as_str)
1783 .collect();
1784 keys.sort_unstable();
1785 assert_eq!(
1786 keys,
1787 [
1788 "bytes",
1789 "dir",
1790 "members",
1791 "project",
1792 "scope",
1793 "scripts_decoded"
1794 ]
1795 );
1796 }
1797}