@pixlcore/xycli 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/sync.js CHANGED
@@ -30,6 +30,11 @@ module.exports = {
30
30
  // xy sync --up "events,plugins" --delete plugins
31
31
  await this.getMultiple();
32
32
 
33
+ // Both ordinary sync and setup completion commands report failures through
34
+ // the shared warning and error collectors.
35
+ this.warnings = [];
36
+ this.errors = [];
37
+
33
38
  // optionally setup local filesystem for sync
34
39
  if (this.args.other && (this.args.other[0] === 'setup')) {
35
40
  this.args.other.shift();
@@ -72,9 +77,6 @@ module.exports = {
72
77
  this.die("Delete mode requires up-only sync. Enable --up and disable --down.");
73
78
  }
74
79
 
75
- this.warnings = [];
76
- this.errors = [];
77
-
78
80
  // scan all base directories, default to cwd
79
81
  var files = [];
80
82
  if (!this.args.other || !this.args.other.length) {
@@ -353,6 +355,14 @@ module.exports = {
353
355
  // update state db
354
356
  await this.syncState(state);
355
357
 
358
+ // Deletion is the destructive final phase, so only begin it after every
359
+ // update and the sync-state write completed cleanly. Earlier successful
360
+ // updates are intentionally left in place and will compare equal on retry.
361
+ if (sconfig.delete && (this.errors.length || this.warnings.length)) {
362
+ this.logSyncWarning("Delete pass skipped because the sync encountered earlier warnings or errors.");
363
+ return await this.completeSync();
364
+ }
365
+
356
366
  // now handle deletes
357
367
  if (!sconfig.delete) return await this.completeSync();
358
368
 
@@ -627,17 +637,19 @@ module.exports = {
627
637
  } // run_event
628
638
  },
629
639
 
630
- syncFinishCmd(cmd) {
640
+ syncFinishCmd(cmd, opts) {
631
641
  // spawn command and log output
632
- var sconfig = this.sconfig;
633
- var cwd = this.args.other[0]; // use first base dir as cwd for shell commands
642
+ opts = opts || {};
643
+ var sconfig = this.sconfig || {};
644
+ var cwd = opts.cwd || this.args.other[0]; // default to first normal sync base dir
645
+ var cmd_timeout = opts.cmd_timeout || sconfig.cmd_timeout || 30;
634
646
 
635
647
  println( "\n " + green.bold("Running completion command: ") + cyan.bold(cmd) );
636
648
 
637
649
  try {
638
650
  const output = cp.execSync(cmd, {
639
651
  cwd: cwd,
640
- timeout: (sconfig.cmd_timeout || 30) * 1000,
652
+ timeout: cmd_timeout * 1000,
641
653
  encoding: 'utf8',
642
654
  stdio: ['ignore', 'pipe', 'pipe']
643
655
  });
@@ -724,12 +736,8 @@ module.exports = {
724
736
  async setupSync() {
725
737
  // export select resources to local XYPDF files in suggested layout
726
738
  // xy sync --setup all --file_props "script,params.script"
739
+ // xy sync --setup events --new
727
740
  // xy sync --setup all --marketplace --stock --force
728
- var data_types = this.config.ui.data_types;
729
- var list_data_types = Object.keys(data_types).map( key => {
730
- return { ...data_types[key], id: key };
731
- } );
732
-
733
741
  if ((this.args.setup === 'all') || (this.args.setup[0] === 'all')) {
734
742
  this.args.setup = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(buckets|secrets|users|roles)$/) );
735
743
  }
@@ -752,87 +760,279 @@ module.exports = {
752
760
  if (!Array.isArray(this.args.file_props)) this.dieUsage('sync setup');
753
761
  }
754
762
 
763
+ // New-only setup is intentionally an additive operation. It must never be
764
+ // combined with the overwrite behavior of --force.
765
+ var new_only = false;
766
+ if (this.args.new !== undefined) {
767
+ if ((this.args.new !== true) && (this.args.new !== false)) {
768
+ this.die("Invalid --new value (use --new or --new false).");
769
+ }
770
+ new_only = this.args.new;
771
+ }
772
+ if (new_only && this.args.force) this.die("Sync setup --new cannot be combined with --force.");
773
+
774
+ var default_ext = this.normalizeSyncDefaultExt(this.args.default_ext);
775
+ var existing_keys = new_only ? this.scanSyncSourceIndex(['.']) : {};
776
+
755
777
  this.printBoxList({
756
778
  title: 'Sync Setup',
757
779
  rows: [
758
780
  ['Item Types', this.args.setup.join(', ')],
759
781
  ['File Props', this.args.file_props ? this.args.file_props.join(', ') : '(None)'],
782
+ ['Default Ext', '.' + default_ext],
783
+ new_only ? ['New Only', 'Yes'] : null,
760
784
  this.dry ? ['Dry Run', 'No changes will be made.'] : null
761
785
  ]
762
786
  });
763
787
 
764
788
  println( "\n " + this.color('theme').bold("WRITING FILES:") );
765
789
 
766
- // split up workflows into their own folder
767
- if (this.args.setup.includes('events')) {
768
- this.workflows = this.events.filter( event => event.type == 'workflow' );
769
- this.events = this.events.filter( event => event.type != 'workflow' );
770
- this.args.setup.push('workflows');
771
- list_data_types.push( { id: "event", "list": "workflows" } );
790
+ var result = this.writeSyncSetupFiles(this.args.setup, {
791
+ base_dir: '.',
792
+ existing_keys: existing_keys,
793
+ new_only: new_only,
794
+ file_props: this.args.file_props || [],
795
+ default_ext: default_ext,
796
+ stock: !!this.args.stock,
797
+ marketplace: !!this.args.marketplace,
798
+ force: !!this.args.force,
799
+ dry: !!this.dry
800
+ });
801
+
802
+ if (new_only && !result.written) println( "\n " + gray("No new definitions found.") );
803
+
804
+ println( "\n " + this.color('theme').bold("Setup complete!") );
805
+
806
+ // Setup writes into the current directory, not a positional base directory.
807
+ // Only run an explicitly supplied down command after real files were written.
808
+ if (!this.dry && result.written && this.args.down_cmd) {
809
+ this.syncFinishCmd(this.args.down_cmd, {
810
+ cwd: '.',
811
+ cmd_timeout: this.args.cmd_timeout
812
+ });
813
+ }
814
+ },
815
+
816
+ normalizeSyncDefaultExt(value) {
817
+ // Allow users to choose the fallback extension for extracted properties whose
818
+ // language cannot be detected. A leading dot is accepted for convenience,
819
+ // but paths and compound values are rejected before any files are written.
820
+ if (value === undefined) return 'txt';
821
+ if (typeof(value) != 'string') {
822
+ this.die("Invalid --default_ext value (use a filename extension such as ps1 or .ps1).");
823
+ }
824
+
825
+ var ext = value.trim().replace(/^\./, '');
826
+ if (!ext.match(/^[A-Za-z0-9][A-Za-z0-9_]*$/)) {
827
+ this.die("Invalid --default_ext value (use a filename extension such as ps1 or .ps1).");
772
828
  }
829
+ return ext;
830
+ },
831
+
832
+ scanSyncSourceIndex(base_dirs) {
833
+ // Build an identity index from an existing sync tree. Filenames and folders
834
+ // are deliberately ignored, because sync sources may be freely reorganized.
835
+ var files = [];
836
+ base_dirs.forEach( dir => {
837
+ dir = Path.normalize(dir).replace(/[\\/]+$/, '') || '.';
838
+ if (!fs.existsSync(dir)) this.die("Sync base dir does not exist: " + dir);
839
+ files = files.concat( Tools.findFilesSync(dir, { stats: true }) );
840
+ });
773
841
 
774
- this.args.setup.forEach( (list_name) => {
775
- var list = this[list_name];
776
- if (!list || !list.length) return;
842
+ var index = {};
843
+ files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
844
+ var xypdf = null;
845
+ try { xypdf = Tools.parseJSON( fs.readFileSync(file.path, 'utf8') ); }
846
+ catch (err) { this.die("Failed to parse JSON file: " + file.path + ": " + err); }
777
847
 
778
- var type_def = Tools.findObject( list_data_types, { list: list_name } );
779
- if (!type_def) return; // sanity
848
+ // Ordinary JSON files are unrelated and may coexist in the tree.
849
+ if (!xypdf || !Tools.isaHash(xypdf) || (xypdf.type !== 'xypdf')) return;
850
+ if (xypdf.version !== '1.0') this.die("File is not a compatible version of XYPDF: " + file.path);
851
+ if (xypdf.xyops !== undefined && this.compareVersions(xypdf.xyops, this.xyopsVersion) > 0) {
852
+ this.die("Item requires xyOps v" + xypdf.xyops + " or higher: " + file.path);
853
+ }
854
+ if (!Array.isArray(xypdf.items) || (xypdf.items.length !== 1)) {
855
+ this.die("Sync source must contain exactly one item: " + file.path);
856
+ }
780
857
 
781
- if (!this.dry) Tools.mkdirp.sync( list_name );
782
- println( "\n " + cli.emoji('📂') + " " + bold.cyan(list_name + '/') );
858
+ var item = xypdf.items[0];
859
+ if (!item || !Tools.isaHash(item) || !item.type || (typeof(item.type) != 'string')) {
860
+ this.die("Malformed item type in file: " + file.path);
861
+ }
862
+ if (item.type.match(/^(bucket|secret|user|role)$/)) {
863
+ this.die("Unsupported item type: " + item.type + " in file: " + file.path);
864
+ }
865
+ if (!item.data || !Tools.isaHash(item.data) || !item.data.id || (typeof(item.data.id) != 'string')) {
866
+ this.die("Malformed or missing item data or ID in file: " + file.path);
867
+ }
783
868
 
784
- list.forEach( (item) => {
785
- // exclude marketplace and stock items
786
- if (item.marketplace && !this.args.marketplace) return;
787
- if (item.stock && !this.args.stock) return;
869
+ var type_def = this.config.ui.data_types[item.type];
870
+ if (!type_def) this.die("Cannot locate internal type definition: " + item.type);
871
+ var list = this[type_def.list];
872
+ if (!list) this.die("Cannot locate internal list: " + type_def.list);
873
+ if (!Tools.findObject(list, { id: item.data.id })) {
874
+ this.die("Cannot find " + item.type + " in xyOps: " + (item.data.title || item.data.id) + " (" + item.data.id + ")");
875
+ }
876
+
877
+ var key = item.type + '-' + item.data.id;
878
+ if (index[key]) {
879
+ this.die("Duplicate source item found: " + file.path + " (already found in " + index[key].file + ")");
880
+ }
881
+ index[key] = { file: file.path, item: item };
882
+ });
883
+
884
+ return index;
885
+ },
886
+
887
+ writeSyncSetupFiles(list_names, opts = {}) {
888
+ // Write selected remote definitions using setup's suggested layout. Keeping
889
+ // this operation separate allows a future downsync to append newly discovered
890
+ // remote definitions after it finishes updating existing local sources.
891
+ var data_types = this.config.ui.data_types;
892
+ var list_data_types = Object.keys(data_types).map( key => {
893
+ return { ...data_types[key], id: key };
894
+ });
895
+ var targets = [];
896
+
897
+ list_names.forEach( list_name => {
898
+ if (list_name == 'events') {
899
+ targets.push({ name: 'events', type: 'event', items: this.events.filter( event => event.type != 'workflow' ) });
900
+ targets.push({ name: 'workflows', type: 'event', items: this.events.filter( event => event.type == 'workflow' ) });
901
+ return;
902
+ }
903
+
904
+ var type_def = Tools.findObject( list_data_types, { list: list_name } );
905
+ if (type_def) targets.push({ name: list_name, type: type_def.id, items: this[list_name] || [] });
906
+ });
907
+
908
+ var result = { written: 0, skipped: 0 };
909
+ var existing_keys = opts.existing_keys || {};
910
+ var base_dir = opts.base_dir || '.';
911
+ var default_ext = this.normalizeSyncDefaultExt(opts.default_ext);
912
+
913
+ // Build and validate the complete write plan before creating any directories.
914
+ // Events and workflows are grouped under their xyOps Category titles. A
915
+ // missing Category indicates inconsistent remote data, so setup must stop
916
+ // without leaving behind a partially generated tree.
917
+ targets.forEach( target => {
918
+ var items = target.items.filter( item => {
919
+ if (item.marketplace && !opts.marketplace) return false;
920
+ if (item.stock && !opts.stock) return false;
921
+ if (opts.new_only && existing_keys[target.type + '-' + item.id]) {
922
+ result.skipped++;
923
+ return false;
924
+ }
925
+ return true;
926
+ });
927
+
928
+ target.plans = items.map( source_item => {
929
+ var dir = Path.join(base_dir, target.name);
930
+ var display_dir = target.name + '/';
788
931
 
789
- // extract extras first
932
+ if (target.type == 'event') {
933
+ var category = Tools.findObject( this.categories, { id: source_item.category } );
934
+ if (!category) {
935
+ this.die("Cannot find Category for " + (source_item.type == 'workflow' ? 'workflow' : 'Event') + ": " + (source_item.title || source_item.id) + " (" + (source_item.category || 'missing ID') + ")");
936
+ }
937
+
938
+ var category_slug = this.getSyncSetupTitleSlug(category.title, 'Category', category.id);
939
+ dir = Path.join(dir, category_slug);
940
+ display_dir += category_slug + '/';
941
+ }
942
+
943
+ var title = this.getSyncSetupTitleSlug(source_item.title, (source_item.type == 'workflow' ? 'workflow' : target.type), source_item.id);
944
+ return { source_item, title, dir, display_dir };
945
+ });
946
+
947
+ // Remote Event order is not guaranteed to keep Categories together. Group
948
+ // plans explicitly so each printed folder heading owns every file shown
949
+ // beneath it, while preserving the first-seen order of folders and items.
950
+ var groups_by_dir = new Map();
951
+ var plan_groups = [];
952
+ target.plans.forEach( plan => {
953
+ var group = groups_by_dir.get(plan.dir);
954
+ if (!group) {
955
+ group = [];
956
+ groups_by_dir.set(plan.dir, group);
957
+ plan_groups.push(group);
958
+ }
959
+ group.push(plan);
960
+ });
961
+ target.plans = [].concat(...plan_groups);
962
+ });
963
+
964
+ // All Categories are now known to be valid, so it is safe to start writing.
965
+ targets.forEach( target => {
966
+ var active_dir = '';
967
+ target.plans.forEach( plan => {
968
+ var dir = plan.dir;
969
+ if (dir != active_dir) {
970
+ if (!opts.dry) Tools.mkdirp.sync(dir);
971
+ println( "\n " + cli.emoji('📂') + " " + bold.cyan(plan.display_dir) );
972
+ active_dir = dir;
973
+ }
974
+
975
+ // Work on a deep copy so setup never changes the cached remote definition.
976
+ var item = Tools.copyHash(plan.source_item, true);
790
977
  var extras = [];
791
- (this.args.file_props || []).forEach( (path) => {
978
+ (opts.file_props || []).forEach( path => {
792
979
  var extra = Tools.getPath(item, path);
793
980
  if ((typeof(extra) == 'string') && extra.match(/\S/)) {
794
981
  extras.push({ path, content: extra });
795
-
796
- // we do not need to check the return value of setPath here,
797
- // because this path is guaranteed to exist at this point
798
982
  Tools.setPath( item, path, "(External)" );
799
983
  }
800
- } );
984
+ });
801
985
 
802
- var data = this.getItemExportData(type_def.id, item);
803
- var title = item.title.replace(/\W+/g, '-').replace(/\-+$/, '').replace(/^\-+/, '');
986
+ var data = this.getItemExportData(target.type, item);
987
+ var title = plan.title;
804
988
  var filename = title + '.json';
805
- var file = Path.join(list_name, filename);
989
+ var file = Path.join(dir, filename);
806
990
 
807
- if (!this.dry) {
808
- if (!this.args.force && fs.existsSync(file)) this.die("File exists, add '--force' to overwrite all: " + file);
991
+ if (!opts.dry) {
992
+ if (!opts.force && fs.existsSync(file)) this.die("File exists, add '--force' to overwrite all: " + file);
809
993
  Tools.writeFileAtomicSync( file, JSON.stringify(data, null, "\t") + "\n" );
810
994
  }
811
995
  println( " " + cli.emoji('📄') + " " + bold.green(filename) );
996
+ result.written++;
812
997
 
813
- // now handle extras
814
- extras.forEach( (extra) => {
998
+ extras.forEach( extra => {
815
999
  var command = item.command;
816
1000
  if (!command && extra.content.match(/^\s*\#\!(.+?)\n/)) command = RegExp.$1;
817
1001
 
818
- var ext = 'txt';
819
- if (command) ext = this.getExtFromBinary(command);
1002
+ var ext = default_ext;
1003
+ if (command) {
1004
+ var detected_ext = this.getExtFromBinary(command);
1005
+ if (detected_ext != 'txt') ext = detected_ext;
1006
+ }
820
1007
  else if (extra.content.trim().match(/^\{[\S\s]*\}$/) || extra.content.trim().match(/^\[[\S\s]*\]$/)) ext = 'json';
821
1008
 
822
1009
  var extra_filename = title + '-' + extra.path + '.' + ext;
823
- var extra_file = Path.join(list_name, extra_filename);
824
-
825
- if (!this.dry) {
826
- if (!this.args.force && fs.existsSync(extra_file)) this.die("File exists, add '--force' to overwrite all: " + extra_file);
1010
+ var extra_file = Path.join(dir, extra_filename);
1011
+ if (!opts.dry) {
1012
+ if (!opts.force && fs.existsSync(extra_file)) this.die("File exists, add '--force' to overwrite all: " + extra_file);
827
1013
  Tools.writeFileAtomicSync( extra_file, extra.content );
828
1014
  }
829
-
830
1015
  println( " " + cli.emoji('📜') + " " + bold.yellow(extra_filename) );
831
- } );
832
- } ); // foreach item
833
- } ); // foreach list
1016
+ });
1017
+ });
1018
+ });
834
1019
 
835
- println( "\n " + this.color('theme').bold("Setup complete!") );
1020
+ return result;
1021
+ },
1022
+
1023
+ getSyncSetupTitleSlug(title, item_type, item_id) {
1024
+ // Use one filename-safe transformation for definitions and Category folders.
1025
+ // Reject unusable titles explicitly instead of silently producing `.json` or
1026
+ // collapsing an Event back into the top-level resource directory.
1027
+ if (typeof(title) != 'string') {
1028
+ this.die("Cannot create sync setup path for " + item_type + ": Missing title (" + item_id + ")");
1029
+ }
1030
+
1031
+ var slug = title.replace(/\W+/g, '-').replace(/\-+$/, '').replace(/^\-+/, '');
1032
+ if (!slug) {
1033
+ this.die("Cannot create sync setup path for " + item_type + ": Title has no usable filename characters (" + item_id + ")");
1034
+ }
1035
+ return slug;
836
1036
  },
837
1037
 
838
1038
  getItemExportData(type, item) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixlcore/xycli",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "A complete command-line interface for the xyOps workflow automation system.",
5
5
  "author": "Joseph Huckaby <jhuckaby@pixlcore.com>",
6
6
  "homepage": "https://github.com/pixlcore/xycli",
@@ -20,7 +20,8 @@
20
20
  "xyops", "cli"
21
21
  ],
22
22
  "scripts": {
23
- "test": "node test.js"
23
+ "test": "node test.js",
24
+ "changelog": "node tools/changelog.js"
24
25
  },
25
26
  "dependencies": {
26
27
  "@pixlcore/xyops-sdk": "^1.0.7",
package/test/README.md CHANGED
@@ -8,7 +8,7 @@ Use Node.js 22 or newer and install the CLI dependencies with `npm install`.
8
8
 
9
9
  Start xyOps at `http://localhost:5522` and configure an administrator API key using your normal CLI config file or the `XYOPS_API_KEY` environment variable. The tests read the same configuration as `xy`, including `XYOPS_BASE_URL` overrides. They refuse to run against a different server address.
10
10
 
11
- Use an xyOps version that supports restoring API key IDs, hashes, and masks through `create_api_key`. The Server tests need a connected server, and the Monitor tests also need a server group. The log tests expect the standard log columns and debug entries in the current xyOps log.
11
+ Use an xyOps version that supports restoring API key IDs, hashes, and masks through `create_api_key`. The Server and Job tests need a connected server, the Job tests need the enabled stock Shell Plugin, and the Monitor tests also need a server group. The log tests expect the standard log columns and debug entries in the current xyOps log.
12
12
 
13
13
  ## Running Tests
14
14
 
@@ -55,6 +55,8 @@ node --test test/config.test.js test/sync-unit.test.js
55
55
  | `categories` | CRUD, actions and limits, JSON input, validation, pagination, help |
56
56
  | `channels` | CRUD, recipients, indexed edits, filters, validation, help |
57
57
  | `config` | Offline user-file updates, inherited-setting isolation, missing-file creation, dotted edits, permissions, and read-only display |
58
+ | `events` | CRUD, dependency filters, nested edits, triggers, actions, limits, fields, validation, and help |
59
+ | `jobs` | Event launch, parameter and input overrides, completed reports, output logs, search, rerun, deletion, validation, and help |
58
60
  | `log` | Log searches, latest rows, matching, column selection, native output |
59
61
  | `markdown` | Terminal rendering, inline formatting inside list items, typographic bullets, and output margins |
60
62
  | `monitors` | CRUD, groups, evaluator requests, type conversions, validation, help |
@@ -65,7 +67,7 @@ node --test test/config.test.js test/sync-unit.test.js
65
67
  | `servers` | Active and recently offline lists, local filters, installer generation, historical search, live and historical Server details, charts, processes, connections, pagination, validation, help |
66
68
  | `system` | Admin dashboard, conductors, connected users, export, maintenance, optimization, rate resets, diagnostics, broadcasts, help |
67
69
  | `sync` | Setup export, isolated filesystem discovery, external property files, dry-run, upsync, downsync, composite global-state governance, no-op detection, and guarded delete mode |
68
- | `sync-unit` | Offline PID locking and stale recovery, up-only deletion guards, stock/Marketplace deletion protection, inherited settings and CLI overrides, warning/error exit status, duplicates, quiet/dry runs, completion failures, and notification delivery after warnings or errors |
70
+ | `sync-unit` | Offline setup extension fallbacks, new-only exports and setup completion commands, PID locking and stale recovery, up-only deletion guards, pre-delete failure blocking, stock/Marketplace deletion protection, inherited settings and CLI overrides, warning/error exit status, duplicates, quiet/dry runs, completion failures, and notification delivery after warnings or errors |
69
71
  | `tags` | CRUD, metadata search, sparse future-proof updates, JSON input, validation, pagination, help |
70
72
  | `tickets` | Search, CRUD, number resolution, comments, attachment uploads/downloads, Events, related Job lookup, sparse updates, validation, help |
71
73
  | `toast` | Warning-box word wrapping and terminal-width limits |
@@ -80,7 +82,7 @@ Pagination checks that require saved events, completed jobs, or alert history re
80
82
 
81
83
  The mutating suites create uniquely named disposable objects and remove them in cleanup blocks, including after assertion failures. Test events have no active triggers, channels are never invoked, monitor sources use constants, and Plugin definitions are never executed or attached to other objects. Temporary files live in private operating-system temp directories and are removed by native test hooks.
82
84
 
83
- The Marketplace suite installs and upgrades `pixlcore/xyplug-ai`, then deletes it without executing or attaching it to an Event. The suite requires this product to be uninstalled when the test begins and removes its own installation during cleanup. Ticket fixtures remain in `draft` for their entire lifecycle so assigned test users never receive email notifications.
85
+ The Job suite creates one enabled Event with no triggers, launches short-lived Shell Plugin Jobs only through explicit test commands, and deletes the completed Jobs and Event. The Marketplace suite installs and upgrades `pixlcore/xyplug-ai`, then deletes it without executing or attaching it to an Event. The suite requires this product to be uninstalled when the test begins and removes its own installation during cleanup. Ticket fixtures remain in `draft` for their entire lifecycle so assigned test users never receive email notifications.
84
86
 
85
87
  API key tests keep plaintext secrets in memory. Export files contain the stored hash and mask, and are deleted after the test. Captured CLI output is omitted from process failure messages because it can contain credentials.
86
88
 
@@ -0,0 +1,187 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { loadTestConfig, createCheck, xy, json, call: apiCall, cleanupFixtures } = require('./helpers/common.js');
4
+
5
+ test('events', async t => {
6
+ loadTestConfig();
7
+ const check = createCheck(t);
8
+
9
+ // Keep the Event disabled for its entire lifecycle. Its schedule, actions,
10
+ // limits, and source are saved definitions only and can never launch a Job.
11
+ const stamp = Date.now();
12
+ const id = 'cli_event_test_' + stamp;
13
+ const title = 'CLI Event Test ' + stamp;
14
+ const action = { type: 'email', enabled: true, condition: 'success', users: ['admin'] };
15
+ const limit = { type: 'time', enabled: true, duration: 300, abort: true };
16
+ const field = { id: 'message', title: 'Message', type: 'text', value: 'Hello' };
17
+ const script = 'echo "Event fixture ' + stamp + '"';
18
+ let category;
19
+ let plugin;
20
+ let group;
21
+
22
+ try {
23
+ await check('local server has Event dependencies', async () => {
24
+ const data = await apiCall('getMultiple', { lists: 'categories,plugins,groups' });
25
+ category = data.categories.find(item => item.enabled) || data.categories[0];
26
+ plugin = data.plugins.find(item => item.id == 'shellplug') || data.plugins.find(item => item.type == 'event');
27
+ group = data.groups[0];
28
+ assert.ok(category, 'At least one Category is required');
29
+ assert.ok(plugin, 'At least one Event Plugin is required');
30
+ assert.ok(group, 'At least one Server Group is required');
31
+ });
32
+
33
+ await check('dry creation builds a complete Event request', () => {
34
+ const req = json([
35
+ 'event', 'create', '--id', id, '--title', title,
36
+ '--enabled', 'false', '--category', category.id, '--plugin', plugin.id,
37
+ '--targets', JSON.stringify([group.id]), '--algo', 'random',
38
+ '--notes', 'Original notes', '--params.script', script,
39
+ '--cron', '15 4 * * *', '--action', JSON.stringify(action),
40
+ '--limit', JSON.stringify(limit), '--field', JSON.stringify(field), '--dry'
41
+ ]);
42
+ assert.equal(req.id, id);
43
+ assert.equal(req.enabled, false);
44
+ assert.equal(req.category, category.id);
45
+ assert.equal(req.plugin, plugin.id);
46
+ assert.deepEqual(req.targets, [group.id]);
47
+ assert.equal(req.params.script, script);
48
+ assert.ok(req.triggers.some(trigger => trigger.type == 'manual' && trigger.enabled));
49
+ assert.ok(req.triggers.some(trigger => trigger.type == 'schedule' && trigger.enabled));
50
+ assert.deepEqual(req.actions.at(-1), action);
51
+ assert.deepEqual(req.limits.at(-1), limit);
52
+ assert.deepEqual(req.fields.at(-1), field);
53
+ });
54
+
55
+ await check('dry creation does not persist', () => assert.deepEqual(json(['events', '--id', id]), []));
56
+
57
+ xy([
58
+ 'event', 'create', '--id', id, '--title', title,
59
+ '--enabled', 'false', '--category', category.id, '--plugin', plugin.id,
60
+ '--targets', JSON.stringify([group.id]), '--algo', 'random',
61
+ '--notes', 'Original notes', '--params.script', script,
62
+ '--cron', '15 4 * * *', '--action', JSON.stringify(action),
63
+ '--limit', JSON.stringify(limit), '--field', JSON.stringify(field)
64
+ ]);
65
+ const created = json(['event', id]);
66
+
67
+ await check('create persists the complete Event definition', () => {
68
+ assert.equal(created.id, id);
69
+ assert.equal(created.title, title);
70
+ assert.equal(created.enabled, false);
71
+ assert.equal(created.category, category.id);
72
+ assert.equal(created.plugin, plugin.id);
73
+ assert.deepEqual(created.targets, [group.id]);
74
+ assert.equal(created.params.script, script);
75
+ assert.equal(created.notes, 'Original notes');
76
+ assert.equal(created.revision, 1);
77
+ assert.deepEqual(created.actions.at(-1), action);
78
+ assert.deepEqual(created.limits.at(-1), limit);
79
+ assert.deepEqual(created.fields.at(-1), field);
80
+ });
81
+
82
+ await check('exact ID, named ID, and fuzzy title get the Event', () => {
83
+ assert.deepEqual(json(['event', id]), created);
84
+ assert.equal(json(['event', 'get', '--id', id]).id, id);
85
+ assert.equal(json(['event', 'get', '--title', title.toLowerCase()]).id, id);
86
+ });
87
+
88
+ await check('search and dependency filters find only the fixture', () => {
89
+ assert.deepEqual(json(['events', title, '--category', category.id, '--plugin', plugin.id, '--target', group.id]), [created]);
90
+ assert.deepEqual(json(['event', 'list', id, '--enabled', 'true']), []);
91
+ });
92
+
93
+ await check('human list and detail render Event configuration', () => {
94
+ const list = xy(['events', title]);
95
+ const detail = xy(['event', id]);
96
+ assert.match(list, /FILTERED EVENTS/);
97
+ assert.match(list, /Other Commands:/);
98
+ for (const label of ['Event Summary', 'Event ID', 'Event Notes', 'Original notes', 'Triggers', 'Actions', 'Limits', 'User Parameters']) {
99
+ assert.ok(detail.toLowerCase().includes(label.toLowerCase()), label);
100
+ }
101
+ });
102
+
103
+ await check('dry update is sparse and preserves the saved revision', () => {
104
+ assert.deepEqual(json(['event', 'update', id, '--notes', 'Preview', '--dry']), { notes: 'Preview', id: id });
105
+ assert.equal(json(['event', id]).revision, 1);
106
+ });
107
+
108
+ const actionIndex = created.actions.length - 1;
109
+ const limitIndex = created.limits.length - 1;
110
+ const fieldIndex = created.fields.length - 1;
111
+ const updatedScript = 'echo "Updated Event fixture ' + stamp + '"';
112
+ xy([
113
+ 'event', 'update', id, '--title', title + ' Updated',
114
+ '--notes', 'Updated notes', '--params.script', updatedScript,
115
+ '--actions.' + actionIndex + '.condition', 'complete',
116
+ '--limits.' + limitIndex + '.duration', '600',
117
+ '--fields.' + fieldIndex + '.value', 'Updated'
118
+ ]);
119
+ let updated = json(['event', id]);
120
+
121
+ await check('dotted update preserves untouched Event settings', () => {
122
+ assert.equal(updated.title, title + ' Updated');
123
+ assert.equal(updated.notes, 'Updated notes');
124
+ assert.equal(updated.params.script, updatedScript);
125
+ assert.equal(updated.actions[actionIndex].condition, 'complete');
126
+ assert.equal(updated.actions[actionIndex].users[0], 'admin');
127
+ assert.equal(updated.limits[limitIndex].duration, 600);
128
+ assert.equal(updated.fields[fieldIndex].value, 'Updated');
129
+ assert.equal(updated.enabled, false);
130
+ assert.equal(updated.revision, 2);
131
+ });
132
+
133
+ const originalTriggerCount = updated.triggers.length;
134
+ const delay = { type: 'delay', enabled: true, duration: 60 };
135
+ xy(['event', 'update', id, '--trigger', JSON.stringify(delay)]);
136
+ updated = json(['event', id]);
137
+
138
+ await check('singular trigger appends to the saved trigger list', () => {
139
+ assert.equal(updated.triggers.length, originalTriggerCount + 1);
140
+ assert.deepEqual(updated.triggers.at(-1), delay);
141
+ });
142
+
143
+ const revision = updated.revision;
144
+ for (const args of [
145
+ ['update', id],
146
+ ['update', id, '--actions.99.enabled', 'true'],
147
+ ['update', id, '--actions.0.__proto__.polluted', 'true'],
148
+ ['update', id, '--unknown', 'value'],
149
+ ['delete', title + ' Updated', '--confirm']
150
+ ]) {
151
+ await check('reject Event ' + args.slice(0, 4).join(' '), () => xy(['event', ...args], { fail: true }));
152
+ }
153
+
154
+ await check('rejected requests leave the Event unchanged', () => assert.equal(json(['event', id]).revision, revision));
155
+
156
+ await check('duplicate Event ID error is surfaced', () => {
157
+ assert.match(xy(['event', 'create', '--id', id, '--title', title, '--enabled', 'false', '--params.script', script], { fail: true }), /already exists/i);
158
+ });
159
+
160
+ await check('unconfirmed delete shows the Event and warning toast', () => {
161
+ for (const args of [['delete', id], ['delete', id, '--confirm', 'maybe']]) {
162
+ const out = xy(['event', ...args]);
163
+ assert.match(out, /DELETE EVENT/);
164
+ assert.match(out, new RegExp(id));
165
+ assert.match(out, /Please confirm the delete/);
166
+ }
167
+ });
168
+
169
+ await check('dry delete sends only the Event ID', () => {
170
+ assert.deepEqual(json(['event', 'delete', id, '--confirm', '--dry']), { id: id });
171
+ assert.equal(json(['event', id]).id, id);
172
+ });
173
+
174
+ xy(['event', 'delete', id, '--confirm']);
175
+ await check('confirmed delete removes the Event', () => assert.deepEqual(json(['events', '--id', id]), []));
176
+ await check('get after deletion fails', () => xy(['event', id], { fail: true }));
177
+
178
+ for (const topic of ['events', 'event', 'event get', 'event create', 'event update', 'event delete', 'event run', 'run']) {
179
+ await check('help ' + topic, () => assert.ok(xy(['help', ...topic.split(' ')]).length > 100));
180
+ }
181
+ }
182
+ finally {
183
+ await cleanupFixtures([
184
+ { list: 'events', method: 'deleteEvent', match: item => item.id == id || (item.title || '').startsWith(title) }
185
+ ]);
186
+ }
187
+ });