@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/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
- var sconfig = this.sconfig;
641
- 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;
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: (sconfig.cmd_timeout || 30) * 1000,
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
- // split up workflows into their own folder
775
- if (this.args.setup.includes('events')) {
776
- this.workflows = this.events.filter( event => event.type == 'workflow' );
777
- this.events = this.events.filter( event => event.type != 'workflow' );
778
- this.args.setup.push('workflows');
779
- list_data_types.push( { id: "event", "list": "workflows" } );
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
- this.args.setup.forEach( (list_name) => {
783
- var list = this[list_name];
784
- if (!list || !list.length) return;
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
- var type_def = Tools.findObject( list_data_types, { list: list_name } );
787
- if (!type_def) return; // sanity
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
- if (!this.dry) Tools.mkdirp.sync( list_name );
790
- println( "\n " + cli.emoji('📂') + " " + bold.cyan(list_name + '/') );
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
- list.forEach( (item) => {
793
- // exclude marketplace and stock items
794
- if (item.marketplace && !this.args.marketplace) return;
795
- if (item.stock && !this.args.stock) return;
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
- // extract extras first
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
- (this.args.file_props || []).forEach( (path) => {
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(type_def.id, item);
811
- var title = item.title.replace(/\W+/g, '-').replace(/\-+$/, '').replace(/^\-+/, '');
987
+ var data = this.getItemExportData(target.type, item);
988
+ var title = plan.title;
812
989
  var filename = title + '.json';
813
- var file = Path.join(list_name, filename);
990
+ var file = Path.join(dir, filename);
814
991
 
815
- if (!this.dry) {
816
- if (!this.args.force && fs.existsSync(file)) this.die("File exists, add '--force' to overwrite all: " + file);
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
- // now handle extras
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 = 'txt';
827
- if (command) ext = this.getExtFromBinary(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(list_name, extra_filename);
832
-
833
- if (!this.dry) {
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
- } ); // foreach item
841
- } ); // foreach list
1017
+ });
1018
+ });
1019
+ });
842
1020
 
843
- println( "\n " + this.color('theme').bold("Setup complete!") );
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
- var cmd = Path.basename( bin.trim() ).replace(/\s+.+$/, '').replace(/\d+$/, '').toLowerCase();
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.1",
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 |
@@ -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
- t.after(() => fs.rmSync(home_dir, { recursive: true, force: true }));
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
- env: {
27
- ...process.env,
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
- assert.equal(fs.statSync(fixture.file).mode & 0o777, 0o600);
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
- assert.equal(fs.statSync(fixture.file).mode & 0o777, 0o600);
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 => {
@@ -59,6 +59,7 @@ function runCLI(args, options = {}) {
59
59
  timeout: 30000,
60
60
  maxBuffer: 16 * 1024 * 1024,
61
61
  input: options.input,
62
+ windowsHide: true,
62
63
  env: {
63
64
  ...process.env,
64
65
  XYOPS_COLOR: 'false',