@pixlcore/xycli 1.0.0 → 1.0.2

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.
@@ -0,0 +1,205 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const sdk = require('@pixlcore/xyops-sdk');
4
+ const { loadTestConfig, createCheck, xy, json, call: apiCall, cleanupFixtures } = require('./helpers/common.js');
5
+
6
+ test('jobs', async t => {
7
+ loadTestConfig();
8
+ const check = createCheck(t);
9
+
10
+ // This suite launches short-lived Shell Plugin Jobs, waits for each one to
11
+ // become final, and records every Job ID immediately for exact cleanup.
12
+ const stamp = Date.now();
13
+ const eventID = 'cli_job_test_' + stamp;
14
+ const eventTitle = 'CLI Job Test ' + stamp;
15
+ const marker = 'XYCLI_JOB_TEST_' + stamp;
16
+ const script = 'echo "' + marker + '"';
17
+ const jobIDs = new Set();
18
+ let eventCreated = false;
19
+
20
+ async function loadJob(id) {
21
+ const result = await sdk.api.getJob({ id: id, remove: 'timelines' });
22
+ if (result.err || !result.data || !result.data.job) return null;
23
+ return result.data.job;
24
+ }
25
+
26
+ async function waitForFinalJob(id, timeout = 20000) {
27
+ const expires = Date.now() + timeout;
28
+ while (Date.now() < expires) {
29
+ const job = await loadJob(id);
30
+ if (job && job.final) return job;
31
+ await new Promise(resolve => setTimeout(resolve, 200));
32
+ }
33
+ throw new Error('Timed out waiting for Job to complete: ' + id);
34
+ }
35
+
36
+ function launchedJobID(output) {
37
+ const match = output.match(/Successfully launched job: #(\w+)/i);
38
+ assert.ok(match, 'CLI printed the launched Job ID');
39
+ jobIDs.add(match[1]);
40
+ return match[1];
41
+ }
42
+
43
+ async function cleanupJobs() {
44
+ const errors = [];
45
+ for (const id of jobIDs) {
46
+ try {
47
+ let job = await loadJob(id);
48
+ if (!job) continue;
49
+ if (!job.final) {
50
+ await sdk.api.abortJob({ id: id });
51
+ job = await waitForFinalJob(id, 10000);
52
+ }
53
+ const result = await sdk.api.deleteJob({ id: id });
54
+ if (result.err) throw result.err;
55
+ }
56
+ catch (error) { errors.push(error); }
57
+ }
58
+ if (errors.length) throw new AggregateError(errors, 'Disposable Job cleanup failed.');
59
+ }
60
+
61
+ try {
62
+ let category;
63
+ let plugin;
64
+ let serverID;
65
+
66
+ await check('local server can run a Shell Plugin Job', async () => {
67
+ const data = await apiCall('getMultiple', { lists: 'categories,plugins', servers: 1 });
68
+ category = data.categories.find(item => item.enabled) || data.categories[0];
69
+ plugin = data.plugins.find(item => item.id == 'shellplug' && item.enabled);
70
+ serverID = Object.keys(data.servers || {})[0];
71
+ assert.ok(category && category.enabled, 'An enabled Category is required');
72
+ assert.ok(plugin, 'The enabled stock shellplug Event Plugin is required');
73
+ assert.ok(serverID, 'A connected Server is required');
74
+ });
75
+
76
+ // Mark cleanup eligibility before creation. This also covers the rare case
77
+ // where the server commits the Event but the client loses the response.
78
+ eventCreated = true;
79
+ await apiCall('createEvent', {
80
+ id: eventID,
81
+ title: eventTitle,
82
+ enabled: true,
83
+ category: category.id,
84
+ plugin: plugin.id,
85
+ params: { script: script },
86
+ fields: [],
87
+ tags: [],
88
+ targets: [serverID],
89
+ algo: 'random',
90
+ notes: 'Disposable CLI Job test Event',
91
+ actions: [],
92
+ limits: [],
93
+ triggers: [{ type: 'manual', enabled: true }]
94
+ });
95
+
96
+ await check('dry run previews overrides without launching a Job', () => {
97
+ const req = json(['run', eventID, '--params.script', script, '--input.data.marker', marker, '--dry']);
98
+ assert.equal(req.id, eventID);
99
+ assert.equal(req.params.script, script);
100
+ assert.equal(req.input.data.marker, marker);
101
+ });
102
+
103
+ const launchOutput = xy(['run', eventID, '--params.script', script, '--input.data.marker', marker]);
104
+ const firstJobID = launchedJobID(launchOutput);
105
+ let firstJob = await waitForFinalJob(firstJobID);
106
+
107
+ await check('run launches a successful Event-backed Job', () => {
108
+ assert.match(launchOutput, /Watch job live:/);
109
+ assert.equal(firstJob.id, firstJobID);
110
+ assert.equal(firstJob.event, eventID);
111
+ assert.equal(firstJob.plugin, plugin.id);
112
+ assert.equal(firstJob.server, serverID);
113
+ assert.equal(firstJob.final, true);
114
+ assert.equal(firstJob.code || 0, 0);
115
+ assert.equal(firstJob.params.script, script);
116
+ assert.equal(firstJob.input.data.marker, marker);
117
+ assert.match(firstJob.output || '', new RegExp(marker));
118
+ });
119
+
120
+ await check('Job get supports JSON and human reports', () => {
121
+ assert.deepEqual(json(['job', firstJobID]), firstJob);
122
+ const out = xy(['job', firstJobID]);
123
+ assert.match(out, /JOB SUMMARY/);
124
+ assert.match(out, new RegExp(firstJobID));
125
+ assert.match(out, /Job completed successfully/i);
126
+ assert.match(out, /Other Commands:/);
127
+ });
128
+
129
+ await check('verbose Job detail and Job log include process output', () => {
130
+ assert.match(xy(['job', firstJobID, '--verbose']), new RegExp(marker));
131
+ assert.match(xy(['job', 'log', firstJobID]), new RegExp(marker));
132
+ });
133
+
134
+ await check('completed Job search finds the launched Job', () => {
135
+ const out = xy(['jobs', '--event', eventID]);
136
+ assert.match(out, /JOB SEARCH RESULTS/);
137
+ assert.match(out, new RegExp(firstJobID));
138
+ assert.match(out, new RegExp(eventTitle));
139
+ });
140
+
141
+ await check('dry rerun previews the original Job settings', () => {
142
+ const req = json(['job', 'run', firstJobID, '--dry']);
143
+ assert.equal(req.id, eventID);
144
+ assert.equal(req.event, eventID);
145
+ assert.equal(req.params.script, script);
146
+ assert.equal(req.input.data.marker, marker);
147
+ });
148
+
149
+ const rerunOutput = xy(['job', 'run', firstJobID]);
150
+ const secondJobID = launchedJobID(rerunOutput);
151
+ const secondJob = await waitForFinalJob(secondJobID);
152
+
153
+ await check('Job rerun launches a distinct successful Job', () => {
154
+ assert.notEqual(secondJob.id, firstJobID);
155
+ assert.equal(secondJob.event, eventID);
156
+ assert.equal(secondJob.code || 0, 0);
157
+ assert.equal(secondJob.params.script, script);
158
+ assert.equal(secondJob.input.data.marker, marker);
159
+ assert.match(secondJob.output || '', new RegExp(marker));
160
+ });
161
+
162
+ await check('completed Job refuses active-only operations', () => {
163
+ assert.match(xy(['job', 'abort', firstJobID], { fail: true }), /already completed/i);
164
+ assert.match(xy(['job', 'resume', firstJobID], { fail: true }), /already completed/i);
165
+ });
166
+
167
+ await check('unconfirmed Job delete shows its target and warning', () => {
168
+ const out = xy(['job', 'delete', firstJobID]);
169
+ assert.match(out, /DELETE JOB/);
170
+ assert.match(out, new RegExp(firstJobID));
171
+ assert.match(out, /Please confirm the delete/);
172
+ });
173
+
174
+ await check('dry Job delete leaves the completed Job intact', async () => {
175
+ assert.deepEqual(json(['job', 'delete', firstJobID, '--confirm', '--dry']), { id: firstJobID });
176
+ assert.ok(await loadJob(firstJobID));
177
+ });
178
+
179
+ for (const id of [firstJobID, secondJobID]) {
180
+ xy(['job', 'delete', id, '--confirm']);
181
+ }
182
+
183
+ await check('confirmed Job delete removes both completed Jobs', async () => {
184
+ assert.equal(await loadJob(firstJobID), null);
185
+ assert.equal(await loadJob(secondJobID), null);
186
+ jobIDs.delete(firstJobID);
187
+ jobIDs.delete(secondJobID);
188
+ });
189
+
190
+ await apiCall('deleteEvent', { id: eventID });
191
+ eventCreated = false;
192
+
193
+ for (const topic of ['jobs', 'job', 'job get', 'job log', 'job run', 'job resume', 'job abort', 'job delete']) {
194
+ await check('help ' + topic, () => assert.ok(xy(['help', ...topic.split(' ')]).length > 100));
195
+ }
196
+ }
197
+ finally {
198
+ await cleanupJobs();
199
+ if (eventCreated) {
200
+ await cleanupFixtures([
201
+ { list: 'events', method: 'deleteEvent', match: item => item.id == eventID || (item.title || '').startsWith(eventTitle) }
202
+ ]);
203
+ }
204
+ }
205
+ });
@@ -13,28 +13,31 @@ const script = `
13
13
  const cli = require('pixl-cli');
14
14
  cli.global();
15
15
  const options = JSON.parse(process.argv[1]);
16
- cli.args.quiet = true;
16
+ cli.args.quiet = options.quiet !== false;
17
+ const sync = require('./lib/sync.js');
18
+ if (options.setupCwd) process.chdir(options.setupCwd);
17
19
  const calls = [];
18
20
  const requests = [];
19
21
  const app = {
20
- ...require('./lib/sync.js'),
22
+ ...sync,
21
23
  args: options.args,
22
24
  config: {
23
25
  base_url: 'https://fixture.invalid',
24
26
  sync: options.config || {},
25
27
  ui: {
26
28
  list_list: ['categories', 'events', 'plugins'].map(id => ({ id })),
27
- data_types: { category: { list: 'categories' }, plugin: { list: 'plugins' } }
29
+ data_types: { category: { list: 'categories' }, event: { list: 'events' }, plugin: { list: 'plugins' } }
28
30
  }
29
31
  },
30
32
  state: {},
31
33
  categories: options.categories || [],
32
- events: [],
34
+ events: options.events || [],
33
35
  plugins: options.plugins || [],
34
36
  version: 'test',
35
37
  dry: !!options.args.dry,
36
38
  color() { return cli.chalk.white; },
37
39
  markdown(text) { return text; },
40
+ printBoxList() {},
38
41
  compareVersions() { return 0; },
39
42
  async getMultiple() {},
40
43
  die(message) { throw new Error(message); },
@@ -58,6 +61,7 @@ function runSync(t, options) {
58
61
  const dir = fs.mkdtempSync(Path.join(os.tmpdir(), 'xycli-sync-unit-'));
59
62
  t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
60
63
  const args = { other: [dir], ...options.args };
64
+ if (options.setup) args.other = ['setup', ...options.setup];
61
65
 
62
66
  if (options.source) {
63
67
  fs.writeFileSync(Path.join(dir, 'Category.json'), JSON.stringify({
@@ -79,9 +83,25 @@ function runSync(t, options) {
79
83
  fs.copyFileSync(Path.join(dir, 'Category.json'), Path.join(dir, 'Duplicate.json'));
80
84
  }
81
85
  }
86
+ if (options.existingSources) {
87
+ options.existingSources.forEach(source => {
88
+ const file = Path.join(dir, source.path);
89
+ fs.mkdirSync(Path.dirname(file), { recursive: true });
90
+ const contents = source.contents !== undefined ? source.contents : JSON.stringify({
91
+ type: 'xypdf',
92
+ version: '1.0',
93
+ items: [{ type: source.type, data: source.data }]
94
+ });
95
+ fs.writeFileSync(file, contents);
96
+ });
97
+ }
82
98
  if (options.missingDir) args.other = [Path.join(dir, 'missing')];
83
99
 
84
- const result = spawnSync(process.execPath, ['-e', script, JSON.stringify({ ...options, args })], {
100
+ const result = spawnSync(process.execPath, ['-e', script, JSON.stringify({
101
+ ...options,
102
+ args,
103
+ setupCwd: options.setup ? dir : undefined
104
+ })], {
85
105
  cwd: Path.join(__dirname, '..'),
86
106
  encoding: 'utf8',
87
107
  timeout: 10000
@@ -89,7 +109,14 @@ function runSync(t, options) {
89
109
  assert.ifError(result.error);
90
110
  assert.equal(result.signal, null);
91
111
  const report = JSON.parse(result.stdout.trim().split('\n').pop());
92
- return { ...result, report };
112
+ return { ...result, report, dir };
113
+ }
114
+
115
+ function getSetupMarkerCommand(filename) {
116
+ // Use the current Node.js binary so the completion-command fixture is
117
+ // portable and does not depend on a separate shell utility being installed.
118
+ const code = `require('node:fs').writeFileSync(${JSON.stringify(filename)}, process.cwd())`;
119
+ return `${JSON.stringify(process.execPath)} -e ${JSON.stringify(code)}`;
93
120
  }
94
121
 
95
122
  test('sync update guard requires confirmation only for the exact managed object', async () => {
@@ -217,6 +244,267 @@ test('sync API errors preserve failure status while still sending both notificat
217
244
  }
218
245
  });
219
246
 
247
+ test('sync setup uses a custom extension only when detection falls back to txt', t => {
248
+ const result = runSync(t, {
249
+ setup: ['plugins'],
250
+ plugins: [
251
+ { id: 'fallback', title: 'Fallback', command: 'custom-runner', script: 'Write-Host "Hello"\n' },
252
+ { id: 'detected', title: 'Detected', command: 'pwsh -File', script: 'Write-Host "Hello"\n' },
253
+ { id: 'json', title: 'JSON Data', command: '', script: '{ "hello": true }\n' }
254
+ ],
255
+ args: { file_props: 'script', default_ext: '.code' }
256
+ });
257
+
258
+ assert.equal(result.status, 0);
259
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Fallback-script.code')), true);
260
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Detected-script.ps1')), true);
261
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'JSON-Data-script.json')), true);
262
+ });
263
+
264
+ test('sync setup rejects unsafe or malformed default extensions before writing', t => {
265
+ for (const defaultExt of [true, '', '.', '../ps1', 'ps/1', 'ps-1', 'tar.gz']) {
266
+ const result = runSync(t, {
267
+ setup: ['plugins'],
268
+ plugins: [{ id: 'fixture', title: 'Fixture', command: 'custom-runner', script: 'Example\n' }],
269
+ args: { file_props: 'script', default_ext: defaultExt }
270
+ });
271
+
272
+ assert.equal(result.status, 1);
273
+ assert.match(result.stderr, /Invalid --default_ext value/);
274
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins')), false);
275
+ }
276
+ });
277
+
278
+ test('sync setup new exports only remote definitions missing from the local tree', t => {
279
+ const existing = { id: 'existing', title: 'Existing', command: 'node', script: 'console.log("Existing");\n' };
280
+ const added = { id: 'added', title: 'Added Plugin', command: 'custom-runner', script: 'Write-Host "Added"\n' };
281
+ const result = runSync(t, {
282
+ setup: ['plugins'],
283
+ plugins: [existing, added],
284
+ existingSources: [{
285
+ path: 'custom/layout/Renamed-Existing.json',
286
+ type: 'plugin',
287
+ data: existing
288
+ }],
289
+ args: { new: true, file_props: 'script', default_ext: 'ps1' }
290
+ });
291
+
292
+ assert.equal(result.status, 0);
293
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Added-Plugin.json')), true);
294
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Added-Plugin-script.ps1')), true);
295
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Existing.json')), false);
296
+ assert.equal(fs.existsSync(Path.join(result.dir, 'custom', 'layout', 'Renamed-Existing.json')), true);
297
+ });
298
+
299
+ test('sync setup runs down_cmd after writing files from the setup directory', t => {
300
+ const marker = 'setup-complete.txt';
301
+ const result = runSync(t, {
302
+ setup: ['plugins'],
303
+ plugins: [{ id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
304
+ args: {
305
+ file_props: 'script',
306
+ down_cmd: getSetupMarkerCommand(marker)
307
+ }
308
+ });
309
+
310
+ assert.equal(result.status, 0);
311
+ assert.equal(fs.readFileSync(Path.join(result.dir, marker), 'utf8'), fs.realpathSync(result.dir));
312
+ });
313
+
314
+ test('sync setup new runs down_cmd only when it writes new files', t => {
315
+ const existing = { id: 'existing', title: 'Existing', command: 'node', script: 'Existing\n' };
316
+ const marker = 'setup-new-complete.txt';
317
+ const command = getSetupMarkerCommand(marker);
318
+ const added = runSync(t, {
319
+ setup: ['plugins'],
320
+ plugins: [existing, { id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
321
+ existingSources: [{ path: 'Existing.json', type: 'plugin', data: existing }],
322
+ args: { new: true, down_cmd: command }
323
+ });
324
+
325
+ assert.equal(added.status, 0);
326
+ assert.equal(fs.existsSync(Path.join(added.dir, marker)), true);
327
+
328
+ const unchanged = runSync(t, {
329
+ setup: ['plugins'],
330
+ plugins: [existing],
331
+ existingSources: [{ path: 'Existing.json', type: 'plugin', data: existing }],
332
+ args: { new: true, down_cmd: command }
333
+ });
334
+
335
+ assert.equal(unchanged.status, 0);
336
+ assert.equal(fs.existsSync(Path.join(unchanged.dir, marker)), false);
337
+ });
338
+
339
+ test('sync setup does not run down_cmd during a dry run', t => {
340
+ const marker = 'setup-dry-complete.txt';
341
+ const result = runSync(t, {
342
+ setup: ['plugins'],
343
+ plugins: [{ id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
344
+ args: { dry: true, down_cmd: getSetupMarkerCommand(marker) }
345
+ });
346
+
347
+ assert.equal(result.status, 0);
348
+ assert.equal(fs.existsSync(Path.join(result.dir, marker)), false);
349
+ });
350
+
351
+ test('sync setup completion-command errors exit nonzero', t => {
352
+ const result = runSync(t, {
353
+ setup: ['plugins'],
354
+ plugins: [{ id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
355
+ args: { down_cmd: `${JSON.stringify(process.execPath)} -e "process.exit(2)"` }
356
+ });
357
+
358
+ assert.equal(result.status, 1);
359
+ assert.match(result.report.errors[0], /Command failed/);
360
+ });
361
+
362
+ test('sync setup new dry run discovers additions without writing them', t => {
363
+ const existing = { id: 'existing', title: 'Existing', command: 'node', script: 'Existing\n' };
364
+ const result = runSync(t, {
365
+ setup: ['plugins'],
366
+ plugins: [existing, { id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
367
+ existingSources: [{ path: 'Existing-Renamed.json', type: 'plugin', data: existing }],
368
+ args: { new: true, dry: true, file_props: 'script' }
369
+ });
370
+
371
+ assert.equal(result.status, 0);
372
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins')), false);
373
+ });
374
+
375
+ test('sync setup new preserves existing Events and writes new workflows separately', t => {
376
+ const category = { id: 'operations', title: 'Operations Team' };
377
+ const existing = { id: 'existing-event', title: 'Existing Event', type: 'event', category: category.id };
378
+ const workflow = { id: 'new-workflow', title: 'New Workflow', type: 'workflow', category: category.id };
379
+ const result = runSync(t, {
380
+ setup: ['events'],
381
+ categories: [category],
382
+ events: [existing, workflow],
383
+ existingSources: [{ path: 'custom/Event.json', type: 'event', data: existing }],
384
+ args: { new: true }
385
+ });
386
+
387
+ assert.equal(result.status, 0);
388
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events')), false);
389
+ assert.equal(fs.existsSync(Path.join(result.dir, 'workflows', 'Operations-Team', 'New-Workflow.json')), true);
390
+ });
391
+
392
+ test('sync setup groups Events and workflows into Category folders', t => {
393
+ const operations = { id: 'operations', title: 'Operations Team' };
394
+ const limited = { id: 'limited', title: 'Limited Run' };
395
+ const engineering = { id: 'engineering', title: 'Engineering & QA' };
396
+ const result = runSync(t, {
397
+ setup: ['events'],
398
+ categories: [operations, limited, engineering],
399
+ events: [
400
+ { id: 'nightly', title: 'Nightly Backup', type: 'event', category: operations.id, params: { script: '#!/bin/sh\necho backup\n' } },
401
+ { id: 'limited', title: 'Limited Event', type: 'event', category: limited.id },
402
+ { id: 'release', title: 'Release Pipeline', type: 'workflow', category: engineering.id },
403
+ { id: 'ondemand', title: 'On Demand Backup', type: 'event', category: operations.id }
404
+ ],
405
+ args: { file_props: 'params.script' },
406
+ quiet: false
407
+ });
408
+
409
+ assert.equal(result.status, 0);
410
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events', 'Operations-Team', 'Nightly-Backup.json')), true);
411
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events', 'Operations-Team', 'Nightly-Backup-params.script.sh')), true);
412
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events', 'Operations-Team', 'On-Demand-Backup.json')), true);
413
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events', 'Limited-Run', 'Limited-Event.json')), true);
414
+ assert.equal(fs.existsSync(Path.join(result.dir, 'workflows', 'Engineering-QA', 'Release-Pipeline.json')), true);
415
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events', 'Nightly-Backup.json')), false);
416
+ assert.equal(fs.existsSync(Path.join(result.dir, 'workflows', 'Release-Pipeline.json')), false);
417
+
418
+ // The source order deliberately returns to Operations after Limited Run.
419
+ // Its two Events must still print beneath one folder heading.
420
+ const operationsHeader = result.stdout.indexOf('events/Operations-Team/');
421
+ const nightlyFile = result.stdout.indexOf('Nightly-Backup.json');
422
+ const onDemandFile = result.stdout.indexOf('On-Demand-Backup.json');
423
+ const limitedHeader = result.stdout.indexOf('events/Limited-Run/');
424
+ const limitedFile = result.stdout.indexOf('Limited-Event.json');
425
+ const workflowHeader = result.stdout.indexOf('workflows/Engineering-QA/');
426
+ assert.ok(operationsHeader < nightlyFile);
427
+ assert.ok(nightlyFile < onDemandFile);
428
+ assert.ok(onDemandFile < limitedHeader);
429
+ assert.ok(limitedHeader < limitedFile);
430
+ assert.ok(limitedFile < workflowHeader);
431
+ assert.equal(result.stdout.indexOf('events/Operations-Team/', operationsHeader + 1), -1);
432
+ });
433
+
434
+ test('sync setup validates all Event Categories before writing any files', t => {
435
+ const result = runSync(t, {
436
+ setup: ['plugins', 'events'],
437
+ plugins: [{ id: 'plugin', title: 'Plugin', script: 'Hello\n' }],
438
+ events: [{ id: 'orphan', title: 'Orphan Event', type: 'event', category: 'missing' }],
439
+ args: {}
440
+ });
441
+
442
+ assert.equal(result.status, 1);
443
+ assert.match(result.stderr, /Cannot find Category for Event: Orphan Event \(missing\)/);
444
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins')), false);
445
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events')), false);
446
+ });
447
+
448
+ test('sync setup new rejects overwrite mode and malformed values before writing', t => {
449
+ for (const args of [
450
+ { new: true, force: true },
451
+ { new: 'yes' }
452
+ ]) {
453
+ const result = runSync(t, {
454
+ setup: ['plugins'],
455
+ plugins: [{ id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
456
+ args
457
+ });
458
+
459
+ assert.equal(result.status, 1);
460
+ assert.match(result.stderr, /--new/);
461
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins')), false);
462
+ }
463
+ });
464
+
465
+ test('sync setup new aborts on duplicate or malformed existing sources before writing', t => {
466
+ const existing = { id: 'existing', title: 'Existing', command: 'node', script: 'Existing\n' };
467
+ for (const existingSources of [
468
+ [
469
+ { path: 'one.json', type: 'plugin', data: existing },
470
+ { path: 'nested/two.json', type: 'plugin', data: existing }
471
+ ],
472
+ [{ path: 'broken.json', contents: '{ nope' }],
473
+ [{ path: 'orphan.json', type: 'plugin', data: { id: 'missing', title: 'Missing' } }]
474
+ ]) {
475
+ const result = runSync(t, {
476
+ setup: ['plugins'],
477
+ plugins: [existing, { id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
478
+ existingSources,
479
+ args: { new: true }
480
+ });
481
+
482
+ assert.equal(result.status, 1);
483
+ assert.match(result.stderr, /Duplicate source item|Failed to parse JSON file|Cannot find plugin in xyOps/);
484
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins')), false);
485
+ }
486
+ });
487
+
488
+ test('sync skips the entire delete pass after an update or state API failure', t => {
489
+ const category = { id: 'fixture', title: 'Fixture', notes: 'Remote' };
490
+ const orphan = { id: 'orphan', title: 'Orphan', notes: 'Delete candidate' };
491
+
492
+ for (const failAPI of ['update_category', 'update_global_state']) {
493
+ const result = runSync(t, {
494
+ categories: [category, orphan],
495
+ source: { ...category, notes: 'Local' },
496
+ failAPI,
497
+ args: { up: 'categories', delete: 'categories' }
498
+ });
499
+
500
+ assert.equal(result.status, 1);
501
+ assert.equal(result.report.errors.length, 1);
502
+ assert.match(result.report.warnings[0], /Delete pass skipped/);
503
+ assert.equal(result.report.calls.includes('update_global_state'), true);
504
+ assert.deepEqual(result.report.requests.filter(req => req.method.startsWith('delete_')), []);
505
+ }
506
+ });
507
+
220
508
  test('sync completion-command errors also exit nonzero and notify', t => {
221
509
  const category = { id: 'fixture', title: 'Fixture', notes: 'Remote' };
222
510
  const result = runSync(t, {
package/test/sync.test.js CHANGED
@@ -171,8 +171,9 @@ test('sync', async t => {
171
171
  const categoryExport = Path.join(temp, 'categories', slugify(categoryTitle) + '.json');
172
172
  const pluginExport = Path.join(temp, 'plugins', slugify(pluginTitle) + '.json');
173
173
  const pluginScriptExport = Path.join(temp, 'plugins', slugify(pluginTitle) + '-script.js');
174
- const eventExport = Path.join(temp, 'events', slugify(eventTitle) + '.json');
175
- const eventScriptExport = Path.join(temp, 'events', slugify(eventTitle) + '-params.script.sh');
174
+ const eventDir = Path.join(temp, 'events', slugify(categoryTitle));
175
+ const eventExport = Path.join(eventDir, slugify(eventTitle) + '.json');
176
+ const eventScriptExport = Path.join(eventDir, slugify(eventTitle) + '-params.script.sh');
176
177
 
177
178
  await check('setup writes valid XYPDF and external property files', () => {
178
179
  const category = readXYPDF(categoryExport);
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Build xyCLI CHANGELOG.md file
4
+
5
+ var fs = require('fs');
6
+ var Path = require('path');
7
+ var cp = require('child_process');
8
+ var Tools = require('pixl-tools');
9
+
10
+ var debug = (process.argv[2] == 'debug');
11
+
12
+ // make sure we're in the correct dir
13
+ process.chdir( Path.dirname( __dirname ) );
14
+
15
+ // make sure git sandbox is clean
16
+ var porcelain = cp.execSync('git status --porcelain', { encoding: 'utf8' }).trim();
17
+ if (!debug && porcelain.length) {
18
+ console.error("\nERROR: Git sandbox has local changes. Please commit these before updating the changelog.\n");
19
+ process.exit(1);
20
+ }
21
+
22
+ // get list of tags
23
+ var tags = cp.execSync('git tag --list --sort=version:refname', { encoding: 'utf8' }).trim().split(/\n/).reverse();
24
+
25
+ var md = '';
26
+ md += "# xyCLI Changelog\n";
27
+
28
+ var last_tag = tags.pop();
29
+ if (!tags.length) {
30
+ console.error("\nERROR: No tags found after popping first one. Please push another tag before running changelog.\n");
31
+ process.exit(1);
32
+ }
33
+
34
+ tags.forEach( function(tag, idx) {
35
+ var prev_tag = tags[idx + 1] || last_tag;
36
+ md += `\n## Version ${tag}\n\n`;
37
+
38
+ var cmd = `git log ${prev_tag}..${tag} --no-merges --pretty=format:'%h %H %ad %s%n%n%b%n----PX----' --date=short`;
39
+ var output = cp.execSync(cmd, { encoding: 'utf8' }).trim();
40
+ var commits = output.split('----PX----');
41
+ var first = true;
42
+
43
+ // 029a96a 029a96aebd721fe565b1b5c8f2b661564c9017f3 2025-12-30 Version 0.9.2
44
+ commits.forEach( function(chunk) {
45
+ var lines = chunk.trim().split(/\n/);
46
+ var line = lines.shift();
47
+
48
+ var matches = line.trim().match(/^(\w+)\s+(\w+)\s+([\d\-]+)\s+(.+)$/);
49
+ if (!matches) return;
50
+ if (matches[4].match(/\b(CHANGELOG)\b/)) return;
51
+
52
+ var url = 'https://github.com/pixlcore/xycli/commit/' + matches[2];
53
+
54
+ if (first) {
55
+ md += `> ` + Tools.formatDate( matches[3] + ' 00:00:00', '[mmmm] [mday], [yyyy]' ) + "\n\n";
56
+ first = false;
57
+ }
58
+
59
+ md += `- [\`${matches[1]}\`](${url}): ` + matches[4] + "\n";
60
+
61
+ if (matches[4].match(/^Version/)) lines.forEach( function(extra) {
62
+ if (!extra.match(/\S/)) return;
63
+ extra = extra.trim();
64
+ if (!extra.match(/^\-/)) extra = '- ' + extra;
65
+ md += "\t" + extra + "\n";
66
+ } );
67
+ } );
68
+ } );
69
+
70
+ md += `\n## Version ${last_tag}\n\n> September 20, 2026\n\n- Initial release!\n`;
71
+
72
+ if (debug) {
73
+ console.log(md);
74
+ process.exit(0);
75
+ }
76
+
77
+ fs.writeFileSync( 'CHANGELOG.md', md );
78
+
79
+ // make sure log has actually changed
80
+ porcelain = cp.execSync('git status --porcelain', { encoding: 'utf8' }).trim();
81
+ if (!porcelain.length) {
82
+ console.error("\nWarning: Changelog has not changed since last run. Skipping actions.\n");
83
+ process.exit(1);
84
+ }
85
+
86
+ cp.execSync('git add CHANGELOG.md && git commit -m "Update CHANGELOG" && git push', { stdio: 'inherit' } );