@pixlcore/xycli 1.0.0

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.
Files changed (63) hide show
  1. package/README.md +543 -0
  2. package/docs/help.md +2587 -0
  3. package/docs/screenshots.md +219 -0
  4. package/docs/sync.md +920 -0
  5. package/docs/tutorial.md +204 -0
  6. package/index.js +444 -0
  7. package/lib/alerts.js +735 -0
  8. package/lib/apikey.js +425 -0
  9. package/lib/buckets.js +607 -0
  10. package/lib/categories.js +323 -0
  11. package/lib/channels.js +290 -0
  12. package/lib/config.js +86 -0
  13. package/lib/dashboard.js +237 -0
  14. package/lib/doc.js +131 -0
  15. package/lib/events.js +1314 -0
  16. package/lib/groups.js +1038 -0
  17. package/lib/jobs.js +1254 -0
  18. package/lib/log.js +125 -0
  19. package/lib/marketplace.js +386 -0
  20. package/lib/monitors.js +412 -0
  21. package/lib/plugins.js +491 -0
  22. package/lib/secrets.js +441 -0
  23. package/lib/servers.js +1502 -0
  24. package/lib/snapshots.js +498 -0
  25. package/lib/sync.js +963 -0
  26. package/lib/system.js +612 -0
  27. package/lib/tags.js +250 -0
  28. package/lib/tickets.js +681 -0
  29. package/lib/transfer.js +447 -0
  30. package/lib/utils.js +2745 -0
  31. package/lib/webhook.js +414 -0
  32. package/logo.webp +0 -0
  33. package/package.json +38 -0
  34. package/test/README.md +97 -0
  35. package/test/api-validation.test.js +170 -0
  36. package/test/categories.test.js +246 -0
  37. package/test/channels.test.js +209 -0
  38. package/test/config.test.js +96 -0
  39. package/test/doc.test.js +84 -0
  40. package/test/groups.test.js +211 -0
  41. package/test/help.test.js +26 -0
  42. package/test/helpers/common.js +127 -0
  43. package/test/helpers/pagination-preload.cjs +18 -0
  44. package/test/log.test.js +103 -0
  45. package/test/markdown.test.js +61 -0
  46. package/test/marketplace.test.js +229 -0
  47. package/test/monitors.test.js +227 -0
  48. package/test/pagination.test.js +138 -0
  49. package/test/plugins.test.js +351 -0
  50. package/test/secrets.test.js +271 -0
  51. package/test/servers.test.js +303 -0
  52. package/test/snapshots.test.js +167 -0
  53. package/test/sync-unit.test.js +446 -0
  54. package/test/sync.test.js +542 -0
  55. package/test/system.test.js +94 -0
  56. package/test/tags.test.js +230 -0
  57. package/test/tickets.test.js +261 -0
  58. package/test/toast.test.js +73 -0
  59. package/test/transfer-apikey.test.js +96 -0
  60. package/test/transfer-workflow.test.js +122 -0
  61. package/test/transfer.test.js +314 -0
  62. package/test/webhook.test.js +332 -0
  63. package/test.js +49 -0
@@ -0,0 +1,446 @@
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 { spawn, spawnSync } = require('node:child_process');
7
+ const utils = require('../lib/utils.js');
8
+
9
+ // Run the real sync handler in an isolated process with an in-memory API.
10
+ // This verifies its actual exit status without contacting an xyOps server or
11
+ // allowing one failed sync to change the test runner's own process.exitCode.
12
+ const script = `
13
+ const cli = require('pixl-cli');
14
+ cli.global();
15
+ const options = JSON.parse(process.argv[1]);
16
+ cli.args.quiet = true;
17
+ const calls = [];
18
+ const requests = [];
19
+ const app = {
20
+ ...require('./lib/sync.js'),
21
+ args: options.args,
22
+ config: {
23
+ base_url: 'https://fixture.invalid',
24
+ sync: options.config || {},
25
+ ui: {
26
+ list_list: ['categories', 'events', 'plugins'].map(id => ({ id })),
27
+ data_types: { category: { list: 'categories' }, plugin: { list: 'plugins' } }
28
+ }
29
+ },
30
+ state: {},
31
+ categories: options.categories || [],
32
+ events: [],
33
+ plugins: options.plugins || [],
34
+ version: 'test',
35
+ dry: !!options.args.dry,
36
+ color() { return cli.chalk.white; },
37
+ markdown(text) { return text; },
38
+ compareVersions() { return 0; },
39
+ async getMultiple() {},
40
+ die(message) { throw new Error(message); },
41
+ async callStandardAPI(method, data) {
42
+ calls.push(method);
43
+ requests.push({ method, data });
44
+ if (options.failAPI === method) throw new Error('Fixture API failure');
45
+ }
46
+ };
47
+ app.cmd_sync().catch(err => {
48
+ console.error(err.message);
49
+ process.exitCode = 1;
50
+ }).finally(() => {
51
+ console.log(JSON.stringify({ calls, requests, errors: app.errors || [], warnings: app.warnings || [] }));
52
+ });
53
+ `;
54
+
55
+ function runSync(t, options) {
56
+ // Every scan uses a disposable tree, including deletion tests. All remote
57
+ // objects and API calls exist only in the child process's mock adapter.
58
+ const dir = fs.mkdtempSync(Path.join(os.tmpdir(), 'xycli-sync-unit-'));
59
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
60
+ const args = { other: [dir], ...options.args };
61
+
62
+ if (options.source) {
63
+ fs.writeFileSync(Path.join(dir, 'Category.json'), JSON.stringify({
64
+ type: 'xypdf',
65
+ version: '1.0',
66
+ items: options.emptyItems ? [] : [{ type: 'category', data: options.source }]
67
+ }));
68
+ if (options.prefixedSource) {
69
+ fs.writeFileSync(Path.join(dir, 'Category-Animals.json'), JSON.stringify({
70
+ type: 'xypdf',
71
+ version: '1.0',
72
+ items: [{ type: 'category', data: options.prefixedSource }]
73
+ }));
74
+ }
75
+ if (options.invalidNeighbor) {
76
+ fs.writeFileSync(Path.join(dir, 'Category-script.js'), 'console.log("not a category property");\n');
77
+ }
78
+ if (options.duplicateSource) {
79
+ fs.copyFileSync(Path.join(dir, 'Category.json'), Path.join(dir, 'Duplicate.json'));
80
+ }
81
+ }
82
+ if (options.missingDir) args.other = [Path.join(dir, 'missing')];
83
+
84
+ const result = spawnSync(process.execPath, ['-e', script, JSON.stringify({ ...options, args })], {
85
+ cwd: Path.join(__dirname, '..'),
86
+ encoding: 'utf8',
87
+ timeout: 10000
88
+ });
89
+ assert.ifError(result.error);
90
+ assert.equal(result.signal, null);
91
+ const report = JSON.parse(result.stdout.trim().split('\n').pop());
92
+ return { ...result, report };
93
+ }
94
+
95
+ test('sync update guard requires confirmation only for the exact managed object', async () => {
96
+ var warnings = [];
97
+ var loads = 0;
98
+ var context = {
99
+ args: { notes: 'Updated' },
100
+ async getMultiple() {
101
+ loads++;
102
+ this.state = { sync: { 'category-managed': true, 'plugin-shared': true } };
103
+ },
104
+ toast(icon, color, message) {
105
+ warnings.push({ icon, color, message });
106
+ },
107
+ die(message) {
108
+ throw new Error(message);
109
+ }
110
+ };
111
+
112
+ assert.equal(await utils.confirmSyncUpdate.call(context, 'category', 'managed', 'category'), false);
113
+ assert.equal(loads, 1);
114
+ assert.deepEqual(warnings.map( warning => warning.color ), ['yellow']);
115
+ assert.match(warnings[0].message, /under remote management/);
116
+ assert.match(warnings[0].message, /--confirm/);
117
+
118
+ warnings.length = 0;
119
+ assert.equal(await utils.confirmSyncUpdate.call(context, 'event', 'shared', 'event'), true);
120
+ assert.deepEqual(warnings, [], 'A matching ID under another type does not block the update');
121
+ });
122
+
123
+ test('sync update guard consumes a valid confirmation before update validation', async () => {
124
+ for (const state of [
125
+ { sync: { 'category-managed': true } },
126
+ { sync: {} }
127
+ ]) {
128
+ var context = {
129
+ args: { notes: 'Updated', confirm: true },
130
+ state,
131
+ async getMultiple() {},
132
+ toast() {
133
+ throw new Error('Confirmed updates must not warn');
134
+ },
135
+ die(message) {
136
+ throw new Error(message);
137
+ }
138
+ };
139
+
140
+ assert.equal(await utils.confirmSyncUpdate.call(context, 'category', 'managed', 'category'), true);
141
+ assert.deepEqual(context.args, { notes: 'Updated' });
142
+ }
143
+ });
144
+
145
+ test('sync update guard rejects malformed confirmation values', async () => {
146
+ var context = {
147
+ args: { confirm: 'yes' },
148
+ state: { sync: {} },
149
+ async getMultiple() {},
150
+ toast() {},
151
+ die(message) {
152
+ throw new Error(message);
153
+ }
154
+ };
155
+
156
+ await assert.rejects(
157
+ utils.confirmSyncUpdate.call(context, 'category', 'managed', 'category'),
158
+ /Update --confirm must be true or false/
159
+ );
160
+ });
161
+
162
+ test('sync rejects deletion with down-only, two-way, or mixed directions before writes', t => {
163
+ for (const args of [
164
+ { down: 'categories', delete: 'categories' },
165
+ { up: 'categories', down: 'categories', delete: 'categories', dry: true },
166
+ { up: 'categories', down: 'events', delete: 'categories' },
167
+ { up: [], delete: 'categories' }
168
+ ]) {
169
+ const result = runSync(t, { args });
170
+ assert.equal(result.status, 1);
171
+ assert.match(result.stderr, /Delete mode requires up-only sync/);
172
+ assert.deepEqual(result.report.calls, []);
173
+ }
174
+ });
175
+
176
+ test('sync validates inherited deletion and direction settings after CLI overrides', t => {
177
+ const config = { up: 'categories', down: 'events', delete: 'categories' };
178
+ const blocked = runSync(t, { config, args: {} });
179
+ assert.equal(blocked.status, 1);
180
+ assert.deepEqual(blocked.report.calls, []);
181
+
182
+ // Explicit false disables the inherited setting rather than adding to it.
183
+ const upOnly = runSync(t, { config, args: { down: false } });
184
+ assert.equal(upOnly.status, 0);
185
+ const noDeletion = runSync(t, { config, args: { delete: false } });
186
+ assert.equal(noDeletion.status, 0);
187
+ const emptyDown = runSync(t, { args: { up: 'all', down: [], delete: true } });
188
+ assert.equal(emptyDown.status, 0);
189
+ });
190
+
191
+ test('sync errors exit nonzero even in quiet mode or a dry run', t => {
192
+ for (const dry of [false, true]) {
193
+ const result = runSync(t, { missingDir: true, args: { up: 'categories', dry, quiet: true } });
194
+ assert.equal(result.status, 1);
195
+ assert.equal(result.report.errors.length, 1);
196
+ assert.deepEqual(result.report.calls, []);
197
+ }
198
+ });
199
+
200
+ test('sync API errors preserve failure status while still sending both notifications', t => {
201
+ const category = { id: 'fixture', title: 'Fixture', notes: 'Remote' };
202
+ for (const failAPI of ['update_category', 'update_global_state', 'delete_category']) {
203
+ const result = runSync(t, {
204
+ categories: [category],
205
+ source: failAPI === 'delete_category' ? undefined : { ...category, notes: 'Local' },
206
+ failAPI,
207
+ args: {
208
+ up: 'categories',
209
+ delete: failAPI === 'delete_category' ? 'categories' : false,
210
+ error_email: 'fixture@example.invalid',
211
+ error_event: 'fixture_error_event'
212
+ }
213
+ });
214
+ assert.equal(result.status, 1);
215
+ assert.equal(result.report.errors.length, 1);
216
+ assert.deepEqual(result.report.calls.slice(-2), ['sendEmail', 'runEvent']);
217
+ }
218
+ });
219
+
220
+ test('sync completion-command errors also exit nonzero and notify', t => {
221
+ const category = { id: 'fixture', title: 'Fixture', notes: 'Remote' };
222
+ const result = runSync(t, {
223
+ categories: [category],
224
+ source: { ...category, notes: 'Local' },
225
+ args: { up: 'categories', up_cmd: 'exit 2', error_event: 'fixture_error_event' }
226
+ });
227
+ assert.equal(result.status, 1);
228
+ assert.match(result.report.errors[0], /Command failed/);
229
+ assert.equal(result.report.calls.at(-1), 'runEvent');
230
+ });
231
+
232
+ test('successful syncs retain a zero exit status', t => {
233
+ const success = runSync(t, { args: { up: 'categories' } });
234
+ assert.equal(success.status, 0);
235
+ assert.deepEqual(success.report.errors, []);
236
+ });
237
+
238
+ test('scan warnings exit nonzero and allow notifications, except in dry mode', t => {
239
+ // Both missing objects and malformed sources stop the scan before changes.
240
+ // Quiet mode must not hide their failure status, and dry mode still fails
241
+ // validation while deliberately skipping notification actions.
242
+ for (const dry of [false, true]) {
243
+ for (const emptyItems of [false, true]) {
244
+ const result = runSync(t, {
245
+ source: { id: 'missing', title: 'Missing' },
246
+ emptyItems,
247
+ args: {
248
+ up: 'categories', dry, quiet: true,
249
+ error_email: 'fixture@example.invalid',
250
+ error_event: 'fixture_error_event'
251
+ }
252
+ });
253
+ assert.equal(result.status, 1);
254
+ assert.equal(result.report.warnings.length, 1);
255
+ assert.deepEqual(result.report.errors, []);
256
+ assert.deepEqual(result.report.calls, dry ? [] : ['sendEmail', 'runEvent']);
257
+ }
258
+ }
259
+ });
260
+
261
+ test('duplicate-source warnings exit nonzero without applying changes', t => {
262
+ const category = { id: 'fixture', title: 'Fixture', notes: 'Remote' };
263
+ for (const args of [
264
+ { up: 'categories', delete: 'categories' },
265
+ { down: 'categories' },
266
+ { up: 'categories', dry: true }
267
+ ]) {
268
+ const result = runSync(t, {
269
+ categories: [category],
270
+ source: { ...category, notes: 'Local' },
271
+ duplicateSource: true,
272
+ args
273
+ });
274
+ assert.equal(result.status, 1);
275
+ assert.match(result.report.warnings[0], /Duplicate source item found/);
276
+ assert.deepEqual(result.report.errors, []);
277
+ assert.deepEqual(result.report.calls, []);
278
+ }
279
+ });
280
+
281
+ test('a prefixed XYPDF source is never loaded as a property neighbor', t => {
282
+ const category = { id: 'general', title: 'General', notes: '' };
283
+ const animals = { id: 'animals', title: 'General Animals', notes: '' };
284
+ const result = runSync(t, {
285
+ categories: [category, animals],
286
+ source: category,
287
+ prefixedSource: animals,
288
+ args: { up: 'categories', dry: true }
289
+ });
290
+
291
+ assert.equal(result.status, 0);
292
+ assert.deepEqual(result.report.warnings, []);
293
+ assert.deepEqual(result.report.errors, []);
294
+ assert.equal(result.report.requests.some(request => request.method === 'update_category'), false);
295
+ });
296
+
297
+ test('a property neighbor must resolve to an existing string property', t => {
298
+ const category = { id: 'fixture', title: 'Fixture', notes: '' };
299
+ const result = runSync(t, {
300
+ categories: [category],
301
+ source: category,
302
+ invalidNeighbor: true,
303
+ args: { up: 'categories', dry: true }
304
+ });
305
+
306
+ assert.equal(result.status, 1);
307
+ assert.match(result.report.warnings[0], /does not match an existing string property/);
308
+ assert.deepEqual(result.report.errors, []);
309
+ assert.deepEqual(result.report.calls, []);
310
+ });
311
+
312
+ test('sync deletion ignores stock and Marketplace markers for every selected type', t => {
313
+ // No objects have local sources. Only unmarked, user-owned definitions may
314
+ // become deletion candidates, in either an apply run or a dry-run preview.
315
+ const protectedObjects = [
316
+ { id: 'stock', title: 'Stock', stock: true },
317
+ { id: 'marketplace', title: 'Marketplace', marketplace: { id: 'fixture/plugin' } },
318
+ { id: 'both', title: 'Both', stock: true, marketplace: {} },
319
+ { id: 'stock-false', title: 'Stock marker present', stock: false },
320
+ { id: 'marketplace-null', title: 'Marketplace marker present', marketplace: null }
321
+ ];
322
+ for (const dry of [false, true]) {
323
+ const result = runSync(t, {
324
+ categories: [...protectedObjects, { id: 'custom-category', title: 'Custom Category' }],
325
+ plugins: [...protectedObjects, { id: 'custom-plugin', title: 'Custom Plugin' }],
326
+ args: {
327
+ up: 'categories,plugins', delete: 'categories,plugins', dry,
328
+ // Setup's inclusion switches must never override deletion protection.
329
+ stock: true, marketplace: true
330
+ }
331
+ });
332
+ assert.equal(result.status, 0);
333
+ assert.deepEqual(result.report.errors, []);
334
+ assert.deepEqual(result.report.warnings, []);
335
+ assert.deepEqual(result.report.requests.filter(req => req.method.startsWith('delete_')), [
336
+ { method: 'delete_category', data: { id: 'custom-category' } },
337
+ { method: 'delete_plugin', data: { id: 'custom-plugin' } }
338
+ ]);
339
+ }
340
+ });
341
+
342
+ test('sync deletion makes no delete requests when only protected objects exist', t => {
343
+ const result = runSync(t, {
344
+ plugins: [
345
+ { id: 'shell', title: 'Shell Plugin', stock: true },
346
+ { id: 'installed', title: 'Installed Plugin', marketplace: { id: 'fixture/plugin' } }
347
+ ],
348
+ args: { up: 'plugins', delete: 'plugins' }
349
+ });
350
+ assert.equal(result.status, 0);
351
+ assert.deepEqual(result.report.requests, []);
352
+ });
353
+
354
+ test('sync PID lock rejects an overlapping run and cleans up afterward', async t => {
355
+ const dir = fs.mkdtempSync(Path.join(os.tmpdir(), 'xycli-sync-lock-'));
356
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
357
+ const lockFile = Path.join(dir, 'sync.pid');
358
+ const holderScript = `
359
+ const cli = require('pixl-cli');
360
+ cli.global();
361
+ const sync = require('./lib/sync.js');
362
+ const app = {
363
+ ...sync,
364
+ args: {},
365
+ config: { base_url: 'https://fixture.invalid', sync: { lock_file: process.argv[1] } },
366
+ die(message) { throw new Error(message); }
367
+ };
368
+ app.acquireSyncLock();
369
+ console.log('LOCKED');
370
+ setTimeout(() => { app.releaseSyncLock(); }, 750);
371
+ `;
372
+ const holder = spawn(process.execPath, ['-e', holderScript, lockFile], {
373
+ cwd: Path.join(__dirname, '..'),
374
+ stdio: ['ignore', 'pipe', 'pipe']
375
+ });
376
+
377
+ await new Promise((resolve, reject) => {
378
+ let output = '';
379
+ const timer = setTimeout(() => reject(new Error('Timed out waiting for lock holder')), 5000);
380
+ holder.stdout.on('data', chunk => {
381
+ output += chunk;
382
+ if (output.includes('LOCKED')) {
383
+ clearTimeout(timer);
384
+ resolve();
385
+ }
386
+ });
387
+ holder.on('error', reject);
388
+ holder.on('exit', code => {
389
+ if (!output.includes('LOCKED')) reject(new Error('Lock holder exited early: ' + code));
390
+ });
391
+ });
392
+
393
+ const blocked = runSync(t, {
394
+ config: { lock_file: lockFile },
395
+ args: { up: 'categories', quiet: true }
396
+ });
397
+ assert.equal(blocked.status, 1);
398
+ assert.match(blocked.stderr, /Another sync process is already running/);
399
+ assert.deepEqual(blocked.report.calls, []);
400
+ const blockedSetup = runSync(t, {
401
+ config: { lock_file: lockFile },
402
+ args: { other: ['setup', 'categories'], quiet: true }
403
+ });
404
+ assert.equal(blockedSetup.status, 1);
405
+ assert.match(blockedSetup.stderr, /Another sync process is already running/);
406
+ assert.deepEqual(blockedSetup.report.calls, []);
407
+
408
+ await new Promise((resolve, reject) => {
409
+ holder.on('error', reject);
410
+ holder.on('exit', code => code === 0 ? resolve() : reject(new Error('Lock holder failed: ' + code)));
411
+ });
412
+ assert.equal(fs.existsSync(lockFile), false, 'Owner removed its lock at completion');
413
+
414
+ const next = runSync(t, { config: { lock_file: lockFile }, args: { up: 'categories' } });
415
+ assert.equal(next.status, 0, 'A new sync starts after the owner exits');
416
+ });
417
+
418
+ test('sync PID lock automatically recovers stale JSON and legacy numeric files', t => {
419
+ const dir = fs.mkdtempSync(Path.join(os.tmpdir(), 'xycli-sync-stale-lock-'));
420
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
421
+ const lockFile = Path.join(dir, 'sync.pid');
422
+ const deadPID = 2147483647;
423
+
424
+ for (const contents of [
425
+ JSON.stringify({ pid: deadPID, started: '2000-01-01T00:00:00.000Z', token: 'stale' }),
426
+ '' + deadPID
427
+ ]) {
428
+ fs.writeFileSync(lockFile, contents);
429
+ const result = runSync(t, { config: { lock_file: lockFile }, args: { up: 'categories' } });
430
+ assert.equal(result.status, 0);
431
+ assert.equal(fs.existsSync(lockFile), false, 'Stale lock was replaced and the new lock was cleaned up');
432
+ }
433
+ });
434
+
435
+ test('sync PID lock fails closed on malformed lock data', t => {
436
+ const dir = fs.mkdtempSync(Path.join(os.tmpdir(), 'xycli-sync-invalid-lock-'));
437
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
438
+ const lockFile = Path.join(dir, 'sync.pid');
439
+ fs.writeFileSync(lockFile, 'not a PID');
440
+
441
+ const result = runSync(t, { config: { lock_file: lockFile }, args: { up: 'categories' } });
442
+ assert.equal(result.status, 1);
443
+ assert.match(result.stderr, /Invalid sync lock file/);
444
+ assert.equal(fs.readFileSync(lockFile, 'utf8'), 'not a PID', 'Invalid lock is preserved for manual inspection');
445
+ assert.deepEqual(result.report.calls, []);
446
+ });