@pixlcore/xycli 1.0.1 → 1.0.3

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.
@@ -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,17 +83,41 @@ 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
- timeout: 10000
107
+ timeout: 10000,
108
+ windowsHide: true
88
109
  });
89
110
  assert.ifError(result.error);
90
111
  assert.equal(result.signal, null);
91
112
  const report = JSON.parse(result.stdout.trim().split('\n').pop());
92
- return { ...result, report };
113
+ return { ...result, report, dir };
114
+ }
115
+
116
+ function getSetupMarkerCommand(filename) {
117
+ // Use the current Node.js binary so the completion-command fixture is
118
+ // portable and does not depend on a separate shell utility being installed.
119
+ const code = `require('node:fs').writeFileSync(${JSON.stringify(filename)}, process.cwd())`;
120
+ return `${JSON.stringify(process.execPath)} -e ${JSON.stringify(code)}`;
93
121
  }
94
122
 
95
123
  test('sync update guard requires confirmation only for the exact managed object', async () => {
@@ -217,6 +245,247 @@ test('sync API errors preserve failure status while still sending both notificat
217
245
  }
218
246
  });
219
247
 
248
+ test('sync setup uses a custom extension only when detection falls back to txt', t => {
249
+ const result = runSync(t, {
250
+ setup: ['plugins'],
251
+ plugins: [
252
+ { id: 'fallback', title: 'Fallback', command: 'custom-runner', script: 'Write-Host "Hello"\n' },
253
+ { id: 'detected', title: 'Detected', command: 'pwsh -File', script: 'Write-Host "Hello"\n' },
254
+ { id: 'json', title: 'JSON Data', command: '', script: '{ "hello": true }\n' }
255
+ ],
256
+ args: { file_props: 'script', default_ext: '.code' }
257
+ });
258
+
259
+ assert.equal(result.status, 0);
260
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Fallback-script.code')), true);
261
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Detected-script.ps1')), true);
262
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'JSON-Data-script.json')), true);
263
+ });
264
+
265
+ test('sync setup rejects unsafe or malformed default extensions before writing', t => {
266
+ for (const defaultExt of [true, '', '.', '../ps1', 'ps/1', 'ps-1', 'tar.gz']) {
267
+ const result = runSync(t, {
268
+ setup: ['plugins'],
269
+ plugins: [{ id: 'fixture', title: 'Fixture', command: 'custom-runner', script: 'Example\n' }],
270
+ args: { file_props: 'script', default_ext: defaultExt }
271
+ });
272
+
273
+ assert.equal(result.status, 1);
274
+ assert.match(result.stderr, /Invalid --default_ext value/);
275
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins')), false);
276
+ }
277
+ });
278
+
279
+ test('sync setup new exports only remote definitions missing from the local tree', t => {
280
+ const existing = { id: 'existing', title: 'Existing', command: 'node', script: 'console.log("Existing");\n' };
281
+ const added = { id: 'added', title: 'Added Plugin', command: 'custom-runner', script: 'Write-Host "Added"\n' };
282
+ const result = runSync(t, {
283
+ setup: ['plugins'],
284
+ plugins: [existing, added],
285
+ existingSources: [{
286
+ path: 'custom/layout/Renamed-Existing.json',
287
+ type: 'plugin',
288
+ data: existing
289
+ }],
290
+ args: { new: true, file_props: 'script', default_ext: 'ps1' }
291
+ });
292
+
293
+ assert.equal(result.status, 0);
294
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Added-Plugin.json')), true);
295
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Added-Plugin-script.ps1')), true);
296
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Existing.json')), false);
297
+ assert.equal(fs.existsSync(Path.join(result.dir, 'custom', 'layout', 'Renamed-Existing.json')), true);
298
+ });
299
+
300
+ test('sync setup runs down_cmd after writing files from the setup directory', t => {
301
+ const marker = 'setup-complete.txt';
302
+ const result = runSync(t, {
303
+ setup: ['plugins'],
304
+ plugins: [{ id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
305
+ args: {
306
+ file_props: 'script',
307
+ down_cmd: getSetupMarkerCommand(marker)
308
+ }
309
+ });
310
+
311
+ assert.equal(result.status, 0);
312
+ assert.equal(fs.readFileSync(Path.join(result.dir, marker), 'utf8'), fs.realpathSync(result.dir));
313
+ });
314
+
315
+ test('sync setup new runs down_cmd only when it writes new files', t => {
316
+ const existing = { id: 'existing', title: 'Existing', command: 'node', script: 'Existing\n' };
317
+ const marker = 'setup-new-complete.txt';
318
+ const command = getSetupMarkerCommand(marker);
319
+ const added = runSync(t, {
320
+ setup: ['plugins'],
321
+ plugins: [existing, { id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
322
+ existingSources: [{ path: 'Existing.json', type: 'plugin', data: existing }],
323
+ args: { new: true, down_cmd: command }
324
+ });
325
+
326
+ assert.equal(added.status, 0);
327
+ assert.equal(fs.existsSync(Path.join(added.dir, marker)), true);
328
+
329
+ const unchanged = runSync(t, {
330
+ setup: ['plugins'],
331
+ plugins: [existing],
332
+ existingSources: [{ path: 'Existing.json', type: 'plugin', data: existing }],
333
+ args: { new: true, down_cmd: command }
334
+ });
335
+
336
+ assert.equal(unchanged.status, 0);
337
+ assert.equal(fs.existsSync(Path.join(unchanged.dir, marker)), false);
338
+ });
339
+
340
+ test('sync setup does not run down_cmd during a dry run', t => {
341
+ const marker = 'setup-dry-complete.txt';
342
+ const result = runSync(t, {
343
+ setup: ['plugins'],
344
+ plugins: [{ id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
345
+ args: { dry: true, down_cmd: getSetupMarkerCommand(marker) }
346
+ });
347
+
348
+ assert.equal(result.status, 0);
349
+ assert.equal(fs.existsSync(Path.join(result.dir, marker)), false);
350
+ });
351
+
352
+ test('sync setup completion-command errors exit nonzero', t => {
353
+ const result = runSync(t, {
354
+ setup: ['plugins'],
355
+ plugins: [{ id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
356
+ args: { down_cmd: `${JSON.stringify(process.execPath)} -e "process.exit(2)"` }
357
+ });
358
+
359
+ assert.equal(result.status, 1);
360
+ assert.match(result.report.errors[0], /Command failed/);
361
+ });
362
+
363
+ test('sync setup new dry run discovers additions without writing them', t => {
364
+ const existing = { id: 'existing', title: 'Existing', command: 'node', script: 'Existing\n' };
365
+ const result = runSync(t, {
366
+ setup: ['plugins'],
367
+ plugins: [existing, { id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
368
+ existingSources: [{ path: 'Existing-Renamed.json', type: 'plugin', data: existing }],
369
+ args: { new: true, dry: true, file_props: 'script' }
370
+ });
371
+
372
+ assert.equal(result.status, 0);
373
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins')), false);
374
+ });
375
+
376
+ test('sync setup new preserves existing Events and writes new workflows separately', t => {
377
+ const category = { id: 'operations', title: 'Operations Team' };
378
+ const existing = { id: 'existing-event', title: 'Existing Event', type: 'event', category: category.id };
379
+ const workflow = { id: 'new-workflow', title: 'New Workflow', type: 'workflow', category: category.id };
380
+ const result = runSync(t, {
381
+ setup: ['events'],
382
+ categories: [category],
383
+ events: [existing, workflow],
384
+ existingSources: [{ path: 'custom/Event.json', type: 'event', data: existing }],
385
+ args: { new: true }
386
+ });
387
+
388
+ assert.equal(result.status, 0);
389
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events')), false);
390
+ assert.equal(fs.existsSync(Path.join(result.dir, 'workflows', 'Operations-Team', 'New-Workflow.json')), true);
391
+ });
392
+
393
+ test('sync setup groups Events and workflows into Category folders', t => {
394
+ const operations = { id: 'operations', title: 'Operations Team' };
395
+ const limited = { id: 'limited', title: 'Limited Run' };
396
+ const engineering = { id: 'engineering', title: 'Engineering & QA' };
397
+ const result = runSync(t, {
398
+ setup: ['events'],
399
+ categories: [operations, limited, engineering],
400
+ events: [
401
+ { id: 'nightly', title: 'Nightly Backup', type: 'event', category: operations.id, params: { script: '#!/bin/sh\necho backup\n' } },
402
+ { id: 'limited', title: 'Limited Event', type: 'event', category: limited.id },
403
+ { id: 'release', title: 'Release Pipeline', type: 'workflow', category: engineering.id },
404
+ { id: 'ondemand', title: 'On Demand Backup', type: 'event', category: operations.id }
405
+ ],
406
+ args: { file_props: 'params.script' },
407
+ quiet: false
408
+ });
409
+
410
+ assert.equal(result.status, 0);
411
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events', 'Operations-Team', 'Nightly-Backup.json')), true);
412
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events', 'Operations-Team', 'Nightly-Backup-params.script.sh')), true);
413
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events', 'Operations-Team', 'On-Demand-Backup.json')), true);
414
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events', 'Limited-Run', 'Limited-Event.json')), true);
415
+ assert.equal(fs.existsSync(Path.join(result.dir, 'workflows', 'Engineering-QA', 'Release-Pipeline.json')), true);
416
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events', 'Nightly-Backup.json')), false);
417
+ assert.equal(fs.existsSync(Path.join(result.dir, 'workflows', 'Release-Pipeline.json')), false);
418
+
419
+ // The source order deliberately returns to Operations after Limited Run.
420
+ // Its two Events must still print beneath one folder heading.
421
+ const operationsHeader = result.stdout.indexOf('events/Operations-Team/');
422
+ const nightlyFile = result.stdout.indexOf('Nightly-Backup.json');
423
+ const onDemandFile = result.stdout.indexOf('On-Demand-Backup.json');
424
+ const limitedHeader = result.stdout.indexOf('events/Limited-Run/');
425
+ const limitedFile = result.stdout.indexOf('Limited-Event.json');
426
+ const workflowHeader = result.stdout.indexOf('workflows/Engineering-QA/');
427
+ assert.ok(operationsHeader < nightlyFile);
428
+ assert.ok(nightlyFile < onDemandFile);
429
+ assert.ok(onDemandFile < limitedHeader);
430
+ assert.ok(limitedHeader < limitedFile);
431
+ assert.ok(limitedFile < workflowHeader);
432
+ assert.equal(result.stdout.indexOf('events/Operations-Team/', operationsHeader + 1), -1);
433
+ });
434
+
435
+ test('sync setup validates all Event Categories before writing any files', t => {
436
+ const result = runSync(t, {
437
+ setup: ['plugins', 'events'],
438
+ plugins: [{ id: 'plugin', title: 'Plugin', script: 'Hello\n' }],
439
+ events: [{ id: 'orphan', title: 'Orphan Event', type: 'event', category: 'missing' }],
440
+ args: {}
441
+ });
442
+
443
+ assert.equal(result.status, 1);
444
+ assert.match(result.stderr, /Cannot find Category for Event: Orphan Event \(missing\)/);
445
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins')), false);
446
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events')), false);
447
+ });
448
+
449
+ test('sync setup new rejects overwrite mode and malformed values before writing', t => {
450
+ for (const args of [
451
+ { new: true, force: true },
452
+ { new: 'yes' }
453
+ ]) {
454
+ const result = runSync(t, {
455
+ setup: ['plugins'],
456
+ plugins: [{ id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
457
+ args
458
+ });
459
+
460
+ assert.equal(result.status, 1);
461
+ assert.match(result.stderr, /--new/);
462
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins')), false);
463
+ }
464
+ });
465
+
466
+ test('sync setup new aborts on duplicate or malformed existing sources before writing', t => {
467
+ const existing = { id: 'existing', title: 'Existing', command: 'node', script: 'Existing\n' };
468
+ for (const existingSources of [
469
+ [
470
+ { path: 'one.json', type: 'plugin', data: existing },
471
+ { path: 'nested/two.json', type: 'plugin', data: existing }
472
+ ],
473
+ [{ path: 'broken.json', contents: '{ nope' }],
474
+ [{ path: 'orphan.json', type: 'plugin', data: { id: 'missing', title: 'Missing' } }]
475
+ ]) {
476
+ const result = runSync(t, {
477
+ setup: ['plugins'],
478
+ plugins: [existing, { id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
479
+ existingSources,
480
+ args: { new: true }
481
+ });
482
+
483
+ assert.equal(result.status, 1);
484
+ assert.match(result.stderr, /Duplicate source item|Failed to parse JSON file|Cannot find plugin in xyOps/);
485
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins')), false);
486
+ }
487
+ });
488
+
220
489
  test('sync skips the entire delete pass after an update or state API failure', t => {
221
490
  const category = { id: 'fixture', title: 'Fixture', notes: 'Remote' };
222
491
  const orphan = { id: 'orphan', title: 'Orphan', notes: 'Delete candidate' };
@@ -391,7 +660,8 @@ test('sync PID lock rejects an overlapping run and cleans up afterward', async t
391
660
  `;
392
661
  const holder = spawn(process.execPath, ['-e', holderScript, lockFile], {
393
662
  cwd: Path.join(__dirname, '..'),
394
- stdio: ['ignore', 'pipe', 'pipe']
663
+ stdio: ['ignore', 'pipe', 'pipe'],
664
+ windowsHide: true
395
665
  });
396
666
 
397
667
  await new Promise((resolve, reject) => {
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,19 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const utils = require('../lib/utils.js');
4
+
5
+ test('executable language detection accepts Unix and Windows command forms', () => {
6
+ // Plugin definitions are portable, so exercise both path styles regardless of
7
+ // the operating system running this test.
8
+ assert.equal(utils.getLangFromBinary('/usr/bin/node --use-strict'), 'javascript');
9
+ assert.equal(utils.getLangFromBinary('/usr/bin/python3.12 -u'), 'python');
10
+ assert.equal(utils.getLangFromBinary('/bin/bash'), 'shell');
11
+ assert.equal(utils.getLangFromBinary('/usr/bin/env -S node --use-strict'), 'javascript');
12
+ assert.equal(utils.getLangFromBinary('"C:\\Program Files\\nodejs\\node.exe" --use-strict'), 'javascript');
13
+ assert.equal(utils.getLangFromBinary('C:\\Python312\\python.exe -u'), 'python');
14
+ assert.equal(utils.getLangFromBinary('pwsh.exe -File'), 'powershell');
15
+ assert.equal(utils.getLangFromBinary('powershell.exe -File'), 'powershell');
16
+ assert.equal(utils.getLangFromBinary('cmd.exe /c'), 'dos');
17
+ assert.equal(utils.getLangFromBinary('unknown-binary.exe'), null);
18
+ assert.equal(utils.getLangFromBinary(null), null);
19
+ });
package/test.js CHANGED
@@ -30,7 +30,8 @@ else {
30
30
  // suite does not prevent the remaining suites from running and reporting.
31
31
  const child = spawn(process.execPath, ['--test', '--test-concurrency=1', ...files], {
32
32
  cwd: __dirname,
33
- stdio: 'inherit'
33
+ stdio: 'inherit',
34
+ windowsHide: true
34
35
  });
35
36
 
36
37
  child.on('error', error => {
@@ -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', windowsHide: true }).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', windowsHide: true }).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', windowsHide: true }).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', windowsHide: true }).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', windowsHide: true } );