@pixlcore/xycli 1.0.1 → 1.0.3
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/.github/workflows/offline-tests.yml +39 -0
- package/CHANGELOG.md +26 -0
- package/README.md +20 -9
- package/docs/help.md +17 -6
- package/docs/sync.md +28 -8
- package/docs/two-way-git-tutorial.md +262 -0
- package/lib/config.js +29 -7
- package/lib/sync.js +247 -54
- package/lib/utils.js +27 -1
- package/package.json +4 -2
- package/test/README.md +1 -1
- package/test/config.test.js +51 -11
- package/test/helpers/common.js +1 -0
- package/test/sync-unit.test.js +278 -8
- package/test/sync.test.js +3 -2
- package/test/utils-unit.test.js +19 -0
- package/test.js +2 -1
- package/tools/changelog.js +86 -0
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) {
|
|
@@ -635,19 +637,22 @@ module.exports = {
|
|
|
635
637
|
} // run_event
|
|
636
638
|
},
|
|
637
639
|
|
|
638
|
-
syncFinishCmd(cmd) {
|
|
640
|
+
syncFinishCmd(cmd, opts) {
|
|
639
641
|
// spawn command and log output
|
|
640
|
-
|
|
641
|
-
var
|
|
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;
|
|
642
646
|
|
|
643
647
|
println( "\n " + green.bold("Running completion command: ") + cyan.bold(cmd) );
|
|
644
648
|
|
|
645
649
|
try {
|
|
646
650
|
const output = cp.execSync(cmd, {
|
|
647
651
|
cwd: cwd,
|
|
648
|
-
timeout:
|
|
652
|
+
timeout: cmd_timeout * 1000,
|
|
649
653
|
encoding: 'utf8',
|
|
650
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
654
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
655
|
+
windowsHide: true
|
|
651
656
|
});
|
|
652
657
|
|
|
653
658
|
if (output.match(/\S/)) {
|
|
@@ -732,12 +737,8 @@ module.exports = {
|
|
|
732
737
|
async setupSync() {
|
|
733
738
|
// export select resources to local XYPDF files in suggested layout
|
|
734
739
|
// xy sync --setup all --file_props "script,params.script"
|
|
740
|
+
// xy sync --setup events --new
|
|
735
741
|
// xy sync --setup all --marketplace --stock --force
|
|
736
|
-
var data_types = this.config.ui.data_types;
|
|
737
|
-
var list_data_types = Object.keys(data_types).map( key => {
|
|
738
|
-
return { ...data_types[key], id: key };
|
|
739
|
-
} );
|
|
740
|
-
|
|
741
742
|
if ((this.args.setup === 'all') || (this.args.setup[0] === 'all')) {
|
|
742
743
|
this.args.setup = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(buckets|secrets|users|roles)$/) );
|
|
743
744
|
}
|
|
@@ -760,87 +761,279 @@ module.exports = {
|
|
|
760
761
|
if (!Array.isArray(this.args.file_props)) this.dieUsage('sync setup');
|
|
761
762
|
}
|
|
762
763
|
|
|
764
|
+
// New-only setup is intentionally an additive operation. It must never be
|
|
765
|
+
// combined with the overwrite behavior of --force.
|
|
766
|
+
var new_only = false;
|
|
767
|
+
if (this.args.new !== undefined) {
|
|
768
|
+
if ((this.args.new !== true) && (this.args.new !== false)) {
|
|
769
|
+
this.die("Invalid --new value (use --new or --new false).");
|
|
770
|
+
}
|
|
771
|
+
new_only = this.args.new;
|
|
772
|
+
}
|
|
773
|
+
if (new_only && this.args.force) this.die("Sync setup --new cannot be combined with --force.");
|
|
774
|
+
|
|
775
|
+
var default_ext = this.normalizeSyncDefaultExt(this.args.default_ext);
|
|
776
|
+
var existing_keys = new_only ? this.scanSyncSourceIndex(['.']) : {};
|
|
777
|
+
|
|
763
778
|
this.printBoxList({
|
|
764
779
|
title: 'Sync Setup',
|
|
765
780
|
rows: [
|
|
766
781
|
['Item Types', this.args.setup.join(', ')],
|
|
767
782
|
['File Props', this.args.file_props ? this.args.file_props.join(', ') : '(None)'],
|
|
783
|
+
['Default Ext', '.' + default_ext],
|
|
784
|
+
new_only ? ['New Only', 'Yes'] : null,
|
|
768
785
|
this.dry ? ['Dry Run', 'No changes will be made.'] : null
|
|
769
786
|
]
|
|
770
787
|
});
|
|
771
788
|
|
|
772
789
|
println( "\n " + this.color('theme').bold("WRITING FILES:") );
|
|
773
790
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
this.args.
|
|
779
|
-
|
|
791
|
+
var result = this.writeSyncSetupFiles(this.args.setup, {
|
|
792
|
+
base_dir: '.',
|
|
793
|
+
existing_keys: existing_keys,
|
|
794
|
+
new_only: new_only,
|
|
795
|
+
file_props: this.args.file_props || [],
|
|
796
|
+
default_ext: default_ext,
|
|
797
|
+
stock: !!this.args.stock,
|
|
798
|
+
marketplace: !!this.args.marketplace,
|
|
799
|
+
force: !!this.args.force,
|
|
800
|
+
dry: !!this.dry
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
if (new_only && !result.written) println( "\n " + gray("No new definitions found.") );
|
|
804
|
+
|
|
805
|
+
println( "\n " + this.color('theme').bold("Setup complete!") );
|
|
806
|
+
|
|
807
|
+
// Setup writes into the current directory, not a positional base directory.
|
|
808
|
+
// Only run an explicitly supplied down command after real files were written.
|
|
809
|
+
if (!this.dry && result.written && this.args.down_cmd) {
|
|
810
|
+
this.syncFinishCmd(this.args.down_cmd, {
|
|
811
|
+
cwd: '.',
|
|
812
|
+
cmd_timeout: this.args.cmd_timeout
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
},
|
|
816
|
+
|
|
817
|
+
normalizeSyncDefaultExt(value) {
|
|
818
|
+
// Allow users to choose the fallback extension for extracted properties whose
|
|
819
|
+
// language cannot be detected. A leading dot is accepted for convenience,
|
|
820
|
+
// but paths and compound values are rejected before any files are written.
|
|
821
|
+
if (value === undefined) return 'txt';
|
|
822
|
+
if (typeof(value) != 'string') {
|
|
823
|
+
this.die("Invalid --default_ext value (use a filename extension such as ps1 or .ps1).");
|
|
780
824
|
}
|
|
781
825
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
826
|
+
var ext = value.trim().replace(/^\./, '');
|
|
827
|
+
if (!ext.match(/^[A-Za-z0-9][A-Za-z0-9_]*$/)) {
|
|
828
|
+
this.die("Invalid --default_ext value (use a filename extension such as ps1 or .ps1).");
|
|
829
|
+
}
|
|
830
|
+
return ext;
|
|
831
|
+
},
|
|
832
|
+
|
|
833
|
+
scanSyncSourceIndex(base_dirs) {
|
|
834
|
+
// Build an identity index from an existing sync tree. Filenames and folders
|
|
835
|
+
// are deliberately ignored, because sync sources may be freely reorganized.
|
|
836
|
+
var files = [];
|
|
837
|
+
base_dirs.forEach( dir => {
|
|
838
|
+
dir = Path.normalize(dir).replace(/[\\/]+$/, '') || '.';
|
|
839
|
+
if (!fs.existsSync(dir)) this.die("Sync base dir does not exist: " + dir);
|
|
840
|
+
files = files.concat( Tools.findFilesSync(dir, { stats: true }) );
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
var index = {};
|
|
844
|
+
files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
|
|
845
|
+
var xypdf = null;
|
|
846
|
+
try { xypdf = Tools.parseJSON( fs.readFileSync(file.path, 'utf8') ); }
|
|
847
|
+
catch (err) { this.die("Failed to parse JSON file: " + file.path + ": " + err); }
|
|
785
848
|
|
|
786
|
-
|
|
787
|
-
if (!
|
|
849
|
+
// Ordinary JSON files are unrelated and may coexist in the tree.
|
|
850
|
+
if (!xypdf || !Tools.isaHash(xypdf) || (xypdf.type !== 'xypdf')) return;
|
|
851
|
+
if (xypdf.version !== '1.0') this.die("File is not a compatible version of XYPDF: " + file.path);
|
|
852
|
+
if (xypdf.xyops !== undefined && this.compareVersions(xypdf.xyops, this.xyopsVersion) > 0) {
|
|
853
|
+
this.die("Item requires xyOps v" + xypdf.xyops + " or higher: " + file.path);
|
|
854
|
+
}
|
|
855
|
+
if (!Array.isArray(xypdf.items) || (xypdf.items.length !== 1)) {
|
|
856
|
+
this.die("Sync source must contain exactly one item: " + file.path);
|
|
857
|
+
}
|
|
788
858
|
|
|
789
|
-
|
|
790
|
-
|
|
859
|
+
var item = xypdf.items[0];
|
|
860
|
+
if (!item || !Tools.isaHash(item) || !item.type || (typeof(item.type) != 'string')) {
|
|
861
|
+
this.die("Malformed item type in file: " + file.path);
|
|
862
|
+
}
|
|
863
|
+
if (item.type.match(/^(bucket|secret|user|role)$/)) {
|
|
864
|
+
this.die("Unsupported item type: " + item.type + " in file: " + file.path);
|
|
865
|
+
}
|
|
866
|
+
if (!item.data || !Tools.isaHash(item.data) || !item.data.id || (typeof(item.data.id) != 'string')) {
|
|
867
|
+
this.die("Malformed or missing item data or ID in file: " + file.path);
|
|
868
|
+
}
|
|
791
869
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
870
|
+
var type_def = this.config.ui.data_types[item.type];
|
|
871
|
+
if (!type_def) this.die("Cannot locate internal type definition: " + item.type);
|
|
872
|
+
var list = this[type_def.list];
|
|
873
|
+
if (!list) this.die("Cannot locate internal list: " + type_def.list);
|
|
874
|
+
if (!Tools.findObject(list, { id: item.data.id })) {
|
|
875
|
+
this.die("Cannot find " + item.type + " in xyOps: " + (item.data.title || item.data.id) + " (" + item.data.id + ")");
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
var key = item.type + '-' + item.data.id;
|
|
879
|
+
if (index[key]) {
|
|
880
|
+
this.die("Duplicate source item found: " + file.path + " (already found in " + index[key].file + ")");
|
|
881
|
+
}
|
|
882
|
+
index[key] = { file: file.path, item: item };
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
return index;
|
|
886
|
+
},
|
|
887
|
+
|
|
888
|
+
writeSyncSetupFiles(list_names, opts = {}) {
|
|
889
|
+
// Write selected remote definitions using setup's suggested layout. Keeping
|
|
890
|
+
// this operation separate allows a future downsync to append newly discovered
|
|
891
|
+
// remote definitions after it finishes updating existing local sources.
|
|
892
|
+
var data_types = this.config.ui.data_types;
|
|
893
|
+
var list_data_types = Object.keys(data_types).map( key => {
|
|
894
|
+
return { ...data_types[key], id: key };
|
|
895
|
+
});
|
|
896
|
+
var targets = [];
|
|
897
|
+
|
|
898
|
+
list_names.forEach( list_name => {
|
|
899
|
+
if (list_name == 'events') {
|
|
900
|
+
targets.push({ name: 'events', type: 'event', items: this.events.filter( event => event.type != 'workflow' ) });
|
|
901
|
+
targets.push({ name: 'workflows', type: 'event', items: this.events.filter( event => event.type == 'workflow' ) });
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
var type_def = Tools.findObject( list_data_types, { list: list_name } );
|
|
906
|
+
if (type_def) targets.push({ name: list_name, type: type_def.id, items: this[list_name] || [] });
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
var result = { written: 0, skipped: 0 };
|
|
910
|
+
var existing_keys = opts.existing_keys || {};
|
|
911
|
+
var base_dir = opts.base_dir || '.';
|
|
912
|
+
var default_ext = this.normalizeSyncDefaultExt(opts.default_ext);
|
|
913
|
+
|
|
914
|
+
// Build and validate the complete write plan before creating any directories.
|
|
915
|
+
// Events and workflows are grouped under their xyOps Category titles. A
|
|
916
|
+
// missing Category indicates inconsistent remote data, so setup must stop
|
|
917
|
+
// without leaving behind a partially generated tree.
|
|
918
|
+
targets.forEach( target => {
|
|
919
|
+
var items = target.items.filter( item => {
|
|
920
|
+
if (item.marketplace && !opts.marketplace) return false;
|
|
921
|
+
if (item.stock && !opts.stock) return false;
|
|
922
|
+
if (opts.new_only && existing_keys[target.type + '-' + item.id]) {
|
|
923
|
+
result.skipped++;
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
return true;
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
target.plans = items.map( source_item => {
|
|
930
|
+
var dir = Path.join(base_dir, target.name);
|
|
931
|
+
var display_dir = target.name + '/';
|
|
932
|
+
|
|
933
|
+
if (target.type == 'event') {
|
|
934
|
+
var category = Tools.findObject( this.categories, { id: source_item.category } );
|
|
935
|
+
if (!category) {
|
|
936
|
+
this.die("Cannot find Category for " + (source_item.type == 'workflow' ? 'workflow' : 'Event') + ": " + (source_item.title || source_item.id) + " (" + (source_item.category || 'missing ID') + ")");
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
var category_slug = this.getSyncSetupTitleSlug(category.title, 'Category', category.id);
|
|
940
|
+
dir = Path.join(dir, category_slug);
|
|
941
|
+
display_dir += category_slug + '/';
|
|
942
|
+
}
|
|
796
943
|
|
|
797
|
-
|
|
944
|
+
var title = this.getSyncSetupTitleSlug(source_item.title, (source_item.type == 'workflow' ? 'workflow' : target.type), source_item.id);
|
|
945
|
+
return { source_item, title, dir, display_dir };
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
// Remote Event order is not guaranteed to keep Categories together. Group
|
|
949
|
+
// plans explicitly so each printed folder heading owns every file shown
|
|
950
|
+
// beneath it, while preserving the first-seen order of folders and items.
|
|
951
|
+
var groups_by_dir = new Map();
|
|
952
|
+
var plan_groups = [];
|
|
953
|
+
target.plans.forEach( plan => {
|
|
954
|
+
var group = groups_by_dir.get(plan.dir);
|
|
955
|
+
if (!group) {
|
|
956
|
+
group = [];
|
|
957
|
+
groups_by_dir.set(plan.dir, group);
|
|
958
|
+
plan_groups.push(group);
|
|
959
|
+
}
|
|
960
|
+
group.push(plan);
|
|
961
|
+
});
|
|
962
|
+
target.plans = [].concat(...plan_groups);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// All Categories are now known to be valid, so it is safe to start writing.
|
|
966
|
+
targets.forEach( target => {
|
|
967
|
+
var active_dir = '';
|
|
968
|
+
target.plans.forEach( plan => {
|
|
969
|
+
var dir = plan.dir;
|
|
970
|
+
if (dir != active_dir) {
|
|
971
|
+
if (!opts.dry) Tools.mkdirp.sync(dir);
|
|
972
|
+
println( "\n " + cli.emoji('📂') + " " + bold.cyan(plan.display_dir) );
|
|
973
|
+
active_dir = dir;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Work on a deep copy so setup never changes the cached remote definition.
|
|
977
|
+
var item = Tools.copyHash(plan.source_item, true);
|
|
798
978
|
var extras = [];
|
|
799
|
-
(
|
|
979
|
+
(opts.file_props || []).forEach( path => {
|
|
800
980
|
var extra = Tools.getPath(item, path);
|
|
801
981
|
if ((typeof(extra) == 'string') && extra.match(/\S/)) {
|
|
802
982
|
extras.push({ path, content: extra });
|
|
803
|
-
|
|
804
|
-
// we do not need to check the return value of setPath here,
|
|
805
|
-
// because this path is guaranteed to exist at this point
|
|
806
983
|
Tools.setPath( item, path, "(External)" );
|
|
807
984
|
}
|
|
808
|
-
}
|
|
985
|
+
});
|
|
809
986
|
|
|
810
|
-
var data = this.getItemExportData(
|
|
811
|
-
var title =
|
|
987
|
+
var data = this.getItemExportData(target.type, item);
|
|
988
|
+
var title = plan.title;
|
|
812
989
|
var filename = title + '.json';
|
|
813
|
-
var file = Path.join(
|
|
990
|
+
var file = Path.join(dir, filename);
|
|
814
991
|
|
|
815
|
-
if (!
|
|
816
|
-
if (!
|
|
992
|
+
if (!opts.dry) {
|
|
993
|
+
if (!opts.force && fs.existsSync(file)) this.die("File exists, add '--force' to overwrite all: " + file);
|
|
817
994
|
Tools.writeFileAtomicSync( file, JSON.stringify(data, null, "\t") + "\n" );
|
|
818
995
|
}
|
|
819
996
|
println( " " + cli.emoji('📄') + " " + bold.green(filename) );
|
|
997
|
+
result.written++;
|
|
820
998
|
|
|
821
|
-
|
|
822
|
-
extras.forEach( (extra) => {
|
|
999
|
+
extras.forEach( extra => {
|
|
823
1000
|
var command = item.command;
|
|
824
1001
|
if (!command && extra.content.match(/^\s*\#\!(.+?)\n/)) command = RegExp.$1;
|
|
825
1002
|
|
|
826
|
-
var ext =
|
|
827
|
-
if (command)
|
|
1003
|
+
var ext = default_ext;
|
|
1004
|
+
if (command) {
|
|
1005
|
+
var detected_ext = this.getExtFromBinary(command);
|
|
1006
|
+
if (detected_ext != 'txt') ext = detected_ext;
|
|
1007
|
+
}
|
|
828
1008
|
else if (extra.content.trim().match(/^\{[\S\s]*\}$/) || extra.content.trim().match(/^\[[\S\s]*\]$/)) ext = 'json';
|
|
829
1009
|
|
|
830
1010
|
var extra_filename = title + '-' + extra.path + '.' + ext;
|
|
831
|
-
var extra_file = Path.join(
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
if (!this.args.force && fs.existsSync(extra_file)) this.die("File exists, add '--force' to overwrite all: " + extra_file);
|
|
1011
|
+
var extra_file = Path.join(dir, extra_filename);
|
|
1012
|
+
if (!opts.dry) {
|
|
1013
|
+
if (!opts.force && fs.existsSync(extra_file)) this.die("File exists, add '--force' to overwrite all: " + extra_file);
|
|
835
1014
|
Tools.writeFileAtomicSync( extra_file, extra.content );
|
|
836
1015
|
}
|
|
837
|
-
|
|
838
1016
|
println( " " + cli.emoji('📜') + " " + bold.yellow(extra_filename) );
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
}
|
|
1017
|
+
});
|
|
1018
|
+
});
|
|
1019
|
+
});
|
|
842
1020
|
|
|
843
|
-
|
|
1021
|
+
return result;
|
|
1022
|
+
},
|
|
1023
|
+
|
|
1024
|
+
getSyncSetupTitleSlug(title, item_type, item_id) {
|
|
1025
|
+
// Use one filename-safe transformation for definitions and Category folders.
|
|
1026
|
+
// Reject unusable titles explicitly instead of silently producing `.json` or
|
|
1027
|
+
// collapsing an Event back into the top-level resource directory.
|
|
1028
|
+
if (typeof(title) != 'string') {
|
|
1029
|
+
this.die("Cannot create sync setup path for " + item_type + ": Missing title (" + item_id + ")");
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
var slug = title.replace(/\W+/g, '-').replace(/\-+$/, '').replace(/^\-+/, '');
|
|
1033
|
+
if (!slug) {
|
|
1034
|
+
this.die("Cannot create sync setup path for " + item_type + ": Title has no usable filename characters (" + item_id + ")");
|
|
1035
|
+
}
|
|
1036
|
+
return slug;
|
|
844
1037
|
},
|
|
845
1038
|
|
|
846
1039
|
getItemExportData(type, item) {
|
package/lib/utils.js
CHANGED
|
@@ -254,7 +254,21 @@ module.exports = {
|
|
|
254
254
|
// This mirrors the xyOps UI helper, while avoiding slow source auto-detection
|
|
255
255
|
// for common interpreters.
|
|
256
256
|
if ((typeof(bin) != 'string') || !bin.trim()) return null;
|
|
257
|
-
|
|
257
|
+
|
|
258
|
+
// Normalize both path styles because Plugin definitions may come from a
|
|
259
|
+
// different operating system than the CLI displaying them. Remove command
|
|
260
|
+
// arguments before taking the basename, including quoted paths with spaces.
|
|
261
|
+
var cmdline = bin.trim().replace(/\\/g, '/');
|
|
262
|
+
var cmd = cmdline;
|
|
263
|
+
if (cmd.match(/^(?:.*\/)?env(?:\.exe)?\s+(?:\-S\s+)?(\S+)/i)) cmd = RegExp.$1;
|
|
264
|
+
else if (cmd.match(/^"([^"]+)"/)) cmd = RegExp.$1;
|
|
265
|
+
else if (cmd.match(/^'([^']+)'/)) cmd = RegExp.$1;
|
|
266
|
+
else cmd = cmd.replace(/\s+.+$/, '');
|
|
267
|
+
cmd = Path.posix.basename(cmd);
|
|
268
|
+
|
|
269
|
+
// Windows executables conventionally include .exe. Version suffixes are
|
|
270
|
+
// also common for interpreters such as python3.12 and node24.
|
|
271
|
+
cmd = cmd.replace(/\.exe$/i, '').replace(/\d+(?:\.\d+)*$/, '').toLowerCase();
|
|
258
272
|
|
|
259
273
|
switch (cmd) {
|
|
260
274
|
case 'sh':
|
|
@@ -268,14 +282,26 @@ module.exports = {
|
|
|
268
282
|
break;
|
|
269
283
|
|
|
270
284
|
case 'node':
|
|
285
|
+
case 'nodejs':
|
|
271
286
|
case 'deno':
|
|
272
287
|
case 'bun':
|
|
273
288
|
cmd = 'javascript';
|
|
274
289
|
break;
|
|
275
290
|
|
|
276
291
|
case 'pwsh':
|
|
292
|
+
case 'powershell':
|
|
277
293
|
cmd = 'powershell';
|
|
278
294
|
break;
|
|
295
|
+
|
|
296
|
+
case 'cmd':
|
|
297
|
+
cmd = 'dos';
|
|
298
|
+
break;
|
|
299
|
+
|
|
300
|
+
case 'py':
|
|
301
|
+
case 'pythonw':
|
|
302
|
+
case 'pypy':
|
|
303
|
+
cmd = 'python';
|
|
304
|
+
break;
|
|
279
305
|
}
|
|
280
306
|
|
|
281
307
|
return HIGHLIGHT_LANGUAGES.includes(cmd) ? cmd : null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pixlcore/xycli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
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,9 @@
|
|
|
20
20
|
"xyops", "cli"
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
|
-
"test": "node test.js"
|
|
23
|
+
"test": "node test.js",
|
|
24
|
+
"test:offline": "node --test test/config.test.js test/help.test.js test/markdown.test.js test/sync-unit.test.js test/toast.test.js test/utils-unit.test.js",
|
|
25
|
+
"changelog": "node tools/changelog.js"
|
|
24
26
|
},
|
|
25
27
|
"dependencies": {
|
|
26
28
|
"@pixlcore/xyops-sdk": "^1.0.7",
|
package/test/README.md
CHANGED
|
@@ -67,7 +67,7 @@ node --test test/config.test.js test/sync-unit.test.js
|
|
|
67
67
|
| `servers` | Active and recently offline lists, local filters, installer generation, historical search, live and historical Server details, charts, processes, connections, pagination, validation, help |
|
|
68
68
|
| `system` | Admin dashboard, conductors, connected users, export, maintenance, optimization, rate resets, diagnostics, broadcasts, help |
|
|
69
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 |
|
|
70
|
-
| `sync-unit` | Offline 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 |
|
|
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 |
|
|
71
71
|
| `tags` | CRUD, metadata search, sparse future-proof updates, JSON input, validation, pagination, help |
|
|
72
72
|
| `tickets` | Search, CRUD, number resolution, comments, attachment uploads/downloads, Events, related Job lookup, sparse updates, validation, help |
|
|
73
73
|
| `toast` | Warning-box word wrapping and terminal-width limits |
|
package/test/config.test.js
CHANGED
|
@@ -4,12 +4,25 @@ const fs = require('node:fs');
|
|
|
4
4
|
const os = require('node:os');
|
|
5
5
|
const Path = require('node:path');
|
|
6
6
|
const { spawnSync } = require('node:child_process');
|
|
7
|
+
const config = require('../lib/config.js');
|
|
8
|
+
|
|
9
|
+
function assertPrivateMode(file) {
|
|
10
|
+
// Windows permissions are represented by ACLs, not Unix owner/group/other
|
|
11
|
+
// mode bits. The file still has to exist, but an exact 0600 assertion only
|
|
12
|
+
// describes the Unix behavior that fs.chmodSync can enforce.
|
|
13
|
+
assert.equal(fs.existsSync(file), true);
|
|
14
|
+
if (process.platform != 'win32') assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
|
15
|
+
}
|
|
7
16
|
|
|
8
17
|
function createConfigFixture(t, initial) {
|
|
9
18
|
// Give each CLI process a disposable home directory. Config commands exit
|
|
10
19
|
// before contacting xyOps, so these checks need no development server.
|
|
11
20
|
const home_dir = fs.mkdtempSync(Path.join(os.tmpdir(), 'xycli-config-'));
|
|
12
|
-
|
|
21
|
+
const program_data = fs.mkdtempSync(Path.join(os.tmpdir(), 'xycli-program-data-'));
|
|
22
|
+
t.after(() => {
|
|
23
|
+
fs.rmSync(home_dir, { recursive: true, force: true });
|
|
24
|
+
fs.rmSync(program_data, { recursive: true, force: true });
|
|
25
|
+
});
|
|
13
26
|
const file = Path.join(home_dir, '.config', 'xyops', 'cli.json');
|
|
14
27
|
|
|
15
28
|
if (initial) {
|
|
@@ -20,17 +33,26 @@ function createConfigFixture(t, initial) {
|
|
|
20
33
|
return {
|
|
21
34
|
file,
|
|
22
35
|
run(args) {
|
|
36
|
+
const env = {
|
|
37
|
+
...process.env,
|
|
38
|
+
HOME: home_dir,
|
|
39
|
+
USERPROFILE: home_dir,
|
|
40
|
+
ProgramData: program_data,
|
|
41
|
+
XYOPS_API_KEY: 'fixture-key',
|
|
42
|
+
XYOPS_BASE_URL: 'https://fixture.invalid',
|
|
43
|
+
XYOPS_ITEMS_PER_PAGE: '99',
|
|
44
|
+
XYOPS_COLOR: 'false'
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Prove the Windows code path does not receive a compatibility HOME
|
|
48
|
+
// variable from Git Bash, WSL, or the parent test environment.
|
|
49
|
+
if (process.platform == 'win32') delete env.HOME;
|
|
50
|
+
|
|
23
51
|
const result = spawnSync(process.execPath, [Path.join(__dirname, '..', 'index.js'), 'config', ...args], {
|
|
24
52
|
encoding: 'utf8',
|
|
25
53
|
timeout: 10000,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
HOME: home_dir,
|
|
29
|
-
XYOPS_API_KEY: 'fixture-key',
|
|
30
|
-
XYOPS_BASE_URL: 'https://fixture.invalid',
|
|
31
|
-
XYOPS_ITEMS_PER_PAGE: '99',
|
|
32
|
-
XYOPS_COLOR: 'false'
|
|
33
|
-
}
|
|
54
|
+
windowsHide: true,
|
|
55
|
+
env
|
|
34
56
|
});
|
|
35
57
|
|
|
36
58
|
// Keep captured CLI output out of assertion messages, since a config
|
|
@@ -43,6 +65,24 @@ function createConfigFixture(t, initial) {
|
|
|
43
65
|
};
|
|
44
66
|
}
|
|
45
67
|
|
|
68
|
+
test('config resolves native Unix and Windows source paths', () => {
|
|
69
|
+
assert.deepEqual(config.getConfigFiles('linux', {}, '/home/nick'), [
|
|
70
|
+
'/etc/xyops/cli.json',
|
|
71
|
+
'/home/nick/.config/xyops/cli.json'
|
|
72
|
+
]);
|
|
73
|
+
assert.deepEqual(config.getConfigFiles('win32', { ProgramData: 'C:\\ProgramData' }, 'C:\\Users\\Nick'), [
|
|
74
|
+
'C:\\ProgramData\\xyops\\cli.json',
|
|
75
|
+
'C:\\Users\\Nick\\.config\\xyops\\cli.json'
|
|
76
|
+
]);
|
|
77
|
+
assert.deepEqual(config.getConfigFiles('win32', { ALLUSERSPROFILE: 'D:\\SharedData' }, 'C:\\Users\\Nick'), [
|
|
78
|
+
'D:\\SharedData\\xyops\\cli.json',
|
|
79
|
+
'C:\\Users\\Nick\\.config\\xyops\\cli.json'
|
|
80
|
+
]);
|
|
81
|
+
assert.deepEqual(config.getConfigFiles('win32', {}, 'C:\\Users\\Nick'), [
|
|
82
|
+
'C:\\Users\\Nick\\.config\\xyops\\cli.json'
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
46
86
|
test('config updates only requested user settings without saving inherited overrides', t => {
|
|
47
87
|
const fixture = createConfigFixture(t, {
|
|
48
88
|
items_per_page: 10,
|
|
@@ -61,7 +101,7 @@ test('config updates only requested user settings without saving inherited overr
|
|
|
61
101
|
suggest: true,
|
|
62
102
|
sync: { up: ['events'], down: false }
|
|
63
103
|
});
|
|
64
|
-
|
|
104
|
+
assertPrivateMode(fixture.file);
|
|
65
105
|
});
|
|
66
106
|
|
|
67
107
|
test('config creates a missing user file with only supplied settings', t => {
|
|
@@ -69,7 +109,7 @@ test('config creates a missing user file with only supplied settings', t => {
|
|
|
69
109
|
fixture.run(['--items_per_page', '25']);
|
|
70
110
|
|
|
71
111
|
assert.deepEqual(fixture.read(), { items_per_page: 25 });
|
|
72
|
-
|
|
112
|
+
assertPrivateMode(fixture.file);
|
|
73
113
|
});
|
|
74
114
|
|
|
75
115
|
test('config dotted updates preserve sibling user settings and explicit false or zero values', t => {
|