├── .gitignore
├── .hygen.js
├── .vscode
└── settings.json
├── LICENSE.md
├── README.md
├── _templates
└── gen
│ ├── generateGenericFunctions
│ └── GenericFunctions.ejs.gen
│ ├── generateKeyCredential
│ └── KeyCredential.ejs.gen
│ ├── generateNodeCredentialDocs
│ └── NodeCredentialDocs.ejs.gen
│ ├── generateNodeMainDocs
│ └── NodeMainDocs.ejs.gen
│ ├── generateOAuth2Credential
│ └── OAuth2Credential.ejs.gen
│ ├── generateRegularNodeComplex
│ └── RegularNodeComplex.ejs.gen
│ ├── generateRegularNodeSimple
│ └── RegularNodeSimple.ejs.gen
│ ├── generateResourceDescription
│ └── ResourceDescription.ejs.gen
│ ├── generateTriggerNodeSimple
│ └── TriggerNodeSimple.ejs.gen
│ ├── updateCredentialPackageJson
│ └── packageJsonCredential.ejs.inj
│ └── updateNodePackageJson
│ └── packageJsonNode.ejs.inj
├── build
└── .gitkeep
├── client
├── .browserslistrc
├── .eslintrc.js
├── .gitignore
├── README.md
├── Requester.ts
├── assests
│ └── n8n.png
├── babel.config.js
├── channels
│ ├── DocsgenChannel.ts
│ ├── EmptyChannel.ts
│ ├── ExampleChannel.ts
│ ├── IpcChannel.interface.ts
│ ├── NodegenChannel.ts
│ ├── PackgenChannel.ts
│ └── PlacementChannel.ts
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ └── index.html
├── src
│ ├── App.vue
│ ├── assets
│ │ ├── n8n.png
│ │ └── n8n_copy.png
│ ├── background.ts
│ ├── components
│ │ ├── AddButton.vue
│ │ ├── BackwardButton.vue
│ │ ├── Checkbox.vue
│ │ ├── Dropdown.vue
│ │ ├── ForwardButton.vue
│ │ ├── GenericButton.vue
│ │ ├── Header.vue
│ │ ├── InputField.vue
│ │ ├── Instructions.vue
│ │ ├── SmallButton.vue
│ │ ├── Switch.vue
│ │ └── TextArea.vue
│ ├── main.ts
│ ├── mixins
│ │ ├── params-build-mixin.ts
│ │ └── routes-mixin.ts
│ ├── router
│ │ └── index.ts
│ ├── shims-tsx.d.ts
│ ├── shims-vue.d.ts
│ ├── store
│ │ ├── index.ts
│ │ ├── modules
│ │ │ ├── basicInfo.ts
│ │ │ ├── docsInfo.ts
│ │ │ ├── fields.ts
│ │ │ ├── operations.ts
│ │ │ ├── properties.ts
│ │ │ └── resources.ts
│ │ └── types.ts
│ └── views
│ │ ├── BasicInfo.vue
│ │ ├── RegularNode
│ │ ├── Complete.vue
│ │ ├── Fields.vue
│ │ ├── Operations.vue
│ │ └── Resources.vue
│ │ └── TriggerNode
│ │ ├── Complete.vue
│ │ ├── Fields.vue
│ │ └── Properties.vue
└── tsconfig.json
├── config
└── index.ts
├── docs
├── README.md
├── cli-reference.md
├── codebase-functionality.md
├── images
│ ├── graphs
│ │ ├── backend-frontend-communication.png
│ │ ├── backend.svg
│ │ └── frontend.svg
│ ├── icons
│ │ ├── icons8-bug-64.png
│ │ ├── icons8-code-file-80.png
│ │ ├── icons8-console-64.png
│ │ ├── icons8-product-documents-64.png
│ │ └── icons8-repository-80.png
│ ├── logos
│ │ ├── electron.png
│ │ ├── node.png
│ │ ├── ts.png
│ │ └── vue.png
│ ├── nodemaker-banner.png
│ └── screencaps
│ │ ├── credentials.png
│ │ ├── icongen-output.png
│ │ ├── icongen-querystring.png
│ │ ├── node-doc.png
│ │ ├── node.png
│ │ ├── nodegen-prompt.png
│ │ ├── packageJson.png
│ │ ├── place-prompt.png
│ │ ├── place-small.png
│ │ ├── placement.png
│ │ ├── resize-icon-candidates.png
│ │ ├── resize-prompt.png
│ │ ├── typeguard-error.png
│ │ ├── validation-error.png
│ │ ├── workflow-submission.png
│ │ └── workflow.png
├── issue-submission-guidelines.md
├── official-repos-setup.md
└── output-examples
│ ├── GenericFunctions.ts
│ ├── HackerNews.md
│ ├── HackerNews.node.ts
│ ├── HackerNewsCredentials.md
│ └── HackerNewsOAuth2Api.credentials.ts
├── generators
├── Generator.ts
├── NodeDocsGenerator.ts
├── NodeFilesGenerator.ts
└── PackageJsonGenerator.ts
├── globals.d.ts
├── output
└── .gitkeep
├── package-lock.json
├── package.json
├── parameters.ts
├── scripts
├── createWorkflow.ts
├── emptyOutputDir.ts
├── generateIconCandidates.ts
├── generateNodeDocs.ts
├── generateNodeFiles.ts
├── generatePackageJson.ts
├── placeFiles.ts
├── resizeIcon.ts
├── takeScreenshot.ts
└── validateParams.ts
├── services
├── DirectoryEmptier.ts
├── FileFinder.ts
├── FilePlacer.ts
├── Highlighter.ts
├── IconResizer.ts
├── ImageFetcher.ts
├── Prompter.ts
├── ScreenshotTaker.ts
├── Validator.ts
└── WorkflowCreator.ts
├── tester.json
├── tsconfig.json
└── utils
├── constants.ts
├── enums.ts
├── getWorkflowSubmissionUrl.ts
├── sleep.ts
└── typeGuards.ts
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /output/*
3 | /build/*
4 | !build/.gitkeep
5 | /config/.env
--------------------------------------------------------------------------------
/.hygen.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | helpers: {
3 | /**Check if a field or option is collective (contains fields or options).*/
4 | isCollective: (entity) =>
5 | entity.type === "collection" ||
6 | entity.type === "fixedCollection" ||
7 | entity.type === "multiOptions" ||
8 | entity.type === "options",
9 | /**Format a string as a class name, uppercase for each initial and no whitespace.*/
10 | classify: (name) => name.replace(/\s/g, ""),
11 | /**Format a string as a lowercase single word, or a lowercase first word and uppercase initial + lowercase rest for following words.*/
12 | camelify: (input) => {
13 | const isSingleWord = input.split(" ").length === 1;
14 | const uppercaseInitialLowercaseRest = (input) =>
15 | input[0].toUpperCase() + input.slice(1).toLowerCase();
16 |
17 | if (isSingleWord) return input.toLowerCase();
18 |
19 | // multi-word
20 | let result = [];
21 | input.split(" ").forEach((word, index) => {
22 | index === 0
23 | ? result.push(word.toLowerCase())
24 | : result.push(uppercaseInitialLowercaseRest(word));
25 | });
26 | return result.join("");
27 | },
28 | /**Check if operations array has a Get All operation.*/
29 | hasGetAll: (operations) =>
30 | operations.some((operation) => operation.name === "Get All"),
31 | /**Check if a particular operation is a GET request.*/
32 | isRetrieval: (operation) => operation.requestMethod === "GET",
33 | /**Check if a particular operation has any associated additional fields.*/
34 | hasAdditionalFields: (operation) =>
35 | operation.fields.filter((field) => field.name === "Additional Fields"),
36 | /**Create the credential string for based on auth type.*/
37 | getCredentialsString: (name, auth) =>
38 | name + (auth === "OAuth2" ? "OAuth2" : "") + "Api",
39 | /**Check if the endpoint has an extractable variable.*/
40 | hasEndpointVariable: (endpoint) => endpoint.split("").includes(":"),
41 | /**Extract the variable from the endpoint.*/
42 | getVariableFromEndpoint: (endpoint) => endpoint.match(/:(.*)(\/)?/)[1],
43 | /**Reformat endpoint from colon version to string-interpolated version.*/
44 | fixEndpoint: (endpoint) => {
45 | const routeParameter = endpoint.match(/:(.*)(\/)?/)[1];
46 | return endpoint.replace(
47 | ":" + routeParameter,
48 | "${" + routeParameter + "}"
49 | );
50 | },
51 |
52 | hasNumericalLimits: (field) => field.numericalLimits,
53 | },
54 | };
55 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "[javascript]": {
3 | "editor.formatOnSave": true
4 | },
5 | "[typescript]": {
6 | "editor.formatOnSave": true
7 | },
8 | "[markdown]": {
9 | "editor.formatOnSave": true
10 | },
11 | "[vue]": {
12 | "editor.formatOnSave": true
13 | },
14 | }
15 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # MIT License
2 |
3 | © 2020 Iván Ovejero and Erin McNulty
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
Nodemaker
7 |
8 |
9 |
10 | Desktop app and CLI utility to auto-generate n8n nodes
11 | by Iván Ovejero and Erin McNulty
12 |
13 |
14 |
15 | Installation •
16 | Operation •
17 | Examples •
18 | Documentation
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | **Nodemaker** is an automatic node generator for [n8n](https://github.com/n8n-io/n8n), a workflow automation tool. Nodemaker outputs all functionality and documentation files for a node, places them in the official repos, and uploads a sample workflow to [n8n.io](https://n8n.io/workflows).
30 |
31 | Developed as a **desktop app** and **CLI utility**, in MVP stage, as a capstone project for the [MLH Fellowship](https://github.com/MLH-Fellowship).
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | Built with TypeScript , Node , Electron and Vue
47 |
48 |
49 | ## Installation
50 |
51 | To set up Nodemaker:
52 |
53 | ```sh
54 | git clone https://github.com/MLH-Fellowship/nodemaker.git
55 | cd nodemaker && npm run setup
56 | ```
57 |
58 | To quickly see it working:
59 |
60 | ```sh
61 | npm run nodegen
62 | ```
63 |
64 | With `nodegen`, Nodemaker will read the built-in sample params and generate sample output files in `/output`.
65 |
66 | ## Operation
67 |
68 | Nodemaker can be operated as a desktop app or as a CLI utility.
69 |
70 | - To run the desktop app: `npm run desktop`
71 | - To run the CLI, see the [CLI reference](/docs/cli-reference.md).
72 |
73 | Nodemaker generates two types of files:
74 |
75 | **Node functionality files**
76 |
77 | - `*.node.ts` — main logic (regular or trigger node)
78 | - `GenericFunctions.ts` — node helper functions
79 | - `*Description.ts` — separate logic per resource (optional)
80 | - `*.credentials.ts` — node authentication params
81 | - `package.json` — updated node listing
82 | - `*.png` — node icon
83 |
84 | |   |
85 | | :------------------------------------------------------------------------------: |
86 | | Auto-generated `HackerNews.node.ts` and `GenericFunctions.ts` |
87 |
88 |
89 |
90 | **Node documentation files**
91 |
92 | - `README.md` — main doc file
93 | - `README.md` — credentials doc file
94 | - `workflow.png` — in-app screenshot for main doc file
95 |
96 | |   |
97 | | :-------------------------------------------------------------------------------: |
98 | | Auto-generated `README.md` and `workflow.png` |
99 |
100 |
101 |
102 | Once these files are generated, Nodemaker can:
103 |
104 | - relocate them in your copies of the [n8n](https://github.com/n8n-io/n8n) and [n8n-docs](https://github.com/n8n-io/n8n-docs) repos, and
105 | - submit a sample workflow to the [n8n.io](https://n8n.io/workflows) collection.
106 |
107 | |  |
108 | | :---------------------------------------------------------------------------------------------------------------: |
109 | | Node files auto-placed in `n8n` repo and workflow auto-submitted on [n8n.io](https://n8n.io/workflows) |
110 |
111 | ## Authors
112 |
113 | © 2020 [Iván Ovejero](https://github.com/ivov) and [Erin McNulty](https://github.com/erin2722)
114 |
115 | ## License
116 |
117 | Distributed under the MIT License. See [LICENSE.md](LICENSE.md).
118 |
--------------------------------------------------------------------------------
/_templates/gen/generateGenericFunctions/GenericFunctions.ejs.gen:
--------------------------------------------------------------------------------
1 | ---
2 | to: output/GenericFunctions.ts
3 | mainParameters: <%= mainParameters %>
4 | metaParameters: <%= metaParameters %>
5 | ---
6 | <%_
7 | mainParameters = JSON.parse(mainParameters);
8 | metaParameters = JSON.parse(metaParameters);
9 | _%>
10 | import {
11 | IExecuteFunctions,
12 | IHookFunctions,
13 | } from 'n8n-core';
14 |
15 | import {
16 | IDataObject,
17 | } from 'n8n-workflow';
18 |
19 | import {
20 | OptionsWithUri,
21 | } from 'request';
22 |
23 |
24 | /**
25 | * Make an API request to <%= metaParameters.serviceName %>
26 | *
27 | * @param {IHookFunctions | IExecuteFunctions} this
28 | * @param {string} method
29 | * @param {string} endpoint
30 | * @param {IDataObject} body
31 | * @param {IDataObject} qs
32 | * @param {string} [uri]
33 | * @param {IDataObject} [headers]
34 | * @returns {Promise}
35 | */
36 | export async function <%= h.camelify(metaParameters.serviceName) %>ApiRequest(
37 | this: IHookFunctions | IExecuteFunctions,
38 | method: string,
39 | endpoint: string,
40 | body: IDataObject,
41 | qs: IDataObject,
42 | uri?: string,
43 | headers?: IDataObject,
44 | ): Promise { // tslint:disable-line:no-any
45 |
46 | const options: OptionsWithUri = {
47 | headers: {},
48 | body,
49 | method,
50 | qs,
51 | uri: uri || `<%= metaParameters.apiUrl %>${endpoint}`,
52 | json: true,
53 | };
54 |
55 | try {
56 | <%_ if (metaParameters.authType !== "None") { _%>
57 |
58 | const credentials = this.getCredentials('<%= h.getCredentialsString(h.camelify(metaParameters.serviceName), metaParameters.authType) %>');
59 |
60 | if (credentials === undefined) {
61 | throw new Error('No credentials got returned!');
62 | }
63 | <%_ } _%>
64 |
65 | if (Object.keys(headers).length !== 0) {
66 | options.headers = Object.assign({}, options.headers, headers);
67 | }
68 |
69 | if (Object.keys(body).length === 0) {
70 | delete options.body;
71 | }
72 |
73 | <%_ if (metaParameters.authType === "OAuth2") { _%>
74 | return await this.helpers.requestOAuth2.call(this, '<%= h.getCredentialsString(h.camelify(metaParameters.serviceName), metaParameters.auth) %>', options);
75 | <%_ } else { _%>
76 | return await this.helpers.request!(options);
77 | <%_ } _%>
78 |
79 | } catch (error) {
80 |
81 | // TODO: Replace TODO_ERROR_STATUS_CODE and TODO_ERROR_MESSAGE based on the error object returned by API.
82 |
83 | <%_ if (metaParameters.authType !== "None") { _%>
84 | if (TODO_ERROR_STATUS_CODE === 401) {
85 | // Return a clear error
86 | throw new Error('The <%= metaParameters.serviceName %> credentials are invalid!');
87 | }
88 |
89 | <%_ } _%>
90 | if (TODO_ERROR_MESSAGE) {
91 | // Try to return the error prettier
92 | throw new Error(`<%= metaParameters.serviceName %> error response [${TODO_ERROR_STATUS_CODE}]: ${TODO_ERROR_MESSAGE}`);
93 | }
94 |
95 | // If that data does not exist for some reason, return the actual error.
96 | throw error;
97 | }
98 | }
99 |
100 |
101 | /**
102 | * Make an API request to <%= metaParameters.serviceName %> and return all results
103 | *
104 | * @export
105 | * @param {IHookFunctions | IExecuteFunctions} this
106 | * @param {string} method
107 | * @param {string} endpoint
108 | * @param {IDataObject} body
109 | * @param {IDataObject} qs
110 | * @returns {Promise}
111 | */
112 | export async function <%= h.camelify(metaParameters.serviceName) %>ApiRequestAllItems(
113 | this: IHookFunctions | IExecuteFunctions,
114 | propertyName: string,
115 | method: string,
116 | endpoint: string,
117 | body: IDataObject,
118 | qs: IDataObject
119 | ): Promise { // tslint:disable-line:no-any
120 |
121 | const returnData: IDataObject[] = [];
122 | let responseData: any;
123 |
124 | do {
125 | responseData = await <%= h.camelify(metaParameters.serviceName) %>ApiRequest.call(this, method, endpoint, body, qs);
126 | // TODO: Get next page using `responseData` or `qs`
127 | returnData.push.apply(returnData, responseData[propertyName]);
128 |
129 | } while (
130 | // TODO: Add condition for total not yet reached
131 | );
132 |
133 | return returnData;
134 | }
135 |
--------------------------------------------------------------------------------
/_templates/gen/generateKeyCredential/KeyCredential.ejs.gen:
--------------------------------------------------------------------------------
1 | ---
2 | to: output/<%= serviceCredential %>.credentials.ts
3 | serviceCredential: <%= serviceCredential %>
4 | ---
5 | import {
6 | ICredentialType,
7 | NodePropertyTypes,
8 | } from 'n8n-workflow';
9 |
10 |
11 | export class <%= serviceCredential %> implements ICredentialType {
12 | name = '<%= serviceCredential %>';
13 | extends = [
14 | 'oAuth2Api'
15 | ];
16 | displayName = '<%= name %> OAuth2 API';
17 | properties = [
18 | {
19 | displayName: 'API Key',
20 | // name: '...', // TODO: Fill in key parameter name per API
21 | type: 'string' as NodePropertyTypes,
22 | default: '',
23 | }
24 | ];
25 | }
--------------------------------------------------------------------------------
/_templates/gen/generateNodeCredentialDocs/NodeCredentialDocs.ejs.gen:
--------------------------------------------------------------------------------
1 | ---
2 | to: output/<%= name.replace(/ /g, "") %>Credentials.md
3 | docsParameters: <%= docsParameters %>
4 | metaParameters: <%= metaParameters %>
5 | ---
6 | <%_
7 | docsParameters = JSON.parse(docsParameters);
8 | metaParameters = JSON.parse(metaParameters);
9 | filename = name.replace(/ /g, "")
10 | _%>
11 | ---
12 | permalink: /nodes/n8n-nodes-base.<%= h.camelify(docsParameters.serviceName) %>
13 | ---
14 |
15 | # <%= docsParameters.serviceName %>
16 |
17 | You can find information about the operations supported by the <%= docsParameters.serviceName %> node on the [integrations](https://n8n.io/integrations/n8n-nodes-base.<%= h.camelify(docsParameters.serviceName) %>) page. You can also browse the source code of the node on [GitHub](https://github.com/n8n-io/n8n/tree/master/packages/nodes-base/nodes/<%= docsParameters.serviceName.replace(/ /g, "") %>).
18 |
19 | ## Pre-requisites
20 |
21 | Create a [<%= name %>](<%= docsParameters.serviceUrl %>) account.
22 |
23 | <%_ if (metaParameters.authType === "API Key") { _%>
24 |
25 | ## Using Access Token
26 |
27 |
28 | <%_ } else if (metaParameters.authType === "OAuth2") { _%>
29 | ## Using OAuth
30 |
31 |
32 | <%_ } _%>
--------------------------------------------------------------------------------
/_templates/gen/generateNodeMainDocs/NodeMainDocs.ejs.gen:
--------------------------------------------------------------------------------
1 | ---
2 | to: output/<%= name.replace(/ /g, "") %>.md
3 | docsParameters: <%= docsParameters %>
4 | nodeOperations: <%= nodeOperations %>
5 | ---
6 | <%_
7 | docsParameters = JSON.parse(docsParameters);
8 | nodeOperations = JSON.parse(nodeOperations);
9 | filename = name.replace(/ /g, "")
10 | _%>
11 | ---
12 | permalink: /nodes/n8n-nodes-base.<%= h.camelify(docsParameters.serviceName) %>
13 | ---
14 |
15 | # <%= docsParameters.serviceName %>
16 |
17 | [<%= docsParameters.serviceName %>](<%= docsParameters.serviceUrl %>) is <%= docsParameters.introDescription %>.
18 |
19 | ::: tip 🔑 Credentials
20 | You can find authentication information for this node [here](../../../credentials/<%= filename %>/README.md).
21 | :::
22 |
23 | ## Basic Operations
24 |
25 | <%_ Object.keys(nodeOperations).forEach((resource) => { _%>
26 | - <%= resource %>
27 | <%_ for (let operation of nodeOperations[resource]) { _%>
28 | - <%= operation %>
29 | <%_ } _%>
30 | <%_ }); _%>
31 |
32 | ## Example Usage
33 |
34 | This workflow allows you to <%= docsParameters.exampleUsage %>. You can also find the [workflow](<%= docsParameters.workflowUrl %>) on this website. This example usage workflow would use the following two nodes.
35 | - [Start](../../core-nodes/Start/README.md)
36 | - [<%= docsParameters.serviceName %>]()
37 |
38 | The final workflow should look like the following image.
39 |
40 | 
41 |
42 | ### 1. Start node
43 |
44 | The start node exists by default when you create a new workflow.
45 |
46 | ### 2. <%= docsParameters.serviceName %> node
47 |
48 | 1. First of all, you'll have to enter credentials for the <%= docsParameters.serviceName %> node. You can find out how to do that [here](../../../credentials/<%= docsParameters.serviceName %>/README.md).
49 |
50 | X. Click on *Execute Node* to run the workflow.
51 |
52 |
--------------------------------------------------------------------------------
/_templates/gen/generateOAuth2Credential/OAuth2Credential.ejs.gen:
--------------------------------------------------------------------------------
1 | ---
2 | to: output/<%= serviceCredential %>.credentials.ts
3 | serviceCredential: <%= serviceCredential %>
4 | ---
5 | import {
6 | ICredentialType,
7 | NodePropertyTypes,
8 | } from 'n8n-workflow';
9 |
10 |
11 | export class <%= serviceCredential %> implements ICredentialType {
12 | name = '<%= serviceCredential %>';
13 | extends = [
14 | 'oAuth2Api'
15 | ];
16 | displayName = '<%= name %> OAuth2 API';
17 | properties = [
18 | {
19 | displayName: 'Authorization URL',
20 | name: 'authUrl',
21 | type: 'hidden' as NodePropertyTypes,
22 | // default: '...', // TODO: Fill in authorization URL
23 | },
24 | {
25 | displayName: 'Access Token URL',
26 | name: 'accessTokenUrl',
27 | type: 'hidden' as NodePropertyTypes,
28 | // default: '...', // TODO: Fill in access token URL
29 | },
30 | {
31 | displayName: 'Scope',
32 | name: 'scope',
33 | type: 'hidden' as NodePropertyTypes,
34 | // default: '...', // TODO: Fill in default scopes
35 | },
36 | {
37 | displayName: 'Authentication',
38 | name: 'authentication',
39 | type: 'hidden' as NodePropertyTypes,
40 | // default: '...', // TODO: Select 'header' or 'body'
41 | },
42 | // TODO: Select auth query parameters if necessary
43 | // {
44 | // displayName: 'Auth URI Query Parameters',
45 | // name: 'authQueryParameters',
46 | // type: 'hidden' as NodePropertyTypes,
47 | // default: 'response_type=code',
48 | // },
49 | // {
50 | // displayName: 'Auth URI Query Parameters',
51 | // name: 'authQueryParameters',
52 | // type: 'hidden' as NodePropertyTypes,
53 | // default: 'grant_type=authorization_code',
54 | // },
55 | ];
56 | }
--------------------------------------------------------------------------------
/_templates/gen/generateRegularNodeComplex/RegularNodeComplex.ejs.gen:
--------------------------------------------------------------------------------
1 | ---
2 | to: output/<%= name.replace(/\s/g, "") %>.node.ts
3 | mainParameters: <%= mainParameters %>
4 | metaParameters: <%= metaParameters %>
5 | ---
6 | <%_
7 | mainParameters = JSON.parse(mainParameters);
8 | metaParameters = JSON.parse(metaParameters);
9 | [operations] = Object.values(mainParameters);
10 | _%>
11 | import {
12 | IExecuteFunctions,
13 | } from 'n8n-core';
14 |
15 | import {
16 | INodeExecutionData,
17 | INodeType,
18 | INodeTypeDescription,
19 | IDataObject,
20 | } from 'n8n-workflow';
21 |
22 | import {
23 | <%= h.camelify(metaParameters.serviceName) %>ApiRequest,
24 | <%_ if (h.hasGetAll(operations)) { _%>
25 | <%= h.camelify(name) %>ApiRequestAllItems,
26 | <%_ } _%>
27 | } from './GenericFunctions';
28 |
29 | <%_ Object.keys(mainParameters).forEach((resource) => { _%>
30 | import {
31 | <%= h.camelify(resource) %>Operations,
32 | <%= h.camelify(resource) %>Fields,
33 | } from './<%= h.camelify(resource) %>Description';
34 |
35 | <%_ }) _%>
36 |
37 | export class <%= h.classify(metaParameters.serviceName) %> implements INodeType {
38 | description: INodeTypeDescription = {
39 | displayName: '<%= metaParameters.serviceName %>',
40 | name: '<%= h.camelify(metaParameters.serviceName) %>',
41 | icon: 'file:<%= h.camelify(metaParameters.serviceName) %>.png',
42 | group: ['transform'],
43 | version: 1,
44 | subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
45 | description: 'Consume <%= metaParameters.serviceName %> API',
46 | defaults: {
47 | name: '<%= metaParameters.serviceName %>',
48 | color: '<%= metaParameters.nodeColor %>',
49 | },
50 | inputs: ['main'],
51 | outputs: ['main'],
52 | properties: [
53 | // ----------------------------------
54 | // Resources
55 | // ----------------------------------
56 | {
57 | displayName: 'Resource',
58 | name: 'resource',
59 | type: 'options',
60 | options: [
61 | <%_ for (let resource of Object.keys(mainParameters)) { _%>
62 | {
63 | name: '<%= resource %>',
64 | value: '<%= h.camelify(resource) %>',
65 | },
66 | <%_ } _%>
67 | ],
68 | default: '<%= Object.keys(mainParameters)[0].toLowerCase() %>',
69 | description: 'Resource to consume',
70 | },
71 | // ----------------------------------
72 | // Operations
73 | // ----------------------------------
74 | <%_ Object.keys(mainParameters).forEach((resource) => { _%>
75 | ...<%= h.camelify(resource) %>Operations,
76 | ...<%= h.camelify(resource) %>Fields,
77 | <%_ }) _%>
78 | ],
79 | };
80 |
81 | async execute(this: IExecuteFunctions): Promise {
82 | const items = this.getInputData();
83 | const returnData: IDataObject[] = [];
84 |
85 | const resource = this.getNodeParameter('resource', 0) as string;
86 | const operation = this.getNodeParameter('operation', 0) as string;
87 |
88 | let responseData: any;
89 |
90 | for (let i = 0; i < items.length; i++) {
91 |
92 | let qs: IDataObject = {};
93 | let body: IDataObject = {};
94 |
95 | <%_ Object.keys(mainParameters).forEach(function(resource) { _%>
96 | <%_ if (Object.keys(mainParameters).indexOf(resource) !== 0) { _%>
97 | } else if (resource === '<%= h.camelify(resource) %>') {
98 |
99 | <%_ } else { _%>
100 | if (resource === '<%= h.camelify(resource) %>') {
101 |
102 | <%_ } _%>
103 | <%_ for (let operation of mainParameters[resource]) { _%>
104 | <%_ if (mainParameters[resource].indexOf(operation) !== 0) { _%>
105 |
106 | } else if (operation === '<%= h.camelify(operation.name) %>') {
107 | <%_ } else { _%>
108 | if (operation === '<%= h.camelify(operation.name) %>') {
109 | <%_ } _%>
110 |
111 | <%_ if (h.hasEndpointVariable(operation.endpoint)) { _%>
112 | const <%= h.getVariableFromEndpoint(operation.endpoint) %> = this.getNodeParameter('<%= h.getVariableFromEndpoint(operation.endpoint) %>', i);
113 | <%_ } _%>
114 | const endpoint =<%_ if (h.hasEndpointVariable(operation.endpoint)) { _%>
115 | <%_ %> `<%= h.fixEndpoint(operation.endpoint) _%>`;
116 | <%_ } else { _%>
117 | <%_ %> '<%= operation.endpoint _%>';
118 | <%_ } _%>
119 |
120 | <%_ if (h.hasAdditionalFields(operation)) { _%>
121 | const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject;
122 | // TODO: Use additionalFields.fieldName in `qs` or in `body` or as boolean flag
123 | <%_ } _%>
124 |
125 | <%_ if (operation.name === "Get All") { _%>
126 | // TODO: Replace TODO_PROPERTY_NAME with the name of the property whose value is all the items
127 | responseData = await <%= h.camelify(metaParameters.serviceName) %>ApiRequestAllItems.call(this, TODO_PROPERTY_NAME, '<%= operation.requestMethod %>', endpoint, body, qs);
128 | <%_ } else if (h.isRetrieval(operation)) { _%>
129 | responseData = await <%= h.camelify(metaParameters.serviceName) %>ApiRequest.call(this, '<%= operation.requestMethod %>', endpoint, body, qs);
130 | <%_ } else { _%>
131 | await <%= h.camelify(metaParameters.serviceName) %>ApiRequest.call(this, '<%= operation.requestMethod %>', endpoint, body, qs);
132 | responseData = { success: true };
133 | <%_ } _%>
134 | <%_ if (mainParameters[resource].indexOf(operation) + 1 === mainParameters[resource].length) { _%>
135 |
136 | } else {
137 | throw new Error(`The operation ${operation} is not known!`);
138 | }
139 | <%_ } else { _%>
140 | <%_ } _%>
141 | <%_ } _%>
142 | <%_ if (Object.keys(mainParameters).indexOf(resource) + 1 === Object.keys(mainParameters).length) { _%>
143 |
144 | } else {
145 | throw new Error(`The resource ${resource} is not known!`);
146 | }
147 | <%_ } _%>
148 |
149 | <%_ }); _%>
150 | if (Array.isArray(responseData)) {
151 | returnData.push.apply(returnData, responseData as IDataObject[]);
152 | } else {
153 | returnData.push(responseData as IDataObject);
154 | }
155 |
156 | }
157 |
158 | return [this.helpers.returnJsonArray(returnData)];
159 | }
160 | }
161 |
162 |
163 |
--------------------------------------------------------------------------------
/_templates/gen/generateResourceDescription/ResourceDescription.ejs.gen:
--------------------------------------------------------------------------------
1 | ---
2 | to: output/<%= resourceName.replace(/\s/g, "") %>Description.ts
3 | resourceObject: <%= resourceObject %>
4 | ---
5 | <%_ resourceObject = JSON.parse(resourceObject); _%>
6 | import {
7 | INodeProperties,
8 | } from 'n8n-workflow';
9 |
10 | export const <%= h.camelify(resourceName) %>Operations = [
11 | {
12 | displayName: 'Operation',
13 | name: 'operation',
14 | type: 'options',
15 | displayOptions: {
16 | show: {
17 | resource: [
18 | '<%= h.camelify(resourceName) %>',
19 | ],
20 | },
21 | },
22 | options: [
23 | <%_ for (let operation of resourceObject) { _%>
24 | {
25 | name: '<%= operation.name %>',
26 | value: '<%= h.camelify(operation.name) %>',
27 | description: '<%= operation.description %>',
28 | },
29 | <%_ } _%>
30 | ],
31 | default: '<%= h.camelify(resourceObject[0].name) %>',
32 | description: 'Operation to perform',
33 | },
34 | ] as INodeProperties[];
35 |
36 | export const <%= h.camelify(resourceName) %>Fields = [
37 | <%_ for (let operation of resourceObject) { _%>
38 | <%_ for (let field of operation.fields) { _%>
39 | {
40 | displayName: '<%= field.name %>',
41 | name: '<%= field.name %>',
42 | <%_ if (field.name !== 'Additional Fields') { _%>
43 | description: '<%= field.description %>',
44 | <%_ } _%>
45 | type: '<%= field.type %>',
46 | <%_ if (h.hasNumericalLimits(field)) { _%>
47 | typeOptions: {
48 | minValue: <%= field.numericalLimits.minLimit %>,
49 | maxValue: <%= field.numericalLimits.maxLimit %>
50 | },
51 | <%_ } _%>
52 | required: <%= field.name !== 'additionalFields' ? true : false %>,
53 | default: <%_ if (field.default === true || field.default === false || typeof field.default === "number") { _%>
54 | <%_ %> <%= field.default _%>,
55 | <%_ } else if (typeof field.default === 'object' && field.default !== null) { _%>
56 | <%_ %> <%= JSON.stringify(field.default) _%>,
57 | <%_ } else { _%>
58 | <%_ %> '<%= field.default _%>',
59 | <%_ } _%>
60 | displayOptions: {
61 | show: {
62 | resource: [
63 | '<%= h.camelify(resourceName) %>',
64 | ],
65 | operation: [
66 | '<%= h.camelify(operation.name) %>',
67 | ],
68 | <%_ if (field.extraDisplayRestriction) { _%>
69 | <%= h.camelify(Object.keys(field.extraDisplayRestriction).toString()) %>: [
70 | <%= Object.values(field.extraDisplayRestriction) %>,
71 | ]
72 | <%_ } _%>
73 | },
74 | },
75 | <%_ if (h.isCollective(field)) { _%>
76 | options: [
77 | <%_ for (let option of field.options) { _%>
78 | {
79 | name: '<%= option.name %>',
80 | description: '<%= option.description %>',
81 | type: '<%= option.type %>',
82 | default: <%_ if (typeof option.default === "boolean" || typeof option.default === "number") { _%>
83 | <%_ %> <%= option.default %>,
84 | <%_ } else if (typeof option.default === 'object' && option.default !== null) { _%>
85 | <%_ %> <%= JSON.stringify(option.default) %>,
86 | <%_ } else { _%>
87 | <%_ %> '<%= option.default _%>',
88 | <%_ } _%>
89 | <%_ if (h.isCollective(option)) { _%>
90 | options: [
91 | <%_ for (let suboption of option.options) { _%>
92 | {
93 | name: '<%= suboption.name %>',
94 | description: '<%= suboption.description %>',
95 | type: '<%= suboption.type %>',
96 | default: <%_ if (typeof suboption.default === "boolean" || typeof suboption.default === "number") { _%>
97 | <%_ %> <%= suboption.default %>,
98 | <%_ } else if (typeof suboption.default === 'object' && suboption.default !== null) { _%>
99 | <%_ %> <%= JSON.stringify(suboption.default) %>,
100 | <%_ } else { _%>
101 | <%_ %> '<%= suboption.default %>',
102 | <%_ } _%>
103 | <%_ if (h.isCollective(suboption)) { _%>
104 | options: [
105 | <%_ for (let maxNestedOption of suboption.options) { _%>
106 | {
107 | name: '<%= maxNestedOption.name %>',
108 | description: '<%= maxNestedOption.description %>',
109 | },
110 | <%_ } _%>
111 | ],
112 | <%_ } _%>
113 | },
114 | <%_ } _%>
115 | ],
116 | <%_ } _%>
117 | },
118 | <%_ } _%>
119 | ],
120 | <%_ } _%>
121 | },
122 | <%_ } _%>
123 | <%_ } _%>
124 | ] as INodeProperties[];
--------------------------------------------------------------------------------
/_templates/gen/generateTriggerNodeSimple/TriggerNodeSimple.ejs.gen:
--------------------------------------------------------------------------------
1 | ---
2 | to: output/<%= name.replace(/\s/g, "") %>Trigger.node.ts
3 | mainParameters: <%= mainParameters %>
4 | metaParameters: <%= metaParameters %>
5 | ---
6 | <%_
7 | mainParameters = JSON.parse(mainParameters);
8 | metaParameters = JSON.parse(metaParameters);
9 | _%>
10 | import {
11 | IHookFunctions,
12 | IWebhookFunctions,
13 | } from 'n8n-core';
14 |
15 | import {
16 | IWebhookResponseData,
17 | INodeType,
18 | INodeTypeDescription,
19 | IDataObject,
20 | } from 'n8n-workflow';
21 |
22 | import {
23 | <%= h.camelify(metaParameters.serviceName) %>ApiRequest,
24 | } from './GenericFunctions';
25 |
26 | export class <%= h.classify(metaParameters.serviceName) %> implements INodeType {
27 | description: INodeTypeDescription = {
28 | displayName: '<%= metaParameters.serviceName %> Trigger',
29 | name: '<%= h.camelify(metaParameters.serviceName) %>Trigger',
30 | icon: 'file:<%= h.camelify(metaParameters.serviceName) %>.png',
31 | group: ['trigger'],
32 | version: 1,
33 | subtitle: '={{$parameter["event"]}}',
34 | description: 'Handle <%= metaParameters.serviceName %> events via webhooks',
35 | defaults: {
36 | name: '<%= metaParameters.serviceName %>',
37 | color: '<%= metaParameters.nodeColor %>',
38 | },
39 | inputs: [],
40 | outputs: ['main'],
41 | credentials: [
42 | {
43 | name: '<%= h.getCredentialsString(h.camelify(metaParameters.serviceName), metaParameters.authType) %>',
44 | required: true,
45 | }
46 | ],
47 | webhooks: [
48 | {
49 | name: 'default',
50 | httpMethod: 'POST',
51 | responseMode: 'onReceived',
52 | path: 'webhook',
53 | },
54 | ],
55 | properties: [
56 | <%_ for (let property of mainParameters.webhookProperties) { _%>
57 | {
58 | displayName: '<%= property.displayName %>',
59 | name: '<%= property.name %>',
60 | type: '<%= property.type %>',
61 | required: <%= property.required %>,
62 | default: <%_ if (property.default === true || property.default === false || typeof property.default === "number") { _%>
63 | <%_ %> <%= property.default _%>,
64 | <%_ } else if (typeof property.default === 'object' && property.default !== null) { _%>
65 | <%_ %> <%= JSON.stringify(property.default) _%>,
66 | <%_ } else { _%>
67 | <%_ %> '<%= property.default _%>',
68 | <%_ } _%>
69 | description: '<%= property.description %>',
70 | options: [
71 | <%_ for (let option of property.options) { _%>
72 | {
73 | name: '<%= option.name %>',
74 | value: '<%= option.value %>',
75 | description: '<%= option.description %>',
76 | },
77 | <%_ } _%>
78 | ],
79 | },
80 | <%_ } _%>
81 | <%_ for (let property of mainParameters.webhookProperties) { _%>
82 | <%_ if (property.options !== undefined) { _%>
83 | <%_ for (let option of property.options) { _%>
84 | <%_ if (option.fields !== undefined) { _%>
85 | <%_ for (let field of option.fields) { _%>
86 | {
87 | displayName: '<%= field.name %>',
88 | name: '<%= field.name %>',
89 | description: '<%= field.description %>',
90 | type: '<%= field.type %>',
91 | required: <%= property.required %>,
92 | default: <%_ if (field.default === true || field.default === false || typeof field.default === "number") { _%>
93 | <%_ %> <%= field.default _%>,
94 | <%_ } else if (typeof field.default === 'object' && field.default !== null) { _%>
95 | <%_ %> <%= JSON.stringify(field.default) _%>,
96 | <%_ } else { _%>
97 | <%_ %> '<%= field.default _%>',
98 | <%_ } _%>
99 | displayOptions: {
100 | show: {
101 | event: [
102 | '<%= option.value %>',
103 | ],
104 | },
105 | },
106 | },
107 | <%_ } _%>
108 | <%_ } _%>
109 | <%_ } _%>
110 | <%_ } _%>
111 | <%_ } _%>
112 | ],
113 | };
114 |
115 | // @ts-ignore (because of request)
116 | webhookMethods = {
117 | default: {
118 | async checkExists(this: IHookFunctions): Promise {
119 | const webhookData = this.getWorkflowStaticData('node');
120 |
121 | if (webhookData.webhookId === undefined) {
122 | // No webhook id is set, so no webhook can exist
123 | return false;
124 | }
125 |
126 | // webhook was created before, so check if it still exists
127 | const endpoint = `webhooks/${webhookData.webhookId}`; // TODO
128 |
129 | try {
130 | await <%= h.camelify(metaParameters.serviceName) %>ApiRequest.call(this, 'GET', endpoint, {});
131 | } catch (error) {
132 | if (error.statusCode === 404) {
133 | // webhook does not exist
134 | delete webhookData.webhookId;
135 | return false;
136 | }
137 |
138 | // some error occured
139 | throw error;
140 | }
141 |
142 | // if no error, then the webhook exists
143 | return true;
144 | },
145 |
146 | async create(this: IHookFunctions): Promise {
147 | let webhook;
148 | const webhookUrl = this.getNodeWebhookUrl('default');
149 | <%_ for (let property of mainParameters.webhookProperties) { _%>
150 | const <%= property.name %> = this.getNodeParameter('<%= property.name %>', 0);
151 | <%_ } _%>
152 |
153 | const endpoint = '<%= mainParameters.webhookEndpoint %>';
154 |
155 | const qs: IDataObject = {};
156 |
157 | try {
158 | // TODO: Use node parameters in `qs` or in `body` or as boolean flag
159 | webhook = await <%= h.camelify(metaParameters.serviceName) %>ApiRequest.call(this, 'POST', endpoint, {}, qs);
160 | } catch (error) {
161 | throw error;
162 | }
163 |
164 | // TODO: Replace TODO_PROPERTY_NAME with the name of the property
165 | // that, if missing, indicates that webhook creation has failed
166 | if (TODO_PROPERTY_NAME === undefined) {
167 | return false;
168 | }
169 |
170 | const webhookData = this.getWorkflowStaticData('node');
171 | webhookData.webhookId = webhook.rule.id as string; // TODO
172 | webhookData.events = event; // TODO
173 | return true;
174 | },
175 |
176 | async delete(this: IHookFunctions): Promise {
177 | const webhookData = this.getWorkflowStaticData('node');
178 |
179 | if (webhookData.webhookId !== undefined) {
180 | const endpoint = `/automations/hooks/${webhookData.webhookId}`;
181 |
182 | try {
183 | await <%= h.camelify(metaParameters.serviceName) %>ApiRequest.call(this, 'DELETE', endpoint, {}, {});
184 | } catch (error) {
185 | return false;
186 | }
187 |
188 | // Remove properties from static workflow data to
189 | // make it clear that no webhooks are registred anymore
190 | delete webhookData.webhookId;
191 | delete webhookData.events;
192 | }
193 | return true;
194 | },
195 | },
196 | };
197 |
198 | async webhook(this: IWebhookFunctions): Promise {
199 | const returnData: IDataObject[] = [];
200 | returnData.push(this.getBodyData());
201 |
202 | return {
203 | workflowData: [
204 | this.helpers.returnJsonArray(returnData),
205 | ],
206 | };
207 | }
208 | }
209 |
--------------------------------------------------------------------------------
/_templates/gen/updateCredentialPackageJson/packageJsonCredential.ejs.inj:
--------------------------------------------------------------------------------
1 | ---
2 | inject: true
3 | credentialSpot: <%= credentialSpot %>
4 | serviceCredential: <%= serviceCredential %>
5 | to: output/package.json
6 | before: \s+"dist\/credentials\/<%= credentialSpot %>.credentials.js",
7 | ---
8 | "dist/credentials/<%= serviceCredential %>.credentials.js",
--------------------------------------------------------------------------------
/_templates/gen/updateNodePackageJson/packageJsonNode.ejs.inj:
--------------------------------------------------------------------------------
1 | ---
2 | inject: true
3 | nodeSpot: <%= nodeSpot %>
4 | serviceName: <%= serviceName %>
5 | to: output/package.json
6 | before: \s+"dist\/nodes\/<%= nodeSpot %>.node.js",
7 | ---
8 | "dist/nodes/<%= serviceName %>/<%= serviceName %>.node.js",
--------------------------------------------------------------------------------
/build/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/build/.gitkeep
--------------------------------------------------------------------------------
/client/.browserslistrc:
--------------------------------------------------------------------------------
1 | > 1%
2 | last 2 versions
3 | not dead
4 |
--------------------------------------------------------------------------------
/client/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: {
4 | node: true
5 | },
6 | 'extends': [
7 | 'plugin:vue/essential',
8 | 'eslint:recommended',
9 | '@vue/typescript'
10 | ],
11 | parserOptions: {
12 | parser: '@typescript-eslint/parser'
13 | },
14 | rules: {
15 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
16 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 |
5 | # local env files
6 | .env.local
7 | .env.*.local
8 |
9 | # Log files
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 | pnpm-debug.log*
14 |
15 | # Editor directories and files
16 | .idea
17 | .vscode
18 | *.suo
19 | *.ntvs*
20 | *.njsproj
21 | *.sln
22 | *.sw?
23 |
24 | #Electron-builder output
25 | /dist_electron
--------------------------------------------------------------------------------
/client/README.md:
--------------------------------------------------------------------------------
1 | # client
2 |
3 | ## Project setup
4 | ```
5 | npm install
6 | ```
7 |
8 | ### Compiles and hot-reloads for development
9 | ```
10 | npm run serve
11 | ```
12 |
13 | ### Compiles and minifies for production
14 | ```
15 | npm run build
16 | ```
17 |
18 | ### Lints and fixes files
19 | ```
20 | npm run lint
21 | ```
22 |
23 | ### Customize configuration
24 | See [Configuration Reference](https://cli.vuejs.org/config/).
25 |
--------------------------------------------------------------------------------
/client/Requester.ts:
--------------------------------------------------------------------------------
1 | const { ipcRenderer } = window.require("electron");
2 |
3 | /**Responsible for receiving a request through a channel and returning a response through that same channel.*/
4 | export default class Requester {
5 | // prettier-ignore
6 | public request(channel: string, argument?: T): Promise> {
7 | ipcRenderer.send(channel, argument);
8 |
9 | return new Promise((resolve) => {
10 | ipcRenderer.on(channel, (event, response) => resolve(response));
11 | });
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/client/assests/n8n.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/client/assests/n8n.png
--------------------------------------------------------------------------------
/client/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | '@vue/cli-plugin-babel/preset'
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/client/channels/DocsgenChannel.ts:
--------------------------------------------------------------------------------
1 | import { IpcMainEvent } from "electron";
2 | import IpcChannel from "./IpcChannel.interface";
3 | import NodeDocsGenerator from "../../generators/NodeDocsGenerator";
4 |
5 | export default class DocsgenChannel implements IpcChannel {
6 | public name = "docsgen-channel";
7 |
8 | public async handle(event: IpcMainEvent, paramsBundle: DocsgenParamsBundle) {
9 | process.chdir("..");
10 | const generator = new NodeDocsGenerator(paramsBundle);
11 | const result = await generator.run();
12 | process.chdir("client");
13 |
14 | event.sender.send(this.name, result);
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/client/channels/EmptyChannel.ts:
--------------------------------------------------------------------------------
1 | import { IpcMainEvent } from "electron";
2 | import IpcChannel from "./IpcChannel.interface";
3 | import DirectoryEmptier from "../../services/DirectoryEmptier";
4 |
5 | export default class EmptyChannel implements IpcChannel {
6 | public name = "empty-channel";
7 |
8 | public async handle(event: IpcMainEvent) {
9 | process.chdir("..");
10 | const emptier = new DirectoryEmptier();
11 | const result = await emptier.run();
12 | process.chdir("client");
13 |
14 | event.sender.send(this.name, result);
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/client/channels/ExampleChannel.ts:
--------------------------------------------------------------------------------
1 | import { IpcMainEvent } from "electron";
2 | import IpcChannel from "./IpcChannel.interface";
3 |
4 | export default class ExampleChannel implements IpcChannel {
5 | public name = "example-channel";
6 |
7 | public async handle(event: IpcMainEvent) {
8 | event.sender.send(this.name, "hello my name is erin");
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/client/channels/IpcChannel.interface.ts:
--------------------------------------------------------------------------------
1 | import { IpcMainEvent } from "electron";
2 |
3 | export default interface IpcChannel {
4 | name: string;
5 | handle(event: IpcMainEvent, argument?: any): Promise;
6 | }
7 |
--------------------------------------------------------------------------------
/client/channels/NodegenChannel.ts:
--------------------------------------------------------------------------------
1 | import { IpcMainEvent } from "electron";
2 | import IpcChannel from "./IpcChannel.interface";
3 | import NodeFilesGenerator from "../../generators/NodeFilesGenerator";
4 |
5 | export default class NodegenChannel implements IpcChannel {
6 | public name = "nodegen-channel";
7 |
8 | public async handle(event: IpcMainEvent, paramsBundle: NodegenParamsBundle) {
9 | process.chdir("..");
10 | const generator = new NodeFilesGenerator(paramsBundle);
11 | const result = await generator.run();
12 | process.chdir("client");
13 |
14 | event.sender.send(this.name, result);
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/client/channels/PackgenChannel.ts:
--------------------------------------------------------------------------------
1 | import { IpcMainEvent } from "electron";
2 | import IpcChannel from "./IpcChannel.interface";
3 | import PackageJsonGenerator from "../../generators/PackageJsonGenerator";
4 |
5 | export default class PackgenChannel implements IpcChannel {
6 | public name = "packgen-channel";
7 |
8 | public async handle(event: IpcMainEvent, metaParameters: MetaParameters) {
9 | process.chdir("..");
10 | const generator = new PackageJsonGenerator(metaParameters);
11 | const result = await generator.run();
12 | process.chdir("client");
13 |
14 | event.sender.send(this.name, result);
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/client/channels/PlacementChannel.ts:
--------------------------------------------------------------------------------
1 | import { IpcMainEvent } from "electron";
2 | import IpcChannel from "./IpcChannel.interface";
3 | import FilePlacer from "../../services/FilePlacer";
4 |
5 | export default class PlacementChannel implements IpcChannel {
6 | public name = "placement-channel";
7 |
8 | public async handle(
9 | event: IpcMainEvent,
10 | { filesToPlace }: PlacementChannelArgument
11 | ) {
12 | process.chdir("..");
13 |
14 | const filePlacer = new FilePlacer();
15 |
16 | const result =
17 | filesToPlace === "functionality"
18 | ? await filePlacer.placeNodeFunctionalityFiles()
19 | : await filePlacer.placeNodeDocumentationFiles();
20 |
21 | process.chdir("client");
22 |
23 | event.sender.send(this.name, result);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "serve": "vue-cli-service serve",
7 | "build": "vue-cli-service build",
8 | "lint": "vue-cli-service lint",
9 | "electron:build": "vue-cli-service electron:build",
10 | "electron:serve": "vue-cli-service electron:serve",
11 | "postinstall": "electron-builder install-app-deps",
12 | "postuninstall": "electron-builder install-app-deps"
13 | },
14 | "main": "background.js",
15 | "dependencies": {
16 | "core-js": "^3.6.5",
17 | "vue": "^2.6.11",
18 | "vue-class-component": "^7.2.3",
19 | "vue-property-decorator": "^8.4.2",
20 | "vue-router": "^3.2.0",
21 | "vuex": "^3.4.0"
22 | },
23 | "devDependencies": {
24 | "@types/electron-devtools-installer": "^2.2.0",
25 | "@types/node": "^12.12.38",
26 | "@typescript-eslint/eslint-plugin": "^2.33.0",
27 | "@typescript-eslint/parser": "^2.33.0",
28 | "@vue/cli-plugin-babel": "~4.4.0",
29 | "@vue/cli-plugin-eslint": "~4.4.0",
30 | "@vue/cli-plugin-router": "~4.4.0",
31 | "@vue/cli-plugin-typescript": "^4.4.6",
32 | "@vue/cli-plugin-vuex": "^4.4.6",
33 | "@vue/cli-service": "~4.4.0",
34 | "@vue/eslint-config-typescript": "^5.0.2",
35 | "babel-eslint": "^10.1.0",
36 | "electron": "^9.0.0",
37 | "electron-devtools-installer": "^3.1.0",
38 | "eslint": "^6.7.2",
39 | "eslint-plugin-vue": "^6.2.2",
40 | "typescript": "~3.9.3",
41 | "vue-cli-plugin-electron-builder": "^2.0.0-rc.4",
42 | "vue-template-compiler": "^2.6.11"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | <%= htmlWebpackPlugin.options.title %>
11 |
12 |
13 |
14 | We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/client/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
20 |
21 |
102 |
--------------------------------------------------------------------------------
/client/src/assets/n8n.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/client/src/assets/n8n.png
--------------------------------------------------------------------------------
/client/src/assets/n8n_copy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/client/src/assets/n8n_copy.png
--------------------------------------------------------------------------------
/client/src/background.ts:
--------------------------------------------------------------------------------
1 | import { readdirSync } from "fs";
2 | import { join } from "path";
3 | import { app, protocol, BrowserWindow, ipcMain } from "electron";
4 | import { createProtocol } from "vue-cli-plugin-electron-builder/lib";
5 | import installExtension, { VUEJS_DEVTOOLS } from "electron-devtools-installer";
6 | import IpcChannel from "../channels/IpcChannel.interface";
7 |
8 | // Vue boilerplate
9 | protocol.registerSchemesAsPrivileged([
10 | { scheme: "app", privileges: { secure: true, standard: true } },
11 | ]);
12 |
13 | /**Responsible for managing the Electron app (main process), the various windows (renderer processes), and IPC channels.*/
14 | class Client {
15 | mainWindow: BrowserWindow | null; // first renderer process
16 |
17 | constructor() {
18 | app.on("ready", async () => {
19 | this.installVueDevTools();
20 | this.createMainWindow();
21 | await this.registerIpcChannels();
22 | });
23 | app.on("window-all-closed", app.quit);
24 | app.allowRendererProcessReuse = true;
25 | }
26 |
27 | // Vue boilerplate
28 | private installVueDevTools() {
29 | if (process.env.NODE_ENV !== "production" && !process.env.IS_TEST) {
30 | try {
31 | installExtension(VUEJS_DEVTOOLS);
32 | } catch (error) {
33 | console.error("Vue Devtools failed to install: ", error.toString());
34 | }
35 | }
36 | }
37 |
38 | private createMainWindow() {
39 | this.mainWindow = new BrowserWindow({
40 | width: 1000,
41 | height: 600,
42 | resizable: false,
43 | webPreferences: { nodeIntegration: true },
44 | });
45 |
46 | // Vue boilerplate
47 | if (process.env.WEBPACK_DEV_SERVER_URL) {
48 | this.mainWindow.loadURL(process.env.WEBPACK_DEV_SERVER_URL);
49 | if (!process.env.IS_TEST) {
50 | this.mainWindow.webContents.openDevTools();
51 | }
52 | } else {
53 | createProtocol("app");
54 | this.mainWindow.loadURL("app://./index.html");
55 | }
56 |
57 | this.mainWindow.on("closed", () => {
58 | this.mainWindow = null;
59 | });
60 | }
61 |
62 | /**Registers all the IPC channels for handling requests from the renderer process.*/
63 | private async registerIpcChannels() {
64 | const ipcChannels: IpcChannel[] = [];
65 |
66 | for (let module of this.getChannelModules()) {
67 | const ChannelClass = require(`../channels/${module}`).default; // dynamic import
68 | ipcChannels.push(new ChannelClass());
69 | }
70 |
71 | ipcChannels.forEach((channel) =>
72 | ipcMain.on(channel.name, (event, argument?: any) =>
73 | channel.handle(event, argument)
74 | )
75 | );
76 | }
77 |
78 | private getChannelModules() {
79 | return readdirSync(join("channels")).filter(
80 | (channel) => !channel.endsWith(".interface.ts")
81 | );
82 | }
83 | }
84 |
85 | new Client();
86 |
--------------------------------------------------------------------------------
/client/src/components/AddButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
10 |
16 |
17 |
--------------------------------------------------------------------------------
/client/src/components/BackwardButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
10 |
16 |
17 |
54 |
--------------------------------------------------------------------------------
/client/src/components/Checkbox.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 | {{label}}
9 |
10 |
11 |
12 |
23 |
24 |
77 |
--------------------------------------------------------------------------------
/client/src/components/Dropdown.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{label}}:
5 |
6 |
7 |
8 |
9 | Please select one
10 |
15 | {{ option }}
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
40 |
41 |
75 |
--------------------------------------------------------------------------------
/client/src/components/ForwardButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
10 |
16 |
17 |
--------------------------------------------------------------------------------
/client/src/components/GenericButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{text}}
4 |
5 |
6 |
7 |
13 |
14 |
--------------------------------------------------------------------------------
/client/src/components/Header.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
13 |
14 |
--------------------------------------------------------------------------------
/client/src/components/InputField.vue:
--------------------------------------------------------------------------------
1 |
2 |
18 |
19 |
20 |
36 |
37 |
70 |
--------------------------------------------------------------------------------
/client/src/components/Instructions.vue:
--------------------------------------------------------------------------------
1 | :
2 |
3 |
{{header}}
4 | {{subtitle}}
5 | {{instructions}}
6 |
7 |
8 |
9 |
19 |
20 |
--------------------------------------------------------------------------------
/client/src/components/SmallButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
16 |
17 |
--------------------------------------------------------------------------------
/client/src/components/Switch.vue:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 |
25 |
26 |
--------------------------------------------------------------------------------
/client/src/components/TextArea.vue:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 |
27 |
28 |
62 |
--------------------------------------------------------------------------------
/client/src/main.ts:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import App from './App.vue'
3 | import router from './router'
4 | import store from './store'
5 |
6 | Vue.config.productionTip = false
7 |
8 | new Vue({
9 | router,
10 | store,
11 | render: h => h(App)
12 | }).$mount('#app')
13 |
--------------------------------------------------------------------------------
/client/src/mixins/routes-mixin.ts:
--------------------------------------------------------------------------------
1 | import { Component, Vue } from 'vue-property-decorator';
2 | import Requester from '../../Requester';
3 |
4 |
5 | const requester = new Requester();
6 |
7 | @Component
8 | class RoutesMixin extends Vue {
9 | public async simpleNode(paramsBundle: NodegenParamsBundle): Promise {
10 | const result = await requester.request(
11 | "nodegen-channel",
12 | paramsBundle
13 | );
14 |
15 | console.log("Simple Node Generation _______________________________");
16 | console.log(paramsBundle);
17 |
18 | if( result.completed ) {
19 | console.log("Success");
20 | } else {
21 | console.log("Error: " + result.error)
22 | }
23 |
24 | return result;
25 | }
26 | public async emptyOutput(): Promise {
27 | const result = await requester.request(
28 | "empty-channel"
29 | );
30 | console.log("Empty Output Folder _______________________________");
31 |
32 | if( result.completed ) {
33 | console.log("Success");
34 | } else {
35 | console.log("Error: " + result.error)
36 | }
37 | return result;
38 | }
39 | public async packageGenerator(metaParameters: MetaParameters): Promise {
40 | const result = await requester.request(
41 | "packgen-channel",
42 | metaParameters
43 | );
44 | console.log("Package Generation _______________________________");
45 | console.log(metaParameters);
46 |
47 | if( result.completed ) {
48 | console.log("Success");
49 | } else {
50 | console.log("Error: " + result.error)
51 | }
52 | return result;
53 | }
54 | public async docsGen(paramsBundle: DocsgenParamsBundle): Promise {
55 | const result = await requester.request(
56 | "docsgen-channel",
57 | paramsBundle
58 | );
59 | console.log("Documentation Generation _______________________________");
60 | console.log(paramsBundle);
61 |
62 | if( result.completed ) {
63 | console.log("Success");
64 | } else {
65 | console.log("Error: " + result.error)
66 | }
67 | return result;
68 | }
69 | public async placeFunctional(): Promise {
70 | const result = await requester.request(
71 | "placement-channel",
72 | { filesToPlace: "functionality" }
73 | );
74 | console.log("Place Functional Files _______________________________");
75 |
76 | if( result.completed ) {
77 | console.log("Success");
78 | } else {
79 | console.log("Error: " + result.error)
80 | }
81 | return result;
82 | }
83 | public async placeDocumentation(): Promise {
84 | const result = await requester.request(
85 | "placement-channel",
86 | { filesToPlace: "documentation" }
87 | );
88 | console.log("Place Documentation Files _______________________________");
89 |
90 | if( result.completed ) {
91 | console.log("Success");
92 | } else {
93 | console.log("Error: " + result.error)
94 | }
95 | return result;
96 | }
97 | }
98 | export default RoutesMixin
99 |
--------------------------------------------------------------------------------
/client/src/router/index.ts:
--------------------------------------------------------------------------------
1 | import Vue from "vue";
2 | import VueRouter from "vue-router";
3 |
4 | Vue.use(VueRouter);
5 |
6 | const routes = [
7 | {
8 | path: '/',
9 | name: 'Home',
10 | component: () => import(/* webpackChunkName: "basicInfoRegular" */ '../views/BasicInfo.vue')
11 | },
12 | {
13 | path: '/regular/resources',
14 | name: 'Resources',
15 | component: () => import(/* webpackChunkName: "resources" */ '../views/RegularNode/Resources.vue')
16 | },
17 | {
18 | path: '/regular/operations',
19 | name: 'Operations',
20 | component: () => import(/* webpackChunkName: "operations" */ '../views/RegularNode/Operations.vue')
21 | },
22 | {
23 | path: '/regular/fields',
24 | name: 'Fields',
25 | component: () => import(/* webpackChunkName: "fields" */ '../views/RegularNode/Fields.vue')
26 | },
27 | {
28 | path: '/regular/complete',
29 | name: 'Complete',
30 | component: () => import(/* webpackChunkName: "complete" */ '../views/RegularNode/Complete.vue')
31 | },
32 | {
33 | path: '/trigger/properties',
34 | name: 'Trigger Node Properties',
35 | component: () => import(/* webpackChunkName: "properties" */ '../views/TriggerNode/Properties.vue')
36 | },
37 | {
38 | path: '/trigger/fields',
39 | name: 'Trigger Node Fields',
40 | component: () => import(/* webpackChunkName: "fieldsTrigger" */ '../views/TriggerNode/Fields.vue')
41 | },
42 | {
43 | path: '/trigger/complete',
44 | name: 'Complete Trigger Node',
45 | component: () => import(/* webpackChunkName: "completeTrigger" */ '../views/TriggerNode/Complete.vue')
46 | },
47 | ]
48 |
49 | const router = new VueRouter({
50 | mode: "history",
51 | base: process.env.BASE_URL,
52 | routes,
53 | });
54 |
55 | export default router;
56 |
--------------------------------------------------------------------------------
/client/src/shims-tsx.d.ts:
--------------------------------------------------------------------------------
1 | import Vue, { VNode } from 'vue'
2 |
3 | declare global {
4 | namespace JSX {
5 | // tslint:disable no-empty-interface
6 | interface Element extends VNode {}
7 | // tslint:disable no-empty-interface
8 | interface ElementClass extends Vue {}
9 | interface IntrinsicElements {
10 | [elem: string]: any
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/client/src/shims-vue.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.vue" {
2 | import Vue from "vue";
3 | export default Vue;
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import Vuex from 'vuex'
3 |
4 | import basicInfo from './modules/basicInfo';
5 |
6 | import resources from './modules/resources';
7 | import operations from './modules/operations';
8 | import fields from './modules/fields';
9 |
10 | import docsInfo from './modules/docsInfo';
11 |
12 | import properties from './modules/properties';
13 |
14 | Vue.use(Vuex)
15 |
16 | export default new Vuex.Store({
17 | modules: {
18 | basicInfo,
19 | resources,
20 | operations,
21 | fields,
22 | docsInfo,
23 | properties,
24 | }
25 | })
26 |
--------------------------------------------------------------------------------
/client/src/store/modules/basicInfo.ts:
--------------------------------------------------------------------------------
1 | const state: BasicInfoState = {
2 | basicInfo: {
3 | serviceName: "",
4 | //@ts-ignore because of the empty string
5 | authType: "",
6 | nodeColor: "",
7 | apiUrl: "",
8 | webhookEndpoint: "",
9 | },
10 | nodeType: "",
11 | documentation: false,
12 | };
13 |
14 | const getters = {
15 | basicInfo: (state: BasicInfoState): BasicInfo => {return state.basicInfo;},
16 | nodeType: (state: BasicInfoState): FrontendNodeType => {return state.nodeType},
17 | documentation: (state: BasicInfoState): boolean => {return state.documentation},
18 | };
19 |
20 | const mutations = {
21 | submitBasicInfo: (state: BasicInfoState, info: BasicInfo) => state.basicInfo = info,
22 | setNodeType: (state: BasicInfoState, nodeType: FrontendNodeType) => state.nodeType = nodeType,
23 | setDocumentation: (state: BasicInfoState, documentation: boolean) => state.documentation = documentation,
24 | };
25 |
26 | export default {
27 | state,
28 | getters,
29 | mutations
30 | }
31 |
32 |
--------------------------------------------------------------------------------
/client/src/store/modules/docsInfo.ts:
--------------------------------------------------------------------------------
1 | const state: DocsInfoState = {
2 | docsInfo: {
3 | serviceName: "",
4 | serviceUrl: "",
5 | introDescription: "",
6 | exampleUsage: "",
7 | workflowUrl: "",
8 | }
9 | };
10 |
11 | const getters = {
12 | docsInfo: (state: DocsInfoState): DocsParameters => {return state.docsInfo;}
13 | };
14 |
15 | const mutations = {
16 | submitDocsInfo: (state: DocsInfoState, info: DocsParameters) => state.docsInfo = info,
17 | };
18 |
19 | export default {
20 | state,
21 | getters,
22 | mutations
23 | }
24 |
25 |
--------------------------------------------------------------------------------
/client/src/store/modules/fields.ts:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 |
3 | const state: FieldsState = {
4 | fields: [
5 | {
6 | key: 0,
7 | resourceOperation: [
8 | {
9 | key: 0,
10 | value: "",
11 | add: true,
12 | cancel: false
13 | }
14 | ],
15 | //@ts-ignore for empty string
16 | type: "",
17 | name: "",
18 | description: "",
19 | required: false,
20 | default: "",
21 | min: "",
22 | max: "",
23 | displayRestrictions: "",
24 | }
25 | ]
26 | };
27 |
28 | const getters = {
29 | fields: (state: FieldsState): FrontendField[] => {return state.fields;},
30 | };
31 |
32 | const actions = {
33 | addField({ commit }: any) {
34 | const field: FrontendField = {
35 | key: state.fields.length,
36 | resourceOperation: [
37 | {
38 | key: 0,
39 | value: "",
40 | add: true,
41 | cancel: false
42 | }
43 | ],
44 | //@ts-ignore for empty string
45 | type: "",
46 | name: "",
47 | description: "",
48 | default: "",
49 | cancel: true
50 | };
51 |
52 | commit('pushToFields', field);
53 | },
54 | addResourceOperation({ commit }: any, key: number) {
55 | const newObj: AssociatedProps = {
56 | key: state.fields[key].resourceOperation.length,
57 | value: "",
58 | add: false,
59 | cancel: true
60 | };
61 |
62 | commit('pushToResourceOperation', { newObj, key });
63 | },
64 | createOption({ commit }: any, fieldKey: number) {
65 | const option: OptionsOption = {
66 | name: "",
67 | description: "",
68 | key: 0,
69 | add: true,
70 | cancel: false
71 | };
72 |
73 | commit('createOption', { fieldKey, option });
74 | },
75 | addOption({ commit }: any, fieldKey: number) {
76 | const option: OptionsOption = {
77 | name: "",
78 | description: "",
79 | //@ts-ignore because of optional chaining operator
80 | key: state.fields[fieldKey].options?.length,
81 | add: false,
82 | cancel: true
83 | };
84 |
85 | commit('pushOption', { fieldKey, option });
86 | },
87 | createInnerOption({ commit }: any, { fieldKey, optionKey }: { fieldKey: number; optionKey: number}) {
88 | const option: OptionsOption = {
89 | name: "",
90 | description: "",
91 | key: 0,
92 | add: true,
93 | cancel: false
94 | };
95 |
96 | commit('createInnerOption', { fieldKey, optionKey, option });
97 | },
98 | addInnerOption({ commit }: any, { fieldKey, optionKey }: { fieldKey: number; optionKey: number}) {
99 | const option: OptionsOption = {
100 | name: "",
101 | description: "",
102 | //@ts-ignore this will only be called on a CollectionOption
103 | key: state.fields[fieldKey]?.options[optionKey].options.length,
104 | add: false,
105 | cancel: true
106 | };
107 |
108 | commit('pushInnerOption', { fieldKey, optionKey, option });
109 | },
110 | createCollectionOption({ commit }: any, fieldKey: number) {
111 | const option: CollectionOption = {
112 | key: 0,
113 | //@ts-ignore for empty string
114 | type: "",
115 | name: "",
116 | description: "",
117 | default: "",
118 | add: true,
119 | cancel: false
120 | };
121 |
122 | commit('createOption', { fieldKey, option });
123 | },
124 | addCollectionOption({ commit }: any, fieldKey: number) {
125 | const option: CollectionOption = {
126 | key: state.fields[fieldKey].options.length,
127 | //@ts-ignore for empty string
128 | type: "",
129 | name: "",
130 | description: "",
131 | default: "",
132 | add: false,
133 | cancel: true
134 | };
135 |
136 | commit('pushOption', { fieldKey, option });
137 | },
138 | };
139 |
140 | const mutations = {
141 | toggleFieldsRequired: (state: FieldsState, { fieldKey, newValue }: { fieldKey: number; newValue: boolean}) => Vue.set(state.fields[fieldKey], 'required', newValue),
142 |
143 | pushToResourceOperation: (state: FieldsState, { newObj, key }: { newObj: AssociatedProps; key: number}) => state.fields[key].resourceOperation.push(newObj),
144 | submitResourceOperation: (state: FieldsState, { newObj, fieldKey }: { newObj: AssociatedProps[]; fieldKey: number}) => state.fields[fieldKey].resourceOperation = newObj,
145 |
146 | createOption: (state: FieldsState, { fieldKey, option }: { fieldKey: number; option: FrontendOption}) => Vue.set(state.fields[fieldKey], 'options', [option]),
147 | pushOption: (state: FieldsState, { fieldKey, option} : { fieldKey: number; option: FrontendOption}) => state.fields[fieldKey].options.push(option),
148 | submitOptions: (state: FieldsState, { fieldKey, newObj }: { fieldKey: number; newObj: FrontendOption[]}) => state.fields[fieldKey].options = newObj,
149 |
150 | createInnerOption: (state: FieldsState, { fieldKey, optionKey, option }: { fieldKey: number; optionKey: number; option: OptionsOption}) => Vue.set(state.fields[fieldKey].options[optionKey], 'options', [option]),
151 | //@ts-ignore because this will always be a CollectionOption and the error is caused by OptionsOption
152 | pushInnerOption: (state: FieldsState, { fieldKey, optionKey, option }: { fieldKey: number; optionKey: number; option: OptionsOption}) => state.fields[fieldKey].options[optionKey].options.push(option),
153 | //@ts-ignore because this will always be a CollectionOption and the error is caused by OptionsOption
154 | submitInnerOptions: (state: FieldsState, { fieldKey, optionKey, newObj }: { fieldKey: number; optionKey: number; newObj: OptionsOption[]}) => state.fields[fieldKey].options[optionKey].options = newObj,
155 |
156 | pushToFields: (state: FieldsState, field: FrontendField) => state.fields.push(field),
157 | submitFields: (state: FieldsState, fields: FrontendField[]) => state.fields = fields,
158 | };
159 |
160 | export default {
161 | state,
162 | getters,
163 | actions,
164 | mutations
165 | }
166 |
167 |
--------------------------------------------------------------------------------
/client/src/store/modules/operations.ts:
--------------------------------------------------------------------------------
1 | const state: OperationsState = {
2 | operations: [
3 | {
4 | key: 0,
5 | resource: "",
6 | name: "",
7 | description: "",
8 | endpoint: "",
9 | //@ts-ignore for empty string
10 | requestMethod: "",
11 | cancel: false
12 | }
13 | ]
14 | };
15 |
16 | const getters = {
17 | operations: (state: OperationsState): FrontendOperation[] => {return state.operations},
18 | operationWithResourceNames: (state: OperationsState): string[] => {
19 | const nameList: string[] = [];
20 |
21 | state.operations.forEach(operation => {
22 | nameList.push(operation.name + " : " + operation.resource);
23 | });
24 |
25 | return nameList;
26 | }
27 | };
28 |
29 | const actions = {
30 | addOperation({ commit }: any) {
31 | const operation: FrontendOperation = {
32 | key: state.operations.length,
33 | resource: "",
34 | name: "",
35 | description: "",
36 | endpoint: "",
37 | //@ts-ignore for empty string
38 | requestMethod: "",
39 | cancel: true
40 | };
41 |
42 | commit('pushToOperations', operation);
43 | },
44 | };
45 |
46 | const mutations = {
47 | pushToOperations: (state: OperationsState, operation: FrontendOperation) => state.operations.push(operation),
48 | submitOperations: (state: OperationsState, operations: FrontendOperation[]) => state.operations = operations,
49 | };
50 |
51 | export default {
52 | state,
53 | getters,
54 | actions,
55 | mutations
56 | }
57 |
--------------------------------------------------------------------------------
/client/src/store/modules/properties.ts:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 |
3 | const state: PropertyState = {
4 | properties: [
5 | {
6 | key: 0,
7 | displayName: "",
8 | required: false,
9 | description: "",
10 | //@ts-ignore for empty string
11 | type: "",
12 | default: "",
13 | }
14 | ]
15 | };
16 |
17 | const getters = {
18 | properties: (state: PropertyState): FrontendProperty[] => {return state.properties},
19 | propertyNames: (state: PropertyState): string[] => {
20 | let names: string[] = [];
21 |
22 | state.properties.forEach(property => {
23 | if(property.options !== undefined) {
24 | property.options.forEach(option => {
25 | names.push(property.displayName + " : " + option.name);
26 | });
27 | }
28 | });
29 |
30 | return names;
31 | }
32 | };
33 |
34 | const actions = {
35 | createPropertyOption({ commit }: any, propertyKey: number) {
36 | const option: OptionsOption = {
37 | name: "",
38 | description: "",
39 | key: 0,
40 | add: true,
41 | cancel: false
42 | };
43 |
44 | commit('createPropertyOption', { propertyKey, option });
45 | },
46 | addProperty({ commit }: any) {
47 | const property: FrontendProperty = {
48 | key: state.properties.length,
49 | resource: "",
50 | displayName: "",
51 | description: "",
52 | default: "",
53 | //@ts-ignore for empty string
54 | type: "",
55 | required: false,
56 | cancel: true
57 | };
58 |
59 | commit('pushProperty', property);
60 | },
61 | addPropertyOption({ commit }: any, propertyKey: number) {
62 | const option: WebhookPropertyOption = {
63 | name: "",
64 | description: "",
65 | //@ts-ignore
66 | key: state.properties[propertyKey].options.length,
67 | add: false,
68 | cancel: true
69 | };
70 |
71 | commit('pushPropertyOption', { propertyKey, option });
72 | },
73 | };
74 |
75 | const mutations = {
76 | toggleRequired: (state: PropertyState, { propertyKey, newValue }: { propertyKey: number; newValue: boolean}) => Vue.set(state.properties[propertyKey], 'required', newValue),
77 |
78 | pushProperty: (state: PropertyState, property: FrontendProperty) => state.properties.push(property),
79 | submitProperties: (state: PropertyState, properties: FrontendProperty[]) => state.properties = properties,
80 |
81 | createPropertyOption: (state: PropertyState, { propertyKey, option }: { propertyKey: number; option: WebhookPropertyOption }) => Vue.set(state.properties[propertyKey], 'options', [option]),
82 | //@ts-ignore will not be undefined
83 | pushPropertyOption: (state: PropertyState, { propertyKey, option}: { propertyKey: number; option: WebhookPropertyOption }) => state.properties[propertyKey].options.push(option),
84 | submitPropertyOptions: (state: PropertyState, { propertyKey, newObj }: { propertyKey: number; newObj: WebhookPropertyOption[] }) => state.properties[propertyKey].options = newObj,
85 | };
86 |
87 | export default {
88 | state,
89 | getters,
90 | actions,
91 | mutations
92 | }
93 |
--------------------------------------------------------------------------------
/client/src/store/modules/resources.ts:
--------------------------------------------------------------------------------
1 | const state: ResourcesState = {
2 | resources: [
3 | {
4 | key: 0,
5 | text: "",
6 | cancel: false
7 | }
8 | ]
9 | };
10 |
11 | const getters = {
12 | resources: (state: ResourcesState): FrontendResource[] => {return state.resources},
13 | resourceNames: (state: ResourcesState): string[] => {
14 | const nameList: string[] = [];
15 |
16 | state.resources.forEach(resource => {
17 | nameList.push(resource.text);
18 | });
19 |
20 | return nameList;
21 | }
22 | };
23 |
24 | const actions = {
25 | addResource({ commit }: any) {
26 | const resource: FrontendResource = {
27 | key: state.resources.length,
28 | text: "",
29 | cancel: true
30 | };
31 |
32 | commit('pushToResources', resource);
33 | },
34 | };
35 |
36 | const mutations = {
37 | pushToResources: (state: ResourcesState, resource: FrontendResource) => state.resources.push(resource),
38 | submitResources: (state: ResourcesState, resources: FrontendResource[]) => state.resources = resources,
39 | };
40 |
41 | export default {
42 | state,
43 | getters,
44 | actions,
45 | mutations
46 | }
47 |
48 |
--------------------------------------------------------------------------------
/client/src/store/types.ts:
--------------------------------------------------------------------------------
1 | export interface RootState {
2 | version: string;
3 | }
--------------------------------------------------------------------------------
/client/src/views/BasicInfo.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
19 |
27 |
35 |
43 |
51 |
60 |
68 |
69 |
77 |
85 |
93 |
101 |
102 |
103 |
104 |
105 |
108 |
109 |
110 |
113 |
114 |
115 |
116 |
117 |
121 |
122 |
123 |
124 |
125 |
163 |
--------------------------------------------------------------------------------
/client/src/views/RegularNode/Complete.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
16 |
22 |
28 |
34 |
41 |
47 |
54 |
55 |
60 |
61 |
62 |
63 |
64 |
68 |
69 |
70 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
159 |
160 |
167 |
168 |
--------------------------------------------------------------------------------
/client/src/views/RegularNode/Operations.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
20 |
28 |
36 |
44 |
52 |
53 |
54 |
59 |
60 |
66 |
67 |
68 |
72 |
73 |
74 |
75 |
76 |
80 |
81 |
82 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
123 |
--------------------------------------------------------------------------------
/client/src/views/RegularNode/Resources.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
27 |
30 |
31 |
32 |
36 |
37 |
38 |
39 |
40 |
44 |
45 |
46 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
85 |
86 |
--------------------------------------------------------------------------------
/client/src/views/TriggerNode/Complete.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
16 |
22 |
28 |
34 |
35 |
40 |
41 |
42 |
43 |
44 |
48 |
49 |
50 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
115 |
116 |
123 |
124 |
--------------------------------------------------------------------------------
/client/src/views/TriggerNode/Properties.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
19 |
27 |
35 |
42 |
51 |
52 |
Options:
53 |
54 |
55 |
63 |
71 |
72 |
77 |
82 |
83 |
84 |
85 |
86 |
91 |
92 |
98 |
99 |
100 |
104 |
105 |
106 |
107 |
108 |
112 |
113 |
114 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
170 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "module": "esnext",
5 | "strict": true,
6 | "jsx": "preserve",
7 | "importHelpers": true,
8 | "moduleResolution": "node",
9 | "experimentalDecorators": true,
10 | "esModuleInterop": true,
11 | "allowSyntheticDefaultImports": true,
12 | "strictPropertyInitialization": false,
13 | "sourceMap": true,
14 | "baseUrl": ".",
15 | "types": [
16 | "webpack-env"
17 | ],
18 | "paths": {
19 | "@/*": [
20 | "src/*"
21 | ]
22 | },
23 | "lib": [
24 | "esnext",
25 | "dom",
26 | "dom.iterable",
27 | "scripthost"
28 | ]
29 | },
30 | "include": [
31 | "../globals.d.ts",
32 | "src/**/*.ts",
33 | "src/**/*.tsx",
34 | "src/**/*.vue",
35 | "tests/**/*.ts",
36 | "tests/**/*.tsx", "src/store/modules/fields.js"
37 | ],
38 | "exclude": [
39 | "node_modules"
40 | ]
41 | }
42 |
--------------------------------------------------------------------------------
/config/index.ts:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import dotenv from "dotenv";
3 |
4 | if (!fs.existsSync("config/.env")) {
5 | throw Error("No .env file found at /config dir");
6 | }
7 |
8 | dotenv.config({ path: "config/.env" });
9 |
10 | [
11 | process.env.GOOGLE_IMAGE_SEARCH_ENGINE_ID,
12 | process.env.GOOGLE_PROJECT_API_KEY,
13 | process.env.N8N_LOGIN_USERNAME,
14 | process.env.N8N_LOGIN_PASSWORD,
15 | process.env.IMGBB_API_KEY,
16 | ].forEach((envVar) => {
17 | if (envVar === undefined) {
18 | throw Error("Missing required environment variable! Check for: " + envVar);
19 | }
20 | });
21 |
22 | export default {
23 | googleImageSearch: {
24 | engineId: process.env.GOOGLE_IMAGE_SEARCH_ENGINE_ID,
25 | apiKey: process.env.GOOGLE_PROJECT_API_KEY,
26 | },
27 | n8n: {
28 | username: process.env.N8N_LOGIN_USERNAME,
29 | password: process.env.N8N_LOGIN_PASSWORD,
30 | },
31 | imgbb: {
32 | apiKey: process.env.IMGBB_API_KEY,
33 | },
34 | };
35 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
Nodemaker Documentation
7 |
8 |
9 |
10 |
11 | **Documentation sections**
12 |
13 | - [Setup for Official Repos](official-repos-setup.md) — to set up the repos [`n8n`](https://github.com/n8n-io/n8n) and [`n8n-docs`](https://github.com/n8n-io/n8n-docs)
14 | - [CLI Operation Reference](cli-reference.md) — to operate the Nodemaker's CLI utility
15 | - [Codebase Functionality](codebase-functionality.md) — to understand how the codebase works
16 | - [Issue Submission Guidelines](issue-submission-guidelines.md) — to read before submitting an issue
17 |
--------------------------------------------------------------------------------
/docs/codebase-functionality.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
Codebase functionality
7 |
8 |
9 |
10 | Understand how the Nodemaker's codebase works
11 |
12 |
13 |
14 |
15 | ## Backend
16 |
17 | The Nodemaker's backend is a TypeScript/Node app that calls **scripts** that use **generators** and **services**.
18 |
19 | **1.** Every CLI command transpiles TypeScript into JavaScript and calls the transpiled version.
20 | **2.** Each command executes a short script in the `/scripts` directory, which:
21 |
22 | - gathers the required parameters (if any),
23 | - instantiates the the required generator or service, and
24 | - runs checks and executes the relevant methods.
25 |
26 | **3.** The successful or failed result of the backend operation is simply logged out.
27 |
28 | ### Generators
29 |
30 | Generators are the `Generator` class and its three children `NodeFilesGenerator`, `NodeDocsGenerator` and `PackageJsonGenerator` in the `/generators` directory. All generators rely on [hygen library](https://github.com/jondot/hygen/) to combine the parameters in `parameters.ts` and the EJS templates in `/_templates/gen` to generate output files in `/output`, in TypeScript, Markdown or JSON.
31 |
32 | **Note:** While `PackageJsonGenerator` is listed under generators and relies on hygen, this operation is more precisely described, not as generation, as insertion into a `package.json` file, supplied as just another parameter.
33 |
34 | ### Services
35 |
36 | Major operations that do not involve generation with hygen are the services in the `/services` directory:
37 |
38 | - `DirectoryEmptier` — to clear out the `/output` directory
39 | - `FilePlacer` — to place output files in the official repos,
40 | - `IconResizer` — to resize the selected icon candidate,
41 | - `ImageFetcher` — to fetch five icon candidates,
42 | - `ScreenshotTaker` — to take an in-app screenshot,
43 | - `Validator` — to validate an object built on the frontend, and
44 | - `WorkflowCreator` — to submit a workflow on [n8n.io](https://n8n.io/workflows).
45 |
46 | The three classes `Prompter` (user input retrieval), `Highlighter` (colored console logging) and `FileFinder` (identification of output files) are auxiliary services, used only indirectly.
47 |
48 | ### Utilities and configuration
49 |
50 | The utilities in `/utils` are various type guards, enums, constants and two functions used throughout the codebase. The global type definition file `globals.d.ts`, although placed at the project's root directory, belongs in this category as well.
51 |
52 | The single TypeScript file in `/config` reads the `.env` file to be created there by the user and filled in with their credentials.
53 |
54 |
55 |
56 |
57 |
Backend dependency graph
58 |
59 |
60 |
61 |
62 |
63 |
64 | ## Frontend
65 |
66 | The Nodemaker's frontend is an Electron/Vue app in `/client`. It uses **channels** for communication between the frontend's renderer process (Vue app in `main.ts`) and its main process (Electron app in `background.ts`).
67 |
68 | Firstly, the command `npm run desktop` starts up the Electron app in `client/src/background.ts`, which serves the Vue app rooted in `main.ts` and self-registers all the inter-process communication channels in `client/channels`, namely `NodegenChannel`, `DocsgenChannel`, `PackgenChannel`, `PlacementChannel` and `EmptyChannel`.
69 |
70 | #### Interface
71 |
72 |
73 |
74 |
75 | To communicate with the backend, the Electron app imports and instantiates a class called `Requester` and uses its `request` method like a `fetch` call: `requester.request(channel, argument)` sends out an argument to the backend and returns a `Promise` containing a backend operation result.
76 |
77 | The argument is sent out by the frontend's renderer process to its main process through a specific channel. Each channel registered in the Electron app satisfies `Channel.interface.ts`, so it only has a `name` for identification and a `handle` method containing all the logic (provided by the backend) to service the request.
78 |
79 | ### Vue App
80 |
81 | The frontend application is written in Vue with Vuex.
82 |
83 | #### Store
84 |
85 | The store holds all of the user's form data in seperate state objects:
86 |
87 | ##### Shared
88 |
89 | - BasicInfo.vue --> basicInfo.ts
90 | - Fields.vue --> fields.ts
91 |
92 | ##### Regular
93 |
94 | - Docs info (in BasicInfo.vue) --> docsInfo.ts
95 | - Resources.vue --> resource.ts
96 | - Operations.vue --> operations.ts
97 |
98 | ##### Trigger
99 |
100 | - Properties.vue --> properties.ts
101 |
102 | #### Views
103 |
104 | There are two "paths" of views the user can go down depending on whether or not they choose to make a regular/trigger node.
105 |
106 | Each View aims to handle as little of the state logic as possible (instead leaving it up to vuex).
107 |
108 | - BasicInfo is shown initially to users, and they enter the meta parameters and the documentation parameters here.
109 |
110 | - Resources and operations are exclusively for the regular nodes, and properties are exclusively for the trigger nodes.
111 |
112 | - Fields is different depending on the node type due to the slightly different inputs and instructions needed, but they share the same store.
113 |
114 | - The Complete views allow the user to check off the different operations they want done and then generate the node. They then call methods in the mixins.
115 |
116 | ### Mixins
117 |
118 | There are two mixin files: `params-build-mixin.ts` and `routes-mixin.ts`.
119 |
120 | - `params-build-mixin.ts` contains the functions that build the parameters (based on the backend specifications in `parameters.ts`) out of the existing state objects.
121 |
122 | - `routes-mixins.ts` contains functions with requester calls to the channels.
123 |
124 |
125 |
Frontend dependency graph
126 |
127 |
128 |
129 |
130 |
131 |
--------------------------------------------------------------------------------
/docs/images/graphs/backend-frontend-communication.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/graphs/backend-frontend-communication.png
--------------------------------------------------------------------------------
/docs/images/icons/icons8-bug-64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/icons/icons8-bug-64.png
--------------------------------------------------------------------------------
/docs/images/icons/icons8-code-file-80.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/icons/icons8-code-file-80.png
--------------------------------------------------------------------------------
/docs/images/icons/icons8-console-64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/icons/icons8-console-64.png
--------------------------------------------------------------------------------
/docs/images/icons/icons8-product-documents-64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/icons/icons8-product-documents-64.png
--------------------------------------------------------------------------------
/docs/images/icons/icons8-repository-80.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/icons/icons8-repository-80.png
--------------------------------------------------------------------------------
/docs/images/logos/electron.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/logos/electron.png
--------------------------------------------------------------------------------
/docs/images/logos/node.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/logos/node.png
--------------------------------------------------------------------------------
/docs/images/logos/ts.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/logos/ts.png
--------------------------------------------------------------------------------
/docs/images/logos/vue.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/logos/vue.png
--------------------------------------------------------------------------------
/docs/images/nodemaker-banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/nodemaker-banner.png
--------------------------------------------------------------------------------
/docs/images/screencaps/credentials.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/screencaps/credentials.png
--------------------------------------------------------------------------------
/docs/images/screencaps/icongen-output.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/screencaps/icongen-output.png
--------------------------------------------------------------------------------
/docs/images/screencaps/icongen-querystring.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/screencaps/icongen-querystring.png
--------------------------------------------------------------------------------
/docs/images/screencaps/node-doc.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/screencaps/node-doc.png
--------------------------------------------------------------------------------
/docs/images/screencaps/node.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/screencaps/node.png
--------------------------------------------------------------------------------
/docs/images/screencaps/nodegen-prompt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/screencaps/nodegen-prompt.png
--------------------------------------------------------------------------------
/docs/images/screencaps/packageJson.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/screencaps/packageJson.png
--------------------------------------------------------------------------------
/docs/images/screencaps/place-prompt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/screencaps/place-prompt.png
--------------------------------------------------------------------------------
/docs/images/screencaps/place-small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/screencaps/place-small.png
--------------------------------------------------------------------------------
/docs/images/screencaps/placement.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/screencaps/placement.png
--------------------------------------------------------------------------------
/docs/images/screencaps/resize-icon-candidates.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/screencaps/resize-icon-candidates.png
--------------------------------------------------------------------------------
/docs/images/screencaps/resize-prompt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/screencaps/resize-prompt.png
--------------------------------------------------------------------------------
/docs/images/screencaps/typeguard-error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/screencaps/typeguard-error.png
--------------------------------------------------------------------------------
/docs/images/screencaps/validation-error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/screencaps/validation-error.png
--------------------------------------------------------------------------------
/docs/images/screencaps/workflow-submission.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/screencaps/workflow-submission.png
--------------------------------------------------------------------------------
/docs/images/screencaps/workflow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/docs/images/screencaps/workflow.png
--------------------------------------------------------------------------------
/docs/issue-submission-guidelines.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
Issue Submission Guidelines
7 |
8 |
9 |
10 | Please read before submitting an issue to Nodemaker
11 |
12 |
13 | ## Bug Report
14 |
15 | The most common type of bugs will likely be unexpected or broken outputs based on the frontend or `parameters.ts` inputs. When you encounter such issues, it is important to submit the JSON object that caused the bug.
16 |
17 | To get the JSON from the Vue frontend, toggle on the developer tools (under View in the top menu). Locate which command you found an issue with (by viewing the console logs in the developer tools), and copy the object by right clicking the object, selecting "Store as global variable" and then running `copy(VAR_NAME)` in the console. Include this object directly in the issue or through a pastebin link.
18 |
19 | If no object has been outputted because of an error in the frontend, toggle on the developer tools and screenshot the error message and include that in the issue along with a list of steps to replicate the issue.
20 |
21 | Add any applicable tags as well.
22 |
23 | ## Feature Request
24 |
25 | We would love any feature requests from the community!
26 |
27 | When submitting a feature request, please specify (through tags) whether this issue is a frontend or backend request. Be as specific as possible with your specs and perhaps give some use cases of the new feature to illustrate the value add.
28 |
--------------------------------------------------------------------------------
/docs/official-repos-setup.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
Setup for official repos
7 |
8 |
9 |
10 | Set up the official repos for use with Nodemaker
11 |
12 |
13 |
14 |
15 | Nodemaker is a companion project to the official repos [`n8n`](https://github.com/n8n-io/n8n) and [`n8n-docs`](https://github.com/n8n-io/n8n-docs). This section explains how to set up the two official repos, for use with the `nodemaker` repo.
16 |
17 | > This setup is only needed for the Nodemaker's **automated placement** and **screenshot generation** services. If instead you are looking to set up the **Nodemaker** repo, refer back to [the Installation section in the main README](../README.md#installation).
18 |
19 | ### For automated placement
20 |
21 | Nodemaker's output files are meant to be placed in your local copies of the two official repos, through the Nodemaker's **automated placement** service.
22 |
23 | To set up both official repos, clone them:
24 |
25 | ```sh
26 | git clone https://github.com/n8n-io/n8n.git
27 | git clone https://github.com/n8n-io/n8n-docs.git
28 | ```
29 |
30 | And locate them alongside the `nodemaker` repo:
31 |
32 | ```sh
33 | .
34 | ├── n8n
35 | ├── n8n-docs
36 | └── nodemaker
37 | ```
38 |
39 | ### For screenshot generation
40 |
41 | In addition, to run the Nodemaker's screenshot generation service `shotgen`, the `n8n` repo also needs to be _built_.
42 |
43 | ```sh
44 | # get build tools on Windows
45 | npm install -g windows-build-tools
46 |
47 | # get build tools on Linux
48 | apt-get install -y build-essential python
49 |
50 | # install lerna
51 | npm i lerna -g
52 |
53 | # run the build process
54 | cd n8n
55 | lerna bootstrap --hoist
56 | npm run build
57 | ```
58 |
59 | This builds your copy of the `n8n` repo, so that Nodemaker can run it locally for screenshot generation.
60 |
--------------------------------------------------------------------------------
/docs/output-examples/GenericFunctions.ts:
--------------------------------------------------------------------------------
1 | import {
2 | IExecuteFunctions,
3 | IHookFunctions,
4 | } from 'n8n-core';
5 |
6 | import {
7 | IDataObject,
8 | } from 'n8n-workflow';
9 |
10 | import {
11 | OptionsWithUri,
12 | } from 'request';
13 |
14 |
15 | /**
16 | * Make an API request to Hacker News
17 | *
18 | * @param {IHookFunctions | IExecuteFunctions} this
19 | * @param {string} method
20 | * @param {string} endpoint
21 | * @param {IDataObject} body
22 | * @param {IDataObject} qs
23 | * @param {string} [uri]
24 | * @param {IDataObject} [headers]
25 | * @returns {Promise}
26 | */
27 | export async function hackerNewsApiRequest(
28 | this: IHookFunctions | IExecuteFunctions,
29 | method: string,
30 | endpoint: string,
31 | body: IDataObject,
32 | qs: IDataObject,
33 | uri?: string,
34 | headers?: IDataObject,
35 | ): Promise { // tslint:disable-line:no-any
36 |
37 | const options: OptionsWithUri = {
38 | headers: {},
39 | body,
40 | method,
41 | qs,
42 | uri: uri || `http://hn.algolia.com/api/v1/${endpoint}`,
43 | json: true,
44 | };
45 |
46 | try {
47 |
48 | const credentials = this.getCredentials('hackerNewsOAuth2Api');
49 |
50 | if (credentials === undefined) {
51 | throw new Error('No credentials got returned!');
52 | }
53 |
54 | if (Object.keys(headers).length !== 0) {
55 | options.headers = Object.assign({}, options.headers, headers);
56 | }
57 |
58 | if (Object.keys(body).length === 0) {
59 | delete options.body;
60 | }
61 |
62 | return await this.helpers.requestOAuth2.call(this, 'hackerNewsApi', options);
63 |
64 | } catch (error) {
65 |
66 | // TODO: Replace TODO_ERROR_STATUS_CODE and TODO_ERROR_MESSAGE based on the error object returned by API.
67 |
68 | if (TODO_ERROR_STATUS_CODE === 401) {
69 | // Return a clear error
70 | throw new Error('The Hacker News credentials are invalid!');
71 | }
72 |
73 | if (TODO_ERROR_MESSAGE) {
74 | // Try to return the error prettier
75 | throw new Error(`Hacker News error response [${TODO_ERROR_STATUS_CODE}]: ${TODO_ERROR_MESSAGE}`);
76 | }
77 |
78 | // If that data does not exist for some reason, return the actual error.
79 | throw error;
80 | }
81 | }
82 |
83 |
84 | /**
85 | * Make an API request to Hacker News and return all results
86 | *
87 | * @export
88 | * @param {IHookFunctions | IExecuteFunctions} this
89 | * @param {string} method
90 | * @param {string} endpoint
91 | * @param {IDataObject} body
92 | * @param {IDataObject} qs
93 | * @returns {Promise}
94 | */
95 | export async function hackerNewsApiRequestAllItems(
96 | this: IHookFunctions | IExecuteFunctions,
97 | propertyName: string,
98 | method: string,
99 | endpoint: string,
100 | body: IDataObject,
101 | qs: IDataObject
102 | ): Promise { // tslint:disable-line:no-any
103 |
104 | const returnData: IDataObject[] = [];
105 | let responseData: any;
106 |
107 | do {
108 | responseData = await hackerNewsApiRequest.call(this, method, endpoint, body, qs);
109 | // TODO: Get next page using `responseData` or `qs`
110 | returnData.push.apply(returnData, responseData[propertyName]);
111 |
112 | } while (
113 | // TODO: Add condition for total not yet reached
114 | );
115 |
116 | return returnData;
117 | }
118 |
--------------------------------------------------------------------------------
/docs/output-examples/HackerNews.md:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: /nodes/n8n-nodes-base.hackerNews
3 | ---
4 |
5 | # Hacker News
6 |
7 | [Hacker News](https://news.ycombinator.com) is a social news website focusing on computer science and entrepreneurship.
8 |
9 | ::: tip 🔑 Credentials
10 | You can find authentication information for this node [here](../../../credentials/HackerNews/README.md).
11 | :::
12 |
13 | ## Basic Operations
14 |
15 | - Article
16 | - Get a Hacker News article
17 | - Get all Hacker News articles
18 | - User
19 | - Get a Hacker News user
20 | - Rename a Hacker News user
21 |
22 | ## Example Usage
23 |
24 | This workflow allows you to get an article from Hacker News. You can also find the [workflow](https://n8n.io/workflows/123) on this website. This example usage workflow would use the following two nodes.
25 | - [Start](../../core-nodes/Start/README.md)
26 | - [Hacker News]()
27 |
28 | The final workflow should look like the following image.
29 |
30 | 
31 |
32 | ### 1. Start node
33 |
34 | The start node exists by default when you create a new workflow.
35 |
36 | ### 2. Hacker News node
37 |
38 | 1. First of all, you'll have to enter credentials for the Hacker News node. You can find out how to do that [here](../../../credentials/Hacker News/README.md).
39 |
40 | X. Click on *Execute Node* to run the workflow.
41 |
42 |
--------------------------------------------------------------------------------
/docs/output-examples/HackerNewsCredentials.md:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: /nodes/n8n-nodes-base.hackerNews
3 | ---
4 |
5 | # Hacker News
6 |
7 | You can find information about the operations supported by the Hacker News node on the [integrations](https://n8n.io/integrations/n8n-nodes-base.hackerNews) page. You can also browse the source code of the node on [GitHub](https://github.com/n8n-io/n8n/tree/master/packages/nodes-base/nodes/HackerNews).
8 |
9 | ## Pre-requisites
10 |
11 | Create a [Hacker News](https://news.ycombinator.com) account.
12 |
13 | ## Using OAuth
14 |
15 |
16 |
--------------------------------------------------------------------------------
/docs/output-examples/HackerNewsOAuth2Api.credentials.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ICredentialType,
3 | NodePropertyTypes,
4 | } from 'n8n-workflow';
5 |
6 |
7 | export class HackerNewsOAuth2Api implements ICredentialType {
8 | name = 'HackerNewsOAuth2Api';
9 | extends = [
10 | 'oAuth2Api'
11 | ];
12 | displayName = 'Hacker News OAuth2 API';
13 | properties = [
14 | {
15 | displayName: 'Authorization URL',
16 | name: 'authUrl',
17 | type: 'hidden' as NodePropertyTypes,
18 | // default: '...', // TODO: Fill in authorization URL
19 | },
20 | {
21 | displayName: 'Access Token URL',
22 | name: 'accessTokenUrl',
23 | type: 'hidden' as NodePropertyTypes,
24 | // default: '...', // TODO: Fill in access token URL
25 | },
26 | {
27 | displayName: 'Scope',
28 | name: 'scope',
29 | type: 'hidden' as NodePropertyTypes,
30 | // default: '...', // TODO: Fill in default scopes
31 | },
32 | {
33 | displayName: 'Authentication',
34 | name: 'authentication',
35 | type: 'hidden' as NodePropertyTypes,
36 | // default: '...', // TODO: Select 'header' or 'body'
37 | },
38 | // TODO: Select auth query parameters if necessary
39 | // {
40 | // displayName: 'Auth URI Query Parameters',
41 | // name: 'authQueryParameters',
42 | // type: 'hidden' as NodePropertyTypes,
43 | // default: 'response_type=code',
44 | // },
45 | // {
46 | // displayName: 'Auth URI Query Parameters',
47 | // name: 'authQueryParameters',
48 | // type: 'hidden' as NodePropertyTypes,
49 | // default: 'grant_type=authorization_code',
50 | // },
51 | ];
52 | }
--------------------------------------------------------------------------------
/generators/Generator.ts:
--------------------------------------------------------------------------------
1 | /**Container for methods shared by child generators.*/
2 | export default class Generator {
3 | /**Format a command by adding a prefix and removing whitespaces included in the codebase for readability.*/
4 | protected formatCommand(command: string) {
5 | return this.addPrefix(command).replace(/\s{2}/g, "").trim();
6 | }
7 |
8 | /**Prefix a command with an env var and the path to hygen.*/
9 | private addPrefix(command: string) {
10 | return (
11 | "env HYGEN_OVERWRITE=1 node node_modules/hygen/dist/bin.js" + command
12 | );
13 | }
14 |
15 | /**Create a service credential name string based on auth type.*/
16 | protected getServiceCredentialName(metaParameters: MetaParameters) {
17 | const serviceName = metaParameters.serviceName.replace(/\s/g, "");
18 | return (
19 | serviceName +
20 | (metaParameters.authType === "OAuth2" ? "OAuth2" : "") +
21 | "Api"
22 | );
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/generators/NodeDocsGenerator.ts:
--------------------------------------------------------------------------------
1 | import { execSync as exec } from "child_process";
2 | import { join } from "path";
3 | import Generator from "./Generator";
4 | import { AuthEnum } from "../utils/enums";
5 | import { areTriggerNodeParameters } from "../utils/typeGuards";
6 | import fs from "fs";
7 | import { promisify } from "util";
8 |
9 | // IMPORTANT! Currently only for regular nodes, not trigger nodes.
10 |
11 | /**Responsible for generating the node functionality documentation file and the node credential documentation file.*/
12 | export default class NodeDocsGenerator extends Generator {
13 | private readdir = promisify(fs.readdir);
14 |
15 | private docsParameters: DocsParameters;
16 | private metaParameters: MetaParameters;
17 | private mainParameters: MainParameters;
18 |
19 | constructor(paramsBundle: DocsgenParamsBundle) {
20 | super();
21 | this.docsParameters = paramsBundle.docsParameters;
22 | this.metaParameters = paramsBundle.metaParameters;
23 | this.mainParameters = paramsBundle.mainParameters;
24 | }
25 |
26 | public async run(): Promise {
27 | try {
28 | this.generateNodeMainDocs();
29 |
30 | if (this.metaParameters.authType !== AuthEnum.None) {
31 | this.generateNodeCredentialDocs();
32 | }
33 |
34 | await this.verifyGeneratedDocsFiles();
35 | return { completed: true };
36 | } catch (error) {
37 | return { completed: false, error };
38 | }
39 | }
40 |
41 | /**Generate the node functionality documentation file.*/
42 | private generateNodeMainDocs() {
43 | const command = this.formatCommand(`
44 | gen generateNodeMainDocs
45 | --name '${this.metaParameters.serviceName}'
46 | --docsParameters '${JSON.stringify(this.docsParameters)}'
47 | --nodeOperations '${JSON.stringify(this.getNodeOperations())}'
48 | `);
49 |
50 | exec(command);
51 | }
52 |
53 | /**Generate the node credential documentation file.*/
54 | private generateNodeCredentialDocs() {
55 | const command = this.formatCommand(`
56 | gen generateNodeCredentialDocs
57 | --name '${this.metaParameters.serviceName}'
58 | --docsParameters '${JSON.stringify(this.docsParameters)}'
59 | --metaParameters '${JSON.stringify(this.metaParameters)}'
60 | `);
61 |
62 | exec(command);
63 | }
64 |
65 | private getNodeOperations() {
66 | const nodeOperations: { [key: string]: string[] } = {};
67 |
68 | Object.keys(this.mainParameters).forEach((resource) => {
69 | if (areTriggerNodeParameters(this.mainParameters)) {
70 | throw Error("Node operations cannot be generated for trigger nodes!");
71 | }
72 |
73 | nodeOperations[resource] = this.mainParameters[resource].map(
74 | (operation) => operation.description
75 | );
76 | });
77 |
78 | return nodeOperations;
79 | }
80 |
81 | /**Verify if the one to two files that are to be generated by `generateNodeDocsFiles` were actually generated.*/
82 | private async verifyGeneratedDocsFiles() {
83 | const files = await this.readdir(join("output"));
84 |
85 | const wasGenerated = (snippet: string) =>
86 | files.some((file) => file.endsWith(snippet));
87 |
88 | const filesToBeVerified = [this.getMainDocFilename() + ".md"];
89 |
90 | if (this.metaParameters.authType !== AuthEnum.None) {
91 | filesToBeVerified.push("Credentials.md");
92 | }
93 |
94 | filesToBeVerified.forEach((file) => {
95 | if (!wasGenerated(file)) {
96 | throw Error("Generation failed for file: " + file);
97 | }
98 | });
99 | }
100 |
101 | private getMainDocFilename() {
102 | return this.metaParameters.serviceName.replace(/\s/g, "");
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/generators/NodeFilesGenerator.ts:
--------------------------------------------------------------------------------
1 | import { execSync as exec } from "child_process"; // sync to facilitate subsequent verification
2 | import { join } from "path";
3 | import { sortBy } from "underscore";
4 | import Generator from "./Generator";
5 | import { NodeGenerationEnum, AuthEnum } from "../utils/enums";
6 | import { readdirSync } from "fs";
7 | import {
8 | areTriggerNodeParameters,
9 | isManyValuesGroupField,
10 | isOptionWithMaxNesting,
11 | } from "../utils/typeGuards";
12 |
13 | /**Responsible for generating all node functionality files at `/output`:
14 | * - `*.node.ts`
15 | * - `GenericFunctions.ts`
16 | * - `*.credentials.ts`
17 | * - one or more `*Description.ts` files (in complex node generation)
18 | */
19 | export default class NodeFilesGenerator extends Generator {
20 | private metaParameters: MetaParameters;
21 | private mainParameters: MainParameters;
22 | private nodeGenerationType: NodeGenerationType;
23 | private nodeType: NodeType;
24 |
25 | constructor(paramsBundle: NodegenParamsBundle) {
26 | super();
27 | this.mainParameters = paramsBundle.mainParameters;
28 | this.metaParameters = paramsBundle.metaParameters;
29 | this.nodeGenerationType = paramsBundle.nodeGenerationType;
30 | this.nodeType = paramsBundle.nodeType;
31 | }
32 |
33 | /**Generate all node functionality files.*/
34 | async run(): Promise {
35 | try {
36 | this.sortOptionsAlphabetically();
37 |
38 | this.generateMainNodeFile();
39 | this.generateGenericFunctionsFile();
40 |
41 | if (this.nodeGenerationType === NodeGenerationEnum.Complex) {
42 | this.generateResourceDescriptionFile();
43 | }
44 |
45 | if (this.metaParameters.authType !== AuthEnum.None) {
46 | this.generateCredentialsFile();
47 | }
48 |
49 | this.verifyGeneratedFuncFiles();
50 | return { completed: true };
51 | } catch (error) {
52 | return { completed: false, error };
53 | }
54 | }
55 |
56 | /**Sort all options (first and second/max nested) in `regularNodeParameters` alphabetically. See n8n submission guidelines:
57 | * _"Ensure that all the options are ordered alphabetically, unless a different order is needed for a specific reason"_
58 | * https://github.com/n8n-io/n8n/blob/master/CONTRIBUTING.md#checklist-before-submitting-a-new-node"*/
59 | // prettier-ignore
60 | private sortOptionsAlphabetically() {
61 | if (areTriggerNodeParameters(this.mainParameters)) return; // skip trigger node params
62 |
63 | for (let resource in this.mainParameters) {
64 | this.mainParameters[resource].forEach((operation) => {
65 | operation.fields.forEach((field) => {
66 | // first nested level
67 | if (isManyValuesGroupField(field)) {
68 | field.options = sortBy(field.options, (option) => option.name);
69 | field.options.forEach((option) => {
70 | // second/max nested level
71 | if (isOptionWithMaxNesting(option)) {
72 | option.options = sortBy(option.options, (option) => option.name);
73 | }
74 | });
75 | }
76 | });
77 | });
78 | }
79 | }
80 |
81 | /**Generate `*.node.ts` (regular node) or `*Trigger.node.ts` (trigger node), with a different version for simple or complex node generation.*/
82 | private generateMainNodeFile() {
83 | const command = this.formatCommand(`
84 | gen generate${this.nodeType}Node${this.nodeGenerationType}
85 | --name '${this.metaParameters.serviceName}'
86 | --metaParameters '${JSON.stringify(this.metaParameters)}'
87 | --mainParameters '${JSON.stringify(this.mainParameters)}'
88 | `);
89 | exec(command);
90 | }
91 |
92 | /**Generate `GenericFunctions.ts`.*/
93 | private generateGenericFunctionsFile() {
94 | const command = this.formatCommand(`
95 | gen generateGenericFunctions
96 | --metaParameters '${JSON.stringify(this.metaParameters)}'
97 | --mainParameters '${JSON.stringify(this.mainParameters)}'
98 | `);
99 |
100 | exec(command);
101 | }
102 |
103 | /**Generate `*.credentials.ts` for OAuth2 or API Key.*/
104 | private generateCredentialsFile() {
105 | const command = this.formatCommand(`
106 | gen generate${this.metaParameters.authType}Credential
107 | --name '${this.metaParameters.serviceName}'
108 | --serviceCredential ${this.getServiceCredentialName(this.metaParameters)}
109 | `);
110 |
111 | exec(command);
112 | }
113 |
114 | /** In complex node generation, generate one additional file per resource.*/
115 | private generateResourceDescriptionFile() {
116 | if (areTriggerNodeParameters(this.mainParameters)) {
117 | throw Error("Descriptions cannot be generated for trigger nodes!");
118 | }
119 |
120 | for (let resourceName in this.mainParameters) {
121 | const command = this.formatCommand(`
122 | gen generateResourceDescription
123 | --resourceName ${resourceName}
124 | --resourceObject '${JSON.stringify(this.mainParameters[resourceName])}'
125 | `);
126 |
127 | exec(command);
128 | }
129 | }
130 |
131 | /**Verify if the two to four files that are to be generated by `generateNodeFuncFiles` were actually generated.*/
132 | private verifyGeneratedFuncFiles() {
133 | let files = readdirSync(join("output"));
134 |
135 | const wasGenerated = (snippet: string) =>
136 | files.some((file) => file.endsWith(snippet));
137 |
138 | const filesToBeVerified = [".node.ts", "GenericFunctions.ts"];
139 |
140 | if (this.nodeGenerationType === NodeGenerationEnum.Complex)
141 | filesToBeVerified.push("Description.ts");
142 |
143 | if (this.metaParameters.authType === AuthEnum.OAuth2)
144 | filesToBeVerified.push("OAuth2Api.credentials.ts");
145 |
146 | filesToBeVerified.forEach((file) => {
147 | if (!wasGenerated(file)) {
148 | throw Error("Generation failed for file: " + file);
149 | }
150 | });
151 | }
152 | }
153 |
--------------------------------------------------------------------------------
/generators/PackageJsonGenerator.ts:
--------------------------------------------------------------------------------
1 | import { exec } from "child_process";
2 | import fs from "fs";
3 | import fetch from "node-fetch";
4 | import { join } from "path";
5 | import Generator from "./Generator";
6 | import { PACKAGE_JSON_URL } from "../utils/constants";
7 | import sleep from "../utils/sleep";
8 |
9 | export default class PackageJsonGenerator extends Generator {
10 | private readonly localPackageJsonPath = join("output", "package.json");
11 | private packageJsonData: any;
12 |
13 | constructor(private metaParameters: MetaParameters) {
14 | super();
15 | }
16 |
17 | public async run(): Promise {
18 | try {
19 | await this.getPackageJson();
20 | this.verifyIfNodeToBeCreatedExists();
21 | this.savePackageJson();
22 | this.insertCredentialInPackageJson();
23 | await sleep(1000); // to ensure both insertions succeed
24 | this.insertNodeInPackageJson();
25 | return { completed: true };
26 | } catch (error) {
27 | return { completed: false, error };
28 | }
29 | }
30 |
31 | /**Verify if the service name of the node to be created already exists in the package.json of the official n8n repo.*/
32 | private verifyIfNodeToBeCreatedExists() {
33 | const nodeToBeCreated = this.metaParameters.serviceName.replace(/\s/, "");
34 |
35 | for (let node of this.packageJsonData.n8n.nodes) {
36 | let existingNode = node.match(/dist\/nodes\/(.*)\.node\.js/)[1];
37 |
38 | // remove dir name if it exists
39 | if (existingNode.split("").includes("/")) {
40 | existingNode = existingNode.replace(/.*\//, "");
41 | }
42 |
43 | if (nodeToBeCreated === existingNode) {
44 | throw Error(
45 | "The node you are trying to create already exists in the official n8n repo.\nPlease change the serviceName of the node in metaParameters in parameters.ts."
46 | );
47 | }
48 | }
49 | }
50 |
51 | /**Insert the new node credential at their appropriate location in `package.json`.*/
52 | private insertCredentialInPackageJson() {
53 | const command = this.formatCommand(`
54 | gen updateCredentialPackageJson
55 | --serviceCredential ${this.getServiceCredentialName(this.metaParameters)}
56 | --credentialSpot ${this.findCredentialSpot()}
57 | `);
58 |
59 | exec(command);
60 | }
61 |
62 | /**Insert the new node at their appropriate location in `package.json`.*/
63 | private insertNodeInPackageJson() {
64 | const { serviceName } = this.metaParameters;
65 | const formattedServiceName = serviceName.replace(/\s/, "");
66 |
67 | const command = this.formatCommand(`
68 | gen updateNodePackageJson
69 | --serviceName ${formattedServiceName}
70 | --nodeSpot ${this.findNodeSpot()}
71 | `);
72 |
73 | exec(command);
74 | }
75 |
76 | /**Get contents of `package.json` from `packages/nodes-base` from the official n8n repo.*/
77 | private async getPackageJson() {
78 | const response = await fetch(PACKAGE_JSON_URL);
79 | this.packageJsonData = await response.json();
80 | }
81 |
82 | /**Write contents of `package.json` from `packages/nodes-base` from official repo in /output dir.*/
83 | private savePackageJson() {
84 | fs.writeFileSync(
85 | this.localPackageJsonPath,
86 | JSON.stringify(this.packageJsonData, null, 2)
87 | );
88 | }
89 |
90 | /**Find the credential right after which the new node credential is to be inserted in `package.json`.*/
91 | private findCredentialSpot() {
92 | const serviceCredential = this.getServiceCredentialName(
93 | this.metaParameters
94 | );
95 | for (let credential of this.packageJsonData.n8n.credentials) {
96 | const relevantString = credential.slice(17);
97 | if (relevantString[0] < serviceCredential[0]) {
98 | continue;
99 | }
100 | return relevantString.replace(".credentials.js", "");
101 | }
102 |
103 | throw Error(
104 | "No spot for node credential path insertion found in package.json retrieved from official n8n repository."
105 | );
106 | }
107 |
108 | /**Find the node right after which the new node is to be inserted in `package.json`.*/
109 | private findNodeSpot() {
110 | for (let node of this.packageJsonData.n8n.nodes) {
111 | const pathMatch = node.match(/dist\/nodes\/(.*)\.node\.js/);
112 |
113 | if (pathMatch === null) {
114 | throw Error(
115 | "No path match found in package.json retrieved from official n8n repository."
116 | );
117 | }
118 |
119 | const relevantString = pathMatch[1];
120 |
121 | if (relevantString[0] < this.metaParameters.serviceName[0]) {
122 | continue;
123 | }
124 | return relevantString.replace(".node.js", "");
125 | }
126 | throw Error(
127 | "No spot for node path insertion found in package.json retrieved from official n8n repository."
128 | );
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/globals.d.ts:
--------------------------------------------------------------------------------
1 | // ********************************************************************
2 | // Env vars-related
3 | // ********************************************************************
4 |
5 | declare namespace NodeJS {
6 | export interface ProcessEnv {
7 | GOOGLE_IMAGE_SEARCH_ENGINE_ID: string;
8 | GOOGLE_PROJECT_API_KEY: string;
9 | N8N_LOGIN_USERNAME: string;
10 | N8N_LOGIN_PASSWORD: string;
11 | IMGBB_API_KEY: string;
12 | }
13 | }
14 |
15 | // ********************************************************************
16 | // Interface-related
17 | // ********************************************************************
18 |
19 | type RequesterInputType =
20 | | string
21 | | NodegenParamsBundle
22 | | DocsgenParamsBundle
23 | | PlacementChannelArgument
24 | | PackgenChannelArgument
25 | | EmptyChannelArgument;
26 |
27 | // TODO - Refactor once UI is finished
28 | // prettier-ignore
29 | type RequesterOutputType =
30 | T extends string ? string :
31 | T extends NodegenParamsBundle ? BackendOperationResult :
32 | T extends DocsgenParamsBundle ? BackendOperationResult :
33 | T extends PlacementChannelArgument ? BackendOperationResult :
34 | T extends PackgenChannelArgument ? BackendOperationResult :
35 | T extends EmptyChannelArgument ? BackendOperationResult :
36 | never;
37 |
38 | type BackendOperationResult =
39 | | { completed: true }
40 | | { completed: false; error: any };
41 |
42 | type PlacementChannelArgument = {
43 | filesToPlace: "functionality" | "documentation";
44 | };
45 |
46 | type PackgenChannelArgument = MetaParameters;
47 |
48 | type EmptyChannelArgument = void;
49 |
50 | // ********************************************************************
51 | // CLI-related
52 | // ********************************************************************
53 |
54 | type HighlighterArgument = {
55 | result: BackendOperationResult;
56 | successMessage: string;
57 | inspectMessage?: boolean;
58 | };
59 |
60 | // ********************************************************************
61 | // Bundle-related
62 | // ********************************************************************
63 |
64 | type NodegenParamsBundle = {
65 | metaParameters: MetaParameters;
66 | mainParameters: MainParameters;
67 | nodeGenerationType: NodeGenerationType;
68 | nodeType: NodeType;
69 | };
70 |
71 | type NodeGenerationType = "Simple" | "Complex";
72 |
73 | type NodeType = "Regular" | "Trigger";
74 |
75 | type DocsgenParamsBundle = {
76 | metaParameters: MetaParameters;
77 | mainParameters: MainParameters;
78 | docsParameters: DocsParameters;
79 | };
80 |
81 | type IconCandidate = "1" | "2" | "3" | "4" | "5";
82 |
83 | // ********************************************************************
84 | // Parameters-related
85 | // ********************************************************************
86 |
87 | // ----------------------------------
88 | // Meta parameters
89 | // ----------------------------------
90 |
91 | type MetaParameters = {
92 | serviceName: string;
93 | authType: AuthType;
94 | nodeColor: string;
95 | apiUrl: string;
96 | };
97 |
98 | type AuthType = "OAuth2" | "API Key" | "None";
99 |
100 | // ----------------------------------
101 | // Docs parameters
102 | // ----------------------------------
103 |
104 | type DocsParameters = {
105 | serviceName: string;
106 | serviceUrl: string;
107 | introDescription: string;
108 | exampleUsage: string;
109 | workflowUrl: string;
110 | };
111 |
112 | // ----------------------------------
113 | // Main parameters
114 | // ----------------------------------
115 |
116 | type MainParameters = RegularNodeParameters | TriggerNodeParameters;
117 |
118 | // ----------------------------------
119 | // Trigger node parameters
120 | // ----------------------------------
121 |
122 | type TriggerNodeParameters = {
123 | webhookEndpoint: string;
124 | webhookProperties: WebhookProperty[];
125 | };
126 |
127 | type WebhookProperty = {
128 | displayName: string;
129 | name: string;
130 | required: boolean;
131 | description: string;
132 | type: SingleValueFieldType | CollectionType | OptionsType;
133 | default: FieldDefault;
134 | options?: WebhookPropertyOption[]; // only for `type: OptionsType`
135 | };
136 |
137 | type WebhookPropertyOption = {
138 | name: string;
139 | description: string;
140 | value: string;
141 | fields?: WebhookPropertyOptionField[]; // only for `type: OptionsType` in `WebhookProperty`
142 | };
143 |
144 | type WebhookPropertyOptionField = OperationField & {
145 | displayName: string;
146 | required: boolean;
147 | };
148 |
149 | // ----------------------------------
150 | // Regular node parameters
151 | // ----------------------------------
152 |
153 | type RegularNodeParameters = { [key: string]: Resource };
154 |
155 | type Resource = Operation[];
156 |
157 | type Operation = {
158 | name: string;
159 | description: string;
160 | endpoint: string;
161 | requestMethod: RequestMethod;
162 | fields: OperationField[];
163 | };
164 |
165 | type RequestMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
166 |
167 | type OperationField = SingleValueOperationField | ManyValuesGroupField;
168 |
169 | type SingleValueOperationField = {
170 | name: string;
171 | description: string;
172 | type: SingleValueFieldType;
173 | default: SingleValueFieldDefault;
174 | extraDisplayRestriction?: { [key: string]: boolean };
175 | numericalLimits?: { minLimit: number; maxLimit: number }; // for `type: number` only
176 | };
177 |
178 | type ManyValuesGroupField = {
179 | name: string;
180 | description: string;
181 | type: CollectionType | OptionsType;
182 | default: ManyValuesFieldDefault;
183 | options: ManyValuesGroupFieldOption[];
184 | extraDisplayRestriction?: { [key: string]: boolean };
185 | };
186 |
187 | type SingleValueFieldType = "string" | "number" | "boolean";
188 |
189 | type OptionsType = "options" | "multiOptions";
190 |
191 | type CollectionType = "collection" | "fixedCollection";
192 |
193 | type FieldType = SingleValueFieldType | OptionsType | CollectionType;
194 |
195 | type FieldDefault = SingleValueFieldDefault | ManyValuesFieldDefault;
196 |
197 | type SingleValueFieldDefault = string | number | boolean;
198 |
199 | type ManyValuesFieldDefault = {};
200 |
201 | type ManyValuesGroupFieldOption = {
202 | name: string;
203 | description: string;
204 | type: SingleValueFieldType | OptionsType;
205 | default: FieldDefault;
206 | options?: MaxNestedFieldOption[];
207 | };
208 |
209 | type MaxNestedFieldOption = {
210 | name: string;
211 | description: string;
212 | };
213 |
214 | type OptionWithMaxNesting = ManyValuesGroupFieldOption & {
215 | options: MaxNestedFieldOption[];
216 | }; // only used for type guard
217 |
218 |
219 | // ********************************************************************
220 | // Frontend Types
221 | // ********************************************************************
222 | type FrontendNodeType = NodeType | "";
223 |
224 | type BasicInfo = MetaParameters & {
225 | webhookEndpoint: string;
226 | };
227 |
228 | type BasicInfoState = {
229 | basicInfo: BasicInfo;
230 | nodeType: FrontendNodeType;
231 | documentation: boolean;
232 | };
233 |
234 | type DocsInfoState = {
235 | docsInfo: DocsParameters;
236 | };
237 |
238 | type FrontendResource = FrontendAdditionalProps & {
239 | text: string;
240 | };
241 |
242 | type ResourcesState = {
243 | resources: FrontendResource[];
244 | };
245 |
246 | type FrontendOperation = Operation & FrontendAdditionalProps & {
247 | resource: string;
248 | };
249 |
250 | type OperationsState = {
251 | operations: FrontendOperation[];
252 | };
253 |
254 | type AssociatedProps = FrontendAdditionalProps & {
255 | value: string;
256 | };
257 |
258 | type FrontendRegularField = OperationField & FrontendAdditionalProps & {
259 | resourceOperation: AssociatedProps[];
260 | options: FrontendOption[];
261 | displayRestrictions: string;
262 | min?: string;
263 | max?: string;
264 | };
265 |
266 | type FrontendTriggerField = WebhookPropertyOptionField & FrontendAdditionalProps & {
267 | resourceOperation: AssociatedProps[];
268 | options: FrontendOption[];
269 | };
270 |
271 | type FrontendField = FrontendRegularField | FrontendTriggerField;
272 |
273 | type FrontendProperty = WebhookProperty & FrontendAdditionalProps & {
274 | resource: string;
275 | };
276 |
277 | type PropertyState = {
278 | properties: FrontendProperty[];
279 | };
280 |
281 | type OptionsOption = MaxNestedFieldOption & FrontendAdditionalProps;
282 |
283 | type CollectionOption = ManyValuesGroupFieldOption & FrontendAdditionalProps;
284 |
285 | type FrontendOption = CollectionOption | OptionsOption;
286 |
287 | type FieldsState = {
288 | fields: FrontendField[];
289 | };
290 |
291 | type FrontendAdditionalProps = {
292 | key: number;
293 | add?: boolean;
294 | cancel?: boolean;
295 | };
296 |
297 | type MainParametersBuilder = MainParameters & {
298 | [key: string]: any;
299 | };
300 |
--------------------------------------------------------------------------------
/output/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MLH-Fellowship/nodemaker/659c888332823f462eaff0148bab204e2be4c33f/output/.gitkeep
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nodemaker",
3 | "version": "1.0.0",
4 | "description": "Automatic node generator for n8n",
5 | "scripts": {
6 | "setup": "npm i && cd client && npm i && cd ..",
7 | "nodegen": "cp .hygen.js build/.hygen.js && tsc && node build/scripts/generateNodeFiles.js",
8 | "packgen": "tsc && node build/scripts/generatePackageJson.js",
9 | "icongen": "tsc && node build/scripts/generateIconCandidates.js",
10 | "docsgen": "tsc && node build/scripts/generateNodeDocs.js",
11 | "place": "tsc && node build/scripts/placeFiles.js",
12 | "resize": "tsc && node build/scripts/resizeIcon.js",
13 | "flowgen": "tsc && node build/scripts/createWorkflow.js",
14 | "runapp-shotgen": "concurrently --kill-others-on-fail \"npm run runapp\" \"npm run shotgen\"",
15 | "runapp": "cd ../n8n && npm run start",
16 | "shotgen": "tsc && node build/scripts/takeScreenshot.js",
17 | "empty": "tsc && node build/scripts/emptyOutputDir.js",
18 | "desktop": "cd client && npm run electron:serve",
19 | "validate": "ttsc && node build/scripts/validateParams.js"
20 | },
21 | "keywords": [
22 | "n8n"
23 | ],
24 | "author": "Iván Ovejero & Erin McNulty",
25 | "license": "MIT",
26 | "dependencies": {
27 | "chalk": "^4.1.0",
28 | "concurrently": "^5.2.0",
29 | "dotenv": "^8.2.0",
30 | "electron": "^9.1.1",
31 | "form-data": "^3.0.0",
32 | "hygen": "^5.0.3",
33 | "inquirer": "^7.3.1",
34 | "node-fetch": "^2.6.0",
35 | "puppeteer": "^5.2.1",
36 | "sharp": "^0.25.4",
37 | "typescript-is": "^0.16.3",
38 | "underscore": "^1.10.2"
39 | },
40 | "devDependencies": {
41 | "@types/inquirer": "^6.5.0",
42 | "@types/node": "^12.12.38",
43 | "@types/node-fetch": "^2.5.7",
44 | "@types/puppeteer": "^3.0.1",
45 | "@types/sharp": "^0.25.1",
46 | "@types/underscore": "^1.10.21",
47 | "ttypescript": "^1.5.10",
48 | "typescript": "^3.9.6"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/parameters.ts:
--------------------------------------------------------------------------------
1 | import { getWorkflowSubmissionUrl } from "./utils/getWorkflowSubmissionUrl";
2 |
3 | // Sample parameters to test the nodemaker CLI or to replace with your own.
4 |
5 | export const metaParameters: MetaParameters = {
6 | serviceName: "Nodemaker News",
7 | authType: "OAuth2",
8 | nodeColor: "#ff6600",
9 | apiUrl: "http://api.nodemaker.com/",
10 | };
11 |
12 | export const regularNodeParameters: RegularNodeParameters = {
13 | Article: [
14 | {
15 | name: "Get",
16 | description: "Get a Nodemaker News article",
17 | endpoint: "articles/:articleId",
18 | requestMethod: "GET",
19 | fields: [
20 | {
21 | name: "Article ID",
22 | description: "The ID of the Nodemaker News article to be returned",
23 | type: "string",
24 | default: "",
25 | },
26 | {
27 | name: "Additional Fields",
28 | type: "collection",
29 | description: "",
30 | default: {},
31 | options: [
32 | {
33 | name: "Include comments",
34 | type: "boolean",
35 | default: false,
36 | description: "Whether to include all the comments in the article",
37 | },
38 | ],
39 | },
40 | ],
41 | },
42 | {
43 | name: "Get All",
44 | description: "Get all Nodemaker News articles",
45 | endpoint: "search?",
46 | requestMethod: "GET",
47 | fields: [
48 | {
49 | name: "Return All",
50 | description: "Whether to return all results or only up to a limit",
51 | type: "boolean",
52 | default: false,
53 | },
54 | {
55 | name: "Limit",
56 | description:
57 | "Limit of Nodemaker News articles to be returned for the query",
58 | type: "number",
59 | default: 5,
60 | extraDisplayRestriction: { "Return All": true },
61 | },
62 | {
63 | name: "Additional Fields",
64 | type: "collection",
65 | description: "",
66 | default: {},
67 | options: [
68 | {
69 | name: "Keyword",
70 | description: "The keyword for filtering the results of the query",
71 | type: "multiOptions",
72 | default: "",
73 | options: [
74 | {
75 | name: "Feature1",
76 | description: "Some description",
77 | },
78 | {
79 | name: "Feature2",
80 | description: "Some other description",
81 | },
82 | ],
83 | },
84 | {
85 | name: "Tags",
86 | description: "Tags for filtering the results of the query",
87 | type: "multiOptions",
88 | default: {},
89 | options: [
90 | {
91 | name: "Story",
92 | description: "Returns query results filtered by story tag",
93 | },
94 | {
95 | name: "Comment",
96 | description: "Returns query results filtered by comment tag",
97 | },
98 | {
99 | name: "Poll",
100 | description: "Returns query results filtered by poll tag",
101 | },
102 | {
103 | name: "Show Nodemaker News",
104 | description: "Returns query results filtered by Show HN tag",
105 | },
106 | {
107 | name: "Ask Nodemaker News",
108 | description: "Returns query results filtered by Ask HN tag",
109 | },
110 | {
111 | name: "Front Page",
112 | description:
113 | "Returns query results filtered by Front Page tag",
114 | },
115 | ],
116 | },
117 | ],
118 | },
119 | ],
120 | },
121 | ],
122 | User: [
123 | {
124 | name: "Get",
125 | description: "Get a Nodemaker News user",
126 | endpoint: "users/:username",
127 | requestMethod: "GET",
128 | fields: [
129 | {
130 | name: "Username",
131 | description: "The Nodemaker News user to be returned",
132 | type: "string",
133 | default: "",
134 | },
135 | ],
136 | },
137 | ],
138 | };
139 |
140 | export const docsParameters: DocsParameters = {
141 | serviceName: "Nodemaker News",
142 | serviceUrl: "https://news.ycombinator.com",
143 | introDescription:
144 | "a social news website focusing on computer science and entrepreneurship",
145 | exampleUsage: "get an article from Nodemaker News",
146 | workflowUrl: getWorkflowSubmissionUrl(),
147 | };
148 |
149 | export const triggerNodeParameters: TriggerNodeParameters = {
150 | webhookEndpoint: "/automations/hooks",
151 | webhookProperties: [
152 | {
153 | displayName: "Event",
154 | name: "event",
155 | type: "options",
156 | required: true,
157 | default: "subscriberActivated",
158 | description:
159 | "The events that can trigger the webhook and whether they are enabled.",
160 | options: [
161 | {
162 | name: "Subscriber Activated",
163 | value: "subscriberActivated",
164 | description:
165 | "Whether the webhook is triggered when a subscriber is activated.",
166 | },
167 | {
168 | name: "Link Clicked",
169 | value: "linkClicked",
170 | description:
171 | "Whether the webhook is triggered when a link is clicked.",
172 | fields: [
173 | {
174 | displayName: "Initiating Link",
175 | name: "link",
176 | type: "string",
177 | required: true,
178 | default: "",
179 | description: "The URL of the initiating link",
180 | },
181 | ],
182 | },
183 | ],
184 | },
185 | ],
186 | };
187 |
--------------------------------------------------------------------------------
/scripts/createWorkflow.ts:
--------------------------------------------------------------------------------
1 | import WorkflowCreator from "../services/WorkflowCreator";
2 | import { docsParameters } from "../parameters";
3 | import Highlighter from "../services/Highlighter";
4 |
5 | (async () => {
6 | const creator = new WorkflowCreator(docsParameters);
7 | const result = await creator.run();
8 |
9 | Highlighter.showResult({
10 | result,
11 | successMessage: "Workflow submission on n8n.io successful.",
12 | });
13 | })();
14 |
--------------------------------------------------------------------------------
/scripts/emptyOutputDir.ts:
--------------------------------------------------------------------------------
1 | import DirectoryEmptier from "../services/DirectoryEmptier";
2 | import Highlighter from "../services/Highlighter";
3 |
4 | (async () => {
5 | const emptier = new DirectoryEmptier();
6 | const result = await emptier.run();
7 |
8 | Highlighter.showResult({
9 | result,
10 | successMessage: "Emptied /output dir successfully.",
11 | });
12 | })();
13 |
--------------------------------------------------------------------------------
/scripts/generateIconCandidates.ts:
--------------------------------------------------------------------------------
1 | import ImageFetcher from "../services/ImageFetcher";
2 | import Prompter from "../services/Prompter";
3 | import Highlighter from "../services/Highlighter";
4 |
5 | (async () => {
6 | const { imageQuery } = await Prompter.forIconGeneration();
7 |
8 | const fetcher = new ImageFetcher(imageQuery);
9 |
10 | const result = await fetcher.run();
11 |
12 | Highlighter.showResult({
13 | result,
14 | successMessage: "Icon candidates successfully generated.",
15 | inspectMessage: true,
16 | });
17 | })();
18 |
--------------------------------------------------------------------------------
/scripts/generateNodeDocs.ts:
--------------------------------------------------------------------------------
1 | import NodeDocsGenerator from "../generators/NodeDocsGenerator";
2 | import {
3 | regularNodeParameters,
4 | metaParameters,
5 | docsParameters,
6 | } from "../parameters";
7 | import Highlighter from "../services/Highlighter";
8 |
9 | (async () => {
10 | const paramsBundle = {
11 | mainParameters: regularNodeParameters,
12 | metaParameters,
13 | docsParameters,
14 | };
15 | const generator = new NodeDocsGenerator(paramsBundle);
16 | const result = await generator.run();
17 |
18 | Highlighter.showResult({
19 | result,
20 | successMessage: "Node documentation files successfully generated.",
21 | inspectMessage: true,
22 | });
23 | })();
24 |
--------------------------------------------------------------------------------
/scripts/generateNodeFiles.ts:
--------------------------------------------------------------------------------
1 | import NodeFilesGenerator from "../generators/NodeFilesGenerator";
2 | import {
3 | regularNodeParameters,
4 | triggerNodeParameters,
5 | metaParameters,
6 | } from "../parameters";
7 | import Prompter from "../services/Prompter";
8 | import { NodeTypeEnum } from "../utils/enums";
9 | import Highlighter from "../services/Highlighter";
10 |
11 | (async () => {
12 | const { nodeType } = await Prompter.forNodeType();
13 |
14 | let nodeGenerationType: NodeGenerationType;
15 |
16 | // regular node may be simple or complex, trigger node is always simple
17 | nodeType === NodeTypeEnum.Regular
18 | ? ({ nodeGenerationType } = await Prompter.forNodeGenerationType())
19 | : (nodeGenerationType = "Simple");
20 |
21 | const mainParameters =
22 | nodeType === NodeTypeEnum.Regular
23 | ? regularNodeParameters
24 | : triggerNodeParameters;
25 |
26 | const paramsBundle = {
27 | mainParameters,
28 | metaParameters,
29 | nodeType,
30 | nodeGenerationType,
31 | };
32 |
33 | const generator = new NodeFilesGenerator(paramsBundle);
34 | const result = await generator.run();
35 |
36 | Highlighter.showResult({
37 | result,
38 | successMessage: "Node functionality files successfully generated.",
39 | inspectMessage: true,
40 | });
41 | })();
42 |
--------------------------------------------------------------------------------
/scripts/generatePackageJson.ts:
--------------------------------------------------------------------------------
1 | import PackageJsonGenerator from "../generators/PackageJsonGenerator";
2 | import { metaParameters } from "../parameters";
3 | import Highlighter from "../services/Highlighter";
4 |
5 | (async () => {
6 | const generator = new PackageJsonGenerator(metaParameters);
7 | const result = await generator.run();
8 |
9 | Highlighter.showResult({
10 | result,
11 | successMessage: "Package.json successfully generated.",
12 | inspectMessage: true,
13 | });
14 | })();
15 |
--------------------------------------------------------------------------------
/scripts/placeFiles.ts:
--------------------------------------------------------------------------------
1 | import FilePlacer from "../services/FilePlacer";
2 | import Prompter from "../services/Prompter";
3 | import Highlighter from "../services/Highlighter";
4 |
5 | (async () => {
6 | const { filesToPlace } = await Prompter.forPlacement();
7 | const filePlacer = new FilePlacer();
8 |
9 | const result =
10 | filesToPlace === "Node functionality files"
11 | ? await filePlacer.placeNodeFunctionalityFiles()
12 | : await filePlacer.placeNodeDocumentationFiles();
13 |
14 | Highlighter.showResult({
15 | result,
16 | successMessage: "Placement of files successful.",
17 | });
18 | })();
19 |
--------------------------------------------------------------------------------
/scripts/resizeIcon.ts:
--------------------------------------------------------------------------------
1 | import IconResizer from "../services/IconResizer";
2 | import Prompter from "../services/Prompter";
3 | import { metaParameters } from "../parameters";
4 | import Highlighter from "../services/Highlighter";
5 |
6 | (async () => {
7 | const { iconToResize } = await Prompter.forIconResizing();
8 | const resizer = new IconResizer(metaParameters, iconToResize);
9 | const result = await resizer.run();
10 |
11 | Highlighter.showResult({
12 | result,
13 | successMessage: `Icon candidate ${iconToResize} successfully resized.`,
14 | inspectMessage: true,
15 | });
16 | })();
17 |
--------------------------------------------------------------------------------
/scripts/takeScreenshot.ts:
--------------------------------------------------------------------------------
1 | import ScreenshotTaker from "../services/ScreenshotTaker";
2 | import { metaParameters } from "../parameters";
3 | import Highlighter from "../services/Highlighter";
4 |
5 | (async () => {
6 | const taker = new ScreenshotTaker(metaParameters);
7 | const result = await taker.run();
8 |
9 | Highlighter.showResult({
10 | result,
11 | successMessage: "Screenshot generation successful.",
12 | inspectMessage: true,
13 | });
14 | })();
15 |
--------------------------------------------------------------------------------
/scripts/validateParams.ts:
--------------------------------------------------------------------------------
1 | import Validator from "../services/Validator";
2 |
3 | const dataToValidate = {
4 | serviceName: "Hacker News",
5 | authType: "OAuth2",
6 | nodeColor: "#ff6600",
7 | apiUrl: "http://hn.algolia.com/api/v1/",
8 | };
9 |
10 | const validator = new Validator();
11 |
12 | validator.validateDocsParameters(dataToValidate);
13 |
--------------------------------------------------------------------------------
/services/DirectoryEmptier.ts:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import { join } from "path";
3 | import { promisify } from "util";
4 | import FileFinder from "./FileFinder";
5 |
6 | export default class DirectoryEmptier {
7 | private readonly outputDir = join(__dirname, "..", "..", "output");
8 | private readonly iconCandidatesDir = join(this.outputDir, "icon-candidates");
9 | private readonly deleteFile = promisify(fs.unlink);
10 | private readonly deleteDir = promisify(fs.rmdir);
11 |
12 | public async run(): Promise {
13 | try {
14 | await this.deleteIconCandidatesDir();
15 | this.getFilesToBeDeleted().forEach((file) =>
16 | this.deleteFile(join("output", file))
17 | );
18 | return { completed: true };
19 | } catch (error) {
20 | return { completed: false, error };
21 | }
22 | }
23 |
24 | /**Delete the output/icon-candidates dir.*/
25 | private async deleteIconCandidatesDir() {
26 | if (fs.existsSync(this.iconCandidatesDir)) {
27 | await this.deleteDir(this.iconCandidatesDir, { recursive: true });
28 | }
29 | }
30 |
31 | /**Return all filenames in the /output dir except for `.gitkeep` and the output/icon-candidates dir and its contents.*/
32 | private getFilesToBeDeleted() {
33 | return fs
34 | .readdirSync(this.outputDir)
35 | .filter(FileFinder.isAnyButGitKeepAndIconCandidatesDir);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/services/FileFinder.ts:
--------------------------------------------------------------------------------
1 | /**Container of boolean checks to identify files in the `/output` dir.*/
2 | export default class FileFinder {
3 | /**Identify `*.node.ts`.*/
4 | public static readonly isMainFuncFile = (file: string) =>
5 | file.endsWith(".node.ts");
6 |
7 | /**Identify `*.credentials.ts`.*/
8 | public static readonly isCredFuncFile = (file: string) =>
9 | file.endsWith(".credentials.ts");
10 |
11 | /**Identify final PNG icon inside output/icon-candidates dir.*/
12 | public static readonly isIconFile = (file: string) =>
13 | !file.startsWith("icon-candidate");
14 |
15 | /**Identify the main node documentation file.*/
16 | public static readonly isMainDocFile = (file: string) =>
17 | file.endsWith(".md") && !file.endsWith("Credentials.md");
18 |
19 | /**Identify the main credentials documentation file.*/
20 | public static readonly isCredDocFile = (file: string) =>
21 | file.endsWith("Credentials.md");
22 |
23 | /**Identify node functionality files in TypeScript.*/
24 | public static readonly isFuncFileInTypeScript = (file: string) => {
25 | // TODO: Refactor
26 | return (
27 | file !== ".gitkeep" &&
28 | file !== "package.json" &&
29 | file !== "workflow.png" &&
30 | file !== "unsaved_workflow.json" &&
31 | !file.startsWith("icon-candidate") &&
32 | !file.endsWith(".credentials.ts") &&
33 | !file.endsWith(".md") &&
34 | !file.endsWith(".txt")
35 | );
36 | };
37 |
38 | /**Identify all files in the /output dir except for `.gitkeep` and the /icon-candidates dir.*/
39 | public static isAnyButGitKeepAndIconCandidatesDir = (file: string) =>
40 | file !== ".gitkeep" && file !== "icon-candidates";
41 | }
42 |
--------------------------------------------------------------------------------
/services/Highlighter.ts:
--------------------------------------------------------------------------------
1 | import chalk from "chalk";
2 |
3 | /**Responsible for logging success and error messages in the CLI for a BackendOperationResult.*/
4 | export default class Highlighter {
5 | /**Log a success or error message in a colored background in the CLI.*/
6 | static showResult(arg: HighlighterArgument) {
7 | const { result, successMessage, inspectMessage } = arg;
8 |
9 | result.completed
10 | ? this.highlight(
11 | "green",
12 | "\n" +
13 | successMessage +
14 | (inspectMessage ? "\nPlease inspect the /output directory." : "")
15 | )
16 | : this.highlight("red", result.error);
17 | }
18 |
19 | /**Log a message with a colored background in the CLI.*/
20 | static highlight(color: string, message: string) {
21 | console.log(chalk.keyword(color).inverse(message));
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/services/IconResizer.ts:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import { join } from "path";
3 | import { promisify } from "util";
4 | import sharp from "sharp";
5 |
6 | export default class ImageResizer {
7 | private readonly readFile = promisify(fs.readFile);
8 |
9 | // prettier-ignore
10 | private readonly iconCandidatesDir = join(__dirname, "..", "..", "output", "icon-candidates");
11 |
12 | constructor(private metaParameters: MetaParameters, private number: string) {}
13 |
14 | public async run(): Promise {
15 | try {
16 | this.resize(this.number);
17 | return { completed: true };
18 | } catch (error) {
19 | return { completed: false, error };
20 | }
21 | }
22 |
23 | /**Resize the selected icon candidate into a 60×60 px PNG icon.*/
24 | private async resize(number: string) {
25 | const filename = join(
26 | this.iconCandidatesDir,
27 | `icon-candidate-${number}.png`
28 | );
29 | const inputBuffer = await this.readFile(filename);
30 |
31 | const outputFile = this.metaParameters.serviceName
32 | .toLowerCase()
33 | .replace(/ /g, "");
34 |
35 | await sharp(inputBuffer)
36 | .resize({ width: 60, height: 60 })
37 | .toFile(join(this.iconCandidatesDir, `${outputFile}.png`));
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/services/ImageFetcher.ts:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import { join } from "path";
3 | import fetch from "node-fetch";
4 | import { CUSTOM_SEARCH_API_URL } from "../utils/constants";
5 | import config from "../config";
6 |
7 | /**Responsible for finding, fetching and saving to disk images for the icon candidates.*/
8 | export default class ImageFetcher {
9 | private imageObject: any;
10 | private imageLinks: string[] = [];
11 | // prettier-ignore
12 | private readonly iconCandidatesDir = join(__dirname, "..", "..", "output", "icon-candidates");
13 |
14 | constructor(private imageQuery: string) {}
15 |
16 | public async run(): Promise {
17 | try {
18 | await this.fetchImageObject(this.imageQuery);
19 | this.extractImageLinks();
20 | this.downloadIconCandidates();
21 | return { completed: true };
22 | } catch (error) {
23 | return { completed: false, error };
24 | }
25 | }
26 |
27 | /**Fetch an image object from Google's Custom Search Engine based on the input query.*/
28 | private async fetchImageObject(query: string) {
29 | const endpoint = `
30 | ${CUSTOM_SEARCH_API_URL}?
31 | q="${query}"&
32 | cx=${config.googleImageSearch.engineId}&
33 | key=${config.googleImageSearch.apiKey}&
34 | searchType=image&
35 | imgSize=medium&
36 | filetype=png&
37 | num=5&
38 | start=1
39 | `.replace(/\s/g, "");
40 |
41 | const response = await fetch(endpoint);
42 | this.imageObject = await response.json();
43 | }
44 |
45 | /**Extract all the URLs of the image candidates in the image object.*/
46 | private extractImageLinks() {
47 | this.imageObject.items.forEach((item: any) =>
48 | this.imageLinks.push(item.link)
49 | );
50 | }
51 |
52 | /**Download and write to disk all the image candidates.*/
53 | private downloadIconCandidates() {
54 | this.imageLinks.forEach(async (imageLink, index) => {
55 | const response = await fetch(imageLink);
56 |
57 | if (!fs.existsSync(this.iconCandidatesDir)) {
58 | fs.mkdirSync(this.iconCandidatesDir);
59 | }
60 |
61 | const outputFile = fs.createWriteStream(
62 | join(this.iconCandidatesDir, `icon-candidate-${index + 1}.png`)
63 | );
64 |
65 | response.body.pipe(outputFile);
66 | });
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/services/Prompter.ts:
--------------------------------------------------------------------------------
1 | import inquirer from "inquirer";
2 |
3 | /**Responsible for prompting the user to select choices or enter input.*/
4 | export default class Prompter {
5 | private static readonly nodeGenerationTypePrompt = [
6 | {
7 | name: "nodeGenerationType",
8 | type: "list",
9 | message:
10 | "Node generation type?\n - Simple: Node resources in single file.\n - Complex: Node resources in Description files.\n",
11 | choices: ["Simple", "Complex"],
12 | },
13 | ];
14 |
15 | private static readonly nodeTypePrompt = [
16 | {
17 | name: "nodeType",
18 | type: "list",
19 | message:
20 | "Node type?\n - Regular: Called when the workflow is executed.\n - Trigger: Called when the workflow is activated.\n",
21 | choices: ["Regular", "Trigger"],
22 | },
23 | ];
24 |
25 | private static readonly placementPrompt = [
26 | {
27 | name: "filesToPlace",
28 | type: "list",
29 | message:
30 | "Which files to place?\n - Node functionality files → n8n repo: *.node.ts, GenericFunctions.ts, *.credentials.ts, PNG icon, etc.\n - Node documentation files → n8n-docs repo: Markdown files for node and credentials, workflow screencap, etc.\n",
31 | choices: ["Node functionality files", "Node documentation files"],
32 | },
33 | ];
34 |
35 | private static readonly iconQueryPrompt = [
36 | {
37 | name: "imageQuery",
38 | type: "input",
39 | message: "Enter query string for image search:",
40 | },
41 | ];
42 |
43 | private static readonly iconNumberPrompt = [
44 | {
45 | name: "iconToResize",
46 | type: "list",
47 | message:
48 | "Which icon to resize?\n - Inspect /output/icon-candidates and select the icon by its number\n",
49 | choices: "12345".split(""),
50 | },
51 | ];
52 |
53 | static async forNodeGenerationType() {
54 | return inquirer.prompt<{ nodeGenerationType: NodeGenerationType }>(
55 | this.nodeGenerationTypePrompt
56 | );
57 | }
58 |
59 | static async forNodeType() {
60 | return inquirer.prompt<{ nodeType: NodeType }>(this.nodeTypePrompt);
61 | }
62 |
63 | static async forPlacement() {
64 | return inquirer.prompt<{ filesToPlace: string }>(this.placementPrompt);
65 | }
66 |
67 | static async forIconGeneration() {
68 | return inquirer.prompt<{ imageQuery: string }>(this.iconQueryPrompt);
69 | }
70 |
71 | static async forIconResizing() {
72 | return await inquirer.prompt<{ iconToResize: IconCandidate }>(
73 | this.iconNumberPrompt
74 | );
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/services/ScreenshotTaker.ts:
--------------------------------------------------------------------------------
1 | import puppeteer from "puppeteer";
2 | import fetch from "node-fetch";
3 | import FormData from "form-data";
4 | import { N8N_APP_LOCALHOST } from "../utils/constants";
5 | import { join } from "path";
6 | import { IMGBB_API_URL } from "../utils/constants";
7 | import config from "../config";
8 | import { readFileSync, writeFileSync } from "fs";
9 |
10 | /**Responsible for running n8n and taking a screenshot for the docs.*/
11 | export default class ScreenshotTaker {
12 | private browser: puppeteer.Browser;
13 | private page: puppeteer.Page; // browser tab
14 | private readonly pngSavePath = join("output", "workflow.png"); // in-app screenshot
15 | // prettier-ignore
16 | private readonly imageUploadUrlSavePath = join("output", "image-upload-url.txt"); // uploaded image URL
17 |
18 | constructor(private metaParameters: MetaParameters) {}
19 |
20 | public async run(): Promise {
21 | try {
22 | await this.init();
23 | await this.useChromeInstance();
24 | await this.uploadImage();
25 | return { completed: true };
26 | } catch (error) {
27 | return { completed: false, error };
28 | }
29 | }
30 |
31 | public async init() {
32 | this.browser = await puppeteer.launch({ headless: false });
33 | this.page = await this.browser.newPage();
34 | }
35 |
36 | public async useChromeInstance() {
37 | await this.page.goto(N8N_APP_LOCALHOST);
38 | await this.placeNewNodeOnCanvas();
39 | await this.connectStartToNewNode();
40 | await this.page.screenshot({ path: this.pngSavePath });
41 | await this.getJsonNodeCode();
42 | }
43 |
44 | private async placeNewNodeOnCanvas() {
45 | const nodeCreatorButtonSelector = ".node-creator-button";
46 | const nodeFilterSelector = 'input[placeholder="Type to filter..."]';
47 | const nodeDivSelector = ".node-item.clickable.active";
48 | const closeButtonSelector = ".close-button.clickable.close-on-click";
49 |
50 | await this.page.waitFor(1000); // waiting for UI element fails
51 | await this.page.mouse.click(400, 300); // spot where new node will appear
52 | await this.page.click(nodeCreatorButtonSelector);
53 | await this.page.type(nodeFilterSelector, this.metaParameters.serviceName);
54 |
55 | await this.page.waitFor(nodeDivSelector);
56 | await this.page.click(nodeDivSelector);
57 |
58 | await this.page.waitFor(closeButtonSelector);
59 | await this.page.click(closeButtonSelector);
60 | }
61 |
62 | private async connectStartToNewNode() {
63 | await this.page.waitFor(1000);
64 | await this.page.mouse.click(360, 350); // circular connector
65 | await this.page.mouse.down();
66 | await this.page.mouse.move(410, 350); // rectangular connector
67 | await this.page.mouse.up();
68 | }
69 |
70 | /**Upload image to https://imgbb.com and return URL.*/
71 | public async uploadImage() {
72 | const imageFile = readFileSync(this.pngSavePath, { encoding: "base64" });
73 | const formData = new FormData();
74 | formData.append("image", imageFile);
75 |
76 | const url = `${IMGBB_API_URL}?key=${config.imgbb.apiKey}`;
77 |
78 | const options = {
79 | method: "POST",
80 | body: formData,
81 | headers: formData.getHeaders(),
82 | };
83 |
84 | const response = await fetch(url, options);
85 | const jsonResponse = await response.json();
86 | this.saveImageUploadUrl(jsonResponse.data.url);
87 | }
88 |
89 | /**TODO - Temporary function to save image upload URL to a TXT file.
90 | * To be adjusted once UI is further developed.*/
91 | private saveImageUploadUrl(url: string) {
92 | writeFileSync(this.imageUploadUrlSavePath, url, "utf8");
93 | }
94 |
95 | private async getJsonNodeCode() {
96 | await this.setDownloadDirectory();
97 | await this.downloadJsonNodeCode();
98 | }
99 |
100 | private async setDownloadDirectory() {
101 | // @ts-ignore
102 | await this.page._client.send("Page.setDownloadBehavior", {
103 | behavior: "allow",
104 | downloadPath: join(__dirname, "..", "..", "output"),
105 | });
106 | }
107 |
108 | private async downloadJsonNodeCode() {
109 | await this.page.evaluate(() => {
110 | const findNodeBySelectorAndRegex = (selector: string, regexp: RegExp) => {
111 | const elements = Array.from(
112 | document.querySelectorAll(selector)
113 | );
114 | const matches = elements.filter((e) => e.innerText.match(regexp));
115 | return matches[0];
116 | };
117 |
118 | findNodeBySelectorAndRegex("span", /Download/).click();
119 | });
120 |
121 | await this.page.waitFor(2000);
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/services/Validator.ts:
--------------------------------------------------------------------------------
1 | import { assertType } from "typescript-is";
2 |
3 | /**Responsible for validating an unknown object against a type and reporting missing properties or unexpected property types. Intended for troubleshooting why an object built on the frontend is failing to generate a node.*/
4 | export default class Validator {
5 | private handleValidation(
6 | callback: () => void,
7 | hideStack = true,
8 | hideInput = true,
9 | hideMessage = true
10 | ) {
11 | try {
12 | callback();
13 | console.log("Parameters validation succeeded!");
14 | } catch (error) {
15 | if (hideStack) delete error.stack;
16 | if (hideInput) delete error.input;
17 | if (hideMessage) delete error.message;
18 | console.error(error);
19 | }
20 | }
21 |
22 | // TODO - De-duplicate these methods by abstracting away the type.
23 | // Beware: https://github.com/woutervh-/typescript-is/issues/32
24 | // Beware: https://github.com/woutervh-/typescript-is/blob/master/README.md#-what-it-wont-do
25 | // validate(params: any) {
26 | // this.handleValidation(() => assertType(params));
27 | // }
28 |
29 | public validateMetaParameters(metaParameters: any) {
30 | this.handleValidation(() => assertType(metaParameters));
31 | }
32 |
33 | public validateRegularNodeParameters(regularNodeParameters: any) {
34 | this.handleValidation(() =>
35 | assertType(regularNodeParameters)
36 | );
37 | }
38 |
39 | public validateTriggerNodeParameters(triggerNodeParameters: any) {
40 | this.handleValidation(() =>
41 | assertType(triggerNodeParameters)
42 | );
43 | }
44 |
45 | public validateDocsParameters(docsParameters: any) {
46 | this.handleValidation(() => assertType(docsParameters));
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/services/WorkflowCreator.ts:
--------------------------------------------------------------------------------
1 | import puppeteer from "puppeteer";
2 | import config from "../config";
3 | import { N8N_HOMEPAGE_URL } from "../utils/constants";
4 | import fs from "fs";
5 | import { join } from "path";
6 |
7 | /**Responsible for logging into the n8n website and creating a workflow.*/
8 | export default class WorkflowCreator {
9 | private browser: puppeteer.Browser;
10 | private page: puppeteer.Page; // browser tab
11 | private readonly nodeCodeSavePath = join("output", "unsaved_workflow.json");
12 | // prettier-ignore
13 | private readonly workflowUrlSavePath = join("output", "workflow-submission-url.txt");
14 | // prettier-ignore
15 | private readonly imageUploadUrlSavePath = join("output", "image-upload-url.txt");
16 |
17 | constructor(private docsParameters: DocsParameters) {}
18 |
19 | public async run(): Promise {
20 | try {
21 | await this.init();
22 | await this.doLogin();
23 | await this.createWorkflow();
24 | await this.close();
25 | return { completed: true };
26 | } catch (error) {
27 | return { completed: false, error };
28 | }
29 | }
30 |
31 | private async init() {
32 | this.browser = await puppeteer.launch({
33 | headless: false,
34 | args: ["--start-maximized"],
35 | });
36 | this.page = await this.browser.newPage();
37 | await this.page.setViewport({ width: 1100, height: 800 }); // prevent mobile layout
38 | }
39 |
40 | private async doLogin() {
41 | const { username, password } = config.n8n;
42 | const homepageLoginButtonSelector = "a[title='Login']";
43 | const usernameSelector = 'input[placeholder="Username or email address"]';
44 | const passwordSelector = 'input[placeholder="Password"]';
45 | const loginButtonSelector = 'button[type="submit"]';
46 |
47 | await this.page.goto(N8N_HOMEPAGE_URL);
48 | await this.page.click(homepageLoginButtonSelector);
49 |
50 | await this.page.waitFor(usernameSelector);
51 |
52 | await this.page.type(usernameSelector, username);
53 | await this.page.type(passwordSelector, password);
54 | await this.page.click(loginButtonSelector);
55 |
56 | await this.page.waitForNavigation({ waitUntil: "networkidle0" });
57 | }
58 |
59 | private async createWorkflow() {
60 | await this.page.click('a[href="/workflows/edit"]');
61 |
62 | const nameSelector =
63 | 'input[placeholder="The name the workflow should be published as"]';
64 | const codeAreaSelector = "pre.prism-editor__code";
65 | const imageLinkTextInputSelector = 'input[placeholder="Image Text"]';
66 | const imageLinkUrlInputSelector = 'input[placeholder="Image Link"]';
67 | const imageLinkUrlSubmissionButtonSelector = ".sure";
68 |
69 | const capitalizeFirstLetter = (string: string) =>
70 | string[0].toUpperCase() + string.slice(1);
71 |
72 | const workflowTitle = capitalizeFirstLetter(
73 | this.docsParameters.exampleUsage
74 | );
75 |
76 | await this.page.waitFor(nameSelector);
77 | await this.page.type(nameSelector, workflowTitle);
78 |
79 | await this.clearTextArea(codeAreaSelector);
80 | await this.page.type(codeAreaSelector, this.readNodeCode());
81 |
82 | await this.page.evaluate(() => {
83 | const dropdownButtons = document.querySelectorAll(".dropdown-item");
84 | const imageLinkButton = dropdownButtons[6] as HTMLElement;
85 | imageLinkButton.click();
86 | });
87 |
88 | await this.page.type(imageLinkTextInputSelector, "Workflow");
89 | await this.page.type(imageLinkUrlInputSelector, this.readImageUploadUrl());
90 |
91 | await this.page.click(imageLinkUrlSubmissionButtonSelector);
92 |
93 | // TODO - Click inside `evaluate` because `this.page.click` fails to submit.
94 | await this.page.evaluate(() => {
95 | const btn = document.querySelector("button[type=submit]") as HTMLElement;
96 | btn.click();
97 | });
98 |
99 | await this.page.waitForNavigation({ waitUntil: "networkidle0" });
100 | await this.saveWorkflowUrl();
101 | }
102 |
103 | /**TODO - Temporary function to save workflow url into a TXT file.
104 | * To be adjusted once UI is further developed.*/
105 | private async saveWorkflowUrl() {
106 | fs.writeFileSync(this.workflowUrlSavePath, this.page.url(), "utf8");
107 | }
108 |
109 | /**TODO - Temporary function to read image upload URL from a TXT file.
110 | * To be adjusted once UI is further developed.*/
111 | private readImageUploadUrl() {
112 | return fs.readFileSync(this.imageUploadUrlSavePath, "utf-8");
113 | }
114 |
115 | private async clearTextArea(textArea: string) {
116 | await this.page.focus(textArea);
117 | await this.page.keyboard.down("Control");
118 | await this.page.keyboard.press("A");
119 | await this.page.keyboard.up("Control");
120 | await this.page.keyboard.press("Backspace");
121 | }
122 |
123 | private readNodeCode() {
124 | return fs.readFileSync(this.nodeCodeSavePath).toString();
125 | }
126 |
127 | private async close() {
128 | await this.page.close();
129 | await this.browser.close();
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/tester.json:
--------------------------------------------------------------------------------
1 | {
2 | "metaParameters": {
3 | "serviceName": "Nodemaker News",
4 | "authType": "OAuth2",
5 | "nodeColor": "#ffffff",
6 | "apiUrl": "http://api.com"
7 | },
8 | "mainParameters": {
9 | "Article": [
10 | {
11 | "name": "Get",
12 | "description": "Get a hacker news article",
13 | "endpoint": "/items/:articleId",
14 | "requestMethod": "GET",
15 | "fields": [
16 | {
17 | "name": "Return All",
18 | "description": "Whether to return all results or only up to a limit",
19 | "type": "boolean",
20 | "default": true
21 | },
22 | {
23 | "name": "Limit",
24 | "description": "Limit of Nodemaker News articles to be returned for the query",
25 | "type": "number",
26 | "default": 20,
27 | "extraDisplayRestriction": {
28 | "Return All": true
29 | },
30 | "numericalLimits": {
31 | "minLimit": 5,
32 | "maxLimit": 50
33 | }
34 | },
35 | {
36 | "name": "Additional Fields",
37 | "description": "",
38 | "type": "collection",
39 | "default": {},
40 | "options": [
41 | {
42 | "name": "Tags",
43 | "description": "The keyword for filtering the results of the query",
44 | "type": "multiOptions",
45 | "default": "feature1",
46 | "options": [
47 | {
48 | "name": "Feature1",
49 | "description": "Some description"
50 | },
51 | {
52 | "name": "Feature2",
53 | "description": "Some other description"
54 | }
55 | ]
56 | }
57 | ]
58 | }
59 | ]
60 | }
61 | ]
62 | },
63 | "nodeGenerationType": "Simple",
64 | "nodeType": "Regular"
65 | }
66 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "exclude": [ "output", "docs" ],
3 | "include": [
4 | "**/*.ts",
5 | "*.d.ts", "client/src/store/modules/fields.js"
6 | ],
7 | "compilerOptions": {
8 | "target": "es5",
9 | "module": "commonjs",
10 | "outDir": "./build",
11 | "strict": true,
12 | "strictPropertyInitialization": false,
13 | "esModuleInterop": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "experimentalDecorators": true,
16 | "plugins": [
17 | { "transform": "typescript-is/lib/transform-inline/transformer" }
18 | ]
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/utils/constants.ts:
--------------------------------------------------------------------------------
1 | export const PACKAGE_JSON_URL =
2 | "https://raw.githubusercontent.com/n8n-io/n8n/master/packages/nodes-base/package.json";
3 |
4 | export const CUSTOM_SEARCH_API_URL =
5 | "https://www.googleapis.com/customsearch/v1";
6 |
7 | export const N8N_HOMEPAGE_URL = "https://n8n.io";
8 |
9 | export const N8N_APP_LOCALHOST = "http://localhost:5678/workflow";
10 |
11 | export const IMGBB_API_URL = "https://api.imgbb.com/1/upload";
12 |
--------------------------------------------------------------------------------
/utils/enums.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Node generation may be:
3 | * - Simple: Output node with resource operations and fields in a single file.
4 | * - Complex: Output node with resource operations and fields in separate files.
5 | */
6 | export enum NodeGenerationEnum {
7 | Simple = "Simple",
8 | Complex = "Complex",
9 | }
10 |
11 | /**
12 | * Node type may be:
13 | * - Regular: Called when the workflow is executed.
14 | * - Trigger: Called when the workflow is activated.
15 | */
16 | export enum NodeTypeEnum {
17 | Regular = "Regular",
18 | Trigger = "Trigger",
19 | }
20 |
21 | /**API auth may be:
22 | * - OAuth2: Various OAuth2 parameters required.
23 | * - Api Key: Usually a token string credential.
24 | * - None: No credential needed.
25 | */
26 | export enum AuthEnum {
27 | OAuth2 = "OAuth2",
28 | ApiKey = "API Key",
29 | None = "None",
30 | }
31 |
--------------------------------------------------------------------------------
/utils/getWorkflowSubmissionUrl.ts:
--------------------------------------------------------------------------------
1 | import { readFileSync } from "fs";
2 | import { join } from "path";
3 |
4 | /**Extracts the workflow number from `workflow-submission-url.txt`, for use in `DocsParameters` for `docsgen`.*/
5 | export const getWorkflowSubmissionUrl = () => {
6 | try {
7 | return readFileSync(
8 | join("output", "workflow-submission-url.txt")
9 | ).toString();
10 | } catch (error) {
11 | // TODO - temporary default
12 | return "https://n8n.io/workflows/123";
13 | }
14 | };
15 |
--------------------------------------------------------------------------------
/utils/sleep.ts:
--------------------------------------------------------------------------------
1 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
2 |
3 | export default sleep;
4 |
--------------------------------------------------------------------------------
/utils/typeGuards.ts:
--------------------------------------------------------------------------------
1 | export const areTriggerNodeParameters = (
2 | params: MainParameters
3 | ): params is TriggerNodeParameters =>
4 | (params as TriggerNodeParameters).webhookProperties !== undefined;
5 |
6 | export const isManyValuesGroupField = (
7 | field: OperationField
8 | ): field is ManyValuesGroupField =>
9 | (field as ManyValuesGroupField).type === "options" ||
10 | (field as ManyValuesGroupField).type === "multiOptions" ||
11 | (field as ManyValuesGroupField).type === "collection" ||
12 | (field as ManyValuesGroupField).type === "fixedCollection";
13 |
14 | export const isOptionWithMaxNesting = (
15 | option: ManyValuesGroupFieldOption
16 | ): option is OptionWithMaxNesting => option.options !== undefined;
17 |
--------------------------------------------------------------------------------