@pixlcore/xycli 1.0.6 → 1.0.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # xyCLI Changelog
2
2
 
3
+ ## Version v1.0.6
4
+
5
+ > September 22, 2026
6
+
7
+ - [`985c132`](https://github.com/pixlcore/xycli/commit/985c132607e1dcdabbadc829d6f21e599a2eb52b): Version 1.0.6
8
+ - [`f0aac32`](https://github.com/pixlcore/xycli/commit/f0aac320cbd301a5dc45862edaec9f842c2c6c79): Feature: Add "xy upgrade" command.
9
+ - [`a9b653b`](https://github.com/pixlcore/xycli/commit/a9b653b9649afd15d93cb3d29ee4f23da47bde0a): Bug Fix: Sync files with similar stems and neighbors can get mixed up. Fixes #4.
10
+
3
11
  ## Version v1.0.5
4
12
 
5
13
  > September 21, 2026
package/README.md CHANGED
@@ -499,7 +499,7 @@ cd xyops-automation
499
499
  xy sync setup events plugins categories --file_props script,params.script
500
500
  ```
501
501
 
502
- Setup creates one file per definition and can extract scripts into adjacent source files. Events and workflows are automatically grouped into subfolders named after their Categories. You can reorganize the tree afterward; sync scans subdirectories and matches definitions by resource type and exact ID.
502
+ Setup creates one file per definition and can extract scripts into adjacent source files. The same `--file_props script,params.script` selection also extracts matching scripts from workflow nodes automatically. Events and workflows are grouped into subfolders named after their Categories. You can reorganize the tree afterward; sync scans subdirectories and matches definitions by resource type and exact ID.
503
503
 
504
504
  Preview a push or pull, then apply the direction you want:
505
505
 
package/docs/help.md CHANGED
@@ -308,6 +308,8 @@ Setup creates one folder per resource type and one XYPDF JSON file per object, u
308
308
 
309
309
  For `--file_props`, use comma-separated property names or dot paths, such as `script,params.script`. Only nonempty string values are extracted. The JSON value becomes `(External)`, and sync loads the external file automatically. Setup chooses extensions from executable commands, shebangs, and JSON content when possible. Use `--default_ext ps1` (or `--default_ext .ps1`) to replace the `.txt` fallback for properties whose type cannot be detected. The override does not replace an extension that setup successfully detects.
310
310
 
311
+ The same property selection also checks every workflow node automatically. For example, `params.script` in a node's `data` becomes a neighbor such as `My-Workflow-workflow.nt3sr3y4.params.script.sh`. The node ID in the filename identifies which node owns the property. No additional setup option is needed.
312
+
311
313
  Use `--new` from the root of an existing sync tree to add definitions that exist in xyOps but have no local source. Setup scans recursively and matches existing sources by item type and exact ID, so renamed files and custom folder layouts are preserved. New definitions use the normal suggested type and Category folders. Malformed, duplicate, unsupported, or orphaned local sources stop the operation before any new files are written. `--new` cannot be combined with `--force`, and it does not create new definitions in xyOps.
312
314
 
313
315
  An explicitly supplied `--down_cmd` runs from the current setup directory after at least one definition is written. It does not run when `--new` finds nothing or during a dry run. Saved sync completion commands are not inherited by setup.
package/docs/sync.md CHANGED
@@ -96,7 +96,7 @@ Setup always writes below the current working directory. Type names after `setup
96
96
  xy sync setup events plugins categories --file_props script,params.script --dry
97
97
  ```
98
98
 
99
- The `--file_props` switch will externalize specific JSON property paths into individually named files, for easy editing. This is great for storing Plugin scripts and Event shell scripts in separate files alongside the main JSON definition files.
99
+ The `--file_props` switch will externalize specific JSON property paths into individually named files, for easy editing. This is great for storing Plugin scripts, Event shell scripts, and workflow node scripts in separate files alongside the main JSON definition files.
100
100
 
101
101
  The preview lists the folders and files that would be written. `--dry` creates no directories or files.
102
102
 
@@ -122,6 +122,7 @@ xyops-automation/
122
122
  workflows/
123
123
  Deployments/
124
124
  Release-Pipeline.json
125
+ Release-Pipeline-workflow.nt3sr3y4.params.script.sh
125
126
  ```
126
127
 
127
128
  Setup creates one folder per selected resource type and one JSON file per definition. Event and workflow exports are separated into `events/` and `workflows/`, then grouped into subfolders using their Category titles. Both participate in normal sync through the `events` selection.
@@ -242,9 +243,12 @@ This extracts the selected content, if found, and moves it to a neighboring file
242
243
  | --- | --- | --- |
243
244
  | `My-Plugin.json` | `script` | `My-Plugin-script.js` |
244
245
  | `My-Event.json` | `params.script` | `My-Event-params.script.sh` |
246
+ | `Release-Pipeline.json` | `workflow.nt3sr3y4.params.script` | `Release-Pipeline-workflow.nt3sr3y4.params.script.sh` |
245
247
 
246
248
  The general naming pattern is **`BASENAME-PROPERTY.EXT`**, in the same directory as `BASENAME.json`. Dot paths such as `params.script` identify nested properties. Setup chooses an extension from the executable command or a script's shebang when possible, falling back to JSON-looking content or `.txt`. Normal sync accepts your chosen extension.
247
249
 
250
+ The same `--file_props script,params.script` selection checks every node in a workflow automatically. If a node has a nonempty string at `data.params.script`, setup writes a neighbor for that node and leaves `(External)` in the workflow JSON. In its filename, `workflow.NODE_ID.params.script` identifies the node by its ID and then reads `data.params.script`. No additional option is needed for workflow nodes.
251
+
248
252
  Use `--default_ext EXT` to replace the `.txt` fallback when setup cannot detect a type. For example, `--default_ext ps1` and `--default_ext .ps1` both produce `.ps1` neighbors. Detected interpreter and JSON extensions still take precedence. The fallback applies to every undetected property selected by that setup command, so group properties with the same expected file type or run setup separately when needed.
249
253
 
250
254
  External properties are detected automatically when sync runs; you do not need to repeat `--file_props`. The neighbor's text replaces that property in the local definition before comparison and before an upload. A pull writes the latest value into the existing neighbor file.
@@ -67,7 +67,7 @@ xy sync setup events --file_props script,params.script --default_ext ps1 --dry
67
67
 
68
68
  The `events` selection includes both Events and workflows. Setup places workflows in their own `workflows/` directory, then groups Events and workflows into subfolders named after their Categories.
69
69
 
70
- `--file_props script,params.script` extracts common Plugin and Event script properties into neighboring files. If your Plugin uses another property, add its dot path to the list. For example, use `script,params.script,params.command` when an Event stores its PowerShell source in `params.command`.
70
+ `--file_props script,params.script` extracts common script properties into neighboring files. It also checks each workflow node automatically, so a node's `data.params.script` gets its own file named with that node's ID. If a property uses another path, add its dot path to the list. For example, use `script,params.script,params.command` when an Event stores its PowerShell source in `params.command`.
71
71
 
72
72
  The optional `--default_ext ps1` uses `.ps1` when setup cannot detect a neighbor file's language from an executable command, shebang, or JSON content. Remove it or choose another extension if PowerShell is not the appropriate fallback for your repository.
73
73
 
package/lib/jobs.js CHANGED
@@ -414,8 +414,8 @@ module.exports = {
414
414
 
415
415
  this.printNiceHTML( job.html.content );
416
416
 
417
- if (job.table.caption) {
418
- println( " " + gray(job.table.caption) );
417
+ if (job.html.caption) {
418
+ println( " " + gray(job.html.caption) );
419
419
  }
420
420
  } // job.html
421
421
 
package/lib/sync.js CHANGED
@@ -253,13 +253,13 @@ module.exports = {
253
253
  }
254
254
 
255
255
  // External files may only replace string properties that already exist in
256
- // the definition. Tools.setPath() creates missing keys, so validate first.
257
- if (typeof(Tools.getPath(item.data, prop_path)) != 'string') {
256
+ // the definition. setSyncPropByPath() creates missing keys, so validate first.
257
+ if (typeof(this.getSyncPropByPath(item.data, prop_path)) != 'string') {
258
258
  this.logSyncWarning("Neighbor file does not match an existing string property: " + neighbor.path + " (" + prop_path + ")");
259
259
  neighbor_error = true;
260
260
  return;
261
261
  }
262
- if (!Tools.setPath(item.data, prop_path, content)) {
262
+ if (!this.setSyncPropByPath(item.data, prop_path, content)) {
263
263
  this.logSyncWarning("Invalid neighbor property path: " + neighbor.path + " (" + prop_path + ")");
264
264
  neighbor_error = true;
265
265
  return;
@@ -703,9 +703,9 @@ module.exports = {
703
703
  if (item.neighbors) {
704
704
  for (var path in item.neighbors) {
705
705
  var file = item.neighbors[path];
706
- var value = Tools.getPath( data, path ) ?? "";
706
+ var value = this.getSyncPropByPath( data, path ) ?? "";
707
707
 
708
- if (!Tools.setPath( data, path, "(External)" )) {
708
+ if (!this.setSyncPropByPath( data, path, "(External)" )) {
709
709
  throw "External neighbor file path not found: " + file + ": " + path;
710
710
  }
711
711
 
@@ -1006,10 +1006,22 @@ module.exports = {
1006
1006
  var item = Tools.copyHash(plan.source_item, true);
1007
1007
  var extras = [];
1008
1008
  (opts.file_props || []).forEach( path => {
1009
- var extra = Tools.getPath(item, path);
1009
+ var extra = this.getSyncPropByPath(item, path);
1010
1010
  if ((typeof(extra) == 'string') && extra.match(/\S/)) {
1011
1011
  extras.push({ path, content: extra });
1012
- Tools.setPath( item, path, "(External)" );
1012
+ this.setSyncPropByPath( item, path, "(External)" );
1013
+ }
1014
+
1015
+ // handle workflow nodes separately
1016
+ if (item.workflow && item.workflow.nodes && Array.isArray(item.workflow.nodes)) {
1017
+ item.workflow.nodes.forEach( node => {
1018
+ if (!node.data || !Tools.isaHash(node.data)) return; // sanity
1019
+ var extra = this.getSyncPropByPath(node.data, path);
1020
+ if ((typeof(extra) == 'string') && extra.match(/\S/)) {
1021
+ extras.push({ path: `workflow.${node.id}.${path}`, content: extra });
1022
+ this.setSyncPropByPath( node.data, path, "(External)" );
1023
+ }
1024
+ } );
1013
1025
  }
1014
1026
  });
1015
1027
 
@@ -1188,6 +1200,42 @@ module.exports = {
1188
1200
  }
1189
1201
 
1190
1202
  return ext;
1203
+ },
1204
+
1205
+ getSyncPropByPath(data, path) {
1206
+ // special-case Tools.getPath for handling shorthand for named workflow nodes
1207
+ // e.g. `workflow.nabcd1234.params.foo` == `workflow.nodes[id='nabcd1234'].data.params.foo`
1208
+ if (path.match(/^workflow\.(\w+)\.(.+)$/) && data.workflow && data.workflow.nodes && Array.isArray(data.workflow.nodes)) {
1209
+ var node_id = RegExp.$1;
1210
+ var sub_path = RegExp.$2;
1211
+ if (node_id === 'nodes') return Tools.getPath(data, path);
1212
+
1213
+ var node = Tools.findObject( data.workflow.nodes, { id: node_id } );
1214
+ if (!node || !node.data) return undefined;
1215
+
1216
+ data = node.data;
1217
+ path = sub_path;
1218
+ }
1219
+
1220
+ return Tools.getPath(data, path);
1221
+ },
1222
+
1223
+ setSyncPropByPath(data, path, value) {
1224
+ // special-case Tools.setPath for handling shorthand for named workflow nodes
1225
+ // e.g. `workflow.nabcd1234.params.foo` == `workflow.nodes[id='nabcd1234'].data.params.foo`
1226
+ if (path.match(/^workflow\.(\w+)\.(.+)$/) && data.workflow && data.workflow.nodes && Array.isArray(data.workflow.nodes)) {
1227
+ var node_id = RegExp.$1;
1228
+ var sub_path = RegExp.$2;
1229
+ if (node_id === 'nodes') return Tools.setPath(data, path, value);
1230
+
1231
+ var node = Tools.findObject( data.workflow.nodes, { id: node_id } );
1232
+ if (!node || !node.data) return undefined;
1233
+
1234
+ data = node.data;
1235
+ path = sub_path;
1236
+ }
1237
+
1238
+ return Tools.setPath(data, path, value);
1191
1239
  }
1192
1240
 
1193
1241
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixlcore/xycli",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
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",
@@ -432,6 +432,61 @@ test('sync setup groups Events and workflows into Category folders', t => {
432
432
  assert.equal(result.stdout.indexOf('events/Operations-Team/', operationsHeader + 1), -1);
433
433
  });
434
434
 
435
+ test('sync setup externalizes each matching workflow node parameter', t => {
436
+ const shellScript = '#!/bin/sh\necho first\n';
437
+ const nodeScript = '#!/usr/bin/env node\nconsole.log("second");\n';
438
+ const workflow = {
439
+ id: 'flow', title: 'Flow', type: 'workflow', category: 'operations',
440
+ workflow: { nodes: [
441
+ { id: 'ntrigger1', type: 'trigger' },
442
+ { id: 'nfirst01', type: 'event', data: { params: { script: shellScript } } },
443
+ { id: 'nsecond1', type: 'job', data: { params: { script: nodeScript } } },
444
+ { id: 'nempty001', type: 'job', data: { params: { script: '' } } }
445
+ ] }
446
+ };
447
+ const result = runSync(t, {
448
+ setup: ['events'],
449
+ categories: [{ id: 'operations', title: 'Operations' }],
450
+ events: [workflow],
451
+ args: { file_props: 'params.script' }
452
+ });
453
+ const dir = Path.join(result.dir, 'workflows', 'Operations');
454
+ const source = JSON.parse(fs.readFileSync(Path.join(dir, 'Flow.json'), 'utf8')).items[0].data;
455
+
456
+ assert.equal(result.status, 0);
457
+ assert.deepEqual(result.report.errors, []);
458
+ assert.equal(fs.readFileSync(Path.join(dir, 'Flow-workflow.nfirst01.params.script.sh'), 'utf8'), shellScript);
459
+ assert.equal(fs.readFileSync(Path.join(dir, 'Flow-workflow.nsecond1.params.script.js'), 'utf8'), nodeScript);
460
+ assert.equal(source.workflow.nodes[1].data.params.script, '(External)');
461
+ assert.equal(source.workflow.nodes[2].data.params.script, '(External)');
462
+ assert.equal(source.workflow.nodes[3].data.params.script, '');
463
+ assert.equal(fs.readdirSync(dir).length, 3, 'Empty and trigger nodes have no neighbor files');
464
+ });
465
+
466
+ test('sync setup accepts a shorthand path for one workflow node', t => {
467
+ const workflow = {
468
+ id: 'flow', title: 'Flow', type: 'workflow', category: 'operations',
469
+ workflow: { nodes: [
470
+ { id: 'nfirst01', type: 'job', data: { params: { script: 'First\n' } } },
471
+ { id: 'nsecond1', type: 'job', data: { params: { script: 'Second\n' } } }
472
+ ] }
473
+ };
474
+ const result = runSync(t, {
475
+ setup: ['events'],
476
+ categories: [{ id: 'operations', title: 'Operations' }],
477
+ events: [workflow],
478
+ args: { file_props: 'workflow.nfirst01.params.script' }
479
+ });
480
+ const dir = Path.join(result.dir, 'workflows', 'Operations');
481
+ const source = JSON.parse(fs.readFileSync(Path.join(dir, 'Flow.json'), 'utf8')).items[0].data;
482
+
483
+ assert.equal(result.status, 0);
484
+ assert.equal(fs.readFileSync(Path.join(dir, 'Flow-workflow.nfirst01.params.script.txt'), 'utf8'), 'First\n');
485
+ assert.equal(source.workflow.nodes[0].data.params.script, '(External)');
486
+ assert.equal(source.workflow.nodes[1].data.params.script, 'Second\n');
487
+ assert.equal(fs.readdirSync(dir).length, 2);
488
+ });
489
+
435
490
  test('sync setup validates all Event Categories before writing any files', t => {
436
491
  const result = runSync(t, {
437
492
  setup: ['plugins', 'events'],
@@ -655,6 +710,112 @@ test('property neighbor matching requires an exact source basename boundary', t
655
710
  assert.equal(result.report.requests.some(request => request.method === 'update_event'), false);
656
711
  });
657
712
 
713
+ test('workflow node neighbors replace placeholders before upload', t => {
714
+ const shellScript = '#!/bin/sh\necho local\n';
715
+ const nodeScript = '#!/usr/bin/env node\nconsole.log("local");\n';
716
+ const remote = {
717
+ id: 'flow', title: 'Flow', type: 'workflow', category: 'operations',
718
+ workflow: { nodes: [
719
+ { id: 'nfirst01', type: 'event', data: { params: { script: 'Remote shell\n' } } },
720
+ { id: 'nsecond1', type: 'job', data: { params: { script: 'Remote node\n' } } }
721
+ ] }
722
+ };
723
+ const local = JSON.parse(JSON.stringify(remote));
724
+ local.workflow.nodes.forEach(node => { node.data.params.script = '(External)'; });
725
+ const result = runSync(t, {
726
+ events: [remote],
727
+ existingSources: [
728
+ { path: 'Flow.json', type: 'event', data: local },
729
+ { path: 'Flow-workflow.nfirst01.params.script.sh', contents: shellScript },
730
+ { path: 'Flow-workflow.nsecond1.params.script.js', contents: nodeScript }
731
+ ],
732
+ args: { up: 'events' }
733
+ });
734
+ const updates = result.report.requests.filter(request => request.method === 'update_event');
735
+
736
+ assert.equal(result.status, 0);
737
+ assert.deepEqual(result.report.warnings, []);
738
+ assert.deepEqual(result.report.errors, []);
739
+ assert.equal(updates.length, 1);
740
+ assert.equal(updates[0].data.workflow.nodes[0].data.params.script, shellScript);
741
+ assert.equal(updates[0].data.workflow.nodes[1].data.params.script, nodeScript);
742
+ const source = JSON.parse(fs.readFileSync(Path.join(result.dir, 'Flow.json'), 'utf8')).items[0].data;
743
+ assert.equal(source.workflow.nodes[0].data.params.script, '(External)');
744
+ assert.equal(source.workflow.nodes[1].data.params.script, '(External)');
745
+ });
746
+
747
+ test('workflow node neighbors receive pulled values while JSON keeps placeholders', t => {
748
+ const shellScript = '#!/bin/sh\necho remote\n';
749
+ const nodeScript = '#!/usr/bin/env node\nconsole.log("remote");\n';
750
+ const remote = {
751
+ id: 'flow', title: 'Flow', type: 'workflow', category: 'operations',
752
+ workflow: { nodes: [
753
+ { id: 'nfirst01', type: 'event', data: { params: { script: shellScript } } },
754
+ { id: 'nsecond1', type: 'job', data: { params: { script: nodeScript } } }
755
+ ] }
756
+ };
757
+ const local = JSON.parse(JSON.stringify(remote));
758
+ local.workflow.nodes.forEach(node => { node.data.params.script = '(External)'; });
759
+ const result = runSync(t, {
760
+ events: [remote],
761
+ existingSources: [
762
+ { path: 'Flow.json', type: 'event', data: local },
763
+ { path: 'Flow-workflow.nfirst01.params.script.sh', contents: 'Old shell\n' },
764
+ { path: 'Flow-workflow.nsecond1.params.script.js', contents: 'Old node\n' }
765
+ ],
766
+ args: { down: 'events' }
767
+ });
768
+ const source = JSON.parse(fs.readFileSync(Path.join(result.dir, 'Flow.json'), 'utf8')).items[0].data;
769
+
770
+ assert.equal(result.status, 0);
771
+ assert.deepEqual(result.report.warnings, []);
772
+ assert.deepEqual(result.report.errors, []);
773
+ assert.equal(fs.readFileSync(Path.join(result.dir, 'Flow-workflow.nfirst01.params.script.sh'), 'utf8'), shellScript);
774
+ assert.equal(fs.readFileSync(Path.join(result.dir, 'Flow-workflow.nsecond1.params.script.js'), 'utf8'), nodeScript);
775
+ assert.equal(source.workflow.nodes[0].data.params.script, '(External)');
776
+ assert.equal(source.workflow.nodes[1].data.params.script, '(External)');
777
+ });
778
+
779
+ test('workflow node shorthand preserves ordinary indexed dot paths', () => {
780
+ const sync = require('../lib/sync.js');
781
+ const data = { workflow: { nodes: [
782
+ { id: 'nfirst01', data: { params: { script: 'Original\n' } } }
783
+ ] } };
784
+
785
+ // The literal "nodes" segment must not be mistaken for a workflow node ID.
786
+ assert.equal(sync.getSyncPropByPath(data, 'workflow.nfirst01.params.script'), 'Original\n');
787
+ assert.equal(sync.getSyncPropByPath(data, 'workflow.nodes.0.data.params.script'), 'Original\n');
788
+ assert.equal(sync.setSyncPropByPath(data, 'workflow.nodes.0.data.params.script', 'Indexed\n'), true);
789
+ assert.equal(sync.getSyncPropByPath(data, 'workflow.nfirst01.params.script'), 'Indexed\n');
790
+ assert.equal(sync.setSyncPropByPath(data, 'workflow.nfirst01.params.script', 'Shorthand\n'), true);
791
+ assert.equal(sync.getSyncPropByPath(data, 'workflow.nodes.0.data.params.script'), 'Shorthand\n');
792
+ assert.equal(sync.getSyncPropByPath(data, 'workflow.nmissing1.params.script'), undefined);
793
+ assert.equal(sync.setSyncPropByPath(data, 'workflow.nmissing1.params.script', 'Missing\n'), undefined);
794
+ assert.equal(data.workflow.nodes.length, 1);
795
+ });
796
+
797
+ test('a workflow neighbor for a missing node stops sync before upload', t => {
798
+ const workflow = {
799
+ id: 'flow', title: 'Flow', type: 'workflow', category: 'operations',
800
+ workflow: { nodes: [
801
+ { id: 'nfirst01', type: 'job', data: { params: { script: 'Inline\n' } } }
802
+ ] }
803
+ };
804
+ const result = runSync(t, {
805
+ events: [workflow],
806
+ existingSources: [
807
+ { path: 'Flow.json', type: 'event', data: workflow },
808
+ { path: 'Flow-workflow.nmissing1.params.script.sh', contents: 'Orphan\n' }
809
+ ],
810
+ args: { up: 'events' }
811
+ });
812
+
813
+ assert.equal(result.status, 1);
814
+ assert.match(result.report.warnings[0], /does not match an existing string property/);
815
+ assert.deepEqual(result.report.errors, []);
816
+ assert.equal(result.report.requests.some(request => request.method === 'update_event'), false);
817
+ });
818
+
658
819
  test('a property neighbor must resolve to an existing string property', t => {
659
820
  const category = { id: 'fixture', title: 'Fixture', notes: '' };
660
821
  const result = runSync(t, {