mirror of
https://github.com/n8n-io/n8n.git
synced 2025-11-20 17:46:34 +00:00
feat: Add docs to @n8n/eslint-plugin-community-nodes (#20266)
This commit is contained in:
parent
00ee0d63eb
commit
6cb36b5194
60
packages/@n8n/eslint-plugin-community-nodes/README.md
Normal file
60
packages/@n8n/eslint-plugin-community-nodes/README.md
Normal file
@ -0,0 +1,60 @@
|
||||
# @n8n/eslint-plugin-community-nodes
|
||||
|
||||
ESLint plugin for linting n8n community node packages to ensure consistency and best practices.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
npm install --save-dev eslint @n8n/eslint-plugin-community-nodes
|
||||
```
|
||||
|
||||
**Requires ESLint `>=9` and [flat config](https://eslint.org/docs/latest/use/configure/configuration-files)
|
||||
|
||||
## Usage
|
||||
|
||||
See the [ESLint docs](https://eslint.org/docs/latest/use/configure/configuration-files) for more information about extending config files.
|
||||
|
||||
### Recommended config
|
||||
|
||||
This plugin exports a `recommended` config that enforces good practices.
|
||||
|
||||
```js
|
||||
import { n8nCommunityNodesPlugin } from '@n8n/eslint-plugin-community-nodes';
|
||||
|
||||
export default [
|
||||
// …
|
||||
n8nCommunityNodesPlugin.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
'@n8n/community-nodes/node-usable-as-tool': 'warn',
|
||||
},
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
<!-- begin auto-generated rules list -->
|
||||
|
||||
💼 Configurations enabled in.\
|
||||
⚠️ Configurations set to warn in.\
|
||||
✅ Set in the `recommended` configuration.\
|
||||
☑️ Set in the `recommendedWithoutN8nCloudSupport` configuration.\
|
||||
🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\
|
||||
💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
|
||||
|
||||
| Name | Description | 💼 | ⚠️ | 🔧 | 💡 |
|
||||
| :--------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------- | :--- | :--- | :- | :- |
|
||||
| [credential-documentation-url](docs/rules/credential-documentation-url.md) | Enforce valid credential documentationUrl format (URL or camelCase slug) | ✅ ☑️ | | | |
|
||||
| [credential-password-field](docs/rules/credential-password-field.md) | Ensure credential fields with sensitive names have typeOptions.password = true | ✅ ☑️ | | 🔧 | |
|
||||
| [credential-test-required](docs/rules/credential-test-required.md) | Ensure credentials have a credential test | ✅ ☑️ | | | 💡 |
|
||||
| [icon-validation](docs/rules/icon-validation.md) | Validate node and credential icon files exist, are SVG format, and light/dark icons are different | ✅ ☑️ | | | 💡 |
|
||||
| [no-credential-reuse](docs/rules/no-credential-reuse.md) | Prevent credential re-use security issues by ensuring nodes only reference credentials from the same package | ✅ ☑️ | | | 💡 |
|
||||
| [no-deprecated-workflow-functions](docs/rules/no-deprecated-workflow-functions.md) | Disallow usage of deprecated functions and types from n8n-workflow package | ✅ ☑️ | | | 💡 |
|
||||
| [no-restricted-globals](docs/rules/no-restricted-globals.md) | Disallow usage of restricted global variables in community nodes. | ✅ | | | |
|
||||
| [no-restricted-imports](docs/rules/no-restricted-imports.md) | Disallow usage of restricted imports in community nodes. | ✅ | | | |
|
||||
| [node-usable-as-tool](docs/rules/node-usable-as-tool.md) | Ensure node classes have usableAsTool property | ✅ ☑️ | | 🔧 | |
|
||||
| [package-name-convention](docs/rules/package-name-convention.md) | Enforce correct package naming convention for n8n community nodes | ✅ ☑️ | | | 💡 |
|
||||
| [resource-operation-pattern](docs/rules/resource-operation-pattern.md) | Enforce proper resource/operation pattern for better UX in n8n nodes | | ✅ ☑️ | | |
|
||||
|
||||
<!-- end auto-generated rules list -->
|
||||
@ -0,0 +1,94 @@
|
||||
# Enforce valid credential documentationUrl format (URL or lowercase alphanumeric slug) (`@n8n/community-nodes/credential-documentation-url`)
|
||||
|
||||
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Options
|
||||
|
||||
<!-- begin auto-generated rule options list -->
|
||||
|
||||
| Name | Description | Type |
|
||||
| :----------- | :----------------------------------------------------- | :------ |
|
||||
| `allowSlugs` | Whether to allow lowercase alphanumeric slugs with slashes | Boolean |
|
||||
| `allowUrls` | Whether to allow valid URLs | Boolean |
|
||||
|
||||
<!-- end auto-generated rule options list -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Ensures that credential `documentationUrl` values are in a valid format. For community packages, this should always be a complete URL to your documentation.
|
||||
|
||||
The lowercase alphanumeric slug option (`allowSlugs`) is only intended for internal n8n use when referring to slugs on docs.n8n.io, and should not be used in community packages. When enabled, uppercase letters in slugs will be automatically converted to lowercase.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
name = 'myApi';
|
||||
displayName = 'My API';
|
||||
documentationUrl = 'invalid-url-format'; // Not a valid URL
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
name = 'myApi';
|
||||
displayName = 'My API';
|
||||
documentationUrl = 'MyApi'; // Invalid: uppercase letters (will be autofixed to 'myapi')
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
name = 'myApi';
|
||||
displayName = 'My API';
|
||||
documentationUrl = 'my-api'; // Invalid: special characters not allowed
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
name = 'myApi';
|
||||
displayName = 'My API';
|
||||
documentationUrl = 'https://docs.myservice.com/api-setup'; // Complete URL to documentation
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
name = 'myApi';
|
||||
displayName = 'My API';
|
||||
documentationUrl = 'https://github.com/myuser/n8n-nodes-myapi#credentials'; // GitHub README section
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
By default, only URLs are allowed, which is the recommended setting for community packages.
|
||||
|
||||
The `allowSlugs` option is available for internal n8n development:
|
||||
|
||||
```json
|
||||
{
|
||||
"rules": {
|
||||
"@n8n/community-nodes/credential-documentation-url": [
|
||||
"error",
|
||||
{
|
||||
"allowSlugs": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Community package developers should keep the default settings and always use complete URLs for their documentation.
|
||||
@ -0,0 +1,45 @@
|
||||
# Ensure credential fields with sensitive names have typeOptions.password = true (`@n8n/community-nodes/credential-password-field`)
|
||||
|
||||
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Ensures that credential fields with names like "password", "secret", "token", or "key" are properly masked in the UI by having `typeOptions.password = true`.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
default: '',
|
||||
// Missing typeOptions.password
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
@ -0,0 +1,58 @@
|
||||
# Ensure credentials have a credential test (`@n8n/community-nodes/credential-test-required`)
|
||||
|
||||
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Ensures that your credentials include a `test` method to validate user credentials. This helps users verify their credentials are working correctly.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
name = 'myApi';
|
||||
displayName = 'My API';
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
// Missing test method
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
name = 'myApi';
|
||||
displayName = 'My API';
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: 'https://api.myservice.com',
|
||||
url: '/user',
|
||||
method: 'GET',
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
@ -0,0 +1,67 @@
|
||||
# Validate node and credential icon files exist, are SVG format, and light/dark icons are different (`@n8n/community-nodes/icon-validation`)
|
||||
|
||||
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Validates that your node and credential icon files exist, are in SVG format, and use the correct `file:` protocol. Icons must be different files when providing light/dark theme variants.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Node',
|
||||
name: 'myNode',
|
||||
icon: 'icons/my-icon.png', // Missing 'file:' prefix, wrong format
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Node',
|
||||
name: 'myNode',
|
||||
icon: {
|
||||
light: 'file:icons/my-icon.svg',
|
||||
dark: 'file:icons/my-icon.svg', // Same file for both themes
|
||||
},
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Node',
|
||||
name: 'myNode',
|
||||
icon: 'file:icons/my-service.svg', // Correct format
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Node',
|
||||
name: 'myNode',
|
||||
icon: {
|
||||
light: 'file:icons/my-service-light.svg',
|
||||
dark: 'file:icons/my-service-dark.svg', // Different files
|
||||
},
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
@ -0,0 +1,82 @@
|
||||
# Prevent credential re-use security issues by ensuring nodes only reference credentials from the same package (`@n8n/community-nodes/no-credential-reuse`)
|
||||
|
||||
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Ensures your nodes only reference credentials by their `name` property that match credential classes declared in your package's `package.json` file. This prevents security issues where nodes could access credentials from other packages.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
// MyApiCredential.credentials.ts
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
name = 'myApiCredential';
|
||||
displayName = 'My API';
|
||||
// ...
|
||||
}
|
||||
|
||||
// package.json: "n8n": { "credentials": ["dist/credentials/MyApiCredential.credentials.js"] }
|
||||
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Node',
|
||||
name: 'myNode',
|
||||
credentials: [
|
||||
{
|
||||
name: 'someOtherCredential', // No credential class with this name in package
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
// MyApiCredential.credentials.ts
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
name = 'myApiCredential'; // This name must match what's used in nodes
|
||||
displayName = 'My API';
|
||||
// ...
|
||||
}
|
||||
|
||||
// package.json: "n8n": { "credentials": ["dist/credentials/MyApiCredential.credentials.js"] }
|
||||
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Node',
|
||||
name: 'myNode',
|
||||
credentials: [
|
||||
{
|
||||
name: 'myApiCredential', // Matches credential class name property
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
Declare your credential files in `package.json` and ensure the credential name in nodes matches the `name` property in your credential classes:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "n8n-nodes-my-service",
|
||||
"n8n": {
|
||||
"credentials": [
|
||||
"dist/credentials/MyApiCredential.credentials.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -0,0 +1,61 @@
|
||||
# Disallow usage of deprecated functions and types from n8n-workflow package (`@n8n/community-nodes/no-deprecated-workflow-functions`)
|
||||
|
||||
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Prevents usage of deprecated functions from n8n-workflow package and suggests modern alternatives.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
import { IRequestOptions } from 'n8n-workflow';
|
||||
|
||||
export class MyNode implements INodeType {
|
||||
async execute(this: IExecuteFunctions) {
|
||||
// Using deprecated request helper function
|
||||
const response = await this.helpers.request({
|
||||
method: 'GET',
|
||||
url: 'https://api.example.com/data',
|
||||
});
|
||||
|
||||
// Using deprecated type
|
||||
const options: IRequestOptions = {
|
||||
method: 'POST',
|
||||
url: 'https://api.example.com/data',
|
||||
};
|
||||
|
||||
return [this.helpers.returnJsonArray([response])];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
import { IHttpRequestOptions } from 'n8n-workflow';
|
||||
|
||||
export class MyNode implements INodeType {
|
||||
async execute(this: IExecuteFunctions) {
|
||||
// Using modern httpRequest helper function
|
||||
const response = await this.helpers.httpRequest({
|
||||
method: 'GET',
|
||||
url: 'https://api.example.com/data',
|
||||
});
|
||||
|
||||
// Using modern type
|
||||
const options: IHttpRequestOptions = {
|
||||
method: 'POST',
|
||||
url: 'https://api.example.com/data',
|
||||
};
|
||||
|
||||
return [this.helpers.returnJsonArray([response])];
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -0,0 +1,44 @@
|
||||
# Disallow usage of restricted global variables in community nodes (`@n8n/community-nodes/no-restricted-globals`)
|
||||
|
||||
💼 This rule is enabled in the ✅ `recommended` config.
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Prevents the use of Node.js global variables that are not allowed in n8n Cloud. While these globals may be available in self-hosted environments, they are restricted on n8n Cloud for security and stability reasons.
|
||||
|
||||
Restricted globals include: `clearInterval`, `clearTimeout`, `global`, `globalThis`, `process`, `setInterval`, `setTimeout`, `setImmediate`, `clearImmediate`, `__dirname`, `__filename`.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
async execute(this: IExecuteFunctions) {
|
||||
// These globals are not allowed on n8n Cloud
|
||||
const pid = process.pid;
|
||||
const dir = __dirname;
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('This will not work on n8n Cloud');
|
||||
}, 1000);
|
||||
|
||||
return this.prepareOutputData([]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
async execute(this: IExecuteFunctions) {
|
||||
// Use n8n context methods instead
|
||||
const timezone = this.getTimezone();
|
||||
|
||||
return this.prepareOutputData([]);
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -0,0 +1,47 @@
|
||||
# Disallow usage of restricted imports in community nodes (`@n8n/community-nodes/no-restricted-imports`)
|
||||
|
||||
💼 This rule is enabled in the ✅ `recommended` config.
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Prevents importing external dependencies that are not allowed on n8n Cloud. Community nodes running on n8n Cloud are restricted to a specific set of allowed modules for security and performance reasons.
|
||||
|
||||
**Allowed modules:** `n8n-workflow`, `lodash`, `moment`, `p-limit`, `luxon`, `zod`, `crypto`, `node:crypto`
|
||||
|
||||
Relative imports (starting with `./` or `../`) are always allowed.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
import axios from 'axios'; // External dependency not allowed
|
||||
import { readFile } from 'fs'; // Node.js modules not in allowlist
|
||||
const request = require('request'); // Same applies to require()
|
||||
|
||||
// Dynamic imports are also restricted
|
||||
const module = await import('some-package');
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
import { IExecuteFunctions, INodeType } from 'n8n-workflow'; // Allowed
|
||||
import { get } from 'lodash'; // Allowed
|
||||
import moment from 'moment'; // Allowed
|
||||
import { DateTime } from 'luxon'; // Allowed
|
||||
import { createHash } from 'crypto'; // Allowed
|
||||
|
||||
import { MyHelper } from './helpers/MyHelper'; // Relative imports allowed
|
||||
import config from '../config'; // Relative imports allowed
|
||||
|
||||
export class MyNode implements INodeType {
|
||||
// ... implementation
|
||||
}
|
||||
```
|
||||
|
||||
## When This Rule Doesn't Apply
|
||||
|
||||
This rule only applies to community nodes intended for n8n Cloud. If you're building nodes exclusively for self-hosted environments, you may disable this rule, but be aware that your package will not be compatible with n8n Cloud.
|
||||
@ -0,0 +1,43 @@
|
||||
# Ensure node classes have usableAsTool property (`@n8n/community-nodes/node-usable-as-tool`)
|
||||
|
||||
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Ensures your nodes declare whether they can be used as tools in AI workflows. This property helps n8n determine if your node is suitable for AI-assisted automation.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Node',
|
||||
name: 'myNode',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
// Missing usableAsTool property
|
||||
properties: [],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Node',
|
||||
name: 'myNode',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
usableAsTool: true,
|
||||
properties: [],
|
||||
};
|
||||
}
|
||||
```
|
||||
@ -0,0 +1,52 @@
|
||||
# Enforce correct package naming convention for n8n community nodes (`@n8n/community-nodes/package-name-convention`)
|
||||
|
||||
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Validates that your package name follows the correct n8n community node naming convention. Package names must start with `n8n-nodes-` and can optionally be scoped.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-service-integration"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "nodes-my-service"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@company/my-service"
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "n8n-nodes-my-service"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@company/n8n-nodes-my-service"
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use descriptive service names: `n8n-nodes-github` rather than `n8n-nodes-api`
|
||||
- For company packages, use your organization scope: `@mycompany/n8n-nodes-internal-tool`
|
||||
@ -0,0 +1,84 @@
|
||||
# Enforce proper resource/operation pattern for better UX in n8n nodes (`@n8n/community-nodes/resource-operation-pattern`)
|
||||
|
||||
⚠️ This rule _warns_ in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Warns when a node has more than 5 operations without organizing them into resources. The resource/operation pattern improves user experience by grouping related operations together, making complex nodes easier to navigate.
|
||||
|
||||
When you have many operations, users benefit from having them organized into logical resource groups (e.g., "User", "Project", "File") rather than seeing a long flat list of operations.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Service',
|
||||
name: 'myService',
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Get User', value: 'getUser' },
|
||||
{ name: 'Create User', value: 'createUser' },
|
||||
{ name: 'Update User', value: 'updateUser' },
|
||||
{ name: 'Delete User', value: 'deleteUser' },
|
||||
{ name: 'Get Project', value: 'getProject' },
|
||||
{ name: 'Create Project', value: 'createProject' },
|
||||
{ name: 'List Files', value: 'listFiles' },
|
||||
// 7+ operations without resources - hard to navigate!
|
||||
],
|
||||
},
|
||||
// ... other properties
|
||||
],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Service',
|
||||
name: 'myService',
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'User', value: 'user' },
|
||||
{ name: 'Project', value: 'project' },
|
||||
{ name: 'File', value: 'file' },
|
||||
],
|
||||
default: 'user',
|
||||
},
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['user'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{ name: 'Get', value: 'get' },
|
||||
{ name: 'Create', value: 'create' },
|
||||
{ name: 'Update', value: 'update' },
|
||||
{ name: 'Delete', value: 'delete' },
|
||||
],
|
||||
default: 'get',
|
||||
},
|
||||
// ... similar operation blocks for 'project' and 'file' resources
|
||||
],
|
||||
};
|
||||
}
|
||||
```
|
||||
@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import { nodeConfig } from '@n8n/eslint-config/node';
|
||||
import eslintPlugin from 'eslint-plugin-eslint-plugin';
|
||||
|
||||
export default defineConfig([
|
||||
nodeConfig,
|
||||
eslintPlugin.configs.recommended,
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
allowDefaultProject: true,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// We use RuleCreator which adds this automatically
|
||||
'eslint-plugin/require-meta-docs-url': 'off',
|
||||
// typescript-eslint uses different pattern
|
||||
'eslint-plugin/require-meta-default-options': 'off',
|
||||
// Disable naming convention for plugin configs (ESLint rule names use kebab-case)
|
||||
'@typescript-eslint/naming-convention': 'off',
|
||||
// Allow default exports for ESLint plugin
|
||||
'import-x/no-default-export': 'off',
|
||||
},
|
||||
},
|
||||
]);
|
||||
@ -11,28 +11,41 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"build:docs": "eslint-doc-generator",
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome ci .",
|
||||
"lint": "eslint src",
|
||||
"lint:fix": "eslint src --fix",
|
||||
"lint:docs": "eslint-doc-generator --check",
|
||||
"test": "vitest run",
|
||||
"test:dev": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"watch": "tsc --watch"
|
||||
"watch": "tsc --watch --project tsconfig.build.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@typescript-eslint/utils": "^8.35.0"
|
||||
"@typescript-eslint/utils": "^8.35.0",
|
||||
"fastest-levenshtein": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@n8n/typescript-config": "workspace:*",
|
||||
"@n8n/vitest-config": "workspace:*",
|
||||
"@typescript-eslint/rule-tester": "^8.35.0",
|
||||
"eslint-doc-generator": "^2.2.2",
|
||||
"eslint-plugin-eslint-plugin": "^7.0.0",
|
||||
"rimraf": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">= 9"
|
||||
},
|
||||
"eslint-doc-generator": {
|
||||
"configEmoji": [
|
||||
["recommended", "✅"],
|
||||
["recommendedWithoutN8nCloudSupport", "☑️"]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
import type { ESLint, Linter } from 'eslint';
|
||||
import { rules } from './rules/index.js';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
||||
import pkg from '../package.json' with { type: 'json' };
|
||||
import { rules } from './rules/index.js';
|
||||
|
||||
const plugin = {
|
||||
meta: {
|
||||
name: pkg.name,
|
||||
version: pkg.version,
|
||||
namespace: '@n8n/eslint-plugin-community-nodes',
|
||||
namespace: '@n8n/community-nodes',
|
||||
},
|
||||
// @ts-expect-error Rules type does not match for typescript-eslint and eslint
|
||||
rules: rules as ESLint.Plugin['rules'],
|
||||
@ -18,38 +17,43 @@ const configs = {
|
||||
recommended: {
|
||||
ignores: ['eslint.config.{js,mjs,ts,mts}'],
|
||||
plugins: {
|
||||
'@n8n/eslint-plugin-community-nodes': plugin,
|
||||
'@n8n/community-nodes': plugin,
|
||||
},
|
||||
rules: {
|
||||
'@n8n/eslint-plugin-community-nodes/no-restricted-globals': 'error',
|
||||
'@n8n/eslint-plugin-community-nodes/no-restricted-imports': 'error',
|
||||
'@n8n/eslint-plugin-community-nodes/credential-password-field': 'error',
|
||||
'@n8n/eslint-plugin-community-nodes/no-deprecated-workflow-functions': 'error',
|
||||
'@n8n/eslint-plugin-community-nodes/node-usable-as-tool': 'error',
|
||||
'@n8n/eslint-plugin-community-nodes/package-name-convention': 'error',
|
||||
'@n8n/eslint-plugin-community-nodes/credential-test-required': 'error',
|
||||
'@n8n/eslint-plugin-community-nodes/no-credential-reuse': 'error',
|
||||
'@n8n/eslint-plugin-community-nodes/icon-validation': 'error',
|
||||
'@n8n/eslint-plugin-community-nodes/resource-operation-pattern': 'warn',
|
||||
'@n8n/community-nodes/no-restricted-globals': 'error',
|
||||
'@n8n/community-nodes/no-restricted-imports': 'error',
|
||||
'@n8n/community-nodes/credential-password-field': 'error',
|
||||
'@n8n/community-nodes/no-deprecated-workflow-functions': 'error',
|
||||
'@n8n/community-nodes/node-usable-as-tool': 'error',
|
||||
'@n8n/community-nodes/package-name-convention': 'error',
|
||||
'@n8n/community-nodes/credential-test-required': 'error',
|
||||
'@n8n/community-nodes/no-credential-reuse': 'error',
|
||||
'@n8n/community-nodes/icon-validation': 'error',
|
||||
'@n8n/community-nodes/resource-operation-pattern': 'warn',
|
||||
'@n8n/community-nodes/credential-documentation-url': 'error',
|
||||
},
|
||||
},
|
||||
recommendedWithoutN8nCloudSupport: {
|
||||
ignores: ['eslint.config.{js,mjs,ts,mts}'],
|
||||
plugins: {
|
||||
'@n8n/eslint-plugin-community-nodes': plugin,
|
||||
'@n8n/community-nodes': plugin,
|
||||
},
|
||||
rules: {
|
||||
'@n8n/eslint-plugin-community-nodes/credential-password-field': 'error',
|
||||
'@n8n/eslint-plugin-community-nodes/no-deprecated-workflow-functions': 'error',
|
||||
'@n8n/eslint-plugin-community-nodes/node-usable-as-tool': 'error',
|
||||
'@n8n/eslint-plugin-community-nodes/package-name-convention': 'error',
|
||||
'@n8n/eslint-plugin-community-nodes/credential-test-required': 'error',
|
||||
'@n8n/eslint-plugin-community-nodes/no-credential-reuse': 'error',
|
||||
'@n8n/eslint-plugin-community-nodes/icon-validation': 'error',
|
||||
'@n8n/eslint-plugin-community-nodes/resource-operation-pattern': 'warn',
|
||||
'@n8n/community-nodes/credential-password-field': 'error',
|
||||
'@n8n/community-nodes/no-deprecated-workflow-functions': 'error',
|
||||
'@n8n/community-nodes/node-usable-as-tool': 'error',
|
||||
'@n8n/community-nodes/package-name-convention': 'error',
|
||||
'@n8n/community-nodes/credential-test-required': 'error',
|
||||
'@n8n/community-nodes/no-credential-reuse': 'error',
|
||||
'@n8n/community-nodes/icon-validation': 'error',
|
||||
'@n8n/community-nodes/credential-documentation-url': 'error',
|
||||
'@n8n/community-nodes/resource-operation-pattern': 'warn',
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, Linter.Config>;
|
||||
|
||||
export const n8nCommunityNodesPlugin = { ...plugin, configs } satisfies ESLint.Plugin;
|
||||
export default n8nCommunityNodesPlugin;
|
||||
const pluginWithConfigs = { ...plugin, configs } satisfies ESLint.Plugin;
|
||||
|
||||
const n8nCommunityNodesPlugin = pluginWithConfigs;
|
||||
export default pluginWithConfigs;
|
||||
export { rules, configs, n8nCommunityNodesPlugin };
|
||||
|
||||
@ -0,0 +1,306 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { CredentialDocumentationUrlRule } from './credential-documentation-url.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
function createCredentialCode(documentationUrl: string): string {
|
||||
return `
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class TestCredential implements ICredentialType {
|
||||
name = 'testApi';
|
||||
displayName = 'Test API';
|
||||
documentationUrl = '${documentationUrl}';
|
||||
|
||||
properties: INodeProperties[] = [];
|
||||
}`;
|
||||
}
|
||||
|
||||
function createCredentialWithoutDocUrl(): string {
|
||||
return `
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class TestCredential implements ICredentialType {
|
||||
name = 'testApi';
|
||||
displayName = 'Test API';
|
||||
|
||||
properties: INodeProperties[] = [];
|
||||
}`;
|
||||
}
|
||||
|
||||
function createRegularClass(): string {
|
||||
return `
|
||||
export class RegularClass {
|
||||
documentationUrl = 'invalid-url';
|
||||
}`;
|
||||
}
|
||||
|
||||
ruleTester.run('credential-documentation-url', CredentialDocumentationUrlRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'valid URL with default options (URLs only)',
|
||||
code: createCredentialCode('https://example.com/docs'),
|
||||
},
|
||||
{
|
||||
name: 'valid URL with explicit options',
|
||||
code: createCredentialCode('https://example.com/docs'),
|
||||
options: [{ allowUrls: true, allowSlugs: false }],
|
||||
},
|
||||
{
|
||||
name: 'valid lowercase slug when slugs are allowed',
|
||||
code: createCredentialCode('myservice'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
},
|
||||
{
|
||||
name: 'valid lowercase slug with slashes when slugs are allowed',
|
||||
code: createCredentialCode('myservice/advanced/config'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
},
|
||||
{
|
||||
name: 'valid URL when both URLs and slugs are allowed',
|
||||
code: createCredentialCode('https://example.com/docs'),
|
||||
options: [{ allowUrls: true, allowSlugs: true }],
|
||||
},
|
||||
{
|
||||
name: 'valid lowercase slug when both URLs and slugs are allowed',
|
||||
code: createCredentialCode('myservice/config'),
|
||||
options: [{ allowUrls: true, allowSlugs: true }],
|
||||
},
|
||||
{
|
||||
name: 'credential without documentationUrl should not trigger',
|
||||
code: createCredentialWithoutDocUrl(),
|
||||
},
|
||||
{
|
||||
name: 'class not implementing ICredentialType should be ignored',
|
||||
code: createRegularClass(),
|
||||
},
|
||||
{
|
||||
name: 'valid lowercase slug with multiple segments',
|
||||
code: createCredentialCode('myservice/somefeature/advancedconfig'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
},
|
||||
{
|
||||
name: 'valid lowercase alphanumeric slug',
|
||||
code: createCredentialCode('myservice123'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
},
|
||||
{
|
||||
name: 'valid lowercase alphanumeric slug with slashes',
|
||||
code: createCredentialCode('myservice123/config456'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'invalid URL with default options',
|
||||
code: createCredentialCode('invalid-url'),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'invalid-url',
|
||||
expectedFormats: 'a valid URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'slug not allowed with default options',
|
||||
code: createCredentialCode('myservice'),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'myservice',
|
||||
expectedFormats: 'a valid URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'slug with special characters should not be autofixable',
|
||||
code: createCredentialCode('My-Service'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'My-Service',
|
||||
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'uppercase slug should be autofixable',
|
||||
code: createCredentialCode('MyService'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
output: createCredentialCode('myservice'),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'MyService',
|
||||
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'invalid URL when only URLs are allowed',
|
||||
code: createCredentialCode('not-a-valid-url'),
|
||||
options: [{ allowUrls: true, allowSlugs: false }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'not-a-valid-url',
|
||||
expectedFormats: 'a valid URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'invalid when neither URLs nor slugs are allowed',
|
||||
code: createCredentialCode('https://example.com'),
|
||||
options: [{ allowUrls: false, allowSlugs: false }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'https://example.com',
|
||||
expectedFormats: 'a valid format (none configured)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'slug with invalid characters (special chars) should not be autofixable',
|
||||
code: createCredentialCode('my@service/config'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'my@service/config',
|
||||
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'slug with uppercase segment should be autofixable',
|
||||
code: createCredentialCode('myService/Config'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
output: createCredentialCode('myservice/config'),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'myService/Config',
|
||||
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'slug with hyphens should not be autofixable',
|
||||
code: createCredentialCode('myservice/advanced-config'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'myservice/advanced-config',
|
||||
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'slug with underscores should not be autofixable',
|
||||
code: createCredentialCode('my_service/config_advanced'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'my_service/config_advanced',
|
||||
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'invalid value when both formats are allowed - shows both in error message',
|
||||
code: createCredentialCode('Invalid-Value!'),
|
||||
options: [{ allowUrls: true, allowSlugs: true }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'Invalid-Value!',
|
||||
expectedFormats: 'a valid URL or a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'empty string should be invalid with default options',
|
||||
code: createCredentialCode(''),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: '',
|
||||
expectedFormats: 'a valid URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'empty string should be invalid when slugs are allowed',
|
||||
code: createCredentialCode(''),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: '',
|
||||
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'mixed case slug with numbers should be autofixable',
|
||||
code: createCredentialCode('MyService123/Config456'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
output: createCredentialCode('myservice123/config456'),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'MyService123/Config456',
|
||||
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'slug starting with number should be invalid and not autofixable',
|
||||
code: createCredentialCode('123service/config'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: '123service/config',
|
||||
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@ -0,0 +1,129 @@
|
||||
import {
|
||||
isCredentialTypeClass,
|
||||
findClassProperty,
|
||||
getStringLiteralValue,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
type RuleOptions = {
|
||||
allowUrls?: boolean;
|
||||
allowSlugs?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS: RuleOptions = {
|
||||
allowUrls: true,
|
||||
allowSlugs: false,
|
||||
};
|
||||
|
||||
function isValidUrl(value: string): boolean {
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isValidSlug(value: string): boolean {
|
||||
// TODO: Remove this special case once these slugs are updated
|
||||
if (
|
||||
['google/service-account', 'google/oauth-single-service', 'google/oauth-generic'].includes(
|
||||
value,
|
||||
)
|
||||
)
|
||||
return true;
|
||||
|
||||
return value.split('/').every((segment) => /^[a-z][a-z0-9]*$/.test(segment));
|
||||
}
|
||||
|
||||
function hasOnlyCaseIssues(value: string): boolean {
|
||||
return value.split('/').every((segment) => /^[a-zA-Z][a-zA-Z0-9]*$/.test(segment));
|
||||
}
|
||||
|
||||
function validateDocumentationUrl(value: string, options: RuleOptions): boolean {
|
||||
return (!!options.allowUrls && isValidUrl(value)) || (!!options.allowSlugs && isValidSlug(value));
|
||||
}
|
||||
|
||||
function getExpectedFormatsMessage(options: RuleOptions): string {
|
||||
const formats = [
|
||||
...(options.allowUrls ? ['a valid URL'] : []),
|
||||
...(options.allowSlugs ? ['a lowercase alphanumeric slug (can contain slashes)'] : []),
|
||||
];
|
||||
|
||||
if (formats.length === 0) return 'a valid format (none configured)';
|
||||
if (formats.length === 1) return formats[0]!;
|
||||
return formats.slice(0, -1).join(', ') + ' or ' + formats[formats.length - 1];
|
||||
}
|
||||
|
||||
export const CredentialDocumentationUrlRule = createRule({
|
||||
name: 'credential-documentation-url',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Enforce valid credential documentationUrl format (URL or lowercase alphanumeric slug)',
|
||||
},
|
||||
messages: {
|
||||
invalidDocumentationUrl: "documentationUrl '{{ value }}' must be {{ expectedFormats }}",
|
||||
},
|
||||
fixable: 'code',
|
||||
schema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
allowUrls: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to allow valid URLs',
|
||||
},
|
||||
allowSlugs: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to allow lowercase alphanumeric slugs with slashes',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
defaultOptions: [DEFAULT_OPTIONS],
|
||||
create(context, [options = {}]) {
|
||||
const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
|
||||
|
||||
return {
|
||||
ClassDeclaration(node) {
|
||||
if (!isCredentialTypeClass(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const documentationUrlProperty = findClassProperty(node, 'documentationUrl');
|
||||
if (!documentationUrlProperty?.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const documentationUrl = getStringLiteralValue(documentationUrlProperty.value);
|
||||
if (documentationUrl === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateDocumentationUrl(documentationUrl, mergedOptions)) {
|
||||
const canAutofix = !!mergedOptions.allowSlugs && hasOnlyCaseIssues(documentationUrl);
|
||||
|
||||
context.report({
|
||||
node: documentationUrlProperty.value,
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: documentationUrl,
|
||||
expectedFormats: getExpectedFormatsMessage(mergedOptions),
|
||||
},
|
||||
fix: canAutofix
|
||||
? (fixer) =>
|
||||
fixer.replaceText(
|
||||
documentationUrlProperty.value!,
|
||||
`'${documentationUrl.toLowerCase()}'`,
|
||||
)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -1,4 +1,5 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { CredentialPasswordFieldRule } from './credential-password-field.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import { ESLintUtils } from '@typescript-eslint/utils';
|
||||
import { TSESTree } from '@typescript-eslint/types';
|
||||
import type { ReportFixFunction } from '@typescript-eslint/utils/ts-eslint';
|
||||
|
||||
import {
|
||||
isCredentialTypeClass,
|
||||
findClassProperty,
|
||||
findObjectProperty,
|
||||
getStringLiteralValue,
|
||||
getBooleanLiteralValue,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
const SENSITIVE_PATTERNS = [
|
||||
@ -31,10 +34,10 @@ function isSensitiveFieldName(name: string): boolean {
|
||||
return SENSITIVE_PATTERNS.some((pattern) => lowerName.includes(pattern));
|
||||
}
|
||||
|
||||
function hasPasswordTypeOption(element: any): boolean {
|
||||
function hasPasswordTypeOption(element: TSESTree.ObjectExpression): boolean {
|
||||
const typeOptionsProperty = findObjectProperty(element, 'typeOptions');
|
||||
|
||||
if (typeOptionsProperty?.value?.type !== 'ObjectExpression') {
|
||||
if (typeOptionsProperty?.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -44,31 +47,43 @@ function hasPasswordTypeOption(element: any): boolean {
|
||||
return passwordValue === true;
|
||||
}
|
||||
|
||||
function createPasswordFix(element: any, typeOptionsProperty: any) {
|
||||
return function (fixer: any) {
|
||||
if (typeOptionsProperty?.value?.type === 'ObjectExpression') {
|
||||
function createPasswordFix(
|
||||
element: TSESTree.ObjectExpression,
|
||||
typeOptionsProperty: TSESTree.Property | null,
|
||||
): ReportFixFunction {
|
||||
return (fixer) => {
|
||||
if (typeOptionsProperty?.value.type === TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
const passwordProperty = findObjectProperty(typeOptionsProperty.value, 'password');
|
||||
|
||||
if (passwordProperty) {
|
||||
return fixer.replaceText(passwordProperty.value, 'true');
|
||||
}
|
||||
|
||||
if (typeOptionsProperty.value.properties.length > 0) {
|
||||
const lastProperty =
|
||||
typeOptionsProperty.value.properties[typeOptionsProperty.value.properties.length - 1];
|
||||
return lastProperty ? fixer.insertTextAfter(lastProperty, ', password: true') : null;
|
||||
const objectValue = typeOptionsProperty.value;
|
||||
if (objectValue.properties.length > 0) {
|
||||
const lastProperty = objectValue.properties[objectValue.properties.length - 1];
|
||||
if (lastProperty) {
|
||||
return fixer.insertTextAfter(lastProperty, ', password: true');
|
||||
}
|
||||
} else {
|
||||
const openBrace = typeOptionsProperty.value.range![0] + 1;
|
||||
return fixer.insertTextAfterRange([openBrace, openBrace], ' password: true ');
|
||||
const range = objectValue.range;
|
||||
if (range) {
|
||||
const openBrace = range[0] + 1;
|
||||
return fixer.insertTextAfterRange([openBrace, openBrace], ' password: true ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lastProperty = element.properties[element.properties.length - 1];
|
||||
return fixer.insertTextAfter(lastProperty, ',\n\t\t\ttypeOptions: { password: true }');
|
||||
if (lastProperty) {
|
||||
return fixer.insertTextAfter(lastProperty, ',\n\t\t\ttypeOptions: { password: true }');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
export const CredentialPasswordFieldRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
export const CredentialPasswordFieldRule = createRule({
|
||||
name: 'credential-password-field',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
@ -90,12 +105,15 @@ export const CredentialPasswordFieldRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
}
|
||||
|
||||
const propertiesProperty = findClassProperty(node, 'properties');
|
||||
if (!propertiesProperty?.value || propertiesProperty.value.type !== 'ArrayExpression') {
|
||||
if (
|
||||
!propertiesProperty?.value ||
|
||||
propertiesProperty.value.type !== TSESTree.AST_NODE_TYPES.ArrayExpression
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const element of propertiesProperty.value.elements) {
|
||||
if (element?.type !== 'ObjectExpression') {
|
||||
if (element?.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { CredentialTestRequiredRule } from './credential-test-required.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
@ -20,13 +21,13 @@ function createCredentialCode(options: {
|
||||
} = options;
|
||||
|
||||
const imports = hasTest
|
||||
? `import type { ICredentialTestRequest, ICredentialType, INodeProperties } from 'n8n-workflow';`
|
||||
: `import type { ICredentialType, INodeProperties } from 'n8n-workflow';`;
|
||||
? "import type { ICredentialTestRequest, ICredentialType, INodeProperties } from 'n8n-workflow';"
|
||||
: "import type { ICredentialType, INodeProperties } from 'n8n-workflow';";
|
||||
|
||||
const extendsStr = extendsArray ? `\n\textends = ${JSON.stringify(extendsArray)};` : '';
|
||||
|
||||
const testProperty = hasTest
|
||||
? `\n\n\ttest: ICredentialTestRequest = {\n\t\trequest: {\n\t\t\tbaseURL: 'https://api.example.com',\n\t\t\turl: '/test',\n\t\t},\n\t};`
|
||||
? "\n\n\ttest: ICredentialTestRequest = {\n\t\trequest: {\n\t\t\tbaseURL: 'https://api.example.com',\n\t\t\turl: '/test',\n\t\t},\n\t};"
|
||||
: '';
|
||||
|
||||
return `
|
||||
@ -46,57 +47,6 @@ export class ${className} {
|
||||
}`;
|
||||
}
|
||||
|
||||
function createNodeCode(options: {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
credentials?: Array<string | { name: string; testedBy?: string }>;
|
||||
extendsClass?: string;
|
||||
}): string {
|
||||
const { name = 'myNode', displayName = 'My Node', credentials = [], extendsClass } = options;
|
||||
|
||||
const credentialsArray = credentials
|
||||
.map((cred) => {
|
||||
if (typeof cred === 'string') {
|
||||
return `'${cred}'`;
|
||||
}
|
||||
const testedByStr = cred.testedBy ? `, testedBy: '${cred.testedBy}'` : '';
|
||||
return `{ name: '${cred.name}'${testedByStr} }`;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
const classDeclaration = extendsClass
|
||||
? `export class ${name.charAt(0).toUpperCase() + name.slice(1)} extends ${extendsClass}`
|
||||
: `export class ${name.charAt(0).toUpperCase() + name.slice(1)} implements INodeType`;
|
||||
|
||||
const descriptionProperty = extendsClass
|
||||
? `description = {` // Extending classes might not need full INodeTypeDescription
|
||||
: `description: INodeTypeDescription = {`;
|
||||
|
||||
return `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
${classDeclaration} {
|
||||
${descriptionProperty}
|
||||
displayName: '${displayName}',
|
||||
name: '${name}',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'A test node',
|
||||
defaults: {
|
||||
name: '${displayName}',
|
||||
},
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [${credentialsArray}],
|
||||
properties: [],
|
||||
};
|
||||
|
||||
execute() {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}`;
|
||||
}
|
||||
|
||||
ruleTester.run('credential-test-required', CredentialTestRequiredRule, {
|
||||
valid: [
|
||||
{
|
||||
@ -129,19 +79,96 @@ ruleTester.run('credential-test-required', CredentialTestRequiredRule, {
|
||||
name: 'credential class missing test property and no testedBy in package',
|
||||
filename: 'MyApi.credentials.ts',
|
||||
code: createCredentialCode({}),
|
||||
errors: [{ messageId: 'missingCredentialTest', data: { className: 'MyApi' } }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'missingCredentialTest',
|
||||
data: { className: 'MyApi' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'addTemplate',
|
||||
output: `
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class MyApi implements ICredentialType {
|
||||
name = 'myApi';
|
||||
displayName = 'My API';
|
||||
properties: INodeProperties[] = [];
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '={{$credentials.server}}/test', // Replace with actual endpoint
|
||||
},
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'credential class with extends but not oAuth2Api and no testedBy in package',
|
||||
filename: 'MyApi.credentials.ts',
|
||||
code: createCredentialCode({ extends: ['someOtherApi'] }),
|
||||
errors: [{ messageId: 'missingCredentialTest', data: { className: 'MyApi' } }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'missingCredentialTest',
|
||||
data: { className: 'MyApi' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'addTemplate',
|
||||
output: `
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class MyApi implements ICredentialType {
|
||||
name = 'myApi';
|
||||
extends = ["someOtherApi"];
|
||||
displayName = 'My API';
|
||||
properties: INodeProperties[] = [];
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '={{$credentials.server}}/test', // Replace with actual endpoint
|
||||
},
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'credential class with empty extends array and no testedBy in package',
|
||||
filename: 'MyApi.credentials.ts',
|
||||
code: createCredentialCode({ extends: [] }),
|
||||
errors: [{ messageId: 'missingCredentialTest', data: { className: 'MyApi' } }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'missingCredentialTest',
|
||||
data: { className: 'MyApi' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'addTemplate',
|
||||
output: `
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class MyApi implements ICredentialType {
|
||||
name = 'myApi';
|
||||
extends = [];
|
||||
displayName = 'My API';
|
||||
properties: INodeProperties[] = [];
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '={{$credentials.server}}/test', // Replace with actual endpoint
|
||||
},
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { ESLintUtils } from '@typescript-eslint/utils';
|
||||
import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
import {
|
||||
isCredentialTypeClass,
|
||||
findClassProperty,
|
||||
@ -7,20 +9,23 @@ import {
|
||||
getStringLiteralValue,
|
||||
findPackageJson,
|
||||
areAllCredentialUsagesTestedByNodes,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
export const CredentialTestRequiredRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
export const CredentialTestRequiredRule = createRule({
|
||||
name: 'credential-test-required',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Ensure credentials have a credential test',
|
||||
},
|
||||
messages: {
|
||||
addTemplate: 'Add basic credential test template',
|
||||
missingCredentialTest:
|
||||
'Credential class "{{ className }}" must have a test property or be tested by a node via testedBy',
|
||||
},
|
||||
schema: [],
|
||||
hasSuggestions: true,
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
@ -73,27 +78,68 @@ export const CredentialTestRequiredRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
|
||||
const pkgDir = getPackageDir();
|
||||
if (!pkgDir) {
|
||||
const suggestions: ReportSuggestionArray<'addTemplate' | 'missingCredentialTest'> = [];
|
||||
|
||||
const testProperty = createCredentialTestTemplate();
|
||||
suggestions.push({
|
||||
messageId: 'addTemplate',
|
||||
fix(fixer) {
|
||||
const classBody = node.body.body;
|
||||
const lastProperty = classBody[classBody.length - 1];
|
||||
if (lastProperty) {
|
||||
return fixer.insertTextAfter(lastProperty, `\n\n${testProperty}`);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'missingCredentialTest',
|
||||
data: {
|
||||
className: node.id?.name || 'Unknown',
|
||||
className: node.id?.name ?? 'Unknown',
|
||||
},
|
||||
suggest: suggestions,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const allUsagesTestedByNodes = areAllCredentialUsagesTestedByNodes(credentialName, pkgDir);
|
||||
if (!allUsagesTestedByNodes) {
|
||||
const suggestions: ReportSuggestionArray<'addTemplate' | 'missingCredentialTest'> = [];
|
||||
|
||||
const testProperty = createCredentialTestTemplate();
|
||||
suggestions.push({
|
||||
messageId: 'addTemplate',
|
||||
fix(fixer) {
|
||||
const classBody = node.body.body;
|
||||
const lastProperty = classBody[classBody.length - 1];
|
||||
if (lastProperty) {
|
||||
return fixer.insertTextAfter(lastProperty, `\n\n${testProperty}`);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'missingCredentialTest',
|
||||
data: {
|
||||
className: node.id?.name || 'Unknown',
|
||||
className: node.id?.name ?? 'Unknown',
|
||||
},
|
||||
suggest: suggestions,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function createCredentialTestTemplate(): string {
|
||||
return `\ttest: ICredentialTestRequest = {
|
||||
\t\trequest: {
|
||||
\t\t\tmethod: 'GET',
|
||||
\t\t\turl: '={{$credentials.server}}/test', // Replace with actual endpoint
|
||||
\t\t},
|
||||
\t};`;
|
||||
}
|
||||
|
||||
@ -1,26 +1,51 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
import { IconValidationRule } from './icon-validation.js';
|
||||
import { vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { IconValidationRule } from './icon-validation.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
readdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExistsSync = vi.mocked(fs.existsSync);
|
||||
const mockReaddirSync = vi.mocked(fs.readdirSync);
|
||||
|
||||
const mockSvgFiles = [
|
||||
'TestNode.svg',
|
||||
'ValidIcon.svg',
|
||||
'ValidIcon.dark.svg',
|
||||
'SameIcon.svg',
|
||||
'github.svg',
|
||||
];
|
||||
|
||||
function setupMockFileSystem() {
|
||||
mockExistsSync.mockImplementation((path: fs.PathLike) => {
|
||||
const pathStr = path.toString();
|
||||
return (
|
||||
pathStr.includes('TestNode.svg') ||
|
||||
pathStr.includes('ValidIcon.svg') ||
|
||||
pathStr.includes('ValidIcon.dark.svg') ||
|
||||
pathStr.includes('SameIcon.svg') ||
|
||||
pathStr.includes('NotSvg.png')
|
||||
);
|
||||
|
||||
if (mockSvgFiles.some((file) => pathStr.includes(file)) || pathStr.includes('NotSvg.png')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathStr.endsWith('/tmp/icons') || pathStr.endsWith('/tmp') || pathStr.endsWith('icons')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// @ts-expect-error Typescript does not select the correct overload
|
||||
mockReaddirSync.mockImplementation((path: fs.PathLike): string[] => {
|
||||
const pathStr = path.toString();
|
||||
|
||||
if (pathStr.includes('icons')) {
|
||||
return [...mockSvgFiles, 'NotSvg.png'];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
@ -34,10 +59,10 @@ function createNodeCode(
|
||||
includeTypeImport: boolean = false,
|
||||
): string {
|
||||
const typeImport = includeTypeImport
|
||||
? `import type { INodeType, INodeTypeDescription } from 'n8n-workflow';`
|
||||
: `import type { INodeType } from 'n8n-workflow';`;
|
||||
? "import type { INodeType, INodeTypeDescription } from 'n8n-workflow';"
|
||||
: "import type { INodeType } from 'n8n-workflow';";
|
||||
|
||||
const typeAnnotation = includeTypeImport ? `: INodeTypeDescription` : '';
|
||||
const typeAnnotation = includeTypeImport ? ': INodeTypeDescription' : '';
|
||||
|
||||
let iconProperty = '';
|
||||
if (icon) {
|
||||
@ -151,7 +176,18 @@ ruleTester.run('icon-validation', IconValidationRule, {
|
||||
name: 'node missing icon property in description',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode(undefined, true),
|
||||
errors: [{ messageId: 'missingIcon' }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'missingIcon',
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'addPlaceholder',
|
||||
output:
|
||||
"\nimport type { INodeType, INodeTypeDescription } from 'n8n-workflow';\n\nexport class TestNode implements INodeType {\n\tdescription: INodeTypeDescription = {\n\t\tdisplayName: 'Test Node',\n\t\tname: 'testNode',\n\t\t\n\t\tgroup: ['input'],\n\t\tversion: 1,\n\t\tdescription: 'A test node',\n\t\tdefaults: {\n\t\t\tname: 'Test Node',\n\t\t},\n\t\tinputs: ['main'],\n\t\toutputs: ['main'],\n\t\tproperties: [],\n\t\ticon: \"file:./icon.svg\",\n\t};\n}",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'icon file does not exist in description',
|
||||
@ -175,7 +211,18 @@ ruleTester.run('icon-validation', IconValidationRule, {
|
||||
name: 'credential missing icon property',
|
||||
filename: credentialFilePath,
|
||||
code: createCredentialCode(),
|
||||
errors: [{ messageId: 'missingIcon' }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'missingIcon',
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'addPlaceholder',
|
||||
output:
|
||||
"\nimport type { ICredentialType, INodeProperties } from 'n8n-workflow';\n\nexport class TestCredential implements ICredentialType {\n\tname = 'testApi';\n\tdisplayName = 'Test API';\n\t\n\tproperties: INodeProperties[] = [];\n\n\ticon = \"file:./icon.svg\";\n}",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'credential icon file does not exist',
|
||||
@ -192,5 +239,41 @@ ruleTester.run('icon-validation', IconValidationRule, {
|
||||
}),
|
||||
errors: [{ messageId: 'lightDarkSame', data: { iconPath: 'icons/SameIcon.svg' } }],
|
||||
},
|
||||
{
|
||||
name: 'node icon file does not exist but similar file exists - should suggest similar file',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode('file:icons/github2.svg'),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'iconFileNotFound',
|
||||
data: { iconPath: 'icons/github2.svg' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'similarIcon',
|
||||
data: { suggestedName: 'icons/github.svg' },
|
||||
output: `
|
||||
import type { INodeType } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
icon: "file:icons/github.svg",
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
description: 'A test node',
|
||||
defaults: {
|
||||
name: 'Test Node',
|
||||
},
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
|
||||
import { TSESTree } from '@typescript-eslint/utils';
|
||||
import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
import {
|
||||
isNodeTypeClass,
|
||||
isCredentialTypeClass,
|
||||
@ -7,24 +9,34 @@ import {
|
||||
findObjectProperty,
|
||||
getStringLiteralValue,
|
||||
validateIconPath,
|
||||
findSimilarSvgFiles,
|
||||
isFileType,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
const messages = {
|
||||
iconFileNotFound: 'Icon file "{{ iconPath }}" does not exist',
|
||||
iconNotSvg: 'Icon file "{{ iconPath }}" must be an SVG file (end with .svg)',
|
||||
lightDarkSame: 'Light and dark icons cannot be the same file. Both point to "{{ iconPath }}"',
|
||||
invalidIconPath: 'Icon path "{{ iconPath }}" must use file: protocol and be a string',
|
||||
missingIcon: 'Node/Credential class must have an icon property defined',
|
||||
addPlaceholder: 'Add icon property with placeholder',
|
||||
addFileProtocol: "Add 'file:' protocol to icon path",
|
||||
changeExtension: "Change icon extension to '.svg'",
|
||||
similarIcon: "Use existing icon '{{ suggestedName }}'",
|
||||
} as const;
|
||||
|
||||
export const IconValidationRule = createRule({
|
||||
name: 'icon-validation',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Validate node and credential icon files exist, are SVG format, and light/dark icons are different',
|
||||
},
|
||||
messages: {
|
||||
iconFileNotFound: 'Icon file "{{ iconPath }}" does not exist',
|
||||
iconNotSvg: 'Icon file "{{ iconPath }}" must be an SVG file (end with .svg)',
|
||||
lightDarkSame: 'Light and dark icons cannot be the same file. Both point to "{{ iconPath }}"',
|
||||
invalidIconPath: 'Icon path "{{ iconPath }}" must use file: protocol and be a string',
|
||||
missingIcon: 'Node/Credential class must have an icon property defined',
|
||||
},
|
||||
messages,
|
||||
schema: [],
|
||||
hasSuggestions: true,
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
@ -40,7 +52,7 @@ export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'invalidIconPath',
|
||||
data: { iconPath: iconPath || '' },
|
||||
data: { iconPath: iconPath ?? '' },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@ -49,30 +61,68 @@ export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
const validation = validateIconPath(iconPath, currentDir);
|
||||
|
||||
if (!validation.isFile) {
|
||||
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
|
||||
if (!iconPath.startsWith('file:')) {
|
||||
suggestions.push({
|
||||
messageId: 'addFileProtocol',
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(node, `"file:${iconPath}"`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'invalidIconPath',
|
||||
data: { iconPath },
|
||||
suggest: suggestions,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!validation.isSvg) {
|
||||
const relativePath = iconPath.replace(/^file:/, '');
|
||||
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
|
||||
|
||||
const pathWithoutExt = relativePath.replace(/\.[^/.]+$/, '');
|
||||
const svgPath = `${pathWithoutExt}.svg`;
|
||||
suggestions.push({
|
||||
messageId: 'changeExtension',
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(node, `"file:${svgPath}"`);
|
||||
},
|
||||
});
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'iconNotSvg',
|
||||
data: { iconPath: relativePath },
|
||||
suggest: suggestions,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!validation.exists) {
|
||||
const relativePath = iconPath.replace(/^file:/, '');
|
||||
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
|
||||
|
||||
// Find similar SVG files in the same directory
|
||||
const similarFiles = findSimilarSvgFiles(relativePath, currentDir);
|
||||
for (const similarFile of similarFiles) {
|
||||
suggestions.push({
|
||||
messageId: 'similarIcon',
|
||||
data: { suggestedName: similarFile },
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(node, `"file:${similarFile}"`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'iconFileNotFound',
|
||||
data: { iconPath: relativePath },
|
||||
suggest: suggestions,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@ -81,10 +131,10 @@ export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
};
|
||||
|
||||
const validateIconValue = (iconValue: TSESTree.Node) => {
|
||||
if (iconValue.type === 'Literal') {
|
||||
if (iconValue.type === TSESTree.AST_NODE_TYPES.Literal) {
|
||||
const iconPath = getStringLiteralValue(iconValue);
|
||||
validateIcon(iconPath, iconValue);
|
||||
} else if (iconValue.type === 'ObjectExpression') {
|
||||
} else if (iconValue.type === TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
const lightProperty = findObjectProperty(iconValue, 'light');
|
||||
const darkProperty = findObjectProperty(iconValue, 'dark');
|
||||
|
||||
@ -121,7 +171,7 @@ export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
const descriptionProperty = findClassProperty(node, 'description');
|
||||
if (
|
||||
!descriptionProperty?.value ||
|
||||
descriptionProperty.value.type !== 'ObjectExpression'
|
||||
descriptionProperty.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
@ -130,11 +180,27 @@ export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
return;
|
||||
}
|
||||
|
||||
const iconProperty = findObjectProperty(descriptionProperty.value, 'icon');
|
||||
const descriptionValue = descriptionProperty.value;
|
||||
const iconProperty = findObjectProperty(descriptionValue, 'icon');
|
||||
if (!iconProperty) {
|
||||
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
|
||||
|
||||
suggestions.push({
|
||||
messageId: 'addPlaceholder',
|
||||
fix(fixer) {
|
||||
const lastProperty =
|
||||
descriptionValue.properties[descriptionValue.properties.length - 1];
|
||||
if (lastProperty) {
|
||||
return fixer.insertTextAfter(lastProperty, ',\n\t\ticon: "file:./icon.svg"');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'missingIcon',
|
||||
suggest: suggestions,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -143,9 +209,24 @@ export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
} else if (isCredentialClass) {
|
||||
const iconProperty = findClassProperty(node, 'icon');
|
||||
if (!iconProperty?.value) {
|
||||
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
|
||||
|
||||
suggestions.push({
|
||||
messageId: 'addPlaceholder',
|
||||
fix(fixer) {
|
||||
const classBody = node.body.body;
|
||||
const lastProperty = classBody[classBody.length - 1];
|
||||
if (lastProperty) {
|
||||
return fixer.insertTextAfter(lastProperty, '\n\n\ticon = "file:./icon.svg";');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'missingIcon',
|
||||
suggest: suggestions,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import type { AnyRuleModule } from '@typescript-eslint/utils/ts-eslint';
|
||||
|
||||
import { CredentialDocumentationUrlRule } from './credential-documentation-url.js';
|
||||
import { CredentialPasswordFieldRule } from './credential-password-field.js';
|
||||
import { CredentialTestRequiredRule } from './credential-test-required.js';
|
||||
import { IconValidationRule } from './icon-validation.js';
|
||||
import { NoCredentialReuseRule } from './no-credential-reuse.js';
|
||||
import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js';
|
||||
import { NoRestrictedGlobalsRule } from './no-restricted-globals.js';
|
||||
import { NoRestrictedImportsRule } from './no-restricted-imports.js';
|
||||
import { CredentialPasswordFieldRule } from './credential-password-field.js';
|
||||
import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js';
|
||||
import { NodeUsableAsToolRule } from './node-usable-as-tool.js';
|
||||
import { PackageNameConventionRule } from './package-name-convention.js';
|
||||
import { CredentialTestRequiredRule } from './credential-test-required.js';
|
||||
import { NoCredentialReuseRule } from './no-credential-reuse.js';
|
||||
import { IconValidationRule } from './icon-validation.js';
|
||||
import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
|
||||
|
||||
export const rules = {
|
||||
@ -21,4 +23,5 @@ export const rules = {
|
||||
'no-credential-reuse': NoCredentialReuseRule,
|
||||
'icon-validation': IconValidationRule,
|
||||
'resource-operation-pattern': ResourceOperationPatternRule,
|
||||
'credential-documentation-url': CredentialDocumentationUrlRule,
|
||||
} satisfies Record<string, AnyRuleModule>;
|
||||
|
||||
@ -1,35 +1,27 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
import { NoCredentialReuseRule } from './no-credential-reuse.js';
|
||||
import { vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import { NoCredentialReuseRule } from './no-credential-reuse.js';
|
||||
import * as fileUtils from '../utils/file-utils.js';
|
||||
|
||||
vi.mock('../utils/file-utils.js', async () => {
|
||||
const actual = await vi.importActual('../utils/file-utils.js');
|
||||
return {
|
||||
...actual,
|
||||
readPackageJsonCredentials: vi.fn(),
|
||||
findPackageJson: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockReadPackageJsonCredentials = vi.mocked(fileUtils.readPackageJsonCredentials);
|
||||
const mockFindPackageJson = vi.mocked(fileUtils.findPackageJson);
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
// Mock fs functions
|
||||
vi.mock('node:fs', () => ({
|
||||
readFileSync: vi.fn(),
|
||||
existsSync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockReadFileSync = vi.mocked(fs.readFileSync);
|
||||
const mockExistsSync = vi.mocked(fs.existsSync);
|
||||
|
||||
const nodeFilePath = '/tmp/TestNode.node.ts';
|
||||
|
||||
function createCredentialClass(name: string, displayName: string): string {
|
||||
return `
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class ${name.charAt(0).toUpperCase() + name.slice(1)} implements ICredentialType {
|
||||
name = '${name}';
|
||||
displayName = '${displayName}';
|
||||
properties: INodeProperties[] = [];
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function createNodeCode(
|
||||
credentials: (string | { name: string; required?: boolean })[] = [],
|
||||
credentials: Array<string | { name: string; required?: boolean }> = [],
|
||||
): string {
|
||||
const credentialsArray =
|
||||
credentials.length > 0
|
||||
@ -66,6 +58,45 @@ export class TestNode implements INodeType {
|
||||
}`;
|
||||
}
|
||||
|
||||
// Helper function to create expected outputs with double quotes (matching rule fix behavior)
|
||||
function createExpectedNodeCode(
|
||||
credentials: Array<string | { name: string; required?: boolean }> = [],
|
||||
): string {
|
||||
const credentialsArray =
|
||||
credentials.length > 0
|
||||
? credentials
|
||||
.map((cred) => {
|
||||
if (typeof cred === 'string') {
|
||||
return `"${cred}"`;
|
||||
} else {
|
||||
const required =
|
||||
cred.required !== undefined ? `,\n\t\t\t\trequired: ${cred.required}` : '';
|
||||
return `{\n\t\t\t\tname: "${cred.name}"${required},\n\t\t\t}`;
|
||||
}
|
||||
})
|
||||
.join(',\n\t\t\t')
|
||||
: '';
|
||||
|
||||
const credentialsProperty =
|
||||
credentials.length > 0 ? `credentials: [\n\t\t\t${credentialsArray}\n\t\t],` : '';
|
||||
|
||||
return `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
${credentialsProperty}
|
||||
properties: [],
|
||||
};
|
||||
}`;
|
||||
}
|
||||
|
||||
// Helper function to create non-node class
|
||||
function createNonNodeClass(): string {
|
||||
return `
|
||||
@ -90,41 +121,10 @@ export class NotANode {
|
||||
}
|
||||
|
||||
function setupMockFileSystem() {
|
||||
const packageJson = {
|
||||
name: 'test-package',
|
||||
n8n: {
|
||||
credentials: [
|
||||
'dist/credentials/MyApi.credentials.js',
|
||||
'dist/credentials/AnotherApi.credentials.js',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const myApiCredential = createCredentialClass('myApiCredential', 'My API');
|
||||
const anotherApiCredential = createCredentialClass('anotherApiCredential', 'Another API');
|
||||
|
||||
mockExistsSync.mockImplementation((path: fs.PathLike) => {
|
||||
const pathStr = path.toString();
|
||||
return (
|
||||
pathStr.includes('package.json') ||
|
||||
pathStr.includes('MyApi.credentials.ts') ||
|
||||
pathStr.includes('AnotherApi.credentials.ts')
|
||||
);
|
||||
});
|
||||
|
||||
mockReadFileSync.mockImplementation((path: any): string => {
|
||||
const pathStr = path.toString();
|
||||
if (pathStr.includes('package.json')) {
|
||||
return JSON.stringify(packageJson, null, 2);
|
||||
}
|
||||
if (pathStr.includes('MyApi.credentials.ts')) {
|
||||
return myApiCredential;
|
||||
}
|
||||
if (pathStr.includes('AnotherApi.credentials.ts')) {
|
||||
return anotherApiCredential;
|
||||
}
|
||||
throw new Error(`File not found: ${pathStr}`);
|
||||
});
|
||||
mockFindPackageJson.mockReturnValue('/tmp/package.json');
|
||||
mockReadPackageJsonCredentials.mockReturnValue(
|
||||
new Set(['myApiCredential', 'anotherApiCredential']),
|
||||
);
|
||||
}
|
||||
|
||||
setupMockFileSystem();
|
||||
@ -171,6 +171,18 @@ ruleTester.run('no-credential-reuse', NoCredentialReuseRule, {
|
||||
{
|
||||
messageId: 'credentialNotInPackage',
|
||||
data: { credentialName: 'ExternalApi' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'myApiCredential' },
|
||||
output: createExpectedNodeCode([{ name: 'myApiCredential', required: true }]),
|
||||
},
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'anotherApiCredential' },
|
||||
output: createExpectedNodeCode([{ name: 'anotherApiCredential', required: true }]),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -182,6 +194,18 @@ ruleTester.run('no-credential-reuse', NoCredentialReuseRule, {
|
||||
{
|
||||
messageId: 'credentialNotInPackage',
|
||||
data: { credentialName: 'ExternalApi' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'myApiCredential' },
|
||||
output: createExpectedNodeCode(['myApiCredential']),
|
||||
},
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'anotherApiCredential' },
|
||||
output: createExpectedNodeCode(['anotherApiCredential']),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -197,10 +221,118 @@ ruleTester.run('no-credential-reuse', NoCredentialReuseRule, {
|
||||
{
|
||||
messageId: 'credentialNotInPackage',
|
||||
data: { credentialName: 'ExternalApi' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'myApiCredential' },
|
||||
output: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
'myApiCredential',
|
||||
{
|
||||
name: "myApiCredential",
|
||||
required: true,
|
||||
},
|
||||
'AnotherExternalApi'
|
||||
],
|
||||
properties: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'anotherApiCredential' },
|
||||
output: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
'myApiCredential',
|
||||
{
|
||||
name: "anotherApiCredential",
|
||||
required: true,
|
||||
},
|
||||
'AnotherExternalApi'
|
||||
],
|
||||
properties: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
messageId: 'credentialNotInPackage',
|
||||
data: { credentialName: 'AnotherExternalApi' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'myApiCredential' },
|
||||
output: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
'myApiCredential',
|
||||
{
|
||||
name: 'ExternalApi',
|
||||
required: true,
|
||||
},
|
||||
"myApiCredential"
|
||||
],
|
||||
properties: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'anotherApiCredential' },
|
||||
output: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
'myApiCredential',
|
||||
{
|
||||
name: 'ExternalApi',
|
||||
required: true,
|
||||
},
|
||||
"anotherApiCredential"
|
||||
],
|
||||
properties: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -215,10 +347,126 @@ ruleTester.run('no-credential-reuse', NoCredentialReuseRule, {
|
||||
{
|
||||
messageId: 'credentialNotInPackage',
|
||||
data: { credentialName: 'ExternalApi1' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'myApiCredential' },
|
||||
output: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
{
|
||||
name: "myApiCredential",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'ExternalApi2',
|
||||
required: false,
|
||||
}
|
||||
],
|
||||
properties: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'anotherApiCredential' },
|
||||
output: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
{
|
||||
name: "anotherApiCredential",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'ExternalApi2',
|
||||
required: false,
|
||||
}
|
||||
],
|
||||
properties: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
messageId: 'credentialNotInPackage',
|
||||
data: { credentialName: 'ExternalApi2' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'myApiCredential' },
|
||||
output: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'ExternalApi1',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "myApiCredential",
|
||||
required: false,
|
||||
}
|
||||
],
|
||||
properties: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'anotherApiCredential' },
|
||||
output: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'ExternalApi1',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "anotherApiCredential",
|
||||
required: false,
|
||||
}
|
||||
],
|
||||
properties: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { ESLintUtils } from '@typescript-eslint/utils';
|
||||
import { TSESTree } from '@typescript-eslint/types';
|
||||
import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
|
||||
|
||||
import {
|
||||
isNodeTypeClass,
|
||||
findClassProperty,
|
||||
@ -7,9 +9,12 @@ import {
|
||||
findPackageJson,
|
||||
readPackageJsonCredentials,
|
||||
isFileType,
|
||||
findSimilarStrings,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const NoCredentialReuseRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
export const NoCredentialReuseRule = createRule({
|
||||
name: 'no-credential-reuse',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
@ -17,10 +22,13 @@ export const NoCredentialReuseRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
'Prevent credential re-use security issues by ensuring nodes only reference credentials from the same package',
|
||||
},
|
||||
messages: {
|
||||
didYouMean: "Did you mean '{{ suggestedName }}'?",
|
||||
useAvailable: "Use available credential '{{ suggestedName }}'",
|
||||
credentialNotInPackage:
|
||||
'SECURITY: Node references credential "{{ credentialName }}" which is not defined in this package. This creates a security risk as it attempts to reuse credentials from other packages. Nodes can only use credentials from the same package as listed in package.json n8n.credentials field.',
|
||||
},
|
||||
schema: [],
|
||||
hasSuggestions: true,
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
@ -52,7 +60,10 @@ export const NoCredentialReuseRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
}
|
||||
|
||||
const descriptionProperty = findClassProperty(node, 'description');
|
||||
if (!descriptionProperty?.value || descriptionProperty.value.type !== 'ObjectExpression') {
|
||||
if (
|
||||
!descriptionProperty?.value ||
|
||||
descriptionProperty.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -66,12 +77,41 @@ export const NoCredentialReuseRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
credentialsArray.elements.forEach((element) => {
|
||||
const credentialInfo = extractCredentialNameFromArray(element);
|
||||
if (credentialInfo && !allowedCredentials.has(credentialInfo.name)) {
|
||||
const similarCredentials = findSimilarStrings(credentialInfo.name, allowedCredentials);
|
||||
const suggestions: ReportSuggestionArray<
|
||||
'didYouMean' | 'useAvailable' | 'credentialNotInPackage'
|
||||
> = [];
|
||||
|
||||
for (const similarName of similarCredentials) {
|
||||
suggestions.push({
|
||||
messageId: 'didYouMean',
|
||||
data: { suggestedName: similarName },
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(credentialInfo.node, `"${similarName}"`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (suggestions.length === 0 && allowedCredentials.size > 0) {
|
||||
const availableCredentials = Array.from(allowedCredentials).slice(0, 3);
|
||||
for (const availableName of availableCredentials) {
|
||||
suggestions.push({
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: availableName },
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(credentialInfo.node, `"${availableName}"`);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
context.report({
|
||||
node: credentialInfo.node,
|
||||
messageId: 'credentialNotInPackage',
|
||||
data: {
|
||||
credentialName: credentialInfo.name,
|
||||
},
|
||||
suggest: suggestions,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
@ -60,6 +61,16 @@ const response3 = await this.helpers.requestOAuth2.call(this, 'google', options)
|
||||
{
|
||||
messageId: 'deprecatedRequestFunction',
|
||||
data: { functionName: 'request', replacement: 'httpRequest' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'suggestReplaceFunction',
|
||||
data: { functionName: 'request', replacement: 'httpRequest' },
|
||||
output: `
|
||||
const response1 = await this.helpers.httpRequest('https://example.com/1');
|
||||
const response2 = await this.helpers.requestWithAuthentication.call(this, 'oauth', options);
|
||||
const response3 = await this.helpers.requestOAuth2.call(this, 'google', options);`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
messageId: 'deprecatedRequestFunction',
|
||||
@ -67,10 +78,33 @@ const response3 = await this.helpers.requestOAuth2.call(this, 'google', options)
|
||||
functionName: 'requestWithAuthentication',
|
||||
replacement: 'httpRequestWithAuthentication',
|
||||
},
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'suggestReplaceFunction',
|
||||
data: {
|
||||
functionName: 'requestWithAuthentication',
|
||||
replacement: 'httpRequestWithAuthentication',
|
||||
},
|
||||
output: `
|
||||
const response1 = await this.helpers.request('https://example.com/1');
|
||||
const response2 = await this.helpers.httpRequestWithAuthentication.call(this, 'oauth', options);
|
||||
const response3 = await this.helpers.requestOAuth2.call(this, 'google', options);`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
messageId: 'deprecatedRequestFunction',
|
||||
data: { functionName: 'requestOAuth2', replacement: 'httpRequestWithAuthentication' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'suggestReplaceFunction',
|
||||
data: { functionName: 'requestOAuth2', replacement: 'httpRequestWithAuthentication' },
|
||||
output: `
|
||||
const response1 = await this.helpers.request('https://example.com/1');
|
||||
const response2 = await this.helpers.requestWithAuthentication.call(this, 'oauth', options);
|
||||
const response3 = await this.helpers.httpRequestWithAuthentication.call(this, 'google', options);`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -86,14 +120,50 @@ function makeRequest(options: IRequestOptions): Promise<any> {
|
||||
{
|
||||
messageId: 'deprecatedType',
|
||||
data: { typeName: 'IRequestOptions', replacement: 'IHttpRequestOptions' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'suggestReplaceType',
|
||||
data: { typeName: 'IRequestOptions', replacement: 'IHttpRequestOptions' },
|
||||
output: `
|
||||
import { IHttpRequestOptions } from 'n8n-workflow';
|
||||
|
||||
function makeRequest(options: IRequestOptions): Promise<any> {
|
||||
return this.helpers.request(options);
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
messageId: 'deprecatedType',
|
||||
data: { typeName: 'IRequestOptions', replacement: 'IHttpRequestOptions' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'suggestReplaceType',
|
||||
data: { typeName: 'IRequestOptions', replacement: 'IHttpRequestOptions' },
|
||||
output: `
|
||||
import { IRequestOptions } from 'n8n-workflow';
|
||||
|
||||
function makeRequest(options: IHttpRequestOptions): Promise<any> {
|
||||
return this.helpers.request(options);
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
messageId: 'deprecatedRequestFunction',
|
||||
data: { functionName: 'request', replacement: 'httpRequest' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'suggestReplaceFunction',
|
||||
data: { functionName: 'request', replacement: 'httpRequest' },
|
||||
output: `
|
||||
import { IRequestOptions } from 'n8n-workflow';
|
||||
|
||||
function makeRequest(options: IRequestOptions): Promise<any> {
|
||||
return this.helpers.httpRequest(options);
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
|
||||
import type { TSESTree } from '@typescript-eslint/utils';
|
||||
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
||||
|
||||
import { createRule } from '../utils/index.js';
|
||||
|
||||
const DEPRECATED_FUNCTIONS = {
|
||||
request: 'httpRequest',
|
||||
@ -21,7 +24,8 @@ function isDeprecatedTypeName(name: string): name is keyof typeof DEPRECATED_TYP
|
||||
return name in DEPRECATED_TYPES;
|
||||
}
|
||||
|
||||
export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
export const NoDeprecatedWorkflowFunctionsRule = createRule({
|
||||
name: 'no-deprecated-workflow-functions',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
@ -34,8 +38,11 @@ export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.without
|
||||
deprecatedType: "'{{ typeName }}' is deprecated. Use '{{ replacement }}' instead.",
|
||||
deprecatedWithoutReplacement:
|
||||
"'{{ functionName }}' is deprecated and should be removed or replaced with alternative implementation.",
|
||||
suggestReplaceFunction: "Replace '{{ functionName }}' with '{{ replacement }}'",
|
||||
suggestReplaceType: "Replace '{{ typeName }}' with '{{ replacement }}'",
|
||||
},
|
||||
schema: [],
|
||||
hasSuggestions: true,
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
@ -45,7 +52,10 @@ export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.without
|
||||
ImportDeclaration(node) {
|
||||
if (node.source.value === 'n8n-workflow') {
|
||||
node.specifiers.forEach((specifier) => {
|
||||
if (specifier.type === 'ImportSpecifier' && specifier.imported.type === 'Identifier') {
|
||||
if (
|
||||
specifier.type === AST_NODE_TYPES.ImportSpecifier &&
|
||||
specifier.imported.type === AST_NODE_TYPES.Identifier
|
||||
) {
|
||||
n8nWorkflowTypes.add(specifier.local.name);
|
||||
}
|
||||
});
|
||||
@ -53,7 +63,10 @@ export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.without
|
||||
},
|
||||
|
||||
MemberExpression(node) {
|
||||
if (node.property.type === 'Identifier' && isDeprecatedFunctionName(node.property.name)) {
|
||||
if (
|
||||
node.property.type === AST_NODE_TYPES.Identifier &&
|
||||
isDeprecatedFunctionName(node.property.name)
|
||||
) {
|
||||
if (!isThisHelpersAccess(node)) {
|
||||
return;
|
||||
}
|
||||
@ -74,6 +87,13 @@ export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.without
|
||||
replacement,
|
||||
message: getDeprecationMessage(functionName),
|
||||
},
|
||||
suggest: [
|
||||
{
|
||||
messageId: 'suggestReplaceFunction',
|
||||
data: { functionName, replacement },
|
||||
fix: (fixer) => fixer.replaceText(node.property, replacement),
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
context.report({
|
||||
@ -89,7 +109,7 @@ export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.without
|
||||
|
||||
TSTypeReference(node) {
|
||||
if (
|
||||
node.typeName.type === 'Identifier' &&
|
||||
node.typeName.type === AST_NODE_TYPES.Identifier &&
|
||||
isDeprecatedTypeName(node.typeName.name) &&
|
||||
n8nWorkflowTypes.has(node.typeName.name)
|
||||
) {
|
||||
@ -103,6 +123,13 @@ export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.without
|
||||
typeName,
|
||||
replacement,
|
||||
},
|
||||
suggest: [
|
||||
{
|
||||
messageId: 'suggestReplaceType',
|
||||
data: { typeName, replacement },
|
||||
fix: (fixer) => fixer.replaceText(node.typeName, replacement),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
@ -111,9 +138,9 @@ export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.without
|
||||
// Check if this import is from n8n-workflow by looking at the parent ImportDeclaration
|
||||
const importDeclaration = node.parent;
|
||||
if (
|
||||
importDeclaration?.type === 'ImportDeclaration' &&
|
||||
importDeclaration?.type === AST_NODE_TYPES.ImportDeclaration &&
|
||||
importDeclaration.source.value === 'n8n-workflow' &&
|
||||
node.imported.type === 'Identifier' &&
|
||||
node.imported.type === AST_NODE_TYPES.Identifier &&
|
||||
isDeprecatedTypeName(node.imported.name)
|
||||
) {
|
||||
const typeName = node.imported.name;
|
||||
@ -126,6 +153,13 @@ export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.without
|
||||
typeName,
|
||||
replacement,
|
||||
},
|
||||
suggest: [
|
||||
{
|
||||
messageId: 'suggestReplaceType',
|
||||
data: { typeName, replacement },
|
||||
fix: (fixer) => fixer.replaceText(node.imported, replacement),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
@ -137,11 +171,11 @@ export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.without
|
||||
* Check if the MemberExpression follows the this.helpers.* pattern
|
||||
*/
|
||||
function isThisHelpersAccess(node: TSESTree.MemberExpression): boolean {
|
||||
if (node.object?.type === 'MemberExpression') {
|
||||
if (node.object?.type === AST_NODE_TYPES.MemberExpression) {
|
||||
const outerObject = node.object;
|
||||
return (
|
||||
outerObject.object?.type === 'ThisExpression' &&
|
||||
outerObject.property?.type === 'Identifier' &&
|
||||
outerObject.object?.type === AST_NODE_TYPES.ThisExpression &&
|
||||
outerObject.property?.type === AST_NODE_TYPES.Identifier &&
|
||||
outerObject.property.name === 'helpers'
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { NoRestrictedGlobalsRule } from './no-restricted-globals.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { ESLintUtils } from '@typescript-eslint/utils';
|
||||
import { TSESTree } from '@typescript-eslint/types';
|
||||
import type { TSESLint } from '@typescript-eslint/utils';
|
||||
|
||||
import { createRule } from '../utils/index.js';
|
||||
|
||||
const restrictedGlobals = [
|
||||
'clearInterval',
|
||||
'clearTimeout',
|
||||
@ -15,7 +17,8 @@ const restrictedGlobals = [
|
||||
'__filename',
|
||||
];
|
||||
|
||||
export const NoRestrictedGlobalsRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
export const NoRestrictedGlobalsRule = createRule({
|
||||
name: 'no-restricted-globals',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
@ -33,7 +36,7 @@ export const NoRestrictedGlobalsRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
|
||||
// Skip property access (like console.process - we want process.exit but not obj.process)
|
||||
if (
|
||||
parent?.type === 'MemberExpression' &&
|
||||
parent?.type === TSESTree.AST_NODE_TYPES.MemberExpression &&
|
||||
parent.property === ref.identifier &&
|
||||
!parent.computed
|
||||
) {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { NoRestrictedImportsRule } from './no-restricted-imports.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import { ESLintUtils } from '@typescript-eslint/utils';
|
||||
import { getModulePath, isDirectRequireCall, isRequireMemberCall } from '../utils/index.js';
|
||||
import {
|
||||
getModulePath,
|
||||
isDirectRequireCall,
|
||||
isRequireMemberCall,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
const allowedModules = [
|
||||
'n8n-workflow',
|
||||
@ -22,7 +26,8 @@ const isModuleAllowed = (modulePath: string): boolean => {
|
||||
return allowedModules.includes(moduleName);
|
||||
};
|
||||
|
||||
export const NoRestrictedImportsRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
export const NoRestrictedImportsRule = createRule({
|
||||
name: 'no-restricted-imports',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { NodeUsableAsToolRule } from './node-usable-as-tool.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import { ESLintUtils } from '@typescript-eslint/utils';
|
||||
import { TSESTree } from '@typescript-eslint/types';
|
||||
|
||||
import {
|
||||
isNodeTypeClass,
|
||||
findClassProperty,
|
||||
findObjectProperty,
|
||||
getBooleanLiteralValue,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const NodeUsableAsToolRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
export const NodeUsableAsToolRule = createRule({
|
||||
name: 'node-usable-as-tool',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
@ -33,7 +35,7 @@ export const NodeUsableAsToolRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
}
|
||||
|
||||
const descriptionValue = descriptionProperty.value;
|
||||
if (descriptionValue?.type !== 'ObjectExpression') {
|
||||
if (descriptionValue?.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -44,10 +46,10 @@ export const NodeUsableAsToolRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
node,
|
||||
messageId: 'missingUsableAsTool',
|
||||
fix(fixer) {
|
||||
if (descriptionValue?.type === 'ObjectExpression') {
|
||||
if (descriptionValue?.type === TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
const properties = descriptionValue.properties;
|
||||
if (properties.length === 0) {
|
||||
const openBrace = descriptionValue.range![0] + 1;
|
||||
const openBrace = descriptionValue.range[0] + 1;
|
||||
return fixer.insertTextAfterRange(
|
||||
[openBrace, openBrace],
|
||||
'\n\t\tusableAsTool: true,',
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { PackageNameConventionRule } from './package-name-convention.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
@ -80,33 +81,109 @@ ruleTester.run('package-name-convention', PackageNameConventionRule, {
|
||||
name: 'invalid package name - generic',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "my-package", "version": "1.0.0" }',
|
||||
errors: [{ messageId: 'invalidPackageName', data: { packageName: 'my-package' } }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidPackageName',
|
||||
data: { packageName: 'my-package' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'renameTo',
|
||||
data: { suggestedName: 'n8n-nodes-my-package' },
|
||||
output: '{ "name": "n8n-nodes-my-package", "version": "1.0.0" }',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'invalid package name - missing nodes',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-example", "version": "1.0.0" }',
|
||||
errors: [{ messageId: 'invalidPackageName', data: { packageName: 'n8n-example' } }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidPackageName',
|
||||
data: { packageName: 'n8n-example' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'renameTo',
|
||||
data: { suggestedName: 'n8n-nodes-example' },
|
||||
output: '{ "name": "n8n-nodes-example", "version": "1.0.0" }',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'invalid scoped package name',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "@company/example-nodes", "version": "1.0.0" }',
|
||||
errors: [
|
||||
{ messageId: 'invalidPackageName', data: { packageName: '@company/example-nodes' } },
|
||||
{
|
||||
messageId: 'invalidPackageName',
|
||||
data: { packageName: '@company/example-nodes' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'renameTo',
|
||||
data: { suggestedName: '@company/n8n-nodes-example' },
|
||||
output: '{ "name": "@company/n8n-nodes-example", "version": "1.0.0" }',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'invalid package name - wrong order',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "nodes-n8n-example", "version": "1.0.0" }',
|
||||
errors: [{ messageId: 'invalidPackageName', data: { packageName: 'nodes-n8n-example' } }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidPackageName',
|
||||
data: { packageName: 'nodes-n8n-example' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'renameTo',
|
||||
data: { suggestedName: 'n8n-nodes-example' },
|
||||
output: '{ "name": "n8n-nodes-example", "version": "1.0.0" }',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'empty package name',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "", "version": "1.0.0" }',
|
||||
errors: [{ messageId: 'invalidPackageName', data: { packageName: '' } }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidPackageName',
|
||||
data: { packageName: '' },
|
||||
suggestions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'incomplete package name with missing suffix',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-", "version": "1.0.0" }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidPackageName',
|
||||
data: { packageName: 'n8n-nodes-' },
|
||||
suggestions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'incomplete scoped package name with missing suffix',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "@company/n8n-nodes-", "version": "1.0.0" }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidPackageName',
|
||||
data: { packageName: '@company/n8n-nodes-' },
|
||||
suggestions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@ -1,16 +1,23 @@
|
||||
import { ESLintUtils, TSESTree, AST_NODE_TYPES } from '@typescript-eslint/utils';
|
||||
import type { TSESTree } from '@typescript-eslint/utils';
|
||||
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
||||
import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
|
||||
|
||||
export const PackageNameConventionRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
import { createRule } from '../utils/index.js';
|
||||
|
||||
export const PackageNameConventionRule = createRule({
|
||||
name: 'package-name-convention',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce correct package naming convention for n8n community nodes',
|
||||
},
|
||||
messages: {
|
||||
renameTo: "Rename to '{{suggestedName}}'",
|
||||
invalidPackageName:
|
||||
'Package name "{{ packageName }}" must follow the convention "n8n-nodes-[PACKAGE-NAME]" or "@[AUTHOR]/n8n-nodes-[PACKAGE-NAME]"',
|
||||
},
|
||||
schema: [],
|
||||
hasSuggestions: true,
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
@ -43,12 +50,29 @@ export const PackageNameConventionRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
const packageNameStr = typeof packageName === 'string' ? packageName : null;
|
||||
|
||||
if (!packageNameStr || !isValidPackageName(packageNameStr)) {
|
||||
const suggestions: ReportSuggestionArray<'invalidPackageName' | 'renameTo'> = [];
|
||||
|
||||
// Generate package name suggestions if we have a valid string
|
||||
if (packageNameStr) {
|
||||
const suggestedNames = generatePackageNameSuggestions(packageNameStr);
|
||||
for (const suggestedName of suggestedNames) {
|
||||
suggestions.push({
|
||||
messageId: 'renameTo',
|
||||
data: { suggestedName },
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(nameProperty.value, `"${suggestedName}"`);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
context.report({
|
||||
node: nameProperty,
|
||||
messageId: 'invalidPackageName',
|
||||
data: {
|
||||
packageName: packageNameStr ?? 'undefined',
|
||||
},
|
||||
suggest: suggestions,
|
||||
});
|
||||
}
|
||||
},
|
||||
@ -61,3 +85,23 @@ function isValidPackageName(name: string): boolean {
|
||||
const scoped = /^@.+\/n8n-nodes-.+$/;
|
||||
return unscoped.test(name) || scoped.test(name);
|
||||
}
|
||||
|
||||
function generatePackageNameSuggestions(invalidName: string): string[] {
|
||||
const cleanName = (name: string) => {
|
||||
return name
|
||||
.replace(/^nodes?-?n8n-?/, '')
|
||||
.replace(/^n8n-/, '')
|
||||
.replace(/^nodes?-?/, '')
|
||||
.replace(/^node-/, '')
|
||||
.replace(/-nodes$/, '');
|
||||
};
|
||||
|
||||
if (invalidName.startsWith('@')) {
|
||||
const [scope, packagePart] = invalidName.split('/');
|
||||
const clean = cleanName(packagePart ?? '');
|
||||
return clean ? [`${scope}/n8n-nodes-${clean}`] : [];
|
||||
}
|
||||
|
||||
const clean = cleanName(invalidName);
|
||||
return clean ? [`n8n-nodes-${clean}`] : [];
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
|
||||
import type { TSESTree } from '@typescript-eslint/utils';
|
||||
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
||||
|
||||
import {
|
||||
isNodeTypeClass,
|
||||
findClassProperty,
|
||||
findObjectProperty,
|
||||
getStringLiteralValue,
|
||||
isFileType,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const ResourceOperationPatternRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
export const ResourceOperationPatternRule = createRule({
|
||||
name: 'resource-operation-pattern',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
@ -26,12 +30,15 @@ export const ResourceOperationPatternRule = ESLintUtils.RuleCreator.withoutDocs(
|
||||
}
|
||||
|
||||
const analyzeNodeDescription = (descriptionValue: TSESTree.Expression | null): void => {
|
||||
if (!descriptionValue || descriptionValue.type !== 'ObjectExpression') {
|
||||
if (!descriptionValue || descriptionValue.type !== AST_NODE_TYPES.ObjectExpression) {
|
||||
return;
|
||||
}
|
||||
|
||||
const propertiesProperty = findObjectProperty(descriptionValue, 'properties');
|
||||
if (!propertiesProperty?.value || propertiesProperty.value.type !== 'ArrayExpression') {
|
||||
if (
|
||||
!propertiesProperty?.value ||
|
||||
propertiesProperty.value.type !== AST_NODE_TYPES.ArrayExpression
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -41,7 +48,7 @@ export const ResourceOperationPatternRule = ESLintUtils.RuleCreator.withoutDocs(
|
||||
let operationNode: TSESTree.Node | null = null;
|
||||
|
||||
for (const property of propertiesArray.elements) {
|
||||
if (!property || property.type !== 'ObjectExpression') {
|
||||
if (!property || property.type !== AST_NODE_TYPES.ObjectExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -62,7 +69,7 @@ export const ResourceOperationPatternRule = ESLintUtils.RuleCreator.withoutDocs(
|
||||
if (name === 'operation' && type === 'options') {
|
||||
operationNode = property;
|
||||
const optionsProperty = findObjectProperty(property, 'options');
|
||||
if (optionsProperty?.value?.type === 'ArrayExpression') {
|
||||
if (optionsProperty?.value?.type === AST_NODE_TYPES.ArrayExpression) {
|
||||
operationCount = optionsProperty.value.elements.length;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { TSESTree, AST_NODE_TYPES } from '@typescript-eslint/utils';
|
||||
import type { TSESTree } from '@typescript-eslint/utils';
|
||||
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
||||
import { distance } from 'fastest-levenshtein';
|
||||
|
||||
function implementsInterface(node: TSESTree.ClassDeclaration, interfaceName: string): boolean {
|
||||
return (
|
||||
node.implements?.some(
|
||||
(impl) =>
|
||||
impl.type === 'TSClassImplements' &&
|
||||
impl.expression.type === 'Identifier' &&
|
||||
impl.type === AST_NODE_TYPES.TSClassImplements &&
|
||||
impl.expression.type === AST_NODE_TYPES.Identifier &&
|
||||
impl.expression.name === interfaceName,
|
||||
) ?? false
|
||||
);
|
||||
@ -16,7 +18,7 @@ export function isNodeTypeClass(node: TSESTree.ClassDeclaration): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node.superClass?.type === 'Identifier' && node.superClass.name === 'Node') {
|
||||
if (node.superClass?.type === AST_NODE_TYPES.Identifier && node.superClass.name === 'Node') {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -33,11 +35,11 @@ export function findClassProperty(
|
||||
): TSESTree.PropertyDefinition | null {
|
||||
const property = node.body.body.find(
|
||||
(member) =>
|
||||
member.type === 'PropertyDefinition' &&
|
||||
member.key?.type === 'Identifier' &&
|
||||
member.type === AST_NODE_TYPES.PropertyDefinition &&
|
||||
member.key?.type === AST_NODE_TYPES.Identifier &&
|
||||
member.key.name === propertyName,
|
||||
);
|
||||
return property?.type === 'PropertyDefinition' ? property : null;
|
||||
return property?.type === AST_NODE_TYPES.PropertyDefinition ? property : null;
|
||||
}
|
||||
|
||||
export function findObjectProperty(
|
||||
@ -46,13 +48,15 @@ export function findObjectProperty(
|
||||
): TSESTree.Property | null {
|
||||
const property = obj.properties.find(
|
||||
(prop) =>
|
||||
prop.type === 'Property' && prop.key.type === 'Identifier' && prop.key.name === propertyName,
|
||||
prop.type === AST_NODE_TYPES.Property &&
|
||||
prop.key.type === AST_NODE_TYPES.Identifier &&
|
||||
prop.key.name === propertyName,
|
||||
);
|
||||
return property?.type === 'Property' ? property : null;
|
||||
return property?.type === AST_NODE_TYPES.Property ? property : null;
|
||||
}
|
||||
|
||||
export function getLiteralValue(node: TSESTree.Node | null): string | boolean | number | null {
|
||||
if (node?.type === 'Literal') {
|
||||
if (node?.type === AST_NODE_TYPES.Literal) {
|
||||
return node.value as string | boolean | number | null;
|
||||
}
|
||||
return null;
|
||||
@ -70,7 +74,7 @@ export function getModulePath(node: TSESTree.Node | null): string | null {
|
||||
}
|
||||
|
||||
if (
|
||||
node?.type === 'TemplateLiteral' &&
|
||||
node?.type === AST_NODE_TYPES.TemplateLiteral &&
|
||||
node.expressions.length === 0 &&
|
||||
node.quasis.length === 1
|
||||
) {
|
||||
@ -90,7 +94,7 @@ export function findArrayLiteralProperty(
|
||||
propertyName: string,
|
||||
): TSESTree.ArrayExpression | null {
|
||||
const property = findObjectProperty(obj, propertyName);
|
||||
if (property?.value.type === 'ArrayExpression') {
|
||||
if (property?.value.type === AST_NODE_TYPES.ArrayExpression) {
|
||||
return property.value;
|
||||
}
|
||||
return null;
|
||||
@ -100,11 +104,11 @@ export function hasArrayLiteralValue(
|
||||
node: TSESTree.PropertyDefinition,
|
||||
searchValue: string,
|
||||
): boolean {
|
||||
if (node.value?.type !== 'ArrayExpression') return false;
|
||||
if (node.value?.type !== AST_NODE_TYPES.ArrayExpression) return false;
|
||||
|
||||
return node.value.elements.some(
|
||||
(element) =>
|
||||
element?.type === 'Literal' &&
|
||||
element?.type === AST_NODE_TYPES.Literal &&
|
||||
typeof element.value === 'string' &&
|
||||
element.value === searchValue,
|
||||
);
|
||||
@ -125,14 +129,16 @@ export function isFileType(filename: string, extension: string): boolean {
|
||||
|
||||
export function isDirectRequireCall(node: TSESTree.CallExpression): boolean {
|
||||
return (
|
||||
node.callee.type === 'Identifier' && node.callee.name === 'require' && node.arguments.length > 0
|
||||
node.callee.type === AST_NODE_TYPES.Identifier &&
|
||||
node.callee.name === 'require' &&
|
||||
node.arguments.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function isRequireMemberCall(node: TSESTree.CallExpression): boolean {
|
||||
return (
|
||||
node.callee.type === 'MemberExpression' &&
|
||||
node.callee.object.type === 'Identifier' &&
|
||||
node.callee.type === AST_NODE_TYPES.MemberExpression &&
|
||||
node.callee.object.type === AST_NODE_TYPES.Identifier &&
|
||||
node.callee.object.name === 'require' &&
|
||||
node.arguments.length > 0
|
||||
);
|
||||
@ -148,7 +154,7 @@ export function extractCredentialInfoFromArray(
|
||||
return { name: stringValue, node: element };
|
||||
}
|
||||
|
||||
if (element.type === 'ObjectExpression') {
|
||||
if (element.type === AST_NODE_TYPES.ObjectExpression) {
|
||||
const nameProperty = findObjectProperty(element, 'name');
|
||||
const testedByProperty = findObjectProperty(element, 'testedBy');
|
||||
|
||||
@ -161,7 +167,7 @@ export function extractCredentialInfoFromArray(
|
||||
if (nameValue) {
|
||||
return {
|
||||
name: nameValue,
|
||||
testedBy: testedByValue || undefined,
|
||||
testedBy: testedByValue ?? undefined,
|
||||
node: nameProperty.value,
|
||||
};
|
||||
}
|
||||
@ -177,3 +183,25 @@ export function extractCredentialNameFromArray(
|
||||
const info = extractCredentialInfoFromArray(element);
|
||||
return info ? { name: info.name, node: info.node } : null;
|
||||
}
|
||||
|
||||
export function findSimilarStrings(
|
||||
target: string,
|
||||
candidates: Set<string>,
|
||||
maxDistance: number = 3,
|
||||
maxResults: number = 3,
|
||||
): string[] {
|
||||
const matches: Array<{ name: string; distance: number }> = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const levenshteinDistance = distance(target.toLowerCase(), candidate.toLowerCase());
|
||||
|
||||
if (levenshteinDistance <= maxDistance) {
|
||||
matches.push({ name: candidate, distance: levenshteinDistance });
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
.slice(0, maxResults)
|
||||
.map((match) => match.name);
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import type { TSESTree } from '@typescript-eslint/typescript-estree';
|
||||
import { parse, simpleTraverse, AST_NODE_TYPES } from '@typescript-eslint/typescript-estree';
|
||||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { dirname, parse as parsePath } from 'node:path';
|
||||
import { parse, simpleTraverse, TSESTree } from '@typescript-eslint/typescript-estree';
|
||||
|
||||
import {
|
||||
isCredentialTypeClass,
|
||||
isNodeTypeClass,
|
||||
@ -9,6 +11,7 @@ import {
|
||||
getStringLiteralValue,
|
||||
findArrayLiteralProperty,
|
||||
extractCredentialInfoFromArray,
|
||||
findSimilarStrings,
|
||||
} from './ast-utils.js';
|
||||
|
||||
/**
|
||||
@ -46,11 +49,11 @@ export function safeJoinPath(parentPath: string, ...paths: string[]): string {
|
||||
}
|
||||
|
||||
export function findPackageJson(startPath: string): string | null {
|
||||
let currentDir = startPath;
|
||||
let currentDir = path.dirname(startPath);
|
||||
|
||||
while (parsePath(currentDir).dir !== parsePath(currentDir).root) {
|
||||
const testPath = safeJoinPath(currentDir, 'package.json');
|
||||
if (existsSync(testPath)) {
|
||||
if (fileExistsWithCaseSync(testPath)) {
|
||||
return testPath;
|
||||
}
|
||||
|
||||
@ -60,10 +63,24 @@ export function findPackageJson(startPath: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function readPackageJsonN8n(packageJsonPath: string): any {
|
||||
interface PackageJsonN8n {
|
||||
credentials?: string[];
|
||||
nodes?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function isValidPackageJson(obj: unknown): obj is { n8n?: PackageJsonN8n } {
|
||||
return typeof obj === 'object' && obj !== null;
|
||||
}
|
||||
|
||||
function readPackageJsonN8n(packageJsonPath: string): PackageJsonN8n {
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
||||
return packageJson.n8n || {};
|
||||
const content = readFileSync(packageJsonPath, 'utf8');
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (isValidPackageJson(parsed)) {
|
||||
return parsed.n8n ?? {};
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
@ -87,7 +104,7 @@ function resolveN8nFilePaths(packageJsonPath: string, filePaths: string[]): stri
|
||||
|
||||
export function readPackageJsonCredentials(packageJsonPath: string): Set<string> {
|
||||
const n8nConfig = readPackageJsonN8n(packageJsonPath);
|
||||
const credentialPaths = n8nConfig.credentials || [];
|
||||
const credentialPaths = n8nConfig.credentials ?? [];
|
||||
const credentialFiles = resolveN8nFilePaths(packageJsonPath, credentialPaths);
|
||||
const credentialNames: string[] = [];
|
||||
|
||||
@ -117,7 +134,7 @@ export function extractCredentialNameFromFile(credentialFilePath: string): strin
|
||||
|
||||
simpleTraverse(ast, {
|
||||
enter(node: TSESTree.Node) {
|
||||
if (node.type === 'ClassDeclaration' && isCredentialTypeClass(node)) {
|
||||
if (node.type === AST_NODE_TYPES.ClassDeclaration && isCredentialTypeClass(node)) {
|
||||
const nameProperty = findClassProperty(node, 'name');
|
||||
if (nameProperty) {
|
||||
const nameValue = getStringLiteralValue(nameProperty.value);
|
||||
@ -149,7 +166,7 @@ export function validateIconPath(
|
||||
const isSvg = relativePath.endsWith('.svg');
|
||||
// Should not use safeJoinPath here because iconPath can be outside of the node class folder
|
||||
const fullPath = path.join(baseDir, relativePath);
|
||||
const exists = existsSync(fullPath);
|
||||
const exists = fileExistsWithCaseSync(fullPath);
|
||||
|
||||
return {
|
||||
isValid: isFile && isSvg && exists,
|
||||
@ -161,7 +178,7 @@ export function validateIconPath(
|
||||
|
||||
export function readPackageJsonNodes(packageJsonPath: string): string[] {
|
||||
const n8nConfig = readPackageJsonN8n(packageJsonPath);
|
||||
const nodePaths = n8nConfig.nodes || [];
|
||||
const nodePaths = n8nConfig.nodes ?? [];
|
||||
return resolveN8nFilePaths(packageJsonPath, nodePaths);
|
||||
}
|
||||
|
||||
@ -203,11 +220,11 @@ function checkCredentialUsageInFile(
|
||||
|
||||
simpleTraverse(ast, {
|
||||
enter(node: TSESTree.Node) {
|
||||
if (node.type === 'ClassDeclaration' && isNodeTypeClass(node)) {
|
||||
if (node.type === AST_NODE_TYPES.ClassDeclaration && isNodeTypeClass(node)) {
|
||||
const descriptionProperty = findClassProperty(node, 'description');
|
||||
if (
|
||||
!descriptionProperty?.value ||
|
||||
descriptionProperty.value.type !== 'ObjectExpression'
|
||||
descriptionProperty.value.type !== AST_NODE_TYPES.ObjectExpression
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@ -238,3 +255,40 @@ function checkCredentialUsageInFile(
|
||||
return { hasUsage: false, allTestedBy: true };
|
||||
}
|
||||
}
|
||||
|
||||
function fileExistsWithCaseSync(filePath: string): boolean {
|
||||
try {
|
||||
const dir = path.dirname(filePath);
|
||||
const file = path.basename(filePath);
|
||||
const files = new Set(readdirSync(dir));
|
||||
|
||||
return files.has(file);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function findSimilarSvgFiles(targetPath: string, baseDir: string): string[] {
|
||||
try {
|
||||
const targetFileName = path.basename(targetPath, path.extname(targetPath));
|
||||
const targetDir = path.dirname(targetPath);
|
||||
// Should not use safeJoinPath here because iconPath can be outside of the node class folder
|
||||
const searchDir = path.join(baseDir, targetDir);
|
||||
|
||||
if (!existsSync(searchDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = readdirSync(searchDir);
|
||||
const svgFileNames = files
|
||||
.filter((file) => file.endsWith('.svg'))
|
||||
.map((file) => path.basename(file, '.svg'));
|
||||
|
||||
const candidateNames = new Set(svgFileNames);
|
||||
const similarNames = findSimilarStrings(targetFileName, candidateNames);
|
||||
|
||||
return similarNames.map((name) => path.join(targetDir, `${name}.svg`));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from './ast-utils.js';
|
||||
export * from './file-utils.js';
|
||||
export * from './rule-creator.js';
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
import { ESLintUtils } from '@typescript-eslint/utils';
|
||||
|
||||
const REPO_URL = 'https://github.com/n8n-io/n8n';
|
||||
const DOCS_PATH = 'blob/master/packages/@n8n/eslint-plugin-community-nodes/docs/rules';
|
||||
|
||||
export const createRule = ESLintUtils.RuleCreator((name) => `${REPO_URL}/${DOCS_PATH}/${name}.md`);
|
||||
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
@ -6,6 +6,5 @@
|
||||
"outDir": "dist",
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["**/*.test.ts"]
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ export class AzureEntraCognitiveServicesOAuth2Api implements ICredentialType {
|
||||
|
||||
extends = ['oAuth2Api'];
|
||||
|
||||
documentationUrl = 'azureEntraCognitiveServicesOAuth2Api';
|
||||
documentationUrl = 'azureentracognitiveservicesoauth2api';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import { nodeConfig } from '@n8n/eslint-config/node';
|
||||
import nodesBasePlugin from 'eslint-plugin-n8n-nodes-base';
|
||||
import { n8nCommunityNodesPlugin } from '@n8n/eslint-plugin-community-nodes';
|
||||
|
||||
export default defineConfig(
|
||||
nodeConfig,
|
||||
{
|
||||
plugins: {
|
||||
'@n8n/community-nodes': n8nCommunityNodesPlugin,
|
||||
},
|
||||
rules: {
|
||||
// TODO: remove all the following rules
|
||||
eqeqeq: 'warn',
|
||||
@ -21,6 +25,8 @@ export default defineConfig(
|
||||
|
||||
'n8n-local-rules/no-argument-spread': 'warn', // TODO: mark error
|
||||
|
||||
'@n8n/community-nodes/credential-documentation-url': ['error', { allowSlugs: true }],
|
||||
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
|
||||
'@typescript-eslint/naming-convention': ['error', { selector: 'memberLike', format: null }],
|
||||
'@typescript-eslint/no-explicit-any': 'warn', //812 warnings, better to fix in separate PR
|
||||
|
||||
@ -151,6 +151,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@n8n/eslint-plugin-community-nodes": "workspace:*",
|
||||
"@types/basic-auth": "catalog:",
|
||||
"@types/cheerio": "^0.22.15",
|
||||
"@types/html-to-text": "^9.0.1",
|
||||
|
||||
@ -11,7 +11,7 @@ export class ActionNetworkApi implements ICredentialType {
|
||||
|
||||
displayName = 'Action Network API';
|
||||
|
||||
documentationUrl = 'actionNetwork';
|
||||
documentationUrl = 'actionnetwork';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -10,7 +10,7 @@ export class ActiveCampaignApi implements ICredentialType {
|
||||
|
||||
displayName = 'ActiveCampaign API';
|
||||
|
||||
documentationUrl = 'activeCampaign';
|
||||
documentationUrl = 'activecampaign';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -5,7 +5,7 @@ export class AcuitySchedulingApi implements ICredentialType {
|
||||
|
||||
displayName = 'Acuity Scheduling API';
|
||||
|
||||
documentationUrl = 'acuityScheduling';
|
||||
documentationUrl = 'acuityscheduling';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -7,7 +7,7 @@ export class AcuitySchedulingOAuth2Api implements ICredentialType {
|
||||
|
||||
displayName = 'AcuityScheduling OAuth2 API';
|
||||
|
||||
documentationUrl = 'acuityScheduling';
|
||||
documentationUrl = 'acuityscheduling';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -5,7 +5,7 @@ export class AgileCrmApi implements ICredentialType {
|
||||
|
||||
displayName = 'AgileCRM API';
|
||||
|
||||
documentationUrl = 'agileCrm';
|
||||
documentationUrl = 'agilecrm';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -10,7 +10,7 @@ export class ApiTemplateIoApi implements ICredentialType {
|
||||
|
||||
displayName = 'APITemplate.io API';
|
||||
|
||||
documentationUrl = 'apiTemplateIo';
|
||||
documentationUrl = 'apitemplateio';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -5,7 +5,7 @@ export class BambooHrApi implements ICredentialType {
|
||||
|
||||
displayName = 'BambooHR API';
|
||||
|
||||
documentationUrl = 'bambooHr';
|
||||
documentationUrl = 'bamboohr';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -5,7 +5,7 @@ export class CircleCiApi implements ICredentialType {
|
||||
|
||||
displayName = 'CircleCI API';
|
||||
|
||||
documentationUrl = 'circleCi';
|
||||
documentationUrl = 'circleci';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -10,7 +10,7 @@ export class ClickUpApi implements ICredentialType {
|
||||
|
||||
displayName = 'ClickUp API';
|
||||
|
||||
documentationUrl = 'clickUp';
|
||||
documentationUrl = 'clickup';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -7,7 +7,7 @@ export class ClickUpOAuth2Api implements ICredentialType {
|
||||
|
||||
displayName = 'ClickUp OAuth2 API';
|
||||
|
||||
documentationUrl = 'clickUp';
|
||||
documentationUrl = 'clickup';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -5,7 +5,7 @@ export class ConvertKitApi implements ICredentialType {
|
||||
|
||||
displayName = 'ConvertKit API';
|
||||
|
||||
documentationUrl = 'convertKit';
|
||||
documentationUrl = 'convertkit';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -5,7 +5,7 @@ export class CrateDb implements ICredentialType {
|
||||
|
||||
displayName = 'CrateDB';
|
||||
|
||||
documentationUrl = 'crateDb';
|
||||
documentationUrl = 'cratedb';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -11,7 +11,7 @@ export class CustomerIoApi implements ICredentialType {
|
||||
|
||||
displayName = 'Customer.io API';
|
||||
|
||||
documentationUrl = 'customerIo';
|
||||
documentationUrl = 'customerio';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -10,7 +10,7 @@ export class DeepLApi implements ICredentialType {
|
||||
|
||||
displayName = 'DeepL API';
|
||||
|
||||
documentationUrl = 'deepL';
|
||||
documentationUrl = 'deepl';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -11,7 +11,7 @@ export class ElasticSecurityApi implements ICredentialType {
|
||||
|
||||
displayName = 'Elastic Security API';
|
||||
|
||||
documentationUrl = 'elasticSecurity';
|
||||
documentationUrl = 'elasticsecurity';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -5,7 +5,7 @@ export class FileMaker implements ICredentialType {
|
||||
|
||||
displayName = 'FileMaker API';
|
||||
|
||||
documentationUrl = 'fileMaker';
|
||||
documentationUrl = 'filemaker';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -5,7 +5,7 @@ export class FormIoApi implements ICredentialType {
|
||||
|
||||
displayName = 'Form.io API';
|
||||
|
||||
documentationUrl = 'formIoTrigger';
|
||||
documentationUrl = 'formiotrigger';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -5,7 +5,7 @@ export class FormstackApi implements ICredentialType {
|
||||
|
||||
displayName = 'Formstack API';
|
||||
|
||||
documentationUrl = 'formstackTrigger';
|
||||
documentationUrl = 'formstacktrigger';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -9,7 +9,7 @@ export class FormstackOAuth2Api implements ICredentialType {
|
||||
|
||||
displayName = 'Formstack OAuth2 API';
|
||||
|
||||
documentationUrl = 'formstackTrigger';
|
||||
documentationUrl = 'formstacktrigger';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -10,7 +10,7 @@ export class GetResponseApi implements ICredentialType {
|
||||
|
||||
displayName = 'GetResponse API';
|
||||
|
||||
documentationUrl = 'getResponse';
|
||||
documentationUrl = 'getresponse';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -7,7 +7,7 @@ export class GoToWebinarOAuth2Api implements ICredentialType {
|
||||
|
||||
displayName = 'GoToWebinar OAuth2 API';
|
||||
|
||||
documentationUrl = 'goToWebinar';
|
||||
documentationUrl = 'gotowebinar';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -7,7 +7,7 @@ export class HelpScoutOAuth2Api implements ICredentialType {
|
||||
|
||||
displayName = 'HelpScout OAuth2 API';
|
||||
|
||||
documentationUrl = 'helpScout';
|
||||
documentationUrl = 'helpscout';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -10,7 +10,7 @@ export class HighLevelApi implements ICredentialType {
|
||||
|
||||
displayName = 'HighLevel API';
|
||||
|
||||
documentationUrl = 'highLevel';
|
||||
documentationUrl = 'highlevel';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -7,7 +7,7 @@ export class HighLevelOAuth2Api implements ICredentialType {
|
||||
|
||||
displayName = 'HighLevel OAuth2 API';
|
||||
|
||||
documentationUrl = 'highLevel';
|
||||
documentationUrl = 'highlevel';
|
||||
|
||||
icon: Icon = 'file:icons/highLevel.svg';
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ export class HomeAssistantApi implements ICredentialType {
|
||||
|
||||
displayName = 'Home Assistant API';
|
||||
|
||||
documentationUrl = 'homeAssistant';
|
||||
documentationUrl = 'homeassistant';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -5,7 +5,7 @@ export class HttpBasicAuth implements ICredentialType {
|
||||
|
||||
displayName = 'Basic Auth';
|
||||
|
||||
documentationUrl = 'httpRequest';
|
||||
documentationUrl = 'httprequest';
|
||||
|
||||
genericAuth = true;
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ export class HttpBearerAuth implements ICredentialType {
|
||||
|
||||
displayName = 'Bearer Auth';
|
||||
|
||||
documentationUrl = 'httpRequest';
|
||||
documentationUrl = 'httprequest';
|
||||
|
||||
genericAuth = true;
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ export class HttpCustomAuth implements ICredentialType {
|
||||
|
||||
displayName = 'Custom Auth';
|
||||
|
||||
documentationUrl = 'httpRequest';
|
||||
documentationUrl = 'httprequest';
|
||||
|
||||
genericAuth = true;
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ export class HttpDigestAuth implements ICredentialType {
|
||||
|
||||
displayName = 'Digest Auth';
|
||||
|
||||
documentationUrl = 'httpRequest';
|
||||
documentationUrl = 'httprequest';
|
||||
|
||||
genericAuth = true;
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ export class HttpHeaderAuth implements ICredentialType {
|
||||
|
||||
displayName = 'Header Auth';
|
||||
|
||||
documentationUrl = 'httpRequest';
|
||||
documentationUrl = 'httprequest';
|
||||
|
||||
genericAuth = true;
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ export class HttpQueryAuth implements ICredentialType {
|
||||
|
||||
displayName = 'Query Auth';
|
||||
|
||||
documentationUrl = 'httpRequest';
|
||||
documentationUrl = 'httprequest';
|
||||
|
||||
genericAuth = true;
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ export class HttpSslAuth implements ICredentialType {
|
||||
|
||||
displayName = 'SSL Certificates';
|
||||
|
||||
documentationUrl = 'httpRequest';
|
||||
documentationUrl = 'httprequest';
|
||||
|
||||
icon: Icon = 'node:n8n-nodes-base.httpRequest';
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ export class HumanticAiApi implements ICredentialType {
|
||||
|
||||
displayName = 'Humantic AI API';
|
||||
|
||||
documentationUrl = 'humanticAi';
|
||||
documentationUrl = 'humanticai';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -11,7 +11,7 @@ export class InvoiceNinjaApi implements ICredentialType {
|
||||
|
||||
displayName = 'Invoice Ninja API';
|
||||
|
||||
documentationUrl = 'invoiceNinja';
|
||||
documentationUrl = 'invoiceninja';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -5,7 +5,7 @@ export class JotFormApi implements ICredentialType {
|
||||
|
||||
displayName = 'JotForm API';
|
||||
|
||||
documentationUrl = 'jotForm';
|
||||
documentationUrl = 'jotform';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -11,7 +11,7 @@ export class KoBoToolboxApi implements ICredentialType {
|
||||
displayName = 'KoBoToolbox API Token';
|
||||
|
||||
// See https://support.kobotoolbox.org/api.html
|
||||
documentationUrl = 'koBoToolbox';
|
||||
documentationUrl = 'kobotoolbox';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -5,7 +5,7 @@ export class LingvaNexApi implements ICredentialType {
|
||||
|
||||
displayName = 'LingvaNex API';
|
||||
|
||||
documentationUrl = 'lingvaNex';
|
||||
documentationUrl = 'lingvanex';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -9,7 +9,7 @@ export class LinkedInCommunityManagementOAuth2Api implements ICredentialType {
|
||||
|
||||
displayName = 'LinkedIn Community Management OAuth2 API';
|
||||
|
||||
documentationUrl = 'linkedIn';
|
||||
documentationUrl = 'linkedin';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -7,7 +7,7 @@ export class LinkedInOAuth2Api implements ICredentialType {
|
||||
|
||||
displayName = 'LinkedIn OAuth2 API';
|
||||
|
||||
documentationUrl = 'linkedIn';
|
||||
documentationUrl = 'linkedin';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -11,7 +11,7 @@ export class MailerLiteApi implements ICredentialType {
|
||||
|
||||
displayName = 'Mailer Lite API';
|
||||
|
||||
documentationUrl = 'mailerLite';
|
||||
documentationUrl = 'mailerlite';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -5,7 +5,7 @@ export class MessageBirdApi implements ICredentialType {
|
||||
|
||||
displayName = 'MessageBird API';
|
||||
|
||||
documentationUrl = 'messageBird';
|
||||
documentationUrl = 'messagebird';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -5,7 +5,7 @@ export class MicrosoftSql implements ICredentialType {
|
||||
|
||||
displayName = 'Microsoft SQL';
|
||||
|
||||
documentationUrl = 'microsoftSql';
|
||||
documentationUrl = 'microsoftsql';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -10,7 +10,7 @@ export class MondayComApi implements ICredentialType {
|
||||
|
||||
displayName = 'Monday.com API';
|
||||
|
||||
documentationUrl = 'mondayCom';
|
||||
documentationUrl = 'mondaycom';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -5,7 +5,7 @@ export class MongoDb implements ICredentialType {
|
||||
|
||||
displayName = 'MongoDB';
|
||||
|
||||
documentationUrl = 'mongoDb';
|
||||
documentationUrl = 'mongodb';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -5,7 +5,7 @@ export class MonicaCrmApi implements ICredentialType {
|
||||
|
||||
displayName = 'Monica CRM API';
|
||||
|
||||
documentationUrl = 'monicaCrm';
|
||||
documentationUrl = 'monicacrm';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -7,7 +7,7 @@ export class MySql implements ICredentialType {
|
||||
|
||||
displayName = 'MySQL';
|
||||
|
||||
documentationUrl = 'mySql';
|
||||
documentationUrl = 'mysql';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -11,7 +11,7 @@ export class NextCloudApi implements ICredentialType {
|
||||
|
||||
displayName = 'NextCloud API';
|
||||
|
||||
documentationUrl = 'nextCloud';
|
||||
documentationUrl = 'nextcloud';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -7,7 +7,7 @@ export class NextCloudOAuth2Api implements ICredentialType {
|
||||
|
||||
displayName = 'NextCloud OAuth2 API';
|
||||
|
||||
documentationUrl = 'nextCloud';
|
||||
documentationUrl = 'nextcloud';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -5,7 +5,7 @@ export class NocoDb implements ICredentialType {
|
||||
|
||||
displayName = 'NocoDB';
|
||||
|
||||
documentationUrl = 'nocoDb';
|
||||
documentationUrl = 'nocodb';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -10,7 +10,7 @@ export class NocoDbApiToken implements ICredentialType {
|
||||
|
||||
displayName = 'NocoDB API Token';
|
||||
|
||||
documentationUrl = 'nocoDb';
|
||||
documentationUrl = 'nocodb';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
||||
@ -5,7 +5,7 @@ export class OAuth1Api implements ICredentialType {
|
||||
|
||||
displayName = 'OAuth1 API';
|
||||
|
||||
documentationUrl = 'httpRequest';
|
||||
documentationUrl = 'httprequest';
|
||||
|
||||
genericAuth = true;
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ export class OAuth2Api implements ICredentialType {
|
||||
|
||||
displayName = 'OAuth2 API';
|
||||
|
||||
documentationUrl = 'httpRequest';
|
||||
documentationUrl = 'httprequest';
|
||||
|
||||
genericAuth = true;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user