@pixlcore/xycli 1.0.4 → 1.0.6

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,26 @@
1
1
  # xyCLI Changelog
2
2
 
3
+ ## Version v1.0.5
4
+
5
+ > September 21, 2026
6
+
7
+ - [`2542921`](https://github.com/pixlcore/xycli/commit/25429212b664130bf767db85f52610c8d07c00aa): Version 1.0.5
8
+ - [`8818ec6`](https://github.com/pixlcore/xycli/commit/8818ec6291c0d06ec6b48174443eaf330682cff2): Bug FIx: Help system was breaking on Windows due to CRLF line endings.
9
+
10
+ ## Version v1.0.4
11
+
12
+ > September 21, 2026
13
+
14
+ - [`553d4d9`](https://github.com/pixlcore/xycli/commit/553d4d95eec0dd4e966dba03d1cccadee49454fc): Version 1.0.4
15
+ - [`4a68d5a`](https://github.com/pixlcore/xycli/commit/4a68d5ad6ab1d2c28b5a5a933e6c6ec711fd2d90): Bug Fix: Validate local command routing before requiring a configured server.
16
+
17
+ ## Version v1.0.3
18
+
19
+ > September 21, 2026
20
+
21
+ - [`00c2805`](https://github.com/pixlcore/xycli/commit/00c280596899cee1de66a4179684d1a7992ee1d9): Version 1.0.3
22
+ - [`82777d0`](https://github.com/pixlcore/xycli/commit/82777d01cb751769e14987a7cd86e0d71f91ee79): Feature: Add Windows support. Fixes #3.
23
+
3
24
  ## Version v1.0.2
4
25
 
5
26
  > 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
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
@@ -312,6 +314,20 @@ An explicitly supplied `--down_cmd` runs from the current setup directory after
312
314
 
313
315
  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
316
 
317
+ ## upgrade
318
+
319
+ 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.
320
+
321
+ ```sh
322
+ xy upgrade
323
+ xy upgrade --confirm
324
+ xy upgrade --confirm --dry
325
+ ```
326
+
327
+ 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.
328
+
329
+ 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.
330
+
315
331
  ## api
316
332
 
317
333
  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/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;
@@ -380,7 +381,10 @@ const app = {
380
381
  printHelp(heading) {
381
382
  // print named section from help file
382
383
  heading = heading.toLowerCase();
383
- var md = fs.readFileSync(Path.join(__dirname, 'docs', 'help.md'), 'utf8').trim() + "\n\n# end sentinel\n";
384
+ // Git may check this file out with CRLF on Windows. Normalize it before
385
+ // matching headings so the parser behaves identically on every platform.
386
+ var md = fs.readFileSync(Path.join(__dirname, 'docs', 'help.md'), 'utf8')
387
+ .replace(/\r\n?/g, '\n').trim() + "\n\n# end sentinel\n";
384
388
  var re = new RegExp( "(^|\\n)(\\#+)\\s+(" + Tools.escapeRegExp(heading) + ")\\n([\\s\\S]*?)\\n\#+\\s+" );
385
389
  var matches = md.match(re);
386
390
  if (!matches) {
@@ -425,6 +429,7 @@ const app = {
425
429
 
426
430
  Tools.mergeHashInto( app, require('./lib/utils.js') );
427
431
  Tools.mergeHashInto( app, require('./lib/config.js') );
432
+ Tools.mergeHashInto( app, require('./lib/upgrade.js') );
428
433
  Tools.mergeHashInto( app, require('./lib/doc.js') );
429
434
  Tools.mergeHashInto( app, require('./lib/dashboard.js') );
430
435
  Tools.mergeHashInto( app, require('./lib/system.js') );
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
224
+
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;
200
230
 
201
- var suffix = nstem.substring(stem.length);
202
- if (!suffix.match(/\-([\w\.\-]+)$/)) return; // not correct format
203
- var prop_path = RegExp.$1;
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 {
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.4",
3
+ "version": "1.0.6",
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
@@ -1,5 +1,6 @@
1
1
  const { test } = require('node:test');
2
2
  const assert = require('node:assert/strict');
3
+ const Path = require('node:path');
3
4
  const { xy } = require('./helpers/common.js');
4
5
 
5
6
  // Deliberately shadow any developer-machine configuration. Help and command
@@ -10,12 +11,24 @@ const offlineEnv = {
10
11
  };
11
12
 
12
13
  const offlineFailure = { fail: true, env: offlineEnv };
14
+ const crlfHelpPreload = Path.join(__dirname, 'helpers', 'crlf-help.js');
13
15
 
14
16
  test('help is available without server credentials', () => {
15
17
  const output = xy(['help'], { env: offlineEnv });
16
18
  assert.match(output, /HELP: OVERVIEW/);
17
19
  });
18
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
+
27
+ test('help parses a document with Windows line endings', () => {
28
+ const output = xy(['help'], { env: offlineEnv, preload: crlfHelpPreload });
29
+ assert.match(output, /HELP: OVERVIEW/);
30
+ });
31
+
19
32
  test('help suggests the closest chapter for a typo', () => {
20
33
  const output = xy(['help', 'event', 'udpate'], offlineFailure);
21
34
  assert.match(output, /Could not find help chapter for "event udpate"\. Did you mean "event update"\?/);
@@ -0,0 +1,18 @@
1
+ // Force the help document through the same CRLF checkout transformation Git
2
+ // commonly applies on Windows, so this regression is covered on every runner.
3
+ const fs = require('node:fs');
4
+ const Path = require('node:path');
5
+ const readFileSync = fs.readFileSync;
6
+
7
+ fs.readFileSync = function(file) {
8
+ var contents = readFileSync.apply(this, arguments);
9
+ var filename = String(file);
10
+ var is_help = (Path.basename(filename) == 'help.md') &&
11
+ (Path.basename(Path.dirname(filename)) == 'docs');
12
+
13
+ if (is_help && (typeof(contents) == 'string')) {
14
+ return contents.replace(/\r?\n/g, '\r\n');
15
+ }
16
+
17
+ return contents;
18
+ };
@@ -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
+ };
@@ -583,6 +583,78 @@ test('a prefixed XYPDF source is never loaded as a property neighbor', t => {
583
583
  assert.equal(result.report.requests.some(request => request.method === 'update_category'), false);
584
584
  });
585
585
 
586
+ test('a property neighbor belongs to the longest matching XYPDF source stem', t => {
587
+ const shortCommand = 'Write-Host "Test Event"\n';
588
+ const longCommand = 'Write-Host "Test Event V2"\n';
589
+ const shortEvent = { id: 'short', title: 'Test Event', params: { command: shortCommand } };
590
+ const longEvent = { id: 'long', title: 'Test Event V2', params: { command: longCommand } };
591
+ const result = runSync(t, {
592
+ events: [shortEvent, longEvent],
593
+ existingSources: [
594
+ {
595
+ path: 'Test-Event.json',
596
+ type: 'event',
597
+ data: { ...shortEvent, params: { command: '(External)' } }
598
+ },
599
+ {
600
+ path: 'Test-Event-params.command.ps1',
601
+ contents: shortCommand
602
+ },
603
+ {
604
+ path: 'Test-Event-V2.json',
605
+ type: 'event',
606
+ data: { ...longEvent, params: { command: '(External)' } }
607
+ },
608
+ {
609
+ path: 'Test-Event-V2-params.command.ps1',
610
+ contents: longCommand
611
+ }
612
+ ],
613
+ args: { up: 'events', dry: true }
614
+ });
615
+
616
+ assert.equal(result.status, 0);
617
+ assert.deepEqual(result.report.warnings, []);
618
+ assert.deepEqual(result.report.errors, []);
619
+ assert.equal(result.report.requests.some(request => request.method === 'update_event'), false);
620
+ });
621
+
622
+ test('property neighbor matching requires an exact source basename boundary', t => {
623
+ const singularCommand = 'Write-Host "Test Event"\n';
624
+ const pluralCommand = 'Write-Host "Test Events"\n';
625
+ const singularEvent = { id: 'singular', title: 'Test Event', params: { command: singularCommand } };
626
+ const pluralEvent = { id: 'plural', title: 'Test Events', params: { command: pluralCommand } };
627
+ const result = runSync(t, {
628
+ events: [singularEvent, pluralEvent],
629
+ existingSources: [
630
+ {
631
+ path: 'Test-Event.json',
632
+ type: 'event',
633
+ data: { ...singularEvent, params: { command: '(External)' } }
634
+ },
635
+ {
636
+ path: 'Test-Event-params.command.ps1',
637
+ contents: singularCommand
638
+ },
639
+ {
640
+ path: 'Test-Events.json',
641
+ type: 'event',
642
+ data: { ...pluralEvent, params: { command: '(External)' } }
643
+ },
644
+ {
645
+ path: 'Test-Events-params.command.ps1',
646
+ contents: pluralCommand
647
+ }
648
+ ],
649
+ args: { up: 'events', dry: true }
650
+ });
651
+
652
+ assert.equal(result.status, 0);
653
+ assert.deepEqual(result.report.warnings, []);
654
+ assert.deepEqual(result.report.errors, []);
655
+ assert.equal(result.report.requests.some(request => request.method === 'update_event'), false);
656
+ });
657
+
586
658
  test('a property neighbor must resolve to an existing string property', t => {
587
659
  const category = { id: 'fixture', title: 'Fixture', notes: '' };
588
660
  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
+ });