feat: Add docs to @n8n/eslint-plugin-community-nodes (#20266)

This commit is contained in:
Elias Meire 2025-10-13 13:03:30 +02:00 committed by GitHub
parent 00ee0d63eb
commit 6cb36b5194
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
135 changed files with 2598 additions and 380 deletions

View 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 -->

View File

@ -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.

View File

@ -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: '',
},
];
}
```

View File

@ -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',
},
};
}
```

View File

@ -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
},
// ...
};
}
```

View File

@ -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"
]
}
}
```

View File

@ -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])];
}
}
```

View File

@ -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([]);
}
}
```

View File

@ -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.

View File

@ -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: [],
};
}
```

View File

@ -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`

View File

@ -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
],
};
}
```

View File

@ -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',
},
},
]);

View File

@ -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", "☑️"]
]
}
}

View File

@ -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 };

View File

@ -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)',
},
},
],
},
],
});

View File

@ -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,
});
}
},
};
},
});

View File

@ -1,4 +1,5 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { CredentialPasswordFieldRule } from './credential-password-field.js';
const ruleTester = new RuleTester();

View File

@ -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;
}

View File

@ -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
},
};
}`,
},
],
},
],
},
],
});

View File

@ -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};`;
}

View File

@ -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: [],
};
}`,
},
],
},
],
},
],
});

View File

@ -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;
}

View File

@ -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>;

View File

@ -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: [],
};
}`,
},
],
},
],
},

View File

@ -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,
});
}
});

View File

@ -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);
}`,
},
],
},
],
},

View File

@ -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'
);
}

View File

@ -1,4 +1,5 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { NoRestrictedGlobalsRule } from './no-restricted-globals.js';
const ruleTester = new RuleTester();

View File

@ -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
) {

View File

@ -1,4 +1,5 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { NoRestrictedImportsRule } from './no-restricted-imports.js';
const ruleTester = new RuleTester();

View File

@ -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: {

View File

@ -1,4 +1,5 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { NodeUsableAsToolRule } from './node-usable-as-tool.js';
const ruleTester = new RuleTester();

View File

@ -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,',

View File

@ -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: [],
},
],
},
],
});

View File

@ -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}`] : [];
}

View File

@ -1,4 +1,5 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
const ruleTester = new RuleTester();

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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 [];
}
}

View File

@ -1,2 +1,3 @@
export * from './ast-utils.js';
export * from './file-utils.js';
export * from './rule-creator.js';

View File

@ -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`);

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
}

View File

@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts"],
"exclude": []
}

View File

@ -6,6 +6,5 @@
"outDir": "dist",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts"],
"exclude": ["**/*.test.ts"]
"include": ["src/**/*.ts"]
}

View File

@ -10,7 +10,7 @@ export class AzureEntraCognitiveServicesOAuth2Api implements ICredentialType {
extends = ['oAuth2Api'];
documentationUrl = 'azureEntraCognitiveServicesOAuth2Api';
documentationUrl = 'azureentracognitiveservicesoauth2api';
properties: INodeProperties[] = [
{

View File

@ -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

View File

@ -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",

View File

@ -11,7 +11,7 @@ export class ActionNetworkApi implements ICredentialType {
displayName = 'Action Network API';
documentationUrl = 'actionNetwork';
documentationUrl = 'actionnetwork';
properties: INodeProperties[] = [
{

View File

@ -10,7 +10,7 @@ export class ActiveCampaignApi implements ICredentialType {
displayName = 'ActiveCampaign API';
documentationUrl = 'activeCampaign';
documentationUrl = 'activecampaign';
properties: INodeProperties[] = [
{

View File

@ -5,7 +5,7 @@ export class AcuitySchedulingApi implements ICredentialType {
displayName = 'Acuity Scheduling API';
documentationUrl = 'acuityScheduling';
documentationUrl = 'acuityscheduling';
properties: INodeProperties[] = [
{

View File

@ -7,7 +7,7 @@ export class AcuitySchedulingOAuth2Api implements ICredentialType {
displayName = 'AcuityScheduling OAuth2 API';
documentationUrl = 'acuityScheduling';
documentationUrl = 'acuityscheduling';
properties: INodeProperties[] = [
{

View File

@ -5,7 +5,7 @@ export class AgileCrmApi implements ICredentialType {
displayName = 'AgileCRM API';
documentationUrl = 'agileCrm';
documentationUrl = 'agilecrm';
properties: INodeProperties[] = [
{

View File

@ -10,7 +10,7 @@ export class ApiTemplateIoApi implements ICredentialType {
displayName = 'APITemplate.io API';
documentationUrl = 'apiTemplateIo';
documentationUrl = 'apitemplateio';
properties: INodeProperties[] = [
{

View File

@ -5,7 +5,7 @@ export class BambooHrApi implements ICredentialType {
displayName = 'BambooHR API';
documentationUrl = 'bambooHr';
documentationUrl = 'bamboohr';
properties: INodeProperties[] = [
{

View File

@ -5,7 +5,7 @@ export class CircleCiApi implements ICredentialType {
displayName = 'CircleCI API';
documentationUrl = 'circleCi';
documentationUrl = 'circleci';
properties: INodeProperties[] = [
{

View File

@ -10,7 +10,7 @@ export class ClickUpApi implements ICredentialType {
displayName = 'ClickUp API';
documentationUrl = 'clickUp';
documentationUrl = 'clickup';
properties: INodeProperties[] = [
{

View File

@ -7,7 +7,7 @@ export class ClickUpOAuth2Api implements ICredentialType {
displayName = 'ClickUp OAuth2 API';
documentationUrl = 'clickUp';
documentationUrl = 'clickup';
properties: INodeProperties[] = [
{

View File

@ -5,7 +5,7 @@ export class ConvertKitApi implements ICredentialType {
displayName = 'ConvertKit API';
documentationUrl = 'convertKit';
documentationUrl = 'convertkit';
properties: INodeProperties[] = [
{

View File

@ -5,7 +5,7 @@ export class CrateDb implements ICredentialType {
displayName = 'CrateDB';
documentationUrl = 'crateDb';
documentationUrl = 'cratedb';
properties: INodeProperties[] = [
{

View File

@ -11,7 +11,7 @@ export class CustomerIoApi implements ICredentialType {
displayName = 'Customer.io API';
documentationUrl = 'customerIo';
documentationUrl = 'customerio';
properties: INodeProperties[] = [
{

View File

@ -10,7 +10,7 @@ export class DeepLApi implements ICredentialType {
displayName = 'DeepL API';
documentationUrl = 'deepL';
documentationUrl = 'deepl';
properties: INodeProperties[] = [
{

View File

@ -11,7 +11,7 @@ export class ElasticSecurityApi implements ICredentialType {
displayName = 'Elastic Security API';
documentationUrl = 'elasticSecurity';
documentationUrl = 'elasticsecurity';
properties: INodeProperties[] = [
{

View File

@ -5,7 +5,7 @@ export class FileMaker implements ICredentialType {
displayName = 'FileMaker API';
documentationUrl = 'fileMaker';
documentationUrl = 'filemaker';
properties: INodeProperties[] = [
{

View File

@ -5,7 +5,7 @@ export class FormIoApi implements ICredentialType {
displayName = 'Form.io API';
documentationUrl = 'formIoTrigger';
documentationUrl = 'formiotrigger';
properties: INodeProperties[] = [
{

View File

@ -5,7 +5,7 @@ export class FormstackApi implements ICredentialType {
displayName = 'Formstack API';
documentationUrl = 'formstackTrigger';
documentationUrl = 'formstacktrigger';
properties: INodeProperties[] = [
{

View File

@ -9,7 +9,7 @@ export class FormstackOAuth2Api implements ICredentialType {
displayName = 'Formstack OAuth2 API';
documentationUrl = 'formstackTrigger';
documentationUrl = 'formstacktrigger';
properties: INodeProperties[] = [
{

View File

@ -10,7 +10,7 @@ export class GetResponseApi implements ICredentialType {
displayName = 'GetResponse API';
documentationUrl = 'getResponse';
documentationUrl = 'getresponse';
properties: INodeProperties[] = [
{

View File

@ -7,7 +7,7 @@ export class GoToWebinarOAuth2Api implements ICredentialType {
displayName = 'GoToWebinar OAuth2 API';
documentationUrl = 'goToWebinar';
documentationUrl = 'gotowebinar';
properties: INodeProperties[] = [
{

View File

@ -7,7 +7,7 @@ export class HelpScoutOAuth2Api implements ICredentialType {
displayName = 'HelpScout OAuth2 API';
documentationUrl = 'helpScout';
documentationUrl = 'helpscout';
properties: INodeProperties[] = [
{

View File

@ -10,7 +10,7 @@ export class HighLevelApi implements ICredentialType {
displayName = 'HighLevel API';
documentationUrl = 'highLevel';
documentationUrl = 'highlevel';
properties: INodeProperties[] = [
{

View File

@ -7,7 +7,7 @@ export class HighLevelOAuth2Api implements ICredentialType {
displayName = 'HighLevel OAuth2 API';
documentationUrl = 'highLevel';
documentationUrl = 'highlevel';
icon: Icon = 'file:icons/highLevel.svg';

View File

@ -5,7 +5,7 @@ export class HomeAssistantApi implements ICredentialType {
displayName = 'Home Assistant API';
documentationUrl = 'homeAssistant';
documentationUrl = 'homeassistant';
properties: INodeProperties[] = [
{

View File

@ -5,7 +5,7 @@ export class HttpBasicAuth implements ICredentialType {
displayName = 'Basic Auth';
documentationUrl = 'httpRequest';
documentationUrl = 'httprequest';
genericAuth = true;

View File

@ -7,7 +7,7 @@ export class HttpBearerAuth implements ICredentialType {
displayName = 'Bearer Auth';
documentationUrl = 'httpRequest';
documentationUrl = 'httprequest';
genericAuth = true;

View File

@ -7,7 +7,7 @@ export class HttpCustomAuth implements ICredentialType {
displayName = 'Custom Auth';
documentationUrl = 'httpRequest';
documentationUrl = 'httprequest';
genericAuth = true;

View File

@ -5,7 +5,7 @@ export class HttpDigestAuth implements ICredentialType {
displayName = 'Digest Auth';
documentationUrl = 'httpRequest';
documentationUrl = 'httprequest';
genericAuth = true;

View File

@ -5,7 +5,7 @@ export class HttpHeaderAuth implements ICredentialType {
displayName = 'Header Auth';
documentationUrl = 'httpRequest';
documentationUrl = 'httprequest';
genericAuth = true;

View File

@ -5,7 +5,7 @@ export class HttpQueryAuth implements ICredentialType {
displayName = 'Query Auth';
documentationUrl = 'httpRequest';
documentationUrl = 'httprequest';
genericAuth = true;

View File

@ -7,7 +7,7 @@ export class HttpSslAuth implements ICredentialType {
displayName = 'SSL Certificates';
documentationUrl = 'httpRequest';
documentationUrl = 'httprequest';
icon: Icon = 'node:n8n-nodes-base.httpRequest';

View File

@ -5,7 +5,7 @@ export class HumanticAiApi implements ICredentialType {
displayName = 'Humantic AI API';
documentationUrl = 'humanticAi';
documentationUrl = 'humanticai';
properties: INodeProperties[] = [
{

View File

@ -11,7 +11,7 @@ export class InvoiceNinjaApi implements ICredentialType {
displayName = 'Invoice Ninja API';
documentationUrl = 'invoiceNinja';
documentationUrl = 'invoiceninja';
properties: INodeProperties[] = [
{

View File

@ -5,7 +5,7 @@ export class JotFormApi implements ICredentialType {
displayName = 'JotForm API';
documentationUrl = 'jotForm';
documentationUrl = 'jotform';
properties: INodeProperties[] = [
{

View File

@ -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[] = [
{

View File

@ -5,7 +5,7 @@ export class LingvaNexApi implements ICredentialType {
displayName = 'LingvaNex API';
documentationUrl = 'lingvaNex';
documentationUrl = 'lingvanex';
properties: INodeProperties[] = [
{

View File

@ -9,7 +9,7 @@ export class LinkedInCommunityManagementOAuth2Api implements ICredentialType {
displayName = 'LinkedIn Community Management OAuth2 API';
documentationUrl = 'linkedIn';
documentationUrl = 'linkedin';
properties: INodeProperties[] = [
{

View File

@ -7,7 +7,7 @@ export class LinkedInOAuth2Api implements ICredentialType {
displayName = 'LinkedIn OAuth2 API';
documentationUrl = 'linkedIn';
documentationUrl = 'linkedin';
properties: INodeProperties[] = [
{

View File

@ -11,7 +11,7 @@ export class MailerLiteApi implements ICredentialType {
displayName = 'Mailer Lite API';
documentationUrl = 'mailerLite';
documentationUrl = 'mailerlite';
properties: INodeProperties[] = [
{

View File

@ -5,7 +5,7 @@ export class MessageBirdApi implements ICredentialType {
displayName = 'MessageBird API';
documentationUrl = 'messageBird';
documentationUrl = 'messagebird';
properties: INodeProperties[] = [
{

View File

@ -5,7 +5,7 @@ export class MicrosoftSql implements ICredentialType {
displayName = 'Microsoft SQL';
documentationUrl = 'microsoftSql';
documentationUrl = 'microsoftsql';
properties: INodeProperties[] = [
{

View File

@ -10,7 +10,7 @@ export class MondayComApi implements ICredentialType {
displayName = 'Monday.com API';
documentationUrl = 'mondayCom';
documentationUrl = 'mondaycom';
properties: INodeProperties[] = [
{

View File

@ -5,7 +5,7 @@ export class MongoDb implements ICredentialType {
displayName = 'MongoDB';
documentationUrl = 'mongoDb';
documentationUrl = 'mongodb';
properties: INodeProperties[] = [
{

View File

@ -5,7 +5,7 @@ export class MonicaCrmApi implements ICredentialType {
displayName = 'Monica CRM API';
documentationUrl = 'monicaCrm';
documentationUrl = 'monicacrm';
properties: INodeProperties[] = [
{

View File

@ -7,7 +7,7 @@ export class MySql implements ICredentialType {
displayName = 'MySQL';
documentationUrl = 'mySql';
documentationUrl = 'mysql';
properties: INodeProperties[] = [
{

View File

@ -11,7 +11,7 @@ export class NextCloudApi implements ICredentialType {
displayName = 'NextCloud API';
documentationUrl = 'nextCloud';
documentationUrl = 'nextcloud';
properties: INodeProperties[] = [
{

View File

@ -7,7 +7,7 @@ export class NextCloudOAuth2Api implements ICredentialType {
displayName = 'NextCloud OAuth2 API';
documentationUrl = 'nextCloud';
documentationUrl = 'nextcloud';
properties: INodeProperties[] = [
{

View File

@ -5,7 +5,7 @@ export class NocoDb implements ICredentialType {
displayName = 'NocoDB';
documentationUrl = 'nocoDb';
documentationUrl = 'nocodb';
properties: INodeProperties[] = [
{

View File

@ -10,7 +10,7 @@ export class NocoDbApiToken implements ICredentialType {
displayName = 'NocoDB API Token';
documentationUrl = 'nocoDb';
documentationUrl = 'nocodb';
properties: INodeProperties[] = [
{

View File

@ -5,7 +5,7 @@ export class OAuth1Api implements ICredentialType {
displayName = 'OAuth1 API';
documentationUrl = 'httpRequest';
documentationUrl = 'httprequest';
genericAuth = true;

View File

@ -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