@aderis-erp/cli 1.0.15 → 1.1.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 (182) hide show
  1. package/README.md +30 -8
  2. package/dist/commands/access-groups.d.ts +0 -1
  3. package/dist/commands/access-groups.js +54 -78
  4. package/dist/commands/account-transaction-allocations.d.ts +0 -1
  5. package/dist/commands/account-transaction-allocations.js +51 -75
  6. package/dist/commands/account-transactions.d.ts +0 -1
  7. package/dist/commands/account-transactions.js +80 -94
  8. package/dist/commands/accounts.d.ts +0 -1
  9. package/dist/commands/accounts.js +59 -83
  10. package/dist/commands/agent-platform.d.ts +0 -1
  11. package/dist/commands/agent-platform.js +58 -82
  12. package/dist/commands/agent-runs.d.ts +39 -0
  13. package/dist/commands/agent-runs.js +1155 -0
  14. package/dist/commands/analytics.d.ts +0 -1
  15. package/dist/commands/analytics.js +52 -76
  16. package/dist/commands/{text-templates.d.ts → api-keys.d.ts} +1 -2
  17. package/dist/commands/api-keys.js +1150 -0
  18. package/dist/commands/async-jobs.d.ts +39 -0
  19. package/dist/commands/async-jobs.js +1158 -0
  20. package/dist/commands/audit-log.d.ts +0 -1
  21. package/dist/commands/audit-log.js +51 -75
  22. package/dist/commands/auth/login.d.ts +9 -0
  23. package/dist/commands/auth/login.js +73 -20
  24. package/dist/commands/auth/logout.d.ts +4 -0
  25. package/dist/commands/auth/logout.js +4 -0
  26. package/dist/commands/auth/status.js +26 -17
  27. package/dist/commands/auth/tenant.js +3 -6
  28. package/dist/commands/auth.d.ts +0 -1
  29. package/dist/commands/auth.js +5 -34
  30. package/dist/commands/bank-accounts.d.ts +0 -1
  31. package/dist/commands/bank-accounts.js +68 -91
  32. package/dist/commands/bank-integrations.d.ts +1 -1
  33. package/dist/commands/bank-integrations.js +71 -92
  34. package/dist/commands/business-partners.d.ts +1 -1
  35. package/dist/commands/business-partners.js +84 -100
  36. package/dist/commands/chat.d.ts +0 -1
  37. package/dist/commands/chat.js +57 -80
  38. package/dist/commands/config/add.d.ts +3 -0
  39. package/dist/commands/config/add.js +34 -16
  40. package/dist/commands/config/current.js +3 -0
  41. package/dist/commands/config/list.js +3 -0
  42. package/dist/commands/credit-card-accounts.d.ts +1 -1
  43. package/dist/commands/credit-card-accounts.js +72 -90
  44. package/dist/commands/delivery-notes.d.ts +0 -1
  45. package/dist/commands/delivery-notes.js +83 -105
  46. package/dist/commands/dunning-configs.d.ts +0 -1
  47. package/dist/commands/dunning-configs.js +54 -78
  48. package/dist/commands/dunning-reminders.d.ts +0 -1
  49. package/dist/commands/dunning-reminders.js +57 -81
  50. package/dist/commands/dunning-runs.d.ts +0 -1
  51. package/dist/commands/dunning-runs.js +50 -74
  52. package/dist/commands/email-transactions.d.ts +0 -1
  53. package/dist/commands/email-transactions.js +52 -76
  54. package/dist/commands/employees.d.ts +0 -1
  55. package/dist/commands/employees.js +81 -105
  56. package/dist/commands/entity-configurations.d.ts +0 -1
  57. package/dist/commands/entity-configurations.js +52 -76
  58. package/dist/commands/event-processor-mappings.d.ts +0 -1
  59. package/dist/commands/event-processor-mappings.js +54 -78
  60. package/dist/commands/files.d.ts +0 -1
  61. package/dist/commands/files.js +61 -85
  62. package/dist/commands/health.d.ts +0 -1
  63. package/dist/commands/health.js +54 -78
  64. package/dist/commands/imports.d.ts +39 -0
  65. package/dist/commands/imports.js +1151 -0
  66. package/dist/commands/inbox.d.ts +0 -1
  67. package/dist/commands/inbox.js +57 -80
  68. package/dist/commands/integration-identities.d.ts +40 -0
  69. package/dist/commands/integration-identities.js +1151 -0
  70. package/dist/commands/integrations.d.ts +0 -1
  71. package/dist/commands/integrations.js +52 -76
  72. package/dist/commands/item-categories.d.ts +0 -1
  73. package/dist/commands/item-categories.js +54 -78
  74. package/dist/commands/items.d.ts +0 -1
  75. package/dist/commands/items.js +54 -78
  76. package/dist/commands/journal-lines.d.ts +0 -1
  77. package/dist/commands/journal-lines.js +50 -74
  78. package/dist/commands/journals.d.ts +1 -1
  79. package/dist/commands/journals.js +75 -96
  80. package/dist/commands/ledgers.d.ts +3 -2
  81. package/dist/commands/ledgers.js +100 -95
  82. package/dist/commands/my-account.d.ts +39 -0
  83. package/dist/commands/my-account.js +1160 -0
  84. package/dist/commands/office-locations.d.ts +0 -1
  85. package/dist/commands/office-locations.js +54 -78
  86. package/dist/commands/payment-methods.d.ts +0 -1
  87. package/dist/commands/payment-methods.js +59 -83
  88. package/dist/commands/payment-runs.d.ts +0 -1
  89. package/dist/commands/payment-runs.js +66 -90
  90. package/dist/commands/payment-terms.d.ts +0 -1
  91. package/dist/commands/payment-terms.js +55 -79
  92. package/dist/commands/payments.d.ts +0 -1
  93. package/dist/commands/payments.js +56 -80
  94. package/dist/commands/payroll-runs.d.ts +43 -0
  95. package/dist/commands/payroll-runs.js +1188 -0
  96. package/dist/commands/permissions.d.ts +39 -0
  97. package/dist/commands/permissions.js +1146 -0
  98. package/dist/commands/price-lists.d.ts +0 -1
  99. package/dist/commands/price-lists.js +59 -83
  100. package/dist/commands/processor-mappings.d.ts +0 -1
  101. package/dist/commands/processor-mappings.js +53 -77
  102. package/dist/commands/processors.d.ts +0 -1
  103. package/dist/commands/processors.js +60 -85
  104. package/dist/commands/purchase-order-types.d.ts +0 -1
  105. package/dist/commands/purchase-order-types.js +54 -78
  106. package/dist/commands/purchase-orders.d.ts +0 -1
  107. package/dist/commands/purchase-orders.js +74 -97
  108. package/dist/commands/reports.d.ts +0 -1
  109. package/dist/commands/reports.js +54 -78
  110. package/dist/commands/roles.d.ts +39 -0
  111. package/dist/commands/{text-templates.js → roles.js} +64 -90
  112. package/dist/commands/salary-types.d.ts +0 -1
  113. package/dist/commands/salary-types.js +54 -78
  114. package/dist/commands/sales-invoices.d.ts +0 -1
  115. package/dist/commands/sales-invoices.js +90 -110
  116. package/dist/commands/sales-orders.d.ts +0 -1
  117. package/dist/commands/sales-orders.js +83 -105
  118. package/dist/commands/saved-views.d.ts +0 -1
  119. package/dist/commands/saved-views.js +52 -76
  120. package/dist/commands/scheduled-processor-mappings.d.ts +0 -1
  121. package/dist/commands/scheduled-processor-mappings.js +54 -78
  122. package/dist/commands/schedules.d.ts +0 -1
  123. package/dist/commands/schedules.js +58 -82
  124. package/dist/commands/switch.js +4 -4
  125. package/dist/commands/tag-configurations.d.ts +0 -1
  126. package/dist/commands/tag-configurations.js +56 -79
  127. package/dist/commands/tags.d.ts +0 -1
  128. package/dist/commands/tags.js +54 -78
  129. package/dist/commands/tax-codes.d.ts +0 -1
  130. package/dist/commands/tax-codes.js +57 -81
  131. package/dist/commands/tax-matrix.d.ts +0 -1
  132. package/dist/commands/tax-matrix.js +54 -78
  133. package/dist/commands/templates.d.ts +39 -0
  134. package/dist/commands/templates.js +1157 -0
  135. package/dist/commands/tenants.d.ts +0 -1
  136. package/dist/commands/tenants.js +58 -81
  137. package/dist/commands/tools.d.ts +0 -1
  138. package/dist/commands/tools.js +51 -75
  139. package/dist/commands/users.d.ts +42 -0
  140. package/dist/commands/users.js +1174 -0
  141. package/dist/commands/vat-declarations.d.ts +3 -1
  142. package/dist/commands/vat-declarations.js +78 -93
  143. package/dist/commands/vendor-invoices.d.ts +0 -1
  144. package/dist/commands/vendor-invoices.js +87 -110
  145. package/dist/commands/webhooks.d.ts +0 -1
  146. package/dist/commands/webhooks.js +56 -80
  147. package/dist/commands/workflows.d.ts +0 -1
  148. package/dist/commands/workflows.js +52 -76
  149. package/dist/lib/api-client.d.ts +6 -0
  150. package/dist/lib/api-client.js +16 -6
  151. package/dist/lib/auth/constants.d.ts +11 -0
  152. package/dist/lib/auth/constants.js +11 -0
  153. package/dist/lib/auth/errors.d.ts +17 -0
  154. package/dist/lib/auth/errors.js +22 -0
  155. package/dist/lib/auth/flow.d.ts +27 -0
  156. package/dist/lib/auth/flow.js +163 -0
  157. package/dist/lib/auth/guards.d.ts +5 -0
  158. package/dist/lib/auth/guards.js +14 -0
  159. package/dist/lib/auth/http.d.ts +16 -0
  160. package/dist/lib/auth/http.js +49 -0
  161. package/dist/lib/auth/index.d.ts +49 -0
  162. package/dist/lib/auth/index.js +228 -0
  163. package/dist/lib/auth/jwks.d.ts +19 -0
  164. package/dist/lib/auth/jwks.js +112 -0
  165. package/dist/lib/auth/keycloak-provider.d.ts +91 -0
  166. package/dist/lib/auth/keycloak-provider.js +244 -0
  167. package/dist/lib/auth/pages.d.ts +25 -0
  168. package/dist/lib/auth/pages.js +123 -0
  169. package/dist/lib/auth/pkce.d.ts +11 -0
  170. package/dist/lib/auth/pkce.js +17 -0
  171. package/dist/lib/auth/tokens.d.ts +59 -0
  172. package/dist/lib/auth/tokens.js +105 -0
  173. package/dist/lib/auth/types.d.ts +61 -0
  174. package/dist/lib/auth/types.js +1 -0
  175. package/dist/lib/auth/validated-url.d.ts +6 -0
  176. package/dist/lib/auth/validated-url.js +53 -0
  177. package/dist/lib/config.d.ts +13 -3
  178. package/dist/lib/config.js +24 -19
  179. package/oclif.manifest.json +2447 -641
  180. package/package.json +5 -4
  181. package/dist/lib/auth.d.ts +0 -43
  182. package/dist/lib/auth.js +0 -610
@@ -1,5 +1,5 @@
1
1
  import { Command, Args, Flags } from '@oclif/core';
2
- import { apiRequest, ApiError, normalizeSortBy } from '../lib/api-client.js';
2
+ import { apiRequest, ApiError, isTextualMediaType, normalizeSortBy } from '../lib/api-client.js';
3
3
  import { getCurrentContext, getContext, getBaseUrls, hasCredentials, missingCredentialsMessage } from '../lib/config.js';
4
4
  // Add 'import' action if both create and update exist
5
5
  const HAS_IMPORT = false;
@@ -8,14 +8,14 @@ const HAS_CAMT053 = false;
8
8
  const NESTED_IMPORTS = [];
9
9
  const SUBCOMMANDS = [...["pending", "ongoing", "cancelled", "get", "update", "list", "approve", "cancel"], ...(HAS_IMPORT ? ['import'] : []), ...(HAS_EXPORT ? ['export'] : []), ...NESTED_IMPORTS.map(n => `import-${n}`)];
10
10
  const ENDPOINTS = {
11
- 'pending': { method: 'GET', path: '/v1/dunning-reminders/pending', summary: 'Get pending reminders (awaiting approval) with pagination', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
12
- 'ongoing': { method: 'GET', path: '/v1/dunning-reminders/ongoing', summary: 'Get ongoing reminders (pending, sent, or failed) with pagination', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
13
- 'cancelled': { method: 'GET', path: '/v1/dunning-reminders/cancelled', summary: 'Get cancelled reminders with pagination', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
14
- 'get': { method: 'GET', path: '/v1/dunning-reminders/{id}', summary: 'Get a single dunning reminder by ID', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
15
- 'update': { method: 'PATCH', path: '/v1/dunning-reminders/{id}', summary: 'Update recipient or action on a dunning reminder', pathParams: ['id'], hasBody: true, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
16
- 'list': { method: 'GET', path: '/v1/dunning-reminders', summary: 'Get all dunning reminders with pagination', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
17
- 'approve': { method: 'POST', path: '/v1/dunning-reminders/{reminderId}/approve', summary: 'Approve a dunning reminder for sending', pathParams: ['reminderId'], hasBody: true, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
18
- 'cancel': { method: 'POST', path: '/v1/dunning-reminders/{reminderId}/cancel', summary: 'Cancel a dunning reminder', pathParams: ['reminderId'], hasBody: true, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
11
+ 'pending': { method: 'GET', path: '/v1/dunning-reminders/pending', summary: 'Get pending reminders (awaiting approval) with pagination', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
12
+ 'ongoing': { method: 'GET', path: '/v1/dunning-reminders/ongoing', summary: 'Get ongoing reminders (pending, sent, or failed) with pagination', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
13
+ 'cancelled': { method: 'GET', path: '/v1/dunning-reminders/cancelled', summary: 'Get cancelled reminders with pagination', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
14
+ 'get': { method: 'GET', path: '/v1/dunning-reminders/{id}', summary: 'Get a single dunning reminder by ID', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
15
+ 'update': { method: 'PATCH', path: '/v1/dunning-reminders/{id}', summary: 'Update recipient or action on a dunning reminder', pathParams: ['id'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
16
+ 'list': { method: 'GET', path: '/v1/dunning-reminders', summary: 'Get all dunning reminders with pagination', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
17
+ 'approve': { method: 'POST', path: '/v1/dunning-reminders/{reminderId}/approve', summary: 'Approve a dunning reminder for sending', pathParams: ['reminderId'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
18
+ 'cancel': { method: 'POST', path: '/v1/dunning-reminders/{reminderId}/cancel', summary: 'Cancel a dunning reminder', pathParams: ['reminderId'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
19
19
  };
20
20
  export default class DunningReminders extends Command {
21
21
  static description = 'Manage dunning reminders';
@@ -44,7 +44,7 @@ export default class DunningReminders extends Command {
44
44
  'sort-by': Flags.string({ description: 'Sort field(s), comma-separated. Direction (:ASC/:DESC) is optional; date-like fields default to DESC, others to ASC.' }),
45
45
  filter: Flags.string({ description: 'Filter (e.g. --filter status=ACTIVE). Operators: in:A,B, like:%, gt:, lt:, null:', multiple: true }),
46
46
  query: Flags.string({ description: 'Additional query params as JSON' }),
47
- fields: Flags.string({ description: 'Comma-separated list of fields to show (e.g. --fields=id,name,status)' }),
47
+ fields: Flags.string({ description: 'Comma-separated list of fields to return, applied by the API (e.g. --fields=id,name,status)' }),
48
48
  sanitize: Flags.boolean({ description: 'Remove null values, audit fields, and duplicate UUIDs from output' }),
49
49
  'show-http-request': Flags.boolean({ description: 'Show HTTP request details (method, URL, body)' }),
50
50
  };
@@ -116,6 +116,8 @@ export default class DunningReminders extends Command {
116
116
  queryParams.search = flags.search;
117
117
  if (flags['sort-by'])
118
118
  queryParams.sortBy = normalizeSortBy(flags['sort-by']);
119
+ if (flags.fields)
120
+ queryParams.fields = flags.fields;
119
121
  if (flags.query) {
120
122
  try {
121
123
  Object.assign(queryParams, JSON.parse(flags.query));
@@ -263,10 +265,11 @@ export default class DunningReminders extends Command {
263
265
  process.stderr.write('\n');
264
266
  }
265
267
  try {
266
- // Binary response: download to file
268
+ // Binary response: to -f / --file, or to stdout when it is redirected.
267
269
  if (endpoint.isBinaryResponse) {
268
- if (!flags.file) {
269
- this.error(`Endpoint returns a binary file. Specify the output path with -f / --file (e.g. -f report.xlsx).`);
270
+ const toStdout = !flags.file;
271
+ if (toStdout && process.stdout.isTTY) {
272
+ this.error(`Endpoint returns a binary file. Specify the output path with -f / --file (e.g. -f report.xlsx), or redirect stdout to a file.`);
270
273
  }
271
274
  const binResponse = await apiRequest(endpoint.method, apiPath, {
272
275
  params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
@@ -274,24 +277,55 @@ export default class DunningReminders extends Command {
274
277
  contextName: flags.context,
275
278
  binary: true,
276
279
  });
277
- const fsModule = await import('node:fs');
278
280
  const binData = binResponse.data;
279
281
  const buffer = Buffer.isBuffer(binData)
280
282
  ? binData
281
283
  : Buffer.from(binData);
284
+ if (toStdout) {
285
+ // Nothing else may reach stdout here — it is the file.
286
+ process.stdout.write(buffer);
287
+ return;
288
+ }
289
+ const fsModule = await import('node:fs');
282
290
  fsModule.writeFileSync(flags.file, buffer);
283
291
  this.log(`Saved ${buffer.length} bytes to ${flags.file}`);
284
292
  return;
285
293
  }
286
- const response = await apiRequest(endpoint.method, apiPath, {
287
- params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
288
- data,
289
- contextName: flags.context,
290
- acceptAny: true,
291
- });
294
+ let response;
295
+ if (endpoint.hasBinaryVariant) {
296
+ // This route answers JSON or a file depending on the request body, so the
297
+ // bytes are read first and the response's own content-type decides. Asking
298
+ // for text here would corrupt an xlsx/pdf before anything could inspect it.
299
+ const raw = await apiRequest(endpoint.method, apiPath, {
300
+ params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
301
+ data,
302
+ contextName: flags.context,
303
+ binary: true,
304
+ });
305
+ const rawType = String(raw.headers['content-type'] || '').toLowerCase();
306
+ const rawData = raw.data;
307
+ const buffer = Buffer.isBuffer(rawData) ? rawData : Buffer.from(rawData);
308
+ if (!isTextualMediaType(rawType)) {
309
+ if (process.stdout.isTTY) {
310
+ this.error(`The API returned ${rawType || 'binary data'}. Redirect stdout to a file, e.g. ... > report.xlsx`);
311
+ }
312
+ // Nothing else may reach stdout here — it is the file.
313
+ process.stdout.write(buffer);
314
+ return;
315
+ }
316
+ response = { data: buffer.toString('utf8'), headers: raw.headers, status: raw.status };
317
+ }
318
+ else {
319
+ response = await apiRequest(endpoint.method, apiPath, {
320
+ params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
321
+ data,
322
+ contextName: flags.context,
323
+ acceptAny: true,
324
+ });
325
+ }
292
326
  // Check content-type for non-JSON responses (XML, text, etc.)
293
327
  const contentType = (response.headers['content-type'] || '').toLowerCase();
294
- if (contentType.includes('xml') || contentType.includes('text/')) {
328
+ if (isTextualMediaType(contentType) && !contentType.startsWith('application/json')) {
295
329
  // Output raw content for XML/text responses (CSV, plain, HTML, etc.)
296
330
  this.log(String(response.data));
297
331
  return;
@@ -318,11 +352,6 @@ export default class DunningReminders extends Command {
318
352
  output = obj[arrayKeys[0]];
319
353
  }
320
354
  }
321
- // Filter fields if --fields flag is provided
322
- if (flags.fields) {
323
- const fieldList = flags.fields.split(',').map(f => f.trim());
324
- output = this.filterFields(output, fieldList);
325
- }
326
355
  // Sanitize output if requested
327
356
  if (flags.sanitize) {
328
357
  output = this.sanitizeOutput(output);
@@ -577,6 +606,8 @@ export default class DunningReminders extends Command {
577
606
  baseParams.sortBy = normalizeSortBy(flags['sort-by']);
578
607
  if (flags.include)
579
608
  baseParams.include = flags.include;
609
+ if (flags.fields)
610
+ baseParams.fields = flags.fields;
580
611
  if (flags.query) {
581
612
  try {
582
613
  Object.assign(baseParams, JSON.parse(flags.query));
@@ -631,12 +662,7 @@ export default class DunningReminders extends Command {
631
662
  }
632
663
  }
633
664
  process.stderr.write(' done\n');
634
- // Filter fields if specified
635
665
  let output = allItems;
636
- if (flags.fields) {
637
- const fieldList = flags.fields.split(',').map(f => f.trim());
638
- output = this.filterFields(allItems, fieldList);
639
- }
640
666
  // Sanitize output if requested
641
667
  if (flags.sanitize) {
642
668
  output = this.sanitizeOutput(output);
@@ -965,56 +991,6 @@ export default class DunningReminders extends Command {
965
991
  return `\$${op}`;
966
992
  return opRe.test(rest) ? `\$${op}:${this.parseFilterValue(rest)}` : `\$${op}:${rest}`;
967
993
  }
968
- filterFields(data, fields) {
969
- const isUnsafeKey = (k) => k === '__proto__' || k === 'constructor' || k === 'prototype';
970
- const pick = (obj, keys) => {
971
- // Group keys by their first segment so sibling sub-paths
972
- // (e.g. 'items.amount' + 'items._type') resolve in one recursive pass
973
- // and don't clobber each other on merge.
974
- const directKeys = new Set();
975
- const nestedKeys = new Map();
976
- for (const key of keys) {
977
- if (key.split('.').some(isUnsafeKey))
978
- continue;
979
- if (key.includes('.')) {
980
- const [first, ...rest] = key.split('.');
981
- if (isUnsafeKey(first))
982
- continue;
983
- const list = nestedKeys.get(first);
984
- if (list) {
985
- list.push(rest.join('.'));
986
- }
987
- else {
988
- nestedKeys.set(first, [rest.join('.')]);
989
- }
990
- }
991
- else {
992
- directKeys.add(key);
993
- }
994
- }
995
- const result = {};
996
- for (const key of directKeys) {
997
- if (key in obj)
998
- result[key] = obj[key];
999
- }
1000
- for (const [first, restKeys] of nestedKeys) {
1001
- const val = obj[first];
1002
- if (!val || typeof val !== 'object')
1003
- continue;
1004
- if (Array.isArray(val)) {
1005
- result[first] = val.map(item => pick(item, restKeys));
1006
- }
1007
- else {
1008
- result[first] = pick(val, restKeys);
1009
- }
1010
- }
1011
- return result;
1012
- };
1013
- if (Array.isArray(data)) {
1014
- return data.map(item => pick(item, fields));
1015
- }
1016
- return pick(data, fields);
1017
- }
1018
994
  sanitizeOutput(data) {
1019
995
  const auditFields = ['_type', 'tenantId', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'];
1020
996
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -31,7 +31,6 @@ export default class DunningRuns extends Command {
31
31
  private runCamt053Import;
32
32
  private runNestedImport;
33
33
  private parseFilterValue;
34
- private filterFields;
35
34
  private sanitizeOutput;
36
35
  private getColumns;
37
36
  private getValue;
@@ -1,5 +1,5 @@
1
1
  import { Command, Args, Flags } from '@oclif/core';
2
- import { apiRequest, ApiError, normalizeSortBy } from '../lib/api-client.js';
2
+ import { apiRequest, ApiError, isTextualMediaType, normalizeSortBy } from '../lib/api-client.js';
3
3
  import { getCurrentContext, getContext, getBaseUrls, hasCredentials, missingCredentialsMessage } from '../lib/config.js';
4
4
  // Add 'import' action if both create and update exist
5
5
  const HAS_IMPORT = false;
@@ -8,7 +8,7 @@ const HAS_CAMT053 = false;
8
8
  const NESTED_IMPORTS = [];
9
9
  const SUBCOMMANDS = [...["trigger"], ...(HAS_IMPORT ? ['import'] : []), ...(HAS_EXPORT ? ['export'] : []), ...NESTED_IMPORTS.map(n => `import-${n}`)];
10
10
  const ENDPOINTS = {
11
- 'trigger': { method: 'POST', path: '/v1/dunning-runs/trigger', summary: 'Manually trigger a dunning run', pathParams: [], hasBody: true, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
11
+ 'trigger': { method: 'POST', path: '/v1/dunning-runs/trigger', summary: 'Manually trigger a dunning run', pathParams: [], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
12
12
  };
13
13
  export default class DunningRuns extends Command {
14
14
  static description = 'Manage dunning runs';
@@ -32,7 +32,7 @@ export default class DunningRuns extends Command {
32
32
  'sort-by': Flags.string({ description: 'Sort field(s), comma-separated. Direction (:ASC/:DESC) is optional; date-like fields default to DESC, others to ASC.' }),
33
33
  filter: Flags.string({ description: 'Filter (e.g. --filter status=ACTIVE). Operators: in:A,B, like:%, gt:, lt:, null:', multiple: true }),
34
34
  query: Flags.string({ description: 'Additional query params as JSON' }),
35
- fields: Flags.string({ description: 'Comma-separated list of fields to show (e.g. --fields=id,name,status)' }),
35
+ fields: Flags.string({ description: 'Comma-separated list of fields to return, applied by the API (e.g. --fields=id,name,status)' }),
36
36
  sanitize: Flags.boolean({ description: 'Remove null values, audit fields, and duplicate UUIDs from output' }),
37
37
  'show-http-request': Flags.boolean({ description: 'Show HTTP request details (method, URL, body)' }),
38
38
  };
@@ -104,6 +104,8 @@ export default class DunningRuns extends Command {
104
104
  queryParams.search = flags.search;
105
105
  if (flags['sort-by'])
106
106
  queryParams.sortBy = normalizeSortBy(flags['sort-by']);
107
+ if (flags.fields)
108
+ queryParams.fields = flags.fields;
107
109
  if (flags.query) {
108
110
  try {
109
111
  Object.assign(queryParams, JSON.parse(flags.query));
@@ -251,10 +253,11 @@ export default class DunningRuns extends Command {
251
253
  process.stderr.write('\n');
252
254
  }
253
255
  try {
254
- // Binary response: download to file
256
+ // Binary response: to -f / --file, or to stdout when it is redirected.
255
257
  if (endpoint.isBinaryResponse) {
256
- if (!flags.file) {
257
- this.error(`Endpoint returns a binary file. Specify the output path with -f / --file (e.g. -f report.xlsx).`);
258
+ const toStdout = !flags.file;
259
+ if (toStdout && process.stdout.isTTY) {
260
+ this.error(`Endpoint returns a binary file. Specify the output path with -f / --file (e.g. -f report.xlsx), or redirect stdout to a file.`);
258
261
  }
259
262
  const binResponse = await apiRequest(endpoint.method, apiPath, {
260
263
  params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
@@ -262,24 +265,55 @@ export default class DunningRuns extends Command {
262
265
  contextName: flags.context,
263
266
  binary: true,
264
267
  });
265
- const fsModule = await import('node:fs');
266
268
  const binData = binResponse.data;
267
269
  const buffer = Buffer.isBuffer(binData)
268
270
  ? binData
269
271
  : Buffer.from(binData);
272
+ if (toStdout) {
273
+ // Nothing else may reach stdout here — it is the file.
274
+ process.stdout.write(buffer);
275
+ return;
276
+ }
277
+ const fsModule = await import('node:fs');
270
278
  fsModule.writeFileSync(flags.file, buffer);
271
279
  this.log(`Saved ${buffer.length} bytes to ${flags.file}`);
272
280
  return;
273
281
  }
274
- const response = await apiRequest(endpoint.method, apiPath, {
275
- params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
276
- data,
277
- contextName: flags.context,
278
- acceptAny: true,
279
- });
282
+ let response;
283
+ if (endpoint.hasBinaryVariant) {
284
+ // This route answers JSON or a file depending on the request body, so the
285
+ // bytes are read first and the response's own content-type decides. Asking
286
+ // for text here would corrupt an xlsx/pdf before anything could inspect it.
287
+ const raw = await apiRequest(endpoint.method, apiPath, {
288
+ params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
289
+ data,
290
+ contextName: flags.context,
291
+ binary: true,
292
+ });
293
+ const rawType = String(raw.headers['content-type'] || '').toLowerCase();
294
+ const rawData = raw.data;
295
+ const buffer = Buffer.isBuffer(rawData) ? rawData : Buffer.from(rawData);
296
+ if (!isTextualMediaType(rawType)) {
297
+ if (process.stdout.isTTY) {
298
+ this.error(`The API returned ${rawType || 'binary data'}. Redirect stdout to a file, e.g. ... > report.xlsx`);
299
+ }
300
+ // Nothing else may reach stdout here — it is the file.
301
+ process.stdout.write(buffer);
302
+ return;
303
+ }
304
+ response = { data: buffer.toString('utf8'), headers: raw.headers, status: raw.status };
305
+ }
306
+ else {
307
+ response = await apiRequest(endpoint.method, apiPath, {
308
+ params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
309
+ data,
310
+ contextName: flags.context,
311
+ acceptAny: true,
312
+ });
313
+ }
280
314
  // Check content-type for non-JSON responses (XML, text, etc.)
281
315
  const contentType = (response.headers['content-type'] || '').toLowerCase();
282
- if (contentType.includes('xml') || contentType.includes('text/')) {
316
+ if (isTextualMediaType(contentType) && !contentType.startsWith('application/json')) {
283
317
  // Output raw content for XML/text responses (CSV, plain, HTML, etc.)
284
318
  this.log(String(response.data));
285
319
  return;
@@ -306,11 +340,6 @@ export default class DunningRuns extends Command {
306
340
  output = obj[arrayKeys[0]];
307
341
  }
308
342
  }
309
- // Filter fields if --fields flag is provided
310
- if (flags.fields) {
311
- const fieldList = flags.fields.split(',').map(f => f.trim());
312
- output = this.filterFields(output, fieldList);
313
- }
314
343
  // Sanitize output if requested
315
344
  if (flags.sanitize) {
316
345
  output = this.sanitizeOutput(output);
@@ -565,6 +594,8 @@ export default class DunningRuns extends Command {
565
594
  baseParams.sortBy = normalizeSortBy(flags['sort-by']);
566
595
  if (flags.include)
567
596
  baseParams.include = flags.include;
597
+ if (flags.fields)
598
+ baseParams.fields = flags.fields;
568
599
  if (flags.query) {
569
600
  try {
570
601
  Object.assign(baseParams, JSON.parse(flags.query));
@@ -619,12 +650,7 @@ export default class DunningRuns extends Command {
619
650
  }
620
651
  }
621
652
  process.stderr.write(' done\n');
622
- // Filter fields if specified
623
653
  let output = allItems;
624
- if (flags.fields) {
625
- const fieldList = flags.fields.split(',').map(f => f.trim());
626
- output = this.filterFields(allItems, fieldList);
627
- }
628
654
  // Sanitize output if requested
629
655
  if (flags.sanitize) {
630
656
  output = this.sanitizeOutput(output);
@@ -953,56 +979,6 @@ export default class DunningRuns extends Command {
953
979
  return `\$${op}`;
954
980
  return opRe.test(rest) ? `\$${op}:${this.parseFilterValue(rest)}` : `\$${op}:${rest}`;
955
981
  }
956
- filterFields(data, fields) {
957
- const isUnsafeKey = (k) => k === '__proto__' || k === 'constructor' || k === 'prototype';
958
- const pick = (obj, keys) => {
959
- // Group keys by their first segment so sibling sub-paths
960
- // (e.g. 'items.amount' + 'items._type') resolve in one recursive pass
961
- // and don't clobber each other on merge.
962
- const directKeys = new Set();
963
- const nestedKeys = new Map();
964
- for (const key of keys) {
965
- if (key.split('.').some(isUnsafeKey))
966
- continue;
967
- if (key.includes('.')) {
968
- const [first, ...rest] = key.split('.');
969
- if (isUnsafeKey(first))
970
- continue;
971
- const list = nestedKeys.get(first);
972
- if (list) {
973
- list.push(rest.join('.'));
974
- }
975
- else {
976
- nestedKeys.set(first, [rest.join('.')]);
977
- }
978
- }
979
- else {
980
- directKeys.add(key);
981
- }
982
- }
983
- const result = {};
984
- for (const key of directKeys) {
985
- if (key in obj)
986
- result[key] = obj[key];
987
- }
988
- for (const [first, restKeys] of nestedKeys) {
989
- const val = obj[first];
990
- if (!val || typeof val !== 'object')
991
- continue;
992
- if (Array.isArray(val)) {
993
- result[first] = val.map(item => pick(item, restKeys));
994
- }
995
- else {
996
- result[first] = pick(val, restKeys);
997
- }
998
- }
999
- return result;
1000
- };
1001
- if (Array.isArray(data)) {
1002
- return data.map(item => pick(item, fields));
1003
- }
1004
- return pick(data, fields);
1005
- }
1006
982
  sanitizeOutput(data) {
1007
983
  const auditFields = ['_type', 'tenantId', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'];
1008
984
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -31,7 +31,6 @@ export default class EmailTransactions extends Command {
31
31
  private runCamt053Import;
32
32
  private runNestedImport;
33
33
  private parseFilterValue;
34
- private filterFields;
35
34
  private sanitizeOutput;
36
35
  private getColumns;
37
36
  private getValue;
@@ -1,5 +1,5 @@
1
1
  import { Command, Args, Flags } from '@oclif/core';
2
- import { apiRequest, ApiError, normalizeSortBy } from '../lib/api-client.js';
2
+ import { apiRequest, ApiError, isTextualMediaType, normalizeSortBy } from '../lib/api-client.js';
3
3
  import { getCurrentContext, getContext, getBaseUrls, hasCredentials, missingCredentialsMessage } from '../lib/config.js';
4
4
  // Add 'import' action if both create and update exist
5
5
  const HAS_IMPORT = false;
@@ -8,9 +8,9 @@ const HAS_CAMT053 = false;
8
8
  const NESTED_IMPORTS = [];
9
9
  const SUBCOMMANDS = [...["event-webhook", "list", "get"], ...(HAS_IMPORT ? ['import'] : []), ...(HAS_EXPORT ? ['export'] : []), ...NESTED_IMPORTS.map(n => `import-${n}`)];
10
10
  const ENDPOINTS = {
11
- 'event-webhook': { method: 'POST', path: '/v1/email-transactions/event-webhook', summary: 'SendGrid delivery-event webhook (ECDSA signature-verified, no bearer token)', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
12
- 'list': { method: 'GET', path: '/v1/email-transactions', summary: 'List email transactions for the current tenant', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
13
- 'get': { method: 'GET', path: '/v1/email-transactions/{id}', summary: 'Get a single email transaction (404 if not in this tenant)', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
11
+ 'event-webhook': { method: 'POST', path: '/v1/email-transactions/event-webhook', summary: 'SendGrid delivery-event webhook (ECDSA signature-verified, no bearer token)', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
12
+ 'list': { method: 'GET', path: '/v1/email-transactions', summary: 'List email transactions for the current tenant', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
13
+ 'get': { method: 'GET', path: '/v1/email-transactions/{id}', summary: 'Get a single email transaction (404 if not in this tenant)', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
14
14
  };
15
15
  export default class EmailTransactions extends Command {
16
16
  static description = 'Manage email transactions';
@@ -38,7 +38,7 @@ export default class EmailTransactions extends Command {
38
38
  'sort-by': Flags.string({ description: 'Sort field(s), comma-separated. Direction (:ASC/:DESC) is optional; date-like fields default to DESC, others to ASC.' }),
39
39
  filter: Flags.string({ description: 'Filter (e.g. --filter status=ACTIVE). Operators: in:A,B, like:%, gt:, lt:, null:', multiple: true }),
40
40
  query: Flags.string({ description: 'Additional query params as JSON' }),
41
- fields: Flags.string({ description: 'Comma-separated list of fields to show (e.g. --fields=id,name,status)' }),
41
+ fields: Flags.string({ description: 'Comma-separated list of fields to return, applied by the API (e.g. --fields=id,name,status)' }),
42
42
  sanitize: Flags.boolean({ description: 'Remove null values, audit fields, and duplicate UUIDs from output' }),
43
43
  'show-http-request': Flags.boolean({ description: 'Show HTTP request details (method, URL, body)' }),
44
44
  };
@@ -110,6 +110,8 @@ export default class EmailTransactions extends Command {
110
110
  queryParams.search = flags.search;
111
111
  if (flags['sort-by'])
112
112
  queryParams.sortBy = normalizeSortBy(flags['sort-by']);
113
+ if (flags.fields)
114
+ queryParams.fields = flags.fields;
113
115
  if (flags.query) {
114
116
  try {
115
117
  Object.assign(queryParams, JSON.parse(flags.query));
@@ -257,10 +259,11 @@ export default class EmailTransactions extends Command {
257
259
  process.stderr.write('\n');
258
260
  }
259
261
  try {
260
- // Binary response: download to file
262
+ // Binary response: to -f / --file, or to stdout when it is redirected.
261
263
  if (endpoint.isBinaryResponse) {
262
- if (!flags.file) {
263
- this.error(`Endpoint returns a binary file. Specify the output path with -f / --file (e.g. -f report.xlsx).`);
264
+ const toStdout = !flags.file;
265
+ if (toStdout && process.stdout.isTTY) {
266
+ this.error(`Endpoint returns a binary file. Specify the output path with -f / --file (e.g. -f report.xlsx), or redirect stdout to a file.`);
264
267
  }
265
268
  const binResponse = await apiRequest(endpoint.method, apiPath, {
266
269
  params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
@@ -268,24 +271,55 @@ export default class EmailTransactions extends Command {
268
271
  contextName: flags.context,
269
272
  binary: true,
270
273
  });
271
- const fsModule = await import('node:fs');
272
274
  const binData = binResponse.data;
273
275
  const buffer = Buffer.isBuffer(binData)
274
276
  ? binData
275
277
  : Buffer.from(binData);
278
+ if (toStdout) {
279
+ // Nothing else may reach stdout here — it is the file.
280
+ process.stdout.write(buffer);
281
+ return;
282
+ }
283
+ const fsModule = await import('node:fs');
276
284
  fsModule.writeFileSync(flags.file, buffer);
277
285
  this.log(`Saved ${buffer.length} bytes to ${flags.file}`);
278
286
  return;
279
287
  }
280
- const response = await apiRequest(endpoint.method, apiPath, {
281
- params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
282
- data,
283
- contextName: flags.context,
284
- acceptAny: true,
285
- });
288
+ let response;
289
+ if (endpoint.hasBinaryVariant) {
290
+ // This route answers JSON or a file depending on the request body, so the
291
+ // bytes are read first and the response's own content-type decides. Asking
292
+ // for text here would corrupt an xlsx/pdf before anything could inspect it.
293
+ const raw = await apiRequest(endpoint.method, apiPath, {
294
+ params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
295
+ data,
296
+ contextName: flags.context,
297
+ binary: true,
298
+ });
299
+ const rawType = String(raw.headers['content-type'] || '').toLowerCase();
300
+ const rawData = raw.data;
301
+ const buffer = Buffer.isBuffer(rawData) ? rawData : Buffer.from(rawData);
302
+ if (!isTextualMediaType(rawType)) {
303
+ if (process.stdout.isTTY) {
304
+ this.error(`The API returned ${rawType || 'binary data'}. Redirect stdout to a file, e.g. ... > report.xlsx`);
305
+ }
306
+ // Nothing else may reach stdout here — it is the file.
307
+ process.stdout.write(buffer);
308
+ return;
309
+ }
310
+ response = { data: buffer.toString('utf8'), headers: raw.headers, status: raw.status };
311
+ }
312
+ else {
313
+ response = await apiRequest(endpoint.method, apiPath, {
314
+ params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
315
+ data,
316
+ contextName: flags.context,
317
+ acceptAny: true,
318
+ });
319
+ }
286
320
  // Check content-type for non-JSON responses (XML, text, etc.)
287
321
  const contentType = (response.headers['content-type'] || '').toLowerCase();
288
- if (contentType.includes('xml') || contentType.includes('text/')) {
322
+ if (isTextualMediaType(contentType) && !contentType.startsWith('application/json')) {
289
323
  // Output raw content for XML/text responses (CSV, plain, HTML, etc.)
290
324
  this.log(String(response.data));
291
325
  return;
@@ -312,11 +346,6 @@ export default class EmailTransactions extends Command {
312
346
  output = obj[arrayKeys[0]];
313
347
  }
314
348
  }
315
- // Filter fields if --fields flag is provided
316
- if (flags.fields) {
317
- const fieldList = flags.fields.split(',').map(f => f.trim());
318
- output = this.filterFields(output, fieldList);
319
- }
320
349
  // Sanitize output if requested
321
350
  if (flags.sanitize) {
322
351
  output = this.sanitizeOutput(output);
@@ -571,6 +600,8 @@ export default class EmailTransactions extends Command {
571
600
  baseParams.sortBy = normalizeSortBy(flags['sort-by']);
572
601
  if (flags.include)
573
602
  baseParams.include = flags.include;
603
+ if (flags.fields)
604
+ baseParams.fields = flags.fields;
574
605
  if (flags.query) {
575
606
  try {
576
607
  Object.assign(baseParams, JSON.parse(flags.query));
@@ -625,12 +656,7 @@ export default class EmailTransactions extends Command {
625
656
  }
626
657
  }
627
658
  process.stderr.write(' done\n');
628
- // Filter fields if specified
629
659
  let output = allItems;
630
- if (flags.fields) {
631
- const fieldList = flags.fields.split(',').map(f => f.trim());
632
- output = this.filterFields(allItems, fieldList);
633
- }
634
660
  // Sanitize output if requested
635
661
  if (flags.sanitize) {
636
662
  output = this.sanitizeOutput(output);
@@ -959,56 +985,6 @@ export default class EmailTransactions extends Command {
959
985
  return `\$${op}`;
960
986
  return opRe.test(rest) ? `\$${op}:${this.parseFilterValue(rest)}` : `\$${op}:${rest}`;
961
987
  }
962
- filterFields(data, fields) {
963
- const isUnsafeKey = (k) => k === '__proto__' || k === 'constructor' || k === 'prototype';
964
- const pick = (obj, keys) => {
965
- // Group keys by their first segment so sibling sub-paths
966
- // (e.g. 'items.amount' + 'items._type') resolve in one recursive pass
967
- // and don't clobber each other on merge.
968
- const directKeys = new Set();
969
- const nestedKeys = new Map();
970
- for (const key of keys) {
971
- if (key.split('.').some(isUnsafeKey))
972
- continue;
973
- if (key.includes('.')) {
974
- const [first, ...rest] = key.split('.');
975
- if (isUnsafeKey(first))
976
- continue;
977
- const list = nestedKeys.get(first);
978
- if (list) {
979
- list.push(rest.join('.'));
980
- }
981
- else {
982
- nestedKeys.set(first, [rest.join('.')]);
983
- }
984
- }
985
- else {
986
- directKeys.add(key);
987
- }
988
- }
989
- const result = {};
990
- for (const key of directKeys) {
991
- if (key in obj)
992
- result[key] = obj[key];
993
- }
994
- for (const [first, restKeys] of nestedKeys) {
995
- const val = obj[first];
996
- if (!val || typeof val !== 'object')
997
- continue;
998
- if (Array.isArray(val)) {
999
- result[first] = val.map(item => pick(item, restKeys));
1000
- }
1001
- else {
1002
- result[first] = pick(val, restKeys);
1003
- }
1004
- }
1005
- return result;
1006
- };
1007
- if (Array.isArray(data)) {
1008
- return data.map(item => pick(item, fields));
1009
- }
1010
- return pick(data, fields);
1011
- }
1012
988
  sanitizeOutput(data) {
1013
989
  const auditFields = ['_type', 'tenantId', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'];
1014
990
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -35,7 +35,6 @@ export default class Employees extends Command {
35
35
  private runCamt053Import;
36
36
  private runNestedImport;
37
37
  private parseFilterValue;
38
- private filterFields;
39
38
  private sanitizeOutput;
40
39
  private getColumns;
41
40
  private getValue;