zapier-platform-cli 15.18.1 → 15.19.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.
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import zapier from 'zapier-platform-core';
3
+
4
+ import App from '../../index';
5
+
6
+ const appTester = zapier.createAppTester(App);
7
+ // read the `.env` file into the environment, if available
8
+ zapier.tools.env.inject();
9
+
10
+ describe('<%= ACTION_PLURAL %>.<%= KEY %>', () => {
11
+ it('should run', async () => {
12
+ const bundle = { inputData: {} };
13
+
14
+ const results = await appTester(App.<%= ACTION_PLURAL %>['<%= KEY %>'].<%= MAYBE_RESOURCE %>operation.perform, bundle);
15
+ expect(results).toBeDefined();
16
+ // TODO: add more assertions
17
+ });
18
+ });
@@ -0,0 +1,58 @@
1
+ import type { PerformFunction, Trigger } from 'zapier-platform-core';
2
+
3
+ // triggers on a new <%= LOWER_NOUN %> with a certain tag
4
+ const perform: PerformFunction = async (z, bundle) => {
5
+ const response = await z.request({
6
+ url: 'https://jsonplaceholder.typicode.com/posts',
7
+ params: {
8
+ tag: bundle.inputData.tagName,
9
+ },
10
+ });
11
+ // this should return an array of objects
12
+ return response.data;
13
+ };
14
+
15
+ export default {
16
+ // see here for a full list of available properties:
17
+ // https://github.com/zapier/zapier-platform/blob/main/packages/schema/docs/build/schema.md#triggerschema
18
+ key: '<%= KEY %>' as const,
19
+ noun: '<%= NOUN %>',
20
+
21
+ display: {
22
+ label: 'New <%= NOUN %>',
23
+ description: 'Triggers when a new <%= LOWER_NOUN %> is created.',
24
+ },
25
+
26
+ operation: {
27
+ type: 'polling',
28
+ perform,
29
+
30
+ <%= INCLUDE_INTRO_COMMENTS ? [
31
+ '// `inputFields` defines the fields a user could provide',
32
+ '// Zapier will pass them in as `bundle.inputData` later. They\'re optional.'
33
+ ].join('\n ') : '' %>
34
+ inputFields: [],
35
+
36
+ <%= INCLUDE_INTRO_COMMENTS ? [
37
+ '// In cases where Zapier needs to show an example record to the user, but we are unable to get a live example',
38
+ '// from the API, Zapier will fallback to this hard-coded sample. It should reflect the data structure of',
39
+ '// returned records, and have obvious placeholder values that we can show to any user.'
40
+ ].join('\n ') : '' %>
41
+ sample: {
42
+ id: 1,
43
+ name: 'Test',
44
+ },
45
+
46
+ <%= INCLUDE_INTRO_COMMENTS ? [
47
+ '// If fields are custom to each user (like spreadsheet columns), `outputFields` can create human labels',
48
+ '// For a more complete example of using dynamic fields see',
49
+ '// https://github.com/zapier/zapier-platform/tree/main/packages/cli#customdynamic-fields',
50
+ '// Alternatively, a static field definition can be provided, to specify labels for the fields'
51
+ ].join('\n ') : '' %>
52
+ outputFields: [
53
+ // these are placeholders to match the example `perform` above
54
+ // {key: 'id', label: 'Person ID'},
55
+ // {key: 'name', label: 'Person Name'}
56
+ ],
57
+ },
58
+ } satisfies Trigger;
@@ -1,4 +1,8 @@
1
+ /* eslint-disable camelcase */
2
+ // @ts-check
3
+
1
4
  const path = require('path');
5
+ const fs = require('fs');
2
6
 
3
7
  const { flags } = require('@oclif/command');
4
8
 
@@ -6,8 +10,7 @@ const BaseCommand = require('../ZapierBaseCommand');
6
10
  const { buildFlags } = require('../buildFlags');
7
11
 
8
12
  const {
9
- createTemplateContext,
10
- getRelativeRequirePath,
13
+ createScaffoldingContext,
11
14
  plural,
12
15
  updateEntryFile,
13
16
  isValidEntryFileUpdate,
@@ -18,126 +21,89 @@ const { isValidAppInstall } = require('../../utils/misc');
18
21
  const { writeFile } = require('../../utils/files');
19
22
  const { ISSUES_URL } = require('../../constants');
20
23
 
21
- const getNewFileDirectory = (action, test = false) =>
22
- path.join(test ? 'test/' : '', plural(action));
23
-
24
- const getLocalFilePath = (directory, actionKey) =>
25
- path.join(directory, actionKey);
26
- /**
27
- * both the string to `require` and later, the filepath to write to
28
- */
29
- const getFullActionFilePath = (directory, actionKey) =>
30
- path.join(process.cwd(), getLocalFilePath(directory, actionKey));
31
-
32
- const getFullActionFilePathWithExtension = (directory, actionKey, isTest) =>
33
- `${getFullActionFilePath(directory, actionKey)}${isTest ? '.test' : ''}.js`;
34
-
35
24
  class ScaffoldCommand extends BaseCommand {
36
25
  async perform() {
37
26
  const { actionType, noun } = this.args;
38
-
39
- // TODO: interactive portion here?
27
+ const indexFileLocal = this.flags.entry ?? this.defaultIndexFileLocal();
40
28
  const {
41
- dest: newActionDir = getNewFileDirectory(actionType),
42
- testDest: newTestActionDir = getNewFileDirectory(actionType, true),
43
- entry = 'index.js',
29
+ dest: actionDirLocal = this.defaultActionDirLocal(indexFileLocal),
30
+ 'test-dest': testDirLocal = this.defaultTestDirLocal(indexFileLocal),
44
31
  force,
45
32
  } = this.flags;
46
33
 
47
- // this is possible, just extra work that's out of scope
48
- // const tsParser = j.withParser('ts')
49
- // tsParser(codeStr)
50
- // will have to change logic probably though
51
- if (entry.endsWith('ts')) {
52
- this.error(
53
- `Typescript isn't supported for scaffolding yet. Instead, try copying the example code at https://github.com/zapier/zapier-platform/blob/b8224ec9855be91c66c924b731199a068b1e913a/example-apps/typescript/src/resources/recipe.ts`
54
- );
55
- }
34
+ const language = indexFileLocal.endsWith('.ts') ? 'ts' : 'js';
56
35
 
57
- const shouldIncludeComments = !this.flags['no-help']; // when called from other commands (namely `init`) this will be false
58
- const templateContext = createTemplateContext(
36
+ const context = createScaffoldingContext({
59
37
  actionType,
60
38
  noun,
61
- shouldIncludeComments
62
- );
63
-
64
- const actionKey = templateContext.KEY;
39
+ language,
40
+ indexFileLocal,
41
+ actionDirLocal,
42
+ testDirLocal,
43
+ includeIntroComments: !this.flags['no-help'],
44
+ preventOverwrite: !force,
45
+ });
65
46
 
66
- const preventOverwrite = !force;
67
47
  // TODO: read from config file?
68
48
 
69
- this.startSpinner(
70
- `Creating new file: ${getLocalFilePath(newActionDir, actionKey)}.js`
71
- );
72
- await writeTemplateFile(
73
- actionType,
74
- templateContext,
75
- getFullActionFilePathWithExtension(newActionDir, actionKey),
76
- preventOverwrite
77
- );
49
+ this.startSpinner(`Creating new file: ${context.actionFileLocal}`);
50
+
51
+ await writeTemplateFile({
52
+ destinationPath: context.actionFileResolved,
53
+ templateType: context.actionType,
54
+ language: context.language,
55
+ preventOverwrite: context.preventOverwrite,
56
+ templateContext: context.templateContext,
57
+ });
78
58
  this.stopSpinner();
79
59
 
80
- this.startSpinner(
81
- `Creating new test file: ${getLocalFilePath(
82
- newTestActionDir,
83
- actionKey
84
- )}.js`
85
- );
86
- await writeTemplateFile(
87
- 'test',
88
- templateContext,
89
- getFullActionFilePathWithExtension(newTestActionDir, actionKey, true),
90
- preventOverwrite
91
- );
60
+ this.startSpinner(`Creating new test file: ${context.testFileLocal}`);
61
+ await writeTemplateFile({
62
+ destinationPath: context.testFileResolved,
63
+ templateType: 'test',
64
+ language: context.language,
65
+ preventOverwrite: context.preventOverwrite,
66
+ templateContext: context.templateContext,
67
+ });
92
68
  this.stopSpinner();
93
69
 
94
70
  // * rewire the index.js to point to the new file
95
- this.startSpinner(`Rewriting your ${entry}`);
71
+ this.startSpinner(`Rewriting your ${context.indexFileLocal}`);
96
72
 
97
- const entryFilePath = path.join(process.cwd(), entry);
98
-
99
- const originalContents = await updateEntryFile(
100
- entryFilePath,
101
- templateContext.VARIABLE,
102
- getFullActionFilePath(newActionDir, actionKey),
103
- actionType,
104
- templateContext.KEY
105
- );
73
+ const originalContents = await updateEntryFile({
74
+ language: context.language,
75
+ indexFileResolved: context.indexFileResolved,
76
+ actionRelativeImportPath: context.actionRelativeImportPath,
77
+ actionImportName: context.templateContext.VARIABLE,
78
+ actionType: context.actionType,
79
+ });
106
80
 
107
81
  if (isValidAppInstall().valid) {
108
82
  const success = isValidEntryFileUpdate(
109
- entryFilePath,
110
- actionType,
111
- templateContext.KEY
83
+ context.language,
84
+ context.indexFileResolved,
85
+ context.actionType,
86
+ context.templateContext.KEY
112
87
  );
113
88
 
114
89
  this.stopSpinner({ success });
115
90
 
116
91
  if (!success) {
117
- const entryName = splitFileFromPath(entryFilePath)[1];
92
+ const entryName = splitFileFromPath(context.indexFileResolved)[1];
118
93
 
119
94
  this.startSpinner(
120
95
  `Unable to successfully rewrite your ${entryName}. Rolling back...`
121
96
  );
122
- await writeFile(entryFilePath, originalContents);
97
+ await writeFile(context.indexFileResolved, originalContents);
123
98
  this.stopSpinner();
124
99
 
125
100
  this.error(
126
101
  [
127
- `\nPlease add the following lines to ${entryFilePath}:`,
128
- ` * \`const ${
129
- templateContext.VARIABLE
130
- } = require('./${getRelativeRequirePath(
131
- entryFilePath,
132
- getFullActionFilePath(newActionDir, actionKey)
133
- )}');\` at the top-level`,
134
- ` * \`[${templateContext.VARIABLE}.key]: ${
135
- templateContext.VARIABLE
136
- }\` in the "${plural(
137
- actionType
138
- )}" object in your exported integration definition.`,
102
+ `\nPlease add the following lines to ${context.indexFileResolved}:`,
103
+ ` * \`const ${context.templateContext.VARIABLE} = require('./${context.actionRelativeImportPath}');\` at the top-level`,
104
+ ` * \`[${context.templateContext.VARIABLE}.key]: ${context.templateContext.VARIABLE}\` in the "${context.actionTypePlural}" object in your exported integration definition.`,
139
105
  '',
140
- `Also, please file an issue at ${ISSUES_URL} with the contents of your ${entryFilePath}.`,
106
+ `Also, please file an issue at ${ISSUES_URL} with the contents of your ${context.indexFileResolved}.`,
141
107
  ].join('\n')
142
108
  );
143
109
  }
@@ -146,8 +112,51 @@ class ScaffoldCommand extends BaseCommand {
146
112
  this.stopSpinner();
147
113
 
148
114
  if (!this.flags.invokedFromAnotherCommand) {
149
- this.log(`\nAll done! Your new ${actionType} is ready to use.`);
115
+ this.log(`\nAll done! Your new ${context.actionType} is ready to use.`);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * If `--entry` is not provided, this will determine the path to the
121
+ * root index file. Notably, we'll look for tsconfig.json and
122
+ * src/index.ts first, because even TS apps have a root level plain
123
+ * index.js that we should ignore.
124
+ *
125
+ * @returns {string}
126
+ */
127
+ defaultIndexFileLocal() {
128
+ const tsConfigPath = path.join(process.cwd(), 'tsconfig.json');
129
+ const srcIndexTsPath = path.join(process.cwd(), 'src', 'index.ts');
130
+ if (fs.existsSync(tsConfigPath) && fs.existsSync(srcIndexTsPath)) {
131
+ this.log('Automatically detected TypeScript project');
132
+ return 'src/index.ts';
150
133
  }
134
+
135
+ return 'index.js';
136
+ }
137
+
138
+ /**
139
+ * If `--dest` is not provided, this will determine the directory for
140
+ * the new action file to be created in.
141
+ *
142
+ * @param {string} indexFileLocal - The path to the index file
143
+ * @returns {string}
144
+ */
145
+ defaultActionDirLocal(indexFileLocal) {
146
+ const parent = path.dirname(indexFileLocal);
147
+ return path.join(parent, plural(this.args.actionType));
148
+ }
149
+
150
+ /**
151
+ * If `--test-dest` is not provided, this will determine the directory
152
+ * for the new test file to be created in.
153
+ *
154
+ * @param {string} indexFileLocal - The path to the index file
155
+ * @returns {string}
156
+ */
157
+ defaultTestDirLocal(indexFileLocal) {
158
+ const parent = path.dirname(indexFileLocal);
159
+ return path.join(parent, 'test', plural(this.args.actionType));
151
160
  }
152
161
  }
153
162
 
@@ -179,13 +188,12 @@ ScaffoldCommand.flags = buildFlags({
179
188
  entry: flags.string({
180
189
  char: 'e',
181
190
  description:
182
- "Supply the path to your integration's root (`index.js`). Only needed if your `index.js` is in a subfolder, like `src`.",
183
- default: 'index.js',
191
+ "Supply the path to your integration's entry point (`index.js` or `src/index.ts`). This will try to automatically detect the correct file if not provided.",
184
192
  }),
185
193
  force: flags.boolean({
186
194
  char: 'f',
187
195
  description:
188
- 'Should we overwrite an exisiting trigger/search/create file?',
196
+ 'Should we overwrite an existing trigger/search/create file?',
189
197
  default: false,
190
198
  }),
191
199
  'no-help': flags.boolean({
package/src/utils/ast.js CHANGED
@@ -1,6 +1,9 @@
1
- // tools for modifiyng an AST
1
+ // @ts-check
2
+
3
+ // tools for modifying an AST
2
4
 
3
5
  const j = require('jscodeshift');
6
+ const ts = j.withParser('ts');
4
7
 
5
8
  // simple helper functions used for searching for nodes
6
9
  // can't use j.identifier(name) because it has extra properties and we have to have no extras to find nodes
@@ -21,7 +24,7 @@ const typeHelpers = {
21
24
  /**
22
25
  * adds a `const verName = require(path)` to the root of a codeStr
23
26
  */
24
- const createRootRequire = (codeStr, varName, path) => {
27
+ const importActionInJsApp = (codeStr, varName, path) => {
25
28
  if (codeStr.match(new RegExp(`${varName} ?= ?require`))) {
26
29
  // duplicate identifier, no need to re-add
27
30
  // this would fail if they used this variable name for something else; we'd keep going and double-declare that variable
@@ -61,7 +64,7 @@ const createRootRequire = (codeStr, varName, path) => {
61
64
  return root.toSource();
62
65
  };
63
66
 
64
- const addKeyToPropertyOnApp = (codeStr, property, varName) => {
67
+ const registerActionInJsApp = (codeStr, property, varName) => {
65
68
  // to play with this, use https://astexplorer.net/#/gist/cb4986b3f1c6eb975339608109a48e7d/0fbf2fabbcf27d0b6ebd8910f979bd5d97dd9404
66
69
 
67
70
  const root = j(codeStr);
@@ -136,4 +139,102 @@ const addKeyToPropertyOnApp = (codeStr, property, varName) => {
136
139
  return root.toSource();
137
140
  };
138
141
 
139
- module.exports = { createRootRequire, addKeyToPropertyOnApp };
142
+ /**
143
+ * Adds an import statement to the top of an index.ts file to import a
144
+ * new action, such as `import some_trigger from './triggers/some_trigger';`
145
+ *
146
+ * @param {string} codeStr - The code of the index.ts file to modify.
147
+ * @param {string} identifierName - The name of imported action used as a variable in the code.
148
+ * @param {string} actionRelativeImportPath - The relative path to import the action from
149
+ * @returns {string}
150
+ */
151
+ const importActionInTsApp = (
152
+ codeStr,
153
+ identifierName,
154
+ actionRelativeImportPath
155
+ ) => {
156
+ const root = ts(codeStr);
157
+
158
+ const imports = root.find(ts.ImportDeclaration);
159
+
160
+ const newImportStatement = j.importDeclaration(
161
+ [j.importDefaultSpecifier(j.identifier(identifierName))],
162
+ j.literal(actionRelativeImportPath)
163
+ );
164
+
165
+ if (imports.length) {
166
+ imports.at(-1).insertAfter(newImportStatement);
167
+ } else {
168
+ const body = root.find(ts.Program).get().node.body;
169
+ body.unshift(newImportStatement);
170
+ // Add newline after import?
171
+ }
172
+
173
+ return root.toSource({ quote: 'single' });
174
+ };
175
+
176
+ /**
177
+ *
178
+ * @param {string} codeStr
179
+ * @param {'creates' | 'searches' | 'triggers'} actionTypePlural - The type of action to register within the app
180
+ * @param {string} identifierName - Name of the action imported to be registered
181
+ * @returns {string}
182
+ */
183
+ const registerActionInTsApp = (codeStr, actionTypePlural, identifierName) => {
184
+ const root = ts(codeStr);
185
+
186
+ // the `[thing.key]: thing` entry we'd like to insert.
187
+ const newProperty = ts.property.from({
188
+ kind: 'init',
189
+ key: j.memberExpression(j.identifier(identifierName), j.identifier('key')),
190
+ value: j.identifier(identifierName),
191
+ computed: true,
192
+ });
193
+
194
+ // Find the top level app Object; the one with the `platformVersion`
195
+ // key. This is where we'll insert our new property.
196
+ const appObjectCandidates = root
197
+ .find(ts.ObjectExpression)
198
+ .filter((path) =>
199
+ path.value.properties.some(
200
+ (prop) => prop.key && prop.key.name === 'platformVersion'
201
+ )
202
+ );
203
+ if (appObjectCandidates.length !== 1) {
204
+ throw new Error('Unable to find the app definition to modify');
205
+ }
206
+ const appObj = appObjectCandidates.get().node;
207
+
208
+ // Now we have an app object to modify.
209
+
210
+ // Check if this object already has the actionType group inside it.
211
+ const existingProp = appObj.properties.find(
212
+ (props) => props.key.name === actionTypePlural
213
+ );
214
+ if (existingProp) {
215
+ const value = existingProp.value;
216
+ if (value.type !== 'ObjectExpression') {
217
+ throw new Error(
218
+ `Tried to edit the ${actionTypePlural} key, but the value wasn't an object`
219
+ );
220
+ }
221
+ value.properties.push(newProperty);
222
+ } else {
223
+ appObj.properties.push(
224
+ j.property(
225
+ 'init',
226
+ j.identifier(actionTypePlural),
227
+ j.objectExpression([newProperty])
228
+ )
229
+ );
230
+ }
231
+
232
+ return root.toSource({ quote: 'single' });
233
+ };
234
+
235
+ module.exports = {
236
+ importActionInJsApp,
237
+ registerActionInJsApp,
238
+ importActionInTsApp,
239
+ registerActionInTsApp,
240
+ };