zapier-platform-cli 18.4.0 → 18.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zapier-platform-cli",
3
- "version": "18.4.0",
3
+ "version": "18.5.0",
4
4
  "description": "The CLI for managing integrations in Zapier Developer Platform.",
5
5
  "repository": "zapier/zapier-platform",
6
6
  "homepage": "https://platform.zapier.com/",
@@ -41,7 +41,7 @@
41
41
  "ignore": "7.0.5",
42
42
  "inquirer": "8.2.5",
43
43
  "jscodeshift": "^17.3.0",
44
- "lodash": "4.17.23",
44
+ "lodash": "4.18.1",
45
45
  "luxon": "3.7.1",
46
46
  "marked": "15.0.12",
47
47
  "marked-terminal": "7.3.0",
@@ -20,6 +20,7 @@ module.exports = [
20
20
  // features
21
21
  'dynamic-dropdown',
22
22
  'files',
23
+ 'line-items',
23
24
  'middleware',
24
25
  'resource',
25
26
  'rest-hooks',
@@ -217,6 +217,7 @@ const TEMPLATE_ROUTES = {
217
217
  'digest-auth': writeForAuthTemplate,
218
218
  'dynamic-dropdown': writeForStandaloneTemplate,
219
219
  files: writeForStandaloneTemplate,
220
+ 'line-items': writeForStandaloneTemplate,
220
221
  minimal: writeForMinimalTemplate,
221
222
  'oauth1-trello': writeForAuthTemplate,
222
223
  oauth2: writeForAuthTemplate,
@@ -246,12 +247,12 @@ const ProjectGeneratorPromise = createGeneratorClass((Generator) => {
246
247
  this.destinationRoot(path.resolve(this.options.path));
247
248
 
248
249
  const jsFilter = filter(['*.js', '*.json', '*.ts'], { restore: true });
249
- this.queueTransformStream([
250
+ this.queueTransformStream(
250
251
  { disabled: true },
251
252
  jsFilter,
252
253
  prettier({ singleQuote: true }),
253
254
  jsFilter.restore,
254
- ]);
255
+ );
255
256
  }
256
257
 
257
258
  async prompting() {
@@ -1,34 +1,34 @@
1
1
  # <%= name %>
2
2
 
3
- This Zapier integration project is generated by the `zapier init` CLI command.
3
+ This Zapier integration project is generated by the `zapier-platform init` CLI command.
4
4
 
5
5
  These are what you normally do next:
6
6
 
7
7
  ```bash
8
8
  # Install dependencies
9
- npm install # or you can use pnpm or yarn
9
+ npm install --ignore-scripts # or you can use pnpm or yarn
10
10
 
11
11
  # Run tests
12
- zapier test
12
+ zapier-platform test
13
13
 
14
14
  # Register the integration on Zapier if you haven't
15
- zapier register "App Title"
15
+ zapier-platform register "App Title"
16
16
 
17
17
  # Or you can link to an existing integration on Zapier
18
- zapier link
18
+ zapier-platform link
19
19
 
20
20
  # Push it to Zapier
21
- zapier push
21
+ zapier-platform push
22
22
  ```
23
23
 
24
- Then, to add more features, you can use the `zapier scaffold` command, for example:
24
+ Then, to add more features, you can use the `zapier-platform scaffold` command, for example:
25
25
 
26
26
  ```bash
27
27
  # Add a trigger
28
- zapier scaffold trigger contact
28
+ zapier-platform scaffold trigger contact
29
29
 
30
30
  # Add an action
31
- zapier scaffold create contact
31
+ zapier-platform scaffold create contact
32
32
  ```
33
33
 
34
34
  Find out more on the latest docs: https://docs.zapier.com/platform
@@ -0,0 +1,16 @@
1
+ # line-items
2
+
3
+ An example integration demonstrating line item support. Line items are fields
4
+ with a `children` property that represent structured, repeating data — like rows
5
+ in a spreadsheet or items in an order.
6
+
7
+ ## Testing with `zapier-platform invoke`
8
+
9
+ ```bash
10
+ # Non-interactive with JSON input
11
+ zapier-platform invoke create order --non-interactive \
12
+ -i '{"name": "My Order", "line_items": [{"product_name": "Pens", "quantity": "12", "price": "1.50"}]}'
13
+
14
+ # Interactive mode — use the line item editing UI
15
+ zapier-platform invoke create order -i '{"name": "My Order"}'
16
+ ```
@@ -0,0 +1,67 @@
1
+ const perform = async (z, bundle) => {
2
+ const response = await z.request({
3
+ url: 'https://httpbin.zapier-tooling.com/post',
4
+ method: 'POST',
5
+ body: {
6
+ name: bundle.inputData.name,
7
+ line_items: bundle.inputData.line_items,
8
+ },
9
+ });
10
+
11
+ return response.data;
12
+ };
13
+
14
+ module.exports = {
15
+ key: 'order',
16
+ noun: 'Order',
17
+ display: {
18
+ label: 'Create Order',
19
+ description: 'Creates a new order with line items.',
20
+ },
21
+ operation: {
22
+ inputFields: [
23
+ { key: 'name', required: true, type: 'string', label: 'Order Name' },
24
+ {
25
+ key: 'line_items',
26
+ label: 'Line Items',
27
+ children: [
28
+ {
29
+ key: 'product_name',
30
+ type: 'string',
31
+ label: 'Product Name',
32
+ required: true,
33
+ },
34
+ {
35
+ key: 'quantity',
36
+ type: 'integer',
37
+ label: 'Quantity',
38
+ required: true,
39
+ },
40
+ { key: 'price', type: 'number', label: 'Unit Price' },
41
+ ],
42
+ },
43
+ ],
44
+ perform,
45
+ sample: {
46
+ id: 1,
47
+ name: 'Stationery Order',
48
+ line_items: [
49
+ { product_name: 'Pens', quantity: 12, price: 1.5 },
50
+ { product_name: 'Notebooks', quantity: 3, price: 8.99 },
51
+ ],
52
+ },
53
+ outputFields: [
54
+ { key: 'id', label: 'ID' },
55
+ { key: 'name', label: 'Order Name' },
56
+ {
57
+ key: 'line_items',
58
+ label: 'Line Items',
59
+ children: [
60
+ { key: 'product_name', label: 'Product Name' },
61
+ { key: 'quantity', label: 'Quantity' },
62
+ { key: 'price', label: 'Unit Price' },
63
+ ],
64
+ },
65
+ ],
66
+ },
67
+ };
@@ -0,0 +1,12 @@
1
+ const order = require('./creates/order');
2
+
3
+ const App = {
4
+ version: require('./package.json').version,
5
+ platformVersion: require('zapier-platform-core').version,
6
+
7
+ creates: {
8
+ [order.key]: order,
9
+ },
10
+ };
11
+
12
+ module.exports = App;
@@ -0,0 +1,30 @@
1
+ /* globals describe, expect, test */
2
+
3
+ const zapier = require('zapier-platform-core');
4
+
5
+ const App = require('../index');
6
+ const appTester = zapier.createAppTester(App);
7
+ zapier.tools.env.inject();
8
+
9
+ describe('creates', () => {
10
+ test('create order with line items', async () => {
11
+ const bundle = {
12
+ inputData: {
13
+ name: 'Test Order',
14
+ line_items: [
15
+ { product_name: 'Pens', quantity: 12, price: 1.5 },
16
+ { product_name: 'Notebooks', quantity: 3, price: 8.99 },
17
+ ],
18
+ },
19
+ };
20
+ const result = await appTester(
21
+ App.creates.order.operation.perform,
22
+ bundle,
23
+ );
24
+ const body = JSON.parse(result.data);
25
+ expect(body.name).toBe('Test Order');
26
+ expect(body.line_items).toHaveLength(2);
27
+ expect(body.line_items[0].product_name).toBe('Pens');
28
+ expect(body.line_items[1].quantity).toBe(3);
29
+ });
30
+ });
@@ -1,4 +1,5 @@
1
1
  const debug = require('debug')('zapier:invoke');
2
+ const _ = require('lodash');
2
3
 
3
4
  const { startSpinner, endSpinner } = require('../../../utils/display');
4
5
  const { customLogger } = require('./logger');
@@ -71,8 +72,9 @@ const invokeAction = async (command, context) => {
71
72
  await promptForFields(command, context, inputFields, invokeAction);
72
73
  }
73
74
 
74
- // Preserve original inputData as inputDataRaw before type resolution
75
- const inputDataRaw = { ...context.inputData };
75
+ // Preserve original inputData as inputDataRaw before type resolution (deep
76
+ // copy needed because resolveInputDataTypes mutates nested objects in-place)
77
+ const inputDataRaw = _.cloneDeep(context.inputData);
76
78
  let inputData;
77
79
  if (context.remote) {
78
80
  // Let the remote server resolve input data types
@@ -146,10 +146,12 @@ class InvokeCommand extends BaseCommand {
146
146
  context.appId = (await getLinkedAppConfig(null, false))?.id;
147
147
  context.deployKey = (await readCredentials(false))[AUTH_KEY];
148
148
 
149
+ const hasAuth = Boolean(context.appDefinition.authentication);
150
+
149
151
  if (
150
152
  context.authId === '-' ||
151
153
  context.authId === '' ||
152
- (context.remote && !context.authId)
154
+ (context.remote && !context.authId && hasAuth)
153
155
  ) {
154
156
  if (context.nonInteractive) {
155
157
  throw new Error(
@@ -159,6 +161,12 @@ class InvokeCommand extends BaseCommand {
159
161
  context.authId = (await promptForAuthentication(this)).toString();
160
162
  }
161
163
 
164
+ if (context.remote && !context.authId && !hasAuth) {
165
+ // The remote invoke API requires authentication_id in the POST body,
166
+ // but the server accepts 0 for apps without authentication configured.
167
+ context.authId = '0';
168
+ }
169
+
162
170
  if (context.authId) {
163
171
  context.authId = parseInt(context.authId);
164
172
  if (isNaN(context.authId)) {
@@ -482,7 +490,6 @@ The \`--debug\` flag will show you the HTTP request logs and any console logs yo
482
490
  The following is a non-exhaustive list of current limitations in local and relay mode. We may support them in the future.
483
491
 
484
492
  - Hook triggers, including REST hook subscribe/unsubscribe
485
- - Line items
486
493
  - Output hydration
487
494
  - File upload
488
495
  - Function-based connection label
@@ -57,7 +57,8 @@ const parseDecimal = (s) => {
57
57
  }
58
58
  }
59
59
  const cleaned = chars.join('').replace(/[.,-]$/, '');
60
- return parseFloat(cleaned);
60
+ const result = parseFloat(cleaned);
61
+ return isNaN(result) ? 0 : result;
61
62
  };
62
63
 
63
64
  /**
@@ -232,7 +233,14 @@ const resolveInputDataTypes = (inputData, inputFields, timezone) => {
232
233
  }
233
234
  }
234
235
 
235
- // TODO: Handle line items (fields with "children")
236
+ // Handle line items (fields with "children")
237
+ for (const field of inputFields) {
238
+ if (field.children && field.children.length && Array.isArray(inputData[field.key])) {
239
+ for (const item of inputData[field.key]) {
240
+ resolveInputDataTypes(item, field.children, timezone);
241
+ }
242
+ }
243
+ }
236
244
 
237
245
  return inputData;
238
246
  };
@@ -71,10 +71,43 @@ const getLabelForDynamicDropdown = (obj, preferredKey, fallbackKey) => {
71
71
  */
72
72
  const getMissingRequiredInputFields = (inputData, inputFields) => {
73
73
  return inputFields.filter(
74
- (f) => f.required && !f.default && !inputData[f.key],
74
+ (f) =>
75
+ f.required &&
76
+ !f.default &&
77
+ (inputData[f.key] == null || inputData[f.key] === ''),
75
78
  );
76
79
  };
77
80
 
81
+ /**
82
+ * Finds required child fields (line items) that are missing values in any row.
83
+ * @param {Object} inputData - The current input data
84
+ * @param {Array<Object>} inputFields - Array of field definitions
85
+ * @returns {Array<Object>} Array of required child fields missing in at least one row
86
+ */
87
+ const getMissingRequiredChildFields = (inputData, inputFields) => {
88
+ const missing = [];
89
+ for (const field of inputFields) {
90
+ if (!field.children || !field.children.length) {
91
+ continue;
92
+ }
93
+ const items = inputData[field.key];
94
+ if (!Array.isArray(items)) {
95
+ continue;
96
+ }
97
+ const requiredChildren = field.children.filter(
98
+ (c) => c.required && c.type !== 'copy' && !c.default,
99
+ );
100
+ for (const item of items) {
101
+ for (const c of requiredChildren) {
102
+ if (item[c.key] == null || item[c.key] === '') {
103
+ missing.push(c);
104
+ }
105
+ }
106
+ }
107
+ }
108
+ return missing;
109
+ };
110
+
78
111
  /**
79
112
  * Fetches choices for a dynamic dropdown field.
80
113
  * @param {import('../../ZapierBaseCommand')} command - The command instance for prompting
@@ -324,7 +357,8 @@ const promptForField = async (command, context, field, invokeAction) => {
324
357
  let nextPagingToken = null;
325
358
 
326
359
  while (
327
- !answer ||
360
+ answer == null ||
361
+ answer === '' ||
328
362
  answer === '__next_page__' ||
329
363
  answer === '__prev_page__'
330
364
  ) {
@@ -378,8 +412,12 @@ const promptForField = async (command, context, field, invokeAction) => {
378
412
  }
379
413
  return answer;
380
414
  } else if (field.type === 'boolean') {
381
- const yes = await command.confirm(message, false, !field.required, true);
382
- return yes ? 'yes' : 'no';
415
+ if (field.required) {
416
+ const yes = await command.confirm(message, false, false, true);
417
+ return yes ? 'yes' : 'no';
418
+ } else {
419
+ return await command.prompt(message + ' (yes/no)', { useStderr: true });
420
+ }
383
421
  } else {
384
422
  return await command.prompt(message, { useStderr: true });
385
423
  }
@@ -400,6 +438,7 @@ const promptOrErrorForRequiredInputFields = async (
400
438
  inputFields,
401
439
  invokeAction,
402
440
  ) => {
441
+ // Check top-level required fields
403
442
  const missingFields = getMissingRequiredInputFields(
404
443
  context.inputData,
405
444
  inputFields,
@@ -420,6 +459,49 @@ const promptOrErrorForRequiredInputFields = async (
420
459
  );
421
460
  }
422
461
  }
462
+
463
+ // Check required child fields (line items) per row
464
+ const missingChildFields = getMissingRequiredChildFields(
465
+ context.inputData,
466
+ inputFields,
467
+ );
468
+ if (missingChildFields.length) {
469
+ if (context.nonInteractive || context.meta.isFillingDynamicDropdown) {
470
+ throw new Error(
471
+ "You're in non-interactive mode, so you must at least specify these required fields with --inputData: \n" +
472
+ missingChildFields
473
+ .map((f) => '* ' + formatFieldDisplay(f))
474
+ .join('\n'),
475
+ );
476
+ }
477
+ // Prompt per row for missing child fields
478
+ for (const field of inputFields) {
479
+ if (!field.children || !field.children.length) {
480
+ continue;
481
+ }
482
+ const items = context.inputData[field.key];
483
+ if (!Array.isArray(items)) {
484
+ continue;
485
+ }
486
+ const requiredChildren = field.children.filter(
487
+ (c) => c.required && c.type !== 'copy' && !c.default,
488
+ );
489
+ for (let i = 0; i < items.length; i++) {
490
+ for (const c of requiredChildren) {
491
+ if (items[i][c.key] == null || items[i][c.key] === '') {
492
+ const label = field.label || field.key;
493
+ console.error(`\n${label} (row ${i + 1}):`);
494
+ items[i][c.key] = await promptForField(
495
+ command,
496
+ context,
497
+ c,
498
+ invokeAction,
499
+ );
500
+ }
501
+ }
502
+ }
503
+ }
504
+ }
423
505
  };
424
506
 
425
507
  /**
@@ -451,8 +533,22 @@ const promptForInputFieldEdit = async (
451
533
  } else {
452
534
  name = f.key;
453
535
  }
454
- if (context.inputData[f.key]) {
455
- name += ` [current: "${context.inputData[f.key]}"]`;
536
+ const currentValue = context.inputData[f.key];
537
+ if (currentValue != null && currentValue !== '') {
538
+ if (Array.isArray(currentValue)) {
539
+ const MAX_LEN = 60;
540
+ const csv = currentValue
541
+ .map((item) => `{${Object.values(item).join(',')}}`)
542
+ .join(', ');
543
+ const count = `(${currentValue.length} ${currentValue.length === 1 ? 'item' : 'items'})`;
544
+ if (csv.length <= MAX_LEN) {
545
+ name += ` [${csv}] ${count}`;
546
+ } else {
547
+ name += ` [${csv.slice(0, MAX_LEN)}...] ${count}`;
548
+ }
549
+ } else {
550
+ name += ` [current: "${currentValue}"]`;
551
+ }
456
552
  } else if (f.default) {
457
553
  name += ` [default: "${f.default}"]`;
458
554
  }
@@ -479,12 +575,143 @@ const promptForInputFieldEdit = async (
479
575
  }
480
576
 
481
577
  const field = inputFields.find((f) => f.key === fieldKey);
482
- context.inputData[fieldKey] = await promptForField(
483
- command,
484
- context,
485
- field,
486
- invokeAction,
578
+ if (field.children && field.children.length) {
579
+ await promptForLineItemEdit(command, context, field, invokeAction);
580
+ } else {
581
+ context.inputData[fieldKey] = await promptForField(
582
+ command,
583
+ context,
584
+ field,
585
+ invokeAction,
586
+ );
587
+ }
588
+ }
589
+ };
590
+
591
+ /**
592
+ * Prompts the user to add a new line item row by prompting for each child field.
593
+ * @param {import('../../ZapierBaseCommand')} command - The command instance
594
+ * @param {Object} context - The execution context
595
+ * @param {Object} field - The parent field definition with children
596
+ * @param {Function} invokeAction - Function to invoke actions
597
+ * @returns {Promise<Object>} The new row object
598
+ */
599
+ const promptForNewLineItemRow = async (
600
+ command,
601
+ context,
602
+ field,
603
+ invokeAction,
604
+ ) => {
605
+ const row = {};
606
+ for (const child of field.children) {
607
+ if (child.default) {
608
+ row[child.key] = child.default;
609
+ }
610
+ if (child.required) {
611
+ row[child.key] = await promptForField(
612
+ command,
613
+ context,
614
+ child,
615
+ invokeAction,
616
+ );
617
+ }
618
+ }
619
+ return row;
620
+ };
621
+
622
+ /**
623
+ * Sub-menu for editing line item rows: add, edit, remove rows.
624
+ * @param {import('../../ZapierBaseCommand')} command - The command instance
625
+ * @param {Object} context - The execution context (inputData will be mutated)
626
+ * @param {Object} field - The parent field definition with children
627
+ * @param {Function} invokeAction - Function to invoke actions
628
+ * @returns {Promise<void>}
629
+ */
630
+ const promptForLineItemEdit = async (command, context, field, invokeAction) => {
631
+ if (!Array.isArray(context.inputData[field.key])) {
632
+ context.inputData[field.key] = [];
633
+ }
634
+ const items = context.inputData[field.key];
635
+ const label = field.label || field.key;
636
+
637
+ while (true) {
638
+ const choices = [
639
+ { name: '>>> BACK <<<', short: 'BACK', value: '__back__' },
640
+ { name: '>>> ADD ITEM <<<', value: '__add__' },
641
+ ];
642
+ for (let i = 0; i < items.length; i++) {
643
+ const parts = Object.entries(items[i])
644
+ .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
645
+ .join(', ');
646
+ choices.push({
647
+ name: parts ? `[${i}] {${parts}}` : `[${i}] Empty item - select to edit`,
648
+ value: `__select_${i}`,
649
+ });
650
+ }
651
+
652
+ const action = await command.promptWithList(
653
+ `${label} [${items.length} ${items.length === 1 ? 'item' : 'items'}]:`,
654
+ choices,
655
+ { useStderr: true },
487
656
  );
657
+
658
+ if (action === '__back__') {
659
+ break;
660
+ } else if (action === '__add__') {
661
+ const row = await promptForNewLineItemRow(
662
+ command,
663
+ context,
664
+ field,
665
+ invokeAction,
666
+ );
667
+ items.push(row);
668
+ } else if (action.startsWith('__select_')) {
669
+ const idx = parseInt(action.slice(9));
670
+ // Sub-menu loop for editing/deleting the selected item
671
+ while (true) {
672
+ const editChoices = [
673
+ { name: '>>> BACK <<<', short: 'BACK', value: '__back__' },
674
+ ];
675
+ for (const child of field.children) {
676
+ const current = items[idx] && items[idx][child.key];
677
+ let choiceName;
678
+ if (child.label) {
679
+ choiceName = `${child.label} (${child.key})`;
680
+ } else {
681
+ choiceName = child.key;
682
+ }
683
+ if (current != null) {
684
+ choiceName += ` [current: "${current}"]`;
685
+ }
686
+ editChoices.push({ name: choiceName, value: child.key });
687
+ }
688
+ editChoices.push({
689
+ name: '>>> DELETE ITEM <<<',
690
+ value: '__delete__',
691
+ });
692
+
693
+ const editAction = await command.promptWithList(
694
+ 'Edit or delete the item?',
695
+ editChoices,
696
+ { useStderr: true },
697
+ );
698
+
699
+ if (editAction === '__back__') {
700
+ break;
701
+ } else if (editAction === '__delete__') {
702
+ items.splice(idx, 1);
703
+ break;
704
+ } else {
705
+ const child = field.children.find((c) => c.key === editAction);
706
+ items[idx][editAction] = await promptForField(
707
+ command,
708
+ context,
709
+ child,
710
+ invokeAction,
711
+ );
712
+ }
713
+ }
714
+ }
488
715
  }
489
716
  };
490
717
 
@@ -37,22 +37,48 @@ const fetchInputFields = async (context) => {
37
37
  },
38
38
  },
39
39
  );
40
- return responseData.needs.map((need) => {
41
- return {
42
- key: need.key,
43
- type: FIELD_TYPE_MAP[need.type] || need.type,
44
- required: need.required,
45
- default: need.default,
46
- choices: need.choices,
47
- label: need.label,
48
- helpText: need.help_text,
49
- inputFormat: need.input_format,
50
- dynamic: need.prefill,
51
- list: need.list,
52
- placeholder: need.placeholder,
53
- alterDynamicFields: need.alter_dynamic_fields ?? false,
54
- };
40
+ const mapNeed = (need) => ({
41
+ key: need.key,
42
+ type: FIELD_TYPE_MAP[need.type] || need.type,
43
+ required: need.required,
44
+ default: need.default,
45
+ choices: need.choices,
46
+ label: need.label,
47
+ helpText: need.help_text,
48
+ inputFormat: need.input_format,
49
+ dynamic: need.prefill,
50
+ list: need.list,
51
+ placeholder: need.placeholder,
52
+ alterDynamicFields: need.alter_dynamic_fields ?? false,
55
53
  });
54
+
55
+ // Group child fields (those with parent_key) under their parent
56
+ const parentKeys = new Set(
57
+ responseData.needs.filter((n) => n.parent_key).map((n) => n.parent_key),
58
+ );
59
+ const fields = [];
60
+ const childrenByParent = {};
61
+ for (const need of responseData.needs) {
62
+ if (need.parent_key) {
63
+ if (!childrenByParent[need.parent_key]) {
64
+ childrenByParent[need.parent_key] = [];
65
+ }
66
+ childrenByParent[need.parent_key].push(mapNeed(need));
67
+ } else {
68
+ fields.push(mapNeed(need));
69
+ }
70
+ }
71
+ // Add parent fields for any parent_key that doesn't have a corresponding
72
+ // top-level field in the response, then attach children
73
+ for (const parentKey of parentKeys) {
74
+ let parent = fields.find((f) => f.key === parentKey);
75
+ if (!parent) {
76
+ parent = { key: parentKey };
77
+ fields.push(parent);
78
+ }
79
+ parent.children = childrenByParent[parentKey];
80
+ }
81
+ return fields;
56
82
  };
57
83
 
58
84
  const fetchChoices = async (context, inputFieldKey) => {