@aderis-erp/cli 1.0.11 → 1.0.13

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 (33) hide show
  1. package/dist/commands/{agent-runs.d.ts → agent-platform.d.ts} +2 -2
  2. package/dist/commands/agent-platform.js +1175 -0
  3. package/dist/commands/auth/login.js +2 -3
  4. package/dist/commands/business-partners.js +2 -2
  5. package/dist/commands/chat.d.ts +40 -0
  6. package/dist/commands/chat.js +1177 -0
  7. package/dist/commands/config/add.js +2 -3
  8. package/dist/commands/config/rename.d.ts +10 -0
  9. package/dist/commands/config/rename.js +68 -0
  10. package/dist/commands/delivery-notes.d.ts +43 -0
  11. package/dist/commands/delivery-notes.js +1204 -0
  12. package/dist/commands/dunning-configs.js +2 -2
  13. package/dist/commands/email-transactions.d.ts +40 -0
  14. package/dist/commands/{agent-runs.js → email-transactions.js} +12 -18
  15. package/dist/commands/inbox.js +3 -3
  16. package/dist/commands/payment-runs.js +1 -3
  17. package/dist/commands/payments.d.ts +40 -0
  18. package/dist/commands/payments.js +1180 -0
  19. package/dist/commands/purchase-orders.d.ts +2 -0
  20. package/dist/commands/purchase-orders.js +14 -4
  21. package/dist/commands/sales-invoices.js +11 -8
  22. package/dist/commands/sales-orders.js +8 -6
  23. package/dist/commands/schedules.d.ts +40 -0
  24. package/dist/commands/schedules.js +1182 -0
  25. package/dist/commands/tax-codes.js +2 -2
  26. package/dist/commands/tenants.js +5 -3
  27. package/dist/commands/vat-declarations.js +1 -1
  28. package/dist/commands/vendor-invoices.js +7 -5
  29. package/dist/lib/auth.js +2 -2
  30. package/dist/lib/config.d.ts +2 -1
  31. package/dist/lib/config.js +20 -5
  32. package/oclif.manifest.json +998 -108
  33. package/package.json +3 -2
@@ -0,0 +1,1177 @@
1
+ import { Command, Args, Flags } from '@oclif/core';
2
+ import { apiRequest, ApiError } from '../lib/api-client.js';
3
+ import { getCurrentContext, getContext, getBaseUrls } from '../lib/config.js';
4
+ // Add 'import' action if both create and update exist
5
+ const HAS_IMPORT = true;
6
+ const HAS_EXPORT = false;
7
+ const HAS_CAMT053 = false;
8
+ const NESTED_IMPORTS = [];
9
+ const SUBCOMMANDS = [...["create", "get", "get-all", "update", "delete", "stream"], ...(HAS_IMPORT ? ['import'] : []), ...(HAS_EXPORT ? ['export'] : []), ...NESTED_IMPORTS.map(n => `import-${n}`)];
10
+ const ENDPOINTS = {
11
+ 'create': { method: 'POST', path: '/v1/chat', summary: 'Send a chat message and stream the assistant reply (SSE).', pathParams: [], hasBody: true, isMultipart: false, isBinaryResponse: false, bodyArrayFields: ['attachments'] },
12
+ 'get': { method: 'GET', path: '/v1/conversations', summary: 'List the caller\'s chat conversations, most recently updated first.', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
13
+ 'get-all': { method: 'GET', path: '/v1/conversations/{id}', summary: 'Fetch one conversation with its messages.', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
14
+ 'update': { method: 'PATCH', path: '/v1/conversations/{id}', summary: 'Store client-owned UI state on a conversation.', pathParams: ['id'], hasBody: true, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
15
+ 'delete': { method: 'DELETE', path: '/v1/conversations/{id}', summary: 'Delete one conversation.', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
16
+ 'stream': { method: 'GET', path: '/v1/conversations/{id}/stream', summary: 'Reattach to a conversation\'s running turn (SSE replay + follow).', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
17
+ };
18
+ export default class Chat extends Command {
19
+ static description = 'Manage chat';
20
+ static examples = [
21
+ '<%= config.bin %> chat get <id>',
22
+ '<%= config.bin %> chat create --data \'{...}\'',
23
+ '<%= config.bin %> chat update <id> --set field=value',
24
+ '<%= config.bin %> chat import --file data.json',
25
+ ];
26
+ static args = {
27
+ action: Args.string({ description: 'Action: ' + SUBCOMMANDS.join(', '), required: true }),
28
+ id: Args.string({ description: 'Resource ID (for get, update, delete operations)', required: false }),
29
+ };
30
+ static flags = {
31
+ context: Flags.string({ char: 'c', description: 'Context to use' }),
32
+ output: Flags.string({ char: 'o', description: 'Output format (default: table for import, json for other commands)', options: ['json', 'table', 'csv'] }),
33
+ table: Flags.boolean({ char: 't', description: 'Output as table (shortcut for --output table)' }),
34
+ csv: Flags.boolean({ description: 'Output as CSV (shortcut for --output csv)' }),
35
+ data: Flags.string({ char: 'd', description: 'JSON data for request body' }),
36
+ file: Flags.string({ char: 'f', description: 'Read request body from file' }),
37
+ set: Flags.string({ char: 's', description: 'Set individual field (e.g. --set firstName=John)', multiple: true }),
38
+ page: Flags.string({ description: 'Page number' }),
39
+ limit: Flags.string({ description: 'Items per page' }),
40
+ include: Flags.string({ description: 'Relations to include' }),
41
+ search: Flags.string({ description: 'Search term' }),
42
+ 'sort-by': Flags.string({ description: 'Sort field' }),
43
+ filter: Flags.string({ description: 'Filter (e.g. --filter status=ACTIVE). Operators: in:A,B, like:%, gt:, lt:, null:', multiple: true }),
44
+ query: Flags.string({ description: 'Additional query params as JSON' }),
45
+ fields: Flags.string({ description: 'Comma-separated list of fields to show (e.g. --fields=id,name,status)' }),
46
+ sanitize: Flags.boolean({ description: 'Remove null values, audit fields, and duplicate UUIDs from output' }),
47
+ 'show-http-request': Flags.boolean({ description: 'Show HTTP request details (method, URL, body)' }),
48
+ };
49
+ async run() {
50
+ const { args, flags } = await this.parse(Chat);
51
+ // Handle import action specially
52
+ if (args.action === 'import') {
53
+ if (!HAS_IMPORT)
54
+ this.error('Import not available for this resource');
55
+ await this.runImport(flags);
56
+ return;
57
+ }
58
+ // Handle export action specially
59
+ if (args.action === 'export') {
60
+ if (!HAS_EXPORT)
61
+ this.error('Export not available for this resource');
62
+ await this.runExport(flags);
63
+ return;
64
+ }
65
+ // Handle CAMT.053 import specially - sends raw XML
66
+ if (args.action === 'import-camt053') {
67
+ if (!HAS_CAMT053)
68
+ this.error('CAMT.053 import not available for this resource');
69
+ await this.runCamt053Import(flags);
70
+ return;
71
+ }
72
+ // Handle nested resource imports (e.g., import-prices)
73
+ if (args.action.startsWith('import-')) {
74
+ const suffix = args.action.replace('import-', '');
75
+ if (NESTED_IMPORTS.includes(suffix)) {
76
+ await this.runNestedImport(suffix, args.id, flags);
77
+ return;
78
+ }
79
+ }
80
+ const endpoint = ENDPOINTS[args.action];
81
+ if (!endpoint)
82
+ this.error(`Unknown action: ${args.action}. Available: ${SUBCOMMANDS.join(', ')}`);
83
+ let apiPath = endpoint.path;
84
+ for (let i = 0; i < endpoint.pathParams.length; i++) {
85
+ const param = endpoint.pathParams[i];
86
+ let value;
87
+ if (i === 0) {
88
+ // First path param always comes from positional argument
89
+ value = args.id;
90
+ if (!value)
91
+ this.error('Missing required argument: id. Usage: aderis chat <action> <id>');
92
+ }
93
+ else {
94
+ const flagName = param.replace(/\./g, '-').replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '');
95
+ value = flags[flagName];
96
+ if (!value)
97
+ this.error(`Missing required parameter: --${flagName}`);
98
+ }
99
+ apiPath = apiPath.replace(`{${param}}`, String(value));
100
+ }
101
+ const context = flags.context ? getContext(flags.context) : getCurrentContext();
102
+ if (!context)
103
+ this.error('No context configured. Run: aderis auth login --new');
104
+ if (!context.tokens)
105
+ this.error('Not logged in. Run: aderis auth login');
106
+ const queryParams = {};
107
+ if (flags.page)
108
+ queryParams.page = flags.page;
109
+ if (flags.limit)
110
+ queryParams.limit = flags.limit;
111
+ if (flags.include)
112
+ queryParams.include = flags.include;
113
+ if (flags.search)
114
+ queryParams.search = flags.search;
115
+ if (flags['sort-by'])
116
+ queryParams.sortBy = flags['sort-by'];
117
+ if (flags.query) {
118
+ try {
119
+ Object.assign(queryParams, JSON.parse(flags.query));
120
+ }
121
+ catch {
122
+ this.error('Invalid JSON in --query');
123
+ }
124
+ }
125
+ // Parse --filter flags (e.g., --filter status=ACTIVE becomes filter.status=$eq:ACTIVE)
126
+ if (flags.filter && flags.filter.length > 0) {
127
+ for (const f of flags.filter) {
128
+ const eqIndex = f.indexOf('=');
129
+ if (eqIndex === -1)
130
+ this.error(`Invalid --filter format: ${f}. Use: --filter key=value`);
131
+ const key = f.slice(0, eqIndex);
132
+ const value = this.parseFilterValue(f.slice(eqIndex + 1));
133
+ const filterKey = `filter.${key}`;
134
+ if (filterKey in queryParams) {
135
+ // Multiple filters on same field: collect as array
136
+ const existing = queryParams[filterKey];
137
+ queryParams[filterKey] = Array.isArray(existing) ? [...existing, value] : [existing, value];
138
+ }
139
+ else {
140
+ queryParams[filterKey] = value;
141
+ }
142
+ }
143
+ }
144
+ let data;
145
+ if (endpoint.isMultipart) {
146
+ if (!flags.file)
147
+ this.error('Missing required --file flag for file upload');
148
+ const fsModule = await import('node:fs');
149
+ const pathModule = await import('node:path');
150
+ const FormDataLib = (await import('form-data')).default;
151
+ const form = new FormDataLib();
152
+ form.append('file', fsModule.createReadStream(flags.file), pathModule.basename(flags.file));
153
+ // Append --set fields as extra form fields (e.g. --set type=CREDIT_CARD_STATEMENT)
154
+ if (flags.set && flags.set.length > 0) {
155
+ for (const pair of flags.set) {
156
+ const eqIndex = pair.indexOf('=');
157
+ if (eqIndex === -1)
158
+ this.error(`Invalid --set format: ${pair}. Use: --set key=value`);
159
+ form.append(pair.slice(0, eqIndex), pair.slice(eqIndex + 1));
160
+ }
161
+ }
162
+ data = form;
163
+ }
164
+ else if (endpoint.hasBody) {
165
+ // For binary-response endpoints, --file is the output path, not a JSON
166
+ // input file — skip the read-as-body branch.
167
+ if (flags.file && !endpoint.isBinaryResponse) {
168
+ const fsModule = await import('node:fs');
169
+ try {
170
+ data = JSON.parse(fsModule.readFileSync(flags.file, 'utf-8'));
171
+ }
172
+ catch {
173
+ this.error(`Failed to read file: ${flags.file}`);
174
+ }
175
+ }
176
+ else if (flags.data) {
177
+ try {
178
+ data = JSON.parse(flags.data);
179
+ }
180
+ catch {
181
+ this.error('Invalid JSON in --data');
182
+ }
183
+ }
184
+ // Apply --set flags (can be combined with --data or --file)
185
+ if (flags.set && flags.set.length > 0) {
186
+ if (!data)
187
+ data = {};
188
+ const body = data;
189
+ const arrayFields = new Set(endpoint.bodyArrayFields);
190
+ for (const pair of flags.set) {
191
+ const eqIndex = pair.indexOf('=');
192
+ if (eqIndex === -1)
193
+ this.error(`Invalid --set format: ${pair}. Use: --set key=value`);
194
+ const key = pair.slice(0, eqIndex);
195
+ let value = pair.slice(eqIndex + 1);
196
+ // Try to parse as JSON for numbers, booleans, arrays, objects
197
+ try {
198
+ value = JSON.parse(value);
199
+ }
200
+ catch { /* keep as string */ }
201
+ // For top-level keys typed as array in the API schema, split bare
202
+ // comma-separated input (e.g. "1171,1170" -> ["1171","1170"]) and
203
+ // wrap a lone scalar in a single-element array. Skips when --set
204
+ // already provided a JSON array.
205
+ const isArrayField = !key.includes('.') && arrayFields.has(key);
206
+ if (isArrayField && !Array.isArray(value)) {
207
+ if (typeof value === 'string') {
208
+ value = value.split(',').map(v => v.trim()).filter(v => v.length > 0);
209
+ }
210
+ else if (value !== undefined && value !== null) {
211
+ value = [value];
212
+ }
213
+ }
214
+ // Support nested keys like "address.city"
215
+ const keys = key.split('.');
216
+ if (keys.some(k => k === '__proto__' || k === 'constructor' || k === 'prototype')) {
217
+ this.error(`Invalid --set key: ${key}`);
218
+ }
219
+ let obj = body;
220
+ for (let i = 0; i < keys.length - 1; i++) {
221
+ if (!(keys[i] in obj) || typeof obj[keys[i]] !== 'object') {
222
+ obj[keys[i]] = {};
223
+ }
224
+ obj = obj[keys[i]];
225
+ }
226
+ obj[keys[keys.length - 1]] = value;
227
+ }
228
+ }
229
+ }
230
+ else if (flags.set && flags.set.length > 0) {
231
+ // For GET endpoints, --set adds query params
232
+ for (const pair of flags.set) {
233
+ const eqIndex = pair.indexOf('=');
234
+ if (eqIndex === -1)
235
+ this.error(`Invalid --set format: ${pair}. Use: --set key=value`);
236
+ const key = pair.slice(0, eqIndex);
237
+ let value = pair.slice(eqIndex + 1);
238
+ try {
239
+ value = JSON.parse(value);
240
+ }
241
+ catch { /* keep as string */ }
242
+ queryParams[key] = value;
243
+ }
244
+ }
245
+ // Show HTTP request details if --show-http-request flag is set
246
+ if (flags['show-http-request']) {
247
+ const baseUrls = getBaseUrls(context.environment);
248
+ const queryString = Object.keys(queryParams).length > 0
249
+ ? '?' + Object.entries(queryParams).flatMap(([k, v]) => {
250
+ const vals = Array.isArray(v) ? v : [v];
251
+ return vals.map(val => `${encodeURIComponent(k)}=${encodeURIComponent(String(val))}`);
252
+ }).join('&')
253
+ : '';
254
+ process.stderr.write(`\n${endpoint.method.toUpperCase()} ${baseUrls.api}${apiPath}${queryString}\n`);
255
+ if (endpoint.isMultipart) {
256
+ process.stderr.write(`Body: [multipart/form-data file upload]\n`);
257
+ }
258
+ else if (data) {
259
+ process.stderr.write(`Body: ${JSON.stringify(data, null, 2)}\n`);
260
+ }
261
+ process.stderr.write('\n');
262
+ }
263
+ try {
264
+ // Binary response: download to file
265
+ if (endpoint.isBinaryResponse) {
266
+ if (!flags.file) {
267
+ this.error(`Endpoint returns a binary file. Specify the output path with -f / --file (e.g. -f report.xlsx).`);
268
+ }
269
+ const binResponse = await apiRequest(endpoint.method, apiPath, {
270
+ params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
271
+ data,
272
+ contextName: flags.context,
273
+ binary: true,
274
+ });
275
+ const fsModule = await import('node:fs');
276
+ const binData = binResponse.data;
277
+ const buffer = Buffer.isBuffer(binData)
278
+ ? binData
279
+ : Buffer.from(binData);
280
+ fsModule.writeFileSync(flags.file, buffer);
281
+ this.log(`Saved ${buffer.length} bytes to ${flags.file}`);
282
+ return;
283
+ }
284
+ const response = await apiRequest(endpoint.method, apiPath, {
285
+ params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
286
+ data,
287
+ contextName: flags.context,
288
+ acceptAny: true,
289
+ });
290
+ // Check content-type for non-JSON responses (XML, text, etc.)
291
+ const contentType = (response.headers['content-type'] || '').toLowerCase();
292
+ if (contentType.includes('xml') || contentType.includes('text/')) {
293
+ // Output raw content for XML/text responses (CSV, plain, HTML, etc.)
294
+ this.log(String(response.data));
295
+ return;
296
+ }
297
+ // Parse JSON if response came as string (due to acceptAny)
298
+ let output = response.data;
299
+ if (typeof output === 'string') {
300
+ try {
301
+ output = JSON.parse(output);
302
+ }
303
+ catch { /* keep as string */ }
304
+ }
305
+ // Extract data array from paginated wrapper (e.g., { data: [...], total: 3 })
306
+ if (output && typeof output === 'object' && !Array.isArray(output) && 'data' in output && Array.isArray(output.data)) {
307
+ output = output.data;
308
+ }
309
+ // For table/csv on non-array responses, auto-extract the primary array
310
+ // e.g., { payments: [...], total: 3 } → extract payments array
311
+ if ((flags.table || flags.output === 'table' || flags.csv || flags.output === 'csv')
312
+ && output && typeof output === 'object' && !Array.isArray(output)) {
313
+ const obj = output;
314
+ const arrayKeys = Object.keys(obj).filter(k => Array.isArray(obj[k]));
315
+ if (arrayKeys.length === 1) {
316
+ output = obj[arrayKeys[0]];
317
+ }
318
+ }
319
+ // Filter fields if --fields flag is provided
320
+ if (flags.fields) {
321
+ const fieldList = flags.fields.split(',').map(f => f.trim());
322
+ output = this.filterFields(output, fieldList);
323
+ }
324
+ // Sanitize output if requested
325
+ if (flags.sanitize) {
326
+ output = this.sanitizeOutput(output);
327
+ }
328
+ const fieldList = flags.fields?.split(',').map(f => f.trim());
329
+ if (flags.csv || flags.output === 'csv')
330
+ this.printCsv(output, fieldList);
331
+ else if (flags.table || flags.output === 'table')
332
+ this.printTable(output, fieldList);
333
+ else
334
+ this.log(JSON.stringify(output, null, 2));
335
+ }
336
+ catch (error) {
337
+ if (error instanceof ApiError) {
338
+ if (error.status === 404)
339
+ this.error(error.message);
340
+ this.error(`API Error (${error.status}): ${error.message}`);
341
+ }
342
+ throw error;
343
+ }
344
+ }
345
+ async runImport(flags) {
346
+ const context = flags.context ? getContext(flags.context) : getCurrentContext();
347
+ if (!context)
348
+ this.error('No context configured. Run: aderis auth login --new');
349
+ if (!context.tokens)
350
+ this.error('Not logged in. Run: aderis auth login');
351
+ // Get data from file, --data, or --set flags
352
+ let items = [];
353
+ if (flags.file) {
354
+ const fsModule = await import('node:fs');
355
+ try {
356
+ const content = JSON.parse(fsModule.readFileSync(flags.file, 'utf-8'));
357
+ items = Array.isArray(content) ? content : [content];
358
+ }
359
+ catch {
360
+ this.error(`Failed to read file: ${flags.file}`);
361
+ }
362
+ }
363
+ else if (flags.data) {
364
+ try {
365
+ const content = JSON.parse(flags.data);
366
+ items = Array.isArray(content) ? content : [content];
367
+ }
368
+ catch {
369
+ this.error('Invalid JSON in --data');
370
+ }
371
+ }
372
+ else if (flags.set && flags.set.length > 0) {
373
+ const item = {};
374
+ const arrayFields = new Set(ENDPOINTS['create']?.bodyArrayFields ?? []);
375
+ for (const pair of flags.set) {
376
+ const eqIndex = pair.indexOf('=');
377
+ if (eqIndex === -1)
378
+ this.error(`Invalid --set format: ${pair}. Use: --set key=value`);
379
+ const key = pair.slice(0, eqIndex);
380
+ let value = pair.slice(eqIndex + 1);
381
+ try {
382
+ value = JSON.parse(value);
383
+ }
384
+ catch { /* keep as string */ }
385
+ const isArrayField = !key.includes('.') && arrayFields.has(key);
386
+ if (isArrayField && !Array.isArray(value)) {
387
+ if (typeof value === 'string') {
388
+ value = value.split(',').map(v => v.trim()).filter(v => v.length > 0);
389
+ }
390
+ else if (value !== undefined && value !== null) {
391
+ value = [value];
392
+ }
393
+ }
394
+ const keys = key.split('.');
395
+ if (keys.some(k => k === '__proto__' || k === 'constructor' || k === 'prototype')) {
396
+ this.error(`Invalid --set key: ${key}`);
397
+ }
398
+ let obj = item;
399
+ for (let i = 0; i < keys.length - 1; i++) {
400
+ if (!(keys[i] in obj))
401
+ obj[keys[i]] = {};
402
+ obj = obj[keys[i]];
403
+ }
404
+ obj[keys[keys.length - 1]] = value;
405
+ }
406
+ items = [item];
407
+ }
408
+ else {
409
+ this.error('Import requires --file, --data, or --set flags');
410
+ }
411
+ const createEndpoint = ENDPOINTS['create'];
412
+ const updateEndpoint = ENDPOINTS['update'];
413
+ if (!createEndpoint || !updateEndpoint) {
414
+ this.error('Import requires both create and update endpoints');
415
+ }
416
+ // Helper to extract identifier from item (id, code, or xxxCode)
417
+ const getItemIdentifier = (item) => {
418
+ if (item.id)
419
+ return String(item.id);
420
+ if (item.code)
421
+ return String(item.code);
422
+ // Look for xxxCode fields (e.g., ledgerCode, accountCode)
423
+ for (const key of Object.keys(item)) {
424
+ if (key.endsWith('Code') && item[key])
425
+ return String(item[key]);
426
+ }
427
+ // Look for name as fallback
428
+ if (item.name)
429
+ return String(item.name).slice(0, 30);
430
+ return '';
431
+ };
432
+ const results = [];
433
+ // Stream output row-by-row so progress is visible during long imports.
434
+ // Pre-compute the identifier column width from the input items so the
435
+ // table stays aligned without buffering all rows.
436
+ // When stdout is piped (IDE terminals, tee, CI), Node switches to async-buffered
437
+ // mode and rows accumulate in the pipe buffer until it fills. Force blocking
438
+ // mode so each \n-terminated write flushes immediately.
439
+ const stdoutHandle = process.stdout._handle;
440
+ if (stdoutHandle?.setBlocking)
441
+ stdoutHandle.setBlocking(true);
442
+ const outputFormat = flags.output || 'table';
443
+ const useCsv = outputFormat === 'csv' || Boolean(flags.csv);
444
+ const useJson = outputFormat === 'json';
445
+ const statusWidth = 8;
446
+ const identifierWidth = Math.max(10, ...items.map(i => getItemIdentifier(i).length));
447
+ if (useCsv) {
448
+ this.log('status,identifier,id,error');
449
+ }
450
+ else if (!useJson) {
451
+ this.log(`${'status'.padEnd(statusWidth)} ${'identifier'.padEnd(identifierWidth)} error`);
452
+ this.log(`${'-'.repeat(statusWidth)} ${'-'.repeat(identifierWidth)} ${'-'.repeat(50)}`);
453
+ }
454
+ const emitRow = (r) => {
455
+ if (useJson)
456
+ return;
457
+ if (useCsv) {
458
+ const err = (r.error || '').replace(/"/g, '""');
459
+ this.log(`${r.status},${r.identifier || ''},${r.id || ''},"${err}"`);
460
+ }
461
+ else {
462
+ this.log(`${r.status.padEnd(statusWidth)} ${(r.identifier || '').padEnd(identifierWidth)} ${r.error || ''}`);
463
+ }
464
+ };
465
+ // Server-managed fields the API rejects in create/update bodies. Stripped
466
+ // before every request so re-importing a previously-exported payload Just Works.
467
+ const STRIP_FIELDS = new Set(['id', '_type', 'tenantId', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy']);
468
+ const stripServerFields = (item) => {
469
+ const out = {};
470
+ for (const [k, v] of Object.entries(item)) {
471
+ if (!STRIP_FIELDS.has(k))
472
+ out[k] = v;
473
+ }
474
+ return out;
475
+ };
476
+ const processItem = async (item) => {
477
+ const identifier = getItemIdentifier(item);
478
+ const body = stripServerFields(item);
479
+ // If the payload carries an id (e.g. re-importing an export), PATCH directly.
480
+ // Fall through to POST only on 404 (so a missing record still gets created).
481
+ const itemId = typeof item.id === 'string' ? item.id : undefined;
482
+ if (itemId) {
483
+ try {
484
+ const updatePath = updateEndpoint.path.replace('{id}', itemId);
485
+ await apiRequest('PATCH', updatePath, {
486
+ data: body,
487
+ contextName: flags.context,
488
+ });
489
+ return { status: 'updated', id: itemId, identifier };
490
+ }
491
+ catch (error) {
492
+ if (!(error instanceof ApiError && error.status === 404)) {
493
+ const msg = error instanceof ApiError ? `(${error.status}) ${error.message}` : String(error);
494
+ return { status: 'error', id: itemId, identifier, error: msg };
495
+ }
496
+ // 404 → record gone, try create below
497
+ }
498
+ }
499
+ try {
500
+ const response = await apiRequest('POST', createEndpoint.path, {
501
+ data: body,
502
+ contextName: flags.context,
503
+ });
504
+ const id = response.data?.id;
505
+ return { status: 'created', id: id ? String(id) : undefined, identifier };
506
+ }
507
+ catch (error) {
508
+ if (error instanceof ApiError && error.status === 409) {
509
+ const errorData = error.data;
510
+ const existingId = errorData?.existingId;
511
+ if (existingId) {
512
+ try {
513
+ const updatePath = updateEndpoint.path.replace('{id}', existingId);
514
+ await apiRequest('PATCH', updatePath, {
515
+ data: body,
516
+ contextName: flags.context,
517
+ });
518
+ return { status: 'updated', id: existingId, identifier };
519
+ }
520
+ catch (updateError) {
521
+ const msg = updateError instanceof ApiError ? updateError.message : String(updateError);
522
+ return { status: 'error', id: existingId, identifier, error: `Update failed: ${msg}` };
523
+ }
524
+ }
525
+ return { status: 'error', identifier, error: 'Conflict but no existingId returned' };
526
+ }
527
+ const msg = error instanceof ApiError ? `(${error.status}) ${error.message}` : String(error);
528
+ return { status: 'error', identifier, error: msg };
529
+ }
530
+ };
531
+ // Worker pool: keep N requests in flight at all times. Rows are emitted as
532
+ // each request resolves (so output order may differ from input order). The
533
+ // summary at the end remains accurate regardless.
534
+ const concurrency = 4;
535
+ const queue = [...items];
536
+ await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
537
+ for (;;) {
538
+ const item = queue.shift();
539
+ if (!item)
540
+ return;
541
+ const r = await processItem(item);
542
+ results.push(r);
543
+ emitRow(r);
544
+ }
545
+ }));
546
+ // Summary counts
547
+ const created = results.filter(r => r.status === 'created').length;
548
+ const updated = results.filter(r => r.status === 'updated').length;
549
+ const errors = results.filter(r => r.status === 'error');
550
+ if (useJson) {
551
+ this.log(JSON.stringify(results, null, 2));
552
+ }
553
+ // Always show summary at the end
554
+ this.log('');
555
+ this.log(`Summary: ${created} created, ${updated} updated, ${errors.length} failed`);
556
+ }
557
+ async runExport(flags) {
558
+ const context = flags.context ? getContext(flags.context) : getCurrentContext();
559
+ if (!context)
560
+ this.error('No context configured. Run: aderis auth login --new');
561
+ if (!context.tokens)
562
+ this.error('Not logged in. Run: aderis auth login');
563
+ const listEndpoint = ENDPOINTS['list'];
564
+ if (!listEndpoint)
565
+ this.error('Export requires a list endpoint');
566
+ const allItems = [];
567
+ let page = 1;
568
+ const limit = 100;
569
+ let hasMore = true;
570
+ // Build base query params
571
+ const baseParams = {};
572
+ if (flags.search)
573
+ baseParams.search = flags.search;
574
+ if (flags['sort-by'])
575
+ baseParams.sortBy = flags['sort-by'];
576
+ if (flags.include)
577
+ baseParams.include = flags.include;
578
+ if (flags.query) {
579
+ try {
580
+ Object.assign(baseParams, JSON.parse(flags.query));
581
+ }
582
+ catch {
583
+ this.error('Invalid JSON in --query');
584
+ }
585
+ }
586
+ // Parse --filter flags (auto-prepend $eq: if no operator specified)
587
+ if (flags.filter && flags.filter.length > 0) {
588
+ for (const f of flags.filter) {
589
+ const eqIndex = f.indexOf('=');
590
+ if (eqIndex === -1)
591
+ this.error(`Invalid --filter format: ${f}. Use: --filter key=value`);
592
+ const key = f.slice(0, eqIndex);
593
+ const value = this.parseFilterValue(f.slice(eqIndex + 1));
594
+ const filterKey = `filter.${key}`;
595
+ if (filterKey in baseParams) {
596
+ const existing = baseParams[filterKey];
597
+ baseParams[filterKey] = Array.isArray(existing) ? [...existing, value] : [existing, value];
598
+ }
599
+ else {
600
+ baseParams[filterKey] = value;
601
+ }
602
+ }
603
+ }
604
+ // Fetch all pages
605
+ process.stderr.write('Exporting...');
606
+ while (hasMore) {
607
+ const params = { ...baseParams, page, limit };
608
+ const response = await apiRequest('GET', listEndpoint.path, {
609
+ params,
610
+ contextName: flags.context,
611
+ });
612
+ let items = [];
613
+ const data = response.data;
614
+ // Handle paginated response format
615
+ if (data && typeof data === 'object' && 'data' in data && Array.isArray(data.data)) {
616
+ items = data.data;
617
+ }
618
+ else if (Array.isArray(response.data)) {
619
+ items = response.data;
620
+ }
621
+ allItems.push(...items);
622
+ process.stderr.write(` ${allItems.length}`);
623
+ // Check if there are more pages
624
+ if (items.length < limit) {
625
+ hasMore = false;
626
+ }
627
+ else {
628
+ page++;
629
+ }
630
+ }
631
+ process.stderr.write(' done\n');
632
+ // Filter fields if specified
633
+ let output = allItems;
634
+ if (flags.fields) {
635
+ const fieldList = flags.fields.split(',').map(f => f.trim());
636
+ output = this.filterFields(allItems, fieldList);
637
+ }
638
+ // Sanitize output if requested
639
+ if (flags.sanitize) {
640
+ output = this.sanitizeOutput(output);
641
+ }
642
+ // Output in requested format
643
+ const fieldList = flags.fields ? flags.fields.split(',').map(f => f.trim()) : undefined;
644
+ if (flags.csv || flags.output === 'csv') {
645
+ this.printCsv(output, fieldList);
646
+ }
647
+ else {
648
+ this.log(JSON.stringify(output, null, 2));
649
+ }
650
+ }
651
+ async runCamt053Import(flags) {
652
+ const context = flags.context ? getContext(flags.context) : getCurrentContext();
653
+ if (!context)
654
+ this.error('No context configured. Run: aderis auth login --new');
655
+ if (!context.tokens)
656
+ this.error('Not logged in. Run: aderis auth login');
657
+ if (!flags.file) {
658
+ this.error('CAMT.053 import requires --file (-f) flag with path to XML file');
659
+ }
660
+ const fsModule = await import('node:fs');
661
+ let xmlContent;
662
+ try {
663
+ xmlContent = fsModule.readFileSync(flags.file, 'utf-8');
664
+ }
665
+ catch {
666
+ this.error(`Failed to read file: ${flags.file}`);
667
+ }
668
+ const endpoint = ENDPOINTS['import-camt053'];
669
+ try {
670
+ const response = await apiRequest(endpoint.method, endpoint.path, {
671
+ data: xmlContent,
672
+ contextName: flags.context,
673
+ contentType: 'application/xml',
674
+ });
675
+ // Extract transactions array and counts from response
676
+ let transactions = [];
677
+ let imported = 0;
678
+ let skipped = 0;
679
+ if (response.data && typeof response.data === 'object') {
680
+ const data = response.data;
681
+ if ('data' in data && Array.isArray(data.data)) {
682
+ transactions = data.data;
683
+ }
684
+ else if ('transactions' in data && Array.isArray(data.transactions)) {
685
+ transactions = data.transactions;
686
+ }
687
+ else if (Array.isArray(response.data)) {
688
+ transactions = response.data;
689
+ }
690
+ if (typeof data.imported === 'number')
691
+ imported = data.imported;
692
+ if (typeof data.skipped === 'number')
693
+ skipped = data.skipped;
694
+ }
695
+ else if (Array.isArray(response.data)) {
696
+ transactions = response.data;
697
+ }
698
+ if (imported === 0 && skipped === 0 && transactions.length > 0) {
699
+ for (const tx of transactions) {
700
+ const t = tx;
701
+ if (t.status === 'skipped' || t.skipped === true) {
702
+ skipped++;
703
+ }
704
+ else {
705
+ imported++;
706
+ }
707
+ }
708
+ if (imported === 0 && skipped === 0) {
709
+ imported = transactions.length;
710
+ }
711
+ }
712
+ let output = transactions;
713
+ if (flags.sanitize) {
714
+ output = this.sanitizeOutput(transactions);
715
+ }
716
+ const fieldList = flags.fields ? flags.fields.split(',').map(f => f.trim()) : undefined;
717
+ if (flags.csv || flags.output === 'csv') {
718
+ this.printCsv(output, fieldList);
719
+ }
720
+ else if (flags.table || flags.output === 'table') {
721
+ this.printTable(output, fieldList);
722
+ }
723
+ else {
724
+ this.log(JSON.stringify(output, null, 2));
725
+ }
726
+ this.log('');
727
+ this.log(`Summary: ${imported} imported, ${skipped} skipped`);
728
+ }
729
+ catch (error) {
730
+ if (error instanceof ApiError) {
731
+ if (error.status === 404)
732
+ this.error(error.message);
733
+ this.error(`API Error (${error.status}): ${error.message}`);
734
+ }
735
+ throw error;
736
+ }
737
+ }
738
+ async runNestedImport(suffix, parentId, flags) {
739
+ if (!parentId) {
740
+ this.error(`Missing required argument: id. Usage: aderis chat import-${suffix} <parent-id> --file data.json`);
741
+ }
742
+ const context = flags.context ? getContext(flags.context) : getCurrentContext();
743
+ if (!context)
744
+ this.error('No context configured. Run: aderis auth login --new');
745
+ if (!context.tokens)
746
+ this.error('Not logged in. Run: aderis auth login');
747
+ // Get data from file, --data, or --set flags
748
+ let items = [];
749
+ if (flags.file) {
750
+ const fsModule = await import('node:fs');
751
+ try {
752
+ const content = JSON.parse(fsModule.readFileSync(flags.file, 'utf-8'));
753
+ items = Array.isArray(content) ? content : [content];
754
+ }
755
+ catch {
756
+ this.error(`Failed to read file: ${flags.file}`);
757
+ }
758
+ }
759
+ else if (flags.data) {
760
+ try {
761
+ const content = JSON.parse(flags.data);
762
+ items = Array.isArray(content) ? content : [content];
763
+ }
764
+ catch {
765
+ this.error('Invalid JSON in --data');
766
+ }
767
+ }
768
+ else if (flags.set && flags.set.length > 0) {
769
+ const item = {};
770
+ const arrayFields = new Set(ENDPOINTS[`create-${suffix}`]?.bodyArrayFields ?? []);
771
+ for (const pair of flags.set) {
772
+ const eqIndex = pair.indexOf('=');
773
+ if (eqIndex === -1)
774
+ this.error(`Invalid --set format: ${pair}. Use: --set key=value`);
775
+ const key = pair.slice(0, eqIndex);
776
+ let value = pair.slice(eqIndex + 1);
777
+ try {
778
+ value = JSON.parse(value);
779
+ }
780
+ catch { /* keep as string */ }
781
+ const isArrayField = !key.includes('.') && arrayFields.has(key);
782
+ if (isArrayField && !Array.isArray(value)) {
783
+ if (typeof value === 'string') {
784
+ value = value.split(',').map(v => v.trim()).filter(v => v.length > 0);
785
+ }
786
+ else if (value !== undefined && value !== null) {
787
+ value = [value];
788
+ }
789
+ }
790
+ const keys = key.split('.');
791
+ if (keys.some(k => k === '__proto__' || k === 'constructor' || k === 'prototype')) {
792
+ this.error(`Invalid --set key: ${key}`);
793
+ }
794
+ let obj = item;
795
+ for (let i = 0; i < keys.length - 1; i++) {
796
+ if (!(keys[i] in obj))
797
+ obj[keys[i]] = {};
798
+ obj = obj[keys[i]];
799
+ }
800
+ obj[keys[keys.length - 1]] = value;
801
+ }
802
+ items = [item];
803
+ }
804
+ else {
805
+ this.error(`Import requires --file, --data, or --set flags`);
806
+ }
807
+ const createEndpoint = ENDPOINTS[`create-${suffix}`];
808
+ const updateEndpoint = ENDPOINTS[`update-${suffix}`];
809
+ if (!createEndpoint || !updateEndpoint) {
810
+ this.error(`Import requires both create-${suffix} and update-${suffix} endpoints`);
811
+ }
812
+ // Build the base path with parent ID (first path param)
813
+ const firstParam = createEndpoint.pathParams[0];
814
+ const basePath = createEndpoint.path.replace(`{${firstParam}}`, parentId);
815
+ // Helper to extract identifier from item
816
+ const getItemIdentifier = (item) => {
817
+ if (item.id)
818
+ return String(item.id);
819
+ if (item.itemId)
820
+ return String(item.itemId);
821
+ if (item.code)
822
+ return String(item.code);
823
+ for (const key of Object.keys(item)) {
824
+ if (key.endsWith('Id') && item[key])
825
+ return String(item[key]);
826
+ if (key.endsWith('Code') && item[key])
827
+ return String(item[key]);
828
+ }
829
+ return '';
830
+ };
831
+ const results = [];
832
+ const stdoutHandle = process.stdout._handle;
833
+ if (stdoutHandle?.setBlocking)
834
+ stdoutHandle.setBlocking(true);
835
+ const outputFormat = flags.output || 'table';
836
+ const useCsv = outputFormat === 'csv' || Boolean(flags.csv);
837
+ const useJson = outputFormat === 'json';
838
+ const statusWidth = 8;
839
+ const identifierWidth = Math.max(10, ...items.map(i => getItemIdentifier(i).length));
840
+ if (useCsv) {
841
+ this.log('status,identifier,id,error');
842
+ }
843
+ else if (!useJson) {
844
+ this.log(`${'status'.padEnd(statusWidth)} ${'identifier'.padEnd(identifierWidth)} error`);
845
+ this.log(`${'-'.repeat(statusWidth)} ${'-'.repeat(identifierWidth)} ${'-'.repeat(50)}`);
846
+ }
847
+ const emitRow = (r) => {
848
+ if (useJson)
849
+ return;
850
+ if (useCsv) {
851
+ const err = (r.error || '').replace(/"/g, '""');
852
+ this.log(`${r.status},${r.identifier || ''},${r.id || ''},"${err}"`);
853
+ }
854
+ else {
855
+ this.log(`${r.status.padEnd(statusWidth)} ${(r.identifier || '').padEnd(identifierWidth)} ${r.error || ''}`);
856
+ }
857
+ };
858
+ const STRIP_FIELDS = new Set(['id', '_type', 'tenantId', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy']);
859
+ const stripServerFields = (item) => {
860
+ const out = {};
861
+ for (const [k, v] of Object.entries(item)) {
862
+ if (!STRIP_FIELDS.has(k))
863
+ out[k] = v;
864
+ }
865
+ return out;
866
+ };
867
+ const buildUpdatePath = (existingId) => {
868
+ const firstUpdateParam = updateEndpoint.pathParams[0];
869
+ const nestedIdParam = updateEndpoint.pathParams.find((p, i) => i > 0);
870
+ let updatePath = updateEndpoint.path.replace(`{${firstUpdateParam}}`, parentId);
871
+ if (nestedIdParam) {
872
+ updatePath = updatePath.replace(`{${nestedIdParam}}`, existingId);
873
+ }
874
+ return updatePath;
875
+ };
876
+ const processItem = async (item) => {
877
+ const identifier = getItemIdentifier(item);
878
+ const body = stripServerFields(item);
879
+ const itemId = typeof item.id === 'string' ? item.id : undefined;
880
+ if (itemId) {
881
+ try {
882
+ await apiRequest('PATCH', buildUpdatePath(itemId), {
883
+ data: body,
884
+ contextName: flags.context,
885
+ });
886
+ return { status: 'updated', id: itemId, identifier };
887
+ }
888
+ catch (error) {
889
+ if (!(error instanceof ApiError && error.status === 404)) {
890
+ const msg = error instanceof ApiError ? `(${error.status}) ${error.message}` : String(error);
891
+ return { status: 'error', id: itemId, identifier, error: msg };
892
+ }
893
+ // 404 → fall through and try create
894
+ }
895
+ }
896
+ try {
897
+ const response = await apiRequest('POST', basePath, {
898
+ data: body,
899
+ contextName: flags.context,
900
+ });
901
+ const id = response.data?.id;
902
+ return { status: 'created', id: id ? String(id) : undefined, identifier };
903
+ }
904
+ catch (error) {
905
+ if (error instanceof ApiError && error.status === 409) {
906
+ const errorData = error.data;
907
+ const existingId = errorData?.existingId || item.id;
908
+ if (existingId) {
909
+ try {
910
+ await apiRequest('PATCH', buildUpdatePath(existingId), {
911
+ data: body,
912
+ contextName: flags.context,
913
+ });
914
+ return { status: 'updated', id: existingId, identifier };
915
+ }
916
+ catch (updateError) {
917
+ const msg = updateError instanceof ApiError ? updateError.message : String(updateError);
918
+ return { status: 'error', id: existingId, identifier, error: `Update failed: ${msg}` };
919
+ }
920
+ }
921
+ return { status: 'error', identifier, error: 'Conflict but no existingId returned' };
922
+ }
923
+ const msg = error instanceof ApiError ? `(${error.status}) ${error.message}` : String(error);
924
+ return { status: 'error', identifier, error: msg };
925
+ }
926
+ };
927
+ // Worker pool: 4 requests in flight at a time. Rows are emitted as each
928
+ // request resolves so output order may differ from input order.
929
+ const concurrency = 4;
930
+ const queue = [...items];
931
+ await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
932
+ for (;;) {
933
+ const item = queue.shift();
934
+ if (!item)
935
+ return;
936
+ const r = await processItem(item);
937
+ results.push(r);
938
+ emitRow(r);
939
+ }
940
+ }));
941
+ // Summary counts
942
+ const created = results.filter(r => r.status === 'created').length;
943
+ const updated = results.filter(r => r.status === 'updated').length;
944
+ const errors = results.filter(r => r.status === 'error');
945
+ if (useJson) {
946
+ this.log(JSON.stringify(results, null, 2));
947
+ }
948
+ this.log('');
949
+ this.log(`Summary: ${created} created, ${updated} updated, ${errors.length} failed`);
950
+ }
951
+ parseFilterValue(value) {
952
+ // Recognises the nestjs-paginate operator set (with or without the leading $)
953
+ // and recursively unwraps chained operators like 'not:null' -> '$not:$null'
954
+ // or 'not:like:John' -> '$not:$like:John'. Falls back to '$eq:<value>' when
955
+ // no operator prefix is present.
956
+ const opRe = /^\$?(and|or|not|eq|gt|gte|in|null|lt|lte|btw|ne|ilike|like|sw|contains)(?::|$)/;
957
+ const m = value.match(opRe);
958
+ if (!m)
959
+ return `\$eq:${value}`;
960
+ const op = m[1];
961
+ const rest = value.slice(m[0].length);
962
+ if (rest === '')
963
+ return `\$${op}`;
964
+ return opRe.test(rest) ? `\$${op}:${this.parseFilterValue(rest)}` : `\$${op}:${rest}`;
965
+ }
966
+ filterFields(data, fields) {
967
+ const isUnsafeKey = (k) => k === '__proto__' || k === 'constructor' || k === 'prototype';
968
+ const pick = (obj, keys) => {
969
+ // Group keys by their first segment so sibling sub-paths
970
+ // (e.g. 'items.amount' + 'items._type') resolve in one recursive pass
971
+ // and don't clobber each other on merge.
972
+ const directKeys = new Set();
973
+ const nestedKeys = new Map();
974
+ for (const key of keys) {
975
+ if (key.split('.').some(isUnsafeKey))
976
+ continue;
977
+ if (key.includes('.')) {
978
+ const [first, ...rest] = key.split('.');
979
+ if (isUnsafeKey(first))
980
+ continue;
981
+ const list = nestedKeys.get(first);
982
+ if (list) {
983
+ list.push(rest.join('.'));
984
+ }
985
+ else {
986
+ nestedKeys.set(first, [rest.join('.')]);
987
+ }
988
+ }
989
+ else {
990
+ directKeys.add(key);
991
+ }
992
+ }
993
+ const result = {};
994
+ for (const key of directKeys) {
995
+ if (key in obj)
996
+ result[key] = obj[key];
997
+ }
998
+ for (const [first, restKeys] of nestedKeys) {
999
+ const val = obj[first];
1000
+ if (!val || typeof val !== 'object')
1001
+ continue;
1002
+ if (Array.isArray(val)) {
1003
+ result[first] = val.map(item => pick(item, restKeys));
1004
+ }
1005
+ else {
1006
+ result[first] = pick(val, restKeys);
1007
+ }
1008
+ }
1009
+ return result;
1010
+ };
1011
+ if (Array.isArray(data)) {
1012
+ return data.map(item => pick(item, fields));
1013
+ }
1014
+ return pick(data, fields);
1015
+ }
1016
+ sanitizeOutput(data) {
1017
+ const auditFields = ['_type', 'tenantId', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'];
1018
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1019
+ const sanitize = (obj, parentUuids = new Set()) => {
1020
+ if (obj === null || obj === undefined)
1021
+ return undefined;
1022
+ if (Array.isArray(obj)) {
1023
+ const result = obj.map(item => sanitize(item, new Set(parentUuids))).filter(item => item !== undefined);
1024
+ return result.length > 0 ? result : undefined;
1025
+ }
1026
+ if (typeof obj === 'object') {
1027
+ const input = obj;
1028
+ // Use a Map to avoid any prototype-pollution risk on the result object
1029
+ const collected = new Map();
1030
+ // Collect UUIDs from this level to pass to children
1031
+ const currentUuids = new Set(parentUuids);
1032
+ for (const [, value] of Object.entries(input)) {
1033
+ if (typeof value === 'string' && uuidRegex.test(value)) {
1034
+ currentUuids.add(value);
1035
+ }
1036
+ }
1037
+ for (const [key, value] of Object.entries(input)) {
1038
+ // Skip prototype-pollution keys
1039
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype')
1040
+ continue;
1041
+ // Skip audit fields
1042
+ if (auditFields.includes(key))
1043
+ continue;
1044
+ // Skip null/undefined values
1045
+ if (value === null || value === undefined)
1046
+ continue;
1047
+ // Skip UUID values that exist in parent
1048
+ if (typeof value === 'string' && uuidRegex.test(value) && parentUuids.has(value))
1049
+ continue;
1050
+ // Recursively sanitize nested objects/arrays
1051
+ const sanitized = sanitize(value, currentUuids);
1052
+ if (sanitized !== undefined) {
1053
+ collected.set(key, sanitized);
1054
+ }
1055
+ }
1056
+ if (collected.size === 0)
1057
+ return undefined;
1058
+ return Object.fromEntries(collected);
1059
+ }
1060
+ return obj;
1061
+ };
1062
+ return sanitize(data) ?? (Array.isArray(data) ? [] : {});
1063
+ }
1064
+ getColumns(data, fields, flatten = false) {
1065
+ const items = Array.isArray(data) ? data : [data];
1066
+ if (items.length === 0)
1067
+ return { cols: [], items: [] };
1068
+ const first = items[0];
1069
+ const hiddenFields = ['tenantId', '_type'];
1070
+ if (fields) {
1071
+ return { cols: fields, items: items };
1072
+ }
1073
+ // Recursively collect all column paths, flattening objects (but not arrays)
1074
+ const collectPaths = (obj, prefix = '') => {
1075
+ const paths = [];
1076
+ for (const key of Object.keys(obj)) {
1077
+ const fullKey = prefix ? `${prefix}.${key}` : key;
1078
+ if (hiddenFields.includes(key))
1079
+ continue;
1080
+ const val = obj[key];
1081
+ if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
1082
+ // Flatten nested objects (but not arrays)
1083
+ if (flatten) {
1084
+ paths.push(...collectPaths(val, fullKey));
1085
+ }
1086
+ // Skip objects when not flattening
1087
+ }
1088
+ else if (!Array.isArray(val)) {
1089
+ // Include primitives (and null)
1090
+ paths.push(fullKey);
1091
+ }
1092
+ // Skip arrays entirely
1093
+ }
1094
+ return paths;
1095
+ };
1096
+ // Scan multiple items to discover all columns (handles fields absent/null in first item)
1097
+ const cols = collectPaths(first);
1098
+ const colSet = new Set(cols);
1099
+ const scanCount = Math.min(items.length, 50);
1100
+ for (let i = 1; i < scanCount; i++) {
1101
+ for (const p of collectPaths(items[i])) {
1102
+ if (!colSet.has(p)) {
1103
+ cols.push(p);
1104
+ colSet.add(p);
1105
+ }
1106
+ }
1107
+ }
1108
+ return { cols: flatten ? cols : cols.slice(0, 8), items: items };
1109
+ }
1110
+ getValue(row, col) {
1111
+ if (!col.includes('.'))
1112
+ return String(row[col] ?? '');
1113
+ const parts = col.split('.');
1114
+ // Walk the dot-path; descending through an array branches into every
1115
+ // element so leaf values get collected from each one. Multiple leaves are
1116
+ // joined with ';' (e.g. dunningReminders.id -> "uuid1;uuid2").
1117
+ const walk = (o, idx) => {
1118
+ if (idx >= parts.length)
1119
+ return [o];
1120
+ if (o == null)
1121
+ return [];
1122
+ if (Array.isArray(o))
1123
+ return o.flatMap(item => walk(item, idx));
1124
+ if (typeof o === 'object')
1125
+ return walk(o[parts[idx]], idx + 1);
1126
+ return [];
1127
+ };
1128
+ return walk(row, 0).filter(v => v !== undefined && v !== null).map(v => String(v)).join(';');
1129
+ }
1130
+ printTable(data, fields) {
1131
+ const { cols, items } = this.getColumns(data, fields);
1132
+ if (items.length === 0) {
1133
+ this.log('No results');
1134
+ return;
1135
+ }
1136
+ // Calculate column widths
1137
+ const widths = cols.map(c => c.length);
1138
+ for (const item of items) {
1139
+ cols.forEach((col, i) => {
1140
+ const val = this.getValue(item, col).slice(0, 40);
1141
+ widths[i] = Math.max(widths[i], val.length);
1142
+ });
1143
+ }
1144
+ // Print header
1145
+ const header = cols.map((c, i) => c.padEnd(widths[i])).join(' ');
1146
+ this.log(header);
1147
+ this.log(widths.map(w => '-'.repeat(w)).join(' '));
1148
+ // Print rows
1149
+ for (const item of items) {
1150
+ const row = cols.map((col, i) => {
1151
+ const val = this.getValue(item, col).slice(0, 40);
1152
+ return val.padEnd(widths[i]);
1153
+ }).join(' ');
1154
+ this.log(row);
1155
+ }
1156
+ }
1157
+ printCsv(data, fields) {
1158
+ const { cols, items } = this.getColumns(data, fields, true); // flatten=true for CSV
1159
+ if (items.length === 0) {
1160
+ this.log('No results');
1161
+ return;
1162
+ }
1163
+ const escape = (val) => {
1164
+ if (val.includes(',') || val.includes('"') || val.includes('\n')) {
1165
+ return '"' + val.replace(/"/g, '""') + '"';
1166
+ }
1167
+ return val;
1168
+ };
1169
+ // Print header
1170
+ this.log(cols.map(escape).join(','));
1171
+ // Print rows
1172
+ for (const item of items) {
1173
+ const row = cols.map(col => escape(this.getValue(item, col)));
1174
+ this.log(row.join(','));
1175
+ }
1176
+ }
1177
+ }