@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.
- package/CHANGELOG.md +7 -0
- package/README.md +5 -2
- package/docs/help.md +16 -5
- package/docs/sync.md +30 -8
- package/docs/two-way-git-tutorial.md +262 -0
- package/lib/sync.js +253 -53
- package/package.json +3 -2
- package/test/README.md +5 -3
- package/test/events.test.js +187 -0
- package/test/jobs.test.js +205 -0
- package/test/sync-unit.test.js +294 -6
- package/test/sync.test.js +3 -2
- package/tools/changelog.js +86 -0
|
@@ -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
|
+
});
|
package/test/sync-unit.test.js
CHANGED
|
@@ -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 =
|
|
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
|
-
...
|
|
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({
|
|
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
|
|
175
|
-
const
|
|
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' } );
|