@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,230 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const Path = require('node:path');
5
+ const { loadTestConfig, createCheck, createTempDir, xy, json, call: apiCall, cleanupFixtures } = require('./helpers/common.js');
6
+
7
+ test('tags', async t => {
8
+ loadTestConfig();
9
+ const check = createCheck(t);
10
+
11
+ // These definitions are never attached to Events, Jobs, Tickets, Actions, or
12
+ // Limits. Track every generated ID so cleanup remains exact after failures.
13
+ const stamp = Date.now();
14
+ const id = 'cli_tag_test_' + stamp;
15
+ const title = 'CLI Tag Test ' + stamp;
16
+ const ids = new Set([id]);
17
+ const temp = createTempDir(t, 'xycli-tag-test-');
18
+
19
+ try {
20
+ const original = (await apiCall('getTags')).rows;
21
+
22
+ await check('dry creation fills UI defaults', () => {
23
+ const req = json(['tag', 'create', '--id', id, '--title', title, '--dry']);
24
+ assert.deepEqual(req, {
25
+ icon: 'tag-outline',
26
+ notes: '',
27
+ id: id,
28
+ title: title
29
+ });
30
+ });
31
+
32
+ await check('dry creation does not persist', () => assert.deepEqual(json(['tags', '--id', id]), []));
33
+
34
+ const created = json([
35
+ 'tag', 'create', '--id', id, '--title', title,
36
+ '--icon', 'mdi-alert-rhombus', '--notes', 'First line\nSecond line'
37
+ ]);
38
+
39
+ await check('create persists all editable fields', () => {
40
+ assert.equal(created.id, id);
41
+ assert.equal(created.title, title);
42
+ assert.equal(created.icon, 'alert-rhombus');
43
+ assert.equal(created.notes, 'First line\nSecond line');
44
+ assert.equal(created.revision, 1);
45
+ assert.ok(created.username);
46
+ assert.ok(created.created);
47
+ assert.equal(created.created, created.modified);
48
+ });
49
+
50
+ await check('exact ID get', () => assert.deepEqual(json(['tag', id]), created));
51
+
52
+ await check('fuzzy title get', () => assert.equal(json(['tag', 'get', '--title', title.toLowerCase()]).id, id));
53
+
54
+ await check('named ID get', () => assert.equal(json(['tag', 'get', '--id', id]).id, id));
55
+
56
+ await check('list search and icon filter', () => {
57
+ assert.deepEqual(json(['tags', title, '--icon', 'alert-rhombus']), [created]);
58
+ assert.deepEqual(json(['tag', 'list', '--id', id]), [created]);
59
+ });
60
+
61
+ await check('JSON list ignores human pagination arguments', () => {
62
+ assert.deepEqual(json(['tags', '--id', id, '--limit', '1', '--page', '2']), [created]);
63
+ });
64
+
65
+ await check('Tag list is ordered alphabetically', () => {
66
+ const rows = json(['tags']);
67
+ assert.ok(rows.every((row, idx) => !idx || rows[idx - 1].title.toLowerCase().localeCompare(row.title.toLowerCase()) <= 0));
68
+ });
69
+
70
+ await check('human list supports pagination and suggestions', () => {
71
+ const out = xy(['tags', '--limit', '1', '--page', '1']);
72
+ assert.match(out, /page 1 of/i);
73
+ assert.match(out, /Other Commands:/);
74
+ });
75
+
76
+ await check('empty filtered Tag table', () => {
77
+ assert.match(xy(['tags', id, '--icon', 'tag-outline']), /No filtered tags found/i);
78
+ });
79
+
80
+ await check('human detail shows summary and multiline notes', () => {
81
+ const out = xy(['tag', id]);
82
+ for (const label of ['Tag Summary', 'Tag ID', 'Icon', 'Events', 'Author', 'Revision', 'Tag Notes', 'First line', 'Second line']) {
83
+ assert.ok(out.toLowerCase().includes(label.toLowerCase()), label);
84
+ }
85
+ assert.match(out, /Other Commands:/);
86
+ });
87
+
88
+ await check('tagged Event suggestion uses the supported tags filter', () => {
89
+ assert.deepEqual(json(['events', '--tags', id]), []);
90
+ });
91
+
92
+ await check('human update shows target and parsed data', () => {
93
+ const out = xy(['tag', 'update', id, '--notes', 'Preview', '--dry']);
94
+ assert.match(out, /UPDATE TAG/);
95
+ assert.match(out, new RegExp('Tag ID:\\s+' + id));
96
+ assert.match(out, /UPDATE DATA[\s\S]*"notes": "Preview"/);
97
+ });
98
+
99
+ await check('sparse dry update excludes unrelated fields', () => {
100
+ assert.deepEqual(json(['tag', 'update', id, '--notes', 'Preview', '--dry']), { notes: 'Preview', id: id });
101
+ });
102
+
103
+ await check('dry update keeps revision unchanged', () => assert.equal(json(['tag', id]).revision, 1));
104
+
105
+ // Simulate a property introduced by a future xyOps release. The CLI must
106
+ // omit unknown saved properties from its sparse update request.
107
+ await apiCall('updateTag', { id: id, future_property: { enabled: true } });
108
+ json(['tag', 'update', id, '--title', title + ' Updated', '--notes', 'Updated notes']);
109
+
110
+ await check('sparse update preserves a future server property', () => {
111
+ const row = json(['tag', id]);
112
+ assert.equal(row.title, title + ' Updated');
113
+ assert.equal(row.notes, 'Updated notes');
114
+ assert.equal(row.icon, 'alert-rhombus');
115
+ assert.deepEqual(row.future_property, { enabled: true });
116
+ assert.equal(row.revision, 3);
117
+ });
118
+
119
+ json(['tag', 'update', id, '--json', '@-'], {
120
+ input: JSON.stringify({ icon: '', notes: 'From standard input' })
121
+ });
122
+
123
+ await check('complete JSON update from stdin', () => {
124
+ const row = json(['tag', id]);
125
+ assert.equal(row.icon, '');
126
+ assert.equal(row.notes, 'From standard input');
127
+ assert.deepEqual(row.future_property, { enabled: true });
128
+ });
129
+
130
+ const revision = json(['tag', id]).revision;
131
+ await check('unconfirmed delete shows target and warning toast', () => {
132
+ for (const args of [['delete', id], ['delete', id, '--confirm', 'false']]) {
133
+ const out = xy(['tag', ...args]);
134
+ assert.match(out, /DELETE TAG/);
135
+ assert.match(out, /⚠️[\s\S]*Please confirm the Tag delete/);
136
+ }
137
+ });
138
+
139
+ const rejected = [
140
+ ['update', id],
141
+ ['update', title + ' Updated', '--notes', 'Wrong selector'],
142
+ ['update', id, '--id', 'different', '--notes', 'Wrong ID'],
143
+ ['update', id, '--title', ' '],
144
+ ['update', id, '--icon', 'true'],
145
+ ['update', id, '--notes', 'false'],
146
+ ['update', id, '--revision', '99'],
147
+ ['update', id, '--notess', 'Typo'],
148
+ ['update', id, '--notes.extra', 'Bad path'],
149
+ ['delete', title + ' Updated', '--confirm'],
150
+ ['delete', id, '--confirm', 'maybe']
151
+ ];
152
+
153
+ for (const args of rejected) {
154
+ await check('reject Tag ' + args.slice(0, 4).join(' '), () => xy(['tag', ...args], { fail: true }));
155
+ }
156
+
157
+ await check('rejected requests leave revision unchanged', () => assert.equal(json(['tag', id]).revision, revision));
158
+
159
+ await check('duplicate Tag ID error is surfaced', () => {
160
+ assert.match(xy(['tag', 'create', '--id', id, '--title', title], { fail: true }), /already exists/i);
161
+ });
162
+
163
+ const auto = json(['tag', 'create', '--title', title + ' Automatic']);
164
+ ids.add(auto.id);
165
+
166
+ await check('automatic ID and default icon', () => {
167
+ assert.match(auto.id, /^t/);
168
+ assert.equal(auto.icon, 'tag-outline');
169
+ assert.equal(auto.notes, '');
170
+ });
171
+
172
+ const file = Path.join(temp, 'tag.json');
173
+ fs.writeFileSync(file, JSON.stringify({
174
+ title: title + ' File',
175
+ icon: 'tag-heart-outline',
176
+ notes: 'Created from JSON'
177
+ }));
178
+ const imported = json(['tag', 'create', '--json', '@' + file]);
179
+ ids.add(imported.id);
180
+
181
+ await check('complete creation from JSON file', () => {
182
+ assert.equal(imported.icon, 'tag-heart-outline');
183
+ assert.equal(imported.notes, 'Created from JSON');
184
+ });
185
+
186
+ const humanID = id + '_human';
187
+ ids.add(humanID);
188
+ await check('human creation prints suggested commands', () => {
189
+ assert.match(xy(['tag', 'create', '--id', humanID, '--title', title + ' Human']), /Other Commands:/);
190
+ });
191
+
192
+ // Remove auxiliary definitions before the final primary delete checks.
193
+ for (const tagID of Array.from(ids)) {
194
+ if (tagID === id) continue;
195
+ json(['tag', 'delete', tagID, '--confirm']);
196
+ ids.delete(tagID);
197
+ }
198
+
199
+ await check('dry delete request contains only the ID', () => {
200
+ assert.deepEqual(json(['tag', 'delete', id, '--confirm', '--dry']), { id: id });
201
+ });
202
+
203
+ await check('dry delete leaves the Tag intact', () => assert.equal(json(['tag', id]).id, id));
204
+
205
+ json(['tag', 'delete', id, '--confirm']);
206
+ ids.delete(id);
207
+
208
+ await check('confirmed delete removes the Tag', () => assert.deepEqual(json(['tags', '--id', id]), []));
209
+
210
+ await check('get after deletion fails', () => xy(['tag', id], { fail: true }));
211
+
212
+ for (const topic of ['tags', 'tag', 'tag list', 'tag get', 'tag create', 'tag update', 'tag delete']) {
213
+ await check('help ' + topic, () => assert.ok(xy(['help', ...topic.split(' ')]).length > 100));
214
+ }
215
+
216
+ await check('existing Tags remain unchanged', async () => {
217
+ const now = (await apiCall('getTags')).rows;
218
+ for (const tag of original) {
219
+ const current = now.find(item => item.id === tag.id);
220
+ assert.ok(current, 'Existing Tag still present: ' + tag.id);
221
+ assert.equal(JSON.stringify(current), JSON.stringify(tag), 'Existing Tag unchanged: ' + tag.id);
222
+ }
223
+ });
224
+ }
225
+ finally {
226
+ await cleanupFixtures([
227
+ { list: 'tags', method: 'deleteTag', match: item => ids.has(item.id) || item.title.startsWith(title) }
228
+ ]);
229
+ }
230
+ });
@@ -0,0 +1,261 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const Path = require('node:path');
5
+ const sdk = require('@pixlcore/xyops-sdk');
6
+ const {
7
+ loadTestConfig, createCheck, createTempDir, runCLI, xy, json,
8
+ call: apiCall
9
+ } = require('./helpers/common.js');
10
+
11
+ test('tickets', async t => {
12
+ loadTestConfig();
13
+ const check = createCheck(t);
14
+ const stamp = Date.now();
15
+ const id = 'tcli_ticket_' + stamp;
16
+ const subject = 'CLI Ticket Test ' + stamp;
17
+ const tagID = 'cli_ticket_tag_' + stamp;
18
+ const temp = createTempDir(t, 'xycli-ticket-test-');
19
+ const bodyFile = Path.join(temp, 'ticket.md');
20
+ const firstFile = Path.join(temp, 'first-attachment.txt');
21
+ const secondFile = Path.join(temp, 'second-attachment.json');
22
+ const traceFile = Path.join(temp, 'trace.jsonl');
23
+ const body = '# Test Ticket\n\n- **Bold item** with `code`\n';
24
+ let ticketCreated = false;
25
+ let tagCreated = false;
26
+
27
+ fs.writeFileSync(bodyFile, body);
28
+ fs.writeFileSync(firstFile, 'First Ticket attachment.\n');
29
+ fs.writeFileSync(secondFile, '{\n\t"attachment": true\n}\n');
30
+
31
+ try {
32
+ const multiple = await apiCall('getMultiple', { lists: 'all' });
33
+ const event = (multiple.events || []).find( item => item.enabled ) || (multiple.events || [])[0];
34
+
35
+ await apiCall('createTag', { id: tagID, title: 'CLI Ticket Tag ' + stamp, icon: 'tag-outline' });
36
+ tagCreated = true;
37
+
38
+ await check('dry creation fills web editor defaults', () => {
39
+ const req = json(['ticket', 'create', '--id', id, '--subject', subject, '--dry']);
40
+ assert.equal(req.status, 'open');
41
+ assert.equal(req.type, 'change');
42
+ assert.equal(req.body, '');
43
+ assert.deepEqual(req.assignees, []);
44
+ assert.deepEqual(req.cc, []);
45
+ assert.deepEqual(req.notify, []);
46
+ assert.deepEqual(req.tags, []);
47
+ assert.equal(req.due, 0);
48
+ });
49
+
50
+ // Mark cleanup eligible before the combined create/upload command. If the
51
+ // upload fails after creation, the finally block still removes the Ticket.
52
+ ticketCreated = true;
53
+ const created = json([
54
+ 'ticket', 'create', '--id', id, '--subject', subject,
55
+ '--status', 'draft', '--type', 'issue', '--body', '@' + bodyFile,
56
+ '--file', firstFile
57
+ ]);
58
+
59
+ await check('create keeps Ticket draft and uploads a saved attachment', () => {
60
+ assert.equal(created.id, id);
61
+ assert.equal(created.subject, subject);
62
+ assert.equal(created.status, 'draft');
63
+ assert.equal(created.type, 'issue');
64
+ assert.equal(created.body, body);
65
+ assert.deepEqual(created.assignees, []);
66
+ assert.equal(created.files.length, 1);
67
+ assert.equal(created.files[0].filename, Path.basename(firstFile));
68
+ assert.equal(created.files[0].ticket, id);
69
+ });
70
+
71
+ const num = created.num;
72
+ await check('number, hash-number, named number, and ID fetch the same Ticket', () => {
73
+ assert.equal(json(['ticket', String(num)]).id, id);
74
+ assert.equal(json(['ticket', '#' + num]).id, id);
75
+ assert.equal(json(['ticket', 'get', '--number', String(num)]).id, id);
76
+ assert.equal(json(['ticket', 'get', '--id', id]).num, num);
77
+ });
78
+
79
+ await check('raw and named Ticket searches find the fixture', () => {
80
+ assert.ok(json(['tickets', '"' + subject + '"']).some( row => row.id == id));
81
+ assert.ok(json(['tickets', '--num', String(num), '--status', 'draft', '--type', 'issue']).some( row => row.id == id));
82
+ assert.ok(json(['ticket', 'list', '--number', String(num)]).some( row => row.id == id));
83
+ });
84
+
85
+ await check('human Ticket search paginates and suggests commands', () => {
86
+ const out = xy(['tickets', '--status', 'draft', '--limit', '1']);
87
+ assert.match(out, /TICKET SEARCH RESULTS/);
88
+ assert.match(out, /Other Commands:/);
89
+ });
90
+
91
+ await check('close, open, and draft actions translate to sparse status updates', () => {
92
+ assert.deepEqual(json(['ticket', 'update', String(num), 'close', '--dry']), { status: 'closed', id: id });
93
+ assert.deepEqual(json(['ticket', 'update', String(num), 'open', '--dry']), { status: 'open', id: id });
94
+ assert.deepEqual(json(['ticket', 'update', String(num), 'draft', '--dry']), { status: 'draft', id: id });
95
+ });
96
+
97
+ json(['ticket', 'update', String(num), '--assign', 'admin', '--tag', tagID]);
98
+ json(['ticket', 'update', String(num), '--assign', 'admin', '--tag', tagID]);
99
+ await check('assign and Tag sugar append without duplicates', () => {
100
+ const row = json(['ticket', String(num)]);
101
+ assert.deepEqual(row.assignees, ['admin']);
102
+ assert.deepEqual(row.tags, [tagID]);
103
+ assert.equal(row.status, 'draft');
104
+ });
105
+
106
+ const comment = 'Investigation update:\n\n- **Checked** the `logs`.';
107
+ json(['ticket', String(num), '--comment', comment]);
108
+ await check('comment shortcut stores Markdown while Ticket remains draft', () => {
109
+ const row = json(['ticket', String(num)]);
110
+ const saved = row.changes.filter( change => change.type == 'comment' );
111
+ assert.equal(saved.length, 1);
112
+ assert.equal(saved[0].body, comment);
113
+ assert.equal(row.status, 'draft');
114
+ });
115
+
116
+ await check('explicit comment supports stdin and dry run', () => {
117
+ const req = json(['ticket', 'comment', String(num), '--body', '@-', '--dry'], {
118
+ input: '{\n\t"markdown": "text"\n}\n'
119
+ });
120
+ assert.equal(req.id, id);
121
+ assert.equal(req.change.type, 'comment');
122
+ assert.equal(req.change.body, '{\n\t"markdown": "text"\n}\n');
123
+ });
124
+
125
+ const files = json(['ticket', 'upload', String(num), '--file', secondFile]);
126
+ await check('dedicated upload always saves the attachment', () => {
127
+ assert.equal(files.length, 2);
128
+ assert.ok(files.some( file => file.filename == Path.basename(secondFile) && file.ticket == id));
129
+ });
130
+
131
+ const secondMeta = files.find( file => file.filename == Path.basename(secondFile));
132
+ const downloadedFile = Path.join(temp, 'downloaded-attachment.json');
133
+ await check('download saves exact attachment bytes by File ID', () => {
134
+ const out = xy(['ticket', 'download', String(num), secondMeta.id, downloadedFile]);
135
+ assert.match(out, /Successfully downloaded Ticket file/);
136
+ assert.equal(fs.readFileSync(downloadedFile, 'utf8'), fs.readFileSync(secondFile, 'utf8'));
137
+ });
138
+
139
+ await check('download dry run resolves filename and default output path', () => {
140
+ const req = json(['ticket', 'download', String(num), secondMeta.filename, '--dry']);
141
+ assert.equal(req.ticket, id);
142
+ assert.equal(req.number, num);
143
+ assert.equal(req.file, secondMeta.filename);
144
+ assert.equal(req.request.path, secondMeta.path.replace(/^files\//, ''));
145
+ assert.equal(req.request.download, secondMeta.filename);
146
+ assert.equal(req.output, Path.resolve(secondMeta.filename));
147
+ });
148
+
149
+ await check('download refuses unknown files and existing destinations', () => {
150
+ xy(['ticket', 'download', String(num), 'missing-file-id'], { fail: true });
151
+ xy(['ticket', 'download', String(num), secondMeta.id, downloadedFile], { fail: true });
152
+ });
153
+
154
+ if (event) await apiCall('updateTicket', { id: id, events: [{ id: event.id, params: {} }] });
155
+
156
+ await check('human detail renders body, comments, files, and Events', () => {
157
+ const out = xy(['ticket', String(num)]);
158
+ for (const text of [
159
+ 'TICKET #' + num, 'Number', '#' + num, 'Ticket ID', id, subject,
160
+ 'TICKET BODY', 'Test Ticket', '• Bold item with code',
161
+ 'TICKET FILES', Path.basename(firstFile), Path.basename(secondFile), 'Download a file',
162
+ 'Comment by admin', 'Investigation update', '• Checked the logs'
163
+ ]) assert.ok(out.includes(text), text);
164
+ if (event) {
165
+ assert.match(out, /TICKET EVENTS/);
166
+ assert.ok(out.includes(event.id));
167
+ }
168
+ });
169
+
170
+ await check('Ticket detail searches for associated completed Jobs with pagination', () => {
171
+ fs.writeFileSync(traceFile, '');
172
+ runCLI(['ticket', String(num), '--limit', '3', '--page', '2'], {
173
+ preload: Path.join(__dirname, 'helpers/pagination-preload.cjs'),
174
+ env: { XYCLI_PAGINATION_TRACE: traceFile }
175
+ });
176
+ const trace = fs.readFileSync(traceFile, 'utf8').trim().split('\n').filter(Boolean).map( line => JSON.parse(line) );
177
+ const search = trace.find( row => row.method == 'searchJobs' );
178
+ assert.deepEqual(search, { method: 'searchJobs', query: 'tickets:' + id, offset: 3, limit: 3 });
179
+ });
180
+
181
+ await check('exact ID update skips get_ticket when old arrays are unnecessary', () => {
182
+ fs.writeFileSync(traceFile, '');
183
+ runCLI(['ticket', 'update', id, '--subject', subject + ' Updated', '--dry'], {
184
+ preload: Path.join(__dirname, 'helpers/pagination-preload.cjs'),
185
+ env: { XYCLI_PAGINATION_TRACE: traceFile }
186
+ });
187
+ const trace = fs.readFileSync(traceFile, 'utf8').trim().split('\n').filter(Boolean).map( line => JSON.parse(line) );
188
+ assert.ok(!trace.some( row => row.method == 'getTicket'));
189
+ });
190
+
191
+ await apiCall('updateTicket', { id: id, future_property: { retained: true } });
192
+ json(['ticket', 'update', id, '--subject', subject + ' Updated']);
193
+ await check('sparse update preserves future Ticket properties', () => {
194
+ const row = json(['ticket', String(num)]);
195
+ assert.equal(row.subject, subject + ' Updated');
196
+ assert.deepEqual(row.future_property, { retained: true });
197
+ assert.equal(row.status, 'draft');
198
+ });
199
+
200
+ const rejected = [
201
+ ['update', String(num)],
202
+ ['update', String(num), '--subjec', 'Typo'],
203
+ ['update', String(num), '--status', 'pending'],
204
+ ['update', String(num), '--assignees', '{}'],
205
+ ['update', String(num), '--tag', 'missing_ticket_tag'],
206
+ ['comment', String(num), '--body', ''],
207
+ ['upload', String(num), '--file', Path.join(temp, 'missing.txt')],
208
+ ['delete', String(num), '--confirm', 'maybe']
209
+ ];
210
+ for (const args of rejected) {
211
+ await check('reject Ticket ' + args.slice(0, 4).join(' '), () => xy(['ticket', ...args], { fail: true }));
212
+ }
213
+
214
+ await check('unconfirmed delete shows the Ticket and warning toast', () => {
215
+ const out = xy(['ticket', 'delete', String(num)]);
216
+ assert.match(out, /DELETE TICKET/);
217
+ assert.match(out, new RegExp('#' + num));
218
+ assert.match(out, /⚠️[\s\S]*Please confirm the Ticket delete/);
219
+ });
220
+
221
+ await check('dry delete resolves number to the internal ID', () => {
222
+ assert.deepEqual(json(['ticket', 'delete', String(num), '--confirm', '--dry']), { id: id });
223
+ });
224
+
225
+ json(['ticket', 'delete', String(num), '--confirm']);
226
+ ticketCreated = false;
227
+ await check('confirmed delete removes the Ticket', async () => {
228
+ const { err } = await sdk.api.getTicket({ id: id });
229
+ assert.ok(err);
230
+ });
231
+
232
+ for (const topic of [
233
+ 'tickets', 'ticket', 'ticket list', 'ticket get', 'ticket create',
234
+ 'ticket update', 'ticket comment', 'ticket upload', 'ticket download', 'ticket delete'
235
+ ]) {
236
+ await check('help ' + topic, () => assert.ok(xy(['help', ...topic.split(' ')]).length > 100));
237
+ }
238
+ }
239
+ finally {
240
+ const cleanupErrors = [];
241
+ if (ticketCreated) {
242
+ try {
243
+ await sdk.api.deleteTicket({ id: id });
244
+ const result = await sdk.api.getTicket({ id: id });
245
+ if (!result.err) cleanupErrors.push(new Error('Disposable Ticket remains: ' + id));
246
+ }
247
+ catch (error) { cleanupErrors.push(error); }
248
+ }
249
+ if (tagCreated) {
250
+ try {
251
+ await sdk.api.deleteTag({ id: tagID });
252
+ const result = await sdk.api.getTags();
253
+ if (!result.err && result.data.rows.some( tag => tag.id == tagID )) {
254
+ cleanupErrors.push(new Error('Disposable Tag remains: ' + tagID));
255
+ }
256
+ }
257
+ catch (error) { cleanupErrors.push(error); }
258
+ }
259
+ if (cleanupErrors.length) throw new AggregateError(cleanupErrors, 'Ticket fixture cleanup failed.');
260
+ }
261
+ });
@@ -0,0 +1,73 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const cli = require('pixl-cli');
4
+ const utils = require('../lib/utils.js');
5
+
6
+ cli.global();
7
+
8
+ test('toast wraps within the terminal width without ellipsis', () => {
9
+ const originalWidth = cli.width;
10
+ const originalWrite = process.stdout.write;
11
+ const originalColor = cli.chalk.enabled;
12
+ let output = '';
13
+
14
+ try {
15
+ cli.width = function() { return 40; };
16
+ cli.chalk.enabled = false;
17
+ process.stdout.write = function(chunk) {
18
+ output += chunk;
19
+ return true;
20
+ };
21
+
22
+ utils.toast.call({
23
+ colors: { orange: [255, 105, 0] },
24
+ color: utils.color
25
+ }, '⚠️', 'orange', 'Preview only. Review the Plugin data above, then use --confirm to install it.');
26
+ }
27
+ finally {
28
+ cli.width = originalWidth;
29
+ cli.chalk.enabled = originalColor;
30
+ process.stdout.write = originalWrite;
31
+ }
32
+
33
+ const box_lines = output.split('\n').filter( line => line.match(/[┌│└]/) );
34
+ const plain_text = cli.Tools.stripANSI(output).replace(/[┌─┐│└┘]/g, ' ').replace(/\s+/g, ' ');
35
+ assert.ok(box_lines.length > 5, 'Long toast wrapped onto multiple content lines');
36
+ assert.ok(box_lines.every( line => cli.stringWidth(line) <= 40 ), 'Toast stayed within terminal width');
37
+ assert.doesNotMatch(output, /\.\.\./);
38
+ assert.match(plain_text, /Preview only\. Review the Plugin data above, then use --confirm to install it\./);
39
+ });
40
+
41
+ test('user notes preserve explicit lines and wrap within the terminal width', () => {
42
+ const originalWidth = cli.width;
43
+ const originalWrite = process.stdout.write;
44
+ const originalColor = cli.chalk.enabled;
45
+ let output = '';
46
+
47
+ try {
48
+ cli.width = function() { return 40; };
49
+ cli.chalk.enabled = false;
50
+ process.stdout.write = function(chunk) {
51
+ output += chunk;
52
+ return true;
53
+ };
54
+
55
+ utils.printUserNotes.call({
56
+ colors: { theme: [0, 0, 0] },
57
+ color: utils.color
58
+ }, 'THING NOTES', 'First line\nSecond line that is intentionally very long so it wraps around the terminal width.');
59
+ }
60
+ finally {
61
+ cli.width = originalWidth;
62
+ cli.chalk.enabled = originalColor;
63
+ process.stdout.write = originalWrite;
64
+ }
65
+
66
+ const box_lines = output.split('\n').filter( line => line.match(/[┌│└]/) );
67
+ const plain_text = cli.Tools.stripANSI(output);
68
+ assert.match(plain_text, /THING NOTES/);
69
+ assert.match(plain_text, /First line/);
70
+ assert.match(plain_text, /Second line that is intentionally/);
71
+ assert.ok(box_lines.length > 4, 'Long notes wrapped onto multiple content lines');
72
+ assert.ok(box_lines.every( line => cli.stringWidth(line) <= 40 ), 'Notes stayed within terminal width');
73
+ });
@@ -0,0 +1,96 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const Path = require('node:path');
5
+ const zlib = require('node:zlib');
6
+ const crypto = require('node:crypto');
7
+ const { loadTestConfig, createCheck, createTempDir, xy, json, call, cleanupFixtures } = require('./helpers/common.js');
8
+
9
+ test('transfer-apikey', async t => {
10
+ const config = loadTestConfig();
11
+ const check = createCheck(t);
12
+ const verify = (condition, description) => check(description, () => assert.ok(condition, description));
13
+
14
+ // Live API key portability check. Keep plaintext credentials in memory only.
15
+ const title = 'cli_key_portability_' + Date.now();
16
+ const temp = createTempDir(t, 'xycli-key-portability-');
17
+
18
+ async function authenticate(secret) {
19
+ // Use only the disposable secret, with no configured admin credentials or
20
+ // session cookies. A protected read verifies actual authentication.
21
+ const response = await fetch(config.base_url.replace(/\/$/, '') + '/api/app/get_events/v1', {
22
+ method: 'POST',
23
+ headers: { 'Content-Type': 'application/json', 'X-API-Key': secret },
24
+ body: '{}',
25
+ signal: AbortSignal.timeout(10000)
26
+ });
27
+
28
+ return response.json();
29
+ }
30
+
31
+ const original = (await call('getApiKeys', {})).rows;
32
+
33
+ try {
34
+ // Create through the CLI, then retain exactly this original secret for
35
+ // every authentication probe. No replacement secret is ever requested.
36
+ const description = 'First description line.\nSecond description line with enough text to exercise the dedicated display box.';
37
+ const created = json(['key', 'create', '--title', title, '--description', description, '--active', 'true']);
38
+ const id = created.api_key.id;
39
+ const secret = created.plain_key;
40
+
41
+ await verify(typeof secret === 'string' && secret.length > 0, 'Created disposable key with a plaintext secret');
42
+
43
+ const detail = xy(['key', id]);
44
+ await verify(/APP DESCRIPTION[\s\S]*First description line\.[\s\S]*Second description line/.test(detail), 'App description has its own multiline section');
45
+ await verify(!/│ Description\s*:/.test(detail.split('APP DESCRIPTION')[0]), 'App description is omitted from the summary box');
46
+
47
+ await verify((await authenticate(secret)).code === 0, 'Original secret authenticates before export');
48
+
49
+ for (const extension of ['json', 'json.gz']) {
50
+ const filename = Path.join(temp, 'key.' + extension);
51
+ const exported = json(['key', id, '--export', filename]);
52
+ const bytes = fs.readFileSync(filename);
53
+ const text = (extension.endsWith('.gz') ? zlib.gunzipSync(bytes) : bytes).toString();
54
+ const payload = JSON.parse(text);
55
+ const saved = payload.items[0].data;
56
+ const expectedHash = crypto.createHash('sha256').update(secret + id).digest('hex');
57
+
58
+ await verify(exported.count === 1 && payload.items[0].type === 'api_key', extension + ': exported API key as XYPDF');
59
+
60
+ await verify(saved.id === id && saved.key === expectedHash && saved.mask === created.api_key.mask, extension + ': export preserves ID, salted hash and mask');
61
+
62
+ await verify(!text.includes(secret) && !('plain_key' in saved), extension + ': export contains no plaintext secret');
63
+
64
+ xy(['key', 'delete', id, '--confirm']);
65
+
66
+ await verify(!(await call('getApiKeys', {})).rows.some(key => key.id === id), extension + ': key deleted');
67
+ const rejected = await authenticate(secret);
68
+
69
+ await verify(rejected.code !== 0 && /Invalid API Key/i.test(rejected.description || ''), extension + ': original secret rejected after deletion');
70
+
71
+ const preview = json(['import', filename]);
72
+
73
+ await verify(preview.preview === true && preview.items.length === 1 && preview.items[0].operation === 'create', extension + ': import preview plans creation');
74
+
75
+ await verify((await authenticate(secret)).code !== 0, extension + ': preview does not restore access');
76
+
77
+ const imported = json(['import', filename, '--confirm']);
78
+
79
+ await verify(imported.code === 0 && imported.items[0].status === 'created' && imported.items[0].id === id, extension + ': confirmed import recreates original ID');
80
+ const restored = (await call('getApiKeys', {})).rows.find(key => key.id === id);
81
+
82
+ await verify(restored && restored.key === saved.key && restored.mask === saved.mask && !!restored.active, extension + ': restored credential fields and active state match');
83
+
84
+ await verify((await authenticate(secret)).code === 0, extension + ': SAME original plaintext secret authenticates after import');
85
+ }
86
+
87
+ const now = (await call('getApiKeys', {})).rows.filter(key => key.title !== title);
88
+
89
+ await verify(JSON.stringify(now) === JSON.stringify(original), 'Existing API key definitions unchanged');
90
+ }
91
+ finally {
92
+ await cleanupFixtures([
93
+ { list: 'api_keys', method: 'deleteApiKey', match: item => item.title === title }
94
+ ]);
95
+ }
96
+ });