@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 +29 -0
- package/README.md +8 -1
- package/docs/help.md +18 -0
- package/docs/sync.md +5 -1
- package/docs/two-way-git-tutorial.md +1 -1
- package/index.js +5 -3
- package/lib/jobs.js +2 -2
- package/lib/sync.js +88 -11
- package/lib/upgrade.js +126 -0
- package/package.json +2 -2
- package/test/help.test.js +6 -0
- package/test/helpers/upgrade-preload.js +44 -0
- package/test/sync-unit.test.js +233 -0
- package/test/upgrade.test.js +121 -0
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
195
|
-
|
|
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
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
|
|
223
|
+
if (!nstem.startsWith(stem + '-')) return; // no exact basename boundary
|
|
200
224
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
var
|
|
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.
|
|
228
|
-
if (typeof(
|
|
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 (!
|
|
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 =
|
|
706
|
+
var value = this.getSyncPropByPath( data, path ) ?? "";
|
|
678
707
|
|
|
679
|
-
if (!
|
|
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 =
|
|
1009
|
+
var extra = this.getSyncPropByPath(item, path);
|
|
981
1010
|
if ((typeof(extra) == 'string') && extra.match(/\S/)) {
|
|
982
1011
|
extras.push({ path, content: extra });
|
|
983
|
-
|
|
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.
|
|
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
|
+
};
|
package/test/sync-unit.test.js
CHANGED
|
@@ -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
|
+
});
|