@pixlcore/xycli 1.0.5 → 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,34 @@
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
+
11
+ ## Version v1.0.5
12
+
13
+ > September 21, 2026
14
+
15
+ - [`2542921`](https://github.com/pixlcore/xycli/commit/25429212b664130bf767db85f52610c8d07c00aa): Version 1.0.5
16
+ - [`8818ec6`](https://github.com/pixlcore/xycli/commit/8818ec6291c0d06ec6b48174443eaf330682cff2): Bug FIx: Help system was breaking on Windows due to CRLF line endings.
17
+
18
+ ## Version v1.0.4
19
+
20
+ > September 21, 2026
21
+
22
+ - [`553d4d9`](https://github.com/pixlcore/xycli/commit/553d4d95eec0dd4e966dba03d1cccadee49454fc): Version 1.0.4
23
+ - [`4a68d5a`](https://github.com/pixlcore/xycli/commit/4a68d5ad6ab1d2c28b5a5a933e6c6ec711fd2d90): Bug Fix: Validate local command routing before requiring a configured server.
24
+
25
+ ## Version v1.0.3
26
+
27
+ > September 21, 2026
28
+
29
+ - [`00c2805`](https://github.com/pixlcore/xycli/commit/00c280596899cee1de66a4179684d1a7992ee1d9): Version 1.0.3
30
+ - [`82777d0`](https://github.com/pixlcore/xycli/commit/82777d01cb751769e14987a7cd86e0d71f91ee79): Feature: Add Windows support. Fixes #3.
31
+
3
32
  ## Version v1.0.2
4
33
 
5
34
  > September 20, 2026
package/README.md CHANGED
@@ -43,6 +43,13 @@ npm install -g @pixlcore/xycli
43
43
 
44
44
  This installs the `xy` command. In the xyOps web interface, open **API Keys** and create a key for the CLI. Choose privileges and resource access appropriate for the work you want to do. The [tutorial](docs/tutorial.md) needs access to view Events and servers, create and edit Events, run jobs, and create and test Web Hooks. System administration commands require a full administrator key.
45
45
 
46
+ To check for a newer xyCLI release, use `xy upgrade`. The command displays the installed and latest npm versions without making any changes. Add `--confirm` to install the version shown.
47
+
48
+ ```sh
49
+ xy upgrade
50
+ xy upgrade --confirm
51
+ ```
52
+
46
53
  Save your xyOps URL and API Key:
47
54
 
48
55
  ```sh
@@ -492,7 +499,7 @@ cd xyops-automation
492
499
  xy sync setup events plugins categories --file_props script,params.script
493
500
  ```
494
501
 
495
- 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.
496
503
 
497
504
  Preview a push or pull, then apply the direction you want:
498
505
 
package/docs/help.md CHANGED
@@ -24,6 +24,7 @@ xy tags
24
24
  xy tickets
25
25
  xy hooks
26
26
  xy marketplace
27
+ xy upgrade
27
28
  xy event EVENT_ID --export event.json
28
29
  xy import event.json
29
30
  xy sync ./ --up events,plugins --dry
@@ -95,6 +96,7 @@ xy help marketplace
95
96
  xy help marketplace search
96
97
  xy help marketplace get
97
98
  xy help marketplace install
99
+ xy help upgrade
98
100
  xy help export
99
101
  xy help import
100
102
  xy help sync
@@ -306,12 +308,28 @@ Setup creates one folder per resource type and one XYPDF JSON file per object, u
306
308
 
307
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.
308
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
+
309
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.
310
314
 
311
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.
312
316
 
313
317
  Without `--force`, ordinary setup stops when an output file already exists. Files written before an error are not rolled back. Ensure object titles produce distinct filename slugs, especially when using `--force`, and back up any local edits before rerunning setup. For a delete-mode inventory, verify that every object eligible for deletion that you intend to keep has a source file. Stock and Marketplace objects are protected from deletion and may be omitted.
314
318
 
319
+ ## upgrade
320
+
321
+ Check the installed xyCLI version against the latest version published to npm. This command is local and does not require an xyOps URL or API Key.
322
+
323
+ ```sh
324
+ xy upgrade
325
+ xy upgrade --confirm
326
+ xy upgrade --confirm --dry
327
+ ```
328
+
329
+ The command always displays the installed and latest versions. When an upgrade is available, add `--confirm` to install the exact version shown using npm. npm output is connected directly to the current terminal. A dry run prints the installation command without executing it.
330
+
331
+ The command never downgrades xyCLI. If the installed version is newer than npm's `latest` version, it reports that status and exits without running npm. Global npm installations may require a user-writable npm prefix; the command does not invoke `sudo` or otherwise elevate privileges.
332
+
315
333
  ## api
316
334
 
317
335
  Call any API method exposed by the xyOps SDK and print its JSON request and response. Named options become request properties, and dotted names provide a convenient way to build nested objects.
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/index.js CHANGED
@@ -54,7 +54,7 @@ const JSON_ARG_EXCEPTIONS = ['body'];
54
54
  // Keep the public command inventory in one place, so typo suggestions and the
55
55
  // fallback help text always advertise the exact same top-level commands.
56
56
  const TOP_LEVEL_COMMANDS = [
57
- 'help', 'config', 'doc', 'dashboard', 'system', 'upcoming',
57
+ 'help', 'config', 'upgrade', 'doc', 'dashboard', 'system', 'upcoming',
58
58
  'alerts', 'alert', 'buckets', 'bucket', 'categories', 'category',
59
59
  'channels', 'channel', 'events', 'event', 'run', 'groups', 'group',
60
60
  'jobs', 'job', 'keys', 'key', 'log', 'monitors', 'monitor',
@@ -191,8 +191,9 @@ const app = {
191
191
 
192
192
  println( "\n " + cli.emoji('🚀') + " " + this.color('theme').bold("xyOps CLI ") + gray("v" + this.version) );
193
193
 
194
- // call config cmd early (before contacting xyops)
195
- if (cmd == 'config') {
194
+ // Call local commands early, before requiring xyOps credentials or
195
+ // contacting the configured server. The upgrade command only contacts npm.
196
+ if ((cmd == 'config') || (cmd == 'upgrade')) {
196
197
  await this['cmd_' + cmd]();
197
198
  print("\n");
198
199
  return;
@@ -428,6 +429,7 @@ const app = {
428
429
 
429
430
  Tools.mergeHashInto( app, require('./lib/utils.js') );
430
431
  Tools.mergeHashInto( app, require('./lib/config.js') );
432
+ Tools.mergeHashInto( app, require('./lib/upgrade.js') );
431
433
  Tools.mergeHashInto( app, require('./lib/doc.js') );
432
434
  Tools.mergeHashInto( app, require('./lib/dashboard.js') );
433
435
  Tools.mergeHashInto( app, require('./lib/system.js') );
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
@@ -100,6 +100,30 @@ module.exports = {
100
100
 
101
101
  println( " " + Tools.commify(files.length) + " files found." );
102
102
 
103
+ // Index XYPDF basenames by directory before attaching any neighbor files.
104
+ // A source such as "Test-Event.json" is a prefix of both
105
+ // "Test-Event-V2.json" and "Test-Event-V2-params.command.ps1". Sorting
106
+ // longest-first lets each external property file belong to the most specific
107
+ // source basename instead of also being claimed by every shorter prefix.
108
+ var source_stems_by_dir = new Map();
109
+ files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
110
+ var xypdf = null;
111
+ try { xypdf = Tools.parseJSON( fs.readFileSync(file.path, 'utf8') ); }
112
+ catch (err) { return; } // the normal scan below reports malformed JSON
113
+ if (!xypdf || !Tools.isaHash(xypdf) || (xypdf.type !== 'xypdf')) return;
114
+
115
+ var dir = Path.dirname(file.path);
116
+ var stems = source_stems_by_dir.get(dir);
117
+ if (!stems) {
118
+ stems = [];
119
+ source_stems_by_dir.set(dir, stems);
120
+ }
121
+ stems.push( Path.basename(file.path).replace(/\.\w+$/, '') );
122
+ } );
123
+ source_stems_by_dir.forEach( stems => {
124
+ stems.sort( (a, b) => b.length - a.length );
125
+ } );
126
+
103
127
  // load all xypdf files
104
128
  var items = [];
105
129
 
@@ -196,11 +220,16 @@ module.exports = {
196
220
  var nfilename = Path.basename(neighbor.path);
197
221
  var nstem = nfilename.replace(/\.\w+$/, '');
198
222
  if (nstem.length <= stem.length) return; // neighbor stem too short
199
- if (!nstem.startsWith(stem)) return; // no shared stem
223
+ if (!nstem.startsWith(stem + '-')) return; // no exact basename boundary
200
224
 
201
- var suffix = nstem.substring(stem.length);
202
- if (!suffix.match(/\-([\w\.\-]+)$/)) return; // not correct format
203
- var prop_path = RegExp.$1;
225
+ // Multiple XYPDF sources may share a title prefix. The longest matching
226
+ // source stem owns this neighbor, so shorter sources must ignore it.
227
+ var source_stems = source_stems_by_dir.get(dir) || [];
228
+ var owner_stem = source_stems.find( source_stem => nstem.startsWith(source_stem + '-') );
229
+ if (owner_stem && (owner_stem !== stem)) return;
230
+
231
+ var prop_path = nstem.substring(stem.length + 1);
232
+ if (!prop_path.match(/^[\w\.\-]+$/)) return; // not correct format
204
233
 
205
234
  var content = '';
206
235
  try {
@@ -224,13 +253,13 @@ module.exports = {
224
253
  }
225
254
 
226
255
  // External files may only replace string properties that already exist in
227
- // the definition. Tools.setPath() creates missing keys, so validate first.
228
- 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') {
229
258
  this.logSyncWarning("Neighbor file does not match an existing string property: " + neighbor.path + " (" + prop_path + ")");
230
259
  neighbor_error = true;
231
260
  return;
232
261
  }
233
- if (!Tools.setPath(item.data, prop_path, content)) {
262
+ if (!this.setSyncPropByPath(item.data, prop_path, content)) {
234
263
  this.logSyncWarning("Invalid neighbor property path: " + neighbor.path + " (" + prop_path + ")");
235
264
  neighbor_error = true;
236
265
  return;
@@ -674,9 +703,9 @@ module.exports = {
674
703
  if (item.neighbors) {
675
704
  for (var path in item.neighbors) {
676
705
  var file = item.neighbors[path];
677
- var value = Tools.getPath( data, path ) ?? "";
706
+ var value = this.getSyncPropByPath( data, path ) ?? "";
678
707
 
679
- if (!Tools.setPath( data, path, "(External)" )) {
708
+ if (!this.setSyncPropByPath( data, path, "(External)" )) {
680
709
  throw "External neighbor file path not found: " + file + ": " + path;
681
710
  }
682
711
 
@@ -977,10 +1006,22 @@ module.exports = {
977
1006
  var item = Tools.copyHash(plan.source_item, true);
978
1007
  var extras = [];
979
1008
  (opts.file_props || []).forEach( path => {
980
- var extra = Tools.getPath(item, path);
1009
+ var extra = this.getSyncPropByPath(item, path);
981
1010
  if ((typeof(extra) == 'string') && extra.match(/\S/)) {
982
1011
  extras.push({ path, content: extra });
983
- 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
+ } );
984
1025
  }
985
1026
  });
986
1027
 
@@ -1159,6 +1200,42 @@ module.exports = {
1159
1200
  }
1160
1201
 
1161
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);
1162
1239
  }
1163
1240
 
1164
1241
  };
package/lib/upgrade.js ADDED
@@ -0,0 +1,126 @@
1
+ // xyCLI Self-Upgrade Layer
2
+
3
+ const cp = require('node:child_process');
4
+
5
+ const cli = require('pixl-cli');
6
+ const chalk = cli.chalk;
7
+
8
+ const PACKAGE_NAME = '@pixlcore/xycli';
9
+ const REGISTRY_URL = 'https://registry.npmjs.org/@pixlcore%2Fxycli/latest';
10
+ const REGISTRY_TIMEOUT_MS = 10000;
11
+
12
+ module.exports = {
13
+
14
+ async cmd_upgrade() {
15
+ // This is a deliberately small local command. It must remain usable before
16
+ // the user has configured an xyOps URL or API key.
17
+ if (this.args.other.length) this.dieUsage('upgrade');
18
+ if (('confirm' in this.args) && (typeof(this.args.confirm) != 'boolean')) {
19
+ this.die("Upgrade --confirm must be true or false.");
20
+ }
21
+
22
+ // These common switches are consumed by the main command runner after local
23
+ // commands return. Ignore them here, but reject command-specific typos.
24
+ var allowed = ['other', 'confirm', 'debug', 'echo', 'quiet', 'verbose'];
25
+ var unsupported = Object.keys(this.args).find( key => !allowed.includes(key) );
26
+ if (unsupported) this.die("Unsupported upgrade option: --" + unsupported);
27
+
28
+ var installed_version = this.version;
29
+ var latest_version = await this.getLatestXYCLIVersion();
30
+ var comparison = this.compareVersions(latest_version, installed_version);
31
+ var status = comparison > 0 ? this.color('blue').bold('Upgrade available') :
32
+ comparison < 0 ? this.color('yellow').bold('Installed version is newer than npm') :
33
+ this.color('green').bold('Up to date');
34
+
35
+ this.printBoxList({
36
+ title: 'xyCLI Version',
37
+ rows: [
38
+ [ 'Installed Version', 'v' + installed_version ],
39
+ [ 'Latest Version', 'v' + latest_version ],
40
+ [ 'Status', status ]
41
+ ]
42
+ });
43
+
44
+ // A different version is not necessarily an upgrade. In particular, local
45
+ // development builds must never be silently downgraded to npm's latest tag.
46
+ if (comparison <= 0) return;
47
+
48
+ if (this.args.confirm !== true) {
49
+ this.toast('ℹ️', 'blue', "A new xyCLI version is available! Add '--confirm' to perform the upgrade now.");
50
+ return;
51
+ }
52
+
53
+ // Install the exact version shown above. This prevents the npm latest tag
54
+ // from moving between the version check and the confirmed installation.
55
+ var command = 'npm install --global ' + PACKAGE_NAME + '@' + latest_version;
56
+ println( "\n " + this.color('green').bold('Running command: ') + chalk.bold(command) );
57
+
58
+ if (this.dry) {
59
+ this.toast('⚠️', 'orange', "DRY RUN: The upgrade command was not executed.");
60
+ return;
61
+ }
62
+
63
+ var result;
64
+ try {
65
+ result = await this.spawnUpgradeCommand(command);
66
+ }
67
+ catch (err) {
68
+ this.die("Could not start the upgrade command: " + err.message);
69
+ }
70
+
71
+ if (result.signal) this.die("Upgrade command was terminated by signal: " + result.signal);
72
+ if (result.code !== 0) this.die("Upgrade command failed with exit code: " + result.code);
73
+
74
+ this.toast('✅', 'green', 'Successfully upgraded xyCLI to v' + latest_version + '.');
75
+ },
76
+
77
+ async getLatestXYCLIVersion() {
78
+ // The npm latest document is authoritative for the version that an
79
+ // unqualified global npm install would select.
80
+ this.cli.progress.start({ amount: 1, pct: false, text: this.color('gray')('Checking npm for the latest xyCLI version...') });
81
+
82
+ var response;
83
+ try {
84
+ response = await fetch(REGISTRY_URL, {
85
+ headers: {
86
+ 'Accept': 'application/json',
87
+ 'User-Agent': PACKAGE_NAME + '/v' + this.version
88
+ },
89
+ signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS)
90
+ });
91
+ }
92
+ catch (err) {
93
+ this.cli.progress.end();
94
+ this.die("Could not contact the npm registry: " + err.message);
95
+ }
96
+
97
+ this.cli.progress.end();
98
+ if (!response.ok) this.die("npm registry request failed with HTTP status: " + response.status);
99
+
100
+ var data;
101
+ try { data = await response.json(); }
102
+ catch (err) { this.die("Could not parse the npm registry response: " + err.message); }
103
+
104
+ if (!data || (typeof(data.version) != 'string') || !data.version.length) {
105
+ this.die("The npm registry response did not include a package version.");
106
+ }
107
+
108
+ return data.version;
109
+ },
110
+
111
+ spawnUpgradeCommand(command) {
112
+ // npm is npm.cmd on Windows, so launch the fixed, validated command through
113
+ // the platform shell. Inherited stdio gives npm full use of the terminal.
114
+ return new Promise((resolve, reject) => {
115
+ var child = cp.spawn(command, {
116
+ shell: true,
117
+ stdio: 'inherit',
118
+ windowsHide: false
119
+ });
120
+
121
+ child.once('error', reject);
122
+ child.once('close', (code, signal) => resolve({ code, signal }));
123
+ });
124
+ }
125
+
126
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixlcore/xycli",
3
- "version": "1.0.5",
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",
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "scripts": {
23
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",
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/upgrade.test.js test/utils-unit.test.js",
25
25
  "changelog": "node tools/changelog.js"
26
26
  },
27
27
  "dependencies": {
package/test/help.test.js CHANGED
@@ -18,6 +18,12 @@ test('help is available without server credentials', () => {
18
18
  assert.match(output, /HELP: OVERVIEW/);
19
19
  });
20
20
 
21
+ test('upgrade help is available without server credentials', () => {
22
+ const output = xy(['help', 'upgrade'], { env: offlineEnv });
23
+ assert.match(output, /HELP: UPGRADE/);
24
+ assert.match(output, /xy upgrade --confirm/);
25
+ });
26
+
21
27
  test('help parses a document with Windows line endings', () => {
22
28
  const output = xy(['help'], { env: offlineEnv, preload: crlfHelpPreload });
23
29
  assert.match(output, /HELP: OVERVIEW/);
@@ -0,0 +1,44 @@
1
+ // Replace npm registry access and the final npm process for offline CLI tests.
2
+ const fs = require('node:fs');
3
+ const cp = require('node:child_process');
4
+ const { EventEmitter } = require('node:events');
5
+
6
+ global.fetch = async function(url, options) {
7
+ if (process.env.XYCLI_TEST_REQUEST_FILE) {
8
+ fs.writeFileSync(process.env.XYCLI_TEST_REQUEST_FILE, JSON.stringify({
9
+ url: url,
10
+ headers: options.headers
11
+ }));
12
+ }
13
+
14
+ if (process.env.XYCLI_TEST_FETCH_ERROR) {
15
+ throw new Error(process.env.XYCLI_TEST_FETCH_ERROR);
16
+ }
17
+
18
+ var status = Number(process.env.XYCLI_TEST_HTTP_STATUS || 200);
19
+ return {
20
+ ok: (status >= 200) && (status < 300),
21
+ status: status,
22
+ async json() {
23
+ return { version: process.env.XYCLI_TEST_LATEST_VERSION };
24
+ }
25
+ };
26
+ };
27
+
28
+ cp.spawn = function(command, options) {
29
+ if (process.env.XYCLI_TEST_SPAWN_FILE) {
30
+ fs.writeFileSync(process.env.XYCLI_TEST_SPAWN_FILE, JSON.stringify({
31
+ command: command,
32
+ shell: options.shell,
33
+ stdio: options.stdio,
34
+ windowsHide: options.windowsHide
35
+ }));
36
+ }
37
+
38
+ var child = new EventEmitter();
39
+ process.nextTick(() => {
40
+ if (process.env.XYCLI_TEST_SPAWN_ERROR) child.emit('error', new Error(process.env.XYCLI_TEST_SPAWN_ERROR));
41
+ else child.emit('close', Number(process.env.XYCLI_TEST_SPAWN_EXIT || 0), null);
42
+ });
43
+ return child;
44
+ };
@@ -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'],
@@ -583,6 +638,184 @@ test('a prefixed XYPDF source is never loaded as a property neighbor', t => {
583
638
  assert.equal(result.report.requests.some(request => request.method === 'update_category'), false);
584
639
  });
585
640
 
641
+ test('a property neighbor belongs to the longest matching XYPDF source stem', t => {
642
+ const shortCommand = 'Write-Host "Test Event"\n';
643
+ const longCommand = 'Write-Host "Test Event V2"\n';
644
+ const shortEvent = { id: 'short', title: 'Test Event', params: { command: shortCommand } };
645
+ const longEvent = { id: 'long', title: 'Test Event V2', params: { command: longCommand } };
646
+ const result = runSync(t, {
647
+ events: [shortEvent, longEvent],
648
+ existingSources: [
649
+ {
650
+ path: 'Test-Event.json',
651
+ type: 'event',
652
+ data: { ...shortEvent, params: { command: '(External)' } }
653
+ },
654
+ {
655
+ path: 'Test-Event-params.command.ps1',
656
+ contents: shortCommand
657
+ },
658
+ {
659
+ path: 'Test-Event-V2.json',
660
+ type: 'event',
661
+ data: { ...longEvent, params: { command: '(External)' } }
662
+ },
663
+ {
664
+ path: 'Test-Event-V2-params.command.ps1',
665
+ contents: longCommand
666
+ }
667
+ ],
668
+ args: { up: 'events', dry: true }
669
+ });
670
+
671
+ assert.equal(result.status, 0);
672
+ assert.deepEqual(result.report.warnings, []);
673
+ assert.deepEqual(result.report.errors, []);
674
+ assert.equal(result.report.requests.some(request => request.method === 'update_event'), false);
675
+ });
676
+
677
+ test('property neighbor matching requires an exact source basename boundary', t => {
678
+ const singularCommand = 'Write-Host "Test Event"\n';
679
+ const pluralCommand = 'Write-Host "Test Events"\n';
680
+ const singularEvent = { id: 'singular', title: 'Test Event', params: { command: singularCommand } };
681
+ const pluralEvent = { id: 'plural', title: 'Test Events', params: { command: pluralCommand } };
682
+ const result = runSync(t, {
683
+ events: [singularEvent, pluralEvent],
684
+ existingSources: [
685
+ {
686
+ path: 'Test-Event.json',
687
+ type: 'event',
688
+ data: { ...singularEvent, params: { command: '(External)' } }
689
+ },
690
+ {
691
+ path: 'Test-Event-params.command.ps1',
692
+ contents: singularCommand
693
+ },
694
+ {
695
+ path: 'Test-Events.json',
696
+ type: 'event',
697
+ data: { ...pluralEvent, params: { command: '(External)' } }
698
+ },
699
+ {
700
+ path: 'Test-Events-params.command.ps1',
701
+ contents: pluralCommand
702
+ }
703
+ ],
704
+ args: { up: 'events', dry: true }
705
+ });
706
+
707
+ assert.equal(result.status, 0);
708
+ assert.deepEqual(result.report.warnings, []);
709
+ assert.deepEqual(result.report.errors, []);
710
+ assert.equal(result.report.requests.some(request => request.method === 'update_event'), false);
711
+ });
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
+
586
819
  test('a property neighbor must resolve to an existing string property', t => {
587
820
  const category = { id: 'fixture', title: 'Fixture', notes: '' };
588
821
  const result = runSync(t, {
@@ -0,0 +1,121 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const Path = require('node:path');
6
+ const pkg = require('../package.json');
7
+ const { xy } = require('./helpers/common.js');
8
+
9
+ const preload = Path.join(__dirname, 'helpers', 'upgrade-preload.js');
10
+ const offlineEnv = {
11
+ XYOPS_API_KEY: '',
12
+ XYOPS_BASE_URL: ''
13
+ };
14
+
15
+ function createUpgradeFixture(t, latest) {
16
+ // Each command gets private observation files. The preload records registry
17
+ // and spawn calls without contacting npm or installing anything globally.
18
+ const dir = fs.mkdtempSync(Path.join(os.tmpdir(), 'xycli-upgrade-'));
19
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
20
+
21
+ return {
22
+ requestFile: Path.join(dir, 'request.json'),
23
+ spawnFile: Path.join(dir, 'spawn.json'),
24
+ run(args, options = {}) {
25
+ return xy(['upgrade', ...args], {
26
+ preload: preload,
27
+ fail: options.fail,
28
+ env: {
29
+ ...offlineEnv,
30
+ XYCLI_TEST_LATEST_VERSION: latest,
31
+ XYCLI_TEST_REQUEST_FILE: this.requestFile,
32
+ XYCLI_TEST_SPAWN_FILE: this.spawnFile,
33
+ ...options.env
34
+ }
35
+ });
36
+ }
37
+ };
38
+ }
39
+
40
+ test('upgrade checks npm and works without xyOps credentials', t => {
41
+ const fixture = createUpgradeFixture(t, pkg.version);
42
+ const output = fixture.run([]);
43
+ const request = JSON.parse(fs.readFileSync(fixture.requestFile, 'utf8'));
44
+ const escaped_version = pkg.version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
45
+
46
+ assert.equal(request.url, 'https://registry.npmjs.org/@pixlcore%2Fxycli/latest');
47
+ assert.equal(request.headers.Accept, 'application/json');
48
+ assert.match(request.headers['User-Agent'], /^@pixlcore\/xycli\/v/);
49
+ assert.match(output, new RegExp('Installed Version:\\s+v' + escaped_version));
50
+ assert.match(output, new RegExp('Latest Version:\\s+v' + escaped_version));
51
+ assert.match(output, /Up to date/);
52
+ assert.equal(fs.existsSync(fixture.spawnFile), false);
53
+ });
54
+
55
+ test('upgrade advertises a newer version without installing it', t => {
56
+ const fixture = createUpgradeFixture(t, '99.0.0');
57
+ const output = fixture.run([]);
58
+
59
+ assert.match(output, /Upgrade available/);
60
+ assert.match(output, /A new xyCLI version is available!/);
61
+ assert.match(output, /--confirm/);
62
+ assert.equal(fs.existsSync(fixture.spawnFile), false);
63
+ });
64
+
65
+ test('confirmed upgrade installs the exact checked version with inherited stdio', t => {
66
+ const fixture = createUpgradeFixture(t, '99.0.0');
67
+ const output = fixture.run(['--confirm']);
68
+ const spawn = JSON.parse(fs.readFileSync(fixture.spawnFile, 'utf8'));
69
+
70
+ assert.match(output, /Running command:\s+npm install --global @pixlcore\/xycli@99\.0\.0/);
71
+ assert.match(output, /Successfully upgraded xyCLI to v99\.0\.0/);
72
+ assert.equal(spawn.command, 'npm install --global @pixlcore/xycli@99.0.0');
73
+ assert.equal(spawn.shell, true);
74
+ assert.equal(spawn.stdio, 'inherit');
75
+ assert.equal(spawn.windowsHide, false);
76
+ });
77
+
78
+ test('upgrade dry run prints the pinned command without spawning npm', t => {
79
+ const fixture = createUpgradeFixture(t, '99.0.0');
80
+ const output = fixture.run(['--confirm', '--dry']);
81
+
82
+ assert.match(output, /npm install --global @pixlcore\/xycli@99\.0\.0/);
83
+ assert.match(output, /DRY RUN: The upgrade command was not executed/);
84
+ assert.equal(fs.existsSync(fixture.spawnFile), false);
85
+ });
86
+
87
+ test('upgrade never downgrades a newer local version', t => {
88
+ const fixture = createUpgradeFixture(t, '0.0.1');
89
+ const output = fixture.run(['--confirm']);
90
+
91
+ assert.match(output, /Installed version is newer than npm/);
92
+ assert.equal(fs.existsSync(fixture.spawnFile), false);
93
+ });
94
+
95
+ test('upgrade rejects malformed registry versions before spawning a shell', t => {
96
+ const fixture = createUpgradeFixture(t, '99.0.0; unwanted-command');
97
+ const output = fixture.run(['--confirm'], { fail: true });
98
+
99
+ assert.match(output, /Invalid version/);
100
+ assert.equal(fs.existsSync(fixture.spawnFile), false);
101
+ });
102
+
103
+ test('upgrade reports registry and npm process failures', async t => {
104
+ await t.test('registry failure', t => {
105
+ const fixture = createUpgradeFixture(t, '99.0.0');
106
+ const output = fixture.run([], {
107
+ fail: true,
108
+ env: { XYCLI_TEST_FETCH_ERROR: 'fixture network failure' }
109
+ });
110
+ assert.match(output, /Could not contact the npm registry: fixture network failure/);
111
+ });
112
+
113
+ await t.test('npm failure', t => {
114
+ const fixture = createUpgradeFixture(t, '99.0.0');
115
+ const output = fixture.run(['--confirm'], {
116
+ fail: true,
117
+ env: { XYCLI_TEST_SPAWN_EXIT: '7' }
118
+ });
119
+ assert.match(output, /Upgrade command failed with exit code: 7/);
120
+ });
121
+ });